초기화되지 않은 값을 읽도록 내버려 두면 정의되지 않은 동작이 그대로 흘러나오게 됩니다. 어떤 플랫폼의 경우애는 미초기화 객체를 읽기만 해도 프로그램이 멈추기도 하지만, 대체적인 경우에는 적당히 무작위 비트의 값을 읽고 객체의 내부가 이상한 값을 갖게 됩니다. C++은 객체(변수)가 언제 초기화가 보장되며 언제 그렇지 않은지에 대해 규칙이 명확히 준비되어 있습니다.
C++의 C 부분만을 쓰고 있으며 초기화에 런타임 비용이 소모될 수 있는 상황이라면 값이 초기화된다는 보장이 없습니다. 그렇지만 C가 아닌 부분으로 발을 걸치게 되면 사정이 때때로 달라집니다. 배열(C++의 C 부분)은 각 원소가 확실히 초기화된다는 보장이 없으나 vector(C++의 STL 부분)는 그러한 보장을 갖게 되는 이유가 바로 이런 법칙 때문입니다.
따라서 가장 좋은 방법은 모든 객체를 사용하기 전에 항상 초기화하는 것입니다.
1 2 3 4 | int x = 0; // int의 직접 초기화 const char * text = "string"; // 포인터의 직접 초기화 double d; // 입력 스트림에서 읽음으로써 std::cin >> d; // "초기화" | cs |
이런 부분을 제외하고 나면, C++ 초기화의 나머지 부분은 생성자로 귀결됩니다. 생성자에서는 해당 객체의 모든 것을 초기화하자는 규칙만 치키면 됩니다. 하지만 대입(assignment)을 초기화(initialization)와 헷갈리지 않는 것이 중요합니다.
주소록의 개인별 기재사항을 나타내는 클래스를 한 예로 들어 보죠. 이 클래스의 생성자는 다음과 같이 구현되어 있다고 가정합시다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | 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 = thePhones; numTimesConsulted = 0; } | cs |
C++ 규칙에 의하면 어떤 객체이든 그 객체의 데이터 멤버는 생성자의 본문이 실행되기 전에 초기화되어야 한다고 명기되어 있습니다. theName, theAddress, thePhones는 여기서 초기화되고 있는 것이 아니라, 어떤 값이 대입되고 있는 것입니다. numTimesConsulted는 기본 데이터 멤버이기 때문에 (생성자 안에서) 대입되기 전에 초기화되리란 보장이 없습니다. 해당 문제를 해결하기 위해 대입문 대신에 멤버 초기화 리스트를 사용하면 됩니다.
1 2 3 4 5 6 7 | ABEntry::ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones) : theName(name), // 이제 이들은 모두 초기화되고 있습니다. theAddress(address), thePhones(phones), numTimesConsulted(0) {} // 생성자 본문엔 이제 아무것도 들어가 있지 않고요. | cs |
데이터 멤버에 사용자가 원하는 값을 주고 시작한다는 점에서는 똑같지만, 방금 만든 생상자는 앞의 것보다 더 효율적일 가능성이 큽니다. 대입만 사용한 버전의 경우 기본 생성자 호출 후에 복사 대입 연산자를 연달아 호출합니다. 멤버 초기화 리스트를 사용한 버전의 경우 복사 생성자를 한 번만 호출합니다. 대부분의 데이터 타입에 대해서는, 전자의 방법보다 후자의 방법이 더 효율적입니다.
'대부분의 타입'에 포함되지 않는 타입인 numTimesConsulted와 같은 기본 제공 타입의 객체는 초기화와 대입에 걸리는 비용의 차이가 없지만, 역시 멤버 초기화 리스트에 모두 넣어 주는 쪽이 가장 좋습니다. 또, 데이터 멤버를 기본 생성자로 초기화하고 싶을 때도 멤버 초기화 리스트를 사용하는 것이 좋습니다.
어떤 데이터 멤버가 멤버 초기화 리스트에 들어가지 않았고 그 데이터 멤버의 타입이 사용자 정의 타입이면, 컴파일러가 자동으로 그들 멤버에 대해 기본 생성자를 호출합니다. 하지만 기본 생성자이든 아니든 클래스 데이터 멤버는 모두 초기화 리스트에 항상 올려주어야, 초기화되지 않아 미정의 동작에 빠지는 일이 미연에 방지됩니다. 기본제공 타입의 멤버를 초기화 리스트로 넣는 일이 선택이 아니라 의무가 될 때도 있는데, 상수이거나 참조자로 되어 있는 데이터 멤버의 경우엔 반드시 초기화되어야 합니다. 상수와 참조자는 대입 자체가 불가능하기 때문입니다.
C++에서의 객체 초기화는 꽤나 변덕스럽지만, 이 와중에도 어떤 컴파일러를 막론하고 항상 똑같은 벽던스럽지 않은 부분이 있습니다.
첫 번째로, 기본 클래스는 파생 클래스보다 먼저 초기화됩니다.
두 번째로, 클래스 데이터 멤버는 그들이 선언된 순서대로 초기화됩니다.
ABEntry를 예로 들면 theName이 항상 첫 번째로 초기화되고, theAddress가 두 번째, thePhones가 세 번째, numTimesConsulted가 마지막으로 초기화됩니다.
멤버 초기화 리스트에 이들이 넣어진 순서가 다르더라도 초기화 순서는 그대로입니다.
비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해집니다.
정적 객체(static object)는 자신이 생성된 시점부터 프로그램이 끝날 때까지 살아 있는 객체를 일컫습니다.
정적 객체의 종류에는
1. 전역 객체
2. 네임스페이스 유효범위에서 정의된 객체
3. 클래스 안에서 static으로 선언된 객체
4. 함수 안에서 static으로 선언된 객체
5. 파일 유효범위에서 static으로 정의된 객체
이렇게 다섯 종류입니다.
이들 중 함수 안에 있는 정적 객체는 지역 정적 객체(local static object)라고 하고(함수에 대해서 지역성을 가지므로), 나머지는 비지역 정적 객체(non-local static object)라고 합니다.
번역 단위(translation unit)는 컴파일을 통해 하나의 목적 파일(object file)을 만드는 바탕이 되는 소스 코드를 일컫습니다. 여기서 번역은 소스의 언어를 기계어로 옮긴다는 의미이고, 기본적으로 소스 파일 하나와 해당 파일이 #include하는 파일(들)까지 합쳐서 하나의 번역 단위가 됩니다.
별도로 컴파일된 소스 파일이 두 개 이상 있으며 각 소스 파일에 비지역 정적 객체(전역 객체, 네임스페이스에 있는 객체, 클래스 혹은 파일에 있는 정적 객체)가 한 개 이상 들어 있는 경우에 문제가 생길 수도 있습니다. 한쪽 번역 단위에 있는 비정적 객체의 초기화가 진행되면서 다른 쪽 번역 단위에 있는 비지역 정적 객체가 사용되는데, 불행히도 다른 쪽 번역 단위에 있는 객체가 초기화되어 있지 않을지도 모른다는 점이죠. 별개의 번역 단위에서 정의된 비지역 정적 객체들 사이의 상대적인 초기화 순서는 정해져 있지 않다는 사실 때문에 그렇습니다.
인터넷에 있는 파일을 마치 로컬 컴퓨터에 있는 것처럼 보이게 하는 파일 시스템을 나타내는 FileSystem이라는 클래스가 있다고 가정합시다.
1 2 3 4 5 6 7 8 | class FileSystem { // 라이브러리에 포함된 클래스 public: ... std::size_t numDisks() const; // 많고 많은 멤버 함수들 중 하나 ... }; extern FileSystem tfs; // 사용자가 쓰게 될 객체 | cs |
파일 시스템 내의 디렉토리를 나타내는 클래스를 사용자가 만들었다고 가정해 보죠.
1 2 3 4 5 6 7 8 9 10 11 12 | class Directory { // 사용자가 만든 클래스 public: Directory( params ); ... }; Directory::Directory( params ) { ... std::size_t disks = tfs.numDisks(); // tfs 객체를 여기서 사용함 ... } | cs |
이제는 이 사용자가 Directory 클래스를 사용해서 임시 파일을 담는 디렉토리 객체 하나를 생성하기로 마음먹습니다.
Directory tempDir( params ); // 임시 파일을 담는 디렉토리
tfs와 tempDir은 제작자도 다르고 만들어진 시기도 다른데다가 소재지(소스 파일)도 다른, 다시 말해 다른 번역 단위 안에서 정의된 비지역 정적 객체입니다.
따라서 상대적인 초기화 순서는 정해져 있지 않습니다.
tfs가 tempDir보다 먼저 초기화되지 않으면, tempDir의 생성자는 tfs가 초기화되지도 않았는데 tfs를 사용하려고 하겠지요.
설계에 약간의 변화만 살짝 주면 이 문제를 사전에 봉쇄할 수 있습니다.
방법은 간단한데, 비지역 정적 객체를 하나씩 맡는 함수를 준비하고 이 안에 각 객체를 넣는 것입니다.
함수 속에서도 이들은 정적 객체로 선언하고, 그 함수에서는 이들에 대한 참조자를 반환하게 만듭니다.
사용자 쪽에서는 비지역 정적 객체를 직접 참조하지 않고, 함수 호출로 대신합니다.
정리하면 '비지역 정적 객체'가 '지역 정적 객체'로 바뀐 것입니다.
지역 정적 객체는 함수 호출 중에 그 객체의 정의에 최초로 닿았을 때 초기화되도록 만들어져 있습니다.
위의 방법은 바로 그 사실을 이용한 것입니다.
따라서 비지역 정적 객체를 직접 접근하지 않고 지역 정적 객체에 대한 참조자를 반환하는 쪽으로 바꾸었다면, 얻어낸 참조자는 반드시 초기화된 객체를 참조하도록 맞추어 주어야 하겠습니다.
이는 디자인 패턴 중 Singleton pattern의 전형적인 구현양식이지요.
그리하여 tfs와 tempDir에 이 방법을 적용한 결과를 보시겠습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | class FileSystem { ... }; // 이전과 다를 것이 없는 클래스 FileSystem& tfs() // tfs 객체를 이 함수로 대신합니다. 이 함수는 { // 클래스 안에 정적 멤버로 들어가도 됩니다. static FileSystem fs; // 지역 정적 객체를 정의하고 초기화합니다. return fs; // 이 객체에 대한 참조자를 반환합니다. } class Directory { ... }; Directory::Directory( params ) // 이전과 동일합니다. tfs의 참조자였던 것이 { // 지금은 tfs()로 바뀌었다는 것만 다릅니다. ... std::size_t disks = tfs().numDisks(); ... } Directory& tempDir() // tempDir 객체를 이 함수로 대신합니다. 이 함수는 { // Directory 클래스의 정적 멤버로 들어가도 됩니다. static Directory td; // 지역 정적 객체를 정의하고 초기화합니다. return td; // 이 객체에 대한 참조자를 반환합니다. } | cs |
tfs와 tempDir 대신에 tfs()와 tempDir()을 참조하는 것으로 바뀐 게 전부입니다.
정적 객체 자체를 직접 사용하지 않고 그 객체에 대한 참조자를 반환하는 함수를 사용하고 있는 것이죠.
이 기법을 도입하면서 출현한 '참조자 반환' 함수는 어느 경우이든 복잡하게 구현될 일이 없습니다.
첫 번째 줄에서 지역 정적 객체를 정의/초기화하고, 두 번째 줄에서 그 객체의 참조자를 반환하면 끝입니다.
물론 초기화 순서 문제를 방지하기 위해 이처럼 참조자 반환 함수를 사용하는 아이디어는 객체들의 초기화 순서를 제대로 맞춰 둔다는 전제조건이 뒷받침되어 있어야 말이 됩니다.
함수의 호출빈도가 잦다면 인라인해도 좋습니다.
다중스레드 시스템에서는 다중스레드로 돌입하기 전의 시동 단계에서 참조자 반환 함수를 전부 호출해주면, 초기화에 관련된 경쟁 상태(race condition)가 없어집니다.
기본제공 타입의 객체는 직접 손으로 초기화합니다.
경우에 따라 저절로 되기도 하고 안되기도 하기 때문입니다.
생성자에서는, 데이터 맴버에 대한 대입문을 생성자 본문 내부에 넣는 방법으로 멤버를 초기화하지 말고 멤버 초기화 리스트를 즐겨 사용합시다.
그리고 초기화 리스트에 데이터 멤버를 나열할 때는 클래스에 각 데이터 멤버가 선언된 순서와 똑같이 나열합시다.
여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야 합니다.
비지역 정적 객체를 지역 정적 객체로 바꾸면 됩니다.