[C] - The Preprocessor

Chris Kim·2024년 9월 29일

프로그래밍언어

목록 보기
13/25

0. 들어가며

여태까지 #include#define에 대한 자세한 설명이 없었다. 이들은 전처리기로 다뤄지는 지시자들 중 일부다. 전처리기에 대한 의존은 C언어로 하여금 다른언어와의 차별점을 가지게 만들었다.

1. 전처리기의 작동방식

전처리기의 작동은 전처리 지시자(preprocessing directives) 에 의해 통제된다.
#define 지시자는 매크로를 정의한다. 우리는 상수 혹은 자주 쓰이는 표현식을 매크로로 정의했다. 전처리기는 #define 지시자에 매크로의 이름과 그 정의를 저장하는 방식으로 반응한다. 매크로가 나중에 사용되는 경우, 전처리기는 정의된 값을 대체하면서 매크로를 "확장"한다.
#include는 프로세서로 하여금 특정 파일을 열고 파일의 콘텐츠를 컴파일 대상이 포함하도록 만든다.
사실 우리가 짠 코드는 바로 컴파일 되는 것이 아니다. 프로그램은 전처리기에 투입되고 전처리기는 프로그램의 지시자들에 따른 명령을 수행한다. 그리고 지시자들을 프로세스에서 삭제한다. 그 후 프로그램은 다른 프로그램이 되는데, 이것이 지시자를 포함하지 않는 오리지널 프로그램이며, 컴파일러에 투입되어 에러유무 체크와, 기계어로 번역을 거친다. 단 전처리기에서 지시자를 삭제하는 경우 그 줄을 빈칸으로 두지 줄 자체를 삭제하지는 않는다.
예전에는 C의 전처리기가 별도의 프로그램이었으나 요즘은 컴파일러에 포함되어있다. 그래서 컴파일러에 있는 전처리기는 굳이 C언어 코드로 결과물을 전달할 필요가 없어졌다. 그러나 전처리기를 별개로 생각하는 것이 좋은게 GCC 컴파일러에서와 같이 전처리 후의 결과물을 볼 수 있기 때문이다.
다만 전처리기는 C언어에 대해 제한적인 정보만을 가지고 있으므로, 지시자를 실행하는 유효하지 않은 프로그램을 만들 수도 있다.

2. 전처리 지시자

