brunch

매거진 Sinclair

You can make anything
by writing

C.S.Lewis

by Sinclair Sep 01. 2016

착한 선행처리기

우리 만남은 우연이 아니야 I




자, 이제 마지막 글타래입니다.

사실 선행처리기는 C문법이 아닙니다. 때문에 컴파일러를 제공하는 회사마다 서로 다른 것들을 제공할 수도 있습니다. 그래서 자주 사용하고 공통된 문법을 위주로 설명하겠습니다.


우리는 첫부분의 글타래에서 이미 컴파일의 네 단계를 공부했습니다. 우리가 알고 있었던 컴파일은 실제로는 두 번째 단계였음을 이해해야 합니다. 작은 의미의 컴파일을 실행하기 전에 먼저 컴파일을 예비하는 단계가 바로 선행처리(preprocess)입니다.

일반적으로 선행처리기는 #으로 시작하는 선행처리 문법을 해석하거나 주석을 지워서 컴파일러가 소스를 읽기 편하게 재구성하는 일을 합니다. 그렇기 때문에 실제 컴파일러는 주석이 있었는지도 모르게 됩니다.

선행처리기가 주석을 지운다는데 뭣 하러 악착같이 주석을 달아야 할까? 이런 의문이 듭니다. 그러나 주석은 컴파일러를 위해 작성하는 게 아니라 프로그래머 자신과 함께 일하는 동료들을 위해 작성해야 합니다.

그리고 기회가 된다면 어셈블러와 링커에 대해서도 미리 공부해 두면 멋진 프로그래머로 거듭 날 수 있습니다.


선행처리기는 소스를 줄 단위로 처리하기 때문에 모든 문법이 줄 단위 입니다. 그래서 #으로 시작하고 그냥 한 줄의 끝이 한 문장이라고 할 수 있습니다.

C문법처럼 세미콜론으로 끝나지 않으며 주석문을 제외한 다른 문장이 뒤따라 올 수 없습니다. 그렇기 때문에 선행처리기 문장과 불필요하거나 관계 없는 C문장을 함께 적게 되면 에러가 발생할 수도 있습니다.

그리고 줄 단위로 처리하기 때문에 현재 문장이 존재하는 이전 영역에는 어떤 영향도 주지 않습니다.


만약에 #include <stdio.h>를 main() 함수 안에 넣어도 아무런 문제없이 실행되지만 그 헤더파일 안에 있는 모든 프로토타입들이 main() 함수 밖의 다른 함수나 선언하기 이전의 영역에는 어떤 영향도 줄 수 없습니다. 그래서 헤더파일을 포함하는 문장은 항상 프로그램의 가장 첫 부분에 적은 후에 프로그래밍을 시작하는 것입니다.

그리고 #은 한 줄의 첫 번째 칸에 적는 것이 일반적입니다. 어떤 선행처리기는 #이 첫 번째 칸에 오지 않으면 선행처리기 문법으로 인식하지 못하는 경우도 있다고 합니다.

그러면 이제 선행처리기가 어떤 착한 일을 하는지 함께 알아 보도록 하겠습니다.  



#include 파일이름


그 동안 우리가 이미 많이 사용했으며 또, 실제로도 가장 많이 사용하는 선행처리기 문법입니다.

파일이름은 <>이나 ""로 감싸져 있으며 그 파일의 내용을 포함시키라는 의미입니다.

<stdio.h>와 "stdio.h"의 차이는 파일을 찾는 위치에 있습니다.

<stdio.h>는 컴파일러가 지정한 디렉터리(주로 include 디렉터리)에서 파일을 검색하고 만약에 그곳에 stdio.h 파일이 존재하지 않으면 선행처리기 에러가 발생합니다.

"stdio.h"는 먼저 프로그래머가 지정한 디렉터리에서 검색을 합니다. 지금처럼 디렉터리를 지정하지 않으면 작성 중인 C소스파일과 같은 현재 디렉터리에서 찾은 뒤 없으면 컴파일러가 지정한 디렉터리를 한번 더 검색을 합니다. 그곳에도 없으면 역시 선행처리기 에러가 발생합니다.

