Records

Mutually dependent types

In this section, we discuss how to use incomplete types to declare mutually dependent types. Let's start with this example:

    
    
    
        
package Mutually_Dependent is type T1 is record B : T2; end record; type T2 is record A : T1; end record; end Mutually_Dependent;

When you try to compile this example, you get a compilation error. The first problem with this code is that, in the declaration of the T1 record, the compiler doesn't know anything about T2. We could solve this by declaring an incomplete type (type T2;) before the declaration of T1. This, however, doesn't solve all the problems in the code: the compiler still doesn't know the size of T2, so we cannot create a component of this type. We could, instead, declare an access type and use it here. By doing this, even though the compiler doesn't know the size of T2, it knows the size of an access type designating T2, so the record component can be of such an access type.

To summarize, in order to solve the compilation error above, we need to:

  • use at least one incomplete type;

  • declare at least one component as an access to an object.

For example, we could declare an incomplete type T2 and then declare the component B of the T1 record as an access to T2. This is the corrected version:

    
    
    
        
package Mutually_Dependent is type T2; type T2_Access is access T2; type T1 is record B : T2_Access; end record; type T2 is record A : T1; end record; end Mutually_Dependent;

We could strive for consistency and declare two incomplete types and two accesses, but this isn't strictly necessary in this case. Here's the adapted code:

    
    
    
        
package Mutually_Dependent is type T1; type T1_Access is access T1; type T2; type T2_Access is access T2; type T1 is record B : T2_Access; end record; type T2 is record A : T1_Access; end record; end Mutually_Dependent;

Later on, we'll see that these code examples can be written using anonymous access types.

In the Ada Reference Manual

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.

Per-Object Expressions

In record type declarations, we might want to define a component that makes use of a name that refers to a discriminant of the record type, or to the record type itself. The expression where we use that name is called a per-object expression.

The term "per-object" comes from the fact that, in the component definition, we're referring to a piece of information that will be known just when creating an object of that type. For example, if the per-object expression refers to a discriminant of a type T, the actual value of that discriminant will only be specified when we declare an object of type T. Therefore, the component definition is specific for that individual object — but not necessarily for other objects of the same type, as we might use different values for the discriminant.

The constraint that contains a per-object expression is called a per-object constraint. The actual constraint of that component isn't completely known when we declare the record type, but only later on when an object of that type is created.

In addition to referring to discriminants, per-object expressions can also refer to the record type itself, as we'll see later.

Let's start with a simple record declaration:

    
    
    
        
package Rec_Per_Object_Expressions is type Stack (S : Positive) is private; private type Integer_Array is array (Positive range <>) of Integer; type Stack (S : Positive) is record Arr : Integer_Array (1 .. S); -- ^^^^^^ -- -- S -- ^ -- Per-object expression -- -- 1 .. S -- ^^^^^^ -- Per-object constraint Top : Natural := 0; end record; end Rec_Per_Object_Expressions;

In this example, we see the Stack record type with a discriminant S. In the declaration of the Arr component of the that type, S is a per-object expression, as it refers to the S discriminant. Also, 1 .. S is a per-object constraint.

Let's look at another example using anonymous access types:

    
    
    
        
package Rec_Per_Object_Expressions is type T is private; type T_Processor (Selected_T : access T) is private; private type T is null record; type T_Container (Selected_T : access T) is null record; type T_Processor (Selected_T : access T) is record E : T_Container (Selected_T); -- ^^^^^^^^^^ -- Per-object expression -- Per-object constraint end record; end Rec_Per_Object_Expressions;

Let's focus on the T_Processor type from this example. The Selected_T discriminant is being used in the definition of the E component. In this case, Selected_T is at the same time a per-object expression and a per-object constraint.

Finally, per-object expressions can also refer to the record type we're declaring. For example:

    
    
    
        
package Rec_Per_Object_Expressions is type T is limited private; private type T_Processor (Selected_T : access T) is null record; type T is limited record E : T_Processor (T'Access); -- ^^^^^^^^ -- Per-object expression -- Per-object constraint end record; end Rec_Per_Object_Expressions;

In this example, when we write T'Access within the declaration of the T record type, the actual value for the Access attribute will be known when an object of T type is created. In that sense, T'Access is a per-object expression — and a per-object constraint as well.

Relevant topics