文章目录
14.函数指针与复杂类型的解析
好久不见!因为指针篇的阅读量破了2k,所以我打算把指针篇中略过的函数指针部分再重新拿出来讲一讲,顺便来聊一聊如何解析C语言中的复杂类型声明/定义式,例如:
char *(*(*f(char *(*para)(char *)))[2])();
int (*(*(*pfunc)(int *))[5]) (int *);
看着是不是有种眼前一黑的感觉,别急,我等会儿就来告诉你怎么解析。
(1). 函数指针到底是什么
之前在指针篇中我们讲到了qsort函数,它的原型是这样的:
void qsort(void* base, size_t num, size_t width, int (__cdecl* compare)(const void*,const void*));
我们说最后这个参数int (__cdecl* compare)(const void*,const void*)是一个函数指针,你在自己写完了cmp函数之后直接通过qsort(a, n sizeof(int), cmp);调用了qsort,好像有点感觉了,这个cmp是函数的名字,而传入的是一个函数指针,或许这二者之间,有什么联系?
其实很简单,C语言的一切都和指针相关,C语言要去访问内存中的某个对象,不是依照它的变量名一个一个从什么表中找到的,而是每个变量对应一个地址,再直接从内存地址读出变量,对于函数也是一样,C语言会在对应的内存地址中找到我们需要调用的函数,所以这就引出了函数指针的概念:函数指针就是一个指向函数地址的指针。
(2). 声明和定义函数指针
接下来就该试试看怎么声明、定义函数指针了,函数指针的声明方式如下:
int (*h)(int a, int b);
int (*h)(int, int);
h是这个指针的名字,用()括起来*和h用来提升结合的优先级,之后再跟着参数表,参数表中的形参名称可以写也可以不写,这样就完成了一个函数指针的声明。
定义也很简单,比如如果我们有这么一个函数:
int f(int a, int b)
{
return a + b;
}
对于上述的h我们可以直接:
h = f;
printf("h = %p\n", h);
这样就可以把f赋值给h,并且把h的内容打印出来:
居然真的有地址!所以函数的背后也是指针在承受着一切。在给函数指针赋值之后,它就可以和原来的函数一样正常调用了,例如:
printf("h(%d, %d) = %d\n", 1, 3, h(1, 3));
这也是qsort这个函数能够正常运行的基本条件——函数指针可以完成和先前定义的函数一样的传参调用操作,也能够完成返回值的操作,在qsort的过程中,它会把我们传入的cmp函数赋值给int (__cdecl* compare)(const void*, const void*),然后在里面经常性地调用compare函数完成比较操作。
不过你要记住了,函数指针的声明一定要和被用于赋值的函数指针的参数类型和返回类型一致,例如下面的例子:
#include <stdio.h>
int f(int a, int b)
{
return a+b;
}
int main()
{
int (*h)(int) = f;
printf("h = %p\n", h);
printf("h(%d, %d) = %d\n", 1, 2, h(1, 2));
return 0;
}
这段代码是不能通过编译的,因为h被声明为只有一个参数的函数指针类型,从f到h会发生一次隐式类型转换,一旦确定这个h的类型,那么在调用的时候假设参数列表不匹配,就会出现编译错误,例如这里的h(1, 2)和int (*)(int);的类型是不匹配的。所以千万记得要尽可能保证函数指针的声明与传入函数的类型匹配哦!
(3). 函数指针数组
开始加速咯~函数指针作为一种数据类型,当然也是可以存进数组里面的,依旧采用上面说的f这个函数,我们可以写出以下的数组:
int (*func_ptr_arr[10])(int, int) = {f};
烧脑起来了,我们需要用下面这样的形式来声明一个函数指针数组:
ReturnType (*ArrayName[ArraySize])(ArgType1, ArgType2, ...);
形式很复杂,如果你看到这样的表达式,想要看懂肯定是要花一些时间的,不过没关系,在后面我会告诉你怎么解决这样的问题。
(4). 函数指针有什么用呢?
#1.模拟面向对象的程序设计模式
前面提到了一个用途,就是允许我们把函数作为参数传入函数当中了,比较好想到的函数指针的另一个用途是:补足C语言的面向对象程序设计范式。
C语言当然没有支持面向对象的模式,但是struct能够完成一部分类的工作,但是我们当时只能往里面塞属性,方法没有办法塞进去,有了函数指针,我们就可以让结构体长得更像类一点了,例如:
typedef struct _student
{
char name[120];
long long ID;
struct _student* this;
void (*Student_)(const char* name, long long ID, struct _student* this_);
void (*greeting)(struct _student* this);
} Student;
void Stu_Init(const char* name, long long ID, Student* this_)
{
strcpy(this_->name, name);
this_->ID = ID;
this_->this = this_; // 让Student对象的this指针指向自己
}
void greeting(Student* this)
{
printf("Hello! My name is %s, ID is %lld\n", this->name, this->ID);
}
Student* Factory()
{
Student* ans = (Student*)malloc(sizeof(Student));
ans->Student_ = Stu_Init;
ans->greeting = greeting;
ans->this = ans;
return ans;
}
// 用于测试的代码
int main()
{
Student s = *Factory();
s.Student_("Voltline", 1278412942, &s);
s.greeting(&s);
return 0;
}
结果如下:
在这里,我们把几个函数作为类的方法以函数指针的形式放进了这个结构体,之后就可以通过.直接访问了。
拜托,这个是真的很好玩,虽然我们需要显式传入一个Student指针进去,但是这看起来真的有点像面向对象的设计方式了,你也可以试着完成一个自己的“类”,不过也不要过分依赖于这样实现,因为C语言不会自动调用构造函数和析构函数,我这里写的因为不涉及动态内存分配,所以对于析构的要求还不是很高,但是一旦涉及到,你可能就要想想怎么完成析构的过程了。
#2.使用一个函数名完成对于不同函数的调用
我们来看这么一道题(EOJ-854.上古计算机以及C++版的EOJ-899.赛博计算机2077):
Smith有一台古老的计算机,这台计算机使用一套非常特殊的标记编程语言。现在我们仅考虑这一语言的一个小的子集,该子集可以实现一个简单计算器。
这台机器一共有 4 个无差别的寄存器,分别为 AX, BX, CX, DX,在指令中他们可以互相替代,下面的表格列出了所有支持的指令。
指令 | 中文名 | 格式 | 解释 |
---|---|---|---|
IN | 读操作指令 | IN AX,number | 读数据存储在AX中,AX=number |
MOV | 传送指令 | MOV AX,BX | AX=BX |
ADD | 加法指令 | ADD AX,BX | AX=AX+BX |
SUB | 减法指令 | SUB AX,BX | AX=AX-BX |
MUL | 乘法指令 | MUL AX,BX | AX=AX * BX |
DIV | 除法指令 | DIV AX,BX | AX=AX/BX(整除) |
OUT | 写操作指令 | OUT AX | 输出AX的值 |
例如: 以下代码实现表达式 (2+3*5)/6的计算并输出计算结果。
IN AX,3
IN BX,5
MUL AX,BX
IN CX,2
ADD CX,AX
IN BX,6
DIV CX,BX
OUT CX
输入用这种特殊标记编程语言编写的一段代码,请编写一个翻译器,对代码进行翻译,输出最后运行结果。
你可以自己挑战一下,我在这里只是提一提一种可能的做法,因为我的C语言代码是由相似的另一道C++题代码改编而来,所以会有几个上面没有涉及到的指令函数,例如MOD、AND等等,存着的函数指针也是三操作数,是之前的C++版本中可能出现两个或三个操作数,不过这些都是小问题,不影响我们理解这段代码:
typedef struct
{
const char* name;
void (*f)(const char*, const char*, const char*);
} command;
command commands[] =
{
{"ADD", ADD}, {"SUB", SUB}, {"MUL", MUL}, {"DIV", DIV},
{"MOD", MOD}, {"AND", AND}, {"OR" , OR }, {"XOR", XOR}
};
在编写完了八种操作后,我们用一个command数组来把所有的函数和名字对应起来,所以在调用的时候就可以:
char* temp = NULL;
char regs[5][20] = {{0}};
int cnt = 0;
while (temp = strtok(NULL, " ")) {
strcpy(regs[cnt++], temp);
}
void (*fT)(const char*, const char*, const char*) = commands[mapping_cmd(cmd)].f;
fT(regs[0], regs[1], regs[2]);
我们使用strtok完成切分字符串的操作,比如ADD AX,BX,CX,首先会被处理为ADD AX BX CX,之后通过strtok函数完成切分,变成"ADD", “AX”, “BX”, “CX”。
第一个"ADD"作为命令并使用mapping_cmd()函数完成到命令数组的映射,找到之后把这个command的f复制给fT,之后我们就可以向fT()传参调用函数了,看起来还真是挺好玩的,对吧?
这道题是我最近做的自认为最有意思的一道题了,大家也可以试试看。
(5). 复杂类型的解析问题
最后让我们回到这个问题上来,这一类问题其实是相当令人头痛的,因为下面的类型一般来说出现了目的就是为了让人看不懂的:
char *(*(*f(char *(*para)(char *)))[2])();
int (*(*(*pfunc)(int *))[5]) (int *);
正经项目里这么写是会被人打的,千万不要这么写,但是毕竟大家可能会有考试的需求,或者说有的时候即便是一个简单的声明式,你可能也不能很快地看出它到底是什么意思,比如:
int *p[10];
int (*p)[10];
我知道,有些人可能一眼就看出来了,但是也有一部分人可能要仔细想一会儿才能得出结论。
#1.C语言的数组类型
我们发现,声明一个数组的方式是这样的:
int a[10];
我们要把代表数组的中括号写在数组名称的后面,这样虽然看着还挺美观的,但是带来一个问题:如果用中括号代表数组,那我们不能用int[]来声明或定义数组,这有点别扭,在Java中这个问题就不存在了,因为Java的中括号是和类型连在一起的,例如:
String[] args = new String[10];
不过如果我们是解析,或许可以把这个[]拿到前面来,只是让我们更好理解,比如 int[10] a;那么a就是一个10个元素的数组,每个元素都是int类型,好起来了,这样听起来要好得多啊。
#2.三种运算符的优先级
我们在这里要提到三种运算符,*,[]和(),这三种运算符的优先级是’*’ < ‘[]’ = ‘()’,记住它,这真的相当重要。
#3.完整版的类型提前法
对于一个复杂的类型声明,我们可以依照*,[]和()划分为对应的三个部分,其中*的结合优先级最低,[]和()的保持一致,相同优先级的,依照从左到右的结合顺序,从而能够将三个部分进行排序,依次把优先级最弱的运算符划到类型的位置上,最终声明的变量只剩下变量名,左边的类型从右向左读取,就是a的类型。
我把这个方法称为类型提前法,听起来还是很抽象,我们来操作一下:
char *(*(*f(char *(*para)(char *)))[2])();
第一步,把现阶段不是类型的部分划分为不同优先级的部分,即:
* (*(*f(char *(*para)(char *)))[2]) ()
其中*的优先级最弱,优先提前到类型,之后的两个圆括号,右边的优先级弱,也提前到类型,因此第一步简化后的结果是:
char*() *(*f(char *(*para)(char *)))[2]; // 仅剩的外层圆括号可以去掉了
所有直接提前的圆括号都认为是函数指针,例如我们现在把后面那一堆乱七八糟的认为是t,那么t的类型就是一个函数,返回类型为char*,不过当然还没结束,我们继续操作。
第二步,继续按照上述的逻辑划分,得到下面三个部分:
* (*f(char *(*para)(char *))) [2]
其中*的优先级最弱,继续提前,之后()和[]的优先级相同,从左到右结合,所以 [2]的优先级更弱,提前到类型,所以就变成了:
char*()*[2] *f(char *(*para)(char *));
第三步,继续划分,得到:
* f (char *(*para)(char *))
变量名直接划分出来作为单独的部分,在这种情况下我们发现后面的圆括号其实是参数表,f是一个函数,所以我们就不要再把后面的圆括号提前了,只提前*,变成
char*()*[2]* f(char *(*para)(char *));
最后,我们来解析一下f后的圆括号内的类型,char *(*para)(char*)划分为:
* (*para) (char *)
按照弱优先级优先提前的原则,我们把*先提前,再提前(char*),最后得到这个表达式:
char*(char*) *para;
不过其实这一步已经可以不用这么做了,因为char *(*para)(char *)已经是我们熟悉的函数指针形式了,所以到这里,所有的准备工作已经完成,我们开始进行解析:
char*()*[2]* f (char *(*para)(char *));
f是一个函数,参数para是一个函数指针,para的参数类型是char*,这个函数指针返回类型为char*,f的返回类型是一个指针,这个指针指向一个包含两个元素的数组,每个元素都是一个指针,指向一个函数,这个函数的参数表为空,返回类型是char*。
虽然这个结构真的很复杂,但是我们依照这样的步骤一步一步下来就会很清楚地知道这个f的类型到底是什么,最后的最后切记要根据我们分解出来的类型从右向左读,这样才能得到正确的结果。
所以开头举的另一个例子,你也可以试试看,我把结果写在下面:
int (*(*(*pfunc)(int *))[5]) (int *);
\***********************************\
int(int *)*[5]* (*pfunc)(int *);
pfunc是一个函数指针,参数类型为int*,返回类型是一个指针,指向一个有五个元素的数组,每个元素都是一个指针,指针指向一个函数,这个函数的参数类型为int*,返回类型为int。
#4.检验方法
cdecl是linux下一个比较好用的C语言声明解析工具,在Ubuntu环境下,首先输入下面的命令进行安装:
sudo apt-get install cdecl
安装完成之后,输入cdecl -i进入程序,再输入explain + 语句让工具来帮我们解析,比如解析这一条:
int (*(*(*pfunc)(int *))[5]) (int *);
我们翻译一下它的结果,pfunc是一个函数指针,参数类型为int*,返回一个指针,指向五个元素的数组,每个元素是一个指针,指向一个函数,参数类型为int*,返回类型为int。
完全正确,所以这样一来你就可以检查自己的想法是不是对的了。
小结
这是比较短的一篇,我们讲了讲之前没有提到的函数指针,以及函数指针的一些应用,最后我们还讲了讲一些非常复杂的类型声明的解析方法,希望能够帮助到你。