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]

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

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

The two forms place the ellipsis differently on purpose: the declaring one declares a pack, the assigning one 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.

When the assigning form carries ..., the sb-identifier is a pack expansion ([temp.variadic]) whose pattern is the unary-expression. The unexpanded pack may appear anywhere within that pattern — as the pattern itself (targets) or nested inside it (arr[Is]), exactly as in any other pack expansion — and each element of the structured binding is assigned from the corresponding element of e to the corresponding element of that expansion.

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 specifier 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].

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 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 — an unexpanded pack may appear nested inside it, 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. It is a draft for reviewing the design as specifiable text, not a request to apply changes at this stage.

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 ¶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