[Effective C++]낌새만 보이면 const를 들이대 보자!

C++에 왔으면 C++의 법을 따릅시다, 세 번째 이야기

Posted by SungBeom on November 21, 2019 · 16 mins read

const의 용도

const는 '의미적인 제약'(const 키워드가 붙은 객체는 외부 변경을 불가능하게 한다)을 소스 코드 수준에서 붙인다는 점과 컴파일러가 이 제약을 단단히 지켜준다는 점에서 정말 멋지다고 할 수 있습니다. 어떤 값(객체의 내용)이 불변이어야 한다는 제작자의 의도를 컴파일러 및 다른 프로그래머와 나눌 수 있는 수단입니다.

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

1
2
3
4
5
6
char greeting[] = "Hello";
 
char *= greeting;               // 비상수 포인터, 비상수 데이터
const char *= greeting;         // 비상수 포인터, 상수 데이터
char * const p = greeting;        // 상수 포인터, 비상수 데이터
const char * const p = greeting;  // 상수 포인터, 상수 데이터
cs

const 키워드가 *의 앞에 있으면 포인터가 가리키는 대상이 상수이고, const 키워드가 *의 뒤에 있으면 포인터 자체가 상수입니다. const가 *의 양쪽에 다 있으면 포인터가 가리키는 대상 및 포인터가 다 상수라는 뜻입니다.
포인터가 가리키는 대상을 상수로 만들 때(const 키워드가 *의 앞에 있을 때) const와 타입의 순서가 바뀌어도 의미적인 차이는 없습니다.

가장 강력한 const의 용도는 함수 선언에 쓸 경우입니다. 함수 선언문에 있어서 const는 함수 반환 값, 각각의 매개변수, 멤버 함수 앞에 붙을 수 있고, 함수 전체에 대해 const 성질을 붙일 수 있습니다.
함수 반환값을 상수로 정해 주면, 안전성이나 효율을 포기하지 않고도 사용자측의 에러 돌발 상황을 줄이는 효과를 꽤 자주 볼 수 있게 됩니다.
const 매개변수에 대해선 const 타입의 지역 객체와 특성이 똑같습니다.

상수 멤버 함수

멤버 함수에 붙는 const 키워드의 역할은 "해당 멤버 함수가 상수 객체에 대해 호출될 함수이다"라는 사실을 알려 주는 것입니다. 이것이 중요한 이유는 첫 번째로, 클래스의 인터페이스를 이해하기 좋게 하기 위해서인데, 해당 클래스로 만들어진 객체를 변경할 수 있는 함수는 무엇이고, 또 변경할 수 없는 함수는 무엇인가를 사용자 쪽에서 알고 있어야 하는 것입니다. 두 번째로, const 키워드를 통해 상수 객체를 사용할 수 있게 하자는 것인데, 코드의 효율을 위해 아주 중요한 부분이기도 합니다. C++ 프로그램의 실행 성능을 높이는 핵심 기법 중 하나가 객체 전달을 '상수 객체에 대한 참조자(reference-to-const)'로 진행하는 것이기 때문입니다. 여기서 이 기법이 제대로 동작하려면 상수 상태로 전달된 객체를 조작할 수 있는 const 멤버 함수, 즉 상수 멤버 함수가 준비되어 있어야 합니다.

const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능합니다.

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

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

1
2
3
4
5
TextBlock tb("Hello");         // TextBlock::operator[]의
std::cout << tb[0];            // 비상수 멤버를 호출합니다.
 
const TextBlock ctb("World");  // TextBlock::operator[]의
std::cout << ctb[0];           // 상수 멤버를 호출합니다.
cs

여기서 눈여겨 볼 부분은 operator[]의 비상수 멤버는 char의 참조자(reference)를 반환한다는 것인데, char 하나만 쓰면 안 된다는 점을 꼭 주의해야 합니다. 만약 operator[]가 그냥 char를 반환하게 만들어져 있으면, 다음과 같은 문장이 컴파일되지 않게 됩니다.
tb[0] = 'x';
그 이유는 기본제공 타입을 반환하는 함수의 반환 값을 수정하는 일은 절대로 있을 수 없기 때문입니다. 설령 통한다고 해도, 반환 시 '값에 의한 반환'을 수행하는 C++의 성질이 버티고 있습니다. 즉, 수정되는 값은 tb.text[0]의 사본이지, tb.text[0] 자체가 아니라는 것입니다.

비트수준 상수성 vs 논리적 상수성

어떤 멤버 함수가 상수 멤버(const)라는 것에는 굵직한 양대 개념이 자리 잡고 있습니다. 하나는 비트수준 상수성(bitwise constness, 다른 말로 물리적 상수성(physical constness))이고, 또 하나는 논리적 상수성(logical constness)입니다.

비트수준 상수성은 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야(정적 멤버는 제외) 그 멤버 함수가 'const'임을 인정하는 개념입니다. 즉, 그 객체를 구성하는 비트들 중 어떤 것도 바꾸면 안 된다는 것입니다. 컴파일러는 데이터 멤버에 대해 대입 연산이 수행되었는지만 보면 되고, C++에서 정의하고 있는 상수성이 물리적 상수성입니다. 그런데, 애석하게도 '제대로 const'로 동작하지 않는데도 이 비트수준 상수성 검사를 통과하는 멤버 함수들이 적지 않습니다. 어떤 포인터가 가리키는 대상을 수정하는 멤버 함수들 중 상당수가 이런 경우에 속합니다.

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

