Types and Representation

Data Representation

This section provides a glimpse on attributes and aspects used for data representation. They are usually used for embedded applications because of strict requirements that are often found there. Therefore, unless you have very specific requirements for your application, in most cases, you won't need them. However, you should at least have a rudimentary understanding of them. To read a thorough overview on this topic, please refer to the Introduction to Embedded Systems Programming course.

Todo

Add link once available:

Introduction to Embedded Systems Programming </courses/intro-to-embedded-sys-prog/low_level_programming>

Sizes

Ada offers multiple attributes to retrieve the size of a type or an object:

Attribute

Description

Size

Size of the representation of a subtype or an object.

Object_Size

Size of a component or an aliased object.

Component_Size

Size of a component of an array.

Storage_Size

Number of storage elements reserved for an access type or a task object.

For the first three attributes, the size is measured in bits. In the case of Storage_Size, the size is measured in storage elements. Note that the size information depends your target architecture. We'll discuss some examples to better understand the differences among those attributes.

Size attribute and aspect

Let's start with a code example using the Size attribute:

package Custom_Types is type UInt_7 is range 0 .. 127; type UInt_7_S32 is range 0 .. 127 with Size => 32; end Custom_Types;
with Ada.Text_IO; use Ada.Text_IO; with Custom_Types; use Custom_Types; procedure Show_Sizes is V1 : UInt_7; V2 : UInt_7_S32; begin Put_Line ("UInt_7'Size: " & UInt_7'Size'Image); Put_Line ("UInt_7'Object_Size: " & UInt_7'Object_Size'Image); Put_Line ("V1'Size: " & V1'Size'Image); New_Line; Put_Line ("UInt_7_S32'Size: " & UInt_7_S32'Size'Image); Put_Line ("UInt_7_S32'Object_Size: " & UInt_7_S32'Object_Size'Image); Put_Line ("V2'Size: " & V2'Size'Image); end Show_Sizes;

Depending on your target architecture, you may see this output:

UInt_7'Size:             7
UInt_7'Object_Size:      8
V1'Size:                 8

UInt_7_S32'Size:         32
UInt_7_S32'Object_Size:  32
V2'Size:                 32

When we use the Size attribute for a type T, we're retrieving the minimum number of bits necessary to represent objects of that type. Note that this is not the same as the actual size of an object of type T because the compiler will select an object size that is appropriate for the target architecture.

In the example above, the size of the UInt_7 is 7 bits, while the most appropriate size to store objects of this type in the memory of our target architecture is 8 bits. To be more specific, the range of UInt_7 (0 .. 127) can be perfectly represented in 7 bits. However, most target architectures don't offer 7-bit registers or 7-bit memory storage, so 8 bits is the most appropriate size in this case.

We can retrieve the size of an object of type T by using the Object_Size. Alternatively, we can use the Size attribute directly on objects of type T to retrieve their actual size — in our example, we write V1'Size to retrieve the size of V1.

In the example above, we've used both the Size attribute (for example, UInt_7'Size) and the Size aspect (with Size => 32). While the size attribute is a function that returns the size, the size aspect is a request to the compiler to verify that the expected size can be used on the target platform. You can think of this attribute as a dialog between the developer and the compiler:

(Developer) "I think that UInt_7_S32 should be stored using at least 32 bits. Do you agree?"

(Ada compiler) "For the target platform that you selected, I can confirm that this is indeed the case."

Depending on the target platform, however, the conversation might play out like this:

(Developer) "I think that UInt_7_S32 should be stored using at least 32 bits. Do you agree?"

(Ada compiler) "For the target platform that you selected, I cannot possibly do it! COMPILATION ERROR!"

Component size

Let's continue our discussion on sizes with an example that makes use of the Component_Size attribute:

package Custom_Types is type UInt_7 is range 0 .. 127; type UInt_7_Array is array (Positive range <>) of UInt_7; type UInt_7_Array_Comp_32 is array (Positive range <>) of UInt_7 with Component_Size => 32; end Custom_Types;
with Ada.Text_IO; use Ada.Text_IO; with Custom_Types; use Custom_Types; procedure Show_Sizes is Arr_1 : UInt_7_Array (1 .. 20); Arr_2 : UInt_7_Array_Comp_32 (1 .. 20); begin Put_Line ("UInt_7_Array'Size: " & UInt_7_Array'Size'Image); Put_Line ("UInt_7_Array'Object_Size: " & UInt_7_Array'Object_Size'Image); Put_Line ("UInt_7_Array'Component_Size: " & UInt_7_Array'Component_Size'Image); Put_Line ("Arr_1'Component_Size: " & Arr_1'Component_Size'Image); Put_Line ("Arr_1'Size: " & Arr_1'Size'Image); New_Line; Put_Line ("UInt_7_Array_Comp_32'Object_Size: " & UInt_7_Array_Comp_32'Size'Image); Put_Line ("UInt_7_Array_Comp_32'Object_Size: " & UInt_7_Array_Comp_32'Object_Size'Image); Put_Line ("UInt_7_Array_Comp_32'Component_Size: " & UInt_7_Array_Comp_32'Component_Size'Image); Put_Line ("Arr_2'Component_Size: " & Arr_2'Component_Size'Image); Put_Line ("Arr_2'Size: " & Arr_2'Size'Image); New_Line; end Show_Sizes;

Depending on your target architecture, you may see this output:

UInt_7_Array'Size:                    17179869176
UInt_7_Array'Object_Size:             17179869176
UInt_7_Array'Component_Size:          8
Arr_1'Component_Size:                 8
Arr_1'Size:                           160

UInt_7_Array_Comp_32'Size:            68719476704
UInt_7_Array_Comp_32'Object_Size:     68719476704
UInt_7_Array_Comp_32'Component_Size:  32
Arr_2'Component_Size:                 32
Arr_2'Size:                           640

Here, the value we get for Component_Size of the UInt_7_Array type is 8 bits, which matches the UInt_7'Object_Size — as we've seen in the previous subsection. In general, we expect the component size to match the object size of the underlying type.

However, we might have component sizes that aren't equal to the object size of the component's type. For example, in the declaration of the UInt_7_Array_Comp_32 type, we're using the Component_Size aspect to query whether the size of each component can be 32 bits:

type UInt_7_Array_Comp_32 is array (Positive range <>) of UInt_7
  with Component_Size => 32;

If the code compiles, we see this value when we use the Component_Size attribute. In this case, even though UInt_7'Object_Size is 8 bits, the component size of the array type (UInt_7_Array_Comp_32'Component_Size) is 32 bits.

Note that we can use the Component_Size attribute with data types, as well as with actual objects of that data type. Therefore, we can write UInt_7_Array'Component_Size and Arr_1'Component_Size, for example.

This big number (17179869176 bits) for UInt_7_Array'Size and UInt_7_Array'Object_Size might be surprising for you. This is due to the fact that Ada is reporting the size of the UInt_7_Array type for the case when the complete range is used. Considering that we specified a positive range in the declaration of the UInt_7_Array type, the maximum length on this machine is 231 - 1. The object size of an array type is calculated by multiplying the maximum length by the component size. Therefore, the object size of the UInt_7_Array type corresponds to the multiplication of 231 - 1 components (maximum length) by 8 bits (component size).

Storage size

To complete our discussion on sizes, let's look at this example of storage sizes:

package Custom_Types is type UInt_7 is range 0 .. 127; type UInt_7_Access is access UInt_7; end Custom_Types;
with Ada.Text_IO; use Ada.Text_IO; with System; with Custom_Types; use Custom_Types; procedure Show_Sizes is AV1, AV2 : UInt_7_Access; begin Put_Line ("UInt_7_Access'Storage_Size: " & UInt_7_Access'Storage_Size'Image); Put_Line ("UInt_7_Access'Storage_Size (bits): " & Integer'Image (UInt_7_Access'Storage_Size * System.Storage_Unit)); Put_Line ("UInt_7'Size: " & UInt_7'Size'Image); Put_Line ("UInt_7_Access'Size: " & UInt_7_Access'Size'Image); Put_Line ("UInt_7_Access'Object_Size: " & UInt_7_Access'Object_Size'Image); Put_Line ("AV1'Size: " & AV1'Size'Image); New_Line; Put_Line ("Allocating AV1..."); AV1 := new UInt_7; Put_Line ("Allocating AV2..."); AV2 := new UInt_7; New_Line; Put_Line ("AV1.all'Size: " & AV1.all'Size'Image); New_Line; end Show_Sizes;

Depending on your target architecture, you may see this output:

UInt_7_Access'Storage_Size:           0
UInt_7_Access'Storage_Size (bits):    0

UInt_7'Size:                7
UInt_7_Access'Size:         64
UInt_7_Access'Object_Size:  64
AV1'Size:                   64

Allocating AV1...
Allocating AV2...

AV1.all'Size:               8

As we've mentioned earlier on, Storage_Size corresponds to the number of storage elements reserved for an access type or a task object. In this case, we see that the storage size of the UInt_7_Access type is zero. This is because we haven't indicated that memory should be reserved for this data type. Thus, the compiler doesn't reserve memory and simply sets the size to zero.

Because Storage_Size gives us the number of storage elements, we have to multiply this value by System.Storage_Unit — which gives us the size (in bits) of a single storage element — to get the total storage size in bits. (In this particular example, however, the multiplication doesn't make any difference, as the number of storage elements is zero.)

Note that the size of our original data type UInt_7 is 7 bits, while the size of its corresponding access type UInt_7_Access (and the access object AV1) is 64 bits. This is due to the fact that the access type doesn't contain an object, but rather memory information about an object. You can retrieve the size of an object allocated via new by first dereferencing it — in our example, we do this by writing AV1.all'Size.

Now, let's use the Storage_Size aspect to actually reserve memory for this data type:

package Custom_Types is type UInt_7 is range 0 .. 127; type UInt_7_Reserved_Access is access UInt_7 with Storage_Size => 8; end Custom_Types;
with Ada.Text_IO; use Ada.Text_IO; with System; with Custom_Types; use Custom_Types; procedure Show_Sizes is RAV1, RAV2 : UInt_7_Reserved_Access; begin Put_Line ("UInt_7_Reserved_Access'Storage_Size: " & UInt_7_Reserved_Access'Storage_Size'Image); Put_Line ("UInt_7_Reserved_Access'Storage_Size (bits): " & Integer'Image (UInt_7_Reserved_Access'Storage_Size * System.Storage_Unit)); Put_Line ("UInt_7_Reserved_Access'Size: " & UInt_7_Reserved_Access'Size'Image); Put_Line ("UInt_7_Reserved_Access'Object_Size: " & UInt_7_Reserved_Access'Object_Size'Image); Put_Line ("RAV1'Size: " & RAV1'Size'Image); New_Line; Put_Line ("Allocating RAV1..."); RAV1 := new UInt_7; Put_Line ("Allocating RAV2..."); RAV2 := new UInt_7; New_Line; end Show_Sizes;

Depending on your target architecture, you may see this output:

UInt_7_Reserved_Access'Storage_Size:         8
UInt_7_Reserved_Access'Storage_Size (bits):  64

UInt_7_Reserved_Access'Size:         64
UInt_7_Reserved_Access'Object_Size:  64
RAV1'Size:                           64

Allocating RAV1...
Allocating RAV2...

raised STORAGE_ERROR : s-poosiz.adb:108 explicit raise

In this case, we're reserving 8 storage elements in the declaration of UInt_7_Reserved_Access.

type UInt_7_Reserved_Access is access UInt_7
  with Storage_Size => 8;

Since each storage unit corresponds to one byte (8 bits) in this architecture, we're reserving a maximum of 64 bits for the UInt_7_Reserved_Access type.

This example raises an exception at runtime — a storage error, to be more specific. This is because the maximum reserved size is 64 bits, and the size of a single access object is 64 bits as well. Therefore, after the first allocation, the reserved storage space is already consumed, so we cannot allocate a second access object.

This behavior might be quite limiting in many cases. However, for certain applications where memory is very constrained, this might be exactly what we want to see. For example, having an exception being raised when the allocated memory for this data type has reached its limit might allow the application to have enough memory to at least handle the exception gracefully.

Alignment

For many algorithms, it's important to ensure that we're using the appropriate alignment. This can be done by using the Alignment attribute and the Alignment aspect. Let's look at this example:

package Custom_Types is type UInt_7 is range 0 .. 127; type Aligned_UInt_7 is new UInt_7 with Alignment => 4; end Custom_Types;
with Ada.Text_IO; use Ada.Text_IO; with Custom_Types; use Custom_Types; procedure Show_Alignment is V : constant UInt_7 := 0; Aligned_V : constant Aligned_UInt_7 := 0; begin Put_Line ("UInt_7'Alignment: " & UInt_7'Alignment'Image); Put_Line ("UInt_7'Size: " & UInt_7'Size'Image); Put_Line ("UInt_7'Object_Size: " & UInt_7'Object_Size'Image); Put_Line ("V'Alignment: " & V'Alignment'Image); Put_Line ("V'Size: " & V'Size'Image); New_Line; Put_Line ("Aligned_UInt_7'Alignment: " & Aligned_UInt_7'Alignment'Image); Put_Line ("Aligned_UInt_7'Size: " & Aligned_UInt_7'Size'Image); Put_Line ("Aligned_UInt_7'Object_Size: " & Aligned_UInt_7'Object_Size'Image); Put_Line ("Aligned_V'Alignment: " & Aligned_V'Alignment'Image); Put_Line ("Aligned_V'Size: " & Aligned_V'Size'Image); New_Line; end Show_Alignment;

Depending on your target architecture, you may see this output:

UInt_7'Alignment:            1
UInt_7'Size:                 7
UInt_7'Object_Size:          8
V'Alignment:                 1
V'Size:                      8

Aligned_UInt_7'Alignment:    4
Aligned_UInt_7'Size:         7
Aligned_UInt_7'Object_Size:  32
Aligned_V'Alignment:         4
Aligned_V'Size:              32

In this example, we're reusing the UInt_7 type that we've already been using in previous examples. Because we haven't specified any alignment for the UInt_7 type, it has an alignment of 1 storage unit (or 8 bits). However, in the declaration of the Aligned_UInt_7 type, we're using the Alignment aspect to request an alignment of 4 storage units (or 32 bits):

type Aligned_UInt_7 is new UInt_7
  with Alignment => 4;

When using the Alignment attribute for the Aligned_UInt_7 type, we can confirm that its alignment is indeed 4 storage units (bytes).

Note that we can use the Alignment attribute for both data types and objects — in the code above, we're using UInt_7'Alignment and V'Alignment, for example.

Because of the alignment we're specifying for the Aligned_UInt_7 type, its size — indicated by the Object_Size attribute — is 32 bits instead of 8 bits as for the UInt_7 type.

Note that you can also retrieve the alignment associated with a class using S'Class'Alignment. For example:

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Class_Alignment is type Point_1D is tagged record X : Integer; end record; type Point_2D is new Point_1D with record Y : Integer; end record with Alignment => 16; type Point_3D is new Point_2D with record Z : Integer; end record; begin Put_Line ("1D_Point'Alignment: " & Point_1D'Alignment'Image); Put_Line ("1D_Point'Class'Alignment: " & Point_1D'Class'Alignment'Image); Put_Line ("2D_Point'Alignment: " & Point_2D'Alignment'Image); Put_Line ("2D_Point'Class'Alignment: " & Point_2D'Class'Alignment'Image); Put_Line ("3D_Point'Alignment: " & Point_3D'Alignment'Image); Put_Line ("3D_Point'Class'Alignment: " & Point_3D'Class'Alignment'Image); end Show_Class_Alignment;

Overlapping Storage

Algorithms can be designed to perform in-place or out-of-place processing. In other words, they can take advantage of the fact that input and output arrays share the same storage space or not.

We can use the Has_Same_Storage and the Overlaps_Storage attributes to retrieve more information about how the storage space of two objects related to each other:

  • the Has_Same_Storage attribute indicates whether two objects have the exact same storage.

    • A typical example is when both objects are exactly the same, so they obviously share the same storage. For example, for array A, A'Has_Same_Storage (A) is always True.

  • the Overlaps_Storage attribute indicates whether two objects have at least one bit in common.

    • Note that, if two objects have the same storage, this implies that their storage also overlaps. In other words, A'Has_Same_Storage (B) = True implies that A'Overlaps_Storage (B) = True.

