qsort, signal, pthread_create처럼 C 라이브러리는 동작을 바꿀 때 함수의 주소를 받는다. C++에서도 C 라이브러리와 연동하거나, 명령 이름과 처리 함수를 표로 묶거나, 플러그인 진입점을 받을 때 함수 포인터(function pointer)를 그대로 쓴다. 다만 선언 문법이 읽기 어렵고, 멤버 함수 포인터는 일반 함수 포인터와 크기부터 다르다. 이 글에서는 g++ 13.3으로 함수 포인터 선언과 호출, 콜백과 디스패치 테이블, 멤버 함수 포인터, C API에 객체를 넘기는 방법을 정리한다. 함수 객체와의 성능 비교는 함수 객체 편에서 다뤘다.
이 시리즈의 다른 글
- C++ 고급 문법 (8) — const 멤버 함수(const Member Function)
- C++ 고급 문법 (9) — 객체 복사(Object Copy)
- C++ 고급 문법 (11) — 연산자 오버로딩(Operator Overloading)
- C++ 고급 문법 (12) — cout과 endl
선언과 호출
#include <cstdio>
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
using BinOp = int (*)(int, int); // 별칭으로 읽기 쉽게
typedef int (*BinOpOld)(int, int); // C 스타일 typedef
int main()
{
int (*op)(int, int) = add; // 함수 이름은 포인터로 암시적 변환
std::printf("%d\n", op(2, 3));
op = ⊂ // & 는 생략 가능
std::printf("%d\n", (*op)(2, 3)); // (*op)(...) 와 op(...) 는 같다
BinOp ops[] = {add, sub};
BinOpOld old = add;
std::printf("%d %d %d\n", ops[0](10, 4), ops[1](10, 4), old(1, 1));
std::printf("sizeof(BinOp)=%zu\n", sizeof(BinOp));
}$ g++ -std=c++20 -Wall -o b basic.cpp
$ ./b
5
-1
14 6 2
sizeof(BinOp)=8
함수 이름은 함수 포인터로 암시적 변환되므로 &와 *는 생략해도 된다. 선언이 복잡해지면 using 별칭이 typedef보다 읽기 쉽다.
| 선언 | 의미 |
|---|---|
int (*fp)(int) | int를 받아 int를 반환하는 함수의 포인터 |
int *fp(int) | int*를 반환하는 함수 선언 (괄호가 빠짐) |
int (*arr[4])(int) | 함수 포인터 4개의 배열 |
void (*signal(int, void (*)(int)))(int) | 함수 포인터를 받고 함수 포인터를 반환하는 함수 |
using H = void (*)(int); H signal(int, H); | 위와 같은 선언을 별칭으로 |
콜백
#include <cstdio>
#include <cstdlib>
struct Proc { int pid; int cpu; };
// C API용 비교 함수: void*로 받아 직접 캐스트해야 한다
int by_cpu_desc(const void* a, const void* b)
{
auto* pa = static_cast<const Proc*>(a);
auto* pb = static_cast<const Proc*>(b);
return pb->cpu - pa->cpu;
}
int main()
{
Proc ps[] = {{101, 5}, {102, 40}, {103, 12}};
std::qsort(ps, 3, sizeof(Proc), by_cpu_desc); // 콜백으로 함수 포인터 전달
for (auto& p : ps) std::printf("pid=%d cpu=%d\n", p.pid, p.cpu);
}$ g++ -std=c++20 -Wall -o q qsort.cpp
$ ./q
pid=102 cpu=40
pid=103 cpu=12
pid=101 cpu=5
C API 콜백은 void*로 받기 때문에 타입 검사가 없다. C++ 코드끼리라면 템플릿을 받는 std::sort가 타입 안전하고 더 빠르다.
디스패치 테이블
#include <cstdio>
#include <cstring>
void cmd_start(const char* arg) { std::printf("start %s\n", arg); }
void cmd_stop(const char* arg) { std::printf("stop %s\n", arg); }
void cmd_status(const char*) { std::printf("status: running\n"); }
struct Command {
const char* name;
void (*handler)(const char*); // 명령 이름 -> 처리 함수
};
constexpr Command table[] = {
{"start", cmd_start},
{"stop", cmd_stop},
{"status", cmd_status},
};
void dispatch(const char* name, const char* arg)
{
for (const auto& c : table)
if (std::strcmp(c.name, name) == 0) return c.handler(arg);
std::printf("unknown: %s\n", name);
}
int main()
{
dispatch("start", "nginx");
dispatch("status", "");
dispatch("reload", "");
}$ g++ -std=c++20 -Wall -o d dispatch.cpp
$ ./d
start nginx
status: running
unknown: reload
새 명령은 함수 하나와 표 한 줄만 추가하면 된다. constexpr 배열이라 표 자체가 읽기 전용 데이터 영역에 놓인다.
오버로드된 함수의 주소
int scale(int v) { return v * 2; }
double scale(double v) { return v * 2.5; }
int main()
{
auto f = scale; // 어느 오버로드인지 모른다
}$ g++ -std=c++20 -Wall -c overload_addr.cpp 2>&1 | head -3
overload_addr.cpp: In function ‘int main()’:
overload_addr.cpp:7:14: error: unable to deduce ‘auto’ from ‘scale’
7 | auto f = scale; // 어느 오버로드인지 모른다
대상 포인터 타입을 명시하거나 static_cast로 시그니처를 지정해 오버로드를 고른다.
#include <cstdio>
int scale(int v) { return v * 2; }
double scale(double v) { return v * 2.5; }
int main()
{
double (*fd)(double) = scale; // 대상 타입으로 선택
auto fi = static_cast<int (*)(int)>(scale); // 캐스트로 선택
std::printf("%.1f %d\n", fd(2), fi(2));
}$ g++ -std=c++20 -Wall -o oa overload_addr2.cpp
$ ./oa
5.0 4
멤버 함수 포인터
비정적 멤버 함수는 this가 필요하므로 일반 함수 포인터에 담을 수 없다. 클래스 이름이 붙은 별도 타입을 쓰고 .*/->*로 객체와 결합해 호출한다.
#include <cstdio>
#include <functional>
struct Motor {
int rpm = 0;
void set(int v) { rpm = v; }
int get() const { return rpm; }
virtual void stop() { std::puts("Motor::stop"); }
virtual ~Motor() = default;
};
struct Servo : Motor {
void stop() override { std::puts("Servo::stop"); }
};
int main()
{
void (Motor::*setter)(int) = &Motor::set; // & 와 클래스 한정 이름이 필수
int (Motor::*getter)() const = &Motor::get;
void (Motor::*stopper)() = &Motor::stop;
int Motor::*field = &Motor::rpm; // 데이터 멤버 포인터
Servo s;
Motor* p = &s;
(s.*setter)(1200); // 객체: .*
std::printf("get=%d field=%d\n", (p->*getter)(), s.*field); // 포인터: ->*
(p->*stopper)(); // 가상 함수는 여전히 가상 호출
std::invoke(setter, s, 3000); // C++17: 호출 문법 통일
std::printf("rpm=%d\n", std::invoke(field, s));
std::printf("sizeof(void(*)()) = %zu\n", sizeof(void (*)()));
std::printf("sizeof(void(Motor::*)()) = %zu\n", sizeof(stopper));
}$ g++ -std=c++20 -Wall -o m member.cpp
$ ./m
get=1200 field=1200
Servo::stop
rpm=3000
sizeof(void(*)()) = 8
sizeof(void(Motor::*)()) = 16
멤버 함수 포인터는 가상 함수 여부와 this 보정값을 함께 담아 16바이트다(Itanium C++ ABI). 그래서 가상 함수를 가리키면 호출 시점에도 가상 호출이 된다.
C API에 객체 넘기기
#include <cstdio>
// 흔한 C 라이브러리 형태: 함수 포인터 + void* 사용자 데이터
using Callback = void (*)(int event, void* user);
void lib_run(Callback cb, void* user)
{
for (int e = 1; e <= 3; ++e) cb(e, user);
}
class Logger {
const char* prefix_;
int count_ = 0;
public:
explicit Logger(const char* p) : prefix_(p) {}
void on_event(int e) { std::printf("%s event=%d (#%d)\n", prefix_, e, ++count_); }
};
int main()
{
Logger log("[net]");
// 캡처 없는 람다는 함수 포인터로 변환된다. 객체는 void*로 넘겨 되돌린다
lib_run([](int e, void* user) { static_cast<Logger*>(user)->on_event(e); }, &log);
}$ g++ -std=c++20 -Wall -o u userdata.cpp
$ ./u
[net] event=1 (#1)
[net] event=2 (#2)
[net] event=3 (#3)
캡처가 있는 람다는 함수 포인터로 변환되지 않으므로, 객체 주소를 void* 사용자 데이터로 넘기고 캡처 없는 람다 안에서 되돌린다.
noexcept도 타입의 일부
void may_throw();
void never_throw() noexcept;
int main()
{
void (*a)() noexcept = never_throw; // OK
void (*b)() = never_throw; // OK: noexcept -> 일반 포인터 변환 허용
void (*c)() noexcept = may_throw; // C++17부터 noexcept는 타입의 일부
}$ g++ -std=c++20 -Wall -c noexcept_type.cpp 2>&1 | grep error
noexcept_type.cpp:8:28: error: invalid conversion from ‘void (*)()’ to ‘void (*)() noexcept’ [-fpermissive]
널 함수 포인터 호출
#include <cstdio>
using Handler = void (*)(int);
Handler g_on_error = nullptr; // 등록되지 않은 콜백
int main()
{
if (g_on_error) g_on_error(5); // 확인 후 호출
std::fputs("checked call ok\n", stderr);
g_on_error(5); // 확인 없이 호출
}$ g++ -std=c++20 -Wall -o nc nullcall.cpp
$ ./nc; echo "exit=$?"
checked call ok
bash: line 1: 7510 Segmentation fault (core dumped) ./nc
exit=139
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| 등록되지 않은 콜백 호출 | 널 포인터 호출로 즉시 크래시 | 호출 전 검사하거나 기본 no-op 함수를 넣어둔다 |
| 오버로드된 함수의 주소 | auto 추론 실패 | 대상 타입 명시 또는 static_cast |
| 캡처 있는 람다를 C 콜백으로 | 함수 포인터 변환 불가 | void* 사용자 데이터 + 캡처 없는 람다 |
멤버 함수 포인터를 void*나 일반 포인터로 캐스트 | 크기가 달라 정보 손실 | std::function이나 std::invoke로 다룬다 |
| C++ 코드 내부 콜백 | 인라인이 막혀 느림 | 템플릿 매개변수나 함수 객체 사용 |
| C에서 호출할 콜백이 예외를 던짐 | C 프레임을 넘는 예외 전파는 미정의 | 콜백 안에서 예외를 잡아 오류 코드로 변환 |
마무리
- 함수 포인터는 C API 연동과 디스패치 테이블에 적합하며, 선언은
using별칭으로 정리한다. - 멤버 함수 포인터는 별도 타입(16바이트)이고
.*/->*나std::invoke로 호출한다. - C 콜백에 객체를 넘길 때는
void*사용자 데이터와 캡처 없는 람다를 조합한다.