封面来源:由博主个人绘制,如需使用请联系博主。
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 也无法终止程序
捕获 SystemExit,sys.exit() 可能失效
掩盖编程错误,例如 NameError、TypeError 也会被静默吞掉
丢失错误信息,尤其是配合 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)
等价于:
如果不加 * 直接传入 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 是仅限位置参数
同时使用 / 和 * 时,函数签名被分成三个区域:
/ 左侧:只能按位置传递
/ 与 * 之间:既可以按位置传递,也可以按名称传递
* 右侧:只能按名称传递
/ 和裸 * 只是参数类别的分界线,本身不是参数,也不会接收值。
2.5 收集剩余元素
Python 支持解构赋值:
如果左右两侧元素数量不一致,会抛出 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 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 * 一次性导入模块中的所有公开名称:
由于 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 的基本结构是:
注意: Lambda 的函数体只能有一个表达式(Expression,如上面的 x + y + z),函数体里不能包含语句(Statement,如 return、assert、raise)。
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 顺序:
Local:当前函数的局部作用域
Enclosing:外层函数的作用域
Global:当前模块的全局作用域
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."
Iterable 与 Iterator
类型标注还可以表达函数需要的是可迭代对象,还是会记录遍历位置的迭代器:
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 并不意味着它一定能重复遍历,因为迭代器本身也是可迭代对象。如果函数确实需要多次遍历,可以先将其转换为列表:
这样可以重复遍历,但也会一次性加载全部元素,需要结合数据量判断是否合适。
更多有关类型提示的相关特性可以参考 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 )
数据类不允许直接把常见的可变对象作为字段默认值,否则会在定义类时抛出 ValueError。default_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 为例,现在默认重复输出两次,那如果要重复输出 n n n 次,难道要写 n n n 个装饰器?
没有什么是套一层不能解决的,如果有,那就再套一层。
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)
调用 sing 和 greet 函数等价于:
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 函数等价于:
装饰时从下往上包,调用时从外往里进入。
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.age、user.age = 20 和 del user.age 会分别触发上述三个方法。
实现了 __set__() 或 __delete__() 的对象称为数据描述符;只有 __get__() 的对象称为非数据描述符。Python 函数就是常见的非数据描述符,访问 user.method 时,它会把函数与 user 绑定成方法。
访问实例属性时,Python 大致按以下顺序查找:
类及其父类中的数据描述符
实例字典中的属性
类及其父类中的非数据描述符或普通类属性
找不到时调用 __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。
可以将三者的职责概括为:类字典保存共享的属性规则,实例字典保存各个对象的数据,描述符负责在属性访问时连接二者。