Aggregates
Null records
A null record is a record that doesn't have any components. Consequently, it
cannot store any information. When declaring a null record, we simply
write null
instead of declaring actual components, as we usually do for
records. For example:
Note that the syntax can be simplified to is null record
, which is much
more common than the previous form:
Although a null record doesn't have components, we can still specify subprograms for it. For example, we could specify an addition operation for it:
In the Ada Reference Manual
Simple Prototyping
A null record doesn't provide much functionality on itself, as we're not storing any information in it. However, it's far from being useless. For example, we can make use of null records to design an API, which we can then use in an application without having to implement the actual functionality of the API. This allows us to design a prototype without having to think about all the implementation details of the API in the first stage.
Consider this example:
In the Devices
package, we're declaring the Device
type and its
primitive subprograms: Create
, Reset
, Process
,
Activate
and Deactivate
. This is the API that we use in our
prototype. Note that, although the Device
type is declared as a private
type, it's still defined as a null record in the full view.
In this example, the Create
function, implemented as an expression
function in the private part, simply returns a null record. As expected, this
null record returned by Create
matches the definition of the
Device
type.
All procedures associated with the Device
type are implemented as null
procedures, which means they don't actually have an implementation nor have any
effect. We'll discuss this topic
later on in the course.
In the Show_Device
procedure — which is an application
that implements our prototype —, we declare an object of Device
type and call all subprograms associated with that type.
Extending the prototype
Because we're either using expression functions or null procedures in the
specification of the Devices
package, we don't have a package body for
it (as there's nothing to be implemented). We could, however, move those user
messages from the Show_Devices
procedure to a dummy implementation of
the Devices
package. This is the adapted code:
As we changed the specification of the Devices
package to not use null
procedures, we now need a corresponding package body for it. In this package
body, we implement the operations on the Device
type, which actually
just display a user message indicating which operation is being called.
Let's focus on this updated version of the Show_Device
procedure. Now
that we've removed all those calls to Put_Line
from this procedure and
just have the calls to operations associated with the Device
type, it
becomes more apparent that, even though Device
is just a null record, we
can design an application with a sequence of various commands operating on it.
Also, when we just read the source-code of the Show_Device
procedure,
there's no clear indication that the Device
type doesn't actually hold
any information.
More complex applications
As we've just seen, we can use null records like any other type and create complex prototypes with them. We could, for instance, design an application that makes use of many null records, or even have types that depend on or derive from null records. Let's see a simple example:
In this example, the Create
function has a null record parameter
(of Device_Config
type) and returns a null record (of Device
type). Also, we derive the Derived_Device
type from the Device
type. Consequently, Derived_Device
is also a null record (since it's
derived from a null record). In the Show_Derived_Device
procedure, we
declare objects of those types (A
, B
and C
) and call
primitive subprograms to operate on them.
This example shows that, even though the types we've declared are just null records, they can still be used to represent dependencies in our application.
Implementing the API
Let's focus again on the previous example. After we have an initial prototype,
we can start implementing some of the functionality needed for the
Device
type. For example, we can store information about the current
activation state in the record:
Now, the Device
record contains an Active
component, which is
used in the updated versions of Create
, Activate
and
Deactivate
.
Note that we haven't done any change to the implementation of the
Show_Device
procedure: it's still the same application as before. As
we've been hinting in the beginning, using null records makes it easy for us to
first create a prototype — as we did in the Show_Device
procedure
— and postpone the API implementation to a later phase of the project.
Tagged null records
A null record may be tagged, as we can see in this example:
As we see in this example, a type can be tagged
, or even
abstract tagged
. We discuss abstract types
later on in the course.
As expected, in addition to deriving from tagged types, we can also extend them. For example:
In this example, we derive Derived_Device
from the Device
type
and extend it with the Active
component. (Because we have a type
extension, we also need to override the Create
function.)
Since we're now introducing elements from object-oriented programming, we could consider using interfaces instead of null records. We'll discuss this topic later on in the course.
Extension Aggregates
Extension aggregates provide a convenient way to express an aggregate for a type that extends — adds components to — some existing type (the "ancestor"). Although mainly a matter of convenience, an extension aggregate is essential when we want to express an aggregate for an extension of a private ancestor type, that is, when we don't have compile-time visibility to the ancestor type's components.
In the Ada Reference Manual
Assignments to objects of derived types
Before we discuss extension aggregates in more detail, though, let's start with a simple use-case. Let's say we have:
an object
A
of tagged typeT1
, andan object
B
of tagged typeT2
, which extendsT1
.
We can initialize object B
by:
copying the
T1
specific information fromA
toB
, andinitializing the
T2
specific components ofB
.
We can translate the description above to the following code:
A : T1;
B : T2;
begin
T1 (B) := A;
B.Extended_Component_1 := Some_Value;
-- [...]
Here, we use T1 (B)
to select the ancestor view of object B
, and
we copy all the information from A
to this part of B
. Then, we
initialize the remaining components of B
. We'll elaborate on this kind
of assignments later on.
Example: Points
To present a more concrete example, let's start with a package that defines one, two and three-dimensional point types:
Let's now focus on the Show_Points
procedure below, where we initialize
a two-dimensional point using a one-dimensional point.
In this example, we're initializing P_2D
using the information stored in
P_1D
. By writing Point_1D (P_2D)
on the left side of the
assignment, we specify that we want to limit our focus on the Point_1D
view of the P_2D
object. Then, we assign P_1D
to the
Point_1D
view of the P_2D
object. This assignment initializes the
X
component of the P_2D
object. The Point_2D
specific
components are not changed by this assignment. (In other words, this is
equivalent to just writing P_2D.X := P_1D.X
, as the Point_1D
type
only has the X
component.) Finally, in the next line, we initialize the
Y
component with 0.7.
Using extension aggregates
Note that, in the assignment to P_1D
, we use a record aggregate.
Extension aggregates are similar to record aggregates, but they include the
with
keyword — for example: (Obj1 with Y => 0.5)
. This
allows us to assign to an object with information from another object
Obj1
and, in the same expression, set the value of the Y
component.
Let's rewrite the previous Show_Points
procedure using extension
aggregates:
When we write P_2D := (P_1D with Y => 0.7)
, we're initializing
P_2D
using:
the information from the
P_1D
object — ofPoint_1D
type, which is an ancestor of thePoint_2D
type —, andthe information from the record component association list for the remaining components of the
Point_2D
type. (In this case, the only remaining component of thePoint_2D
type isY
.)
We could also specify the type of the extension aggregate. For example, in the
previous assignment to P_2D
, we could write Point_2D'(...)
to
indicate that we expect the Point_2D
type for the extension aggregate.
-- Explicitly state that the type of the
-- extension aggregate is Point_2D:
P_2D := Point_2D'(P_1D with Y => 0.7);
Also, we don't have to use named association in extension aggregates. We
could just use positional association instead. Therefore, we could simplify the
assignment to P_2D
in the previous example by just writing:
P_2D := (P_1D with 0.7);
More extension aggregates
We can use extension aggregates for descendents of the Point_2D
type as
well. For example, let's extend our previous code example by declaring an
object of Point_3D
type (called P_3D
) and use extension
aggregates in assignments to this object:
In the first assignment to P_3D
in the example above, we're
initializing this object with information from P_2D
and specifying
the value of the Z
component. Then, in the next assignment to the
P_3D
object, we're using an aggregate with information from P_1
and specifying values for the Y
and Z
components. (Just as a
reminder, we can write Y | Z => 0.1
to assign 0.1 to both Y
and
Z
components.)
with others
Other versions of extension aggregates are possible as well. For example, we
can combine keywords and write with others
to focus on all remaining
components of an extension aggregate.
In this example, the first assignment to P_3D
has an aggregate with
information from P_1D
, while the remaining components — in this
case, Y
and Z
— are just set to 0.6.
Continuing with this example, in the next assignment to P_3D
, we're
using information from P_2
in the extension aggregate. This covers the
Point_2D
part of the P_3D
object — components X
and
Y
, to be more specific. The Point_3D
specific components of
P_3D
— component Z
in this case — receive their
corresponding default value. In this specific case, however, we haven't
specified a default value for component Z
in the declaration of the
Point_3D
type, so we cannot rely on any specific value being assigned to
that component when using others => <>
.
with null record
We can also use extension aggregates with null records. Let's focus on the
P_3D_Ext
object of Point_3D_Ext
type. This object is declared in
the Show_Points
procedure of the next code example.
The P_3D_Ext
object is of Point_3D_Ext
type, which is declared in
the Points.Extensions
package and derived from the Point_3D
type.
Note that we're not extending Point_3D_Ext
with new components, but
using a null record instead in the declaration. Therefore, as the
Point_3D_Ext
type doesn't own any new components, we just write
(P_3D with null record)
to initialize the P_3D_Ext
object.
Extension aggregates and descendent types
In the examples above, we've been initializing objects of descendent types by using objects of ascending types in extension aggregates. We could, however, do the opposite and initialize objects of ascending types using objects of descendent type in extension aggregates. Consider this code example:
Here, we're using Point_1D (P_3D)
to select the Point_1D
view of
an object of Point_3D
type. At this point, we have specified the
Point_1D
part of the aggregate, so we still have to specify the
remaining components of the Point_2D
type — the Y
component, to be more specific. When we do that, we get the appropriate
aggregate for the Point_2D
type. In summary, by carefully selecting the
appropriate view, we're able to initialize an object of ascending type
(Point_2D
), which contains less components, using an object of a
descendent type (Point_3D
), which contains more components.