Let's look at this example:

package Int_Array_Processing is type Int_Array is array (Positive range <>) of Integer; procedure Show_Storage (X : Int_Array; Y : Int_Array); procedure Process (X : Int_Array; Y : out Int_Array); end Int_Array_Processing;
with Ada.Text_IO; use Ada.Text_IO; package body Int_Array_Processing is procedure Show_Storage (X : Int_Array; Y : Int_Array) is begin if X'Has_Same_Storage (Y) then Put_Line ("Info: X and Y have the same storage."); else Put_Line ("Info: X and Y don't the have same storage."); end if; if X'Overlaps_Storage (Y) then Put_Line ("Info: X and Y overlap."); else Put_Line ("Info: X and Y don't overlap."); end if; end Show_Storage; procedure Process (X : Int_Array; Y : out Int_Array) is begin Put_Line ("==== PROCESS ===="); Show_Storage (X, Y); if X'Has_Same_Storage (Y) then Put_Line ("In-place processing..."); else if not X'Overlaps_Storage (Y) then Put_Line ("Out-of-place processing..."); else Put_Line ("Cannot process overlapping arrays..."); end if; end if; New_Line; end Process; end Int_Array_Processing;
with Int_Array_Processing; use Int_Array_Processing; procedure Main is A : Int_Array (1 .. 20) := (others => 3); B : Int_Array (1 .. 20) := (others => 4); begin Process (A, A); -- In-place processing: sharing the exact same storage Process (A (1 .. 10), A (10 .. 20)); -- Overlapping one component: A (10) Process (A (1 .. 10), A (11 .. 20)); -- Out-of-place processing: same array, but not sharing any storage Process (A, B); -- Out-of-place processing: two different arrays end Main;

In this code example, we implement two procedures:

  • Show_Storage, which shows storage information about two arrays by using the Has_Same_Storage and Overlaps_Storage attributes.

  • Process, which are supposed to process an input array X and store the processed data in the output array Y.

    • Note that the implementation of this procedure is actually just a mock-up, so that no processing is actually taking place.

We have four different instances of how we can call the Process procedure:

  • in the Process (A, A) call, we're using the same array for the input and output arrays. This is a perfect example of in-place processing. Because the input and the output arrays arguments are actually the same object, they obviously share the exact same storage.

  • in the Process (A (1 .. 10), A (10 .. 20)) call, we're using two slices of the A array as input and output arguments. In this case, a single component of the A array is shared: A (10). Because the storage space is overlapping, but not exactly the same, neither in-place nor out-of-place processing can usually be used in this case.

  • in the Process (A (1 .. 10), A (11 .. 20)) call, even though we're using the same array A for the input and output arguments, we're using slices that are completely independent from each other, so that the input and output arrays are not sharing any storage in this case. Therefore, we can use out-of-place processing.

  • in the Process (A, B) call, we have two different arrays — which obviously don't share any storage space —, so we can use out-of-place processing.

Packed Representation

As we've seen previously, the minimum number of bits required to represent a data type might be less than the actual number of bits used to store an object of that same type. We've seen an example where UInt_7'Size was 7 bits, while UInt_7'Object_Size was 8 bits. The most extreme case is the one for the Boolean type: in this case, Boolean'Size is 1 bit, while Boolean'Object_Size might be 8 bits (or even more on certain architectures). In such cases, we have 7 (or more) unused bits in memory for each object of Boolean type. In other words, we're wasting memory. On the other hand, we're gaining speed of access because we can directly access each element without having to first change its internal representation back and forth. We'll come back to this point later.

The situation is even worse when implementing bit-fields, which can be declared as an array of Boolean components. For example:

package Flag_Definitions is type Flags is array (Positive range <>) of Boolean; end Flag_Definitions;
with Ada.Text_IO; use Ada.Text_IO; with Flag_Definitions; use Flag_Definitions; procedure Show_Flags is Flags_1 : Flags (1 .. 8); begin Put_Line ("Boolean'Size: " & Boolean'Size'Image); Put_Line ("Boolean'Object_Size: " & Boolean'Object_Size'Image); Put_Line ("Flags_1'Size: " & Flags_1'Size'Image); Put_Line ("Flags_1'Component_Size: " & Flags_1'Component_Size'Image); end Show_Flags;

Depending on your target architecture, you may see this output:

Boolean'Size:            1
Boolean'Object_Size:     8
Flags_1'Size:            64
Flags_1'Component_Size:  8

In this example, we're declaring the Flags type as an array of Boolean components. As we can see in this case, although the size of the Boolean type is just 1 bit, an object of this type has a size of 8 bits. Consequently, each component of the Flags type has a size of 8 bits. Moreover, an array with 8 components of Boolean type — such as the Flags_1 array — has a size of 64 bits.

Therefore, having a way to compact the representation — so that we can store multiple objects without wasting storage space — may help us improving memory usage. This is actually possible by using the Pack aspect. For example, we could extend the previous example and declare a Packed_Flags type that makes use of this aspect:

package Flag_Definitions is type Flags is array (Positive range <>) of Boolean; type Packed_Flags is array (Positive range <>) of Boolean with Pack; end Flag_Definitions;
with Ada.Text_IO; use Ada.Text_IO; with Flag_Definitions; use Flag_Definitions; procedure Show_Packed_Flags is Flags_1 : Flags (1 .. 8); Flags_2 : Packed_Flags (1 .. 8); begin Put_Line ("Boolean'Size: " & Boolean'Size'Image); Put_Line ("Boolean'Object_Size: " & Boolean'Object_Size'Image); Put_Line ("Flags_1'Size: " & Flags_1'Size'Image); Put_Line ("Flags_1'Component_Size: " & Flags_1'Component_Size'Image); Put_Line ("Flags_2'Size: " & Flags_2'Size'Image); Put_Line ("Flags_2'Component_Size: " & Flags_2'Component_Size'Image); end Show_Packed_Flags;

Depending on your target architecture, you may see this output:

Boolean'Size:            1
Boolean'Object_Size:     8
Flags_1'Size:            64
Flags_1'Component_Size:  8
Flags_2'Size:            8
Flags_2'Component_Size:  1

In this example, we're declaring the Flags_2 array of Packed_Flags type. Its size is 8 bits — instead of the 64 bits required for the Flags_1 array. Because the array type Packed_Flags is packed, we can now effectively use this type to store an object of Boolean type using just 1 bit of the memory, as indicated by the Flags_2'Component_Size attribute.

In many cases, we need to convert between a normal representation (such as the one used for the Flags_1 array above) to a packed representation (such as the one for the Flags_2 array). In many programming languages, this conversion may require writing custom code with manual bit-shifting and bit-masking to get the proper target representation. In Ada, however, we just need to indicate the actual type conversion, and the compiler takes care of generating code containing bit-shifting and bit-masking to performs the type conversion.

Let's modify the previous example and introduce this type conversion:

package Flag_Definitions is type Flags is array (Positive range <>) of Boolean; type Packed_Flags is array (Positive range <>) of Boolean with Pack; Default_Flags : constant Flags := (True, True, False, True, False, False, True, True); end Flag_Definitions;
with Ada.Text_IO; use Ada.Text_IO; with Flag_Definitions; use Flag_Definitions; procedure Show_Flag_Conversion is Flags_1 : Flags (1 .. 8); Flags_2 : Packed_Flags (1 .. 8); begin Flags_1 := Default_Flags; Flags_2 := Packed_Flags (Flags_1); for I in Flags_2'Range loop Put_Line (I'Image & ": " & Flags_1 (I)'Image & ", " & Flags_2 (I)'Image); end loop; end Show_Flag_Conversion;

In this extended example, we're now declaring Default_Flags as an array of constant flags, which we use to initialize Flags_1.

The actual conversion happens with Flags_2 := Packed_Flags (Flags_1). Here, the type conversion Packed_Flags() indicates that we're converting from the normal representation (used for the Flags type) to the packed representation (used for Packed_Flags type). We don't need to write more code than that to perform the correct type conversion.

