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:

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.