学习Scala:函数字面量是如何实现的

时间:2023-01-07 19:55:13


Scala提拱了强大且简洁的函数式的编程方式。说实话, 到目前为止, 我还没有真正体验到函数式编程的好处, 因为确实缺少这方面的实战经验, 从毕业到现在, 一直在写Java代码。 但是Scala的函数式编程, 一眼看上去就给人简洁的感觉。


本文介绍Scala函数式编程中的一个重要内容: 函数字面量。


所谓的函数字面量, 说白了就是一段代码, 和Java 8中的lambda表达式相似。 lambda翻译成中文, 有匿名函数的意思, 也可以把Scala中的函数字面量或者Java中的lambda表达式叫做匿名函数。


下面先看一下Scala中的函数字面量长什么样。 打开Scala命令行, 敲下一个简单的Scala函数字面量:

scala> (x:Int) => println(x)
res19: Int => Unit = <function1>

其中 (x:Int) => println(x)  就是一个简单的函数字面量。 => 左边是字面量的参数列表, =>右边是函数体。 如果函数体操作一行, 要用花括号括起来。如下:
scala> (x:Int) => { println(x)
     | println(x + 1)
     | }
res20: Int => Unit = <function1>


函数字面量是有类型的。 有上面的打印信息可知, 函数字面量 (x:Int) => println(x) 的类型是  Int => Unit 。 这个类型同样由两部分组成, =>左边是参数的类型, =>右边是函数体的返回值类型。 


函数字面量使用最多的方式是作为参数传递。 下面定义一个这样一个类:

class FunctionTest{
	
	def doSomething(func : Int => Unit){
		func(4)
	}

	def doSomething1(){
		doSomething( ( (x : Int) => println(x) ) )
	}
}

在这个类中, 有两个方法,doSomething 方法接收一个   Int => Unit 型的字面量作为参数, 并且在方法体中调用了这个函数字面量。 doSomething1 方法调用doSomething 方法, 并且为doSomething 方法传入一个函数字面量 (x : Int) => println(x) 作为参数。


下面编译这个类:

scalac FunctionTest.scala


编译完成之后, 可以看到FunctionTest.scala源码相同目录下, 多出两个class文件:

学习Scala:函数字面量是如何实现的


和源码中的FunctionTest类相对应的是FunctionTest.class 。 另一个名称奇怪的class是scalac编译器自动生成的。 我们可以猜想, 这个类是为了辅助函数字面量的实现。 


下面反编译Function.class :

javap -c -v -classpath . -private FunctionTest


下面是反编译之后的结果(为了减少篇幅, 省略了部分无关信息)
public class FunctionTest
  SourceFile: "FunctionTest.scala"
  InnerClasses:
       public #24; //class FunctionTest$$anonfun$doSomething1$1
  RuntimeVisibleAnnotations:
    0: #6(#7=s#8)
    ScalaSig: length = 0x3
     05 00 00
  minor version: 0
  major version: 50
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:

  ......
  //省略了常量池
  ......



{
  public void doSomething(scala.Function1<java.lang.Object, scala.runtime.BoxedUnit>);
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_1
         1: iconst_4
         2: invokeinterface #16,  2           // InterfaceMethod scala/Function1.apply$mcVI$sp:(I)V
         7: return


  public void doSomething1();
    flags: ACC_PUBLIC
    Code:
      stack=4, locals=1, args_size=1
         0: aload_0
         1: new           #24                 // class FunctionTest$$anonfun$doSomething1$1
         4: dup
         5: aload_0
         6: invokespecial #28                 // Method FunctionTest$$anonfun$doSomething1$1."<init>":(LFunctionTest;)V
         9: invokevirtual #30                 // Method doSomething:(Lscala/Function1;)V
        12: return

    ......
    //省略了自动生成的构造方法
    ......

}



首先看doSomething方法:


1在源码中, 它接收一个Int => Unit 类型的函数字面量, 而在class文件中, 它被编译成接收一个scala.Function1类型的参数。


2 在源码中, doSomething方法中调用了函数字面量 func(4) 。 而在class文件中, 转换成使用scala.Function1类型的对象调用scala.Function1接口的apply$mcVI$sp方法。 之所以说scala..Function1是接口, 是因为调用apply$mcVI$sp方法的字节码指令是invokeinterface 。 也就是说doSomething方法接收的那个参数, 实现了scala.Function1接口。


分析到这里, doSomething方法就分析完了。


然后再看doSomething1 方法:


1 在源码中, doSomething1 函数调用了doSomething函数, 并且传入了一个函数字面量。 


2 在class文件中, doSomething1 函数中使用new字节码指令创建了一个FunctionTest$$anonfun$doSomething1$1类型的对象, 并且使用invokespecial字节码指令调用这个对象的构造方法<init> 。 从反编译输出结果的上面的部分, 可以看到该类的InnerClasses属性, 这个属性描述当前类的内部类:

  InnerClasses:
       public #24; //class FunctionTest$$anonfun$doSomething1$1

 可以看到 FunctionTest$$anonfun$doSomething1$1类被编译成了当前类的内部类。 也就是说编译器自动为当前类生成了内部类FunctionTest$$anonfun$doSomething1$1。 


在调用构造函数初始化这个FunctionTest$$anonfun$doSomething1$1对象之后, 又使用invokevirtual指令调用了当前类的doSomething方法, 并且把这个新创建的FunctionTest$$anonfun$doSomething1$1对象作为参数传入了doSomething方法中。 


