Skip to content

Admit lvalue-reference payloads in just #417

Description

@Bronek

Lift the reference restriction at just.hpp:89 for lvalue references. just<T&> becomes well-formed and adopts the semantics of optional<T&> verbatim. Rvalue references remain ill-formed, matching optional and pack's rejection of T&& elements.

Motivation: Close the Identity Cluster

C++26 adopted optional<T&>. pfn polyfills it, and TYPE_ALGEBRA §15 already recognizes it as "the deliberate exception—the standard specifies it." However, §10 defines just<T> as the carrier canonically isomorphic to its payload: the always-engaged floor beneath optional<T>.

Because optional<T&> exists, the library now contains a reference-capable fallible carrier whose infallible floor cannot be expressed. There is no spelling for the degenerate, always-engaged case of optional<T&>. The standard's evolution created this asymmetry within the identity cluster; admitting just<T&> removes it. This is both an extension and a fix.

The Remaining Boundary Is Storage-Principled

Lvalue references are permitted where storage is direct: in pack elements, optional, and just. The payload of just is a plain member (just.hpp:102); no union is involved.

References remain prohibited where a union stores alternatives: in expected, copack alternatives, and therefore choice. C++ forbids union members of reference type, and std::expected draws exactly the same boundary.

The note in §15 changes from "the carriers refuse references, optional excepted" to this storage-based rule. The wrap-in-a-pack doctrine remains, but with a sharper purpose: it is how a reference crosses the union boundary, as in expected<pack<T&>, E>, rather than how references are carried in general.

Semantics

Every semantic question inherits a ruling already in force:

  • Assignment rebinds, never assigns through — mirroring optional<T&>.
  • Comparison compares referents, following the existing rule shared by optional<T&> and pack<T&>.
  • value() and callback invocation always yield T&, never T&&. An rvalue just<T&> must not synthesize a move from a referent it does not own, mirroring C++26 optional<T&>.
  • CTAD never deduces a reference. The guide just(T) -> just<T> at just.hpp:742 takes T by value. Consequently, just{lvalue} currently decays to just<int> and continues to do so; the reference form requires explicit spelling. This is the safety gate that makes every just<T&> deliberate.
  • There is no default constructor. A reference payload is born bound: the unit requires its argument, and there is no disengaged state to default to.

Compiled Witness

The probe below pins the cited optional<int&> precedent behaviors and the CTAD decay guarantee. It compiles cleanly today with g++ 16.2.1 and clang 22.1.8 under -std=c++20 -Wall -Wextra -Werror; every check is a static_assert over a constexpr evaluation.

constexpr bool probe_referent_compare_and_rebind()
{
  int a = 1;
  int b = 1;
  fn::optional<int &> x{a};
  fn::optional<int &> y{b};
  bool const referent_compare = (x == y); // distinct objects, equal values
  x = fn::optional<int &>{b};             // assignment rebinds ...
  bool const rebound = (&x.value() == &b) && a == 1; // ... leaving `a` untouched
  return referent_compare && rebound;
}
static_assert(probe_referent_compare_and_rebind());

// value() yields int& regardless of the optional's own value category
static_assert(std::same_as<decltype(std::declval<fn::optional<int &> &>().value()), int &>);
static_assert(std::same_as<decltype(std::declval<fn::optional<int &> &&>().value()), int &>);

constexpr bool probe_ctad_decays()
{
  int x = 42;
  fn::just j{x};
  static_assert(std::same_as<decltype(j), fn::just<int>>); // lvalue deduces int, never int&
  return j.value() == 42;
}
static_assert(probe_ctad_decays());

The full probe also establishes that transform on optional<int&> receives int& and leaves the referent untouched.

Consequences

  • Admission to transform and and_then widens: a callback returning U& produces just<U&>, where that return is refused today. This is strictly additive—previously ill-formed code becomes well-formed, while no existing program changes meaning.
  • The lift rule in Add fn::thunk - call-by-name right operands for the carrier conjunction and disjunction #372 loses its sole exception. () -> T& lifts directly to just<T&>, superseding the just<pack<T&>> ruling recorded there. Amend Add fn::thunk - call-by-name right operands for the carrier conjunction and disjunction #372 if this proposal is accepted. The thunk lift then becomes fully uniform: carriers pass through, and everything else embeds as just<result>.
  • The remaining refusals—expected<T&, E>, a reference copack alternative, and choice with a reference alternative—receive a diagnostic that explains the union rule and names pack<T&> as the crossing spelling. Without that explanation, "just<T&> is legal, but choice<T&, U> is illegal" appears arbitrary.
  • The interaction table, normalization, deduplication, and grading remain unchanged. References never enter a copack, so the sum machinery never sees them. Bridging through and_then between just<T&> and optional<T&> requires no new machinery; as always, bind adopts the callback's family.
  • Implementation requires a reference specialization of just storage: a pointer internally to support rebinding, with T& at the interface. Keep the type structural, add a value-category test battery as required by CONTRIBUTING, and apply the usual MSVC care around deduced returns. The absence of union involvement keeps the risk profile low.

Interaction with #414

The proposals are independent in substance. After the re-founding, just has two specializations, and T& belongs to the non-copack specialization. The copack specialization—the choice carrier—continues to reject references under the union rule. Coordinate the implementations, but either may land first.

Documentation

  • Rewrite the reference-payload note in §15 around the storage-principled boundary.
  • Note in §10 that the reference sub-cluster spans just<T&> and optional<T&>, ending at the union boundary; crossing that boundary is explicit through pack.
  • Add a dated CHANGELOG.md entry recording what this reverses and why.
  • List it among the documented changes in the 0.2.0 release notes.

Assisted-by: Claude:claude-fable-5

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