생성자와 소멸자
생성자 선택과 멤버 초기화 순서, 소멸 경계, 직접 소유 자원의 Rule of Zero·Three·Five를 이해합니다.
클래스 객체를 초기화하는 생성자(Constructor)와 객체 수명의 끝에서 정리를 수행하는 소멸자(Destructor)를 다룹니다.
자동·정적·스레드 저장 기간 객체와 임시 객체는 각 수명 경계에서 소멸자가 호출됩니다. 반면 new로 만든 동적 객체는 대응하는 delete 표현식이나 RAII 소유자가 수명을 끝내야 소멸자가 호출되고 저장 공간도 해제됩니다.
C++ · lifetime · flowchart
객체는 생성자 선택부터 소멸까지 하나의 수명 계약을 따른다
초기화 목록에 적은 순서가 실제 순서를 바꾸지는 않습니다. 기본 생성자 경로를 먼저 판정하고, 하위 객체의 정해진 순서와 생성자 본문을 모두 마쳐야 불변식을 전제로 객체를 사용할 수 있습니다.
01 · default gate
사용자 선언 생성자와 생성자 템플릿이 모두 없는가?
그렇다면 매개변수 없는 기본 생성자가 암시적으로 선언되고 defaulted 후보가 됩니다. 하나라도 있다면 호출 가능한 생성자를 직접 제공해야 하며, = default와 = delete도 사용자 선언입니다.
02 · fixed initialization order
초기화 목록의 표기 순서가 실제 순서를 바꾸지 않는다
① 가상 기반 클래스(최종 파생 객체에서 DFS 좌→우), ② 직접 기반 클래스(기반 클래스 목록의 선언 순서), ③ 비정적 데이터 멤버(클래스 선언 순서), ④ 생성자 본문 순서입니다.
03 · invariant and use
생성자 본문을 마친 뒤에야 불변식을 전제로 사용한다
기반 클래스와 멤버가 모두 초기화된 뒤 생성자 본문이 실행됩니다. 본문이 검증을 마치면 객체의 수명이 시작된 상태에서 멤버 함수가 그 불변식을 전제로 동작합니다.
04 · destruction boundary
소멸자 호출이 시작되면 객체 수명이 끝난다
완전 객체의 형식적 수명은 소멸자 호출과 함께 끝나지만, 소멸자 본문은 특별 규칙에 따라 아직 살아 있는 멤버와 기반 클래스 하위 객체를 사용할 수 있습니다. 본문 뒤에는 각 하위 객체의 수명이 생성 완료의 역순으로 끝납니다.
05 · construction rollback
생성 중 예외가 나면 완전 객체의 소멸자는 호출되지 않는다
이미 생성을 마친 기반 클래스와 멤버 하위 객체만 생성 완료의 역순으로 파괴됩니다. 아직 생성되지 않은 하위 객체와 완전 객체의 소멸자는 호출 대상이 아닙니다.
자동·정적·스레드 저장 기간 객체와 임시 객체는 각 수명 경계에서 소멸자가 호출됩니다. new로 만든 객체는 delete 표현식이나 RAII 소유자가 그 경계를 만들어야 합니다. 생성 중 예외가 나면 완전 객체의 소멸자는 호출되지 않고, 생성 완료된 하위 객체만 역순으로 롤백됩니다.
생성자 (Constructor)
생성자는 클래스의 객체가 생성될 때 자동으로 호출되는 특별한 멤버 함수입니다.
생성자의 주된 목적은 객체가 올바른 초기 상태를 갖도록 멤버 변수들을 초기화하는 것입니다.
생성자의 특징- 선언 문법에서 클래스 이름을 사용합니다. 표준 용어로 생성자는 일반 함수처럼 조회되는 이름을 갖지 않습니다.
- 반환 타입이 없습니다. (심지어
void도 사용하지 않습니다.) public접근 지정자로 선언하는 것이 일반적입니다. (외부에서 객체를 생성할 수 있도록)- 오버로딩(Overloading)이 가능하여 여러 개의 생성자를 가질 수 있습니다. (매개변수 리스트가 다르면 됨)
#include <string>
// 클래스 선언 (헤더 파일)
class MyClass {
private:
int number{0};
std::string text;
public:
MyClass(); // 1. 기본 생성자 (매개변수가 없는 생성자)
MyClass(int a, std::string b); // 2. 매개변수가 있는 생성자 (오버로딩)
// ... 다른 멤버들 ...
};
// 생성자 정의 (소스 파일)
MyClass::MyClass() { // 1. 기본 생성자 정의
// 멤버 변수 초기화
}
MyClass::MyClass(int a, std::string b) // 2. 매개변수가 있는 생성자 정의
: number(a), text(b) {}#include <iostream>
#include <string>
class Book {
private:
std::string title;
std::string author;
int year;
public:
// 1. 기본 생성자 (Default Constructor)
// 매개변수가 없으며, 객체 생성 시 초기값을 지정하지 않을 때 호출됩니다.
Book() : title("제목 없음"), author("작가 미상"), year(0) {
std::cout << "기본 생성자 호출: 책 정보가 초기화되었습니다.\n";
}
// 2. 매개변수가 있는 생성자 (Parameterized Constructor)
// 객체 생성 시 초기값을 전달받아 멤버 변수를 초기화합니다.
Book(std::string t, std::string a, int y) : title(t), author(a), year(y) {
std::cout << "매개변수 생성자 호출: " << title << " 책이 생성되었습니다.\n";
}
void displayBookInfo() {
std::cout << "제목: " << title << ", 저자: " << author << ", 출판년도: " << year << std::endl;
}
};
int main() {
// 1. 기본 생성자 호출하여 객체 생성
Book book1; // Book() 생성자 호출
book1.displayBookInfo(); // 출력: 제목: 제목 없음, 저자: 작가 미상, 출판년도: 0
std::cout << "--------------------------------\n";
// 2. 매개변수 생성자 호출하여 객체 생성
Book book2("어린 왕자", "앙투안 드 생텍쥐페리", 1943); // Book("...", "...", ...) 생성자 호출
book2.displayBookInfo(); // 출력: 제목: 어린 왕자, 저자: 앙투안 드 생텍쥐페리, 출판년도: 1943
std::cout << "--------------------------------\n";
// 힙에 동적 할당 시에도 생성자 호출
Book* book3 = new Book("구름빵", "백희나", 2004); // Book("...", "...", ...) 생성자 호출
book3->displayBookInfo(); // 출력: 제목: 구름빵, 저자: 백희나, 출판년도: 2004
delete book3; // 힙 객체 해제 (이때 소멸자 호출)
return 0;
}Book(std::string t, ...) { title = t; ... }처럼 생성자 본문에서 대입하는 방식은 비교를 위한 비권장 패턴입니다. 이 방식은 title을 먼저 기본 생성한 뒤 다시 대입합니다. 위 예시처럼 멤버 초기화 리스트를 사용하면 멤버가 생성되는 순간 원하는 값으로 직접 초기화됩니다.
new로 만든 book3은 delete book3가 수명을 끝내면서 소멸자를 호출합니다. delete를 생략하면 프로그램이 포인터를 잃은 뒤에는 그 동적 객체의 소멸자와 저장 공간 해제가 자동으로 따라오지 않습니다.
멤버 초기화 리스트
생성자에서 멤버 변수를 초기화하는 또 다른 방법이자 권장되는 방법은 멤버 초기화 리스트를 사용하는 것입니다.
class MyClass {
private:
int member;
public:
explicit MyClass(int value);
};
MyClass::MyClass(int value) : member(value) {}- 생성자의 매개변수 리스트 뒤에 콜론(
:)을 붙이고, 멤버 변수 이름과 괄호 안에 초기화할 값을 지정합니다. - 실제 초기화 순서는 목록에 적은 순서가 아닙니다. 가장 파생된 객체의 가상 기반 클래스, 직접 기반 클래스의 선언 순서, 비정적 데이터 멤버의 선언 순서, 생성자 본문 순서입니다.
-
장점
- 효율성: 멤버가 생성되는 시점에 바로 초기화되므로, 기본 초기화 후 다시 대입하는 과정을 피할 수 있습니다. 생성자 인자로 값을 받는
const멤버와 참조자(reference) 멤버는 본문 대입이 아니라 멤버 초기화 리스트에서 초기화해야 합니다. 유효한 기본 멤버 초기값이 있다면 그 값을 사용할 수도 있습니다. - 명확성: 어떤 멤버 변수가 어떤 값으로 초기화되는지 한눈에 파악하기 쉽습니다.
- 효율성: 멤버가 생성되는 시점에 바로 초기화되므로, 기본 초기화 후 다시 대입하는 과정을 피할 수 있습니다. 생성자 인자로 값을 받는
#include <iostream>
#include <string>
class Student {
private:
std::string name;
int id;
const double GPA; // const 멤버 변수는 반드시 초기화 리스트에서 초기화해야 함
public:
// 멤버 초기화 리스트를 사용한 생성자
Student(std::string n, int i, double g) : name(n), id(i), GPA(g) {
std::cout << "Student 객체 생성: " << name << std::endl;
}
void displayInfo() {
std::cout << "이름: " << name << ", 학번: " << id << ", GPA: " << GPA << std::endl;
}
};
int main() {
Student s1("김민수", 2023001, 3.85);
s1.displayInfo();
// GPA를 목록에서 빠뜨린 별도 생성자를 선언하면 const 멤버를 초기화하지 못해 오류입니다.
return 0;
}이제부터는 특별한 이유가 없는 한 멤버 변수를 초기화할 때 멤버 초기화 리스트를 사용하는 것을 강력히 권장합니다.
소멸자 (Destructor)
소멸자는 클래스 객체의 수명이 끝나는 경계에서 정리를 수행하는 특별한 멤버 함수입니다.
생성자와 반대로, 소멸자의 주된 목적은 객체가 사용하던 리소스(예: 동적으로 할당된 메모리, 파일 핸들, 네트워크 연결 등)를 해제하고 정리하는 것입니다.
자동 저장 기간 객체는 선언된 블록을 벗어날 때 소멸자가 암시적으로 호출됩니다. new 표현식으로 만든 객체는 delete 표현식이 소멸자를 호출한 뒤 저장 공간을 해제하므로, 동적 객체에는 소유권을 끝낼 경로가 반드시 필요합니다.
완전 객체의 형식적인 수명은 소멸자 호출이 시작될 때 끝납니다. 다만 소멸 과정에서는 [class.cdtor]의 특별 규칙에 따라 소멸자 본문이 아직 살아 있는 멤버와 기반 클래스 하위 객체를 사용할 수 있습니다. 각 하위 객체의 수명은 이후 자신의 소멸이 시작되는 시점에 끝나며, 멤버와 기반 클래스는 생성 완료의 역순으로 파괴됩니다.
- 선언 문법에서 클래스 이름 앞에
~(틸드)를 붙입니다. (예:~Book()) - 반환 타입이 없습니다. (심지어
void도 사용하지 않습니다.) - 매개변수를 가질 수 없습니다.
- 일반적인 비템플릿 클래스에서는 보통
~클래스이름()하나만 선언합니다. C++20부터는 클래스 템플릿에서 제약 조건이 다른 후보 소멸자(prospective destructor)를 여러 개 선언할 수 있고, 클래스 정의가 끝날 때 오버로드 해석으로 그 특수화가 사용할 선택된 소멸자(selected destructor) 하나를 결정합니다. public접근 지정자로 선언하는 것이 일반적입니다.
// 클래스 선언 (헤더 파일)
class MyClass {
public:
MyClass(); // 생성자
~MyClass(); // 소멸자
// ... 다른 멤버들 ...
};
// 소멸자 정의 (소스 파일)
MyClass::~MyClass() {
// 리소스 해제 코드
}#include <iostream>
#include <stdexcept>
class MyArray {
private:
int* data; // 동적으로 할당된 정수 배열을 가리키는 포인터
int size;
public:
// 생성자: 동적으로 메모리 할당
MyArray(int s) : data(nullptr), size(s) {
if (size > 0) {
data = new int[size]; // 힙에 size만큼의 int 배열 할당
std::cout << "MyArray 생성자 호출: " << size << "개의 정수 배열 할당됨. 주소: " << data << std::endl;
for (int i = 0; i < size; ++i) {
data[i] = 0; // 초기화
}
} else {
data = nullptr;
size = 0;
std::cout << "MyArray 생성자 호출: 유효하지 않은 크기. 배열 할당되지 않음.\n";
}
}
// raw 포인터를 직접 소유하므로 얕은 복사를 금지합니다.
MyArray(const MyArray&) = delete;
MyArray& operator=(const MyArray&) = delete;
MyArray(MyArray&&) = delete;
MyArray& operator=(MyArray&&) = delete;
// 소멸자: 동적으로 할당된 메모리 해제
~MyArray() {
if (data != nullptr) {
delete[] data; // new[]로 할당한 메모리는 delete[]로 해제
std::cout << "MyArray 소멸자 호출: 할당된 메모리 " << size * sizeof(int) << " 바이트 해제됨.\n";
} else {
std::cout << "MyArray 소멸자 호출: 해제할 메모리가 없습니다.\n";
}
}
// 배열 요소에 접근하는 함수 (안전하게)
int& operator[](int index) { // [] 연산자 오버로딩 (뒤에서 자세히 학습)
if (index < 0 || index >= size) {
throw std::out_of_range("배열 인덱스 범위를 벗어났습니다.");
}
return data[index];
}
void display() {
std::cout << "배열 내용: [";
for (int i = 0; i < size; ++i) {
std::cout << data[i] << (i == size - 1 ? "" : ", ");
}
std::cout << "]\n";
}
};
int main() {
// 1. 스택에 MyArray 객체 생성
std::cout << "--- 스택 객체 생성 시작 ---\n";
MyArray arr1(5); // MyArray 생성자 호출 (5개 정수 배열 할당)
arr1[0] = 10;
arr1[4] = 50;
arr1.display(); // 출력: 배열 내용: [10, 0, 0, 0, 50]
std::cout << "--- 스택 객체 생성 끝 ---\n";
// main 함수 종료 시 arr1의 소멸자가 자동으로 호출되어 메모리 해제
std::cout << "\n--- 힙 객체 생성 시작 ---\n";
// 2. 힙에 MyArray 객체 동적 생성
MyArray* arr2 = new MyArray(3); // MyArray 생성자 호출 (3개 정수 배열 할당)
(*arr2)[0] = 100; // arr2->operator[](0)
arr2->display(); // 출력: 배열 내용: [100, 0, 0]
std::cout << "--- 힙 객체 사용 중 ---\n";
delete arr2; // delete 표현식이 소멸자를 호출한 뒤 객체 저장 공간을 해제
arr2 = nullptr;
std::cout << "--- 힙 객체 소멸 완료 ---\n";
// 범위 벗어난 인덱스 접근 시도 (오류 발생 예시)
// MyArray bad_arr(2);
// bad_arr[10] = 100; // 오류 발생!
return 0; // main 함수 종료 시 arr1의 소멸자가 호출됨
}위 예시에서 MyArray 클래스는 int* data 멤버 변수를 가집니다.
data는 힙에 동적으로 할당된 배열을 가리킵니다.
생성자는 new int[size]를 사용하여 메모리를 할당하고, 소멸자에서 delete[] data를 호출하여 할당된 메모리를 안전하게 해제합니다.
만약 소멸자가 없거나 delete[]를 호출하지 않으면, MyArray 객체가 소멸되더라도 동적으로 할당된 data 배열은 해제되지 않아 메모리 누수가 발생합니다.
소멸자만 추가한 채 컴파일러의 얕은 복사를 허용하면 두 객체가 같은 data를 가리키고 두 번 해제할 수 있습니다. 이 예시는 복사와 이동을 모두 = delete하여 단일 소유 객체로 제한했습니다. 실전에서는 std::vector<int>처럼 자원을 직접 관리하는 멤버에 소유권을 맡겨 Rule of Zero로 설계하는 편이 더 안전합니다.
소멸 직전 data = nullptr로 바꾸더라도 다른 곳에 복사된 포인터가 있다면 그 외부 별칭의 댕글링 상태까지 막을 수는 없습니다. 그래서 곧 끝날 MyArray 내부 포인터를 다시 설정하지 않고, delete arr2 뒤에는 실제로 계속 존재하는 호출자 쪽 포인터 arr2를 nullptr로 바꿨습니다. 또한 소멸자의 std::cout은 호출 시점을 관찰하기 위한 학습용 로그입니다. 스트림 예외가 활성화된 환경에서는 출력이 예외를 던져 암시적으로 noexcept인 소멸자에서 프로그램이 종료될 수 있으므로, 실제 정리 코드에서는 실패 가능한 로깅을 소멸자 밖으로 분리하거나 내부에서 처리해야 합니다.
기본 생성자 (Default Constructor)
클래스에 사용자 선언 생성자(user-declared constructor) 또는 생성자 템플릿이 하나도 없으면, 컴파일러는 매개변수 없는 기본 생성자(Default Constructor)를 암시적으로 선언합니다.
이 생성자는 초기화 목록과 본문이 비어 있는 사용자 작성 기본 생성자와 같은 초기화를 수행합니다. 클래스 타입 멤버는 자신의 기본 생성자를 호출하고 기본 멤버 초기값도 적용되지만, 기본 멤버 초기값이 없는 기본 타입 멤버를 Point pt;처럼 기본 초기화하면 값이 정해지지 않습니다.
- 클래스에 사용자 선언 생성자나 생성자 템플릿이 전혀 없을 때만 암시적으로 선언됩니다.
- 멤버들을 기본 초기화합니다. 기본 멤버 초기값은 적용되고 클래스 타입은 해당 타입의 기본 생성자를 호출합니다.
기본 생성자의 암시적 선언 여부는 생성자를 정의했는가가 아니라 생성자 또는 생성자 템플릿을 사용자가 선언했는가를 기준으로 판단합니다. 빈 상태를 허용할지에 따라 = default 또는 = delete로 의도를 명시할 수도 있습니다.
#include <iostream>
#include <string>
class Point {
public:
int x{0};
int y{0};
// 사용자 선언 생성자가 없음 -> Point()가 암시적으로 선언됨
};
class RequiredPoint {
public:
RequiredPoint(int x, int y) : x(x), y(y) {}
int x;
int y;
};
class Line {
public:
Point p1; // Point 객체 (기본 생성자 호출됨)
Point p2;
// 사용자 선언 생성자가 없음 -> Line()이 암시적으로 선언됨
};
int main() {
Point pt; // 암시적으로 선언된 Point() 기본 생성자 호출
std::cout << "Point.x: " << pt.x << ", Point.y: " << pt.y << std::endl; // 0, 0
RequiredPoint required(10, 20);
std::cout << "RequiredPoint.x: " << required.x << std::endl;
// RequiredPoint missing; // 오류: 호출할 기본 생성자가 없음
Line l; // 암시적으로 선언된 Line(), 이어서 p1과 p2의 Point() 호출
std::cout << "Line.p1.x: " << l.p1.x << ", Line.p1.y: " << l.p1.y << std::endl;
return 0;
}만약 클래스에 매개변수를 받는 생성자를 하나라도 사용자 선언했다면, 컴파일러는 더 이상 기본 생성자를 암시적으로 선언하지 않습니다.
이 경우 매개변수가 없는 클래스이름() 형식의 객체 생성이 필요하다면, 프로그래머가 기본 생성자를 직접 선언해 정의하거나 = default해야 합니다.
규칙 3 (Rule of Three / Five / Zero) (심화)
Rule of Three/Five/Zero는 컴파일러가 강제하는 문법 규칙이 아니라, 복사·이동·소멸 의미를 일관되게 설계하기 위한 지침입니다.
C++ · ownership · decision tree
직접 소유하는 순간 복사·이동·소멸은 같은 정책이 된다
먼저 클래스가 raw 자원을 직접 소유하는지 묻습니다. 아니라면 멤버 타입의 안전한 기본 동작을 따르고, 맞다면 깊은 복사, 복사 금지, 이동 이전 가운데 일관된 정책을 명시합니다.
01 · prefer zero
raw 자원을 직접 소유하지 않으면 Rule of Zero를 우선한다
std::vector, std::string, 스마트 포인터처럼 자원을 관리하는 멤버에 소유권을 맡깁니다. 바깥 클래스의 복사와 이동 가능 여부는 그 멤버 정책을 따릅니다.
02 · direct ownership
직접 소유하면 Rule of Three부터 세 연산을 함께 검토한다
소멸자, 복사 생성자, 복사 대입 연산자의 의미를 한 번에 정합니다. 값 복사가 의미 있으면 깊은 복사를 구현하고, 아니면 복사 연산을 = delete합니다.
03 · move policy
소유권 이전이 필요하면 Rule of Five로 확장한다
사용자 정의 이동 생성자와 이동 대입은 원본을 최소한 안전하게 소멸할 수 있는 문서화된 상태로 남겨야 합니다. 표준 라이브러리 타입은 달리 명시되지 않으면 유효하지만 값은 미지정인 상태를 보장합니다. 이동을 제공하지 않을 때도 생략 또는 = delete가 호출 결과에 미치는 영향을 확인합니다.
Rule of Zero/Three/Five는 언어가 강제하는 문법이 아니라 일관된 소유권을 위한 설계 지침입니다. 사용자 정의 타입의 이동 후 상태는 구현자가 지켜야 할 계약이며 자동 보장이 아닙니다. 표준 라이브러리 타입에는 유효하지만 값은 미지정인 기본 상태 규칙이 적용됩니다. 실제 암시적 선언과 삭제 여부는 각 특별 멤버 함수의 C++ 언어 규칙을 따릅니다.
클래스가 동적으로 할당된 메모리나 파일 핸들 같은 자원(Resource)을 직접 소유한다면, 소멸자와 복사·이동 특별 멤버 함수의 의미를 함께 검토해야 합니다. 멤버별 동작이 원하는 소유권 의미와 같을 때만 = default를 사용하고, 지원하지 않을 동작은 = delete로 막습니다.
소멸자, 복사 생성자, 복사 대입 연산자 중 하나를 직접 선언해야 한다면 나머지 둘도 함께 검토하는 지침을 규칙 3(Rule of Three)이라고 합니다.
소멸자 (Destructor): 자원 해제
복사 생성자 (Copy Constructor): 객체 복사 시 깊은 복사(Deep Copy)를 구현하거나 = delete
복사 대입 연산자 (Copy Assignment Operator): 객체 대입 시 깊은 복사를 구현하거나 = delete
C++11 이후 이동을 지원하는 직접 소유 타입은 이동 생성자(Move Constructor)와 이동 대입 연산자(Move Assignment Operator)까지 함께 검토하므로 규칙 5(Rule of Five)라고 부릅니다. 사용자 선언 소멸자나 복사 연산은 암시적 이동 연산의 생성에도 영향을 주므로, 이동을 제공할지 명시적으로 결정해야 합니다.
현대 C++에서는 문자열, 컨테이너, 스마트 포인터처럼 스스로 자원을 관리하는 멤버에 소유권을 맡기고 특별 멤버 함수를 직접 선언하지 않는 규칙 0(Rule of Zero)를 우선합니다. 이때 바깥 클래스의 복사·이동 가능 여부는 멤버 타입의 정책을 그대로 따릅니다. 예를 들어 std::unique_ptr 멤버가 있으면 기본 복사는 허용되지 않습니다.
이 내용은 10장의 ‘Rule of Three, Five, Zero 실전’에서 실제 코드로 더 자세히 다룹니다.
지금은 생성자와 소멸자의 중요성을 이해하는 것에 집중하세요.
객체 수명은 생성, 사용, 정리, 복사/이동 정책이 한 번에 맞물려야 안전해집니다.
직접 자원을 소유하는 순간에는 생성자와 소멸자만 보지 말고 복사와 이동까지 같은 계약으로 읽어야 합니다.