Effective C++ 정리

03. const 키워드

cks5730 2019. 12. 14. 17:57

const 키워드

const 키워드는 const 객체 값이 불변이라는 것을 컴파일러와 다른 제작자에게

알릴 수 있어서 코드의 안정성이 증가합니다.

 

const와 포인터

int num1 = 10;
int num2 = 20;



// * 앞에 있는 const 키워드
// 포인터가 가리키는 데이터를 상수화 시킨다.
const int* ptr1 = &num1;
ptr1 = &num2;
// *ptr1 = 999; // error



// * 뒤에 있는 const 키워드
// 포인터 자체를 상수화시킨다. (선언과 동시에 초기화)
int* const ptr2 = &num2;
*ptr2 = 999;
// ptr2 = &num1 // error

 

// ptr이 가리키는 대상을 상수화
void Func(const int* ptr);

// 위와 똑같이 ptr이 가리키는 대상을 상수화
// (자료형의 위치와 상관 없음)
void Func(int const* ptr);

 

const와 포인터는 const가 *(에스크리터) 앞, 또는 뒤에 있는지에 따라 결정됩니다.

 

const int* ptrstd::vector<T>::const_iterator와 같습니다.

반복자가 가리키는 데이터를 변경할 수 없습니다.

 

반대로 int* const ptrconst std::vector<T>::iterator와 같습니다.

반복자가 가리키는 대상을 다른 대상으로 바꿀 수 없습니다.

 

함수에 쓰이는 const 키워드

아래 코드는 함수의 리턴 타입, 매개 변수 앞에 const 키워드를 붙여서

의도치 않은 상황을 방지한 예시입니다.

 

class Rational
{
 public:
   // 매개 변수 앞에 붙는 const 키워드는 로컬 객체의 값을 수정할 수 없게 한다.
   // 대개 함수의 매개변수로 const 키워드를 붙이는걸 추천.
   const Rational operator * (const Rational& lhs); // {...}
}

int main()
{
   Rational a, b, c;
   
   // 아래는 의도와 다른 동작.
   // a * b의 결과로 나오는 임시 객체에 c를 대입하는 실수를 방지할 수 있다.
   (a * b ) = c;
   
   return 0;
}

 

상수 멤버 함수

멤버 함수에 붙는 const 키워드는 상수 객체가 호출할 함수라는 것을 명시할 수 있고

일반 객체에서 호출할 때, 외부에서 멤버의 데이터가 변경되는 것을 막을 수 있습니다.

그리고 const 키워드 유무의 차이로 함수의 오버로딩도 가능합니다.

 

// TextBook.h //

class TextBook
{
  public:
   ...

    // 상수 객체에 대한 operator
   const char& operator [ ] (std::size_t position) const
   {
      return text[position];
   }
   
   // 비상수 객체에 대한 operator
   char& operator [ ] (std::size_t position)
   {
      return text[position];
   }

  private:
     std::string text;
};
#include "TextBook.h"

int main()
{
    TextBook tb("Hello");
    std::cout << tb[0]; // 비상수 멤버 함수를 호출한다.
    
    const TextBook ctb("Hello");
    std::cout << tb[0]; // 상수 멤버 함수를 호출한다.
    return 0;
}

 

프로그램에서 상수 객체가 생기는 경우

class TextBook
{
  public:
   ...

    // 상수 객체에 대한 operator
   const char& operator [ ] (std::size_t position) const
   {
      return text[position];
   }

   // 비상수 객체에 대한 operator
   char& operator [ ] (std::size_t position)
   {
      return text[position];
   }

   void print(const TextBook& ctb)
   {
      // 아래와 같은 상수 객체의 [] 연산자는
      // 상수 객체에 대한 operator를 통해서 호출한다.
      // (상수 객체에 대한 참조자로 매개변수가 전달되었다)
      std::cout << ctb[0];
   }

  private:
     std::string text;
};

 

위에 있는 코드는 문제가 없어 보이지만 주의해야할 부분이 있습니다.

 

// 단순히 상수 타입의 객체를 읽는 것이니까 문제 없음.
std::cout << ctb[0] // (O)

 

// 컴파일 에러가 발생한다.
// 상수 타입의 객체이므로 객체에 대해 쓰기는 불가능.
ctb[0] = 'x'; // error

 

상수 객체 ctb는 상수 멤버 함수를 호출하고 상수 멤버 함수 리턴 타입도 const가 붙어서 

쓰기 연산을 시도하니까 에러가 발생했습니다.

 

그렇다면 일반 객체가 호출할 함수 operator [ ]를 char&가 아닌 char로 반환해서

원하는 인덱스에 데이터 쓰기를 시도하겠습니다.

 

   // 비상수 객체에 대한 operator
   char operator [ ] (std::size_t position)
   {
      return text[position];
   }
   
   ...