Also, by using the same strategy, we could read information from a packed representation. For example:

Flags_1 := Flags (Flags_2);

In this case, we use Flags() to convert from a packed representation to the normal representation.

We elaborate on the topic of converting between data representations in the section on changing data representation.

Trade-offs

As indicated previously, when we're using a packed representation (vs. using a standard unpacked representation), we're trading off speed of access for less memory consumption. The following table summarizes this:

Representation

More speed of access

Less memory consumption

Unpacked

X

Packed

X

On one hand, we have better memory usage when we apply packed representations because we may save many bits for each object. On the other hand, there's a cost associated with accessing those packed objects because they need to be unpacked before we can actually access them. In fact, the compiler generates code — using bit-shifting and bit-masking — that converts a packed representation into an unpacked representation, which we can then access. Also, when storing a packed object, the compiler generates code that converts the unpacked representation of the object into the packed representation.

This packing and unpacking mechanism has a performance cost associated with it, which results in less speed of access for packed objects. As usual in those circumstances, before using packed representation, we should assess whether memory constraints are more important than speed in our target architecture.

Record Representation and storage clauses

In this section, we discuss how to use record representation clauses to specify how a record is represented in memory. Our goal is to provide a brief introduction into the topic. If you're interested in more details, you can find a thorough discussion about record representation clauses in the Introduction to Embedded Systems Programming course.

Let's start with the simple approach of declaring a record type without providing further information. In this case, we're basically asking the compiler to select a reasonable representation for that record in the memory of our target architecture.

Let's see a simple example:

package P is type R is record A : Integer; B : Integer; end record; end P;

Considering a typical 64-bit PC architecture with 8-bit storage units, and Integer defined as a 32-bit type, we get this memory representation:

digraph foo {
     "Record_R" [
         label = "{ position | component } | { { 0 | 1 | 2 | 3 } | A } | { { 4 | 5 | 6 | 7 } | B }"
         shape = "record"
     ];
}

Each storage unit is a position in memory. In the graph above, the numbers on the top (0, 1, 2, ...) represent those positions for record R.

In addition, we can show the bits that are used for components A and B:

digraph foo {
     "Record_R" [
         label = "{ position | bits | component } |  { { { 0 | #0 .. 7 } | { 1 | #8 .. #15 } | { 2 | #16 .. #23 } | { 3 | #24 .. #31 } } | A } | { { { 4 | #0 .. 7 } | { 5 | #8 .. #15 } | { 6 | #16 .. #23 } | { 7 | #24 .. #31 } } | B }"
         shape = "record"
     ];
}

The memory representation we see in the graph above can be described in Ada using representation clauses, as you can see in the code starting at the for R use record line in the code example below — we'll discuss the syntax and further details right after this example.

package P is type R is record A : Integer; B : Integer; end record; -- Representation clause for record R: for R use record A at 0 range 0 .. 31; -- ^ starting memory position B at 4 range 0 .. 31; -- ^ first bit .. last bit end record; end P;

Here, we're specifying that the A component is stored in the bits #0 up to #31 starting at position #0. Note that the position itself doesn't represent an absolute address in the device's memory; instead, it's relative to the memory space reserved for that record. The B component has the same 32-bit range, but starts at position #4.

This is a generalized view of the syntax:

for Record_Type use record
   Component_Name at Start_Position range First_Bit .. Last_Bit;
end record;

These are the elements we see above:

  • Component_Name: name of the component (from the record type declaration);

  • Start_Position: start position — in storage units — of the memory space reserved for that component;

  • First_Bit: first bit (in the start position) of the component;

  • Last_Bit: last bit of the component.

Note that the last bit of a component might be in a different storage unit. Since the Integer type has a larger width (32 bits) than the storage unit (8 bits), components of that type span over multiple storage units. Therefore, in our example, the first bit of component A is at position #0, while the last bit is at position #3.

Also note that the last eight bits of component A are bits #24 .. #31. If we think in terms of storage units, this corresponds to bits #0 .. #7 of position #3. However, when specifying the last bit in Ada, we always use the First_Bit value as a reference, not the position where those bits might end up. Therefore, we write range 0 .. 31, well knowing that those 32 bits span over four storage units (positions #0 .. #3).

In the Ada Reference Manual

Storage Place Attributes

We can retrieve information about the start position, and the first and last bits of a component by using the storage place attributes:

  • Position, which retrieves the start position of a component;

  • First_Bit, which retrieves the first bit of a component;

  • Last_Bit, which retrieves the last bit of a component.

Note, however, that these attributes can only be used with actual records, and not with record types.

We can revisit the previous example and verify how the compiler represents the R type in memory:

package P is type R is record A : Integer; B : Integer; end record; end P;
with Ada.Text_IO; use Ada.Text_IO; with System; with P; use P; procedure Show_Storage is R1 : R; begin Put_Line ("R'Size: " & R'Size'Image); Put_Line ("R'Object_Size: " & R'Object_Size'Image); New_Line; Put_Line ("System.Storage_Unit: " & System.Storage_Unit'Image); New_Line; Put_Line ("R1.A'Position : " & R1.A'Position'Image); Put_Line ("R1.A'First_Bit : " & R1.A'First_Bit'Image); Put_Line ("R1.A'Last_Bit : " & R1.A'Last_Bit'Image); New_Line; Put_Line ("R1.B'Position : " & R1.B'Position'Image); Put_Line ("R1.B'First_Bit : " & R1.B'First_Bit'Image); Put_Line ("R1.B'Last_Bit : " & R1.B'Last_Bit'Image); end Show_Storage;

On a typical 64-bit PC architecture, you probably see this output:

R'Size:               64
R'Object_Size:        64
System.Storage_Unit:  8

R1.A'Position  :  0
R1.A'First_Bit :  0
R1.A'Last_Bit  :  31

R1.B'Position  :  4
R1.B'First_Bit :  0
R1.B'Last_Bit  :  31

First of all, we see that the size of the R type is 64 bits, which can be explained by those two 32-bit integer components. Then, we see that components A and B start at positions #0 and #4, and each one makes use of bits in the range from #0 to #31. This matches the graph we've seen above.

In the Ada Reference Manual

Using Representation Clauses

We can use representation clauses to change the way the compiler handles memory for a record type. For example, let's say we want to have an empty storage unit between components A and B. We can use a representation clause where we specify that component B starts at position #5 instead of #4, leaving an empty byte after component A and before component B:

digraph foo {
     "Record_R" [
         label = "{ position | bits | component } |  { { { 0 | #0 .. 7 } | { 1 | #8 .. #15 } | { 2 | #16 .. #23 } | { 3 | #24 .. #31 } } | A } | { 4 |  |  } | { { { 5 | #0 .. 7 } | { 6 | #8 .. #15 } | { 7 | #16 .. #23 } | { 8 | #24 .. #31 } } | B }"
         shape = "record"
     ];
}

This is the code that implements that:

package P is type R is record A : Integer; B : Integer; end record; for R use record A at 0 range 0 .. 31; B at 5 range 0 .. 31; end record; end P;
with Ada.Text_IO; use Ada.Text_IO; with P; use P; procedure Show_Empty_Byte is begin Put_Line ("R'Size: " & R'Size'Image); Put_Line ("R'Object_Size: " & R'Object_Size'Image); end Show_Empty_Byte;

When running the application above, we see that, due to the extra byte in the record representation, the sizes increase. On a typical 64-bit PC, R'Size is now 76 bits, which reflects the additional eight bits that we introduced between components A and B. Depending on the target architecture, you may also see that R'Object_Size is now 96 bits, which is the size the compiler selects as the most appropriate for this record type. As we've mentioned in the previous section, we can use aspects to request a specific size to the compiler. In this case, we could use the Object_Size aspect:

package P is type R is record A : Integer; B : Integer; end record with Object_Size => 72; for R use record A at 0 range 0 .. 31; B at 5 range 0 .. 31; end record; end P;
with Ada.Text_IO; use Ada.Text_IO; with P; use P; procedure Show_Empty_Byte is begin Put_Line ("R'Size: " & R'Size'Image); Put_Line ("R'Object_Size: " & R'Object_Size'Image); end Show_Empty_Byte;

If the code compiles, R'Size and R'Object_Size should now have the same value.

Derived Types And Representation Clauses

In some cases, you might want to modify the memory representation of a record without impacting existing code. For example, you might want to use a record type that was declared in a package that you're not allowed to change. Also, you would like to modify its memory representation in your application. A nice strategy is to derive a type and use a representation clause for the derived type.

We can apply this strategy on our previous example. Let's say we would like to use record type R from package P in our application, but we're not allowed to modify package P — or the record type, for that matter. In this case, we could simply derive R as R_New and use a representation clause for R_New. This is exactly what we do in the specification of the child package P.Rep:

package P is type R is record A : Integer; B : Integer; end record; end P;
package P.Rep is type R_New is new R with Object_Size => 72; for R_New use record A at 0 range 0 .. 31; B at 5 range 0 .. 31; end record; end P.Rep;
with Ada.Text_IO; use Ada.Text_IO; with P; use P; with P.Rep; use P.Rep; procedure Show_Empty_Byte is begin Put_Line ("R'Size: " & R'Size'Image); Put_Line ("R'Object_Size: " & R'Object_Size'Image); Put_Line ("R_New'Size: " & R_New'Size'Image); Put_Line ("R_New'Object_Size: " & R_New'Object_Size'Image); end Show_Empty_Byte;

When running this example, we see that the R type retains the memory representation selected by the compiler for the target architecture, while the R_New has the memory representation that we specified.

Representation on Bit Level

A very common application of representation clauses is to specify individual bits of a record. This is particularly useful, for example, when mapping registers or implementing protocols.

Let's consider the following fictitious register as an example:

digraph foo {
     "Record_R" [
         label = "{ bit | component } | { { 0 | 1 }  | S } | { { 2 | 3 } | (reserved) } | { 4 | Error } | { { 5 | 6 | 7 } | V1 }"
         shape = "record"
     ];
}

Here, S is the current status, Error is a flag, and V1 contains a value. Due to the fact that we can use representation clauses to describe individual bits of a register as records, the implementation becomes as simple as this:

package P is type Status is (Ready, Waiting, Processing, Done); type UInt_3 is range 0 .. 2 ** 3 - 1; type Simple_Reg is record S : Status; Error : Boolean; V1 : UInt_3; end record; for Simple_Reg use record S at 0 range 0 .. 1; -- Bit #2 and 3: reserved! Error at 0 range 4 .. 4; V1 at 0 range 5 .. 7; end record; end P;
with Ada.Text_IO; use Ada.Text_IO; with P; use P; procedure Show_Simple_Reg is begin Put_Line ("Simple_Reg'Size: " & Simple_Reg'Size'Image); Put_Line ("Simple_Reg'Object_Size: " & Simple_Reg'Object_Size'Image); end Show_Simple_Reg;

As we can see in the declaration of the Simple_Reg type, each component represents a field from our register, and it has a fixed location (which matches the register representation we see in the graph above). Any operation on the register is as simple as accessing the record component. For example:

with Ada.Text_IO; use Ada.Text_IO; with P; use P; procedure Show_Simple_Reg is Default : constant Simple_Reg := (S => Ready, Error => False, V1 => 0); R : Simple_Reg := Default; begin Put_Line ("R.S: " & R.S'Image); R.V1 := 4; Put_Line ("R.V1: " & R.V1'Image); end Show_Simple_Reg;

As we can see in the example, to retrieve the current status of the register, we just have to write R.S. To update the V1 field of the register with the value 4, we just have to write R.V1 := 4. No extra code — such as bit-masking or bit-shifting — is needed here.

In other languages

Some programming languages require that developers use complicated, error-prone approaches — which may include manually bit-shifting and bit-masking variables — to retrieve information from or store information to individual bits or registers. In Ada, however, this is efficiently handled by the compiler, so that developers only need to correctly describe the register mapping using representation clauses.

Changing Data Representation

Note

This section was originally written by Robert Dewar and published as Gem #27: Changing Data Representation and Gem #28.

A powerful feature of Ada is the ability to specify the exact data layout. This is particularly important when you have an external device or program that requires a very specific format. Some examples are:

package Communication is type Com_Packet is record Key : Boolean; Id : Character; Val : Integer range 100 .. 227; end record; for Com_Packet use record Key at 0 range 0 .. 0; Id at 0 range 1 .. 8; Val at 0 range 9 .. 15; end record; end Communication;

which lays out the fields of a record, and in the case of Val, forces a biased representation in which all zero bits represents 100. Another example is:

package Array_Representation is type Val is (A, B, C, D, E, F, G, H); type Arr is array (1 .. 16) of Val with Component_Size => 3; end Array_Representation;

which forces the components to take only 3 bits, crossing byte boundaries as needed. A final example is:

package Enumeration_Representation is type Status is (Off, On, Unknown); for Status use (Off => 2#001#, On => 2#010#, Unknown => 2#100#); end Enumeration_Representation;

which allows specified values for an enumeration type, instead of the efficient default values of 0, 1, 2.

In all these cases, we might use these representation clauses to match external specifications, which can be very useful. The disadvantage of such layouts is that they are inefficient, and accessing individual components, or, in the case of the enumeration type, looping through the values can increase space and time requirements for the program code.

One approach that is often effective is to read or write the data in question in this specified form, but internally in the program represent the data in the normal default layout, allowing efficient access, and do all internal computations with this more efficient form.

To follow this approach, you will need to convert between the efficient format and the specified format. Ada provides a very convenient method for doing this, as described in RM 13.6 "Change of Representation".

The idea is to use type derivation, where one type has the specified format and the other has the normal default format. For instance for the array case above, we would write:

package Array_Representation is type Val is (A, B, C, D, E, F, G, H); type Arr is array (1 .. 16) of Val; type External_Arr is new Arr with Component_Size => 3; end Array_Representation;

Now we read and write the data using the External_Arr type. When we want to convert to the efficient form, Arr, we simply use a type conversion.

with Array_Representation; use Array_Representation; procedure Using_Array_For_IO is Input_Data : External_Arr; Work_Data : Arr; Output_Data : External_Arr; begin -- (read data into Input_Data) -- Now convert to internal form Work_Data := Arr (Input_Data); -- (computations using efficient Work_Data form) -- Convert back to external form Output_Data := External_Arr (Work_Data); end Using_Array_For_IO;

Using this approach, the quite complex task of copying all the data of the array from one form to another, with all the necessary masking and shift operations, is completely automatic.

Similar code can be used in the record and enumeration type cases. It is even possible to specify two different representations for the two types, and convert from one form to the other, as in:

package Enumeration_Representation is type Status_In is (Off, On, Unknown); type Status_Out is new Status_In; for Status_In use (Off => 2#001#, On => 2#010#, Unknown => 2#100#); for Status_Out use (Off => 103, On => 1045, Unknown => 7700); end Enumeration_Representation;

There are two restrictions that must be kept in mind when using this feature. First, you have to use a derived type. You can't put representation clauses on subtypes, which means that the conversion must always be explicit. Second, there is a rule RM 13.1(10) that restricts the placement of interesting representation clauses:

10 For an untagged derived type, no type-related representation items are allowed if the parent type is a by-reference type, or has any user-defined primitive subprograms.

All the representation clauses that are interesting from the point of view of change of representation are "type related", so for example, the following sequence would be illegal:

package Array_Representation is type Val is (A, B, C, D, E, F, G, H); type Arr is array (1 .. 16) of Val; procedure Rearrange (Arg : in out Arr); type External_Arr is new Arr with Component_Size => 3; end Array_Representation;

Why these restrictions? Well, the answer is a little complex, and has to do with efficiency considerations, which we will address below.

Restrictions

In the previous subsection, we discussed the use of derived types and representation clauses to achieve automatic change of representation. More accurately, this feature is not completely automatic, since it requires you to write an explicit conversion. In fact there is a principle behind the design here which says that a change of representation should never occur implicitly behind the back of the programmer without such an explicit request by means of a type conversion.

The reason for that is that the change of representation operation can be very expensive, since in general it can require component by component copying, changing the representation on each component.

Let's have a look at the -gnatG expanded code to see what is hidden under the covers here. For example, the conversion Arr (Input_Data) from the previous example generates the following expanded code:

B26b : declare
   [subtype p__TarrD1 is integer range 1 .. 16]
   R25b : p__TarrD1 := 1;
begin
   for L24b in 1 .. 16 loop
      [subtype p__arr___XP3 is
        system__unsigned_types__long_long_unsigned range 0 ..
        16#FFFF_FFFF_FFFF#]
      work_data := p__arr___XP3!((work_data and not shift_left!(
        16#7#, 3 * (integer(L24b - 1)))) or shift_left!(p__arr___XP3!
        (input_data (R25b)), 3 * (integer(L24b - 1))));
      R25b := p__TarrD1'succ(R25b);
   end loop;
end B26b;

That's pretty horrible! In fact, we could have simplified it for this section, but we have left it in its original form, so that you can see why it is nice to let the compiler generate all this stuff so you don't have to worry about it yourself.

Given that the conversion can be pretty inefficient, you don't want to convert backwards and forwards more than you have to, and the whole approach is only worthwhile if we'll be doing extensive computations involving the value.

The expense of the conversion explains two aspects of this feature that are not obvious. First, why do we require derived types instead of just allowing subtypes to have different representations, avoiding the need for an explicit conversion?

The answer is precisely that the conversions are expensive, and you don't want them happening behind your back. So if you write the explicit conversion, you get all the gobbledygook listed above, but you can be sure that this never happens unless you explicitly ask for it.

This also explains the restriction we mentioned in previous subsection from RM 13.1(10):

10 For an untagged derived type, no type-related representation items are allowed if the parent type is a by-reference type, or has any user-defined primitive subprograms.

It turns out this restriction is all about avoiding implicit changes of representation. Let's have a look at how type derivation works when there are primitive subprograms defined at the point of derivation. Consider this example:

package My_Ints is type My_Int_1 is range 1 .. 10; function Odd (Arg : My_Int_1) return Boolean; type My_Int_2 is new My_Int_1; end My_Ints;
package body My_Ints is function Odd (Arg : My_Int_1) return Boolean is (True); -- Dummy implementation! end My_Ints;

Now when we do the type derivation, we inherit the function Odd for My_Int_2. But where does this function come from? We haven't written it explicitly, so the compiler somehow materializes this new implicit function. How does it do that?

We might think that a complete new function is created including a body in which My_Int_2 replaces My_Int_1, but that would be impractical and expensive. The actual mechanism avoids the need to do this by use of implicit type conversions. Suppose after the above declarations, we write:

with My_Ints; use My_Ints; procedure Using_My_Int is Var : My_Int_2; begin if Odd (Var) then -- ^ Calling Odd function for My_Int_2 type. null; end if; end Using_My_Int;

The compiler translates this as:

with My_Ints; use My_Ints; procedure Using_My_Int is Var : My_Int_2; begin if Odd (My_Int_1 (Var)) then -- ^ Converting My_Int_2 to My_Int_1 type before -- calling Odd function. null; end if; end Using_My_Int;

This implicit conversion is a nice trick, it means that we can get the effect of inheriting a new operation without actually having to create it. Furthermore, in a case like this, the type conversion generates no code, since My_Int_1 and My_Int_2 have the same representation.

But the whole point is that they might not have the same representation if one of them had a representation clause that made the representations different, and in this case the implicit conversion inserted by the compiler could be expensive, perhaps generating the junk we quoted above for the Arr case. Since we never want that to happen implicitly, there is a rule to prevent it.

The business of forbidding by-reference types (which includes all tagged types) is also driven by this consideration. If the representations are the same, it is fine to pass by reference, even in the presence of the conversion, but if there was a change of representation, it would force a copy, which would violate the by-reference requirement.

So to summarize this section, on the one hand Ada gives you a very convenient way to trigger these complex conversions between different representations. On the other hand, Ada guarantees that you never get these potentially expensive conversions happening unless you explicitly ask for them.

Valid

When receiving data from external sources, we're subjected to problems such as transmission errors. If not handled properly, erroneous data can lead to major issues in an application.

One of those issues originates from the fact that transmission errors might lead to invalid information stored in memory. When proper checks are active, using invalid information is detected at runtime and an exception is raised at this point, which might then be handled by the application.

Instead of relying on exception handling, however, we could instead ensure that the information we're about to use is valid. We can do this by using the Valid attribute. For example, if we have a variable Var, we can verify that the value stored in Var is valid by writing Var'Valid, which returns a Boolean value. Therefore, if the value of Var isn't valid, Var'Valid returns False, so we can have code that handles this situation before we actually make use of Var. In other words, instead of handling a potential exception in other parts of the application, we can proactively verify that input information is correct and avoid that an exception is raised.

In the next example, we show an application that

  • generates a file containing mock-up data, and then

  • reads information from this file as state values.

The mock-up data includes valid and invalid states.

procedure Create_Test_File (File_Name : String);
with Ada.Sequential_IO; procedure Create_Test_File (File_Name : String) is package Integer_Sequential_IO is new Ada.Sequential_IO (Integer); use Integer_Sequential_IO; F : File_Type; begin Create (F, Out_File, File_Name); Write (F, 1); Write (F, 2); Write (F, 4); Write (F, 3); Write (F, 2); Write (F, 10); Close (F); end Create_Test_File;
with Ada.Sequential_IO; package States is type State is (Off, On, Waiting) with Size => Integer'Size; for State use (Off => 1, On => 2, Waiting => 4); package State_Sequential_IO is new Ada.Sequential_IO (State); procedure Read_Display_States (File_Name : String); end States;
with Ada.Text_IO; use Ada.Text_IO; package body States is procedure Read_Display_States (File_Name : String) is use State_Sequential_IO; F : State_Sequential_IO.File_Type; S : State; procedure Display_State (S : State) is begin -- Before displaying the value, check whether it's valid or not. if S'Valid then Put_Line (S'Image); else Put_Line ("Invalid value detected!"); end if; end Display_State; begin Open (F, In_File, File_Name); while not End_Of_File (F) loop Read (F, S); Display_State (S); end loop; Close (F); end Read_Display_States; end States;
with States; use States; with Create_Test_File; procedure Show_States_From_File is File_Name : constant String := "data.bin"; begin Create_Test_File (File_Name); Read_Display_States (File_Name); end Show_States_From_File;

When running the application, you'd see this output:

OFF
ON
WAITING
Invalid value detected!
ON
Invalid value detected!

Let's start our discussion on this example with the States package, which contains the declaration of the State type. This type is a simple enumeration containing three states: Off, On and Waiting. We're assigning specific integer values for this type by declaring an enumeration representation clause. Note that we're using the Size aspect to request that objects of this type have the same size as the Integer type. This becomes important later on when parsing data from the file.

In the Create_Test_File procedure, we create a file containing integer values, which is parsed later by the Read_Display_States procedure. The Create_Test_File procedure doesn't contain any reference to the State type, so we're not constrained to just writing information that is valid for this type. On the contrary, this procedure makes use of the Integer type, so we can write any integer value to the file. We use this strategy to write both valid and invalid values of State to the file. This allows us to simulate an environment where transmission errors occur.

We call the Read_Display_States procedure to read information from the file and display each state stored in the file. In the main loop of this procedure, we call Read to read a state from the file and store it in the S variable. We then call the nested Display_State procedure to display the actual state stored in S. The most important line of code in the Display_State procedure is the one that uses the Valid attribute:

if S'Valid then

In this line, we're verifying that the S variable contains a valid state before displaying the actual information from S. If the value stored in S isn't valid, we can handle the issue accordingly. In this case, we're simply displaying a message indicating that an invalid value was detected. If we didn't have this check, the Constraint_Error exception would be raised when trying to use invalid data stored in S — this would happen, for example, after reading the integer value 3 from the input file.

In summary, using the Valid attribute is a good strategy we can employ when we know that information stored in memory might be corrupted.

In the Ada Reference Manual

Unchecked Union

We've introduced variant records back in the Introduction to Ada course. 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.

The State_Or_Integer declaration in the States package below is an example of a variant record:

package States is type State is (Off, On, Waiting) with Size => Integer'Size; for State use (Off => 1, On => 2, Waiting => 4); type State_Or_Integer (Use_Enum : Boolean) is record case Use_Enum is when False => I : Integer; when True => S : State; end case; end record; procedure Display_State_Value (V : State_Or_Integer); end States;
with Ada.Text_IO; use Ada.Text_IO; package body States is procedure Display_State_Value (V : State_Or_Integer) is begin Put_Line ("State: " & V.S'Image); Put_Line ("Value: " & V.I'Image); end Display_State_Value; end States;

As mentioned in the previous course, if you try to access a component that is not valid for your record, a Constraint_Error exception is raised. For example, in the implementation of the Display_State_Value procedure, we're trying to retrieve the value of the integer component (I) of the V record. When calling this procedure, the Constraint_Error exception is raised as expected because Use_Enum is set to True, so that the I component is invalid — only the S component is valid in this case.

with States; use States; procedure Show_Variant_Rec_Error is V : State_Or_Integer (Use_Enum => True); begin V.S := On; Display_State_Value (V); end Show_Variant_Rec_Error;

In addition to not being able to read the value of a component that isn't valid, assigning a value to a component that isn't valid also raises an exception at runtime. In this example, we cannot assign to V.I:

with States; use States; procedure Show_Variant_Rec_Error is V : State_Or_Integer (Use_Enum => True); begin V.I := 4; -- Error: V.I cannot be accessed because Use_Enum is set to True. end Show_Variant_Rec_Error;

We may circumvent this limitation by using the Unchecked_Union aspect. For example, we can derive a new type from State_Or_Integer and use this aspect in its declaration. We do this in the declaration of the Unchecked_State_Or_Integer type below.

package States is type State is (Off, On, Waiting) with Size => Integer'Size; for State use (Off => 1, On => 2, Waiting => 4); type State_Or_Integer (Use_Enum : Boolean) is record case Use_Enum is when False => I : Integer; when True => S : State; end case; end record; type Unchecked_State_Or_Integer (Use_Enum : Boolean) is new State_Or_Integer (Use_Enum) with Unchecked_Union; procedure Display_State_Value (V : Unchecked_State_Or_Integer); end States;
with Ada.Text_IO; use Ada.Text_IO; package body States is procedure Display_State_Value (V : Unchecked_State_Or_Integer) is begin Put_Line ("State: " & V.S'Image); Put_Line ("Value: " & V.I'Image); end Display_State_Value; end States;

Because we now use the Unchecked_State_Or_Integer type for the input parameter of the Display_State_Value procedure, no exception is raised at runtime, as both components are now accessible. For example:

with States; use States; procedure Show_Unchecked_Union is V : State_Or_Integer (Use_Enum => True); begin V.S := On; Display_State_Value (Unchecked_State_Or_Integer (V)); end Show_Unchecked_Union;

Note that, in the call to the Display_State_Value procedure, we first need to convert the V argument from the State_Or_Integer to the Unchecked_State_Or_Integer type.

Also, we can assign to any of the components of a record that has the Unchecked_Union aspect. In our example, we can now assign to both the S and the I components of the V record:

with States; use States; procedure Show_Unchecked_Union is V : Unchecked_State_Or_Integer (Use_Enum => True); begin V := (Use_Enum => True, S => On); Display_State_Value (V); V := (Use_Enum => False, I => 4); Display_State_Value (V); end Show_Unchecked_Union;

In the example above, we're use an aggregate in the assignments to V. By doing so, we avoid that Use_Enum is set to the wrong component. For example:

with States; use States; procedure Show_Unchecked_Union is V : Unchecked_State_Or_Integer (Use_Enum => True); begin V.S := On; Display_State_Value (V); V.I := 4; -- Error: cannot directly assign to V.I, as Use_Enum is -- set to True. Display_State_Value (V); end Show_Unchecked_Union;

Here, even though the record has the Unchecked_Union attribute, we cannot directly assign to the I component because Use_Enum is set to True, so only the S is accessible. We can, however, read its value, as we do in the Display_State_Value procedure.

Be aware that, due to the fact the union is not checked, we might write invalid data to the record. In the example below, we initialize the I component with 3, which is a valid integer value, but results in an invalid value for the S component, as the value 3 cannot be mapped to the representation of the State type.

with States; use States; procedure Show_Unchecked_Union is V : Unchecked_State_Or_Integer (Use_Enum => True); begin V := (Use_Enum => False, I => 3); Display_State_Value (V); end Show_Unchecked_Union;

To mitigate this problem, we could use the Valid attribute — discussed in the previous section — for the S component before trying to use its value in the implementation of the Display_State_Value procedure:

with Ada.Text_IO; use Ada.Text_IO; package body States is procedure Display_State_Value (V : Unchecked_State_Or_Integer) is begin if V.S'Valid then Put_Line ("State: " & V.S'Image); else Put_Line ("State: <invalid>"); end if; Put_Line ("Value: " & V.I'Image); end Display_State_Value; end States;
with States; use States; procedure Show_Unchecked_Union is V : Unchecked_State_Or_Integer (Use_Enum => True); begin V := (Use_Enum => False, I => 3); Display_State_Value (V); end Show_Unchecked_Union;

However, in general, you should avoid using the Unchecked_Union aspect due to the potential issues you might introduce into your application. In the majority of the cases, you don't need it at all — except for special cases such as when interfacing with C code that makes use of union types or solving very specific problems when doing low-level programming.

In the Ada Reference Manual

Shared variable control

Ada has built-in support for handling both volatile and atomic data. Let's start by discussing volatile objects.

In the Ada Reference Manual

Volatile

A volatile object can be described as an object in memory whose value may change between two consecutive memory accesses of a process A — even if process A itself hasn't changed the value. This situation may arise when an object in memory is being shared by multiple threads. For example, a thread B may modify the value of that object between two read accesses of a thread A. Another typical example is the one of memory-mapped I/O, where the hardware might be constantly changing the value of an object in memory.

Because the value of a volatile object may be constantly changing, a compiler cannot generate code to store the value of that object in a register and then use the value from the register in subsequent operations. Storing into a register is avoided because, if the value is stored there, it would be outdated if another process had changed the volatile object in the meantime. Instead, the compiler generates code in such a way that the process must read the value of the volatile object from memory for each access.

Let's look at a simple example:

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Volatile_Object is Val : Long_Float with Volatile; begin Val := 0.0; for I in 0 .. 999 loop Val := Val + 2.0 * Long_Float (I); end loop; Put_Line ("Val: " & Long_Float'Image (Val)); end Show_Volatile_Object;

In this example, Val has the Volatile aspect, which makes the object volatile. We can also use the Volatile aspect in type declarations. For example:

package Shared_Var_Types is type Volatile_Long_Float is new Long_Float with Volatile; end Shared_Var_Types;
with Ada.Text_IO; use Ada.Text_IO; with Shared_Var_Types; use Shared_Var_Types; procedure Show_Volatile_Type is Val : Volatile_Long_Float; begin Val := 0.0; for I in 0 .. 999 loop Val := Val + 2.0 * Volatile_Long_Float (I); end loop; Put_Line ("Val: " & Volatile_Long_Float'Image (Val)); end Show_Volatile_Type;

Here, we're declaring a new type Volatile_Long_Float in the Shared_Var_Types package. This type is based on the Long_Float type and uses the Volatile aspect. Any object of this type is automatically volatile.

In addition to that, we can declare components of an array to be volatile. In this case, we can use the Volatile_Components aspect in the array declaration. For example:

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Volatile_Array_Components is Arr : array (1 .. 2) of Long_Float with Volatile_Components; begin Arr := (others => 0.0); for I in 0 .. 999 loop Arr (1) := Arr (1) + 2.0 * Long_Float (I); Arr (2) := Arr (2) + 10.0 * Long_Float (I); end loop; Put_Line ("Arr (1): " & Long_Float'Image (Arr (1))); Put_Line ("Arr (2): " & Long_Float'Image (Arr (2))); end Show_Volatile_Array_Components;

Note that it's possible to use the Volatile aspect for the array declaration as well:

package Shared_Var_Types is private Arr : array (1 .. 2) of Long_Float with Volatile; end Shared_Var_Types;

Note that, if the Volatile aspect is specified for an object, then the Volatile_Components aspect is also specified automatically — if it makes sense in the context, of course. In the example above, even though Volatile_Components isn't specified in the declaration of the Arr array , it's automatically set as well.

Independent

When you write code to access a single object in memory, you might actually be accessing multiple objects at once. For example, when you declare types that make use of representation clauses — as we've seen in previous sections —, you might be accessing multiple objects that are grouped together in a single storage unit. For example, if you have components A and B stored in the same storage unit, you cannot update A without actually writing (the same value) to B. Those objects aren't independently addressable because, in order to access one of them, we have to actually address multiple objects at once.

When an object is independently addressable, we call it an independent object. In this case, we make sure that, when accessing that object, we won't be simultaneously accessing another object. As a consequence, this feature limits the way objects can be represented in memory, as we'll see next.

To indicate that an object is independent, we use the Independent aspect:

package Shared_Var_Types is I : Integer with Independent; end Shared_Var_Types;

Similarly, we can use this aspect when declaring types:

package Shared_Var_Types is type Independent_Boolean is new Boolean with Independent; type Flags is record F1 : Independent_Boolean; F2 : Independent_Boolean; end record; end Shared_Var_Types;

In this example, we're declaring the Independent_Boolean type and using it in the declaration of the Flag record type. Let's now derive the Flags type and use a representation clause for the derived type:

package Shared_Var_Types.Representation is type Rep_Flags is new Flags; for Rep_Flags use record F1 at 0 range 0 .. 0; F2 at 0 range 1 .. 1; -- ^ ERROR: start position of F2 -- is wrong! -- ^ ERROR: F1 and F2 share the -- same storage unit! end record; end Shared_Var_Types.Representation;

As you can see when trying to compile this example, the representation clause that we used for Rep_Flags isn't following these limitations:

  1. The size of each independent component must be a multiple of a storage unit.

  2. The start position of each independent component must be a multiple of a storage unit.

For example, for architectures that have a storage unit of one byte — such as standard desktop computers —, this means that the size and the position of independent components must be a multiple of a byte. Let's correct the issues in the code above by:

  • setting the size of each independent component to correspond to Storage_Unit — using a range between 0 and Storage_Unit - 1 —, and

  • setting the start position to zero.

This is the corrected version:

with System; package Shared_Var_Types.Representation is type Rep_Flags is new Flags; for Rep_Flags use record F1 at 0 range 0 .. System.Storage_Unit - 1; F2 at 1 range 0 .. System.Storage_Unit - 1; end record; end Shared_Var_Types.Representation;

Note that the representation that we're now using for Rep_Flags is most likely the representation that the compiler would have chosen for this data type. We could, however, have added an empty storage unit between F1 and F2 — by simply writing F2 at 2 ...:

with System; package Shared_Var_Types.Representation is type Rep_Flags is new Flags; for Rep_Flags use record F1 at 0 range 0 .. System.Storage_Unit - 1; F2 at 2 range 0 .. System.Storage_Unit - 1; end record; end Shared_Var_Types.Representation;

As long as we follow the rules for independent objects, we're still allowed to use representation clauses that don't correspond to the one that the compiler might select.

For arrays, we can use the Independent_Components aspect:

package Shared_Var_Types is Flags : array (1 .. 8) of Boolean with Independent_Components; end Shared_Var_Types;

We've just seen in a previous example that some representation clauses might not work with objects and types that have the Independent aspect. The same restrictions apply when we use the Independent_Components aspect. For example, this aspect prevents that array components are packed when the Pack aspect is used. Let's discuss the following erroneous code example:

package Shared_Var_Types is type Flags is array (Positive range <>) of Boolean with Independent_Components, Pack; F : Flags (1 .. 8) with Size => 8; end Shared_Var_Types;

As expected, this code doesn't compile. Here, we can have either independent components, or packed components. We cannot have both at the same time because packed components aren't independently addressable. The compiler warns us that the Pack aspect won't have any effect on independent components. When we use the Size aspect in the declaration of F, we confirm this limitation. If we remove the Size aspect, however, the code is compiled successfully because the compiler ignores the Pack aspect and allocates a larger size for F:

package Shared_Var_Types is type Flags is array (Positive range <>) of Boolean with Independent_Components, Pack; end Shared_Var_Types;
with Ada.Text_IO; use Ada.Text_IO; with System; with Shared_Var_Types; use Shared_Var_Types; procedure Show_Flags_Size is F : Flags (1 .. 8); begin Put_Line ("Flags'Size: " & F'Size'Image & " bits"); Put_Line ("Flags (1)'Size: " & F (1)'Size'Image & " bits"); Put_Line ("# storage units: " & Integer'Image (F'Size / System.Storage_Unit)); end Show_Flags_Size;

As you can see in the output of the application, even though we specify the Pack aspect for the Flags type, the compiler allocates eight storage units, one per each component of the F array.

Atomic

An atomic object is an object that only accepts atomic reads and updates. The Ada standard specifies that "for an atomic object (including an atomic component), all reads and updates of the object as a whole are indivisible." In this case, the compiler must generate Assembly code in such a way that reads and updates of an atomic object must be done in a single instruction, so that no other instruction could execute on that same object before the read or update completes.

In other contexts

Generally, we can say that operations are said to be atomic when they can be completed without interruptions. This is an important requirement when we're performing operations on objects in memory that are shared between multiple processes.

This definition of atomicity above is used, for example, when implementing databases. However, for this section, we're using the term "atomic" differently. Here, it really means that reads and updates must be performed with a single Assembly instruction.

For example, if we have a 32-bit object composed of four 8-bit bytes, the compiler cannot generate code to read or update the object using four 8-bit store / load instructions, or even two 16-bit store / load instructions. In this case, in order to maintain atomicity, the compiler must generate code using one 32-bit store / load instruction.

Because of this strict definition, we might have objects for which the Atomic aspect cannot be specified. Lots of machines support integer types that are larger than the native word-sized integer. For example, a 16-bit machine probably supports both 16-bit and 32-bit integers, but only 16-bit integer objects can be marked as atomic — or, more generally, only objects that fit into at most 16 bits.

Atomicity may be important, for example, when dealing with shared hardware registers. In fact, for certain architectures, the hardware may require that memory-mapped registers are handled atomically. In Ada, we can use the Atomic aspect to indicate that an object is atomic. This is how we can use the aspect to declare a shared hardware register:

with System; package Shared_Var_Types is private R : Integer with Atomic, Address => System'To_Address (16#FFFF00A0#); end Shared_Var_Types;

Note that the Address aspect allows for assigning a variable to a specific location in the memory. In this example, we're using this aspect to specify the address of the memory-mapped register.

In addition to atomic objects, we can declare atomic types — similar to what we've seen before for volatile objects. For example:

with System; package Shared_Var_Types is type Atomic_Integer is new Integer with Atomic; private R : Atomic_Integer with Address => System'To_Address (16#FFFF00A0#); end Shared_Var_Types;

In this example, we're declaring the Atomic_Integer type, which is an atomic type. Objects of this type — such as R in this example — are automatically atomic.

We can also declare atomic array components:

package Shared_Var_Types is private Arr : array (1 .. 2) of Integer with Atomic_Components; end Shared_Var_Types;

This example shows the declaration of the Arr array, which has atomic components — the atomicity of its components is indicated by the Atomic_Components aspect.

Note that if an object is atomic, it is also volatile and independent. In other words, these type declarations are equivalent:

package Shared_Var_Types is type Atomic_Integer_1 is new Integer with Atomic; type Atomic_Integer_2 is new Integer with Atomic, Volatile, Independent; end Shared_Var_Types;

A simular rule applies to components of an array. When we use the Atomic_Components, the following aspects are implied: Volatile, Volatile_Components and Independent_Components. For example, these array declarations are equivalent:

package Shared_Var_Types is Arr_1 : array (1 .. 2) of Integer with Atomic_Components; Arr_2 : array (1 .. 2) of Integer with Atomic_Components, Volatile, Volatile_Components, Independent_Components; end Shared_Var_Types;