The Ada Programming Language

by John

Introduction

This is the first of a series (I hope ;-) of posts about characteristics of different programming languages. The structure of the text is not consolidated yet, it may (and probably will) change in further texts. In this first post, I will write about the Ada programming language. You should not consider this text as a tutorial (e.g., I will not write about usual things like conditional or looping structures, etc), but rather as a collection of notes that I collected about the language as I tried to write a lot of codes (implement several algorithms) in it. You can find a collection of examples here: https://github.com/JohnTortugo/Babel

Overview

Ada as a language permits a great deal of freedom and, sincerely, surprised with its versatility and comprehensive list of features. Ada supports not only very structured code blocks (like Pascal) but also object-oriented programming, exception handling, and even generics. If you are interested about knowing a little better about Countess Ada Lovelace or some of the amazing things that were done using the Ada programming language take a look at this page: http://celebratingada.com/

To make long numbers easier to read, underscores are permitted inside a numeric literal. For example, “1_000_000” is legal. This is similar to the way commas are used in the United States and periods are used in Europe. Underscores aren’t allowed to be consecutive, numbers may not end in an underscore, and underscores don’t change the value of a number.

A useful Ada capability is its ability to write out literals in other bases from 2 to 16. These are called, reasonably enough, based literals. To create a based literal, write out the desired base, a “#” sign, the number in the requested base, and another “#” sign. For example, “2#1001_1000#” is a base 2 number equal to 128+16+8 = 152.

Ada guarantees that an Integer can store numbers between -32767 and 32767 (inclusive); an Integer is likely to have a wider range. In other words, an Integer type must use at least 16 bits, but the actual number of bits used will depend on the compiler and machine.

A key difference between Ada and some other languages (such as C and C++) is what happens when an evaluation cannot be completed. If a division by zero is attempted, or an expression result is too large, Ada will normally raise an exception. Exceptions can be handled, but if they aren’t, the program will halt (with some debugging output to help identify the kind and location of the problem). This means that instead of silently giving wrong answers, Ada programs normally will halt when a computation cannot be completed. This simplifies debugging.

Unlike C or C++, but like Pascal and many other languages, Integers are not considered the same as True or False. Ada insists that types be correct in operations, and there aren’t any predefined operations for mixing Boolean, Integer and Float using +, -, *, or /. Thus, if you’re using an Integer and Float together, put a function called `Float()’ around the Integer variables to cause them to be converted into floating-point values. This makes it clear when such conversions are taking place, which is sometimes important in understanding what a program is doing. Also, whenever you set a Float to a constant, the constant must be written with a period in it, or the compiler will complain.

Normally Ada will evaluate these expressions in whatever order is most efficient for the machine. If it’s important to evaluate them in a certain order and to stop evaluating them when the answer is known, there are versions of `and’ and `or’ that are called `short-circuit operations’. These operations will execute strictly left-to-right and will not execute anything if they don’t have to. C’s && and || operations work this way. The short-circuit version of `and’ is `and then’; the short-circuit version of `or’ is `or else’.

One very important difference between Ada and some other languages is that Ada considers types different even if they happen to be implemented the same way at a particular time.

Ada array indices are not required to start at zero or one. Array indices can begin (and end) with any discrete value – whatever makes the most sense for the data. This means that you can start arrays at -5 (if that makes sense), and you can use enumerated values as indices as well. Ada programs usually use a starting index of 1 if there’s no particularly natural starting point;

Ada 95 provides a number of different “string” types, each best for a certain purpose.

