Post

[Effective C++] 1. C++에 왔으면 C++의 법을 따릅시다 [2/2]

[Effective C++] 1. C++에 왔으면 C++의 법을 따릅시다 [2/2]

이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.

항목 3: 낌새만 보이면 const를 들이대 보자!


const의 면모에 대해 생각해 볼 때 정말 멋지다고 말할 수 있는 부분이 있다면 아마도 ‘의미적인 제약’(const 키워드가 붙은 객체는 외부 변경을 불가능하게 한다)을 소스 코드 수준에서 붙인다는 점과 컴파일러가 이 제약을 지켜준다는 점이다. 어떤 값이 불변이어야 한다는 제작자의 의도를 컴파일러 및 다른 프로그래머와 나눌 수 있는 수단이다. 값이 변하면 안되는게 맞는다면, 우리도 반드시 지켜야 한다. 이렇게 해야 컴파일러가 제약 위반을 막는 일에 한몫 거들 수 있기 때문이다.

const 키워드는 클래스 바깥에서는 전역 혹은 네임스페이스 유효범위의 상수를 선언(정의)하는 데 쓸 수 있다. 그뿐 아니라 파일, 함수, 블록 유효범위에서 static으로 선언한 객체에도 const를 붙일 수 있다. 클래스 내부의 경우에는, 정적 멤버 및 비정적 데이터 멤버 모두를 상수로 선언할 수 있다. 포인터는 어떨까? 기본적으로는 포인터 자체를 상수로, 혹은 포인터가 가리키는 데이터를 상수로 지정할 수 있는데, 둘 다 지정할 수도 있고 아무것도 지정하지 않을 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
char greeting[] = "Hello";

// 비상수 포인터, 비상수 데이터
char *p = greeting;

// 비상수 포인터, 상수 데이터
const char *p = greeting;

// 상수 포인터, 비상수 데이터
char * const p = greeting;

// 상수 포인터, 상수 데이터
const char * const p = greeting;

const의 키워드가 *표 왼쪽에 있으면 포인터가 가리키는 대상이 상수이다. 반면에, const가 *표의 오른쪽에 있으면 포인터 자체가 상수이다. const가 *표의 양쪽에 다 있으면 포인터가 가리키는 대상 및 포인터가 다 상수를 뜻이다.

포인터가 가리키는 대상을 상수로 만들 때 const를 사용하는 스타일은 조금씩 다르다. 어떤 프로그래머는 타입 앞에 const를 붙이기도 한다. 그 외에는 타입의 뒤쪽이자 *표의 앞에 const를 붙이는 사람들이겠다. 의미적인 차이는 전혀 없다. 즉, 아래의 함수들이 받아들이는 매개변수 타입은 모두 똑같다는 이야기이다.

1
2
3
4
// f1은 상수 Widget 객체에 대한 포인터를 매개변수로 취한다.
void f1(const Widget *pw);
// f2도 동일하다.
void f2(Widget const *pw);

두 가지 형태 모두가 현업 개발자들의 코드에 아주 잘 쓰이고 있으므로, 잘 익혀두자.

STL 반복자는 포인터를 본뜬 것이기 때문에, 기본적인 동작 원리가 T* 포인터와 유사하다. 어떤 반복자를 const로 선언하는 일은 포인터를 상수로 선언하는 것(T* const 포인터)과 같다. 반복자는 자신이 가리키는 대상이 아닌 것을 가리키는 경우가 허용되지 않지만, 반복자가 가리키는 대상 자체는 변경이 가능하다. 만약 변경이 불가능한 객체를 가리키는 반복자(즉, const T* 포인터의 STL 대응물)가 필요하다면 const_iterator를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
std::vector<int> vec;
...
// iter는 T* const처럼 동작한다.
const std::vector<int>::iterator iter = vec.begin();

*iter = 10; // iter가 가리키는 대상을 변경하는건 가능하다.
++iter;     // iter 자체는 상수이므로 iter를 증가시키는 것은 불가능하다.

// cIter는 const T*처럼 동작한다.
std::vector<int>::const_iterator cIter = vec.begin();

*cIter = 10; // *cIter가 상수이기에 안된다.
++cIter;     // cIter를 변경하는 것은 가능하다.

뭐니 뭐니 해도 가장 강력한 const의 용도는 함수 선언에 쓸 경우이다. 함수 선언문에 있어서 const는 함수 반환 값, 각각의 매개변수, 멤버 함수 앞에 붙을 수 있고, 함수 전체에 대해 const의 성질을 붙일 수 있다.

함수 반환 값을 상수로 정해 주면, 안전성이나 효율을 포기하지 안혹도 사용자측의 에러 돌발 상황을 줄이는 효과를 꽤 자주 볼 수 있다. 한 예로, 항목 24에서 살펴볼 유리수 클래스에서 operator* 함수가 어떻게 선언되어 있는지를 보자.

1
2
3
class Rational { ... };

const Rational operator*(const Rational& lhs, const Rational& rhs);

operator*의 반환 값이 상수 객체일 이유를 모르겠다는 프로그래머도 있을 수 있는데, 상수 객체로 되어 있지 않으면 사용자 쪽에서 저지르는 아래와 같은 실수를 지켜볼 수 밖에 없다.

