《Python编程规范》
什么样的代码才是优秀的代码
许多去 Google 参观的人,用完洗手间后,都会惊奇而略带羞涩地问:“你们马桶前面的门上,贴着的 Python 编程规范,是用来搞笑的吗?”
这事儿还真不是搞笑,Google 对编码规范的要求极其严格。今天,我们就来聊聊编程规范这件事儿。
1.代码是写给谁的?
随着科技与互联网的进步,今天得我们可以很容易从事计算机相关工作,基本上只要简单懂一些计算机编程相关的内容,就可以写出让计算机理解和执行的代码,但是处在当下的我们,写出的代码虽然是需要计算机执行.但是更多的情况下,我们编写的代码是给人类理解和阅读的.
代码更多是写给人看,而不是仅仅给机器跑而已
代码千万条,整洁第一条,代码不规范,同事两行泪
2.代码不规范导致的问题
- 刚找到一份新工作,想要快速融入,结果看看开发的项目代码一团如麻.
- 当你的上司看到你写的代码时,你解释了半天他依然一头雾水
- 作为一个老司机,本想在公司大干一场,结果天天在改代码找bug
- 作为团队中的一员,你们每个人的代码风格迥异.
- 在电脑前工作的你,码字速度越来越快,代码越写越多,结果最终你像码农一样垒代码.却跑题了..
- 无数个日夜疯狂敲代码的你,在提交代码后终于长舒一口气,却发现测试没过…
- 开发到一半的项目,在休假结束后,本想快速继续开发,却发现有点看不懂了..
- 总是被催促着,着急赶工,代码写的乱七八糟,特性越加越多,崩溃的几率越来越大,最后代码无法管理,产品下线,公司倒闭..
- 着急写出的烂代码居然能够运行,心想着有朝一日能够再回头清理,结果稍后等于永不
- 团队初期开发进展迅速,但是随着时间却慢如蜗牛,代码的每次修改都会影响其它几处代码.
- 项目随着开发团队人员的变动和迭代,每一次都会有所改变,却越来越糟糕,最后束手无策.
3.代码风格规范的好处
任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。
- 利于团队合作
- 发现潜在的bug
- 提高运行效率
- 降低维护成本
- 有助于代码审查
- 有利于代码安全
- 有助于自身的成长
- 令人心情愉悦
4.什么样的代码才是优秀的代码
好的代码,就像是好的笑话——无需解释就能让别人明白。如果你的代码能够做到不解自明,在大多数时候,你根本无需为其配备说明文档。
举例:
好代码像是一本写作技巧高超的人所写的书
1.容易理解
2.分章明确,每一章都有清晰的主旨
而坏的代码像是刚刚学会写字的人所写的书
1.各个章节之间纷乱复杂,每一章都没有明确的主旨
2.连篇累牍的重复一句话,而且毫无缘由
3.作者在一开始设定了一些规则,但是在后面的内容中却自己不断的违反这些规则
4.突然间书里出现了一个吸血鬼,而且还能在白天出来吸血。
优秀代码的特点
- 可读性——不只是你,还有你身边与你合作的其他开发者
- 可维护性——让你的代码在修改的时候很简单
- 简洁性——不要让你的代码看上去毫无必要的复杂
- 效率性——尽可能的让你的代码获得最快的运行速度
- 明确性——如果你的代码能够做到不解自明,在大多数时候,你根本无需为其配备说明文档。在为方法和属性命名的时候,做到尽可能的合理。把长的代码进行拆分。不要复制/粘贴代码块。
如果你的同事不能轻松的看懂你写的代码,那么你的代码就不够好。
如何知道我写的代码好不好?
简单的代码质量测试
1.找一个从来没读过你的代码的开发者,让他看你的代码,并且让他试着说出每一个模块的作用。
2.如果你经常需要向他进行解释,那么说明你的代码不够好。解释的次数越多,代码的质量就越低。
3.如果你只是静静的坐在一边,他无需问你任何问题,那说明你的代码质量很高。
当你在写代码的时候,一些信号可以证明你写的代码质量不错:
- 代码写的很聪明,但是又不会过分的聪明
- 无论在速度上,还是可读性上,你都使用了最佳的算法
- 类、变量和函数都得到了正确的命名,让人看一眼就能理解
- 休息了一个周末之后,你继续写代码,发现自己可以立刻继续之前的工作
- 那些需要重复使用的东西总是可用
- 你所使用的方法都很短,最理想的情况下要少于50行,最多不超过100行而且能够完美的执行单个任务
- 在调用方法的时候,你有着足够的信息,无需在代码堆中苦苦寻找
- 能够很轻松的在此前的代码中进行功能添加和修改
- try/catch块的体量尽可能的小
- 毫不费力的就可以写出单元测试
当然以上都是经验的总结,其实关于写代码,还是有很多编程规范可以遵循的.也是我们本次课程中的重点内容.
总结:好看+安全+高效
规范修炼-PEP8规范解读
当你的老大让你去修改别人的代码时,当你怀着热切的心情打开代码定睛一瞧,缩进错乱,命名不规范,通篇没注释,你是不是有那木一刻非常想提起四十米的大刀大喊一声:狗贼,来吃洒家一刀!!!
因代码不规范,码农枪击4名同事的事件真的在美国出现过.
为了防止世界被破坏,为了守护世界的和平,呸呸呸,串台了。咱吧,也不为别的,就为了把代码写的漂亮整洁,让同事一看,嚯,这代码真靓。
有句笑话是这样讲的:代码写的好的人,离职后找个人很快就能接手代码,而代码写的乱的人则是公司的不可替代人才!为何叫不可替代人才呢,自己写的代码回头看一看自己都看不懂,这境界就叫做不可替代。
因此我们需要非常重视编程规范?那么什么是编程规范,又都有哪些呢?
1.编程规范指的是什么
编程规范,指的是一组规则或指导意见,建议你,要求你,在编程时,用某种计算机语言写代码时,如何写,要遵循什么意见或建议。
一般来说,不是强制的,但是是多数人都遵守的一些规范。
不过,很多公司,倒是强制性的,使用统一的某种规范,此时,此规范或约定,就是要强制性的实行了。
在讨论编程规范时,另外还有几个常提到的概念,在此处可以理解基本上是同一个意思:
- 编程约定Coding Convention(s)
- 编程规范Coding Rule(s)
- 编程风格Coding Style(s)
- 编码标准Coding Standard(s)
2.编程规范有哪些
- 不同编程语言有自己的编程规范
- 不同公司或组织有自己的编程规范
- PEP8解读
- google规范解读
规范里面大部分是 不要做的项多, 要做的比较少, 落地比较容易 代码最主要的不是性能,而是可读性, 有了可读性才有维护性 我觉得我的编码习惯比技术更有价值
傻瓜都能写出计算机可以读懂的代码, 只有优秀的程序员才能写出人能读懂的代码
程序猿不招妹子们喜爱的根本原因在于程序员追求了错误的目标:更短、更小、更快。
3.PEP8解读
python官网PEP8文档地址:www.python.org/dev/peps/pe…
1. 什么是PEP?
PEP是 Python Enhancement Proposal 的缩写,翻译过来就是 Python增强建议书 。
PEP8译者:本文基于 2013-08-02 最后修改的 PEP8 版本翻译,若要查看英文原文,请参考PEP8
然而,有时候编码规范的建议并不适用。
许多项目都有一套专有的编码风格指南,当冲突发生时,应以项目编码规范为优先。
特别是不要为了遵守PEP约定而破坏兼容性!
当以下情况发生时,也是忽略某个风格指南的好理由:
- 当遵守规范会降低代码可读性,甚至对于那些依循 PEP 去阅读代码的人也是这样时。
- 当遵守规范会与其他部分的代码风格背离时 — 当然也许这是一个修正某些混乱代码的机会。
- 当那些并没有遵循规范的旧代码已无法修改时,而且也没有充足的理由去修改他们。
- 当你的代码需要与旧版本的 Python 保持兼容,而旧版本的 Python 不支持规范中提到的特性时。
2.PEP8的主要内容
- 代码布局
- 字符串引号
- 表达式与空格
- 注释的使用
- 文档描述
- 命名规范
- 编码建议
3. 代码布局
1. 缩进
- 每一级缩进使用4个空格。
- 续行应该与其包裹元素对齐,要么使用圆括号、方括号和花括号内的隐式行连接来垂直对齐,
要么使用挂行缩进对齐
- 当使用挂行缩进时,应该考虑到第一行不应该有参数,以及使用缩进以区分自己是续行。
不推荐:
# 没有使用垂直对齐时,禁止把参数放在第一行
foo = long_function_name(var_one, var_two,
var_three, var_four)
# 当缩进没有与其他行区分时,要增加缩进
def long_function_name(
var_one, var_two, var_three,
var_four):
print(var_one)
推荐:
# 与左括号对齐
foo = long_function_name(var_one, var_two,
var_three, var_four)
# 用更多的缩进来与其他行区分
def long_function_name(
var_one, var_two, var_three,
var_four):
print(var_one)
# 挂行缩进应该再换一行
foo = long_function_name(
var_one, var_two,
var_three, var_four)
- 挂行缩进不一定要用4个空格
# 挂行缩进不一定要用4个空格
foo = long_function_name(
var_one, var_two,
var_three, var_four)
- 当if语句的条件部分长到需要换行写的时候,注意可以在两个字符关键字的连接处(比如if),增加一个空格,再增加一个左括号来创造一个4空格缩进的多行条件。这会与if语句内同样使用4空格缩进的代码产生视觉冲突。对于如何(或是否)在视觉上进一步将这些条件行与
if语句内的嵌套套件区分开,PEP不做任何明确的表述。可使用的选项包括但不限于下面几种情况:
# 没有额外的缩进
if (this_is_one_thing and
that_is_another_thing):
do_something()
# 增加一个注释,在能提供语法高亮的编辑器中可以有一些区分
if (this_is_one_thing and
that_is_another_thing):
# Since both conditions are true, we can frobnicate.
do_something()
# 在条件判断的语句添加额外的缩进
if (this_is_one_thing
and that_is_another_thing):
do_something()
- 在多行结构中的大括号/中括号/小括号的右括号可以与内容对齐单独起一行作为最后一行的第一个字符,
# 就像这样:
my_list = [
1, 2, 3,
4, 5, 6,
]
result = some_function_that_takes_arguments(
'a', 'b', 'c',
'd', 'e', 'f',
)
# 或者也可以与多行结构的第一行第一个字符对齐,就像这样:
my_list = [
1, 2, 3,
4, 5, 6,
]
result = some_function_that_takes_arguments(
'a', 'b', 'c',
'd', 'e', 'f',
)
2. 制表符还是空格?
空格是首选的缩进方式。 制表符只能用于与同样使用制表符缩进的代码保持一致。 Python3不允许同时使用空格和制表符的缩进。
3. 行的最大长度
- 所有行限制的最大字符数为79。
- 没有结构化限制的大块文本(文档字符或者注释),每行的最大字符数限制在72。
4. 在二元运算符之前应该换行吗?
几十年来,推荐的风格是在二元运算符之后中断。但是这回影响可读性,原因有二:操作符一般分布在屏幕上不同的列中,而且每个运算符被移到了操作数的上一行。
下面例子这个情况就需要额外注意,那些变量是相加的,那些变量是相减的:
# 不推荐: 操作符离操作数太远
income = (gross_wages +
taxable_interest +
(dividends - qualified_dividends) -
ira_deduction -
student_loan_interest)
为了解决这种可读性的问题,数学家和他们的出版商遵循了相反的约定。
遵循数学的传统能产出更多可读性高的代码:
# 推荐:运算符和操作数很容易进行匹配
income = (gross_wages
+ taxable_interest
+ (dividends - qualified_dividends)
- ira_deduction
- student_loan_interest)
5. 空行
- 顶层函数和类的定义,前后用两个空行隔开。
- 类里的方法定义用一个空行隔开。
- 相关的功能组可以用额外的空行(谨慎使用)隔开。
- 一堆相关的单行代码之间的空白行可以省略。
- 在函数中使用空行来区分逻辑段(谨慎使用)。
6. 源文件编码
-
Python核心发布版本中的代码总是以UTF-8格式编码(或者在Python2中用ASCII编码)。
-
使用ASCII(在Python2中)或UTF-8(在Python3中)编码的文件不应具有编码声明。
-
对于Python 3和更高版本,标准库规定了以下策略(参见 PEP 3131):
- Python标准库中的所有标识符必须使用ASCII标识符,并在可行的情况下使用英语单词(在许多情况下,缩写和技术术语是非英语的)。
- 此外,字符串文字和注释也必须是ASCII。
7. Imports 导入
-
导入通常在分开的行,例如:
# 推荐: import os import sys # 不推荐: import sys, os # 也可以: from subprocess import Popen, PIPE -
导入总是位于文件的顶部,在模块注释和文档字符串之后,在模块的全局变量与常量之前。
-
导入应该按照以下顺序分组:
1. 标准库导入 2. 相关第三方库导入 3. 本地应用/库特定导入 4. 你应该在每一组导入之间加入空行 -
推荐使用绝对路径导入,如果导入系统没有正确的配置(比如包里的一个目录在sys.path里的路径后),使用绝对路径会更加可读并且性能更好(至少能提供更好的错误信息):
import mypkg.sibling from mypkg import sibling from mypkg.sibling import example -
当从一个包含类的模块中导入类时,常常这么写:
from myclass import MyClass from foo.bar.yourclass import YourClass -
如果上述的写法导致名字的冲突,那么这么写:
import myclass import foo.bar.yourclass然后使用“myclass.MyClass”和“foo.bar.yourclass.YourClass”。
-
避免通配符的导入(from import *),因为这样做会不知道命名空间中存在哪些名字,会使得读取接口和许多自动化工具之间产生混淆。
8.模块级Dunder名称
模块级“dunders”(也就是名字里有两个前缀下划线和两个后缀下划线)
如__all__,__author__,__version__等应被放置在模块文档字符串之后,以及除from __future__ 之外的import表达式前面。Python要求将来在模块中的导入,必须出现在除文档字符串之外的其他代码之前。
"""This is the example module.
This module does stuff.
"""
from __future__ import barry_as_FLUFL
__all__ = ['a', 'b', 'c']
__version__ = '0.1'
__author__ = 'Cardinal Biggles'
import os
import sys
4. 字符串引号
在Python中,单引号和双引号字符串是相同的。PEP不会为这个给出建议。选择一条规则并坚持使用下去。当一个字符串中包含单引号或者双引号字符的时候,使用和最外层不同的符号来避免使用反斜杠,从而提高可读性。
对于三引号字符串,总是使用双引号字符来与PEP 257中的文档字符串约定保持一致。
5. 表达式和语句中的空格
总体原则,避免不必要的空格。
- 各种右括号前不要加空格。
# 符合约定的代码
spam(ham[1], {eggs: 2})
# 不符合约定的代码
spam( ham[ 1 ], { eggs: 2 } )
- 逗号、冒号、分号前不要加空格。
# 符合约定的代码
if x == 4: print x, y; x, y = y, x
# 不符合约定的代码
if x == 4 : print x , y ; x , y = y , x
- 函数的左括号前不要加空格。如Func(1)。
# 符合约定代码
spam(1)
# 不符合约定的代码
spam (1)
- 序列的左括号前不要加空格。如list[2]。
# 符合约定的代码
dict['key'] = list[index]
# 不符合约定的代码
dict ['key'] = list [index]
- 操作符左右各加一个空格,不要为了对齐增加空格。
# 符合约定的代码
x = 1
y = 2
long_variable = 3
# 不符合约定的代码
x = 1
y = 2
long_variable = 3
- 函数默认参数使用的赋值符左右省略空格。
# 符合约定的代码
def complex(real, imag=0.0):
return magic(r=real, i=imag)
# 不符合约定的代码
def complex(real, imag = 0.0):
return magic(r = real, i = imag)
- 不要将多句语句写在同一行,尽管使用’;’允许。
- if/for/while语句中,即使执行语句只有一句,也必须另起一行。
复合语句(同一行中的多个语句)通常是不允许的。
# 推荐:
if foo == 'blah':
do_blah_thing()
do_one()
do_two()
do_three()
# 不推荐:
if foo == 'blah': do_blah_thing()
do_one(); do_two(); do_three()
# 虽然有时候将小的代码块和 if/for/while 放在同一行没什么问题,多行语句块的情况不要这样用,同样也要避免代码行太长!
# 不推荐:
if foo == 'blah': do_blah_thing()
for x in lst: total += x
while t < 10: t = delay()
# 不推荐:
if foo == 'blah': do_blah_thing()
else: do_non_blah_thing()
try: something()
finally: cleanup()
# 不推荐:
do_one(); do_two(); do_three(long, argument,
list, like, this)
if foo == 'blah': one(); two(); three()
6. Comments 注释
总体原则,错误的注释不如没有注释。所以当一段代码发生变化时,第一件事就是要修改注释! 注释必须使用英文,最好是完整的句子,首字母大写,句后要有结束符,结束符后跟两个空格,开始下一句。 如果是短语,可以省略结束符。
- 块注释 在一段代码前增加的注释。在‘#’后加一空格。段落之间以只有‘#’的行间隔。比如:
# Description : Module config.
#
# Input : None
#
# Output : None
- 行注释 在一句代码后加注释。比如:
x = x + 1 # Increment x
但是这种方式尽量少使用。 3. 避免无谓的注释。
-
文档字符串 要为所有的公共模块,函数,类以及方法编写文档说明。 非公共的方法没有必要,但是应该有一个描述方法具体作用的注释。这个注释应该在def那一行之后。 对于单行的文档说明,尾部的三引号应该和文档在同一行。 特别需要注意的是,多行文档说明使用的结尾三引号应该自成一行,例如:
"""Return a foobang Optional plotz says to frobnicate the bizbaz first. """
7. Naming Conventions 命名规范
总体原则,新编代码必须按下面命名风格进行,现有库的编码尽量保持风格。
Python库的命名规范很乱,从来没能做到完全一致。但是目前有一些推荐的命名标准。
约定俗成的命名约定:
-
Names to Avoid 应避免的名字
- 永远不要使用字母‘l’(小写的L),‘O’(大写的O),或者‘I’(大写的I)作为单字符变量名。
- 在有些字体里,这些字符无法和数字0和1区分,如果想用‘l’,用‘L’代替。
-
Class Names 类名
- 类名一般使用首字母大写的约定。
- 在接口被文档化并且主要被用于调用的情况下,可以使用函数的命名风格代替。
- 注意,对于内置的变量命名有一个单独的约定:大部分内置变量是单个单词(或者两个单词连接在一起),首字母大写的命名法只用于异常名或者内部的常量。
-
Function Names 函数名
- 函数名应该小写,如果想提高可读性可以用下划线分隔。
- 大小写混合仅在为了兼容原来主要以大小写混合风格的情况下使用(比如 threading.py),保持向后兼容性。
-
Function and method arguments 函数和方法参数
- 始终要将 self 作为实例方法的的第一个参数。
- 始终要将 cls 作为类静态方法的第一个参数。
- 如果函数的参数名和已有的关键词冲突,在最后加单一下划线比缩写或随意拼写更好。因此 class_ 比 clss 更好。(也许最好用同义词来避免这种冲突)
总结:
- 严格要求4个空格缩进,而不是制表符
- 注意代码长度,每行不超过79个字符,并适当使用换行符
- 注意适当的代码空行以更好的区分代码区域
- 代码注释和文档注释说明必须正确,并优先更新
- 源代码编码格式统一使用utf-8,或和旧文件代码保持一致
- 从文件到类与函数甚至是变量的命名都要保持规范,且不要使用中文
- 重要的是要意识到代码的阅读比编写的频率要高很多很多
Google开源项目风格指南
Python是Google主要的脚本语言。这本风格指南主要包含的是针对python的编程规范。
Google开源项目风格指南-Python风格指南包含以下两个主要内容
- Python语言规范
- Python风格规范
文档地址: Google开源项目风格指南
一. Python语言规范
1.Lint
对你的代码运行pylint,pylint是一个在Python源代码中查找bug的工具.
可以捕获容易忽视的错误, 例如输入错误, 使用未赋值的变量等.
2.导入
仅对包和模块使用导入.并且必要时使用as
3.包
使用模块的全路径名来导入每个模块
4.异常
允许使用异常, 但必须小心
异常必须遵守特定条件:
-
像这样触发异常:
raise MyException("Error message")或者raise MyException. 不要使用两个参数的形式(raise MyException, "Error message")或者过时的字符串异常(raise "Error message"). -
模块或包应该定义自己的特定域的异常基类, 这个基类应该从内建的Exception类继承. 模块的异常基类应该叫做”Error”.
class Error(Exception): pass -
永远不要使用
except:语句来捕获所有异常, 也不要捕获Exception或者StandardError, 除非你打算重新触发该异常, 或者你已经在当前线程的最外层(记得还是要打印一条错误消息). 在异常这方面, Python非常宽容,except:真的会捕获包括Python语法错误在内的任何错误. 使用except:很容易隐藏真正的bug. -
尽量减少try/except块中的代码量. try块的体积越大, 期望之外的异常就越容易被触发. 这种情况下, try/except块将隐藏真正的错误.
-
使用finally子句来执行那些无论try块中有没有异常都应该被执行的代码. 这对于清理资源常常很有用, 例如关闭文件.
-
当捕获异常时, 使用
as而不要用逗号. 例如try: raise Error except Error as error: pass
5.全局变量
避免全局变量,导入时可能改变模块行为, 因为导入模块时会对模块级变量赋值.
避免使用全局变量, 用类变量来代替. 但也有一些例外:
- 脚本的默认选项.
- 模块级常量. 例如: PI = 3.14159. 常量应该全大写, 用下划线连接.
- 有时候用全局变量来缓存值或者作为函数返回值很有用.
- 如果需要, 全局变量应该仅在模块内部可用, 并通过模块级的公共函数来访问.
6.嵌套/局部/内部类或函数
鼓励使用嵌套/本地/内部类或函数
类可以定义在方法, 函数或者类中. 函数可以定义在方法或函数中. 封闭区间中定义的变量对嵌套函数是只读的.
优点:允许定义仅用于有效范围的工具类和函数.
7.列表推导(List Comprehensions)
可以在简单情况下使用,复杂的列表推导或者生成器表达式可能难以阅读.
# 适用于简单情况.
# 每个部分应该单独置于一行: 映射表达式, for语句, 过滤器表达式.
# 禁止多重for语句或过滤器表达式. 复杂情况下还是使用循环.
# Yes:
result = []
for x in range(10):
for y in range(5):
if x * y > 10:
result.append((x, y))
for x in xrange(5):
for y in xrange(5):
if x != y:
for z in xrange(5):
if y != z:
yield (x, y, z)
return ((x, complicated_transform(x))
for x in long_generator_function(parameter)
if x is not None)
squares = [x * x for x in range(10)]
eat(jelly_bean for jelly_bean in jelly_beans
if jelly_bean.color == 'black')
# No:
result = [(x, y) for x in range(10) for y in range(5) if x * y > 10]
return ((x, y, z)
for x in xrange(5)
for y in xrange(5)
if x != y
for z in xrange(5)
if y != z)
8.默认迭代器和操作符
如果类型支持, 就使用默认迭代器和操作符. 比如列表, 字典及文件等.
# 内建类型也定义了迭代器方法. 优先考虑这些方法, 而不是那些返回列表的方法. # 当然,这样遍历容器时,你将不能修改容器. # Yes: for key in adict: ... if key not in adict: ... if obj in alist: ... for line in afile: ... for k, v in dict.iteritems(): ... # No: for key in adict.keys(): ... if not adict.has_key(key): ... for line in afile.readlines(): ...
9.生成器
按需使用生成器.
优点:简化代码, 因为每次调用时, 局部变量和控制流的状态都会被保存. 比起一次创建一系列值的函数, 生成器使用的内存更少.
鼓励使用. 注意在生成器函数的文档字符串中使用”Yields:”而不是”Returns:”.
10.Lambda函数
适用于单行函数.
优点:方便。
缺点:比本地函数更难阅读和调试. 没有函数名意味着堆栈跟踪更难理解. 由于lambda函数通常只包含一个表达式, 因此其表达能力有限.
结论:适用于单行函数. 如果代码超过60-80个字符, 最好还是定义成常规(嵌套)函数.
11.条件表达式
条件表达式是对于if语句的一种更为简短的句法规则. 例如:
x = 1 if cond else 2.优点:比if语句更加简短和方便.
缺点:比if语句难于阅读. 如果表达式很长, 难于定位条件.
结论:适用于单行函数. 在其他情况下,推荐使用完整的if语句.
12.默认参数值
适用于大部分情况。鼓励使用, 不过有如下注意事项:
不要在函数或方法定义中使用可变对象作为默认值.
# 不要在函数或方法定义中使用可变对象作为默认值.
Yes: def foo(a, b=None):
if b is None:
b = []
No: def foo(a, b=[]):
...
No: def foo(a, b=time.time()): # The time the module was loaded???
...
No: def foo(a, b=FLAGS.my_thing): # sys.argv has not yet been parsed...
...
13.True/False的求值
尽可能使用隐式false,
Python在布尔上下文中会将某些值求值为false. 按简单的直觉来讲, 就是所有的”空”值都被认为是false.
因此0, None, [], {}, “” 都被认为是false.
优点: 使用Python布尔值的条件语句更易读也更不易犯错. 大部分情况下, 也更快.
结论:
尽可能使用隐式的false, 例如: 使用
if foo:而不是if foo != []:. 不过还是有一些注意事项需要你铭记在心:
永远不要用==或者!=来比较单件, 比如None. 使用is或者is not.
注意: 当你写下
if x:时, 你其实表示的是if x is not None. 例如: 当你要测试一个默认值是None的变量或参数是否被设为其它值. 这个值在布尔语义下可能是false!永远不要用==将一个布尔量与false相比较. 使用
if not x:代替. 如果你需要区分false和None, 你应该用像if not x and x is not None:这样的语句.对于序列(字符串, 列表, 元组), 要注意空序列是false. 因此
if not seq:或者if seq:比if len(seq):或if not len(seq):要更好.处理整数时, 使用隐式false可能会得不偿失(即不小心将None当做0来处理). 你可以将一个已知是整型(且不是len()的返回结果)的值与0比较.
Yes: if not users: print 'no users' if foo == 0: self.handle_zero() if i % 10 == 0: self.handle_multiple_of_ten() No: if len(users) == 0: print 'no users' if foo is not None and not foo: self.handle_zero() if not i % 10: self.handle_multiple_of_ten()注意‘0’(字符串)会被当做true.
14.函数与方法装饰器
如果好处很显然, 就明智而谨慎的使用装饰器
优点:优雅的在函数上指定一些转换. 该转换可能减少一些重复代码, 保持已有函数不变(enforce invariants), 等.
缺点:装饰器可以在函数的参数或返回值上执行任何操作, 这可能导致让人惊异的隐藏行为. 而且, 装饰器在导入时执行. 从装饰器代码的失败中恢复更加不可能.
结论:
如果好处很显然, 就明智而谨慎的使用装饰器. 装饰器应该遵守和函数一样的导入和命名规则. 装饰器的python文档应该清晰的说明该函数是一个装饰器. 请为装饰器编写单元测试.
避免装饰器自身对外界的依赖(即不要依赖于文件, socket, 数据库连接等), 因为装饰器运行时这些资源可能不可用(由
pydoc或其它工具导入). 应该保证一个用有效参数调用的装饰器在所有情况下都是成功的.装饰器是一种特殊形式的”顶级代码”. 参考后面关于 Main的话题.
15.线程
优先使用Queue模块的
Queue数据类型作为线程间的数据通信方式.另外, 使用threading模块及其锁原语(locking primitives).
了解条件变量的合适使用方式, 这样你就可以使用
threading.Condition来取代低级别的锁了.
16.威力过大的特性
避免使用这些特性
定义:
Python是一种异常灵活的语言, 它为你提供了很多花哨的特性, 诸如元类(metaclasses), 字节码访问, 任意编译(on-the-fly compilation), 动态继承, 对象父类重定义(object reparenting), 导入黑客(import hacks), 反射, 系统内修改(modification of system internals), 等等.
优点:
强大的语言特性, 能让你的代码更紧凑.
缺点:
使用这些很”酷”的特性十分诱人, 但不是绝对必要. 使用奇技淫巧的代码将更加难以阅读和调试. 开始可能还好(对原作者而言), 但当你回顾代码, 它们可能会比那些稍长一点但是很直接的代码更加难以理解.
结论:
在你的代码中避免这些特性.
二. Python风格规范
1.分号
不要在行尾加分号, 也不要用分号将两条命令放在同一行.
2.行长度
每行不超过80个字符 例外:1.长的导入模块语句。2.注释里的URL
3.括号
宁缺毋滥的使用括号
除非是用于实现行连接, 否则不要在返回语句或条件语句中使用括号. 不过在元组两边使用括号是可以的
Yes: if foo: bar() while x: x = bar() if x and y: bar() if not x: bar() return foo for (x, y) in dict.items(): ... No: if (x): bar() if not(x): bar() return (foo)
4.缩进
用4个空格来缩进代码,绝对不要用tab, 也不要tab和空格混用.
5.空行
顶级定义之间空两行, 方法定义之间空一行
6.空格
按照标准的排版规范来使用标点两边的空格
# 括号内不要有空格. Yes: spam(ham[1], {eggs: 2}, []) No: spam( ham[ 1 ], { eggs: 2 }, [ ] ) # 不要在逗号, 分号, 冒号前面加空格, 但应该在它们后面加(除了在行尾). Yes: if x == 4: print x, y x, y = y, x No: if x == 4 : print x , y x , y = y , x # 参数列表, 索引或切片的左括号前不应加空格. Yes: spam(1) no: spam (1) Yes: dict['key'] = list[index] No: dict ['key'] = list [index] #在二元操作符两边都加上一个空格, 比如赋值(=), 比较(==, <, >, !=, <>, <=, >=, in, not in, is, is not), 布尔(and, or, not). 至于算术操作符两边的空格该如何使用, 需要你自己好好判断. 不过两侧务必要保持一致. Yes: x == 1 No: x<1 # 当’=’用于指示关键字参数或默认参数值时, 不要在其两侧使用空格. Yes: def complex(real, imag=0.0): return magic(r=real, i=imag) No: def complex(real, imag = 0.0): return magic(r = real, i = imag) # 不要用空格来垂直对齐多行间的标记, 因为这会成为维护的负担(适用于:, #, =等): Yes: foo = 1000 # comment long_name = 2 # comment that should not be aligned dictionary = { "foo": 1, "long_name": 2, } No: foo = 1000 # comment long_name = 2 # comment that should not be aligned dictionary = { "foo" : 1, "long_name": 2, }
7.Shebang
大部分.py文件不必以#!作为文件的开始. 根据 PEP-394 , 程序的main文件应该以 #!/usr/bin/python2或者 #!/usr/bin/python3开始.
在计算机科学中, Shebang (也称为Hashbang)是一个由井号和叹号构成的字符串行(#!), 其出现在文本文件的第一行的前两个字符. 在文件中存在Shebang的情况下, 类Unix操作系统的程序载入器会分析Shebang后的内容, 将这些内容作为解释器指令, 并调用该指令, 并将载有Shebang的文件路径作为该解释器的参数. 例如, 以指令#!/bin/sh开头的文件在执行时会实际调用/bin/sh程序.)
#!先用于帮助内核找到Python解释器, 但是在导入模块时, 将会被忽略. 因此只有被直接执行的文件中才有必要加入#!.
8.注释
确保对模块, 函数, 方法和行内注释使用正确的风格
9.类
如果一个类不继承自其它类, 就显式的从object继承. 嵌套类也一样.
Yes: class SampleClass(object): pass class OuterClass(object): class InnerClass(object): pass class ChildClass(ParentClass): """Explicitly inherits from another class already.""" No: class SampleClass: pass class OuterClass: class InnerClass: pass # 继承自 object 是为了使属性(properties)正常工作, 并且这样可以保护你的代码, 使其不受 PEP-3000 的一个特殊的潜在不兼容性影响. 这样做也定义了一些特殊的方法, 这些方法实现了对象的默认语义, 包括 __new__, __init__, __delattr__, __getattribute__, __setattr__, __hash__, __repr__, and __str__ .
10.字符串
即使参数都是字符串, 使用%操作符或者格式化方法格式化字符串. 不过也不能一概而论, 你需要在+和%之间好好判定.
Yes: x = a + b x = '%s, %s!' % (imperative, expletive) x = '{}, {}!'.format(imperative, expletive) x = 'name: %s; score: %d' % (name, n) x = 'name: {}; score: {}'.format(name, n) No: x = '%s%s' % (a, b) # use + in this case x = '{}{}'.format(a, b) # use + in this case x = imperative + ', ' + expletive + '!' x = 'name: ' + name + '; score: ' + str(n) # 避免在循环中用+和+=操作符来累加字符串. 由于字符串是不可变的, 这样做会创建不必要的临时对象, 并且导致二次方而不是线性的运行时间. 作为替代方案, 你可以将每个子串加入列表, 然后在循环结束后用 .join 连接列表. (也可以将每个子串写入一个 cStringIO.StringIO 缓存中.) Yes: items = ['<table>'] for last_name, first_name in employee_list: items.append('<tr><td>%s, %s</td></tr>' % (last_name, first_name)) items.append('</table>') employee_table = ''.join(items) No: employee_table = '<table>' for last_name, first_name in employee_list: employee_table += '<tr><td>%s, %s</td></tr>' % (last_name, first_name) employee_table += '</table>'
11.文件和sockets
在文件和sockets结束时, 显式的关闭它.
# 推荐使用 “with”语句 以管理文件: with open("hello.txt") as hello_file: for line in hello_file: print line
12.TODO注释
为临时代码使用TODO注释, 它是一种短期解决方案. 不算完美, 但够好了.
TODO注释应该在所有开头处包含”TODO”字符串, 紧跟着是用括号括起来的你的名字, email地址或其它标识符. 然后是一个可选的冒号. 接着必须有一行注释, 解释要做什么. 主要目的是为了有一个统一的TODO格式, 这样添加注释的人就可以搜索到(并可以按需提供更多细节). 写了TODO注释并不保证写的人会亲自解决问题. 当你写了一个TODO, 请注上你的名字.
# TODO(kl@gmail.com): Use a "*" here for string repetition. # TODO(Zeke) Change this to use relations.
13.导入格式
每个导入应该独占一行
14.语句
通常每个语句应该独占一行
# 不过, 如果测试结果与测试语句在一行放得下, 你也可以将它们放在同一行. 如果是if语句, 只有在没有else时才能这样做. 特别地, 绝不要对 try/except 这样做, 因为try和except不能放在同一行. Yes: if foo: bar(foo) No: if foo: bar(foo) else: baz(foo) try: bar(foo) except ValueError: baz(foo) try: bar(foo) except ValueError: baz(foo)
15.访问控制
在Python中, 对于琐碎又不太重要的访问函数, 你应该直接使用公有变量来取代它们, 这样可以避免额外的函数调用开销. 当添加更多功能时, 你可以用属性(property)来保持语法的一致性.
16.命名
整个项目中所有的名字都要遵循规范和有意义的定义
Python之父Guido推荐的规范
| Type | Public | Internal | | ————————– | —————— | ———————————————————— | | Modules | lower_with_under | _lower_with_under | | Packages | lower_with_under | | | Classes | CapWords | _CapWords | | Exceptions | CapWords | | | Functions | lower_with_under() | _lower_with_under() | | Global/Class Constants | CAPS_WITH_UNDER | _CAPS_WITH_UNDER | | Global/Class Variables | lower_with_under | _lower_with_under | | Instance Variables | lower_with_under | _lower_with_under (protected) or lower_with_under (private) | | Method Names | lower_with_under() | _lower_with_under() (protected) or lower_with_under() (private) | | Function/Method Parameters | lower_with_under | | | Local Variables | lower_with_under | |
17.Main
即使是一个打算被用作脚本的文件, 也应该是可导入的. 并且简单的导入不应该导致这个脚本的主功能(main functionality)被执行, 这是一种副作用. 主功能应该放在一个main()函数中.
在Python中, pydoc以及单元测试要求模块必须是可导入的.
你的代码应该在执行主程序前总是检查 if __name__ == '__main__' ,
这样当模块被导入时主程序就不会被执行.
def main():
...
if __name__ == '__main__':
main()
所有的顶级代码在模块导入时都会被执行.
要小心不要去调用函数, 创建对象, 或者执行那些不应该在使用pydoc时执行的操作.
临别赠言
请务必保持代码的一致性
如果你正在编辑代码, 花几分钟看一下周边代码, 然后决定风格. 如果它们在所有的算术操作符两边都使用空格, 那么你也应该这样做. 如果它们的注释都用标记包围起来, 那么你的注释也要这样.
制定风格指南的目的在于让代码有规可循, 这样人们就可以专注于”你在说什么”, 而不是”你在怎么说”. 我们在这里给出的是全局的规范, 但是本地的规范同样重要. 如果你加到一个文件里的代码和原有代码大相径庭, 它会让读者不知所措. 避免这种情况.
规范修炼与代码安全
在编写符合规范的代码以提高可阅读性时,注意代码的安全问题也不能忽视。
互联网企业的核心就是产品,如果对软件产品安全不够重视,受到的经济损失将是无法估计的,有可能影响着企业的生死存亡。
当相关开发者心中没有安全的相关概念、项目的开发,上线及迭代更新没有相应的规范等等,这些问题都将会是导致出现安全问题
理论上来讲对于安全,无论企业规模大小,无论企业产品的重要程度如何,注重软件安全是非常有必要的。
- 通用安全开发规范
- python安全编码规范
一,通用安全开发规范
输入验证
- 对所有输入的信息和内容都需要做必要验证,包括数据类型,长度和范围等
- 禁止向exec()/eval()方法传递不可信、未净化的数据(当参数中包含空格,双引号,以-或者/符号开头表示一个参数开关时,可能会导致参数注入漏洞)
- 必要时可以考虑在python中禁用exec或eval方法
输出编码
- 必要时对所有输出字符进行编码
- 特别包括SQL,XML,HTML,JavaScript等内容
- 对于操作系统命令,净化所有不可信数据输出
异常处理
- 禁止在异常中泄露敏感信息(错误信息,路径,IP,版本,架构等)
- 发生异常时要恢复到之前的对象状态(业务操作失败时,进行回滚业务;或者避免去修改对象状态,维持对象状态一致性)
身份验证
- 除了那些特定设为“公开”的内容以外,对所有的网页和资源都要求进行身份验证,并正确设计身份验证功能
- 所有的身份验证过程必须在服务器后端上执行
- 所有的身份验证控制应当安全的处理未成功的身份验证,比如给出错误模糊提示,隐藏敏感信息
- 登录入口应具有防止暴力猜解及撞库猜解(利用已泄漏的密码字典进行批量登录尝试)的措施,超过设定失败次数需要启用锁定或图片随机码进行访问限制
- 在执行关键操作(如个人信息密码修改操作)时,应对用户身份进行再次验证
- 为高度敏感或重要的交易账户使用多因子身份验证机制,如支付密码、短信验证码等
短信验证码
- 发送频率控制(建议60s获取一次)
- 验证码有效期(建议60s内有效,发短信时进行友好提示)
- 复杂度(短信验证码建议6位数字)
- 一次一用,次数限制,防止被黑客恶意消耗短信
- 在前端校验只能作为辅助手段,很容易被绕过,必须使用服务端代码对输入数据进行最终校验
图形验证码
- 一次一用
- 验证码有效期(10分钟内有效,可根据场景兼容安全和体验灵活设置)
- 复杂度(4位及以上数字、字母交替),根据需要也可采用当下流行的拖拽验证码或计算值的验证方式
- 服务器端进行认证
- 从用户体验和安全角度出发,可设计为当用户输3次错误密码后自动弹出验证码输入框进行验证操作
密码管理
- 所有密码不得明文处理,必须选择加密
- 禁止使用私有或者弱加密算法,推荐使用AES: 128位,RSA: 2048位,DSA: 2048位
- 密码重设和更改操作,需要进行二次合法身份验证
- 密码重设时,应对注册手机号和邮箱进行有效验证,链接只能发送到预先注册的邮件地址或预先绑定的手机号
- 临时密码和链接应设计一个短暂的有效期(比如5分钟),防止暴力破解
- 当密码重新设置时,应短信通知用户是否是本人在操作,告知安全风险
- 密码复杂度设置:建议8个字符以上,包含字母、数字及特殊字符等
会话安全
- 用户登出后应立即清理会话及其相关登录信息
- 注销功能应当完全终止相关的会话或连接
- 会话cookie应设计有效期,超时后立即失效
- 当设计允许用户在多渠道终端同时登录时,建议应进行常用设备登录限制
访问控制
- 将具有特权的逻辑从其他应用程序代码中隔离开
- 限制只有授权的用户才能访问文件资源,url等
- 服务器端执行的访问控制规则和前端实施的访问控制规则必须匹配
- 服务器中创建文件时需指定合理的访问权限(读/写/可执行)
- 当权限重新设置发生变更时,应记录好日志,并短信通知用户是否是本人在操作,告知可能存在的安全风险
日志规范
- 不要在日志中保存敏感信息,包括系统指纹信息、会话标识符、账号密码、证件、ID等
- 确保日志记录包含了重要的日志事件数据
- 记录所有的管理功能操作行为,包含但不限于安全配置设置的变更
- 禁止将日志直接保存在可被浏览器访问到的WEB目录中
SQL注入
- 永远不要信任用户的输入,要对用户的所有输入进行校验,包含SQL语句的过滤和转义
- 永远不要使用动态拼装SQL,可以使用参数化的SQL或者使用存储过程进行数据查询存取
- 永远不要使用管理员权限进行数据库连接,为每个应用使用单独的非特权权限,且配置有限的数据库连接数
- 不要把敏感信息明文存放,采用加密或者哈希、混淆等方式对敏感信息进行脱敏存储
- 应用的异常信息应不带有敏感信息,给出尽可能少的提示
XSS跨站脚本攻击
- 对输入的数据进行过滤和转义,包含但不限于< >” ‘ % ( ) & + \ ‘ “等危险特殊字符
- 数据添加到html元素属性或者内容中时,对数据进行HTML转义
- 数据添加到script脚本中时,对数据进行script转义
- 数据添加到style中时,对数据进行css转义
CSRF跨站请求伪造
- 建议在每个关键表单中引入了CSRF Token验证(会话中生成的随机串,提交后校验)
- 在关键表单提交时要求用户进行二次身份验证(录入密码、插KEY、输入图片验证码、短信验证码)
- 对请求referer做验证(比如跨域、系统内部应用)
文件上传安全
- 上传操作应设计身份验证机制,并进行合法身份校验
- 只允许上传满足业务需要的相关文档类型
- 通过检查文件头信息,比如JPEG (jpg)文件头信息(十六进制):FFD8FF,验证上传文档是否是所期待的类型
- 不要把文件保存在与应用程序相同的 Web 环境中,建议将文件保存在专用的文档服务器中,单独给文档服务器配置域名访问更好
- 限制上传任意可能被 Web 服务器解析的文件 ,比如jsp、php,py等
- 上传文件以二进制形式下载,建议不提供直接访问(防止木马文件直接执行)
- 禁止授予上传文件存储目录的可执行权限
- 禁止客户端自定义文件上传/下载路径(如:使用../../../../进行跳转)
- 文件上传后重命名(需根据业务实际需求制定命名规则)
接口安全
- 调用方来源IP控制,比如可通过防火墙、主机host deny、Nginx deny等技术措施进行实施
- 调用方身份认证,比如key、secret、证书等技术措施进行实施
- 调用参数认证,需设计参数容错机制,避免出现参数可遍历敏感数据安全问题
- 采用数字签名保障接口身份来源可信,数据防篡改
- 调用方权限控制设置,调用频率、有效期进行控制
二,Python安全编码规范
Python开发本身要注意的有,一些危险函数,危险模块的调用处理等,以下是代码中各种容易引发安全问题的调用和处理方式。
1.危险的函数调用
- eval,任何时候都不要使用eval,这个函数会到时代码上下文出现不可预期的变化,并且在eval的内容不确定时可能会导致黑客直接通过植入代码控制进程甚至是服务器。如非得使用该函数,推荐使用 ast.literal_eval 来进行操作。
- exec,execfile, 该statement用于在python中执行了一段代码并控制上下文,并可能导致产生与eval相同的问题。
- pickle., marshal.(包含pickle和cpickle) ,pickle相关命令用于对包装当前上下文的一个代码处理逻辑,并序列化成一个字符串。在使用pickle.loads一段pickle序列化后的字符串时,如序列化字符串包含 reduce 魔术方法,则会导致pickle执行reduce中的内容,该序列化内容如被修改,则会导致在反序列化时触发恶意代码的注入。
- os.system, os.exec , os.popen , posix.system, ctypes.CDLL**等, 在Python中以下类型的方法均可以调用系统命令(ctypes.CDLL包含的_load_library方法存在命令注入),在使用时,需要对传惨内容进行检查。
2.危险的处理方式
-
对于跨语言进行RPC通信时(如查询SQL时),尽量使用ORM后的接口调用,尽可能避免使用原生的查询代码。
-
举例: sql = ‘select smcolumn from smtable where name=”%s”’ % columnquery
- 当columnquery传入可闭合sql语句时,就可以使得SQL条件跳出当前逻辑,从而执行更多其他类型的数据。如 columnquery=’abc” and sleep(1e10000) # 如此即可挂起整个SQL事物,触发异常。
-
在使用SQL时,请使用预编译绑定方式传入查询参数,一是可以加快sql的查询,二是可以避免大多数危险的突破SQL逻辑的语句产生
-
同理适用于其他非结构化控制的指令器(bash、mongodb、redis⇒exec)等
-
也同理适用于操作系统sh命令的执行等
-
-
对于正则处理,需要预估传入正则的内容大小以及正则的处理模式,如有大量文本传入正则解析时,会导致CPU飞起来,影响其他协程、事物的处理。
-
在使用XML解析时候,请关闭外部实体的解析逻辑来防止XXE攻击
-
对于入参的控制,请使用“检查”而非“过滤”,来避免错误的过滤导致的检查绕过。
3.运行时安全
- 请尽可能的使用vitualenv,并指定site-package目录,以避免可能产生的攻击。
- 尽可能的将进程的启动权限压到非root用户,如遇到启动端口等高权方式,请使用setuid将进程降权。
三,Web编程
对应Web编程中安全概念在python web框架中的实现。url跳转,目录遍历,任意文件读取也需要考虑在内。针对不同的框架也需要。
Flask 安全
- 使用Flask-Security
- 直接生成 HTML 而不通过使用Jinja2
- 不要在用户提交的数据上调用Markup
- 使用 Content-Disposition: attachment 标头去避免上传html文件
- 防止CSRF,flask本身没有实现该功能
Django 安全
可参考phithon的博客,有较多相关资料。
- 关闭DEBUG模式
- 妥善保存SECRET_KEY
- 设置SECURE_BROWSER_XSS_FILTER输出x-xss-protection头,让浏览器强制开启XSS过滤
- 设置SECURE_SSL_REDIRECT让HTTP的请求强制跳转到HTTPS
- 设置SESSION_COOKIE_SECURE使Cookie为Secure,不允许在HTTP中传输
- 设置CSRF_COOKIE_SECURE使CSRF Token Cookie设置为Secure,不允许在HTTP中传输
- 设置X_FRAME_OPTIONS返回X-FRAME-OPTIONS: DENY头,以防止被其他页面作为框架加载导致ClickJacking
- 部署前运行安全性检测 django-admin.py checksecure –settings=production_settings
巧用上下文管理器和With语句精简代码
我想你对 Python 中的 with 语句一定不陌生,尤其是在文件的输入输出操作中,不过我想,大部分人可能习惯了它的使用,却并不知道隐藏在其背后的“秘密”。
那么,究竟 with 语句要怎么用,与之相关的上下文管理器(context manager)是什么,它们之间又有着怎样的联系呢?这节课,我就带你一起揭开它们的神秘面纱。
什么是上下文管理器?
在任何一门编程语言中,文件的输入输出、数据库的连接断开等,都是很常见的资源管理操作。但资源都是有限的,在写程序时,我们必须保证这些资源在使用过后得到释放,不然就容易造成资源泄露,轻者使得系统处理缓慢,重则会使系统崩溃。
光说这些概念,你可能体会不到这一点,我们可以看看下面的例子:
for x in range(10000000):
f = open('test.txt', 'w')
f.write('hello')
这里我们一共打开了 10000000 个文件,但是用完以后都没有关闭它们,如果你运行该段代码,便会报错:
OSError: [Errno 23] Too many open files in system: 'test.txt'
这就是一个典型的资源泄露的例子。因为程序中同时打开了太多的文件,占据了太多的资源,造成系统崩溃。
为了解决这个问题,不同的编程语言都引入了不同的机制。而在 Python 中,对应的解决方式便是上下文管理器(context manager)。上下文管理器,能够帮助你自动分配并且释放资源,其中最典型的应用便是 with 语句。所以,上面代码的正确写法应该如下所示:
for x in range(10000000):
with open('test.txt', 'w') as f:
f.write('hello')
这样,我们每次打开文件“test.txt”,并写入‘hello’之后,这个文件便会自动关闭,相应的资源也可以得到释放,防止资源泄露。当然,with 语句的代码,也可以用下面的形式表示:
f = open('test.txt', 'w')
try:
f.write('hello')
finally:
f.close()
要注意的是,最后的 finally 尤其重要,哪怕在写入文件时发生错误异常,它也可以保证该文件最终被关闭。不过与 with 语句相比,这样的代码就显得冗余了,并且还容易漏写,因此我们一般更倾向于使用 with 语句。
另外一个典型的例子,是 Python 中的 threading.lock 类。举个例子,比如我想要获取一个锁,执行相应的操作,完成后再释放,那么代码就可以写成下面这样:
some_lock = threading.Lock()
some_lock.acquire()
try:
...
finally:
some_lock.release()
而对应的 with 语句,同样非常简洁:
some_lock = threading.Lock()
with somelock:
...
我们可以从这两个例子中看到,with 语句的使用,可以简化了代码,有效避免资源泄露的发生。
上下文管理器的实现
基于类的上下文管理器
了解了上下文管理的概念和优点后,下面我们就通过具体的例子,一起来看看上下文管理器的原理,搞清楚它的内部实现。这里,我自定义了一个上下文管理类 FileManager,模拟 Python 的打开、关闭文件操作:
class FileManager:
def __init__(self, name, mode):
print('calling __init__ method')
self.name = name
self.mode = mode
self.file = None
def __enter__(self):
print('calling __enter__ method')
self.file = open(self.name, self.mode)
return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
print('calling __exit__ method')
if self.file:
self.file.close()
with FileManager('test.txt', 'w') as f:
print('ready to write to file')
f.write('hello world')
## 输出
# calling __init__ method
# calling __enter__ method
# ready to write to file
# calling __exit__ method
需要注意的是,当我们用类来创建上下文管理器时,必须保证这个类包括方法__enter__()和方法__exit__()。其中,方法__enter__()返回需要被管理的资源,方法__exit__()里通常会存在一些释放、清理资源的操作,比如这个例子中的关闭文件等等。
而当我们用 with 语句,执行这个上下文管理器时:
with FileManager('test.txt', 'w') as f:
f.write('hello world')
下面这四步操作会依次发生:
- 方法
__init__()被调用,程序初始化对象 FileManager,使得文件名(name)是”test.txt”,文件模式 (mode) 是’w’; - 方法
__enter__()被调用,文件“test.txt”以写入的模式被打开,并且返回 FileManager 对象赋予变量 f; - 字符串“hello world”被写入文件“test.txt”;
- 方法
__exit__()被调用,负责关闭之前打开的文件流。
因此,这个程序的输出是:
calling __init__ method
calling __enter__ method
ready to write to file
calling __exit__ meth
另外,值得一提的是,方法__exit__()中的参数“exc_type, exc_val, exc_tb”,分别表示 exception_type、exception_value 和 traceback。当我们执行含有上下文管理器的 with 语句时,如果有异常抛出,异常的信息就会包含在这三个变量中,传入方法__exit__()。
因此,如果你需要处理可能发生的异常,可以在__exit__()添加相应的代码,比如下面这样来写:
class Foo:
def __init__(self):
print('__init__ called')
def __enter__(self):
print('__enter__ called')
return self
def __exit__(self, exc_type, exc_value, exc_tb):
print('__exit__ called')
if exc_type:
print(f'exc_type: {exc_type}')
print(f'exc_value: {exc_value}')
print(f'exc_traceback: {exc_tb}')
print('exception handled')
return True
with Foo() as obj:
raise Exception('exception raised').with_traceback(None)
# 输出
__init__ called
__enter__ called
__exit__ called
exc_type: <class 'Exception'>
exc_value: exception raised
exc_traceback: <traceback object at 0x1046036c8>
exception handled
这里,我们在 with 语句中手动抛出了异常“exception raised”,你可以看到,__exit__()方法中异常,被顺利捕捉并进行了处理。不过需要注意的是,如果方法__exit__()没有返回 True,异常仍然会被抛出。因此,如果你确定异常已经被处理了,请在__exit__()的最后,加上“return True”这条语句。
同样的,数据库的连接操作,也常常用上下文管理器来表示,这里我给出了比较简化的代码:
class DBConnectionManager:
def __init__(self, hostname, port):
self.hostname = hostname
self.port = port
self.connection = None
def __enter__(self):
self.connection = DBClient(self.hostname, self.port)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.connection.close()
with DBConnectionManager('localhost', '8080') as db_client:
与前面 FileManager 的例子类似:
- 方法
__init__()负责对数据库进行初始化,也就是将主机名、接口(这里是 localhost 和 8080)分别赋予变量 hostname 和 port; - 方法
__enter__()连接数据库,并且返回对象 DBConnectionManager; - 方法
__exit__()则负责关闭数据库的连接。
这样一来,只要你写完了 DBconnectionManager 这个类,那么在程序每次连接数据库时,我们都只需要简单地调用 with 语句即可,并不需要关心数据库的关闭、异常等等,显然大大提高了开发的效率。
同样的,数据库的连接操作,也常常用上下文管理器来表示
这里我给出了比较简化的代码:
class DBConnectionManager:
def __init__(self, hostname, port):
self.hostname = hostname
self.port = port
self.connection = None
def __enter__(self):
self.connection = DBClient(self.hostname, self.port)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.connection.close()
with DBConnectionManager('localhost', '8080') as db_client:
....
基于生成器的上下文管理器
诚然,基于类的上下文管理器,在 Python 中应用广泛,也是我们经常看到的形式,不过 Python 中的上下文管理器并不局限于此。除了基于类,它还可以基于生成器实现。
接下来我们来看一个例子。
比如,你可以使用装饰器 contextlib.contextmanager,来定义自己所需的基于生成器的上下文管理器,用以支持 with 语句。
还是拿前面的类上下文管理器 FileManager 来说,我们也可以用下面形式来表示:
from contextlib import contextmanager
@contextmanager
def file_manager(name, mode):
try:
f = open(name, mode)
yield f
finally:
f.close()
with file_manager('test.txt', 'w') as f:
f.write('hello world')
这段代码中,函数 file_manager() 是一个生成器,当我们执行 with 语句时,便会打开文件,并返回文件对象 f;当 with 语句执行完后,finally block 中的关闭文件操作便会执行。
你可以看到,使用基于生成器的上下文管理器时,我们不再用定义__enter__()和__exit__()方法,但请务必加上装饰器 @contextmanager,这一点新手很容易疏忽。
讲完这两种不同原理的上下文管理器后,还需要强调的是,基于类的上下文管理器和基于生成器的上下文管理器,这两者在功能上是一致的。只不过,
- 基于类的上下文管理器更加 flexible,适用于大型的系统开发;
- 而基于生成器的上下文管理器更加方便、简洁,适用于中小型程序。
无论你使用哪一种,请不用忘记在方法__exit__()或者是 finally block 中释放资源,这一点尤其重要。
总结
这节课,我们先通过一个简单的例子,了解了资源泄露的易发生性,和其带来的严重后果,从而引入了应对方案——即上下文管理器的概念。上下文管理器,通常应用在文件的打开关闭和数据库的连接关闭等场景中,可以确保用过的资源得到迅速释放,有效提高了程序的安全性,
接着,我们通过自定义上下文管理的实例,了解了上下文管理工作的原理,并一起学习了基于类的上下文管理器和基于生成器的上下文管理器,这两者的功能相同,具体用哪个,取决于你的具体使用场景。
另外,上下文管理器通常和 with 语句一起使用,大大提高了程序的简洁度。需要注意的是,当我们用 with 语句执行上下文管理器的操作时,一旦有异常抛出,异常的类型、值等具体信息,都会通过参数传入__exit__()函数中。你可以自行定义相关的操作对异常进行处理,而处理完异常后,也别忘了加上“return True”这条语句,否则仍然会抛出异常。
pdb & cProfile:调试和性能分析的法宝
在实际生产环境中,对代码进行调试和性能分析,是一个永远都逃不开的话题。调试和性能分析的主要场景,通常有这么三个:
- 一是代码本身有问题,需要我们找到 root cause 并修复;
- 二是代码效率有问题,比如过度浪费资源,增加 latency,因此需要我们 debug;
- 三是在开发新的 feature 时,一般都需要测试。
在遇到这些场景时,究竟应该使用哪些工具,如何正确的使用这些工具,应该遵循什么样的步骤等等,就是这节课我们要讨论的话题。
用 pdb 进行代码调试
pdb 的必要性
首先,我们来看代码的调试。也许不少人会有疑问:代码调试?说白了不就是在程序中使用 print() 语句吗?
没错,在程序中相应的地方打印,的确是调试程序的一个常用手段,但这只适用于小型程序。因为你每次都得重新运行整个程序,或是一个完整的功能模块,才能看到打印出来的变量值。如果程序不大,每次运行都非常快,那么使用 print(),的确是很方便的。
但是,如果我们面对的是大型程序,运行一次的调试成本很高。特别是对于一些 tricky 的例子来说,它们通常需要反复运行调试、追溯上下文代码,才能找到错误根源。这种情况下,仅仅依赖打印的效率自然就很低了。 我们可以想象下面这个场景。比如你最常使用的北京图灵学院 App,最近出现了一个 bug,部分用户无法登陆。于是,后端工程师们开始 debug。
他们怀疑错误的代码逻辑在某几个函数中,如果使用 print() 语句 debug,很可能出现的场景是,工程师们在他们认为的 10 个最可能出现 bug 的地方,都使用 print() 语句,然后运行整个功能块代码(从启动到运行花了 5min),看打印出来的结果值,是不是和预期相符。
如果结果值和预期相符,并能直接找到错误根源,显然是最好的。但实际情况往往是,
- 要么与预期并不相符,需要重复以上步骤,继续 debug;
- 要么虽说与预期相符,但前面的操作只是缩小了错误代码的范围,所以仍得继续添加 print() 语句,再一次运行相应的代码模块(又要 5min),进行 debug。
你可以看到,这样的效率就很低下了。哪怕只是遇到稍微复杂一点的 case,两、三个工程师一下午的时间可能就没了。 可能又有人会说,现在很多的 IDE 不都有内置的 debug 工具吗? 这话说的也没错。比如我们常用的 Pycharm,可以很方便地在程序中设置断点。这样程序只要运行到断点处,便会自动停下,你就可以轻松查看环境中各个变量的值,并且可以执行相应的语句,大大提高了调试的效率。
看到这里,你不禁会问,既然问题都解决了,那为什么还要学习 pdb 呢?其实在很多大公司,产品的创造与迭代,往往需要很多编程语言的支持;并且,公司内部也会开发很多自己的接口,尝试把尽可能多的语言给结合起来。
这就使得,很多情况下,单一语言的 IDE,对混合代码并不支持 UI 形式的断点调试功能,或是只对某些功能模块支持。另外,考虑到不少代码已经挪到了类似 Jupyter 的 Notebook 中,往往就要求开发者使用命令行的形式,来对代码进行调试。
而 Python 的 pdb,正是其自带的一个调试库。它为 Python 程序提供了交互式的源代码调试功能,是命令行版本的 IDE 断点调试器,完美地解决了我们刚刚讨论的这个问题。
如何使用 pdb
了解了 pdb 的重要性与必要性后,接下来,我们就一起来看看,pdb 在 Python 中到底应该如何使用。 首先,要启动 pdb 调试,我们只需要在程序中,加入“import pdb”和“pdb.set_trace()”这两行代码就行了,比如下面这个简单的例子:
a = 1
b = 2
import pdb
pdb.set_trace()
c = 3
print(a + b + c)
当我们运行这个程序时时,它的输出界面是下面这样的,表示程序已经运行到了“pdb.set_trace()”这行,并且暂停了下来,等待用户输入。
> /Users/yc/Desktop/code/demo.py(5)<module>()
-> c = 3
(Pdb)
这时,我们就可以执行,在 IDE 断点调试器中可以执行的一切操作,比如打印,语法是”p ”:
(pdb) p a
1
(pdb) p b
2
你可以看到,我打印的是 a 和 b 的值,分别为 1 和 2,与预期相符。为什么不打印 c 呢?显然,打印 c 会抛出异常,因为程序目前只运行了前面几行,此时的变量 c 还没有被定义:
(pdb) p c
*** NameError: name 'c' is not defined
除了打印,常见的操作还有“n”,表示继续执行代码到下一行,用法如下:
(pdb) n
-> print(a + b + c)
而命令”l“,则表示列举出当前代码行上下的 11 行源代码,方便开发者熟悉当前断点周围的代码状态:
(pdb) l
1 a = 1
2 b = 2
3 import pdb
4 pdb.set_trace()
5 -> c = 3
6 print(a + b + c)
命令“s“,就是 step into 的意思,即进入相对应的代码内部。这时,命令行中会显示”–Call–“的字样,当你执行完内部的代码块后,命令行中则会出现”–Return–“的字样。 我们来看下面这个例子:
def func():
print('enter func()')
a = 1
b = 2
import pdb
pdb.set_trace()
func()
c = 3
print(a + b + c)
# pdb
> /Users/yc/test.py(9)<module>()
-> func()
(pdb) s
--Call--
> /Users/yc/test.py(1)func()
-> def func():
(Pdb) l
1 -> def func():
2 print('enter func()')
3
4
5 a = 1
6 b = 2
7 import pdb
8 pdb.set_trace()
9 func()
10 c = 3
11 print(a + b + c)
(Pdb) n
> /Users/yc/test.py(2)func()
-> print('enter func()')
(Pdb) n
enter func()
--Return--
> /Users/yc/test.py(2)func()->None
-> print('enter func()')
(Pdb) n
> /Users/yc/test.py(10)<module>()
-> c = 3
这里,我们使用命令”s“进入了函数 func() 的内部,显示”–Call–“;而当我们执行完函数 func() 内部语句并跳出后,显示”–Return–“。 另外, 与之相对应的命令”r“,表示 step out,即继续执行,直到当前的函数完成返回。 命令”b [ ([filename:]lineno | function) [, condition] ]“可以用来设置断点。比方说,我想要在代码中的第 10 行,再加一个断点,那么在 pdb 模式下输入”b 11“即可。 而”c“则表示一直执行程序,直到遇到下一个断点。 当然,除了这些常用命令,还有许多其他的命令可以使用,这里我就不在一一赘述了。你可以参考对应的官方文档(docs.python.org/3/library/p…),来熟悉这些用法。
用 cProfile 进行性能分析
关于调试的内容,我主要先讲这么多。事实上,除了要对程序进行调试,性能分析也是每个开发者的必备技能。
日常工作中,我们常常会遇到这样的问题:在线上,我发现产品的某个功能模块效率低下,延迟(latency)高,占用的资源多,但却不知道是哪里出了问题。
这时,对代码进行 profile 就显得异常重要了。
这里所谓的 profile,是指对代码的每个部分进行动态的分析,比如准确计算出每个模块消耗的时间等。
这样你就可以知道程序的瓶颈所在,从而对其进行修正或优化。当然,这并不需要你花费特别大的力气,在 Python 中,这些需求用 cProfile 就可以实现。
举个例子,比如我想计算斐波拉契数列,运用递归思想,我们很容易就能写出下面这样的代码:
def fib(n):
if n == 0:
return 0
elif n == 1:
return 1
else:
return fib(n-1) + fib(n-2)
def fib_seq(n):
res = []
if n > 0:
res.extend(fib_seq(n-1))
res.append(fib(n))
return res
fib_seq(30)
接下来,我想要测试一下这段代码总的效率以及各个部分的效率。那么,我就只需在开头导入 cProfile 这个模块,并且在最后运行 cProfile.run() 就可以了:
import cProfile
def fib(n):...
def fib_seq(n):...
cProfile.run('fib_seq(30)')
或者更简单一些,直接在运行脚本的命令中,加入选项“-m cProfile”也很方便:
python3 -m cProfile xxx.py
运行完毕后,我们可以看到下面这个输出界面:
7049218 function calls (96 primitive calls) in 2.280 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 2.280 2.280 <string>:1(<module>)
31/1 0.000 0.000 2.280 2.280 demo.py:10(fib_seq)
7049123/31 2.280 0.000 2.280 0.074 demo.py:3(fib)
1 0.000 0.000 2.280 2.280 {built-in method builtins.exec}
31 0.000 0.000 0.000 0.000 {method 'append' of 'list' objects}
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
30 0.000 0.000 0.000 0.000 {method 'extend' of 'list' objects}
这里有一些参数你可能比较陌生,我来简单介绍一下:
- ncalls,是指相应代码 / 函数被调用的次数;
- tottime,是指对应代码 / 函数总共执行所需要的时间(注意,并不包括它调用的其他代码 / 函数的执行时间);
- tottime percall,就是上述两者相除的结果,也就是tottime / ncalls;
- cumtime,则是指对应代码 / 函数总共执行所需要的时间,这里包括了它调用的其他代码 / 函数的执行时
- cumtime percall,则是 cumtime 和 ncalls 相除的平均结果。
了解这些参数后,再来看这张图。我们可以清晰地看到,这段程序执行效率的瓶颈,在于第二行的函数 fib(),它被调用了 700 多万次。
有没有什么办法可以提高改进呢?答案是肯定的。通过观察,我们发现,程序中有很多对 fib() 的调用,其实是重复的,那我们就可以用字典来保存计算过的结果,防止重复。
改进后的代码如下所示:
def memoize(f):
memo = {}
def helper(x):
if x not in memo:
memo[x] = f(x)
return memo[x]
return helper
@memoize
def fib(n):
if n == 0:
return 0
elif n == 1:
return 1
else:
return fib(n-1) + fib(n-2)
def fib_seq(n):
res = []
if n > 0:
res.extend(fib_seq(n-1))
res.append(fib(n))
return res
fib_seq(30)
这时,我们再对其进行 profile,你就会得到新的输出结果,很明显,效率得到了极大的提高。
这个简单的例子,便是 cProfile 的基本用法,也是我今天想讲的重点。当然,cProfile 还有很多其他功能,还可以结合 stats 类来使用,你可以阅读相应的 官方文档 来了解。
总结
这节课,我们一起学习了 Python 中常用的调试工具 pdb,和经典的性能分析工具 cProfile。
pdb 为 Python 程序提供了一种通用的、交互式的高效率调试方案;
而 cProfile 则是为开发者提供了每个代码块执行效率的详细分析,有助于我们对程序的优化与提高。
关于它们的更多用法,你可以通过它们的官方文档进行实践,都不太难,熟能生巧。
学会合理分解代码,提高代码可读性
有句话说得好,好的代码本身就是一份文档。同样功能的一份程序,一个组件,一套系统,让不同的人来写,写出来的代码却是千差万别。
有些人的设计风格和代码风格犹如热刀切黄油,从顶层到底层的代码看下来酣畅淋漓,注释详尽而又精简;深入到细节代码,无需注释也能理解清清楚楚。
而有些人,代码勉勉强强能跑起来,遇到稍微复杂的情况可能就会出 bug;深入到代码中 debug,则发现处处都是魔术数、函数堆在一起。一个文件上千行,设计模式又是混淆不堪,让人实在很难阅读,更别提修改和迭代开发。
Guido van Rossum(吉多·范罗苏姆,Python 创始人 )说过,代码的阅读频率远高于编写代码的频率。毕竟,即使是在编写代码的时候,你也需要对代码进行反复阅读和调试,来确认代码能够按照期望运行。
PEP 8 规范
前面课程中我们简单提起过 PEP 8 ,本节课我们来简单复习一下。
PEP 是 Python Enhancement Proposal 的缩写,翻译过来叫“Python 增强规范”。正如我们写文章,会有句式、标点、段落格式、开头缩进等标准的规范一样,Python 书写自然也有一套较为官方的规范。PEP 8 就是这样一种规范,它存在的意义,就是让 Python 更易阅读,换句话,增强代码可读性。
事实上,Pycharm 已经内置了 PEP 8 规范检测器,它会自动对编码不规范的地方进行检查,然后指出错误,并推荐修改方式。下面这张图就是其界面。
因此,在学习今天的内容时,我推荐你使用 Pycharm IDE 进行代码检查,看一下自己的代码格式哪里有问题。尤其对于初学者,从某些程度来说,代码规范甚至是比代码准确更重要的事情,因为实际工作中,代码可读性的重要性一定比你想象的多得多。
缩进规范
空行规范
空格规范
换行规范
文档规范
注释规范
文档描述
命名规范
代码分解技巧
本节课我们再讲一些很实用的代码优化技巧。
编程中一个核心思想是,不写重复代码。重复代码大概率可以通过使用条件、循环、构造函数和类来解决。而另一个核心思想则是,减少迭代层数,尽可能让 Python 代码扁平化,毕竟,人的大脑无法处理过多的栈操作。
所以,在很多业务逻辑比较复杂的地方,就需要我们加入大量的判断和循环。不过,这些一旦没写好,程序看起来就是地狱了。
我们来看下面几个示例,来说说写好判断、循环的细节问题。先来看第一段代码:
if i_am_rich:
money = 100
send(money)
else:
money = 10
send(money)
这段代码中,同样的 send 语句出现了两次,所以我们完全可以合并一下,把代码改造成下面这样:
if i_am_rich:
money = 100
else:
money = 10
send(money)
再来看一个例子:
def send(money):
if is_server_dead:
LOG('server dead')
return
else:
if is_server_timed_out:
LOG('server timed out')
return
else:
result = get_result_from_server()
if result == MONEY_IS_NOT_ENOUGH:
LOG('you do not have enough money')
return
else:
if result == TRANSACTION_SUCCEED:
LOG('OK')
return
else:
LOG('something wrong')
return
这段代码层层缩进,显而易见的难看。我们来改一下:
def send(money):
if is_server_dead:
LOG('server dead')
return
if is_server_timed_out:
LOG('server timed out')
return
result = get_result_from_server()
if result == MONET_IS_NOT_ENOUGH:
LOG('you do not have enough money')
return
if result == TRANSACTION_SUCCEED:
LOG('OK')
return
LOG('something wrong')
新的代码是不是就清晰多了?
另外,我们知道,一个函数的粒度应该尽可能细,不要让一个函数做太多的事情。所以,对待一个复杂的函数,我们需要尽可能地把它拆分成几个功能简单的函数,然后合并起来。那么,应该如何拆分函数呢?
这里,我以一个简单的二分搜索来举例说明。我给定一个非递减整数数组,和一个 target,要求你找到数组中最小的一个数 x,可以满足 x*x > target。一旦不存在,则返回 -1。
这个功能应该不难写吧。你不妨先自己写一下,写完后再对照着来看下面的代码,找出自己的问题。
def solve(arr, target):
l, r = 0, len(arr) - 1
ret = -1
while l <= r:
m = (l + r) // 2
if arr[m] * arr[m] > target:
ret = m
r = m - 1
else:
l = m + 1
if ret == -1:
return -1
else:
return arr[ret]
print(solve([1, 2, 3, 4, 5, 6], 8))
print(solve([1, 2, 3, 4, 5, 6], 9))
print(solve([1, 2, 3, 4, 5, 6], 0))
print(solve([1, 2, 3, 4, 5, 6], 40))
我给出的第一段代码这样的写法,在算法比赛和面试中已经 OK 了。不过,从工程角度来说,我们还能继续优化一下:
def comp(x, target):
return x * x > target
def binary_search(arr, target):
l, r = 0, len(arr) - 1
ret = -1
while l <= r:
m = (l + r) // 2
if comp(arr[m], target):
ret = m
r = m - 1
else:
l = m + 1
return ret
def solve(arr, target):
id = binary_search(arr, target)
if id != -1:
return arr[id]
return -1
print(solve([1, 2, 3, 4, 5, 6], 8))
print(solve([1, 2, 3, 4, 5, 6], 9))
print(solve([1, 2, 3, 4, 5, 6], 0))
print(solve([1, 2, 3, 4, 5, 6], 40))
你可以看出,第二段代码中,我把不同功能的代码拿了出来。其中,comp() 函数作为核心判断,拿出来后可以让整个程序更清晰;同时,我也把二分搜索的主程序拿了出来,只负责二分搜索;最后的 solve() 函数拿到结果,决定返回不存在,还是返回值。这样一来,每个函数各司其职,阅读性也能得到一定提高。
最后,我们再来看一下如何拆分类。老规矩,先看代码:
class Person:
def __init__(self, name, sex, age, job_title, job_description, company_name):
self.name = name
self.sex = sex
self.age = age
self.job_title = job_title
self.job_description = description
self.company_name = company_name
你应该能看得出来,job 在其中出现了很多次,而且它们表达的是一个意义实体,这种情况下,我们可以考虑将这部分分解出来,作为单独的类。
class Person:
def __init__(self, name, sex, age, job_title, job_description, company_name):
self.name = name
self.sex = sex
self.age = age
self.job = Job(job_title, job_description, company_name)
class Job:
def __init__(self, job_title, job_description, company_name):
self.job_title = job_title
self.job_description = description
self.company_name = company_name
你看,改造后的代码,瞬间就清晰了很多。
总结
在我们学习了编程规范之后重要的是在你未来的开发中注重和准守编程规范
并在必要时合理分解代码,提高代码可读性
如何合理利用assert?
相信你平时在写代码时,肯定或多或少看到过 assert 的存在。我也曾在日常的代码开发中,被一些同事要求增加 assert 语句,让代码更加健壮。
不过,尽管如此,我发现在很多情况下,assert 还是很容易被忽略,人们似乎对这么一个“不起眼”的东西并不关心。但事实上,这个看似“不起眼”的东西,如果能用好,对我们的程序大有裨益。
说了这么多,那么究竟什么是 assert,我们又该如何合理地使用 assert 呢?今天这节课,我就带你一起来学习它的用法。
什么是 assert?
Python 的 assert 语句,可以说是一个 debug 的好工具,主要用于测试一个条件是否满足。如果测试的条件满足,则什么也不做,相当于执行了 pass 语句;如果测试条件不满足,便会抛出异常 AssertionError,并返回具体的错误信息(optional)。
它的具体语法是下面这样的:
assert_stmt ::= "assert" expression ["," expression]
我们先来看一个简单形式的assert expression,比如下面这个例子:
assert 1 == 2
它就相当于下面这两行代码:
if __debug__:
if not expression: raise AssertionError
再来看assert expression1, expression2的形式,比如下面这个例子:
assert 1 == 2, 'assertion is wrong'
它就相当于下面这两行代码:
if __debug__:
if not expression1: raise AssertionError(expression2)
这里的__debug__是一个常数。
如果 Python 程序执行时附带了-O这个选项,比如Python test.py -O,那么程序中所有的 assert 语句都会失效,常数__debug__便为 False;反之__debug__则为 True。
不过,需要注意的是,直接对常数__debug__赋值是非法的,因为它的值在解释器开始运行时就已经决定了,中途无法改变。
此外,一定记住,不要在使用 assert 时加入括号,比如下面这个例子:
assert(1 == 2, 'This should fail')
# 输出
<ipython-input-8-2c057bd7fe24>:1: SyntaxWarning: assertion is always true, perhaps remove parentheses?
assert(1 == 2, 'This should fail')
如果你按照这样来写,无论表达式对与错(比如这里的 1 == 2 显然是错误的),assert 检查永远不会 fail,程序只会给你 SyntaxWarning。
正确的写法,应该是下面这种不带括号的写法:
assert 1 == 2, 'This should fail'
# 输出
AssertionError: This should fail
总的来说,assert 在程序中的作用,是对代码做一些 internal 的 self-check。使用 assert,就表示你很确定。这个条件一定会发生或者一定不会发生。
举个例子,比如你有一个函数,其中一个参数是人的性别,因为性别只有男女之分(这里只指生理性别),你便可以使用 assert,以防止程序的非法输入。如果你的程序没有 bug,那么 assert 永远不会抛出异常;而它一旦抛出了异常,你就知道程序存在问题了,并且可以根据错误信息,很容易定位出错误的源头。
assert 的用法
讲完了 assert 的基本语法与概念,我们接下来通过一些实际应用的例子,来看看 assert 在 Python 中的用法,并弄清楚 assert 的使用场景。
第一个例子,假设你现在使用的北京图灵学院App正在做专栏促销活动,准备对一些专栏进行打折,所以后台需要写一个 apply_discount() 函数,要求输入为原来的价格和折扣,输出是折后的价格。那么,我们可以大致写成下面这样:
def apply_discount(price, discount):
updated_price = price * (1 - discount)
assert 0 <= updated_price <= price, 'price should be greater or equal to 0 and less or equal to original price'
return updated_price
可以看到,在计算新价格的后面,我们还写了一个 assert 语句,用来检查折后价格,这个值必须大于等于 0、小于等于原来的价格,否则就抛出异常。
我们可以试着输入几组数,来验证一下这个功能:
apply_discount(100, 0.2)
80.0
apply_discount(100, 2)
AssertionError: price should be greater or equal to 0 and less or equal to original price
显然,当 discount 是 0.2 时,输出 80,没有问题。
但是当 discount 为 2 时,程序便抛出下面这个异常:
AssertionError:price should be greater or equal to 0 and less or equal to original price
这样一来,如果开发人员修改相关的代码,或者是加入新的功能,导致 discount 数值的异常时,我们运行测试时就可以很容易发现问题。正如我开头所说,assert 的加入,可以有效预防 bug 的发生,提高程序的健壮性。
再来看一个例子,最常见的除法操作,这在任何领域的计算中都经常会遇到。同样还是以北京图灵学院App为例,假如北京图灵学院App后台想知道每个专栏的平均销售价格,那么就需要给定销售总额和销售数目,这样平均销售价格便很容易计算出来:
def calculate_average_price(total_sales, num_sales):
assert num_sales > 0, 'number of sales should be greater than 0'
return total_sales / num_sales
同样的,我们也加入了 assert 语句,规定销售数目必须大于 0,这样就可以防止后台计算那些还未开卖的专栏的价格。
除了这两个例子,在实际工作中,assert 还有一些很常见的用法,比如下面的场景:
def func(input):
assert isinstance(input, list), 'input must be type of list'
# 下面的操作都是基于前提:input必须是list
if len(input) == 1:
...
elif len(input) == 2:
...
else:
...
这里函数 func() 里的所有操作,都是基于输入必须是 list 这个前提。是不是很熟悉的需求呢?那我们就很有必要在开头加一句 assert 的检查,防止程序出错。
当然,我们也要根据具体情况具体分析。比如上面这个例子,之所以能加 assert,是因为我们很确定输入必须是 list,不能是其他数据类型。
如果你的程序中,允许 input 是其他数据类型,并且对不同的数据类型都有不同的处理方式,那你就应该写成 if else 的条件语句了:
def func(input):
if isinstance(input, list):
...
else:
...
assert 错误示例
前面我们讲了这么多 assert 的使用场景,可能给你一种错觉,也可能会让你有些迷茫:很多地方都可以使用 assert, 那么,很多 if 条件语句是不是都可以换成 assert 呢?这么想可就不准确了,接下来,我们就一起来看几个典型的错误用法,避免一些想当然的用法。
还是以北京图灵学院App为例,我们假设下面这样的场景:后台有时候需要删除一些上线时间较长的专栏,于是,相关的开发人员便设计出了下面这个专栏删除函数。
def delete_course(user, course_id):
assert user_is_admin(user), 'user must be admin'
assert course_exist(course_id), 'course id must exist'
delete(course_id)
北京图灵学院App规定,必须是 admin 才能删除专栏,并且这个专栏课程必须存在。有的同学一看,很熟悉的需求啊,所以在前面加了相应的 assert 检查。那么我想让你思考一下,这样写到底对不对呢?
答案显然是否定的。你可能觉得,从代码功能角度来说,这没错啊。但是在实际工程中,基本上没人会这么写。为什么呢?
要注意,前面我说过,assert 的检查是可以被关闭的,比如在运行 Python 程序时,加入-O这个选项就会让 assert 失效。因此,一旦 assert 的检查被关闭,user_is_admin() 和 course_exist() 这两个函数便不会被执行。这就会导致:
- 任何用户都有权限删除专栏课程;
- 并且,不管这个课程是否存在,他们都可以强行执行删除操作。
这显然会给程序带来巨大的安全漏洞。所以,正确的做法,是使用条件语句进行相应的检查,并合理抛出异常:
def delete_course(user, course_id):
if not user_is_admin(user):
raise Exception('user must be admin')
if not course_exist(course_id):
raise Exception('coursde id must exist')
delete(course_id)
再来看一个例子,如果你想打开一个文件,进行数据读取、处理等一系列操作,那么下面这样的写法,显然也是不正确的:
def read_and_process(path):
assert file_exist(path), 'file must exist'
with open(path) as f:
...
因为 assert 的使用,表明你强行指定了文件必须存在,但事实上在很多情况下,这个假设并不成立。另外,打开文件操作,也有可能触发其他的异常。所以,正确的做法是进行异常处理,用 try 和 except 来解决:
def read_and_process(path):
try:
with open(path) as f:
...
except Exception as e:
...
总的来说,assert 并不适用 run-time error 的检查。比如你试图打开一个文件,但文件不存在;再或者是你试图从网上下载一个东西,但中途断网了了等等,这些情况下,还是应该参照我们前面所讲的错误与异常的内容,进行正确处理。
总结
今天这节课,我们一起学习了 assert 的用法。assert 通常用来对代码进行必要的 self check,表明你很确定这种情况一定发生,或者一定不会发生。需要注意的是,使用 assert 时,一定不要加上括号,否则无论表达式对与错,assert 检查永远不会 fail。另外,程序中的 assert 语句,可以通过-O等选项被全局 disable。
通过这节课的几个使用场景,你能看到,assert 的合理使用,可以增加代码的健壮度,同时也方便了程序出错时开发人员的定位排查。
不过,我们也不能滥用 assert。很多情况下,程序中出现的不同情况都是意料之中的,需要我们用不同的方案去处理,这时候用条件语句进行判断更为合适。而对于程序中的一些 run-time error,请记得使用异常处理。
真的有必要写单元测试吗?
说到 unit test(即单元测试,下文统一用中文称呼),大部分人的反应估计有这么两种:
- 要么就是,单元测试啊,挺简单的呀,做不做无所谓吧;
- 要么就是,哎呀,项目进度太赶,单元测试拖一拖之后再来吧。
显然,这两种人,都没有正确认识到单元测试的价值,也没能掌握正确的单元测试方法。你是不是觉得自己只要了解 Python 的各个 feature,能够编写出符合规定功能的程序就可以了呢?
其实不然,完成产品的功能需求只是很基础的一部分,如何保证所写代码的稳定、高效、无误,才是我们工作的关键。而学会合理地使用单元测试,正是帮助你实现这一目标的重要路径。
我们总说,测试驱动开发(TDD)。今天我就以 Python 为例,教你设计编写 Python 的单元测试代码,带你熟悉并掌握这一重要技能。
什么是单元测试?
单元测试,通俗易懂地讲,就是编写测试来验证某一个模块的功能正确性,一般会指定输入,验证输出是否符合预期。
实际生产环境中,我们会对每一个模块的所有可能输入值进行测试。这样虽然显得繁琐,增加了额外的工作量,但是能够大大提高代码质量,减小 bug 发生的可能性,也更方便系统的维护。
说起单元测试,就不得不提 Python unittest 库,它提供了我们需要的大多数工具。我们来看下面这个简单的测试,从代码中了解其使用方法:
import unittest
# 将要被测试的排序函数
def sort(arr):
l = len(arr)
for i in range(0, l):
for j in range(i + 1, l):
if arr[i] >= arr[j]:
tmp = arr[i]
arr[i] = arr[j]
arr[j] = tmp
# 编写子类继承unittest.TestCase
class TestSort(unittest.TestCase):
# 以test开头的函数将会被测试
def test_sort(self):
arr = [3, 4, 1, 5, 6]
sort(arr)
# assert 结果跟我们期待的一样
self.assertEqual(arr, [1, 3, 4, 5, 6])
if __name__ == '__main__':
## 如果在Jupyter下,请用如下方式运行单元测试
unittest.main(argv=['first-arg-is-ignored'], exit=False)
## 如果是命令行下运行,则:
## unittest.main()
## 输出
..
----------------------------------------------------------------------
Ran 2 tests in 0.002s
OK
这里,我们创建了一个排序函数的单元测试,来验证排序函数的功能是否正确。代码里我做了非常详细的注释,相信你能够大致读懂,我再来介绍一些细节。
首先,我们需要创建一个类TestSort,继承类‘unittest.TestCase’;然后,在这个类中定义相应的测试函数 test_sort(),进行测试。注意,测试函数要以‘test’开头,而测试函数的内部,通常使用 assertEqual()、assertTrue()、assertFalse() 和 assertRaise() 等 assert 语句对结果进行验证。
最后运行时,如果你是在 IPython 或者 Jupyter 环境下,请使用下面这行代码:
unittest.main(argv=['first-arg-is-ignored'], exit=False)
而如果你用的是命令行,直接使用 unittest.main() 就可以了。你可以看到,运行结果输出’OK‘,这就表示我们的测试通过了。
当然,这个例子中的被测函数相对简单一些,所以写起对应的单元测试来也非常自然,并不需要很多单元测试的技巧。但实战中的函数往往还是比较复杂的,遇到复杂问题,高手和新手的最大差别,便是单元测试技巧的使用。
单元测试的几个技巧
接下来,我将会介绍 Python 单元测试的几个技巧,分别是 mock、side_effect 和 patch。这三者用法不一样,但都是一个核心思想,即用虚假的实现,来替换掉被测试函数的一些依赖项,让我们能把更多的精力放在需要被测试的功能上。
mock
mock 是单元测试中最核心重要的一环。mock 的意思,便是通过一个虚假对象,来代替被测试函数或模块需要的对象。
举个例子,比如你要测一个后端 API 逻辑的功能性,但一般后端 API 都依赖于数据库、文件系统、网络等。这样,你就需要通过 mock,来创建一些虚假的数据库层、文件系统层、网络层对象,以便可以简单地对核心后端逻辑单元进行测试。
Python mock 则主要使用 mock 或者 MagicMock 对象,这里我也举了一个代码示例。这个例子看上去比较简单,但是里面的思想很重要。下面我们一起来看下:
import unittest
from unittest.mock import MagicMock
class A(unittest.TestCase):
def m1(self):
val = self.m2()
self.m3(val)
def m2(self):
pass
def m3(self, val):
pass
def test_m1(self):
a = A()
a.m2 = MagicMock(return_value="custom_val")
a.m3 = MagicMock()
a.m1()
self.assertTrue(a.m2.called) #验证m2被call过
a.m3.assert_called_with("custom_val") #验证m3被指定参数call过
if __name__ == '__main__':
unittest.main()
## 输出
..
----------------------------------------------------------------------
Ran 2 tests in 0.002s
OK
这段代码中,我们定义了一个类的三个方法 m1()、m2()、m3()。我们需要对 m1() 进行单元测试,但是 m1() 取决于 m2() 和 m3()。如果 m2() 和 m3() 的内部比较复杂, 你就不能只是简单地调用 m1() 函数来进行测试,可能需要解决很多依赖项的问题。
这一听就让人头大了吧?但是,有了 mock 其实就很好办了。我们可以把 m2() 替换为一个返回具体数值的 value,把 m3() 替换为另一个 mock(空函数)。这样,测试 m1() 就很容易了,我们可以测试 m1() 调用 m2(),并且用 m2() 的返回值调用 m3()。
可能你会疑惑,这样测试 m1() 不是基本上毫无意义吗?看起来只是象征性地测了一下逻辑呀?
其实不然,真正工业化的代码,都是很多层模块相互逻辑调用的一个树形结构。单元测试需要测的是某个节点的逻辑功能,mock 掉相关的依赖项是非常重要的。这也是为什么会被叫做单元测试 unit test,而不是其他的 integration test、end to end test 这类。
Mock Side Effect
第二个我们来看 Mock Side Effect,这个概念很好理解,就是 mock 的函数,属性是可以根据不同的输入,返回不同的数值,而不只是一个 return_value。
比如下面这个示例,例子很简单,测试的是输入参数是否为负数,输入小于 0 则输出为 1 ,否则输出为 2。代码很简短,你一定可以看懂,这便是 Mock Side Effect 的用法。
from unittest.mock import MagicMock
def side_effect(arg):
if arg < 0:
return 1
else:
return 2
mock = MagicMock()
mock.side_effect = side_effect
mock(-1)
1
mock(1)
2
patch
至于 patch,给开发者提供了非常便利的函数 mock 方法。它可以应用 Python 的 decoration 模式或是 context manager 概念,快速自然地 mock 所需的函数。它的用法也不难,我们来看代码:
from unittest.mock import patch
@patch('sort')
def test_sort(self, mock_sort):
...
...
在这个 test 里面,mock_sort 替代 sort 函数本身的存在,所以,我们可以像开始提到的 mock object 一样,设置 return_value 和 side_effect。
另一种 patch 的常见用法,是 mock 类的成员函数,这个技巧我们在工作中也经常会用到,比如说一个类的构造函数非常复杂,而测试其中一个成员函数并不依赖所有初始化的 object。它的用法如下:
with patch.object(A, '__init__', lambda x: None):
…
代码应该也比较好懂。在 with 语句里面,我们通过 patch,将 A 类的构造函数 mock 为一个 lambda 函数,这样就可以很方便地避免一些复杂的初始化(initialization)。
其实,综合前面讲的这几点来看,你应该感受到了,单元测试的核心还是 mock,mock 掉依赖项,测试相应的逻辑或算法的准确性。在我看来,虽然 Python unittest 库还有很多层出不穷的方法,但只要你能掌握了 MagicMock 和 patch,编写绝大部分工作场景的单元测试就不成问题了。
高质量单元测试的关键
这节课的最后,我想谈一谈高质量的单元测试。我很理解,单元测试这个东西,哪怕是正在使用的人也是“百般讨厌”的,不少人很多时候只是敷衍了事。我也嫌麻烦,但从来不敢松懈,因为在大公司里,如果你写一个很重要的模块功能,不写单元测试是无法通过代码评审的。
低质量的单元测试,可能真的就是摆设,根本不能帮我们验证代码的正确性,还浪费时间。那么,既然要做单元测试,与其浪费时间糊弄自己,不如追求高质量的单元测试,切实提高代码品质。
那该怎么做呢?结合工作经验,我认为一个高质量的单元测试,应该特别关注下面两点。
Test Coverage
首先我们要关注 Test Coverage,它是衡量代码中语句被 cover 的百分比。可以说,提高代码模块的 Test Coverage,基本等同于提高代码的正确性。
为什么呢?
要知道,大多数公司代码库的模块都非常复杂。尽管它们遵从模块化设计的理念,但因为有复杂的业务逻辑在,还是会产生逻辑越来越复杂的模块。所以,编写高质量的单元测试,需要我们 cover 模块的每条语句,提高 Test Coverage。
我们可以用 Python 的 coverage tool 来衡量 Test Coverage,并且显示每个模块为被 coverage 的语句。如果你想了解更多更详细的使用,可以点击这个链接来学习:coverage.readthedocs.io/en/v4.5.x/ 。
模块化
高质量单元测试,不仅要求我们提高 Test Coverage,尽量让所写的测试能够 cover 每个模块中的每条语句;还要求我们从测试的角度审视 codebase,去思考怎么模块化代码,以便写出高质量的单元测试。
光讲这段话可能有些抽象,我们来看这样的场景。比如,我写了一个下面这个函数,对一个数组进行处理,并返回新的数组:
def work(arr):
# pre process
...
...
# sort
l = len(arr)
for i in range(0, l):
for j in range(i + 1, j):
if arr[i] >= arr[j]:
tmp = arr[i]
arr[i] = arr[j]
arr[j] = tmp
# post process
...
...
Return arr
这段代码的大概意思是,先有个预处理,再排序,最后再处理一下然后返回。如果现在要求你,给这个函数写个单元测试,你是不是会一筹莫展呢?
毕竟,这个函数确实有点儿复杂,以至于你都不知道应该是怎样的输入,并要期望怎样的输出。这种代码写单元测试是非常痛苦的,更别谈 cover 每条语句的要求了。
所以,正确的测试方法,应该是先模块化代码,写成下面的形式:
def preprocess(arr):
...
return arr
def sort(arr):
...
return arr
def postprocess(arr):
...
return arr
def work(self):
arr = preprocess(arr)
arr = sort(arr)
arr = postprocess(arr)
return arr
接着再进行相应的测试,测试三个子函数的功能正确性;然后通过 mock 子函数,调用 work() 函数,来验证三个子函数被 call 过。
from unittest.mock import patch
def test_preprocess(self):
...
def test_sort(self):
...
def test_postprocess(self):
...
@patch('%s.preprocess')
@patch('%s.sort')
@patch('%s.postprocess')
def test_work(self,mock_post_process, mock_sort, mock_preprocess):
work()
self.assertTrue(mock_post_process.called)
self.assertTrue(mock_sort.called)
self.assertTrue(mock_preprocess.called)
你看,这样一来,通过重构代码就可以使单元测试更加全面、精确,并且让整体架构、函数设计都美观了不少。
总结
回顾下这节课,整体来看,单元测试的理念是先模块化代码设计,然后针对每个作用单元,编写单独的测试去验证其准确性。更好的模块化设计和更多的 Test Coverage,是提高代码质量的核心。而单元测试的本质就是通过 mock,去除掉不影响测试的依赖项,把重点放在需要测试的代码核心逻辑上。
讲了这么多,还是想告诉你,单元测试是个非常非常重要的技能,在实际工作中是保证代码质量和准确性必不可少的一环。同时,单元测试的设计技能,不只是适用于 Python,而是适用于任何语言。所以,单元测试必不可少。
如何逐步突破,成为Python高手?
工作中,我总听到很多程序员抱怨,说现在的计算机编程语言太多了,学不过来了。
一些人 Java 用了很多年,但是最近的项目突然需要用 Python,就会不知所措,压力很大。
在网络上总是能够看到 如何学习python,怎么才能成为python高手等问题。
我想说在互联网这么发达的今天,在各种技术书籍和教育机构的烘托下,学习资料和参考书籍从来都是最大的问题。
我反而觉得大多数的问题都出在眼高手低的问题上。
在此,个人根据开发和教学经验为广大编程学习者爱好者提出以下几点建议,仅供参考,并欢迎讨论
-
切忌眼高手低,勤加练习是第一步
我建议你,在掌握必要的基础时,就得多上手操作了。千万不要等到把教材上所有东西都学完了才开始,因为到那时候你会发现,前面好不容易记住的一堆东西似乎又忘记了。计算机科学是一门十分讲究实战的学科,因此越早上手练习,练得越多越勤,就越好。
-
不要当伸手党,而是多思考
在熟练掌握一些编程基础例如:基本语法,数据类型,流程控制等内容时,就可以思考多做一些小项目练练手的同时,多动动脑子。(例如:编写一个小的计算机等。。。)
-
编程规范应该从一开始
在从一开始的时候,就要严格准守编程规范,这是一个优秀程序员的开始。
-
开发经验,质的突破
开发经验一定是自己的,但是在这之前你可能需要多去通过阅读优秀的项目和案例并动手实践才能具备。