Item 3: const를 많이 사용하자.
ㅣconst 키워드를 유용한 점은 const가 붙은 객체의 변경을 허용하지 않는다는 의미적 제안을 소스코드 레벨에서 가능하게 한다는 것과 이것을 컴파일러가 잘 지켜준다는 점이다.
const의 의미
char greeting[] = "Hello";
char *p = greeting; // 비상수 포인터,
// 비상수 데이터
const char *p = greeting; // 비상수 포이터,
// 상수 데이터
char * const p = greeting; // 상수 포인터,
// 비상수 데이터
const char * const p = greeting; // 상수 포인터,
// 상수 데이터
포인터가 가리키는 대상을 상수로 만들 때의 const 위치는 2가지 방식이 있다. 아래는 모두 같은 의미다.
void f1(const Widget *pw); // f1은 상수 Widget 객체에 대한
// 포인터를 매개변수로 받는다.
void f2(Widget const *pw); // 마찬가지임.
STL에서 iterator
STL의 iterator는 포인터를 본따 만들었다. 그래서 동작이 포인터와 매우 유사하다.
std::vector<int> vec;
...
const std::vector<int>::iterator iter = vec.begin();
// T* const iter;와 같음
*iter = 10; // OK, iter가 가리키는 대상을 변경.
++iter; // 에러! iter는 상수임.
std::vector<int>::const_iterator cIter = vec.begin();
// cIter는 const T* cTter; 와 같음.
*cIter = 10; // 에러! *cIter가 상수임.
++cIter; // OK
반복자 자체는 포인터처럼 동작한다. 하지만 반복자 자체가 가리키는 대상을 상수화 하려면 위 예처럼 const_iterator를 사용해야 한다.
함수에서의 const
const의 유용성은 함수에서 사용될 때이다. 함수에서 사용될 때
- 함수반환값
- 매개변수
- 멤버 함수 앞
- 함수 전체에 대해 const 성질을 붙일 수 있다.
반환값을 const 로 하면 좋은 점
class Rational{ ... };
const Ratonal operator*(const Rational& lhs, const Rational& rhs);
위 코드 처럼 operator*의 연산 결과를 상수로 선언하면 아래와 같이 잘 못 사용하는 경우를 컴파일 타임에 막을 수 있다.
Rational a, b, c;
...
(a * b) = c; // a*b의 결과에 operator=를 호출함.
두 수의 곱에 다른 수를 대입하고 있음.
if (a * b = c) .. // 원래 의도는 비교하려는 것이였음.
상수 멤버 함수
const 를 클래스 멤버 함수 이름 뒤에(닫는 괄호와 중괄호 사이) 쓰는 목적은 해당 멤버 함수가 상수 객체에 대해 호출될 함수임을 알려주는것이다. 이 사실이 중요한 이유는
- 클래스의 인터페이스를 이해하기 좋게 함(클래스 객체를 변경 가능한 함수와 변경 불가능한 함수 구분 용이)
- 이 키워드를 통해서 상수 객체를 사용할 수 있게 해줌.
두번째 내용은 프로그램 실행의 효율성을 높이는 핵심 기법중 하나이다. 객체 전달을 "상수 객체에 대한 참조로(reference-to-const)"로 진행할 때 유용하다. 보통의 멤버 함수는 상수 객체에 대해서 호출이 불가능하다. 하지만 const 키워드가 붙은 멤버 함수는 상수 객체에 대해서도 호출이 가능하다.
또 하나 알아야 할 사항은 C++에서는 const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다.
class TextBlock {
public:
...
const char& operator[](std::size_t position) const // 상수 객체에 대한
{ // operator[]
return text[position];
}
char& operator[](std::size_t position) // 비상수 객체에 대한
{ // operator[]
return text[position];
}
private:
std::string text;
};
위와 같은 코드는 아래 처럼 사용이 가능하다.
TextBlock tb("Hello");
std::cout << tb[0]; // 비상수 멤버함수 호출
const TextBlock ctb("World");
std::cout << ctb[0]; // 상수 멤버함수 호출
함수의 파라미터로 상수 객체에 대한 레퍼런스가 전달되는 경우를 보자.
void print(const TextBlock& ctb) // 이 하뭇에서 ctb는 상수 객체로 쓰인다.
{
std::cout << ctb[0]; // 상수 멤버함수 호출.
...
}
아래의 예를 추가로 보자.
std::cout << tb[0]; // OK. 비상수 버전의
// TextBlock 객체를 읽는다.
tb[0] = 'x'; // OK.
std::cout << ctb[0]; // OK. 상수 버전의
// TextBlock 객체를 읽는다.
ctb[0] = 'x'; // ERROR! 상수 버전의
// TextBlock 객체에 대해 쓰기 시도함.
상수성이 제대로 동작되지 않는 경우
정상적으로 컴파일이 되어도 상수성이 보장되지 않는 경우가 있다. 대표적인 경우가 포인터와 결합되는 경우이다.
아래의 예를 보자.
class CTextBlock {
public:
...
char& operator[](std::size_t position) const // 함수자체는 부적절하다.
{ return pText[position]; } // 하지만 비트수준의 상수성이
// 있어서 빌드됨.
private:
char *pText;
};
operator[ ] 함수가 상수 멤버 함수로 선언됨.(물론 이 함수는 문제가 있음) 그리고 const로 선언된 것 처럼 pText 를 수정하지 않고 있음. 즉 비트수준의 상수성을 지키고 있음.
하지만 다음과 같이 사용될 수 있어 문제가 된다.
const CTextBlock cctb("Hello"); // 상수 객체를 선언함
char *pc = &cctb[0]; // 상수 버전의 operator[]를 호출하여 cctb의
// 내부 데이터에 대한 포인터를 얻음.
*pc = 'J'; // cctb는 이제 "Jello" 값을 갖음.
논리적 상수성을 위한 mutable
상수 멤버함수라고 해서 객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀 수 있되, 그것을 사용자측에서 알아채지 못하게만 하면 상수 멤버의 자격이 있다는 주장이다.
아래 예를 보자.
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; // 멤버변수에 대임할 수 없다.
}
return textLength;
}
위 코드는 당연히 빌드 에러가 발생한다. 이 문제를 해결하기 위해서는 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); // mutable덕분에 이제 문제 없음.
lengthIsValid = true; // 마찬가지.
}
return textLength;
}
mutable은 비트 수준의 상수성을 논리적 상수성을 만들어 주는데 사용된다.
상수 멤버 및 비상수 멤버 함수에서 코드 중복 해결
앞에서 봤던 것처럼 operator[ ] 함수들은 거의 같은 코드를 갖고 있다. 이런 코드 중복을 해결하는 방법으로 다음과 같은 방법을 생각해 볼 수 있다.
- 비상수 함수에서 상수함수를 호출해서 사용
- 상수 함수에서 비상수 함수를 호출해서 사용
1번의 경우 논리적으로 별문제가 없어 보인다. 하지만 2번의 경우는 문제가 된다. 상수 함수를 호출한다고 해서 해당 객체의 데이터 멤버의 값이 변경되지 않아야 하지만 2번의 경우는 상수함수에서 비상수함수를 호출하게 됨으로써 이를 보장받지 못하기 때문이다.
아래는 중복되는 기존코드이다.
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;
};
위 코드는 경계검사, 접근 데이터 로깅, 자료 무결성 검증 코드가 정확하게 중복되고 있다. 이러한 중복은 아래 코드와 같이 해결이 가능하다.
char& operator[](std::size_t position) // 상수 버전 op[]를 호출하고 끝
{
return const_cast<char&>( // op[]의 반환 타입에 캐스팅을 적용,
// const를 떼어낸다.
static_cast<const TextBlock&> // *this의 타입에 const를 붙인다.
(*this)[position] // op[]의 상수 버전을 호출한다.
);
}
사실 캐스팅은 일반적으로 좋지 않은 생각이다. 하지만 코드 중복도 꽤 큰 문제이다. 위 코드처럼 캐스팅을 하면 보기는 매우 싫지만 코드 중복을 해결할 수는 있다.
정리
- const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 준다. const는 어떤 유효범위에 있는 객체도 붙을 수 있으며, 함수 매개변수, 반환타입, 멤버함수에도 붙을 수 있다.
- 컴파일러는 비트수준 상수성을 요구하지만, 우리는 논리적인 상수성을 사용해서 프로그래밍 해야한다.
- 상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우에는 코드중복을 피하는 것이 좋은데, 이때 비상수 버전이 상수 버전을 호출하도록 만들어야 한다.