Python 入门教程、代码格式和工具

在本章中,我们将探讨与干净代码相关的第一个概念,从它是什么以及它的含义开始。本章的主要目标是理解干净的代码不仅仅是一件好东西,也不仅仅是软件项目中的奢侈品。这是必要的。如果没有质量代码,项目将面临由于技术债务累积而失败的风险(技术债务是我们将在本章后面详细讨论的内容,因此如果您以前没有听说过这个术语,请不要担心)。

同样,但更为详细的是格式化和记录代码的概念。这些可能听起来像是多余的需求或任务,但我们将再次发现,它们在保持代码库的可维护性和可操作性方面起着根本性的作用。

我们将分析为该项目采用良好编码准则的重要性。意识到维护代码与引用的一致性是一项持续的任务,我们将看到如何从自动化工具中获得帮助,从而简化我们的工作。因此,我们将讨论如何配置作为构建的一部分自动在项目上运行的工具。

本章的目标是了解什么是干净的代码,为什么它很重要,为什么格式化和记录代码是至关重要的任务,以及如何自动化此过程。由此,您应该获得快速组织新项目结构的心态,以获得良好的代码质量。

阅读本章后,您将学到以下内容:

我们将首先了解什么是干净的代码,以及为什么这对于软件工程项目的成功非常重要。在前两部分中,我们将了解保持良好的代码质量对于高效工作是多么重要。

然后,我们将讨论这些规则的一些例外情况:也就是说,在这种情况下,不重构代码来偿还所有技术债务甚至可能具有成本效益。毕竟,我们不能简单地期望一般规则适用于所有地方,因为我们知道有例外。这里重要的一点是要正确理解为什么我们愿意破例,并正确识别这些情况。我们不想误导自己,认为有些东西不应该改进,而事实上应该改进。

清洁代码的含义

对清洁代码没有唯一或严格的定义。此外,可能没有正式衡量干净代码的方法,因此您无法在存储库上运行一个工具来告诉您代码的好坏或可维护性。当然,您可以运行诸如checkerslintersstatic analyzers等工具,这些工具非常有用。它们是必要的,但还不够。干净的代码不是机器或脚本可以识别的东西(到目前为止),而是我们作为专业人员可以决定的东西。

几十年来,我们一直认为编程语言是用来将我们的想法传达给机器,让机器运行我们的程序。我们错了。这不是事实,而是事实的一部分。“编程语言”中“语言”部分的真正含义是将我们的想法传达给其他开发人员。

这就是干净代码的真正本质所在。这取决于其他工程师是否能够阅读和维护代码。因此,我们作为专业人士,是唯一能够判断这一点的人。想想看;作为开发人员,我们花在阅读代码上的时间比实际编写代码要多得多。每当我们想要进行更改或添加新功能时,我们首先必须阅读需要修改或扩展的代码的所有环境。语言(Python)是我们用来相互交流的语言。

因此,与其给你一个干净代码的定义(或我的定义),我请你通读这本书,阅读所有关于惯用 Python 的内容,看看好代码和坏代码的区别,找出好代码和好架构的特点,然后提出你自己的定义。阅读本书后,您将能够自己判断和分析代码,并且您将对干净的代码有更清晰的理解。你会知道它是什么,它意味着什么,不管给你什么定义。

拥有干净代码的重要性

干净的代码之所以重要,原因有很多。其中大多数都围绕着可维护性、减少技术债务、有效地进行敏捷开发以及管理成功的项目等理念展开。

我想探讨的第一个想法是关于敏捷开发和持续交付。如果我们希望我们的项目能够以稳定和可预测的速度持续成功地交付特性,那么必须有一个良好且可维护的代码库。

想象一下,在某个时间点,你正驾驶着一辆汽车在公路上驶向你想要到达的目的地。你必须估计你到达的时间,这样你才能告诉等待你的人。如果这辆车运转良好,道路平坦完美,那么我不明白你为什么会大幅度地错过你的估计。然而,如果道路状况不佳,你必须走出去将岩石移开,或者避免裂缝,每隔几公里停下来检查发动机,那么你就不太可能确定何时到达(或者是否到达)。我认为类比是清楚的;道路就是密码。如果您想以稳定、恒定和可预测的速度移动,代码需要可维护和可读。如果不是,每次产品管理部门要求一个新功能时,您都必须停止重构并修复技术债务。

技术债务是指由于做出妥协或错误决策而导致软件出现问题的概念。可以从两个方面考虑技术债务。从现在到过去:如果我们目前面临的问题是以前编写的错误代码的结果,该怎么办?而且,从现在到未来:如果我们现在决定走捷径,而不是把时间花在一个适当的解决方案上,我们会给自己制造什么问题?

“债务”这个词是个不错的选择。这是一笔债务,因为未来修改代码比现在更难。发生的成本就是债务的利息。招致技术债务意味着明天,代码将比今天更难修改,成本更高(甚至可以衡量这一点),第二天甚至更高,以此类推。

每当团队不能按时交付一些东西,不得不停下来修复和重构代码时,它就要付出技术债务的代价。

有人甚至可以说,一个拥有技术债务代码库的团队并没有进行敏捷软件开发。因为,敏捷的反面是什么?固执的如果代码充满了代码气味,那么它就不容易更改,因此团队无法快速响应需求的更改并持续交付。

关于技术债务,最糟糕的是它代表了一个长期的潜在问题。这并不是引起恐慌的事情。相反,这是一个无声的问题,分散在项目的各个部分,有一天,在某个特定的时间,会醒来,成为一个表演的阻碍。

在一些更令人担忧的案例中,“技术债务”甚至是轻描淡写,因为问题更严重。在前面的几段中,我提到了技术债务会使团队在未来更加困难的场景,但是如果现实更加危险呢?想象一下,使用一个使代码处于脆弱状态的快捷方式(一个简单的示例可能是函数中导致内存泄漏的可变默认参数,我们将在后面的章节中看到)。您可以部署您的代码,并且它在相当长的一段时间内都可以正常工作(只要该缺陷没有表现出来)。但这实际上是一个等待发生的崩溃:有一天,在最不经意的时候,代码中的某个条件将被满足,这将导致应用程序出现运行时问题,就像代码中的定时炸弹在随机时间爆炸一样。

