C++ 고급 문법 (10) — 함수 포인터(Function Pointer)

qsort, signal, pthread_create처럼 C 라이브러리는 동작을 바꿀 때 함수의 주소를 받는다. C++에서도 C 라이브러리와 연동하거나, 명령 이름과 처리 함수를 표로 묶거나, 플러그인 진입점을 받을 때 함수 포인터(function pointer)를 그대로 쓴다. 다만 선언 문법이 읽기 어렵고, 멤버 함수 포인터는 일반 함수 포인터와 크기부터 다르다. 이 글에서는 g++ 13.3으로 함수 포인터 선언과 호출, 콜백과 디스패치 테이블, 멤버 함수 포인터, C API에 객체를 넘기는 방법을 정리한다. 함수 객체와의 성능 비교는 함수 객체 편에서 다뤘다.

이 시리즈의 다른 글

선언과 호출

#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 = &sub;                            // & 는 생략 가능
    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* 사용자 데이터와 캡처 없는 람다를 조합한다.

참고

답글 남기기