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:

package Null_Recs is type Null_Record is record null; end record; end Null_Recs;

Note that the syntax can be simplified to is null record, which is much more common than the previous form:

package Null_Recs is type Null_Record is null record; end Null_Recs;

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:

package Null_Recs is type Null_Record is null record; function "+" (A, B : Null_Record) return Null_Record; end Null_Recs;
package body Null_Recs is function "+" (A, B : Null_Record) return Null_Record is pragma Unreferenced (A, B); begin return (null record); end "+"; end Null_Recs;
with Null_Recs; use Null_Recs; procedure Show_Null_Rec is A, B : Null_Record; begin B := A + A; A := A + B; end Show_Null_Rec;

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:

package Devices is type Device is private; function Create (Active : Boolean) return Device; procedure Reset (D : out Device) is null; procedure Process (D : in out Device) is null; procedure Activate (D : in out Device) is null; procedure Deactivate (D : in out Device) is null; private type Device is null record; function Create (Active : Boolean) return Device is (null record); end Devices;
with Ada.Text_IO; use Ada.Text_IO; with Devices; use Devices; procedure Show_Device is A : Device; begin Put_Line ("Creating device..."); A := Create (Active => True); Put_Line ("Processing on device..."); Process (A); Put_Line ("Deactivating device..."); Deactivate (A); Put_Line ("Activating device..."); Activate (A); Put_Line ("Resetting device..."); Reset (A); end Show_Device;

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:

package Devices is type Device is null record; function Create (Active : Boolean) return Device; procedure Reset (D : out Device); procedure Process (D : in out Device); procedure Activate (D : in out Device); procedure Deactivate (D : in out Device); end Devices;
with Ada.Text_IO; use Ada.Text_IO; package body Devices is function Create (Active : Boolean) return Device is pragma Unreferenced (Active); begin Put_Line ("Creating device..."); return (null record); end Create; procedure Reset (D : out Device) is pragma Unreferenced (D); begin Put_Line ("Processing on device..."); end Reset; procedure Process (D : in out Device) is pragma Unreferenced (D); begin Put_Line ("Deactivating device..."); end Process; procedure Activate (D : in out Device) is pragma Unreferenced (D); begin Put_Line ("Activating device..."); end Activate; procedure Deactivate (D : in out Device) is pragma Unreferenced (D); begin Put_Line ("Resetting device..."); end Deactivate; end Devices;
with Devices; use Devices; procedure Show_Device is A : Device; begin A := Create (Active => True); Process (A); Deactivate (A); Activate (A); Reset (A); end Show_Device;

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:

package Many_Devices is type Device is null record; type Device_Config is null record; function Create (Config : Device_Config) return Device is (null record); type Derived_Device is new Device; procedure Process (D : Derived_Device) is null; end Many_Devices;
with Many_Devices; use Many_Devices; procedure Show_Derived_Device is A : Device; B : Derived_Device; C : Device_Config; begin A := Create (Config => C); B := Create (Config => C); Process (B); end Show_Derived_Device;

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:

package Devices is type Device is private; function Create (Active : Boolean) return Device; procedure Reset (D : out Device); procedure Process (D : in out Device); procedure Activate (D : in out Device); procedure Deactivate (D : in out Device); private type Device is record Active : Boolean; end record; end Devices;
with Ada.Text_IO; use Ada.Text_IO; package body Devices is function Create (Active : Boolean) return Device is pragma Unreferenced (Active); begin Put_Line ("Creating device..."); return (Active => Active); end Create; procedure Reset (D : out Device) is pragma Unreferenced (D); begin Put_Line ("Processing on device..."); end Reset; procedure Process (D : in out Device) is pragma Unreferenced (D); begin Put_Line ("Deactivating device..."); end Process; procedure Activate (D : in out Device) is begin Put_Line ("Activating device..."); D.Active := True; end Activate; procedure Deactivate (D : in out Device) is begin Put_Line ("Resetting device..."); D.Active := False; end Deactivate; end Devices;
with Ada.Text_IO; use Ada.Text_IO; with Devices; use Devices; procedure Show_Device is A : Device; begin A := Create (Active => True); Process (A); Deactivate (A); Activate (A); Reset (A); end Show_Device;

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:

package Null_Recs is type Tagged_Null_Record is tagged null record; type Abstract_Tagged_Null_Record is abstract tagged null record; end Null_Recs;

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:

package Devices is type Device is private; function Create (Active : Boolean) return Device; type Derived_Device is private; private type Device is tagged null record; function Create (Active : Boolean) return Device is (null record); type Derived_Device is new Device with record Active : Boolean; end record; function Create (Active : Boolean) return Derived_Device is (Active => Active); end Devices;

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.

Full coverage rules for Aggregates

Note

This section was originally written by Robert A. Duff and published as Gem #1: Limited Types in Ada 2005.

One interesting feature of Ada are the full coverage rules for aggregates. For example, suppose we have a record type:

with Ada.Strings.Unbounded; use Ada.Strings.Unbounded; package Persons is type Years is new Natural; type Person is record Name : Ada.Strings.Unbounded.Unbounded_String; Age : Years; end record; end Persons;

We can create an object of the type using an aggregate:

with Ada.Strings.Unbounded; use Ada.Strings.Unbounded; with Persons; use Persons; procedure Show_Aggregate_Init is X : constant Person := (Name => To_Unbounded_String ("John Doe"), Age => 25); begin null; end Show_Aggregate_Init;

The full coverage rules say that every component of Person must be accounted for in the aggregate. If we later modify type Person by adding a component:

with Ada.Strings.Unbounded; use Ada.Strings.Unbounded; package Persons is type Years is new Natural; type Person is record Name : Unbounded_String; Age : Natural; Shoe_Size : Positive; end record; end Persons;

and we forget to modify X accordingly, the compiler will remind us. Case statements also have full coverage rules, which serve a similar purpose.

Of course, we can defeat the full coverage rules by using others (usually for array aggregates and case statements, but occasionally useful for record aggregates):

with Ada.Strings.Unbounded; use Ada.Strings.Unbounded; with Persons; use Persons; procedure Show_Aggregate_Init_Others is X : constant Person := (Name => To_Unbounded_String ("John Doe"), others => 25); begin null; end Show_Aggregate_Init_Others;

According to the Ada RM, others here means precisely the same thing as Age | Shoe_Size. But that's wrong: what others really means is "all the other components, including the ones we might add next week or next year". That means you shouldn't use others unless you're pretty sure it should apply to all the cases that haven't been invented yet.

Later on, we'll discuss full coverage rules for limited types.

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 type T1, and

  • an object B of tagged type T2, which extends T1.

We can initialize object B by:

  • copying the T1 specific information from A to B, and

  • initializing the T2 specific components of B.

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:

package Points is type Point_1D is tagged record X : Float; end record; procedure Display (P : Point_1D); type Point_2D is new Point_1D with record Y : Float; end record; procedure Display (P : Point_2D); type Point_3D is new Point_2D with record Z : Float; end record; procedure Display (P : Point_3D); end Points;
with Ada.Text_IO; use Ada.Text_IO; package body Points is procedure Display (P : Point_1D) is begin Put_Line ("(X => " & P.X'Image & ")"); end Display; procedure Display (P : Point_2D) is begin Put_Line ("(X => " & P.X'Image & ", Y => " & P.Y'Image & ")"); end Display; procedure Display (P : Point_3D) is begin Put_Line ("(X => " & P.X'Image & ", Y => " & P.Y'Image & ", Z => " & P.Z'Image & ")"); end Display; end Points;

Let's now focus on the Show_Points procedure below, where we initialize a two-dimensional point using a one-dimensional point.

with Points; use Points; procedure Show_Points is P_1D : Point_1D; P_2D : Point_2D; begin P_1D := (X => 0.5); Display (P_1D); Point_1D (P_2D) := P_1D; -- Equivalent to: "P_2D.X := P_1D.X;" P_2D.Y := 0.7; Display (P_2D); end Show_Points;

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:

with Points; use Points; procedure Show_Points is P_1D : Point_1D; P_2D : Point_2D; begin P_1D := (X => 0.5); Display (P_1D); P_2D := (P_1D with Y => 0.7); Display (P_2D); end Show_Points;

When we write P_2D := (P_1D with Y => 0.7), we're initializing P_2D using:

  • the information from the P_1D object — of Point_1D type, which is an ancestor of the Point_2D type —, and

  • the information from the record component association list for the remaining components of the Point_2D type. (In this case, the only remaining component of the Point_2D type is Y.)

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:

with Points; use Points; procedure Show_Points is P_1D : Point_1D; P_2D : Point_2D; P_3D : Point_3D; begin P_1D := (X => 0.5); Display (P_1D); P_2D := (P_1D with Y => 0.7); Display (P_2D); P_3D := (P_2D with Z => 0.3); Display (P_3D); P_3D := (P_1D with Y | Z => 0.1); Display (P_3D); end Show_Points;

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.

with Points; use Points; procedure Show_Points is P_1D : Point_1D; P_2D : Point_2D; P_3D : Point_3D; begin P_1D := (X => 0.5); P_2D := (P_1D with Y => 0.7); -- Initialize P_3D with P_1D and set other -- components to 0.6. -- P_3D := (P_1D with others => 0.6); Display (P_3D); -- Initialize P_3D with P_2D, and other -- components with their default value. -- P_3D := (P_2D with others => <>); Display (P_3D); end Show_Points;

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.

package Points.Extensions is type Point_3D_Ext is new Point_3D with null record; end Points.Extensions;
with Points; use Points; with Points.Extensions; use Points.Extensions; procedure Show_Points is P_3D : Point_3D; P_3D_Ext : Point_3D_Ext; begin P_3D := (X => 0.0, Y => 0.5, Z => 0.4); P_3D_Ext := (P_3D with null record); Display (P_3D_Ext); end Show_Points;

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:

with Points; use Points; procedure Show_Points is P_2D : Point_2D; P_3D : Point_3D; begin P_3D := (X => 0.5, Y => 0.7, Z => 0.3); Display (P_3D); P_2D := (Point_1D (P_3D) with Y => 0.3); Display (P_2D); end Show_Points;

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.