on
[KNK 요약] Chap 17. Advanced Uses of Pointers
[KNK 요약] Chap 17. Advanced Uses of Pointers
안녕하세요! 17장은 분량이 다소 많습니다. 앞에서 배운 내용을 다시 리뷰해보면, 11장에서는 변수를 가리키는 포인터를 사용하여, 함수가 인자로 받는 포인터의 값을 수정할 수 있다는 걸 배웠죠? 12장에서는 배열과 포인터의 관계와 연산에 대해서 배웠구요. 이번 시간에는 메모리 동적 할당과 함수 포인터를 자세히 알아보고, 그 외 포인터와 연관된 특징들에 대해서 알아보겠습니다.
K.N.K C Programming
17.1 Dynamic Storage Allocation 메모리 동적 할당
C의 자료 구조들은 보통 크기가 정해져 있어. 예를 들자면, 배열의 크기를 생각해보자. 배열의 크기는 프로그램이 컴파일될 때 정해진단다. (C99에서의 가변길이 배열은 런타임 동안 크기가 정해지지만, 한번 정해진 이후에는 계속 크기가 고정된 채로 남아있지) 결국 우리는 프로그램을 작성할 때 크기를 사전에 정해야겠지? 코드를 수정하고 다시 컴파일해야만 크기를 바꿀 수 있을 거고. 이러한 점이 조금 불편할텐데, 다행히도 C의 "메모리 동적 할당" 기능은 프로그램이 실행되는 동안 메모리를 할당시켜 줄 수 있단다! 이 기능을 통해서 런타임 동안 자료 구조를 우리가 필요한 만큼 늘이고 줄일 수 있겠지?
Memory Allocation Functions
메모리 동적 할당과 관련된 3가지 함수를 보자. 셋 다 헤더에 선언되어 있다.
malloc 메모리 블럭을 할당하나 초기화하지 않는다. calloc 메모리 블럭을 할당하고 초기화(clear) 해준다. realloc 이미 할당된 메모리 블럭의 크기를 다시 정해준다.
셋 중에는 malloc 함수가 가장 많이 쓰인다. 할당한 메모리 블럭을 초기화할 필요는 없기 때문에, malloc이 calloc보다 효율적이다.
It's more efficient than calloc, since it doesn't have to clear the memory block that it allocates.
우리가 이러한 함수에 메모리 블럭을 요청할 때, 함수는 우리가 블럭에 어떤 타입의 데이터를 넣을 것인지 모른다. 그래서 함수는 void* 타입 값을 반환한다. void* 값은 "일반화된" 포인터로, 본질적으로는 그저 메모리 주소이다.
Null Pointers
위 함수가 호출되었는데 메모리 할당을 실패할 가능성도 있겠지? 만약 실패할 경우, 함수는 널 포인터를 반환할 것이다.
널 포인터는 " 아무것도 가리키지 않는 포인터(pointer to nothing) "이다. 물론 다른 유효한 포인터들과는 다른 특별한 포인터다. 우리가 함수의 반환값을 포인터 변수에 저장한 후에는, 널 포인터가 저장된 건 아닌지 꼭 확인을 해줘야 한다. 널 포인터에 무언가 하려고 하면 오류가 날 테니까! The effect of attempting to access memory through a null pointer is undefined.
널 포인터는 프로그램에서 NULL이라는 매크로로 흔히 사용된다. 이 매크로는 6개 헤더에 정의되어 있다: , , , , , (C99 헤더 도 NULL을 정의했다)
C는 포인터가 참인지 거짓인지 판별할 때 숫자처럼 행동한다. 오직 널 포인터만 거짓이고, non-null pointer는 모두 참이다. 그렇다면 널 포인터 검사를 이렇게 하는 것도 가능하겠지?
if (p == NULL) ... if (!p) ... /*both are same*/
17.2 Dynamically Allocated Strings 동적 할당 문자열
메모리 동적 할당은 문자열을 다룰 때도 유용하단다! 문자열은 문자형 배열에 저장되고, 배열이 얼마나 커야 할지 예상하기 힘들기 때문이지.
Using malloc to Allocate Memory for a String
malloc 함수는 다음과 같이 쓰인다.
void *malloc(size_t size);
malloc 함수는 size 바이트만큼의 블럭을 할당해주고 그 포인터를 반환한다. size가 size_t 타입이라는 것에 주목해보자. C 라이브러리에서 size_t 타입은 unsigned integer 타입으로 정의되어 있다. 따라서 우리가 아주 큰 메모리 블럭을 다루지 않는다면 int형을 다룬다고 생각해도 무방하겠지?
malloc을 사용해서 문자열에 메모리를 할당하는 건 쉽다. 왜냐하면 C는 char 값이 정확히 1바이트를 갖기를 요구 하기 때문이지. 그렇다면 sizeof(char)이 항상 1이라는 건 알겠지? 따라서 n개의 문자를 갖는 문자열에 공간을 할당하려면 이렇게 작성해야겠지.
p = malloc(n+1);
p는 char*형 변수고, n이 아니라 n+1이라는 점에 주목하자. 우선 n+1이 널 캐릭터('\0')를 저장하기 위한 공간이라는 건 이해했지? 그리고 malloc이 반환한 void*형 포인터는 할당이 수행될 때 char*형으로 변환되었네. 이는 캐스팅이 필요하지 않다는 뜻이겠지? 그래도 몇몇 프로그래머들은 malloc을 사용할 때 캐스팅을 해주기도 해.
p = (char*) malloc(n+1);
이 코드가 수행되면 p는 초기화되지 않은 n+1 크기의 배열을 가리키고 있을 거야. 배열을 초기화하려면 이렇게 해주면 되겠지?
strcpy(p, "abc");
Using Dynamic Storage Allocation in String Functions
메모리 동적 할당은 "새로운 문자열"을 가리키는 포인터를 반환하는 함수를 작성할 수 있게 해준다. 이 때 새로운 문자열은 함수 호출 전에는 존재하지 않았던 문자열이다. 두 개의 문자열 중 하나를 바꾸는 일 없이 두 문자열을 붙이는 함수를 만들어보자. C의 표준 함수는 이러한 함수를 갖고 있지 않다. (strcat 함수는 둘 중 하나를 바꾸니까..)
어떻게 해야 할까? 우선 (1) 합칠 두 문자열의 길이를 측정하고 (2) malloc을 호출해서 결과값이 충분한 공간을 차지할 수 있게 메모리를 할당해 주면 되겠지? 완성된 함수를 보자.
char *concat(const char *s1, const char *s2) { char *result; result = malloc(strlen(s1)+strlen(s2)+1); if (result == NULL) { printf("Error: malloc failed in concat
"); exit(EXIT_FAILURE); } strcpy(result, s1); strcpy(result, s2); return result; }
만약 이 concat 함수를 다음과 같이 실행시킨다면,
p = concat("abc", "def");
p는 "abcdef" 문자열을 가리키고 있겠지?
Arrays of Dynamically Allocated Strings
13.7장에서 우리는 배열에 문자열을 저장하는 법에 대해 살펴보았다. 2차원 배열에 문자열을 저장하는 건 메모리 낭비가 심해서, 문자열을 가리키는 포인터의 배열을 만들었다. 이러한 기술은 동적 할당된 문자열에도 잘 적용해볼 수 있다.
PROGRAM: Printing a One-Month Reminder List (Revisited)
간단한 리마인더를 만드는 프로그램이다.
17.3 Dynamically Allocated Arrays 동적 할당 배열
동적 할당된 배열은 동적 할당된 문자열과 같은 이점을 지닌다. (문자열과 배열은 같잖아?) 종종 배열의 적당한 크기를 정하는 게 어려운데, 프로그램 실행 도중 이 크기를 정할 수 있다면 훨씬 편리할 것이다.
Using malloc to Allocate Storage for an Array
이제 배열에도 malloc을 사용해서 메모리를 할당해보자. 문자열과의 주요한 차이점은, 임의의 배열에 들어갈 요소는 꼭 1바이트 크기(char)가 아니어도 되겠지? 우리는 sizeof 연산자를 사용해서 배열의 각 요소가 차지할 공간의 크기를 계산해야 한다.
n개의 정수를 담는 배열을 만들어보자.
int *a; a = malloc(n * sizeof(int));
a가 동적 할당된 메모리 블럭을 가리키고 나면, 우리는 a가 포인터라는 사실을 무시하고 배열 이름 대신 사용할 수 있다.
a가 가리키는 배열을 초기화해주는 코드는 다음과 같다.
for (i=0;i
당연히 배열 인덱스 대신 포인터 연산을 사용할 수도 있겠지?
The calloc Function
다음 calloc 함수를 보자.
void *calloc(size_t nmemb, size_t size);
calloc 함수는 nmemb개 요소를 가진 배열을 위한 공간을 할당한다. 배열의 각 요소는 size 길이의 바이트를 가진다.
메모리 할당 이후 calloc은 모든 비트를 clear해준다. 다음 예시를 보자.
a = calloc(n, sizeof(int)); struct point { int x, y;} *p; p = calloc(1, sizeof(struct point));
a가 n개의 0을 담고 있는 정수형 배열이라는 걸 알겠지? 그렇다면 밑의 p는 뭘까? calloc의 첫 번째 인자에 1을 넣어주면, 어떤 타입이든지 데이터 항목에 공간을 할당해줄 수 있다. p는 point 구조체를 가리키는 포인터겠지? 물론 모든 멤버가 0 값을 가질 거고.
The realloc Function
우리가 배열에 메모리를 할당한 이후, 뒤늦게 메모리 크기가 너무 크거나 작다는 걸 깨닫게 될 수도 있다. realloc 함수는 배열의 크기를 재조정해 이러한 문제를 해결해준다. realloc 함수는 다음과 같다.
void *realloc(void *ptr, size_t size);
realloc 함수가 호출될 때, ptr은 이전에 malloc, calloc, 또는 realloc으로 얻은 메모리 블럭을 가리키고 있어야 한다. 만약 ptr이 이 조건을 충족하지 않는다면, realloc 함수 호출은 undefined behavior를 야기한다. size 매개변수는 블럭의 새로운 크기를 나타낸다. ptr이 꼭 배열로 사용되는 메모리를 가리킬 필요는 없지만, 실제로는 보통 그렇다. (realloc 함수가 배열 크기를 수정하는 목적 이외에는 잘 사용되지 않는다는 뜻이겠죠?)
C 표준은 realloc의 행동에 관한 많은 규칙을 보여준다.
메모리 블럭이 확대될 때, realloc 함수는 블럭에 추가된 바이트를 초기화하지 않는다.
realloc 함수가 요청받은 대로 메모리 블럭을 늘리지 못하면, 널 포인터를 반환한다. 이전 메모리 블럭의 데이터는 바뀌지 않는다.
realloc 함수의 첫 번째 인자(ptr)가 널 포인터라면, malloc 함수처럼 행동한다.
realloc 함수의 두 번째 인자가 0이라면, 메모리 블럭을 해방시킨다. (free the memory block)
메모리 블럭의 크기를 줄이도록 요청받으면, realloc은 블럭을 데이터의 이동 없이 "제자리에서" 줄여야 한다. 블럭을 늘릴 때도 마찬가지다. 하지만 블럭 바로 뒤의 바이트가 이미 사용 중이라 늘리는 게 불가능하면, realloc은 빈 공간 어디든 새 블럭을 할당한 뒤, 예전 블럭의 내용을 새로운 블럭으로 복사한다. 따라서 realloc이 반환되면 메모리 블럭을 가리키고 있던 모든 포인터를 갱신해야겠지? realloc이 메모리 블럭을 옮겼을 수도 있으니까!
17.4 Deallocating Storage 메모리 할당 해제
malloc과 다른 메모리 할당 함수는 메모리 블럭을 heap이라는 저장고에서 가져온다. 메모리 할당을 너무 자주 하거나, 너무 큰 블럭을 요청하면 널 포인터가 반환될 수도 있다. (heap에 있는 메모리 블럭이 다 떨어지니) 심지어 프로그램이 메모리 블럭을 할당하고 놓쳐버리면, 메모리 낭비가 일어난다. 다음 예시를 보자.
p = malloc(...); q = malloc(...); p = q;
위 코드가 실행되고 나면, p가 (1) 먼저 할당된 메모리를 가리키고 (2) 원래 가리키던 메모리 대신 q가 가리키는 메모리를 가리키게 된다. 그렇다면 (1)에서 할당된 메모리를 가리키는 포인터가 없으므로, 우리는 그 메모리를 다시 사용할 수 없다. 이처럼 더 이상 접근할 수 없는 메모리 블럭을 가비지(garbage)라고 부른다. 가비지를 남겨두는 프로그램은 메모리 누수(memory leak)를 가진다. 어떤 프로그래밍 언어는 자동으로 가비지를 수거해서 재활용하는 garbage collector를 제공하기도 하는데, C는 그렇지 않다. C에서 가비지를 재활용하기 위해서는 free 함수를 호출하는 방법이 있다.
The free Function
free 함수는 헤더에 정의되어 있다. 다음을 보자.
void free(void *ptr);
사용법은 아주 간단하다. 필요없는 메모리를 인자로 넣어주면 된다.
free(p);
한 가지 주의할 점은 free의 인자는 메모리 할당 함수가 이전에 반환한 포인터여야만 한다. 변수나 배열의 요소와 같은 객체를 넣는다면 undefined behavior가 일어난다.
The "Dangling Pointer" Problem
free 함수는 "허상 포인터(dangling pointers)" 라는 문제를 낳는다. free(p) 호출은 p가 가리키는 메모리 블럭을 할당 해제하지만, p 자체를 바꾸지는 않는다. 만약 우리가 p가 메모리 블럭을 더 이상 가리키지 않는다는 사실을 잊는다면, 사고가 일어난다.
char *p = malloc(4); free(p); strcpy(p, "abc"); /***WRONG***/
할당 해제된 메모리 블럭에 접근하려 한다면 undefined behavior가 일어난다.
17.5 Linked Lists 연결 리스트
메모리 동적 할당은 리스트, 트리, 그래프나 다른 연결 자료구조를 만들 때 특히 유용하다. 연결 리스트는 노드(node)라는 구조체의 연결로 구성되어 있다. 각각의 노드는 다음 노드를 가리키는 포인터를 가진다. 마지막 노드는 널 포인터를 갖게 되겠지?
Declaring a Node Type
struct node { int value; struct node *next; };
node 구조체가 멤버로 node 구조체 포인터를 갖고 있다는 점을 주목해 보자. 이 경우, 구조체 태그를 사용해야 한다. (typedef는 유효하지 않다) 태그가 없다면 next의 타입을 선언할 방법이 없겠지?
Creating a Node
노드를 만들기 위해서는 세 단계를 거쳐야 한다. (1) 노드를 위한 메모리를 할당하고 (2) 노드 안에 데이터를 저장하고 (3) 리스트 안에 노드를 삽입한다.
유의할 점은 다음과 같다. 노드에 메모리를 할당할 때는 다음과 같은 코드를 사용한다.
new_node = malloc(sizeof(struct node)); new_node = malloc(sizeof(new_node));
둘 중 유효한 코드는 뭘까? 당연히 위의 코드이다. 밑의 코드는 노드 구조체가 아니라, 노드를 가리키는 "포인터"가 들어갈 공간을 할당한다. 헷갈리지 말자!
new_node에 값을 할당하려면, 다음과 같은 코드를 사용한다.
(*new_node).value = 10;
이 때, 우리가 의도한 결과를 얻으려면 *new_node를 꼭 괄호로 둘러싸야 한다. *연산자보다 .연산자가 우선하기 때문에 괄호가 없으면 잘못된 결과가 나온다.
The -> Operator
구조체 멤버에 포인터를 사용해서 접근하는 일은 흔하기 때문에, C는 특별한 연산자를 제공한다. -> 연산자를 사용한다면 위의 코드를 더 간편하게 바꿀 수 있다.
(*new_node).value = 10; new_node->value = 10;
-> 연산자는 lvalue를 생성하기 때문에, 다양한 곳에 사용될 수 있다. 할당의 왼쪽에 나타날 수도 있고, scanf를 호출할 때 사용될 수도 있겠지?
scanf("%d", &new;_node->value);
new_node는 포인터지만 & 연산자가 사용되었다는 점에 주목해 보자. new_node->value의 값은 int형이다. 포인터 형이 아니다! (scanf의 두 번째 인자는 address인 거 기억나지?)
Inserting a Node at the Beginning of a Linked List
~
Ordered Lists
연결리스트의 삽입, 탐색, 삭제 연산을 다루고, 정렬된 연결 리스트를 소개하는 내용이다.
자세한 내용은 자료구조 관련 서적을 참고하자. (내용 측면에서 특별할 게 없다)
17.6 Pointers to Pointers 이중 포인터
"이중 포인터" 개념은 연결된 자료구조에서 빈번하게 나타난다. 함수의 인자가 포인터 변수일 때, 인자로 받은 변수를 수정해서 다른 어딘가를 가리키도록 하는 함수를 만들고 싶을 때가 있다. 이러한 함수를 만드려면 이중 포인터를 사용해야 한다.
연결리스트의 처음에 노드를 삽입하는 add_to_list 함수를 보자. 우리가 이 함수를 호출하면, 리스트의 첫 번째 노드를 가리키는 포인터를 함수의 인자로 넣게 된다. 그 후 함수는 갱신된 리스트의 첫 번째 노드를 가리키는 포인터를 반환한다.
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
"); exit(EXIT_FAILURE); } new_node->value = n; new_node->next = list; return new_node; }
new_node를 반환하는 대신, list에 new_node를 넣도록 함수를 바꿔 보자. 만약 return문을 없애고 list = new_node; 로 바꾸면 될까? 다음 코드로 함수를 호출해보자.
add_to_list(first, 10);
우선, first는 list에 복사될 것이다. 함수의 마지막 줄은 list의 값을 바꾼다. (new node를 가리키도록)
하지만, 이 할당이 first에 영향을 주진 못한다. 전에 배웠던 것을 다시 떠올려보면, 함수 내부에서 return 없이 인자를 수정하려면 포인터를 넣어주었다. 즉, 포인터형인 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
"); exit(EXIT_FAILURE); } new_node->value = n; new_node->next = *list; *list = new_node; }
새로운 add_to_list 함수를 호출하려면, 다음 예시와 같이 할 수 있다.
add_to_list(&first;, 10);
17.7 Pointers to Functions 함수 포인터
포인터는 데이터뿐만 아니라, 함수 역시 가리킬 수 있다. 변수가 고유의 주소를 갖고 있다는 건 기억나지? 함수 역시 메모리 공간을 차지하므로, 고유한 주소를 가지고 있다.
Function Pointers as Arguments
점 a부터 점 b까지 (수학에서 쓰이는) 함수 f를 적분하는 (CS에서 쓰이는) integrate 함수를 작성해보자. 이 함수를 만들 때, 함수 f를 함수 포인터로 선언해보자. 함수 f가 double형 매개변수와 반환값을 가진다면, integrate 함수의 프로토타입은 다음과 같다.
double integrate(double (*f)(double), double a, double b);
*f를 괄호로 둘러싼 것은 f가 포인터를 반환하는 함수가 아니라, 함수 포인터라는 것을 나타낸다 . f를 그냥 함수처럼 써주는 것도 가능하다.
double integrate(double f(double), double a, double b);
컴파일러가 볼 때는 두 종류의 프로토타입을 같은 형식으로 취급한다.
만약 사인함수를 0부터 π/2까지 적분하려면 이렇게 작성할 수 있겠지?
result = integrate(sin, 0.0, PI/2);
sin 뒤에 괄호가 없다는 점에 주목하자( sin()로 쓰이지 않았다). 함수 이름 뒤에 괄호가 없다면, C 컴파일러는 함수 호출을 위한 코드를 만드는 대신 함수의 포인터를 만든다. 예시를 통해 보면, integrate에 전달하는 sin은 함수 호출이 아니다. 함수 포인터다. 배열 이름을 포인터로 사용할 수 있다는 사실을 생각해 보면 이해가 더 잘 되겠지?
만약 integrate 함수 내에서 인자로 받은 f를 호출하려면 어떻게 해야할까?
y = (*f)(x);
와 같이 호출할 수 있다. *f는 f가 가리키는 함수를 나타내고, x는 호출에 사용되는 인자를 의미한다.
C는 (*f)(x)를 사용하는 대신 f(x)를 사용할 수 있도록 허용한다. (둘 다 f가 가리키는 함수를 호출)
하지만 f(x)가 보기엔 더 자연스러워도, f가 함수 포인터라는 걸 기억하려면 (*f)(x)를 사용하는 게 낫겠지?
The qsort Function
함수 포인터를 사용해서 퀵정렬을 구현한다. 역시 자료구조에서 배우는 내용이다.
Other Uses of Function Pointers
C는 함수 포인터를 데이터를 가리키는 포인터처럼 대한다. 따라서 변수에 함수 포인터를 저장하거나, 배열의 요소에 저장하거나, 구조체나 공용체의 멤버로 사용할 수도 있다. 심지어는 함수 포인터를 반환하는 함수를 작성할 수도 있다.
함수 포인터를 저장하는 변수를 살펴보자.
void (*pf) (int);
pf는 int 매개변수를 갖고 반환값이 void 형인 어떤 함수든 가리킬 수 있다. 만약 f가 여기에 해당되는 함수라면, 다음과 같이 pf가 f를 가리키도록 할 수 있다.
pf = f;
f 앞에 & 연산자가 없다는 점에 주목하자. pf가 f를 가리키도록 했으면, 이제 f를 다음과 같이 호출할 수 있다.
(*pf) (i); 또는 pf(i);
함수 포인터를 요소로 갖는 배열의 예시를 살펴보자. 명령어 메뉴를 보여준 뒤 사용자가 명령어를 고르도록 하는 프로그램을 작성할 때, 우리는 각각의 명령어들을 함수로 우선 작성하고 배열에 저장할 수 있다.
void (*file_cmd[])(void) = {new_cmd, open_cmd, close_cmd, ..};
배열 안의 함수를 호출하려면 다음과 같이 하면 되겠지?
(*file_cmd[n])(); 또는 file_cmd[n]();
PROGRAM: Tabulating the Trigonometric Functions
x값을 입력받아 그에 따른 삼각함수의 결과값을 표로 출력하는 프로그램을 작성한다.
17.8 Restricted Pointers (C99)
C99에서, restrict이라는 키워드는 포인터 선언 시 나타날 수 있다.
int * restrict p;
이 restrict는 무슨 기능을 할까? 다음 정의를 살펴보자.
The C99 keyword restrict is an indication to the compiler that different object pointer types and function parameter arrays do not point to overlapping regions of memory.
p가 나중에 수정된 객체를 가리킨다면, 그 객체는 p를 제외한 어떠한 방법으로도 접근될 수 없다.
If p points to an object that is later modified, then that object is not accessed in any way other than through p.
( 객체에 접근하는 대안은 같은 객체를 가리키는 또 다른 포인터를 갖거나, p가 변수 이름을 가리키도록 하는 것이 있다 )
객체에 접근하는 방법이 2개 이상인 객체를 종종 aliasing 이라고 부른다.
restricted pointer가 취하는 행동을 예시를 통해 알아보자.
int * restrict p; int * restrict q; p = malloc(sizeof(int)); q=p; *q=0; /* cause undefined bahavior */
p가 restricted pointer이기 때문에, *q=0; 구문을 실행할 때의 결과는 정의되지 않는다(undefined). 이 때 p와 q가 같은 객체를 가리키고 있으니, *p와 *q가 aliases임을 알 수 있을 것이다.
만약 restricted pointer p가 extern이라는 storage class 없이 지역변수로 선언되면, restrict는 오직 p가 선언된 블럭이 실행되는 동안만 적용된다. (함수의 몸체가 블럭이라는 걸 기억하자!) restrict pointer가 함수의 매개변수로 쓰인다면, 함수가 실행되는 동안에만 적용되겠지? 만약 restrict가 file scope를 지닌 포인터 변수에 적용된다면, 프로그램이 종료될 때까지 지속될 것이다.
restrict의 정확한 사용법은 더 복잡하다; C99 표준에서 더 자세히 알아보도록 하자. 앞서 우리는 restricted pointer 이외에 이 포인터가 가리키는 객체에 접근하는 방법이 없다고 배웠지만, restricted pointer에서 생성된 alias가 표준에 위배되지 않는 상황도 존재한다. 예를 들어, restricted pointer p는 또 다른 restricted pointer 변수인 q에 복사될 수 있다. 단, p가 함수의 지역변수이고 q가 함수의 몸체 안에 중첩된 블럭 안에서 정의될 때만 가능하다.
이번에는 헤더에 있는 memcpy와 memmove 함수를 보자. 우선 memcpy 함수부터 보도록 하자.
void *memcpy(void * restrict s1, const void * restrict s2, size_t n);
memcpy는 strcpy와 유사하지만, 바이트를 복사한다는 점에서 다르다. (strcpy는 문자열의 문자를 복사한다!)
s2는 복사될 데이터를 가리키고, s1는 복사될 데이터가 도착하는 곳을 가리키며, n은 복사될 바이트 개수를 의미한다.
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));
memcpy 함수에서만 restrict 키워드가 사용됨으로써 알 수 있는 점은 뭘까? 프로그래머에게 s1과 s2가 각각의 객체를 겹치지 않도록 가리켜야 한다고 알려준다는 것이다. 만약 겹친다면 함수의 작동을 보장할 수 없다는 거다.
restrict 키워드는 컴파일러가 더 효율적인 코드를 만들 수 있도록 정보를 제공하는 역할도 한다. 이 과정을 최적화 라고 한다. (storage class인 register도 마찬가지다) 결론적으로, C99 표준은 restrict가 표준을 준수하는 프로그램의 행동 에 어떠한 영향도 끼치지 않음을 보장한다. (그걸 처리하는 건 컴파일러의 영역 이므로) 만약 프로그램 내 모든 restrict 키워드를 지운다 해도, 프로그램은 똑같이 행동할 것이다.
17.9 Flexible Array Members (C99)
가끔 우리는 크기를 모르는 배열을 포함하는 구조체를 정의해야 할 때가 있다. 예를 들면, 문자열을 기존의 형식과는 다르게 저장하고 싶을지도 모른다. 보통 문자열은 널 문자로 마무리되는 문자형 배열이다. 하지만, 문자열을 다른 방식으로 저장하여 얻는 장점이 있다. 한 방법은 문자(널 문자 제외)와 함께 문자열의 길이를 저장하는 것이다. 길이와 문자는 다음과 같이 구조체에 저장될 수 있다.
struct vstring{ int len; char chars[N]; };
N은 문자열의 최대 길이를 나타내는 매크로다. 하지만 이처럼 고정된 길이의 배열을 쓰는 건 바람직하지 않다. 우리에게 문자열의 길이를 강제하고 메모리도 낭비시키기 때문이다. (항상 N 길이만큼 꽉꽉 채워서 문자열을 만들진 않으니까!)
프로그래머들은 chars의 길이를 1로 선언하고 각 문자열마다 메모리를 동적 할당하여 이러한 문제를 해결하였다.
struct vstring{ int len; char chars[1]; }; ... struct vstring *str = malloc(sizeof(struct vstring) + n - 1); str -> len = n;
이 기술은 " struct hack " 으로도 불린다. struck hack은 문자형 배열에만 쓰이지 않고, 다양하게 쓰일 수 있다. GCC를 포함한 몇몇 컴파일러는 문자형 배열의 길이를 0으로 설정하도록 허용하여, 이 기술을 조금 더 명시적으로 쓰일 수 있게 하였다. ( zero length array ) 하지만 C89 표준은 struck hack의 작동을 보장하지 않는다. (zero length array 또한 마찬가지다)
이러한 struck hack의 유용성을 감안하여, C99는 flexibe array member로 알려진 특징을 가지고 있다. (struck hack과 사용 목적은 똑같다) 구조체의 마지막 멤버가 배열일 때, 배열의 크기는 생략가능하다.
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 자체는 복사되지 않는다. 이러한 구조체는 incomplete type 이라고도 한다. Incomplete type은 얼마나 많은 메모리가 필요한지에 대한 정보를 가지고 있지 않다. 따라서 이러한 타입은 다양한 제약에 묶여있다. 특히, incomplete type은 다른 구조체의 멤버나 배열의 요소로 사용될 수 없다. 하지만, 배열은 flexible array member를 갖는 구조체 포인터 를 포함시킬 수 있다.
Q
Q: NULL 매크로는 무엇을 나타내는 건가요?
A: NULL은 사실 0을 의미한단다. 우리가 만약 포인터가 들어가야 할 자리에 0을 써준다면, 컴파일러는 받은 정수 0 대신 널 포인터로 인식하게 될거야. NULL 매크로는 단지 혼동을 피하고자 제공되는 거란다!
p = 0 ;
위 구문은 변수 p에 0값이 할당된건지, 아니면 포인터 변수에 널 포인터가 할당된건지 쉽사리 구분할 수 없지만,
p = NULL ;
이렇게 쓰면 p가 포인터라는 걸 확실히 알겠지?
Q: 제 컴파일러의 헤더 파일을 보니 NULL이 이렇게 정의되어 있어요.
#define NULL (void *) 0
0을 void*형으로 캐스팅해서 얻는 이점이 뭐죠?
A: 캐스팅이 없어도 표준에 위배되는 건 아니란다! 하지만 캐스팅을 해줌으로써 컴파일러가 우리에게 경고를 해 줄 수 있어 . 우리가 정수를 넣어야 하는 자리에 포인터를 넣는다면 경고해주겠지?
일반적인 함수 호출에서는 NULL이 그냥 0으로 정의되어 있어도 컴파일러가 알아서 포인터로 인식할거야. 함수의 프로토타입으로부터 포인터가 들어와야 할 자리에 대한 정보를 받는거지. 하지만 가변 길이 인자 리스트를 가진 함수에 NULL을 인자로 넣어준다 고 생각해보자. 이런 특수한 경우, NULL이 0으로 정의되어 있다면 컴파일러는 포인터로 인식하지 못하고 그저 정수형 0 값으로 인식한단다. 당연히 의도한 결과가 나오지 않겠지?
Q: 0이 널 포인터를 나타내는 데 사용되니, 널 포인터는 모든 게 0비트로 이루어진 주소가 맞나요?
A: 꼭 그렇진 않아! C 컴파일러에 따라 다르단다. 어떤 컴파일러는 널 포인터를 위해 " 존재하지 않는 메모리 주소 "를 사용해. 이렇게 되면 널 포인터로 메모리에 접근하는 행위는 하드웨어에 감지될거야.
Q: NULL을 널 캐릭터('\0')로 사용해도 되나요?
A: 절대 아니야. NULL은 널 포인터를 나타내지, 널 캐릭터를 나타내지는 않아. 어떤 컴파일러는 NULL을 널 캐릭터로 사용해도 되긴 하는데, 모든 컴파일러가 그런건 아니야. 만약 널 캐릭터에 이름을 지어주고 싶으면, 매크로를 이렇게 써보렴.
#define NUL '\0'
Q: 제 프로그램이 종료될 때, "Null pointer assignment"라는 메시지를 받았어요. 이게 무슨 뜻이죠?
A: 이 메시지는 프로그램이 데이터를 "올바르지 않은 포인터가 사용된(using a bad pointer)" 메모리 안에 저장할 때 나타나. (bad pointer가 꼭 널 포인터는 아니야!) 이러한 메시지가 나타나는 상황에 대해 몇 가지만 살펴보자면, 다음과 같아.
scanf("%d", i); /* missing & */ *p = i; /* p is uninitialized or null */
Q: 프로그램은 어떻게 "null pointer assignment"가 발생했는지 아는 거죠?
A: 대규모 크기가 아닌 메모리 모델에서, 데이터는 0으로 시작하는 주소를 가진 single segment에 저장된단다. 컴파일러는 data segment의 시작에 "구멍"을 남겨놔. 이 구멍은 0으로 초기화되지만 프로그램에서 사용되진 않아. 프로그램은 종료될 때 모든 데이터를 검사해. (구멍에 0이 아닌 값이 있는지를) 만약 0이 아닌 값이 있다면, 올바르지 않은 포인터로 프로그램이 변경되었다는 뜻일거야.
Q: malloc이나 다른 메모리 할당 함수의 반환값을 캐스팅해서 얻는 이점이 있나요?
A: 보통은 없어. void*형 포인터는 할당될 시에 자동으로 변환되기 때문에 캐스팅이 불필요해. C89에서는 오히려 캐스팅을 안함으로써 얻는 이점이 있어. 를 빼먹고 malloc 함수를 호출했다고 가정해보자. 컴파일러는 malloc 함수의 반환값이 int형이라고 가정할거야. 만약 우리가 캐스팅을 하지 않았다면, 컴파일러가 경고나 에러를 보내겠지? 포인터가 들어가야 할 자리에 int 값을 넣으려고 시도하니까. C99에서는 캐스팅을 안해서 생기는 이점이 딱히 없어. C99에서는 함수가 호출되기 전 미리 선언되어 있어야 하므로, 헤더를 빼먹었다면 malloc을 호출할 때 에러가 나오겠지.
Q: calloc 함수는 메모리 블럭의 비트들을 0으로 설정하잖아요. 이건 블럭 안의 모든 데이터 항목이 0이 된다는 걸 의미하는 건가요?
A: 보통은 그렇지만, 항상은 아니야. 정수를 0 비트들로 설정하는 건 항상 그 정수를 0으로 만들어. floating-point number를 0비트들로 설정하는 건 보통 number 자체를 0으로 만들지만, 이 결과가 항상 보장되는 것까지는 아니야. floating-point number가 어떻게 저장되었느냐에 달렸어. 이는 포인터에도 똑같은 내용이야. 모든 비트들이 0인 포인터가 꼭 널 포인터인 것은 아니지.
Q: 구조체 태그는 구조체가 구조체 자기 자신을 포인터로 가리켜 (멤버로) 포함시키는 걸 허용하잖아요. 만약 두 구조체가 서로를 가리키는 포인터를 멤버로 가지는 것도 되나요?
A: 이게 우리가 다뤄야 할 상황이지?
struct s1; /* incomplete declaration of s1 */ struct s2 { ... struct s1 *p; ... }; struct s1 { ... struct s2 *q; ... };
s1의 첫 번째 선언에서 우리는 s1의 멤버를 알 수가 없어. s1의 두 번째 선언에서야 구조체의 멤버가 나타나고, s1의 타입은 "완전(complete)"한 상태가 되지. 제약이 조금 있지만, 구조체의 불완전(incomplete)한 선언은 C에서 허용된단다. 우리가 p를 선언할 때 포인터를 만들었지? 이게 불완전한 선언을 하는 방법 중 하나라고 보면 돼.
Q: malloc에 인자를 잘못 넣어서 호출하면 너무 많거나 적은 메모리를 할당하게 되잖아요? 이게 흔한 에러처럼 보이는데, 좀 더 안전하게 malloc을 쓰는 방법은 없나요?
A: 물론 있단다. 몇몇 프로그래머들은 단일 객체에 메모리를 할당할 때 이러한 관용구를 쓰기도 해.
p = malloc(n * sizeof(*p));
sizeof (*p) 는 p가 가리키는 객체의 크기니까, 이 구문은 올바른 크기의 메모리가 할당될 것이라는 걸 보장하겠지? 처음 이 구문을 보면 수상한 냄새가 날 거야. p가 초기화되지 않았고, *p의 값을 undefined로 만드는 건 아닐까라는 생각이 들겠지. 하지만 sizeof 연산자는 *p를 평가하지 않고, 단지 크기만을 계산해. 그래서 이 관용구는 p가 초기화되지 않았거나 널 포인터를 갖고 있다 해도 작동한단다. n개의 요소를 갖는 배열에 메모리를 할당하려면 다음과 같은 관용구를 사용할 수 있겠지?
p = malloc(n * sizeof(*p));
Q: 문자열의 배열을 정렬하고 싶어서, strcmp 함수를 문자열을 비교해주는 함수로 쓰려고 했어요. 그런데 제가 strcmp 함수를 qsort의 인자로 넣으니 컴파일러가 경고를 띄웠습니다. 일단 strcmp를 제가 만든 함수에 끼워넣었어요.
int compare_strings(const void *p, const void *q) { return strcmp(p, q); }
이렇게 하니 프로그램이 컴파일은 되는데, qsort가 정렬을 하지 않아요. 뭐가 문제일까요?
A: 우선 qsort의 인자로 strcmp 함수 자체를 넣을 순 없어. qsort에는 두 개의 const void*형을 매개변수로 가진 함수만 들어갈 수 있어. strcmp 함수의 매개변수는 const char*형이잖니?
너가 만든 compare_strings 함수가 작동하지 않는 이유는 네 함수가 p와 q를 문자열(=char*형)이라고 잘못 가정해서야. 사실 p와 q는 char*형 포인터를 갖는 배열 요소들을 가리키고 있지?
<이해를 돕기 위한 예시>
char* p[n] = {"AAA","BBB", ... }
네가 만든 함수를 고치려면, p와 q를 char**형으로 캐스팅하고, * 연산자를 사용해서 간접 참조를 한 번 해줘야 해.
이렇게 써주면 되겠지.
int compare_strings(const void *p, const void *q) { return strcmp(*(char **)p, *(char **) q); }
이렇게 17장이 마무리되었습니다. 어려운 내용을 적절한 예시로 풀어나간다는 점에서 참 좋은 교재인 것 같아요!
calloc 함수는 원서 표현이 "allocate memory and clear it"으로만 되어 있어서, 왜 "initialized to zero"라는 더 이해하기 쉬운 표현을 쓰지 않는 걸까 의문이 들었는데, Q&A;를 보고 제가 잘못 알고 있었다는 걸 깨달았습니다. 항상 0으로 초기화되는 것이 아니라는 점이 충격이네요.
다음 18장에서는 선언에 대해서 알아보겠습니다.
from http://dm0ng.tistory.com/4 by ccl(A) rewrite - 2021-08-27 20:00:26