본문 바로가기

업무용 언어/C++

[Effective C++] 항목 4. 객체를 사용하기 전에 반드시 그 객체를 초기화하자



[Effective C++] 항목 4. 객체를 사용하기 전에 반드시 그 객체를 초기화하자

비멤버 객체 초기화

객체의 값을 초기화 하는데 있어 C++는 복잡한 규칙을 따른다.
아래와 같은 경우,

int x;

어떤 상황에서는 x의 값은 0으로 확실히 초기화되지만, 또 다른 상황에서는 x의 값이 보장되지 않는다. 또한,

class Point {
    int x, y;
};
// ...
Point p;

p의 데이터 멤버 역시 어떤 상황에서는 초기화가 보장되지만, 또 어떤 상황에서는 초기화가 보장되지 않는다. 대부분의 경우, 객체의 내부가 무작위 비트로 된 이상한 값을 갖게 된다.

C++의 객체 초기화는 나름대로의 규칙을 갖고 있지만, 이 규칙이 복잡하기 때문에 항상 초기화하는 습관을 들이는 것이 좋다.

비멤버 객체에 대해서는 초기화를 손수 해주어야 한다.

int x = 0;
const char* text = "A C-style string";
double d;
std::cin >> d; // 입력 스트림에서 읽음으로써 "초기화" 수행

멤버 객체 초기화

멤버 객체는 생성자의 초기화 리스트로 초기화한다. 이때, 그 객체의 모든 멤버를 초기화하는 습관을 들이자. 단, 대입과 초기화를 구분하는 것이 중요하다.

class PhoneNumber { ... };

class ABEntry   // Address Book Entry
{
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;
    thePhone = phones;
    numTimesConsulted = 0;
}

C++ 규칙에 의하면 어떤 객체이든 그 객체의 데이터 멤버는 생성자의 본문이 실행되기 전에 초기화되어야 한다고 명기되어 있다.

theNametheAddress, thePhone은 생성자 본문이 실행되기 전에 기본 생성자가 호출됨으로써 초기화가 수행되었고, 생성자 본문에서는 그냥 대입만 되고 있다.

그렇다면, numTimesConsulted도 미리 초기화가 되었을까? 꼭 그렇지만은 않다. 기본 제공 타입의 경우, 생성자 본문이 실행되기 전 초기화되리란 보장이 없다.

더욱 효과적으로 초기화를 하려면 어떻게 할까? 바로 멤버 초기화 리스트를 사용하는 것이다.

ABEntry::ABEntry(const std::string& name, const std::string& address,
                 const std::list<PhoneNumber>& phones)
: theName(name),       // 이제 이들은 모두 초기화되고 있다.
  theAddress(address),
  thePhone(phones)
  numTimesConsulted(0)
{ }                    // 생성자 본문엔 이제 아무 것도 필요 없다.

멤버 초기화 리스트를 이용한 방법은 대입을 이용한 방법보다 더 효율적일 가능성이 크다. 이전 버전은 기본 생성자로 미리 초기화를 해 놓은 후, 바로 대입을 하고 있기 때문에 먼저 호출된 기본 생성자에서 해놓은 초기화는 그냥 헛짓이 되고 만다.

따라서 기본 생성자 + 복사 대입 연산자보다 복사 생성자를 한번만 호출하는 쪽이 대부분의 데이터 타입의 경우, 더 효율적이기 때문이다.

멤버를 기본 생성자로 초기화

데이터 멤버를 기본 생성자로 초기화하고 싶을 땐 다음과 같이 한다.

ABEntry::ABEntry()
: theName(), // 기본 생성자 호출
  theAddress(),
  thePhone()
  numTimesConsulted(0)
{ }

위와 같이 기본 생성자를 호출하는 것이 약간 오버처럼 느껴질 수도 있다. 어떤 데이터 멤버가 멤버 초기화 리스트에 들어가지 않았고 그 데이터 멤버가 사용자 정의 타입이면, 컴파일러가 자동으로 기본 생성자를 호출하게 되기 때문이다. 그러나 기본 제공 타입은 기본값 같은 것이 없기 때문에(C 언어의 특성상) 초기화가 되지 않는다.

기본 제공 타입은 초기화와 대입에 들어가는 비용에 거의 차이가 없기 때문에 생성자 본문에 넣어도 되지 않을까 라는 생각을 가질 수 있다. 그러나 기본 제공 타입이 상수 혹은 참조자로 되어 있는 경우는 대입 자체가 불가능하기 때문에 무조건 멤버 초기화 리스트에 넣어 주어야 한다.

이렇듯 규칙이 너무 복잡하기 때문에 골치가 아파진다. 그냥 이것저것 따지지 말고, 멤버 초기화 리스트에서 모든 멤버를 초기화해 주는 쪽이 속이 편하다.

현장에서 쓰이는 많은 클래스가 여러 개의 생성자를 갖고 있다. 그리고 각 생성자마다 멤버 초기화 리스트가 붙어 있을 것이다. 이런 클래스에 데이터 멤버와 부모 클래스가 적지 않게 붙어 있다면, 각 생성자마다 멤버 초기화 리스트가 주렁주렁 매달려 있을 것이다. 같은 멤버들의 초기값이 중복되어 출현할 것이고, 프로그래머가 보기에도 코드가 지겨워질 것은 불을 보듯 뻔한 일이다.

이럴 때는 대입으로도 초기화가 가능한 데이터 멤버들을 별도의 함수로 빼내어(대게 private 함수) 초기화를 시키는 방법을 사용하면 되지만, 이런 가짜 초기화보다는 아무래도 멤버 초기화 리스트를 사용하는 쪽이 더 좋을 것이다.
(C++11 에서는 이런 문제를 해결할 수 있는 방법이 존재합니다. 자세한 내용은 [C++11] 생성자 위임 글 참조)

초기화 순서

이처럼 변덕스러운 C++의 객체 초기화에도 변하지 않는 것이 딱 하나 있다. (1) 기본 클래스는 파생 클래스보다 먼저 초기화되고(항목 12 참조), (2) 클래스 데이터 멤버는 그들이 선언된 순서대로 초기화된다는 사실이다.

ABEntry로 예를 들면, 가장 먼저 theName이 초기화되고, 두번째로 theAddress, 세번째로 thePhones, 그리고 마지막으로 numTimesConsulted가 초기화된다.

어쩌다가 멤버 초기화 리스트에 이들이 넣어진 순서가 다르더라도, 초기화 순서는 선언된 순서를 따라간다. 멤버 초기화가 서로 얽혀 있다면 이 같은 문제는 찾기 어려운 버그를 만들어 내므로, 멤버 초기화 리스트에 넣는 멤버들의 순서를 클래스에 선언한 순서와 동일하게 맞춰주자.

비지역 정적 객체(non-local static object)의 초기화 순서

서로 다른 번역 단위에서 정의된 비지역 정적 객체들 사이의 상대적인 초기화 순서는 정해져 있지 않다.

  • 정적 객체: 자신이 생성된 시점부터 프로그램이 끝날 때까지 살아 있는 객체로 아래 다섯가지가 있다.
    1. 전역 객체
    2. 네임스페이스 유효범위에서 정의된 객체
    3. 클래스 안에서 static으로 선언된 객체
    4. 함수 안에서 static으로 선언된 객체
    5. 파일 유효범위에서 static으로 선언된 객체

스택 객체(지역 객체) 및 힙 객체(new 혹은 malloc으로 할당된 객체, 다른 말로 동적 객체)는 정적 객체가 아니다.

위 다섯가지 중, 함수 안에 있는 정적 객체는 지역 정적 객체, 나머지는 비지역 정적 객체라고 한다.

이 다섯 종류의 정적 객체는 프로그램이 끝날 때(main() 함수의 실행이 끝날 때) 자동으로 소멸된다.

  • 번역 단위(translation unit): 컴파일을 통해 하나의 목적 파일(object file)을 만드는 바탕이 되는 소스 코드. 기본적으로는 소스 파일 하나와 그 파일이 #include하는 파일들까지 합쳐서 하나의 번역 단위가 된다.

객체의 초기화 순서 문제는 다음과 같은 경우에 발생한다.

  1. 별도로 컴파일된 소스 파일이 두 개 이상 있으며
  2. 각 소스 파일에 비지역 정적 객체가 한 개 이상 들어있고
  3. 한 쪽 번역 단위에 있는 객체의 초기화가 진행되면서, 다른 쪽에 있는 비지역 정적 객체가 사용될 때

