P3817R0 — Structured Binding Assignments

Structured Binding Assignments

History

R0: Initial version

Abstract

This proposal introduces an extension to C++ structured bindings, allowing assignment to existing variables.

Motivation

Structured bindings (auto [x, y] = foo();) can only declare new variables. Assigning to pre-existing variables requires std::tie from <tuple>. There is no single construct that does both.

This proposal closes that gap, with several advantages over std::tie::

  1. Mixed-mode — Neither std::tie nor structured bindings alone allow some elements to be new variables and others pre-existing in the same statement. P3817 uniquely enables this:

    int id;
    auto [using id, name] = get_record();  // id is assigned; name is declared
  2. Unified syntax — One construct for both assignment and initialization reduces cognitive overhead and produces more consistent code. Currently, initialization uses structured bindings; reassignment uses std::tie.

  3. No standard library requiredstd::tie requires <tuple>, which is unavailable in many constrained environments (embedded systems, bare-metal, OS kernels). A language-level feature works everywhere C++ does.

  4. Encouraged by P0144R2 — The authors of the original structured bindings paper explicitly invited this extension [section 3.3]: > “This can always be proposed separately later as a pure extension if desired.”

Proposal

Extend structured binding syntax to allow the using keyword before an element in the sb-identifier-list to denote assignment to an already-existing lvalue, rather than declaration of a new variable.

Syntax

The grammar splits sb-identifier into two explicit alternatives:

sb-identifier:
    ...opt identifier attribute-specifier-seqopt
    using ...opt unary-expression

The first alternative is the existing declaration form, unchanged. The second is new: using followed by a unary-expression that shall designate a modifiable lvalue. No attribute-specifier-seq appears in the second alternative — attributes appertain to a newly declared variable, and no variable is introduced here.

When ... is present, the unary-expression shall be a pack expression; the ... marks the expansion of that expression, not the introduction of a new pack name.

Semantics

The hidden variable e is introduced exactly as today (per [dcl.struct.bind] ¶1). The difference lies in how each element SBᵢ is resolved: for a using-marked element, instead of introducing a new name, the implementation assigns from the corresponding element of e to the existing variable via operator=.

The ref-qualifier determines the type of e, which in turn determines whether the assignment is a copy or a move — no special-casing is required:

Declaration e Assignment to using x
auto [using x, y] = f()
owned object (xvalue) move
auto& [using x, y] = f()
lvalue reference copy
auto&& [using x, y] = f()
lvalue ref or rvalue ref copy or move

Illustration

The expansions below are illustrative — they show the intended semantics in terms of equivalent code, not normative wording. Types shared across all examples:

struct Point { int x, y; };
using PointPair = std::pair<Point, Point>;
PointPair  get_pair();
Point      get_point();
auto auto& auto&&
Array
given
Point arr[2];
Point x;
auto [using x, y] = arr;
expands
to
Point __e_p3817[2] = {arr[0], arr[1]};
x = std::move(__e_p3817[0]);
Point& y = __e_p3817[1];
given
Point arr[2];
Point x;
auto& [using x, y] = arr;
expands
to
Point (&__e_p3817)[2] = arr;
x = __e_p3817[0];
Point& y = __e_p3817[1];
arrays are always lvalues; auto&& deduces to lvalue ref
given
Point arr[2];
Point x;
auto&& [using x, y] = arr;
expands
to
Point (&__e_p3817)[2] = arr;
x = __e_p3817[0];
Point& y = __e_p3817[1];
Tuple-like
given
Point x;
auto [using x, y] = get_pair();
expands
to
PointPair __e_p3817 = get_pair();
x = std::get<0>(static_cast<PointPair&&>(__e_p3817));
Point&& y = std::get<1>(static_cast<PointPair&&>(__e_p3817));
given
PointPair p;
Point x;
auto& [using x, y] = p;
expands
to
PointPair& __e_p3817 = p;
x = std::get<0>(__e_p3817);
Point& y = std::get<1>(__e_p3817);
given (lvalue)
PointPair p;
Point x;
auto&& [using x, y] = p;
expands
to
PointPair& __e_p3817 = p;
x = std::get<0>(__e_p3817);
Point& y = std::get<1>(__e_p3817);
given (rvalue)
Point x;
auto&& [using x, y] = get_pair();
expands
to
PointPair&& __e_p3817 = get_pair();
x = std::get<0>(static_cast<PointPair&&>(__e_p3817));
Point&& y = std::get<1>(static_cast<PointPair&&>(__e_p3817));
Otherwise
given
int px;
auto [using px, y] = get_point();
expands
to
Point __e_p3817 = get_point();
px = std::move(__e_p3817.x);
int& y = __e_p3817.y;
given
Point pt;
int px;
auto& [using px, y] = pt;
expands
to
Point& __e_p3817 = pt;
px = __e_p3817.x;
int& y = __e_p3817.y;
given (lvalue)
Point pt;
int px;
auto&& [using px, y] = pt;
expands
to
Point& __e_p3817 = pt;
px = __e_p3817.x;
int& y = __e_p3817.y;
given (rvalue)
int px;
auto&& [using px, y] = get_point();
expands
to
Point&& __e_p3817 = get_point();
px = std::move(__e_p3817.x);
int& y = __e_p3817.y;

