[C언어 강좌] #19-1 전처리기와 분할 컴파일


모든 일에는 어떤 행동을 처리하기전에 해야할 일이 있습니다. 예를 들어서 밥을 먹기 위해선 밥상을 차리는 것을 먼저해야합니다.  일상에서 먼저 처리해야 하는 일을 '전처리' 라고 가볍게 표현할 수 있습니다.

밥을 먹는 처리를 위해선 밥상을 차리는 '전처리'를 해야한다는 것입니다.

 

컴퓨팅 세계에서도 전처리를 해야 할 필요가 있습니다. 이때 전처리기를 나타내는 기호로 '#' 을 사용합니다.

전처리기를 사용하면 여러 개의 파일을 분할해서 컴파일 하는것도 가능합니다.

 

전처리기

프로그램 작성을 한 후 실행까지 과정은 아래와 같습니다

프로그램 작성 -> (전처리) -> 컴파일 -> 링크 -> 실행

컴파일의 경우 고급언어(프로그래밍 언어)로 작성한 프로그램을 기계어(0과 1)로 바꿔주는 작업이였고,

링크는 이 바꿔준 것을 *.exe로 실행할 수 있게 병합하는 작업이였습니다.

 

그런데 컴파일 전에 보시면 전처리 과정이 있죠.

프로그래머가 작성한 소스 파일을 컴파일 하기 전에 뭔가 처리 해야 하는 일(전처리)이 있는 것 입니다.

 

전처리란 컴파일 전에 처리해야 하는 일을 말하고, 전처리를 수행하는 장치를 전처리기라고 합니다.

그리고 다음 예와 같이 # 문자로 시작하는 문장을 가리켜 전처리기 지시자라고 합니다. 

 

#include <stdio.h>
#define MAX 100

 

대표적으로 우리가 지금까지 써왔던 헤더 파일을 인클루드 하는 전처리기 지시자 #include 와 매크로 상수를 정의하는 전처리기 지시자 #define 이 있습니다.

 

C언어에서는 문장의 끝을 나타내기 위해 세미콜론(;) 을 마침표로 사용합니다. 

세미콜론을 붙여주지 않으면 컴파일 에러가 발생하는데, C언어 구문의 특성산 한 줄에 둘 이상의 구문을 작성하고 싶을때도 세미콜론으로 분리시켜주면

 

컴파일러가 세미콜론으로 분리해서 읽기 때문에 한 줄에 둘 이상의 구문 실행이 가능합니다.

그러나 전처리기는 한 줄에 하나의 지시자를 사용하기 때문에 전처리기 지시자 뒤에는 세미콜론을 붙이지 않습니다.

 

<전처리기 지시자의 특징>

- 전처리기 지시자는 # 문자로 시작한다

- 전처리기 지시자 뒤에는 세미콜론(;) 을 사용하지 않는다.

 

전처리기는 보통 지금까지 해왔던 것 처럼 헤더 파일을 인클루드 하거나, 소스 파일 내부의 특정 문자열을 상수 또는 문자로 치환하거나, 조건에 따라서 컴파일 하거나 컴파일 하지 못하게 하는 선택 기능까지도 제공합니다.

전처리기 지시자는 # 문자로 시작하기 때문에 식별하기도 쉽습니다.

 

아래 표는 전처리기 지시자의 종류를 정리해본 것 입니다.

이미 써본 것 말고도 여러가지 전처리기 지시자가 존재합니다.

전처리기 지시자 설명
#include 헤더 파일을 인클루드 한다.
#define 매크로를 정의한다
#undef 이미 정의된 매크로를 해제한다
#if, #elif, #else, #endif 조건에 따라 컴파일 한다
#ifdef 매크로가 정의된 경우에 컴파일 하는 기능
#ifndef 매크로가 정의되지 않은 경우에 컴파일 하는 기능

 

매크로

#define... 으로 시작되는 전처리 문장을 매크로라고 하며, 매크로는 크게 두 가지로 나누어집니다.

첫번째로 매크로 상수가 있고, 두번째로 매크로 함수가 있습니다.

이 중에서 매크로 상수는 이미 상수편에서도 배워본적이 있습니다.

 

