본문 바로가기
C++공부/Effective C++

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

by daisy0461 2024. 10. 10.

아마 다들 알겠지만 초기화되지 않는 값을 읽으면 정의되지 않은 동작이 그대로 나온다.

공부하고 있는 Unreal의 경우 접근 불가능한 *에 대해 접근하기만 해도 프로그램이 다운된다.

대부분은 적당히 무작위 비트의 값을 읽고 객체의 내부가 이상한 값을 갖게 한다.

 

C++에서 객체의 초기화가 중구난방은 아니긴하다. 언제 초기화가 보장되며 언제 그렇지 않은지에 대해서 명확한 규칙이 존재하긴 하다. 하지만 너무 복잡하다.

 

복잡한 것보다 그럼 간단한게 좋은데 간단한 방법은 당연히

모든 객체를 사용하기 전에 초기화하는 것이다.

 

int x = 0;		//int 초기화
const char* text = "C-Style String";	//포인터 직접 초기화

double d;
cin >> d;		//입력 스트림에서 읽어서 초기화

생성자에서 지킬 규칙은 간단하다. 객체의 모든 것을 초기화하면 된다.

 

이때 헷갈릴 수 있는 부분이 '대입'(assignment)과 '초기화'(initalization)이다.

class PhoneNumber
{
    ...
};


class ABEntry
{ // 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; // "초기화"가 아니다.
    thePhones = phones;
    numTimesConsulted = 0;
}

위처럼 작성을 한다면 ABEntry는 매개변수로 들어온 값을 '대입'해서 사용될 것이다.

물론 ABEntry는 원하는 값을 갖게 될 것이다.

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

다시 말하지만 '대입'이 된 것이고 초기화 된 것이 아니다. 초기화는 이미 지나갔다.

 

그럼 초기화는 어떻게 하는가?

초기화 리스트를 사용하면 된다.

 

초기화리스트

위를 보면 :가 생기고 {}(본문)안에 아무것도 없는 것을 확인할 수 있다.

:뒤에 나오는 부분을 초기화 리스트라고 한다.

 

생각해보면 위나 아래나 결과는 똑같아보인다. 매개변수로 들어온 값을 사용할 수 있게 한다.

하지만 이렇게 초기화 리스트를 통해서 초기화하는 것이 더 효율적일 가능성이 높다.

 

'대입'을 사용하는 경우 theName, theAddress, thePhones에 대한 기본 생성자를 부른 이후에 새로운 값을 '대입'한다.

그렇다면 기본 생성자를 부른 과정이 쓸모 없게 된 것이다.

(numTimesConsulted의 경우에는 기본제공 데이터 멤버이기 때문에 대입 되기 전에 초기화 된다는 보장이 없다,)

 

그러면 저런 비효율적인 작업보다 복사 생성자를 한번 호출하는 쪽(초기화 리스트 사용)이 더 효율적이다.

 

numTimesConsulted의 타입 같은 기본 제공타입은 초기화와 대입에 걸리는 비용의 차이가 별로 없긴하지만 그래도 멤버 초기화 리스트에 넣어주는 것이 가장 좋다. 기본 생성자로 초기화하고 싶을 때도 멤버 초기화 리스트를 사용하는 것이 좋다.

 

혹시 매개변수가 없는 방식은 어떻게하면 되는가에 대해서 궁금할 수 있다.

ABEntry::ABEntry()
    : theName(),
      theAddress(),
      thePhones(),
      numTimesConsulted(0)		//명시적으로 0으로 초기화
{
}

위 처럼 생성자로 아무것도 주지 않으면 된다. 그러면 기본 생성자가 호출되서 초기화된다.

 

위 코드를 보고 이렇게 생각할 수 있다.

"왜 계속 초기화 리스트에 넣어라고 하는가? 귀찮다. 어떤 데이터 멤버가 멤버 초기화 리스트에 들어가지 않고 그 데이터가 사용자 정의 타입이면 컴파일러가 알아서 기본 생성자를 호출하는데 왜 저렇게 하냐."

 

틀린 것은 아니지만 기본 생성자든 아니든 저렇게 초기화 리스트로 하는 습관을 들이는 것이 낫기 때문이다.

  1. 계속 신경써서 '이건 사용자 정의 타입이니까 초기화 리스트에 넣지말고 이건 기본 제공 데이터니까 넣고' 이렇게 하는게 더 힘들 것이라고 생각된다.
  2. 어쩌다 리스트에서 어떤 멤버를 빼먹고 초기화가 안된 사실을 모르고 끌고 갈 수 있기 때문이다.
  3. 기본제공 타입의 멤버를 초기화 리스트로 넣는 일은 선택이 아니라 의무가 될 때도 있기 때문이다.
    상수이거나 참조자로 되어 있는 데이터 멤버는 반드시 초기화해야한다. 대입 자체가 불가능하기 때문이다.

 

C++에서 객체를 구성하는 데이터 초기화 순서

규칙은 2가지이다.

  • 기본 클래스는 파생 클래스보다 먼저 초기화된다. (이건 당연하게 보인다.)
  • 클래스 데이터 멤버는 그들이 선언된 순서대로 초기화된다.

위에 이렇게 클래스 데이터 멤버를 정의했다.

private:
    std::string theName;
    std::string theAddress;
    std::list<PhoneNumber> thePhones;
    int numTimesConsulted;

위 순서대로 된다는 말이다. theName -> theAddress -> thePhones

혹시 멤버 초기화 리스트에 이들이 넣어진 순서가 달라도 예를 들어

ABEntry::ABEntry(const std::list<PhoneNumber> &phones, 
		const std::string &address,
            	const std::string &name)
    : thePhones(phones),
      theAddress(address),
      theName(name),
      numTimesConsulted(0)
{
}

이렇게 반대로 있어도 순서는 동일하다는 이야기이다. 컴파일되긴 한다. 하지만 순서가 헷갈리기는 건 사실이다.

그렇기에 헷갈리는 상황을 방지하기 위해서 멤버 초기화 리스트에 넣는 멤버들의 순서도 클래스에 선언한 순서와 동일하게 맞추는 것이 혼동을 줄일 수 있다.

 

이제 초기화에서 신경써야할게 한가지 남아있다.

 

 

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

우와.. 이건 무슨 말이지.. 단어부터 살펴봐야한다.

  1. 정적 객체
    자신이 생성된 시점부터 프로그램이 끝날 때까지 살아 있는 객체를 의미한다.
    정적 객체는 프로그램이 끝날 때 자동으로 소멸된다.

    정적 객체의 종류
    - 전역 객체
    - 네임스페이스 유효범위에서 정의된 객체
    - 클래스 안에서 static으로 선언된 객체
    - 함수 안에서 static으로 선언된 객체
    - 파일 유효범위에서 static으로 정의된 객체
    이렇게 5가지가 있다.

    이 중 함수안에서 static으로 선언된 객체는 지역 정적 객체이며 나머지는 비지역 정적 객체이다.

  2. 번역 단위
    컴파일을 통해 하나의 목적 파일(object File)을 만드는 바탕이되는 소스를 말한다.
    '번역'의 의미는 소스의 언어를 기계어로 옮긴다는 의미이다.
    기본적으로는 소스 파일 하나지만 해당 파일의 #include되는 파일까지 합쳐서 하나의 번역단위가 된다.

자 그럼 위의 문장이 이해가 될 것이다.

 

그렇다면 문제는 무엇일까?

초기화 순서가 개별 번역 단위에서 정해진다는 것이다.

즉, 다른 번역 단위에서의 비지역 정적 객체의 초기화 순서는 정해져있지 않다.

이게 왜 문제를 일으키는 것일까?

 

예시를 들어서 다음과 같다.

class FileSystem {
public:
...
	size_t numDisks() const;
...
};

extern FileSystem tfs;

위 코드의 역할은 인터넷에 있는 파일을 로컬 컴퓨터에 있는 것 처럼 보이게하는 System이라고 하자.

 

사용자의 시점에서는 다음과 같이 파일 시스템 내의 디렉토리를 나타내는 클래스를 만들었다.

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

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

 

그리고 임시 파일을 담는 디렉토리 객체를 전역 객체로 하나 생성한다.

 

Directory tempDir(params);

 

이제 문제가 발생했다.

 

Directory tempDir(params);로 객체를 생성했을 때 두 전역 객체인 tfs, tempDir 중 tfs가 먼저 초기화되어야한다.

Directory에 size_t disks = tfs.numDisk()를 사용하기 때문이다.
만약 먼저 초기화되지 않으면 tempDir의 생성자는 초기화도 되지 않은 값을 사용하게 된다.

 

이런 문제점을 사전에 봉쇄할 수 있는 방법이 있다.

비지역 정적 객체를 하나씩 맡는 함수를 준비하고 이 안에 각 객체를 넣는 것이다.

예시를 보자.

class FileSystem { ... };
FileSystem& tfs() {				//tfs객체를 이 함수로 대체한다.
	static FileSystem fs;			//static이 함수 안에 생겨서 지역 정적 객체가 된다.
	return fs;
}

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

Directory& tempDir() {
	static Directory td;	//동일하게 지역 정적 객체가 된다.
	return td;
}

전역 정적 객체인 tfs, tempDir에서 지역 정적 객체로 바뀌었다.

지역 정적 객체는 함수 호출 중에 해당 객체의 정의에 최초로 닿았을 때 초기화 되도록 만들어져있다.

 

그럼 다시 이렇게 사용자가 동일하게 객체를 만들면 초기화 순서는 어떻게 될까?

Directory tempDir(params);

tempDir의 초기화는 프로그램이 시작될 때 Directory의 생성자에서 tfs()를 호출한다. 이때 static FileSystem fs에 닿게 되고 초기화가 된다. 그리고 tempDir이라는 전역 객체가 초기화가 된다.

이후에 tempDir()이라는 함수가 호출이 될 때 td가 초기화된다.

 

물론 이러한 방법도 객체들의 초기화 순서를 제대로 맞춰둔다는 전제조건이 있어야 가능하다.