颜林林的个人网站

现代C++学习笔记(6):函数指针与函数引用

2022-02-10 01:05

Image (题图来自网络并做适当修改)

1

上周写了一篇《硬核透视一个C++函数调用》,从汇编语言的角度,额外解释了一下 C/C++ 中的函数及其调用过程。理解该过程后,就能够更容易认清,函数本质上就是包含了一段机器指令的内存块,并通常用它的起始地址来表示。

函数的这个地址,与其他变量(同样也是用某个内存地址表示)之间,最大的不同在于其使用方法。一旦调用函数,编译器就会在相应的调用发生地,生成一堆代码,准备各种参数及堆栈,用call指令跳转到函数的起始地址,等待函数返回后,又对返回值进行必要的善后处理。

通常,函数的调用都是直接“硬编码”在程序中的,通过既定的函数名,直接调用即可。然而,有时候,出于种种目的,希望在运行时动态地决定调用一组同类型函数中的某一个,就需要用到函数指针(或函数引用)。这里所说的“同类型函数”,是指参数个数、参数顺序、每个参数的类型、函数返回值等都完全一样。

下面是一个示例:

int add(int x, int y) { return (x + y); } int sub(int m, int n) { // 这里虽然参数名不同,但类型是一样 return (m - n); } void test() { int a = 1, b = 2, c = 3; int (*func)(int, int); // 定义一个函数指针 if (c > 0) { // 根据条件,对函数指针进行不同赋值 func = add; } else { func = sub; } (*func)(a, b); // 使用函数指针进行调用 } 上述例子中,“(*func)”起到了与 add 或 sub 函数名本身一样的作用。稍微展开解释下,“func”本身就已经足以说明是在“使用该指针”,之所以要包一层括号,是因为后面的“(a, b)”作为函数调用的操作符,其优先级高于“指针使用操作”(即星号“”)。

而事实上,编译器是足够聪明的,或者说,编译器在对待上述“add”“sub”和“func”时,是把它们都当做同样的东西(一个指针,或一个地址)来处理的。所以,它们也同样可以写成:

func(a, b); // 与 (*func)(a, b); 等价 (*add)(a, b); // 与 add(a, b); 等价 (*sub)(a, b); // 与 sub(a, b); 等价 2

在展示和解释过函数指针后,我们来看看《Effective Modern C++》中条款1(理解模板型别推导)的一段代码:

void someFunc(int, double);

template void f1(T param);

template void f2(T& param);

f1(someFunc); // param推导为函数指针,型别为 void(*)(int, double) f2(someFunc); // param推导为函数引用,型别为 void(&)(int, double) 在模板定义中,是否写了“&”符号,决定了函数的解析方式,是采用函数指针,还是采用函数引用。然而不管哪种解析,上述 f1 和 f2 的实现中,对于参数 param 的使用都是一样的,直接当做函数名进行函数调用即可。

下面,我把该代码补充完整展示下其使用:

void someFunc(int, double);

template void f1(T param, int a, double b) { param(a, b); }

template void f2(T& param, int a, double b) { param(a, b); }

int a = 1; double b = 2.0; f1(someFunc, a, b); // param型别为 void(*)(int, double) f2(someFunc, a, b); // param型别为 void(&)(int, double) 因此,函数指针与函数引用,都是指向的代码块的起始地址。并在被调用的时候,根据所声明的参数类型及返回值类型,执行相应的初始化及善后工作而已。

既然如此,那指针和引用之间有什么区别的?其实重点在于代码安全性。

对于引用,编译器会要求有确定的初始化过程,因此是可以确保不存在为空的情况的。而指针则不然,在复杂的代码中,若某个疏忽导致指针在正确初始化前就被使用,那程序就有崩溃的风险。

因此,需要记住的重要原则:在既能够使用指针、又能够使用引用的场景,应优先使用引用。除了安全性的考虑外,在上一篇笔记中也提到过,引用的使用也经常会让代码更简练。

3

指针和引用,除了用于上述“全局函数”外,其实也可以用于“成员函数”,即定义在 class 中的函数。这种用法在初次见到时可能会感觉很奇怪,但理解其背后的含义后,就会觉得还算顺理成章。

class A { public: int add(int x, int y) { return x + y; } int sub(int x, int y) { return x - y; } void foo(); };

void A::foo() { int a = 1, b = 2; int (A::*func)(int, int); func = &A::add; // 或 func = &A::sub; (this->*func)(a, b); }

int main() { A a; int m = 1, n = 2; int (A::*func)(int, int); func = &A::add; // 或 func = &A::sub; (a.*func)(m, n); return 0; } 在上面这个例子中,分别展示了在成员函数 A::foo() 和全局函数 int main() 中,关于指向成员函数的指针的用法。

其中用到了两个特别的运算符“->”和“.”,是会被编译器特殊解析的。这里虽然可以从字面意思上把其中的“*”与指针联合起来,理解为指针的使用。但如果把“*func”用括号包起来,编译器是不能正确理解的:

(this->(*func))(a, b); // 编译将出错 (a.(*func))(a, b); // 编译将出错 如果读过用 C 语言编写面向对象程序的实践代码,就能理解背后的逻辑。C 语言没有 class 关键字及其支持,通常就需要通过 struct 来进行“模拟”:

// 这一段是 C语言代码 struct A { int (*add)(int, int); // 这里只是定义了两个指针 int (*sub)(int, int); // 作为 struct 结构体的元素 int data; // 其他元素 };

int add_impl(int a, int b) // 实现函数 { return (a + b); };

int sub_impl(int a, int b) // 实现函数 { return (a - b); };

int main() { struct A a = { add_impl, sub_impl }; int m = 1, n = 2; a.add(m, n); a.sub(m, n); return 0; } 然而上述实现函数中,是没法访问结构体中的其他变量(如上面代码中的 data)的,所以代码还需要改造下,加上一个指向该结构体的 this 指针:

// 这一段是 C语言代码 struct A { int (add)(struct A, int, int); // 这里只是定义了两个指针 int (sub)(struct A, int, int); // 作为 struct 结构体的元素 int data; // 其他元素 };

int add_impl(struct A* pThis, int a, int b) // 实现函数 { pThis->data; // 这样就能使用到 data 变量了 return (a + b); };

int sub_impl(struct A* pThis, int a, int b) // 实现函数 { return (a - b); };

int main() { struct A a = { add_impl, sub_impl }; int m = 1, n = 2; a.add(&a, m, n); // 在函数调用时,需要把地址也一起传入 a.sub(&a, m, n); return 0; } 从这个例子,可以看出,C++ 编译器还是默默帮助我们做了很多工作的,避免了我们大量重复劳动。

4

最后,在结尾前,再简单介绍一个 C++ 新标准中引入的神器 std::mem_fn。它其实是一个关于函数的对象式封装。在 C++ 中,想要编写尽可能安全的代码,其中一个重要原则,就是应该尽量避免使用原生指针。而 std::mem_fn 就提供了这样的便利,它能非常优雅地封装并处理成员函数,避免上面提到的奇怪的操作符(“->”和“.”)。

#include #include

class A { public: void foo(int x) { std::cout « “A::foo(” « x « “)” « std::endl; } void foo2(int x, int y) { std::cout « “A::foo2(” « x « “,” « y « “)” « std::endl; } };

int main() { auto f1 = std::mem_fn(&A::foo); auto f2 = std::mem_fn(&A::foo2); A a; f1(a, 1); // 调用了 a.foo(1); f2(a, 2, 3); // 调用了 a.foo2(2, 3); } 至于为什么这样的神器,只有在 C++ 新标准中才能得以引入。这将在后续的笔记中,再做详细解释。这篇就先到这里。

--- END ---

注:本文首发表于“不靠谱颜论”公众号,并同步至本站。

相关文章