- 기본제공 타입의 객체는 직접 초기화한다. 경우에 따라 저절로 되기도 하지만 그렇지 않을 수 있다.
- 생성자에서는 초기화 리스트를 사용하여 초기화하자. 나열 순서는 선언된 순서를 지키자.
- 비지역 정적 객체를 지역 정적 객체로 바꾸어 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계하자.
올바른 초기화
C++에서 객체의 값을 초기화하는 것은 상황에 따라 다르게 동작한다.
초기화되지 않은 값을 읽도록 내버려 두면 정의되지 않은 동작이 발생할 수 있다.
하지만, C++의 객체 초기화는 규칙이 명확히 있으니 올바르게 사용하자.
C++의 C 부분만을 쓰고 있으며 초기화에 런타임 비용이 소모될 수 있는 상황이라면 값이 초기화된다는 보장이 없다.
그렇지만 C가 아닌 부분으로 확장한다면 상황에 따라 다르게 동작한다.
배열은 각 원소가 확실히 초기화된다는 보장이 없으나 vector는 그러한 보장을 갖게 된다.
이러한 복잡한 규칙을 하나하나 외우기보다는 모든 객체를 사용하기 전에 항상 초기화하면 된다.
기본제공 타입으로 만들어진 비멤버 객체에 대해서는 직접 초기화해야 한다.
int x = 0; // int 직접 초기화
const char* text = "A C-style string"; // 포인터의 직접 초기화
double d;
std::cin >> d; // 입력 스트림에서 읽어 초기화 수행
이런 부분을 제외하면 C++의 초기화는 생성자에서 이루어진다.
흔히 초기화를 한다고 착각하는 부분은 대입과 혼동하는 부분이다. 대입과 초기화은 엄연히 다르다.
class PhoneNumber { ... };
class ABEntry {
public:
ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones);
private:
std::string theName;
std::string theAddress;
std::list<PhoneNumber> 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;
}
이렇게 ABEntry를 작성하면 초기화가 아닌 대입으로 값을 할당하는 것이다.
C++ 규칙에 의하면 어떤 객체이든 그 객체의 데이터 멤버는 생성자 본문이 실행되기 전에 초기화되어야 한다.
theName, theAddress, thePhones는 ABEntry의 생성자 본문이 실행되기 전에 기본 생성자가 호출되어 초기화를 진행한다. 하지만 numTimesConsulted는 기본제공 타입이기 때문에 생성자 본문이 실행되기 전에 초기화된다는 보장이 없다.
이러한 문제는 초기화 리스트를 사용하여 해결할 수 있다.
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) // numTimesConsulted은 명시적으로 0으로 초기화
{}
초기화 리스트에 포함되지 않은 데이터 멤버는 컴파일러가 자동으로 기본 생성자를 호출하여 초기화를 진행하기는 한다.
하지만, 예외를 두고 진행을 하다 보면 혼동스러운 상황이 발생할 수 있고 만약 문제가 발생해도 원인을 찾아내기 어렵다.
또한, 기본제공 타입이 상수이거나 참조자라면 반드시 초기화가 되어야 한다. (상수와 참조자는 대입이 불가능)
이런 규칙들을 하나하나 외우고 그에 맞게 작성하느니 모두 초기화 리스트로 초기화하는게 좋을 것이다.
여러 개의 생성자가 사용되고 각 생성자마다 멤버 초기화 리스트가 붙어 있다면 가독성이 떨어질 수 있다.
이럴 때는 대입으로도 초기화가 가능한 데이터 멤버들을 초기화리스트에서 빼내어 별도의 함수로 옮기는 것도 나쁘지 않다. 이들에 대한 대입 연산을 하나의 함수에 몰아놓고 모든 생성자에서 이 함수를 호출하게 하면 된다.
이러한 방법은 데이터 멤버의 초기값을 파일에서 읽어온다든지 데이터 베이스에서 찾아오는 경우에 유용하게 쓰인다.
하지만 일반적인 경우엔 초기화 리스트를 통해 초기화 하는 편이 좋다.
초기화 순서
C++에서는 객체를 구성하는 데이터의 초기화 순서는 보장된다. 이 순서는 모든 컴파일러에서 동일하게 적용된다.
- 기본 클래스는 파생 클래스보다 먼저 초기화된다.
- 클래스 데이터 멤버는 선언된 순서대로 초기화된다.
초기화 리스트에 작성한 순서가 다르더라도 초기화 순서는 규칙을 따른다.
코드의 가독성을 올리기 위해서는 초기화 리스트에 작성하는 순서도 선언된 순서와 맞춰 주는 것이 좋다.
비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다.
정적 객체(static object)란 자신이 생성된 시점부터 프로그램이 끝날 때까지 살아 있는 객체이다.
정적 객체의 속하는 것들은 다음과 같다.
- 전역 객체
- 네임스페이스 유효범위에서 정의된 객체
- 클래스 안에 static으로 선언된 객체
- 함수 안에서 static으로 선언된 객체
- 파일 유효범위에서 static으로 정의된 객체
이들 중 함수 안에 있는 정적 객체는 지역 정적 객체이고 나머지는 비지역 정적 객체이다.
이러한 정적 객체들은 프로그램이 끝날 때 자동으로 소멸자가 호출되어 소멸된다.
번역 단위는 컴파일을 통해 하나의 목적 파일을 만드는 바탕이 되는 소스코드이다.
기본적으로 소스 파일 하나가 되는데 그 파일이 #include하는 파일까지 합쳐서 하나의 번역 단위가 된다.
즉, 별도로 컴파일된 소스 파일이 두 개 이상 있으며 각 소스 파일에 비지역 정적 객체가 한 개 이상 들어 있는 경우엔 초기화 순서를 어떻게 정의해야 되는지 문제이다. 별개의 번역 단위에서 정의된 비지역 정적 객체들의 초기화 순서는 정해져 있지 않기 때문에 문제가 발생할 수 있다.
class FileSystem {
public:
...
std::size_t numDisks() const; // 멤버 함수
...
};
extern FileSystem tfs; // 사용자가 사용할 객체
FileSystem을 사용하는 라이브러리를 작성한다고 가정을 해보자.
class Directory {
public:
Directory( params );
...
};
Directory::Directory( params )
{
...
std::size_t disks = tfs.numDisks(); // tfs 객체를 사용
...
}
Directory tempDir( params ); //Directory 생성
tfs가 tempDir보다 먼저 초기화되지 않으면 tempDir의 생성자는 tfs가 초기화되지 않았는데 사용한 것이다.
그런데 이 둘은 다른 번역 단위에서 작성되어 있어 순서를 보장할 방법이 없다.
하지만 약간의 변화를 주면 문제를 발생하지 않게는 할 수 있다.
비지역 정적 객체를 하나씩 맡는 함수를 준비하고 이안에 각 객체를 넣는 것이다.
함수 속에서도 이들을 정적 객체로 선언하고, 그 함수에서는 참조자를 반환하게 만드는 것이다.
즉, 비지역 정정 객체를 지역 정적 객체로 바꾸는 것이다. (singleton pattern의 전형적인 구현양식)
지역 정적 객체는 함수 호출 중 그 객체가 최초로 정의될 때 초기화되도록 만들어져 있다.
따라서 비지역 정적 객체를 직접 접근하지 않고 지역 정적 객체에 대한 참조자를 얻어 사용한다면 그 객체는 반드시 초기화된 것이다. 또한, 그 함수를 호출하지 않는다면 객체 생성, 소멸 비용도 아낄 수 있다.
class FileSystem { ... };
FileSystem& tfs()
{
static FileSystem fs; // 지역 정적 객체
return fs;
}
class Directory { ... };
Directory::Directory( params )
{
...
std::size_t disks = tfs().numDisks(); // tfs() 함수를 이용
...
};
Directory& tempDir()
{
static Directory td; // tempDir도 함수로 대체
return td;
}
비지역 정적 객체의 참조자를 사용하는 것이 아닌 지역 정적 객체의 참조자를 반환하는 함수를 사용한 것이다.
지역 정적 객체를 정의, 초기화 하고 그 객체의 참조자를 반환하면 끝이기 때문에 구현도 간단하다.
호출 빈도가 늘어난다면 inline으로 작성하는 것도 괜찮다.
하지만, 참조자 반환 함수는 내부적으로 정적 객체를 쓰기 때문에, 다중스레드 시스템에서는 문제가 발생할 수 있다.
다중스레드 문제를 해결하기 위해서는 다중스레드가 시작되기 전에 참조자 반환 함수를 전부 직접 호출하여 미리 생성하면 초기화에 관련된 경쟁 상태를 없앨 수 있다.
물론 이러한 기법들은 객체들의 초기화 순서를 제대로 맞춰 설계하는 것을 기본으로 하는 것이다.