[C] 컴파일 전처리기
🔊 해당 포스팅은 인프런의 널널한 개발자님의 독하게 시작하는 C 프로그래밍 강의를 듣고 개인적인 복습 목적 하에 작성된 글입니다. 해당 포스팅에 사용된 모든 자료는 필자가 직접 재구성하였음을 알립니다.
이번 포스팅에서는 # 이라는 문자를 사용해서 정의하는 전처리기에 대해서 알아보자. 전처리기에 대한 처음 소개는 이전에 한 적이 있었다. 그 때 당시에는 간단하게만 짚고 넘어갔는데 이번엔 좀 더 의미를 자세히 알아보자.
컴파일 전처리기는 이름 자체에서도 알 수 있듯이, 컴파일 타임이 시작하기 이전(before)에 어떠한 처리를 해주는 것을 의미한다. 대표적인 전처리기로는 stdio.h 와 같은 헤더 파일을 포함하도록 하는 것, 조건부 컴파일, 심볼릭 상수를 정의, 매크로 정의 이렇게 총 4가지가 있다.
1. #include의 첫번째 기능 : 헤더파일을 포함하기!
C언어 첫 포스팅에서 Hello World를 출력하는 소스코드를 작성해보았었다. 그 때도 우리는 #include 라는 전처리기를 이용해서 헤더파일을 포함시켰다.
stdio.h 와 같은 헤더파일은 보통 함수 또는 변수를 선언만해서 모아놓은 소스코드이다. 물론 헤더파일 안에서 정의까지 할 수도 있긴 하지만 보통은 선언까지만 수행한다. 이렇게 정의된 헤더 파일은 .c 확장자의 소스코드 파일과 합성되어 컴파일이 되게 된다.
#include 전처리기 뒤에 헤더 파일을 명시할 때, <> 과 ""(쌍따옴표) 2가지로 정의할 수 있다. <>로 정의하는 것은 컴파일러의 시스템 설정에서 헤더파일을 검색할 때 사용한다. 반면에 ""로 정의하는 것은 내가 별도로 만든 헤더파일과 같이 현재 경로에서 헤더파일을 검색할 때 사용한다.
2. #define의 두가지 기능 : 심볼릭 상수와 매크로 정의
#include의 두번째 기능을 알아보기 전에 #define 이라는 전처기부터 이해해야 하므로 먼저 살펴보자. #define은 크게 2가지 기능을 하는데, 첫 번째는 변수를 심볼릭 상수화시키는 기능이다. 이 기능은 이전 포스팅에서 살펴보았으므로 자세한 설명은 생략하겠다. 두 번째는 매크로라는 것을 정의하는 기능이다.
매크로를 사용하는 방법을 알아보기 전에 매크로가 등장한 이유부터 살펴보자. 매크로는 사실 함수인 것 같이 생겼지만 함수는 아닌데, 매크로가 등장한 이유를 "함수 호출"과 연관을 지어서 생각해볼 수 있다. 예를 들어, 단순히 두 수의 합을 구하는 로직을 구현하기 위해 Add 라는 이름의 함수를 정의했다고 해보자. 함수로 구현한 것까지는 좋은데, 어쨌건 Caller 함수 쪽에서 이 Add 함수를 호출함에 따라 발생하는 부가적인 비용(오버헤드)이 발생하게 된다. 부가적인 비용이라고 한다면 함수를 호출함에 따라 쓰레드의 스택 메모리에 어떤 값들이 저장되고 삭제되는 등의 비용을 의미한다. 그런데 이렇게 발생되는 부가적인 비용에 비해 해당 함수가 구현하는 로직(두 수의 합을 구하는)이 상대적으로 매우 간단하다.
그래서 함수를 호출함에 따라 발생하는 오버헤드를 피하기 위해 로직을 동일하게 구현하되 함수로 작성하는 것이 아닌 매크로로 작성할 수 있다. 그래서 매크로를 함수처럼 생겼지만, 함수는 아니라고 하는 것이다. 매크로는 컴파일 타임에 소스코드로 치환되게 된다.
그런데 아쉽게도 요즘은 매크로를 잘 사용하지는 않는다. 앞서 말한 것처럼 매크로를 쓰는 이유가 함수로 구현할 때 발생하는 오버헤드를 줄이기 위함인데 요즘에 사용되는 컴파일러가 최적화를 매우 잘하도록 발전되었기 때문에 매크로의 사용빈도가 최근에는 현저히 줄었다고 한다. 그래서 여기서는 매크로를 사용하는 소스코드를 우리가 읽고 이해할 수 있을 정도로만 이해해보도록 하자.
그러면 매크로를 본격적으로 사용해보자. 매크로는 이름을 정의할 때 주로 대문자로만 이름을 지어준다.
#include <stdio.h>
#define ADD(a, b) (a + b)
int Add(int a, int b) {
return a + b;
}
int main(void) {
printf("%d\n", Add(3, 4));
printf("%d\n", ADD(3, 4));
return 0;
}
그런데 매크로를 정의하는 부분을 보자. 매크로 이름을 정의하고 마치 함수처럼 소괄호를 이용해 argument를 넣어준다. 매크로에서는 함수와 달리 각 argument가 어떤 자료형인지는 명시해주지 않는다. 그리고 그 뒤에 어떤 로직으로 구현되고 return 할지 로직을 작성해주는데, 이 때도 소괄호를 묶어 (a + b) 식으로 작성해주었다. 물론 여기에서는 소괄호가 없어도 상관은 없다. 즉, 아래 예시 소스코드는 정상 동작한다.
#include <stdio.h>
#define ADD(a, b) a + b
int Add(int a, int b) {
return a + b;
}
int main(void) {
printf("%d\n", Add(3, 4));
printf("%d\n", ADD(3, 4));
return 0;
}
그러면 굳이 소괄호를 넣어주는 이유는 뭘까? 대표적으로 오류가 발생하는 케이스는 연산자의 우선순위가 있다. 예를 들어, 위 소스코드에서 함수와 매크로를 호출하는 곳에 곱하기 곱하기 10을 해주는 연산을 추가해보자.
#include <stdio.h>
#define ADD(a, b) a + b
int Add(int a, int b) {
return a + b;
}
int main(void) {
printf("%d\n", Add(3, 4) * 10); // 출력: 70
printf("%d\n", ADD(3, 4) * 10); // 출력: 43
return 0;
}
함수는 의도한 대로 결과가 70이 나왔지만 매크로의 결과는 43이 나왔다. 대체 왜 43이 나왔을까? 매크로의 로직 구현 부분에서 소괄호로 묶어주지 않았기 때문이다. 그래서 ADD 매크로를 호출함에 따라 3 + 4 * 10 이라는 소스코드로 치환되고, 곱셈 연산이 덧셈 연산보다 우선순위가 높아서 43이 되는 것이다.
그러면 매크로의 로직 구현에서 소괄호로 묶어주면 어떻게 될까? 당연히 결과가 함수를 호출할 때와 똑같이 70이 나온다. 이유는 소괄호로 묶어주었을 때 매크로를 호출하게 되면 (3 + 4) * 10 이라는 소스코드로 치환되고, 덧셈 연산이 소괄호 안에 있기 때문에 곱셈 연산보다 우선순위를 갖게 되어 먼저 수행되기 때문이다.
매크로에서 추가로 알아볼 부분은 특수화 연산자이다. 두 종류가 있는데, 먼저 예시 소스코드부터 살펴보자.
#include <stdio.h>
#define MAKESTRING(a) #a
#define PASTER(a, b) a##b
int main(void) {
int nData = 10;
printf("%s\n", MAKESTRING(nData));
printf("%d\n", PASTER(nD, ata));
return 0;
}
먼저 MAKESTRING 이라는 이름의 매크로를 보자. 인자 a 하나를 받아 #a 로 리턴하라고 했다. #a 가 의미하는 바는 인자로 넣어준 a 라는 소스코드를 문자열로 만들어주라는 것이다. 매크로는 아까 위에서 컴파일 타임에 소스코드로 치환해준다고 했다. 그래서 해당 매크로의 출력을 보면 nData 라는 변수에 들어가 있는 값이 10이 아닌 "nData" 라는 문자열이 출력되는 것을 볼 수 있다.
다음은 PASTER 라는 이름의 매크로를 보자. 인자 a, b 두개를 받아서 a##b 로 리턴하라고 했다. a##b 가 의미하는 바는 소스코드 a 뒤에 소스코드 b를 마치 문자열 연결처럼 붙이라는 뜻이다. 그런데 MAKESTRING 매크로에서 #a 때와는 달리 a##b 는 소스코드를 해석하게 된다. 그래서 출력은 nData 라는 변수에 들어가 있는 값인 10이 출력된다.
3. #include의 두번째 기능 : 조건부 컴파일
#include 전처리기의 두 번째 기능으로 조건부 컴파일이다.(참고로 분할 컴파일과는 다른 개념이다. 헷갈리지 말자)
조건부 컴파일에는 2번 목차에서 배운 #define 전처기로 정의할 수 있는 심볼릭 상수가 관여한다. 즉, 심볼릭 상수 값이 정의되었는지 여부에 따라 실제 컴파일 되는 코드가 달라지도록 하는 것이다. 예시 소스코드를 살펴보자.
#include <stdio.h>
#define FLAG
#ifdef FLAG
#define MSG "FlAG exists"
#else
#define MSG "FLAG doesn't exist"
#endif
int main(void) {
puts(MSG);
return 0;
}
조건부 컴파일에 사용되는 키워드는 #ifdef, #else #endif 를 모두 사용해야 한다. 이름에서 유추할 수 있듯이 어떤 조건에 부합하면 #ifdef 구문에 있는 것이 실행되는 것이고, 부합하지 않으면 #else 구문에 있는 것이 실행된다. 일반적인 if 분기문과 유사하다.
그런데 #ifdef 구문에 있는 것을 실행할지, #else 구문에 있는 것을 실행할지 조건을 판단하는 기준은 그 위에 정의한 FLAG 라는 심볼릭 상수로 판단한다. 만약 FLAG 라는 심볼릭 상수가 정의되어 있다면 #ifdef 구문을, 정의되어 있지 않으면 #else 구문을 실행하게 된다.
참고로 조건부 컴파일은 이전에 배웠던 Debug/Release 모드 빌드 중 하나를 선택하거나, 문자열을 MBCS(Multi-Byte Character Set)/Unicode 방식 중 하나를 선택할 때 주로 사용한다.(MBCS 방식은 기존에 우리가 계속 사용해왔던 방식임)