[C] 전처리기: 주요 키워드와 활용 방법 소개

C 언어에서 전처리기는 컴파일러가 프로그램을 처리하기 전에 특정 단계에서 코드를 변경하거나 추가하는 역할을 합니다.

전처리기 소개

C 컴파일러의 독특한 특징 중 하나는 프로그래머가 제공한 소스 코드를 직접 처리하지 않는다는 것입니다. 컴파일 이전에 프로그램의 특별한 재작성 단계가 있습니다. 이 단계를 처리하는 유틸리티를 전처리기라고 합니다.

우리가 알아볼 것처럼, 이 전처리기는 매크로 정의, 파일 포함 또는 조건부 C 코드 컴파일과 같은 여러 가지 작업을 가능하게 합니다. 그러므로 우리는 이러한 각각의 포인트와 몇 가지 다른 것들을 하나씩 살펴볼 것입니다.

지금부터 전처리기의 모든 명령은 ‘#’ 문자로 시작한다는 점을 주목해야합니다. 이 문자를 잊어버리면 전처리기의 명령이 아니라 컴파일러가 처리하는 C 명령으로 간주될 것입니다. 그 결과 컴파일 에러가 발생합니다. 또 다른 주의점은 전처리기 명령의 ‘대소문자를 구분한다’는 것입니다. 즉, 대소문자를 혼동해서는 안 된다는 것입니다.

코드 매크로 생성

코드 매크로 생성은 컴파일 단계 이전에 코드 대체를 가능하게 합니다. 이는 코드 소스의 단일 지점에 상수 값을 집중시키는 데 유용할 수 있습니다. 이 경우 매크로는 상수의 정의로 간주되며 매크로 상수의 각 사용은 전처리기에 의해 연관된 값으로 대체됩니다. 매크로의 다른 사용 방법으로 함수를 시뮬레이션하는 것이 있습니다. 이 경우, 매크로가 함수 호출 대신 사용되는 곳에 그 코드가 삽입될 것입니다. 따라서 이 경우에는 함수 호출이 생성되지 않기 때문에 소규모 처리(최솟값, 최댓값 계산 등)에 대한 성능이 향상될 것입니다. 그러나 큰 처리에 대해서는 매크로 사용을 지나치게 하는 것은 실행 파일의 크기를 증가시키는 경향이 있으니 주의해야 합니다.

참고: 매크로를 상수 값 처리에 사용하는 경우에는 다른 동등한 방법이 있습니다. ‘const’ 키워드를 사용하여 상수를 정의하는 것입니다. 차이점은 ‘const’ 키워드를 사용할 경우, 문제가 컴파일러에서 관리되고 전처리기에서 관리되지 않는다는 것입니다. 두 가지 기술은 동등하며 각각이 보편적으로 사용됩니다.

#define

‘#define’ 명령은 매크로를 정의하는 데 사용됩니다. 앞에서 언급했듯이, 두 가지 유형의 매크로가 있습니다: 상수 매크로 및 매개변수가 있는 매크로입니다. 이를 구분하기 위해서는 매크로 이름 뒤에 괄호 사이에 매개변수가 지정되어 있는지 확인하면 됩니다. 매개변수가 지정된 경우, 매개변수가 있는 매크로입니다. 그렇지 않으면 상수 매크로입니다.

구문에 관해서는 매크로는 키워드로 시작하여 매크로 이름(필요한 경우 매개변수 목록이 함께)으로 시작하고 마지막으로 전처리기에 의해 대체될 코드로 끝납니다. 세 부분은 모두 공백으로 구분됩니다.

주의사항: C 언어의 명령과 달리 전처리기 명령은 반드시 ‘;’ 문자로 끝나야 하는 것은 아닙니다. 이 규칙을 잊어버리면 컴파일 오류가 발생하거나 프로그램 실행 중에 오동작이 발생할 가능성이 매우 높습니다.

#include <stdio.h>
#include <stdlib.h>

/* 몇 가지 매크로 정의 */
#define FALSE 0
#define TRUE 1
#define BOOL int
#define MINI( a, b ) ((a)<(b) ? (a) : (b))
#define MAXI( a, b ) ((a)>(b) ? (a) : (b))

/* 이러한 매크로의 사용 예 */
int main() {

    BOOL state = TRUE;

    if ( state == FALSE ) {
        printf( "Maximum(3, 5) == %d",  MAXI( 3, 5 ) );
    } else {
        printf( "Minimum(3, 5) == %d",  MINI( 3, 5 ) );
    }

    return EXIT_SUCCESS;
}

