본문 바로가기

생성자와 소멸자

생성자 선택과 멤버 초기화 순서, 소멸 경계, 직접 소유 자원의 Rule of Zero·Three·Five를 이해합니다.

객체는 만들어지는 순간부터 올바른 상태여야 합니다. 멤버를 먼저 만들어 두고 나중에 값을 채우면, 그 사이에 객체를 잘못 쓸 틈이 생깁니다. 생성자(constructor)는 객체를 만들 때 자동으로 실행되어 멤버를 초기화하고, 소멸자(destructor)는 객체가 사라질 때 자동으로 실행되어 객체가 쓰던 자원을 정리합니다.

이 글은 생성자를 정의하고 오버로딩하는 방법, 멤버 초기화 목록과 실제 초기화 순서, 소멸자가 실행되는 시점, 컴파일러가 만들어 주는 기본 생성자, 그리고 자원을 직접 소유하는 클래스가 지켜야 할 Rule of Three, Five, Zero를 다룹니다. 아래 그림은 생성자를 고르는 데서 소멸까지 객체 하나의 수명을 따라갑니다.

C++ 객체 생성 순서와 수명 경계

가상 기반→직접 기반→멤버→본문→사용→소멸의 실제 수명 순서이며 생성 실패는 완료된 하위 객체만 역순 정리한다. 기본 후보 존재 검사는 별도 언어 계약으로 남긴다.

사용자 선언 생성자 또는 생성자 템플릿 유무로 기본 생성자 경로를 판정하고, 가상 기반 클래스, 직접 기반 클래스, 비정적 데이터 멤버, 생성자 본문 순서로 초기화한다. 성공하면 불변식을 지키며 사용하고 소멸자 호출로 완전 객체 수명을 끝내며, 생성 중 예외가 나면 완전 객체 소멸자 없이 이미 생성된 하위 객체만 역순 정리한다.

C++ · lifetime · flowchart

객체는 생성자 선택부터 소멸까지 하나의 수명 계약을 따른다

초기화 목록에 적은 순서가 실제 순서를 바꾸지는 않습니다. 기본 생성자 경로를 먼저 판정하고, 하위 객체의 정해진 순서와 생성자 본문을 모두 마쳐야 불변식을 전제로 객체를 사용할 수 있습니다.

기본 생성자 선택과 정해진 초기화 및 소멸 경계원desktop9노드9간선의사용자선언유무두경로가가상기반클래스에서합류하고직접기반→멤버→본문→사용→소멸경계순서다.암시적선언후삭제/호출가능성확인이필요하다.생성중예외롤백은원caption별도범위이며완전객체소멸자를호출한것처럼화살표를그리지않는다.모바일에서도기본후보경로는직접제공후보를건너뛴다.선언한 생성자 없음?생성자 템플릿도 없음기본 생성자 후보암시적 선언·삭제 확인호출할 생성자 필요직접 정의/default/delete가상 기반 클래스최종 파생 객체DFS 왼쪽→오른쪽직접 기반 클래스기반 목록 선언 순서비정적 데이터 멤버멤버 선언 순서생성자 본문하위 객체 초기화 뒤사용할 객체불변식 전제소멸자 호출 시작본문 뒤 멤버·기반완료한 생성의 역순예아니요생성 중 예외: 완전 객체 소멸 없음완료한 하위 객체만 역순 정리
C++ 객체 생성 순서와 수명 경계 사용자 선언 생성자 또는 생성자 템플릿이 없으면 암시적으로 선언된 기본 생성자 후보를 사용하고, 있으면 호출 가능한 생성자가 필요하다. 두 경로는 가장 파생된 객체의 가상 기반 클래스, 직접 기반 클래스의 선언 순서, 비정적 데이터 멤버의 선언 순서, 생성자 본문으로 합쳐진다. 본문이 불변식을 세우면 객체를 사용한다. 소멸자 호출이 시작되면 완전 객체 수명이 끝나지만 본문은 아직 살아 있는 하위 객체를 사용할 수 있고, 이후 멤버와 기반 클래스가 생성의 역순으로 파괴된다. 생성 중 예외가 나면 완전 객체 소멸자는 호출되지 않고 생성 완료된 하위 객체만 역순 정리된다. DEFAULT GATE 사용자 선언 생성자· 생성자 템플릿이 없는가? IMPLICITLY DECLARED 기본 생성자 후보 암시적 선언 · 삭제 여부 확인 USER-DECLARED PATH 호출할 생성자 필요 = default / = delete / 직접 정의 01 · VIRTUAL BASES 가상 기반 클래스 최종 파생 객체 · DFS 좌→우 02 · DIRECT BASES 직접 기반 클래스 기반 클래스 목록 선언 순서 03 · DATA MEMBERS 비정적 데이터 멤버 클래스 선언 순서 초기화 목록 순서 무관 04 · BODY 생성자 본문 하위 객체 초기화 뒤 실행 불변식을 세울 책임 VALID OBJECT 사용 가능한 객체 멤버 함수는 불변식 전제 DESTRUCTION 소멸자 호출 시작 객체 수명 종료 본문 뒤 멤버·기반 역순 정리 YESNO

