hubring

[EC++] 항목 3 : 낌새만 보이면 const를 들이대 보자! 본문

C++/Effective C++

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

Hubring 2021. 6. 16. 01:14

const 큰 매력은 “의미적인 제약”(외부 변경을 불가능하게 함)을 코드 수준에서 붙이며 컴파일러에서 이 제약을 지켜줄 수 있다는 점이다.
또한 클래스 바깥에서 전역 혹은 네임스페이스 유효범위의 상수를 선언(정의)하는 데 쓸 수 있다.(항목 2 참조)
그 뿐아니라 파일, 함수, 블록 유효범위에서 static으로 선언한 객체에도 const를 붙일 수 있다.
클래스 내부의 경우에는, 정적 멤버 및 비정적 데이터 멤버 모두를 상수로 선언할 수 있다.

그렇다면 포인터의 경우는 어떨까?

const 포인터 사용


포인터를 상수 지정 방법은 아래 3가지이다.
1. 포인터 자체를 상수 : char * const p
2. 포인터가 가리키는 데이터를 상수 : const char *p
3. 둘 다 상수로 지정 : const char * const p or char const * const p

STL 반복자는 포인터를 본뜬 것이기 때문에 기본적인 동작 원리가 T* 포인터와 흡사하다.
어떤 반복자를 const로 선언하는 일은 포인터를 상수로 선언하는 것(T* const 포인터)과 같다.
반복자가 가리키는 대상은 변경하지 못하며 그 대상의 값은 변경이 가능하다.

const std::vector<int>::iterator iter = vec.begin();
* iter = 10; // 대상의 값 변경 가능
++iter; // 대상을 변경하는 것은 불가능


만약 변경이 불가능한 대상 값(객체)를 가리키는 반복자(const T* 포인터)가 필요하다면 const_iterator 를 사용하면 된다

const std::vector<int>::const_iterator iter = vec.begin();
* iter = 10; // 대상의 값 변경하는 것은 불가능
++iter; // 대상을 변경 가능

const 함수 선언에 사용

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

함수 반환 값을 상수로 정해 주면, 사용자측의 에러 돌발 상황을 줄이는 효과를 줄 수 있다.

class Rational {...};
const Retional operator*(const Rational& lhs, const Rational& rhs);

위 예시에서 operator* 반환값을 상수 객체로 지정하지 않게 되면 아래와 같은 문제가 있다.

Rational a, b, c;
( a * b ) = c; // a*b 결과에 = 으로 c 값을 넣을 수 있음.
...
If( a * b = c ) ... // 개발자의 실수 비교를 원한 것이였으나 문법 위반에 걸리지 않음.

위 코드는 문법 위반에 걸리지 않아 문제가 될 수 있다.
따라서 operator*의 반환 값을 const로 지정해야 이런 경우를 미연에 막을 수 있다.

const 매개변수에 대해선 const 타입의 지역 객체와 특성이 동일하다.
이 기능 역시 매개변수 혹은 지역 객체를 수정할 수 없게 하는 것이 목적이라면 const를 잊지않고 가능한 한 항상 사용하는 것이 좋다.

상수 멤버 함수

멤버 함수에 붙는 const
=> 해당 멤버 함수가 상수 객체에 대해 호출될 함수이다.

중요한 이유가 무엇일까?

1. 클래스의 인터페이스를 이해야기 좋게 하기 위함
그 클래스로 만들어진 객체를 변경할 수 있는 함수는 무엇이고, 또 변경할 수 없는 함수는 무엇인가를 사용자 쪽에서 알고 있어야 한다.

2. 이 키워드를 통해 상수 객체를 사용할 수 있게 하자는 것
C++ 실행 성능을 높이는 핵심 기법 중 하나가 객체 전달을 ‘상수 객체에 대한 참조자(reference-to-const)’로 진행하는 것이다.
이 기법을 제대로 사용하려면 상수 사앹로 전달된 객체를 조작할 수 있는 const 멤버 함수, 즉 상수 멤버 함수가 준비되어 있어야한다.

const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다. (C++의 중요한 성질이므로 꼭 외워두자)

class TextBlock {
public :
const char& operator[](std::size_t position) const. // 상수 객체에 대한 operator[]
{ return text[position]; }
char& operator[](std::size_t position) // 비상수 객체에 대한 operator[]
{ return text[postion]; }
private :
std::string text;
}

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

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

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


