构建您的项目

../_images/33907151224_0574e7dfc2_k_d.jpg

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

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

在本节中,我们将更详细地了解python的模块和导入系统,因为它们是项目中实施结构的核心元素。然后,我们讨论了如何构建可以可靠地扩展和测试的代码的各种观点。

存储库的结构

重点。

正如代码风格、API设计和自动化对于健康的开发周期至关重要一样,存储库结构也是项目的关键部分 architecture .

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

  • 项目名称

  • 项目说明

  • O型文件

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

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

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

当然,第一印象并不是一切。您和您的同事将花费无数的时间来处理这个存储库,最终熟悉每个角落和缝隙。它的布局很重要。

示例存储库

DR :这就是 Kenneth Reitz recommended in 2013<https://www.kennethreitz.org/essays/repository-structure-and-python> .

此存储库是 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 requirements file 应该放在存储库的根目录下。它应该指定对项目做出贡献所需的依赖性:测试、构建和生成文档。

如果您的项目没有开发依赖项,或者您更喜欢通过 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 需要从 workers.py 回答一个问题,如 table.isdoneby() 如果相反,班上的木匠需要导入桌椅来回答这个问题。 carpenter.whatdo() ,则具有循环依赖关系。在这种情况下,您将不得不求助于脆弱的黑客,例如在方法或函数中使用import语句。

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

  • 大量使用全局状态或上下文:而不是显式传递 (height, width, type, wood) 对于彼此而言,Table和Carpenter依赖于全局变量,这些变量可以由不同的代理动态修改。您需要仔细检查对这些全局变量的所有访问,以了解矩形表为什么变成正方形,并发现远程模板代码也在修改此上下文,与表维度发生冲突。

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

  • Ravioli代码在Python中的可能性更大:它由数百个类似的小逻辑块组成,通常是类或对象,没有适当的结构。如果你永远记不清你是否需要用家具、可装配的或桌子,甚至是新的桌子来完成手头的任务,那么你可能是在用饺子代码游泳。

模块

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

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

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

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

如果是 my.spam.py Python expects to find a spam.py file in a folder named my which is not the case. There is an example 关于在python文档中应该如何使用点符号。

如果你愿意,你可以给你的模块命名 my_spam.py 但是,即使是我们的朋友,在模块名中也不应该经常看到下划线。但是,在模块名称中使用其他字符(空格或连字符)将阻止导入(减法运算符),因此请尽量缩短模块名称,这样就不需要分隔单词。而且,最重要的是,不要使用带有下划线的名称空间;而是使用子模块。

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

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

具体来说, import modu 语句将查找正确的文件,即 modu.py 与调用方(如果存在)在同一目录中。如果找不到,python解释器将搜索 modu.py 在“路径”中递归,如果找不到importError异常,则引发该异常。

一次 modu.py 如果找到,python解释器将在独立的范围内执行模块。中的任何顶级语句 modu.py 将执行,包括其他进口(如有)。函数和类定义存储在模块的字典中。

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

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

可以使用import语句的特殊语法来模拟更标准的行为: from modu import * .这通常被认为是不好的做法。 使用 import * 使代码更难读取,并使依赖项的划分更少 .

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

非常糟糕

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

更好

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

更好

# 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

依赖项

Runners