Memory Management in Ada — When to Use Access Types, Controlled Types, and Storage Pools

· · Ada, GNAT, Memory Management, Access Types, RAII, Storage Pools

Post on X
Revision history (first version, published Nov 1, 2026)
First published
Cite this article(DOI: 10.5281/zenodo.23856526)

This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.

Go Komura (2026). Memory Management in Ada — When to Use Access Types, Controlled Types, and Storage Pools. KomuraSoft LLC. https://doi.org/10.5281/zenodo.23856526 https://comcomponent.com/en/blog/ada-memory-management/

DOI (latest version)
10.5281/zenodo.23856526
DOI (this version)
10.5281/zenodo.23856527

The Ada standard does not assume garbage collection (GC). GNAT’s default allocator uses malloc and free and does not automatically reclaim memory that is no longer used1. Instead, Ada has ways of writing code that avoid the heap altogether, and mechanisms for writing the rules that apply when you do use the heap into types.

This article explains memory management in Ada in the following order:

  1. Put values on the stack. Even values whose length is decided at run time can be returned from functions without writing new.
  2. When the heap is needed, use access types, and decide with types what they can point to and how long it lives.
  3. Deallocate with Unchecked_Deallocation. As the name says, it is unchecked, so reduce the places that call it directly.
  4. Leave deallocation to the Finalize of a controlled type. This is the same idea as RAII in C++.
  5. Decide where memory comes from with storage pools. You can set limits and count allocations.

All examples were compiled and run with GNAT 13.3.0 on Ubuntu 24.04, and the output is shown as is. valgrind 3.22.0 was used to check for memory misuse. The language rules were checked against the Ada 2022 Reference Manual (RM). The characteristics of Ada itself are introduced in “The Appeal of the Ada Language”.

Where values are placed in Ada

In Ada, declared variables are normally placed on the stack. Even arrays whose length is decided at run time can be declared as variables without writing new. The heap (a storage pool, in Ada terminology) is used when you write new2.

Where values are placed in AdaShows that declared variables go on the stack, return values of unknown length go on the secondary stack in GNAT, and values created with new go in the access type's storage pool.Declared variablesStackReturn values of unknown lengthSecondary stack (GNAT)Values created with newStorage poolThe default pool uses malloc and free

Figure 1: Declared variables go on the stack, and values created with new go in a storage pool.

Grouped by where the memory lives, the mechanisms covered in this article are as follows.

Location Allocation Deallocation Section of this article
Stack Variable declaration When leaving the scope Returning values of unknown length without new
Secondary stack (GNAT) A function that returns a value of unknown length When leaving the caller’s scope Same as above
Storage pool new Unchecked_Deallocation, or controlled types and containers Access types onward

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (15 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

Returning values of unknown length without new

In C, to return a string whose length is decided at run time, you normally either have the caller provide a buffer or malloc inside the function and have the caller free it. In Ada, a String of unknown length can be the return value as is.

with Ada.Text_IO; use Ada.Text_IO;

procedure Repeat_Demo is
   --  The length of the return value is not known until the call
   function Repeat (C : Character; N : Natural) return String is
      Result : constant String (1 .. N) := (others => C);
   begin
      return Result;
   end Repeat;

   N : Natural := 3;
begin
   for I in 1 .. 3 loop
      declare
         Line : constant String := Repeat ('*', N);
      begin
         Put_Line (Line & " length =" & Natural'Image (Line'Length));
      end;
      N := N * 2;
   end loop;
end Repeat_Demo;
*** length = 3
****** length = 6
************ length = 12

There is no new and no deallocation. The length of Line is decided by the value Repeat returns. Running this program under valgrind showed zero heap allocations.

The GNAT in this article’s environment places such return values on the secondary stack. The GNAT binder documentation describes the secondary stack as the place for “objects of unconstrained types (for example, an unconstrained String) returned by functions”3. In many environments the secondary stack grows as needed and is implemented as a chain of blocks on the heap3. The GNAT documentation also says that only some targets use the secondary stack for function return values4.

I checked whether the memory is reused after leaving the block, with a loop that returns a 1 MB string 10,000 times.

with Ada.Text_IO; use Ada.Text_IO;

procedure SS_Loop is
   function Repeat (C : Character; N : Natural) return String is
      Result : constant String (1 .. N) := (others => C);
   begin
      return Result;
   end Repeat;

   Total : Long_Long_Integer := 0;