我们显然希望避免上述情况。并不是所有的东西都能被自动化工具捕获,但只要有可能,这是一项很好的投资。其余的依赖于良好、彻底的代码审查和良好的自动化测试。

软件只有在易于更改的程度上才有用。想想看。我们创建软件以满足某些需求(无论是购买机票、在线购物还是听音乐,仅举几个例子)。这些需求很少被冻结,这意味着一旦导致编写该软件的上下文中的某些内容发生变化,就必须立即更新该软件。如果代码无法更改(我们知道现实确实发生了变化),那么代码就没用了。拥有一个干净的代码库是修改它的绝对要求,因此干净代码的重要性。

一些例外

在上一节中,我们探讨了干净的代码库在软件项目成功中所起的关键作用。也就是说,请记住,这是一本面向实践者的书,因此务实的读者可能会正确地指出,这回避了一个问题:“这有合法的例外吗?”

当然,如果这本书不允许读者质疑它的一些假设,它就不会是一本真正实用的书。

事实上,在某些情况下,您可能希望考虑放宽一些拥有原始代码库的限制。以下列出了可能需要跳过某些质量检查的情况(并非详尽无遗):

  • 哈卡通
  • 如果您正在为一次性任务编写简单脚本
  • 代码竞赛
  • 在开发概念验证时
  • 在开发原型时(只要你确保它确实是一个会被扔掉的原型)
  • 当您正在处理一个将被弃用的遗留项目,并且它仅在一段固定的、短期的时间内处于维护模式时(同样,如果这是有保证的话)

在这些情况下,常识适用。例如,如果您刚刚到达一个项目,该项目将只在未来几个月内运行,直到它退役,那么可能不值得费尽心机修复所有继承的技术债务,等待它被归档可能是一个更好的选择。

请注意,这些示例都有一个共同点,即它们假设代码可以不按照高质量标准编写,这也是我们永远不必再看的代码。这与之前公开的内容是一致的,可以被认为是我们最初前提的反建议:我们编写干净的代码是因为我们希望实现高可维护性。如果不需要维护该代码,那么我们可以跳过在其上维护高质量标准的工作。

记住,我们编写干净的代码是为了维护一个项目。这意味着将来能够自己修改代码,或者,如果我们要将代码的所有权转移给公司的另一个团队,则可以使这种转移(以及未来维护人员的生活)更容易。这意味着,如果一个项目只处于维护模式,但它不会被弃用,那么它可能仍然是一个很好的投资来偿还它的技术债务。这是因为在某个时刻(通常是在最不期望的时候),将有一个 bug 必须被修复,这将有利于代码尽可能具有可读性。

干净的代码只是关于格式化和构造代码吗?简而言之,答案是否定的。

有一些编码标准如 PEP-8(https://www.python.org/dev/peps/pep-0008/ )说明代码应如何编写和格式化。在 Python 中,PEP-8 是最著名的标准,该文档提供了关于如何编写程序的指南,包括间距、命名约定、行长等。

然而,干净的代码远远超出了编码标准、格式、线头工具和其他关于代码布局的检查。干净的代码是关于实现高质量的软件和构建一个健壮且可维护的系统。一段代码或整个软件组件可能 100%符合 PEP-8(或任何其他指南),但仍然不能满足这些要求。

尽管格式化不是我们的主要目标,但不注意代码结构也有一些危险。因此,我们将首先分析错误代码结构的问题以及如何解决这些问题。之后,我们将看到如何为 Python 项目配置和使用工具来自动检查最常见的问题。

总之,我们可以说干净的代码与 PEP-8 或编码风格无关。它远远不止于此,而且对于代码的可维护性和软件的质量更有意义。但是,正如我们将看到的,正确格式化代码对于高效工作非常重要。

遵守项目的编码样式指南

编码指南是一项最低要求,必须考虑根据质量标准开发项目。在本节中,我们将探讨这背后的原因。在下面的部分中,我们可以开始研究通过使用工具自动执行此操作的方法。

当我试图在代码布局中找到好的特性时,首先想到的是一致性。我希望代码的结构保持一致,以便易于阅读和遵循。如果代码不正确,结构也不一致,并且团队中的每个人都以自己的方式做事,那么我们最终将得到需要额外努力和专注才能理解的代码。它很容易出错,容易引起误解,错误或微妙之处可能很容易被忽略。

我们希望避免这种情况。我们想要的正是与我们一眼就能快速阅读和理解的代码相反的东西。

如果开发团队的所有成员都同意一种标准化的代码结构方式,那么生成的代码看起来会更加熟悉。因此,您将快速识别模式(稍后会有更多关于这方面的信息),并且记住这些模式,将更容易理解事情和检测错误。例如,当某件事情不对劲时,你会注意到,不知何故,在你习惯看到的模式中有一些奇怪的东西,这会引起你的注意。你将仔细观察,你很可能会发现错误!

正如经典著作代码完成中所述,在题为国际象棋认知(1973)的论文中对此进行了有趣的分析,其中进行了一项实验,以确定不同的人如何理解或记忆不同的国际象棋位置。该实验是在所有级别(新手、中级和象棋大师)以及棋盘上不同棋位的棋手身上进行的。他们发现,当位置是随机的,新手做得和象棋大师一样好;这只是一个记忆练习,任何人都可以在相当水平上完成。当位置遵循真实游戏中可能出现的逻辑顺序(同样,一致性,遵循模式)时,国际象棋大师们的表现要比其他人好得多。

现在想象一下,同样的情况也适用于软件。作为 Python 的软件工程师专家,我们就像前面示例中的象棋大师一样。如果代码是随机构造的,没有遵循任何逻辑,也没有遵循任何标准,那么我们就很难像新手一样发现错误。另一方面,如果我们习惯于以结构化的方式阅读代码,并且我们已经学会了通过遵循模式从代码中快速获得想法,那么我们将处于相当大的优势。

特别是对于 Python,您应该遵循的编码风格是 PEP-8。您可以根据您正在处理的项目的特殊性(例如,行的长度、关于字符串的注释等)对其进行扩展或采用其某些部分。

如果你意识到你正在做的项目不符合任何编码标准,那么在代码库中推动采用 PEP-8。理想情况下,应该为您所在的公司或团队准备一份书面文档,解释预期要遵循的编码标准。这些编码准则可以是 PEP-8 的改编。

如果您注意到您的团队中没有与代码风格保持一致,并且在代码审查期间对此进行了多次讨论,那么重新审视编码指南并投资于自动验证工具可能是一个好主意。

特别是,PEP-8 涉及了一些你不想在项目中错过的质量特征的要点;其中包括:

  • Searchability: This refers to the ability to identify tokens in the code at a glance; that is, to search in certain files (and in which part of those files) for the particular string we are looking for. One key point of PEP-8 is that it differentiates the way of writing the assignment of values to variables, from the keyword arguments being passed to functions. To see this better, let's use an example. Let's say we are debugging, and we need to find where the value to a parameter named location is being passed. We can run the following grep command, and the result will tell us the file and the line we are looking for:

    $ grep -nr "location=" . 
    ./core.py:13: location=current_location, 

    现在,我们想知道这个变量被分配这个值的位置,下面的命令也将提供我们要查找的信息:

    $ grep -nr "location =" .
    ./core.py:10: current_location = get_location() 

    PEP-8 建立了这样一种约定:当通过关键字向函数传递参数时,我们不使用空格,但在为变量设置值时使用空格。因此,我们可以调整搜索条件(在第一个示例中,=周围没有空格,在第二个示例中有一个空格),从而提高搜索效率。这是遵守公约的好处之一。

  • 一致性:如果代码有统一的格式,那么读取起来就容易多了。如果您希望欢迎新的开发人员加入您的项目,或者甚至在您的团队中雇佣新的(可能是经验较少的)程序员,并且他们需要熟悉代码(甚至可能包含多个存储库),那么这对于新员工来说尤其重要。如果他们在所有存储库中打开的所有文件中的代码布局、文档、命名约定等都是相同的,那么他们的生活就会轻松得多。

  • 更好的错误处理:PEP-8 中提出的建议之一是将try/except块中的代码量限制在尽可能小的范围内。这减少了错误表面,从某种意义上说,它减少了意外吞咽异常和掩盖错误的可能性。可以说,这可能很难通过自动检查来实现,但在执行代码检查时,还是值得注意的。

  • 代码质量:通过以结构化的方式查看代码,您将更加熟练地一目了然地理解代码(同样,如国际象棋中的感知),并且您将更容易发现错误和错误。除此之外,检查代码质量的工具也会提示潜在的 bug。对代码的静态分析可能有助于降低每行代码中的 bug 比率。

正如我在引言中提到的,格式化是干净代码的必要部分,但它并没有到此为止。还有更多需要考虑的因素,比如在代码中记录设计决策,以及尽可能使用工具来利用自动质量检查。在下一节中,我们从第一节开始。

本节介绍如何从代码中用 Python 编写代码文档。好的代码是不言自明的,但也有很好的文档记录。解释它应该做什么(而不是如何做)是个好主意。

一个重要的区别是:记录代码与添加注释不同。本节将探讨 docstring 和注释,因为它们是 Python 中用于记录代码的工具。也就是说,作为插入语,我将简要地讨论代码注释的主题,只是为了确定一些要点,以便更清楚地区分。

代码文档在 Python 中非常重要,因为它是动态类型的,很容易在函数和方法中丢失变量或对象的值。因此,说明这些信息将使未来的代码读者更容易阅读。

还有另一个与注释相关的原因。它们还可以通过mypy等工具帮助运行一些自动检查,例如类型提示 http://mypy-lang.org/pytypehttps://google.github.io/pytype/ )。我们将发现,最终,添加注释是值得的。

代码注释

作为的一般规则,我们应该尽可能减少代码注释。这是因为我们的代码应该是自文档化的。这意味着,如果我们努力使用正确的抽象(比如将代码中的职责划分为有意义的函数或对象),并且我们清楚地命名事物,那么就不需要注释。

在编写注释之前,请尝试查看是否可以仅使用代码(即,通过添加新函数或使用更好的变量名)来表达相同的含义。

本书中关于注释的观点与软件工程的其他文献非常一致:代码中的注释是我们无法正确表达代码的症状。

然而,在某些情况下,不可能避免在代码中添加注释,不这样做是危险的。通常情况下,代码中的某些内容必须针对特定的技术细微差别进行处理,乍一看并非微不足道(例如,如果底层外部函数中存在错误,我们需要传递一个特殊参数以避免此问题)。在这种情况下,我们的任务是尽可能简洁,以最好的方式解释问题是什么,以及为什么我们在代码中采用这种特定路径,以便读者能够理解情况。

最后,在代码中还有另一种注释是绝对不好的,而且根本没有办法证明它是正确的:注释掉的代码。必须毫不留情地删除此代码。请记住,代码是开发人员之间的通信语言,是设计的最终表达。代码就是知识。注释掉的代码带来混乱(很可能是矛盾),会污染知识。

特别是现在,对于现代版本控制系统,没有什么好的理由可以忽略那些可以简单删除(或隐藏在其他地方)的注释代码。

总而言之:代码注释是邪恶的。有时这是一种必要的邪恶,但无论如何,我们应该尽量避免。另一方面,关于代码的文档则有所不同。这是指在代码本身中记录设计或体系结构,以使其清晰明了,这是一种积极的力量(也是下一节的主题,我们在其中讨论 docstring)。

文件串

简单来说,我们可以说 docstring 是嵌入在源代码中的文档docstring基本上是一个文本字符串,放在代码的某个地方,用于记录该部分逻辑。

请注意对单词文档的强调。这一点很重要,因为它代表的是解释,而不是理由。文档字符串不是注释;它们是文档。

docstring 旨在为代码中的特定组件(amoduleclassmethodfunction提供文档,这些文档对其他开发人员有用。这个想法是,当其他工程师想要使用您正在编写的组件时,他们很可能会查看文档字符串,以了解它应该如何工作,预期的输入和输出是什么,等等。因此,尽可能添加 docstring 是一种好的做法。

文档字符串对于文档设计和体系结构决策也很有用。将 docstring 添加到最重要的 Python 模块、函数和类中可能是一个好主意,以便向读者提示该组件如何适合整个体系结构。

之所以它们是代码中的好东西(或者甚至是必需的,取决于项目的标准),是因为 Python 是动态类型的。这意味着,例如,函数可以将任何内容作为其任何参数的值。Python 不会强制执行或检查类似的内容。因此,假设您在代码中找到了一个您知道必须修改的函数。幸运的是,函数有一个描述性名称,其参数也有描述性名称。您应该传递给它的类型可能仍然不太清楚。即使是这样,他们将如何使用?

这里是一个好的文档字符串可能会有所帮助的地方。记录函数的预期输入和输出是一种很好的实践,它将帮助函数的读者理解函数应该如何工作。

要运行以下代码,您将需要一个IPythonhttps://ipython.org/ )根据本书要求设置 Python 版本的交互式 shell。如果您没有IPythonshell,您仍然可以在正常的Python shell中运行相同的命令,方法是将<function>??替换为help(<function>)

从标准库中考虑这个好例子:

Type: method_descriptor 

这里,字典上的update方法的 docstring 为我们提供了有用的信息,它告诉我们我们可以以不同的方式使用它:

  1. 我们可以使用.keys()方法传递某个内容(例如,另一个字典),它将使用根据参数

    >>> d = {}
    >>> d.update({1: "one", 2: "two"})
    >>> d
    {1: "one", 2: 'two'} 

    传递的对象的键更新原始字典

  2. 我们可以传递一组对的键和值,并将它们解包到update

    >>> d.update([(3, "three"), (4, "four")])
    >>> d
    {1: 'one', 2: 'two', 3: 'three', 4: 'four'} 
  3. 它还告诉我们,我们可以使用关键字参数中的值更新字典:

    >>> d.update(five=5)
    >>> d
    {1: 'one', 2: 'two', 3: 'three', 4: 'four', 'five': 5} 

(请注意,在此表单中,关键字参数是字符串,因此我们不能在表单5="five"中设置某些内容,因为它可能不正确。)

对于想要学习和理解新函数如何工作以及如何利用它的人来说,这些信息是至关重要的。

请注意,在第一个示例中,我们使用函数上的双问号(dict.update??获得了函数的 docstring。这是IPython交互式口译员(的一个特点 https://ipython.org/ )。调用此函数时,它将打印所需对象的 docstring。现在,想象一下,以同样的方式,我们从标准库的这个函数中获得了帮助;如果您将 docstring 放在您编写的函数上,以便其他人能够以相同的方式理解它们的工作,那么您可以让读者(代码的用户)的生活变得容易多少呢?

docstring 不是与代码分离或隔离的东西。它成为代码的一部分,您可以访问它。当一个对象定义了 docstring 时,它通过其__doc__属性成为该对象的一部分:

>>> def my_function():
        """Run some computation"""
        return None
     ...
>>> my_function.__doc__  # or help(my_function)
 'Run some computation' 

这意味着甚至可以在运行时访问它,甚至可以从源代码生成或编译文档。事实上,有一些工具可以做到这一点。如果您运行Sphinx,它将为您的项目文档创建基本框架。特别是使用autodoc扩展名(sphinx.ext.autodoc),该工具将从代码中获取 docstring,并将它们放在记录函数的页面中。

一旦有了构建文档的工具,就将其公开,使其成为项目本身的一部分。对于开源项目,您可以使用read the docshttps://readthedocs.org/ ),将根据分支机构或版本自动生成文档(可配置)。对于公司或项目,您可以使用相同的工具,也可以在本地配置这些服务,但不管做出何种决定,重要的是,文档应该准备好并可供团队的所有成员使用。

不幸的是,docstring 有一个缺点,那就是,正如所有文档一样,它需要手动和持续维护。随着代码的更改,必须对其进行更新。另一个问题是,为了使 docstring 真正有用,它们必须是详细的,这需要多行。考虑到这两个因素,如果您正在编写的函数非常简单,并且是自解释的,那么最好避免添加以后需要维护的冗余 docstring。

维护正确的文档是我们无法逃避的软件工程挑战。这样做也是有道理的。如果您仔细想想,手动编写文档的原因是因为它是供其他人阅读的。如果它是自动化的,它可能不会有多大用处。为了使文档具有任何价值,团队中的每个人都必须同意这是需要人工干预的事情,因此需要付出努力。关键是要理解软件不仅仅是代码。随附的文档也是可交付成果的一部分。因此,当有人对函数进行更改时,同样重要的是将文档的相应部分更新为刚刚更改的代码,而不管它是 wiki、用户手册、README文件还是几个 docstring。

注释

PEP-3107 引入了注释的概念。它们的基本思想是向代码读者提示函数中参数的值。提示一词的使用并非随意;注释支持类型暗示,在第一次介绍注释之后,我们将在本章后面讨论。

注释允许您指定已定义的某些变量的预期类型。事实上,它不仅与类型有关,还与任何类型的元数据有关,这些元数据可以帮助您更好地了解该变量实际代表的内容。

考虑下面的例子:

@dataclass
class Point
    lat: float
    long: float

def locate(latitude: float, longitude: float) -> Point:
    """Find an object in the map by its coordinates""" 

这里,我们使用float来表示latitudelongitude的预期类型。这只是为函数的读者提供信息,以便他们能够了解这些预期类型。Python 不会检查或强制执行这些类型。

我们还可以指定函数返回值的预期类型。在本例中,Point是一个用户定义的类,因此这意味着返回的任何内容都将是Point的实例。

然而,类型或内置并不是我们可以用作注释的唯一类型。基本上,在当前 Python 解释器范围内有效的所有内容都可以放在那里。例如,解释变量意图的字符串、用作回调或验证函数的可调用函数,等等。

我们可以利用注释使代码更具表现力。考虑下面的示例,该函数应该启动一个任务,但也接受一个参数来推迟执行:

def launch_task(delay_in_seconds):
    ... 

在这里,论点delay_in_seconds的名称似乎相当冗长,但尽管如此,它仍然没有提供太多信息。什么构成秒的可接受良好值?它考虑分数吗?

我们在代码中回答这些问题怎么样?

Seconds = float
def launch_task(delay: Seconds):
    ... 

现在,代码本身就说明了问题。此外,我们可以说,随着Seconds注释的引入,我们围绕如何在代码中解释时间创建了一个小的抽象,我们可以在代码库的更多部分重用这个抽象。如果我们以后决定在几秒钟内更改底层抽象(假设从现在开始,只允许整数),我们可以在一个地方进行更改。

随着注释的引入,还包含了一个新的特殊属性,即__annotations__。这将使我们能够访问一个字典,该字典将注释的名称(作为字典中的键)与其相应的值(即我们为注释定义的值)进行映射。在我们的示例中,如下所示:

>>> locate.__annotations__
{'latitude': <class 'float'>, 'longitude': <class 'float'>, 'return': <class 'Point'>} 

如果我们认为有必要,我们可以使用它来生成文档、运行验证或在代码中强制执行检查。

说到通过注释检查代码,这就是 PEP-484 发挥作用的时候。本 PEP 规定了类型暗示的基础;通过注释检查函数类型的想法。再次澄清一下,并引用 PEP-484 本身:

“Python 将仍然是一种动态类型化语言,即使按照惯例,作者也不希望强制使用类型提示。”

类型提示的思想是使用额外的工具(独立于解释器)来检查代码中类型的正确使用,并在检测到任何不兼容时向用户提示。有一些有用的工具可以对数据类型以及它们在代码中的使用情况进行检查,以发现潜在的问题。一些示例工具,如mypypytype工具部分中有更详细的说明,我们将在这里讨论如何为项目使用和配置工具。现在,您可以将其视为一种检查代码中使用的类型语义的 linter。因此,最好在项目上配置mypypytype,并在与静态分析的其他工具相同的级别上使用它。

然而,类型暗示不仅仅意味着检查代码中类型的工具。根据前面的示例,我们可以在代码中为类型创建有意义的名称和抽象。考虑一个处理客户端列表的函数的下面的情况。在其最简单的形式中,只需使用通用列表即可对其进行注释:

def process_clients(clients: list):
    ... 

如果我们知道在当前的数据建模中,客户机表示为整数和文本的元组,那么我们可以添加更多的细节:

def process_clients(clients: list[tuple[int, str]]):
    ... 

但这仍然没有给我们提供足够的信息,因此最好是明确的,并为别名命名,这样我们就不必推断该类型的含义:

from typing import Tuple
Client = Tuple[int, str]
def process_clients(clients: list[Client]):
    ... 

在这种情况下,含义更加明确,它支持不断演变的数据类型。也许元组是适合于正确表示客户机问题的最小数据结构,但稍后,我们将希望为另一个对象更改元组或创建特定类。在这种情况下,注释将保持正确,所有其他类型验证也将保持正确。

这背后的基本思想是,现在语义扩展到更有意义的概念,使我们(人类)更容易理解代码的含义,或者在给定的点上期望什么。

注释带来了一个额外的好处。随着 PEP-526 和 PEP-557 的引入,有了一种以紧凑的方式编写类和定义小容器对象的方便方法。其思想是只在类中声明属性,并使用注释来设置它们的类型,在@dataclass装饰器的帮助下,它们将作为实例属性处理,而无需在__init__方法中显式声明并为其设置值:

from dataclasses import dataclass
@dataclass
class Point:
    lat: float
    long: float 
>>> Point.__annotations__
{'lat': <class 'float'>, 'long': <class 'float'>}
>>> Point(1, 2)
Point(lat=1, long=2) 

在本书的后面,我们将探讨注释的其他重要用途,更多地涉及代码的设计。当我们探索面向对象设计的良好实践时,我们可能希望使用依赖注入之类的概念,在这些概念中,我们将代码设计为依赖于声明契约的接口。声明代码依赖于特定接口的最好方法可能是使用注释。更重要的是,有一些工具专门利用 Python 注释来自动提供对依赖项注入的支持。

在设计模式中,我们通常还希望将部分代码与特定实现分离,并依赖抽象接口或契约,以使代码更灵活和可扩展。此外,设计模式通常通过创建所需的适当抽象来解决问题(这通常意味着拥有封装部分逻辑的新类)。在这两种情况下,注释我们的代码将有额外的帮助。

注释是否替换文档字符串?

这是一个有效的问题,因为在较早的 Python 版本中,早在引入注释之前,记录函数或属性的参数类型的方法就是在其上放置 docstring。对于如何构造 docstring 以包含函数基本信息的格式,甚至有一些约定,包括每个参数的类型和含义、返回值以及函数可能引发的异常。

其中大部分已经通过注释以一种更简洁的方式解决了,因此人们可能会想,是否也值得拥有 docstring。答案是肯定的,这是因为它们相辅相成。

的确,docstring 中先前包含的一部分信息现在可以移动到注释中(不再需要指出 docstring 中的参数类型,因为我们可以使用注释)。但这只会为文档字符串上更好的文档留下更多空间。特别是对于动态和嵌套数据类型,最好提供预期数据的示例,以便更好地了解所处理的内容。

考虑下面的例子。假设我们有一个函数,它期望字典验证某些数据:

def data_from_response(response: dict) -> dict:
    if response["status"] != 200:
        raise ValueError
    return {"data": response["payload"]} 

在这里,我们可以看到一个函数,它接受一个字典并返回另一个字典。如果键"status"下的值不是预期值,则可能引发异常。然而,我们没有更多关于它的信息。例如,response对象的正确实例是什么样的?result的实例是什么样的?为了回答这两个问题,最好记录预期由参数传入并由该函数返回的数据示例。

让我们看看是否可以借助 docstring 更好地解释这一点:

def data_from_response(response: dict) -> dict:
    """If the response is OK, return its payload.

    - response: A dict like::

    {
        "status": 200, # <int>
        "timestamp": "....", # ISO format string of the current
        date time
        "payload": { ... } # dict with the returned data
    }

    - Returns a dictionary like::

    {"data": { .. } }

    - Raises:
    - ValueError if the HTTP status is != 200
    """
    if response["status"] != 200:
        raise ValueError
    return {"data": response["payload"]} 

现在,我们对该函数预期接收和返回的内容有了更好的了解。文档作为有价值的输入,不仅有助于理解和了解传递的内容,而且也是单元测试的重要来源。我们可以导出这样的数据作为输入,我们知道在测试中使用的正确值和错误值。实际上,这些测试也可以作为我们代码的可操作文档,但这将在本书后面进行更详细的解释。

这样做的好处是,现在我们知道了键的可能值以及它们的类型,并且我们对数据的外观有了更具体的解释。成本是,正如我们前面提到的,它占用了很多行,并且需要冗长和详细才能有效。

在本节中,我们将探讨如何配置一些基本工具并自动运行代码检查,目的是利用部分重复验证检查。

这一点很重要:请记住,代码是供我们大家理解的,因此只有我们才能确定代码的好坏。我们应该在代码审查上投入时间,思考什么是好代码,以及它的可读性和可理解性。当查看由同行编写的代码时,您应该提出以下问题:

  • 这段代码是否易于理解,并易于其他程序员理解?
  • 它是否涉及问题的领域?
  • 新加入团队的人是否能够理解它,并有效地与之合作?

正如我们前面看到的,代码格式、一致的布局和适当的缩进是必需的,但在代码库中还不足以具备这些特性。此外,作为具有高度质量意识的工程师,这些都是理所当然的事情,因此我们会阅读和编写远远超出其布局基本概念的代码。因此,我们不愿意浪费时间审查这类项目,因此我们可以通过查看代码中的实际模式来更有效地投入时间,以便理解其真正含义并提供有价值的结果。

所有这些检查都应该自动化。它们应该是测试或检查表的一部分,而这反过来应该是持续集成构建的一部分。如果这些检查未通过,则使生成失败。这是确保代码结构始终保持连续性的唯一方法。它还可以作为团队的客观参数,作为参考。与其让一些工程师或团队负责人在代码评审时总是指出关于 PEP-8 的相同评论,构建将自动失败,使其成为客观的东西。

本节介绍的工具将让您了解可以自动对代码执行的检查。这些工具应该强制执行一些标准。一般来说,它们是可配置的,每个存储库都有自己的配置是非常好的。

使用工具的想法是有一种可重复的自动方式来运行某些检查。这意味着每个工程师都应该能够在其本地开发环境中运行这些工具,并与团队中的任何其他成员获得相同的结果。此外,这些工具应配置为持续集成(CI构建的一部分。

检查类型一致性

类型一致性是我们希望自动检查的主要内容之一。Python 是动态类型化的,但我们仍然可以添加类型注释,以提示读者(和工具)代码不同部分中的预期内容。尽管注释是可选的,正如我们所看到的,添加注释是一个好主意,不仅因为它使代码更可读,而且因为我们可以使用注释和一些工具来自动检查一些最可能是 bug 的常见错误。

自从 Python 中引入类型暗示以来,已经开发了许多用于检查类型一致性的工具。在这一节中,我们来看看其中的两个:mypyhttps://github.com/python/mypy 、及pytypehttps://github.com/google/pytype 。有多种工具,您甚至可以选择使用不同的工具,但一般来说,无论使用哪种工具,都适用相同的原则:重要的是要有一种自动验证更改的方法,并将这些验证添加到 CI 构建中。mypy是 Python 中可选静态类型检查的主要工具。其思想是,一旦安装它,它将分析项目中的所有文件,检查类型使用中的不一致性。这是很有用的,因为在大多数情况下,它会尽早检测到实际的 bug,但有时它会给出误报。

您可以使用pip进行安装,建议将其作为项目的依赖项包含在安装文件中:

$ pip install mypy 

一旦它安装在虚拟环境中,您只需运行前面的命令,它就会报告类型检查的所有结果。尽量遵守其报告,因为在大多数情况下,it 提供的见解有助于避免可能滑入生产的错误。但是,该工具并不完美,因此如果您认为它报告的是假阳性,您可以忽略该行,并使用以下标记作为注释:

type_to_ignore = "something" # type: ignore 

需要注意的是,为了使这个工具或任何工具有用,我们必须小心使用代码中声明的类型注释。如果我们对类型集过于泛化,我们可能会错过一些工具可以报告合法问题的情况。

在下面的示例中,有一个函数用于接收要迭代的参数。最初,任何 iterable 都可以工作,因此我们希望利用 Python 的动态类型功能,并允许使用传递列表、元组、字典键、集合或几乎任何支持for循环的函数:

def broadcast_notification(
    message: str, 
    relevant_user_emails: Iterable[str]
):
    for email in relevant_user_emails:
        logger.info("Sending %r to %r", message, email) 

问题是,如果代码的某些部分错误地传递了这些参数,mypy不会报告错误:

broadcast_notification("welcome", "user1@domain.com") 

当然,这不是一个有效的实例,因为它将迭代字符串中的每个字符,并尝试将其用作电子邮件。

相反,如果我们对该参数设置的类型更严格(比如只接受字符串的列表或元组),那么运行mypy确实会识别出这个错误场景:

$ mypy <file-name>
error: Argument 2 to "broadcast_notification" has incompatible type "str"; expected "Union[List[str], Tuple[str]]" 

类似地,pytype也是可配置的,并且以类似的方式工作,因此您可以根据项目的特定上下文调整这两个工具。我们可以看到此工具报告的错误与前一个案例非常相似:

File "...", line 22, in <module>: Function broadcast_notification was called with the wrong arguments [wrong-arg-types]
         Expected: (message, relevant_user_emails: Union[List[str], Tuple[str]])
  Actually passed: (message, relevant_user_emails: str) 

pytype的一个关键区别在于,它不仅会根据参数检查定义,还会尝试解释运行时的代码是否正确,并报告哪些是运行时错误。例如,如果暂时违反了其中一个类型定义,只要最终结果符合声明的类型,就不会认为这是一个问题。虽然这是一个很好的特性,但总的来说,我建议您不要破坏代码中设置的不变量,并尽可能避免中间无效状态,因为这将使您的代码更容易推理,并且依赖的副作用更少。

代码中的泛型验证

除了使用上一节介绍的工具之外,为了检查我们程序的类型管理错误,我们可以使用其他工具,根据更广泛的参数范围进行验证。

Python 中有许多用于检查代码结构的工具(基本上,这符合 PEP-8),例如pycodestyle(以前在PyPi中称为pep8)、flake8等等。它们都是可配置的,并且与运行它们提供的命令一样易于使用。

这些工具是运行在一组 Python 文件上的程序,用于检查代码是否符合 PEP-8 标准,并报告每一行违反的内容和违反规则的指示错误。

还有其他一些工具可以提供更完整的检查,因此它们不仅可以验证 PEP-8 的符合性,还包括对超过 PEP-8 的更复杂情况的额外检查(请记住,代码仍然可以完全符合 PEP-8,并且质量仍然不高)。

例如,PEP-8 主要是关于代码的样式化和结构化,但它并不强制我们在每个public methodclassmodule上放置一个文档字符串。它也没有提到需要太多参数的函数(我们将在本书后面的部分中指出这是一个不好的特性)。

这种工具的一个例子是pylint。这是用于验证 Python 项目的最完整、最严格的工具之一,而且也是可配置的。如前所述,要使用它,您只需在虚拟环境中使用pip进行安装即可:

$ pip install pylint 

然后,只要运行pylint命令就足以在代码中检查它。

可以通过名为pylintrc的配置文件配置pylint。在此文件中,您可以决定要启用或禁用的规则,并对其他规则进行参数化(例如,更改列的最大长度)。例如,正如我们刚才讨论的,我们可能不希望每个函数都有一个 docstring,因为强制这样做可能会适得其反。但是,默认情况下,pylint将施加此限制,但我们可以在配置文件中声明它来推翻它:

 [DESIGN]
    disable=missing-function-docstring 

一旦这个配置文件达到稳定状态(这意味着它符合编码准则,不需要进一步调整),那么它就可以复制到其他存储库中,在那里它也应该受到版本控制。

记录开发团队同意的编码标准,然后在存储库中自动运行的工具的配置文件中强制执行这些标准。

最后,我想提到另一个工具,那就是Coalahttps://github.com/coala/coala )。Coala有点更通用(这意味着它支持多种语言,而不仅仅是 Python),但其思想与之前的想法类似:它接受一个配置文件,然后提供一个命令行工具,可以对代码运行一些检查。运行时,如果工具在扫描文件时检测到一些错误,它可能会提示用户这些错误,并建议在适用时自动应用修复补丁。

但是如果我有一个工具默认规则没有涵盖的用例呢?pylintCoala都有很多预定义的规则,涵盖了最常见的场景,但您可能仍然会在组织中发现一些导致错误的模式。

如果您在代码中检测到一个容易出错的重复模式,我建议您花一些时间定义自己的规则。这两个工具都是可扩展的:在pylint的情况下,有多个插件可用,您可以编写自己的插件。在Coala的情况下,您可以编写自己的验证模块,与常规检查一起运行。

自动格式化

如本章开头所述,团队最好就代码的编写约定达成一致,避免讨论拉取请求的个人偏好,并关注代码的本质。但协议只能让你走到这一步,如果这些规则不被执行,它们会随着时间的推移而消失。

除了通过工具检查是否符合标准外,直接自动格式化代码也很有用。

有多种自动格式化 Python 代码的工具(例如,大多数验证 PEP-8 的工具,如flake8,都有重写代码并使其符合 PEP-8 的模式),并且它们也可配置并适用于每个特定项目。其中,也许正是因为完全的灵活性和配置的相反,我想强调一点:black

blackhttps://github.com/psf/black 有一个特性,它以一种独特且确定的方式格式化代码,不允许任何参数(可能除了行的长度)。

一个例子是,black总是使用双引号格式化字符串,参数的顺序总是遵循相同的结构。这听起来可能很僵硬,但这是确保代码中的差异保持在最低限度的唯一方法。如果代码始终遵循相同的结构,那么代码中的更改只会在具有实际更改的拉取请求中显示,而不会进行额外的修饰性修改。它比 PEP-8 更具限制性,但也很方便,因为通过直接通过工具格式化代码,我们实际上不必担心这一点,而且我们可以关注当前问题的症结所在。

这也是black存在的原因。PEP-8 定义了一些准则来构造我们的代码,但是有多种方法可以使代码符合 PEP-8,因此仍然存在发现风格差异的问题。black格式化代码的方式是将其移动到 PEP-8 中更严格的子集,该子集始终是确定性的。

例如,请参见以下代码符合 PEP-8,但不符合black的约定:

def my_function(name):
    """
    >>> my_function('black')
    'received Black'
    """
    return 'received {0}'.format(name.title()) 

现在,我们可以运行以下命令来格式化文件:

black -l 79 *.py 

我们可以看到这个工具写了什么:

def my_function(name):
    """
    >>> my_function('black')
    'received Black'
    """
    return "received {0}".format(name.title()) 

在更复杂的代码上,可能会有更多的变化(后面的逗号等),但这一想法可以清楚地看到。同样,这是自以为是的,但拥有一个为我们处理细节的工具也是一个好主意。

这也是 Golang 社区很久以前学到的东西,以至于有一个标准工具库go fmt,可以根据语言的约定自动格式化代码。很好,Python 现在有了这样的东西。

安装后,'black'命令在默认情况下将尝试格式化代码,但它还有一个'--check'选项,该选项将根据标准验证文件,如果验证不通过,则该过程将失败。该命令是自动检查和 CI 过程的一部分。

值得一提的是,black将彻底格式化文件,并且它不支持部分格式化(与其他工具相反)。对于已经有不同风格代码的遗留项目来说,这可能是一个问题,因为如果您想在项目中采用black作为格式标准,您很可能必须接受以下两种情况之一:

  1. 创建里程碑pull请求,该请求将black格式应用于存储库中的所有 Python 文件。这样做的缺点是增加了大量噪音,并污染了回购协议的版本控制历史。在某些情况下,您的团队可能会决定接受风险(取决于您对git历史记录的依赖程度)。
  2. 或者,您可以使用应用了black格式的代码中的更改重写历史记录。在git中,通过在每次提交时应用一些命令,可以重写提交(从一开始)。在这种情况下,我们可以在应用'black'格式后重写每个提交。最后,看起来项目从一开始就采用了新的形式,但是有一些警告。首先,项目的历史记录被重写,因此每个人都必须刷新存储库的本地副本。其次,根据存储库的历史记录,如果有大量提交,此过程可能需要一段时间。

如果不能接受“全部或无”格式,我们可以使用yapfhttps://github.com/google/yapf ),这是另一个工具,与black有很多不同之处:它高度可定制,并且还接受部分格式(仅将格式应用于文件的某些区域)。

yapf接受参数以指定要应用格式的行的范围。有了它,您可以配置编辑器或 IDE(或者更好的是,设置一个git预提交挂钩),以便仅在刚刚更改的代码区域上自动格式化代码。通过这种方式,项目可以在进行更改时以分阶段的间隔与编码标准保持一致。

为了结束本节关于自动格式化代码的工具的讨论,我们可以说,black是一个将代码推向规范标准的伟大工具,因此,您应该尝试在存储库中使用它。在创建的新存储库上使用black绝对没有摩擦,但对于遗留存储库来说,这可能成为一个障碍也是可以理解的。如果团队认为在遗留存储库中采用black太麻烦,那么yapf之类的工具可能更合适。

自动检查的设置

在 Unix 开发环境中,最常见的工作方式是通过 makefile。Makefiles 是强大的工具,让我们可以配置要在项目中运行的命令,主要用于编译、运行等。除此之外,我们可以在项目的根目录中使用一个Makefile,并配置一些命令来自动检查代码的格式和约定。

**实现这一点的一个好方法是为测试和每个特定的测试设置目标,然后设置另一个完全运行的目标;例如:

.PHONY: typehint
typehint:
    mypy --ignore-missing-imports src/
.PHONY: test
test:
    pytest tests/
.PHONY: lint
lint:
    pylint src/
.PHONY: checklist
checklist: lint typehint test
.PHONY: black
black:
    black -l 79 *.py
.PHONY: clean
clean:
    find . -type f -name "*.pyc" | xargs rm -fr
    find . -type d -name __pycache__ | xargs rm -fr 

这里,我们运行的命令(在我们的开发机器和 CI 环境构建上)如下所示:

make checklist 

这将按以下步骤运行所有操作:

  1. 它将首先检查是否符合编码准则(例如,PEP-8 或带有'--check'参数的black
  2. 然后它将检查代码中类型的使用情况。
  3. 最后,它将运行测试。

如果这些步骤中的任何一个失败,则认为整个过程都是失败的。

这些工具(blackpylintmypy等等)可以与您选择的编辑器或 IDE 集成,使事情变得更加简单。将编辑器配置为在保存文件时或通过快捷方式进行此类修改是一项不错的投资。

值得一提的是,Makefile的使用非常方便,原因有两个:第一,有一种简单的方法可以自动执行最重复的任务。团队的新成员可以通过学习'make format'之类的东西自动格式化代码,而不考虑所使用的底层工具(及其参数),从而快速加入团队。此外,如果后来决定更改工具(假设您正在从yapf切换到black,那么相同的命令('make format'仍然有效。

其次,尽可能地利用Makefile是很好的,这意味着配置您的 CI 工具来调用Makefile中的命令。通过这种方式,您的项目中有一种标准化的运行主要任务的方式,我们在 CI 工具中放置尽可能少的配置(这在将来可能会发生变化,而这不一定是一个主要负担)。

现在,我们对什么是干净的代码有了一个初步的概念,并对它有了一个可行的解释,这将作为本书其余部分的参考点。

更重要的是,我们现在明白了干净的代码比代码的结构和布局更重要。我们必须关注如何在代码中表示想法,以查看它们是否正确。干净的代码是关于代码的可读性、可维护性,将技术债务保持在最低限度,并在代码中有效地传达我们的想法,以便其他人能够理解我们最初打算写什么。

然而,我们讨论了遵守编码风格或准则的重要性,原因有多种。我们一致认为,这是一个必要但不充分的条件,因为这是每个实体项目都应该遵守的最低要求,很明显,这是我们最好留给工具的。因此,自动化所有这些检查变得至关重要,在这方面,我们必须记住如何配置工具,如mypypylintblack等。

下一章将更加关注特定于 Python 的代码,以及如何用惯用 Python 表达我们的想法。我们将探讨 Python 中的习惯用法,这些习惯用法有助于实现更紧凑、更高效的代码。在本分析中,我们将看到,与其他语言相比,Python 通常有不同的想法或不同的实现方式。

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

Go语言核心36讲 -〔郝林〕

TensorFlow快速入门与实战 -〔彭靖田〕

从0开发一款iOS App -〔朱德权〕

即时消息技术剖析与实战 -〔袁武林〕

后端技术面试 38 讲 -〔李智慧〕

分布式协议与算法实战 -〔韩健〕

如何成为学习高手 -〔高冷冷〕

超级访谈:对话汤峥嵘 -〔汤峥嵘〕

大型Android系统重构实战 -〔黄俊彬〕