API 演进的正确方法

负责任的库作者与用户保持的十项约定。
147 位读者喜欢这篇文章。
Browser of things

想象一下,您是一位创造神,正在为一个生物设计身体。您仁慈地希望这个生物随着时间推移而进化:首先,因为它必须响应环境的变化,其次,因为您的智慧增长,并且您想到了更好的生物设计。它不应该永远保持相同的身体!

Serpents

然而,这个生物可能依赖于其当前身体结构的特征。您不能在没有警告的情况下添加翅膀或改变它的鳞片。它需要一个有序的过程来使其生活方式适应新的身体。作为负责这个生物自然史的负责任的设计者,您如何才能温和地引导它走向更伟大的改进?

对于负责任的库维护者来说,情况也是如此。我们信守对依赖我们代码的人们的承诺:我们发布错误修复和有用的新功能。如果删除某些功能对库的未来有利,我们有时会删除它们。我们不断创新,但我们不会破坏使用我们库的人们的代码。我们如何才能同时实现所有这些目标?

添加有用的功能

您的库不应该永远保持不变:您应该添加使您的库对用户更有用的功能。例如,如果您有一个 Reptile 类,并且拥有翅膀以便飞行会很有用,那就去做吧。

class Reptile:
    @property
    def teeth(self):
        return 'sharp fangs'

    # If wings are useful, add them!
    @property
    def wings(self):
        return 'majestic wings'

但请注意,功能会带来风险。考虑一下 Python 标准库中的以下功能,看看它出了什么问题。

bool(datetime.time(9, 30)) == True
bool(datetime.time(0, 0)) == False

这很奇怪:将任何时间对象转换为布尔值都会产生 True,午夜除外。(更糟糕的是,时区感知时间的规则甚至更奇怪。)

我编写 Python 代码已经超过十年了,但我直到上周才发现这个规则。这种奇怪的行为会在用户的代码中引起什么样的错误?

考虑一个具有创建事件功能的日历应用程序。如果一个事件有结束时间,该功能要求它也必须有开始时间。

def create_event(day,
                 start_time=None,
                 end_time=None):
    if end_time and not start_time:
        raise ValueError("Can't pass end_time without start_time")

# The coven meets from midnight until 4am.
create_event(datetime.date.today(),
             datetime.time(0, 0),
             datetime.time(4, 0))

不幸的是,对于巫师来说,午夜开始的事件无法通过此验证。一位细心的程序员如果知道午夜的怪癖,当然可以正确地编写这个函数。

def create_event(day,
                 start_time=None,
                 end_time=None):
    if end_time is not None and start_time is None:
        raise ValueError("Can't pass end_time without start_time")

但这种微妙之处令人担忧。如果库创建者想要创建一个让用户感到困惑的 API,那么像午夜的布尔转换这样的“功能”就非常合适。

Man being chased by an alligator

然而,负责任的创建者的目标是使您的库易于正确使用。

此功能由 Tim Peters 在 2002 年首次创建 datetime 模块时编写。即使像 Tim 这样的 Python 创始人也会犯错误。这个怪癖已被删除,现在所有时间都为 True。

# Python 3.5 and later.

bool(datetime.time(9, 30)) == True
bool(datetime.time(0, 0)) == True

不知道午夜怪异之处的程序员从晦涩的错误中解脱出来,但这让我感到不安,想到任何依赖怪异旧行为并且没有注意到更改的代码。如果这个糟糕的功能从未实现过,那就更好了。这使我们得出任何库维护者的第一个承诺

第一条约定:避免糟糕的功能

最痛苦的更改是您必须删除功能的时候。避免糟糕功能的一种方法是总体上少添加功能!如果没有充分的理由,请勿创建任何公共方法、类、函数或属性。因此

第二条约定:最小化功能

功能就像孩子:在激情时刻构思出来,它们必须得到多年的支持。不要仅仅因为您可以就做任何愚蠢的事情。不要给蛇添加羽毛!

Serpents with and without feathers