01 · default gate

사용자 선언 생성자와 생성자 템플릿이 모두 없는가?

그렇다면 매개변수 없는 기본 생성자가 암시적으로 선언되고 defaulted 후보가 됩니다. 하나라도 있다면 호출 가능한 생성자를 직접 제공해야 하며, = default와 = delete도 사용자 선언입니다.

02 · fixed initialization order

초기화 목록의 표기 순서가 실제 순서를 바꾸지 않는다

① 가상 기반 클래스(최종 파생 객체에서 DFS 좌→우), ② 직접 기반 클래스(기반 클래스 목록의 선언 순서), ③ 비정적 데이터 멤버(클래스 선언 순서), ④ 생성자 본문 순서입니다.

03 · invariant and use

생성자 본문을 마친 뒤에야 불변식을 전제로 사용한다

기반 클래스와 멤버가 모두 초기화된 뒤 생성자 본문이 실행됩니다. 본문이 검증을 마치면 객체의 수명이 시작된 상태에서 멤버 함수가 그 불변식을 전제로 동작합니다.

04 · destruction boundary

소멸자 호출이 시작되면 객체 수명이 끝난다

완전 객체의 형식적 수명은 소멸자 호출과 함께 끝나지만, 소멸자 본문은 특별 규칙에 따라 아직 살아 있는 멤버와 기반 클래스 하위 객체를 사용할 수 있습니다. 본문 뒤에는 각 하위 객체의 수명이 생성 완료의 역순으로 끝납니다.

05 · construction rollback

생성 중 예외가 나면 완전 객체의 소멸자는 호출되지 않는다

이미 생성을 마친 기반 클래스와 멤버 하위 객체만 생성 완료의 역순으로 파괴됩니다. 아직 생성되지 않은 하위 객체와 완전 객체의 소멸자는 호출 대상이 아닙니다.

자동·정적·스레드 저장 기간 객체와 임시 객체는 각 수명 경계에서 소멸자가 호출됩니다. new로 만든 객체는 delete 표현식이나 RAII 소유자가 그 경계를 만들어야 합니다. 생성 중 예외가 나면 완전 객체의 소멸자는 호출되지 않고, 생성 완료된 하위 객체만 역순으로 롤백됩니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명원래 가로 그림의 9개 노드와 9개 간선에서 사용자 선언 유무에 따른 두 경로가 가상 기반 클래스에서 합류하고 직접 기반→멤버→본문→사용→소멸 경계 순서로 이어진다. 암시적 선언 후 삭제/호출 가능성 확인이 필요하다. 생성 중 예외 롤백은 원래 캡션의 별도 범위이며 완전 객체의 소멸자를 호출한 것처럼 화살표를 그리지 않는다. 모바일에서도 기본 후보 경로는 직접 제공 후보를 건너뛴다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
하위 객체 구성

가상 기반·직접 기반·멤버 순으로 구성한다.

  • 순서: 가상 기반→직접 기반→멤버
  • 완료 조건: 각 초기화 성공
  • 초기화 목록에 쓴 순서가 실제 멤버 선언 순서를 바꾸지 않는다.
본문과 사용

전체 하위 객체가 완성된 뒤 생성자 본문을 수행한다.

  • 현재: 완성 객체 사용
  • 기반·멤버: 구성 완료
  • 생성 실패는 완성된 객체의 사용 단계로 진행하지 않는다.
역순 정리

정상 경로에서는 소멸하고, 생성 실패 경로에서는 완성된 하위 객체만 정리한다.

  • 정상: 완성된 구성의 역순
  • 실패: 완료된 하위 객체만 역순
  • 생성에 실패한 전체 객체의 소멸자가 실행되었다고 표시하지 않는다.

C++ 객체 생성 순서와 수명 경계

가상 기반→직접 기반→멤버→본문→사용→소멸의 실제 수명 순서이며 생성 실패는 완료된 하위 객체만 역순 정리한다. 기본 후보 존재 검사는 별도 언어 계약으로 남긴다.

사용자 선언 생성자 또는 생성자 템플릿 유무로 기본 생성자 경로를 판정하고, 가상 기반 클래스, 직접 기반 클래스, 비정적 데이터 멤버, 생성자 본문 순서로 초기화한다. 성공하면 불변식을 지키며 사용하고 소멸자 호출로 완전 객체 수명을 끝내며, 생성 중 예외가 나면 완전 객체 소멸자 없이 이미 생성된 하위 객체만 역순 정리한다.

C++ · lifetime · flowchart

객체는 생성자 선택부터 소멸까지 하나의 수명 계약을 따른다

초기화 목록에 적은 순서가 실제 순서를 바꾸지는 않습니다. 기본 생성자 경로를 먼저 판정하고, 하위 객체의 정해진 순서와 생성자 본문을 모두 마쳐야 불변식을 전제로 객체를 사용할 수 있습니다.

기본 생성자 선택과 정해진 초기화 및 소멸 경계원desktop9노드9간선의사용자선언유무두경로가가상기반클래스에서합류하고직접기반→멤버→본문→사용→소멸경계순서다.암시적선언후삭제/호출가능성확인이필요하다.생성중예외롤백은원caption별도범위이며완전객체소멸자를호출한것처럼화살표를그리지않는다.모바일에서도기본후보경로는직접제공후보를건너뛴다.선언한 생성자 없음?생성자 템플릿도 없음기본 생성자 후보암시적 선언·삭제 확인호출할 생성자 필요직접 정의/default/delete가상 기반 클래스최종 파생 객체DFS 왼쪽→오른쪽직접 기반 클래스기반 목록 선언 순서비정적 데이터 멤버멤버 선언 순서생성자 본문하위 객체 초기화 뒤사용할 객체불변식 전제소멸자 호출 시작본문 뒤 멤버·기반완료한 생성의 역순예아니요생성 중 예외: 완전 객체 소멸 없음완료한 하위 객체만 역순 정리
C++ 객체 생성 순서와 수명 경계 사용자 선언 생성자 또는 생성자 템플릿이 없으면 암시적으로 선언된 기본 생성자 후보를 사용하고, 있으면 호출 가능한 생성자가 필요하다. 두 경로는 가장 파생된 객체의 가상 기반 클래스, 직접 기반 클래스의 선언 순서, 비정적 데이터 멤버의 선언 순서, 생성자 본문으로 합쳐진다. 본문이 불변식을 세우면 객체를 사용한다. 소멸자 호출이 시작되면 완전 객체 수명이 끝나지만 본문은 아직 살아 있는 하위 객체를 사용할 수 있고, 이후 멤버와 기반 클래스가 생성의 역순으로 파괴된다. 생성 중 예외가 나면 완전 객체 소멸자는 호출되지 않고 생성 완료된 하위 객체만 역순 정리된다. DEFAULT GATE 사용자 선언 생성자· 생성자 템플릿이 없는가? IMPLICITLY DECLARED 기본 생성자 후보 암시적 선언 · 삭제 여부 확인 USER-DECLARED PATH 호출할 생성자 필요 = default / = delete / 직접 정의 01 · VIRTUAL BASES 가상 기반 클래스 최종 파생 객체 · DFS 좌→우 02 · DIRECT BASES 직접 기반 클래스 기반 클래스 목록 선언 순서 03 · DATA MEMBERS 비정적 데이터 멤버 클래스 선언 순서 초기화 목록 순서 무관 04 · BODY 생성자 본문 하위 객체 초기화 뒤 실행 불변식을 세울 책임 VALID OBJECT 사용 가능한 객체 멤버 함수는 불변식 전제 DESTRUCTION 소멸자 호출 시작 객체 수명 종료 본문 뒤 멤버·기반 역순 정리 YESNO

01 · default gate

사용자 선언 생성자와 생성자 템플릿이 모두 없는가?

그렇다면 매개변수 없는 기본 생성자가 암시적으로 선언되고 defaulted 후보가 됩니다. 하나라도 있다면 호출 가능한 생성자를 직접 제공해야 하며, = default와 = delete도 사용자 선언입니다.

02 · fixed initialization order

초기화 목록의 표기 순서가 실제 순서를 바꾸지 않는다

① 가상 기반 클래스(최종 파생 객체에서 DFS 좌→우), ② 직접 기반 클래스(기반 클래스 목록의 선언 순서), ③ 비정적 데이터 멤버(클래스 선언 순서), ④ 생성자 본문 순서입니다.

03 · invariant and use

생성자 본문을 마친 뒤에야 불변식을 전제로 사용한다

기반 클래스와 멤버가 모두 초기화된 뒤 생성자 본문이 실행됩니다. 본문이 검증을 마치면 객체의 수명이 시작된 상태에서 멤버 함수가 그 불변식을 전제로 동작합니다.

04 · destruction boundary

소멸자 호출이 시작되면 객체 수명이 끝난다

완전 객체의 형식적 수명은 소멸자 호출과 함께 끝나지만, 소멸자 본문은 특별 규칙에 따라 아직 살아 있는 멤버와 기반 클래스 하위 객체를 사용할 수 있습니다. 본문 뒤에는 각 하위 객체의 수명이 생성 완료의 역순으로 끝납니다.

05 · construction rollback

생성 중 예외가 나면 완전 객체의 소멸자는 호출되지 않는다

이미 생성을 마친 기반 클래스와 멤버 하위 객체만 생성 완료의 역순으로 파괴됩니다. 아직 생성되지 않은 하위 객체와 완전 객체의 소멸자는 호출 대상이 아닙니다.

자동·정적·스레드 저장 기간 객체와 임시 객체는 각 수명 경계에서 소멸자가 호출됩니다. new로 만든 객체는 delete 표현식이나 RAII 소유자가 그 경계를 만들어야 합니다. 생성 중 예외가 나면 완전 객체의 소멸자는 호출되지 않고, 생성 완료된 하위 객체만 역순으로 롤백됩니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명원래 가로 그림의 9개 노드와 9개 간선에서 사용자 선언 유무에 따른 두 경로가 가상 기반 클래스에서 합류하고 직접 기반→멤버→본문→사용→소멸 경계 순서로 이어진다. 암시적 선언 후 삭제/호출 가능성 확인이 필요하다. 생성 중 예외 롤백은 원래 캡션의 별도 범위이며 완전 객체의 소멸자를 호출한 것처럼 화살표를 그리지 않는다. 모바일에서도 기본 후보 경로는 직접 제공 후보를 건너뛴다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
하위 객체 구성

가상 기반·직접 기반·멤버 순으로 구성한다.

  • 순서: 가상 기반→직접 기반→멤버
  • 완료 조건: 각 초기화 성공
  • 초기화 목록에 쓴 순서가 실제 멤버 선언 순서를 바꾸지 않는다.
본문과 사용

전체 하위 객체가 완성된 뒤 생성자 본문을 수행한다.

  • 현재: 완성 객체 사용
  • 기반·멤버: 구성 완료
  • 생성 실패는 완성된 객체의 사용 단계로 진행하지 않는다.
역순 정리

정상 경로에서는 소멸하고, 생성 실패 경로에서는 완성된 하위 객체만 정리한다.

  • 정상: 완성된 구성의 역순
  • 실패: 완료된 하위 객체만 역순
  • 생성에 실패한 전체 객체의 소멸자가 실행되었다고 표시하지 않는다.

생성자

생성자는 클래스 이름과 같은 이름으로 선언하고, 반환 타입을 적지 않습니다. 매개변수 목록이 다르면 여러 개를 둘 수 있으며, 객체를 만들 때 넘긴 인자에 맞는 생성자가 함수 오버로딩의 규칙으로 선택됩니다. 매개변수가 없거나 모든 매개변수에 기본값이 있어서 인자 없이 부를 수 있는 생성자를 기본 생성자(default constructor)라고 합니다.

book.cpp
#include <iostream>
#include <memory>
#include <string>

class Book {
private:
    std::string title;
    std::string author;
    int year;

public:
    Book() : title("Untitled"), author("Unknown"), year(0) {
        std::cout << "default constructor\n";
    }

    Book(std::string t, std::string a, int y) : title(t), author(a), year(y) {
        std::cout << "constructor: " << title << '\n';
    }

    void display() const {
        std::cout << title << " / " << author << " / " << year << '\n';
    }
};

int main() {
    Book book1;
    book1.display();

    Book book2("The Little Prince", "Saint-Exupery", 1943);
    book2.display();

    auto book3 = std::make_unique<Book>("Cloud Bread", "Baek Heena", 2004);
    book3->display();
}
default constructor
Untitled / Unknown / 0
constructor: The Little Prince
The Little Prince / Saint-Exupery / 1943
constructor: Cloud Bread
Cloud Bread / Baek Heena / 2004

Book book1;은 인자가 없으므로 기본 생성자를, book2는 인자 세 개를 받는 생성자를 부릅니다. std::make_unique<Book>(...)도 넘긴 인자로 생성자를 고르며, 동적으로 만든 객체라도 생성자가 실행된다는 점은 같습니다. 인자가 없는 객체를 Book book1();처럼 괄호를 붙여 선언하면 객체가 아니라 함수 선언으로 해석되므로, 괄호 없이 쓰거나 Book book1{};로 씁니다.

멤버 초기화 목록

생성자의 매개변수 목록 뒤에 콜론을 붙이고 title(t)처럼 멤버마다 초깃값을 적은 것을 멤버 초기화 목록(member initializer list)이라고 합니다. 생성자 본문에서 title = t;로 대입해도 결과는 같아 보이지만, 그러면 title이 먼저 빈 문자열로 만들어진 뒤 다시 대입됩니다. 초기화 목록은 멤버가 만들어지는 순간 바로 그 값으로 초기화합니다.

const 멤버와 참조 멤버는 만든 뒤에 대입할 수 없으므로, 반드시 초기화 목록이나 클래스 안의 기본값으로 초기화해야 합니다. 그래서 특별한 이유가 없다면 멤버는 초기화 목록으로 초기화합니다.

const_member.cpp
#include <string>

class Student {
private:
    std::string name;
    const int id;

public:
    Student(std::string n, int i) : name(n), id(i) {}
    // Student(std::string n, int i) : name(n) { id = i; } // 컴파일 오류
};

멤버가 실제로 초기화되는 순서는 초기화 목록에 적은 순서가 아니라 클래스 안에 선언한 순서입니다. 그림처럼 기반 클래스가 먼저 만들어지고, 그다음 멤버가 선언 순서대로, 마지막으로 생성자 본문이 실행됩니다. 다음 프로그램은 초기화 목록을 일부러 선언과 반대로 적었습니다.

init_order.cpp
#include <iostream>

struct Trace {
    const char* name;
    explicit Trace(const char* n) : name(n) { std::cout << "+" << name << '\n'; }
    ~Trace() { std::cout << "-" << name << '\n'; }
};

class Order {
    Trace first;
    Trace second;

public:
    Order() : second("second"), first("first") { std::cout << "body\n"; }
    ~Order() { std::cout << "~Order\n"; }
};

int main() {
    {
        Order value;
        std::cout << "use\n";
    }
    std::cout << "after\n";
}
+first
+second
body
use
~Order
-second
-first
after

목록에는 second를 먼저 적었지만, 선언 순서대로 first가 먼저 만들어집니다. 생성자 본문은 두 멤버가 모두 만들어진 뒤에 실행되고, 블록이 끝나면 소멸자 본문이 실행된 뒤 멤버가 만들어진 순서의 역순으로 정리됩니다. GCC는 -Wall에 포함된 -Wreorder로 목록의 순서가 선언과 다르다고 경고하므로, 실제 코드에서는 목록도 선언 순서대로 적습니다.

Trace의 생성자에 붙은 explicit은 이 생성자가 암시적 변환에 쓰이지 않게 합니다. 매개변수가 하나인 생성자는 Trace t = "x";처럼 다른 타입의 값을 객체로 바꾸는 데 쓰일 수 있는데, 의도하지 않은 변환을 막으려면 explicit을 붙입니다.

소멸자

소멸자는 클래스 이름 앞에 ~를 붙여 선언하며, 반환 타입과 매개변수가 없고 클래스마다 하나입니다. 지역 객체는 블록을 벗어날 때, 동적 객체는 소유자가 정리할 때 소멸자가 실행됩니다. 소멸자의 주된 일은 객체가 소유한 메모리, 파일, 잠금 같은 자원을 돌려주는 것입니다.

my_array.cpp
#include <iostream>
#include <stdexcept>

class MyArray {
private:
    int* data = nullptr;
    int size = 0;

public:
    explicit MyArray(int s) {
        if (s > 0) {
            data = new int[s]{};
            size = s;
        }
        std::cout << "allocate " << size << '\n';
    }

    MyArray(const MyArray&) = delete;
    MyArray& operator=(const MyArray&) = delete;

    ~MyArray() {
        delete[] data;
        std::cout << "release " << size << '\n';
    }

    int& at(int index) {
        if (index < 0 || index >= size) {
            throw std::out_of_range("MyArray index");
        }
        return data[index];
    }

    void display() const {
        std::cout << '[';
        for (int i = 0; i < size; ++i) {
            std::cout << data[i] << (i + 1 < size ? ", " : "");
        }
        std::cout << "]\n";
    }
};

int main() {
    MyArray arr1(5);
    arr1.at(0) = 10;
    arr1.at(4) = 50;
    arr1.display();
    {
        MyArray arr2(3);
        arr2.at(0) = 100;
        arr2.display();
    }
    std::cout << "end of main\n";
}
allocate 5
[10, 0, 0, 0, 50]
allocate 3
[100, 0, 0]
release 3
end of main
release 5

생성자가 new int[s]{}로 0으로 채운 배열을 할당하고, 소멸자가 delete[]로 해제합니다. arr2는 안쪽 블록이 끝날 때, arr1은 main이 끝날 때 소멸자가 실행되므로 release 3이 end of main보다 먼저 나옵니다. 소멸자가 없다면 MyArray 객체가 사라져도 배열은 해제되지 않아 메모리가 새어 나갑니다.

복사 생성자와 복사 대입을 = delete로 막은 이유도 중요합니다. 막지 않으면 컴파일러가 만든 복사가 포인터 data만 복사해, 두 객체가 같은 배열을 가리키다 각자의 소멸자에서 두 번 해제합니다. 복사 연산을 직접 선언하면 이동 연산도 자동으로 만들어지지 않으므로, 이 클래스는 복사도 이동도 할 수 없는 객체가 됩니다.

생성자가 끝나기 전에 예외가 던져지면 객체는 만들어지지 않은 것으로 보고, 그 객체의 소멸자는 실행되지 않습니다. 대신 그때까지 다 만들어진 멤버와 기반 클래스만 역순으로 정리됩니다. 그래서 생성자에서 원시 포인터로 자원을 여러 개 얻다가 중간에 실패하면 앞의 자원이 새어 나갈 수 있으며, 이것도 자원을 RAII 멤버에 맡기는 이유입니다. 또 소멸자는 기본적으로 예외를 던지지 않는 noexcept로 취급되므로, 소멸자 안에서 실패할 수 있는 일은 하지 않는 편이 좋습니다.

더 깊이: 위임 생성자와 예외

C++11부터는 생성자의 초기화 목록에 같은 클래스의 다른 생성자를 적어 초기화를 그 생성자에 맡길 수 있습니다. Book() : Book("Untitled", "Unknown", 0) {}처럼 쓰는 이런 생성자를 위임 생성자(delegating constructor)라고 하며, 맡겨진 쪽인 대상 생성자(target constructor)가 먼저 실행된 뒤 위임 생성자의 본문이 실행됩니다. 이때 초기화 목록에는 대상 생성자 하나만 적을 수 있어 다른 멤버의 초기화를 함께 적을 수 없고, 생성자끼리 서로 위임해 되돌아오게 만들 수도 없습니다.

예외가 났을 때의 규칙도 다릅니다. 보통의 생성자에서 예외가 나면 이미 초기화를 마친 멤버와 기반 클래스만 정리되고 그 객체의 소멸자는 실행되지 않지만, 위임 생성자의 본문이 예외로 끝나면 대상 생성자가 이미 마친 그 객체의 소멸자가 실행됩니다. 이 규칙은 표준 초안의 해당 절에 있습니다. 그래서 대상 생성자에서 자원을 얻고 위임 생성자의 본문에서 실패할 수 있는 일을 하는 구성이라면, 그 자원은 소멸자가 정리하게 됩니다.

기본 생성자가 만들어지는 조건

