동적 할당을 사용하면 프로그램 실행 중에 필요한 메모리 블록을 할당할 수 있다. 동적 할당 구조체를 활용하면 리스트, 트리, 또는 그밖의 여러 데이터 구조를 만들 수 있다. C의 가장 강력한 함수는 함수 포인터를 매개변수로 요구한다.(qsort)
일반적으로 C의 데이터 구조는 고정된 크기를 가지고 있다. VLA를 반례로 들고 싶지만, 실행 시점 이후에는 이들도 고정된 크기를 가진다. 크기를 변경하고 싶으면 프로그램을 재작성하고 재-컴파일 해야한다.
이전에 창고 부품 재고와 관련된 함수를 만들었다. 하지만 프로그램 실행 도중 최대 한도가 다 찬다면 어떻게 해야할까? 이때 등장해야하는 것이 동적 할당이다. 동적할당은 메모리 할당을 프로그램 실행중에 수행하는 것이다. 이로써, 데이터 구조는 필요할때마다 크기 조정이 가능하다.
문자열, 배열, 구조체에 동적 할당을 수행할 수 있지만, 특히 구조체를 통해 리스트, 트리 등의 데이터 구조를 설계할 수있다.
메모리 할당을 위해서 <stdlib.h>의 함수 세 가지가 필요하다.
malloc- 메모리 블록을 할당하나 초기화 하지는 않음calloc- 메모리 블록을 할당하고, 블록을 정리함(clear it)realloc- 이전에 할당된 메모리 블록의 크기를 다시 정함
이중 malloc이 제일 많이 쓰인다. 할당된 메모리를 정리할 필요는 없으므로 ,calloc 보다 많이 쓰인다.
이 함수들을 실행할 때에는 어떤 자료형을 메모리에 저장할지 모르므로, int 나 ,char에 대한 포인터를 반환할 수 없다. 대신 일반적인 포인터(그냥 메모리 주소)인 void *를 반환한다.
메모리 할당 함수를 요청했을때, 이 요청을 만족시킬 충분한 메모리 공간이 없을 수 있다. 이 경우에는 null 포인터 를 반환한다.(nothing에 대한 포인터) 따라서 우리는 포인터 변수에 함수 반환값을 저장한 이후에 null 포인터가 반환되었는지를 검사해야한다.
[주의] null 포인터 반환여부를 검사하는 것과 적절한 메모리 접근은 프로그래머의 책임이다. null 포인터를 통한 메모리 접근은 정의되지 않은 행위다.
null 포인터는 NULL로 매크로 정의되어 있다. 따라서 다음과 같이 검사를 수행할 수 있다.
p = malloc(10000);
if (p == NULL){
/* appropriate action, if allocation failed */
}
if문과 할당 함수를 결합 할 수 있다.
if((p = malloc(10000)) == NULL){ }
NUll 매크로는 <locate.h>, <stddef.h>, <stdio.h>, <stdlib.h>, <string.h>, <time.h>에서 정의되어 있다.
C언어에서 포인터는 참과 거짓을 숫자들과 같은 방식으로 판단한다. 오직 Null 포인터만이 거짓을 의미한다.(나머지 포인터는 전부 참임) 즉 if(!p)... 이런식으로도 사용가능하다.
malloc을 문자열에 사용해보자malloc 함수는 다음과 같은 prototype을 가진다.
void *malloc(size_t size);
malloc은 size 바이트 만큼 메모리를 할당하고 포인터를 반환한다. size는 size_t라는 C 라이브러리에 정의된 무부호 정수형이라는 사실을 기억하자.
char는 1 바이트를 필요로 하므로, 문자 n개를 저장하기 위해서는 (n+1) 바이트가 필요하다.
p = malloc(n + 1);
여기서 p는 char * 변수다. void *는 메모리 할당과 동시에 적절하게 변환된다. 물론 캐스트를 사용해서 메모리 할당을 해도 된다.
[주의]
malloc함수를 null 문자를 위한 공간을 확보해주어야 한다.
나중에 "abc"를 p에 저장할 떄에는 다음과 같이 하면 된다.
strcpy(p, "abc")
동적할당을 통해서, 새로운 문자열에 대한 포인터를 반환하는 함수를 작성할 수 있다.
예로 들 함수는 두 문자열의 길이를 측정하고, 그 길이만큼 메모리를 할당하며, 그 뒤에 strcat을 통해 두 문자열을 붙인 새로운 문자열을 반환한다.
char *concat(const char *s1, const char *s2)
{
char *result;
result = malloc(strlen(s1) + strlen(s2) + 1);
if (result == NULL){
print("ERROR: malloc failed in concat\n");
exit(EXIT_FAILURE);
}
strcpy(result, s1);
strcpy(result, s2);
return result
}
[주의] concat 같이 동적 할당을 사용하는 함수는 반드시 주의를 기울여야한다. 만약 concat을 통해 얻은 문자열이 필요하지 않는 시점이 오면 free 함수를 통해 할당을 해제해야한다. 그렇지 않으면 메모리 부족에 시달릴 것이다.
앞서 여러 문자열을 2차원 배열을 통해 저장하고, 이 과정에서 낭비되는 공간을 없애기 위해서 문자열 리터럴에 대한 배열 포인터를 사용했다. 여기에다 동적 할당을 사용할 수도 있다. 이 경우, 1차원 배열만으로 문제를 해결할 수 있다.
문자열과 마찬가지로 malloc을 통해 배열에 메모리를 할당할 수 있다. 차이점이라면, 배열 내 임의의 원소가 다양한 크기를 가질 수 있다는 점이다. 따라서 우리는 sizeof 연산자를 통해 각 웥소가 필요로 하는 크기를 측정할 것이다.
n개의 정수형으로 구성된 배열을 필요로 하는 프로그램을 예시로 들자. 먼저 우리는 다음과 같이 포인터 변수를 선언해야한다.
int *a;
n을 알게되면 우리는 malloc을 통해 배열에 메모리를 할당 할 수 있다.
a = malloc(n * sizeof(int));
[주의]
sizeof함수를 반드시 사용하자. 실수로 충분하지 않은 메모리가 할당되면, 끔찍한 일이 벌어진다.
일단 동적 할당이 이뤄지면 우리는 a가 포인터라는 사실을 무시하고 배열 이름처럼 사용할 수 있다.
for ( i = 0; i < n; i++)
a[i] = 0;
가끔은 calloc 함수를 쓰는게 더 나을 수 있다.
void *calloc(size_t nmemb, size_t size);
calloc은 size 바이트 길이를 가지는 nmemb 개의 원소가 들어가는 배열 크기의 메모리를 할당한다. 할당이 이뤄진 뒤, calloc은 0으로 모든 비트를 초기화한다. n개의 정수형을 가지는 배열에 calloc을 통해 메모리를 할당해보자.
a = calloc(n, sizeof(int));
calloc은 메모리 정리를 수행하므로, 배열 말고 다른 객체에 대해서도 malloc 보다는 calloc을 사용하고 싶을 수 있다. 첫 번째 입력변수에 1을 넣어서, 어떤 자료형이든 메모리를 할당할 수 있다.
struct point {int x, y;} *p;
p = calloc(1, sizeof(struct point));
위의 구문을 통해 x와 y가 0으로 초기화 된 구조체를 p가 가리킬 것이다.
할당을 하고나서도 크기를 조정하고 싶을 수 있다. 그럴땐 이걸 써보자.
void *realloc(void *ptr, size_t size);
realloc가 호출되면, ptr은 반드시 이전에 malloc나 calloc, realloc을 통해 할당된 메모리 블록을 가리키는 포인터여야만 한다. size 매개변수는 새로운 메모리의 크기를 뜻하며, 이전 크기보다 클 수도, 작을 수도 있다.
C 표준은 realloc과 관련해서 다음과 같은 규칙을 가진다.
- 메모리 블록을 확장하는 경우, realloc는 초기화를 수행하지 않는다.
- 만약 요청된 크기만큼 메모리를 확장할 수 없는 경우 null 포인터를 반환한다.
- 만약 null 포인터가 첫 번째 입력변수로 들어오면, malloc 처럼 작동한다.
- realloc의 두 번째 입력변수가 0인 경우 메모리 할당이 해제된다.
C 표준은 realloc의 작동방식을 정확하게 정의하지 않는다. realloc으로 메모리 크기를 줄이면, "그 자리"에서 줄어든다.(데이터가 이동하지 않는다.) 확장도 마찬가지다. 만약 확장하려는 그 메모리가 이미 다른 목적으로 쓰이고 있다면, 새로운 곳에 메모리를 만들고 할당한다.(기존것도 복사해서 옮긴다.)
[주의] 만약 realloc 함수를 사용하거든, 반드시 메모리 블록에 대한 포인터를 업데이트 해야한다. 이유는 상술
malloc과 다른 메모리 할당 함수는 메모리 블록을 메모리 풀로부터 heap 형태로 획득한다. 이 함수들을 너무 자주 호출한다면, 힙을 고갈시킬 수있으며 null 포인터 반환을 일으킨다.
메모리 할당을 수행한뒤, 추적이 이뤄지지 않는 경우 상황은 악화된다.
p = malloc(...);
q = malloc(...);
p = q;
처음 q에 malloc이 수행되면서 할당된 메모리는 "붕" 뜬다. p = q가 수행 된 뒤에는 q가 가리켰던 메모리에 현실적으로 다시는 접근할 수 없다.(그 메모리 주소를 직접 기억하고 있다면 모를까) 이걸 보고 가비지(garbage) 라고 한다. 가비지를 관리하지 않는 프로그램은 메모리 누출을 일으킨다. 몇몇 언어들은 가비지 컬렉터(garbage collector) 를 통해 자동적으로 가비지를 재활용하고 위치시킨다. 하지만 C는 그런거 없다. 대신 C는 free 함수를 통해 필요하지 않은 메모리를 관리해야한다.
void free(void *ptr);
사용법은 쉽다. 그냥 필요하지 않은 메모리 주소를 포인터로 넘기면 된다.
free(q);
[주의] free 함수 반환 값을 변수나 배열 원소에 전달하는 것은 정의되지 않은 행위다.
free 함수의 문제는 댕글링 포인터다. 댕글링 포인터는 할당 해제된 메모리 주소를 가리키는 포인터다. free는 할당을 해제하기만 할 뿐, 이전에 메모리 할당 함수를 통해 주소를 전달 받은 변수에는 영향을 주지 않는다.(비유를 하자면, 애인하고 헤어지고 그 자리에서 혼자 떠드는거라고 생각하면 된다.)
햘당해제된 메모리에 대해 접근하거나 수정하려는 것은 정의되지 않은 행위므로 주의해야한다.
동적 할당과 함꼐라면 다양한 데이터 구조를 만들 수 있다. 여기서는 연결 리스트만 살펴볼 것이다. 연결 리스트는 노드의 연결로 이뤄져 있으며, 각 노드는 다음 노드에 대한 포인터를 가지고 있다. 그러므로 마지막 노드는 null 포인터를 가진다.
연결 리스트는 배열의 대체재가 될 수 있다. 연결 리스트은 배열보다 유연하고, 데이터 삽입/삭제가 용이하며, 크기를 조정하는 것이 쉽다. 하지만 배열의 "무작위 접근성"을 잃는다.
연결 리스트를 위한 첫 단계는 바로 단일 노드를 나타내는 구조체다.
struct node{
int value;
struct node *next;
};
next는 struct node * 형을 가진다는 것에 주의하자.
앞서 태그나 형정의를 통해 구조체의 이름을 정했던 바가 있다. 뭘 쓰든 상관 없다고 하긴 했지만, 다른 구조체를 가리키는 구조체를 만들떄에는 태그를 사용해야한다. 그렇지 않으면 next의 자료형을 정의할 방법이 없다.
이제 항상 연결 리스트의 맨 앞을 가리킬 변수가 필요하다.
struct node *first = NULL;
NULL로 설정하는 것은 리스트의 맨 앞을 빈칸으로 시작하는 것이다.
이제 노드를 하나씩 생성하면 된다. (1) 노드를 위한 메모리를 할당하고, (2) 노드에 데이터를 저장하고, (3) 노드를 리스트에 삽입한다.
노드를 생성 할 때에는, 임시로 해당 노드를 가리킬 변수가 필요하다.
struct node *new_node;
new_node = malloc(sizeof(struct node));
new_node는 이제 node 구조체를 담는 메모리를 가리킨다. 이제 데이터를 저장해보자.
(*new_node).value = 10;
()는 연산 우선순위 때문에 사용했다.
C언어에서는 포인터를 통한 구조체 접근이 많아지면서, 특수 목적을 갖는 연산자를 제공한다. ->다.
new_node->value = 10;
new_node->next = first;
first = new_node;
struct node *add_to_list(struct node *list, int n)
{
struct node *new_node;
new_node = malloc(sizeof(struct node));
if (new_node == NULL){
printf("Error");
exit(EXIT_FAILURE);
}
new_node->value = n;
new_node->next = list;
return new_node;
}
위의 함수는 list 포인터를 변경하지 않는다. 대신, 새로 생성된 노드를 반환한다.
따라서 리스트 맨 앞에 넣을 때에는
first = add_to_list(first, 10);
first = add_to_list(first, 20);
연결 리스트 탐색에는 while문이 사용될 수 있지만, for문이 우월하다.
for (p = first; p != NULL; p = p->next)
...
p = p->next 가 어색할 수도 있는데 이는 p에 다음 노드를 가리키는 포인터를 저장시키는 것이다.
연결 리스트의 장점은 필요없는 노드를 삭제하는데에서 온다. 노드 삭제는 노드 생성처럼 3단계로 구성된다. (1) 삭제할 노드를 찾고, (2) 앞 노드가 삭제할 노드를 우회하도록 만든다. (3) 삭제할 노드의 할당을 해제한다.
(1) 단계가 보기보다 어렵다. 만약 평범한 방식을 쓴다면, 삭제할 노드를 가리키는 포인터를 찾을 수는 있다. 불행하게도 앞 노드를 수정해야하는 (2) 단계는 수행할 수 없다.
(1) 단계를 수행할 때, 이전 노드가 무엇인지 기록함으로써 문제를 해결할 수 있다. 이를 "trailing pointer" 라고 부른다.
struct node *cur, *prev;
for (cur = list, prev = NULL;
cur != NULL && cur->value != n;
prev = cur, cur = cur->next)
;
if (cur == NULL);
return list;
if (prev ==NULL);
list = list->next;
else
prev->next = cur->next;
free(cur);
return list;
만약 리스트 내의 노드들이 순서를 유지하고 있다면 이를보고 리스트가 정렬되었다고 한다. 이러한 리스트에 노드를 삽입하는 것은 좀 더 어렵다. 하지만 탐색이 빠르다.
배열 대신에 연결 리스트를 사용하면 두 가지 이점이 있다. (1) 사전에 데이터 베이스의 크기를 정하지 않아도 된다. (2) 번호/이름 등을 통해 데이터 베이스를 쉽게 정렬할 수 있다.
앞서 이미 포인터의 포인터에 대해 다룬 바가 있다. 여기서는 char *형을 원소로 갖는 배열을 다룰 것이다. "포인터의 포인터"라는 개념은 연결 데이터 구조가 등장하면서 같이 떠올랐다. 특히 함수의 어떤 입력변수가 포인터 변수인 경우, 우리는 함수를 통해 포인터 변수가 다른 곳을 가리키도록 변경하고 싶을 때가 있다. 이때 포인터의 포인터가 필요하다.
앞에서의 add_to_list를 보자. 우리가 이 함수를 부를떄, 우리는 포인터를 첫 번째 노드에 전달한다. 그리고 변경된 리스트의 첫 노드 포인터를 반환한다.
이 함수에서 return new_node;를 지우고 list = new_node 를 넣으면 안된다. 이때 "포인터의 포인터"를 쓰자
사실 자료에 대한 포인터뿐만 아니라, 함수에 대한 포인터도 가능하다. 예를들어, 수학적 함수 f 내에서 a점과 b점 사이를 적분하는 함수 integrate를 보자. 우리는 f를 함수에 대한 포인터로 선언 할 것이다. integrate 함수가 double 매개변수와 결과를 가진다 할 떄, integrate 프로토타입은 다음과 같다.
double integrate(double (*f)(double), double a, double b);
*f는 f가 함수에 대한 포인터임을 나타낸다.(함수 자체가 아니다.) 물론 다음과 같이 f를 함수처럼 선언해도 문법에 어긋나지 않는다.
double integrate(double f(double), double a, double b);
컴파일러의 관점에서, 둘 모두 같다. integrate함수를 호출할 떄, 우리는 함수 이름을 첫 입력변수로 넣을 것이다. 예를들어 sin(sine) 함수를 0에서 π/2까지 적분할 떄는
result = integrate(sin, 0.0, PI / 2);
여기서 함수 이름을 감싸는 괄호는 없다. 함수 뒤에 괄호가 없는경우, 컴파일러는 함수 포인터를 생성한다. 위에서는, 우리는 sin을 호출하지 않는다. 대신 sin 에 대한 포인터를 integrate 함수에 전달한다. 이는 배열과 비슷하다. 배열 뒤에 대괄호가 없으면 포인터로 기능하는 것 처럼, 함수도 마찬가지다.
integrate 함수 내에서, 우리는 f가 가리키는 함수를 호출한다.
y = (*f)(x)
*f는 f가 가리키는 함수를 말한다.
평균적인 프로그래머에게 함수 포인터는 낯설어 보이지만, 이는 부정할 수 없는 사실이다. 사실 C 라이브러리 내의 유용한 함수들 대부분은 함수 포인터를 입력변수로 요구한다. 그 중 하나가 <stdlib.h>의 qsort다.
qsort는 둘 중 어느 배열이 더 "작냐"를 판단함으로써, 어떤 자료형이라도 정렬시킨다. 우리는 qsort에 비교 함수를 전달할것이다. p q 두 개의 배열원소 포인터가 주어졌을때, 비교식은 반드시 전건이 작으면 부정을, 같으면 0을, 크면 긍정을 반환한다.
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void*, const void *));
base는 첫 원소를 가리키는 포인터다. 간단하게, base는 배열 이름, nmemb는 정렬될 원소의 갯수, size는 원소의 크기, compar는 비교 함수라고 하자.
qsort(inventory, num_parts, sizeof(struct part), compare_parts);
compare_parts 함수를 작성하는 것은 기대보다 쉽지않다. qsort는 매개변수로 void *을 요구한다. 하지만 part 구조체에 void * 포인터를 통해 접근할 수 없다. struct part *를 통해 접근 가능하다. 문제를 해결하기 위해 compare_parts이 매개변수를 할당하도록, 즉 p와 q에 struct part *형에 매개변수를 할당하도록 만든다. 오름차순 정렬을 가정할 때, 여기 compare_parts 함수가 있다.
int compare_parts(const void *p, const void *q)
{
const struct part *p1 = p;
const struct part *q1 = q;
if (p1->number < q1->number)
return -1;
else if (p1->number == q1->number)
return 0;
else
return 1;
}
p와 q가 const 포인터이므로, 반드시 const로 선언된 포인터 변수에 할당되어야 한다.
하지만 대부분의 C 프로그래머들은 함수를 더 간결하게 작성한다.
int compare_parts(const void *p, const void *q)
{
if (((struct part *) p)->number <
((struct part *) q)->number)
return -1;
else if (((struct part *) p)->number ==
((struct part *) q)->number)
return 0;
else
return 1;
}
그냥 캐스팅 써서 하는 거다. 우선순위를 고려해야하므로 괄호를 잘 써주도록 하자. 사실 더 짧은거도 된다.
int compare_parts(const void *p, const void *q)
{
return((struct part *) p)->number -
((struct part *) q)->number;
}
여태까지 함수 포인터의 장점을 역설했지만, 항상 좋은것만은 아니다. C는 함수 포인터를 데이터 포인터처럼 다룬다. 즉 함수포인터를 변수에 저장하거나, 배열 원소처럼 사용하거나, 구조체나 공용체의 구성원처럼 다룰 수 있다는 것이다. 심지어 함수포인터를 반환하는 함수를 만들 수도 있다.
함수 포인터를 반환하는 함수는 다음과 같다.
void (*pf)(int);
pf는 void형을 반환하고, int를 매개변수로 가지는 함수를 가리킨다. 만약 f가 그런 함수라면 다음과 같이 쓸 수 있다.
pf = f;
앰퍼샌드(&)가 앞서지 않음에 주의하자. 만약 pf가 f를 가리키면 우리는 f함수를 다음과 같이 쓸 수 있다.
(*pf)(i);
나
pf(i);
함수포인터가 원소인 배열은 다양한 응용법을 가지고 있다. 예를들어. 커맨드를 유저가 선택하도록 하는 메뉴를 보여주는 프로그램이 있다.
void (*file_cmd[])(void) = {new_cmd,
open_cmd,
close_cmd,
close_all_cmd,
save_cmd,
save_as_cmd,
save_all_cmd,
print_cmd,
exit_cmd,
};
switch문을 사용해도 되지만, 이 방법이 더 유연하다. 왜냐하면 배열내의 원소들은 프로그래밍 실행 중에 변경 될 수 있기 때문이다.
이제 C99에서의 포인터와 관련된 특징 두 가지를 살펴보자. 대부분은 넘어가고 싶겠지만, C에 익숙한 프로그래머라면 관심가질만 하다.
C99에서, restrict 라는 단어가 포인터 선언에서 나타날때가 있다.
int * restrict p;
이런식으로 선언된 포인터를 restricted pointer라고 부른다. 이는 추후에 p가 변경되는 경우 p 가 아닌 다른 방식을 통해 객체에 접근할 수 없게 하기 위함이다. 객체에 접근할 수 있는 방식이 두 개 이상인 경우, 앨리어싱(aliasing) 이라고 한다.
도대체 왜 이렇게 접근을 제한하는건가? 예를 살펴보자. p와 q가 다음과 같이 선언된다.
int * restrict p;
int * restrict q;
이제 p는 동적 할당된 메모리를 가리킨다.
p = malloc(sizeof(int));
(변수나 배열 원소의 주소를 받아도 마찬가지다) 일반적으로 p를 q에 복사하고 q를 통해 정수를 변경하는 것은 가능했었다.
p = q;
*q = 0; /* Wrong, undefined behavior */
p가 restricted pointer이므로 *q = 0;은 정의되지 않은 행위다.
만약 p가 지역 변수로 선언된(extern 클래스가 아닌) 제한 포인터라면, restrict는 해당 블록(스코프)에서만 유효하다.restrict는 함수에서 포인터를 매개변수로 사용할때 나타날 수 있다. 이 때에는 해당 함수에서만 유효하다. 파일 스코프를 가지는 restrict는 프로그램을 실행하는 동안 유효하다.
restrict에 관한 정확한 규칙은 복잡하다. C99 표준을 살펴보아야 한다.거기에는 제한 포인터를 통해 생성된 앨리어싱 등의 사례도 있다. 예를 들어, p는 함수에서 지역 변수로, q는 함수 내 정의된 다른 블록의 변수인 경우에 p가 q에 복사되는 것은 문법에 어긋나지 않는다.
restrict을 설명하기 위해, memcpy와 memmove 함수를 살펴보자.(<string.h>) memcpy는 C99에서 다음과 같은 프로토타입을 가진다.
void *memcpy(void * restrict s1, const void * restrict s2, size_t n);
memcpy는 strcpy와 비슷하다. 다만 한 객체에서 다른 객체로 바이트를 복사한다. restroct는 s1과 s2가 서로 겹쳐 쓰이지 않음을 가리킨다.(이를 보장하는 것은 절대 아니다.) 반대로 memmove에서는
void *memmove(void *s1, const void *s2, size_t n);
memmove memcpy와 같은 작업을 한다. 차이점은 memmove는 원본과 복사본이 겹치더라도 작업 수행을 보장한다는 점이다. 예를 들어 memmove는 배열의 원소들을 한 자리씩 옮길 수 있다.
int a[100];
...
memmove(&a[0], &a[1], 99 * sizeof(int));
C99 이전에는, memcpy와 memmove의 차이점을 설명할 수 없었다. 왜냐면 두 개의 프로토타입이 완전히 같았기 떄문이다.
restrict가 문서화에 상당히 유용한건 사실이나, 이게 주된 존재이유인건 아니다. restrict는 컴파일러에게 효율적인 코드를 짜도록 정보를 전달해준다.(이를 최적화라고 한다.)모든 컴파일러가 최적화 프로그램을 허용한다는 것은 아니며, 프로그래머 이 기능을 비활성화 할 수 있게 해주는 컴파일러도 있다. 그 결과 C99 표준은 restrict의 존재 여부와 무관하게, 표준을 따르는 프로그램은 작동에 차이가 없음을 보장하며, 그래야만 한다.
대부분의 프로그래머는 restrict를 사용하지 않는다.(최고의 성능을 뽑기위한 정밀한 조정을 위한게 아니면). 하지만 C99의 라이브러리에 있는 함수들의 프로토타입에서 등장하므로, 알고있는게 좋다.
다들 크기를 모르는 배열을 포함하는 구조체를 정의한적이 있을 것ㅇ디ㅏ. 예를들어, 일반적인 방식과 다른 방식으로 문자열을 저장하고 싶을 수 있다. 일반적으로 문자열은 문자의 배열이다. 하지만 문자열을 다른 방식으로 저장하면 좋은 점이 있다. 첫번째 대안은 문자열의 길이를 문자열의 문자와 함께 저장하는 것이다.(여기선 null 문자가 없다) 길이와 문자는 구조체에 저장된다.
struct vstring {
int len;
char chars[N};
}
N은 매크로이며, 최대 저장 길이를 뜻한다. 하지만 보면 알 수 있듯이, 여전히 메모리는 낭비되고 있다. 프로그래머들은 전통적으로 chars의 길이를 1(더미 값)로 선언했다. 그리고 그 후에 동적 할당을 수행했다.
이 방식은 문자 배열뿐만 아니라 다양하게 사용되었다. 여태까지, 많은 컴파일러에 의해 지원되며 널리 퍼졌다. GCC를 포함한 몇 컴파일러들은 chars 배열이 0의 길이를 가질 수 있게 했다. 불행히도 C89는 이 방식의 효과를 보장하지 않았으며, 0의 길이 따위를 인정하지도 않았다.
이에 C99는 flexble array member라고 알려진 기능을 가지게 되었다. 이는 기존 방식과 완전히 같은 것이다. 구조체의 마지막 구성원이 배열인 경우 길이는 생략될 수 있다.
struct vstring{
int len;
char chars[];
};
chars의 길이는 메모리가 할당될 때까지 정해지지 않는다.
struct vstring *str = malloc(sizeof(struct vstring) + n);
str->len = n;
여기서, str은 vstring 구조체를 가리킨다. sizeof 연산자는 chars 구성원을 무시한채 구조체의 크기를 계산한다.
구조체에 적용되는 특별한 규칙들은 flexible array member을 포함한다. FAM은 반드시 구조체 마지막 구성원이어야한다. 그리고 구조체는 최소한 다른 구성원 하나를 가져야한다. FAM을 포함하는 구조체 복사는 flexible array 그 자체를 복사하지는 않는다.(다른건 다 복사한다.)
FAM을 포함한 구조체는 미완성형(incomplete type) 이다. 미완성형은 필요한 메모리 크기와 관련된 정보가 빠진 것을 말한다. 미완성형에는 다양한 제한이 있으며, 특히 미완성형은 다른 구조체의 구성원 혹은 다른 배열의 원소가 될 수 없다. 하지만 배열은 FAM을 포함한 구조체를 가리키는 포인터를 원소로 가질 수 있다.