1
2
Rational a, b, c;
(a * b) = c; // a*b의 결과에 대고 operator=를 호출

우리가 두 수의 곱에 대입 연산을 취하려고 하지 않겠지만, 어쩌다가 키보드를 잘못 눌러서 경험해 본 프로그래머는 있을 것이다.

1
if (a * b = c) ... // 이처럼 비교하려고 그랬던건데, 오타가 난 것이다.

위의 코드는 a 및 b의 타입이 기본제공 타입이었다면 문법 위반에 걸리겠지만, 사용자 정의 타입이라면 그렇지 않을 수 있다. 훌륭한 사용자 정의 타입들의 특징 중 하나는 기본제공 타입과의 쓸데없는 비호환성을 피한다는 것인데, 위에서 본 바와 같이 두 수의 곱에 대입 연산이 되도록 놓아두는 것이 바로 ‘쓸데없는’ 경우가 된다. operator*의 반환 값을 const로 정해 놓으면 이런 경우를 미연에 막을 수 있다. 그렇기 때문에 상수 반환 값 지정이 정답이 되는 것이다.

const 매개변수에 대해선 특별히 새로 할 이야기는 없다. 그냥 const 타입의 지역 객체와 특성이 같다. 그리고 이것 역시 가능한 한 항상 사용하자. 매개 변수 혹은 지역 객체를 수정할 수 없게 하는 것이 목적이라면 const로 선언하는 것을 잊지 말자.


상수 멤버 함수

멤버 함수에 붙는 const 키워드의 역할은 “해당 멤버 함수가 상수 객체에 대해 호출될 함수이다”라는 사실을 알려주는 것이다. 그런데 이런 함수가 왜 중요할까? 이유는 두 가지이다.

첫째는 클래스의 인터페이스를 이해하기 좋게 하기 위해서 그 클래스로 만들어진 객체를 변경할 수 있는 함수는 무엇이고, 또 변경할 수 없는 함수는 무엇인가를 사용자 쪽에서 알고 있어야 하는 것이다. 둘째는 이 키워드를 통해 상수 객체를 사용할 수 있게 하자는 것인데, 코드의 효율을 위해 아주 중요한 부분이기도 하다. 항목 20에서 이야기할 것인데, c++ 프로그램의 실행 성능을 높이는 핵심 기법 중 하나가 객체 전달을 ‘상수 객체에 대한 참조자(reference to const)’로 하는 것이다. 그런데 이 기법이 제대로 적용되려면 상수 상태로 전달된 개체를 조작할 수 있는 const 멤버 함수가 준비되어 있어야 한다.

const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다. 이 성질은 C++의 아주 중요한 성질이니 꼭 외워두자. 한 예로, 문서의 한 구역을 나타내는 데 쓰려고 만든 클래스를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class TextBlock
{
public:
    // 상수 객체에 대한 operator[]
    const char& operator[](std::size_t position) const
    { return text[position]; }
    
    // 비상수 객체에 대한 operator[]
    char& operator[](std::size_t position)
    { return text[position]; }
    
private:
    std::string text;
};

위처럼 선언된 TextBlock의 operator[]는 다음과 같이 쓸 수 있다.

1
2
3
4
5
6
7
8
TextBlock tb("Hello");

// TextBlock::operator[]의 비상수 멤버 호출
std::cout << tb[0];

const TextBlock ctb("World");
// TextBlock::operator[]의 상수 멤버 호출
std::cout << ctb[0];

실제 프로그램에서 상수 객체가 생기는 경우는 1. 상수 객체에 대한 포인터 혹은 2. 상수 객체에 대한 참조자로 객체가 전달될 때이다. 위의 ctb 예제는 이해를 돕기 위한 용도의 성격이 짙고, 아래의 예제가 더 실제의 경우와 가깝다.

1
2
3
4
5
void print(const TextBlock& ctb)
{
    // TextBlock::operator[]의 상수 멤버 호출
    std::cout << ctb[0];
}

operator[]를 ‘오버로드(overload)’해서 각 버전마다 반환 타입을 다르게 가져갔기 때문에, TextBlock의 상수 객체와 비상수 객체의 쓰임새가 달라진다.

1
2
3
4
5
6
7
8
9
10
11
// 가능. 비상수 버전의 TextBlock 객체를 읽는다.
std::cout << tb[0];

// 가능. 비상수 버전의 TextBlock 객체를 쓴다.
tb[0] = 'x';

// 가능. 상수 버전의 TextBlock 객체를 읽는다.
std::cout << ctb[0];

// 불가능. 상수 버전의 TextBlock 객체를 쓸 수 없다. 컴파일 에러 발생
ctb[0] = 'x';

주의할 것이 하나 있는데, 넷째 줄에서 발생한 에러는 순전히 operator[]의 반환 타입 때문에 생긴 것이란 점이다. 그러니까 operator[]의 호출이 잘못된 것은 없다. 이 에러는 const char& 타입에 대입 연산을 시도했기 때문에 생긴 것이다. 상수 멤버로 되어 있는 operator[]의 반환 타입이 const char&이기 때문이다.

