More about types
Aggregates: A primer
So far, we have talked about aggregates quite a bit and have seen a number of examples. Now we will revisit this feature in some more detail.
An Ada aggregate is, in effect, a literal value for a composite type. It's a very powerful notation that helps you to avoid writing procedural code for the initialization of your data structures in many cases.
A basic rule when writing aggregates is that every component of the array or record has to be specified, even components that have a default value.
This means that the following code is incorrect:
There are a few shortcuts that you can use to make the notation more convenient:
To specify the default value for a component, you can use the
<>
notation.You can use the
|
symbol to give several components the same value.You can use the
others
choice to refer to every component that has not yet been specified, provided all those fields have the same type.You can use the range notation
..
to refer to specify a contiguous sequence of indices in an array.
However, note that as soon as you used a named association, all subsequent components likewise need to be specified with named associations.
Overloading and qualified expressions
Ada has a general concept of name overloading, which we saw earlier in the section on enumeration types.
Let's take a simple example: it is possible in Ada to have functions that have the same name, but different types for their parameters.
This is a common concept in programming languages, called overloading, or name overloading.
One of the novel aspects of Ada's overloading facility is the ability to resolve overloading based on the return type of a function.
Attention
Note that overload resolution based on the type is allowed for both functions and enumeration literals in Ada - which is why you can have multiple enumeration literals with the same name. Semantically, an enumeration literal is treated like a function that has no parameters.
However, sometimes an ambiguity makes it impossible to resolve which declaration of an overloaded name a given occurrence of the name refers to. This is where a qualified expression becomes useful.
Syntactically the target of a qualified expression can be either any expression in parentheses, or an aggregate:
This illustrates that qualified expressions are a convenient (and sometimes necessary) way for the programmer to make the type of an expression explicit, for the compiler of course, but also for other programmers.
Attention
While they look and feel similar, type conversions and qualified expressions are not the same.
A qualified expression specifies the exact type that the target expression will be resolved to, whereas a type conversion will try to convert the target and issue a run-time error if the target value cannot be so converted.
Note that you can use a qualified expression to convert from one subtype to another, with an exception raised if a constraint is violated.
X : Integer := Natural'(1);
Access types (pointers)
Pointers are a potentially dangerous construct, which conflicts with Ada's underlying philosophy.
There are two ways in which Ada helps shield programmers from the dangers of pointers:
One approach, which we have already seen, is to provide alternative features so that the programmer does not need to use pointers. Parameter modes, arrays, and varying size types are all constructs that can replace typical pointer usages in C.
Second, Ada has made pointers as safe and restricted as possible, but allows "escape hatches" when the programmer explicitly requests them and presumably will be exercising such features with appropriate care.
Here is how you declare a simple pointer type, or access type, in Ada:
This illustrates how to:
Declare an access type whose values point to ("designate") objects from a specific type
Declare a variable (access value) from this access type
Give it a value of
null
In line with Ada's strong typing philosophy, if you declare a second access type whose designated type is Date, the two access types will be incompatible with each other, and you will need an explicit type conversion to convert from one to the other:
In other languages
In most other languages, pointer types are structurally, not nominally typed, like they are in Ada, which means that two pointer types will be the same as long as they share the same target type and accessibility rules.
Not so in Ada, which takes some time getting used to. A seemingly simple problem is, if you want to have a canonical access to a type, where should it be declared? A commonly used pattern is that if you need an access type to a specific type you "own", you will declare it along with the type:
package Access_Types is
type Point is record
X, Y : Natural;
end record;
type Point_Access is access Point;
end Access_Types;
Allocation (by type)
Once we have declared an access type, we need a way to give variables of the
types a meaningful value! You can allocate a value of an access type
with the new
keyword in Ada.
If the type you want to allocate needs constraints, you can put them in the subtype indication, just as you would do in a variable declaration:
In some cases, though, allocating just by specifying the type is not ideal, so Ada also allows you to initialize along with the allocation. This is done via the qualified expression syntax:
Dereferencing
The last important piece of Ada's access type facility is how to get from an
access value to the object that is pointed to, that is, how to dereference the
pointer. Dereferencing a pointer uses the .all
syntax in Ada, but is
often not needed — in many cases, the access value will be implicitly
dereferenced for you:
Other features
As you might know if you have used pointers in C or C++, we are still missing features that are considered fundamental to the use of pointers, such as:
Pointer arithmetic (being able to increment or decrement a pointer in order to point to the next or previous object)
Manual deallocation - what is called
free
ordelete
in C. This is a potentially unsafe operation. To keep within the realm of safe Ada, you need to never deallocate manually.
Those features exist in Ada, but are only available through specific standard library APIs.
Attention
The guideline in Ada is that most of the time you can avoid manual allocation, and you should.
There are many ways to avoid manual allocation, some of which have been covered (such as parameter modes). The language also provides library abstractions to avoid pointers:
One is the use of containers. Containers help users avoid pointers, because container memory is automatically managed.
A container to note in this context is the Indefinite holder. This container allows you to store a value of an indefinite type such as String.
GNATCOLL has a library for smart pointers, called Refcount Those pointers' memory is automatically managed, so that when an allocated object has no more references to it, the memory is automatically deallocated.
Mutually recursive types
The linked list is a common idiom in data structures; in Ada this would be most naturally defined through two types, a record type and an access type, that are mutually dependent. To declare mutually dependent types, you can use an incomplete type declaration:
More about records
Dynamically sized record types
We have previously seen some simple examples of record types. Let's now look at some of the more advanced properties of this fundamental language feature.
One point to note is that object size for a record type does not need to be known at compile time. This is illustrated in the example below:
It is completely fine to determine the size of your records at run time, but note that all objects of this type will have the same size.
Records with discriminant
In the example above, the size of the Items field is determined once, at
run-time, but every Growable_Stack
instance will be exactly the same size.
But maybe that's not what you want to do. We saw that arrays in general offer
this flexibility: for an unconstrained array type, different objects can have
different sizes.
You can get analogous functionality for records, too, using a special kind of field that is called a discriminant:
Discriminants, in their simple forms, are constant: You cannot modify them once you have initialized the object. This intuitively makes sense since they determine the size of the object.
Also, they make a type indefinite: Whether or not the discriminant is used to specify the size of an object, a type with a discriminant will be indefinite if the discriminant is not declared with an initialization:
This also means that, in the example above, you cannot declare an array of Point values, because the size of a Point is not known.
As mentioned in the example above, we could provide a default value for the
discriminants, so that we could legally declare Point
values without
specifying the discriminants. For the example above, this is how it would look:
Also note that, even though the Point
type now has default
discriminants, we can still specify discriminants, as we're doing in the
declarations of P2
and P3
.
In most other respects discriminants behave like regular fields: You have to specify their values in aggregates, as seen above, and you can access their values via the dot notation.
Note
In the examples above, we used a discriminant to determine the size of an array, but it is not limited to that, and could be used, for example, to determine the size of a nested discriminated record.
Variant records
The examples of discriminants thus far have illustrated the declaration of records of varying size, by having components whose size depends on the discriminant.
However, discriminants can also be used to obtain the functionality of what are sometimes called "variant records": records that can contain different sets of fields.
The fields that are in a when
branch will be only available when the
value of the discriminant is covered by the branch. In the example above, you
will only be able to access the fields Left
and Right
when the
Kind
is Bin_Op_Plus
or Bin_Op_Minus
.
If you try to access a field that is not valid for your record, a
Constraint_Error
will be raised.
Here is how you could write an evaluator for expressions:
In other languages
Ada's variant records are very similar to Sum types in functional languages such as OCaml or Haskell. A major difference is that the discriminant is a separate field in Ada, whereas the 'tag' of a Sum type is kind of built in, and only accessible with pattern matching.
There are other differences (you can have several discriminants in a variant record in Ada). Nevertheless, they allow the same kind of type modeling as sum types in functional languages.
Compared to C/C++ unions, Ada variant records are more powerful in what they allow, and are also checked at run time, which makes them safer.
Fixed-point types
Decimal fixed-point types
We have already seen how to specify floating-point types. However, in some applications floating-point is not appropriate since, for example, the roundoff error from binary arithmetic may be unacceptable or perhaps the hardware does not support floating-point instructions. Ada provides a category of types, the decimal fixed-point types, that allows the programmer to specify the required decimal precision (number of digits) as well as the scaling factor (a power of ten) and, optionally, a range. In effect the values will be represented as integers implicitly scaled by the specified power of 10. This is useful, for example, for financial applications.
The syntax for a simple decimal fixed-point type is
type <type-name> is delta <delta-value> digits <digits-value>;
In this case, the delta
and the digits
will be used by the
compiler to derive a range.
Several attributes are useful for dealing with decimal types:
Attribute Name |
Meaning |
---|---|
First |
The first value of the type |
Last |
The last value of the type |
Delta |
The delta value of the type |
In the example below, we declare two data types: T3_D3
and T6_D3
.
For both types, the delta value is the same: 0.001.
When running the application, we see that the delta value of both
types is indeed the same: 0.001. However, because T3_D3
is restricted
to 3 digits, its range is -0.999 to 0.999. For the T6_D3
, we have
defined a precision of 6 digits, so the range is -999.999 to 999.999.
Similar to the type definition using the range
syntax, because we
have an implicit range, the compiled code will check that the variables
contain values that are not out-of-range. Also, if the result of a
multiplication or division on decimal fixed-point types is smaller than
the delta value required for the context, the actual result will be
zero. For example:
In this example, the result of the operation 0.001 * 0.5 is
0.0005. Since this value is not representable for the T3_D3
type
because the delta value is 0.001, the actual value stored in variable
A
is zero. However, accuracy is preserved during the arithmetic
operations if the target has sufficient precision, and the value
displayed for C is 0.000500.
Fixed-point types
Ordinary fixed-point types are similar to decimal fixed-point types in that the
values are, in effect, scaled integers. The difference between them is in the
scale factor: for a decimal fixed-point type, the scaling, given explicitly by
the type's delta
, is always a power of ten.
In contrast, for an ordinary fixed-point type, the scaling is defined by the
type's small
, which is derived from the specified delta
and, by
default, is a power of two. Therefore, ordinary fixed-point types are sometimes
called binary fixed-point types.
Note
Ordinary fixed-point types can be thought of being closer to the actual representation on the machine, since hardware support for decimal fixed-point arithmetic is not widespread (rescalings by a power of ten), while ordinary fixed-point types make use of the available integer shift instructions.
The syntax for an ordinary fixed-point type is
type <type-name> is
delta <delta-value>
range <lower-bound> .. <upper-bound>;
By default the compiler will choose a scale factor, or small
, that is a
power of 2 no greater than <delta-value>.
For example, we may define a normalized range between -1.0 and 1.0 as following:
In this example, we are defining a 32-bit fixed-point data type for our normalized range. When running the application, we notice that the upper bound is close to one, but not exact one. This is a typical effect of fixed-point data types — you can find more details in this discussion about the Q format. We may also rewrite this code with an exact type definition:
We may also use any other range. For example:
In this example, we are defining a 16-bit type called T_Inv_Trig
,
which has a range from -π/2 to π/2.
All standard operations are available for fixed-point types. For example:
As expected, R
contains 0.75 after the addition of A
and B
.
In fact the language is more general than these examples imply, since in practice it is typical to need to multiply or divide values from different fixed-point types, and obtain a result that may be of a third fixed-point type. The details are outside the scope of this introductory course.
It is also worth noting, although again the details are outside the scope of
this course, that you can explicitly specify a value for an ordinary
fixed-point type's small
. This allows non-binary scaling, for example:
type Angle is
delta 1.0/3600.0
range 0.0 .. 360.0 - 1.0 / 3600.0;
for Angle'Small use Angle'Delta;
Character types
As noted earlier, each enumeration type is distinct and incompatible with every other enumeration type. However, what we did not mention previously is that character literals are permitted as enumeration literals. This means that in addition to the language's strongly typed character types, user-defined character types are also permitted: