본문 바로가기
프로그래밍/C & C++

[C++] 객체 초기화 / 비지역 정적 객체의 초기화

by 별준 2021. 7. 26.

Reference

  • Effective C++ (항목 4)

Contents

  • 객체 초기화
  • 멤버 초기화 리스트
  • 비지역 정적 객체의 초기화 순서

int x;

class Point {
	int x, y;
};

Point p;

C++에서 위의 코드처럼 객체를 선언할 때, 어떤 상황에서는 x나 p의 데이터 멤버의 초기화가 보장되지만, 어떤 경우에서는 초기화가 보장되지 않습니다. (Global에 초기화하는 경우 0으로 초기화되지만, 함수 내부에서 초기화하는 경우 초기화가 되지 않는 경우)

 

초기화되지 않은 값을 읽도록 내버려 둔다면 정의되지 않은 동작이 발생하게되고, 어떤 플랫폼의 경우에는 미초기화 객체를 읽기만 해도 프로그램이 멈추기도 하지만, 대부분의 경우에는 적당히 무작위 비트의 값을 일고 객체의 내부가 이상한 값을 가지게 됩니다.

 

C++의 초기화가 규칙이 없는 것은 아닙니다. 다만 모두 숙지하기에는 그 규칙 자체가 복잡합니다.

일단 일반적인 사항을 살펴보면, C++의 C 부분만을 사용하고 있으며, 초기화에 런타임 비용이 소모될 수 있는 상황이라면 값이 초기화된다는 보장이 없습니다. 하지만, C가 아닌 부분으로 걸치게 되면 사정이 때때로 다릅니다.

(여기서 런타임 비용은 초기화에 사용되는 리소스를 의미하는 것 같습니다.)

 

배열(C++의 C부분)은 각 원소가 확실히 초기화된다는 보장이 없지만, vector(C++의 STL부분)은 그러한 보장을 갖게되는 것이 위의 법칙 때문입니다.

 

어쨋든 찜찜한 상태이며, 가장 좋은 방법은 모든 객체를 사용하기 전에는 항상 초기화하는 것입니다. 특히 기본제공 타입으로 만들어진 비멤버 객체에 대해서는 초기화를 손수 해야합니다.

int x = 0;
const char* text = "A C-style string";

double d:
std::cin >> d;		//입력 스트림에서 읽음으로써 초기화

 

위와 같은 부분을 제외하면, C++ 초기화의 나머지 부분은 생성자로 귀결됩니다.

생성자에서 지킬 규칙은 매우 간단합니다. 바로 객체의 모든 것을 초기화하자라는 것입니다. 쉬운 규칙이긴 하지만, 대입(assignment)와 초기화(initialization)을 헷갈리지 않는 것이 가장 중요합니다.

주소록의 개인별 기재사항을 나타내는 클래스를 예시로 살펴보겠습니다.

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(C++의 STL 부분)은 생성자에서 초기화되는 것이 아니라, 어떤 값이 대입되고 있는 것입니다. 초기화는 생성자가 불리기 전에 이미 수행되었는데, 정확하게 말하자면 AEBEntry의 생성자에 진입하기 전에 C++의 STL 부분인 데이터 멤버의 기본 생성자가 호출된 것입니다. 하지만, numTimesConsulted는 기본제공 타입의 데이터 멤버이기 때문에 ABEntry에서 대입되기 전에 초기화가 되었으리라는 보장이 없습니다.

 

위와 같은 대입문 대신에 멤버 초기화 리스트를 사용하면, 생성자가 호출되기 전에 초기화를 수행할 수 있습니다.

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

데이터 멤버에 사용자가 원하는 값을 주고 시작한다는 점에서 똑같지만, 이 코드는 방금 위에서 본 것보다 더 효율적일 가능성이 큽니다. 대입을 한 경우에는 theName, theAddress, thePhones에 대해 기본 생성자를 호출해서 초기화를 진행한 다음에 생성자에서 곧바로 새로운 값을 대입하고 있습니다. 따라서 먼저 호출된 기본 생성자에서의 초기화가 아무런 소용이 없는 것이 됩니다. 

멤버 초기화 리스트를 사용한 경우에는 theName은 name으로부터 복사 생성자에 의해 초기화되고, theAddress는 address, thePhones는 phones으로부터 복사 생성자에 의해서 초기화가 됩니다.

 

대부분의 데이터 타입에 대해서는 기본 생성자 호출 후에 복사 대입 연산자를 연달아 호출하는 첫 번째 방법보다 복사 생성자를 한 번 호출하는 쪽이 훨씬 더 효율적입니다.

 

대부분의 데이터 타입에 포함되지 않는 타입이 numTimesConsulted와 같은 기본제공 타입입니다. 기본제공 타입의 객체는 초기화와 대입에 걸리는 비용의 차이가 없지만, 멤버 초기화 리스트에 모두 넣어 주는 쪽이 가장 좋습니다. 

데이터 멤버를 기본 생성자로 초기화하고 싶을 때에도 멤버 초기화 리스트를 사용하는 습관은 좋습니다.

ABEntry::ABEntry()		//매개변수가 없는 경우
	: theName(),
	theAddress(),
	thePhones(),
	numTimesConsulted(0)
{}

특히 numTimesConsulted가 멤버 초기화 리스트에서 빠졌다고 생각했을 때, 기본제공 타입이니까 초기화될 지 안 될지는 장담하지 못하는 상황이 발생할 수 있으므로, 무조건 넣어주는 것이 실수를 줄이는 데 효과적입니다.

 

기본제공 타입의 멤버를 초기화 리스트로 넣는 것은 선택이 아니라 의무가 될 때도 있습니다. 바로 상수이거나 참조자로 되어 있는 데이터 멤버의 경우인데, 이 경우에는 반드시 초기화가 되어야 합니다. 그 이유는 상수와 참조자는 대입이 불가능하기 때문입니다.


현업에서 쓰이는 클래스들 중 상당수는 여러 개의 생성자를 가지고 있습니다. 각 생성자마다 멤버 초기화 리스트가 아마 붙어 있겠죠. 만약 이런 생성자마다 초기화 멤버 리스트가 너무 많이 있다면 코드가 예쁘게 보이지도 않을 것이고, 수정이 필요하다고 느껴진다면, 대입으로도 초기화가 가능한 데이터 멤버들을 초기화 리스트에서 빼고, 별도의 함수로 옮기는 것도 나쁘지는 않습니다. 이들에 대한 대입 연산을 하나의 함수에 몰아넣고, 모든 생성자에서 이 함수를 호출하는 하는 것이죠. 이런 방법은 데이터 멤버의 진짜 초기값을 파일에서 읽어온다든지 데이터베이스에서 찾아오는 경우에 특히 유용하게 사용할 수 있습니다.

하지만 일반적인 경우만 따지면 대입을 통한 가짜 초기화보다는 진짜 멤버 초기화(초기화 리스트 사용)가 좋습니다.


C++에서의 객체 초기화는 꽤나 변덕스럽지만, 한 가지 확실한 것이 있습니다. 이는 바로 객체를 구성하는 데이터의 초기화 순서입니다. 이 순서는 어떤 컴파일러를 사용하든지 항상 동일합니다. 

  1. 기본 클래스는 파생 클래스보다 먼저 초기화됨
  2. 클래스 데이터 멤버는 선언된 순서대로 초기화됨
    -> ABEntry를 예로 들면 theNames가 항상 첫 번째로 초기화되고, theAddress, thePhones, numTimesConsulted 순으로 초기화됨. 멤버 초기화 리스트에 이 멤버들이 넣어진 순서가 다르더라도 컴파일은 되지만 초기화 순서는 선언된 순서임. 따라서 문제는 없겠지만, 멤버 초기화 리스트에 넣는 멤버들의 순서도 선언된 순서대로 넣어주는게 좋음

비지역 정적 객체의 초기화 순서

마지막으로 알아두어야 할 것은 비지역 정적 객체의 초기화 순서는 개별 번역 단위(TU)에서 정해진다는 사실입니다.

무슨 말인지 잘 모르겠으니, 우선 단어 하나하나를 살펴보도록 하겠습니다.

 

정적 객체(static object)는 자신이 생성된 시점부터 프로그램이 끝날 때까지 살아 있는 객체를 일컷습니다. 따라서, 스택 및 힙 기반 객체는 static 객체가 될 수 없습니다. 

static 객체의 범주에 들어가는 것은

  1. 전역 객체(Global object)
  2. namespace 유효범위에서 정의된 객체
  3. 클래스 안에서 static으로 선언된 객체
  4. 함수 안에서 static으로 선언된 객체
  5. 파일 유효범위에서 static으로 정의된 객체

이렇게 다섯 종류가 있습니다. 

이들 중에서 함수 안에 있는 static 객체는 지역 정적 객체(local static object)라고 하며, 나머지는 비지역 정적 객체(non-local static object)라고 합니다. 이 다섯 종류의 객체, 즉, static 객체는 프로그램이 끝날 때(main 함수의 실행이 끝날 때) 자동으로 소멸됩니다.

 

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

 

이제 말하고자 하는 문제를 다시 정리하자면, 별도로 컴파일된 소스 파일이 두 개 이상(두 개 이상의 TU) 있으며, 각 소스 파일에 비지역 정적 객체가 한 개 이상 들어 있는 경우에 어떻게 되느냐하는 것이죠.

즉, 한쪽 TU에 존재하는 비지역 정적 객체의 초기화가 진행되면서 다른 쪽 TU에 있는 비지역 정적 객체를 사용하는데, 불행히도 사용할 비지역 정적 객체가 초기화가 되지 않을지도 모른다는 것입니다.

 

이는 별개의 TU에서 정의된 비지역 정적 객체들의 초기화 순서는 '정해져 있지 않다'라는 사실 때문입니다.

 

예제를 보면서 다시 살펴보겠습니다.

인터넷에 있는 파일을 로컬에 존재하는 것처럼 보이게 하는 파일 시스템을 나타내는 FileSystem이라는 클래스가 있다고 가정합니다. 이 클래스는 주변의 모든 파일을 단일 파일 시스템처럼 보이게끔 하므로, 단일 파일 시스템을 나타내는 특수한 객체가 전역 유효범위 또는 namespace 유효범위에 들어 있어야 합니다.

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

extern FileSystem tfs;	// 사용자가 쓰게 될 객체(the file system)

그리고 이 객체의 사용자 쪽으로 초점을 바꾸어서, 파일 시스템 내의 디렉토리를 나타내는 클래스를 사용자가 만들었다고 가정해봅시다. 

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

Directory::Directory( params )
{
	...
    std::size_t disks = tfs.numDisks();	//tfs 객체를 사용
    ...
}

그리고 사용자가 Directory 클래스를 사용해서 임시 파일을 담는 디렉토리 객체 하나를 생성한다고 가정해봅시다.

Directory tempDir( params );

정적 객체의 초기화 순서 때문에 문제가 발생할 수 있는 상황이 발생한 것입니다. tfs가 tempDir보다 먼저 초기화되지 않으면, tempDir의 생성자는 tfs가 초기화가 되지 않았는데 tfs를 사용하려고 합니다. 그러나, tfs와 tempDir는 다른 TU에서 정의된 비지역 정적 객체입니다.(제작자, 소스 파일이 다름)

어쨋든 tempDir 전에 tfs가 초기화되게 만드려면 어떻게 할 수 있을까요?