실제 프로그램에서 상수 객체가 생기는 경우는 아래와 같다.
1. 상수 객체에 대한 포인터
2. 상수 객체에 대한 참조자로 객체가 전달될 때

실제 예제

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

}


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

std::cout << tb[0]; // 비상수 버전의 객체를 읽음
tb[0] = ‘x’; // 비상수 버전의 객체를 씀
std::cout << ctb[0]; // 상수 버전의 객체를 읽음
ctb[0] = ‘x’; // 상수 버전의 객체를 씀 => 컴파일 에러 발생! 상수 버전의 객체는 쓰기가 안된다.

넷째줄에서 발생한 에러는 operator[]의 반환 타입 때문에 생긴 것이다.
반환 타입 (const char&)에 대입 연산을 시도했기 때문이다.

하나 더 눈여겨 볼 부분은 operator[]의 비상수 멤버는 char의 참조자를 반환한다는 것이다.
만약 참조자를 반환하지 않는다면 아래 문장이 컴파일되지 않는다.

tb[0] = ‘x’;

왜 그럴까? 기본제공 타입을 반환하는 함수의 반환 값을 수정하는 일은 절대로 있을 수 없기 때문이다.
이는 C++ 성질 중 ‘값에 의한 반환’을 수행하기 때문이다.
즉 수정된 값은 tb.text[0]의 사본이지 그 자체는 아니다.

어떤 멤버 함수가 상수 멤버(const)라는 것이 대체 어떤 의미일까?
여기에는 굵직한 양대 개념이 있다. 비트수준 상수성과 논리적 상수성이다.

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

그러나, 제대로 const로 동작하지 않는데도 이 비트수준 상수성 검사를 통과하는 멤버 함수들이 적지 않다.
포인터가 가리키는 대상을 수정하는 멤버 함수들이 이런 경우에 속한다.
이 함수는 비트수준 상수성을 갖는 것으로 판별되고 컴파일러도 불평하지 않는다.

