본문 바로가기
프로그래밍 언어/C, C++

[Effective C++] EC++ 정리하기 - Chapter 1 C++에 왔으면 C++의 법을 따릅시다

by 니키티스 2025. 5. 31.

 

챕터 명: Chapter 1 C++에 왔으면 C++의 법을 따릅시다

Chapter 1의 주요 내용을 정리해 보았다. 다만, 너무 장황하게 정리한 것 같아서 다음부터는 좀 더 짧게 정리해 볼 예정.

항목 1: C++를 언어들의 연합체로 바라보는 안목은 필수

현대의 C++은 다중패러다임 프로그래밍 언어(multiparadigm programming language)라 불린다. 절차적(procedural) 프로그래밍, 객체 지향(object-oriented), 함수식(functional), 일반화(generic) 프로그래밍을 포함하여 메타프로그래밍(metaprogramming) 개념까지 지원하고 있다. 물론 표현력, 유연성 덕분에 C++는 대체할 만한 도구가 없지만 규칙적으로 혼동을 주는 면이 많다. 그래서 아예 다른 시각으로 바라봐야 한다.

C++를 단일 언어로 보는 눈을 넓혀, 상관관계가 있는 여러 언어들의 연합체(federation)로 보자. 그러고 나서 각 언어에 대한 규칙을 각개 격파해서 이해하도록 하자. C++는 다음과 같은 여러 개의 하위 언어를 제공한다고 볼 수 있다.

  • C: C++는 여전히 C를 기본으로 한다(블록, 문장, 전처리자, 기본제공 데이터타입, 배열, 포인터 등). C++에서 C보다 나은 요소도 있지만, C만 뽑아서 사용할 수도 있다.
  • 객체 지향 개념의 C++: ‘클래스를 쓰는 C’라는 개념이 해당하는 내용. 클래스(생성자&소멸자), 캡슐화, 상속, 다형성, 가상 함수(동적 바인딩) 등 흔히 우리가 배우는 객체 지향 설계 규칙이 여기에 해당한다.
  • 템플릿 C++: C++의 일반화 프로그래밍 부분. 템플릿은 접해보기 쉽지 않지만, 프로그래밍 규칙마다 하나쯤은 템플릿 구문이 존재한다. 템플릿의 강력함으로 인해 ‘템플릿 메타프로그래밍(template metaprogramming: TMP)’이 등장했다.
  • STL: 템플릿 라이브러리. STL은 컨테이너(container), 반복자(iterator), 알고리즘(algorithm), 함수 객체(function object)를 기반으로 돌아가는 것을 규약으로 삼고 있으나, 템플릿과 라이브러리는 얼마든지 다른 아이디어를 중심으로 만들 수 있다. 독특한 사용규약이 있어 이를 따라야 한다.

효과적인 프로그램 개발을 위해서는 한 하위 언어에서 다른 하위 언어로 옮겨가면서, 대응 전략을 바꿔야 한다.

예를 들어, C 스타일에서는 기본 제공 타입에 대해서는 “값 전달이 참조 전달보다 대개 효율이 더 좋다”. 그러나 객체 지향 C++로 가면 사용자 정의 생성자/소멸자 개념으로 인해 상수 객체 참조자에 의한 전달(pass-by-reference-to-const)이 더 효율이 좋다. 템플릿 C++에서 이것이 더 두드러지는데, 왜냐하면 템플릿 C++에서는 객체의 타입을 알 수 없기 때문. 그러나 STL에서는, 반복자와 함수 객체가 C의 포인터를 본떠 만든 것이기 때문에 ‘값 전달’에 대한 규칙이 더 효과적이라고 한다.

 

항목 2: #define을 쓰려거든 cosnt, enum, inline을 떠올리자

좀 더 구체적으로 말하자면, “가급적 전처리자보다 컴파일러를 더 가까이 하자”.

#define ASPECT_RATIO 1.653 과 같이 코드를 쓴다고 생각해보자.

개발자에겐 ASPECT_RATIO가 기호식 이름(symbolic name)으로 읽히지만 컴파일러에겐 전혀 보이지 않는다. 소스 코드가 컴파일러로 넘어가기 전에 전처리자가 이를 숫자 상수로 바꾸어 버리기 때문이다. 그로 인해 ASPECT_RATIO란 이름은 컴파일러 기호 테이블(symbolic table)엔 들어가지 않는다. 그래서 숫자 상수로 대체된 코드에서 컴파일러 에러가 뜨게 되면, 에러 메시지엔 ASPECT_RATIO 대신 1.653이 있기 때문에 찾기 정말 곤란해진다. 이 문제는 기호식 디버거(Symbolic debugger)에서도 발생하는데, 마찬가지로 기호 테이블에 이름이 들어가지 않기 때문이다.

이 문제의 해결 방법은 매크로 대신 상수를 쓰는 것이다.

const double AspectRatio = 1.653; // 대문자로만 표기하는 이름은 대개 매크로에서만 쓰는 것이므로, 이름 표기도 바꾸자.

AspectRatio는 C++ 언어 차원에서 지원하는상수 타입 데이터이기 때문에, 당연히 컴파일러에서도 보이고 기호 테이블에도 들어간다. 게다가 위처럼 상수가 부동 소수점 실수 타입일 땐 컴파일을 거친 최종 코드의 크기가 #define을 썼을 때보다 작게 나올 수 있다. 매크로를 쓰면 프로젝트 코드 안에 1.653 사본이 등장 횟수만큼 들어가지만, 상수 타입의 AspectRatio는 여러 번 쓰여도 사본은 단 한 개만 생기기 때문.

주의사항: #define을 상수로 교체할 땐 두 가지를 조심해야 한다.

  • 상수 포인터(constant pointer)를 정의할 때: - 상수 정의는 헤더에 넣는 게 일반적이므로, 포인터는 꼭 const로 선언해주어야 하고, 포인터가 가리키는 대상까지 const로 선언하는 게 보통이다. 즉, 다음과 같이 const를 두 번이나 써줘야 한다.
const char * const authorName = "Scott Meyers";
  • 다만 문자열 상수를 쓸 땐 char\* 기반의 구식 문자열보단 string 객체로 선언하는 게 대체적으로 유리하다.
