본문 바로가기
프로그래밍/C & C++

[C/C++] #define (선행처리자)

by 별준 2021. 7. 22.

References

  • Effective C++ (항목 2)

Effective C++에서 언급하고 있는 #define에 대해서 알아보겠습니다.

 

우선 아래의 코드를 썻다고 가정해봅시다.

#define ASPECT_RATIO 1.653

우리가 코드를 볼 때에는 ASPECT_RATIO가 심볼릭 기호(Symbolic name)으로 보이지만 컴파일러에게는 전혀 보이지 않습니다. 이는 소스 코드가 컴파일러로 전달되기 전에 전처리기(preprocessor)가 ASPECT_RATIO를 전부 숫자 상수로 바꾸어 버리기 때문입니다. 그 결과, ASPECT_RATIO는 컴파일러의 심볼 테이블에 들어가지 않게 됩니다.

 

그래서 숫자 상수로 대체된 코드에서 ASPECT_RATIO와 관련되어 에러가 발생하게 된다면, 꽤 헷갈릴 수 있는 가능성이 존재할 수 있습니다. 소스 코드에는 ASPECT_RATIO가 있는데, 에러 메시지에는 1.653이 있을 수 있는 것이죠.

지금은 우리가 ASPECT_RATIO가 1.653으로 선언했기 때문에 이게 왜 헷갈리는지 의문이 들 수 있지만, 우리가 작성하지 않은 코드에서는 쉽게 발견하지 못할 수도 있습니다.

 

