《Python 工匠》读书笔记

变量与注释

变量命名原则

  1. 遵循 PEP8 规范
  2. 描述性要强(结合场景尽可能详尽的描述变量)
  3. 尽量短(为变量命名要结合情景和上下文)
  4. 要匹配类型
    • bool (is_, has_, allow_)
    • int/float
      • 含义为数字的单词:port, age, radius
      • 以 _id 结尾的变量:user_id, product_id
      • 以 length/count 开头或结尾的单词,users_count, length_of_name
      • 慎用名词复数表示 int 变量,users 容易误解为 List[User]
    • 其他类型的变量建议使用明确的类型注解来标注类型
  5. 超短命名结合场景使用和避免
    • 数组索引三剑客 i, j, k
    • 某个整数 n
    • 某个字符串 s
    • 某个异常 e
    • 文件对象 fp

变量和注释使用原则

  1. 保持变量的一致性
    • 名字一致性:在同一个项目、模块中,对一类事物的称呼要保持一致
    • 类型一致性:同一个变量不要重复指向不同的类型
  2. 变量类型定义尽量靠近使用:C 语言后遗症;靠近使用能够把一条逻辑完整的串在一起,不必来回翻阅
  3. 定义临时变量提升代码可读性:例如过长的 if 条件,会使读者头痛
  4. 同一作用域内不要有太多变量:变量太多会导致读者难以理解,建议拆分函数
  5. 能不定义变量就别定义:例如将计算结果放入列表,而不是先定义变量,再将变量放入列表
  6. 不要使用 locals():容易暴露没有真正使用的变量
  7. 空行也是一种注释:空行可以将代码分组,提升可读性
  8. 先写注释后写代码:先写注释可以帮助思考,也可以帮助后续的代码阅读者理解代码

数值与字符串

数值使用

  • Python的浮点数有精度问题,请使用Decimal对象做精确的小数运算
  • 布尔类型是整型的子类型,布尔值可以当作0和1来使用
  • 使用 float('inf') 无穷大可以简化边界处理逻辑

字符串使用

  • 字符串分为两类:str(给人阅读的文本类型)和bytes(给计算机阅读的二进制类型)
  • 通过 str.encode()byte.decode() 可以在两种字符串之间做转换
  • 优先推荐的字符串格式化方式(从前往后): f-stringstr.format()C语言风格格式化(%)
  • 使用以r开头的字符串内置方法可以从右往左处理字符串,特定场景下可以派上用场

代码可读性

  • 在定义数值字面量时,可以通过插入_字符来提升可读性
  • 不要出现“神奇”的字面量,使用常量或者枚举类型替换它们
  • 保留数学算式表达式不会影响性能,并且可以提升可读性
  • 使用 textwrap.dedent() 可以让多行字符串更好地融入代码

代码可维护性

  • 当操作SQL语句等结构化字符串时,使用专有模块比裸处理的代码更易于维护
  • 使用 Jinja2 模板来替代字符串拼接操作

语言内部

  • 对于从 -5 到 256 的这些常用小整数, Python 会将它们缓存在内存里的一个数组中。当你的程序需要用到这些数字时, Python 不会创建任何新的整型对象,而是会返回缓存中的对象。这样能为程序节约可观的内存
  • 使用dis模块可以查看Python字节码,帮助我们理解内部原理
  • 使用 timeit 模块可以对Python代码方便地进行性能测试
  • Python语言进化得很快,不要轻易被旧版本的“经验”所左右

容器类型

基础知识

  • 在进行函数调用时,传递的不是变量的值或者引用,而是变量所指对象的引用
  • Python 内置类型分为可变与不可变两种,可变性会影响一些操作的行为,比如 +=
  • 对于可变类型,必要时对其进行拷贝操作,能避免产生意料之外的影响
  • 常见的浅拷贝方式:copy.copy、推导式、切片操作
  • 使用 copy.deepcopy 可以进行深拷贝操作

列表与元组

  • 使用 enumerate 可以在遍历列表的同时获取下标
  • 函数的多返回值其实是一个元组
  • 不存在元组推导式,但可以使用tuple来将生成器表达式转换为元组
  • 元组经常用来表示一些结构化的数据

字典与集合

  • 在Python 3.7版本前,字典类型是无序的,之后变为保留数据的插入顺序
  • 使用 OrderedDict 可以在 Python 3.7 以前的版本里获得有序字典
  • 只有可哈希的对象才能存入集合,或者作为字典的键使用
  • 使用有序字典 OrderedDict 可以快速实现有序去重
  • 使用 fronzenset 可以获得一个不可变的集合对象
  • 集合可以方便地进行集合运算,计算交集、并集
  • 不要通过继承 dict 来创建自定义字典类型

代码可读性技巧

  • 具名元组比普通元组可读性更强
  • 列表推导式可以更快速地完成遍历、过滤、处理以及构建新列表操作
  • 不要编写过于复杂的推导式,用朴实的代码替代就好
  • 不要把推导式当作代码量更少的循环,写普通循环就好

代码可维护性技巧

  • 当访问的字典键不存在时,可以选择捕获异常或先做判断,优先推荐捕获异常
  • 使用 getsetdefault带参数的pop方法 可以简化边界处理逻辑
  • 使用具名元组作为返回值,比普通元组更好扩展
  • 当字典键不存在时,使用 defaultdict 可以简化处理
  • 继承 MutableMapping 可以方便地创建自定义字典类,封装处理逻辑(如果直接继承 dict ,当重写 __setitem__ 时,直接赋值可以出发该函数,但 update 无法触发该操作,继承 MutableMapping 可以解决这个问题,自定义其他容器也会存在类似问题)
  • 用生成器按需返回成员,比直接返回一个结果列表更灵活,也更省内存
  • 使用动态解包语法可以方便地合并字典
  • 不要在遍历列表的同时修改,否则会出现不可预期的结果

代码性能要点

  • 列表的底层实现决定了它的头部操作很慢, deque 类型则没有这个问题
  • 当需要判断某个成员在容器中是否存在时,使用字典/集合更快

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 具名元组
from collections import namedtuple

Rectangle = namedtuple('Rectangle', 'width,height')
rect = Rectangle(width=100, height=200)
rect.width # 100

# Python3.6 语法
from typing import NamedTuple

class Rectangle2(NamedTuple):
width: int
height: int
rect = Rectangle2(100, 200)
rect.width # 100
1
2
3
4
5
6
# 字典的 setdefault 方法
d = {'title': 'foobar'}
d.setdefault('items', []).append('foo')
print(d) # {'title': 'foobar', 'items': ['foo']}
d.setdefault('items', []).append('bar')
print(d) # {'title': 'foobar', 'items': ['foo', 'bar']}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# OrderedDict
from collections import OrderedDict

d1 = {'name': 'piglei', 'fruit': 'apple'}
d2 = {'fruit': 'apple', 'name': 'piglei'}
d1 == d2 # True

d1 = OrderedDict(name='piglei', fruit='apple')
d2 = OrderedDict(fruit='apple', name='piglei')
d1 == d2 # False

# 使用 OrderedDict 可以实现有序去重
nums = [10, 2, 3, 21, 10, 2]
set(nums) # {2, 3, 10, 21}
list(OrderedDict.fromkeys(nums)) # [10, 2, 3, 21]
1
2
3
# frozenset
# frozenset 是一个内置的类型,不需要导入
f_set = frozenset([1, 2, 3, 4, 5])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# deque,当用列表向头插入输入数据时,会存在性能问题,使用 deque 可以解决这个问题
from collections import deque
 
def deque_append():
"""不断往尾部追加"""
l = deque()
for i in range(5000):
l.append(i)

def deque_appendleft():
"""不断往头部插入"""
l = deque()
for i in range(5000):
l.appendleft(i)

分之流程控制

条件分支语句

  • 不要显式地和布尔值做比较
  • 利用类型本身的布尔值规则,省略零值判断
  • 把not代表的否定逻辑移入表达式内部
  • 仅在需要判断某个对象是否是None、True、False时,使用is运算符

Python数据模型

  • 定义 __len____bool__ 魔法方法,可以自定义对象的布尔值规则
  • 定义 __eq__ 方法,可以修改对象在进行 == 运算时的行为

代码可读性技巧

  • 不同分支内容易出现重复或类似的代码,把它们抽到分支外可提升代码的可读性
  • 使用“德摩根定律”可以让有多重否定的表达式变得更容易理解
    • 德摩根定律 - not A or not B 等价于 not (A and B)

代码可维护性技巧

  • 尽可能让三元表达式保持简单
  • 扁平优于嵌套:使用“提前返回”优化代码里的多层分支嵌套
  • 当条件表达式变得特别复杂时,可以尝试封装新的函数和方法来简化
  • and的优先级比or高,不要忘记使用括号来让逻辑更清晰
  • 在使用or运算符替代条件分支时,请注意避开因布尔值运算导致的陷阱

代码组织技巧

  • bisect 模块可以用来优化范围类分支判断
  • 字典类型可以用来替代简单的条件分支语句
  • 尝试总结条件分支代码里的规律,用更精简、更易扩展的方式改写它们
  • 使用 any()all() 内置函数可以让条件表达式变得更精简

代码示例

1
2
3
4
5
6
7
import bisect
# 注意:用来做二分查找的容器必须是已经排好序的
breakpoints = [10, 20, 30]
# bisect 函数会返回值在列表中的位置,0 代表相应的值位于第一个元素 10 之前
bisect.bisect(breakpoints, 1) # 0
# 3 代表相应的值位于第三个元素 30 之后
bisect.bisect(breakpoints, 35) # 3

异常与错误处理

基础知识

  • 一个 try 语句支持多个 except 子句,但请记得把更精确的异常类放在前面
  • try 语句的 else 分支会在没有异常时执行,因此它可用来替代标记变量
  • 不带任何参数的 raise 语句会重复抛出当前异常
  • 上下文管理器经常用来处理异常,它最常见的用途是替代 finally 子句
  • 上下文管理器可以用来忽略某段代码里的异常
  • 使用 @contextmanager 装饰器可以轻松定义上下文管理器
  • 和许多其他编程语言不同,在Python里抛出和捕获异常是很轻量的操作,即使大量抛出、捕获异常,也不会给程序带来过多额外负担。

错误处理与参数校验

  • 当你可以选择编写条件判断或异常捕获时,优先选异常捕获(EAFP)
    • EAFP(easier to ask for forgiveness than permission),可直译为“获取原谅比许可简单”
  • 不要让函数返回错误信息,直接抛出自定义异常吧
  • 手动校验数据合法性非常烦琐,尽量使用专业模块来做这件事
  • 不要使用 assert 来做参数校验,用 raise 替代它
    • assert 是一个专供开发者调试程序的关键字。它所提供的断言检查,可以在执行 Python 时使用 -O 选项直接跳过
  • 处理错误需要付出额外成本,假如能通过设计避免它就再好不过了
  • 在设计 API 时,需要慎重考虑是否真的有必要抛出错误
  • 使用“空对象模式”能免去一些针对边界情况的错误处理工作

捕获异常时

  • 过于模糊和宽泛的异常捕获可能会让程序免于崩溃,但也可能会带来更大的麻烦
  • 异常捕获贵在精确,只捕获可能抛出异常的语句,只捕获可能的异常类型
  • 有时候,让程序提早崩溃未必是什么坏事
  • 完全忽略异常是风险非常高的行为,大多数情况下,至少记录一条错误日志

抛出异常时

  • 保证模块内抛出的异常与模块自身的抽象级别一致
  • 如果异常的抽象级别过高,把它替换为更低级的新异常
  • 如果异常的抽象级别过低,把它包装成更高级的异常,然后重新抛出
  • 不要让调用方用字符串匹配来判断异常种类,尽量提供可区分的异常

代码示例

1
2
3
4
5
6
7
8
9
10
from contextlib import contextmanager

@contextmanager
def create_conn_obj(host, port, timeout=None):
"""创建连接对象,并在退出上下文时自动关闭"""
conn = create_conn(host, port, timeout=timeout)
try:
yield conn
finally:
conn.close()

循环与可迭代对象

迭代与迭代器原理

  • 使用 iter() 函数会尝试获取一个迭代器对象
  • 使用 next() 函数会获取迭代器的下一个内容
  • 可以将for循环简单地理解为 while 循环+不断调用 next()
  • 自定义迭代器需要实现 __iter____next__ 两个魔法方法
  • 生成器对象是迭代器的一种
  • iter(callable, sentinel) 可以基于可调用对象构造一个迭代器

迭代器与可迭代对象

  • 迭代器和可迭代对象是不同的概念
  • 可迭代对象不一定是迭代器,但迭代器一定是可迭代对象
  • 对可迭代对象使用 iter() 会返回迭代器,迭代器则会返回它自身
  • 每个迭代器的被迭代过程是一次性的,可迭代对象则不一定
  • 可迭代对象只需要实现 __iter__ 方法,而迭代器要额外实现 __next__ 方法

代码可维护性技巧

  • 通过定义生成器函数来修饰可迭代对象,可以优化循环内部代码
  • itertools 模块里有许多函数可以用来修饰可迭代对象
  • 生成器函数可以用来解耦循环代码,提升可复用性
  • 不要使用多个 break ,拆分为函数然后直接 return 更好
  • 使用 next() 函数有时可以完成一些意想不到的功能

文件操作知识

  • 使用标准做法读取文件内容,在处理没有换行符的大文件时会很慢
  • 调用 file.read() 方法可以解决读取大文件的性能问题

函数

函数参数与返回相关基础知识

  • 不要使用可变类型作为参数默认值,用None来代替
  • 通过 __defaults__ 属性可以直接获取函数的参数默认值
  • object() 来做可能传入 None 的函数默认值
  • 使用标记对象,可以严格区分函数调用时是否提供了某个参数
  • 定义仅限关键字参数,可以强制要求调用方提供参数名,提升可读性
  • 函数应该拥有稳定的返回类型,不要返回多种类型
  • 适合返回None的情况——操作类函数、查询类函数表示意料之中的缺失值
  • 在执行失败时,相比返回None,抛出异常更为合适
  • 如果提前返回结果可以提升可读性,就提前返回,不必追求“单一出口”
1
2
3
4
5
6
7
def append_value(value, items=[]):
"""向 items 列表中追加内容,并返回列表"""
items.append(value)
return items

In [2]: append_value.__defaults__
Out[2]: ([],)
1
2
3
4
5
6
7
# 定义标记变量
# object 通常不会单独使用,但是拿来做这种标记变量刚刚好
_not_set = object()
def dump_value(value, extra=_not_set):
if extra is _not_set:
# 调用方没有传递 extra 参数
...

代码可维护性技巧

  • 不要编写太长的函数,但长度并没有标准,65行算是一个危险信号
  • 圈复杂度是评估函数复杂程度的常用指标,圈复杂度超过10的函数需要重构
  • 抽象与分层思想可以帮我们更好地构建与管理复杂的系统
  • 同一个函数内的代码应该处在同一抽象级别

函数与状态

  • 没有副作用的无状态纯函数易于理解,容易维护,但大多数时候“状态”不可避免
  • 避免使用全局变量给函数增加状态
  • 当函数状态较简单时,可以使用闭包技巧
  • 当函数需要较为复杂的状态管理时,建议定义类来管理状态

语言机制对函数的影响

  • functools.partial() 可以用来快速构建偏函数
  • functools.lru_cache() 可以用来给函数添加缓存
  • 比起 mapfilter ,列表推导式的可读性更强,更应该使用
  • lambda 函数只是一种语法糖,你可以使用 operator 模块等方式来替代它
  • Python 语言里的递归限制较多,可能的话,请尽量使用循环来替代

装饰器

基础与技巧

  • 装饰器最常见的实现方式,是利用闭包原理通过多层嵌套函数实现
  • 在实现装饰器时,请记得使用 wraps() 更新包装函数的元数据,添加 @wraps(wrapped) 来装饰 decorated 函数后,wraps() 首先会基于原函数 func 来更新包装函数 decorated 的名称、文档等内置属性,之后会将 func 的所有额外属性赋值到 decorated 上
  • wraps() 不光可以保留元数据,还能保留包装函数的额外属性
  • 利用仅限关键字参数,可以很方便地实现可选参数的装饰器

使用类来实现装饰器

  • 只要是可调用的对象,都可以用作装饰器
  • 实现了__call__方法的类实例可调用
  • 基于类的装饰器分为两种:“函数替换”与“实例替换”
  • “函数替换”装饰器与普通装饰器没什么区别,只是嵌套层级更少
  • 通过类来实现“实例替换”装饰器,在管理状态和追加行为上有天然的优势
  • 混合使用类和函数来实现装饰器,可以灵活满足各种场景

使用wrapt模块

  • 使用wrapt模块可以方便地让装饰器同时兼容函数和类方法
  • 使用wrapt模块可以帮你写出结构更扁平的装饰器代码

装饰器设计技巧

  • 装饰器将包装调用提前到了函数被定义的位置,它的大部分优点也源于此
  • 在编写装饰器时,请考虑你的设计是否能很好发挥装饰器的优势
  • 在某些场景下,类装饰器可以替代元类,并且代码更简单
  • 装饰器和装饰器模式截然不同,不要弄混它们
  • 装饰器里应该只有一层浅浅的包装代码,要把核心逻辑放在其他函数与类中

附录1-内置语法特性

  • locals() - 返回当前作用域内的所有局部变量

  • Python 的浮点数是使用符合 IEEE-754 规范的双精度,使用 53 个比特精度来表示十进制浮点数

  • str.partition(sep) - 从左往右查找第一个 sep 出现的位置,返回一个三元组,包含分割前的字符串、sep、分割后的字符串

  • str.translate(table) - 使用 table 中的映射关系来替换字符串中的字符

    1
    2
    3
    4
    5
    s = '明明是中文,却使用了英文标点.'
    # 创建替换规则表:',' -> ',', '.' -> '。'
    table = s.maketrans(',.', ',。')
    s.translate(table)
    # '明明是中文,却使用了英文标点。'
  • enmuerate(iterable, start=0) - 返回一个枚举对象,包含每个元素的索引值与元素值

    • start - 索引起始值,默认为0
  • frozenset - 不可变集合,不支持添加、删除操作,比起 set 没有以下方法

    • add
    • clear
    • discard
    • pop
    • remove
    • update
    • |= / &= / -= / ^=
  • if 语句后直接放自定义类型,会优先查找 __bool__ 的定义,如果没有再查找 __len__ 的定义,如果都没有,会返回 True

  • Python 中 True / False / None 是严格以单例模式实现的,可以使用 is 来判断两个变量是否指向同一个对象

  • all(iterable) - 仅当iterable中所有成员的布尔值都为真时返回True,否则返回False

  • any(iterable) - 只要iterable中任何一个成员的布尔值为真就返回True,否则返回False

  • with 语句会在代码块执行前调用 __enter__ 方法,执行后调用 __exit__ 方法,使用 with 语句的对象必须实现这两个方法,这两个方法都可以返回一个值,如果 __exit__ 返回 True,异常会被忽略,否则异常会被重新抛出

    • __exit__ 接受三个参数,exc_type / exc_val / exc_tb,分别表示异常类型、异常对象、异常堆栈信息
    • __exit__ 返回 True 时,异常会被忽略,返回 False 时,异常会被重新抛出
    1
    2
    3
    4
    5
    6
    7
    class ignore_close():
    def __enter__(self):
    pass
    def __exit__(self, exc_type, exc_val, exc_tb):
    if exc_type == CloseError:
    return True
    return False
  • 当你使用 for 循环遍历某个可迭代对象时,其实是先调用了 iter() 拿到它的迭代器,然后不断地用 next() 从迭代器中获取值

  • 如果一个类型没有定义 __iter__ ,但是定义了 __getitem__ 方法,那么 Python 也会认为它是可迭代的。在遍历它时,解释器会不断使用数字索引值(0, 1, 2, …)来调用 __getitem__ 方法获得返回值,直到抛出 IndexError 为止。

附录2-内置库功能

  • timeit.timeit(setup, setup, timer, number) - 测试代码执行时间

    • stmt - 这将采用您要测量其执行时间的代码。默认值为 pass
    • setup - 这将包含需要在stmt之前执行的设置详细信息。默认值为 pass
    • timer - 它将具有计时器值,timeit() 已经设置了默认值,我们可以忽略它
    • number - stmt 将按照此处给出的编号执行多少次。默认值为1000000
  • textwrap.dedent(text) - dedent方法会删除整段字符串左侧的空白缩进。使整段代码的缩进视觉效果保持正常

  • collections.namedtuple(typename, field_names, *, rename=False, defaults=None, module=None) - 创建一个具名元组子类

    • typename - 元组名称
    • field_names - 元组字段名称,可以是字符串列表、空格分隔的字符串、逗号分割的字符串
    • rename - 如果字段名称中有 Python 关键字,需要设置为 True,默认为 False
    • defaults - 字段的默认值,可以是一个列表或者是一个字典
    • module - 指定元组所在的模块名称
  • bisect.bisect(a, x) - 在有序序列 a 中查找 x 的插入位置,返回插入位置的索引值

  • bisect.insort(a, x) - 在有序序列 a 中插入 x,返回插入位置的索引值

    1
    2
    3
    4
    5
    from bisect import bisect, insort

    a = [1, 4, 6, 8, 12, 15, 20]
    bisect(a, 7) # 3
    insort(a, 7) # [1, 4, 6, 7, 8, 12, 15, 20]
  • itertools.product(*iterables, repeat=1) - 接受多个可迭代对象,返回计算笛卡尔积

  • itertools.islice(iterable, start, stop[, step]) - 返回一个迭代器,从 start 开始,到 stop 结束,步长为 step