在Java 中安全使用介面引用

Photo by Joseph Maxim Reskp on Unsplash
我使用Java 開發過很多專案,這其中包括一些Web 應用和Android 客戶端應用。作為Android 開發人員,Java 就像我們的母語一樣,但Android 世界是多元化的,並不是只有Java 才能用來寫Android 程式,Kotlin 和Groovy 同樣優秀,並且有著大量的粉絲。我在過去的一年中嘗試學習並使用它們,它們的語法糖讓我愛不釋手,我尤其對 ?.
操作符感到驚訝,它讓我寫更少的程式碼,就能夠避免空指標異常(NPE)。可惜的是Java 中並沒有提供這種操作符,所以本文就和大家聊聊如何在Java 中構造出同樣的效果。
由於原始碼分析與呼叫原理不屬於本文的範疇,只提供解讀思路,所以本文不涉及詳細的原始碼解讀,僅點到為止。本文所涉及的專案已經開源: interface-buoy 。
介面隔離原則
軟體程式設計中始終都有一些好的程式設計規範值得我們的學習:如果你在一個多人協作的團隊工作,那麼模組之間的關係就應該建立在介面上,這是降低耦合的最佳方式;如果你是一個SDK 的提供者,暴露給客戶端的始終應該是介面,而不是某個具體實現類。
在Android 開發中我們經常會持有介面的引用,或者註冊事件的監聽,諸如系統服務的通知,點選事件的回撥等,雖不勝列舉,但大部分監聽都需要我們去實現一個介面,因此我們今天就拿註冊一個回撥監聽舉例:
private Callback callback; public void registerXXXX(Callback callback) { this.callback = callback; } ...... public interface Callback { void onXXXX(); }
當事件真正發生的時候呼叫 callback
介面中的相應函式:
...... if (callback != null) { callback.onXXXX(); }
這看起來並沒有什麼問題,因為我們平時就是這樣書寫程式碼的,因此我們的專案中存在大量的對介面引用的非空判斷,即使有引數型註解 @NonNull
的標記,但仍無法阻止外部傳入一個 null
物件。
說實話,我需要的無非就是當介面引用為空的時候,不進行任何的函式呼叫,然而我們卻需要在每一行程式碼之上強行新增醜陋的非空判斷,這讓我的程式碼看起來失去了信任,變得極其不可靠,而且繁瑣的非空判斷讓我感到十分疲憊 : (
使用操作符 ' ?. '
Kotlin 和Groovy 似乎意識到了上述尷尬,因此加入了非常實用的操作符:
?.
操作符只有物件引用不為空時才會分派呼叫
我們接下來分別拿Kotlin 和Groovy 舉例:
在Kotlin 中使用 ' ?. ' :
fun register(callback: Callback?) { ...... callback?.on() } interface Callback { fun on() }
在Groovy 中使用 ' ?. ' :
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
:
# $ 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: ifnull13 5: invokeinterface #13,1// InterfaceMethod KotlinTest$Callback.on:()V 10: goto14 13: pop 14: return ...... }
通過分析 register()
函式體中的所有JVM 指令,我們看到了熟悉的 ifnull 指令,因此我們可以很快地將程式碼還原:
fun register(callback: Callback?) { if (callback!=null){ callback.on() } }
kotlinc 編譯器在編譯過程中將操作符 ?.
完完全全地替換成 if (callback != null) {}
程式碼塊 。這和我們手寫的Java 程式碼在位元組碼層面毫無差別。
編譯/反編譯 GroovyTest.groovy
# $ 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
檔案在編譯過程中由編譯器生成大量的不存在於原始碼中的額外函式和變數,感興趣的朋友可以自行閱讀反編譯後的 位元組碼 。此處為了方便理解,在不影響原有核心邏輯的條件下做出近似還原:
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
,:
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()
:
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
引用中的對應函式:
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 的海洋中盡情遨遊。
歡迎討論或在評論區留下您寶貴的建議。