코드에 나와 있듯이 operator[] 함수가 상수 멤버 함수로 선언되어 있습니다. 이 함수는 그럼에도 불구하고 해당 객체의 내부 데이터에 대한 참조자를 버젓이 반환합니다. 하지만 비트수준에서 상수성을 지키고 있고, 컴파일러 선에서는 이것까지만 점검하면 끝입니다.

1
2
3
4
5
6
const CTextBlock cctb("Hello");  // 상수 객체를 선언합니다.
 
char *pc = &cctb[0];             // 상수 버전의 operator[]를 호출하여 cctb의
                                 // 내부 데이터에 대한 포인터를 얻습니다.
 
*pc = 'J';                       // cctb는 이제 "Jello"라는 값을 갖습니다.
cs

확실히 무언가 잘못됐습니다. 어떤 값으로 초기화된 상수 객체를 하나 만들어 놓고 이것에다 상수 멤버 함수를 호출했더니 값이 변해버린 것입니다! 논리적 상수성이란 개념은 이런 황당한 상황을 보완하는 대체 개념으로 나오게 되었습니다. 상수 멤버 함수라고 해서 객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀 수 있되, 그것을 사용자측에서 알아채지 못하게만 하면 상수 멤버 자격이 있다는 주장입니다.

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 = std::strlen(pText);  // 에러! 상수 멤버 함수 안에서는
        lengthIsValid = true;             // textLength 및 lengthIsValid에
    }                                     // 대입할 수 없습니다.
 
    return textLength;
}
cs

legnth의 구현은 '비트수준 상수성'과 멀리 떨어져 있습니다. textLength 및 lengthIsValid가 바뀔 수 있으니까요. 컴파일러의 검열을 통과하려면 비트 수준의 상수성이 지켜져야 합니다. 이런 상황에서는 어떻게 해야 할까요?

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;
    mutable std::size_t textLength;       // 이 데이터 멤버들은 어떤 순간에도
    mutable bool lengthIsValid;           // 수정이 가능합니다. 심지어 상수
};                                        // 멤버 함수 안에서도 수정할 수 있습니다.
 
std::size_t CTextBlock::length() const
{
    if (!lengthIsValid) {
        textLength = std::strlen(pText);  // 이제 문제없습니다.
        lengthIsValid = true;             // 당연히 문제없죠.
    }
 
    return textLength;
}
cs

mutable을 사용하면 이를 해결할 수 있습니다. mutable은 비정적 데이터 멤버를 비트수준 상수성의 족쇄에서 풀어 주는 키워드입니다.

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

mutable은 비트수준 상수성 문제를 해결하는 꽤 괜찮은 방법이지만, 이것으로 const에 관련된 골칫거리 전부를 말끔히 씻어내진 못합니다. 이런저런 코드를 모조리 operator[]의 상수/비상수 버전에 넣어 버리면 코드 중복이 발생하기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class TextBlock {
public:
    ...
 
    const char& operator[](std::size_t position) const
    {
        ...  // 경계 검사, 접근 데이터 로깅, 자료 무결성 검증
        return text[position];
    }
 
    char& operator[](std::size_t position)
    {
        ...  // 경계 검사, 접근 데이터 로깅, 자료 무결성 검증
        return text[position];
    }
 
private:
    std::string text;
};
cs

기본적으로, 캐스팅은 일반적으로도 통념적으로도 썩 좋지 않은 아이디어입니다. 하지만 지금의 경우, operator[]의 상수 버전은 비상수 버전과 비교해서 하는 일이 정확히 똑같습니다. 단지 다른 점이 있다면 반환 타입에 const 키워드가 덧붙어 있다는 것뿐입니다. 따라서 여기서는 캐스팅을 써서 반환 타입으로부터 const 껍데기를 없애더라도 안전합니다. 왜냐하면 비상수 operator[] 함수를 호출하는 쪽이라면 그 호출부엔 비상수 객체가 우선적으로 들어 있었을 게 분명하기 때문입니다.

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

캐스팅이 한 번이 아니라 두 번 되어 있습니다. 비상수 operator[] 속에서 그냥 operator[]라고 적으면 그 자신이 재귀적으로 호출됩니다. 이러한 무한 재귀호출을 피해 가기 위해서는 상수 operator[]를 호출하고 싶으실 텐데, 이에 대한 직접적인 방법이 없습니다. 그 대신으로 생각한 차선책이 *this의 타입 캐스팅입니다. 결국 정리하면, 두 개의 캐스팅 중 첫 번째 것은 *this에 const를 붙이는 캐스팅이고(비상수 operator[]에서 상수 버전을 호출하기 위해), 두 번째 것은 상수 operator[]의 반환값에서 const를 떼어내는 캐스팅입니다.

const는 참으로 대단한 축복입니다. 포인터나 반복자에 대해서 그렇고, 포인터/반복자/참조자가 가리키는 객체에 대해서 그렇고, 함수의 매개변수 및 반환 타입에 대해서도 마찬가지이며, 지역 변수는 물론이고 멤버 함수까지 const는 매우 든든한 친구입니다. 할 수 있으면 아끼지 말고 남발하세요.


정리

const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 줍니다. const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및 반환 타입에도 붙을 수 있으며, 멤버 함수에도 붙을 수 있습니다.
컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 여러분은 개념적인(논리적인) 상수성을 사용해서 프로그래밍해야 합니다.
상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우에는 코드 중복을 피하는 것이 좋은데, 이때 비상수 버전이 상수 버전을 호출하도록 만드세요.