肯尼斯·里科《C和指针》第13章 高级指针话题(2)函数指针

我们不会每天都使用函数指针。但是,它们的确有用武之地,**最常见的两个用途是转换表(jump table)和作为参数传递给另一个函数。**本节将探索这两方面的一些技巧。但是,首先容我指出一个常见的错误,这是非常重要的。

简单声明一个函数指针并不意味着它马上就可以使用。和其他指针一样,对函数指针执行间接访问之前必须把它初始化为指向某个函数。下面的代码段说明了一种初始化函数指针的方法

cpp 复制代码
int        f( int );
int        (*pf)( int ) = &f;

第2个声明创建了函数指针pf,并把它初始化为指向函数f。函数指针的初始化也可以通过一条赋值语句来完成。在函数指针的初始化之前具有f的原型是很重要的,否则编译器将无法检查f的类型是否与pf所指向的类型一致。

初始化表达式中的&操作符是可选的,因为函数名被使用时总是由编译器把它转换为函数指针。&操作符只是显式地说明了编译器将隐式执行的任务

在函数指针被声明并且初始化之后,就可以使用3种方式调用函数:

cpp 复制代码
int ans;

ans = f(25) ;     /*第一种办法*/
ans = (*pf)(25);  /*第二种办法*/
ans = pf(25);     /*第三种办法*/

第1条语句简单地使用名字调用函数f,但它的执行过程可能和想象的不太一样。函数名f首先被转换为一个函数指针,该指针指定函数在内存中的位置。然后,函数调用操作符调用该函数,执行开始于这个地址的代码。

第2条语句对pf执行间接访问操作,它把函数指针转换为一个函数名。这个转换并不是真正需要的,因为编译器在执行函数调用操作符之前又会把它转换回去。不过,这条语句的效果和第1条语句是完全一样的。

第3条语句和前两条语句的效果是一样的。间接访问操作并非必需的,因为编译器需要的是一个函数指针。这个例子显示了函数指针通常是如何使用的。

什么时候应该使用函数指针呢? 前面提到过,两个最常见的用途是把函数指针作为参数传递给函数以及用于转换表。让我们各看一个例子。

13.3.1 回调函数

这里有一个简单的函数,它用于在一个单链表中查找一个值。它的参数是一个指向链表第1个节点的指针以及那个需要查找的值。

cpp 复制代码
Node* search_list(Node *node, int const value)
{
    while(node != NULL){
        if(node->value == value)
            break;
        node = node->link;
    } 
    return node;   
}

这个函数看上去相当简单,但它只适用于值为整数的链表。如果需要在一个字符串链表中查找,则不得不另外编写一个函数。这个函数和上面那个函数的绝大部分代码相同,只是第2个参数的类型以及节点值的比较方法不同。

一种更为通用的方法是使查找函数与类型无关,这样它就能用于任何类型的值的链表。我们必须对函数的两个方面进行修改,使它与类型无关。首先,必须改变比较的执行方式,这样函数就可以对任何类型的值进行比较。这个目标听上去好像不可能,如果编写语句用于比较整型值,它怎么还可能用于其他类型(如字符串)的比较呢?解决方案就是使用函数指针。调用者编写一个函数,用于比较两个值,然后把一个指向这个函数的指针作为参数传递给查找函数。然后查找函数调用这个函数来执行值的比较。使用这种方法,任何类型的值都可以进行比较。

必须修改的第二个方面是向函数传递一个指向值的指针而不是值本身。函数有一个void *形参,用于接受这个参数。然后指向这个值的指针便传递给比较函数。这个修改使字符串和数组对象也可以被使用。字符串和数组无法作为参数传递给函数,但指向它们的指针却可以。

**使用这种技巧的函数被称为回调函数(callback function),因为用户把一个函数指针作为参数传递给其他函数,后者将"回调"用户的函数。**任何时候,如果所编写的函数必须能够在不同的时刻执行不同类型的工作,或者执行只能由函数调用者定义的工作,都可以使用这个技巧。许多窗口系统使用回调函数连接多个动作,如拖拽鼠标和点击按钮来指定用户程序中的某个特定函数。

