ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

简明的C++函数指针学习教程

简明的C++函数指针学习教程

前言


函数指针(pointer to function)保存的是函数的入口地址。它的经典用途有三个:把「行为」作为参数传进另一个函数(回调)、用数组实现跳转表、以及和只接受函数指针的 C 接口打交道(qsort、pthread_create、各种系统 API)。


在 C++ 里,函数指针仍然有用,但已经不是首选。lambda、std::function、模板和虚函数覆盖了大多数原本需要手写函数指针的场景。理解它仍然必要:读老代码、对接 C 库、以及理解「捕获的 lambda 为什么不能转成函数指针」,都绕不开它。


典型误解之一,是把函数指针和成员函数指针混为一谈。int (*)(int)与int (MyClass::*)(int)是两种完全不同的类型:前者存的是代码地址,后者还需要一个对象才能调用,大小和调用语法都不同,也不能互相转换。


典型误解之二,是以为取函数地址必须写&。函数名在大多数表达式里会隐式转换成「指向该函数的指针」,所以fp = add;与fp = &add;等价;调用时fp(1, 2)、(*fp)(1, 2)、(***fp)(1, 2)也都合法。


本文从声明语法讲起,依次覆盖类型别名、回调、跳转表、成员函数指针,最后说明什么时候该换成 lambda 或std::function。


一、声明语法:从名字开始读


函数指针的声明容易读错,原因是*与函数调用运算符()的优先级。规则是:先找变量名,再看它右边,最后看左边。


// 适用于 C++17
int add(int a, int b) { return a + b; }

int (*fp)(int, int) = add; // fp 是指针,指向「两个 int、返回 int」的函数
int (*fp2)(int, int) = &add; // 与上一行完全等价

int (*make_table())(int, int); // 先看 make_table():它是函数,
// 返回 int (*)(int, int)

对比下面这个常见错误:int* fp(int, int);声明的是一个函数,它接收两个int、返回int*,不是函数指针。差别只在括号的位置。


用using(或等价的typedef)可以显著改善可读性:


// 适用于 C++17
using BinOp = int (*)(int, int);

BinOp choose(char op) {
if (op == '+') return add;
return nullptr;
}

using的别名是可读的,而且能直接作为模板参数。C++11 之后还允许auto fp = add;让编译器推导出函数指针类型,注意这里fp的类型是int (*)(int, int),不是函数类型。


调用时,fp(1, 2)与(*fp)(1, 2)都会做一次「函数到函数指针」的隐式转换再解引用,语义相同。真正要避免的是调用空指针:函数指针为nullptr时调用是 UB,通常直接崩溃,但标准不保证一定崩溃。


二、回调:把函数交给别人调用


回调是函数指针最直接的用途。下面这个例子把比较规则传给一个「找出最大值」的函数:


// 适用于 C++17
#include <iostream>
#include <vector>

using Comparator = bool (*)(int, int); // 返回 true 表示 a 应排在 b 前面

bool ascending(int a, int b) { return a < b; }
bool descending(int a, int b) { return a > b; }

int pick_best(const std::vector<int>& v, Comparator better) {
if (v.empty()) return 0;
int best = v[0];
for (int x : v) {
if (better(x, best)) best = x;
}
return best;
}

int main() {
std::vector<int> v{3, 9, 2, 7};
std::cout << pick_best(v, ascending) << '\n';
std::cout << pick_best(v, descending) << '\n';
}

注意better的语义:它回答「x 是否比当前最优更好」。一旦把比较器设计成「小于」,就必须在循环里写成if (better(x, best)),不能写成if (!better(best, x))之类的绕法——那会把相等的情况也换掉。回调的语义约定必须写清楚,否则调用方没法正确实现。


C 标准库的qsort就是一个典型例子:


// 适用于 C++17
#include <cstdlib>

int cmp_int(const void* a, const void* b) {
int lhs = *static_cast<const int*>(a);
int rhs = *static_cast<const int*>(b);
if (lhs < rhs) return -1;
if (lhs > rhs) return 1;
return 0;
}

void sort_ints(int* data, std::size_t n) {
std::qsort(data, n, sizeof(int), cmp_int);
}

qsort的比较器签名固定为int (*)(const void*, const void*),返回负数、零、正数分别表示小于、等于、大于。它没有类型检查,传错函数或传错元素大小都会在运行时出错。C++ 里应优先用std::sort,它接受任意可调用对象、有类型检查、通常是模板内联的。


三、跳转表:用数组代替长 switch


当一组操作共享同一签名、又需要按编号分派时,函数指针数组比一长串if/else更清晰,也便于扩展:


// 适用于 C++17
#include <iostream>
#include <string>

enum class Op { Add, Sub, Mul, Div };

int do_add(int a, int b) { return a + b; }
int do_sub(int a, int b) { return a - b; }
int do_mul(int a, int b) { return a * b; }
int do_div(int a, int b) { return a / b; }

using BinOp = int (*)(int, int);

const BinOp kTable[] = { do_add, do_sub, do_mul, do_div };

int apply(Op op, int a, int b) {
std::size_t idx = static_cast<std::size_t>(op);
if (idx >= sizeof(kTable) / sizeof(kTable[0])) {
return 0; // 越界保护
}
return kTable[idx](a, b);
}

