Python Guide 系列 2.1:结构化你的项目

所谓“结构”就是使项目清楚地直接地干净地优雅地达到他的目的。我们要考虑如何最大限度的利用python的特性来写干净、有效的代码。实际上,“structure”意味着写干净的代码(逻辑关系,依赖关系明确),以及如何在文件系统中组织文件和文件夹。

哪个函数应该在哪个模块里?项目的数据流是怎样的?什么特性和功能应该放在一起,什么样的应该隔离?广义上说,通过回答这些问题你可以开始计划成品是什么样子的。

在这一节,我们会仔细研究python的module(模块)和import(导入)系统,因为它们是增强项目结构化的核心内容。然后我们讨论如何构建可扩展可测试的可靠代码。

结构是关键

由于import和module是python掌控的,结构化一个Python项目相对比较容易。这里所指的容易,意味着没有很多约束而且python的模块导入模型比较容掌握。因此,留给你的就剩下一些架构性的工作,编写项目的不同模块并负责它们之间的交互。 Easy structuring of a project means it is also easy to do it poorly. Some signs of a poorly structured project include: 容易结构化的项目同样意味着它的结构化容易做的不好。。结构性差的项目,其特征包括:

  • 多重且混乱的循环依赖关系:如果furn.py中你的类Table和Chair需要import worker.py中的Carpenter来回答例如table.isdomeby()这种问题,相返Carpenter类需要import Table和Chair类来回答carpenter.whatdo()这种问题,所以你有一个循环依赖项。在这种情况下你将不得不借助于一些不太可靠的技巧,比如在方法或函数内部使用import语句。
  • 隐藏耦合:每一次改变Table的实现都会打破20个互不相关的测试用例,因为它打破了Carpenter的代码,所以需要小心操作来适应改变。这意味着在Carpenter的代码中你有太多关于Table的假设,反过来也是如此。
  • 大量使用全局状态或上下文:彼此间不使用显示传递(高度、宽度、类型、材料)参数,Table和Carpenter都依赖全局变量,可以被修改而且被不同的引用修改。你需要仔细检查所有访问这些全局变量的地方,来理解为什么一个矩形变成一个正方形,并发现远程模板代码也修改上下文,弄乱了桌子的尺寸参数。
  • 面条式代码:多层嵌套的if语句for循环,大量复制粘贴代码,没有适当分割的代码被称为面条式代码。python的具有意义的缩进(其最具争议的特性之一)使它难以维持这种代码。所以好消息是你也许看不到太多这种面条式代码。
  • python中更可能出现混沌代码:如果没有适当的结构,它会包括几百个相似的小逻辑块,类或对象。如果你记不住在手头的任务中你是否必须用FurnitureTable,AssetTable或Table甚至TableNew,你就可能陷入混沌的代码中。

模块

Python 的模块是最主要的抽象层之一,也算最自然的一个。抽象层允许我们把代码分成不同的部分,每部分包含着相关的数据和功能。

例如:一层控制用户的行为的接口,而另一层处理低级别的数据操作。分离这两层的最自然的方式是将所有接口功能重新组合到一个文件里,所有低级别操作在另一个文件。在这种情况下,接口文件需要import低级别操作的文件。通过from … import 语句来完成。

只要你一用import语句,你就可以用这个module了。可以是内置的模块比如os和sys,可以是已经安装到环境中的第三方模块,也可以是你项目的内部模块。

为了和编码风格保存一致,模块名要短,使用小写字母,一定要避免使用特殊符号,如点(.),问号(?)。所以要避免的像my.spam.pu这样的文件名!这种命名方式会干扰python查找模块。

在my.spam.py 这个例子中,python想要在my文件夹中查找 spam.py文件,而这不是我们想要的。在python文档中还有一个关于应该如何使用点的例子

你可以将模块命名为my_spam.py。尽管可以使用,但还是不应该经常在模块名中看到下划线。

除了一些命名的限制,在将一个python文件当做一个模块方面没有什么特别的要求。但是你需要理解import的机制来正确使用这一概念和避免一些问题。

具体而言,如果modu.py文件和调用方在同一目录中,import modu 语句将能寻找到适当的文件。如果找不到他,python解释器将在“path”中递归查找,如果没有找到将raise ImportError异常。

一旦发现了modu.py,python解释器会在一个隔离的作用域内执行这个模块。任何modu.py中的顶级语句将被执行,包括其他import。函数和类的定义将被存储在module的字典中。

然后,该模块的变量、函数和类将通过模块的命名空间提供给调用方。在python中这是特别有用和强大的核心概念。

在很多语言中,包含文件的指令的作用是:由预处理器找到文件中的所有代码并复制到调用方的代码中。在Python是不同的:包含的代码被隔离在一个模块命名空间中,这就意味着你一般不需要担心包含的代码产生不良影响,例如覆盖具有相同名称的现有函数。

也可以通过用特殊语法的import语句来模拟更标准的行为:from modu import *。普遍认为这是不好的做法。使用 `i使得代码难以阅读并且使得依赖关系没有进行足够的划分**.

from modu import func 这种方式可以准确定位你想要imprint的函数并把它引入全局命名空间。比 import * 的危害小的多,因为它明确地显示什么被引入全局命名空间中,相比import modu的唯一优势是他可以少打点字。

非常糟糕

好一点

最好

Code Style一节中提到可读性是python的主要特点之一。可读性意味着避免无用的文字和散乱的结构,因此要花费一些精力在达到一定程度的简洁。但是不能太简介,否则就晦涩难懂了。要能够立刻告诉一个类或函数来自哪里,比如modu.func这种。这能大大提高代码的可读性和可理解性,除了最简单的单文件项目。

python提供了一个非常简单的包系统,可以简单的将一个目录扩展为一个包。 任何有 __init__.py 文件的目录都可以被认为是一个python包。包中不同的模块可以像普通的模块一样被引入。__init__.py文件有一个特殊的作用,收集所有包范围的定义。

import pack.modu语句可以引入pack/目录里的modu.py文件。此语句将在pack中查找__init__.py文件,执行所有其顶层的语句。然后他将查找名为pack/modu.py的文件并执行文件中的所有顶级语句。这些操作中,所有modu.py中的变量、函数和类的定义可以通过pack.modu命名空间获得。

一个常见的问题是将太多代码写在了__init__.py文件中。当项目的复杂性增长时,可能在深层的目录结构中可能会有子包甚至子子包。在这种情况下,从子子包中import一个简单的项目同样需要执行所有在遍历树中遇到的__init__.py文件。

__init__.py文件是空的这很正常。如果包的模块和子模块不需要共享任何代码这甚是是一个好的做法。 最后,介绍一种方便的语法,可以用来引入深层嵌套的包:import very.deep.module as mod。这样可以用 mode来代替冗长的very.deep.module

面向对象编程

Python有时被描述为一种面向对象的编程语言。这可能会让人误解需要加以澄清。

在python中,一切都是对象。这是什么意思,例如:函数是一级对象。函数、类、字符串等在python中都是对象:像任何对象一样,他们有类型,他们可以被作为函数参数传递,他们可能有方法和属性。这样理解的话,python是一种面向对象的语言。

但是不像java。python没有将面向对象编程作为主要的编程范式。对于python项目不是面向对象的(也就是没有使用或很少使用类的定义、类的继承或任何其他特定于面向对象编程的机制)是完全可行的。

此外,在模块部分,python处理模块和命名空间的方式给开发者很自然的方式去确保抽象层的封装和分离,这成为了使用面向对象的最常见的原因。因此,当没有被要求时,python程序员有更多空间来不使用面向对象。

实际上确实存在一些场合,应当避免在不必要的时候使用”面向对象”。若要将“状态”和“功能”结合起来,通过自定义类的方式自然是很受用。不过问题在于,正如我们在讨论函数式编程时指出的那样,函数式的“状态”和类的“状态”根本就不是一回事。

通常在一些架构中,典型的例子是web应用程序,会生成 Python程序的多个实例,使得可以在同一时间对外部请求进行响应。在这种情况下,实例化的对象持有着某种状态,也就是说持有一些环境的静态信息,这很容易出现并发问题或争态条件。有时,在初始化的对象 (通常是用__init__() 方法) 与实际使用对象之间,环境可能发生了改变,而且保留的状态可能已经过时。例如,请求可能加载一个item到内存中的,并将其标记为已读。在同一时间如果另一个请求需要删这个item,可能会发生这样的事情:第一个进程加载了item后被删除了,然后我们把已经删除的对象标记为了已读。

这个问题或者其他问题让我们产生这样一个想法,使用无状态函数或许是一个更好的编程范式。 另一种方式是建议使用隐式上下文和副作用尽可能少的函数和过程。函数的隐式上下文是由全局变量和在函数内部访问可以访问的持久层中的项组成。副作用是函数会使其隐式上下文发生改变,如果一个函数保存或删除了全局变量或持久层中的数据,我们把这种行为称之为副作用。

把带有上下文和副作用的函数从仅仅包含逻辑的函数(纯函数)中小心的剥离出来,会带来如下的益处:

  • 纯函数都具有确定性: 给出一个固定的输入,输出总是会相同。
  • 纯函数更容易更改或替换,如果它们需要重构或优化的话。
  • 纯函数的测试与单元测试更容易编写: 很少需要复杂的上下文设置和之后的数据清洗。
  • 纯函数更容易操纵,修饰,分发。

总之,一些架构中纯函数比类和对象能更有效地进行模块化构建。因为他们没有任何上下文或副作用。很明显,在许多情况下面向对象是有用的,甚至是必要的,例如当开发图形化桌面应用程序或游戏,有需要的操纵的东西 (窗口、 按钮、 人物、 车辆) 需要在计算机的内存中具有相对较长的生命周期。

修饰器

python语言提供简单但功能强大的语法:“修饰器”。装饰器是一个函数或者类,它可以包装(或修饰)一个函数或方法。装饰器函数或方法将取代原来的“未装饰”的函数或方法。因为在python中函数是一级对象,它可以被“手动操作”,但是用@decorator语法更清晰,因此要首选这种方式。

这种机制是对分离关注点和避免外部非相关的逻辑 ‘污染’ 函数或方法的核心逻辑来说是有用的。有些功能如果用装饰器来实现会更好,缓存就是一个很好的例子:你想要将耗时的函数结果存储在一个表中并直接使用他们而不是重复计算他们。这显然不是函数逻辑的一部分。

动态类型

python是动态类型的,这意味着变量并没有固定的类型。事实上,在python中,变量和很多其他语言非常不同,特别是静态类型语言。变量不是电脑内存中的一段,他们是指向对象的’tags’或’names’。因此可能变量“a”被设为1,然后变成了“a string”,然后又变成了一个函数。

这样不好

这样好

python的动态类型通常被认为是不可靠的,确实会带来复杂的,难以调试的代码。命名为“a”的可能是很多不同的东西,开发者或维护者需要在代码中跟踪它确保它没有被设为完全无关的对象。 一些方法有助于避免这种问题: 避免为不同的事物使用相同的变量名

使用剪短的函数或方法有助于降低使用同名代表两个不同事物所带来的风险。

甚至对于相关的事物,也最好使用不同的名称,如果它们类型不同的话

这样不好

重用名称并不会提高效率:赋值的时候无论怎样都会去创建新的对象。然而,随着复杂性的上升,赋值语句被其他代码分开,包括“if”分支和循环,将越来越难以确定变量的类型是什么。 一些编码实践,比如函数式编程,建议永远不会重新分配一个变量。在java中,可以使用final关键字,python没有final关键字而且无论如何这都是违反python的哲学的。不过,避免多次为同一个变量赋值是一个好习惯,而且可以有助于掌握可变类型和不可变类型的概念

可变类型和不可变类型

python提供两种内置或用户定义的类型。 可变类型是内容允许修改的。典型的可变类型是list和dict:所有的list都有可变方法,比如list.append()或llist.pop(),并且可以就地修改。字典也是一样的。 不可变类型没有提供改变其内容的方法。例如:设置为6的整数变量x没有“increment”方法。如果你想要计算x+1,你必须创建另一个整数并给他一个名称。

这种差异的一个后果是可变类型不是”稳定的”,并因此不能用作字典的键。 可变性质的东西用可变类型,固定不变的用不可变类型这有助于阐明代码的目的。

例如,类似列表的不可变类型是元组,通过类似 (1,2)这种方式创建。此元组是一对,不能就地更改,并且可以用作键的字典。 python中一件令初学者吃惊的事情是,字符串类型是不可变的。这意味着,当需要组合一个字符串时,把每一部分都放到列表中(是可变的)会比较好,然后当需要整个字符串的时候再 把他们连(‘join’)起来。然而,有一件事要注意,列表推导比在循环调用append () 来构造列表要更好和更快。

不好

最佳

关于字符串最后要提的是,使用 join () 不是总是最好。比如,当你要用预先确定数目的字符串创建一个新的字符串时,使用加法运算符确实是更快,但在上述情况下或添加到现有的字符串的情况下用你应该首选 join ()。

注意 除了str.join() 和 +,你也可以使用 %格式运算符来串联预先确定数目的字符串。然而PEP 3101,建议用 str.format() 方法 取代%运算符。

2 4 收藏 评论

关于作者:mtunique

微博:@孟涛_hustGithub:mtunique个人网站:mtunique.com 个人主页 · 我的文章 · 13

可能感兴趣的话题



直接登录
跳到底部
返回顶部