在Java 中安全使用接口引用

2019-03-05 15:24:20 浏览数 (1)

Photo by Joseph Maxim Reskp on Unsplash

我使用Java 开发过很多项目,这其中包括一些Web 应用和Android 客户端应用。作为Android 开发人员,Java 就像我们的母语一样,但Android 世界是多元化的,并不是只有Java 才能用来写Android 程序,Kotlin 和Groovy 同样优秀,并且有着大量的粉丝。我在过去的一年中尝试学习并使用它们,它们的语法糖让我爱不释手,我尤其对?. 操作符感到惊讶,它让我写更少的代码,就能够避免空指针异常(NPE)。可惜的是Java 中并没有提供这种操作符,所以本文就和大家聊聊如何在Java 中构造出同样的效果。

由于源码分析与调用原理不属于本文的范畴,只提供解读思路,所以本文不涉及详细的源码解读,仅点到为止。本文所涉及的项目已经开源:interface-buoy

接口隔离原则

软件编程中始终都有一些好的编程规范值得我们的学习:如果你在一个多人协作的团队工作,那么模块之间的关系就应该建立在接口上,这是降低耦合的最佳方式;如果你是一个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 指令进行反汇编。

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

Compiled from "KotlinTest.kt"
public final class KotlinTest {
  public final void register(KotlinTest$Callback);
    Code:
       0: aload_1
       1: dup
       2: ifnull        13
       5: invokeinterface #13,  1           // InterfaceMethod KotlinTest$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 代码在字节码层面毫无差别。

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

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

  public void register(GroovyTest$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(GroovyTest.Callback callback) {

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

    CallSiteArray callSiteArray = new CallSiteArray(GroovyTest.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);

那么回到文章的主题,在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.on();
  }


public static final class ProxyHandler {

  public static <T> T wrap(final T reference) {
    Class<?> clazz = reference.getClass();

    if (clazz.isInterface()) {
      return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class[] { clazz },
          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 接口中声明的方法使用的是invokeinterface 指令,因此我们只需要找到函数体中invokeinterface 指令所在的位置,在前面添加对接口引用的动态代理并返回代理结果的相关字节码操作。

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

总结&讨论

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

可能有人会说反射很慢,套用动态代理后会变得更慢,我倒是认为这种观点是缺乏说服力的,因为在这个级别上担心性能问题是不明智的,除非能够分析表明这个方法正是造成性能损失的源头,否则在没有任何衡量标准的前提下,固执地断定反射和动态代理很慢的观点是站不稳脚的。

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

欢迎讨论或在评论区留下您宝贵的建议。

0 人点赞