Skip to content

Repository files navigation

tacit

ci license

Status: pre-v1, so there may be breaking changes or backwards incompatible additions or removals.

A pithy C++23 library, header-only, for handing operations to algorithms with the least notation the language allows. The core surface is one object, tacit::_, whose members return closures that forward to a same-named operation on whatever they're later applied to:

#include <tacit/_.hpp>
using tacit::_;

std::ranges::sort(pairs, {}, _.get<0>());            // project: order by first element
std::ranges::count_if(words, 1u <= _.size() < 4u);   // comparisons chain, see below
std::ranges::for_each(nums, _ *= 2);                 // mutating sections update in place

using tacit::_; imports exactly one name. The vocabulary is reached through the object, the operator sections are hidden friends found by ADL, and everything else is a qualified tacit:: helper.

Why this exists

The lambda for the middle line above is [](auto const& w) { return 1 <= w.size() && w.size() < 4; } — the operation appears once, the plumbing four times, and the result is neither reusable nor comparable at a glance. _ inverts that ratio.

The obvious alternatives each stop short:

  • Member pointers (&std::string::size as a projection) are not portable C++: since C++20, the standard library forbids taking the address of most of its functions — only a short list of addressable ones may be pointed to. _.size() is the conforming spelling of that intent, and it also routes through std::ranges::size, so it works on C arrays and views where no member exists.
  • std::bind_front / bind_back bind arguments to an existing callable; they don't project members, compose, or build comparators.
  • Ranges projections are excellent — where an algorithm offers a projection slot. A tacit closure is an ordinary callable, so it also goes everywhere else: predicates, comparators, transform, your own APIs.
  • Boost.Lambda2 is the nearest relative — placeholder arithmetic on _1/_2. tacit differs in the vocabulary (_.size(), _.get<0>(), ~200 standard names — Lambda2 has operators only), in comparison chaining, and in its blank model (each _ is a distinct anonymous fill; positional reuse is deliberately out — see the FAQ).

Costs are measured and small; see Costs, limits, coexistence.

Requirements

  • C++23, no dependencies beyond the standard library. The full suite runs in CI on clang 18 and 22, g++ 13 and 16 (-std=c++23), MSVC v19.4x and clang-cl (/std:c++latest), and AppleClang — four front ends over three standard libraries, on Linux, Windows, and macOS. Spot-checked besides (whole surface compiles and runs): Intel ICX 2026, and EDG 6.9, the front end behind Visual Studio's IntelliSense.
  • On MSVC, /utf-8 and /Zc:preprocessor are required and not implied by /std:c++latest — the first for λ.hpp's encoding, the second because the vocabulary's X-macro engine uses __VA_OPT__. CMake users get both automatically.
  • Optional C++26 reflection (P2996) unlocks the reflective members; auto-detected, otherwise compiled out. See Reflective hatch.

Two opt-in headers extend the surface later in this README: <tacit/$.hpp> (the eager side, and partial CTAD) and <tacit/λ.hpp> (the lambda head). Both are include-is-the-opt-in; the default surface is just _.

Notation

Blanks (partial application)

Each _ token is one blank; the arity of the resulting closure is the number of blanks, filled left to right. The receiver counts as a blank:

_.push_back(y)    // 1 blank  (c)        ->  c.push_back(y)
_.push_back(_)    // 2 blanks (c, v)     ->  c.push_back(v)
_.replace(_, _)   // 3 blanks (c, a, b)  ->  c.replace(a, b)
_ + _             // 2 blanks (a, b)     ->  a + b

Repeated _ are distinct blanks — there are no positional _1/_2 sigils. Reach for a named lambda the moment you need to reorder or reuse an argument.

A blank can also project: an fn in argument position applies its projection to the fill, so _.push_back(_.size()) is (c, x) -> c.push_back(size(x)).