begin
   --  Return a 1 MB string 10,000 times, about 10 GB in total
   for I in 1 .. 10_000 loop
      declare
         Line : constant String := Repeat ('*', 1_000_000);
      begin
         Total := Total + Long_Long_Integer (Line'Length);
      end;
   end loop;
   Put_Line ("total =" & Long_Long_Integer'Image (Total));
end SS_Loop;
total = 10000000000
==32596==     in use at exit: 1,000,048 bytes in 1 blocks
==32596==   total heap usage: 1 allocs, 0 frees, 1,000,048 bytes allocated

Even after returning 10 GB of strings in total, there was only one heap allocation, of 1,000,048 bytes. That one allocation is presumably the block for growing the secondary stack; the iterations after it made no new allocations. Unlike values created with new, the program does not need to write any deallocation.

How the secondary stack is usedShows that when a function returns a value of unknown length, it is pushed onto the secondary stack, used in the caller's block, and released when leaving the block.Call RepeatPush the result onto the secondary stackUse it as Line in the declare blockLeave the blockRelease the pushed memory

Figure 2: A return value of unknown length is released when leaving the caller’s block.

You can confirm this GNAT usage by adding pragma Restrictions (No_Secondary_Stack). This restriction guarantees at compile time that the generated code does not reference the secondary stack4. Compiling the same function as Repeat above under this restriction gave the following errors.

pragma Restrictions (No_Secondary_Stack);

procedure Nosec is
   function Repeat (C : Character; N : Natural) return String is
      Result : constant String (1 .. N) := (others => C);
   begin
      return Result;
   end Repeat;

   Line : constant String := Repeat ('*', 3);
begin
   null;
end Nosec;
nosec.adb:7:07: error: violation of restriction "No_Secondary_Stack" at line 1
nosec.adb:10:04: error: violation of restriction "No_Secondary_Stack" at line 1
nosec.adb:10:30: error: violation of restriction "No_Secondary_Stack" at line 1

In code under this restriction, the caller prepares an array of fixed length and the function writes into it.

Deciding “what it can point to” with access types

You need the heap when you want to use a value outside the scope that created it, or when elements are linked to each other, as in a list or a tree. In Ada, you use access types for this. An access type is close to a C pointer, but its declaration decides what it can point to2.

Pool-specific and general access types

A type declared with just access is a pool-specific access type. It can only point to values created with that type’s new (elements of its storage pool). A type declared with access all is a general access type, and it can also point to variables declared with aliased2.

with Ada.Text_IO; use Ada.Text_IO;

procedure Access_Kinds is
   --  Pool-specific access type: can only point to what new allocated
   type Int_Ptr is access Integer;

   --  General access type: can also point to aliased variables
   type Int_Ref is access all Integer;

   P : constant Int_Ptr := new Integer'(10);

   Counter : aliased Integer := 20;
   R       : constant Int_Ref := Counter'Access;
begin
   R.all := R.all + 1;
   Put_Line ("P.all   =" & Integer'Image (P.all));
   Put_Line ("Counter =" & Integer'Image (Counter));
end Access_Kinds;
P.all   = 10
Counter = 21

The opposite combinations are compile errors: trying to point to a variable with a pool-specific type, and trying to point to a variable without aliased.

procedure Access_Errors is
   type Int_Ptr is access Integer;
   type Int_Ref is access all Integer;

   Plain   : Integer := 1;
   Counter : aliased Integer := 2;

   P1 : Int_Ptr := Counter'Access;  --  A pool-specific type cannot point to a variable
   R1 : Int_Ref := Plain'Access;    --  Cannot point to a variable that is not aliased
begin
   null;
end Access_Errors;
access_errors.adb:8:27: error: result must be general access type
access_errors.adb:8:27: error: add "all" to type "Int_Ptr" defined at line 2
access_errors.adb:9:20: error: prefix of "Access" attribute must be aliased

Thanks to this rule, you can tell from the declarations which variables may be pointed to.

Kinds of access types and what they can point toShows that a pool-specific access type points only to values created with new, and a general access type can point to values created with new as well as variables declared aliased.Pool-specific access type (access)Values created with newGeneral access type (access all)Variables declared aliasedVariables without aliasedNo access type can point to them

Figure 3: Only general access types can point to variables declared aliased.

Lifetime checks (accessibility checks)

A common mistake in C is to store the address of a function’s local variable somewhere that survives after the function returns. Ada prevents this with accessibility checks. If a variable’s lifetime (accessibility level) is shorter than the access type’s lifetime, a value pointing to that variable cannot be stored in that type5.

When it can be determined at compile time, it is a compile error. The following package tries to store a value pointing to a parameter variable in Saved, a library-level variable (one that lives until the end of the program).

package Registry is
   type Int_Ref is access all Integer;

   Saved : Int_Ref;  --  A library-level variable; it lives until the end of the program

   procedure Remember_Static (X : aliased in out Integer);
end Registry;

package body Registry is
   procedure Remember_Static (X : aliased in out Integer) is
   begin
      Saved := X'Access;
   end Remember_Static;
end Registry;
registry.adb:4:16: error: non-local pointer cannot point to local object

If the parameter is of an anonymous access type (access Integer), what the caller passes is not known at compile time. In that case the check is done at run time, and if it fails, Program_Error is raised5.

package Registry is
   type Int_Ref is access all Integer;

   Saved : Int_Ref;  --  A library-level variable; it lives until the end of the program

   Global_Value : aliased Integer := 100;

   procedure Remember (X : access Integer);
end Registry;

package body Registry is
   procedure Remember (X : access Integer) is
   begin
      Saved := Int_Ref (X);  --  The actual parameter's lifetime is checked at run time
   end Remember;
end Registry;
with Ada.Text_IO; use Ada.Text_IO;
with Registry;    use Registry;

procedure Remember_Demo is
   procedure Try_Local is
      Local : aliased Integer := 1;
   begin
      Remember (Local'Access);
      Put_Line ("saved a local object");
   end Try_Local;
begin
   Remember (Global_Value'Access);
   Put_Line ("saved:" & Integer'Image (Saved.all));

   Try_Local;
end Remember_Demo;
saved: 100

raised PROGRAM_ERROR : registry.adb:4 accessibility check failed

Passing the library-level Global_Value could be stored, while passing the local variable of Try_Local stopped at the point of storing it. If you do the same thing in C, the store succeeds, and using that value after the function returns is undefined behavior. In Ada, it stops on the line that tries to store it.

When accessibility checks are performedShows that trying to store a value pointing to a short-lived variable in a long-lived access type is a compile error if it can be determined at compile time, and raises Program_Error if it cannot be known until run time, as with a parameter of an anonymous access type.YesNo (anonymous access type parameter)Store a value pointing to a short-lived variableIs the lifetime known at compile time?Compile errorChecked at run timeProgram_Error

Figure 4: Lifetime checks are done at compile time when possible, and at run time otherwise.

Access types that exclude null

A variable of an access type is null unless you give it an initial value. Adding not null to the type of a parameter or variable makes that type exclude null2. If null is passed, Constraint_Error is raised at the point of the call.

with Ada.Text_IO; use Ada.Text_IO;

procedure Not_Null_Demo is
   type Int_Ptr is access Integer;

   --  The type promises that the parameter is never null
   procedure Show (P : not null Int_Ptr) is
   begin
      Put_Line ("value =" & Integer'Image (P.all));
   end Show;

   A : constant Int_Ptr := new Integer'(5);
   B : Int_Ptr;  --  An access variable with no initial value is null
begin
   Show (A);
   Show (B);
end Not_Null_Demo;
not_null_demo.adb:13:04: warning: variable "B" is read but never assigned [-gnatwv]
value = 5

raised CONSTRAINT_ERROR : not_null_demo.adb:16 access check failed

The first is the compile-time warning, the second the run-time output. Show no longer needs to check for null, and when null is passed, the error is reported at the calling line (line 16) rather than inside Show.

Deallocating with Unchecked_Deallocation

A value allocated with new is deallocated with a procedure instantiated from Ada.Unchecked_Deallocation. By convention this procedure is named Free. Under the RM rules, X becomes null after Free (X), and if X is already null, nothing happens6.

As the word Unchecked in the name says, this procedure does not look at other pointers to the same memory. In the following example, A is deallocated while A and B point to the same value.

with Ada.Text_IO; use Ada.Text_IO;
with Ada.Unchecked_Deallocation;

procedure Dangling_Demo is
   type Int_Ptr is access Integer;
   procedure Free is new Ada.Unchecked_Deallocation (Integer, Int_Ptr);

   A : Int_Ptr := new Integer'(42);
   B : constant Int_Ptr := A;  --  A second pointer to the same object
begin
   Free (A);
   Put_Line ("A = null : " & Boolean'Image (A = null));
   Put_Line ("B = null : " & Boolean'Image (B = null));
   Put_Line ("B.all    =" & Integer'Image (B.all));  --  Read the freed memory
end Dangling_Demo;
A = null : TRUE
B = null : FALSE
B.all    = 1613378576

A became null, but B still holds the original address. The RM calls such a value, which points to an object that no longer exists, a dangling reference, and says that an execution that dereferences it is erroneous (the result is not defined by the standard)6. With GNAT 13.3, no exception was raised, and B.all returned a value other than 42. Running the same program under valgrind reports that freed memory was read.

==6788== Invalid read of size 4
==6788==    at 0x10BAD8: _ada_dangling_demo (dangling_demo.adb:14)
==6788==    by 0x10B8DE: main (b~dangling_demo.adb:248)
==6788==  Address 0x5004040 is 0 bytes inside a block of size 4 free'd
==6788==    at 0x484988F: free (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==6788==    by 0x4B08E48: __gnat_free (in /usr/lib/x86_64-linux-gnu/libgnat-13.so)
==6788==    by 0x10B954: _ada_dangling_demo (dangling_demo.adb:11)

The trace also shows that GNAT’s default pool calls C’s free through __gnat_free.

The pointer left behind after Unchecked_DeallocationShows that Free(A) makes A null, but B, which pointed to the same memory, keeps the original address, dereferencing B is erroneous, and valgrind reports it as a read of freed memory.A and B point to the same valueFree (A)A becomes nullB keeps the original addressRead B.allErroneous (result undefined)valgrind: Invalid read

Figure 5: Free sets only the variable you pass to null; other variables pointing to the same memory remain.

Conversely, if you do not deallocate, the memory is not returned. The following loop assigns the result of new to the same variable 1,000 times, losing the pointer to the previous value each time.

procedure Leak_Demo is
   type Int_Ptr is access Integer;
   P : Int_Ptr;
begin
   for I in 1 .. 1_000 loop
      P := new Integer'(I);  --  The pointer to the previous memory is lost here
   end loop;
end Leak_Demo;
==10504==     in use at exit: 4,000 bytes in 1,000 blocks
==10504== 4,000 bytes in 1,000 blocks are definitely lost in loss record 1 of 1
==10504==    at 0x4846828: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==10504==    by 0x4B08DE1: __gnat_malloc (in /usr/lib/x86_64-linux-gnu/libgnat-13.so)
==10504==    by 0x10A6FF: _ada_leak_demo (leak_demo.adb:6)

Memory that a language with GC would reclaim stayed allocated until the end of the program with GNAT. The GNAT documentation also says that the default pool (System.Pool_Global) uses malloc and free and does not reclaim memory automatically1.

The standard does not assume GC either. In the Ada 2012 revision, the text about garbage collection was removed from the Reference Manual. The Annotated Reference Manual (AARM) says that instead Ada 2012 provides subpools, which are much more predictable than GC7.

SPARK’s ownership rules

SPARK, a subset of Ada, imposes ownership rules on access types. Only one access value at a time can read and write the allocated memory8. I wrote the same sequence as the earlier Dangling_Demo as SPARK code and checked it with GNATprove 15.1 using --mode=check_all.

with Ada.Unchecked_Deallocation;

procedure Dangling_Spark with SPARK_Mode is
   type Int_Ptr is access Integer;
   procedure Free is new Ada.Unchecked_Deallocation (Integer, Int_Ptr);

   A : Int_Ptr := new Integer'(42);
   B : Int_Ptr := A;  --  Ownership of A moves to B
   V : Integer;
begin
   Free (A);
   V := B.all;
   Free (B);
end Dangling_Spark;
dangling_spark.adb:11:10: error: "A" is not readable
   11 |   Free (A);
      |         ^ here
  object was moved at line 8 [E0010]
    8 |   B : Int_Ptr := A;  --  Ownership of A moves to B
      |                  ^ here

Ownership moves to B at B := A, so the check stopped the line that tries to deallocate A. In Dangling_Demo, compiled and run with GNAT, the same mistake raised no exception and was only found with valgrind. How to proceed with proofs in SPARK is covered in “Proving AI-Written Code with SPARK”.

How Ada and SPARK differ in handling the same mistakeShows that the mistake of pointing two pointers at the same memory and freeing one of them goes undetected and reads freed memory when run as Ada, while checked as SPARK, GNATprove reports an error because ownership was moved.Point two pointers at the same memory and free itRun it as AdaCheck it as SPARKReads freed memory without detectionGNATprove reports an error due to the ownership move

Figure 6: In SPARK, the same mistake is found at the checking stage by the ownership rules.

If you write in plain Ada, limit the places that call Free to a few spots where it is clear who owns the value. The controlled types below are the main example.

Cleaning up when leaving a scope with controlled types

If you derive from the Controlled type in Ada.Finalization, you can override three procedures9.

Procedure When it is called
Initialize When the object is declared without an initial value
Adjust After a copy by assignment, on the target of the copy
Finalize When leaving the scope, before being overwritten by an assignment, and when deallocated with Free

To check the order in which they are called, I made a type that prints its name each time one is called. In Adjust, the name is lowercased so you can tell which object is the copy.

with Ada.Finalization;

package Tracked is
   type Resource is new Ada.Finalization.Controlled with record
      Name : Character := '?';
   end record;

   overriding procedure Initialize (R : in out Resource);
   overriding procedure Adjust     (R : in out Resource);
   overriding procedure Finalize   (R : in out Resource);
end Tracked;
with Ada.Characters.Handling; use Ada.Characters.Handling;
with Ada.Text_IO;             use Ada.Text_IO;

package body Tracked is
   overriding procedure Initialize (R : in out Resource) is
   begin
      Put_Line ("  Initialize " & R.Name);
   end Initialize;

   --  Lowercase the name so you can tell it is the copy
   overriding procedure Adjust (R : in out Resource) is
   begin
      Put_Line ("  Adjust     " & R.Name & " -> " & To_Lower (R.Name));
      R.Name := To_Lower (R.Name);
   end Adjust;

   overriding procedure Finalize (R : in out Resource) is
   begin
      Put_Line ("  Finalize   " & R.Name);
   end Finalize;
end Tracked;
with Ada.Finalization;
with Ada.Text_IO; use Ada.Text_IO;
with Tracked;     use Tracked;

procedure Raii_Demo is
begin
   Put_Line ("enter block");
   declare
      --  No initial value, so Initialize is called
      A : Resource;
      --  Initialized with an aggregate, so Initialize is not called
      B : Resource := (Ada.Finalization.Controlled with Name => 'B');
   begin
      A.Name := 'A';
      Put_Line ("assign B := A");
      B := A;
      Put_Line ("leave block");
   end;
   Put_Line ("after block");

   Put_Line ("enter block with exception");
   begin
      declare
         C : Resource;
      begin
         C.Name := 'C';
         raise Constraint_Error;
      end;
   exception
      when Constraint_Error =>
         Put_Line ("handled Constraint_Error");
   end;
end Raii_Demo;
enter block
  Initialize ?
assign B := A
  Finalize   B
  Adjust     A -> a
leave block
  Finalize   a
  Finalize   A
after block
enter block with exception
  Initialize ?
  Finalize   C
handled Constraint_Error

The output shows the following:

  • Initialize was called only for A, which had no initial value, and not for B, which was initialized with an aggregate. The RM also states that Initialize is called on default initialization9.
  • For B := A, first Finalize was called on B, which is being overwritten, and then Adjust on the copied value.
  • When leaving the block, Finalize was called in the reverse order of declaration (the copy in B, then A).
  • Finalize was also called for C, which left its block through an exception.
The order in which controlled type procedures are calledShows that Initialize is called for a declaration without an initial value, that an assignment calls Finalize on the overwritten side and Adjust on the copy, and that leaving the scope or leaving by an exception calls Finalize in the reverse order of declaration.Declare without an initial valueInitializeExecute B := AFinalize B's old valueAdjust the copied valueLeave the scope or exit by an exceptionFinalize in reverse order of declaration

Figure 7: An assignment calls Finalize on the old value and Adjust on the copy, and leaving the scope calls Finalize in reverse order.

Because Finalize is also called when leaving by an exception, writing the heap deallocation here means the caller can no longer forget to write Free. This is the same idea as RAII in C++, where deallocation is written in the destructor. RAII on the C++ side is covered in “Practical Multithreading Best Practices: C++ Edition”.

For resources you do not want copied, use Limited_Controlled. It makes a type that cannot be assigned, so there is no Adjust. The example in the next section uses it.

Deciding where memory comes from with storage pools

Where new takes memory from is decided by the storage pool of each access type. If you specify nothing, the implementation’s default pool is used; in GNAT, that is System.Pool_Global, which uses malloc and free1. In Ada you can replace this pool per type or put a limit on it10.

Setting a limit with Storage_Size

When you specify Storage_Size for an access type, new for that type allocates from an implementation-defined pool with at least the specified amount of memory. When that memory is used up, it allocates no more and raises Storage_Error10.

with Ada.Text_IO; use Ada.Text_IO;

procedure Bounded_Demo is
   --  Limit the memory available to new for this type to 64 bytes
   type Int_Ptr is access Integer
     with Storage_Size => 64;

   P     : Int_Ptr;
   Count : Natural := 0;
begin
   loop
      P := new Integer'(Count);
      Count := Count + 1;
   end loop;
exception
   when Storage_Error =>
      Put_Line ("Storage_Error after" & Natural'Image (Count) & " allocations");
end Bounded_Demo;
Storage_Error after 8 allocations

After 8 allocations, the 9th new raised Storage_Error. How much one allocation uses is decided by the implementation, so the count cannot simply be derived from the size of Integer. The memory of this pool is reclaimed when leaving the scope where the access type was declared10. You can use this when you want to cap memory use per type, for example in embedded devices where you want a fixed memory limit, or in processes that run for a long time.

Allocation with an access type that specifies Storage_SizeShows that executing new with an access type that specifies Storage_Size allocates if the pool has free space, raises Storage_Error if it is used up, and that the pool's memory is reclaimed when leaving the scope where the access type was declared.YesNoExecute new with Int_PtrDoes the pool have free space?AllocateStorage_ErrorLeave the scope where the access type was declaredReclaim the pool's memory

Figure 8: A type with Storage_Size raises Storage_Error when its budget is used up, and the whole budget is reclaimed when leaving the scope.

Writing your own pool

You can make your own pool by deriving from Root_Storage_Pool in System.Storage_Pools and overriding Allocate, Deallocate, and Storage_Size10. The following pool counts allocations, deallocations, and bytes in use, and leaves the actual allocation to GNAT’s default pool. System.Pool_Global is a GNAT-specific unit, so other implementations need a different place to allocate from.

with System;
with System.Storage_Elements; use System.Storage_Elements;
with System.Storage_Pools;    use System.Storage_Pools;

package Counting_Pools is
   --  A pool that counts allocations, deallocations, and bytes in use
   type Counting_Pool is new Root_Storage_Pool with record
      Allocations   : Natural       := 0;
      Deallocations : Natural       := 0;
      In_Use        : Storage_Count := 0;
   end record;

   overriding procedure Allocate
     (Pool                     : in out Counting_Pool;
      Storage_Address          : out System.Address;
      Size_In_Storage_Elements : Storage_Count;
      Alignment                : Storage_Count);

   overriding procedure Deallocate
     (Pool                     : in out Counting_Pool;
      Storage_Address          : System.Address;
      Size_In_Storage_Elements : Storage_Count;
      Alignment                : Storage_Count);

   overriding function Storage_Size
     (Pool : Counting_Pool) return Storage_Count;

   procedure Report (Pool : Counting_Pool; Label : String);
end Counting_Pools;
with Ada.Text_IO;      use Ada.Text_IO;
with System.Pool_Global;  --  GNAT's default pool (a GNAT-specific unit)

package body Counting_Pools is
   Backing : System.Pool_Global.Unbounded_No_Reclaim_Pool
     renames System.Pool_Global.Global_Pool_Object;

   overriding procedure Allocate
     (Pool                     : in out Counting_Pool;
      Storage_Address          : out System.Address;
      Size_In_Storage_Elements : Storage_Count;
      Alignment                : Storage_Count) is
   begin
      Backing.Allocate (Storage_Address, Size_In_Storage_Elements, Alignment);
      Pool.Allocations := Pool.Allocations + 1;
      Pool.In_Use      := Pool.In_Use + Size_In_Storage_Elements;
   end Allocate;

   overriding procedure Deallocate
     (Pool                     : in out Counting_Pool;
      Storage_Address          : System.Address;
      Size_In_Storage_Elements : Storage_Count;
      Alignment                : Storage_Count) is
   begin
      Backing.Deallocate (Storage_Address, Size_In_Storage_Elements, Alignment);
      Pool.Deallocations := Pool.Deallocations + 1;
      Pool.In_Use        := Pool.In_Use - Size_In_Storage_Elements;
   end Deallocate;

   overriding function Storage_Size
     (Pool : Counting_Pool) return Storage_Count is
   begin
      return Storage_Count'Last;  --  No upper limit
   end Storage_Size;

   procedure Report (Pool : Counting_Pool; Label : String) is
   begin
      Put_Line (Label & ": allocations =" & Natural'Image (Pool.Allocations)
                & ", deallocations =" & Natural'Image (Pool.Deallocations)
                & ", in use =" & Storage_Count'Image (Pool.In_Use) & " bytes");
   end Report;
end Counting_Pools;

This pool is combined with the Limited_Controlled type from the previous section. Buffer is a type that holds exactly one array on the heap and frees it in Finalize. The access type Byte_Array_Ptr specifies Storage_Pool => Pool, so that new for this type goes through Counting_Pool.

with Ada.Finalization;
with Counting_Pools;

package Buffers is
   Pool : Counting_Pools.Counting_Pool;

   type Byte_Array is array (Positive range <>) of Character;
   type Byte_Array_Ptr is access Byte_Array
     with Storage_Pool => Pool;

   --  A type that holds exactly one array on the heap and frees it when leaving the scope
   type Buffer is new Ada.Finalization.Limited_Controlled with record
      Data : Byte_Array_Ptr;
   end record;

   procedure Allocate (B : in out Buffer; Length : Positive);
   overriding procedure Finalize (B : in out Buffer);
end Buffers;
with Ada.Unchecked_Deallocation;

package body Buffers is
   procedure Free is new Ada.Unchecked_Deallocation (Byte_Array, Byte_Array_Ptr);

   procedure Allocate (B : in out Buffer; Length : Positive) is
   begin
      Free (B.Data);  --  If it already holds one, give it back first (does nothing if null)
      B.Data := new Byte_Array (1 .. Length);
   end Allocate;

   overriding procedure Finalize (B : in out Buffer) is
   begin
      Free (B.Data);
   end Finalize;
end Buffers;
with Counting_Pools; use Counting_Pools;
with Buffers;        use Buffers;

procedure Pool_Demo is
begin
   Report (Pool, "start      ");

   declare
      X, Y : Buffer;
   begin
      Allocate (X, 100);
      Allocate (Y, 200);
      Allocate (X, 50);  --  Give back X's 100 bytes, then take 50 bytes again
      Report (Pool, "in block   ");
   end;  --  Finalize for X and Y frees the rest

   Report (Pool, "after block");
end Pool_Demo;
start      : allocations = 0, deallocations = 0, in use = 0 bytes
in block   : allocations = 3, deallocations = 1, in use = 268 bytes
after block: allocations = 3, deallocations = 3, in use = 0 bytes

Inside the block there were 3 allocations and 1 deallocation, and after leaving the block the counts of allocations and deallocations matched and usage was back to 0. Pool_Demo never calls Free. Deallocation happens only in the re-allocation inside Allocate and in Buffer’s Finalize. valgrind’s --leak-check=full also reported nothing for this program.

The 268 bytes in use are more than the total number of elements (250). Printing each size passed to Allocate gave 108, 208, and 60 bytes. The amount beyond the element count presumably includes space for holding the array’s index bounds, but the breakdown is up to the implementation and was not verified in this article.

Allocation and deallocation through the counting poolShows that new and Free in the Buffer type call Allocate and Deallocate of the Counting_Pool specified for the access type, and Counting_Pool counts them and then leaves allocation and deallocation to GNAT's default pool.new inside BufferAllocate of Counting_PoolFree in FinalizeDeallocate of Counting_PoolCount calls and usageGNAT's default poolmalloc and free

Figure 9: When you specify a pool for an access type, every new and Free for that type goes through the pool.

A pool that counts allocations like this can be used in tests to check that “usage returns to 0 after the operation.” GNAT also provides GNAT.Debug_Pools, which detects things like references to freed memory11. It was not tried in this article.

Subpools

Ada 2012 added System.Storage_Pools.Subpools, which divides one pool into multiple subpools12. You can specify the subpool to allocate from, as in new (Subpool) T, and deallocate a whole subpool at once. This suits uses such as throwing away, all at once at the end, the values allocated for each unit of processing. No subpool example was run in this article.

Reducing hand-written pointers with the standard containers

Combining access types and controlled types lets you build safe types, but you do not need to write your own every time you need a variable-length sequence or a dictionary. Ada.Containers, available since Ada 2005, has vectors, lists, maps, sets, and more13, and the containers handle allocating and deallocating elements.

Elements whose size differs from value to value, such as Strings of different lengths, go into the containers with Indefinite_ in their names.

with Ada.Containers.Indefinite_Vectors;
with Ada.Text_IO; use Ada.Text_IO;

procedure Containers_Demo is
   --  Strings of different lengths can be elements as they are
   package String_Vectors is new Ada.Containers.Indefinite_Vectors
     (Index_Type => Positive, Element_Type => String);

   Names : String_Vectors.Vector;
begin
   Names.Append ("Ada");
   Names.Append ("SPARK");
   Names.Append ("GNAT");

   for Name of Names loop
      Put_Line (Name & ":" & Natural'Image (Name'Length));
   end loop;

   Names.Delete (2);
   Put_Line ("length after delete:" & Ada.Containers.Count_Type'Image (Names.Length));
end Containers_Demo;
Ada: 3
SPARK: 5
GNAT: 4
length after delete: 2

This program has neither new nor Free. Run under valgrind, usage at exit was 0 bytes, and every allocated block had been freed.

==10040==     in use at exit: 0 bytes in 0 blocks
==10040== All heap blocks were freed -- no leaks are possible

The containers are generic packages, so you instantiate them by passing the element type. How generics work is explained in “Generic Programming in Ada”.

Rules of thumb for choosing a mechanism

The mechanisms so far are easier to choose if you consider them in order from the top.

The order for choosing how to handle memory in AdaShows the order of consideration: first, whether it can be declared as a value; use containers if elements are added and removed; access types and controlled types if a custom structure is needed; and storage pools if you want to decide where memory comes from or set a limit.YesNoYesNoYesCan it be declared and returned as a value?Handle it as a variable or return valueIs a sequence or dictionary enough?Ada.ContainersAccess types and controlled typesDo you want to decide where memory comes from or set a limit?Storage pools

Figure 10: If it cannot be handled as a value, consider containers; if those are not enough, consider access types and controlled types.

  • If the only issue is that the length is decided at run time, declare it as a value and return it from functions. You do not need new.
  • If you add and remove elements, use Ada.Containers.
  • If you need a custom structure such as a tree or graph, build it with access types, and limit the places that call Free to a few, such as the Finalize of a controlled type. Use aliased and not null to write into the type what can be pointed to and whether null is allowed.
  • When you want to decide the allocation limit or where memory comes from, specify Storage_Size or a storage pool.

Even when Ada’s types express how memory is used, the compiler does not go as far as stopping misuse of Unchecked_Deallocation. Keep that part small, and check the remaining places with valgrind or a counting pool. For parts where you want the ownership rules checked as well, you can also write them in SPARK.

How to investigate memory that keeps growing in an environment with GC, such as .NET, is covered in “Telling GC Lag from a Memory Leak in .NET”.

References

  1. GNAT Reference Manual, System.Pool_Global (s-pooglo.ads). The same pool as the default storage pool, which uses malloc and free and does no automatic reclamation. ↩ ↩2 ↩3

  2. Ada Reference Manual (Ada 2022), 3.10 Access Types. Pool-specific and general access types, aliased, and null exclusion (not null). ↩ ↩2 ↩3 ↩4

  3. GNAT User’s Guide, Switches for gnatbind. The role and implementation of the secondary stack, in the description of the -D switch. ↩ ↩2

  4. GNAT Reference Manual, No_Secondary_Stack. A restriction that guarantees at compile time that the generated code does not reference the secondary stack. ↩ ↩2

  5. Ada Reference Manual (Ada 2022), 3.10.2 Operations of Access Types. Accessibility levels, and Program_Error when the check fails. ↩ ↩2

  6. Ada Reference Manual (Ada 2022), 13.11.2 Unchecked Storage Deallocation. That X becomes null after Free (X), dangling references, and erroneous execution. ↩ ↩2

  7. Annotated Ada Reference Manual (Ada 2022), 13.11.3 Default Storage Pools. 9.d/3 under “Wording Changes from Ada 2005”: that Ada 2012 removed the text about garbage collection and provided subpools. ↩

  8. AdaCore, SPARK User’s Guide, Language Restrictions. That access types and allocators follow the ownership rules, and that only one access value at a time can read and write the allocated memory (Memory Ownership Policy). ↩

  9. Ada Reference Manual (Ada 2022), 7.6 Assignment and Finalization. Controlled and Limited_Controlled, the conditions under which Initialize, Adjust, and Finalize are called, and the steps of assignment. ↩ ↩2

  10. Ada Reference Manual (Ada 2022), 13.11 Storage Management. Root_Storage_Pool with Allocate, Deallocate, and Storage_Size; the pool of a type with Storage_Size specified; and Storage_Error. ↩ ↩2 ↩3 ↩4

  11. GNAT User’s Guide, The GNAT Debug Pool Facility. GNAT.Debug_Pools, which checks on every dereference that the pointer points to properly allocated memory. ↩

  12. Ada Reference Manual (Ada 2022), 13.11.4 Storage Subpools. Subpools with System.Storage_Pools.Subpools. ↩

  13. Ada Reference Manual (Ada 2022), A.18 Containers. The vectors, lists, maps, and sets of Ada.Containers, and the containers with Indefinite_ in their names. ↩

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These are topic pages close to this theme. Starting from the article, you can go on to related services and other articles.

This article leads to the following service pages. Start from the entry point closest to your situation.

Bug Investigation & Root Cause Analysis

Investigating symptoms such as memory that keeps growing during long runs, or values that get corrupted because code reads memory that should already have been freed, is a good fit for bug investigation and root-cause analysis.

Frequently Asked Questions

Questions commonly asked at the time of consultation about the topic of this article.

Does Ada have garbage collection?
GNAT's default storage pool allocates and deallocates with malloc and free, and does not automatically reclaim memory that is no longer used. In the Ada 2012 revision, the text about garbage collection was removed from the Reference Manual, and subpools were added as a more predictable mechanism. Deallocate with Unchecked_Deallocation, or leave it to controlled types or the standard containers.
Does a function that returns a string whose length is decided at run time use the heap?
You can return it without writing new. Depending on the target, GNAT uses the secondary stack for functions that return arrays or records of unknown length. With GNAT 13.3 on Linux x86_64, returning a 1 MB string 10,000 times caused only a single heap allocation of about 1 MB. Adding pragma Restrictions (No_Secondary_Stack) makes such uses a compile error.
After deallocating with Unchecked_Deallocation, what happens to other pointers to the same memory?
The variable you passed to the deallocation becomes null, but other variables that pointed to the same memory keep the original address. Dereferencing them makes the execution erroneous (the result is not defined by the standard). When tried with GNAT 13.3, no exception was raised, and valgrind reported it as a read of freed memory.
Does Ada have an equivalent of C++ RAII?
Yes: the Controlled and Limited_Controlled types in Ada.Finalization. If you override Finalize, it is called when the object goes out of scope, including when leaving by an exception. If you write the heap deallocation there, the caller does not have to write it.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and bug investigation, with particular strength in projects where existing assets remain and in investigating failures whose cause is hard to see.

Back to the Blog