C++ · 기본
C++ 객체와 자원 관리
C++ 생성자와 소멸자 - 객체가 태어나고 죽는 순서, 멤버 초기화 목록 (C++ 객체와 자원 관리 2장)
생성·소멸 순간을 찍어 지역·멤버·임시·전역·힙 객체의 수명 순서를 확인한다. 멤버 초기화 순서 함정과 explicit 도 다룬다.
개발자 · 원고 갱신
이 장에서 배우는 것
C에서는 구조체를 만든 뒤 init_motor(&m)을 부르고, 다 쓰면 destroy_motor(&m)을 부른다. 둘 중 하나를 빼먹어도 컴파일러는 아무 말도 하지 않는다. C++의 클래스는 이 두 함수를 생성자와 소멸자라는 이름으로 언어에 넣었고, 부르는 시점을 컴파일러가 정한다. 이 장에서는 그 시점을 눈으로 확인한다.
- 생성자와 소멸자가 불리는 순간을 출력으로 찍어, 지역·멤버·반환값·임시·전역·힙 객체의 수명 순서를 확인한다.
- 멤버 초기화 목록을 쓰는 이유와, 적힌 순서가 아니라 선언된 순서로 초기화되는 함정을 본다.
explicit,private멤버, 클래스 불변식(invariant)이라는 생각을 익힌다.
문제 상황
이동 로봇에 모터 두 개가 있다. 모터 객체가 만들어질 때 드라이버를 켜고, 없어질 때 반드시 전원을 끊어야 한다. 전원을 끊지 않고 프로그램이 끝나면 모터가 마지막 속도로 계속 돌 수 있다. C 방식으로는 모든 종료 경로에 motor_off()를 넣어야 한다. 함수가 커지고 return이 늘어날수록 하나쯤은 빠진다.
C++에서는 "만들 때 켜고, 없어질 때 끈다"를 클래스 안에 한 번만 적는다. 그런데 이걸 믿고 쓰려면 "없어질 때"가 정확히 언제인지 알아야 한다. 블록이 끝날 때인가, 함수가 끝날 때인가, 멤버는 본체보다 먼저 죽는가 나중에 죽는가? 이 장의 코드는 그 순서를 전부 출력한다.
완성 코드
lifetime.cpp로 저장한다. 실제 드라이버 대신 출력으로 생성·소멸 시점을 보여 준다.
#include <iostream>
#include <string>
#include <utility>
class Motor {
public:
explicit Motor(std::string name) : name_(std::move(name)) {
std::cout << " [생성] " << name_ << '\n';
}
~Motor() { std::cout << " [소멸] " << name_ << '\n'; }
const std::string& name() const { return name_; }
private:
std::string name_;
};
class Robot {
public:
Robot() : left_("왼쪽 모터"), right_("오른쪽 모터") {
std::cout << " [생성] 로봇 본체\n";
}
~Robot() { std::cout << " [소멸] 로봇 본체\n"; }
private:
Motor left_;
Motor right_;
};
Motor make_spare() { return Motor("예비 모터"); }
Motor global_motor("전역 모터");
int main() {
std::cout << "main 시작\n";
std::cout << "1) 블록 안 지역 객체\n";
{
Motor a("A");
Motor b("B");
std::cout << " 블록 끝\n";
}
std::cout << "2) 멤버를 가진 객체\n";
{
Robot robot;
}
std::cout << "3) 함수가 돌려준 객체\n";
{
Motor spare = make_spare();
std::cout << " 받은 것: " << spare.name() << '\n';
}
std::cout << "4) 임시 객체\n";
std::size_t length = Motor("임시").name().size();
std::cout << " 이름 바이트 수: " << length << '\n';
std::cout << "5) new 와 delete\n";
Motor* heap = new Motor("힙 모터");
delete heap;
std::cout << "main 끝\n";
}
줄별 해설
explicit Motor(std::string name) — 생성자
클래스 이름과 같고 반환 타입이 없는 함수가 생성자다. 객체가 메모리에 자리를 잡은 직후 딱 한 번 불린다. 앞에 붙은 explicit은 "암묵적 변환에 쓰지 말라"는 표시다. 이것이 없으면 Motor m = std::string("A");나, Motor를 받는 함수에 문자열을 넘기는 코드가 몰래 Motor를 만들어 버린다. 인자가 하나인 생성자에는 기본으로 explicit을 붙인다고 기억하면 된다.
: name_(std::move(name)) — 멤버 초기화 목록
콜론 뒤는 본문이 실행되기 전에 멤버를 초기화하는 자리다. 본문 안에서 name_ = name;이라고 쓰면 name_을 빈 문자열로 먼저 만든 뒤 다시 대입하므로 일을 두 번 한다. 참조 멤버와 const 멤버는 대입이 불가능해서 반드시 초기화 목록에서 값을 줘야 한다. std::move는 값으로 받은 매개변수의 내용물을 멤버로 옮기라는 뜻이다(4장에서 자세히 본다).
~Motor() — 소멸자
물결표(~)가 붙은 함수가 소멸자다. 인자도 반환값도 없고 클래스마다 하나뿐이다. 객체의 수명이 끝나는 순간 컴파일러가 자동으로 부른다. 직접 부르는 일은 거의 없다. 여기서 모터 전원을 끄면, 어떤 경로로 객체가 사라지든 전원이 꺼진다.
private:와 뒤에 붙은 밑줄
name_은 private이라 클래스 밖에서 직접 바꿀 수 없다. 바깥에서는 name()이라는 const 멤버 함수로 읽기만 한다. 이렇게 하면 "모터 이름은 만들 때 정해지고 바뀌지 않는다" 같은 규칙, 즉 불변식을 클래스가 지킬 수 있다. 멤버 이름 뒤의 밑줄은 흔한 관례일 뿐 문법은 아니다.
class Robot — 멤버를 가진 객체
Robot은 Motor 두 개를 멤버로 가진다. Robot의 생성자 본문이 실행되기 전에 멤버들이 먼저 만들어지고, Robot의 소멸자 본문이 끝난 뒤 멤버들이 정리된다. 본체 입장에서 보면 "내 부품은 내가 일을 시작하기 전에 준비되어 있고, 내가 일을 끝낸 뒤에 치워진다"는 보장이다.
Motor make_spare() — 값으로 돌려주기
함수가 객체를 값으로 돌려주면 옛날에는 "함수 안에서 한 번 만들고, 밖으로 복사하고, 안의 것을 지우는" 과정이 걱정거리였다. C++17부터는 return Motor(...)처럼 임시 객체를 바로 돌려주는 경우 복사 생략(copy elision)이 보장된다. 객체는 처음부터 호출한 쪽의 spare 자리에 한 번만 만들어진다.
Motor global_motor("전역 모터");
함수 밖에 선언한 전역 객체는 main이 시작하기 전에 만들어지고 main이 끝난 뒤에 정리된다. 여러 소스 파일에 흩어진 전역 객체끼리의 생성 순서는 정해져 있지 않아서, 전역 객체가 다른 파일의 전역 객체를 쓰면 위험하다. 실무에서는 전역 객체를 최소로 줄인다.
실제 실행 결과
$ clang++ -std=c++20 -Wall -Wextra lifetime.cpp -o lifetime && ./lifetime
[생성] 전역 모터
main 시작
1) 블록 안 지역 객체
[생성] A
[생성] B
블록 끝
[소멸] B
[소멸] A
2) 멤버를 가진 객체
[생성] 왼쪽 모터
[생성] 오른쪽 모터
[생성] 로봇 본체
[소멸] 로봇 본체
[소멸] 오른쪽 모터
[소멸] 왼쪽 모터
3) 함수가 돌려준 객체
[생성] 예비 모터
받은 것: 예비 모터
[소멸] 예비 모터
4) 임시 객체
[생성] 임시
[소멸] 임시
이름 바이트 수: 6
5) new 와 delete
[생성] 힙 모터
[소멸] 힙 모터
main 끝
[소멸] 전역 모터
출력에서 규칙을 뽑아 보면 다음과 같다.
표 2-1. 객체 종류별 생성·소멸 시점(lifetime.cpp 실행 결과)
| 상황 | 생성 시점 | 소멸 시점 |
|---|---|---|
| 블록 안 지역 객체 | 선언한 줄 | 블록의 닫는 중괄호, 만든 역순(B 다음 A) |
| 멤버 | 본체 생성자 본문보다 먼저, 선언 순서대로 | 본체 소멸자 본문 뒤, 선언 역순 |
| 값으로 받은 반환 객체 | 함수 안에서 한 번(복사 없음) | 받은 변수의 블록 끝 |
| 이름 없는 임시 객체 | 그 식을 계산할 때 | 그 식(세미콜론까지)이 끝날 때 |
| 전역 객체 | main 전 | main 후 |
new로 만든 객체 | new 할 때 | delete 할 때. 안 하면 영원히 안 불린다 |
"역순"은 우연이 아니다. 나중에 만든 객체가 먼저 만든 객체를 쓰고 있을 수 있으니(B가 A를 참조하는 경우) 나중 것부터 치워야 안전하다. 스택에 쌓인 접시를 위에서부터 치우는 것과 같다. 임시 객체의 줄에서 [소멸] 임시가 "이름 바이트 수" 출력보다 먼저 나온 것도 확인하자. length에 숫자를 복사해 둔 뒤 임시 객체가 사라졌기 때문에 안전하다. 반대로 임시 객체 안의 문자열을 참조로 붙잡아 두었다면 매달린 참조가 된다(8장).
표 마지막 줄이 이 책 전체의 출발점이다. 스택 객체의 정리는 컴파일러가 책임지지만 new로 만든 객체의 정리는 프로그래머 책임이다. 다음 장부터는 이 책임을 다시 컴파일러에게 넘기는 방법을 배운다.
실무에서 자주 틀리는 것
1. 초기화 목록 순서를 믿는다
멤버는 초기화 목록에 적힌 순서가 아니라 클래스 안에 선언된 순서로 초기화된다. 다음 코드는 size_를 먼저 채우고 그 값으로 capacity_를 계산하려고 했다.
#include <iostream>
class Ring {
public:
explicit Ring(int size) : size_(size), capacity_(size_ * 2) {}
int capacity() const { return capacity_; }
private:
int capacity_;
int size_;
};
int main() {
Ring ring(8);
std::cout << ring.capacity() << '\n';
}
$ clang++ -std=c++20 -Wall -Wextra init_order.cpp -o init_order && ./init_order
init_order.cpp:5:31: warning: field 'size_' will be initialized after field 'capacity_' [-Wreorder-ctor]
5 | explicit Ring(int size) : size_(size), capacity_(size_ * 2) {}
| ^~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~
| capacity_(size_ * 2) size_(size)
init_order.cpp:5:54: warning: field 'size_' is uninitialized when used here [-Wuninitialized]
5 | explicit Ring(int size) : size_(size), capacity_(size_ * 2) {}
| ^
2 warnings generated.
2
capacity_가 먼저 선언되어 있으므로 먼저 초기화되고, 그때 size_는 아직 쓰레기 값이다. 기대한 16이 아니라 2가 나왔다. 이 값은 실행할 때마다 달라질 수 있다. clang은 -Wall에서 두 가지 경고(순서가 다르다, 초기화 전에 쓴다)를 정확히 냈다. 해결은 선언 순서를 의존 순서에 맞추거나, capacity_(size * 2)처럼 멤버 대신 매개변수를 쓰는 것이다.
표 2-2. 초기화 목록에서만 초기화할 수 있는 멤버
| 멤버 종류 | 본문에서 대입하면 | 초기화 목록에서 |
|---|---|---|
참조 멤버 T& | 컴파일 오류 | 대상을 묶는다 |
const 멤버 | 컴파일 오류 | 값을 정한다 |
| 기본 생성자가 없는 타입 | 컴파일 오류 | 인자를 넘겨 만든다 |
std::string 등 일반 멤버 | 빈 값 생성 후 다시 대입(두 번 일) | 한 번에 만든다 |
2. 소멸자에서 실패할 수 있는 일을 한다
소멸자에서 예외가 밖으로 나가면 프로그램이 종료된다(9장). 파일 닫기처럼 실패할 수 있는 정리 작업은 소멸자 안에서 오류를 기록만 하고 삼키거나, 실패를 알아야 한다면 close() 같은 명시적 함수를 따로 제공한다.
3. 생성자에서 반쯤 만든 객체를 남긴다
생성자에 반환값이 없으니 실패를 알릴 방법이 없다고 생각하고 bool init()을 따로 만드는 경우가 많다. 그러면 "만들어졌지만 아직 쓸 수 없는 객체"라는 상태가 생기고, 모든 멤버 함수가 그 상태를 검사해야 한다. C++에서는 생성자에서 예외를 던지는 것이 기본 방법이다. 생성자가 끝까지 실행되지 못하면 객체는 태어나지 않은 것으로 취급되고 소멸자도 불리지 않는다(3장 마지막 예제).
4. 인자 하나짜리 생성자에 explicit을 빼먹는다
class Timeout { public: Timeout(int ms); };라면 void wait(Timeout t)에 wait(5)가 통과한다. 5가 초인지 밀리초인지 읽는 사람은 모른다. explicit을 붙이면 wait(Timeout(5))처럼 의도를 드러내야만 컴파일된다.
연습 문제
- 팔 객체
Arm이Part wrist{"손목"};,Part elbow{"팔꿈치"};순서로 멤버를 선언했다.Arm의 생성자와 소멸자도 각각 "+팔", "-팔"을 기록한다.{ Arm arm; }을 실행했을 때 기록되는 순서를 써라. Part make() { return Part("x"); }를Part p = make();로 받으면 생성자와 소멸자는 각각 몇 번 불리는가? C++17 이전 컴파일러와 무엇이 다른가?{ Part* p = new Part("누수"); }블록을 나오면 소멸자가 불리는가? 불리게 하려면 어떻게 해야 하는가? 가장 좋은 방법은 무엇인가?
정답
+손목 +팔꿈치 +팔 -팔 -팔꿈치 -손목. 멤버가 선언 순서대로 먼저 만들어지고, 본체 생성자 본문이 마지막에 실행된다. 정리는 정확히 역순이다. 검증 프로그램으로 이 순서를 확인했다.- 생성 1번, 소멸 1번이다. C++17부터는
return에 임시 객체를 쓰는 경우 복사·이동 없이 호출한 쪽의 변수 자리에 바로 만든다. 그 이전 표준에서는 이론상 복사(또는 이동)가 두 번까지 일어날 수 있었고, 컴파일러가 최적화로 없애 주기를 기대하는 수밖에 없었다. 그래서 C++17 이전에는 복사 생성자가 없는 타입을 이렇게 돌려줄 수 없었다. - 불리지 않는다. 블록을 나가면 포인터 변수
p만 사라지고,p가 가리키던 힙 객체는 남는다(누수). 블록 끝에delete p;를 쓰면 불리지만, 중간에return이나 예외가 생기면 또 빠진다. 가장 좋은 방법은 5장의auto p = std::make_unique<Part>("누수");처럼 소유 객체에 맡겨서, 블록이 끝나면 소멸자가 자동으로 불리게 하는 것이다.
다음 장에서는 이 장에서 확인한 "블록이 끝나면 반드시 소멸자가 불린다"는 규칙 하나로 파일, 뮤텍스 같은 자원 누수를 없애는 RAII를 만든다.
READER FEEDBACK
질문·오탈자·의견
내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.