하나 더 눈여겨 볼 부분이 있다. operator[]의 비상수 멤버는 char의 참조자(reference)를 반환한다는 것인데, char 하나만 쓰면 안 된다는 점을 꼭 주의하자. 만약 operator[]가 그냥 char를 반환하게 만들어져 있으면, 다음과 같은 문장이 컴파일되지 않게 된다.

1
tb[0] = 'x';

왜냐하면 기본제공 타입을 반환하는 함수의 반환 값을 수정하는 일은 절대로 있을 수 없기 때문이다. 설령 이것이 합법적으로 통한다고 해도, 반환 시 ‘값에 의한 반환’을 수행하는 C++의 성질이 버티고 있다. 즉, 수정되는 값은 tb.text[0]의 사본이지, tb.text[0] 자체가 아니다. 이건 우리가 의도한 동작은 아닐 것이다.


어떤 멤버 함수가 상수 멤버라는 것이 대체 어떤 의미일까? 여기에는 굵직한 양대 개념이 자리 잡고 있다. 하나는 비트 수준의 상수성[bitwise constness] (다른 말로는 물리적 상수성(physical constness)이라고도 함)이고, 또 하나는 논리적 상수성(logical constness)이다.

비트수준의 상수성은 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야(정적 멤버는 제외) 그 멤버 함수가 const임을 인정하는 개념이다. 즉, 그 객체를 구성하는 비트들 중 어떤 것도 바꾸지 안 된다는 것이다. 비트수준 상수성을 사용하면 상수성 위반을 발견하는 데 힘들지 않다. 컴파일러는 데이터 멤버에 대해 대입 연산이 수행되었는지만 보면 되니까이다. 사실 C++에서 정의하고 있는 상수성이 비트수준 상수성이다. 그리고 상수 멤버 함수는 그 함수가 호출된 객체의 어떤 비정적 멤버도 수정할 수 없게 되어 있다.

그런데, 애석하게도 ‘제대로 const’로 동작하지 않는데도 이 비트수준 상수성 검사를 통과하는 멤버 함수들이 적지 않다. 어떤 포인터가 가리키는 대상을 수정하는 멤버 함수들 중 상당수가 이런 경우에 속하낟. 하지만 그 포인터가 객체의 멤버로 들어 있는 한, 이 함수는 비트수준 상수성을 갖는 것으로 판별되고 컴파일러도 에러를 내지 않는다. 그런데 이것 때문에 상식적으로 이해가 안되는 동작이 생길 수 있다. 예를 들어 TextBlock 비슷한 클래스를 하나 만들어서 가지고 있는데, 내부 데이터가 string이 아니고 char*로 관리된다고 가정하자. 이렇게 만든 이유는 string 객체를 사용할 수 없는 C API에도 이것을 쑬 수 있게 하기 위해서이겠다.

1
2
3
4
5
6
7
8
9
10
11
class CTextBlock
{
public
    // 부적절한 (그러나 비트수준 상수성이 있어서 허용되는) operator[]의 선언
    char& operator[](std::size_t position) const
    { return pText[position]; }
    
private:
    char* pText;
}

코드에서 나와 있듯이 operator[]가 상수 멤버 함수로 선언되어 있다. 이 함수는 그럼에도 불구하고 해당 객체의 내부 데이터에 대한 참조자를 반환해버린다. 참조자를 반환하면 외부에서 수정할 수 있다. operator[]의 내부 코드만 보면 pText는 안건드린다는 점은 확실하기에 컴파일러가 이에 대한 코드를 생성할 때 에러를 내지 않는다. 어쨌든 비트 수준에서 상수성을 지키고 있고, 컴파일러 선에서는 이것까지만 점검하면 끝이기 때문이다. 하지만 이로 인해 어떤 사태가 생길지는 아래에서 확인해 보도록 하자.

1
2
3
4
5
6
const CTextBlock cctb("Hello");
// 상수 버전의 operator[]를 호출하여 cctb의 내부 데이터에 대한 포인터를 얻는다.
char *pc = &cctb[0];

// cctb는 이제 "Jello"라는 값을 갖는다.
*pc = 'J';

잘못됐다. 어떤 값으로 초기화된 상수 객체를 하나 만들어 놓고 이것에다 상수 멤버 함수를 호출했더니 값이 변해버린 것이다.

논리적 상수성이란 개념을 이런 황당한 상황을 보완하는 대체 개념으로 나오게 되었다. 이 개념을 부르짖는 사람들의 주장은 이렇다. 상수 멤버 함수라고 해서 객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀 수 있되, 그것을 사용자측에서 알아채지 못하게만 하면 상수 멤버 자격이 있다는 것이다. 예를 하나 들어보자. CTextBlock 클래스는 문장 구역의 길이를 사용자들이 요구할 때마다 이 정보를 캐시해 둘 수 있을 것이다. 그래서 다음과 같은 멤버를 둘 수 있을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class CTextBlock
{
public:
    std::size_t length() const;

private:
    char* pText;
    std::size_t textLength; // 바로 직전에 계산한 텍스트 길이
    bool lengthIsValid;     // 이 길이가 현재 유효한가?
};

