在Java 中安全使用接口引用

2019-08-16 16:33:16 浏览数 (1)

本文由我的好基友 小鄧子 原创投稿 github: https://github.com/SmartDengg/interface-buoy

我使用Java 开发过很多项目,这其中包括一些Web 应用和Android 客户端应用。作为Android 开发人员,Java 就像我们的母语一样,但Android 世界是多元化的,并不是只有Java 才能用来写Android 程序,Kotlin 和Groovy 同样优秀,并且有着大量的粉丝。

我在过去的一年中尝试学习并使用它们,它们的语法糖让我爱不释手,我尤其对?. 操作符感到惊讶,它让我写更少的代码,就能够避免空指针异常(NullPointerException)。

可惜的是Java 并没有提供这种操作符,所以本文就和大家聊聊如何在Java 中取代繁琐的非空判断。

接口隔离原则

软件编程中始终都有一些好的编程规范值得我们的学习:如果你在一个多人协作的团队工作,那么模块之间的依赖关系就应该建立在接口上,这是降低耦合的最佳方式;如果你是一个SDK 的提供者,暴露给客户端的始终应该是接口,而不是某个具体实现类。

在Android 开发中我们经常会持有接口的引用,或注册某个事件的监听,如系统服务的通知,点击事件的回调等,虽不胜枚举,但大部分监听都需要我们去实现一个接口,因此我们就拿注册回调监听来举例:

代码语言:javascript复制
  private Callback callback;

  public void registerXXXX(Callback callback) {
    this.callback = callback;
  }

  ......

  public interface Callback {
    void onXXXX();
  }

当事件真正发生的时候调用callback 接口中的函数:

代码语言:javascript复制
......

 if (callback != null) {
   callback.onXXXX();
}

这看起来并没有什么问题,因为我们平时就是这样书写代码的,所以我们的项目中存在大量的对接口引用的非空判断,即使有参数型注解@NonNull 的标记,但仍无法阻止外部传入一个null 对象。

