1장 C++에 왔으면 C++의 법을 따릅시다 🏳

Effective C++ 책을 읽고 정리하고자 합니다.
해당 포스팅은 이전 포스팅에 연계되어 진행됩니다.
이전 포스팅 을 읽고 해당 포스팅을 읽으면 더욱 더 이해하기 쉬울 겁니다 🙏

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

const 함수 선언

const의 가장 강점은 함수 선언 할 때 사용하는 것이다. 함수 선언문에 있어 const 는 “반환값” (이전 포스팅 에서 다룸.), “각각의 매개변수”, “멤버함수” 앞 혹은 함수 전체에 const 성질을 부여할 수도 있다.

🌱 매개변수의 const 화

보통 매개변수를 const화 하는 이유는 “상수 객체”를 사용하자는 것이다. 코드 효율을 위해 아주 중요한 부분이기도 하다. 우리가 이후 항목 20 에서 다루겠지만 C++ 프로그램의 성능을 높이는 핵심 기법 중 하나로 객체 전달을 상수 객체에 대한 참조 (reference-to-const) 기법을 사용한다.

실제 프로그램 내에서 상수객체가 생기는 경우는 두 가지이다.

// 해당 함수는 상수 객체에 대한 참조자로 전달 될 경우다.
void print (const TextBlock& ctb) {
	...
}

위의 예시를 보면 객체를 print 함수에 전달 할 경우 상수 객체가 생긴다는 것을 알 수 있다. 참조자는 포인터로 구현이 가능하다. (해당 부분은 항목 20부분에서 다루도록 하자.)

🌱 멤버 함수의 const 화

멤버 함수에 붙은 const 키워드의 역할은 “해당 멤버 함수가 상수 객체에 대해 호출 될 함수이다.” 라는 것을 알려 주는 것이다.

왜 중요할까? 🤔

1️⃣ 첫째는 클래스의 인터페이스를 이해하기 좋게 하기 위해서이다.

2️⃣ 둘째는 상수 객체를 사용할 수 있는 환경을 구축할 수 있다.

두 번째 내용을 조금 더 자세히 알아보자.

const 키워드 유무에 따라 멤버함수의 오버로딩이 가능하다.

class TextBox {
pulbic:
  ...

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

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

private:
  std::string text;
}

위의 예시에서 보듯 멤버함수를 상수 객체로 만들기 위해서는 const 키워드를 함수선언 마지막 부분에 붙여야한다. 해당 선언은 다음과 같이 사용할 수 있다.

TextBlock tb("Hello");
std::cout << tb[0];   // 비상수 객체에 대한 operator[] 호출

const TextBlock ctb("World");
std::cout << ctb[0];    // 상수 객체에 대한 operator[] 호출

일반 Textblock객체를 만들어 사용하게 될 경우 const선언되지 않은 비상수 객체 가 호출 된다. 만약 const기반의 객체를 생성한 후 operator[]를 호출할 경우 const가 선언된 상수 객체 가 호출 된다. 여기서 알 수 있는 점은 const라는 키워드를 멤버 함수 끝에 붙일 경우 내부의 모든 것은 const 로 인식하게 된다는 점이다. 즉, 전달 받는 객체에 const가 적혀 있지 않아도 const로 인식되고 멤버함수 내에 어떤 값도 변경되면 안된다는 것을 의미한다. 해당 개념을 비트수준 상수성 이라고 한다. 아래의 상수성 내용에서 자세히 다루겠다.

overload에 대해 한 가지 주의할 점을 알아보자.

const TextBlock ctb("Hello");

std::cout << ctb[0];    // 문제 없이 동작함.

ctb[0] = 'x';   // 에러 발생

해당 에러는 const 멤버함수를 부르면서 발생한 에러가 아니라 operator[]의 반환 타입 const char&로 인해 발생한다는 점을 기억해야한다. 즉, operator[]를 호출했을 때 정상동작하고 반환 값에 새로운 값을 넣으려고 시도해서 발생하는 에러라는 사실을 꼭 기억해야한다. (const를 썻다고 에러나구나 하고 지우면 안되고 원인을 분석해봐야한다..)

여기서 눈여겨 봐야할 점은 operator[]const char or char 타입만 반환하는 것이 아닌 참조 값 (reference) 를 반환한다는 점이다. 해당 부분은 잘 기억해야하는데, C++의 기본적으로 값을 적으면 “값에의한 반환”을 시도한다. 따라서, 원본 값이 아닌 사본 값이 반환 되게 된다. 사본 값에 의해 값을 변경하게 될 경우 나중에 버그를 찾느라 하루종일을 보낼수도 있다는 점을 기억하자.

