Python 进阶语法
这篇是 Python 进阶语法的一份整理:从变量赋值和内置函数出发,走过序列、字典与推导式,再到函数进阶、特殊方法、鸭子类型,最后落在元编程(
type与元类)上,末尾附一组常用的优雅写法。所有示例都以 Python 3 为准。
1. 变量与赋值
先记住一句话:Python 中变量名指向的是对象的地址,不是对象的值。 变量只是贴在对象上的一个标签,赋值做的事情是让标签改指向另一个对象,而不是把数据搬进变量里。下面这些解包、交换的写法之所以成立,根子都在这里。
1.1 交换变量
不需要临时变量,右边先被打包成元组再解包给左边:
1 | a, b = b, a |
1.2 序列解包
等号右边是任意可迭代对象,左边的变量个数对得上就能一次性拆开:
1 | p = '1', '2', '3', 4 |
变量个数不确定时可以用星号收集剩余部分:
1 | first, *rest = [1, 2, 3, 4] |
关于命名、作用域和变量的更多讲究,推荐读 Python 工匠:善用变量改善代码质量。
2. 内置函数与 lambda
2.1 lambda 匿名函数
没有函数名的函数,适合写一次就丢的小逻辑(例如 sort 的 key):
1 | x = lambda a, b: a * b |
2.2 map
map() 把函数逐个应用到可迭代对象的元素上,返回的是迭代器而不是列表。多个序列长度不一致时,以最短的为准:
1 | x = map(lambda a, b: a + b, [1, 3, 5, 7, 9], [2, 4, 6, 8, 10, 11]) |
注意迭代器只能消费一次,list(x) 之后再取就是空的了。
2.3 filter
和 map() 类似,但只保留判断为真的元素:
1 | numbers = [1, 2, 3, 4, 5, 6, 7, 8] |
if num % 2 == 0: return True else: return False 这种写法可以直接简化成返回比较表达式本身。
2.4 any 与 all
all() 要求全部为真,any() 只要有一个为真。空序列的结果分别是 True 和 False:
1 | x = [0, 2, 1] |
2.5 zip
zip() 把多个可迭代对象里对应位置的元素打包成元组,同样以最短的为准,返回迭代器:
1 | keys = ['a', 'b', 'c', 'd', 'e'] |
3. 序列、字典与推导式
3.1 遍历时拿到下标:enumerate
普通版要靠下标去取值:
1 | items = [8, 23, 45] |
优雅版直接把下标和元素一起拿到:
1 | for index, item in enumerate(items): |
enumerate 还能指定起始序号,默认从 0 开始:
1 | for index, item in enumerate(items, start=1): |
3.2 append 与 extend 的区别
append 把参数当成一个新元素追加到末尾,参数可以是任意对象:
1 | x = [1, 2, 3] |
extend 的参数必须是可迭代对象,它把里面的元素逐个追加进去:
1 | x = [1, 2, 3] |
3.3 判断列表是否为空
普通版:
1 | if len(items) == 0: |
优雅版,直接利用容器的真值判断:
1 | if not items: |
3.4 切片
语法是 items[start:end:step],取的是 start 到 end - 1 之间的元素,step 是步长,默认 1:
1 | a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] |
对 range 对象切片得到的仍是 range,想要列表就先 list(range(10))。
3.5 拷贝列表
1 | new_list = old_list[:] # 切片 |
以上两种都是浅拷贝,嵌套的子对象仍然共享。需要完全独立的副本就用 copy 模块:
1 | import copy |
3.6 取最后一个元素
下标支持负数,从右往左数:
1 | a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] |
3.7 排序:sort 与 sorted
list.sort() 原地排序、返回 None;内置函数 sorted() 不动原列表、返回新列表。复杂结构靠 key 指定排序依据:
1 | items = [{'name': 'Homer', 'age': 39}, |
默认升序,加 reverse=True 变降序:
1 | items.sort(key=lambda item: item['age'], reverse=True) |
不想改动原列表就用 sorted():
1 | items = [{'name': 'Homer', 'age': 39}, |
3.8 删除元素:remove、del、pop
remove 按值删除,只删第一次出现的那个;找不到会抛 ValueError:
1 | a = [0, 2, 2, 3] |
del 按位置删除,越界抛 IndexError:
1 | a = [3, 2, 2, 1] |
pop 和 del 类似,但会把被删掉的元素返回:
1 | a = [4, 3, 5] |
3.9 连接两个列表
1 | listone = [1, 2, 3] |
列表能用 + 是因为它实现了 __add__。任何对象只要实现了 __add__ 都能支持 +,具体见后面”特殊方法(魔术方法)”一节。
3.10 随机取一个元素
random.choice() 从序列里随机返回一个元素,不需要自己算随机下标:
1 | import random |
3.11 频繁增删两端就换 deque
list 的查询很快,但在头部插入或删除元素时要搬动后面所有元素,效率较低。collections.deque 是双向队列,两端的增删都是常数时间:
1 | from collections import deque |
3.12 字典的几个常用写法
遍历键值对用 items(),它返回视图对象,不会额外复制一份数据:
1 | d = {1: 'haha', 2: 'xixi'} |
取值时用 get() 可以指定默认值,键不存在也不会抛 KeyError:
1 | d = {'name': 'foo'} |
按键分组时,setdefault() 和 defaultdict 都能免去”先判断键在不在”的样板代码:
1 | data = [('a', 1), ('a', 2), ('b', 3)] |
3.13 推导式
列表推导式是最常用的一种:
1 | [i for i in range(10)] |
字典和集合也有对应写法:
1 | numbers = [1, 2, 3, 4] |
把方括号换成圆括号得到的是生成器表达式,它不会一次性构建出全部元素:
1 | squares = (i * i for i in range(10)) # 生成器,边迭代边算 |
4. 函数进阶
4.1 为什么要写函数
- 给一组语句起一个名字,表达意图;
- 减少重复代码;
- 把一长段程序拆成多个函数再组合起来;
- 一次书写,多次调用。
调用时实参会赋值给形参。没有 return 的函数返回 None,它只产生副作用:
1 | def print_twice(x): |
4.2 增量开发
每次只增加并测试一小部分代码,避免最后陷入长时间调试。例如已知圆心坐标 (x1, y1) 和圆上一点 (x2, y2),求圆的面积。
第一步,先写面积:
1 | import math |
第二步,写两点间距离,也就是半径:
1 | def distance(x1, y1, x2, y2): |
函数体开头的字符串是文档字符串(docstring),用来简明地说明函数做什么。调试期间那些为了看中间值而写的临时变量属于”脚手架代码”,写完可以撤掉。
第三步,把两步串起来并封装:
1 | def circle_area(x1, y1, x2, y2): |
确认无误后再简化成一行:
1 | def circle_area(x1, y1, x2, y2): |
4.3 递归
调用自己的函数称为递归函数。没有终止条件的递归会一直调下去,直到超出递归深度上限:
1 | def recurse(): |
正确的递归一定有一个能返回的出口:
1 | def print_n(s, n): |
参数可能过大时,可以在函数开头加一道”守卫”,先把非法输入挡回去:
1 | def countdown(n): |
4.4 递归还是循环
同样的逻辑用 while 写不占用调用栈,数据量大时更稳:
1 | def countdown(n): |
Python 没有尾递归优化,默认递归深度只有一千层左右。所以”能自然写成循环的就写循环”,把递归留给树、图这类天生分叉的结构。循环里用 break 提前退出:
1 | while True: |
4.5 装饰器:把与业务无关的代码抽出去
装饰器可以把计时、缓存、日志这类和业务逻辑无关的代码抽离出来,让主逻辑保持干净,而且能重复利用。下面这个 cache 装饰器把抓过的网页存起来,同一个 URL 第二次就直接返回缓存:
1 | import urllib.request |
最容易踩的坑是把 return wrapper 缩进到了 wrapper 函数体内部——那样 cache() 本身没有返回值,被装饰的函数会变成 None,调用时报 TypeError: 'NoneType' object is not callable。另外这种纯粹的结果缓存,标准库已经给好了 functools.lru_cache,不必自己写。
装饰器的完整玩法(functools.wraps 保留元信息、带参数的装饰器、用类的 __call__ 实现装饰器等)见 11. 装饰器。
5. 特殊方法(魔术方法)
特殊方法就是以双下划线 __ 作前后缀的那些方法。它们是普通方法,只不过 Python 会在特定语法下自动调用它们——这就是”魔法”的来源:定义了 __str__,str() 和 print() 就知道怎么显示你的对象;定义了 __iter__ 和 __next__,for 循环就能迭代它;定义了 __bool__,if obj 就走你自己的判断逻辑。
5.1 实例的创建与销毁
__new__() 创造实例,__init__() 初始化实例。 __init__() 常被叫作构造函数,但它并不是最先执行的那个:
__new__(cls, *args, **kwargs):真正创建实例的方法,最先被调用;__init__(self, *args, **kwargs):初始化实例属性,绝大多数情况只需要写它;__del__(self):对象被垃圾回收时的行为,也叫析构方法。
需要澄清一点:参数不是由 __new__ “转交”给 __init__ 的,串起这两步的是元类上的 type.__call__。Cls(*args) 实际展开成这样:
1 | obj = Cls.__new__(Cls, *args) |
也就是说 __new__ 内部怎么折腾参数,都不影响 __init__ 收到什么,两边各自从调用处拿了一份原样的参数。由此还能推出一个可以实测的结论:如果 __new__ 返回的不是本类(或其子类)的实例,那句 isinstance 判断不成立,__init__ 根本不会被调用。
1 | class SimpleInit: |
1 | __new__ is called |
后面那段异常说明了两件事:__del__ 不该手动调用,因为对象真正被回收时它还会再执行一次;而 _list 已经被删掉了,第二次访问就抛 AttributeError。这类在 __del__ 里抛出的异常不会中断程序,只会被打印成 “Exception ignored”,非常难排查——所以 __del__ 里的逻辑越少越好。
5.2 算术运算与增量赋值
| 运算 | 方法 | 增量赋值 |
|---|---|---|
+ |
__add__ |
__iadd__ |
- |
__sub__ |
__isub__ |
* |
__mul__ |
__imul__ |
/ |
__truediv__ |
__itruediv__ |
// |
__floordiv__ |
__ifloordiv__ |
% |
__mod__ |
__imod__ |
** |
__pow__ |
__ipow__ |
& 按位与 |
__and__ |
__iand__ |
| 按位或 | __or__ |
__ior__ |
^ 按位异或 |
__xor__ |
__ixor__ |
<< 左移 |
__lshift__ |
__ilshift__ |
>> 右移 |
__rshift__ |
__irshift__ |
真除法在 Python 2 里叫 __div__,Python 3 已经改名为 __truediv__,写老代码时容易搞错。
实现 __add__ 之后,自定义对象就能用 +;顺手实现 __repr__ 还能让它在打印时有个像样的样子:
1 | class User: |
5.3 比较运算
__eq__(==)、__ne__(!=)、__lt__(<)、__gt__(>)、__le__(<=)、__ge__(>=)。
Python 3.7 起,@dataclass(order=True) 能自动生成这一整套比较方法,@dataclass 也会顺带生成 __init__ 和 __repr__,不必手写。
5.4 类型转换
__int__(self):int()__float__(self):float()__complex__(self):complex()__bool__(self):真值判断__index__(self):转成整数,对象被用作切片下标时调用
Python 2 里的 __long__、__oct__、__hex__ 在 Python 3 中已经移除:long 类型不存在了,oct() 和 hex() 转而依赖 __index__。
5.5 最常用的一批
__str__(self):给人看的字符串__repr__(self):给开发者看的字符串,交互式环境和容器打印时用它__hash__(self):hash()的行为,决定对象能否作字典键__len__(self):容器长度__getitem__/__setitem__/__delitem__:下标读、写、删__iter__(self):返回一个迭代器,让对象能被for遍历__contains__(self, item):in运算__call__(self):让实例可以像函数一样被调用
__call__() 不影响实例的构造和析构,但可以在调用时读写实例内部的状态:
1 | from dataclasses import dataclass |
迭代相关的 __iter__ / __next__ 以及生成器,见 10. 迭代对象、迭代器与生成器。
6. 鸭子类型与协议
“如果它走起来像鸭子、叫起来像鸭子,那它就是鸭子。” 鸭子类型说的是:调用方不关心对象属于哪个类,只关心它有没有需要的方法。下面三个类之间毫无继承关系,但都有 fly(),于是都能被 lift_off() 使唤——不管传进来的是类本身还是实例:
1 | class Duck: |
换成静态类型语言,这三者得先抽出一个公共接口或基类才能统一调用。Python 把这件事交给了运行时:能调通就行,调不通再抛 AttributeError。
上一节的特殊方法正是 Python 内置协议的实现方式——实现 __len__ 就能被 len() 调用,实现 __iter__ 就是可迭代对象,实现 __getitem__ 就能用下标取值。所谓”像序列一样的对象”,指的就是它实现了序列协议要求的那几个魔术方法,而不是它继承自 list。
7. 元编程:type 与元类
7.1 type 既能查类型,也是内置元类
元类是”类的类”:普通类描述实例的行为,元类描述类本身的行为。type 就是 Python 内置的元类,想改变类的行为(而不是实例的行为),就继承 type 定义自己的元类——这是 Python 里做元编程的主要方式。
1 | class SomeClass: |
用 inspect.isclass() 可以看得更清楚:实例不是类,而实例的类型是类。
1 | import inspect |
再往上追溯,type 的类型就是 type 自己,这条链到这里闭合:
1 | type(type(SomeClass)) |
除 type 之外,Python 中的一切都是对象,它们要么是类的实例,要么是元类的实例:
1 | isinstance(some_object, SomeClass) |
7.2 用 type 动态创建类
用一个参数调用 type 是查询类型,用三个参数调用则是创建一个新的类对象,参数依次是类名、基类元组、以及包含全部字段和方法的命名空间字典。
1 | SomeClass = type('SomeClass', (), {}) |
它等价于最普通的类定义:
1 | class SomeClass: |
带基类和成员的版本:
1 | def some_function(self): |
等价于:
1 | class ParentClass: |
区别在于前者的类名、基类、成员都可以在运行时算出来,这正是框架做代码生成时需要的能力。
7.3 自定义元类的用途
编写自定义元类分两步:先写一个 type 的子类,再通过 metaclass= 把它挂到类的创建流程上。
由于元类会被子类继承,它天然适合消除重复代码(DRY);同时它能在生成类对象的过程中插入额外操作,把与类创建有关的复杂逻辑收拢到一处。常见用例有:
- 抽象基类;
- 类的自动注册;
- 在库和框架中构建 API。
7.4 抽象基类
抽象基类只能被继承,不能被实例化,用来约定子类必须实现哪些方法:
1 | from abc import ABCMeta, abstractmethod |
子类把抽象方法全部实现之后才能正常实例化:
1 | class Truck(Vehicle): |
metaclass=ABCMeta 也可以简写成继承 abc.ABC,两者等价。
本节原先参考的 IBM developerWorks 文章链接已失效(developerWorks 已于 2019 年前后下线)。相同主题可改看官方文档 Metaclasses 与
abc抽象基类。
8. 优雅写法小结
8.1 字符串拼接优先用 join
1 | names = ['tom', 'jerry'] |
join 全程只产生一个字符串对象,而每次 + 都会生成一个新的字符串。不过现在 CPython 对循环里的 += 做了优化,少量拼接直接用加号并不会拖慢程序,不必被过时的经验绑住手脚;拼接大量片段时 join 依然是首选。
值得知道这个优化的成立条件,因为它随时会失效。它的原理是:当左侧字符串的引用计数为 1(除了这个变量没人再持有它),解释器就可以直接 realloc 原地扩容,不必新建对象再整体拷贝。一旦这个字符串还被别处引用住,就只能退回”每次新建 + 全量拷贝”,复杂度立刻变成 O(n²):
1 | # 快:s 只有一个引用,能原地扩容 |
两万次拼接实测(已经对齐 list append 的开销):前者 0.85 ms,后者 24.31 ms,差 28.7 倍。同理,self.s += x 这种写在实例属性上的也命中不了优化。
另外两个限制:这是 CPython 的实现细节,不是语言保证,PyPy 上完全没有这回事;所以在库代码里还是老老实实收集片段再 join,别把性能押在解释器的实现上。
8.2 打开文件用 with
1 | with open('xxx.xxx', 'r') as f: |
离开 with 代码块时文件一定会被关闭,中途抛异常也一样。
8.3 链式比较
1 | age = 18 |
链式比较会被展开成用 and 连接的多个比较,所以下面这行的结果是 False——它等价于 False == True and True == True and True == True:
1 | False == True == True == True # False |
8.4 条件表达式
简单的 if/else 赋值可以压成一行:
1 | text = '男' if gender == 'male' else '女' |
8.5 直接做真值判断
1 | if a: |
空字符串、空容器、0、None 都是假值,非空即真,不需要写 if len(a) != 0 或者 if a is not None and a != ''。
8.6 f-string 格式化
1 | a, b = 'haha', [1, 2, 3] |
f-string 需要 Python 3.6 及以上,冒号后面跟的是格式说明符,数字的位数、对齐、千分位都在这里控制。
8.7 for/else
Python 特有的写法:else 分支只在 for 正常跑完(没有被 break 打断)时执行,适合表达”找遍了也没找到”。
1 | mylist = [1, 2, 3, 'theflag', 4, 5, 6] |
有了 for/else,就不用再额外维护一个 flag_found 变量了。
8.8 生成器
生成器不会一次性把所有元素加载进内存,只有迭代到某个元素时才计算并返回它,而列表是预先把全部元素都建好。函数里出现 yield 就会让它变成生成器函数,执行到 yield 处暂停并交出控制权,下次迭代再从这里继续:
1 | def fib(n): |
概念上的细分(容器、可迭代对象、迭代器、生成器)见 10. 迭代对象、迭代器与生成器。
8.9 数字里的下划线分隔符
Python 3.6 起,数字字面量中可以插入 _ 来分组,不影响实际数值:
1 | a = 11_22_33_44 # 11223344 |
8.10 类型注解
给参数和返回值加上类型注解,能让编辑器和 mypy 这类工具帮你查错:
1 | def my_add(a: str, b: int) -> str: |
注意注解只是提示,解释器并不做检查,my_add(1, 2) 照样会返回 3 而不报错。真正的校验要靠静态检查工具或运行时的显式判断。
9. 模块与导入
9.1 给 import 添加搜索路径
import 只在 sys.path 列出的目录里找模块。要导入的模块不在其中时,可以在运行时把目录追加进去:
1 | import sys |
这属于临时手段,路径写死在代码里不利于迁移;长期方案是把项目做成包,或者用 PYTHONPATH、虚拟环境来管理。
9.2 一个模块只会被导入一次
同一个模块无论被 import 多少次,模块里的代码只在第一次导入时执行,之后都直接复用 sys.modules 中的缓存。所以在交互式环境里改完模块源码,再 import 一遍是没有效果的,得显式重新加载:
1 | import importlib |
⚠️ 注:老代码里常见的
from imp import *配reload(...)已经不能照抄了——imp模块自 Python 3.4 起被废弃,并在 Python 3.12 中彻底移除,请改用importlib.reload()。另外reload(sys)是 Python 2 时代为了调sys.setdefaultencoding('utf-8')才有的偏方,Python 3 里这个函数已经不存在,也不需要这么做。
10. 迭代对象、迭代器与生成器
容器
容器是一种把多个元素组织在一起的数据结构,容器中的元素可以逐个地迭代获取,可以用 in , not in 关键字判断元素是否包含在容器中
assert 如果为Fasle, 那么raise一个AssertionError
1 | assert False, 'error' |
大多数容器提供了某种方式来获取其中每一个元素,(是可迭代对象赋予了容器这种能力)
可迭代对象 (iterable)
凡是可以返回一个迭代器的对象都可以称之为可迭代对象
可迭代对象只需要实现 __iter__,而且它返回的是一个新的迭代器,不是自己;迭代器才额外实现 __next__,并且它自己的 __iter__ 返回 self。
1 | x = [1, 2, 3] # 列表,可迭代对象 |
这也正是”列表能被 for 反复遍历,迭代器只能走一遍”的原因:每次 for 一个列表,都会向它要一个全新的游标;而 for 一个迭代器,用的还是那个已经走过一遍的游标。
怎么判断一个对象到底可不可迭代
用 collections.abc 里的 ABC 做 isinstance 是最直接的判据:
1 | from collections.abc import Iterable, Iterator |
但这里有个坑:只实现了 __getitem__ 的旧式序列协议同样能被 for 迭代,而 ABC 判不出来。
1 | class Seq: |
iter() 拿不到 __iter__ 时会退回这条老路:从 0 开始依次取 obj[0]、obj[1]……直到抛 IndexError 为止。所以”可迭代”其实有两条通路,isinstance(x, Iterable) 只覆盖了新式那条;真想判准就直接 try: iter(x)。
还有一个不太常见但很好用的两参形式 iter(callable, sentinel):反复调用第一个参数,直到返回值等于哨兵就停。
1 | it = iter(lambda c=iter([1, 2, 3, 99]): next(c), 99) |
典型用法是按块读文件:for block in iter(lambda: f.read(4096), b''): ...。
最后一点:for 循环内部是吞掉 StopIteration 来结束的。这解释了一个容易踩的坑——生成器里如果不小心让 StopIteration 冒出来(比如手写 next() 而不判断),在 Python 3.7 之后会被转成 RuntimeError 而不是安静地结束循环(PEP 479)。
迭代器 (iterator)
迭代器是一个带状态的对象,能在调用 next() 方法时返回容器的下一个值.
迭代器等有人需要的时候才生成值返回, 没调用的时候, 就处于休眠状态等待下一次调用。
生成器(generator)
相比其他容器对象,它更能节省内存和cpu。
1 | sum(i for i in range(100_000_000)) # 生成器表达式,边算边丢,内存几乎不涨 |
生成器是一种特殊的迭代器,只需要yield关键字。生成器一定是迭代器(反之不成立)。
1 | def someting(): |
都可以改写
1 | def iter_something(): |
生成器还有另一面:控制流
上面讲的都是”省内存”这一面——生成器作为数据源被动地被取值。但 yield 是个表达式,它还能接收值,这就跨到协程那一侧去了:
1 | def accumulator(): |
配套的三个方法:send(v) 把 v 作为 yield 表达式的值送进去并继续执行;throw(exc) 在 yield 那一行抛异常,让生成器有机会自己 try/except 处理;close() 在 yield 处抛 GeneratorExit,配合 try/finally 就能做可靠的资源清理。
yield from 则是把迭代和这套双向通道一起转交给子生成器,不用自己手写转发循环。这条路径正是 asyncio 早期基于生成器实现协程的基础,后来才有了独立的 async def / await 语法。
从”省内存的迭代器”走到”能被驱动、能接收数据、能被中止的控制流单元”,是理解生成器的分水岭。
生成器表达式(generator expression)
列表推导式的生成器版本,但他返回的是一个生成器对象,而不是列表对象
1 | a = (x * x for x in range(10)) |
生成器只能遍历一次
附
判断 a 是不是 b 的子序列
1 | def is_subsequence(a, b): |
11. 装饰器
绝大多数装饰器都是基于函数和 闭包 实现的,但这并非制造装饰器的唯一方式.
Python 对某个对象是否能通过装饰器(@decorator)形式使用只有一个要求:decorator 必须是一个“可被调用(callable)的对象
1 | def foo(): pass |
只要自定义类的 __call__ 魔法方法即可让任意类变成可被调用
1 | from dataclasses import dataclass |
1 | import time |
1 |
|
wrapt 模块是一个专门帮助编写装饰器的工具库。可以非常方便的改造 provide_number 装饰器,完美解决“嵌套层级深”和“无法通用”两个问题
1 | import wrapt |
1 | import time |
1 | def add(a, b): |
参考
- 序列与容器一节的部分内容整理自微信公众号文章《Python 列表操作》
- 优雅写法一节是阅读《Python Cookbook》时的笔记
- 元编程一节参考的 IBM developerWorks 原文已随站点下线,替代资料见该节末尾的官方文档链接