The C Developer's Perspective on Ada

What we mean by Embedded Software

The Ada programming language is a general programming language, which means it can be used for many different types of applications. One type of application where it particularly shines is reliable and safety-critical embedded software; meaning, a platform with a microprocessor such as ARM, PowerPC, x86, or RISC-V. The application may be running on top of an embedded operating system, such as an embedded Linux, or directly on bare metal. And the application domain can range from small entities such as firmware or device controllers to flight management systems, communication based train control systems, or advanced driver assistance systems.

The GNAT Toolchain

The toolchain used throughout this course is called GNAT, which is a suite of tools with a compiler based on the GCC environment. It can be obtained from AdaCore, either as part of a commercial contract with GNAT Pro or at no charge with the GNAT Community edition. The information in this course will be relevant no matter which edition you're using. Most examples will be runnable on the native Linux or Windows version for convenience. Some will only be relevant in the context of a cross toolchain, in which case we'll be using the embedded ARM bare metal toolchain.

As for any Ada compiler, GNAT takes advantage of implementation permissions and offers a project management system. Because we're talking about embedded platforms, there are a lot of topics that we'll go over which will be specific to GNAT, and sometimes to specific platforms supported by GNAT. We'll try to make the distinction between what is GNAT-specific and Ada generic as much as possible throughout this course.

For an introduction to the GNAT Toolchain for the GNAT Community edition, you may refer to the Introduction to GNAT Toolchain course.

The GNAT Toolchain for Embedded Targets

When we're discussing embedded programming, our target device is often different from the host, which is the device we're using to actually write and build an application. In this case, we're talking about cross compilation platforms (concisely referred to as cross platforms).

The GNAT toolchain supports cross platform compilation for various target devices. This section provides a short introduction to the topic. For more details, please refer to the GNAT User’s Guide Supplement for Cross Platforms

GNAT supports two types of cross platforms:

  • cross targets, where the target device has an embedded operating system.

    • ARM-Linux, which is commonly found in a Raspberry-Pi, is a prominent example.

  • bareboard targets, where the run-times do not depend on an operating system.

    • In this case, the application has direct access to the system hardware.

For each platform, a set of run-time libraries is available. Run-time libraries implement a subset of the Ada language for different use cases, and they're different for each target platform. They may be selected via an attribute in the project's GPR project file or as a command-line switch to GPRbuild. Although the run-time libraries may vary from target to target, the user interface stays the same, providing portability for the application.

Run-time libraries consists of:

  1. Files that are dependent on the target board.

    • These files are responsible for configuring and interacting with the hardware.

    • They are known as a Board Support Package — commonly referred to by their abbrevation BSP.

  2. Code that is target-independent.

    • This code implements language-defined functionality.

The bareboard run-time libraries are provided as customized run-times that are configured to target a very specific micro-controller or processor. Therefore, for different micro-controllers and processors, the run-time libraries need to be ported to the specific target. These are some examples of what needs to be ported:

  • startup code / scripts;

  • clock frequency initializations;

  • memory mapping / allocation;

  • interrupts and interrupt priorities;

  • register descriptions.

For more details on the topic, please refer to the following chapters of the GNAT User’s Guide Supplement for Cross Platforms:

Hello World in Ada

The first piece of code to translate from C to Ada is the usual Hello World program:

[C]

    
    
    
        
#include <stdio.h> int main(int argc, const char * argv[]) { printf("Hello World\n"); return 0; }

[Ada]

    
    
    
        
with Ada.Text_IO; procedure Hello_World is begin Ada.Text_IO.Put_Line ("Hello World"); end Hello_World;

The resulting program will print Hello World on the screen. Let's now dissect the Ada version to describe what is going on:

The first line of the Ada code is giving us access to the Ada.Text_IO library which contains the Put_Line function we will use to print the text to the console. This is similar to C's #include <stdio.h>. We then create a procedure which executes Put_Line which prints to the console. This is similar to C's printf function. For now, we can assume these Ada and C features have similar functionality. In reality, they are very different. We will explore that more as we delve further into the Ada language.

You may have noticed that the Ada syntax is more verbose than C. Instead of using braces {} to declare scope, Ada uses keywords. is opens a declarative scope — which is empty here as there's no variable to declare. begin opens a sequence of statements. Within this sequence, we're calling the function Put_Line, prefixing explicitly with the name of the library unit where it's declared, Ada.Text_IO. The absence of the end of line \n can also be noted, as Put_Line always terminates by an end of line.

The Ada Syntax

Ada syntax might seem peculiar at first glance. Unlike many other languages, it's not derived from the popular C style of notation with its ample use of brackets; rather, it uses a more expository syntax coming from Pascal. In many ways, Ada is a more explicit language — its syntax was designed to increase readability and maintainability, rather than making it faster to write in a condensed manner. For example:

  • full words like begin and end are used in place of curly braces.

  • Conditions are written using if, then, elsif, else, and end if.

  • Ada's assignment operator does not double as an expression, eliminating potential mistakes that could be caused by = being used where == should be.