클래스에 생성자를 하나도 선언하지 않으면, 컴파일러가 매개변수 없는 기본 생성자를 만들어 줍니다. 이 기본 생성자는 클래스 타입의 멤버는 그 타입의 기본 생성자로 만들고, 클래스 안에서 준 기본값을 적용합니다. 하지만 기본값이 없는 int 같은 기본 타입 멤버는 값이 정해지지 않은 채로 남습니다.

default_constructor.cpp
#include <iostream>

class Point {
public:
    int x{0};
    int y{0};
};

class RequiredPoint {
public:
    RequiredPoint(int x, int y) : x(x), y(y) {}
    int x;
    int y;
};

class Line {
public:
    Point p1;
    Point p2;
};

int main() {
    Point pt;
    std::cout << pt.x << ' ' << pt.y << '\n';

    RequiredPoint required(10, 20);
    std::cout << required.x << '\n';
    // RequiredPoint missing; // 컴파일 오류: 기본 생성자가 없음

    Line line;
    std::cout << line.p1.x << ' ' << line.p2.y << '\n';
}
0 0
10
0 0

Point와 Line은 생성자를 선언하지 않았으므로 기본 생성자가 만들어지고, Line의 기본 생성자는 두 Point 멤버를 각자의 기본 생성자로 만듭니다. RequiredPoint는 매개변수가 있는 생성자를 선언했으므로 기본 생성자가 만들어지지 않아, 인자 없이 만들 수 없습니다. 인자 없이도 만들고 싶다면 RequiredPoint() = default;를 추가하되, 이때 x와 y가 정해진 값을 갖도록 멤버에 기본값을 주어야 합니다.

Rule of Three, Five, Zero

MyArray처럼 원시 포인터나 파일 핸들 같은 자원을 직접 소유하는 클래스는 소멸자 하나만으로는 안전하지 않습니다. 복사와 이동이 그 자원을 어떻게 다룰지도 함께 정해야 하며, 이것을 정리한 지침이 Rule of Three, Five, Zero입니다. 컴파일러가 강제하는 규칙은 아니고, 복사, 이동, 정리를 서로 맞게 설계하기 위한 지침입니다.

C++ · OWNERSHIP POLICY

자원 소유 방식에 맞춰 복사·이동·소멸 정책을 함께 정한다

Rule of Zero/Three/Five는 설계 지침입니다. 직접 소유하는 raw 자원이 있는지 확인하고, 실제로 제공할 동작과 금지할 동작을 결정합니다.

Rule of Zero · 소유권을 멤버에 맡김

vector, string, 스마트 포인터 같은 RAII 멤버가 자원을 관리하면 특별 멤버를 직접 정의하지 않는 설계를 우선합니다.

std::vector<int> data;

바깥 클래스의 복사·이동 가능성은 멤버의 정책과 언어 규칙을 따릅니다. unique_ptr 멤버라면 기본 복사는 불가능합니다.

Rule of Three · 직접 소유

raw 메모리·파일 핸들을 직접 소유하면 소멸자와 두 복사 연산의 의미를 함께 검토합니다.

~MyArray();
MyArray(const MyArray&) = delete;
MyArray& operator=(
  const MyArray&) = delete;

값 복사가 필요하면 깊은 복사를 구현하고, 복사할 수 없는 소유권이면 명시적으로 금지합니다.

Rule of Five · 이동 정책도 결정

소유권 이전을 제공한다면 이동 생성자와 이동 대입까지 함께 검토합니다.

MyArray(MyArray&&) = delete;
MyArray& operator=(
  MyArray&&) = delete;

본문 MyArray는 이동도 금지합니다. 이동을 구현한다면 원본·대상의 소멸과 대입을 안전하게 유지하는 후상태를 문서화합니다.

이동 후 상태 · 타입의 계약

사용자 타입의 이동 후 상태는 구현자가 보장해야 합니다. 생략과 = delete가 호출 결과에 미치는 영향도 확인합니다.

표준 라이브러리 타입은 달리 명시되지 않으면 유효하지만 값은 미지정인 상태를 보장합니다. 사용자 타입에 같은 상태가 자동으로 생기지는 않습니다.

이 그림의 MyArray 선언은 직접 소유 예제에서 복사·이동을 금지하는 정책을 보여 줍니다. 특별 멤버의 실제 암시적 선언·삭제 여부는 C++ 언어 규칙을 따릅니다.

