代码结构
这里我们所说的「结构」就是关于如何让项目达到预期的目标。我们需要考虑的是如何更好的利用 Python 的特性创造出干净、有效的代码。实际上,「结构」意味着代码的结构、依赖非常的清晰,像文件系统中的文件和文件夹一样。
哪些函数应该放在哪个模块中?项目中的数据流是怎样的?哪些函数应该组织在一起或者分离开?从广义上讲,通过回答这些问题,你就可以开始规划你的项目长什么样子。
在本节中,我们将深入研究 Python 的模块和导入系统,因为它们是项目结构中的核心元素。然后我们将探讨如何构建可扩展易测试的代码结构的各种观点。
仓库结构
这很重要。
就像代码风格,API 设计和自动化对于健康的开发周期是必不可少的,仓库结构也是项目体系结构中的重要组成部分。
当一个潜在用户或贡献者打开你的仓库页面,他们将看到:
- 项目名称
- 项目描述
- 大堆的文件
只有当他们在下滑滚动时,才能看到项目里的自述文档。
如果你的仓库包含了大量的垃圾文件或者混乱嵌套的目录结构,即使有漂亮的自述文档,用户也可能尚未看到就前往查看其他项目了。
为你想要的工作而装扮,而不是你现在做的工作而装扮。
当然,第一印象并不代表全部。你和你的同事可能花费数个小时在这个仓库中,最终对每个细节都非常熟悉。它的布局很重要。
简单的仓库
简述 : 这个仓库是 Kenneth Reitz 的建议。
可以在 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/` 或 `./sample.py` |
---|---|
目的 | 具体代码 |
模块包是仓库的核心。它不应该被藏起来:
./sample/
如果你的模块内只有一个文件,你可以直接把这个文件放在仓库的根目录下:
./sample.py
你的模块不应属于一个模糊的 src 目录或 python 的子目录。
授权
位置 | `./LICENSE` |
---|---|
目的 | 法律相关 |
除了源码本身外,这可以说是仓库中最重要的部分。在这个文件中应该包含完整的许可证文本和版权声明。
如果你不确定项目中应该使用哪种授权,请参考 choosealicense.com 。
当然,你也可以发布不包含授权的代码,但这可能会让很多人不去使用你的代码。
Setup.py
位置 | `./setup.py` |
---|---|
目的 | 包安装和分发管理 |
如果你的模块包在仓库的根目录下,这个文件也应该位于根目录。
依赖文件
位置 | `./requirements.txt` |
---|---|
目的 | 开发中的依赖 |
pip requirements file 应该放置在仓库的根目录下。它应指出项目所需的依赖关系,包括测试、构建和生成文档过程中的。
如果该项目开发不需要依赖,或者你喜欢通过 setup.py
安装开发环境,这个文件可能不是必须的。
文档
位置 | `./docs/` |
---|---|
目的 | 项目的参考文档 |
没有理由把它放在其他地方。
测试套件
有关编写测试的建议,请参考
Testing Your Code
。
位置 | `./test_sample.py` 或 `./tests` |
---|---|
目的 | 软件包集成和单元测试 |
开始时,一个小的测试套件经常在一个文件中:
./test_sample.py
一旦测试套件多起来时,可以像这样把各套件放在一个文件夹中:
tests/test_basic.py
tests/test_advanced.py
显然,这些测试套件需要你导入包模块才能运行测试。你可以通过这些方法来做:
- 将包安装在 site-packages 中。
- 使用一个简单(但是
明确
)的路径去修正解决它。
我强烈推荐后者。需要开发者在修改代码后主动运行 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
位置 | `./Makefile` |
---|---|
目的 | 通用的管理任务。 |
如果你看过我的大部分项目或任何 Pocoo 项目,你都会注意到有一个 Makefile 文件。 为什么? 这些项目不是用 C 语言编写的... 简而言之, make 是一个定义项目通用任务的非常有用的工具。
一个简单的 Makefile:
init:
pip install -r requirements.txt
test:
py.test tests
.PHONY: init test
其他通用的管理脚本(例如 manage.py
或 fabfile.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 项目相对容易。容易是指没有太多的约束,并且模块导入模型很容易掌握。因此,您所剩的是纯架构方面的任务,比如设计项目的不同点及其交互。
一个项目的结构简单意味着它也很容易做得不好。结构不佳的项目包括以下一些特征:
- 多个杂乱的循环依赖关系:假如
furn.py
中 Table (桌子) 和 Chair (椅子)类需要从workers.py
导入 Carpenter (木匠) 来回答诸如table.isdoneby()
的问题;相反地,假如 Carpenter 类需要导入 Table 和 Chair 来回答诸如carpenter.whatdo()
的问题。这样,就产生了一个循环依赖关系。在这种情况下,你不得不凭借脆弱的技巧,例如在方法或函数内使用导入语句。 - 隐藏的耦合关系: Table 实现中的每一次更改会在不相关的测试用例中中断20次测试,因为它破坏了 Carpenter 的代码,需要非常仔细的处理来适应此次更改。这意味着在 Carpenter 中有较多关于 Table 的假设,或在 Table 中有较多关于 Carpenter 的假设。
- 对全局状态或上下文的大量使用: Table 和 Carpenter 依赖于可修改的全局变量(可由不同的代理进行修改),而不是显式传递
(高度、宽度、类型、木材)
。你需要检查对这些全局变量的所有访问,以了解为什么长方形桌子变成了正方形,然后发现远程模板代码也在修改此上下文,从而弄乱了桌子的尺寸。 - “意大利面条”式代码: 代码有多页嵌套 if 子句、 for 循环,有大量的复制粘贴程序代码,并没有适当的分块称为“意大利面条”式代码。 Python 的有意义的缩进 (最具争议性的特征之一) 使得维护这种代码变得非常困难。好消息是你可能看不到太多这种情况。
- 在 Python 中更有可能看到的是“意大利饺子”式代码:它由数百个类似的小逻辑片段组成,通常是类或对象,没有适当的结构。如果你不记得是否应该使用 FurnitureTable 、 AssetTable ,还是 TableNew 来完成手头上的任务,那么你可能会在“意大利饺子”式代码中漫无目的地游着。
模块
Python 模块是可用的主要抽象层之一,也是最自然的一个。抽象层允许将代码分成包含相关数据和功能的多个部分。
例如,项目的一层可以处理用户操作的接口,而另一层可以处理数据的底层操作。将这两层分离最自然的方法是:将所有接口功能组合在一个文件中,而在另一个文件中组合数据底层操作。在这种情况下,接口文件需要导入数据底层操作文件,这是通过 import
和 from... import
语句完成的。
一旦使用 import
语句,就会使用模块。这些模块可以是内置的模块,如 os
和 sys
、环境中安装的第三方模块,或项目中的内部模块。
要与样式指南保持一致,需保持模块名称简短、小写,并确保避免使用特殊符号,如点(.)或问号(?)。所以应该避免像 my.spam.py
这样的文件名!以这种方式命名会影响 Python 查找模块的方式。
在这个 my.spam.py
情况下, Python 希望在名为 my
的文件夹中找到一个 spam.py
文件,但不应该这样命名。在 Python 文档中有一个应该如何使用点表示法的 例子 。
如果你希望将模块命名为 my_spam.py
,但其实下划线也不应该在模块名称中经常出现。在模块名称中使用其他字符(空格或连字符)将会阻止导入(- 是减操作符),因此请尽量保持模块名称的简短,这样就不需要将单词分开了。而且,最重要的是,不要使用带下划线的名称空间,应该使用子模块。
# 可以
import library.plugin.foo
# 不行
import library.foo_plugin
除了一些命名限制之外, Python 文件并不是一个特殊的模块,但你需要了解导入机制才能正确使用这个概念并避免一些问题。
具体地说, import modu
语句会去查找正确的文件,首先会在调用者所在的目录中查找 modu.py
。如果找不到,Python 解释器将递归搜索 "path" 中的 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) # sqrt是modu的一部分吗? 内置的? 上面定义了?
好一点的
from modu import sqrt
[...]
x = sqrt(4) # sqrt 可能是modu的一部分, 如果不在之间重新定义的话
最佳
import modu
[...]
x = modu.sqrt(4) # sqrt显然是modu的命名空间的一部分
如 Code Style 部分所述, 可读性是 Python 的主要特性之一。可读性意味着避免无用的样板文字和混乱,因此有必要花费一些努力来达到一定程度的简洁性上。但简洁不是简短和晦涩难懂。如 modu.func
的习惯用法,就能够立即判断类或函数来自何处。 除了最简单的单个文件项目之外,这样写可以大大提高了代码的可读性和可理解性。
包系统
Python 提供了一个非常简单的封装系统,它只是模块机制到目录的扩展。
任何一个带有 __init__.py
文件的目录都被认为是 Python 包。包中的不同模块以类似于普通模块的方式导入,但 __init__.py
文件具有特殊的行为,该文件用于收集所有包范围的定义。
一个在目录 pack/
中的 modu.py
文件通过语句 import pack.modu
来导入。这个语句将在 pack
目录中查找 __init__.py
文件,并执行其所有顶级语句。接着它将查找一个叫 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 项目不采用面向对象的方式是完全可行的,即不使用或很少使用类定义、类继承或特定于面向对象编程的任何其他机制。
此外,从 模块 部分可以看出, Python 处理模块和名称空间的方式为开发人员提供了一种自然的方法来确保抽象层的封装和分离,这两者都是使用面向对象的最常见原因。因此,当业务模型不需要面向对象时, Python 程序员有更大的自由来不使用面向对象编程。
基于一些因素的考虑,我们应避免不必要的面向对象编程。当我们想将一些状态和功能粘合在一起时,定义自定义类是很有用的。在函数编程的讨论中,我们指出,“不必要的面向对象编程”这个问题出自方程的“状态”部分。
在某些体系结构中,例如典型的 web 应用程序,会生成多个 Python 进程实例,以响应可能同时发生的外部请求。在这种情况下,将一些状态保存到实例对象中,意味着保留一些关于世界的静态信息,这很容易出现并发或竞争问题。有时,在对象的初始化(通常用 __init__()
方法来完成)状态和实际使用对象方法的状态之间,世界信息可能已经改变,保持的状态可能已经过时。例如,一个请求加载了内存中的某一项,并将其标记为由用户读取。而另一个请求同时要求删除该项,这可能发生在第一个进程加载该项之后,然后我们必须将其标记为已删除对象。
上述以及其他问题引出了这样的想法:使用无状态函数是一种更好的编程范例。
另一种说法是建议尽可能少的使用具有隐式上下文和副作用的函数和程序。函数的隐式上下文由全局变量和持久层中的数据项(使用方法访问)组成。副作用是指函数对其隐式上下文所做的更改。如果函数会保存或删除全局变量或持久层中的数据,则称它有副作用。
将有上下文和副作用的函数与逻辑函数(称为纯函数)隔离开来,可以获得以下好处:
- 纯函数是确定性的:给定一个固定的输入,输出始终是相同的。
- 纯函数需要重构或优化时,更容易更改或替换。
- 纯函数更易于使用单元测试进行测试:对于复杂的上下文设置和事后的数据清理的需求更少。
- 纯函数更容易操作、修饰和传递。
总之,针对某些体系结构,由于没有上下文或副作用,纯函数是比类和对象更有效的构建块。
显然,面向对象编程在许多情况下是有用的,甚至是必要的,例如在开发图形化桌面应用程序或游戏时,被操作的东西(窗口、按钮、化身、车辆)在计算机内存中具有相对较长的寿命。
装饰器
Python 语言提供了一种简单而强大的语法,称为“装饰器”。装饰器是一个函数或类,它包装(或装饰)了函数或方法。装饰器函数或方法将替换原来“未装饰”的函数或方法。因为函数是 Python 中的一级对象,所以可以“手动”完成(参见下述示例),但是使用 @decorator 语法则更清晰,也更受青睐。
def foo():
# 一些操作
def decorator(func):
# 处理 func
return func
foo = decorator(foo) # 手动装饰
@decorator
def bar():
# 一些操作
# bar() 被装饰了
此机制对于分离关注点和避免外部非相关逻辑“污染”函数或方法的核心逻辑很有用。最好采用装饰器来处理的一个功能示例是 备注 或缓存:你希望将计算非常耗时/耗空间的函数的结果存储在表中,并直接使用这些结果,而不是在已经计算过以后重新调用并计算。这显然不是函数逻辑的一部分。
上下文管理器
上下文管理器是一个 Python 对象,为操作提供了额外的上下文信息。 这种额外的信息, 在使用 with
语句初始化上下文,以及完成 with
块中的所有代码时,采用可调用的形式。 这里展示了使用上下文管理器的为人熟知的示例,打开文件:
with open('file.txt') as f:
contents = f.read()
任何熟悉这种模式的人都知道以这种形式调用 open
能确保 f
的 close
方法会在某个时候被调用。 这样可以减少开发人员的认知负担,并使代码更容易阅读。
实现这个功能有两种简单的方法:使用类或使用生成器。 让我们自己实现上面的功能,以使用类方式开始:
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__
的返回值在 as f
语句中被赋给 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
语句,然后在 as f
部分将 yield 的 f
赋值给 f
。 finally
确保不论 with
中是否发生异常, close()
都会被调用。
由于这两种方法都是一样的,所以我们应该遵循 Python 之禅来决定何时使用哪种。 如果封装的逻辑量很大,则类的方法可能会更好。 而对于处理简单操作的情况,函数方法可能会更好。
动态类型
Python 是动态类型语言,这意味着变量并没有固定的类型。实际上,Python 中的变量和其他语言有很大的不同,特别是静态类型语言。变量并不是计算机内存中被写入的某个值,它们只是指向内存的『标签』或『名称』。因此可能存在这样的情况,变量 a
先代表值 1,然后变成 字符串 a string
, 然后又变为指向一个函数。
Python 的动态类型常被认为是它的缺点,的确这个特性会导致复杂度提升和难以调试的代码。 命名为 a
的变量可能是各种类型,开发人员或维护人员需要在代码中追踪命名,以保证它 没有被设置到毫不相关的对象上。
这里有些避免发生类似问题的参考方法:
- 避免对不同类型的对象使用同一个变量名
不推荐
a = 1
a = 'a string'
def a():
pass # Do something
推荐
count = 1
msg = 'a string'
def func():
pass # Do something
使用简短的函数或方法能降低对不相关对象使用同一个名称的风险。
即使是相关的不同 类型的对象,也更建议使用不同命名:
不推荐
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
) 起来的做法更高效。 值得注意的是,使用列表推导的构造方式比在循环中调用 append()
来构造列表更好也更快。
还有一个选项是使用 map
函数,它可以 map
一个函数 str
到可序列化对象上 ( 例如 range(20)
)。 结果会产生一个集合,你可以对结果进行 join
在一起,就如其他的例子一样。map
函数在一些场景下速度会更快。
差
# 从 0 到 19 创建一个连续的字符串(例如「012..1819」)
nums = ""
for n in range(20):
nums += str(n) # slow and inefficient
print nums
好
# 从 0 到 19 创建一个连续的字符串(例如「012..1819」)
nums = []
for n in range(20):
nums.append(str(n))
print "".join(nums) # much more efficient
较好
# 从 0 到 19 创建一个连续的字符串(例如「012..1819」)
nums = [str(n) for n in range(20)]
print "".join(nums)
最好
# 从 0 到 19 创建一个连续的字符串(例如「012..1819」)
nums = map(str, 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