참고 1: 이 예에서는 매크로를 사용하여 BOOL 형식을 미리 정의 하는 것입니다. 이것은 C에는 Boolean 이 단순히 없기 때문에 많은 C 개발자들이 사용하는 전처리기 입니다.

참고 2: 상수 매크로의 이름은 대문자로 표기하는 것이 일반적입니다. 따라서 코드를 읽기 쉽게 하려면 이 규칙을 따르는 것이 좋습니다.

정보: 다음은 전처리기에 의해 처리된 후의 ‘main’ 함수의 코드입니다.
즉 위 코드는 아래 코드로 치환 됩니다.

int main() {

    int state = 1;

    if ( state == 0 ) {
        printf( "Maximum(3, 5) == %d", ((3)>(5) ? (3) : (5)) );
    } else {
        printf( "Minimum(3, 5) == %d", ((3)<(5) ? (3) : (5)) );
    }

    return 0;
}

#undef

전처리기 #undef는 매크로의 정의를 취소하는 지시문입니다. 이 지시문은 매크로를 정의한 후에 사용되어 해당 매크로를 더 이상 사용하지 않겠다고 명시하는 역할을 합니다. 이렇게 하면 컴파일러가 그 이후부터 해당 매크로를 인식하지 앖습니다.

undef 이후에 매크로 사용은 더 이상 존재하지 않으므로 컴파일 오류가 발생합니다. 매크로와 이름이 같은 요소가 프로그램에 존재하지 않는 한(아래 예의 경우와 같이). 매크로가 변수와 같은 이름을 가질 수 이습니다.

아래 예제에서 전처리기와 컴파일러는 서로 다른 두 시간에 실행되는 두 개의 프로세스이므로 동일한 이름을 가진 이 두 요소가 공존할 수 있기 때문에 가능합니다.

#include <stdio.h>
#include <stdlib.h>

int main() {

    int a = 123;

    #define a 456
    printf( "a == %d\n", a );
    #undef a

    printf( "a == %d\n", a );

    return EXIT_SUCCESS;
}

이 프로그램을 컴파일하고 실행하면 다음과 같은 결과가 반환됩니다.

a == 456
a == 123

미리 정의된 매크로

미리 정의되어 있고 이를 통해 여러 매크로를 알 수 있습니다. 것. 이러한 매크로는 어떤 식으로든 삭제하거나 변경할 수 없습니다. 따라서 변경할 수 없습니다. 다음 표에서 알려줍니다 는 이러한 매크로의 이름과 개발을 제공합니다.

매크로 이름 설명
__cplusplus  C++로 컴파일되는 경우 정수 리터럴 값으로 정의됩니다. 그 이외의 경우에는 정의되지 않습니다.
__DATE__  현재 소스 파일의 컴파일 날짜입니다. 날짜는 Mmm dd yyyy 형식의 상수 길이 문자열 리터럴입니다. 
__FILE__  현재 소스 파일의 이름입니다.
__LINE__  현재 소스 파일의 정수 줄 번호로 정의됩니다. 
__STDC__  C로 컴파일되고 컴파일러 옵션이 지정된 경우 /Za 1로 정의됩니다. Visual Studio 2022 버전 17.2부터는 C로 컴파일할 때와 컴파일러 옵션이 지정된 경우에도 /std:c11/std:c17 1로 정의됩니다. 그 이외의 경우에는 정의되지 않습니다.
__STDC_HOSTED__  구현이 전체 필수 표준 라이브러리를 지원하는 ‘호스트된 구현’인 경우 1로 정의됩니다. 그 이외의 경우에는 0으로 정의됩니다.
__STDC_NO_ATOMICS__  구현이 선택적 표준 원자성을 지원하지 않는 경우 1로 정의됩니다. MSVC 구현은 C로 컴파일되고 /std C11 또는 C17 옵션 중 하나가 지정되는 경우 1로 정의됩니다.
__STDC_NO_COMPLEX__  구현이 선택적 표준 복소수를 지원하지 않는 경우 1로 정의됩니다. MSVC 구현은 C로 컴파일되고 /std C11 또는 C17 옵션 중 하나가 지정되는 경우 1로 정의됩니다.
__STDC_NO_THREADS__  구현이 선택적 표준 스레드를 지원하지 않는 경우 1로 정의됩니다. MSVC 구현은 C로 컴파일되고 /std C11 또는 C17 옵션 중 하나가 지정되는 경우 1로 정의됩니다.
__STDC_NO_VLA__  구현이 표준 가변 길이 배열을 지원하지 않는 경우 1로 정의됩니다. MSVC 구현은 C로 컴파일되고 /std C11 또는 C17 옵션 중 하나가 지정되는 경우 1로 정의됩니다.
__STDC_VERSION__  C로 컴파일되고 /std C11 또는 C17 옵션 중 하나가 지정되는 경우 정의됩니다. 201112L로 확장(/std:c11의 경우)되거나 201710L로 확장(/std:c17의 경우)됩니다.
__STDCPP_DEFAULT_NEW_ALIGNMENT__ /std:c17 지정하거나 나중에 지정하면 이 매크로는 맞춤을 인식하지 않는 operator new호출에 의해 보장되는 맞춤 값이 있는 리터럴로 확장 size_t 됩니다. 더 큰 맞춤은 맞춤 인식 오버로드(예: .)에 operator new(std::size_t, std::align_val_t)전달됩니다. 자세한 내용은 (C++17 과잉 정렬 할당)을 참조 /Zc:alignedNew 하세요.
__STDCPP_THREADS__  프로그램에 실행 스레드가 두 개 이상 포함될 수 있으며 C++로 컴파일되는 경우에만 1로 정의됩니다. 그 이외의 경우에는 정의되지 않습니다.
__TIME__  전처리된 변환 단위를 변환하는 시간입니다. 시간은 hh:mm:ss 형식의 문자열 리터럴로, CRT asctime 함수에서 반환하는 시간과 동일합니다. 

