Python 小白快速从入门到放弃:阅读源码

学编程最重要的就是写代码、读代码。上节提到了要阅读大神的或优秀的代码,之前也一直在强调要动手实践,这节咱们关注下如何阅读源代码。好的源代码不仅能让我们学到关于编程的知识,而且还有如何思考问题、抽象业务、设计架构等等方面知识。

读什么

很多人应该都有这样的经历:任何你想了解的领域或学习的知识,只要随便一搜都有海量的资源。我们的问题是如何找到最适合自己当前情况的资源。如果有同学对这个问题感兴趣,可以关注开智学堂的《信息分析》课程,这里我就直接给出来了。其实关键点就三个:

  • 大神的

  • 优秀的

  • 简单的

我们接下来就分别说一说,说完之后,我们要读的东西自然就出来了。

首先,要看大神的作品,理由就不必多说了。我推荐两个公认代码写的好的(而且人也很帅):

第一个是 requests, python-guidepipenv 的作者,以后大家多半会用到,现在不管就是了;第二个是 Flask 和 Jinja 的作者,以后也多半会用到。从他们的 GitHub Repository 开始就是个非常棒的开始。

其次,选择优秀的项目。大神的一般就很优秀,咱就不多说了。除了大神的作品外,各个领域往往也有很多优秀的项目,比如你是搞机器学习的,那么 sklearn 的源代码就非常的工业化,绝对值得学习;如果你是搞深度学习的,Google 和 Facebook 的代码就可以多看看。通过这两个例子大家应该看出来了:没错,就是选择知名的库和知名的公司出品的项目。另外,有本很多地方都推荐过的书:The Architecture of Open Source Applications

最后,选个简单的,尤其是刚开始的时候;或者选择优秀项目中的一部分功能。什么是简单的呢?第一,代码文件比较少,最好选择一两个的;第二,领域熟悉的,你不应该在不知道 SQL 的情况下选择一个数据库相关的项目。如果在 GitHub 上找,可以点击到 Repositories 列表,然后 filter language 选择 Python 后挨个浏览查找适合自己阅读的。

如果你实在难以选出适合自己的项目,geekcomputers/Python: My Python Examples 有一些非常非常简单的脚本可以拿来搞一搞。也可以自己搜索一些资源,但需要注意的是,尽量选择英文的资源,一方面是搞这个看英文资料是常态;另一方面是中文的不靠谱比例很高。不管咋样,还是建议同学能克服下,尽量按咱们之前提到的的方法找。

怎么读

当我们选择到合适的项目后就要开始阅读学习了。在正式开始之前,我们需要简单理一下流程和注意事项,无论你是系统读还是带着问题读,一般都可以归纳为以下几个步骤:

  • 通过文档了解项目实现的功能。一般项目都有 README 或文档,其中一般都会包括项目的主要功能和具体使用案例。
  • 如果是你怎么做。在第一步及具体的阅读过程中,时刻反问自己:“如果我来实现会怎么做”?尝试去理解作者的思维方式。
  • 从程序主入口到核心代码。从入口文件快速定位到要读的核心代码,在需要确定某个问题时一般都会这样做。当然,正常阅读时也建议直接找到主程序,然后按模块阅读。不要挨个文件或文件夹看,或者随机乱看。
  • 忽略掉不必要的细节,关注输入输出。有时候我们可能需要快速了解某个功能,或者解决某个问题,这时候就需要过滤掉无关细节,重点阅读相关代码。对于我们新人来说,肯定还会经常遇到一些自己不懂的,这时候有两种解决策略:如果已经知道某个函数的功能和输入输出,细节不懂的可以先跳过;或者就不懂的地方开始查资料,然后尽量掌握它。
  • 记录总结。这应该是最重要的,我们可以记录项目的相关情况,比如架构图、功能啊之类的,可以记录自己新掌握的知识,也可以记录学习的心得体会。总之,记录下来好处多多,比如便于我们日后随时复习翻阅(一般情况下,学一遍几乎肯定会忘的),节省时间;便于我们形成自己的知识图谱和体系,而且也是自己进一步学习的素材。

一个案例

接下来,我们就找一个案例来实践一下,我从 500lines 里面找了一个数据存储相关的:500lines/data-store/code at master · aosabook/500lines,作者的描述是:

A key/value store that you’d use like BDB or SQLite. It’s built like a couch, but not as nice.