A blank always means "another fill", even where that reading is less obvious — _[_] is (x, i) -> x[i], _(_) is (f, x) -> f(x), and _.size() < _ is (v, n) -> size(v) < n. Where a blank cannot be filled, the expression is rejected where you write it: a chained call binds its arguments, so _.front().substr(_) is a compile error, not a dead closure. (_.substr(_) is fine — that section fills its own blanks.)

Vocabulary

_ carries a curated first-class vocabulary of standard-library member names (at, push_back, substr, value_or, find, emplace, …), kept in one editable table. Range access (size, begin, end, empty, data, …) routes through the std::ranges customization points, so _.size() / _.begin() also work on C arrays, string views, and third-party ranges.

The table reaches past containers to the names you project or test with: diagnostics (what, message), filesystem::path (extension, stem, filename, …), the monadic family (and_then, transform, value_or, …), concurrency (join, load, wait, …), streams, bitset, regex match results, complex, chrono, and span. Names cost nothing until used — each is a member template, so a wider table is a longer declaration list, not a bigger binary.

Tuple-like projection takes a template argument, so it gets its own spelling — by index or by type, either of which composes like any other verb:

_.get<0>()                              // x -> std::get<0>(x)
_.get<std::string>()                    // by type
_.get<1>().size()                       // composes onward
std::ranges::sort(v, {}, _.get<0>());   // sort by first element
std::ranges::count_if(v, _.get<0>() > 1)

It reaches the free get<…>(x) first — the real route for tuple, pair, array, variant and subrange — and falls back to a member get<…>() for types that spell it that way. The plain _.get() (shared_ptr, unique_ptr, future) is untouched: the two overload rather than collide.

The rest of the type-argument family follows the same shape:

_.to<std::vector>()                       // C++23 ranges::to, pipeline terminator
_.to<std::vector<long>>()                 // ...or spelled out
_.any_cast<int>()
_.holds_alternative<std::string>()
_.duration_cast<std::chrono::seconds>()
_.static_pointer_cast<Derived>()

These are reached unqualified, by ADL, so the header doesn't have to include <any>, <variant>, <memory> or <chrono> — a caller holding a std::any has already included <any>. (ranges::to is the exception: std::ranges isn't an associated namespace of std::vector, so it's qualified. It's also feature-tested — it shipped in libstdc++ 14 and libc++ 17, so _.to<C>() is simply not declared on g++-13; #if TACIT_HAS_RANGES_TO to test for it.)

Field-style verbs. pair's components are data members, not calls, and read better without empty parentheses:

std::ranges::sort(v, {}, _.first);           // not _.first()
std::ranges::count_if(v, _.second.size() == 2u)
_.first(p) = 9;                              // a reference, so it writes through

Because first is a field rather than a call, it doesn't chain from a projection (_.front().first has no member to find) and the lift doesn't mirror it — both hops spell the same access as .get<0>().

There is a type-level table too — see tacit_extras.md.

Operator sections

The comparison, arithmetic, bitwise, shift, and logical operators are finite and lexical: _ == y, x + _, _ + _ all build the obvious closure (a one-sided form is unary; _ op _ is a two-input combiner). Unary forms work too — -_, !_, ~_, *_ (deref), ++_ — as does streaming (os << _, so ranges::for_each(v, std::cout << _)) and member access through a pointer, _->size(), which uses the pointee's real operator->.

Its sibling ->* keeps its natural meaning, member-pointer projection: _ ->* &Widget::x is p -> (*p).x. Since .* is not overloadable this is the only closure spelling there is, so the section falls back to deref-then-select where no built-in ->* exists — smart pointers and iterators work, not just raw pointers. Data members only: (*p).*pmf is valid solely as a call head, so member functions stay with _->f(args).

Comparisons chain. C++ parses 0 < _ < 10 as (0 < _) < 10 — a bool compared against 10, so the closure is silently always true. A comparison section therefore remembers its rightmost operand, and a comparison applied to one rewrites itself into the conjunction the notation means:

