[feat] use git submodule to get undate from orgin repo.

This commit is contained in:
zhangzhi Peng 2022-03-17 08:45:46 +00:00
parent 8511e9ed7a
commit acf761c93d
21 changed files with 40 additions and 6485 deletions

201
LICENSE
View File

@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

299
README.md
View File

@ -1,299 +0,0 @@
《Python 工匠》图书首页上线啦!共有 3 章在线内容,欢迎阅读。
- [图书首页](https://www.zlovezl.cn/book/)
- [第 1 章 变量与注释](https://www.zlovezl.cn/book/ch01_variables.html)
- [第 6 章 循环与可迭代对象](https://www.zlovezl.cn/book/ch06_loop.html)
- [第 10 章 面向对象设计原则](https://www.zlovezl.cn/book/ch10_solid_p1.html)
包含全部内容的纸质书现已正式上市,欢迎购买。
[点击购买](https://item.jd.com/13068111.html) | [豆瓣书评](https://book.douban.com/subject/35723705/) | [图灵社区](https://www.ituring.com.cn/book/3007)
---
## 『Python 工匠』是什么?
我一直觉得编程某种意义上是一门『手艺』,因为优雅而高效的代码,就如同完美的手工艺品一样让人赏心悦目。
在雕琢代码的过程中有大工程比如应该用什么架构、哪种设计模式。也有更多的小细节比如何时使用异常Exceptions、或怎么给变量起名。那些真正优秀的代码正是由无数优秀的细节造就的。
『Python 工匠』这个系列文章,是我的一次小小尝试。它专注于分享 Python 编程中的一些偏 **『小』** 的东西。希望能够帮到每一位编程路上的匠人。
## 文章列表
- 1\. <del>善用变量改善代码质量</del> [[图书版:变量与注释](https://www.zlovezl.cn/book/ch01_variables.html)]
- [2. 编写条件分支代码的技巧](zh_CN/2-if-else-block-secrets.md)
- [3. 使用数字与字符串的技巧](zh_CN/3-tips-on-numbers-and-strings.md)
- [4. 容器的门道](zh_CN/4-mastering-container-types.md)
- [5. 让函数返回结果的技巧](zh_CN/5-function-returning-tips.md)
- [6. 异常处理的三个好习惯](zh_CN/6-three-rituals-of-exceptions-handling.md)
- 7\. <del>编写地道循环的两个建议</del> [[图书版:循环与可迭代对象](https://www.zlovezl.cn/book/ch06_loop.html)]
- [8. 使用装饰器的技巧](zh_CN/8-tips-on-decorators.md)
- [9. 一个关于模块的小故事](zh_CN/9-a-story-on-cyclic-imports.md)
- [10. 做一个精通规则的玩家](zh_CN/10-a-good-player-know-the-rules.md)
- [11. 高效操作文件的三个建议](zh_CN/11-three-tips-on-writing-file-related-codes.md)
- 12\. <del>写好面向对象代码的原则(上)</del> [[图书版:面向对象设计原则(上)](https://www.zlovezl.cn/book/ch10_solid_p1.html)]
- [13. 写好面向对象代码的原则(中)](zh_CN/13-write-solid-python-codes-part-2.md)
- [14. 写好面向对象代码的原则(下)](zh_CN/14-write-solid-python-codes-part-3.md)
- [15. 在边界处思考](zh_CN/15-thinking-in-edge-cases.md)
关注我的微信公众号,在第一时间阅读最新文章:
<img src="https://user-images.githubusercontent.com/731266/54093209-2edced80-43d0-11e9-8e69-764f5da8b275.png" />
所有文章禁止转载,如需转载请通过微信公众号联系我。
## 详细内容
### 1. [善用变量改善代码质量](zh_CN/1-using-variables-well.md)
* 如何为变量起名
* 1 - 变量名要有描述性,不能太宽泛
* 2 - 变量名最好让人能猜出类型
* 『什么样的名字会被当成 bool 类型?』
* 『什么样的名字会被当成 int/float 类型?』
* 其他类型
* 3 - 适当使用『匈牙利命名法』
* 4 - 变量名尽量短,但是绝对不要太短
* 使用短名字的例外情况
* 5 - 其他注意事项
* 更好的使用变量
* 1 - 保持一致性
* 2 - 尽量不要用 globals()/locals()
* 3 - 变量定义尽量靠近使用
* 4 - 合理使用 namedtuple/dict 来让函数返回多个值
* 5 - 控制单个函数内的变量数量
* 6 - 及时删掉那些没用的变量
* 7 - 能不定义变量就不定义
* 结语
### 2. [编写条件分支代码的技巧](zh_CN/2-if-else-block-secrets.md)
* 最佳实践
* 1 - 避免多层分支嵌套
* 2 - 封装那些过于复杂的逻辑判断
* 3 - 留意不同分支下的重复代码
* 4 - 谨慎使用三元表达式
* 常见技巧
* 1 - 使用“德摩根定律”
* 2 - 自定义对象的“布尔真假”
* 3 - 在条件判断中使用 all() / any()
* 4 - 使用 try/while/for 中 else 分支
* 常见陷阱
* 1 - 与 None 值的比较
* 2 - 留意 and 和 or 的运算优先级
* 结语
* 注解
### 3. [使用数字与字符串的技巧](zh_CN/3-tips-on-numbers-and-strings.md)
* 最佳实践
* 1 - 少写数字字面量
* 使用 enum 枚举类型改善代码
* 2 - 别在裸字符串处理上走太远
* 3 - 不必预计算字面量表达式
* 实用技巧
* 1 - 布尔值其实也是“数字”
* 2 - 改善超长字符串的可读性
* 当多级缩进里出现多行字符串时
* 3 - 别忘了那些 “r” 开头的内建字符串函数
* 4 - 使用“无穷大” float("inf")
* 常见误区
* 1 - “value += 1” 并非线程安全
* 2 - 字符串拼接并不慢
* 结语
### 4. [容器的门道](zh_CN/4-mastering-container-types.md)
* 底层看容器
* 写更快的代码
* 1 - 避免频繁扩充列表/创建新列表
* 2 - 在列表头部操作多的场景使用 deque 模块
* 3 - 使用集合/字典来判断成员是否存在
* 高层看容器
* 写扩展性更好的代码
* 面向容器接口编程
* 常用技巧
* 1 - 使用元组改善分支代码
* 2 - 在更多地方使用动态解包
* 3 - 使用 next() 函数
* 4 - 使用有序字典来去重
* 常见误区
* 1 - 当心那些已经枯竭的迭代器
* 2 - 别在循环体内修改被迭代对象
* 总结
* 系列其他文章
* 注解
### 5. [让函数返回结果的技巧](zh_CN/5-function-returning-tips.md)
* 编程建议
* 1 - 单个函数不要返回多种类型
* 2 - 使用 partial 构造新函数
* 3 - 抛出异常,而不是返回结果与错误
* 4 - 谨慎使用 None 返回值
* 1 - 作为操作类函数的默认返回值
* 2 - 作为某些“意料之中”的可能没有的值
* 3 - 作为调用失败时代表“错误结果”的值
* 5 - 合理使用“空对象模式”
* 6 - 使用生成器函数代替返回列表
* 7 - 限制递归的使用
* 总结
* 附录
### 6. [异常处理的三个好习惯](zh_CN/6-three-rituals-of-exceptions-handling.md)
* 前言
* 三个好习惯
* 1 - 只做最精确的异常捕获
* 2 - 别让异常破坏抽象一致性
* 3 - 异常处理不应该喧宾夺主
* 总结
* 附录
### 7. [编写地道循环的两个建议](zh_CN/7-two-tips-on-loop-writing.md)
* 前言
* 什么是“地道”的循环?
* enumerate() 所代表的编程思路
* 建议1使用函数修饰被迭代对象来优化循环
* 1 - 使用 product 扁平化多层嵌套循环
* 2 - 使用 islice 实现循环内隔行处理
* 3 - 使用 takewhile 替代 break 语句
* 4 - 使用生成器编写自己的修饰函数
* 建议2按职责拆解循环体内复杂代码块
* 复杂循环体如何应对新需求
* 使用生成器函数解耦循环体
* 总结
* 附录
### 8. [使用装饰器的技巧](zh_CN/8-tips-on-decorators.md)
* 前言
* 最佳实践
* 1 - 尝试用类来实现装饰器
* 2 - 使用 wrapt 模块编写更扁平的装饰器
* 常见错误
* 1 - “装饰器”并不是“装饰器模式”
* 2 - 记得用 functools.wraps() 装饰内层函数
* 3 - 修改外层变量时记得使用 nonlocal
* 总结
* 附录
### 9. [一个关于模块的小故事](zh_CN/9-a-story-on-cyclic-imports.md)
* 前言
* 一个关于模块的小故事
* 需求变更
* 解决环形依赖问题
* 小 C 的疑问
* 总结
* 附录
### 10. [做一个精通规则的玩家](zh_CN/10-a-good-player-know-the-rules.md)
* 前言
* Python 里的规则
* 案例:从两份旅游数据中获取人员名单
* 第一次蛮力尝试
* 尝试使用集合优化函数
* 对问题的重新思考
* 利用集合的游戏规则
* 使用 dataclass 简化代码
* 案例总结
* 其他规则如何影响我们
* 使用 `__format__` 做对象字符串格式化
* 使用 `__getitem__` 定义对象切片操作
* 总结
* 附录
### 11. [高效操作文件的三个建议](zh_CN/11-three-tips-on-writing-file-related-codes.md)
* 前言
* 建议一:使用 pathlib 模块
* 使用 pathlib 模块改写代码
* 其他用法
* 建议二:掌握如何流式读取大文件
* 标准做法的缺点
* 使用 read 方法分块读取
* 利用生成器解耦代码
* 建议三:设计接受文件对象的函数
* 如何编写兼容二者的函数
* 总结
* 附录
* 注解
### 12. [写好面向对象代码的原则(上)](zh_CN/12-write-solid-python-codes-part-1.md)
* 前言
* Python 对 OOP 的支持
* SOLID 设计原则
* SOLID 原则与 Python
* S单一职责原则
* 违反“单一职责原则”的坏处
* 拆分大类为多个小类
* 另一种方案:使用函数
* O开放-关闭原则
* 如何违反“开放-关闭原则”
* 使用类继承来改造代码
* 使用组合与依赖注入来改造代码
* 使用数据驱动思想来改造代码
* 总结
* 附录
### 13. [写好面向对象代码的原则(中)](zh_CN/13-write-solid-python-codes-part-2.md)
* 前言
* 里氏替换原则与继承
* L里氏替换原则
* 一个违反 L 原则的样例
* 不当继承关系如何违反 L 原则
* 一个简单但错误的解决办法
* 正确的修改办法
* 另一种违反方式:子类修改方法返回值法返回值)
* 分析类方法返回结果
* 如何修改代码
* 方法参数与 L 原则
* 总结
* 附录
### 14. [写好面向对象代码的原则(下)](zh_CN/14-write-solid-python-codes-part-3.md)
* 前言
* D依赖倒置原则
* 需求:按域名分组统计 HN 新闻数量
* 为 SiteSourceGrouper 编写单元测试
* 使用 mock 模块
* 实现依赖倒置原则
* 依赖倒置后的单元测试
* 问题:一定要使用抽象类 abc 吗?
* 问题:抽象一定是好东西吗?
* I接口隔离原则
* 例子:开发页面归档功能
* 问题:实体类不符合 HNWebPage 接口规范
* 成功违反 I 协议
* 如何分拆接口
* 一些不容易发现的违反情况
* 现实世界中的接口隔离
* 总结
* 附录
### 15. [在边界处思考](zh_CN/15-thinking-in-edge-cases.md)
* 前言
* 第一课:使用分支还是异常?
* 获取原谅比许可简单(EAFP)
* 当容器内容不存在时
* 使用 defaultdict 改写示例
* 使用 setdefault 取值并修改
* 使用 dict.pop 删除不存在的键
* 当列表切片越界时
* 好用又危险的 “or” 操作符
* 不要手动去做数据校验
* 不要忘记做数学计算
* 总结
* 附录

View File

@ -1 +0,0 @@
theme: jekyll-theme-cayman

1
one-python-craftsman Submodule

@ -0,0 +1 @@
Subproject commit 45a5b884f382aea6f3dffede0955e8dd21fb64c7

View File

@ -24,20 +24,20 @@
## 文章列表
- 1\. <del>善用变量改善代码质量</del> [[图书版:变量与注释](https://www.zlovezl.cn/book/ch01_variables.html)]
- [2. 编写条件分支代码的技巧](../../zh_CN/2-if-else-block-secrets.md)
- [3. 使用数字与字符串的技巧](../../zh_CN/3-tips-on-numbers-and-strings.md)
- [4. 容器的门道](../../zh_CN/4-mastering-container-types.md)
- [5. 让函数返回结果的技巧](../../zh_CN/5-function-returning-tips.md)
- [6. 异常处理的三个好习惯](../../zh_CN/6-three-rituals-of-exceptions-handling.md)
- [2. 编写条件分支代码的技巧](../../one-python-craftsman/zh_CN/2-if-else-block-secrets.md)
- [3. 使用数字与字符串的技巧](../../one-python-craftsman/zh_CN/3-tips-on-numbers-and-strings.md)
- [4. 容器的门道](../../one-python-craftsman/zh_CN/4-mastering-container-types.md)
- [5. 让函数返回结果的技巧](../../one-python-craftsman/zh_CN/5-function-returning-tips.md)
- [6. 异常处理的三个好习惯](../../one-python-craftsman/zh_CN/6-three-rituals-of-exceptions-handling.md)
- 7\. <del>编写地道循环的两个建议</del> [[图书版:循环与可迭代对象](https://www.zlovezl.cn/book/ch06_loop.html)]
- [8. 使用装饰器的技巧](../../zh_CN/8-tips-on-decorators.md)
- [9. 一个关于模块的小故事](../../zh_CN/9-a-story-on-cyclic-imports.md)
- [10. 做一个精通规则的玩家](../../zh_CN/10-a-good-player-know-the-rules.md)
- [11. 高效操作文件的三个建议](../../zh_CN/11-three-tips-on-writing-file-related-codes.md)
- [8. 使用装饰器的技巧](../../one-python-craftsman/zh_CN/8-tips-on-decorators.md)
- [9. 一个关于模块的小故事](../../one-python-craftsman/zh_CN/9-a-story-on-cyclic-imports.md)
- [10. 做一个精通规则的玩家](../../one-python-craftsman/zh_CN/10-a-good-player-know-the-rules.md)
- [11. 高效操作文件的三个建议](../../one-python-craftsman/zh_CN/11-three-tips-on-writing-file-related-codes.md)
- 12\. <del>写好面向对象代码的原则(上)</del> [[图书版:面向对象设计原则(上)](https://www.zlovezl.cn/book/ch10_solid_p1.html)]
- [13. 写好面向对象代码的原则(中)](../../zh_CN/13-write-solid-python-codes-part-2.md)
- [14. 写好面向对象代码的原则(下)](../../zh_CN/14-write-solid-python-codes-part-3.md)
- [15. 在边界处思考](../../zh_CN/15-thinking-in-edge-cases.md)
- [13. 写好面向对象代码的原则(中)](../../one-python-craftsman/zh_CN/13-write-solid-python-codes-part-2.md)
- [14. 写好面向对象代码的原则(下)](../../one-python-craftsman/zh_CN/14-write-solid-python-codes-part-3.md)
- [15. 在边界处思考](../../one-python-craftsman/zh_CN/15-thinking-in-edge-cases.md)
关注我的微信公众号,在第一时间阅读最新文章:
@ -47,7 +47,7 @@
## 详细内容
### 1. [善用变量改善代码质量](../../zh_CN/1-using-variables-well.md)
### 1. [善用变量改善代码质量](../../one-python-craftsman/zh_CN/1-using-variables-well.md)
* 如何为变量起名
* 1 - 变量名要有描述性,不能太宽泛
@ -69,7 +69,7 @@
* 7 - 能不定义变量就不定义
* 结语
### 2. [编写条件分支代码的技巧](../../zh_CN/2-if-else-block-secrets.md)
### 2. [编写条件分支代码的技巧](../../one-python-craftsman/zh_CN/2-if-else-block-secrets.md)
* 最佳实践
* 1 - 避免多层分支嵌套
@ -87,7 +87,7 @@
* 结语
* 注解
### 3. [使用数字与字符串的技巧](../../zh_CN/3-tips-on-numbers-and-strings.md)
### 3. [使用数字与字符串的技巧](../../one-python-craftsman/zh_CN/3-tips-on-numbers-and-strings.md)
* 最佳实践
* 1 - 少写数字字面量
@ -105,7 +105,7 @@
* 2 - 字符串拼接并不慢
* 结语
### 4. [容器的门道](../../zh_CN/4-mastering-container-types.md)
### 4. [容器的门道](../../one-python-craftsman/zh_CN/4-mastering-container-types.md)
* 底层看容器
* 写更快的代码
@ -127,7 +127,7 @@
* 系列其他文章
* 注解
### 5. [让函数返回结果的技巧](../../zh_CN/5-function-returning-tips.md)
### 5. [让函数返回结果的技巧](../../one-python-craftsman/zh_CN/5-function-returning-tips.md)
* 编程建议
* 1 - 单个函数不要返回多种类型
@ -143,7 +143,7 @@
* 总结
* 附录
### 6. [异常处理的三个好习惯](../../zh_CN/6-three-rituals-of-exceptions-handling.md)
### 6. [异常处理的三个好习惯](../../one-python-craftsman/zh_CN/6-three-rituals-of-exceptions-handling.md)
* 前言
* 三个好习惯
@ -153,7 +153,7 @@
* 总结
* 附录
### 7. [编写地道循环的两个建议](../../zh_CN/7-two-tips-on-loop-writing.md)
### 7. [编写地道循环的两个建议](../../one-python-craftsman/zh_CN/7-two-tips-on-loop-writing.md)
* 前言
* 什么是“地道”的循环?
@ -169,7 +169,7 @@
* 总结
* 附录
### 8. [使用装饰器的技巧](../../zh_CN/8-tips-on-decorators.md)
### 8. [使用装饰器的技巧](../../one-python-craftsman/zh_CN/8-tips-on-decorators.md)
* 前言
* 最佳实践
@ -182,7 +182,7 @@
* 总结
* 附录
### 9. [一个关于模块的小故事](../../zh_CN/9-a-story-on-cyclic-imports.md)
### 9. [一个关于模块的小故事](../../one-python-craftsman/zh_CN/9-a-story-on-cyclic-imports.md)
* 前言
* 一个关于模块的小故事
@ -192,7 +192,7 @@
* 总结
* 附录
### 10. [做一个精通规则的玩家](../../zh_CN/10-a-good-player-know-the-rules.md)
### 10. [做一个精通规则的玩家](../../one-python-craftsman/zh_CN/10-a-good-player-know-the-rules.md)
* 前言
* Python 里的规则
@ -209,7 +209,7 @@
* 总结
* 附录
### 11. [高效操作文件的三个建议](../../zh_CN/11-three-tips-on-writing-file-related-codes.md)
### 11. [高效操作文件的三个建议](../../one-python-craftsman/zh_CN/11-three-tips-on-writing-file-related-codes.md)
* 前言
* 建议一:使用 pathlib 模块
@ -225,7 +225,7 @@
* 附录
* 注解
### 12. [写好面向对象代码的原则(上)](../../zh_CN/12-write-solid-python-codes-part-1.md)
### 12. [写好面向对象代码的原则(上)](../../one-python-craftsman/zh_CN/12-write-solid-python-codes-part-1.md)
* 前言
* Python 对 OOP 的支持
@ -243,7 +243,7 @@
* 总结
* 附录
### 13. [写好面向对象代码的原则(中)](../../zh_CN/13-write-solid-python-codes-part-2.md)
### 13. [写好面向对象代码的原则(中)](../../one-python-craftsman/zh_CN/13-write-solid-python-codes-part-2.md)
* 前言
* 里氏替换原则与继承
@ -259,7 +259,7 @@
* 总结
* 附录
### 14. [写好面向对象代码的原则(下)](../../zh_CN/14-write-solid-python-codes-part-3.md)
### 14. [写好面向对象代码的原则(下)](../../one-python-craftsman/zh_CN/14-write-solid-python-codes-part-3.md)
* 前言
* D依赖倒置原则
@ -280,7 +280,7 @@
* 总结
* 附录
### 15. [在边界处思考](../../zh_CN/15-thinking-in-edge-cases.md)
### 15. [在边界处思考](../../one-python-craftsman/zh_CN/15-thinking-in-edge-cases.md)
* 前言
* 第一课:使用分支还是异常?

View File

@ -1,18 +1,18 @@
- [1. 图书版:变量与注释](https://www.zlovezl.cn/book/ch01_variables.html)
- [2. 编写条件分支代码的技巧](../../zh_CN/2-if-else-block-secrets.md)
- [3. 使用数字与字符串的技巧](../../zh_CN/3-tips-on-numbers-and-strings.md)
- [4. 容器的门道](../../zh_CN/4-mastering-container-types.md)
- [5. 让函数返回结果的技巧](../../zh_CN/5-function-returning-tips.md)
- [6. 异常处理的三个好习惯](../../zh_CN/6-three-rituals-of-exceptions-handling.md)
- [2. 编写条件分支代码的技巧](../../one-python-craftsman/zh_CN/2-if-else-block-secrets.md)
- [3. 使用数字与字符串的技巧](../../one-python-craftsman/zh_CN/3-tips-on-numbers-and-strings.md)
- [4. 容器的门道](../../one-python-craftsman/zh_CN/4-mastering-container-types.md)
- [5. 让函数返回结果的技巧](../../one-python-craftsman/zh_CN/5-function-returning-tips.md)
- [6. 异常处理的三个好习惯](../../one-python-craftsman/zh_CN/6-three-rituals-of-exceptions-handling.md)
- [7. 图书版:循环与可迭代对象](https://www.zlovezl.cn/book/ch06_loop.html)
- [8. 使用装饰器的技巧](../../zh_CN/8-tips-on-decorators.md)
- [9. 一个关于模块的小故事](../../zh_CN/9-a-story-on-cyclic-imports.md)
- [10. 做一个精通规则的玩家](../../zh_CN/10-a-good-player-know-the-rules.md)
- [11. 高效操作文件的三个建议](../../zh_CN/11-three-tips-on-writing-file-related-codes.md)
- [8. 使用装饰器的技巧](../../one-python-craftsman/zh_CN/8-tips-on-decorators.md)
- [9. 一个关于模块的小故事](../../one-python-craftsman/zh_CN/9-a-story-on-cyclic-imports.md)
- [10. 做一个精通规则的玩家](../../one-python-craftsman/zh_CN/10-a-good-player-know-the-rules.md)
- [11. 高效操作文件的三个建议](../../one-python-craftsman/zh_CN/11-three-tips-on-writing-file-related-codes.md)
- [12. 图书版:面向对象设计原则(上)](https://www.zlovezl.cn/book/ch10_solid_p1.html)
- [13. 写好面向对象代码的原则(中)](../../zh_CN/13-write-solid-python-codes-part-2.md)
- [14. 写好面向对象代码的原则(下)](../../zh_CN/14-write-solid-python-codes-part-3.md)
- [15. 在边界处思考](../../zh_CN/15-thinking-in-edge-cases.md)
- [13. 写好面向对象代码的原则(中)](../../one-python-craftsman/zh_CN/13-write-solid-python-codes-part-2.md)
- [14. 写好面向对象代码的原则(下)](../../one-python-craftsman/zh_CN/14-write-solid-python-codes-part-3.md)
- [15. 在边界处思考](../../one-python-craftsman/zh_CN/15-thinking-in-edge-cases.md)

View File

@ -1,324 +0,0 @@
# Python 工匠:善用变量来改善代码质量
## 『Python 工匠』是什么?
我一直觉得编程某种意义上是一门『手艺』,因为优雅而高效的代码,就如同完美的手工艺品一样让人赏心悦目。
在雕琢代码的过程中有大工程比如应该用什么架构、哪种设计模式。也有更多的小细节比如何时使用异常Exceptions、或怎么给变量起名。那些真正优秀的代码正是由无数优秀的细节造就的。
『Python 工匠』这个系列文章,是我的一次小小尝试。它专注于分享 Python 编程中的一些偏 **『小』** 的东西。希望能够帮到每一位编程路上的匠人。
> 这是 “Python 工匠”系列的第 1 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
## 变量和代码质量
作为『Python 工匠』系列文章的第一篇,我想先谈谈 『变量Variables』。因为如何定义和使用变量一直都是学习任何一门编程语言最先要掌握的技能之一。
变量用的好或不好,和代码质量有着非常重要的联系。在关于变量的诸多问题中,为变量起一个好名字尤其重要。
### 内容目录
- [Python 工匠:善用变量来改善代码质量](#python-工匠善用变量来改善代码质量)
- [『Python 工匠』是什么?](#python-工匠是什么)
- [变量和代码质量](#变量和代码质量)
- [内容目录](#内容目录)
- [如何为变量起名](#如何为变量起名)
- [1. 变量名要有描述性,不能太宽泛](#1-变量名要有描述性不能太宽泛)
- [2. 变量名最好让人能猜出类型](#2-变量名最好让人能猜出类型)
- [『什么样的名字会被当成 bool 类型?』](#什么样的名字会被当成-bool-类型)
- [『什么样的名字会被当成 int/float 类型?』](#什么样的名字会被当成-intfloat-类型)
- [其他类型](#其他类型)
- [3. 适当使用『匈牙利命名法』](#3-适当使用匈牙利命名法)
- [4. 变量名尽量短,但是绝对不要太短](#4-变量名尽量短但是绝对不要太短)
- [使用短名字的例外情况](#使用短名字的例外情况)
- [5. 其他注意事项](#5-其他注意事项)
- [更好的使用变量](#更好的使用变量)
- [1. 保持一致性](#1-保持一致性)
- [2. 尽量不要用 globals()/locals()](#2-尽量不要用-globalslocals)
- [3. 变量定义尽量靠近使用](#3-变量定义尽量靠近使用)
- [4. 合理使用 namedtuple/dict 来让函数返回多个值](#4-合理使用-namedtupledict-来让函数返回多个值)
- [5. 控制单个函数内的变量数量](#5-控制单个函数内的变量数量)
- [6. 及时删掉那些没用的变量](#6-及时删掉那些没用的变量)
- [7. 定义临时变量提升可读性](#7-定义临时变量提升可读性)
- [结语](#结语)
## 如何为变量起名
在计算机科学领域,有一句著名的格言(俏皮话):
> There are only two hard things in Computer Science: cache invalidation and naming things.
> 在计算机科学领域只有两件难事:缓存失效 和 给东西起名字
>
> -- Phil Karlton
第一个『缓存过期问题』的难度不用多说,任何用过缓存的人都会懂。至于第二个『给东西起名字』这事的难度,我也是深有体会。在我的职业生涯里,度过的最为黑暗的下午之一,就是坐在显示器前抓耳挠腮为一个新项目起一个合适的名字。
编程时起的最多的名字,还数各种变量。给变量起一个好名字很重要,**因为好的变量命名可以极大提高代码的整体可读性。**
下面几点,是我总结的为变量起名时,最好遵守的基本原则。
### 1. 变量名要有描述性,不能太宽泛
在**可接受的长度范围内**,变量名能把它所指向的内容描述的越精确越好。所以,尽量不要用那些过于宽泛的词来作为你的变量名:
- **BAD**: `day`, `host`, `cards`, `temp`
- **GOOD**: `day_of_week`, `hosts_to_reboot`, `expired_cards`
### 2. 变量名最好让人能猜出类型
所有学习 Python 的人都知道Python 是一门动态类型语言,它(至少在 [PEP 484](https://www.python.org/dev/peps/pep-0484/) 出现前)没有变量类型声明。所以当你看到一个变量时,除了通过上下文猜测,没法轻易知道它是什么类型。
不过,人们对于变量名和变量类型的关系,通常会有一些直觉上的约定,我把它们总结在了下面。
#### 『什么样的名字会被当成 bool 类型?』
布尔类型变量的最大特点是:它只存在两个可能的值 **『是』** 或 **『不是』**。所以,用 `is`、`has` 等非黑即白的词修饰的变量名,会是个不错的选择。原则就是:**让读到变量名的人觉得这个变量只会有『是』或『不是』两种值**。
下面是几个不错的示例:
- `is_superuser`:『是否超级用户』,只会有两种值:是/不是
- `has_error`:『有没有错误』,只会有两种值:有/没有
- `allow_vip`:『是否允许 VIP』只会有两种值允许/不允许
- `use_msgpack`:『是否使用 msgpack』只会有两种值使用/不使用
- `debug`:『是否开启调试模式』,被当做 bool 主要是因为约定俗成
#### 『什么样的名字会被当成 int/float 类型?』
人们看到和数字相关的名字,都会默认他们是 int/float 类型,下面这些是比较常见的:
- 释义为数字的所有单词,比如:`port端口号`、`age年龄`、`radius半径` 等等
- 使用 _id 结尾的单词,比如:`user_id`、`host_id`
- 使用 length/count 开头或者结尾的单词,比如: `length_of_username`、`max_length`、`users_count`
**注意:** 不要使用普通的复数来表示一个 int 类型变量,比如 `apples`、`trips`,最好用 `number_of_apples`、`trips_count` 来替代。
#### 其他类型
对于 str、list、tuple、dict 这些复杂类型,很难有一个统一的规则让我们可以通过名字去猜测变量类型。比如 `headers`,既可能是一个头信息列表,也可能是包含头信息的 dict。
对于这些类型的变量名,最推荐的方式,就是编写规范的文档,在函数和方法的 document string 中,使用 sphinx 格式([Python 官方文档使用的文档工具](http://www.sphinx-doc.org/en/stable/))来标注所有变量的类型。
### 3. 适当使用『匈牙利命名法』
第一次知道『[匈牙利命名法](https://en.wikipedia.org/wiki/Hungarian_notation)』,是在 [Joel on Software 的一篇博文](http://www.joelonsoftware.com/articles/Wrong.html)中。简而言之,匈牙利命名法就是把变量的『类型』缩写,放到变量名的最前面。
关键在于,这里说的变量『类型』,并非指传统意义上的 int/str/list 这种类型,而是指那些和你的代码业务逻辑相关的类型。
比如,在你的代码中有两个变量:`students` 和 `teachers`,他们指向的内容都是一个包含 Person 对象的 list 。使用『匈牙利命名法』后,可以把这两个名字改写成这样:
students -> `pl_students`
teachers -> `pl_teachers`
其中 pl 是 **person list** 的首字母缩写。当变量名被加上前缀后,如果你看到以 `pl_` 打头的变量,就能知道它所指向的值类型了。
很多情况下,使用『匈牙利命名法』是个不错的主意,因为它可以改善你的代码可读性,尤其在那些变量众多、同一类型多次出现时。注意不要滥用就好。
### 4. 变量名尽量短,但是绝对不要太短
在前面,我们提到要让变量名有描述性。如果不给这条原则加上任何限制,那么你很有可能写出这种描述性极强的变量名:`how_much_points_need_for_level2`。如果代码中充斥着这种过长的变量名,对于代码可读性来说是个灾难。
一个好的变量名,长度应该控制在 **两到三个单词左右**。比如上面的名字,可以缩写为 `points_level2`
**绝大多数情况下,都应该避免使用那些只有一两个字母的短名字**,比如数组索引三剑客 `i`、`j`、`k`,用有明确含义的名字,比如 person_index 来代替它们总是会更好一些。
#### 使用短名字的例外情况
有时,上面的原则也存在一些例外。当一些意义明确但是较长的变量名重复出现时,为了让代码更简洁,使用短名字缩写是完全可以的。但是为了降低理解成本,同一段代码内最好不要使用太多这种短名字。
比如在 Python 中导入模块时,就会经常用到短名字作为别名,像 Django i18n 翻译时常用的 `gettext` 方法通常会被缩写成 `_` 来使用*from django.utils.translation import ugettext as _*
### 5. 其他注意事项
其他一些给变量命名的注意事项:
- 同一段代码内不要使用过于相似的变量名,比如同时出现 `users`、`users1`、 `user3` 这种序列
- 不要使用带否定含义的变量名,用 `is_special` 代替 `is_not_normal`
## 更好的使用变量
前面讲了如何为变量取一个好名字,下面我们谈谈在日常使用变量时,应该注意的一些小细节。
### 1. 保持一致性
如果你在一个方法内里面把图片变量叫做 `photo`,在其他的地方就不要把它改成 `image`,这样只会让代码的阅读者困惑:『`image` 和 `photo` 到底是不是同一个东西?』
另外,虽然 Python 是动态类型语言,但那也不意味着你可以用同一个变量名一会表示 str 类型,过会又换成 list。**同一个变量名指代的变量类型,也需要保持一致性。**
### 2. 尽量不要用 globals()/locals()
也许你第一次发现 globals()/locals() 这对内建函数时很兴奋,迫不及待的写下下面这种极端『简洁』的代码:
```python
def render_trip_page(request, user_id, trip_id):
user = User.objects.get(id=user_id)
trip = get_object_or_404(Trip, pk=trip_id)
is_suggested = is_suggested(user, trip)
# 利用 locals() 节约了三行代码,我是个天才!
return render(request, 'trip.html', locals())
```
千万不要这么做,这样只会让读到这段代码的人(包括三个月后的你自己)痛恨你,因为他需要记住这个函数内定义的所有变量(想想这个函数增长到两百行会怎么样?),更别提 locals() 还会把一些不必要的变量传递出去。
更何况, [The Zen of PythonPython 之禅)](https://www.python.org/dev/peps/pep-0020/) 说的清清楚楚:**Explicit is better than implicit.(显式优于隐式)**。所以,还是老老实实把代码写成这样吧:
```python
return render(request, 'trip.html', {
'user': user,
'trip': trip,
'is_suggested': is_suggested
})
```
### 3. 变量定义尽量靠近使用
这个原则属于老生常谈了。很多人(包括我)在刚开始学习编程时,会有一个习惯。就是把所有的变量定义写在一起,放在函数或方法的最前面。
```python
def generate_trip_png(trip):
path = []
markers = []
photo_markers = []
text_markers = []
marker_count = 0
point_count = 0
... ...
```
这样做只会让你的代码『看上去很整洁』,但是对提高代码可读性没有任何帮助。
更好的做法是,**让变量定义尽量靠近使用**。那样当你阅读代码时,可以更好的理解代码的逻辑,而不是费劲的去想这个变量到底是什么、哪里定义的?
### 4. 合理使用 namedtuple/dict 来让函数返回多个值
Python 的函数可以返回多个值:
```python
def latlon_to_address(lat, lon):
return country, province, city
# 利用多返回值一次解包定义多个变量
country, province, city = latlon_to_address(lat, lon)
```
但是,这样的用法会产生一个小问题:如果某一天, `latlon_to_address` 函数需要返回『城区District』时怎么办
如果是上面这种写法,你需要找到所有调用 `latlon_to_address` 的地方,补上多出来的这个变量,否则 *ValueError: too many values to unpack* 就会找上你:
```python
country, province, city, district = latlon_to_address(lat, lon)
# 或者使用 _ 忽略多出来的返回值
country, province, city, _ = latlon_to_address(lat, lon)
```
对于这种可能变动的多返回值函数,使用 namedtuple/dict 会更方便一些。当你新增返回值时,不会对之前的函数调用产生任何破坏性的影响:
```python
# 1. 使用 dict
def latlon_to_address(lat, lon):
return {
'country': country,
'province': province,
'city': city
}
addr_dict = latlon_to_address(lat, lon)
# 2. 使用 namedtuple
from collections import namedtuple
Address = namedtuple("Address", ['country', 'province', 'city'])
def latlon_to_address(lat, lon):
return Address(
country=country,
province=province,
city=city
)
addr = latlon_to_address(lat, lon)
```
不过这样做也有坏处,因为代码对变更的兼容性虽然变好了,但是你不能继续用之前 `x, y = f()` 的方式一次解包定义多个变量了。取舍在于你自己。
### 5. 控制单个函数内的变量数量
人脑的能力是有限的,研究表明,人类的短期记忆只能同时记住不超过十个名字。所以,当你的某个函数过长(一般来说,超过一屏的的函数就会被认为有点过长了),包含了太多变量时。请及时把它拆分为多个小函数吧。
### 6. 及时删掉那些没用的变量
这条原则非常简单,也很容易做到。但是如果没有遵守,那它对你的代码质量的打击是毁灭级的。会让阅读你代码的人有一种被愚弄的感觉。
```python
def fancy_func():
# 读者心理:嗯,这里定义了一个 fancy_vars
fancy_vars = get_fancy()
... ...(一大堆代码过后)
# 读者心理:这里就结束了?之前的 fancy_vars 去哪了?被猫吃了吗?
return result
```
所以,请打开 IDE 的智能提示,及时清理掉那些定义了但是没有使用的变量吧。
### 7. 定义临时变量提升可读性
有时,我们的代码里会出现一些复杂的表达式,像下面这样:
```python
# 为所有性别为女性,或者级别大于 3 的活跃用户发放 10000 个金币
if user.is_active and (user.sex == 'female' or user.level > 3):
user.add_coins(10000)
return
```
看见 `if` 后面那一长串了吗?有点难读对不对?但是如果我们把它赋值成一个临时变量,
就能给读者一个心理缓冲,提高可读性:
```
# 为所有性别为女性,或者级别大于 3 的活跃用户发放 10000 个金币
user_is_eligible = user.is_active and (user.sex == 'female' or user.level > 3):
if user_is_eligible:
user.add_coins(10000)
return
```
定义临时变量可以提高可读性。但有时,把不必要的东西赋值成临时变量反而会让代码显得啰嗦:
```python
def get_best_trip_by_user_id(user_id):
# 心理活动:『嗯,这个值未来说不定会修改/二次使用』,让我们先把它定义成变量吧!
user = get_user(user_id)
trip = get_best_trip(user_id)
result = {
'user': user,
'trip': trip
}
return result
```
其实,你所想的『未来』永远不会来,这段代码里的三个临时变量完全可以去掉,变成这样:
```python
def get_best_trip_by_user_id(user_id):
return {
'user': get_user(user_id),
'trip': get_best_trip(user_id)
}
```
没必要为了那些可能出现的变动,牺牲代码当前的可读性。如果以后有定义变量的需求,那就以后再加吧。
## 结语
碎碎念了一大堆,不知道有多少人能够坚持到最后。变量作为程序语言的重要组成部分,值得我们在定义和使用它时,多花一丁点时间思考一下,那样会让你的代码变得更优秀。
这是『Python 工匠』系列文章的第一篇,不知道看完文章的你,有没有什么想吐槽的?请留言告诉我吧。
[>>>下一篇【2.编写条件分支代码的技巧】](2-if-else-block-secrets.md)
> 文章更新记录:
>
> - 2018.04.09:根据 @onlyice 的建议,添加了 namedtuple 部分

View File

@ -1,369 +0,0 @@
# Python 工匠:做一个精通规则的玩家
## 前言
> 这是 “Python 工匠”系列的第 10 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/05/jeshoots-com-632498-unsplash_w1280.jpg" width="100%" />
</div>
编程,其实和玩电子游戏有一些相似之处。你在玩不同游戏前,需要先学习每个游戏的不同规则,只有熟悉和灵活运用游戏规则,才更有可能在游戏中获胜。
而编程也是一样,不同编程语言同样有着不一样的“规则”。大到是否支持面向对象,小到是否可以定义常量,编程语言的规则比绝大多数电子游戏要复杂的多。
当我们编程时,如果直接拿一种语言的经验套用到另外一种语言上,很多时候并不能取得最佳结果。这就好像一个 CS反恐精英 高手在不了解规则的情况下去玩 PUBG绝地求生虽然他的枪法可能万中无一但是极有可能在发现第一个敌人前他就会倒在某个窝在草丛里的敌人的伏击下。
### Python 里的规则
Python 是一门初见简单、深入后愈觉复杂的语言。拿 Python 里最重要的“对象”概念来说Python 为其定义了多到让你记不全的规则,比如:
- 定义了 `__str__` 方法的对象,就可以使用 `str()` 函数来返回可读名称
- 定义了 `__next__``__iter__` 方法的对象,就可以被循环迭代
- 定义了 `__bool__` 方法的对象,在进行布尔判断时就会使用自定义的逻辑
- ... ...
**熟悉规则,并让自己的代码适应这些规则,可以帮助我们写出更地道的代码,事半功倍的完成工作。**下面,让我们来看一个有关适应规则的故事。
## 案例:从两份旅游数据中获取人员名单
某日,在一个主打新西兰出境游的旅游公司里,商务同事突然兴冲冲的跑过来找到我,说他从某合作伙伴那里,要到了两份重要的数据:
1. 所有去过“泰国普吉岛”的人员及联系方式
2. 所有去过“新西兰”的人员及联系方式
数据采用了 JSON 格式,如下所示:
```python
# 去过普吉岛的人员数据
users_visited_phuket = [
{"first_name": "Sirena", "last_name": "Gross", "phone_number": "650-568-0388", "date_visited": "2018-03-14"},
{"first_name": "James", "last_name": "Ashcraft", "phone_number": "412-334-4380", "date_visited": "2014-09-16"},
... ...
]
# 去过新西兰的人员数据
users_visited_nz = [
{"first_name": "Justin", "last_name": "Malcom", "phone_number": "267-282-1964", "date_visited": "2011-03-13"},
{"first_name": "Albert", "last_name": "Potter", "phone_number": "702-249-3714", "date_visited": "2013-09-11"},
... ...
]
```
每份数据里面都有着`姓`、`名`、`手机号码`、`旅游时间` 四个字段。基于这份数据,商务同学提出了一个 *(听上去毫无道理)* 的假设:“去过普吉岛的人,应该对去新西兰旅游也很有兴趣。我们需要从这份数据里,找出那些**去过普吉岛但没有去过新西兰的人**,针对性的卖产品给他们。
### 第一次蛮力尝试
有了原始数据和明确的需求,接下来的问题就是如何写代码了。依靠蛮力,我很快就写出了第一个方案:
```python
def find_potential_customers_v1():
"""找到去过普吉岛但是没去过新西兰的人
"""
for phuket_record in users_visited_phuket:
is_potential = True
for nz_record in users_visited_nz:
if phuket_record['first_name'] == nz_record['first_name'] and \
phuket_record['last_name'] == nz_record['last_name'] and \
phuket_record['phone_number'] == nz_record['phone_number']:
is_potential = False
break
if is_potential:
yield phuket_record
```
因为原始数据里没有 *“用户 ID”* 之类的唯一标示,所以我们只能把“姓名和电话号码完全相同”作为判断是不是同一个人的标准。
`find_potential_customers_v1` 函数通过循环的方式,先遍历所有去过普吉岛的人,然后再遍历新西兰的人,如果在新西兰的记录中找不到完全匹配的记录,就把它当做“潜在客户”返回。
这个函数虽然可以完成任务,但是相信不用我说你也能发现。**它有着非常严重的性能问题**。对于每一条去过普吉岛的记录,我们都需要遍历所有新西兰访问记录,尝试找到匹配。整个算法的时间复杂度是可怕的 `O(n*m)`,如果新西兰的访问条目数很多的话,那么执行它将耗费非常长的时间。
为了优化内层循环性能,我们需要减少线性查找匹配部分的开销。
### 尝试使用集合优化函数
如果你对 Python 有所了解的话那么你肯定知道Python 里的字典和集合对象都是基于 [哈希表Hash Table](https://en.wikipedia.org/wiki/Hash_table) 实现的。判断一个东西是不是在集合里的平均时间复杂度是 `O(1)`,非常快。
所以,对于上面的函数,我们可以先尝试针对新西兰访问记录初始化一个集合,之后的查找匹配部分就可以变得很快,函数整体时间复杂度就能变为 `O(n+m)`
让我们看看新的函数:
```python
def find_potential_customers_v2():
"""找到去过普吉岛但是没去过新西兰的人,性能改进版
"""
# 首先,遍历所有新西兰访问记录,创建查找索引
nz_records_idx = {
(rec['first_name'], rec['last_name'], rec['phone_number'])
for rec in users_visited_nz
}
for rec in users_visited_phuket:
key = (rec['first_name'], rec['last_name'], rec['phone_number'])
if key not in nz_records_idx:
yield rec
```
使用了集合对象后,新函数在速度上相比旧版本有了飞跃性的突破。但是,对这个问题的优化并不是到此为止,不然文章标题就应该改成:“如何使用集合提高程序性能” 了。
### 对问题的重新思考
让我们来尝试重新抽象思考一下问题的本质。首先,我们有一份装了很多东西的容器 A*(普吉岛访问记录)*,然后给我们另一个装了很多东西的容器 B*(新西兰访问记录)*,之后定义相等规则:“姓名与电话一致”。最后基于这个相等规则,求 A 和 B 之间的 **“差集”**。
如果你对 Python 里的集合不是特别熟悉,我就稍微多介绍一点。假如我们拥有两个集合 A 和 B那么我们可以直接使用 `A - B` 这样的数学运算表达式来计算二者之间的 **差集**
```python
>>> a = {1, 3, 5, 7}
>>> b = {3, 5, 8}
# 产生新集合:所有在 a 但是不在 b 里的元素
>>> a - b
{1, 7}
```
所以,计算“所有去过普吉岛但没去过新西兰的人”,其实就是一次集合的求差值操作。那么要怎么做,才能把我们的问题套入到集合的游戏规则里去呢?
### 利用集合的游戏规则
在 Python 中,如果要把某个东西装到集合或字典里,一定要满足一个基本条件:**“这个东西必须是可以被哈希Hashable的”** 。什么是 “Hashable”
举个例子Python 里面的所有可变对象,比如字典,就 **不是** Hashable 的。当你尝试把字典放入集合中时,会发生这样的错误:
```python
>>> s = set()
>>> s.add({'foo': 'bar'})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'dict'
```
所以,如果要利用集合解决我们的问题,就首先得定义我们自己的 “Hashable” 对象:`VisitRecord`。而要让一个自定义对象变得 Hashable唯一要做的事情就是定义对象的 `__hash__` 方法。
```python
class VisitRecord:
"""旅游记录
"""
def __init__(self, first_name, last_name, phone_number, date_visited):
self.first_name = first_name
self.last_name = last_name
self.phone_number = phone_number
self.date_visited = date_visited
```
一个好的哈希算法,应该让不同对象之间的值尽可能的唯一,这样可以最大程度减少[“哈希碰撞”](https://en.wikipedia.org/wiki/Collision_(computer_science))发生的概率,默认情况下,所有 Python 对象的哈希值来自它的内存地址。
在这个问题里,我们需要自定义对象的 `__hash__` 方法,让它利用 `(姓,名,电话)`元组作为 `VisitRecord` 类的哈希值来源。
```python
def __hash__(self):
return hash(
(self.first_name, self.last_name, self.phone_number)
)
```
自定义完 `__hash__` 方法后,`VisitRecord` 实例就可以正常的被放入集合中了。但这还不够,为了让前面提到的求差值算法正常工作,我们还需要实现 `__eq__` 特殊方法。
`__eq__` 是 Python 在判断两个对象是否相等时调用的特殊方法。默认情况下,它只有在自己和另一个对象的内存地址完全一致时,才会返回 `True`。但是在这里,我们复用了 `VisitRecord` 对象的哈希值,当二者相等时,就认为它们一样。
```python
def __eq__(self, other):
# 当两条访问记录的名字与电话号相等时,判定二者相等。
if isinstance(other, VisitRecord) and hash(other) == hash(self):
return True
return False
```
完成了恰当的数据建模后,之后的求差值运算便算是水到渠成了。新版本的函数只需要一行代码就能完成操作:
```python
def find_potential_customers_v3():
return set(VisitRecord(**r) for r in users_visited_phuket) - \
set(VisitRecord(**r) for r in users_visited_nz)
```
> Hint如果你使用的是 Python 2那么除了 `__eq__` 方法外,你还需要自定义类的 `__ne__`(判断不相等时使用) 方法。
### 使用 dataclass 简化代码
故事到这里并没有结束。在上面的代码里,我们手动定义了自己的 **数据类** `VisitRecord`,实现了 `__init__`、`__eq__` 等初始化方法。但其实还有更简单的做法。
因为定义数据类这种需求在 Python 中实在太常见了,所以在 3.7 版本中,标准库中新增了 [dataclasses](https://docs.python.org/3/library/dataclasses.html) 模块,专门帮你简化这类工作。
如果使用 dataclasses 提供的特性,我们的代码可以最终简化成下面这样:
```python
@dataclass(unsafe_hash=True)
class VisitRecordDC:
first_name: str
last_name: str
phone_number: str
# 跳过“访问时间”字段,不作为任何对比条件
date_visited: str = field(hash=False, compare=False)
def find_potential_customers_v4():
return set(VisitRecordDC(**r) for r in users_visited_phuket) - \
set(VisitRecordDC(**r) for r in users_visited_nz)
```
不用干任何脏活累活,只要不到十行代码就完成了工作。
### 案例总结
问题解决以后,让我们再做一点小小的总结。在处理这个问题时,我们一共使用了三种方案:
1. 使用普通的两层循环筛选符合规则的结果集
2. 利用哈希表结构set 对象)创建索引,提升处理效率
3. 将数据转换为自定义对象,利用规则,直接使用集合运算
为什么第三种方式会比前面两种好呢?
首先,第一个方案的性能问题过于明显,所以很快就会被放弃。那么第二个方案呢?仔细想想看,方案二其实并没有什么明显的缺点。甚至和第三个方案相比,因为少了自定义对象的过程,它在性能与内存占用上,甚至有可能会微微强于后者。
但请再思考一下,如果你把方案二的代码换成另外一种语言,比如 Java它是不是基本可以做到 1:1 的完全翻译?换句话说,**它虽然效率高、代码直接,但是它没有完全利用好 Python 世界提供的规则,最大化的从中受益。**
如果要具体化这个问题里的“规则”,那就是 **“Python 拥有内置结构集合,集合之间可以进行差值等四则运算”** 这个事实本身。匹配规则后编写的方案三代码拥有下面这些优势:
- 为数据建模后,可以更方便的定义其他方法
- 如果需求变更,做反向差值运算、求交集运算都很简单
- 理解集合与 dataclasses 逻辑后,代码远比其他版本更简洁清晰
- 如果要修改相等规则,比如“只拥有相同姓的记录就算作一样”,只需要继承`VisitRecord` 覆盖 `__eq__` 方法即可
## 其他规则如何影响我们
在前面我们花了很大的篇幅讲了如何利用“集合的规则”来编写事半功倍的代码。除此之外Python 世界中还有着很多其他规则。如果能熟练掌握这些规则,就可以设计出符合 Python 惯例的 API让代码更简洁精炼。
下面是两个具体的例子。
### 使用 `__format__` 做对象字符串格式化
如果你的自定义对象需要定义多种字符串表示方式,就像下面这样:
```python
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
def get_simple_display(self):
return f'{self.name}({self.age})'
def get_long_display(self):
return f'{self.name} is {self.age} years old.'
piglei = Student('piglei', '18')
# OUTPUT: piglei(18)
print(piglei.get_simple_display())
# OUTPUT: piglei is 18 years old.
print(piglei.get_long_display())
```
那么除了增加这种 `get_xxx_display()` 额外方法外,你还可以尝试自定义 `Student` 类的 `__format__` 方法,因为那才是将对象变为字符串的标准规则。
```python
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
def __format__(self, format_spec):
if format_spec == 'long':
return f'{self.name} is {self.age} years old.'
elif format_spec == 'simple':
return f'{self.name}({self.age})'
raise ValueError('invalid format spec')
piglei = Student('piglei', '18')
print('{0:simple}'.format(piglei))
print('{0:long}'.format(piglei))
```
### 使用 `__getitem__` 定义对象切片操作
如果你要设计某个可以装东西的容器类型,那么你很可能会为它定义“是否为空”、“获取第 N 个对象”等方法:
```python
class Events:
def __init__(self, events):
self.events = events
def is_empty(self):
return not bool(self.events)
def list_events_by_range(self, start, end):
return self.events[start:end]
events = Events([
'computer started',
'os launched',
'docker started',
'os stopped',
])
# 判断是否有内容,打印第二个和第三个对象
if not events.is_empty():
print(events.list_events_by_range(1, 3))
```
但是,这样并非最好的做法。因为 Python 已经为我们提供了一套对象规则,所以我们不需要像写其他语言的 OO *(面向对象)* 代码那样去自己定义额外方法。我们有更好的选择:
```python
class Events:
def __init__(self, events):
self.events = events
def __len__(self):
"""自定义长度,将会被用来做布尔判断"""
return len(self.events)
def __getitem__(self, index):
"""自定义切片方法"""
# 直接将 slice 切片对象透传给 events 处理
return self.events[index]
# 判断是否有内容,打印第二个和第三个对象
if events:
print(events[1:3])
```
新的写法相比旧代码,更能适配进 Python 世界的规则API 也更为简洁。
关于如何适配规则、写出更好的 Python 代码。Raymond Hettinger 在 PyCon 2015 上有过一次非常精彩的演讲 [“Beyond PEP8 - Best practices for beautiful intelligible code”](https://www.youtube.com/watch?v=wf-BqAjZb8M)。这次演讲长期排在我个人的 *“PyCon 视频 TOP5”* 名单上,如果你还没有看过,我强烈建议你现在就去看一遍 :)
> Hint更全面的 Python 对象模型规则可以在 [官方文档](https://docs.python.org/3/reference/datamodel.html) 找到,有点难读,但值得一读。
## 总结
Python 世界有着一套非常复杂的规则,这些规则的涵盖范围包括“对象与对象是否相等“、”对象与对象谁大谁小”等等。它们大部分都需要通过重新定义“双下划线方法 `__xxx__`” 去实现。
如果熟悉这些规则,并在日常编码中活用它们,有助于我们更高效的解决问题、设计出更符合 Python 哲学的 API。下面是本文的一些要点总结
- **永远记得对原始需求做抽象分析,比如问题是否能用集合求差集解决**
- 如果要把对象放入集合,需要自定义对象的 `__hash__``__eq__` 方法
- `__hash__` 方法决定性能(碰撞出现概率),`__eq__` 决定对象间相等逻辑
- 使用 dataclasses 模块可以让你少写很多代码
- 使用 `__format__` 方法替代自己定义的字符串格式化方法
- 在容器类对象上使用 `__len__`、`__getitem__` 方法,而不是自己实现
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【11.高效操作文件的三个建议】](11-three-tips-on-writing-file-related-codes.md)
[<<<上一篇【9.一个关于模块的小故事】](9-a-story-on-cyclic-imports.md)
## 附录
- 题图来源: Photo by JESHOOTS.COM on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:编写条件分支代码的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:异常处理的三个好习惯](https://www.zlovezl.cn/articles/three-rituals-of-exceptions-handling/)
- [Python 工匠:编写地道循环的两个建议](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)

View File

@ -1,396 +0,0 @@
# Python 工匠:高效操作文件的三个建议
## 前言
> 这是 “Python 工匠”系列的第 11 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/06/devon-divine-1348025-unsplash_1280.jpg" width="100%" />
</div>
在这个世界上,人们每天都在用 Python 完成着不同的工作。而文件操作,则是大家最常需要解决的任务之一。使用 Python你可以轻松为他人生成精美的报表也可以用短短几行代码快速解析、整理上万份数据文件。
当我们编写与文件相关的代码时,通常会关注这些事情:**我的代码是不是足够快?我的代码有没有事半功倍的完成任务?** 在这篇文章中,我会与你分享与之相关的几个编程建议。我会向你推荐一个被低估的 Python 标准库模块、演示一个读取大文件的最佳方式、最后再分享我对函数设计的一点思考。
下面,让我们进入第一个“模块安利”时间吧。
> **注意:**因为不同操作系统的文件系统大不相同,本文的主要编写环境为 Mac OS/Linux 系统,其中一些代码可能并不适用于 Windows 系统。
## 建议一:使用 pathlib 模块
如果你需要在 Python 里进行文件处理,那么标准库中的 `os``os.path` 兄弟俩一定是你无法避开的两个模块。在这两个模块里,有着非常多与文件路径处理、文件读写、文件状态查看相关的工具函数。
让我用一个例子来展示一下它们的使用场景。有一个目录里装了很多数据文件,但是它们的后缀名并不统一,既有 `.txt`,又有 `.csv`。我们需要把其中以 `.txt` 结尾的文件都修改为 `.csv` 后缀名。
我们可以写出这样一个函数:
```python
import os
import os.path
def unify_ext_with_os_path(path):
"""统一目录下的 .txt 文件名后缀为 .csv
"""
for filename in os.listdir(path):
basename, ext = os.path.splitext(filename)
if ext == '.txt':
abs_filepath = os.path.join(path, filename)
os.rename(abs_filepath, os.path.join(path, f'{basename}.csv'))
```
让我们看看,上面的代码一共用到了哪些与文件处理相关的函数:
- [`os.listdir(path)`](https://docs.python.org/3/library/os.html#os.listdir):列出 path 目录下的所有文件*(含文件夹)*
- [`os.path.splitext(filename)`](https://docs.python.org/3/library/os.path.html#os.path.splitext):切分文件名里面的基础名称和后缀部分
- [`os.path.join(path, filename)`](https://docs.python.org/3/library/os.path.html#os.path.join):组合需要操作的文件名为绝对路径
- [`os.rename(...)`](https://docs.python.org/3/library/os.html#os.rename):重命名某个文件
上面的函数虽然可以完成需求,但说句实话,即使在写了很多年 Python 代码后,我依然觉得:**这些函数不光很难记,而且最终的成品代码也不怎么讨人喜欢。**
### 使用 pathlib 模块改写代码
为了让文件处理变得更简单Python 在 3.4 版本引入了一个新的标准库模块:[pathlib](https://docs.python.org/3/library/pathlib.html)。它基于面向对象思想设计,封装了非常多与文件操作相关的功能。如果使用它来改写上面的代码,结果会大不相同。
使用 pathlib 模块后的代码:
```python
from pathlib import Path
def unify_ext_with_pathlib(path):
for fpath in Path(path).glob('*.txt'):
fpath.rename(fpath.with_suffix('.csv'))
```
和旧代码相比,新函数只需要两行代码就完成了工作。而这两行代码主要做了这么几件事:
1. 首先使用 [Path(path)](https://docs.python.org/3/library/pathlib.html#pathlib.Path) 将字符串路径转换为 `Path` 对象
2. 调用 [.glob('*.txt')](https://docs.python.org/3/library/pathlib.html#pathlib.Path.glob) 对路径下所有内容进行模式匹配并以生成器方式返回,结果仍然是 `Path` 对象,所以我们可以接着做后面的操作
3. 使用 [.with_suffix('.csv')](https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.with_suffix) 直接获取使用新后缀名的文件全路径
4. 调用 [.rename(target)](https://docs.python.org/3/library/pathlib.html#pathlib.Path.rename) 完成重命名
相比 `os``os.path`,引入 `pathlib` 模块后的代码明显更精简,也更有整体统一感。所有文件相关的操作都是一站式完成。
### 其他用法
除此之外pathlib 模块还提供了很多有趣的用法。比如使用 `/` 运算符来组合文件路径:
```python
# 😑 旧朋友:使用 os.path 模块
>>> import os.path
>>> os.path.join('/tmp', 'foo.txt')
'/tmp/foo.txt'
# ✨ 新潮流:使用 / 运算符
>>> from pathlib import Path
>>> Path('/tmp') / 'foo.txt'
PosixPath('/tmp/foo.txt')
```
或者使用 `.read_text()` 来快速读取文件内容:
```python
# 标准做法,使用 with open(...) 打开文件
>>> with open('foo.txt') as file:
... print(file.read())
...
foo
# 使用 pathlib 可以让这件事情变得更简单
>>> from pathlib import Path
>>> print(Path('foo.txt').read_text())
foo
```
除了我在文章里介绍的这些pathlib 模块还提供了非常多有用的方法,强烈建议去 [官方文档]((https://docs.python.org/3/library/pathlib.html#module-pathlib)) 详细了解一下。
如果上面这些都不足以让你动心,那么我再多给你一个使用 pathlib 的理由:[PEP-519](https://www.python.org/dev/peps/pep-0519/) 里定义了一个专门用于“文件路径”的新对象协议,这意味着从该 PEP 生效后的 Python 3.6 版本起pathlib 里的 Path 对象,可以和以前绝大多数只接受字符串路径的标准库函数兼容使用:
```python
>>> p = Path('/tmp')
# 可以直接对 Path 类型对象 p 进行 join
>>> os.path.join(p, 'foo.txt')
'/tmp/foo.txt'
```
所以,无需犹豫,赶紧把 pathlib 模块用起来吧。
> **Hint:** 如果你使用的是更早的 Python 版本,可以尝试安装 [pathlib2](https://pypi.org/project/pathlib2/) 模块 。
## 建议二:掌握如何流式读取大文件
几乎所有人都知道,在 Python 里读取文件有一种“标准做法”:首先使用 `with open(fine_name)` 上下文管理器的方式获得一个文件对象,然后使用 `for` 循环迭代它,逐行获取文件里的内容。
下面是一个使用这种“标准做法”的简单示例函数:
```python
def count_nine(fname):
"""计算文件里包含多少个数字 '9'
"""
count = 0
with open(fname) as file:
for line in file:
count += line.count('9')
return count
```
假如我们有一个文件 `small_file.txt`,那么使用这个函数可以轻松计算出 9 的数量。
```python
# small_file.txt
feiowe9322nasd9233rl
aoeijfiowejf8322kaf9a
# OUTPUT: 3
print(count_nine('small_file.txt'))
```
为什么这种文件读取方式会成为标准?这是因为它有两个好处:
1. `with` 上下文管理器会自动关闭打开的文件描述符
2. 在迭代文件对象时,内容是一行一行返回的,不会占用太多内存
### 标准做法的缺点
但这套标准做法并非没有缺点。如果被读取的文件里,根本就没有任何换行符,那么上面的第二个好处就不成立了。**当代码执行到 `for line in file`line 将会变成一个非常巨大的字符串对象,消耗掉非常可观的内存。**
让我们来做个试验:有一个 **5GB** 大的文件 `big_file.txt`,它里面装满了和 `small_file.txt` 一样的随机字符串。只不过它存储内容的方式稍有不同,所有的文本都被放在了同一行里:
```raw
# FILE: big_file.txt
df2if283rkwefh... <剩余 5GB 大小> ...
```
如果我们继续使用前面的 `count_nine` 函数去统计这个大文件里 `9` 的个数。那么在我的笔记本上,这个过程会足足花掉 **65** 秒,并在执行过程中吃掉机器 **2GB** 内存 [[注1]]((#annot1))。
### 使用 read 方法分块读取
为了解决这个问题,我们需要暂时把这个“标准做法”放到一边,使用更底层的 `file.read()` 方法。与直接循环迭代文件对象不同,每次调用 `file.read(chunk_size)` 会直接返回从当前位置往后读取 `chunk_size` 大小的文件内容,不必等待任何换行符出现。
所以,如果使用 `file.read()` 方法,我们的函数可以改写成这样:
```python
def count_nine_v2(fname):
"""计算文件里包含多少个数字 '9',每次读取 8kb
"""
count = 0
block_size = 1024 * 8
with open(fname) as fp:
while True:
chunk = fp.read(block_size)
# 当文件没有更多内容时read 调用将会返回空字符串 ''
if not chunk:
break
count += chunk.count('9')
return count
```
在新函数中,我们使用了一个 `while` 循环来读取文件内容,每次最多读取 8kb 大小,这样可以避免之前需要拼接一个巨大字符串的过程,把内存占用降低非常多。
### 利用生成器解耦代码
假如我们在讨论的不是 Python而是其他编程语言。那么可以说上面的代码已经很好了。但是如果你认真分析一下 `count_nine_v2` 函数,你会发现在循环体内部,存在着两个独立的逻辑:**数据生成read 调用与 chunk 判断)** 与 **数据消费**。而这两个独立逻辑被耦合在了一起。
正如我在[《编写地道循环》](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)里所提到的,为了提升复用能力,我们可以定义一个新的 `chunked_file_reader` 生成器函数,由它来负责所有与“数据生成”相关的逻辑。这样 `count_nine_v3` 里面的主循环就只需要负责计数即可。
```python
def chunked_file_reader(fp, block_size=1024 * 8):
"""生成器函数:分块读取文件内容
"""
while True:
chunk = fp.read(block_size)
# 当文件没有更多内容时read 调用将会返回空字符串 ''
if not chunk:
break
yield chunk
def count_nine_v3(fname):
count = 0
with open(fname) as fp:
for chunk in chunked_file_reader(fp):
count += chunk.count('9')
return count
```
进行到这一步,代码似乎已经没有优化的空间了,但其实不然。[iter(iterable)](https://docs.python.org/3/library/functions.html#iter) 是一个用来构造迭代器的内建函数,但它还有一个更少人知道的用法。当我们使用 `iter(callable, sentinel)` 的方式调用它时,会返回一个特殊的对象,迭代它将不断产生可调用对象 callable 的调用结果,直到结果为 setinel 时,迭代终止。
```python
def chunked_file_reader(file, block_size=1024 * 8):
"""生成器函数:分块读取文件内容,使用 iter 函数
"""
# 首先使用 partial(fp.read, block_size) 构造一个新的无需参数的函数
# 循环将不断返回 fp.read(block_size) 调用结果,直到其为 '' 时终止
for chunk in iter(partial(file.read, block_size), ''):
yield chunk
```
最终,只需要两行代码,我们就完成了一个可复用的分块文件读取函数。那么,这个函数在性能方面的表现如何呢?
和一开始的 **2GB 内存/耗时 65 秒** 相比,使用生成器的版本只需要 **7MB 内存 / 12 秒** 就能完成计算。效率提升了接近 4 倍,内存占用更是不到原来的 1%。
## 建议三:设计接受文件对象的函数
统计完文件里的 “9” 之后,让我们换一个需求。现在,我想要统计每个文件里出现了多少个英文元音字母*aeiou*。只要对之前的代码稍作调整,很快就可以写出新函数 `count_vowels`
```python
def count_vowels(filename):
"""统计某个文件中,包含元音字母(aeiou)的数量
"""
VOWELS_LETTERS = {'a', 'e', 'i', 'o', 'u'}
count = 0
with open(filename, 'r') as fp:
for line in fp:
for char in line:
if char.lower() in VOWELS_LETTERS:
count += 1
return count
# OUTPUT: 16
print(count_vowels('small_file.txt'))
```
和之前“统计 9”的函数相比新函数变得稍微复杂了一些。为了保证程序的正确性我需要为它写一些单元测试。但当我准备写测试时却发现这件事情非常麻烦主要问题点如下
1. 函数接收文件路径作为参数,所以我们需要传递一个实际存在的文件
2. 为了准备测试用例,我要么提供几个样板文件,要么写一些临时文件
3. 而文件是否能被正常打开、读取,也成了我们需要测试的边界情况
**如果,你发现你的函数难以编写单元测试,那通常意味着你应该改进它的设计**。上面的函数应该如何改进呢?答案是:*让函数依赖“文件对象”而不是文件路径*。
修改后的函数代码如下:
```python
def count_vowels_v2(fp):
"""统计某个文件中,包含元音字母(aeiou)的数量
"""
VOWELS_LETTERS = {'a', 'e', 'i', 'o', 'u'}
count = 0
for line in fp:
for char in line:
if char.lower() in VOWELS_LETTERS:
count += 1
return count
# 修改函数后,打开文件的职责被移交给了上层函数调用者
with open('small_file.txt') as fp:
print(count_vowels_v2(fp))
```
**这个改动带来的主要变化,在于它提升了函数的适用面**。因为 Python 是“鸭子类型”的,虽然函数需要接受文件对象,但其实我们可以把任何实现了文件协议的 “类文件对象file-like object” 传入 `count_vowels_v2` 函数中。
而 Python 中有着非常多“类文件对象”。比如 io 模块内的 [StringIO](https://docs.python.org/3/library/io.html#io.StringIO) 对象就是其中之一。它是一种基于内存的特殊对象,拥有和文件对象几乎一致的接口设计。
利用 StringIO我们可以非常方便的为函数编写单元测试。
```python
# 注意:以下测试函数需要使用 pytest 执行
import pytest
from io import StringIO
@pytest.mark.parametrize(
"content,vowels_count", [
# 使用 pytest 提供的参数化测试工具,定义测试参数列表
# (文件内容, 期待结果)
('', 0),
('Hello World!', 3),
('HELLO WORLD!', 3),
('你好,世界', 0),
]
)
def test_count_vowels_v2(content, vowels_count):
# 利用 StringIO 构造类文件对象 "file"
file = StringIO(content)
assert count_vowels_v2(file) == vowels_count
```
使用 pytest 运行测试可以发现,函数可以通过所有的用例:
```raw
pytest vowels_counter.py
====== test session starts ======
collected 4 items
vowels_counter.py ... [100%]
====== 4 passed in 0.06 seconds ======
```
而让编写单元测试变得更简单,并非修改函数依赖后的唯一好处。除了 StringIO 外subprocess 模块调用系统命令时用来存储标准输出的 [PIPE](https://docs.python.org/3/library/subprocess.html#subprocess.PIPE) 对象,也是一种“类文件对象”。这意味着我们可以直接把某个命令的输出传递给 `count_vowels_v2` 函数来计算元音字母数:
```python
import subprocess
# 统计 /tmp 下面所有一级子文件名(目录名)有多少元音字母
p = subprocess.Popen(['ls', '/tmp'], stdout=subprocess.PIPE, encoding='utf-8')
# p.stdout 是一个流式类文件对象,可以直接传入函数
# OUTPUT: 42
print(count_vowels_v2(p.stdout))
```
正如之前所说,将函数参数修改为“文件对象”,最大的好处是提高了函数的 **适用面****可组合性**。通过依赖更为抽象的“类文件对象”而非文件路径给函数的使用方式开启了更多可能StringIO、PIPE 以及任何其他满足协议的对象都可以成为函数的客户。
不过,这样的改造并非毫无缺点,它也会给调用方带来一些不便。假如调用方就是想要使用文件路径,那么就必须得自行处理文件的打开操作。
### 如何编写兼容二者的函数
有没有办法即拥有“接受文件对象”的灵活性,又能让传递文件路径的调用方更方便?答案是:*有,而且标准库中就有这样的例子。*
打开标准库里的 `xml.etree.ElementTree` 模块,翻开里面的 `ElementTree.parse` 方法。你会发现这个方法即可以使用文件对象调用,也接受字符串的文件路径。而它实现这一点的手法也非常简单易懂:
```
def parse(self, source, parser=None):
"""*source* is a file name or file object, *parser* is an optional parser
"""
close_source = False
# 通过判断 source 是否有 "read" 属性来判定它是不是“类文件对象”
# 如果不是,那么调用 open 函数打开它并负担起在函数末尾关闭它的责任
if not hasattr(source, "read"):
source = open(source, "rb")
close_source = True
```
使用这种基于“鸭子类型”的灵活检测方式,`count_vowels_v2` 函数也同样可以被改造得更方便,我在这里就不再重复啦。
## 总结
文件操作我们在日常工作中经常需要接触的领域,使用更方便的模块、利用生成器节约内存以及编写适用面更广的函数,可以让我们编写出更高效的代码。
让我们最后再总结一下吧:
- 使用 pathlib 模块可以简化文件和目录相关的操作,并让代码更直观
- [PEP-519](https://www.python.org/dev/peps/pep-0519/) 定义了表示“文件路径”的标准协议Path 对象实现了这个协议
- 通过定义生成器函数来分块读取大文件可以节约内存
- 使用 `iter(callable, sentinel)` 可以在一些特定场景简化代码
- 难以编写测试的代码,通常也是需要改进的代码
- 让函数依赖“类文件对象”可以提升函数的适用面和可组合性
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【12.写好面向对象代码的原则(上)】](12-write-solid-python-codes-part-1.md)
[<<<上一篇【10.做一个精通规则的玩家】](10-a-good-player-know-the-rules.md)
## 附录
- 题图来源: Photo by Devon Divine on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:编写条件分支代码的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:异常处理的三个好习惯](https://www.zlovezl.cn/articles/three-rituals-of-exceptions-handling/)
- [Python 工匠:编写地道循环的两个建议](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)
## 注解
1. <a id="annot1"></a>视机器空闲内存的多少,这个过程可能会消耗比 2GB 更多的内存。

View File

@ -1,594 +0,0 @@
# Python 工匠:写好面向对象代码的原则(上)
## 前言
> 这是 “Python 工匠”系列的第 12 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/06/kelly-sikkema-Z9AU36chmQI-unsplash_w1280.jpg" width="100%" />
</div>
Python 是一门支持多种编程风格的语言,面对相同的需求,拥有不同背景的程序员可能会写出风格迥异的 Python 代码。比如一位习惯编写 C 语言的程序员,通常会定义一大堆函数来搞定所有事情,这是[“过程式编程”](https://en.wikipedia.org/wiki/Procedural_programming)的思想。而一位有 Java 背景的程序员则更倾向于设计许多个相互关联的类*class*,这是 [“面向对象编程(后简称 OOP](https://en.wikipedia.org/wiki/Object-oriented_programming)。
虽然不同的编程风格各有特点,无法直接比较。但是 OOP 思想在现代软件开发中起到的重要作用应该是毋庸置疑的。
很多人在学习如何写好 OOP 代码时,会选择从那 [23 种经典的“设计模式”](https://zh.wikipedia.org/wiki/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F_(%E8%AE%A1%E7%AE%97%E6%9C%BA))开始。不过对于 Python 程序员来说,我认为这并非是一个最佳选择。
### Python 对 OOP 的支持
Python 语言虽然拥有类、继承、多态等核心 OOP 特性,但和那些完全基于 OOP 思想设计的编程语言*(比如 Java*相比,它在 OOP 支持方面做了很多简化工作。比如它 **没有严格的类私有成员没有接口Interface对象** 等。
而与此同时Python 灵活的函数对象、鸭子类型等许多动态特性又让一些在其他语言中很难做到的事情变得非常简单。这些语言间的差异共同导致了一个结果:*很多经典的设计模式到了 Python 里,就丢失了那个“味道”,实用性也大打折扣。*
拿大家最熟悉的单例模式来说。你可以花上一大把时间,来学习如何在 Python 中利用 `__new__` 方法或元类*metaclass*来实现单例设计模式,但最后你会发现,自己 95% 的需求都可以通过直接定义一个模块级全局变量来搞定。
所以,与具体化的 **设计模式** 相比,我觉得一些更为抽象的 **设计原则** 适用性更广、更适合运用到 Python 开发工作中。而谈到关于 OOP 的设计原则“SOLID” 是众多原则中最有名的一个。
### SOLID 设计原则
著名的设计模式书籍[《设计模式:可复用面向对象软件的基础》](https://book.douban.com/subject/1052241/)出版于 1994 年,距今已有超过 25 年的历史。而这篇文章的主角: “SOLID 设计原则”同样也并不年轻。
早在 2000 年,[Robert C. Martin](https://en.wikipedia.org/wiki/Robert_C._Martin) 就在他的文章 "Design Principles and Design Patterns" 中整理并提出了 “SOLID” 设计原则的雏型,之后又在他的经典著作[《敏捷软件开发 : 原则、模式与实践》](https://book.douban.com/subject/1140457/)中将其发扬光大。“SOLID” 由 5 个单词组合的首字母缩写组成,分别代表 5 条不同的面向对象领域的设计原则。
在编写 OOP 代码时,如果遵循这 5 条设计原则,就更可能写出可扩展、易于修改的代码。相反,如果不断违反其中的一条或多条原则,那么很快你的代码就会变得不可扩展、难以维护。
接下来,让我用一个真实的 Python 代码样例来分别向你诠释这 5 条设计原则。
> 写在最前面的注意事项:
>
> 0. “原则”不是“法律”,它只起到指导作用,并非不可以违反
> 1. “原则”的后两条与接口Interface有关而 Python 没有接口,所以对这部分的诠释是我的个人理解,与原版可能略有出入
> 2. 文章后面的内容含有大量代码,请做好心理准备 ☕️
> 3. 为了增强代码的说明性,本文中的代码使用了 Python3 中的 [类型注解特性](https://docs.python.org/3/library/typing.html)
## SOLID 原则与 Python
[Hacker News](https://news.ycombinator.com/)*(后简称 HN)* 是一个在程序员圈子里很受欢迎的站点。在它的首页,有很多由用户提交后基于推荐算法排序的科技相关内容。
我经常会去上面看一些热门文章,但我觉得每次打开浏览器访问有点麻烦。所以,我准备编写一个脚本,自动抓取 HN 首页 Top5 的新闻标题与链接,并用纯文本的方式写入到文件。方便自己用其他工具阅读。
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/06/hackernews_frontpage.png" width="100%" />
Hacker News 首页截图
</div>
编写爬虫几乎是 Python 天生的拿手好戏。利用 requests、lxml 等模块提供的好用功能,我可以轻松实现上面的需求。下面是我第一次编写好的代码:
```python
import io
import sys
from typing import Generator
import requests
from lxml import etree
class Post:
"""HN(https://news.ycombinator.com/) 上的条目
:param title: 标题
:param link: 链接
:param points: 当前得分
:param comments_cnt: 评论数
"""
def __init__(self, title: str, link: str, points: str, comments_cnt: str):
self.title = title
self.link = link
self.points = int(points)
self.comments_cnt = int(comments_cnt)
class HNTopPostsSpider:
"""抓取 HackerNews Top 内容条目
:param fp: 存储抓取结果的目标文件对象
:param limit: 限制条目数,默认为 5
"""
ITEMS_URL = 'https://news.ycombinator.com/'
FILE_TITLE = 'Top news on HN'
def __init__(self, fp: io.TextIOBase, limit: int = 5):
self.fp = fp
self.limit = limit
def fetch(self) -> Generator[Post, None, None]:
"""从 HN 抓取 Top 内容
"""
resp = requests.get(self.ITEMS_URL)
# 使用 XPath 可以方便的从页面解析出你需要的内容,以下均为页面解析代码
# 如果你对 xpath 不熟悉,可以忽略这些代码,直接跳到 yield Post() 部分
html = etree.HTML(resp.text)
items = html.xpath('//table[@class="itemlist"]/tr[@class="athing"]')
for item in items[:self.limit]:
node_title = item.xpath('./td[@class="title"]/a')[0]
node_detail = item.getnext()
points_text = node_detail.xpath('.//span[@class="score"]/text()')
comments_text = node_detail.xpath('.//td/a[last()]/text()')[0]
yield Post(
title=node_title.text,
link=node_title.get('href'),
# 条目可能会没有评分
points=points_text[0].split()[0] if points_text else '0',
comments_cnt=comments_text.split()[0]
)
def write_to_file(self):
"""以纯文本格式将 Top 内容写入文件
"""
self.fp.write(f'# {self.FILE_TITLE}\n\n')
# enumerate 接收第二个参数,表示从这个数开始计数(默认为 0
for i, post in enumerate(self.fetch(), 1):
self.fp.write(f'> TOP {i}: {post.title}\n')
self.fp.write(f'> 分数:{post.points} 评论数:{post.comments_cnt}\n')
self.fp.write(f'> 地址:{post.link}\n')
self.fp.write('------\n')
def main():
# with open('/tmp/hn_top5.txt') as fp:
# crawler = HNTopPostsSpider(fp)
# crawler.write_to_file()
# 因为 HNTopPostsSpider 接收任何 file-like 的对象,所以我们可以把 sys.stdout 传进去
# 实现往控制台标准输出打印的功能
crawler = HNTopPostsSpider(sys.stdout)
crawler.write_to_file()
if __name__ == '__main__':
main()
```
你可以把上面的代码称之为符合 OOP 风格的,因为在上面的代码里,我定义了两个类:
1. `Post`:表示单个 HN 内容条目,其中定义了标题、链接等字段,是用来衔接“抓取”和“写入文件”两件事情的数据类
2. `HNTopPostsSpider`:抓取 HN 内容的爬虫类,其中定义了抓取页面、解析、写入结果的方法,是完成主要工作的类
如果你本地的 Python 环境配置正常,那么可以尝试执行一下上面这段代码,它会输出下面这样的内容:
```text
python news_digester.py
> TOP 1: Show HN: NoAgeismInTech Job board for companies fighting ageism in tech
> 分数104 评论数26
> 地址https://noageismintech.com/
------
> TOP 2: Magic Leap sues former employee who founded the China-based Nreal for IP theft
> 分数17 评论数2
> 地址https://www.bloomberg.com/news/articles/2019-06-18/secretive-magic-leap-says-ex-engineer-copied-headset-for-china
------
... ...
```
这个脚本基于面向对象的方式编写*(换句话说,就是定义了一些 class 😒)*,可以满足我的需求。但是从设计的角度来看,它却违反了 SOLID 原则的第一条“Single responsibility principle单一职责原则让我们来看看是为什么。
## S单一职责原则
SOLID 设计原则里的第一个字母 S 来自于 “Single responsibility principle单一职责原则” 的首字母。这个原则认为:**“一个类应该仅仅只有一个被修改的理由。”** 换句话说,每个类都应该只有一种职责。
而在上面的代码中,`HNTopPostsSpider` 这个类违反了这个原则。因为我们可以很容易的找到两个不同的修改它的理由:
- **理由 1**: HN 网站的程序员突然更新了页面样式,旧的 xpath 解析算法从新页面上解析不到内容,需要修改 `fetch` 方法内的解析逻辑。
- **理由 2**: 用户*(也就是我)*突然觉得纯文本格式的输出不好看,想要改成 Markdown 样式。需要修改 `write_to_file` 方法内的输出逻辑。
所以,`HNTopPostsSpider` 类违反了“单一职责原则”,因为它有着多个被修改的理由。而这背后的根本原因是因为它承担着 “抓取帖子列表” 和 "将帖子列表写入文件" 这两种完全不同的职责。
### 违反“单一职责原则”的坏处
如果某个类违反了“单一职责原则”,那意味着我们经常会因为不同的原因去修改它。这可能会导致不同功能之间相互影响。比如,可能我在某天调整了页面解析逻辑,却发现输出的文件格式也全部乱掉了。
另外,单个类承担的职责越多,意味着这个类的复杂度也就越高,它的维护成本也同样会水涨船高。违反“单一职责原则”的类同样也难以被复用,假如我有其他代码想复用 `HNTopPostsSpider` 类的抓取和解析逻辑,会发现我必须要提供一个莫名其妙的文件对象给它才行。
那么,要如何修改代码才能让它遵循“单一职责原则”呢?办法有很多,最传统的是:**把大类拆分为小类**。
### 拆分大类为多个小类
为了让 `HNTopPostsSpider` 类的职责更纯粹,我们可以把其中与“写入文件”相关的内容拆分出去作为一个新的类:
```python
class PostsWriter:
"""负责将帖子列表写入到文件
"""
def __init__(self, fp: io.TextIOBase, title: str):
self.fp = fp
self.title = title
def write(self, posts: List[Post]):
self.fp.write(f'# {self.title}\n\n')
# enumerate 接收第二个参数,表示从这个数开始计数(默认为 0
for i, post in enumerate(posts, 1):
self.fp.write(f'> TOP {i}: {post.title}\n')
self.fp.write(f'> 分数:{post.points} 评论数:{post.comments_cnt}\n')
self.fp.write(f'> 地址:{post.link}\n')
self.fp.write('------\n')
```
而在 `HNTopPostsSpider` 类里,可以通过调用 `PostsWriter` 的方式来完成之前的工作:
```python
class HNTopPostsSpider:
FILE_TITLE = 'Top news on HN'
<... 已省略 ...>
def write_to_file(self, fp: io.TextIOBase):
"""以纯文本格式将 Top 内容写入文件
实例化参数文件对象 fp 被挪到了 write_to_file 方法中
"""
# 将文件写入逻辑托管给 PostsWriter 类处理
writer = PostsWriter(fp, title=self.FILE_TITLE)
writer.write(list(self.fetch()))
```
通过这种方式,我们让 `HNTopPostsSpider``PostsWriter` 类都各自满足了“单一职责原则”。我只会因为解析逻辑变动才去修改 `HNTopPostsSpider` 类,同样,修改 `PostsWriter` 类的原因也只有调整输出格式一种。这两个类各自的修改可以单独进行而不会相互影响。
### 另一种方案:使用函数
“单一职责原则”虽然是针对类说的,但其实它的适用范围可以超出类本身。比如在 Python 中,通过定义函数,同样也可以让上面的代码符合单一职责原则。
我们可以把“写入文件”的逻辑拆分为一个新的函数,由它来专门承担起将帖子列表写入文件的职责:
```python
def write_posts_to_file(posts: List[Post], fp: io.TextIOBase, title: str):
"""负责将帖子列表写入文件
"""
fp.write(f'# {title}\n\n')
for i, post in enumerate(posts, 1):
fp.write(f'> TOP {i}: {post.title}\n')
fp.write(f'> 分数:{post.points} 评论数:{post.comments_cnt}\n')
fp.write(f'> 地址:{post.link}\n')
fp.write('------\n')
```
而对于 `HNTopPostsSpider` 类来说,改动可以更进一步。这次我们可以直接删除其中和文件写入相关的所有代码。让它只负责一件事情:“获取帖子列表”。
```python
class HNTopPostsSpider:
"""抓取 HackerNews Top 内容条目
:param limit: 限制条目数,默认为 5
"""
ITEMS_URL = 'https://news.ycombinator.com/'
def __init__(self, limit: int = 5):
self.limit = limit
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
```
相应的,类和函数的调用方 `main` 函数就需要稍作调整,它需要负责把 `write_posts_to_file` 函数和 `HNTopPostsSpider` 类之间协调起来,共同完成工作:
```python
def main():
crawler = HNTopPostsSpider()
posts = list(crawler.fetch())
file_title = 'Top news on HN'
write_posts_to_file(posts, sys.stdout, file_title)
```
将“文件写入”职责拆分为新函数是一个 Python 特色的解决方案,它虽然没有那么 OO *(面向对象)*,但是同样满足“单一职责原则”,而且在很多场景下更灵活与高效。
## O开放-关闭原则
O 来自于 “Openclosed principle开放-关闭原则)” 的首字母,它认为:**“类应该对扩展开放,对修改封闭。”**这是一个从字面上很难理解的原则,它同样有着另外一种说法:**“你应该可以在不修改某个类的前提下,扩展它的行为。”**
这原则听上去有点让人犯迷糊,如何能做到不修改代码又改变行为呢?让我来举一个例子:你知道 Python 里的内置排序函数 `sorted` 吗?
如果我们想对某个列表排序,可以直接调用 `sorted` 函数:
```python
>>> l = [5, 3, 2, 4, 1]
>>> sorted(l)
[1, 2, 3, 4, 5]
```
现在,假如我们想改变 `sorted` 函数的排序逻辑。比如,让它使用所有元素对 3 取余后的结果来排序。我们是不是需要去修改 `sorted` 函数的源码?当然不用,只需要在调用 `sort` 函数时,传入自定义的排序函数 `key` 参数就行了:
```python
>>> l = [8, 1, 9]
# 按照元素对 3 的余数排序,能被 3 整除的 9 排在了最前面,随后是 1 和 8
>>> sorted(l, key=lambda i: i % 3)
[9, 1, 8]
```
通过上面的例子,我们可以认为:`sorted` 函数是一个符合“开放-关闭原则”的绝佳例子,因为它:
- **对扩展开放**:你可以通过传入自定义 `key` 函数来扩展它的行为
- **对修改关闭**:你无需修改 sort 函数本身
### 如何违反“开放-关闭原则”
现在,让我们回到爬虫小程序。在使用了一段时间之后,用户*(还是我)*觉得每次抓取到的内容有点不合口味。我其实只关注那些来自特定网站,比如 github 上的内容。所以我需要修改 `HNTopPostsSpider` 类的代码来对结果进行过滤:
```python
class HNTopPostsSpider:
# <... 已省略 ...>
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
counter = 0
for item in items:
if counter >= self.limit:
break
# <... 已省略 ...>
link = node_title.get('href')
# 只关注来自 github.com 的内容
if 'github' in link.lower():
counter += 1
yield Post(... ...)
```
完成修改后,让我们来简单测试一下效果:
```text
python news_digester_O_before.py
# Top news on HN
> TOP 1: Mimalloc A compact general-purpose allocator
> 分数291 评论数40
> 地址https://github.com/microsoft/mimalloc
------
> TOP 2: Olivia: An open source chatbot build with a neural network in Go
> 分数53 评论数19
> 地址https://github.com/olivia-ai/olivia
------
<... 已省略 ...>
```
看上去新加的过滤代码起到了作用,现在只有链接中含有 `github` 的内容才会被写入到结果中。
但是,正如某位哲学家的名言所说:*“这世间唯一不变的,只有变化本身。”*某天,用户*(永远是我)*突然觉得,来自 `bloomberg` 的内容也都很有意思,所以我想要把 `bloomberg` 也加入筛选关键字逻辑里。
这时我们就会发现:现在的代码违反了"开放-关闭原则"。因为我必须要修改现有的 `HNTopPostsSpider` 类代码,调整那个 `if 'github' in link.lower()` 判断语句才能完成我的需求。
“开放-关闭原则”告诉我们,类应该通过扩展而不是修改的方式改变自己的行为。那么我应该如何调整代码,让它可以遵循原则呢?
### 使用类继承来改造代码
继承是面向对象理论中最重要的概念之一。它允许我们在父类中定义好数据和方法,然后通过继承的方式让子类获得这些内容,并可以选择性的对其中一些进行重写,修改它的行为。
使用继承的方式来让类遵守“开放-关闭原则”的关键点在于:**找到父类中会变动的部分,将其抽象成新的方法(或属性),最终允许新的子类来重写它以改变类的行为。**
对于 `HNTopPostsSpider` 类来说。首先,我们需要找到其中会变动的那部分逻辑,也就是*“判断是否对条目感兴趣”*,然后将其抽象出来,定义为新的方法:
```python
class HNTopPostsSpider:
# <... 已省略 ...>
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
for item in items:
# <... 已省略 ...>
post = Post( ... ... )
# 使用测试方法来判断是否返回该帖子
if self.interested_in_post(post):
counter += 1
yield post
def interested_in_post(self, post: Post) -> bool:
"""判断是否应该将帖子加入结果中
"""
return True
```
如果我们只关心来自 `github` 的帖子,那么只需要定义一个继承于 `HNTopPostsSpider` 子类,然后重写父类的 `interested_in_post` 方法即可。
```python
class GithubOnlyHNTopPostsSpider(HNTopPostsSpider):
"""只关心来自 Github 的内容
"""
def interested_in_post(self, post: Post) -> bool:
return 'github' in post.link.lower()
def main():
# crawler = HNTopPostsSpider()
# 使用新的子类
crawler = GithubOnlyHNTopPostsSpider()
<... ...>
```
假如我们的兴趣发生了变化?没关系,增加新的子类就行:
```python
class GithubNBloomBergHNTopPostsSpider(HNTopPostsSpider):
"""只关系来自 Github/BloomBerg 的内容
"""
def interested_in_post(self, post: Post) -> bool:
if 'github' in post.link.lower() \
or 'bloomberg' in post.link.lower():
return True
return False
```
所有的这一切,都不需要修改原本的 `HNTopPostsSpider` 类的代码,只需要不断在它的基础上创建新的子类就能完成新需求。最终实现了对扩展开放、对改变关闭。
### 使用组合与依赖注入来改造代码
虽然类的继承特性很强大,但它并非唯一办法,[依赖注入Dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) 是解决这个问题的另一种思路。与继承不同,依赖注入允许我们在类实例化时,通过参数将业务逻辑的变化点:**帖子过滤算法** 注入到类实例中。最终同样实现“开放-关闭原则”。
首先,我们定义一个名为 `PostFilter` 的抽象类:
```python
from abc import ABC, abstractmethod
class PostFilter(metaclass=ABCMeta):
"""抽象类:定义如何过滤帖子结果
"""
@abstractmethod
def validate(self, post: Post) -> bool:
"""判断帖子是否应该被保留"""
```
> Hint定义抽象类在 Python 的 OOP 中并不是必须的,你也可以不定义它,直接从下面的 DefaultPostFilter 开始。
然后定义一个继承于该抽象类的默认 `DefaultPostFilter` 类,过滤逻辑为保留所有结果。之后再调整一下 `HNTopPostsSpider` 类的构造方法,让它接收一个名为 `post_filter` 的结果过滤器:
```python
class DefaultPostFilter(PostFilter):
"""保留所有帖子
"""
def validate(self, post: Post) -> bool:
return True
class HNTopPostsSpider:
"""抓取 HackerNews Top 内容条目
:param limit: 限制条目数,默认为 5
:param post_filter: 过滤结果条目的算法,默认为保留所有
"""
ITEMS_URL = 'https://news.ycombinator.com/'
def __init__(self, limit: int = 5, post_filter: Optional[PostFilter] = None):
self.limit = limit
self.post_filter = post_filter or DefaultPostFilter()
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
for item in items:
# <... 已省略 ...>
post = Post( ... ... )
# 使用测试方法来判断是否返回该帖子
if self.post_filter.validate(post):
counter += 1
yield post
```
默认情况下,`HNTopPostsSpider.fetch` 会保留所有的结果。假如我们想要定义自己的过滤算法,只要新建自己的 `PostFilter` 类即可,下面是两个分别过滤 GitHub 与 BloomBerg 的 `PostFilter` 类:
```
class GithubPostFilter(PostFilter):
def validate(self, post: Post) -> bool:
return 'github' in post.link.lower()
class GithubNBloomPostFilter(PostFilter):
def validate(self, post: Post) -> bool:
if 'github' in post.link.lower() or 'bloomberg' in post.link.lower():
return True
return False
```
`main()` 函数中,我可以用不同的 `post_filter` 参数来实例化 `HNTopPostsSpider` 类,最终满足不同的过滤需求:
```python
def main():
# crawler = HNTopPostsSpider()
# crawler = HNTopPostsSpider(post_filter=GithubPostFilter())
crawler = HNTopPostsSpider(post_filter=GithubNBloomPostFilter())
posts = list(crawler.fetch())
file_title = 'Top news on HN'
write_posts_to_file(posts, sys.stdout, file_title)
```
与基于继承的方式一样,利用将“过滤算法”抽象为 `PostFilter` 类并以实例化参数的方式注入到 `HNTopPostsSpider` 中,我们同样实现了“开放-关闭原则”。
### 使用数据驱动思想来改造代码
在实现“开放-关闭”原则的众多手法中,除了继承与依赖注入外,还有一种经常被用到的方式:**“数据驱动”**。这个方式的核心思想在于:**将经常变动的东西,完全以数据的方式抽离出来。当需求变动时,只改动数据,代码逻辑保持不动。**
它的原理与“依赖注入”有一些相似,同样是把变化的东西抽离到类外部。不同的是,后者抽离的通常是类,而前者抽离的是数据。
为了让 `HNTopPostsSpider` 类的行为可以被数据驱动,我们需要使其接收 `filter_by_link_keywords` 参数:
```python
class HNTopPostsSpider:
"""抓取 HackerNews Top 内容条目
:param limit: 限制条目数,默认为 5
:param filter_by_link_keywords: 过滤结果的关键词列表,默认为 None 不过滤
"""
ITEMS_URL = 'https://news.ycombinator.com/'
def __init__(self,
limit: int = 5,
filter_by_link_keywords: Optional[List[str]] = None):
self.limit = limit
self.filter_by_link_keywords = filter_by_link_keywords
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
for item in items:
# <... 已省略 ...>
post = Post( ... ... )
if self.filter_by_link_keywords is None:
counter += 1
yield post
# 当 link 中出现任意一个关键词时,返回结果
elif any(keyword in post.link for keyword in self.filter_by_link_keywords):
counter += 1
yield post
```
调整了初始化参数后,还需要在 `main` 函数中定义 `link_keywords` 变量并将其传入到 `HNTopPostsSpider` 类的构造方法中,之后所有针对过滤关键词的调整都只需要修改这个列表即可,无需改动 `HNTopPostsSpider` 类的代码,同样满足了“开放-关闭原则”。
```python
def main():
# link_keywords = None
link_keywords = [
'github.com',
'bloomberg.com'
]
crawler = HNTopPostsSpider(filter_by_link_keywords=link_keywords)
posts = list(crawler.fetch())
file_title = 'Top news on HN'
write_posts_to_file(posts, sys.stdout, file_title)
```
与前面的继承和依赖注入方式相比,“数据驱动”的代码更简洁,不需要定义额外的类。但它同样也存在缺点:**它的可定制性不如前面的两种方式**。假如,我想要以“链接是否以某个字符串结尾”作为新的过滤条件,那么现在的数据驱动代码就有心无力了。
如何选择合适的方式来让代码符合“开放-关闭原则”,需要根据具体的需求和场景来判断。这也是一个无法一蹴而就、需要大量练习和经验积累的过程。
## 总结
在这篇文章中,我通过一个具体的 Python 代码案例,向你描述了 “SOLID” 设计原则中的前两位成员:**“单一职责原则”** 与 **“开放-关闭原则”**。
这两个原则虽然看上去很简单,但是它们背后蕴藏了许多从好代码中提炼而来的智慧。它们的适用范围也不仅仅局限在 OOP 中。一旦你深入理解它们后,你可能会惊奇的在许多设计模式和框架中发现它们的影子 *(比如这篇文章就出现了至少 3 种设计模式,你知道是哪些吗?)*
让我们最后再总结一下吧:
- **“S: 单一职责原则”** 认为一个类只应该有一种被修改的原因
- 编写更小的类通常更不容易违反 S 原则
- S 原则同样适用于函数,你可以让函数和类协同工作
- **“O: 开放-关闭原则”** 认为类应该对改动关闭,对扩展开放
- 找到需求中频繁变化的那个点,是让类遵循 O 原则的重点所在
- 使用子类继承的方式可以让类遵守 O 原则
- 通过定义算法类,并进行依赖注入,也可以让类遵循 O 原则
- 将数据与逻辑分离,使用数据驱动的方式也是改造代码的好办法
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【13.写好面向对象代码的原则(中)】](13-write-solid-python-codes-part-2.md)
[<<<上一篇【11.高效操作文件的三个建议】](11-three-tips-on-writing-file-related-codes.md)
## 附录
- 题图来源: Photo by Kelly Sikkema on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:让函数返回结果的技巧](https://www.zlovezl.cn/articles/function-returning-tips/)
- [Python 工匠:编写地道循环的两个建议](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)
- [Python 工匠:高效操作文件的三个建议](https://www.zlovezl.cn/articles/three-tips-on-writing-file-related-codes/)

View File

@ -1,356 +0,0 @@
# Python 工匠:写好面向对象代码的原则(中)
## 前言
> 这是 “Python 工匠”系列的第 13 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/11/neonbrand-CXDw96Oy-Yw-unsplash_w1280.jpg" width="100%" />
</div>
在 [上一篇文章](https://www.zlovezl.cn/articles/write-solid-python-codes-part-1/) 里我用一个虚拟小项目作为例子讲解了“SOLID”设计原则中的前两位成员S *(单一职责原则)* 与 O *(开放-关闭原则)*
在这篇文章中,我将继续介绍 SOLID 原则的第三位成员:**L里氏替换原则**。
## 里氏替换原则与继承
在开始前,我觉得有必要先提一下 [继承Inheritance](https://en.wikipedia.org/wiki/Inheritance)。因为和前面两条非常抽象的原则不同,“里氏替换原则”是一条非常具体的,和类继承有关的原则。
在 OOP 世界里,继承算是一个非常特殊的存在,它有点像一把无坚不摧的双刃剑,强大且危险。合理使用继承,可以大大减少类与类之间的重复代码,让程序事半功倍,而不当的继承关系,则会让类与类之间建立起错误的强耦合,带来大片难以理解和维护的代码。
正是因为这样,对继承的态度也可以大致分为两类。大多数人认为,继承和多态、封装等特性一样,属于面向对象编程的几大核心特征之一。而同时有另一部分人觉得,继承带来的 [坏处远比好处多](https://www.javaworld.com/article/2073649/why-extends-is-evil.html)。甚至在 Go 这门相对年轻的编程语言里,设计者直接去掉了继承,提倡完全使用组合来替代。
从我个人的编程经验来看,继承确实极易被误用。要设计出合理的继承关系,是一件需要深思熟虑的困难事儿。不过幸运的是,在这方面,"里氏替换原则"*(后简称 L 原则)* 为我们提供了非常好的指导意义。
让我们来看看它的内容。
## L里氏替换原则
同前面的 S 与 O 两个原则的命名方式不同,里氏替换原则 *Liskov Substitution Principle* 是直接用它的发明者 [Barbara Liskov](https://en.wikipedia.org/wiki/Barbara_Liskov) 命名的,原文看起来像一个复杂的数学公式:
> Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.
> - 出处: [Liskov substitution principle - Wikipedia](https://en.wikipedia.org/wiki/Liskov_substitution_principle)
如果把它比较通俗的翻译过来,大概是这样:**当你使用继承时,子类(派生类)对象应该可以在程序中替代父类(基类)对象使用,而不破坏程序原本的功能。**
光说有点难理解,让我们用代码来看看一个在 Python 中违反 Liskov 原则的例子。
## 一个违反 L 原则的样例
假设我们在为一个 Web 站点设计用户模型。这个站点的用户分为两类:普通用户和站点管理员。所以在代码里,我们定义了两个用户类:普通用户类 `User` 和管理员类 `Admin`
```python
class User(Model):
"""普通用户模型类
"""
def __init__(self, username: str):
self.username = username
def deactivate(self):
"""停用当前用户
"""
self.is_active = False
self.save()
class Admin(User):
"""管理员用户类
"""
def deactivate(self):
# 管理员用户不允许被停用
raise RuntimeError('admin can not be deactivated!')
```
因为普通用户的绝大多数操作在管理员上都适用,所以我们把 `Admin` 类设计成了继承自 `User` 类的子类。不过在“停用”操作方面,管理员和普通用户之间又有所区别: **普通用户可以被停用,但管理员不行。**
于是在 `Admin` 类里,我们重写了 `deactivate` 方法,使其抛出一个 `RuntimeError` 异常,让管理员对象无法被停用。
子类继承父类,然后重写父类的少量行为,这看上去正是类继承的典型用法。但不幸的是,这段代码违反了“里氏替换原则”。具体是怎么回事呢?让我们来看看。
### 不当继承关系如何违反 L 原则
现在,假设我们需要写一个新函数,它可以同时接受多个用户对象作为参数,批量将它们停用。代码如下:
```python
def deactivate_users(users: Iterable[User]):
"""批量停用多个用户
"""
for user in users:
user.deactivate()
```
很明显,上面的代码是有问题的。因为 `deactivate_users` 函数在参数注解里写到,它接受一切 **可被迭代的 User 对象**,那么管理员 `Admin` 是不是 `User` 对象?当然是,因为它是继承自 `User` 类的子类。
但是,如果你真的把 `[User("foo"), Admin("bar_admin")]` 这样的用户列表传到 `deactivate_users` 函数里,程序立马就会抛出 `RuntimeError` 异常,因为管理员对象 `Admin("bar_admin")` 压根不支持停用操作。
`deactivate_users` 函数看来,子类 `Admin` 无法随意替换父类 `User` 使用,所以现在的代码是不符合 L 原则的。
### 一个简单但错误的解决办法
要修复上面的函数,最直接的办法就是在函数内部增加一个额外的类型判断:
```python
def deactivate_users(users: Iterable[User]):
"""批量停用多个用户
"""
for user in users:
# 管理员用户不支持 deactivate 方法,跳过
if isinstance(user, Admin):
logger.info(f'skip deactivating admin user {user.username}')
continue
user.deactivate()
```
在修改版的 `deactivate_users` 函数里,如果它在循环时恰好发现某个用户是 `Admin` 类,就跳过这次操作。这样它就能正确处理那些混合了管理员的用户列表了。
但是,这样修改的缺点是显而易见的。因为虽然到目前为止,只有 `Admin` 类型的用户不允许被停用。但是,**谁能保证未来不会出现其他不能被停用的用户类型呢?**比如:
- 公司员工不允许被停用
- VIP 用户不允许被停用
- 等等(... ...)
而当这些新需求在未来不断出现时,我们就需要重复的修改 `deactivate_users` 函数,来不断适配这些无法被停用的新用户类型。
```python
def deactivate_users(users: Iterable[User]):
for user in users:
# 在类型判断语句不断追加新用户类型
if isinstance(user, (Admin, VIPUser, Staff)):
... ...
```
现在,让我们再回忆一下前面的 SOLID 第二原则:**“开放-关闭原则”**。这条原则认为:好的代码应该对扩展开发,**对修改关闭**。而上面的函数很明显不符合这条原则。
到这里你会发现,**SOLID 里的每条原则并非完全独立的个体,它们之间其实互有联系。**比如,在这个例子里,我们先是违反了“里氏替换原则”,然后我们使用了错误的修复方式:*增加类型判断*。之后发现,这样的代码同样也无法符合“开放-关闭原则”。
### 正确的修改办法
既然为函数增加类型判断无法让代码变得更好,那我们就应该从别的方面入手。
“里氏替换原则”提到,**子类 *Admin* 应该可以随意替换它的父类 *User*,而不破坏程序 *deactivate_users* 本身的功能**。 我们试过直接修改类的使用者来遵守这条原则,但是失败了。所以这次,让我们试着从源头上解决问题:重新设计类之间的继承关系。
具体点来说,子类不能只是简单通过抛出异常的方式对某个类方法进行“退化”。如果 *“对象不能支持某种操作”* 本身就是这个类型的 **核心特征** 之一,那我们在进行父类设计时,就应该把这个 **核心特征** 设计进去。
拿用户类型举例,*“用户可能无法被停用”* 就是 `User` 类的核心特征之一,所以在设计父类时,我们就应该把它作为类方法 *(或属性)* 写进去。
让我们看看调整后的代码:
```python
class User(Model):
"""普通用户模型类
"""
def __init__(self, username: str):
self.username = username
def allow_deactivate(self) -> bool:
"""是否允许被停用
"""
return True
def deactivate(self):
"""将当前用户停用
"""
self.is_active = True
self.save()
class Admin(User):
"""管理员用户类
"""
def allow_deactivate(self) -> bool:
# 管理员用户不允许被停用
return False
def deactivate_users(users: Iterable[User]):
"""批量停用多个用户
"""
for user in users:
if not user.allow_deactivate():
logger.info(f'user {user.username} does not allow deactivating, skip.')
continue
user.deactivate()
```
在新代码里,我们在父类中增加了 `allow_deactivate` 方法,由它来决定当前的用户类型是否允许被停用。而在 `deactivate_users` 函数中,也不再需要通过脆弱的类型判断,来判定某类用户是否可以被停用。我们只需要调用 `user.allow_deactivate()` 方法,程序便能自动跳过那些不支持停用操作的用户对象。
在这样的设计中,`User` 类的子类 `Admin` 做到了可以完全替代父类使用,而不会破坏程序 `deactivate_users` 的功能。
所以我们可以说,修改后的类继承结构是符合里氏替换原则的。
## 另一种违反方式:子类修改方法返回值
除了上面的例子外,还有一种常见的违反里氏替换原则的情况。让我们看看下面这段代码:
```python
class User(Model):
"""普通用户模型类
"""
def __init__(self, username: str):
self.username = username
def list_related_posts(self) -> List[int]:
"""查询所有与之相关的帖子 ID
"""
return [post.id for post in session.query(Post).filter(username=self.username)]
class Admin(User):
"""管理员用户类
"""
def list_related_posts(self) -> Iterable[int]:
# 管理员与所有的帖子都有关,为了节约内存,使用生成器返回帖子 ID
for post in session.query(Post).all():
yield post.id
```
在这段代码里,我给用户类增加了一个新方法:`list_related_posts`,调用它可以拿到所有和当前用户有关的帖子 ID。对于普通用户方法返回的是自己发布过的所有帖子而管理员则是站点里的所有帖子。
现在,假设我需要写一个函数,来获取和用户有关的所有帖子标题:
```python
def list_user_post_titles(user: User) -> Iterable[str]:
"""获取与用户有关的所有帖子标题
"""
for post_id in user.list_related_posts():
yield session.query(Post).get(post_id).title
```
对于上面的 `list_user_post_titles` 函数来说,无论传入的 `user` 参数是 `User` 还是 `Admin` 类型,它都能正常工作。因为,虽然普通用户和管理员类型的 `list_related_posts` 方法返回结果略有区别,但它们都是 **“可迭代的帖子 ID”**,所以函数里的循环在碰到不同的用户类型时都能正常进行。
既然如此,那上面的代码符合“里氏替换原则”吗?答案是否定的。因为虽然在当前 `list_user_post_titles` 函数的视角看来,子类 `Admin` 可以任意替代父类 `User` 使用,但这只是特殊用例下的一个巧合,并没有通用性。请看看下面这个场景。
有一位新成员最近加入了项目开发,她需要实现一个新函数来获取与用户有关的所有帖子数量。当她读到 `User` 类代码时,发现 `list_related_posts` 方法返回一个包含所有帖子 ID 的列表,于是她就此写下了统计帖子数量的代码:
```python
def get_user_posts_count(user: User) -> int:
"""获取与用户相关的帖子个数
"""
return len(user.list_related_posts())
```
在大多数情况下,当 `user` 参数只是普通用户类时,上面的函数是可以正常执行的。
不过有一天,有其他人偶然使用了一个管理员用户调用了上面的函数,马上就碰到了异常:`TypeError: object of type 'generator' has no len()`。这时因为 `Admin` 虽然是 `User` 类型的子类,但它的 `list_related_posts` 方法返回却是一个可迭代的生成器,并不是列表对象。而生成器是不支持 `len()` 操作的。
所以,对于新的 `get_user_posts_count` 函数来说,现在的用户类继承结构仍然违反了 L 原则。
### 分析类方法返回结果
在我们的代码里,`User` 类和 `Admin` 类的 `list_related_posts` 返回的是两类不同的结果:
- `User 类`:返回一个包含帖子 ID 的列表对象
- `Admin 类`:返回一个产生帖子 ID 的生成器
很明显,二者之间存在共通点:它们都是可被迭代的 int 对象(`Iterable[int]`)。这也是为什么对于第一个获取用户帖子标题的函数来说,两个用户类可以互相交换使用的原因。
不过,针对某个特定函数,子类可以替代父类使用,并不等同于代码就符合“里氏替换原则”。要符合 L 原则,**我们一定得让子类方法和父类返回同一类型的结果,支持同样的操作。或者更进一步,返回支持更多种操作的子类型结果也是可以接受的。**
而现在的设计没做到这点,现在的子类返回值所支持的操作,只是父类的一个子集。`Admin` 子类的 `list_related_posts` 方法所返回的生成器,只支持父类 `User` 返回列表里的“迭代操作”,而不支持其他行为(比如 `len()`)。所以我们没办法随意的用子类替换父类,自然也就无法符合里氏替换原则。
> 注意:此处说“生成器”支持的操作是“列表”的子集其实不是特别严谨,因为生成器还支持 `.send()` 等其他操作。不过在这里,我们可以只关注它的可迭代特性。
### 如何修改代码
为了让代码符合“里氏替换原则”。我们需要让子类和父类的同名方法,返回同一类结果。
```python
class User(Model):
"""普通用户模型类
"""
def __init__(self, username: str):
self.username = username
def list_related_posts(self) -> Iterable[int]:
"""查询所有与之相关的帖子 ID
"""
for post in session.query(Post).filter(username=self.username):
yield post.id
def get_related_posts_count(self) -> int:
"""获取与用户有关的帖子总数
"""
value = 0
for _ in self.list_related_posts():
value += 1
return value
class Admin(User):
"""管理员用户类
"""
def list_related_posts(self) -> Iterable[int]:
# 管理员与所有的帖子都有关,为了节约内存,使用生成器返回
for post in session.query(Post).all():
yield post.id
```
而对于“获取与用户有关的帖子总数”这个需求,我们可以直接在父类 `User` 中定义一个 `get_related_posts_count` 方法,遍历帖子 ID统计数量后返回。
### 方法参数与 L 原则
除了子类方法返回不一致的类型以外,子类对父类方法参数的变更也容易导致违反 L 原则。拿下面这段代码为例:
```python
class User(Model):
def list_related_posts(self, include_hidden: bool = False) -> List[int]:
# ... ...
class Admin(User):
def list_related_posts(self) -> List[int]:
# ... ...
```
如果父类 `User``list_related_posts` 方法接收一个可选的 `include_hidden` 参数,那它的子类就不应该去掉这个参数。否则当某个函数调用依赖了 `include_hidden` 参数,但用户对象却是子类 `Admin` 类型时,程序就会报错。
为了让代码符合 L 原则,我们必须做到 **让子类的方法参数签名和父类完全一致,或者更宽松**。这样才能做到在任何使用参数调用父类方法的地方,随意用子类替换。
比如下面这样就是符合 L 原则的:
```python
class User(Model):
def list_related_posts(self, include_hidden: bool = False) -> List[int]:
# ... ...
class Admin(User):
def list_related_posts(self, include_hidden: bool = False, active_only = True) -> List[int]:
# 子类可以为方法增加额外的可选参数active_only
# ... ...
```
## 总结
在这篇文章里,我通过两个具体场景,向你描述了 “SOLID” 设计原则中的第三位成员:**里氏替换原则**。
“里氏替换原则”是一个非常具体的原则,它专门为 OOP 里的继承场景服务。当你设计类继承关系,尤其是编写子类代码时,请经常性的问自己这个问题:*“如果我把项目里所有使用父类的地方换成这个子类,程序是否还能正常运行?”*
如果答案是否定的,那么你就应该考虑调整一下现在的类设计了。调整方式有很多种,有时候你得把大类拆分为更小的类,有时候你得调换类之间的继承关系,有时候你得为父类添加新的方法和属性,就像文章里的第一个场景一样。只要开动脑筋,总会找到合适的办法。
让我们最后再总结一下吧:
- **“L里氏替换原则”** 认为子类应该可以任意替换父类被使用
- 在类的使用方增加具体的类型判断(*isinstance*),通常不是最佳解决方案
- 违反里氏替换原则,通常也会导致违反“开放-关闭”原则
- 考虑什么是类的核心特征,然后为父类增加新的方法和属性可以帮到你
- 子类方法应该和父类同名方法返回同一类型,或者返回支持更多操作的子类型也行
- 子类的方法参数应该和父类同名方法完全一致,或者更为宽松
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【14.写好面向对象代码的原则(下)】](14-write-solid-python-codes-part-3.md)
[<<<上一篇【12.写好面向对象代码的原则(上)】](12-write-solid-python-codes-part-1.md)
## 附录
- 题图来源: Photo by NeONBRAND on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:写好面向对象代码的原则(上)](https://www.zlovezl.cn/articles/write-solid-python-codes-part-1/)
- [Python 工匠:编写地道循环的两个建议](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)
- [Python 工匠:高效操作文件的三个建议](https://www.zlovezl.cn/articles/three-tips-on-writing-file-related-codes/)

View File

@ -1,550 +0,0 @@
# Python 工匠:写好面向对象代码的原则(下)
## 前言
> 这是 “Python 工匠”系列的第 14 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/carolina-garcia-tavizon-w1280.jpg" width="100%" />
</div>
在这篇文章中,我将继续介绍 SOLID 原则剩下的两位成员:**I接口隔离原则** 和 **D依赖倒置原则**。为了方便,这篇文章将会使用先 D 后 I 的顺序。
## D依赖倒置原则
软件是由一个个模块组合而成的。当你跟别人说:*“我在写一个很复杂的软件”*,其实你并不是直接在写那个软件,你只是在编写它的一个个模块,最后把它们放在一起组合成你的软件。
有了模块,模块间自然就有了依赖关系。比如,你的个人博客可能依赖着 Flask 框架,而 Flask 又依赖了 WerkzeugWerkzeug 又由更多个低层模块组成。
依赖倒置原则Dependency Inversion Principle就是一条和依赖关系相关的原则。它认为**“高层模块不应该依赖于低层模块,二者都应该依赖于抽象。”**
> High-level modules should not depend on low-level modules. Both should depend on abstractions.
这个原则看上去有点反直觉。毕竟,在我们的第一堂编程课上,老师就是这么教我们写代码的:*“高层模块要依赖低层模块hello world 程序依赖 printf()。”* 那为什么这条原则又说不要这样做呢?而依赖倒置原则里的“倒置”又是指什么?
让我们先把这些问题放在一边,看看下面这个小需求。上面这些问题的答案都藏在这个需求中。
### 需求:按域名分组统计 HN 新闻数量
这次出场的还是我们的老朋友:新闻站点 [Hacker News](https://news.ycombinator.com/)。在 HN 上,每个用户提交的条目标题后面,都跟着这条内容的来源域名。
我想要按照来源域名来分组统计条目数量,这样就能知道哪个站在 HN 上最受欢迎。
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/SOLID_3_hn.jpg" width="100%" />
Hacker News 条目来源截图
</div>
这个需求非常简单,使用 `requests`、`lxml` 模块可以很快完成任务:
```python
# file: hn_site_grouper.py
import requests
from lxml import etree
from typing import Dict
from collections import Counter
class SiteSourceGrouper:
"""对 HN 页面的新闻来源站点进行分组统计
"""
def __init__(self, url: str):
self.url = url
def get_groups(self) -> Dict[str, int]:
"""获取 (域名, 个数) 分组
"""
resp = requests.get(self.url)
html = etree.HTML(resp.text)
# 通过 xpath 语法筛选新闻域名标签
elems = html.xpath('//table[@class="itemlist"]//span[@class="sitestr"]')
groups = Counter()
for elem in elems:
groups.update([elem.text])
return groups
def main():
groups = SiteSourceGrouper("https://news.ycombinator.com/").get_groups()
# 打印最常见的 3 个域名
for key, value in groups.most_common(3):
print(f'Site: {key} | Count: {value}')
if __name__ == '__main__':
main()
```
代码执行结果:
```bash
python hn_sitestr_grouper.py
Site: github.com | Count: 2
Site: howonlee.github.io | Count: 1
Site: latimes.com | Count: 1
```
这段代码很短,核心代码总共不到 20 行。现在,让我们来理一理它里面的依赖关系。
`SiteSourceGrouper` 是我们的核心类。为了完成任务,它需要使用 `requests` 模块获取首页内容、`lxml` 模块解析标题。所以,现在的依赖关系是“正向”的,高层模块 `SiteSourceGrouper` 依赖低层模块 `requests`、`lxml`。
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/SOLID_D_before.png" width="100%" />
SiteSourceGrouper 依赖 requests、lxml
</div>
也许现在这张图在你眼里看起来特别合理。正常的依赖关系不就应该是这样的吗?别着急,我们还没给代码写单元测试呢。
### 为 SiteSourceGrouper 编写单元测试
现在让我来为这段代码加上单元测试。首先让最普通的情况开始:
```python
from hn_site_grouper import SiteSourceGrouper
from collections import Counter
def test_grouper_returning_valid_types():
"""测试 get_groups 是否返回了正确类型
"""
grouper = SiteSourceGrouper('https://news.ycombinator.com/')
result = grouper.get_groups()
assert isinstance(result, Counter), "groups should be Counter instance"
```
这是一个再简单不过的单元测试,我调用了 `SiteSourceGrouper.get_groups()` 方法,然后简单校验了一下返回结果类型是否正常。
这个测试在本地电脑上执行时没有一点问题,可以正常通过。但当我在服务器上执行这段单元测试代码时,却发现它根本没办法成功。因为 **我的服务器不能访问外网。**
```python
# 运行单元测试时提示网络错误
requests.exceptions.ConnectionError: HTTPSConnectionPool(host='news.ycombinator.com', port=443): ... ... [Errno 8] nodename nor servname provided, or not known'))
```
到这里,单元测试暴露了 `SiteSourceGrouper` 类的一个问题:*它的核心逻辑依赖 requests 模块和网络连接,严格限制了单元测试的执行条件。*
既然如此,那要如何解决这个问题呢?如果你去问一个有经验的 Python 的开发者,十有八九他会甩给你一句话:**“用 mock 啊!”**
#### 使用 mock 模块
[mock](https://docs.python.org/3/library/unittest.mock.html) 是 unittest 里的一个模块,同时也是一类测试手法的统称。假如你需要测试的模块里有一部分依赖很难被满足*(比如代码需要访问一整套 Kubernetes 集群)*,或者你想在测试时故意替换掉某些依赖,那么 mock 就能派上用场。
在这个例子里,使用 unittest.mock 模块需要做下面这些事情:
- 把一份正确的 HN 页面内容保存为本地文件 `static_hn.html`
- 在测试文件中导入 `unittest.mock` 模块
- 在测试函数中,通过 [`mock.path('requests.get')`](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch) 替换网络请求部分
- 将其修改为直接返回文件 `static_hn.html` 的内容
使用 mock 后的代码看起来是这样的:
```python
from unittest import mock
def test_grouper_returning_valid_types():
"""测试 get_groups 是否返回了正确类型
"""
resp = mock.Mock()
# Mock 掉 requests.get 函数
with mock.patch('hn_site_grouper.requests.get') as mocked_get:
mocked_get.return_value = resp
with open('static_hn.html', 'r') as fp:
# Mock 掉响应的 text 字段
resp.text = fp.read()
grouper = SiteSourceGrouper('https://news.ycombinator.com/')
result = grouper.get_groups()
assert isinstance(result, Counter), "groups should be Counter instance"
```
上面的代码并不算复杂。对于 Python 这类动态语言来说,使用 mock 有着一种得天独厚的优势。因为在 Python 里,运行时的一切对象几乎都可以被替换掉。
不过虽然 mock 用起来很方便,但它不是解决我们问题的最佳做法。因为 mock 在带来方便的同时,也让测试代码变得更复杂和难以理解。而且,给测试加上 mock 也仅仅只是让我的单元测试能够跑起来,糟糕设计仍然是糟糕设计。它无法体现出单元测试最重要的价值之一:**“通过编写测试反向推动设计改进”**。
所以,我们需要做的是改进依赖关系,而不只是简单的在测试时把依赖模块替换掉。如何改进依赖关系?让我们看看“依赖倒置”是如何做的。
### 实现依赖倒置原则
首先,让我们重温一下“依赖倒置原则”*(后简称 D 原则)* 的内容:**“高层模块不应该依赖于低层模块,二者都应该依赖于抽象。”**
在上面的代码里,高层模块 `SiteSourceGrouper` 就直接依赖了低层模块 `requests`。为了让代码符合 D 原则,我们首先需要创造一个处于二者中间的抽象,然后让两个模块可以都依赖这个新的抽象层。
创建抽象的第一步 *(可能也是最重要的一步)*,就是确定这个抽象层的职责。在例子中,高层模块主要依赖 `requests` 做了这些事:
- 通过 `requests.get()` 获取 response
- 通过 `response.text` 获取响应文本
所以,这个抽象层的主要职责就是产生 HN 站点的页面文本。我们可以给它起个名字:`HNWebPage`。
确定了抽象层的职责和名字后,接下来应该怎么实现它呢?在 Java 或 Go 语言里,标准答案是定义 **Interface**(接口)。因为对于这些编程语言来说,“接口”这两个字基本就可以等同于“抽象”。
拿 Go 来说“Hacker News 站点页面”这层抽象就可以被定义成这样的 Interface
```go
type HNWebPage interface {
// GetText 获取页面文本
GetText() (string, error)
}
```
不过Python 根本没有接口这种东西。那该怎么办呢?虽然 Python 没有接口,但是有一个非常类似的东西:**“抽象类Abstrace Class”**。使用 [`abc`](https://docs.python.org/3/library/abc.html) 模块就可以轻松定义出一个抽象类:
```python
from abc import ABCMeta, abstractmethod
class HNWebPage(metaclass=ABCMeta):
"""抽象类Hacker New 站点页面
"""
@abstractmethod
def get_text(self) -> str:
raise NotImplementedError
```
抽象类和普通类的区别之一就是你不能将它实例化。如果你尝试实例化一个抽象类,解释器会报出下面的错误:
```python
TypeError: Can't instantiate abstract class HNWebPage with abstract methods get_text
```
所以,光有抽象类还不能算完事,我们还得定义几个依赖这个抽象类的实体。首先定义的是 `RemoteHNWebPage` 类。它的作用就是通过 requests 模块请求 HN 页面,返回页面内容。
```python
class RemoteHNWebPage(HNWebPage):
"""远程页面,通过请求 HN 站点返回内容"""
def __init__(self, url: str):
self.url = url
def get_text(self) -> str:
resp = requests.get(self.url)
return resp.text
```
定义了 `RemoteHNWebPage` 类后,`SiteSourceGrouper` 类的初始化方法和 `get_groups` 也需要做对应的调整:
```python
class SiteSourceGrouper:
"""对 HN 页面的新闻来源站点进行分组统计
"""
def __init__(self, page: HNWebPage):
self.page = page
def get_groups(self) -> Dict[str, int]:
"""获取 (域名, 个数) 分组
"""
html = etree.HTML(self.page.get_text())
# 通过 xpath 语法筛选新闻域名标签
elems = html.xpath('//table[@class="itemlist"]//span[@class="sitestr"]')
groups = Counter()
for elem in elems:
groups.update([elem.text])
return groups
def main():
# 实例化 page传入 SiteSourceGrouper
page = RemoteHNWebPage(url="https://news.ycombinator.com/")
grouper = SiteSourceGrouper(page).get_groups()
```
做完这些修改后,让我们再看看现在的模块依赖关系:
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/SOLID_D_after.png" width="100%" />
SiteSourceGrouper 和 RemoteHNWebPage 都依赖抽象层 HNWebPage
</div>
在图中,高层模块不再依赖低层模块,二者同时依赖于抽象概念 `HNWebPage`,低层模块的依赖箭头和之前相比倒过来了。所以我们称其为 **依赖倒置**
### 依赖倒置后的单元测试
再回到之前的单元测试上来。通过引入了新的抽象层 `HNWebPage`,我们可以实现一个不依赖外部网络的新类型 `LocalHNWebPage`
```python
class LocalHNWebPage(HNWebPage):
"""本地页面,根据本地文件返回页面内容"""
def __init__(self, path: str):
self.path = path
def get_text(self) -> str:
with open(self.path, 'r') as fp:
return fp.read()
```
所以,单元测试也可以改为使用 `LocalHNWebPage`
```python
def test_grouper_from_local():
page = LocalHNWebPage(path="./static_hn.html")
grouper = SiteSourceGrouper(page)
result = grouper.get_groups()
assert isinstance(result, Counter), "groups should be Counter instance"
```
这样就可以在没有外网的服务器上测试 `SiteSourceGrouper` 类的核心逻辑了。
> Hint其实上面的测试函数 `test_grouper_from_local` 远远算不上一个合格的测试用例。
>
> 如果真要测试 `SiteSourceGrouper` 的核心逻辑。我们应该准备一个虚构的 Hacker News 页面 *(比如刚好包含 5 个 来源自 github.com 的条目)*,然后判断结果是否包含 `assert result['github.com] == 5`
### 问题:一定要使用抽象类 abc 吗?
为了实现依赖倒置,我们在上面定义了抽象类:`HNWebPage`。那是不是只有定义了抽象类才能实现依赖倒置?只有用了抽象类才算是依赖倒置呢?
**答案是否定的。** 如果你愿意,你可以把代码里的抽象类 `HNWebPage` 以及所有的相关引用都删掉,你会发现没有它们代码仍然可以正常运行。
这是因为 Python 是一门“鸭子类型”语言。这意味着只要 `RemoteHNWebPage``LocalHNWebPage` 类型保持着统一的接口协议*(提供 .get_text() 公开方法)*,并且它们的 **协议符合我们定义的抽象**。那么那个中间层就存在,依赖倒置就是成立的。至于这份 **协议** 是通过抽象类还是普通父类(甚至可以是普通函数)定义的,就没那么重要了。
所以,虽然在某些编程语言中,实现依赖倒置必须得定义新的接口类型,但在 Python 里,依赖倒置并不是抽象类 abc 的特权。
### 问题:抽象一定是好东西吗?
前面的所有内容,都是在说新增一个抽象层,然后让依赖关系倒过来的种种好处。所以,多抽象的代码一定就是好的吗?缺少抽象的代码就一定不够灵活?
和所有这类问题的标准回答一样,答案是:**视情况而定。**
当你习惯了依赖倒置原则以后,你会发现 *抽象Abstract* 其实是一种思维方式,而不仅仅是一种编程手法。如果你愿意,你可以在代码里的所有地方都 **硬挤** 一层额外抽象出来:
- 比如代码依赖了 lxml 模块的 xpath 具体实现,我是不是得定义一层 *“HNTitleDigester”* 把它抽象进去?
- 比如代码里的字符串字面量也是具体实现,我是不是得定义一个 *"StringLike"* 类型把它抽象进去?
- ... ...
事实上,抽象的好处显而易见:**它解耦了高层模块和低层模块间的依赖关系,让代码变得更灵活。** 但抽象同时也带来了额外的编码与理解成本。所以,了解何时 **不** 抽象与何时抽象同样重要。**只有对代码中那些现在或未来会发生变化的东西进行抽象,才能获得最大的收益。**
## I接口隔离原则
接口隔离原则*(后简称 I 原则)*全称为 *“Interface Segregation Principles”*。顾名思义它是一条和“接口Interface”有关的原则。
我在前面解释过何为“接口Interface”。**接口是模块间相互交流的抽象协议**,它在不同的编程语言里有着不同的表现形态。比如在 Go 里它是 `type ... interface`,而在 Python 中它可以是抽象类、普通类或者函数,甚至某个只在你大脑里存在的一套协议。
I 原则认为:**“客户client应该不依赖于它不使用的方法”**
> The interface-segregation principle (ISP) states that no client should be forced to depend on methods it does not use.
这里说的“客户Client”指的是接口的使用方 *(客户程序)*,也就是调用接口方法的高层模块。拿上一个统计 HN 页面条目的例子来说:
- `使用方(客户程序)`SiteSourceGrouper
- `接口(其实是抽象类)`HNWebPage
- `依赖关系`:调用接口方法:`get_text()` 获取页面文本
在 I 原则看来,**一个接口所提供的方法,应该就是使用方所需要的方法,不多不少刚刚好。** 所以,在上个例子里,我们设计的接口 `HNWebPage` 是符合接口隔离原则的。因为它没有向使用方提供任何后者不需要的方法 。
> 你需要 get_text()!我提供 get_text()!刚刚好!
所以,这条原则看上去似乎很容易遵守。既然如此,让我们试试来违反它吧!
### 例子:开发页面归档功能
让我们接着上一个例子开始。在实现了上个需求后,我现在有一个代表 Hacker News 站点页面的抽象类 `HNWebPage`,它只提供了一种行为,就是获取当前页面的文本内容。
```python
class HNWebPage(metaclass=ABCMeta):
@abstractmethod
def get_text(self) -> str:
"""获取页面文本内容"""
```
现在,假设我要开发一个和 HN 页面有关的新功能: **我想在不同时间点对 HN 首页内容进行归档,观察热点新闻在不同时间点发生的变化。** 所以除了页面文本内容外,我还需要拿到页面的大小、生成时间这些额外信息,然后将它们都保存到数据库中。
为了做到这一点,现在的 `HNWebPage` 类需要被扩展一下:
```python
class HNWebPage(metaclass=ABCMeta):
@abstractmethod
def get_text(self) -> str:
"""获取页面文本内容"""
# 新增 get_size 与 get_generated_at
@abstractmethod
def get_size(self) -> int:
"""获取页面大小"""
@abstractmethod
def get_generated_at(self) -> datetime.datetime:
"""获取页面生成时间"""
```
我在原来的类上增加了两个新的抽象方法:`get_size` 和 `get_generated_at`。这样归档程序就能通过它们拿到页面大小和生成时间了。
改完抽象类后,紧接着的任务就是修改依赖它的实体类。
### 问题:实体类不符合 HNWebPage 接口规范
在修改抽象类前,我们有两个实现了它协议的实体类:`RemoteHNWebPage` 和 `LocalHNWebPage`。如今,`HNWebPage` 增加了两个新方法 `get_size``get_generated_at`。我们自然需要把这两个实体类也加上这两个方法。
`RemoteHNWebPage` 类的修改很好做,我们只要让 `get_size` 放回页面长度,让 `get_generated_at` 返回当前时间就行了。
```python
# class RemoteHNWebPage:
#
def get_generated_at(self) -> datetime.datetime:
# 页面生成时间等同于通过 requests 请求的时间
return datetime.datetime.now()
```
但是,在给 `LocalHNWebPage` 添加 `get_generated_at` 方法时,我碰到了一个问题。`LocalHNWebPage` 是一个完全基于本地页面文件作为数据来源的类,仅仅通过 “static_hn.html” 这么一个本地文件,我根本就没法知道它的内容是什么时候生成的。
这时我只能选择让它的 `get_generated_at` 方法返回一个错误的结果 *(比如文件的修改时间)*,或者直接抛出异常。无论是哪种做法,我都可能违反 [里式替换原则](https://www.zlovezl.cn/articles/write-solid-python-codes-part-2/)。
> Hint里式替换原则认为子类派生类对象应该可以在程序中替代父类基类对象使用而不破坏程序原本的功能。让方法抛出异常显然破坏了这一点。
```python
# class LocalHNWebPage:
#
def get_generated_at(self) -> datetime.datetime:
raise NotImplementedError("local web page can not provide generate_at info")
```
所以,对现有接口的盲目扩展暴露出来一个问题:**更多的接口方法意味着更高的实现成本,给实现方带来麻烦的概率也变高了。**
不过现在让我们暂且把这个问题放到一边,继续写一个 `SiteAchiever` 类完成归档任务:
```python
class SiteAchiever:
"""将不同时间点的 HN 页面归档"""
def save_page(self, page: HNWebPage):
"""将页面保存到后端数据库
"""
data = {
"content": page.get_text(),
"generated_at": page.get_generated_at(),
"size": page.get_size(),
}
# 将 data 保存到数据库中
```
### 成功违反 I 协议
代码写到这,让我们回头看看上个例子里的 *条目来源分组类 `SiteSourceGrouper`*
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/SOLID_I_before.png" width="100%" />
图:成功违反了 I 协议
</div>
当我修改完抽象类后,虽然 `SiteSourceGrouper` 仍然依赖着 `HNWebPage`,但它其实只使用了 `get_text` 这一个方法而已,其他 `get_size`、`get_generated` 这些它 **不使用的方法也成为了它的依赖。**
很明显,现在的设计违反了接口隔离原则。为了修复这一点,我们需要将 `HNWebPage` 拆成更小的接口。
### 如何分拆接口
设计接口有一个技巧:**让客户(调用方)来驱动协议设计**。让我们来看看,`HNWebPage` 到底有哪些客户:
- `SiteSourceGrouper`:域名来源统计,依赖 `get_text()`
- `SiteAchiever`HN 页面归档程序,依赖 `get_text()`、`get_size()`、`get_generated_at()`
按照上面的方式,我们可以把 `HNWebPage` 分离成两个独立的抽象类:
```python
class ContentOnlyHNWebPage(metaclass=ABCMeta):
"""抽象类Hacker New 站点页面(仅提供内容)
"""
@abstractmethod
def get_text(self) -> str:
raise NotImplementedError
class HNWebPage(ContentOnlyHNWebPage):
"""抽象类Hacker New 站点页面(含元数据)
"""
@abstractmethod
def get_size(self) -> int:
"""获取页面大小"""
@abstractmethod
def get_generated_at(self) -> datetime.datetime:
"""获取页面生成时间"""
```
将旧类拆分成两个不同的抽象类后,`SiteSourceGrouper` 和 `SiteAchiever` 就可以分别依赖不同的抽象类了。
同时,对于 `LocalHNWebPage` 类来说,它也只需要实现那个只返回的文本的 `ContentOnlyHNWebPage` 就行。
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/02/SOLID_I_after.png" width="100%" />
图:实施接口隔离后的结果
</div>
### 一些不容易发现的违反情况
虽然我花了很长的篇幅,用了好几个抽象类才把接口隔离原则讲明白,但其实在我们的日常编码中,对这条原则的违反经常会出现在一些更容易被忽视的地方。
举个例子,当我们在 web 站点里判断用户请求的 Cookies 或头信息是否包含某个标记值时,我们经常直接写一个依赖整个 `request` 对象的函数:
```python
def is_new_visitor(request: HttpRequest) -> bool:
"""从 Cookies 判断是否新访客
"""
return request.COOKIES.get('is_new_visitor') == 'y'
```
但事实上,除了 `.COOKIES` 以外,`is_new_visitor` 根本就不需要 `request` 对象里面的任何其他内容。“用户请求对象request”是一个比“Cookie 字典request.COOKIES”复杂得多的抽象。我们完全可以把函数改成只接收 cookies 字典。
```python
def is_new_visitor(cookies: Dict) -> bool:
"""从 Cookies 判断是否新访客
"""
return cookies.get('is_new_visitor') == 'y'
```
类似的情况还有很多,比如一个发短信的函数本身只需要两个参数 `电话号码``用户姓名`,但是函数却依赖了整个用户对象 `User`,里面包含着几十个用不上的其他字段和方法。
对于这类函数,我们都可以重新考虑一下它们的抽象是否合理,是否需要应用接口隔离原则。
### 现实世界中的接口隔离
当你知道了接口隔离原则的种种好处后,你很自然就会养成写小类、小接口的习惯。在现实世界里,其实已经有很多小而精的接口设计可以供你参考。比如:
- Python 的 [collections.abc](https://docs.python.org/3/library/collections.abc.html) 模块里面有非常多的小接口
- Go 里面的 [Reader 和 Writer](https://golang.org/pkg/io/#Reader) 也是非常好的例子
## 总结
在这篇文章里,我向你介绍了 SOLID 原则的最后两位成员:**“依赖倒置原则”** 与 **“接口隔离原则”**。
这两条原则之间有一个共同点,那就是它们都和 **“抽象”** 有着紧密的联系。前者告诉我们要面向抽象而非实现编程,后者则教导我们在设计抽象时应该做到精准。
最后再总结一下:
- **“D依赖倒置原则”** 认为高层模块和低层模块都应该依赖于抽象
- 依赖抽象,意味着我们可以完全修改低层实现,而不影响高层代码
- 在 Python 中你可以使用 abc 模块来定义抽象类
- 除 abc 外,你也可以使用其他技术来完成依赖倒置
- **“I接口隔离原则”** 认为客户不应该依赖任何它不使用的方法
- 设计接口就是设计抽象
- 违反接口隔离原则也可能会导致违反单一职责与里式替换原则
- 写更小的类、写更小的接口在大多数情况下是个好主意
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【15.在边界处思考】](15-thinking-in-edge-cases.md)
[<<<上一篇【13.写好面向对象代码的原则(中)】](13-write-solid-python-codes-part-2.md)
## 附录
- 题图来源: Photo by Carolina Garcia Tavizon on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:写好面向对象代码的原则(上)](https://www.zlovezl.cn/articles/write-solid-python-codes-part-1/)
- [Python 工匠:写好面向对象代码的原则(中)](https://www.zlovezl.cn/articles/write-solid-python-codes-part-2/)
- [Python 工匠:写好面向对象代码的原则(下)](https://www.zlovezl.cn/articles/write-solid-python-codes-part-3/)

View File

@ -1,475 +0,0 @@
# Python 工匠:在边界处思考
## 前言
> 这是 “Python 工匠”系列的第 15 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2020/06/jessica-ruscello-DoSDQvzjeH0-unsplash_w1440.jpg" width="100%" />
</div>
2016 年Linux 操作系统的创造者 Linus Torvalds 参加了一场[ TED 访谈节目](https://www.ted.com/talks/linus_torvalds_the_mind_behind_linux/transcript?language=en)。整个节目的前半部分,主要是他在讲如何在家光着膀子写出 Linux 的故事,没有涉及太多编程相关的事情。
不过在访谈快结束时,突然出现了一个有趣的环节。主持人向 Linus 提问道:“你曾说过更愿意和那些有着好的 **代码品味** 的人共事,那在你眼里,什么才是好的代码品味?”
为了解释这个问题Linus 在大屏幕上展示了一份代码。我把其摘抄如下。
```c
remove_list_entry(entry) {
prev = NULL;
walk = head;
// 遍历链表
while (walk != entry) {
prev = walk;
walk = walk->next;
}
// 关键:当要删除时,判断当前位置是否在链表头部进行不同的动作
if (!prev)
head = entry->next;
else
prev->next = entry->next;
}
```
函数 `remove_list_entry` 的主要功能是通过遍历链表,删除里面的某个成员。但在这份代码中,存在一个 **[边界情况Edge Case](https://en.wikipedia.org/wiki/Edge_case)**。
在编程时,“边界情况”是指那些只在极端情景下出现的情况。比如在上面的代码里,当我们要找的元素刚好处于链表头部时,就是一个边界情况。为了处理它,函数在删除前进行了一次 `if / else` 判断。
Linus 认为这条 if 语句是整段代码的“坏味道”来源,写出它的人代码品味不够好 ☹️。那么,一个品味更好的人应该怎么写呢?很快,屏幕上出现了第二份代码。
```c
remove_list_entry(entry) {
indirect = &head
// 遍历链表过程代码已省略
// 当要删除时,直接进行指针操作删除
*indirect = entry->next
}
```
在新代码中,`remove_list_entry` 函数利用了 C 语言里的指针特性,把之前的 `if / else` 完全消除了。无论待删除的目标是在链表头部还是中间,函数都能一视同仁的完成删除操作。之前的边界情况消失了。
看到这你是不是在犯嘀咕:*Python 又没有指针,你跟我说这么多指针不指针的干啥?* 虽然 Python 没有指针,但我觉得这个例子为我们提供了一个很有趣的主题。那就是 **如何充分利用语言特性,更好的处理编码时的边界情况。**
我认为,好代码在处理边界情况时应该是简洁的、“润物细无声”的。就像上面的例子一样,可以做到让边界情况消融在代码主流程中。在写 Python 时,有不少编码技巧和惯例可以帮我们做到这一点,一块来看看吧。
## 第一课:使用分支还是异常?
今天周末,你计划参加朋友组织的聚餐,临出门时突然想起来最近是雨季。于是你掏出手机打开天气 App看看今天是不是会下雨。如果下雨就带上一把伞再出门。
假如把“今天下雨”类比成编程时的 *边界情况*,那“看天气预报 + 带伞”就是我们的边界处理代码。这种 `if 下雨 then 带伞` 的分支式判断,基本是一种来自直觉的思考本能。所以,当我们在编程时发现边界情况时,第一反应往往就是:**“弄个 if 分支把它包起来吧!”**。
比如下面这段代码:
```python
def counter_ap(l):
"""计算列表里面每个元素出现的数量"""
result = {}
for key in l:
# 主流程:累加计数器
if key in result:
result[key] += 1
# **边界情况:当元素第一次出现时,先初始化值为 1**
else:
result[key] = 1
return result
# 执行结果:
print(counter_ap(['apple', 'banana', 'apple']))
{'apple': 2, 'banana': 1}
```
在上面的循环里,代码的主流程是*“对每个 key 的计数器加 1”*。但是,当 result 字典里还没有 `key` 元素时,是不能直接进行累加操作的(会抛出 `KeyError`)。
```python
>>> result = {}
>>> result['foo'] += 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'foo'
```
于是一个边界情况出现了:当元素第一次出现时,我们需要对值进行初始化。
所以,我专门写了一条 `if` 语句去处理这个边界情况。代码简单,无需过多解释。但你可能不知道的是,其实有一个术语来专门描述这种编程风格:**“LBYLLook Before You Leap”**。
“LBYL” 这缩写不太好翻译。用大白话讲,就是在进行操作前,先对可能的边界情况进行条件判断。根据结果不同,确定是处理边界情况,还是执行主流程。
如之前所说,使用 “LBYL” 来处理边界情况,几乎是一种直觉式的行为。*“有边界情况,就加上 if 分支”*和*“如果天气预报说下雨,我就带伞出门”*一样,是一种基本不需要过脑子的操作。
而在 LBYL 之外,有着与其形成鲜明对比的另外一种风格:**“EAFPEasier to Ask for Forgiveness than Permission”**。
### 获取原谅比许可简单(EAFP)
“EAFP” 通常被翻译成“获取原谅比许可简单”。如果还用下雨举例,那 EAFP 的做法就类似于 *“出门前不看任何天气预报,如果淋雨了,就回家后洗澡吃感冒药 💊”*
使用 EAFP 风格的代码是这样的:
```python
def counter_af(l):
result = {}
for key in l:
try:
# 总是直接执行主流程:累加计数器
result[key] += 1
except KeyError:
# 边界情况:当元素第一次出现时会报错 KeyError此时进行初始化
result[key] = 1
return result
```
和 LBYL 相比EAFP 编程风格更为简单粗暴。它总是直奔主流程而去,把边界情况都放在异常处理 `try except` 块内消化掉。
如果你问我:“这两种编程风格哪个更好?”,我只能说整个 Python 社区对基于异常捕获的“请求原谅EAFP”型编程风格有着明显的偏爱。其中的原因有很多。
首先,和许多其他编程语言不同,在 Python 里抛出异常是一个很轻量的操作,即使程序会大量抛出、捕获异常,使用 EAFP 也不会给程序带来额外的负担。
其次,“请求原谅”在性能上通常也更有优势,因为程序总是直奔主流程而去,只有极少数情况下才需要处理边界情况。拿上面的例子来说,第二段代码通常会比第一段更快,因为它不用在每次循环时都做一次额外的成员检查。
> Hint如果你想了解更多这方面的知识建议阅读 [Write Cleaner Python: Use Exceptions](https://jeffknupp.com/blog/2013/02/06/write-cleaner-python-use-exceptions/)
所以,每当你想凭直觉写下 `if else` 来处理边界情况时,先考虑下使用 `try` 来捕获异常是不是更合适。毕竟Pythonista 们总是喜欢“吃感冒药 💊”胜过“看天气预报”。😅
## 当容器内容不存在时
Python 里有很多内建的容器类型,比如字典、列表、集合等等。在进行容器操作时,经常会出现一些边界情况。其中“要访问的内容不存在”,是最为常见的一类:
- 操作字典时,访问的键 `key` 不存在,会抛出 `KeyError` 异常
- 操作列表、元组时,访问的下标 `index` 不存在,会抛出 `IndexError` 异常
对于这类边界情况,除了针对性的捕获对应异常外,还有许多其他处理方式。
### 使用 defaultdict 改写示例
在前面的例子里,我们使用了 `try except` 语句处理了*“key 第一次出现”*这个边界情况。虽然我说过,使用 `try` 的代码比 `if` 更好,但这不代表它就是一份地道的 Python 代码。
为什么?因为如果你想统计列表元素的话,直接用 `collections.defaultdict` 就可以了:
```python
from collections import defaultdict
def counter_by_collections(l):
result = defaultdict(int)
for key in l:
result[key] += 1
return result
```
这样的代码既不用“获取许可”,也无需“请求原谅”。 整个函数只有一个主流程,代码更清晰、更自然。
为什么 `defaultdict` 可以让边界情况消失?因为究其根本,之前的代码就是少了针对 *“键不存在”* 时的默认处理逻辑。所以,当我们用 `defaultdict` 声明了如何处理这个边界情况时,原本需要手动判断的部分就消失了。
> Hint就上面的例子来说使用 [collections.Counter](https://docs.python.org/3/library/collections.html#collections.Counter) 也能达到同样的目的。
### 使用 setdefault 取值并修改
有时候,我们需要操作字典里的某个值,但它又可能并不存在。比如下面这个例子:
```python
# 往字典的 values 键追加新值,假如不存在,先以列表初始化
try:
d['values'].append(value)
except KeyError:
d['values'] = [value]
```
针对这种情况,我们可以使用 **`d.setdefault(key, default=None)`** 方法来简化边界处理逻辑,直接替换上面的异常捕获语句:
```python
# 如果 setdefault 指定的 key此处为 "values")不存在,以 [] 初始化,否则返回已存在
# 的值。
d.setdefault('values', []).append(value)
```
> Hint使用 `defaultdict(list)` 同样可以利索的解决这个问题。
### 使用 dict.pop 删除不存在的键
如果我们要删除字典的某个 `key`,一般会使用 `del` 关键字。但当 `key` 不存在时,删除操作就会抛出 `KeyError` 异常。
所以,想要安全删除某个 `key`,还得加上一段异常捕获逻辑。
```python
try:
del d[key]
except KeyError:
# 忽略 key 不存在的情况
pass
```
但假设只是单纯的想删除某个 `key`,并不关心它是否存在、有没有删成功。使用 `dict.pop(key, default)` 方法就够了。
只要在调用 `dict.pop` 方法时传入默认值,`key` 不存在时就不会抛出异常了。
```python
# 使用 pop 方法,指定 default 值为 None当 key 不存在时,不会报错
d.pop(key, None)
```
> Hint严格来说`pop` 方法的主要用途并不是去删除某个 key而是 **取出** 某个 key 对应的值。不过我觉得偶尔用它来做删除也无伤大雅。
### 当列表切片越界时
所有人都知道,当你的列表*(或元组)*只有 3 个元素,而你想要访问第 4 个时,解释器会报出 `IndexError` 错误。我们通常称这类错误为*“数组越界”*。
```python
>>> l = [1, 2, 3]
>>> l[2]
3
>>> l[3]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range
```
但你可能不知道的是,假如你请求的不是某一个元素,而是一段范围的切片。那么无论你指定的范围是否有效,程序都只会返回一个空列表 `[]`,而不会抛出任何错误:
```python
>>> l = []
>>> l[1000:1001]
[]
```
了解了这点后,你会发现像下面这种边界处理代码根本没有必要:
```python
def sum_list(l, limit):
"""对列表的前 limit 个元素求和
"""
# 如果 limit 过大,设置为数组长度避免越界
if limit > len(l):
limit = len(l)
return sum(l[:limit])
```
因为做切片不会抛出任何错误,所以不需要判断 limit 是否超出范围,直接做 `sum` 操作即可:
```python
def sum_list(l, limit):
return sum(l[:limit])
```
利用这个特点,我们还可以简化一些特定的边界处理逻辑。比如安全删除列表的某个元素:
```python
# 使用异常捕获安全删除列表的第 5 个元素
try:
l.pop(5)
except IndexError:
pass
# 删除从 5 开始的长度为 1 的切片,不需要捕获任何异常
del l[5:6]
```
## 好用又危险的 “or” 操作符
`or` 是一个几乎在所有编程语言里都有的操作符,它在 Python 里通常被用来和 `and` 一起做布尔值逻辑运算。比如:
```python
>>> False or True
True
```
`or` 还有一个有趣的特点是短路求值,比如在下面的例子里,`1 / 0` 永远不会被执行*(也就意味着不会抛出 ZeroDivisionError*
```python
>>> True or (1 / 0)
True
```
在很多场景下,我们可以利用 `or` 的特点来简化一些边界处理逻辑。看看下面这个例子:
```python
context = {}
# 仅当 extra_context 不为 None 时,将其追加进 context 中
if extra_context:
context.update(extra_context)
```
在这段代码里,`extra_context` 的值一般情况下会是一个字典,但有时也可能是 `None`。所以我加了一个条件判断语句,当它的值不为 `None` 时才做 `.update` 操作。
如果使用 `or` 操作符,我们可以让上面的语句更简练:
```python
context.update(extra_context or {})
```
因为 `a or b or c or ...` 这样的表达式,会返回这些变量里第一个布尔值为真的值,直到最后一个为止。所以 `extra_context or {}``extra_context``None` 时其实就等于 `{}`。因此之前的条件判断就可以被简化成一个 `or` 表达式了。
使用 `a or b` 来表示*“ a 为空时用 b 代替”*,这种写法一点也不新鲜。你在各种编程语、各类框架源码源码里都能发现它的影子。但在这个写法下,其实也藏有一个陷阱。
因为 `or` 操作计算的是变量的布尔真假值。所以,不光是 `None`,所有的 0、[]、{}、set() 以及其他所有会被判断为布尔假的东西,都会在 `or` 运算中被忽略。
```python
# 所有的 0、空列表、空字符串等都是布尔假值
>>> bool(None), bool(0), bool([]), bool({}), bool(''), bool(set())
(False, False, False, False, False, False)
```
如果忘记了 `or` 的这个特点,可能会碰到一些很奇怪的问题。比如这段代码:
```python
timeout = config.timeout or 60
```
虽然上面代码的目的,是想要判断当 `config.timeout``None` 时使用 60 做默认值。但假如 `config.timeout` 的值被主动配置成了 `0` 秒,`timeout` 也会因为上面的 `0 or 60 = 60` 运算被重新赋值为 60。正确的配置因此被忽略掉了。
所以,有时使用 `if` 来进行精确的边界处理会更稳妥一些:
```python
if config.timeout is None:
timeout = 60
```
## 不要手动去做数据校验
无数前辈的经验告诉我们:*“不要信任任何用户输入”*。这意味着所有存在用户输入的地方,都必须对其进行校验。那些无效、危险的用户输入值,就是需要我们处理的边界情况。
假如我在写一个命令行小程序,需要让用户输入一个 0-100 范围的数字。要是用户的输入无效,就要求其重新输入。
程序大概长这样:
```python
def input_a_number():
"""要求用户输入一个 0-100 的数字,如果无效则重新输入
"""
while True:
number = input('Please input a number (0-100): ')
# 此处往下的三条 if 语句都是输入值的边界校验代码
if not number:
print('Input can not be empty!')
continue
if not number.isdigit():
print('Your input is not a valid number!')
continue
if not (0 <= int(number) <= 100):
print('Please input a number between 0 and 100!')
continue
number = int(number)
break
print(f'Your number is {number}')
```
执行效果如下:
```python
Please input a number (0-100):
Input can not be empty!
Please input a number (0-100): foo
Your input is not a valid number!
Please input a number (0-100): 65
Your number is 65
```
这个函数一共有 14 行有效代码。其中有 3 段 if 共 9 行代码,都是用于校验的边界值检查代码。也许你觉得这样的检查很正常,但请想象一下,假如需要校验的输入不止一个、校验逻辑也比这个复杂怎么办?那样的话,**这些边界值检查代码就会变得又臭又长。**
如何改进这些代码呢?把它们抽离出去,作为一个校验函数和核心逻辑隔离开是个不错的办法。但更重要的在于,要把 *“输入数据校验”* 作为一个独立的职责与领域,用更恰当的模块来完成这项工作。
在数据校验这块,[pydantic](https://pydantic-docs.helpmanual.io/) 模块是一个不错的选择。如果用它来做校验,代码可以被简化成这样:
```python
from pydantic import BaseModel, conint, ValidationError
class NumberInput(BaseModel):
# 使用类型注解 conint 定义 number 属性的取值范围
number: conint(ge=0, le=100)
def input_a_number_with_pydantic():
while True:
number = input('Please input a number (0-100): ')
# 实例化为 pydantic 模型,捕获校验错误异常
try:
number_input = NumberInput(number=number)
except ValidationError as e:
print(e)
continue
number = number_input.number
break
print(f'Your number is {number}')
```
在日常编码时,我们应该尽量避免去手动校验数据。而是应该使用*(或者自己实现)*合适的第三方校验模块,把这部分边界处理工作抽象出去,简化主流程代码。
> Hint: 假如你在开发 Web 应用,那么数据校验部分通常来说都挺容易。比如 Django 框架有自己的 forms 模块Flask 也可以使用 WTForms 来进行数据校验。
## 不要忘记做数学计算
很多年前刚接触 Web 开发时,我想学着用 JavaScript 来实现一个简单的文字跑马灯动画。如果你不知道啥是“跑马灯”,我可以稍微解释一下。“跑马灯”就是让一段文字从页面左边往右边不断循环滚动,十几年前的网站特别流行这个。😬
我记得里面有一段逻辑是这样的:*控制文字不断往右边移动,当横坐标超过页面宽度时,重置坐标后继续*。我当时写出来的代码,翻译成 Python 大概是这样:
```python
while True:
if element.position_x > page_width:
# 边界情况:当对象位置超过页面宽度时,重置位置到最左边
element.position_x -= page_width
# 元素向右边滚动一个单位宽度
element.position_x += width_unit
```
看上去还不错对不对?我刚写完它时也是这么认为的。但后来有一天,我重新看到它时,才发现其中的古怪之处。
在上面的代码里,我需要在主循环里保证 “element.position_x 不会超过页面宽度 page_width”。所以我写了一个 if 来处理当 `position_x` 超过页面宽度的情况。
但如果是要保证某个累加的数字 *position_x* 不超过另一个数字 *page_width*,直接用 `%` 做取模运算不就好了吗?
```python
while True:
# 使用 % page_width 控制不要超过页面宽度
element.position_x = (element.position_x + width_unit) % page_width
```
这样写的话,代码里的边界情况就连着那行 `if` 语句一起消失了。
和取模运算类似的操作还有很多,比如 `abs()`、`math.floor()` 等等。我们应该记住,不要写出 `if value < 0: value = -value` 这种“边界判断代码”,直接使用 `abs(value)` 就好,不要重新发明绝对值运算。
## 总结
“边界情况Edge cases”是我们在日常编码时的老朋友。但它不怎么招人喜欢毕竟我们都希望自己的代码只有一条主流程贯穿始终不需要太多的条件判断、异常捕获。
但边界情况同时又是无法避免的,只要有代码,边界情况就会存在。所以,如果能更好的处理它们,我们的代码就可以变得更清晰易读。
除了上面介绍的这些思路外,还有很多东西都可以帮助我们处理边界情况,比如利用面向对象的多态特性、使用 [空对象模式](https://github.com/piglei/one-python-craftsman/blob/master/zh_CN/5-function-returning-tips.md#5-%E5%90%88%E7%90%86%E4%BD%BF%E7%94%A8%E7%A9%BA%E5%AF%B9%E8%B1%A1%E6%A8%A1%E5%BC%8F) 等等。
最后再总结一下:
- 使用条件判断和异常捕获都可以用来处理边界情况
- 在 Python 里,我们更倾向于使用基于异常捕获的 EAFP 风格
- 使用 defaultdict / setdefault / pop 可以巧妙的处理当键不存在时的边界情况
- 对列表进行不存在的范围切片不会抛出异常
- 使用 `or` 可以简化默认值边界处理逻辑,但也要注意不要掉入陷阱
- 不要手动去做数据校验,使用 `pydantic` 或其他的数据校验模块
- 利用取模、绝对值计算等方式,可以简化一些特定的边界处理逻辑
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[<<<上一篇【14.写好面向对象代码的原则(下)】](14-write-solid-python-codes-part-3.md)
> 为了避免内容重复,在系列第 4 篇“容器的门道”里出现的 EAPF 相关内容会被删除,并入到本文中。
## 附录
- 题图来源: Photo by Jessica Ruscello on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:写好面向对象代码的原则(上)](https://www.zlovezl.cn/articles/write-solid-python-codes-part-1/)
- [Python 工匠:让函数返回结果的技巧](https://www.zlovezl.cn/articles/function-returning-tips/)

View File

@ -1,397 +0,0 @@
# Python 工匠:编写条件分支代码的技巧
## 序言
> 这是 “Python 工匠”系列的第 2 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
编写条件分支代码是编码过程中不可或缺的一部分。
如果用道路来做比喻,现实世界中的代码从来都不是一条笔直的高速公路,而更像是由无数个岔路口组成的某个市区地图。我们编码者就像是驾驶员,需要告诉我们的程序,下个路口需要往左还是往右。
编写优秀的条件分支代码非常重要,因为糟糕、复杂的分支处理非常容易让人困惑,从而降低代码质量。所以,这篇文章将会重点谈谈在 Python 中编写分支代码应该注意的地方。
### 内容目录
- [Python 工匠:编写条件分支代码的技巧](#python-工匠编写条件分支代码的技巧)
- [序言](#序言)
- [内容目录](#内容目录)
- [Python 里的分支代码](#python-里的分支代码)
- [最佳实践](#最佳实践)
- [1. 避免多层分支嵌套](#1-避免多层分支嵌套)
- [2. 封装那些过于复杂的逻辑判断](#2-封装那些过于复杂的逻辑判断)
- [3. 留意不同分支下的重复代码](#3-留意不同分支下的重复代码)
- [4. 谨慎使用三元表达式](#4-谨慎使用三元表达式)
- [常见技巧](#常见技巧)
- [1. 使用“德摩根定律”](#1-使用德摩根定律)
- [2. 自定义对象的“布尔真假”](#2-自定义对象的布尔真假)
- [3. 在条件判断中使用 all() / any()](#3-在条件判断中使用-all--any)
- [4. 使用 try/while/for 中 else 分支](#4-使用-trywhilefor-中-else-分支)
- [常见陷阱](#常见陷阱)
- [1. 与 None 值的比较](#1-与-none-值的比较)
- [2. 留意 and 和 or 的运算优先级](#2-留意-and-和-or-的运算优先级)
- [结语](#结语)
- [注解](#注解)
### Python 里的分支代码
Python 支持最为常见的 `if/else` 条件分支语句,不过它缺少在其他编程语言中常见的 `switch/case` 语句。
除此之外Python 还为 `for/while` 循环以及 `try/except` 语句提供了 else 分支,在一些特殊的场景下,它们可以大显身手。
下面我会从 `最佳实践`、`常见技巧`、`常见陷阱` 三个方面讲一下如果编写优秀的条件分支代码。
## 最佳实践
### 1. 避免多层分支嵌套
如果这篇文章只能删减成一句话就结束,那么那句话一定是**“要竭尽所能的避免分支嵌套”**。
过深的分支嵌套是很多编程新手最容易犯的错误之一。假如有一位新手 JavaScript 程序员写了很多层分支嵌套,那么你可能会看到一层又一层的大括号:`if { if { if { ... }}}`。俗称 *“嵌套 if 地狱Nested If Statement Hell”*
但是因为 Python 使用了缩进来代替 `{}`,所以过深的嵌套分支会产生比其他语言下更为严重的后果。比如过多的缩进层次很容易就会让代码超过 [PEP8](https://www.python.org/dev/peps/pep-0008/) 中规定的每行字数限制。让我们看看这段代码:
```Python
def buy_fruit(nerd, store):
"""去水果店买苹果
- 先得看看店是不是在营业
- 如果有苹果的话,就买 1 个
- 如果钱不够,就回家取钱再来
"""
if store.is_open():
if store.has_stocks("apple"):
if nerd.can_afford(store.price("apple", amount=1)):
nerd.buy(store, "apple", amount=1)
return
else:
nerd.go_home_and_get_money()
return buy_fruit(nerd, store)
else:
raise MadAtNoFruit("no apple in store!")
else:
raise MadAtNoFruit("store is closed!")
```
上面这段代码最大的问题,就是过于直接翻译了原始的条件分支要求,导致短短十几行代码包含了有三层嵌套分支。
这样的代码可读性和维护性都很差。不过我们可以用一个很简单的技巧:**“提前结束”** 来优化这段代码:
```python
def buy_fruit(nerd, store):
if not store.is_open():
raise MadAtNoFruit("store is closed!")
if not store.has_stocks("apple"):
raise MadAtNoFruit("no apple in store!")
if nerd.can_afford(store.price("apple", amount=1)):
nerd.buy(store, "apple", amount=1)
return
else:
nerd.go_home_and_get_money()
return buy_fruit(nerd, store)
```
“提前结束”指:**在函数内使用 `return``raise` 等语句提前在分支内结束函数。** 比如,在新的 `buy_fruit` 函数里,当分支条件不满足时,我们直接抛出异常,结束这段代码分支。这样的代码没有嵌套分支,更直接也更易读。
### 2. 封装那些过于复杂的逻辑判断
如果条件分支里的表达式过于复杂,出现了太多的 `not/and/or`,那么这段代码的可读性就会大打折扣,比如下面这段代码:
```
# 如果活动还在开放,并且活动剩余名额大于 10为所有性别为女性或者级别大于 3
# 的活跃用户发放 10000 个金币
if activity.is_active and activity.remaining > 10 and \
user.is_active and (user.sex == 'female' or user.level > 3):
user.add_coins(10000)
return
```
对于这样的代码,我们可以考虑将具体的分支逻辑封装成函数或者方法,来达到简化代码的目的:
```
if activity.allow_new_user() and user.match_activity_condition():
user.add_coins(10000)
return
```
事实上,将代码改写后,之前的注释文字其实也可以去掉了。**因为后面这段代码已经达到了自说明的目的。** 至于具体的 *什么样的用户满足活动条件?* 这种问题,就应由具体的 `match_activity_condition()` 方法来回答了。
> **Hint:** 恰当的封装不光直接改善了代码的可读性,事实上,如果上面的活动判断逻辑在代码中出现了不止一次的话,封装更是必须的。不然重复代码会极大的破坏这段逻辑的可维护性。
### 3. 留意不同分支下的重复代码
重复代码是代码质量的天敌,而条件分支语句又非常容易成为重复代码的重灾区。所以,当我们编写条件分支语句时,需要特别留意,不要生产不必要的重复代码。
让我们看下这个例子:
```python
# 对于新用户,创建新的用户资料,否则更新旧资料
if user.no_profile_exists:
create_user_profile(
username=user.username,
email=user.email,
age=user.age,
address=user.address,
# 对于新建用户,将用户的积分置为 0
points=0,
created=now(),
)
else:
update_user_profile(
username=user.username,
email=user.email,
age=user.age,
address=user.address,
updated=now(),
)
```
在上面的代码中,我们可以一眼看出,在不同的分支下,程序调用了不同的函数,做了不一样的事情。但是,因为那些重复代码的存在,**我们却很难简单的区分出,二者的不同点到底在哪。**
其实,得益于 Python 的动态特性,我们可以简单的改写一下上面的代码,让可读性可以得到显著的提升:
```python
if user.no_profile_exists:
profile_func = create_user_profile
extra_args = {'points': 0, 'created': now()}
else:
profile_func = update_user_profile
extra_args = {'updated': now()}
profile_func(
username=user.username,
email=user.email,
age=user.age,
address=user.address,
**extra_args
)
```
当你编写分支代码时,请额外关注**由分支产生的重复代码块**,如果可以简单的消灭它们,那就不要迟疑。
### 4. 谨慎使用三元表达式
三元表达式是 Python 2.5 版本后才支持的语法。在那之前Python 社区一度认为三元表达式没有必要,我们需要使用 `x and a or b` 的方式来模拟它。[[注]](#annot1)
事实是,在很多情况下,使用普通的 `if/else` 语句的代码可读性确实更好。盲目追求三元表达式很容易诱惑你写出复杂、可读性差的代码。
所以,请记得只用三元表达式处理简单的逻辑分支。
```python
language = "python" if you.favor("dynamic") else "golang"
```
对于绝大多数情况,还是使用普通的 `if/else` 语句吧。
## 常见技巧
### 1. 使用“德摩根定律”
在做分支判断时,我们有时候会写成这样的代码:
```python
# 如果用户没有登录或者用户没有使用 chrome拒绝提供服务
if not user.has_logged_in or not user.is_from_chrome:
return "our service is only available for chrome logged in user"
```
第一眼看到代码时,是不是需要思考一会才能理解它想干嘛?这是因为上面的逻辑表达式里面出现了 2 个 `not` 和 1 个 `or`。而我们人类恰好不擅长处理过多的“否定”以及“或”这种逻辑关系。
这个时候,就该 [德摩根定律](https://zh.wikipedia.org/wiki/%E5%BE%B7%E6%91%A9%E6%A0%B9%E5%AE%9A%E5%BE%8B) 出场了。通俗的说,德摩根定律就是 `not A or not B` 等价于 `not (A and B)`。通过这样的转换,上面的代码可以改写成这样:
```python
if not (user.has_logged_in and user.is_from_chrome):
return "our service is only available for chrome logged in user"
```
怎么样,代码是不是易读了很多?记住德摩根定律,很多时候它对于简化条件分支里的代码逻辑非常有用。
### 2. 自定义对象的“布尔真假”
我们常说,在 Python 里,“万物皆对象”。其实,不光“万物皆对象”,我们还可以利用很多魔法方法*(文档中称为:[user-defined method](https://docs.python.org/3/reference/datamodel.html)*,来自定义对象的各种行为。我们可以用很多在别的语言里面无法做到、有些魔法的方式来影响代码的执行。
比如Python 的所有对象都有自己的“布尔真假”:
- 布尔值为假的对象:`None`, `0`, `False`, `[]`, `()`, `{}`, `set()`, `frozenset()`, ... ...
- 布尔值为真的对象:非 `0` 的数值、`True`,非空的序列、元组,普通的用户类实例,... ...
通过内建函数 `bool()`,你可以很方便的查看某个对象的布尔真假。而 Python 进行条件分支判断时用到的也是这个值:
```python
>>> bool(object())
True
```
重点来了,虽然所有用户类实例的布尔值都是真。但是 Python 提供了改变这个行为的办法:**自定义类的 `__bool__` 魔法方法** *(在 Python 2.X 版本中为 `__nonzero__`*。当类定义了 `__bool__` 方法后,它的返回值将会被当作类实例的布尔值。
另外,`__bool__` 不是影响实例布尔真假的唯一方法。如果类没有定义 `__bool__` 方法Python 还会尝试调用 `__len__` 方法*(也就是对任何序列对象调用 `len` 函数)*,通过结果是否为 `0` 判断实例真假。
那么这个特性有什么用呢?看看下面这段代码:
```python
class UserCollection(object):
def __init__(self, users):
self._users = users
users = UserCollection([piglei, raymond])
if len(users._users) > 0:
print("There's some users in collection!")
```
上面的代码里,判断 `UserCollection` 是否有内容时用到了 `users._users` 的长度。其实,通过为 `UserCollection` 添加 `__len__` 魔法方法,上面的分支可以变得更简单:
```python
class UserCollection:
def __init__(self, users):
self._users = users
def __len__(self):
return len(self._users)
users = UserCollection([piglei, raymond])
# 定义了 __len__ 方法后UserCollection 对象本身就可以被用于布尔判断了
if users:
print("There's some users in collection!")
```
通过定义魔法方法 `__len__``__bool__` ,我们可以让类自己控制想要表现出的布尔真假值,让代码变得更 pythonic。
### 3. 在条件判断中使用 all() / any()
`all()``any()` 两个函数非常适合在条件判断中使用。这两个函数接受一个可迭代对象,返回一个布尔值,其中:
- `all(seq)`:仅当 `seq` 中所有对象都为布尔真时返回 `True`,否则返回 `False`
- `any(seq)`:只要 `seq` 中任何一个对象为布尔真就返回 `True`,否则返回 `False`
假如我们有下面这段代码:
```python
def all_numbers_gt_10(numbers):
"""仅当序列中所有数字大于 10 时,返回 True
"""
if not numbers:
return False
for n in numbers:
if n <= 10:
return False
return True
```
如果使用 `all()` 内建函数,再配合一个简单的生成器表达式,上面的代码可以写成这样:
```python
def all_numbers_gt_10_2(numbers):
return bool(numbers) and all(n > 10 for n in numbers)
```
简单、高效,同时也没有损失可用性。
### 4. 使用 try/while/for 中 else 分支
让我们看看这个函数:
```python
def do_stuff():
first_thing_successed = False
try:
do_the_first_thing()
first_thing_successed = True
except Exception as e:
print("Error while calling do_some_thing")
return
# 仅当 first_thing 成功完成时,做第二件事
if first_thing_successed:
return do_the_second_thing()
```
在函数 `do_stuff` 中,我们希望只有当 `do_the_first_thing()` 成功调用后*(也就是不抛出任何异常)*,才继续做第二个函数调用。为了做到这一点,我们需要定义一个额外的变量 `first_thing_successed` 来作为标记。
其实,我们可以用更简单的方法达到同样的效果:
```python
def do_stuff():
try:
do_the_first_thing()
except Exception as e:
print("Error while calling do_some_thing")
return
else:
return do_the_second_thing()
```
`try` 语句块最后追加上 `else` 分支后,分支下的`do_the_second_thing()` 便只会在 **try 下面的所有语句正常执行(也就是没有异常,没有 return、break 等)完成后执行**
类似的Python 里的 `for/while` 循环也支持添加 `else` 分支,它们表示:当循环使用的迭代对象被正常耗尽、或 while 循环使用的条件变量变为 False 后才执行 else 分支下的代码。
## 常见陷阱
### 1. 与 None 值的比较
在 Python 中,有两种比较变量的方法:`==` 和 `is`,二者在含义上有着根本的区别:
- `==`:表示二者所指向的的**值**是否一致
- `is`:表示二者是否指向内存中的同一份内容,也就是 `id(x)` 是否等于 `id(y)`
`None` 在 Python 语言中是一个单例对象,如果你要判断某个变量是否为 None 时,记得使用 `is` 而不是 `==`,因为只有 `is` 才能在严格意义上表示某个变量是否是 None。
否则,可能出现下面这样的情况:
```python
>>> class Foo(object):
... def __eq__(self, other):
... return True
...
>>> foo = Foo()
>>> foo == None
True
```
在上面代码中Foo 这个类通过自定义 `__eq__` 魔法方法的方式,很容易就满足了 `== None` 这个条件。
**所以,当你要判断某个变量是否为 None 时,请使用 `is` 而不是 `==`。**
### 2. 留意 and 和 or 的运算优先级
看看下面这两个表达式,猜猜它们的值一样吗?
```python
>>> (True or False) and False
>>> True or False and False
```
答案是:不一样,它们的值分别是 `False``True`,你猜对了吗?
问题的关键在于:**`and` 运算符的优先级大于 `or`**。因此上面的第二个表达式在 Python 看来实际上是 `True or (False and False)`。所以结果是 `True` 而不是 `False`
在编写包含多个 `and``or` 的表达式时,请额外注意 `and``or` 的运算优先级。即使执行优先级正好是你需要的那样,你也可以加上额外的括号来让代码更清晰。
## 结语
以上就是『Python 工匠』系列文章的第二篇。不知道文章的内容是否对你的胃口。
代码内的分支语句不可避免,我们在编写代码时,需要尤其注意它的可读性,避免对其他看到代码的人造成困扰。
看完文章的你,有没有什么想吐槽的?请留言告诉我吧。
[>>>下一篇【3.使用数字与字符串的技巧】](3-tips-on-numbers-and-strings.md)
[<<<上一篇【1.善用变量来改善代码质量】](1-using-variables-well.md)
## 注解
1. <a id="annot1"></a>事实上 `x and a or b` 不是总能给你正确的结果,只有当 a 与 b 的布尔值为真时,这个表达式才能正常工作,这是由逻辑运算的短路特性决定的。你可以在命令行中运行 `True and None or 0` 试试看,结果是 0 而非 None。
> 文章更新记录:
>
> - 2018.04.08:在与 @geishu 的讨论后,调整了“运算优先符”使用的代码样例
> - 2018.04.10:根据 @dongweiming 的建议,添加注解说明 "x and y or c" 表达式的陷阱

View File

@ -1,426 +0,0 @@
# Python 工匠:使用数字与字符串的技巧
## 序言
> 这是 “Python 工匠”系列的第 3 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
数字是几乎所有编程语言里最基本的数据类型,它是我们通过代码连接现实世界的基础。在 Python 里有三种数值类型整型int、浮点型float和复数complex。绝大多数情况下我们只需要和前两种打交道。
整型在 Python 中比较让人省心,因为它不区分有无符号并且永不溢出。但浮点型仍和绝大多数其他编程语言一样,依然有着精度问题,经常让很多刚进入编程世界大门的新人们感到困惑:["Why Are Floating Point Numbers Inaccurate?"](https://stackoverflow.com/questions/21895756/why-are-floating-point-numbers-inaccurate)。
相比数字Python 里的字符串要复杂的多。要掌握它,你得先弄清楚 bytes 和 str 的区别。如果更不巧,你还是位 Python2 用户的话,光 unicode 和字符编码问题就够你喝上好几壶了 *(赶快迁移到 Python3 吧,就在今天!)*
不过,上面提到的这些都不是这篇文章的主题,如果感兴趣,你可以在网上找到成堆的相关资料。在这篇文章里,我们将讨论一些 **更细微、更不常见** 的编程实践。来帮助你写出更好的 Python 代码。
### 内容目录
- [Python 工匠:使用数字与字符串的技巧](#python-工匠使用数字与字符串的技巧)
- [序言](#序言)
- [内容目录](#内容目录)
- [最佳实践](#最佳实践)
- [1. 少写数字字面量](#1-少写数字字面量)
- [使用 enum 枚举类型改善代码](#使用-enum-枚举类型改善代码)
- [2. 别在裸字符串处理上走太远](#2-别在裸字符串处理上走太远)
- [3. 不必预计算字面量表达式](#3-不必预计算字面量表达式)
- [实用技巧](#实用技巧)
- [1. 布尔值其实也是“数字”](#1-布尔值其实也是数字)
- [2. 改善超长字符串的可读性](#2-改善超长字符串的可读性)
- [当多级缩进里出现多行字符串时](#当多级缩进里出现多行字符串时)
- [大数字也可以变得更加可读](#大数字也可以变得更加可读)
- [3. 别忘了那些 “r” 开头的内建字符串函数](#3-别忘了那些-r-开头的内建字符串函数)
- [4. 使用“无穷大” float("inf")](#4-使用无穷大-floatinf)
- [常见误区](#常见误区)
- [1. “value += 1” 并非线程安全](#1-value--1-并非线程安全)
- [2. 字符串拼接并不慢](#2-字符串拼接并不慢)
- [结语](#结语)
## 最佳实践
### 1. 少写数字字面量
“数字字面量integer literal” 是指那些直接出现在代码里的数字。它们分布在代码里的各个角落,比如代码 `del users[0]` 里的 `0` 就是一个数字字面量。它们简单、实用,每个人每天都在写。**但是,当你的代码里不断重复出现一些特定字面量时,你的“代码质量告警灯”就应该亮起黄灯 🚥 了。**
举个例子,假如你刚加入一家心仪已久的新公司,同事转交给你的项目里有这么一个函数:
```python
def mark_trip_as_featured(trip):
"""将某个旅程添加到推荐栏目
"""
if trip.source == 11:
do_some_thing(trip)
elif trip.source == 12:
do_some_other_thing(trip)
... ...
return
```
这个函数做了什么事?你努力想搞懂它的意思,不过 `trip.source == 11` 是什么情况?那 `== 12` 呢?这两行代码很简单,没有用到任何魔法特性。但初次接触代码的你可能需要花费**一整个下午**,才能弄懂它们的含义。
**问题就出在那几个数字字面量上。** 最初写下这个函数的人,可能是在公司成立之初加入的那位元老程序员。而他对那几个数字的含义非常清楚。但如果你是一位刚接触这段代码的新人,就完全是另外一码事了。
#### 使用 enum 枚举类型改善代码
那么,怎么改善这段代码?最直接的方式,就是为这两个条件分支添加注释。不过在这里,“添加注释”显然不是提升代码可读性的最佳办法*(其实在绝大多数其他情况下都不是)*。我们需要用有意义的名称来代替这些字面量,而`枚举类型enum`用在这里最合适不过了。
`enum` 是 Python 自 3.4 版本引入的内置模块,如果你使用的是更早的版本,可以通过 `pip install enum34` 来安装它。下面是使用 enum 的样例代码:
```python
# -*- coding: utf-8 -*-
from enum import IntEnum
class TripSource(IntEnum):
FROM_WEBSITE = 11
FROM_IOS_CLIENT = 12
def mark_trip_as_featured(trip):
if trip.source == TripSource.FROM_WEBSITE:
do_some_thing(trip)
elif trip.source == TripSource.FROM_IOS_CLIENT:
do_some_other_thing(trip)
... ...
return
```
将重复出现的数字字面量定义成枚举类型,不光可以改善代码的可读性,代码出现 Bug 的几率也会降低。
试想一下,如果你在某个分支判断时将 `11` 错打成了 `111` 会怎么样?我们时常会犯这种错,而这类错误在早期特别难被发现。将这些数字字面量全部放入枚举类型中可以比较好的规避这类问题。类似的,将字符串字面量改写成枚举也可以获得同样的好处。
使用枚举类型代替字面量的好处:
- **提升代码可读性**:所有人都不需要记忆某个神奇的数字代表什么
- **提升代码正确性**:减少打错数字或字母产生 bug 的可能性
当然,你完全没有必要把代码里的所有字面量都改成枚举类型。 **代码里出现的字面量,只要在它所处的上下文里面容易理解,就可以使用它。** 比如那些经常作为数字下标出现的 `0``-1` 就完全没有问题,因为所有人都知道它们的意思。
### 2. 别在裸字符串处理上走太远
什么是“裸字符串处理”?在这篇文章里,它指**只使用基本的加减乘除和循环、配合内置函数/方法来操作字符串,获得我们需要的结果。**
所有人都写过这样的代码。有时候我们需要拼接一大段发给用户的告警信息,有时我们需要构造一大段发送给数据库的 SQL 查询语句,就像下面这样:
```python
def fetch_users(conn, min_level=None, gender=None, has_membership=False, sort_field="created"):
"""获取用户列表
:param int min_level: 要求的最低用户级别,默认为所有级别
:param int gender: 筛选用户性别,默认为所有性别
:param int has_membership: 筛选所有会员/非会员用户,默认非会员
:param str sort_field: 排序字段,默认为按 created "用户创建日期"
:returns: 列表:[(User ID, User Name), ...]
"""
# 一种古老的 SQL 拼接技巧,使用 "WHERE 1=1" 来简化字符串拼接操作
# 区分查询 params 来避免 SQL 注入问题
statement = "SELECT id, name FROM users WHERE 1=1"
params = []
if min_level is not None:
statement += " AND level >= ?"
params.append(min_level)
if gender is not None:
statement += " AND gender >= ?"
params.append(gender)
if has_membership:
statement += " AND has_membership == true"
else:
statement += " AND has_membership == false"
statement += " ORDER BY ?"
params.append(sort_field)
return list(conn.execute(statement, params))
```
我们之所以用这种方式拼接出需要的字符串 - *在这里是 SQL 语句* - 是因为这样做简单、直接,符合直觉。但是这样做最大的问题在于:**随着函数逻辑变得更复杂,这段拼接代码会变得容易出错、难以扩展。**事实上,上面这段 Demo 代码也只是仅仅做到**看上去**没有明显的 bug 而已 *(谁知道有没有其他隐藏问题)*
其实,对于 SQL 语句这种结构化、有规则的字符串,用对象化的方式构建和编辑它才是更好的做法。下面这段代码用 [SQLAlchemy](https://www.sqlalchemy.org/) 模块完成了同样的功能:
```python
def fetch_users_v2(conn, min_level=None, gender=None, has_membership=False, sort_field="created"):
"""获取用户列表
"""
query = select([users.c.id, users.c.name])
if min_level is not None:
query = query.where(users.c.level >= min_level)
if gender is not None:
query = query.where(users.c.gender == gender)
query = query.where(users.c.has_membership == has_membership).order_by(users.c[sort_field])
return list(conn.execute(query))
```
上面的 `fetch_users_v2` 函数更短也更好维护,而且根本不需要担心 SQL 注入问题。所以,当你的代码中出现复杂的裸字符串处理逻辑时,请试着用下面的方式替代它:
`Q: 目标/源字符串是结构化的,遵循某种格式吗?`
- 是:找找是否已经有开源的对象化模块操作它们,或是自己写一个
- SQLSQLAlchemy
- XMLlxml
- JSON、YAML ...
- 否:尝试使用模板引擎而不是复杂字符串处理逻辑来达到目的
- Jinja2
- Mako
- Mustache
### 3. 不必预计算字面量表达式
我们的代码里偶尔会出现一些比较复杂的数字,就像下面这样:
```python
def f1(delta_seconds):
# 如果时间已经过去了超过 11 天,不做任何事
if delta_seconds > 950400:
return
...
```
话说在前头,上面的代码没有任何毛病。
首先,我们在小本子(当然,和我一样的聪明人会用 IPython上算了算`11天一共包含多少秒`。然后再把结果 `950400` 这个神奇的数字填进我们的代码里,最后心满意足的在上面补上一行注释:告诉所有人这个神奇的数字是怎么来的。
我想问的是:*“为什么我们不直接把代码写成 `if delta_seconds < 11 * 24 * 3600:` 呢?”*
**“性能”,答案一定会是“性能”**。我们都知道 Python 是一门~~(速度欠佳的)~~解释型语言,所以预先计算出 `950400` 正是因为我们不想让每次对函数 `f1` 的调用都带上这部分的计算开销。不过事实是:**即使我们把代码改成 `if delta_seconds < 11 * 24 * 3600:`,函数也不会多出任何额外的开销。**
Python 代码在执行时会被解释器编译成字节码,而真相就藏在字节码里。让我们用 dis 模块看看:
```python
def f1(delta_seconds):
if delta_seconds < 11 * 24 * 3600:
return
import dis
dis.dis(f1)
# dis 执行结果
5 0 LOAD_FAST 0 (delta_seconds)
2 LOAD_CONST 1 (950400)
4 COMPARE_OP 0 (<)
6 POP_JUMP_IF_FALSE 12
6 8 LOAD_CONST 0 (None)
10 RETURN_VALUE
>> 12 LOAD_CONST 0 (None)
14 RETURN_VALUE
```
看见上面的 `2 LOAD_CONST 1 (950400)` 了吗?这表示 Python 解释器在将源码编译成成字节码时,会计算 `11 * 24 * 3600` 这段表达式,并用 `950400` 替换它。
所以,**当我们的代码中需要出现复杂计算的字面量时,请保留整个算式吧。它对性能没有任何影响,而且会增加代码的可读性。**
> HintPython 解释器除了会预计算数值字面量表达式以外,还会对字符串、列表做类似的操作。一切都是为了性能。谁让你们老吐槽 Python 慢呢?
## 实用技巧
### 1. 布尔值其实也是“数字”
Python 里的两个布尔值 `True``False` 在绝大多数情况下都可以直接等价于 `1``0` 两个整数来使用,就像这样:
```python
>>> True + 1
2
>>> 1 / False
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
```
那么记住这点有什么用呢?首先,它们可以配合 `sum` 函数在需要计算总数时简化操作:
```python
>>> l = [1, 2, 4, 5, 7]
>>> sum(i % 2 == 0 for i in l)
2
```
此外,如果将某个布尔值表达式作为列表的下标使用,可以实现类似三元表达式的目的:
```python
# 类似的三元表达式:"Javascript" if 2 > 1 else "Python"
>>> ["Python", "Javascript"][2 > 1]
'Javascript'
```
### 2. 改善超长字符串的可读性
单行代码的长度不宜太长。比如 PEP8 里就建议每行字符数不得超过 **79**。现实世界里,大部分人遵循的单行最大字符数在 79 到 119 之间。如果只是代码,这样的要求是比较容易达到的,但假设代码里需要出现一段超长的字符串呢?
这时,除了使用斜杠 `\` 和加号 `+` 将长字符串拆分为好几段以外,还有一种更简单的办法:**使用括号将长字符串包起来,然后就可以随意折行了**
```python
s = (
"There is something really bad happened during the process. "
"Please contact your administrator."
)
print(s)
def main():
logger.info(
"There is something really bad happened during the process. "
"Please contact your administrator."
)
```
#### 当多级缩进里出现多行字符串时
日常编码时,还有一种比较麻烦的情况。就是需要在已经有缩进层级的代码里,插入多行字符串字面量。因为多行字符串不能包含当前的缩进空格,所以,我们需要把代码写成这样:
```python
def main():
if user.is_active:
message = """Welcome, today's movie list:
- Jaw (1975)
- The Shining (1980)
- Saw (2004)"""
```
但是这样写会破坏整段代码的缩进视觉效果,显得非常突兀。要改善它有很多种办法,比如我们可以把这段多行字符串作为变量提取到模块的最外层。不过,如果在你的代码逻辑里更适合用字面量的话,你也可以用标准库 `textwrap` 来解决这个问题:
```python
from textwrap import dedent
def main():
if user.is_active:
# dedent 将会缩进掉整段文字最左边的空字符串
message = dedent("""\
Welcome, today's movie list:
- Jaw (1975)
- The Shining (1980)
- Saw (2004)""")
```
#### 大数字也可以变得更加可读
> 该小节内容由 [@laixintao](https://github.com/laixintao) 提供。
对那些特别大的数字,可以通过在中间添加下划线来提高可读性
([PEP515](https://www.python.org/dev/peps/pep-0515/),需要 Python3.6+)。
比如:
```python
>>> 10_000_000.0 # 以“千”为单位划分数字
10000000.0
>>> 0xCAFE_F00D # 16进制数字同样有效4个一组更易读
3405705229
>>> 0b_0011_1111_0100_1110 # 二进制也有效
16206
>>> int('0b_1111_0000', 2) # 处理字符串的时候也会正确处理下划线
240
```
### 3. 别忘了那些 “r” 开头的内建字符串函数
Python 的字符串有着非常多实用的内建方法,最常用的有 `.strip()`、`.split()` 等。这些内建方法里的大多数,处理起来的顺序都是从左往右。但是其中也包含了部分以 `r` 打头的**从右至左处理**的镜像方法。在处理特定逻辑时,使用它们可以让你事半功倍。
假设我们需要解析一些访问日志,日志格式为:"{user_agent}" {content_length}
>>> log_line = '"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36" 47632'
如果使用 `.split()` 将日志拆分为 `(user_agent, content_length) `,我们需要这么写:
```python
>>> l = log_line.split()
>>> " ".join(l[:-1]), l[-1]
('"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632')
```
但是如果使用 `.rsplit()` 的话,处理逻辑就更直接了:
```python
>>> log_line.rsplit(None, 1)
['"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632']
```
### 4. 使用“无穷大” float("inf")
如果有人问你:*“Python 里什么数字最大/最小?”*。你应该怎么回答?有这样的东西存在吗?
答案是:“有的,它们就是:`float("inf")` 和 `float("-inf")`”。它们俩分别对应着数学世界里的正负无穷大。当它们和任意数值进行比较时,满足这样的规律:`float("-inf") < 任意数值 < float("inf")`。
因为它们有着这样的特点,我们可以在某些场景用上它们:
```python
# A. 根据年龄升序排序,没有提供年龄放在最后边
>>> users = {"tom": 19, "jenny": 13, "jack": None, "andrew": 43}
>>> sorted(users.keys(), key=lambda user: users.get(user) or float('inf'))
['jenny', 'tom', 'andrew', 'jack']
# B. 作为循环初始值,简化第一次判断逻辑
>>> max_num = float('-inf')
>>> # 找到列表中最大的数字
>>> for i in [23, 71, 3, 21, 8]:
...: if i > max_num:
...: max_num = i
...:
>>> max_num
71
```
## 常见误区
### 1. “value += 1” 并非线程安全
当我们编写多线程程序时,经常需要处理复杂的共享变量和竞态等问题。
“线程安全”,通常被用来形容 **某个行为或者某类数据结构,可以在多线程环境下被共享使用并产生预期内的结果。**一个典型的满足“线程安全”的模块就是 [queue 队列模块](https://docs.python.org/3/library/queue.html)。
而我们常做的 `value += 1` 操作,很容易被想当然的认为是“线程安全”的。因为它看上去就是一个原子操作 *(指一个最小的操作单位,执行途中不会插入任何其他操作)*。然而真相并非如此,虽然从 Python 代码上来看,`value += 1` 这个操作像是原子的。但它最终被 Python 解释器执行的时候,早就不再 *“原子”* 了。
我们可以用前面提到的 `dis` 模块来验证一下:
```python
def incr(value):
value += 1
# 使用 dis 模块查看字节码
import dis
dis.dis(incr)
0 LOAD_FAST 0 (value)
2 LOAD_CONST 1 (1)
4 INPLACE_ADD
6 STORE_FAST 0 (value)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
```
在上面输出结果中,可以看到这个简单的累加语句,会被编译成包括取值和保存在内的好几个不同步骤,而在多线程环境下,任意一个其他线程都有可能在其中某个步骤切入进来,阻碍你获得正确的结果。
**因此,请不要凭借自己的直觉来判断某个行为是否“线程安全”,不然等程序在高并发环境下出现奇怪的 bug 时,你将为自己的直觉付出惨痛的代价。**
### 2. 字符串拼接并不慢
我刚接触 Python 不久时,在某个网站看到这样一个说法: *“Python 里的字符串是不可变的,所以每一次对字符串进行拼接都会生成一个新对象,导致新的内存分配,效率非常低”。* 我对此深信不疑。
所以,一直以来,我尽量都在避免使用 `+=` 的方式去拼接字符串,而是用 `"".join(str_list)` 之类的方式来替代。
但是,在某个偶然的机会下,我对 Python 的字符串拼接做了一次简单的性能测试后发现: **Python 的字符串拼接根本就不慢!** 在查阅了一些资料后,最终发现了真相。
Python 的字符串拼接在 2.2 以及之前的版本确实很慢,和我最早看到的说法行为一致。但是因为这个操作太常用了,所以之后的版本里专门针对它做了性能优化。大大提升了执行效率。
如今使用 `+=` 的方式来拼接字符串,效率已经非常接近 `"".join(str_list)` 了。所以,该拼接时就拼接吧,不必担心任何性能问题。
> Hint: 如果你想了解更详细的相关内容,可以读一下这篇文章:[Python - Efficient String Concatenation in Python (2016 edition) - smcl](http://blog.mclemon.io/python-efficient-string-concatenation-in-python-2016-edition)
## 结语
以上就是『Python 工匠』系列文章的第三篇,内容比较零碎。由于篇幅原因,一些常用的操作比如字符串格式化等,文章里并没有涵盖到。以后有机会再写吧。
让我们最后再总结一下要点:
- 编写代码时,请考虑阅读者的感受,不要出现太多神奇的字面量
- 当操作结构化字符串时,使用对象化模块比直接处理更有优势
- dis 模块非常有用,请多多使用它验证你的猜测
- 多线程环境下的编码非常复杂,要足够谨慎,不要相信自己的直觉
- Python 语言的更新非常快,不要被别人的经验所左右
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【4.容器的门道】](4-mastering-container-types.md)
[<<<上一篇【2.编写条件分支代码的技巧】](2-if-else-block-secrets.md)

View File

@ -1,435 +0,0 @@
# Python 工匠:容器的门道
## 序言
> 这是 “Python 工匠”系列的第 4 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static//uploaded/2019/01/6002476959_cca2bf5424_b_thumb.jpg" width="100%" /><div>
图片来源: <a href="https://www.flickr.com/photos/chiotsrun/6002476959/in/photolist-a9qgh4-W4eQ1j-7MrCfo-4ARLWp-dwCzHh-Tascu9-RNRbRf-foLHW5-22dkkHM-9ceFA8-aGGd3a-26X3sqQ-iuTwX9-q52ktA-osn2eb-29oujY-6mXd1c-8E92nc-mPbq55-9GuPU8-26Q1NZG-8UL8PL-pdyFsW-7V8ifD-VZavJ8-2cUdHbU-9WrgjZ-6g7M5K-VMLVrb-cXDd4-bygFJG-C76kP-nMQW54-7MoQqn-qA3fud-c92dBU-tAzTBm-7KqFXc-24VvcW1-djQX9e-5LzjkA-63U4kb-bt1EEY-jLRpKo-dQSWBH-aDbqXc-8KhfnE-2m5ZsF-6ciuiR-qwdbt">"The Humble Mason Jar" by Chiot's Run</a> - 非商业性使用 2.0 通用</div>
</div>
“容器”这两个字很少被 Python 技术文章提起。一看到“容器”,大家想到的多是那头蓝色小鲸鱼:*Docker*,但这篇文章和它没有任何关系。本文里的容器,是 Python 中的一个抽象概念,是对**专门用来装其他对象的数据类型**的统称。
在 Python 中,有四类最常见的内建容器类型:`列表list`、`元组tuple`、`字典dict`、`集合set`。通过单独或是组合使用它们,可以高效地完成很多事情。
Python 语言自身的内部实现细节也与这些容器类型息息相关。比如 Python 的类实例属性、全局变量 `globals()` 等就都是通过字典类型来存储的。
在这篇文章里,我首先会从容器类型的定义出发,尝试总结出一些日常编码的最佳实践。之后再围绕各个容器类型提供的特殊机能,分享一些编程的小技巧。
### 内容目录
- [Python 工匠:容器的门道](#python-工匠容器的门道)
- [序言](#序言)
- [内容目录](#内容目录)
- [当我们谈论容器时,我们在谈些什么?](#当我们谈论容器时我们在谈些什么)
- [底层看容器](#底层看容器)
- [写更快的代码](#写更快的代码)
- [1. 避免频繁扩充列表/创建新列表](#1-避免频繁扩充列表创建新列表)
- [2. 在列表头部操作多的场景使用 deque 模块](#2-在列表头部操作多的场景使用-deque-模块)
- [3. 使用集合/字典来判断成员是否存在](#3-使用集合字典来判断成员是否存在)
- [高层看容器](#高层看容器)
- [写扩展性更好的代码](#写扩展性更好的代码)
- [面向容器接口编程](#面向容器接口编程)
- [常用技巧](#常用技巧)
- [1. 使用元组改善分支代码](#1-使用元组改善分支代码)
- [2. 在更多地方使用动态解包](#2-在更多地方使用动态解包)
- [3. 使用 next() 函数](#3-使用-next-函数)
- [4. 使用有序字典来去重](#4-使用有序字典来去重)
- [常见误区](#常见误区)
- [1. 当心那些已经枯竭的迭代器](#1-当心那些已经枯竭的迭代器)
- [2. 别在循环体内修改被迭代对象](#2-别在循环体内修改被迭代对象)
- [总结](#总结)
- [系列其他文章](#系列其他文章)
- [注解](#注解)
### 当我们谈论容器时,我们在谈些什么?
我在前面给了“容器”一个简单的定义:*专门用来装其他对象的就是容器*。但这个定义太宽泛了,无法对我们的日常编程产生什么指导价值。要真正掌握 Python 里的容器,需要分别从两个层面入手:
- **底层实现**:内置容器类型使用了什么数据结构?某项操作如何工作?
- **高层抽象**:什么决定了某个对象是不是容器?哪些行为定义了容器?
下面,让我们一起站在这两个不同的层面上,重新认识容器。
## 底层看容器
Python 是一门高级编程语言,**它所提供的内置容器类型,都是经过高度封装和抽象后的结果**。和“链表”、“红黑树”、“哈希表”这些名字相比,所有 Python 内建类型的名字,都只描述了这个类型的功能特点,其他人完全没法只通过这些名字了解它们的哪怕一丁点内部细节。
这是 Python 编程语言的优势之一。相比 C 语言这类更接近计算机底层的编程语言Python 重新设计并实现了对编程者更友好的内置容器类型,屏蔽掉了内存管理等额外工作,为我们提供了更好的开发体验。
但如果这是 Python 语言的优势的话,为什么我们还要费劲去了解容器类型的实现细节呢?答案是:**关注细节可以帮助我们编写出更快的代码。**
### 写更快的代码
#### 1. 避免频繁扩充列表/创建新列表
所有的内建容器类型都不限制容量。如果你愿意,你可以把递增的数字不断塞进一个空列表,最终撑爆整台机器的内存。
在 Python 语言的实现细节里,列表的内存是按需分配的[[注1]](#annot1),当某个列表当前拥有的内存不够时,便会触发内存扩容逻辑。而分配内存是一项昂贵的操作。虽然大部分情况下,它不会对你的程序性能产生什么严重的影响。但是当你处理的数据量特别大时,很容易因为内存分配拖累整个程序的性能。
还好Python 早就意识到了这个问题,并提供了官方的问题解决指引,那就是:**“变懒”**。
如何解释“变懒”?`range()` 函数的进化是一个非常好的例子。
在 Python 2 中,如果你调用 `range(100000000)`,需要等待好几秒才能拿到结果,因为它需要返回一个巨大的列表,花费了非常多的时间在内存分配与计算上。但在 Python 3 中,同样的调用马上就能拿到结果。因为函数返回的不再是列表,而是一个类型为 `range` 的懒惰对象,只有在你迭代它、或是对它进行切片时,它才会返回真正的数字给你。
**所以说,为了提高性能,内建函数 `range` “变懒”了。** 而为了避免过于频繁的内存分配,在日常编码中,我们的函数同样也需要变懒,这包括:
- 更多的使用 `yield` 关键字,返回生成器对象
- 尽量使用生成器表达式替代列表推导表达式
- 生成器表达式:`(i for i in range(100))` 👍
- 列表推导表达式:`[i for i in range(100)]`
- 尽量使用模块提供的懒惰对象:
- 使用 `re.finditer` 替代 `re.findall`
- 直接使用可迭代的文件对象: `for line in fp`,而不是 `for line in fp.readlines()`
#### 2. 在列表头部操作多的场景使用 deque 模块
列表是基于数组结构Array实现的当你在列表的头部插入新成员`list.insert(0, item)`)时,它后面的所有其他成员都需要被移动,操作的时间复杂度是 `O(n)`。这导致在列表的头部插入成员远比在尾部追加(`list.append(item)` 时间复杂度为 `O(1)`)要慢。
如果你的代码需要执行很多次这类操作,请考虑使用 [collections.deque](https://docs.python.org/3.7/library/collections.html#collections.deque) 类型来替代列表。因为 deque 是基于双端队列实现的,无论是在头部还是尾部追加元素,时间复杂度都是 `O(1)`
#### 3. 使用集合/字典来判断成员是否存在
当你需要判断成员是否存在于某个容器时,用集合比列表更合适。因为 `item in [...]` 操作的时间复杂度是 `O(n)`,而 `item in {...}` 的时间复杂度是 `O(1)`。这是因为字典与集合都是基于哈希表Hash Table数据结构实现的。
```python
# 这个例子不是特别恰当,因为当目标集合特别小时,使用集合还是列表对效率的影响微乎其微
# 但这不是重点 :)
VALID_NAMES = ["piglei", "raymond", "bojack", "caroline"]
# 转换为集合类型专门用于成员判断
VALID_NAMES_SET = set(VALID_NAMES)
def validate_name(name):
if name not in VALID_NAMES_SET:
# 此处使用了 Python 3.6 添加的 f-strings 特性
raise ValueError(f"{name} is not a valid name!")
```
> Hint: 强烈建议阅读 [TimeComplexity - Python Wiki](https://wiki.python.org/moin/TimeComplexity),了解更多关于常见容器类型的时间复杂度相关内容。
>
> 如果你对字典的实现细节感兴趣,也强烈建议观看 Raymond Hettinger 的演讲 [Modern Dictionaries(YouTube)](https://www.youtube.com/watch?v=p33CVV29OG8&t=1403s)
## 高层看容器
Python 是一门“[鸭子类型](https://en.wikipedia.org/wiki/Duck_typing)”语言:*“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。”* 所以,当我们说某个对象是什么类型时,在根本上其实指的是: **这个对象满足了该类型的特定接口规范,可以被当成这个类型来使用。** 而对于所有内置容器类型来说,同样如此。
打开位于 [collections](https://docs.python.org/3.7/library/collections.html) 模块下的 [abc](https://docs.python.org/3/library/collections.abc.html)*(“抽象类 Abstract Base Classes”的首字母缩写* 子模块,可以找到所有与容器相关的接口(抽象类)[[注2]](#annot2)定义。让我们分别看看那些内建容器类型都满足了什么接口:
- **列表list**:满足 `Iterable`、`Sequence`、`MutableSequence` 等接口
- **元组tuple**:满足 `Iterable`、`Sequence`
- **字典dict**:满足 `Iterable`、`Mapping`、`MutableMapping` [[注3]](#annot3)
- **集合set**:满足 `Iterable`、`Set`、`MutableSet` [[注4]](#annot4)
每个内置容器类型,其实就是满足了多个接口定义的组合实体。比如所有的容器类型都满足 `“可被迭代的”Iterable` 这个接口,这意味着它们都是“可被迭代”的。但是反过来,不是所有“可被迭代”的对象都是容器。就像字符串虽然可以被迭代,但我们通常不会把它当做“容器”来看待。
了解这个事实后,我们将**在 Python 里重新认识**面向对象编程中最重要的原则之一:**面向接口而非具体实现来编程。**
让我们通过一个例子,看看如何理解 Python 里的“面向接口编程”。
### 写扩展性更好的代码
某日,我们接到一个需求:*有一个列表,里面装着很多用户评论,为了在页面正常展示,需要将所有超过一定长度的评论用省略号替代*。
这个需求很好做,很快我们就写出了第一个版本的代码:
```python
# 注为了加强示例代码的说明性本文中的部分代码片段使用了Python 3.5
# 版本添加的 Type Hinting 特性
def add_ellipsis(comments: typing.List[str], max_length: int = 12):
"""如果评论列表里的内容超过 max_length剩下的字符用省略号代替
"""
index = 0
for comment in comments:
comment = comment.strip()
if len(comment) > max_length:
comments[index] = comment[:max_length] + '...'
index += 1
return comments
comments = [
"Implementation note",
"Changed",
"ABC for generator",
]
print("\n".join(add_ellipsis(comments)))
# OUTPUT:
# Implementati...
# Changed
# ABC for gene...
```
上面的代码里,`add_ellipsis` 函数接收一个列表作为参数,然后遍历它,替换掉需要修改的成员。这一切看上去很合理,因为我们接到的最原始需求就是:“有一个 **列表**,里面...”。**但如果有一天,我们拿到的评论不再是被继续装在列表里,而是在不可变的元组里呢?**
那样的话,现有的函数设计就会逼迫我们写出 `add_ellipsis(list(comments))` 这种即慢又难看的代码了。😨
#### 面向容器接口编程
我们需要改进函数来避免这个问题。因为 `add_ellipsis` 函数强依赖了列表类型,所以当参数类型变为元组时,现在的函数就不再适用了*(原因:给 `comments[index]` 赋值的地方会抛出 `TypeError` 异常)。* 如何改善这部分的设计?秘诀就是:**让函数依赖“可迭代对象”这个抽象概念,而非实体列表类型。**
使用生成器特性,函数可以被改成这样:
```python
def add_ellipsis_gen(comments: typing.Iterable[str], max_length: int = 12):
"""如果可迭代评论里的内容超过 max_length剩下的字符用省略号代替
"""
for comment in comments:
comment = comment.strip()
if len(comment) > max_length:
yield comment[:max_length] + '...'
else:
yield comment
print("\n".join(add_ellipsis_gen(comments)))
```
在新函数里,我们将依赖的参数类型从列表改成了可迭代的抽象类。这样做有很多好处,一个最明显的就是:无论评论是来自列表、元组或是某个文件,新函数都可以轻松满足:
```python
# 处理放在元组里的评论
comments = ("Implementation note", "Changed", "ABC for generator")
print("\n".join(add_ellipsis_gen(comments)))
# 处理放在文件里的评论
with open("comments") as fp:
for comment in add_ellipsis_gen(fp):
print(comment)
```
将依赖由某个具体的容器类型改为抽象接口后,函数的适用面变得更广了。除此之外,新函数在执行效率等方面也都更有优势。现在让我们再回到之前的问题。**从高层来看,什么定义了容器?**
答案是: **各个容器类型实现的接口协议定义了容器。** 不同的容器类型在我们的眼里,应该是 `是否可以迭代`、`是否可以修改`、`有没有长度` 等各种特性的组合。我们需要在编写相关代码时,**更多的关注容器的抽象属性,而非容器类型本身**,这样可以帮助我们写出更优雅、扩展性更好的代码。
> Hint在 [itertools](https://docs.python.org/3/library/itertools.html) 与 [more-itertools](https://pypi.org/project/more-itertools/) 模块里可以找到更多关于处理可迭代对象的宝藏。
## 常用技巧
### 1. 使用元组改善分支代码
有时,我们的代码里会出现超过三个分支的 `if/else` 。就像下面这样:
```python
import time
def from_now(ts):
"""接收一个过去的时间戳,返回距离当前时间的相对时间文字描述
"""
now = time.time()
seconds_delta = int(now - ts)
if seconds_delta < 1:
return "less than 1 second ago"
elif seconds_delta < 60:
return "{} seconds ago".format(seconds_delta)
elif seconds_delta < 3600:
return "{} minutes ago".format(seconds_delta // 60)
elif seconds_delta < 3600 * 24:
return "{} hours ago".format(seconds_delta // 3600)
else:
return "{} days ago".format(seconds_delta // (3600 * 24))
now = time.time()
print(from_now(now))
print(from_now(now - 24))
print(from_now(now - 600))
print(from_now(now - 7500))
print(from_now(now - 87500))
# OUTPUT:
# less than 1 second ago
# 24 seconds ago
# 10 minutes ago
# 2 hours ago
# 1 days ago
```
上面这个函数挑不出太多毛病,很多很多人都会写出类似的代码。但是,如果你仔细观察它,可以在分支代码部分找到一些明显的“**边界**”。 比如,当函数判断某个时间是否应该用“秒数”展示时,用到了 `60`。而判断是否应该用分钟时,用到了 `3600`
**从边界提炼规律是优化这段代码的关键。** 如果我们将所有的这些边界放在一个有序元组中,然后配合二分查找模块 [bisect](https://docs.python.org/3.7/library/bisect.html)。整个函数的控制流就能被大大简化:
```python
import bisect
# BREAKPOINTS 必须是已经排好序的,不然无法进行二分查找
BREAKPOINTS = (1, 60, 3600, 3600 * 24)
TMPLS = (
# unit, template
(1, "less than 1 second ago"),
(1, "{units} seconds ago"),
(60, "{units} minutes ago"),
(3600, "{units} hours ago"),
(3600 * 24, "{units} days ago"),
)
def from_now(ts):
"""接收一个过去的时间戳,返回距离当前时间的相对时间文字描述
"""
seconds_delta = int(time.time() - ts)
unit, tmpl = TMPLS[bisect.bisect(BREAKPOINTS, seconds_delta)]
return tmpl.format(units=seconds_delta // unit)
```
除了用元组可以优化过多的 `if/else` 分支外,有些情况下字典也能被用来做同样的事情。关键在于从现有代码找到重复的逻辑与规律,并多多尝试。
### 2. 在更多地方使用动态解包
动态解包操作是指使用 `*``**` 运算符将可迭代对象“解开”的行为,在 Python 2 时代,这个操作只能被用在函数参数部分,并且对出现顺序和数量都有非常严格的要求,使用场景非常单一。
```python
def calc(a, b, multiplier=1):
return (a + b) * multiplier
# Python2 中只支持在函数参数部分进行动态解包
print calc(*[1, 2], **{"multiplier": 10})
# OUTPUT: 30
```
不过Python 3 尤其是 3.5 版本后,`*` 和 `**` 的使用场景被大大扩充了。举个例子,在 Python 2 中,如果我们需要合并两个字典,需要这么做:
```python
def merge_dict(d1, d2):
# 因为字典是可被修改的对象,为了避免修改原对象,此处需要复制一个 d1 的浅拷贝
result = d1.copy()
result.update(d2)
return result
user = merge_dict({"name": "piglei"}, {"movies": ["Fight Club"]})
```
但是在 Python 3.5 以后的版本,你可以直接用 `**` 运算符来快速完成字典的合并操作:
```
user = {**{"name": "piglei"}, **{"movies": ["Fight Club"]}}
```
除此之外,你还可以在普通赋值语句中使用 `*` 运算符来动态地解包可迭代对象。如果你想详细了解相关内容,可以阅读下面推荐的 PEP。
> Hint推进动态解包场景扩充的两个 PEP
> - [PEP 3132 -- Extended Iterable Unpacking | Python.org](https://www.python.org/dev/peps/pep-3132/)
> - [PEP 448 -- Additional Unpacking Generalizations | Python.org](https://www.python.org/dev/peps/pep-0448/)
### 3. 使用 next() 函数
`next()` 是一个非常实用的内建函数,它接收一个迭代器作为参数,然后返回该迭代器的下一个元素。使用它配合生成器表达式,可以高效的实现 *“从列表中查找第一个满足条件的成员”* 之类的需求。
```python
numbers = [3, 7, 8, 2, 21]
# 获取并 **立即返回** 列表里的第一个偶数
print(next(i for i in numbers if i % 2 == 0))
# OUTPUT: 8
```
### 4. 使用有序字典来去重
字典和集合的结构特点保证了它们的成员不会重复所以它们经常被用来去重。但是使用它们俩去重后的结果会丢失原有列表的顺序。这是由底层数据结构“哈希表Hash Table”的特点决定的。
```python
>>> l = [10, 2, 3, 21, 10, 3]
# 去重但是丢失了顺序
>>> set(l)
{3, 10, 2, 21}
```
如果既需要去重又必须保留顺序怎么办?我们可以使用 `collections.OrderedDict` 模块:
```python
>>> from collections import OrderedDict
>>> list(OrderedDict.fromkeys(l).keys())
[10, 2, 3, 21]
```
> Hint: 在 Python 3.6 中,默认的字典类型修改了实现方式,已经变成有序的了。并且在 Python 3.7 中,该功能已经从 **语言的实现细节** 变成了为 **可依赖的正式语言特性**
>
> 但是我觉得让整个 Python 社区习惯这一点还需要一些时间,毕竟目前“字典是无序的”还是被印在无数本 Python 书上。所以,我仍然建议在一切需要有序字典的地方使用 OrderedDict。
## 常见误区
### 1. 当心那些已经枯竭的迭代器
在文章前面,我们提到了使用“懒惰”生成器的种种好处。但是,所有事物都有它的两面性。生成器的最大的缺点之一就是:**它会枯竭**。当你完整遍历过它们后,之后的重复遍历就不能拿到任何新内容了。
```python
numbers = [1, 2, 3]
numbers = (i * 2 for i in numbers)
# 第一次循环会输出 2, 4, 6
for number in numbers:
print(number)
# 这次循环什么都不会输出,因为迭代器已经枯竭了
for number in numbers:
print(number)
```
而且不光是生成器表达式Python 3 里的 map、filter 内建函数也都有一样的特点。忽视这个特点很容易导致代码中出现一些难以察觉的 Bug。
Instagram 就在项目从 Python 2 到 Python 3 的迁移过程中碰到了这个问题。它们在 PyCon 2017 上分享了对付这个问题的故事。访问文章 [Instagram 在 PyCon 2017 的演讲摘要](https://www.zlovezl.cn/articles/instagram-pycon-2017/),搜索“迭代器”可以查看详细内容。
### 2. 别在循环体内修改被迭代对象
这是一个很多 Python 初学者会犯的错误。比如,我们需要一个函数来删掉列表里的所有偶数:
```python
def remove_even(numbers):
"""去掉列表里所有的偶数
"""
for i, number in enumerate(numbers):
if number % 2 == 0:
# 有问题的代码
del numbers[i]
numbers = [1, 2, 7, 4, 8, 11]
remove_even(numbers)
print(numbers)
# OUTPUT: [1, 7, 8, 11]
```
注意到结果里那个多出来的 “8” 了吗?当你在遍历一个列表的同时修改它,就会出现这样的事情。因为被迭代的对象 `numbers` 在循环过程中被修改了。**遍历的下标在不断增长,而列表本身的长度同时又在不断缩减。这样就会导致列表里的一些成员其实根本就没有被遍历到。**
所以对于这类操作,请使用一个新的空列表保存结果,或者利用 `yield` 返回一个生成器。而不是修改被迭代的列表或是字典对象本身。
## 总结
在这篇文章中,我们首先从“容器类型”的定义出发,在底层和高层两个层面探讨了容器类型。之后遵循系列文章传统,提供了一些编写容器相关代码时的技巧。
让我们最后再总结一下要点:
- 了解容器类型的底层实现,可以帮助你写出性能更好的代码
- 提炼需求里的抽象概念,面向接口而非实现编程
- 多使用“懒惰”的对象,少生成“迫切”的列表
- 使用元组和字典可以简化分支代码结构
- 使用 `next()` 函数配合迭代器可以高效完成很多事情,但是也需要注意“枯竭”问题
- collections、itertools 模块里有非常多有用的工具,快去看看吧!
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【5.让函数返回结果的技巧】](5-function-returning-tips.md)
[<<<上一篇【3.使用数字与字符串的技巧】](3-tips-on-numbers-and-strings.md)
## 系列其他文章
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:善用变量改善代码质量](https://www.zlovezl.cn/articles/python-using-variables-well/)
- [Python 工匠:编写条件分支代码的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:使用数字与字符串的技巧](https://www.zlovezl.cn/articles/tips-on-numbers-and-strings/)
## 注解
1. <a id="annot1"></a>Python 这门语言除了 CPython 外,还有许许多多的其他版本实现。如无特别说明,本文以及 “Python 工匠” 系列里出现的所有 Python 都特指 Python 的 C 语言实现 CPython
2. <a id="annot2"></a>Python 里没有类似其他编程语言里的“Interface 接口”类型,只有类似的“抽象类”概念。为了表达方便,后面的内容均统一使用“接口”来替代“抽象类”。
3. <a id="annot3"></a>有没有只实现了 Mapping 但又不是 MutableMapping 的类型?试试 [MappingProxyType({})](https://docs.python.org/3/library/types.html#types.MappingProxyType)
4. <a id="annot4"></a>有没有只实现了 Set 但又不是 MutableSet 的类型?试试 [frozenset()](https://docs.python.org/3/library/stdtypes.html#frozenset)

View File

@ -1,414 +0,0 @@
# Python 工匠:让函数返回结果的技巧
## 序言
> 这是 “Python 工匠”系列的第 5 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/03/dominik-scythe-283337-unsplash-w1280.jpg" width="100%" />
</div>
毫无疑问,函数是 Python 语言里最重要的概念之一。在编程时,我们将真实世界里的大问题分解为小问题,然后通过一个个函数交出答案。函数既是重复代码的克星,也是对抗代码复杂度的最佳武器。
如同大部分故事都会有结局,绝大多数函数也都是以**返回结果**作为结束。函数返回结果的手法,决定了调用它时的体验。所以,了解如何优雅的让函数返回结果,是编写好函数的必备知识。
### Python 的函数返回方式
Python 函数通过调用 `return` 语句来返回结果。使用 `return value` 可以返回单个值,用 `return value1, value2` 则能让函数同时返回多个值。
如果一个函数体内没有任何 `return` 语句,那么这个函数的返回值默认为 `None`。除了通过 `return` 语句返回内容,在函数内还可以使用抛出异常 *raise Exception* 的方式来“返回结果”。
接下来,我将列举一些与函数返回相关的常用编程建议。
### 内容目录
- [Python 工匠:让函数返回结果的技巧](#python-工匠让函数返回结果的技巧)
- [序言](#序言)
- [Python 的函数返回方式](#python-的函数返回方式)
- [内容目录](#内容目录)
- [编程建议](#编程建议)
- [1. 单个函数不要返回多种类型](#1-单个函数不要返回多种类型)
- [2. 使用 partial 构造新函数](#2-使用-partial-构造新函数)
- [3. 抛出异常,而不是返回结果与错误](#3-抛出异常而不是返回结果与错误)
- [4. 谨慎使用 None 返回值](#4-谨慎使用-none-返回值)
- [1. 作为操作类函数的默认返回值](#1-作为操作类函数的默认返回值)
- [2. 作为某些“意料之中”的可能没有的值](#2-作为某些意料之中的可能没有的值)
- [3. 作为调用失败时代表“错误结果”的值](#3-作为调用失败时代表错误结果的值)
- [5. 合理使用“空对象模式”](#5-合理使用空对象模式)
- [6. 使用生成器函数代替返回列表](#6-使用生成器函数代替返回列表)
- [7. 限制递归的使用](#7-限制递归的使用)
- [总结](#总结)
- [附录](#附录)
## 编程建议
### 1. 单个函数不要返回多种类型
Python 语言非常灵活,我们能用它轻松完成一些在其他语言里很难做到的事情。比如:*让一个函数同时返回不同类型的结果。* 从而实现一种看起来非常实用的“多功能函数”。
就像下面这样:
```python
def get_users(user_id=None):
if user_id is not None:
return User.get(user_id)
else:
return User.filter(is_active=True)
# 返回单个用户
get_users(user_id=1)
# 返回多个用户
get_users()
```
当我们需要获取单个用户时,就传递 `user_id` 参数,否则就不传参数拿到所有活跃用户列表。一切都由一个函数 `get_users` 来搞定。这样的设计似乎很合理。
然而在函数的世界里,以编写具备“多功能”的瑞士军刀型函数为荣不是一件好事。这是因为好的函数一定是 [“单一职责Single responsibility](https://en.wikipedia.org/wiki/Single_responsibility_principle) 的。**单一职责意味着一个函数只做好一件事,目的明确。** 这样的函数也更不容易在未来因为需求变更而被修改。
而返回多种类型的函数一定是违反“单一职责”原则的,**好的函数应该总是提供稳定的返回值,把调用方的处理成本降到最低。** 像上面的例子,我们应该编写两个独立的函数 `get_user_by_id(user_id)`、`get_active_users()` 来替代。
### 2. 使用 partial 构造新函数
假设这么一个场景,在你的代码里有一个参数很多的函数 `A`,适用性很强。而另一个函数 `B` 则是完全通过调用 `A` 来完成工作,是一种类似快捷方式的存在。
比方在这个例子里, `double` 函数就是完全通过 `multiply` 来完成计算的:
```python
def multiply(x, y):
return x * y
def double(value):
# 返回另一个函数调用结果
return multiply(2, value)
```
对于上面这种场景,我们可以使用 `functools` 模块里的 [`partial()`](https://docs.python.org/3.6/library/functools.html#functools.partial) 函数来简化它。
`partial(func, *args, **kwargs)` 基于传入的函数与可变(位置/关键字)参数来构造一个新函数。**所有对新函数的调用,都会在合并了当前调用参数与构造参数后,代理给原始函数处理。**
利用 `partial` 函数,上面的 `double` 函数定义可以被修改为单行表达式,更简洁也更直接。
```python
import functools
double = functools.partial(multiply, 2)
```
> 建议阅读:[partial 函数官方文档](https://docs.python.org/3.6/library/functools.html#functools.partial)
### 3. 抛出异常,而不是返回结果与错误
我在前面提过Python 里的函数可以返回多个值。基于这个能力,我们可以编写一类特殊的函数:**同时返回结果与错误信息的函数。**
```python
def create_item(name):
if len(name) > MAX_LENGTH_OF_NAME:
return None, 'name of item is too long'
if len(CURRENT_ITEMS) > MAX_ITEMS_QUOTA:
return None, 'items is full'
return Item(name=name), ''
def create_from_input():
name = input()
item, err_msg = create_item(name)
if err_msg:
print(f'create item failed: {err_msg}')
else:
print(f'item<{name}> created')
```
在示例中,`create_item` 函数的作用是创建新的 Item 对象。同时,为了在出错时给调用方提供错误详情,它利用了多返回值特性,把错误信息作为第二个结果返回。
乍看上去,这样的做法很自然。尤其是对那些有 `Go` 语言编程经验的人来说更是如此。但是在 Python 世界里,这并非解决此类问题的最佳办法。因为这种做法会增加调用方进行错误处理的成本,尤其是当很多函数都遵循这个规范而且存在多层调用时。
Python 具备完善的*异常Exception*机制,并且在某种程度上鼓励我们使用异常([官方文档关于 EAFP 的说明](https://docs.python.org/3/glossary.html#term-eafp))。所以,**使用异常来进行错误流程处理才是更地道的做法。**
引入自定义异常后,上面的代码可以被改写成这样:
```python
class CreateItemError(Exception):
"""创建 Item 失败时抛出的异常"""
def create_item(name):
"""创建一个新的 Item
:raises: 当无法创建时抛出 CreateItemError
"""
if len(name) > MAX_LENGTH_OF_NAME:
raise CreateItemError('name of item is too long')
if len(CURRENT_ITEMS) > MAX_ITEMS_QUOTA:
raise CreateItemError('items is full')
return Item(name=name)
def create_for_input():
name = input()
try:
item = create_item(name)
except CreateItemError as e:
print(f'create item failed: {err_msg}')
else:
print(f'item<{name}> created')
```
使用“抛出异常”替代“返回 (结果, 错误信息)”后,整个错误流程处理乍看上去变化不大,但实际上有着非常多不同,一些细节:
- 新版本函数拥有更稳定的返回值类型,它永远只会返回 `Item` 类型或是抛出异常
- 虽然我在这里鼓励使用异常,但“异常”总是会无法避免的让人 **感到惊讶**,所以,最好在函数文档里说明可能抛出的异常类型
- 异常不同于返回值,它在被捕获前会不断往调用栈上层汇报。所以 `create_item` 的一级调用方完全可以省略异常处理,交由上层处理。这个特点给了我们更多的灵活性,但同时也带来了更大的风险。
> Hint如何在编程语言里处理错误是一个至今仍然存在争议的主题。比如像上面不推荐的多返回值方式正是缺乏异常的 Go 语言中最核心的错误处理机制。另外,即使是异常机制本身,不同编程语言之间也存在着差别。
>
> 异常,或是不异常,都是由语言设计者进行多方取舍后的结果,更多时候不存在绝对性的优劣之分。**但是,单就 Python 语言而言,使用异常来表达错误无疑是更符合 Python 哲学,更应该受到推崇的。**
### 4. 谨慎使用 None 返回值
`None` 值通常被用来表示 **“某个应该存在但是缺失的东西”**,它在 Python 里是独一无二的存在。很多编程语言里都有与 None 类似的设计,比如 JavaScript 里的 `null`、Go 里的 `nil` 等。因为 None 所拥有的独特 *虚无* 气质,它经常被作为函数返回值使用。
当我们使用 None 作为函数返回值时,通常是下面 3 种情况。
#### 1. 作为操作类函数的默认返回值
当某个操作类函数不需要任何返回值时,通常就会返回 None。同时None 也是不带任何 `return` 语句函数的默认返回值。
对于这种函数,使用 None 是没有任何问题的,标准库里的 `list.append()`、`os.chdir()` 均属此类。
#### 2. 作为某些“意料之中”的可能没有的值
有一些函数,它们的目的通常是去尝试性的做某件事情。视情况不同,最终可能有结果,也可能没有结果。**而对调用方来说,“没有结果”完全是意料之中的事情**。对这类函数来说,使用 None 作为“没结果”时的返回值也是合理的。
在 Python 标准库里,正则表达式模块 `re` 下的 `re.search`、`re.match` 函数均属于此类,这两个函数在可以找到匹配结果时返回 `re.Match` 对象,找不到时则返回 `None`
#### 3. 作为调用失败时代表“错误结果”的值
有时,`None` 也会经常被我们用来作为函数调用失败时的默认返回值,比如下面这个函数:
```python
def create_user_from_name(username):
"""通过用户名创建一个 User 实例"""
if validate_username(username):
return User.from_username(username)
else:
return None
user = create_user_from_name(username)
if user:
user.do_something()
```
当 username 不合法时,函数 `create_user_from_name` 将会返回 None。但在这个场景下这样做其实并不好。
不过你也许会觉得这个函数完全合情合理,甚至你会觉得它和我们提到的上一个“没有结果”时的用法非常相似。那么如何区分这两种不同情形呢?关键在于:**函数签名(名称与参数)与 None 返回值之间是否存在一种“意料之中”的暗示。**
让我解释一下,每当你让函数返回 None 值时,请**仔细阅读函数名**,然后问自己一个问题:*假如我是该函数的使用者,从这个名字来看,“拿不到任何结果”是否是该函数名称含义里的一部分?*
分别用这两个函数来举例:
- `re.search()`:从函数名来看,`search`,代表着从目标字符串里去**搜索**匹配结果,而搜索行为,一向是可能有也可能没有结果的,所以该函数适合返回 None
- `create_user_from_name()`:从函数名来看,代表基于一个名字来构建用户,并不能读出一种`可能返回、可能不返回`的含义。所以不适合返回 None
对于那些不能从函数名里读出 None 值暗示的函数来说,有两种修改方式。第一种,如果你坚持使用 None 返回值,那么请修改函数的名称。比如可以将函数 `create_user_from_name()` 改名为 `create_user_or_none()`
第二种方式则更常见的多:用抛出异常 *raise Exception* 来代替 None 返回值。因为,如果返回不了正常结果并非函数意义里的一部分,这就代表着函数出现了 *“意料以外的状况”*,而这正是 **Exceptions 异常** 所掌管的领域。
使用异常改写后的例子:
```python
class UnableToCreateUser(Exception):
"""当无法创建用户时抛出"""
def create_user_from_name(username):
"""通过用户名创建一个 User 实例
:raises: 当无法创建用户时抛出 UnableToCreateUser
"""
if validate_username(username):
return User.from_username(username)
else:
raise UnableToCreateUser(f'unable to create user from {username}')
try:
user = create_user_from_name(username)
except UnableToCreateUser:
# Error handling
else:
user.do_something()
```
与 None 返回值相比,抛出异常除了拥有我们在上个场景提到的那些特点外,还有一个额外的优势:**可以在异常信息里提供出现意料之外结果的原因**,这是只返回一个 None 值做不到的。
### 5. 合理使用“空对象模式”
我在前面提到函数可以用 `None` 值或异常来返回错误结果,但这两种方式都有一个共同的缺点。那就是所有需要使用函数返回值的地方,都必须加上一个 `if``try/except` 防御语句,来判断结果是否正常。
让我们看一个可运行的完整示例:
```python
import decimal
class CreateAccountError(Exception):
"""Unable to create a account error"""
class Account:
"""一个虚拟的银行账号"""
def __init__(self, username, balance):
self.username = username
self.balance = balance
@classmethod
def from_string(cls, s):
"""从字符串初始化一个账号"""
try:
username, balance = s.split()
balance = decimal.Decimal(float(balance))
except ValueError:
raise CreateAccountError('input must follow pattern "{ACCOUNT_NAME} {BALANCE}"')
if balance < 0:
raise CreateAccountError('balance can not be negative')
return cls(username=username, balance=balance)
def caculate_total_balance(accounts_data):
"""计算所有账号的总余额
"""
result = 0
for account_string in accounts_data:
try:
user = Account.from_string(account_string)
except CreateAccountError:
pass
else:
result += user.balance
return result
accounts_data = [
'piglei 96.5',
'cotton 21',
'invalid_data',
'roland $invalid_balance',
'alfred -3',
]
print(caculate_total_balance(accounts_data))
```
在这个例子里,每当我们调用 `Account.from_string` 时,都必须使用 `try/except` 来捕获可能发生的异常。如果项目里需要调用很多次该函数,这部分工作就变得非常繁琐了。针对这种情况,可以使用[“空对象模式Null object pattern”](https://en.wikipedia.org/wiki/Null_object_pattern)来改善这个控制流。
Martin Fowler 在他的经典著作[《重构》](https://martinfowler.com/books/refactoring.html) 中用一个章节详细说明过这个模式。简单来说,**就是使用一个符合正常结果接口的“空类型”来替代空值返回/抛出异常,以此来降低调用方处理结果的成本。**
引入“空对象模式”后,上面的示例可以被修改成这样:
```python
class Account:
# def __init__ 已省略... ...
@classmethod
def from_string(cls, s):
"""从字符串初始化一个账号
:returns: 如果输入合法,返回 Account object否则返回 NullAccount
"""
try:
username, balance = s.split()
balance = decimal.Decimal(float(balance))
except ValueError:
return NullAccount()
if balance < 0:
return NullAccount()
return cls(username=username, balance=balance)
class NullAccount:
username = ''
balance = 0
@classmethod
def from_string(cls, s):
raise NotImplementedError
```
在新版代码里,我定义了 `NullAccount` 这个新类型,用来作为 `from_string` 失败时的错误结果返回。这样修改后的最大变化体现在 `caculate_total_balance` 部分:
```python
def caculate_total_balance(accounts_data):
"""计算所有账号的总余额
"""
return sum(Account.from_string(s).balance for s in accounts_data)
```
调整之后,调用方不必再显式使用 try 语句来处理错误,而是可以假设 `Account.from_string` 函数总是会返回一个合法的 Account 对象,从而大大简化整个计算逻辑。
> Hint在 Python 世界里,“空对象模式”并不少见,比如大名鼎鼎的 Django 框架里的 [AnonymousUser](https://docs.djangoproject.com/en/2.1/ref/contrib/auth/#anonymoususer-object) 就是一个典型的 null object。
### 6. 使用生成器函数代替返回列表
在函数里返回列表特别常见,通常,我们会先初始化一个列表 `results = []`,然后在循环体内使用 `results.append(item)` 函数填充它,最后在函数的末尾返回。
对于这类模式,我们可以用生成器函数来简化它。粗暴点说,就是用 `yield item` 替代 `append` 语句。使用生成器的函数通常更简洁、也更具通用性。
```python
def foo_func(items):
for item in items:
# ... 处理 item 后直接使用 yield 返回
yield item
```
我在 [系列第 4 篇文章“容器的门道”](https://www.zlovezl.cn/articles/mastering-container-types/) 里详细分析过这个模式,更多细节可以访问文章,搜索 “写扩展性更好的代码” 查看。
### 7. 限制递归的使用
当函数返回自身调用时,也就是 `递归` 发生时。递归是一种在特定场景下非常有用的编程技巧但坏消息是Python 语言对递归支持的非常有限。
这份“有限的支持”体现在很多方面。首先Python 语言不支持[“尾递归优化”](https://en.wikipedia.org/wiki/Tail_call)。另外 Python 对最大递归层级数也有着严格的限制。
所以我建议:**尽量少写递归**。如果你想用递归解决问题,先想想它是不是能方便的用循环来替代。如果答案是肯定的,那么就用循环来改写吧。如果迫不得已,一定需要使用递归时,请考虑下面几个点:
- 函数输入数据规模是否稳定,是否一定不会超过 `sys.getrecursionlimit()` 规定的最大层数限制
- 是否可以通过使用类似 [functools.lru_cache](https://docs.python.org/3/library/functools.html#functools.lru_cache) 的缓存工具函数来降低递归层数
## 总结
在这篇文章中,我虚拟了一些与 Python 函数返回有关的场景,并针对每个场景提供了我的优化建议。最后再总结一下要点:
- 让函数拥有稳定的返回值,一个函数只做好一件事
- 使用 `functools.partial` 定义快捷函数
- 抛出异常也是返回结果的一种方式,使用它来替代返回错误信息
- 函数是否适合返回 None由函数签名的“含义”所决定
- 使用“空对象模式”可以简化调用方的错误处理逻辑
- 多使用生成器函数,尽量用循环替代递归
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【6.异常处理的三个好习惯】](6-three-rituals-of-exceptions-handling.md)
[<<<上一篇【4.容器的门道】](4-mastering-container-types.md)
## 附录
- 题图来源: Dominik Scythe on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:善用变量改善代码质量](https://www.zlovezl.cn/articles/python-using-variables-well/)
- [Python 工匠:编写条件分支代码的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:使用数字与字符串的技巧](https://www.zlovezl.cn/articles/tips-on-numbers-and-strings/)

View File

@ -1,326 +0,0 @@
# Python 工匠: 异常处理的三个好习惯
## 前言
> 这是 “Python 工匠”系列的第 6 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/03/bernard-hermant-665508-unsplash_w1280.jpg" width="100%" />
</div>
如果你用 Python 编程,那么你就无法避开异常,因为异常在这门语言里无处不在。打个比方,当你在脚本执行时按 `ctrl+c` 退出,解释器就会产生一个 `KeyboardInterrupt` 异常。而 `KeyError`、`ValueError`、`TypeError` 等更是日常编程里随处可见的老朋友。
异常处理工作由“捕获”和“抛出”两部分组成。“捕获”指的是使用 `try ... except` 包裹特定语句,妥当的完成错误流程处理。而恰当的使用 `raise` 主动“抛出”异常,更是优雅代码里必不可少的组成部分。
在这篇文章里,我会分享与异常处理相关的 3 个好习惯。继续阅读前,我希望你已经了解了下面这些知识点:
- 异常的基本语法与用法*(建议阅读官方文档 [“Errors and Exceptions”](https://docs.python.org/3.6/tutorial/errors.html)*
- 为什么要使用异常代替错误返回*(建议阅读[《让函数返回结果的技巧》](https://www.zlovezl.cn/articles/function-returning-tips/)*
- 为什么在写 Python 时鼓励使用异常 *(建议阅读 [“Write Cleaner Python: Use Exceptions”](https://jeffknupp.com/blog/2013/02/06/write-cleaner-python-use-exceptions/)*
## 三个好习惯
### 1. 只做最精确的异常捕获
假如你不够了解异常机制,就难免会对它有一种天然恐惧感。你可能会觉得:*异常是一种不好的东西,好的程序就应该捕获所有的异常,让一切都平平稳稳的运行。* 而抱着这种想法写出的代码,里面通常会出现大段含糊的异常捕获逻辑。
让我们用一段可执行脚本作为样例:
```python
# -*- coding: utf-8 -*-
import requests
import re
def save_website_title(url, filename):
"""获取某个地址的网页标题,然后将其写入到文件中
:returns: 如果成功保存,返回 True否则打印错误返回 False
"""
try:
resp = requests.get(url)
obj = re.search(r'<title>(.*)</title>', resp.text)
if not obj:
print('save failed: title tag not found in page content')
return False
title = obj.grop(1)
with open(filename, 'w') as fp:
fp.write(title)
return True
except Exception:
print(f'save failed: unable to save title of {url} to {filename}')
return False
def main():
save_website_title('https://www.qq.com', 'qq_title.txt')
if __name__ == '__main__':
main()
```
脚本里的 `save_website_title` 函数做了好几件事情。它首先通过网络获取网页内容,然后利用正则匹配出标题,最后将标题写在本地文件里。而这里有两个步骤很容易出错:**网络请求** 与 **本地文件操作**。所以在代码里,我们用一个大大的 `try ... except` 语句块,将这几个步骤都包裹了起来。**安全第一** ⛑。
那么,这段看上去简洁易懂的代码,里面藏着什么问题呢?
如果你旁边刚好有一台安装了 Python 的电脑,那么你可以试着跑一遍上面的脚本。你会发现,上面的代码是不能成功执行的。而且你还会发现,无论你如何修改网址和目标文件的值,程序仍然会报错 *“save failed: unable to...”*。为什么呢?
问题就藏在这个硕大无比的 `try ... except` 语句块里。假如你把眼睛贴近屏幕,非常仔细的检查这段代码。你会发现在编写函数时,我犯了一个**小错误**,我把获取正则匹配串的方法错打成了 `obj.grop(1)`,少了一个 'u'`obj.group(1)`)。
但正是因为那个过于庞大、含糊的异常捕获,这个由打错方法名导致的原本该被抛出的 `AttibuteError` 却被吞噬了。从而给我们的 debug 过程增加了不必要的麻烦。
异常捕获的目的,不是去捕获尽可能多的异常。假如我们从一开始就坚持:**只做最精准的异常捕获**。那么这样的问题就根本不会发生,精准捕获包括:
- 永远只捕获那些可能会抛出异常的语句块
- 尽量只捕获精确的异常类型,而不是模糊的 `Exception`
依照这个原则,我们的样例应该被改成这样:
```python
from requests.exceptions import RequestException
def save_website_title(url, filename):
try:
resp = requests.get(url)
except RequestException as e:
print(f'save failed: unable to get page content: {e}')
return False
# 这段正则操作本身就是不应该抛出异常的,所以我们没必要使用 try 语句块
# 假如 group 被误打成了 grop 也没关系,程序马上就会通过 AttributeError 来
# 告诉我们。
obj = re.search(r'<title>(.*)</title>', resp.text)
if not obj:
print('save failed: title tag not found in page content')
return False
title = obj.group(1)
try:
with open(filename, 'w') as fp:
fp.write(title)
except IOError as e:
print(f'save failed: unable to write to file {filename}: {e}')
return False
else:
return True
```
### 2. 别让异常破坏抽象一致性
大约四五年前,当时的我正在开发某移动应用的后端 API 项目。如果你也有过开发后端 API 的经验,那么你一定知道,这样的系统都需要制定一套 **“API 错误码规范”**,来为客户端处理调用错误时提供方便。
一个错误码返回大概长这个样子:
```javascript
// HTTP Status Code: 400
// Content-Type: application/json
{
"code": "UNABLE_TO_UPVOTE_YOUR_OWN_REPLY",
"detail": "你不能推荐自己的回复"
}
```
在制定好错误码规范后,接下来的任务就是如何实现它。当时的项目使用了 Django 框架,而 Django 的错误页面正是使用了异常机制实现的。打个比方,如果你想让一个请求返回 404 状态码,那么只要在该请求处理过程中执行 `raise Http404` 即可。
所以,我们很自然的从 Django 获得了灵感。首先,我们在项目内定义了错误码异常类:`APIErrorCode`。然后依据“错误码规范”,写了很多继承该类的错误码。当需要返回错误信息给用户时,只需要做一次 `raise` 就能搞定。
```python
raise error_codes.UNABLE_TO_UPVOTE
raise error_codes.USER_HAS_BEEN_BANNED
... ...
```
毫无意外,所有人都很喜欢用这种方式来返回错误码。因为它用起来非常方便,无论调用栈多深,只要你想给用户返回错误码,调用 `raise error_codes.ANY_THING` 就好。
随着时间推移,项目也变得越来越庞大,抛出 `APIErrorCode` 的地方也越来越多。有一天,我正准备复用一个底层图片处理函数时,突然碰到了一个问题。
我看到了一段让我非常纠结的代码:
```python
# 在某个处理图像的模块内部
# <PROJECT_ROOT>/util/image/processor.py
def process_image(...):
try:
image = Image.open(fp)
except Exception:
# 说明(非项目原注释):该异常将会被 Django 的中间件捕获,往前端返回
# "上传的图片格式有误" 信息
raise error_codes.INVALID_IMAGE_UPLOADED
... ...
```
`process_image` 函数会尝试解析一个文件对象,如果该对象不能被作为图片正常打开,就抛出 `error_codes.INVALID_IMAGE_UPLOADED APIErrorCode 子类)` 异常,从而给调用方返回错误代码 JSON。
让我给你从头理理这段代码。最初编写 `process_image` 时,我虽然把它放在了 `util.image` 模块里,但当时调这个函数的地方就只有 *“处理用户上传图片的 POST 请求”* 而已。为了偷懒,我让函数直接抛出 `APIErrorCode` 异常来完成了错误处理工作。
再来说当时的问题。那时我需要写一个在后台运行的批处理图片脚本,而它刚好可以复用 `process_image` 函数所实现的功能。但这时不对劲的事情出现了,如果我想复用该函数,那么:
- 我必须去捕获一个名为 `INVALID_IMAGE_UPLOADED` 的异常
- **哪怕我的图片根本就不是来自于用户上传**
- 我必须引入 `APIErrorCode` 异常类作为依赖来捕获异常
- **哪怕我的脚本和 Django API 根本没有任何关系**
**这就是异常类抽象层级不一致导致的结果**。`APIErrorCode` 异常类的意义,在于表达一种能够直接被终端用户(人)识别并消费的“错误代码”。**它在整个项目里,属于最高层的抽象之一**。但是出于方便,我们却在底层模块里引入并抛出了它。这打破了 `image.processor` 模块的抽象一致性,影响了它的可复用性和可维护性。
这类情况属于“模块抛出了**高于**所属抽象层级的异常”。避免这类错误需要注意以下几点:
- 让模块只抛出与当前抽象层级一致的异常
- 比如 `image.processer` 模块应该抛出自己封装的 `ImageOpenError` 异常
- 在必要的地方进行异常包装与转换
- 比如,应该在贴近高层抽象(视图 View 函数)的地方,将图像处理模块的 `ImageOpenError` 低级异常包装转换为 `APIErrorCode` 高级异常
修改后的代码:
```python
# <PROJECT_ROOT>/util/image/processor.py
class ImageOpenError(Exception):
pass
def process_image(...):
try:
image = Image.open(fp)
except Exception as e:
raise ImageOpenError(exc=e)
... ...
# <PROJECT_ROOT>/app/views.py
def foo_view_function(request):
try:
process_image(fp)
except ImageOpenError:
raise error_codes.INVALID_IMAGE_UPLOADED
```
除了应该避免抛出**高于**当前抽象级别的异常外,我们同样应该避免泄露**低于**当前抽象级别的异常。
如果你用过 `requests` 模块,你可能已经发现它请求页面出错时所抛出的异常,并不是它在底层所使用的 `urllib3` 模块的原始异常,而是通过 `requests.exceptions` 包装过一次的异常。
```python
>>> try:
... requests.get('https://www.invalid-host-foo.com')
... except Exception as e:
... print(type(e))
...
<class 'requests.exceptions.ConnectionError'>
```
这样做同样是为了保证异常类的抽象一致性。因为 urllib3 模块是 requests 模块依赖的底层实现细节,而这个细节有可能在未来版本发生变动。所以必须对它抛出的异常进行恰当的包装,避免未来的底层变更对 `requests` 用户端错误处理逻辑产生影响。
### 3. 异常处理不应该喧宾夺主
在前面我们提到异常捕获要精准、抽象级别要一致。但在现实世界中,如果你严格遵循这些流程,那么很有可能会碰上另外一个问题:**异常处理逻辑太多,以至于扰乱了代码核心逻辑**。具体表现就是,代码里充斥着大量的 `try`、`except`、`raise` 语句,让核心逻辑变得难以辨识。
让我们看一段例子:
```python
def upload_avatar(request):
"""用户上传新头像"""
try:
avatar_file = request.FILES['avatar']
except KeyError:
raise error_codes.AVATAR_FILE_NOT_PROVIDED
try:
resized_avatar_file = resize_avatar(avatar_file)
except FileTooLargeError as e:
raise error_codes.AVATAR_FILE_TOO_LARGE
except ResizeAvatarError as e:
raise error_codes.AVATAR_FILE_INVALID
try:
request.user.avatar = resized_avatar_file
request.user.save()
except Exception:
raise error_codes.INTERNAL_SERVER_ERROR
return HttpResponse({})
```
这是一个处理用户上传头像的视图函数。这个函数内做了三件事情,并且针对每件事都做了异常捕获。如果做某件事时发生了异常,就返回对用户友好的错误到前端。
这样的处理流程纵然合理,但是显然代码里的异常处理逻辑有点“喧宾夺主”了。一眼看过去全是代码缩进,很难提炼出代码的核心逻辑。
早在 2.5 版本时Python 语言就已经提供了对付这类场景的工具“上下文管理器context manager”。上下文管理器是一种配合 `with` 语句使用的特殊 Python 对象,通过它,可以让异常处理工作变得更方便。
那么,如何利用上下文管理器来改善我们的异常处理流程呢?让我们直接看代码吧。
```python
class raise_api_error:
"""captures specified exception and raise ApiErrorCode instead
:raises: AttributeError if code_name is not valid
"""
def __init__(self, captures, code_name):
self.captures = captures
self.code = getattr(error_codes, code_name)
def __enter__(self):
# 该方法将在进入上下文时调用
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# 该方法将在退出上下文时调用
# exc_type, exc_val, exc_tb 分别表示该上下文内抛出的
# 异常类型、异常值、错误栈
if exc_type is None:
return False
if exc_type == self.captures:
raise self.code from exc_val
return False
```
在上面的代码里,我们定义了一个名为 `raise_api_error` 的上下文管理器,它在进入上下文时什么也不做。但是在退出上下文时,会判断当前上下文中是否抛出了类型为 `self.captures` 的异常,如果有,就用 `APIErrorCode` 异常类替代它。
使用该上下文管理器后,整个函数可以变得更清晰简洁:
```python
def upload_avatar(request):
"""用户上传新头像"""
with raise_api_error(KeyError, 'AVATAR_FILE_NOT_PROVIDED'):
avatar_file = request.FILES['avatar']
with raise_api_error(ResizeAvatarError, 'AVATAR_FILE_INVALID'),\
raise_api_error(FileTooLargeError, 'AVATAR_FILE_TOO_LARGE'):
resized_avatar_file = resize_avatar(avatar_file)
with raise_api_error(Exception, 'INTERNAL_SERVER_ERROR'):
request.user.avatar = resized_avatar_file
request.user.save()
return HttpResponse({})
```
> Hint建议阅读 [PEP 343 -- The "with" Statement | Python.org](https://www.python.org/dev/peps/pep-0343/),了解与上下文管理器有关的更多知识。
>
> 模块 [contextlib](https://docs.python.org/3/library/contextlib.html) 也提供了非常多与编写上下文管理器相关的工具函数与样例。
## 总结
在这篇文章中,我分享了与异常处理相关的三个建议。最后再总结一下要点:
- 只捕获可能会抛出异常的语句,避免含糊的捕获逻辑
- 保持模块异常类的抽象一致性,必要时对底层异常类进行包装
- 使用“上下文管理器”可以简化重复的异常处理逻辑
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【7.编写地道循环的两个建议】](7-two-tips-on-loop-writing.md)
[<<<上一篇【5.让函数返回结果的技巧】](5-function-returning-tips.md)
## 附录
- 题图来源: Photo by Bernard Hermant on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:善用变量改善代码质量](https://www.zlovezl.cn/articles/python-using-variables-well/)
- [Python 工匠:编写条件分支代码的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:让程序返回结果的技巧](https://www.zlovezl.cn/articles/function-returning-tips/)

View File

@ -1,324 +0,0 @@
# Python 工匠:编写地道循环的两个建议
## 前言
> 这是 “Python 工匠”系列的第 7 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/04/lai-man-nung-1205465-unsplash_w1280.jpg" width="100%" />
</div>
循环是一种常用的程序控制结构。我们常说,机器相比人类的最大优点之一,就是机器可以不眠不休的重复做某件事情,但人却不行。而**“循环”**,则是实现让机器不断重复工作的关键概念。
在循环语法方面Python 表现的即传统又不传统。它虽然抛弃了常见的 `for (init; condition; incrment)` 三段式结构,但还是选择了 `for``while` 这两个经典的关键字来表达循环。绝大多数情况下,我们的循环需求都可以用 `for <item> in <iterable>` 来满足,`while <condition>` 相比之下用的则更少些。
虽然循环的语法很简单,但是要写好它确并不容易。在这篇文章里,我们将探讨什么是“地道”的循环代码,以及如何编写它们。
## 什么是“地道”的循环?
“地道”这个词,通常被用来形容某人做某件事情时,非常符合当地传统,做的非常好。打个比方,你去参加一个朋友聚会,同桌的有一位广东人,对方一开口,句句都是标准京腔、完美儿化音。那你可以对她说:“您的北京话说的真**地道**”。
既然“地道”这个词形容的经常是口音、做菜的口味这类实实在在的东西,那“地道”的循环代码又是什么意思呢?让我拿一个经典的例子来解释一下。
如果你去问一位刚学习 Python 一个月的人:“*如何在遍历一个列表的同时获取当前下标?*”。他可能会交出这样的代码:
```python
index = 0
for name in names:
print(index, name)
index += 1
```
上面的循环虽然没错,但它确一点都不“地道”。一个拥有三年 Python 开发经验的人会说,代码应该这么写:
```python
for i, name in enumerate(names):
print(i, name)
```
[`enumerate()`](https://docs.python.org/3/library/functions.html#enumerate) 是 Python 的一个内置函数,它接收一个“可迭代”对象作为参数,然后返回一个不断生成 `(当前下标, 当前元素)` 的新可迭代对象。这个场景使用它最适合不过。
所以,在上面的例子里,我们会认为第二段循环代码比第一段更“地道”。因为它用更直观的代码,更聪明的完成了工作。
### enumerate() 所代表的编程思路
不过,判断某段循环代码是否地道,并不仅仅是以知道或不知道某个内置方法作为标准。我们可以从上面的例子挖掘出更深层的东西。
如你所见Python 的 `for` 循环只有 `for <item> in <iterable>` 这一种结构,而结构里的前半部分 - *赋值给 item* - 没有太多花样可玩。所以后半部分的 **可迭代对象** 是我们唯一能够大做文章的东西。而以 `enumerate()` 函数为代表的 *“修饰函数”*,刚好提供了一种思路:**通过修饰可迭代对象来优化循环本身。**
这就引出了我的第一个建议。
## 建议1使用函数修饰被迭代对象来优化循环
使用修饰函数处理可迭代对象,可以在各种方面影响循环代码。而要找到合适的例子来演示这个方法,并不用去太远,内置模块 [itertools](https://docs.python.org/3.6/library/itertools.html) 就是一个绝佳的例子。
简单来说itertools 是一个包含很多面向可迭代对象的工具函数集。我在之前的系列文章[《容器的门道》](https://www.zlovezl.cn/articles/mastering-container-types/)里提到过它。
如果要学习 itertools那么 [Python 官方文档](https://docs.python.org/3.6/library/itertools.html) 是你的首选,里面有非常详细的模块相关资料。但在这篇文章里,侧重点将和官方文档稍有不同。我会通过一些常见的代码场景,来详细解释它是如何改善循环代码的。
### 1. 使用 product 扁平化多层嵌套循环
虽然我们都知道*“扁平的代码比嵌套的好”*。但有时针对某类需求,似乎一定得写多层嵌套循环才行。比如下面这段:
```python
def find_twelve(num_list1, num_list2, num_list3):
"""从 3 个数字列表中,寻找是否存在和为 12 的 3 个数
"""
for num1 in num_list1:
for num2 in num_list2:
for num3 in num_list3:
if num1 + num2 + num3 == 12:
return num1, num2, num3
```
对于这种需要嵌套遍历多个对象的多层循环代码,我们可以使用 [product()](https://docs.python.org/3.6/library/itertools.html#itertools.product) 函数来优化它。`product()` 可以接收多个可迭代对象,然后根据它们的笛卡尔积不断生成结果。
```python
from itertools import product
def find_twelve_v2(num_list1, num_list2, num_list3):
for num1, num2, num3 in product(num_list1, num_list2, num_list3):
if num1 + num2 + num3 == 12:
return num1, num2, num3
```
相比之前的代码,使用 `product()` 的函数只用了一层 for 循环就完成了任务,代码变得更精炼了。
### 2. 使用 islice 实现循环内隔行处理
有一份包含 Reddit 帖子标题的外部数据文件,里面的内容格式是这样的:
```
python-guide: Python best practices guidebook, written for humans.
---
Python 2 Death Clock
---
Run any Python Script with an Alexa Voice Command
---
<... ...>
```
可能是为了美观,在这份文件里的每两个标题之间,都有一个 `"---"` 分隔符。现在,我们需要获取文件里所有的标题列表,所以在遍历文件内容的过程中,必须跳过这些无意义的分隔符。
参考之前对 `enumerate()` 函数的了解,我们可以通过在循环内加一段基于当前循环序号的 `if` 判断来做到这一点:
```python
def parse_titles(filename):
"""从隔行数据文件中读取 reddit 主题名称
"""
with open(filename, 'r') as fp:
for i, line in enumerate(fp):
# 跳过无意义的 '---' 分隔符
if i % 2 == 0:
yield line.strip()
```
但对于这类在循环内进行隔行处理的需求来说,如果使用 itertools 里的 [islice()](https://docs.python.org/3.6/library/itertools.html#itertools.islice) 函数修饰被循环对象,可以让循环体代码变得更简单直接。
`islice(seq, start, end, step)` 函数和数组切片操作* list[start:stop:step] *有着几乎一模一样的参数。如果需要在循环内部进行隔行处理的话,只要设置第三个递进步长参数 step 值为 2 即可*(默认为 1*。
```python
from itertools import islice
def parse_titles_v2(filename):
with open(filename, 'r') as fp:
# 设置 step=2跳过无意义的 '---' 分隔符
for line in islice(fp, 0, None, 2):
yield line.strip()
```
### 3. 使用 takewhile 替代 break 语句
有时,我们需要在每次循环开始时,判断循环是否需要提前结束。比如下面这样:
```python
for user in users:
# 当第一个不合格的用户出现后,不再进行后面的处理
if not is_qualified(user):
break
# 进行处理 ... ...
```
对于这类需要提前中断的循环,我们可以使用 [takewhile()](https://docs.python.org/3.6/library/itertools.html#itertools.takewhile) 函数来简化它。`takewhile(predicate, iterable)` 会在迭代 `iterable` 的过程中不断使用当前对象作为参数调用 `predicate` 函数并测试返回结果,如果函数返回值为真,则生成当前对象,循环继续。否则立即中断当前循环。
使用 `takewhile` 的代码样例:
```
from itertools import takewhile
for user in takewhile(is_qualified, users):
# 进行处理 ... ...
```
itertools 里面还有一些其他有意思的工具函数,他们都可以用来和循环搭配使用,比如使用 chain 函数扁平化双层嵌套循环、使用 zip_longest 函数一次同时循环多个对象等等。
篇幅有限,我在这里不再一一介绍。如果有兴趣,可以自行去官方文档详细了解。
### 4. 使用生成器编写自己的修饰函数
除了 itertools 提供的那些函数外,我们还可以非常方便的使用生成器来定义自己的循环修饰函数。
让我们拿一个简单的函数举例:
```python
def sum_even_only(numbers):
"""对 numbers 里面所有的偶数求和"""
result = 0
for num in numbers:
if num % 2 == 0:
result += num
return result
```
在上面的函数里,循环体内为了过滤掉所有奇数,引入了一条额外的 `if` 判断语句。如果要简化循环体内容,我们可以定义一个生成器函数来专门进行偶数过滤:
```python
def even_only(numbers):
for num in numbers:
if num % 2 == 0:
yield num
def sum_even_only_v2(numbers):
"""对 numbers 里面所有的偶数求和"""
result = 0
for num in even_only(numbers):
result += num
return result
```
`numbers` 变量使用 `even_only` 函数装饰后,`sum_even_only_v2` 函数内部便不用继续关注“偶数过滤”逻辑了,只需要简单完成求和即可。
> Hint当然上面的这个函数其实并不实用。在现实世界里这种简单需求最适合直接用生成器/列表表达式搞定:`sum(num for num in numbers if num % 2 == 0)`
## 建议2按职责拆解循环体内复杂代码块
我一直觉得循环是一个比较神奇的东西,每当你写下一个新的循环代码块,就好像开辟了一片黑魔法阵,阵内的所有内容都会开始无休止的重复执行。
但我同时发现,这片黑魔法阵除了能带来好处,**它还会引诱你不断往阵内塞入越来越多的代码,包括过滤掉无效元素、预处理数据、打印日志等等。甚至一些原本不属于同一抽象的内容,也会被塞入到同一片黑魔法阵内。**
你可能会觉得这一切理所当然,我们就是迫切需要阵内的魔法效果。如果不把这一大堆逻辑塞满到循环体内,还能把它们放哪去呢?
让我们来看看下面这个业务场景。在网站中,有一个每 30 天执行一次的周期脚本,它的任务是是查询过去 30 天内,在每周末特定时间段登录过的用户,然后为其发送奖励积分。
代码如下:
```python
import time
import datetime
def award_active_users_in_last_30days():
"""获取所有在过去 30 天周末晚上 8 点到 10 点登录过的用户,为其发送奖励积分
"""
days = 30
for days_delta in range(days):
dt = datetime.date.today() - datetime.timedelta(days=days_delta)
# 5: Saturday, 6: Sunday
if dt.weekday() not in (5, 6):
continue
time_start = datetime.datetime(dt.year, dt.month, dt.day, 20, 0)
time_end = datetime.datetime(dt.year, dt.month, dt.day, 23, 0)
# 转换为 unix 时间戳,之后的 ORM 查询需要
ts_start = time.mktime(time_start.timetuple())
ts_end = time.mktime(time_end.timetuple())
# 查询用户并挨个发送 1000 奖励积分
for record in LoginRecord.filter_by_range(ts_start, ts_end):
# 这里可以添加复杂逻辑
send_awarding_points(record.user_id, 1000)
```
上面这个函数主要由两层循环构成。外层循环的职责,主要是获取过去 30 天内符合要求的时间,并将其转换为 UNIX 时间戳。之后由内层循环使用这两个时间戳进行积分发送。
如之前所说,外层循环所开辟的黑魔法阵内被塞的满满当当。但通过观察后,我们可以发现 **整个循环体其实是由两个完全无关的任务构成的:“挑选日期与准备时间戳” 以及 “发送奖励积分”**
### 复杂循环体如何应对新需求
这样的代码有什么坏处呢?让我来告诉你。
某日,产品找过来说,有一些用户周末半夜不睡觉,还在刷我们的网站,我们得给他们发通知让他们以后早点睡觉。于是新需求出现了:**“给过去 30 天内在周末凌晨 3 点到 5 点登录过的用户发送一条通知”**。
新问题也随之而来。敏锐如你,肯定一眼可以发现,这个新需求在用户筛选部分的要求,和之前的需求非常非常相似。但是,如果你再打开之前那团循环体看看,你会发现代码根本没法复用,因为在循环内部,不同的逻辑完全被 **耦合** 在一起了。☹️
在计算机的世界里,我们经常用**“耦合”**这个词来表示事物之间的关联关系。上面的例子中,*“挑选时间”*和*“发送积分”*这两件事情身处同一个循环体内,建立了非常强的耦合关系。
为了更好的进行代码复用,我们需要把函数里的*“挑选时间”*部分从循环体中解耦出来。而我们的老朋友,**“生成器函数”** 是进行这项工作的不二之选。
### 使用生成器函数解耦循环体
要把 *“挑选时间”* 部分从循环内解耦出来,我们需要定义新的生成器函数 `gen_weekend_ts_ranges()`,专门用来生成需要的 UNIX 时间戳:
```python
def gen_weekend_ts_ranges(days_ago, hour_start, hour_end):
"""生成过去一段时间内周六日特定时间段范围,并以 UNIX 时间戳返回
"""
for days_delta in range(days_ago):
dt = datetime.date.today() - datetime.timedelta(days=days_delta)
# 5: Saturday, 6: Sunday
if dt.weekday() not in (5, 6):
continue
time_start = datetime.datetime(dt.year, dt.month, dt.day, hour_start, 0)
time_end = datetime.datetime(dt.year, dt.month, dt.day, hour_end, 0)
# 转换为 unix 时间戳,之后的 ORM 查询需要
ts_start = time.mktime(time_start.timetuple())
ts_end = time.mktime(time_end.timetuple())
yield ts_start, ts_end
```
有了这个生成器函数后,旧需求“发送奖励积分”和新需求“发送通知”,就都可以在循环体内复用它来完成任务了:
```python
def award_active_users_in_last_30days_v2():
"""发送奖励积分"""
for ts_start, ts_end in gen_weekend_ts_ranges(30, hour_start=20, hour_end=23):
for record in LoginRecord.filter_by_range(ts_start, ts_end):
send_awarding_points(record.user_id, 1000)
def notify_nonsleep_users_in_last_30days():
"""发送通知"""
for ts_start, ts_end in gen_weekend_ts_ranges(30, hour_start=3, hour_end=6):
for record in LoginRecord.filter_by_range(ts_start, ts_end):
notify_user(record.user_id, 'You should sleep more')
```
## 总结
在这篇文章里,我们首先简单解释了“地道”循环代码的定义。然后提出了第一个建议:使用修饰函数来改善循环。之后我虚拟了一个业务场景,描述了按职责拆解循环内代码的重要性。
一些要点总结:
- 使用函数修饰被循环对象本身,可以改善循环体内的代码
- itertools 里面有很多工具函数都可以用来改善循环
- 使用生成器函数可以轻松定义自己的修饰函数
- 循环内部,是一个极易发生“代码膨胀”的场地
- 请使用生成器函数将循环内不同职责的代码块解耦出来,获得更好的灵活性
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【8.使用装饰器的技巧】](8-tips-on-decorators.md)
[<<<上一篇【6.异常处理的三个好习惯】](6-three-rituals-of-exceptions-handling.md)
## 附录
- 题图来源: Photo by Lai man nung on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:容器的门道](https://www.zlovezl.cn/articles/mastering-container-types/)
- [Python 工匠:编写条件分支代码的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:异常处理的三个好习惯](https://www.zlovezl.cn/articles/three-rituals-of-exceptions-handling/)

View File

@ -1,361 +0,0 @@
# Python 工匠:使用装饰器的技巧
## 前言
> 这是 “Python 工匠”系列的第 8 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/05/clem-onojeghuo-142120-unsplash_w1280.jpg" width="100%" />
</div>
装饰器 *(Decorator)* 是 Python 里的一种特殊工具,它为我们提供了一种在函数外部修改函数的灵活能力。它有点像一顶画着独一无二 `@` 符号的神奇帽子,只要将它戴在函数头顶上,就能悄无声息的改变函数本身的行为。
你可能已经和装饰器打过不少交道了。在做面向对象编程时,我们就经常会用到 `@staticmethod``@classmethod` 两个内置装饰器。此外,如果你接触过 [click](https://click.palletsprojects.com/en/7.x/) 模块就更不会对装饰器感到陌生。click 最为人所称道的参数定义接口 `@click.option(...)` 就是利用装饰器实现的。
除了用装饰器,我们也经常需要自己写一些装饰器。在这篇文章里,我将从 `最佳实践``常见错误` 两个方面,来与你分享有关装饰器的一些小知识。
## 最佳实践
### 1. 尝试用类来实现装饰器
绝大多数装饰器都是基于函数和 [闭包](https://en.wikipedia.org/wiki/Closure_(computer_programming)) 实现的但这并非制造装饰器的唯一方式。事实上Python 对某个对象是否能通过装饰器(`@decorator`)形式使用只有一个要求:**decorator 必须是一个“可被调用callable的对象**。
```python
# 使用 callable 可以检测某个对象是否“可被调用”
>>> def foo(): pass
...
>>> type(foo)
<class 'function'>
>>> callable(foo)
True
```
函数自然是“可被调用”的对象。但除了函数外我们也可以让任何一个类class变得“可被调用”callable。办法很简单只要自定义类的 `__call__` 魔法方法即可。
```python
class Foo:
def __call__(self):
print("Hello, __call___")
foo = Foo()
# OUTPUT: True
print(callable(foo))
# 调用 foo 实例
# OUTPUT: Hello, __call__
foo()
```
基于这个特性,我们可以很方便的使用类来实现装饰器。
下面这段代码,会定义一个名为 `@delay(duration)` 的装饰器,使用它装饰过的函数在每次执行前,都会等待额外的 `duration` 秒。同时,我们也希望为用户提供无需等待马上执行的 `eager_call` 接口。
```python
import time
import functools
class DelayFunc:
def __init__(self, duration, func):
self.duration = duration
self.func = func
def __call__(self, *args, **kwargs):
print(f'Wait for {self.duration} seconds...')
time.sleep(self.duration)
return self.func(*args, **kwargs)
def eager_call(self, *args, **kwargs):
print('Call without delay')
return self.func(*args, **kwargs)
def delay(duration):
"""装饰器:推迟某个函数的执行。同时提供 .eager_call 方法立即执行
"""
# 此处为了避免定义额外函数,直接使用 functools.partial 帮助构造
# DelayFunc 实例
return functools.partial(DelayFunc, duration)
```
如何使用装饰器的样例代码:
```python
@delay(duration=2)
def add(a, b):
return a + b
# 这次调用将会延迟 2 秒
add(1, 2)
# 这次调用将会立即执行
add.eager_call(1, 2)
```
`@delay(duration)` 就是一个基于类来实现的装饰器。当然,如果你非常熟悉 Python 里的函数和闭包,上面的 `delay` 装饰器其实也完全可以只用函数来实现。所以,为什么我们要用类来做这件事呢?
与纯函数相比,我觉得使用类实现的装饰器在**特定场景**下有几个优势:
- 实现有状态的装饰器时,操作类属性比操作闭包内变量更符合直觉、不易出错
- 实现为函数扩充接口的装饰器时,使用类包装函数,比直接为函数对象追加属性更易于维护
- 更容易实现一个同时兼容装饰器与上下文管理器协议的对象(参考 [unitest.mock.patch](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch)
### 2. 使用 wrapt 模块编写更扁平的装饰器
在写装饰器的过程中,你有没有碰到过什么不爽的事情?不管你有没有,反正我有。我经常在写代码的时候,被下面两件事情搞得特别难受:
1. 实现带参数的装饰器时,层层嵌套的函数代码特别难写、难读
2. 因为函数和类方法的不同,为前者写的装饰器经常没法直接套用在后者上
比如,在下面的例子里,我实现了一个生成随机数并注入为函数参数的装饰器。
```python
import random
def provide_number(min_num, max_num):
"""装饰器:随机生成一个在 [min_num, max_num] 范围的整数,追加为函数的第一个位置参数
"""
def wrapper(func):
def decorated(*args, **kwargs):
num = random.randint(min_num, max_num)
# 将 num 作为第一个参数追加后调用函数
return func(num, *args, **kwargs)
return decorated
return wrapper
@provide_number(1, 100)
def print_random_number(num):
print(num)
# 输出 1-100 的随机整数
# OUTPUT: 72
print_random_number()
```
`@provide_number` 装饰器功能看上去很不错,但它有着我在前面提到的两个问题:**嵌套层级深、无法在类方法上使用。** 如果直接用它去装饰类方法,会出现下面的情况:
```
class Foo:
@provide_number(1, 100)
def print_random_number(self, num):
print(num)
# OUTPUT: <__main__.Foo object at 0x104047278>
Foo().print_random_number()
```
`Foo` 类实例中的 `print_random_number` 方法将会输出类实例 `self` ,而不是我们期望的随机数 `num`
之所以会出现这个结果,是因为类方法 *method* 和函数 *function* 二者在工作机制上有着细微不同。如果要修复这个问题,`provider_number` 装饰器在修改类方法的位置参数时,必须聪明的跳过藏在 `*args` 里面的类实例 `self` 变量,才能正确的将 `num` 作为第一个参数注入。
这时,就应该是 [wrapt](https://pypi.org/project/wrapt/) 模块闪亮登场的时候了。`wrapt` 模块是一个专门帮助你编写装饰器的工具库。利用它,我们可以非常方便的改造 `provide_number` 装饰器,完美解决*“嵌套层级深”*和*“无法通用”*两个问题,
```python
import wrapt
def provide_number(min_num, max_num):
@wrapt.decorator
def wrapper(wrapped, instance, args, kwargs):
# 参数含义:
#
# - wrapped被装饰的函数或类方法
# - instance
# - 如果被装饰者为普通类方法,该值为类实例
# - 如果被装饰者为 classmethod 类方法,该值为类
# - 如果被装饰者为类/函数/静态方法,该值为 None
#
# - args调用时的位置参数注意没有 * 符号)
# - kwargs调用时的关键字参数注意没有 ** 符号)
#
num = random.randint(min_num, max_num)
# 无需关注 wrapped 是类方法或普通函数,直接在头部追加参数
args = (num,) + args
return wrapped(*args, **kwargs)
return wrapper
<... 应用装饰器部分代码省略 ...>
# OUTPUT: 48
Foo().print_random_number()
```
使用 `wrapt` 模块编写的装饰器,相比原来拥有下面这些优势:
- 嵌套层级少:使用 `@wrapt.decorator` 可以将两层嵌套减少为一层
- 更简单:处理位置与关键字参数时,可以忽略类实例等特殊情况
- 更灵活:针对 `instance` 值进行条件判断后,更容易让装饰器变得通用
## 常见错误
### 1. “装饰器”并不是“装饰器模式”
[“设计模式”](https://en.wikipedia.org/wiki/Software_design_pattern)是一个在计算机世界里鼎鼎大名的词。假如你是一名 Java 程序员,而你一点设计模式都不懂,那么我打赌你找工作的面试过程一定会度过的相当艰难。
但写 Python 时,我们极少谈起“设计模式”。虽然 Python 也是一门支持面向对象的编程语言,但它的 [鸭子类型](https://en.wikipedia.org/wiki/Duck_typing) 设计以及出色的动态特性决定了,大部分设计模式对我们来说并不是必需品。所以,很多 Python 程序员在工作很长一段时间后,可能并没有真正应用过几种设计模式。
不过 [*“装饰器模式Decorator Pattern”*](https://en.wikipedia.org/wiki/Decorator_pattern) 是个例外。因为 Python 的“装饰器”和“装饰器模式”有着一模一样的名字,我不止一次听到有人把它们俩当成一回事,认为使用“装饰器”就是在实践“装饰器模式”。但事实上,**它们是两个完全不同的东西。**
“装饰器模式”是一个完全基于“面向对象”衍生出的编程手法。它拥有几个关键组成:**一个统一的接口定义**、**若干个遵循该接口的类**、**类与类之间一层一层的包装**。最终由它们共同形成一种 *“装饰”* 的效果。
而 Python 里的“装饰器”和“面向对象”没有任何直接联系,**它完全可以只是发生在函数和函数间的把戏**。事实上,“装饰器”并没有提供某种无法替代的功能,它仅仅就是一颗[“语法糖”](https://en.wikipedia.org/wiki/Syntactic_sugar)而已。下面这段使用了装饰器的代码:
```python
@log_time
@cache_result
def foo(): pass
```
基本完全等同于下面这样:
```python
def foo(): pass
foo = log_time(cache_result(foo))
```
**装饰器最大的功劳,在于让我们在某些特定场景时,可以写出更符合直觉、易于阅读的代码**。它只是一颗“糖”,并不是某个面向对象领域的复杂编程模式。
> Hint: 在 Python 官网上有一个 [实现了装饰器模式的例子](https://wiki.python.org/moin/DecoratorPattern),你可以读读这个例子来更好的了解它。
### 2. 记得用 functools.wraps() 装饰内层函数
下面是一个简单的装饰器,专门用来打印函数调用耗时:
```python
import time
def timer(wrapped):
"""装饰器:记录并打印函数耗时"""
def decorated(*args, **kwargs):
st = time.time()
ret = wrapped(*args, **kwargs)
print('execution take: {} seconds'.format(time.time() - st))
return ret
return decorated
@timer
def random_sleep():
"""随机睡眠一小会"""
time.sleep(random.random())
```
`timer` 装饰器虽然没有错误,但是使用它装饰函数后,函数的原始签名就会被破坏。也就是说你再也没办法正确拿到 `random_sleep` 函数的名称、文档内容了,所有签名都会变成内层函数 `decorated` 的值:
```python
print(random_sleep.__name__)
# 输出 'decorated'
print(random_sleep.__doc__)
# 输出 None
```
这虽然只是个小问题,但在某些时候也可能会导致难以察觉的 bug。幸运的是标准库 `functools` 为它提供了解决方案,你只需要在定义装饰器时,用另外一个装饰器再装饰一下内层 `decorated` 函数就行。
听上去有点绕,但其实就是新增一行代码而已:
```python
def timer(wrapped):
# 将 wrapper 函数的真实签名赋值到 decorated 上
@functools.wraps(wrapped)
def decorated(*args, **kwargs):
# <...> 已省略
return decorated
```
这样处理后,`timer` 装饰器就不会影响它所装饰的函数了。
```python
print(random_sleep.__name__)
# 输出 'random_sleep'
print(random_sleep.__doc__)
# 输出 '随机睡眠一小会'
```
### 3. 修改外层变量时记得使用 nonlocal
装饰器是对函数对象的一个高级应用。在编写装饰器的过程中,你会经常碰到内层函数需要修改外层函数变量的情况。就像下面这个装饰器一样:
```python
import functools
def counter(func):
"""装饰器:记录并打印调用次数"""
count = 0
@functools.wraps(func)
def decorated(*args, **kwargs):
# 次数累加
count += 1
print(f"Count: {count}")
return func(*args, **kwargs)
return decorated
@counter
def foo():
pass
foo()
```
为了统计函数调用次数,我们需要在 `decorated` 函数内部修改外层函数定义的 `count` 变量的值。但是,上面这段代码是有问题的,在执行它时解释器会报错:
```raw
Traceback (most recent call last):
File "counter.py", line 22, in <module>
foo()
File "counter.py", line 11, in decorated
count += 1
UnboundLocalError: local variable 'count' referenced before assignment
```
这个错误是由 `counter``decorated` 函数互相嵌套的作用域引起的。
当解释器执行到 `count += 1` 时,并不知道 `count` 是一个在外层作用域定义的变量,它把 `count` 当做一个局部变量,并在当前作用域内查找。最终却没有找到有关 `count` 变量的任何定义,然后抛出错误。
为了解决这个问题,我们需要通过 `nonlocal` 关键字告诉解释器:**“count 变量并不属于当前的 local 作用域,去外面找找吧”**,之前的错误就可以得到解决。
```python
def decorated(*args, **kwargs):
nonlocal count
count += 1
# <... 已省略 ...>
```
> Hint如果要了解更多有关 nonlocal 关键字的历史,可以查阅 [PEP-3104](https://www.python.org/dev/peps/pep-3104/)
## 总结
在这篇文章里,我与你分享了有关装饰器的一些技巧与小知识。
一些要点总结:
- 一切 callable 的对象都可以被用来实现装饰器
- 混合使用函数与类,可以更好的实现装饰器
- wrapt 模块很有用,用它可以帮助我们用更简单的代码写出复杂装饰器
- “装饰器”只是语法糖,它不是“装饰器模式”
- 装饰器会改变函数的原始签名,你需要 `functools.wraps`
- 在内层函数修改外层函数的变量时,需要使用 `nonlocal` 关键字
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【9.一个关于模块的小故事】](9-a-story-on-cyclic-imports.md)
[<<<上一篇【7.编写地道循环的两个建议】](7-two-tips-on-loop-writing.md)
## 附录
- 题图来源: Photo by Clem Onojeghuo on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:编写条件分支代码的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:异常处理的三个好习惯](https://www.zlovezl.cn/articles/three-rituals-of-exceptions-handling/)
- [Python 工匠:编写地道循环的两个建议](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)

View File

@ -1,198 +0,0 @@
# Python 工匠:一个关于模块的小故事
## 前言
> 这是 “Python 工匠”系列的第 9 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.zlovezl.cn/static/uploaded/2019/05/ricardo-gomez-angel-669574-unsplash_w1280.jpg" width="100%" />
</div>
模块Module是我们用来组织 Python 代码的基本单位。很多功能强大的复杂站点,都由成百上千个独立模块共同组成。
虽然模块有着不可替代的用处,但它有时也会给我们带来麻烦。比如,当你接手一个新项目后,刚展开项目目录。第一眼就看到了攀枝错节、难以理解的模块结构,那你肯定会想: *“这项目也太难搞了。”* 😂
在这篇文章里,我准备了一个和模块有关的小故事与你分享。
## 一个关于模块的小故事
小 R 是一个刚从学校毕业的计算机专业学生。半个月前,他面试进了一家互联网公司做 Python 开发,负责一个与用户活动积分有关的小项目。项目的主要功能是查询站点活跃用户,并为他们发送有关活动积分的通知: *“亲爱的用户,您好,您当前的活动积分为 x”*
项目主要由 `notify_users.py` 脚本和 `fancy_site` 包组成,结构与各文件内容如下:
```text
├── fancy_site
│   ├── __init__.py
│   ├── marketing.py # 与市场活动有关的内容
│   └── users.py # 与用户有关的内容
└── notify_users.py # 脚本:发送积分通知
```
文件 `notify_users.py`
```python
from fancy_site.users import list_active_users
from fancy_site.marketing import query_user_points
def main():
"""获取所有的活跃用户,将积分情况发送给他们"""
users = list_active_users()
points = query_user_points(users)
for user in users:
user.add_notification(... ...)
# <... 已省略 ...>
```
文件 `fancy_site/users.py`
```python
from typing import List
class User:
# <... 已省略 ...>
def add_notification(self, message: str):
"""为用户发送新通知"""
pass
def list_active_users() -> List[User]:
"""查询所有活跃用户"""
pass
```
文件:`fancy_site/marketing.py`
```python
from typing import List
from .users import User
def query_user_points(users: List[User]) -> List[int]:
"""批量查询用户活动积分"""
def send_sms(phone_number: str, message: str):
"""为某手机号发送短信"""
```
只要在项目目录下执行 `python notify_user.py`,就能实现给所有活跃用户发送通知。
### 需求变更
但有一天,产品经理找过来说,光给用户发站内信通知还不够,容易被用户忽略。除了站内信以外,我们还需要同时给用户推送一条短信通知。
琢磨了五秒钟后,小 R 跟产品经理说:*“这个需求可以做!”*。毕竟给手机号发送短信的 `send_sms()` 函数早就已经有人写好了。他只要先给 `add_notification` 方法添加一个可选参数 `enable_sms=False`,当传值为 `True` 时调用 `fancy_site.marketing` 模块里的 `send_sms` 函数就行。
一切听上去根本没有什么难度可言,十分钟后,小 R 就把 `user.py` 改成了下面这样:
```python
# 导入 send_sms 模块的发送短信函数
from .marketing import send_sms
class User:
# <...> 相关初始化代码已省略
def add_notification(self, message: str, enable_sms=False):
"""为用户添加新通知"""
if enable_sms:
send_sms(user.mobile_number, ... ...)
```
但是,当他修改完代码,再次执行 `notify_users.py` 脚本时,程序却报错了:
```raw
Traceback (most recent call last):
File "notify_users.py", line 2, in <module>
from fancy_site.users import list_active_users
File .../fancy_site/users.py", line 3, in <module>
from .marketing import send_sms
File ".../fancy_site/marketing.py", line 3, in <module>
from .users import User
ImportError: cannot import name 'User' from 'fancy_site.users' (.../fancy_site/users.py)
```
错误信息说,无法从 `fancy_site.users` 模块导入 `User` 对象。
### 解决环形依赖问题
小 R 仔细分析了一下错误,发现错误是因为 `users``marketing` 模块之间产生的环形依赖关系导致的。
当程序在 `notify_users.py` 文件导入 `fancy_site.users` 模块时,`users` 模块发现自己需要从 `marketing` 模块那里导入 `send_sms` 函数。而解释器在加载 `marketing` 模块的过程中,又反过来发现自己需要依赖 `users` 模块里面的 `User` 对象。
如此一来,整个模块依赖关系成为了环状,程序自然也就没法执行下去了。
![modules_before](https://www.zlovezl.cn/static/uploaded/2019/05/modules_before.png)
不过,没有什么问题能够难倒一个可以正常访问 Google 的程序员。小 R 随便上网一搜,发现这样的问题很好解决。因为 Python 的 import 语句非常灵活,他只需要 **把在 users 模块内导入 send_sms 函数的语句挪到 `add_notification` 方法内,延缓 import 语句的执行就行啦。**
```python
class User:
# <...> 相关初始化代码已省略
def add_notification(self, message: str, send_sms=False):
"""为用户添加新通知"""
# 延缓 import 语句执行
from .marketing import send_sms
```
改动一行代码后,大功告成。小 R 简单测试后,发现一切正常,然后把代码推送了上去。不过小 R 还没来得及为自己点个赞,意料之外的事情发生了。
这段明明几乎完美的代码改动在 **Code Review** 的时候被审计人小 C 拒绝了。
### 小 C 的疑问
小 R 的同事小 C 是一名有着多年经验的 Python 程序员,他对小 R 说:“使用延迟 import虽然可以马上解决包导入问题。但这个小问题背后隐藏了更多的信息。比如**你有没有想过 send_sms 函数,是不是已经不适合放在 marketing 模块里了?”**
被小 C 这么一问,聪明的小 R 马上意识到了问题所在。要在 `users` 模块内发送短信,重点不在于用延迟导入解决环形依赖。而是要以此为契机,**发现当前模块间依赖关系的不合理,拆分/合并模块,创建新的分层与抽象,最终消除环形依赖。**
认识清楚问题后,他很快提交了新的代码修改。在新代码中,他创建了一个专门负责通知与消息类的工具模块 `msg_utils`,然后把 `send_sms` 函数挪到了里面。之后 `users` 模块内就可以毫无困难的从 `msg_utils` 模块中导入 `send_sms` 函数了。
```python
from .msg_utils import send_sms
```
新的模块依赖关系如下图所示:
![modules_afte](https://www.zlovezl.cn/static/uploaded/2019/05/modules_after.png)
在新的模块结构中,整个项目被整齐的分为三层,模块间的依赖关系也变得只有**单向流动**。之前在函数内部 `import` 的“延迟导入”技巧,自然也就没有用武之地了。
小 R 修改后的代码获得了大家的认可,很快就被合并到了主分支。故事暂告一段落,那么这个故事告诉了我们什么道理呢?
## 总结
模块间的循环依赖是一个在大型 Python 项目中很常见的问题,越复杂的项目越容易碰到这个问题。当我们在参与这些项目时,**如果对模块结构、分层、抽象缺少应有的重视。那么项目很容易就会慢慢变得复杂无比、难以维护。**
所以,合理的模块结构与分层非常重要。它可以大大降低开发人员的心智负担和项目维护成本。这也是我为什么要和你分享这个简单故事的原因。“在函数内延迟 import” 的做法当然没有错,但我们更应该关注的是:**整个项目内的模块依赖关系与分层是否合理。**
最后,让我们再尝试从 小 R 的故事里强行总结出几个道理吧:
- 合理的模块结构与分层可以降低项目的开发维护成本
- 合理的模块结构不是一成不变的,应该随着项目发展调整
- 遇到问题时,不要选**“简单但有缺陷”**的那个方案,要选**“麻烦但正确”**的那个
- 整个项目内的模块间依赖关系流向,应该是单向的,不能有环形依赖存在
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【10.做一个精通规则的玩家】](10-a-good-player-know-the-rules.md)
[<<<上一篇【8.使用装饰器的技巧】](8-tips-on-decorators.md)
## 附录
- 题图来源: Photo by Ricardo Gomez Angel on Unsplash
- 更多系列文章地址https://github.com/piglei/one-python-craftsman
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:编写条件分支代码的技巧](https://www.zlovezl.cn/articles/python-else-block-secrets/)
- [Python 工匠:异常处理的三个好习惯](https://www.zlovezl.cn/articles/three-rituals-of-exceptions-handling/)
- [Python 工匠:编写地道循环的两个建议](https://www.zlovezl.cn/articles/two-tips-on-loop-writing/)