R0: Initial version
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.
No standard library required —
std::tie requires <tuple>, which is
unavailable in many constrained environments (embedded systems,
bare-metal, OS kernels). A language-level feature works everywhere C++
does.
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 lvalue, rather than declaration of a
new variable.
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.
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
|
|---|---|---|
|
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.
constA 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 bindingAlternative 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.
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-formedNon-using elements are unaffected — y above
would be a valid static or thread-local binding.
constexprC++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 expressionsFor 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 constconstinitconstinit 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.
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();_ 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.
This provides a complete, library-free replacement for
std::tie with std::ignore:
std::tie with std::ignore
|
With P3817 and C++26 |
|---|---|
|
|
|
|
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-formedEven for types where repeated assignment would be well-defined (e.g.,
a type whose operator= accumulates values), the construct
is rejected as inherently confusing.
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 lastusing ...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:
using ..._ is ill-formed — _ is a discard
placeholder.sizeof...(expr) must equal the structured binding size
of e minus the number of non-pack elements; otherwise the
program is ill-formed.expr produces duplicate lvalue targets,
the program is ill-formed (the duplicate variable rule extended to
packs).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);
}forBecause 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 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: - 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
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
=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.Will be completed upon positive response on the direction of the proposal.