2020年7月12日 星期日

[筆記] API 設計概念 — 下篇

  下篇整理演講後半的內容,主要更進一步探討類別跟函式的設計概念。

類別設計


減少易變性(mutability)

  • 除非有好理由,不然類別應該要 immutable:好處是單純、thread-safe、可重複使用;缺點是每個不同的值都是獨立的物件,也就是為了改一個值,就要放棄現在的物件取得新的物件。
  以 python 為例,int 是 immutable 的,所以使用 '1' 的值,它只要產生一次對應的物件,後續只要使用到 '1' 的值,就使用同一個物件即可。
>>> id(1)
10914496
>>> x = 1
>>> id(x)
10914496
  • 如果類別是 mutable,要使類別的狀態空間(state space)小且清楚定義:寫清楚在甚麼狀態下可以呼叫哪些函式。
  如前面舉過的例子,通訊類別在連線關閉的時候,就不能呼叫 recvsend

子類別要合理

  • 如果 class A 繼承自 class B,那是否能回答 class A is a class B(is-a relationship),如果不行那就用複合(composition,has-a relationship)
  • 不要為了實作方便而讓 public 類別繼承另一個 public 類別
  Stack 不應該繼承自 Vector,因為 Stack 不會有 Vector 有的如 inserterase 的函式,另外使用者也可以使用 Vector 的函式來對 Stack 操作,所以 Stack is not a Vector。而是使用 Vector 來做儲存容器,只提供操作 Stack 有關的函式,如:pushpop,也就是 Stack has a Vector。

為繼承設計與撰寫文件,否則禁止繼承

  • 繼承會違反封裝原則。繼承類別對被繼承的類別的實作細節敏感,一旦被繼承的類別有改動,就會影響繼承類別
  • 讓不是設計用來繼承的類別完整(不要留有需要被實作的地方)

函式設計


不要讓使用者做任何模組可以作到的事情

  • 減少樣板程式碼。樣板程式碼通常用剪貼就可以達成,又醜又煩而且容易出錯,應該整理起來
  在 MLGame 中需要遊戲開發者設置遊戲啟動方式,原本需要呼叫大量函式,而且每個遊戲幾乎一樣,只是參數不同,後來就改成直接提供一個 dict 存放需要的參數,由 MLGame 讀取並幫助配置。改善前樣子:
def ml_mode(config: GameConfig):
    try:
        difficulty, level = _get_difficulty_and_level(config.game_params)
    except GameParameterError as e:
        print("Error: " + str(e) + "\n" + usage())
        return

    from mlgame.process import ProcessManager

    process_manager = ProcessManager()
    process_manager.set_game_process(_start_game_process, \
        args = (config.fps, difficulty, level, \
        config.record_progress, config.one_shot_mode))
    process_manager.add_ml_process(config.input_modules[0], "ml")

    process_manager.start()
改善後,使用者只需設置像 config 的內容,除了簡單明瞭,也減少出錯的機會:
GAME_PARAMS = {
    "()": {
        "prog": "arkanoid",
        "game_usage": "%(prog)s <difficulty> <level>"
    },
    "difficulty": {
        "choices": ("EASY", "NORMAL"),
        "metavar": "difficulty",
        "help": "Specify the game style. Choices: %(choices)s"
    },
    "level": {
        "type": int,
        "help": "Specify the level map"
    }
}

from .game.arkanoid import Arkanoid

GAME_SETUP = {
    "game": Arkanoid,
    "ml_clients": [
        { "name": "ml" }
    ]
}

不要違反最小驚訝原則

  • 使用者不能被 API 行為嚇到。例如:某函式順手做了多餘的事情。
  • 寧可多一點額外的實作功夫,就算降低效能也值得

Fail Fast

  • 當出錯時,越早報錯越好。
  • 能在 compile time 報錯最好,如果是在 runtime,第一個錯誤的函式調用發生時報錯最好
  例如:在初始化類別時就檢查參數有沒有錯,而不是在使用 save 函式後才報錯。

為所有可以以字串形式取得的資料,提供程式化的存取方式

  • 否則使用者只能解析回傳的字串,也會讓這個字串形式變成 API 的一部分,讓你不能更動回傳的資料格式

小心地 overloading

  • 避免曖昧的 overloading:傳相同的物件(透過 casting)傳給不同的 overloading 函式要有相同的效果
  • 即使可以用 overloading,也不代表你應該要用,最好是給予不同的名稱

函式引數與回傳值使用合適的型別

  • 引數傾向使用介面(interface)而非類別:會更有彈性,效能也較好
  • 引數使用最可能的型別(縮小可能性),使程式能夠在編譯時期報錯
  • 如果有更好的型別,就不要用字串型別:Boolean vs "yes/no"
  • 不要使用浮點數作為貨幣數值:浮點數無法精確表示 0.1
  • 使用雙精度浮點數(64bit double)比較精確

使用一致的引數排序

  • 尤其是當引數的型別一樣的時候:如果放的排序不同,出錯也很難找到
  • 同樣類型的引數,在函式之間應該要有同樣的位置
  例如 C 語言中的
char* strcpy(char* dest, const char* src)
void bcopy(const void *src, void *dest, size_t n)
兩者 destsrc 的順序相反,如果照著 strcpy 的引數順序放到 bcopy,變成 bcopy(dest, src),結果就是 dest 沒有改變,src 變成 dest(可能是一堆垃圾值)。不過後來 bcopy 就被廢棄了,被 void *memcpy(void *dest, const void *src, size_t n) 取代。

避免過多的引數

  • 理想是兩到三個引數,更多的引數就必須靠文件才能使用了
  • 如果一個函式引數過多,則可以拆解函式或是建立幫助類別來包含這些引數,因為大部分的引數應該會有預設值,最後再傳入這個幫助類別的物件到函式中

避免回傳需要特別處理的值

  • 像一個回傳陣列的函式,就不要回傳 null,而是空陣列


演講的整理就到這邊結束,感受到寫出好的程式碼真的是需要花心力的,如果有問題的話歡迎討論。

沒有留言:

張貼留言