说实话,我需要的无非就是当接口引用为空的时候,不进行任何的函数调用,然而我们却需要在每一行代码之上强行添加丑陋的非空判断,这让我的代码看起来失去了信任,变得极其不可靠,而且频繁的非空判断让我感到十分疲惫 : (

使用操作符 ' ?. '

Kotlin 和Groovy 似乎意识到了上述尴尬,因此加入了非常实用的操作符:

?. 操作符只有对象引用不为空时才会分派调用

接下来分别拿Kotlin 和Groovy 举例:

在Kotlin 中使用 ' ?. ' :
代码语言:javascript复制
  fun register(callback: Callback?) {

    ......

    callback?.on()
  }

  interface Callback {
    fun on()
  }
在Groovy 中使用 ' ?. ' :
代码语言:javascript复制
  void register(Callback callback) {

    ......

    callback?.on()
  }

  interface Callback {
    void on()
  }

可以看到使用?. 操作符后我们再也不需要添加if (callback != null) {} 代码块了,代码更加清爽,所要表达的意思也更加直接:如果callback 引用不为空则调用on() 函数,否则不做任何处理

' ?. ' 是黑魔法吗?我们将在下一个章节介绍操作符?. 的实现原理。

反编译操作符 ' ?. '

我始终相信在代码层面没有所谓的黑魔法,更没有万能的银弹,我们之所以能够使用语法糖,一定是语言本身或者框架内部帮我们做了更复杂的操作。

现在,我们可以先提出一个假设:编译器将操作符?. 优化成了与if (callback != null) {} 效果相同的代码逻辑,无论是Java,Kotlin 还是Groovy,它们在字节码层面的表现相同

为了验证这个假设,我们分别用kotlinc 和groovyc 将之前的代码编译成class 文件,然后再使用javap 指令进行反汇编。

编译/反编译`KotlinSample.kt`:
代码语言:javascript复制
# $ kotlinc KotlinSample.kt
# $ javap -c KotlinSample.kt

Compiled from "KotlinSample.kt"
public final class KotlinSample {
  public final void register(KotlinSample$Callback);
    Code:
       0: aload_1
       1: dup
       2: ifnull        13
       5: invokeinterface #13,  1           // InterfaceMethod KotlinSample$Callback.on:()V
      10: goto          14
      13: pop
      14: return

    ......

}

通过分析register() 函数体中的所有JVM 指令,我们看到了熟悉的ifnull 指令,因此我们可以很快地将字节码还原:

代码语言:javascript复制
  fun register(callback: Callback?) {
    if (callback!=null){
      callback.on()
    }
  }

由此可见:kotlinc 编译器在编译过程中将操作符?. 完完全全地替换成if (callback != null) {} 代码块。这和我们手写的Java 代码在字节码层面毫无差别。

编译/反编译`GroovySample.groovy`
代码语言:javascript复制
# $ groovyc GroovySample.groovy
# $ javap -c GroovySample.class

Compiled from "GroovySample.groovy"
public class GroovySample implements groovy.lang.GroovyObject {

  public void register(GroovySample$Callback);
    Code:
       0: invokestatic  #19                 // Method $getCallSiteArray:()[Lorg/codehaus/groovy/runtime/callsite/CallSite;
       3: astore_2
       4: aload_2
       5: ldc           #32                 // int 0
       7: aaload
       8: aload_1
       9: invokeinterface #38,  2           // InterfaceMethod org/codehaus/groovy/runtime/callsite/CallSite.callSafe:(Ljava/lang/Object;)Ljava/lang/Object;
      14: pop
      15: return

    ......

}

需要注意的是,groovy 文件在编译过程中由编译器生成大量的不存在于源代码中的额外函数和变量,感兴趣的朋友可以自行阅读反编译后的字节码。此处为了方便理解,在不影响原有核心逻辑的条件下做出近似还原:

代码语言:javascript复制
public void register(GroovySample.Callback callback) {

    String[] strings = new String[1]
    strings[0] = 'on'

    CallSiteArray callSiteArray = new CallSiteArray(GroovySample.class, strings)
    CallSite[] array = callSiteArray.array

    array[0].callSafe(callback)
  }

其中CallSite 是一个接口,具体实现类是AbstractCallSite ,:

代码语言:javascript复制
public class AbstractCallSite implements CallSite {

    public final Object callSafe(Object receiver) throws Throwable {
        if (receiver == null)
            return null;

        return call(receiver);
    }

  ......

}

函数AbstractCallSite#call(Object) 之后是一个漫长的调用过程,这其中包括一系列重载函数的调用和对接口引用callback 的代理等,最终得益于Groovy 的元编程能力,在标准GroovyObject对象上获取meatClass ,最后使用反射调用接口引用的指定方法,即callback.on()

代码语言:javascript复制
callback.metaClass.invokeMethod(callback, 'on', null);
代码语言:javascript复制

那么回到文章的主题,在AbstractCallSite#call(Object) 函数中我们可以看到对receiver 参数也就是对callback 引用进行了非空判断,因此我们可以肯定的是:操作符?. 在Groovy 和Kotlin 中的原理是基本相同的。

因此可以得出结论:编译器将?. 操作符编译成亦或在框架内部调用与if (callback != null) {} 等同效果的代码片段。Java,Kotlin 和Groovy 在字节码层面使用了相同方式的非空判断

为Java 添加' ?. ' 操作符

事情变得简单起来,我们只需要给Java 添加?. 操作符就行了。

其实,与其说为Java 添加?. 操作符不如说是通过一些小技巧达到相同的处理效果,毕竟改变javac 的编译方式成本较大。

面向接口的编程方式,使我们有天然的优势可以利用,而且动态代理也是基于接口的,因此我们可以对接口引进行动态代理并返回代理后的值,这样callback 实际指向了动态代理对象,在代理的内部我们使用反射调用callback 引用中的函数:

代码语言:javascript复制
  private void register(Callback callback) {
    callback = ProxyHandler.wrap(callback, Callback.class);

    ......

    callback.on();
  }


public static final class ProxyHandler {

  public static <T> T wrap(final T reference, Class<? extends T> interfacee) {

    if (interfacee.isInterface()) {
      return (T) Proxy.newProxyInstance(interfacee.getClassLoader(), new Class[] { interfacee },
          new InvocationHandler() {
            @Override public Object invoke(Object proxy, Method method, Object[] args)
                throws Throwable {
              if (reference == null) return null;
              return method.invoke(reference, args);
            }
          });
    }
    return reference;
  }
}

通过这样的一层代理关系,我们可以安全使用callback 引用上的任何函数,而不必关心空指针的发生。也就是说,我们在Java 上通过使用动态代理加反射的方式,构造出了一个约等于?. 操作符的效果

集成Android gradle plugin (AGP)

我们发现每次使用前都需要手动添加代理关系实在麻烦,能否像javac 或者kotlinc 那样在编译过程或者构建过程中使用自动化的方式代替手动添加呢?

答案是肯定的:在构建过程中修改字节码!

首先,我们找一段简单的java 代码:

代码语言:javascript复制
public class JavaSample {

  public Callback callback;

  public void doOperation() {

    //Called when progress is updated
    callback.onProgress(99);
  }

  interface Callback {
    void onProgress(int progress);
  }
}

编译/反编译JavaSample.java

代码语言:javascript复制
# $ javac JavaSample.java
# $ javap -c JavaSample.class

public class JavaSample {
  public JavaSample$Callback callback;

  public void doOperation();
    Code:
       0: aload_0
       1: getfield      #2                  // Field callback:LJavaSample$Callback;
       4: bipush        99
       6: invokeinterface #3,  2            // InterfaceMethod JavaSample$Callback.onProgress:(I)V
      11: return
}

然后,通过观察字节码指令,我们知道调用Java 接口中声明的方法使用的是invokeinterface 指令,因此我们只需要找到函数体中invokeinterface 指令所在位置,对其进行就修改即可。本项目所采取的思路是将invokeinterface 替换成invokestatic 并调用根据接口函数调用信息所生成的静态函数static void buoy$onProgress(JavaSample$Callback, int);

代码语言:javascript复制
  public void doOperation();
    Code:
       0: aload_0
       1: getfield      #19                 // Field callback:LJavaSample$Callback;
       4: bipush        99
       6: invokestatic  #23                 // Method buoy$onProgress:(LJavaSample$Callback;I)V
       9: return

  static void buoy$onProgress(JavaSample$Callback, int);
    Code:
       0: aload_0
       1: ldc           #25                 // String JavaSample$Callback
       3: ldc           #27                 // String JavaSample$Callback.onProgress:(int)void
       5: invokestatic  #33                 // Method com/smartdengg/interfacebuoy/compiler/InterfaceBuoy.proxy:(Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Object;
       8: iload_1
       9: invokeinterface #37,  2           // InterfaceMethod JavaSample$Callback.onProgress:(I)V
      14: return

值得一提的是:源码级别中我们无法在非静态内部类中创建静态函数,但是在字节码中这是允许的

下面我们将JavaSample.class 还原:

代码语言:javascript复制
public class JavaSample {
  public Callback callback;

  public void doOperation() {
    buoy$onProgress(this.callback, 99);
  }

  @Buoy
  static void buoy$onProgress(JavaSample.Callback var0, int var1) {
    ((JavaSample.Callback)InterfaceBuoy.proxy(var0, "JavaSample$Callback", "JavaSample$Callback.onProgress:(int)void")).onProgress(var1);
  }

  interface Callback {
    void onProgress(int var1);
  }
}

其中:

  • @Buoy 注解表示该函数用户保护接口引用的安全使用。
  • InterfaceBuoy 类则用于创建接口引用的动态代理对象。

这里需要说明一下,我并没有在生成的静态函数中直接对接口引用进行非空判断,而是交给了源码级别的InterfaceBuoy 类,我给出的理由是:字节码织入应该尽可能的简单,更复杂的操作应该交给源码级别的类,这不仅可以防止调用栈的过度污染,从而降低调试成本,而且源代码比字节码更容易编写,出现问题的几率会更小,因为我们不会比编译器更了解字节码!

最后,通过ASM 修改字节码并集成到AGP 中,使其成为Android 构建过程的一部分,我们做到了 : )

总结&讨论

通篇下来,其实我们并没有修改javac ,我们不能也不应该去修改这些编译工具,我们使用Java 平台所提供的动态代理与反射就完成了类似?. 操作符的功能。

可能有人会说反射很慢,加上动态代理后会变得更慢,我倒是认为这种观点是缺乏说服力的,因为在这个级别上担心性能问题是不明智的,除非能够分析表明这种方式正是造成性能损耗的源头,否则在没有统一衡量标准的前提下,盲目反对反射和动态代理的观点是站不稳脚的。

为了安全使用定义在接口中的函数,我做了这个小工具,目前已经开源,所有代码都可以通过github 获取,希望这个避免空指针的“接口救生圈”能够让你在Java 的海洋中尽情遨游。

~~原文完~~

0 人点赞