当然,在很多情况下,用户需要您的库提供一些它尚未提供的功能。您如何选择要提供给他们的正确功能?这里有另一个警示故事。

来自 asyncio 的警示故事

您可能知道,当您调用协程函数时,它会返回一个协程对象

async def my_coroutine():
    pass

print(my_coroutine())
<coroutine object my_coroutine at 0x10bfcbac8>

您的代码必须“await”这个对象才能运行协程。这很容易忘记,因此 asyncio 的开发人员想要一个“调试模式”来捕获这个错误。每当协程在未被 await 的情况下销毁时,调试模式会打印一个警告,其中包含创建它的行的回溯。

当 Yury Selivanov 实现调试模式时,他添加了一个“协程包装器”功能作为其基础。包装器是一个函数,它接受一个协程并返回任何东西。Yury 使用它在每个协程上安装警告逻辑,但其他人可以使用它将协程变成字符串“hi!”

import sys

def my_wrapper(coro):
    return 'hi!'

sys.set_coroutine_wrapper(my_wrapper)

async def my_coroutine():
    pass

print(my_coroutine())
hi!

这是一种非常糟糕的自定义。它改变了“async”的真正含义。调用一次 set_coroutine_wrapper 将全局且永久地更改所有协程函数。正如 Nathaniel Smith 所写,这是一个“有问题的 API”,容易被滥用,因此不得不删除。如果 asyncio 开发人员更好地塑造了它的用途,他们本可以避免删除功能的痛苦。负责任的创建者必须牢记这一点

第三条约定:保持功能狭窄

幸运的是,Yury 有很好的判断力,将此功能标记为临时的,因此 asyncio 用户知道不要依赖它。Nathaniel 可以自由地用一个更窄的功能替换 set_coroutine_wrapper,该功能仅自定义回溯深度。

import sys

sys.set_coroutine_origin_tracking_depth(2)

async def my_coroutine():
    pass

print(my_coroutine())
<coroutine object my_coroutine at 0x10bfcbac8>

RuntimeWarning:'my_coroutine' was never awaited

Coroutine created at (most recent call last)
  File "script.py", line 8, in <module>
    print(my_coroutine())

这好多了。不再有可以更改协程类型的全局设置,因此 asyncio 用户无需进行防御性编码。神灵都应该像 Yury 一样有远见。

第四条约定:将实验性功能标记为“临时的”

如果您只是预感您的生物想要角和四叉舌头,请引入这些功能,但将它们标记为“临时的”。

Serpent with horns

您可能会发现角是多余的,但四叉舌头毕竟很有用。在您库的下一个版本中,您可以删除前者并将后者标记为正式的。

删除功能

无论我们多么明智地指导生物的进化,都可能有一天最好删除一个正式的功能。例如,您可能创建了一只蜥蜴,现在您选择删除它的腿。也许您想将这个笨拙的生物转变成一条光滑而现代的蟒蛇。

Lizard transformed to snake

删除功能主要有两个原因。首先,您可能会通过用户反馈或您自己不断增长的智慧,发现某个功能是个坏主意。午夜的怪异行为就是这种情况。或者,该功能最初可能很适合您库的环境,但生态环境发生了变化。也许另一位神灵发明了哺乳动物。您的生物想要挤进哺乳动物的小洞穴并吃掉美味的哺乳动物馅料,因此它必须失去腿。

A mouse

同样,Python 标准库会删除功能以响应语言本身的变化。考虑 asyncio 的 Lock。自从“await”作为关键字添加以来,它一直是可等待的

lock = asyncio.Lock()

async def critical_section():
    await lock
    try:
        print('holding lock')
    finally:
        lock.release()

但是现在,我们可以使用“async with lock”。

lock = asyncio.Lock()

async def critical_section():
    async with lock:
        print('holding lock')

新风格好得多!它简短,并且在一个包含其他 try-except 块的大型函数中不易出错。由于“应该有一种——最好只有一种——明显的做事方式”,旧语法在 Python 3.7 中被弃用,并且很快将被禁止。