0 < _ < 10          // x -> (0 < x) && (x < 10)      not  ((0 < x) < 10)
1u <= _.size() < 4u // x -> (1 <= size(x)) && (size(x) < 4)
0 <= _ <= 10 < 20   // chains to any length, any mix of == != < > <= >=

The middle term is evaluated once per link (so keep a projection cheap and pure) and && short-circuits, exactly as in the spelled-out form. Only those six operators build a chain; any other operator ends it. The one spelling that looks like negation, (_ < 10) == false, would chain into (x < 10) && (10 == false) — an always-false closure — so it is rejected at compile time with a message pointing at _ >= 10 (or !). _ < _ is unaffected: with two blanks it's the two-input comparator, not a link.

Assignment is included and mutates: _ = 0 and compound forms like _ += 1 bind the argument by reference, so ranges::for_each(v, _ += 1) updates v in place; _ = _ and _ += _ are the two-input forms. Bitwise | is an ordinary section (_ | 4), symmetric with &; general function composition lives in tacit::compose, not in |.

Comma builds tuples. Yes — the comma operator, overloaded. Before the reflex fires: every overload is a constrained hidden friend that requires a tacit operand (your commas are untouched), the results are [[nodiscard]], and the alternative is worse — without the overload, the built-in comma silently discards half of what you wrote inside (_, _). It earns its place as the one section that makes data rather than calling something, and the only n-ary one — each further , appends an operand:

(_, _)                 // (a, b)    -> std::pair{a, b}
(_, 9)                 // x         -> {x, 9}      one-sided binds
(_, _, _)              // (a, b, c) -> std::tuple{a, b, c}
(_.size(), _.front())  // (a, b)    -> {size(a), front(b)}
(_, 5, _)              // (a, b)    -> {a, 5, b}   bound: no fill

Two operands stay a std::pair; three or more are a std::tuple. The operand list is flat(_, (_, _)) and ((_, _), _) are the same three-slot tuple. The parens are load-bearing everywhere , would otherwise read as a separator (argument lists, init-lists) — that's the built-in comma doing its usual job, untouched.

The usual blank rule applies, and it's the thing to watch: each _ is a distinct blank, so (_.size(), _.front()) takes two arguments — it is not a one-argument key function. For the same-input tuple (the lexicographic projection you probably want) that's tacit::fanout:

tacit::fanout(_.size(), _.front())  // x      -> {size(x), front(x)}
(_.size(), _.front())               // (a, b) -> {size(a), front(b)}

A comma section composes onward through the value it builds, keeping its arity — the six comparisons (the operators pair and tuple actually have) apply to what comes out:

(_, _) == std::pair{1, 2}   // (a, b) -> {a, b} == {1, 2}
(_, _) < std::pair{2, 0}    // (a, b) -> lexicographic, as pair defines it

Composition

The closure _ hands back is itself composable, so a projection and a section chain without ever naming a lambda:

std::ranges::count_if(v, _.size() >= 2u);       // size(x) >= 2
std::ranges::sort(v, _.size() < _.size());      // order by size

auto scaled = (_ + 1) * 2;         // x -> (x + 1) * 2
auto head   = _[0];                // x -> x[0]

Composition is arity-preserving: (_ + _) + 1 is (a, b) -> (a + b) + 1. Arity is load-bearing only in argument position, where a one-fill closure is a projected blank (_.push_back(_.size())) while a many-fill one is an ordinary bound value — which is what lets _.sort(_ < _) pass a comparator.

Member access chains, too: a projection keeps the vocabulary, so _.front().size() is x -> size(front(x))_.front().size()(words) is the length of the first word.

A few _-agnostic named combinators round out the surface — qualified tacit:: functions, so they never enter your scope uninvited: tacit::compose(f, g, …) composes arbitrary closures left-to-right (compose(_ + 1, _ * 2)(3) is 8), tacit::fanout(f, g, …) maps a value to a tuple of projections, tacit::first / tacit::second transform one component of a pair, and the *_element family (transform_elements, any_of_element, …) drives a closure over a tuple-like. Each returns an fn, so results keep composing.

