封面来源:由博主个人绘制,如需使用请联系博主。

0. 学习目标与范围

面向 Java 开发者的 Python 一文中,跟随帝国理工学院 Josiah Wang 教授的课程,对 Python 基础进行了快速梳理。

本文将在此基础上继续补充部分 Python 基础知识:

本文暂不讨论 Python 中的并发与异步编程。这部分内容相对独立,展开后篇幅较大,后续有实际需求时再单独整理成文。

我们的目标是快速掌握 Python 基础,学习构建现代 Python 项目,进而学习 LangChain、LangGraph 等基于 Python 的 AI Agent 框架。

1. 异常处理

1.1 基本语法

在 Java 中,异常处理使用 try...catch 块来完成,Python 也差不多,使用 try...except 块来完成。

比如根据输入的两个数来计算它们的商:

1
2
3
4
5
6
7
8
try:
    a = int(input("a = "))
    b = int(input("b = "))
    print(f"a/b = {a / b}")
except ValueError:
    print("Cannot convert your input to a number.")
except ZeroDivisionError:
    print("b should not be zero.")

如果要用相同的方式处理这些异常,也可以一起处理:

1
2
3
4
try:
    # --snip--
except (ValueError, ZeroDivisionError):
    print("Invalid input. You must enter non-zero integers.")

Python 建议让 try 代码块保持短小,每个 try 代码块最好只针对一种特定错误,后续维护时更容易定位出现错误的位置:

1
2
3
4
5
6
7
8
9
10
11
try:
    a = int(input("a = "))
    b = int(input("b = "))
except ValueError:
    print("Cannot convert your input to a number.")
    exit()

try:
    print(f"a/b = {a / b}")
except ZeroDivisionError:
    print("b should not be zero.")

1.2 异常之后的处理

如果 try 块里的代码没有异常,可能还需要继续运行一些代码。

如何让 try 块保持最小,又在没有异常时执行一些代码呢?

Python 还支持 else 子句。当 try 块里的代码没有出现异常时,就会执行 else 子句里的内容:

1
2
3
4
5
6
7
8
9
10
11
try:
    x = a / b
except ZeroDivisionError:
    print("b should not be zero.")
else:
    print("No errors. Woohoo!")
    y = x * 5
    z = y + 1
    print(x + y + z)

print("The end.")

与 Java 类似,如果要无论是否出现异常都执行,Python 也支持 finally 块:

1
2
3
4
5
6
7
8
9
10
11
try:
    x = a / b
except ZeroDivisionError:
    print("b should not be zero.")
else:
    print("No errors. Woohoo!")
    y = x * 5
    z = y + 1
    print(x + y + z)
finally:
    print("The end.")

1.3 捕获所有异常

可以在 except 后不指定任何具体的异常类型来处理任何类型的异常:

1
2
3
4
5
6
7
8
9
10
try:
    a = int(input("a = "))
    b = int(input("b = "))
    print(f"a/b = {a / b}")
except ValueError:
    print("Cannot convert your input to a number.")
except ZeroDivisionError:
    print("b should not be zero.")
except:
    print("Something bad happened.")

PEP 8 不建议这样做,因为会:

  • 捕获 KeyboardInterrupt,用户按 Ctrl + C 也无法终止程序
  • 捕获 SystemExitsys.exit() 可能失效
  • 掩盖编程错误,例如 NameErrorTypeError 也会被静默吞掉
  • 丢失错误信息,尤其是配合 pass
  • 异常处理返回不明确,维护者无法判断究竟处理了什么错误

如果要捕获所有表示程序错误的异常,应该使用 except Exception:,但这同样要谨慎使用。如果可以,最好还是使用具体的异常,既保证代码可读性,又使未来的维护更加容易。

1.4 自定义异常

在 Python 里定义一个自定义异常需要继承 Exception(或它的子类),PEP 8 还建议异常类型末尾以 Error 结尾,比如 StupidInputError

1
2
3
4
5
6
7
8
9
10
class StupidInputError(Exception):
    def __init__(self, input, message="Don't be silly!"):
        self.input = input
        self.message = message

    def print(self):
        print(
            f"Josiah says: {self.message} "
            f"You have typed a stupid input: {self.input}"
        )

使用方式和内置异常无异:

1
2
3
4
5
6
7
try:
    user_input = input("Please enter a stupid input: ")
    raise StupidInputError(user_input, "You are silly.")
except StupidInputError as err:
    err.print()
    print(err.input)
    print(err.message)
Please enter a stupid input: hahaha
Josiah says: You are silly. You have typed a stupid input: hahaha
hahaha
You are silly.

2. 星号的非乘法用法

Python 中的 * 除了用于乘法计算外,还有其他的作用。与它紧密相关的 ** 除了用于次幂计算外,也有其他的作用。

与 C 系列语言不同,Python 里的 *** 不涉及指针或内存地址。

* 主要用于处理「一组位置值」,** 主要用于处理「一组名称和值」:

符号 动作 输入 结果
* 展开 一个可迭代对象 多个位置值
* 收集 多个位置值 一个元组或列表
** 展开 一个映射 多个关键字参数
** 收集 多个关键字参数 一个字典

2.1 展开位置参数

在调用函数时,使用 * 将一个可迭代对象展开为多个独立的位置参数。

1
2
3
4
5
6
def rgb(red, green, blue):
    print(red, green, blue)


colors = [255, 128, 0]
rgb(*colors)

等价于:

1
rgb(255, 128, 0)

如果不加 * 直接传入 colors

1
rgb(colors)

整个列表会被当作一个参数,这时 rgb() 会因为缺少另外两个参数而抛出 TypeError

使用 * 后,Python 会在调用函数时检查展开后的参数数量,过多、过少都会抛出 TypeError

可以展开任何可迭代对象

* 不仅适用于列表,元组、range、字符串和推导式等各种 可迭代对象 都可以被展开:

1
2
3
rgb(*(255, 128, 0))
rgb(*range(3))
rgb(*(value for value in [1, 2, 3]))

字符串字面量和字典有一些特殊:

1
2
3
4
5
# 字符串字面量被展开为单个字符
print(*"ABC")  # A B C
# 字典按键展开
data = {"name": "张三", "age": 18}
print(*data)   # name age

如果要把字典展开成关键字参数,需要使用 **,后面会说。

典型用法:zip(*pairs)

假设数据按「姓名、成绩」成对排列:

1
2
3
4
pairs = [
    ("张三", 85),
    ("李四", 92),
]

执行 zip(*pairs) 后,*pairs 先把外层列表展开为两个参数:

1
2
3
4
zip(
    ("张三", 85),
    ("李四", 92),
)

然后 zip() 函数每次从两个参数中分别取出一个元素:

1
2
第 1 次:("张三", "李四")
第 2 次:(85, 92)

最终:

1
2
3
4
names, scores = zip(*pairs)

print(names)   # ("张三", "李四")
print(scores)  # (85, 92)

整个过程相当于把行转换为列:

1
2
张三  85              张三  李四
李四  92      -->      85   92

zip(*rows) 常被称为转置或解压。

下面代码中存在两次解包:

1
2
3
names, scores = zip(*pairs)
#                   ^ 1. 调用参数展开
# ^^^^^^^^^^^^^     2. 返回值解构赋值

2.2 收集位置参数

在定义函数时,参数上使用 * 会把调用方传入的多个位置参数收集成一个元组:

1
2
3
4
5
6
def add(*numbers):
    print(numbers)
    return sum(numbers)


add(10, 20, 30)
(10, 20, 30)
60

收集和展开也可以配合使用:

1
2
3
4
5
def wrapper(*args):
    print("调用前")
    result = add(*args)
    print("调用后")
    return result

在定义 wrapper() 函数时,使用 *args 收集调用 wrapper() 函数时的位置参数。

在调用 add() 函数时,传入 *args 再把先前收集的元组展开传给 add() 函数。

这种写法常见于装饰器、代理函数和框架代码。

2.3 处理关键字参数

* 用于处理位置参数,** 则用于处理带名称的关键字参数。

定义函数时收集关键字参数

在定义函数时,参数上使用 ** 会把未被其他形参接收的关键字参数收集成一个字典:

1
2
3
4
5
6
def create_user(name, **attributes):
    print(name)
    print(attributes)


create_user("张三", age=18, city="成都")
张三
{'age': 18, 'city': '成都'}

调用函数时展开关键字参数

在调用函数时,使用 ** 会把映射中的键和值展开成关键字参数:

1
2
3
4
5
6
7
8
9
10
def greet(name, message):
    print(f"{message}{name}")


options = {
    "name": "张三",
    "message": "你好",
}

greet(**options)

最后调用 greet() 函数的方式等价于:

1
greet(name="张三", message="你好")

映射的键必须是字符串,且能被目标函数接收。

如果同一个参数同时通过位置、显式名称或 ** 重复传值,会抛出 TypeError

1
2
greet("张三", **{"name": "李四", "message": "你好"})
# TypeError:参数 name 被重复传值

同时转发位置参数和关键字参数

*** 经常配合使用,将调用参数原样转发给另一个函数:

1
2
3
4
5
6
7
8
def wrapper(function, *args, **kwargs):
    print("调用前")
    result = function(*args, **kwargs)
    print("调用后")
    return result


wrapper(greet, "张三", message="你好")

* 收集并展开位置参数,** 收集并展开关键字参数。

2.4 限制参数的传递方式

函数定义中的裸 * 不负责收集数据,而是参数类别的分界线,表示它后面的参数只能按名称传递(这类参数被称为「仅限关键字参数」),前面的参数既可以按位置传递,也可以按名称传递。

1
2
def connect(host, port, *, timeout=10, retry=3):
    print(host, port, timeout, retry)

调用方式:

1
2
3
4
# 正确调用方式
connect("localhost", 8080, timeout=30, retry=5)
# 错误调用方式
connect("localhost", 8080, 30, 5)  # TypeError

*args 后面的参数也仅限关键字参数

1
2
3
4
5
def send(message, *receivers, timeout=10):
    print(message, receivers, timeout)


send("你好", "张三", "李四", timeout=30)

此时:

1
2
receivers == ("张三", "李四")
timeout == 30

因为 timeout 位于 *receivers 后面,所以调用时必须写成 timeout=30

使用 / 限制仅限位置参数

Python 3.8 开始,在定义函数时可以使用 / 规定它前面的参数只能按位置传递,不能写出参数名:

1
2
def do_something(a, b, /, c, *, d=0, e=0):
    print(a, b, c, d, e)