파일 포함

#include

매크로 코드 생성 외에도 전처리기는 다른 가능성을 가능하게 합니다. 이러한 가능성 중 하나는 파일을 포함하는 것입니다. C에서는 이 메커니즘 덕분에 모듈식 프로그래밍을 수행할 수 있어야 합니다. 실제로 단일 파일에 200만 줄의 코드를 인코딩하는 것은 단일 파일이므로 매우 문제가 됩니다(컴파일 시간이 매우 길고, 프로그램의 유지 보수성이 매우 좋지 않습니다 …). 따라서 이러한 프로그램을 여러 파일에 분산시키는 것이 좋습니다.
파일을 분산하고 헤더파일을 만들고 헤더에 프로토타입 선언등을 하게 되며 다른 파일에서 파일을 포함하여 컴파일을 하게 됩니다.
사실, 우리는 한동안 이 옵션을 사용해 왔습니다. 다음은 그 예제입니다.

#include <stdio.h>

이 명령은 stdio.h 파일을 그대로 포함 하겠다는 의미입니다.

보통 #include는 <filename>"filename" 두 가지 형태로 사용됩니다. <filename>은 컴파일러가 제공하는 기본 헤더 파일 또는 경로가 설정된 위치의 헤더파일을 포함하는 것이며, "filename"은 경로가 설정되지 않은 파일을 포함할때 사용합니다.

중복 포함 방지

#ifndef _STDIO_H
#define _STDIO_H   1

...

#endif /* !_STDIO_H */

헤더 파일의 중복 포함은 코드의 정확성을 해치고 오류를 일으킬 수 있습니다. 이를 방지하기 위해 매크로 메커니즘을 활용하여 중복 포함을 방지할 수 있습니다. 매크로를 이용한 중복 포함 방지는 코드의 안정성을 높이고 오류를 방지하는 데에 유용합니다.

조건문 컴파일

조건부 컴파일은 프로그래밍에서 특정 조건에 따라 코드를 컴파일하는 기술입니다. 이 기법은 코드의 유연성을 높이고, 여러 환경에서 동일한 코드를 사용할 수 있도록 해줍니다.

C 언어에서 조건부 컴파일은 #ifdef, #ifndef, #if, #else, #elif, #endif와 같은 전처리기 지시문을 사용하여 구현됩니다. 이를 활용하면 특정 조건이 충족될 때에만 코드 블록을 컴파일할 수 있습니다.

예를 들어, 디버그용 코드는 실제 배포되는 제품에는 포함되지 않아야 할 때 사용됩니다. 이때 #ifndef NDEBUG과 같은 구문을 사용하여 디버그 코드를 포함하거나 제외할 수 있습니다.

