Status: experimental, pre-1.0. The API may change, and it isn't released or announced yet.
A tiny, header-only library for point-free (tacit) programming in C++23. Its whole public surface
is one object, tacit::_, whose members return closures that forward to a same-named operation on
whatever they're later applied to — so you can hand operations to algorithms without writing lambdas.
#include <tacit/_.hpp>
#include <algorithm> // the ranges algorithms live here, not in <tacit/_.hpp>
#include <ranges> // std::views
using tacit::_;
using namespace std::ranges;
sort(nums, _ < _); // ascending (two-blank comparator)
count_if(nums, _ == 0); // count zeros
transform(words, out, _.size()); // string lengths
nums | views::filter(_ != 0) | views::take(2); // predicate drops into std::views_ is the one name that enters your scope: using tacit::_; imports exactly _ — the vocabulary is
reached through the object, and the operator forms (sections, |) are hidden friends found by ADL,
so they need no using. Everything else is a qualified tacit:: helper that never enters your scope.
(The free-function combinators live behind #define TACIT_COMBINATORS, off by default — see
Composition; a small, experimental type-level surface is noted near the end.)
Prefer #include alone? #define TACIT_USING_UNDERSCORE before including and the header does the
using for you — opt-in, so it never imposes a global _ on anyone who didn't ask.
- C++23 (tested on g++ 13 and clang 18,
-std=c++23). No dependencies beyond the standard library. - Optional C++26 reflection (P2996) unlocks the reflective members; auto-detected, otherwise compiled out. See Reflective hatch.
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 + bRepeated _ 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)).
_ 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 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, like the _.size() < _.size() comparator). Unary forms work too — -_, !_, ~_,
*_ (deref), ++_ — as does streaming (os << _, so for_each(v, std::cout << _)) and member access
through a pointer, _->size(), which uses the pointee's real operator-> (distinct from (*_).size()).
Assignment is included and mutates: _ = 0 and compound forms like _ += 1 build sections that
bind the argument by reference, so std::ranges::for_each(v, _ += 1) updates v in place. (operator|
stays composition, so bitwise | is intentionally absent — see the design notes.)
The closure _ hands back is itself composable, so a projection and a section chain without ever
naming a lambda — sections, subscript (_[i]), and arithmetic all build a new closure:
std::ranges::count_if(v, _.size() >= 2); // 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]Every single-argument closure _ produces is a small composable fn; the multi-blank forms
(_.foo(_), _ < _) stay partial applications, where composition would not mean anything.
Member access chains, too: a projection keeps the vocabulary, so _.front().size() is
x -> size(front(x)) — handy as a projection: std::ranges::sort(words, {}, _.front().size()).
f | g composes closures left-to-right (x -> g(f(x))); it's a hidden friend of fn, always
available, and never clashes with the ranges pipe (whose left operand is a range, not an fn).
A few more _-agnostic combinators are available behind #define TACIT_COMBINATORS (off by default,
to keep the surface at _ + bind): tacit::fanout(f, g, …) maps a value to a tuple of projections
(x -> {f(x), g(x)}), 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. They're free tacit:: functions rather than hidden
friends, so they take qualification and a #define — the operators (|, sections) don't.
There's a third way to apply. Where _.size() applies a named member and _ == y applies an
operator, _(args...) applies the subject itself — it builds [args...](f){ return f(args...); },
the closure that calls its argument. It mirrors mapping a function over data: _(3) fans the value 3
across a set of callables, while _() (no args) simply invokes — handy for forcing a thunk.
_(3)(std::negate{}); // -> -3 (applies negate to 3)
std::vector<std::function<void()>> thunks{ []{}, []{} };
std::ranges::for_each(thunks, _()); // invoke each nullary callable_ is the only placeholder — there is no separate derived object to learn or spell. To hand it 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 deposit, balance, freeze
#include <tacit/_.hpp>
#include <algorithm>
using tacit::_;
using namespace std::ranges;
sort(accounts, {}, _.balance()); // _.balance() is now first-class on _
_.deposit(_)(account, 100); // blanks work, exactly as with the built-in vocabulary
count_if(accounts, _.frozen()); // ... and a verb reaches _'s projections and _-> tooEach verb is requires-guarded, so a name a given type lacks is a clean SFINAE miss rather than a hard
error — a domain verb sits safely alongside the standard vocabulary. 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.
Its type-level twin is TACIT_NOUNS — a comma list of nested-type names, each projected as
_::name::of<X> (see Type-level _):
#define TACIT_NOUNS payload_type, shape_tag
#include <tacit/_.hpp>
using tacit::_;
using P = _::payload_type::of<Message>; // == Message::payload_typeBlank detection is trait-based, so _ is recognised as a blank in any argument position.
When a P2996 toolchain is present (__cpp_impl_reflection + __cpp_lib_reflection), TACIT_CORE
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)— foldfover a value's data members.
These are compiled out otherwise. TACIT_HAS_REFLECTION (the one macro kept on the clean include
path) lets you #if on whether they exist.
import tacit; is available as an experimental C++20 module (tacit.cppm), which wraps the header
and re-exports _ and the type-level bind / apply / quote:
import tacit;
using tacit::_;Macros don't cross a module boundary, so the extension hooks (TACIT_VERBS, TACIT_NOUNS) are
consumed at include time and stay with #include <tacit/_.hpp> — import is enough to use _,
#include to teach it your own names (just as import std; exports no macros). For the same reason
TACIT_COMBINATORS can't be switched on from the consumer side; build the interface with
-DTACIT_COMBINATORS to have it export the combinators too. Verified on clang; GCC's -fmodules-ts
isn't reliable for this pattern yet, so prefer #include there.
The same _ does double duty at the type level — a hole for partially applying a class template and
a projection namespace for pulling nested members out of a type. bind<F, args…>::with<Xs…> fixes the
template and fills its holed arguments; apply<Slots…>::with<Fills…> generalizes to holing the
template itself (quote it with quote<F>), so it curries both grains under one op. An experimental
#define TACIT_STD_HOLES adds the natural spelling — std::map<struct _, int>::with<char> — by
injecting specializations into namespace std (off by default; it's a convenience, not a standards
guarantee). That whole surface is unstable and left undocumented in detail here on purpose while it
settles; see tacit_extras.md, and the typelevel / typeproject / typeapply
tests for what works today.
Header-only — just 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 (no extra setup needed — the
add_subdirectory path defines tacit::tacit and skips tacit's own tests when consumed):
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-failuretacit names the paradigm (point-free / tacit programming). _'s own type is just tacit::_; _
itself is the lieutenant — French lieu tenant, literally "place-holding," a stand-in —
sidestepping the loaded English word "placeholder" (already spoken for by std::placeholders and by
the grammar term placeholder type specifier for auto); the irony is not lost entirely that
point-free style almost necessarily involves more points in the literal sense (periods/dots),
whether through composition syntax like f . g in Haskell or member access function objects for
partial application like _.m(...) here.
Boost Software License 1.0 — see LICENSE. Chosen for header-only friendliness: the notice is required only in source distributions, not in binaries.