생성자는 객체를 사용 가능한 상태로 만드는 함수인데, 본문 {}이 실행되기 전에 이미 많은 일이 끝나 있다. 기반 클래스와 멤버는 정해진 순서로 먼저 생성되고, 초기화 리스트에 쓴 순서는 그 순서를 바꾸지 못한다. 이 규칙을 모르면 초기화되지 않은 멤버를 읽거나 불필요한 대입을 한 번 더 하게 된다. 이 글에서는 g++ 13.3으로 생성 순서, 초기화 리스트, 위임 생성자, = default/= delete, 생성자에서 예외가 날 때의 정리 동작을 확인한다. 복사·이동 생성자는 객체 복사 편, explicit과 변환 생성자는 변환 편에서 따로 다룬다.
이 시리즈의 다른 글
- C++ 고급 문법 (5) — 임시 객체(Temporary Object)
- C++ 고급 문법 (6) — 명시적 캐스팅(Explicit Casting)
- C++ 고급 문법 (8) — const 멤버 함수(const Member Function)
- C++ 고급 문법 (9) — 객체 복사(Object Copy)
생성과 소멸 순서
#include <cstdio>
struct Member {
const char* name;
Member(const char* n) : name(n) { std::printf(" member %s ctor\n", name); }
~Member() { std::printf(" member %s dtor\n", name); }
};
struct Base {
Base() { std::puts(" Base ctor"); }
~Base() { std::puts(" Base dtor"); }
};
struct Derived : Base {
Member a{"a"};
Member b{"b"};
Derived() { std::puts(" Derived ctor body"); }
~Derived() { std::puts(" Derived dtor body"); }
};
int main()
{
std::puts("construct:");
{ Derived d; std::puts("destruct:"); }
}$ g++ -std=c++20 -Wall -o t trace_order.cpp
$ ./t
construct:
Base ctor
member a ctor
member b ctor
Derived ctor body
destruct:
Derived dtor body
member b dtor
member a dtor
Base dtor
기반 클래스 → 멤버(선언 순서) → 생성자 본문 순으로 생성되고, 소멸은 정확히 그 역순이다.
초기화 리스트 순서는 무시된다
#include <cstdio>
struct Buffer {
int* data;
int size;
// 초기화 리스트는 size, data 순서로 썼지만 실제 초기화는 선언 순서(data -> size)
Buffer(int n) : size(n), data(new int[size]) {}
~Buffer() { delete[] data; }
};
int main()
{
Buffer b(4);
std::printf("size=%d\n", b.size);
}$ g++ -std=c++20 -Wall -o order order.cpp
order.cpp: In constructor ‘Buffer::Buffer(int)’:
order.cpp:5:10: warning: ‘Buffer::size’ will be initialized after [-Wreorder]
5 | int size;
| ^~~~
order.cpp:4:10: warning: ‘int* Buffer::data’ [-Wreorder]
4 | int* data;
| ^~~~
order.cpp:7:5: warning: when initialized here [-Wreorder]
7 | Buffer(int n) : size(n), data(new int[size]) {}
| ^~~~~~
order.cpp: In constructor ‘Buffer::Buffer(int)’:
order.cpp:7:43: warning: ‘*this.Buffer::size’ is used uninitialized [-Wuninitialized]
7 | Buffer(int n) : size(n), data(new int[size]) {}
| ^~~~
data가 먼저 선언되어 있어 new int[size]가 초기화되지 않은 size로 실행된다. 초기화 리스트는 항상 멤버 선언 순서대로 쓰는 것이 안전하다.
초기화 리스트와 본문 대입
#include <cstdio>
struct Name {
Name() { std::puts(" Name()"); }
Name(const char*) { std::puts(" Name(const char*)"); }
Name& operator=(const char*) { std::puts(" Name::operator=(const char*)"); return *this; }
};
struct ByAssign {
Name n;
ByAssign(const char* s) { n = s; } // 기본 생성 후 대입
};
struct ByInit {
Name n;
ByInit(const char* s) : n(s) {} // 바로 생성
};
int main()
{
std::puts("ByAssign:"); ByAssign a("x");
std::puts("ByInit:"); ByInit b("x");
}$ g++ -std=c++20 -Wall -o il initlist.cpp
$ ./il
ByAssign:
Name()
Name::operator=(const char*)
ByInit:
Name(const char*)
본문에서 대입하면 멤버가 기본 생성된 뒤 다시 대입된다. const 멤버와 참조 멤버는 대입 자체가 불가능하므로 초기화 리스트가 필수다.
struct Config {
const int max_conn;
int& counter;
Config(int m, int& c)
{
max_conn = m; // const 멤버는 대입 불가
counter = c; // 참조 멤버는 생성 시 바인딩해야 함
}
};$ g++ -std=c++20 -Wall -c constref.cpp
constref.cpp: In constructor ‘Config::Config(int, int&)’:
constref.cpp:4:5: error: uninitialized const member in ‘const int’ [-fpermissive]
4 | Config(int m, int& c)
| ^~~~~~
constref.cpp:2:16: note: ‘const int Config::max_conn’ should be initialized
2 | const int max_conn;
| ^~~~~~~~
constref.cpp:4:5: error: uninitialized reference member in ‘int&’ [-fpermissive]
4 | Config(int m, int& c)
| ^~~~~~
constref.cpp:3:16: note: ‘int& Config::counter’ should be initialized
3 | int& counter;
| ^~~~~~~
constref.cpp:6:18: error: assignment of read-only member ‘Config::max_conn’
6 | max_conn = m; // const 멤버는 대입 불가
| ~~~~~~~~~^~~
기본 멤버 초기화자와 위임 생성자
#include <cstdio>
#include <string>
class Connection {
std::string host_;
int port_ = 80; // 기본 멤버 초기화자(default member initializer)
int timeout_ = 30;
bool tls_ = false;
public:
Connection(std::string host, int port, bool tls)
: host_(std::move(host)), port_(port), tls_(tls) {} // timeout_은 30
Connection(std::string host)
: Connection(std::move(host), 80, false) {} // 위임 생성자(delegating constructor)
Connection() : Connection("localhost") {}
void print() const
{
std::printf("%s:%d timeout=%d tls=%d\n", host_.c_str(), port_, timeout_, tls_);
}
};
int main()
{
Connection().print();
Connection("example.com").print();
Connection("example.com", 443, true).print();
}$ g++ -std=c++20 -Wall -o d delegate.cpp
$ ./d
localhost:80 timeout=30 tls=0
example.com:80 timeout=30 tls=0
example.com:443 timeout=30 tls=1
초기화 리스트에 없는 멤버(timeout_)는 선언에 적은 기본값을 쓴다. 위임 생성자는 다른 생성자에 초기화를 맡기며, 이때 초기화 리스트에 다른 멤버를 함께 쓸 수 없다.
= default와 = delete
생성자를 하나라도 직접 선언하면 컴파일러는 기본 생성자를 만들어주지 않는다.
#include <vector>
struct Point {
int x, y;
Point(int x_, int y_) : x(x_), y(y_) {} // 사용자 생성자를 하나라도 선언하면
};
int main()
{
Point p; // 기본 생성자가 암시적으로 생기지 않는다
std::vector<Point> v(3);
}$ g++ -std=c++20 -Wall -c nodefault.cpp 2>&1 | grep -E 'error|note: +candidate' | head -4
nodefault.cpp:10:11: error: no matching function for call to ‘Point::Point()’
nodefault.cpp:5:5: note: candidate: ‘Point::Point(int, int)’
nodefault.cpp:5:5: note: candidate expects 2 arguments, 0 provided
nodefault.cpp:3:8: note: candidate: ‘constexpr Point::Point(const Point&)’
= default로 기본 생성자를 다시 요청하고, 원치 않는 오버로드는 = delete로 막는다.
#include <cstdio>
struct Point {
int x = 0, y = 0;
Point() = default; // 기본 생성자를 다시 요청
Point(int x_, int y_) : x(x_), y(y_) {}
Point(double, double) = delete; // 이 오버로드는 금지
};
int main()
{
Point a;
Point b(1, 2);
std::printf("a=(%d,%d) b=(%d,%d)\n", a.x, a.y, b.x, b.y);
// Point c(1.5, 2.5); // 주석을 풀면 아래 에러
}$ g++ -std=c++20 -Wall -o dd defdel.cpp
$ ./dd
a=(0,0) b=(1,2)
$ g++ -std=c++20 -Wall -c defdel_err.cpp 2>&1 | head -4
defdel_err.cpp: In function ‘int main()’:
defdel_err.cpp:15:21: error: use of deleted function ‘Point::Point(double, double)’
15 | Point c(1.5, 2.5);
| ^
delete가 없으면 Point(1.5, 2.5)는 (int, int) 생성자로 암시적 변환되어 조용히 소수점이 잘린다.
괄호와 중괄호
#include <cstdio>
#include <vector>
struct Timer { Timer() { std::puts("Timer()"); } };
int main()
{
Timer t1(); // 객체가 아니라 함수 선언 (most vexing parse)
Timer t2{}; // 중괄호는 항상 객체
(void)t2;
std::vector<int> a(3, 5); // 원소 3개, 값 5
std::vector<int> b{3, 5}; // initializer_list 생성자가 우선: 원소 2개 {3, 5}
std::printf("a.size=%zu b.size=%zu\n", a.size(), b.size());
}$ g++ -std=c++20 -Wall -o v vexing.cpp
vexing.cpp: In function ‘int main()’:
vexing.cpp:8:13: warning: empty parentheses were disambiguated as a function declaration [-Wvexing-parse]
8 | Timer t1(); // 객체가 아니라 함수 선언 (most vexing parse)
| ^~
vexing.cpp:8:13: note: remove parentheses to default-initialize a variable
8 | Timer t1(); // 객체가 아니라 함수 선언 (most vexing parse)
| ^~
| --
vexing.cpp:8:13: note: or replace parentheses with braces to value-initialize a variable
$ ./v
Timer()
a.size=3 b.size=2
Timer t1();은 함수 선언이라 생성자가 한 번만 호출됐다. 반대로 std::vector처럼 initializer_list 생성자가 있는 클래스는 중괄호를 쓰면 그 생성자가 우선 선택된다.
| 표기 | 의미 |
|---|---|
T x; | 기본 초기화 (스칼라 멤버는 쓰레기 값일 수 있음) |
T x{}; | 값 초기화 (스칼라는 0) |
T x(); | 함수 선언 |
T x(a, b); | 일반 생성자 오버로드 해석 |
T x{a, b}; | initializer_list 생성자 우선, 축소 변환(narrowing) 금지 |
생성자에서 예외가 나면
#include <cstdio>
#include <memory>
#include <stdexcept>
struct Res {
const char* n;
Res(const char* s) : n(s) { std::printf(" acquire %s\n", n); }
~Res() { std::printf(" release %s\n", n); }
};
struct Device {
Res lock{"lock"}; // 멤버 객체: 자동 정리됨
std::unique_ptr<Res> buf;
Res* raw; // 생 포인터: 정리 안 됨
Device()
{
buf = std::make_unique<Res>("buffer");
raw = new Res("raw");
throw std::runtime_error("init failed");
}
~Device() { std::puts(" ~Device"); delete raw; }
};
int main()
{
try { Device d; }
catch (const std::exception& e) { std::printf("caught: %s\n", e.what()); }
}$ g++ -std=c++20 -Wall -o tc throwctor.cpp
$ ./tc
acquire lock
acquire buffer
acquire raw
release buffer
release lock
caught: init failed
생성이 끝나지 않은 객체의 소멸자(~Device)는 호출되지 않는다. 이미 생성된 멤버(lock, buf)만 역순으로 정리되므로, 생 포인터로 잡은 raw는 누수된다.
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| 초기화 리스트 순서 ≠ 선언 순서 | 초기화 전 멤버를 읽음 | -Wall의 -Wreorder 경고 확인, 선언 순서대로 작성 |
| 본문에서 멤버 대입 | 기본 생성 + 대입으로 두 번 작업, const/참조 멤버 불가 | 초기화 리스트 사용 |
| 사용자 생성자 추가 | 기본 생성자가 사라져 컨테이너·배열에서 컴파일 에러 | T() = default; |
| 원치 않는 암시적 변환으로 생성자 선택 | 소수점 잘림 등 조용한 값 변경 | = delete 오버로드나 중괄호 초기화 |
T x(); | 함수 선언으로 해석 | T x; 또는 T x{}; |
| 생성자에서 예외 | 소멸자가 불리지 않아 생 포인터 자원 누수 | 자원은 스마트 포인터 같은 멤버 객체로 소유 |
마무리
- 생성 순서는 기반 클래스 → 멤버 선언 순서 → 본문이며, 초기화 리스트 순서와 무관하다.
- 멤버는 초기화 리스트나 기본 멤버 초기화자로 초기화하고, 겹치는 생성자는 위임 생성자로 합친다.
- 생성자가 예외로 끝나면 이미 만들어진 멤버만 정리되므로 자원은 RAII 멤버로 관리한다.