之所以用这个,一方面是因为它涉及了不少 Python 相关的基本操作;另一方面是它还涉及到一个二叉树的数据结构;还有就是它涉及到不少关于类的复杂操作。我们可以在阅读的过程中学习如何跳过这些复杂的模块。

在正式按步骤开始阅读之前,我们得首先把它安装在本地,让它能够正常运行。需要说明的是,我们在本地测试(或者创建)某个项目时,一般都会单独给该项目配置一个环境,可以用 virtualenvpyenv 或者 pipenv,无论哪个工具最后都是隔离出一个相对独立的环境。咱们这里就不做这个了,直接将项目安装到系统环境下,这样也方便在 Jupyter Notebook 中进行交互操作(否则还需要给虚拟环境安装 Jupyter Notebook)。

  • 进入 500lines/data-store/code 目录,执行:pip install -r requirements.txt 安装项目依赖,然后执行:nosetests -v 保证测试全部通过。
  • 在该目录下创建一个 notebook,打开后应该可以执行:import dbdb;把 tool.py 也移动到这个目录,就可以通过 python tool.py args 这样使用了。

接下来我们就可以随意玩转儿项目了。

通过文档了解项目的功能

文档里只提到这是一个基于树的 key-value 简易数据库,数据更新从叶子结点开始,共享共同的结点。并没有使用例子,不过这个也没关系,我们可以通过 tool.py 或者 tests 来了解如何使用。而且 500lines 的项目都提供了作者写的相应的文章(就在第一部分很多地方推荐过的那本书里),比如这个项目:500 Lines or Less | DBDB: Dog Bed Database,我们也可以先读一遍这篇文章。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# BASIC USAGE
import dbdb

# 创建并连接一个 db 实例
dbname = "play1.db"
db = dbdb.connect(dbname)

# 添加一个 key value 存储
key = "name"
db[key] = "Your Name"
db.commit()

# 获取存储的 key value
db["name"]

# 删除存储的 key value
del db[key]
db.commit()

如果是你怎么做

这个问题在开始的时候可能连自己都会觉得幼稚,不过没关系,哪个大神不是从幼稚开始的呢?我们保持这个思考问题的习惯就好。你一开始可能想用一个字典存,或者把 key value 写在一个 json 文件里,这都是可以的。毕竟如果从单用户开机状态来说,基本功能确实也能满足。

现在我们考虑这么几个问题:如果多个用户同时更改 db 怎么办,甚至他们在修改同一个 key?如果某个用户在读一个 key,另一个用户同时在更改怎么办?很多个 value 都有重复的字段,只有少量字段不同,能不能共享这些相同的字段以节省空间?存储的过程中突然中断(系统故障、断电、手贱取消等)怎么办,怎么处理已经存进去的,如果很多个用户同时这么做呢?

这些其实都是一个 db 非常有可能面临的问题,其实真实的场景可能面临的问题更多。我们可以在这里停下来好好想一想,其实与其说这些是代码问题,还不如说是设计问题,即使你完全没有代码经验也完全可以思考这些问题。比如对多个用户同时更改 db,我们是不是可以设计一个机制,让一次只能有一个用户更改 db。但你马上一想就会觉得这肯定不现实,如果有个用户一直在更改,那其他用户难道就一直要等他一个人吗?于是我们可能又会想,那就把这个限制放在更新同一个 key 时,也就是说如果多个人要更新同一个 key,就让他们排队。暂时看起来貌似还能 work,我们可以继续思考其他问题,当把自己能想到的所有问题都想清楚后,就可以去看作者是怎么考虑这些问题并实现它们了。

从程序主入口到核心代码

主入口文件是 interface.py,非常直观,不过我们可以从 tool.py 看起,这个是命令行工具,展示了如何使用的一个实例。我估计不少人看到这个文件时就懵逼了,好像突然进入了另一个世界,明明还是 Python 代码,咋和之前几节的感觉完全不一样呢。其实这中间最大的区别是:之前的是能执行一定功能的代码片段,现在的是真正的工程项目。

那咋办呢?看看标题,咱们直接到核心的,其他的忽略。这个思路我一般叫它 “抽象”,简单来说就是不同情况下我们需要不同层次的抽象。比如你可以弄清楚每一行代码,甚至它底层的原理,但你也可以只关注主要功能。这个文件主要做了两件事:

  • 从命令行并识别读取参数
  • 根据参数执行相应结果
1
dbname, verb, key, value = (argv[1:] + [None])[:4]