生态变化不可避免地也会对您的代码产生这种影响,因此请学会温和地删除功能。在您这样做之前,请考虑删除它的成本或收益。负责任的维护者不愿意让他们的用户更改大量代码或更改他们的逻辑。(还记得 Python 3 删除“u”字符串前缀,然后又将其添加回来的痛苦吗?)但是,如果代码更改是机械的,例如简单的搜索和替换,或者该功能是危险的,则可能值得删除。

是否删除功能

Balance scales
缺点 优点
代码必须更改 更改是机械的
逻辑必须更改 功能是危险的

在我们的饥饿蜥蜴的案例中,我们决定删除它的腿,以便它可以溜进老鼠洞并吃掉它。我们该如何进行呢?我们可以直接删除 walk 方法,将代码从

class Reptile:
    def walk(self):
        print('step step step')

更改为

class Reptile:
    def slither(self):
        print('slide slide slide')

这不是一个好主意;这个生物习惯于走路!或者,就库而言,您的用户有代码依赖于现有方法。当他们升级到最新版本的库时,他们的代码将崩溃。

# User's code. Oops!
Reptile.walk()

因此,负责任的创建者做出以下承诺

第五条约定:温和地删除功能

温和地删除功能涉及几个步骤。从一只用腿走路的蜥蜴开始,您首先添加新方法“slither”。接下来,弃用旧方法。

import warnings

class Reptile:
    def walk(self):
        warnings.warn(
            "walk is deprecated, use slither",
            DeprecationWarning, stacklevel=2)
        print('step step step')

    def slither(self):
        print('slide slide slide')

Python warnings 模块非常强大。默认情况下,它将警告打印到 stderr,每个代码位置仅打印一次,但您可以静音警告或将其转换为异常,以及其他选项。

一旦您将此警告添加到您的库中,PyCharm 和其他 IDE 就会用删除线呈现已弃用的方法。用户立即知道该方法即将被删除。

Reptile().walk()

当他们使用升级后的库运行代码时会发生什么?

$ python3 script.py

DeprecationWarning: walk is deprecated, use slither
  script.py:14: Reptile().walk()

step step step

默认情况下,他们会在 stderr 上看到警告,但脚本会成功并打印“step step step”。警告的回溯显示了用户代码中必须修复的行。(这就是“stacklevel”参数的作用:它显示了用户需要更改的调用站点,而不是库中生成警告的行。)请注意,错误消息具有指导意义,它描述了库用户必须做什么才能迁移到新版本。

您的用户将希望测试他们的代码并证明他们没有调用已弃用的库方法。仅警告不会使单元测试失败,但异常会。Python 有一个命令行选项可以将弃用警告转换为异常。

> python3 -Werror::DeprecationWarning script.py

Traceback (most recent call last):
  File "script.py", line 14, in <module>
    Reptile().walk()
  File "script.py", line 8, in walk
    DeprecationWarning, stacklevel=2)
DeprecationWarning: walk is deprecated, use slither

现在,不会打印“step step step”,因为脚本以错误终止。

因此,一旦您发布了库的一个版本,该版本警告已弃用的“walk”方法,您就可以在下一个版本中安全地删除它了。对吗?

考虑一下您的库用户可能在其项目需求中有什么。

# User's requirements.txt has a dependency on the reptile package.
reptile

下次他们部署代码时,他们将安装最新版本的库。如果他们尚未处理所有弃用,那么他们的代码将会崩溃,因为它仍然依赖于“walk”。您需要比这更温和。您必须对用户保持另外三个承诺:维护变更日志、选择版本方案以及编写升级指南。

第六条约定:维护变更日志

您的库必须有一个变更日志;其主要目的是宣布用户依赖的功能何时被弃用或删除。

版本 1.1 中的更改

新功能

  • 新函数 Reptile.slither()

弃用

  • Reptile.walk() 已弃用,将在版本 2.0 中删除,请使用 slither()

负责任的创建者使用版本号来表达库的变化,以便用户可以就升级做出明智的决定。“版本方案”是一种用于交流更改节奏的语言。

第七条约定:选择版本方案

有两种广泛使用的方案,语义化版本控制 和基于时间的版本控制。我建议几乎任何库都使用语义化版本控制。Python 的版本在 PEP 440 中定义,并且像 pip 这样的工具理解语义化版本号。

如果您为您的库选择语义化版本控制,您可以使用如下版本号温和地删除其腿

1.0:第一个“稳定”版本,带有 walk()

1.1:添加 slither(),弃用 walk()

2.0:删除 walk()

您的用户应该依赖于您库的版本范围,如下所示

# User's requirements.txt.
reptile>=1,<2

这允许他们在主要版本内自动升级,接收错误修复并可能引发一些弃用警告,但不升级到下一个主要版本并冒着更改破坏其代码的风险。

如果您遵循基于时间的版本控制,您的版本号可能是这样的

2017.06.0:2017 年 6 月发布的版本

2018.11.0:添加 slither(),弃用 walk()

2019.04.0:删除 walk()

用户可以像这样依赖您的库

# User's requirements.txt for time-based version.
reptile==2018.11.*

这很棒,但是您的用户如何知道您的版本控制方案以及如何测试其代码的弃用?您必须建议他们如何升级。

第八条约定:编写升级指南

以下是负责任的库创建者如何指导用户

升级到 2.0

从已弃用的 API 迁移

请参阅 变更日志 以了解已弃用的功能。

启用弃用警告

升级到 1.1 并使用以下命令测试您的代码

python -Werror::DeprecationWarning

​​​​​​现在可以安全升级了。

您必须教用户如何通过向他们展示命令行选项来处理弃用警告。并非所有 Python 程序员都知道这一点——我当然每次都必须查找语法。请注意,您必须发布一个版本,该版本会打印来自每个已弃用 API 的警告,以便用户可以在再次升级之前使用该版本进行测试。在本例中,版本 1.1 是桥梁版本。它允许您的用户逐步重写他们的代码,分别修复每个弃用警告,直到他们完全迁移到最新的 API。他们可以独立于彼此测试代码更改和库更改,并隔离错误的原因。

如果您选择了语义化版本控制,则此过渡期将持续到下一个主要版本,从 1.x 到 2.0,或从 2.x 到 3.0,等等。温和删除生物腿的方法是至少给它一个版本来调整其生活方式。不要一次性移除所有的腿!

A skink

版本号、弃用警告、变更日志和升级指南共同作用,以温和地演进您的库,而不会破坏与用户的约定。Twisted 项目的兼容性策略对此进行了精美的解释

“第一个总是免费的”

任何在没有警告的情况下运行的应用程序都可以升级 Twisted 的一个小版本。

换句话说,任何在运行测试时没有触发 Twisted 任何警告的应用程序都应该能够至少升级一次 Twisted 版本,而不会产生不良影响,除了可能产生新的警告。

现在,我们创造神已经获得了智慧和力量,可以通过添加方法来添加功能,并通过温和的方式删除它们。我们还可以通过添加参数来添加功能,但这带来了新的难度。您准备好了吗?

添加参数

想象一下,您刚刚给了您的蛇形生物一对翅膀。现在您必须允许它选择是通过滑行还是飞行来移动。目前,它的“move”函数接受一个参数。

# Your library code.
def move(direction):
    print(f'slither {direction}')

# A user's application.
move('north')

您想添加一个“mode”参数,但这会破坏用户的代码,如果他们升级,因为他们只传递一个参数。

# Your library code.
def move(direction, mode):
    assert mode in ('slither', 'fly')
    print(f'{mode} {direction}')

# A user's application. Error!
move('north')

真正有智慧的创造者承诺不会以这种方式破坏用户的代码。

第九条约定:兼容地添加参数

为了遵守此约定,请为每个新参数添加一个默认值,以保留原始行为。

# Your library code.
def move(direction, mode='slither'):
    assert mode in ('slither', 'fly')
    print(f'{mode} {direction}')

# A user's application.
move('north')

随着时间的推移,参数成为函数演变的自然历史。它们按从旧到新的顺序列出,每个参数都有一个默认值。库用户可以传递关键字参数来选择加入特定的新行为,并接受所有其他参数的默认值。

# Your library code.
def move(direction,
         mode='slither',
         turbo=False,
         extra_sinuous=False,
         hail_lyft=False):
    # ...

# A user's application.
move('north', extra_sinuous=True)

然而,存在一个危险,即用户可能会编写如下代码

# A user's application, poorly-written.
move('north', 'slither', False, True)

如果在您库的下一个主要版本中,您摆脱了其中一个参数,例如“turbo”,会发生什么?

# Your library code, next major version. "turbo" is deleted.
def move(direction,
         mode='slither',
         extra_sinuous=False,
         hail_lyft=False):
    # ...


# A user's application, poorly-written.
move('north', 'slither', False, True)

用户的代码仍然可以编译,这是一件坏事。代码停止了超凡脱俗的移动,开始呼叫 Lyft,这不是本意。我相信您可以预测接下来我要说什么:删除参数需要几个步骤。当然,首先要弃用“turbo”参数。我喜欢像这样的技术,它可以检测任何用户的代码是否依赖于此参数。

# Your library code.
_turbo_default = object()

def move(direction,
         mode='slither',
         turbo=_turbo_default,
         extra_sinuous=False,
         hail_lyft=False):
    if turbo is not _turbo_default:
        warnings.warn(
            "'turbo' is deprecated",
            DeprecationWarning,
            stacklevel=2)
    else:
        # The old default.
        turbo = False

但是您的用户可能不会注意到警告。警告不是很响亮:它们可能会被抑制或丢失在日志文件中。用户可能会不注意地升级到您库的下一个主要版本,即删除“turbo”的版本。他们的代码将无错误地运行,并且静默地做错事!正如 Python 之禅所说,“错误永远不应悄无声息地传递。”的确,爬行动物听力很差,因此当它们犯错时,您必须非常大声地纠正它们。

Woman riding an alligator

保护用户的最佳方法是使用 Python 3 的星号语法,该语法要求调用者传递关键字参数。

# Your library code.
# All arguments after "*" must be passed by keyword.
def move(direction,
         *,
         mode='slither',
         turbo=False,
         extra_sinuous=False,
         hail_lyft=False):
    # ...

# A user's application, poorly-written.
# Error! Can't use positional args, keyword args required.
move('north', 'slither', False, True)

有了星号,这是唯一允许的语法

# A user's application.
move('north', extra_sinuous=True)

现在,当您删除“turbo”时,您可以确定任何依赖它的用户代码都会响亮地失败。如果您的库也支持 Python 2,那也没什么可耻的;您可以像这样模拟星号语法(感谢 Brett Slatkin

# Your library code, Python 2 compatible.
def move(direction, **kwargs):
    mode = kwargs.pop('mode', 'slither')
    turbo = kwargs.pop('turbo', False)
    sinuous = kwargs.pop('extra_sinuous', False)
    lyft = kwargs.pop('hail_lyft', False)

    if kwargs:
        raise TypeError('Unexpected kwargs: %r'
                        % kwargs)

    # ...

要求关键字参数是一个明智的选择,但这需要远见。如果您允许以位置方式传递参数,则以后无法将其转换为仅关键字参数的版本。所以,现在就添加星号。您可以在 asyncio API 中观察到它在构造函数、方法和函数中普遍使用星号。即使“Lock”到目前为止只接受一个可选参数,asyncio 开发人员也立即添加了星号。这是天意。

# In asyncio.
class Lock:
    def __init__(self, *, loop=None):
        # ...

现在我们已经获得了智慧,可以在更改方法和参数的同时遵守与用户的约定。现在是尝试最具挑战性的进化的时候了:在不更改方法或参数的情况下更改行为。

更改行为

假设您的生物是一条响尾蛇,您想教给它一种新的行为。

Rattlesnake

侧向移动!生物的身体看起来会一样,但它的行为会改变。我们如何为它的进化步骤做好准备?

负责任的创建者可以从 Python 标准库中的以下示例中学习,当时行为发生了变化,但没有新的函数或参数。很久以前,引入了 os.stat 函数来获取文件统计信息,例如创建时间。起初,时间总是整数。

>>> os.stat('file.txt').st_ctime
1540817862

有一天,核心开发人员决定对 os.stat 时间使用浮点数,以提供亚秒级精度。但他们担心现有的用户代码尚未准备好进行更改。他们在 Python 2.3 中创建了一个设置“stat_float_times”,默认情况下为 false。用户可以将其设置为 True 以选择加入浮点时间戳。

>>> # Python 2.3.
>>> os.stat_float_times(True)
>>> os.stat('file.txt').st_ctime
1540817862.598021

从 Python 2.5 开始,浮点时间成为默认值,因此为 2.5 及更高版本编写的任何新代码都可以忽略该设置并期望浮点数。当然,您可以将其设置为 False 以保留旧行为,或将其设置为 True 以确保所有 Python 版本中的新行为,并为 stat_float_times 被删除的那一天做好准备。

岁月流逝。在 Python 3.1 中,弃用了该设置,以便人们为遥远的未来做好准备,最后,经过数十年的历程,该设置被删除。浮点时间现在是唯一的选择。这是一条漫长的道路,但负责任的神灵是耐心的,因为我们知道这种渐进的过程很有可能使用户免受意外的行为更改。

第十条约定:逐步更改行为

以下是步骤

  • 添加一个标志以选择加入新行为,默认为 False,如果为 False 则发出警告
  • 将默认值更改为 True,完全弃用标志
  • 删除标志

如果您遵循语义化版本控制,则版本可能如下所示

库版本 库 API 用户代码
1.0 无标志 期望旧行为
1.1 添加标志,默认为 False,

如果为 False 则发出警告
设置标志为 True,

处理新行为
2.0 将默认值更改为 True,

完全弃用标志
处理新行为
3.0 删除标志 处理新行为

您需要两个主要版本才能完成此操作。如果您直接从“添加标志,默认为 False,如果为 False 则发出警告”转到“删除标志”,而没有中间版本,则用户的代码将无法升级。为 1.1 正确编写的用户代码,它将标志设置为 True 并处理新行为,必须能够升级到下一个版本,而不会产生不良影响,除了新的警告,但如果在下一个版本中删除了该标志,则该代码将崩溃。负责任的神灵永远不会违反 Twisted 策略:“第一个总是免费的。”

负责任的创造者

Demeter

我们的 10 条约定大致属于三个类别

谨慎进化

  1. 避免糟糕的功能
  2. 最小化功能
  3. 保持功能狭窄
  4. 将实验性功能标记为“临时的”
  5. 温和地删除功能

严格记录历史

  1. 维护变更日志
  2. 选择版本方案
  3. 编写升级指南

缓慢而响亮地改变

  1. 兼容地添加参数
  2. 逐步更改行为

如果您与您的生物保持这些约定,您将成为一位负责任的创造神。您的生物的身体可以随着时间的推移而进化,永远改进并适应环境的变化,但不会发生生物措手不及的突然变化。如果您维护一个库,请向您的用户信守这些承诺,您就可以创新您的库,而不会破坏依赖您的人们的代码。


本文最初出现在 A. Jesse Jiryu Davis 的博客 上,并经许可转载。

插图鸣谢

标签
User profile image.
我是纽约市 MongoDB 的一名 staff engineer。我编写了 Motor,即异步 MongoDB Python 驱动程序,并且我是 MongoDB C 驱动程序的首席开发人员。我为 PyMongo、asyncio、Python 和 Tornado 做出贡献。我在国际摄影中心学习,并在 Village Zendo 练习。

评论已关闭。

© . All rights reserved.