또 다른 사용 사례는 여러 플랫폼에 대응하는 코드를 작성할 때입니다. 예를 들어 리눅스와 윈도우즈 플랫폼에 따라 다른 코드를 사용해야 할 때, 조건부 컴파일을 활용하여 각 플랫폼에 맞는 코드 블록을 선택적으로 컴파일할 수 있습니다.

이러한 기술을 사용하면 하나의 코드 베이스에서 여러 조건을 고려하여 코드를 관리할 수 있습니다. 이는 코드의 유지보수를 용이하게 하고, 다양한 환경에서 손쉽게 작동할 수 있는 소프트웨어를 개발하는 데 도움이 됩니다.

#ifdef MACRO_NAME
#if defined MACRO_NAME

#ifndef MACRO_NAME
#if !defined MACRO_NAME

참고: 여기서 ‘!’ 문자는 부정 연산자를 의미합니다. 따라서 마지막 줄은 ‘만약 정의되지 않았다면’이라고 읽을 수 있습니다.

다음은 디버그 모드로 컴파일될 때에만 특정 메시지를 표시하는 구체적인 예시입니다.

#include <stdio.h>
#include <stdlib.h>

int main() {

    #ifndef NDEBUG
    printf( "디버그 모드 활성화됨\n" );
    #endif

    printf( "애플리케이션 코드\n" );

    return EXIT_SUCCESS;
}

기타 전처리기

프리프로세서 C는 이미 제안된 몇 가지 명령 외에도 몇 가지 추가적인 명령을 제공합니다. 이미 제안된 명령보다는 중요도가 낮지만 특정한 경우에 유용할 수 있습니다.

#line 명령어

일반적으로 프리프로세서는 각 파일 요소에 연결되어야 하는 줄 번호를 C 컴파일러에게 알려줍니다. 이것은 중요합니다. 왜냐하면 컴파일 체인 입력으로 제공하는 소스 코드 버전과 프리프로세서가 생성하는 코드 간에는 매우 큰 차이가 있을 수 있기 때문입니다. 오류나 경고가 발생할 경우 임시 파일 대신에 소스 파일에 대한 번호가 제공된다면 코드 소스에서 문제를 찾는 데 큰 어려움이 있을 수 있습니다. (특히 파일에 중간에 삽입되는 () 인클루드의 경우 총 줄 수를 변경시켜 버림을 고려해 보세요). 이 사이트에서 제공된 예제 세트에서는 이 명령을 사용하지 않았습니다. 왜냐하면 프리프로세서가 이러한 측면을 아주 잘 다루기 때문입니다.

하지만 언젠가는 이러한 줄 번호를 제어해야 할 수도 있습니다. 이를 위한 명령어가 있습니다. 이 명령은 새로운 줄 번호와 필요한 경우 다른 파일 이름을 지정할 수 있게 해줍니다(다른 포함 파일에서 발생한 오류를 보고하는 데 사용됨). 새로운 줄 번호가 지정되면 그 다음 줄부터 해당 번호가 적용되며, 그 뒤에 오는 줄들은 이 새로운 줄 번호에 따라 증가됩니다. 이 명령어의 두 가지 사용 예를 살펴봅시다.

** #line 명령어의 사용법 **

  • #line 번호
  • #line 번호 "파일_이름"

#error 명령어

이 명령어는 줄 끝에 오류 메시지를 표시하는 데 사용됩니다.

** #error 명령어 사용법 **

  • #error 여기에 오류 메시지를 입력하세요

#pragma 명령어

이 명령어는 컴파일러에 추가 정보를 전달하는 데 사용됩니다. 예를 들어 일부 컴파일러는 생성하는 경고 유형을 제거하는 것을 허용합니다. C99 표준은 언어에 키워드의 존재를 요구하지만, 컴파일러 개발 팀은 자신들의 필요에 따라 이 구문을 사용할 수 있습니다. 만약 지정된 pragma가 컴파일러에서 인식되지 않는다면 무시됩니다. 아래 예제는 마이크로소프트의 C 컴파일러로 사용하지 않는 로컬 변수 경고를 제거하는 방법을 보여줍니다.

** #pragma 명령어 사용법 **

  • #pragma warning( disable : 4101 )

결론

C 에서의 전처리기는 코드 작성 중에 다양한 상황에서 유용하게 활용될 수 있습니다. 이러한 명령들을 잘 활용하여 코드 작성의 효율성을 높여보세요.

Leave a Comment