在上面分析doSomething时, 我们知道doSomething方法有一个scala.Function1接口类型的参数, 从这里不难看出, 生成的内部类FunctionTest$$anonfun$doSomething1$1实现了scala.Function1接口 。 


分析到这里, doSomething1方法的实现就分析完了。 


现在, 我们把注意力集中到自动生成的内部类FunctionTest$$anonfun$doSomething1$1上。 下面反编译这个类:

javap -c -v -classpath . -private FunctionTest$$anonfun$doSomething1$1

输出结果如下(省略了一些不相关的信息):

public final class FunctionTest$$anonfun$doSomething1$1 extends scala.runtime.AbstractFunction1$mcVI$sp implements scala.Serializable
  SourceFile: "FunctionTest.scala"
  EnclosingMethod: #9.#12                 // FunctionTest.doSomething1
  InnerClasses:
       public #2; //class FunctionTest$$anonfun$doSomething1$1
    Scala: length = 0x0

  minor version: 0
  major version: 50
  flags: ACC_PUBLIC, ACC_FINAL, ACC_SUPER
Constant pool:
    ......
    ......
    
{
  public static final long serialVersionUID;
    flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: long 0l


  public final void apply(int);
    flags: ACC_PUBLIC, ACC_FINAL
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: invokevirtual #21                 // Method apply$mcVI$sp:(I)V
         5: return


  public void apply$mcVI$sp(int);
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: getstatic     #31                 // Field scala/Predef$.MODULE$:Lscala/Predef$;
         3: iload_1
         4: invokestatic  #37                 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
         7: invokevirtual #41                 // Method scala/Predef$.println:(Ljava/lang/Object;)V
        10: return


  public final java.lang.Object apply(java.lang.Object);
    flags: ACC_PUBLIC, ACC_FINAL, ACC_BRIDGE, ACC_SYNTHETIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: invokestatic  #46                 // Method scala/runtime/BoxesRunTime.unboxToInt:(Ljava/lang/Object;)I
         5: invokevirtual #48                 // Method apply:(I)V
         8: getstatic     #54                 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit;
        11: areturn
}


先从最上面看起, 这个类继承了一个叫做scala.runtime.AbstractFunction1$mcVI$sp的类, 并没有直接实现scala.Function1接口, 我们猜测scala.runtime.AbstractFunction1$mcVI$sp类实现了scala.Function1接口 。 


可以看到, 该类中有三个方法(其实还有一个构造方法, 省略掉了), 其中有我们关心的apply$mcVI$sp方法。 现在我们直接分析apply$mcVI$sp方法, 由于这个方法由编译器自动生成, 不存在对应的源码, 所以我们直接分析class文件中的字节码:


首先使用getstatic指令访问scala/Predef$类中的静态字段MODULE$


然后调用scala/runtime/BoxesRunTime类中的boxToInteger静态方法, 这个方法实现将int装箱成Integer 。 


最后调用MODULE$对象的println方法, 打印整型的参数。


也就是说, 我们定义的函数字面量中的prinln打印逻辑, 是在这个apply$mcVI$sp方法中实现的! 到此为止, 内部类FunctionTest$$anonfun$doSomething1$1也分析完了。



总结


到此为止, 我们大概可以知道, 函数字面量在Scala中是如何实现的了。 现在把实现过程总结一下:


1 如果一个方法接收一个函数字面量作为参数, 那么在编译时把这个参数编译成scala.FunctionN接口类型, 这里的N和参数的个数相同。


2 如果一个方法中创建了字面量, 比如写上了 (x : Int) => println(x)  , 那么就会创建一个内部类, 这个内部类间接实现上述的scala.FunctionN接口, 并且实现一个类似于apply$mcVI$sp的函数(这个函数的参数和返回值, 和函数字面量的参数和返回值相对应)。 然后创建一个这个内部类的对象, 也就是说, 在实现方式上, 函数字面量就是这个内部类对象。


3 如果将这个字面量传入其他接收字面量的函数中, 相当于把上述的内部类对象传入接收字面量的函数中, 我们已经知道, 接收函数字面量的方法, 被编译成接收scala.FunctionN, 而这个内部类正好实现了这个scala.FunctionN接口, 所以这个代表函数字面量的内部类对象, 正好可以传给接收字面量的方法。 


4 在接收函数字面量的方法中, 如果调用了这个函数字面量, 相当于调用代表该函数字面量的对象的apply$mcVI$sp函数。 


在本例中使用的Scala源码如下:

class FunctionTest{
	
	def doSomething(func : Int => Unit){
		func(4)
	}

	def doSomething1(){
		doSomething( ( (x : Int) => println(x) ) )
	}
}

如果用java描述的话, 这个过程是这样的(伪代码):

class FunctionTest {
	
	void doSomething(scala.Function1 arg){
		arg.apply$mcVI$sp(4);
	}

	void doSomething1(){

		scala.Function1  obj = new FunctionTest$$anonfun$doSomething1$1();

		doSomething(obj)l
	}

	/*内部类*/
	class FunctionTest$$anonfun$doSomething1$1 impliments scala.Function1{

		public void apply$mcVI$sp(int arg){

			scala.Predef$.MODULE$.println(new Interger(arg));

		}
	}
}

所以可以得出如下的结论, 在Scala中, 函数字面量虽然作为一个函数,但是在class的底层实现上, 是使用对象实现的。 其中Scalac编译器做了大量的工作, 包括生成对应的内, 创建对应的对应等。 


所以, 请记住, 在你用Scala编程的时候, 编译器也在帮你写代码。 





本文基于分析class字节码来分析scala, 对class文件格式不熟悉的同学, 可以参考我的专栏: 深入理解Java语言 。