构建您的项目

../_images/33907151224_0574e7dfc2_k_d.jpg

“结构”是指你对你的项目如何最好地满足它的目标所做的决定。我们需要考虑如何最好地利用Python的特性来创建干净、有效的代码。实际上,“结构”意味着生成逻辑和依赖关系清晰的干净代码,以及文件和文件夹在文件系统中的组织方式。

哪些功能应该进入哪些模块?数据如何在项目中流动?哪些特性和功能可以分组和隔离?通过回答这些问题,你可以从广义上开始计划你的成品是什么样子的。

在本节中,我们将更仔细地了解Python的模块和导入系统,因为它们是在项目中实施结构的中心元素。然后,我们将从不同的角度讨论如何构建可可靠扩展和测试的代码。

存储库的结构

这很重要。

正如代码样式、API设计和自动化一样,对于健康的开发周期也是必不可少的。存储库结构是项目的关键部分 architecture

当潜在用户或贡献者登录到您的存储库页面时,他们会看到以下几点:

  • 项目名称

  • 项目说明

  • 一堆O型文件

只有当它们滚动到折叠下面时,用户才能看到项目的自述文件。

如果您的repo是文件的大量转储或目录的嵌套混乱,那么在阅读漂亮的文档之前,它们可能会在其他地方查找。

穿上你想要的工作服,而不是你拥有的工作。

当然,第一印象不是一切。您和您的同事将花费数不清的时间使用这个存储库,最终会非常熟悉每个角落和缝隙。布局很重要。

示例存储库

TL;DR :这就是什么? Kenneth Reitz recommended in 2013

此存储库是 available on GitHub

README.rst
LICENSE
setup.py
requirements.txt
sample/__init__.py
sample/core.py
sample/helpers.py
docs/conf.py
docs/index.rst
tests/test_basic.py
tests/test_advanced.py

让我们讨论一些细节。

实际模块

位置

./sample/ or ./sample.py

目的

兴趣码

您的模块包是存储库的核心焦点。它不应该被藏起来:

./sample/

如果模块仅包含一个文件,则可以将其直接放在存储库的根目录中:

./sample.py

您的库不属于不明确的src或python子目录。

许可

位置

./LICENSE

目的

律师。

除了源代码本身,这可以说是存储库中最重要的部分。完整的许可证文本和版权声明应该存在于该文件中。

如果您不确定应该为项目使用哪个许可证,请签出 choosealicense.com .

当然,您也可以在没有许可证的情况下自由发布代码,但这会阻止许多人潜在地使用您的代码或对您的代码做出贡献。

Setup.py

位置

./setup.py

目的

包装和配送管理。

如果您的模块包位于存储库的根目录下,那么它显然也应该位于根目录下。

需求文件

位置

./requirements.txt

目的

开发依赖项。

A pip要求文件 应该放在存储库的根目录下。它应该指定对项目做出贡献所需的依赖性:测试、构建和生成文档。

如果您的项目没有开发依赖项,或者如果您喜欢通过以下方式设置开发环境 setup.py ,则此文件可能是不必要的。

文档

位置

./docs/

目的

包装参考文件。

这在其他地方是没有理由存在的。

测试套件

有关编写测试的建议,请参阅 测试您的代码

位置

./test_sample.py or ./tests

目的

包集成和单元测试。

首先,一个小的测试套件通常存在于一个文件中:

./test_sample.py

一旦测试套件增长,您可以将测试移动到一个目录,如下所示:

tests/test_basic.py
tests/test_advanced.py

显然,这些测试模块必须导入打包的模块来测试它。您可以通过以下几种方式完成此操作:

  • 希望包安装在站点包中。

  • 使用简单的(但是 明确的 )路径修改以正确解析包。

我强烈推荐后者。要求开发人员运行 setup.py develop 要测试主动更改的代码库,还需要为代码库的每个实例设置一个独立的环境。

要为各个测试提供导入上下文,请创建 tests/context.py 文件:

import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

import sample

然后,在各个测试模块中,导入模块,如下所示:

from .context import sample

无论安装方法如何,这将始终按预期工作。

有些人会断言您应该在模块内部分发测试——我不同意。它通常会增加用户的复杂性;许多测试套件通常需要额外的依赖项和运行时上下文。

生成文件

位置

./Makefile

目的

一般管理任务。

如果你看我的大多数项目或任何Pocoo项目,你会发现一个makefile就在附近。为什么?这些项目不是用C…简而言之,make是一个非常有用的工具,可以为项目定义一般任务。

示例生成文件:

init:
    pip install -r requirements.txt

test:
    py.test tests

.PHONY: init test

其他通用管理脚本(例如 manage.pyfabfile.py )也属于存储库的根目录。

关于Django应用程序

自从Django 1.4发布以来,我注意到Django应用程序的一个新趋势。由于新的捆绑应用程序模板,许多开发人员对其存储库的结构不好。

怎样?好吧,他们像往常一样转到原始的全新存储库并运行以下内容:

$ django-admin.py startproject samplesite

生成的存储库结构如下所示:

README.rst
samplesite/manage.py
samplesite/samplesite/settings.py
samplesite/samplesite/wsgi.py
samplesite/samplesite/sampleapp/models.py

不要这样做。

对于您的工具和开发人员来说,重复的路径都是令人困惑的。不必要的筑巢对任何人都没有帮助(除非他们怀念单一的SVN回购)。

让我们正确地做:

$ django-admin.py startproject samplesite .

注意“.`”。

结果结构:

README.rst
manage.py
samplesite/settings.py
samplesite/wsgi.py
samplesite/sampleapp/models.py

代码结构是关键

由于在Python中处理导入和模块的方式,构建Python项目相对容易。这里的easy意味着您没有很多约束,并且模块导入模型很容易掌握。因此,您只剩下纯粹的体系结构任务,即构建项目的不同部分及其交互。

简单的项目结构意味着它也很容易做得不好。结构不良的项目的一些迹象包括:

  • 多个杂乱无章的循环依赖项:如果 furn.py 需要从以下位置导入Carpenter workers.py 回答像这样的问题 table.isdoneby() ,如果相反,类木匠需要导入桌子和椅子来回答问题 carpenter.whatdo() ,那么您就有了循环依赖关系。在这种情况下,您将不得不求助于脆弱的攻击,例如在您的方法或函数中使用import语句。

  • 隐藏耦合:Table实现中的每一次更改都会破坏无关测试用例中的20个测试,因为它违反了Carpenter的代码,这需要非常仔细的手术才能适应更改。这意味着您在Carpenter的代码中或相反的代码中对Table有太多的假设。

  • 大量使用全局状态或上下文:而不是显式传递 (height, width, type, wood) 桌子和木匠相互依赖于全局变量,这些变量可以修改,并且可以由不同的代理在飞翔上进行修改。您需要仔细检查对这些全局变量的所有访问,以便了解矩形表为什么会变成正方形,并发现远程模板代码也在修改此上下文,扰乱了表的尺寸。

  • 意大利面代码:多页嵌套的if子句和带有大量复制粘贴的过程代码且没有正确分段的for循环称为意大利面代码。Python的有意义的缩进(它最有争议的特性之一)使得维护这类代码非常困难。好消息是你可能不会看到太多。

  • 饺子代码更像是在Python中编写的:它由数百个类似的小逻辑片段组成,通常是类或对象,没有适当的结构。如果您永远记不住,如果您必须使用FurnitureTable、AssetTable或Table,甚至TableNew来完成手头的任务,那么您可能会在饺子代码中游泳。

模块

Python模块是可用的主要抽象层之一,可能是最自然的抽象层。抽象层允许将代码分离为包含相关数据和功能的部分。

例如,项目的一个层可以处理与用户操作的接口,而另一个层可以处理数据的低级操作。分离这两层的最自然的方法是将所有接口功能重新组合到一个文件中,并将所有低级操作重新组合到另一个文件中。在这种情况下,接口文件需要导入低级文件。这是用 importfrom ... import 声明。

一旦您使用了 import 语句,您可以使用模块。这些模块可以是内置模块,如 ossys 、环境中安装的第三方模块或项目的内部模块。

要与样式指南保持一致,请保持模块名称简短、小写,并确保避免使用点(.)等特殊符号或问号(?)。如下所示的文件名 my.spam.py 才是你应该避免的!这种命名方式会干扰Python查找模块的方式。

如果是`my.spam.py`,Python希望在名为 :file:`my`的文件夹中找到一个 :file:`spam.py`文件,事实并非如此。有一个 `示例<http://docs.python.org/tutorial/modules.html#packages>`_,说明了如何在Python文档中使用点符号。

如果愿意,您可以将模块命名为 my_spam.py ,但即使是我们值得信赖的朋友下划线,也不应该经常出现在模块名称中。但是,在模块名称中使用其他字符(空格或连字符)将阻止导入(-是减法运算符)。尽量保持模块名称简短,这样就不需要分隔单词。而且,最重要的是,不要使用下划线命名空间;而是使用子模块。

# OK
import library.plugin.foo
# not OK
import library.foo_plugin

除了一些命名限制外,Python文件作为模块不需要任何特殊要求。但您需要了解导入机制,以便正确使用此概念并避免一些问题。

具体地说, import modu 语句将查找正确的文件,即 modu.py 位于与调用方相同的目录中(如果存在)。如果未找到,Python解释器将搜索 modu.py 并在未找到ImportError异常时引发ImportError异常。

什么时候 modu.py 如果找到,Python解释器将在独立的作用域中执行该模块。中的任何顶级语句 modu.py 将被执行,包括其他导入(如果有)。函数和类定义存储在模块的字典中。

然后,模块的变量、函数和类将通过模块的名称空间提供给调用者,这是编程中的一个中心概念,在Python中特别有用且功能强大。

在许多语言中, include file 预处理器使用指令获取文件中找到的所有代码并将其“复制”到调用方的代码中。在python中是不同的:所包含的代码被隔离在一个模块名称空间中,这意味着您通常不必担心所包含的代码会产生不必要的影响,例如,用相同的名称覆盖现有的函数。

It is possible to simulate the more standard behavior by using a special syntax of the import statement: from modu import *. This is generally considered bad practice. Using import * makes the code harder to read and makes dependencies less compartmentalized.

使用 from modu import func 是一种精确定位要导入的函数并将其放入本地命名空间的方法。虽然危害比 import * 因为它显式地显示在本地命名空间中导入的内容,所以它的唯一优点是 import modu 它可以节省一点打字时间。

非常糟糕

[...]
from modu import *
[...]
x = sqrt(4)  # Is sqrt part of modu? A builtin? Defined above?

Better

from modu import sqrt
[...]
x = sqrt(4)  # sqrt may be part of modu, if not redefined in between

Best

import modu
[...]
x = modu.sqrt(4)  # sqrt is visibly part of modu's namespace

如中所述 代码风格 第节,可读性是Python的主要特性之一。可读性意味着避免无用的样板文件文本和混乱;因此,一些努力是为了达到一定程度的简洁性。但是简洁和晦涩是简洁应该停止的极限。能够立即知道类或函数的来源,如 modu.func 成语,大大提高了代码的可读性和可理解性,除了最简单的单个文件项目。

封装

Python提供了一个非常简单的打包系统,它只是一个目录的模块机制的扩展。

任何带有 __init__.py 文件被认为是一个python包。包中的不同模块以与普通模块相似的方式导入,但具有针对 __init__.py 文件,用于收集所有包范围的定义。

一份文件 modu.py 在目录中 pack/ 与语句一起导入 import pack.modu 。此语句将查找 __init__.py 文件位于 pack 并执行其所有顶级语句。然后,它将查找名为 pack/modu.py 并执行其所有顶级语句。在这些操作之后,在中定义的任何变量、函数或类 modu.py 在Pack.modu名称空间中可用。

一个常见的问题是将过多的代码添加到 __init__.py 文件。当项目复杂性增加时,可能会在深层目录结构中存在子包和子包。在这种情况下,从子包导入单个项目将需要执行所有 __init__.py 遍历树时遇到文件。

留下一个 __init__.py 如果包的模块和子包不需要共享任何代码,则认为文件为空是正常的,甚至是良好的做法。

最后,可以使用方便的语法导入深度嵌套的包: import very.deep.module as mod .这允许你使用 mod 代替冗长的重复 very.deep.module .

面向对象编程

Python有时被描述为面向对象的编程语言。这可能有些误导,需要进一步澄清。

在Python中,任何东西都是对象,可以这样处理。例如,当我们说函数是一级对象时,这就是我们的意思。函数、类、字符串甚至类型都是Python中的对象:与任何对象一样,它们都有类型,可以作为函数参数传递,还可以有方法和属性。在这种理解中,Python可以被认为是一种面向对象的语言。

但是,与Java不同的是,Python不会强制将面向对象编程作为主要编程范例。Python项目完全可以不面向对象,也就是说,不使用或很少使用类定义、类继承或任何其他特定于面向对象编程语言的机制。

此外,正如在 modules 一节中,Python处理模块和名称空间的方式为开发人员提供了一种自然的方式来确保抽象层的封装和分离,这两者都是使用面向对象的最常见原因。因此,当业务模型不需要面向对象时,Python程序员可以更自由地不使用面向对象。

有一些理由可以避免不必要的面向对象。当我们想要将某些状态和某些功能粘合在一起时,定义自定义类很有用。正如关于函数式编程的讨论所指出的那样,问题来自等式的“状态”部分。

在一些体系结构中,通常是Web应用程序,作为对同时发生的外部请求的响应,派生出多个Python进程实例。在这种情况下,在实例化对象中保留某些状态(这意味着保留一些有关世界的静电信息)容易出现并发问题或争用条件。有时,在对象状态的初始化之间(通常使用 __init__() 方法)以及通过其方法之一对对象状态的实际使用,则世界可能已改变,并且保留的状态可能已过时。例如,请求可以将项目加载到存储器中并将其标记为由用户读取。如果另一个请求同时要求删除该项,则删除实际上可能发生在第一个进程加载该项之后,然后我们必须将已删除的对象标记为已读。

这和其他问题导致了使用无状态函数是更好的编程范例的想法。

另一种说法是建议使用隐式上下文和副作用尽可能少的函数和过程。函数的隐式上下文由持久层中从函数内部访问的任何全局变量或项组成。副作用是函数对其隐式上下文所做的更改。如果函数在全局变量或持久层中保存或删除数据,则称其具有副作用。

仔细地将具有上下文和副作用的函数与具有逻辑的函数(称为纯函数)隔离开来可以带来以下好处:

  • 纯函数是确定性的:给定一个固定的输入,输出总是相同的。

  • 如果需要重构或优化纯函数,那么它们更容易更改或替换。

  • 纯函数更容易用单元测试进行测试:之后不需要复杂的上下文设置和数据清理。

  • 纯函数更容易操作、修饰和传递。

总之,对于某些体系结构来说,纯函数比类和对象更有效,因为它们没有上下文或副作用。

显然,在许多情况下,对象定向是有用的,甚至是必要的,例如在开发图形桌面应用程序或游戏时,操作的对象(窗口、按钮、虚拟人物、车辆)在计算机内存中的使用寿命相对较长。

装饰器

Python语言提供了一种简单而强大的语法,称为“decorators”。decorator是包装(或修饰)函数或方法的函数或类。“decorated”函数或方法将替换原始的“undecorated”函数或方法。因为函数是Python中的第一类对象,所以可以“手动”完成,但是使用@decorator语法更清晰,因此更可取。

def foo():
    # do something

def decorator(func):
    # manipulate func
    return func

foo = decorator(foo)  # Manually decorate

@decorator
def bar():
    # Do something
# bar() is decorated

这种机制对于分离关注点和避免外部无关逻辑“污染”函数或方法的核心逻辑非常有用。一个更好地处理装饰的功能的好例子是 memoization 或者缓存:您希望将昂贵函数的结果存储在一个表中,并直接使用它们,而不是在已经计算它们时重新计算它们。这显然不是功能逻辑的一部分。

上下文管理器

上下文管理器是一个为操作提供额外上下文信息的Python对象。这个额外的信息采取的形式是在使用 with 语句,以及在完成 with 阻止。最著名的使用上下文管理器的示例如下所示,打开一个文件:

with open('file.txt') as f:
    contents = f.read()

任何熟悉此模式的人都知道调用 open 以这种方式确保 fclose 方法将在某个时刻被调用。这减少了开发人员的认知负载,使代码更容易阅读。

有两种简单的方法可以自己实现这个功能:使用类或使用生成器。让我们自己实现上述功能,从类方法开始:

class CustomOpen(object):
    def __init__(self, filename):
        self.file = open(filename)

    def __enter__(self):
        return self.file

    def __exit__(self, ctx_type, ctx_value, ctx_traceback):
        self.file.close()

with CustomOpen('file') as f:
    contents = f.read()

这只是一个具有两个额外方法的常规python对象,由 with 声明。CustomOpen首先被实例化,然后它的 __enter__ 方法被调用 __enter__ 退货分配给 fas f 声明的一部分。当内容 with 块执行完毕, __exit__ 然后调用方法。

现在,生成器方法使用了Python自己的 contextlib

from contextlib import contextmanager

@contextmanager
def custom_open(filename):
    f = open(filename)
    try:
        yield f
    finally:
        f.close()

with custom_open('file') as f:
    contents = f.read()

这与上面的类示例的工作方式完全相同,尽管它更简洁。这个 custom_open 函数执行到 yield 声明。然后它将控制权还给 with 声明,分配 yield “编辑到” fas f 部分。这个 finally 子句确保 close() 是否在 with .

由于这两种方法看起来是相同的,所以我们应该遵循python的禅来决定何时使用哪个方法。如果有大量的逻辑需要封装,那么类方法可能更好。对于处理简单操作的情况,函数方法可能更好。

动态键入

Python是动态类型的,这意味着变量没有固定类型。事实上,在Python中,变量与许多其他语言(特别是静态类型语言)中的变量非常不同。变量不是计算机内存中写入某些值的部分,它们是指向对象的“标签”或“名称”。因此,可以将变量‘a’设置为值1,然后将值‘a string’设置为函数。

Python的动态类型通常被认为是一个弱点,实际上它可能导致复杂和难以调试代码。名为“a”的内容可以设置为许多不同的内容,开发人员或维护人员需要在代码中跟踪此名称,以确保它没有设置为完全不相关的对象。

一些指导原则有助于避免此问题:

  • 避免对不同的事物使用相同的变量名。

Bad

a = 1
a = 'a string'
def a():
    pass  # Do something

Good

count = 1
msg = 'a string'
def func():
    pass  # Do something

使用简短的函数或方法有助于降低对两个不相关的事物使用相同名称的风险。

最好使用不同的名称,即使是相关的事物,当它们具有不同的类型时:

Bad

items = 'a b c d'  # This is a string...
items = items.split(' ')  # ...becoming a list
items = set(items)  # ...and then a set

重用名称时没有效率提高:分配无论如何都必须创建新对象。但是,当复杂度增加,并且每个分配被其他代码行(包括“if”分支和循环)分隔时,就很难确定给定变量的类型。

一些编码实践,比如函数式编程,建议永远不要重新分配变量。在Java中,这是用 final 关键字。python没有 final 不管怎样,它都会违背它的哲学。然而,避免多次赋值给变量可能是一个很好的规则,它有助于理解可变和不可变类型的概念。

可变和不可变类型

Python有两种内置或用户定义的类型。

可变类型是那些允许就地修改内容的类型。典型的可变词是列表和字典:所有列表都有可变方法,比如 list.append()list.pop() ,可以就地修改。字典也是如此。

不可变类型不提供更改其内容的方法。例如,设置为整数6的变量x没有“递增”方法。如果要计算x+1,必须创建另一个整数并给它一个名称。

my_list = [1, 2, 3]
my_list[0] = 4
print(my_list)  # [4, 2, 3] <- The same list has changed

x = 6
x = x + 1  # The new x is another object

这种行为差异的一个后果是可变类型不“稳定”,因此不能用作字典键。

对自然界中可变的事物使用适当的可变类型,对自然界中固定的事物使用不可变类型,有助于澄清代码的意图。

例如,列表的不可变等价物是用 (1, 2)。这个元组是一对不能就地更改的元组,可以用作字典的键。

Python的一个特性可以让初学者感到惊讶,那就是字符串是不可变的。这意味着,当从字符串的各个部分构造字符串时,将每个部分附加到字符串是无效的,因为在每个附加上复制了整个字符串。相反,它更有效地将零件聚集在一个列表中,这个列表是可变的,然后粘合 (join )当需要完整的字符串时,这些部分将在一起。清单理解通常是最快和最惯用的方法。

Bad

# create a concatenated string from 0 to 19 (e.g. "012..1819")
nums = ""
for n in range(20):
    nums += str(n)   # slow and inefficient
print(nums)

Better

# create a concatenated string from 0 to 19 (e.g. "012..1819")
nums = []
for n in range(20):
    nums.append(str(n))
print("".join(nums))  # much more efficient

Best

# create a concatenated string from 0 to 19 (e.g. "012..1819")
nums = [str(n) for n in range(20)]
print("".join(nums))

关于字符串,最后要提的一件事是使用 join() 并不总是最好的。在从预定数量的字符串创建新字符串的情况下,使用加法运算符实际上更快。但在类似上述情况下或在向现有字符串添加时,请使用 join() 应该是你首选的方法。

foo = 'foo'
bar = 'bar'

foobar = foo + bar  # This is good
foo += 'ooo'  # This is bad, instead you should do:
foo = ''.join([foo, 'ooo'])

备注

您也可以使用 % 格式化运算符以连接预先确定数量的字符串 str.join()+。然而, PEP 3101 不鼓励使用 % 有利于 str.format() 方法。

foo = 'foo'
bar = 'bar'

foobar = '%s%s' % (foo, bar) # It is OK
foobar = '{0}{1}'.format(foo, bar) # It is better
foobar = '{foo}{bar}'.format(foo=foo, bar=bar) # It is best

供应依赖性

跑步者

进一步阅读