如何写一个回调?


在本文中,我们将介绍在cjava这三种编程语言中如何在把函数作为一个参数传入另一个函数中,以及分析这些语言函数传参内部的原理。

  • c语言 (函数指针)
  • Java (lambda表达式)

一、c语言与函数指针

  1. 指针函数和函数指针
    在介绍函数指针的使用之前我们先要分清楚和它长得特别想的一个概念——指针函数
    来人啊,上定义!

函数指针

指针函数是指带指针的函数,即本质是一个函数,函数返回类型是某一类型的指针

比如说:

int * add(int a,int b){
     int c = a+b;
     return &c;
}

这就是一个指针函数,它的接受两个int值的参数,返回一个int类型的指针。

指针函数

函数指针是指向函数的指针变量,即本质是一个指针变量

函数指针的定义形式如下:

函数返回值类型 (* 指针变量名) (函数参数列表);

我们可以这样去使用一个函数指针——

int add(int a,int b){
    return a+b;
}

int main(){
    int (*fun)(int,int);
    fun = &add;
    int c = (*fun)(1,2);
    printf("%d\n",c);
    return 0;
}

上面代码main函数中的第一行就定义了一个函数指针int (*fun)(int,int),这个函数的参数列表中有两个int类型的参数,返回值类型是,并将add函数的首地址赋给了这个指针fun,在后面我们就像调用了这个函数,结果是显而易见的,打印出来的c值为3。
这个过程是不是有些熟悉?想想我们平常所用的变量指针,我们会写如下的代码:

int main(){
    int a = 0;
    int * p;
    p = &a;
    printf("%p\n",p);
}