分别对应传入的四个参数,最后一个 value 可能是 None (get 和 delete 时就没有),你可以在 main 函数下面加一行:pring(argv) 然后在命令行执行 python tool.py arg1 arg2 试试,屏幕上会输出:

1
2
3
4
5
['tool.py', 'arg1', 'arg2']
Usage:
python -m dbdb.tool DBNAME get KEY
python -m dbdb.tool DBNAME set KEY VALUE
python -m dbdb.tool DBNAME delete KEY

也可以在 Jupyter Notebook 中执行:!python tool.py arg1 arg2。如果你发现改了文件的某个地方结果没有变化,有时候需要 Restart 一下你的 Notebook,重新 import dbdb

下面的就很容易理解了:创建一个 db 实例 db = dbdb.connect(dbname),然后像字典一样 get set 和 delete。

接下来看一下 __init__.pytool.py 中 import 实际就是这个文件,这个文件的 connect 函数其实就是创建了一个 interface.py 里面的 DBDB 实例,它接受一个 open 的 file 作为参数。

然后我们就到了 interface.py 这个文件了,这里就实现了所有的功能(接口)。BinaryTreeStorage 是两个独立的实现,我们可以暂时不关注它们的细节(这次阅读不关注都可以),这里有几个新东西是我们之前没见过的:

  • 前面有一个下划线的是保护成员,只有类实例和子类实例能够访问
  • 前面有两个下划线的是私有成员,只有类对象自己能访问
  • getitem 等方法

需要注意的是,Python 中的私有或保护都是伪的,并不是真的不能访问到(实例+类名+方法可以访问)。我们了解下就好,感兴趣的可以在 Notebook 中自己尝试一下。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A:
def public(self):
return "public"

def _protected(self):
return "protected"

def __private(self):
return "private"

a = A()
print(a.public()) # => public
print(a._protected()) # => protected
print(a._A__private()) # => private
print(a.__private()) # => AttributeError: 'A' object has no attribute '__private'

关于 getitem, setitem, delitem, contains, len 几个方法,可以阅读 3. Data model — Python 3.7.5rc1 documentation 或者这个中文的:定制类和魔法方法 - Python 之旅 - 极客学院 Wiki,它们的主要功能是让我们像可以操作字典那样操作对象。这在有些时候很有用(比如我们现在的这种场景)。这里我们就不继续深入了,大家根据个人情况斟酌。

你可能会觉得怎么到处都是不懂的,有这样的想法很正常,刚开始都一样,下次再碰到这个不就懂了么,多看几个项目,一些项目中常用的东西就都知道了。坚持住,看的慢没关系,让自己时刻在进步就好了。

忽略掉不必要的细节,关注输入输出

如果刚入门,或者时间紧张,或者不想看了等等,到上一步停下也没关系。而且我们有时候在快速了解某个功能或者解决某个问题时,往往在上一步完成后直接跳到相应的代码。当然啦,我们这里还是要继续的。

整体来看,physical.py 中的 Storage 实现的是物理层面的存储,然后 binary_tree 中的 BinaryTree,它通过继承 logical.py 中的 LogicalBase(关于存储逻辑的),实现了数据基于 BinaryTree 的存储。回看下刚刚的 interface.py,几个重要的功能都与 BinaryTree 有关,我们就重点介绍下 set 这个接口,其他的大家用类似的方法就可以了。

首先是保证数据库没有被关闭,因为它本质是一个文件(还记得 __init__.py 中的 connect 函数吧),所以这里的判断也很简单,直接调用 Storageclosed 方法,该方法是个 property,这是将方法的返回值作为对象的一个属性,不需要加括号调用,方法名就是属性名。

然后就是 BinaryTree_set 方法,我们并没有在类下看到这个方法,所以其实是来自父类,也就是 LogicalBase 的方法。

  • 判断文件是否锁定,调用了 Storagelock 方法,因为这里 Storage 实例是作为一个参数传递给了 BinaryTree 类,这个方法很简单,使用了 portalocker · PyPI ,其实只通过代码我们也能看出来它们的功能,同时将 Storage 实例的 locked 属性设为 True
  • 如果 lock 方法返回 True,执行 _refresh_tree_ref 方法;lock 方法只有在文件没有锁定时会返回 True,如果文件已经锁定就返回 False,此时也意味着不需要执行 _refresh_tree_ref 方法。
  • _refresh_tree_ref 方法用来获取最新根结点的引用,因为是 locked 状态,所以其他进程可能已经做了一些更新;否则说明此时的结点本来就是最新的。我们需要注意的是,这个方法返回的其实是继承自 ValueRefBinaryNodeRef 实例。
  • 接下来调用 BinaryTree_insert 方法,传递了三个参数:第一个是调用了 BinaryNodeRef 继承自 ValueRefget 方法返回的 _referent,其实是一个 BinaryNode。有个地方需要特别注意,ValueRefBinaryNodeRef 都有一个 string_to_referent 方法,这里调用的是子类也就是后者的。第二个是 key,第三个是 ValueRef 实例。最终返回一个新的树(一个新的 BinaryNodeRef 实例),它共享了没有变化的子树,这时候数据并没有真正写入硬盘。