int main() {
std::cout << apply(Op::Mul, 6, 7) << '\n';
}

static_cast<std::size_t>(op)依赖枚举值从 0 连续排列,并且顺序与数组一致。这是一个隐式契约,改动枚举时要同步改表。数组大小用sizeof(kTable) / sizeof(kTable[0])计算,C++17 也可以直接用std::size(kTable)(需要<iterator>)。


do_div没有处理除零。整数除零是 UB,调用前必须自己检查b != 0。


四、成员函数指针:另一种类型


非静态成员函数不属于某个对象,取它的地址得到的是「指向成员的指针」,而不是普通函数指针:


// 适用于 C++17
#include <iostream>

class Widget {
public:
explicit Widget(int base) : base_(base) {}
int compute(int x) const { return base_ + x; }
private:
int base_;
};

int main() {
Widget w(10);
int (Widget::*pmf)(int) const = &Widget::compute;

std::cout << (w.*pmf)(5) << '\n'; // 通过对象调用
Widget* p = &w;
std::cout << (p->*pmf)(5) << '\n'; // 通过指针调用
}

几个要点:



  • 类型里必须带类名和const限定,int (Widget::*)(int) const与int (Widget::*)(int)是不同类型。

  • 调用要用.*或->*,且左边必须是Widget类型(或其派生类)的对象。

  • 成员函数指针通常比普通指针大(可能保存调整量),不能可靠地转成void*,也不能直接传给 C 回调。

  • 想把成员函数交给std::function或算法,用 lambda 捕获对象,或std::mem_fn/std::bind。


五、什么时候不该用函数指针




需求推荐写法原因



一次性回调、带捕获lambda能捕获局部状态,通常可内联

需要保存任意可调用对象std::function类型擦除,可存 lambda、函数对象、成员函数绑定

泛型算法、热路径模板参数静态分派,无间接调用开销

C 接口要求函数指针别无选择

运行时分派函数指针表或虚函数取决于是否需要对象状态



一个重要限制:带捕获的 lambda 不能转换成函数指针。只有无捕获的 lambda 才有到函数指针的隐式转换。std::function没有这个限制,代价是一次类型擦除和可能的间接调用。


完整示例:简单的回调注册表


// 适用于 C++17
#include <functional>
#include <iostream>
#include <string>
#include <utility>
#include <vector>

struct Event {
std::string name;
std::function<void(const std::string&)> handler;
};

class Dispatcher {
public:
void on(const std::string& name,
std::function<void(const std::string&)> fn) {
events_.push_back(Event{name, std::move(fn)});
}

void emit(const std::string& name, const std::string& payload) const {
for (const auto& e : events_) {
if (e.name == name && e.handler) {
e.handler(payload);
}
}
}

private:
std::vector<Event> events_;
};

int main() {
Dispatcher d;
int count = 0;

d.on("tick", [](const std::string& s) {
std::cout << "plain: " << s << '\n';
});
d.on("tick", [&count](const std::string& s) {
++count;
std::cout << "counting: " << s << " #" << count << '\n';
});

d.emit("tick", "hello");
d.emit("tick", "world");
}

第二个处理器捕获了count的引用,所以它能跨调用累加。这正是不用裸函数指针的原因:无捕获函数无法保存这种状态。


常见坑点


1. 把返回指针的函数写成函数指针


❌int* fp(int, int);以为声明了函数指针。 ✅int (*fp)(int, int);括号不能省。


2. 调用空函数指针


❌BinOp f = nullptr; f(1, 2);✅ 调用前判断if (f != nullptr);空指针调用是 UB。


3. 强制转换函数指针类型


❌ 把void(*)(int)强转成void(*)(double)再调用。 ✅ 通过正确签名的中间函数转发;签名不匹配的调用是 UB。


4. 把带捕获的 lambda 赋给函数指针


❌void (*fp)(int) = [&](int x) { total += x; };✅ 用std::function<void(int)>保存,或改成无捕获 lambda 并把状态放进参数。


5. 成员函数指针当成普通函数指针


❌void (*fp)() = &Widget::draw;✅ 用void (Widget::*fp)() = &Widget::draw;,调用时配对象。


6. 函数指针数组与枚举顺序不一致


❌ 枚举中间插入新值,却忘了同步数组。 ✅ 用static_cast<std::size_t>前检查上界,或改用switch/查表结构体。


7. 忽略qsort比较器的返回值语义


❌ 比较器只返回 0/1,导致「相等」和「大于」无法区分。 ✅ 返回负数、0、正数三种结果。


8. 在函数指针与std::function之间反复转换


❌ 为了统一接口,把每个回调都包进std::function再取出函数指针。 ✅ 明确接口边界:C 接口用函数指针,C++ 内部用std::function或模板。


总结




写法能否带状态能否内联典型用途



函数指针否可能C 回调、跳转表

无捕获 lambda否通常可以短小的局部回调

有捕获 lambda是通常可以需要上下文的回调

std::function是一般不存储异构可调用对象

成员函数指针需配对象一般不反射式调用、成员分派



函数指针的语法难点几乎都在声明上:记住「先看名字,再看右边,最后看左边」,并优先用using起别名。真正写业务代码时,只有在对接 C 接口或需要一张运行时分派表时才用它;其余场景,lambda 和std::function更安全,也更容易读。




RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进