极客时间已完结课程限时免费阅读

13 | 搭建积木:Python 模块化

13 | 搭建积木:Python 模块化-极客时间

13 | 搭建积木:Python 模块化

讲述:冯永吉

时长11:07大小10.16M

你好,我是景霄。
这是基础版块的最后一节。到目前为止,你已经掌握了 Python 这一门当代武功的基本招式和套路,走出了新手村,看到了更远的世界,有了和这个世界过过招的冲动。
于是,你可能开始尝试写一些不那么简单的系统性工程,或者代码量较大的应用程序。这时候,简单的一个 py 文件已经过于臃肿,无法承担一个重量级软件开发的重任。
今天这节课的主要目的,就是化繁为简,将功能模块化、文件化,从而可以像搭积木一样,将不同的功能,组件在大型工程中搭建起来。

简单模块化

说到最简单的模块化方式,你可以把函数、类、常量拆分到不同的文件,把它们放在同一个文件夹,然后使用 from your_file import function_name, class_name 的方式调用。之后,这些函数和类就可以在文件内直接使用了。
# utils.py
def get_sum(a, b):
return a + b
# class_utils.py
class Encoder(object):
def encode(self, s):
return s[::-1]
class Decoder(object):
def decode(self, s):
return ''.join(reversed(list(s)))
# main.py
from utils import get_sum
from class_utils import *
print(get_sum(1, 2))
encoder = Encoder()
decoder = Decoder()
print(encoder.encode('abcde'))
print(decoder.decode('edcba'))
########## 输出 ##########
3
edcba
abcde
我们来看这种方式的代码:get_sum() 函数定义在 utils.py,Encoder 和 Decoder 类则在 class_utils.py,我们在 main 函数直接调用 from import ,就可以将我们需要的东西 import 过来。
非常简单。
但是这就足够了吗?当然不,慢慢地,你会发现,所有文件都堆在一个文件夹下也并不是办法。
于是,我们试着建一些子文件夹:
# utils/utils.py
def get_sum(a, b):
return a + b
# utils/class_utils.py
class Encoder(object):
def encode(self, s):
return s[::-1]
class Decoder(object):
def decode(self, s):
return ''.join(reversed(list(s)))
# src/sub_main.py
import sys
sys.path.append("..")
from utils.class_utils import *
encoder = Encoder()
decoder = Decoder()
print(encoder.encode('abcde'))
print(decoder.decode('edcba'))
########## 输出 ##########
edcba
abcde
而这一次,我们的文件结构是下面这样的:
.
├── utils
│ ├── utils.py
│ └── class_utils.py
├── src
│ └── sub_main.py
└── main.py
很容易看出,main.py 调用子目录的模块时,只需要使用 . 代替 / 来表示子目录,utils.utils 表示 utils 子文件夹下的 utils.py 模块就行。
那如果我们想调用上层目录呢?注意,sys.path.append("..") 表示将当前程序所在位置向上提了一级,之后就能调用 utils 的模块了。
同时要注意一点,import 同一个模块只会被执行一次,这样就可以防止重复导入模块出现问题。当然,良好的编程习惯应该杜绝代码多次导入的情况。在 Facebook 的编程规范中,除了一些极其特殊的情况,import 必须位于程序的最前端
最后我想再提一下版本区别。你可能在许多教程中看到过这样的要求:我们还需要在模块所在的文件夹新建一个 __init__.py,内容可以为空,也可以用来表述包对外暴露的模块接口。不过,事实上,这是 Python 2 的规范。在 Python 3 规范中,__init__.py 并不是必须的,很多教程里没提过这一点,或者没讲明白,我希望你还是能注意到这个地方。
整体而言,这就是最简单的模块调用方式了。在我初用 Python 时,这种方式已经足够我完成大学期间的项目了,毕竟,很多学校项目的文件数只有个位数,每个文件代码也只有几百行,这种组织方式能帮我顺利完成任务。
但是在我来到 Facebook 后,我发现,一个项目组的 workspace 可能有上千个文件,有几十万到几百万行代码。这种调用方式已经完全不够用了,学会新的组织方式迫在眉睫。
接下来,我们就系统学习下,模块化的科学组织方式。

项目模块化