이미 우리는 서로 다른 TU에서 정의된 비지역 정적 객체들 사이의 상대적인 초기화 순서는 정해져 있지 않다는 것을 알고 있기 때문에 불가능하다는 것을 눈치채셨을 것입니다.


이렇듯 비지역 정적 객체들의 초기화에 대해 적절한 순서를 결정하는 것은 매우 어렵습니다.

한 가지 다행스러운 점은 설계에 약간의 변화만 주면 이 문제를 사전에 방지할 수 있다는 점입니다.

 

방법은 간단합니다. 

비지역 정적 객체를 하나씩 맡는 함수를 생성하고 이 함수 안에 각 객체를 넣는 것입니다. 함수 속에서도 이들은 static 객체로 선언하고, 그 함수에서는 이들에 대한 참조자를 반환하게 만듭니다. 

이렇게 하면 사용자 쪽에서는 비지역 정적 객체를 직접 참조하는 것이 아니라 함수 호출을 통하여 비지역 정적 객체를 사용합니다. 즉, 비지역 정적 객체가 지역 정적 객체로 변경된 것입니다. 

(이 것은 디자인 패턴에서 단일체 패턴[Singleton Pattern)의 전형적인 구현양식입니다.)

 

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

즉, 함수안에서 정의된 static 객체는 그 함수가 호출되어 처음으로 정의된 부분에 도달했을 때 초기화된다는 것입니다.

이것은 C++에서 보장하는 규칙이며, 위의 방법이 바로 이 규칙을 이용한 것입니다.

따라서, 비지역 정적 객체를 직접 참조하지 않고, 지역 정적 객체에 대한 참조자를 반환하는 쪽으로 변경했다면 사용자 측에서 전달받은 참조자는 반드시 초기화가 먼저 이루어지게 됩니다.

 

위 내용을 코드로 구현하면 다음과 같이 됩니다.

// FileSystem TU
class FileSystem { ... };

FileSystem& tfs()	// tfs 객체를 이 함수로 대체
{
	static FileSystem tf;	// 지역 정적 객체를 정의하고 초기화
    return fs;		// 이 객체에 대한 참조자를 반환
}

// Directory TU
class Directory { ... };

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

Directory& tempDir()	// tempDir 객체를 이 함수로 대체
{
	static Directory td;
    return td;
}

코드를 보니 조금 더 확실하게 다가오시나요?

 

이 기법을 도입하면서 생성한 참조자를 반환하는 함수(tfs, tempDir)는 어느 경우에서든지 복잡하게 구현될 일이 없습니다. 먼저 지역 정적 객체를 정의/초기화하고, 그 객체의 참조자를 반환하면 끝입니다. 함수가 간단하다 보니, inline으로 정의해도 좋은데, 특히 이 함수의 호출빈도가 잦다면 더욱 좋습니다.


하지만 다른 쪽으로 생각해본다면 문제가 발생할 수 있습니다. 참조자 반환 함수는 내부적으로 정적 객체를 쓰기 때문에, 다중스레드 시스템에서는 동작에 장애가 생길 수도 있다는 점입니다. 다중스레드를 사용하는 프로그램에서는 비상수 정적 객체(지역/비지역 모두)는 언제든지 문제를 발생할 수 있는 시한폭탄이라고 생각하시면 됩니다. 이런 문제를 해결하는 한 가지 방법은 바로 다중스레드로 진입하기 전에 미리 참조자 반환 함수를 전부 호출하는 것입니다. 이렇게 하면 초기화에 관련된 race condition을 없앨 수 있습니다.

 

물론 초기화 순서 문제를 방지하기 위해서 참조자 반환 함수를 사용한다는 것은 우선 객체들의 초기화 순서를 제대로 맞춰 둔다는 전제조건이 뒷받침되어야 합니다. 이것만 잘 지켜진다면 단일 스레드 어플리케이션에서 위의 방법들을 확실합니다.

댓글