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__ 实现装饰器等)另有一篇专门讲:Python 装饰器。
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__ 以及生成器,另见迭代对象,迭代器,生成器。
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): |
概念上的细分(容器、可迭代对象、迭代器、生成器)见迭代对象,迭代器,生成器。
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 里这个函数已经不存在,也不需要这么做。
参考
- 序列与容器一节的部分内容整理自微信公众号文章《Python 列表操作》
- 优雅写法一节是阅读《Python Cookbook》时的笔记
- 元编程一节参考的 IBM developerWorks 原文已随站点下线,替代资料见该节末尾的官方文档链接