함수 포인터란?
함수 포인터는 함수의 코드가 시작되는 메모리 주소를 저장하는 변수이다.
일반 포인터가 변수 데이터의 주소를 담는다면, 함수 포인터는 코드의 주소를 담는다. 프로그램이 실행되면 함수의 기계어 코드도 메모리의 코드 영역에 올라가기 때문에, 함수도 주소를 가지게 된다.
int x = 10;
int *p = &x;
int add(int a, int b){ return a + b; }
int (*fp)(int, int) = add; // 함수 포인터
선언 읽는 법
int (*fp)(int x, int *p);
// (*fp) : fp 는 포인터다
// (int x, int *p) : 그 포인터는 int 와 int *를 받는 함수를 가리킨다
// 맨 앞 int : 그 함수는 int 를 반환한다.
안쪽에서 바깥쪽으로 읽으면 된다. 괄호를 빼면 의미가 완전히 달라지기 때문에, 괄호가 꼭 있어야 한다.
int *fp (int x, int *p); // fp 는 int*를 반환하는 함수 선언
int (*fp)(int x, int *p); // fp는 함수를 가리키는 포인터
대입과 호출
- 함수 이름은 표현식 안에서 자동으로 주소로 변환된다. add 와 &add 는 같은 주소이다.
- 호출 fp(...) = (*fp)(...) 같은 의미이다.
- 시그니처 (반환형 + 매개변수 타입) 가 일치하는 함수만 대입해야 한다. 타입이 다른 함수를 억지로 캐스팅해서 호출하면 정의되지 않은 동작이다.
#include <stdio.h>
int add(int x, int *p) { return x + *p; }
int sub(int x, int *p) { return x - *p; }
int main(void) {
int (*fp)(int, int *);
int a = 3;
fp = add; // &add 와 동일
printf("%d\n", fp(10, &a)); // 13
fp = sub; // 같은 변수가 다른 함수를 가리킴
printf("%d\n", (*fp)(10, &a)); // 7
return 0;
}
전체적인 함수 포인터 코드
fp 가 함수를 직접 담는게 아닌, 주소만 들고 있다. 호출하는 순간 그 주소로 점프한다.
1. 변수에 담아서 호출
#include <stdio.h>
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int main(void) {
int (*fp)(int, int); // 1. 함수 포인터 변수 선언
fp = add; // 2. add 의 주소를 담음
printf("%d\n", fp(5,3)); // 3. add 호출
fp = sub; // 같은 변수에 다른 함수 담기
printf("%d\n", fp(5,3)); // sub 호출
return 0;
}
2. 콜백 형태
계산 방법을 인자로 넘긴다.
#include <stdio.h>
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }
int calc(int a, int b, int (*op)(int, int)) {
return op(a, b);
}
int main(void){
printf("%d\n", calc(5, 3, add)); // 자동으로 주소 &add 로 변환됨
printf("%d\n", calc(5, 3, mul));
}
목적은 무엇을 할지 호출하는 쪽이 정하게 해서, 같은 코드가 다른 동작을 하게 하는 것이다. 함수 포인터는 C 에서 다형성을 구현하는 도구이다.
3. 함수 테이블
#include <stdio.h>
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int main(void) {
int (*ops[3])(int, int) = { add, sub, mul };
// 0번 1번 2번
for (int i = 0; i < 3; i++) {
printf("%d\n", ops[i](5, 3)); // 8, 2, 15
}
return 0;
}
함수 포인터를 왜 쓸까?
함수 포인터는 어떤 함수를 실행할지를 실행 중에 정하기 위해서 쓴다.
1. 콜백 : 동작을 인자로 넘김
표준 라이브러리의 qsort 를 예시로 들면, 정렬 알고리즘은 고정이고 비교 방법만 사용자가 함수로 넘긴다.
#include <stdio.h>
#include <stdlib.h>
int asc(const void *a, const void *b) {
return *(int *)a - *(int *)b;
// const void *a : 타입을 정하지 않은 그냥 주소인 void * 로 넘겨줌.
// (int *) a : 캐스팅, void * 는 타입 정보가 없어서 그대로 값을 꺼낼 수 없다.
// *(int *)a : 역참조, 그 주소에 가서 실제 int 값을 꺼낸다.
}
int desc(const void *a, const void *b) {
return *(int *)b - *(int *)a;
}
int main(void) {
int arr[] = {5, 2, 9, 1};
qsort(arr, 4, sizeof(int), asc);
for (int i = 0; i < 4; i++) printf("%d ", arr[i]); // 1 2 5 9
printf("\n");
qsort(arr, 4, sizeof(int), desc);
for (int i = 0; i < 4; i++) printf("%d ", arr[i]); // 9 5 2 1
printf("\n");
return 0;
}
2. 함수 테이블 : if/ switch 대신 배열로 분기
int (*ops[])(int, int *) = { add, sub };
int result = ops[choice](10, &a); // choice 가 0 이면 add, 1 이면 sub
명령어 번호에 따라 처리 함수를 고르는 인터프리터나 이벤트 핸들러에서 자주 쓰는 패턴이다.
- 인터프리터 : 명령을 하나씩 읽어서 실행하는 프로그램이다. 명령마다 번호가 매겨져 있다면, 읽은 번호로 ops[번호]()를 호출하면 바로 처리된다.
- 이벤트 핸들러 : 키보드가 눌렸다, 마우스가 클릭됐다는 사건 (이벤트) 에 반응하는 함수이다.
if (cmd == 0) add(x, y);
else if (cmd == 1) sub(x, y);
else if (cmd == 2) mul(x, y);
else if (cmd == 3) divide(x, y); // div 는 stdlib.h의 표준함수와 이름이 같다.
int (*ops[])(int, int) = { add, sub, mul, divide };
// 0번 1번 2번 3번
ops[cmd](x, y); // cmd 번호에 맞는 함수 실행
함수가 늘어나도 if 문을 늘릴 필요 없이 배열에 추가만 하면 되어서 코드가 간결해진다. 대신 인덱스가 배열 범위를 벗어나면 잘못된 주소로 점프하므로 범위 검사가 필요하다.
3. 가독성 typedef
선언이 복잡하니 보통 타입에 이름을 붙인다.
typedef int (*op_t)(int, int *);
op_t fp = add;
op_t ops[] = { add, sub };
기계 수준에서의 함수 포인터
일반 함수 호출은 주소가 고정된 직접 호출이다.
call add # 목적지 주소가 명령어에 박혀 있음
함수 포인터 호출은 레지스터나 메모리에 든 주소로 점프하는 간접 호출이다.
movq fp(%rip), %rax # fp가 전역변수일 때
call *%rax # %rax에 들어있는 주소로 점프
* 가 붙은 call 이 이 값을 주소로 보고 거기로 가라는 의미이다. 호출 규약은 똑같아서, 인자는 %rdi, %rsi 로 전달되고 반환값은 %rax 에 담긴다. switch 문이 점프 테이블로 컴파일될 때 쓰는 jmp *.L4(,%rdi,8) 도 같은 원리이다.