我们先来回顾下相对路径和绝对路径的概念。
在 Linux 系统中,每个文件都有一个绝对路径,以 / 开头,来表示从根目录到叶子节点的路径,例如 /home/ubuntu/Desktop/my_project/test.py,这种表示方法叫作绝对路径。
另外,对于任意两个文件,我们都有一条通路可以从一个文件走到另一个文件,例如 /home/ubuntu/Downloads/example.json。再如,我们从 test.py 访问到 example.json,需要写成 '../../Downloads/example.json',其中 .. 表示上一层目录。这种表示方法,叫作相对路径。
通常,一个 Python 文件在运行的时候,都会有一个运行时位置,最开始时即为这个文件所在的文件夹。当然,这个运行路径以后可以被改变。运行 sys.path.append("..") ,则可以改变当前 Python 解释器的位置。不过,一般而言我并不推荐,固定一个确定路径对大型工程来说是非常必要的。
理清楚这些概念后,我们就很容易搞懂,项目中如何设置模块的路径。
首先,你会发现,相对位置是一种很不好的选择。因为代码可能会迁移,相对位置会使得重构既不雅观,也易出错。因此,在大型工程中尽可能使用绝对位置是第一要义。对于一个独立的项目,所有的模块的追寻方式,最好从项目的根目录开始追溯,这叫做相对的绝对路径。
事实上,在 Facebook 和 Google,整个公司都只有一个代码仓库,全公司的代码都放在这个库里。我刚加入 Facebook 时对此感到很困惑,也很新奇,难免会有些担心:
这样做似乎会增大项目管理的复杂度吧?
是不是也会有不同组代码隐私泄露的风险呢?
后来,随着工作的深入,我才发现了这种代码仓库独有的几个优点。
第一个优点,简化依赖管理。整个公司的代码模块,都可以被你写的任何程序所调用,而你写的库和模块也会被其他人调用。调用的方式,都是从代码的根目录开始索引,也就是前面提到过的相对的绝对路径。这样极大地提高了代码的分享共用能力,你不需要重复造轮子,只需要在写之前,去搜一下有没有已经实现好的包或者框架就可以了。
第二个优点,版本统一。不存在使用了一个新模块,却导致一系列函数崩溃的情况;并且所有的升级都需要通过单元测试才可以继续。
第三个优点,代码追溯。你可以很容易追溯,一个 API 是从哪里被调用的,它的历史版本是怎样迭代开发,产生变化的。
在做项目的时候,虽然你不可能把全世界的代码都放到一个文件夹下,但是类似模块化的思想还是要有的——那就是以项目的根目录作为最基本的目录,所有的模块调用,都要通过根目录一层层向下索引的方式来 import。
明白了这一点后,这次我们使用 PyCharm 来创建一个项目。这个项目结构如下所示:
.
├── proto
│ ├── mat.py
├── utils
│ └── mat_mul.py
└── src
└── main.py
# proto/mat.py
class Matrix(object):
def __init__(self, data):
self.data = data
self.n = len(data)
self.m = len(data[0])
# utils/mat_mul.py
from proto.mat import Matrix
def mat_mul(matrix_1: Matrix, matrix_2: Matrix):
assert matrix_1.m == matrix_2.n
n, m, s = matrix_1.n, matrix_1.m, matrix_2.m
result = [[0 for _ in range(n)] for _ in range(s)]
for i in range(n):
for j in range(s):
for k in range(m):
result[i][k] += matrix_1.data[i][j] * matrix_2.data[j][k]
return Matrix(result)
# src/main.py
from proto.mat import Matrix
from utils.mat_mul import mat_mul
a = Matrix([[1, 2], [3, 4]])
b = Matrix([[5, 6], [7, 8]])
print(mat_mul(a, b).data)
########## 输出 ##########
[[19, 22], [43, 50]]
这个例子和前面的例子长得很像,但请注意 utils/mat_mul.py,你会发现,它 import Matrix 的方式是from proto.mat。这种做法,直接从项目根目录中导入,并依次向下导入模块 mat.py 中的 Matrix,而不是使用 .. 导入上一级文件夹。
是不是很简单呢?对于接下来的所有项目,你都能直接使用 Pycharm 来构建。把不同模块放在不同子文件夹里,跨模块调用则是从顶层直接索引,一步到位,非常方便。
我猜,这时你的好奇心来了。你尝试使用命令行进入 src 文件夹,直接输入 Python main.py,报错,找不到 proto。你不甘心,退回到上一级目录,输入Python src/main.py,继续报错,找不到 proto。
Pycharm 用了什么黑魔法呢?
实际上,Python 解释器在遇到 import 的时候,它会在一个特定的列表中寻找模块。这个特定的列表,可以用下面的方式拿到:
import sys
print(sys.path)
########## 输出 ##########
['', '/usr/lib/python36.zip', '/usr/lib/python3.6', '/usr/lib/python3.6/lib-dynload', '/usr/local/lib/python3.6/dist-packages', '/usr/lib/python3/dist-packages']
请注意,它的第一项为空。其实,Pycharm 做的一件事,就是将第一项设置为项目根目录的绝对地址。这样,每次你无论怎么运行 main.py,import 函数在执行的时候,都会去项目根目录中找相应的包。
你说,你想修改下,使得普通的 Python 运行环境也能做到?这里有两种方法可以做到:
import sys
sys.path[0] = '/home/ubuntu/workspace/your_projects'
第一种方法,“大力出奇迹”,我们可以强行修改这个位置,这样,你的 import 接下来肯定就畅通无阻了。但这显然不是最佳解决方案,把绝对路径写到代码里,是我非常不推荐的方式(你可以写到配置文件中,但找配置文件也需要路径寻找,于是就会进入无解的死循环)。
第二种方法,是修改 PYTHONHOME。这里我稍微提一下 Python 的 Virtual Environment(虚拟运行环境)。Python 可以通过 Virtualenv 工具,非常方便地创建一个全新的 Python 运行环境。
事实上,我们提倡,对于每一个项目来说,最好要有一个独立的运行环境来保持包和模块的纯净性。更深的内容超出了今天的范围,你可以自己查资料了解。
回到第二种修改方法上。在一个 Virtual Environment 里,你能找到一个文件叫 activate,在这个文件的末尾,填上下面的内容:
export PYTHONPATH="/home/ubuntu/workspace/your_projects"
这样,每次你通过 activate 激活这个运行时环境的时候,它就会自动将项目的根目录添加到搜索路径中去。

神奇的 if __name__ == '__main__'

最后一部分,我们再来讲讲 if __name__ == '__main__' ,这个我们经常看到的写法。
Python 是脚本语言,和 C++、Java 最大的不同在于,不需要显式提供 main() 函数入口。如果你有 C++、Java 等语言经验,应该对 main() {} 这样的结构很熟悉吧?
不过,既然 Python 可以直接写代码,if __name__ == '__main__' 这样的写法,除了能让 Python 代码更好看(更像 C++ )外,还有什么好处吗?
项目结构如下:
.
├── utils.py
├── utils_with_main.py
├── main.py
└── main_2.py
# utils.py
def get_sum(a, b):
return a + b
print('testing')
print('{} + {} = {}'.format(1, 2, get_sum(1, 2)))
# utils_with_main.py
def get_sum(a, b):
return a + b
if __name__ == '__main__':
print('testing')
print('{} + {} = {}'.format(1, 2, get_sum(1, 2)))
# main.py
from utils import get_sum
print('get_sum: ', get_sum(1, 2))
########## 输出 ##########
testing
1 + 2 = 3
get_sum: 3
# main_2.py
from utils_with_main import get_sum
print('get_sum: ', get_sum(1, 2))
########## 输出 ##########
get_sum_2: 3
看到这个项目结构,你就很清晰了吧。
import 在导入文件的时候,会自动把所有暴露在外面的代码全都执行一遍。因此,如果你要把一个东西封装成模块,又想让它可以执行的话,你必须将要执行的代码放在 if __name__ == '__main__'下面。
为什么呢?其实,__name__ 作为 Python 的魔术内置参数,本质上是模块对象的一个属性。我们使用 import 语句时,__name__ 就会被赋值为该模块的名字,自然就不等于 __main__了。更深的原理我就不做过多介绍了,你只需要明白这个知识点即可。

总结

今天这节课,我为你讲述了如何使用 Python 来构建模块化和大型工程。这里需要强调几点:
通过绝对路径和相对路径,我们可以 import 模块;
在大型工程中模块化非常重要,模块的索引要通过绝对路径来做,而绝对路径从程序的根目录开始;
记着巧用if __name__ == '__main__'来避开 import 时执行。

思考题

最后,我想为你留一道思考题。from module_name import *import module_name有什么区别呢?欢迎留言和我分享,也欢迎你把这篇文章分享给你的同事、朋友。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 35

提建议

上一篇
12 | 面向对象(下):如何实现一个搜索引擎?
下一篇
14 | 答疑(一):列表和元组的内部实现是怎样的?
unpreview
 写留言

精选留言(83)

  • Jingxiao
    置顶
    2019-06-09
    思考题答案: 很多回复说的很对,from module_name import * 会把 module 中所有的函数和类全拿过来,如果和其他函数名类名有冲突就会出问题;import model_name 也会导入所有函数和类,但是调用的时候必须使用 model_name.func 的方法来调用,等于增加了一层 layer,有效避免冲突。
    共 3 条评论
    237
  • helloworld
    2019-07-16
    本文中说在 Python 3 规范中,包目录下的__init__.py 并不是必须的,这个我想补充一下,这个__init__.py最好还是有,并且在这个文件里面通过from . import module的形式把该目录下的所有模块文件都写上,如果不这样做,我们只能通过from 包目录 import 模块 的方式来正确使用,而如果直接 import 包目录 的话,虽然import过程不会报错,但是我们在使用该包目录下的模块的时候就会报找不到模块的错误了!
    共 1 条评论
    37
  • jim
    2019-06-07
    from module_name import * 是导入module_name 内的所有内容,可以直接调用内部方法;import module_name,则是导入module_name,在代码中必须写成module_name.function的形式。
    29
  • 上梁山
    2019-12-30
    文章中有这么一句话:“import 同一个模块只会被执行一次,这样就可以防止重复导入模块出现问题。” “import 同一个模块只会被执行一次”,这句话让我这个新手有点困惑。 这里的只会被执行一次,指的是导入模块的语句只执行一次,还是指被导入的模块中的语句只执行一次。 我相信很多向我这样的新手,都会认为是前者。 但是,经过代码实验,这里指的是被导入的模块中的语句只执行一次。 假如现在有main.py和foo.py两个文件, foo.py中内容如下: def bar(): print('bar') print('hello') main.py中内容如下: from foo import bar import foo bar() foo.bar() 执行python main.py后的结果是: hello bar bar
    展开

    作者回复: 好建议,这里确实没想到读者会有误解。

    共 4 条评论
    13
  • Kuzaman
    2019-06-21
    找到一种通用的加载环境变量的方法,很适用于 python虚拟环境virtualenvwrapper-win: 原理:python运行时都会先去site-packages目录下寻找.pth文件,如果有就先加载里面的路径到环境变量中。 操作:在X:\Python36\Lib\site-packages目录下增加一个 xxx.pth文件文件内容是要运行项目的绝对地址,windows操作系统记得使用 \\ 作为分隔符。 如果项目路径中有中文,运行python会报错:“UnicodeDecodeError: 'gbk' codec can't decode byte 0xaa in position 42: illegal multibyte sequence”。 解决方案:修改python环境源码 X:\Python36\lib\site.py的159行,由【f = open(fullname, "r")】修改为【f = open(fullname, "r",encoding='utf-8')】
    展开
    共 2 条评论
    13
  • 书了个一
    2019-06-07
    老师端午节快乐!
    10
  • 萧潇风
    2019-06-07
    export PYTHONPATH="/home/ubuntu/workspace/your_projects" 在windows系统 中 亲测无效 T_T

    作者回复: windows 的文件系统和 Linux 不太相同,学计算机科学还是尽早切换到 linux 系统吧

    共 2 条评论
    9
  • 乘坐Tornado的线程魔...
    2019-06-15
    老师,对于文中“项目模块化”的段落,有关二维矩阵相乘运算,您给的例子是行与列维度相同的,所以代码运行没有问题;如果您试一下3 x 2的矩阵与2 x 3的矩阵相乘, 例如:[[1, 2], [3, 4], [5, 6]] 与[[5, 6, 7], [7, 8, 9]],代码则会报错(索引溢出)。 我改写了一下mat_nul方法: def mat_mul(matrix_1: Matrix, matrix_2: Matrix): assert matrix_1.m == matrix_2.n n, s, m = matrix_1.n, matrix_1.m, matrix_2.m result = [[0 for _ in range(m)] for _ in range(n)] for i in range(n): for j in range(s): for k in range(m): print(i, j, k) result[i][k] += matrix_1.data[i][j] * matrix_2.data[j][k] return Matrix(result) 我自测通过,请您帮忙验证下!不胜感激!
    展开
    共 1 条评论
    7
  • Smirk
    2019-06-14
    这节不错,目录结构那个之前一直用相对路径,但是觉得不干净,也奇怪为什么pycharm可以,但没深究,终于等到老师的文字,赞
    7
  • Cynthia🌸
    2019-07-02
    1. 在 Python 3 规范中,__init__.py 并不是必须的,这只是python2的规范。 2. 项目中,import可以用相对路径,是pycharm的黑魔法。自己也可以通过虚拟环境配置path实现。强烈建议一个项目用一个虚拟环境以保持纯净! 3. import在导入时会把暴露在外面的代码都执行一遍,因此不想执行的话,请加上 if __main__ 的条件判断。
    6
  • enjoylearning
    2019-06-08
    作者写的都是原来疑惑的地方,如有时候要新建一个模块总是纠结于是添加文件夹还是包,怀疑加文件夹是不是不如加包规范,有时面对每个文件夹一个空的__init__.py,觉得真是不够优雅,现在好了,原来是2和3的区别,以后可以大胆的用文件夹来组织模块了。另外就是觉得python 命名模块名不能像java和.net那样以公司名.application.web.api格式,觉得还是有点别扭。

    作者回复: 哈哈对,除非是老项目维护,新项目最好使用 python 3 来构建,确定好规范后就可以大胆 coding,不用太担心兼容性问题。

    5
  • Paul Shan
    2019-11-15
    思考题 两者的前缀不同。第一种,把目录下的文件都倒入了,每个文件有各自前缀。第二种,只是倒入了一个前缀,所有文件都通过这个相同前缀。第一种类似于拷贝一个目录下所有文件。第二种类似于拷贝整个目录。

    作者回复: 👍

    4
  • 2020
    2019-09-01
    我自己试了下:实际上PyCharm在程序执行的时候会将项目根目录的绝对路径以及当前执行的文件所在的绝对路径一起加到sys.path里,所以无论怎么执行都能找到模块
    4
  • 更好的做自己
    2019-06-09
    老师,我使用anaconda配置的virtual env,在相应virtual env中找到的activate文件后加上PYTHONPATH,使用source activate激活virtual env后,echo $PYTHONPATH没有值输出,运行python main.py,依然会报ModuleNotFoundError: No module named 'proto'这个错误,使用原生Python解释器和mkvirtualenv能够成功,这是anaconda在activate时的机制不同吗?希望老师解答一下
    3
  • 非同凡想
    2020-12-26
    老师矩阵乘法有错误!!!正确的如下: ''' matrix 1 n*m matrix 2 m*s result n*s ''' def mat_mul(matrix_1: Matrix, matrix_2: Matrix): assert matrix_1.m == matrix_2.n n, m, s = matrix_1.n, matrix_1.m, matrix_2.m result = [[0 for _ in range(s)] for _ in range(n)] for i in range(n): for j in range(s): for k in range(m): #matrx_1 的第i行每个格子 * matrx_2 第j列每个格子,再求和 result[i][j] += matrix_1.data[i][k] * matrix_2.data[k][j] return Matrix(result)
    展开
    共 1 条评论
    2
  • Kevin
    2020-06-22
    方法一:from module_name import * 是将 模块中的说有函数或类,以原来的名称导入,可以直接使用; 方法二:import module_name 相当于导入了一个字典,需要通过module_name.xxxx的方式获取模块中的函数,属性等; 方法一多个模块中如果用相同名字的函数,可能会报错; 方法二模块名字太长的话不方便。可以使用 as 使用别名,有利于解决方法一中的函数重名问题。
    展开
    2
  • Geek_66525b
    2019-06-26
    如果使用conda, 怎么达到和Virtualenv同样的效果呢?
    共 1 条评论
    2
  • Kuzaman
    2019-06-11
    老师,python虚拟环境使用【virtualenvwrapper-win、virtualenv】,我用的是sublimetext3它只能配置编译环境的位置(我在sublime的tools--build system--python-venv01指向了虚拟环境Venv01),但是我无法达到老师文章说的:“这样,每次你通过 activate 激活这个运行时环境的时候,,它就会自动将项目的根目录添加到搜索路径中去。”
    2
  • Geek_59f23e
    2019-06-08
    class_utils.py那儿应该是reversed吧,而不是reverse。

    作者回复: 多谢,是 reversed.

    2
  • 天蓬太帅
    2020-10-05
    老师,您说“在 Facebook 和 Google,整个公司都只有一个代码仓库,全公司的代码都放在这个库里。” 能不能多介绍一下?在一个代码仓库,如果用pycharm,怎么做到不同project,不同虚拟环境公用这个代码库呢?
    1