C++ 고급 문법 (7) — 생성자(Constructor)

생성자는 객체를 사용 가능한 상태로 만드는 함수인데, 본문 {}이 실행되기 전에 이미 많은 일이 끝나 있다. 기반 클래스와 멤버는 정해진 순서로 먼저 생성되고, 초기화 리스트에 쓴 순서는 그 순서를 바꾸지 못한다. 이 규칙을 모르면 초기화되지 않은 멤버를 읽거나 불필요한 대입을 한 번 더 하게 된다. 이 글에서는 g++ 13.3으로 생성 순서, 초기화 리스트, 위임 생성자, = default/= delete, 생성자에서 예외가 날 때의 정리 동작을 확인한다. 복사·이동 생성자는 객체 복사 편, explicit과 변환 생성자는 변환 편에서 따로 다룬다.

이 시리즈의 다른 글

생성과 소멸 순서

#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 멤버로 관리한다.

참고

답글 남기기