The C++ Interface
An optional header-only layer over the builder API, where mistakes the C API allows stop compiling and ownership is handled by scope.
This page describes the current SSA builder and backend implementation. Start with the new text walkthrough for esper 1 syntax and
the roadmap for the library-first migration boundary.
The previous guide builds a unit through esper.h. This
one builds the same unit through esper.hpp, which is a second spelling of the
same API rather than a second API. It adds no capability. What it adds is that a
set of mistakes the C API allows stop compiling, and that nothing has to be
freed by hand.
It is optional and header-only. There is no library to link beyond libesper
itself, no .cpp to compile, and no allocation the C API did not already make.
A C consumer never parses a line of it.
#include "esper.hpp"
using namespace esper;
Unit unit("sum_to_ten");
Function fn = unit.function("add", {I32, I32}, {I32});
fn.bind(fn.block("entry"));
fn.ret({fn.add(I32, fn.param(0), fn.param(1))});
if (!fn.finish())
std::fprintf(stderr, "%.*s\n", (int)fn.error().size(), fn.error().data());
What it buys
Three things, and it is worth being precise about them because the layer is deliberately thin.
A block index and an SSA value stop being the same type. Both are a
uint32_t in C, so passing one where the other belongs compiles, and produces a
program that branches somewhere else. Value, Block, Global, Const,
Signature, Scope, Local and Source are eight distinct types, each
wrapping one word, with no implicit conversion to a number and no arithmetic.
Block header = fn.block("header");
Value count = fn.ldi(I32, 0);
fn.add(I32, header, count); // does not compile
fn.jmp(count); // does not compile
fn.add(I32, 0u, 1u); // does not compile
Ownership is scope. Unit owns the EsperUnit * and frees it on
destruction; Function owns the EsperBuilder * and releases it whether or not
finish() was called. Both are move-only, because the C API has no way to
duplicate either, so a copy constructor could only be a lie.
Everything else is the same calls in the same order. tests/api_overhead.sh
compiles the worked example both ways at -O2 and compares the sequence of
calls each object makes into the library; they are identical, which is what
“zero overhead” is being claimed to mean.
The worked example
The same two functions the builder guide builds: a straight-line add, and a
counting loop that carries its values on the back edge.
func add(a: Int32, b: Int32) -> Int32 {
return a + b
}
func main() -> Int32 {
var sum: Int32 = 0
for i in 1..<11 { sum = add(sum, i) }
return sum // 55
}
The straight-line function
static void build_add(esper::Unit &unit) {
using namespace esper;
Function fn = unit.function("add", {I32, I32}, {I32});
fn.bind(fn.block("entry"));
Value sum = fn.add(I32, fn.param(0), fn.param(1));
fn.ret({sum});
if (!fn.finish())
std::fprintf(stderr, "add: %.*s\n", (int)fn.error().size(),
fn.error().data());
}static void build_add(EsperUnit *unit) {
EsperTypeRef params[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
EsperTypeRef rets[1] = {ESP_TYPE_I32};
EsperBuilder *b = esper_build_begin(unit, "add", params, 2, rets, 1);
esper_build_bind(b, esper_build_block(b, "entry"));
EsperVal sum = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I32,
esper_build_param(b, 0),
esper_build_param(b, 1));
esper_build_ret(b, &sum, 1);
if (!esper_build_end(b))
fprintf(stderr, "add: %s\n", esper_builder_last_error(b));
esper_builder_free(b);
}Three differences, and none of them is a difference in the IR produced.
The opcode is a method name rather than an argument, so fn.add and fn.div_s
are separate calls and cannot be misspelled into each other. The whole family is
there: sub, mul, div_s, div_u, rem_s, rem_u, the shifts and
rotations, and_, or_, xor_ and not_ with the trailing underscore the
keywords force, every comparison, the float family, the conversions, the
saturating and overflow-checked forms, and the lane-wise operations.
fn.binary(op, ...) is still there for a pass that carries the opcode as data.
The return list is a braced list rather than a pointer and a count, so a call site cannot pass a length that does not match what it handed over.
There is no esper_builder_free. The Function destructor does it, including
on the path where finish() was never reached.
The loop, and the values it carries
static void build_main(esper::Unit &unit) {
using namespace esper;
Function fn = unit.function("main", {}, {I32});
// Created up front, so the branches below can name them.
Block entry = fn.block("entry");
Block header = fn.block("header");
Block body = fn.block("body");
Block done = fn.block("done");
fn.bind(entry);
Value zero = fn.ldi(I32, 0);
Value one = fn.ldi(I32, 1);
fn.jmp(header, {zero, one});
fn.bind(header);
Value sum = fn.block_param(I32);
Value i = fn.block_param(I32);
Value limit = fn.ldi(I32, 11);
Value test = fn.lt_s(I32, i, limit);
fn.jmp_if(test, body, done);
fn.bind(body);
Value next_sum = fn.call(I32, "add", {sum, i});
Value step = fn.ldi(I32, 1);
Value next_i = fn.add(I32, i, step);
// The back edge, written where the values it carries exist.
fn.jmp(header, {next_sum, next_i});
fn.bind(done);
fn.ret({sum});
fn.finish();
}
The two rules from the builder guide are unchanged, because they are rules about the IR rather than about the API. Every parameter must be declared before anything else in its block, and every branch into the block carries one argument per parameter, in the order they were declared.
Driving it is the same shape, minus the frees:
int main() {
esper::Unit unit("sum_to_ten");
build_add(unit);
build_main(unit);
unit.print(stdout);
std::printf("main() = %d\n", unit.exec("main"));
unit.emit_c(stdout);
return 0;
}
This prints exactly what the builder guide prints, and unit.exec("main")
returns 55. That is not an assertion made here for the reader’s benefit:
tests/api_smoke.cpp builds the unit both ways and compares the printed text,
so the two halves of this page are the same program or the build fails.
Binding blocks
bind() is the primary form, and the examples above use it because it is what a
real frontend needs. A visitor walking an AST emits from call sites that are not
lexically nested, and a form requiring a lambda around each block would turn
that frontend inside out.
Forgetting to bind is already caught. The builder reports when anything is emitted with no block bound, so the instruction is refused rather than quietly misplaced:
Function fn = unit.function("bad", {}, {I32});
Value v = fn.ldi(I32, 1); // no bind() above this
// v is NoValue, fn.error_count() is 1, and fn.finish() is false.
For hand-written emission where the nesting is natural, fill() binds and runs
a callable. It is a convenience rather than the recommended path:
fn.fill(entry, [&] {
Value zero = fn.ldi(I32, 0);
fn.jmp(header, {zero});
});
Types
Type wraps EsperTypeRef, the same word the C API takes. The named
constants are Void, I8, I16, I32, I64, F32, F64, Ptr, V128 and
Tuple; Type::i(n) builds an integer of any width, since the encoding allows
it.
What it adds over the raw word is that a type is not interchangeable with the
integers around it: a member index, a block id and a type are all uint32_t in
C, and passing one where another belongs compiles. It also carries the
aggregate helpers, so declaring a struct and using it read as one step:
Type point = unit.struct_type("Point", {{"x", I32}, {"y", I32}});
Function area = unit.function("area", {point}, {I32});
area.bind(area.block("entry"));
Value p = area.param(0);
area.ret({area.add(I32, area.field(I32, p, 0), area.field(I32, p, 1))});
area.finish();
struct_type and union_type declare the type and its members and lay the
table out, so the offsets its members report are final when they return.
array_type is the array form. For a frontend whose types name each other,
declare_type and type_add_member still exist, with type_layout() to run at
the end.
struct_new and array_new take the Type rather than a table index, so the
same value names the type everywhere:
Value made = fn.struct_new(point, {fn.ldi(I32, 20), fn.ldi(I32, 22)});
Value x = fn.field(I32, made, 0);
Value with = fn.field_set(made, 0, fn.ldi(I32, 1));
Errors
Errors accumulate and are checked once, which mirrors the C contract exactly.
Once the builder records a mistake every emitter answers NoValue and does
nothing, so a frontend emits a whole function and checks at the end. The first
error is the one worth reading; the rest are usually its consequences.
Function fn = unit.function("add", {I32, I32}, {I32});
fn.bind(fn.block("entry"));
fn.ret({fn.add(I32, fn.param(0), fn.param(1))});
if (!fn.finish()) {
// error() is a std::string_view of the first mistake.
std::fprintf(stderr, "%.*s\n", (int)fn.error().size(), fn.error().data());
return 1;
}
finish() returns something contextually convertible to bool that carries the
finished function on success:
if (Built built = fn.finish()) {
Func f = built.func();
std::printf("%.*s has %u blocks\n", (int)f.name().size(), f.name().data(),
f.block_count());
}
There are no exceptions here and no macro to turn them on. This library targets
embedders who compile with -fno-exceptions, and a header whose behaviour
depends on a macro is a trap the moment two translation units disagree about it.
Parsing reports differently, because a unit does not exist to carry a sink until
parsing is done. Diagnostics collects instead:
esper::Diagnostics diags;
esper::Unit unit = esper::Unit::parse_string_diag(src, len, "in.esp", diags);
for (const esper::Diag &d : diags)
std::fprintf(stderr, "%u:%u: %s\n", d.line, d.column,
esper::diag_code_name(d.code));
Reading a unit back
The read-back half is exposed as ranges usable with a plain range-for. They are non-owning views over the unit, cheap to copy, and valid for as long as the unit is.
for (Func fn : unit.functions())
for (BasicBlock blk : fn.blocks())
for (Inst inst : blk.instructions())
std::printf("%.*s: %s\n", (int)blk.label().size(), blk.label().data(),
esper::op_name(inst.op()));
Block and BasicBlock are different types on purpose. A Block is the id the
builder hands out while a function is being written, and it is what a branch
names. A BasicBlock is the view onto one that has been written, and it is what
holds instructions. Nothing can branch to a BasicBlock, and nothing can read
instructions out of a Block.
An instruction’s operands are exposed two ways, and the difference is the whole
point of the slot API. operands() yields the SSA values it reads, numbered
densely: a call’s arguments without its callee, a branch’s edge arguments
without its targets. slots() yields the raw storage with each slot’s kind attached,
which is what a consumer needs to tell a block index from an SSA id:
for (Slot slot : inst.slots()) {
switch (slot.kind()) {
case SlotKind::Ssa: use_value(slot.value()); break;
case SlotKind::Block: use_block(slot.block()); break;
default: break;
}
}
slot.value() answers NoValue when the slot is not an SSA id, so reading one
as the other is a false handle rather than a plausible wrong number.
Mixing the two APIs
A consumer part way through adopting this has to be able to hold both. Every
owning type exposes get() for the raw pointer and release() for handing
ownership back out, and adopt() for taking it in:
esper::Unit unit = esper::Unit::adopt(esper_parse("in.esp"));
esper_optimize(unit.get(), &config); // a C call on a C++-owned unit
EsperUnit *raw = unit.release(); // ownership back to the caller
esper_unit_free(raw);
The handles convert explicitly rather than implicitly, so a raw id reaching a C call is visible in the source:
esper_build_ret(fn.get(), &raw_id, 1); // raw_id from value.raw()
What is checked
Four things, run by task cpp-check:
tests/api_smoke.cppbuilds a unit through both APIs and compares the printed text, over the worked example and again over a function touching every instruction family. An operand forwarded to the wrong slot moves a character. Running it with--mutateputs one operand deliberately wrong and requires the comparison to notice, so a comparison that has stopped comparing fails rather than passing.tests/api_reject.shcompiles each mistake the layer is supposed to refuse and requires the compiler to refuse it, with a correct program first so a broken include path cannot make every rejection pass.tests/api_coverage.shfails when a publicesper_*function has no C++ counterpart, which is what keeps the header from falling behind the C one.tests/api_overhead.shcompiles the same program both ways at-O2and compares the calls each makes into the library.