이 문제의 해결법은 매크로(#define) 대신 상수(const)를 사용하는 것입니다.

const double aspectRatio = 1.653;

aspectRatio는 C/C++ 언어 차원에서 지원하는 상수 타입의 데이터이기 때문에 컴파일러도 aspectRatio를 인식하고, 이 상수는 심볼 테이블에도 들어가게 됩니다. 

또한, 위 예시처럼 상수가 부동소수점 실수 타입인 경우에는 컴파일을 거친 최종 코드가 #define을 사용했을 때보다 작을 수도 있습니다.

 

매크로를 사용하게 되면 코드에 ASPECT_RATIO가 등장하면 전처리기에 의해서 모두 1.653으로 바뀌면서 등장 횟수만큼 1.653의 사본이 들어가게 되지만, 상수 타입의 aspectRatio는 아무리 많이 사용되더라도 사본은 하나만 생성되기 때문입니다.

 

#define을 상수로 교체할 때에 주의해야할 점이 두 가지가 있습니다. 

첫 번째는 상수 포인터를 정의하는 경우인데, 상수 정의는 일반적으로 헤더 파일에 넣고, 소스 파일에서 이 헤더파일은 include하여 사용하게 됩니다. 

상수 포인터에서 포인터는 꼭 const로 선언해주어야 하고, 이와 더불어서 포인터가 가리키는 대상까지 const로 선언하는 것이 보통입니다. 예를 들어, 어떤 헤더 파일 안에 char* 기반의 문자열 상수를 정의한다면 다음과 같이 const를 두 번 써야됩니다.

const char* const authorName = "Scott Meyers";

참고로 문자열 상수를 쓸 때는 위와 같이 char* 타입보다는 string 객체를 사용하는 것이 대체적으로 좋습니다.

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

 

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

class GamePlayer {
private:
	static const int NumTurns = 5;	//상수 선언
    int score[Numturns];		//상수를 사용
    ...
};

위 경우에 NumTurns는 '정의'가 아닌 '선언(declaration)'된 것입니다.

보통 선언과 정의를 동시에 하기 때문에 익숙하지 않을 수도 있지만, 선언과 정의는 엄연히 다릅니다.
함수의 원형을 헤더파일에 미리 선언해주는 것을 생각하면 익숙하실 것 같은데, 이는 미리 컴파일러에게 이러한 함수가 있다는 것을 헤더파일에서 알려주고, 구현파일에서 선언한 함수를 정의해주는 것을 생각하시면 될 것 같습니다.

C++에서는 사용하고자 하는 것에 대해 '정의'가 되어있어야 하는 것이 보통입니다만, static 멤버로 선언되는 클래스 내부의 상수(정수류; 각종 정수 타입, char, bool 등)는 예외입니다.

(int가 아닌 double을 넣어보시면, error가 발생하는 것을 볼 수 있습니다 : a member of type "const double" cannot have an in-class initializer)

 

위의 코드에서 const를 제거하면, 이해가 빠를 것 같습니다.

class GamePlayer {
private:
	static int NumTurns = 5; //error 발생
};

위 클래스는 단순히 선언만 되어있고, 정의가 되어있지 않습니다. 따라서 메모리가 할당되지 않았고, 5로 초기화를 할 수 없기 때문에 에러가 발생하게 됩니다.

만약 여기서 const를 추가하여 상수로 만들게 되면, 선언하는 즉시 메모리가 할당되어 초기값이 선언된 시점에서 주어져야합니다.

 

다시 클래스 내부 static 상수로 돌아와서, 구식 컴파일러의 경우에는 상수 선언과 동시에 정의가 되는 문법을 받아들이지 않는 경우가 있습니다. 이는 static 클래스 멤버가 선언된 시점에 초기값을주는 것이 맞지 않다고 판단(const를 제거한 코드처럼)하기 때문입니다. 이러한 문법이 허락되지 않는 컴파일러의 경우에는 초기값을 상수 '정의'시점에 주면됩니다.

(보통 선언은 헤더파일, 정의는 구현파일에서 합니다.)

// header file
class CostEstimate {
private:
	static const double FudgeFactor;
    ...
}

// cpp file
const double CostEstimate::FudgeFactor = 1.35;

 

웬만한 경우는 위의 내용만으로도 충분하지만, 한 가지 예외가 있다면 해당 클래스를 컴파일하는 도중에 클래스 상수의 값이 필요한 경우입니다. 예를 들면, 처음 예시에서 GamePlayer::scores 배열 멤버를 선언할 때, 클래스 상수를 사용한 경우입니다. 

구형 컴파일러를 사용할 수도 있으므로, 위의 방법 말고 다른 방법을 추천한다면 'enum hack'이라고 불리는 기법을 사용할 수 있습니다. 이 기법은 enum 타입의 값은 int가 사용되는 곳에 쓸 수 있다는 C++의 룰을 활용하는 것입니다. 따라서, GamePlayer 클래스는 다음과 같이 정의할 수 있습니다.

class GamePlayer {
private:
	enum { NumTurns = 5 };
	int scores[NumTurns];
    ...
};

이 기법은 동작 방식이 const보다 #define에 더 가깝습니다. 예를 들어, const의 주소를 읽는 것은 가능하지만, enum의 주소를 읽는 것은 불가능하며, #define의 주소를 읽는 것 또한 말이 되지 않죠. 혹시, 다른 사람들이 정수 상수의 주소를 얻거나 참조자를 사용한다는 것이 싫다면 enum이 좋은 제약이 될 수 있습니다. 

또한, 최신 컴파일러는 정수 타입의 const 객체에 대해 저장공간을 할당하지 않지만(그 객체의 포인터나 참조자를 만들지 않는 한), 구식의 경우에는 할당할 수도 있습니다. 따라서, 양쪽 컴파일러를 모두 고려하여 안전하게 const 객체에 대한 메모리를 만드는 경우를 피하기 위해서 enum을 사용할 수 있습니다.

 

그리고, 'enum hack'은 템플릿 메타프로그래밍(TMP)의 핵심 기법이기도 합니다. (기회가 될 때 TMP에 대해서 한 번 알아봐야 겠네요)

 

 

다시 #define으로 돌아와서 설명을 드리자면, #define으로 매크로 함수가 많이 사용됩니다. 함수처럼 보이지만 함수 호출 오버헤드를 일이키지 않도록 매크로로 구현하는 것이죠.

// 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가 한 번 증가

위 예시에서 보다시피 f 함수가 호출되기 전에 a가 증가하는 횟수가 달라지게 됩니다. (비교를 통해 처리한 결과가 어떤 것이냐에 따라 달라지기 때문)

 

C++에는 매크로 함수 대신, 기존 매크로의 효율은 그대로 유지하면서 정규 함수의 동작방식과 타입 안정성까지 완벽하게 유지할 수 있는 방법이 있습니다.

바로, 인라인 함수에 대한 템플릿을 사용하는 것입니다.

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

위 함수는 템플릿이기 때문에 동일한 타입의 객체 두 개를 인자로 받고 둘 중의 큰 것을 f에 넘겨서 호출하는 구조입니다. 보다시피 함수 본문에 괄호를 사용할 필요도 없고, 인자를 여러 번 평가할지도 모른다는 걱정도 없어지게 됩니다. 또한, callWithMax는 실제 함수이기 때문에 유효범위 및 접근 규칙을 그대로 따라가게 됩니다.

 

const, enum, inline을 생각하면 생각보다 #define과 같은 선행처리자를 꼭 써야하는 경우가 많이 줄어듭니다. 하지만 완전히 뿌리뽑기는 힘듭니다. #include는 필수 요소이며, 컴파일 조정을 위해서 #ifdef/#ifndef도 많이 사용하기 때문이죠.

 

그래도 단순 상수를 쓸 때는, #define보다는 const 혹은 enum을 우선 생각하고, 함수처럼 쓰이는 매크로는 inline 함수를 우선 생각하는 습관은 실수할 가능성을 줄이는데 많은 도움이 되는 것 같습니다 !

댓글