Post

[Effective C++] 1. C++에 왔으면 C++의 법을 따릅시다 [1/2]

[Effective C++] 1. C++에 왔으면 C++의 법을 따릅시다 [1/2]

이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.

개요

우리가 어떤 프로그래밍 언어를 어떻게 써왔는지는 상관없다. 어쨌든 중요한 건 C++는 익숙해지는 데 살짝 시간이 필요해 보인다는 거다. 제공하는 기능의 범위만 해도 어마어마한데다가 강력함까지 가지고 있는 C++이지만, 이 언어에서 뿜어져 나오는 힘을 자유자재로 휘두르면서 기능을 효과적으로 활용하기 위해서는 우리의 행동방식과 사고방식을 C++ 스타일로 만들 필요가 잇다. 이 책 전체가 그 방법에 대한 내용이지만, 다른 것들보다 좀더 우선적이고 근본적인 것들이 몇 가지 있는 것은 사실이고, 그 중 ‘가장’ 근본적인 것들을 다루고 있는 단원이 제1장이다.


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


초창기의 C++는 단순히 C 언어에 객체 지향 기능 몇 가지가 결합된 형태였다. 오죽하면 C++의 처음 이름조차도 이 점을 드러내려는 듯 ‘클래스를 쓰는 C(C with Classes)’였겠는가.

아시다시피 C++는 그 후 꾸준한 성장을 거쳤다. 단지 ‘클래스를 쓰는 C’라고는 말할 수 없을 만큼 아이디어, 기능, 프로그래밍 전략들을 취해 자기 것으로 만드는 데 있어 점점 대담하고 과감한 행보를 보였다. 예외는 함수 구성방식의 헤게모니를 크게 바꾸었다. 템플릿은 프로그램 설계에 대한 새로운 사고방식을 우리에게 선사했으며, STL은 거의 어느 누구도 본 적 없을 듯한 ‘확장성’에 대한 지평을 열었다.

이렇게 발전한 오늘날의 C++는 다중패러다임 프로그래밍 언어라고 불리낟. 절차적 프로그래밍을 기본으로 하여 객체 지향, 함수식, 일반화 프로그래밍을 포함하여 메타프로그래밍 개념까지 지원하고 있다. 이런 엄청난 표현력과 유연성 덕택에 C++는 소프트웨어 개발에 있어 대체할 만한 것이 없는 도구가 되었지만, 혹자에게는 어느 정도 혼동을 줄 여지가 있는 것도 사실이다. 지원하는 프로그래밍 개념마다 ‘적절한 사용’ 규칙이 있다 해도 예외가 없는 경우는 못 본 것 같다. 그렇다면 C++는 대체 어떻게 이해해야 잘했다고 소문이 날까?

가장 쉬우면서 정확한 방법은 C++를 단일 언어로 바라보는 눈을 넓혀, 상관 관계가 있는 여러 언어들의 연합체로 보는 것이다. 그러고 나서 각 언어에 관한 규칙을 각개 격파하는 것이다. 이렇게 해 가면 시각이 단순해지고 명확해지며, 기억하기도 편해진다. 물론, 규칙은 언어마다 정도의 차이는 있겠으나 다를 수 있다. C++를 제대로 따라잡으려면, 이 언어가 여러 개의 하위 언어를 제공한다는 점을 새기고 있어야 한다. 하위 언어는 아래 네 가지뿐이다.

  1. C
    C++를 처음 배우는 사람조차도 알고 있는 불변의 사실이 이거다. C++는 여전히 C를 기본으로 하고 있다. 블록, 문장, 선행 처리자, 기본제공 데이터타입, 배열, 포인터 등 모든 것이 C에서 왔다. C에 대응되며 그보다 월등한 C++만의 것들이 상당히 많이 제공되고 있긴 하지만, 어쨌든 C만 쏙 뽑아 써도 된다. 이렇게 하다 보면 효과적인 프로그래밍을 위한 규칙을 C에 적용할 수 있는 범위가 아주 좁아지겠지만 말이다. 템플릿, 예외, 오버로딩 등이 없을 것이다.
  2. 객체 지향 개념의 C++ 이 부분에 ‘클래스를 쓰는 C’에 관한 것이 모두 해당된다. 그러니까 클래스, 캡슐화, 상속, 다형성, 가상 함수 등이다. 우리가 학교에서 배웠던 객체 지향 설계의 규칙들 대부분이 그대로 들어맞는 부분이라고 보면 된다.
  3. 템플릿 C++
    C++의 일반화 프로그래밍 부분으로, 많은 프로그래머들이 경험해 보지 않은 영역 중 하나이다. 오늘날 이 템플릿이 C++에 끼치는 영향은 가히 전방위적이기 때문에, 어지간히 괜찮다고들 하는 프로그래밍 규칙에서는 저마다의 용도에 맞춘 템플릿 구문 하나쯤은 흔히 발견할 수 있다. 그뿐 아니라 템플릿의 주체 못할 강력함이 너무나도 크고 훌륭하다 보니 완전히 새로운 프로그래밍 패러다임이 파생되기까지 했다. 이름하여 템플릿 메타프로그래밍이다. TMP에 대한 이야기는 항목 48에서 전반적으로 다룬다.
  4. STL
    STL은 이름에서 알 수 있듯이 템플릿 라이브러리이다. 하지만 대단히 특별한 템플릿 라이브러리라고 말할 수 있다. STL의 세계는 컨테이너, 반복자, 알고리즘과 함수 객체가 얽혀 돌아가는 것을 규약으로 삼고 있으나, 템플릿과 라이브러리는 얼마든지 다른 아이디어를 중심으로 만들어질 수 있다. 또한 STL은 나름대로 독특한 사용규약이 있어서, STL을 써서 프로그래밍하려면 그 규약을 따르면 된다.

이렇게 네 가지 하위 언어들이 C++을 이루고 있다는 점을 마음에 새겨두자. 그리고 이제는 효과적인 프로그램 개발을 위해 한 하위 언에에서 다른 하위 언어로 옮겨 가면서 대응 전략을 바꾸어야 하는 상황이 오더라도 당황하지 말아야 한다. 예를 들어, C 스타일로만 쓰고 있으면 기본제공 타입에 대해서는 “값 전달이 참조 전달보다 대개 효율이 더 좋다”라는 규칙이 통하지만, C++의 C 부분만 쓰다가 객체 지향 C++로 옮겨 가면 사용자 정의 생성자/소멸자 개념이 생기면서 상수 객체 참조자에 의한 전달(pass-by-reference-to-const) 방식이 더 좋은 효율을 보인다. 이런 현상은 템플릿 C++를 쓰게 될 때 특히 두드러지는데, 왜냐하면 템플릿 C++의 세계에서는 우리가 손에 들고 있는 객체의 타입조차 알 수 없기 때문이다. 그러나 STL 쪽으로 넘어오면 전세가 또 바뀐다. STL을 배우다 보면 반복자와 함수 객체가 C의 포인터를 본떠 만든 것이란 점을 알게 되고, 그렇기 때문에 STL의 반복자 및 함수 객체에 대해서는 값 전달에 대한 규칙이 다시 힘을 발휘하게 된다.

C++는 한 가지 프로그래밍 규칙 아래 똘똘 뭉친 통합 언어가 아니라 네 가지 하위 언어들의 연합체이다. 각각의 하위 언어가 자신만의 규칙을 가지고 있다. “하위 언어들로 구성되어 있다”라는 점을 기억하고 있으면 C++ 이해의 관문에 들어서기가 대단히 쉬워질 것이다.

C++를 사용한 효과적인 프로그래밍 규칙은 경우에 따라 달라진다. 그 경우란, 바로 C++의 어느 부분을 사용하느냐이다.


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


항목 이름이 위처럼 되어 있지만, “가급적 선행 처리자보다 컴파일러를 더 가까이 하자”라는 제목이 더 나았을 수도 있다. #define은 C++ 언어 자체의 일부가 아닌 것으로 취급될 수 있으니까 말이다. 이 점도 문제이다. 아래 줄과 비슷한 코드를 썼다고 가정해 보자.

1
#define ASPECT_RATIO 1.653

우리에겐 이미 ASPECT_RATIO가 기호식 이름으로 보이지만 컴파일러에겐 전혀 보이지 않다. 소스 코드가 어떻게든 컴파일러에게 넘어가기 전에 선행 처리자가 밀어버리고 숫자 상수로 바꾸어 버리기 때문이다. 그 결과로, ASPECT_RATIO라는 이름은 컴파일러가 쓰는 기호 테이블에 들어가지 않는다. 그래서 숫자 상수로 대체된 코드에서 컴파일 에러라도 발생하게 되면 꽤 헷갈릴 수 있다. 소스 코드엔 분명히 ASPECT_RATIO가 있었는데, 에러 메세지엔 1.653이 나오는 식이니까 말이다. 행여 ASPECT_RATIO가 정의된 파일이 우리가 작성한 것이 아니라면 곤란해진다. 대체 1.653이 어디에서 왔는지 모를 수도 있고, 이것을 찾아 들어가느라 시간을 허비할 일도 생길테다. 이 문제는 기호식 디버거에서도 나타날 소지가 있다. 마찬가지로 기호 테이블에 이름이 들어가지 않기 때문이다.

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

1
const double AspectRatio = 1.653;

AspectRatio는 언어 차원에서 지원하는 상수 타입의 데이터이기 때문에 당연히 컴파일러의 눈에도 보이며 기호 테이블에도 당연히 들어간다ㅣ 게다가 상수가 부동소수점 실수 타입일 경우에는 컴파일을 거친 최종 코드의 크기가 #define을 썼을 때보다 작게 나올 수 있다. 가만히 생각해보면 이유를 알 수 있다. 매크로를 쓰면 코드에 ASPECT_RATIO가 등장하기만 하면 선행 처리자에 의해 1.653으로 모두 바뀌면서 결국 목적 코드 안에 1.653의 사본이 등장 횟수만큼 들어가게 되지만, 상수 타입의 AspectRatio는 아무리 여러 번 쓰이더라도 사본은 딱 한 개만 생기기 때문이다.

그리고 #define을 상수로 교체하려는 사람에게는 딱 두 가지 경우만 특별히 조심하라고 말한다. 첫째는 상수 포인터를 정의하는 경우이다. 상수 정의는 대게 헤더 파일에 넣는 것이 상례이므로 포인터는 꼭 cosnt로 선언해 주어야 하고, 이와 아울러 포인터가 가리키는 대상까지 const로 선언하는 것이 보통이다. 이를테면 어떤 헤터 파일 안에 char* 기반의 문자열 상수를 정의한다면 다음과 같이 const를 두번 써야 한다는 말이다.

1
const char* const authorName = "Scott Meyers";

첫 번째 const는 포인터가 가리키는 데이터를 상수로 만든다.
즉, authorName[0] = ‘K’처럼 이름을 수정할 수 없다.

두 번째 const는 포인터 변수(주소값) 자체를 상수로 만든다.
즉, authorName = “Other Author”처럼 포인터가 다른 문자열을 가리키도록 할 수 없다.

문자열 상수를 쓸 때 위와 같이 char* 기반의 구닥다리 문자열보다는 string 객체가 대체적으로 사용하기 괜찮다.

1
const std::string authorName("Scott Meyers");

두 번째 경우는 클래스 멤버로 상수를 정의하는 경우, 즉 클래스 상수를 정의하는 경우이다. 어떤 상수의 유효범위를 클래스로 한정하고자 할 때는 그 상수를 멤버로 만들어야 하는데, 그 상수의 사본 개수가 한 개를 넘지 못하게 하고 싶다면 정적(static) 멤버로 만들어야 한다.

1
2
3
4
5
6
class GamePlayer {
private:
    static const int NumTurns = 5;  // 상수 선언
    int scores[NumTurns];           // 상수를 사용하는 부분
    // ...
};

위에서 본 NumTurns는 ‘선언(declaration)’된 것이지 ‘정의(definition)’된 것은 아니다. C++에서는 우리가 사용하고자 하는 것에 대해 ‘정의’가 마련되어 있어야 하는게 보통이지만, 정적 멤버로 만들어지는 정수류(각종 정수 타입, char, bool 등) 타입의 클래스 내부 상수는 예외이다. 이들에 대해 주소를 취하지 않는 한, 정의 없이 선언만 해도 아무 문제가 없게 되어 있다. 단, 클래스 상수의 주소를 구한다든지, 우리가 주소를 구하지 않는데도 우리가 쓰는 컴파일러가 잘못 만들어진 관계로 정의를 달라고 떼쓰는 경우에는 별도의 정의를 제공해야 한다. 아래가 그 예이다.

1
const int GamePlayer::NumTurns; // NumTurns의 정의. 값이 주어지지 않는 이유는 아래를 계속 보면 나온다.

이때 클래스 상수의 정의는 구현 파일에 둔다. 헤더 파일에 두지 않는다. 정의에는 상수의 초기값이 있으면 안 되는데, 왜냐하면 클래스 상수의 초기값은 해당 상수가 선언된 시점에서 바로 주어지기 때문이다. (즉, NumTurns은 선언될 당시에 바로 초기화된다는 것이다.)

그런데 주의할 것이 하나 있다. 클래스 상수를 #define으로 만드는 방법 자체는 말이 안된다. #define은 유효범위가 없는 것이다. 매크로는 일단 정의되면 컴파일이 끝날 때까지 유효하다는 점을 기억해 두길 바란다. (중간에 #undef되지 않으면) 정리하면, #define은 클래스 상수를 정의하는데 쓸 수도 없을 뿐 아니라 어떤 형태의 캡슐화 혜택도 받을 수 없다. 말하자면 ‘private’ 성격의 #define 같은 것은 없다는 이야기이다. 물론 이와 대조적으로 NumTurns 같은 상수 데이터 멤버는 캡슐화가 된다.

조금 오래된 컴파일러는 위의 문법을 받아들이지 않는 경우가 종종 있다. 이유는 간단하다. 정적 클래스 멤버가 선언된 시점에 초기값을 주는 것이 대개 맞지 않다고 판단하기 때문이다. 게다가 클래스 내부 초기화를 허용하는 경우가 정수 타입의 상수에 대해서만 국한되어 있으니 말이다. 어쨌든 위의 문법이 먹히지 않는 컴파일러를 쓸 때는, 초기값을 상수 ‘정의’ 시점에 주도록 하자.

1
2
3
4
5
6
7
class CostEstimate
{
private:
    static const double FudgeFactor; // 정적 클래스 상수의 선언 
};

const double CostEstimate::FudgeFactor = 1.35; // 정적 클래스 상수의 정의

웬만한 경우라면 이것으로 충분하다. 딱 한 가지 예외가 있다면 해당 클래스를 컴파일하는 도중에 클래스 상수의 값이 필요할 때인데, 이를테면 GamePlayer::scores 등의 배열 멤버를 선언할 때가 대표적인 예이다. (컴파일러는 컴파일 과정에서 이 배열의 크기를 알아야한다며 버틸 것이다.) 그렇기 때문에 정수 타입의 정적 클래스 상수에 대한 클래스 내 초기화를 금지하는(다시 말하지만 이것은 표준에 어긋난 구식이다) 구식 컴파일러에 대한 배려로서 괜찮은 방법을 추천한다면, ‘나열자 둔갑술(enum hack)’이라는 기법을 사용할 수 있다. 이 기법의 원리는 나열자(enumerator) 타입의 값은 int가 놓일 곳에도 쓸 수 있다는 C++의 진실을 활용하는 것이다. 그러니까 GamePlayer는 다음과 같이 정의할 수 있다.

1
2
3
4
5
6
class GamePlayer
{
private:
    enum { NumTurns = 5 };  // 나열자 둔갑술 : NumTurns를 5에 대한 기호식 이름으로 만든다.
    int scores[NumTurns];   // 해결
}

