C++ 프로그래밍을 할 때 우리는 종종 선행 처리기(Pre-processor)를 사용합니다. 보통 프로그램 전체에 걸쳐 사용되는 상수가 필요할 경우, 아래의 형식으로 사용되죠.

#define PI 3.14159265359

위의 전처리문으로 프로그램의 코드가 컴파일러에게 넘어가기 전에 선행 처리기가 코드에 등장하는 모든 PI3.14159265359로 바꾸어 버립니다. 그 결과로 컴파일러가 사용하는 기호 테이블에는 PI가 포함되지 않습니다. 이건 프로그래머에게 상당히 곤란한 상황을 만들어 낼 수 있습니다. PI가 사용된 코드에서 컴파일 에러가 난 상황이 바로 그 상황입니다. 분명 코드엔 PI가 있었는데, 컴파일 에러에선 PI가 보이지 않고 3.14159265359만이 보일 뿐입니다. 행여 PI가 정의된 파일이 자신이 작성한 것이 아니라면 이 문제는 더욱 심각해집니다. 이 경우엔 대체 3.14159265359이 어디서 왔는지 찾아내느라 시간을 낭비해야만 합니다.

이런 문제는 매크로 대신 상수를 사용해서 해결할 수 있습니다.

const double pi = 3.14159265359;

pi는 C++에서 지원하는 상수 타입의 데이터이기 때문에 컴파일러에게도 보이고 기호 테이블에도 포함됩니다. pi처럼 부동소수점 실수형 데이터인 경우엔 컴파일러가 뱉어낸 코드의 길이도 매크로를 썼을 때보다 짧습니다. 매크로를 썼을 땐 선행 처리기가 PI3.14159265359로 모조리 바꾸면서 그 데이터의 사본이 사용한 만큼 생깁니다. 하지만 const를 썼을 땐 아무리 많이 사용해도 사본이 생기지 않기 때문이죠. 우리가 const를 쓸 수 있을 땐 반드시 사용해야 하는 이유 중 하나입니다.

우리가 #defineconst로 교체할 때는 딱 두 가지만 조심하면 됩니다. 첫째는 상수 포인터를 쓰는 경우입니다. 상수 정의는 보통 헤더 파일에 하고 이때 포인터는 꼭 const로 선언합니다. 포인터의 여러 사본이 같은 대상을 가리키도록 하기 위해서 입니다. 또한 그 포인터가 가리키는 대상도 const로 선언합니다. 코드가 아래와 같이 되겠죠.

const double* const pi = 3.14159265359;

두번째 경우는 상수가 클래스 안에 정의되는 경우입니다. 어떤 상수가 클래스 안에서만 유효하도록 하고 싶을 때 이렇게 하는데, 이럴 땐 상수를 정적 멤버로 만들어야 합니다. 나중에 클래스의 인스턴스가 여러번 생겨날 때 굳이 상수의 사본이 여러개가 될 필요가 없기 때문이죠. 아래를 봅시다.

class Team {
private:
    static const int numOfMembers = 4;
    std::string names[numOfMembers];
}

numOfMembersstatic으로 선언되어 있습니다. 정의(Definition)이 아니라 선언(Declaration)입니다. 이 둘은 분명 다릅니다. C++에선 보통 선언 이후 정의가 필요합니다. 하지만 정적 정수 타입의 상수는 예외입니다. 하지만 여러분이 정의를 필요로 하는 컴파일러를 사용하고 있다면 이야기가 달라집니다. 아래와 같이 구현 파일에서 정의를 해주어야 합니다.

const double Team::numOfMembers;

여기에 초기값 4를 적으면 안됩니다. 초기화는 이미 선언과 동시에 이루어 지고 있습니다.

C++에서 선언은 주로 헤더 파일에 하고 정의는 구현 파일에 합니다. 다른 파일에서 그 헤더 파일을 쉽게 가져다 쓰도록 하기 위함이죠. 근데 구현 파일에선 선언과 정의를 둘 다 할 수 있지만 헤더 파일에선 선언만 할 수 있습니다.