대부분의 전처리 지시자들은 세 개의 분류로 나뉜다. (1) 매크로 정의, (2) 파일 포함, (3)조건적 컴파일 이다. 이 분류에 들어가지 않은 지시자들(#error, #line, #pragma 등)은 매우 특별하며 잘 쓰이지 않는다.
지시자들을 자세히 다루기 이전에 모든 지시사들에게 적용되는 규칙을 살펴보자

  1. 지시자들은 반드시 '#'로 시작해야하고, 그 뒤에 지시자 이름이. 그리고 지시자가 요구하는 정보가 입력되어야 한다.
  2. 띄어쓰기, 탭을 통한 공백은 지시자 속 토큰을 분리할 수 있다.
  3. 지시자는 반드시 한 줄에 작성되어야 한다. 만약 그럴 수 없다면 \을 명시적으로 사용해야한다.
  4. 지시자는 프로그램 내부 어디에서든 나타날 수 있다.
  5. 지시자 끝에 주석을 달아 줄 수도 있다.

3. 매크로 정의

단순 매크로

단순 매크로(C표준: 객체유사 매크로)는 #define identifier replacement-list의 형식을 가지고 있다. 여기서 replacement-list에는 어떤 전처리 토큰이 들어와도 상관 없다.
replacement list는 식별자, 키워드, 상수, 문자 상수, 문자열 리터럴, 연산자, 그리고 ,가 들어갈 수 있다. 이들이 만약 매크로 정의를 마주치는 경우 전처리기는 identifier가 이들을 나타내도록 한다.

[주의] 절대로 매크로 정의 뒤에 매크로 정의 대상 외의 표식을 쓰면 안된다. 정말 그대로 매크로 정의에 포함되기 떄문이다.

단순 매크로는 주로 KnR에서 메니페스트 상수라고 불린다. 우리는 이제 수, 문자 그리고 값에 이름을 부여할 수 있다. 이렇게 이름을 부여하면 상당한 이점이 있다.
(1) 프로그램의 가독성이 상승한다. (2) 프로그램을 수정하기 쉽게 만든다. (3) 비일관성, 오타로 인한 문제를 피할 수 있다. (4) C언어 문법을 약간 수정할 수 있다. (5) 자료형의 이름을 새로 지을 수 있다. (5) 조건부 컴파일을 통제할 수 있다.
수많은 프로그래머들이 상수 매크로를 사용하는 경우 이름을 대문자로 쓰는데, 사실 여기에는 어떤 일정한 합의가 있는 것이 아니다. 단순히 나중에 리뷰를 할 떄 수월하게 하기 위함이다. KnR 스타일에서는 소문자로 쓴다.

매개변수 매크로

매개변수 매크로(함수유사 매크로)는 다음과 같은 형식을 가진다.

#defome identifier(x1, x2, x3, ..., xn) replacement-list

여기서 ()로 감싸진 내용은 식별자들이며, 매개변수는 replacement-list에서 쓰일때마다 등장할 것이다.

[주의] 매크로의 이름과 괄호 사이에는 공백이 없다. 만약 공백이 있다면, 전처리기는 단순 매크로를 정의한다고 판단 할 수 있다.

괄호 안의 일련의 토큰들과 식별자와 같은 형식으로 프로그램의 사용되는 경우 replacement-list로 대체된다. 예시는 다음과 같다.

#define MAX(x,y)	((x)>(y)?(x):(y))
#define IS_EVEN(n)	((n)%2==0)
i = MAX(j+k, m-n);
if(IS_EVEN(i)) i++;

이와같이 매개변수 매크로는 함수처럼 쓰인다. 물론 이들의 매개변수 리스트가 비어있을수는 있다. 매개변수 매크로를 함수 대신 사용하는 것은 두 개의 이점을 가진다.
(1) 프로그램이 조금 더 빨라진다. (2)매크로는 함수와는 다르개 함수 매개변수의 자료형이 지정되지 않는다.
하지만 컴파일 된 코드는 종종 원래의 코드보다 더 길어진다.각 함수 호출은 매크로 내용을 삽입하게 되어있고, 결국 소스프로그램과 컴파일 코드의 양을 증가시킨다. 이 문제는 매크로 호출이 다른 괄호 안에서 일어났을 떄, 더 복잡해진다. 입력변수의 자료형은 검사 되지 않으며, 메크로-포인터는 사용불가능하다. 마지막으로, 매크로는의 입력변수는 한번 이상 계산 될 수 있다. 이는 정의되지 않은 행위를 일으킬 수 있다.

[주의] 입력변수를 여러번 계산함에 따라 발생하는 오류는 사전에 찾아내기 매우 힘들다. 왜냐하면 이들은 함수 호출과 유사 해 보이기 때문이다. 대부분의 경우, 매크로는 그냥 작동하며, 부-효과를 가지는 경우에 종종 문제가 일어난다. 따라서 매크로를 작성할 때에는 부-효과가 발생하지 않도록 유의하는 것이 좋다.

매개변수 매크로는 반복적으로 나타나는 일정한 형식의 코드 세그먼트를 대신할 때 매우 유용하다.

#연산자

매크로 정의는 다음 두 연산자를 가진다. ###다. 이 둘은 컴파일러에 의해 인식되지 않는다. 단 전처리 절차를 거칠 때 실행된다. #연산자는 매개변수 매크로에서 replacement-list를 문자열 리터럴로 변환한다.

#define PRINT_INT(n) printf(#n " = %d\n". m)

여기서 #연산자는 전처리기에게 문자열 리터럴을 PRINT_INT의 입력변수로부터 생성하도록 지시한다.

##연산자

##연산자는 두 개의 토큰을 하나의 토큰으로 이어붙인다. 만약 피연산자 중 하나가 매크로의 매개변수인 경우에, 매개변수가 입력변수로 대체 된 뒤 이어붙이기가 이뤄집니다. 예시는 다음과 같습니다.

int MK_ID(n) i##n
...
int MK_ID(1), MK_ID(2), MK_ID(3);

사실 ##연산자는 자주 쓰이지 않는다. 하지만 여러 자료형에 대응하는 함수를 만들어야하지만, 여러개를 직접 만들고 싶지 않은 경우에 ##을 활용한 매개변수 매크로가 유용할 것이다.

매크로가 가지는 일반적인 성질

단순 매크로와, 매개변수 매크로가 가지는 공통점은 다음과 같다.

  1. 매크로의 replacement-list는 다른 매크로 호출을 포함할 수 있다.
  2. 전처리기는 토큰 전체를 대체하지, 토큰 일부를 대체하지는 않는다.
  3. 매크로 정의는 일반적으로 파일이 종료될 때까지 유효한 효과를 가진다. 즉 일반적인 스코프룰을 따르지 않는다.
  4. 매크로는 #undef 지시자에 의해 "정의되지 않을" 수 있다. 이거 완전 직역투인데. 알아듣기 쉽게 말하자면 정의를 "해제"하고 다시 재정의할 수 있는 상태로 만든다.

매크로 정의 속 (괄호)

굳이 ()를 써야하는지 묻는다면, 당연하다. 만약 그렇지 않다면 의도치 않은 결과를 불러온다. ()을 매크로 정의에 쓸 때 따라야하는 규칙이 두 개 있다. (1) replacement-list가 연산자를 포함하는 경우에, 항상 ()가 쓰여야 한다. (2) 매크로가 매개변수를 가지는 경우 매개변수는 () 안에 들어가야한다. 왜냐하면 매크로가 쓰이는 위치에 따라 연산 우선순위와 결합방향이 우리의 예상과 달라질 수 있기 때문이다.

[주의] 매크로 정의에서 괄호의 쌍이 맞지 않는 경우, 또는 부족한 경우 에러가 발생한다.

긴 매크로 만들기

콤마 연산자는 replacement-list를 일련의 표현식 들로 구성하게 만듦으로써 복잡한 매크로를 작성할 수 있게 도와준다.

#define ECHO(s) (gets(s), puts(s))

물론 중괄호 {}를 이용하여 복합문으로 구성해도 촣다. 다만 이 경우 매크로 호출 시, ;을 적절하게 사용할지 말지를 결정해야한다. 다만 ;을 쓰지 않으면 굉장히 이상하게 보일것이다.(나중에 누가 ;을 추가해주면 이거 떄문에 에러가 발생하는거다.)
문제는 ,는 표현식을 묶어주지 구문을 묶어주지는 않는다. {}을 매크로에 활용할 때 필요한건 표현식이 아닌 구문을 묶어주는 것이 필요하다. 이경우 do{}while(0) 형식을 매크로에 활용하면 된다. 그러면 실제로 매크로를 호출할 때, ;를 뒤에 붙이면서 호출 할 수 있다.

사전에 정의 된 매크로들

C는 사전에 정의된 매크로들을 가지고 있다.각 매크로들은 정수형 상수 혹은 문자열 리터럴을 나타낸다. __LINE__,__FILE__,__DATE__,__TIME__,__STDC__ 가 있다.

C99에 와서 추가된 사전정의 매크로

C99에서 추가된 사전정의 매크로중 __STDC__HOSTED__ 를 이해하기 위해서는 C의 구현 이 어떻게 구성되었는지 알아야한다. C의 구현은 두 가지로 나뉘는데 하나는 호스트-구현이고 하나는 프리스탠딩 구현이다. 전자의 경우, C99 표준을 따르는 모든 프로그램을 수용하며, 후자의 경우 복잡한 자료형 혹은 기본적으로 잘 안쓰이는 표준 헤더가 담긴 프로그램을 컴파일 할 필요가 없다. 요컨데, 전자는 무조건 표준만 따르면 컴파일 하고, 후자는 비교적 익숙한 것들만 컴파일한다고 보면 된다. C89,C99 버전의 경우 __STDC__HOSTED__ 는 1을 가진다.
그 밖의 추가적인 사전정의 매크로를 알고 싶다면 KnK 14.3 330p를 참고하도록 하자.

Empty 매크로 입력변수

C99에서는 매크로 호출 시 입력변수를 비워둘 수 있다. 이 경우, 대응하는 매개변수는 replacement-list로 대체될 때 아무것도 없는 것처럼 비워진다. 만약 비어있는 입력변수가 # 의 피연산자인 경우에는 비어있는 입력변수가 문자열-화 된다.
##의 피연산자가 비어있는 경우, 보이지 않는 placemarker 토큰으로 대체되며 이 토큰은 다른 토큰과 결합되는 경우 그냥 사라진다. 그리고 한번 매크로 확장이 수행되면, placemarker 토큰은 프로그램에서 사라진다.

가변길이 입력변수 매크로

C89에서는 반드시 입력변수의 수를 고정하여 가지거나, 아예 가지지 않아야 했다. C99는 이걸 좀 완화했다. 이를 완화한 이유는 가변길이 입력변수를 전달받는 함수에게 입력변수를 전달하기 위해서다. printf. scanf가 바로 그 예시다.

#define TEST(condition, ...) ((condition)? \
	printf("Passed test: %s\n, #condition):\
    printf(__VA_ARGS__))

...토큰은 생략부호로서, 매크로 매개변수 리스트의 끝부분에 온다.__VA_ARGS__는 가변길이 입력변수 매크로의 대체리스트에서만 나타날 수 있는 특수한 식별자다. 이 식별자는 생략부호에 대응하는 모든 입력변수를 대체한다. 위의 매크로 사용예시는 다음과 같다.

TEST(voltage <= max_voltage, "Voltage %d exceeds %d\n", voltage, max_voltage);

func 식별자

C99의 새로운 특징은 func 에 있다. 이건 전처리기와 관련된게 없긴한데, 다른 전처리기의 특징들처럼 디버깅을하는데 유용하다. 각 함수는 모두, 현재 실행중인 함수의 이름을 저장하는 문자열 변수처럼 행동하는, __func__ 식별자에 접근한다. 따라서 다음과 같은 디버깅이 가능하다.

#define FUNCTION_CALLED() printf("%s called\n". __func__);
#define FUNCTION_RETURNS() printf("%s returns\n". __func__);

4. 조건부 컴파일

C의 전처리기는 조건부 컴파일을 지원하는 수많은 지시자를 인식한다.

#if와 #endif 지시자

현재 프로그램을 디버깅 할 떄, 특정 변수의 값을 확인하기 위해 printf함수 호출을 삽입할 수 있다. 버그 발생 위치를 특정해도 나중에 필요할 수 있으니 내버려 둘 수도 있는데, 조건부 컴파일을 활용하여 컴파일러가 평소에는 무시하게 만들 수 있다.
먼저 매크로를 생성하여 0이 아닌 값을 주자

#define DEBUG 1

매크로의 이름은 상관 없다. 그 다음에는 $if#endif로 감싼다.

#if DEBUG
printf("Value of i: %d\n". i);
printf("Value of j: %d\n:. j);
#endif

전처리 과정에서 #ifDEBUG의 값을 테스트 한다. 그리고 참인 경우에만 감싸진 부분을 무시하지 않을 것이다. #if#if constant-expression의 형식을 가진다. 만약 저 상수 표현식이 정의되지 않았다면 매크로는 0으로 취급함에 주의하라.

defined 연산자

defined 연산자를 현재 정의된 매크로인 식별자에 적용하는 경우, 1을 만들어낸다. 아닌 경우에는 0을 만든다.

#if defined(DEBUG)
...
#endif

이 경우 DEBUG가 매크로 정의 된 경우에만 지시자 내의 코드가 프로그램에 포함된다. 사실 ()는 필요하지 않다. 참고로 매크로 정의 여부만 따지기에 DEBUG가 값을 가질 필요는 없다.

#ifdef와 #ifndef 지시자

#ifdef 지시자는 식별자가 현재 매크로로 정의되어 있는지 따진다.

#ifdef identifier

사용은 마치 #if와 비슷하다. 사실 이건 필요가 없긴하다. 앞서 살펴본 지시자들의 결합을 통해 충분히 동일한 코드를 작성할 수 있기 때문이다. #ifndef는 식별자가 매크로 정의되지 않은은 경우를 판단한다. 즉 전자와 반대 조건일때 프로그램에 포함된다.

#elif와 #else 지시자

사실 일반적인 if문 처럼 사용하면 된다.

조건부 컴파일의 활용

조건부 컴파일은 디버깅을 할 떄 매우 유용하다. 그러나 활용법은 거기서 그치지 않는다.
(1) 여러 기기나 운영체제에서 실행되는, 가용성이 높은 프로그램을 작성할 수 있다.
(2) 각기 다른 컴파일러에 의해 컴파일 되는 프로그램을 작성할 수 있다.
(3) 매크로에 대한 기본 정의를 제공할 수 있다.
(4) 일시적으로 주석을 포함하는 코드가 실행되지 않게 만들 수 있다.

5. 기타 지시자

#error 지시자

#error 지시자는 다음의 형식을 가진다.

#error message

여기서 message는 일련의 토큰들로 구성된다. 만약 전처리기가 이 지시자를 인식하는 경우, message을 포함하는 에러 메시지를 출력한다.

#line/#pragma 지시자

https://wikidocs.net/86266 참고

profile
회계+IT=???

0개의 댓글