Application

There's a third way to apply. Where _.size() applies a named member and _ == y applies an operator, _(args...) applies the subject itself — the closure that calls its argument. _(3) fans the value 3 across a set of callables, while _() simply invokes — handy for forcing a thunk:

_(3)(std::negate{}); // -> -3

using thunk_t = std::function<void()>;
auto thunks = std::vector<thunk_t>{};
std::ranges::for_each(thunks, _()); // invoke each thunk

The traffic runs both ways: a closure is an ordinary callable, so the function wrappers hold it — std::function for the copyable ones (most sections are), std::move_only_function when a section binds a move-only value (which makes the closure itself move-only: exactly that wrapper's case), and C++26's std::function_ref refers, so point it at a named closure, never a temporary.

The term wrapper: $

_ is a blank, awaiting its subject. $ is the other side: it takes the subject now. $(x) gives a plain value the vocabulary it may not have as members and applies it eagerly; $<F>(a…) builds a value. It lives in <tacit/$.hpp>, and including the header is the opt-in — no macro:

#include <tacit/$.hpp>
using tacit::$;

$(v).size()         // ranges::size(v) — even where v.size() doesn't exist
$(-42).abs()        // 42 — a bare value has no members at all
$("abc").length()   // 3

The rule is exactly $(x).f(a…) == _.f(a…)(x), so there is one vocabulary and one dispatch, not two to keep in step. Two things worth knowing. A string literal's raw type is never what you mean — const char[4] has no members, and ranges::size("abc") counts the NUL — so a char array is normalized to string_view on the way in. And a call hands back the operation's own result, not a wrapper, so chaining continues on that result's own type: $ is a one-hop lift, not a fluent facade — $(v).front() is a std::string&, and if you need two hops you are describing a computation, which is _'s job (_.front().size()(v) says it better, and is reusable).

The vocabulary has a third dispatch kind for this: beside member calls and the range CPOs, names that are free functions in the standard library (abs, sqrt, floor, round, isnan, …) route to std::. They work on _ as well — count_if(v, _.abs() > 1).

$ is a function, not a macro — it keeps its namespace, obeys ADL, and claims nothing from the rest of the translation unit. $(p)->f() reaches through a handle to the pointee, mirroring _->f():

$(ptr)->size()      // ptr->size()
$(ptr).use_count()  // the holder's own vocabulary, on the dot surface

Making a value: $<F> and partial CTAD

$(x) adopts a value that exists; $<F>(a…) builds one. The two can never collide: a call with no explicit template arguments cannot deduce F, so $(42) only ever reaches the eager side.

$<std::vector>(1, 2, 3)                  // vector<int>{1,2,3}  — plain CTAD
$<std::vector, double>(1.0, 2.0)         // vector<double>      — arguments given
$<std::set, _, std::greater<>>(3, 1, 2)  // set<int, greater<>> — PARTIAL CTAD

The third line is the one C++ cannot otherwise spell: CTAD is all-or-nothing, so fixing one template argument means writing them all — std::set<int, std::greater<>>{3,1,2} re-types that int by hand. A _ in the list means deduce this position; everything else is fixed, and trailing parameters you never mention re-default as usual:

$<std::map, _, _, std::greater<>>(pairs…)      // deduce key and mapped, fix the order
$<std::unordered_map, _, _, _, _, MyAlloc>(p)  // deduce four, fix the allocator

It works by deducing the whole specialization and then overlaying the positions you fixed. Deduction runs first and unmodified, so the deduced positions are exactly what plain CTAD would have given.

$<F>(a…) returns the value itself, not a wrapped one: auto v = $<std::vector>(1,2,3) is a std::vector you can hand to anything. Wrap it — $($<F>(a…)) — if you want the vocabulary.

Two limits, both the language's. F is a template <class…> class, so the <class, size_t> families (array, span) are out of reach — no loss, since their one type argument is all partial CTAD could have fixed. And blanks reach four positions deep, which covers every standard container.

Closures as types: decltype(_ > _) for std::greater<>

A closure built purely from _ holds nothing, so it is default-constructible and empty — exactly what a comparator or hasher template parameter wants. decltype is the whole crossing:

std::set<int, decltype(_ > _)> s{3, 1, 2};   // descending — *s.begin() == 3
$<std::set, _, decltype(_ > _)>(3, 1, 2);    // deduce the element, order by `>`
static_assert(sizeof(std::set<int, decltype(_ > _)>)
              == sizeof(std::set<int>));     // costs nothing*

* The closure type is empty everywhere; whether a container then compresses it is the standard library's call. One known exception: clang-cl won't apply empty-base optimization to a class whose emptiness comes from [[msvc::no_unique_address]], so on that one front end std::set<int, decltype(_ > _)> is a pointer-width larger. MSVC's own front end compresses it.

Binding a value correctly forfeits this — decltype(_ > 3) is not default-constructible, because it has to keep the 3.

A composed closure reaches the type world too, so ordering by a projection no longer has to fall back to the value form (std::ranges::sort(v, {}, _.size())):

std::set<std::string, decltype(_.size() < _.size())> by_length{"ccc", "a", "bb"};

It is default-constructible but not empty: a composed closure holds its operand closures as distinct subobjects, and two subobjects of the same type cannot share an address whatever [[no_unique_address]] says. So the costs-nothing claim above stays scoped to closures built purely from _, which really do hold nothing at all.

† clang 18 crashes — a front-end segfault, not a diagnostic — compiling std::sort or std::ranges::sort against a default-constructed composed comparator. Containers are unaffected (they default-construct the same comparator and are fine), and clang 19 and later are clean.

The conforming spellings: lift and make

$ is accepted by every major compiler — clang, GCC, and MSVC alike — it just sits outside the standard's identifier grammar, so a maximum-strictness build (-pedantic-errors) rejects the character at the lexer. Hence the separate header, and synonyms that satisfy even that flag: <tacit/_.hpp> alone never sees a $, and the same two functions exist there as tacit::lift(x) == $(x) and tacit::make<F>(a…) == $<F>(a…). Nothing is $-only; the strictest build simply keeps to these names.

lift(v).size()                              // == $(v).size()
make<std::set, _, std::greater<>>(3, 1, 2)  // == the $<> form above

λ — when you do need a lambda (opt-in)

Some things the expression grammar cannot say: an argument used twice, statements, a name in a non-projection position. For those, <tacit/λ.hpp> sheds the ceremony a hand-written lambda drags in — the macro expands to exactly the head, λ(a, b) == [&](auto&& a, auto&& b), and the body follows in ordinary braces:

#include <tacit/λ.hpp>

std::ranges::sort(v, λ(a, b) { return a.size() < b.size(); });
std::ranges::count_if(v, λ(s) { return s.size() * s.size() > 4u; })  // s used twice
λ(s) -> decltype(auto) { return s.front(); }   // your own trailing return

Because the body never passes through the macro, it is plain C++ — commas, statements, multiple returns, no escaping rules. Capture is [&] — right for a lambda used where it's written; don't store one beyond its scope. The header is completely standalone (it includes nothing, not even _.hpp) and fully conforming: λ is a legal C++23 identifier (UAX #31), so it survives even -pedantic-errors; it only asks for UTF-8 source. One caveat has no cure: macros cannot cross a module boundary, so #include <tacit/λ.hpp> is the permanent vehicle — no import will ever carry it. Why λ can only be a macro at all, and why the { return … } cannot be elided, is recorded in tacit_extras.md.

Further out: sigils and the type level

Two more surfaces exist and are deliberately not documented here. Synthetic sigils (#define TACIT_SIGILS) add Haskell's arrow spellings as glued token sequences — f &&& g fanout, f *** g product, f >>* g / f <<* g compose — with the full maximal-munch sweep, the costs, and the precedence rules in tacit_extras.md. The experimental type level (bind/apply/quote, ungated and always present) lives in the same file, along with the design log for everything above. That file is the lab notebook; this one is the manual.

Teach _ your own names

To hand _ a domain vocabulary, pre-#define TACIT_VERBS (a comma list of member-call names) before the include, and each name becomes first-class on the same _:

#define TACIT_VERBS make_deposit, balance, is_frozen
#include <tacit/_.hpp>
using tacit::_;

_.make_deposit(_)(account, 100);        // blanks work here too
std::ranges::sort(accounts, {}, _.balance());   // now first-class on _
std::ranges::count_if(accounts, _.is_frozen()); // also on projections and _->

Each verb is requires-guarded, so a name a given type lacks is a clean SFINAE miss rather than a hard error. The same list also lands on _'s composable projections (_.balance() < _.balance()) and on the arrow proxy (_->balance()), so a verb behaves everywhere the built-in names do.

A vocabulary file

The comma list has to be #defined before the include, identically, in every translation unit — miss one and that TU gets a different _. A vocabulary file avoids the ritual: point TACIT_VOCABULARY at a header, or drop a tacit_vocabulary.hpp on the include path and __has_include finds it.

// bank/vocabulary.hpp — entries only, and NO include guard
TACIT_VERB(deposit)               // x.deposit(a...)
TACIT_FREE(risk, bank::risk)      // bank::risk(x)
TACIT_CPO(tier, bank::tier)       // bank::tier(x)
TACIT_NOUN(money_type)            // _::money_type::of<X>
#define TACIT_VOCABULARY <bank/vocabulary.hpp>
#include <tacit/_.hpp>

The gain over a bare list is that each entry picks its dispatch kind, so a domain free function or customization point is reachable — TACIT_VERBS can only make member calls. The file is expanded once per surface, X-macro style, which is why it must contain nothing but entries and carry no include guard. It makes an ODR mismatch far less likely, not impossible: a TU that points TACIT_VOCABULARY elsewhere still gets a different _, and mixing those in one program is an ODR violation like any other.

Reflective hatch (C++26)

When a P2996 toolchain is present (__cpp_impl_reflection + __cpp_lib_reflection), _ also provides, for names not in a table:

  • _.m<"method">(args...) — call an arbitrary member resolved by name;
  • _.field<"x">() — project a data member by name;
  • _.enum_name() — enumerator → string_view;
  • _.each_field(f) — fold f over a value's data members.

These are compiled out otherwise. TACIT_HAS_REFLECTION — one of the two feature flags kept on the clean include path, beside TACIT_HAS_RANGES_TO and the TACIT_VERSION numbers — lets you #if on whether they exist.

Modules

import tacit; is available as an experimental C++20 module (tacit.cppm), which wraps the headers and re-exports everything — _, $, lift, make, and the type-level names. One module, on purpose: the _.hpp / $.hpp split exists because #include injects tokens (a TU that includes $.hpp lexes $, which -pedantic-errors rejects), but an import injects only names, and a name costs nothing until you spell it. A strictly-conforming TU can import tacit; and keep to lift/make — CI compiles exactly that consumer with -pedantic-errors against the $-bearing interface. The one TU that does lex $ is tacit.cppm itself, so build that file without -pedantic-errors; there is no knob for it, deliberately, since a knob there would make two different modules answering to the same import tacit;.

import tacit;   // _, $, lift, make, blank, bind, apply, quote
using tacit::_;
using tacit::$;

Macros don't cross a module boundary, so the TACIT_VERBS extension hook stays with #include <tacit/_.hpp>import is enough to use _, #include to teach it your own names. CI verifies the module path on clang only; GCC's modules support is not exercised, so prefer #include there.

Costs, limits, coexistence

Compile time. Including <tacit/_.hpp> costs ~0.1 s over a typical <vector>+<ranges>+ <algorithm> baseline on clang (~0.30 s vs ~0.19 s, cold); $.hpp adds nothing measurable. The vocabulary is declarations only — member templates cost nothing until called.

Error messages. A misuse is a normal overload-resolution failure, ~10 lines, first line naming your call site — not expression-template spew. The two designed rejections are static_asserts with the fix in the message: a bool folded into a comparison chain ("write _ >= 10, or negate with !"), and a non-copyable temporary bound into a closure ("name it first").

Runtime. A tacit closure is an ordinary lambda composition — no type erasure, nothing virtual; codegen spot-checks against hand-written lambdas are in tacit_extras.md. Space and allocation behaviour is asserted rather than assumed — tests/allocation.cpp pins that calling a closure allocates nothing, that binding an operand costs exactly one copy of it, and that an rvalue operand is moved rather than copied. Throughput benchmarks across compilers are not yet part of the suite.

MSVC runs the full suite in CI (see Requirements for its two required flags).

If your codebase already has a _:

  • gettext defines a function-like macro _(...), so only the application forms collide (_(x), _()); sections like _ < 10 and _.size() survive. In gettext TUs, qualify (tacit::_) or bind another name: constexpr auto& o = tacit::_;.
  • GMock's ::testing::_ — keep the two usings in different scopes, or qualify one; they collide only if both are imported into the same scope and then used unqualified.
  • C++26 name-independent _ (P2169) coexists: a local auto [_, x] = …; shadows tacit::_ inside that scope and nowhere else (verified on clang trunk -std=c++2c).

Extending the vocabulary (TACIT_VERBS) is per-TU at include time; every TU in a program must see the same definitions or they get differently-shaped _s — the vocabulary-file pattern below exists to make that mechanical.

Build & test

Vendoring: the library is three files, and they are the distribution — there is nothing to generate. Copy include/tacit/_.hpp into your tree, keep it under a tacit/ directory, and #include <tacit/_.hpp> works. λ.hpp is likewise copy-one-file. $.hpp wants only its sibling _.hpp next to it — copy both. CI builds from a bare directory with the repo's include path absent, so those claims stay true.

Otherwise, header-only as usual — add include/ to your include path, or use CMake:

add_subdirectory(tacit)
target_link_libraries(your_target PRIVATE tacit::tacit)

Or install it and find_package:

find_package(tacit REQUIRED)   # after `cmake --install`
target_link_libraries(your_target PRIVATE tacit::tacit)

Or fetch it with CPM.cmake:

CPMAddPackage("gh:ajg/tacit#master")   # or pin a tagged release
target_link_libraries(your_target PRIVATE tacit::tacit)

To run the test suite:

cmake -B build
cmake --build build
ctest --test-dir build --output-on-failure

To run CI — every job in .github/workflows/ci.yml — there's ci.sh, and a shell.nix that pins the compilers it uses:

nix-shell --run ./ci.sh                    # clang (CI's clang++-18 leg)
nix-shell --argstr cc gcc --run ./ci.sh    # gcc 13 (CI's g++-13 leg)
CXX=g++-13 ./ci.sh                         # or your own compiler, no nix

It prints which compiler it actually used, and skips (rather than silently drops) anything the local platform can't run. On aarch64-darwin the shell substitutes the nearest working clang and says so; shell.nix documents each substitution and why.

Asks

Some things we wanted, we could not get — a same-_ type level (_<>), a conforming $, stateless composed closures, MSVC reports — and a few we believe we've proven impossible. Both kinds are listed in ASKS.md: solve one, or break a proof, and it's yours.

License

Copyright (c) 2026 Alvaro J. Genial — https://github.com/ajg/tacit

Boost Software License 1.0 — see LICENSE. Chosen for header-only friendliness: the notice is required only in source distributions, not in binaries. If you vendor a header, keep its two SPDX lines: BSL-1.0 requires the copyright notices to travel with every copy and derivative work.

About

A pithy C++ library to write pithy C++

Resources

Stars

10 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages