Designing Pythonic library APIs

Ben Hoyt

设计 Pythonic 风格的库 API

原文由 Ben Hoyt 发布,订阅该博客

摘要:本文介绍了一些我认为有助于设计优秀 Python 库 API 的原则,包括结构、命名、错误处理、类型注解等。这是 2023 年 6 月我在基督城 Python 聚会上一次演讲的文字版。

我们先来看一段代码。你觉得这段代码是做什么的?

manager = urllib.request.HTTPPasswordMgrWithDefaultRealm()
manager.add_password(None, 'https://httpbin.org/', 'usr', 'pwd')
handler = urllib.request.HTTPBasicAuthHandler(manager)
opener = urllib.request.build_opener(handler)
response = opener.open('https://httpbin.org/basic-auth/usr/pwd')
print(response.status)

正如你大概已经猜到的,它是向 httpbin.org 的一个 URL 发起带用户名和密码的 HTTP 请求。

urllib.request 是一个好用的 API 吗?

不,糟透了!你得去搞懂什么是“管理器(manager)”、什么是“处理器(handler)”、什么是“开启器(opener)”。里面还有两个超长的名字,HTTPPasswordMgrWithDefaultRealm(真够拗口的!)和 HTTPBasicAuthHandler

正是这类代码催生了 Requests 库,它诞生于 2011 年。事实上,如果你看过 Requests 的文档,你大概会知道这个例子就取自其文档顶部链接的对比示例(根据官方 how-to 更新而来)。

用 Requests API 来完成同样的 HTTP 调用是这样的:

response = requests.get('https://httpbin.org/basic-auth/usr/pwd',
                        auth=('usr', 'pwd'))
print(response.status_code)

这才叫好用的 API。

标准库的版本需要样板代码、多层类似 Java 的类,以及五行代码。而 Requests 的版本只需要一个带 auth 参数的简单函数调用。

用标准库其实还有一种稍微简单一点的做法,但你得绕过 urllib.request 的 Basic Authentication API,自己添加 Authorization 请求头:

request = urllib.request.Request('https://httpbin.org/basic-auth/usr/pwd')
encoded_auth = base64.b64encode(username.encode() + b':' + password.encode())
request.add_header('Authorization', b'Basic ' + encoded_auth)
response = urllib.request.urlopen(request)
print(response.status)

当底层 API 反而比号称为特定任务而设的高层 API 更好用时,就说明有些地方不对劲了。

易用性正是 Requests 在 2011 年脱颖而出的原因,也是它至今仍然受欢迎的原因。在本文中我会多次以 Requests 为例,而且几乎都是正面的例子。Python 内置的 HTTP 处理能力有所改进,但改进不大。

本文会给出好几个“要点”,但整体主题是:

要点:好的 API 设计对用户至关重要。

Requests 也并非一开始就完美无缺。我翻回了 Requests Git 仓库最早的提交,第一条真正有内容的提交如下(格式稍作调整):

import urllib2

class Request(object):
    """The :class:`Request` object. It's awesome."""

class Response(object):
    """The :class:`Request` object. It's awesome."""

class AuthObject(object):
    """The :class:`AuthObject` is a simple HTTP Authentication token. ..."""
    def __init__(self, username, password):
        self.username = username
        self.password = password

def get():    pass
def post():   pass
def put():    pass
def delete(): pass

作者 Kenneth Reitz 显然还有大量实现工作要做。但你已经可以看到基本 API 的轮廓:RequestResponse 对象,以及简单的 HTTP 方法函数。

在接下来几周密集的提交中,他实现了库的第一个版本并不断打磨 API。

要点:创建库时,先打好基础,再不断迭代。

在本文中,我们将探讨几条设计优秀 Python 库 API 的原则。但首先,我们来简单聊聊什么是 Pythonic 的 API。

“Pythonic”是什么意思?

这个问题有点棘手,因为不同的人对这个词的理解不同。我所说的“Pythonic”,是指在 Python 开发者中已逐渐成为主流的函数和类设计方式。

遵循官方的 Python 风格指南(PEP 8) 是个不错的起点。它涵盖了代码格式、导入语法、注释、命名以及各种编程建议。我认为每个写 Python 的人都应该阅读并遵循它,尤其是写库的人更应该如此。

然后是 “Python 之禅”。这是一组带有几分幽默、禅意色彩的格言,描述了好的 Python 代码应该是什么样子。不过,就像禅宗一样,它也包含了一些悖论(甚至自相矛盾之处)。

这里是 Python 之禅的前几句:

优美胜于丑陋。
显式胜于隐式。
简单胜于复杂。
复杂胜于晦涩。
扁平胜于嵌套。
稀疏胜于稠密。
可读性很重要。

这些都很重要,但我加粗的两句对设计优秀的库 API 尤其重要。

关于“Pythonic”,还有一点就是它是 Python。这话听起来是废话,但你会惊讶地发现,有那么多人从(比如)Java 转过来后,写起 Python 来依然带着浓浓的 Java 味。

在 Python 中我们喜欢保持简单。而且我们有更多工具可以用来打造优雅的 API,比如关键字参数(后面会细说)。

要点:尽量遵循 PEP 8,领会 PEP 20。这就是正道。

在继续之前,我们先快速看一下 Python 标准库:它算是 Pythonic 吗?

这个问题有点好笑,因为你会以为 Python 内置的东西应该是最 Pythonic 的。但其实并非全部都很出色!

Python 标准库由许多不同的人在几十年间陆续设计而成,因此其中有不少在今天看来可能算不上 Pythonic。

以下是标准库中一些不太好的 API 示例:

  • email 包,例如 email.mime.multipart.MIMEMultipart。Python 之禅刚告诉我们“扁平胜于嵌套”,但这个名字却有四层嵌套!为什么不用 email.MultipartMessage,甚至直接叫 email.Multipart 呢?
  • threading.Thread 是我个人特别在意的一个例子:Thread 类的第一个参数 group 根本没被使用。文档说它是“为未来扩展保留的”……但它已经保留了 26 年了!
  • unittest:倒不是什么大问题,但这是命名违背 PEP 8 的一个典型例子,按 PEP 8 函数应该命名为(例如)assert_equal 而不是 assertEqual。此外,unittest 要求你学习一整套新函数,而不是直接写 assert a == bpytest 就简化了这一点)。
  • urllib.request:我们已经见识过它有多不优雅了。

另一方面,也有很多设计良好的模块:

  • collections:这些类用起来非常顺手。我在标准库中最喜欢的两个工具是 defaultdictCounter
  • csv:名副其实,而且易于使用。如果你还没用过 DictReader,不妨试试!
  • datetime:肯定不完美(有哪个时间库是完美的呢?),但总体来说它提供了一组相当不错的类型,还巧妙地运用了运算符重载。
  • json:非常直观易用,dumpload 就能满足大部分需求,需要配置时还有一堆关键字参数可用。
  • 还有很多……

要点:标准库并不总是值得效仿的榜样。

模块和包结构

首先,明确一下 Python 术语:

  • 模块(Module):单个暴露 API(函数、类等)的 .py 文件
  • 包(Package):一个目录中的一组模块,以及一个 __init__.py

那么,应该如何组织文件、模块和包呢?

我们再回到 Requests。所有使用 Requests 的人都只需写 import requests,然后使用 requests.get()requests.Session 之类的东西。get() 函数实际上是在 api.py 中实现的,而 Session 是在 sessions.py 中实现的,但你不需要知道这些,也不需要导入子模块。

这是这样实现的:

# requests/__init__.py
from .api import delete, get, post, put, ...
from .sessions import Session

# requests/api.py
def get(url, params=None, **kwargs):
    return request("get", url, params=params, **kwargs)

# requests/sessions.py
class Session:
    ...

我建议始终从单个 .py 文件开始:它简单、易于导航。甚至有一些中等规模的库也是这么做的,例如 Web 框架 Bottle 就以单个 bottle.py 文件而闻名。

假设你在设计一个炸鱼薯条订餐库(我们会经常用到这个例子——这也是本文会有要点总结的原因!)。你在单个文件中写出了第一个版本:

# fishnchips.py (library code)
class Shop:
    ...

def order(chips=None, fish=None):
    ...

# app.py (user code)
import fishnchips
fishnchips.order(chips=1, fish=2)

这里有一个 Shop 类( presumably 有一堆方法)和一个顶层的 order 函数。用户只需 import fishnchips,然后像 app.py 中那样使用 fishnchips.order()

但如果你的库开始变得很大,你想把实现拆到多个文件中呢?你可以改成一个包(一个带 __init__.py 的目录),像这样:

# fishnchips/__init__.py
from .api import order
from .shop import Shop

# fishnchips/api.py
def order(chips=None, fish=None):
    ...

# fishnchips/shop.py
class Shop:
    ...

现在你的库有了三个文件,但因为我们在 __init__.py 中把相关内容导入到了顶层,用户仍然可以像以前一样写 import fishnchips 并使用 fishnchips.order()

他们不必写 import fishnchips.apiimport fishnchips.shop。你的新版本与旧的单文件实现完全向后兼容。你彻底改变了代码的布局,但 API 却没有变!

要点:暴露简洁的 API;文件结构只是实现细节。

包设计的另一个方面是嵌套。我们已经谈过 email.mime.multipart.MIMEMultipart。Django 的很多 API 也有同样的问题。这里有一个从 Django 文档中随手挑的例子:

from django.core.files import File
from django.core.files.images import ImageFile

file = File(f)
image = ImageFile(f)

这里对 ImageFile 的导入足足有四层深!django dot core dot files dot images dot ImageFile。

所以我想,也许 Django 里有好几种不同的 ImageFile,需要用深层命名空间来区分。但并非如此——我 grep 了 Django 源码,只找到这一个。File 也是如此。所以这四五层的命名空间完全没有必要;直接叫 django.ImageFile 就够了。

我知道 Django 是一个拥有众多组件的庞大库,所以两层嵌套也许还算合理,但绝不该是四五层。已故的 Aaron Swartz 在他 2005 年的博客文章 “Rewriting Reddit” 中也对 Django 提出了类似的观点——可以看看那篇以“Another Django goal”开头的一段。

最近在我为工作维护的一个库中,我们做了一次改动,把子模块中定义的所有内容都提升到 __init__.py 的顶层,让类名更容易被发现。用户不再需要猜“嗯,ActiveStatus 到底是在 ops.charm 还是 ops.model 子模块里?”,现在我们推荐直接用 import ops 然后写 ops.ActiveStatus。用户需要的所有类都在那儿了。

要点:扁平胜于嵌套。

现在来问你一个问题:你觉得下面哪段代码更好,第一段:

from fishnchips import order

...

def eat_takeaways():
    meal = order(chips=1)
    ingest(meal)

def ingest(meal):
    ...

还是这段?我等你找出区别。

import fishnchips

...

def eat_takeaways():
    meal = fishnchips.order(chips=1)
    ingest(meal)

def ingest(meal):
    ...

我强烈建议把库设计成第二种用法。

在第一种写法中,当调用 order() 时,你看不出是在哪里下的单。是麦当劳,还是炸鱼薯条店?order 是这个文件里定义的函数,还是导入进来的?

在第二种写法中,你一眼就能看出是用 fishnchips 模块下的单,而不必滚动到文件顶部去查看它是从哪里导入的。

要让库能这样使用,你需要在模块设计上多花些心思。例如,不要把函数命名为 order_fishnchips,因为模块名里已经有了 fishnchips。你肯定不想让人写 fishnchips.order_fishnchips(),那样就重复了。

我们可以看到 Requests 也是这样设计的:requests.get() 读起来自然、意思明确。你不应该写 from requests import get,因为脱离了模块名的上下文,get() 本身的含义就不够清晰了。

要点:把库设计成 import lib ... lib.Thing() 的用法,而不是 from lib import LibThing ... LibThing()

避免全局配置和状态

首先,什么是全局配置?

假设你的 fishnchips 库是通过 Web API 下单的。好,你会想要一个超时时间。但默认超时应该设为多少呢?你于是加了一个“常量”:

# fishnchips.py
DEFAULT_TIMEOUT = 10

def order(..., timeout=None):
    if timeout is None:
        timeout = DEFAULT_TIMEOUT
    ...

每当有人调用 order() 而不指定超时,就会得到 10 秒的默认超时。

但如果团队里另一个人也在程序中使用了 fishnchips 模块,而这个人就住在炸鱼薯条店隔壁,于是他设置了 fishnchips.DEFAULT_TIMEOUT = 1。那么你所有对 order() 的调用都会使用这个过低的默认值,这可不是你想要的。

与其暴露像 DEFAULT_TIMEOUT 这样的全局配置,不如把它标记为私有的,比如 _default_timeout,以明确表示它不应该在模块外使用。或者干脆使用带常量的关键字参数,让用户根本改不了:

def order(..., timeout=10):
    ...

如果有人想用不同的超时下单,他可以显式地调用 order(..., timeout=1),或者在自己的函数里包装一下。你也可以用 functools.partial 来做这种事,以“冻结”特定参数:

import functools

order_fast = functools.partial(fishnchips.order, timeout=1)

# calls fishnchips.order with supplied arguments, and timeout=1
order_fast(...)

这样,另一个使用 fishnchips.order 的人仍然会得到他预期的超时时间。functools.partial 的做法基本等同于下面这段代码,但避免了啰嗦的 *args**kwargs

def order_fast(*args, **kwargs):
    return fishnchips.order(*args, **kwargs, timeout=1)

要点:避免全局配置;使用合理的默认值,并让用户自行覆盖。

另一件要避免的是全局状态,即你的库为了让使用更简单而把一堆东西存在模块级变量里。

例如,假设你在创建一个 URL 路由库 routa,其中有一个添加 URL 路由的 add() 函数:

# routa.py
_urls = []

def add(pattern, func):
    _urls.append((pattern, func))

def match(url):
    ...

# app.py
routa.add('/', home_page)
routa.add('/contact', contact_page)

当用户只需要一个路由器时,这一切都很美好,但如果他突然需要两个,一个用于 HTML 页面,一个用于 JSON API 呢?这些 add() 调用会互相覆盖,因为只有一个 _urls 列表。

那怎么解决呢?合理的做法是加一个 Router 类。

# routa.py
class Router:
    def __init__(self):
        self._urls = []

    def add(self, pattern, func):
        self._urls.append((pattern, func))

    def match(self, url):
        ...

# app.py
router = routa.Router()
router.add('/', home_page)
router.add('/contact', contact_page)

这在库里只是多了几行代码,在用户的应用里也只多了一行代码。但它让库好用得多,用户可以创建任意多个路由器而不用担心互相覆盖。

Jack Diederich 有一个非常有趣的演讲叫 “Stop Writing Classes”。他的观点之一是,Python 中的类常常是不必要的样板——尤其是那些只有两个方法、其中一个还是 __init__ 的类!

然而,当你的库需要在函数调用之间记住一些状态时,类通常就是合适的工具。再以 Requests 为例,看下面的代码:

session = requests.Session()
session.auth = ('usr', 'pwd')
session.headers = {'User-Agent': 'mylib/1.0'}

session.get('https://example.com/foo')
session.get('https://example.com/bar')

这段代码没有直接使用 requests.get(),而是先创建了一个 requests.Session。这样做有两个好处:

  1. 你可以为每个请求附加自定义的认证或自定义请求头。这很方便,不过如果仅仅是为了这个,用 functools.partial 或包装函数也能做到。
  2. Session 会复用 TCP 连接,显著加快对同一域名的多次请求。

对于 TLS(https:// 链接)——如今几乎所有请求都是如此——这一点可能非常重要。我之前从新西兰向英格兰发起请求时就体会过这一点,由于 Requests 不必每次都重新建立 TLS 连接,后续请求的耗时从每次 1 秒降到了约 0.3 秒。

要点:避免全局状态;改用类。

命名

我们已经稍微谈过命名,但命名很难!

给变量命名时,有时人们用的名字太短。而在给 API 函数命名时,我发现人们通常用的名字又太长了。

正如我们所见,API 中的名字本身就已经有了模块名作为前缀。来看一个例子。

想象一下,如果刚毕业的小 Bob 来设计 Requests,他的某位老师曾说“给函数起个能说明它做什么的名字”。他听成了“给函数起个能说明它所做一切的名字”,于是写出了这个:

# requests.py
def send_get_request_and_receive_response():
    ...

这有什么问题?多余的词并没有增加任何信息,反而降低了清晰度:requests.get() 一目了然、易于阅读,而那个更长的名字中,最重要的部分——它是“get”方法——反而被藏在了名字中间。

“request”这个词出现了两次,一次在模块名里,一次在函数名里。还有“send”和“receive response”。但整个库的意义就是发送请求和接收响应,所以这些也是多余的。

最后剩下简洁的 requests.get——发起一个 GET 请求——清晰而精炼。已经不能再短了。我的意思是,你倒是可以把它改成 requests.g——但那就太傻了。

回想我们的 fishnchips 库:我们可以把 order 函数叫做 order_foodorder_meal,但这可是炸鱼薯条库——当然是食物、当然是一餐。所以直接叫 fishnchips.order 就足够了。

要点:名字应该在保持清晰的前提下尽可能短。

现在稍微岔开一下:你可能听说过函数名应该是动词,因为它们某件事,比如 getpost(它们是 HTTP 动词或方法)。属性名和类名应该是名词,因为它们是事物,比如 SessionResponse。这是一条不错的经验法则。

但有时候很难分清。“shop”是名词还是动词?“order”呢?

没错——它们都是!英语中很多短词既是动词也是名词,所以就像日常说话一样,你得根据上下文来判断。

不过,在 Python 中我们还可以用大小写来增加含义:如果我们有一个 shop 函数,它会是小写 s。而如果我们有一个 Order 类,它会是大写 O。这正是 PEP 8 带来的一个好处,可以很好地区分。

要点:函数名应该是动词,类名应该是名词,但不必对此过于纠结。

我们来聊聊隐私。

在 Python 中,以下划线开头的名称,如 _private,按约定是私有的。用户仍然可以访问它们,但这相当于在说“我知道这样做不好,但我还是要访问 _private”。当你在编写库时,在本应保持向后兼容的新版本中,你完全可以修改或删除 _private。如果用户访问了 _private,那就是他们自己的事了。

别费心去用双下划线前缀来制造“更私有”的效果,比如 __extra_private。当你这样做时,Python 会进行一种非常简单的名称改写(name-mangling),但它往往弊大于利,而且想访问的人也很容易绕过,所以它其实也不是真正私有。单下划线就足够了。

对私有成员使用双下划线还会让人困惑:它看起来像魔法方法,但其实不是(所有 Python 魔法方法都有双下划线,像 __init__ 这样)。

要点:_private 这样就够了;__extra_privacy 没必要。

错误和异常

在 Python 中,错误几乎都应该作为异常抛出。

首先,对于像 TypeErrorValueError 这样的核心编程错误,你可以使用庞大的内置异常层级。例如,我们可以给 fishnchips.order 函数加上一些范围检查:

def order(chips=None, fish=None):
    if chips is None and fish is None:
        raise ValueError('nothing to order!')
    if chips <= 0:
        raise ValueError('"chips" must be greater than zero')
    if fish <= 0:
        raise ValueError('"fish" must be greater than zero')
    ...

在这里使用 ValueError 是合适的——没必要为此定义自定义异常类型。

如果你在创建一个与标准操作映射得很好的东西,比如文件系统库,你可能想复用 OSError 的子类,如 FileNotFoundErrorPermissionError

然而,当你构建一个新库时,通常最好为库的所有异常创建一个自定义基类,并抛出这个基类下有意义的子类。你的基类应该继承自 Exception。例如,在我们的炸鱼薯条库中,你可以这样做:

# fishnchips.py

class Error(Exception):  # will be used as fishnchips.Error
    """Base class for all of this library's exceptions."""

class NetworkError(Error):
    """Low-level networking error."""

class APIError(Error):
    """Error talking to the shop's API."""

这样库的用户就可以轻松捕获它可能抛出的任何异常。例如,如果你的库在调用 HTTP API 时可能抛出 ssl.SSLError,最好捕获它并重新抛出为 fishnchips.NetworkError

你甚至可以让你的异常同时继承自你的异常基类标准库中的异常。例如,Requests 有一个异常层级,其中像 InvalidURL 这样的异常同时继承自 RequestException 基类和内置的 ValueError

class InvalidURL(RequestException, ValueError):
    """The URL provided was somehow invalid."""

创建自定义异常的另一个好处是可以提供额外信息。例如,你可以添加一个 OrderError,它能提供来自 HTTP 4xx 响应的信息:

class OrderError(Error):
    def __init__(self, code, chef_name, message):
        self.code = code
        self.chef_name = chef_name
        self.message = message

def order(chips=None, fish=None):
    quantities = {'chips': chips, 'fish': fish}
    try:
        response = requests.post(_shop_url, json=quantities)
    except requests.RequestException as e:
        raise NetworkError(f'Network error: {e}')
    if 500 <= response.status_code <= 599:
        raise APIError(f'API Error {response.status_code}: {response.text}')
    if 400 <= response.status_code <= 499:
        data = response.json()
        raise OrderError(
            code=response.status_code,
            chef_name=data['chef_name'],
            message=data['error_message'],
        )

如果出现了像 requests.Timeout 这样的底层异常,它会被 except requests.RequestException 块捕获并重新抛出为 NetworkError

如果在与 Web API 通信时出现了 HTTP 5xx 服务器错误,我们就抛出 APIError

而如果在与 Web API 通信时出现了 HTTP 4xx 客户端错误,我们就抛出 OrderError,这是我们新定义的自定义异常类型。它有三个属性:HTTP 状态码、厨师的名字和一条消息(其中两个字段来自响应 JSON)。

现在用户可以捕获 OrderError 并利用详细信息做一些有用的事,比如打印一条友好的错误信息:

try:
    fishnchips.order(chips=1, fish=2)
except fishnchips.OrderError as e:
    print(f'Error with order: {e.chef_name} said {e.message}', file=sys.stderr)
    sys.exit(1)
except fishnchips.Error as e:
    print(f'Unexpected error, please contact us: {e}', file=sys.stderr)
    sys.exit(1)

有一个错误没有作为异常的有趣例子其实就来自 Requests:

>>> response = requests.get('https://benhoyt.com/resume/')
>>> response.status_code
404
>>> response.raise_for_status()
Traceback (most recent call last):
  ...
requests.exceptions.HTTPError: 404 Client Error: ...

这个页面不存在,所以是 404 错误——我的简历在 /cv/,而不是 /resume/。但 Requests 并不会抛出异常,除非你显式调用 response.raise_for_status()

我想他们早期做出的决定是,如果 HTTP 请求完成了,即使是 HTTP 层面的错误,Requests 库也应将其视为成功。

我实际上认为这是 Requests API 设计中少有的几个设计缺陷之一。HTTP 的 4xx 或 5xx 状态码就是错误,所以它应该是一个异常。按现在的设计,很容易写出这样的代码:

response = requests.get('https://benhoyt.com/resume/')
pathlib.Path('resume.html').write_text(response.text)

我们忘了检查 response.status_code,所以这段代码会悄悄地把我的 404 页面的 HTML 写入 resume.html。你可以通过检查 status_code 属性或调用 raise_for_status 来“修复”它,但这种 API 设计让人很容易忘记。API 应该被设计得让人很难犯错。

要点:如果发生错误,应抛出自定义异常;如果合适,也可使用内置异常。

版本控制和向后兼容

如果你要在 Python Package Index(PyPI) 上发布库的更新,你应该始终提升版本号并编写说明变更的发行说明。但这其中有很多细节。

你可能听说过语义化版本(semantic versioning),简称“semver”。它使用像 1.2.3 这样的版本号,其中 1 是版本号,2 是版本号,3 是补丁版本号。

semver 规范说你应该在以下情况下递增:

  • 当你做了不兼容的 API 变更时,递增主版本号
  • 当你以向后兼容的方式添加功能时,递增次版本号
  • 当你做了向后兼容的 bug 修复时,递增补丁版本号

这很容易做到。先把版本定为 1.0.0,然后依此递增。当你添加新类或新函数时,就升到 1.1.0,再到 1.2.0,以此类推。中间的数字超过 9 甚至变得很大是很常见的,比如 1.42.0 或 1.365.0。

记住,你是在为用户打造 API,所以你应该非常缓慢地更新主版本号。如果用户想升级到新的主版本,他们很可能得改自己的代码。这非常痛苦,所以别经常这么做!

这有点主观,但我认为只有在彻底改变 API 结构时,才应该发布新的主版本。

要点:只有在重构 API 时才打破向后兼容。

好在 Python 有一些非常适合保持向后兼容的特性:其中两个主要特性是关键字参数动态类型

我们再回到炸鱼薯条库。这是第一版——你可以订一定份数的薯条和一定数量的鱼:

def order(chips=None, fish=None):
    """Place an order.

    Args:
        chips: number of scoops of chips
        fish: number of fish
    """

但假设我们的炸鱼薯条店变得更讲究了,他们想开始供应裹面包屑的鱼。于是现在有两种鱼:裹面糊的和裹面包屑的。

我们需要修改 API,让用户可以指定鱼的种类。最简单的方法是添加一个关键字参数。默认值仍然是裹面糊的,但调用者如果想要,可以传入 fish_type='crumbed'

def order(chips=None, fish=None, fish_type='battered'):
    """Place an order.

    Args:
        chips: number of scoops of chips
        fish: number of fish
        fish_type: type of fish, 'battered' or 'crumbed'
    """

# example usage:
fishnchips.order(chips=1, fish=2, fish_type='crumbed')

关键字参数的好处在于,作为库作者,你可以添加任意多个,每个都可以有合理的默认值。所以我们可以再加一个 timeout 参数,以及任意多种新食物,比如 onion_ringshotdogs

你甚至可以为鱼的种类使用枚举(enum),比如 FishType.CRUMBED。这会稍微啰嗦一点,但如果拼写错了,解释器会帮你抓住。

另一种做法是利用动态类型,让 fish 既可以是整数,也可以是(数量,种类)的元组,这样你就可以点“2 份裹面包屑的鱼”:

def order(chips=None, fish=None):
    """Place an order.

    Args:
        chips: number of scoops of chips
        fish: either the number of fish, or a tuple of (quantity, type),
            where type is 'battered' or 'crumbed'
    """

# example usage:
fishnchips.order(chips=1, fish=(2, 'crumbed'))

我觉得这个 API 稍微好一点。但如果妈妈想要裹面包屑的,爸爸想要裹面糊的呢?我们再允许传入元组的列表,这样他们就可以各得其所:

def order(chips=None, fish=None):
    """Place an order.

    Args:
        chips: number of scoops of chips
        fish: either the number of fish,
           a tuple of (quantity, type),
           or a list of such tuples
    """

# example usage:
fishnchips.order(chips=1, fish=[(1, 'crumbed'), (1, 'battered')])

签名有点复杂,但在 Python 中这种做法相当常见,它让简单的事简单,让复杂的事也能做到。所有这些改动都是向后兼容的,这对现有用户来说是好事。

要点:关键字参数和动态类型非常适合保持向后兼容。

类型注解

我得先说明,我对 Python 中的类型注解感情复杂。我先说说缺点:

  • 它们是后来硬加进去的,这一点很明显。最初的 类型提示 PEP 是在 Python 诞生 23 年后才出现的。
  • 对于 Python 中常见的动态类型参数,它们会让签名变得冗长难读。
  • 注解语法一直在更新。
  • 有多个类型检查器,它们之间并不完全一致。

举一个它们会很快变得冗长的例子,看看前面“鱼的种类”的例子,其中可选的 fish 参数可以是整数、元组或元组列表。下面是它的签名:

def order(
        chips: int | None = None,
        fish: int | tuple[int, str] | list[tuple[int, str]] | None = None,
    ):

对于一个相当简单的东西,这可是相当多的(咳)打字工作!

在 Python 的 Reddit 分区上有一篇非常精彩的帖子,标题颇为耸动,叫 “Why Type Hinting Sucks!”,作者在文中展示了面对鸭子类型时,试图写出正确类型签名会让你陷入多么纠结的境地。作者通过 10 次越来越离谱的尝试,来为一个简单的两数相加函数添加注解。

上面的例子也展示了语法变化之快:上面用的是新的 x | y 联合类型语法(在 Python 3.10 中加入),以及 list[T] 语法而非 typing.List[T](在 Python 3.9 中加入)。就在几个版本之前,你还得这样写:

from typing import List, Optional, Tuple, Union

def order(
        chips: Optional[int] = None,
        fish: Optional[Union[int, Tuple[int, str], List[Tuple[int, str]]]] = None,
    ):

就在我写这篇文章时,我还看到一篇关于 PEP 695 的帖子,它为类型参数(泛型)提出了一种新语法。它将包含在 Python 3.12 中,同时还有另外两个引入新语法的提案,分别是关于方法重写和在 TypedDict 中使用 **kwargs

但我必须公平一点——类型注解肯定也有积极的一面:

  • 它们有助于捕获 bug。有了类型检查,你需要的测试就更少了。
  • 它们让重构更容易、更安全。
  • 它们说明了哪些类型是可接受的。
  • 它们能帮助 IDE 提供更好的导航和自动补全。

尤其是最后两点,对你的库用户来说很有用。

总体而言,我认为在 2023 年为你的库提供类型注解肯定是正确的做法。

当然,光加上注解还不够,还要在每次提交时用 PyrightMyPy 对库代码进行检查。(目前我没有特别偏好哪一个)。

要点:至少为你的公共 API 加上类型注解;你的用户会感谢你的。

关于 Python 中的类型和类型注解还有很多可说的,但在这里我只想再提一个现代 Python 工具:数据类(data class)。

我相信我们都写过这样的“数据类”:

class User:
    def __init__(self, username, display_name, active):
        self.username = username
        self.display_name = display_name
        self.active = active

然后我们发现了 collections.namedtuple,开始改用它:

User = collections.namedtuple('User', ['username', 'display_name', 'active'])

然而,我们其实并不想要一个元组(它的字段是可索引、可迭代的),我们想要的是一个“结构体”或“纯数据类”。好在从 Python 3.7 起有了 dataclasses 模块。你可以这样用:

from dataclasses import dataclass

@dataclass
class User:
    username: str
    display_name: str
    active: bool

它不仅比普通类更短、更优雅,你还能免费获得很多东西:字段上的类型注解、自动生成的 __init____repr____eq__,甚至还有用于模式匹配__match_args__

所以,如果你有那些主要是数据的类,一定要看看 dataclasses 模块。你完全也可以给它们添加方法。

要点:对于(主要是)数据的类,使用 @dataclass

Python 的表达力:要小心!

最后我想留给你一个思考:Python 几乎拥有无限的灵活性。你可以重载像 a+ba[b] 这样的运算符,你可以让一次简单的属性查找就删掉某人的硬盘,你可以在运行时按名称动态导入包,你可以创造出只有你自己能看懂的领域特定语言,等等等等。

但能做不代表就应该做!编程或许是魔法,但太多魔法会让人困惑、难以理解。

以下是我的经验法则:

  • 只有在创建数字类型时,才重载像 a+b 这样的数学运算符。
  • 只有在创建可索引集合时,才重载像 a[b] 这样的索引运算符。
  • 属性的 getter 和 setter “看起来很廉价”,所以它们就应该很廉价。例如,不要执行 I/O 或抛出异常。
  • 如果类型签名太难写,那这个设计可能就是个坏主意。

要点:Python 的表达力是无穷的;不要过度使用!

要点总结

还有很多可以说的。事实上,你大概可以就 API 设计写一整本书。但就到这里吧!

最后,我把所有要点汇总在一处:

  • 好的 API 设计对用户至关重要。
  • 创建库时,先打好基础,再不断迭代。
  • 尽量遵循 PEP 8,领会 PEP 20。这就是正道。
  • 标准库并不总是值得效仿的榜样。
  • 暴露简洁的 API;文件结构只是实现细节。
  • 扁平胜于嵌套。
  • 把库设计成 import lib ... lib.Thing() 的用法,而不是 from lib import LibThing ... LibThing()
  • 避免全局配置;使用合理的默认值,并让用户自行覆盖。
  • 避免全局状态;改用类。
  • 名字应该在保持清晰的前提下尽可能短。
  • 函数名应该是动词,类名应该是名词,但不必对此过于纠结。
  • _private 这样就够了;__extra_privacy 没必要。
  • 如果发生错误,应抛出自定义异常;如果合适,也可使用内置异常。
  • 只有在重构 API 时才打破向后兼容。
  • 关键字参数和动态类型非常适合保持向后兼容。
  • 至少为你的公共 API 加上类型注解;你的用户会感谢你的。
  • 对于(主要是)数据的类,使用 @dataclass
  • Python 的表达力是无穷的;不要过度使用!

祝设计愉快!欢迎分享你自己的 API 设计想法或任何其他反馈。

本文章由 muse-spark-1.2-contributor 进行翻译

评论