여기서 잠깐, 클래스 상수를 #define으로 하려는 건 아니겠죠? 사실 그 순간 클래스 상수라는 건 말이 안됩니다. 매크로는 유효범위라는 것이 따로 없습니다. 컴파일이 끝날 때까지 유효합니다. 따라서 클래스 상수를 선언할 때 #define를 사용할 순 없습니다. 그렇게 하면 캡슐화도 할 수가 없습니다. private 같은 걸 사용할 수 없단 얘기죠. 그치만 상수 멤버는 그게 가능합니다.

여기서 끝난 게 아닙니다. 간혹 위의 문법을 허용하지 않는 컴파일러가 있습니다. 선언 시점에 초기화를 진행하는 것 말이죠. 표준에 어긋나는 것이지만 옛날 컴파일러는 그럴 수 있습니다. 또 여건에 따라 그런 걸 사용해야하는 상황일 수도 있고요. 그럴 땐 어쩔 수 없이 정의할 때 초기값을 제공해야 합니다.

// team.hpp
class Team {
private:
    static const int numOfMembers;
    std::string names[numOfMembers];
}

// team.cpp
const int Team::numOfMembers = 4;

이렇게 하면 앞서 말한 문제는 해결할 수 있습니다. 근데, 다른 컴파일 에러가 우릴 기다리고 있습니다. names를 선언할 때 초기화되지 않은 numOfMembers를 쓰고 있기 때문이죠. 답이 없습니다. 어떻게 해야할까요? enum으로 장난을 조금 치면 됩니다.

class Team {
private:
    enum { numOfMembers = 5 };
    std::string names[numOfMembers];
}

간지나게 'enum hack'이라고 부르는 방법입니다. 간단하지만 C++의 특징을 살리고 우리의 목적도 달성할 수 있는 섹시한 방법입니다. const로 선언된 상수는 나중에 포인터를 얻을 수 있지만 enum은 그것도 안됩니다. 그래서 나중에 다른 사용자나 개발자가 포인터를 얻지 못하게 하거나 참조자를 쓰지 못하게 할 때에도 좋은 열쇠가 될 수 있습니다. 하나 더 남았습니다. enum은 쓸데없이 메모리 낭비도 하지 않습니다.

이제 마지막 #define의 오용 사례를 보겠습니다. 아래는 두개의 인자 중 큰 것을 골라 함수를 호출하는 매크로 함수입니다.

#define CALL_WITH_MAX(a, b) foo((a) > (b) ? (a) : (b))

문제없는 코드처럼 보이죠? 과연 그럴까요? 아래 코드를 봅시다.

int a = 5;
int b = 0;

CALL_WITH_MAX(++a, b); // a가 2 증가
CALL_WITH_MAX(++a, b + 10); // a가 1 증가

이제 문제가 있는 게 확실해졌습니다. 끔찍합니다. 코드 작성시에 모르고 이렇게 썼다가 런타임시에 뭔가 문제가 있다는 것을 알아챈다면 더 끔찍해집니다. 매크로 함수가 함수 호출을 막아준다는 장점은 분명 가지고 있습니다. 하지만 이런 경우엔 그게 다가 아니죠. 그렇다면 어떻게 하면 이런 상황을 섹시하게 해결할 수 있을까요? 바로 템플릿과 인라인 함수를 쓰면 됩니다.

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

매크로 함수의 효율성도 유지하고 정규 함수의 동작 방식과 타입 안정성까지 취할 수 있는 방법입니다. 매크로 함수를 썼을 때 나타내는 문제가 발생할 여지도 없습니다. 더군다나 callWithMax는 진짜 함수기 때문에 유효 범위와 접근 규칙도 그대로 따라갑니다. 클래스안에서만 쓸 수 있는 인라인 함수도 있을 수 있다는 거죠. 매크로 함수는 그런 개념 자체가 없습니다.

const, enum, inline을 적절히 활용하면 얻을 수 있는 장점들이 이렇게나 많습니다. 무턱대고 #define를 쓰기 전에 한번 생각해보세요.