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;

Approach 2: Shared Pointers

The second approach uses shared pointers to hold references to other objects. The advantage is that shared pointers manage their own scope. This means that, like in programming systems with garbage collection, the developer does not have to manage an object’s life cycle. The disadvantage is that shared pointers maintain reference counters which the application has to update and check continuously at runtime.

I recommend this method only in cases where the scope of objects is hard to predict because of the added runtime cost of reference counting.

alr with gnatcoll
--  bs.ads

with GNATCOLL.Refcount;

package Bs is

   type B is interface;

   function Is_Ready (Self : B) return Boolean is abstract;

   package Pointers is new GNATCOLL.Refcount.Shared_Pointers (B'Class);

   function Make_B (Ready : Boolean) return Pointers.Ref;
   function Make_B (Child : Pointers.Ref) return Pointers.Ref;

end Bs;
--  bs.adb

with Bs.Simple;
with Bs.Composed;

package body Bs is

   function Make_B (Ready : Boolean) return Pointers.Ref
   renames Bs.Simple.Make_B;

   function Make_B (Child : Pointers.Ref) return Pointers.Ref
   renames Bs.Composed.Make_B;

end Bs;
--  bs-simple.ads

package Bs.Simple is

   type Simple_B is new B with private;

   overriding
   function Is_Ready (Self : Simple_B) return Boolean;

   function Make_B (Ready : Boolean) return Pointers.Ref;

private

   type Simple_B is new B with
      record
         Ready : Boolean;
      end record;

end Bs.Simple;
--  bs-simple.adb

package body Bs.Simple is

   overriding
   function Is_Ready (Self : Simple_B) return Boolean
   is
     (Self.Ready);

   function Make_B (Ready : Boolean) return Pointers.Ref
   is
   begin
      return R : Pointers.Ref do
         R.Set (Simple_B'(Ready => Ready));
      end return;
   end Make_B;

end Bs.Simple;
--  bs-composed.ads

package Bs.Composed is

   type Composed_B is new B with private;

   overriding
   function Is_Ready (Self : Composed_B) return Boolean;

   function Make_B (Child : Pointers.Ref) return Pointers.Ref;

private

   type Composed_B is new B with
      record
         Child : Pointers.Ref;
      end record;

end Bs.Composed;
--  bs-composed.adb

package body Bs.Composed is

   overriding
   function Is_Ready (Self : Composed_B) return Boolean
   is
     (Self.Child.Get.Is_Ready);

   function Make_B (Child : Pointers.Ref) return Pointers.Ref
   is
   begin
      return R : Pointers.Ref do
         R.Set (Composed_B'(Child => Child));
      end return;
   end Make_B;

end Bs.Composed;

Common Data Structures

Strings

Ada.Strings.Unbounded

Lists and Maps

Ada.Containers