주로 함수의 프로토타입이나 구조체 선언 등을 모아 놓은 헤더파일을 포함시키는 것이 일반적이지만 반드시 헤더파일만 포함해야 하는 것은 절대 아닙니다. 모든 종류의 파일을 다 포함시킬 수는 있지만 포함된 후에 컴파일러가 그것을 해석할 수 없다면 컴파일 에러가 발생할 수 있습니다. 때론 분할 컴파일 후 오브젝트 파일을 링크하는 것 대신 C 소스파일을 #include하는데 프로그램이 커지고 파일이 많은 경우 컴파일 시간이 늘어나는 등 문제점이 발생합니다.  



#define 매크로이름 매크로값(문장)


주로 매크로상수 또는 매크로함수를 만들 때 사용합니다. 일반적으로 매크로는 여러 문장이나 기능을 간단하게 요약해 놓는 것으로 실제 사용 될 때는 확장되어 쓰입니다.

매크로는 매크로이름과 매크로값으로 구성되어 있습니다. 매크로 상수는 실제 상수가 아닐 수도 있지만 컴파일 되기 전에 이미 확정되어 있기 때문에 거의 상수처럼 동작합니다.

마찬가지로 매크로함수는 실제 함수가 아니지만 마치 함수처럼 동작합니다. 일반함수와 처리 속도를 비교해 보면 매크로함수가 일반함수보다 훨씬 더 빠르게 동작합니다. 그러면 앞으로 모든 함수를 매크로함수로 만들어도 될까요? 천만의 말씀 만만의 콩떡입니다.

매크로함수가 빠르게 동작하는 이유는 함수처럼 이름으로 호출하고 인자를 전달하는 것이 아니라 매크로함수를 호출하는 그곳에 인자 전달 없이 실제 코드로 확장되기 때문입니다. 함수를 부르는 일은 현재 상태를 저장하고 인자를 전달하고 호출된 함수를 실행하여 리턴 값을 받은 뒤에 다시 원래 함수로 돌아와야 하는 매우 복잡한 작업입니다. 하지만 매크로함수는 전혀 그럴 필요가 없습니다. 컴파일 하기 전에 이미 매크로함수를 부르는 곳 마다 코드가 확장되어 포함되기 때문에 프로그램의 실행 속도는 좀 빨라지지만 반복 확장되는 코드만큼 메모리를 많이 사용하는 문제가 있습니다.

코드도 메모리에 올라가야 실행되므로 항상 메모리를 차지하고 있다는 사실을 절대 잊으면 안됩니다. 실행 프로그램에서 한 줄의 코드는 한 줄의 메모리 입니다.

또한 매크로함수를 작성할 때는 괄호를 적절히 함께 사용해야 합니다. #define compute(i,j) i * j 라고 선언한 경우 compute(a+b,3); 이렇게 호출하면 실제 컴파일 되는 코드가 (a+b)*3 이 아니라 a+b*3, 즉 a+(b*3)이 되는 사태가 발생합니다. 때문에 제대로 (a+b)*3처럼 동작하길 원한다면 반드시 #define compute(i,j) ((i) * (j)) 라고 작성하는 것이 좋습니다.

매크로이름을 줄 때는 일반적인 C의 변수 명명법을 그대로 따르게 됩니다. 그래서 현재 선언된 매크로이름과 이후에 나오는 변수나 다른 매크로이름과 같으면 안됩니다. 주로 매크로이름은 대문자를 사용하고 단어 사이나 앞뒤에 _를 넣어 만드는 것이 일반적입니다. 매크로값 또는 매크로문장으로 사용 가능한 것은 상수 값 또는 수식(들)과 앞에 선언된 다른 매크로이름이며, 경우에 따라서 아무 값도 주지 않을 수 있습니다. 매크로값을 주지 않아 이름뿐인 매크로는 컴파일러에게 어떤 특정 사실을 알리거나 조건에 따라 다르게 컴파일 할 때 사용됩니다.  



