Skip to content

Add fn::thunk - call-by-name right operands for the carrier conjunction and disjunction #372

Description

@Bronek

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestrelease-0.2Planned for release 0.2

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions