C++ 집합체 초기화: 집합체 조건, 중괄호 초기화, C++20 지정 초기화 순서 규칙
이 글의 핵심
생성자를 하나 추가하거나 상속을 넣는 순간 중괄호 초기화가 컴파일되지 않는 이유는 집합체 조건이 깨졌기 때문입니다. = default·= delete 생성자에 대한 C++20의 규칙 변화, 멤버 순서와 지정 초기화 순서가 어긋날 때의 오류, 값·리스트 초기화와의 차이를 짚어 구조체 API를 안전하게 설계할 수 있게 합니다.
집합체 초기화란?
집합체(aggregate)는 사용자가 선언한 생성자, 비공개 데이터 멤버, 가상 함수 등이 없는 클래스 타입이나 배열을 말하며, 중괄호 {} 안에 멤버 값을 선언 순서대로 나열해 한 번에 초기화할 수 있습니다. 리스트 초기화, 값 초기화, 지정 초기화와 함께 보면 초기화 규칙 전체를 정리하기 좋습니다.
struct Point {
int x;
int y;
};
Point p = {10, 20}; // 집합체 초기화
같은 일을 생성자로 하면 멤버 수만큼 매개변수와 초기화 목록을 적어야 합니다. 집합체는 그런 보일러플레이트 없이 구조체 정의만으로 바로 값을 채울 수 있고, 멤버를 추가해도 생성자를 고칠 필요가 없습니다.
// 생성자 방식
struct PointWithCtor {
int x, y;
PointWithCtor(int x_, int y_) : x(x_), y(y_) {}
};
PointWithCtor a(10, 20);
// 집합체 방식
struct Point2 {
int x, y;
};
Point2 b = {10, 20};
집합체 조건
C++20 기준으로 다음 조건을 모두 만족하는 배열 또는 클래스 타입(struct, class, union)이 집합체입니다.
- 생성자 조건은 표준 버전마다 다릅니다. C++17까지는 사용자 제공(user-provided) 생성자가 없으면 됐고, 첫 선언에서
= default나= delete로 쓴 생성자는 사용자 제공이 아니어서 허용됐습니다. C++20부터는 사용자 선언(user-declared) 생성자가 하나도 없어야 하므로,= default나= delete도 선언하는 순간 집합체가 아닙니다. 상속받은 생성자(using Base::Base;)도 C++17부터 금지됩니다. - 비공개(private)나 보호(protected) 비정적 데이터 멤버가 없어야 합니다.
- 가상 함수가 없어야 합니다.
- 가상·비공개·보호 기반 클래스가 없어야 합니다. 기반 클래스 자체는 C++17부터 public 비가상 상속이면 허용됩니다.
// 집합체
struct Aggregate1 {
int x;
double y;
};
struct Aggregate2 : Aggregate1 { // C++17부터: public 상속 허용
int z;
};
// 비집합체
struct NonAggregate1 {
int x;
NonAggregate1() = default; // C++20부터 비집합체 (C++17까지는 집합체)
};
struct NonAggregate2 {
int x;
private:
int y; // 비공개 데이터 멤버
};
struct NonAggregate3 {
virtual void func() {} // 가상 함수
};
어떤 타입이 집합체인지는 C++17의 std::is_aggregate로 컴파일 타임에 확인할 수 있습니다.
#include <type_traits>
struct Point { int x, y; };
static_assert(std::is_aggregate_v<Point>);
template<typename T>
void resetIfAggregate(T& obj) {
if constexpr (std::is_aggregate_v<T>) {
obj = {}; // 집합체 초기화한 임시 객체를 대입
}
}
실전 예시
구조체 초기화
#include <string>
struct Person {
std::string name;
int age;
double height;
};
Person p1 = {"Alice", 30, 165.5};
Person p2{"Bob", 25, 175.0};
// 뒤쪽 멤버 생략
Person p3 = {"Charlie", 35}; // height는 0.0
Person p4 = {"David"}; // age는 0, height는 0.0
위치 기반 집합체 초기화의 가장 큰 약점은 멤버 순서에 의존한다는 점입니다. 나중에 누군가 Person의 name과 age 사이에 int id;를 끼워 넣으면, {"Alice", 30, 165.5}는 id = 30, age = 165.5로 해석이 바뀝니다. 이 예에서는 165.5를 int로 줄이는 축소 변환이 중괄호 초기화에서 금지되어 있어 운 좋게 컴파일 에러가 나지만, 새 멤버와 기존 멤버의 타입이 같다면 값이 한 칸씩 밀린 채 조용히 컴파일됩니다. 멤버가 서너 개를 넘는 구조체라면 C++20 지정 초기화를 쓰고, GCC·Clang의 -Wmissing-field-initializers(-Wextra에 포함) 경고로 일부만 초기화한 곳을 찾아 두는 편이 안전합니다.
배열 초기화
int arr1[5] = {1, 2, 3, 4, 5};
int arr2[5] = {1, 2}; // 나머지는 0
int arr3[] = {1, 2, 3}; // 크기 3으로 추론
// int arr4[2] = {1, 2, 3}; // 에러: 초기값이 너무 많음
int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
중첩 구조체
#include <iostream>
#include <string>
struct Address {
std::string city;
int zipCode;
};
struct Person {
std::string name;
Address address;
};
Person p = {
"Alice",
{"Seoul", 12345}
};
std::cout << p.name << '\n';
std::cout << p.address.city << '\n';
안쪽 멤버가 다시 집합체이면 중괄호를 한 겹 더 씁니다. 안쪽 중괄호를 생략하는 brace elision도 허용되지만({"Alice", "Seoul", 12345}), 어떤 값이 어느 멤버로 가는지 읽기 어려워지므로 명시하는 편이 낫습니다.
생략된 멤버와 기본값
struct Data {
int x = 10;
int y = 20;
int z = 30;
};
Data d1 = {}; // {10, 20, 30}
Data d2 = {100}; // {100, 20, 30}
Data d3 = {100, 200}; // {100, 200, 30}
집합체 초기화에서 초기값을 주지 않은 멤버는, 기본 멤버 초기화식(= 10 같은)이 있으면 그 값으로, 없으면 빈 초기화 목록 {}으로 초기화됩니다. int나 포인터, bool 같은 스칼라는 0, nullptr, false가 되고, std::string 같은 클래스 타입은 기본 생성자가 호출됩니다. 기본 멤버 초기화식이 있어도 집합체 조건은 깨지지 않습니다(C++14부터).
struct Config {
int width = 800;
int height = 600;
bool fullscreen; // 기본값 없음
};
Config c1 = {}; // {800, 600, false}
Config c2 = {1920}; // {1920, 600, false}
Config c3 = {1920, 1080, true}; // {1920, 1080, true}
Config c4; // 기본 초기화: 지역 변수라면 fullscreen은 미정 값
마지막 줄처럼 중괄호 없이 선언하면 집합체 초기화가 일어나지 않습니다. 기본 멤버 초기화식이 있는 width, height는 값이 정해지지만, fullscreen은 지역 변수일 때 미정 값이고 읽으면 미정의 동작입니다.
이 규칙을 이용하면 필수 값만 앞에 두고 나머지는 기본값을 갖는 옵션 구조체를 만들 수 있습니다.
struct HttpRequest {
std::string url;
std::string method = "GET";
int timeout = 30;
bool followRedirects = true;
};
HttpRequest req1 = {"https://example.com"};
// url만 지정, 나머지는 GET / 30 / true
HttpRequest req2 = {"https://example.com", "POST", 60};
// followRedirects만 기본값
다른 초기화 방식과의 비교
| 문법 | 집합체 S에서 일어나는 일 | 생성자가 있는 클래스에서 |
|---|---|---|
S a; | 기본 초기화. 지역 변수라면 기본값 없는 스칼라 멤버는 미정 값 | 기본 생성자 호출 |
S a{}; / S a = {}; | 빈 목록으로 집합체 초기화. 모든 멤버가 기본값 또는 0 | 기본 생성자 호출(값 초기화) |
S a = {1, 2}; | 집합체 초기화(복사 리스트 초기화 형태) | explicit이 아닌 생성자 호출 |
S a{1, 2}; | 집합체 초기화(직접 리스트 초기화 형태), 축소 변환 금지 | 생성자 호출, 축소 변환 금지 |
S a(1, 2); | C++20부터 괄호 집합체 초기화, 축소 변환 허용 | 생성자 호출 |
생성자가 있는 클래스에서 S a{...}는 생성자 호출로 해석되므로, 엄밀히 말해 “집합체 초기화”라는 용어는 집합체 타입에만 해당합니다.
C++20부터는 괄호로도 집합체를 초기화할 수 있습니다(P0960). Point p(10, 20);이 생성자 없이도 컴파일되며, 덕분에 std::make_unique<Point>(10, 20)이나 vector<Point>::emplace_back(10, 20)처럼 내부에서 괄호로 객체를 만드는 표준 함수에 집합체를 그대로 넘길 수 있게 되었습니다. C++17까지는 이 코드가 no matching function for call to 'Point::Point(int, int)' 에러를 냈습니다. 다만 괄호 형태는 중괄호와 달리 축소 변환을 허용하고 brace elision도 하지 않으므로, 직접 쓰는 코드에서는 여전히 중괄호가 더 안전합니다.
C++20 지정 초기화(designated initializers)
C++20에서는 C99와 비슷한 .멤버 = 값 문법을 집합체 초기화에 쓸 수 있습니다. 지정자는 멤버 선언 순서와 같은 순서로만 쓸 수 있고, 건너뛴 멤버는 앞에서 설명한 대로 기본 멤버 초기화식이 있으면 그 값, 없으면 0 같은 빈 초기화 값을 갖습니다.
struct Point3 { int x; int y; int z = 0; };
Point3 p = {.x = 1, .y = 2}; // z는 기본 멤버 초기화식 0
// Point3 q = {.y = 2, .x = 1}; // 에러: 선언 순서와 다름
필드 이름이 초기화 코드에 드러나므로, 같은 타입의 멤버가 여러 개인 큰 구조체에서 값의 순서를 잘못 적는 실수를 막아 줍니다. 위에서 본 “멤버 추가로 값이 한 칸씩 밀리는” 문제도 지정 초기화를 쓰면 생기지 않습니다.
C 코드와의 차이는 주의해야 합니다. C99의 지정 초기화와 달리 C++20은 순서를 바꿀 수 없고, {.x = 1, 2}처럼 지정자와 위치 기반 초기값을 섞을 수 없으며, .inner.a = 1 같은 중첩 지정자와 [3] = 5 같은 배열 지정자도 지원하지 않습니다. C 헤더의 초기화 코드를 C++로 옮길 때 이 부분에서 에러가 많이 납니다. 중첩 구조체는 .inner = {.a = 1}처럼 중괄호를 한 겹 더 써서 표현합니다.
자주 발생하는 문제
지정 초기화 순서
struct Point { int x; int y; };
// Point p = {.y = 20, .x = 10}; // 에러
Point p = {.x = 10, .y = 20}; // 선언 순서 유지
리팩터링으로 멤버 순서를 바꾸면 이 구조체를 지정 초기화하는 모든 코드도 함께 고쳐야 합니다.
생성자가 있는 클래스
struct WithCtor {
int x;
WithCtor(int v) : x(v) {}
};
WithCtor a = {10}; // 컴파일됨: 집합체 초기화가 아니라 WithCtor(int) 호출
WithCtor b(10);
struct WithExplicitCtor {
int x;
explicit WithExplicitCtor(int v) : x(v) {}
};
// WithExplicitCtor c = {10}; // 에러: 복사 리스트 초기화는 explicit 생성자를 쓸 수 없음
WithExplicitCtor d{10}; // 직접 리스트 초기화는 가능
생성자를 추가해도 = {10}이 계속 컴파일되는 경우가 많아서, 집합체가 아니게 되었다는 사실을 놓치기 쉽습니다. 차이는 멤버가 여러 개일 때 드러납니다. 생성자가 받는 인자 수와 맞지 않는 {1, 2, 3}은 더 이상 컴파일되지 않습니다.
상속
struct Base {
int x;
};
struct Derived : Base {
int y;
};
Derived d = {{10}, 20}; // Base{10}, y = 20
struct Hidden : private Base {
int y;
};
// Hidden h = {{10}, 20}; // 에러: private 상속이면 집합체가 아님
static_assert(!std::is_aggregate_v<Hidden>);
기반 클래스 부분은 중괄호 한 겹을 더 씌워 데이터 멤버보다 먼저 초기화합니다({{10}, 20}). 기반 클래스가 여러 개면 선언 순서대로 각각 한 겹씩 들어갑니다. 바깥 중괄호를 생략하는 brace elision도 허용되지만(Derived d = {10, 20};), 기반 클래스가 여러 개가 되는 순간 읽기 어려워지므로 명시하는 편이 낫습니다.
= default / = delete 생성자와 C++20의 변화
struct Config {
Config() = delete; // "기본 생성은 막겠다"는 의도
int port;
};
Config c{}; // C++17: 컴파일됨, C++20: error: use of deleted function 'Config::Config()'
C++17까지는 = delete한 기본 생성자가 “사용자 제공”이 아니므로 Config가 여전히 집합체였고, Config c{};는 생성자를 부르지 않고 집합체 초기화로 통과했습니다. 기본 생성을 막으려고 쓴 코드가 아무 경고 없이 무시되는 셈입니다. 이 구멍 때문에 C++20은 조건을 “사용자 선언 생성자 없음”으로 바꿨고(P1008), 그 결과 반대 방향의 문제가 생겼습니다. Point() = default;처럼 생성자를 명시적으로 기본값으로 선언해 둔 구조체는 C++20으로 올리는 순간 집합체가 아니게 되어, 기존의 Point p{1, 2};가 “일치하는 생성자 없음” 에러로 깨집니다.
표준 버전을 올릴 때 자주 부딪히는 소스 호환성 문제이므로, 집합체로 쓸 구조체에는 생성자를 아예 선언하지 않는 것이 가장 안전합니다. 기본값이 필요하면 생성자 대신 기본 멤버 초기화식(int port = 8080;)을 씁니다.
std::string 멤버를 {0}으로 초기화
C에서 구조체를 0으로 채울 때 쓰던 S s = {0};을 그대로 옮기면, 첫 멤버가 std::string인 경우 0이 널 포인터 상수로 해석되어 std::string(const char*)에 널 포인터가 전달됩니다. 컴파일은 되지만 실행 시 미정의 동작입니다. C++에서는 S s{};가 모든 멤버를 각자의 규칙대로 비우는 올바른 방법이고, std::string처럼 트리비얼하지 않은 멤버가 있는 구조체를 memset으로 0 채우는 것은 객체 내부 포인터를 망가뜨리므로 쓰면 안 됩니다.
실무 패턴
설정 구조체
struct ServerConfig {
std::string host = "localhost";
int port = 8080;
int maxConnections = 100;
bool enableLogging = true;
};
ServerConfig cfg1 = {}; // 모두 기본값
ServerConfig cfg2 = {"0.0.0.0", 3000}; // 앞의 두 개만 변경
// C++20: 이름으로 필요한 것만 지정
ServerConfig cfg3 = {
.host = "192.168.1.1",
.port = 9000
};
여러 값 반환
struct Result {
bool success;
std::string message;
int errorCode;
};
Result processData(const std::string& data) {
if (data.empty()) {
return {false, "Empty data", 1};
}
return {true, "Success", 0};
}
반환 타입이 정해져 있으므로 return {...};만으로 집합체가 만들어집니다. std::tuple이나 std::pair보다 필드 이름이 의미를 설명해 주고, 호출하는 쪽에서는 구조화된 바인딩(auto [ok, msg, code] = processData(s);)으로 풀어 쓸 수 있습니다.
테스트 데이터
struct TestCase {
std::string input;
std::string expected;
bool shouldPass;
};
std::vector<TestCase> tests = {
{"hello", "HELLO", true},
{"world", "WORLD", true},
{"", "", false}
};
테이블 기반 테스트에서 각 행을 한 줄로 적을 수 있어, 케이스를 추가하는 비용이 거의 없습니다.
직접 실행한 집합체 판정과 초기화 결과
Apple clang 21.0.0(libc++), macOS arm64, -std=c++20에서 std::is_aggregate_v와 초기화 결과를 확인했습니다.
| 타입 | is_aggregate_v |
|---|---|
struct P{int x; int y;} | true |
기본 멤버 초기화자가 있는 struct Cfg{int port=80; std::string host="localhost"; bool tls=false;} | true |
| 가상 함수가 있는 구조체 | false |
private 멤버가 있는 구조체 | false |
H() = default;를 직접 선언한 구조체 | false (C++20에서는 사용자가 선언한 생성자가 있으면 집합체가 아님) |
기반 클래스가 있는 struct D : Base { int d; } | true (C++17부터) |
int[3], std::array<int,3> | true |
std::vector<int> | false |
| 초기화 | 결과 |
|---|---|
P p{1}; | y는 0 (생략된 멤버는 값 초기화) |
Cfg c{.port=8080, .tls=true}; | host는 기본값 localhost, port 8080, tls true |
N n{1, {2,3}, {4,5}}; (N은 중첩 구조체와 int arr[3]를 가짐) | in.q는 3, arr[2]는 0 |
D d{{1}, 2}; | b 1, d 2 |
P q(3, 4); (소괄호, C++20) | x 3, y 4 |
컴파일러가 막거나 경고한 것도 확인했습니다.
P p{1.5, 2};는 오류type 'double' cannot be narrowed to 'int' in initializer list [-Wc++11-narrowing]로 컴파일이 되지 않았습니다. 중괄호 초기화는 좁히는 변환을 허용하지 않습니다.Cfg c{.timeout=5, .port=80};처럼 선언 순서와 다르게 지정하면 clang 21에서는ISO C++ requires field designators to be specified in declaration order경고(-Wreorder-init-list)가 나왔습니다. 다른 컴파일러에서는 오류일 수 있으니 순서를 맞춰 쓰는 편이 안전합니다.