可维护的Python代码库的7条规则

2023-09-04 12:55:18 浏览数 (1)

作者:Niels Cautaerts 编辑:@公众号:数据STUDIO

Python是一种出色的编程语言。凭借其易读的语法和庞大的库生态系统,Python可用于构建从小型脚本到机器学习项目再到生产级网络平台的任何内容。对于编程新手来说,Python很容易成为他们的第一门语言,而对于经验丰富的老手来说,Python的强大功能足以提高他们的工作效率。

尽管Python很受欢迎,但它也饱受争议,尤其是那些精通多种编程语言的人。而大多数有效的批评是对Python是它的灵活性也是它的弱点,尤其是对于成长中的项目。争论的关键在于Python给了开发者太多的自由。自由度对于快速创建一个可运行的程序来说是很好的,但对于长期维护和改进项目来说却是噩梦。

实际上,Python很容易产生 "技术债务"。通常情况下,开发者快速创建大量的工作代码,但这些代码可能会很脆弱、结构不良、没有文档。在某些时候,代码库会变得混乱,以至于在不破坏其他东西的情况下进行有效增减有些不可能;这些债务必须在以后通过重构来偿还。

随着时间的推移,一些渐进的重构是不可避免的,但是通过遵循这些原则,我们可以在一个更稳定的基础上编写 Python 代码:

目录

  1. 编写尽可能多的单元测试
  2. 使用类型注解和静态类型检查
  3. 使用自动格式化工具
  4. 最小化继承,最大化组合
  5. 尽可能选择不变性
  6. 尽可能选择纯函数
  7. 仅在有充分理由的情况下打破清洁代码规则

请注意,这些要点大多降低了灵活性,增加了开发工作量。而如果我们的目标是创建可持续增长的代码,就必须做出这样的权衡。

1.尽可能多的编写单元测试

这可以说是所有规则中最重要的一条。

测试非常重要,因为有效地记录了代码可以做什么以及它的预期行为是什么。如果无法验证代码是否产生了预期的输出,就无法放心地进行修改。测试是团队成员之间的契约:对代码的所有修改都必须通过测试

反之,对测试的修改会透明地表明代码行为的破坏性变化。测试也可以作为入职团队成员的一个重要起点,通过简单的示例熟悉各种组件应该如何使用。

以下是一个测试方法;这可能不是最好的方法。你可以选择接受或拒绝,找到适合你的策略:

  • 首先编写代码。实验阶段的 "测试" 通常是临时性和交互式的,推荐使用 jupyter 笔记本来迭代功能片段。
  • 当你有了一个看起来很稳定并且能按预期工作的类或函数时,为如何与它的公共API交互设计多种策略。对于函数来说,这只是考虑输入参数的组合(参见第6条)。对于对象和类,由于对象的内部状态,这可能会更复杂(参见第6条),但理想情况下,所有的公共方法都要经过测试。
  • 大致按照以下模式为每个函数和面向公共的方法编写单元测试:
代码语言:javascript复制
import pytest
from mypackage import my_function, MyClass

@pytest.mark.parametrize(
    "arg1, arg2, expected",
    [
      ("val1_1", "val1_2", "expected1"),  # different argument combinations
      ("val2_1", "val2_2", "expected2"),
    ],
)
def test_my_function(arg1, arg2, expected):
    output = my_function(arg1, arg2)
    assert output == expected  # 在numpy数组或pandas数据帧的情况下
                # 你可能需要另一种比较方法


# 在testclass中对方法进行分组测试并不是绝对必要的
def TestMyClass:
    my_object = MyClass()    

    def test_method_1(self):
        expected = "expected output 1"
        assert my_object.method_1() == expected

    def test_method_2(self):
        expected = "expected output 2"
        assert my_object.method_2() == expected

单元测试测试单个函数和方法,并验证输出或效果。测试应尽可能相互独立,以便测试失败能迅速引导开发人员找到根本原因。

  • 使用 pytest[1] 测试 Python。
  • 不要纠结是单元测试还是集成测试。专注于编写能够减少不确定性并增加对代码各组成部分信心的测试,无论是高层还是低层。
  • 不要追求100%的测试覆盖率;这是不切实际的,也不足以证明代码是正确的。在测试中,要专注于制定策略,智能地采样输入组合和状态空间。

在 Python 中,公有属性和私有属性之间没有区别,但习惯上以 _ 开头的属性是私有的。我们假定这些属性是类的隐藏内部实现的一部分,因此应该避免用单元测试来测试它们。

总结

测试并不能证明你的软件是有效的,而且编写测试也不是一件愉快的事情。而忽略测试是灾难的根源。测试应该被看作是经验证据,在此基础上你可以对代码的状态进行归纳推理。测试对于一个团队在同一个代码库上工作以及新开发人员的入职至关重要。未经测试的代码可能会变异,也可能被完全废弃。

2.使用类型注解和静态类型检查

Python 是一种动态类型的编程语言,这意味着你不必声明变量或函数参数的类型。这对于灵活性来说是非常好的,因为你可以为任何类型的对象重用一个函数。你可以非常快速地创建可以运行的函数。

代码语言:javascript复制
def add(a, b):
    return a   b

可以接受整数、浮点数或任何实现特殊 __add__ 方法的对象。参数 a 和 b 甚至不必是相同的类型。例如,我们可以添加一个常量到一个 numpy 数组中;Python 处理了所有的复杂性。

然而,随着代码库的增长,由于以下原因,没有声明的变量类型会成为一个问题:

  • 加入代码库的新贡献者(或很久没有接触过代码的人)必须花费大量时间弄清楚这些部分是如何组合在一起的。函数可以接受哪些输入?如果函数输出一个自定义类,应该如何处理?
  • 在 Python 这样的动态类型解释型语言中,每增加一行代码,遇到运行时错误的几率就会增加。对于未键入的代码,在运行之前无法推理其正确性。函数接收的参数是否合理?输入参数的任何组合都有效吗?Python 允许你做任何事情,直到它遇到不知道该做什么的情况;只有在这种情况下,它才会抛出异常。而这种bug的麻烦在于,可能需要很长时间才会出现。

因此,声明类型有两个重要目的:最小化文档和在运行前捕获大量常见错误。

即使你不使用静态代码分析工具,在代码中添加类型提示也会对将来的贡献者有所帮助。以下面的函数文档为例:

代码语言:javascript复制
def connect_to_server(host, config):
    ...

该函数的作者可能清楚地知道参数 hostconfig 的含义,但第一次登陆这个代码库的其他同事却不知道。他们现在必须做现花时间来弄清这个函数是如何工作的,要么通过解释函数本身来推断参数的类型,要么在代码中寻找函数被调用的地方,看看它是如何被使用的。

这还只是一个函数,想象一下,在一个庞大的代码库中,相互关联的类和函数分布在多个模块中。要想知道所有的东西是如何组合在一起的,难度可想而知。假设现在的函数签名是这样的

代码语言:javascript复制
def connect_to_server(
    host: str, 
    config: ConnectionConfig,
) -> Session:
    ...

在不深入实现函数的情况下,如果需要找出使用该函数的基本方法,则需要查看 ConnectionConfig 和 Session 类,但至少函数文档揭示了他们应该查看的地方。

在每个新函数和类上都强制执行标准格式的文档说明,还是很有必要的:

代码语言:javascript复制
def connect_to_server(host, config):
    """连接到远程服务器

    Parameters
    ----------
    host: str
        要连接的主机
    config: ConnectionConfig
        要传递给会话的配置

    Returns
    -------
    session: Session
        会话
    """
    ...

这种方法的优点是这些文档可以被Sphinx[2]解析,用于自动生成文档。这些文档可以提供更多的信息,包括函数做了什么,参数是什么,输出是什么。长文档的缺点是编写和维护起来更加麻烦,而且不能用于静态类型分析来自动检查代码的正确性。尽管如此,对于面向用户的函数,拥有描述性的文档还是很重要的。在这种情况下,可以将两者的优点结合起来,使用混合方法:

代码语言:javascript复制
def connect_to_server(
    host: str, 
    config: ConnectionConfig,
) -> Session:
    """连接到远程服务器

    Parameters
    ----------
    host
        要连接的主机
    config
        要传递给会话的配置

    Returns
    -------
    session
        会话
    """
    ...

到目前为止,我们只讨论了容易被开发者忽略的作为开发者文档的类型提示。像Mypy[3]这样的静态类型检查工具旨在将类型提示作为定义正确代码的规则来执行。Mypy查看所有源代码文件,检查类型规则是否满足要求。例如,传递给函数的所有参数类型是否与函数签名兼容?对象上调用的方法是否真正定义?

Mypy不会运行你的代码,所以它不会捕获所有的运行时bug。但是类型相关的bug是非常常见的,或者经常导致其他bug。因此,确保你的代码遵循类型规则将大大减少运行时bug的数量,并且它可以帮助你更快地捕获那些仍然发生的错误。就像单元测试一样,静态类型检查增加了对代码正确性的信心。

当你开始使用mypy,或者当你尝试将一个未类型化的代码库转换为类型化的代码库时,你会遇到很多bug。在尝试解决这些bug的过程中,你会发现代码中的一些模式非常适合静态类型检查,而另一些模式则根本不起作用。后一类情况通常是反模式,所以mypy通常会指导你重构代码,使其更简单、更好地实现。

你可以把mypy作为一个独立的程序来使用,但是更舒适的使用方法是把它作为一个插件嵌入到你的编辑器中,这样你在写代码的时候就可以得到实时的反馈。至于如何使用,可以自行搜索。

总结

类型提示作为最小的文档,可以帮助其他开发人员快速理解你的代码。使用这些类型提示和静态类型检查可以大大减少你遇到的运行时bug的数量。它还可以指导你采用更好的设计模式。

3.使用自动格式化

代码的格式化与代码的功能无关,但对于代码数量的增长仍然非常重要。代码数量的增长是通过人们添加和插入新代码来实现的。需要能够阅读和理解现有的代码,规范代码的格式有助于团队中的所有开发人员阅读代码。

Python 已经有了关于如何格式化代码的标准:PEP 8[4]。许多工具检查是否符合 PEP 8,比如 flake8[5]ruff[6]。也有自动格式化代码的工具,最流行的可能是 Black[7]。虽然Black并不总是100%符合PEP 8,但它通常能够提供足够好的格式,其便利性胜过一切。另一个是isort[8],它可以在每个模块的开头根据 PEP 8 规则自动组织导入。

一个小提示:你可以通过如何编写原始代码来引导自动格式化的行为。就我个人而言,我更喜欢将代码分成许多小行。我特别喜欢用这种方式列出函数参数及其类型,就像下面这样:

代码语言:javascript复制
def connect_to_server(
    host: str, 
    config: ConnectionConfig,
) -> Session:

而不是这样:

代码语言:javascript复制
def connect_to_server(host: str, config: ConnectionConfig) -> Session:

对于只有两个参数的函数来说,这并不会产生很大的影响,但如果你有十个参数,多行格式就会突出其优势了。Black有时会尝试删除多行格式,但可以在最后一个选项中添加逗号来强制保留它。

总结

使用像Black这样的自动格式化工具,使你的代码具有可读性和统一性。这不需要花费时间或精力。还可以将其作为你的预提交[9]钩子的一部分,这样你的代码就会在你提交时自动格式化。

4.最小化继承,最大化组合

虽然 Python 是一种多范式的语言,但它是面向对象编程 OOP。Python的OOP实现的问题在于它非常灵活,而很容易适得其反,就像下面的例子:

代码语言:javascript复制
class BaseClass:
    def __init__(self, c):
        self.b = self.a
        self.initialize_c(c)

    def do_something(self, arg1):
        return self.do_something_else(arg1)

    def foo(self, arg1, arg2, arg3):
        return arg1, arg2, arg3

    def initialize_c(self, c):
        self.c = c

class ChildClass(BaseClass):
    a = "a variable"

    def __init__(self):
        super().__init__(7)
        
    def do_something_else(self, arg1):
        return self.c, self.b, arg1

    def foo(self, arg1):
        return arg1
    
if __name__ == "__main__":
    child = ChildClass()
    print(child.do_something(5))  # -> (7, 'a variable', 5)
    print(child.foo(5))  # -> 5
    print(child.foo(5, 4, 3))  # -> error, overridden

这是完全正确的 Python,但我希望大多数读者都能感觉到一些严重的代码问题。我们看到的是

  • 在基类中引用仅在子类中定义或实现的属性和方法。
  • 重载子类中具有不同的方法。

这种方法有什么问题?

  • 目前还不清楚子类中应该实现什么,不应该实现什么。这只能通过研究父类和一些子类示例来回答。反之,也可能不清楚为什么子类具有某些只在基类中使用的属性。子类中的ado_something_else有什么意义?
  • 由于基类中的(部分)实现,不清楚子类在任何时候的状态。如果我只看子类,我怎么知道self.c存在?它来自哪里?在这个例子中,所有的东西都是在构造函数中实例化的,但我也见过有些状态是在某个事件发生之后才被初始化的;这可能会更加困惑人。
  • 如果我将BaseClass子类的实例传递到另一个函数中,我怎么知道它们的行为是否良好?假设其他函数调用foo,它应该如何处理不同数量的参数?通常情况下,可以使用*args**kwargs来解决这个问题。

该示例甚至没有涉及多重继承的问题,在多重继承中,你必须搜索多个父类以了解实现细节,或者在不同的父类中可能存在相同方法的不同冲突实现。即使是单继承,对代码进行推理也很困难。像这样的问题:“**一个类有什么属性,一个对象有什么状态,这种状态将来会如何演变?**”的问题很难用多层继承和示例中的“弹性”代码来回答。

继承的主要缺点是紧密耦合,所有子类都被同一个基类绑在一起。这就使得改变基类并非易事。反之,基类可能对某些子类限制过多,久而久之,就会产生像上例这样的问题。

那么为什么要做继承呢?我们看到一个方法在几个类中重复,将这个方法分解到一个基类中,这样就完成了!

在 Python 中,你可以很容易地破坏继承,但最终代码仍然可以正常工作,这可能是它如此流行的主要原因;它是去除重复元素的最直接的方法。然而,在规则 2 中建议使用 mypy,它会不兼容的重载[10]

可以尝试用组合[11](表示 "有" 关系)代替继承(表示 "是" 关系)。一般来说,这意味着共享行为被分解为不同的类,这些类的实例被其他类引用。我们将共享结构定义(抽象)和共享实现(代码重用)分开,而不是让一个基类同时定义结构和部分实现。

通过继承,我们实现了抽象,并从基类派生出多个子类。至少在代码遵循Liskov替换原则[12]的情况下(上面的代码没有遵循该原则[13]),可以将每个子类替换为期望基类的方法。

另一方面,对于组成,如果子类不继承自基类,我们如何表示它们之间的相似性呢?在无类型的 Python 中,不需要做任何事情,因为可以传递任何对象到任何地方。但是如果使用静态类型检查,最好的做法是定义接口。接口定义了最小的属性和方法,这些属性和方法必须在类上实现才能被认为是一个子类型。下面的接口与上例中的BaseClass派生类兼容:

代码语言:javascript复制
from __future__ import annotations
from typing import Protocol, Tuple, TypeVar

T = TypeVar("T")

class Interface(Protocol):
    a: str

    def do_something(self, arg1: int) -> Tuple[int, str, int]:
        ...

    def foo(self, *args: T) -> Tuple[T, ...]:
        ...

接口不应该有实现;实现由子类决定。当一个类至少实现了接口中的所有方法并具有所有属性时,类型检查程序自动将其视为一个子类型。定义接口的优点如下:

  • 在不深入研究实现细节的情况下,所有团队成员都知道一个类应该能够做什么,以及它的最小外部API是什么。这对学习如何使用类和找出如何实现子类型非常有利。如果没有静态类型检查,接口就是类的文档(就像类型提示是方法的文档一样);有了静态类型检查,接口就是可执行的契约。
  • 不必从接口继承,即一个类可以同时遵循多个接口。这完全符合作为 Python 核心思想的鸭子类型[14]的精神。例如
代码语言:javascript复制
from __future__ import annotations
from typing import Protocol

class CanFly(Protocol):
    def fly(self) -> None:
        ...

class CanSwim(Protocol):
    def swim(self) -> None:
        ...

class Duck:
    def fly(self) -> None:
        print("The duck took to the sky!")

    def swim(self) -> None:
        print("The duck swam across the pond!")

class Whale:
    def swim(self) -> None:
        print("The whale swam across the ocean!")

def make_swim(animal: CanSwim) -> None:
    animal.swim()

def make_fly(animal: CanFly) -> None:
    animal.fly()

if __name__ == "__main__":
    duck = Duck()
    whale = Whale()
    make_swim(duck)  # ok
    make_swim(whale)  # ok
    make_fly(duck)  # ok
    make_fly(whale)  # mypy complains

这种 "基于特质 "的多态性很难通过继承来实现,这也是Protocol比abc模块中的ABC抽象类更适合定义接口的原因之一。接口必须通过Protocol的继承来声明,这有点讽刺。

如果遵守接口不能包含任何实现的规则,那么仍然没有解决代码重用的问题。为此,需要将可重用的实现分离到自己的类中,然后通过依赖注入的方式在必要的地方使用这些类。请看下面的示例,这是对上面代码的一种可能的重构:

在软件工程中,依赖注入是一种编程技术,其中一个对象或函数接收它所依赖的其他对象或函数。该模式确保了希望使用给定服务的对象或函数不必知道如何构建这些服务。

代码语言:javascript复制
from __future__ import annotations
from typing import Protocol, Tuple, TypeVar
from dataclasses import dataclass

T = TypeVar("T")

class Interface(Protocol):
    a: str

    def do_something(self, arg1: int) -> Tuple[int, str, int]:
        ...

    def foo(self, *args: T) -> Tuple[T, ...]:
        ...

class Behavior(Protocol):
    b: str
    c: int

    def do_something_else(self, arg: int) -> Tuple[int, str, int]:
        ...

    def foo(self, *args: T) -> Tuple[T, ...]:
        ...

@dataclass
class Behavior1:
    b: str
    c: int

    def do_something_else(self, arg: int) -> Tuple[int, str, int]:
        return self.c, self.b, arg

    def foo(self, *args: T) -> Tuple[T]:
        assert len(args) >= 1
        return (args[0],)


@dataclass
class Behavior2:
    b: str
    c: int
    variable: int

    def do_something_else(self, arg: int) -> Tuple[int, str, int]:
        return self.c   self.variable, self.b, arg

    def foo(self, *args: T) -> Tuple[T, T, T]:
        assert len(args) >= 3
        return (args[0], args[1], args[2])


class MyClass:
    a = "a variable"

    def __init__(self, behavior: Behavior) -> None:
        self.__behavior = behavior

    @classmethod
    def with_behavior1(cls) -> MyClass:
        return cls(Behavior1(cls.a, 7))

    @classmethod
    def with_behavior2(cls) -> MyClass:
        return cls(Behavior2(cls.a, 7, 4))

    @property
    def behavior(self) -> Behavior:
        return self.__behavior

    def do_something(self, arg1: int) -> Tuple[int, str, int]:
        return self.behavior.do_something_else(arg1)

    def foo(self, *args: T) -> Tuple[T, ...]:
        return self.behavior.foo(*args)


def make_it_do_something(obj: Interface) -> None:
    print(obj.do_something(5))


def make_it_foo(obj: Interface, *args: T) -> None:
    print(obj.foo(*args))


if __name__ == "__main__":
    obj_1 = MyClass.with_behavior1()
    obj_2 = MyClass.with_behavior2()
    make_it_do_something(obj_1)  # -> (7, 'a variable', 5)
    make_it_do_something(obj_2)  # -> (11, 'a variable', 5)
    make_it_foo(obj_1, 5)  # -> (5,)
    make_it_foo(obj_2, 5, 4, 3)  # -> (5, 4, 3)

这里的关键是将实现逻辑拆分成行为类。对于这些类,我们也定义了MyClass期望的接口。在构造函数中传递不同的Behaviors可以改变类的行为,同时保持与Interface兼容。为了方便起见,我们定义了两个工厂方法来实例化具有不同行为的MyClass。我们取得了什么成果?

  • 任何类或对象中都不再有 "隐藏 "的行为或状态。
  • 只要符合行为接口,任何人都可以创建新的行为实现。由于在代码中明确定义了接口,因此很清楚最低要求是什么。同样,对于符合 Interface.

实际上,我们已经尝试将我们所有的类解耦,这将使我们更容易推理代码库,并使其更灵活地进行更改,同时保持严格的静态类型规则。在foo方法中仍然存在不理想的黑客行为,它通过*args接受任意数量的参数,尽管Behavior的实现并不这样做。

Mypy并没有抱怨这些方法签名,但它使得应该提供给foo的参数数量变得模糊不清。更好的方法是重新考虑foo方法,并考虑是否有可能将参数分组为某种类型的集合对象。

这种重构看起来很可笑。我们增加了大量的模板,而回报却是微乎其微。只有当代码基数增加,模板/实现比率降低时,重构的价值才会真正显现出来。由于我们必须多次明确定义封装方法,因此组合可能总是会带来更多的模板。然而或许可以从面向继承的范例中移除大多数子类,并将它们浓缩为单个类;然后通过将每个实例引用到其他对象来实现自定义行为。这就自然而然地引出了单一责任原则[15]

继承是罪过吗?不,对于正确的问题,这可能是正确的答案。但我建议你在确定继承之前考虑一下所有其他的选择。你当然不需要它:在 Rust 中,没有类,也没有继承,但是类型系统仍然允许通过特质边界来实现灵活的多态性。然而,避免继承违背了许多 Python 程序员的本能,这就是为什么许多人会抵制这条规则。无论你做什么,你都应该遵守 Liskov 替换原则。但是当在 Python 中使用继承时,打破这条规则可能很诱人,也很容易。静态类型检查将引导你采用可接受的架构。

总结

继承本质上并不坏,但在 Python 中,它很容易产生反模式。尽量避免使用继承,而使用组合。在不得不多写一些模板的代价下,你将创建更可维护、更松耦合、更易于多人协作的代码。

5.尽可能选择不变性

这一条与规则 1、规则 2 和规则 4 相联系。Python 可以在运行时改变任何东西。这对于推理程序的状态和设计良好的单元测试是有问题的。它削弱了类型提示的有效性。它意味着当你使用依赖注入将一个对象传递到另一个对象的方法时,可以做任何类型的隐藏状态突变。

像 Rust 这样的编程语言非常重视这个问题,它们默认所有的数据都是不可变的;你必须显式地添加 mutor & mut 关键字来允许变量或函数参数被变异。不幸的是,这在 Python 中并不存在,我们必须满足于下面的选项:

  • 在变量类型为T的变量上使用Final[T]类型提示,只要该变量不能再被变异。静态类型检查应该可以剔除违规行为。然而,当T是一个可变类型(如list或dictionary)时,这个方法似乎不起作用;它不能处理内部可变性。
  • 如果你需要传递数据集合,请使用默认情况下不可变的数据类型(如元组),而不是可变的数据类型(如列表)。同样,也可以使用NamedTuple来替代dict。你还可以研究一下数据类型,并选择将其冻结。

总结

尽可能选择不可变性。这样可以更容易地推理出程序的可能状态。

6.尽可能选择纯函数

纯函数的一大优势在于它们是幂等的。它们的输出只取决于输入,而不是某些隐藏状态。相同的输入将始终产生相同的输出。对于测试来说,这是一个很好的特性。对于逻辑推理来说,这也是一个很好的特性。它意味着函数签名几乎可以给我们提供函数的所有相关信息。

类中定义的方法很容易被滥用来产生副作用。它们执行一些计算,返回一些东西,但同时,它们修改了类中的一些东西。这违反了 Clean Code 规则,但在 Python 中仍然很容易做到。如果对象的内部状态被用于方法中,那么函数将不是幂等的。这使得单元测试更具挑战性。

纯函数的缺点是有些函数需要很多输入参数。这意味着我们最终可能会得到一个带有很长输入参数列表的大函数签名。将参数分组到某个集合类中可以避免这种情况,但这样就必须编写逻辑来实例化参数对象。尽管如此,长长的输入参数列表仍然难以推理。

用 Python 创建纯函数有两种方法:

  • 在类之外定义。
  • 在类中定义方法为classmethod或staticmethod。

在 Python 中,也许仍然有办法通过修改 globals 字典来使这些函数不纯,但这无异于故意自取灭亡。

总结

与依赖于方法外部定义的状态的方法相比,纯函数非常容易测试。只要有可能,就选择纯函数,但要权衡需要的参数数量。

7.只在有充分理由的情况下才打破干净代码规则

编写可读性和可维护性代码的技巧有很多,我不可能在此一一列举。在Robert C. Martin所著的《*Clean Code*[16]》这本广受欢迎的书中可以找到一些技巧。这本书非常面向Java,但许多经验可以用于任何OOP语言。对于没有耐心的人来说,可以在 GitHub Gist[17] 中找到一份很好的规则总结。

有些规则很容易纳入你的工作流程(例如如何命名事物),而另一些则较难,需要实践(例如如何重构和重组代码)。你可能会发现有些规则比其他规则更有价值,而有些重构示例则非常荒谬。就我个人而言,我发现有些规则会让代码变得更糟。总的来说,它仍然可以作为如何编写团队工作良好的代码的灵感来源。

我们有理由打破《Clean Code》的规则。抽象会带来性能代价,在某些情况下,这是不能容忍的。不过对于Python来说,这很少有意义,因为与编译语言相比,Python本身的性能很差;额外的函数调用不会带来显著的差别。如果性能是个问题,那么很可能只有一小部分代码是主要瓶颈。因此应该集中精力优化这部分代码的性能。

总结

要写好代码,还有很多事情要做。但不要将《Clean Code》理解为法律规则,需要保持务实,权衡利弊。

参考资料

[1]

pytest: https://docs.pytest.org/en/7.2.x/

[2]

Sphinx: https://www.sphinx-doc.org/en/master/

[3]

Mypy: https://www.mypy-lang.org/

[4]

PEP 8: https://pep8.org/

[5]

flake8: https://flake8.pycqa.org/en/latest/

[6]

ruff: https://beta.ruff.rs/docs/

[7]

Black: https://black.readthedocs.io/en/stable/index.html

[8]

isort: https://pycqa.github.io/isort/

[9]

预提交: https://pre-commit.com/

[10]

不兼容的重载: https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides

[11]

组合: https://en.wikipedia.org/wiki/Composition_over_inheritance

[12]

Liskov替换原则: https://en.wikipedia.org/wiki/Liskov_substitution_principle

[13]

原则: https://en.wikipedia.org/wiki/Liskov_substitution_principle

[14]

鸭子类型: https://en.wikipedia.org/wiki/Duck_typing

[15]

单一责任原则: https://en.wikipedia.org/wiki/Single-responsibility_principle

[16]

Clean Code: https://www.oreilly.com/library/view/clean-code-a/9780136083238/

[17]

GitHub Gist: https://gist.github.com/wojteklu/73c6914cc446146b8b533c0988cf8d29

0 人点赞