调用方式:

1
2
3
4
5
6
# 正确调用方式
do_something(1, 3, 5, d=4, e=2)
do_something(1, 3, c=5, d=4, e=2)
# 错误调用方式
do_something(a=1, b=3, c=5, d=4, e=2)
# TypeError:a 和 b 是仅限位置参数

同时使用 /* 时,函数签名被分成三个区域:

  1. / 左侧:只能按位置传递
  2. /* 之间:既可以按位置传递,也可以按名称传递
  3. * 右侧:只能按名称传递

/ 和裸 * 只是参数类别的分界线,本身不是参数,也不会接收值。

2.5 收集剩余元素

Python 支持解构赋值:

1
name, age = ["张三", 18]

如果左右两侧元素数量不一致,会抛出 ValueError

1
2
name, age = ["张三", 18, "北京"]
# ValueError:要解包的值过多

使用 * 可以收集剩余元素:

1
2
3
4
name, *others = ["张三", 18, "北京", "程序员"]

print(name)    # 张三
print(others)  # [18, "北京", "程序员"]

赋值场景中,带 * 的变量得到的一定是列表。

* 可以出现在不同位置

收集尾部元素:

1
2
3
4
first, *rest = [1, 2, 3, 4]

# first = 1
# rest = [2, 3, 4]

收集头部元素:

1
2
3
4
*rest, last = [1, 2, 3, 4]

# rest = [1, 2, 3]
# last = 4

收集中间元素:

1
2
3
4
5
first, *middle, last = [1, 2, 3, 4, 5]

# first = 1
# middle = [2, 3, 4]
# last = 5

注意, 同一层赋值中只能有一个带 * 的目标:

1
2
first, *middle, *rest = [1, 2, 3]
# SyntaxError

在循环中解构

1
2
3
4
5
6
7
rows = [
    ["张三", 80, 90, 85],
    ["李四", 70, 88, 92],
]

for name, *scores in rows:
    print(name, *scores)
张三 80 90 85
李四 70 88 92

2.6 构造容器时展开元素

展开到列表

1
2
3
4
5
6
7
left = [1, 2]
right = [4, 5]

result = [0, *left, 3, *right, 6]

print(result)
# [0, 1, 2, 3, 4, 5, 6]

展开到元组

1
2
3
4
5
6
values = [2, 3]

result = (1, *values, 4)

print(result)
# (1, 2, 3, 4)

只有一个展开项时,元组仍然需要尾部逗号:

1
result = (*values,)

展开到集合

1
2
3
4
5
6
7
a = {1, 2}
b = {2, 3}

result = {*a, *b}

print(result)
# {1, 2, 3}

集合会自动去重。

展开是浅层操作

* 只展开最外层,不会递归处理嵌套结构:

1
2
3
4
5
6
values = [[1, 2], [3, 4]]

result = [*values]

print(result)
# [[1, 2], [3, 4]]

2.7 模式匹配中的应用

Python 3.10 开始支持结构化模式匹配,也可以使用 * 收集剩余部分:

1
2
3
4
5
6
command = ["copy", "a.txt", "b.txt", "backup"]

match command:
    case ["copy", source, *targets]:
        print(source)
        print(targets)
a.txt
['b.txt', 'backup']

2.8 通配符导入

与 Java 类似,Python 可以使用 import * 一次性导入模块中的所有公开名称:

1
from math import *

由于 Python 的导入发生在运行时,并会直接向当前命名空间写入名称。多个模块中的同名对象可能互相覆盖,因此实际项目通常不推荐 使用 import *

更清晰的写法是:

1
2
import math
math.sqrt(16)

或者显式导入需要的名称:

1
from math import sin, sqrt

2.9 注意事项

展开操作会消费迭代器

生成器和迭代器通常只能完整遍历一次:

1
2
3
4
values = (number for number in range(3))

print(*values)  # 0 1 2
print(*values)  # 不再输出元素

无序集合的展开顺序不可靠

1
2
values = {1, 2, 3}
print(*values)

集合本身不保证业务所需的稳定顺序,不应依赖展开后的排列结果。

容器展开会创建新容器

1
combined = [*left, *right]

这会创建一个新列表,但其中的嵌套对象仍然是原来的引用,因此它是浅层复制,不是深复制。

参数错误通常在运行时暴露

Python 会在函数实际调用时验证展开后的参数数量、名称和重复传值问题,因此需要通过测试覆盖相关调用路径。

2.10 用法总结

非乘法场景中的 * 主要表达两个相反方向:

1
2
3
4
5
def function(*args):
    #             ^ 把多个位置参数收集为元组

    other(*args)
    #     ^ 把元组展开为多个位置参数

与它对应,** 在字典和关键字参数之间转换:

1
2
3
4
5
def function(**kwargs):
    #              ^ 把多个关键字参数收集为字典

    other(**kwargs)
    #      ^ 把字典展开为多个关键字参数

简单来说:

  • * 的含义由语法位置决定。在调用函数和构造容器时表示展开,在函数定义和解构赋值时表示收集
  • ** 在函数调用中展开关键字参数,在函数定义中把关键字参数收集成字典
  • 函数定义中的 / 表示它前面的参数只能按位置传递,函数定义中的裸 * 表示它后面的参数只能按名称传递
  • 展开依赖可迭代协议,并不要求对象必须是列表或元组

3. Lambda 与高阶函数

3.1 Lambda

Java 8 里的一个重量级特性就是 Lambda 表达式,在 Python 里也有类似的特性。

比如现在有一个 add() 函数:

1
2
3
4
5
def add(x, y, z):
    return x + y + z


print(add(1, 2, 3))

如果用 Lambda 的形式来定义 add()

1
2
add = lambda x, y, z: x + y + z
print(add(1, 2, 3))

上述代码仅做展示,实际开发场景不推荐这么做,该用 def 定义方法还是用 def,可读性更强。

Python Lambda 的基本结构是:

1
lambda 参数: 返回值表达式

注意: Lambda 的函数体只能有一个表达式(Expression,如上面的 x + y + z),函数体里不能包含语句(Statement,如 returnassertraise)。

Python 中,函数天然就是一等公民,无需依赖 Lambda 才能进行函数式编程,Lambda 只是提供了一种「在表达式中创建函数」的便捷写法,用于简短的临时计算规则,而不是代替普通函数。与直接使用函数相比,使用 Lambda 的唯一优势是不需要为该函数想一个名字。如果使用 Lambda 会降低代码可读性,那就不要使用。

Java 中,方法不是一等公民,而是通过 Lambda 表达式和方法引用让一段行为能够像值一样被传递,注意区分两者的区别。

3.2 Lambda 的使用场景

在 Python 标准库里,当函数需要一个「从元素中提取比较依据」的函数参数时,通常把这个参数命名为 key,此时以 Lambda 的形式传入参数就很方便。

sort() 里的 Lambda

如何自定义 list 的排序规则呢?

使用 Lambda:

1
2
3
4
5
images = [
    "image1", "image2", "image30", "image3", "image20", "image200", "image100"
]
images.sort(key=lambda image: int(image[5:]))
print(images)
['image1', 'image2', 'image3', 'image20', 'image30', 'image100', 'image200']

sorted() 里的 Lambda

现在有一个包含词频的 dict,需要按照词频对单词进行降序排序,可以使用内置的 sorted() 函数:

1
2
3
freq = {"python": 24, "cat": 78, "mat": 12, "aardvark": 1, "fish": 56}
sorted_tuples = sorted(freq.items(), key=lambda x: x[1], reverse=True)
print(sorted_tuples)
[('cat', 78), ('fish', 56), ('python', 24), ('mat', 12), ('aardvark', 1)]

当两个单词的词频相同时,再按照单词升序排序:

1
2
3
freq = {"snake": 24, "python": 24, "cat": 78, "mat": 12, "mac": 12, "fish": 56}
sorted_tuples = sorted(freq.items(), key=lambda x: (-x[1], x[0]))
print(sorted_tuples)
[('cat', 78), ('fish', 56), ('python', 24), ('snake', 24), ('mac', 12), ('mat', 12)]

max()min() 里的 Lambda

max()min() 函数也支持传入 Lambda:

1
2
3
4
5
words = ["the", "person", "is", "extremely", "smart"]
# 'extremely'
longest_word = max(words, key=lambda word: len(word))
# 'is'
shortest_word = min(words, key=lambda word: len(word))

3.3 高阶函数

在 Python 中,一切皆对象,函数也不例外。

>>> type(max)
<class 'builtin_function_or_method'>
>>> def my_func():
...     return 1
...
>>> type(my_func)
<class 'function'>
>>> type(my_func())
<class 'int'>

如果一个函数可以接收函数作为输入参数,或者能够返回一个函数,那么称这个函数为「高阶函数」。

3.4 内置高阶函数

Python 中有两个常见的内置高阶函数 map()filter(),使用这两个函数能够对可迭代对象进行转换和过滤。

当然,使用列表推导式也能实现相同的效果,并且 Python 社区也更偏好使用列表推导式,因为它让代码的含义更一目了然。

map()

有一个数值列表 numbers,现在需要将列表中的每个元素乘 3 再加 5。

使用列表推导式,可以这样做:

1
transformed = [3 * x + 5 for x in numbers]

当然也可以使用 map() 函数:

1
transformed = list(map(lambda x: 3 * x + 5, numbers))

再举一个例子,有一个字符串列表 greetings

1
greetings = ["hello", "bonjour", "hola", "ciao"]

现在需要将列表中的每个单词首字母大写,然后再重复一遍。

使用列表推导式有:

1
transformed = [greeting.capitalize() * 2 for greeting in greetings]

使用 map() 函数:

1
transformed = list(map(lambda x: x.capitalize() * 2, greetings))

似乎已经初见端倪,列表推导式确实要清晰一些。

filter()

有一个数值列表 numbers,现在需要过滤出其中的偶数元素并形成新列表 even_numbers

使用列表推导式:

1
even_numbers = [n for n in numbers if n % 2 == 0]

使用 filter() 函数可以这样做:

1
even_numbers = list(filter(lambda n: n % 2 == 0, numbers))

3.5 作用域、闭包与延迟绑定

Python 查找名称时遵循 LEGB 顺序:

  1. Local:当前函数的局部作用域
  2. Enclosing:外层函数的作用域
  3. Global:当前模块的全局作用域
  4. Built-in:Python 的内置作用域
1
2
3
4
5
6
7
8
9
10
11
12
13
14
message = "global"


def outer():
    message = "enclosing"

    def inner():
        message = "local"
        print(message)

    inner()


outer()  # local

赋值与 global

如果函数内部出现了对某个名称的赋值,Python 默认会把它认定为局部变量:

1
2
3
4
5
6
7
8
count = 0


def increment():
    count += 1


increment()  # UnboundLocalError

count += 1 既要读取又要赋值,但局部变量 count 在读取时还没有值,因此抛出 UnboundLocalError

使用 global 关键字来声明操作的是全局变量:

1
2
3
4
5
6
count = 0


def increment():
    global count
    count += 1

不过实际开发中应该尽量减少全局状态的变更。

闭包与 nonlocal

内部函数可以保留对外层函数变量的引用,即使外层函数已经执行结束,内部函数仍然能够访问这些变量。这种结构称为闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def create_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment


counter = create_counter()

print(counter())  # 1
print(counter())  # 2

nonlocal 表示赋值的目标来自外层函数作用域,既不是当前函数的局部变量,也不是模块中的全局变量。

循环变量的延迟绑定

闭包在函数真正执行时才读取其中引用的变量,因此下面三个 Lambda 会共享同一个 number

1
2
3
4
5
6
7
functions = []

for number in range(3):
    functions.append(lambda: number)

print([function() for function in functions])
# [2, 2, 2]

循环结束后 number 的值是 2,调用三个 Lambda 时读取到的都是这个值。

可以使用默认参数在创建函数时保存当前值:

1
2
3
4
5
6
7
functions = []

for number in range(3):
    functions.append(lambda number=number: number)

print([function() for function in functions])
# [0, 1, 2]

4. 鸭子类型

4.1 抽象类

Python 不使用关键字来实现抽象类(Java 使用 abstract 关键字实现抽象类)。

Python 通过继承 abc.ABC(抽象基类)显式将某个类设为抽象类,通过使用 @abstractmethod 装饰器将方法显式标记为抽象方法。

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


class Device(ABC):
    def __init__(self, name):
        self.name = name

    @abstractmethod
    def turn_off(self):
        pass


device = Device("Some device")

如果执行上述代码,会出现以下错误:

TypeError: Can't instantiate abstract class Device with abstract methods turn_off

4.2 鸭子类型

Java 通常根据参数声明的静态类型,在编译阶段检查实参是否兼容。Python 中的对象同样具有明确的运行时类型,但函数调用默认不会根据类型限制实参。

采用鸭子类型的代码不要求对象属于某个指定类型,而是直接使用当前需要的操作。只要对象提供这些操作,并且调用方式与预期兼容,就可以被使用,无需继承共同父类或显式实现接口。

这就是所谓的:「如果一个对象走路像鸭子,叫声也像鸭子,那么就可以把它当作鸭子使用。」

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Duck:
    def speak(self):
        print("嘎嘎")


class Person:
    def speak(self):
        print("我在模仿鸭子")


def make_it_speak(obj):
    obj.speak()


make_it_speak(Duck())
make_it_speak(Person())

make_it_speak() 不要求 obj 必须是 Duck,只要求执行 obj.speak() 时可以无参数调用该方法。如果方法不存在,会抛出 AttributeError;如果方法不可调用或参数不兼容,则可能抛出 TypeError

1
2
3
4
5
6
7
class WrongSpeaker:
    def speak(self, message):
        print(message)


make_it_speak(WrongSpeaker())
# TypeError

Python 中许多内置功能都采用了鸭子类型,比如 len() 不只适用于列表,任何实现了 __len__() 方法的对象都可以传给 len(),比如字符串、字典、集合。

类似的还有:

  • 能迭代,就可以用于 for
  • 支持 __enter__()__exit__(),就可以用于 with

鸭子类型减少了调用方对具体类型和继承层次的依赖,因此更容易替换实现、构造测试对象和复用代码。

Python 还经常采用 EAFP(Easier to Ask Forgiveness than Permission)风格:先尝试执行操作,在操作失败时处理异常。EAFP 经常与鸭子类型配合使用,但二者不是同一个概念。鸭子类型关注对象是否具备所需能力,EAFP 关注何时以及如何处理失败。

鸭子类型也有代价:接口要求不够直观,错误可能到运行时才暴露。

如果希望在运行前发现这类不兼容问题,可以使用 Protocol 描述对象需要具备的能力。

4.3 Protocol

启用 Pyright、mypy 等静态类型检查工具时,可以使用 Protocol 描述所需能力:

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


# 继承 Protocol,使 Speaker 被标记为协议类
class Speaker(Protocol):
    def speak(self) -> None:
        pass


# 让静态类型检查器知道这个参数需要满足 Speaker 协议,需要在参数上标注类型
def make_it_speak(obj: Speaker) -> None:
    obj.speak()


class Duck:
    def speak(self) -> None:
        print("嘎嘎")


make_it_speak(Duck())

Speaker 是协议,而不是必须继承的业务父类。

Duck 无需继承 Speaker,只要方法签名兼容,静态类型检查器就会认为它满足协议。make_it_speak() 函数上的类型标注只供静态检查工具使用,Python 运行时不会自动检查传入对象是否满足 Speaker 协议。

推荐按以下方式处理鸭子类型:

  • 项目内部使用 Protocol 和静态类型检查工具
  • 外部输入或插件边界需要友好报错时,使用内置函数 getattr() 获取对象的属性和 callable() 判断一个对象能否被调用进行运行时判断
  • 只有确实需要通过 isinstance() 做运行时分支检查时才使用 @runtime_checkable,这个装饰器通常只能检查成员是否存在,不能完整验证方法签名

@runtime_checkable 的用法

1
2
3
4
5
6
7
8
9
10
11
12
from typing import Protocol, runtime_checkable


@runtime_checkable
class Speaker(Protocol):
    def speak(self) -> None:
        pass


# 需要运行时判断 obj 是否是 Speaker 实例
if isinstance(obj, Speaker):
    obj.speak()

不过这种检查比较粗略,通常只检查成员是否存在,不会完整检查方法签名:

1
2
3
4
5
6
class WrongSpeaker:
    def speak(self, message: str) -> int:
        return 1


isinstance(WrongSpeaker(), Speaker)  # 可能仍是 True

5. 类型特性

5.1 类型提示

Python 是一门动态语言,因此它的变量不会绑定到任何特定的类型上。

这虽然减少了对变量类型的过度关注,但有时了解变量的预期类型更容易写出健壮的代码。

Python 3.5 中引入了 Type hinting(类型提示)功能,用于标注函数参数和返回值的预期类型:

1
2
def greet(username: str, visits: int = 0) -> str:
    return f"Greetings, {username}. You have visited us {visits} times."

IterableIterator

类型标注还可以表达函数需要的是可迭代对象,还是会记录遍历位置的迭代器:

1
2
3
4
5
6
7
8
9
10
from collections.abc import Iterable, Iterator


def total(values: Iterable[int]) -> int:
    return sum(values)


def generate_ids() -> Iterator[int]:
    yield 1
    yield 2

Iterable[int] 表示函数接受能够产出整数的可迭代对象,例如列表、元组或生成器;Iterator[int] 表示返回的对象本身会记录遍历位置,通常只能完整消费一次。

需要注意,参数标注为 Iterable 并不意味着它一定能重复遍历,因为迭代器本身也是可迭代对象。如果函数确实需要多次遍历,可以先将其转换为列表:

1
items = list(values)

这样可以重复遍历,但也会一次性加载全部元素,需要结合数据量判断是否合适。

更多有关类型提示的相关特性可以参考 typing — Support for type hints

5.2 命名元组

有时候可能不需要一个完整的类,很自然可以想到使用元组。

但是元组只能通过索引进行访问,不够直观。

此时可以使用 namedtuple,顾名思义,这是「带字段名的元组」。它用于表示结构固定、创建后不需要修改的轻量数据,比如坐标、颜色等。

>>> from collections import namedtuple
>>> Coordinates = namedtuple("Coordinates", "row col")
>>> type(Coordinates)
<class 'type'>
>>> position = Coordinates(4, 5)
>>> position
Coordinates(row=4, col=5)
>>> position.row
4
>>> position.col
5
>>> position[0]  # 依旧可以通过索引访问
4
>>> position[1]
5
>>> Coordinates(4, 5) == (4, 5)  # Coordinates 依旧是一个元组
True

5.3 数据类

已知可以使用以下方式定义一个类:

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

    def __repr__(self):
        return f"Person(name='{self.name}', age={self.age})"

    def greet(self):
        print(f"Hi! My name is {self.name}!")


lecturer = Person("Josiah", 20)
print(lecturer)       # Person(name='Josiah', age=20)
print(lecturer.name)  # Josiah
print(lecturer.age)   # 20

每次定义属性时就要编写一个 __init__() 方法,内部写上 self.xx = xx 的样板代码。

Python 3.7 里引入了数据类,使用 @dataclass 后可以像在 Java 中定义属性一样来定义:

1
2
3
4
5
6
7
8
9
10
from dataclasses import dataclass


@dataclass
class Person:
    name: str
    age: int = 0

    def greet(self):
        print(f"Hi! My name is {self.name}!")

数据类中的可变默认值需要使用 field(default_factory=...)。这样每次创建实例时都会调用工厂函数,得到一个新的对象:

1
2
3
4
5
6
from dataclasses import dataclass, field


@dataclass
class Team:
    members: list[str] = field(default_factory=list)

数据类不允许直接把常见的可变对象作为字段默认值,否则会在定义类时抛出 ValueErrordefault_factory 与函数参数使用 None 避免共享默认列表解决的是同一类问题。

更多有关数据类的相关特性可以参考 dataclasses — Data Classes

6. 装饰器

6.1 内部/嵌套函数

在 Python 里,可以在一个函数中定义另一个函数,这些函数被称为内部函数或嵌套函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def outer(text):
    print("Inside outer")

    def inner():
        print("Inside inner")
        print(text)
        print("Exiting inner")

    print("Calling inner")
    inner()
    print("Exiting outer")


outer("Hello!")
Inside outer
Calling inner
Inside inner
Hello!
Exiting inner
Exiting outer

注意,内部函数中的 print(text) 访问了 inner() 之外的 text 变量。inner() 保留了对外层变量的引用,这就是前面介绍过的闭包。

理解了上面的内部函数后,在开始装饰器的正式内容前,还需要知道:

  • Python 中,函数是一等对象:函数可以赋值给变量,函数可以存储在数据结构中,函数可以作为参数,函数可以作为返回值
  • 高阶函数:接受函数作为参数,或返回一个函数的函数

如果简单接触过 JavaScript,上述内容很好理解。

6.2 初识装饰器

先来一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def christmas_decorator(func):
    def wrapper():
        print("wrapping")
        func()
        print("with a Christmas-y wrapper")

    return wrapper


def gift():
    print("a toy")


print("Before decorating:")
gift()
print(gift)

print("After decorating:")
gift = christmas_decorator(gift)
gift()
print(gift)

Python 装饰器就是一个函数,它接收一个函数作为输入,然后再返回一个函数。

在函数(装饰器)内部,定义一个内部函数,在内部函数中调用传入的函数,此时可以为传入的函数添加更多功能,最后返回的是内部函数。

使用装饰器时,gift = christmas_decorator(gift) 会把装饰器返回的函数重新赋值给原始函数的名称。

这条赋值语句中提到了两次 gift。为了减少重复,可以使用 @装饰器名称 的方式标注原始函数,后续调用原始函数的效果与显式赋值一致:

1
2
3
4
5
6
@christmas_decorator
def gift():
    print("a toy")


gift()

@wraps(func)

通常会使用 @wraps 标注装饰器内部的内部函数。

还是以 christmas_decorator() 为例,在不使用 @wraps 时,尝试打印 gift() 函数的名称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def christmas_decorator(func):
    def wrapper():
        print("wrapping")
        func()
        print("with a Christmas-y wrapper")

    return wrapper


@christmas_decorator
def gift():
    print("a toy")


print(gift.__name__)  # 'wrapper'

根据输出结果可知,装饰后的函数实际上是 wrapper

修改它,再打印 gift() 函数的名称:

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


def christmas_decorator(func):
    @wraps(func)
    def wrapper():
        print("wrapping")
        func()
        print("with a Christmas-y wrapper")

    return wrapper


# --snip--

print(gift.__name__)  # 'gift'

使用 @wraps 后,经过装饰的函数还会保留原始名称、文档等信息。

6.3 装饰器与参数

装饰带有参数的函数

实际情况下,被装饰的函数通常都带有参数。那怎么装饰这些函数呢?

给内部函数定义几个参数就行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from functools import wraps


def validator(func):
    @wraps(func)
    def wrapper(numerator, denominator):
        try:
            return func(numerator, denominator)
        except ZeroDivisionError:
            print("Sorry, I do not know how to divide by 0.")
            return float("NaN")

    return wrapper


@validator
def divide(numerator, denominator):
    return numerator / denominator


answer = divide(5, 0)  # Sorry, I do not know how to divide by 0.
print(answer)          # nan

如果不知道被装饰的函数究竟有多少个参数,只需在内部函数中使用 *args**kwargs 即可适配所有情况:

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


def repeat(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)
        func(*args, **kwargs)

    return wrapper


@repeat
def greet(message, firstname, lastname):
    print(f"{message}, {firstname} {lastname}!")


greet("Merry Christmas", "Josiah", "Wang")
Merry Christmas, Josiah Wang!
Merry Christmas, Josiah Wang!

*args**kwargs 用于收集参数:

  • *args 收集所有位置参数,包装成元组
  • **kwargs 收集所有关键字参数,包装成字典
  • Python 中的位置参数必须写在关键字参数之前,因此内部函数的参数列表顺序是 *args, **kwargs

带有参数的装饰器

如果装饰器需要参数改怎么办?

以前面的 @repeat 为例,现在默认重复输出两次,那如果要重复输出 nn 次,难道要写 nn 个装饰器?

没有什么是套一层不能解决的,如果有,那就再套一层。

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
from functools import wraps


def n_repeat(times):
    def repeat(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for i in range(times):
                func(*args, **kwargs)

        return wrapper

    return repeat


@n_repeat(5)  # 通过参数传递次数
def greet(message, firstname, lastname):
    print(f"{message}, {firstname} {lastname}!")


@n_repeat(times=3)  # 也可以使用关键字参数
def sing(line):
    print(line)


sing("We wish you a Merry Christmas...")
greet("Happy New Year", "Josiah", "Wang")

print(sing)

调用 singgreet 函数等价于:

1
2
sing = n_repeat(3)(sing)
greet = n_repeat(5)(greet)

以调用 greet() 为例,实际上有三层:

1
2
3
4
5
6
7
8
n_repeat(5)
    → 返回 repeat

repeat(greet)
    → 返回 wrapper

greet()
    → 执行 wrapper

6.4 链式装饰器

可以使用多个装饰器来装饰同一个函数,它们会按照列出的顺序执行。

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
from functools import wraps


def html(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("<html>")
        func(*args, **kwargs)
        print("</html>")

    return wrapper


def body(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("<body>")
        func(*args, **kwargs)
        print("</body>")

    return wrapper


@html
@body
def text_printer(text):
    print(text)


text_printer("This is my text")
<html>
<body>
This is my text
</body>
</html>

简化上述代码为:

1
2
3
4
@A
@B
def func():
    pass

被装饰的 func 函数等价于:

1
func = A(B(func))

装饰时从下往上包,调用时从外往里进入。

6.5 装饰器的示例

为了保持被装饰函数原有的返回语义,通用装饰器通常应返回原函数的调用结果;如果原函数未显式返回,该结果自然是 None

将函数的执行状态保存到指定文件里

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 wraps


def logger(filename="log.txt"):
    def logger_decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            with open(filename, "a", encoding="utf-8") as logfile:
                logfile.write(f"Calling {func.__name__}\n")
                result = func(*args, **kwargs)
                logfile.write(f"{func.__name__} returned\n")
            return result

        return wrapper

    return logger_decorator


@logger(filename="add.log")
def add():
    pass


add()

函数在指定时间内未执行完毕时,抛出 TimeoutError

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
from functools import wraps
import signal


def timeout(seconds):
    def process_timeout(func):
        def handle_timeout(signum, frame):
            raise TimeoutError("The function timed out")

        @wraps(func)
        def wrapper(*args, **kwargs):
            signal.signal(signal.SIGALRM, handle_timeout)
            signal.alarm(seconds)

            try:
                return func(*args, **kwargs)
            finally:
                signal.alarm(0)  # No need to time out!

        return wrapper

    return process_timeout


@timeout(seconds=2)
def infinite():
    print("Running infinite...")
    while True:
        pass


# Wait 2 seconds. It should time out.
infinite()

6.6 属性装饰器的原理

@property 可以把方法包装成属性,使调用方以 obj.attribute 的形式触发方法。它本质上结合了装饰器语法、property 类和描述符协议。

1
2
3
4
5
6
7
8
9
10
11
12
13
class User:
    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("age 不能小于 0")
        self._age = value

其中,property 本身是一个类。装饰器只要求是可调用对象,因此函数、类和实现了 __call__() 的实例都可以作为装饰器:

1
2
3
@property
def age(self):
    return self._age

等价于:

1
2
3
4
5
def age(self):
    return self._age


age = property(age)

同理,@MyProperty 等价于 age = MyProperty(age):调用类的构造过程,将原函数传入并得到一个实例。

@age.setter@age.deleter 分别注册赋值与删除逻辑。最终,property 对象通过描述符协议把属性读写转换为受控的方法调用,具体机制见第 7 节。

如果想要探索更多使用装饰器的不同方式,可以查看 PythonDecoratorLibrary

7. 属性查找与描述符

7.1 类字典与实例字典

类体中定义的方法、类属性和描述符保存在类的命名空间中,可以通过 类.__dict__ 查看;通过 self.name = value 设置的数据通常保存在当前对象的 实例.__dict__ 中:

1
2
3
4
5
6
7
8
9
10
11
class User:
    category = "person"

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


user = User("张三")

User.__dict__["category"]  # 类共享的数据
user.__dict__              # {"name": "张三"}

因此,类字典主要保存所有实例共享的定义,实例字典保存每个对象自己的状态。通过实例给普通类属性重新赋值时,通常不会修改类字典,而是在实例字典中创建同名属性:

1
2
3
4
user.category = "vip"

print(user.__dict__)  # {"name": "张三", "category": "vip"}
print(User.category)  # person

此后访问 user.category 会得到实例字典中的 "vip",它遮蔽了类中的普通同名属性。描述符则可以改变这种普通的遮蔽关系。

7.2 描述符与属性查找

定义了 __get__()__set__()__delete__() 中任一方法,并作为类属性使用的对象称为描述符。这三个方法分别用于属性的读取、赋值和删除。

下面实现一个管理年龄的描述符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Age:
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__["_age"]

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("age 不能小于 0")
        instance.__dict__["_age"] = value

    def __delete__(self, instance):
        del instance.__dict__["_age"]


class User:
    age = Age()


user = User()
user.age = 18

Age 对象保存在 User 的类字典中,负责所有实例共同遵守的读写规则;每个用户的实际年龄则保存在各自实例字典的 _age 中。于是 user.ageuser.age = 20del user.age 会分别触发上述三个方法。

实现了 __set__()__delete__() 的对象称为数据描述符;只有 __get__() 的对象称为非数据描述符。Python 函数就是常见的非数据描述符,访问 user.method 时,它会把函数与 user 绑定成方法。

访问实例属性时,Python 大致按以下顺序查找:

  1. 类及其父类中的数据描述符
  2. 实例字典中的属性
  3. 类及其父类中的非数据描述符或普通类属性
  4. 找不到时调用 __getattr__()

数据描述符的优先级高于实例字典,因此实例中的同名属性不能绕过它所规定的读写逻辑。

7.3 property 的原理

property 是数据描述符,放在类字典中并记录 getter、setter 和 deleter:

  • user.age 通过 __get__() 调用 getter
  • user.age = 20 通过 __set__() 调用 setter
  • del user.age 通过 __delete__() 调用 deleter
  • User.age 返回 property 对象本身

以读取 user.age 为例,可以粗略理解为 Python 找到类字典中的 property 对象后执行:

1
User.__dict__["age"].__get__(user, User)

描述符再调用 getter,从实例中取得 _age。赋值和删除的过程类似,只是分别进入 __set__()__delete__()

即使没有定义 setter,property 仍然是数据描述符;此时赋值会进入它的 __set__(),然后因为缺少 setter 而抛出 AttributeError。这也意味着不能通过在实例字典中放入同名属性来绕过只读的 property

可以将三者的职责概括为:类字典保存共享的属性规则,实例字典保存各个对象的数据,描述符负责在属性访问时连接二者。