All languages provide one or more ways to express comments. In Ada, two consecutive hyphens -- mark the start of a comment that continues to the end of the line. This is exactly the same as using // for comments in C. Multi line comments like C's /* */ do not exist in Ada.

Ada compilers are stricter with type and range checking than most C programmers are used to. Most beginning Ada programmers encounter a variety of warnings and error messages when coding, but this helps detect problems and vulnerabilities at compile time — early on in the development cycle. In addition, checks (such as array bounds checks) provide verification that could not be done at compile time but can be performed either at run-time, or through formal proof (with the SPARK tooling).

Ada identifiers and reserved words are case insensitive. The identifiers VAR, var and VaR are treated as the same identifier; likewise begin, BEGIN, Begin, etc. Identifiers may include letters, digits, and underscores, but must always start with a letter. There are 73 reserved keywords in Ada that may not be used as identifiers, and these are:

abort

else

null

select

abs

elsif

of

separate

abstract

end

or

some

accept

entry

others

subtype

access

exception

out

synchronized

aliased

exit

overriding

tagged

all

for

package

task

and

function

pragma

terminate

array

generic

private

then

at

goto

procedure

type

begin

if

protected

until

body

in

raise

use

case

interface

range

when

constant

is

record

while

declare

limited

rem

with

delay

loop

renames

xor

delta

mod

requeue

digits

new

return

do

not

reverse

Compilation Unit Structure

Both C and Ada were designed with the idea that the code specification and code implementation could be separated into two files. In C, the specification typically lives in the .h, or header file, and the implementation lives in the .c file. Ada is superficially similar to C. With the GNAT toolchain, compilation units are stored in files with an .ads extension for specifications and with an .adb extension for implementations.

One main difference between the C and Ada compilation structure is that Ada compilation units are structured into something called packages.

Packages

The package is the basic modularization unit of the Ada language, as is the class for Java and the header and implementation pair for C. A specification defines a package and the implementation implements the package. We saw this in an earlier example when we included the Ada.Text_IO package into our application. The package specification has the structure:

[Ada]

--  my_package.ads
package My_Package is

   --  public declarations

private

   --  private declarations

end My_Package;

The package implementation, or body, has the structure:

--  my_package.adb
package body My_Package is

   --  implementation

end My_Package;

Declaration Protection

An Ada package contains three parts that, for GNAT, are separated into two files: .ads files contain public and private Ada specifications, and .adb files contain the implementation, or Ada bodies.

[Ada]

package Package_Name is
   -- public specifications
private
   -- private specifications
end Package_Name;

package body Package_Name is
   -- implementation
end Package_Name;

Private types are useful for preventing the users of a package's types from depending on the types' implementation details. Another use-case is the prevention of package users from accessing package state/data arbitrarily. The private reserved word splits the package spec into public and private parts. For example:

[Ada]

    
    
    
        
package Types is type Type_1 is private; type Type_2 is private; type Type_3 is private; procedure P (X : Type_1); -- ... private procedure Q (Y : Type_1); type Type_1 is new Integer range 1 .. 1000; type Type_2 is array (Integer range 1 .. 1000) of Integer; type Type_3 is record A, B : Integer; end record; end Types;

Subprograms declared above the private separator (such as P) will be visible to the package user, and the ones below (such as Q) will not. The body of the package, the implementation, has access to both parts. A package specification does not require a private section.

Hierarchical Packages

Ada packages can be organized into hierarchies. A child unit can be declared in the following way:

[Ada]

-- root-child.ads

package Root.Child is
   --  package spec goes here
end Root.Child;

-- root-child.adb

package body Root.Child is
   --  package body goes here
end Root.Child;

Here, Root.Child is a child package of Root. The public part of Root.Child has access to the public part of Root. The private part of Child has access to the private part of Root, which is one of the main advantages of child packages. However, there is no visibility relationship between the two bodies. One common way to use this capability is to define subsystems around a hierarchical naming scheme.

Using Entities from Packages

Entities declared in the visible part of a package specification can be made accessible using a with clause that references the package, which is similar to the C #include directive. After a with clause makes a package available, references to the package contents require the name of the package as a prefix, with a dot after the package name. This prefix can be omitted if a use clause is employed.

[Ada]

    
    
    
        
-- pck.ads package Pck is My_Glob : Integer; end Pck;
-- main.adb with Pck; procedure Main is begin Pck.My_Glob := 0; end Main;

In contrast to C, the Ada with clause is a semantic inclusion mechanism rather than a text inclusion mechanism; for more information on this difference please refer to Packages.

Statements and Declarations

The following code samples are all equivalent, and illustrate the use of comments and working with integer variables:

[C]

    
    
    
        
#include <stdio.h> int main(int argc, const char * argv[]) { // variable declarations int a = 0, b = 0, c = 100, d; // c shorthand for increment a++; // regular addition d = a + b + c; // printing the result printf("d = %d\n", d); return 0; }

