Sonji-log
[Effective C++] Cp 4. 객체를 사용하기 전 반드시 초기화 본문
1. 왜 초기화가 중요한가
초기화되지 않은 객체를 쓰면 버그가 바로 드러날 때도 있고, 꽤 잘 재현되는 것처럼 보일 때도 있다.
문제는 그 재현성이 신뢰할 만하지 않다는 점이다. 빌드 옵션이나 코드 배치, 실행 환경이 바뀌면 증상이 달라지거나 갑자기 사라질 수 있다.
핵심은 단순하다.
- 초기화되지 않은 값은 믿을 수 없다.
- 컴파일러가 항상 자동으로 올바른 초기화를 해주지 않는다.
특히 기본 타입(int, double, 포인터 등)은 상황에 따라 초기화가 되기도 하고 안 되기도 한다.
int x; // 초기화되지 않음 (값 미정)
double y; // 초기화되지 않음 (값 미정)
int* p; // 초기화되지 않음 (쓰레기 주소 가능)
이 상태에서 값을 읽으면 동작이 예측 불가능해진다.
2. "자동으로 초기화될 것"이라는 기대를 버리자
2-1. 기본 타입과 사용자 정의 타입의 차이
사용자 정의 타입(클래스)은 생성자가 호출되면서 대체로 초기화가 진행된다.
심지어 아예 생성자를 명시적으로 적지 않더라도, 컴파일러가 디폴트 생성자를 삽입해서 런타임에 초기화되도록 최적화를 지원하기도 한다.
반면 기본 타입은 직접 값을 주지 않으면 그대로 남는다.
class Point
{
private:
int x;
int y;
public:
Point() : x(0), y(0) {}
};
Point pt; // 생성자 호출로 초기화
int n; // 초기화되지 않음
그래서 "클래스니까 괜찮겠지", "지역 변수니까 0이겠지" 같은 가정은 매우 위험하다.사실 이런 가정 자체가 말이 안된다. 해서도 안된다.
2-2. 안전한 기본 습관
값이 필요한 변수는 선언과 동시에 초기화한다.
int n = 0;
double ratio = 0.0;
int* p = nullptr;
이 습관 하나로 디버깅 시간을 크게 줄일 수 있다.
3. 생성자 본문 대입보다 초기화 리스트를 우선하자
3-1. 본문 대입은 "초기화"가 아니라 "대입"이다
아래 코드는 얼핏 정상처럼 보이지만, 사실 "초기화"가 아니라 "기본 생성 후 대입"이다.
class PhoneNumber
{
public:
PhoneNumber() {}
};
class ABEntry
{
private:
std::string theName;
std::string theAddress;
std::list<PhoneNumber> thePhones;
int numTimesConsulted;
public:
ABEntry(const std::string& name,
const std::string& address,
const std::list<PhoneNumber>& phones)
{
this->theName = name; // 이미 기본 생성된 뒤 대입
this->theAddress = address; // 이미 기본 생성된 뒤 대입
this->thePhones = phones; // 이미 기본 생성된 뒤 대입
this->numTimesConsulted = 0;
}
};
멤버들은 생성자 본문이 실행되기 전에 먼저 생성된다.
즉 위 코드는 "기본 생성 + 대입" 2단계를 거치므로 불필요한 작업이 생길 수 있다.
3-2. 초기화 리스트가 정석
class ABEntry
{
private:
std::string theName;
std::string theAddress;
std::list<PhoneNumber> thePhones;
int numTimesConsulted;
public:
ABEntry(const std::string& name,
const std::string& address,
const std::list<PhoneNumber>& phones)
: theName(name),
theAddress(address),
thePhones(phones),
numTimesConsulted(0)
{}
};
이 방식은 멤버를 "처음부터 원하는 값으로" 만들기 때문에 더 자연스럽고, 대체로 더 효율적이다.
3-3. const 멤버와 reference 멤버는 초기화 리스트가 필수
const와 참조(&) 멤버는 생성 후 재대입이 불가능하다.
그래서 생성자 본문 대입 방식은 아예 동작하지 않는다.
class Widget
{
private:
const int id;
std::string& name;
public:
Widget(int id, std::string& name)
: id(id), name(name) // 필수
{}
};
4. 멤버 초기화 순서는 "리스트 순서"가 아니라 "선언 순서"
많이 헷갈리는 부분이다.
초기화는 생성자 초기화 리스트에 적은 순서가 아니라, 클래스에 선언된 순서로 실행된다.
class Example
{
private:
int a;
int b;
public:
Example()
: b(2), a(b) // 이렇게 써도 실제 초기화는 a -> b 순서
{}
};
위 코드에서 a(b)는 의도와 다르게 동작할 수 있다. a가 먼저 초기화되기 때문이다.
안전한 규칙은 아래 한 줄로 정리된다.
- 멤버 선언 순서와 초기화 리스트 순서를 동일하게 맞춘다.
코드 리뷰에서도 이 규칙을 강하게 지키면 초기화 관련 버그를 많이 줄일 수 있다.
5. 비지역 정적 객체의 초기화 순서 문제
5-1. 문제의 핵심
서로 다른 소스 파일(번역 단위)에 있는 비지역 정적 객체(non-local static object)들은
초기화 순서가 보장되지 않는다.
예를 들면:
FileA.cpp의 정적 객체AFileB.cpp의 정적 객체B
A 생성자에서 B를 사용하는데, 실행 시점에 B가 아직 초기화되지 않았을 수 있다.
이게 유명한 정적 초기화 순서 문제(static initialization order fiasco) 다.
5-2. 왜 위험한가
이 버그는 빌드 옵션, 링크 순서, 플랫폼에 따라 재현 여부가 달라진다.
즉 "내 PC에서는 잘 됨"이 가장 쉽게 나오는 유형이다.
6. 해결책: 함수 지역 정적 객체(Construct on First Use)
가장 널리 쓰는 해결책은 "전역처럼 쓰고 싶은 객체를 함수 안 static 지역 변수로 만들고, 그 함수를 통해서만 접근하는 방식"이다.
필요할 때 처음 생성되도록 만들어 초기화 순서 의존을 줄이는 방식이다.
즉 "프로그램 시작 시점에 무조건 생성"이 아니라, "처음 필요해지는 순간 생성"되게 바꾸는 방식이다.
class FileSystem
{
public:
std::size_t numDisks() const;
};
FileSystem& tfs()
{
static FileSystem fs; // 최초 호출 시 1회 초기화
return fs;
}
class Directory
{
public:
Directory()
{
std::size_t disks = tfs().numDisks();
}
};
이 패턴의 장점:
- 객체가 실제로 필요할 때 초기화된다.
- 번역 단위 간 정적 초기화 순서 의존을 피하기 쉽다.
전역 상태가 필요하다면, 우선 이 패턴으로 시작하는 편이 안전하다.
7. 핵심 정리
반드시 기억할 것
- 객체는 사용 전에 항상 초기화한다.
- 생성자에서는 멤버 대입보다 초기화 리스트를 우선한다.
- 멤버 초기화 순서는 선언 순서로 결정된다.
- 번역 단위가 다른 비지역 정적 객체끼리의 초기화 순서는 믿지 않는다.
- 전역 정적 의존은 함수 지역 정적(Construct on First Use)으로 완화한다.
