最近在家里修習 Java 這項技能,估計快點滿技能點兒了,很開心。不過遇到了一個問題,困擾了我一陣子。問題是這樣的,我要寫 Android App,與服務器交互。大家都知道 Javascript 不知為什么占領了瀏覽器腳本的全部份額,緊接著 Json 成為了 Web 服務器與瀏覽器傳輸數據的事實標準。大家也知道 Java 不知為什么成為了 Android App 開發的推薦做法,而 Java 聽上去就有著濃濃的“企業級”味道,自有一個序列化和一大堆 RPC 標準。Json 跟 Java 聽上去就格格不入,但我要把 Java 對象變成 Json 傳出去,再收到 Json 變回 Java 對象!
先展示一下我的工具叭:我使用 android.util.JsonWriter
和 android.util.JsonReader
,它們是做什么的呢?簡單說,就是把 Json 字符串解析成 AST。比如我們可以用 reader.beginObject()
, reader.nextName()
, reader.nextInt()
去讀取 Json 字符串里的左大括號,屬性名和整型值,而不必考慮中間有幾個空格,整數怎么一位一位讀這些細節。或者說這個就是可以造出來的最低級的工具了。值得注意的是,我只會向同一個方向讀寫。
然后我先不想太多,先開始寫代碼。數組很好處理,讀的時候先 beginArray()
讀左中括號,然后在 hasNext()
返回 true
的時候用 nextXxx()
讀下一個值(Xxx
可能是 Double
, Int
, Long
, String
或 Null
),如果返回 false
了,就調用 endArray()
讀右中括號,保證狀態適合接著讀東西。至于讀進來的東西,隨便找個 Collection
扔進去就好。寫數組就更好寫了,先 beginArray()
寫左中括號,然后每次用 value(val)
寫值,最后 endArray()
寫右中括號就好了。
對象有點麻煩但也不難處理,讀的時候先 beginObject()
讀左大括號,然后在 hasNext()
返回 true
的時候用 nextName()
讀下一個屬性名,然后用 switch ... case ...
判斷這個屬性應該讀進哪個變量,并用 nextXxx()
把值讀進這個變量,最后不要忘記 default
情況要 skipValue()
,不然下面操作讀取器的狀態就不對了,直到 hasNext()
返回 false
,用 endObject()
讀右大括號。寫的話比較好辦,就先 beginObject()
寫左大括號,然后不停地 name(name)
value(val)
寫屬性,完了 endObject()
寫右大括號。一個對象有一個接收 JsonReader
的構造函數和一個接收 JsonWriter
的方法來做這一套過程。
啊聽起來非常好,不過麻煩事兒是有繼承。有繼承后有兩個問題,一是繼承的對象 Json 數據該長成什么樣子,二是這個數據該怎么解析。對問題一我思考一段時間(腦殘兒童 QAQ)決定答案是 Json 的結構應該和 Java 保持一致,或者說就是如果 Java 里用了繼承,那么新增的屬性應該和原來的屬性放在同一個對象里,如果 Java 用了委托,新增的屬性就應該放在一個新的對象里作為原來對象的屬性。對問題二我又思考了一段時間(TAT 腦殘兒童不許笑)發現所謂 Json 就是一個序列化機制罷了。那看看前輩是怎么寫序列化的,于是我拿來了 Parcelable
,發現它們的做法是先寫祖宗的屬性,然后寫自己的屬性,讀的時侯先在構造函數里調祖宗的構造函數讀祖宗的屬性,然后讀自己的屬性。我試圖把這個辦法搬到 Json 上。果然碰到問題了:祖宗構造函數和自己構造函數都要讀大括號,然而我沒有帶那么多大括號可以讀。不過我很快想到了解決辦法:構造函數永遠永遠不讀大括號,把大括號留給調用構造函數的人讀,這樣就好啦(嗯大括號熟么的在這種不需要數據結構自描述的情況下就是冗余米有錯)。可是這樣子以后我會被人抽的,因為 Json 對象不保證順序,要是逼著寫 Javascript 的在 Json 化時候必須保證順序的話我相信它會毫不猶豫去跳樓的。
于是我想到了一個辦法,就是給基類寫一個接口,里面有一個方法。當基類讀 Json 讀到一個屬性自己不認識的時候,就把屬性名和 JsonReader
對象傳給這個方法。這樣,子類只要寫一個內部類實現這個接口,在方法中看看這個屬性認識不認識,認識就讀進來,不認識就遞歸地給自己的子類的這個接口處理。
不過這么做還是有問題。問題是基類的部分控制代碼每次寫個基類都得寫一遍,得抽出來。然后我又想了好長時間,發現其實根本問題是我有一堆屬性,我得給每個屬性在繼承樹上找到它應該在的位置。這樣說來,每次讀取屬性名的操作就不應該由基類來做了,應該由外面的函數來做。這些類們只實現一個方法 readProperty
:接收一個屬性名和一個 JsonReader
,要是自己認得,就讀出屬性值,否則把屬性名和 JsonReader
照樣傳給父類(如果有的話)或跳過值(如果父類沒有 readProperty
)。然后再寫一個工廠方法叫 newInstanceFromJson
,接收 JsonReader
返回新對象,它每次新建一個該對象的空白實例,不停地讀屬性名并喂給接收屬性名那個方法,直到所有屬性都讀完了,這時看看對象需要知道的屬性是不是都讀進來了,要是沒有就返回空或報異常,要是都讀進來了就返回這個實例。考慮到上一篇文章所述和方便重用,我把工廠方法寫在一個內部類里,并要求它們繼承自 JsonCreator
。JsonCreator
是個類,里面寫著上述工廠方法的代碼,并要求繼承者實現 newBlankInstance
和 isComplete
,前者是返回空白實例的方法,后者是判斷是否讀了所有要知道的屬性的方法。readProperty
不寫在 JsonCreator
里的原因是否則需要實現者手工維護 this
指針。然后我寫一個接口 Jsonable
,要求實現者實現 writeToJson
和 readProperty
方法。接口定義中包含一個內部類叫 JsonCreator
的定義,然后在文檔中要求所有實現 Jsonable
的類都要定義一個靜態成員叫 JsonCREATOR
,它是 JsonCreator
的一個子類,其中實現了 newBlankInstance
。
嗯其實我知道 Gson 可以很隨意地把這件事做掉,但是我沒有用 Gson,原因有兩個。一個是我喜歡造輪子,另外一個是 Gson 這種東西一定會用一些反射,我不想用反射,我想只用語言的靜態特性(比如找父類不用反射,用 super
)。
但是這樣子又有點兒問題,每一個 readProperty
里其實是一個拿字符串做的 switch...case...
語句,而 Java 里它的實現可以認為是查一次哈希表。這樣一個屬性如果一個類不認識,那么它就會去問它父類,父類要再不認識就會問父類的父類……這樣問下去可能一直要查掉整個繼承鏈的長度,好慢的感覺。
不過這個問題是沒辦法的事兒,我們可以把它抽象一下:有 N 個類,它們組成一個有根森林(邊代表繼承關系);有 M 個屬性,每個屬性可以被某些類擁有;現在每次給一個類和一個屬性,查詢從該類到根的路徑上,距該類最近的有該屬性的類。這個問題還有兩個版本,就是可不可以動態添加類和屬性,動態語言對應著可以動態添加類和屬性的版本,靜態語言則相反。這個問題里預處理對應著編譯時,而查詢處理對應著運行時。
暴力的方法有兩個。一是給每個節點存它擁有的屬性,然后每次查詢從某個類開始逐個查父節點一直到根,預處理復雜度 O(M + N),查詢復雜度 O(N) (最壞情況深度等于類的個數,并且認為判斷表里是否有一個屬性是 O(1) 的)。另一個是給每個節點存一個表,里面存著它擁有每個屬性的最近祖先是誰,查詢時直接查表即可,預處理復雜度 O(MN),查詢復雜度 O(1)。而且前一個方法動態添加刪除類和屬性效率較高。
OI 里很流行 log 記號,帶 log 的算法我只想了一個。先考慮每個屬性只被一個類擁有的情況,這時我們可以預處理每個類的深度和它的 1, 2, 4, 8, ... 輩祖宗是誰,這樣可以在 O(log N) 時間內找到它的 N 輩祖宗。查詢時我們找到這個類,找到擁有這個屬性的那個類,如果“這個類”比“那個類”深度更淺,那就找不到,否則求它們的深度差,找“這個類”的“深度差”輩祖宗,看是不是“那個類”。然后考慮每個屬性被多個類擁有的情況,先找到這個類,然后找到擁有這個屬性的 DFS 序恰小于這個類的那個類。然后把“那個類”到根路徑上所有有這個屬性的類做成一個表,在這個表里找到深度最深的是“這個類”的祖宗的類。尋找可以用二分的辦法,因為如果一個類是“這個類”的祖宗,那么它的祖宗也是“這個類”的祖宗。二分法做起來可以這么辦:預處理出“那個類”到根的路徑里距它 1, 2, 4, 8, ... 近的有該屬性的類,然后就可以折半了。二分后的判斷就用之前簡單情況的辦法,這樣整個方法,預處理的復雜度是 O((N + M) log N),查詢的復雜度是 O(log N * log M)。不過這個方法不支持動態添加刪除類和屬性,要支持的的話應該要動態樹什么的我不會 T_T。