Ada: Object Orientation
Here, I give an introduction to object orientation in Ada. I introduce a programming style that allows for the same flexibility we expect from object systems in C++, Java, or CLOS. In particular, an object system needs to (i) to allow for polymorphism, (ii) to shrink and grow its memory footprint as needed, (iii) to allow arbitrary composition, and (iv) to play well with standard data structures, e.g., strings, lists, or maps.
Ada is an interesting case because while polymorphism has been a key stone of the language from the outset, automatic memory management features like garbage collection or shared pointers are not part of Ada even today. However, it is possible to create shared pointers and several implementations of them have been proposed.
The downside of shared pointers is that in the absence of garbage collection, reference counting is needed. However, reference counting is expensive.
Thus, I will present two styles to do object orientation in Ada: The first makes do without shared pointers. It has the advantage that it uses only built-in Ada features and that its only run-time burden is the method dispatch. The second uses GNATcoll’s shared pointers to manage the heap. It has the advantage that it can grow and shrink its memory footprint just like a garbage collected language. But it adds the run-time burden of reference counting.
Preparation
Assuming an Alire-managed project, add the appropaite build switches to the alire.toml file.
alr init myproject
cd myproject
alr toolchain --select
[build-switches]
"*".ada_version = "Ada2022"
Approach 1: Access Types
The first approach uses access types as the main memory management facility. Like with C++’s freestore the developer has to allocate and release memory on the heap manually. The advantage of this approach is that it is very flexible: the application does not need to know its memory demand in advance and grows and shrinks as needed. Also, the overhead incurred by memory management is minimal and is limited to allocation and release. The disadvantage is that the developer needs to manage memory explicitly. The developer might release memory too early, exposing null pointers, or too late, creating resource leaks.
I recommend this method for cases where object-oriented design is important and where the scope of objects is either easy to predict (like in workflow applications) or trivial (resources are allocated at startup and released on shutdown). In cases where the scope of objects is hard to predict, I recommend the second approach: Shared pointers.
Interfaces
The first pillar of an object-oriented programming style is that we separate interfaces from implementations.
I define an interface by introducing its type and access type. I introduce an interface’s methods as abstract basic operations. Last, I introduce two constructors.
-- as.ads
with Ada.Finalization;
with Ada.Unchecked_Deallocation;
package As is
type A is abstract limited new Ada.Finalization.Limited_Controlled with null record;
type A_Access is access A'Class;
function Is_Ready
(Self : A)
return Boolean is abstract;
procedure Free_A is new Ada.Unchecked_Deallocation
(Object => A'Class,
Name => A_Access);
procedure Free (Self : in out A_Access)
renames Free_A;
function Make_A
(Ready : Boolean)
return not null A_Access;
function Make_A
(Child : not null A_Access)
return not null A_Access;
end As;
When introducing an interface, Note, that I need an access type onto A'Class instead of just A since A is abstract and, thus, cannot be constructed directly.
The function Is_Ready is abstract and, thus, has no body. However, the two constructors need to be implemented. I defer the implementation to two child packages: As.R and As.S.
-- as.adb
with As.Simple; use As.Simple;
with As.Composed; use As.Composed;
package body As
is
function Make_A
(Ready : Boolean)
return not null A_Access
renames As.Simple.Make_A;
function Make_A
(Child : not null A_Access)
return not null A_Access
renames As.Composed.Make_A;
end As;
Classes
We define a class by extending its interface type.
-- as-simple.ads
package As.Simple
is
type Simple_A is limited new A with private;
overriding
function Is_Ready
(Self : Simple_A)
return Boolean;
function Make_A
(Ready : Boolean)
return not null A_Access;
private
type Simple_A is limited new A with
record
Ready : Boolean;
end record;
end As.Simple;
-- as-simple.adb
package body As.Simple
is
overriding
function Is_Ready
(Self : Simple_A)
return Boolean
is
(Self.Ready);
function Make_A
(Ready : Boolean)
return not null A_Access
is
(new Simple_A'(A with Ready => Ready));
end As.Simple;
I give the class fields only in the package’s private part to force clients to access objects via it’s methods. I mark method implementations overriding to underpin their relation to the inherited interface.
Composition
One of the most important patterns in object-oriented design is the composite pattern.
-- as-composed.ads
package As.Composed
is
type Composed_A is limited new A with private;
overriding
procedure Finalize
(Self : in out Composed_A);
overriding
function Is_Ready
(Self : Composed_A)
return Boolean;
function Make_A
(Child : not null A_Access)
return not null A_Access;
private
type Composed_A is limited new A with
record
Child : A_Access;
end record;
end As.Composed;
-- as-composed.adb
package body As.Composed
is
overriding
procedure Finalize
(Self : in out Composed_A)
is
begin
A (Self).Finalize;
Free (Self.Child);
end Finalize;
overriding
function Is_Ready
(Self : Composed_A)
return Boolean
is
(Self.Child.all.Is_Ready);
function Make_A
(Child : not null A_Access)
return not null A_Access
is
(new Composed_A'(A with Child => Child));
end As.Composed;