设计 Pythonic 风格的库 API
摘要:本文介绍了一些我认为有助于设计优秀 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 的轮廓:Request 和 Response 对象,以及简单的 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 == b(pytest 就简化了这一点)。urllib.request:我们已经见识过它有多不优雅了。
另一方面,也有很多设计良好的模块:
collections:这些类用起来非常顺手。我在标准库中最喜欢的两个工具是defaultdict和Counter。csv:名副其实,而且易于使用。如果你还没用过DictReader,不妨试试!datetime:肯定不完美(有哪个时间库是完美的呢?),但总体来说它提供了一组相当不错的类型,还巧妙地运用了运算符重载。json:非常直观易用,dump和load就能满足大部分需求,需要配置时还有一堆关键字参数可用。- 还有很多……
要点:标准库并不总是值得效仿的榜样。
模块和包结构
首先,明确一下 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.api 或 import 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。这样做有两个好处:
- 你可以为每个请求附加自定义的认证或自定义请求头。这很方便,不过如果仅仅是为了这个,用
functools.partial或包装函数也能做到。 - 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_food 或 order_meal,但这可是炸鱼薯条库——当然是食物、当然是一餐。所以直接叫 fishnchips.order 就足够了。
要点:名字应该在保持清晰的前提下尽可能短。
现在稍微岔开一下:你可能听说过函数名应该是动词,因为它们做某件事,比如 get 和 post(它们是 HTTP 动词或方法)。属性名和类名应该是名词,因为它们是事物,比如 Session 和 Response。这是一条不错的经验法则。
但有时候很难分清。“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 中,错误几乎都应该作为异常抛出。
首先,对于像 TypeError 或 ValueError 这样的核心编程错误,你可以使用庞大的内置异常层级。例如,我们可以给 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 的子类,如 FileNotFoundError 或 PermissionError。
然而,当你构建一个新库时,通常最好为库的所有异常创建一个自定义基类,并抛出这个基类下有意义的子类。你的基类应该继承自 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_rings 和 hotdogs。
你甚至可以为鱼的种类使用枚举(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 年为你的库提供类型注解肯定是正确的做法。
当然,光加上注解还不够,还要在每次提交时用 Pyright 或 MyPy 对库代码进行检查。(目前我没有特别偏好哪一个)。
要点:至少为你的公共 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+b 和 a[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 设计想法或任何其他反馈。
随机一篇博客
评论
登录后参与讨论