#include <stdio.h>  

/*

* copyleft (l) 2006 - 2017 programmed by Sinclair

*/

#define __FIRST___ "Sinclair"

#define __SECOND___ " 바보"

#define __THIRD___ __FIRST___ __SECOND___ // 이런 거두 가능

/*

#define LINUX_I // 이건 불가능

// 위와 같이 선언하면 아래 정수형 선언에서 에러발생, 변수이름이 사라진다~

// warning: useless keyword or type name in empty declaration

// warning: empty declaration

*/

int LINUX_I = 26 ;

#define LINUX_I // 아래에 선언한 이건 가능

// 하지만 이 밑에서 이 변수를 사용 못하니 이것도 문제, 음~ 이러는 거 아니야~

// Sinclair's MACRO pets :)

#define MAX(i,j) ((i) > (j) ? (i) : (j))

#define MIN(i,j) ((i) < (j) ? (i) : (j))

#define ABS(i) ((i) > 0 ? (i) : -(i))

#define In2Cm(i) ((i)*2.54)

#define Cm2In(i) ((i)/2.54)

#define C2F(i) ((i)*1.8+32)

#define F2C(i) (((i)-32)/1.8)  


int main() {

    printf("%s\n" , __THIRD___) ;

    printf("MAX(10,20) = %d\n" , MAX(10,20)) ;

    printf("MIN(10,20) = %d\n" , MIN(10,20)) ;

    printf("ABS(-3.141592) = %f\n" , ABS(-3.141592)) ;

    printf("3.141592 inch = %f cm\n" , In2Cm(3.141592)) ;

    printf("3.141592 cm = %f inch\n" , Cm2In(3.141592)) ;

    printf("26℃ = %f℉\n" , C2F(26)) ;

    printf("79℉ = %f℃\n" , F2C(79)) ;

    return 0 ;

} // end main()   




매크로 선언이 지나치게 많아 지거나 매크로 중첩이 반복되면 프로그램이 너무 복잡해지는 문제가 발생합니다. IOCCC(The International Obfuscated C Code Competition) 대회에서 그랑프리를 수상한 프로그램들을 보면 종종 어이가 없을 때도 많이 있습니다. IOCCC에 내보낼 소스가 아니라면 차라리 그냥 상수 값으로 적고 그 옆에 주석을 달아주거나 열거형 데이터를 사용하는 것도 괜찮은 방법이라고 생각됩니다. 그리고 너무 많은 매크로선언으로 소스를 분석하거나 디버깅하기가 어렵다면 컴파일러의 help기능을 통해 옵션들을 찾아 보고 선행처리기 단계를 끝내고 멈추도록 하는 옵션을 사용하면 모든 매크로가 풀려 있는 순수 C코드를 볼 수도 있습니다. 우리 눈에 보이는 소스와 컴파일러가 실제로 컴파일 하는 소스가 다르기 때문에 실제로 대부분 매크로와 관련된 수식의 논리적인 오류들은 작성된 소스를 아무리 들여다 봐도 오류를 발견하기 매우 어렵습니다. 그래서 때론 순수 C코드를 봐야만 할 때가 분명히 있습니다.

밤새도록 아무리 들여다 보고 있어도 안 잡히던 버그를 순수 C코드를 확인해서 잡았던 적이 한 두 번이 아니었습니다. 그리고 매크로 값을 소스 파일에 직접 #define으로 적지 않아도 컴파일러의 다른 옵션을 사용하면 컴파일 하는 도중에 줄 수도 있습니다. 주로 디버깅 할 때 유용한 기법들입니다.  



#if #elif #else #ifdef #ifndef #endif: 조건부 컴파일


프로그램의 버전이나 OS환경이나 네트워크 서비스 등 외부 조건에 따라 달리 컴파일을 해야 할 경우 사용하는 조건부 컴파일 선행처리기 문법입니다. 조건부 컴파일의 모든 조건은 반드시 #endif로 끝나야 합니다. #ifdef는 매크로가 선언된 경우에, #ifndef는 매크로가 선언되어 있지 않은 경우에만 포함된 코드를 컴파일 하게 됩니다.



