KoreanFoodie's Study

Effective C++ | 항목 4 : 객체를 사용하기 전에 반드시 그 객체를 초기화하자 본문

Tutorials/C++ : Advanced

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

GoldGiver 2022. 10. 25. 16:03

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 4 : 객체를 사용하기 전에 반드시 그 객체를 초기화하자

핵심 :

1. 기본제공 타입의 객체는 직접 손으로 초기화하자.
2. 생성자에서는, 생성자 본문 내무에 데이터 멤버에 대한 대입문을 넣지 않고 초기화 리스트를 사용하자. 또한 데이터 멤버가 선언된 순서와 똑같이 나열하자.
3. 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야 한다. 비지역 정적 객체를 지역 정적 객체로 바꾸면 된다.

 

생성자를 다음과 같이 쓰는 것은, 생성과 동시에 '초기화' 하는 것이 아닌, '대입'을 행하는 것이다.

class UseInitializationList
{
  public:
    UseInitializationList(const std::string& name, const std::list<PhoneNumber> phones);

  private:
    std::string _name;
    std::list<PhoneNumber> _phones;
    int _nums;
}

// 이 생성자는 초기화가 아닌 대입이다
UseInitializationList::UseInitializationList(const std::string& name, const std::list<PhoneNumber> phones)
{
  _name = name;
  _phones = phones;
  _nums = 0;
}

'대입' 대신에, '초기화 리스트를 사용하자

UseInitializationList(const std::string& name, const std::list<PhoneNumber> phones)
: _name(name),
  _phones(phones),
  _nums(0)
{}

name 과 phones 의 경우, 멤버 초기화 리스트를 이용하면 복사 생성자를 한 번 호출하지만, 이전의 '대입' 방식에서는 기본 생성자 후 복사 대입 연산자를 연달아 호출하여 매우 비효율적이다.

전달할 인자가 없다면 다음과 같이 정의해야 미정의 동작을 예방할 수 있다.

UseInitializationList()
: _name(),
  _phones(),
  _nums(0)
{}

 

참고 : 객체를 구성하는 데이터의 초기화 순서는 다음과 같다.

1.기본 클래스는 파생 클래스보다 먼저 초기화된다
2. 클래스 데이터 멤버는 그들이 선언된 순서대로 초기화된다(멤버 초기화 리스트에 넣어진 순서가 아닌, 선언된 순서대로 초기화가 된다. 따라서 순서를 맞춰서 잠재적인 동작 버그의 가능성을 피하자)

 

비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다

먼저, 정적 객체(static object) 는 자신이 생성된 시점부터 프로그램이 끝날 때까지 살아 있는 객체를 의미한다. 스택 및 힙 기반 객체는 애초부터 정적 객체가 될 수 없다.

  1. 전역 객체
  2. 네임스페이스 유효범위에서 정의된 객체
  3. 클래스 안에서 static 으로 선언된 객체
  4. 함수 안에서 static 으로 선언된 객체
  5. 파일 유효범위에서 static 으로 정의된 객체

위의 다섯가지 중, 함수 안에 있는 정적 객체를 지역 정적 객체(local static object) 라고 하고(함수에 대해 지역성을 가지므로), 나머지는 비지역 정적 객체(non-local static object) 라고 한다. 아 다섯 종류의 객체는 main() 함수의 실행이 끝날 때 정적 객체의 소멸자가 호출된다.

번역 단위(translation unit)는 컴파일을 통해 하나의 목적 파일(object file) 을 만드는 바탕이 되는 소스 코드를 일컫는다. 여기서 번역은 소스의 언어를 기계어로 옮긴다는 의미고, 기본적으로는 소스 파일 하나가 되는데, 그 파일이 #include 하는 파일(들) 까지 합쳐서 하나의 번역 단위가 된다.

즉, 문제는 다음과 같다. 별도로 컴파일된 소스 파일이 2개가 있고, 각 소스 파일에 비지역 정적 객체(전역 객체, 네임스페이스에 있는 객체, 클래스 혹은 파일에 있는 정적 객체) 가 한 개 이상 들어가 있는 경우에는 어떻게 될까?

한 쪽 번역 단위에 있는 비정적 객체의 초기화가 진행되면서 다른 쪽 번역 단위에 있는 비지역 정적 객체가 사용되는데, 불행히도 이 객체가 초기화 되어 있지 않을지도 모른다는 점이다. 이유는, 위에서 말했듯, 비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해지기 때문이다!

이를 해결하기 위해서는, 싱글톤 패턴과 비슷한 방식을 활용하면 된다!

class FileSystem {...};
// tfs 객체를 이 함수로 대신한다
// 이 함수는 클래스 안에 정적 멤버로 들어가도 된다
FileSystem& tfs()
{
  // 지역 정적 객체를 정의하고 초기화한다
  // 객체에 대한 참조자를 반환한다
  static FileSystem fs;
  return fs;
}

class Directory {...};
Directory::Directory( params )
{
  ...
  // tfs 자체가 아니라, 함수 호출을 통해 순서 문제를 해결한다
  // 어떻게 보면 늦은 초기화(lazy initialization) 이라고도 볼 수 있다
  std::size_t disks = tfs().numDisks();
  ...
}

Directory& tempDir()
{
  // 지역 정적 객체를 정의하고 초기화한다
  // 객체에 대한 참조자를 반환한다
  static Directory td;
  return td;
}

만약 다중스레드라면 어떨까? 비상수 정적 객체(지역 객체이든 비지역 객체이든)는 온갖 골칫거리의 시한 폭탄이다. 한 가지 해결법으로는, 프로그램이 다중스레드로 돌입하기 전의 시동 단계에서 참조자 반환 함수를 전부 손으로 호출하도록 할 수 있다. 이렇게 하면 초기화에 관계된 경쟁 상태(race condition) 을 없앨 수 있다!

(C# 에서는 Lazy Initialization 시 일어나는 정적 객체 중복 생성 문제와 성능 저하를 synchronized 를 이용해 해결할 수 있다)

 

지역 정적 객체, 혹은 정적 지역 객체(static local variable) 에 대한 더 자세한 설명은 이 블로그 글을 참고하자(MSDN 글을 번역한 것이라고 함).

Comments