[Ada]

    
    
    
        
with Ada.Text_IO; procedure Main is -- variable declaration A, B : Integer := 0; C : Integer := 100; D : Integer; begin -- Ada does not have a shortcut format for increment like in C A := A + 1; -- regular addition D := A + B + C; -- printing the result Ada.Text_IO.Put_Line ("D =" & D'Img); end Main;

You'll notice that, in both languages, statements are terminated with a semicolon. This means that you can have multi-line statements.

The shortcuts of incrementing and decrementing

You may have noticed that Ada does not have something similar to the a++ or a-- operators. Instead you must use the full assignment A := A + 1 or A := A - 1.

In the Ada example above, there are two distinct sections to the procedure Main. This first section is delimited by the is keyword and the begin keyword. This section is called the declarative block of the subprogram. The declarative block is where you will define all the local variables which will be used in the subprogram. C89 had something similar, where developers were required to declare their variables at the top of the scope block. Most C developers may have run into this before when trying to write a for loop:

[C]

    
    
    
        
/* The C89 version */ #include <stdio.h> int average(int* list, int length) { int i; int sum = 0; for(i = 0; i < length; ++i) { sum += list[i]; } return (sum / length); } int main(int argc, const char * argv[]) { int vals[] = { 2, 2, 4, 4 }; printf("Average: %d\n", average(vals, 4)); return 0; }

[C]

    
    
    
        
// The modern C way #include <stdio.h> int average(int* list, int length) { int sum = 0; for(int i = 0; i < length; ++i) { sum += list[i]; } return (sum / length); } int main(int argc, const char * argv[]) { int vals[] = { 2, 2, 4, 4 }; printf("Average: %d\n", average(vals, 4)); return 0; }

For the fun of it, let's also see the Ada way to do this:

[Ada]

    
    
    
        
with Ada.Text_IO; procedure Main is type Int_Array is array (Natural range <>) of Integer; function Average (List : Int_Array) return Integer is Sum : Integer := 0; begin for I in List'Range loop Sum := Sum + List (I); end loop; return (Sum / List'Length); end Average; Vals : constant Int_Array (1 .. 4) := (2, 2, 4, 4); begin Ada.Text_IO.Put_Line ("Average: " & Integer'Image (Average (Vals))); end Main;

We will explore more about the syntax of loops in Ada in a future section of this course; but for now, notice that the I variable used as the loop index is not declared in the declarative section!

Declaration Flippy Floppy

Something peculiar that you may have noticed about declarations in Ada is that they are backwards from the way C does declarations. The C language expects the type followed by the variable name. Ada expects the variable name followed by a colon and then the type.

The next block in the Ada example is between the begin and end keywords. This is where your statements will live. You can create new scopes by using the declare keyword:

[Ada]

    
    
    
        
with Ada.Text_IO; procedure Main is -- variable declaration A, B : Integer := 0; C : Integer := 100; D : Integer; begin -- Ada does not have a shortcut format for increment like in C A := A + 1; -- regular addition D := A + B + C; -- printing the result Ada.Text_IO.Put_Line ("D =" & D'Img); declare E : constant Integer := D * 100; begin -- printing the result Ada.Text_IO.Put_Line ("E =" & E'Img); end; end Main;

Notice that we declared a new variable E whose scope only exists in our newly defined block. The equivalent C code is:

[C]

    
    
    
        
#include <stdio.h> int main(int argc, const char * argv[]) { // variable declarations int a = 0, b = 0, c = 100, d; // c shorthand for increment a++; // regular addition d = a + b + c; // printing the result printf("d = %d\n", d); { const int e = d * 100; printf("e = %d\n", e); } return 0; }

Fun Fact about the C language assignment operator =: Did you know that an assignment in C can be used in an expression? Let's look at an example:

[C]

    
    
    
        
#include <stdio.h> int main(int argc, const char * argv[]) { int a = 0; if (a = 10) printf("True\n"); else printf("False\n"); return 0; }

Run the above code example. What does it output? Is that what you were expecting?

The author of the above code example probably meant to test if a == 10 in the if statement but accidentally typed = instead of ==. Because C treats assignment as an expression, it was able to evaluate a = 10.

Let's look at the equivalent Ada code:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is A : Integer := 0; begin if A := 10 then Put_Line ("True"); else Put_Line ("False"); end if; end Main;

The above code will not compile. This is because Ada does no allow assignment as an expression.

The "use" clause

You'll notice in the above code example, after with Ada.Text_IO; there is a new statement we haven't seen before — use Ada.Text_IO;. You may also notice that we are not using the Ada.Text_IO prefix before the Put_Line statements. When we add the use clause it tells the compiler that we won't be using the prefix in the call to subprograms of that package. The use clause is something to use with caution. For example: if we use the Ada.Text_IO package and we also have a Put_Line subprogram in our current compilation unit with the same signature, we have a (potential) collision!

Conditions

The syntax of an if statement:

[C]

    
    
    
        
#include <stdio.h> int main(int argc, const char * argv[]) { // try changing the initial value to change the // output of the program int v = 0; if (v > 0) { printf("Positive\n"); } else if (v < 0) { printf("Negative\n"); } else { printf("Zero\n"); } return 0; }

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is -- try changing the initial value to change the -- output of the program V : constant Integer := 0; begin if V > 0 then Put_Line ("Positive"); elsif V < 0 then Put_Line ("Negative"); else Put_Line ("Zero"); end if; end Main;

In Ada, everything that appears between the if and then keywords is the conditional expression, no parentheses are required. Comparison operators are the same except for:

Operator

C

Ada

Equality

==

=

Inequality

!=

/=

Not

!

not

And

&&

and

Or

||

or

The syntax of a switch/case statement:

[C]

    
    
    
        
#include <stdio.h> int main(int argc, const char * argv[]) { // try changing the initial value to change the // output of the program int v = 0; switch(v) { case 0: printf("Zero\n"); break; case 1: case 2: case 3: case 4: case 5: case 6: case 7: case 8: case 9: printf("Positive\n"); break; case 10: case 12: case 14: case 16: case 18: printf("Even number between 10 and 18\n"); break; default: printf("Something else\n"); break; } return 0; }

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is -- try changing the initial value to change the -- output of the program V : constant Integer := 0; begin case V is when 0 => Put_Line ("Zero"); when 1 .. 9 => Put_Line ("Positive"); when 10 | 12 | 14 | 16 | 18 => Put_Line ("Even number between 10 and 18"); when others => Put_Line ("Something else"); end case; end Main;

Switch or Case?

A switch statement in C is the same as a case statement in Ada. This may be a little strange because C uses both keywords in the statement syntax. Let's make an analogy between C and Ada: C's switch is to Ada's case as C's case is to Ada's when.

Notice that in Ada, the case statement does not use the break keyword. In C, we use break to stop the execution of a case branch from falling through to the next branch. Here is an example:

[C]

    
    
    
        
#include <stdio.h> int main(int argc, const char * argv[]) { int v = 0; switch(v) { case 0: printf("Zero\n"); case 1: printf("One\n"); default: printf("Other\n"); } return 0; }

Run the above code with v = 0. What prints? What prints when we change the assignment to v = 1?

When v = 0 the program outputs the strings Zero then One then Other. This is called fall through. If you add the break statements back into the switch you can stop this fall through behavior from happening. The reason why fall through is allowed in C is to allow the behavior from the previous example where we want a specific branch to execute for multiple inputs. Ada solves this a different way because it is possible, or even probable, that the developer might forget a break statement accidentally. So Ada does not allow fall through. Instead, you can use Ada's syntax to identify when a specific branch can be executed by more than one input. If you want a range of values for a specific branch you can use the First .. Last notation. If you want a few non-consecutive values you can use the Value1 | Value2 | Value3 notation.

Instead of using the word default to denote the catch-all case, Ada uses the others keyword.

Loops

Let's start with some syntax:

[C]

    
    
    
        
#include <stdio.h> int main(int argc, const char * argv[]) { int v; // this is a while loop v = 1; while(v < 100) { v *= 2; } printf("v = %d\n", v); // this is a do while loop v = 1; do { v *= 2; } while(v < 200); printf("v = %d\n", v); // this is a for loop v = 0; for(int i = 0; i < 5; ++i) { v += (i * i); } printf("v = %d\n", v); // this is a forever loop with a conditional exit v = 0; while(1) { // do stuff here v += 1; if(v == 10) break; } printf("v = %d\n", v); // this is a loop over an array { #define ARR_SIZE (10) const int arr[ARR_SIZE] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; int sum = 0; for(int i = 0; i < ARR_SIZE; ++i) { sum += arr[i]; } printf("sum = %d\n", sum); } return 0; }

[Ada]

    
    
    
        
with Ada.Text_IO; procedure Main is V : Integer; begin -- this is a while loop V := 1; while V < 100 loop V := V * 2; end loop; Ada.Text_IO.Put_Line ("V = " & Integer'Image (V)); -- Ada doesn't have an explicit do while loop -- instead you can use the loop and exit keywords V := 1; loop V := V * 2; exit when V >= 200; end loop; Ada.Text_IO.Put_Line ("V = " & Integer'Image (V)); -- this is a for loop V := 0; for I in 0 .. 4 loop V := V + (I * I); end loop; Ada.Text_IO.Put_Line ("V = " & Integer'Image (V)); -- this is a forever loop with a conditional exit V := 0; loop -- do stuff here V := V + 1; exit when V = 10; end loop; Ada.Text_IO.Put_Line ("V = " & Integer'Image (V)); -- this is a loop over an array declare type Int_Array is array (Natural range 1 .. 10) of Integer; Arr : constant Int_Array := (1, 2, 3, 4, 5, 6, 7, 8, 9, 10); Sum : Integer := 0; begin for I in Arr'Range loop Sum := Sum + Arr (I); end loop; Ada.Text_IO.Put_Line ("Sum = " & Integer'Image (Sum)); end; end Main;

The loop syntax in Ada is pretty straightforward. The loop and end loop keywords are used to open and close the loop scope. Instead of using the break keyword to exit the loop, Ada has the exit statement. The exit statement can be combined with a logic expression using the exit when syntax.

The major deviation in loop syntax is regarding for loops. You'll notice, in C, that you sometimes declare, and at least initialize a loop counter variable, specify a loop predicate, or an expression that indicates when the loop should continue executing or complete, and last you specify an expression to update the loop counter.

[C]

for (initialization expression; loop predicate; update expression) {
    // some statements
}

In Ada, you don't declare or initialize a loop counter or specify an update expression. You only name the loop counter and give it a range to loop over. The loop counter is read-only! You cannot modify the loop counter inside the loop like you can in C. And the loop counter will increment consecutively along the specified range. But what if you want to loop over the range in reverse order?

[C]

    
    
    
        
#include <stdio.h> #define MY_RANGE (10) int main(int argc, const char * argv[]) { for (int i = MY_RANGE; i >= 0; --i) { printf("%d\n", i); } return 0; }

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is My_Range : constant := 10; begin for I in reverse 0 .. My_Range loop Put_Line (I'Img); end loop; end Main;

Tick Image

Strangely enough, Ada people call the single apostrophe symbol, ', "tick". This "tick" says the we are accessing an attribute of the variable. When we do 'Img on a variable of a numerical type, we are going to return the string version of that numerical type. So in the for loop above, I'Img, or "I tick image" will return the string representation of the numerical value stored in I. We have to do this because Put_Line is expecting a string as an input parameter.

We'll discuss attributes in more details later in this chapter.

In the above example, we are traversing over the range in reverse order. In Ada, we use the reverse keyword to accomplish this.

In many cases, when we are writing a for loop, it has something to do with traversing an array. In C, this is a classic location for off-by-one errors. Let's see an example in action:

[C]

    
    
    
        
#include <stdio.h> #define LIST_LENGTH (100) int main(int argc, const char * argv[]) { int list[LIST_LENGTH]; for(int i = LIST_LENGTH; i > 0; --i) { list[i] = LIST_LENGTH - i; } for (int i = 0; i < LIST_LENGTH; ++i) { printf("%d ", list[i]); if (i % 10 == 0) { printf("\n"); } } return 0; }

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is type Int_Array is array (Natural range 1 .. 100) of Integer; List : Int_Array; begin for I in reverse List'Range loop List (I) := List'Last - I; end loop; for I in List'Range loop Put (List (I)'Img & " "); if I mod 10 = 0 then New_Line; end if; end loop; end Main;

The above Ada and C code should initialize an array using a for loop. The initial values in the array should be contiguously decreasing from 99 to 0 as we index from the first index to the last index. In other words, the first index has a value of 99, the next has 98, the next 97 ... the last has a value of 0.

If you run both the C and Ada code above you'll notice that the outputs of the two programs are different. Can you spot why?

In the C code there are two problems:

  1. There's a buffer overflow in the first iteration of the loop. We would need to modify the loop initialization to int i = LIST_LENGTH - 1;. The loop predicate should be modified to i >= 0;

  2. The C code also has another off-by-one problem in the math to compute the value stored in list[i]. The expression should be changed to be list[i] = LIST_LENGTH - i - 1;.

These are typical off-by-one problems that plagues C programs. You'll notice that we didn't have this problem with the Ada code because we aren't defining the loop with arbitrary numeric literals. Instead we are accessing attributes of the array we want to manipulate and are using a keyword to determine the indexing direction.

We can actually simplify the Ada for loop a little further using iterators:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is type Int_Array is array (Natural range 1 .. 100) of Integer; List : Int_Array; begin for I in reverse List'Range loop List (I) := List'Last - I; end loop; for I of List loop Put (I'Img & " "); if I mod 10 = 0 then New_Line; end if; end loop; end Main;

In the second for loop, we changed the syntax to for I of List. Instead of I being the index counter, it is now an iterator that references the underlying element. This example of Ada code is identical to the last bit of Ada code. We just used a different method to index over the second for loop. There is no C equivalent to this Ada feature, but it is similar to C++'s range based for loop.

Type System

Strong Typing

Ada is considered a "strongly typed" language. This means that the language does not define any implicit type conversions. C does define implicit type conversions, sometimes referred to as integer promotion. The rules for promotion are fairly straightforward in simple expressions but can get confusing very quickly. Let's look at a typical place of confusion with implicit type conversion:

[C]

    
    
    
        
#include <stdio.h> int main(int argc, const char * argv[]) { unsigned char a = 0xFF; char b = 0xFF; printf("Does a == b?\n"); if(a == b) printf("Yes.\n"); else printf("No.\n"); printf("a: 0x%08X, b: 0x%08X\n", a, b); return 0; }

Run the above code. You will notice that a != b! If we look at the output of the last printf statement we will see the problem. a is an unsigned number where b is a signed number. We stored a value of 0xFF in both variables, but a treated this as the decimal number 255 while b treated this as the decimal number -1. When we compare the two variables, of course they aren't equal; but that's not very intuitive. Let's look at the equivalent Ada example:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is type Char is range 0 .. 255; type Unsigned_Char is mod 256; A : Char := 16#FF#; B : Unsigned_Char := 16#FF#; begin Put_Line ("Does A = B?"); if A = B then Put_Line ("Yes"); else Put_Line ("No"); end if; end Main;

If you try to run this Ada example you will get a compilation error. This is because the compiler is telling you that you cannot compare variables of two different types. We would need to explicitly cast one side to make the comparison against two variables of the same type. By enforcing the explicit cast we can't accidentally end up in a situation where we assume something will happen implicitly when, in fact, our assumption is incorrect.

Another example: you can't divide an integer by a float. You need to perform the division operation using values of the same type, so one value must be explicitly converted to match the type of the other (in this case the more likely conversion is from integer to float). Ada is designed to guarantee that what's done by the program is what's meant by the programmer, leaving as little room for compiler interpretation as possible. Let's have a look at the following example:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Strong_Typing is Alpha : constant Integer := 1; Beta : constant Integer := 10; Result : Float; begin Result := Float (Alpha) / Float (Beta); Put_Line (Float'Image (Result)); end Strong_Typing;

[C]

    
    
    
        
#include <stdio.h> void weakTyping (void) { const int alpha = 1; const int beta = 10; float result; result = alpha / beta; printf("%f\n", result); } int main(int argc, const char * argv[]) { weakTyping(); return 0; }

Are the three programs above equivalent? It may seem like Ada is just adding extra complexity by forcing you to make the conversion from Integer to Float explicit. In fact, it significantly changes the behavior of the computation. While the Ada code performs a floating point operation 1.0 / 10.0 and stores 0.1 in Result, the C version instead store 0.0 in result. This is because the C version perform an integer operation between two integer variables: 1 / 10 is 0. The result of the integer division is then converted to a float and stored. Errors of this sort can be very hard to locate in complex pieces of code, and systematic specification of how the operation should be interpreted helps to avoid this class of errors. If an integer division was actually intended in the Ada case, it is still necessary to explicitly convert the final result to Float:

[Ada]

-- Perform an Integer division then convert to Float
Result := Float (Alpha / Beta);

The complete example would then be:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Strong_Typing is Alpha : constant Integer := 1; Beta : constant Integer := 10; Result : Float; begin Result := Float (Alpha / Beta); Put_Line (Float'Image (Result)); end Strong_Typing;

Floating Point Literals

In Ada, a floating point literal must be written with both an integral and decimal part. 10 is not a valid literal for a floating point value, while 10.0 is.

Language-Defined Types

The principal scalar types predefined by Ada are Integer, Float, Boolean, and Character. These correspond to int, float, int (when used for Booleans), and char, respectively. The names for these types are not reserved words; they are regular identifiers. There are other language-defined integer and floating-point types as well. All have implementation-defined ranges and precision.

Application-Defined Types

Ada's type system encourages programmers to think about data at a high level of abstraction. The compiler will at times output a simple efficient machine instruction for a full line of source code (and some instructions can be eliminated entirely). The careful programmer's concern that the operation really makes sense in the real world would be satisfied, and so would the programmer's concern about performance.

The next example below defines two different metrics: area and distance. Mixing these two metrics must be done with great care, as certain operations do not make sense, like adding an area to a distance. Others require knowledge of the expected semantics; for example, multiplying two distances. To help avoid errors, Ada requires that each of the binary operators +, -, *, and / for integer and floating-point types take operands of the same type and return a value of that type.

[Ada]

    
    
    
        
procedure Main is type Distance is new Float; type Area is new Float; D1 : Distance := 2.0; D2 : Distance := 3.0; A : Area; begin D1 := D1 + D2; -- OK D1 := D1 + A; -- NOT OK: incompatible types for "+" A := D1 * D2; -- NOT OK: incompatible types for ":=" A := Area (D1 * D2); -- OK end Main;

Even though the Distance and Area types above are just Float, the compiler does not allow arbitrary mixing of values of these different types. An explicit conversion (which does not necessarily mean any additional object code) is necessary.

The predefined Ada rules are not perfect; they admit some problematic cases (for example multiplying two Distance yields a Distance) and prohibit some useful cases (for example multiplying two Distances should deliver an Area). These situations can be handled through other mechanisms. A predefined operation can be identified as abstract to make it unavailable; overloading can be used to give new interpretations to existing operator symbols, for example allowing an operator to return a value from a type different from its operands; and more generally, GNAT has introduced a facility that helps perform dimensionality checking.

Ada enumerations work similarly to C enum:

[Ada]

    
    
    
        
procedure Main is type Day is (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday); D : Day := Monday; begin null; end Main;

[C]

    
    
    
        
enum Day { Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday }; int main(int argc, const char * argv[]) { enum Day d = Monday; return 0; }

But even though such enumerations may be implemented by the compiler as numeric values, at the language level Ada will not confuse the fact that Monday is a Day and is not an Integer. You can compare a Day with another Day, though. To specify implementation details like the numeric values that correspond with enumeration values in C you include them in the original enum declaration:

[C]

    
    
    
        
#include <stdio.h> enum Day { Monday = 10, Tuesday = 11, Wednesday = 12, Thursday = 13, Friday = 14, Saturday = 15, Sunday = 16 }; int main(int argc, const char * argv[]) { enum Day d = Monday; printf("d = %d\n", d); return 0; }

But in Ada you must use both a type definition for Day as well as a separate representation clause for it like:

[Ada]

    
    
    
        
with Ada.Text_IO; procedure Main is type Day is (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday); -- Representation clause for Day type: for Day use (Monday => 10, Tuesday => 11, Wednesday => 12, Thursday => 13, Friday => 14, Saturday => 15, Sunday => 16); D : Day := Monday; V : Integer; begin V := Day'Enum_Rep (D); Ada.Text_IO.Put_Line (Integer'Image (V)); end Main;

Note that however, unlike C, values for enumerations in Ada have to be unique.

Type Ranges

Contracts can be associated with types and variables, to refine values and define what are considered valid values. The most common kind of contract is a range constraint introduced with the range reserved word, for example:

[Ada]

    
    
    
        
procedure Main is type Grade is range 0 .. 100; G1, G2 : Grade; N : Integer; begin -- ... -- Initialization of N G1 := 80; -- OK G1 := N; -- Illegal (type mismatch) G1 := Grade (N); -- Legal, run-time range check G2 := G1 + 10; -- Legal, run-time range check G1 := (G1 + G2) / 2; -- Legal, run-time range check end Main;

In the above example, Grade is a new integer type associated with a range check. Range checks are dynamic and are meant to enforce the property that no object of the given type can have a value outside the specified range. In this example, the first assignment to G1 is correct and will not raise a run-time exception. Assigning N to G1 is illegal since Grade is a different type than Integer. Converting N to Grade makes the assignment legal, and a range check on the conversion confirms that the value is within 0 .. 100. Assigning G1 + 10 to G2 is legal since + for Grade returns a Grade (note that the literal 10 is interpreted as a Grade value in this context), and again there is a range check.

The final assignment illustrates an interesting but subtle point. The subexpression G1 + G2 may be outside the range of Grade, but the final result will be in range. Nevertheless, depending on the representation chosen for Grade, the addition may overflow. If the compiler represents Grade values as signed 8-bit integers (i.e., machine numbers in the range -128 .. 127) then the sum G1 + G2 may exceed 127, resulting in an integer overflow. To prevent this, you can use explicit conversions and perform the computation in a sufficiently large integer type, for example:

[Ada]

    
    
    
        
with Ada.Text_IO; procedure Main is type Grade is range 0 .. 100; G1, G2 : Grade := 99; begin G1 := Grade ((Integer (G1) + Integer (G2)) / 2); Ada.Text_IO.Put_Line (Grade'Image (G1)); end Main;

Range checks are useful for detecting errors as early as possible. However, there may be some impact on performance. Modern compilers do know how to remove redundant checks, and you can deactivate these checks altogether if you have sufficient confidence that your code will function correctly.

Types can be derived from the representation of any other type. The new derived type can be associated with new constraints and operations. Going back to the Day example, one can write:

[Ada]

    
    
    
        
procedure Main is type Day is (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday); type Business_Day is new Day range Monday .. Friday; type Weekend_Day is new Day range Saturday .. Sunday; begin null; end Main;

Since these are new types, implicit conversions are not allowed. In this case, it's more natural to create a new set of constraints for the same type, instead of making completely new ones. This is the idea behind subtypes in Ada. A subtype is a type with optional additional constraints. For example:

[Ada]

    
    
    
        
procedure Main is type Day is (Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday); subtype Business_Day is Day range Monday .. Friday; subtype Weekend_Day is Day range Saturday .. Sunday; subtype Dice_Throw is Integer range 1 .. 6; begin null; end Main;

These declarations don't create new types, just new names for constrained ranges of their base types.

The purpose of numeric ranges is to express some application-specific constraint that we want the compiler to help us enforce. More importantly, we want the compiler to tell us when that constraint cannot be met — when the underlying hardware cannot support the range given. There are two things to consider:

  • just a range constraint, such as A : Integer range 0 .. 10;, or

  • a type declaration, such as type Result is range 0 .. 1_000_000_000;.

Both represent some sort of application-specific constraint, but in addition, the type declaration promotes portability because it won't compile on targets that do not have a sufficiently large hardware numeric type. That's a definition of portability that is preferable to having something compile anywhere but not run correctly, as in C.

Unsigned And Modular Types

Unsigned integer numbers are quite common in embedded applications. In C, you can use them by declaring unsigned int variables. In Ada, you have two options:

  • declare custom unsigned range types;

    • In addition, you can declare custom range subtypes or use existing subtypes such as Natural.

  • declare custom modular types.

The following table presents the main features of each type. We discuss these types right after.

Feature

[C] unsigned int

[Ada] Unsigned range

[Ada] Modular

Excludes negative value

Wraparound

When declaring custom range types in Ada, you may use the full range in the same way as in C. For example, this is the declaration of a 32-bit unsigned integer type and the X variable in Ada:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is type Unsigned_Int_32 is range 0 .. 2 ** 32 - 1; X : Unsigned_Int_32 := 42; begin Put_Line ("X = " & Unsigned_Int_32'Image (X)); end Main;

In C, when unsigned int has a size of 32 bits, this corresponds to the following declaration:

[C]

    
    
    
        
#include <stdio.h> #include <limits.h> int main(int argc, const char * argv[]) { unsigned int x = 42; printf("x = %u\n", x); return 0; }

Another strategy is to declare subtypes for existing signed types and specify just the range that excludes negative numbers. For example, let's declare a custom 32-bit signed type and its unsigned subtype:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is type Signed_Int_32 is range -2 ** 31 .. 2 ** 31 - 1; subtype Unsigned_Int_31 is Signed_Int_32 range 0 .. Signed_Int_32'Last; -- Equivalent to: -- subtype Unsigned_Int_31 is Signed_Int_32 range 0 .. 2 ** 31 - 1; X : Unsigned_Int_31 := 42; begin Put_Line ("X = " & Unsigned_Int_31'Image (X)); end Main;

In this case, we're just skipping the sign bit of the Signed_Int_32 type. In other words, while Signed_Int_32 has a size of 32 bits, Unsigned_Int_31 has a range of 31 bits, even if the base type has 32 bits.

Note that the declaration above is actually similar to the existing Natural subtype. Ada provides the following standard subtypes:

subtype Natural  is Integer range 0..Integer'Last;
subtype Positive is Integer range 1..Integer'Last;

Since they're standard subtypes, you can declare variables of those subtypes directly in your implementation, in the same way as you can declare Integer variables.

As indicated in the table above, however, there is a difference in behavior for the variables we just declared, which occurs in case of overflow. Let's consider this C example:

[C]

    
    
    
        
#include <stdio.h> #include <limits.h> int main(int argc, const char * argv[]) { unsigned int x = UINT_MAX + 1; /* Now: x == 0 */ printf("x = %u\n", x); return 0; }

The corresponding code in Ada raises an exception:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is type Unsigned_Int_32 is range 0 .. 2 ** 32 - 1; X : Unsigned_Int_32 := Unsigned_Int_32'Last + 1; -- Overflow: exception is raised! begin Put_Line ("X = " & Unsigned_Int_32'Image (X)); end Main;

While the C uses modulo arithmetic for unsigned integer, Ada doesn't use it for the Unsigned_Int_32 type. Ada does, however, support modular types via type definitions using the mod keyword. In this example, we declare a 32-bit modular type:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is type Unsigned_32 is mod 2**32; X : Unsigned_32 := Unsigned_32'Last + 1; -- Now: X = 0 begin Put_Line ("X = " & Unsigned_32'Image (X)); end Main;

In this case, the behavior is the same as in the C declaration above.

Modular types, unlike Ada's signed integers, also provide bit-wise operations, a typical application for unsigned integers in C. In Ada, you can use operators such as and, or, xor and not. You can also use typical bit-shifting operations, such as Shift_Left, Shift_Right, Shift_Right_Arithmetic, Rotate_Left and Rotate_Right.

Attributes

Attributes start with a single apostrophe ("tick"), and they allow you to query properties of, and perform certain actions on, declared entities such as types, objects, and subprograms. For example, you can determine the first and last bounds of scalar types, get the sizes of objects and types, and convert values to and from strings. This section provides an overview of how attributes work. For more information on the many attributes defined by the language, you can refer directly to the Ada Language Reference Manual.

The 'Image and 'Value attributes allow you to transform a scalar value into a String and vice-versa. For example:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is A : Integer := 10; begin Put_Line (Integer'Image (A)); A := Integer'Value ("99"); Put_Line (Integer'Image (A)); end Main;

Important

Semantically, attributes are equivalent to subprograms. For example, Integer'Image is defined as follows:

function Integer'Image(Arg : Integer'Base) return String;

Certain attributes are provided only for certain kinds of types. For example, the 'Val and 'Pos attributes for an enumeration type associates a discrete value with its position among its peers. One circuitous way of moving to the next character of the ASCII table is:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is C : Character := 'a'; begin Put (C); C := Character'Val (Character'Pos (C) + 1); Put (C); end Main;

A more concise way to get the next value in Ada is to use the 'Succ attribute:

[Ada]

    
    
    
        
with Ada.Text_IO; use Ada.Text_IO; procedure Main is C : Character := 'a'; begin Put (C); C := Character'Succ (C); Put (C); end Main;