Rule of Three는 소멸자, 복사 생성자, 복사 대입 연산자 가운데 하나를 직접 정의해야 한다면 나머지 둘도 함께 검토하라는 지침입니다. 값의 복사가 필요하면 배열까지 새로 할당해 복사하는 깊은 복사(deep copy)를 구현하고, 복사가 의미 없다면 MyArray처럼 = delete로 막습니다. C++11부터는 소유를 옮기는 이동 생성자와 이동 대입 연산자까지 다섯 가지를 함께 검토하며, 이를 Rule of Five라고 합니다.

현대 C++이 먼저 권하는 것은 Rule of Zero입니다. 자원을 std::vector, std::string, std::unique_ptr처럼 스스로 정리하는 멤버에 맡기면, 다섯 가지 특별 멤버 함수를 하나도 직접 쓰지 않아도 컴파일러가 만든 것이 올바르게 동작합니다. MyArray의 int* data를 std::vector<int> data로 바꾸면 소멸자와 복사 금지가 모두 필요 없어지고, 복사하면 배열 전체가 복사됩니다. 실제 코드로 다섯 가지를 구현하는 방법은 Rule of Three, Five, Zero 실전에서 다룹니다.

정리

  • 생성자는 클래스 이름과 같고 반환 타입이 없으며, 오버로딩할 수 있고 인자에 맞는 것이 선택됩니다.
  • 멤버는 초기화 목록으로 초기화하며, const 멤버와 참조 멤버는 반드시 초기화 목록이나 기본값으로 초기화합니다.
  • 멤버는 목록의 순서가 아니라 선언 순서대로 초기화되고, 소멸은 그 역순이며, GCC의 -Wreorder가 순서의 불일치를 알려 줍니다.
  • 소멸자는 ~클래스이름()이며, 객체의 수명이 끝날 때 실행되어 소유한 자원을 정리합니다.
  • 생성자를 하나라도 선언하면 기본 생성자가 만들어지지 않으며, 필요하면 = default로 요청합니다.
  • 자원을 직접 소유하면 복사와 이동까지 함께 정하고, 가능하면 RAII 멤버에 맡기는 Rule of Zero를 따릅니다.

연습 문제

1. 선언 순서 바꾸기

init_order.cpp에서 멤버 선언을 Trace second;, Trace first;의 순서로 바꾸고 초기화 목록은 그대로 두면, +와 -로 시작하는 줄은 어떤 순서로 출력되나요? 반대로 선언은 원래대로 두고 목록만 first("first"), second("second")로 바꾸면 어떻게 되나요?

해설 보기

선언이 second, first의 순서이면 생성은 +second, +first이고 정리는 -first, -second입니다. 선언을 원래대로 두고 목록만 바꾸면 출력은 처음의 여덟 줄과 같습니다. 목록에 적은 순서는 초기화 순서를 바꾸지 못하고, 선언 순서만이 순서를 정합니다.

2. 기본 생성자 요청하기

RequiredPoint에 RequiredPoint() = default;만 추가하고 RequiredPoint missing;으로 만든 뒤 missing.x를 출력하면 어떻게 되나요? 어떻게 고치면 0이 출력되나요?

해설 보기

기본 생성자는 생기지만 x와 y에 기본값이 없으므로, 지역 객체의 두 멤버는 값이 정해지지 않은 상태이고 이를 읽으면 정의되지 않은 동작입니다. 멤버를 int x{0};, int y{0};으로 선언하면 기본 생성자가 그 기본값을 적용해 0이 출력되며, 인자를 받는 생성자는 초기화 목록으로 이 기본값을 덮어씁니다.

3. 복사를 막은 이유

my_array.cpp에서 복사 생성자의 = delete 줄을 지우고 MyArray copy = arr1;을 추가하면 어떤 문제가 생기나요?

해설 보기

컴파일러가 만든 복사 생성자는 멤버를 그대로 복사하므로 copy.data와 arr1.data가 같은 배열을 가리킵니다. main이 끝나면 두 객체의 소멸자가 같은 배열에 각각 delete[]를 실행해 이중 해제가 되며, 이는 정의되지 않은 동작입니다. 복사가 필요하다면 새 배열을 할당해 값을 복사하는 깊은 복사를 구현하거나, data를 std::vector<int>로 바꿉니다.

다음 글 멤버 함수와 멤버 변수에서는 this 포인터, const 멤버 함수, 모든 객체가 함께 쓰는 static 멤버를 다룹니다.