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:
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.
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
andend
are used in place of curly braces.Conditions are written using
if
,then
,elsif
,else
, andend 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 |
|
|
And |
|
|
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:
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 toi >= 0;
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 belist[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;
, ora 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] |
[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;