Sonji-log

[Effective C++] Cp 2. #define 보다는 const, enum, inline 본문

책/Effective C++

[Effective C++] Cp 2. #define 보다는 const, enum, inline

Sonji 2025. 3. 13. 17:14

책 내용 정리

예를 들어, 아래와 같은 코드를 작성했다고 가정하자.

#define ASPECT_RATIO 1.653

우리는 당연하게 ASPECT_RATIO 가 symbolic name으로 파악할 수 있지만, 컴파일러 입장에서는 전처리기가 숫자 상수로 바꾸어버리기 때문에 컴파일러의 기호 테이블에 들어가지 않아 디버깅이 다소 난해해질 수 있다.

이를 매크로 대신 상수로 작성하고, 비교를 위해 아래처럼 코드를 작성했다고 가정하자.

#include <iostream>

using namespace std;

#define ASPECT_RATIO 1.653

int main(void)
{
	const double aspect_ratio = 1.653;

	cout << ASPECT_RATIO + 2 << endl;
	cout << aspect_ratio + 2 << endl;
	return 0;
}

실제로 이 코드에서 cout 전에 breakpoint를 찍고 기호 테이블을 보면 아래 그림처럼 소문자 상수만 등장한다.

물론 두 cout의 출력 결과는 같다.

 

게다가, 전처리기가 ASPECT_RATIO의 등장마다 족족 1.653으로 바꾸어 버린다면 object code에 1.653의 사본이 등장하는 횟수만큼 들어간다. 반면 상수로 선언해버리면 아무리 여러번 쓰더라도 단 한개만 생성한다.

 

단, 모든 전처리문을 상수로 교체할 경우, 2가지 부분을 주의해야 한다.

1. 상수 포인터(constant pointer)를 정의하는 경우

보통 상수 정의는 헤더 파일에 넣는 것이 일반적이므로, 포인터와 포인터가 가리키는 대상 모두에 const로 선언하는 것이 일반적이다(포인터가 가리키는 대상과 포인터 자체가 변경되지 않도록 방지해 안전성과 유지보수성을 높이기 위함).

 

책에서 든 예시를 가져와서 각 문의 의미를 보자면,

const char * const authorName = "Scott Meyers";

첫 번째 const( const char* 의 구성요소)는 가리키는 문자열의 내용이 상수임을 의미한다.

즉, 위 상수 포인터를 include한 다른 코드에서 authorName의 값을 변경할 수 없음을 말한다.

 

두 번째 const(const authorName의 구성요소)는 포인터 자체가 상수임을 의미한다.

즉, authorName에 다른 문자열의 주소를 재할당할 수 없음을 말한다.

 

2. 클래스 멤버 안에 상수를 정의하는 경우

이는 보통 사용하고자 하는 상수의 유효 범위를 클래스 내부로 한정하고자 할 때 사용한다.

이 경우 상수의 사본 개수가 2개 이상 생성되는 일을 방지하고 싶다면, static 멤버로 만들어야 한다.

(static 키워드를 사용하지 않으면, 클래스로 만든 각 객체마다 상수 사본을 하나씩 가지고 있게 된다)

 

아래 예시 코드를 보자.

#include <iostream>

using namespace std;

class Sample
{
public:
	const int value = 10;
};

class StaticSample
{
public:
	static const int value;
};

const int StaticSample::value = 20; //(1). 후위 부록 참조

int main(void)
{
	Sample smp1;
	Sample smp2;

	StaticSample ssmp1;
	StaticSample ssmp2;

	cout << "smp1.value 주소: " << &(smp1.value) << endl;
	cout << "smp2.value 주소: " << &(smp2.value) << endl;

	cout << "ssmp1.value 주소: " << &(StaticSample::value) << endl;
	cout << "ssmp2.value 주소: " << &(StaticSample::value) << endl;

	return 0;
}

 

Sample 클래스에서 사용하는 static 선언이 없는 상수와, StaticSample 클래스에서 사용하는 static 선언이 있는 상수의 개수를 보기 위해 만든 예제 코드다.

이 코드의 실행 결과는 예상할 수 있듯, Sample 객체의 상수는 각각의 사본을 가지고 있으므로 주소가 다른 반면, StaticSample 객체의 상수는 단 하나의 메모리 공간을 참조하고 있으므로 주소가 같다.

