Ch20.13: fast_io Is a Freestanding I/O Library
1. The Claim
fast_io is an I/O library that works with no operating system underneath it. Not "the formatting part is header-only, so you can use it if you avoid the interesting bits" — the whole thing, including containers, formatting, scanning and transcoding, with the operating-system-dependent pieces isolated behind type parameters that the target supplies.
This page is the payoff of the chapter, so it is written as four design decisions, each one the direct answer to a failure the previous pages measured:
| Failure | fast_io's answer |
|---|---|
| One hidden global heap (20.7) | The allocator is a type parameter, and sealed into the container type |
| Error handling needs an OS (20.9) | Herbceptions on the return path, with the policy chosen by the allocator |
| Message tables cost flash (20.10) | A build knob that reports [domain](code), and overridable domains |
| The library headers assume a console (20.6) | The device is a type too — a scatter write, or nothing at all |
2. The Layer Split Is Visible in the Headers
fast_io has three umbrella headers, and the split between them is the freestanding/hosted
boundary made explicit: fast_io_freestanding.h for formatting and scanning into
buffers, fast_io_hosted.h for devices, files and consoles, and
fast_io.h for both.
The same split appears in the containers, and it is enforced by the preprocessor rather than
by documentation. ::fast_io::containers::vector<T, Alloc> is always
available; the convenience alias ::fast_io::vector<T>, which picks the
platform's default heap, exists only when there is a hosted platform:
#if ((__STDC_HOSTED__ == 1 && (!defined(_GLIBCXX_HOSTED) || _GLIBCXX_HOSTED == 1) && \
!defined(_LIBCPP_FREESTANDING)) || \
defined(FAST_IO_ENABLE_HOSTED_FEATURES))
namespace fast_io
{
template <typename T, typename Alloc = ::fast_io::native_global_allocator>
using vector = ::fast_io::containers::vector<T, Alloc>;
So on a freestanding target, ::fast_io::vector does not exist, and the compiler
says so in the clearest possible way — a name that is not found, with
::fast_io::containers::vector suggested next to it. There is no silent
fallback to a hidden heap, because there is no name to fall back to. You write
::fast_io::containers::vector<int, my_arena_allocator>, and the
allocator is part of the type.
3. The Heap Comes From the Target
The default allocator is selected by exactly the condition you would hope for. When the
platform is hosted it is c_malloc_allocator (or the NT heap, or mimalloc, or
whatever was configured); when it is freestanding it is
freestanding_global_allocator:
using native_global_allocator = generic_allocator_adapter<
#if defined(FAST_IO_USE_CUSTOM_GLOBAL_ALLOCATOR)
custom_global_allocator
#elif ...
c_malloc_allocator
#else
freestanding_global_allocator
#endif
>;
And freestanding_global_allocator is only declared in the
library. Its three functions have no definition anywhere in fast_io:
class freestanding_global_allocator
{
public:
static ::fast_io::allocation_least_result allocate_aligned_conditional_zero_at_least_die(::std::size_t alignment, ::std::size_t n, bool zero) noexcept;
static ::fast_io::allocation_least_result allocate_aligned_conditional_zero_at_least_try(::std::size_t alignment, ::std::size_t n, bool zero) FAST_IO_HERBCEPTIONS_THROWS;
static void deallocate_aligned_n(void* p, ::std::size_t alignment, ::std::size_t n) noexcept;
};
That is the whole freestanding allocator contract: allocate-with-alignment, optionally
zeroed, returning {pointer, count}, or throw a herbception, or die. You
implement it for your target — a bump arena, a slab, a per-CPU pool, whatever the
target has — and every fast_io container and every formatting routine that needs
scratch memory uses exactly that, and nothing else. A library that cannot link without you
providing a heap is a library that cannot allocate behind your back.
4. What the Generated Code Requires: Nothing
Here is a program that formats into a stack buffer and uses a container with a
caller-supplied arena. It is one translation unit, it includes only
fast_io_core.h and the vector, and it never touches a file, a console or a
locale:
#include <fast_io_core.h>
#include <fast_io_dsal/vector.h>
template <::std::size_t extent>
class arena {
public:
alignas(::std::max_align_t) static inline ::std::byte storage[extent]{};
static inline ::std::size_t used{};
static ::fast_io::allocation_least_result
allocate_aligned_conditional_zero_at_least_die(::std::size_t alignment,
::std::size_t n, bool) noexcept {
::std::size_t const start{(used + alignment - 1u) / alignment * alignment};
if (start + n > extent) { ::fast_io::fast_terminate(); }
used = start + n;
return {storage + start, n};
}
static void deallocate(void *) noexcept {}
static void deallocate_aligned_n(void *, ::std::size_t, ::std::size_t) noexcept {}
};
using heap_a = ::fast_io::generic_allocator_adapter<arena<4096>>;
int render(int value) throws {
char buffer[64];
::fast_io::containers::vector<int, heap_a> v;
v.push_back(value);
::fast_io::basic_obuffer_view<char> out{buffer, buffer + sizeof(buffer)};
::fast_io::operations::print_freestanding<false>(out, "v[0] = ", v[0], "\n");
return static_cast<int>(out.size());
}
int main() {
try { return render(12345); }
catch throws (::std::error) { return -1; }
}
$ g++ -std=c++26 -fherbceptions -O2 -I fast_io/include -c core2.cc -o core2.o
$ nm -u core2.o
[0 undefined symbols]
$ size core2.o
1444 0 4104 5548 15ac core2.o
Zero. No malloc, no memcpy, no
__cxa_*, no unwinder, no write, no locale, no
__gxx_personality_v0. The 1444 bytes of code and 4 KiB of arena are the entire
program: integer formatting, a growable container, and an error channel. The only thing that
object file needs from the world is a stack.
And when the arena is exhausted, the failure policy is written in the arena: the
_die flavour above calls fast_terminate()
(19.2); the _try
flavour reports ::std::errc::not_enough_memory on the throws
channel instead (19.9).
Choosing which one is a compile-time decision per container type, not a global switch, and
that is what makes a fast_io container safe to hand to a subsystem that promised not to
allocate.
5. Why This Is the Library to Use
A hosted program benefits from all of this without knowing it: no hidden allocation, no
locale, no iostream initialization, no exceptions on the failure path, and one formatting
engine that writes straight into the destination instead of building a temporary
std::string per argument
(17.8 shows what the
+ version costs). Then, the day the code has to run on a board, nothing
changes: the same headers, the same print, the same containers, with the
allocator and the device supplied by the target.
That is the practical form of the argument this chapter has been making. The standard
library's I/O and containers were designed for a hosted program and will never be
freestanding (20.6), because their
interface is the hidden heap and the thrown exception. fast_io's interface is the
allocator and the device, so it works on both sides of the boundary — and that is why
this book uses it for everything, rather than treating it as a faster
<iostream>.