Skip to content

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

Description

@Bronek

Both carrier operators are eager: operator& for conjunction and operator| for disjunction. An overloaded operator is an ordinary function call, so both operand expressions are fully evaluated—in unspecified relative order—before the operator body runs. Every "short-circuit" in & and | is therefore semantic selection over values that have already been constructed.

The current lazy spellings are the Kleisli verbs and_then and or_else. They deliberately compute a different algebra: the verbs join or replace, whereas the operators union the values and take the product of the errors.

Add fn::thunk<F>, a wrapper around a nullary invocable, with CTAD as fn::thunk{f} and an accompanying some_thunk concept. Admit it 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 occurs at most once, inside the operator body, after lh's state is known; this also removes the unspecified-evaluation-order hazard carried by an eager right operand;
  • folds remain left-associative, as they are today (a | fn::thunk{f} | fn::thunk{g}): each step produces the carrier that discriminates the next step, so each deferred operand is forced only when the preceding fold requires it.

Lifting the Return Value

The thunk's return value enters the operator through the identity-cluster unit. One uniform rule applies: a carrier is taken as-is, while every other return type is embedded through the unit as just<result>:

  • a pack becomes just<pack<...>>;
  • a copack becomes just<copack<...>>, which is the choice carrier under Re-found choice as an alias of just over a copack #414;
  • a plain T becomes just<T>;
  • void becomes just<void>;
  • a reference T& becomes just<pack<T&>>, as explained under "Resolved Questions."

A bare-data or plain return is a total computation. Under |, it is the lazy catch-all that collapses the result into the cluster; under &, it is a lazy pure contributor.

Sequence this work after the choice re-founding (#414), which makes the rule above a single clause. With choice<Ts...> = just<copack<Ts...>>, the copack case requires no special spelling, and the lift never needs to name a second carrier family.

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. Values determine engagement. The admission table carries over verbatim: any combination rejected by the eager operator, such as mixed expected and optional, remains rejected 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 or copack is total, so the thunk would be forced unconditionally; laziness without a discriminator is vacuous.

Nor is the thunk admitted as a left operand. The left operand is always forced, and accepting a thunk there would falsely suggest that the eager right operand is protected.

Deliberate Asymmetry

The eager operators continue to reject bare-data operands: lh | pack{…} remains ill-formed, and its eager spelling is lh | just{…}.

The lift exists only inside the thunk, where a body naturally ends with return compute();. Requiring just{…} around every return statement would be boilerplate.

Resolved Questions

Both questions were open in the first version of this issue and were settled during the 0.2 planning close-reads.

  • () -> void under & is admitted. It lifts to just<void>—the strict unit of &, elided from the value product—so lh & fn::thunk{f} with a void-returning f means exactly: "run this if and only if the left succeeded, and contribute nothing." This is lawful because the Monoidal identity laws constrain the value side of the operation, which a void contributor leaves unchanged. It is not a lazy inspect in disguise: inspect receives the value, while the thunk receives nothing. The apparent collision therefore requires one clarifying sentence in the documentation, not a refusal.
  • () -> T& lifts to just<pack<T&>>. just<T&> is mandated ill-formed, and the standing doctrine in TYPE_ALGEBRA §15 propagates references inside carriers by wrapping them in a pack. Because append splices, lh & fn::thunk{f} with an f returning T& composes like any other operand. For example, expected<A,E> on the left produces expected<pack<A,T&>,E>. This makes T& the one lift case that inserts a pack: an exception to the uniform rule that must be stated explicitly in the documentation rather than left implicit.

Documentation Obligation

The eager and thunked operators are two lawful points in the same family. McBride & Paterson draw exactly this distinction with the miffy/iffy pair in §5 of Applicative Programming with Effects.

Consequently, a & b ≠ a & fn::thunk{[&]{ return b; }} whenever constructing b has observable effects. The eager form performs both computations unconditionally; the thunked form lets the left-hand outcome decide. The documentation must state this non-equivalence, just as the paper documents iffy.

Naming

"Thunk" is the ALGOL 60 term for exactly this object: a nullary procedure that implements call-by-name argument evaluation (Ingerman, 1961). This is the term's original meaning, not a metaphorical extension. No source-level C++ entity claims the name; the ABI term "adjustor thunk" exists below the source level and has a compatible meaning.

It is a noun naming a type, consistent with just, choice, and pack.

Assisted-by: Claude:claude-fable-5

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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