Quoting the relevant context from that post that this elaborates on:
I also need to touch on one of Ada's design philosophies, which is to avoid dynamic allocation unless actually necessary for safety reasons, since it doesn't have a borrow checker for fully-safe allocation like Rust does[1]. The key problem is that when you use one single call stack to both store return addresses and local variables, a called function can't return the contents of one of its stack-allocated variables “upwards” because the callee needs to be able to pop off the return address and have the stack pointer be precisely where it was before it got called. Instead, any larger-than-register returned value has to have its stack slots be allocated by the caller, with a size known to the caller. Ada solves this by having a second non-call stack[3] that functions can allocate compile-time unknown space on and return that upward, without needing dynamic allocation and preserving the nice scoping and free lexical deallocation that a stack provides. The only time you really need dynamic allocation in Ada is when you're interworking with C/C++/Fortran/COBOL[4], or if you have an aggregate type that contains an object of unknown length, e.g. an array of strings, where you have to dynamically allocate all the strings and then the array of pointers can be handled like normal (Ada has a rich container library so it's rare you need to think about it at all). Allowing use of the second stack rather than dynamic allocation (i.e. avoiding needing a pointer elsewhere in order to encapsulate an undefined-length objects within another object) is a key motivator for one style of Ada's dependent types.
[1]: SPARK, the formally verified subset of Ada, does have a borrow checker though!
[3]: Comments in the GCC GNAT source code is the only real documentation on how the second stack works in detail that I know of[2]. Probably because while I consider it a brilliant solution, the secondary stack is actually an implementation detail and the standard just mandates “you have to be able to return indefinite types”. An implementation could in theory dynamically allocate and insert malloc and free calls appropriately in the caller and callee code.
[4]: The Ada specification has official, albeit optional-to-implement, APIs for interfacing with all of those languages lol
I do want to note that while I say “[Ada avoids] dynamic allocation”, in practice you still dynamically allocate a lot. The key benefit of Ada's dependent types and the secondary stack is that it allows you to avoid dynamic allocations for objects of unknown but unchanging sizes, as well as avoiding allocation objects of known and unchanging sizes like most languages allow. But there are plenty of scenarios where there are things that need to change size over time and in that case you'd still dynamically allocate the same way as any other language.
Luckily the Ada standard library is very extensive and includes containers and related. For instance, there's the generic packages Ada.Containers.Vectors
and Ada.Containers.Indefinite_Vectors
that are essentially no different from C++ vectors or Java arraylists. There's two packages because the first one is a single dynamically allocated array holding a fixed-size definite type, while the latter is a dynamically allocated array that boxes all of its values to allow it to hold indefinite-sized types. There's also things like bounded vectors that are discriminated types with fixed sizes and use the secondary stack, and hashmaps and ordered trees and strings and whatever. I've never had to manually allocate memory when working in plain Ada, using the standard library works just fine.
The only time I've ever encountered needing manual allocation and deallocation is when interfacing to another language such as C. While Ada does freely allow you to do unsafe, unchecked allocation and deallocation, usually you'd keep the allocation and deallocation confined within the initializer, copy constructor, and deinitializer of an RAII type that encapsulates the underlying C type.
For further reading, Ada calls RAII types “controlled types”, and you'd use the new
keyword and the Ada.Unchecked_Deallocation
subprogram to allocate and deallocate, respectively. To go really down the rabbit hole, there's also the concept of “storage pools” which are essentially fully custom allocators that can be used by a package or even by individual pointer (“access”) types.
As mentioned in a footnote in the quote above, the secondary stack is really an implementation detail. But given that GCC GNAT uses it and has always been the only FOSS Ada compiler, and has been for a long time now really the only modern Ada compiler in existence, it's fair to talk about it.
First-off, you don't need to worry about overflowing it unless you'd also be worrying about a heap OOM too. The runtime automatically allocates new regions for the secondary stack to spill into if necessary; unless you configure it to not do so of course. It is important to note that it does do this dynamic allocation though, because it does mean that, especially for large objects on the secondary stack, you aren't completely avoiding dynamic allocation—although you are avoiding having to deal with it yourself in any way.
The secondary stack is basically just an arena allocator with incremental deallocation. When an object goes lexically out of scope in code then the compiler will mark it as unused, and then the runtime will try to “roll back” the secondary stack as far as possible without clobbering live data (potentially rolling back other regions marked unused if they weren't cleaned up before). Since it's used in primarily situations where stuff would normally be stack-allocated but can't be allocated by the caller because the size is unknown (like returning arrays from a function) it's rare that a dead object can't be fully popped. They are scoped similarly to any other stack-allocated object, just in a different region so they can persist through a return from a function call.
The secondary stack is also allocated in discrete “chunks” rather than as a large continuous block. This means that even if something is on top of the stack and preventing all the unused things below it from being popped, it can still free up some of the unused objects, as long as all the objects in a chunk are out-of-scope. So the only things that get stuck in practice are things that are in the same chunk as and are under the long-lived object, since objects on top of the long-lived object in the chunk can also be freed. That means that the long-lived object as well as all the other trapped things would need to be rather small (and as such relatively inconsequential) allocations, because large allocations will tend to be put in their own otherwise-unused chunk unless there happens to be room in a partially used one.
With the caveat of things rarely getting fully-stuck, there is one way I can think of to “gum up” the second stack and prevent some things from getting deallocated (in theory, I haven't looked with a debugger to see if that actually happens):
function Thing returns String is Helper_Array : constant String := Make_A_String; Out_Buffer : String(1 .. Helper_Array'Length); begin -- Do stuff return Out_Buffer; end;
text/gemini;lang=en-US;charset=utf-8
This content has been proxied by September (ba2dc).