생성자 없는 초기화 필요성
(consteval.ca)- C++의 객체 초기화는
T t;,T t{};,T t{...}처럼 비슷한 문법도 default/value/list/aggregate initialization으로 갈라져 실제 멤버 상태가 달라질 수 있음 - 기본 생성자를 어디서
= default하느냐에 따라 zero-initialization 여부가 바뀌며, 클래스 밖에서 정의하면 멤버가 초기화되지 않은 값으로 남을 수 있음 const int멤버가 있어 기본 생성자가 삭제될 것처럼 보이는 타입도 aggregate라면A a{};가 aggregate initialization을 거쳐0으로 초기화될 수 있음- C++20의 괄호 기반 aggregate initialization은 중괄호 초기화보다 더 허용적이어서 narrowing conversion과 dangling reference 같은 차이를 만들 수 있음
- 실무에서는 암묵적 생성자와 초기화 규칙의 조합에 기대기보다 직접 생성자로 초기화 의도를 드러내는 편이 예외를 피하기 쉬움
C++ 초기화 문법이 갈라지는 지점
T t;는 default-initialization을 수행함T가 클래스 타입이고 기본 생성자가 있으면 그 생성자를 실행함T가 배열이면 각 원소를 default-initialize함- 그 외에는 아무것도 하지 않음
T t{};는 value-initialization처럼 보이지만, 중괄호 문법 때문에 먼저 list-initialization으로 처리됨- 단순한 예시에서도 차이가 바로 드러남
int x{};는 value-initialized되어0으로 초기화됨int y;는 default-initialized되어 값이 초기화되지 않음- 기본 생성자를 가진
Pair p;와Pair q{};는 생성자를 호출함 - 멤버만 있는
SimplePair r;는 default-initialized되어 멤버가 초기화되지 않을 수 있음
기본 생성자의 선언 위치가 바꾸는 결과
- 사용자가 생성자를 선언하지 않으면 컴파일러가 implicitly-declared 기본 생성자를 선언함
T() = default;를 클래스 안 첫 선언에서 쓰면 표준상 암시적 선언 생성자와 거의 비슷하게 취급됨- 암시적으로 선언되거나 명시적으로 defaulted된 기본 생성자가 삭제되지 않으면 컴파일러는 implicitly-defined default constructor를 제공함
- 구현상 빈 본문과 빈 멤버 초기화 목록을 가진
T() {}와 동등해야 함
- 구현상 빈 본문과 빈 멤버 초기화 목록을 가진
- 다음 코드에서는
t.x가0이 됨
struct T {
int x;
T() = default;
};
T t{};
std::cout << t.x << std::endl;
t가 value-initialized되고,T의 기본 생성자가 user-provided가 아니어서 먼저 zero-initialization이 일어난 뒤 기본 생성자가 호출되기 때문임
클래스 밖 = default와 user-provided 생성자
- 같은
= default라도 클래스 밖에서 정의하면 user-provided 생성자가 됨
struct T {
int x;
T();
};
T::T() = default;
T t{};
std::cout << t.x << std::endl;
- 이때
T::T() = default;는 첫 선언에서 defaulted된 것이 아니라, 클래스 밖에서 defaulted로 정의된 생성자임 - value-initialization 규칙은 user-provided 또는 deleted 기본 생성자가 있으면 default-initialization을 수행함
- 따라서 위 예시는 zero-initialization 없이 기본 생성자만 실행되고, 그 생성자가 아무것도 하지 않으므로
t.x는 쓰레기값이 됨
기본 생성자가 삭제되는 경우
- 컴파일러가 합리적인 기본 생성자를 만들 수 없으면 암시적으로 선언된 기본 생성자는 deleted로 정의될 수 있음
- 대표적인 조건은 다음과 같음
- 비정적 참조 멤버가 있는 경우
- default-construct 또는 destruct가 적절하지 않은 비정적 멤버나 비추상 기반 클래스가 있는 경우
- 기본 멤버 초기화자가 없는
const비정적 멤버가 있고, 그 멤버가 const-default-constructible이 아닌 경우
- 사용자가 생성자를 직접 제공하면 컴파일러는 암시적 기본 생성자를 정의하려 하지 않으며, deleted 생성자도 만들지 않음
- 이런 조건이 있어도 C++ 초기화 규칙은 전반적으로 매우 허용적으로 동작함
aggregate initialization이 끼어드는 순간
struct A { const int x; }; A a{};는 기본 생성자가 삭제되어 컴파일되지 않을 것처럼 보이지만, 실제로는 컴파일되고a.x는0이 됨- 핵심은
A가 aggregate라는 점임 - aggregate는 배열이거나, 다음 조건을 만족하는 클래스임
- user-declared 또는 inherited 생성자가 없음
- private/protected 직접 비정적 데이터 멤버가 없음
- private/protected 직접 기반 클래스가 없음
- virtual 함수나 virtual 기반 클래스가 없음
- aggregate에 list-initialization이 수행되면 특정 예외를 제외하고 aggregate initialization이 적용됨
- 초기화 목록의 각 원소가 aggregate의 각 원소를 순서대로 초기화함
- 목록 원소 수가 부족하면 남은 원소는 기본 멤버 초기화자가 있으면 그것으로 초기화됨
- 기본 멤버 초기화자가 없고 참조가 아니면 빈 초기화 목록으로 copy-initialized됨
- 따라서
A a{};는 생성자 호출이 아니라,a.x가 빈 목록으로 copy-list-initialized되고 value-initialization을 거쳐 zero-initialization되는 사례임
중괄호 초기화의 추가 규칙
T t{...}와T t = {...}같은 중괄호 기반 문법은 대체로 list-initialization임T t{...}는 direct-list-initializationT t = {...}는 copy-list-initialization
- aggregate가 아닌 클래스 타입에서 초기화 목록이 비어 있고 기본 생성자가 있으면 value-initialization이 수행됨
- 그 외에는 일반적인 overload resolution으로 생성자를 고려하며,
std::initializer_list생성자 오버로드가 우선권을 가짐 - 중괄호 초기화는 한 원소로 초기화할 때 narrowing conversion을 허용하지 않음
struct A {
const int x;
};
A b{4}; // 가능
A c{4.0f}; // narrowing conversion 오류
괄호 초기화는 더 permissive함
T t(a, b, c);같은 괄호 초기화는 direct-non-list-initialization을 호출하며, direct-list-initialization과 비슷하지만 다른 규칙을 가짐T t();는 객체 생성이 아니라 인자가 없고 반환 타입이T인 함수 선언으로 해석되는 most vexing parse 문제가 있음- C++20부터 aggregate에도 괄호 초기화를 사용할 수 있음
- 괄호 기반 aggregate initialization은 중괄호 기반 초기화와 다르게 동작함
- narrowing conversion을 허용함
- 남은 원소는 빈 목록 초기화가 아니라 직접 value-initialized됨
- 참조에 바인딩된 임시 객체의 수명을 연장하지 않음
struct T {
const int& r;
};
T t(42);
- 위 코드에서
t.r은 dangling reference가 되고, 읽으면 undefined behaviour임
std::initializer_list, 복사 생성자, elision
- 괄호 초기화는
std::initializer_list<T>생성자 오버로드가 있을 때도T의 복사 생성자를 호출할 수 있음 - 반대로 중괄호 초기화에서는
std::initializer_list생성자가 우선될 수 있음
struct T {
T(std::initializer_list<T>) {
std::cout << "list" << std::endl;
}
T(const T&) {
std::cout << "copy" << std::endl;
}
};
T t{}; // list
T s{t}; // list
T r(t); // copy
T q(T{}); // list, copy 없음
T q(T{});에서 복사가 일어나지 않는 것은 direct- 및 copy-initialization에서 initializer가T타입 prvalue이면 객체가 그 표현식으로 직접 초기화되는 규정 때문임- 이 동작은 보통 copy elision으로 알려져 있지만, 표준은 이를 명시적으로 elision이라고 부르지 않음
- list-initialization에서 같은 elision이 가능한지는 표준 설명이 부족하며, CWG issue 2311이 관련됨
- GCC와 Clang은 대부분의 경우 elision을 수행함
std::initializer_list<T>생성자 오버로드가 있으면 GCC는 elision 대신 그 생성자를 사용함
평가 순서와 실무적 결론
- 괄호 초기화 목록의 원소 평가 순서는 보장되지 않음
- 중괄호 초기화 목록은 원소를 왼쪽에서 오른쪽으로 엄격하게 평가함
- 정적 변수 초기화와 constant initialization 같은 특수 규칙도 있지만, 일반 객체 초기화만으로도 규칙은 충분히 복잡함
- 실무에서는 암묵적 기본 생성자와 초기화 규칙에 기대기보다 직접 생성자를 작성해 초기화 의도를 명시하는 편이 안전함
댓글과 토론
Hacker News 의견들
-
T를 값 초기화하면 결과가 0이 되는 설명이 맞았고, 처음엔 놓쳤지만 작성자가 실제로x를 값 초기화하고 있었음
전반적으로 C++ 초기화 규칙은 40년간 확장과 보수를 거치며 난해하고 비상식적인 구석이 생겼지만, 99.9% 정도는 기대한 대로 동작한다고 봄
큰 개선은 기본 초기화를 명시적으로 만들고, 그 외에는 항상 값 초기화하게 하는 것이라고 생각함. 큰 배열을 0으로 채우는 비용을 피하고 싶을 때만std::array = void;같은 문법으로 명시하는 편이 더 낫겠음- 인스턴스를 실제로 초기화하면 문제를 겪지 않으며, 생성자를 호출하고 싶다면 생성자를 정의하면 됨
너무 영리하게 굴다가 감당하기 어렵다고 불평하는 사례로 보임
- 인스턴스를 실제로 초기화하면 문제를 겪지 않으며, 생성자를 호출하고 싶다면 생성자를 정의하면 됨
-
전체 접근이 잘못됐음. 구조체에 const 참조를 넣지 말고, 꼭 필요하면
std::reference_wrapper를 쓰는 편이 맞음
답이 다소 퉁명스럽긴 하지만, 글의 결론이 완전히 틀렸다는 게 핵심임. 직접 생성자를 쓰지 말고 5/3/0 규칙을 따르며, const 참조를 들고 있어야 한다면 rvalue 임시 객체를 넘기는지 확인해야 함. 이건 그렇게 무서운 문제가 아님- 5/3/0 규칙은 소멸자, 이동 생성자, 복사 생성자에 관한 것임. 마지막 예제를 제외하면 글은 주로 표준 생성자라고 부를 만한 것의 특이한 동작을 다룸
- 같은 생각임. 글쓴이가 아주 기본적인 원칙과 지침 몇 가지를 일부러 무시하면서 불평거리를 찾아낸 느낌이 강함
기본 튜토리얼만 거친 사람이라도 이런 인위적인 시나리오는 그냥 피해 감 std::reference_wrapper는 한 번도 써본 적이 없고, 일해 본 여러 C++ 코드베이스에서도 본 적이 없음
깊고 복잡한 템플릿 코드 어딘가에서는 쓰이겠지만, 맞는 말일 수 있어도 내 경험상 상식적인 지식은 아님
-
I Have No Mouth, and I Must Scream (1967)원문은 여기서 볼 수 있음: https://talesofmytery.blogspot.com/2018/10/harlan-ellison-i-...- Harlan Ellison과 한 번 통화한 적이 있음. 17살 때 새벽 2시에 집으로 전화가 왔고, 30피트짜리 전화선을 끌고 방에 들어가 문을 닫던 시절이었음
Martin H Greenberg 가족이 우리 집에 머물고 있었고, 그는 Martin을 찾고 있었음. Sci-Fi를 좋아해서 그의 책도 몇 권 읽었는데, 이름을 듣고 난 뒤로는 대화가 거의 흐릿하게 기억남. 결국 Martin을 깨워 전화를 넘겼고, 그 뒤로 잠들기 어려웠음
- Harlan Ellison과 한 번 통화한 적이 있음. 17살 때 새벽 2시에 집으로 전화가 왔고, 30피트짜리 전화선을 끌고 방에 들어가 문을 닫던 시절이었음
-
unless가 굵게까지 표시된 부분에 빈정대는 댓글이 없어서 놀랐음
T t{v0};와T t{v0, v1};같은 멋진 문법이 있지만, 원소 1개짜리 초기화는 원소 2개 이상인 경우처럼 안정적으로 동작하지 않음
매개변수 팩으로 구조체를 만들기 쉬워지는 방향으로 가고, 이런 식으로 초기화 가능한 가변 길이 배열 비슷한 것도 지원하는 언어에서 이 차이는 이상함. 타입은 당연히 템플릿일 수도 있음
직접 생성자를 쓸 수도 있고, 원소 하나만 공급해서 튜플이나 배열을 초기화할 수도 있지만, 특수한 경우에는 엉뚱한 생성자가 호출될 수 있음. C++11 초기화 리스트가 막 나왔을 때 이걸 발견하고 미쳤다고 생각했음- 초기화 리스트는 별로 중요하지 않음. 몇몇 표준 컨테이너가 쓰는 것 말고는 거의 아무도 안 쓰니, 그 이상한 기능은 그냥 무시해도 됨
-
C++의 더 많은 괴상함을 보려면 C++ FQA를 추천함: https://yosefk.com/c++fqa/
이제 15년쯤 낡았지만, C++는 오래된 기능이나 동작을 거의 제거하지 않아서 동시에 시대를 타지 않음 -
블로그 테마가 정말 아름다움. DEC 시대 컴퓨터에서 영감을 받은 게 분명해 보이면서도 깔끔하고 미니멀해서 신선함
- 제목 오른쪽의 선들이 페이지 폭과 줄 수에 반응하는 방식이 마음에 듦
-
끔찍함. C++에서 무서운 부분 중 하나임. 멋진 기능도 있고 뛰어난 사람들이 작업하는 언어라 더 아쉬움
Herb의 C++ syntax 2 같은 것이 나 같은 보통 사람도 쓸 수 있는 언어로 만들어 주길 기대함- 소프트웨어 개발을 조금이라도 해 본 사람에게는 별문제가 되지 않는 걸 두고 과하게 불평하는 것 같음
글의 예제들은 프로그래머가 직접 쓰지 않기로 한 특수 멤버 함수를 언어가 예외적으로 자동 생성하는 기능의 모서리 사례를 일부러 건드리도록 만든 것들임
C++는 쓰는 만큼만 비용을 치르는 설계 목표가 있고, 3의 규칙/5의 규칙은 C++ 입문 수준의 기본 지식임. 이런 특수 멤버 함수는 특정 조건에서만 컴파일러가 생성하므로, 조건이 맞지 않으면 자동 생성되지 않는다는 것도 기본에 속함
결국 사용할 생성자를 직접 설정해야 한다는 걸 아는 게 그렇게 터무니없는 일인지 모르겠음. 인스턴스를 만들 때 초기화해야 한다는 사실이 놀랄 일도 아님. 현실에서는 사람들이 큰 소란 없이 이런 방식으로 실제 일을 하고 있음
- 소프트웨어 개발을 조금이라도 해 본 사람에게는 별문제가 되지 않는 걸 두고 과하게 불평하는 것 같음
-
이걸 읽으면 현기증이 남. Java 생성자와 객체 초기화를 이해하려고 했던 때가 떠오름
적어도 지금까지의 경험으로는 Go와 Rust처럼 특별한 생성자가 없는 선택이 많은 것을 단순하게 만들어 줌
생성자가 없는 언어로 옮긴 뒤 생성자가 그리웠던 사람이 있는지 궁금함- 제자리에서 동작하는 생성자가 가능하게 해 주는 다소 난해한 마법 같은 경우들이 있음. 예를 들어 Rust에는 아직 보장된 placement new류 동작이 없음
물론MaybeUninit에ptr::write로 필드를 하나씩 직접 써 넣을 수는 있지만, 인체공학적이지 않고 구조체가 이를 명시적으로 허용해야 함 - 컴파일러가 아무 일도 해 주지 않는 게 “정말 단순하게 만든다”는 말은 잘 이해되지 않음
결국 모든 생성자를 명시적으로 선언하고 정의해야 한다는 뜻인데, C++도 처음부터 그 선택지를 제공함. 특수 멤버 함수 자동 생성은 반복 코드를 피하고 싶은 사람을 위해 아주 특정한 경우에만 동작하도록 추가된 기능임
원하면 직접 생성자를 추가하면 평범하게 살 수 있음. 특수 멤버 함수 때문에 단순한 일이 복잡해진다고 불평하는 건, 사전에 어려운 단어가 있으니 영어가 단순하지 않다고 불평하는 것과 비슷함 - Java 생성자는 쉬운 편이고, 이 C++ 쪽은 정말 흑마법 같음
내가 이해하기로 Rust 생성자는 기본적으로 Java와 비슷하지 않나? - Go의 제로 초기화도 때때로 언어 규칙을 따져야 할 때가 있음
https://codefibershq.com/blog/golang-why-nil-is-not-always-n...
- 제자리에서 동작하는 생성자가 가능하게 해 주는 다소 난해한 마법 같은 경우들이 있음. 예를 들어 Rust에는 아직 보장된 placement new류 동작이 없음
-
뒤에서 일어나는 암시적 동작을 모두 추가하거나 보여 주는 C++ 도구가 있는지 궁금함
예를 들어 자동으로 추가되는 생성자, 암시적 복사 생성자, 그 밖의 놀라운 것들을 보여 주는 도구 말임- 현실적으로는 Godbolt와
cppinsights를 조합하는 게 최선일 듯함
- 현실적으로는 Godbolt와
-
T::T() = default;의 경우 출력이 0일 거라 기대하겠지만 쓰레기 값이 된다는 예시는 실제로 그렇게 이상하진 않음
관련 링크: https://consteval.ca/2024/07/03/initialization/#:~:text=You%...
다르게 만들면 라이브러리 사용자 누구나 라이브러리 동작을 바꿀 수 있게 되기 때문임- 대안이 말이 안 된다고 해서 이 해법이 자동으로 말이 되는 건 아님. 오히려 C++ 설계가 얼마나 많은 구석에 스스로 몰렸는지를 보여 줌