与之前的代码进行比较,其区别在于指针指向的函数而不是变量,而我们调用这个函数指针所指向的函数,得到了返回值并打印。

  1. c语言的函数回调
    首先先说一下什么是回调,回调函数就是一个通过函数指针调用的函数。emmm其实还蛮好理解的嘛,就是把函数作为参数传进去嘛,这么机智的你一定想到了可以在函数的参数列表中接收一个函数指针类型的参数~没错就是酱紫˖° (๑´ڡ`๑):

    int add(int a,int b){
        return a+b;
    }
    
    void dosome(int (*callback)(int a,int b))
    {
        printf("%d\n",(*callback)(1,2));
    }
    
    int main(){
        dosome(&add);
    }
    

    我们把add函数作为参数传给了dosome,在dosome函数中调用了这个callback函数(也就是我们传入的add函数),并把返回值打印了出来,输出结果显而易见也是3。是不是写一个回调特别的简单呢~当然我们可以定义不同参数列表和返回值的函数指针来满足我们不同的需求。

  2. 函数指针内部原理
    在讲内部原理之前,先需要预备一些汇编的知识。计算机并不能识别我们平时写的c语言程序,真正物理机器可以识别的,就是一串串二进制01流。我们用c语言写的程序,到最后都会被编译成二进制指令。在函数被执行时,由eip指向下一条要被执行的指令,CPU 会取出这一条指令并且执行。就比如之前的add函数,编译成对应的汇编语言就是:

    //c语言函数
    int add(int a,int b){
        return a+b;
    }
    
    //对应汇编
    add:
    pushl %ebp 
    movl %esp,%ebp
    subl $16,%esp
    movl $3, -4(%ebp)
    movl -4(%ebp),%eax
    leave
    ret
    

    注:汇编语言其实就是机器指令的助记符,为方便展示,此处使用汇编语言

    而这些代表程序代码的01流被存放在了内存区域中的Text段。之前我们提到过,函数指针是一个指针,因此,它和别的指针并没有什么太大的差异,只不过其它指针一般指向的是Heap、Stack和Data区中的变量,而函数指针如果指向函数的话,指针指向的是存放在Text段中,这个函数编译得到的二进制串的首地址。

    在我们调用这个函数时,就把这个地址赋值给了eip寄存器,然后,CPU读取到地址中存放的指令,开始执行这一段函数。而给函数指针赋不同的地址值,其指向的函数(二进制代码)也就不同,我们可以通过一个函数指针赋不同的值来调用不同的函数。

  3. 来点骚操作?
    之前我们说函数指针如果指向函数的话,指针指向的是存放在Text段中,这个函数编译得到的二进制串的首地址。但是我们一定要指向一个函数吗?答案是否定的,我们可以自己把一个函数的二进制代字符流存在一个char数组中,并让函数指向这个数组。

    //add函数对应的二进制代码
    const unsigned char code[]="\x55\x89\xe5\x8b\x45\x0c\x8b\x55\x08\x01\xd0\x5d\xc3";
    
    int main(){
      int (*fun)(int,int);
      fun = (void*)code;
      r = fun(1,2);
      printf("%d\n",r);
    }
    

    在本实例中,定义了一个全局数组 code,code数组里保存的若干字符就是机器指令,这些制定与我们之前定义的add函数作用一样,在main函数中通过int (*fun)(int,int)定义了一个指针函数 fun ,接着通过 fun = (void*)code 将该指针函数指向一个内存地址,也就是code数组的首地址,最后通过 r = fun(1,2); 就可以调用这个指针函数了。当这段c程序执行到 r = fun(1,2) 这条指令时,就会将CS:IP 段寄存器指向了code首地址,从而code数组所在的一块连续内存区域被当作代码来执行。不出意外这段代码的运行结果,依旧是3。
    值得一提的是,这也是JVM中函数执行的奥秘所在,JVM会将由解释器或者即时编译的生成的二进制文件存放在内存的某一个区域,在需要执行的时候,将函数指针指向想要执行的二进制代码的首地址,并执行这个函数。

二、Java与 lambda表达式

  1. java 回调
    在jdk1.8之前还没有lambda表达式的时候,我们一般会定义一个接口,然后接收这个接口类的对象,调用这个对象的方法:
    //OnClickListener.java
    interface OnClickListener{
        void onClick();
    }
    
    //OnClickListenerImpl.java
    class OnClickListenerImpl implements OnClickListener{    
        @Override
        void onClick(){
               System.out.println("clicked");
        }
    }
    
    //Test.java
    class Test{
        void static onClicked(OnClickedListener listener){
            listener.onClick();
        }
        
        public static void main(String []args){
            onClicked(new OnClickListenerImpl());
        }
     }
    
    当然我们可以用匿名内部类来简化这种写法:
    //OnClickListener.java
    interface OnClickListener{
        void onClick();
    }
     
    //Test.java
    class Test{
        void static onClicked(OnClickedListener listener){
            listener.onClick();
        }
        
        public static void main(String []args){
            onClicked(new OnClickListener(){
                  @Override
                  void onClick(){
                       System.out.println("clicked");
                  }            
            });
        }
     }
    

2.为什么要需要传一个接口的实现?
从上面的代码我们可以看出,如果不用lambda表达式的话,其实Java写回调似乎是要比c语言的函数指针要麻烦一些的。我们需要实现一个接口,并把这个接口的实例传进去。

  • java是一门纯粹的面向对象语言,不允许函数脱离类而存在。
  • 在jdk1.7以前,java又没有动态语言支持,不可以随便一个类中的同参数列表、同返回值的函数就可以被函数调用。
  • 在Java中不允许多继承
    所以,最好的解决办法就是使用接口作为函数和参数之间的桥梁。
  1. 函数句柄
    那在Java中有没有类似函数指针的概念呢?笔者个人认为,在jdk1.7中出现的java.lang.invoke包中的MethodHandle就是类似的概念。
    MethodHandle如何使用呢?请参考 https://www.jianshu.com/p/a9cecf8ba5d9
    这篇文章

  2. lambda表达式:
    Lambda表达式是java8的重要更新,可以替代传统的匿名内部类去实现参数的回调,比如之前的代码可以简化如下

    //OnClickListener.java
    interface OnClickListener{
        void onClick();
    }
     
    //Test.java
    class Test{
        void static onClicked(OnClickedListener listener){
            listener.onClick();
        }  
    
        public static void main(String []args){
            onClicked(()->System.out.println("clicked"));
        }
     }
    

    lambda表达式的写法为

    (参数列表)->{
         //程序代码
    }
    

    除此之外,lambda表达式还有着一定的限制,就是lambda表达式所表示的接口中只能有一个非default方法。

  3. lambda表达式的内部原理
    看完如此简洁的lambda表达式,我们应当去一探其内部的实现原理。
    其实我们也可以猜测一下,我们
    在java语言层面我们自然是看不出来什么东西了啦,不如去探究一下lambda表达式编译后的字节码,于是编写demo如下:

    interface Add{
         void add(int a,int b); 
    }
    class Test {
        public static void main(String []args){
              Add add = (int a,int b) -> a+b;
              
        }
    }
    

Test类的编译后的字节码如下:

// class version 52.0 (52)
// access flags 0x21
public class com/example/Test {

  // compiled from: Test.java
  // access flags 0x19
  public final static INNERCLASS java/lang/invoke/MethodHandles$Lookup java/lang/invoke/MethodHandles Lookup

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 7 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this Lcom/example/Test; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x9
  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 9 L0
    INVOKEDYNAMIC add()Lcom/example/Add; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
      // arguments:
      (II)I, 
      // handle kind 0x6 : INVOKESTATIC
      com/example/Test.lambda$main$0(II)I, 
      (II)I
    ]
    ASTORE 1
   L1
    LINENUMBER 10 L1
    ALOAD 1
    ICONST_1
    ICONST_2
    INVOKEINTERFACE com/example/Add.add (II)I
    ISTORE 2
   L2
    LINENUMBER 11 L2
    RETURN
   L3
    LOCALVARIABLE args [Ljava/lang/String; L0 L3 0
    LOCALVARIABLE add Lcom/example/Add; L1 L3 1
    LOCALVARIABLE c I L2 L3 2
    MAXSTACK = 3
    MAXLOCALS = 3

  // access flags 0x100A
  private static synthetic lambda$main$0(II)I
   L0
    LINENUMBER 9 L0
    ILOAD 0
    ILOAD 1
    IADD
    IRETURN
   L1
    LOCALVARIABLE a I L0 L1 0
    LOCALVARIABLE b I L0 L1 1
    MAXSTACK = 2
    MAXLOCALS = 2
}
 

其中<init>是编译器帮我们生成的无参构造函数。
而lambdamain0是编译器根据我们写的lambda表达式生成的一个静态函数

reference

  1. 函数指针与指针函数
  2. 函数指针定义
  3. 《揭秘Java虚拟机》 ——封亚飞
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容