前言
菜鱼最近一直在读JVM和Redis的源码,虽然很早之前学过一点C/C++的语法,经过长时间在Java中的增删改查,之前学的那点知识早就不知道被抛到什么地方去了。有人说指针是C语言的灵魂,那么指针里面的函数指针对于菜鱼我来说就是噩梦。很多时候,根本看不懂代码在表达什么,被一个函数声明搞懵逼是常有的事。长时间的懵逼以后,菜鱼我还是花了一些时间来了解这个东西。
菜鱼我把自己在学习函数指针过程中得到的一些体会以及对函数指针的认识整理出这么一篇文章。当然,菜鱼我是做Java的,可能对一些概念或知识点了解的不够透彻,甚至出现误解都是有可能的。不过还是希望能够这篇文章能给你带来新的收获。
在开始本文之前,我们先理清一个概念:指针函数和函数指针。
- 指针函数:首先是一个函数,其次,函数的返回值是一个指针,这种函数称为指针函数,例如:
int* fun(int firstParam,i secondParam);
void* fun(void);
- 函数指针:首先是一个指针,它的指向类型是一个函数,这种指针称为函数指针,例如:
int(*fun)(int* a,int b);
int (*swap)(int* (*fun)(char a,char b),char a,char b);
函数指针的一个小例子
先贴一段代码热热身:
void (*funPoint)(int *a, int *b);
void swap(int *a, int *b) {
int c=*a;
*a=*b;
*b=c;
}
int main() {
int first=10;
int second=20;
funPoint=swap;
printf("execute before first is:%d, second is:%d\n",first,second);
funPoint(&first,&second);
printf("execute after first is:%d, second is:%d\n",first,second);
return 0;
}
这个程序的目的很简单,就是交换两个变量,声明一个函数指针funPoint,在程序运行的时候,把一个函数swap赋值给这个函数指针,然后调用函数指针funPoint,实现了变量first和second两个值的交换。是不是有一个想法:这也太简单了,交换两个变量的值,直接写一个swap函数就行了,干嘛还要多此一举,额外定义一个funPoint呢?
为什么要有函数指针
在回答这个问题之前,菜鱼我先假定诸位看客是使用面向对象语言做开发的,那么对于面向对象的三大概念:封装、继承和多态应该有了解。由于菜鱼我是做Java开发的,所以就拿Java举例,如何用Java实现两个变量的交换呢?贴一段代码:
public abstract class AbstractSwap<K> {
public AbstractSwap(K first,K second){}
public void invoke(){
print();
swap();
print();
}
abstract void swap();
abstract void print();
}
public class SwapInt extends AbstractSwap<Integer> {
Integer first, second;
public SwapInt(Integer first, Integer second) {
super(first, second);
this.first = first;
this.second = second;
}
@Override
void swap() {
Integer temp = first;
first = second;
second = temp;
}
@Override
void print() {
System.err.println(String.format("current first is:%d,second is:%d.", first, second));
}
}
public static void main(String[] args){
AbstractSwap<Integer> swap=new SwapInt(10,20);
swap.invoke();
}
这段Java代码很简单,目的也完成两个Integer变量值的交换,但是却定义了一个类和一个abstract方法,这难道不也是多此一举吗?在回答这个多此一举的问题之前,先再创建一个问题,现在我们需要交换Float值,试问一下,现在该怎么办?当前有两个办法:
-
直接修改
SwapInt类,将里面形式类型参数K硬编码成Float,然后将里面所有的Integer也硬编码成Float。 -
新建一个类,命名为
SwapFloat,同SwapInt一样,将形式类型参数K硬编码为Float。
从设计模式的角度而言,第一种方法绝对不能通过,原因是违反了开放-封闭原则。既然这样,只能选择第二种方式,不过在新建SwapFloat类之前,还有一个问题,如果菜鱼此时想交换Long、Character亦或者Short值,也需要新建SwapXXX类吗?能不能通融一下?当然可以,类的数量并不是越多越好。我们可以对基础数据类型做一个抽象:
public class SwapBaseType<K> extends AbstractSwap<K> {
K first, second;
public SwapBaseType(K first, K second) {
super(first, second);
this.first = first;
this.second = second;
}
@Override
void swap() {
K temp = first;
first = second;
second = temp;
}
@Override
void print() {
System.err.println(String.format("current first is:%s,second is:%s.", String.valueOf(first), String.valueOf(second)));
}
}
这一坨代码,可以支持Java中所有基础数据类型的包装类型。但是无休止的新需求又来了,能不能接收自定义类型呢?当然是不完全可以。SwapBaseType根据其算法,只能支持基础类型的值交换,以及自定义类型的地址交换。而对于自定义类型的内部属性,这个算法不知道自定义类型要交换什么属性,于SwapBaseType而言就是有心无力。
现在回到我们之前的问题,为了交换两个变量的值,专门定义了类和abstract方法,算不算多此一举呢?定义类,目的是为了交换两个变量的值,而定义抽象方法,目的是为了执行交换两个变量这件事。再回到我们原始的问题上来,函数指针出现的意义是什么?说白了,这不就是定义规范嘛。
再举两个例子
上一节我们讨论出了函数指针出现的意义,还是需要搞两个例子,膜拜一下顶级代码大师是怎么玩规范的。菜鱼我选择了两个示例,一个是Redis中的dict,另一个是用于创建线程的pthread_create,菜鱼我尽量把内容讲清楚。
对于粘贴的一些代码,为了节省篇幅,把和函数指针无关的部分代码全部用注释// other code...替代。
Redis中的dict
之所以采用了dict,一个原因是:经典,Redis中的所有数据都存在这个结构里面,Redis提供的所有命令也存在这个结构里面,Redis的读写效率如此之高,除了其事件循环机制之外,和这个结构也不无关系;另一个原因是:可以使用它来和Java中的HashMap作比较。
dict
先贴一段server.h中的结构定义:
struct redisServer{
// otehr code....
redisDb *db;
// other code...
}
整个redis中所有的数据库,都是存储在这个字段里面。这个是一个指针类型,在C语言里面,遇到一个一级非函数指针,这个指针的指向有两种可能,一种是指向一个数组(空间连续)或链表(空间不连续),另一种是单纯的指向一个对象。这是指针和数组的问题,可能会在其他文章中讲述。这里的db所指向的是一个数组,看其初始化的位置server.c中:
void initServer(void) {
// other code...
server.db = zmalloc(sizeof(redisDb)*server.dbnum);
// other code...
for (j = 0; j < server.dbnum; j++) {
server.db[j].dict = dictCreate(&dbDictType,NULL);
// other code...
}
}
简单解释一下这段代码,server.db被分配了server.dbnum个大小为sizeof(redisDb)的空间。紧接着for循环初始化数组中每一个元素,里面创建了dict实例,创建实例时传递了一个&dbDictType指针,这个参数非常重要,放在后面讲述。顺便普及一个小知识点:
这里的server.dbnum,指的就是在redis.conf中配置的databases属性,默认值是16,当我们运行select命令时,所选择第n个数据库,指的就是server.db中下标为n的元素。现在看一下db的类型,在server.h中:
typedef struct redisDb {
dict *dict; /* 所有的key-value,都存储在这里*/
// other code...
} redisDb;
找到了存储K-V的数据结构:dict,进入dict的定义,在dict.h中。
typedef struct dictEntry {
void *key; // 这是key
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; // 这是value
struct dictEntry *next; // 出现hash冲突时,当前entry指向的下一个entry
} dictEntry;
typedef struct dictht {
dictEntry **table; // 持有多个dictEntry*的结构
unsigned long size; // table数组的大小,即总空间容量,size的值永远,且必须是2的幂次方
unsigned long sizemask; // 掩码=size-1,用来计算key的索引值
unsigned long used; // 用来存储,table中的已使用的空间元素数量,包含dictEntry中next的数量
} dictht;
typedef struct dict {
dictType *type;
dictht ht[2];
long rehashidx;
// other fileds...
} dict;
先说一下这个三个结构体,第一个是dictEntry,如果读过Java中HashMap的源码,很容易就能和里面的java.util.HashMap.Node这个类联系起来,本质上他们都是用来持有K-V数据的。由于C语言里面没有泛型,所以使用了一个void*来表示key,dictEntry里面使用了一个union来作为v,其本意是:key必须是一个指针,v只有四种选择:指针、uint64_t类型、int64_t类型和double。至于为什么不把v也当做一个void*,这个问题菜鱼研究过,但是没有得到准确的答案。最后一个字段是next,这个是用来解决hash冲突的,其解决冲突的方式就是拉链法。
第二个结构体是dictht,很多人看到C语言中的二级指针就头疼,菜鱼我也一样。所谓二级指针,就是指向指针的指针,三级指针就是指向指针的指针的指针,四级指针就是。。。。算了,不说了。面对这个二级指针,我们先把问题简化一下,如果table是一个一级指针,比如dictEntry* table,会出现什么问题?之前菜鱼说了,遇到一个一级非函数指针,它可能指向的是一个链表,也可能是一个对象。在当前这种情况下,table绝对不能指向一个对象,因为要存储很多个dictEntry。那么只能指向一个数组,而若是其指向一个数组,其内部的元素类型,只能是dictEntry,而绝对不能是一个dictEntry*。对于没有深入接触过指针概念的程序员而言,可能还是云里雾里,可以去看一些C/C++专业的书籍。
第三个结构体是dict,long rehashidx和dictht ht[2]两个字段涉及到的是dict中的rehash。rehash过程可以和HashMap中当冲突到达一定数量之后,冲突链会转化成一个红黑树这个过程进行类比,但dict解决冲突的方式和HashMap不完全相同,dict采用的是"渐进式"rehash,这是一个比较复杂而且很复杂的过程,复杂到菜鱼我还没彻底搞懂。直接看dictType *type,其实这才是我们要研究的东西,dict的规范指的就是这个数据类型。在dict.h中,dictType的结构定义如下:
typedef struct dictType {
uint64_t (*hashFunction)(const void *key); // 计算key的hash值
void *(*keyDup)(void *privdata, const void *key); // 创建一份给定key的拷贝
void *(*valDup)(void *privdata, const void *obj); // 创建一份给定value的拷贝
int (*keyCompare)(void *privdata, const void *key1, const void *key2); // 两个key作比较
void (*keyDestructor)(void *privdata, void *key); // 销毁key
void (*valDestructor)(void *privdata, void *obj); // 销毁value
} dictType;
接下来,菜鱼我结合Java中的HashMap,来分析一下这几个函数指针定义了哪些规范。
Redis版的HashMap
先创建一个HashMap实例,key和value都是自定义类型:
Map<JavaKey,JavaValue> javaMap=new HashMap<>();
既然使用到了HashMap,回想一下,之前我们学习HashMap的时候,除了告诉我们HashMap是一个K-V容器之外,Map中的key也就是JavaKey,其自身要重写equals和hashCode方法。原因是什么呢?通过源码可以一探究竟,在HashMap的put、putVal和hash方法中:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0){
n = (tab = resize()).length; // 1. hash表为空,初始化hash表
}
if ((p = tab[i = (n - 1) & hash]) == null){ // 2. 根据给定hash值,计算出的索引位置没有被其他元素占用
tab[i] = newNode(hash, key, value, null); // 直接占位
}
else {
// 3. 计算出的位置已经被其他元素占用
Node<K,V> e; K k;
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))){
// 占用位置的元素和本次将要插入的key是同一个
e = p;
}
// other code ...
}
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里,hashCode其中一个作用就是计算key存储位置的索引,另一个作用就是用来判等。HashMap就是用两个相等概念来判断出现冲突的两个key是不是真的相等,一个是hash相等:p.hash == hash;另一个就是值相等,判断值相等有两种途径:一种是使用==来为两个key进行所谓地址上的比较,同一个对象的地址肯定是相同的,这毋庸置疑。
另一个途径就是使用equals来为两个key进行比较,这个比较我们可以称其为内容上的比较,因为HashMap不知道在自定义类型里面使用哪些字段来判断其相等性。而前面提到key需要重写equals和hashCode方法,目的就在于此。
那么dictType中的两个函数指针:hashFunction和keyCompare的作用是什么呢?在dict.c的dictAddRaw和_dictKeyIndex函数中:
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing)
{
// other code...
uint64_t hashValue=d->type->hashFunction(key); // 获取key的hash值
index = _dictKeyIndex(d, key, hashValue, existing); // 计算出key应该存储的位置
if(index==-1){
return NULL;
}
// other code...
}
static long _dictKeyIndex(dict *d, const void *key, uint64_t hash, dictEntry **existing)
{
// other code...
for (table = 0; table <= 1; table++) {
// 1. 根据hash值和sizemask,计算出key存放的位置索引
idx = hash & d->ht[table].sizemask;
/* 2. 上一步计算出的位置,现在是不是被其他key占用了 */
he = d->ht[table].table[idx];
// 3. 解决冲突的问题
while(he) {
// 判断出现冲突的key,是不是同一个key
if (key==he->key || dictCompareKeys(d, key, he->key)) {
// 是同一个key
// other code...
return -1;
}
he = he->next;
}
// other code...
}
}
这里的dictCompareKeys是一个宏定义,其展开形式类似于这样:
if(d->type->keyCompare){
return d->type->keyCompare(d->privdata, key,he->key);
}
return key==he->key;
在这段代码中,hashFunction也是用来生成hash值,和参与计算key的存储位置。在遇到冲突时,dict判等的方式和HashMap有一点差别,dict没有用到key的hash值,但是对于值判等方式,却是一样的,dict利用的是==和keyCompare。
至此,代码分析完毕。虽然看起来没有什么干货,但是能让我们更近一步的了解函数指针的意义。HashMap依托面向对象语言Java的强大优势,制定让使用者重写equals和hashCode方法的规范。但dict所依赖的C语言是没有Java这种优势的,制定规范的表现形式只能选择函数指针,无论什么值存储进来,无论创建的dict实例是给数据库使用,还是用来存储Redis中的命令,使用者必须要实现dict所指定的规范,即dictType。C语言虽然是面向过程的语言,但Redis却是使用面向对象的思想编写出来的,从dict的设计上,可见一斑。
简单说一下dictType中其他函数指针的作用:keyDestructor和valDestructor,分别是用来销毁key和value的,因为Java有自动内存管理机制,但是C语言没有,必须手动释放;keyDup和valueDup是分别用来复制key和value的,这个机制在Java中也存在,实现Cloneable接口,并且重写clone方法就OK了。
线程的创建
在Java中,创建一个线程有几种方式不重要,重要的是线程执行的代码存在于Runnable接口里面的run方法中,这就是Java线程中定义的规范。在实现了POSIX规范的接口中,创建一个线程使用的是pthread_create:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);
这个函数里面,有一个函数指针参数void* (*start_routine)(void*),这个参数的作用,和run方法的作用相同,线程运行的就是这个函数指针所指向的函数里面的代码。从pthread_create接口设计者的角度而言,这个接口就是用来创建线程,至于在线程里面运行什么,那是使用者的事情,设计者和使用者之间的规约,就是void* (*start_routine)(void*)。
有一点很有意思,Java里面的run方法没有返回值,获取外部参数也只能通过访问run方法所在的类的字段,而这里声明的函数指针不仅有参数,还有返回值。若是做过这两种语言的开发或者对这两种语言有较深入的了解,好好体会一下,那种内在关系,不言而喻的联系,回味无穷。
几种函数指针
上文我们讲述了函数指针的意义和两个示例,但是在我们实际研究别人代码的时候,遇到一个函数指针,很多时候就蒙了,接下来菜鱼我将描述几种阅读源码过程中遇到的函数指针类型。
- 普通型:所谓普通型,这种函数指针言简意赅,在参数和返回值上面没有其他的弯弯绕,诸如上文提到的
pthread_create的参数,以及dictType结构体里面的定义的函数指针。Redis 6.0引入多线程IO以后,其创建线程地方在server.c的initThreadedIO函数中:
void initThreadedIO(void) {
// other code...
for (int i = 0; i < server.io_threads_num; i++) {
// other code...
if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {
// other code...
}
}
}
这个里面函数指针指向的是一个叫做IOThreadMain的函数,这个函数在networking.c里面实现:
void *IOThreadMain(void *myid) {
// code ...
}
直接对应了void* (*start_routine)(void*)这个函数指针的签名。这种比较简单,在研究代码的过程中,直接去找和指针签名长得一样的函数就OK了。
- 函数指针的函数指针参数:指的是函数指针的其中一个和全部参数是一个函数指针,比如:
void (*swap)(void* first,void* second,void (*callback)(void*));
看到这种函数指针,我们首先将其参数的个数和类型确认下来,从这个示例来看,可以确定其接收是三个参数,前两个参数是一个void*类型,另外一个是函数指针,我们单独把函数指针参数抽离出来:void (*free_callback)(void*),发现其除了返回值类型(void)和pthread_create的那个函数指针参数不同之外,也没啥区别。比如,菜鱼想交换两个值,交换完成后再打印一句话:
typedef void (*callback)(void *);
void callbackPrint(void *arg) {
printf("swap succeed! swap type is :%s\n", (char *) arg);
}
void swapInt(void *first, void *second, callback back) {
int c = *(int *) first;
*(int *) first = *(int *) second;
*(int *) second = c;
back((void *) "int");
}
void swapLong(void *first, void *second, callback back) {
long c = *(long *) first;
*(long *) first = *(long *) second;
*(long *) second = c;
back((void *) "long");
}
void (*swap)(void *first, void *second, callback back);
int main() {
int a = 10, b = 20;
printf("swap int before,a=%d,b=%d\n", a, b);
swap = swapInt;
swap(&a, &b, callbackPrint);
printf("swap int after,a=%d,b=%d\n", a, b);
long c=40,d=50;
printf("swap long before,c=%ld,d=%ld\n", c, d);
swap=swapLong;
swap(&c, &d, callbackPrint);
printf("swap long after,c=%ld,d=%ld\n", c, d);
return 0;
}
这段代码里面取了一个巧,使用typedef对void (*callback)(void *)进行了重命名,C/C++的代码里面很多都使用到这个这个关键字,很基础也很简单的一个知识点。
- 多级函数指针:这种函数指针,就是多级指针,不怎么常用,但有时也能碰得到(面试的时候),类似于下面这样:
void (**swap) (void* first,void* second,void (*callback)(void *));
上文我们一直在讲,遇到一级非函数指针,其指向的可能性有两种,指向对象或一个数组(链表)。因为指针和数组的原因,菜鱼特意把函数指针和普通指针分开来,但是遇到这种函数指针,就必须把指针和数组联系起来,这是一个比较大的概念,这篇文章里就不过多赘述了。直接还是用交换两个变量做示例吧:
void (**swap) (void* first,void* second,callback);
int main() {
int a = 10, b = 20;
swap= (void (**)(void *, void *, callback))(malloc(2 * sizeof(swap)));// 分配空间
swap[0]=swapInt;
swap[1]=swapLong;
printf("swap int before,a=%d,b=%d\n", a, b);
swap[0](&a, &b, callbackPrint);
printf("swap int after,a=%d,b=%d\n", a, b);
long c=40,d=50;
printf("swap long before,c=%ld,d=%ld\n", c, d);
swap[1](&c, &d, callbackPrint);
printf("swap long after,c=%ld,d=%ld\n", c, d);
free(swap); // 释放之前分配的空间
return 0;
}
遗漏的几点
以上基本就把函数指针相关的概念讲述完成了,对于此文,菜鱼我自认为有一些知识点没有提及到:
- 函数指针的工作原理
- 如何定义一个函数指针
void*- 函数指针的本质
首先,要明白函数指针的工作原理,必须对函数的执行原理要有深入了解,这是一个很大的知识体系,涉及到机器码、寄存器和CPU架构等诸多概念。其实写这篇文章的原因,就是因为此菜鱼想要研究Java中方法之间的调用原理,在研究的过程中,卡在了函数指针以及特定于CPU架构的机器码生成的地方。不过幸运的是,菜鱼还是研究出了一些东西。下一篇文章就是函数的调用原理。
如何定义一个函数指针?按照《C++ Primer Plus》上的说法,要创建指向特定类型函数的函数指针,先创建这个函数,然后把函数名用(* pf)替换。菜鱼我认为,没必要特定去研究,除非你去做C/C++开发,当你源码看多了,自然就明白了,水到渠成嘛。
可能有的小伙伴会注意到,某些函数指针的声明,其接收的参数或者返回值是void*,这代表什么?前文简单提到过,是因为C语言里面没有Java中的泛型概念,C语言的函数中,若是想接收一个任意类型的指针,参数类型必须是void*,然后在具体的函数实现中手动进行强制转换;若是一个函数返回一个任意类型的指针,其返回值也必须定义成void*,然后在接收的地方手动进行强制类型转换,类似于Java中方法接收Object类型的参数或返回值是一个Object类型,C语言中不存在顶级父类这个概念,这点需要注意。
说到函数指针的本质,就不得不提指针的本质,什么是指针?指针就是地址,地址就是指针。同样,函数指针也是地址,只不过持有的是一个函数的地址。通常我们看到一个函数指针,下意识的就去寻找这个函数指针的实现,如果这个函数指针没有实现呢?或者说其实现就是一串地址呢?这里不得不提一嘴JVM里面的实现了,直接把函数生成机器码,然后把这个函数的地址赋值给函数指针,让你怎么找都找不到函数的实现,这个放在后面讲述。
更多的的参考书籍,可以直接去看C/C++的一些名著,这里就不过多介绍了。