std::size_t CTextBlock::length() const
{
    if (!lengthIsValid)
    {
        // 에러! 상수 멤버 함수 안에서는 textLength 및 lengthIsValid에 대입할 수 없다.
        textLength = std::strlen(pText);
        lengthIsValid = true;
    }
    return textLength;
}

length의 구현은 너무나도 자명하게 ‘비트수준 상수성’과 멀리 떨어져 있다. textLength와 lengthIsValid가 바뀔 수 있으니까이다. 그렇지만 CTextBlock의 상수 객체에 대해서는 당연히 아무 문제가 없어야 할 것 같은 코드이다. 컴파일러는 에러를 낼 것이다. 컴파일러의 검열을 통과하려면 비트수준의 상수성이 지켜져야 하는데, 이런 상황에서는 어떻게 해야 할까?

해답은 단순하다. mutable을 사용하는 것이다. mutable은 비정적 데이터 멤버를 비트수준 상수성의 족쇄에서 풀어 주는 키워드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class CTextBlock
{
public:
    std::size_t length() const;
    
private:
    char* pText;
    // 이 데이터 멤버들은 어떤 순간에도 수정이 가능하다. 심지어 상수 멤버 함수 안에서도 수정할 수 있다.
    mutable std::size_t textLength;
    mutable bool lengthIsValid;
};

std::size_t CTextBlock::length() const
{
    if (!lengthIsValid)
    {
        // 이제 문제없다.
        textLength = std::strlen(pText);
        lengthIsValid = true;
    }
    return textLength;
}


상수 멤버 및 비상수 멤버 함수에서 코드 중복 현상을 피하는 방법

mutable은 생각지도 않던 비트수준 상수성이 웬말이냐 문제를 단어 하나로 해결하는 꽤 괜찮은 방법임엔 틀림없지만, 이것으로 const에 관련된 골칫거리를 전부 말끔히 씻어내진 못한다. 또 예를 들어보자. TextBlock(및 CTextBlock)의 operator[] 함수가 지금은 특정 문자의 참조자만 반환하고 있지만, 이것 말고도 여러 가지를 더 할 수도 있을것이다. 경계 검사라든지 접근정보 로깅도 그렇고, 게다가 내부자료 무결성 검증도 못할리 없다. 이런저런 코드를 모조리 operator[]의 상수/비상수 버전에 넣어 버리면 어느덧 코드 판박이 괴물이 있을 것이다.

컴파일 시간, 유지보수, 코드 크기 부풀림 등을 감당할 수 없다. 경계 검사 등의 자질구레한 코드를 별도의 private 멤버 함수로 옮겨 두고 이것을 operator[]의 양 버전에서 호출하게 만들면 나쁘지 않을 수도 있다. 하지만 이렇게 해도 함수 호출이 두 번씩 되기에 코드 중복은 여전하고, return 문 또한 중복 코드이다.

사실은 우리는 operator[]의 핵심 기능을 한 번만 구현해 두고 이것을 두 번 사용하고 싶은 마음이 있다. 그러니까, operator[]의 양 버전 중 하나만 제대로 만들고 다른 버전은 이것을 호출하는 식으로 만들고 싶은 것이다. 그러고 보니, const 껍데기를 캐스팅으로 날리면 어떨까 하는 생각에 이르게 된다.

기본적으로, 캐스팅은 일반적으로도 통념적으로도 썩 좋지 않은 아이디어이다. 심지어 이 책에서도 이거 하지 말라는 이야기를 아예 한 항목으로 만들어 버렸다. 하지만 코드 중복도 결코 가벼운 일은 아니다. 지금의 경우, operator[]의 상수 버전은 비상수 버전과 비교해서 하는 일이 정확히 똑같다. 단지 다른 점이 있다면 반환 타입에 const 키워드가 덧붙어 있다는 것뿐이다. 따라서 여기서는 캐스팅을 써서 반환 타입으로부터 const 껍데기를 없애더라도 안전하다. 왜냐하면 비상수 operator[] 함수를 호출하는 쪽이라면 그 호출부엔 비상수 객체가 우선적으로 들어 있을 게 분명하기 때문이다. 그렇지 않았다면 비상수 멤버 함수를 호출했을리도 만무하다. 결론은, 캐스팅이 필요하긴 하지만, 안전성도 유지하면서 코드 중복을 피하는 방법은 비상수 operator[]가 상수 버전을 호출하도록 구현하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class TextBlock
{
    const char& operator[](std::size_t position) const
    {
        // ... 이전과 동일
        return text[position];
    }
    
    char& operator[](std::size_t position)
    {
        return
        const_cast<char&>(                  // operator[]의 반환 타입에 캐스팅을 적용, const를 떼어낸다.
            static_cast<const TextBlock&>   // *this의 타입에 const를 붙인다.
                (*this)[position]           // operator[]의 상수 버전을 호출한다.
        );
    }
}

