目录
设计建议
保持 I/O 分离
科学代码重用面临的最大障碍之一是,当 I/O 代码(假设某些文件位置、名称、格式或布局)与科学逻辑交织在一起时。
与 I/O 相关的函数仅应执行 I/O。例如,它们应该接收一个文件路径并返回一个 NumPy 数组,或一个包含数组和元数据的字典。有价值的科学逻辑应编码在接收标准数据类型并返回标准数据类型的函数中。这使得它们更容易测试,在数据格式发生变化时更容易维护,或在意外的应用程序中重用。
鸭子类型是个好主意
鸭子类型 根据对象可以做什么来对待对象,而不是根据它们的类型是什么。“如果它走起来像鸭子,叫起来像鸭子,那么它一定是鸭子。”
Python,特别是科学 Python,利用接口(也称为协议)来支持互操作性和重用。例如,可以将 Pandas DataFrame 传递给numpy.sum
函数,即使 Pandas 是在numpy.sum
之后创建的。这是因为numpy.sum
避免假设它将传递特定数据类型;它接受任何提供正确方法(接口)的对象。在可能的情况下,避免在代码中使用isinstance
检查,并尝试使您的函数适用于尽可能广泛的输入类型。
考虑:这是否可以只是一个函数?
并非所有内容都需要面向对象。面向对象设计需要遵循与其他代码相同的原则,例如模块化,并且需要经过充分测试。如果您能够使用函数处理现有的数据结构(如 DataFrame),请这样做。
最好有 100 个函数操作一个数据结构,而不是 10 个函数操作 10 个数据结构。
– 来自 ACM 的 SIGPLAN 出版物(1982 年 9 月),耶鲁大学 Alan J. Perlis 的文章“编程中的格言”。
一个很受欢迎的演讲,“停止编写类”说明了一些看起来适合面向对象编程的情况,使用函数处理起来要简单得多。在不需要时使用面向对象设计的最大危险如下:更改状态。
避免更改状态
通常会想发明一个自定义类来表达工作流程,如下所示。
data = Data()
data.load_data()
data.prepare()
data.do_calculations()
data.plot()
这很容易且简单。除非您忘记了一步。哦,是的,静态分析工具无法告诉您是否忘记了一步,API 不会静态地“知道”例如.prepare()
是必需的。制表符补全会告诉您.plot()
立即有效。根本问题是Data
具有隐式更改状态,并且并非所有操作在所有可能的状态下都是有效的。
一种替代方案是用多个表示每个步骤状态的不可变类替换Data
。
empty_data = EmptyData()
loaded_data = empty_data.load_data()
prepared_data = loaded_data.prepare()
computed_data = prepared_data.do_calculations()
computed_data.plot()
我们也可以避免命名临时变量
computed_data = EmptyData().load_data().prepare().do_calculations()
computed_data.plot()
这些类不必是不可变的。也许您可以将更多数据加载到已加载的数据中。但是,当它们至少避免使可用操作(即方法)的子集无效的突变状态时,它们更容易正确使用。请注意,在这种情况下,制表符补全每次都会显示允许的操作集。
考虑:我是否真的需要一个自定义类?
使用内置 Python 类型(int
、float
、str
)和标准科学 Python 类型(如 NumPy 数组和 Pandas DataFrame)使代码具有互操作性。它使数据能够在不同的库之间平滑地流动,并能够以意想不到的方式扩展。
例如,广泛使用的库 scikit-image 最初尝试使用Image
类,但最终决定最好使用普通的 NumPy 数组。所有科学 Python 库都理解 NumPy 数组,但它们不理解自定义类,因此最好将特定于应用程序的元数据与标准数组一起传递,而不是尝试将所有这些信息封装在一个新的、定制的对象中。(现代 NumPy 使用协议使这种类型的使用变得更加容易)。
当您想出于方便将数据组合到一个对象中时,请考虑使用数据类。
from dataclasses import dataclass
@dataclass
class Data:
angle: float
temperature: float
count: int
静态类型很冗长,但使代码更易读
带有静态类型的代码有很多额外的字符,但它为读者提供了更多信息;timestamp: int
或timestamp: float
为读者提供了关于可能难以仅从变量名推断出的类型的宝贵信息,从而了解什么有效以及什么无效。它还可以由静态类型检查器(如 MyPy)验证,因此它更有可能比文档字符串中的类型正确。
还有另一个好处:如果您在设计代码时考虑了类型,您将倾向于更简单、更少动态的设计,并具有明确定义的预期用法。您会记住(好吧,至少您更有可能记住)处理特殊情况,例如列表与字符串或也可能是None
的值。
使用静态类型时,鸭子类型通过协议来表达。如果可能,应强烈优先于旧的解决方案(如继承或 ABC),因为它们以牺牲少量额外代码为代价来消除对象之间的依赖关系。
宽容并不总是方便的
过于宽容的代码会导致非常令人困惑的错误。如果您需要一个灵活的面向用户的接口,该接口通过猜测用户的需求来尝试“做正确的事情”,请将其分成两层:一层薄薄的“友好”层位于“暴躁”层的顶部,后者仅接收它需要的内容并执行实际工作。暴躁层应该易于测试;它应该对其接受的内容和返回的内容有约束。这种分层设计使得能够编写具有不同观点和不同默认值的许多友好层。
如有疑问,请使函数参数成为必需参数。可选参数难以发现,并且可能会隐藏用户应该知道他们正在做出的重要选择。
异常应该直接引发:不要捕获它们并打印。异常是用于明确代码需要什么并让调用者决定如何处理它的工具。应用程序代码(例如 GUI)应该捕获并处理错误以避免崩溃,但库代码通常应该引发错误,除非它确定用户或调用者希望如何处理它们。
编写有用的错误消息
具体点。包括错误的值是什么,错误在哪里,以及如何修复它。例如,如果代码未能找到它需要的一个文件,它应该说明它在寻找什么以及在哪里查找。
编写易读的代码
除非您正在编写一个计划在明天或下周删除的脚本,否则您的代码可能被读取的次数远远多于被编写的次数。而今天的“临时解决方案”往往会变成明天的关键代码。因此,应优先考虑清晰度而不是简洁性,使用描述性和一致的名称。
复杂度总是守恒的
复杂度总是守恒的,并且严格大于代码正在建模的系统。试图向用户隐藏复杂度常常适得其反。
例如,通常会想隐藏函数中某些重复使用的关键字,将此
def get_image(
filename: Path,
normalize: bool = True,
beginning: int = 0,
end: int | None = None,
) -> np.ndarray: ...
简化为
def get_image(filename: Path, **kwargs: Any) -> np.ndarray: ...
虽然接口似乎通过隐藏的关键字参数得到了简化,但现在用户需要记住kwargs
是什么,或者深入研究文档以更好地了解如何使用它们。您还会失去静态类型。
由于新的科学是在旧的思想以意想不到的方式重新应用或扩展时产生的,因此科学代码不应掩盖其复杂性或过度优化特定用例。它应该直接地暴露存在的复杂性。
更好的是,您应该考虑使用 Python 3 中引入的“仅限关键字”参数,这要求用户通过关键字而不是位置传递参数。
def get_image(
filename: Path,
*,
normalize: bool = True,
beginning: int = 0,
end: int | None = None
) -> np.ndarray: ...
在*
之后的每个参数都是仅限关键字的。因此,用法get_image('thing.png', False)
将不被允许;调用者必须显式地键入get_image('thing.png', normalize=False)
。后者更易于阅读,并且它使作者能够插入其他参数而不会破坏向后兼容性。
类似地,编写一个执行多个步骤并具有许多选项的函数,而不是编写多个执行单个步骤并具有少量选项的函数,可能会很诱人。 “多个小函数”的优势会随着时间的推移而显现。
- 小函数更容易解释和记录,因为它们的行为范围明确。
- 小函数可以单独测试,并且很容易看出哪些路径已经测试过,哪些路径尚未测试过。
- 如果函数的行为定义明确且范围紧密,则更容易将该函数与其他函数组合并以意想不到的方式重用。这就是Unix 哲学,“做好一件事”。
- 参数之间可能的交互次数随着参数数量的增加而增加,这使得函数难以推理和测试。特别是,应避免含义取决于其他参数的参数。
函数应该无论其参数(尤其是可选参数)是什么,都返回相同类型的东西。违反“返回类型稳定性”会给函数的调用者带来负担,现在调用者必须了解函数的内部细节才能知道对于任何给定的输入期望什么类型。这使得函数更难以记录、测试和使用。Python 不强制执行返回类型稳定性,但我们应该尽量做到这一点。如果你的函数根据其输入返回不同类型的东西,则表示应将其重构为多个函数。
Python 非常灵活。它可以适应许多可能的设计选择。通过对科学 Python 生态系统进行一些约束和一致性,Python 可以用于构建经久耐用且随着时间推移而不断发展的科学工具。