Item 3: 낌새만 보이면 const를 들이대 보자

C++에서 불변값을 보장하기 위해 사용하는 const 키워드는 매우 유용하다. 이 제약은 변수에만 적용되는 것이 아니라 함수의 인자, 반환값, 함수 자체에도 적용할 수 있다. 값이 불변을 보장해야 한다는 제약은 제작자의 의도나 다른 프로그래머에게 전달하는 정보로도 사용된다.

클래스 바깥에서 전역 혹은 네임스페이스 유효범위의 상수를 선언하는 데 사용할 수 있다. 그 뿐만 아니라 파일, 함수, 블록 유효범위에서 static으로 선언한 객체에도 const를 붙일 수 있다. 클래스 내부의 경우에는, 정적 멤버 및 비정적 데이터 멤버 모두를 상수로 선언할 수 있다.

또한 C++의 특징인 포인터에도 상수를 붙일 수 있는데, 포인터가 가리키는 데이터를 상수로 지정할 수도 있다.

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

const키워드가 * 왼쪽에 있으면 포인터가 가리키는 대상이 상수인 반면에, 오른쪽에 있는 경우는 포인터 자체가 상수라는 뜻이다. 즉, const가 양쪽에 다 있다면 포인터와 포인터가 가리키는 대상 모두 상수라는 뜻이다.

갑자기 실제 어셈블리에선 어떻게 할당이 되는지 궁금해져서 정리해봤다.

포인터가 가리키는 대상을 상수로 만들 때 const를 사용하는 스타일은 사람마다 다르지만, 개인적으로는 타입 앞에 붙이는 스타일이다.

const와 반복자

STL 반복자인 Iterator는 포인터를 본뜬 것이기 때문에, 기본적인 동작 원리가 T* 포인터와 진짜 흡사하다. 이는 C#의 IEnumerable과 IEnumerator도 마찬가지이다. 어떤 반복자를 const로 선언하는 일은 포인터를 상수로 선언하는 것과 같다. 반복자는 자신이 가리키는 대상이 아닌 것을 가리키는 경우가 허용되지 않지만, 반복자가 가리키는 대상 자체는 변경이 가능하다. 만약 변경이 불가능한 객체를 가리키는 반복자의 경우엔 const_iterator를 사용하면 된다.

std::vector<int> vec;

const std::vector<int>::iterator iter = vec.begin(); // 상수 반복자
*iter = 10; // 가능
++iter; // 불가능

std::vector<int>::const_iterator cIter = vec.begin(); // 상수 반복자
*cIter = 10; // 불가능
++cIter; // 가능

const와 함수

함수 선언문에 있어서 const는 함수 반환 값, 각각의 매개변수, 멤버 함수 앞에 붙을 수 있고, 함수 전체에 대해 const의 성질을 붙일 수 있다. 함수 반환 값을 상수로 정해 주면, 안전성이나 효율을 포기하지 않고도 사용자측의 에러 돌발 상황을 줄이는 효과를 꽤 자주 볼 수 있다.

const 매개변수는 const 타입의 지역 객체와 특성이 똑같다. const는 왠만하면 그냥 항상 사용하는 편이 좋다.

상수 멤버 함수

엠버 함수에 붙는 const 키워드는 “해당 멤버 함수가 상수 객체에 대해 호출될 함수이다” 라는 사실을 알려 주는 것이다. 이런 함수가 중요한 이유는 클래스 의 인터페이스를 이해하기 좋게 하기 위함이다. 그 클래스로 만들어진 객체를 변경할 수 있는 함수는 무엇이고, 또 변경할 수 없는 함수는 무엇인가를 알 수 있게 해준다.

또한 이 키워드를 통해 상수 객체를 사용할 수 있게 하자는 것인데, 코드의 효율을 위해 아주 중요한 부분이다. C++에서 상수 객체에 대한 참조자의 기법은 상수 상태로 전달된 객체를 조작할 수 있는 const 멤버 함수, 즉 상수 멤버 함수가 준비되어 있어야 한다.

const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다. 이 성질은 C++의 아주 중요한 성질이다.

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;
};

각각 다음과 같이 사용할 수 있다.

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

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

실제로 프로그램에서 상수 객체가 생기는 이유는 상수 객체에 대한 포인터 혹은 상수 객체에 대한 참조자로 객체가 전달될 때이다. 다음과 같은 예제다.

void print(const TextBlock& ctb) { // 이 함수에서 ctb는 상수 객체로 쓰인다.
    std::cout << ctb[0];
}

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

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

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

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

ctb[0] = 'x';       // 상수 버전의 TextBlock 객체를 쓰려고 하면 컴파일 에러가 발생한다.

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

추가로 operator[]의 비상수 멤버는 char의 참조자를 반환한다는 것인데, char 하나만 쓰면 안 된다는 점을 꼭 주의하자. 이것은 C++의 성질인 값에 의한 반환을 수행하기 때문이다. 이는 기본제공 타입을 반환하는 함수의 반환 값을 수정하는 일은 절대로 있을 수 없기 때문이다. 값에 의한 반환을 수행하는 C++의 성질이 있기 때문이다.

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

어떤 멤버 함수가 상수 멤버(const)라는 것은

비트수준 상수성이란 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야 그 멤버 함수가 const임을 인정하는 개념이다. (정적 멤버는 제외) 즉, 그 객체를 구성하는 비트들 중 어떤 것도 바꾸면 안된다는 뜻이다.

비트수준 상수성을 사용하면 상수성 위반을 발견하는데 많은 힘이 들지 않는다. 컴파일러는 데이터 멤버에 대해 대입 연산이 수행되었는지만 보면 되니 사실 C++에서 정의하고 있는 상수성이 비트수준 상수성이다.

논리적 상수성은 앞서 말한 비트수준 상수성을 보완하는 대체 개념으로 “상수 멤버 함수라고 해서 객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀 수 있되, 그것을 사용차측에서 알아채지 못하게만 하면 상수 멤버 자격이 있다.” 라는 개념이다.

이러한 개념으로 mutable이 나오게 되었다.

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

앞서 말한 mutable을 사용하거나 상수성을 위해 코드를 작성하다 보면 여러 예외상황으로 쉽게 코드가 중복된다. 이를 해결하기 위해 캐스팅이라는 방법을 떠올릴 수 있지만, 캐스팅은 좋지 못한 방법이다.

정리

결국 const는 C++의 엄청난 강점이자 축복이다. 이를 잘 사용하기 위해선 책에서 규칙과 실제 동작 방식등을 제대로 이해하고 사용하는 것이 좋다. 포인터/반복자/참조자가 가리키는 객체에 대해서도 그렇고 함수의 매개 변수 및 반환 타입에 대해서도 마찬가지다.

const는 일단 전부 사용하고 남발하자. 저질러도 후회는 안할 것이다.

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

느낀점

const뿐만 아니라 대부분의 프로그래밍 언어에서 불변성을 보장한다는 개념은 매우 중요하다. 다만 이 챕터는 실제 사용용례를 많이 따지기 때문에 앞의 기본적인 지식이 없다면 힘들 것 같다고 생각한다. const 자체를 잘 활용할 수 있도록 실습 자체를 많이 해봐야 할 것 같다.

태그: ,

카테고리:

업데이트:

댓글남기기