보면 알겠지만 캐스팅이 두 번 되어 있다. 지금 해야 하는 일은 비상수 operator[]가 상수 버전을 호출하게 하는 것이다. 그런데 비상수 operator[] 속에서 그냥 operator[]라고 적으면 그 자신이 재귀적으로 호출되고 말거다. 그 대신에 차선책이 *this의 타입 캐스팅이다. 원래의 타입인 TextBlock&에서 const TextBlock&으로 바꾸는 것이다. 캐스팅을 쓴 이유는 const가 붙어야 하기 때문이었다. 결국 정리하면, 두 개의 캐스팅 중 첫 번째 것은 *this에 const를 붙이는 캐스팅이고, 두 번째 것은 상수 operator[]의 반환 값에서 const를 떼어내는 캐스팅이다.

const를 붙이는 캐스팅은 안전한 타입 변환을 강제로 진행하는 것뿐이기 때문에 static_cast만 써도 딱 맞는다. 반면에 const를 제거하는 캐스팅은 const_cast밖에 없으므로 별다른 선택의 여지가 없다.

참고로, 앞의 방법을 뒤집어서 하는 쪽(즉, 코드 중복 회피를 위해 상수 버전이 비상수 버전을 호출하게 만드는 것)도 생각할 수 있는데, 이건 우리가 원하는 바가 아니다. 혹시 잊었을까봐 재차 강조하는데, 상수 멤버 함수는 해당 객체의 논리적인 상태를 바꾸지 않겠다고 컴파일러와 굳게 약속한 함수인 반면, 비상수 멤버 함수는 이런 약속 같은 걸 하지 않는다. 즉, 어쩌다가 상수 멤버에서 비상수 멤버를 호출하게 되면, 수정하지 않겠다고 약속한 그 객체를 배신하는 셈이 되고 그 객체는 변경될 위험에 빠질 수 있다. 그렇기 때문에 상수 멤버 함수에서 비상수 멤버 함수를 호출하면 틀리다는 이야기가 나오는 것이다.

const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 준다. const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및 반환 타입에도 붙을 수 있으며, 멤버 함수에도 붙을 수 있다.

컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 우리는 개념적인(논리적인) 상수성을 사용해서 프로그래밍해야 한다.


C++23의 명시적 객체 매개변수 (deducing this, this 추론)


항목 3의 마지막 부분에서 상수/비상수 버전의 코드 중복 문제는 C++23의 deducing this으로 캐스팅 없이 단 하나의 함수로 합칠 수 있게 되었다.

기본적으로 C++의 멤버 함수에서 this 포인터는 암시적이다. 하지만 C++23부터는 함수의 첫 번째 매개변수로 this를 직접 써줄 수 있게 되었다.

정적(static)이 아니고 가상(virtual)이 아니며, cv-한정자(const, volatile)나 참조 한정자(&, &&)가 붙지 않은 멤버 함수의 경우, 그 함수의 첫 번째 매개변수가 함수 매개변수 팩이 아니라면 this 키워드가 붙은 명시적 객체 매개변수가 될 수 있다.

1
2
3
4
5
6
7
struct X
{
    void foo(this X const& self, int i); // void foo(int i) const &;와 동일
    // void foo(int i) const &; // 에러. 이미 선언됨
    
    void bar(this X self, int i); // 값에 의한 객체 전달. *this에 대한 복사본을 만든다.
}

멤버 함수 템플릿의 경우, 명시적인 객체 매개변수를 사용하면 유형과 값 범주를 추론할 수 있으며, 이 기능을 deducing this라고 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct X
{
    template<typename Self>
    void foo(this Self&&, int);
};
 
struct D : X {};
 
void ex(X& x, D& d)
{
    x.foo(1);       // Self = X&
    move(x).foo(2); // Self = X
    d.foo(3);       // Self = D&
}

이 방식을 사용하면 아래처럼 상수/비상수 버전의 operator[]를 하나로 합칠 수 있다.

1
2
3
4
5
6
7
8
class TextBlock
{
public:
    template <typename Self>
    auto& operator[](this Self&& self, std::size_t position) 
    {
        return self.text[position];
    }


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


객체의 값을 초기화하는 데 있어서 C++는 초기화될 수도 있고 안될 수도 있다.

예를 들어,

1
int x;

어떤 상황에서는 x의 값이 0으로 확실히 초기화되지만, 또 다른 상황에서는 보장되지 않는다. 다른 예를 하나 더 보자.

1
2
3
4
5
6
class Point
{
    int x, y;
}

Point p;

p의 데이터 멤버 역시 어떤 상황에서는 초기화가 보장되지만 어쩔 떄는 또 안된다.

초기화되지 않은 값을 읽도록 내버려 두면 정의되지 않은 동작이 그대로 흘러 나오게 된다. 어떤 플랫폼의 경우 미초기화 객체를 읽기만 해도 프로그램이 서 버리기도 한다. 대체적인 경우에는 적당히 무작위 비트의 값을 읽고 객체의 내부가 이상한 값을 갖게 된다.

C++의 객체(변수) 초기화가 중구난방인 것은 절대 아니다. 언제 초기화가 보장되며 언제 그렇지 않은지에 대한 규칙이 명확히 준비되어 있다. 안타까운 점은 규칙 자체가 아주 조금 복잡하다는 것인데, 필자의 말로는 머리에 새겨둘 가치가 있기엔 너무 복잡하다고 하다. 일단 일반적인 사항부터 정리해 보자. C++의 C 부분만을 쓰고 있으며 초기화에 런타임 비용이 소모될 수 있는 상황이라면 값이 초기화된다는 보장이 없다. 그렇지만 C가 아닌 부분으로 발을 거치게 되면 사정이 때때로 달라진다. C++의 C 부분인 배열은 각 원소가 확실히 초기화된다는 보장이 없으나 C++의 STL 부분은 vector는 그렇나 보장을 갖게 되는 이유가 바로 이런 법칙 때문이다.

가장 좋은 방법은 모든 객체를 사용하기 전에 항상 초기화하는 것이다. 기본제공 타입으로 만들어진 비멤버 객체에 대해서는 초기화를 손수 해야 한다. 아래 예시를 보자.

1
2
3
4
5
6
7
8
9
// int의 직접 초기화
int x = 0;

// 포인터의 직접 초기화
const char* text = "A C-style string";

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

이런 부분을 제외하고 나면, C++ 초기화의 나머지 부분은 생성자로 귀결된다. 생성자에서 지킬 규칙은 지극히 간단하다. 그 객체의 모든 것을 초기화하자! 이것만 지키면 된다.

지키기 쉬운 규칙이나, 대입(assignment)을 초기화(initialization)와 헷갈리지 않는 것이 가장 중요하다. 주소록의 개인별 기재사항을 나타내는 클래스를 한 예로 보자. 이 클래스의 생성자는 다음과 같이 구현되어 있다고 가정하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 객체는 우리가 원했던 값을 가지지만, 개운한 방법은 아니다. C++ 규칙에 의하면 어떤 객체이든 그 객체의 데이터 멤버는 생성자의 본문이 실행되기 전에 초기화되어야 한다고 명기되어 있다. 현재의 ABEntry 생성자를 보면, theName, theAddress, thePhones는 여기서 초기화되고 있는 것이 아니라, 어떤 값이 대입되고 있는 것이다. 초기화는 진작 지나갔다. 정확히 말하자면, ABEntry 생성자에 진입하기도 전에 이들 세 데이터 멤버의 기본 생성자가 호출되었다. 잠깐, 그럼 numTimesConsulted도 미리 초기화되었을까? 꼭 그런 것은 아니다. 기본제공 타입의 데이터 멤버이기 떄문이다. 기본 제공 타입의 경우에는 생성자 안에서 대입되기 전에 초기화되리란 보장이 없다.

ABEntry 생성자를 더 깔끔하게 만드려면 대입문 대신에 멤버 초기화 리스트를 사용하면 된다.

1
2
3
4
5
6
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에 대해 기본 생성자를 호출해서 초기화를 미리 해 놓은 후에 생성자에서 곧바로 새로운 값을 대입하고 있다. 따라서 먼저 호출된 기본 생성자에서 해 놓은 초기화는 아깝게도 헛짓이 되고 말았다. 이 문제는 멤버 초기화 리스트를 사용하면 피해 갈 수 있다. 초기화 리스트에 들어가는 인자는 바로 데이터 멤버에 대한 생성자의 인자로 쓰이기 떄문이다. 대부분의 데이터 타입에 대해서는, 기본 생성자 호출 후에 복사 대입 연산자를 연달아 호출하는 이전의 방법보다 복사 생성자를 한 번 호출하는 쪽이 더 효율적이다. 어쩔 떄는 훨씬 더 그렇다.

앞에서 말한 ‘대부분의 타입’에 포함되지 않는 타입이 numTimesConsulted와 같은 기본 제공 타입이다. 기본제공 타입의 객체는 초기화와 대입에 걸리는 비용의 차이가 없지만, 역시 멤버 초기화 리스트에 모두 넣어 주는 쪽이 좋다. 또, 데이터 멤버를 기본 생성자로 초기화하고 싶을 떄도 멤버 초기화 리스트를 사용하는 습관을 들이자. 생성자 인자로 아무것도 주지 않으면 되니, 그리 힘든 일도 아니다. 예를 들어, 매개변수 없는 생성자가 ABEntry 클래스에 들어 있었다면, 아마 아래처럼 구현할 수 있을 것이다.

1
2
3
ABEntry::ABEntry()
    : theName(), theAddress(), thePhones(), numTimesConsulted(0)
{ }

위와 같이 하는 것이 오버가 아닌가라고 생각하는 개발자도 있을 것이다. 어떤 데이터 멤버가 멤버 초기화 리스트에 들어가지 않았고 그 데이터 멤버의 타입이 사용자 정의 타입이면, 컴파일러가 자동으로 그들 멤버에 대해 기본 생성자를 호출하기 되어 있기 때문이다. 틀린 이야기는 아니다. 하지만 기본 생성자이든 아니든 클래스 데이터 멤버는 모두 초기화 리스트에 올려두는 습관을 들여야만, 어쩌다가 리스트에서 어떤 멤버를 빼먹었을 때 어떤 멤버가 초기화되지 않을 수 있다는 사실을 끌고 가야 하는 부담이 없어진다. numTimesConsulted가 멤버 초기화 리스트에서 빠졌다고 생각해 보자. 이 멤버의 타입은 기본제공 타입이니까, 이것이 초기화될지 안 될지 장담을 못하는 거다.

기본 제공 타입의 멤버를 초기화 리스트로 넣는 일이 선택이 아니라 의무가 될 때도 있다. 상수이거나 참조자로 되어 있는 데이터 멤버의 경우엔 반드시 초기화되어야 한다. 이것은 아주 중요한데, 상수와 참조자는 대입 자체가 불가능하기 때문이다. 어떤 경우는 데이터 멤버를 꼭 초기화해야 하고 또 어떤 경우는 안 해도 되는 경우를 하나하나 외울 빠에는, 멤버 초기화 리스트를 항상 사용하는 편이 더 쉬울 것이다. 필수적으로 그래야 할 때도 있는데다가, 대입보다 더 효율적일 경우가 많기도 하다.

현장에서 쓰이는 클래스들 중 상당수가 여러 개의 생성자를 가지고 있다. 각 생성자마다 멤버 초기화 리스트가 붙어 있을 것이다. 만약 이런 클래스에 데이터 멤버와 기본 클래스가 적지 않게 붙어 있다면, 생성자마다 주렁주렁 매달려 있는 멤버 초기화 리스트의 모습은 그리 예뻐 보이지 않을 것이다. 같은 멤버들이 여러번 보이는 건 물론이고 코드도 지겨워진다. 이런 경우를 자주 접하게 되어 간결하게 작성하고 싶다면, 대입으로도 초기화가 가능한 데이터 멤버들을 초기화 리스트에서 빼내어 별도의 함수로 옮기는 것도 나쁘지 않다. 이들에 대한 대입 연산을 하나의 함수에 몰아놓고, 모든 생성자에서 이 함수를 호출하게 하는 거다. 이 방법은 데이터 멤버의 진짜 초기값을 파일에서 읽어온다든지 데이터베이스에서 찾아오는 경우에 특히 유용하게 사용할 수 있다. 하지만 일반적인 경우만 따지면 대입을 통한 가짜초기화보다는 진짜 멤버 초기화(초기화 리스트)가 아무래도 좋다.

C++에서의 객체 초기화는 꽤나 변덕스럽다는 이야기였다. 이 와중에도 꼭 알아두어야 할 변덕스럽지 않은 부분이 하나 있다. 바로 객체를 구성하는 데이터의 초기화 순서이다. 이 순서는 어떤 컴파일러를 막론하고 항상 똑같다. 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하는 파일들까지 합쳐서 하나의 번역 단위가 된다.

그러면 문제는 이렇게 정리된다. 별도로 컴파일된 소스 파일이 두 개 이상 있으며 각 소프 파일에 비지역 정적 객체가 한 개 이상 들어 있는 경우에 어떻게 되드냐 하는거다. 그리고 실제적인 문제는 이것이다. 한쪽 번역 단위에 있는 비정적 객체의 초기화가 진행되면서 다른 쪽 번역 단위에 있는 비지역 정적 객체가 사용되는데, 불행히도 이 다른 쪽 번역 단위에 있는 객체가 초기화되어 있지 않을지도 모른다는 점이다. 이유는 위에서 말했듯이 별개의 번역 단위에서 정의된 비지역 정적 객체들의 초기화 순서는 정해져 있지 않다라는 사실 때문이다.

예제를 하나 보고 가자.

1
2
3
4
5
6
7
8
9
class FileSystem
{
public:
    ...
    std::size_t numDisks() const; // 많고 많은 멤버 함수들 중 하나
    ...
};

extern FileSystem tfs; // tfs = "the file system". 사용자가 쓰게 될 객체

FileSystem 객체가 그리 가볍지 않고, 이 클래스로 만든 객체가 초기화되기(생성자가 호출되기) 전에 그 객체를 사용한단느 것은 대재앙일 것이다. 이제 이 객체의 사용자 쪽으로 초점을 돌려보자. 파일 시스템 내의 디렉토리를 나타내는 클래스를 사용자가 만들었다고 가정해 보자. 이 클래스는 tfs를 사용하는 게 자연스러울 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Directory
{
public:
    Directory( params };
    ...
};

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

어짜피 가정이니, 한 발짝 더 나아가보자. 이제는 이 사용자가 Directory 클래스를 사용해서 임시 파일을 담는 디렉토리 객체 하나를 생성하기로 마음 먹는다.

1
Directory tempDir( params );

정적 객체의 초기화 순서 때문에 문제가 심각해질 수도 있는 상황이 드디어 눈 앞에 나타났다. tfs가 tempDir보다 먼저 초기화되지 않으면, tempDir의 생성자는 tfs가 초기화되지도 않았는데 tfs를 사용하려고 할 것이다. 그러나 tfs와 tempDir은 제작자도 다르고 만들어진 시기도 다른데다가 소스 파일도 다르다. 다시 말해 이들은, 다른 번역 단위 안에서 정의된 비지역 정적 객체이다. 어쨌든 tempDir 전에 tfs가 초기화되기 만들고 싶은데, 이 목표를 어떻게 달성할 수 있을까?

단언컨대 안된다. 말했듯이, 서로 다른 번역 단위에 정의된 비지역 정적 객체들 사이의 상대적인 초기화 순서는 정해져 있지 않다. C++가 원망스러워진다. 비지역 정적 객체들의 초기화에 대해 적절한 순서를 결정하는 것은 도저히 해답이 안 나올 정도로 정말 어렵다. 가장 일반적인 형태인 암시적 템플릿 인스턴스화로 생성된 비지역 정적 객체가 여러 번역 단위에 들어 있는 경우를 놓고 시작해 보아도, 초기화 순서를 어떻게 정해야 맞는지 알 수도 없을 뿐더러, 맞는 순서를 결정할 수 있는 특수한 상황을 찾는 일도 그다지 보람이 없는 경우가 대부분이다.

한 가지 다행인 사실은 설계에 약간의 변화만 주면 이 문제를 사전에 봉쇄할 수 있다 방법도 간단하다. 비지역 정적 객체를 하나씩 맡는 함수를 준비하고 이 안에 각 객체를 넣는 것이다. 함수 속에서도 이들은 정적 객체로 선언하고, 그 함수에서는 이들에 대한 참조자를 반환하게 만든다. 사용자 쪽에서는 비지역 정적 객체를 직접 참조하는 과거의 폐단을 버리고 이제는 함수 호출로 대신한다. 정리하면, 비지역 정적 객체가 지역 정적 객체로 바뀐 것이다. 이건 싱글톤 패턴의 전형적인 구현양식이다.

지역 정적 객체는 함수 호출 중에 그 객체의 정의에 최초로 닿았을 때 초기화되도록 만들어져 있다. 이것은 C++에서 보장하는 사실이며, 위의 방법은 바로 그 사실을 이용한 것이다. 따라서 비지역 정적 객체를 직접 접근하지 않고 지역 정적 객체에 대한 참조자를 반환하는 쪽으로 바꾸었다면, 우리가 얻어낸 참조자는 반드시 초기화된 객체를 참조하도록 맞추어 주어야 한다. 추가로, 비지역 정적 객체 대신에 준비한 ‘지역 객체 참조자 반환’ 함수를 호출할 일이 없다면 해당 객체의 생성/소멸 비용도 생기지 않게 막아야 한다. 진짜 비지역 정적 객체의 경우에 막고 자시고 할 일도 없다.

그리하여 tfs와 tempDir에 이 방법을 적용해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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;
}

