Ch19.9: fast_io's Allocator Flags
1. The Policy Is a Type Parameter, Not a Global
Ch19.2 said what fast_io does
when an allocation cannot be satisfied, a size cannot be represented, or an invariant is
violated: it calls ::fast_io::fast_terminate(), on the spot. That is the
default, and it is the default of every container in the library.
It is not a law of nature, it is a property of the allocator type. fast_io
does not use the standard allocator model — it does not use ::std::allocator,
it does not use ::operator new, and it has nothing to do with
std::allocator_traits. An allocator in fast_io is a type with a
static interface and a policy, and the policy is spelled as a bit flag in the
allocator's own type:
enum class allocator_adapter_flags
{
none = 0,
secure_clear = 1,
throws_on_allocation_failure = 2, // by default we fail fast
throws_on_violations = 4, // by default we fail fast
throws_on_overflow = 8, // by default we fail fast
prefer_big_allocation_on_stack = 16, // pick stack scratch (e.g. print) over heap
};
The three middle flags are the ones this chapter is about: they turn the three
corruption cases of Ch19.2 from
"terminate" into "throw a ::std::error on the throws channel".
secure_clear makes the allocator wipe memory before returning it, and
prefer_big_allocation_on_stack chooses a stack scratch buffer over the heap
when a print operation needs a large temporary — both are policies of the same kind,
which is the point: the allocator is the policy object.
2. How an Allocator Is Assembled
Two layers. The inner layer is a class with static functions in two flavours:
allocate_die, which may not return on failure, and allocate_try,
which throws a herbception. ::fast_io::c_malloc_allocator is the smallest
example and shows the whole idea:
class c_malloc_allocator
{
public:
static inline void *allocate_die(::std::size_t n) noexcept
{
if (n == 0)
{
n = 1;
}
void *p{__builtin_malloc(n)};
if (p == nullptr)
{
::fast_io::fast_terminate();
}
return p;
}
static inline void *allocate_try(::std::size_t n) FAST_IO_HERBCEPTIONS_THROWS
{
if (n == 0)
{
n = 1;
}
void *p{__builtin_malloc(n)};
if (p == nullptr)
{
::fast_io::herbceptions::throws_errc(::std::errc::not_enough_memory);
}
return p;
}
// deallocate and the reallocate pair follow the same two-flavour pattern
};
The outer layer is ::fast_io::generic_allocator_adapter<alloc, flags>. It
reads the flags, and for each operation picks the flavour the policy asks for: with the
throws_on_* flags it requires the _try functions and propagates
their errors; without them it prefers _die, and if the allocator only has a
_try function, the adapter calls it and terminates when it
throws. That fallback is deliberate: a policy that says "fail fast" must fail fast, even
when the underlying allocator was written to throw.
Every fast_io container takes the allocator type:
template <typename T, typename Alloc = ::fast_io::native_global_allocator>
using vector = ::fast_io::containers::vector<T, Alloc>;
template <::std::integral chartype, typename allocator = ::fast_io::native_global_allocator>
using basic_string = ::fast_io::containers::basic_string<chartype, allocator>;
So the choice is made once, per container type, at compile time. There is no global switch
to flip at run time, no virtual allocator, no std::pmr, and no way for two
parts of a program to disagree about the policy of a container they share: the policy is
part of the type.
3. Asking for Herbceptions
Here is the same program as
Ch19.2's, with an allocator that
says "report it, do not die". The underlying allocator is the same
c_malloc_allocator, and the container is the same basic_string
— only the flag changed:
#include <fast_io.h>
#include <fast_io_dsal/string.h>
/* the same malloc-backed allocator, but with the failure policy changed */
using throwing_allocator = ::fast_io::generic_allocator_adapter<
::fast_io::c_malloc_allocator,
::fast_io::allocator_adapter_flags::throws_on_allocation_failure |
::fast_io::allocator_adapter_flags::throws_on_violations |
::fast_io::allocator_adapter_flags::throws_on_overflow>;
using throwing_string = ::fast_io::basic_string<char, throwing_allocator>;
int main()
{
try
{
throwing_string s;
::std::size_t const huge{static_cast<::std::size_t>(1) << 40};
::fast_io::io::print("reserving ", huge, " bytes\n");
s.reserve(huge);
::fast_io::io::print("reserved ", s.capacity(), " bytes\n");
}
catch throws (::std::error e)
{
::fast_io::io::print("caught ", e, "\n");
}
}
reserving 1099511627776 bytes
caught [posix](12)Not enough space
The error is ::std::errc::not_enough_memory, reported by
c_malloc_allocator::allocate_try when malloc returns
nullptr, and it arrives as a normal herbception with a normal domain, so
::fast_io::mnp::message(e) prints the operating system's own text for it.
Notice what did not happen: no legacy exception, no bad_alloc, no
unwinding, no ::std::terminate, and no null pointer.
The huge value is a size the operating system cannot possibly satisfy
(1 TiB); a size that cannot be represented is the throws_on_overflow
case instead, and a null handle passed to a container operation is
throws_on_violations. They are separate flags because a program may want to
report one and die on the others.
4. When to Turn It On
The default is right for applications. Turn the flags on in exactly one situation: you
are writing a library, and your caller must be able to see the failure. A library
that terminates cannot be composed with anything; a library that reports
::std::errc::not_enough_memory can be used by an application that decides, at
its own boundary, whether to retry, to shrink the request, or to die.
| You are writing | Allocator policy | Why |
|---|---|---|
| An application | Default: fail fast | There is nobody above you to report to, and a program that survives a failed allocation is a program nobody has audited |
A library with a throws interface |
throws_on_allocation_failure (and the other two if you expose
sizes) |
Your caller can only handle what it is told about |
| A test harness | throws_on_allocation_failure |
You want to prove the failure path, not to abort the test runner |
| Anything holding secrets | Default plus secure_clear |
Freed memory must not retain the plaintext |
And the rule that keeps this honest: do not mix policies inside one object.
A container's allocator type is part of the container's type, so a
::fast_io::basic_string<char, throwing_allocator> cannot be silently
passed to code expecting ::fast_io::string. That is the same "the policy is
the type" principle as everywhere else in this book, and it is why the choice cannot be
wrong at run time.
5. Why Not ::std::allocator
The standard allocator model cannot express any of this, and the reasons are structural:
-
There is no policy. A standard allocator has one failure mode chosen by
the allocator's own implementation:
::std::allocator<T>::allocatethrows::std::bad_alloc, and that is all it can do. There is no way to say "fail fast here" or "report a size overflow differently" without replacing the whole allocator, and then you are no longer using the standard model. -
It is type-parameterized by the wrong thing.
::std::allocator<T>is parameterized by the element type, so two containers with different element types have different allocator types even when the policy is identical, and rebound allocators make the policy impossible to name. -
It allocates through
::operator new, which is itself a global with an invisible failure policy and, in every implementation, is not freestanding on the targets that matter (Ch20). -
It reports failure by throwing a legacy exception, which
Ch19.8 rules out for most
targets.
std::nothrowis the other option, and Ch19.2 rules that out.
fast_io's allocator is a static interface with a policy and no state: the compiler picks
the _die or the _try implementation for you from the flags, and
the failure path is whatever that policy says. That is what makes it possible to have
::fast_io::vector terminate in a game and report
::std::errc::not_enough_memory in a library, from the same header, with no
configuration file and no runtime switch.