상수성

상수성에서는 물리적 상수성 (비트수준 상수성)논리적 상수성 두 가지 개념으로 나뉜다.

🌱 물리적 상수성 (이하. 비트수준 상수성)

멤버함수의 비트수준 상수성은 어떤 멤버함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야 그 함수는 const 하다라는 것을 인정하는 개념이다. 즉, 멤버함수 내 어떤 데이터도 변경하면 안된다는 의미이다. 우리가 멤버함수를 const화 한다는 개념은 비트수준 상수성을 취하겠다는 의미이다. 비트수준 상수성을 사용하면 상수성 위반을 검사하는데 시간이 오래 걸리지 않는다. 컴파일러는 해당 멤버함수 내에서 = (대입 연산자) 가 사용됐는지만 확인하면 되기 때문이다.

컴파일러 단에서 = 을 확인해 const를 보장해줄 것 같지만, “const의 역할”을 하지 못하는 멤버함수들도 비트수준 상수성을 통과해 const를 보장한다고 주장하는 문제가있다.

class CTextBlock {
public:
  ...

  char& operator[](std::size_t position) const
  {
    return pText[position]; // 비트수준 상수성 통과
                            // const화 해주겠다라는 의미.
  }

private:
  char *pText;
};

위의 코드를 보자. 해당 코드에서는 C와 언어 호환을 위해 string객체가 아닌 char * (pointer)로 Text 자료구조를 관리 한다. opeartor[] 내부를 보면, 실제 변경되는 건 없기 때문에 비트수준 상수성 검사는 충분히 통과하고 const화 됐다고 볼 수 있다. 하지만, 아래와 같이 opeartor 후 값을 변경하면 어떻게 될까?

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

char *pc = &cctb[0];    // 상수 버전의 opeartor[]호출 후
                        // 내부 데이터의 포인터 획득

*pc = 'J';              // 값 변경으로 "Jello"가 됨

위의 코드를 보면 확실히 무언가 잘못됨을 감지할 것이다. const화 했다고 이야기 했는데!! 값이 변경되는 기이한 현상을 만나게 되다니.. 비트수준 상수성을 취할 경우 위와 같은 문제를 만날 수 있다. 해당 문제를 해결하기 위해 등장한 개념이 논리적 상수성 이다.

🌱 논리적 상수성

멤버함수의 논리적 상수성이란 상수 멤버 함수라고 해서 객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀 수 있되, 그것을 사용자 측에서 알아채지 못하게만 하면 상수 멤버 자격이 있다라는 의미이다. 아래의 문장 길이를 구하는 예시를 보자. 해당 함수는 상수멤버함수 이고, 문장의 길이를 caching 해두어 매번 strlen()를 사용하는 부하를 줄이기 위해 최적화한 함수이다.

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;
}

해당 예시는 비트수준 상수성 기준으로 볼 때, =연산자가 들어가는 caching 부분으로 인해 에러가 발생하게 된다. 당연히 textLengthlengthIsValid 같은 경우는 값이 변경되야하기 때문에 비트수준 상수성에는 부합되지 않는다. 하지만, 개발자 입장에서 볼 땐, 최적화를 위해선 당연히 필요한 부분이고, length 라는 함수에 영향을 끼치지도, 그렇다고 길이를 바꾸지도 않으니 사용할 수 있어야 되는거 아닌가? 라고 생각할 수 있다. 논리적 상수성은 이런 부분을 해결해주며, 비트수준 상수성의 검열을 회피 위해 새로운 키워드인 mutable을 사용한다.

mutable은 말그대로 “돌연변이”이다. 비정적 멤버를 비트수준 상수성의 족쇄를 풀어주는 아름다운 키워드이다.

아래와 같이 바꿔서 테스트를 해보자.

class CTextBlock {
public:
  ...

  std::size_t length() const;

private:
  char *pText;
  // mutable 에 주목하라!
  mutable std::size_t textLength;   // 바로 직전에 계산한 텍스트 길이
  mutable bool lengthIsValid;       // 현재 길이가 유효한가?
};

std::size_t CTextBlock::length() const {
  if (!lengthIsValid) {
    textLength = std::strlen(pText);   // 문제없이 동작함.
    lengthIsValid = true;              // 당연히 문제없다 ^_^
  }

  return textLength;
}

😴 포스팅 읽는 시간이 너무 길어지면 집중력이 떨어진다. 잠시 숨 좀 고르고🥺 다음 포스팅 을 보도록 하자. (다음 포스팅은 굉장히 짧다)