python工程的合理化组织
目录
python工程的合理化组织
版本 | 日期 | 作者 | 内容 |
---|---|---|---|
1.0 | 2023-1-18 | zhaolb | 初始版本 |
1.1 | 2023-1-27 | zhaolb | 完善 import章节 |
1.2 | 2023-1-28 | zhaolb | 其它章节持续更新中 |
1、python的工程化要达到什么目的
python的工程化,就是要合理的组织大量的代码文件。
- 合理的组织结构
- 合理的分层化、模块化设计
- 合理的对外接口形式
- 版本管理、pip安装、说明文档等
- 测试与样例代码
2、import 详解
import 是 python 中模块间相互依赖引用的方式,搞懂 import 是学好 python 的必要条件,按照实际工作情况看,对 import 不熟悉,可能会导致各种莫名其妙的运行异常。
本文的可能用到的例子如下:
lubo@lubo-L460:~/work/python/VSCodeProjects/test_import$ tree .
.
├── main.py
└── pyfamily
├── child
│ ├── daughter.py
│ ├── grandchild
│ │ ├── grandson.py
│ │ └── __init__.py
│ ├── __init__.py
│ └── son.py
├── __init__.py
└── vehicle
├── car.py
├── __init__.py
└── motor.py
2.1 可以 import 哪类内容
从导入内容的所属方来看,主要分为如下几种:
- 系统内建模块
- pip安装的第三方模块
- 本工程内的其它模块
2.2 import 的一些写法、形式
import 的作用
一句话概括:
import 的作用是,把要 import 的对象,添加到执行 import 的模块的命名空间内。
换句话说,你导入的是谁,你就【能且只能】【直接】访问谁(以及它命名空间内的子对象),访问导入对象时,添加多余的前缀(命名空间)、或遗漏该有的前缀,都会导致访问异常。
注意1:此处的对象可能是包、模块、函数、变量、类等,按照 python 的说法,“一切皆对象”。
注意2:包可以认为是一种特殊的“模块”,它既可以是被导入的对象,也可以是导入其它对象的执行者,init.py 是包的的“模块代码”,可参与导入或被导入。
接下来,我们从3个场景,反复验证 “【能且只能】【直接】访问被导入对象(及其命名空间内的子对象)” 这句话的要义。
- import 一个“包”
- import 一个包中的“所有或某些对象”
- import 一个模块或模块中的所有或某些对象
import 一个 “包”
import xxx.package1
或者
import package1
package1 是一个 python 包(含有 __init__.py)。
导入一个包会发生什么,要分两种情况讲。
情况1,当 package1.__init__.py 中没有显式定义 import 规则时(没有 import 本包中任何模块)。
这种情况下,运行虽然不会报错,但并没有什么卵用,包中的任何模块文件,都无法被导入。
举例:
#pyfamily.child.__init__.py
#内容为空
#main.py
import pyfamily.child
#无法访问!!!!!
s = pyfamily.child.son.Son()
这时会报错,AttributeError: module ‘pyfamily.child’ has no attribute ‘son’
因为 child.__init__.py 为空,意味着 child 这个“模块”内没有子对象(内建属性除外)。
情况2,package1.__init__.py 中显式 import 了 package1 中某些模块或模块内的某些内容时。
这种情况下,其它模块就可以像导入普通模块那样,来导入 package1 或 package1 中的部分对象。
举例1:
#pyfamily.child.__init__.py
#将 son 模块添加到 child 的命名空间内
from . import son
#将 daughter 模块的 Daughter 类添加到 child 的命名空间内
from .daughter import Daughter
# main.py
import pyfamily.child
# 可正常访问 pyfamily.child.xxx :
s = pyfamily.child.son.Son()
d = pyfamily.child.Daughter()
# 无法访问 child.daughter !!!!!
d2 = pyfamily.child.daughter.Daughter()
解释:因为导入的是 pyfamily.child 这个包,所以,main.py 中能且只能以 pyfamily.child.xxx 的形式访问该包内的内容。
import 一个包中的“所有或某些对象”
首先要明确的是,此处的包内的“对象”,可能是某个模块,也可能是某个模块内的某个对象。python 的“一切皆对象”。
from package1 import *
这种情况,和上文中 import 一个 “包” 是类似的,只不过是将 package1 中的内容直接导入到 main.py 的命名空间,在 main.py 中访问时,不能加 package1 前缀,而是直接访问 package1 中的模块。
具体能导入哪些内容要看 dir2.init.py 中是否进一步定义了 import 哪些内容。
举例1:
#pyfamily.child.__init__.py
from .son import Son
from .daughter import Daughter
# main.py
from pyfamily.child import *
# 可正常访问:
s = Son()
d = Daughter()
# 命名空间错误,无法正常访问!!!!!
s2 = pyfamily.child.Son()
# child.__init__.py 中并没有导入 son ,所以无法正常访问!!!!!
s3 = son.Son()
解释:因为导入的是 pyfamily.child 内的对象,而不是 pyfamily.child 这个包,所以,main.py 的命名空间内,是无法访问 pyfamily.child.xxx 的,能且只能【直接】访问 child 包内的对象。
import 一个模块或模块中的所有或某些对象
#module.py
var1;
var2;
导入一个模块的写法:
import module
module.var1;
导入一个模块所有对象的写法:
from module import *
var1;
导入一个模块中某些内容的写法:
from module import var1
#可正常访问:
var1;
#无法正常访问!!!
module.var1;
或者
from module import var1 as VAR1, var2 as V2
VAR1;
V2;
2.3 对 import 的进一步理解
探究 import 的本质
首先强调,python 内一切皆对象,或者精确点说,一切都是某个类的实例。
例如,模块就是一种特殊的类,模块对象就是一个已经被导入内存的具体模块。打印 type(modulexxx) 的显示结果是 class ‘module’,代码中可以用 isinstance(xxx, types.ModuleType) 来判断某个对象是不是模块。
命名空间树
当执行 import a.b.c 时,发生了什么?
一开始,我会粗略的认为,解释器会将 “a.b.c” 作为一个整体,导入到当前模块的命名空间,其实不然。
因为,在当前模块的属性列表(__dict__)中只有 a,没有 b 和 c ,更没有 “a.b.c”,但进一步查看,a.__dict__ 包含 b ,b.__dict__ 包含 c。
这说明,解释器在导入所有模块后,会在进程范围内生成一棵“树”,靠这棵树来维护模块关系,它可以认为是以“属性”为节点的命名空间树。解释器会根据这个树来查找访问具体的模块节点。
这也解释了我们经常遇到的一种异常,当访问一个并没有被导入的模块 a.b.x 时,运行会报如下错误:
AttributeError: module ‘b’ has no attribute ‘x’
例如,当访问 a.b.c 时,解释器的处理方式很可能是这样的:先在当前模块属性列表中中查找 ‘a’,再去 a 的属性列表查找 ‘b’,再去b 的属性列表查找 ‘c’。当某个节点查找失败,会提示没有这个属性。虽然从逻辑上来讲,是这样的一个过程,但解释器会不会为了性能而走其它方式(比如利用 sys.modules 字典),不好说。(后文中有专门一节来测试访问 a.b.c 和直接访问 c 的耗时差异)
再进一步,由于保存了 a 这个根节点,当前模块并不是只能访问 a.b.c,而是能访问 a 节点下面所有其它已导入内存的模块节点,例如 a.d.e 、 a.m.n ,实际运行测试也验证了这一点。
另外,虽然任何对象都有属性列表,但我们此处关心的是“模块”级别的颗粒,所以,为了简化树的结构,我们不再对非模块类型的属性进行展开(遍历其子属性),也就是说,我们把模块类型的属性,看成是树的非叶子节点,把其它类型的属性(变量、方法、类等),看做是叶子节点。
导入方式对命名空间树的影响
当我们执行如下两条 import 语句后:
import a.b.c
from a.b import c
这时,当前模块的属性列表中有 a 和 c,当前模块可以通过 a.b.c 访问 c ,也可以直接访问 c,说明:
这个按照实际的模块文件路径来生成的命名空间树,还是存在,只不过当前模块也存储了 c 节点的“快捷访问方式”(对象引用),使得它可以不必通过根节点 a 来一级级查找,而是直接跳到正确节点。
此时的 sys.modules 中,会有如下几条:
a
a.b
a.b.c
而当我们执行如下一条 import 语句后:
from a.b import c
这时,当前模块的属性列表中只有 c,所以只可以直接访问 c,而不能通过 a.b.c 来访问,
而此时的 sys.modules 中,依然会有如下相同的几条:
a
a.b
a.b.c
这说明,解释器从进程层面生成的这棵命名空间树,是以模块文件的真实路径来生成的,不受模块导入方式的影响,导入方式仅影响访问范围,并不影响命名空间树的存在性或结构性。
经过上述分析,import 的本质已经比较清晰了,一句话概括如下:
import 的本质是,解释器将被导入模块(及其完整的访问路径)添加到进程内唯一的【命名空间树】内,并在执行 import 的当前模块的【命名空间】内,保存一个访问被导入对象的【入口】。
解释:
- 命名空间树。这是一个虚拟概念,它的对外呈现形式有两种:sys.modules 及 从主模块开始对属性的深度或广度遍历。
- 模块的命名空间。可以认为就是模块的属性列表,可通过 dir(obj)+getattr(obj,name) 或 obj.__dict__ 来获取该对象的命名空间内的所有可访问对象。
- 入口,也就是属性列表内的某个模块类型的对象。
import 一个“对象”(模块或模块内的变量、类、方法等),会发生如下几件事:
- 解释器通过查看该“对象”所属模块(的完整路径)是否已经存在于 sys.modules 字典中,来判断该模块是否已经被系统导入了;如果已经在 sys.modules ,说明已经系统已经导入过一次,不再重复导入。
- 如果被导入模块是首次导入内存,则会执行它的主代码(模块文件中没有缩进的代码),这相当于在内存中创建了一个对象实例。
- 将该“对象”的引用添加到主模块的属性列表中。
- 多个模块都导入同一个内容,只是对同一个对象创建了多个引用。
查看某个对象(模块、类、类对象等都可以认为是对象)的属性列表(命名空间)的方法,可以有两种方式,dir(obj)+getattr(obj) 或者 obj.__dict__ ,参考代码如下:
def print_attrs(obj):
attrs = dir(obj)
print("attrs of", obj,":",attrs)
for name in attrs:
print("attr[",name,"]: type =",type(getattr(obj,name))," ","value =",getattr(obj,name))
print("show attrs by __dict__:",obj.__dict__)
# 查看当前模块(主程序)的属性列表的方法:
print_attrs(sys.modules['__main__']);
__all__ 的作用(及局限性)
- 在普通模块文件中的作用
- 在 __init__.py 中的作用
1,在普通模块文件中的作用。
默认情况下,当执行 from module import * 时,module 中的所有非私有属性(不带下划线前缀),都会导入。
这时,module 中一些不必要的变量可能会污染当前命名空间。
而如果我们用 __all__,就可以显式指定哪些对外可见,除此之外的都对外不可见,并且,可以显式让一些私有属性对外可见。
例如下面这种情况,只有原本是私有属性的 _var2 是对外可见的,而原本是公有属性的 Class1 及 var1 反倒无法对外可见了。
#module.py
class Class1:
...
var1;
_var2;
__all__=["_var2";];
#main.py
from module import *
# 运行报错!!!!!
module.Class1();
# 运行报错!!!!!
module.var1;
# 运行正常
module._var2;
但是,注意,__all__ 仅适用于 from module import * 的场景,而无法适用于 import module 的场景。
例如下面这种情况,module.py 虽然用 __all__ 限定了没有任何对象对外可见,但 main.py 通过 module 前缀来访问 module 内的所有内容,是完全可行的:
#module.py
class Class1:
...
var1;
_var2;
__all__=[];
#-------------------
#main.py
import module
# 运行正常
module.Class1();
# 运行正常
module.var1;
# 运行正常
module._var2;
2,在 init.py 中的作用。
__all__ 在 init.py 中的作用和普通模块的情况类似,当执行 from package import * 时生效,当执行 import package 时,__all__ 是不起作用的!
举例,假设 package 下包含两个文件 init.py , module.py ,
# __init__.py
from module import var1, var2, _priv3, _priv4
__all__ = ["var1", "_priv3"]
#main.py
from package import *
# 可访问 var1
var1;
# 不可访问 var2,因为不在 all 列表中!!!
var2;
# 可访问私有属性 _priv3,因为在 all 列表中
_priv3;
# 不可访问 _priv4 ,因为不在 all 列表中!!!
_priv4;
#main.py
import package
#可访问 var1
package.var1;
#同样可访问 _priv3,关于私有属性的可见性,详见下文
package._priv3;
私有属性的可见性
私有属性是指用下划线做前缀来定义的任何类型对象,如 _var1, _func1, _class1 等。
一句话概括私有属性的可见性:
当前模块无法直接访问其它模块内的私有属性,两种情况除外:带有命名空间前缀的私有属性;__all__ 列表中包含的私有属性。
举几个例子。
(1)普通情况。当前模块(main.py)无法直接访问其它模块(module.py)的私有属性。
# module.py
var1;
_var2;
# main.py
from module import *
# 运行错误!!!!
_var2;
(2)带有命名空间前缀的私有属性,可访问。
# main.py
import module
# 运行正常
module._var2;
(3)__all__ 中包含的私有属性可访问。
# module.py
var1;
_var2;
__all__ = ["var1", "_var2"]
# main.py
from module import *
# 运行正常
_var2;
导入模块时如何避免命名空间污染
命名空间污染的意思是,假设 main.py 中 import module *
如果 module 中定义的变量或类与 main.py 定义的命名发生冲突,则可能会被覆盖或者引发莫名其妙的运行逻辑错误。
1,尽量避免导入模块的所有内容,而是仅导入我们需要的函数或类。
from module import var1/method1/class1
或者,利用 as 起个别名来避免命名冲突.
from module import var1 as VVV, method1 as MMM, class1 as CCC
2,如果一定要导入模块的全部内容,则应注意 import 用法。
合理的导入及访问方式:
import module
module.var1 = xxx;
module.method1();
将 module 作为一个整体导入,这样的好处是,只能通过前缀 “module” 来访问 module 内的内容,一般不会产生本地命名空间污染;并且,提高了代码可读性。
不合理的导入及访问方式:
from module import *
var1 = xxx;
method1();
不是将 module 作为整体导入,而是将 module 内的所有内容导入,这样的坏处是,可以直接访问 module 内的内容,很容易发生本地命名空间污染;并且,严重降低了代码可读性(一眼看不出 var1/method1 属于哪个模块)
3,模块内部的设计实现,应遵循如下几个原则。
- 尽量减少全局变量的使用,不得不定义但不必对外可见的全局变量、方法、类,要用下划线前缀(如 _var1、_class1)将其设为私有类型
- 对外可见的全局变量,命名要合理,比如名字不能太短,可增加合理的前缀名
- 利用 __all__ 来严格、精确控制对外可见的内容(仅适用于 from module import * 场景)
模块的命名空间长度对其访问性能的影响
既然前文知道了,python 进程内会维护一颗“命名空间树”,那么,当我们以不同长度的路径访问同一个模块时,访问效率有影响吗?
例如下面两种访问变量 var 的方式,耗时是否有明显差异?
import a.b.c.d.module
from a.b.c.d import module
a.b.c.d.module.var = 1
module.var = 1
先说结论:路径越长,耗时越多,耗时与路径长度呈线性相关。
测试代码如下:
import pyfamily.child.grandchild.grandson
from pyfamily.child import son
from pyfamily.child.grandchild import grandson
cnt=0
calc_range = 100
start_time = time.time_ns()
for i in range(calc_range):
pyfamily.child.grandchild.grandson.cnt += 1
end_time = time.time_ns()
print("time cost of pyfamily.child.grandchild.grandson.cnt:", end_time - start_time, " ns")
start_time = time.time_ns()
for i in range(calc_range):
pyfamily.child.son.cnt += 1
end_time = time.time_ns()
print("time cost of pyfamily.child.son.cnt:", end_time - start_time, " ns")
start_time = time.time_ns()
for i in range(calc_range):
son.cnt += 1
end_time = time.time_ns()
print("time cost of son.cnt: ", end_time - start_time, " ns")
start_time = time.time_ns()
for i in range(calc_range):
grandson.cnt += 1
end_time = time.time_ns()
print("time cost of grandson.cnt: ", end_time - start_time, " ns")
start_time = time.time_ns()
for i in range(calc_range):
cnt += 1
end_time = time.time_ns()
print("time cost of cnt:", end_time - start_time, " ns")
执行结果(多次测试结果差不多):
time cost of pyfamily.child.grandchild.grandson.cnt: 25315 ns
time cost of pyfamily.child.son.cnt: 20926 ns
time cost of son.cnt: 14970 ns
time cost of grandson.cnt: 15159 ns
time cost of cnt: 10045 ns
import 与 “引用”
问题探究:
from import 和 import 的另一个区别:当前模块修改导入对象的值后,原模块内的该对象值是否发生变化?
main.py
from module import var
那么,当 main.var 发生变化后,会影响 module.var的原值吗?换句话说,main.var和module.var到底是不是同一个对象?
改成 import module 后呢?
“python 内一切皆为对象。”
我们重新理解一下这句话,用C++的语言描述就是,python 内一切变量都是指向某个对象的指针,而对象只会再第一次创建时开辟空间。这也解释了为什么 python 变量不会限定类型,因为本质上它类似于一个 C++ 语言内的 void* 指针,在运行过程中可以改为指向任意其它类型。
由此延伸出另一个事实:一切等号赋值皆是修改引用。
var1 = Obj(); #创建对象,并让引用var1指向该对象
var2 = var1; #此时,var2和var1都是Obj()的引用。
var1 = 1; #创建对象"1",并让引用var1指向该对象
var2 = var1; #var2和var1都指向 1 这个对象
var2 = 2; #创建对象"2",var2引用发生变化,指向2这个对象,且不影响var1依然指向1)
https://blog.csdn.net/ywsydwsbn/article/details/124594486
https://blog.csdn.net/zheshigeren/article/details/115310328
可变对象与不可变对象
https://www.cnblogs.com/luosongchao/p/3724194.html
https://www.cnblogs.com/bubblebeee/p/15867934.html
https://blog.csdn.net/qq_41987033/article/details/81675514
赋值与引用
https://www.cnblogs.com/daminghuahua/p/16044691.html
现在我们理解了,import 导入的是原对象的一个引用,而不是开辟新空间创建新对象。所以,要搞明白 import 和 from import 的区别就很简单了:
情况1
#main.py
import module
module.var = newvalue
由于 main.py 只是保存了 ‘module’ 的引用,所以修改 main.module.var 的值(引用),就等同于直接修改原 module.var 的值。
情况2
#main.py
from module import var
var = newvalue
这时,main.py 是创建了对 module.var 的一个新引用 var,那修改 var 的值,相当于让 var 引用了别的对象,当然不会对原 module.var 有任何影响了。
情况3
#main.py
from module import list1
list1[0] = newvalue
这种情况,修改的不是 list1 这个引用,而是 list1 引用的实际对象内存空间内的数据,所以,module.list1 的内容是一定会变化的。
2.4 绝对导入与相对导入
原则:
- 对本包(及子包)内的模块,采用相对导入
- 对其他包内的模块,采用绝对导入
# pyfamily/child/son.py
# 对本包内采取相对导入
from . import daughter
from .grandchild import grandson
# 对其它包采取绝对导入
import pyfamily.vehicle.car as car
import pyfamily.vehicle.motor as motor
关于 sys.path
一般来讲,只允许主模块中添加少量 sys.path 路径,包内的功能模块文件,不得私自添加 sys.path 路径,而是要通过合理的结构设计、合理的 import 方式来避免。
2.5 动态导入
利用 import() 函数或 importlib 来动态导入模块,可用于反射、热补丁等场景。
略。
2.6 总结:import 的原则
- 大前提:合理的包组织结构及模块划分
- 合理的导入方式(绝对导入&相对导入)
- 避免命名空间污染
- 避免同名冲突覆盖(其实也算命名空间污染)
- 避免路径过长导致的访问性能低下
3、如何合理化设计工程包的目录结构
工程包从对外的功能呈现形态的角度,可分成如下两类:
- 库包。仅对外界提供一些功能性的模块,没有 __main__ 脚本,不负责启动进程
- 主程序包。对外提供功能性模块及启动业务功能的主程序,主程序是包含 __main__ 的脚本
两者又是相辅相成,由于涉及到 sys.path 相关的问题,主程序包最好也设计成 主程序脚本 + 库包 的形式。
3.1 主程序脚本的位置
例如下面这个名叫 test_import 的工程,核心的功能逻辑代码在 pyfamily 包里,pyfamily 对外提供一组业务层面的模块 api,然后在 pyfamily 的同级目录创建一个 main.py 来调用 pyfamily ,启动相关业务。
lubo@lubo-L460:~/work/python/VSCodeProjects/test_import$ tree .
.
├── main.py
└── pyfamily
├── child
│ ├── daughter.py
│ ├── grandchild
│ │ ├── grandson.py
│ │ └── __init__.py
│ ├── __init__.py
│ └── son.py
├── __init__.py
└── vehicle
├── car.py
├── __init__.py
└── motor.py
为何要这样设计呢?因为:当 python 解释器启动一个 py 脚本时,会将该 py 脚本所在的目录(注意,不是执行命令的目录),自动添加到 sys.path 中。也就是说,在 py 脚本中执行 sys.path.append(“.”) 其实是多此一举。
例如,在如下三个不同的目录中启动 main.py 脚本,效果是一样的,都会自动把 “/work/python$ python3 VSCodeProjects/test_import/” 这个目录添加到 sys.path 中。
lubo@lubo-L460:~/work/python/VSCodeProjects/test_import$ python3 main.py
lubo@lubo-L460:~/work/python/VSCodeProjects$ python3 test_import/main.py
lubo@lubo-L460:~/work/python$ python3 VSCodeProjects/test_import/main.py
所以,只要主程序 main.py 和 包 pyfamily 的相对位置保持不变,不管将 test_import 这整个工程包放到哪个路径、不管在哪个路径下启动 main.py ,都不会存在 import 报错。
总结:主程序 py 文件,应放到和核心包同级的目录中。
3.2 模块测试代码如何运行
上一节讲了主程序 py 文件的位置问题。那如果单运行某个模块的测试代码呢?模块往往是位于核心包内部,直接运行该脚本会因为 sys.path 问题导致 import 出错。
例如,如何单独运行 pyfamily/child/son.py ?
正确的做法是:
在主入口程序的目录下,添加 -m 参数来执行模块文件:
lubo@lubo-L460:~/work/python/VSCodeProjects/test_import$ python3 -m pyfamily/child/son.py
-m 参数的作用就是,告诉解释器:当前要执行的是一个“模块”,请将执行命令的路径(而不是模块文件路径)添加到 sys.path 中。