tb[0] = 'x';  // 컴파일 에러 발생 !!

 

컴파일 에러가 발생했습니다. 에러가 발생하는 이유는

기본제공 타입(char)을 반환하는 함수의 반환 값을 수정할 수 없기 때문입니다. 

이는 C++의 '값에 의한 반환'에서 수행하는 성질이라고 합니다.

 

그래서 리턴 타입에 char&로 바꾸면 컴파일 에러가 없어집니다.

리턴타입에 &를 붙여서 값에 의한 반환으로 수행하는 성질을 없앴기 때문입니다.

 

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

멤버 함수에 const 키워드를 붙이면 멤버 함수의 데이터를 외부에서 보호할 수 있었고,

상수 객체가 호출할 함수라는 것을 명시 할 수 있었습니다.

이것은 비트수준 상수성(bitwise constness)에 의한 것입니다.

 

비트수준 상수성이란, 데이터를 건드릴 수 없는 const 멤버임을 인정해서

이를 구성하는 객체의 비트 어느 것 하나라도 건드리지 않겠다는 뜻입니다.

컴파일러는 const 키워드로 비트수준 상수성을 지켜야하는지 아닌지를

쉽게 구분할 수 있다고 합니다. 

 

하지만 클래스에 포인터 멤버가 있다면 문제가 될 수 있습니다.

아무리 비트수준 상수성을 지켜야하는 멤버라고 해도

컴파일러는 포인터가 가리키는 대상의 변경 여부까지 확인하지 않습니다.

 

class TextBook
{
 public:
   ...   

    // 비트수준 상수성을 지키기 위한 상수 멤버 함수
    char& operator [ ] (std::size_t position) const
    {
       return pText[position];
    }

 private:
     char* pText;
};

 

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

// 상수 멤버 함수 operator []를 호출하여 
// 내부 데이터에 대한 주소를 얻는다 (!)
char* pc = &cctb[0]; 

// Hello에서 Jello로 변경되었다.
*pc = 'J';

 

상수 객체 내부의 데이터가 외부의 포인터에 의해 변경되었습니다. 

여기서 논리적 상수성(logical constness)이 등장하게 됩니다.

 

const 멤버라고 해도 비트 일부분이 변경되는 것을 사용자가 알아채지 못하면

상수 멤버의 자격이 있다는 뜻입니다. 그러나 위에 있는 예시는 논리적 상수성에도 위반되었습니다.

내부의 데이터가 외부에서 변경 되었다는 것을 알게 되었기 때문입니다.

그래서 다른 예시로 논리적 상수성을 살펴보겠습니다.

 

class TextBlock
{
 public:
   ...

    std::size_t TextBlock::length() const
    {
        // 컴파일 에러
        // 상수 멤버 함수안에서 데이터 멤버는 수정할 수 없다.
        if(!lengthIsValid)
        {
            textlength = std::strlen(pText);

            lengthIsValid = true;
        }
    }

 private:
    char* pText;
    std::size_t textlength;
    bool lengthIsValid;
};

 

상수 객체가 TestBlock::length 함수를 호출하면

컴파일러는 비트수준 상수성을 지켜내기 위해 에러가 발생하게 됩니다.

 

논리적 상수성은 비트의 일부가 변경되는 것을

사용자 측에서 알아채지 못하게 한다.

이것은 논리적 상수성으로 인정된다고 했습니다.

 

위에 있는 함수는 Text의 length를 얻기 위한 함수입니다.

그래서 멤버의 textlength와 lengthIsValid의 비트가 변경되는 것을 허용합니다.

 

함수에서 Text의 내부는 변경되지 않으므로 이는 논리적으로 상수성을 지키고 있고

사용자 입장에서 textlength와 lengthIsValid의 비트가 바뀌는 것을 알 수 없다.

이는 논리적 상수성을 지켰다고 할 수 있겠네요.

 

이를 위해 textlength와 lengthIsValid 멤버 앞에 mutable 키워드를 통해서

컴파일에러가 발생하는 것을 없애줍시다. 위에 있는 함수는 논리적 상수성을 지켰기 때문에

mutable로 허용합니다.

 

class CTextBlock
{
 public:
   ...

    std::size_t CTextBlock::length() const
    {
        // 상수 멤버 함수 안에서 수정이 가능해졌다.
        if(!lengthIsValid)
        {
            textlength = std::strlen(pText);

            lengthIsValid = true;
        }
    }

 private:
    char* pText;
    
    // 멤버 객체에 mutable 키워드를 적용한다.
    mutable std::size_t textlength;
    mutable bool lengthIsValid;
};

 

그러나 mutable 키워드는 논리적 상수성을 꼭 지켜야할 때가 아니라면

상수 멤버의 데이터를 변경할 수 있기 때문에 사용하지 않는 쪽이 좋습니다.