좀 바뀌긴 했지만 사용자는 원래 하던 대로 프로그램을 만들면 된다. tfs와 tempDir 대신에 tfs()와 tempDir()을 참조하는 것으로 바뀐 게 전부이다. 즉, 정적 객체 자체를 직접 사용하지 않고 그 객체에 대한 참조자를 반환하는 함수를 사용하고 있는 것이다.

이 기법을 도입하면서 출현한 ‘참조자 반환’ 함수는 어느 경우이든 복잡하게 구현될 일이 없다. 첫 번째 줄에서 지역 정적 객체를 정의/초기화하고, 두 번째 줄에서 그 객체의 참조자를 반환하면 끝이다. 함수 본문이 지극히 단순하다 보니, 인라인해도 좋겠다는 생각도 든다. 특히 이 함수의 호출빈도가 잦다면 더욱 그럴것읻. 하지만 다른 쪽에서 생각해 보면 이런 문제도 있다. 참조자 반환 함수는 내부적으로 정적 객체를 쓰기 때문에, 다중스레드 시스템에서는 동작에 장애가 생길 수도 있다는 것이다. 다중스레드가 돌아가는 프로그램이라면 비상수 정적 객체는 온갖 골칫거리의 시한폭탄이라고 볼 수 있다. 골칫거리를 다루는 한 가지 방법으로, 프로그램이 다중스레드로 돌입하기 전의 시동 단게에서 참조자 반환 함수를 전부 손으로 호출해 줄 수 있다. 이렇게 하면 초기화에 관계된 경쟁 상태가 없어진다.

물론 초기화 순서 문제를 방지하기 위해 이처럼 참조자 반환 함수를 사용하는 아이디어는 우리가 객체들의 초기화 순서를 제대로 맞춰 둔다는 전제조건이 뒷받침되어 있어야 말이 된다. 이를테면 객체 B가 초기화되기 전에 객체 A가 초기화되어야 하는데, A의 초기화과 B의 초기화에 의존하도록 만들어져 있다면 이건 진짜 문제다.

정리하자면 어떤 객체가 초기화되기 전에 그 객체를 사용하는 일이 생기지 않도록 하려면 딱 세가지만 기억해 두고 실천하면 된다. 첫째, 멤버가 아닌 기본제공 타입 객체는 우리 손으로 직접 초기화하자. 둘째, 객체의 모든 부분에 대한 초기화에는 멤버 초기화 리스트를 사용하자. 마지막으로, 별개의 번역 단위에 정의된 비지역 정적 객체에 영향을 끼치는 불확실한 초기화 순서를 염두에 두고 이러한 불확실성을 피해서 프로그램을 설계해야 한다.

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


참고

This post is licensed under CC BY 4.0 by the author.