추가로, 전처리기에서 치환된 상수는 캡슐화에 관한 혜택을 전혀 받지 못하므로(전처리기는 유효범위라는 개념 자체가 없기 때문에 일단 정의되면 컴파일이 끝날 때까지 전역에서 유효함), 접근지정연산자를 부여할 수 있는 클래스 멤버 상수로 관리해주는 편이 효율적이다.


간혹 전처리기를 통해 매크로함수를 만들 때, 매크로 함수 안에서 파라미터가 여러 번 사용되는 경우 의도치 않은 동작이 발생할 수 있다. 대표적으로 연산자 우선순위가 중요한 요소가 될 경우 이런 부작용이 발생하기 쉽다.

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

int a = 5, b = 0;
CALL_WITH_MAX(++a, b);   // (A), a가 2번 증가
CALL_WITH_MAX(++a, b+10); // (B), a가 1번 증가

위와 같은 경우, (A)에서는 삼항연산자의 평가단계에서 1회, 평가 이후 참에 해당하여 분기로 빠지는 단계에서 2회, 해서 총 2회 증가된다. 반면 (B)는 평가 단계에서 1회, 분기에서 빠지지 않아 증가가 이루어지지 않기 때문에 총 1회 증가된다.

#define SQUARE(x) x * x

int result = SQUARE(3 + 2);  
// 예상: (3 + 2) * (3 + 2) = 25
// 실제: 3 + 2 * 3 + 2 = 3 + 6 + 2 = 11

위와 같은 경우, 괄호가 제대로 전달되지 않아서 우선순위 문제가 발생한다. 

 

따라서, 이런 부작용을 원천적으로 차단하기 위해 inline 함수와 템플릿을 이용해 기능 강점을 그대로 취할 수 있다.

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

int a = 5, b = 0;
callWithMax(++a, b);  // `++a`가 한 번만 실행됨

이런 식으로 수정할 경우, Template 덕분에 매크로함수처럼 자료형으로부터 자유롭고, 호출 오버헤드가 없으므로 이득을 그대로 얻을 수 있다.

반면 매크로 함수는 구문이 치환되어 컴파일되기 때문에 매개변수가 여러 번 평가될 우려가 있지만(위 예시와 같은 상황), inline 함수는 어찌되건 C++의 함수이므로 인수를 한 번만 평가한다는 대전제를 무조건 따른다. 따라서 매개변수에 대한 계산이 1번으로 보장된다.

 

결론

되도록이면 단순한 상수를 쓸 경우, #define보다는 const 객체 똔느 enum으로 사용하자.

함수처럼 쓰이는 매크로를 만들고자 한다면, #define 매크로 함수보다 inline 함수를 우선 생각하자.


부록) Static 멤버변수의 선언 및 초기화에 대한 고찰

※위 클래스 멤버안에 상수를 정의하는 예제 코드에서, (1)처럼 상수를 클래스 내부에서 선언할 때 초기화하지 않고, 외부에서 정의해주는 부분을 볼 수 있다. 왜 이렇게 만들었는지 다른 예시를 통해 전개하겠다(이하 내용은 책 내용이 빈약해 추가 조사 후 정리한 내용).

 

결론부터 얘기하자면, C++의 원칙(클래스 선언과 구현부는 분리해야 한다) 컴파일 과정의 원리 때문.

 

아래와 같이 C++의 원칙을 지킨 예시 코드 3개가 있다고 가정해보자.

// filename : Example.h

#ifndef EXAMPLE_H
#define EXAMPLE_H

class Example {
public:
    static int x;  // (1) 선언 (메모리 할당 X, 단순히 타입과 존재만 알림)
};

#endif
// filename : Example.cpp
#include "Example.h"

// (2) 클래스 외부에서 정의 (메모리 할당)
int Example::x = 10;
// filename : "main.cpp"
#include <iostream>
#include "Example.h"

using namespace std;

int main() {
    cout << Example::x << endl;  // (3) 실제 사용 (링커가 참조하는 과정)
    return 0;
}

(1) 선언

 클래스 선언부에서 static int x; 를 보면 '변수가 존재한다' 는 점만 인지하고, 메모리 공간을 할당해주지는 않는다.

→static 변수는 개별 객체가 아니라, 클래스 단위에서 단 하나만 존재해야 하므로, 선언만 이해하고 정의라고 받아들이진 않는다.

 

(2) 정의

 컴파일러가 이후 외부에서 정의되는 구문을 만나면, 이때 실제로 메모리 공간을 할당해준다.

컴파일러는 static 변수를  전역 변수처럼 처리(=메모리의 data 영역)하므로 전역 심볼을 만든다.

 

(3) 사용

 컴파일러는 변수 x가 Example 클래스의 static 멤버임을 알고 있으나, cpp 파일 단위로 독립 처리하기 때문에(이때, 컴파일러가 동작하는 단위를 번역 단위(TU : Translation Unit) 라고 한다) 실제로 변수가 정의된 위치(=메모리 위치)를 모른다. 

후에 Linker가 모든 번역 단위(object 파일로 출력된 번역 결과)를 결합할 때 Example.cpp의 Example::x가 전역 심볼 테이블에 등록되어 정의된 메모리 주소를 알 수 있게 된다.

 

단, 예외적으로 static const int같은 정수형 상수는 예외적으로 클래스 내부에서 초기화가 가능하다.

회피방법

기본적으로 이 방법은 C++의 원칙을 잘 지키고 있다는 전제가 깔려 있다. 하지만 코드가 분리되어 있어 가독성이 다소 떨어질 수 있고, 상수가 클래스 외부에서 정의되면 컴파일 타임 상수가 아니라 링크 타임 상수로 처리되기 때문에, 배열 크기와 같이 컴파일 타임 상수가 필요한 경우에서는 이 방법을 사용할 수가 없다.

 

당연히 벡터로 처리한다면 회피가 가능하겠지만 메모리 핸들링의 효율성 등을 고려해 필히 배열을 사용해야 한다면, Enum Hack 기법으로 해결할 수 있다(C++11 이후부터는 constexpr 키워드로 해결).

 

Enum Hack

C++컴파일러는 enum을 컴파일 타임 상수로 처리한다는 점을 이용해서 요소 1개를 컴파일 타임 상수로 간주하게 만들 수 있다. enum에 정의된 값들은 컴파일 타임에 바로 되므로 실행시 별도의 메모리를 할당하지 않고, 오히려 #define의 처리 루틴과 유사하다.

 

실제 예시를 통해 그 특성을 고찰해보자.

#include <iostream>

using namespace std;

class Example {
public:
    enum { VALUE = 10 };
};

int main() {
    std::cout << Example::VALUE << std::endl;  //  가능 : 값을 가져올 수 있음
    // std::cout << &Example::VALUE << std::endl;  //  오류 : 주소를 가져올 수 없음
    int sample[Example::VALUE];

    std::cout << sample[9];
}

값이 컴파일 타임에 확정되므로 배열의 크기로 사용하는 데에도 문제없이 동작한다(맨 아래 cout이 실제 쓰레기값으로 도출됨).

하지만 변수로 존재하지 않기 때문에 주소를 취득할 수 없고, 다른 사람이 주소를 얻거나 참조자를 사용할 수 없다. 


코멘트

 내용이 좀 길다.

처음 이 책을 읽었을 때는 그런가보다 하고 지나가는 경우가 많아서 이만큼의 깊이로 읽지는 않았다. 정리하는 과정에서 이유를 조금씩 찾다보니, 처음보다 깊이가 많이 깊어졌다. 실제로 코드를 직접 가져와서 비교해보니 내용을 풀어나가는 데 더 쉬웠던 것 같다. 

 

 언뜻 글만 봐서는 전처리기의 작동 방식이 많이 위험해서 사용하지 않는 편을 권하는 어투처럼 들린다. 하지만 실제로 전처리기를 필수로 사용할 수 밖에 없는 상황도 물론 존재한다(e.g., 라이브러리 추가). 앞으로 진행할 모든 코딩 룰에 있어서 이런 내용을 0순위로 우선 적용하기보다는 타 코드를 볼때 시각을 넓힌다는 정도로만 알고 넘어가자.

특히, 위 내용중 전역 포인터 상수는 후위에 내용이 더 있어 길게 말을 적지 않았다. 아직 넓어질 시각이 남았다.

 

원 내용 출처 : https://www.yes24.com/Product/Goods/17525589

 

Effective C++ 이펙티브 C++ - 예스24

Effective C++ 이펙티브 C++

www.yes24.com

(이 글은 본 책의 내용을 필자가 이해한 방향으로 작성한 글입니다.)

' > Effective C++' 카테고리의 다른 글

[Effective C++] Cp 1. C++는 언어들의 연합체  (0) 2025.03.13