Type Description
String This is the basic Ada String type, and is also called a “fixed length string”. This type (String) is simply an array of Characters.
Bounded String Values of this type can vary in length up to a maximum length (which you supply).
Unbounded_String Values of this type can vary in length up to the largest value of type `Natural’ (usually that’s over 2 billion characters).

Ada also includes some types that represent strings from other languages, namely C, COBOL, and Fortran.

The main difference between a procedure and function is that a function returns a value, while a procedure does not (though a procedure can change the values of parameters sent to it). Another interesting point is that function/procedure parameters receive annotations indicating whether they will be used for reading (in) and/or writing (in out) data. Therefore a function definition may look something like:

	procedure Insert(root : in out TreeNode_ptr; key : in Integer) is
	begin
	-- Comment example...
	end Insert;

Ada programs consist of packages, functions, procedures, variables, etc. You can think of a package like a collection of related functions, procedures, etc. A package can have a specification section that can be stored in a file specific for this purpose, which must have the extension “.ads”. Think of a specification as a C/C++ header file. An “.adb” file is like an C++ “.cpp” file and contains the definition for the functions/procedures declared in the specification file.

The “main” procedure of a Ada program must be a library unit procedure with no parameters. You put most of your code in packages, in files like this_package.ads, this_package.adb, that_package.ads, that_package.adb. Then you put a main procedure (that is not inside any package) inside an “.adb” program. This procedure “with’s” (i.e., import) one or more packages and calls
things in them.

Compiling and Running Ada Programs in Linux

I will describe first the gnatmake tool, which automatically determines the set of sources needed by an Ada compilation unit (i.e., a “.adb” file) and executes the necessary (re)compilations, binding and linking. I also explains how to use each tool individually: the compiler gcc, the binder gnatbind, and linker gnatlink, to build executable programs.

“gnatmake” automatically takes care of compiling, rebinding and linking all object files related to changed source files. In other words, it determines which sources need to be compiled, compiles them, and binds and links the resulting object files producing the executable file.

The usual form of the gnatmake command is:

$ gnatmake [switches] <file_name> [<file_name>] [<switches>]

The only required argument is one “file_name”, which specifies a compilation unit. If you are using standard file extensions (.adb and .ads), then the extension may be omitted from the file_name arguments.

Using GCC:

The first step in creating an executable program is to compile the units of the program using the gcc command. In case of Ada programming this means compiling “.adb” files to object files “.o”. The basic command for compiling a file containing an Ada unit is:

$ gcc -c [switches] <file_name>

After producing the object files for all compilation units you need to bind the object of the projects. The GNAT binder, gnatbind, is used to bind compiled objects. You can invoke gnatbind using the following command:

$ gnatbind [<switches>] <mainprog>[.ali] [<switches>]

the “mainprog.ali” file is an ‘Ada Library Information’ file created in the previous step. It contains information that the binder will use to check the consistency of the program and also to create a linking plan for the object file. As usual the last step is to link all object files together, and for that you can use the gnatlink:

$ gnatlink [<switches>] <mainprog>[.ali] [<switches>]

this will produce a “mainprog” executable file.

The Type System

The primitive types are: Integer (Natural, Positve), Float, Duration, Character, String, Boolean and others. The user can create new types and subtypes (similar to classes in C/C++) as well as records (similar to structs in C/C++).

Four principles govern the type system:

Strong typing: types are incompatible with one another, so it is not possible to mix apples and oranges. There are, however, ways to convert between types.

Static typing: type checked while compiling, this allows type errors to be found earlier.

Abstraction: types represent the real world or the problem at hand; not how the computer represents the data internally. There are ways to specify exactly how a type must be represented at the bit level, but we will defer that discussion to another chapter.

Name equivalence, as opposed to structural equivalence used in most other languages. Two types are compatible if and only if they have the same name; not if they just happen to have the same size or bit representation. You can thus declare two integer types with the same ranges that are totally incompatible, or two record types with exactly the same components, but which are incompatible.

Types are incompatible with one another. However, each type can have any number of subtypes, which are compatible with their base type and may be compatible with one another. You can define a new type with the following syntax:

type T is...

type Integer_1 is range 1 .. 10;
type Integer_2 is range 1 .. 10;

Note how the new type also specify the range of values that a variable of that type can assume.

A derived type is a new, full-blown type created from an existing one. Like any other type, it is incompatible with its parent; however, it inherits the primitive operations defined for the parent type.

type Integer_2 is new Integer_1 range 2 .. 8;
A : Integer_1 := 8;
B : Integer_2 := A; -- illegal!

Ada also have something similar to pointers and also support dynamic memory allocation. To create a pointer to a variable of type T1 you need to create a new type T2 that represents a pointer to a variable of type T1. The declaration of T1 may specify the access permissions (i.e., reading, writing, etc.) of the pointers. For instance:

-- declare existence of type TreeNode
type TreeNode;

-- pointer to TreeNode
type TreeNode_ptr is access TreeNode;

-- complete definition of TreeNode
type TreeNode is
record
Value : Integer;
Left : TreeNode_ptr;
Right : TreeNode_ptr;
end record;

creates a new type named “TreeNode_ptr” that can points to objects of type “TreeNode”. To create a new object of type TreeNode and assign its address to a pointer named “ptr” you can use something like:

ptr : ptr := null;
ptr := new TreeNode'(Value => 0, Left => null, Right => null);

“new” takes a free block of memory from a storage pool of available memory (often referred to as a heap) and reserves it for use as an TreeNode variable. A reference to its location is then assigned to the variable ptr so that we then have some way of accessing it. An initial value is specified for the new node by means of named parameters to the type “constructor”.

Apart from assigning a value generated by new to ptr, you can assign the special value null to ptr to indicate that it doesn’t point to anything (a ‘null pointer’). Access variables are automatically set to null when they are declared unless new is used to initialise them. Attempting to access the value that a null pointer points to will generate a constraint error.

Having set ptr to point to a dynamically allocated TreeNode variable, you can then use ‘ptr.all’ to access the node itself. You can then select components of the node in the usual way:

ptr.all.Value := 2016; -- you can also omit the ".all." part...
if (key < ptr.all.Value) then ...

Be careful not to confuse ‘ptr’ and ‘ptr.all’; ‘ptr’ on its own is the name of the access variable, but ‘ptr.all’ is the value that ptr points to:

ptr.all := ptr2.all; -- copy one node into another
ptr := ptr2; -- set ptr to point to the same thing as ptr2

Assuming that ptr and ptr2 point to different nodes, the first assignment will copy the contents of one node into the other so that you end up with two identical nodes. In the second case ptr and ptr2 will both end up pointing to the same node, and the node that ptr pointed to before is now inaccessible unless there’s another access variable which points to it. After the first assignment, you can alter ptr.Value and it won’t affect ptr2.Value since ptr and ptr2 point to different nodes, but after the second assignment ptr.Value and ptr2.Value both refer to the same thing, so any change to ptr.Value will also be a change to ptr2.Value.

An Small Example

I decided to include a small example program to illustrate several points of the language syntax/semantics. The example is simply a package that implements a Binary Search Tree and a driver function that uses this package. I added comments in the code in places where I think the language mostly differ from C/C++. The first file below is the file one that contains the main entry point to the program: BSTDriver.

-- Ada.Text_IO: This will enable us to print texts to standard output.
-- BinSearchTree: This will include the BinSearchTree package in the scope of
-- this compilation unit.
with Ada.Text_IO, BinSearchTree;
use Ada.Text_IO, BinSearchTree;

-- This is the "main" procedure of the project. The execution
-- of the program will start here.
procedure BSTDriver is
	-- Local variables usually are declared before the "begin" key word.
	-- Node that TreeNode_ptr is a pointer to TreeNode.
	root : TreeNode_ptr := null;
begin

	-- The usual syntax of function call is the same as other imperative
	-- languages like C, Java, etc.
	Insert(root, 1);	
	Insert(root, 10);	
	Insert(root, 5);	
	Insert(root, 8);	

	Remove(root, 1);
	Remove(root, 5);
	Remove(root, 8);
	Remove(root, 10);

	Insert(root, 50);

	-- Note the use of the "then" and "end if" keywords, similar to pascal
	-- and also the comparison operator. Ada uses the ":=" for assignment
	-- and "=" for comparison.
	if Search(root, 50) = True then
		Put_Line("Found.");
	else
		Put_Line("Not found.");
	end if;

	Insert(root, 1);	
	Insert(root, 10);	
	Insert(root, 5);	
	Insert(root, 8);	

	Put("InOrder: ");
	PrintInOrder(root);
	-- This is one of the ways to print new lines in Ada. The parameter tells
	-- how many new lines should be printed.
	New_Line(1);

	Put("PreOrder: ");
	PrintPreOrder(root);
	New_Line(1);

	Put("PosOrder: ");
	PrintPosOrder(root);
	New_Line(1);

end BSTDriver;

the next file is “binsearchtree.ads”, the specification for the package BinSearchTree:

-- This is the specification file ".ads" for the BinSearchTree
-- package. Here we don't include the body of the functions, we
-- just declare their prototype.

package BinSearchTree is
	-- Declare the existence of a TreeNode type
	type TreeNode;

	-- This is how we declare a type "pointer to TreeNode"
	type TreeNode_ptr is access TreeNode;

	-- Complete definition of the TreeNode type.
	-- This is like an struct in C/C++.
	type TreeNode is
	record
		Value		: Integer;
		Left		: TreeNode_ptr;
		Right		: TreeNode_ptr;
	end record;

	-- Note that we specify how the parameters will be used inside the
	-- function: if they are input or output parameters, or both.
	-- Note also that to separe the parameters you use a semicolon instead of a comma.
	procedure Insert(root : in out TreeNode_ptr; key : in Integer);
	procedure Remove(root : in out TreeNode_ptr; key : in Integer);

	-- The return type of the function is the last past of the prototype
	-- and only functions can have return types.
	function Search(root : in TreeNode_ptr; key : in Integer) return Boolean;

	procedure PrintInOrder(root : in TreeNode_ptr);
	procedure PrintPosOrder(root : in TreeNode_ptr);
	procedure PrintPreOrder(root : in TreeNode_ptr);
end BinSearchTree;

and this last file “binsearchtree.adb” is the one containing the body (implementation) for the package BinSearchTree:

with Ada.Text_IO, Ada.Integer_Text_IO;
use Ada.Text_IO, Ada.Integer_Text_IO;

package body BinSearchTree is
	function SearchRightmost(root : in TreeNode_ptr) return Integer is
	begin
		-- The way we access the fields of the struct is the same as in 
		-- C/C++.
		if (root.Right = null) then
			return root.Value;
		else 
			return SearchRightmost(root.Right);
		end if;
	end;

	procedure Insert(root : in out TreeNode_ptr; key : in Integer) is
	begin
		if root = null then
			-- You use the keyword "New" to allocate memory in the heap.
			-- For some reason you need to include an apostrophe after
			-- the type name. A nice thing is that you can initialize the
			-- record parameters by their name. The syntax for assignment
			-- here is a little different from the ones in other places.
			root := new TreeNode'(Value => key, Left => null, Right => null);
		else
			if key < root.Value then
				Insert(root.Left, key);
			else
				Insert(root.Right, key);
			end if;
		end if;
	end Insert;

	procedure Remove(root : in out TreeNode_ptr; key : in Integer) is
		-- Local variables are declared in this region.
		rightmostValue : Integer := 0;
	begin
		-- The inequality operator (/=) is a little different of the commonly
		-- used nowadays (!=). But it is more similar to the mathematical symbol.
		if (root /= null) then
			if (root.Value = key) then
				if (root.Left = null and root.Right = null) then
					root := null;
				-- note that the word is "elsif" (like Bash) not "elseif"
				elsif (root.Left /= null) then
					root := root.Left;
				elsif (root.Right /= null) then
					root := root.Right;
				else 
					rightmostValue := SearchRightmost(root.Left);

					Remove(root.Left, rightmostValue);

					root.Value := rightMostValue;
				end if;
			elsif (key < root.Value) then
				Remove(root.Left, key);
			else
				Remove(root.Right, key);
			end if;
		end if;
	end Remove;

	function Search(root : in TreeNode_ptr; key : in Integer) return Boolean is
	begin
		if (root = null) then
			return False;
		else
			if (root.Value = key) then
				return True;
			elsif (key < root.Value) then
				return Search(root.Left, key);
			else
				return Search(root.Right, key);
			end if;
		end if;
	end Search;

	procedure PrintInOrder(root : in TreeNode_ptr) is
	begin
		if (root /= null) then
			PrintInOrder(root.Left);

			Put("(");
			Put(root.Value, 2, 10);
			Put(")");

			PrintInOrder(root.Right);
		end if;
	end PrintInOrder;

	procedure PrintPreOrder(root : in TreeNode_ptr) is
	begin
		if (root /= null) then
			Put("(");
			Put(root.Value, 2, 10);
			Put(")");

			PrintPreOrder(root.Left);

			PrintPreOrder(root.Right);
		end if;
	end PrintPreOrder;

	procedure PrintPosOrder(root : in TreeNode_ptr) is
	begin
		if (root /= null) then
			PrintPosOrder(root.Left);

			PrintPosOrder(root.Right);

			Put("(");
			Put(root.Value, 2, 10);
			Put(")");
		end if;
	end PrintPosOrder;

end BinSearchTree;

 

References

  1. Types:
    https://en.wikibooks.org/wiki/Ada_Programming/Type_System
  2. Access Types and Storage Allocation:
    http://www.adaic.org/resources/add_content/docs/craft/html/ch11.htm
  3. GNAT Users Guide:
    https://docs.adacore.com/gnat_ugn-docs/html/gnat_ugn/gnat_ugn.html
  4. Running a simple Ada program:
    https://gcc.gnu.org/onlinedocs/gnat_ugn/Running-a-Simple-Ada-Program.html