Python API 設計(1):關於 OORedis 中的類繼承

嘿,讓我們換種方式


當我剛開始關注API設計的時候,我決定先找一些相關的資料來看,比如博客日誌、PPT還有書,這方面的資料很少,而且最後我發現他們很多都只是單調地列舉一些有用的規則,並沒有仔細地展開討論,這些規則可能是有用的,但讀起來讓人感覺相當乏味,所以我決定自己來寫一篇(可能是幾篇)關於API設計的文章。


於是我列了一個提綱,把我認爲重要的設計原則記錄下來,然後對着每條要點準備虛構一個聲色俱全的故事,然後我發現我自己的文章變成了之前我看過的八股文格式。。。


於是我決定換種方式,拿之前寫的ooredis項目作爲引子,來談談Python API設計方面的事情,有時候我也引用一些Python方面著名的項目比如Django來說事兒,但大多數時候,這篇文章看上去更像是“ooredis開發記事”。


文章裏所說的都是在寫ooredis時真實遇到的問題,我想這樣比起總結兩條基本原則再虛構一些例子強多了,當然我在這方面的經驗也不多,主要是就這個話題拋磚引肉一下,希望大家注意到API設計的重要性。

 

 

緣起

 

大概在七月份的時候,我譯完了Redis的命令參考前幾章,那時候我剛開始學習Redis不久,當時用的是redis-py庫,這個庫是面向過程的,只是Redis命令的簡單包裝,比如一個HSET命令,在Redis裏是:

 

 

hset key field value
 


而在redis-py裏則是:

 

 

from redis import Redis

client = Redis()
client.hset(key, field, value)
 


這樣的庫有幾個問題:


第一,大量的命令聚在在一起,污染了客戶端的命名空間。


如果你用dir(Redis())查看redis-py的對象,你會發現數十個方法聚集在了這個客戶端對象裏面,用眼睛檢索這種對象的方法實在是太累人了,很難在命令行中使用這個庫。


第二,因爲redis-py只有一個對象,所有命令都是通過給方法傳不同的參數來執行的。


這樣的問題就是你很可能在執行命令的時候犯錯。


比如你想執行一系列hset命令,來保存個人信息,你執行

 

 

client.hset('person', 'name', 'peter')
client.hset('person', 'age', 25)
client.hset('perso', 'phone', 10086)

 


但是後來你卻發現'person'哈希表裏面沒有'phone'這個域,你仔細看了看,發現原來前面的命令最後一行,你錯誤地將'person'寫成了'perso',你將'phone'保存到了'perso'哈希表裏,噢。


如果有一個對象實例作爲句柄,綁定'person'作爲對象的參數,你是絕對不會犯這樣的錯誤的。


第三,面向過程式的庫沒有利用Python語言的機制。


redis-py單純的方法調用方式沒有利用到Python語言的機制,比如迭代器、字典方法,各類魔法函數,等等,這使得redis-py用起來很不Pythonic。


最後還有缺乏一種方便的類型轉換機制(redis中只保存字符串值),以及跨類型之間覆蓋而不報錯等(試試對一個list結構執行set命令看看)。


爲了解決redis-py的以上問題,我決定在redis-py之上寫一個Redis的庫,稱爲ooredis,它將是面向對象的、Pythonic的,而且,因爲這個庫是一個通用庫,我希望ooredis能被更多人使用,所以它必須寫得比較標準,看上去比較專業——最起碼,沒有什麼特別大的問題,最好有天成爲和redis-py一樣被Pythoner廣爲使用的庫(現在ooredis還遠遠沒有達到這個目標,唉。。。不過這不太妨礙我們的討論,大概。。。)。


(平心而論,這樣評論redis-py並不是完全正確的,作爲一個底層客戶端,redis-py已經提供了相當充實的功能,爲在其上構造更高層次做好了準備。當然redis-py也還是有一些小問題,後面我會說到。)

 


what's under the box?

 

計算機程序很少(或者說,不可能)是全新地被編寫出來的,很多時候,我們只是在一個低層抽象之上寫一個更高層的抽象層,用高層抽象包裹低層抽象,併爲新層次提供一簇新API,好讓這個新層次作爲基石,繼續構建更高層的抽象:就像硬件包裹電路,操作系統包裹硬件指令,編譯器用C語言寫出來,然後又作爲其他語言(比如Python)的基石一樣。


ooredis也一樣,不同的是,它的目標不是構建一門新語言那樣的高科技,而只是包裹一個Redis客戶端而已,不過它們的道理是相同的——要在一個層次之上構建更高層次,你必須先了解(最起碼是部分了解)現有的層次,這樣才能寫出好程序,於是我扎進redis-py和Redis命令參考裏面,思考着該如何設計ooredis的類。


第一個跳出腦海的方式就是按照Redis的各類函數來分類(這裏我們只考慮Redis的數據結構類命令,忽略事務、Pub/Sub等命令),用一個類包裹一簇命令,比如用BaseKey類包裹Redis的keys類函數,用String類包裹Redis的strings類函數,以此類推:

 

 

class BaseKey:
    pass

class String:
    pass

# 其他類...
 


但是這一完全直覺化的分類並不是完全正確的,比如keys類的expire、ttl、exists等命令,是Redis所有數據結構所共有的,而keys類的sort方法,則是除string結構和hash結構之外,list、set、sorted set纔有的,於是我稍稍更改了一下類的設計:

 

 

class BaseKey:
    # 除了sort之外,所有Redis的Key類命令
    pass

class SortableKey(BaseKey):
    def sort():
        pass

# 沒有sort方法的類
class String(BaseKey):
    pass

class Hash(BaseKey):
    pass

# 有sort方法的類
class List(SortableKey):
    pass

class Set(SortableKey):
    pass

class SortedSet(SortableKey):
    pass

 


OK,一切順利,似乎沒有什麼難的,於是我開始爲各個類寫相應的方法。


不過很快,我發現,有一種更好的類定義方式,比現在的類定義方式更好,於是我開始修改程序,但這一次,事情就沒有那麼容易了。。。

 


是一個(is a)和有一個(has a)


就在ooredis第一版中,我將Redis的keys類命令分爲了兩個類,一個BaseKey類,另一個SortableKey,然後其他數據結構如String、Hash等類繼承BaseKey或SortableKey,但是仔細思考一下,就會發現這種類設計並不太正確。


拿BaseKey和SortableKey來說,你會發現其實SortableKey相比BaseKey這個類來說,我們只是想爲支持sort方法的數據結構如Hash類提供sort方法而已,這個繼承並不合理。


再往後面推一步,BaseKey和SortableKey,對Hash和String這些數據結構類來說,它們其實不是一個“父類”,它們只是一簇方法,我們其實不想要BaseKey和SortableKey,而只是想要一種在數據結構類裏重用keys類函數的方法。


用專業點的術語來說,Redis中的string數據結構和keys類命令在ooredis中應該是“有一個(has a)”而不是“是一個(is a)”關係——我需要有一種可以組合使用各個方法的機制。


這個問題其實是相當直觀的,但是很遺憾Python似乎沒有提供這樣的機制,也即是,簡單快捷地重用方法的唯一方式,就是抽取出這個方法,比如sort方法,然後給他弄一個SortableKey類,所有要用sort方法的類就繼承SortableKey方法,就是這樣。


認識到這一事實讓我有點難過,不過也只是一點點,“有一個”和“是一個”關係的差別聽上去這似乎只是某種理論問題,畢竟多繼承一兩個類其實關係不大,馬照跑,舞照跳——咱們可是實用主義者。說實在的,如果以前有人想跟我討論這類問題的話,我會跟他說別鬧了,拿着你的《JAVA變成死相》離我遠點。

 

 

Queue、Stack和Dequeue

 

於是我繼續前進,很快就把String類的幾個方法搞定了,然後我開始寫List類——用來包裹Redis的list數據結構,然後我發現我的老朋友——“是一個和有一個”問題,又攔住了我的去路。


先來分析一下Redis的list數據結構,它是一個雙端隊列,也即是,push和pop可以在隊列的兩邊進行,包裹這個數據結構的一蹴而就的方式自然就是用一個List類,將所有list結構的相關命令“裝”進去,這種方法簡單明瞭,也沒有什麼大錯。


但是我不想這麼幹,因爲我覺得list結構按操作還可以細分爲好幾個類,像棧(stack,LIFO)、隊列(queue,FIFO)和雙端隊列(dequeue),這些數據結構只有輕微差別,但是實際應用中相當有用,如果我只寫一個雙端隊列的話,想用棧或者隊列的人就得自力更生了,我不是一個自私的人,而且爲了ooredis將來的蓬勃發展(這一景願至今仍未實現),多寫幾行代碼也沒啥的,於是我決定將原本的List一分爲三:

 

 

class Dequeue:
    # 提供表頭和表尾兩邊的push和pop
    pass

class Stack:
    # 只提供表尾一邊的push和pop
    pass

class Queue:
    # 只提供表尾的push和表頭的pop
    pass

 

 

很明顯,這些三個類裏面有一些共有的方法,比如獲取列表長度的llen命令,以及讀取列表項的lrange命令,但也有一些命令是某個類中獨有的,比如Stack類就應該只有lpush和lpop(或者rpush和rpop),Queue應該只有lpush和rpop(或者rpush和lpop),而Dequeue則四個方法都可以有。


按照老方法,我們可以用一個GenericQueueProperty之類的類,將列表的通用方法裝進去,然後Stack加上lpush和lpop,給Queue加上lpush和rpop,然後Dequeue繼承Stack和Queue(只爲重用方法)。


最終,我們類成了一團糟:

 

 

class BaseKey:
    pass

class SortableKey(BaseKey):
    pass

