상세 컨텐츠

본문 제목

항목 4) 객체 사용 전에 반드시 먼저 초기화하자

C++/Effective C++

by DeaKyungLee 2021. 12. 5. 10:00

본문

먼저, 기본 제공 타입으로 만들어진 비멤버 객체에 대한 초기화는 반드시 직접 해줘야한다.

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

그렇다면 사용자 정의 자료형이나 멤버 객체는?

→ 생성자를 사용해서 초기화하는 것이 가장 일반적이고 좋은 방법이다.

그런데 초기화( initialization )와 대입( assignment )은 반드시 구분해서 생각해야 한다.

// 예시 1) 기본 생성자 호출 이후 대입
class PhoneNumber {};
 
class ABEntry{
public:
    ABEntry(const std::string& name, const std::string& address,
        const std::list& phones);
private:
    std::string theName;
    std::string theAddress;
    std::list thePhones;
    int numTimesConsulted;
};
 
ABEntry::ABEntry(const std::string& name, const std::string& address,
    const std::list& phones)
{
    theName = name; 		// 이 행위는 모두 '초기화'가 아니라 '대입'이다.
    theAddress = address;	// ''
    thePhones = phones;		// '' 
    numTimesConsulted = 0;	// ''
}

결과만 놓고 본다면, 위의 방식으로도 파라미터로 전달된 값을 가지게 되지만 그 과정은 초기화와는 완전히 다르다.

명확하게 말해서, 위의 코드에서 대입 연산이 이루어지기도 전에 이미 각 객체에 대한 초기화는 이미 완료되었다.

즉, 해당 코드는 이미 초기화가 한 번 진행된 이 후에 대입을 진행하는 것이다.

// 예시 2) 복사 생성자 호출
ABEntry::ABEntry(const std::string& name, const std::string& address,
    const std::list& phones)
    :theName(name), theAddress(address), thePhones(phones), numTimesConsulted(0)
{} // 초기화 리스트를 통해서 대입이 아닌 '초기화'가 진행된다.

초기화 리스트에 들어가는 인자는 해당 데이터 멤버에 대한 생성자의 인자로 사용된다.

theName은 name 으로, theAddress는 address, thePhones 는 phones 를 통해서 복사 생성자에 의해서 초기화된다.

당연하겠지만, 이러한 방식이 '예시 1) 기본 생성자 호출 이후 대입' 보다 더 효율적이다.

기본 제공 타입 객체( int, double, char... )는 두 방식의 성능 차이가 거의 없지만 초기화 리스트를 사용하는 것이 좋다.

또한 사용자 정의 타입의 기본 생성자로 초기화하고 싶을 때에도 초기화 리스트를 사용하는 것이 좋다.

성능의 문제가 아니더라도 클래스 데이터 멤버는 모두 생성자 초기화 리스트를 사용하는 습관을 들이라는 말이다.

참고로, 상수이거나 참조자인 데이터 멤버는 대입이 아니라 반드시 초기화 되어야한다. ( 상수와 참조자는 대입 자체가 불가능하기 때문에 → 항목 5) 참조 )

특별히 데이터베이스나 파일에서 초기값을 찾아오는 경우가 아니라면 거의 대부분의 경우에는 초기화 리스트를 통한 초기화가 효율적이다.

C++ 에서 객체 초기화 순서

어떤 컴파일러라도 해당 순서는 일치한다.

  1. 기본 클래스는 파생 클래스보다 먼저 초기화 된다.
  2. 클래스 데이터 멤버는 선언되는 순서대로 초기화 된다.
    ( 설령 초기화 리스트에서의 순서가 다르더라도, 선언된 순서대로 초기화가 진행된다. 이 경우 컴파일 에러도 발생하지 않기 때문에 주의가 필요하다. )
  3. 비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다.

 

3 번 항목에 대해서 자세하게 설명하자면,

먼저 정적 객체( Static Object ) 는 자신이 생성된 시점부터 프로그램이 끝날 때까지 살아 있는 객체를 말한다.

때문에 스택( Stack ) 및 힙( Heap ) 영역에 존재하는 객체는 애초에 정적 객체가 될 수 없다.

더보기

  프로그램의 각 영역

 

code(text) 영역은,
프로그램의 code 자체를 구성하는 명령이나, 기계어 명령이 존재하고, 이 부분은 read only입니다.
그러므로 이곳에 데이터를 쓰려면 access violation 을 발생 시킵니다.
CPU 가 읽어 들여 수행한다고 해서 text라고 부르며, code영역 이라고도합니다.

Data 영역은,
전역(global)변수, 정적(static)변수, 초기화된 배열과 그 구조들이 저장되는 영역이고,
이 영역은 프로그램이 실행될 때 생성되고 프로그램이 종료될 때, 시스템에 반환됩니다.
( 생성된 시점부터 프로그램이 끝날 때까지 살아있다. )

heap 영역은,
프로그래머가 필요에 의해서 동적으로 할당되는 메모리가 위치하는 영역입니다.
즉, C++(또는 JAVA)에서 new 나 C 에서 malloc 으로 할당하는 것으로
C 또는 C++에서는 해제를 해줘야 하지만 자바의 경우엔 자동으로 가비지콜랙터(GarbageCollector)에 의해 자동으로 해체가 이루어집니다.