const std::string authorName("Scott Meyers");
  • 클래스 멤버로 상수를 정의하는 경우: - 어떤 상수의 유효 범위를 클래스로 한정하고자 할 땐 상수를 멤버로 만들어야 한다. - 이때 그 상수의 사본 개수가 한 개를 넘지 못하게 하려면, 정적 멤버로 만들어야 한다. - ```cpp
    class GamePlayer {
    private:
    static const int NumTurns = 5; // 상수 선언
    int scores[NumTurns]; // 상수 사용
    }
  • 이때 NumTurns는 ‘선언(declaration)’된 것으로 정의가 아니다. C++에서는 보통 정의가 필요하지만, 정적 멤버로 만들어지는 정수류(정수, char, bool 등) 타입의 클래스 내부 상수는 예외이다. 주소를 취하려 하지 않는 한, 정의 없이 선언만 해도 문제가 없다.
  • 단, 클래스 상수의 주소를 구하려고 하거나, (간혹) 컴파일러가 정의를 달라고 하는 경우에는 별도로 정의해주어야 한다.
const int GamePlayer::NumTurns; // NumTurns의 정의. 잘 보면 값이 없다.
  • 클래스 상수 정의는 헤더 파일이 아니라 구현 파일에 둔다. 이때 정의에는 상수의 초기값이 있으면 안된다. 왜냐하면 클래스 상수의 초기값은 해당 상수가 선언되는 시점(`private: static const int NumTurns = 5;` )에서 바로 주어지기 때문.
  • 단, 클래스 상수를 #define으로 만들 수는 없는데, 매크로는 #undef되기 전까지는 계속 유효하기 때문에 private 멤버 상수 같은 것으로 사용할 수가 없다.
  • 다만 오래된 컴파일러에서는 위 문법을 받아들이지 않는 경우가 있다. 정적 클래스 멤버가 선언된 시점에서 초기값을 주는 것이 맞지 않다 판단하기 때문이다. 특히 클래스 내부 초기화는 정수 타입에 대해서만 허용한다. 그로 인해 위 문법이 통하지 않는 컴파일러에서는, 초기값을 변수 ‘정의’ 시점에 주자.
// 헤더 파일
class CostEstimate {
private:
static const double FudgeFactor; // 정적 클래스 상수 선언
};
// 구현 파일
const double CostEstimate::FudgeFactor = 1.35; // 정적 클래스 상수 정의

위와 같이만 해도 충분하지만, 예외적으로 클래스 컴파일 도중에 클래스 상수의 값이 필요한 경우가 있다.

예를 들어, GamePlayer::scores 등의 배열 멤버를 선언할 때가 있다. 컴파일 과정에서 배열 선언을 위해선 배열의 크기를 알아야 한다.

그래서 정수 타입의 정적 클래스 상수에 대한 클래스 내 초기화를 금지하는 컴파일러(이는 표준에 어긋난 구식임)에서는 **enum hack** 기법을 쓸 수 있다.

enum hack 기법의 원리는 enumerator 타입의 값은 int가 놓일 곳에도 쓸 수 있다는 C++의 기능을 이용한다. 즉, GamePlayer는 이런 식으로도 쓸 수 있다.

class GamePlayer {
private:
    enum { NumTurns = 5 };        // enum hack: NumTurns를 5에 대한 기호식 이름으로 만든다.
    int scores[NumTurns];          // 상수 사용
    ...
};

이 enum hack은 알아 놓으면 여러모로 좋다.

  • 첫째, enum hack은 동작 방식이 const보다 #define에 가깝다:
    • const의 주소는 잡아낼 수 있지만, enum은 #define처럼 주소를 얻을 수 없다. 정수 상수의 주소를 얻거나 참조자를 쓰는 것이 싫다면, enum은 좋은 선택이 된다.
    • 또한, 제대로 만들어진 컴파일러는 정수 타입의 const 객체에 대해 저장 공간을 준비하지 않지만(객체에 대한 포인터나 참조자를 만들지 않는 한), 많은 컴파일러에서는 반대로 동작할 수 있다. 따라서, const 객체에 대한 메모리를 확실하게 만들지 않기 위해서 enum을 사용할 수도 있다. enum은 #define처럼 쓸데없는 메모리 할당을 하지 않는다.
  • 둘째, 상당히 많은 코드에서 enum hack이 쓰인다:
    • 이미 많은 곳에서 쓰이니, 미리 알아두는 것이 좋다.
    • 실제로 enum hack은 템플릿 메타프로그래밍의 핵심이다.



추가 연구: const의 저장 공간?

이때 컴파일러마다 const 객체에 대한 저장 공간을 준비할 수도, 아닐 수도 있다고 이야기했는데, 이는 const가 할당될 공간은 표준에 명시되어 있지 않기 때문이다. 이 부분은 정확히 적혀 있지 않아서, 직접 찾아보면서 연구해 보았다.

직접 컴파일해서 확인해보자.

#include <iostream>

const int global_const = 123;
int global_var = 456;

int main() {
    const int local_const = 789;
    std::cout << "&global_const: " << (void*)&global_const << "\n";
    std::cout << "&global_var: " << (void*)&global_var << "\n";
    std::cout << "&local_const: " << (void*)&local_const << "\n";
}

예시 코드는 위와 같다.

  • g++ -g: gdb에게 제공하는 정보를 바이너리로 삽입한다.
  • nm 명령어: 오브젝트 파일에 포함된 심볼을 확인한다.

이렇게 nm을 통해 심볼을 확인할 수 있다. 위 그림은 global 키워드로 데이터를 찾은 것.

nm에서는 위와 같이 심볼 확인이 가능하다. 이때, r은 .read-only data 혹은 .rodata를 말한다. D는 data area를 의미한다.

local은 찾아보아도 찾을 수 없는데(global로 검색하지 않더라도), 이는 스택에 저장되었기 때문이다.

반면, 단순한 전역 변수는 global variable에 저장되고, 전역 const는 .rodata에 저장되었음을 확인할 수 있다.

다만 경우에 따라 컴파일러가 const는 상수로 치환할 가능성도 있다고 한다. 그 경우엔 메모리를 차지 안 하는 게 맞다.

해당 부분은 다음을 참고하였다(Where is a C++ const variable stored in memory?, const가 메모리에 할당되지 않는다는게)

 

Where is a C++ const variable stored in memory?

Answer (1 of 6): Let ‘s take an example. Example: [code]const int a=8; [/code]In first, pass compiler allocates the space and later it will determine whether it have to convert it to a literal or not. If you have not ever take the address of it then comp

www.quora.com

 

 

const가 메모리에 할당되지 않는다는게 - 인프런 | 커뮤니티 질문&답변

누구나 함께하는 인프런 커뮤니티. 모르면 묻고, 해답을 찾아보세요.

www.inflearn.com

 



다시 전처리자에 대한 이야기로 돌아오자.

#define 지시자의 오용 사례는 ‘매크로 함수’이다. 함수처럼 보이지만 함수 호출 오버헤드를 일으키지 않기 위해 매크로를 구현할 수 있다. 아래 예시는 인자 중 큰 값을 이용해 함수 f를 호출한다.

// a와 b 중에 큰 것을 f에 넘겨 호출한다.
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

이러한 매크로는 단점이 많다.

우선, 매크로를 작성할 땐 인자마다 괄호를 씌워줘야 한다.

그리고 다른 문제로, 아래 같은 경우에 괴현상이 발생한다.

int a = 5, b = 0;
CALL_WITH_MAX(++a, b);      // a가 두 번 증가
CALL_WITH_MAX(++a, b + 10); // a가 한 번 증가

이 경우, 비교를 통해 처리한 결과가 어떤 것이냐에 따라 a가 두 번 증가할 수도 있고, 한 번만 증가할 수도 있어 예측이 상당히 어려워 진다.

다행히 C++에서는 이와 비슷하게 매크로의 효율을 유지하면서, 정규 함수의 모든 동작 방식, 타입 안전성까지 완벽히 취할 수 있는 대체안이 있다. 바로 인라인 함수에 대한 템플릿이다.

template<typename T>
inline void callWithMax(const T& a, const T& b)
{
    f(a > b ? a : b);
}

위 함수는 템플릿이니 동일 계열 함수군(family of function)템플릿으로 만들어질 것으로 예측 가능한 모든 함수을 만들어낸다. 동일 타입의 객체 두 개를 받아 둘 중 큰 것을 f에 넘겨 호출하는 구조이다.

이렇게 하면 인자마다 괄호 칠 필요도 없고, 인자를 여러 번 평가할 일도 없다. 또한, callWithMax는 진짜 함수이므로 유효 범위, 접근 규칙도 그대로 따라간다. 그래서 매크로와 달리 특정 클래스에서만 쓸 수 있는 함수도 만들 수 있다.

정리하자면, 굳이 전처리자(특히 #define)를 쓰지 않고 const, enum, inline를 쓰는 게 더 나은 경우가 많다. 물론 현실적으로 #include나 컴파일 조정 기능을 하는 #ifdef/#ifndef는 실뭉서 여전히 사용한다. 그러니 이를 잘 고려하여 가능하다면 전처리자를 줄여보자.

요약

  • 단순한 상수를 쓸 땐 #define보다 const 객체 혹은 enum을 우선 생각하기
  • 함수처럼 쓰이는 매크로를 만들려면, #define 매크로보다 인라인 함수 우선 생각하기

 

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

const에 대하여

const는 의미적으로 ‘const 키워드가 붙은 객체는 외부 변경을 불가능하게 한다’는 제약을 소스 코드 수준에서 붙일 수 있다. 그리고 컴파일러가 이 제약을 단단히 지켜주는 점이 좋은 점이다. 어떤 값(객체의 내용)이 불변이어야 한다는 제작자의 의도를 컴파일러 및 다른 프로그래머와도 나눌 수 있는 수단이 되기도 한다.

const는 다양한 곳에 사용할 수 있다. 클래스 밖에서는 전역이나, 네임스페이스 유효범위 상수를 선언(정의)할 수 있다. 혹은, 파일, 함수, 블록 유효범위에서 static 선언 객체에도 const를 붙일 수 있다. 클래스 내부에선, 정적 멤버 및 비정적 데이터 멤버 모두 상수 선언이 가능하다. 포인터를 상수로 할 수도 있고, 포인터가 가리키는 데이터를 상수 지정할 수 있다.

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

굉장히 변덕스러워 보이지만, const 키워드가 *표 왼쪽에 있으면 포인터가 가리키는 대상이 상수이고, const가 *표 오른쪽에 있으면 포인터 자체가 상수이다.

이때 const는 타입 앞에 쓸 수도 있고(const char * ) 뒤에 쓸 수도 있다(char const * ). 둘 다 많이 쓴다고 한다.

STL 반복자(iterator)는 포인터를 본뜬 것이기 때문에, 기본적인 동작 원리가 T* 포인터와 흡사하다. 어떤 반복자를 const로 선언하면 포인터를 상수로 선언하는 것(T* const)과 같다. 반복자가 가리키는 대상을 다른 대상으로 바꿀 수는 없지만, 반복자가 가리키는 대상 그 자체는 변경할 수 있다. 변경 불가능한 객체를 가리키는 반복자(const T* 포인터의 반복자)가 필요하다면 const_iterator를 쓰면 된다.

std::vector<int> vec;

const std::vector<int>::iterator iter = vec.begin(); // T* const처럼 동작
*iter = 10; // 대상 그 자체는 변경 가능
++iter; // error! iter는 상수

std::vector<int>::const_iterator cIter = vec.begin(); // const T* 처럼 동작
*cIter = 10; // error! *cIter는 상수
++cIter; // cIter는 변경 가능

const의 가장 강력한 용도는 함수 선언에 쓸 경우이다. 함수 선언문에서 const는 함수 반환 값, 매개변수, 멤버 함수 앞에 붙을 수 있고, 함수 전체에 const의 성질을 붙일 수 있다.

특히 함수 반환 값을 상수로 정해 주면, 안전성, 효율을 포기하지 않고 사용자측 에러 및 돌발 상황을 줄일 수 있다.

// 유리수 클래스에서 operator*의 선언을 보자.
class Rational { ... };
const Rational operator*(const Rational& lhs, const Rational& rhs);

// const Rational을 반환하면 다음 상황을 막을 수 있다!
Rational a, b, c;
(a * b) = c; // 결과에 대입을 하는 이상한 상황이 생긴다...

위 상황 같이 두 수의 곱에 대입을 취하는 건 대부분 실수이다. a, b의 타입이 기본제공 타입이었다면, 용서 없이 문법 위반에 걸릴 것이다.

훌륭한 사용자 정의 타입들은 기본제공 타입과의 쓸데없는 비호환성을 피한다(항목 18)는 특징이 있다. 위 같이 두 수의 곱에 대입 연산이 되도록 놓는 것이 ‘쓸데없는 경우’가 되겠다.

const 매개변수는 특별한 점은 없고, const 타입 지역 객체와 특성이 같다. 가능하면 항상 사용하자. 매개변수, 지역 객체를 수정할 수 없게 하는 게 목적이라면 언제나 const로 선언할 것.

 

상수 멤버 함수

멤버 함수에 붙는 const 키워드는 “해당 멤버 함수가 상수 객체에 대해 호출될 함수이다”라는 사실을 알려준다.

이것이 중요한 이유는 두 가지이다.

  • 첫째, 클래스의 인터페이스를 이해하기 좋게 한다.
    • 그 클래스로 만들어진 객체를 변경할 수 있는 함수는 무엇이고, 변경할 수 없는 함수는 무엇인지 사용자에게 알려줄 수 있다.
  • 둘째, const 키워드를 통해 상수 객체를 사용할 수 있게 할 수 있다.
    • C++ 프로그램의 실행 성능을 높이는 핵심 기법 중 하나가 객체 전달을 ‘상수 객체에 대한 참조자(reference-to-const)’로 진행하는 것이기 때문이다(항목 20).
    • 이 기법을 제대로 쓰려면, 상수 상태로 전달된 객체를 조작할 수 있는 const 멤버 함수, 즉 상수 멤버 함수가 준비되어 있어야 한다.

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의 operator[]는 다음과 같이 쓸 수 있다.

TextBlock tb("Hello");
std::cout << tb[0]; // TextBlock::operator[]의 비상수 멤버 호출
const TextBlock ctb("World");
std::cout << ctb[0]; // TextBlock::operator[]의 상수 멤버 호출

실제 프로그램에서 상수 객체가 생기는 것은 1. 상수 객체에 대한 포인터 혹은 2. 상수 객체에 대한 참조자로 객체가 전달될 때이다. 보통은 아래와 같은 경우가 예시가 된다.

void print(const TextBlock& ctb) // 이 함수에서 ctb는 상수 객체
{
  std::cout << ctb[0]; // TextBlock::operator[]의 상수 멤버를 호출
}

여기서 operator[]를 ‘오버로드(overload)’해서 각 버전마다 반환 타입을 다르게 가져갔기 때문에, 상수 객체와 비상수 객체의 쓰임새도 달라지게 된다.

std::cout << tb[0]; // 정상적으로 비상수 버전의 TextBlock 객체 읽음
tb[0] = 'x'; // 정상적으로 비상수 버전의 TextBlock 객체 씀
std::cout << ctb[0]; // 정상적으로 상수 버전의 TextBlock 객체 읽음
// ctb[0] = 'x'; // 컴파일 에러: 상수 버전의 TextBlock 객체는 쓸 수 없습니다.

주의할 점은 넷째 줄에서 발생한 에러는 순전히 operator[]의 반환 타입(return type) 때문에 생긴 것이라는 점이다. 즉, operator[] 호출은 잘못되지 않았고, const char& 타입에 대한 연산을 시도해서 에러가 생긴 것. 만약 return을 비상수로 했다면, 요소의 내용을 바꿀 수 있었을 것이다.

하나 더 집중할 점은, operator[]의 비상수 멤버는 char의 참조자(reference)를 반환한다는 점이다. 단순히 char를 반환한다면, tb[0] = ‘x’; 와 같은 문장은 컴파일되지 않았을 것이다. 왜냐하면 기본제공 타입을 반환하는 함수의 반환 값을 수정하는 일은 불가능하기 때문. C++ 특성 상 반환 시 ‘값에 의한 반환’을 하기 때문에, tb.text[0]의 사본이 변경될 뿐 tb.text[0]은 변경되지 않을 것이다.

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

어떤 멤버 함수가 상수 멤버(const)라는 것은 어떤 의미일까? 여기에는 몇 가지 개념이 존재한다.

비트수준 상수성[bitwise constness, 혹은 물리적 상수성(physical constness)]이고 하나는 논리적 상수성(logical constness)이다.

비트수준 상수성은 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야(정적 멤버 제외) 그 멤버 함수가 ‘const’임을 인정하는 개념이다. 즉, 그 객체를 구성하는 비트들 중 어떤 것도 바꾸면 안된다. 컴파일러에서는 이를 확인하는 게 어렵지 않으므로, C++에서는 비트수준 상수성을 구현한다. 상수 멤버 함수는 그 함수가 호출된 어떤 비정적 멤버도 수정할 수 없다.

다만, 제대로 const로 동작하지 않아도 비트수준 상수성 검사를 통과하는 멤버 함수도 있다. 어떤 포인터가 가리키는 대상을 수정하는 멤버 함수는 비트수준 상수성을 통과하는 참사가 생긴다. 멤버로 포인터가 똑같이 들어 있기 때문에, 비트수준에서 바뀌지 않았다고 보기 때문이다.

예를 들어, operator[](std::size_t position) const 가 pointer를 반환한다면, 이 반환된 포인터를 통해 마음대로 멤버를 수정해도 pointer 자체만 바꾸지 않는다면 문제가 생기지 않는다. 이처럼 비트수준 상수성은 당장 비트가 데이터가 바뀔 위험이 없는 것이 아니다.

 

이를 보완하기 위해 논리적 상수성(logical constness)이라는 개념이 나왔다.

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

다만, const 함수에서는 아예 비트를 바꾸는 것을 허용하지 않기 때문에, 논리적 상수성을 지키는 함수에서는 mutable 키워드가 필요하다. 이는 비정적 데이터 멤버를 비트수준 상수성의 제약에서 벗어나게 해준다. 아래 예시에서는 textLength와 lengthIsValid에 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 키워드를 사용하면 const 함수에서 멤버가 바뀌어도 OK
    lengthIsValid = true;
  }
}

 

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

  • 상수 멤버, 비상수 멤버 함수는 오버로딩이 가능하다 보니, 여러모로 코드가 겹칠 수 있다.
class TextBlock {
public:
  const char& operator[](std::size_t position) const
  {
    ... // 경계 검사
    ... // 접근 데이터 로깅
    ... // 자료 무결성 검증
    return text[position];
  }
  char& operator[](std::size_t position)
  {
    ... // 경계 검사
    ... // 접근 데이터 로깅
    ... // 자료 무결성 검증
    return text[position];
  }
};

위와 같은 케이스가 대표적이다. 상수, 비상수 멤버 함수를 같이 만들게 되면 필히 기능이 겹치게 된다.

  • 이때 코드 중복을 막으려면 비상수 멤버 함수가 상수 멤버 함수를 호출하게 하자.
    • 이를 위해서는 static_cast로 상수 포인터로 바꿔 상수 멤버 함수를 호출한 뒤, 결과에서 const_cast로 const를 떼어내야 한다. 예쁘진 않지만 획기적으로 코드를 줄일 수 있다.
class TextBlock {
public:
  const char& operator[](std::size_t position) const // 이전과 동일
  {
    ... // 경계 검사
    ... // 접근 데이터 로깅
    ... // 자료 무결성 검증
    return text[position];
  }
  char& operator[](std::size_t position)
  {
    return const_cast<char&>(       // op[]의 반환 타입에 캐스팅 적용하여 const 떼기
      static_cast<const TextBlock&> // *this의 타입에 const 붙인다.
        (*this)[position];          // 상수 멤버 함수 버전을 호출한다.
  }
};
  • 만약 반대로 ‘상수 멤버 함수’가 ‘비상수 멤버 함수’를 호출하게 하면, 비상수 멤버 함수는 내부 객체가 어떻게 바뀔지 모르므로 객체 자체가 변경될 위험이 존재한다. 그러니 호출되는 건 상수 멤버 함수 쪽으로 설정하자.

 

요약

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

 

항목 4 객체를 사용하기 전에 반드시 그 객체를 초기화하자

  • C++의 객체(변수) 초기화는 규칙이 다소 복잡해서 헷깔린다.
    • C++에서 C 부분은 초기화 보장이 없고, 다른 부분은 때에 따라 다르다.
  • 따라서 아예 다 초기화하고 시작한다는 마인드를 깔고 가자.
  • 기본제공 타입으로 만들어진 비멤버 객체는 손수 초기화할 것.
  • 멤버 생성자에서는 초기화 리스트로 ‘모든 멤버를 나열하여 초기화’하도록 하자.
    • 대입은 초기화가 안 될 가능성이 있어 비효율적일 수 있다.
    • 초기화 리스트에서 없으면 사용자 정의 타입은 자동으로 기본 생성자를 호출하지만, 그냥 기본적으로 다 초기화하는 게 실수를 줄일 수 있다.
    • 단, 기본제공 타입 멤버를 초기화 리스트에 넣는 게 의무가 되는 경우: 상수, 참조자
    • 너무 생성자가 많아서 멤버 초기화 리스트가 겹치면, 대입으로 초기화 가능한 멤버는 별도의 초기화 함수로 빼어서 처리하는 것도 나쁘지 않다. 다만 이건 특수 케이스.
  • 컴파일러 막론하고 객체의 초기화 순서는 달라지지 않는다.
  1. 기본 클래스는 파생 클래스보다 먼저 초기화된다(항목 12).
  2. 클래스 데이터 멤버는 그들이 선언된 순서대로 초기화된다.
    • 초기화 리스트에서 나열된 순서와 무관 → 이 때문에 생각했던 것과 달라질 수 있으니 멤버 순서대로 초기화 리스트에 나열하자.
  • 중요: 비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다.
    • 정적 객체엔 전역 객체, 네임스페이스 유효범위에서 정의된 객체, 클래스 안에서 static으로 선언된 객체, 함수에서 static으로 선언된 객체, 파일 유효범위에서 static으로 정의된 객체가 있다.
    • 이때 함수 안의 정적 객체는 지역 정적 객체, 나머지는 비지역 정적 객체. 이들 모두 main()이 끝날 때 소멸된다.
    • 번역 단위란 컴파일을 통해 하나의 목적 파일을 만드는 바탕이 되는 소스 코드. 보통은 소스 파일 한 개 + 거기에 include된 파일까지 합쳐서 말함.
    • 별도 컴파일된 소스 파일이 두 개 이상이고 각각 비지역 정적 객체가 있으면, 둘은 초기화 순서를 알 수 없다.
    • “별개의 번역 단위에서 정의된 비지약 정적 객체들의 초기화 순서는 ‘정해져 있지 않기’ 때문”이다.
    • A 정적 객체가 B 정적 객체를 필요로 해도, 그 순서는 정해져 있지 않아서 B가 초기화 안 됐는데 A가 B를 참조할 수도 있다는 뜻.
  • 이걸 피하려면 비지역 정적 객체를 하나씩 맡는 함수를 준비하고, 각 함수에서 정적 객체의 참조자를 반환하게 하면 된다.
    • 지역 정적 객체는 함수 호출 중 그 객체의 정의에 최초로 닿았을 때 초기화된다.
    • 각 함수에서는 정적 객체의 초기화를 보장해야 한다.
    • 이 함수를 한번도 호출하지 않으면 객체도 생기지 않는다.
    • 그런 점에서 싱글턴 패턴으로 볼 수 있다.
    • 만약 아래와 같은 식으로 코드를 짜면, tempDir()를 호출할 때 디렉토리가 생성되면서 tfs() 함수를 호출하게 되고, 이때 fs도 함께 생성된다. 즉, 순서가 꼬여서 어느 하나가 생성되지 않는 경우는 없을 것이라는 뜻! (다만 static으로 만들면 싱글턴 패턴 자체가 끝날 때까지 객체가 사라지지 않는 문제가 있으니, 다른 방법을 고민해볼 수도 있다)
class FileSystem { ... };
FileSystem& tfs()        // 함수로 fs를 반환한다(클래스의 static 함수여도 된다)
{
  static FileSystem fs;  // 지역 정적 객체를 정의하고 초기화하여 반환한다.
  return fs;
}
class Directory { ... };
Directory::Directory(params)
{
  ...
  std::size_t disks = tfs().numDisks(); // tfs()로 파일 시스템을 획득한다.
}
Directory& tempDir()    // 임시 temporary directory를 반환
{
  static Directory td;  // 이것도 지역 정적 객체로 정의한다.
  return td;
}
  • 단, 다중스레드 프로그램에선 동시에 한 객체를 초기화하려 할 가능성도 있다. 해결책은, 다중스레드로 돌입하기 전에 참조자 반환 함수를 모두 호출해줘서, 초기화 경쟁 상태를 없애버리는 것.
  • 다만 A가 B를 참조하고 B가 A를 참조해서 서로가 초기화되어야 초기화되는 구조라면 문제가 생긴다.

요약

  • 기본 타입의 객체는 직접 손으로 초기화하자. 경우에 따라 저절로 되기도 하고 안되기도 한다.
  • 생성자에서는, 데이터 멤버에 대한 대입문을 생성자 본문 내부에 넣는 방법으로 멤버를 초기화하지 말고, 멤버 초기화 리스트를 즐겨 사용하자. 그리고 초기화 리스트에 데이터 멤버를 나열할 때는 클래스에 각 데이터 멤버가 선언된 순서대로 똑같이 나열하자.
  • 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야 한다. 이 경우, 비지역 정적 객체를 지역 정적 객체로 바꾸면 된다.

댓글