我们无法在这个上下文环境中为回调函数编写一个准确的原型,因为并不知道进行比较的值的类型。事实上,我们需要查找函数能作用于任何类型的值。解决这个难题的方法是把参数类型声明为void *,表示"一个指向未知类型的指针"

在使用比较函数中的指针之前,它们必须被强制转换为正确的类型。因为强制类型转换能够躲过一般的类型检查,所以在使用时必须格外小心,确保函数的参数类型是正确的。

在这个例子里,回调函数比较两个值。查找函数向比较函数传递两个指向需要进行比较的值的指针,并检查比较函数的返回值。例如,零表示相等的值,非零值表示不相等的值。现在,查找函数就与类型无关,因为它本身并不执行实际的比较。确实,调用者必须编写必需的比较函数,但这样做是很容易的,因为调用者知道链表中所包含的值的类型。如果使用几个分别包含不同类型值的链表,为每种类型编写一个比较函数就允许单个查找函数作用于所有类型的链表。

程序13.1是类型无关查找函数的一种实现方法。注意,函数的第3个参数是一个函数指针。这个参数用一个完整的原型进行声明。同时注意,虽然函数绝不会修改参数node所指向的任何节点,但node并未被声明为const。如果node被声明为const,函数将不得不返回一个const结果,这将限制调用程序,它便无法修改查找函数所找到的节点。

cpp 复制代码
/*
** 在一个单链表中查找一个指定值的函数。它的参数是一个指向链表第1个节点的
** 指针、一个指向需要查找的值的指针和一个函数指针,它所指向的函数用于比
** 较存储于链表中的类型的值。
*/
#include <stdio.h>
#include "node.h"
Node *
search_list( Node *node, void const *value,
    int (*compare)( void const *, void const * ) )
{
        while( node != NULL ){
            if( compare( &node->value, value ) == 0 )
               break;
        node = node->link;
        }
        return node;
}

程序13.1 类型无关的链表查找 search.c

指向值参数的指针和&node->value被传递给比较函数。后者是我们当前所检查的节点的值。在选择比较函数的返回值时,这里选择了与直觉相反的约定,就是相等返回零值,不相等返回非零值。它的目的是为了与标准库的一些函数所使用的比较函数规范兼容。在这个规范中,不相等操作数的报告方式更为明确------负值表示第1个参数小于第2个参数,正值表示第1个参数大于第2个参数。

在一个特定的链表中进行查找时,用户需要编写一个适当的比较函数,并把指向该函数的指针和指向需要查找的值的指针传递给查找函数。例如,下面是一个比较函数,它用于在一个整数链表中进行查找。

cpp 复制代码
int compare_ints(void const *a, void const *b)
{
    if(*(int *)a == *(int *)b)
        return 0;
    else
        return 1;

}

这个函数将像下面这样使用:

cpp 复制代码
desired_node = search_list( root, &desired_value,
     compare_ints );

注意强制类型转换:比较函数的参数必须声明为void *以匹配查找函数的原型,然后它们再强制转换为int *类型,用于比较整型值。

如果希望在一个字符串链表中进行查找,下面的代码可以完成这项任务:

cpp 复制代码
#include<string.h>
...
desired_node = search_list(root,"desired_value",strcmp);

碰巧,库函数strcmp所执行的比较和我们需要的完全一样,不过有些编译器会发出警告信息,因为它的参数被声明为char *而不是void *。

13.3.2 转移表

转移表最好用个例子来解释。下面的代码段取自一个程序,它用于实现一个袖珍式计算器。程序的其他部分已经读入两个数(op1和op2)和一个操作符(oper)。下面的代码对操作符进行测试,然后决定调用哪个函数。

cpp 复制代码
switch( oper ){
case ADD:
    result = add(op1, op2);
    break;

case SUB:
    result = sub(op1, op2);
    break;

case MUL:
    result = mul(op1, op2);
    break;

case DIV:
    result = div(op1, op2);
    break;

...

对于一个新奇的具有上百个操作符的计算器来说,这条switch语句将会非常之长。

为什么要调用函数来执行这些操作呢?把具体操作和选择操作的代码分开是一种良好的设计方案。更为复杂的操作将肯定以独立的函数来实现,因为它们的长度可能很长。但即使是简单的操作,也可能具有副作用,例如保存一个常量值用于以后的操作。

为了使用switch语句,表示操作符的代码必须是整数。如果它们是从零开始连续的整数,则可以使用转换表来实现相同的任务。转换表就是一个函数指针数组。

创建一个转换表需要两个步骤。声明并初始化一个函数指针数组。唯一需要留心之处就是确保这些函数的原型出现在这个数组的声明之前。

在初始化列表中,各个函数名的正确顺序取决于程序中用于表示每个操作符的整型代码。这个例子假定ADD是0,SUB是1,MUL是2;依此类推。

第二个步骤是用下面这条语句替换前面整条switch语句!

cpp 复制代码
result = oper_func[ oper ]( op1, op2 );

oper从数组中选择正确的函数指针,而函数调用操作符将执行这个函数。

在转换表中,越界下标引用就像在其他任何数组中一样是不合法的。但一旦出现这种情况,把它诊断出来要困难得多。当这种错误发生时,程序有可能在3个地方终止。首先,如果下标值远远越过了数组的边界,它所标识的位置可能在分配给该程序的内存之外。有些操作系统能检测到这个错误并终止程序,但有些操作系统并不这样做。如果程序被终止,这个错误将在靠近转换表语句的地方被报告,问题相对而言较易诊断。

如果程序并未终止,非法下标所标识的值被提取,处理器跳到该位置。这个不可预测的值可能代表程序中一个有效的地址,但也可能不是。如果它不代表一个有效地址,程序此时也会终止,但错误所报告的地址从本质上说是一个随机数。此时,问题的调试就极为困难。

如果程序此时还未失败,机器将开始执行根据非法下标所获得的虚假地址的指令,此时要调试出问题根源就更为困难了。如果这个随机地址位于一块存储数据的内存中,程序通常会很快终止,这通常是由于非法指令或非法的操作数地址所致(尽管数据值有时也能代表有效的指令,但并不总是这样)。要想知道机器为什么会到达那个地方,唯一的线索是转移表调用函数时存储于堆栈中的返回地址。如果任何随机指令在执行时修改了堆栈或堆栈指针,那么连这个线索也消失了。

更糟的是,如果这个随机地址恰好位于一个函数的内部,那么该函数就会顺利地执行,修改谁也不知道的数据,直到它运行结束。但是,函数的返回地址并不是该函数所期望的保存于堆栈上的地址,而是另一个随机值。这个值就成为下一个指令的执行地址,计算机将在各个随机地址间跳转,执行位于那里的指令。

问题在于指令破坏了机器如何到达错误最后发生地点的线索。没有了这方面的信息,要查明问题的根源简直难如登天。如果怀疑转移表有问题,可以在那个函数调用之前和之后各打印一条信息。如果被调用函数不再返回,用这种方法就可以看得很清楚。但困难在于人们很难认识到程序某个部分的失败可以是由于程序中相隔甚远的且不相关部分的一个转移表错误所引起的。

一开始就保证转移表所使用的下标位于合法的范围是很容易做到的。在这个计算器例子里,用于读取操作符并把它转换为对应整数的函数应该核实该操作符是否有效。

相关推荐
XH华2 小时前
初识C语言之二维数组(下)
c语言·算法
南宫生3 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
sanguine__3 小时前
Web APIs学习 (操作DOM BOM)
学习
冷眼看人间恩怨3 小时前
【Qt笔记】QDockWidget控件详解
c++·笔记·qt·qdockwidget
菜鸡中的奋斗鸡→挣扎鸡3 小时前
滑动窗口 + 算法复习
数据结构·算法
axxy20004 小时前
leetcode之hot100---240搜索二维矩阵II(C++)
数据结构·算法
数据的世界015 小时前
.NET开发人员学习书籍推荐
学习·.net
四口鲸鱼爱吃盐5 小时前
CVPR2024 | 通过集成渐近正态分布学习实现强可迁移对抗攻击
学习
这是我585 小时前
C++打小怪游戏
c++·其他·游戏·visual studio·小怪·大型·怪物
Uu_05kkq6 小时前
【C语言1】C语言常见概念(总结复习篇)——库函数、ASCII码、转义字符
c语言·数据结构·算法