#ifdef 매크로이름

코드들

#endif

// 매크로선언이 선언되어 있다면 코드들을 컴파일 한다

// 때론 선언 안 한 매크로이름을 사용하여 주석처리 용도로 사용하기도 합니다.

#ifndef 매크로이름

코드들

#endif

// 매크로선언이 선언되어 있지 않다면 코드들을 컴파일 한다



주로 프로그래머 지정 헤더 파일을 만들 때 많이 사용합니다. 프로토타입이나 구조체 선언도 일반 변수 선언과 같이 같은 것을 두 번 이상 선언할 수 없습니다. 물론 직접 두 번 연거푸 선언하는 경우는 없겠지만 헤더파일에 다른 헤더파일이 포함되거나 한다면 의도하지 않은 중복 선언이 일어나게 됩니다. 때문에 보통 헤더파일을 만들 때 아래와 같은 구조를 사용합니다.



#ifndef __MY_HEADER___

#define __MY_HEADER___

// 선언들 여기에

#endif

// 이렇게 만들면 중복 포함되는 문제를 막을 수 있습니다.



#ifdef와 #ifndef가 단순히 매크로선언의 여부만을 묻는다면 #if #elif #else를 사용하면 매크로 값을 비교하여 조건에 따라 컴파일 할 수도 있습니다. 대부분의 컴파일러에서 비교할 때 사용하는 매크로 값은 실수형이나 문자열이 아닌 반드시 정수형 값이어야 하며 C에서 사용하는 모든 비교, 논리 연산자를 다 사용할 수 있습니다. 뿐만 아니라 중첩되는 #if #endif 블록도 지원하고 있는 등, 처리하는 방식과 사용 방법은 C문법의 if else와 동일합니다.



#if 조건식1

코드1 // 조건식1이 참이면 코드1을 컴파일하고

#elif 조건식2

코드2 // 조건식2가 참이면 코드2를 컴파일하고

#else

코드3 // 둘 다 거짓이면 코드 3을 컴파일합니다~

#endif  



그리고 이것들과 함께 사용할 수 있는 defined라는 선행처리기만의 수식도 있습니다. #if defined(매크로이름)은 #ifdef 매크로이름과 같은 동작을 하고 #if !defined(매크로이름)은 #ifndef 매크로이름과 같은 동작을 합니다.

차라리 그냥 #ifdef나 #ifndef를 사용하지 뭣 하러 defined를 사용할까 생각하겠지만 #if와 defined를 사용하면 #elif나 #else 뿐만 아니라 #if defined(매크로이름1) && defined(매크로이름2) 하는 식으로 다른 비교, 논리 연산자를 함께 사용할 수 있다는 장점이 있습니다.  



#undef 매크로이름


이미 선언한 매크로의 선언을 무효화 시키는 선행처리기 문장입니다. 매크로는 중복 선언될 수 없습니다. 때문에 코드 중간에서 앞에 선언한 매크로 값을 변경하고 싶다면 반드시 #undef를 사용하여 먼저 해제한 후에 다시 #define으로 선언해야 합니다. 여기서 재밌는 사실은 앞에 선언되지 않은 매크로이름을 #undef하더라도 아무런 에러가 발생하지 않는다는 것입니다. 때문에 외부에 선언된 매크로를 무시하고 나만의 매크로 값을 갖고 싶다면 그냥 아래와 같이 작성할 수 있습니다.



// #ifdef __SINCLAIR___

#undef __SINCLAIR___

#define __SINCLAIR___ "바보?"

// #endif

// 조건부 컴파일을 빼더라도 제대로 동작









#Sinclair #씽클레어 #싱클레어 #씽클레어도씨 #씨언어 #씨프로그래밍  #C언어 #Cprogramming #C_Programming #C #Programming #Clanguage #C_Language

매거진의 이전글 착한 선행처리기
브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari