R1:
Substantive changes:
Moved the pack ellipsis to postfix (using expr...
instead of using ...expr), so an expansion may use any
pattern, including a nested pack such as
using arr[Is]...
Specified left-to-right order for using-marked
assignments across all three decomposition kinds
Tightened the “no standard library” motivation into a concrete real-world example (Fuchsia’s Zircon kernel)
Reversed the ill-formed specifier rules: const,
constexpr, constinit and the storage-class
specifiers appertain to the hidden object e and are well-formed
with using-marked elements
Drafted the Wording section, replacing the placeholder: the
change is confined to the using-marked element, and the
specifier rules need no wording at all
Split sb-identifier into two named forms,
sb-declaring-identifier and
sb-assigning-identifier, so the wording can name the kind
of element it means
Duplicate targets are now allowed rather than ill-formed: the ban would not have caught a mistake, and it cannot be enforced
Documented that an assignment target may share a name with a binding the declaration introduces, and that forbidding it is unnecessary
Editorial changes:
Added clang implementation and implementation-tracker references
Restructured the back matter: References is now a bibliography of the cited papers. Runnable examples and implementation links moved into their own section.
Replaced lvalue with modifiable lvalue
where the assignment target is meant
R0:
This proposal introduces an extension to C++ structured bindings, allowing assignment to existing variables.
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:
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 declaredUnified 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.
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);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.”
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.
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.
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
|
|---|---|---|
|
owned object (xvalue) | move |
|
lvalue reference | copy |
|
lvalue ref or rvalue ref | copy or move |
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 |
|
|
|
||||||||||||||||
| Tuple-like |
|
|
|
||||||||||||||||
| Otherwise† |
|
|
|
||||||||||||||||
† [dcl.struct.bind] ¶8 imposes no aggregate requirement: any class type with publicly accessible direct members and no
std::tuple_sizespecialization 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 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:
const qualifies e, and constexpr
— valid on structured binding declarations since C++26, [P2686R5] — implies it. The elements read from
e are const, so the assignment to a using-marked
target copies rather than moves.static and thread_local give e
static or thread storage duration. e is initialized once; every
later execution of the statement assigns from that same e —
which earlier iterations may have moved from.constinit constrains the initialization of e,
and volatile qualifies it. Neither changes what a
using-marked element does.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();_ PlaceholderP3817 composes naturally with C++26’s _ placeholder for
discarding elements:
int x;
auto [using x, _] = get_pair(); // assign first element to x, discard secondNote: 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 |
|---|---|
|
|
|
|
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 objectAn 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.
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 lastusing 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
}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 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]
}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 tupleThe existing constraints extend naturally:
using _... is ill-formed — _ is a discard
placeholder.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);
}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:
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 trajectoryExpected<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;
}- 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);- std::tie(params["kn"], params["cf"]) = *split_kt(kn_msg);
+ auto [using params["kn"], using params["cf"]] = *split_kt(kn_msg);& Symbolint x;
auto [&x, y] = get_values();Pros:
& suggests “referencing something that already
exists”Cons:
auto [&get_reference(), y] = ...]] in auto [&map[key], y] resembles
attribute syntaxauto& [x, y] may cause initial
confusionusing Keyword (preferred by authors)int x;
auto [using x, y] = get_values();Pros:
auto [using get_reference(), y] = ...using to refer to a name
declared elsewhere.Cons:
&=x — Conflicts with lambda
capture-by-value intuition ([=]).let — Conflicts with Pattern Matching
proposals.tie x — Overly verbose; confusingly
evokes std::tie even though it does not use it.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.
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
[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]
S Uᵢ rᵢ = initializer ; — and “Each SBᵢ that is a
structured binding is the name of an lvalue of type Tᵢ that refers
to the object bound to rᵢ; the referenced type is Tᵢ.”e.mᵢ; …”In ¶4, restrict the definition to the declaring form:
In ¶6, add to the list of contexts in which a pack expansion can occur:
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:
std in kernel”.zircon/kernel/hypervisor/aspace.cc.