위의 경우에 비지역 정적 객체의 초기화 순서가 정해져 있지 않아 문제가 발생한다.

예를 보자. 아래와 같은 파일 시스템을 나타내는 클래스가 있고, 이 클래스로 만들어지는 객체는 단 하나만 존재해야 하기에 비지역 정적 객체로 만들었다.

class FileSystem {
public:
    ...
    std::size_t numDisks() const;
    ...
};
extern FileSystem tfs; // 사용자가 쓰게될 객체

이제 사용자 측에서 tfs를 사용하여 디렉토리를 나타내는 클래스를 만들기로 하였다.

class Directory {
    public:
        Directory(params);
        ..
};

Directory::Directory(params) {
    ...
    std::size_t disks = tfs.numDisks();
    ...
}

그리고 이 클래스의 객체를 생성해서 사용하려고 하면

Directory tempDir(params)

비지역 정적 객체의 초기화 순서가 정해져 있지 않기 때문에 문제가 발생할 수 있다.
tempDir을 생성하기 전에 tfs가 생성이 되어 있어야 하는데, 개별 번역 단위에 있기 때문에 tfs가 먼저 초기화되리란 보장이 없기 때문이다.

사실 컴파일러 입장에서도 비지역 정적 객체들의 초기화에 대해 적절한 순서를 결정하기란 매우 어려운 일이다. 어렵기도 어렵지만 이런 문제를 해결하는 것이 크게 보람있는 일도 아닐 것이다.

다행히 이런 경우는 설계에 약간 변화만 주면 해결이 가능하다.

비지역 정적 객체를 하나씩 맡는 함수를 준비하고 이 안에 각 객체를 넣으면 된다. 함수 속에서도 이들을 정적 객체로 선언하고, 그 함수에서는 이들에 대한 참조자를 반환하게 만든다. 사용자 쪽에서는 비지역 정적 객체를 직접 참조하는 방식을 버리고 함수 호출로 대신한다. 즉 정리하자면, 비지역 정적 객체를 지역 정적 객체로 바꾸는 것이다. (이는 싱글턴 패턴의 전형적인 방식이다.)

C++에서는(정확하게는 C) 지역 정적 객체는 함수 호출 중에 그 객체의 정의에 최초로 닿았을 때 초기화 되도록 만들어져 있다. 위의 방법은 이 사실을 이용한 것이다.

또한 비지역 정적 객체 대신에 준비한 지역 객체 참조자 반환 함수를 호출할 일이 없다면 해당 객체의 생성/소멸 비용도 생기지 않게 막아야 한다.

그리하여 이전 코드에 다음과 같은 변화를 주면

class FileSystem {...} // 이전과 같은 클래스

FileSystem& tfs() {
    static FileSystem fs;
    return fs;
}
class Directory {...} // 이전과 같은 클래스

Directory::Directory(params) {
    ...
    std::size_t disks = tfs().numDisks();
    ...
}

Directory& tempDir()
{
    static Directory td;
    return td;
}

tempDir 함수를 호출하기 전에는 Directory 객체도 생기지 않을 것이며 FileSystem 객체도 생기지 않을 것이다.

단, 다중 스레드 시스템에서는 위의 방법이 문제가 될 수도 있다. 비상수 정적 객체(지역 객체이든 비지역 객체이든)는 다중 스레드 시스템에서는 온갖 문제점을 야기할 수 있다. 한가지 방법은 프로그램이 다중 스레드로 돌입하기 전, 참조자 반환 함수를 전부 손으로 호출해 주어서 초기화에 관계된 경쟁 상태(race condition)를 피할 수 있다. 물론 객체들의 초기화 순서를 제대로 맞춰두기만 한다면 말이다.

  • 기본 제공 타입의 객체는 직접 손으로 초기화한다. 경우에 따라 저절로 되기도 하고 안되기도 하기 때문이다.
  • 생성자에서는 데이터 멤버에 대한 대입문을 본문 내부에 넣는 방법으로 멤버를 초기화하지 말고 멤버 초기화 리스트를 즐겨 사용하자. 그리고 초기화 리스트에 데이터 멤버를 나열할 때는 클래스에 각 데이터 멤버가 선언된 순서와 똑같이 나열하자.
  • 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야 한다. 비지역 정적 객체를 지역 정적 객체로 바꾸면 된다.