15. 응용포인터
1 포인터의 응용
우선 간단하게 포인터에 대한 개념을 정리한 후에 응용되는 예를 하나씩 살펴보도록 하자. 포인터는 다음의 두 가지 내용만 충실히 이해하고 있으면 얼마든지 응용이 가능하다.
|
첫째, 포인터가 가리키는 자료형은 무엇인가? |
포인터는 자료형에 대한 정보를 가지고 있으므로 특정 자료형의 기억공간을 가리킨다. 예를 들어, int val;이 선언되어 있을 때 포인터 &val은 int형을 가리킨다고 말한다.
|
int val; &val → int형 변수 (포인터 &val은 int형을 가리킨다.) double val; &val → double형 변수 (포인터 &val은 double형을 가리킨다.) 둘째, 포인터는 같은 자료형을 가리키는 포인터변수에 저장해야 한다. |
포인터를 포인터변수에 저장할 때는 반드시 포인터가 가리키는 자료형과 같은 자료형을 가리키는 포인터변수를 사용해야 한다. 예를 들어, int형 변수 val의 포인터를 저장하는 포인터변수는 다음과 같이 선언해야 한다.
|
int * ip; (int : 가리키는 자료형, * : ip는 포인터변수다., ip : 변수의 이름) ip = &val; //포인터를 포인터변수에 대입 ↓ ↓ 가리키는 자료형은 모두 int형으로 같다! |
2 다중포인터
포인터를 저장한 포인터변수는 주로 참조연산자를 사용하여 그것이 가리키는 기억공간을 참조하는데 사용된다. 그러나 그 자체도 하나의 변수이므로 주소연산자를 사용하여 포인터를 구할 수 있다. 즉, 포인터변수를 가리키는 포인터를 구하는 것이다. 이러한 포인터를 다중포인터라고 한다(다중의 의미는 2중, 3중, 그 이상이 가능함을 말한다).
|
int *ip; &ip → (int *)형 변수 // 포인터 &ip는 (int *)형을 가리킨다. 이중포인터 포인터변수 |
이중포인터를 사용하여 변수에 저장된 값을 참조할 때는 조금 복잡한 참조과정을 거쳐야 한다. 이중포인터가 가리키는 것은 포인터변수이므로 참조연산자를 사용하면 포인터변수에 저장된 포인터값을 구하게 된다. 결국 포인터변수가 가리키는 기억공간의 값을 참조하기 위해서는 다시 한번 참조연산자를 사용해야 하는 것이다.
이중포인터 또한 하나의 포인터이므로 포인터변수를 선언하여 저장할 수 있을 것이다. 용어 자체는 복잡해 보이지만 서두에 강조한 원리만 이해하면 이중포인터변수를 간단하게 만들 수 있다. 즉, 이중포인터가 가리키는 자료형을 파악한 후에 같은 자료형을 가리키는 포인터변수를 선언하면 되는 것이다.
이중포인터 &ip는 (int *)형 변수의 포인터이므로 결국 이중포인터가 가리키는 자료형은 (int *)형이 될 것이다. 따라서 다음과 같이 (int *)형을 가리키는 이중포인터변수를 선언해 준다.
|
int * * ipp; (int * : 가리키는 자료형, * : ipp는 포인터변수다., ipp : 변수의 이름)
ipp = &ip; // 이중포인터를 이중포인터변수에 대입 |
변수명 앞에 있는 별표는 변수가 포인터변수임을 표시하는 것이며 그 나머지가 가리키는 자료형이 되는 것이다.
이중포인터변수도 이중포인터와 마찬가지로 참조연산자를 두 번 사용하면 변수에 저장된 값을 참조할 수 있을 것이다.
|
int val=10; // int형 변수의 선언과 초기화 int *ip; // 포인터변수 선언 int **ipp; // 이중포인터변수 선언 ip=&val; // int형 변수의 포인터를 포인터변수에 저장 ipp=&ip; // 포인터변수의 포인터를 이중포인터변수에 저장 printf("변수 val의 값 : %d\n", **ipp); // 참조연산자를 두 번 사용하여 참조한다. |
이제 이중포인터변수를 사용하는 예를 한 가지 살펴보자. 다음과 같이 두 개의 포인터변수가 문자열을 연결하고 있다고 하자.
|
char *ap = "success"; char *bp = "failure";
100 101 102 103 104 105 106 107 ap 100 → s u c c e s s \0
200 201 202 203 204 205 206 207 bp 200 → f a i l u r e \0
|
이때, 두 포인터변수가 가리키는 문자열을 서로 바꾸는 코드를 작성해 보자.
|
char *ap = "success"; char *bp = "failure"; char *tp; // 교환을 위한 임시 포인터변수 tp=ap; // 임시 포인터변수를 사용하여 두 포인터변수의 값을 바꾼다. ap=bp; bp=tp; printf("ap -> %s, bp -> %s\n, ap, bp); |
|
출력 결과 ap -> failure, bp -> success |
이 방법은 두 개의 정수값을 임시변수를 사용하여 바꾸는 것과 같다. 즉, 두 포인터변수에 저장된 포인터값을 임시포인터변수를 사용하여 바꾸는 것이다. 이렇게 포인터값만 교환하면 두 포인터변수는 서로 다른 문자열을 가리키게 될 것이다.
|
100 101 102 103 104 105 106 107 ap 200 ↘ s u c c e s s \0
200 201 202 203 204 205 206 207 bp 100 ↗ f a i l u r e \0 |
그런데 이러한 작업이 프로그램에서 자주 발생한다면 함수를 만들어 사용하는 것이 좋을 것이다. 이 함수의 기능은 두 포인터변수 ap, bp의 값을 바꾸는 것이다. 따라서 ap, bp의 포인터를 전달인자로 주고 함수가 그 위치를 참조하여 두 값을 바꾸도록 해야 할 것이다.
|
exchange_ptr(&ap, &bp); // ap, bp의 포인터를 전달인자로 주고 호출한다. |
ap, bp는 포인터변수이므로 함수의 전달인자는 이중포인터가 될 것이다. 따라서 매개변수는 이중포인터변수로 선언해야 하며, 주소에 의한 호출이므로 특별히 리턴값은 필요 없을 것이다.
|
void exchange_ptr(char **ap, char **bp); // 함수의 원형 |
이제 함수의 정의 부분에서 매개변수 app, bpp를 사용하여 ap, bp의 값을 바꿔주면 될 것이다. ap와 bp가 포인터변수이므로 이 값을 임시로 저장할 포인터변수를 하나 선언하고 다음과 같이 세 단계를 거치면 두 값이 바뀌게 된다.
|
char *tp; // 임시 포인터변수를 선언한다. ① tp = *app; // app가 가리키는 곳에 저장된 tp에 저장한다. ② *app = *bpp; // bpp가 가리키는 곳에 저장된 값을 app가 가리키는 곳에 저장한다. ③ *bpp=tp; // tp의 값을 bpp가 가리키는 곳에 저장한다. |
3 배열 포인터
다음과 같은 2차원 배열이 있다고 하자.
|
int ary[3][4] = { {1, 2, 3, 4}, {11, 12, 13, 14}, {21, 22, 23, 24} }; |
이 배열의 모든 값을 출력하는 함수를 만들어서 필요할 때마다 호출하고자 한다. 이때 함수의 전달인자는 배열명이 될 것이다. 배열명은 배열이 할당되어 있는 메모리의 시작 위치이므로 이 값을 함수가 받아서 배열요소를 참조하도록 하는 것이다.
|
ary_prn(ary); // 배열명을 전달인자로 주고 호출한다. |
결국 주소값인 포인터가 함수로 전달되는 것인데, 그렇다면 매개변수는 어떤 포인터변수를 사용해야 하는 것일까?
|
void ary_prn( ? ); // 함수의 선언 |
이 질문에 대한 답을 구하기 위해서는 먼저 배열명에 대한 정체를 파헤칠 필요가 있다.
배열명은 다음과 같은 두 얼굴을 가지고 있다.
|
첫째는, 첫번째 배열요소를 가리키는 포인터로서의 기능이다. |
따라서 배열명에 참조연산자를 사용하면 첫번째 배열요소를 참조할 수 있으며, 배열명을 변수에 저장할 때는 반드시 같은 자료형을 가리키는 포인터변수에 저장해야 한다. 또 정수값을 더하면 자신이 가리키는 자료형의 크기를 곱해서 더해주게 될 것이다.
|
int nums[5] = {10, 20, 30, 40, 50}; int *ip = nums; // 배열명은 포인터이므로 포인터변수에 저장한다.
printf("%d\n", *nums); // 첫번째 기억공간을 참조하여 10이 출력된다. printf("%d\n", *(nums+4)); // nums의 값이 100이면 116번지를 참조한다.
nums 10 20 30 40 50 | ↑ 배열명은 첫번째 배열요소를 가리키는 포인터! |
|
둘째는, 배열의 기억공간 전체를 나타내는 논리적 변수의 기능이다. |
배열명은 논리적 변수이기 때문에 일반 변수와 같이 기억공간을 바로 참조하는 용도로 사용할 수는 없다. 특정 기억공간을 참조하기 위해서는 첨자를 사용해야 하는 것이다.
|
int ary[5]; ary = 10; (X) // 배열명에 직접 값을 대입할 수 없다. ary[0] = 10; (O) // 특정 기억공간을 첨자로 지정하여 값을 대입한다. |
그러나 변수가 가지는 자료형에 대한 정보도 배열명은 가지고 있다. 그것은 크기와 형태에 대한 정보이다. 예를 들어, int val; 과 같이 변수를 선언하면 변수 val은 4바이트 크기의 정수형이라는 정보를 가지는 것이다.
배열도 마찬가지로 크기와 형태에 대한 정보를 가진다. 물론 배열은 응용자료형이므로 사용자가 어떻게 선언하느냐에 따라 크기와 형태는 항상 변하게 될 것이다. 예를 들어, 다음과 같은 배열이 선언되어 있다고 하자.
|
int ary[5]; → ary int int int int int ← 전체 20바이트 → |
이때 배열명 ary는 다음과 같이 크기와 형태에 대한 정보를 가지게 된다.
|
크기 : 20바이트 형태 : int형 변수 다섯 개의 배열형 결국 배열명은 논리적으로 배열 전체의 기억공간을 나타내는 변수와 같은 역할을 한다고 생각하면 될 것이다. 배열명은 이와 같이 포인터와 변수 두 가지 기능을 가지므로 연산식에 따라 서로 다른 기능을 수행하게 된다. 몇 가지 예를 살펴보면 경우에 따라 어떤 기능으로 사용되는지 쉽게 추측할 수 있다. ① 참조연산자를 사용하는 경우 : *ary - 포인터로서의 기능 참조연산자는 포인터나 포인터변수를 피연산자로 취하여 그들이 가리키는 기억공간을 참조하므로 ary는 변수명이 될 수 없다. ② sizeof 연산자를 사용하는 경우 : sizeof(ary) - 변수로서의 기능 ary가 포인터라면 연산결과는 포인터의 크기인 4바이트가 되어야 한다. 그러나 연산 결과는 배열 전체의 크기인 20바이트이다. ③ 함수의 전달인자로 사용되는 경우 : ary_prn(ary) - 포인터로서의 기능 ary가 변수라면 변수에 저장된 값을 전달인자로 주겠지만 배열명은 논리적 변수이므로 실제 기억공간의 값을 참조할 수 없다. 결국 함수에는 포인터값이 전달되는 것이다. ④ 정수값을 더하는 연산 : ary+1 - 포인터로서의 기능 ary는 논리적 변수이므로 기억공간에 저장된 값을 참조하여 정수값과 연산할 수 없다. 그러나 포인터라면 정수값 연산이 가능할 것이다. 이때 ary가 가리키는 자료형의 크기를 곱해서 더해주므로 ary의 값이 100이라면 연산 결과는 104가 될 것이다. |
그렇다면 다음 연산에서 배열명은 어떤 기능을 수행하는 것인지 생각해 보자.
|
&ary 주소연산자는 변수와 같이 그 위치를 알 수 있는 기억공간에 사용하여 시작주소값을 구해 주는 연산자이다. 따라서 ary는 변수명으로서의 기능을 수행할 것이다. |
그렇다면 &ary 연산의 결과로 구해진 포인터가 가리키는 자료형은 무엇일까? 바로 변수로서의 ary의 형태이며 그것은 "int형 변수 다섯 개의 배열형"이 되는 것이다. 즉, 이 포인터는 배열 전체의 기억공간을 가리키고 있는 것이다.
|
&ary → ( int int int int int ) 배열포인터 배열 전체를 가리킨다. |
배열명 ary가 포인터로서의 기능을 할 때는 배열의 첫번째 기억공간을 가리키지만 배열명에 주소연산자를 사용하여 구한 포인터는 배열 전체를 가리키는 것이다. 이 포인터를 배열포인터(배열을 가리키는 포인터)라고 한다.
물론 포인터로서의 ary와 ary에 주소연산자를 부터 구한 &ary의 값 자체는 같다. 그러나 두 포인터가 가리키는 자료형은 분명히 다르다는 것을 기억해야 한다.
배열포인터는 가리키는 것이 배열 전체이므로 정수값을 더하면 배열 전체의 크기를 곱해서 더해주게 된다. 간단히 코드를 작성하여 확인해 보도록 하자.
|
1. #include <stdio.h> 2. 3. int main() 4. { 5. int ary[5]; 6. 7. printf("ary 의 주소 : %u\n", ary); // 포인터로서의 배열명의 값 8. printf("&ary의 주소 : %u\n", &ary); // 배열을 가리키는 포인터의 값 9. 10. printf("ary+1 의 값 : %u\n", ary+1); 11. printf("&ary+1의 값 : %u\n", &ary+1); 12. return 0; 13. } |
|
출력 결과 ary 의 주소 : 1245036 &ary의 주소 : 1245036 ary+1 의 값 : 1245040 &ary+1의 값 : 1245056 |
출력 결과에서 알 수 있듯이 ary와 &ary의 값 자체는 같다. 모두 배열의 시작 위치값이 되는 것이다. 그러나 각각 가리키는 자료형의 크기가 다르기 때문에 두 포인터에 1을 더한 결과는 다르다. ary는 배열의 첫번째 요소를 가리키므로 가리키는 자료형의 크기는 4가 된다. 반면에 &ary는 배열 전체를 가리키므로 가리키는 자료형의 크기는 20이 될 것이다. 따라서 실제 연산은 다음과 같이 계산되는 것이다.
|
ary + 1 → 1245036 + (1 * sizeof(ary[0])) → 1245036 + (1 * 4) → 1245040 &ary +1 → 1245036 + (1 * sizeof(ary)) → 1245036 + (1 * 20) → 1245056 36 40 44 48 52 56 (할당되지 않은 기억공간) int int int int int int int int int int &ary &ary+1 ↑ ↑ int형 변수 다섯 개의 배열을 가리키는 배열포인터 |
1차원 배열에서는 배열명으로 각 기억공간을 참조하므로 배열포인터를 구하는 것이 의미가 없다. 배열포인터에 정수값을 더하면 배열이 할당된 메모리 영역을 벗어나게 되므로 이 값으로 기억공간을 참조하면 허용되지 않은 기억공간을 침범하게 될 것이다. 그러나 2차원 배열에서는 배열명 자체가 배열포인터로서 행렬의 논리적 구조를 만드는 결정적 역할을 한다.
2차원 배열은 같은 형태의 1차원 배열을 모아서 만든 새로운 배열이다. 즉, 2차원 배열은 1차원 배열을 배열요소로 갖는 것이다. 예를 들어, int ary[3][4]; 의 2차원 배열을 선언하면 이 배열의 배열요소는 세 개이며 배열요소의 형태는 "int형 변수네 개의 배열형"이 되는 것이다.
배열명은 배열의 첫번째 배열요소를 가리키는 포인터이므로, 결국 2차원 배열의 배열명은 첫번째 배열요소인 부분배열 전체를 가리키는 배열포인터가 되는 것이다.
|
int ary[3][4]; ary (배열명 ary는 첫번째 부분배열을 가리키는 배열포인터이다!) ↓ ary[0] → int int int int ary[1] → int int int int → 배열요소는 세 개 : 각 배열요소의 형태는 "int형 변수 네 개의 배열형" ary[2] → int int int int 부분배열명 부분배열명은 각 부분배열의 첫번째 배열요소를 가리키는 포인터이다! |
이제, 배열명 ary가 가리키는 것이 무엇인지 확인했으므로 포인터로서의 배열명 ary를 저장할 포인터변수를 선언해 보자. 배열포인터를 저장하는 배열포인터변수는 다음과 같이 선언한다.
|
int (* ap)[4]; (int [4] : 가리키는 자료형 - int형 변수 네 개의 1차원 배열, * : ap는 포인터변수다. ap : 변수의 이름) |
일단 변수명 앞에 별표를 붙여서 포인터변수임을 나타내고 괄호를 묶어준다. 그리고 양 옆에 가리키는 배열의 형태를 표시해 주는 것이다. 이때 가리키는 배열의 배열요소의 형태와 개수를 나누어 적어준다. 괄호가 생략되면 포인터배열(포인터변수들의 배열)을 선언하는 형식이 되므로 주의해야 한다.
배열포인터 변수가 2차원 배열의 배열명을 저장하면 배열포인터 변수도 배열명처럼 사용할 수 있게 된다. 배열포인터 변수를 사용하여 2차원 배열의 값을 출력하는 프로그램을 작성해 보자.
|
#include <stdio.h>
int main() { int ary[3][4]={{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}}; // 2차원 배열선언 int (*ap)[4]; // int형 변수 네 개의 배열을 가리키는 포인터변수 int i, j; // 반복 제어변수
ap=ary; // ap는 2차원 배열의 배열명처럼 쓰일 수 있다. for(i=0; i<3; i++) // 3행 반복 { for(j=0; j<4; j++) // 4열 반복 { printf("%5d", ap[i][j]); // 물리적 배열요소의 값 출력 } printf("\n"); // 한 줄에 한 행씩 출력하기 위해 줄을 바꾼다. } return 0; }
|
|
출력 결과 1 2 3 4 5 6 7 8 9 10 11 12 |
이제 배열의 모든 값을 출력하는 반복문 부분을 함수로 만들어 보자. 메인함수에서는 배열명을 전달인자로 주고 호출할 것이므로, 함수의 매개변수는 이 값을 저장할 배열포인터변수를 선언하면 될 것이다.
|
void ary_prn(int (*ap)[4]); // 함수의 선언, 매개변수는 배열포인터변수 (함수의 선언에는 변수명 생략이 가능하므로 int (*)[4]와 같이 적을 수 있다.)
#include <stdio.h>
void ary_prn(int (*)[4]); // 함수의 선언 int main() { int ary[3][4]={{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}}; ary_prn(ary); // 배열명을 전달인자로 주고 함수 호출 return 0; }
void ary_prn(int (*ap)[4]) // 매개변수는 배열포인터변수 { int i, j;
for(i=0; i<3; i++) // 3행 반복 { for(j=0; j<4; j++) // 4열 반복 { printf("%5d", ap[i][j]); // 물리적 배열요소의 값 출력 } printf("\n"); // 한 줄에 한 행씩 출력하기 위해 줄을 바꾼다. } } |
2차원 배열은 첨자를 두 개 사용하므로 배열을 행렬의 논리적 구조로 사용할 수 있도록 해준다. 그러나 물리적으로는 메모리에 1차원의 형태로 기억공간을 할당하게 된다. 1차원의 물리적 기억공간을 행렬의 2차원 구조로 참조할 수 있는 것은 2차원 배열의 배열명과 부분배열명이 각각 배열포인터와 포인터로서 적절한 기능을 수행하기 때문이다.
2차원 배열 ary가 다음과 같이 메모리에 할당되었다고 할 때 7번째 물리적 배열요소를 참조하는 주소계산 과정을 살펴보자.
|
ary → 배열명 ary의 값은 배열의 시작위치값 100과 같다 100 104 108 112 ary[0] → 1 2 3 4 116 120 124 128 ary[1] → 5 6 7 8 132 136 140 144 ary[2] → 9 10 11 12 부분배열명은 각 부분배열의 시작위치값이다. |
7번째 물리적 배열요소는 두 번째 부분배열에 속하므로 먼저 두 번째 부분배열의 시작위치값을 구해야 한다. 2차원 배열의 배열명은 첫번째 부분배열 전체를 가리키므로 배열명에 1을 더하면 두 번째 부분배열의 시작위치값을 구할 수 있다.
|
ary + 1 → 100+(1*sizeof(ary[0])) → 100+(1*16) → 116 이때 부분배열명 ary[0]는 변수의 기능을 하며 부분배열 전체의 크기를 계산한다. |
이제 두 번째 부분배열의 시작위치값은 구했는데, 우리가 원하는 값은 124번지이다. 언뜻 생각하면 116번지에 2를 더하면 7번째 물리적 배열요소의 위치를 구할 수 있을 듯하다. 그러나 (ary+1)+2의 값은 ary+3이 되는 것이고 이 값은 124번지가 아니라 148번지 값이 될 것이다.
이것은 (ary+1)의 연산의 결과로 구해진 116번지도 ary와 마찬가지로 두 번째 부분배열 전체를 가리키는 배열포인터가 되기 때문이다. 포인터는 혈통이 그대로 유전되므로 포인터에 정수를 더해서 구한 값도 포인터이며 가리키는 자료형은 변하지 않는다. 따라서 116번지 값에 참조연산자를 사용하여 두 번째 부분배열을 참조하는 과정이 필요하다. 즉, 2차원 배열의 두번째 배열요소를 참조하는 것이다. 부분배열을 참조한다는 것은 부분배열명을 구하는 것이다.
|
*(ary+1) → ary[1] // 두 번째 부분배열명 |
부분배열명은 부분배열의 첫번째 배열요소인 int형을 가리키는 포인터이므로 *(ary+1)의 연산결과는 5번째 물리적 배열요소를 가리키게 된다. 이제 이 값에 2를 더하면 우리가 원하는 124번지를 구할 수 있다.
|
*(ary+1)+2 → *(ary+1)+(2*sizeof(ary[1][0])) → 116+(2*4) → 124 두 번째 부분배열의 첫번째 배열요소의 크기를 계산한다. |
연산의 결과로 구해진 124번지는 7번째 물리적 배열요소를 가리키는 포인터이다. 따라서 그 기억공간을 참조하기 위해서는 마지막으로 참조연산자를 사용해야 할 것이다.
|
*(*(ary+1)+2) → ary[1][2] // 두 번째 부분배열의 세 번째 배열요소 |
지금까지 배열명에 정수를 더하고 참조연산을 수행하여 특정 기억공간을 참조하는 방법을 살펴보았다. 실제로 프로그램은 이러한 방식으로 메모리를 참조하므로 배열요소를 참조할 때는 포인터 연산식을 상용하는 것이 좋다. 그러나 포인터표현법은 포인터에 대한 정확한 이해가 필요하고 사용이 복잡하므로 주로 첨자를 사용한 배열표현을 많이 사용하는 것이다.
이번에는 조금은 생소한 함수포인터에 대해 살펴보자.
여기서 잠깐
2차원 배열 int ary[3][4];에서 다음 포인터들은 모두 같은 값을 가진다.
|
1. &ary // 2차원 배열 전체를 가리키는 포인터 2. ary // 첫번째 부분배열을 가리키는 포인터 3. &ary[0] // 첫번째 부분배열을 가리키는 포인터 4. ary[0] // 첫번째 부분배열의 첫번째 배열요소를 가리키는 포인터 5. &ary[0][0] // 첫번째 부분배열의 첫번째 배열요소를 가리키는 포인터 |
그러나 포인터는 가리키는 자료형이 다르면 같다고 할 수 없으므로 2, 3번과 4, 5번만 서로 같다고 할 수 있을 것이다. 포인터는 가리키는 자료형이 다르면 관계연산 자체가 불가능하다.
|
if(&ary=ary) 실행문장; // 컴파일 에러 |
좀더 정확히 얘기하면 2번과 3번도 차이가 있다. 2번은 배열명이고 3번은 단순한 포인터이기 때문이다. 배열명은 포인터뿐만 아니라 논리적으로 변수의 기능도 가지고 있다. 따라서 sizeof 연산을 수행하면 크기가 서로 다르게 계산된다.
|
sizeof(ary) // 배열 전체의 크기 48바이트 sizeof(&ary[0]) // 포인터의 크기 4바이트 |
4번과 5번도 마찬가지이다. 4번은 부분배열명으로 논리적으로 변수의 기능을 가지지만 5번은 단지 포인터일 뿐이다.
|
sizeof(ary[0]) // 부분배열 전체의 크기 16바이트 sizeof(&ary[0][0]) // 포인터의 크기 4바이트 |
4 함수 포인터
지금까지 포인터를 구체적이고 물리적으로만 이해하고 있었다면 함수포인터는 약간 추상적인 관점에서 생각할 필요가 있다.
함수포인터는 함수의 이름을 말한다. 즉, 함수명이 포인터라는 것이다. 그렇다면 함수포인터가 가리키는 것은 무엇일까?
함수를 작성하여 컴파일하면 함수의 정의는 기계어코드로 번역되어 실행파일의 한 부분을 차지하게 된다. 따라서 프로그램이 실행되면 함수 부분도 메모리에 올려져 실행될 것이다. 이때 함수를 실행시키기 위해서는 함수가 존재하는 메모리의 위치를 알아야 하는데 이 위치값이 바로 함수의 이름이 되는 것이다. 그래서 함수를 호출할 때 함수의 이름을 사용하는 것이다.
함수명이 포인터라는 증거는 참조연산자를 사용하면 알 수 있다. 포인터에 참조연산자를 사용하면 가리키는 대상을 참조할 수 있듯이, 함수명에도 참조연산자를 사용하면 그것이 가리키는 함수의 기능을 사용할 수 있다. 따라서 sum 함수는 다음과 같이 호출하는 것도 가능하다.
|
(*sum)(10, 20); // 10과 20을 전달인자로 주고 sum함수를 호출한다. (함수를 참조한 후에 호출해야 하므로 참조연산에 괄호를 사용한다.) |
보통은 참조연산자 없이 함수의 이름만으로 호출하는데, 함수 사용의 편의를 위한 특별한 경우로 생각할 수 있다.
이제 함수포인터를 저장할 수 있는 함수포인터변수를 만들고 포인터변수를 통하여 함수를 호출하는 방법을 생각해 보자. 포인터를 저장할 포인터변수는 포인터가 가리키는 것과 동일한 형태를 가리키도록 선언해야 한다. 따라서 먼저 sum이 가리키는 함수의 형태를 파악해야 할 것이다.
하나의 함수는 매개변수의 개수와 형태, 그리고 리턴값의 형태로 함수의 형태를 정의한다.(매개변수의 이름은 의미가 없다). 이것은 함수의 선언에서 알 수 있으며, 이것이 함수포인터가 가리키는 형태가 되는 것이다.
|
함수의 형태 ↓ ↓ int sum (int, int); // 함수의 선언 (int : 리턴값의 형태, (int, int) : 매개변수의 개수와 형태) |
이제 함수포인터 sum을 저장할 함수포인터변수를 선언해 보자.
|
가리키는 함수의 형태 : int형 값 두 개를 전달인자로 받고 int형 값을 리턴하는 함수 ↓ ↓ int (* fp) (int, int); (* : fp는 포인터변수다., fp : 변수의 이름) |
함수포인터변수를 선언할 때는 변수명 앞에 별표를 붙여 포인터변수임을 나타낸다. 그리고 가리키는 함수의 형태를 리턴값의 형태와 매개변수의 형태를 분리하여 표현해 준다. 이때 반드시 변수 이름을 별표와 함께 괄호로 묶어주어야 한다. 그렇지 않으면 포인터를 리턴하는 함수를 선언하는 문장이 될 것이다.
|
int *fp(int, int); → 괄호가 없으면 포인터를 리턴하는 함수가 된다! |
함수포인터변수를 선언한 후에는 함수포인터를 저장하여 마치 함수명처럼 사용할 수 있다. 이때 함수포인터도 함수명과 같이 참조연산자 없이 사용할 수 있다.
|
int (*fp) (int, int); // 함수포인터변수 선언 fp = sum; // 함수명을 함수포인터변수에 저장한다. fp(10, 20); // 함수포인터변수로 함수 호출, (*fp) (10, 20)도 사용 가능 |
함수포인터변수는 함수의 형태만 같으면 기능과 상관없이 모든 함수포인터를 저장할 수 있다.
|
int sum(int, int); // 두 정수값을 더해서 리턴하는 함수 int mul(int, int); // 두 정수값을 곱해서 리턴하는 함수 int max(int, int); // 두 정수값 중에서 큰 값을 리턴하는 함수 int (* fp) (int, int); → fp = sum; → fp = mul; → 모두 사용 가능하다. fp = max; → |
따라서 형태가 같은 다양한 기능의 함수를 선택적으로 호출하는 데 사용할 수 있다. 예를 들어, 다음과 같은 기능을 수행하는 함수를 만들고자 한다.
|
함수 func 1. 정수값 두 개를 키보드로부터 입력 받는다. 2. 두 정수값으로 연산을 수행한다. 3. 연산결과를 화면에 출력한다. |
이 함수의 기능에서 1번과 3번은 항상 변함이 없고 2번의 연산과정은 함수를 호출할 때 필요한 연산이 수행되도록 만들고자 한다. 즉, 함수의 일부분은 함수를 호출할 때 그 기능이 결정되도록 하자는 것이다.
이러한 함수를 만들기 위해서는 함수포인터변수를 매개변수로 사용해야 한다. func 함수를 호출할 때 원하는 기능의 함수포인터를 전달인자로 주면 func 함수에서는 함수포인터변수의 매개변수로 받아서 그 기능을 사용하는 것이다.
|
void func(int (*fp) (int, int)) // 함수포인터변수가 함수명을 저장하면 fp는 해당함수를 가리킨다. { ... fp(a, b) // 변수 a, b가 입력 받은 정수값일 때, a, b의 값을 ... // 전달인자로 주고 fp가 가리키는 함수를 호출한다. } |
그렇다면 함수포인터를 사용하여 프로그램을 작성한 이유는 무엇일까? 함수포인터를 사용하면 프로그램 유지보수에 도움이 되기 때문이다. 프로그램에 sum, mul, max 외에 새로운 기능의 함수가 추가되더라도 func 함수는 수정할 필요가 없을 것이다. 반면에 func 함수에서 다른 함수를 직접 호출한다면 새로운 기능이 추가될 때마다 func 함수를 수정해야 할 것이다. 만약 func 함수가 목적파일의 형태로 제공되어 수정할 수 없는 경우라면 함수포인터의 사용은 반드시 필요할 것이다.
마지막으로 자료형에 대한 정보가 없는 특이한 포인터를 살펴보자.
5 void 포인터
포인터의 생명은 바로 그 안에 가지고 있는 자료형에 대한 정보이다. 자신이 가리키는 기억공간의 형태를 알고 있어야 참조가 가능하기 때문이다. 그러나 경우에 따라 이러한 정보가 없는 포인터가 사용되는데 이를 void 포인터라고 한다.
보통의 포인터는 변수에 주소연산자를 사용해서 구하지만 void 포인터는 다른 포인터에 형변환연산자를 사용하여 강제로 만들어줘야 한다. 즉, 포인터가 가진 정보를 제거하는 것이다.
|
int a; // int형 변수 선언 (void *) &a; // int형 변수의 포인터를 void 포인터로 강제 형변환 |
생명을 잃은 포인터는 더 이상 포인터로서의 역할을 수행하지 못한다. 자신이 무엇을 가리키는지 알지 못하므로 대부분의 포인터연산을 수행할 수 없다. 참조연산자를 사용하여 가리키는 기억공간을 참조할 수 없으며, 정수값을 더하는 연산도 불가능할 것이다.
따라서 void 포인터를 일부러 만들어 사용할 일은 없을 것이다. 다만 메모리를 동적 할당하는 함수의 경우 리턴값으로 사용되는데, 이 내용은 다음 장에서 자세히 살펴볼 예정이다.
포인터뿐만 아니라 포인터변수도 void형으로 선언할 수 있다. void 포인터변수를 만드는 방법은 다음과 같다.
|
void * vp; (void : 가리키는 자료형이 정해져 있지 않다., * : vp는 포인터변수다., vp : 변수의 이름) |
void 포인터변수는 가리키는 자료형이 정해져 있지 않으므로 모든 포인터를 저장할 수 있다.
|
int in; // int형 변수 double db; // double형 변수 void *vp; // void형 포인터변수 vp=∈ // int형 변수의 포인터를 저장할 수 있고, vp=&db; // double형 변수의 포인터도 저장할 수 있다. |
그러나 역시 가리키는 자료형에 대한 정보가 없으므로 참조연산이나 정수값 연산이 불가능하다. 참조연산의 경우는 메모리의 특정 번지로 가서 몇 바이트를 알면 어떤 형태로 읽어야 할지 알 수 없으며, 정수값 연산도 얼마를 곱해서 더해야 하는지 알 수 없기 때문이다.
따라서 void 포인터변수를 사용할 때는 원하는 형태로 변환하여 사용해야 한다.
|
printf("%d\n", *(int *)vp); // 형변환 후에 참조연산자로 저장된 값을 출력한다. vp = (int *)vp+1; // 형변환 후에 정수값을 더한다. |
대입연산의 경우는 형변환 없이 void 포인터나 void 포인터변수를 다른 포인터변수에 대입할 수 있다. 그러나 C++ 문법이 적용되는 VC++ 컴파일러는 명시적인 형변환 없이 대입하는 것이 불가능하다. C++ 문법에서는 형변환 규칙이 보다 엄격하기 때문이다(파일명의 확장자를 .c로 저장하여 컴파일하면 C++ 문법이 적용되지 않으므로 대입이 가능하다). 호환성 있는 프로그램을 작성한다면 항상 명시적으로 형변환하여 사용하는 것이 좋을 것이다.
|
int *ip = (int *)vp; // 명시적으로 형변환하여 대입하는 것이 좋다! |
void 포인터변수는 모든 형태의 포인터를 저장할 수 있으므로 이 포인터변수를 매개변수로 사용하면 다양한 형태의 포인터를 전달인자로 받을 수 있는 함수를 만들 수 있다. 예를 들어, 포인터에 의해서 두 수의 값을 바꾸는 exchange 함수는 보통 특정 자료형만 처리할 수 있도록 만들어진다. 즉, int형 변수의 값을 바꾸도록 만들면 double형 변수의 값을 바꿀 수 없다는 것이다.
그러나 매개변수로 void 포인터변수를 사용하면 모든 포인터를 전달인자로 받을 수 있으므로 정수값이나 실수값 모두 교환하는 것이 가능하다. 단, 자료형에 대한 정보를 추가로 전달인자로 넘겨줘야 할 것이다.
|
1. #include <stdio.h> 3. 4. void exchange(char *, void *, void*); // 함수의 선언 11. 12. exchange("double", &da, &db); // 두 실수값을 교환한다. 16. 17. void exchange(char *type, void *vp1, void *vp2) // 함수의 정의 21. 22. if(strcmp(type, "int")==0){ // 교환할 자료형이 "int"형이면 |
9번 줄이 exchange 함수를 호출하는 문장인데, 지금까지 작성했던 exchange 함수와는 다르게 전달인자가 3개이다. 즉, 추가된 첫번째 전달인자는 교환할 값이 어떤 자료형인지 알려주는 역할을 하는 것이다. 그리고 나머지 전달인자는 교환할 변수의 포인터를 넘겨주게 된다.
함수가 호출되면 17번 줄에서 매개변수에 전달인자를 저장할 것이다. 첫번째 전달인자는 문자열이므로 char 포인터변수로 받고, 두 번째와 세 번째는 어떤 포인터가 전달인자로 넘어올지 모르기 때문에 void 포인터변수를 선언할 것이다.
22번 줄에서 31번 줄까지 문자열을 비교하여 자료형에 맞게 void 포인터변수를 형변환하는 부분이다. 형변환한 후에는 각각 가리키는 기억공간을 참조하여 값을 바꾸어야 할 것이다. 형변환 연산자와 참조연산자는 모두 단항 연산자로서 우선순위가 같다. 이 경우 연산순서는 오른쪽부터 왼쪽으로 차례로 연산이 수행된다.
|
* (int *) vp1 | | | ↓ ↓ ① vp1을 (int *)형으로 변환 ② 변환된 vp1이 가리키는 기억공간 참조 |
void 포인터변수를 명시적으로 형변환하는 것을 제외하면 지금까지의 exchange 함수와 특별히 다른 것은 없다.
|
Reference : 뇌를 자극하는 C 프로그래밍, 서현우, 한빛미디어 |