class CTextBlock {
public :
char& operator[](std::size_t position) const // 부적절한(그러나 비트수준 상수성에 있어서 허용되는) operator[] 선언
{ return pText[postion]; }
private :
char *pText;

코드에 나와 있듯이 operator[] 함수가 상수 멤버 함수로 선언되어 있음.
그럼에도 불구하고 해당 객체의 내부 데이터에 대한 참조자를 반환한다.(관련 항목 28 참조)

operator[]의 내부 코드만 보면 pText는 안 건드리는 점은 확실하다, 그러니 컴파일러가 이 코드에 대해 불평하지 않는다.
하지만 이로 인해 아래와 같은 사태가 발생 할 수 있다.

const CTextBlock cctb(“Hello”); // 상수 객체를 선언
char *pc = &cctb[0]; // 상수 버전의 operator[]를 호출하여 cctb의 내부 데이터에 대한 포인터를 얻는다.
*pc = ‘J’; //cctb는 이제 “Jello”라는 값을 갖는다.

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


2. 논리적 상수성(logical constness)
논리적 상수성이란 개념은 이런 황당한 상황을 보완하는 대체 개념으로 나오게 되었다.
상수 멤버 함수라고 하여 객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀 수 있되, 그것을 사용자 측에서 알아채지 못하게만 하면 상수 자격이 있다는 것이다.

CTextBlock 클래스는 문장 구역의 길이를 사용자들이 요구할 때마다 이 정보를 캐치해 둘 수 있을 텐데 다음과 같이 멤버를 둘 수 있을 것이다.

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

length의 구현은 비트수준의 상수성과 멀리떨어져 있다.
textLength 및 lengthIsValid가 바뀔 수 있기 때문이다.
하지만 CTextBlock의 상수 객체에 대해서는 아무 문제가 없어야 할 것 같은 코드이다. 하지만 컴파일러는 에러를 쏟아낸다.

이런 상황에서는 어떻게 해야할까? 해답은 단순하다.
const에 맞서는 C++의 mutable을 사용하는 것이다.
mutable은 비정적 데이터 멤버를 비트수순 상수성의 족쇄에서 풀어 주는 키워드이다.

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의 operator[] 함수가 지금은 특정 문자의 참조자만 반환하고 있지만, 이것 말고도 경계 검사나 접근 정보 로깅, 내부자료 무결성 검증도 못할리 없다.

이런저런 코드를 모조리 operator[]의 상수/비상수 버전에 넣어버리면 어느덧 코드 판박이 괴물이 떡 하니 우리 앞에서 뒹굴고 있게 된다.

class TextBlock {
public :
const char& operator[] (std::size_t postion) const {
…. // 경계 검사, 접근 데이터 로깅, 자료 무결성 검증
return text[postion];
}
char& operator[](std::size_t postion){
…. // 경계 검사, 접근 데이터 로깅, 자료 무결성 검증
return text[postion];
}
private :
std::string text;

무서운 코드 중복이다.. 컴파일 시간, 유지보수, 코드 크기 부풀림 감당할 수 있겠습니까?

경계 검사 등의 코드를 별도의 멤버 함수에 옮겨 두고 operator[]에서 호출하게 만들면 제법 괜찮을 것이라 생각하지만 그대로 코드 중복은 여전하다.
한 번만 구현해 두고 이것을 두번 사용하고 싶진 않은가? opertator[]의 양 버전 중 하나만 제대로 만들고 다른 버전은 이것을 호출하는 식으로 만들고 싶을 것이다.
Const 껍데기를 캐스팅으로 날리면 어떨까 하는 생각에 이르게 된다. 기본적으로 캐스팅은 통념적으로 썩 좋은 아이디어는 아니다.

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

따라서 캐스팅이 필요하긴 하지만, 안전성도 유지하면서 코드 중복을 피하는 방법은 비상수 operator[]가 상수 버전을 호출하도록 구현하는 것이다.

class TextBlock {
  public :
    const char& operator[](std::size_t position) const {
       …
       return text[position];
    }
    char& operator[](std::size_t position) {
       return const_cast<char&>(static_cast<const TextBlock&>(*this)[position]);
    }
}

보다시피 캐스팅이 한 번이 아니라 두 번 되어 있다.
지금 해야 하는 일은 비상수 operator[]가 상수 버전을 호출하게 하는 것이다.
그런데 비상수 operator[] 속에서 그냥 operator[]라고 적으면 그 자신이 재귀적으로 호출될 것이다.
해서 차선책으로 *this의 타입 캐스팅을 사용하는 것이다. 캐스팅을 쓴 이유는 const가 붙어야 하기 때문이다.

정리하면 두 개의 캐스팅의 첫 번째 것은 *this에 const를 붙이는 캐스팅이고 두 번째 것은 상수 operator[]의 반환 값에서 const를 떼어내는 캐스팅이다.
이런 구현은 문법이 조금 이상해보이나 이 기법(비상수 멤버 함수의 구현에 상수 멤버 쌍둥이를 사용하는 기법) 자체는 꼭 알아둘 가치가 있다.

참고
* const_cast : 객체의 상수성을 없애는 용도로 사용 (C++ 스타일의 캐스트는 이것밖에 없다.)
* static_cast : 암시적 반환을 강제로 진행할 때 사용한다. 흔히들 이루어지는 타입 변환을 거꾸로 수행하는 용도로 쓰임. 상수 객체를 비상수 객체로 캐스팅하는 데 이것을 쓸 수는 없다.

추가로 앞의 방법을 뒤집어서 하는 쪽 (코드 중복 회피를 위해 상수 버전이 비상수 버전을 호출하게 만드는 것)도 생각할 수 있는데, 상수 멤버 함수는 해당 객체의 논리적인 상태를 바꾸지 않겠다고 컴파일러와 굳게 약속한 함수인 반면, 비상수 멤버 함수는 이런 약속을 하지 않기 때문이다.
즉, 어쩌다가 상수 멤버 함수가 비상수 멤버 함수를 호출하게 되면 수정하지 않겠다는 그 약속을 위반하게 될 가능성이 있다.

실제로 상수 멤버 함수에서 비상수 멤버 함수를 호출하는 코드를 어떻게든 컴파일 하려면 const_cast를 적용해서 *this에 붙은 const를 떼어내야 하는데, 이게 온갖 재앙의 씨앗이다.

정리

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


이것만은 잊지 말자!


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