class GenericQueueProperty(SortableKey):
    # 提供隊列的共有屬性和方法
    pass

class Stack(GenericQueueProperty):
    # 只提供表尾一邊的push和pop
    pass

class Queue(GenericQueueProperty):
    # 只提供表尾的push和表頭的pop
    pass

class Dequeue(Stack, Queue):
    # push和pop可以在兩邊進行
    pass
 

 

 

解決方法

 

上面Dequeue類的定義讓人感覺自己像是錯過了京東買100送100活動一樣難過,很自然地,你會問,是否有更好的辦法在Python中解決重用方法的問題?


有人推薦使用多繼承來解決方法重用的問題,這樣的話,Dequeue的定義將是這樣:

 

 

class SortableKey:
    # 提供sort方法,但不繼承BaseKey
    pass

class LeftSideOperation:
    # 提供lpush和lpop
    pass

class RightSideOperation:
    # 提供rpush和rpop
    pass

class Dequeue(BaseKey, SortableKey, GenericQueueProperty, LeftSideOperation, RightSideOperation):
    pass
 

 


這種方法的特點其實就是用繼承數量換繼承高度,其實複雜性是沒有變的,一棵高高的繼承樹和一串長長的繼承列表之間,我真的說不清楚它們到底那個好一些。


而且這種方法有一個很隱晦的危險性,考慮如果你在繼承列表中的類A中,定義了foo方法,但是在類B中,你又定義了一個foo方法,這樣的話,它們就會互相覆蓋,而在Python中這種覆蓋是沒有任何警告的,你繼承的類越多,就可能越出現這種問題,一但這種問題出現,你就要檢查所有繼承類,如果你只有一個基類,那你就回溯祖先鏈,看看是那個環節出了問題;如果你有兩個類,你的工作量就多了一倍;如果你有很多個基類。。。祝你好運!


Python標準庫提供了另外一種思路,就是使用鉤子方法:基類定義一些通用操作,比如push方法,push方法調用鉤子_push方法,而派生類則通過覆蓋_push方法,來提供不同的行爲,比如這樣:

 

 

class GenericQueue:
    def push():
        pass

    def _push():
        pass

    def pop():
        pass

    def _pop():
        pass

class Stack(GenericQueue):
    def _push():
        # lpush
        pass

    def _pop():
        # lpop
        pass

class Queue(GenericQueue):
    def _push():
        # lpush
        pass

    def _pop():
        # rpop
        pass
 

 

這種方法的問題是你要寫很多額外的鉤子方法,你必須小心處理,以免遺漏了哪一個或者不小心把_push寫成了push,諸如此類。


說實在的,這種方法相當醜陋。

 

 

one more time, one more chance

 

以上兩種方法都治標不治本,它們解決一些問題的同時也引入了一些更大的問題,究其原因,是因爲Python裏沒有一種好的方法來重用已有方法,繼承是重用方法的唯一簡單快捷的手段。


必須承認我寫ooredis時思維有點僵硬了,總是想着怎麼用Python解決這個問題,而沒有想到換一種語言來試試,比如在Ruby中,解決這個問題就相當簡單:

 

 

module BaseKey
    # 所有除了sort方法之外的keys類方法
end

module SortableKey
    def sort
    end
end

module GenericQueue
    # 隊列共有的屬性和方法
end

module LeftSideOperation:
    # lpush & lpop
end

module RightSideOperation:
    # rpop & rpush
end

class Dequeue
    include BaseKey
    include SortableKey
    include GenericQueue
    include LeftSideOperation
    include RightSideOperation
end

 

 

 

這個Dequeue沒有繼承任何類,因爲它本身已經是一個key,它和BaseKey、SortableKey等模塊的關係是有一個而不是是一個,這纔是正確的語義。(這個類混入的模塊有點多,通過混入 Enumerable 模塊,實際寫起來其實會更簡單。


我最終選擇了多繼承來實現ooredis,而且只用一個List類包裹所有的list數據結構命令,因爲redis的命令基本上是正交的,沒有相同的方法,所以多繼承的風險比較低,如果你的程序多態方法相當多,我強烈建議你不要隨便使用多繼承,一棵高高的繼承樹和一大串繼承列表之間,我寧願選擇前者。


用各種hack給編程語言“打補丁”是一條不歸路,如果當時能想到這個方法的話,ooredis就會是Ruby Gem而不是Python庫了。


當然現在的ooredis距離我的預想也不是太遠,它只是不太美而已,嗯。。。不太美。。。

 

 

待續


我有一個目標,就是讓那些不會用Redis的Python使用者不用學一條Redis命令,就能用我的ooredis。這可能嗎?如果可以的話,怎麼達到這一目標?


其次,爲什麼說好的API和好的程序一樣,都是重用爲主?創造和創新有什麼區別?


下一篇文章,我們就來談談關於一致性的思考,看看如何達到我們的目標——寫出不用學習就能輕鬆上手的API。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章