티스토리 뷰
주의 사항!
- 이 일지는 작성하고 있는 현시점, 공부와 병행하면서 작성되고 있습니다.
- 공부 중에 떠오른 생각이나 그때그때의 개념정리 같은 내용이 포함됩니다.
- 따라서 이 일지의 내용은 제가 공부하고 이해한 대로 작성되기 때문에 실제 사실과는 다를 수 있습니다.
프로그램은 컴파일 환경을 바꾸거나 여러 개의 모듈로 나누어 작성할 때 이식성과 호환성을 고려해야 합니다.
따라서 컴파일하기 전에 컴파일 환경에 맞게 소스코드를 편집할 수 있는 기능이 필요합니다.
이번 시간에는 그 기능을 담당하는 전처리 지시자의 종류와 기능을 살펴봅니다.
지금까지 코드의 첫 줄에서 항상 사용해온 #include도 전처리 지시자입니다.
#include는 꺽쇠괄호 안의 파일의 내용을 읽어와 지시자가 있는 위치에 붙여 넣습니다.
예제를 통해 사용법을 살펴보겠습니다.
typedef struct
{
int num;
char name[20];
} Student;
먼저 헤더파일 목록에 새 항목을 추가하여 student.h 라는 이름의 헤더파일을 만들어줍니다.
그리고 해당 파일에 위와 같이 구조체를 선언하고 재정의합니다.
이후 소스파일로 돌아와 다음과 같이 코드를 작성해 줍니다.
#include <stdio.h>
#include "student.h"
int main(void)
{
Student a = { 315, "홍길동" };
printf("학번 : %d, 이름 : %s\n", a.num, a.name);
return 0;
}
두 번째 줄을 보면 아까 만든 student.h의 헤더파일을 전처리 지시자 #include를 통해 불러오고 있습니다.
해당 소스코드에 Student라는 구조체를 선언하지 않아도 전처리 지시자를 통해 구조체가 선언된 헤더파일을 불러왔기 때문에 이후부터는 해당 구조체를 사용할 수 있게 되었습니다.
이는 사용자 정의 헤더파일을 사용하는 방법입니다.
다시 student.h 파일을 불러오는 줄을 잘 보면 stdio.h파일을 불러오는 것과는 달리 큰따옴표가 사용되었습니다.
꺽쇠괄호로 헤더파일을 묶으면 헤더파일들이 모여 있는 include 디렉터리에서 해당 헤더파일을 찾습니다.
반면 큰 따옴표로 묶으면 소스파일이 저장된 디렉터리에서 먼저 찾습니다.
만약 해당 파일이 없으면 앞서 얘기한 include디렉터리에서 다시 한 번 찾습니다.
따라서 이 경우는 주로 사용자가 직접 만든 헤더 파일을 포함할 때 사용합니다.
다른 디렉터리에 있는 파일을 직접 포함할 때는 경로를 포함한 파일명을 사용합니다.
헤더파일을 사용하면 프로그램을 깔끔하고 편하게 작성할 수 있습니다.
보통 하나의 프로그램은 독립적으로 컴파일 가능한 파일 단위인 모듈로 나누어 분할 컴파일합니다.
따라서 각 모듈이 같이 사용하는 구조체나 함수, 전역 변수의 경우 각 선언을 하나의 헤더파일로 만들면
필요한 모듈에서 쉽게 포함하여 쓸 수 있습니다.
이 경우 헤더 파일의 내용이 수정되더라도 컴파일만 다시 하면 수정된 내용이 모든 파일에 동시에 적용되므로
빠르고 정확하게 수정할 수 있습니다.
#include는 파일의 내용을 단순히 복사하여 붙여 넣는 기능입니다.
따라서 텍스트 형태의 파일이면 모두 똑같은 방법으로 사용할 수 있습니다.
심지어 소스파일도 포함할 수 있습니다.
그리고 함수 중간에 들어가는 코드를 따로 떼어 헤더파일로 만들고,
함수 안에서 이를 인클루드해 사용하는 것도 가능합니다.
전처리 과정은 소스 파일에 다른 파일의 내용을 포함시키거나 일부 문장을 다른 문장으로 바꾸는 등
소스파일을 편집하는 일을 주로 수행하므로 전처리된 후의 파일도 소스 파일과 형태가 같은 텍스트 파일입니다.
#define은 매크로명을 정의하는 전처리 지시자입니다.
매크로명은 다은 변수명과 쉽게 구분할 수 있도록 관례상 대문자로 쓰며,
치환될 부분은 매크로명과 하나 이상의 빈 칸을 둡니다.
예제를 통해 사용법을 살펴보겠습니다.
#include <stdio.h>
#define PI 3.1415926535
#define LIMIT 100.0
#define MSG "passed!"
#define ERR_PRN printf("범위를 벗어났습니다.!\n");
int main(void)
{
double radius, area;
printf("반지름을 입력하세요 : ");
scanf("%lf", &radius);
area = PI * radius * radius;
if (area > LIMIT)
{
ERR_PRN;
}
else
{
printf("원의 면적 : %.2lf (%s)\n", area, MSG);
}
return 0;
}
/*
실행결과
반지름을 입력하세요 : 5
원의 면적 : 78.54 (passed!)
*/
/*
실행결과
반지름을 입력하세요 : 8
범위를 벗어났습니다.!
*/
#define의 사용법은 어렵지 않습니다.
두 번째 줄을 살펴보면
#define(매크로를 정의하겠다) PI(매크로명은 PI다) 3.1415926535(매크로명 PI로 3.1415926535를 대신한다)
라고 볼 수 있습니다.
이후 main 함수에서 3.1415926535를 직접 사용하는 대신 PI라는 매크로명을 이용해 사용했습니다.
만약 매크로명을 정의할 때 치환될 부분이 너무 길어 여러 줄에 써야 한다면 백슬래시로 연결합니다.
#define INTRO "Perfect C Languege \
& Basic Data Structure"
매크로명은 자주 사용하는 복잡한 숫자나 문자열 등을 의미 있는 단어로 쉽게 표현할 수 있습니다.
그러나 문제가 발생하면 매크로명이 어떤 형태로 치환되는지 다시 확인해야 하므로 디버깅과 유지보수가 힘듭니다.
컴파일러는 전처리가 끝난 후에 치환된 소스코드로 컴파일하고,
사용자는 매크로명으로 작성된 소스코드를 보게 되므로
컴파일러가 표시하는 에러 메시지를 소스코드에서 즉시 확인하기가 힘듭니다.
따라서 매크로명은 필요한 경우에만 제한적으로 사용하는 것이 좋습니다.
#define을 사용해서 매크로 함수도 만들 수 있습니다.
매크로 함수는 비록 함수는 아니지만, 인수에 따라 서로 다른 결과값을 갖도록 치환되므로 함수처럼 사용할 수 있습니다.
예제를 통해 사용법과 장단점을 살펴보겠습니다.
#include <stdio.h>
#define SUM(a, b) ((a) + (b))
#define MUL(a, b) ((a) * (b))
int main(void)
{
int a = 10, b = 20;
int x = 30, y = 40;
int res;
printf("a + b = %d\n", SUM(a, b));
printf("x + y = %d\n", SUM(x, y));
res = 30 / MUL(2, 5);
printf("res : %d\n", res);
return 0;
}
/*
실행결과
a + b = 30
x + y = 70
res : 3
*/
2행과 3행에서 #define을 사용해 매크로 함수를 만들었습니다.
그런데 치환될 부분을 보면 괄호가 꽤 많이 들어간 것을 확인할 수 있습니다.
3행에서 만약 치환될 부분을 괄호 없이 x*y로 적는다면 어떻게 될까요?
res = 30 / MUL(2, 5);
위의 코드는 전처리를 거치면서 다음과 같이 바뀌게 됩니다.
res = 30 / 2*5;
이렇게 되면 문제가 생깁니다.
나누기와 곱하기는 연산에서 순위가 같기 때문에 가장 앞에서부터 계산이 수행됩니다.
따라서 30 / 2 = 15, 15 * 5 = 75가 되어 res는 75가 됩니다.
그렇다면 다시 3행에서 치환될 부분을 (x * y)로 적으면 문제가 해결될까요?
이때는 다른 문제가 생깁니다.
만약 다음과 같이 인수에 수식이 들어가면 어떻게 될까요?
res = 30 / MUL(2, 2 + 3);
이는 전처리 과정을 거치면서 다음과 같이 바뀝니다.
res = 30 / 2 * 2 + 3;
역시 계산은 개발자가 원하는 대로 수행되지 못하고 res는 33이 됩니다.
매크로 함수는 함수처럼 쓰이지만 치환된 후 문제를 예측하기가 쉽지 않습니다.
또한 많은 기능을 매크로 함수로 구현하기 힘들고 수정하기도 쉽지 않습니다.
그러나 매크로 함수는 함수 호출보다 상대적으로 실행 속도가 빠릅니다.
따라서 크기가 작은 함수를 자주 호출한다면 매크로 함수가 도움이 될 것입니다.
매크로에는 이미 그 정의가 약속되어 있어 사용자가 취소하거나 바꿀 수 없는 매크로명이 있습니다.
그 종류는 다양하고 컴파일러나 버전에 따라 다를 수 있습니다.
예제를 통해 디버깅에 유용한 몇 가지 이미 정의된 매크로를 살펴보겠습니다.
#include <stdio.h>
void func(void);
int main(void)
{
printf("컴파일 날짜와 시간 : %s, %s\n\n", __DATE__, __TIME__);
printf("파일명 : %s\n", __FILE__);
printf("함수명 : %s\n", __FUNCTION__);
printf("행번호 : %d\n", __LINE__);
#line 100 "macro.c"
func();
return 0;
}
void func(void)
{
printf("\n");
printf("파일명 : %s\n", __FILE__);
printf("함수명 : %s\n", __FUNCTION__);
printf("행번호 : %d\n", __LINE__);
}
/*
실행결과
컴파일 날짜와 시간 : Feb 4 2021, 21:12:07
파일명 : D:\sutudyC\CPart1\CPart1\Part1.c
함수명 : main
행번호 : 10
파일명 : macro.c
함수명 : func
행번호 : 110
*/
__DATE__는 컴파일된 날짜로 치환됩니다.
__TIME__은 컴파일된 시간으로 치환됩니다.
__FILE__은 프로그램의 전체 디렉터리 경로를 포함한 파일명으로 치환됩니다.
__FUNCTION__은 해당 매크로명이 사용된 함수명으로 치환됩니다.
__LINE__은 해당 매크로명이 사용된 행번호로 치환됩니다.
#line은 행번호의 시작번호와 파일명을 새로 설정할 수 있습니다.
#line 100 "macro.c"
예제에서 위와 같이 사용했습니다.
시작 행번호를 100으로 설정했으므로 그 다음 줄이 행번호 100이 되고 이후 1씩 증가합니다.
그리고 파일명을 macro.c로 바꾸었습니다.
__FILE__은 기본적으로 경로까지 포함한 파일명으로 치환되어 복잡합니다.
이를 #line 지시자에서 파일명을 새로 표시하면 간단한 파일명으로 치환할 수 있습니다.
#line 지시자를 사용할 때 행번호만 바꾸고 싶으면 파일명은 생략할 수 있으나
파일명만 사용하는 것은 불가능합니다.
이 매크로들을 사용하면 프로그램이 실행 중 갑자기 종료되는 경우 함수명이나 행 번호를 출력하여
어디까지 진행되었는지 확인하는 용도로 쓸 수 있습니다.
매크로 함수를 만들 때 매크로 연산자를 사용하면 인수를 특별한 방법으로 치환할 수 있습니다.
매크로 연산자는 #과 ##이 있습니다.
#은 매크로 함수의 인수를 문자열로 치환하고, ##은 두 인수를 붙여서 치환합니다.
예제를 통해 살펴보겠습니다.
#include <stdio.h>
#define PRINT_EXPR(x) printf(#x " = %d\n", x);
#define NAME_CAT(x, y) (x##y)
int main(void)
{
int a1, a2;
NAME_CAT(a, 1) = 10;
NAME_CAT(a, 2) = 20;
PRINT_EXPR(a1 + a2);
PRINT_EXPR(a2 - a1);
return 0;
}
/*
실행결과
a1 + a2 = 30
a2 - a1 = 10
*/
NAME_CAT(a, 1) = 10;
NAME_CAT(a, 2) = 20;
NAME_CAT 매크로 함수는 ##연산자를 사용했습니다.
인수로 a와 1을 주었는데 이 사이에 ##연산자가 있어 a와 1이 서로 붙어 a1으로 치환됩니다.
#define PRINT_EXPR(x) printf(#x " = %d\n", x);
PRINT_EXPR(a1 + a2);
PRINT_EXPR(a2 - a1);
PRINT_EXPR 매크로 함수는 #연산자를 사용했습니다.
이 연산자로 인해 a1+a2를 인수로 주었을 때 이들을 큰 따옴표로 묶여 문자의 형태로 치환되어 사용됐습니다.
#연산자를 사용하지 않은 곳은 a1+a2가 연산되어 그 결과값으로 사용되었습니다.
조건부 컴파일 지시자는 소스코드를 조건에 따라 선택적으로 컴파일합니다.
이때 #if, #else, #elif, #ifdef, #ifndef, #endif 등의 전처리 지시자를 다양한 방법으로 조합하여 사용합니다.
예제를 통해 사용법을 알아보겠습니다.
#include <stdio.h>
#define VER 7
#define BIT16
int main(void)
{
int max;
#if VER >= 6
printf("버전 %d입니다.\n", VER);
#endif
#ifdef BIT16
max = 32767;
#else
max = 2147483647;
#endif
printf("int형 변수의 최댓값 : %d\n", max);
return 0;
}
/*
실행결과
버전 7입니다.
int형 변수의 최댓값 : 32767
*/
사용법은 if조건문과 비슷합니다.
#if는 if, #elif는 else if, #else는 else와 쓰임새가 같습니다.
하지만 차이점도 있습니다.
우선 조건식에는 정수나 정수로 치환될 매크로 상수만으로 만들어진 수식을 사용합니다.
또 조건식을 괄호로 묶지 않고 실행문도 중괄호로 묶지 않습니다.
또 if 조건문은 if만 사용하는 것도 가능했지만 조건부 전처리 지시자는 마지막에 꼭 #endif를 붙여주어야 합니다.
조건식에 매크로명이 정의되어 있는지 확인하려면 #ifdef를 사용합니다.
#ifndef는 반대의 경우, 즉 매크로명이 정의되어 있지 않은지 확인할 때 사용합니다.
이때 #if와 defined를 묶어서 #ifdef와 같이 사용할 수도 있습니다.
따라서 #if defined BIT16과 #ifdef BIT16은 같은 역할을 합니다.
#ifndef는 #if !defined로 사용할 수 있습니다.
이 둘은 같아 보이지만 다른 점이 있습니다.
#ifdef나 #ifndef는 오로지 매크로가 정의되어 있는지만 확인할 수 있습니다.
따라서 그 외 수식을 사용할 땐 다음의 예와 같이 사용할 수 있습니다.
#if (defined(BIT16) && VER >= 6)
컴파일할 문장
#endif
조건을 만족하지 않는 경우 컴파일 자체를 중단할 때는 #error 지시자를 사용합니다.
#include <stdio.h>
#define VER 5
#define BIT16
int main(void)
{
int max;
#if VER >= 6
printf("버전 %d입니다.\n", VER);
#else
#error 컴파일 버전은 6.0이상이어야 합니다.
#endif
#ifdef BIT16
max = 32767;
#else
max = 2147483647;
#endif
printf("int형 변수의 최댓값 : %d\n", max);
return 0;
}
/*
실행결과
빌드 시작...
1>------ 빌드 시작: 프로젝트: CPart1, 구성: Debug Win32 ------
1>Part1.c
1>D:\sutudyC\CPart1\CPart1\Part1.c(12,1): fatal error C1189: #error: 컴파일 버전은 6.0이상이어야 합니다.
1>"CPart1.vcxproj" 프로젝트를 빌드했습니다. - 실패
========== 빌드: 성공 0, 실패 1, 최신 0, 생략 0 ==========
*/
위의 예처럼 #error지시자를 추가하고 VER을 5로 낮추면
컴파일하는 단계에서부터 아래쪽 출력창에 위와 같은 메세지가 출력되며 컴파일을 종료합니다.
이때 #error 옆에 적었던 메세지가 오류 메세지로 출력됩니다.
조건부 컴파일은 프로그램의 호환성을 좋게 합니다.
C언어의 기본 문법은 같지만 컴파일러와 운영체제에 따라
자료형의 크기나 지원되는 라이브러리 함수가 다를 수 있습니다.
따라서 조건부 컴파일 지시자를 사용하여 컴파일 코드를 구별하면
서로 다른 컴파일러에서 컴파일이 가능한 코드를 만들 수 있습니다.
#pragma 지시자는 컴파일러의 컴파일 방법을 세부적으로 제어할 때 사용합니다.
사용법은 지시명을 통해 컴파일러의 어떤 기능을 제어할 것인지 알려줍니다.
pack은 구조체의 패딩바이트의 크기를 결정하며, warning은 경고 메세지를 관리합니다.
예제를 통해 살펴보겠습니다.
#include <stdio.h>
#pragma pack(push, 1)
typedef struct
{
int in;
char ch;
} Sample1;
#pragma pack(pop)
typedef struct
{
int in;
char ch;
} Sample2;
int main(void)
{
printf("Sample1 구조체의 크기 : %d바이트\n", sizeof(Sample1));
printf("Sample2 구조체의 크기 : %d바이트\n", sizeof(Sample2));
return 0;
}
/*
실행결과
Sample1 구조체의 크기 : 5바이트
Sample2 구조체의 크기 : 8바이트
*/
#pragma pack은 바이트 얼라인먼트 단위 크기를 결정합니다.
#pragma pack(push, 1)
여기서 바이트 얼라인먼트의 크기를 1로 설정했습니다.
그리고 1로 설정하기 이전의 값을 기억하기 위해 push를 사용했습니다.
바이트 얼라인먼트의 크기를 1로 설정하고 구조체를 선언하면 1바이트 단위로 구조체의 멤버를 저장합니다.
따라서 구조체의 크기는 int형 4바이트 + char형 1바이트로 해서 5바이트가 됩니다.
#pragma pack(pop)
이후 #pragma pack에 pop을 인수로 주어 이전에 push로 기억했던 값을 다시 불러와 설정합니다.
따라서 이후 선언된 구조체의 크기는 패딩바이트가 3바이트 추가되어 8바이트가 되었습니다.
posh와 pop을 사용하지 않고 바이트 얼라인먼트의 크기만 사용하는 것도 가능합니다.
warning은 컴파일러가 표시하는 경고 메시지를 제거하는 데 쓸 수 있습니다.
VC++컴파일러는 scanf 나 gets 함수와 같이 포인터를 사용하는 함수의 안전성을 걱정하는 경고 메시지를 표시합니다.
warning C4996: 'scanf' : This function or variable may be unsafe. warning C4996: 'gets' : This function or variable may be unsafe. |
이들 경고를 무시하고자 한다면 다음과 같이 컴파일러에 지시할 수 있습니다.
#pragma warning(disable:4996)
다음 시간에는 분할 컴파일에 대해 공부하겠습니다.
'공부 일지 > C언어 공부 일지' 카테고리의 다른 글
전처리와 분할 컴파일 실전문제1 (0) | 2021.02.05 |
---|---|
분할 컴파일 (0) | 2021.02.05 |
파일 입출력 실전문제3 (0) | 2021.02.04 |
파일 입출력 실전문제2 (2) | 2021.02.01 |
파일 입출력 실전문제1 (0) | 2021.02.01 |