끝으로 stack 영역엔,
지역(local)변수 및 매개변수(parameter), 복귀 번지(retrun address) 등등이 저장되어 있는 곳입니다.
함수 호출시에 역시 stack 영역에 생성되고 사용된 후 시스템에 반환됩니다.
또한 함수로 인수(argument)를 보낼 때는 인수(argument)를 역순으로 보낸 뒤 복귀 번지(return address)를 저장합니다.
따라서 선입후출(First In Last Out)의 형태가 됩니다.

여기서 말하는 정적 객체의 종류에는,

1. 전역 객체, 2. 네임스페이스 유효 범위에서 정의된 객체, 3. 클래스 안에서 static으로 선언된 객체, 4. 함수 안에서 static으로 선언된 객체, 5. 파일 유효범위에서 static으로 선언된 객체로 다섯 가지 종류가 존재한다.

여기서 함수 내부에서 선언된 정적 객체는 지역 정적 객체 ( local static object ) 라고 하고, 나머지는 비지역 정적 객체 ( non-local static object ) 라고 한다.

이 모든 정적 객체는 main 함수가 끝나는 시점에 소멸자가 호출된다.

다음으로 번역 단위 ( Translation unit ) 는 컴파일을 통해서 하나의 목적 파일 ( Object File ) 을 만드는 바탕이 되는 소스 코드를 말한다.

( 다시 말해서, 하나의 Object 파일을 이루는 소스 코드를 말하는데, #include에 포함된 모든 파일들을 합쳐서 하나의 번역 단위가 된다. )

구체적인 예시를 말하자면,

서로 다른 소스 코드 파일이 두 개 이상 존재하고, 하나의 소스 파일(A.cpp)에서 선언 및 초기화된 비지역 정적 객체 ( 1, 2, 3, 5 ) 를 다른 소스 파일(B.cpp)에서 사용한다면 어떻게 될까?

A.cpp 의 비지역 정적 객체에 대한 초기화가 반드시 먼저 일어난 뒤에, B.cpp 에서 사용된다고 확신할 수 있을까?

 별개의 번역 단위에서 정의된 비지역 정적 객체들의 초기화 순서는 정해져 있지 않다.

// A.cpp
class FileSystem{
public:
    std::size_t numDisks() const;
};
 
extern FileSystem tfs;
// B.cpp
class Directory{
public:
    Directory(params);
};
 
Directory::Directory(params)
{
    std::size_t disks = tfs.numDisks();
}
 
Directory tempDir(params);

A.cpp 의 tfs 가 B.cpp의 tempDir 보다 먼저 초기화되지 않으면, tempDir 에는 초기화 되지 않는 값이 들어갈 수 있다.

이러한 상황을 방지하기 위해서는 설계의 변화가 필요하다.

비지역 정적 객체를 지역 정적 객체로 바꾸는 방식인데, 아래의 예시를 확인하자.

// A.cpp
class FileSystem{};
 
FileSystem& tfs()
{
    static FileSystem fs;
    return fs;
}
// B.cpp
class Directory{...};
 
Directory::Directory(params)
{
    std::size_t disks = tfs().numDisks();
}
 
Directory& tempDir()
{
    static Directory td;
    return td;
}

각 파일 내부에 함수 하나씩 선언하고,

비지역 정적 객체 ( 기존의 tfs, tempDir ) 를 해당 함수 내부로 이동시킨 뒤에, 해당 객체의 참조자를 반환하도록 만든다.

지역 정적 객체는 함수 호출 중에 그 객체의 정의에 최초로 닿았을 때 초기화 된다. ( C++ 에서 무조건 보장하는 항목 )

위의 개선 코드에서 설령 B.cpp가 먼저 실행되고 Directory 생성자가 실행되어서 tfs() 함수가 실행되더라도

fs 는 지역 정적 객체이므로 최초로 함수 호출이 되는 순간에 초기화가 진행된다.

즉, B.cpp의 td 지역 정적 객체는 무조건 초기화 된 fs 지역 정적 객체를 사용함을 보장할 수 있게 된다.

이러한 설계 방식이 바로 ' 싱글톤 패턴 ( Singleton pattern ) ' 의 전통적인 구현 방식이다.

( 초기화 순서가 정해지지 않는 비지역 정적 객체를 사용하는 것에 안정성을 보장해주는 패턴 )

다중 스레드 환경에서는 정적 객체에 대한 활용 자체가 매우 까다롭지만,

적어도 단일 스레드 환경에서는 굉장히 유용한 패턴이다.

( 하지만 B 객체 초기화 이전에 A 객체 초기화가 보장 되어야 하는데,

정작 A 객체는 B의 초기화에 의존하는 것과 같은 말도 안 되는 형식은 무조건 없어야 한다. )

 

Things to Must Remember

  • 기본제공 타입의 객체는 반드시 직접 초기화 해야 한다.
  • 생성자 내부에서의 초기화는 반드시 초기화 리스트 방식을 사용하자. 그리고 가능하다면 데이터 멤버 선언 순서와 일치시키자.
  • 비지역 정적 객체의 초기화 순서 문제는 정해져 있지 않다. 때문에 필요하다면, 지역 정적 객체로 바꾸는 싱글톤 패턴 방식을 사용해야한다.

관련글 더보기

댓글 영역