上面的 set 之后还需要执行 commit 命令才能将数据真正写入硬盘。我们已经明显忽略了非常多的细节,仅仅只是大致知道了这个接口内部是怎么运作的,距离完全理解还远远不够。当然,这个项目其实有点复杂的,我们可以逐步阅读学习。即便如此,我们现在已经可以在其上做一些简单的修改了,比如针对其接口做一些调整。

由于篇幅有限,无法对项目进一步详细介绍,就简单说下作者的设计理念和逻辑:

  • 借鉴了 Erlang 的数据不可变性,也就是说数据从开始到结束是不可更改的,不需要担心数据在执行过程中发生变化。在本项目中,整个 BinaryTree 的数据改变只有在 root 被替换时才可见,所以我们不用担心查找时数据会发生变化。之前在《基础知识》中提过的 Elixir 就是基于 Erlang 的编程语言。
  • 逻辑与存储分离,逻辑的树只有 referent 和 address,referent 通过 referent_to_stringstring_to_referent 方法存储和读取真正的 Node 结点。这样就不需要把整个树的所有结点读入内存,每次通过结点的 address 调用 get(调用了 string_to_referent) 或 store(调用了 referent_to_string) 方法就可以沿着树操作真正的结点。

记录总结

无论代码看了多少,只要有新的收获,这部分就必不可少。虽然我们迄今为止连最核心的逻辑(其实这是本项目的核心)都没看,但依然有不少可以记录下来的,比如:

  • 参数
  • __init__.py
  • 保护成员、私有成员
  • 类和实例
  • 魔法方法
  • property
  • 继承
  • 文件读写

每一个地方都可以至少写出一篇笔记来,如果日后遇到相关的部分,不仅可以阅读自己的笔记温习,还可以在此基础上进一步扩充完善。而且在具体应用(写代码)时,也肯定需要不时查阅笔记回顾相关知识点的。除此之外,还可以记录作者解决问题的思路、某个算法的执行过程等等,这些都是宝贵的经验。

至此,我们一个项目的阅读就这么简单地告一段落了,虽然没能一一展示,但基本方法就是这样,我们需要做的就是不断执行。

番外

由于这个例子正好是数据库相关的,所以这里给大家补充一点数据库的基础但非常重要的设计思想。可以跳过。

数据库在互联网领域的重要性往往不被人重视,但其实所有的电子交易都离不开数据库提供的高效和可靠的存储服务。想象一个转账的例子,你的账号划出去 100 块,中间突然断网了,那这个交易怎么办?

数据库有很多张表,每张表的每一行都是一个单个事件的不同类别的信息,每一列就是这些不同的类别,俗称字段。数据库中有个非常重要的概念叫 “一致性”,就是其中的信息不能自相矛盾。比如考虑这样一个表:

1
2
3
| name | age | married_with |
| John | 28 | Mary |
| Mary | 27 | John |

John 与 Mary 是一对夫妻,现在假设更改 John 这条记录的 married_withKate,这就是一个不一致的例子,我们必须添加一条规则:“如果 A 和 B 结婚,那么 B 必须也和 A 结婚”,如果违反规则的更改就会提示失败。看起来好像比较容易解决。那我们再考虑另外一个表:

1
2
3
4
| name | balance |
| John | 800 |
| Mary | 300 |
| Kate | 150 |

比如 John 给 Mary 转账 200,但中间突然由于某些原因意外崩溃了,John 的账户少了 200,但这 200 却没有能出现在 Mary 的账户上:数据库崩溃前后状态不一致,少了 200。这样的不一致没办法用结婚那样的规则去限制(余额必须是多少,余额之间的关系必须怎么样)。那怎么办呢?这时候我们就引出了可能是数据库最重要的一个概念:“事务”——如果要让数据库最后保持一致性,就必须在数据库上完成一系列更改,如果只执行了一部分更改,那所作的所有更改都会被撤消。

数据库的事务要使用一种特殊的待办事项列表(预写日志记录)来完成,它的基本思想是维护一个数据库计划采取的动作日志(被存储在硬盘或永久性存储介质中,保证信息免于崩溃),如果事务成功完成则删除日志中的待办列表,否则重新执行日志中的完整事务。日志中的每项活动都必须幂等(也就是无论执行多少次都一样)。

1
2
3
4
5
# 预写日志
1. 开始转账事务
2. 将 John 的余额从 800 变为 600 # 幂等的
3. 将 Mary 的余额从 300 变为 500 # 幂等的
4. 终止转账事务

如果变更完成了,日志项就被删除,这是正常情况;如果中间崩溃了,重启后就能发现上面的日志记录,再重新执行一次完整的日志活动即可。这个也叫事务的原子性:不能分成更小的操作,要么正笔事务成功地完成,要么数据处于原始状态,就像从未开始一般。这样我们就保证了这种情况的一致性。

事务可能由于一些原因不能完成,比如磁盘空间用完了,或者另一个更常见的原因:“锁定”。我们在上一部分自己思考时已经考虑过一点这个了:冻结要更改的项目。乍一看似乎还不错,但这实际上却可能导致 “死锁”:当两次事务同时尝试锁定同一行但方向相反时就会出现死锁。比如事务 A 和 B 分别表示 John 向 Marry 转账和 Marry 向 John 转账,事务 A 锁住了 John 行,B 锁住了 Marry 行。然后执行转账开始时,A 发现需要锁住 Marry 行,B 发现需要锁住 John 行。A 只能等 B 结束时才能锁定,B 只有在 A 结束时才能锁定,A 和 B 就互为死锁,两个事务谁也不能完成。当出现这种情况时,必须让死锁的其中一项事务取消,以便让另一项事务先执行。此时,就需要具备撤销或回滚的能力。总之,只要事务没能正常完成就需要回滚。回滚一项事务可以通过预写日志记录逆向操作来完成。

数据库复制(备份)是抵御数据丢失的最好方法,但也有危险:它引入了另一种可能的不一致性——复制品彼此数据不一致。这时候我们就不知道哪个才是正确的备份了,所以其中的一些复制品可能需要回滚。一般来说,其中一个复制品是协同事务的 “主管”,比如有 A B C 三个复制品,A 是 master。假设需要执行一项向表中插入一行新数据的事务。第一阶段,A 先锁定表,接着将新数据写入预写日志,同时 A 将新数据发给 B 和 C,B 和 C 也会锁定各自的表复制,并在日志中写入新数据。然后 B 和 C 向 A 返回报告它们是否成功地做了这一事务。第二阶段,如果 A B C 中任一项事务遇到了问题,A 就知道事务必须回滚,并通知所有复制品。如果所有复制品在第一阶段报告成功,A 就会向每份复制品发送消息确认事务,复制品接下来就会完成事务。这种办法叫 “两阶段提交协议”:第一阶段是 “预备” 阶段;第二阶段是 “提交” 阶段或 “撤销” 阶段,取决于最初的提议是否被所有人接受。

以上就是围绕数据库一致性的一些设计思想简介,虽然看起来有一点点复杂,但我们可以感觉到复杂的背后是因为业务本身的复杂,通过计算机科学家的设计反而让这种复杂看起来简单清晰了很多。

本节内容参考自《改变未来的九大算法》。

小结

本节主要介绍了如何阅读优秀的源代码,当有了一些系统知识、能做一些基本的操作时,就可以开始通过阅读优秀的代码来进行学习了,然后在写代码时运用已经学到的。这种成长速度远远超过其他方式,就我自己的经验来看,每阅读一份优秀的代码,都能明显的感觉到代码实力有所提升,可惜自己还是看的太少。阅读过一些优秀的代码后,我们再系统阅读一些书籍时就会非常有感觉了。

最后,在找案例的过程中还发现两个不太复杂、html 解析相关的例子,如下:

第一个刚出来时就看过了,非常值得一看。当然,还有很多其他的可选,看大家自己兴趣爱好和方向了。通过这节课的内容大家应该能感受到一点点计算机世界的魅力了吧?希望大家乐在其中,不断精进。