C++20 지정 초기화(Designated Initializers): 필드 이름으로 구조체 초기화하기와 순서 규칙
이 글의 핵심
C++20 지정 초기화의 문법과 선언 순서를 지켜야 하는 규칙, 중첩 구조체와 부분 초기화, 설정 객체를 읽기 쉽게 만드는 실무 패턴을 정리합니다.
멤버 이름으로 초기화하면 달라지는 것
구조체 멤버가 많을 때 이런 코드를 본 적 있나요?
Config cfg = {"localhost", 8080, true, 30, 100};
문제: 8080이 port인가요, 아니면 timeout인가요? 30은 뭔가요?
C++20 Designated Initializers를 사용하면 멤버 이름을 명시해서 이 혼란을 없앨 수 있습니다.
필드 이름을 쓴 초기화
struct Config {
std::string host;
int port;
bool ssl;
int timeout;
int max_connections;
};
// ✅ C++20: 멤버 이름을 명시
Config cfg = {
.host = "localhost",
.port = 8080,
.ssl = true,
.timeout = 30,
.max_connections = 100
};
이제 각 값이 무엇인지 한눈에 알 수 있습니다!
위치 기반 초기화와 비교한 장점
| 기존 초기화 | Designated Initializers |
|---|---|
{"val1", val2, val3} | .name1 = val1, .name2 = val2 |
| 순서 외우기 | 이름으로 명확히 |
| 실수하기 쉬움 | 실수 방지 |
| 순서 바뀌면 조용한 버그 | 순서가 틀리면 컴파일 에러 (C++은 선언 순서 필수) |
.멤버 = 값 문법
기본 사용
struct Point {
int x;
int y;
};
int main() {
// C++20 Designated Initializers
Point p1 = {.x = 10, .y = 20};
// = 없이 직접 리스트 초기화도 가능
Point p2{.x = 30, .y = 40};
std::cout << p1.x << ", " << p1.y << '\n'; // 10, 20
}
위치 기반 집합체 초기화와 나란히 보기
struct Person {
std::string name;
int age;
std::string city;
};
int main() {
// 일반 초기화 (순서 중요)
Person p1 = {"Alice", 30, "Seoul"};
// Designated Initializers (멤버 이름 명시)
Person p2 = {
.name = "Bob",
.age = 25,
.city = "Busan"
};
}
지정 초기화는 새로운 초기화 방식이 아니라 집합 초기화(aggregate initialization)에 이름표를 붙인 것입니다. 그래서 중괄호 초기화의 규칙이 그대로 적용됩니다. 대표적인 것이 축소 변환(narrowing) 금지로, .age = 25.5처럼 double을 int 멤버에 넣으면 경고가 아니라 컴파일 에러가 납니다. 순서 초기화에서 {"Alice", 30, "Seoul"}의 두 값을 실수로 바꿔 쓰면 타입이 같을 때 아무 오류 없이 잘못된 값이 들어가지만, 지정 초기화는 이름이 틀리면 no member named 'agee' 같은 오류로, 순서가 틀리면 순서 오류로 잡아 줍니다. 즉 이 기능의 가치는 “읽기 쉬움”과 함께 실수를 컴파일 시점으로 끌어오는 것에 있습니다.
선언 순서를 지켜야 하는 C++20 규칙
지정자는 선언 순서대로
C++20에서는 Designated Initializers가 선언 순서를 따라야 합니다 (C와 다름).
struct Data {
int a;
int b;
int c;
};
int main() {
// ✅ 선언 순서대로
Data d1 = {.a = 1, .b = 2, .c = 3}; // OK
// ❌ 순서 위반
// Data d2 = {.b = 2, .a = 1, .c = 3}; // Error in C++20
// ✅ 건너뛰기는 OK
Data d3 = {.a = 1, .c = 3}; // b = 0 (기본값)
}
C와 C++20의 지정 초기화 차이
| 항목 | C | C++20 |
|---|---|---|
| 순서 | 자유 | 선언 순서 필수 |
| 혼용 | 가능 | 불가 |
배열 원소 지정 ([2] = x) | 가능 | 불가 |
// C에서는 OK
struct Data { int a, b, c; };
struct Data d = {.c = 3, .a = 1, .b = 2}; // C: OK, C++20: Error
int arr[5] = {[2] = 7, [4] = 9}; // C: OK, C++20: 문법 자체가 없음
C 헤더를 C++에서 그대로 include하는 프로젝트라면, 헤더 안의 초기화 코드는 두 언어의 공통 부분(선언 순서대로, 배열 지정자 없이)만 쓰는 게 안전합니다.
C++이 순서를 강제하는 이유는 C++ 객체의 초기화와 소멸 순서가 멤버 선언 순서로 고정되어 있기 때문입니다. C의 구조체 멤버는 단순한 값이라 어떤 순서로 채워도 결과가 같지만, C++에서는 멤버가 생성자와 소멸자를 가진 객체일 수 있고, 초기화 식이 부작용을 가질 수도 있습니다. {.b = f(), .a = g()}를 허용하면 코드를 읽는 사람은 f()가 먼저 호출된다고 기대하지만 실제 초기화는 a부터 일어나야 하므로, 표준은 아예 선언 순서대로만 쓰게 해서 보이는 순서와 실행 순서를 일치시켰습니다. 생성자의 멤버 초기화 리스트에서 순서가 다르면 -Wreorder 경고가 나는 것과 같은 맥락입니다. C에서 흔한 중첩 지정자 .address.city = "Seoul"도 C++에서는 안 되고, 앞의 예제처럼 .address = {.city = "Seoul"}로 중괄호를 한 단계 더 써야 합니다.
중첩 구조체 초기화
한 단계 중첩
struct Address {
std::string city;
std::string street;
int zipcode;
};
struct Employee {
std::string name;
int id;
Address address;
};
int main() {
Employee emp = {
.name = "Alice",
.id = 100,
.address = {
.city = "Seoul",
.street = "Gangnam",
.zipcode = 12345
}
};
std::cout << emp.address.city << '\n'; // Seoul
}
여러 단계 중첩
struct Location {
double lat;
double lon;
};
struct Address {
std::string street;
Location location;
};
struct Person {
std::string name;
Address address;
};
int main() {
Person p = {
.name = "Bob",
.address = {
.street = "Main St",
.location = {
.lat = 37.5,
.lon = 127.0
}
}
};
}
중첩된 멤버를 부분만 지정하면 생략된 안쪽 멤버도 같은 규칙으로 채워집니다. 예를 들어 .address = {.street = "Main St"}만 쓰면 location은 {}로 초기화되어 lat, lon이 모두 0.0이 됩니다. 좌표에서 (0, 0)은 실제로 존재하는 위치(대서양의 한 지점)라서, “값이 없음”과 “0”을 구분해야 하는 데이터라면 std::optional<Location>처럼 부재를 표현할 수 있는 타입을 쓰는 편이 안전합니다. 중첩이 깊어질수록 닫는 중괄호를 맞추기 어려워지므로, 한 줄에 한 멤버를 쓰고 clang-format으로 들여쓰기를 맞추면 리뷰가 훨씬 쉬워집니다.
기본 멤버 초기화와 부분 초기화
기본값이 있는 멤버 건너뛰기
struct Settings {
int width = 800;
int height = 600;
bool fullscreen = false;
int fps = 60;
};
int main() {
// 일부만 지정, 나머지는 기본값
Settings s1 = {
.width = 1920,
.height = 1080
};
// fullscreen = false, fps = 60
Settings s2 = {
.fullscreen = true
};
// width = 800, height = 600, fps = 60
}
빈 중괄호 초기화
struct Point {
int x;
int y;
};
int main() {
Point p = {}; // x = 0, y = 0
}
Point p = {};와 Point p;의 차이는 실무에서 자주 문제가 됩니다. 중괄호를 쓰면 값 초기화가 일어나 모든 멤버가 0이 되지만, 지역 변수로 Point p;만 쓰면 기본 멤버 초기화자가 없는 int 멤버는 초기화되지 않은 쓰레기 값을 가집니다. 기본 멤버 초기화자를 모든 멤버에 붙여 두면 두 경우 모두 안전해지고, 그 상태에서도 구조체는 여전히 aggregate라서 지정 초기화를 쓸 수 있습니다(C++14부터 기본 멤버 초기화자가 있어도 aggregate로 인정).
컴파일 에러로 알 수 있는 세 가지 제약
designator order … does not match
증상: error: designator order for field does not match declaration order.
struct Data {
int a;
int b;
int c;
};
int main() {
// ❌ 순서 위반
// Data d = {.b = 2, .a = 1}; // Error
// ✅ 선언 순서
Data d = {.a = 1, .b = 2}; // OK
}
지정자와 위치 초기화를 섞음
증상: error: cannot mix designated and non-designated initializers.
struct Point {
int x;
int y;
int z;
};
int main() {
// ❌ 혼용 불가
// Point p = {10, .y = 20, .z = 30}; // Error
// ✅ Designated만
Point p1 = {.x = 10, .y = 20, .z = 30}; // OK
// ✅ 일반만
Point p2 = {10, 20, 30}; // OK
}
생성자가 있는 비집합체 타입에 사용함
증상: error: designated initializers cannot be used with a non-aggregate type.
원인: 생성자가 있거나, private 멤버가 있는 클래스는 Aggregate가 아닙니다.
// ❌ 생성자 있음
class MyClass {
public:
MyClass(int x) : value(x) {}
int value;
};
// MyClass obj = {.value = 10}; // Error
// ✅ Aggregate (생성자 없음)
struct MyStruct {
int value;
};
MyStruct obj = {.value = 10}; // OK
C++20에서 이 규칙이 조금 더 엄격해졌다는 점도 알아 두세요. C++17까지는 MyStruct() = default;처럼 생성자를 선언만 하고 = default나 = delete로 두면 여전히 aggregate였지만, C++20부터는 사용자가 선언한 생성자가 하나라도 있으면 aggregate가 아닙니다. 그래서 C++17에서 컴파일되던 = default 생성자가 있는 구조체의 중괄호 초기화가 C++20으로 올리면서 깨지는 경우가 있습니다. 해결책은 대개 그 생성자 선언을 지우는 것입니다. 공용체(union)에도 지정 초기화를 쓸 수 있지만, 멤버를 정확히 하나만 지정할 수 있어서 {.i = 1}처럼 어떤 멤버를 활성화할지 명시하는 용도가 됩니다.
설정 구조체·빌더 대체·테스트 데이터
옵션이 많은 설정 구조체
struct ServerConfig {
std::string host = "0.0.0.0";
int port = 8080;
bool ssl = false;
int timeout_ms = 30000;
int max_connections = 1000;
std::string log_level = "info";
};
ServerConfig load_config() {
// 기본값 + 일부 오버라이드
return ServerConfig{
.host = "localhost",
.port = 9000,
.ssl = true
// 나머지는 기본값
};
}
빌더 패턴 대신 쓰기
// Before: 빌더 패턴
class ConfigBuilder {
public:
ConfigBuilder& setHost(std::string h) { host = h; return *this; }
ConfigBuilder& setPort(int p) { port = p; return *this; }
Config build() { return Config{host, port, ...}; }
private:
std::string host;
int port;
};
// After: Designated Initializers (더 간단)
struct Config {
std::string host = "localhost";
int port = 8080;
bool ssl = false;
};
Config cfg = {
.host = "example.com",
.port = 443,
.ssl = true
};
테스트 픽스처 데이터
struct TestCase {
std::string name;
int input;
int expected;
};
std::vector<TestCase> tests = {
{.name = "zero", .input = 0, .expected = 0},
{.name = "positive", .input = 5, .expected = 25},
{.name = "negative", .input = -3, .expected = 9}
};
세 패턴에 공통으로 쓸 수 있는 기법이 이름 있는 인자(named arguments) 흉내입니다. C++에는 Python의 키워드 인자가 없지만, 옵션을 구조체로 받는 함수를 만들면 호출 지점에서 connect({.host = "db", .timeout_ms = 500})처럼 쓸 수 있습니다. 매개변수를 추가할 때 기존 호출을 전혀 건드리지 않아도 되고(새 멤버에 기본값을 주면 됨), bool 인자가 여러 개 나란히 있을 때 f(true, false, true)처럼 읽을 수 없는 호출이 사라집니다. 다만 함수 인자로 넘길 때는 타입이 매개변수 선언에서 추론되므로 오버로드된 함수가 여러 옵션 구조체를 받으면 모호성 오류가 날 수 있어, 이 방식은 옵션 구조체가 하나인 함수에 쓰는 편이 좋습니다.
빌더 패턴과 비교하면, 지정 초기화는 검증 로직을 넣을 곳이 없다는 것이 가장 큰 차이입니다. 빌더의 build()는 “포트가 0이면 예외”처럼 조합 규칙을 검사할 수 있지만, 지정 초기화는 값을 채울 뿐입니다. 제가 설정 구조체에 이 방식을 쓸 때는 구조체는 aggregate로 두고 validate(const ServerConfig&)를 별도로 만들어 서버 시작 시 한 번 호출하는 식으로 역할을 나눕니다.
HTTP 요청 설정 구조체 만들기
#include <string>
#include <map>
#include <chrono>
#include <iostream>
struct HttpHeaders {
std::string content_type = "application/json";
std::string authorization;
std::string user_agent = "MyApp/1.0";
};
struct HttpRequest {
std::string method = "GET";
std::string url;
HttpHeaders headers;
std::string body;
std::chrono::seconds timeout = std::chrono::seconds(30);
bool follow_redirects = true;
int max_redirects = 5;
};
void send_request(const HttpRequest& req) {
std::cout << req.method << " " << req.url << '\n';
std::cout << "Content-Type: " << req.headers.content_type << '\n';
std::cout << "Timeout: " << req.timeout.count() << "s\n";
}
int main() {
// GET 요청 (기본값 활용)
HttpRequest get_req = {
.url = "https://api.example.com/users"
};
send_request(get_req);
// POST 요청 (일부 오버라이드)
HttpRequest post_req = {
.method = "POST",
.url = "https://api.example.com/users",
.headers = {
.content_type = "application/json",
.authorization = "Bearer token123"
},
.body = R"({"name":"Alice","age":30})",
.timeout = std::chrono::seconds(60)
};
send_request(post_req);
}
post_req의 .headers에서는 content_type과 authorization만 지정했으므로 user_agent는 기본값 "MyApp/1.0"을 그대로 가집니다. 이처럼 중첩 구조체마다 기본값을 두면 “대부분은 기본값, 몇 개만 바꾼다”는 설정 객체의 흔한 요구를 코드 한 덩어리로 표현할 수 있습니다. follow_redirects, max_redirects를 생략한 것도 같은 원리입니다. 반대로 url처럼 기본값이 의미 없는 필수 값은 빈 문자열이 되어도 컴파일러가 알려 주지 않으므로, send_request 안에서 비어 있는지 검사해야 합니다. std::chrono::seconds 같은 강한 타입을 쓴 것은 지정 초기화와 잘 어울리는 선택입니다. .timeout = 60처럼 단위 없는 정수를 넣으면 컴파일 에러가 나서, 초와 밀리초를 헷갈리는 버그를 막아 줍니다.
언제 쓰고, 무엇을 조심할까
같은 구조체를 초기화하는 방법은 여러 가지이고, 역할이 조금씩 다릅니다.
| 방식 | 예 | 특징 |
|---|---|---|
| 순서 집합 초기화 | Point p{1, 2, 3}; | 짧지만 선언 순서에 의존 |
| 지정 초기화 (C++20) | Point p{.x = 1, .y = 2, .z = 3}; | 멤버 이름이 보임, 순서는 선언 순서 |
| 생성자 호출 | Point p(1, 2, 3); | 불변식 검증 가능, 대신 aggregate가 아님 |
| 기본 멤버 초기화자 | int z = 0; | 생략한 멤버의 값 |
필드가 서너 개 이상이거나 같은 타입이 나란히 있는 설정 구조체(int width, height;, bool verbose, dry_run;)에서 효과가 가장 큽니다. {1920, 1080}은 어느 쪽이 폭인지 선언을 봐야 알지만 {.width = 1920, .height = 1080}은 호출 지점에서 바로 읽힙니다. 반대로 Point{1, 2}처럼 순서가 관례로 굳은 작은 타입에서는 오히려 장황해집니다.
실행 비용은 없습니다. 지정 초기화는 소스 수준의 표기일 뿐이라, 최적화 빌드에서는 같은 값을 순서 초기화로 쓴 것과 같은 코드가 나옵니다. 성능 때문에 피할 이유는 없습니다.
실제로 문제가 되는 건 설계를 바꿀 때입니다.
- 생성자를 추가하는 순간 모든 호출 지점이 깨집니다. 설정 구조체에 값 검증을 넣으려고 생성자를 추가하면 aggregate가 아니게 되어
.field =초기화가 전부 컴파일 에러가 납니다. 검증이 필요하면 생성자 대신validate(const Config&)같은 별도 함수를 두거나, 처음부터 생성자·빌더 방식으로 가는 편이 낫습니다. - 멤버 순서를 바꾸면 호출 지점도 바꿔야 합니다. 지정자가 선언 순서를 따라야 하므로, 구조체 멤버 순서를 재정렬(패딩 줄이기 등)하면 해당 구조체를 지정 초기화하는 곳이 모두 에러가 납니다. 조용히 값이 뒤바뀌는 순서 초기화보다는 낫지만, 공개 헤더의 구조체라면 사용자 코드까지 깨진다는 점을 알고 있어야 합니다.
- 기반 클래스가 있으면 기반 멤버는 지정할 수 없습니다. C++20에서 기반 클래스가 있는 구조체도 aggregate일 수 있지만, 지정자는 직접 멤버에만 붙습니다. 공통 필드를 상속으로 빼기보다 멤버 구조체로 두면(
.common = {...}) 지정 초기화를 계속 쓸 수 있습니다.
지정 초기화 핵심 정리
| 개념 | 설명 |
|---|---|
| Designated Initializers | 멤버 이름으로 초기화 |
| 문법 | {.member = value} |
| 순서 | 선언 순서 유지 필수 (C++20) |
| 혼용 | 일반 초기화와 혼용 불가 |
| 용도 | 설정, 테스트 데이터, API 파라미터 |
Designated Initializers는 코드 가독성을 높이며, 구조체 초기화를 명확하게 만듭니다.
FAQ
Q1: C와 C++20 차이는?
A: 멤버를 건너뛰는 것은 두 언어 모두 가능합니다. 차이는 C는 순서가 자유롭고 배열 지정자·중첩 지정자·위치 초기화와의 혼용을 허용하지만, C++20은 선언 순서를 따라야 하고 이런 확장 형태를 모두 허용하지 않는다는 점입니다.
Q2: 순서를 지키지 않으면?
A: 컴파일 에러가 납니다. 멤버 선언 순서대로 지정해야 합니다.
Q3: 일부 멤버만 지정하면?
A: 나머지 멤버는 기본값 또는 0으로 초기화됩니다. 기본값을 제공하면 명시하지 않은 멤버는 기본값을 사용합니다.
Q4: 클래스에서도 사용 가능한가요?
A: Aggregate 타입에서만 사용 가능합니다. 생성자가 있거나 private 멤버가 있는 클래스는 불가능합니다.
Q5: 배열은?
A: C++20에는 배열 원소 지정자([2] = 7)가 없습니다. 배열은 일반 초기화({1, 2, 3})를 쓰고, 원소가 구조체라면 각 원소 안에서 {.x = 0, .y = 0}처럼 멤버 지정자를 쓸 수 있습니다.
Q6: C++17 이전 코드베이스에서도 쓸 수 있나요?
A: 표준으로는 C++20부터입니다. GCC와 Clang은 오래전부터 확장으로 일부 형태를 받아 주었기 때문에 -std=c++17에서도 경고(-Wpedantic을 켜면 C++20에서만 쓸 수 있는 기능이라는 경고)와 함께 컴파일되는 경우가 있지만, MSVC는 C++20 모드(/std:c++20)에서만 허용합니다. 여러 컴파일러를 지원하는 코드라면 C++20을 요구 사항으로 명시한 뒤 쓰는 것이 안전합니다.
Designated Initializers로 구조체 초기화를 명확하게 할 수 있습니다. 다음으로 Aggregate Initialization을 읽어보면 좋습니다.
같이 보면 좋은 글
- C++11 중괄호 초기화: narrowing 방지, Most Vexing Parse, vector
{10}과 (10)의 차이 - C++ Aggregate Initialization
- C++ struct vs class
- C++20 Concepts