이 글은 『Effective C++』를 읽고 개인적으로 공부한 내용을 정리한 기록입니다.
저는 컴퓨터공학을 전공하지 않았으며, 프로그래밍을 공부하는 과정에서의 이해와 생각을 정리하기 위해 글을 작성하고 있습니다.
따라서 내용 중 일부에 오류나 부정확한 설명이 있을 수 있으며, 피드백은 언제든지 환영합니다. 확인 후 수정하도록 하겠습니다.
전문적인 해설이 아닌 개인적 시선에서의 정리임을 참고하고 읽어주시면 감사하겠습니다.
[Effective C++ 정리 #4] 객체는 반드시 초기화 후 사용하라 — 예측 불가능한 버그의 시작을 차단하는 습관
C++은 객체가 자동으로 초기화된다는 보장을 하지 않습니다. 특히 int나 double 같은 빌트인 타입은 초기화를 생략하면 쓰레기 값이 남아 있게 되고, 이를 사용하면 예측할 수 없는 버그로 이어질 수 있습니다.
초기화되지 않은 객체가 야기하는 문제
int x; // 초기화되지 않음. 값은 알 수 없음.
Point p; // 사용자 정의 타입도 멤버마다 다름
이처럼 명시적인 초기화를 하지 않으면, 메모리 상에 남아 있던 이전 값이 할당되기 때문에 정상적인 동작을 보장할 수 없습니다.
C++에서 초기화가 복잡한 이유
C++은 C의 특성을 유지하면서 객체 지향을 도입했기 때문에, built-in type과 class type에 따라 초기화 방식이 달라집니다. 예를 들어 std::string은 생성자에서 자동 초기화되지만, int는 명시적으로 초기화를 하지 않으면 메모리 쓰레기 값이 들어갑니다.
명시적으로 초기화하라
int x = 0;
double pi = 3.14;
const char* greeting = "Hello";
std::cin >> x; // 입력을 통해 초기화하는 것도 포함
초기화를 명시하는 습관을 들이면, 의도치 않은 값을 막고 프로그램 안정성을 높일 수 있습니다.
생성자에서는 멤버 초기화 리스트를 사용하라
다음처럼 생성자 내부에서 값을 대입하는 방식은 초기화가 아니라 할당입니다.
class PhoneNumber {...};
class ABEntry{
public:
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones);
private:
std::string theName;
std::string theAddress;
std::list thePhones;
int numTimesConsulted;
}
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones) {
theName = name;
theAddress = address;
thePhones = phones;
numTimesConsulted = 0;
}
이 경우, theName, theAddress, thePhones는 생성자 본문에 들어오기 전 이미 디폴트 생성자가 호출됩니다. 그 후에 다시 값을 할당하게 되므로 **불필요한 복사 비용**이 발생합니다.
멤버 초기화 리스트의 올바른 사용 예
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
: theName(name),
theAddress(address),
thePhones(phones),
numTimesConsulted(0)
{}
이렇게 작성하면 복사 생성자만 호출되고, 불필요한 디폴트 생성과 대입이 생략됩니다.
디폴트 생성자도 명시적으로 초기화하라
ABEntry::ABEntry()
: theName(), theAddress(), thePhones(), numTimesConsulted(0)
{}
생성자가 인자를 받지 않더라도, 멤버 초기화 리스트를 통해 각 멤버가 어떻게 초기화되는지 명시하는 것이 좋습니다. 기본 생성자로 초기화된다는 걸 컴파일러가 해주기도 하지만, 개발자가 의도적으로 초기화하지 않으면 실수로 누락되는 경우가 많습니다.
특히 빌트인 타입인 int, double 등은 디폴트 생성 시 초기화되지 않아, 이전 메모리의 쓰레기 값이 그대로 들어가게 됩니다. 따라서 numTimesConsulted와 같은 멤버는 0과 같이 의도된 값으로 명시적으로 초기화해야 합니다.
또한 모든 멤버를 초기화 리스트에 일괄적으로 나열하면, 추후 클래스에 새로운 멤버 변수가 추가되었을 때도 초기화 누락을 한눈에 파악할 수 있고, 초기값 변경 시 일관성 있게 수정할 수 있어 유지보수에 유리합니다.
결국 이 습관은 단순히 “코드를 깔끔하게 쓰는 것” 이상의 효과가 있으며, 버그를 미리 막고, 협업 시 의도를 명확하게 전달하는 데도 도움이 됩니다.
초기화 리스트가 필수인 경우
다음과 같은 멤버는 반드시 초기화 리스트에서 초기화해야 합니다.
const멤버reference타입 멤버
이들은 생성 이후에는 값을 바꿀 수 없기 때문에, 생성자 본문에서 대입하는 방식으로는 초기화가 불가능합니다.
정적 전역 객체의 초기화 순서 문제
C++에서는 여러 개의 전역(static) 객체가 서로 다른 소스 파일에 정의되어 있을 때, 이 객체들이 어떤 순서로 초기화될지는 **컴파일러가 보장해주지 않습니다**.
이 문제는 객체들이 서로에게 의존성을 가질 경우 큰 위험을 초래합니다. 아직 초기화되지 않은 객체를 참조하게 되면, 프로그램은 정의되지 않은 동작을 일으킬 수 있기 때문입니다.
다음은 두 객체가 서로 다른 소스 파일에 정의되어 있고, 한 객체가 다른 객체를 내부에서 참조하고 있는 상황을 보여줍니다.
// FileSystem.h
class FileSystem {
public:
std::string getTempDir() const;
};
// Directory.h
#include "FileSystem.h"
class Directory {
public:
Directory(const std::string& path);
};
// Directory.cpp
#include "Directory.h"
extern FileSystem tfs;
Directory::Directory(const std::string& path) {
std::string tempPath = tfs.getTempDir(); // tfs를 참조
// ...
}
Directory 클래스는 생성자 내부에서 tfs 객체의 getTempDir() 함수를 호출합니다. 즉, Directory는 FileSystem에 의존하고 있습니다.
이제 이 전역 객체들을 아래와 같이 서로 다른 파일에서 정의해봅시다.
// FileSystem.cpp
#include "FileSystem.h"
FileSystem tfs; // 전역 객체 정의
// DirectoryInstance.cpp
#include "Directory.h"
Directory tempDir("/tmp"); // 생성 시 tfs 참조
이 코드에는 큰 문제가 숨어 있습니다. tempDir이 정의되는 시점에 tfs가 **아직 초기화되지 않았을 수도** 있기 때문입니다.
즉, 두 객체의 초기화 순서가 컴파일러 구현에 따라 달라질 수 있고, 예상과 다를 수 있습니다. 이처럼 명시적 의존 관계가 있음에도 초기화 순서를 보장하지 않는 것은 C++의 큰 함정 중 하나입니다.
실제로 이 문제는 프로젝트가 커지고 전역 객체가 많아질수록 자주 발생하며, 디버깅 또한 매우 어렵기 때문에 반드시 안전한 대안이 필요합니다.
로컬 static 객체를 이용한 안전한 초기화
C++에서는 함수 내부에 정의된 static 지역 객체는 해당 함수가 처음 호출될 때 단 한 번만 초기화됩니다. 이 특성을 활용하면, 앞서 언급한 전역 객체 간의 초기화 순서를 명확하게 제어할 수 있습니다.
FileSystem& tfs() {
static FileSystem fs;
return fs;
}
Directory& tempDir() {
static Directory td;
return td;
}
위와 같이 함수를 통해 객체를 감싸고, static 지역 객체로 선언하면 그 객체는 해당 함수가 실제로 호출되는 시점에 딱 한 번만 생성됩니다. 따라서 tempDir()에서 tfs()를 참조한다고 해도, tfs()가 먼저 호출되기만 하면 반드시 그 안의 fs 객체는 생성된 상태가 됩니다.
이 방식은 C++11 이후부터는 thread-safe하게 보장되기 때문에, 멀티스레드 환경에서도 안전하게 사용할 수 있습니다.
실제로 많은 라이브러리와 프레임워크에서도 Singleton 패턴이나 Factory 객체 등을 구현할 때 이 방식을 채택하고 있습니다.
핵심 요약
- C++이 자체적으로 초기화하는 경우는 일부이기 때문에, built-in type은 명시적으로 초기화한다
- 생성자에서는 멤버 초기화 리스트를 습관화하라
- 전역 객체의 초기화 순서를 피하기 위해 로컬 static 객체를 사용해서 초기화 순서 문제를 방지한다
감사합니다.
'프로그래밍 > C\C++' 카테고리의 다른 글
| [Effective C++ 정리 #6] 복사 금지! 컴파일러가 몰래 만든 함수 차단 방법 (0) | 2025.06.16 |
|---|---|
| [Effective C++ 정리 #5] 컴파일러가 몰래 생성하는 특별 함수들 (6) | 2025.06.15 |
| [Effective C++ 정리 #3] const — 타입 안정성과 효율성의 시작점 (4) | 2025.06.15 |
| [Effective C++ 정리 #2] #define 대신 const, enum, inline으로 대체하는 이유 (0) | 2025.06.14 |
| [Effective C++ 정리 #1] C++는 하나의 언어가 아니다 (6) | 2025.06.13 |