[dcl.struct.bind] ¶8 imposes no aggregate requirement: any class type with publicly accessible direct members and no std::tuple_size specialization reaches this case, including non-aggregates with user-provided constructors.

Immediate-move implication. For auto [using x, ...], the move assignment to x happens at the binding statement.

Specifiers

const

A const-qualified structured binding that contains using-marked elements is ill-formed.

Example:

const auto [using x, y] = ar;  // ill-formed: cannot assign in a const binding

Alternative Considered

Under this alternative, const appertains to the hidden variable e — not to the using-marked targets — and the declaration is valid. However, this raises a question that defies easy resolution: given

Point p, q;
const auto [using p, using q] = get_pair();

p and q are already-declared variables with well-known types. Does this declaration change their types? If yes, that is contrary to C++ semantics — a declaration cannot retroactively change the type of an existing variable. If no, then const has no observable effect on the using-marked elements, which is misleading.

In practice, const would only make e const, causing assignments to copy rather than move — a subtle effect invisible in the source.

Note: for types with mutable data members, const on e does not suppress those members — mutable members remain modifiable and moveable even through a const object. Under this alternative, const auto [using p, ...] where the corresponding source member is mutable would still produce a move, not a copy. This inconsistency — copying from some elements and moving from others depending on mutable — further undermines the predictability of this option.

The authors therefore prefer the ill-formed approach.

Storage Class

static and thread_local are both storage-class specifiers — they declare a new entity with a particular storage duration. using-marked elements introduce no new entity; an existing variable already has its own storage class. There is nothing for static or thread_local to act on in the using-marked positions, making their combination with using ill-formed:

static auto [using x, y] = f();        // ill-formed
thread_local auto [using x, y] = f();  // ill-formed

Non-using elements are unaffected — y above would be a valid static or thread-local binding.

constexpr

C++26 (P2686R5) makes constexpr valid for structured binding declarations for non-using elements:

constexpr auto [x, y] = get_pair();  // valid in C++26; x and y usable in constant expressions

For using-marked elements, constexpr implies const and is therefore ill-formed for the same reason as const:

constexpr auto [using p, y] = get_pair();  // ill-formed: constexpr implies const

constinit

constinit requires static or thread-local storage duration. Since both storage-class specifiers are ill-formed with using-marked elements (see Storage Class above), constinit with using is always ill-formed — no new rule beyond the storage class rule is needed.

Further Design Decisions

Returned Lvalues

The using specifier may also appear before an expression yielding an lvalue, not just a plain identifier. This is the language-level equivalent of std::tie with non-variable arguments:

// std::tie equivalent
std::tie(foo(), s[0]) = get_pair();

// with P3817
auto [using foo(), using s[0]] = get_pair();

C++26 _ Placeholder

P3817 composes naturally with C++26’s _ placeholder for discarding elements:

int x;
auto [using x, _] = get_pair();  // assign first element to x, discard second

Note: using _ is ill-formed — _ is a discard placeholder and cannot be the target of an assignment.

This provides a complete, library-free replacement for std::tie with std::ignore:

std::tie with std::ignore With P3817 and C++26
std::tie(x, std::ignore) = get_pair();
auto [using x, _] = get_pair();
std::tie(std::ignore, y) = get_pair();
auto [_, using y] = get_pair();

Duplicate Variables: ill-formed for assigned elements

Using the same variable more than once in a using-marked binding list is ill-formed:

int x;
auto [using x, using x] = foo();  // ill-formed

Even for types where repeated assignment would be well-defined (e.g., a type whose operator= accumulates values), the construct is rejected as inherently confusing.

Packs

P3817 composes naturally with C++26 structured binding packs (P1061).

Non-pack using alongside a regular pack requires no special treatment — the two are orthogonal:

int x;
auto [using x, ...rest]  = get_tuple();  // x is assigned; rest is a new binding pack
auto [...rest, using x]  = get_tuple();  // rest is a new binding pack; x is assigned last

using ...expr — when ... is present after using, expr shall be a pack expression. Each element of the structured binding is assigned from the corresponding element of e to the corresponding expansion of expr, following the same ref-qualifier rules as non-pack using. No new name is introduced:

template <typename... Ts>
void assign_from(std::tuple<Ts...> t, Ts&... targets) {
    auto [using ...targets] = std::move(t);  // each tuple element moved into the corresponding target
}

Mixed usage is also valid:

template <typename T, typename... Rest>
void assign_all(T& head, Rest&... tail, std::tuple<T, Rest...> t) {
    auto [using head, using ...tail] = std::move(t);
}

The existing constraints extend naturally:

Current Alternatives

Assigning from a tuple-like type to a pack of existing variables without P3817 requires either the standard library or significant boilerplate:

template <typename... Ts>
void assign_from(std::tuple<Ts...> t, Ts&... targets) {
    // Option 1: std::tie — requires <tuple>
    std::tie(targets...) = std::move(t);

    // Option 2: index sequence — verbose
    [&]<std::size_t... Is>(std::index_sequence<Is...>) {
        int dummy[] = { (targets = std::get<Is>(std::move(t)), 0)... };
        (void)dummy;
    }(std::index_sequence_for<Ts...>{});
}

With P3817:

template <typename... Ts>
void assign_from(std::tuple<Ts...> t, Ts&... targets) {
    auto [using ...targets] = std::move(t);
}

Examples

Patterns

Range-based for

Because the structured binding declaration fires on every iteration, using-marked elements are reassigned each time. This enables tracking state across iterations without a separate assignment in the loop body:

// Returns {head, tail} split at the first delimiter
std::pair<std::string_view, std::string_view>
split_first(std::string_view s, char delim);

std::string_view remaining = input;
while (!remaining.empty()) {
    auto [token, using remaining] = split_first(remaining, ' ');
    process(token);
}

Mixed-mode is equally natural — accumulate one variable while binding fresh names for the rest:

Point last{};
for (auto [using last, _] : trajectory) { /* last updated each step */ }
// last is the final point of the trajectory

Real-World Examples

1. llvm PassBuilder

Expected<bool> PassBuilder::parseSinglePassOption(StringRef Params,
                                                  StringRef OptionName,
                                                  StringRef PassName) {
  bool Result = false;
  while (!Params.empty()) {
-    StringRef ParamName;
-    std::tie(ParamName, Params) = Params.split(';');
+    auto [ParamName, using Params] = Params.split(';');

    if (ParamName == OptionName) {
      Result = true;
    } else {
      return make_error<StringError>(
          formatv("invalid {} pass parameter '{}'", PassName, ParamName).str(),
          inconvertibleErrorCode());
    }
  }
  return Result;
}

2. scylladb: repair/row_level.cc

- mutation_reader rd(nullptr);
- std::tie(rd, _reader_handle) = make_manually_paused_evictable_reader(
+ auto [rd, using _reader_handle] = make_manually_paused_evictable_reader(
    std::move(ms),
    _schema,
    _permit,
    _range,
    _schema->full_slice(),
    {},
    mutation_reader::forwarding::no);

3. tools/scylla-nodetool.cc

- std::tie(params["kn"], params["cf"]) = *split_kt(kn_msg);
+ auto [using params["kn"], using params["cf"]] = *split_kt(kn_msg);

Alternative Syntaxes Considered

Option A: & Symbol

int x;
auto [&x, y] = get_values();

Pros: - Concise - & suggests “referencing something that already exists” - Familiar to developers comfortable with reference syntax

Cons: - Ambiguous with address-of in expressions like auto [&get_reference(), y] = ... - ]] in auto [&map[key], y] resembles attribute syntax - Visual similarity to auto& [x, y] may cause initial confusion

Option B: using Keyword (preferred by authors)

int x;
auto [using x, y] = get_values();

Pros: - Unambiguous — no confusion with address-of or function pointers - Clear semantic intent: “using” an existing variable rather than declaring a new one - Works cleanly with returned lvalues: auto [using get_reference(), y] = ... - Echoes the existing use of using to refer to a name declared elsewhere.

Cons: - More verbose than & - Introduces contextual keyword usage within structured bindings

Other Syntaxes (Rejected)

Wording

Will be completed upon positive response on the direction of the proposal.

Previous Papers

References