C++17 구조적 바인딩: map·pair·tuple 분해와 auto&·const auto& 선택
이 글의 핵심
C++17 구조화된 바인딩으로 배열·구조체·튜플·map 요소를 이름 있는 변수로 푸는 방법과 auto·auto&·const auto&·auto&& 선택 기준을 정리합니다. insert 결과와 if 초기화문, 커스텀 타입의 튜플 프로토콜, 중첩 분해 불가·C++17 람다 캡처 제한 같은 함정도 다룹니다.
들어가며
Structured Binding (구조적 바인딩)은 C++17에서 도입된 기능으로, 튜플, 배열, 구조체 등을 여러 변수로 한 번에 분해할 수 있습니다.
#include <tuple>
#include <iostream>
#include <string>
int main() {
// tuple 생성: 여러 타입의 값을 하나로 묶음
std::tuple<int, double, std::string> t = {42, 3.14, "Hello"};
// 구조적 바인딩 (Structured Binding, C++17)
// auto [i, d, s]: tuple의 각 요소를 개별 변수로 분해
// i, d, s는 각각 tuple의 첫 번째, 두 번째, 세 번째 요소
// 타입은 자동 추론: i는 int, d는 double, s는 string
auto [i, d, s] = t;
std::cout << i << std::endl; // 42
std::cout << d << std::endl; // 3.14
std::cout << s << std::endl; // Hello
}
왜 필요한가?:
- 간결성: 여러 변수를 한 줄로 선언
- 가독성: 의미 있는 이름 부여
- 안전성: 타입 추론으로 실수 방지
- 편의성: 맵 순회, 함수 반환값 처리
// ❌ 기존 방식: 복잡
std::map<std::string, int> m;
for (const auto& pair : m) {
std::cout << pair.first << ": " << pair.second << '\n';
}
// ✅ 구조적 바인딩: 간결
for (const auto& [key, value] : m) {
std::cout << key << ": " << value << '\n';
}
배열·구조체·튜플 분해하기
구조적 바인딩은 배열, 구조체, 튜플이라는 세 가지 대표적인 자료 형태를 모두 같은 문법(auto [a, b, c] = 대상;)으로 분해할 수 있습니다. 어떤 자료형이든 요소 개수와 변수 개수만 맞추면 컴파일러가 나머지를 알아서 처리해주기 때문에, 세 가지 형태를 하나씩 살펴보며 공통된 문법 감각을 익혀보겠습니다.
배열
고정 크기 배열은 구조적 바인딩이 지원하는 가장 단순한 형태입니다. 아래 int arr[] = {1, 2, 3};을 auto [a, b, c] = arr;로 분해하면 배열의 각 요소가 순서대로 a, b, c에 복사됩니다. 배열은 크기 정보가 타입에 포함되어 있어(int[3]), 컴파일러가 요소 개수를 정확히 알고 있으므로 변수 개수가 배열 크기와 다르면 즉시 컴파일 에러로 알려줍니다.
#include <iostream>
int main() {
int arr[] = {1, 2, 3};
auto [a, b, c] = arr;
std::cout << a << std::endl; // 1
std::cout << b << std::endl; // 2
std::cout << c << std::endl; // 3
return 0;
}
구조체
멤버 변수만 가진 단순한 집합체(aggregate) 구조체도 구조적 바인딩으로 분해할 수 있는데, 이때는 별도의 접근 함수 없이 멤버가 선언된 순서대로 변수에 대응됩니다. 아래 Point는 x, y 두 멤버를 가지고 있어 auto [x, y] = p;로 분해하면 x에는 p.x가, y에는 p.y가 매핑되는데, 멤버 이름과 바인딩 변수 이름이 우연히 같아 보일 뿐 실제로는 선언 순서만으로 대응 관계가 결정된다는 점을 알아두어야 합니다.
#include <iostream>
struct Point {
int x;
int y;
};
int main() {
Point p = {10, 20};
auto [x, y] = p;
std::cout << "x: " << x << std::endl; // 10
std::cout << "y: " << y << std::endl; // 20
return 0;
}
튜플
구조체와 달리 std::tuple은 이름 있는 멤버가 없기 때문에, 구조적 바인딩 이전에는 std::get<0>, std::get<1>처럼 인덱스로만 요소에 접근할 수 있었습니다. 아래 getData()가 반환하는 tuple<int, double, string>을 auto [i, d, s] = getData();로 즉시 분해하면, 함수가 여러 값을 반환하는 상황에서도 각 값에 인덱스 대신 의미 있는 이름을 바로 붙여 사용할 수 있어 코드의 의도가 훨씬 분명해집니다.
#include <tuple>
#include <iostream>
#include <string>
std::tuple<int, double, std::string> getData() {
return {42, 3.14, "Hello"};
}
int main() {
auto [i, d, s] = getData();
std::cout << "int: " << i << std::endl;
std::cout << "double: " << d << std::endl;
std::cout << "string: " << s << std::endl;
return 0;
}
복사로 받을지 참조로 받을지
구조적 바인딩 앞에 붙이는 한정자(auto, auto&, const auto&)에 따라 바인딩된 변수가 원본의 복사본인지 참조인지가 완전히 달라집니다. 이 차이를 정확히 이해하지 못하면 원본을 수정했다고 착각하거나, 반대로 불필요한 복사로 성능을 낭비하는 실수를 하기 쉽습니다.
복사 vs 참조
auto [x1, y1] = p;처럼 그냥 auto를 쓰면 x1, y1은 p.x, p.y의 완전한 복사본이 되어, 이후 x1을 바꿔도 원본 p는 전혀 영향을 받지 않습니다. auto& [x2, y2] = p;처럼 &를 붙이면 x2, y2는 p.x, p.y의 별칭(참조)이 되어 그 값을 바꾸면 p도 즉시 함께 바뀌고, const auto& [x3, y3] = p;는 참조이되 수정은 금지해 불필요한 복사 없이 읽기 전용으로만 접근하고 싶을 때 가장 안전하고 효율적인 선택이 됩니다.
#include <iostream>
struct Point {
int x, y;
};
int main() {
Point p = {10, 20};
// 방법 1: 복사 (auto)
// x1, y1은 p.x, p.y의 복사본
// x1, y1 변경해도 p는 영향 없음
auto [x1, y1] = p;
x1 = 100; // x1만 변경 (p.x는 여전히 10)
std::cout << "p.x: " << p.x << std::endl; // 10
// 방법 2: 참조 (auto&)
// x2, y2는 p.x, p.y의 참조 (별칭)
// x2, y2 변경하면 p도 변경됨
auto& [x2, y2] = p;
x2 = 100; // p.x가 100으로 변경됨
std::cout << "p.x: " << p.x << std::endl; // 100
// 방법 3: const 참조 (const auto&)
// x3, y3는 p.x, p.y의 const 참조
// 읽기만 가능, 수정 불가 (불필요한 복사 방지)
const auto& [x3, y3] = p;
// x3 = 200; // ❌ 컴파일 에러: const 참조는 수정 불가
std::cout << "x3: " << x3 << std::endl; // 100
return 0;
}
출력:
p.x: 10
p.x: 100
x3: 100
맵 순회·함수 반환값·pair 언팩
지금까지 배운 문법을 실무에서 가장 자주 마주치는 네 가지 상황에 적용해 보겠습니다. 맵 순회, 함수의 다중 반환값 처리, 표준 알고리즘이 반환하는 pair 언팩, 구조체 벡터 순회입니다.
맵 순회
std::map을 범위 기반 for로 순회하면 각 요소는 std::pair<const Key, Value> 타입인데, 구조적 바인딩이 없던 시절에는 pair.first, pair.second로 접근해야 해서 코드만 봐서는 무엇이 키이고 무엇이 값인지 즉시 알기 어려웠습니다. for (const auto& [name, score] : scores)처럼 구조적 바인딩을 쓰면 name과 score라는 의미 있는 이름이 코드에 바로 드러나고, const auto&를 사용해 매 순회마다 불필요한 복사도 피할 수 있습니다.
#include <map>
#include <iostream>
#include <string>
int main() {
std::map<std::string, int> scores = {
{"Alice", 90},
{"Bob", 85},
{"Charlie", 95}
};
std::cout << "=== 구조적 바인딩 ===" << std::endl;
// const auto& [name, score]: map의 각 요소(pair)를 분해
// name: pair.first (키)
// score: pair.second (값)
// const auto&: 복사 없이 const 참조로 접근 (효율적)
for (const auto& [name, score] : scores) {
// pair.first, pair.second 대신 의미 있는 이름 사용
std::cout << name << ": " << score << std::endl;
}
std::cout << "\n=== 기존 방식 ===" << std::endl;
// 기존 방식: pair 객체로 접근
// pair.first, pair.second는 의미가 불명확
for (const auto& pair : scores) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
return 0;
}
두 방식 모두 같은 맵을 순회하므로 결과는 동일하지만, 코드의 가독성에서 차이가 드러납니다.
=== 구조적 바인딩 ===
Alice: 90
Bob: 85
Charlie: 95
=== 기존 방식 ===
Alice: 90
Bob: 85
Charlie: 95
함수 반환값
함수가 튜플이나 pair로 여러 값을 반환하는 것은 흔한 패턴이지만, 구조적 바인딩이 없다면 호출부에서 매번 std::get<0>, std::get<1>을 반복해야 했습니다. 아래 getRGB()와 divmod()는 각각 색상의 R/G/B 값과 몫/나머지를 튜플·pair로 반환하는데, auto [r, g, b] = getRGB();와 auto [quotient, remainder] = divmod(17, 5);처럼 호출과 동시에 의미 있는 이름으로 분해하면 함수 시그니처만 보고도 반환값의 의미를 짐작하기 쉬워집니다.
#include <tuple>
#include <iostream>
std::tuple<int, int, int> getRGB() {
return {255, 128, 64};
}
std::pair<int, int> divmod(int dividend, int divisor) {
return {dividend / divisor, dividend % divisor};
}
int main() {
auto [r, g, b] = getRGB();
std::cout << "R: " << r << std::endl;
std::cout << "G: " << g << std::endl;
std::cout << "B: " << b << std::endl;
auto [quotient, remainder] = divmod(17, 5);
std::cout << "17 / 5 = " << quotient << " ... " << remainder << std::endl;
return 0;
}
출력:
R: 255
G: 128
B: 64
17 / 5 = 3 ... 2
pair 언팩
표준 라이브러리의 여러 함수가 결과를 pair로 반환하도록 설계되어 있어, 구조적 바인딩과 특히 궁합이 좋습니다. std::minmax_element는 최솟값과 최댓값의 위치를 가리키는 반복자 쌍을 반환하고, std::map::insert는 삽입된(또는 이미 있던) 위치의 반복자와 실제로 삽입이 일어났는지를 나타내는 bool을 pair로 반환합니다. 아래 예제처럼 auto [minIt, maxIt] = ...와 auto [it, inserted] = ...로 즉시 분해하면, 반환된 pair의 각 요소가 무엇을 의미하는지 변수 이름만으로 명확해집니다.
#include <algorithm>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
auto [minIt, maxIt] = std::minmax_element(v.begin(), v.end());
std::cout << "최소: " << *minIt << std::endl; // 1
std::cout << "최대: " << *maxIt << std::endl; // 9
// 삽입 결과
std::map<std::string, int> m;
auto [it, inserted] = m.insert({"key", 10});
if (inserted) {
std::cout << "삽입 성공: " << it->first << " = " << it->second << std::endl;
}
return 0;
}
출력:
최소: 1
최대: 9
삽입 성공: key = 10
구조체 분해
구조적 바인딩은 단일 구조체뿐 아니라 구조체로 이루어진 컨테이너를 순회할 때도 그대로 적용됩니다. 아래 std::vector<Person>을 for (const auto& [name, age, height] : people)로 순회하면, 매 반복마다 Person 객체 하나가 세 개의 이름 있는 변수로 즉시 분해되어 person.name, person.age, person.height처럼 멤버 접근 구문을 반복해서 쓸 필요가 없어집니다. 필드가 많은 구조체를 순회할수록 이 방식이 코드를 얼마나 간결하게 만들어주는지 체감하게 됩니다.
#include <vector>
#include <iostream>
#include <string>
struct Person {
std::string name;
int age;
double height;
};
int main() {
std::vector<Person> people = {
{"Alice", 25, 165.5},
{"Bob", 30, 175.0},
{"Charlie", 28, 180.5}
};
for (const auto& [name, age, height] : people) {
std::cout << name << " (" << age << "세, " << height << "cm)" << std::endl;
}
return 0;
}
출력:
Alice (25세, 165.5cm)
Bob (30세, 175cm)
Charlie (28세, 180.5cm)
개수 불일치·참조 수명·람다 캡처 제약
구조적 바인딩은 문법이 간단한 만큼, 규칙을 정확히 모르면 오히려 당황스러운 컴파일 에러를 마주치기 쉽습니다. 요소 개수, 참조 대상의 수명, 타입 명시라는 세 가지 흔한 실수를 짚어보겠습니다.
요소 개수 불일치
구조적 바인딩의 변수 개수는 분해 대상의 요소 개수와 정확히 일치해야 합니다. 아래 std::tuple<int, int, int>는 요소가 3개인데 auto [a, b] = t;처럼 변수를 2개만 선언하면, 컴파일러는 이를 부분적으로 분해하는 것을 허용하지 않고 즉시 컴파일 에러를 발생시킵니다. 배열 일부만 취급하고 싶은 상황이 흔하다면, 구조적 바인딩 대신 인덱스로 필요한 요소만 골라 쓰는 것이 더 적합합니다.
#include <tuple>
int main() {
std::tuple<int, int, int> t = {1, 2, 3};
// ❌ 에러: 3개인데 2개만
// auto [a, b] = t;
// ✅ 개수 일치
auto [a, b, c] = t;
return 0;
}
참조 수명
auto&로 참조 바인딩을 만들 때는 참조 대상이 바인딩보다 오래 살아있어야 한다는 일반적인 레퍼런스 수명 규칙이 그대로 적용됩니다. 다만 임시 객체를 auto&로 분해하는 auto& [x, y] = Point{10, 20};은 댕글링이 되기 전에 컴파일 에러가 납니다. 비const 좌값 참조는 임시에 바인딩할 수 없기 때문입니다. 임시를 받을 때 쓸 수 있는 선택지와 그 효과는 이렇습니다.
| 선언 | 임시 객체 받기 | 원본 수정 | 비고 |
|---|---|---|---|
auto [a, b] | ✅ | ❌ | 복사본을 분해 |
auto& [a, b] | ❌ 컴파일 에러 | ✅ | 이름 있는 좌값에만 |
const auto& [a, b] | ✅ (수명 연장) | ❌ | 읽기 전용 |
auto&& [a, b] | ✅ (수명 연장) | ✅ | 제네릭 코드용 |
진짜 댕글링은 수명 연장이 적용되지 않는 경우에 생깁니다. 예를 들어 함수가 지역 객체의 참조를 돌려주는 걸 분해하거나, getObj().member()처럼 임시 객체의 멤버 참조를 받는 경우입니다.
#include <iostream>
struct Point {
int x, y;
};
int main() {
// ❌ 컴파일 에러: 임시를 비const 참조로 분해
// auto& [x, y] = Point{10, 20};
// ✅ const 참조: 임시의 수명이 바인딩 변수와 같아짐
const auto& [cx, cy] = Point{10, 20};
// ✅ 복사
auto [x1, y1] = Point{10, 20};
std::cout << "x1: " << x1 << ", y1: " << y1 << std::endl;
// ✅ 변수 저장
Point p = {10, 20};
auto& [x2, y2] = p;
x2 = 100;
std::cout << "p.x: " << p.x << std::endl; // 100
return 0;
}
출력:
x1: 10, y1: 20
p.x: 100
타입 추론
구조적 바인딩의 각 변수는 반드시 auto를 통해서만 타입을 얻을 수 있고, auto [int x, double y] = ...;처럼 개별 변수마다 타입을 직접 명시하는 문법은 허용되지 않습니다. 이는 구조적 바인딩이 애초에 “대상의 실제 타입에 맞춰 각 변수의 타입을 자동으로 결정한다”는 목적으로 설계되었기 때문으로, 타입을 강제로 지정하고 싶다면 구조적 바인딩 이전 단계에서 값을 원하는 타입으로 먼저 변환해두어야 합니다.
#include <tuple>
int main() {
// ❌ 타입 명시 불가
// auto [int x, double y] = std::tuple{1, 2.0}; // 에러
// ✅ auto만 가능
auto [x, y] = std::tuple{1, 2.0};
return 0;
}
중첩 분해는 안 된다
pair<int, pair<int, int>>처럼 중첩된 값을 auto [a, [b, c]]로 한 번에 풀 수는 없습니다(C++26까지도 표준에 없음). 단계별로 나눠 분해합니다.
std::pair<int, std::pair<int, int>> nested{1, {2, 3}};
auto [a, bc] = nested;
auto [b, c] = bc; // 1, 2, 3
C++17에서는 람다가 바인딩 이름을 캡처하지 못한다
C++17에서 auto [key, value] = p;로 만든 이름을 람다에서 [key]나 [&]로 캡처하면 컴파일 에러가 납니다. 바인딩 이름은 “변수”가 아니라 숨은 객체의 멤버를 가리키는 별칭이라서입니다. C++20부터 캡처가 허용됐습니다. C++17 코드베이스라면 [k = key]처럼 초기화 캡처로 복사해 넘기면 됩니다. 반복문 안에서 콜백을 등록하는 코드(for (auto& [id, handler] : handlers) queue.post([&] { handler(id); });)를 C++17 프로젝트로 옮겼다가 이 에러를 처음 만나는 경우가 많습니다.
에러 처리·다중 반환값·범위 for에서 쓰는 방식
지금까지의 개념을 실제 프로젝트에서 자주 쓰이는 세 가지 패턴으로 정리하면, 에러 처리, 다중 반환값 활용, 맵 데이터의 읽기·수정 순회로 요약할 수 있습니다.
에러 처리
예외를 던지지 않고 성공 여부와 결과 메시지를 함께 반환하고 싶을 때, std::pair<bool, std::string>과 구조적 바인딩을 조합하면 깔끔한 에러 처리 코드를 작성할 수 있습니다. 아래 parseConfig는 성공 여부를 bool로, 성공 메시지나 에러 이유를 string으로 함께 반환하고, 호출부에서는 auto [success, message] = parseConfig(...);로 즉시 분해해 if (!success)로 실패를 조기에 처리합니다. 이런 패턴은 예외 처리 비용을 피하면서도 실패 원인을 함께 전달해야 하는 파일 파싱, 설정 로딩 같은 상황에 특히 적합합니다.
#include <iostream>
#include <string>
#include <fstream>
std::pair<bool, std::string> parseConfig(const std::string& path) {
std::ifstream file(path);
if (!file.is_open()) {
return {false, "파일 없음"};
}
// 파싱 로직
std::string content;
if (!std::getline(file, content)) {
return {false, "파싱 실패"};
}
return {true, "성공"};
}
int main() {
auto [success, message] = parseConfig("config.json");
if (!success) {
std::cerr << "에러: " << message << std::endl;
return 1;
}
std::cout << "결과: " << message << std::endl;
return 0;
}
다중 반환값
몫과 나머지처럼 서로 밀접하게 관련된 여러 결과값을 함께 반환해야 할 때, 튜플과 구조적 바인딩의 조합이 진가를 발휘합니다. 아래 divmod는 몫, 나머지, 부호라는 세 가지 값을 한 번의 계산으로 구해 튜플로 반환하고, 호출부는 auto [q, r, s] = divmod(17, 5);로 세 값을 한 줄에 받아옵니다. 음수 나눗셈처럼 부호 처리가 까다로운 연산일수록, 관련된 값들을 하나의 반환값으로 묶어두면 값들 사이의 일관성을 유지하기가 더 쉬워집니다.
#include <tuple>
#include <iostream>
std::tuple<int, int, int> divmod(int dividend, int divisor) {
int quotient = dividend / divisor;
int remainder = dividend % divisor;
int sign = (dividend < 0) ^ (divisor < 0) ? -1 : 1;
return {quotient, remainder, sign};
}
int main() {
auto [q, r, s] = divmod(17, 5);
std::cout << "몫: " << q << ", 나머지: " << r << ", 부호: " << s << std::endl;
// 몫: 3, 나머지: 2, 부호: 1
auto [q2, r2, s2] = divmod(-17, 5);
std::cout << "몫: " << q2 << ", 나머지: " << r2 << ", 부호: " << s2 << std::endl;
// 몫: -3, 나머지: -2, 부호: -1
return 0;
}
출력:
몫: 3, 나머지: 2, 부호: 1
몫: -3, 나머지: -2, 부호: -1
표준 API의 pair 반환값 바로 풀기
map::insert, set::insert, try_emplace는 pair<iterator, bool>을 돌려줍니다. .first/.second로 꺼내는 대신 바로 분해하면 “삽입됐는가”가 이름으로 드러납니다. C++17의 if 초기화문과 함께 쓰면 변수가 if 블록 안에만 머뭅니다.
std::map<int, std::string> m;
if (auto [it, inserted] = m.insert({1, "one"}); inserted) {
std::cout << "삽입: " << it->second << '\n';
} else {
std::cout << "이미 있음: " << it->second << '\n'; // 기존 값은 바뀌지 않음
}
auto [it2, inserted2] = m.try_emplace(1, "uno"); // inserted2 == false, 값도 "one" 그대로
비트 필드가 있는 구조체도 값으로 분해할 수 있습니다(auto [r, w, x] = flags;). 다만 비트 필드는 주소를 가질 수 없어서, 분해한 이름을 int&를 받는 함수에 넘기는 식의 코드는 컴파일되지 않습니다.
범위 기반 for
앞에서 복사 vs 참조를 다룰 때 본 const auto&와 auto&의 차이를 값 종류가 복잡한 맵에 적용해보는 예제입니다. 아래 첫 번째 순회는 const auto& [key, values]로 읽기 전용 접근만 하고, 두 번째 순회는 auto& [key, values]로 참조를 얻어 각 벡터에 push_back(0)을 호출해 맵에 담긴 데이터를 직접 수정합니다. 읽기만 할 때는 const auto&로 불필요한 복사를 막고, 수정이 필요할 때만 auto&로 바꾸는 습관을 들이면 의도치 않은 원본 변경을 방지하면서도 성능을 챙길 수 있습니다.
#include <map>
#include <vector>
#include <iostream>
#include <string>
int main() {
std::map<std::string, std::vector<int>> data = {
{"A", {1, 2, 3}},
{"B", {4, 5, 6}},
{"C", {7, 8, 9}}
};
// 맵 순회
std::cout << "=== 읽기 ===" << std::endl;
for (const auto& [key, values] : data) {
std::cout << key << ": ";
for (int v : values) {
std::cout << v << ' ';
}
std::cout << std::endl;
}
// 수정
std::cout << "\n=== 수정 ===" << std::endl;
for (auto& [key, values] : data) {
values.push_back(0); // 각 벡터에 0 추가
}
for (const auto& [key, values] : data) {
std::cout << key << ": ";
for (int v : values) {
std::cout << v << ' ';
}
std::cout << std::endl;
}
return 0;
}
읽기 순회와 수정 순회를 차례로 실행하면 각 벡터에 0이 추가된 결과가 다음처럼 출력됩니다.
=== 읽기 ===
A: 1 2 3
B: 4 5 6
C: 7 8 9
=== 수정 ===
A: 1 2 3 0
B: 4 5 6 0
C: 7 8 9 0
커스텀 타입에 구조적 바인딩 지원 추가하기
지금까지 본 배열, 구조체, 튜플은 모두 표준이나 컴파일러가 기본적으로 분해 방법을 알고 있는 타입이었지만, 직접 정의한 클래스도 “튜플 프로토콜”이라 불리는 세 가지 요소(get<I>() 멤버 함수, std::tuple_size 특수화, std::tuple_element 특수화)를 구현하면 구조적 바인딩의 대상이 될 수 있습니다. 아래 MyClass는 get<0>()과 get<1>()로 각각 x, y를 반환하도록 구현하고, std::tuple_size<MyClass>와 std::tuple_element<I, MyClass>를 특수화해 컴파일러에게 “이 클래스는 2개의 int 요소로 분해할 수 있다”는 정보를 알려줍니다. 이렇게 튜플 프로토콜을 구현해두면 auto [a, b] = obj;처럼 마치 내장 타입인 것처럼 자연스럽게 구조적 바인딩을 사용할 수 있어, 커스텀 좌표 클래스나 값 래퍼 타입을 라이브러리 사용자에게 더 편리한 인터페이스로 제공할 수 있습니다.
#include <iostream>
class MyClass {
public:
int x, y;
MyClass(int x, int y) : x(x), y(y) {}
// 튜플 프로토콜 구현
template<size_t I>
auto& get() {
if constexpr (I == 0) return x;
else if constexpr (I == 1) return y;
}
template<size_t I>
const auto& get() const {
if constexpr (I == 0) return x;
else if constexpr (I == 1) return y;
}
};
// 특수화
namespace std {
template<>
struct tuple_size<MyClass> : integral_constant<size_t, 2> {};
template<size_t I>
struct tuple_element<I, MyClass> {
using type = int;
};
}
int main() {
MyClass obj{10, 20};
auto [a, b] = obj;
std::cout << a << ", " << b << std::endl; // 10, 20
return 0;
}
학생 성적을 학년별로 묶는 종합 예제
마지막으로 지금까지 배운 구조체 분해, 맵 순회, 함수 반환값 처리를 하나로 엮은 종합 예제입니다. groupByGrade는 학생 벡터를 순회하며 const auto& [name, score, grade]로 각 학생 정보를 분해한 뒤 성적 등급별 맵으로 재구성하고, printStatistics는 다시 그 맵을 const auto& [grade, students]로 순회하며 각 등급의 평균 점수를 계산합니다. 여러 겹으로 중첩된 구조적 바인딩(맵 순회 안에서 다시 벡터 순회, 그 안에서 다시 구조체 분해)이 코드 곳곳에서 반복되지만, 각 단계마다 의미 있는 이름이 붙어 있어 데이터가 어떻게 흘러가는지 전체 흐름을 따라가기가 어렵지 않습니다.
#include <vector>
#include <map>
#include <iostream>
#include <string>
#include <algorithm>
struct Student {
std::string name;
int score;
std::string grade;
};
std::map<std::string, std::vector<Student>> groupByGrade(const std::vector<Student>& students) {
std::map<std::string, std::vector<Student>> groups;
for (const auto& [name, score, grade] : students) {
groups[grade].push_back({name, score, grade});
}
return groups;
}
void printStatistics(const std::map<std::string, std::vector<Student>>& groups) {
for (const auto& [grade, students] : groups) {
int total = 0;
for (const auto& [name, score, g] : students) {
total += score;
}
double average = static_cast<double>(total) / students.size();
std::cout << "등급 " << grade << ": "
<< students.size() << "명, 평균 "
<< average << "점" << std::endl;
}
}
int main() {
std::vector<Student> students = {
{"Alice", 95, "A"},
{"Bob", 85, "B"},
{"Charlie", 92, "A"},
{"David", 78, "C"},
{"Eve", 88, "B"}
};
auto groups = groupByGrade(students);
std::cout << "=== 학생 목록 ===" << std::endl;
for (const auto& [grade, studentList] : groups) {
std::cout << "등급 " << grade << ":" << std::endl;
for (const auto& [name, score, g] : studentList) {
std::cout << " " << name << ": " << score << "점" << std::endl;
}
}
std::cout << "\n=== 통계 ===" << std::endl;
printStatistics(groups);
return 0;
}
위 main 함수를 실행하면 등급별 학생 목록과 통계가 다음처럼 출력됩니다.
=== 학생 목록 ===
등급 A:
Alice: 95점
Charlie: 92점
등급 B:
Bob: 85점
Eve: 88점
등급 C:
David: 78점
=== 통계 ===
등급 A: 2명, 평균 93.5점
등급 B: 2명, 평균 86.5점
등급 C: 1명, 평균 78점
구조적 바인딩 요약
핵심 요약
- 구조적 바인딩: 튜플/배열/구조체 분해
- auto: 복사,
auto&: 참조,const auto&: const 참조 - 맵 순회:
for (const auto& [key, value] : map) - 함수 반환:
auto [a, b] = func() - 성능: 컴파일 타임, 오버헤드 없음
지원 타입
| 타입 | 예시 | 설명 |
|---|---|---|
| 배열 | int arr[3] | 고정 크기 배열 |
| 튜플 | std::tuple<int, double> | std::tuple, std::pair |
| 구조체 | struct Point { int x, y; } | 집합체 (aggregate) |
| 커스텀 | get<I>() 구현 | 튜플 프로토콜 |
실전 팁
사용 원칙:
- 맵 순회:
const auto& [key, value] - 함수 반환:
auto [a, b] = func() - 읽기만:
const auto& - 수정:
auto&
성능:
- 컴파일 타임 처리
- 런타임 오버헤드 없음
- 복사 방지 (참조 사용)
- 가독성 향상
주의사항:
- 요소 개수 일치
- 참조 수명 관리
- 타입 명시 불가
- 중첩 분해 불가 (C++17)
다음 단계
- C++ Tuple
- C++ 범위 기반 for문과 구조화된 바인딩 | 모던 C++ 반복문
- C++ auto와 decltype: 타입 추론 규칙, decltype(auto), AAA 스타일
같이 보면 좋은 글
- C++ 범위 기반 for문과 구조화된 바인딩 | 모던 C++ 반복문
- C++17 std::invoke와 std::apply: INVOKE 규칙과 튜플을 인자로 펼치는 원리
- C++ any
- C++ auto 타입 추론 | 복잡한 타입을 컴파일러에 맡기기
자주 묻는 질문 (FAQ)
Q. 구조화된 바인딩으로 받은 변수의 타입은 무엇인가요?
A. 겉보기엔 auto x처럼 보이지만, 실제로는 컴파일러가 만든 숨은 객체 하나가 있고 각 이름은 그 멤버를 가리키는 별칭입니다. 그래서 auto [a, b] = p;는 p를 통째로 복사한 숨은 객체의 멤버에 이름을 붙이는 것이고, decltype(a)는 멤버의 선언 타입이 됩니다. 이 때문에 C++17에서는 람다 캡처가 안 되고(C++20부터 허용), 개별 이름에 static이나 타입 지정을 붙일 수 없습니다.
Q. 일부 요소만 필요하면 어떻게 하나요?
A. 요소 개수는 반드시 맞춰야 하므로 안 쓰는 자리에도 이름을 붙이고, 컴파일러 경고를 막으려면 [[maybe_unused]]를 선언 앞에 붙입니다([[maybe_unused]] auto [it, inserted] = m.insert(...);). C++26에서는 _를 여러 번 쓰는 플레이스홀더가 허용됩니다.