이 나열자 둔갑술은 알아 두는 것이 여러 가지 이유로 피가 되고 살이 된다. 첫째, 나열주 둔갑술은 동작 방식이 const보다는 #define에 더 가깝다. 예를 들어, const의 주소를 잡아내는 것은 합당하지만, enum의 주소를 취하는 일은 불법이며 #define의 주소를 얻는 것 역시 맞지 않다. 혹시 우리가 선언한 정수 상수를 가지고 다른 사람의 주소를 얻는다든지 참조사를 쓴다든지 하는 것이 싫다면 enum이 아주 좋은 자물쇠가 될 수 있다. 또한 제대로 만들어진 컴파일러는 정수 타입의 const 객체에 대해 저장공간을 준비하지 않겠지만 조금 편찮게 만들어진 컴파일러는 반대로 동작할 수도 있어서, 소심한 프로그래머 측면에선 양쪽 모두에 대해 안전하게 const 객체에 대한 메모리를 만들지 않는 방법을 쓰고 싶을 것이다. enum은 #define처럼 어떤 형태의 쓸데없는 메모리 할당도 절대 저지르지 않는다.

둘째, 실용적인 이유로, 많은 코드에서 이 기법이 쓰이고 있다. 사실 이 나열자 둔갑술은 템플릿 메타프로그래밍의 핵심 기법이기도 하다.

다시 선행 처리자에 집중하면, 상당히 많은 경우에 발견할 수 있는 #define 지시자의 또 다른 오용 사례는 매크로 함수이다. 함수처럼 보이지만 함수 호출 오버헤드를 일으키지 않는 매크로를 구현하는 것이다. 아례 예를 보면, 매크로 인자들 중 큰 것을 사용해서 어떤 함수 f를 호출하는 매크로이다.

1
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

이런 식의 매크로는 단점이 한두 개가 아니다.

다른 프로그래밍 책에서 익히 들어왔겠지만, 이런 매크로를 작성할 때는 매크로 본문에 들어 있는 인자마다 반드시 괄호를 씌워 주는 센스를 잊지 말아야 한다. 이게 안 되어 있으면, 표현식을 매크로에 넘길 때 골치 아픈 일이 발생할 수 있다. 그런데 이 부분을 제대로 처리한다고 해서 끝난 것일까? 괴현상을 아래에서 볼 수 있다.

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

f가 호출되기 전에 a가 증가하는 횟수가 달라진다. 바로, 비교를 통해 처리한 결과가 어떤 것이냐에 따라 달라지니 까무러칠 노릇이다.

C++에서는 함수 호출을 없애 준다는 명목 하에 자행되는 이런 어처구니없는 작태를 참을 필요가 없다. 기존 매크로의 효율을 그대로 유지함은 물론 정규 함수의 모든 동작방식 및 타입 안정성까지 완벽히 취할 수 있는 방법이 있다. 바로, 인라인 함수에 대한 템플릿을 준비하는 것이다.

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

이 함수는 템플릿이기 때문에 동일 계열 함수군(family of functions)을 만들어낸다. 동일한 타입의 객체 두 개를 인자로 받고 둘 중 큰 것을 f에 넘겨서 호출하는 구조이다. 보시면 알겠지만 함수 본문에 괄호로 분칠을 해 댈 필요가 없고, 인자를 여러 번 평가할지도 모른다는 걱정도 없어진다. 그뿐 아니라 callWithMax는 진짜 함수이기 때문에 유효범위 및 접근 규칙을 그대로 따라간다. 임의의 클래스 안에서만 쓸 수 있는 인라인 함수에 대한 이야기가 나오더라도 이상하지 않다는 이야기다. 하지만 매크로의 경우엔 이런 말이 나오는 것 자체가 의미가 없다.

const, enum, inline의 친절한 손길이 우리 가까이에 있다는 사실을 늘 유념해 두면, 선행 처리자(특히 #define)를 꼭 써야 하는 경우가 많이 줄어들게 된다. 그렇다고 현실적으로 완전히 뿌리 뽑기는 힘들다. 예를 들어 #include는 부동의 필수 요소로 남아 있고, #ifdef/ifndef도 컴파일 조정 기능으로 현장에서 아주 잘 뛰고 있다. 선행 처리자는 은퇴시기가 아직 꽤 남았다고 보지만, 기회가 될 때마다 장기 휴가를 자주 보내줄 수 있다.

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


참고

This post is licensed under CC BY 4.0 by the author.