KoreanFoodie's Study

Effective C++ | 항목 3 : 낌새만 보이면 const 를 들이대 보자! 본문

Tutorials/C++ : Advanced

Effective C++ | 항목 3 : 낌새만 보이면 const 를 들이대 보자!

GoldGiver 2022. 10. 25. 16:02

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

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

핵심 :

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

 

다음과 같은 상수 포인터 관련 코드를 보자.

char greeting[] = "Hello";

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

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

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

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

const 키워드가 * 표의 왼쪽에 있으면 포인터가 가리키는 대상이 상수이고, const 가 * 표의 오른쪽에 있는 경우에는 포인터 자체가 상수이다. 

STL 의 반복자(iterator) 는 포인터를 본뜬 것이라서, 기본적인 동작 원리가 T* 포인터와 흡사하다. 즉, iterator 를 const 로 선언한다는 것은 T* const 포인터와 같다. 반복자는 자신이 가리키는 대상이 아닌 것을 가리킬 수는 없지만, 가리키는 대상을 변경할 수는 있다. 만약 변경이 불가능한 객체를 가리키는 반복자를 정의하고 싶으면 const_iterator 를 쓰면 된다!

std::vector<int> vec;

// iter 는 T* const 처럼 동작한다
const std::vector<int>::iterator iter = vec.begin();
*iter = 10; // OK, iter 가 가리키는 대상의 값을 변경한다
++iter; // 에러! iter 포인터 자체는 상수이므로 다른 것을 가리킬 수 없다

// iter 는 T* const 처럼 동작한다
std::vector<int>::const_iterator cIter = vec.begin();
*cIter = 10; // 에러! *cIter 가 상수이기 때문에 안 된다.
++cIter; // 이건 문제 없다. cIter 를 변경하기 때문이다!

const 를 반환 값으로 적어주면, == 를 써야 하는 경우, = 를 써서 에러가 나는 어처구니 없는 실수를 방지할 수 있다.

 

상수 멤버 함수

멤버 함수에 붙는 const 키워드의 역할은 "해당 멤버 함수가 상수 객체에 대해 호출될 함수이다" 라는 사실을 알려주는 것이다. 이렇게 사용하면, 두 가지 장점이 있다.

  1. 클래스의 인터페이스를 이해하기 좋게 만들 수 있다.
  2. 이 키워드를 통해 상수 객체를 사용할 수 있게 만들 수 있다.

C++ 프로그램의 실행 성능을 높이는 핵심 기법 중 하나가 객체 전달을 '상수 객체에 대한 참조자(reference-to-const)' 로 진행하는 것인데, 이 기법을 제대로 쓰려면 상수 상태로 전달된 객체를 조작할 수 있는 const 멤버 함수, 즉 상수 멤버 함수가 준비되어 있어야 하기 때문이다.

const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다!

다음과 같은 예시를 보자.

class TexsBlock
{
  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;
};

...
// 객체 정의
TextBlock tb("Hello");
const TextBlock ctb("Hello");

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

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

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

// 컴파일 에러! : 상수 버전의 TextBlock 객체를 쓴다
ctb[0] = 'x';

 

어떤 멤버 함수가 상수(cosnt) 라는 것은, 두 가지 의미를 가진다.

  1. 비트수준 상수성(bitwise constness, 다른 말로 물리적 상수성(physical constness))
  2. 논리적 상수성 (logical constness)

비트 수준 상수성은 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야 한드는 뜻으로, 컴파일러는 데이터 멤버에 대해 대입 연산이 수행되었는지만 보면 된다(사실 C++ 에서 정의하고 있는 상수성이 비트수준 상수성이다).

비트 수준 상수성만 가지고는, 다음과 같은 문제를 막을 수 없다.

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

...

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

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

// 이럴수가! cctb 는 이제 "Jello" 라는 값을 갖는다!
*pc = 'J';

 

논리적 상수성이라는 개념으로 이런 상황을 보완할 수 있다. 즉, 상수 멤버 함수는 일부 몇 비트 정도는 바꿀 수 있되, 그것을 사용자측에서 알아채지 못하게만 하면 상수 멤버 자격이 있다는 것이다. 길이를 상수로 취급하는 다음 예시 코드를 보자.

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)
  {
    // mutable 로 선언했으므로 멤버 변수 수정이 가능하다!
    textlength = std::strLen(pText);
    lengthIsValid = true;
  }
  
  return textlength;
}

 

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

위의 operator[ ] 에서, 상수 버전 함수 호출을 위해 매번 비상수 버전의 코드를 복사 붙여넣기 해야 한다면 코드가 끔찍하게 증식할 것이다. 이 방법을 회피하는 방법은,  비상수 버전 함수가 상수 버전 함수를 호출하도록 구현하는 것이다.

class TextBlock
{
  public:
    // 이전과 동일
    const char& operator[] (std::size_t position) const
    {
      ...
      return text[position];
    }
    
    // 상수 버전 op[] 를 호출하고 끝
    char& operator[](std::size_t position)
    {
      return
        // op[] 의 반환 타입에 캐스팅을 해서 const 를 떼어낸다
        const_cast<char&>
        (
          // *this 타입에 const 를 붙인다 
          // (자기 자신이 아닌 const 버전 op[] 호출을 위해)
          static_cast<const TextBLock&>
            // op[] 의 상수 버전을 호출한다
            (*this)[position]
        );
    }
  
}

비상수 멤버 함수에서 상수 멤버 함수를 호출하는 것은 논리적으로 문제가 없지만, 상수 멤버 함수에서 비상수 멤버 함수를 호출하는 것은 여러 문제를 야기할 수 있다!

 

Comments