Chapter 17 - Advanced Uses of Pointers - 1/3 에서부터 계속됨
Deleting a Node from a Linked List
링크드 리스트를 사용해 데이터를 저장하는 것의 큰 장점은 필요하지 않은 노드를 쉽게 제거할 수 있다는 것이다. 노드를 만드는 것과 같이 노드를 삭제하는 것은 세 가지 단계를 거친다.
1. Locate the node to be deleted.
2. Alter the previous node so that it "bypasses" the deleted node.
3. Call free to reclaim the space occupied by the deleted node.
1단계는 보기보다 어렵다. 만약 리스트 내에서 검색을 한다면, 삭제될 노드의 포인터를 얻을 것이다. 그러나 이렇게 하면 2단계를 수행할 수 없다. 바로 전 단계의 노드를 수정해야 하기 때문이다.
이 문제의 해결법은 여러가지가 있다. 여기선 "trailing pointer" 방법을 이용한다: 1번 단계를 통해 노드를 검색해 나가면서, 바로 전 단계 노드의 포인터(prev)도 현재 노드의 포인터(cur)와 함께 저장하는 것이다. 만약 변수 list가 검색될 리스트를 가리키고, n이 삭제될 정수라면, 1번 단계를 다음과 같이 구현한다:
for (cur = list, prev = NULL;
cur != NULL && cur->value != n;
prev = cur, cur = cur->next)
;
2단계: 이전 노드가 삭제되는 노드를 지나쳐 가도록 함.
prev->next = cur->next;
이전 노드 안에 있는 포인터가 현재 노드 바로 다음 노드를 가리키도록 한다.
3단계: 삭제되는 노드가 차지한 메모리를 해제.
free(cur);
다음 함수 delete_from_list는 위에서 설명한 전략을 사용한다. 리스트와 정수 n이 주어졌을 때, 이 함수는 n이 담겨 있는 첫번째 노드를 삭제한다. 만약 n이 포함된 노드가 없으면, 아무것도 하지 않는다. 두 경우 모두 리스트를 가리키는 포인터를 반환한다.
struct node *delete_from_list(struct node *list, int n)
{
struct node *cur, *prev;
for (cur = list, prev = NULL;
cur != NULL && cur->value != n;
prev = cur, cur = cur->next)
;
if (cur == NULL)
return list; /* n was not found */
if (prev == NULL)
list = list->next; /* n is in the first node */
else
prev->next = cur->next; /* n is in some other node */
free(cur);
return list;
}
리스트의 첫번째 노드를 삭제하는 것은 특별한 경우이고 2단계의 적용 코드가 다르다. prev==NULL 테스트를 통해 첫번째 노드를 삭제해야 하는지 확인한다.
Ordered Lists
리스트의 노드들이 노드 내에 저장된 데이터에 의해 정렬된 경우 리스트가 정렬되었다고 한다(the list is ordered). 정렬된 리스트에 노드를 삽입하는 것은 더 어렵다(새 노드가 항상 리스트의 처음에 놓이지 않는다), 하지만 검색은 더 빠르다(원하는 데이터가 있을법한 지점까지만 검색하고, 더 이상 찾지 않아도 된다). 아래 프로그램은 더 어려운 삽입, 더 빠른 검색에 대해 보여준다.
16.3에서 만들었던 부품관리 프로그램을 데이터를 링크드 리스트에 저장하는 버전으로 다시 만들어 보자. 배열이 아닌 링크드 리스트를 사용하는 것은 두가지 중요한 장점이 있다.
1. 데이터베이스의 크기 제한을 미리 걸 필요가 없다. 부품을 저장할 메모리 공간이 없을 때까지 계속 데이터베이스가 커질 수 있다.
2. 데이터베이스를 쉽게 부품 번호에 의해 정렬해서 유지할 수 있다 - 데이터베이스에 새로운 부품을 저장할 때, 쉽게 적절한 공간에 삽입할 수 있다. 원래 프로그램에서는 데이터베이스가 정렬되지 않았었다.
새 프로그램에서는 part 구조체가 새로운 멤버(링크드 리스트의 다음 노드를 가리키는 포인터)를 포함한다. 또 inventory 변수는 배열이 아니라 리스트의 첫번째 노드를 가리키는 포인터가 된다.
struct part {
int number;
char name[NAME_LEN+1];
int on_hand;
struct part *next;
};
struct part *inventory = NULL; /* points to first part */
새 프로그램의 대부분의 함수는 원래 프로그램에 대응되는 함수와 아주 닮았다. 하지만 find_part, insert 함수는 더 복잡해진다. inventory의 노드를 저장하고 부품 번호대로 정렬해야 하기 때문이다.
원래 프로그램에서 find_part는 inventory 배열의 index를 반환했다. 새 프로그램에서는, find_part는 원하는 부품 번호가 담긴 노드를 가리키는 포인터를 반환한다. 만약 부품 번호를 찾지 못했을 경우, find_part는 null pointer를 리턴한다. inveotry 리스트가 부품 번호순으로 정렬되어 있으므로, 새로운 버전의 find_part는 찾는 부품 번호보다 큰 번호를 만났을 때 검색을 중단해서 시간을 절약할 수 있다. find_part의 검색 loop는 다음과 같은 형태이다:
for (p = inventory;
p != NULL && number > p->number;
p = p->next)
;
루프는 p가 NULL이거나(부품 번호를 찾지 못했다는 뜻) number > p->number가 거짓일 때(찾고 있는 부품 번호가 저장된 노드에 있는 번호보다 작거나 같을 때) 종료된다. 후자의 경우, 원하는 번호를 찾았는지 알 수 없기 때문에 if문이 하나 더 필요하다.
if (p != NULL && number == p->number)
return p;
원래 버전의 insert 함수는 새로운 부품을 배열 중 사용가능한 첫번째 원소에 저장했다. 새로운 버전에서는 새 부품이 리스트의 어디에 삽입되어야 하는지를 판단해서 그 자리에 넣어야 한다. 또한 새 부품 번호가 이미 리스트에 들어있는지도 확인한다. find_part에서 사용한 루프와 비슷한 형태를 사용해서 두가지 작업을 모두 해낼 수 있다.
for (cur = inventory, prev = NULL;
cur != NULL && new_code->number > cur->number;
prev = cur, cur = cur->next)
;
이 루프는 두 개의 포인터에 의존한다: cur - 현재 노드를 가리킴, prev - 바로 전 노드를 가리킴. 루프가 종료되었을 때, insert는 cur이 NULL이 아니고 new_node ->number와 cur->number가 같은지 테스트한다; 만약 그렇다면, 부품 번호는 이미 리스트에 있다. 그렇지 않은 경우 insert는 prev, cur 사이에 새로운 노드를 삽입한다. 노드를 삭제할 때 썼던 것과 비슷한 방법으로 삽입한다. (이 전략은 부품 번호가 리스트의 어떤 숫자보다 클 때도 적용된다; 그때는 cur이 NULL이 되지만 purev는 리스트의 마지막 노드를 가리킨다.)
(전체 코드는 생략)
17.6 Pointers to Pointers
13.7에서 포인터에 대한 포인터 기호를 사용했었다. 원소의 형식이 char *인 배열을 사용했었고, 그 배열의 원소를 가리키는 포인터의 형식은 char ** 였다. 이 "포인터에 대한 포인터" 개념은 링크드 데이터 구조에서도 자주 등장한다. 특히, 함수의 인자가 포인터 변수인 경우, 함수 내에서 포인터가 가리키는 곳을 바꿔서 포인터를 수정하고 싶을 때가 있다. 그렇게 하려면 포인터에 대한 포인터를 사용해야 한다.
17.5에서 썼던 add_to_list 함수(링크드 리스트의 가장 처음에 노드를 삽입)를 고려하자. add_to_list를 호출할 때, 함수에 원래 리스트의 첫번째 노드를 가리키는 포인터를 pass한다; 그리고 업데이트된 리스트의 첫번째 노드를 가리키는 포인터를 반환한다.
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: malloc failed in add_to_list\n");
exit(EXIT_FAILURE);
}
new_node->value = n;
new_node->next = list;
return new_node;
}
이 함수를 수정해서 new_node를 반환하는 것이 아니라 new_node를 list에 assign하도록 바꿔 보자. return statement를 없애고
list = new_node;
라고 하면, 이 방법은 작동하지 않는다.
add_to_list(first, 10);
함수를 호출하면, first는 list에 복사된다. 포인터는 다른 모든 인수와 마찬가지로 passed by value임. 함수의 마지막 줄은 list의 값을 바꿔서 새로운 노드를 가리키게 만든다. 하지만 이 assignment는 first에는 영향을 주지 못한다.
add_to_list가 first를 수정하게 하려면 first의 포인터를 전달해야 한다. 수정된 정답 버전:
void add_to_list(struct node **list, int n)
{
struct node *new_node;
new_node = malloc(sizeof(struct node));
if (new_node == NULL) {
printf("Error: malloc failed in add_to_list\n");
exit(EXIT_FAILURE);
}
new_node->value = n;
new_node->next = *list;
*list = new_node;
}
새로운 버전의 add_to_list를 호출할 때, 첫번째 인자는 first의 주소가 되어야 한다.
add_to_list(&first, 10);
list가 first의 주소에 할당되므로, *list와 first는 동일하다. 또 *list에 new_node를 할당하는 것은 first를 수정한다.
17.7 Pointers to Functions
포인터가 변수, 배열의 원소, 동적할당된 메모리 블록 등 다양한 데이터를 가리킬 수 있다는 것을 보아왔다. 포인터가 데이터만 가리킬 수 있는 것은 아니다; 포인터는 함수도 가리킬 수 있다. 함수도 메모리의 장소를 차지하기 때문에, 변수들이 주소를 가지고 있는 것처럼 모든 함수에는 주소가 있다.
Function Pointers as Arguments
함수 포인터는 데이터 포인터와 비슷하게 사용할 수 있다. 함수 포인터를 인자로 전달하는 것은 꽤 흔한 일이다. 수학 함수 f를 점 a부터 b 사이에서 적분하는 'integrate' 함수를 쓰고 싶다고 하자. f를 인자로 전달해서 이 함수를 가능한 일반화하고 싶다. C에서 그 효과를 얻으려면, f를 함수를 가리키는 포인터로 선언해야 한다. double 형식을 인자로 받아 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(사인) 함수를 0부터 π/2까지 적분한다:
result = integrate(sin, 0.0, PI /2 );
중요한 점은 함수 이름인 sin 뒤에 괄호가 오지 않는 점이다. 함수의 이름 뒤에 괄호가 붙지 않으면, C 컴파일러는 함수를 호출하는 것이 아니라 함수의 포인터를 생산한다. 위 예시에서 우리는 sin을 호출하는 것이 아니며, integrate 함수에 sin을 가리키는 포인터를 전달하는 것이다. 이 것은 배열을 다루는 방식과 유사하다. a가 배열의 이름일 때, a[i]는 배열의 한 원소를 나타내지만, a는 배열을 가리키는 포인터로 쓰인다. 비슷하게 f가 함수인 경우, C는 f(x)는 함수의 호출로 다루지만, f 자체는 함수에 대한 포인터로 간주한다.
integrate 함수의 바디에서 f가 가리키는 함수를 호출할 수 있다:
y = (*f)(x);
*f는 f가 가리키는 함수를 나타내며, x는 호출시의 인자이다. 따라서 integrate(sin, 0.0, PI / 2)가 실행되는 동안 *f를 호출하는 것은 사실 sin을 호출하는 것이다. (*f)(x)의 대안으로, f(x)로 f가 가리키는 함수를 호출할 수 있다. f(x)가 더 자연스러워 보이지만, 여기에서는 f가 함수의 이름이 아닌 함수의 포인터라는 것을 상기하는 차원에서 계속 (*f)(x)라고 표기한다.
The qsort Function
함수에 대한 포인터 개념이 평균적인 프로그래머와 관련 없어 보일수도 있지만, 그것은 사실과 전혀 다르다. 사실은 C 라이브러리의 가장 유용한 함수들 일부는 인자로 함수의 포인터를 받는다. 그중 하나는 <stdlib.h> 헤더에 속한 qsort이다. qsort는 우리가 선택한 기준에 따라 배열을 정렬할 수 있는 범용 정렬 함수이다.
정렬하는 배열의 원소는 어떤 형식이든 될 수 있다 - 심지어 구조체나 공용체도 가능 - 배열의 두 원소 중 어떤 것이 "작은지"에 대한 기준을 qsort에게 알려줘야 한다. 이 정보를 comparison function을 써서 qsort에게 전달하게 된다. p와 q가 배열 원소를 가리키는 포인터일 때, 비교 함수는 반드시 *p가 *q보다 "작으면" 0보다 작은 정수를, *p와 *q가 "같다면" 0을, *p가 *q보다 "크다면" 0보다 큰 정수를 반환해야 한다. "같다면", "크다면", "작다면"과 같이 따옴표로 묶은 것은 비교의 기준을 정하는 것이 프로그래머의 몫이기 때문이다.
qsort의 원형은 다음과 같다:
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));
base는 배열의 첫번째 원소를 가리켜야 한다(만약 배열의 일부분만 정렬할 거라면, base는 정렬하는 부분의 가장 처음 원소를 가리켜야 한다). 가장 간단한 경우에는 base는 정렬할 배열의 이름이 된다.
nmemb는 정렬될 원소의 갯수이다(꼭 배열에 있는 원소의 갯수가 아니어도 된다).
size는 바이트 단위로 측정된 배열의 각 원소의 크기이다.
compar는 비교 함수에 대한 포인터이다.
qsort가 호출되면, 필요한 만큼 비교 함수를 호출해서 배열 원소를 비교하면서 배열을 오름차순으로 정렬하게 된다.
16.3의 inventory 배열을 정렬하려면, 다음과 같이 qsort를 호출한다:
qsort(inventory, num_parts, sizeof(struct part), compare_parts);
두번째 인자는 MAX_PARTS가 아닌 num_parts임에 유의하자. inventory 배열 전체가 아니라, 현재 부품이 저장되어 있는 부분만 정렬할 것이기 때문이다. 마지막 인자, compare_parts는 두 part 구조체를 비교하는 함수이다.
qsort는 compare_parts 함수의 두 매개변수로 void * 타입을 요구하지만, void* 형식으로는 part 구조체의 멤버에 접근할 수 없고 (p->number 같은 접근 불가능) struct part * 타입의 포인터가 필요하다. 문제 해결을 위해, [① compare_parts에서 struct part * 형식의 변수 p1, q1에 자신의 파라미터인 p와 q를 할당과 동시에 변환한다]. (void *는 어떤 형식의 포인터이든 자유롭게 변환될수 있으니까 casting 불필요) compare_parts는 이제 자신의 변수들로 p와 q가 가리키는 구조체의 멤버에 접근할 수 있다. inventory 배열을 부품 번호의 오름차순대로 정렬하는 경우, 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;
}
p1과 q1의 선언에는 const가 포함되어 있는데 이는 컴파일러의 경고를 피하기 위함이다. p와 q가 const 포인터(그것이 가리키는 object를 수정할 수 없음)이므로, 얘네들은 마찬가지로 const로 선언된 포인터 변수에만 assign될 수 있다.
위 버전의 compare_parts도 작동하지만, 더 간결하게 쓸 수 있다. 먼저, p1과 q1을 cast expression으로 대체할 수 있다.
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;
}
((struct part *) p) 에서 바깥의 괄호는 필요하다. 이 괄호가 없으면 컴파일러는 p->number의 형식을 struct part* 형식으로 변환하려고 시도한다.
위 compare_parts 함수에서 if 문장을 제거함으로써 더 짧게 만들 수 있다:
int compare_parts(const void *p, const void *q)
{
return ((struct part *) p)->number -
((struct part *) q)->number;
}
q의 부품 번호에서 p의 부품 번호를 뺐을 때의 값이 리턴 값이 된다(p와 q의 부품 번호 대소관계에 따라 반환됨).
주의: 두 정수의 뺄셈은 잠재적으로 오버플로우가 발생할 가능성이 있다. 여기서는 부품 번호가 모두 양의 정수이기 때문에 그럴 문제는 없다.
inventory 배열을 부품 번호가 아닌 부품의 이름순으로 정렬하려면, 다음과 같은 compare_parts 함수를 사용한다:
int compare_parts(const void *p, const void *q)
{
return strcmp(((struct part *) p->name,
((struct part *) q->name);
}
문자열 간의 비교는 strcmp 함수를 호출함으로써 쉽게 negative, 0, postive 결과를 얻을 수 있다.
Other Uses of Function Pointers
함수의 인자로 함수의 포인터를 사용하는 경우의 유용성을 강조했지만, 그게 함수 포인터 사용의 전부가 아니다. 함수를 가리키는 포인터는 데이터를 가리키는 포인터처럼 간주된다. 함수를 가리키는 포인터를 변수에 저장하거나, 배열의 원소, 구조체, 공용체의 멤버로도 사용할 수 있다. 심지어 함수의 포인터를 리턴하는 함수도 만들 수 있다.
void (*pf) (int);
위 함수는 함수의 포인터를 저장하는 변수의 예시이다.
pf는 정수 파라미터를 갖고 리턴의 형식이 void인 모든 함수를 가리킬 수 있다. 만약 f가 그런 함수라면, 다음과 같이 pf로 f를 가리킬 수 있다:
pf = f;
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};
만약 사용자가 0부터 8 사이에 있는 n 명령을 선택한다면, file_cmd 배열에서 subscription으로 대응하는 명령을 호출할 수 있다:
(*file_cmd[n])(); /* or file_cmd[n](); */
switch 문을 사용해서 비슷한 효과를 낼 수도 있다. 하지만 함수 포인터의 배열을 사용하는 것이 더 유연하다. 배열의 크기가 프로그램 실행 도중에 바뀔 수 있기 때문이다.
17.8 Restricted Pointers (C99)
->gcc로 컴파일했을 때 책에선 undefined behavior 발생한다고 하지만 잘 된다.
이 섹션과 다음 섹션은 C99의 포인터와 연관된 기능을 설명한다. 두 기능은 고급 기능에 속한다.
C99에서는 restrict 키워드가 포인터 선언시에 등장할 수 있다.
int * restrict p;
restirct를 사용해서 선언된 포인터를 restricted pointer(제한된 포인터)라고 한다. 만약 p가 나중에 수정된 object를 가리키면, 그 object는 p를 제외한 어떤 방법으로도 접근될 수 없다. (p를 제외한 다른 방법이란 다른 포인터가 같은 object를 가리키도록 하거나 p를 어떤 명명된 변수를 가리키게 하는것을 포함한다) 하나의 object에 접근하는 방법이 하나보다 많은 것을 aliasing이라고 부른다.
제한된 포인터가 방해하는 행동의 예를 살펴보자. p와 q를 다음과 같이 선언하자
int * restrict p;
int * restrict q;
p = malloc(sizeof(int));
(p에 변수의 주소나 배열의 원소를 assign해도 비슷한 상황이 발생한다)
보통은 p를 q에 복사하고, q를 통해 정수를 수정하는 것은 legal하다.
q = p;
*q = 0; /* causes undefined behavior */
그러나 p가 제한된 포인터이기 때문에, *q=0;은 정의되지 않아 있다. p와 q를 같은 object를 가리키게 함으로써, *p와 *q는 aliases가 되었다.
만약 제한된 포인터 p가 extern storage class(18.2) 없이 지역변수로 선언된다면, restric는 p가 선언되어 있는 블록 안의 p에서만 적용된다. (함수의 body도 블록이다) restrict는 함수의 파라미터, 포인터 형식 등에도 사용 가능한데, 그러한 경우엔 함수가 실행되는 도중에만 적용된다. 그러나 file scope를 가지는 포인터 변수에 쓰였을 경우 그 제한은 프로그램 전체를 실행하는 내내 지속된다.
restrict 사용의 정확한 규칙은 복잡하다(C99 표준을 참고). 제한된 포인터로부터 생성된 alias가 legal인 경우도 존재한다. 예를 들어 제한된 포인터 p가 함수의 지역변수이고 다른 제한된 포인터 변수 q가 함수 몸체 안의 nested block에서 정의된 경우에는, p를 정상적으로 q로 복사할 수 있다.
restrict의 사용에 대해 알아보기 위해 <string.h> 헤더에 속한 memcpy와 memmove 함수를 살펴보자. C99에서 memcpy 함수의 원형은 다음과 같다:
void *memcpy(void * restrict s1, const void * restrict s2, size_t n);
memcpy는 strcpy와 유사하지만 strcpy가 문자들을 한 문자열에서 다른 문자열로 복사하는 것과 달리 memcpy는 한 object에서 다른 object로 bytes를 복사한다. s2는 복사될 데이터, s1은 복사의 목적지를 가리키며, n은 복사할 byte 수를 나타낸다. s1과 s2 모두에 restrict를 사용한 것은 복사의 소스와 목적지가 겹쳐서는 안된다는 것을 의미한다. (그러나 겹치지 않는 것을 보장해 주지는 않는다)
반면에 memmove의 원형에는 restrict가 나타나지 않는다:
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의 차이점을 기술할 방법이 없었다. 두 함수의 원형은 거의 동일했다:
void *memcpy(void *s1, const void *s2, size_t n);
void *memmove(void *s1, const void *s2, size_t n);
C99에서 memcpy의 원형에서 restrict가 사용됨으로써 프로그래머는 s1과 s2가 가리키는 object가 겹쳐서는 안된다는 것, 만약 겹친다면 함수의 제대로 된 작동을 보장할 수 없다는 것을 알 수 있게 되었다.
restrict를 함수 원형에서 쓰는 것은 유용하지만 주된 존재 이유는 아니다. restrict는 컴파일러에게 더 효율적인 코드를 만들수 있다는 정보를 준다 - a process known as optimization. (the register storage class:18.2 도 같은 목적이다) 하지만 모든 컴파일러가 프로그램 최적화를 시도하지는 않는다. 또 시도하는 컴파일로도 보통 최적화를 프로그래머가 disable할수 있게 한다. 그 결과, C99 표준에서는 표준을 따르는 프로그램에서 restrict가 프로그램에 미치는 영향이 없음을 보증한다: 만약 restrict가 있던 곳에서 전부 제거되더라도, 동일하게 동작한다.
대부분의 프로그래머들은 최상의 성능을 내기 위해서 프로그램을 미세하게 조정하지 않는 이상은 restrict를 사용하지 않는다. 그렇지만 C99의 표준 라이브러리 함수들의 원형에 restrict가 등장하기 때문에 알아둘 가치는 있다.
17.9 Flexible Array Members (C99)
가끔은 정해지지 않은 크기의 배열을 담고 있는 구조체를 정의해야 할 때가 있다. 예를 들어, 보통의 문자열과 다른 형태의 문자열을 저장해야 할 수 있다. 일반적으로 문자열은 가장 끝에 null character가 오는 문자들의 배열이다. 그러나, 문자열을 다르게 저장하는데서 오는 장점이 있다. 한가지 대안은 null character를 제외하고 딱 문자열의 길이만큼만 문자들을 저장하는 방법이다. 문자들의 길이와 문자들은 다음과 같은 구조체에 저장될 수 있다:
struct vstring {
int len;
char chars[N];
};
N은 문자열의 최대 길이를 나타내는 매크로이다. 고정된 길이의 문자열을 사용할 때 이런 구조체는 모든 문자열의 길이를 동일하게 만들면서 메모리를 낭비하기 때문에 바람직하지 않다.
C 프로그래머들이 전통적으로 이 문제를 해결해온 방법은 이렇다. chars의 길이를 1(a dummy value)로 선언하고) 각각의 문자열을 동적 할당하는 것이다.
struct vstring {
int len;
char chars[1];
};
...
struct vstring *str = malloc(sizeof(struct vstring) + n - 1);
str->len = n;
이것은 구조체가 선언될 때 가졌던 메모리보다 더 많이 할당하는 "꼼수" 이다(여기서는 n-1 바이트만큼 추가로 할당한다). 이런 방식이 널리 퍼져서 나중에는 "struct hack"이라는 이름도 갖게 되고, 많은 컴파일러서는 이것을 지원할 뿐 아니라 명시적으로 이 트릭을 사용한다는 것을 알 수 있도록 chars 배열의 길이를 0으로 선언할 수 있도록 허용했다. 그리고 C99에서는 표준으로 flexible array member를 지원하게 되었다. 만약 구조체의 마지막 멤버가 배열인 경우에, 그 길이는 생략될 수 있다:
struct vstring {
int len;
char chars[]; /* flexible array member - C99 only */
};
chars 배열의 길이는 vstring 구조체를 저장할 메모리가 할당되기 전까지는 결정되지 않는다. 주로 malloc을 호출해서 할당한다:
struct vstring *str = malloc(sizeof(struct vstring) + n);
str->len = n;
위 예시에서, str은 chars 배열이 n개의 문자를 차지하는 vstring 구조체를 가리킨다. sizeof 연산자는 구조체의 크기를 계산할 때 chars 멤버를 무시한다. (flexible array member는 구조체 안에서 공간을 차지하지 않는다는 점에서 특이하다.)
구조체에 flexible array member를 포함시킬 때는 몇가지 규칙이 적용된다. flexible array member는 구조체의 가장 마지막 멤버여야 한다. 또 구조체에 반드시 하나 이상의 다른 멤버가 있어야 한다. FAM이 포함된 구조체를 복사하면, FAM을 제외한 다른 멤버들만 복사된다.
// Flexible Array Members(C99)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct vstring {
int len;
char chars[];
};
// Flexible Array Member가 포함되어 있는 구조체를 복사할 때, 나머지 멤버들은 복사되지만
// Flexible array member 자체는 복사되지 않는다.
int main(void) {
int n;
printf("Enter n: ");
scanf("%d", &n);
struct vstring *str = malloc(sizeof(struct vstring) + n);
str->len = n;
printf("size of chars is: %d\n", n);
strncpy(str->chars, "original message", n-1);
str->chars[n-1] = 0; // assign null character
struct vstring *copy_dest = malloc(sizeof(struct vstring) + n);
*copy_dest = *str; // FAM이 들어있는 구조체를 복사 시도.
//copy_dest에 동적할당으로 n바이트를 추가 할당하지 않으면 세그멘테이션 오류 발생
printf("원본\nlen: %d\nchars: %s\n", str->len, str->chars);
printf("복사본\nlen: %d\nchars: %s\n", copy_dest->len, copy_dest->chars);
}
f.a.m을 포함하는 구조체는 incomplete type이다. incomplete type은 저장을 위한 메모리가 얼마나 필요한지에 대한 정보가 결여되어 있다. 19.3에서 또 알아보겠지만 이런 형식에는 다양한 제약 사항이 가해진다. 특히 incomplete type(FAM이담긴 구조체도 포함)은 다른 구조체의 멤버가 되거나 배열의 원소가 될 수 없다. 하지만 배열은 FAM이 포함되어 있는 구조체의 포인터는 원소로 가질 수 있다.