매크로 상수와 매크로 함수에 대해 자세히 알아봅시다.

 

(이 글을 쓰면서 참고한 내용 : https://modoocode.com/88 , https://modoocode.com/99 )

 

매크로 상수

다음은 #define 전처리기 지시자를 이용하여 매크로 상수를 정의하고 있습니다.

 

  • 전처리기 지시자 : 매크로 상수를 선언하기 위해 #define 을 지정
  • 매크로 상수 이름 : 매크로 상수의 이름을 지정
  • 치환값 : 매크로 상수에 치환되는 값을 지정

 

위 문구의 의미는 전처리를 진행할때 PI를 3.14로 치환시키겠다는 의미입니다.

 

#include <stdio.h>
#define PI 3.14

int main()
{
    //원의 넓이 구하기 
    int r = 10;
    printf("%f", PI * r * r);
    return 0;
}

다음과 같이 소스코드를 작성하면

 

#define PI 3.14에 의해 컴파일 이전에 전처리가 이루어져서

PI라는 문구는 전부 3.14로 대체됩니다.

 

그래서 실제로 소스코드가 아래처럼 바뀌는 것 입니다.

 

#include <stdio.h>

int main()
{
    //원의 넓이 구하기 
    int r = 10;
    printf("%f", 3.14 * r * r);
    return 0;
}

이 작업이 컴파일 이전에 이루어지고(전처리), 컴파일 작업이 진행되기 때문에

컴파일러 입장에서는 PI * r * r 라는 문장이 3.14 * r * r 로 대체된 상태로 진행되어 오류 없이 처리가 가능한 것입니다.

 

이제 작동 매커니즘은 알겠고, 사용 이유에 대해 알아봅시다. 매크로 상수를 왜 쓸까요?

지금의 경우에는 원의 넓이 공식인 πr² 를 써주기 위해 파이값 3.14를 겨우 한번 썼습니다만

 

다른 넓이를 계산하기 위해 PI값을 100번 쓴다면, 아니면 1000번 쓴다면 어떻게 될까요?매번 3.14를 적어줘야 하는건 당연하겠고.. 프로그램 피드백을 받다가 정밀도를 높이기 위해서 원주율을 3.14가 아니라 3.14159 로 바꾸라는 요청이 들어오면 멘붕 그자체겠죠.

 

1000번을 바꿔줘야 하니깐요. Ctrl + H로 치환시키는 방법도 있겠지만 3.14가 포함된 애꿏은 다른값들이 바뀔 가능성도 있고 문제가 발생할 가능성도 있습니다.

 

근데 처음부터 전처리기 지시자로 #define PI 3.14 로 적어놓고 PI 값만을 사용했으면 전처리기 지시자에서 3.14만 간단하게 3.14159로 한번만 바꾸면 끝입니다.

 

#define MAX 100
#define PI 3.14
#define STRING "Hello C"
#define OUTPUT printf
#define DATA int

전처리기 지시자에는 정수형 상수뿐만 아니라 실수형 상수, 문자열 상수 등 다양한 것을 정의할 수 있습니다.

심지어 함수 이름까지도 가능합니다.

 

전처리기는 컴파일 전 처리기 때문에 전처리기 과정을 거치게 되면 MAX는 100으로 치환되고,

PI는 3.14로, OUTPUT은 printf로 치환됩니다. 그리고 컴파일시 작업이 시행됩니다.

 

#include <stdio.h>
#define print printf
#define PI 3.14

int main()
{
    //원의 넓이 구하기 
    int r = 10;
    print("%f", PI * PI * r);
    return 0;
}

예제를 보시면 #define을 이용해 print는 printf 로 치환시키고,

PI는 3.14로 치환하라고 하고 있습니다.

 

이 소스코드는 전처리기 과정을 거치게 되면

#include <stdio.h>

int main()
{
    //원의 넓이 구하기 
    int r = 10;
    printf("%f", 3.14 * r * r);
    return 0;
}

라고 변경(치환)되고 컴파일 시 위와 같이 작업이 진행됩니다.

 

<매크로를 이용해 나타낸 상수의 장점>

- 프로그램을 쉽게 수정 할 수 있음

- 한눈에 파악하기 어려운 숫자들 대신에 직관적인 의미를 갖는 이름으로 바꿀 수 있음(PI == 3.14)

- 치환의 개념을 이용하기 때문에 추가적인 메모리 공간을 요구하지 않고,

코드에 등장하는 상수들을 한곳에 모아서 관리 가능

 

매크로 해제

#undef 는 #define과는 반대 역할을 수행하는 전처리기 지시자 입니다.

전처리기 지시자 #undef는 기존 매크로의 정의를 해제하고 이후부터 치환을 중지합니다.

 

다음은 매크로 해제를 위해 전처리기 지시자 #undef를 사용하는 방법입니다.

  • 전처리기 지시자 : 매크로 선언을 해제하기 위해 #undef 를 지정
  • 해제할 매크로 이름 : 해제할 매크로 이름을 지정(미리 정의된 매크로 상수 이름)

 

보통 매크로는 한 번 정의하면 프로젝트 전체에서 일관되게 사용합니다. 그래서 #undef를 사용하는 경우는 흔하지 않고 C언어 강의에서도 많이 다루는 내용은 아닙니다.

그러나 기존 매크로를 다시 정의하고 싶을때 #undef를 이용해서 매크로를 해제하고서 재정의 하는게 가능합니다.

예제를 가지고 사용 방법을 알아보겠습니다.

 

#include <stdio.h>

#define MAX 100
#define PI 3.14

int main()
{
	printf("변경전 : %d %f\n", MAX, PI);
	
	
	//매크로 해제 
	#undef MAX
	#undef PI
	
	#define MAX 1000
	#define PI 3.141592
	printf("변경후 : %d %f", MAX, PI);
	
	
    return 0;
}

변경전 : 100 3.140000
변경후 : 1000 3.141592
--------------------------------
Process exited after 0.004282 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

사용법은 소스코드만 보시면 이해할거라고 믿습니다.

#undef 로 매크로를 해제했다가 다시 재정의가 가능합니다.

물론 일반적으로는 전처리기 문은 맨위에 정리해서 쓰는게 일반적이므로 이런게 있다 정도만 아시면 될 거 같습니다.

 

매크로 함수

매크로 함수는 함수처럼 인자를 설정할 수 있는 매크로를 의미합니다.

매크로 함수라고 부르지만 단순히 치환만 하므로 실제로 함수는 아닙니다.

 

다음은 매크로 함수를 정의하는 방법입니다.

 

이 문장의 의미는 아까 #define을 이용한 매크로 상수와 동일한데 전처리를 진행할때 MUL(a,b) 를 만나면 a*b 로 치환해주라는 명령문입니다.

 

아까전 매크로 상수가 PI를 3.14라는 특정 상수로 치환했다면 매크로 함수는 함수 모양의 형태로 치환해주는 것 입니다.

매크로 함수라고 부르는 이유도 정말 하는 일이 함수와 비슷하기 때문입니다.

 

보통의 매크로 선언에 비해 매크로 함수는 괄호와 함께 인자 목록이 주어져 있다는 점이 다르고, 

함수와 비슷해보이지만 매크로 함수와 함수는 엄연한 차이가 여럿 존재합니다.

 

우선 매크로 함수는 인자의 자료형을 신경쓰지 않습니다. 즉 자료형의 독립성을 보장합니다.

 

예를 들어서 아래 매크로 함수는 다음과 같이 자료형의 독립성을 보장하며 치환됩니다

MUL(3,4) => 3 * 4
MUL(3.14, 5.5) => 3.14 * 5.5
MUL('A', 3) => 'A' * 3

 

 

만약에 우리가 이 MUL() 이라는 것을 매크로 함수가 아니라 함수로 구현했다면

int MUL(int a, int b){ return a * b }

다음과 같이 반환값을 정해주고 값을 반환 해서 구현했어야 했을겁니다.

 

 

그런데 매크로 함수의 경우

 

printf로 출력을 한다고 하면 아래 코드는

printf("MUL(3,4) : %d \n", MUL(3,4));

 

'컴파일 전 전처리에 의해' 이렇게 변환됩니다.

printf("MUL(3,4) : %d \n", 3*4);

중요한건 '컴파일 전에 전처리' 라는 것입니다.

컴파일 전에 이루어지기 때문에 전처리기에 의해 그냥 위 처럼 상수 계산식으로 바뀌어버리는 것이죠.

 

함수를 호출해서 사용하고 이런거 없이 그냥 3*4가 계산되어 버립니다.

 

차이가 좀 느껴지시나요? 우선 예제를 보고 어떻게 사용하는지 한번 봐보겠습니다.

#include <stdio.h>

#define MUL(x, y) x*y

int main()
{
	int a, b;
	double c, d;
	
	printf("두 개의 정수를 입력해주세요 : ");
	scanf("%d %d", &a, &b);
	printf("%d * %d = %d \n", a, b, MUL(a,b));
	
	printf("두 개의 실수를 입력하세요 : ");
	scanf("%lf %lf", &c, &d);
	printf("%lf * %lf = %lf \n", c, d, MUL(c,d));
	
    return 0;
}


두 개의 정수를 입력해주세요 : 5 5
5 * 5 = 25
두 개의 실수를 입력하세요 : 2.5 4.5
2.500000 * 4.500000 = 11.250000

--------------------------------
Process exited after 7.567 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

예제를 보시면 알겠지만 보통 함수와 사용방법이 비슷합니다.

마치 호출을 하듯이 사용을 하고있구요.

 

그런데 매크로 함수는 전처리기에 의해 단순히 치환된다는 것이 중요합니다.

컴파일 이전에 전처리기에 의해 단순히 치환되는 형태로 진행됩니다.

 

이 2줄의 코드는 #define MUL(x, y) x*y (전처리기) 의해

printf("%d * %d = %d \n", a, b, MUL(a,b));
printf("%lf * %lf = %lf \n", c, d, MUL(c,d));

 

printf("%d * %d = %d \n", a, b, a * b);
printf("%lf * %lf = %lf \n", c, d, c * d);

로 치환됩니다.

 

이러한 치환의 원리에 의해 아까도 말했듯이 어떠한 자료형의 변수를 인자로 전달하더라도 잘 동작하게 됩니다. 만약 일반 함수를 통해 앞서와 같은 프로그램을 구현하려면 int형 인자를 가지는 함수, double형의 인자를 가지는 함수를 개별로 만들어야 했을 것 입니다.

 

매크로 함수의 장점은

  • 함수의 인자에 대한 자료형의 독립성을 보장합니다
  • 일반 함수의 몸체 부분이 매크로 함수의 치환 문장으로 대신하기 때문에 속도가 빠릅니다

 

매크로 함수의 단점은

  • 매크로 함수 내부에서 자기 자신을 호출할 수가 없습니다.
  • 한 줄이나 두 줄 정도의 간단한 내용만 매크로 함수로 정의해야 합니다.

 

매크로 함수를 제대로 정의해서 사용하려면 보통 함수에 비해 많은 점을 고려해야 합니다.

다음 예제를 가지고 매크로 함수가 갖는 문제점과 해결책을 알아보도록 합시다.

 

다음 예제는 두 개의 정수 a와 b를 입력받아서 (a+1) * (b+1) 을 계산하는 예제입니다.

 

#include <stdio.h>

#define MUL(x, y) x*y

int func_MUL(int x, int y){ return x * y; }
int main()
{
	int a, b;
	double c, d;
	
	printf("두 개의 정수를 입력해주세요 : ");
	scanf("%d %d", &a, &b);
	printf("(%d+1) * (%d+1) = %d \n", a, b, MUL(a+1,b+1));
	printf("(%d+1) * (%d+1) = %d \n", a, b, func_MUL(a+1,b+1));
	

    return 0;
}

두 개의 정수를 입력해주세요 : 1 2
(1+1) * (2+1) = 4
(1+1) * (2+1) = 6

--------------------------------
Process exited after 10.15 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

??? 생각과는 전혀 다른 결과가 나왔습니다.

우리 생각하기에 (1+1) * (2+1) 은 2 * 3 = 6이 나와야 할거 같은데 매크로 함수를 사용해보니 4가 나오고

 

기존 함수로 구현한 func_MUL() 의 경우에는 원하는 값인 6이 제대로 나왔네요.

도대체 뭐가 문제일까요?

 

아까도 말했듯이 매크로 함수는 단순 치환입니다.

아래 문장은

printf("(%d+1) * (%d+1) = %d \n", a, b, MUL(a+1,b+1));

 

printf("(%d+1) * (%d+1) = %d \n", a, b, a+1*b+1);

로 치환이 됩니다.

 

그런데 문제는 계산을 할때 사칙연산 법칙에 의해서 곱셈을 먼저 계산하게 되죠?

 

a + 1 * b + 1 가 되어서 a = 1, b = 2 이므로

1 + 1 * 2 + 1 = 1 + 2 + 1 = 4 가 출력되는 겁니다.

 

우리가 원하는건 (a+1) * (b + 1) 이였지만 매크로 함수는 단순히 a+1과 b+1을 x와 y의 위치에서 단순히 치환하는 역할만 하기 때문에 그렇습니다.

#include <stdio.h>

#define babo(arg) arg
#define str(arg) #arg

int main()
{
	printf("%d \n", babo(1234));
	printf("%s \n", str(1234));

    return 0;
}

 

일반 함수의 경우에는 (a+1) * (b+1) 이 수행된 결과입니다. 우리의 의도대로 출력이 된 것이죠.

이걸 어떻게 해결할까요. 방법은 단순합니다. 치환을 시킬때 괄호도 넣으라고 명령해주면 되는것이죠.

 

#define MUL(x, y) (x)*(y)
//MUL(2+1, 2+1) => (2+1) * (2+1)

아까 MUL의 전처리기 문을 다음과 같이 바꾸고 다시 컴파일 해주면 원하는 대로 결과가 출력되는걸 알 수 있습니다.

한번 해보세요!

 

매크로 함수는 단순 치환을 하므로 사칙 연산을 할때 우리가 원하지 않던 결과가 나오기도 하므로 사용할때 주의가 필요합니다.

 

# 연산자와 ## 연산자

매크로 함수를 만들때 종종 사용되는 기능 중에 하나가 바로 전처리기 연산자인 #과 ##입니다. 

이들 연산자는 매크로 함수를 정의할 때에만 사용됩니다.

 

#은 매크로 함수의 인자를 문자열로 바꾸어주는 연산자 입니다.

 

#include <stdio.h>

#define babo(arg) arg
#define str(arg) #arg

int main()
{
	printf("%d \n", babo(1234));
	printf("%s \n", str(1234));

    return 0;
}

1234
1234

--------------------------------
Process exited after 0.004297 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .
#define babo(arg) arg
#define str(arg) #arg

매크로 함수 2개를 정의했습니다.

하나는 바보상자 babo로 인자값으로 들어온걸 그대로 자기 자신으로 치환시키고,

 

str의 경우 들어온 인자값 arg을 #arg로 치환시키고 있습니다.

#을 붙이면 매크로 함수의 인자가 문자열로 치환됩니다.

 

printf("%d \n", babo(1234));
printf("%s \n", str(1234));

 

이거 상당히 유용합니다.

단 한줄짜리 코드로 숫자를 문자열로 바꾸는 코드가 완성되었습니다 ㄷㄷ

(C언어를 하다보면 파이썬의 str() 함수같은 변환함수가 그리워질때가 있는데 이렇게 구현할 수도 있습니다..)

 

숫자를 문자열(문자)로 바꾸려면 sprintf() 나 itoa() 함수를 써야했는데.. 이런 방법도 있군요.

 

#include <stdio.h>

#define str_plus(a, b) #a "문자" #b

int main()
{
	printf("%s \n", str_plus(12, 34));

    return 0;
}



12문자34

--------------------------------
Process exited after 0.003951 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

또한 a,b를 받아서 #a #b로 문자열 치환을 할때 중간에 문자를 끼워넣으면

저런식으로 12문자34로 출력이 되는것도 볼 수 있습니다.

 


이제 다음으로 ## 연산자는 토큰 결합 연산자라고 하는데 매크로 함수 안에서 토큰을 결합하는 기능을 수행합니다.

 

프로그래밍 언어에서 토큰(Token) 이란 문법 분석의 단위를 의미합니다.

이렇게 토큰을 분석하는 프로그램을 파서(Parser) 라고 하는데 컴파일러에 포함이 되어 있습니다.

파서는 코드의 문법을 해석 하기 위해 코드를 숫자, 콤마, 연산자, 식별자 등의 토큰 단위로 분리하고 의미를 파악합니다.

 

*C언어에서 토큰의 의미는 컴파일러가 인식하는 문자나 문자열의 최소 단위를 말합니다.

예를 들어서 int num = x + y ;  라는 코드가 있으면 이 코드를 토큰으로 나누어보면 int , num , = , x , +, y, ; 와 같이 총 7개의 토큰을 가집니다.

 

다음 예제를 가지고 ## 연산자에 대해 알아봅시다.

 

#include <stdio.h>

#define concat(a, b, c) a##b##c

int main()
{
    printf("%d\n", concat(1, 2, 3));

    return 0;
}

123

--------------------------------
Process exited after 0.004301 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

다음과 같이 매크로 함수를 선언하고 ##연산자를 이용하면 a,b,c를 붙일 수 있습니다.

a,b,c를 각각 개별된 토큰으로 인식하고 a##b##c와 같은 명령을 써주면 전처리기가 실행되고

1,2,3이 붙어서 123으로 출력이 되는 것입니다.

 

#include <stdio.h>
#define call(x) test##x()

void test1() { printf("hello world 1\n");}
void test2() { printf("hello world 2\n");}

int main()
{
    call(1);    // test1() 호출 
    call(2);    // test2() 호출 

    return 0;
}

hello world 1
hello world 2

--------------------------------
Process exited after 0.004459 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

이런식으로 응용해서 함수도 호출 할 수 있답니다.

 

#define call(x) test##x()

다음과 같이 정의하면 전처리기 작업후 call(x) 를 testx() 로 변경합니다.

 

    call(1);    // test1() 호출 
    call(2);    // test2() 호출

즉 다음코드는

 

 

    test1(); 
    test2();

다음과 같이 변경되겠죠. 신기하죠?

 

##은 위와 같이 변수나 함수의 이름을 동적으로 작성하는 용도로 사용할 수 있고, 윈도우 프로그래밍(WIN32API, MFC)의 헤더 파일이나 소스 코드 내부에 ## 연산자를 이용하여 간결한 코드 작성을 가능하게 합니다.

 

미리 정의된 매크로

C언어에서는 기본적으로 정의되어 있는 매크로가 있습니다. 이것들은 개발자의 편의를 위해 미리 정의되어 있어서 우리는 사용만 하면 됩니다.

 

미리 정의된 매크로 설명
__FILE__ 현재 소스코드의 파일 이름을 나타내는 매크로 %s로 출력
__LINE__ 현재 위치의 소스코드 행 번호를 나타내는 매크로, %d로 출력
__DATE__ 현재 소스 코드의 컴파일 날짜는 나타내는 매크로, %s로 출력
__TIME__ 현재 소스 코드의 컴파일 시간을 나타내는 매크로, %s로 출력

 

#include <stdio.h>

int main()
{
	printf("파일 이름 : %s \n", __FILE__);
	printf("행 번호 : %d \n", __LINE__);
	printf("컴파일 날짜 : %s \n", __DATE__);
	printf("컴파일 시간 : %s \n", __TIME__);
	
    return 0;
}

파일 이름 : C:\Users\pgh26\Desktop\test.c
행 번호 : 6
컴파일 날짜 : Jan 26 2022
컴파일 시간 : 21:00:54

--------------------------------
Process exited after 0.004685 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

출력만 해보면 이건 알 수 있습니다.

행 번호 빼곤 전부 문자열이니깐 한번 출력해보세요!

 

오류가 발생했을때 행 번호를 출력하게 해서 오류가 어디에 있는지 바로알려줄 수 있는것으로 활용할 수 있습니다.

 

다음 편에서 계속됩니다...

 

COMMENT WRITE