Structured Binding Assignments

Structured Binding Assignments

History

R1:

Substantive changes:

Editorial changes:

R0:

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

[‡ 1]

  1. 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.

  2. Standard library use may be restricted, even where available — Some C++ environments prohibit standard library use by policy, regardless of whether <tuple> is technically present.
    std::tie is unusable there; a language-level feature is usable.

    For example, Fuchsia’s Zircon kernel disallows standard library use in-kernel [CxxInZircon].
    [PageFault] assigns a loop-scoped variable from a structured binding to an out-of-loop variable today:

    auto [fault_status, _] =
        mapping->PageFaultLocked(guest_paddr, pf_flags, 0, &page_request);
    status = fault_status;

    With P3817, this becomes one statement:

    auto [using status, _] =
        mapping->PageFaultLocked(guest_paddr, pf_flags, 0, &page_request);

[‡ 2]

  1. 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 modifiable lvalue, rather than declaration of a new variable.

Syntax

The grammar splits sb-identifier into two named alternatives, so that the wording can name the kind of element it means:

sb-identifier:
    sb-declaring-identifier
    sb-assigning-identifier

sb-declaring-identifier:
    ...opt identifier attribute-specifier-seqopt

sb-assigning-identifier:
    using unary-expression ...opt

The sb-declaring-identifier is the existing declaration form, unchanged. The sb-assigning-identifier is new: using followed by a unary-expression that shall designate a modifiable lvalue, optionally followed by .... No attribute-specifier-seq appears in this form: attributes appertain to a newly declared variable, and no variable is introduced here.

Semantics

operator= 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=.

Order of assignment. For all three decomposition kinds (tuple-like, array, and otherwise/aggregate), the elements SB₀, …, SBₙ₋₁ are resolved in that lexical order: SBᵢ — its assignment if using-marked, its reference-binding otherwise — is fully completed before SBᵢ₊₁ is evaluated. In particular, for a using-marked SBᵢ, the assignment reads its right-hand element from e and writes it via operator= before any later SBⱼ (j > i) is evaluated.

Rationale: tuple-like decomposition already has this ordering “for free” from [dcl.struct.bind] ¶7, since each SBᵢ there is a separately-initialized variable, sequenced by ordinary declaration semantics. Array and aggregate decomposition never needed an order, since without using every SBᵢ is a side-effect-free reference binding. using adds operator= side effects, making the order observable: auto& [_, using arr[0], using arr[1]] = arr; intends an evict-and-shift, but silently corrupts arr under the other order. This rule generalizes the existing tuple-like guarantee to the two forms that previously had none.

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()

[‡ 3]

owned object (xvalue) move
auto& [using x, y] = f()

[‡ 4]

lvalue reference copy
auto&& [using x, y] = f()

[‡ 5]

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;

[‡ 6]

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;

[‡ 7]

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;

[‡ 8]

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();

[‡ 9]

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;

[‡ 10]

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;

[‡ 11]

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();

[‡ 12]

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();

[‡ 13]

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;

[‡ 14]

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;

[‡ 15]

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();

[‡ 16]

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, consuming that element of e. A non-using element does not move — it binds a reference into e — so moving out of one always takes an explicit std::move. Where the assignment copies rather than moves, nothing is consumed.

Specifiers

Specifiers appertain to the hidden object e, never to the using-marked targets. [dcl.struct.bind] ¶1 defines S as the declaration’s constexpr, constinit and storage-class specifiers, and e is defined using S together with the declaration’s cv-qualifiers. A using-marked element introduces no new entity — it assigns from the corresponding element of e to an existing variable, whose type this declaration cannot change. Every specifier therefore keeps the meaning it has for an ordinary structured binding, and none of them is ill-formed:

Further Design Decisions

Returned Modifiable Lvalues

The using keyword may also appear before an expression yielding a modifiable 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();

[‡ 17]

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

[‡ 18]

Note: using _ is ill-formed — _ is a discard placeholder and cannot be the target of an assignment, even where _ names an ordinary variable rather than a placeholder.

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();

[‡ 19]

std::tie(std::ignore, y) = get_pair();
auto [_, using y] = get_pair();

[‡ 20]

Duplicate Targets

A binding list may assign to the same object more than once. The order rule makes the result well-defined — the last assignment wins.

A rule forbidding it was considered and dropped: assigning twice is not a mistake, only pointless, and no test can enforce it — a test on the spelling misses two names for one object, and a test on the objects themselves is undecidable, since using f(), using g() may return the same object:

int var;
int& x = var;
int& y = var;
auto [using x, using y] = foo();  // x and y designate the same object

Reusing a Declared Name as a Target

An assignment target may share its name with a binding the declaration introduces. The operand finds whatever the name means outside the list, since the bindings are not in scope inside it:

struct Counter {
    int count = 0;                                  // the member

    std::pair<int, int> tick();                     // {delta, running_total}

    int step() {
        auto [count, using count] = tick();
        //    │             │
        //    │             └── this `count` is the MEMBER: at this point the
        //    │                 binding declared to its left is not yet in scope
        //    └── declares a local binding named `count`

        use(count);          // the binding — first element (delta)
        return this->count;  // the member  — second element (running_total)
    }
};

The two cases that would warrant a rule are already ill-formed: redeclaring a name that exists in the same scope, and naming a binding that is not yet in scope — which is simply an undeclared identifier. What is left is the deliberate reuse above, whose meaning follows from the ordinary scope rules and whose behaviour is well-defined, so forbidding it is unnecessary.

Packs

P3817 composes naturally with C++26 structured binding packs [P1061R10].

The two forms of sb-identifier place the ellipsis differently on purpose: the sb-declaring-identifier declares a pack, the sb-assigning-identifier expands an existing one. C++ spells a declared pack’s ellipsis on either side of the name (Ts... args, T ...args, ...rest), but spells every pack expansion after its pattern (f(args...)); each form follows the convention of the operation it performs. The two never compete for the same token, since in the assigning form the ellipsis follows an expression.

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

[‡ 21]

using expr... — when ... follows the unary-expression, the sb-identifier is a pack expansion ([temp.variadic]) whose pattern is that expression. Each element of the structured binding is assigned from the corresponding element of e to the corresponding element of the expansion, 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
}

[‡ 22]

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);
}

[‡ 23]

The operand may be any expression, not only a name. The unexpanded pack may appear anywhere within the pattern — as the pattern itself (targets) or nested inside it (arr[Is]) — exactly as in any other pack expansion:

template <typename... Ts, std::size_t... Is>
void assign_into(std::tuple<Ts...> t, std::array<std::common_type_t<Ts...>, sizeof...(Is)>& arr,
                 std::index_sequence<Is...>) {
    auto [using arr[Is]...] = std::move(t);  // each tuple element moved into the corresponding arr[Is]
}

[‡ 24]

More than one expansion is allowed — any number of using expansions may appear in one binding list, and a declaring pack may appear alongside them. Nothing is left to infer: each expansion’s length is fixed by the pack named in its pattern, so only a declaring pack’s size comes out as the remainder, and the C++26 rule that a declaration contains at most one declaring pack ([P1061R10]) is what keeps that remainder well-defined.

auto [using a..., using b...] = get_tuple();  // |a| + |b| must be the number of elements of the tuple

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);
}

[‡ 25]

Examples

Patterns

Loops

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);
}

[‡ 26]

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

struct Point { double x, y; };
std::vector<std::pair<Point, double>> trajectory;  // {position, timestamp}

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

[‡ 27]

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:

Cons:

Option B: using Keyword (preferred by authors)

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

[‡ 28]

Pros:

Cons:

Other Syntaxes (Rejected)

Wording

The wording below is relative to [N5054]. Changes are marked as insertions and deletions. Paragraph numbers are those of [N5054]; an inserted paragraph shifts the numbering of the paragraphs that follow it, and no renumbering is shown.

Preamble: [dcl.pre]

Replace the production for sb-identifier:

sb-identifier:
    sb-declaring-identifier
    sb-assigning-identifier

sb-declaring-identifier:
    ...opt identifier attribute-specifier-seqopt

sb-assigning-identifier:
    using unary-expression ...opt

Structured binding declarations: [dcl.struct.bind]

[1]

[2]

[3]

Insert after ¶3:

An sb-assigning-identifier introduces no names and is not a structured binding. If it is not a pack sb-identifier, the object designated by its unary-expression is its assignment target, and that unary-expression shall be a modifiable lvalue. If it is a pack sb-identifier, the pattern designates one assignment target per element of the expansion, and each of them shall be a modifiable lvalue.

For each SBᵢ that is not a structured binding, the assignment expression target = element is evaluated, where target is the assignment target of SBᵢ and element is the object or value that corresponds to SBᵢ — the element i of the array in the array case, the initializer in the tuple-like case, or e.mᵢ in the member case.

An SBᵢ that is a structured binding is resolved by introducing it; any other SBᵢ is resolved by evaluating its assignment expression. The initialization of e, and any conversion of e considered as a decision variable ([stmt.pre]), is sequenced before the resolution of SB₀, and the resolution of SBᵢ is sequenced before the resolution of SBᵢ₊₁.

Insert after ¶5:

If the unary-expression of an sb-assigning-identifier is the identifier _, the program is ill-formed.

[6], [7], [8]

Variadic templates: [temp.variadic]

In ¶4, restrict the definition to the declaring form:

In ¶6, add to the list of contexts in which a pack expansion can occur:

Implementation

The examples in this paper run against a clang implementation of P3817. A snippet that is cross-referenced as [‡ N] links to the numbered row below; every row points to a runnable Godbolt page, in the order the paper presents them.

‡ Description Link
1 Motivation — mixed-mode: one element assigned, one declared godbolt.org/z/KhEfTYsWo
2 Motivation — one statement instead of two (Zircon PageFault) godbolt.org/z/v11xWEWrr
3 Semantics — ref-qualifier table, auto on f() godbolt.org/z/PeGcnMWP8
4 Semantics — ref-qualifier table, auto& on f() godbolt.org/z/fxvMY6qo6
5 Semantics — ref-qualifier table, auto&& on f() godbolt.org/z/7vnEjK4nb
6 Semantics — array, auto godbolt.org/z/Whe8YWz83
7 Semantics — array, auto& godbolt.org/z/56oYPaGqf
8 Semantics — array, auto&& godbolt.org/z/3dzzb755x
9 Semantics — tuple-like, auto godbolt.org/z/E6M9Yf6oK
10 Semantics — tuple-like, auto& godbolt.org/z/1hWaKdG75
11 Semantics — tuple-like, auto&& (lvalue) godbolt.org/z/8d6sW38Mx
12 Semantics — tuple-like, auto&& (rvalue) godbolt.org/z/bbThTbcvo
13 Semantics — member decomposition, auto godbolt.org/z/evvzxvM6o
14 Semantics — member decomposition, auto& godbolt.org/z/e1c8EvxYe
15 Semantics — member decomposition, auto&& (lvalue) godbolt.org/z/7anj3MddP
16 Semantics — member decomposition, auto&& (rvalue) godbolt.org/z/Ws1G1PYYP
17 Returned Modifiable Lvalues — using foo(), using s[0] godbolt.org/z/9qejoMM1P
18 C++26 _ placeholder — assign first, discard second godbolt.org/z/sz1Tnebq8
19 C++26 _ placeholder — std::tie(x, std::ignore) equivalent godbolt.org/z/8EhKrYqfb
20 C++26 _ placeholder — std::tie(std::ignore, y) equivalent godbolt.org/z/3fhKc5h35
21 Packs — non-pack using alongside a regular pack godbolt.org/z/h8dzGsbz7
22 Packs — using targets... godbolt.org/z/f3e1Tvfz6
23 Packs — mixed using head, using tail... godbolt.org/z/5jv7vdMT1
24 Packs — using arr[Is]..., driven by an index pack godbolt.org/z/cr1j8hxxG
25 Packs — Current Alternatives, rewritten with P3817 godbolt.org/z/GTq75Kv49
26 Examples — Loops godbolt.org/z/rYW4jsYTP
27 Examples — Loops, accumulating into one variable godbolt.org/z/cG4z1nWxr
28 Alternative Syntaxes — Option B (using) godbolt.org/z/h1843enhz

Implementation and ongoing work:

References