본문 바로가기
Effective C++

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

by a little good developer 2021. 4. 26.

항목 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 를 타입 왼쪽에 붙이는 사람들도 있고, * 앞에 붙이는 사람들도 있다. 의미차이는 없습니다. 따라서 아래 함수들이 받아들이는 타입은 같습니다.

void f1(const widget *pw);	// f1은 상수 widget 객체에 대한 포인터를 매개변수로 취합니다.

void f2(widget const *pw);	// f2도 마찬가지

두가지 형태 모두 현업에서 잘 사용된다. 눈에 익혀두자!

 

STL iterator 에서 const 활용

std::vector<int> vec;
...

// iterator 를 const  로 선언, 포인터를 상수로 선언하는 것 (T * const 포인터)
const std::vector<int>::iterator iter = vec.begin();

*iter = 10;		// OK, iter 가 가리키는 대상을 변경
++iter;			// 에러! iter는 상수

// 변경이 불가능한 객체를 가리키는 Iterator(const T * 포인터의 STL 대응물)
std::vector<int>::const_iterator cIter = vec.begin();

*cIter = 10;		// 에러! *cIter가 상수이기 때문에
++cIter;		// 이건 문제 없습니다. cIter 를 변경하니까

 

함수 선언에서 const 활용

class Rational { ... };

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

Rational a, b, c;
...
if (a * b = c) ... // 이런 문제를 방지할 수 있다.... 누가 이렇게 하냐... 당연히 실수겠지!?

위 코드 처럼 무의식으로(?! 무지성...) 하는 실수를 컴파일 과정에서 막을 수 있습니다.

 

상수 멤버 함수

const 가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능합니다.(C++ 의 중요한 성질입니다.)

TextBlock 의 operator[]를 오버로드(overload)한 예제

class TextBloock {
public:
 ...
 const char &operator[](std::size_t position) const  // 상수객체에 대한 operator[]
 { return text[position]; }
 
 char &oeprator[](std::size_t position)			// 비상수 객체에 대한 operator[]
 { 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) {
 std::cout << ctb[0];		// TextBlock:operator[] 의 상수 멤버 호출
}

TextBlock 의 상수 객체와 비상수 객체의 쓰임새

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

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

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

ctb[0] = 'x';		// 컴파일 에러! 상수버전의 객체에 대해 쓰기는 안됩니다.

4 번째 줄에서 발생한 에러는 oeprator[] 의 반환타입(return type) 때문에 생겼습니다. oeprator[] 호출이 잘못된 것은 없습니다.

const char & 타입에 대입 연산을 시도했기 때문에 에러가 발생했습니다.

 

위 코드에서 눈여겨 볼점, oeperator[] 의 비상수 멤버는 char 의 참조자(Reference) 를 반환합니다. 만약, char 만 사용했다면,

tb[0] = 'x';

위 코드가 컴파일 되지 않습니다. 기본제공 타입을 반환하는 함수의 반환 값을 수정하는 일은 절대로 있을 수 없기 때문입니다.

설령 이것이 합법적으로 통한다 해도, 반환 시 '값에 의한 반환'을 수행하는 C++의 성질이 뒤에 떡 버티고 있습니다.

tb.text[0] 의 사본이지, tb.text[0] 자체가 아닙니다.

 

비트수준 상수성[bitwise constness, 물리적 상수성(physical constness)]논리적 상수성(logical constness)

비트수준 상수성 : 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야(정적 멤버는 제외합니다) 그 멤버 함수가 'const'임을 인정하는 개념

C++에선 비트수준 상수성으로 정의되어 있습니다. 그러나 포인터가 가리키는  대상을 수정하는 멤버 함수들은 이를 피할 수 있습니다.....

class CTextBlock {
public:
 ...
 char &operator[](std::size_t position) const	// 부적절한 operator[] 의 선언
 { return pText[position]; }			// (비트수준 상수성이 있어서 허용)

private:
 char *pText;
};

operator[] 함수가 상수 멤버 함수로 선언되어 있습니다.

그럼에도 불구하고 해당 객체의 내부 데이터에 대한 참조자를 버젓이 반환합니다.

const CTextBlock cctb("Hello");  // 상수 객체를 선언

char *pc = &cctb[0];		// 상수 버전의 operator[]를 호출하여 cctb의 내부 데이터에 대한 포인터를 얻습니다.

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

확실히 잘못된 동작입니다... 상수 멤버 함수를 호출했더니 값이 변해버렸습니다.

 

논리적 상수성 : 상수 멤버 함수라고 해서 객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀 수 있되, 그것을 사용자측에서 알아채지 못하게만 하면 상수 멤버 자격이 있다는 것입니다.

class CTextBlock {
public:
 ...
 std::size_t length() const;
 
private:
 char *pText;
 std::size_t textlength;
 bool lengthIsValid;
};

std::size_t CTextBlock::length() const {
 if (!legnthIsValid) {
  textLength = std::strlen(pText);	// Error! 상수 멤버 함수 안에서는 대입 불가능!
  lengthIsValid = true;
 }
}

length 구현은 textLengthlengthIsValid 가 바뀔 수 있어서 '비트수준 상수성'과는 거리가 멉니다.

컴파일러를 통과하려면 비트 수준의 상수성을 지켜야합니다.

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 (!legnthIsValid) {
  textLength = std::strlen(pText);	// mutable 키워드로 인해서 에러 없음
  lengthIsValid = true;
 }
}

mutable비정적 데이터 멤버비트수준 상수성의 족쇄에서 풀어줍니다.

 

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

위 코드를 수정하여 operator[] 의 상수/비상수 버전을 구현하면 코드 중복이 발생합니다.

class TextBloock {
public:
 ...
 const char &operator[](std::size_t position) const  // 상수객체에 대한 operator[]
 { 
  ...		// 경계검사
  ...		// 접근 데이터 로깅
  ...		// 자료 무결성 검증
  return text[position]; 
 }
 
 char &oeprator[](std::size_t position)			// 비상수 객체에 대한 operator[]
 {
  ...		// 경계검사
  ...		// 접근 데이터 로깅
  ...		// 자료 무결성 검증
  return text[position];
 }

private:
 std::string text;
};

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

 

class TextBloock {
public:
 ...
 const char &operator[](std::size_t position) const  // 이전과 동일
 { 
  ...		// 경계검사
  ...		// 접근 데이터 로깅
  ...		// 자료 무결성 검증
  return text[position]; 
 }
 
 char &oeprator[](std::size_t position)			// 상수 버전 operator[] 를 호출
 {
  // op[] 의 반환 타입에 캐스팅을 적용, const 를 때어냅니다.
  // *this의 타입에 const 를 붙입니다. op[]의 상수 버전을 호출합니다.
  return const_cast<check &>(static_cast<const TextBlock &>(*this)[position]);
 }

private:
 std::string text;
};

*this 의 타입을 TextBlock & 에서 const TextBlock & 으로 바꿉니다. 그리고 operator[] 의 const 를 떼어버립니다.

위 방식을 뒤집어서 하면 안됩니다. 상수 멤버에서 비상수 멤버를 호출하는 것은 컴파일러가 에러를 반환합니다.

어떻게든 상수 멤버에서 비상수 멤버를 호출하려고 *this 의 const 를 떼어내는 것은 온갖 재앙의 씨앗입니다....

 

이것만은 잊지 말자!

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