C/C++에서는 컴파일러로 소스 코드가 전달되기 전 전처리(preprocessing) 과정을 거친다. C 전처리기는 #로 시작하는 지시자(directive)를 제거하고 C 코드에 의해 생성된 동일한 내용으로 변환한 후 해당 코드를 컴파일러로 전달한다.
매크로는 다음과 같이 활용된다.
#define 지시자로 정의하며, 각 매크로는 이름과 매개변수(parameter) 리스트, 값(value)을 갖는다. 값은 매크로 확장 단계를 통해 전처리 단계에서 매크로 이름으로 대체될 수 있다. #undef 지시자로는 매크로의 정의를 제거할 수 있다.
코드 박스 1-1 [예제 1-1] 매크로 정의하기
#define ABC 5
int main(int argc, char** argv) {
int x = 2;
int y = ABC;
int z = x + y;
return 0;
}
코드 박스 1-2 매크로 확장 단계 이후 [예제 1-1]에서 생성된 코드
int main(int argc, char** argv) {
int x = 2;
int y = 5;
int z = x + y;
return 0;
}
전처리 결과 매크로가 제거되면서 값으로 치환된다.
코드 박스 1-3 [예제 1-2] 유사 함수 매크로 정의하기
#define ADD(a, b) (a + b)
int main(int argc, char** argv) {
int x = 2;
int y = 3;
int z = ADD(x, y);
return 0;
}
코드 박스 1-4 전처리와 매크로 확장 이후 코드
int main(int argc, char** argv) {
int x = 2;
int y = 3;
int z = (x + y);
return 0;
}
ADD는 함수가 아닌 인수(argument)를 전달받는 유사 함수 매크로(function-like macro)이다. 매크로는 컴파일 전 코드를 생성(generate)할 수 있도록 하며, 다른 프로그래밍 언어에서는 이를 위해 코드 생성기(code generator)가 필요하다.
그러나 현대 C 컴파일러는 전처리 단계에 대해 인지하고 있으며 C 전처리기 지시자가 포함된 전처리 단계 이전의 소스 코드를 알고 있다.
코드 박스 1-5 선언되지 않은 식별자 오류가 발생하는 매크로 정의
#include <stdio.h>
#define CODE \
printf("%d\n", i);
int main(int argc, char** argv) {
CODE
return 0;
}
셀 박스 1-1 매크로 정의를 나타내는 컴파일 출력
$ clang example.c
example.c:7:5: error: use of undeclared identifier 'i'
7 | CODE
| ^~~~
example.c:4:16: note: expanded from macro 'CODE'
4 | printf("%d\n", i);
| ^
1 error generated.
변환 단위(translation unit) 또는 컴파일 단위(compilation unit)란 전처리된 C 코드이다. 모든 지시자가 포함(inclusion)되거나 매크로 확장으로 대체되며 단 한 줄의 긴 C 코드가 생성된다.
코드 박스 1-6 [예제 1-3] 루프를 생성하는 매크로 사용하기
#include <stdio.h>
#define PRINT(a) printf("%d\n", a);
#define LOOP(v, s, e) for (int v = s; v <= e; ++v) {
#define ENDLOOP }
int main(int argc, char** argv) {
LOOP(counter, 1, 10)
PRINT(counter)
ENDLOOP
return 0;
}
코드 박스 1-7 전처리 단계 이후의 [예제 1-3]
...
... content of stdio.h ...
...
int main(int argc, char** argv) {
for (int counter = 1; counter <= 10; ++counter) {
printf("%d\n", counter);
}
return 0;
}
위와 같이 매크로를 활용해 그 자체로는 C의 문법이 아니지만 전처리 이후 C의 문법을 준수하도록 할 수 있다. 이는 도메인 특화 언어(DSL: Domain Specific Language)를 정의할 때 사용되는 매크로 활용법이다.
코드 박스 1-8 [예제 1-4] 매크로에서 #과 ## 연산자 사용하기
#include <stdio.h>
#include <string.h>
#define CMD(NAME) \
char NAME ## _cmd[256] = ""; \
strcpy(NAME ## _cmd, #NAME);
int main(int argc, char** argv) {
CMD(copy)
CMD(paste)
CMD(cut)
char cmd[256];
scanf("%s", cmd);
if (strcmp(cmd, copy_cmd) == 0) {
// ...
}
if (strcmp(cmd, paste_cmd) == 0) {
// ...
}
if (strcmp(cmd, cut_cmd) == 0) {
// ...
}
return 0;
}
코드 박스 1-9 전처리 단계 이후의 [예제 1-4]
...
... content of stdio.h ...
...
... content of string.h ...
...
int main(int argc, char** argv) {
char copy_cmd[256] = ""; strcpy(copy_cmd, "copy");
char paste_cmd[256] = ""; strcpy(paste_cmd, "paste");
char cut_cmd[256] = ""; strcpy(cut_cmd, "cut");
char cmd[256];
scanf("%s", cmd);
if (strcmp(cmd, copy_cmd) == 0) {
}
if (strcmp(cmd, paste_cmd) == 0) {
}
if (strcmp(cmd, cut_cmd) == 0) {
}
return 0;
}
매크로 확장 시 # 연산자는 매개변수를 한 쌍의 따옴표로 둘러싼 문자로 변환하고, ## 연산자는 매크로의 매개변수와 다른 요소를 문자열로 결합해 변수 이름을 생성한다.
가변 인자 매크로(variable macro)는 가변 인수(variable argument)를 받을 수 있다.
코드 박스 1-10 [예제 1-5] 가변 인자 매크로의 정의와 사용
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define VERSION "2.3.4"
#define LOG_ERROR(format, ...) \
fprintf(stderr, format, __VA_ARGS__)
int main(int argc, char** argv) {
if (argc < 3) {
LOG_ERROR("Invalid number of arguments for version %s\n", VERSION);
exit(1);
}
if (strcmp(argv[1], "-n") != 0) {
LOG_ERROR("%s is a wrong param at index %d for version %s.", argv[1], 1, VERSION);
exit(1);
}
// ...
return 0;
}
코드 박스 1-11 전처리 단계 이후의 [예제 1-5]
...
... content of stdio.h ...
...
... content of stdlib.h ...
...
... content of string.h ...
...
int main(int argc, char** argv) {
if (argc < 3) {
fprintf(stderr, "Invalid number of arguments for version %s\n.", "2.3.4");
exit(1);
}
if (strcmp(argv[1], "-n") != 0) {
fprintf(stderr, "%s is a wrong param at index %d for version %s.", argv[1], 1, "2.3.4");
exit(1);
}
// ...
return 0;
전처리기는 __VA_ARGS__ 식별자를 아직 매개변수에 할당되지 않은 나머지 모든 입력 인수로 교체한다.
코드 박스 1-12 [예제 1-6] 루프를 모방한 가변 인자 매크로 사용하기
#include <stdio.h>
#define LOOP_3(X, ...) \
printf("%s\n", #X);
#define LOOP_2(X, ...) \
printf("%s\n", #X); \
LOOP_3(__VA_ARGS__)
#define LOOP_1(X, ...) \
printf("%s\n", #X); \
LOOP_2(__VA_ARGS__)
#define LOOP(...) \
LOOP_1(__VA_ARGS__)
int main(int argc, char** argv) {
LOOP(copy paste cut)
LOOP(copy, paste, cut)
LOOP(copy, paste, cut, select)
return 0;
}
코드 박스 1-13 전처리 단계 이후의 [예제 1-6]
...
... content of stdio.h ...
...
int main(int argc, char** argv) {
printf("%s\n", "copy paste cut"); printf("%s\n", ""); printf("%s\n", "");
printf("%s\n", "copy"); printf("%s\n", "paste"); printf("%s\n", "cut");
printf("%s\n", "copy"); printf("%s\n", "paste"); printf("%s\n", "cut");
return 0;
}
매크로로는 직접적인 루프를 만들 수 없고, 루프 풀기(loop unrolling)를 통해 개별 명령어를 반복적으로 삽입하는 우회적인 방법만 존재한다. 이로 인해 이진 파일의 크기는 증가하지만 제한적인 환경에서 고성능을 낼 수 있다.
조건부 컴파일(conditional compilation)은 상이한 조건에 기반해 전처리가 서로 다른 방식으로 수행되는 것이다. 대표적으로 다음 지시자가 사용된다.
#ifdef#ifndef#else#elif#endif코드 박스 1-14 [예제 1-7] 조건부 컴파일 예
#define CONDITION
int main(int argc, char** argv) {
#ifdef CONDITION
int i = 0;
++i;
#endif
int j = 0;
return 0;
}
코드 박스 1-15 전처리 단계 이후의 [예제 1-7]
int main(int argc, char** argv) {
int i = 0;
++i;
int j = 0;
return 0;
}
$ gcc -DCONDITION -E main.c
gcc 컴파일 과정에서 -D 옵션을 사용해 매크로를 정의할 수 있다.
코드 박스 1-16 CONDITION 매크로가 정의되지 않았다고 가정한 전처리 단계 이후의 [예제 1-7]
int main(int argc, char** argv) {
int j = 0;
return 0;
}
#ifdef 지시자로 지정한 매크로가 정의되어 있다면 전처리 과정에서 #endif 지시자 전까지의 소스 코드가 삽입된다.
#ifndef 지시자는 전처리 단계에서 헤더 파일이 두 번 포함되는 것을 방지하기 위한 헤더 가드 구문으로 흔히 사용한다.
코드 박스 1-17 [예제 1-8] 헤더 가드의 예
#ifndef EXAMPLE_1_8_H
#define EXAMPLE_1_8_H
void say_hello();
int read_age();
#endif
처음 헤더 파일이 포함될 때 매크로가 정의되지 않았기 때문에 #ifndef~#endif 사이의 코드가 전처리되지만, 두 번째로 포함될 때부터는 이미 정의되었으므로 해당 코드는 단 한 번만 생성된다.
#pragma once 지시자도 같은 동작을 보이고 대부분의 C 전처리기가 지원하지만 C 표준이 아니기 때문에 코드의 이식성(portability) 측면에서 권장되지 않는다.
코드 박스 1-18 [예제 1-8]에서 #pragma once 사용하기
#pragma once
void say_hello();
int read_age();
코드 박스 1-19 [예제 1-8] C에서 포인터 변수를 선언하고 사용하는 방법
int main(int argc, char** argv) {
int var = 100;
int* ptr = 0;
ptr = &var;
*ptr = 200;
return 0;
}
&는 참조 연산자(reference operator), *는 역참조 연산자(dereference pointer)라 칭한다.
값이 0(NULL 매크로)인 포인터를 널 포인터(null pointer)라고 하며, 유효한 주소를 저장하지 않는 경우나 변수 선언 시 포인터를 널로 만드는 것이 중요하다. 널 포인터를 역참조하는 것은 정의되지 않은 동작이기 때문에 충돌로 이어진다.
코드 박스 1-20 포인터를 널로 만드는 NULL 매크로의 사용
char* ptr = NULL;
코드 박스 1-21 C++11에서 포인터를 널로 만드는 nullptr의 사용
char* ptr = nullptr;
C 자료형(date type)에 의해 포인터 변수에 대한 증감 연산 시의 메모리 이동 간격을 의미하는 산술연산 간격(arithmetic step size)이 결정된다.
코드 박스 1-22 [예제 1-10] 두 포인터의 산술연산 간격
#include <stdio.h>
int main(int argc, char** argv) {
int var = 1;
int* int_ptr = NULL; // initialize as null pointer
int_ptr = &var;
char* char_ptr = NULL;
char_ptr = (char*)&var;
printf("Before arithmetic: int_ptr: %u, char_ptr: %u\n",
(unsigned int) int_ptr, (unsigned int) char_ptr);
++int_ptr; // arithmetic step size == 4
++char_ptr; // arithmetic step size == 1
printf("After arithmetic: int_ptr: %u, char_ptr: %u\n",
(unsigned int) int_ptr, (unsigned int) char_ptr);
return 0;
}
셀 박스 1-4 [예제 1-10]을 처음 실행한 결과
$ ./a.out
Before arithmetic: int_ptr: 2593736244, char_ptr: 2593736244
After arithmetic: int_ptr: 2593736248, char_ptr: 2593736245
셀 박스 1-5 [예제 1-10]을 두 번 실행한 결과
$ ./a.out
Before arithmetic: int_ptr: 2440424868, char_ptr: 2440424868
After arithmetic: int_ptr: 2440424872, char_ptr: 2440424869
반복 실행 시에도 메모리 주소는 무작위하지만 산술연산 간격은 일치한다.
코드 박스 1-23 [예제 1-11] 포인터 연산을 사용하지 않고 배열 반복하기
#include <stdio.h>
#define SIZE 5
int main(int argc, char** argv) {
int arr[SIZE];
arr[0] = 2; arr[1] = 3; arr[2] = 5; arr[3] = 7; arr[4] = 9;
for (int i = 0; i < SIZE; ++i) {
printf("%d\n", arr[i]);
}
return 0;
}
위 코드는 루프 카운터(loop counter)와 인덱서(indexer) 문법을 사용해 원소에 접근한다.
코드 박스 1-24 [예제 1-12] 포인터 연산으로 배열 반복하기
#include <stdio.h>
#define SIZE 5
int main(int argc, char** argv) {
int arr[SIZE];
arr[0] = 2; arr[1] = 3; arr[2] = 5; arr[3] = 7; arr[4] = 9;
int* ptr = &arr[0];
do {
printf("%d\n", *ptr);
} while (++ptr <= &arr[SIZE - 1]);
return 0;
}
위 코드는 배열 경계(array boundary)에서 반복하는 포인터를 사용해 원소에 접근한다. 배열은 메모리 내부의 인접 변수(adjacent variable)이며, C에서 배열은 자신의 첫 번째 원소를 가리키는 포인터이다. 그러므로 arr 변수의 실제 자료형은 int*이고 int* ptr = arr;와 같이 코드를 작성할 수 있다.
void* 자료형의 포인터는 제네릭 포인터(generic pointer)라고 한다. 제네릭 포인터는 메모리 주소만 가리킬 뿐 실제 자료형과 산술연산 간격은 알 수 없어 주로 다른 포인터의 내용을 저장하기 위해 사용된다. 즉, 제네릭 포인터는 역참조와 산술연산이 불가능하다.
코드 박스 1-25 [예제 1-13] 제네릭 포인터를 역참조하면 컴파일 오류가 생성됨
#include <stdio.h>
int main(int argc, char** argv) {
int var = 9;
int* ptr = &var;
void* gptr = ptr;
printf("%d\n", *gptr);
return 0;
}
셀 박스 1-6 리눅스에서 [예제 1-13] 컴파일하기
$ gcc 1_13.c
1_13.c: In function ‘main’:
1_13.c:7:20: warning: dereferencing ‘void *’ pointer
7 | printf("%d\n", *gptr);
| ^~~~~
1_13.c:7:20: error: invalid use of void expression
셀 박스 1-7 macOS(clang compiler)에서 [예제 1-13] 컴파일하기
$ clang 1_13.c
1_13.c:7:20: warning: ISO C does not allow indirection on operand of type
'void *' [-Wvoid-ptr-dereference]
7 | printf("%d\n", *gptr);
| ^~~~~
1_13.c:7:20: error: argument type 'void' is incomplete
1 warning and 1 error generated.
제네릭 포인터에 대한 역참조 오류는 컴파일 시점에 식별된다.
제네릭 포인터는 여러 가지 포인터를 입력 인수(input argument)로 받을 수 있는 제네릭 함수(generic function) 정의에 사용될 수 있다.
코드 박스 1-26 [예제 1-14] 제네릭 함수의 예
#include <stdio.h>
void print_bytes(void* data, size_t length) {
char delim = ' ';
unsigned char* ptr = data;
for (size_t i = 0; i < length; ++i) {
printf("%c 0x%x", delim, *ptr);
delim = ',';
++ptr;
}
printf("\n");
}
int main(int argc, char** argv) {
int a = 9;
double b = 18.9;
print_bytes(&a, sizeof(int));
print_bytes(&b, sizeof(double));
return 0;
}
제네릭 포인터를 할당할 때 명시적 형 변환(explicit cast)이 필요하진 않다. print_bytes 함수에서는 산술연산 간격이 1인 unsigned char 포인터를 사용해 각 바이트를 출력한다. size_t는 C에서 데이터 크기를 저장하기 위해 사용되는 표준 자료형이자 부호가 없는 자료형(unsigned data type)이다.
포인터의 크기는 아키텍처마다 다르기 때문에 항상 sizeof 함수를 사용해 대상 아키텍처(target architecture)에서의 크기를 도출해야 한다.
허상 포인터(dangling pointer)란 유효하지 않은 주소를 가리키는 포인터이다. 이에 대한 역참조는 충돌 또는 세그멘테이션 오류(segmentation fault)를 발생시킨다.
코드 박스 1-27 [예제 1-15] 세그멘테이션 오류 상황 만들기
#include <stdio.h>
int* create_an_integer(int default_value) {
int var = default_value;
return &var;
}
int main() {
int* ptr = NULL;
ptr = create_an_integer(10);
printf("%d\n", *ptr);
return 0;
}
셀 박스 1-8 리눅스에서 [예제 1-15] 컴파일하기
$ gcc 1_15.c
1_15.c: In function ‘create_an_integer’:
1_15.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr]
5 | return &var;
| ^~~~
셀 박스 1-18 [예제 1-15]를 실행했을 때의 세그멘테이션 오류
$ ./a.out
Segmentation fault ./a.out
create_an_integer 함수는 해당 함수의 스택 세그먼트(stack segment)에 생성된 변수의 포인터를 반환한다. 이 포인터는 함수가 반환되면서 허상 포인터가 되기 때문에 세그멘테이션 오류를 발생시키며, 포인터의 잘못된 사용 방식이 컴파일 시점에 탐지된다.
이 경우 힙 메모리(heap memory)를 사용하는 것이 적절하다.
코드 박스 1-28 [예제 1-16] 힙 메모리를 사용해 [예제 1-14] 다시 작성하기
#include <stdio.h>
#include <stdlib.h>
int* create_an_integer(int default_value) {
int* var_ptr = (int*) malloc(sizeof(int));
*var_ptr = default_value;
return var_ptr;
}
int main() {
int* ptr = NULL;
ptr = create_an_integer(10);
printf("%d\n", *ptr);
free(ptr);
return 0;
}
C의 함수는 블로킹 함수(blocking function)이기 때문에 호출자 함수(the caller function)가 피호출자 함수(the callee function)의 반환을 기다린다.
반대로 논블로킹 함수(non-blocking function) 또는 비동기 함수(asynchronous function)는 호출자 함수가 피호출자 함수의 반환을 기다리지 않을 수 있다. 이를 위해 피호출자 함수가 반환될 때 트리거되는 콜백 메커니즘(callback mechanism)이 존재한다. C는 멀티스레딩 솔루션을 활용해 이러한 동작을 모방한다.
논블로킹 함수는 사건 기반 프로그래밍(EDP: event-oriented programming) 접근법의 중심이 된다. libuv, libev 같은 프레임워크가 촉진한 EDP에서 함수 호출은 이벤트 루프 내부에서 발생한다.
함수는 절차적 프로그래밍의 핵심 요소로, 준변수 개체(semi-variable entity)에 추상화된 로직을 저장하여 자유롭게 재사용할 수 있다.
함수 호출 시 반환 주소와 인수를 포함한 스택 프레임이 스택 세그먼트의 최상단에 저장되고 로직이 실행된다. 함수 반환 시 스택 프레임이 제거되며 반환 주소로 이동하여 호출자 함수로 로직이 이어진다. 함수 몸체(function body)에 선언되는 지역 변수 또한 스택 세그먼트 최상단에 배치되므로 함수 반환 시 제거된다.
스택 공간이 초과되면 스택 오버플로우(stack overflow) 오류가 발생한다.
C에는 참조가 없기 때문에 값에 의한 전달만 정의된다.
코드 박스 1-29 [예제 1-17] 값에 의한 전달 함수 호출의 예
#include <stdio.h>
void func(int a) {
a = 5;
}
int main(int argc, char** argv) {
int x = 3;
printf("Before function call: %d\n", x);
func(x);
printf("After function call: %d\n", x);
return 0;
}
셀 박스 1-10 [예제 1-17]의 결과
$ ./a.out
Before function call: 3
After function call: 3
코드 박스 1-30 [예제 1-18] 참조에 의한 전달이 아닌 포인터에 의한 함수 호출의 예
#include <stdio.h>
void func(int* a) {
int b = 9;
*a = 5;
a = &b;
}
int main(int argc, char** argv) {
int x = 3;
int* xptr = &x;
printf("Value before call: %d\n", x);
printf("Pointer before function call: %p\n", (void*) xptr);
func(xptr);
printf("Value after call: %d\n", x);
printf("Pointer after function call: %p\n", (void*) xptr);
return 0;
}
셀 박스 1-11 [예제 1-18]의 결과
$ ./a.out
Value before call: 3
Pointer before function call: 0x7fff70227f8c
Value after call: 5
Pointer after function call: 0x7fff70227f8c
참조에 의한 전달이라면 int, int* 타입의 변수 각각의 값 자체를 피호출자 함수에서 변경했을 경우 그것이 호출자 함수에서 반영되어야 한다. 그러므로 C의 기본 동작은 값에 의한 전달이다.
C에서는 포인터에 의한 전달이 권장된다. 큰 크기의 객체 자체를 함수의 인수로 전달하는 것보다 4/8 바이트의 포인터를 전달하는 것이 훨씬 효율적이기 때문이다.
함수 포인터는 함수의 주소를 저장하기 때문에 함수를 간접 호출할 수 있다. 이 덕분에 모듈화와 소프트웨어 설계가 가능해졌고, C++에서 다형성 구현을 위한 구성 요소가 되었다.
코드 박스 1-31 [예제 1-19] 다른 함수 여러 개를 호출하는 하나의 함수 포인터 예
#include <stdio.h>
int sum(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
int main() {
int (*func_ptr)(int, int);
func_ptr = NULL;
func_ptr = ∑
int result = func_ptr(5, 4);
printf("Sum: %d\n", result);
func_ptr = &subtract;
result = func_ptr(5, 4);
printf("Subtract: %d\n", result);
return 0;
}
셀 박스 1-12 [예제 1-19]의 결과
$ ./a.out
Sum: 9
Subtract: 1
같은 매개변수 목록, 반환형을 갖는 상이한 함수들을 하나의 함수 포인터를 재사용하여 호출할 수 있다. 이는 C로 다형성을 지원하거나 가상 함수(virtual function)를 모방하는 방법이다.
함수 포인터 역시 정의 시 NULL으로 초기화하는 것이 중요하다. 일반적으로 함수 포인터에 대해 타입 별칭(type alias)을 정의하는 것이 권장된다.
코드 박스 1-32 [예제 1-20] 여러 개의 다른 함수를 호출하는 하나의 함수 포인터 사용하기
#include <stdio.h>
typedef int bool_t;
typedef bool_t (*less_than_func_t)(int, int);
bool_t less_than(int a, int b) {
return a < b ? 1 : 0;
}
bool_t less_than_modular(int a, int b) {
return (a % 5) < (b % 5) ? 1 : 0;
}
int main(int argc, char** argv) {
less_than_func_t func_ptr = NULL;
func_ptr = &less_than;
bool_t result = func_ptr(3, 7);
printf("%d\n", result);
func_ptr = &less_than_modular;
result = func_ptr(3, 7);
printf("%d\n", result);
return 0;
}
typedef 키워드로 별칭을 정의하고, C에서 새 타입은 주로 _t 접미사로 끝난다는 규칙에 맞추어 코드의 가독성을 개선할 수 있다.
모든 프로그래밍 언어는 원시 자료형(PDT: Primitive Data Type)을 사용해 자료구조와 알고리즘을 설계 및 구현할 수 있다. 이것으로 불충분할 때 구조체와 같은 사용자 정의 자료형(UDT: User-Defined Type)을 도입할 수 있다.
C++의 클래스, Perl의 패키지 또한 구조체와 같은 개념이며, 타입 메이커(type-maker)로 간주된다.
비즈니스 로직(business logic)을 설명하는 데 원시 자료형은 불충분하다. 그렇기 때문에 최근 타입 시스템(type system) 안에서 사용자 정의 자료형 선언을 지원하지 않는 프로그래밍 언어는 죽은 언어로 간주된다.
C의 사용자 정의 자료형은 CPU 친화적인 원시 자료형 기반의 빠른 계산 로직으로 번역되기 유리하므로 성능 측면에서 확실한 이점을 갖는다.
구조체는 하나의 통합된 타입(single unified type) 아래 관련된 값을 캡슐화한다.
코드 박스 1-33 RGB 컬러를 나타내는 C 구조체
struct color_t {
int red;
int green;
int blue;
};
코드 박스 1-34 [예제 1-21] 구조체 변수에 할당된 바이트 수 출력하기
#include <stdio.h>
struct sample_t {
char first;
char second;
char third;
short fourth;
};
void print_size(struct sample_t* var) {
printf("Size: %lu bytes\n", sizeof(*var));
}
void print_bytes(struct sample_t* var) {
unsigned char* ptr = (unsigned char*) var;
for (int i = 0; i < sizeof(*var); ++i, ++ptr) {
printf("%d ", (unsigned int) *ptr);
}
printf("\n");
}
int main(int argc, char** argv) {
struct sample_t var;
var.first = 'A';
var.second = 'B';
var.third = 'C';
var.fourth = 765;
print_size(&var);
print_bytes(&var);
return 0;
}
셀 박스 1-13 [예제 1-21]의 결과
$ ./a.out
Size: 6 bytes
65 66 67 0 253 2
typedef struct {
char first;
char second;
char third;
short fourth;
} sample_t;
sample_t var;
위와 같이 struct 키워드 없이 변수를 선언하도록 구조체를 정의할 수 있다.
구조체는 배열과 유사한 메모리 레이아웃을 가진다. 상이한 자료형과 크기를 가진 구조체 변수들이 메모리 정렬(memory alignment) 규칙에 의해 워드(word) 크기에 맞춰 메모리 공간에 배치된다.
정렬로 CPU 계산을 용이하게 하기 위해 컴파일러는 패딩(padding)을 사용한다. 이는 추가적인 공간을 사용하는 것으로, 정렬을 사용하지 않는 구조체는 패킹된 구조체(packed structure)라고 한다. 패킹된 구조체는 공간 효율적이지만, 이진 파일 비호환성(incompatibility)과 성능 저하를 유발한다.
코드 박스 1-35 [예제 1-22] 패킹된 구조체 선언하기
#include <stdio.h>
struct __attribute__((__packed__)) sample_t {
char first;
char second;
char third;
short fourth;
};
void print_size(struct sample_t* var) {
printf("Size: %lu bytes\n", sizeof(*var));
}
void print_bytes(struct sample_t* var) {
unsigned char* ptr = (unsigned char*) var;
for (int i = 0; i < sizeof(*var); ++i, ++ptr) {
printf("%d ", (unsigned int) *ptr);
}
printf("\n");
}
int main(int argc, char** argv) {
struct sample_t var;
var.first = 'A';
var.second = 'B';
var.third = 'C';
var.fourth = 765;
print_size(&var);
print_bytes(&var);
return 0;
}
셀 박스 1-14 [예제 1-22]의 결과
$ ./a.out
Size: 5 bytes
65 66 67 253 2
사용자 정의 자료형으로 만들어진 복합 자료형(complex data type)의 대표적인 예시는 중첩 구조체이다.
코드 박스 1-36 [예제 1-23] 몇 가지 중첩 구조체 선언하기
typedef struct {
int x;
int y;
} point_t;
typedef struct {
point_t center;
int radius;
} circle_t;
typedef struct {
point_t start;
point_t end;
} line_t;
중첩 구조체 정의 시엔 데이터 정렬에 유의해야 한다.
사용자 정의 자료형의 포인터는 해당 자료형 크기의 산술연산 간격을 갖는다. 배열과 비슷한 원리로 구조체 변수 포인터는 첫 번째 필드의 주소를 가리킨다.
코드 박스 1-37 [예제 1-24] 메모리에서 동일한 바이트 주소를 가리키는 세 가지 자료형으로부터 얻은 3개의 포인터
#include <stdio.h>
typedef struct {
int x;
int y;
} point_t;
typedef struct {
point_t center;
int radius;
} circle_t;
int main(int argc, char** argv) {
circle_t c;
circle_t* p1 = &c;
point_t* p2 = (point_t*) &c;
int* p3 = (int*) &c;
printf("p1: %p\n", (void*) p1);
printf("p2: %p\n", (void*) p2);
printf("p3: %p\n", (void*) p3);
return 0;
}
셀 박스 1-15 [예제 1-24]의 결과
$ ./a.out
p1: 0x7ffdd22d534c
p2: 0x7ffdd22d534c
p3: 0x7ffdd22d534c
모든 포인터가 같은 바이트를 가리키지만 자료형은 서로 다르다. 따라서 다른 구조체를 확장하기 위해 중첩 구조체를 활용하여 끝에 필드를 추가할 수도 있다. 이는 C에서 상속(inheritance)을 구현하는 방식이다.