구조적 바인딩 선언
구조적 바인딩 선언 (Structured binding declaration, C++17부터)
튜플이나 구조체의 요소를 한 번에 여러 변수로 풀어내고 싶을 때 쓰는 게 구조적 바인딩이에요. 이 페이지에서는 구조적 바인딩이 어떤 원리로 이름을 하위 객체에 묶는지, 그리고 배열·튜플·데이터 멤버의 세 가지 경우가 어떻게 다르게 동작하는지 정리할게요.
출처: cppreference
본문
구조적 바인딩 선언은 지정된 이름들을 이니셜라이저의 하위 객체나 요소에 묶어요.
참조처럼, 구조적 바인딩은 기존 객체의 별명이에요. 참조와 달리, 구조적 바인딩은 참조 타입일 필요가 없어요.
attr(선택) decl-specifier-seq ref-qualifier(선택) [sb-identifier-list] initializer ; |
initializer는 다음 중 하나일 수 있어요:
= expression |
(1) |
{ expression } |
(2) |
( expression ) |
(3) |
구조적 바인딩 선언은 sb-identifier-list의 모든 식별자를 둘러싸는 범위의 이름으로 도입하고, 그것들을 expression이 나타내는 객체의 하위 객체나 요소에 묶어요. 이렇게 도입된 바인딩을 *구조적 바인딩(structured bindings)*이라고 해요.
| sb-identifier-list의 식별자 하나에 줄임표(ellipsis)가 앞에 붙을 수 있어요. 그런 식별자는 *구조적 바인딩 팩(structured binding pack)*을 도입해요. 그 식별자는 템플릿 엔티티를 선언해야 해요. | (C++26부터) |
|---|
구조적 바인딩은 sb-identifier-list에서 줄임표가 앞에 붙지 않은 식별자, 또는 같은 식별자 목록에서 도입된 구조적 바인딩 팩의 요소예요(C++26부터).
바인딩 과정 (Binding process)
구조적 바인딩 선언은 먼저 이니셜라이저의 값을 담기 위해 고유한 이름의 변수(여기서는 e)를 도입해요:
- expression이 배열 타입 cv1
A이고 ref-qualifier가 없으면,e를 attr(선택) specifiersA e;로 정의해요. 여기서 specifiers는 decl-specifier-seq에서auto를 뺀 지정자들의 시퀀스예요. - 그렇지 않으면
e를 attr(선택) decl-specifier-seq ref-qualifier(선택)einitializer;로 정의해요.
식별자 표현식 e의 타입을 E로 나타내요(즉 E는 std::remove_reference_t<decltype((e))>와 동등).
E의 *구조적 바인딩 크기(structured binding size)*는 구조적 바인딩 선언이 도입해야 하는 구조적 바인딩의 개수예요.
sb-identifier-list의 식별자 수는 E의 구조적 바인딩 크기와 같아야 해요. |
(C++26 이전) |
|---|---|
sb-identifier-list의 식별자 수를 N, E의 구조적 바인딩 크기를 S라고 할 때: · 구조적 바인딩 팩이 없으면 N은 S와 같아야 한다. · 그 외에는, 팩이 아닌 요소 수(즉 N-1)는 S보다 작거나 같아야 하고, 구조적 바인딩 팩의 요소 수는 S-N+1이 된다(0이 될 수 있음). |
(C++26부터) |
struct C { int x, y, z; };
template<class T>
void now_i_know_my()
{
auto [a, b, c] = C(); // OK: a, b, c는 각각 x, y, z를 가리킴
auto [d, ...e] = C(); // OK: d는 x를; ...e는 y와 z를 가리킴
auto [...f, g] = C(); // OK: ...f는 x와 y를; g는 z를 가리킴
auto [h, i, j, ...k] = C(); // OK: 팩 k는 비어 있음
auto [l, m, n, o, ...p] = C(); // 오류: 구조적 바인딩 크기가 너무 작음
}
구조적 바인딩 선언은 E에 따라 세 가지 방식 중 하나로 바인딩을 수행해요:
- Case 1:
E가 알려진 경계(bound)의 배열 타입이면, 이름들은 배열 요소에 묶여요. - Case 2:
E가 비-유니온 클래스 타입이고std::tuple_size<E>가value라는 이름의 멤버를 가진 완전 타입이면(그 멤버의 타입이나 접근성과 무관), "튜플-유사(tuple-like)" 바인딩 프로토콜을 사용해요. - Case 3:
E가 비-유니온 클래스 타입인데std::tuple_size<E>가 완전 타입이 아니면, 이름들은E의 접근 가능한 데이터 멤버에 묶여요.
세 경우 각각은 아래에서 더 자세히 설명해요.
각 구조적 바인딩은 *참조된 타입(referenced type)*을 가져요. 이 타입은 괄호로 감싸지 않은 구조적 바인딩에 decltype을 적용했을 때 반환되는 타입이에요.
Case 1: 배열 바인딩
sb-identifier-list의 각 구조적 바인딩은 배열의 대응 요소를 가리키는 lvalue의 이름이 돼요. E의 구조적 바인딩 크기는 배열 요소 수와 같아요.
각 구조적 바인딩의 참조된 타입은 배열 요소 타입이에요. 배열 타입 E가 cv-한정이면 그 요소 타입도 cv-한정이라는 점을 유의하세요.
int a[2] = {1, 2};
auto [x, y] = a; // e[2]를 만들고 a를 e에 복사한 뒤,
// x는 e[0]을, y는 e[1]을 가리킴
auto& [xr, yr] = a; // xr은 a[0]을, yr은 a[1]을 가리킴
Case 2: 튜플 연산을 구현한 타입 바인딩
표현식 std::tuple_size<E>::value는 잘 구성된 정수 상수 표현식이어야 하고, E의 구조적 바인딩 크기는 std::tuple_size<E>::value와 같아요.
각 구조적 바인딩에 대해 "std::tuple_element<I,E>::type에 대한 참조" 타입의 변수가 도입돼요. 대응 이니셜라이저가 lvalue면 lvalue 참조, 아니면 rvalue 참조예요. I번째 변수의 이니셜라이저는:
E의 범위에서 멤버 접근 조회로 식별자get을 찾았을 때, 첫 템플릿 매개변수가 비-타입 매개변수인 함수 템플릿 선언이 적어도 하나 발견되면e.get<I>(),- 그렇지 않으면
get<I>(e). 이때get은 인자 의존 조회(ADL)로만 찾고, 비-ADL 조회는 무시해요.
이 초기화 표현식들에서 e는 엔티티 e의 타입이 lvalue 참조인 경우에만 lvalue고(ref-qualifier가 &이거나, &&이면서 이니셜라이저 표현식이 lvalue일 때만 그렇게 돼요), 그 외에는 xvalue예요(이렇게 일종의 완벽 전달이 수행돼요). I는 std::size_t prvalue이고, <I>는 항상 템플릿 매개변수 목록으로 해석돼요.
그 변수는 e와 같은 저장 기간을 가져요.
그러면 구조적 바인딩은 그 변수에 묶인 객체를 가리키는 lvalue의 이름이 돼요.
I번째 구조적 바인딩의 참조된 타입은 std::tuple_element<I,E>::type이에요.
float x{};
char y{};
int z{};
std::tuple<float&, char&&, int> tpl(x, std::move(y), z);
const auto& [a, b, c] = tpl;
// using Tpl = const std::tuple<float&, char&&, int>;
// a는 x를 가리키는 구조적 바인딩 (get<0>(tpl)에서 초기화)
// decltype(a)는 std::tuple_element<0, Tpl>::type, 즉 float&
// b는 y를 가리키는 구조적 바인딩 (get<1>(tpl)에서 초기화)
// decltype(b)는 std::tuple_element<1, Tpl>::type, 즉 char&&
// c는 tpl의 세 번째 성분 get<2>(tpl)을 가리키는 구조적 바인딩
// decltype(c)는 std::tuple_element<2, Tpl>::type, 즉 const int
Case 3: 데이터 멤버 바인딩
E의 모든 비-static 데이터 멤버는 E의 직접 멤버이거나 E의 같은 기반 클래스의 멤버여야 하고, 구조적 바인딩의 맥락에서 e.name으로 이름 붙였을 때 잘 구성되어야 해요. E는 무명 유니온 멤버를 가질 수 없어요. E의 구조적 바인딩 크기는 비-static 데이터 멤버 수와 같아요.
sb-identifier-list의 각 구조적 바인딩은, e의 다음 멤버를 선언 순서대로 가리키는 lvalue의 이름이 돼요(비트 필드도 지원돼요). 그 lvalue의 타입은 e.mI의 타입이에요. 여기서 mI는 I번째 멤버를 나타내요.
I번째 구조적 바인딩의 참조된 타입은, e.mI의 타입이 참조 타입이 아니면 그 타입이고, 참조 타입이면 mI의 선언된 타입이에요.
#include <iostream>
struct S
{
mutable int x1 : 2;
volatile double y1;
};
S f() { return S{1, 2.3}; }
int main()
{
const auto [x, y] = f(); // x는 2-비트 비트 필드를 식별하는 int lvalue
// y는 const volatile double lvalue
std::cout << x << ' ' << y << '\n'; // 1 2.3
x = -2; // OK
// y = -2.; // 오류: y는 const-한정
std::cout << x << ' ' << y << '\n'; // -2 2.3
}
초기화 순서 (Initialization order)
sb-identifier-list의 I번째 구조적 바인딩이 이름 짓는 객체나 참조를 valI라고 할 때:
e의 초기화는 어떤valI의 초기화보다 앞서 순서화(sequenced before)돼요.- 각
valI의 초기화는,I가J보다 작은 어떤valJ의 초기화보다 앞서 순서화돼요.
주의할 점 (Notes)
구조적 바인딩은 제약(constrained)될 수 없어요: cpp template<class T> concept C = true; C auto [x, y] = std::pair{1, 2}; // 오류: constrained |
(C++20부터) |
|---|
멤버 get에 대한 조회는 평소처럼 접근성을 무시하고, 비-타입 템플릿 매개변수의 정확한 타입도 무시해요. private template<char*> void get(); 멤버가 있으면, ill-formed임에도 멤버 해석을 사용하게 돼요.
[ 앞의 선언 부분은 도입된 구조적 바인딩이 아니라 숨은 변수 e에 적용돼요:
int a = 1, b = 2;
const auto& [x, y] = std::tie(a, b); // x와 y는 int& 타입
auto [z, w] = std::tie(a, b); // z와 w도 여전히 int& 타입
assert(&z == &a); // 통과
튜플-유사 해석은 std::tuple_size<E>가 value라는 멤버를 가진 완전 타입이면 항상 사용돼요. 그게 프로그램을 ill-formed로 만들더라도 그래요:
struct A { int x; };
namespace std
{
template<> struct tuple_size<::A> { void value(); };
}
auto [x] = A{}; // 오류: "데이터 멤버" 해석은 고려되지 않음
ref-qualifier가 있고 표현식이 prvalue면, 임시 객체에 참조를 묶는 평소 규칙(수명 연장 포함)이 적용돼요. 그 경우 숨은 변수 e는 prvalue 표현식에서 구체화된 임시 변수에 묶여 그 수명을 연장하는 참조가 돼요. 평소처럼 e가 비-const lvalue 참조면 바인딩이 실패해요:
int a = 1;
const auto& [x] = std::make_tuple(a); // OK, dangling 아님
auto& [y] = std::make_tuple(a); // 오류, rvalue std::tuple에 auto&를 못 묶음
auto&& [z] = std::make_tuple(a); // 역시 OK
구조적 바인딩을 나타내는 decltype(x)는 그 구조적 바인딩의 참조된 타입을 이름 짓는 것이에요. 튜플-유사 경우엔, 이때 항상 숨은 참조가 도입됨에도, 이것은 std::tuple_element가 반환한 타입일 수 있고 참조가 아닐 수 있어요. 이는 바인딩 자체의 참조성은 구현 세부 사항일 뿐인 채로, std::tuple_element가 반환한 타입을 비-static 데이터 멤버로 가진 구조체에 바인딩하는 동작을 효과적으로 모방해요.
std::tuple<int, int&> f();
auto [x, y] = f(); // decltype(x)는 int
// decltype(y)는 int&
const auto [z, w] = f(); // decltype(z)는 const int
// decltype(w)는 int&
구조적 바인딩은 람다 표현식으로 캡처할 수 없었어요: cpp #include <cassert> int main() { struct S { int p{6}, q{7}; }; const auto& [b, d] = S{}; auto l = [b, d] { return b * d; }; // C++20부터 유효 assert(l() == 42); } |
(C++20 이전) |
|---|
구조적 바인딩 크기가 0이어도, sb-identifier-list가 빈 구조적 바인딩 팩만 도입할 수 있는 식별자 정확히 하나를 담고 있으면 허용돼요. cpp auto return_empty() -> std::tuple<>; template<class> void test_empty() { auto [] = return_empty(); // 오류 auto [...args] = return_empty(); // OK, args는 빈 팩 auto [one, ...rest] = return_empty(); // 오류, 구조적 바인딩 크기가 너무 작음 } |
(C++26부터) |
|---|
키워드
없음(키워드가 아닌 문법 형태)
예제
여기서는 구조적 바인딩이 실제로 어떤 값에 묶이는지 확인해 볼게요. set::insert의 반환값을 풀어내는 경우와, 비트 필드 구조체를 각 멤버로 푸는 경우를 보여줘요.
#include <iomanip>
#include <iostream>
#include <set>
#include <string>
int main()
{
std::set<std::string> myset{"hello"};
for (int i{2}; i; --i)
{
if (auto [iter, success] = myset.insert("Hello"); success)
std::cout << "Insert is successful. The value is "
<< std::quoted(*iter) << ".\n";
else
std::cout << "The value " << std::quoted(*iter)
<< " already exists in the set.\n";
}
struct BitFields
{ // C++20: 비트 필드의 기본 멤버 이니셜라이저
int b : 4 { 1 }, d : 4 { 2 }, p : 4 { 3 }, q : 4 { 4 };
};
{
const auto [b, d, p, q] = BitFields{};
std::cout << b << ' ' << d << ' ' << p << ' ' << q << '\n';
}
{
const auto [b, d, p, q] = []{ return BitFields{4, 3, 2, 1}; }();
std::cout << b << ' ' << d << ' ' << p << ' ' << q << '\n';
}
{
BitFields s;
auto& [b, d, p, q] = s;
std::cout << b << ' ' << d << ' ' << p << ' ' << q << '\n';
b = 4, d = 3, p = 2, q = 1;
std::cout << s.b << ' ' << s.d << ' ' << s.p << ' ' << s.q << '\n';
}
}
출력:
Insert is successful. The value is "Hello".
The value "Hello" already exists in the set.
1 2 3 4
4 3 2 1
1 2 3 4
4 3 2 1
auto [iter, success] = myset.insert("Hello")는 insert가 반환한 std::pair<iterator, bool>을 두 변수로 풀어내요. 첫 호출은 성공해 success가 true, 두 번째 호출은 중복이라 false가 돼요. 뒤의 비트 필드 예제는 BitFields를 const auto [...](값 복사), 람다 반환, 그리고 auto&(원본 참조)로 각각 풀어내는 모습을 보여줘요.
결함 보고 (Defect reports)
다음 동작 변경 결함 보고는 과거에 발표된 C++ 표준에 소급 적용됐어요.
| DR | 적용 표준 | 발표 당시 동작 | 수정된 동작 |
|---|---|---|---|
| CWG 2285 | C++17 | expression이 identifier-list의 이름을 가리킬 수 있었음 | 이 경우 선언은 ill-formed |
| CWG 2312 | C++17 | case 3에서 mutable의 의미가 사라졌음 |
그 의미가 유지됨 |
| CWG 2313 | C++17 | case 2에서 구조적 바인딩 변수를 재선언할 수 있었음 | 재선언 불가 |
| CWG 2339 | C++17 | case 2에서 I의 정의가 빠져 있었음 |
정의 추가 |
| CWG 2341 (P1091R3) | C++17 | 구조적 바인딩을 static 저장 기간으로 선언할 수 없었음 | 허용됨 |
| CWG 2386 | C++17 | std::tuple_size<E>가 완전 타입일 때마다 "튜플-유사" 프로토콜을 사용했음 |
std::tuple_size<E>에 value 멤버가 있을 때만 사용 |
| CWG 2506 | C++17 | expression이 cv-한정 배열 타입이면 cv-한정이 E로 옮겨졌음 |
그 cv-한정은 버림 |
| CWG 2635 | C++20 | 구조적 바인딩이 constrained될 수 있었음 | 금지 |
| CWG 2867 | C++17 | 초기화 순서가 불명확했음 | 명확해짐 |
| P0961R1 | C++17 | case 2에서 조회가 어떤 종류의 get을 찾아도 멤버 get을 사용했음 |
비-타입 매개변수를 가진 함수 템플릿을 찾을 때만 사용 |
| P0969R0 | C++17 | case 3에서 멤버가 public이어야 했음 | 선언 맥락에서 접근 가능하면 충분 |
| P1306R5 | C++17 | E가 알려지지 않은 경계의 배열 타입일 때 구조적 바인딩 크기가 불명확했음 |
배열 경계가 알려져야 함 |
더 알아보기
std::tie(C++11): lvalue 참조들의 std::tuple을 만들거나 튜플을 개별 객체로 푼다 (함수 템플릿).