Handling Variability and Re-usability
Understanding static and dynamic variability
It is common to see embedded software being used in a variety of configurations that require small changes to the code for each instance. For example, the same application may need to be portable between two different architectures (ARM and x86), or two different platforms with different set of devices available. Maybe the same application is used for two different generations of the product, so it needs to account for absence or presence of new features, or it's used for different projects which may select different components or configurations. All these cases, and many others, require variability in the software in order to ensure its reusability.
In C, variability is usually achieved through macros and function pointers, the former being tied to static variability (variability in different builds) the latter to dynamic variability (variability within the same build decided at run-time).
Ada offers many alternatives for both techniques, which aim at structuring possible variations of the software. When Ada isn't enough, the GNAT compilation system also provides a layer of capabilities, in particular selection of alternate bodies.
If you're familiar with object-oriented programming (OOP) — supported in languages such as C++ and Java —, you might also be interested in knowing that OOP is supported by Ada and can be used to implement variability. This should, however, be used with care, as OOP brings its own set of problems, such as loss of efficiency — dispatching calls can't be inlined and require one level of indirection — or loss of analyzability — the target of a dispatching call isn't known at run time. As a rule of thumb, OOP should be considered only for cases of dynamic variability, where several versions of the same object need to exist concurrently in the same application.
Handling variability & reusability statically
Genericity
One usage of C macros involves the creation of functions that works regardless of the type they're being called upon. For example, a swap macro may look like:
[C]
#include <stdio.h> #include <stdlib.h> #define SWAP(t, a, b) ({\ t tmp = a; \ a = b; \ b = tmp; \ }) int main() { int a = 10; int b = 42; printf("a = %d, b = %d\n", a, b); SWAP (int, a, b); printf("a = %d, b = %d\n", a, b); return 0; }
Ada offers a way to declare this kind of functions as a generic, that is, a function that is written after static arguments, such as a parameter:
[Ada]
with Ada.Text_IO; use Ada.Text_IO; procedure Main is generic type A_Type is private; procedure Swap (Left, Right : in out A_Type); procedure Swap (Left, Right : in out A_Type) is Temp : constant A_Type := Left; begin Left := Right; Right := Temp; end Swap; procedure Swap_I is new Swap (Integer); A : Integer := 10; B : Integer := 42; begin Put_Line ("A = " & Integer'Image (A) & ", B = " & Integer'Image (B)); Swap_I (A, B); Put_Line ("A = " & Integer'Image (A) & ", B = " & Integer'Image (B)); end Main;
There are a few key differences between the C and the Ada version here. In C,
the macro can be used directly and essentially get expanded by the preprocessor
without any kind of checks. In Ada, the generic will first be checked for
internal consistency. It then needs to be explicitly instantiated for a
concrete type. From there, it's exactly as if there was an actual version of
this Swap
function, which is going to be called as any other function.
All rules for parameter modes and control will apply to this instance.
In many respects, an Ada generic is a way to provide a safe specification and implementation of such macros, through both the validation of the generic itself and its usage.
Subprograms aren't the only entities that can me made generic. As a matter of fact, it's much more common to render an entire package generic. In this case the instantiation creates a new version of all the entities present in the generic, including global variables. For example:
[Ada]
generic type T is private; package Gen is type C is tagged record V : T; end record; G : Integer; end Gen;
The above can be instantiated and used the following way:
with Gen; procedure Main is package I1 is new Gen (Integer); package I2 is new Gen (Integer); subtype Str10 is String (1 .. 10); package I3 is new Gen (Str10); begin I1.G := 0; I2.G := 1; I3.G := 2; end Main;
Here, I1.G
, I2.G
and I3.G
are three distinct variables.
So far, we've only looked at generics with one kind of parameter: a so-called private type. There's actually much more that can be described in this section, such as variables, subprograms or package instantiations with certain properties. For example, the following provides a sort algorithm for any kind of structurally compatible array type:
[Ada]
generic type Component is private; type Index is (<>); with function "<" (Left, Right : Component) return Boolean; type Array_Type is array (Index range <>) of Component; procedure Sort (A : in out Array_Type);
The declaration above states that we need a type (Component
), a discrete
type (Index
), a comparison subprogram ("<"
), and an array
definition (Array_Type
). Given these, it's possible to write an
algorithm that can sort any Array_Type
. Note the usage of the with
reserved word in front of the function name: it exists to differentiate between
the generic parameter and the beginning of the generic subprogram.
Here is a non-exhaustive overview of the kind of constraints that can be put on types:
type T is private; -- T is a constrained type, such as Integer
type T (<>) is private; -- T can be an unconstrained type e.g. String
type T is tagged private; -- T is a tagged type
type T is new T2 with private; -- T is an extension of T2
type T is (<>); -- T is a discrete type
type T is range <>; -- T is an integer type
type T is digits <>; -- T is a floating point type
type T is access T2; -- T is an access type to T2
For a more complete list please reference the Generic Formal Types in the Appendix of the Introduction to Ada course.
Simple derivation
Let's take a case where a codebase needs to handle small variations of a given device, or maybe different generations of a device, depending on the platform it's running on. In this example, we're assuming that each platform will lead to a different binary, so the code can statically resolve which set of services are available. However, we want an easy way to implement a new device based on a previous one, saying "this new device is the same as this previous device, with these new services and these changes in existing services".
We can implement such patterns using Ada's simple derivation — as opposed to tagged derivation, which is OOP-related and discussed in a later section.
Let's start from the following example:
[Ada]
package Drivers_1 is type Device_1 is null record; procedure Startup (Device : Device_1); procedure Send (Device : Device_1; Data : Integer); procedure Send_Fast (Device : Device_1; Data : Integer); procedure Receive (Device : Device_1; Data : out Integer); end Drivers_1;package body Drivers_1 is -- NOTE: unimplemented procedures: Startup, Send, Send_Fast -- mock-up implementation: Receive procedure Startup (Device : Device_1) is null; procedure Send (Device : Device_1; Data : Integer) is null; procedure Send_Fast (Device : Device_1; Data : Integer) is null; procedure Receive (Device : Device_1; Data : out Integer) is begin Data := 42; end Receive; end Drivers_1;
In the above example, Device_1
is an empty record type. It may also have
some fields if required, or be a different type such as a scalar. Then the four
procedures Startup
, Send
, Send_Fast
and Receive
are
primitives of this type. A primitive is essentially a subprogram that has a
parameter or return type directly referencing this type and declared in the
same scope. At this stage, there's nothing special with this type: we're using
it as we would use any other type. For example:
[Ada]
with Ada.Text_IO; use Ada.Text_IO; with Drivers_1; use Drivers_1; procedure Main is D : Device_1; I : Integer; begin Startup (D); Send_Fast (D, 999); Receive (D, I); Put_Line (Integer'Image (I)); end Main;
Let's now assume that we need to implement a new generation of device,
Device_2
. This new device works exactly like the first one, except for
the startup code that has to be done differently. We can create a new type that
operates exactly like the previous one, but modifies only the behavior of
Startup
:
[Ada]
with Drivers_1; use Drivers_1; package Drivers_2 is type Device_2 is new Device_1; overriding procedure Startup (Device : Device_2); end Drivers_2;package body Drivers_2 is overriding procedure Startup (Device : Device_2) is null; end Drivers_2;
Here, Device_2
is derived from Device_1
. It contains all the
exact same properties and primitives, in particular, Startup
,
Send
, Send_Fast
and Receive
. However, here, we decided to
change the Startup
function and to provide a different implementation.
We override this function. The main subprogram doesn't change much, except for
the fact that it now relies on a different type:
[Ada]
with Ada.Text_IO; use Ada.Text_IO; with Drivers_2; use Drivers_2; procedure Main is D : Device_2; I : Integer; begin Startup (D); Send_Fast (D, 999); Receive (D, I); Put_Line (Integer'Image (I)); end Main;
We can continue with this approach and introduce a new generation of devices.
This new device doesn't implement the Send_Fast
service so we want
to remove it from the list of available services. Furthermore, for the purpose
of our example, let's assume that the hardware team went back to the
Device_1
way of implementing Startup
. We can write this new
device the following way:
[Ada]
with Drivers_1; use Drivers_1; package Drivers_3 is type Device_3 is new Device_1; overriding procedure Startup (Device : Device_3); procedure Send_Fast (Device : Device_3; Data : Integer) is abstract; end Drivers_3;package body Drivers_3 is overriding procedure Startup (Device : Device_3) is null; end Drivers_3;
The is abstract
definition makes illegal any call to a function, so
calls to Send_Fast
on Device_3
will be flagged as being illegal.
To then implement Startup
of Device_3
as being the same as the
Startup
of Device_1
, we can convert the type in the
implementation:
[Ada]
package body Drivers_3 is overriding procedure Startup (Device : Device_3) is begin Drivers_1.Startup (Device_1 (Device)); end Startup; end Drivers_3;
Our Main
now looks like:
[Ada]
with Ada.Text_IO; use Ada.Text_IO; with Drivers_3; use Drivers_3; procedure Main is D : Device_3; I : Integer; begin Startup (D); Send_Fast (D, 999); Receive (D, I); Put_Line (Integer'Image (I)); end Main;
Here, the call to Send_Fast
will get flagged by the compiler.
Note that the fact that the code of Main
has to be changed for every
implementation isn't necessarily satisfactory. We may want to go one step
further, and isolate the selection of the device kind to be used for the whole
application in one unique file. One way to do this is to use the same name for
all types, and use a renaming to select which package to use. Here's a
simplified example to illustrate that:
[Ada]
package Drivers_1 is type Transceiver is null record; procedure Send (Device : Transceiver; Data : Integer); procedure Receive (Device : Transceiver; Data : out Integer); end Drivers_1;package body Drivers_1 is procedure Send (Device : Transceiver; Data : Integer) is null; procedure Receive (Device : Transceiver; Data : out Integer) is pragma Unreferenced (Device); begin Data := 42; end Receive; end Drivers_1;with Drivers_1; package Drivers_2 is type Transceiver is new Drivers_1.Transceiver; procedure Send (Device : Transceiver; Data : Integer); procedure Receive (Device : Transceiver; Data : out Integer); end Drivers_2;package body Drivers_2 is procedure Send (Device : Transceiver; Data : Integer) is null; procedure Receive (Device : Transceiver; Data : out Integer) is pragma Unreferenced (Device); begin Data := 42; end Receive; end Drivers_2;with Drivers_1; package Drivers renames Drivers_1;with Ada.Text_IO; use Ada.Text_IO; with Drivers; use Drivers; procedure Main is D : Transceiver; I : Integer; begin Send (D, 999); Receive (D, I); Put_Line (Integer'Image (I)); end Main;
In the above example, the whole code can rely on drivers.ads
, instead
of relying on the specific driver. Here, Drivers
is another name for
Driver_1
. In order to switch to Driver_2
, the project only has to
replace that one drivers.ads
file.
In the following section, we'll go one step further and demonstrate that this selection can be done through a configuration switch selected at build time instead of a manual code modification.
Configuration pragma files
Configuration pragmas are a set of pragmas that modify the compilation of source-code files. You may use them to either relax or strengthen requirements. For example:
pragma Suppress (Overflow_Check);
In this example, we're suppressing the overflow check, thereby relaxing a requirement. Normally, the following program would raise a constraint error due to a failed overflow check:
[Ada]
package P is function Add_Max (A : Integer) return Integer; end P;package body P is function Add_Max (A : Integer) return Integer is begin return A + Integer'Last; end Add_Max; end P;with Ada.Text_IO; use Ada.Text_IO; with P; use P; procedure Main is I : Integer := Integer'Last; begin I := Add_Max (I); Put_Line ("I = " & Integer'Image (I)); end Main;
When suppressing the overflow check, however, the program doesn't raise an
exception, and the value that Add_Max
returns is -2
, which is a
wraparound of the sum of the maximum integer values
(Integer'Last + Integer'Last
).
We could also strengthen requirements, as in this example:
pragma Restrictions (No_Floating_Point);
Here, the restriction forbids the use of floating-point types and objects. The following program would violate this restriction, so the compiler isn't able to compile the program when the restriction is used:
procedure Main is
F : Float := 0.0;
-- Declaration is not possible with No_Floating_Point restriction.
begin
null;
end Main;
Restrictions are especially useful for high-integrity applications. In fact, the Ada Reference Manual has a separate section for them.
When creating a project, it is practical to list all configuration pragmas in a
separate file. This is called a configuration pragma file, and it usually has
an .adc file extension. If you use GPRbuild for building Ada
applications, you can specify the configuration pragma file in the
corresponding project file. For example, here we indicate that gnat.adc
is the configuration pragma file for our project:
project Default is
for Source_Dirs use ("src");
for Object_Dir use "obj";
for Main use ("main.adb");
package Compiler is
for Local_Configuration_Pragmas use "gnat.adc";
end Compiler;
end Default;
Configuration packages
In C, preprocessing flags are used to create blocks of code that are only compiled under certain circumstances. For example, we could have a block that is only used for debugging:
[C]
#include <stdio.h> #include <stdlib.h> int func(int x) { return x % 4; } int main() { int a, b; a = 10; b = func(a); #ifdef DEBUG printf("func(%d) => %d\n", a, b); #endif return 0; }
Here, the block indicated by the DEBUG
flag is only included in the build
if we define this preprocessing flag, which is what we expect for a debug
version of the build. In the release version, however, we want to keep debug
information out of the build, so we don't use this flag during the build
process.
Ada doesn't define a preprocessor as part of the language. Some Ada toolchains — like the GNAT toolchain — do have a preprocessor that could create code similar to the one we've just seen. When programming in Ada, however, the recommendation is to use configuration packages to select code blocks that are meant to be included in the application.
When using a configuration package, the example above can be written as:
[Ada]
package Config is Debug : constant Boolean := False; end Config;function Func (X : Integer) return Integer;function Func (X : Integer) return Integer is begin return X mod 4; end Func;with Ada.Text_IO; use Ada.Text_IO; with Config; with Func; procedure Main is A, B : Integer; begin A := 10; B := Func (A); if Config.Debug then Put_Line ("Func(" & Integer'Image (A) & ") => " & Integer'Image (B)); end if; end Main;
In this example, Config
is a configuration package. The version of
Config
we're seeing here is the release version. The debug version of
the Config
package looks like this:
package Config is
Debug : constant Boolean := True;
end Config;
The compiler makes sure to remove dead code. In the case of the release
version, since Config.Debug
is constant and set to False
, the
compiler is smart enough to remove the call to Put_Line
from the build.
As you can see, both versions of Config
are very similar to each other.
The general idea is to create packages that declare the same constants, but
using different values.
In C, we differentiate between the debug and release versions by selecting
the appropriate preprocessing flags, but in Ada, we select the appropriate
configuration package during the build process. Since the file name is usually
the same (config.ads
for the example above), we may want to store them
in distinct directories. For the example above, we could have:
src/debug/config.ads
for the debug version, andsrc/release/config.ads
for the release version.
Then, we simply select the appropriate configuration package for each version
of the build by indicating the correct path to it. When using
GPRbuild, we can select the appropriate directory where the
config.ads
file is located. We can use scenario variables in our
project, which allow for creating different versions of a build. For example:
project Default is
type Mode_Type is ("debug", "release");
Mode : Mode_Type := external ("mode", "debug");
for Source_Dirs use ("src", "src/" & Mode);
for Object_Dir use "obj";
for Main use ("main.adb");
end Default;
In this example, we're defining a scenario type called Mode_Type
. Then,
we're declaring the scenario variable Mode
and using it in the
Source_Dirs
declaration to complete the path to the subdirectory
containing the config.ads
file. The expression "src/" & Mode
concatenates the user-specified mode to select the appropriate subdirectory.
We can then set the mode on the command-line. For example:
gprbuild -P default.gpr -Xmode=release
In addition to selecting code blocks for the build, we could also specify
values that depend on the target build. For our example above, we may want to
create two versions of the application, each one having a different version of
a MOD_VALUE
that is used in the implementation of func()
. In C, we
can achieve this by using preprocessing flags and defining the corresponding
version in APP_VERSION
. Then, depending on the value of APP_VERSION
,
we define the corresponding value of MOD_VALUE
.
[C]
#ifndef APP_VERSION #define APP_VERSION 1 #endif #if APP_VERSION == 1 #define MOD_VALUE 4 #endif #if APP_VERSION == 2 #define MOD_VALUE 5 #endif#include <stdio.h> #include <stdlib.h> #include "defs.h" int func(int x) { return x % MOD_VALUE; } int main() { int a, b; a = 10; b = func(a); return 0; }
If not defined outside, the code above will compile version #1 of the
application. We can change this by specifying a value for APP_VERSION
during the build (e.g. as a Makefile switch).
For the Ada version of this code, we can create two configuration packages for each version of the application. For example:
[Ada]
-- ./src/app_1/app_defs.ads package App_Defs is Mod_Value : constant Integer := 4; end App_Defs;function Func (X : Integer) return Integer;with App_Defs; function Func (X : Integer) return Integer is begin return X mod App_Defs.Mod_Value; end Func;with Func; procedure Main is A, B : Integer; begin A := 10; B := Func (A); end Main;
The code above shows the version #1 of the configuration package. The corresponding implementation for version #2 looks like this:
-- ./src/app_2/app_defs.ads
package App_Defs is
Mod_Value : constant Integer := 5;
end App_Defs;
Again, we just need to select the appropriate configuration package for each version of the build, which we can easily do when using GPRbuild.
Handling variability & reusability dynamically
Records with discriminants
In basic terms, records with discriminants are records that include "parameters" in their type definitions. This allows for adding more flexibility to the type definition. In the section about pointers, we've seen this example:
[Ada]
procedure Main is type Arr is array (Integer range <>) of Integer; type S (Last : Positive) is record A : Arr (0 .. Last); end record; V : S (9); begin null; end Main;
Here, Last
is the discriminant for type S
. When declaring the
variable V
as S (9)
, we specify the actual index of the last
position of the array component A
by setting the Last
discriminant to 9.
We can create an equivalent implementation in C by declaring a struct
with a pointer to an array:
[C]
#include <stdio.h> #include <stdlib.h> typedef struct { int * a; const int last; } S; S init_s (int last) { S v = { malloc (sizeof(int) * last + 1), last }; return v; } int main(int argc, const char * argv[]) { S v = init_s (9); return 0; }
Here, we need to explicitly allocate the a
array of the S
struct
via a call to malloc()
, which allocates memory space on the heap. In the
Ada version, in contrast, the array (V.A
) is allocated on the stack and
we don't need to explicitly allocate it.
Note that the information that we provide as the discriminant to the record type (in the Ada code) is constant, so we cannot assign a value to it. For example, we cannot write:
[Ada]
V.Last := 10; -- COMPILATION ERROR!
In the C version, we declare the last
field constant to get the same
behavior.
[C]
v.last = 10; // COMPILATION ERROR!
Note that the information provided as discriminants is visible. In the example
above, we could display Last
by writing:
[Ada]
Put_Line ("Last : " & Integer'Image (V.Last));
Also note that, even if a type is private, we can still access the information of the discriminants if they are visible in the public part of the type declaration. Let's rewrite the example above:
[Ada]
package Array_Definition is type Arr is array (Integer range <>) of Integer; type S (Last : Integer) is private; private type S (Last : Integer) is record A : Arr (0 .. Last); end record; end Array_Definition;with Ada.Text_IO; use Ada.Text_IO; with Array_Definition; use Array_Definition; procedure Main is V : S (9); begin Put_Line ("Last : " & Integer'Image (V.Last)); end Main;
Even though the S
type is now private, we can still display Last
because this discriminant is visible in the non-private part of package
Array_Definition
.
Variant records
In simple terms, a variant record is a record with discriminants that allows
for changing its structure. Basically, it's a record containing a case
.
This is the general structure:
[Ada]
type Var_Rec (V : F) is record
case V is
when Opt_1 => F1 : Type_1;
when Opt_2 => F2 : Type_2;
end case;
end record;
Let's look at this example:
[Ada]
with Ada.Text_IO; use Ada.Text_IO; procedure Main is type Float_Int (Use_Float : Boolean) is record case Use_Float is when True => F : Float; when False => I : Integer; end case; end record; procedure Display (V : Float_Int) is begin if V.Use_Float then Put_Line ("Float value: " & Float'Image (V.F)); else Put_Line ("Integer value: " & Integer'Image (V.I)); end if; end Display; F : constant Float_Int := (Use_Float => True, F => 10.0); I : constant Float_Int := (Use_Float => False, I => 9); begin Display (F); Display (I); end Main;
Here, we declare F
containing a floating-point value, and I
containing an integer value. In the Display
procedure, we present the
correct information to the user according to the Use_Float
discriminant
of the Float_Int
type.
We can implement this example in C by using unions:
[C]
#include <stdio.h> #include <stdlib.h> typedef struct { int use_float; union { float f; int i; }; } float_int; float_int init_float (float f) { float_int v; v.use_float = 1; v.f = f; return v; } float_int init_int (int i) { float_int v; v.use_float = 0; v.i = i; return v; } void display (float_int v) { if (v.use_float) { printf("Float value : %f\n", v.f); } else { printf("Integer value : %d\n", v.i); } } int main(int argc, const char * argv[]) { float_int f = init_float (10.0); float_int i = init_int (9); display (f); display (i); return 0; }
Similar to the Ada code, we declare f
containing a floating-point value,
and i
containing an integer value. One difference is that we use the
init_float()
and init_int()
functions to initialize the
float_int
struct. These functions initialize the correct field of the
union and set the use_float
field accordingly.
Variant records and unions
There is, however, a difference in accessibility between variant records in Ada and unions in C. In C, we're allowed to access any field of the union regardless of the initialization:
[C]
float_int v = init_float (10.0);
printf("Integer value : %d\n", v.i);
This feature is useful to create overlays. In this specific example, however,
the information displayed to the user doesn't make sense, since the union was
initialized with a floating-point value (v.f
) and, by accessing the
integer field (v.i
), we're displaying it as if it was an integer value.
In Ada, accessing the wrong component would raise an exception at run-time ("discriminant check failed"), since the component is checked before being accessed:
[Ada]
V : constant Float_Int := (Use_Float => True, F => 10.0);
begin
Put_Line ("Integer value: " & Integer'Image (V.I));
-- ^ Constraint_Error is raised!
Using this method prevents wrong information being used in other parts of the program.
To get the same behavior in Ada as we do in C, we need to explicitly use the
Unchecked_Union
aspect in the type declaration. This is the modified
example:
[Ada]
with Ada.Text_IO; use Ada.Text_IO; procedure Main is type Float_Int_Union (Use_Float : Boolean) is record case Use_Float is when True => F : Float; when False => I : Integer; end case; end record with Unchecked_Union; V : constant Float_Int_Union := (Use_Float => True, F => 10.0); begin Put_Line ("Integer value: " & Integer'Image (V.I)); end Main;
Now, we can display the integer component (V.I
) even though we
initialized the floating-point component (V.F
). As expected, the
information displayed by the test application in this case doesn't make sense.
Note that, when using the Unchecked_Union
aspect in the declaration of a
variant record, the reference discriminant is not available anymore, since it
isn't stored as part of the record. Therefore, we cannot access the
Use_Float
discriminant as in the following code:
[Ada]
V : constant Float_Int_Union := (Use_Float => True, F => 10.0);
begin
if V.Use_Float then -- COMPILATION ERROR!
-- Do something...
end if;
Unchecked unions are particularly useful in Ada when creating bindings for C code.
Optional components
We can also use variant records to specify optional components of a record. For example:
[Ada]
with Ada.Text_IO; use Ada.Text_IO; procedure Main is type Arr is array (Integer range <>) of Integer; type Extra_Info is (No, Yes); type S_Var (Last : Integer; Has_Extra_Info : Extra_Info) is record A : Arr (0 .. Last); case Has_Extra_Info is when No => null; when Yes => B : Arr (0 .. Last); end case; end record; V1 : S_Var (Last => 9, Has_Extra_Info => Yes); V2 : S_Var (Last => 9, Has_Extra_Info => No); begin Put_Line ("Size of V1 is: " & Integer'Image (V1'Size)); Put_Line ("Size of V2 is: " & Integer'Image (V2'Size)); end Main;
Here, in the declaration of S_Var
, we don't have any component in case
Has_Extra_Info
is false. The component is simply set to null
in
this case.
When running the example above, we see that the size of V1
is greater
than the size of V2
due to the extra B
component — which is
only included when Has_Extra_Info
is true.
Optional output information
We can use optional components to prevent subprograms from generating invalid information that could be misused by the caller. Consider the following example:
[C]
#include <stdio.h> #include <stdlib.h> float calculate (float f1, float f2, int *success) { if (f1 < f2) { *success = 1; return f2 - f1; } else { *success = 0; return 0.0; } } void display (float v, int success) { if (success) { printf("Value = %f\n", v); } else { printf("Calculation error!\n"); } } int main(int argc, const char * argv[]) { float f; int success; f = calculate (1.0, 0.5, &success); display (f, success); f = calculate (0.5, 1.0, &success); display (f, success); return 0; }
In this code, we're using the output parameter success
of the
calculate()
function to indicate whether the calculation was successful
or not. This approach has a major problem: there's no way to prevent that the
invalid value returned by calculate()
in case of an error is misused in
another computation. For example:
[C]
int main(int argc, const char * argv[])
{
float f;
int success;
f = calculate (1.0, 0.5, &success);
f = f * 0.25; // Using f in another computation even though
// calculate() returned a dummy value due to error!
// We should have evaluated "success", but we didn't.
return 0;
}
We cannot prevent access to the returned value or, at least, force the caller
to evaluate success
before using the returned value.
This is the corresponding code in Ada:
[Ada]
with Ada.Text_IO; use Ada.Text_IO; procedure Main is function Calculate (F1, F2 : Float; Success : out Boolean) return Float is begin if F1 < F2 then Success := True; return F2 - F1; else Success := False; return 0.0; end if; end Calculate; procedure Display (V : Float; Success : Boolean) is begin if Success then Put_Line ("Value = " & Float'Image (V)); else Put_Line ("Calculation error!"); end if; end Display; F : Float; Success : Boolean; begin F := Calculate (1.0, 0.5, Success); Display (F, Success); F := Calculate (0.5, 1.0, Success); Display (F, Success); end Main;
The Ada code above suffers from the same drawbacks as the C code. Again,
there's no way to prevent misuse of the invalid value returned by
Calculate
in case of errors.
However, in Ada, we can use variant records to make the component unavailable and therefore prevent misuse of this information. Let's rewrite the original example and wrap the returned value in a variant record:
[Ada]
with Ada.Text_IO; use Ada.Text_IO; procedure Main is type Opt_Float (Success : Boolean) is record case Success is when False => null; when True => F : Float; end case; end record; function Calculate (F1, F2 : Float) return Opt_Float is begin if F1 < F2 then return (Success => True, F => F2 - F1); else return (Success => False); end if; end Calculate; procedure Display (V : Opt_Float) is begin if V.Success then Put_Line ("Value = " & Float'Image (V.F)); else Put_Line ("Calculation error!"); end if; end Display; begin Display (Calculate (1.0, 0.5)); Display (Calculate (0.5, 1.0)); end Main;
In this example, we can determine whether the calculation was successful or not
by evaluating the Success
component of the Opt_Float
. If the
calculation wasn't successful, we won't be able to access the F
component of the Opt_Float
. As mentioned before, trying to access the
component in this case would raise an exception. Therefore, in case of errors,
we can ensure that no information is misused after the call to
Calculate
.
Object orientation
In the previous section, we've seen that we can add variability to records by using discriminants. Another approach is to use tagged records, which are the base for object-oriented programming in Ada.
Type extension
A tagged record type is declared by adding the tagged
keyword. For
example:
[Ada]
procedure Main is type Rec is record V : Integer; end record; type Tagged_Rec is tagged record V : Integer; end record; R1 : Rec; R2 : Tagged_Rec; pragma Unreferenced (R1, R2); begin R1 := (V => 0); R2 := (V => 0); end Main;
In this simple example, there isn't much difference between the Rec
and
Tagged_Rec
type. However, tagged types can be derived and extended. For
example:
[Ada]
procedure Main is type Rec is record V : Integer; end record; -- We cannot declare this: -- -- type Ext_Rec is new Rec with record -- V : Integer; -- end record; type Tagged_Rec is tagged record V : Integer; end record; -- But we can declare this: -- type Ext_Tagged_Rec is new Tagged_Rec with record V2 : Integer; end record; R1 : Rec; R2 : Tagged_Rec; R3 : Ext_Tagged_Rec; pragma Unreferenced (R1, R2, R3); begin R1 := (V => 0); R2 := (V => 0); R3 := (V => 0, V2 => 0); end Main;
As indicated in the example, a type derived from an untagged type cannot have
an extension. The compiler indicates this error if you uncomment the
declaration of the Ext_Rec
type above. In contrast, we can extend a
tagged type, as we did in the declaration of Ext_Tagged_Rec
. In this
case, Ext_Tagged_Rec
has all the components of the Tagged_Rec
type (V
, in this case) plus the additional components from its own type
declaration (V2
, in this case).
Overriding subprograms
Previously, we've seen that subprograms can be overriden. For example, if we
had implemented a Reset
and a Display
procedure for the
Rec
type that we declared above, these procedures would be available for
an Ext_Rec
type derived from Rec
. Also, we could override these
procedures for the Ext_Rec
type. In Ada, we don't need object-oriented
programming features to do that: simple (untagged) records can be used to
derive types, inherit operations and override them. However, in applications
where the actual subprogram to be called is determined dynamically at run-time,
we need dispatching calls. In this case, we must use tagged types to implement
this.
Comparing untagged and tagged types
Let's discuss the similarities and differences between untagged and tagged types based on this example:
[Ada]
package P is type Rec is record V : Integer; end record; procedure Display (R : Rec); procedure Reset (R : out Rec); type New_Rec is new Rec; overriding procedure Display (R : New_Rec); not overriding procedure New_Op (R : in out New_Rec); type Tagged_Rec is tagged record V : Integer; end record; procedure Display (R : Tagged_Rec); procedure Reset (R : out Tagged_Rec); type Ext_Tagged_Rec is new Tagged_Rec with record V2 : Integer; end record; overriding procedure Display (R : Ext_Tagged_Rec); overriding procedure Reset (R : out Ext_Tagged_Rec); not overriding procedure New_Op (R : in out Ext_Tagged_Rec); end P;with Ada.Text_IO; use Ada.Text_IO; package body P is procedure Display (R : Rec) is begin Put_Line ("TYPE: REC"); Put_Line ("Rec.V = " & Integer'Image (R.V)); New_Line; end Display; procedure Reset (R : out Rec) is begin R.V := 0; end Reset; procedure Display (R : New_Rec) is begin Put_Line ("TYPE: NEW_REC"); Put_Line ("New_Rec.V = " & Integer'Image (R.V)); New_Line; end Display; procedure New_Op (R : in out New_Rec) is begin R.V := R.V + 1; end New_Op; procedure Display (R : Tagged_Rec) is begin -- Using External_Tag attribute to retrieve the tag as a string Put_Line ("TYPE: " & Tagged_Rec'External_Tag); Put_Line ("Tagged_Rec.V = " & Integer'Image (R.V)); New_Line; end Display; procedure Reset (R : out Tagged_Rec) is begin R.V := 0; end Reset; procedure Display (R : Ext_Tagged_Rec) is begin -- Using External_Tag attribute to retrieve the tag as a string Put_Line ("TYPE: " & Ext_Tagged_Rec'External_Tag); Put_Line ("Ext_Tagged_Rec.V = " & Integer'Image (R.V)); Put_Line ("Ext_Tagged_Rec.V2 = " & Integer'Image (R.V2)); New_Line; end Display; procedure Reset (R : out Ext_Tagged_Rec) is begin Tagged_Rec (R).Reset; R.V2 := 0; end Reset; procedure New_Op (R : in out Ext_Tagged_Rec) is begin R.V := R.V + 1; end New_Op; end P;with Ada.Text_IO; use Ada.Text_IO; with P; use P; procedure Main is X_Rec : Rec; X_New_Rec : New_Rec; X_Tagged_Rec : aliased Tagged_Rec; X_Ext_Tagged_Rec : aliased Ext_Tagged_Rec; X_Tagged_Rec_Array : constant array (1 .. 2) of access Tagged_Rec'Class := (X_Tagged_Rec'Access, X_Ext_Tagged_Rec'Access); begin -- -- Reset all objects -- Reset (X_Rec); Reset (X_New_Rec); X_Tagged_Rec.Reset; -- we could write "Reset (X_Tagged_Rec)" as well X_Ext_Tagged_Rec.Reset; -- -- Use new operations when available -- New_Op (X_New_Rec); X_Ext_Tagged_Rec.New_Op; -- -- Display all objects -- Display (X_Rec); Display (X_New_Rec); X_Tagged_Rec.Display; -- we could write "Display (X_Tagged_Rec)" as well X_Ext_Tagged_Rec.Display; -- -- Resetting and display objects of Tagged_Rec'Class -- Put_Line ("Operations on Tagged_Rec'Class"); Put_Line ("------------------------------"); for E of X_Tagged_Rec_Array loop E.Reset; E.Display; end loop; end Main;
These are the similarities between untagged and tagged types:
We can derive types and inherit operations in both cases.
Both
X_New_Rec
andX_Ext_Tagged_Rec
inherit theDisplay
andReset
procedures from their respective ancestors.
We can override operations in both cases.
We can implement new operations in both cases.
Both
X_New_Rec
andX_Ext_Tagged_Rec
implement a procedure calledNew_Op
, which is not available for their respective ancestors.
Now, let's look at the differences between untagged and tagged types:
We can dispatch calls for a given type class.
This is what we do when we iterate over objects of the
Tagged_Rec
class — in the loop overX_Tagged_Rec_Array
at the last part of theMain
procedure.
We can use the dot notation.
We can write both
E.Reset
orReset (E)
forms: they're equivalent.
Dispatching calls
Let's look more closely at the dispatching calls implemented above. First, we
declare the X_Tagged_Rec_Array
array and initialize it with the access
to objects of both parent and derived tagged types:
[Ada]
X_Tagged_Rec : aliased Tagged_Rec;
X_Ext_Tagged_Rec : aliased Ext_Tagged_Rec;
X_Tagged_Rec_Array : constant array (1 .. 2) of access Tagged_Rec'Class
:= (X_Tagged_Rec'Access, X_Ext_Tagged_Rec'Access);
Here, we use the aliased
keyword to be able to get access to the objects
(via the 'Access
attribute).
Then, we loop over this array and call the Reset
and Display
procedures:
[Ada]
for E of X_Tagged_Rec_Array loop
E.Reset;
E.Display;
end loop;
Since we're using dispatching calls, the actual procedure that is selected
depends on the type of the object. For the first element
(X_Tagged_Rec_Array (1)
), this is Tagged_Rec
, while for the
second element (X_Tagged_Rec_Array (2)
), this is Ext_Tagged_Rec
.
Dispatching calls are only possible for a type class — for example, the
Tagged_Rec'Class
. When the type of an object is known at compile time,
the calls won't dispatch at runtime. For example, the call to the Reset
procedure of the X_Ext_Tagged_Rec
object
(X_Ext_Tagged_Rec.Reset
) will always take the overriden
Reset
procedure of the Ext_Tagged_Rec
type. Similarly, if we
perform a view conversion by writing
Tagged_Rec (A_Ext_Tagged_Rec).Display
, we're instructing the compiler to
interpret A_Ext_Tagged_Rec
as an object of type Tagged_Rec
, so
that the compiler selects the Display
procedure of the Tagged_Rec
type.
Interfaces
Another useful feature of object-oriented programming is the use of interfaces.
In this case, we can define abstract operations, and implement them in the
derived tagged types. We declare an interface by simply writing
type T is interface
. For example:
[Ada]
type My_Interface is interface;
procedure Op (Obj : My_Interface) is abstract;
-- We cannot declare actual objects of an interface:
--
-- Obj : My_Interface; -- ERROR!
All operations on an interface type are abstract, so we need to write
is abstract
in the signature — as we did in the declaration of
Op
above. Also, since interfaces are abstract types and don't have an
actual implementation, we cannot declare objects for it.
We can derive tagged types from an interface and implement the actual operations of that interface:
[Ada]
type My_Derived is new My_Interface with null record;
procedure Op (Obj : My_Derived);
Note that we're not using the tagged
keyword in the declaration because
any type derived from an interface is automatically tagged.
Let's look at an example with an interface and two derived tagged types:
[Ada]
package P is type Display_Interface is interface; procedure Display (D : Display_Interface) is abstract; type Small_Display_Type is new Display_Interface with null record; procedure Display (D : Small_Display_Type); type Big_Display_Type is new Display_Interface with null record; procedure Display (D : Big_Display_Type); end P;with Ada.Text_IO; use Ada.Text_IO; package body P is procedure Display (D : Small_Display_Type) is pragma Unreferenced (D); begin Put_Line ("Using Small_Display_Type"); end Display; procedure Display (D : Big_Display_Type) is pragma Unreferenced (D); begin Put_Line ("Using Big_Display_Type"); end Display; end P;with P; use P; procedure Main is D_Small : Small_Display_Type; D_Big : Big_Display_Type; procedure Dispatching_Display (D : Display_Interface'Class) is begin D.Display; end Dispatching_Display; begin Dispatching_Display (D_Small); Dispatching_Display (D_Big); end Main;
In this example, we have an interface type Display_Interface
and two
tagged types that are derived from Display_Interface
:
Small_Display_Type
and Big_Display_Type
.
Both types (Small_Display_Type
and Big_Display_Type
) implement
the interface by overriding the Display
procedure. Then, in the inner
procedure Dispatching_Display
of the Main
procedure, we perform
a dispatching call depending on the actual type of D
.
Deriving from multiple interfaces
We may derive a type from multiple interfaces by simply writing
type Derived_T is new T1 and T2 with null record
. For example:
[Ada]
package Transceivers is type Send_Interface is interface; procedure Send (Obj : in out Send_Interface) is abstract; type Receive_Interface is interface; procedure Receive (Obj : in out Receive_Interface) is abstract; type Transceiver is new Send_Interface and Receive_Interface with null record; procedure Send (D : in out Transceiver); procedure Receive (D : in out Transceiver); end Transceivers;with Ada.Text_IO; use Ada.Text_IO; package body Transceivers is procedure Send (D : in out Transceiver) is pragma Unreferenced (D); begin Put_Line ("Sending data..."); end Send; procedure Receive (D : in out Transceiver) is pragma Unreferenced (D); begin Put_Line ("Receiving data..."); end Receive; end Transceivers;with Transceivers; use Transceivers; procedure Main is D : Transceiver; begin D.Send; D.Receive; end Main;
In this example, we're declaring two interfaces (Send_Interface
and
Receive_Interface
) and the tagged type Transceiver
that derives
from both interfaces. Since we need to implement the interfaces, we implement
both Send
and Receive
for Transceiver
.
Abstract tagged types
We may also declare abstract tagged types. Note that, because the type is
abstract, we cannot use it to declare objects for it — this is the same
as for interfaces. We can only use it to derive other types. Let's look at the
abstract tagged type declared in the Abstract_Transceivers
package:
[Ada]
with Transceivers; use Transceivers; package Abstract_Transceivers is type Abstract_Transceiver is abstract new Send_Interface and Receive_Interface with null record; procedure Send (D : in out Abstract_Transceiver); -- We don't implement Receive for Abstract_Transceiver! end Abstract_Transceivers;with Ada.Text_IO; use Ada.Text_IO; package body Abstract_Transceivers is procedure Send (D : in out Abstract_Transceiver) is pragma Unreferenced (D); begin Put_Line ("Sending data..."); end Send; end Abstract_Transceivers;with Abstract_Transceivers; use Abstract_Transceivers; procedure Main is D : Abstract_Transceiver; begin D.Send; D.Receive; end Main;
In this example, we declare the abstract tagged type
Abstract_Transceiver
. Here, we're only partially implementing the
interfaces from which this type is derived: we're implementing Send
, but
we're skipping the implementation of Receive
. Therefore, Receive
is an abstract operation of Abstract_Transceiver
. Since any tagged type
that has abstract operations is abstract, we must indicate this by adding the
abstract
keyword in type declaration.
Also, when compiling this example, we get an error because we're trying to
declare an object of Abstract_Transceiver
(in the Main
procedure), which is not possible. Naturally, if we derive another type from
Abstract_Transceiver
and implement Receive
as well, then we can
declare objects of this derived type. This is what we do in the
Full_Transceivers
below:
[Ada]
with Abstract_Transceivers; use Abstract_Transceivers; package Full_Transceivers is type Full_Transceiver is new Abstract_Transceiver with null record; procedure Receive (D : in out Full_Transceiver); end Full_Transceivers;with Ada.Text_IO; use Ada.Text_IO; package body Full_Transceivers is procedure Receive (D : in out Full_Transceiver) is pragma Unreferenced (D); begin Put_Line ("Receiving data..."); end Receive; end Full_Transceivers;with Full_Transceivers; use Full_Transceivers; procedure Main is D : Full_Transceiver; begin D.Send; D.Receive; end Main;
Here, we implement the Receive
procedure for the
Full_Transceiver
. Therefore, the type doesn't have any abstract
operation, so we can use it to declare objects.
From simple derivation to OOP
In the section about simple derivation, we've seen an example where the actual selection was done at implementation time by renaming one of the packages:
[Ada]
with Drivers_1;
package Drivers renames Drivers_1;
Although this approach is useful in many cases, there might be situations where we need to select the actual driver dynamically at runtime. Let's look at how we could rewrite that example using interfaces, tagged types and dispatching calls:
[Ada]
package Drivers_Base is type Transceiver is interface; procedure Send (Device : Transceiver; Data : Integer) is abstract; procedure Receive (Device : Transceiver; Data : out Integer) is abstract; procedure Display (Device : Transceiver) is abstract; end Drivers_Base;with Drivers_Base; package Drivers_1 is type Transceiver is new Drivers_Base.Transceiver with null record; procedure Send (Device : Transceiver; Data : Integer); procedure Receive (Device : Transceiver; Data : out Integer); procedure Display (Device : Transceiver); end Drivers_1;with Ada.Text_IO; use Ada.Text_IO; package body Drivers_1 is procedure Send (Device : Transceiver; Data : Integer) is null; procedure Receive (Device : Transceiver; Data : out Integer) is pragma Unreferenced (Device); begin Data := 42; end Receive; procedure Display (Device : Transceiver) is pragma Unreferenced (Device); begin Put_Line ("Using Drivers_1"); end Display; end Drivers_1;with Drivers_Base; package Drivers_2 is type Transceiver is new Drivers_Base.Transceiver with null record; procedure Send (Device : Transceiver; Data : Integer); procedure Receive (Device : Transceiver; Data : out Integer); procedure Display (Device : Transceiver); end Drivers_2;with Ada.Text_IO; use Ada.Text_IO; package body Drivers_2 is procedure Send (Device : Transceiver; Data : Integer) is null; procedure Receive (Device : Transceiver; Data : out Integer) is pragma Unreferenced (Device); begin Data := 7; end Receive; procedure Display (Device : Transceiver) is pragma Unreferenced (Device); begin Put_Line ("Using Drivers_2"); end Display; end Drivers_2;with Ada.Text_IO; use Ada.Text_IO; with Drivers_Base; with Drivers_1; with Drivers_2; procedure Main is D1 : aliased Drivers_1.Transceiver; D2 : aliased Drivers_2.Transceiver; D : access Drivers_Base.Transceiver'Class; I : Integer; type Driver_Number is range 1 .. 2; procedure Select_Driver (N : Driver_Number) is begin if N = 1 then D := D1'Access; else D := D2'Access; end if; D.Display; end Select_Driver; begin Select_Driver (1); D.Send (999); D.Receive (I); Put_Line (Integer'Image (I)); Select_Driver (2); D.Send (999); D.Receive (I); Put_Line (Integer'Image (I)); end Main;
In this example, we declare the Transceiver
interface in the
Drivers_Base
package. This interface is then used to derive the tagged
types Transceiver
from both Drivers_1
and Drivers_2
packages.
In the Main
procedure, we use the access to Transceiver'Class
— from the interface declared in the Drivers_Base
package —
to declare D
. This object D
contains the access to the actual
driver loaded at any specific time. We select the driver at runtime in the
inner Select_Driver
procedure, which initializes D
(with the
access to the selected driver). Then, any operation on D
triggers a
dispatching call to the selected driver.
Further resources
In the appendices, we have a step-by-step hands-on overview of object-oriented programming that discusses how to translate a simple system written in C to an equivalent system in Ada using object-oriented programming.
Pointer to subprograms
Pointers to subprograms allow us to dynamically select an appropriate subprogram at runtime. This selection might be triggered by an external event, or simply by the user. This can be useful when multiple versions of a routine exist, and the decision about which one to use cannot be made at compilation time.
This is an example on how to declare and use pointers to functions in C:
[C]
#include <stdio.h> #include <stdlib.h> void show_msg_v1 (char *msg) { printf("Using version #1: %s\n", msg); } void show_msg_v2 (char *msg) { printf("Using version #2:\n %s\n", msg); } int main() { int selection = 1; void (*current_show_msg) (char *); switch (selection) { case 1: current_show_msg = &show_msg_v1; break; case 2: current_show_msg = &show_msg_v2; break; default: current_show_msg = NULL; break; } if (current_show_msg != NULL) { current_show_msg ("Hello there!"); } else { printf("ERROR: no version of show_msg() selected!\n"); } return 0; }
The example above contains two versions of the show_msg()
function:
show_msg_v1()
and show_msg_v2()
. The function is selected depending
on the value of selection
, which initializes the function pointer
current_show_msg
. If there's no corresponding value, current_show_msg
is set to null
— alternatively, we could have selected a default
version of show_msg()
function. By calling
current_show_msg ("Hello there!")
, we're calling the function that
current_show_msg
is pointing to.
This is the corresponding implementation in Ada:
[Ada]
with Ada.Text_IO; use Ada.Text_IO; procedure Show_Subprogram_Selection is procedure Show_Msg_V1 (Msg : String) is begin Put_Line ("Using version #1: " & Msg); end Show_Msg_V1; procedure Show_Msg_V2 (Msg : String) is begin Put_Line ("Using version #2: "); Put_Line (Msg); end Show_Msg_V2; type Show_Msg_Proc is access procedure (Msg : String); Current_Show_Msg : Show_Msg_Proc; Selection : Natural; begin Selection := 1; case Selection is when 1 => Current_Show_Msg := Show_Msg_V1'Access; when 2 => Current_Show_Msg := Show_Msg_V2'Access; when others => Current_Show_Msg := null; end case; if Current_Show_Msg /= null then Current_Show_Msg ("Hello there!"); else Put_Line ("ERROR: no version of Show_Msg selected!"); end if; end Show_Subprogram_Selection;
The structure of the code above is very similar to the one used in the C code.
Again, we have two version of Show_Msg
: Show_Msg_V1
and
Show_Msg_V2
. We set Current_Show_Msg
according to the value of
Selection
. Here, we use 'Access
to get access to the
corresponding procedure. If no version of Show_Msg
is available, we set
Current_Show_Msg
to null
.
Pointers to subprograms are also typically used as callback functions. This approach is extensively used in systems that process events, for example. Here, we could have a two-layered system:
A layer of the system (an event manager) triggers events depending on information from sensors.
For each event, callback functions can be registered.
The event manager calls registered callback functions when an event is triggered.
Another layer of the system registers callback functions for specific events and decides what to do when those events are triggered.
This approach promotes information hiding and component decoupling because:
the layer of the system responsible for managing events doesn't need to know what the callback function actually does, while
the layer of the system that implements callback functions remains agnostic to implementation details of the event manager — for example, how events are implemented in the event manager.
Let's see an example in C where we have a process_values()
function that
calls a callback function (process_one
) to process a list of values:
[C]
typedef int (*process_one_callback) (int); void process_values (int *values, int len, process_one_callback process_one);#include "process_values.h" #include <assert.h> #include <stdio.h> void process_values (int *values, int len, process_one_callback process_one) { int i; assert (process_one != NULL); for (i = 0; i < len; i++) { values[i] = process_one (values[i]); } }#include <stdio.h> #include <stdlib.h> #include "process_values.h" int proc_10 (int val) { return val + 10; } # define LEN_VALUES 5 int main() { int values[LEN_VALUES] = { 1, 2, 3, 4, 5 }; int i; process_values (values, LEN_VALUES, &proc_10); for (i = 0; i < LEN_VALUES; i++) { printf("Value [%d] = %d\n", i, values[i]); } return 0; }
As mentioned previously, process_values()
doesn't have any knowledge about
what process_one()
does with the integer value it receives as a parameter.
Also, we could replace proc_10()
by another function without having to
change the implementation of process_values()
.
Note that process_values()
calls an assert()
for the function
pointer to compare it against null
. Here, instead of checking the validity
of the function pointer, we're expecting the caller of process_values()
to provide a valid pointer.
This is the corresponding implementation in Ada:
[Ada]
package Values_Processing is type Integer_Array is array (Positive range <>) of Integer; type Process_One_Callback is not null access function (Value : Integer) return Integer; procedure Process_Values (Values : in out Integer_Array; Process_One : Process_One_Callback); end Values_Processing;package body Values_Processing is procedure Process_Values (Values : in out Integer_Array; Process_One : Process_One_Callback) is begin for I in Values'Range loop Values (I) := Process_One (Values (I)); end loop; end Process_Values; end Values_Processing;function Proc_10 (Value : Integer) return Integer;function Proc_10 (Value : Integer) return Integer is begin return Value + 10; end Proc_10;with Ada.Text_IO; use Ada.Text_IO; with Values_Processing; use Values_Processing; with Proc_10; procedure Show_Callback is Values : Integer_Array := (1, 2, 3, 4, 5); begin Process_Values (Values, Proc_10'Access); for I in Values'Range loop Put_Line ("Value [" & Positive'Image (I) & "] = " & Integer'Image (Values (I))); end loop; end Show_Callback;
Similar to the implementation in C, the Process_Values
procedure
receives the access to a callback routine, which is then called for each value
of the Values
array.
Note that the declaration of Process_One_Callback
makes use of the
not null access
declaration. By using this approach, we ensure that
any parameter of this type has a valid value, so we can always call the
callback routine.
Design by components using dynamic libraries
In the previous sections, we have shown how to use packages to create separate components of a system. As we know, when designing a complex system, it is advisable to separate concerns into distinct units, so we can use Ada packages to represent each unit of a system. In this section, we go one step further and create separate dynamic libraries for each component, which we'll then link to the main application.
Let's suppose we have a main system (Main_System
) and a
component A (Component_A
) that we want to use in the main system. For
example:
[Ada]
-- -- File: component_a.ads -- package Component_A is type Float_Array is array (Positive range <>) of Float; function Average (Data : Float_Array) return Float; end Component_A;-- -- File: component_a.adb -- package body Component_A is function Average (Data : Float_Array) return Float is Total : Float := 0.0; begin for Value of Data loop Total := Total + Value; end loop; return Total / Float (Data'Length); end Average; end Component_A;-- -- File: main_system.adb -- with Ada.Text_IO; use Ada.Text_IO; with Component_A; use Component_A; procedure Main_System is Values : constant Float_Array := (10.0, 11.0, 12.0, 13.0); Average_Value : Float; begin Average_Value := Average (Values); Put_Line ("Average = " & Float'Image (Average_Value)); end Main_System;
Note that, in the source-code example above, we're indicating the name of each file. We'll now see how to organize those files in a structure that is suitable for the GNAT build system (GPRbuild).
In order to discuss how to create dynamic libraries, we need to dig into some details about the build system. With GNAT, we can use project files for GPRbuild to easily design dynamic libraries. Let's say we use the following directory structure for the code above:
|- component_a
| | component_a.gpr
| |- src
| | | component_a.adb
| | | component_a.ads
|- main_system
| | main_system.gpr
| |- src
| | | main_system.adb
Here, we have two directories: component_a and main_system. Each directory contains a project file (with the .gpr file extension) and a source-code directory (src).
In the source-code example above, we've seen the content of files
component_a.ads
, component_a.adb
and main_system.adb
.
Now, let's discuss how to write the project file for Component_A
(component_a.gpr
), which will build the dynamic library for this
component:
library project Component_A is
for Source_Dirs use ("src");
for Object_Dir use "obj";
for Create_Missing_Dirs use "True";
for Library_Name use "component_a";
for Library_Kind use "dynamic";
for Library_Dir use "lib";
end Component_A;
The project is defined as a library project instead of project. This tells GPRbuild to build a library instead of an executable binary. We then specify the library name using the Library_Name attribute, which is required, so it must appear in a library project. The next two library-related attributes are optional, but important for our use-case. We use:
Library_Kind to specify that we want to create a dynamic library — by default, this attribute is set to static;
Library_Dir to specify the directory where the library is stored.
In the project file of our main system (main_system.gpr
), we just need
to reference the project of Component_A
using a with clause and
indicating the correct path to that project file:
with "../component_a/component_a.gpr";
project Main_System is
for Source_Dirs use ("src");
for Object_Dir use "obj";
for Create_Missing_Dirs use "True";
for Main use ("main_system.adb");
end Main_System;
GPRbuild takes care of selecting the correct settings to link the
dynamic library created for Component_A
with the main application
(Main_System
) and build an executable.
We can use the same strategy to create a Component_B
and dynamically
link to it in the Main_System
. We just need to create the separate
structure for this component — with the appropriate Ada packages and
project file — and include it in the project file of the main system
using a with clause:
with "../component_a/component_a.gpr";
with "../component_b/component_b.gpr";
...
Again, GPRbuild takes care of selecting the correct settings to link both dynamic libraries together with the main application.
You can find more details and special setting for library projects in the GPRbuild documentation.
In the GNAT toolchain
The GNAT toolchain includes a more advanced example focusing on how to load
dynamic libraries at runtime. You can find it in the
share/examples/gnat/plugins
directory of the GNAT toolchain
installation. As described in the README file from that directory, this
example "comprises a main program which probes regularly for the existence
of shared libraries in a known location. If such libraries are present, it
uses them to implement features initially not present in the main program."