Featured image of post python工匠

python工匠

众里寻他千百度,蓦然回首那人却在门口小卖部

变量与注释

变量解包

允许我们把一个可迭代对象的所有成员,一次性赋值给多个变量

假如在赋值语句左侧添加⼩括号 (…),甚⾄可以⼀次展开多层嵌套数据

1
2
3
4
5
6
7
8
attrs = [1,['piglei',100]]

user_id,(username,score) = attrs

>> user_id
1
>> username
'piglei'

除了上⾯的普通解包外,Python 还⽀持更灵活的动态解包语法。只要用星号表达式(*variables)作为变量名,她会贪婪地捕获多个值对象,并将捕获到的内容作为列表赋值给variables

⽐如,下⾯ data 列表⾥的数据就分为三段:头为⽤⼾,尾为分数,中间的都是⽔果名称。通 过把 *fruits 设置为中间的解包变量,我们就能⼀次性解包所有变量——fruits 会捕获 data 去头去尾后的所有成员

1
2
3
4
5
6
7
8
9
data = ['piglei', 'apple', 'orange', 'banana', 100]
username,*fruits,score = data
>>username
'piglei'

>>fruits
['apple','orange','banana']
>>score
100

和常规的切⽚赋值语句⽐起来,动态解包语法要直观许多

1
2
3
4
5
# 动态解包
>> username,*fruits,score = data
# 切片赋值
>> username,fruits,score = data[0],data[1:-1],data[-1]
# 两种完全等价

上⾯的变量解包操作也可以在任何循环语句⾥使⽤

1
2
3
4
for username,score in [('piglei',100),('raymonmd',60)]:
    print(username)
>>glei
>>ymond

单下划线变量名 _

在常⽤的诸多变量名中,单下划线 _ 是⽐较特殊的⼀个。它常作为⼀个⽆意义的占位符出现 在赋值语句中。_ 这个名字本⾝没什么特别之处,这算是⼤家约定俗成的⼀种⽤法。 举个例⼦,假如你想在解包赋值时忽略某些变量,就可以使⽤ _ 作为变量名:

1
2
3
4
5
# 忽略展开时的第二个变量
author,_ = usernames

# 忽略第一个和最后一个变量之间的所有变量
username,*_,score = data

⽽在 Python 交互式命令⾏(直接执⾏ python 命令进⼊的交互环境)⾥,_ 变量还有⼀ 层特殊含义——默认保存我们输⼊的上个表达式的返回值

1
2
3
4
>>> 'foo'.upper()
'FOO'
>>> print(_)
FOO

此时的_变量保存着上一个.upper()表达式的结果

下⾯是增加了 Python 官⽅推荐的 Sphinx 格式⽂档后的效果

1
2
3
4
5
6
def remove_invalid(items):
    """剔除items里面无效的元素

    :param items:待剔除对象
    :type items:包含整数的列表,[int,...]
    """

当然,标注类型的办法肯定不⽌上⾯这⼀种。在 Python 3.5 版本 以后,你可以⽤类型注 解功能来直接注明变量类型。相⽐编写 Sphinx 格式⽂档,我其实更推荐使⽤类型注解,因为它是 Python 的内置功能,⽽且正在变得越来越流⾏

1
2
3
4
from typing import List

def remove_invalid(items:List[int]):
    """剔除items里无效的元素"""

List 表⽰参数为列表类型,[int] 表⽰⾥⾯的成员是整型

  • 对于普通变量,使⽤蛇形命名法,⽐如 max_value;
  • 对于常量,采⽤全⼤写字⺟,使⽤下划线连接,⽐如 MAX_VALUE;
  • 如果变量标记为“仅内部使⽤”,为其增加下划线前缀,⽐如 _local_var;
  • 当名字与 Python 关键字冲突时,在变量末尾追加下划线,⽐如 class_

布尔值(bool)是⼀种很简单的类型,它只有两个可能的值:“是”(True)或“不是” (False)。因此,给布尔值变量起名有⼀个原则:⼀定要让读到变量的⼈觉得它只有 “肯定”和“否定”两种可能。举例来说,is、has 这些⾮⿊即⽩的词就很适合⽤来修饰这 类名字。

最好别拿⼀个名词的复数形式来作为 int 类型的变量名,⽐如 apples、 trips 等,因为这类名字容易与那些装着 Apple 和 Trip 的普通容器对象 (List[Apple]、List[Trip])混淆,建议⽤ number_of_apples 或 trips_count 这类复合词来作为 int 类型的名字。

超短命名

  • 数组索引三剑客 i,j,k
  • 某个整数 n
  • 某个字符串 s
  • 文件对象 fp

另⼀类短名字,则是对⼀些其他常⽤名的缩写。⽐如,在使⽤ Django 框架做国际化内容翻 译时,常常会⽤到 gettext ⽅法。为了⽅便,我们常把 gettext 缩写成 _:

1
2
from django.utils.translation import gettext as _
print(_('待翻译文字'))

除使⽤ # 的注释外,另⼀种注释则是我们前⾯看到过的函数(类)⽂档(docstring),这些 ⽂档也称接⼝注释(interface comment)。

1
2
3
4
5
6
7
class Person:
    """人

    :param name:姓名
    :param age:年龄
    :param favorite_color:最喜欢的颜色
    """

注释作为代码之外的说明性⽂字,应该尽量提供那些读者⽆法从代码⾥读出来的信息。描述代 码为什么要这么做,⽽不是简单复述代码本⾝。

除了描述“为什么”的解释性注释外,还有⼀种注释也很常⻅:指引性注释。这种注释并不直接 复述代码,⽽是简明扼要地概括代码功能,起到“代码导读”的作⽤。

在编写指引性注释时,有⼀点需要注意,那就是你得判断何时该写注释,何时该将代码提炼为 独⽴的函数(或⽅法)。⽐如上⾯的代码,其实可以通过抽象两个新函数改成下⾯这样:

在编写接⼝⽂档时,我们应该站在函数设计者的⻆度,着重描述函数的功能、参数说明等。⽽ 函数⾃⾝的实现细节,⽐如调⽤了哪个第三⽅模块、为何有性能问题等,⽆须放在接⼝⽂档 ⾥。

1
2
3
4
5
6
7
8
9
def resize_image(image,size):
    """将图片缩放到指定尺寸,并返回新的图片
    
    注意:当⽂件超过 5MB 时,请使⽤ resize_big_image(

    :param image:图片文件对象
    :param size:包含宽高的元组:(width,height)
    :return:新图片对象
    """
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def magic_bubble_sort(numbers:List[int]):
    """有魔力的冒泡排序算法,默认所有的偶数都比奇数大

    :param numbers:需要排序的列表,函数会直接修改原始列表
    """

    stop_position = len(numbers) - 1
    while stop_position > 0:
        for i in range(stop_position):
            cuurent,next_ = numbers[i],number[i+1]
            current_is_even,next_is_even = current % 2 == 0,next_ % 2 == 0
            should_swap = False
            # 交换位置的两个条件
            # - 前面是偶数,后面是奇数
            
        # - 前面和后面同为奇数或者偶数,但是前面比后面大
        if current_is_even and not next_is_even:
            should_swap = True
        elif current_is_even == next_is_even and current > next_:
            should_swap = True
        
        if should_swap:
            numbers[i],numbers[i+1] = numbers[i+1],numbers[i]
        
        stop_position -= 1

    return numbers

保持变量的一致性

  • 名字⼀致性是指在同⼀个项⽬(或者模块、函数)中,对⼀类事物的称呼不要变来变去。
  • 类型⼀致性则是指不要把同⼀个变量重复指向不同类型的值

通过把变量定义移动到每段“各司其职”的代码头部,⼤⼤缩短了变量从初始化到被使⽤的“距 离”。当读者阅读代码时,可以更容易理解代码的逻辑,⽽不是来回翻阅代码,⼼想:“这个变量是什 么时候定义的?是⼲什么⽤的?”

定义临时变量提升可读性

1
2
3
4
# 为所有性别为⼥或者级别⼤于 3 的活跃⽤⼾发放 10 000 个⾦币
if user.is_active and (user.sex == 'female' or user.level > 3):
    user.add_coins(10000)
    return

逻辑虽然如此,不代表我们就得把代码直⽩地写成这样。如果把后⾯的复杂表达式赋值为⼀个临 时变量,代码可以变得更易读:

1
2
3
4
5
6
# 为所有性别为⼥或者级别⼤于 3 的活跃⽤⼾发放 10 000 个⾦币
user_is_eligible = user.is_active and (user.sex == 'female' or user.level > 3)

if user_is_eligible:
    user.add_coins(10000)
    return

在新代码⾥,“计算⽤⼾合规的表达式”和“判断合规发送⾦币的条件分⽀”这两段代码不再直接 杂糅在⼀起,⽽是添加了⼀个可读性强的变量 user_is_elegible 作为缓冲。不论是代码的可读 性还是可维护性,都因为这个变量⽽增强了

直接翻译业务逻辑的代码,⼤多不是好代码。优秀的程序设计需要在理解原需求的基础 上,恰到好处地抽象,只有这样才能同时满⾜可读性和可扩展性⽅⾯的需求。抽象有许多种⽅ 式,比如定义新函数,定义新类型,“定义⼀个临时变量”是诸多⽅式⾥不太起眼的⼀个,但⽤得 恰当的话效果也很巧妙。

同一作用域不要有太多变量

对局部变量分组并建模

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class ImportedSummary:
    """保存导入结果摘要的数据类"""
    
    def __init__(self):
        self.succeeded_count = 0
        self.failed_count = 0

class ImportingUserGroup:
    """用于暂存用户导入处理的数据类"""

    def __init__(self):
        self.duplicated = []
        self.banned = []
        self.normal = []

def import_users_from_file(fp):
    """尝试从文件对象读取用户,然后导入数据库

    :param fp:可读文件对象
    :return: 成功与失败的数量
    """
    importing_user_group = ImportingUserGroup()
    for line in fp:
        parsed_user = parse_user(line)
        # …… 进⾏判断处理,修改上⾯定义的 importing_user_group 变量

    summary = ImportedSummary()
    #  …… 读取 importing_user_group,写⼊数据库并修改成功与失败的数量

    return summary.succeeded_count,summary.failed_count

通过增加两个数据类,函数内的变量被更有逻辑地组织了起来,数量变少了许多。 需要说明的⼀点是,⼤多数情况下,只是执⾏上⾯这样的操作是远远不够的。函数内变量的数量 太多,通常意味着函数过于复杂,承担了太多职责。只有把复杂函数拆分为多个⼩函数,代码的整体 复杂度才可能实现根本性的降低

能不定义变量就别定义

1
2
3
4
5
def get_best_trip_by_user_id(user_id):
    return {
        'user': get_user(user_id),
        'trip': get_best_trip(user_id)
    }

这样的代码就像删掉赘语的句⼦,变得更精练、更易读。所以,不必为了那些未来可能出现的变 动,牺牲代码此时此刻的可读性。如果以后需要定义变量,那就以后再做吧

不要使用locals()

locals() 是 Python 的⼀个内置函数,调⽤它会返回当前作⽤域中的所有局部变量:

在有些场景下,我们需要⼀次性拿到当前作⽤域下的所有(或绝⼤部分)变量,⽐如在渲染 Django 模板时:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 = check_if_suggested(user,trip)
    return render(request,'trip.html',{
        'user':user,
        'trip':trip,
        'is_suggested':is_suggested
    })

看上去使⽤ locals() 函数正合适,假如调⽤ locals(),上⾯的代码会简化许多:

1
2
3
4
def render_trip_page(request,user_id)
    # 利⽤ locals() 把当前所有变量作为模板渲染参数返回
    # 节约了三⾏代码,我简直是个天才!
    return render(request,'trip.html',locals())

第⼀眼看上去⾮常“简洁”,但是,这样的代码真的更好吗? 答案并⾮如此。locals() 看似简洁,但其他⼈在阅读代码时,为了搞明⽩模板渲染到底⽤了 哪些变量,必须记住当前作⽤域⾥的所有变量。假如函数⾮常复杂,“记住所有局部变量”简直是个不 可能完成的任务

使⽤ locals() 还有⼀个缺点,那就是它会把⼀些并没有真正使⽤的变量也⼀并暴露

Python 之禅:显式优于隐式

“Python 之禅”中有⼀句“Explicit is better than implicit”(显式优于隐 式),这条原则完全可以套⽤到 locals() 的例⼦上——locals() 实在是太隐晦了,直接写 出变量名显然更好

空行也是一种注释

在写代码时,我们可以适当地在代码中插⼊空⾏,把代码按不同的逻辑块分隔开,这样能有效提 升代码的可读性

先写注释,后写代码

每个函数的名称与接⼝注释(也就是 docstring),其实是⼀种⽐函数内部代码更为抽象的东 西。你需要在函数名和短短⼏⾏注释⾥,把函数内代码所做的事情,⾼度浓缩地表达清楚。

举个例⼦,你在编辑器⾥写下了 def process_user(…):,准备实现⼀个名为 process_user 的新函数。在编写函数注释时,你发现在写了好⼏⾏⽂字后,仍然没法把 process_user() 的职责描述清楚,因为它可以同时完成好多件不同的事情。 这时你就应该意识到,process_user() 函数承担了太多职责,解决办法就是直接删掉它,设 计更多单⼀职责的⼦函数来替代之。 先写注释的另⼀个好处是:不会漏掉任何应该写的注释。 我常常在审查代码时发现,⼀些关键函数的 docstring 位置⼀⽚空⽩,⽽那⾥本该备注详尽 的接⼝注释。每当遇到这种情况,我都会不厌其烦地请代码提交者补充和完善接⼝注释。 为什么⼤家总会漏掉注释?我的⼀个猜测是:程序员在编写函数时,总是跳过接⼝注释直接开始 写代码。⽽当写完代码,实现函数的所有功能后,他就对这个函数失去了兴趣。这时,他最不愿意做 的事,就是回过头去补写函数的接⼝注释,即便写了,也只是草草对付了事。 如果遵守“先写注释,后写代码”的习惯,我们就能完全避免上⾯的问题。要养成这个习惯其实很 简单:在写出⼀句有说服⼒的接⼝注释前,别写任何函数代码

要点总结

  1. 变量和注释决定“第⼀印象”
  • 变量和注释是代码⾥最接近⾃然语⾔的东西,它们的可读性⾮常重要
  • 即使是实现同⼀个算法,变量和注释不⼀样,给⼈的感觉也会截然不同
  1. 基础知识
  • Python 的变量赋值语法⾮常灵活,可以使⽤ *variables 星号表达式灵活赋值
  • 编写注释的两个要点:不要⽤来屏蔽代码,⽽是⽤来解释“为什么”
  • 接⼝注释是为使⽤者⽽写,因此应该简明扼要地描述函数职责,⽽不必包含太多内部细节
  • 可以⽤ Sphinx 格式⽂档或类型注解给变量标明类型
  1. 变量名字很重要
  • 给变量起名要遵循 PEP 8 原则,代码的其他部分也同样如此
  • 尽量给变量起描述性强的名字,但评价描述性也需要结合场景
  • 在保证描述性的前提下,变量名要尽量短
  • 变量名要匹配它所表达的类型
  • 可以使⽤⼀两个字⺟的超短名字,但注意不要过度使⽤
  1. 代码组织技巧
  • 按照代码的职责来组织代码:让变量定义靠近使⽤
  • 适当定义临时变量可以提升代码的可读性
  • 不必要的变量会让代码显得冗⻓、啰唆
  • 同⼀个作⽤域内不要有太多变量,解决办法:提炼数据类、拆分函数
  • 空⾏也是⼀种特殊的“注释”,适当的空⾏可以让代码更易
  1. 代码可维护性技巧
  • 保持变量在两个⽅⾯的⼀致性:名字⼀致性与类型⼀致性
  • 显式优于隐式:不要使⽤ locals() 批量获取变量
  • 把接⼝注释当成⼀种函数设计⼯具:先写注释,后写代码

数值与字符串

为了解决这个问题,Python 提供了⼀个内置模块:decimal。假如你的程序需要精确的浮点数 计算,请考虑使⽤ decimal.Decimal 对象来替代普通浮点数,它在做四则运算时不会损失任何精 度

1
2
3
4
from decimal import Decimal
# 这里的'0.1'和'0.2'必须是字符串
Decimal('0.1') + Decimal('0.2')
Decimal('0.3')

在使⽤ Decimal 的过程中,⼤家需要注意:必须使⽤字符串来表⽰数字。如果你提供的是普通 浮点数⽽⾮字符串,在转换为 Decimal 对象前就会损失精度,掉进所谓的“浮点数陷阱”

1
2
Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

布尔值其实也是数字

布尔(bool)类型是 Python ⾥⽤来表⽰“真假”的数据类型。你肯定知道它只有两个可选值: True 和 False。不过,你可能不知道的是:布尔类型其实是整型的⼦类型,在绝⼤多数情况下, True 和 False 这两个布尔值可以直接当作 1 和 0 来使⽤。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int(True),int(False)
(1,0)
>> True + 1
2

# 把False当除数的效果和0一样
1/False

Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

布尔值的这个特点,常用来简化统计总数操作 假设有⼀个包含整数的列表,我需要计算列表⾥⼀共有多少个偶数。正常来说,我得写⼀个循环 加分⽀结构才能完成统计

1
2
3
4
5
6
numbers = [1, 2, 4, 5, 7]
count = 0
for i in numbers:
    if i % 2 == 0:
        count += 1
print(count)

利用布尔值可以作为整型使用的特性,一个简单的表达式就能完成同样的事情:

1
count = sum(i % 2 == 0 for i in numbers)

此处的表达式 i % 2 == 0 会返回⼀个布尔值结果,该结果随后会被当成数字 0 或 1 由 sum() 函数累加求和

字符串常用操作

字符串是⼀种序列类型,这意味着你可以对它进⾏遍历、切⽚等操作,就像访问⼀个列表对象⼀ 样

第⼀种字符串格式化⽅式历史最为悠久,但现在已经很少使⽤。相⽐之下,后两种⽅式正变得越 来越流⾏。从个⼈体验来说,f-string 格式化⽅式⽤起来最⽅便,是我的⾸选。和其他两种 ⽅式⽐起来,使⽤ f-string 的代码多数情况下更简洁、更直观。

str.format 与 f-string 共享了同⼀种复杂的“字符串格式化微语⾔”。通过这种微语⾔, 我们可以⽅便地对字符串进⾏⼆次加⼯,然后输出。⽐如:

1
2
3
4
5
6
# 将 username 靠右对⻬,左侧补空格到⼀共 20 个字符
# 以下两种⽅式将输出同样的内容
print('{:>20}'.format(username))
print(f'{username:>20}')
# 输出:
#               piglei

虽然年轻的 f-string 抢⾛了 str.format 的⼤部分⻛头,但后者仍有着⾃⼰的独到之 处。⽐如 str.format ⽀持⽤位置参数来格式化字符串,实现对参数的重复使⽤:

1
2
3
print('{0}:name={0} score={1}'.format(username,score))
# 输出:
piglei: name=piglei score=100

综上所述,⽇常编码中推荐优先使⽤ f-string,搭配 str.format 作为补充,想必能满⾜ ⼤家绝⼤多数的字符串格式化需求

拼接多个字符串

假如要拼接多个字符串,⽐较常⻅的 Python 式做法是:⾸先创建⼀个空列表,然后把需要拼 接的字符串都放进列表,最后调⽤ str.join 来获得⼤字符串。⽰例如下:

1
2
3
4
5
6
7
8
9
words = ['Number(1-10):']
for i in range(10):
    words.append(f'Value:{i+1}')

print('\n'.join(words))
Number(1-10):
Value:1
...
Value:10

除了使⽤ join,也可以直接⽤ words_str += f’Value: {i + 1}’ 这种⽅式来拼接字 符串。但也许有⼈告诫过你:“千万别这么⼲!这样操作字符串很慢很不专业!”这个说法也许 曾经正确,但现在看其实有些危⾔耸听。我在 2.3.5 节会向你证明:在拼接字符串时,+= 和 join 同样好⽤。

不常用但特别好用的字符串方法

为了⽅便,Python 为字符串类型实现了⾮常多内置⽅法。在对字符串执⾏某种操作前,请⼀定 先查查某个内置⽅法是不是已经实现了该操作,否则⼀不留神就会重复发明轮⼦。

⽇常编程中,我们最常⽤到的字符串⽅法有 .join()、.split()、.startswith(),等等。 虽然这些常⽤⽅法能满⾜⼤部分的字符串处理需求,但要成为真正的字符串⾼⼿,除了掌握常⽤⽅ 法,了解⼀些不那么常⽤的⽅法也很重要。在这⽅⾯,.partition() 和 .translate() ⽅法就 是两个很好的例⼦

str.partition(sep)的功能是按照分隔符sep切分字符串

返回一个包含三个成员的元组(part_before,sep,part_after),它们分别代表分隔符前的内容,分隔符以及分隔符后的内容

第⼀眼看上去,partition 的功能和 split 的功能似乎是重复的——两个⽅法都是分割字符 串,只是结果稍有不同。但在某些场景下,使⽤ partition 可以写出⽐⽤ split 更优雅的代 码

举个例⼦,我有⼀个字符串 s,它的值可能会是以下两种格式。 (1)’{key}:{value}’,键值对标准格式,此时我需要拿到 value 部分。 (2)’{key}’,只有 key,没有冒号 : 分隔符,此时我需要拿到空字符串 ‘’。 如果⽤ split ⽅法来实现需求,我需要写出下⾯这样的代码

1
2
3
4
5
6
7
def extract_value(s):
    items = s.split(':')
    # 因为s不一定会包含':',所以需要对结果长度进行判断
    if len(items) == 2:
        return items[1]
    else:
        return ''

这个函数的逻辑虽算不上复杂,但由于 split 的特点,函数内的分⽀判断基本⽆法避免。这 时,如果使⽤ partition 函数来替代 split,原本的分⽀判断逻辑就可以消失——⼀⾏代码就能完 成任务:

1
2
3
4
def extract_value_v2(s):
    # 当s包含分隔符 : 时,元组最后一个成员刚好是value
    # 若是没有分隔符,最后一共成员默认时空字符串''
    return s.partition(':')[-1]

除了 partition ⽅法,str.translate(table) ⽅法有时也⾮常有⽤。它可以按规则⼀次 性替换多个字符,使⽤它⽐调⽤多次 replace ⽅法更快也更简单:

1
2
3
4
5
s = '明明是中文,却使用了英文标点.'

# 创建替换规则表:',' -> ',','.'->'。'
table = s.maketrans(',.',',。')
s.translate(table)

字符串与字节符

  1. 字符串:我们最常挂在嘴边的“普通字符串”,有时也被称为⽂本(text),是给⼈看的, 对应 Python 中的字符串(str)类型。str 使⽤ Unicode 标准,可通过 .encode() ⽅法编 码为字节串

  2. 字节串:有时也称“⼆进制字符串”(binary string),是给计算机看的,对应 Python 中的字节串(bytes)类型。bytes ⼀定包含某种真正的字符串编码格式(默认为 UTF-8),可通 过 .decode() 解码为字符串。

要创建⼀个字节串字⾯量,可以在字符串前加⼀个字⺟ b 作为前缀

bin_obj = b’Hello’

bytes 和 str 是两种数据类型,即便有时看上去“⼀样”,但做⽐较时永不相等

‘Hello’ == b’Hello’ False

1
2
3
4
5
6
7
>>> 'Hello'.split('e')
['H', 'llo']
# str 不能使⽤ bytes 来调⽤任何内置⽅法,反之亦然
>>> 'Hello'.split(b'e')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: must be str or None, not bytes

正因为字符串⾯向的是⼈,⽽⼆进制的字节串⾯向的是计算机,因此,在使⽤体验⽅⾯,前者要 好得多。在我们的程序中,应该尽量保证总是操作普通字符串,⽽⾮字节串。必须操作处理字节串的 场景,⼀般来说只有两种:

  1. 程序从文件或其他外部存储读取字节串内容,将其解码为字符串,然后再在内部使⽤;
  2. 程序完成处理,要把字符串写⼊⽂件或其他外部存储,将其编码为字节串,然后继续执⾏其 他操作

当接收的输⼊是字节串时,你需要先将其转换为普通字符串,再调⽤函数:

反之,当你要把字符串写⼊⽂件(进⼊计算机的领域)时,请谨记:普通字符串采⽤的是⽂本格 式,没法直接存放于外部存储,⼀定要将其编码为字节串——也就是“⼆进制字符串”——才⾏

如果不指定 encoding,Python 将通过 locale 模块获取系统偏好的编码格式

说到有意义的数字,⼤家最先想到的⼀般是“常量”(constant)。但 Python ⾥没有真正 的常量类型,⼈们⼀般会把⼤写字⺟全局变量当“常量”来⽤。

⽐如把积分数量定义为常量:

1
2
3
4
# ⽤⼾每⽇奖励积分数量
DAILY_POINTS_REWARDS = 100
# VIP ⽤⼾每⽇额外奖励积分数量
VIP_EXTRA_POINTS = 20

除了常量以外,我们还可以使⽤枚举类型(enum.Enum)。

1
2
3
4
5
6
7
8
9
from enum import Enum

# 在定义枚举类型时,如果同时继承⼀些基础类型,⽐如 int、str,
# 枚举类型就能同时充当该基础类型使⽤。⽐如在这⾥,UserType 就可以当作int 使⽤
class UserType(int,Enum):
    # VIP用户
    VIP = 3
    # 小黑屋用户
    BANNED = 13

有了这些常量和枚举类型后,⼀开始那段满是“密码”的代码就可以重写成这样:

1
2
3
4
5
6
7
8
9
def add_daily_points(user):
    """用户每天完成第一次登录后,为其增加积分"""
    if user.type == UserType.BANNED:
        return
    if user.type == UserType.VIP:
        user.points += DAILY_POINTS_REWARDS + VIP_EXTRA_POINTS
        return
    user.points += DAILY_POINTS_REWARDS
    return

把那些神奇的数字定义成常量和枚举类型后,代码的可读性得到了可观的提升。不仅如此,代 码出现 bug 的概率其实也降低了。

最后,总结⼀下⽤常量和枚举类型来代替字⾯量的好处。

  • 更易读:所有⼈都不需要记忆某个数字代表什么。
  • 更健壮:降低输错数字或字⺟产⽣ bug 的可能性

别轻易成为SQL语句大师

在这个⼤公司的核⼼项⽬⾥,所有的数据库操作代码,都是⽤下⾯这样的“裸字符串处理”逻辑拼 接 SQL 语句⽽成的,⽐如⼀个根据条件查询⽤⼾列表的函数如下所⽰:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def fetch_users(
    conn,
    min_level=None,
    gender=None,
    has_membership=False,
    sort_field="created",
 ):
    """获取⽤⼾列表
    :param min_level: 要求的最低⽤⼾级别,默认为所有级别
    :type min_level: int, optional
    :param gender: 筛选⽤⼾性别,默认为所有性别
    :type gender: int, optional
    :param has_membership: 筛选会员或⾮会员⽤⼾,默认为 False,代表⾮会员
    :type has_membership: bool, optional
    :param sort_field: 排序字段,默认为 "created",代表按⽤⼾创建⽇期排序
    :type sort_field: str, optional
    :return: ⼀个包含⽤⼾信息的列表:[(User ID, User Name), ...]
    """
    # ⼀种古⽼的 SQL 拼接技巧,使⽤“WHERE 1=1”来简化字符串拼接操作
    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)
    # 将查询参数 params 作为位置参数传递,避免 SQL 注⼊问题
    return list(conn.execute(statement, params))

但令⼈遗憾的是,这样的代码只是看上去简单,实际上有⼀个⾮常⼤的问题:⽆法有效表达更复杂的业务逻辑。假如未来查询逻辑要增加⼀些复合条件、连表查询,⼈们很难在现有代码的基础上扩展,修改也容易出错。

使用SQLAlchmey模块改写代码

上述函数所做的事情,我习惯称之为“裸字符串处理”。这种处理⼀般只使⽤基本的加减乘除和循环,配合 .split() 等内置⽅法来不断操作字符串,获得想要的结果。

它的优点显⽽易⻅:⼀开始业务逻辑⽐较简单,操作字符串代码符合思维习惯,写起来容易。但随着业务逻辑逐渐变得复杂,这类裸处理就会显得越来越⼒不从⼼。 其实,对于 SQL 语句这种结构化、有规则的特殊字符串,⽤对象化的⽅式构建和编辑才是更好的做法

下⾯这段代码引⼊了 SQLAlchemy 模块,⽤更少的代码量完成了同样的功能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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 mini_level != None:
        query = query.where(users.c.level >= min_level)
    if gender != 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 注⼊问 题。它最⼤的缺点在于引⼊了⼀个额外依赖:sqlalchemy,但同 sqlalchemy 带来的种种 好处相⽐,这点复杂度成本微不⾜道。

使用Jinja2模板处理字符串

除了 SQL 语句,我们⽇常接触最多的还是⼀些普通字符串拼接任务。⽐如,有⼀份电影评分 数据列表,我需要把它渲染成⼀段⽂字并输出

代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def render_movies(username,movies):
    """
    以文本方式展开电源列表信息
    """
    welcome_test = 'Welcome,{}.\n'.format(username)
    text_parts = [welcome_text]
    for name,rating in movies:
        # 没有提供评分的电影,以[NOT RATED]代替
        rating_text = rating if rating else '[NOT RATED]'
        movie_item = '* {},Rating:{}'.format(name,rating_text)
        text_parts.append(movie_item)
    return '\n'.join(text_parts)

movies = [
    ('The Shawshank Redemption', '9.3'),
    ('The Prestige', '8.5'),
    ('Mulan', None),
]

print(render_movies('piglei',movies))

Welcome, piglei.

  • The Shawshank Redemption, Rating: 9.3
  • The Prestige, Rating: 8.5
  • Mulan, Rating: [NOT RATED]

或许你觉得,这样的字符串拼接代码没什么问题。但如果使⽤ Jinja2 模板引擎处理,代码 可以变得更简单:

1
2
3
4
5
6
7
from jinja2 import Template

