여러분이 컴파일해 본 모든 C 파일은 #include <stdio.h> 같은 줄로 시작했고, 그 줄은 C가 아닙니다. 별도의 프로그램인 전처리기에 대한 지시로, 먼저 실행되어 소스 텍스트를 다시 쓴 뒤 그 결과를 컴파일러에 건넵니다.
이 두 단계 분리를 이해하면 C의 많은 동작이 설명됩니다. 헤더가 왜 임포트되지 않고 붙여 넣어지는지, 잘못된 매크로가 왜 완벽해 보이는 줄에 오류를 내는지, 그리고 같은 소스 파일이 왜 시스템마다 다른 프로그램으로 컴파일될 수 있는지 말입니다.
컴파일 전에 실제로 일어나는 일
.c 파일을 컴파일하는 것은 한 단계가 아닙니다. 대략 넷입니다.
- 전처리 -
#으로 시작하는 모든 줄을 따라, 하나의 크게 확장된 소스 텍스트(번역 단위)를 만듭니다. - 컴파일 - 그 텍스트를 어셈블리로, 그다음 목적 파일로 바꿉니다.
- 어셈블 - 기계어를 만듭니다.
- 링크 - 목적 파일들과 라이브러리를 합쳐 실행 파일을 만듭니다.
전처리기는 순전히 텍스트 위에서 일합니다. 변수가 무엇인지, 타입이 무엇인지, 중괄호가 맞는지 모릅니다. 문자와 토큰을 보고, 그중 일부를 다른 것으로 바꾸고, 지나갑니다. 매크로에 관한 모든 이상한 일이 이 한 가지 사실에서 따라 나옵니다.
컴파일러가 이 프로그램을 읽을 때쯤이면 GREETING은 어디에도 없습니다. 전처리기가 이미 그것을 여러분이 직접 타이핑한 것과 똑같이 리터럴 "매크로에서 온 인사"로 바꿔 놓았습니다.
지시문 가족
모든 전처리기 지시문은 그 줄의 첫 번째 공백 아닌 문자로 #을 갖습니다. 세미콜론은 없고, 역슬래시로 이어 붙이지 않는 한 줄 끝에서 지시문이 끝납니다.
| 지시문 | 하는 일 |
|---|---|
#include | 다른 파일의 내용을 붙여 넣는다 |
#define | 매크로(텍스트 치환)를 정의한다 |
#undef | 매크로 정의를 없앤다 |
#ifdef, #ifndef | 매크로가 정의되어 있(지 않)을 때만 아래 코드를 남긴다 |
#if, #elif, #else, #endif | 상수 식에 따라 코드를 남긴다 |
#error | 메시지와 함께 컴파일을 멈춘다 |
#pragma | #pragma once 같은 컴파일러별 지시 |
#line | 보고되는 줄 번호를 바꾼다 (드묾) |
이 중 셋은 자기 페이지를 따로 갖습니다. 매크로가 #define을 깊이 다루고, 조건부 컴파일이 #if 가족을 다루며, 헤더 파일이 여러 파일로 된 프로그램을 짜는 데 #include가 어떻게 쓰이는지 다룹니다.
#include: 꺾쇠괄호와 따옴표
#include는 딱 하나만 합니다. 자기 줄을 지정된 파일의 내용 전체로 바꿉니다. 그 포함된 파일도 그 자체로 전처리되므로, 그 안의 #include 줄도 확장됩니다.
#include <stdio.h> /* 시스템 인클루드 디렉터리를 뒤진다 */
#include "config.h" /* 이 파일의 디렉터리를 먼저 뒤진다 */
차이는 탐색 순서입니다.
<꺾쇠괄호>는 컴파일러의 표준 인클루드 디렉터리 -/usr/include, 툴체인 자체 폴더, 그리고-I로 추가한 것들 - 를 봅니다. 라이브러리 헤더용입니다."따옴표"는 인클루드를 하는 파일이 있는 디렉터리를 먼저 보고, 그다음 꺾쇠괄호와 같은 목록으로 넘어갑니다. 직접 쓴 헤더용입니다.
대부분의 컴파일러에서는 두 형태 모두 어느 종류의 헤더에도 통하지만, 관례에 의미가 있습니다. 꺾쇠괄호는 "이건 남의 헤더다", 따옴표는 "이건 내 것이다"라고 말합니다. 이 둘을 섞는 것이 프로젝트가 자기 헤더 대신 낡은 시스템 헤더를 포함하게 되는 경로입니다.
포함이 텍스트 붙여 넣기이므로 같은 헤더를 두 번 포함하면 두 번 붙여 넣어집니다. 그래서 헤더에 인클루드 가드가 필요합니다. 헤더 파일 페이지가 가장 먼저 고치는 것이 그것입니다.
#define: 텍스트 치환, 그 이상은 없음
#define NAME replacement는 전처리기에게 이렇게 말합니다. 여기서부터 토큰 NAME이 나타나는 자리마다 대신 replacement를 넣어라.
여기서 일어나지 않는 일을 보세요. MAX_USERS에는 타입이 없습니다. 어디에도 저장되지 않습니다. 디버거에서 들여다볼 수 없습니다. 찾아 바꾸기 규칙이고, 전처리 후 프로그램에는 글자 그대로 printf("%s는 사용자 %d명을 허용합니다\n", "Coddy", 100);이 들어 있습니다.
그것은 치환이 맹목적이라는 뜻이기도 합니다. 다음은 컴파일되고, 놀라운 일을 합니다.
#define SIZE 5 + 1
int arr[SIZE]; /* 괜찮다: int arr[5 + 1]; */
int total = SIZE * 2; /* 5 + 1 * 2 == 7, 12가 아니다 */
해법 - 모든 것을 괄호로 감싸기 - 은 인자를 받는 매크로와 함께 매크로 페이지의 핵심 규칙입니다.
치환 텍스트가 없는 #define은 그 이름을 "존재하지만 비어 있음"으로 정의합니다. 치환으로는 쓸모없지만 플래그로는 필수입니다.
#define DEBUG /* 정의됨, 아무것도 아닌 것으로 확장 */
그러면 코드가 #ifdef로 DEBUG가 존재하는지 물을 수 있습니다.
전처리기의 출력 보기
직관을 기르는 가장 좋은 방법은 전처리기가 실제로 만들어 낸 것을 보는 것입니다. gcc -E는 전처리 후 멈추고 결과를 출력합니다.
gcc -E hello.c
<stdio.h>를 포함한 파일이라면 시스템에 따라 700줄에서 3만 줄이고, 거의 전부가 그 헤더 자체의 내용입니다. 내 부분만 보려면 끝부분을 취하세요.
gcc -E hello.c | tail -20
이런 파일에 해 보세요.
#define SQUARE(x) ((x) * (x))
#define LIMIT 10
int main(void) {
int n = SQUARE(LIMIT);
return n;
}
출력의 끝부분은 이렇게 보입니다.
int main(void) {
int n = ((10) * (10));
return n;
}
매크로는 전부 사라지고 치환된 텍스트만 남습니다. 매크로가 말을 안 들을 때 이 명령이 몇 초 만에 이유를 알려 주고, 추측하는 것보다 낫습니다. 쓸모 있는 동반자 둘. gcc -dM -E - < /dev/null은 컴파일러가 미리 정의하는 모든 매크로를 나열하고, gcc -E -P file.c는 줄 표시 잡음을 생략합니다.
오류가 엉뚱한 줄을 가리키는 이유
컴파일러가 확장된 텍스트를 보므로, 매크로 안의 실수는 그것이 쓰인 자리에서 보고되지 쓰인 곳에서 보고되지 않습니다.
#define HALF(x) (x / 2
int main(void) {
int y = HALF(8); /* 오류가 여기서 보고된다 */
return 0;
}
빠진 괄호는 #define 줄에 있는데, 컴파일러는 HALF(8)이 있는 줄에 대해 불평하고, 종종 문맥상 말이 안 되는 예상치 못한 토큰 메시지를 냅니다. 오류가 불가능해 보일 때는 gcc -E로 파일을 확장해 진짜 줄을 읽으세요.
현대 컴파일러는 도와줍니다. GCC와 clang은 정의 쪽을 가리키는 "매크로 확장 중" 메모를 출력합니다. 그 메모를 실제로 보려면 -Wall -Wextra로 컴파일하세요.
미리 정의된 매크로
전처리기가 스스로 제공하는 매크로도 있습니다. 진단에 정말 쓸모 있습니다.
__FILE__과 __LINE__은 현재 파일 이름과 줄 번호로 확장되며, assert와 로깅 매크로가 무엇이 어디서 잘못됐는지 알리는 방법이 그것입니다. (__func__는 조금 다릅니다. 전처리기 매크로가 아니라 컴파일러가 제공하는 진짜 식별자이지만, 쓰는 방식은 같습니다.)
컴파일러는 __linux__, _WIN32, __APPLE__ 같은 플랫폼 매크로도 미리 정의합니다. 시스템마다 달라져야 하는 코드는 #ifdef로 그것들을 검사하고, 그것이 조건부 컴파일의 주제입니다.
가져갈 것
전처리기는 작고 멍청하며, 둘 다 의도된 것입니다. 힘 셋 - 파일 끌어오기, 텍스트 치환하기, 코드를 켜고 끄기 - 을 주고, 그에 딸린 타입 검사는 전혀 주지 않습니다.
그 거래 때문에, 언어 기능을 먼저 쓰라는 것이 표준적인 조언입니다. 가능하면 상수에 #define 대신 const int나 enum을 쓰고, 함수 같은 매크로 대신 진짜 함수를 쓰세요. 전처리기가 정말로 올바른 도구인 곳 - 헤더, 이식성 스위치, 컴파일 타임 설정 - 에서는 대체 불가능합니다.
다음으로 매크로가 #define을 진지하게 다룹니다. 인자, 괄호 규칙, 그리고 여러분이 쓰지 않은 코드에 텍스트를 밀어 넣는 데 따라오는 함정들입니다.
자주 묻는 질문
C에서 전처리기란 무엇인가요?
컴파일 전에 실행되는 텍스트 처리 단계입니다. #으로 시작하는 줄을 따르며, #include 자리에 헤더 파일의 내용을 붙여 넣고, #define으로 정의된 매크로를 치환하고, #if/#ifdef에 따라 코드를 지우거나 남깁니다. 컴파일러는 그 결과만 볼 뿐 원본 파일을 결코 보지 않습니다.
#include <stdio.h>와 #include "myfile.h"의 차이는 무엇인가요?
꺾쇠괄호는 컴파일러의 시스템 인클루드 디렉터리를 뒤지며, 표준 라이브러리 헤더가 거기 있습니다. 따옴표는 현재 파일의 디렉터리를 먼저 뒤진 뒤 시스템 경로로 넘어갑니다. 라이브러리 헤더에는 꺾쇠괄호를, 직접 쓴 헤더에는 따옴표를 쓰세요.
전처리기가 만들어 낸 결과를 어떻게 볼 수 있나요?
gcc -E file.c를 실행하면 전처리 후 멈추고 확장된 소스를 출력합니다. <stdio.h>를 포함한 파일이라면 출력이 수천 줄이니 파이프로 넘기세요. gcc -E file.c | tail -30이면 모든 매크로가 이미 치환된 여러분 코드만 보입니다.
#include는 C 문장인가요?
아닙니다. 지시문은 C 언어 본체의 일부가 아닙니다. 줄 단위의 자기 문법이 있고, 세미콜론을 받지 않으며, 컴파일러가 프로그램을 파싱할 때쯤이면 사라져 있습니다. 그래서 매크로의 실수가 멀쩡해 보이는 줄에서 헷갈리는 오류로 나타납니다.