十一、测试最佳实践
“没有测试的代码被设计破坏了。”—Jacob Kaplan-Moss,Django 核心开发人员
在过去的几章中,我们已经看到自动化测试是检验 Python 程序是否有效的一种强大的技术。py.test 框架是一个多功能的工具,可以为小代码单元编写测试,使用测试数据集,以及管理带有许多断言和测试函数的测试套件。显然,测试是每个高级 Python 程序员工具箱中非常有用的技术。然而,到目前为止,我们还没有看到全局:自动化测试在开发过程中的什么位置?
“没有测试的代码是被设计破坏的”这句话 Django web 服务器的核心开发人员 Jacob Kaplan-Moss 提出了一个大胆的主张。它说如果没有自动化测试,我们就无法写出正确的代码。我们能推翻这种说法,说“带测试的代码是正确的”吗?在这一章中,我们将研究这两种说法。我们将软件测试视为开发过程的一部分,并将我们的注意力转向如下问题:
- 有哪些不同类型的测试?
- 开发期间什么时候开始测试最好?
- 自动化测试的好处是什么?
- 在什么情况下测试很难写?
- 一般来说,测试的局限性是什么?
- 还有其他选择吗?
- 什么区分好的和坏的测试?
我希望本章涵盖的测试的最佳实践将帮助你编写自动化测试,以最佳方式支持你的编程,这样你就能更快地生产出可工作的软件,并有效地维护它。
自动化测试的类型
首先,有几种重复的测试。与使用 py.test 来实现它们相比,它们在编写目的和使用范围上有更大的不同。本节介绍了许多专业开发人员在谈论自动化测试时使用的词汇。表 11-1 给出了不同类型测试的概述。
表 11-1。
Types of Automated Tests
| 试验 | 描述 | | --- | --- | | 单元测试 | 测试小的、孤立的代码单元。 | | 整合测试 | 测试两个或更多较大组件的协作。 | | 接收试验 | 从用户的角度测试功能。 | | 回归测试 | 重新运行测试以确保先前构建的功能仍然有效。 | | 特性试验 | 测试执行速度、内存使用或其他性能指标。 | | 负荷试验 | 测试高工作负载下的性能,尤其是 web 服务器。 | | 负荷试验 | 在不利条件下(组件故障、攻击等)测试功能。) |单元测试
对单个函数、类或模块的测试称为单元测试。单元测试证明一段代码满足了它的基本需求。单元测试可能会执行非常详细的检查,并包含相当多的吹毛求疵。在编写单元测试时,我们通常希望涵盖许多边界情况,比如空输入、长输入、怪异输入等等。编写单元测试的最佳实践是 Tim Ottinger 和 Jeff Langr 引入的第一个缩写词。单元测试应该是
- 快速—在几秒钟或更短时间内执行
- 隔离—一次只测试一段代码
- 可重复—可以重新运行,但结果相同
- 自我验证——测试套件不需要额外的信息来评估测试
- 适时——测试在代码之前编写(在“测试优先的方法”一节中有更多的介绍)
单元测试中其他常见的最佳实践是每个测试只使用一个assert
(尽管这是一个经验法则),并使用模拟对象用简单的占位符代替复杂的组件,这样测试只依赖于被测试的代码单元。
集成测试
孤立地测试组件是不够的。还需要对组件之间的协作进行测试;它们被称为集成测试。通常,这意味着测试更大组件(如数据库、web 服务器或外部应用程序)之间的协作,而不是测试两个相邻 Python 模块中的两个类。集成测试还可能包括在不同版本的库或 Python 版本上测试软件。在 Python 中,有一些用于集成测试的工具,例如,Tox (
https://tox.readthedocs.io/
),
,它支持在一系列 Python 版本上运行测试套件。当编写集成测试时,需要使用实际的程序组件(没有模拟对象或其他占位符)。集成测试试图尽可能精确地再现软件的使用环境。
验收测试
第三种类型的测试关注用户的观点。验收测试检查一个程序的特性是否“像宣传的那样”工作典型的验收测试将程序作为一个整体运行,并检查输出的一些特性。它们模拟真实用户的行为(例如,从命令行执行程序或通过浏览器发送 HTTP 请求)。验收测试的最佳实践是,我们不需要测试每一种可以想到的情况。这就是单元测试的目的。我们更愿意确保我们的应用程序在假设所有组件都正确的情况下处理输入并交付期望的输出。
如何在实践中实现验收测试很大程度上取决于我们程序的用户界面类型。如果我们正在开发一个程序库,验收测试将看起来像我们以前见过的用 py.test 编写的测试,只是它们将测试我们想要向用户公开的接口部分。使用命令行应用程序,我们可以用os.system
执行程序并重定向输出。命令行应用程序的原始测试可能如下所示:
def test_commandline_app():
"""Make sure a maze with at least one wall is produced"""
cmd = "python create_maze.py > output.txt"
maze = open('output.txt').read()
assert '#' in maze
为图形界面编写验收测试更具挑战性。存在用于 web 接口验收测试的专用工具(例如 Selenium、Cucumber 和 Codecept.js),但是有时,合理的测试可以通过 web 框架中的常规测试来完成(使用 py.test)。在任何情况下,自动化验收测试都不能代替人工检查程序是否完成了它的工作。在任何情况下,程序员和用户之间的交流都是必要的,以找出软件做的事情是否首先是相关的(例如,它使他们的生活更容易,改善他们的业务,玩起来很有趣)。
回归测试
测试的一个重要应用叫做回归测试。回归测试仅仅意味着在程序改变后重新运行测试。这可能包括重新运行单元测试、集成测试、验收测试,或者以上所有的测试。回归测试强调在几种标准情况下重新运行测试是一种最佳实践:
- 添加新功能后
- 修复缺陷后
- 重新组织代码(重构)后
- 在将代码提交到存储库之前(参见第十二章
- 从存储库中签出代码后
回归测试确保在我们编辑了代码之后,我们到目前为止创建的所有东西仍然工作。如果我们有一个快速测试集,在编程期间每隔几分钟重新运行测试是一个非常强大的技术。回归测试确保我们不会在关注程序其他部分的时候无意中破坏了某个特性。py.test 的命令行选项使我们在回归测试过程中的生活变得更加轻松(例如,只重新运行失败的测试会大大加快我们的工作)。您是否决定重新运行失败的测试、单元测试、集成测试,或者您在回归测试期间可以得到的所有测试,取决于代码的变化有多大,以及您离发布程序有多近。
性能测试
到目前为止,所有的测试都是测试一个程序的功能性:这个程序是工作还是不工作?但是其他一些指标也值得测试:程序够快吗?它是否在适当的时间对用户输入做出反应?程序是内存高效的还是消耗了过多的系统资源?所有这些都可以在术语性能测试中找到。在 Python 中,有一些很棒的工具用于手工性能测试。例如,我们可以使用 IPython 中的%timeit
魔法方法来检查函数的性能:
In [1]: from generate_maze import create_maze
In [2]: create_maze(10, 10)
In [3]: %timeit create_maze(10, 10)
1000 loops, best of 3: 589 s per loop
%timeit
函数智能地计算出它需要运行一段代码的频率,以确定可靠的平均执行时间。较慢的函数通常比快的函数需要的运行次数少。相反,如果我们尝试range
,一百万次运行是必要的。此外,IPython 警告我们,结果波动很大:
In [4]: %timeit range(100)
The slowest run took 9.16 times longer than the fastest.
This could mean that an intermediate result is being cached.
1000000 loops, best of 3: 392 ns per loop
使用timeit
优于使用外部程序测量执行时间。特别是,不要试图用 Unix 命令行tool time
来测量单个 Python 函数的性能。通过这种方式,您也将测量大量的开销(例如,启动 Python 解释器),并且结果将是不精确的。通过导入timeit
标准模块,我们可以测量常规 Python 程序中任何 Python 命令的执行时间。通过将timeit.timeit
函数返回的数字与预期的最大时间进行比较,我们可以编写简单的性能测试:
def test_fast_maze_generation():
"""Maze generation is fast"""
seconds = timeit.timeit("create_maze(10, 10)", number=1000)
assert seconds <= 1.0
性能优化
我们如何利用计时信息来提高性能?模块允许我们更详细地检查程序的性能。例如,如果我们使用create maze()
函数创建一个非常大的迷宫,这个函数会变得很慢。通过cProfile.run()
,我们获得了一份关于这个项目具体在哪里花费时间的报告:
cProfile.run("create_maze(200, 200)")
395494 function calls in 19.689 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.003 0.003 19.689 19.689 <string>:1(<module>)
1 0.000 0.000 0.009 0.009 generate_maze.py:22(get_all_dot_pos)
1 0.009 0.009 0.009 0.009 generate_maze.py:24(<listcomp>)
39204 0.078 0.000 0.078 0.000 generate_maze.py:27(get_neighbors)
1 0.262 0.262 19.670 19.670 generate_maze.py:35(generate_dot_pos)
39204 0.126 0.000 0.126 0.000 generate_maze.py:42(<listcomp>)
1 0.000 0.000 19.686 19.686 generate_maze.py:49(create_maze)
1 0.017 0.017 0.017 0.017 generate_maze.py:9(create_grid_string)
...
39204 18.914 0.000 18.914 0.000 {method 'remove' of 'list' objects
ncalls
列告诉我们这个函数被调用了多少次。tottime
是 Python 在这个函数中花费的总时间,后面是每次调用的时间(percall
)。cumtime
是累计时间,Python 在这个函数和从它调用的其他函数中花费的时间,后面是每个函数花费的时间。在输出中,我们看到函数get_neighbors
被调用了 39204 次。我们可以试着让get_neighbors
更快,但是它不会加速我们的程序(在那里花费的总时间只有 78 毫秒)。真正的瓶颈是list.remove
,几乎占了整个执行时间。此时,值得看一下代码(完整的迷宫生成器在第三章中介绍):
def generate_dot_positions(xsize, ysize):
"""Creates positions of dots for a random maze"""
positions = get_all_dot_positions(xsize, ysize)
dots = set()
while positions != []:
x, y = random.choice(positions)
neighbors = get_neighbors(x, y)
free = [nb in dots for nb in neighbors]
if free.count(True) < 5:
dots.add((x, y))
positions.remove((x, y))
return dots
通过检查代码,我们看到每个位置只被使用了一次。使用慢速list.remove
方法的原因是位置以随机顺序处理。为了让代码运行得更快,我们将列表混洗一次,然后在一个for
循环中处理列表的每个元素。以下是删除两行并添加了两个突出显示的行后的更新实现:
def generate_dot_positions(xsize, ysize):
"""Creates positions of dots for a random maze"""
positions = get_all_dot_positions(xsize, ysize)
dots = set()
random.shuffle(positions)
for x, y in positions:
neighbors = get_neighbors(x, y)
free = [nb in dots for nb in neighbors]
if free.count(True) < 5:
dots.add((x, y))
return dots
但是这种实现真的更快吗?再次运行%timeit
或cProfile
,给我们一个答案:
In [13]: %timeit create_maze(200,200)
1 loop, best of 3: 279 ms per loop
通过更改几行代码,我们将功能加速了几乎两个数量级!使用cProfile
对于测量性能和定位瓶颈至关重要。当优化代码时,单元测试是回归测试的一个有价值的工具,以验证我们的程序不仅快速而且正确。性能测试也可能包括许多其他情况。例如:
- 负载测试:程序(例如,web 服务)如何处理大量的并发流量?
- 压力测试:程序如何处理恶意条件(快速变化的条件、组件故障,甚至是试图使程序崩溃的外部攻击)?
- 健壮性:执行时间是稳定的还是波动很大?
因为性能测试往往比单元测试更耗时,所以最好将它们与其他测试分开。一般来说,编写性能测试更具挑战性。幸运的是,很多程序根本不需要它们。
测试优先的方法
在第八章,我们写了一个测试,尽管我们还没有实现满足测试的代码。在编写程序之前编写测试是测试的一个关键的最佳实践。为什么首先编写测试是一个好主意?让我们首先考虑相反的方法,这是我们大多数人凭直觉想到的方法。我们先写一个程序。然后我们调试程序并修复所有缺陷。只有在那之后,我们编写测试,我们就完成了。然而,这种方法产生了一些严重的问题:
- 测试给我们的额外信息很少,因为程序已经工作了:它们都默认通过。
- 如果测试没有立竿见影的效果,那么编写测试就很无聊。如果没有必要,编写低质量的测试是很容易的。
- 如果在测试过程中,我们发现有些事情可以做得更好,我们需要重复整个过程(编写代码,修复错误,测试,重复)。
- 我们不知道什么时候我们写了足够多的测试。
- 我们不知道我们什么时候能修复完错误。
- 我们不知道什么时候写完程序。
特别是最后一点,除非我们对“完成”的含义有详细而准确的了解,否则会对这种方法产生相当大的怀疑。事实证明,当我们开始编程时,我们的知识往往不够准确,因为编程项目的目标是随着代码本身发展的。在很长一段时间里,软件首先被开发,然后被测试(使用手工和自动化测试)。随着 21 世纪初敏捷运动的开始,在实现满足测试的代码之前编写测试变得越来越流行。简而言之,测试优先方法遵循该程序(也见图 11-1 ):
- 写一个测试函数。
- 运行测试,确保失败。
- 写代码。
- 运行测试并确保它通过。
- 清理代码并运行回归测试。
- 重复这个过程,直到你完成。
图 11-1。
The test-first approach. We write a failing test first, and then the code to make it pass. After that, Regression Tests check whether everything else is still working. The process is repeated until the program is complete.
测试优先的方法有几个优点:首先,看到测试失败证明了它的正确性。当我们编写一个测试时,我们默认不知道它是否正确。测试是程序代码,因此可能包含缺陷。相应代码尚不存在的测试可能会失败。如果测试通过了,我们就会知道这个测试包含了一个缺陷或者测试了一些已经存在的东西。这样,我们可以确保测试为我们提供了额外的信息。首先看到测试失败对于开发有用的测试是必不可少的。
第二,先写测试,促进写出好用的代码。当编写测试时,我们需要考虑如何使用代码。这更早地暴露了设计弱点。例如,如果我们发现即使编写测试也很复杂,那么界面可能需要重新设计。
第三,先写测试对程序员来说是激励。如果我们看到我们的测试从FAIL
切换到PASS
,这比“哦,天哪,我需要为所有代码编写测试吗?”的想法更有价值
测试优先的方法现在被认为是促进软件更高质量的最佳实践。它可以应用于各种情况。下面介绍其中的三种。
根据规范编写测试
如果我们有一个书面的规范——一个描述程序应该做的所有事情的文档——我们可以在写代码之前写很多测试。我们甚至可以编写一个完整的测试套件。之后,我们开始实现代码,并看到越来越多的测试通过。这种方法的主要优点是,首先编写测试有助于您为代码开发一个良好的接口,并且看到越来越多的测试通过会极大地激励开发人员。如果我们在一个已经建立了领域知识的领域中工作(也就是说,我们非常详细地理解了软件将要解决的问题),根据一个规范编写测试是可行的。在许多其他情况下(电脑游戏、创业公司和研究项目),要么我们没有开始的书面规范,要么规范很可能会改变。在这种情况下,根据完整的规范编写测试是浪费时间。更实用的方法是从规范中挑选一个组件,为该组件编写测试,然后实现它,挑选下一个,等等。这样我们就减少了浪费的精力。
针对缺陷编写测试
正如我们在第一章中所看到的,寻找和修复缺陷可能是有压力的、令人厌倦的和令人讨厌的。同一个缺陷修复两次就更糟糕了。测试优先的方法保护我们免受这种影响。针对缺陷进行测试的工作流程是对测试优先过程的一个微小的修改:
- 识别缺陷或程序失败。
- 针对缺陷或导致的失败编写一个测试。
- 确保测试失败。
- 修复缺陷。
- 再次运行测试,确保它通过。
每当我们在程序中发现缺陷时,我们可以使用相同的过程。失败的测试作为程序包含特定缺陷的证据。当我们调查缺陷如何在程序中传播时,我们可能会添加更多特定的测试(同样会失败)来更早地捕捉缺陷。一旦我们修复了缺陷,所有新编写的测试都应该通过。这样我们可以确保同样的缺陷不会再次发生。针对 bug 编写测试是使软件更加健壮的一个非常有用的策略。这也是使用测试优先方法最不武断的方式。您会发现大多数程序员都同意这是一个最佳实践。使用编写许多针对 bug 的自动化测试的一个小缺点是,产生的测试套件将是不系统的。然后,不时地重构测试是一个好主意。
测试驱动开发
测试驱动开发(TDD)是对测试第一思想最严格的解释。TDD 将测试优先的方法应用到整个开发过程中。在写任何代码之前,我们都要写一个测试。然后我们编写足够的代码使测试通过,编写下一个测试,等等。当我们在编写测试和编写代码之间交替时,我们构建程序。TDD 背后的想法是,它有助于构建程序,因为要编写一个测试,我们首先需要完全理解程序应该做什么。严格地应用,TDD 导致非常短的开发周期(在几分钟的范围内)。TDD 和本章前面描述的其他测试优先方法的主要区别在于,在 TDD 中,编写测试的人和编写代码的人必须是相同的。在前面描述的方法中,测试人员和开发人员可以是独立的个人,甚至是团队。关于使用 TDD 是否是一个好主意的观点是不同的。一方面,它防止编码太多。当所有测试都通过时,我们就完成了。它也促进了写代码前的思考。两者都是编程中公认的优点。另一方面,TDD 不容易做,需要经验和纪律。一个更大的缺点是,当从一个测试工作到下一个测试时,很难关注软件的整体架构。对 TDD 的一个批评是,它促进了难以维护的设计。Emily Bache (
https://archive.org/details/EuroPython_2014_5ZLqAvRe
)
在 EuroPython 2014 主题演讲中对 TDD 进行了平衡的讨论。无论如何,尝试 TDD 是一个很好的学习经历,我强烈推荐你尝试一下。
自动化测试的优势
我们已经看到,自动化测试可以应用于大小代码单元,从单个功能到整个软件包。我们已经看到了一些例子,在这些例子中,测试更加困难,但是可以编写。最后,我们已经看到,在编写代码之前编写测试是公认的最佳实践。另一方面,我们也看到了测试并不能证明一个程序是正确的。它只能证明缺陷的存在,而不能证明缺陷的不存在。图 11-2 给出了测试只能证明错误的基本原理。
图 11-2。
Possible outcomes if code and tests are correct or wrong
到目前为止,我们还忽略了自动化测试的其他好处或限制吗?
测试节省时间
首先,使用测试比手工检查程序特性要快得多。但是花在编写自动化测试上的时间呢?理想情况下,在编写测试上投入的时间会很快得到回报。每当我们改变一个程序时,都有意外破坏之前正常工作的东西的风险。进行测试消除了手动发现和修复这些缺陷的时间。无论我们是添加新特性,修复缺陷,还是重组(重构)代码,都是如此。自动化测试的主要好处是我们永远不会担心同一个问题两次。因此,投入的努力应该在几轮开发后得到回报。
一些程序员跳过自动化测试,声称他们没有时间做这些。对于一个小程序(100 行或更少),这是毫无疑问的。大多数情况下,运行代码并手动检查会更快。超过 100 行,自动化测试开始变得更加有用。根据项目的不同(如果有一个 bug,会造成多大的损失),有测试和没有测试的程序都可以工作。在 1000 行以上,不编写自动化测试变得非常危险。一般来说,一个有经验的程序员在没有自动测试的情况下所能处理的程序规模比一个混合技能水平的团队要大。随着时间的推移,测试会加速开发,因为现有的特性可以快速地以自动化的方式进行测试。你的程序越大,你维护它的时间越长,自动化测试的好处就越大。
测试增加了精确度
在 Python 这样的动态类型语言中,测试是必要的。Python 在运行程序之前很少捕捉错误,而静态类型语言在编译期间会指出许多缺陷。在 Python 中,我们需要测试来达到相当的精确度。我们可以使用测试来精确地定义一个函数、类或模块应该做什么。通过编写测试,我们清楚地声明我们正在期待一个给定的行为,并且观察到的行为不是巧合。
测试使协作更容易
测试在许多方面促进了开发人员之间的合作。测试使得快速检查从其他程序员那里收到的代码是否工作变得容易。在开始修改别人的代码之前,这是一个必要的步骤。通过测试,我们可以在不咨询其他程序员的情况下进行这样的检查。当参与一个开源项目,加入一个现有的团队,或者接管一个原始开发人员不再可用的项目时,这一点尤其有价值。在相反的网站上,当你对代码的最新修改不能在别人的计算机上工作时,编写测试可以保护你的安全。询问“测试通过了吗?”将讨论引向一个建设性的、基于事实的方向,而不是仅仅根据假设进行指责。
自动化测试的局限性
测试需要可测试的代码
为了编写好的测试,代码本身的结构需要是可测试的。想象一下,我们将编写整个游戏(设置关卡、绘制图形、事件循环处理移动等)。)在单个单片函数中。这将导致几个严重的障碍:
- 将我们的测试数据放入程序会非常复杂(例如,用我们自己的测试场景替换随机地图)。
- 图形会一直显示,减慢测试速度。
- 很难劫持键盘来将命令放入事件循环并在之后离开程序。
拥有一个单一的大程序会极大地膨胀我们的测试代码并减慢测试速度。实际上,将代码分割成易于测试的小函数和类已经完成了一半的工作。结构良好的代码和有用的测试之间的关系是相互的。好的测试使你的程序更容易结构化,而结构良好的代码更容易测试。
测试不适合快速发展的项目
如果一个编程项目变化非常快,测试可能就不值得了。例如,如果您做探索性的数据分析(例如,从文件和数据库中收集数据并生成大量图表),编写自动化测试通常是一种浪费。此外,在实现原型时,开发速度通常比正确性更有用。在这两种情况下,编写测试会不必要地减慢开发速度。然而,程序中的一些变化是正常的。如果程序中的所有东西都一直在变化,这可能表明程序不是结构良好的。在这种情况下,测试可以帮助稳定那些不会改变的部分:程序没有错误地完成,它产生一个输出文件,输出文件不是空的,等等。即使这样的测试看起来微不足道,它们也会覆盖大量的代码。
测试并不能证明正确性
不可能测试所有的东西。自动化测试必然是不完整的。即使有非常详细的边界案例,仍然有许多不确定性。正如第十章所说,100%的测试覆盖率并不意味着我们已经测试了所有的东西。像路径复杂性这样的问题——程序中的多条执行路径——创造了一个程序可以执行的比我们可以测试的更多的可能方式。即使在分支指令数量很少的程序中,可能路径的数量也超过了我们在实践中可以测试的情况的数量。即使我们覆盖了所有可能的路径,我们通常也不能覆盖所有可能的输入。最后,我们的测试总有可能没有准确描述我们希望程序被使用的所有可能的方式。有许多研究证实,一个程序包含未知的缺陷是相当正常的。不幸的是,带有自动化测试的 Python 程序也不例外。结论是,自动化测试并不能证明正确性。
难以测试的程序
随机数
根据定义,随机数是不可预测的。无论何时涉及到随机数,测试都需要涵盖所有可能的结果。使用随机数测试程序的最佳实践是使用random.seed()
来控制种子值,这样我们就知道将创建什么样的随机数序列,并且您的测试结果至少是一致的。这里,为每个测试模块单独设置种子值是很重要的,以避免我们的测试相互干扰。
Hint
我了解到 Python random
模块的实现在所有平台上都是一致的。使用相同的种子值,您应该在任何地方获得相同的结果。我也检查过,发现 Python 2.7 和 3.5 生成的随机数一致,但无论如何测试随机数的时候我都会很谨慎。
图形用户界面
如果你的程序有一个文本接口,那么为它编写测试是非常容易的。基本上,我们可以捕捉标准输出(通过sys.stdin
或在 Unix 上重定向输出)并用我们的测试数据替换用户输入。有了图形界面,事情很快变得繁琐。基本的问题是,很难指导自动测试来检查我们在屏幕上看到的东西对人类是否有意义(作为一个极端的例子,浅灰色背景上的白色字体对自动测试可能是正确的,但对人类用户完全没有用)。此外,还可以模拟用户界面中特定位置的一系列鼠标点击。然而,GUI 元素的位置经常发生变化,导致测试的频繁变化。
一般来说,为动态网页编写测试要简单一些,因为 HTML、CSS 和 Python 的分离有助于将测试集中在功能上。HTML 和后端部分的测试策略包括不同的技术,如 Django 测试框架(与本书中的测试非常相似)和 Selenium(远程控制 web 浏览器的测试脚本)。对于 Django 开发人员来说,Harry j . w . PERC ival(O ’ Reilly Media,2014)的《使用 Python 进行测试驱动开发》是一本关于这一主题的专家级书籍。
处理图形用户界面的最佳实践是将可视组件与其他组件完全分离,如下所示:首先,编写一个经过充分测试的库或命令行工具来完成这项工作。使用前几章介绍的技术测试它。其次,在它上面写图形部分。根据您的能力,编写基本的测试或者求助于图形部分的手工测试。无论如何,您都需要对图形界面进行一些手工测试。
复杂或大量输出
如果程序的输出非常复杂或者非常大,那么编写好的测试会很有挑战性。这种情况可能包括生成图像、音频、视频或大型文本文件。主要问题是,将程序的输出与样本文件进行比较效果不好。假设您在程序中引入了一个小的变化,导致在一个几 MB 大的文件中出现了一些额外的空间。快速重新检查测试数据会变得很昂贵。
在这种情况下,有两种策略可以解决问题:首先,对于许多文件格式,用 Python 编写的支持库已经存在。例如,如果您使用PILLOW
库来生成图像,您可以使用相同的库来验证程序的输出。如果您有一个用于读写目标格式的库(并假设它工作正常),您就不必检查输出的每个细节。检查一些主要特征(例如,图像的数量、大小和一些其他关键属性)通常就足够了。如果这样的支持库不存在,编写一个可能是一个不错的投资。
其次,您可以为非常小的样本数据集编写详细的测试。如果您的常规数据集较大,单独测试一个小文件是不够的。最佳实践是额外测试一个更大的文件,但是将测试限制在几个关键指标上:输出是否非空?文件的大小和条目数量是否正确?图像的像素数量是否正确?音频信号的最小/最大振幅是多少?视频的平均颜色是多少?这样的度量标准覆盖了 80%的情况,甚至使得测试大的输出也是可管理的。
并发
测试并行发生的事情是我个人的噩梦。无论您测试的是并发进程、线程、greenlets 还是任何其他类型的并行处理,都没有关系;并发性很难测试。原因在于并发本身的性质:想象我们有一个玩家和一个幽灵在迷宫中移动(就像我们在第十五章中设计的那样)。如果我们将这个特性作为单线程来实现,我们就可以完全控制事件的顺序。我们可以编写普通的测试来检查 Pygame 事件队列中的内容。如果我们为玩家创建一个线程,为幽灵创建一个线程,我们就放弃了控制权。现在,如果我们编写一个旨在检查两个线程的测试,这个测试很容易干扰这些线程。例如,测试可能会稍微延迟一些事件。因此,作为测试的结果,bug 可能会消失。如果你熟悉海森堡的测不准原理,你可能会发现一个相似之处。因此,在测试时消失的 bug 被称为 Heisenbug。简而言之,在并发环境中,测试和被测系统相互干扰,使得测试更加困难。
如果您必须使用并发,我的建议是更深入地阅读这个主题。选择一个库(asyncio、gevent 或 Twisted)并彻底理解它。在文档中寻找关于测试的具体建议。很可能你将不得不记录大量信息。如果并发编程一定会成为您的日常业务,那么像 Go 和 Scala 这样具有强大并发支持的编程语言也值得一看。
自动化测试失败的情况
计算机科学家已经发现了一些非常糟糕的情况,在这些情况下,测试几乎没有意义。思考以下问题:
- 你能测试一个程序是否结束(例如,是否有一个死循环)?
- 你能测试一个程序在内存耗尽时的行为吗?
- 你能在多少不同的环境(操作系统、Python 版本、库版本以及它们的组合)中测试你的程序?
- 您能测试基础设施(网络连接、web 服务、助手脚本)吗?这在什么情况下有用?
自动化测试的替代方案
在一家生产安全关键软件的公司,我听到了下面的陈述:“我们通常不依赖自动化测试这样的不安全技术。”考虑到自动化测试只能证明 bug 的存在,我们需要承认这样一个事实,即自动化测试本身并不能使程序变得安全、可靠或正确。但是有什么选择呢?在这里,我们简要地看一下在软件工程师的工具箱中找到的几种验证正确性的方法。
样机研究
在经典的软件工程书籍《神话中的人月》(Addison-Wesley,1995)中,Fred Brooks 指出,人们需要准备扔掉自己的第一个实现。这种经验直接指向原型技术。在原型开发中,程序的第一个版本是作为一个概念证明来编写的,并且有一个明确的条件,就是以后不能使用它。工作原型暴露了以前未知的问题。通过创建一个具体的实现,许多没有人想到的概念或架构问题可以被识别出来。为了快速创建一个原型,测试不是必需的。在原型完成后,编写第二个设计得更彻底的程序,以避免原型的缺陷。在那里,自动化测试又有了用武之地。原型对我们来说是一种非常有用的技术,因为 Python 是一种编写原型的优秀语言。写一个原型很快,通常从头开始写一个新程序比试图纠正第一次尝试中引入的缺陷要快得多。
代码审查
第四章中已经介绍了代码评审作为一种识别缺陷的方法。除了调试之外,代码评审还有很多好处:评审通常有助于提高软件质量。评审有助于开发人员一起工作(如果评审是支持性的,而不是以相互指责告终)。评审促进团队的学习和知识的保留。个人觉得评论很好玩。如果你想让别人看看你的代码,请随时发送给我,我会看看。
检查表 s
清单也是维护软件质量的有用工具。清单只是一系列被逐一检查的步骤、问题或提醒。几年来,我一直在用一份清单来打包我的商务旅行行李箱。我单子上的一件东西是牙刷。当然,我会记得带我的牙刷,不是吗?但如果我早上 4 点起床,或者如果我不得不匆忙收拾行李离开,我就不那么确定了。当事情变得棘手时,我不想让我是否带着牙刷碰运气。清单确保了这一点。
在软件项目中,清单上的项目可能如下:
- 您想要排除的典型错误,例如,“所有变量都已经初始化了吗?”
- 软件发布期间的步骤,例如,“创建 zip 文件。上传到项目页面。”
- 显式手动测试,例如,“zip 文件可以下载并解压缩吗?”
通过明确地检查事情,清单将人的因素排除在外。这有好的一面,也有不好的一面。不好的一面是,使用清单是重复的,一段时间后会变得无聊。这就是为什么清单更适合两个人一起工作(例如,在评审期间)。好的一面是清单简化了非常复杂的情况。因此,他们在紧张的条件下工作:做外科手术或操作飞机和直升机的人大量使用清单是有原因的。Atul Gawande 的《清单宣言》( Metropolitan Books,2010 年)提供了更多关于创建和使用清单的背景知识。
促进正确性的过程
编写正确的软件与底层开发过程有很大关系。例如,安全是优先考虑的过程需要在一开始就识别安全风险,并明确地解决这些风险。这包括非编程活动,如风险评估和应急计划。在组织层面上,像 CMMI 的 ISO9001 和 ITIL 的质量标准是常见的。他们的目标是在开发过程中控制、记录和提高质量。然而,这些是大团队和组织的方法。质量标准的知识对于一般的 Python 项目来说不是很有用,除非您的组织使用它们。更有用的方法是采用一些基本的改善实践,如 5 个为什么技术和看板。两者都注重改进,并有助于发现组织中的主导价值观。组织价值观(例如质量、客户服务、个人诚信)对代码质量有巨大的影响,通常是以不明显的方式。这就是为什么我认为对于一个对质量感兴趣的有经验的程序员来说,关于过程、领导力和团队动力的基础知识是不可或缺的。
结论
所有制造正确软件的方法都是有代价的。与编写未经测试的 Python 程序相比,验证代码或使用提高正确性的流程需要更长的时间。在一个科学 Python 编程项目中,我计算出每个团队成员平均每天编写大约 20 行 Python 代码(大量时间也花在测试、调试、重写代码、阅读和撰写科学文章、参加会议、教学、管理职责等方面。).这个数字与文献相符。对于安全关键环境中的软件开发,这个数字甚至可能下降到程序员每天 10 行代码。因此,编写完全验证的软件是非常昂贵的。在快速原型或启动环境中,一个开发人员可能很容易产生 100 多行代码。当然,快速发展的结果将更加难以维持。但是为了功能正确,一个程序只需要在几个月内回答一个业务假设,而在一个安全关键系统中,软件需要可靠地运行很长时间,通常是几年到几十年。
当思考正确性时,瑞士奶酪模型是有帮助的:想象一下,我们为提高正确性所采取的每一项措施都是一块奶酪。每个切片都有漏洞,因为每个度量都有其局限性。但是,如果我们将许多部分堆叠在一起,比如原型、代码评审,当然还有自动化测试,那么缺陷穿透整个堆栈的漏洞数量将会非常少。知道存在测试的替代方法是很好的,如果仅仅是为了让我们不再妄想自动化测试是编写正确代码的唯一最佳实践。Python 是一种允许快速开发和快速发展项目的语言。自动化测试是一种在不牺牲开发速度的情况下使软件项目更加健壮的技术。Python 和自动化测试非常契合。
最佳实践
- 单元测试是对小代码单元的测试。最佳实践是编写快速、独立、可重复、自我验证和及时的单元测试。
- 集成测试测试两个或多个组件是否能正确协作。
- 验收测试测试用户级特性。
- 回归测试是指在重构代码、修复缺陷或进行其他更改后重新运行测试。
timeit
和cProfile
模块分析 Python 语句的执行时间,并允许编写性能测试。- 在编写使测试通过的代码之前编写失败的测试证明测试工作正常。
- 在编写代码之前编写测试对于实现新功能或修复错误非常有用。
- TDD 是对测试优先方法的严格解释,提倡编写测试和代码的周期非常短。
- 自动化测试当然不是创建工作软件的唯一方法。还有很多其他的技术来验证和确认程序,比如手工测试、代码审查和清单。
十二、版本控制
根据大气中的污染成分来判断,我相信我们已经来到了二十世纪的下半叶。——斯波克,星际迷航 4:回家的旅程
有一次,我参与了一个研究项目,涉及一个数据库、一个公共 web 服务器和大量 Python 代码。有一天服务器的硬盘崩溃了,把所有东西都抹掉了。我们试图从分散在不同人电脑上的文件中恢复服务,但失败了。这个项目再也没有实现。我们将需要一个时间机器来恢复项目以前的状态。具有讽刺意味的是,当我一年后从事另一个网络数据库项目时,历史重演了。服务器上的磁盘又崩溃了。这一次,我们真的有了时间机器。我们可以像崩溃前一样拿走所有代码和数据。虽然我们不得不手动配置一些东西,但网站很快又运行了。现在你可能想知道我们如何恢复旧代码。或者,更准确地说:“你是如何给自己造出那个时间机器的?”我们的时光机叫版本控制(见图 12-1 )。在本章中,我们将了解什么是版本控制以及如何使用它。
图 12-1。
Version Control is a bit like time traveling in your source code—temporal paradoxa included. To see a world without program bugs, you would have to travel back into the 19th century before Ada Lovelace wrote the very first computer program.
在前几章中,我们的程序有了很大的变化。我们应用了各种调试和编码策略,并相应地编辑了我们的代码。在尝试不同的方法时,我们可能会复制 Python 文件来保存新旧版本。一段时间后,我们会看到文件越积越多(例如,模块将游戏区域绘制为一个方格):
draw_grid.py
draw_grid2.py
draw_grid2_new_version.py
draw_grid3.py
draw_map.py
draw_map_buggy.py
draw_map_with_introspection.py
看起来我们的项目已经发展壮大了。几天不使用代码后,正确使用这些文件将会非常困难。出现类似下面的问题:最近修改的文件真的是您应该使用的文件吗?再问一下draw_map
和draw_grid
有什么区别?删除所有其他的安全吗,或者它们仍然包含重要的代码片段吗?如果我们的代码包含分布在多个目录中的文件,情况会变得更糟。简而言之,结果是我们的计划有变成一团乱麻的危险。
版本控制是解决这个问题的方法。版本控制意味着跟踪你的程序代码和相关文件中的所有变化,这样随着时间的推移,你可以建立一个你的代码是如何开发的日志。版本控制使我们能够在以后恢复到任何以前的时间点。正因为如此,版本控制被认为是长期维护任何项目的最基本、最重要的最佳实践。即使是小型的单人项目,回报也很快。
在这一章中,我们将使用版本控制系统 git,来跟踪 MazeRun 代码中的变化,并把它放在一个公共源代码库中。
git 入门
git 是 Linux 的发明者 Linus Torvalds 写的。它是当今最常用和最先进的版本控制系统。几乎不用说,它是免费的。使用 git 可以做的事情包括:
- 创建存储库
- 将文件添加到存储库中
- 检查项目的历史
- 跳到更早的状态并返回
- 在公共存储库上发布代码
- 并行管理多个代码分支
在 Ubuntu Linux 上,可以用
sudo apt-get install git
创建存储库
git 中的项目被组织在存储库中。一般来说,存储库是一组版本控制在一起的文件。这是什么意思?让我们考虑一个具体的例子。假设您在一个名为maze_run
的目录中拥有 MazeRun 游戏的所有文件(包括 Python 代码、图像,可能还有一些数据文件)。我们希望使用 git 来跟踪这些文件中的变化。首先,我们从命令行用git init
命令创建一个新的存储库:
cd maze_run/
git init
您应该会看到这样的消息:
Initialized empty Git repository in /home/krother/projects/maze_run/
起初,似乎什么都没有改变。但是如果你更仔细地看(用ls -la
),你会注意到一个叫做.git
的隐藏文件夹。这是 git 存储随着时间的推移应用于文件的更改的地方。现在我们的项目可以开始发展自己的历史了。
将文件添加到存储库中
当我们初始化一个存储库时,git 会自动开始跟踪现有的文件吗?我们可以随时通过输入以下命令来检查 git 对当前目录的了解
git status
我们得到这样一条消息:
Untracked files:
(use "git add <file>..." to include in what will be committed)
draw_maze.py
event_loop.py
generate_maze.py
load_tiles.py
maze_run.py
moves.py
util.py
images/
nothing added to commit
but untracked files present (use "git add" to track)
git 告诉我们的是,它看到了以前从未见过的文件,因此它们还不在存储库中。我们可以使用git add
命令将这些文件和整个目录添加到存储库中:
git add *.py
git add images/*
通过再次使用git status
,您可以检查 git 记录了哪些文件。添加适用于任何类型的文件(源代码、文本文件、图像、Word 文档)。然而,git 对于文本文件中的更改最有效。
为了告诉 git 我们已经完成了文件的添加,我们可以使用 commit 命令并附加一条消息:
git commit
-m "added first working version of MazeRun"
之后git status
报告:
nothing to commit
, working directory clean
commit 命令将我们文件的内容复制到 git 的内部文件的.git
目录中。我们现在已经完整地记录了这些变化。无论我们后来在 Python 代码中做了什么改变,只要我们不弄乱.git
目录(这通常不是一个好主意,除非您真的知道自己在做什么),我们都可以恢复到以前的状态。
跟踪文件中的更改
版本控制的主要用途是跟踪文件中的变化。假设我们想要编辑第三章中介绍的随机迷宫生成器的描述。像以前一样,我们在我们喜欢的文本编辑器中打开文件(generate_maze.py
),并进行修改。例如,我们可以替换斯巴达的评论
# Code for chapter 03 - Semantic Errors
更具说明性的 docstring 解释了该模块的功能及其局限性:
"""
Random Maze Generator
Creates X*Y grids consisting of walls (#) and floor tiles (.),
forming a network of connected corridors.
Warning: Sometimes, the algorithm will create enclosed spaces,
but it is good enough to experiment with debugging techniques.
This module was introduced in chapter 03 - Semantic Errors
"""
当我们保存文件时,更改不会自动添加到 git 存储库中。我们可以再次使用git status
检查哪些文件已经更改:
> git status
On branch master
Changes not staged for commit
:
(use "git add <file>..." to update what will be committed)
modified: generate_maze.py
nothing added to commit but untracked files present (use "git add" to track)
您还可以查看git diff
到底发生了什么变化。输出包含我们添加的每一行的前缀+
和我们删除的每一行的前缀:
> git diff
index c0d5a33..2ea0321 100644
--- a/part1_debugging/generate_maze.py
+++ b/part1_debugging/generate_maze.py
@@ -1,5 +1,13 @@
+"""
+Random Maze Generator
-# Code for chapter 03 - Semantic Errors
+Creates X*Y grids consisting of walls (#) and floor tiles (.),
+so that the floor tiles are connected.
+
+Warning: Sometimes, the algorithm will create enclosed spaces.
+
+This module was introduced in chapter 03 - Semantic Errors
+"""
import random
要将这些更改写入存储库,我们需要添加文件并再次提交更改:
git add generate_maze.py
git commit
-m "improved module docstring for maze generator"
作为一种捷径,您可以在一个步骤中完成添加和提交更改,只要文件generate_maze.py
已经被添加到存储库中,以便 git 知道它:
git commit -a -m "improved module docstring for maze generator"
git
commit
-a -m
命令将从您的存储库中添加最近更改过的所有文件。这是有用的,因为它节省了您逐个添加每个文件的努力。在更大的项目中,有时希望从一轮编辑中创建多个提交(例如,如果您正在写一本书,提交可以被标记为“第十二章的附加数字”、“第十五章的固定代码示例”等)。).git 为您提供了对单个提交内容的细粒度控制。作为一个最佳实践,我建议手动添加文件,而不是使用git commit -a -m,
只是为了意识到你在添加什么。
每天提交至少在一个工作日结束时提交你所有的改变是一个好习惯。如果您正在与其他人协作或进行密集开发,提交可能会更频繁地发生。在高峰时段,我每隔几分钟就使用git commit
。
移动和删除文件
除了改变文件的内容之外,我们可能想要将文件移动到不同的地方或者将它完全扔掉。普通的 Unix 命令mv
和rm
仍然工作,但是 git 会变得混乱,因为它期望的文件已经不在那里了。相反,我们需要明确地告诉 git 我们想要移动或删除一个文件。
git mv
命令相当于移动文件的mv
。它的工作方式与mv
完全相同,只是在我们的存储库中添加了一个注释,说明该文件曾经在其他地方。完整的命令可能如下所示:
git mv foo.py examples/foo.py
类似地,git rm
删除一个文件,但仍会记住存储库中以前的内容:
git rm bar.py
使用-f
标志可以覆盖更改,但是我不推荐使用-f
作为最佳实践。使用git status
,我们可以检查 git 是否注意到了移动和删除的文件。在移动和删除文件之后,需要使用git commit
将更改提交到存储库,就像我们之前编辑文件时所做的那样。
放弃更改
在编辑代码时,有时会发现一个想法行不通。或者说原来我们已经把自己纠结得太厉害了,不如扔掉所有改动,重新开始。或者我们只是不小心删除了代码。在文本编辑器中,按下Ctrl+A, Backspace, Ctrl+S
很容易毁掉几个小时和几天的工作。在这些情况下,我们可以使用存储库来撤销我们的更改,并退回到一个已知的状态。假设我们弄乱了文件generate_maze.py
(鉴于您已经将最近的变更提交到一个存储库中,在家里尝试是安全的)。
我们可以用git checkout
命令恢复文件:
git checkout generate_maze.py
这将重新创建上次提交时的文件。我们也可以用一个命令恢复当前目录中的所有文件:
git checkout .
对于频繁的提交,git checkout
就像一个项目范围的撤销功能。
浏览我们代码的历史
当在一段时间内跟踪我们代码中的变化时,我们会进行大量的提交。我们的知识库正在积累自己的历史。理想情况下,这段历史被静静地保存在.git
目录中,我们再也不需要查看它了。然而,有一天我们可能会注意到,我们搞砸了一些以前有效的事情。在这种情况下,我们可以使用 git 作为我们的时间机器,回去看看代码以前是什么样子的(见图 12-1 )。
我们需要弄清楚的第一件事是进入时间机器的日期。命令git log
给出了我们之前所做的所有提交的列表:
> git log
commit a11d542accc755a47533783b462c69992e218e73
Author: krother <krother@academis.eu>
Date: Fri Jul 1 11:45:40 2016 +0200
improved module docstring for maze generator
commit b865b483a7e042e02724464cc6bd944b23e2324e
Author: krother <krother@academis.eu>
Date: Fri May 27 21:18:51 2016 +0200
cleaned up scripts for part 1
...
每次提交都包含散列码、作者、时间戳和我们编写的消息(此时编写描述性的提交消息是值得的)。我们还可以限制关于单个文件的日志消息的输出:
> git log util.py
commit b9699d1e57292d33aa908a790013c93e15f24962
Author: krother <krother@academis.eu>
Date: Sat May 21 06:29:25 2016 +0200
reorganizd part1
为了获得更长时间的历史概况,使用--pretty=oneline
选项将每个提交压缩到一行是很有用的:
git log --pretty=oneline generate_maze.py
a11d542accc755a47533783b462c69992e218e73 improved module docstring
b865b483a7e042e02724464cc6bd944b23e2324e cleaned up scripts for part 1
6c3f01bb1efbe1dfdf6479a60c96897505c1d7a4 cleaned up code for chapter 03
b9699d1e57292d33aa908a790013c93e15f24962 reorganized part1
Why do we need hash codes instead of simply numbering commits from 1 to X?
时间旅行的类比为我们提供了一个很好的解释:想象我们回到过去给更早的自己一个考试的结果。我们会用现在的两个版本创建另一个历史(一个考试失败,一个考试完美)。除了产生的科学问题之外,仅仅通过一个日期很难明确地提到两个交替的现实中的一个。
有了像 git 这样的时间旅行机器,我们有可能改变代码的历史。此外,您(或一个团队)可以并行创建多个历史。简而言之,线性历史与时间旅行不太合拍。git 避免由此导致的悖论的方法是将每次提交视为独立于时间线的唯一事件——在计算机科学中,哈希代码是识别此类数据项的首选工具。我还没有在现实生活中看到哈希码应用于时间旅行,但这个想法听起来并不太糟糕。
签出旧的提交
确定了感兴趣的提交后,我们可以跳回到项目的历史中。让我们假设我们想要检查标记为“重组 part1”的提交之后的代码是什么样子的。我们需要从git log
的输出中复制的git checkout
命令和相应的散列码b9699d1e572..
。我们回到过去
git checkout b865b483a7e042e02724464cc6bd944b23e2324e
git 给我们一个警告消息(“分离的头”),基本上意味着时间旅行是潜在危险的,git 将放弃任何更改,除非我们明确声明不是这样。当我们抓住机会简单地回顾过去并在编辑器中打开generate_maze.py
时,我们注意到我们最近的 docstring 已经消失了。模块的标题再次显示如下
# Code for chapter 03 - Semantic Errors
git 已经更改了存储库中的所有文件,使其类似于提交历史中的状态。
回溯到最近一次提交
使用git log
,我们只能看到比跳转点更早的提交。也就是说,我们更新的提交是不可见的(我们看不到它的哈希代码)。我们会永远被困在过去吗?还好我们的时光机有一个go back”
按钮。当我们看够了过去,我们可以回到现在
git checkout master
我们的 docstring 再次出现在磁盘上的generate_maze.py
中(取决于您的编辑器处理其他程序正在编辑的文件的能力,您可能会从您的文本编辑器中获得或多或少的惊讶反应)。
在 GitHub 上发布代码
即使你不是用 git 开发代码,在某些时候不碰到 GitHub 几乎是不可能的。GitHub 是一个拥有数百万储存库的网站,包含从 Hello World 程序到整个操作系统的所有内容。GitHub 为开发者提供了一个共享代码和合作项目的市场(见图 12-2 )。使用 GitHub 有很多好处:
- 您可以从多台计算机访问您的程序代码,而无需 SSH。
- 在 GitHub 上发布代码是一个备份系统。
- 您可以在 web 浏览器中浏览已发布存储库的源代码。
- 其他程序员可以复制一个项目,改进它,并友好地请你重新整合他们的更改。
- 自述文件会自动呈现并显示在项目页面上。
- 每个项目都有一个问题跟踪器,它是一个轻量级的项目管理工具。
- 您可以将附加服务插入 Github 存储库中。例如,可以自动执行测试或者发布文档。
图 12-2。
GitHub is a platform to share source code and to collaborate with other developers. The screenshot shows an earlier version of the MazeRun repository .
在撰写本文时,GitHub 是这个星球上开源开发事实上的中心平台。然而,您也可以将它用于私有存储库(通过付费计划)。我见过的很多程序员都把某个人在 GitHub 上的存在看做是一种名片。GitHub 概要文件并不能提供对编程技能的非常准确的评估(例如,因为大多数商业项目并不公开可见),但是熟悉该平台可以被认为是大多数编程语言的最佳实践。幸运的是,使用 GitHub 你只需要记住三个命令:git push, git pull,
和git clone.
在 GitHub 上开始一个项目
在 GitHub 上开始自己的项目很容易。你进入 http://github.com
,
为自己创建一个账户,找到按钮“创建一个新的存储库”。之后,你主要是按照屏幕上的指示输入项目名称和描述。最相关的决定是你是把新的资源库从 GitHub 复制到你的电脑还是从你的电脑复制到 GitHub。GitHub 将显示这两个选项的命令,以便您可以将它们复制粘贴到 Unix 终端中。您只需要这样做一次,所以我们不会详细讨论确切的命令。
哪个选项更好?这取决于你已经有了什么:如果你还没有在你的电脑上用git init
创建一个存储库,用git clone
把存储库从 GitHub 复制到你的电脑上就更简单了。如果您已经在本地使用了 git,即使是一两次提交,也最好使用页面上给出的命令将现有的存储库复制到 GitHub。目前,GitHub 针对这种情况(例如针对 MazeRun 存储库)提出的命令如下:
git remote add origin https://github.com/krother/maze_run.git
git push -u origin master
使用 GitHub 作为单一贡献者
一旦我们在 GitHub 上建立了项目,并完成了第一步配置,事情就变得简单了。我们如何发布我们的代码?让我们假设我们是项目中唯一的工作人员。我们在本地编写代码,并用git commit
将更改提交给本地存储库。每隔一段时间(例如,每天),我们会将我们的更改提交到 GitHub,这样其他人就可以阅读它,并且我们的代码是安全的,以防计算机的驱动器崩溃。提交后,我们需要输入的是
git push
git 会要求我们输入用户名和密码,我们就完成了。如果第一次出现问题,两个最常见的原因是 git 存储库设置不正确(在这种情况下,您可以安全地删除 GitHub 上的存储库,创建一个新的,然后重试)以及您的互联网连接通过代理服务器工作,默认情况下不允许连接(一些公司和公共场所会这样做)。在这种情况下,请咨询您的网络管理员。
这种工作流不仅仅对一个人的编程项目有用。如果我们通过 GitHub 发布教程或培训材料(例如,使用 http://gitbook.com
)或通过 GitHub 页面(
http://pages.github.com
).
管理我们自己的网站,效果也很好
从事其他人开始的项目
最初,git 是为一起工作的开发团队而创建的。这个想法是两个或更多的开发人员可以并行处理相同的代码。他们交换代码中的更改,因此公共代码库的增长速度比他们必须等待另一个人完成要快得多。通过GitHub
开始一个现有的项目很容易。基本上任何人都可以使用git clone
来获得存储库的副本。例如,我们可以开始开发 MazeRun 游戏的本地变体:
git clone https://github.com/krother/maze_run.git
git clone
命令创建 GitHub 项目的本地副本。它还复制了.git
目录,因此项目的整个历史也被复制了。稍后,我们可以和git pull
一起检查 GitHub 上的库是否有任何变化:
git pull
要使用git pull
,我们需要首先提交我们自己的更改。
有多个参与者的项目
默认情况下,只有存储库的所有者可以更改其内容。但是如果我们想和某人合作一个项目,我们可以在 Github 项目的设置中添加多个贡献者。通过添加一个贡献者,我们允许这个人添加代码到项目中,改变它的设置,甚至删除整个项目。这些特权应该只给予你认识和信任的人。
对于多个参与者,典型的工作流如下:
- 用
git clone
或git pull
更新我们的本地副本。 - 编辑代码。
- 用
git commit.
提交更改 - 再次运行
git pull
检查变化。 - 随后,用
git push.
发布所有内容
由两个人合并更改
当两个或更多的人编辑相同的代码时,一个大问题就要出现了。想象一下,你和其他人同时编辑代码。双方都提交代码(前面列表中的步骤 3)。第 4 步和第 5 步会发生什么?如果两个人处理不同的文件,git 会自动合并两次编辑。合并时,将使用每次提交的最新文件。如果同一个文件是由两个人编辑的,合并会更复杂。通常,git 会要求您手动合并文件。您将得到一个标记了冲突更改的文件。然后你有机会解决冲突,用git add
和git commit
贡献代码。
拉取请求
有许多高级选项可以促进与其他开发人员的协作:例如,您可以创建自己的已发布项目副本(称为 Fork)。您可以在不修改原始项目的情况下更改分叉。然而,如果您认为您的更改值得被合并回原始项目中,这可以通过一个拉请求来完成。假设您在 GitHub 上创建了自己的 MazeRun 分支,并在项目副本中为游戏添加了音效。如果我们认为这也是对我的代码的一个很好的补充,会发生什么呢:
- 您在 GitHub 上创建了一个提交到我的存储库的 Pull 请求。
- 我收到拉取请求,并检查将要发生的变化。
- 我接受这些改变。
- 代码被合并(假设没有合并冲突)。
一个好的拉请求需要相关人员之间的某种交流,否则收到拉请求的开发人员很容易被弄糊涂。Pull 请求类似于 merge,只是它完全发生在 GitHub 上。拉请求是对开源软件进行大小改进的一种常见方式。
分支发展
git 的一个关键特性是我们可以并行处理多件事情。为了将时间机器的等效物延伸得更远一点,想象我们有多个交替的现实(2009 年的《星际迷航》电影包含了一个这造成的实际问题的极好例子)。幸运的是,有了 git,这些交替的现实或分支并不那么难以管理。
默认情况下,有一个单独的分支,称为master
。让我们假设我们想要为游戏开发一个新的实验特性(例如,音效)。我们可以使用命令为其创建一个专用分支,并将其命名为sound_effects
:
git branch sound_effects
现在我们有两个代码分支,master
和sound_effects
,两者仍然是相同的。我们可以看到所有分支
git branch
我们看到一个分支列表,当前活动的分支标有星号(*
):
* master
sound_effects
为了处理新的分支,我们需要切换到它:
git checkout sound_effects
你可以用git branch
验证我们现在真的在新的分支机构了。我们现在可以正常地编辑代码、添加文件和提交。这里,我们简单地创建一个占位符:
echo "print(‘BEEP’)" >> sound.py
git add sound.py
git commit -m "created file for sound effects"
新文件被添加到新分支的存储库中。让我们切换回master
分支:
git checkout master
我们注意到新创建的文件sound.py
不见了!我们的代码中现在有两个交替的现实。
合并分支
在某些时候,我们可能会决定音效已经足够成熟,可以包含在节目的主线中。我们可以将一个分支的更改应用到另一个分支。假设我们仍然在master
分支中。我们可以使用git merge
将sound_effects
分支的变更合并到master
中:
git merge sound_effects
请注意,master
分支在此期间可能会进一步发展。在这种情况下,git 将合并更改,并要求我们手动合并,以防它们重叠。这样,我们可以确保代码总是一致的。
使用 git 中的分支进行开发有几种做法。最重要的最佳实践是将稳定的代码保存在master
分支中,并将新实现的特性保存在其他地方(例如,development
分支)。一旦一个特性足够稳定,它就会被合并到master
中。对于本书的代码库(
http://github.com/krother/maze_run
),我为许多章节创建了单独的分支。这样,我可以保持代码的上下文清晰地分开。我定期将不同的分支合并在一起。这些分支及其关系给人留下了深刻的印象(见图 12-3 )。理解什么是分支以及如何使用它们需要一些思考。我建议不要在你的第一个 git 项目中使用分支。然而,它们是职业发展中的一项基本技术。
图 12-3。
Graphical log of a git repository with multiple branches
正在配置 git
一般来说,一旦您熟悉了基本命令(即add, commit, status, push,
和pull
),使用 git 可以很好地与大多数日常编程活动集成。有几个配置选项让使用 git 变得更加愉快。
忽略文件
在导入 Python 模块时,Python 3 会在__pycache__
目录下创建字节码来加速执行。在 MazeRun 中,只要我们运行一次程序,__pycache__
目录就会包含类似于generate_maze.cpython-34.pyc
的文件。Python 会自动更新缓存的文件,所以我们可以安全地忽略它们。然而,git status
会不断纠缠我们将__pycache__
目录添加到我们的存储库中:
> git status
Untracked files:
(use "git add <file>..." to include in what will be committed)
___pycache___/
有充分的理由不将__pycache__
目录添加到我们的存储库中。首先,Python 自动管理它们,所以它们对其他人来说毫无用处。第二,它们会在我们每次运行代码时改变,产生许多不必要的消息。我们更希望 git 不要在我们每次输入git status
时都提醒我们这些文件。我们通过在主项目目录中创建文件.gitignore
来实现这两个目标。.gitignore
文件包含 git 不会打扰我们的名称和模式。该文件可能包含以下行:
___pycache___/*
现在,git status
将不再提及缓存目录。此外,当我们试图通过git add
或git add *,
添加它时,它会被拒绝。除了字节码文件,大多数项目包含其他不需要被版本控制跟踪的文件。例如,这些包括
- 本地配置设置(例如,包含我们的主目录)
- 日志文件
- 数据库
- 临时文件
- 密码
当使用git status
和git diff
时,前三种方法会不必要地扩大存储库,并产生大量背景噪音。最后一个是严重的安全风险(绝对不要向存储库添加密码)。最佳实践是将这些类型的文件作为模式添加到.gitignore
中。典型的.gitignore file
可能包含以下项目:
___pycache___/*
*.conf
*.log
*.db
*.tmp
password.txt
第十三章介绍的 pyscaffold 工具会自动为我们创建一个更详细的.gitignore
文件。当多个人一起开发一个程序时,有时会发生他们来回修改一行代码的情况。这可能是各自机器上不同的目录名或导入语句。当我们观察到这种模式时,它暗示了一些配置需要在存储库之外进行管理(例如,在一个环境变量或.bashrc
文件中)。每当你看到人们来回修改代码,这是停止编码并开始讨论的好时机。
全局设置
git 存储配置选项的第二个地方是我们主目录中的.gitconfig
文件。在这里,我们可以定义我们的用户名和有用的快捷键。可以使用git config
命令或在文本编辑器中编辑该文件。为了简单起见,我将坚持后者。我自己的.gitconfig
文件是这样的:
[user]
email = krother@academis.eu
name = krother
[alias]
ci = commit
st = status
l = log --pretty=oneline
[user]
部分只包含我在 github 上的用户名,这样我就不必每次连接到公共存储库时都输入用户名。[alias]
部分定义快捷方式:git ci
类似于git commit
, git st
类似于git status,``git l
以单行方式总结日志。
用法示例
如果您以前没有使用过版本控制,您可能会发现使用 git 非常有益。当我开始使用版本控制系统时,过了一会儿,我想知道没有它我怎么能写出任何程序。然而,根据项目的规模和贡献者的数量,您可以以许多不同的方式使用 git。
二十个字符:低流量的小项目
在小型项目中,您可以将版本控制视为您的私人时间机器。git 为代码、数据、文档或其他任何东西维护了一个清晰的历史。例如,我用 git 编写和维护许多课程材料。在 GitHub 上维护许多小的存储库并没有什么不好。我已经在 GitHub 上创建了 25 个存储库,在 Bitbucket 上又创建了 5 个。我的一个最小的存储库只包含三个提交、两个 Python 脚本和几个图像文件(
http://github.com/krother/twenty_characters
).
它的目的是保存一个项目,以防我不小心擦除我的备份驱动器。我也为其他人创建的知识库做出了贡献。在某些情况下,我的贡献只有一行。真的没有最小尺寸。
Python:一个每天都有提交的巨大项目
另一方面,共享存储库可能会变得非常大。例如,标准 Python 解释器 CPython 的存储库(镜像在 http://github.com/python/cpython
),
上)包含 131 个贡献者的 93,000 多个提交。吉多·范·罗苏姆自己持有 10800 个提交,添加了超过一百万行代码,删除了 700000 行(见图 12-4 )。历史可以追溯到 1991 年。每天都会提交多次更改。
图 12-4。
Contributions to the Python interpreter by a few core developers over time. The red/green numbers indicate the lines contributed/deleted. Taken from http://github.com/python/cpython
.
grep:一个长期项目
其他存储库的流量较少。例如,grep
的存储库,一个在文件中搜索文本的 Unix 命令行工具,已经存在很长时间了。GNU grep 库在 1999 年有 35,000 行代码,但历史可以追溯到 Mike Haerkal 在 1988 年发布的第一个版本,这是 Ken Thompson 在 1973 年对原始 grep 的重写。虽然grep
通常被认为是一个非常稳定的工具,但一个开发团队每周都会修改一到两次几行代码。如果没有版本控制,这个程序不可能维持超过四十年!
如示例所示,似乎也没有最大大小。我们通常为每个项目找到一个存储库。我强烈反对在同一个存储库中放置多个项目。毕竟,仓库并不昂贵。
git 是一个非常强大的工具。到目前为止,我们只是触及了表面。表 12-1 给出了最常见的命令。要了解更多,我强烈推荐斯科特·沙孔和本·施特劳布的《Pro Git 》( a press,2009 ),这是一本专门介绍 Git 提供的可能性和工作流的书。
表 12-1。
git Commands
| 向存储库添加文件`git init` `git add ` `git commit -m "message"` `git commit –amend` | 在当前目录中创建新的 git 存储库跟踪给定文件中的更改将跟踪的更改写入存储库更改提交消息或将文件添加到上次提交中 | | 导航修订`git status` `git checkout .` `git diff ` `git rm ` `git log` `git log ` | 显示添加、修改和未跟踪的文件放弃本地更改显示自上次提交以来的更改从下次提交中删除文件列表提交消息列出给定文件的提交消息 | | 配置 git `git config` | 更改配置变量 | | 使用远程储存库`git clone` `git pull` `git push` | 创建本地存储库的本地副本更新本地副本将更改提交到远程存储库(例如 GitHub) | | 高级选项`git branch` `git branch ` `git checkout ` `git merge` `git blame ` `git cherry pick hash` `git rebase` `git bisect` | 显示分支创建新分支将本地拷贝切换到给定分支合并两个分支显示谁编辑了哪一行拷贝了旧提交的文件将两个分支重新排列成线性历史记录指导您完成提交历史记录中的二分搜索法 |其他版本控制系统
有一些可供选择的版本控制系统值得一提:
水银的
一个初学者友好的分布式版本控制系统。它的特性比 git 略少,但是常规的工作流比 git 更容易使用。网站 http://hginit.com
是一个不错的起点。
颠覆(SVN)
早在 git 和 Mercurial 之前就存在的非分布式版本控制系统。现在没有理由在新项目中使用它。我听到的使用 SVN 的最有说服力的论据是一家生产安全关键软件的公司。他们希望 100%确定他们使用的是一种稳定的技术,这种技术在过去十年没有改变,在未来也不太可能改变。参见 http://subversion.apache.org/
.
并行版本软件(CVS)
这是一个更老的版本控制系统,在博物馆中赢得了一席之地。
Bitbucket
像 Github 这样的代码库,允许免费使用有限数量的公共和私有库。Bitbucket 兼容 git 和 Mercurial。参见 https://bitbucket.org/
.
一套合作式软件开发管理系统
一个致力于开源项目的代码库。十多年来(在 GitHub 广泛流行之前),Sourceforge 是开源软件的主要市场。参见 https://sourceforge.net/
.
最佳实践
- git 是一个跟踪代码变化的版本控制系统。
- 随着时间的推移,git 会记录代码的后续版本。
- 存储库是存储在
.git
目录中的变更历史。 - 要跟踪文件中的变更,您需要
git add
文件和git commit
变更。 git log
显示提交历史。每个提交都由一个哈希代码标识。- 通过
git checkout
,你可以回到之前的提交,回到现在。 - 项目的历史不一定是线性的。可以存在多个并行分支。
- GitHub 是一个发布和交换代码的平台。它也是一种简单的备份机制。
- 当两个贡献者同时编辑不同的文件时,git 会自动合并更改。
- 当两个参与者编辑相同的代码时,您需要手动合并更改。
.gitignore
文件包含不会被跟踪的文件名模式。- 合理使用存储库没有最小和最大的大小。所有 Python 项目都受益于使用版本控制系统。
十三、设置 Python 项目
For proper home decoration, you need three things: first, clean up the construction site. Second, the right tools. Third, the beer in the refrigerator. -My dad
当我们从头开始编写一个小程序时,我们并不太担心组织文件。我们只是把所有的东西都收集在同一个地方,这很好。MazeRun 游戏从一些 Python 文件和一些图像开始。但是随着项目的发展,其他文件开始积累。我们已经看到了各种输入和输出文件、测试、测试数据、配置文件、文档,当然还有更多 Python 模块。如何合理组织这些文件?我们如何控制对外部 Python 模块的依赖?意识到软件只有一部分由代码组成,一般来说,组织文件和构建 Python 项目的最佳实践是什么?
坚实的项目结构有助于我们
- 快速查找文件。
- 无需太多配置即可应用标准工具(例如,用于测试和打包)。
- 让其他程序员参与进来,不用解释太多。
- 在多个项目之间轻松切换。
- 防止项目相互干扰。
在本章中,我们将进行挖掘工作,使我们的项目进一步发展成为可能(见图 13-1 )。首先,我们将使用 pyscaffold 工具为文件和目录建立一个结构。把这个结构想象成一个有支撑墙的大沟,所有东西都可以放进去。其次,作为我们项目的基础,我们将使用 virtualenv 工具来管理已安装的 Python 模块。
图 13-1。
If programs were buildings, setting up a project would look like an excavation. The project contains a few things that are not a part of the program directly: a versioning system, a test environment, documentation, and so on. But what if I have already set up my project?
与建筑相比,我们可以采用现有的方案来构建这里提出的结构。即使我们已经有了一个 git 存储库,也可以通过git mv
快速重新排列文件以适应给定的结构。因此,不仅我们的程序代码,而且支持代码的所有其他部分都将有自己的位置。
使用 pyscaffold 创建项目结构
幸运的是,Python 项目的文件和目录结构有一个事实上的标准。这种结构在大大小小的项目中都有。坚持它算作一个最佳实践,因为这种结构在 Python 程序员中是众所周知的,并且与其他 Python 工具配合得很好;也就是说,它有助于运行自动化测试和创建软件版本。pyscaffold 工具根据这个标准为 Python 项目创建了一个标准结构。它会在新的项目文件夹中创建重要的目录和文件。当然,我们也可以用一些 Linux 命令来设置它的大部分。使用 pyscaffold 的优点是我们确保了多个项目的一致性,并且它创建了一个干净的脚本,使得我们的软件在整个生命周期中更容易测试、构建和发布。
When is pyscaffold not applicable?
并非所有 Python 项目都使用 pyscaffold 创建的目录结构。最值得注意的是,像 Django、web2py 和 Zope / Plone 这样的大型 Python web 框架都有自己的脚本来为新项目创建目录和配置文件。丹尼尔·格林菲尔德(Daniel Greenfield)和奥黛丽·罗伊(Audrey Roy)所著的《Django 的两勺》(两勺出版社,2015 年)一书是组织 Django 项目(或 Django 总体而言)的绝佳资源。
安装 pyscaffold
pyscaffold 可以通过pip
无痛安装:
sudo pip install pyscaffold
除了 Python 包,pyscaffold 还需要安装 git。详见第十二章。为了用 pyscaffold 创建一个新项目,我们从用于 Python 开发的文件夹(例如,projects/
)开始,运行 pyscaffold 附带的putup
脚本:
cd projects/
putup maze_run
这里的maze_run
是我们想要创建的 Python 项目和 Python 包的名称。我们观察到 pyscaffold 创建了一个带有 4 个子目录和 10 个文件的maze_run
目录,尽管其中一些是隐藏的。键入ls -la
会导致
.git/
docs/
maze_run/
tests/
.coveragerc
.gitattributes
.gitignore
AUTHORS.rst
LICENSE.txt
MANIFEST.in
README.rst
requirements.txt
setup.py
versioneer.py
在下文中,我们将更详细地查看所创建的目录和文件(以及其他一些文件)。
Python 项目中的典型目录
通常,在一个好的目录结构中,每个文件都有一个明显的位置。在典型的项目结构中,pyscaffold 创建四个目录和几个附加文件。
pyscaffold 创建的目录
Pyscaffold 创建了四个目录,其中包含了典型 Python 项目中需要的大部分内容。我们将逐一查看:
Python 包的主目录
这是我们程序的 Python 代码所在的目录。在我们的例子中,它是maze_run
目录。目录是一个可导入的 Python 包;它已经包含了一个__init__.py
文件。它还包含文件_version.py
,该文件从git.
中自动确定版本号,我们根本不需要编辑 version.py 文件。这个目录也是我们可以添加自己的 Python 模块和子包的地方。
测试/目录
这是存储自动化测试的地方。除了一个__init__.py
文件,目录应该是空的。如果我们已经安装了py.test
,我们可以用
python setup.py test
或者
py.test
文档/目录
这是保存文档的单独文件夹。文档工具 Sphinx 的初始文件已经存在。如果我们已经安装了 Sphinx(和 make 工具),我们可以用
cd docs
make html
firefox _build/html/index.html
你可以在第十七章找到关于斯芬克斯的详细介绍。
那个。git /目录
我们看到 pyscaffold 已经自动创建了一个新的git
存储库,并添加了所有文件作为初始提交。隐藏文件夹.git
包含了git
库的内部内容。如果您已经有一个现有的git
存储库,只需将所有目录和文件(除了.git
文件夹)移动到您现有的存储库中,并在那里运行git
add。没有 pyscaffold 在后台做的“隐藏魔法”。
不是由 pyscaffold 创建的目录
还有一些在 Python 项目中常见的其他目录,但不是由 pyscaffold 创建的。有些将由脚本创建;其他的我们需要自己创造:
媒体夹/目录
按照惯例,bin/
目录包含打算从命令行执行的程序(Python 和非 Python)。使用安装 Python 包时
python setup.py install
将为所有用户安装bin/
目录中的程序。在 Linux 上,这些通常会安装在/usr/bin/
目录中。以这种方式安装脚本是一种方便的方法,可以使我们的 Python 工具在系统范围内可用。
目录 build/、dist/和 sdist/
一旦你开始创建你的程序版本,目录build/, dist/,
和/或sdist
就会出现。
那个。hg/目录
如果你在一个项目中看到一个.hg
目录,你就知道这个项目中使用了版本控制系统 Mercurial。.
git
和.hg
可能出现在同一个目录中(在这种情况下,你需要小心一点,因为并行使用两个版本控制系统不是最好的主意)。
数据目录
我们还可能希望在项目中为输入和输出数据建立单独的目录。将数据与程序代码分开通常是明智的。我们是希望将数据目录放在我们的项目文件夹中,还是放在一个完全不同的地方,这取决于我们是否希望将数据添加到同一个git
存储库中。如果没有,最好选择一个单独的位置,并使用环境变量来指定其位置。
文件
pyscaffold 创建的文件
读我. rst
在任何项目中,README
文件都是最重要的文件。因为它在文件系统和公共的git
存储库中是可见的,所以如果大多数开发人员想安装程序或者只是想知道项目是关于什么的,这是他们首先要读的东西。该文件应包含该程序的功能、安装和使用方法以及在哪里可以找到更多信息的简要概述。
拥有一个 ReStructuredText 格式的 README 文件(.rst
)允许我们使用公共存储库 github 和 bitbucket 使用的标记语言来很好地格式化我们的描述。或者,您可以使用 Markdown 格式(带后缀.md
)。两者在相应的 GitHub 页面上呈现得非常相似。
setup.py
setup.py
文件是 Python 项目的核心部分。它包含了构建我们的程序、创建发布和运行测试的指令。我们可以配置setup.py
将你的程序发布到 Python 包索引(pypi.python.org)或者在 Windows 上用 py2exe 创建可执行文件。有了 pyscaffold 创建的setup.py
文件,许多事情都可以开箱即用。最常见的用途是构建您的程序。例如,下面的命令收集了在build/
目录中运行maze_run
Python 包所需的一切:
python setup.py build
我们还可以将该包与系统上安装的其他 Python 模块一起安装:
python setup.py install
AUTHORS.rst
这个文件包含一个简单的开发人员列表和他们的联系方式。
LICENSE.rst
LICENSE.rst
文件是一份涵盖法律方面的文件。默认情况下,作为作者,您是软件的版权所有者,不管这个文件是否存在。但是如果你打算授予其他人使用、修改或重新发布你的软件或者用它赚钱的权利,许多微妙的法律问题就开始起作用了。除非你的同行是律师,否则他们会更喜欢花时间使用软件或改进软件,而不是在定制的许可证之间寻找法律漏洞。因此,最佳实践是使用一个标准软件许可证(麻省理工学院许可证、LGPL、GPL、Apache 许可证,这取决于您希望允许的使用类型)并将默认文本粘贴到该文件中。在 http://choosealicense.com/
.
可以获得开源许可及其人类语言含义的概述
MANIFEST.in
MANIFEST.in
文件包含文件名和文件名模式的列表。该列表用于标识要包含在构建和源代码发布中的文件。例如,我们将在这里找到来自maze_run
包的 Python 文件,但不是测试。我们可以创建一个.tar.gz
档案,通过调用setup.py
来分发MANIFEST.in
文件中指定的所有文件:
python setup.py sdist
versioneer.py
这个脚本方便了用git.
更新版本号,它通常会很好地保护自己,所以我们根本不需要修改它。
requirements.txt
这个文件被pip
用来跟踪所需的 Python 模块及其版本。我们将负责在virtualenv
部分安装它们。
。覆盖范围 c
使用coverage.py
通过自动化测试计算覆盖率的配置文件。我们不会在本书中触及它。
。gitattributes 和。被增加
git.
的默认配置文件详见第十二章。
不是由 pyscaffold 创建的文件
其他一些不是由 pyscaffold 创建的文件值得一提,因为它们经常出现在 Python 项目中。概述见表 13-1 。
表 13-1。
Additional Files Frequently Found in Python Projects Not Created by pyscaffold
| 文件名 | 描述 | | --- | --- | | `CONTRIBUTING.md` | 对希望提交错误报告、错误修复或其他类型的改进的人的说明。 | | `Makefile` | 如果一个程序包含用 C 写的组件,`Makefile`就是相当于`setup.py`的脚本。 | | `fabfile.py` | 通过结构工具促进本地和远程服务器安装之间通信的脚本。 | | `manage.py` | 当您看到这个脚本时,您知道您在一个 Django 项目中。 | | `Dockerfile` | 虚拟化技术 Docker 已经获得了广泛的流行。`Dockerfile`包含创建封装服务或其他组件的 Docker 映像的指令。 | | `tox` `.ini` | 生成工具 tox 的配置文件。 | | `.travis.yml` | 持续集成工具 Travis 的配置文件。 |设置我们程序的版本号
pyscaffold 建立的机制有助于管理我们项目的版本号。要设置版本号,使用git t
ag
命令。按照惯例,版本号总是以一个小的“v
”开头:
git tag v0.3
由于 pyscaffold 创建的基础设施,我们的 Python 包将自动找到这个标记:
>>> import maze_run
>>> maze_run. __version__
’0.3’
使用 virtualenv 管理 Python 项目环境
建立了项目的目录和文件后,我们准备处理项目的第二个基本方面:Python 本身。在管理 Python 安装时,我们很快会遇到一些实际问题:
- 我们将使用哪个 Python 版本?
- 可以用 Python 3.3 和 Python 3.6 一样测试游戏吗?
- 我们可以在没有超级用户权限的情况下安装额外的模块吗?
- 怎样才能防止不同项目需要的 Python 库互相干扰?
- 如何方便地设置
PYTHONPATH
变量和其他环境变量?
在开发软件时,所有这些情况都很常见。有时,我们需要一个 Python 库,它在一个特定的项目中有特定的版本,但在另一个项目中没有。有时,我们希望在同一台机器上安装稳定版本的同时进一步开发我们的程序。在任何情况下,我们都希望防止不同的项目相互干扰。在所有这些情况下,virtualenv 都会出手相救。
Virtualenv 管理多个项目,每个项目都有一组单独安装的 Python 库。它允许我们在项目之间快速切换,我们的整个 Python 环境也随之改变。同时,这是一种轻量级的方法,不会产生大量的管理开销。实际上,virtualenv 就像是我们项目及其 Python 安装周围的护城河。它确保我们项目中 Python 安装或库配置的错误不会破坏我们的整个系统,反之亦然(见图 13-2 )。
图 13-2。
Virtualenv is like building a moat around the house. It prevents a fire from spreading—in both directions. Likewise, a virtual environment prevents that Python projects interfere with each other.
安装 virtualenv
为了方便地使用虚拟环境,需要两个 Python 包。两者都可以安装pip.
第一个是virtualenv
包本身:
sudo pip install virtualenv
第二个是virtualenvwrapper
,它是一个工具集合,使创建虚拟环境和在它们之间切换变得更加容易:
sudo pip install virtualenvwrapper
我们还需要在˜/.bashrc
文件中添加几行,让 virtualenv 知道在哪里可以找到它的配置:
export WORKON_HOME=$HOME/.virtualenvs
export PROJECT_HOME=$HOME/projects
source /usr/local/bin/virtualenvwrapper.sh
最后,我们需要一行额外的代码来使 Python3 成为 virtualenv 管理的项目的默认解释器:
export VIRTUALENV_PYTHON=/usr/bin/python3
将项目连接到 virtualenv
我们现在可以告诉 virtualenv 接管我们用 pyscaffold 创建的项目文件夹。首先,我们使用以下命令启动一个新的 virtualenv 项目:
mkvirtualenv maze_run
在 pyscaffold 项目中使用 virtualenv 不是先决条件;virtualenv 适用于任何类型的目录,甚至是空目录。我们可以明确指定 Python 版本:
mkvirtualenv maze_run -p /usr/bin/python3
我们得到以下消息:
Running virtualenv with interpreter /usr/bin/python3
Using base prefix ’/usr’
New python executable in maze_run/bin/python3
Also creating executable in maze_run/bin/python
Installing setuptools, pip.done.
幕后发生了什么?首先,virtualenv 在自己的文件夹˜/.virtualenvs/maze run
.
中创建一个新的子目录,在那里将存储库和配置脚本。在子文件夹˜/.virtualenvs/maze run/bin/
中,你会找到 Python 和pip.
的副本,它们将代替/usr/bin/python3
或你的 Python 解释器。实际上,您现在有了一个独立的 Python 安装。一件好事是我们没有什么理由去挖掘˜/.virtualenvs
文件夹;大多数时候,它能很好地管理自己。第二步,我们需要将虚拟 Python 环境连接到我们的项目文件夹:
cd maze_run/
setvirtualenvproject ˜/.virtualenvs/maze_run/ .
就这样,我们结束了!虚拟环境已经可以使用了。在图 13-3 中,你会发现项目目录和隐藏的 virtualenv 目录包含了什么。
图 13-3。
Contents of a project directory and the according virtual environment directory. Most of the time, it is sufficient to edit only the files/directories on the left side.
使用 virtualenv 项目
虚拟 Python 环境的主要好处是我们可以在它们之间自由切换。几个简单的命令就可以打开或关闭虚拟环境。要开始处理maze_run
项目,请键入
workon maze_run
我们看到项目名称(maze_run
)出现在我们的提示中。例如:
(maze_run)˜/projects/maze_run:master$
现在,每当我们运行 Python 时,都会使用虚拟环境的 Python 安装。我们可以检查一下
>>> import sys
>>> sys.executable
’/home/krother/.virtualenvs/maze_run/bin/python’
我们已经成功地将我们的工作环境从系统的其余部分分离出来。即使我们替换或删除我们的主要 Python 安装,虚拟环境也将继续工作。
在 virtualenv 中安装软件包
在虚拟环境中使用 Python 解释器时,您可能会注意到之前安装在系统上的所有 Python 库都消失了。virtualenv
改变模块的搜索路径,不再包含标准安装目录。起初这可能感觉有点麻烦,但这正是使用virtualenv.
的目的,我们不需要关心系统范围内安装的库是否有正确的版本,或者是否损坏或有错误。他们实际上已经出局了。相反,我们的项目现在有了自己的库。如果我们的项目需要一个库,我们必须在虚拟环境中使用pip
或python setup.py install
从头开始安装:
pip install pytest
这将安装py.test
测试框架,就像第八章一样,这次是在˜/.virtualenvs/maze run/
目录中。
No administrator privileges are required to install packages
这是使用virtualenv
的一个很好的副作用,它不仅方便,而且避免了由于安装像root
一样的潜在有害包而危及系统安全。
如果我们的程序需要安装特定版本的库,我们可以将它们写入requirements.txt
文件。例如,我们用pip freeze
获得py.test
的版本,并可以将结果放在requirements.txt
中。该文件将包含一个库:
pytest==2.9.2
以下命令安装项目的所有依赖项:
pip -r requirements.txt
Doesn’t installing libraries twice generate extra overhead?
简而言之,是的。如果我们创建十个虚拟环境,每个环境都需要同样的十个库,那么每个库将在我们的系统上安装十次。当然,这将使用十倍以上的磁盘空间。幸运的是,大多数 Python 库并没有那么大。基本上是用磁盘空间来换取单独修改每个安装的自由。
离开虚拟会话
当我们在虚拟环境中完成工作并想关闭它时,我们可以键入
deactivate
虚拟环境专用于单个终端会话。因此,我们可以同时处理许多项目,只要它们在不同的终端中打开。
用于管理虚拟环境的其他 shell 命令包括
lsvirtualenv
rmvirtualenv
cpvirtualenv
How to create many virtual environments for testing?
如果您想用 Python 解释器和库版本的多种组合来测试您的软件,您可以用 Tox 来自动化这个过程。Tox 从版本列表中自动创建虚拟环境,并为您指定的每个组合运行测试。 https://testrun.org/tox/latest/
见
配置 virtualenv 启动和停用
建立一个项目环境通常需要比创建几个目录和安装 Python 库更多的东西。这些可能包括
- 设置
PYTHONPATH
变量 - 设置 C 库的路径
- 使用登录名和密码设置环境变量
- 设置其他环境变量
- 启动数据库服务器
- 启动其他服务和守护程序
- 启动虚拟机
还有,这些东西需要在我们工作结束后清理干净。幸运的是,virtualenv
为特定于项目的配置的设置和清理提供了一个自然的地方。在˜/.virtualenvs/maze run/bin/
目录中,有四个用于该目的的命令行脚本:每次激活虚拟环境时,preactivate
和postactivate
将会运行。每次您停用环境时,predeactivate
和postdeactivate
都会被执行。你可以在图 13-4 中找到事件的精确顺序。
图 13-4。
Activation/deactivation sequence in virtualenv
我们无法在此详细讨论这些任务。相反,我们将看一两个例子。集成其他的并不那么困难,只要它们可以用命令行脚本来表达。
设置 PYTHONPATH 变量
到目前为止,在 virtualenv 的启动脚本中最常见的是设置PYTHONPATH
变量。通过这样做,我们希望将maze_run
包导入到我们的整个项目中。我们不能在系统范围内这样做(例如,在.bashrc
文件中),因为那样我们的环境会再次开始互相干扰。为了设置变量,我们需要在postactivate
脚本中导出它。脚本中相应的一行如下所示:
export PYTHONPATH
=/home/krother/projects/maze_run/
Python 3 非常注意已安装库的路径,所以大多数时候不需要在PYTHONPATH
中包含任何其他内容。
当导出PYTHONPATH
变量时,您可能还会看到下面的表达式:
export PYTHONPATH
=$PYTHONPATH:/home/krother/projects/maze_run/
它复制了PYTHONPATH
变量的内容并附加了我们的项目目录。然而,当将这个命令与virtualenv!
结合使用时,有一个令人讨厌的问题,如果我们现在激活和停用我们的虚拟环境几次,PYTHONPATH
会变得越来越长。这样会很快产生不想要的副作用!
用 virtualenv 安装 Pygame
大多数时候,用pip
在虚拟环境中安装 Python 库效果很好。一个明显的例外是 Pygame。要安装它,我们需要执行几个手动步骤。首先,系统级需要几个 C 库(主要是 SDL 媒体库和版本控制系统 Mercurial)。在 Ubuntu Linux 上,它们可以与
sudo apt-get install mercurial python3-dev python3-numpy libav-tools \
libsdl-image1.2-dev libsdl-mixer1.2-dev libsdl-ttf2.0-dev libsmpeg-dev \
libsdl1.2-dev libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev
其次,我们从存储库中检索 Pygame 包的源代码并编译这些包:
hg clone https://bitbucket.org/pygame/pygame
cd pygame
python3 setup.py build
现在我们可以切换到我们的虚拟环境并在那里安装 Pygame:
workon maze_run
cd pygame/
python3 setup.py install --prefix="$HOME/.virtualenvs/maze_run"
如您所见,我们项目的隔离并不完美。因为我们必须安装系统范围的库,它们仍然可能干扰其他项目。此外,virtualenv 不考虑安全或安保方面。如果我们不小心编写了一个脚本,删除了所有其他项目中的所有文件(或者恶意攻击者故意这样做),virtualenv 不会采取任何措施来防止这种情况。virtualenv 创造的隔离让开发更方便,没别的。如果我们想要更强地隔离我们的项目,我们可以使用更重的虚拟化技术(例如,vagger、VirtualBox、Docker,或者甚至将我们的程序部署到 AWS 这样的基于云的服务)。但是让这些设备正常工作就是另一回事了。
最佳实践
- 代码只是 Python 软件项目的一部分。
- pyscaffold 工具创建了一个包括目录和文件的标准项目结构。
- 标准目录用于程序代码、测试和文档。
- 脚本是测试、构建和安装程序的入口点。
- 可以使用
git tag
命令设置软件的版本号。 - virtualenv 工具允许您管理多个 Python 环境。
- 每个虚拟环境管理自己的一组已安装库。
- 在 virtualenv 中安装库不需要超级用户权限。
- 激活或停用项目时,可以执行自定义 shell 脚本。
- 虚拟环境不提供任何安全防范措施。
十四、清理代码
“首先让它工作,然后让它正确,最后,让它快速。”—Stephen C. Johnson 和 Brian W. Kernighan 在《字节》杂志(1983 年 8 月)《系统编程的 C 语言和模型》中
当我们学习用一种新的语言编程时,我们一开始会编写非常小的程序。在 Python 中,小程序很小;像搜索文本文件、缩小数字图像或创建图表这样的任务可以用十行或更少的代码来完成。在程序员学习之初,每一行都是艰苦的工作:学习简单的事情,如向列表追加一个值或将一个字符串剪成两部分,似乎要花很长时间。每一行至少包含一个 bug。因此,我们在微小的步骤中取得进展。但是过了一段时间后,我们对这门语言的知识变得更加牢固了。几个小时、几天或几周前似乎无法实现的事情,突然间变得容易了。所以我们开始编写更雄心勃勃的程序。我们开始编写不再适合单个屏幕页面的代码。很快,我们的程序超过了 100 行。对于一个程序来说,这是一个有趣的大小,我们已经通过 MazeRun 游戏达到了这个大小。在本书的第一部分,我们开始从头开始开发 MazeRun 游戏。我们一个接一个地增加新功能。在这一章中,我们将增加一个新的特性。我们的编程风格需要如何适应一个不断增长的程序?我们可以永远一个接一个地添加小功能吗?还是我们必须考虑以前没有考虑到的方面?
有组织和无组织的代码
例如,我们将从文本文件中加载图块数据。在第二章中,我们将瓦片实现为元组列表。将这些信息放在一个文件中会使扩展游戏中的图形和测试我们的程序变得更容易。我们可以很容易地将图块及其索引存储在文本文件tiles.txt
中:
REMARK x and y positions of tiles in the .xpm file
# 0 0
0 1
o 1 0
x 1 1
. 2 0
* 3 0
g 5 0
要使用这些信息,我们需要读取文本文件,解析其内容,并收集所有瓷砖的边界矩形作为包含 Pygame 的字典。矩形对象。我们还需要忽略以REMARK
开头的行。读取和解析都可以用一个小的 Python 程序来完成。由经验较少的 Python 程序员编写的代码的第一个工作版本可能如下所示:
tilefile = open('tiles.txt')
TILE_POSITIONS = {}
for text_str in tilefile.readlines():
print([text_str])
x = text_str[2]
# print(data)
# if 'REMARK' in data == True: # didn't work
data = text_str
if text_str.find('REMARK') == 0:
text_str = text_str.strip()
#print(line[7:]) # doesnt need to be printed
continue
else:
import pygame
y = int(text_str[4])
r = pygame.rect.Rect(int(x)*32, int(y)*32, int(32), int(32))
key = data.split()[0]
TILE_POSITIONS[key] = r
# print(TILE_POSITIONS[-1])
continue
print(TILE_POSITIONS)
这个程序工作正常,但是看起来很乱。代码很难理解,主要是因为我们必须阅读并理解所有的代码行才能理解程序的功能。当然,这个程序包含了不必要的行,尽管我们可能不容易看出是哪一行。另一方面,对于熟练的 Python 程序员来说,解析文本文件并不是真正的挑战。他们可能会屈服于用尽可能少的行来解决任务的诱惑:
from pygame import Rect
mkrect = lambda x: (x[0], Rect(int(x[1])*32, int(x[2])*32, 32, 32))
tile_positions = dict(map(mkrect, [l.split('\t') for l in open('tiles.txt')\
if l[0]!='R']))
print(tile_positions)
这个程序也能正常工作。虽然比较短,但也不容易理解。我们可以争论这两个程序中哪一个更丑陋:为了简洁,人们可能更喜欢第二个,或者第一个,因为它使用了不太复杂的语言特性,使得它(理论上)更容易被编程新手理解。我将把决定权留给你,因为这两个项目都有很多需要改进的地方!在本章中,我们将清理这些从文本文件中读取图块坐标的程序。
软件熵:无组织代码的原因
当程序增长时,它更容易变得无组织。保持我们的程序干净变得更加重要。但是为什么程序一开始就变得没有条理呢?当一个程序增长时,为什么我们需要组织更多的东西,做更多的清理工作?正如我们在上一节中看到的,解决同一个编程问题有多种可能性。显然,可能的实现数量随着规模的增加而增加。假设我们把一个四行程序分成函数。为了简单起见,让我们假设我们可以自由地移动函数之间的边界。我们可以创建四个函数,每个函数一行,或者我们可以有一个四行函数,两个两行函数,和三个其他的组合(见图 14-1 )。现在考虑将一个八行程序分成几个函数。有了两倍长的代码,我们可以创建八个单行函数。我们可以创建一个八行的函数。我们可以创建一到四个双线函数。我们可以创造介于两者之间的任何尺寸组合。可能性的数量比行数增长得快得多。如果我们不仅考虑函数,还考虑所有类型的程序结构(列表与字典、for
循环与列表理解、类、模块等等)。),同一问题的可能实现的数量实际上是无限的。
图 14-1。
Possibilities to structure a program into functions. Top row: a four-line program; all six possibilities are shown (four one-line functions, two two-line functions, etc.). Bottom row: an eight-line program; only five out of many possibilities are shown. With more rows, the number of possible structures grows exponentially.
这些可能的实现中哪一个是最好的?这取决于我们的程序做什么,使用的输入类型,我们使用什么库,以及用户的期望。我们将这简单地称为你的程序的上下文。现在有一个令人讨厌的事实:当一个程序成长时,环境会改变。当我们添加新的功能、新的输入类型、新的库时,环境会逐渐改变。当环境改变时,以前看起来是最佳解决方案的代码会变成次等的解决方案。不断变化的环境是难以找到理想实现的一个主要原因。
无组织代码的第二个原因是程序是由人类编写的。例如,编写有效的代码比编写整洁的代码更容易。程序通常在多个会话中编写。有一天我们开始在程序中添加一些东西,但是第二天忘记完成它。最后但同样重要的是,时间压力导致代码写得很快,而不是很干净。保持代码的整洁需要程序员高度的自律,这是一场与懒惰、健忘、匆忙和其他人类不完美表现的持续战斗。
由于两个原因,变化的环境和人类的不完美,随着时间的推移,它们变得无组织是所有程序的固有属性。这种代码无组织的现象被称为软件熵,借用了热力学第二定律的概念。用不那么光彩的话来说,法律规定:无序是自己生长的。不管是什么原因,都是我们要收拾残局。我们需要知道如何识别、清理或防止无组织的代码。
如何识别无组织代码?
有许多方法可以使代码变得杂乱无章。它们中的一些被赋予了奇特的名字,如代码气味 s 或 un pythonic 代码。这些术语有助于与其他 Python 程序员就编码实践展开对话。然而,当我们在寻找清晰的规则时,这些关键词就没什么用了。没有定义什么是 pythonic 代码,什么不是。在这里,我们将检查需要清理的代码的四个方面:可读性、结构缺陷、冗余和设计缺陷。
可读性
在一个更大的程序中,我们花在阅读上的时间比写代码还多。因此,代码的可理解性至关重要。我们可以问自己一个非常简单的问题:当我们阅读代码时,我们理解它吗?初学者和高级程序员编写的代码都或多或少具有可读性。很明显,像第一个例子中那样用print
语句和注释行乱丢代码会降低可读性。在第二个例子中,玩代码高尔夫(用尽可能少的击键来解决问题)也没有帮助。可读性从小事开始。比较线条
if text_str.find('REMARK') == 0:
使用语义相同的行
if line.startswith('REMARK'):
第二种表达方式更接近英语,更明确,因此可读性更强。可读性体现在代码的许多方面:选择描述性变量名、代码格式、合理选择数据结构(想象一下只使用元组编写程序)、代码模块化和使用注释。当然,在阅读代码时,经验很重要。了解高级语言特性和一些库是游戏的一部分。但是很多时候,需要查看一段代码,并快速找出它的作用和存在的原因,而不是手动逐行跟踪代码。如果你发现不在脑子里执行代码很难回答这些问题,那么可读性就需要提高了。
结构性弱点
有两种非常常见的结构性弱点:第一种是缺乏结构。缺乏结构的一个典型标志是不包含任何结构的大块代码:包含 100 行或更多行的函数,没有任何函数的程序,等等。在 Python 中,不用任何函数写程序也是可以的。例如,我经常在不考虑函数的情况下编写简短的数据分析脚本。但是超过 100 行,它们很快就会变成一次性代码。在 100 行以上,结构化是必要的,程序越大,它就变得越重要。
第二个结构弱点是伪结构,代码看起来是结构化的,但实际上是另一种形式的整体块。一个简单的例子是多个for
循环、if
条件和其他代码块,如下例所示:
for line in tilefile:
if not line.startswith('REMARK'):
try:
columns = line.split('\t')
if len(columns) == 3:
x = int(columns[1])
y = int(columns[2])
if 0 < x 100:
if 0 < y < 100:
r = Rect(...)
...
根据经验,当您在任何 Python 程序中达到第四级缩进时(第 16 列之前的所有内容都是空白),有些事情很奇怪。通常代码可以通过重组来改进。特别是,如果有多个嵌套的for
循环,性能可能会很快下降。
伪结构的一个不太明显的例子是代码被分割成多个功能,但是职责没有明确定义。考虑以下示例:
def get_number():
a = input("enter first number")
return a
def calculate(a):
b = input("enter second number")
result = float(a) + (b)
print("the result of the addition is:", end="")
return result
def output(r):
print("{}".format(r))
num = get_number()
result = calculate(num)
output(result)
在本例中,输入和输出部分由calculate
函数执行。功能之间的界限难以理解,代码变得更难管理。当代码变长时,这种结构上的弱点变得非常普遍。还有许多其他种类的结构性弱点。
裁员
冗余违背了编程的不重复原则(DRY)。通常,冗余来自复制粘贴代码片段。最明显的冗余形式是重复行。例如,在本章的第一个代码示例中,continue
语句出现了两次:
...
if text_str.find('REMARK') == 0:
...
continue
...
continue
在这种情况下,第一个continue
是多余的,因为第二个将被执行。第二个continue
也是多余的,因为循环无论如何都会终止。像这样的冗余行增加了程序的大小,损害了可读性。在这种情况下,我们可以简单地删除它们。另一种冗余是代码块以微小的变化重复出现。有时候会出现重复块,因为程序员使用Ctrl-C + Ctrl-V
作为编程工具;有时它们会自己进化。在这两种情况下,重复的代码块可以通过将多余的代码行移到单独的函数或类中来删除。这种重组被称为重构,可以成为更大程序中的复杂操作。冗余的一种更微妙的形式是数据结构中的冗余。考虑到冗余出现在许多层次上,有时很难发现,冗余成为大型程序中缺陷的主要原因就不足为奇了。
设计弱点
程序设计是一个更难的方面。好的程序设计会产生健壮的程序,它能容忍不寻常的输入,有明确定义的可接受值范围,当输入错误时有明确的错误消息。一般来说,稳健的设计可以防止缺陷的蔓延。在 MazeRun 游戏中,至少有一个设计弱点:随机迷宫生成器有时会在迷宫中创建无法到达的点。目前这还不是问题,但将来可能会成为问题。
好设计的第二个方面是可扩展性:在不破坏现有功能的情况下,向程序中添加新内容的难易程度。为了实现一个简单的特性,需要修改的地方越少,设计的可扩展性就越强。好的设计预见到未来会发生什么样的变化。当你想评估一个设计时,问问你自己:你对改变代码感觉如何?如果有你不喜欢接触的区域,设计可能需要改进。
总而言之,难以阅读、无结构、冗余或包含设计缺陷的代码可能被认为是无组织的(见图 14-2 )。通常,这些症状会同时出现。您可以在加载图块坐标的两个代码片段中找到所有四种症状。现在想象在一个 1000 行的程序中出现类似的症状,这个程序跨越了许多屏幕页面——同样,这个问题会随着程序大小的增加而增加。但是抱怨糟糕的代码对我们没有帮助;我们需要思考如何改进它。因此,接下来我们将看看清理 Python 程序的一些最佳实践。
图 14-2。
“I don’t get it. The kitchen got dirty all by itself.” Software becomes messy over time.
清理 Python 指令
我希望前面的例子已经使您相信清理代码是必要的。软件熵原理告诉我们,代码是自己变得无组织的,但不会自己清理(也见图 14-2 )。清理代码是一项日常编程任务。清理代码包括许多非常简单的任务。它们中的大多数都需要很少的 Python 知识,因此我们可以立即开始改进我们的初始实现。为了清理代码,从代码的工作版本开始会有所帮助。理想情况下,有自动测试告诉我们是否破坏了什么。让我们擦,擦,抛光,直到我们的代码发光!
将导入语句放在一起
要理解一个程序,有必要知道它还需要哪些模块来工作(它的依赖)。在 Python 中,依赖关系主要通过import
语句来体现。因此,Python 程序的第一个功能单元应该是一个包含所有import
语句的独立块。我们只是从程序中收集所有的导入语句,并将它们移动到文件的开头。这样,很容易一眼就看出代码需要哪些组件。只导入真正使用的 Python 对象是值得的。在我们的例子中,有一个单一的进口声明,我们只使用pygame.Rect
。我们的进口商品变成了:
from pygame import Rect
我们用一个空行将导入与任何代码分开。
将常数放在一起
之后的导入部分是存放所有常量的好地方。常量是一个变量,它的值在程序执行过程中不会改变。典型的常量是输入和输出文件名、路径变量、列标签或计算中使用的比例因子。我们将简单地在导入块后的单独部分收集所有这些常量。在 Python 中,没有技术手段可以让一个变量成为常量;它们的值总是可以被覆盖。为了更容易区分常量和改变其值的变量,Python 常量按照惯例用UPPER_CASE
字母书写。我们在图块坐标加载器中有两个常量。首先,我们需要计算矩形的像素大小。我们将把它放在一个常量SIZE
中。其次,还有文件名"tiles.txt"
。该文件名假定文件位于当前目录中。为了使程序可以在不同的地方使用,我们需要提供完整的路径。我们可以写
TILE_POSITION_FILE = '/home/krother/projects/maze_run/tiles.txt'
然而,这只能在我自己的计算机上运行,这使得代码非常不灵活。文件名的一个更好的替代方法是使用表达式os.path.dirname(__file__)
来确定当前 Python 模块的位置。然后我们可以用os.path.join
将我们的文件名添加到路径中。程序的完整导入和常量部分现在是:
import os
from pygame import Rect
CONFIG_PATH = os.path.split(__file__)[0]
TILE_POSITION_FILE = os.path.join(CONFIG_PATH, 'tiles.txt')
SIZE = 32
同样,我们用一两行空行将常量与其他代码块分开。在最初混乱的代码中,变量 TILE_POSITIONS 看起来像一个常量,但被程序修改了。我们将其更改为小写以备后用:
tile_positions = {}
随着程序的发展,许多常量会发生变化。在一个 1000 行以下的程序中,这样的改变通常很容易通过编辑代码来适应。但是,如果每次运行程序时常量的值都会发生变化,那么就应该将它们移动到输入文件中,使用 argparse 模块创建命令行选项,或者使用 configparser 模块读取配置文件。
删除不必要的行
在编程中,我们起初认为很重要的行,后来可能证明根本不重要。一个常见的直觉反应是认为“也许我以后会需要它们”,并留下不必要的代码。但是,程序不是仓库!不必要的代码需要被严格剔除。如果您有不想丢失的说明性代码示例,请将它们复制到一个单独的文件中,并为其创建一个单独的git commit
。在我们的例子中,我们有许多不必要的行的例子:print
语句、注释行和前面提到的多余的continue
语句。我们可以简单地删除它们(总共七行)。该程序立即变得更具可读性!现在更容易注意到有一个冗余的变量赋值:
data = text_str
变量data
和text_str
是相同的。我们可以去掉额外的任务。我们也可能认识到,下面的if
条件是一条死胡同:
if text_str.find('REMARK') == 0:
text_str = text_str.strip()
修改后的变量text_str
以后不再使用。因此,我们可以去掉这个代码块,用与if
语句相反的语句替换下面的else
。结果,我们的程序变得比以前清晰多了:
tile_positions = {}
for text_str in open(TILE_POSITION_FILE).readlines():
x = text_str[2]
if text_str.find('REMARK') != 0:
y = int(text_str[4])
r = Rect(int(x)*32, int(y)*32, int(32), int(32))
key = text_str.split()[0]
tile_positions[key] = r
print(tile_positions)
删除线路后,这是验证程序是否仍在运行的最佳时机。我们已经清理了代码中的许多问题,但是我们还没有完成。
选择有意义的变量名
精心选择的变量名对可读性有很大的影响。一般来说,包含英文单词的名字比首字母缩写更好。描述意义的英语单词比描述变量类型的单词更好。如果变量本身是短命的,那么非常短的变量名通常是好的(例如,我们例子中的x
和y
)。我们可以通过用name,
代替rect, key
和用 row 代替text_str
来提高可读性。表 14-1 包含一些好的和坏的变量名的例子。
表 14-1。
Examples of Bad and Good Variable Names
| 严重的 | 好的 | 说明 | | --- | --- | --- | | `xs` | `xsize` | `xs`太短 | | `str_list` | `column_labels` | `str_list`形容一种类型 | | `dat` | `book` | `dat`没有意义 | | `xy` | `position` | `xy`没有意义 | | `plrpos` | `player position` | 露骨的话 | | `line_from_text_file` | `line` | 引用太多 | | `l` | `?` | 史上最烂的变量名!根据字体的不同,它很容易被误认为是 1。 |不时重新检查变量名是值得的。在您开发代码时,它们的含义会发生变化——一个不可理解的变量名是不好的,但一个误导性的变量名更糟糕。有了干净的变量名,缺陷通常变得更容易发现。
惯用的 Python 代码
有一些小的改进要做。我们可以使用 Python 习惯用法,即适用于许多情况的简短、精确的表达式。在此,我仅举两个简短的例子。首先,我们可以使条件表达式更具可读性,如前所述:
if not row.startswith('REMARK'):
其次,我们可以使用csv
模块来挑选文件的列。这比自己解析文件更不容易出错。同样,使用with
语句是一种普遍推荐的打开文件的方式(因为之后它们会自动关闭):
with open(filename) as f:
for row in csv.reader(f, delimiter='\t'):
找到正确的习惯用法很难,关于哪种习惯用法是最好的看法也不尽相同。它需要知识和经验的结合。既没有 Python 习惯用法的完整目录,也没有何时使用它们的明确规则。最接近的是卢西亚诺·拉马尔霍的书《流畅的 Python》(O ’ Reilly,2015)。
重构
到目前为止,我们的清理主要集中在个别线路上。在下一节中,我们将从整体上检查程序的结构。改进程序结构被称为重构。重构是维护大型程序的一个重要的最佳实践。整本书都在讨论重构技术,我们在这里只能对这个主题略知一二。如果你想知道存在哪种重构,网站 https://sourcemaking.com/refactoring
是一个很好的起点。
Hint
当大规模重构代码时,拥有一个好的测试套件是必不可少的。重构的目标总是让程序做和以前一样的事情。很容易(也很诱人)拆开一个程序,重新组装它,然后遗漏一个细节,导致程序后来工作起来不一样。
在基本的清理之后,我们将集中精力进一步改进代码的结构。通常,结构化意味着创建清晰分离的功能、类、模块和其他代码单元。
提取函数
可能最重要的重构是将代码分成精心选择的功能。用 Python 写函数有不同的原因。这里我们主要是将一段较长的代码分成较小的块。为了从现有代码中提取一个函数,我们需要编写一个函数定义,并为输出定义输入参数和返回语句。我们缩进中间的代码,添加一个函数调用和一个 docstring。例如,我们可以从代码中提取一个函数来创建Rect
对象:
def get_rectangle(row):
"""Returns a pygame.Rect for a given tile"""
x = int(row[1])
y = int(row[2])
return Rect(x*SIZE, y*SIZE, SIZE, SIZE)
起初,为仅仅三行代码创建一个单独的函数似乎有些多余。你可能会反对get_rectangle
里面的代码太简单。但这正是重点!我们想要简单的代码。首先,当软件熵出现时,简单代码在更长时间内保持干净;例如,如果我们的函数需要覆盖一两个特殊的情况(并且增长),代码仍然是可读的。第二,简单的代码是其他人(同事、实习生、主管或我们的继任者)可以理解的。第三,简单代码在压力下更可靠:当程序员被最后期限、紧张的经理和天黑后的调试会议困扰时,简单代码是他们最好的朋友。我们从包含大部分剩余代码的第二个函数load_tile_positions
中调用get_rectangle
函数:
def load_tile_positions(filename):
"""Returns a dictionary of positions {name: (x, y), ..} parsed from the file"""
tile_positions = {}
with open(filename) as f:
for row in csv.reader(f, delimiter='\t'):
name = row[0]
if not name.startswith('REMARK'):
rect = get_rectangle(row)
tile_positions[name] = rect
return tile_positions
当你想把自己的程序拆分成函数时,你首先需要确定一段连贯的代码,然后把它移到一个函数中。Python 程序中经常出现一些典型的函数:
- 读取输入,如数据文件或网页
- 解析数据(即,为分析准备数据)
- 生成输出,例如写入数据文件、打印结果或可视化数据
- 任何种类的计算
- 辅助函数,从一个较大的函数中提取代码,使其变小
重构程序时,合理的函数大小是 5-20 行。如果函数将从多个地方被调用,它甚至可能更短。像模块一样,函数应该在函数定义后加上三重引号。文档字符串应该用人类语言描述函数在做什么(如果可能的话,避免使用 Python 术语)。
创建一个简单的命令行界面
把我们的代码划分成函数之后,是时候为程序创建一个顶层接口了。这个接口避免了代码被意外执行(例如,被一个import
)。为了创建接口,我们将所有剩余的函数调用分组在程序的末尾,并将它们包装在一个单独的代码块中。按照惯例,它以一个奇怪的if
语句开始:
if __name__ == '__main__':
tile_positions = load_tile_positions(TILE_POSITION_FILE)
print(tile_positions)
if
表达式是 Python 的一个习惯用法,乍一看很奇怪(尤其是如果你见过其他编程语言的话)。用人类语言来表达就是:“如果这个文件是作为主 Python 程序启动的,那么执行下面的代码块。如果此文件是作为模块导入的,请不要执行任何操作。__main__
块帮助我们避免代码的意外执行。我们现在可以从其他地方导入模块:
from load_tiles import load_tile_positions
tiles = load_tile_positions(my_filename)
在这种情况下,不会打印任何内容,因为导入时不会执行__main__
块。__main__
块的第二个用途是我们可以将load_tiles.py
作为 Python 程序运行:
python3 load_tiles.py
现在我们看到了由print
语句产生的输出,并且可以检查它是否符合我们的期望。在我们的程序中有一个__main__
块作为一般的入口点。如果我们的模块不打算直接执行,我们可以使用__main__
块进行简单的测试代码(本书第一部分的代码包含一些例子)。如果我们正在编写一个用作命令行工具的程序,使用argparse
模块而不是sys.argv
是一个最佳实践。在 Python 项目中,bin/
目录是命令行前端的好地方。
将程序组织成模块
我们已经在前几章中创建了独立的模块。在本章中,我们学习了一个模块。因此,我们将简单地列出一些在开发您自己的模块时要记住的最佳实践:
- 模块不应该变得太大。100-400 线的模块尺寸较好;多达 1000 行的模块是可以忍受的,但是我建议尽快将它们拆分。
- 每个模块都应该有明确的目的。例如,加载数据、写入数据和进行计算都是独立的目的,因此有理由拥有自己的模块。此外,如果您的常量部分变得很大,可能值得将其放在一个单独的模块中。
- 创建一个模块就像将一段代码移动到一个新文件中并在原始文件中添加
import
语句一样简单。 - 不惜一切代价避免循环导入。每当你遇到像 A 需要 B,但是 B 需要 A 这样的关系,就值得考虑一个更好的结构。总是可以避免循环导入。你可以通过把 A 和 B 放在一起来避免这个问题,但是很可能这会在以后引起问题。
- 在导入自己的模块时,写显式导入(避免
import *
)。 - 在每个模块的顶部添加一个三重引用的 docstring。
将程序分解成独立的模块是构建程序最简单的方法之一。
清理后的代码
当我们完成这些清理步骤时,是时候验证程序是否仍然工作了。完全清理和重构的读取瓦片的程序是
"""
Load tile coordinates from a text file
"""
import csv
import os
from pygame import Rect
CONFIG_PATH = os.path.dirname(__file__)
TILE_POSITION_FILE = os.path.join(CONFIG_PATH, 'tiles.txt')
SIZE = 32
def get_rectangle(row):
"""Returns a pygame.Rect for a given tile"""
x = int(row[1])
y = int(row[2])
rect = Rect(x*SIZE, y*SIZE, SIZE, SIZE)
return rect
def load_tile_positions(filename):
"""Returns a dictionary of positions {name: (x, y), } from a text file"""
tile_positions = {}
with open(filename) as f:
for row in csv.reader(f, delimiter='\t'):
name = row[0]
if not name.startswith('REMARK'):
rect = get_rectangle(row)
tile_positions[name] = rect
return tile_positions
if __name__ == ' __main__':
tile_positions = load_tile_positions(TILE_POSITION_FILE)
print(tile_positions)
我们意识到这个程序并没有比我们第一次实现时变得更短。它甚至有点长。但是我们的实现有几个优点值得指出:
- 很容易看出程序做了什么。
- 代码的许多部分比以前更容易阅读。
- 该模块可以被导入并用于定制用途(例如,加载不同的文件或多个文件)。
- 我们可以独立使用这两个功能。这对于编写自动化测试非常有价值(在本书的第三部分)。
- 调试程序时,一次最多读取 10 行就足够了。
- 该程序以
__main__
模块的形式内置了自测功能。
综上所述,这个程序更加简洁,可读性更强。缺陷将很难隐藏在这个程序中。此外,大多数有经验的程序员会认为这段代码写得很好或很有 pythonic 风格。
PEP8 和 pylint
Python 有一个标准的编码风格指南,称为 pep 8(
https://www.python.org/dev/peps/pep-0008
).
pep 8 标准对变量名、导入、文档字符串、函数长度、缩进等给出了明确的指导方针。遵守 PEP8 是最佳实践,因为它使我们的代码对其他人来说是可读的。这也有助于我们以一致的风格写作。幸运的是,我们不需要背诵完整的 PEP8 指南。pylint 工具帮助我们检查我们的代码是否符合 PEP8 标准。例如,我们将在使用 pylint 进行清理之前和之后检查我们的代码。首先,我们需要安装这个工具
pip install pylint
然后,我们可以用以下方法分析任何 Python 文件
pylint load_tiles.py
该程序生成几页控制台输出。对我们来说,有两个部分很有趣:警告消息和代码分数。
警告消息
在 pylint 输出的顶部,我们发现了一个包含警告消息的部分,该警告消息引用了 PEP8 违例。每个警告都包含该警告引用的行号。对于没有经验的 Python 开发人员编写的代码,我们得到
C: 1, 0: Missing module docstring (missing-docstring)
C: 7, 0: Invalid constant name "tilefile" (invalid-name)
而清理后的代码会导致
C: 18, 4: Invalid variable name "x" (invalid-name)
C: 19, 4: Invalid variable name "y" (invalid-name)
W: 25, 4: Redefining name 'tile_positions' from outer scope (line 36) (redefined-outer-name)
C: 26,27: Invalid variable name "f" (invalid-name)
C: 36, 4: Invalid constant name "tile_positions" (invalid-name)
所有这些警告都向我们指出了可以改进的地方。不鼓励使用包含一个字符的变量名,也不鼓励在函数内部和外部使用同名的变量。我们可以开始重命名我们的变量(使它们更长)和常量(大写字符)。然而,我们将暂时克制自己,滚动到输出的底部。
代码分数
在 pylint 输出的最后,我们发现我们的代码得分高达 10 分:
Global evaluation
-----------------
Your code has been rated at 7.73/10
与 pylint 一起工作有时非常有益。当我们开始修复 PEP8 问题时,我们可以重新运行 pylint 并看到我们的分数提高。这使得 PEP8 标准有点靠不住。您可能已经注意到,在清理我们的代码之后,我们比之前混乱的代码中有更多的 PEP8 警告。这告诉我们,pylint 产生的警告和分数并不能很好地代表代码中的较大变化。过分关注风格一致性会分散对更重要问题的注意力。最佳实践是使用 pylint 来符合 PEP8 风格指南,但不要试图将每个 Python 文件都推到 10.0 的 pylint 分数。通常 7.0 左右的分数就已经足够好了。忽略你不同意的警告信息是可以的。用你的理智。根据 Python 核心开发人员 Raymond Hettinger 的说法,“PEP8 是一个指南,而不是法律手册。”把 PEP8 想象成我们建筑上的一层油漆(见图 14-3 )。它改善了我们代码的外观,但是它不支持屋顶。
图 14-3。
Adhering to the PEP8 coding standard is like a good layer of paint: it looks beautiful and protects your code from bad weather.
让它工作,让它正确,让它快
当编写适合一个屏幕页面的小程序时,如何准确地编写代码并不是什么大问题。我们最关心的是让程序运行起来。但是随着尺寸越来越大,可读性的缺乏会落到我们的脚上。我们需要组织我们的代码,或者使它正确。在我们的清理过程中,我们遵循了斯蒂芬·c·约翰逊和布赖恩·w·柯尼根制定的指导方针:“首先让它工作,然后让它正确,最后,让它快。”这一方针被归功于不同的人,包括肯特·贝克(也见 http://c2.com/cgi/wiki?MakeItWorkMakeItRightMakeItFast
)。它当然适用于 100 行以上的 Python 程序。让我们仔细看看指南的三个部分。
使其工作
在这里,工作意味着程序无异常地完成,并且没有我们所知道的语义错误。在第一部分中,我们已经学习了许多使程序工作的调试技术。在第二部分中,我们使用自动化测试来更彻底地检测缺陷。
做正确的事
使它正确通常意味着组织你的代码。在这一章中,我们已经看到了使程序可读性更好、结构更好以及使执行逻辑透明的清理步骤。然而,这些清理策略仅仅是开始。随着我们程序的进一步发展,保持代码组织良好变得更加重要。除了组织功能和模块,设计类和它们之间的相互作用,构建 Python 包,开发一个包含系统所有组件的架构,这些都是可以期望找到大量重构的主题。不过,这些话题超出了本书的范围。
动作快点
当你的程序工作正常,结构良好,可读性强时,它的性能就值得一看了。通常在这个阶段,程序已经足够快了。否则,组织良好的代码至少更容易调优以获得更高的性能。有许多方法可以加速 Python 程序,从增加计算能力和编译 Python 到更快的编程语言,以及消除 Python 代码本身的瓶颈。性能优化不是本书的主题,但是在第十一章中你可以找到一个编写性能测试的例子。
组织良好的代码示例
从不到 100 行到超过 100 行的 Python 代码的转变很有趣。当一个程序超过 100 行时,有很多种可能性可以写出同样的程序。哪个是正确的?为了给你一些试探性的答案,我们来看看这个星球上一些最好的 Python 程序员写的程序的结构。表 14-2 中总结了知名程序员的 7 个 Python 项目的结构。我选择了较小的日常项目或宠物项目来进行比较,而不是他们的(大部分是巨大的)主要项目。这些项目是
表 14-2。
Metrics for Seven Python Projects of Between 100–1000 Lines. The packages, modules, functions, and classes were counted with Unix command-line tools. The comments, blank, and code lines were counted with the cloc tool.
| 项目 | 包装 | 模块 | 功能 | 班级 | 空白行 | 评论 | 代码行 | | --- | --- | --- | --- | --- | --- | --- | --- | | 衬衫 | Zero | Two | six | Zero | Fifty-nine | Fifty-five | Two hundred and twenty-seven | | 皮普西 | Zero | five | Forty | Two | One hundred and twenty-three | Twenty-two | Four hundred and eighty-six | | 爬行者 | Zero | three | Thirty | five | Ninety-one | Ninety | Five hundred and thirty-one | | 搬出去 | three | Twenty-three | Thirty-five | Twenty-five | One hundred and seventy | Thirty-four | Five hundred and ninety-nine | | python progress bar(python 进度列) | one | six | Sixty-one | Seventeen | Two hundred and twenty-three | Two hundred and thirty-one | Five hundred and sixty-seven | | 吉萨!吉萨 | one | Fourteen | Fifty-seven | five | Two hundred and thirty | Two hundred and forty-two | Six hundred and fourteen |- 《从零开始的数据科学》(O’Reilly,2015)一书的作者乔尔·格鲁什(Joel Grus)的衬衫。该程序使用机器学习来比较 t 恤的图像。
(
https://github.com/joelgrus/shirts
- web 框架 Flask 的作者阿明·罗纳彻的 pipsi。pipsi 是一个工具,可以使将软件包安装到虚拟环境中变得更加容易。
(
https://github.com/mitsuhiko/pipsi
- Python 的发明者吉多·范·罗苏姆设计的 crawler 是一个快速的网络爬虫,可以跟踪不到 500 行的网页中的链接。
(
https://github.com/gvanrossum/500lines/tree/master/crawler
- 点燃 Djangogirls 运动的两位开发者之一 Ola Sitarska 的 move-out 是一个 Django 网络应用程序,用于在搬出时分享东西。
(
https://github.com/olasitarska/move-out
- python-progressbar 是一个在命令行显示进度条的模块。
(
https://github.com/niltonvolpato/progressbar
- Zulko 的 gizeh 是 Python 图形库的系列作者,是一个用 Python 创建矢量图形的包。
(
https://github.com/Zulko/gizeh
当比较表 14-2 中的项目时,我们看到所有项目都包含 10%–25%的空行和高达 25%的带注释的行。我们还看到代码的结构有很大的不同。shirts 项目本质上是一个用于数据分析的经过清理和注释的线性脚本,而 pipsi 和 python-progressbar 被分解成 40 多个更小的代码单元,可用于不同的目的。大多数作者使用类,但不是所有作者都使用类(例如,gizeh 更强调函数,而 move-out 使用从 Django 框架派生的类)。我们的结论是,即使在杰出的程序员中,显然也有不止一种正确的方法。
最佳实践
- 有无限的可能性来实现相同的功能。
- 软件熵是代码随着时间的推移变得无组织的现象。
- 无组织的代码可读性差、结构化程度低、冗余或包含其他设计缺陷。
- 清理代码是一项日常编程任务。
- 将
import
语句放在 Python 模块的开头。 - 将常数放在一起,它们的名字写在
UPPER_CASE
中。 - 不必要的线需要严格去除。
- 变量应该有有意义的名字。
- 将程序重构为小而简单的函数会使它更容易理解。
- 大型程序应该被分割成多达 400 行的模块。
- 导入时不执行
__main__
块。 - pylint 是一个检查是否符合 PEP8 编码标准的工具。
- 遵守编程的中心原则:让它工作,让它漂亮,让它快。