_MOVIES_TMPL = '''\
Welcome,{{ username }}.
{%for name,rating in movies %}
* {{ name }}, Rating: {{ rating|default("[NOT RATED]",True)}}
{%- endfor %}

和之前的代码相⽐,新代码少了列表拼接、默认值处理,所有的逻辑都通过模板语⾔来表达。 假如我们的渲染逻辑以后变得更复杂,第⼆份代码也能更好地随之进化。 总结⼀下,当你的代码⾥出现复杂的裸字符串处理逻辑时,请试着问⾃⼰⼀个问题:“⽬标/源 字符串是结构化的且遵循某种格式吗?”如果答案是肯定的,那么请先寻找是否有对应的开源专 有模块,⽐如处理 SQL 语句的 SQLAlchemy、处理 XML 的 lxml 模块等。 如果你要拼接⾮结构化字符串,也请先考虑使⽤ Jinja2 等模板引擎,⽽不是⼿动拼接,因 为⽤模板引擎处理字符串之后,代码写起来更⾼效,也更容易维护。

编程建议

不必预计算字面量表达式

但事实是,即便我们把代码改写成 if delta_seconds < 11 * 24 * 3600:,函数也不会 多出任何额外开销。为了展⽰这⼀点,我们需要⽤到两个知识点:字节码与 dis 模块

使⽤ dis 模块反编译字节码 虽然 Python 是⼀⻔解释型语⾔,但在解释器真正运⾏ Python 代码前,其实仍然有⼀个类 似“编译”的加速过程:将代码编译为⼆进制的字节码。我们没法直接读取字节码,但利⽤内置的 dis 模块 ,可以将它们反汇编成⼈类可读的内容——类似⼀⾏⾏的汇编代码

dis 的全称是 disassembler for Python bytecode,翻译过来就是 Python 字节码的反汇编器。

先举⼀个简单的例⼦。⽐如,⼀个简单的加法函数的反汇编结果是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def add(x,y):
    return x+y

# 导⼊ dis 模块,使⽤它打印 add() 函数的字节码,也就是解释器如何理解 add() 函数
import dis
dis.dis(add)
2           0 LOAD_FAST                     0 (x)
            2 LOAD_FAST                     1 (y)
            4 BINARY_ADD
            6 RETURN_VALUE

在上⾯的输出中,add() 函数的反汇编结果主要展⽰了下⾯⼏种操作。 (1) 两次 LOAD_FAST:分别把局部变量 x 和 y 的值放⼊栈顶。 (2) BINARY_ADD:从栈顶取出两个值(也就是 x 和 y 的值),执⾏加法操作,将结果放回 栈顶。 (3) RETURN_VALUE:返回栈顶的结果。

注意到 2 LOAD_CONST 1 (950400) 那⼀⾏了吗?这表⽰ Python 解释器在将源码编译 成字节码时,会主动计算 11 * 24 * 3600 表达式的结果,并⽤ 950400 替换它。也就是说, ⽆论你调⽤ do_something 多少次,其中的算式 11 * 24 * 3600 都只会在编译期被执⾏ 1 次。

因此,当我们需要⽤到复杂计算的数字字⾯量时,请保留整个算式吧。这样做对性能没有任何影 响,⽽且会让代码更容易阅读。

使用特殊数字:无穷大

答案是“有的”,它们就是 float(“inf”) 和 float("-inf")。这两个值分别对应数学世界 ⾥的正负⽆穷⼤。当它们和任意数值做⽐较时,满⾜这样的规律:float("-inf") < 任意数值 < float(“inf”)。

⽐如有⼀个包含⽤⼾名和年龄的字典,我需要把⾥⾯的⽤⼾名按照年龄升序排序,没有提供年龄 的放在最后。使⽤ float(‘inf’),代码可以这么写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def sort_users_inf(users):

    def key_func(username):
        age = users[username]
        # 当年龄为空时,返回正无穷大作为key,因此就会被排到最后
        return age if age is not None else float('inf')
    
    return sorted(users.keys(),key=key_func)

users = {"tom": 19, "jenny": 13, "jack": None, "andrew": 43}
print(sort_users_inf(users))
# 输出
['jenny', 'tom', 'andrew', 'jack']

改善超长字符串的可读性

为了保证可读性,单⾏代码的⻓度不宜太⻓。⽐如 PEP 8 规范就建议每⾏字符数不超过 79。 在现实世界⾥,⼤部分⼈遵循的单⾏最⼤字符数通常会⽐ 79 稍⼤⼀点⼉,但⼀般不会超过 119 个字符

这时,除了⽤斜杠 \ 和加号 + 将⻓字符串拆分为⼏段,还有⼀种更简单的办法,那就是拿括 号将⻓字符串包起来,之后就可以随意折⾏了

多级缩进里出现多行字符串

但假如你不想那么做,也可以⽤标准库 textwrap 来解决这个问题:

1
2
3
4
5
6
7
8
9
from textwrap import dedent

def main():
    if user.is_active:
        message = dedent("""\
            Welcome,today's move list:
            - Jaw (1975)
            - The Shining (1980)
            - Saw (2004)""")

dedent ⽅法会删除整段字符串左侧的空⽩缩进。使⽤它来处理多⾏字符串以后,整段代码的缩 进视觉效果就能保持正常了。

别忘了以r开头的字符串内置方法

但除了这些“正序”⽅法,字符串其实还有⼀些执⾏从右往左处理的“逆序”⽅法。这些⽅法都以 字符 r 开头,⽐如 rsplit() 就是 split() 的镜像“逆序”⽅法。在处理某些特定任务时,使 ⽤“逆序”⽅法也许能事半功倍

举个例⼦,假设我需要解析⼀些访问⽇志,⽇志格式为 ‘"{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),我们需要这么 写:

1
2
3
4
5
l = log_line.split()

# 因为UserqAgent里面有空格,所以切完后得把它们再连接起来
>>> " ".join(l[:-1]),l[-1]
('"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632')

但假如利⽤ .rsplit(),处理逻辑就可以变得更直接:

1
2
3
# 从右往左切割,None 表⽰以所有的空⽩字符串切割
>>> log_line.rsplit(None,maxsplit=1)
['"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632']

不要害怕字符串拼接

Python 有⼀个内置模块 timeit,利⽤它,我们可以⾮常⽅便地测试代码的执⾏效率。⾸ 先,定义需要测试的两个函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 定义⼀个⻓度为 100 的词汇列表
WORDS = ['Hello', 'string', 'performance', 'test'] * 25

def str_cat():
    """使用字符串拼接"""
    s = ''
    for word in WORDS:
        s += word
    return s

def str_join():
    """使用列表配合join产生字符串"""
    l = []
    for word in WORDS:
        l.append(word)
    return ''.join(l)

然后,导⼊ timeit 模块,定义性能测试:

1
2
3
4
5
6
7
8
import timeit

# 默认执行100万次
cat_spent = timeit.timeit(setup='from __main__ import str_cat',stmt='str_cat()')
pirnt("cat_spent:",cat_spent)

join_spent = timeit.timeit(setup='from __main__ import str_join',stmt='str_join()')
print("join_spent",join_spent)

cat_spent: 7.844882188 join_spent 7.310863505

如今,使⽤ += 拼接字符串基本已经和 “".join(str_list) ⼀样快了。所以,该拼接时就 拼接吧,少量的字符串拼接根本不会带来任何性能问题,反⽽会让代码更直观。

总结

  1. 数值基础知识
  • Python 的浮点数有精度问题,请使⽤ Decimal 对象做精确的⼩数运算
  • 布尔类型是整型的⼦类型,布尔值可以当作 0 和 1 来使⽤
  • 使⽤ float(‘inf’) ⽆穷⼤可以简化边界处理逻辑
  1. 字符串基础知识
  • 字符串分为两类:str(给⼈阅读的⽂本类型)和 bytes(给计算机阅读的⼆进制类型)
  • 通过 .encode() 与 .decode() 可以在两种字符串之间做转换
  • 优先推荐的字符串格式化⽅式(从前往后):f-string、str.format()、C 语⾔⻛格格式 化
  • 使⽤以 r 开头的字符串内置⽅法可以从右往左处理字符串,特定场景下可以派上⽤场
  • 字符串拼接并不慢,不要因为性能原因害怕使⽤它
  1. 代码可读性技巧
  • 在定义数值字⾯量时,可以通过插⼊ _ 字符来提升可读性
  • 不要出现“神奇”的字⾯量,使⽤常量或者枚举类型替换它们
  • 保留数学算式表达式不会影响性能,并且可以提升可读性
  • 使⽤ textwrap.dedent() 可以让多⾏字符串更好地融⼊代码
  1. 代码可维护性技巧
  • 当操作 SQL 语句等结构化字符串时,使⽤专有模块⽐裸处理的代码更易于维护
  • 使⽤ Jinja2 模板来替代字符串拼接操作
  1. 语言内部知识
  • 使⽤ dis 模块可以查看 Python 字节码,帮助我们理解内部原理
  • 使⽤ timeit 模块可以对 Python 代码⽅便地进⾏性能测试
  • Python 语⾔进化得很快,不要轻易被旧版本的“经验”所左右

容器类型

⽐如每个类实例的所有属性,就都存放在⼀个名为 dict 的字典⾥

在遍历列表时获取下标

当你使⽤ for 循环遍历列表时,默认会逐个拿到列表的所有成员。假如你想在遍历的同时,获 取当前循环下标,可以选择⽤内置函数 enumerate() 包裹列表对象

列表推导式

理解列表的可变性

  • 可变(mutable):列表、字典、集合。
  • 不可变(immutable):整数、浮点数、字符串、字节串、元组。

值传递(pass-by-value):调⽤函数时,传过去的是变量所指向对象(值)的拷⻉,因 此对函数内变量的任何修改,都不会影响原始变量——对应 orig_obj 是字符串时的⾏为。 引⽤传递(pass-by-reference):调⽤函数时,传过去的是变量⾃⾝的引⽤(内存地 址),因此,修改函数内的变量会直接影响原始变量——对应 orig_obj 是列表时的⾏为。

看了上⾯的解释,你也许会发出灵魂拷问:为什么 Python 的函数调⽤要同时使⽤两套不同的 机制,把事情搞得这么复杂呢?

答案其实没有你想得那么“复杂”——Python 在进⾏函数调⽤传参时,采⽤的既不是值传递,也不 是引⽤传递,⽽是传递了“变量所指对象的引⽤”(pass-by-object-reference)。

换个⻆度说,当你调⽤ func(orig_obj) 后,Python 只是新建了⼀个函数内部变量 in_func_obj,然后让它和外部变量 orig_obj 指向同⼀个对象,相当于做了⼀次变量赋值 alt text

⼀次函数调⽤基本等于执⾏了 in_func_obj = orig_obj。 所以,当我们在函数内部执⾏ in_func_obj += … 等修改操作时,是否会影响外部变量, 只取决于 in_func_obj 所指向的对象本⾝是否可变

在对字符串进⾏ += 操作时,因为字符串是不可变类型,所以程序会⽣成⼀个新对象 (值):‘foo suffix’,并让 in_func_obj 变量指向这个新对象;旧值(原始变量 orig_obj 指向的对象)则不受任何影响,如图 3-2 右侧所⽰ alt text

但如果对象是可变的(⽐如列表),+= 操作就会直接原地修改 in_func_obj 变量所指向的 值,⽽它同时也是原始变量 orig_obj 所指向的内容;待修改完成后,两个变量所指向的值(同⼀ 个)肯定就都受到了影响。如图 3-3 所⽰,右边的列表在操作后直接多了⼀个成员:‘bar’ alt text

由此可⻅,Python 的函数调⽤不能简单归类为“值传递”或者“引⽤传递”,⼀切⾏为取决于对象 的可变性

返回多个结果,其实就是返回元组

1
2
3
4
5
6
7
8
9
def get_rectangle():
    """返回长方形的宽和高"""
    width = 100
    height = 20
    return width,height

# 获取函数的多个返回值
result = get_rectangle()
print(result,type(result))

将函数返回值⼀次赋值给多个变量时,其实就是对元组做了⼀次解包操作:

1
2
3
results = (n*100 for n in range(10) if n%2 == 0)
results
>>>  <generator object <genexpr> at 0x10e94e2e0>

很遗憾,上⾯的表达式并没有⽣成元组,⽽是返回了⼀个⽣成器(generator)对象。因此它 是⽣成器推导式,⽽⾮元组推导式。 不过幸运的是,虽然⽆法通过推导式直接拿到元组,但⽣成器仍然是⼀种可迭代类型,所以我们 还是可以对它调⽤ tuple() 函数,获得元组

1
2
3
result = tuple((n*100 for n in range(10) if n%2==0))
results
(0,200,400,600,800)

具名元组

具名元组在保留普通元组功能的基础上,允许为元组的每个成员命名,这样你便能通过名称⽽不⽌是数字索引访问 成员

创建具名元组需要⽤到 namedtuple() 函数,它位于标准库的 collections 模块⾥,使⽤ 前需要先导⼊:

1
2
3
from collections import namedtuple

Rectangle = namedtuple('Rectangle','width,height')

除了⽤逗号来分隔具名元组的字段名称以外,还可以⽤空格分隔:‘width height’,或是 直接使⽤⼀个字符串列表:[‘width’, ‘height’]

1
2
3
4
5
6
7
8
9
>>> rect = Rectangle(100, 20) 
>>> rect = Rectangle(width=100, height=20)
>>> print(rect[0]) 
100
>>> print(rect.width) 
100
>>> rect.width += 1 
...
AttributeError: can't set attribute

在 Python 3.6 版本以后,除了使⽤ namedtuple() 函数以外,你还可以⽤ typing.NamedTuple 和类型注解语法来定义具名元组类型。这种⽅式在可读性上更胜⼀筹:

1
2
3
4
5
class Rectangle(NamedTuple):
    width:int
    height:int

rect = Rectangle(100,20)

但需要注意的是,上⾯的写法虽然给 width 和 height 加了类型注解,但 Python 在执⾏ 时并不会做真正的类型校验。也就是说,下⾯这段代码也能正常执⾏:

1
2
# 提供错误的类型来初始化
rect_wrong_type = Rectangle('string', 'not_a_number')

遍历字典

当我们直接遍历⼀个字典对象时,会逐个拿到字典所有的 key。如果你想在遍历字典时同时获 取 key 和 value,需要使⽤字典的 .items() ⽅法:

访问不存在的字典键

当⽤不存在的键访问字典内容时,程序会抛出 KeyError 异常,我们通常称之为程序⾥的边界 情况(edge case)。针对这种边界情况,⽐较常⻅的处理⽅式有两种:

  • 直接操作,但是捕获 KeyError 异常。
  • 读取内容前先做⼀次条件判断,只有判断通过的情况下才继续执⾏其他操作;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 第一种写法
if 'rating' in movie:
    rating = movie['rating']
else:
    rating = 0

# 第二种写法
try:
    rating = movie['rating']
except KeyError:
    rating = 0

在 Python 中,⼈们⽐较推崇第⼆种写法,因为它看起来更简洁,执⾏效率也更⾼。不过,如 果只是“提供默认值的读取操作”,其实可以直接使⽤字典的 .get() ⽅法。

dict.get(key, default) ⽅法接收⼀个 default 参数,当访问的键不存在时,⽅法会 返回 default 作为默认值:

1
movie.get('rating',0)

使用setdefault取值并修改

有时,我们需要修改字典中某个可能不存在的键,⽐如在下⾯的代码⾥,我需要往字典 d 的 items 键⾥追加新值,但 d[‘items’] 可能根本就不存在。因此我写了⼀段异常捕获逻辑—— 假如 d[‘items’] 不存在,就以列表来初始化它

针对上⾯这种情况,其实有⼀个更适合的⼯具:d.setdefault(key, default=None) ⽅ 法。使⽤它,可以直接删掉上⾯的异常捕获,代码逻辑会变得更简单。

视条件的不同,调⽤ dict.setdefault(key, default) 会产⽣两种结果:当 key 不存 在时,该⽅法会把 default 值写⼊字典的 key 位置,并返回该值;假如 key 已经存在, 该⽅法就会直接返回它在字典中的对应值。代码如下:

1
2
3
4
5
6
7
d = {'title':'foobar'}
d.setdefault('items',[]).append('foo')
d
{'title': 'foobar', 'items': ['foo']}
d.setdefault('items',[]).append('bar')
d
{'title': 'foobar', 'items': ['foo', 'bar']}

使⽤ pop ⽅法删除不存在的键

但假设你只是单纯地想去掉某个键,并不关⼼它存在与否、删除有没有成功,那么使⽤ dict.pop(key, default) ⽅法就够了。

只要在调⽤ pop ⽅法时传⼊默认值 None,在键不存在的情况下也不会产⽣任何异常

d.pop(key,None)

严格说来,pop ⽅法的主要⽤途并不是删除某个键,⽽是取出这个键对应的值。但 我个⼈觉得,偶尔⽤它来执⾏删除操作也⽆伤⼤雅。

字典推导式

和列表类似,字典同样有⾃⼰的字典推导式。(⽐元组待遇好多啦!)你可以⽤它来⽅便地过滤 和处理字典成员:

1
2
3
d1 = {'foo':3,'bar':4}
{key:value * 10 for key,value in d1.items() if key == 'foo'}
>>>{'foo',30}

但 Python 语⾔在不断进化。Python 3.6 为字典类型引⼊了⼀个改进:优化了底层实现,同 样的字典相⽐ 3.5 版本可节约多达 25% 的内存。⽽这个改进同时带来了⼀个有趣的副作⽤:字典 变得有序了。

但如果你使⽤的 Python 版本没有那么新,也可以从 collections 模块⾥⽅便地拿到另⼀个 有序字典对象 OrderedDict,它可以在 Python 3.7 以前的版本⾥保证字典有序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from collections import OrderedDict
d = OrderedDict()
d['FIRST_KEY'] = 1
D['SECOND_KEY'] = 2

for key in d:
    print(key)

FIRST_KEY
SECOND_KEY

但在我看来,OrderedDict ⽐起普通字典仍然有⼀些优势。最直接的⼀点是,OrderedDict 把“有序”放在了⾃⼰的名字⾥,因此当你在代码中使⽤它时,其实⽐普通字典更清晰地表达了“此处 会依赖字典的有序特性”这⼀点。

内容⼀致⽽顺序不同的字典被视作相等,因为解释器只对⽐字典的键和值是否⼀致

同样的 OrderedDict 则被视作不相等,因为“键的顺序”也会作为对⽐条件

除此之外,OrderedDict 还有 .move_to_end() 等普通字典没有的⼀些⽅法。所以,即便 Python 3.7 及之后的版本已经提供了内置的“有序字典”,但 OrderedDict 仍然有着⾃⼰的⼀席 之地。

集合常用操作

集合也有⾃⼰的推导式语法:

1
2
3
nums = [1,2,2,4,1]
{n for n in nums if n<3}
{1,2}

集合是⼀种可变类型,使⽤ .add() ⽅法可以向集合追加新成员:

假如你想要⼀个不可变的集合,可使⽤内置类型 frozenset,它和普通 set ⾮常像,只是少 了所有的修改类⽅法:

集合运算

对两个集合求交集,也就是获取两个集合中同时存在的东西:& intersection

对集合求并集,把两个集合⾥的东西合起来:| union

对集合求差集,获得前⼀个集合有、后⼀个集合没有的东西: - difference

正如上⾯的报错信息所⽰,集合⾥只能存放“可哈希”(hashable)的对象。假如把不可哈希的 对象(⽐如上⾯的列表)放⼊集合,程序就会抛出 TypeError 异常

了解对象的可哈希性

在介绍字典类型时,我们说过字典底层使⽤了哈希表数据结构,其实集合也⼀样。当我们把某个 对象放进集合或者作为字典的键使⽤时,解释器都需要对该对象进⾏⼀次哈希运算,得到哈希值,然 后再进⾏后⾯的操作。

这个计算哈希值的过程,是通过调⽤内置函数 hash(obj) 完成的。如果对象是可哈希的, hash 函数会返回⼀个整型结果,否则将会报 TypeError 错误。

因此,要把某个对象放进集合,那它就必须是“可哈希”的。话说到这⾥,到底哪些类型是可哈希 的?哪些⼜是不可哈希的呢?我们来试试看

⾸先,那些不可变的内置类型都是可哈希的:

⽽可变的内置类型都⽆法正常计算哈希值:

可变类型的不可哈希特点有⼀定的“传染性”。⽐如在⼀个原本可哈希的元组⾥放⼊可变的列表对 象后,它也会⻢上变得不可哈希

1
2
hash((1,2,3,['foo','bar']))
TypeError: unhashable type: 'list'

由⽤⼾定义的所有对象默认都是可哈希的:

1
2
3
4
5
6
class Foo:
    pass

foo = Foo()
has(foo)
>>>  273594269

总结⼀下,某种类型是否可哈希遵循下⾯的规则:

  1. 所有的不可变内置类型,都是可哈希的,⽐如 str、int、tuple、frozenset 等;
  2. 所有的可变内置类型,都是不可哈希的,⽐如 dict、list 等;
  3. 对于不可变容器类型 (tuple, frozenset),仅当它的所有成员都不可变时,它⾃⾝才 是可哈希的
  4. ⽤⼾定义的类型默认都是可哈希的。

谨记,只有可哈希的对象,才能放进集合或作为字典的键使⽤

深拷贝与浅拷贝

在操作这些可变对象时,如果不拷⻉原始对象就修改,可能会产⽣我们并不期待的结果

假如我们想让两个变量的修改操作互不影响,就需要拷⻉变量所指向的可变对象,做到让不同变 量指向不同对象。按拷⻉的深度,常⽤的拷⻉操作可分为两种:浅拷⻉与深拷⻉

浅拷贝

要进⾏浅拷⻉,最通⽤的办法是使⽤ copy 模块下的 copy() ⽅法:

1
2
3
4
5
6
7
import copy
nums_copy = copy.copy(nums)
nums[2] = 30

# 修改不再相互影响
>>> nums, nums_copy
([1, 2, 30, 4], [1, 2, 3, 4])

除了使⽤ copy() 函数外,对于那些⽀持推导式的类型,⽤推导式也可以产⽣⼀个浅拷⻉对 象:

1
2
3
4
5
d = {'foo':1}
d2 = {key:value for key,value in d.items()}
d['foo'] = 2
d,d2
({'foo': 2}, {'foo': 1})

使⽤各容器类型的内置构造函数,同样能实现浅拷⻉效果:

1
2
d2 = dict(d.items())
nums_copy = list(nums)

以字典 d 的内容构建⼀个新字典 以列表 nums 的成员构建⼀个新列表

对于⽀持切⽚操作的容器类型——⽐如列表、元组,对其进⾏全切⽚也可以实现浅拷⻉效果:

1
2
#  nums_copy 会变成 nums 的浅拷⻉
nums_copy = nums[:]

除了上⾯这些办法,有些类型⾃⾝就提供了浅拷⻉⽅法,可以直接使⽤:

1
2
3
4
5
6
7
8
9
# 列表有copy方法
num = [1,2,3,4]
nums.copy()
[1,2,3,4]

# 字典也有copy方法
d = {'foo':'bar'}
d.copy()
{'foo':'bar'}

深拷贝

⼤部分情况下,上⾯的浅拷⻉操作⾜以满⾜我们对可变类型的复制需求。但对于⼀些层层嵌套的 复杂数据来说,浅拷⻉仍然⽆法解决嵌套对象被修改的问题。

⽐如,下⾯的 items 是⼀个嵌套了⼦列表的多级列表:

items = [1, [‘foo’, ‘bar’], 2, 3]

1
2
3
4
5
6
7
8
>>> import copy
>>> items_copy = copy.copy(items)
>>> items[0] = 100 
>>> items[1].append('xxx') 
>>> items
[100, ['foo', 'bar', 'xxx'], 2, 3]
>>> items_copy 
[1, ['foo', 'bar', 'xxx'], 2, 3]

❶ 修改 items 的第⼀层成员 ❷ 修改 items 的第⼆层成员,往⼦列表内追加元素 ❸ 对 items[1] 的第⼀层修改没有影响浅拷⻉对象,items_copy[0] 仍然是 1,但 对嵌套⼦列表 items[1] 的修改已经影响了 items_copy[1] 的值,列表内多出了 ‘xxx’

之所以会出现这样的结果,是因为即便对 items 做了浅拷⻉,items[1] 和 items_copy[1] 指向的仍旧是同⼀个列表。如果使⽤ id() 函数查看它们的对象 ID,会发 现它们其实是同⼀个对象:

要解决这个问题,可以⽤ copy.deepcopy() 函数来进⾏深拷⻉操作:

items_deep = copy.deepcopy(items)

深拷⻉会遍历并拷⻉ items ⾥的所有内容——包括它所嵌套的⼦列表。做完深拷⻉后,items 和 items_deep 的⼦列表不再是同⼀个对象,它们的修改操作⾃然也不会再相互影响:

要优化性能,第⼀步永远是找到性能瓶颈。刚好,我把⽹站所有⻚⾯的访问耗时都记录在了⼀个 访问⽇志⾥。因此,我准备先分析访问⽇志,看看究竟是哪些⻚⾯在“拖后腿”

访问⽇志⽂件格式如下: alt text

⽇志⾥记录了每次请求的路径与耗时。基于这些⽇志,我决定先写⼀个访问分析脚本,把请求数 据按路径分组,然后再依据耗时将其划为不同的性能等级,从⽽找到迫切需要优化的⻚⾯。

基于我的设计,响应时间被分为四个性能等级。 (1) ⾮常快:⼩于 100 毫秒。 (2) 较快:100 到 300 毫秒之间。 (3) 较慢:300 毫秒到 1 秒之间。 (4) 慢:⼤于 1 秒

alt text

代码清单 3-1⽇志分析脚本 analyzer_v1.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from enum import Enum

class PagePerfLevel(str,Enum):
    LT_100 = 'Less than 100ms'
    LT_300 = 'Between 100 and 300 ms'
    LT_1000 = 'Between 300 ms and 1s'
    GT_1000 = 'Greater than 1s'

def analyze_v1()
    path_groups = {}
    with open("logs.txt","r") as fp:
        for line in fp:
            path,time_costs_str = line.strip().split()
        
        # 根据页面耗时计算性能等级
        time_cost = int(time_cost_str)
        if time_cost < 100:
            level = PagePerfLevel.LT_100
        elif time_cost < 300:
            level = PagePerfLevel.LT_300
        elif time_cost < 1000:
            level = PagePerfLevel.LT_1000
        else:
            level = PagePerfLevel.GT_1000

        # 如果路径第一次出现,存入初始值
        if path not in path_groups:
            path_groups[path] = {}
        
        # 如果性能level第一次出现,存入初始值1
        try:
            path_groups[path][level] += 1
        except KeyError:
            path_groups[path][level] = 1

    for path,result in path_groups.iems():
        print(f'== Path:{path}')
        total = sum(result.values())
        print(f'    Total requests:{total}')
        print(f'    Performance')

        # 在输出结果前,按照“性能等级”在 PagePerfLevel ⾥⾯的顺序排列,⼩于 100 毫秒的在最前⾯
        sorted_items = sorted(
            result.items(),key=lambda pair:list(PagePerfLevel).index(pair[0])
        )
        for level_name,count in sorted_items:
            print(f'    - {level_name}:{count}')

在上⾯的代码⾥,我⾸先在最外层定义了枚举类型 PagePerfLevel,⽤于表⽰不同的请求性 能等级,随后在 analyze_v1() 内实现了所有的主逻辑。其中的关键步骤有: (1) 遍历整个⽇志⽂件,逐⾏解析请求路径(path)与耗时(time_cost); (2) 根据耗时计算请求属于哪个性能等级; (3) 判断请求路径是否初次出现,如果是,以⼦字典初始化 path_groups ⾥的对应值; (4) 对⼦字典的对应性能等级 key,执⾏请求数加 1 操作。 经以上步骤完成数据统计后,在输出每组路径的结果时,函数不能直接遍历 result.items(),⽽是要先参照 PagePerfLevel 枚举类按性能等级排好序,然后再输出。 在线上测试试⽤这个脚本后,我发现它可以正常分析请求、输出性能分组信息,达到了我的预 期。 不过,虽然脚本功能正常,但我总觉得它的代码写得不太好。⼀个最直观的感受是: analyze_v1() 函数⾥的逻辑特别复杂,耗时转级别、请求数累加的逻辑,全都被糅在了⼀块,整 个函数读起来很困难。 另⼀个问题是,代码⾥分布着太多零碎的字典操作,⽐如 if path not in path_groups、 try: … except KeyError:,等等,看上去⾮常不利落。 于是我决定花点⼉时间重构⼀下这份脚本,解决上述两个问题。

defaultdict(default_factory, …) 是⼀种特殊的字典类型。它在被初始化时,接收 ⼀个可调⽤对象 default_factory 作为参数。之后每次进⾏ d[key] 操作时,如果访问 的 key 不存在,defaultdict 对象会⾃动调⽤ default_factory() 并将结果作为值保 存在对应的 key ⾥。

在 Python 中定义⼀个字典类型,可通过继承 MutableMapping 抽象类来实现,

使用MutableMapping创建自定义字典类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from collections.abc import MutableMapping

class PerfLevelDict(MutableMapping):
    """⽤于存储响应时间的⾃定义字典"""

    def __init__(self):
        self.data = defaultdict(int)

    def __getitem__(self,key):
        """当某个级别不存在时,默认返回0"""
        return self.data[self.compute_level(key)]

    def __setitem__(self,key,value):
        """将key转换为对应的性能等级,然后设置值"""
        self.data[self.compute_level(key)] = value

    def __delitem__(self,key):
        del self.data[key]

    def __iter__(self):
        return iter(self.data)

    def __len__(self):
        return len(self.data)
    
    @staticmethod
    def compute_level(timg_cost_str):
        """根据响应时间计算性能等级"""
        #  假如已经时性能等级,不做转换直接返回
        if time_cost_str in list(PagePerfLevel):
            return time_cost_str

        time_cost = int(time_cost_str):
        if time_cost < 100:
            return PagePerfLevel.LT_100
        elif time_cost < 300:
            return  PagePerfLevel.LT_300
        elif time_cost < 1000:
            return PagePerfLevel.LT_1000

        return PagePerfLevel.GT_1000

在上⾯的代码中,我编写了⼀个继承了 MutableMapping 的字典类 PerfLevelDict。但 光继承还不够,要让这个类变得像字典⼀样,还需要重写包括 getitem____setitem 在内的 6 个魔法⽅法。

我们来试⽤⼀下 PerfLevelDict 类

有了 PerfLevelDict 类以后,我们不需要再去⼿动做“耗时→级别”转换了,⼀切都可以由 ⾃定义字典的内部逻辑处理好。 创建⾃定义字典类还带来了⼀个额外的好处。在之前的代码⾥,有许多有关字典的零碎操作, ⽐如求和、对 .items() 排序等,现在它们全都可以封装到 PerfLevelDict 类⾥,代码 逻辑不再是东⼀块、西⼀块,⽽是全部由⼀个数据类搞定

代码重构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
from enum import Enum
from collections import defaultdict
from collections.abc import MutableMapping

class PagePerfLevel(str,Enum):
    LT_100 = 'Less than 100 ms'
    LT_300 = 'Between 100 and 300 ms'
    LT_1000 = 'Between 300 ms and 1 s'
    GT_1000 = 'Greater than 1 s'

class PerfLevelDict(MutableMapping):
    """存储响应时间性能等级的字典"""
    def __init__(self):
        self.data = defaultdict(int)

    def __getitem__(self,key):
        """当某个性能等级不存在时,默认返回 0"""
        return self.data[self.compute_level(key)]

    def __delitem__(self,key):
        del self.data[key]

    def __iter__(self):
        return iter(self.data)

    def __len__(self):
        return len(self.data)

    def items(self):
        """按照顺序返回性能等级数据"""
        return sorted(
            self.data.items(),
            key = lambda pair:list(PagePerfLevel).index(pair[0]),
        )

    def total_request(self):
        """返回总请求数"""
        return sum(self.values())

    @staticmethod
    def compute_level(time_cost_str):
        """根据响应时间计算性能等级"""
        if time_cost_str in list(PagePerfLevel):
            return time_cost_str

        time_cost = int(time_cost_str)
        if time_cost < 100:
            return PagePerfLevel.LT_100
        elif time_cost < 300:
            return PagePerfLevel.LT_300
        elif time_cost < 1000:
            return PagePerfLevel.LT_1000
        return PagePerfLevel.GT_1000

def analyze_v2():
    path_groups = defaultdict(PerfLevelDict)
    with open("log.txt","r") as fp:
        for line in fp:
            path,time_cost = line.strip().split()
            path_groups[path][time_cost] += 1

    for path,result in path_groups.items():
        print(f'== Path: {path}')
        print(f'   Total requests: {result.total_requests()}')
        print(f'   Performance:')
        for level_name,count in result.items():
            print(f'    - {level_name}:{count}')

在实现⾃定义字典时,我让 PerfLevelDict 继承了 collections.abc 下的MutableMapping 抽象类,⽽不是内置字典 dict。这看起来有点⼉奇怪,因为从直觉 上说,假如你想实现某个⾃定义类型,最⽅便的选择就是继承原类型。但是,如果真的继承 dict 来创建⾃定义字典类型,你会碰到很多问题。 拿⼀个最常⻅的场景来说,假如你继承了 dict,通过 setitem ⽅法重写了它的键赋值操作。此时,虽然常规的 d[key] = value ⾏为会被重写;但假如调⽤⽅使 ⽤ d.update(…) 来更新字典内容,就根本不会触发重写后的键赋值逻辑。这最终会导致⾃定义类型的⾏为不⼀致。

用按需返回替代容器

Python 3,调⽤ range(100000000) 瞬间就会返回结果。因为它不再返回列表,⽽ 是返回⼀个类型为 range 的惰性计算对象。

当序列过⼤时,新的 range() 函数不再会⼀次性耗费⼤量内存和时间,⽣成⼀个巨⼤的列 表,⽽是仅在被迭代时按需返回数字。range() 的进化过程虽然简单,但它其实代表了⼀种重要的 编程思维——按需⽣成,⽽不是⼀次性返回。

生成器

⽣成器(generator)是 Python ⾥的⼀种特殊的数据类型。顾名思义,它是⼀个不断给调 ⽤⽅“⽣成”内容的类型。定义⼀个⽣成器,需要⽤到⽣成器函数与 yield 关键字。

1
2
3
4
5
6
7
8
def generate_even(max_number):
    """一个简单生成器,返回 0 到 max_number 之间的所有偶数"""
    for i in range(0,max_number):
        if i % 2 == 0:
            yield i 
    
for i in generate_even(10):
    print(i)

虽然都是返回结果,但 yield 和 return 的最⼤不同之处在于,return 的返回是⼀次性 的,使⽤它会直接中断整个函数执⾏,⽽ yield 可以逐步给调⽤⽅⽣成结果

1
2
3
4
5
i = generate_even(10)
next(i)
>> 0 
next(i)
>> 2

调⽤ next() 可以逐步从⽣成器对象⾥拿到结果 因为⽣成器是可迭代对象,所以你可以使⽤ list() 等函数⽅便地把它转换为各种其他容器 类型:

1
2
list(generate_even(10))
[0,2,4,6,8]

用生成器替代列表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def batch_process(items):
    """
    批量处理多个items对象
    """
    # 初始化空结果列表
    results = []
    for item in items:
        # 处理 item,可能需要耗费⼤量时间……
        # processed_item = ...
        results.append(processed_item)
    # 将拼装后的结果列表返回
    return results

这样的函数遵循同⼀种模式:初始化容器->处理->将结果存入容器->返回容器

⼀个问题是,如果需要处理的对象 items 过⼤,batch_process() 函数就会像 Python 2 ⾥的 range() 函数⼀样,每次执⾏都特别慢,存放结果的对象 results 也会占⽤⼤量 内存。 另⼀个问题是,如果函数调⽤⽅想在某个 processed_item 对象满⾜特定条件时中断,不再 继续处理后⾯的对象,现在的 batch_process() 函数也做不到——它每次都得⼀次性处理完 所有 items 才会返回。

1
2
3
4
for processed_item in batch_process(items):
    # 如果某个已处理对象过期了,就中断当前的所有处理
    if processed_item.has_expired():
        break

避开列表的性能陷阱

可以看到,同样是构建⼀个⻓度为 5000 的列表,不断往头部插⼊的 insert ⽅式的耗时是 从尾部追加的 append ⽅式的 16 倍还多。

这个性能差距与列表的底层实现有关。Python 在实现列表时,底层使⽤了数组(array)数 据结构。这种结构最⼤的⼀个特点是,当你在数组中间插⼊新成员时,该成员之后的其他成员 都需要移动位置,该操作的平均时间复杂度是 O(n)。因此,在列表的头部插⼊成员,⽐在尾 部追加要慢得多(后者的时间复杂度为 O(1))。 如果你经常需要往列表头部插⼊数据,请考虑使⽤ collections.deque 类型来替代列表 (代码如下)。因为 deque 底层使⽤了双端队列,⽆论在头部还是尾部追加成员,时间复杂 度都是 O(1)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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)

因为列表在底层使⽤了数组结构,所以要判断某个成员是否存在,唯⼀的办法是从前往后遍历 所有成员,执⾏该操作的时间复杂度是 O(n)。如果列表内容很多,这种 in 操作耗时就会很 久。 对于这类判断成员是否存在的场景,我们有更好的选择。

使用集合判断成员是否存在

要判断某个容器是否包含特定成员,⽤集合⽐⽤列表更合适

⽽在集合⾥搜索,就像通过字典查字。我们先按照字的拼⾳从索引找到它所在的⻚码,然后直 接翻到那⼀⻚。完成这种操作需要的时间复杂度是 O(1)。 在集合⾥搜索之所以这么快,是因为其底层使⽤了哈希表数据结构。要判断集合中是否存在某 个对象 obj,Python 只需先⽤ hash(obj) 算出它的哈希值,然后直接去哈希表对应位置 检查 obj 是否存在即可,根本不需要关⼼哈希表的其他部分,⼀步到位

除了集合,对字典进⾏ key in … 查询同样⾮常快,因为⼆者都是基于哈希表结构实现 的。

要实现合并功能,需要⽤到双星号 ** 运算符来做解包操作。在字典中使⽤ **dict_obj 表 达式,可以动态解包 dict_obj 字典的所有内容,并与当前字典合并:

因为解包过程会默认进⾏浅拷⻉操作,所以我们可以⽤它⽅便地合并两个字典

除了使⽤ ** 解包字典,你还可以使⽤单星号 * 运算符来解包任何可迭代对象:

字典的I运算符

字典类型新增了对 | 运算符的⽀持。只要执⾏ d1 | d2,就能快速拿到两个字典合并后的结果:

使用有序字典去重

前⾯提到过,集合⾥的成员不会重复,因此它经常⽤来去重。但是,使⽤集合去重有⼀个很⼤的 缺点:得到的结果会丢失集合内成员原有的顺序:

这种⽆序性是由集合所使⽤的哈希表结构所决定的,⽆法避免。如果你既需要去重,⼜想要保留 原有顺序,怎么办?可以使⽤前⽂提到过的有序字典 OrderedDict 来完成这件事。因为 OrderedDict 同时满⾜两个条件: (1) 它的键是有序的; (2) 它的键绝对不会重复。 因此,只要根据列表构建⼀个字典,字典的所有键就是有序去重的结果

1
2
3
from collections import OrderedDict
list(OrderedDict.fromkeys(nums).keys())
>>> [10,2,3,21]

调⽤ fromkeys ⽅法会创建⼀个有序字典对象。字典的键来⾃⽅法的第⼀个参数:可迭代 对象(此处为 nums 列表),字典的值默认为 None

别在遍历列表是同步修改

1
2
3
4
5
6
7
def remove_even(numbers):
    """去掉列表里所有的偶数"""
    for number in numbers:
        if number %  2 == 0:
            numbers.remove(number)

>>> [1,7,8,11]

注意到那个本不该出现的数字 8 了吗?遍历列表的同时修改列表就会发⽣这样的怪事。 之所以会出现这样的结果,是因为:在遍历过程中,循环所使⽤的索引值不断增加,⽽被遍历对 象 numbers ⾥的成员⼜同时在被删除,⻓度不断缩短——这最终导致列表⾥的⼀些成员其实根本就 没被遍历到。 因此,要修改列表,请不要在遍历时直接修改。只需选择启⽤⼀个新列表来保存修改后的成员, 就不会碰到这种奇怪的问题。

别写太复杂的推导式

别把推导式当作代码量更少的循环

但这样做其实并不合适。推导式的核⼼意义在于它会返回值——⼀个全新构建的列表,如果你不 需要这个新列表,就失去了使⽤表达式的意义。

让函数返回NamedTuple

对于这种未来可能会变动的多返回值函数来说,如果⼀开始就使⽤ NamedTuple 类型对返回结 果进⾏建模,上⾯的改动会变得简单许多:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from typing import NamedTuple

class Address(NamedTuple):
    """地址信息结果"""
    country:str
    province:str
    city:str

def latlon_to_address(lat,lon):
    return Address(
        country=country,
        province=province,
        city=city,
    )

addr = latlon_to_address(lat,lon)
# 通过属性名来使用addr
addr.counrty/addr.province/addr.city

假如我们在 Address ⾥增加了新的返回值 district,已有的函数调⽤代码也不⽤进⾏任何 适配性修改,因为函数结果只是多了⼀个新属性,没有任何破坏性影响

总结

  1. 基础知识
  • 在进⾏函数调⽤时,传递的不是变量的值或者引⽤,⽽是变量所指对象的引⽤
  • Python 内置类型分为可变与不可变两种,可变性会影响⼀些操作的⾏为,⽐如 +=
  • 对于可变类型,必要时对其进⾏拷⻉操作,能避免产⽣意料之外的影响
  • 常⻅的浅拷⻉⽅式:copy.copy、推导式、切⽚操作
  • 使⽤ copy.deepcopy 可以进⾏深拷⻉操作
  1. 列表与元组
  • 使⽤ enumerate 可以在遍历列表的同时获取下标
  • 函数的多返回值其实是⼀个元组
  • 不存在元组推导式,但可以使⽤ tuple 来将⽣成器表达式转换为元组
  • 元组经常⽤来表⽰⼀些结构化的数据
  1. 字典与集合
  • 在 Python 3.7 版本前,字典类型是⽆序的,之后变为保留数据的插⼊顺序
  • 使⽤ OrderedDict 可以在 Python 3.7 以前的版本⾥获得有序字典
  • 只有可哈希的对象才能存⼊集合,或者作为字典的键使⽤
  • 使⽤有序字典 OrderedDict 可以快速实现有序去重
  • 使⽤ fronzenset 可以获得⼀个不可变的集合对象
  • 集合可以⽅便地进⾏集合运算,计算交集、并集
  • 不要通过继承 dict 来创建⾃定义字典类型\
  1. 代码可读性技巧
  • 具名元组⽐普通元组可读性更强
  • 列表推导式可以更快速地完成遍历、过滤、处理以及构建新列表操作
  • 不要编写过于复杂的推导式,⽤朴实的代码替代就好
  • 不要把推导式当作代码量更少的循环,写普通循环就好
  1. 代码可维护性技巧
  • 当访问的字典键不存在时,可以选择捕获异常或先做判断,优先推荐捕获异常
  • 使⽤ get、setdefault、带参数的 pop ⽅法可以简化边界处理逻辑
  • 使⽤具名元组作为返回值,⽐普通元组更好扩展
  • 当字典键不存在时,使⽤ defaultdict 可以简化处理
  • 继承 MutableMapping 可以⽅便地创建⾃定义字典类,封装处理逻辑
  • ⽤⽣成器按需返回成员,⽐直接返回⼀个结果列表更灵活,也更省内存
  • 使⽤动态解包语法可以⽅便地合并字典
  • 不要在遍历列表的同时修改,否则会出现不可预期的结果
  1. 代码性能要点
  • 列表的底层实现决定了它的头部操作很慢,deque 类型则没有这个问题
  • 当需要判断某个成员在容器中是否存在时,使⽤字典 / 集合更快

条件分支控制流

当我们编写分⽀时,第⼀件要注意的事情,就是不要显式地和布尔值做⽐较

绝⼤多数情况下,在分⽀判断语句⾥写 == True 都没有必要,删掉它代码会更短也更易读。 但这条原则也有例外,⽐如你确实想让分⽀仅当值是 True 时才执⾏。不过即便这样,写 if == True 仍然是有问题的,我会在 4.1.3 节解释这⼀点。

省略0值判断

这种判断语句其实可以变得更简单,因为当某个对象作为主⻆出现在 if 分⽀⾥时,解释器会 主动对它进⾏“真值测试”,也就是调⽤ bool() 函数获取它的布尔值。⽽在计算布尔值时, 每类对象都有着各⾃的规则,⽐如整型和列表的规则如下:

  • 布尔值为假:None、0、False、[]、()、{}、set()、frozenset(),等等。
  • 布尔值为真:⾮ 0 的数值、True,⾮空的序列、元组、字典,⽤⼾定义的类和实例,等 等。

把否定逻辑移入表达式内

尽可能让三元表达式保持简单

修改对象的布尔值

但其实,上⾯的分⽀判断语句可以变得更简单。只要给 UserCollection 类实现 len 魔法⽅法,users 对象就可以直接⽤于“真值测试”

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class UserCollection:
    """用于保存多个用户的集合工具类"""

    def __init__(self,users):
        self.items = users

    def __len__(self):
        return len(self.items)

users = UserCollection(['piglei','raymond'])

# 不再需要手动判断对象内部items的长度
if users:
    print("There's some users in collection!")

为对象定义 bool ⽅法后,对它进⾏布尔值运算会直接返回该⽅法的调⽤结果。举个例 ⼦

1
2
3
4
5
6
7
8
class ScoreJudger:
    """仅当分数大于60时为真"""

    def __init__(self,score):
        self.score = score

    def __bool__(self):
        return self.score >= 60

假如⼀个类同时定义了 lenbool 两个⽅法,解释器会优先使⽤ bool ⽅法的执⾏结果。

与None比较时使用is运算符

1
2
3
4
5
6
7
class EqualWithAnything:
    """与任何对象相等"""
    
    def __eq__(self,other):
        # ⽅法⾥的 other ⽅法代表 == 操作时右边的对象,⽐如
        # x == y 会调⽤ x 的 __eq__ ⽅法,other 的参数为 y
        return True

上⾯定义的 EqualWithAnything 对象,在和任何东西做 == 计算时都会返回 True:

既然 == 的⾏为可被魔法⽅法改变,那我们如何严格检查某个对象是否为 None 呢?答案是使 ⽤ is 运算符。虽然⼆者看上去差不多,但有着本质上的区别: (1) == 对⽐两个对象的值是否相等,⾏为可被 eq ⽅法重载; (2) is 判断两个对象是否是内存⾥的同⼀个东西,⽆法被重载。

因此,当你想要判断某个对象是否为 None 时,应该使⽤ is 运算符:

这是因为,除了 None、True 和 False 这三个内置对象以外,其他类型的对象在 Python 中并不是严格以单例模式存在的。换句话说,即便值⼀致,它们在内存中仍然是完全不同的两个对 象

因此,仅当你需要判断某个对象是否是 None、True、False 时,使⽤ is,其他情况下,请 使⽤ ==。

令人迷惑的整型驻留技术

为什么会这样?这是因为 Python 语⾔使⽤了⼀种名为“整型驻留”(integer interning)的底层优化技术。 对于从 -5 到 256 的这些常⽤⼩整数,Python 会将它们缓存在内存⾥的⼀个数组中。 当你的程序需要⽤到这些数字时,Python 不会创建任何新的整型对象,⽽是会返回缓存中的对 象。这样能为程序节约可观的内存。

除了整型以外,Python 对字符串也有类似的“驻留”操作。如果你对这⽅⾯感兴趣,可⾃ ⾏搜索“Python integer/string interning”关键字了解更多内容

案例

现在的电影数据是字典(dict)格式的,处理起来不是很⽅便。于是,我⾸先创建了⼀个类: Movie,⽤来存放与电影数据和封装电影有关的操作。有了 Movie 类后,我在⾥⾯定义了 rank 属性对象,并在 rank 内实现了按评分计算级别的逻辑。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Movie:
    """电源对象数据类"""

    def __init__(slef,name,year,rating):
        self.name = name
        self.year = year
        self.rating = rating

    @property
    def rank(self):
        """按照评分对电影分级

        - S: 8.5 分及以上
        - A:8 ~ 8.5 分
        - B:7 ~ 8 分
        - C:6 ~ 7 分
        - D:6 分以下
        """
        rating_num = floay(self.rating)
        if rating_num >= 8.5:
            return 'S'
        elif rating_num >= 8:
            return 'A'
        elif rating_num >= 7:
            return 'B'
        elif rating_num >= 6:
            return 'C'
        else:
            return 'D'

对电影列表排序,这件事乍听上去很难,但好在 Python 为我们提供了⼀个好⽤的内置函数: sorted()。借助它,我可以很便捷地完成排序操作。我新建了⼀个名为 get_sorted_movies() 的排序函数,它接收两个参数:电影列表(movies)和排序选项(sorting_type),返回排序后 的电影列表作为结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def get_sorted_movies(movies,sorting_type):
    """对电影列表进行排序并返回

    :param movies:Movie 对象列表
    :param sorting_type:排序选项,可选值
        name(名称)、rating(评分)、year(年份)、random(随机乱序)
    """
    if sorting_type == 'name':
        sorted_movies = sorted(movies,key=lambda movie:movie.name.lower())
    elif sorting_type == 'rating':
        sorted_movies = sorted(movies,key=lambda movie:float(movie.rating),reverse=True)
    elif sorting_type == 'year':
        sorted_movies = sorted(
            movies,key=lambda movie:movie.year,reverse=True
        )
    elif sorting_type == 'random':
        sorted_movies = sorted(movies,key=lambda movie:random.random())
    else:
        raise RuntimeError(f'Unkown sorting type:{sorting_type}')
    return sorted_movies

为了把上⾯这些代码串起来,我在 main() 函数⾥实现了接收排序选项、解析电影数据、排序 并打印电影列表等功能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def main():
    # 接收用户输入的排序选项
    sorting_type = input('Please input sorting type')
    if sorting_type not in all_sorting_types:
        print(
            'Sorry, "{}" is not a valid sorting type, please choose from '
            '"{}", exit now'.format(
                sorting_type,
                '/'.join(all_sorting_types),
            )          
        )
        return 

    # 初始化电影数据对象
    movie_items = []
    for movie_json in movies:
        movie = Movie(**movie_json)
        movie_items.append(movie)

    # 排序并输出电影列表
    sorted_movies = get_sorted_movies(movie_items,sorting_type)
    for movie in sorted_movies:
        print(f'- [{movie,rank}] {movie.name} ({movie.year}) | rating: {movie.rating}')

看上去还不错,对吧?只要短短的 100 ⾏不到的代码,⼀个⼩⼯具就完成了。不过,虽然这个⼯具实现了我最初设想的功能,在它的源码⾥却藏着两⼤段可以简化的条件分⽀代码。如果使⽤恰当的⽅式,这些分⽀语句可以彻底从代码中消失。

使用bisect优化范围类分支判断

第一个需要优化的分支,藏在Movie类的rank方法属性中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@property
def rank(self):
    rating_num = float(self.rating)
    if rating_num >= 8.5:
        return 'S'
    elif rating_num >= 8:
        return 'A'
    elif rating_num >= 7:
        return 'B'
    elif rating_num >= 6:
        return 'C'
    else:
        return 'D'

有优化这段代码,我们得先把所有分界点收集起来,放在一个元组里

1
2
# 已经排好序的评级分界点
breakpoints = (6,7,8,8.5)

接下来要做的事,就是根据rating的值,判断它在breakpoints里的位置

要实现这个功能,最直接的做法是编写⼀个循环——通过遍历元组 breakpoints ⾥的所有分界点,我们就能找到 rating 在其中的位置。但除此之外,其实还有更简单的办法。因为breakpoints 已经是⼀个排好序的元组,所以我们可以直接使⽤ bisect 模块来实现查找功能

bisect 是 Python 内置的⼆分算法模块,它有⼀个同名函数 bisect,可以⽤来在有序列表⾥做⼆分查找

1
2
3
4
5
6
7
import bisect
# 注意:用来做二分查找的容器必须事已经排好序的
breakpoints = [10,20,30]

# bisect函数会返回值在列表中的位置,0代表相应的值位于第一个元素10之前
bisect.bisect(breakpoints,1)
0

将分界点定义成元组,并引⼊ bisect 模块后,之前的⼗⼏⾏分⽀代码可以简化成下⾯这 样:

1
2
3
4
5
6
7
8
9
@property
def rank(self):
    # 已经排序号的评级分界点
    breakpoints = (6,7,8,8.5)
    # 各评分区间级别名
    grades = ('D','C','B','A','S')

    index = bisect.bisect(breakpoints,float(slef.rating))
    return grades[index]

优化完 rank ⽅法后,程序中还有另⼀段待优化的条件分⽀代码——get_sorted_movies() 函数⾥的排序⽅式选择逻辑

使用字典优化分支代码

在 get_sorted_movies() 函数⾥,同样有⼀⼤段条件分⽀代码。它们负责根据 sorting_type 的值,为函数选择不同的排序⽅式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def get_sorted_movies(movies,sorting_type):
    if sorting_type == 'name':
        sorted_movies = sorted(movies,key=lambda movie:movie.name.lower())
    elif sorting_type == 'rating':
        sorted_movies = sorted(movies,key=lambda movie:float(movie.rating),reverse=True)
    elif sorting_type == 'year':
        sorted_movies = sorted(movies,key=lambda movie:movie.year,reserve=True)
    elif sorting_type == 'random':
        sorted_type = sorted(movies,key=lambda movie:random.random())
    else:
        raise RuntimeError(f'Unknown sorting type:{sorting_type}')
    return sorted_movies

这段代码有两个⾮常明显的特点。 (1) 它⽤到的条件表达式都⾮常类似,都是对 sorting_type 做等值判断(sorting_type == ’name’)。 (2) 它的每个分⽀的内部逻辑也⼤同⼩异——都是调⽤ sorted() 函数,只是 key 和reverse 参数略有不同

如果⼀段条件分⽀代码同时满⾜这两个特点,我们就可以⽤字典类型来简化它。因为 Python的字典可以装下任何对象,所以我们可以把各个分⽀下不同的东西——排序的 key 函数和 reverse 参数,直接放进字典⾥

1
2
3
4
5
6
7
sorting_algos = {
    # sorting_type:(key_func,reverse)
    'name':(lambda movie:movie.name.lower(),False)
    'rating':(lambda movie:float(movie.rating),True)
    'year':(lambda movie:movie.year,True)
    'random':(lambda movie:random.random(),False)
}

有了这份字典以后,我们的 get_sorted_movies() 函数就可以改写成下⾯这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def get_sorted_movies(movies,sorting_type):
    """对电影列表进行排序并返回
    :param movie:Movie 对象列表
    :param sorting_type 排序选项,可选值
        name(名称),rating(评分)、year(年份)、random(随机乱序)
    """
    sorting_algos = {
        # sorting_type:(key_func,reverse)
        'name':(lambda movie:movie.name.lower(),False),
        'rating':(lambda movie:float(movie.rating),True),
        'year':(lambda movie:movie_year,True),
        'random':(lambda movie:random.random(),False)
    }

    try:
        key_func,reverse = sorting_algos[sorting_type]
    except KeyError:
        raise RuntimeError(f'Unknown sorting type: {sorting_type}')

    sorted_movies = sorted(movies,key=key_func,reverse=reverse)
    
    return sorted_movie

相⽐之前的⼤段 if/elif,新代码变得整⻬了许多,扩展性也更强。如果要增加新的排序算 法,我们只需要在 sorting_algos 字典⾥增加新成员即可

优化成果

通过引入bisect模块和算法字典,案例开头的⼩⼯具代码最终优化成了代码清单 4-5

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import bisect
import random

class Movie:
    """电影对象数据类"""

    def __init__(self,name,year,rating):
        self.name = name
        self.year = year
        self.rating = rating

    @property
    def rank(self):
        """
        按照评分对电影分级      
        """

    # 已经排序好的评级分界点
    breakpoints = (6,7,8,8.5)
    # 各评分区间级别名
    grades = ('D','C','B','A','S')

    index  = bisect.bisect(breakpoints,float(self.rating))
    return grades[index]

    def get_sorted_movies(movies,sorting_type):
        """对电影列表进⾏排序并返回
        :param movies: Movie 对象列表
        :param sorting_type: 排序选项,可选值
        name(名称)、rating(评分)、year(年份)、random(随机乱序)
        """

        sorting_algos = {
            # sorting_type: (key_func, reverse)
            'name':(lambda movie:movie.name.lower(),False)
            'rating':(lambda movie:float(movie.rating),True)
            'year':(lambda movie:movie.year,True)
            'random':(lambda movie:random.random(),False)
        }
        try: 
            key_func,reverse = sorting_algos[sorting_type]
        except KeyError:
            raise RuntimeError(f'Unknown sorting type: {sorting_type}')

        sorted_movies = sorted(movies,key=key_func,reverse=reverse)
        return sorted_movies

在这个案例中,我们⼀共⽤到了两种优化分⽀的⽅法。虽然它们看上去不太⼀样,但代表的思想其实是类似的。当我们编写代码时,有时会下意识地编写⼀段段⼤同⼩异的条件分⽀语句。多数情况下,它们 只是对业务逻辑的⼀种“直译”,是我们对业务逻辑的理解尚处在第⼀层的某种拙劣表现。如果进⼀步深⼊业务逻辑,尝试从中总结规律,那么这些条件分⽀代码也许就可以被另⼀种更 精简、更易扩展的⽅式替代。当你在编写条件分⽀时,请多多思考这些分⽀背后所代表的深层需求,寻找简化它们的办法,进⽽写出更好的代码。

尽量避免多层分支嵌套

幸运的是,这些多层嵌套可以⽤⼀个简单的技巧来优化——“提前返回”。“提前返回”指的是:当你在编写分⽀时,⾸先找到那些会中断执⾏的条件,把它们移到函数的最前⾯,然后在分⽀⾥直接使⽤ return 或 raise 结束执⾏。

使⽤这个技巧,前⾯的代码可以优化成下⾯这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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)

实践“提前返回”后,buy_fruit() 函数变得更扁平了,整个逻辑也变得更直接、更容易理解了

在“Python 之禅”⾥有⼀句:“扁平优于嵌套”(Flat is better than nested),这刚好说明了把嵌套分⽀改为扁平的重要性

别写太复杂的条件表达式

针对这种代码,我们需要对条件表达式进⾏简化,把它们封装成函数或者对应的类⽅法,这样才能提升分⽀代码的可读性

尽量降低分支内代码的相似性

我们可以把重复代码移到分⽀外,尽量降低分⽀内代码的相似性:

像上⾯这种重复的语句很容易发现,下⾯是⼀个隐蔽性更强的例⼦:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 创建或更新⽤⼾资料数据
# 如果是新⽤⼾,创建新 Profile 数据,否则更新已有数据
if user.no_profile_exists:
    create_user_profile(
        username=data.username,
        gender=data.gender,
        email=data.email,
        age=data.age,
        address=data.address,
        points=0,
        created=now(),
    )
 else:
    update_user_profile(
        username=data.username,
        gender=data.gender,
        email=data.email,
        age=data.age,
        address=data.address,
        updated=now(),
    )

在上⾯这段代码⾥,我们可以⼀眼看出,程序在两个分⽀下调⽤了不同的函数,做了不⼀样的事情。但因为那些重复的函数参数,我们很难⼀下看出⼆者的核⼼不同点到底是什么.为了降低这种相似性,我们可以使⽤ Python 函数的动态关键字参数(**kwargs)特性,简单优化⼀下上⾯的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
if user.no_profile_exists:
    _update_or_create = create_user_profile
    extra_args = {'points':0,'created':now()}
else:
    _update_or_created = update_user_profile
    extra_args = {'updated':now()}

_update_or_create(
    username=user.name,
    gender=user.gender,
    email=user.email,
    age=user.age,
    address=user.address,
    **extra_args,
)

降低不同分⽀内代码的相似性,可以帮助读者更快地领会它们之间的差异,进⽽更容易理解分⽀的存在意义。

使用德摩根定律

相⽐之前,新代码少了⼀个 not 关键字,变得好理解了不少。当你的代码出现太多“否定”时,请尝试⽤“德摩根定律”来化繁为简吧。

使用all()/any()函数构建条件表达式

留意and和or的运算优先级

避开or运算符的陷阱

or 运算符是构建逻辑表达式时的常客。or 最有趣的地⽅是它的“短路求值”特性。⽐如在下⾯的例⼦⾥,1 / 0 永远不会被执⾏,也就意味着不会抛出 ZeroDivisionError 异常:

因为 a or b or c or … 这样的表达式,会返回这些变量⾥第⼀个布尔值为真的对象,直到最末⼀个为⽌,所以 extra_context or {} 表达式在对象不为空时就是 extra_context、⾃⾝,⽽当 extra_context 为 None 时就变成 {}。

总结

  1. 条件分支语句惯用写法
  • 不要显式地和布尔值作比较
  • 利用类型本身的布尔值规则,省略0值判断
  • 把not代表的否定逻辑移入表达式内部
  • 仅在需要判断某个对象是否是None,True,False时,使用is运算符
  1. python数据模型
  • 定义__len__和__bool__魔术方法,可以自定义对象的布尔值规则
  • 定义__eq__方法,可以修改对象在进行 == 运算时的行为
  1. 代码可读性技巧
  • 不同分⽀内容易出现重复或类似的代码,把它们抽到分⽀外可提升代码的可读性
  • 使⽤“德摩根定律”可以让有多重否定的表达式变得更容易理解
  1. 代码可维护性技巧
  • 尽可能让三元表达式保持简单
  • 扁平优于嵌套:使⽤“提前返回”优化代码⾥的多层分⽀嵌套
  • 当条件表达式变得特别复杂时,可以尝试封装新的函数和⽅法来简化
  • and 的优先级⽐ or ⾼,不要忘记使⽤括号来让逻辑更清晰
  • 在使⽤ or 运算符替代条件分⽀时,请注意避开因布尔值运算导致的陷阱
  1. 代码组织技巧
  • bisect模块可以用来优化范围类分支判断
  • 字典类型可以用来替代简单的条件分支语句
  • 尝试总结条件分支代码里的规律,用更精简,更易扩展的方式改写它们
  • 使⽤ any() 和 all() 内置函数可以让条件表达式变得更精简

异常与错误处理

基础知识

优先使用异常捕获

⼀种通⽤的编程⻛格:LBYL(look before you leap)。LBYL 常被翻译成“三思⽽后⾏”。通俗点⼉说,就是在执⾏⼀个可能会出错的操作时,先做⼀些关键的条件判断,仅当条件满⾜时才进⾏操作。

获取原谅比许可简单

EAFP“获取原谅⽐许可简单”是⼀种和 LBYL“三思⽽后⾏”截然不同的编程⻛格。

在 Python 世界⾥,EAFP 指不做任何事前检查,直接执⾏操作,但在外层⽤ try 来捕获可能发⽣的异常。如果还⽤下⾬举例,这种做法类似于“出⻔前不看天⽓预报,如果淋⾬了,就回家后洗澡吃感冒药”。

和 LBYL 相⽐,EAFP 编程⻛格更为简单直接,它总是直奔主流程⽽去,把意外情况都放在异常处理 try/except 块内消化掉。

如果你问我:这两种编程⻛格哪个更好?我只能说,整个 Python 社区明显偏爱基于异常捕获的 EAFP⻛格。这⾥⾯的原因很多。⼀个显⽽易⻅的原因是,EAFP ⻛格的代码通常会更精简。因为它不要求开发者⽤分⽀完全覆盖 各种可能出错的情况,只需要捕获可能发⽣的异常即可。另外,EAFP ⻛格的代码通常性能也更好。⽐如在这个例⼦⾥,假如你每次都⽤字符串 ‘73’ 来调⽤函数,这两种⻛格的代码在操作流程上会有如下区别

try语句常用知识

把更精确的except语句放在前面

使用else分支

异常捕获语句⾥的 else 表⽰:仅当 try 语句块⾥没抛出任何异常时,才执⾏ else 分⽀下的内容,效果就像在 try 最后增加⼀个标记变量⼀样。

使用空raise语句

当⼀个空 raise 语句出现在 except 块⾥时,它会原封不动地重新抛出当前异常

抛出异常,而不是返回错误

我们知道,Python ⾥的函数可以⼀次返回多个值(通过返回⼀个元组实现)。所以,当我们要表明函数执⾏出错时,可以让它同时返回结果与错误信息。

使用上下文管理器

with 是⼀个神奇的关键字,它可以在代码中开辟⼀段由它管理的上下⽂,并控制程序在进⼊和退出这段上下⽂时的⾏为。⽐如在上⾯的代码⾥,这段上下⽂所附加的主要⾏为就是:进⼊时打开某 个⽂件并返回⽂件对象,退出时关闭该⽂件对象。并⾮所有对象都能像 open(‘foo.txt’) ⼀样配合 with 使⽤,只有满⾜上下⽂管理器(context manager)协议的对象才⾏。

上下⽂管理器是⼀种定义了“进⼊”和“退出”动作的特殊对象。要创建⼀个上下⽂管理器,只要实现 enterexit 两个魔法⽅法即可。

下⾯这段代码实现了⼀个简单的上下⽂管理器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class DummyContext:
    def __init__(self,name):
        self.name = name

    def __enter__(self):
        # __enter__ 会在进入管理器时被调用,同时可以返回结果
        # 这个结果可以通过as关键字被调用方获取
        # 此处返回一个增加了随机后缀的name
        return f'{self.name}-{random.random()}'

    def __exit__(self,exc_type,exc_val,exc_tb):
        # __exit__ 会在退出管理器被调用
        print('Exiting DummyContext')
        return False

用于替代finally语句清理资源

因此,我们完全可以⽤上下⽂管理器来替代 finally 语句。做起来很简单,只要在 exit ⾥增加需要的回收语句即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class create_conn_obj:
    """创建连接对象,并在退出上下⽂时⾃动关闭"""
    
    def __init__(self,host,port,timeout=None):
        self.conn = create_conn(host,port,timeout=timeout)

    def __enter__(self):
        return self.conn

    def __exit__(self,exc_type,exc_value,traceback):
        # __exit__会在管理器退出时调用
        self.conn.close()
        return False

使⽤ create_conn_obj 可以创建会⾃动关闭的连接对象:

1
2
3
4
5
with create_conn_obj(host,port,timeout=None) as conn:
    try:
        conn.send_text('Hello,world')
    except Exception as e:
        print(f'Unable to use connection: {e}')

除了回收资源外,你还可以⽤ exit ⽅法做许多其他事情,⽐如对异常进⾏⼆次处理后重新抛出,⼜⽐如忽略某种异常,等等

用于忽略异常

如果使⽤上下⽂管理器,我们可以很⽅便地实现可复⽤的“忽略异常”功能——只要在 exit ⽅法⾥稍微写⼏⾏代码就⾏

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class ignore_closed:
    """忽略已经关闭的连接"""

    def __enter__(self):
        pass
    
    def __exit__(self,exc_type,exc_value,traceback):
        if exc_type == AlreadyClosedError:
            return True
        return False

当你想忽略 AlreadyClosedError 异常时,只要把代码⽤ with 语句包裹起来即可:

1
2
with ignore_closed():
    close_conn(conn)

通过 with 实现的“忽略异常”功能,主要利⽤了上下⽂管理器的 exit ⽅法。exit 接收三个参数:exc_type、exc_value 和 traceback。

如果你在真实项⽬中要忽略某类异常,可以直接使⽤标准库模块 contextlib ⾥的 suppress 函数,它提供了现成的“忽略异常”功能

使用contextmanager装饰器

虽然上下⽂管理器很好⽤,但定义⼀个符合协议的管理器对象其实挺⿇烦的——得⾸先创建⼀个类,然后实现好⼏个魔法⽅法。为了简化这部分⼯作,Python 提供了⼀个⾮常好⽤的⼯具:@contextmanager 装饰器

@contextmanager位于内置模块contextlib下,它可以把任何一个生成器函数直接转换为一个上下文管理器

举个例⼦,我在前⾯实现的⾃动关闭连接的 create_conn_obj 上下⽂管理器,假如⽤函数 来改写,可以简化成下⾯这样:

 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()

以 yield 关键字为界,yield 前的逻辑会在进⼊管理器时执⾏(类似于 enter),yield 后的逻辑会在退出管理器时执⾏(类似于 exit

如果要在上下⽂管理器内处理异常,必须⽤ try 语句块包裹 yield 语句

在⽇常⼯作中,我们⽤到的⼤多数上下⽂管理器,可以直接通过“⽣成器函数 + @contextmanager”的⽅式来定义,这⽐创建⼀个符合协议的类要简单得多。

异常捕获不是在拿着捕⾍⽹玩捕⾍游戏,谁捕的⾍⼦多谁就获胜。弄⼀个庞⼤的 try 语句,把所有可能出错、不可能出错的代码,⼀股脑⼉地全部⽤ except Exception:包起来,显然是不妥当的。

“Python 之禅”⾥也提到了这个建议:“除⾮有意静默,否则不要⽆故忽视异常。” (Errors should never pass silently. Unless explicitly silenced.)

在数据校验这块,pydantic 模块是⼀个不错的选择。

在编写代码时,我们应当尽量避免⼿动校验任何数据。因为数据校验任务独⽴性很强,所以应该引⼊合适的第三⽅校验模块(或者⾃⼰实现),让它们来处理这部分专业⼯作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
from pydantic import BaseModel,coint,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}')

请不要拿 assert 来做参数校验,⽤ raise 语句来替代它

空对象模式

简单来说,“空对象模式”就是本该返回 None 值或抛出异常时,返回⼀个符合正常结果接⼝的特制“空类型对象”来代替,以此免去调⽤⽅的错误处理⼯作。

“空对象模式”也是⼀种转换设计观念以避免错误处理的技巧。当函数进⼊边界情况时,“空对象模式”不再抛出错误,⽽是让其返回⼀个类似于正常结果的特殊 对象,因此使⽤⽅⾃然就不必处理任何错误,⼈们写起代码来也会更轻松。

总结

  1. 基础知识
  • ⼀个 try 语句⽀持多个 except ⼦句,但请记得把更精确的异常类放在前⾯
  • try 语句的 else 分⽀会在没有异常时执⾏,因此它可⽤来替代标记变量
  • 不带任何参数的 raise 语句会重复抛出当前异常
  • 上下⽂管理器经常⽤来处理异常,它最常⻅的⽤途是替代 finally ⼦句
  • 上下⽂管理器可以⽤来忽略某段代码⾥的异常
  • 使⽤ @contextmanager 装饰器可以轻松定义上下⽂管理器
  1. 错误处理与参数校验
  • 当你可以选择编写条件判断或异常捕获时,优先选异常捕获(EAFP)
  • 不要让函数返回错误信息,直接抛出⾃定义异常吧
  • ⼿动校验数据合法性⾮常烦琐,尽量使⽤专业模块来做这件事
  • 不要使⽤ assert 来做参数校验,⽤ raise 替代它
  • 处理错误需要付出额外成本,假如能通过设计避免它就再好不过了
  • 在设计 API 时,需要慎重考虑是否真的有必要抛出错误
  • 使⽤“空对象模式”能免去⼀些针对边界情况的错误处理⼯作
  1. 当你捕获异常时
  • 过于模糊和宽泛的异常捕获可能会让程序免于崩溃,但也可能会带来更⼤的⿇烦
  • 异常捕获贵在精确,只捕获可能抛出异常的语句,只捕获可能的异常类型
  • 有时候,让程序提早崩溃未必是什么坏事
  • 完全忽略异常是⻛险⾮常⾼的⾏为,⼤多数情况下,⾄少记录⼀条错误⽇志
  1. 当你抛出异常时
  • 保证模块内抛出的异常与模块⾃⾝的抽象级别⼀致
  • 如果异常的抽象级别过⾼,把它替换为更低级的新异常
  • 如果异常的抽象级别过低,把它包装成更⾼级的异常,然后重新抛出
  • 不要让调⽤⽅⽤字符串匹配来判断异常种类,尽量提供可区分的异常

循环与可迭代对象

iter()和next()内置函数

调⽤ iter() 会尝试返回⼀个迭代器对象

对不可迭代的类型执⾏ iter() 会抛出 TypeError 异常

什么是迭代器(iterator)?顾名思义,这是⼀种帮助你迭代其他对象的对象。迭代器最鲜明的特征是:不断对它执⾏ next() 函数会返回下⼀次迭代结果。

当你使⽤ for 循环遍历某个可迭代对象时,其实是先调⽤了 iter() 拿到它的迭代器,然后不断地⽤ next() 从迭代器中获取值。

自定义迭代器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Range7:
    """生成某个范围内可以被7整除或包含7的整数
    
    :param start:开始数字
    :param end:结束数字
    """

    def __init__(self,start,end):
        self.start = start
        self.end = end
        # 使用current保存当前所处的位置
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        while True:
            # 当已经到达边界时,抛出异常终止迭代
            if self.current >= self.end:
                raise StopIteration
            
            if self.num_is_valid(self.current):
                ret = self.current
                self.current += 1
                return ret
            self.current += 1

    def num_is_valid(self,num):
        """判断数字是否满足要求"""
        if num == 0:
            return False
        return num % 7 == 0 or '7' in str(num)

⼀个合法的迭代器,必须同时实现 iternext 两个魔法⽅法。

可迭代对象只需要实现 iter ⽅法,不⼀定得实现 next ⽅法。

所以,如果想让 Range7 对象在每次迭代时都返回完整结果,我们必须把现在的代码拆成两部分:可迭代类型 Range7 和迭代器类型 Range7Iterator。代码如下所⽰:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Range7:
    """⽣成某个范围内可被 7 整除或包含 7 的数字"""
    def __init__(self, start, end):
        self.start = start
        self.end = end


    def __iter__(self):
        # 返回一个新的迭代器对象
        return Range7Iterator(self)

class Range7Iterator:

    def __init__(self, range_obj):
        self.range_obj = range_obj
        self.current = range_obj.start

    def __iter__(self):
        return self

    def __next__(self):
        while True:
            if self.current >= self.range_obj.end:
                raise StopIteration

            if self.num_is_valid(self.current):
                ret = self.current
                self.current += 1
                return ret
            self.current += 1

    def num_is_valid(self, num):
        if num == 0:
            return False
        return num % 7 == 0 or '7' in str(num)

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

生成器是迭代器

⽣成器是⼀种“懒惰的”可迭代对象,使⽤它来替代传统列表可以节约内存,提升执⾏效率

但除此之外,⽣成器还是⼀种简化的迭代器实现,使⽤它可以⼤⼤降低实现传统迭代器的编码成本。因此在平时,我们基本不需要通过 iternext 来实现迭代器,只要写上⼏个 yield 就⾏。

如果利⽤⽣成器,上⾯的 Range7Iterator 可以改写成⼀个只有 5 ⾏代码的函数:

1
2
3
4
5
6
7
def range_7_gen(start,end):
    """生成器版本的Rang7Iterator"""
    num = start
    while num < end:
        if num != 0 and (num % 7 == 0 or '7' in str(num)):
            yield num
        num += 1

⽣成器(generator)利⽤其简单的语法,⼤⼤降低了迭代器的使⽤⻔槛,是优化循环代码时最得⼒的帮⼿。

enumerate() 是 Python 的⼀个内置函数,它接收⼀个可迭代对象作为参数,返回⼀个不断⽣成 ( 当前下标 , 当前元素 ) 的新可迭代对象。对于这个场景,使⽤它再适合不过了

⽣成器函数 even_only(),它专⻔负责偶数过滤⼯作:

1
2
3
4
def even_only(numbers):
    for num in numbers:
        if num % 2 == 0:
            yield num

之后在 sum_even_only_v2() ⾥,只要先⽤ even_only() 函数修饰 numbers 变量,循环内的“偶数过滤”逻辑就可以完全去掉,只需简单求和即可:

1
2
3
4
5
6
def sum_even_only_v2(numbers):
    """对numbers里面所有的偶数求和"""
    result = 0
    for num in even_only(numbers):
        result += num
    return result

总结⼀下,“修饰可迭代对象”是指⽤⽣成器(或普通的迭代器)在循环外部包装原本的循环主体,完成⼀些原本必须在循环内部执⾏的⼯作——⽐如过滤特定成员、提供额外结果等,以此简化循环代码

除了⾃定义修饰函数外,你还可以直接使⽤标准库模块 itertools ⾥的许多现成⼯具。

使用itertools模块优化循环

itertools 是⼀个和迭代器有关的标准库模块,其中包含许多⽤来处理可迭代对象的⼯具函数。

使用product()扁平化多层嵌套循环

1
2
3
4
5
6
7
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() 函数来优化它。product() 接收多个可迭代对象作为参数,然后根据它们的笛卡⼉积不断⽣成结果:

⽤ product() 优化函数⾥的嵌套循环:

1
2
3
4
5
6
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

相⽐之前,新函数只⽤了⼀层 for 循环就完成了任务,代码变得更精练了。

使用islice()实现循环内隔行处理

islice(seq, start, end, step) 函数和数组切⽚操作(list[start:stop:step])接收的参数⼏乎完全⼀致。如果需要在循环内部实现隔⾏处理,只要设置第三个参数 step(递进步⻓)的值为 2 即可:

1
2
3
4
5
6
7
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()

使用takewhile()替代break语句

takewhile(predicate, iterable) 会在迭代第⼆个参数 iterable 的过程中,不断使⽤当前值作为参数调⽤ predicate() 函数,并对返回结果进⾏真值测试,如果为 True,则返回当前值并继续迭代,否则⽴即中断本次迭代。

使⽤ takewhile() 后代码会变成这样:

1
2
3
4
from itertools import takewhile

for user in takewhile(is_qualified,users):
    ....

除了上⾯这三个函数以外,itertools 还有其他⼀些有意思的⼯具函数,它们都可以搭配循环使⽤,⽐如⽤ chain() 函数可以扁平化双层嵌套循环、⽤ zip_longest() 函数可以同时遍历多个对象,等等

使用while循环加read()方法分块读取

除了直接遍历⽂件对象来逐⾏读取⽂件内容外,我们还可以调⽤更底层的 file.read() ⽅法。与直接⽤循环迭代⽂件对象不同,每次调⽤ file.read(chunk_size),会⻢上读取从游标位置往后 chunk_size ⼤⼩的⽂件内容,不必等待任何换⾏符出现。

使⽤ file.read() 读取⽂件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def count_digits_v2(fname):
    """计算文件里包含多少个数字字符,每次读取8kb"""
    count = 0
    block_size = 1024 * 8
    with open(fname) as file:
        while True:
            chunk = file.read(block_size)
            # 当文件没有更多内容时,read调用将会返回空字符串
            if not chunk:
                break
            for s  in chunk:
                if s.isdigit():
                    count += 1
    return count

在新函数中,我们使⽤了⼀个 while 循环来读取⽂件内容,每次最多读 8 KB,程序不再需要在内存中拼接⻓达数吉字节的字符串,内存占⽤会⼤幅降低。不过,新代码虽然解决了⼤⽂件读取时的性能问题,循环内的逻辑却变得更零碎了。如果使⽤iter() 函数,我们可以进⼀步简化代码

iter()的另一个用法

当我们以 iter(callable, sentinel) 的⽅式调⽤ iter() 函数时,会拿到⼀个特殊的迭代器对象。⽤循环遍历这个迭代器,会不断返回调⽤ callable() 的结果,假如结果等于sentinel,迭代过程中⽌。利⽤这个特点,我们可以把上⾯的 while 重新改为 for,让循环内部变得更简单,如代码清单 6-3 所⽰。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from functools import partial

def count_digit_v3(fname):
    count = 0
    block_size = 1024 * 8
    with open(fname) as fp:
        # 使用functools.partial 构造一个新的无须参数的函数
        _read = partial(fp.read,block_size)

        # 利用iter()构造一个不断调用_read的迭代器
        for chunk in iter(_read,''):
            if s.isdigit():
                count += 1
    return count

要解耦循环体,⽣成器(或迭代器)是⾸选。在这个案例中,我们可以定义⼀个新的⽣成器函数:read_file_digits(),由它来负责所有与“数据⽣成”相关的逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 读取数字内容的⽣成器函数
def read_file_digits(fp,block_size=1024 * 8):
    """生成器函数:分块读取文件内容,返回其中的数字字符"""
    _read = partial(fp.read,block)
    for chunk in iter(_read,''):
        for s in chunk:
            if s.isdigit():
                yield s

# 复⽤读取函数后的统计函数
def count_digits_v4(fname):
    count = 0 
    with open(fname) as file:
        for _ in read_file_digits(file):
            count += 1
    return count

# 复⽤读取函数后的统计偶数函数
from collections import defaultdict

def count_even_groups(fname):
    """分别统计文件里每个偶数字符出现的次数"""
    counter = defaultdict(int)
    with open(fname) as file:
        for num in read_file_digits(file):
            if int(num)%2 == 0:
                counter[int(num)] += 1
    return counter

⼩ R 的故事告诉了我们⼀个道理。在编写循环时,我们需要时常问⾃⼰:循环体内的代码是不是过⻓、过于复杂了?如果答案是肯定的,那就试着把代码按职责分类,抽象成独⽴的⽣成器(或迭代器)吧。这样不光能让代码变得更整洁,可复⽤性也会极⼤提升。

中断嵌套循环的正确方式

如果想快速从嵌套循环⾥跳出,其实有个更好的做法,那就是把循环代码拆分为⼀个新函数,然后直接使⽤ return。

巧用next()函数

举个例⼦,假如有⼀个字典 d,你要怎么拿到它的第⼀个 key 呢? 只要先⽤ iter() 获取⼀个 d.keys() 的迭代器,再对它调⽤ next() 就能⻢上拿到第⼀个元素。这样做不需要遍历字典的所有 key,⾃然⽐先转换列表的⽅法效率更⾼。

假设有⼀个装了⾮常多整数的列表对象 numbers,我需要找到⾥⾯第⼀个可以被 7 整除的数字。除了编写传统的“for 循环配合 break”式代码,你也可以直接⽤ next() 配合⽣成器表达式来完成任务:

1
2
numbers = [3,6,8,2,21,30,42]
print(next(i for i in numbers if i%7==0))

当心已被耗尽的迭代器

因此在平时,你需要将⽣成器(迭代器)的“可被⼀次性耗尽”特点铭记于⼼,避免写出由它所导致的 bug。假如要重复使⽤⼀个⽣成器,可以调⽤ list() 函数将它转成列表后再使⽤

总结

  1. 迭代与迭代器原理
  • 使用iter()函数会尝试获取一个迭代器对象
  • 使⽤ next() 函数会获取迭代器的下⼀个内容
  • 可以将 for 循环简单地理解为 while 循环 + 不断调⽤ next()
  • ⾃定义迭代器需要实现 iternext 两个魔法⽅法
  • ⽣成器对象是迭代器的⼀种
  • iter(callable, sentinel) 可以基于可调⽤对象构造⼀个迭代器
  1. 迭代器与可迭代对象
  • 迭代器和可迭代对象是不同的概念
  • 可迭代对象不⼀定是迭代器,但迭代器⼀定是可迭代对象
  • 对可迭代性对象使用iter()会返回迭代器,迭代器则会返回它自身
  • 每个迭代器的被迭代过程时一次性的,可迭代对象则不一定
  • 可迭代对象只需要实现 iter ⽅法,⽽迭代器要额外实现 next ⽅法
  1. 代码可维护性技巧
  • 通过定义生成器函数来侠士可迭代对象,可以优化循环内部代码
  • itertools模块里有许多函数可以⽤来修饰可迭代对象
  • 生成器函数可以用来解耦循环代码,提升可复用性
  • 不要使用多个break,拆分为函数然后直接return更好
  • 使⽤ next() 函数有时可以完成⼀些意想不到的功能
  1. 文件操作知识
  • 使⽤标准做法读取⽂件内容,在处理没有换⾏符的⼤⽂件时会很慢
  • 调⽤ file.read() ⽅法可以解决读取⼤⽂件的性能问题

函数

别将可变类型作为参数默认值

Python 函数的参数默认值只会在函数定义阶段被创建⼀次,之后不论再调⽤多少次,函数内拿到的默认值都是同⼀个对象。

假如再多花点⼉功夫,你甚⾄可以通过函数对象的保留属性 defaults 直接读取这个默认值:

因此,熟悉 Python 的程序员通常不会将可变类型作为参数默认值。这是因为⼀旦函数在执⾏时修改了这个默认值,就会对之后的所有函数调⽤产⽣影响。

为了规避这个问题,使⽤ None 来替代可变类型默认值是⽐较常⻅的做法:

定义特殊对象来区分是否提供了默认参数

最常⻅的做法是定义⼀个特殊对象(标记变量)作为参数默认值

定义仅限关键字参数

在经典编程图书《代码整洁之道》 中,作者 Robert C. Martin 提到:“函数接收的参数不要太多,最好不要超过 3 个。”这个建议很有道理,因为参数越多,函数的调⽤⽅式就会变得越复杂,代码也会变得更难懂。

所以,当你要调⽤参数较多(超过 3 个)的函数时,使⽤关键字参数模式可以⼤⼤提⾼代码的可读性

尽量只返回一种类型

好的函数设计⼀定是简单的,这种简单体现在各个⽅⾯。返回多种类型明显违反了简单原则。这种做法不光会给函数本⾝增加不必要的复杂度,还会提⾼⽤⼾理解和使⽤函数的成本。

像上⾯的例⼦,更好的做法是将它拆分为两个独⽴的函数

  1. get_user_by_id(user_id):返回单个⽤⼾
  2. get_active_users():返回多个⽤⼾列表。

这样就能让每个函数只返回⼀种类型,变得更简单易⽤

谨慎返回None值

在编程语⾔的世界⾥,“空值”随处可⻅,它通常⽤来表⽰某个应该存在但是缺失的东西。“空值”在不同编程语⾔⾥有不同的名字,⽐如 Go 把它叫作 nil,Java 把它叫作 null, Python 则称它为 None。

在 Python 中,None 是独⼀⽆⼆的存在。因为它有着⼀种独特的“虚⽆”含义,所以经常会⽤作函数返回值。

当我们需要让函数返回 None 时,主要是下⾯ 3 种情况

  • 操作类函数的默认返回值 当某个操作类函数不需要任何返回值时,通常会返回 None。与此同时,None 也是不带任何 return 语句的函数的默认返回值:

  • 意料之中的缺失值 还有⼀类函数,它们所做的事情天⽣就是在尝试,⽐如从数据库⾥查找⼀个⽤⼾、在⽬录中查找⼀个⽂件。视条件不同,函数执⾏后可能有结果,也可能没有结果。⽽重点在于,对于函数的调⽤⽅来说,“没有结果”是意料之中的事情。

针对这类函数,使⽤ None 作为“没有结果”时的返回值通常也是合理的。

  • 在执行失败时代表错误 有时候,None 也会⽤作执⾏失败时的默认返回值。

对这些函数来说,⽤抛出异常来代替返回 None 会更为合理。这也很好理解:当函数被调⽤时,如果⽆法返回正常结果,就代表出现了意料以外的状况,⽽“意料之外”正是异常所掌管的领域。

  • 早返回,多返回 ⾃打我开始写代码以来,常常会听⼈说起⼀条叫“单⼀出⼝”的原则。这条原则是说:“函数应该保证只有⼀个出⼝。

因此,在编写函数时,请不要纠结函数是不是应该只有⼀个 return,只要尽早返回结果可以提升代码可读性,那就多多返回吧

但在 Python 中,“单⼀出⼝原则建议函数只写⼀个 return”只能算是⼀种误读,在“单⼀出⼝”和“多多返回”之间,我们完全可以选择可读性更强的那个。

常用函数模块functools

这是⼀个很常⻅的函数使⽤场景:⾸先有⼀个接收许多参数的函数 a,然后额外定义⼀个接收更少参数的函数 b,通过在 b 内部补充⼀些预设参数,最后返回调⽤ a 函数的结果。

针对这类场景,我们其实不需要像前⾯⼀样,⽤ def 去完全定义⼀个新函数——直接使⽤functools 模块提供的⾼阶函数 partial() 就⾏。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
result = multiply(2, value)
val = multiply(2, number)

def double(value):
    """返回multiply函数调用结果"""
    return multiply(2,value)

# 调用代码变得更简单
result = double(value)
val = double(number)

partial 的调⽤⽅式为 partial(func, *arg, **kwargs),其中:

  • func 是完成具体功能的原函数;
  • *args/**kwargs 是可选位置与关键字参数,必须是原函数 func 所接收的合法参数。

举个例⼦,当你调⽤ partial(func, True, foo=1) 后,函数会返回⼀个新的可调⽤对象(callable object)——偏函数 partial_obj。

拿到这个偏函数后,如果你不带任何参数调⽤它,效果等同于使⽤构建 partial_obj 对象时的参数调⽤原函数:partial_obj() 等同于 func(True, foo=1)。

但假如你在调⽤ partial_obj 对象时提供了额外参数,前者就会⾸先将本次调⽤参数和构造 partial_obj 时的参数进⾏合并,然后将合并后的参数透传给原始函数 func 处理,也就是说,partial_obj(bar=2) 与 func(True, foo=1, bar=2) 效果相同。使⽤ functools.partial,上⾯的 double() 函数定义可以变得更简洁:

1
2
3
import functools

double = functools.partial(multiply,2)

functools.partial 是 Python 标准库中的一个函数,用于固定函数的部分参数,创建一个新的函数(偏函数)。这样可以简化函数调用,特别是在需要多次调用同一个函数但参数略有不同的情况下。

functools.lru_cache()

在编码时,我们的函数常常需要做⼀些耗时较⻓的操作,⽐如调⽤第三⽅ API、进⾏复杂运算等。这些操作会导致函数执⾏速度慢,⽆法满⾜要求。为了提⾼效率,给这类慢函数加上缓存是⽐较常⻅的做法

在缓存⽅⾯,functools 模块为我们提供⼀个开箱即⽤的⼯具:lru_cache()。使⽤它,你可以⽅便地给函数加上缓存功能,同时不⽤修改任何函数内部代码

在使⽤ lru_cache() 装饰器时,可以传⼊⼀个可选的 maxsize 参数,该参数代表当前函数最多可以保存多少个缓存结果。当缓存的结果数量超过 maxsize 以后,程序就会基于“最近最少使⽤”(least recently used,LRU)算法丢掉旧缓存,释放内存。默认情况下,maxsize 的值为 128。

如果你把 maxsize 设置为 None,函数就会保存每⼀个执⾏结果,不再剔除任何旧缓存。这时如果被缓存的内容太多,就会有占⽤过多内存的⻛险。

在函数式编程(functional programming)领域,有⼀个术语纯函数(pure function)。它最⼤的特点是,假如输⼊参数相同,输出结果也⼀定相同,不受任何其他因素影响。换句话说,纯函数是⼀种⽆状态的函数。

虽然全局变量能满⾜需求,⽽且看上去似乎挺简单,但千万不要被它的外表蒙蔽了双眼。⽤全局变量保存状态,其实是写代码时最应该避开的事情之⼀。

如果多个模块在不同线程⾥,同时导⼊并使⽤mosaic_global_var() 函数,整个字符轮换的逻辑就会乱掉,因为多个调⽤⽅共享同⼀个全局标记变量 _mosaic_char_index。

总⽽⾔之,⽤全局变量管理状态,在各种场景下⼏乎都是下策,仅可在迫不得已时作为终极⼿段使⽤。

给函数加上状态:闭包

闭包是一种允许函数访问已执行完成的其他函数里的私有变量的技术,是为函数增加状态的另一种方式

正常情况下,当python完成依次函数执行后,本次使用的局部变量都会在调用结束后被回收,无法继续返回。但是如果你使用下面这种函数套函数的方式,在外层函数执行结束后,返回内嵌函数,后者就可以继续访问前者的局部变量,形成一个闭包结构

1
2
3
4
5
6
7
8
9
def counter():
    value = 0
    def _counter():
        # nonlocal ⽤来标注变量来⾃上层作⽤域,如不标明,内层函数将⽆法直接修改外层函数变量
        nonlocal value

        value += 1
        return value
    return _counter

使用闭包的有状态替换函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def make_mosaic():
    """
    将匹配到的模式替换为其他字符,使用闭包实现轮换字符效果
    """
    char_index = 0
    mmosaic_chars = ['*','x']

    def _mosaic(matchobj):
        nonlocal char_index
        char = mosaic_chars[char_index]
        char_index = (char_index + 1) % len(mosaic_chars)

        length = len(matchobj.group())
        return char * length

    return _mosaic

相⽐全局变量,使⽤闭包最⼤的特点就是封装性要好得多。在闭包代码⾥,索引变量 called_cnt 完全处于闭包内部,不会污染全局命名空间,⽽且不同闭包对象之间也不会相互 影响。总⽽⾔之,闭包是⼀种⾮常有⽤的⼯具,⾮常适合⽤来实现简单的有状态函数。不过,除了闭包之外,还有⼀个天⽣就适合⽤来实现“状态”的⼯具:类

给函数加上状态:类

类(class)是⾯向对象编程⾥最基本的概念之⼀。在⼀个类中,状态和⾏为可以被很好地封装在⼀起,因此它天⽣适合⽤来实现有状态对象。

基于类实现有状态替换方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class CyclicMosaic:
    """使用会轮换的屏蔽字符,基于类实现"""

    _chars = ['*','x']

    def __init__(self):
        self._char_index = 0

    def generate(self,matchobj):
        char = self._chars[self._char_index]
        self._char_index = (self._char_index + 1) % len(self._chars)
        length = len(matchobj.group())
        return char * length

类实例的状态⼀般都在 init 函数⾥初始化

不过严格说来,这个⽅案最终依赖的 CycleMosaic().generate,并⾮⼀个有状态的函数,⽽是⼀个有状态的实例⽅法。但⽆论是函数还是实例⽅法,它们都是“可调⽤对象”的⼀种,都可以作为 re.sub() 函数的 repl 参数使⽤。

别写太复杂的函数

Steve McConnell 提到函数的理想⻓度范围是 65 到 200 ⾏

  • 圈复杂度 在 Python 中,你可以通过 radon ⼯具计算⼀个函数的圈复杂度。radon 基于 Python编写,使⽤ pip install radon 即可完成安装。

一个函数只包含一层抽象

什么是抽象

通⽤领域⾥的“抽象”,是指在⾯对复杂事物(或概念)时,主动过滤掉不需要的细节,只关注与当前⽬的有关的信息的过程

举个例⼦,我吃完饭在⼤街上散步,⾛得有点⼉累了,于是对⾃⼰说:“腿真疼啊,找把椅⼦坐吧。”此时此刻,“椅⼦”在我脑中就是⼀个抽象的概念。 我脑中的椅⼦: 有⼀个平坦的表⾯可以把屁股放上去;离地 20 到 50 厘⽶,能⽀撑 60 千克以上的重量。对这个抽象概念来说,路边的⾦属⿊⾊⻓椅是我需要的椅⼦,饭店⻔⼝的塑料扶⼿椅同样也是 我需要的椅⼦,甚⾄某个⼀尘不染的台阶也可以成为我要的“椅⼦”。在这个抽象下,椅⼦的其他特征,⽐如使⽤什么材料(⽊材还是⾦属)、涂的什么颜⾊(⽩⾊还是⿊⾊),对于我来说都不重要。于是在⼀次逛街中,我不知不觉完成了⼀次对椅⼦的抽象,解决了屁股坐哪⼉的问题。 所以简单来说,抽象就是⼀种选择特征、简化认知的⼿段。接下来,我们看看抽象与软件开发的关系

抽象与软件开发

什么是分层?分层就在设计⼀个复杂系统时,按照问题抽象程度的⾼低,将系统划分为不同的抽象层(abstraction layer)。低级的抽象层⾥包含较多的实现细节。随着层级变⾼,细节越来越少,越接近我们想要解决的实际问题。

在这种分层结构下,每⼀层抽象都只依赖⽐它抽象级别更低的层,同时对⽐它抽象级别更⾼的层⼀⽆所知。因此,每层都可以脱离更⾼级别的层独⽴⼯作。⽐如活跃在传输层的 TCP 协议,可以对应⽤层的 HTTP、HTTPS 等应⽤协议毫⽆感知,独⽴⼯作。

正因为抽象与分层理论特别有⽤,所以不管你有没有意识到,其实在各个维度上都活跃着“分层”的⾝影,如下所⽰。 项⽬间的分层:电商后端 API(⾼层抽象)→数据库(低层抽象)。 项⽬内的分层:账单模块(⾼层抽象)→ Django 框架(低层抽象)。 模块内的分层:函数名–获取账⼾信息(⾼层抽象)→函数内–处理字符串(低层抽象)。

因此,即便是在⾮常微观的层⾯上,⽐如编写⼀个函数时,我们同样需要考虑函数内代码与抽象级别的关系。假如⼀个函数内同时包含了多个抽象级别的内容,就会引发⼀系列的问题。

脚本案例:调用api查找歌手的第一张专辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
"""通过 iTunes API搜索歌手发布的第一张专辑"""
import sys
from json.decoder import JSONDecodeError

import requests
from requests.exceptions import HTTPError

ITUNES_API_ENDPIOINT = 'https://itunes.apple.com/search'

def command_first_album():
    """通过脚本输入查找并打印歌手的第一张专辑信息"""
    if not len(sys.argv) == 2:
        print(f'usage: python {sys.argv[0]} {{SEARCH_TERM}}')
        sys.exit(1)

    term = sys.argv[1]
    resp = requests.get(
        ITUNES_API_ENDPOINT,
        {
            'term':term,
            'media':'music',
            'entity':'album',
            'attribute':'artisitTerm',
            'limit':200,
        },
    )
    try:
        resp.raise_for_status()
    except HTTPError as e:
        print(f'Error: failed to call iTunes API, {e}')
        sys.exit(2)
    try:
        albums = resp.json()['results']
    except JSONDecodeError:
        print(f'Error: response is not valid JSON format')
        sys.exit(2)
    if not albums:
        print(f'Error: no albums found for artist "{term}"')
        sys.exit(1)

    sorted_albums = sorted(albums,key=lambda item: item['releaseDate'])
    first_album = sorted_album[0]
    # 去除发布日期的小时和分钟信息
    release_date = first_album['releaseDate'].split('T')[0]

     # 打印结果
    print(f"{term}'s first album: ")
    print(f" * Name: {first_album['collectionName']}")
    print(f" * Genre: {first_album['primaryGenreName']}")
    print(f" * Released at: {release_date}")

if __name__ == '__main__':
    command_first_album()

上⾯脚本的主函数 command_first_album() 显然不符合这个标准。在函数内部,不同抽象级别的代码随意混合在了⼀起。⽐如,当请求 API 失败时(数据层),函数直接调⽤sys.exit() 中断了程序执⾏(⽤⼾界⾯层)

这种抽象级别上的混乱,最终导致了下⾯两个问题。 函数代码的说明性不够:如果只是简单读⼀遍 command_first_album(),很难搞清楚

  • 它的主流程是什么,因为⾥⾯的代码五花⼋⻔,什么层次的信息都有。
  • 函数的可复⽤性差:假如现在要开发新需求——查询歌⼿的所有专辑,你⽆法复⽤已有函数的任何代码

所以,如果缺乏设计,哪怕是⼀个只有 40 ⾏代码的简单函数,内部也很容易产⽣抽象混乱问题。要优化这个函数,我们需要重新梳理程序的抽象级别。 在我看来,这个程序⾄少可以分为以下三层。

  • 用户界面层:处理⽤⼾输⼊、输出结果。
  • 第一张专辑层:找到第⼀张专辑。
  • 专辑数据层:调⽤ API 获取专辑信息。

在每⼀个抽象层内,程序所关注的事情都各不相同 alt text

基于抽象层重构代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
""" 通过 iTunes API 搜索歌⼿发布的第⼀张专辑"""
import sys
from json.decoder import JSONDecodeError

import requests
from requests.exceptions import HTTPError

ITUNES_API_ENDPOINT = 'https://itunes.apple.com/search'

class  GetFirstAlbumError(Exception):
""" 获取第⼀张专辑失败"""
class QueryAlbumsError(Exception):
"""获取专辑列表失败"""

def command_first_album():
    """通过输⼊参数查找并打印歌⼿的第⼀张专辑信息"""
    if not len(sys.argv) == 2:
        print(f'usage: python {sys.argv[0]} {{SEARCH_TERM}}')
        sys.exit(1)
    artist = sys.argv[1]
    try:
        album = get_first_album(artist)
    except GetFirstAlbumError as e:
        print(f"error: {e}", file=sys.stderr)
        sys.exit(2)
    print(f"{artist}'s first album: ")
    print(f" * Name: {album['name']}")
    print(f" * Genre: {album['genre_name']}")
    print(f" * Released at: {album['release_date']}")

def get_first_album(artist):
    """根据专辑列表获取第⼀张专辑
    :param artist: 歌⼿名字
    :return: 第⼀张专辑
    :raises: 获取失败时抛出 GetFirstAlbumError
    """

    try:
        albums = query_all_albums(artist)
    except  QueryAlbumsError as e:
        raise GetFirstAlbumError(str(e))

    sorted_albums = sorted(albums,key=lambda item:item['releaseDate'])
    first_album = sorted_albums[0]

    # 去除发布日期里的小时与分钟信息
    release_date = first_album['realeaseDate'].split('T')[0]
    return {
        'name':first_album['collectionName'],
        'genre_name':first_album['primaryGenreName'],
        'release_date':release_date,
    }

    def query_all_albums(artist):
        """
        根据歌⼿名字搜索所有专辑列表
        :param artist: 歌⼿名字
        :return: 专辑列表,List[Dict]
        :raises: 获取专辑失败时抛出 GetAlbumsError
        """
        resp = requests.get(
            ITUNES_API_ENDPOINT,
            {
                'term': artist,
                'media': 'music',
                'entity': 'album',
                'attribute': 'artistTerm',
                'limit': 200,
            },
        )
        try:
            resp.raise_for_status()
        except HTTPError as e:
            raise QueryAlbumsError(f'failed to call iTunes API, {e}')
        try:
            albums = resp.json()['results']
        except JSONDecodeError:
            raise QueryAlbumsError('response is not valid JSON format')
        if not albums:
            raise QueryAlbumsError(f'no albums found for artist "{artist}"')
        return albums

在新代码中,旧的主函数被拆分成了三个不同的函数。

  • command_first_album():程序主⼊⼝,对应⽤⼾界⾯层。
  • get_first_album():获取第⼀张专辑,对应“第⼀张专辑”层。
  • query_all_albums():调⽤ API 获取数据,对应专辑数据层。

优先使用列表推导式

函数式编程是⼀种编程⻛格,它最⼤的特征,就是通过组合⼤量没有副作⽤的“纯函数”来实现复杂的功能。如果你想在 Python 中实践函数式编程,最常⽤的⼏个⼯具如下所⽰。

  • map(func, iterable):遍历并执⾏ func 获取结果,迭代返回新结果。
  • filter(func, iterable):遍历并使⽤ func 测试成员,仅当结果为真时返回。
  • lambda:定义⼀个⼀次性使⽤的匿名函数。

举个例⼦,假如你想获取所有处于活跃状态的⽤⼾积分,代码可以这么写:

points = list(map(query_points,filter(lambda user:user.is_active(),users)))

但⽐起上⾯这种 map 套 filter 的写法,我们其实完全可以使⽤列表推导式来搞定这个问题:

points = [query_points(user) for user in users if user.is_active()]

在⼤多数情况下,相⽐函数式编程,使⽤列表推导式的代码通常更短,⽽且描述性更强。所以,当列表推导式可以满⾜需求时,请优先使⽤它吧。

你没那么需要lambda

Python 中有⼀类特殊的函数:匿名函数。你可以⽤ lambda 关键字来快速定义⼀个匿名函数,⽐如 lambda x, y: x + y。匿名函数最常⻅的⽤途就是作为 sorted() 函数的排序参数使⽤

对于任何递归代码来说,⼀劳永逸的办法是将其改写成循环。

总结

  1. 函数参数与返回相关基础知识
  • 不要使⽤可变类型作为参数默认值,⽤ None 来代替
  • 使⽤标记对象,可以严格区分函数调⽤时是否提供了某个参数
  • 定义仅限关键字参数,可以强制要求调⽤⽅提供参数名,提升可读性
  • 函数应该拥有稳定的返回类型,不要返回多种类型
  • 适合返回 None 的情况——操作类函数、查询类函数表⽰意料之中的缺失值
  • 在执⾏失败时,相⽐返回 None,抛出异常更为合适
  • 如果提前返回结果可以提升可读性,就提前返回,不必追求“单⼀出⼝”
  1. 代码可维护性技巧
  • 不要编写太⻓的函数,但⻓度并没有标准,65 ⾏算是⼀个危险信号
  • 圈复杂度是评估函数复杂程度的常⽤指标,圈复杂度超过 10 的函数需要重构
  • 抽象与分层思想可以帮我们更好地构建与管理复杂的系统
  • 同⼀个函数内的代码应该处在同⼀抽象级别
  1. 函数与状态
  • 没有副作⽤的⽆状态纯函数易于理解,容易维护,但⼤多数时候“状态”不可避免
  • 避免使⽤全局变量给函数增加状态
  • 当函数状态较简单时,可以使⽤闭包技巧
  • 当函数需要较为复杂的状态管理时,建议定义类来管理状态
  1. 语言机制对函数的影响
  • functools.partial() 可以⽤来快速构建偏函数
  • functools.lru_cache() 可以⽤来给函数添加缓存
  • ⽐起 map 和 filter,列表推导式的可读性更强,更应该使⽤
  • lambda 函数只是⼀种语法糖,你可以使⽤ operator 模块等⽅式来替代它
  • Python 语⾔⾥的递归限制较多,可能的话,请尽量使⽤循环来替代

装饰器

作为最流⾏的 Web 开发框架,Django 提供了⾮常强⼤的功能。它有⼀个清晰的 MTV(model-template-view,模型—模板—视图)分层架构和开箱即⽤的 ORM 引擎,以及丰富到令⼈眼花缭乱的可配置项

装饰器是⼀种通过包装⽬标函数来修改其⾏为的特殊⾼阶函数,绝⼤多数装饰器是利⽤函数的闭包原理实现的。

先进⾏⼀次调⽤,传⼊装饰器参数,获得第⼀层内嵌函数 decorator 进⾏第⼆次调⽤,获取第⼆层内嵌函数 wrapper

在应⽤有参数装饰器时,⼀共要做两次函数调⽤,所以装饰器总共得包含三层嵌套函数。正因为如此,有参数装饰器的代码⼀直都难写、难读。

使用functools.wraps()修饰包装函数

在装饰器包装⽬标函数的过程中,常会出现⼀些副作⽤,其中⼀种是丢失函数元数据。

⾸先,由 calls_counter 对函数进⾏包装,此时的 random_sleep 变成了新的包装函数,包含 print_counter 属性

使⽤ timer 包装后,random_sleep 变成了 timer 提供的包装函数,原包装函数额外的 print_counter 属性被⾃然地丢掉了

要解决这个问题,我们需要在装饰器内包装函数时,保留原始函数的额外属性。⽽ functools模块下的 wraps() 函数正好可以完成这件事情。

添加 @wraps(wrapped) 来装饰 decorated 函数后,wraps() ⾸先会基于原函数func 来更新包装函数 decorated 的名称、⽂档等内置属性,之后会将 func 的所有额外属性赋值到 decorated 上

正因为如此,在编写装饰器时,切记使⽤ @functools.wraps() 来修饰包装函数。

实现可选参数装饰器

有参数装饰器的这个特点提⾼了它的使⽤成本——如果使⽤者忘记添加那对括号,程序就会出错

那么有没有什么办法,能让我们省去那对括号,直接使⽤ @delayed_start 这种写法呢?答案是肯定的,利⽤仅限关键字参数,你可以很⽅便地做到这⼀点。

代码清单 8-5 定义了可选参数的装饰器 delayed_start

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def delayed_start(func=None,*,duration = 1):
    """装饰器:在执⾏被装饰函数前,等待⼀段时间
    :param duration: 需要等待的秒数
    """
    def decorator(_func):
        def wrapper(*args,**kwargs):
            print(f'Wait for {duration} second before starting...')
            time.sleep(duration)
            return _func(*args, **kwargs)
        
        return wrapper

    if func is None:
        return decorator
    else:
        return decorator(func)

❶ 把所有参数都变成提供了默认值的可选参数 ❷ 当 func 为 None 时,代表使⽤⽅提供了关键字参数,⽐如 @delayed_start(duration=2),此时返回接收单个函数参数的内层⼦装饰器 decorator ❸ 当位置参数 func 不为 None 时,代表使⽤⽅没提供关键字参数,直接⽤了⽆括号的 @ delayed_start 调⽤⽅式,此时返回内层包装函数 wrapper

把参数变为可选能有效降低使⽤者的⼼智负担,让装饰器变得更易⽤。标准库dataclasses 模块⾥的 @dataclass 装饰器就使⽤了这个⼩技巧

用类来实现装饰器(函数替换)

绝⼤多数情况下,我们会选择⽤嵌套函数来实现装饰器,但这并⾮构造装饰器的唯⼀⽅式。事实上,某个对象是否能通过装饰器(@decorator)的形式使⽤只有⼀条判断标准,那就是decorator 是不是⼀个可调⽤的对象

函数⾃然是可调⽤对象,除此之外,类同样也是可调⽤对象

使⽤ callable() 内置函数可以判断某个对象是否可调⽤

如果⼀个类实现了 call 魔法⽅法,那么它的实例也会变成可调⽤对象:

调⽤类实例时,可以像调⽤普通函数⼀样提供额外参数

基于类的这些特点,我们完全可以⽤它来实现装饰器。

如果按装饰器⽤于替换原函数的对象类型来分类,类实现的装饰器可分为两种,⼀种是“函数替换”,另⼀种是“实例替换”。下⾯我们先来看⼀下前者。

函数替换装饰器虽然是基于类实现的,但⽤来替换原函数的对象仍然是个普通的包装函数。这种技术最适合⽤来实现接收参数的装饰器。

⽤类实现的 timer 装饰器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class timer:
    """
    装饰器:打印函数耗时
    :param print_args: 是否打印⽅法名和参数,默认为 False
    """

    def __init__(self,print_args):
        self.print_args = print_args

    def __call__(self,func):
        @wrap(func)
        def decorated(*args,**kwargs):
            st = time.perf_counter()
            ret = func(*args,**kwargs)
            if self.print_args:
                print(f'"{func.__name__}", args: {args}, kwargs: {kwargs}')
            print('time cost: {} seconds'.format(time.perf_counter() - st))
            return ret

        return decorated

还记得我之前说过,有参数装饰器⼀共得提供两次函数调⽤吗?通过类实现的装饰器,其实就是把原本的两次函数调⽤替换成了类和类实例的调⽤。

(1) 第⼀次调⽤:_deco = timer(print_args=True) 实际上是在初始化⼀个 timer 实例。 (2) 第⼆次调⽤:func = _deco(func) 是在调⽤ timer 实例,触发 call ⽅法。

相⽐三层嵌套的闭包函数装饰器,上⾯这种写法在实现有参数装饰器时,代码更清晰⼀些,⾥⾯的嵌套也少了⼀层。不过,虽然装饰器是⽤类实现的,但最终⽤来替换原函数的对象,仍然是⼀个处在 call ⽅法⾥的闭包函数 decorated。

用类来实现装饰器(实例替换)

和“函数替换”装饰器不⼀样,“实例替换”装饰器最终会⽤⼀个类实例来替换原函数。通过组合不同的⼯具,它既能实现⽆参数装饰器,也能实现有参数装饰器。

实现无参数装饰器

⽤类来实现装饰器时,被装饰的函数 func 会作为唯⼀的初始化参数传递到类的实例化⽅法 init 中。同时,类的实例化结果——类实例(class instance),会作为包装对象替换原始函数。

实例替换的⽆参数装饰器 DelayedStart

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class DelayedStart:
    """在执⾏被装饰函数前,等待 1 秒钟"""

    def __init__(self,func):
        update_wrapper(self,func)
        self.func = func

    def __call__(self,*args,**kwargs):
        print(f'Wait for 1 second before starting...')
        time.sleep(1)
        return self.func(*args,**kwargs)

    def eager_call(self,*args,**kwargs):
        """跳过等待,立刻执行被装饰函数"""
        print('Call without delay')
        return self.func(*args,**kwargs)

❶ update_wrapper 与前⾯的 wraps ⼀样,都是把被包装函数的元数据更新到包装者(在这⾥是 DelayedStart 实例)上 ❷ 通过实现 call ⽅法,让 DelayedStart 的实例变得可调⽤,以此模拟函数的调⽤⾏为 ❸ 为装饰器类定义额外⽅法,提供更多样化的接⼝

❶ 被装饰的 hello 函数已经变成了装饰器类 DelayedStart 的实例,但是因为 update_wrapper 的作⽤,这个实例仍然保留了被装饰函数的元数据 ❷ 此时触发的其实是装饰器类实例的 call ⽅法 ❸ 使⽤额外的 eager_call 接⼝调⽤函数

实现有参数装饰器

同普通装饰器⼀样,“实例替换”装饰器也可以⽀持参数。为此我们需要先修改类的实例化⽅法,增加额外的参数,再定义⼀个新函数,由它来负责基于类创建新的可调⽤对象,这个新函数同时也是会被实际使⽤的装饰器。

实例替换的有参数装饰器 delayed_start

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class DelayedStart:
    """在执⾏被装饰函数前,等待⼀段时间
    :param func: 被装饰的函数
    :param duration: 需要等待的秒数
    """

    def __init__(self,func,*,duration=1):
        update_wrapper(self,func)
        self.func = func
        self.duration = duration

    def __call__(self,*args,**kwargs):
        print(f'Wait for {self.duration} second before starting...')
        time.sleep(self.duration)
        return self.func(*args, **kwargs)

    def eager_call(self,*args,*kwargs):...

def delayed_start(**kwargs):
    """装饰器:推迟某个函数的执⾏"""
    return functools.partial(DelayedStart,**kwargs)

❶ 把 func 参数以外的其他参数都定义为“仅限关键字参数”,从⽽更好地区分原始函数与装饰器的其他参数 ❷ 通过 partial 构建⼀个新的可调⽤对象,这个对象接收的唯⼀参数是待装饰函数func,因此可以⽤作装饰器

相⽐传统做法,⽤类来实现装饰器(实例替换)的主要优势在于,你可以更⽅便地管理装饰器的内部状态,同时也可以更⾃然地为被装饰对象追加额外的⽅法和属性。

使用wrapt模块助力装饰器编写

我实现了⼀个⾃动注⼊函数参数的装饰器 provide_number,它在装饰函数后,会在后者被调⽤时⾃动⽣成⼀个随机数,并将其注⼊为函数的第⼀个位置参数。

注入数字的装饰器provide_number

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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) … print_random_number() 57

类⽅法(method)和函数(function)在⼯作机制上有细微的区别。当类实例⽅法被调⽤时,第⼀个位置参数总是当前绑定的类实例 self 对象。因此,当装饰器向 *args 前追加随机数时,其实已经把 *args ⾥的 self 挤到了 num 参数所在的位置

为了修复这个问题,provide_number 装饰器在追加位置参数时,必须聪明地判断当前被修饰的对象是普通函数还是类⽅法。假如被修饰的对象是类⽅法,那就得跳过藏在 *args ⾥的类实例变量,才能正确将 num 作为第⼀个参数注⼊。 假如要⼿动实现这个判断,装饰器内部必须增加⼀些烦琐的兼容代码,费⼯费时。幸运的是,wrapt 模块可以帮我们轻松处理好这类问题。 wrapt 是⼀个第三⽅装饰器⼯具库,利⽤它,我们可以⾮常⽅便地改造 provide_number 装饰器,完美地解决这个问题。

基于 wrapt 模块实现的 provide_number 装饰器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 wrapped

使⽤ wrapt 模块编写的装饰器,除了解决了类⽅法兼容问题以外,代码嵌套层级也⽐普通装饰器少,变得更扁平、更易读。如果你有兴趣,可以参阅 wrapt 模块的官⽅⽂档了解更多信息。

了解装饰器的本质优势

装饰器带来的改变,主要在于把修改函数的调⽤提前到了函数定义处,⽽这⼀点⼉位置上的⼩变化,重塑了读者理解代码的整个过程。

所以,装饰器的优势并不在于它提供了动态修改函数的能⼒,⽽在于它把影响函数的装饰⾏为移到了函数头部,降低了代码的阅读与理解成本。

“动态修改函数”的能⼒,其实并不是由装饰器提供的。假如没有装饰器,我们也能在定义完函数后,⼿动调⽤装饰函数来修改它。

为了充分发挥这个优势,装饰器特别适合⽤来实现以下功能。

  1. 运行时校验:在执行阶段进行特定校验,当校验不通过时终止执行 适合原因:装饰器可以⽅便地在函数执⾏前介⼊,并且可以读取所有参数辅助校验。 代表样例:Django 框架中的⽤⼾登录态校验装饰器 @login_required。

  2. 注入额外参数:在函数被调⽤时⾃动注⼊额外的调⽤参数。 适合原因:装饰器的位置在函数头部,⾮常靠近参数被定义的位置,关联性强。 代表样例:unittest.mock 模块的装饰器 @patch。

  3. 缓存执⾏结果:通过调⽤参数等输⼊信息,直接缓存函数执⾏结果。 适合原因:添加缓存不需要侵⼊函数内部逻辑,并且功能⾮常独⽴和通⽤。 代表样例:functools 模块的缓存装饰器 @lru_cache。

  4. 注册函数:将被装饰函数注册为某个外部流程的一部分 适合原因:在定义函数时可以直接完成注册,关联性强。 代表样例:Flask 框架的路由注册装饰器 @app.route。

  5. 替换为复杂对象:将原函数(⽅法)替换为更复杂的对象,⽐如类实例或特殊的描述符对象 适合原因:在执⾏替换操作时,装饰器语法天然⽐ foo = staticmethod(foo) 的写法要直观得多。 代表样例:静态类⽅法装饰器 @staticmethod。

在设计新的装饰器时,你可以先参考上⾯的常⻅装饰器功能列表,琢磨琢磨⾃⼰的设计是否能很好地发挥装饰器的优势。切勿滥⽤装饰器技术,设计出⼀些天⻢⾏空但难以理解的 API。吸取前⼈经验,同时在设计上保持克制,才能写出更好⽤的装饰器。

使用类装饰器替代元类

Python 中的元类(metaclass)是⼀种特殊的类。就像类可以控制实例的创建过程⼀样,元类可以控制类的创建过程。通过元类,我们能实现各种强⼤的功能。⽐如下⾯的代码就利⽤元类统⼀注册所有 Validator 类: 元类是类的类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
_validators = {}

class ValidatorMeta(type):
    """元类:统一注册所有校验器类,方便后续使用"""

    def __new__(cls,name,bases,attrs):
        ret = super().__new__(cls,name,bases,attrs)
        _validators[attrs['name']] = ret
        return ret

class StringValidator(metaclass=ValidatiorMeta)
    name = 'string'

class IntegerValidator(metaclass=ValidatorMeta)
    name = 'int'

类装饰器的⼯作原理与普通装饰器类似。下⾯的代码就⽤类装饰器实现了 ValidatorMeta 元 类的功能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def register(cls):
    """装饰器:统一注册所有校验器类:方便后续使用"""
    _validators[cls.name] = cls
    return cls

@register
class StringValidator:
    name = 'string'

@register
class IntegerValidator:
    name = 'int'

别弄混装饰器模式和装饰器

装饰器模式属于⾯向对象领域。实现装饰器模式,需要具备以下关键要素: 设计⼀个统⼀的接⼝; 编写多个符合该接⼝的装饰器类,每个类只实现⼀个简单的功能; 通过组合的⽅式嵌套使⽤这些装饰器类; 通过类和类之间的层层包装来实现复杂的功能。

代码清单 8-11 是我⽤ Python 实现的⼀个简单的装饰器模式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Numbers:
    """一个包含多个数字的简单类"""

    def __init__(self,numbers):
        self.numbers = numbers

    def get(self):
        return self.numbers

class EvenOnlyDecorator:
    """装饰器类:过滤所有偶数"""

    def __init__(self,decorated):
        self.decorated = decorated

    def get(self):
        return  [num for num in self.decorated.get() if num % 2 == 0]

class GreaterThanDecorator:
    """装饰器类:过滤大于某个数的数"""

    def __init__(self,decorated,min_value):
        self.decorated = decorated
        self.min_value = min_value

    def get(self):
        return [num for num in self.decorated.get() if num > self.min_value]

obj = Numbers([42, 12, 13, 17, 18, 41, 32])
even_obj = EvenOnlyDecorator(obj)
gt_obj = GreaterThanDecorator(even_obj,min_value=30)
print(gt_obj.get())
>> [42, 32]

从上⾯的代码中你能发现,装饰器模式和 Python ⾥的装饰器毫不相⼲。如果硬要找⼀点⼉联 系,它俩可能都和“包装”有关——⼀个包装函数,另⼀个包装类。

所以,请不要混淆装饰器和装饰器模式,它们只是名字⾥刚好都有“装饰器”⽽已。

浅装饰器,深实现

归根结底,装饰器其实只是⼀类特殊的 API,⼀种提供服务的⽅式。⽐起把所有核⼼逻辑都放在装饰器内,不如让装饰器⾥只有⼀层浅浅的包装层,⽽把更多的实现细节放在其他函数或类中。 这样做之后,假如你未来需要为模块增加装饰器以外的其他 API,⽐如上下⽂管理器,就会发现⾃⼰之前写的⼤部分核⼼代码仍然可以复⽤,因为它们并没有和装饰器耦合。

总结

  1. 基础与技巧 装饰器最常见的实现方式,是利用闭包原理通过多层嵌套函数实现 在实现装饰器时,请记得使用wrap()更新包装函数的元数据 wraps()不光可以保留元数据,还能保留包装函数的额外属性

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

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

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

面向对象编程

实例内容都在字典⾥

我提到 Python 语⾔内部⼤量使⽤了字典类型,⽐如⼀个类实例的所有成员,其实都保存在了⼀个名为 dict 的字典属性中

❶ 实例的 dict ⾥,保存着当前实例的所有数据 ❷ 类的 dict ⾥,保存着类的⽂档、⽅法等所有数据

虽然普通的属性赋值会被 setattr 限制,但如果你直接操作实例的 dict 字典,就可以⽆视这个限制:

在某些特殊场景下,合理利⽤ dict 属性的这个特性,可以帮你完成常规做法难以做到、的⼀些事情。

内置类方法装饰器

类方法

不过,虽然普通⽅法⽆法通过类来调⽤,但你可以⽤ @classmethod 装饰器定义⼀种特殊的⽅法:类⽅法(class method),它属于类但是⽆须实例化也可调⽤。

普通⽅法接收类实例(self)作为参数,但类⽅法的第⼀个参数是类本⾝,通常使⽤名字 cls

作为⼀种特殊⽅法,类⽅法最常⻅的使⽤场景,就是像上⾯⼀样定义⼯⼚⽅法来⽣成新实例。类⽅法的主⻆是类型本⾝,当你发现某个⾏为不属于实例,⽽是属于整个类型时,可以考虑使⽤类⽅法。

静态方法

如果你发现某个⽅法不需要使⽤当前实例⾥的任何内容,那可以使⽤ @staticmethod 来定义⼀个静态⽅法。

静态⽅法不接收当前实例作为第⼀个位置参数

和普通⽅法相⽐,静态⽅法不需要访问实例的任何状态,是⼀种与状态⽆关的⽅法,因此静态⽅法其实可以改写成脱离于类的外部普通函数。

选择静态⽅法还是普通函数,可以从以下⼏点来考虑: 如果静态⽅法特别通⽤,与类关系不⼤,那么把它改成普通函数可能会更好; 如果静态⽅法与类关系密切,那么⽤静态⽅法更好; 相⽐函数,静态⽅法有⼀些先天优势,⽐如能被⼦类继承和重写等。

属性装饰器

在⼀个类⾥,属性和⽅法有着不同的职责:属性代表状态,⽅法代表⾏为。⼆者对外的访问接⼝也不⼀样,属性可以通过 inst.attr 的⽅式直接访问,⽽⽅法需要通过inst.method() 来调⽤。

@property 除了可以定义属性的读取逻辑外,还⽀持⾃定义写⼊和删除逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class FilePath:
    @property
    def basename(self):
        """获取文件名"""
        return self.path.rsplit(os.sep,1)[-1]

    @basename.setter
    def basename(self,name):
        """修改当前路径里的文件名部分"""
        new_path = self.path.rsplit(os.sep,1)[:-1] + [name]
        self.path = os.sep.join(new_path)

    @basename.deleter
    def basename(self):
        raise RuntimeError('Can not delete basename!')

❶ 经过 @property 的装饰以后,basename 已经从⼀个普通⽅法变成了 property对象,因此这⾥可以使⽤ basename.setter ❷ 定义 setter ⽅法,该⽅法会在对属性赋值时被调⽤ ❸ 定义 deleter ⽅法,该⽅法会在删除属性时被调⽤

@property 是个⾮常有⽤的装饰器,它让我们可以基于⽅法定义类属性,精确地控制属性的读取、赋值和删除⾏为,灵活地实现动态属性等功能。

⼈们在读取属性时,总是期望能迅速拿到结果,调⽤⽅法则不⼀样——快点⼉慢点⼉都⽆所谓。让⾃⼰设计的接⼝符合他⼈的使⽤预期,也是写代码时很重要的⼀环。

鸭子类型及其局限性

⾸先,鸭⼦类型不推荐做类型检查,因此编码者可以省去⼤量与之相关的烦琐⼯作。其次,鸭⼦类型只关注对象是否能完成某件事,⽽不对类型做强制要求,这⼤⼤提⾼了代码的灵活性

你甚⾄可以从零开始⾃⼰实现⼀个新类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class StringList:
    """用于保存多个字符串的数据类,实现了read()和可迭代接口"""

    def __init__(self,string):
        self.strings = strings

    def read(self):
        return ''.join(self.strings)

    def __iter__(self):
        for s in self.strings:
            yield s

虽然上⾯的 StringList 类和⽂件类型⼋竿⼦打不着,但是因为 count_vowels() 函数遵循了鸭⼦类型编程⻛格,⽽ StringList 恰好实现了它所需要的接⼝,因此 StringList 对象也可以完美适⽤于 count_vowels 函数:

鸭⼦类型的第⼀个缺点是:缺乏标准。在编写鸭⼦类型代码时,虽然我们不需要做严格的类型校验,但是仍然需要频繁判断对象是否⽀持某个⾏为,⽽这⽅⾯并没有统⼀的标准

鸭⼦类型的另⼀个问题是:过于隐式。在鸭⼦类型编程⻛格下,对象的真实类型变得不再重要,取⽽代之的是对象所提供的接⼝(或者叫协议)变得⾮常重要。但问题是,鸭⼦类型⾥的所有接⼝和协议都是隐式的,它们全藏在代码和函数的注释中

综合考虑了鸭⼦类型的种种特点后,你会发现,虽然这⾮常有效和实⽤,但有时也会让⼈觉得过于灵活、缺少规范。尤其是在规模较⼤的 Python 项⽬中,如果代码⼤量使⽤了鸭⼦类型,编码者就需要理解很多隐式的接⼝与规则,很容易不堪重负。

幸运的是,除了鸭⼦类型以外,Python 还为类型系统提供了许多有效的补充,⽐如类型注解与静态检查(mypy)、抽象类(abstract class)等。

抽象类

抽象类的子类化机制

为了演⽰这个机制,我把前⾯的 Validator 改造成了⼀个抽象类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from abc import ABC

class Validator(ABC):
    """校验器抽象类"""

    @classmethod
    def __subclasshook__(cls,C):
        """任何提供了validate方法的类,都被当作Validator的子类"""
        if any("validate" in B.__dict__ for B in C.__mro__):
            return True
        return NotImplemented

    def validate(self,value):
        raise NotImplementedError

❶ 要定义⼀个抽象类,你需要继承 ABC 类或使⽤ abc.ABCMeta 元类 ❷ C.mro 代表 C 的类派⽣路线上的所有类(⻅ 9.1.5 节)

上⾯代码的重点是 subclasshook 类⽅法。subclasshook 是抽象类的⼀个特殊⽅法,当你使⽤ isinstance 检查对象是否属于某个抽象类时,如果后者定义了这个⽅法,那么该⽅法就会被触发,然后: 实例所属类型会作为参数传⼊该⽅法(上⾯代码中的 C 参数); 如果⽅法返回了布尔值,该值表⽰实例类型是否属于抽象类的⼦类; 如果⽅法返回 NotImplemented,本次调⽤会被忽略,继续进⾏正常的⼦类判断逻辑。

在我编写的 Validator 类中,subclasshook ⽅法的逻辑是:所有实现了validate ⽅法的类都是我的⼦类。

通过 subclasshook 类⽅法,我们可以定制抽象类的⼦类判断逻辑。这种⼦类化形式只关⼼结构,不关⼼真实继承关系,所以常被称为“结构化⼦类”。

这也是之前的 ThreeFactory 类能通过 Iterable 类型校验的原因,因为 Iterable 抽象类对⼦类只有⼀个要求:实现了 iter ⽅法即可。

除了通过 subclasshook 类⽅法来定义动态的⼦类检查逻辑外,你还可以为抽象类⼿动注册新的⼦类。

❶ 默认情况下,Foo 类和 Validator 类没有任何关系 ❷ 调⽤ .register() 把 Foo 注册为 Validator 的⼦类 ❸ 完成注册后,Foo 类的实例就能通过 Validator 的类型校验了

总结⼀下,抽象类通过 subclasshook 钩⼦和 .register() ⽅法,实现了⼀种⽐继承更灵活、更松散的⼦类化机制,并以此改变了 isinstance() 的⾏为。

有了抽象类以后,我们便可以使⽤ isinstance(obj, type) 来进⾏鸭⼦类型编程⻛格的类型校验了。只要待匹配类型 type 是抽象类,类型检查就符合鸭⼦类型编程⻛格——只校验⾏为,不校验类型。

抽象类的其他功能

除了更灵活的⼦类化机制外,抽象类还提供了⼀些其他功能。⽐如,利⽤ abc 模块的 @abstractmethod 装饰器,你可以把某个⽅法标记为抽象⽅法。假如抽象类的⼦类在继承时,没有重写所有抽象⽅法,那么它就⽆法被正常实例化。

这个机制可以帮我们更好地控制⼦类的继承⾏为,强制要求其重写某些⽅法。

此外,虽然抽象类名为抽象,但它也可以像任何普通类⼀样提供已实现好的⾮抽象⽅法。⽐如collections.abc 模块⾥的许多抽象类(如 Set、Mapping 等)像普通基类⼀样实现了⼀些公⽤⽅法,降低了⼦类的实现成本

最后,我们总结⼀下鸭⼦类型和抽象类:

  • 鸭⼦类型是⼀种编程⻛格,在这种⻛格下,代码只关⼼对象的⾏为,不关⼼对象的类型;
  • 鸭⼦类型降低了类型校验的成本,让代码变得更灵活;
  • 传统的鸭⼦类型⾥,各种对象接⼝和协议都是隐式的,没有统⼀的显式标准;
  • 普通的 isinstance() 类型检查和鸭⼦类型的理念是相违背的;
  • 抽象类是⼀种特殊的类,它可以通过钩⼦⽅法来定制动态的⼦类检查⾏为;
  • 因为抽象类的定制⼦类化特性,isinstance() 也变得更灵活、更契合鸭⼦类型了;
  • 使⽤ @abstractmethod 装饰器,抽象类可以强制要求⼦类在继承时重写特定⽅法;
  • 除了抽象⽅法以外,抽象类也可以实现普通的基础⽅法,供⼦类继承使⽤;
  • 在 collections.abc 模块中,有许多与容器相关的抽象类。

多重继承与MRO

在解决多重继承的⽅法优先级问题时,Python 使⽤了⼀种名为 MRO(method resolutionorder)的算法。该算法会遍历类的所有基类,并将它们按优先级从⾼到低排好序。

基于 MRO 算法的基类优先级列表,不光定义了类⽅法的找寻顺序,还影响了另⼀个常⻅的内置函数:super()。

在许多⼈的印象中,super() 是⼀个⽤来调⽤⽗类⽅法的⼯具函数。但这么说并不准确,super() 使⽤的其实不是当前类的⽗类,⽽是它在 MRO 链条⾥的上⼀个类。

⼤多数情况下,你需要的并不是多重继承,⽽也许只是⼀个更准确的抽象模型,在该模型下,最普通的继承关系就能完美解决问题。

Mixin模式

顾名思义,Mixin 是⼀种把额外功能“混⼊”某个类的技术。有些编程语⾔(⽐如 Ruby)为Mixin 模式提供了原⽣⽀持,⽽在 Python 中,我们可以⽤多重继承来实现 Mixin 模式。 要实现 Mixin 模式,你需要先定义⼀个 Mixin 类:

1
2
3
4
5
6
7
8
9
class InfoDumperMixin:
    """Mixin:输出当前实例信息"""

    def dump_info(self):
        d = self.__dict__
        print("Number of members: {}".format(len(d)))
        print("Details:")
        for key,value in d.items():
            print(f' - {key}: {value}')

Mixin 类名常以“Mixin”结尾,这算是⼀种不成⽂的约定

相⽐普通类,Mixin 类有⼀些鲜明的特征。

Mixin 类通常很简单,只实现⼀两个功能,所以很多时候为了实现某个复杂功能,⼀个类常常会同时混⼊多个 Mixin 类。另外,⼤多数 Mixin 类不能单独使⽤,它们只有在被混⼊其他类时才能发挥最⼤作⽤

下⾯是⼀个使⽤ InfoDumperMixin 的例⼦:

1
2
3
4
class Person(InfoDumperMixin):
    def __init__(self,name,age):
        self.name = name
        self.age = age
1
2
3
4
5
6
>>> p = Person('jack',20)
>>> p.dump_info()
Number of members: 2
Details:
    - name: jack
    - age: 20

虽然 Python 中的 Mixin 模式基于多重继承实现,但令 Mixin 区别于普通多重继承的最⼤原因在于:Mixin 是⼀种有约束的多重继承。在 Mixin 模式下,虽然某个类会同时继承多个基类,但⾥⾯最多只会有⼀个基类表⽰真实的继承关系,剩下的都是⽤于混⼊功能的Mixin 类。这条约束⼤⼤降低了多重继承的潜在危害性

许多流⾏的 Web 开发框架使⽤了 Mixin 模式,⽐如 Django、DRF 等

假如你想使⽤ Mixin 模式,需要精⼼设计 Mixin 类的职责,让它们和普通类有所区分,这样才能让 Mixin 模式发挥最⼤的潜⼒

元类

元类是 Python 中的⼀种特殊对象。元类控制着类的创建⾏为,就像普通类控制着实例的创建⾏为⼀样。

type 是 Python 中最基本的元类,利⽤ type,你根本不需要⼿动编写 class … : 代码来创建⼀个类——直接调⽤ type() 就⾏:

1
2
3
4
5
Foo = type('Foo',(),{'bar':3})
Foo
<class '__main__.Foo'>
>>> Foo().bar
3

在调⽤ type() 创建类时,需要提供三个参数,它们的含义如下。

  • name:str,需要创建的类名
  • bases:tuple[Type],包含其他类的元组,代表类的所有基类
  • attrs:Dict[str,Any],包含所有类成员(属性,方法)的字典

虽然 type 是最基本的元类,但在实际编程中使⽤它的场景其实⽐较少。更多情况下,我们会创建⼀个继承 type 的新元类,然后在⾥⾯定制⼀些与创建类有关的⾏为。

为了演⽰元类能做什么,代码清单 9-3 实现了⼀个简单的元类,它的主要功能是将类⽅法⾃动转换成属性对象。另外,该元类还会在创建实例时,为其增加⼀个代表创建时间的created_at 属性。

⽰例元类 AutoPropertyMeta

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import time
import types

class AutoPropertyMeta(type):
    """元类:

    - 把所有类方法变成动态属性
    - 为所有实例增加创建时间属性
    """

    def __new__(cls,name,bases,attrs):
        for key,value in attrs.items():
            if isinstance(value,types.FunctionType) and not key.startswith('_'):
                attrs[key] = property(value)
        return super().__new__(cls,name,bases,attrs)

    def __call__(cls,*args,**kwargs)
        inst = super().__call__(*args,**kwargs)
        inst.created_at = time.time()
        return inst

元类通常会继承基础元类 type 对象 元类的 new ⽅法会在创建类时被调⽤ 将⾮私有⽅法转换为属性对象 调⽤ type() 完成真正的类创建 元类的 call ⽅法,负责创建与初始化类实例

下⾯的 Cat 类使⽤了 AutoPropertyMeta 元类:

1
2
3
4
5
6
7
8
9
import random

class Cat(metaclass=AutoPropertyMeta):
    def __init__(self,name):
        self.name = name

    def sound(self):
        repeats = random.randrange(1,10)
        return ' '.join(['Meow'] * repeats)

效果如下:

milo = Cat(‘milo’) milo.sound ❶ ‘Meow Meow Meow Meow Meow Meow Meow’ milo.created_at ❷ 1615000104.0704262

sound 原本是⽅法,但是被元类⾃动转换成了属性对象 读取由元类定义的创建时间

通过上⾯这个例⼦,你会发现元类的功能相当强⼤,它不光可以修改类,还能修改类的实例。

同时它也相当复杂,⽐如在例⼦中,我只简单演⽰了元类的 newcall ⽅法,除此之外,元类其实还有⽤来准备类命名空间的 prepare ⽅法

和 Python ⾥的其他功能相⽐,元类是个相当⾼级的语⾔特性。通常来说,除⾮要开发⼀些框架类⼯具,否则你在⽇常⼯作中根本不需要⽤到元类。

元类是⼀种深奥的“魔法”,99% 的⽤⼾不必为之操⼼。如果你在琢磨是否需要元类,那你肯定不需要(那些真正要使⽤元类的⼈确信⾃⼰的需求,⽽⽆须解释缘由)

元类很少被使⽤的原因,除了应⽤场景少以外,还在于它其实有许多“替代品”,它们是:

  • 类装饰器
  • init_subclass 钩⼦⽅法
  • 描述符

继承是把双刃剑

Unique Visitor 的⾸字⺟缩写,表⽰访问⽹站的独⽴访客。对于如何统计独⽴访客,常⻅的算法是把每个注册⽤⼾算作⼀个独⽴访客,或者把每个 IP 地址算作⼀个独⽴访客

⼩ Y ⾛后,⼩ R 开始写起了代码。要基于⽇志来统计每天的 UV 数,程序⾄少需要做到这⼏件事:获取⽇志内容、解析⽇志、完成统计

代码清单 9-4 统计某⽇ UV 数的类 UniqueVisitorAnalyzer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class UniqueVisitorAnalyzer:
    """统计某日UV数
    
    :param date:需要统计的日期
    """

    def __init__(self,date)
        self.date = date

    def analyze(self):
        """通过解析与分析API访问日志,返回UV数
        
        :return:uv数
        """
        for entry in self.get_log_entries():
            ... # 省略:根据 entry.user_id 统计 UV 数并返回结果

    def get_log_entries(self):
        """获取当天所有日志记录"""
        for line in self.read_log_lines():
            yield self.parse_log(line)

    def read_log_lines(self):
        """逐⾏获取访问⽇志"""
        ... # 省略:根据⽇志 self.date 读取⽇志⽂件并返回结果

    def parse_log(self,line):
        """将纯文本格式的日志解析为结构化对象

        :param line:纯文本格式日志
        :return: 结构化的日志条目LogEntry对象
        """

        ...  # 省略:复杂的⽇志解析过程
        return LogEntry(
            time=...,
            ip=...,
            path=...,
            user_agent=...,
            user_id=...,
        )

所以,⼩ R 决定通过继承来复⽤ UniqueVisitorAnalyzer 类⾥的⽇志读取和解析逻辑,这样他只要写很少的代码就能完成需求。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Top10CommentsAnalyzer(UniqueVisitorAnalyzer):
    """获取某日点赞量最高的10条评论

    :param date:需要统计的日期
    """

    limit = 10

    def analyze(self):
        """通过解析与统计 API 访问⽇志,返回点赞量最⾼的评论

        :return:评论ID列表
        """

        for entry in self.get_log_entries():
            comment_id = self.extract_comment_id(entry.path)
                ...  # 省略:统计过程与返回结果
        
    def extract_comment_id(self,path):
        """
        根据日志访问路径,获取评论ID
        有效的评论点赞API路径格式:/comments/<ID>/up_votes/

        :return:仅当路径是评论点赞 API 时,返回 ID,否则返回 None
        """
        matched_obj = re.match('/comments/(.*)/up_votes/'.path)
        return matched_obj and matched_obj.group(1)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import re

class UniqueVisitorAnalyzer:

    ...
    def get_log_entries(self):
        """获取当天所有日志记录"""
        for line in self.read_log_lines():
            entry = self.parse_log(line)
            if not self.match_news_pattern(entry.path):
                continue
            yield entry

    def match_news_pattern(self,path):
        """判断API路径是不是在访问新闻

        :param path: API 访问路径
        :return: bool
        """

        return re.match(r'^/news/[^/]*?/$',path)

于是⼩ R 打开统计热⻔评论的代码,很快就找到了问题的原因: alt text

①修改⽗类函数,②⼦类受到影响

但继承是⼀种类与类之间紧密的耦合关系。让⼦类继承⽗类,虽然看上去毫⽆成本地获取了⽗类的全部能⼒,但同时也意味着,从此以后⽗类的所有改动都可能影响⼦类。继承关系越复杂,这种影响就越容易超出⼈们的控制范围。

⼩ R 使⽤继承的初衷,是为了复⽤⽗类中的⽅法。但如果只是为了复⽤代码,其实没有必要使⽤继承。当⼩ R 发现新需求要⽤到 UniqueVisitorAnalyzer 类的“读取⽇志”“解析⽇志”⾏为时,他完全可以⽤组合(composition)的⽅式来解决复⽤问题。

要⽤组合来复⽤ UniqueVisitorAnalyzer 类,我们需要先分析这个类的职责与⾏为。在我看来,UniqueVisitorAnalyzer 类主要负责以下⼏件事

  • 读取日志:根据日期找到并读取日志文件
  • 解析日志:把文本日志信息解析并转化成LogEntry
  • 统计日志:统计日志,计算UV数

基于这些事情,我们可以对 UniqueVisitorAnalyzer 类进⾏拆分,把其中需要复⽤的两个⾏为创建为新的类:

LogReader 和 LogParser 两个新类,分别对应 UniqueVisitorAnalyzer 类⾥的“读取⽇志”和“解析⽇志”⾏为。

相⽐之前把所有⾏为都放在 UniqueVisitorAnalyzer 类⾥的做法,新的代码其实体现了另⼀种⾯向对象建模⽅式——针对事物的⾏为建模,⽽不是对事物本⾝建模

在多数情况下,基于事物的⾏为来建模,可以孵化出更好、更灵活的模型设计。

alt text

继承是⼀种极为紧密的耦合关系。为了避免继承惹来⿇烦,每当你想创建新的继承关系时,应该试着问⾃⼰⼏个问题。

  • 我要让 B 类继承 A 类,但 B 和 A 真的代表同⼀种东西吗?如果它俩不是同类,为什么要继承?
  • 即使 B 和 A 是同类,那它们真的需要⽤继承来表明类型关系吗?要知道,Python 是鸭⼦类型的,你不⽤继承也能实现多态
  • 如果继承只是为了让 B 类复⽤ A 类的⼏个⽅法,那么⽤组合来替代继承会不会更好?

同样是复⽤代码,组合产⽣的耦合关系⽐继承松散得多。如果组合可以达到复⽤⽬的,并且能够很好表达事物间的联系,那么常常是更好的选择。这也是⼈们常说“多⽤组合,少⽤继承”的原因

但这并不代表我们应该完全弃⽤继承。继承所提供的强⼤复⽤能⼒,仍然是组合所⽆法替代的。许多设计模式(⽐如模板⽅法模式——template method pattern)都是依托继承来实现的

使用 __init__subclass__替代元类

init_subclass 是类的⼀个特殊钩⼦⽅法,它的主要功能是在类派⽣出⼦类时,触发额外的操作。假如某个类实现了这个钩⼦⽅法,那么当其他类继承该类时,钩⼦⽅法就会被触发。

通过上⾯的例⼦,你会发现 init_subclass ⾮常适合在这种需要触达所有⼦类的场景中使⽤。⽽且同元类相⽐,钩⼦⽅法只要求使⽤者了解继承,不⽤掌握更⾼深的元类相关知识,⻔槛低了不少。它和类装饰器⼀样,都可以有效替代元类。

在分支中寻找多态的应用时机

当你发现⾃⼰的代码出现以下特征时:

  • 有许多 if/else 判断,并且这些判断语句的条件都⾮常类似;
  • 有许多针对类型的 isinstance() 判断逻辑。

你应该问⾃⼰⼀个问题:代码是不是缺少了某种抽象?如果增加这个抽象,这些分布在各处的条件分⽀,是不是可以⽤多态来表现?如果答案是肯定的,那就去找到那个抽象吧!

有序组织你的类方法

作为惯例,init 实例化⽅法应该总是放在类的最前⾯,new ⽅法同理。

公有⽅法应该放在类的前⾯,因为它们是其他模块调⽤类的⼊⼝,是类的⻔⾯,也是所有⼈最关⼼的内容。以 _ 开头的私有⽅法,⼤部分是类⾃⾝的实现细节,应该放在靠后的

⾄于类⽅法、静态⽅法和属性对象,你不必将它们区分对待,直接参考公有 / 私有的思路即可。⽐如,⼤部分类⽅法是公有的,所有它们通常会⽐较靠前。⽽静态⽅法常常是内部使⽤的私有⽅法,所以常放在靠后的位置。

以 __ 开头的魔法⽅法⽐较特殊,我通常会按照⽅法的重要程度来决定它们的位置。⽐如⼀个迭代器类的 iter ⽅法应该放在⾮常靠前的位置,因为它是构成类接⼝的重要⽅法。

最后⼀点,当你从上往下阅读类时,所有⽅法的抽象级别应该是不断降低的,就好像阅读⼀篇新闻⼀样,第⼀段是新闻的概要,之后才会描述细节。

⽤函数降低 API 使⽤成本

在 Python 中,像上⾯这种⽤函数搭配⾯向对象的代码⾮常多⻅,它有点⼉像设计模式中的外观模式(facade pattern)。在该模式中,函数作为⼀种简化 API 的⼯具,封装了复杂的⾯向对象功能,⼤⼤降低了使⽤成本。

实现预绑定方法模式

假设你在开发⼀个程序,它的所有配置项都保存在⼀个特定⽂件中。在项⽬启动时,程序需要从配置⽂件中读取所有配置项,然后将其加载进内存供其他模块使⽤。

由于程序执⾏时只需要⼀个全局的配置对象,因此你觉得这个场景⾮常适合使⽤经典设计模式:单例模式(singleton pattern)。

下⾯的代码就应⽤了单例模式的配置类 AppConfig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class AppConfig:
    """程序配置类,使用单例模式"""

    _instance = None

    def __new__(cls):
        if cls._instance is None:
            inst = super().__new__(cls)
            # 省略:从外部配置⽂件读取配置
            ...
            cls._instance = inst
        return cls._instance

    def get_database(self):
        """读取数据库配置"""
        ...

    def reload(self):
        """重新读取配置文件,刷新配置"""
        ...

在 Python 中,实现单例模式的⽅式有很多,⽽上⾯这种最为常⻅,它通过重写类的__new__ ⽅法来接管实例创建⾏为。当 new ⽅法被重写后,类的每次实例化返回的不再是新实例,⽽是同⼀个已经初始化的旧实例 cls._instance

c1 = AppConfig() c2 = AppConfig() c1 is c2 ❶ True

❶ 测试单例模式,调⽤ AppConfig() 总是会产⽣同⼀个对像

基于上⾯的设计,如果其他⼈想读取数据库配置,代码需要这样写:

1
2
3
4
5
6
from project.config import AppConfig

db_conf = AppConfig().get_database()

# 重新加载配置
AppConfig().reload()

虽然在处理这种全局配置对象时,单例模式是⼀种⾏之有效的解决⽅案,但在 Python 中,其实有⼀种更简单的做法——预绑定⽅法模式

预绑定方法模式是一种将对象方法绑定为函数的模式,要实现该模式,第一步就是完全删掉 AppConfig ⾥的单例设计模式。因为在 Python ⾥,实现单例压根⼉不⽤这么⿇烦,我们有⼀个随⼿可得的单例对象——模块(module)

当你在 Python 中执⾏ import 语句导⼊模块时,⽆论 import 执⾏了多少次,每个被导⼊的模块在内存中只会存在⼀份(保存在 sys.modules 中)。因此,要实现单例模式,只需在模块⾥创建⼀个全局对象即可

1
2
3
4
5
6
7
8
class AppConfig:
    """程序配置类,使用单例模式"""

    def __init__(self):
        # 省略:从外部配置⽂件读取配置
        ...

_config = AppConfig()

❶ 完全删掉单例模式的相关代码,只实现 init ⽅法 ❷ _config 就是我们的“单例 AppConfig 对象”,它以下划线开头命名,表明⾃⼰是⼀个私有全局变量,以免其他⼈直接操作

下⼀步,为了给其他模块提供好⽤的 API,我们需要将单例对象 _config 的公有⽅法绑定到 config模块上:

1
2
3
4
5
# file: project/config.py
_config = Config()

get_database_conf = _config.get_database
reload_config = _config.reload

之后,其他模块就可以像调⽤普通函数⼀样操作应⽤配置对象了:

1
2
3
4
from project.config import get_ddatabase_conf

db_conf = get_database_conf()
reload_config()

通过“预绑定⽅法模式”,我们既避免了复杂的单例设计模式,⼜有了更易使⽤的函数 API,可谓⼀举两得。

总结

  1. 语⾔基础知识
  • 类与实例的数据,都保存在⼀个名为 dict 的字典属性中
  • 灵活利⽤ dict 属性,能帮你做到常规做法难以完成的⼀些事情
  • 使⽤ @classmethod 可以定义类⽅法,类⽅法常⽤作⼯⼚⽅法
  • 使⽤ @staticmethod 可以定义静态⽅法,静态⽅法不依赖实例状态,是⼀种⽆状态⽅法
  • 使⽤ @property 可以定义动态属性对象,该属性对象的获取、设置和删除⾏为都⽀持⾃定义
  1. 面向对象高级特性
  • Python 使⽤ MRO 算法来确定多重继承时的⽅法优先级
  • super() 函数获取的并不是当前类的⽗类,⽽是当前 MRO 链条⾥的下⼀个类
  • Mixin 是⼀种基于多重继承的有效编程模式,⽤好 Mixin 需要精⼼的设计
  • 元类的功能相当强⼤,但同时也相当复杂,除⾮开发⼀些框架类⼯具,否则你极少需要使⽤元类
  • 元类有许多更简单的替代品,⽐如类装饰器、⼦类化钩⼦⽅法等
  • 通过定义 init_subclass 钩⼦⽅法,你可以在某个类被继承时执⾏⾃定义逻辑
  1. 鸭⼦类型与抽象类
  • 鸭鸭“鸭⼦类型”是 Python 语⾔最为鲜明的特点之⼀,在该⻛格下,⼀般不做任何严格的类型检查
  • 虽然“鸭⼦类型”⾮常实⽤,但是它有两个明显的缺点——缺乏标准和过于隐式
  • 抽象类提供了⼀种更灵活的⼦类化机制,我们可以通过定义抽象类来改变 isinstance() 的⾏为
  • 通过 @abstractmethod 装饰器,你可以要求抽象类的⼦类必须实现某个⽅法
  1. 面向对象设计
  • 继承提供了相当强⼤的代码复⽤机制,但同时也带来了⾮常紧密的耦合关系’
  • 错误使⽤继承容易导致代码失控
  • 对事物的⾏为⽽不是事物本⾝建模,更容易孵化出好的⾯向对象设计
  • 在创建继承关系时应当谨慎。⽤组合来替代继承有时是更好的做法
  1. 函数与面向对象的配合
  • Python ⾥的⾯向对象不必特别纯粹,假如⽤函数打⼀点⼉配合,你可以设计出更好的代码
  • 可以像 requests 模块⼀样,⽤函数为⾃⼰的⾯向对象模块实现⼀些更易⽤的 API
  • 在 Python 中,我们极少会应⽤真正的“单例模式”,⼤多数情况下,⼀个简单的模块级全局对象就够了
  • 使⽤“预绑定⽅法模式”,你可以快速为普通实例包装出类似普通函数的 API
  1. 代码编写细节
  • Python 的成员私有协议并不严格,如果你想标⽰某个属性为私有,使⽤单下划线前缀就够了
  • 编写类时,类⽅法排序应该遵循某种特殊规则,把读者最关⼼的内容摆在最前⾯
  • 多态是⾯向对象编程⾥的基本概念,同时也是最强⼤的思维⼯具之⼀
  • 多态可能的介⼊时机:许多类似的条件分⽀判断、许多针对类型的 isinstance() 判断

面向对象设计原则(上)

《设计模式》中的⼤部分设计模式是作者⽤静态编程语⾔,在⼀个有着诸多限制的⾯向对象环境⾥创造出来的。⽽ Python 是⼀⻔动态到⻣⼦⾥的编程语⾔,它有着⼀等函数对象、“鸭⼦类型”、可⾃定义的数据模型等各种灵活特性。因此,我们极少会⽤ Python 来⼀⽐⼀还原经典设计模式,⽽⼏乎总是会为每种设计模式找到更适合 Python 的表现形式

SOLID 单词⾥的 5 个字⺟,分别代表 5 条设计原则。

  • S:single responsibility principle(单⼀职责原则,SRP)。
  • O:open-closed principle(开放–关闭原则,OCP)。
  • L:Liskov substitution principle(⾥式替换原则,LSP)。
  • I:interface segregation principle(接⼝隔离原则,ISP)。
  • D:dependency inversion principle(依赖倒置原则,DIP)。

类型注解基础

typing 是类型注解⽤到的主要模块,除了 List 以外,该模块内还有许多与类型有关的特殊对象,举例如下。

  • Dict:字典类型,例如 Dict[str, int] 代表键为字符串,值为整型的字典。
  • Callable:可调⽤对象,例如 Callable[[str, str], List[str]] 表⽰接收两个字符串作为参数,返回字符串列表的可调⽤对象。
  • TextIO:使⽤⽂本协议的类⽂件类型,相应地,还有⼆进制类型 BinaryIO。
  • Any:代表任何类型。

SRP:单⼀职责原则

SRP 认为:⼀个类应该仅有⼀个被修改的理由。换句话说,每个类都应该只承担⼀种职责。

违反 SRP 的坏处

单个类承担的职责越多,就意味着这个类越复杂,越难维护。在⾯向对象领域,有⼀种“臭名昭著”的类:God Class,专指那些包含了太多职责、代码特别多、什么事情都能做的类。GodClass 是所有程序员的噩梦,每个理智尚存的程序员在碰到 God Class 后,第⼀个想法总是逃跑,逃得越远越好

违反 SRP 的坏处说了⼀箩筐,那么,究竟怎么修改脚本才能让它符合 SRP 呢?办法有很多,其中最传统的就是把⼤类拆分为⼩类。

大类拆小类

单⼀职责是⾯向对象领域的设计原则,通常⽤来形容类。⽽在 Python 中,单⼀职责的适⽤范围不限于类——通过定义函数,我们同样能让上⾯的代码符合单⼀职责原则

将某个职责拆分为新函数是⼀个具有 Python 特⾊的解决⽅案。它虽然没有那么“⾯向对象”,却⾮常实⽤,甚⾄在许多场景下⽐编写类更简单、更⾼效

OCP:开放 - 关闭原则

该原则认为:类应该对扩展开放,对内修改封闭。换句话说,你可以在不修改某个类的前提下,扩展它的⾏为。

现在,假如我想改变 sorted() 的排序逻辑,⽐如,让它使⽤所有元素对 3 取模后的结果排序。我是不是得去修改 sorted() 函数的源码呢?当然不⽤,我只要在调⽤函数时,传⼊⾃定义的key 参数就⾏了:

通过上⾯的例⼦可以发现,sorted() 函数是⼀个符合 OCP 的绝佳例⼦,原因如下。

  • 对外扩展开放:可以通过传⼊⾃定义 key 参数来扩展它的⾏为。
  • 对内修改关闭:⽆须修改 sort() 函数本⾝ 。

正如古希腊哲学家赫拉克利特所⾔:这世间唯⼀不变的,只有变化本⾝。

通过继承改造代码

继承与 OCP 之间有着重要的联系。继承允许我们⽤⼀种新增⼦类⽽不是修改原有类的⽅式来扩展程序的⾏为,这恰好符合 OCP。⽽要做到有效地扩展,关键点在于先找到⽗类中不稳定、会变动的内容。只有将这部分变化封装成⽅法(或属性),⼦类才能通过继承重写这部分⾏为。

在这个框架下,只要需求变化和“⽤⼾对条⽬是否感兴趣”有关,我都不需要修改原本的HNTopPostsSpider ⽗类,⽽只要不断地在其基础上创建新的⼦类即可。通过继承,我最终实现了OCP 所说的“对扩展开放,对改变关闭”

alt text

通过组合与依赖注入

虽然继承功能强⼤,但它并⾮通往 OCP 的唯⼀途径。除了继承外,我们还可以采⽤另⼀种思路:组合(composition)。更具体地说,使⽤基于组合思想的依赖注⼊(dependency injection)技术。

与继承不同,依赖注⼊允许我们在创建对象时,将业务逻辑中易变的部分(常被称为“算法”)通过初始化参数注⼊对象⾥,最终利⽤多态特性达到“不改代码来扩展类”的效果。

如之前所分析的,在这个脚本⾥,“条⽬过滤算法”是业务逻辑⾥的易变部分。要实现依赖注⼊,我们需要先对过滤算法建模。

⾸先定义⼀个名为 PostFilter 的抽象类:

1
2
3
4
5
6
7
from abc import ABC,abstractmethod

class PostFilter(ABC):
    """抽象类:定义如何过滤帖子结果"""
    @abstractmethod
    def validate(self,post:Post) -> bool:
        """判断帖子是否应该保留"""

随后,为了实现脚本的原始逻辑:不过滤任何条⽬,我们创建⼀个继承该抽象类的默认算法类DefaultPostFilter,它的过滤逻辑是保留所有结果。

要实现依赖注⼊,HNTopPostsSpider 类也需要做⼀些调整,它必须在初始化时接收⼀个名为post_filter 的结果过滤器对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class DefaultPostFilter(PostFilter):
    """保留所有帖⼦"""
    def validate(self, post: Post) -> bool:
        return True

class HNTopPostsSpider:
    """抓取 Hacker News 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) -> Iterable[Post]:
        # ...
        counter = 0
        for item in items:
            # ...
            post = Post(...)
            # 使⽤测试⽅法来判断是否返回该帖⼦
            if self.post_filter.validate(post):
                counter += 1
                yield post

因为HNTopPostsSpider 类所依赖的过滤器是通过初始化参数注入的,所以这个技术被称为依赖注入

alt text

通过依赖注入实现OCP

抽象类不是必需的

类型注解会让 Python 更接近静态语⾔。启⽤类型注解,你就必须时刻寻找那些能作为注解的实体类型。类型注解会强制我们把⼤脑⾥的隐式“接⼝”和“协议”显式地表达出来。

使用数据驱动

在实现 OCP 的众多⼿法中,除了继承与依赖注⼊外,还有另⼀种常⽤⽅式:数据驱动。它的核⼼思想是:将经常变动的部分以数据的⽅式抽离出来,当需求变化时,只改动数据,代码逻辑可以保持不动。

听上去数据驱动和依赖注⼊有点⼉像,它们都是把变化的东西抽离到类外部。⼆者的不同点在于:依赖注⼊抽离的通常是类,⽽数据驱动抽离的是纯粹的数据。

影响每种⽅案可定制性的根本原因在于,各⽅案所处的抽象级别不⼀样。⽐如,在依赖注⼊⽅案下,我选择抽象的内容是“条⽬过滤⾏为”;⽽在数据驱动⽅案下,抽象内容则是“条⽬过滤⾏为的有效站点地址”。很明显,后者的抽象级别更低,关注的内容更具体,所以灵活性不如前者。

总结

  1. SRP
  • 一个类只应该有一种被修改的原因
  • 编写更小的类通常更不容易违反SRP
  • SRP同样适用于函数,你可以让函数和类协同工作
  1. OCP
  • 类应该对内修改关闭,对外扩展开放
  • 通过分析需求,找到代码中易变的部分,是让类符合 OCP 的关键
  • 使用子类继承的方式可以让类符合OCP
  • 通过算法类与依赖注入,也可以让类符合OCP
  • 将数据与逻辑分离,使用数据驱动的方式也是实践OCP的好办法

面向对象设计原则(下)

LSP:⾥式替换原则

给定⼀个属于类型 T 的对象 x,假如 q(x) 成⽴,那么对于 T 的⼦类型 S 来说,S类型的任意对象 y 也都能让 q(y) 成⽴。

这⾥⽤⼀种更通俗的⽅式来描述 LSP:LSP 认为,所有⼦类(派⽣类)对象应该可以任意替代⽗类(基类)对象使⽤,且不会破坏程序原本的功能。

要让⼦类符合 LSP,我们必须让⽤⼾类 User 的“不⽀持停⽤”特性变得更显式,最好将其设计到⽗类协议⾥去,⽽不是让⼦类随⼼所欲地抛出异常。

虽然在 Python ⾥,根本没有“⽗类的异常协议”这种东西,但我们⾄少可以做两件事。

第⼀件事是创建⾃定义异常类。我们可以为“⽤⼾不⽀持停⽤”这件事创建⼀个专⽤的异常类:

第⼆件事是在⽗类 User 和⼦类 Admin 的⽅法⽂档⾥,增加与抛出异常相关的说明:

⽐如,我可以调整 deactivate_users() ⽅法,让它在每次调⽤ deactivate() 时都显式地捕获异常:

1
2
3
4
5
6
7
8
9
def deactivate_users(users: Iterable[User]):
    """批量停⽤多个⽤⼾"""
    for user in users:
        try:
            user.deactivate()
        except DeactivationNotSupported:
            logger.info(
                f'user {user.username} does not allow deactivating, skip.'
            )

只要遵循⽗类的异常规范,当前的⼦类 Admin 对象以及未来可能出现的其他⼦类对象,都可以替代 User 对象。通过对异常做了⼀些微调,我们最终让代码满⾜了 LSP 的要求。

子类随意调整方法参数与返回值

通过上⼀节内容我们了解到,当⼦类⽅法随意抛出⽗类不认识的异常时,代码就会违反 LSP。除此之外,还有两种常⻅的违反 LSP 的情况,分别和⼦类⽅法的返回值与参数有关。

调整返回值以符合LSP

但要符合 LSP,⼦类⽅法与⽗类⽅法所返回的结果不能只是碰巧有⼀些共性。LSP 要求⼦类⽅法的返回值类型与⽗类完全⼀致,或者返回⽗类结果类型的⼦类对象

假如我把之前两个类的⽅法返回值调换⼀下,让⽗类 User 的 list_related_posts()⽅法返回 Iterable[int] 对象,让⼦类 Admin 的⽅法返回 List[int] 对象,这样的设计就完全符合 LSP,因为 List 是 Iterable 类型的⼦类:

列表(以及所有容器类型)都是 Iterable(可迭代类型抽象类)的⼦类

在这种情况下,当我⽤ Admin 对象替换 User 对象时,虽然⽅法返回值类型变了,但新的返回值⽀持旧返回值的所有操作(List ⽀持 Iterable 类型的所有操作——可迭代)。因此,所有依赖旧返回值(Iterable)的代码,都能拿着新的⼦类返回值(List)继续正常执⾏。

方法参数违反LSP

简单来说,要让⼦类符合 LSP,⼦类⽅法的参数必须与⽗类完全保持⼀致,或者,⼦类⽅法所接收的参数应该⽐⽗类更为抽象,要求更为宽松

第⼀条很好理解。⼤多数情况下,我们的⼦类⽅法不应该随意改动⽗类⽅法签名,否则就会违背 LSP

不过,当⼦类⽅法参数与⽗类不⼀致时,有些特殊情况其实仍然可以满⾜ LSP。

第⼀类情况是,⼦类⽅法可以接收⽐⽗类更多的参数,只要保证这些新增参数是可选的即可

⼦类新增了可选参数 include_hidden,保证了与⽗类兼容。当其他⼈把 Admin对象当作 User 使⽤时,不会破坏程序原本的功能

第⼆类情况是,⼦类与⽗类参数⼀致,但⼦类的参数类型⽐⽗类的更抽象:

简单总结⼀下,前⾯我展⽰了违反 LSP 的⼏种常⻅⽅式:

  • 子类抛出父类所不认识的异常类型
  • 子类的方法返回值类型与父类不同,并且该类型不是父类返回值类型的子类
  • 子类的方法参数与父类不同,并且参数要求没有变得更宽松(可选参数)、同名参数没有更抽象

基于隐式合约违反LSP

在 Rectangle 类的设计中,有⼀个隐式的合约:⻓⽅形的宽和⾼应该总是可以单独修改,不会互相影响。上⾯的测试代码正是这个合约的⼀种表现形式

在这个场景下,⼦类 Square 对象并不能替换 Rectangle 使⽤,因此代码违反了 LSP。在真实项⽬中,这种因⼦类打破隐式合约违反 LSP 的情况,相⽐其他原因来说更难察觉,尤其需要当⼼

假如这些⼦类不符合 LSP,那么⾯向对象所提供给我们的最⼤好处之⼀——多态,就不再可靠,变成了⼀句空谈。LSP 能促使我们设计出更合理的继承关系,将多态的潜能更好地激发出来。

在编写代码时,假如你发现⾃⼰的设计违反了 LSP,就需要竭尽所能解决这个问题。有时你得在⽗类中引⼊新的异常类型,有时你得尝试⽤组合替代继承,有时你需要调整⼦类的⽅法参数。总之,只要深⼊思考类与类之间的关系,总会找到正确的解法。

DIP:依赖倒置原则

不论多复杂的程序,都是由⼀个个模块组合⽽成的。当你告诉别⼈:“我正在写⼀个很复杂的程序”时,你其实并不是直接在写那个程序,⽽是在逐个完成它的模块,最后⽤这些模块组成程序

在⽤模块组成程序的过程中,模块间⾃然产⽣了依赖关系。举个例⼦,你的个⼈博客站点可能依赖 Flask 模块,⽽ Flask 依赖 Werkzeug,Werkzeug ⼜由多个低层模块组成。

在正常的软件架构中,模块间的依赖关系应该是单向的,⼀个⾼层模块往往会依赖多个低层模块。整个依赖图就像⼀条蜿蜒⽽下、不断分叉的河流。

DIP 是⼀条与依赖关系相关的原则。它认为:高层模块不应该依赖底层模块,二者都应该依赖抽象

乍⼀看,这个原则有些违反我们的常识——⾼层模块不就是应该依赖低层模块吗?还记得第⼀堂编程课上,在我学会编写 Hello World 程序时,⾼层模块(main() 函数)分明依赖了低层模块(printf())。

你可以发现,上⾯的单元测试暴露了 SiteSourceGrouper 类的⼀个问题:它的执⾏链路依赖 requests 模块和⽹络条件,这严格限制了单元测试的执⾏环境。

使用mock模块

mock 是测试领域的⼀个专有名词,代表⼀类特殊的测试假对象。

假如你的代码依赖了其他模块,但你在执⾏单元测试时不想真正调⽤这些依赖的模块,那么你可以选择⽤⼀些特殊对象替换真实模块,这些⽤于替换的特殊对象常被统称为 mock。

在 Python ⾥,单元测试模块 unittest 为我们提供了⼀个强⼤的 mock ⼦模块,⾥⾯有许多和 mock 技术有关的⼯具,如下所⽰

  • Mock:mock 主类型,Mock() 对象被调⽤后不执⾏任何逻辑,但是会记录被调⽤的情况——包括次数、参数等。
  • MagicMock:在 Mock 类的基础上追加了对魔法⽅法的⽀持,是 patch() 函数所使⽤的默认类型。
  • patch():补丁函数,使⽤时需要指定待替换的对象,默认使⽤⼀个 MagicMock() 替换原始对象,可当作上下⽂管理器或装饰器使⽤。

对于我的脚本来说,假如⽤ unittest.mock 模块来编写单元测试,我需要做以下⼏件事:

  • (1) 把⼀份正确的 Hacker News ⻚⾯内容保存为本地⽂件 static_hn.html;
  • (2) ⽤ mock 对象替换真实的⽹络请求⾏为;
  • (3) 让 mock 对象返回⽂件 static_hn.html 的内容。

使⽤ mock 的测试代码如下所⽰:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from unittest import mock

@mock.patch('hn_site_grouper.requests.get')
def test_grouper_returning_valid_type(mocked_get):
    """测试 get_groups 是否返回了正确类型"""
    with open('static_hn.html','r') as fp:
        mocked_get.return_value.text = fp.read()

    grouper = SiteSourceGrouperO('https://news.ycombinator.com/')
    result = grouper.get_groups()
    assert isinstance(result,Counter),"groups should be Counter instance"

❶ 通过 patch 装饰器将 requests.get 函数替换为⼀个 MagicMock 对象 ❷ 该 MagicMock 对象将会作为函数参数被注⼊ ❸ 将 get() 函数的返回结果(⾃动⽣成的另⼀个 MagicMock 对象)的 text 属性替换为来⾃本地⽂件的内容

通过 mock 技术,我们最终让单元测试不再依赖⽹络环境,可以成功地在 CI 环境中执⾏。

当我们编写单元测试时,有⼀条⾮常重要的指导原则:测试程序的⾏为,⽽不是测试具体实现。

正因为如此,mock 应该总是被当作⼀种应急的技术,⽽不是⼀种低成本、让单元测试能快速开展的⼿段。

实现DIP

DIP ⾥的“抽象”特指编程语⾔⾥的⼀类特殊对象,这类对象只声明⼀些公开的 API,并不提供任何具体实现。⽐如在 Java 中,接⼝就是⼀种抽象 下⾯是⼀个提供“画”动作的接⼝:

1
2
3
interface Drawable {
    public void draw();
}

⽽ Python ⾥并没有上⾯这种接⼝对象,但有⼀个和接⼝⾮常类似的东西——抽象类

1
2
3
4
5
6
from abc import ABC,abstractmethod

class Drawable(ABC):
    @abstracctmethod
    def draw(self):
        pass

搞清楚“抽象”是什么后,接着就是 DIP ⾥最重要的⼀步:设计抽象,其主要任务是确定这个抽象的职责与边界。

在上⾯的脚本⾥,⾼层模块主要依赖 requests 模块做了两件事 (1) 通过 requests.get() 获取响应 response 对象; (2) 利⽤ response.text 获取响应⽂本。

可以看出,这个依赖关系的主要⽬的是获取 Hacker News 的⻚⾯⽂本。因此,我可以创建⼀个名为 HNWebPage 的抽象,让它承担“提供⻚⾯⽂本”的职责。

下⾯的 HNWebPage 抽象类就是实现 DIP 的关键:

1
2
3
4
5
6
7
8
from abc import ABC,abstractmethod

class HNWebPage(ABC):
    """抽象类:Hacker News站点页面"""

    @abstractmethod
    def get_text(self) -> str:
        raise NotImplementedError()

定义好抽象后,接下来分别让⾼层模块和低层模块与抽象产⽣依赖关系。我们从低层模块开始。

低层模块与抽象间的依赖关系表现为它会提供抽象的具体实现。在下⾯的代码⾥,我实现了RemoteHNWebPage 类,它的作⽤是通过 requests 模块请求 Hacker News ⻚⾯,返回⻚⾯内容

1
2
3
4
5
6
7
8
9
class RemoteHNWebPage(HNWebPage):
    """远程⻚⾯,通过请求 Hacker News 站点返回内容"""

    def __init__(self,url:str):
        self.url = url

    def get_text(self) -> str:
        resp = requests.get(self.url)
        return resp.text

此时的依赖关系表现为类与类的继承。除继承外,与抽象类的依赖关系还有许多其他表现形式,⽐如使⽤抽象类的 .register() ⽅法,或者定义⼦类化钩⼦⽅法

处理完低层模块的依赖关系后,接下来我们需要调整⾼层模块 SiteSourceGrouper 类的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class SiteSourceGrouper:
    """对 Hacker News ⻚⾯的新闻来源站点进⾏分组统计"""

    def __init__(self, page: HNWebPage): 
        self.page = page

    def get_groups(self) -> Dict[str, int]:
        """获取 (域名, 个数) 分组"""
        html = etree.HTML(self.page.get_text()) 
        ...


 def main():
    page = RemoteHNWebPage(url="https://news.ycombinator.com/") 
    grouper = SiteSourceGrouper(page).get_groups()

❶ 在初始化⽅法⾥,我⽤类型注解表明了所依赖的是抽象的 HNWebPage 类型 ❷ 调⽤ HNWebPage 类型的 get_text() ⽅法,获取⻚⾯⽂本内容 ❸ 实例化⼀个符合抽象 HNWebPage 的具体实现:RemoteHNWebPage 对象

alt text

SiteSourceGrouper 和 RemoteHNWebPage 都依赖抽象 HNWebPage

可以看到,图 11-3 ⾥的⾼层模块不再直接依赖低层模块,⽽是依赖处于中间的抽象:HNWebPage。低层模块也不再是被依赖的⼀⽅,⽽是反过来依赖处于上⽅的抽象层,这便是 DIP ⾥inversion(倒置)⼀词的由来。

倒置后的单元测试

通过创建抽象实现 DIP 后,我们回到之前的单元测试问题。为了满⾜单元测试的⽆⽹络需求,基于 HNWebPage 抽象类,我可以实现⼀个不依赖⽹络的新类型 LocalHNWebPage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class LocalHNWebPage(HNWebPage):
    """本地⻚⾯,根据本地⽂件返回⻚⾯内容
    
    :param path: 本地⽂件路径
    """

    def __init__(self,path:str):
        self.path = path

    def get_text(self) -> str:
        with open(self.path,'r') as fp:
            return fp.read()

单元测试代码也可以进⾏相应的调整:

1
2
3
4
5
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 ⾥的外⽹访问⾏为。现在的测试代码不需要任何 mock 技术,在⽆法访问外⽹的 CI 服务器上也能正常执⾏。

DIP 要求代码在互相依赖的模块间创建新的抽象概念。当⾼层模块依赖抽象⽽不是具体实现后,我们就能更⽅便地⽤其他实现替换底层模块,提⾼代码灵活性。

退后一步是鸭子,向前一步是协议

如果在抽象类⽅案下,往后退⼀步,从代码⾥删掉抽象类,同时删掉所有的类型注解,你会发现代码仍然可以正常执⾏。在这种情况下,依赖关系仍然是倒过来的,但是处在中间的“抽象”变成了⼀个隐式概念。

没有抽象类后,代码变成了“鸭⼦类型”,依赖倒置也变成了⼀种符合“鸭⼦类型”的倒置。

在 Python 3.8 版本⾥,类型注解 typing 模块增加了⼀个名为“协议”(Protocol)的类型。从各种意义上来说,Protocol 都⽐抽象类更接近传统的“接⼝”。

下⾯是⽤ Protocol 实现的 HNWebPage:

1
2
3
4
5
class HNWebPage(Protocol):
    """协议:Hacker News站点页面"""

    def get_text(self)->str:
        ...

虽然 Protocol 提供了定义协议的能⼒,但像类型注解⼀样,它并不提供运⾏时的协议检查,它的真正实⼒仍然需要搭配 mypy 才能发挥出来。

通过 Protocol 与 mypy 类型检查⼯具,你能实现真正的基于协议的抽象与结构化⼦类技术。也就是说,只要某个类实现了 get_text() ⽅法,并且返回了 str 类型,那么它便可以当作 HNWebPage 使⽤。

不过,Protocol 与 mypy 的上⼿⻔槛较⾼,如果不是⼤型项⽬,实在没必要使⽤。在多数情况下,普通的抽象类或鸭⼦类型已经够⽤了。

事实是,抽象的好处显⽽易⻅:它解耦了模块间的依赖关系,让代码变得更灵活。但抽象同时也带来了额外的编码与理解成本。

所以,了解何时不抽象与何时抽象同样重要。只有对代码中那些容易变化的东西进⾏抽象,才能获得最⼤的收益。

ISP

接口是编程语言里的一类特殊对象,它包含一些公开的抽象协议,可以用来构建模块间的依赖关系,在不同的编程语言里,接口有不同的表现形式,在python中,接口可以是抽象类,Protocol,也可以是鸭子类型里的某个隐式概念

接口是⼀种⾮常有⽤的设计⼯具,为了更好地发挥它的能⼒,ISP 对如何使⽤接⼝提出了要求:客户不应该依赖任何它不使用的方法

拿上⼀节统计 Hacker News ⻚⾯条⽬的例⼦来说:

  • 使⽤⽅(客⼾模块)——SiteSourceGrouper;
  • 接⼝(其实是抽象类)——HNWebPage
  • 依赖关系——调⽤接⼝⽅法 get_text() 获取⻚⾯⽂本

我设计的接⼝ HNWebPage 就是符合 ISP 的,因为它没有提供任何使⽤⽅不需要的⽅法

对 HNWebPage 接⼝的盲⽬扩展暴露出⼀个问题:更丰富的接⼝协议,意味着更⾼的实现成本,也更容易给实现⽅带来⿇烦。

alt text

分拆接口

在设计接⼝时有⼀个简单的技巧:让客⼾(调⽤⽅)来驱动协议设计。在现在的程序⾥,HNWebPage 接⼝共有两个客⼾

  • SiteSourceGrouper:按域名来源统计,依赖 get_text()。
  • SiteAchiever:⻚⾯归档程序,依赖 get_text()、get_size() 和 get_generated_at()。

根据这两个客⼾的需求,我可以把 HNWebPage 分离成两个不同的抽象类: alt text

当你认识到 ISP 带来的种种好处后,很⾃然地会养成写⼩类、⼩接⼝的习惯。在现实世界⾥,其实已经有很多⼩⽽精的接⼝设计可供参考,⽐如:

  • Python 的 collections.abc 模块⾥⾯有⾮常多的⼩接⼝;
  • Go 语⾔标准库⾥的 Reader 和 Writer 接⼝。

总结

  1. LSP
  • LSP认为子类应该可以任意替代父类使用
  • 子类不应该抛出父类不认识的异常
  • 子类方法应该返回与父类一致的类型,或者返回父类返回值的子类型对象
  • 子类的方法参数应该合父类方法完全一致,或者要求更为宽松
  • 某些类可能会存在隐式合约,违反这些合约也会导致违反LSP
  1. DIP
  • DIP认为高层模块合底层模块都应该依赖于抽象
  • 编写单元测试有一个原则:测试行为,而不是测试实现
  • 单元测试不宜使⽤太多 mock,否则需要调整设计
  • 依赖抽象的好处是,修改低层模块实现不会影响高层代码
  • 在python中,你可以用abc模块来定义抽象类
  • 除abc以外,你也可以用Protocol等技术来完成依赖倒置

3.ISP

  • ISP认为客户依赖的接口不应该包含任何它不需要的方法
  • 设计接口就是设计抽象
  • 写更小的类,更小的接口在大多数情况是个好主意

数据模型与描述符

我们常说的数据模型(或者叫对象模型)就是这套规则。假如把 Python 语⾔看作⼀个框架,数据模型就是这个框架的说明书。数据模型描述了框架如何⼯作,创建怎样的对象才能更好地融⼊Python 这个框架

除了 print() 以外,str() 与 .format() 函数同样也会触发 str ⽅法

上⾯展⽰的 str 就是 Python 数据模型⾥最基础的⼀部分。当对象需要当作字符串使⽤时,我们可以⽤ str ⽅法来定义对象的字符串化结果。

使用@total_ordering

@total_ordering 是 functools 内置模块下的⼀个装饰器。它的功能是让重载⽐较运算符变得更简单。

如果使⽤ @total_ordering 装饰⼀个类,那么在重载类的⽐较运算符时,你只要先实现__eq__ ⽅法,然后在 ltlegtge 四个⽅法⾥随意挑⼀个实现即可,@total_ordering 会帮你⾃动补全剩下的所有⽅法。

使⽤ @total_ordering,前⾯的 Square 类可以简化成下⾯这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from functools import total_ordering

@total_ordering
class Square:
    """正方形

    :param length:边长
    """
    
    def __init__(slef,length):
        self.length = length

    def area(self):
        return self.length ** 2

    def __eq__(self,other):
        if isinstance(other,self.__class__):
            return self.length == other.length
        return False

    def __lt__(self,other):
        if isinstance(other,self.__class__):
            return self.length < other.length
        return NotImplemented

描述符

这是因为所有的⽅法、类⽅法、静态⽅法以及属性等诸多 Python 内置对象,都是基于描述符协议实现的。

在⽇常⼯作中,描述符的使⽤并不算频繁。但假如你要开发⼀些框架类⼯具,就会发现描述符⾮常有⽤。接下来我们通过开发⼀个⼩功能,来看看描述符究竟能如何帮助我们。

使⽤ @property 把 age 定义为 property 对象后,我可以很⽅便地增加校验逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Person:
    ...
    @property
    def age(self):
        return self._age
    @age.setter
    def age(self, value):
        """设置年龄,只允许 0〜150 之间的数值"""
        try:
            value = int(value)
        except (TypeError, ValueError):
            raise ValueError('value is not a valid integer!')
        if not (0 < value < 150):
            raise ValueError('value must between 0 and 150!')
        self._age = value

描述符简介

描述符是python对象模型里的一种特殊协议,它主要和 4 个魔法⽅法有关: getsetdeleteset_name

从定义上来说,除了最后一个方法 set_name 以外,任何一个实现了 get,setdelete 的类,都可以称为描述符类,它的实例则叫作描述符对象

描述符之所以叫这个名字,是因为它描述了python获取与设置一个类(实例)成员的整个过程。我们通过简单的代码⽰例,来看看描述符的⼏个魔法⽅法究竟有什么⽤。

从最常⽤的 get ⽅法开始:

1
2
3
4
5
6
7
8
9
class InfoDescriptor:
    """打印帮助信息的描述符"""

    def __get__(self,instance,owner=None):
        print(f'Calling __get__, instance: {instance}, owner: {owner}')
        if not instance:
            print('Calling without instance...')
            return self
        return 'informative descriptor'

上⾯的 InfoDescriptor 是⼀个实现了 get ⽅法的描述符类。

要使⽤⼀个描述符,最常⻅的⽅式是把它的实例对象设置为其他类(常被称为 owner 类)的属性:

1
2
class Foo:
    bar = InfoDescriptor()

描述符的 get ⽅法,会在访问 owner 类或 owner 类实例的对应属性时被触发。get ⽅法⾥的两个参数的含义如下。

  • owner:描述符对象所绑定的类
  • instance:假如用实例来访问描述符属性,该参数值为实例对象,如果通过类来访问该值为None

get ⽅法相对应的是 set ⽅法,它可以⽤来⾃定义设置某个实例属性时的⾏为

  • instance:属性当前绑定的实例对象
  • value:待设置的属性值

值得⼀提的是,描述符的 set 仅对实例起作⽤,对类不起作⽤。这和 get ⽅法不⼀样,get 会同时影响描述符所绑定的类和类实例。当你通过类设置描述符属性值时,不会触发任何特殊逻辑,整个描述符对象会被覆盖:

用描述符实现属性校验功能

为了提供更⾼的可复⽤性,这次我在年龄字段的基础上抽象出了⼀个⽀持校验功能的整型描述符类型:IntegerField。它的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class IntegerField:
    """整型字段,只允许一定范围内的整型值

    :param min_value:允许的最小值
    :param max_value:允许的最大值
    """

    def __init__(self,min_value,max_value):
        self.min_value = min_value
        self.max_value = max_value

    def __get__(self,instance,owner=None):
        # 当不是通过实例访问时,直接返回描述符对象
        if not instance:
            return self
        # 返回保存在实例字典里的值
        return instance.__dict__['_integer_field']

    def __set__(self,instance,value):
        # 校验后将值保存在实例字典里
        value = self._validate_value(value)
        instance.__dict__['_integer_field'] = value

    def _validate_value(self,value):
        """校验值是否为符合要求的整数"""
        try:
            value = int(value)
        except (TypeError, ValueError):
            raise ValueError('value is not a valid integer!')

        if not (self.min_value <= value <= self.max_value):
            raise ValueError(
                f'value must between {self.min_value} and {self.max_value}!'
            )
        return value

IntegerField 最核⼼的逻辑,就是在设置属性值时先做有效性校验,然后再保存数据。

set ⽅法⾥,我使⽤了 instance.dict[’_integer_field’] = value 这样的语句来保存整型数字的值。也许你想问:为什么不直接写 self._integer_field = value,把值存放在描述符对象 self ⾥呢?

这是因为每个描述符对象都是 owner 类的属性,⽽不是类实例的属性。也就是说,所有从owner 类派⽣出的实例,其实都共享了同⼀个描述符对象。假如把值存⼊描述符对象⾥,不同实例间的值就会发⽣冲突,互相覆盖。

所以,为了避免覆盖问题,我把值放在了每个实例各⾃的 dict 字典⾥

使用 set_name

set_name(self, owner, name)是 Python 在 3.6 版本以后,为描述符协议增加的新⽅法,它所接收的两个参数的含义如下。

  • owner:描述符对象当前绑定的类。
  • name:描述符所绑定的属性名称。

set_name ⽅法的触发时机是在 owner 类被创建时

通过给 IntegerField 类增加 set_name ⽅法,我们可以⽅便地解决前⾯的数据冲突问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class IntegerField:

    def __init__(self,min_value,max_value):
        self.min_value = min_value
        self.max_value = max_value

    def __set_name__(self,owner,name):
        # 将绑定属性名保存在描述符对象中
        # 对于 age = IntegerField(...) 来说,此处的 name 就是“age”
        self._name = name

    def __get__(self,instance,owner=None):
        if not instance:
            return self
        # 在数据存取时,使用动态的self._name
        return instance.__dict__[self.name]

    def __set__(self,instance,value):
        value = self._validate_value(value)
        instance.__dict__[self._name] = value

    def _validate_value(self, value):
        """校验值是否为符合要求的整数"""
        # ...

使⽤描述符,我们最终实现了⼀个可复⽤的 IntegerField 类,它使⽤起来⾮常⽅便——⽆须继承任何⽗类、声明任何元类,直接将类属性定义为描述符对象即可。

数据描述符与⾮数据描述符 按实现⽅法的不同,描述符可分为两⼤类。

  • ⾮数据描述符:只实现了 get ⽅法的描述符
  • 数据描述符:实现了 setdelete 其中任何⼀个⽅法的描述符。

这两类描述符的区别主要体现在所绑定实例的属性存取优先级上。 对于⾮数据描述符来说,你可以直接⽤ instance.attr = … 来在实例级别重写描述符属性 attr,让其读取逻辑不再受描述符的 get ⽅法管控。 ⽽对于数据描述符来说,你⽆法做到同样的事情。数据描述符所定义的属性存储逻辑拥有极⾼的优先级,⽆法轻易在实例层⾯被重写

所有的 Python 实例⽅法、类⽅法、静态⽅法,都是⾮数据描述符,你可以轻易覆盖它们。⽽ property() 是数据描述符,你⽆法直接通过重写修改它的⾏为

利用集合的游戏规则

要用集合来解决我们的问题,第一步是建模一个用来表示旅客记录的新类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class VisitRecord:
    """旅客记录
    :param first_name: 名
    :param last_name: 姓
    :param phone_number: 电话号码
    :param date_visited: 旅游时间
    """
    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

默认情况下,Python 的⽤⼾⾃定义类型都是可哈希的。因此,VisitRecord 对象可以直接放进集合⾥,但⾏为可能会和你预想中的有些不同:

出现上⾯这样的结果其实并不奇怪。因为对于任何⾃定义类型来说,当你对两个对象进⾏相等⽐较时,Python 只会判断它们是不是指向内存⾥的同⼀个地址。换句话说,任何对象都只和它⾃⾝相等

因此,为了让集合能正确处理 VisitRecord 类型,我们⾸先要重写类型的 eq 魔法⽅法,让Python 在对⽐两个 VisitRecord 对象时,不再关注对象 ID,只关⼼记录的姓名与电话号码。

1
2
3
4
5
6
7
8
9
def __eq__(self,other):
    if isinstance(other,self.__class__):
        return self.comparable_fields == other.comparable_fileds
    return False

@property
def comparable_fields(self):
    """获取用于对比对象的字段值"""
    return (self.first_name,self.last_name,self.phone_number)

完成这⼀步后,VisitRecord 的相等运算就重写成了我们所需要的逻辑

但要达到计算差集的⽬的,仅重写 eq 是不够的。如果我现在试着把⼀个新的 VisitRecord对象塞进集合,程序⻢上会报错:

发⽣什么事了?VisitRecord 类型突然从可哈希变成了不可哈希!要弄清楚原因,得先从哈希表的⼯作原理讲起

当 Python 把⼀个对象放⼊哈希表数据结构(如集合、字典)中时,它会先使⽤ hash() 函数计算出对象的哈希值,然后利⽤该值在表⾥找到对象应在的位置,之后完成保存。⽽当 Python 需要获知哈希表⾥是否包含某个对象时,同样也会先计算出对象的哈希值,之后直接定位到哈希表⾥的对应位置,再和表⾥的内容进⾏精确⽐较

也就是说,⽆论是往集合⾥存⼊对象,还是判断某对象是否在集合⾥,对象的哈希值都会作为⼀个重要的前置索引被使⽤。

在我重写 eq 前,对象的哈希值其实是对象的 ID(值经过⼀些转换,和 id() 调⽤结果并⾮完全⼀样)。但当 eq ⽅法被重写后,假如程序仍然使⽤对象 ID 作为哈希值,那么⼀个严重的悖论就会出现:即便两个不同的 VisitRecord对象在逻辑上相等,但它们的哈希值不⼀样,这在原理上和哈希表结构相冲突。

因为对于哈希表来说,两个相等的对象,其哈希值也必须⼀样,否则⼀切算法逻辑都不再成⽴。所以,Python 才会在发现重写了 eq ⽅法的类型后,直接将其变为不可哈希,以此强制要求你为其设计新的哈希值算法。

幸运的是,只要简单地重写 VisitRecord 的 hash ⽅法,我们就能解决这个问题:

1
2
def __hash__(self):
    return hash(self.comparabble_fields)

因为 .comparable_fields 属性返回了由姓名、电话号码构成的元组,⽽元组本⾝就是可哈希类型,所以我可以直接把元组的哈希值当作 VisitRecord 的哈希值使⽤。

完成 VisitRecord 建模,做完所有的准备⼯作后,剩下的事情便顺⽔推⾈了。基于集合差值运算的新版函数,只要⼀⾏核⼼代码就能完成操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

    def __hash__(self):
        return hash(self.comparable_fields)

    def __eq__(self, other):
        if isinstance(other, self.__class__):
            return self.comparable_fields == other.comparable_fields
        return False

    @property
    def comparable_fields(self):
        """获取⽤于⽐较对象的字段值"""
        return (self.first_name, self.last_name, self.phone_number)

    def find_potential_customers_v3():
        # 转换为 VisitRecord 对象后计算集合差值
        return set(VisitRecord(**r) for r in users_visited_puket) - set(
        VisitRecord(**r) for r in users_visited_nz
        )

因此,当 Python 通过哈希值在表⾥搜索时,并不会完全依赖哈希值,⽽⼀定会再做⼀次精准的相等⽐较运算 ==(使⽤ eq),这样才能最终保证程序的正确性。

基本没有⼈会在实际⼯作中写出上⾯这种代码来解决这么⼀个简单问题。但是,有了下⾯这个模块的帮助,事情也许会有⼀些变化

使⽤ dataclasses

dataclasses 是 Python 在 3.7 版本后新增的⼀个内置模块。它最主要的⽤途是利⽤类型注解语法来快速定义像上⾯的 VisitRecord ⼀样的数据类。

使⽤ dataclasses 可以极⼤地简化 VisitRecord 类,代码最终会变成下⾯这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from dataclasses import dataclass,field

@dataclass(frozen=True)
class VisitRecordDC:
    first_name: str
    last_name: str
    phone_name: str
    date_visited: str = field(compare=True)

def find_potential_customers_v4():
    return set(VisitRecordDC(**r) for r in users_visited_puket) - set(VisitRecordDC(**r) for r in users_visited_nz)

要定义⼀个 dataclass 字段,只需提供字段名和类型注解即可 因为旅游时间 date_visited 不⽤于⽐较运算,所以需要指定 compare=False 跳过该字段

通过 @dataclass 来定义⼀个数据类,我完全不⽤再⼿动实现 init ⽅法,也不⽤重写任何__eq__ 与 hash ⽅法,所有的逻辑都会由 @dataclass ⾃动完成。

在上⾯的代码⾥,尤其需要说明的是 @dataclass(frozen=True) 语句⾥的 frozen 参数。在默认情况下,由 @dataclass 创建的数据类都是可修改的,不⽀持任何哈希操作。因此你必须指定frozen=True,显式地将当前类变为不可变类型,这样才能正常计算对象的哈希值。

最后,在集合运算和数据类的帮助下,不⽤⼲任何脏活累活,总共不到⼗⾏代码就能完成所有的⼯作。

认识 hash 的危险性

所以,设计哈希算法的原则是:在⼀个对象的⽣命周期⾥,它的哈希值必须保持不变,否则就会出现各种奇怪的事情。这也是 Python 把所有可变类型(列表、字典)设置为“不可哈希”的原因。

每当你想要重写 hash ⽅法时,⼀定要保证⽅法产⽣的哈希值是稳定的,不会随着对象状态⽽改变。要做到这点,要么你的对象不可变,不允许任何修改——就像定义 dataclass 时指定的frozen=True;要么⾄少应该保证,被卷⼊哈希值计算的条件不会改变。

数据模型不是“躺赢”之道

不要把数据模型当成写代码时的万能药,把所有脚都塞进数据模型这双靴⼦⾥

恰当地使⽤数据模型,确实能让我们写出更符合 Python 习惯的代码,设计出更地道的 API。但也得注意不要过度,有时,“聪明”的代码反⽽不如“笨”代码,平铺直叙的“笨”代码或许更能表达出设计者的意图,更容易让⼈理解。

不要依赖 del 方法

我经常⻅到⼈们把 del 当成⼀种⾃动化的资源回收⽅法来⽤。⽐如,⼀个请求其他服务的 Client 对象会在初始化时创建⼀个连接池。那么写代码的⼈极有可能会重写对象的 del⽅法,把关闭连接池的逻辑放在⽅法⾥。

现在你应该明⽩了,⼀个对象的 del ⽅法,并⾮在使⽤ del 语句时被触发,⽽是在它被作为垃圾回收时触发。del 语句⽆法直接回收任何东西,它只是简单地删掉了指向当前对象的⼀个引⽤(变量名)⽽已。

换句话说,del 让对象的引⽤计数减 1,但只有当引⽤计数降为 0 时,它才会⻢上被Python 解释器回收。因此,在 foo 仍然被列表 l 引⽤时,删除 foo 的其中⼀个引⽤是不会触发 del

总⽽⾔之,垃圾回收机制是⼀⻔编程语⾔的实现细节

正因为如此,依赖 del ⽅法来做⼀些清理资源、释放锁、关闭连接池之类的关键⼯作,其实⾮常危险。因为你创建的任何对象,完全有可能因为某些原因⼀直都不被作为垃圾回收。这时,⽹络连接会不断增⻓,锁也⼀直⽆法被释放,最后整个程序会在某⼀刻轰然崩塌。

如果你要给对象定义资源清理逻辑,请避免使⽤ del。你可以要求使⽤⽅显式调⽤清理⽅法,或者实现⼀个上下⽂管理器协议——⽤ with 语句来⾃动清理(参考 Python 的⽂件对象),这些⽅式全都⽐ del 好得多。

总结

  1. 字符串相关协议
  • 使用 str 方法,可以定义对象的字符串值(被str()触发)
  • 使用 repr 方法,可以定义对象对调试友好的详细字符串值(被 repr() 触发)
  • 如果对象只定义了 repr ⽅法,它同时会⽤于替代 str
  • 使⽤ format ⽅法,可以在对象被⽤于字符串模板渲染时,提供多种字符串值(被.format() 触发)
  1. 比较运算符重载
  • 通过重载与⽐较运算符有关的 6 个魔法⽅法,你可以让对象⽀持 ==、>= 等⽐较运算
  • 使⽤ functools.total_ordering 可以极⼤地减少重载⽐较运算符的⼯作量
  1. 描述符协议
  • 使用描述符协议,你可以轻松实现可复用的属性对象
  • 实现了 getsetdelete 其中任何⼀个⽅法的类都是描述符类
  • 要在描述符⾥保存实例级别的数据,你需要将其存放在 instance.dict ⾥,⽽不是直接放在描述符对象上
  • 使⽤ set_name ⽅法能让描述符对象知道⾃⼰被绑定了什么名字
  1. 数据类与⾃定义哈希运算
  • 要让⾃定义类⽀持集合运算,你需要实现 eqhash 两个⽅法
  • 如果两个对象相等,它们的哈希值也必须相等,否则会破坏哈希表的正确性
  • 不同对象的哈希值可以⼀样,哈希冲突并不会破坏程序正确性,但会影响效率
  • 使⽤ dataclasses 模块,你可以快速创建⼀个⽀持哈希操作的数据类
  • 要让数据类⽀持哈希操作,你必须指定 frozen=True 参数将其声明为不可变类型
  • ⼀个对象的哈希值必须在它的⽣命周期⾥保持不变
  1. 其他建议
  • 虽然数据模型能帮我们写出更 Pythonic 的代码,但切勿过度推崇
  • del ⽅法不是在执⾏ del 语句时被触发,⽽是在对象被作为垃圾回收时被触发
  • 不要使⽤ del 来做任何“⾃动化”的资源回收⼯作

开发大型项目

常用工具介绍

  • flake8
  • isort
  • black

作为⼀个代码格式化⼯具,black 最⼤的特点在于它的不可配置性。正如官⽅介绍所⾔,black 是⼀个“毫不妥协的代码格式化⼯具”

不过,虽然我们没法统⼀每个⼈的 IDE,但⾄少⼤部分项⽬使⽤的版本控制软件是⼀样的—— Git。⽽ Git 有个特殊的钩⼦功能,它允许你给每个仓库配置⼀些钩⼦程序(hook),之后每当你进⾏特定的 Git 操作时——⽐如 git commit、git push,这些钩⼦程序就会执⾏。

pre-commit 就是⼀个基于钩⼦功能开发的⼯具。从名字就能看出来,pre-commit 是⼀个专⻔⽤于预提交阶段的⼯具。要使⽤它,你需要先创建⼀个配置⽂件 .pre-commitconfig.yaml。

举个例⼦,下⾯是⼀个我常⽤的 pre-commit 配置⽂件内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fail_fast: true
repos:
- repo: https://github.com/timothycrosley/isort
  rev: 5.7.0
  hooks:
 - id: isort
  additional_dependencies: [toml]
  - repo: https://github.com/psf/black
  rev: 20.8b1
  hooks:
  - id: black
    args: [--config=./pyproject.toml]
- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v2.4.0
  hooks:
  - id: flake8

由于 pre-commit 的配置⽂件与项⽬源码存放在⼀起,都在代码仓库中,因此项⽬的所有开发者天然共享 pre-commit 的插件配置,每个⼈不⽤单独维护各⾃的配置,只要安装 pre-commit⼯具就⾏。

  • mypy

单元测试简介

但如今,事情发⽣了很多变化。由于敏捷开发与快速迭代理论的流⾏,⼈们现在开始想尽办法压缩发布周期、提升发布频率,态度近乎狂热。不少百万⾏代码量级的互联⽹项⽬,每天要构建数⼗个版本,每周发布数次。由于构建和发布⼏乎⽆时⽆刻都在进⾏,⼤家给这类实践起了⼀个贴切的名字:持续集成(CI)与持续交付(CD)

根据关注点的不同,⾃动化测试可分为不同的类型,⽐如 UI 测试、集成测试、单元测试等。不同类型的测试,各⾃关注着不同的领域,覆盖了不⼀样的场景。⽐如,UI 测试是模拟⼀位真实⽤⼾真正使⽤软件,以此验证软件的⾏为是否与预期⼀致。⽽单元测试通过单独执⾏项⽬代码⾥的每个功能单元,来验证它们的⾏为是否正常。

在所有测试中,单元测试数量最多、测试成本最低,是整个⾃动化测试的基础和重中之重

unittest

在 Python ⾥编写单元测试,最正统的⽅式是使⽤ unittest 模块。unittest 是标准库⾥的单元测试模块,使⽤⽅便,⽆须额外安装。

我们先通过⼀个简单的测试⽂件来感受⼀下 unittest 的功能: 文件:test_upper.py

1
2
3
4
5
6
7
8
import unittest

class TestStringUpper(unittest.TestCase):
    def test_normal(self):
        self.assertEqual('foo'.upper(),'FOO')

if __name__ = '__main__':
    unittest.main()

⽤ unittest 编写测试⽤例的第⼀步,是创建⼀个继承 unittest.TestCase 的⼦类,然后编写许多以 test 开头的测试⽅法。在⽅法内部,通过调⽤⼀些以 assert 开头的⽅法来进⾏测试断⾔,如下所⽰

  • self.assertEqual(x, y):断⾔ x 和 y 必须相等。
  • self.assertTrue(x):断⾔ x 必须为布尔真。
  • self.assertGreaterEqual(x, y):断⾔ x 必须⼤于等于 y。

如果⼀个测试⽅法内的所有测试断⾔都能通过,那么这个测试⽅法就会被标记为成功;⽽如果有任何⼀个断⾔⽆法通过,就会被标记为失败。

使⽤ python test_upper.py 来执⾏测试⽂件,会打印出测试⽤例的执⾏结果: 除了定义测试⽅法外,你还可以在 TestCase 类⾥定义⼀些特殊⽅法。⽐如,通过定义setUp() 和 tearDown() ⽅法,你可以让程序在执⾏每个测试⽅法的前后,运⾏额外的代码逻辑

要搞清楚为什么 unittest 会采⽤这些奇怪设计,得从模块的历史出发。Python 的unittest 模块在最初实现时,⼤量参考了 Java 语⾔的单元测试框架 JUnit。因此,它的许多“奇怪”设计其实是“Java 化”的表现,⽐如只能⽤类来定义测试⽤例,⼜⽐如⽅法都采⽤驼峰命名法等。

但在⽇常⼯作中,我其实更偏爱另⼀个在 API 设计上更接近 Python 语⾔习惯的单元测试框架:pytest。接下来我们看看如何⽤ pytest 做单元测试。

pytest

pytest 功能更多,设计更复杂,上⼿难度也更⾼。但 pytest 的最⼤优势在于,它把Python 的⼀些惯⽤写法与单元测试很好地融合了起来。因此,当你掌握了 pytest 以后,⽤它写出的测试代码远⽐⽤ unittest 写的简洁

为了测试函数的功能,我⽤ pytest 写了⼀份单元测试: ⽂件:test_string_utils.py

1
2
3
4
from string_utils import string string_upper

def test_string_upper():
    assert string_upper('foo') = 'FOO'

⾸先,TestCase 类消失了。使⽤ pytest 时,你不必⽤⼀个 TestCase 类来定义测试⽤例,⽤⼀个以 test 开头的普通函数也⾏。

其次,当你要进⾏断⾔判断时,不需要调⽤任何特殊的 assert{X}() ⽅法,只要写⼀条原⽣的断⾔语句 assert {expression} 就好。

正因为这些简化,⽤ pytest 来编写测试⽤例变得⾮常容易。

为了让单元测试覆盖更多场景,最直接的办法是在 test_string_utils.py ⾥增加测试函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from string_utils import string_upper

    def test_string_upper():
        assert string_upper('foo') == 'FOO'
        
    def test_string_empty(): 
        assert string_upper('') == ''

    def test_string_mixed_cases():
        assert string_upper('foo BAR') == 'FOO BAR'

⽤ parametrize 编写参数化测试

在单元测试领域,有⼀种常⽤的编写测试代码的技术:表驱动测试(table-driven testing)。

当你要测试某个函数在接收到不同输⼊参数的⾏为时,最直接的做法是像上⾯那样,直接编写许多不同的测试⽤例。但这种做法其实并不好,因为它很容易催⽣出重复的测试代码。

表驱动测试是⼀种简化单元测试代码的技术。它⿎励你将不同测试⽤例间的差异点抽象出来,提炼成⼀张包含多份输⼊参数、期望结果的数据表,以此驱动测试执⾏。如果你要增加测试⽤例,直接往表⾥增加⼀份数据就⾏,不⽤写任何重复的测试代码。

在 pytest 中实践表驱动测试⾮常容易。pytest 为我们提供了⼀个强⼤的参数测试⼯具:pytest.mark.parametrize。利⽤该装饰器,你可以⽅便地定义表驱动测试⽤例

以测试⽂件 test_string_utils.py 为例,使⽤参数化⼯具,我可以把测试代码改造成代 代码清单 13-2 使⽤ parametrize 后的测试代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import pytest
from  string_utils import string_upper

@pytest.mark.parametrize(
    's,expected',
    [
        ('foo','FOO'),
        ('',''),
        ('foo BAR','FOO BAR')
    ],
)

def test_string_upper(s,expected):
    assert string_upper(s) == expected

⽤逗号分隔的参数名列表,也可以理解为数据表每⼀列字段的名称

数据表的每⾏数据通过元组定义,元组成员与参数名⼀⼀对应

在测试函数的参数部分,按 parametrize 定义的字段名,增加对应参数

在测试函数内部,⽤参数替换静态测试数据

在本节中,我演⽰了如何使⽤ @pytest.mark.parametrize 定义参数化测试,避免编写重复的测试代码。下⾯,我会介绍 pytest 的另⼀个重要功能:fixture(测试固定件)。

使用@pytest.fixture 创建 fixture 对象

在编写单元测试时,我们常常需要重复⽤到⼀些东西。⽐如,当你测试⼀个图⽚操作模块时,可能需要在每个测试⽤例开始时,重复创建⼀张临时图⽚⽤于测试。

这类被许多单元测试依赖、需要重复使⽤的对象,常被称为 fixture。在 pytest 框架下,你可以⾮常⽅便地⽤ @pytest.fixture 装饰器创建 fixture 对象。

举个例⼦,在为某模块编写测试代码时,我需要不断⽤到⼀个⻓度为 32 的随机 token 字符串。为了简化测试代码,我可以创造⼀个名为 random_token 的 fixture,如代码清单13-3 所⽰。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import pytest
import string
import random

@pytest.fixture
def random_token() -> str:
    """生成随机token"""
    token_l = []
    char_pool = string.ascii_lowercase + string.digits
    for _ in range(32):
        token_l.append(random.choice(char_pool))
    return ''.join(token_l)

定义完 fixture 后,假如任何⼀个测试⽤例需要⽤到随机 token,不⽤执⾏ import,也不⽤⼿动调⽤ random_token() 函数,只要简单调整测试函数的参数列表,增加random_token 参数即可:

1
2
def test_foo(random_token):
    print(random_token)

之后每次执⾏ test_foo() 时,pytest 都会⾃动找到名为 random_token 的 fixutre对象,然后将 fixture 函数的执⾏结果注⼊测试⽅法中。

假如你在 fixture 函数中使⽤ yield 关键字,把它变成⼀个⽣成器函数,那么就能为fixture 增加额外的清理逻辑。⽐如,下⾯的 db_connection 会在作为 fixture 使⽤时返回⼀个数据库连接,并在测试结束需要销毁 fixture 前,关闭这个连接:

1
2
3
4
5
6
@pytest.fixture
def db_connection():
    """创建并返回一个数据库连接"""
    conn = create_db_conn()
    yield conn
    conn.close()

yield 前的代码在创建 fixture 前被调⽤

yield 后的代码在销毁 fixture 前被调⽤

除了作为函数参数,被主动注⼊测试⽅法中以外,pytest 的 fixture 还有另⼀种触发⽅式:⾃动执⾏。

通过在调⽤ @pytest.fixture 时传⼊ autouse=True 参数,你可以创建⼀个会⾃动执⾏的 fixture。举个例⼦,下⾯的 prepare_data 就是⼀个会⾃动执⾏的 fixture:

1
2
3
4
5
6
7
8
@pytest.fixture(autouse=True)
def prepare_data():
    # 在测试开始前,创建两个用户
    User.objects.create(...)
    User.objects.create(...)
    yield
    # 在测试结束时,销毁所有用户
    User.objects.all().delete()

⽆论测试函数的参数列表⾥是否添加了 prepare_data,prepare_data fixture ⾥的数据准备与销毁逻辑,都会在每个测试⽅法的开始与结束阶段⾃动执⾏。这类⾃动执⾏的fixture,⾮常适合⽤来做⼀些测试准备与事后清理⼯作

除了 autouse 以外,fixture 还有⼀个⾮常重要的概念:作⽤域(scope)。

在 pyetst 执⾏测试时,每当测试⽤例第⼀次引⽤某个 fixture,pytest 就会执⾏fixture 函数,将结果提供给测试⽤例使⽤,同时将其缓存起来。之后,根据 scope 的不

同,这个被缓存的 fixture 结果会在不同的时机被销毁。⽽再次引⽤ fixture 会重新执⾏ fixture 函数获得新的结果,如此周⽽复始

pytest ⾥的 fixture 可以使⽤五种作⽤域,它们的区别如下。

  • function(函数):默认作⽤域,结果会在每个测试函数结束后销毁。
  • class(类):结果会在执⾏完类⾥的所有测试⽅法后销毁。
  • module(模块):结果会在执⾏完整个模块的所有测试后销毁。
  • package(包):结果会在执⾏完整个包的所有测试后销毁。
  • session(测试会话):结果会在测试会话(也就是⼀次完整的 pytest 执⾏过程)结束后销毁。

举个例⼦,假如你把上⾯ random_token fixture 的 scope 改为 session:

1
2
3
@pytest.fixture(scope='session')
def random_token() -> str:
    ...

那么,⽆论你在测试代码⾥引⽤了多少次 random_token,在⼀次完整的 pytest 会话⾥,所有地⽅拿到的随机 token 都是同⼀个值。

因为 random_token 的作⽤域是 session,所以当 random_token 第⼀次被测试代码引⽤,创建出第⼀个随机值以后,这个值会被后续的所有测试⽤例复⽤。只有等到整个测试会话结束,random_token 的结果才会被销毁。

总结⼀下,fixture 是 pytest 最为核⼼的功能之⼀。通过定义 fixture,你可以快速创建出⼀些可复⽤的测试固定件,并在每个测试的开始和结束阶段⾃动执⾏特定的代码逻辑。

写单元测试不是浪费时间

不要总想着“补”测试

PR 是 Pull Request 的⾸字⺟缩写,它由开发者创建,⾥⾯包含对项⽬的代码修改。PR 在经过代码审查、讨论、调整的流程后,会并⼊主分⽀。PR 是⼈们通过 GitHub 进⾏代码协作的主要⼯具。

但事实是,单元测试不光能验证程序的正确性,还能极⼤地帮助你改进代码设计。但这种帮助有⼀个前提,那就是你必须在编写代码的同时编写单元测试。当开发功能与编写测试同步进⾏时,你会来回切换⾃⼰的⻆⾊,分别作为代码的设计者和使⽤者,不断从代码⾥找出问题,调整设计。经过多次调整与打磨后,你的代码会变得更好、更具扩展性。

测试代码并不⽐普通代码地位低,选择事后补测试,你其实⽩⽩丢掉了⽤测试驱动代码设计的机会。只有在编写代码时同步编写单元测试,才能更好地发挥单元测试的能⼒。

TDD(test-driven development,测试驱动开发)是由 Kent Beck 提出的⼀种软件开发⽅式。在 TDD ⼯作流下,要对软件做⼀个改动,你不会直接修改代码,⽽会先写出这个改动所需要的测试⽤例。

TDD 的⼯作流⼤致如下:

  • 写测试用例(哪怕测试⽤例引⽤的模块根本不存在);
  • 执⾏测试⽤例,让其失败;
  • 编写最简单的代码(此时只关⼼实现功能,不关⼼代码整洁度);
  • 执⾏测试⽤例,让测试通过;
  • 重构代码,删除重复内容,让代码变得更整洁;
  • 执⾏测试⽤例,验证重构
  • 重复整个过程。

但在实际⼯作中,我其实很少宣称⾃⼰在实践 TDD。因为在开发时,我基本不会严格遵循上⾯的 TDD 标准流程。⽐如,有时我会直接跳过 TDD 的前两个步骤,不写任何会失败的测试⽤例,直接就开始编写功能代码。

难测试的代码就是烂代码

举个例⼦,当模块依赖了⼀个全局对象时,写单元测试就会变得很难。全局对象的基本特征决定了它在内存中永远只会存在⼀份。⽽在编写单元测试时,为了验证代码在不同场景下的⾏为,我们需要⽤到多份不同的全局对象。这时,全局对象的唯⼀性就会成为写测试最⼤的阻碍。

因此,每当你发现很难为代码编写测试时,就应该意识到代码设计可能存在问题,需要努⼒调整设计,让代码变得更容易测试。也许你应该直接删掉全局对象,仅在它被⽤到的那⼏个地⽅每次⼿动创建⼀个新对象。也许你应该把 UserPostService 类按照不同的抽象级别,拆分为许多个不同的⼩类,把依赖 I/O 的功能和纯粹的数据处理完全隔离开来。

单元测试是评估代码质量的标尺。每当你写好⼀段代码,都能清楚地知道到底写得好还是坏,因为单元测试不会撒谎。

像应用代码一样对待测试代码

避免教条主义

好吧,我承认这个指责听上去有⼀些道理。但⾸先,单元测试⾥的单元(unit)其实并不严格地指某个⽅法、函数,其实指的是软件模块的⼀个⾏为单元,或者说功能单元

其次,某个测试⽤例应该算作集成测试或单元测试,这真的重要吗?在我看来,所有的⾃动化测试只要能满⾜⼏条基本特征:快、⽤例间互相隔离、没有副作⽤,这样就够了。

单元测试领域的理论确实很多,这刚好说明了⼀件事,那就是要做好单元测试真的很难。要更好地实践单元测试,你要做的第⼀件事就是抛弃教条主义,脚踏实地,不断寻求最合适当前项⽬的测试⽅案,这样才能最⼤地享受单元测试的好处。

总结

除了本章提到的这些内容以外,我还建议你继续学习⼀些敏捷编程、领域驱动设计、整洁架构⽅⾯的内容。从我的个⼈经历来看,这些知识对于⼤型项⽬开发有很好的启发作⽤。

⽆论如何,永远不要停⽌学习。

结语

不要掉进完美主义的陷阱。因为写代码不是什么纯粹的艺术创作,完美的代码是不存在的。有时,代码只要能满⾜当前需求,⼜为未来扩展留了空间就⾜够了 在从事编程⼯作⼗余年后,我深知写代码这件事很难,⽽给⼤型项⽬写代码更是难上加难。写好代码没有捷径,⽆⾮是要多看书、多看别⼈的代码、多写代码⽽已,但这些事说起来简单,要做好并不容易

最后更新于 Jan 21, 2025 13:04 CST
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计