Both carrier operators ( operator& for conjunction and operator| for disjunction) are eager: an overloaded operator is an ordinary function call, so both operand expressions are fully evaluated (in unspecified relative order) before the body runs — every "short-circuit" in & and | is semantic selection over already-constructed values. The lazy spellings today are the Kleisli verbs (and_then/or_else), which deliberately compute a different algebra: they join or replace, where the operators union the values and take the product of the errors.
Proposal: fn::thunk<F> — a wrapper over a nullary invocable, CTAD fn::thunk{f}, concept some_thunk — admitted as the right operand of the carrier-level & and |, giving those operators call-by-name evaluation:
lh & fn::thunk{f} invokes f only if lh is engaged (the product needs its value);
lh | fn::thunk{f} invokes f only if lh has failed (left catch stands);
- the invocation happens at most once, inside the operator body — sequenced after
lh's state is known, which also removes the unspecified-evaluation-order hazard an eager right operand carries;
- folds compose left-associatively as today (
a | fn::thunk{f} | fn::thunk{g}): each step yields a carrier, which is the next step's discriminator, so each deferred operand is forced only when the fold's prefix requires it.
The lift. The thunk's return feeds the operator through the identity-cluster unit: a carrier is taken as-is; a pack lifts to just and a copack to choice (the #350 canonical spellings — identity over a pack is spelled just, over a copack choice); a plain T lifts to just<T> and void to just<void>. One uniform rule: carriers stay, everything else embeds through the unit. A bare-data or plain return is a total computation — under | the lazy catch-all that makes the result collapse into the cluster, under & a lazy pure contributor.
The governing invariant: lh op fn::thunk{f} has exactly the type of lh op LIFT(f()). Result types are computed from std::invoke_result_t<F> — the thunk is never invoked to answer a type question — and values decide engagement. The admission table carries over verbatim: a combination the eager operator refuses (e.g. mixed expected/optional) stays refused under the thunk. noexcept is computed from the invocation, the lift and the fold.
Refused positions. The thunk is not admitted at the data level — a left pack/copack is total, so the thunk would be unconditionally forced; laziness without a discriminator is vacuous — nor as a left operand, which is always forced and would falsely suggest the eager right side is protected.
Deliberate asymmetry. The eager operators keep refusing bare-data operands (lh | pack{…} stays ill-formed; the eager spelling is lh | just{…}). The lift exists only inside the thunk, where a body naturally ends in return compute(); and demanding just{…} around every return statement would be boilerplate.
Open questions
() -> void under & is a lazy inspect in disguise (runs iff the left succeeded, contributes nothing) — admit or refuse?
- Reference returns (
() -> T&): to be decided the moment plain-T lifting exists.
Naming. "Thunk" is the ALGOL 60 term for exactly this object — a nullary procedure implementing call-by-name argument evaluation (Ingerman, 1961); the original meaning, no metaphorical stretch. No source-level C++ entity claims the name (the ABI jargon "adjustor thunk" lives below source level and means the compatible thing). A noun, naming a type — consistent with just, choice, pack.
Both carrier operators (
operator&for conjunction andoperator|for disjunction) are eager: an overloaded operator is an ordinary function call, so both operand expressions are fully evaluated (in unspecified relative order) before the body runs — every "short-circuit" in&and|is semantic selection over already-constructed values. The lazy spellings today are the Kleisli verbs (and_then/or_else), which deliberately compute a different algebra: they join or replace, where the operators union the values and take the product of the errors.Proposal:
fn::thunk<F>— a wrapper over a nullary invocable, CTADfn::thunk{f}, conceptsome_thunk— admitted as the right operand of the carrier-level&and|, giving those operators call-by-name evaluation:lh & fn::thunk{f}invokesfonly iflhis engaged (the product needs its value);lh | fn::thunk{f}invokesfonly iflhhas failed (left catch stands);lh's state is known, which also removes the unspecified-evaluation-order hazard an eager right operand carries;a | fn::thunk{f} | fn::thunk{g}): each step yields a carrier, which is the next step's discriminator, so each deferred operand is forced only when the fold's prefix requires it.The lift. The thunk's return feeds the operator through the identity-cluster unit: a carrier is taken as-is; a
packlifts tojustand acopacktochoice(the #350 canonical spellings — identity over a pack is spelledjust, over a copackchoice); a plainTlifts tojust<T>andvoidtojust<void>. One uniform rule: carriers stay, everything else embeds through the unit. A bare-data or plain return is a total computation — under|the lazy catch-all that makes the result collapse into the cluster, under&a lazy pure contributor.The governing invariant:
lh op fn::thunk{f}has exactly the type oflh op LIFT(f()). Result types are computed fromstd::invoke_result_t<F>— the thunk is never invoked to answer a type question — and values decide engagement. The admission table carries over verbatim: a combination the eager operator refuses (e.g. mixedexpected/optional) stays refused under the thunk.noexceptis computed from the invocation, the lift and the fold.Refused positions. The thunk is not admitted at the data level — a left
pack/copackis total, so the thunk would be unconditionally forced; laziness without a discriminator is vacuous — nor as a left operand, which is always forced and would falsely suggest the eager right side is protected.Deliberate asymmetry. The eager operators keep refusing bare-data operands (
lh | pack{…}stays ill-formed; the eager spelling islh | just{…}). The lift exists only inside the thunk, where a body naturally ends inreturn compute();and demandingjust{…}around every return statement would be boilerplate.Open questions
() -> voidunder&is a lazyinspectin disguise (runs iff the left succeeded, contributes nothing) — admit or refuse?() -> T&): to be decided the moment plain-Tlifting exists.Naming. "Thunk" is the ALGOL 60 term for exactly this object — a nullary procedure implementing call-by-name argument evaluation (Ingerman, 1961); the original meaning, no metaphorical stretch. No source-level C++ entity claims the name (the ABI jargon "adjustor thunk" lives below source level and means the compatible thing). A noun, naming a type — consistent with
just,choice,pack.