Python 进阶语法

这篇是 Python 进阶语法的一份整理:从变量赋值和内置函数出发,走过序列、字典与推导式,再到函数进阶、特殊方法、鸭子类型,最后落在元编程(type 与元类)上,末尾附一组常用的优雅写法。所有示例都以 Python 3 为准。

1. 变量与赋值

先记住一句话:Python 中变量名指向的是对象的地址,不是对象的值。 变量只是贴在对象上的一个标签,赋值做的事情是让标签改指向另一个对象,而不是把数据搬进变量里。下面这些解包、交换的写法之所以成立,根子都在这里。

1.1 交换变量

不需要临时变量,右边先被打包成元组再解包给左边:

1
a, b = b, a

1.2 序列解包

等号右边是任意可迭代对象,左边的变量个数对得上就能一次性拆开:

1
2
p = '1', '2', '3', 4
a, b, c, d = p

变量个数不确定时可以用星号收集剩余部分:

1
2
first, *rest = [1, 2, 3, 4]
print(first, rest) # 1 [2, 3, 4]

关于命名、作用域和变量的更多讲究,推荐读 Python 工匠:善用变量改善代码质量

2. 内置函数与 lambda

2.1 lambda 匿名函数

没有函数名的函数,适合写一次就丢的小逻辑(例如 sortkey):

1
2
x = lambda a, b: a * b
print(x(2, 3)) # 6

2.2 map

map() 把函数逐个应用到可迭代对象的元素上,返回的是迭代器而不是列表。多个序列长度不一致时,以最短的为准:

1
2
3
x = map(lambda a, b: a + b, [1, 3, 5, 7, 9], [2, 4, 6, 8, 10, 11])
print(x) # <map object at 0x00000255474D24A8>
print(list(x)) # [3, 7, 11, 15, 19]

注意迭代器只能消费一次,list(x) 之后再取就是空的了。

2.3 filter

map() 类似,但只保留判断为真的元素:

1
2
3
4
5
6
7
8
9
10
numbers = [1, 2, 3, 4, 5, 6, 7, 8]


def is_even(num):
return num % 2 == 0


filtered = filter(is_even, numbers)
print(filtered) # <filter object at 0x00000237EFD62438>
print(list(filtered)) # [2, 4, 6, 8]

if num % 2 == 0: return True else: return False 这种写法可以直接简化成返回比较表达式本身。

2.4 any 与 all

all() 要求全部为真,any() 只要有一个为真。空序列的结果分别是 TrueFalse

1
2
3
x = [0, 2, 1]
print(all(x)) # False
print(any(x)) # True

2.5 zip

zip() 把多个可迭代对象里对应位置的元素打包成元组,同样以最短的为准,返回迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
keys = ['a', 'b', 'c', 'd', 'e']
values = [1, 2, 3, 3]
zipped = dict(zip(keys, values))
print(zipped) # {'a': 1, 'b': 2, 'c': 3, 'd': 3}

# 键值互换,重复的值会互相覆盖,只留最后一个
d = {v: k for k, v in zipped.items()}
print(d) # {1: 'a', 2: 'b', 3: 'd'}

# 按字典的值从大到小排序
print(dict(sorted(d.items(), key=lambda item: item[1], reverse=True)))
# {3: 'd', 2: 'b', 1: 'a'}

3. 序列、字典与推导式

3.1 遍历时拿到下标:enumerate

普通版要靠下标去取值:

1
2
3
items = [8, 23, 45]
for index in range(len(items)):
print(index, '-->', items[index])

优雅版直接把下标和元素一起拿到:

1
2
3
4
5
6
for index, item in enumerate(items):
print(index, '-->', item)

# 0 --> 8
# 1 --> 23
# 2 --> 45

enumerate 还能指定起始序号,默认从 0 开始:

1
2
3
4
5
6
for index, item in enumerate(items, start=1):
print(index, '-->', item)

# 1 --> 8
# 2 --> 23
# 3 --> 45

3.2 append 与 extend 的区别

append 把参数当成一个新元素追加到末尾,参数可以是任意对象:

1
2
3
4
x = [1, 2, 3]
y = [4, 5]
x.append(y)
print(x) # [1, 2, 3, [4, 5]]

extend 的参数必须是可迭代对象,它把里面的元素逐个追加进去:

1
2
3
4
5
6
7
8
x = [1, 2, 3]
y = [4, 5]
x.extend(y)
print(x) # [1, 2, 3, 4, 5]

# 等价于
for i in y:
x.append(i)

3.3 判断列表是否为空

普通版:

1
2
3
4
5
if len(items) == 0:
print('空列表')

if items == []:
print('空列表')

优雅版,直接利用容器的真值判断:

1
2
if not items:
print('空列表')

3.4 切片

语法是 items[start:end:step],取的是 startend - 1 之间的元素,step 是步长,默认 1:

1
2
3
4
5
6
7
8
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

a[3:8] # [4, 5, 6, 7, 8] 下标 3 到 7
a[3:8:2] # [4, 6, 8] 隔一个取一个
a[1::2] # [2, 4, 6, 8, 10] 从下标 1 开始,步长 2
a[:5] # [1, 2, 3, 4, 5] 省略 start 表示从头开始
a[3:] # [4, 5, 6, 7, 8, 9, 10] 省略 end 表示到末尾
a[::] # 全部省略等于拷贝一份,注意这是浅拷贝

range 对象切片得到的仍是 range,想要列表就先 list(range(10))

3.5 拷贝列表

1
2
new_list = old_list[:]        # 切片
new_list = list(old_list) # 构造函数

以上两种都是浅拷贝,嵌套的子对象仍然共享。需要完全独立的副本就用 copy 模块:

1
2
3
4
import copy

new_list = copy.copy(old_list) # 浅拷贝
new_list = copy.deepcopy(old_list) # 深拷贝

3.6 取最后一个元素

下标支持负数,从右往左数:

1
2
3
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
a[len(a) - 1] # 10
a[-1] # 10

3.7 排序:sort 与 sorted

list.sort() 原地排序、返回 None;内置函数 sorted() 不动原列表、返回新列表。复杂结构靠 key 指定排序依据:

1
2
3
4
5
6
7
items = [{'name': 'Homer', 'age': 39},
{'name': 'Bart', 'age': 10},
{'name': 'cater', 'age': 20}]

items.sort(key=lambda item: item['age'])
print(items)
# [{'name': 'Bart', 'age': 10}, {'name': 'cater', 'age': 20}, {'name': 'Homer', 'age': 39}]

默认升序,加 reverse=True 变降序:

1
2
items.sort(key=lambda item: item['age'], reverse=True)
# [{'name': 'Homer', 'age': 39}, {'name': 'cater', 'age': 20}, {'name': 'Bart', 'age': 10}]

不想改动原列表就用 sorted()

1
2
3
4
5
6
7
items = [{'name': 'Homer', 'age': 39},
{'name': 'Bart', 'age': 10},
{'name': 'cater', 'age': 20}]

new_items = sorted(items, key=lambda item: item['age'])
print(items) # 原列表顺序不变
print(new_items) # 新列表按 age 升序

3.8 删除元素:remove、del、pop

remove 按值删除,只删第一次出现的那个;找不到会抛 ValueError

1
2
3
4
5
6
7
8
>>> a = [0, 2, 2, 3]
>>> a.remove(2)
>>> a
[0, 2, 3]
>>> a.remove(7)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: list.remove(x): x not in list

del 按位置删除,越界抛 IndexError

1
2
3
4
5
6
7
8
>>> a = [3, 2, 2, 1]
>>> del a[1]
>>> a
[3, 2, 1]
>>> del a[7]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list assignment index out of range

popdel 类似,但会把被删掉的元素返回:

1
2
3
4
5
6
7
8
9
>>> a = [4, 3, 5]
>>> a.pop(1)
3
>>> a
[4, 5]
>>> a.pop(7)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: pop index out of range

3.9 连接两个列表

1
2
3
4
5
listone = [1, 2, 3]
listtwo = [4, 5, 6]

merged = listone + listtwo
print(merged) # [1, 2, 3, 4, 5, 6]

列表能用 + 是因为它实现了 __add__。任何对象只要实现了 __add__ 都能支持 +,具体见后面”特殊方法(魔术方法)”一节。

3.10 随机取一个元素

random.choice() 从序列里随机返回一个元素,不需要自己算随机下标:

1
2
3
4
5
import random

items = [8, 23, 45, 12, 78]
random.choice(items) # 78
random.choice(items) # 45

3.11 频繁增删两端就换 deque

list 的查询很快,但在头部插入或删除元素时要搬动后面所有元素,效率较低。collections.deque 是双向队列,两端的增删都是常数时间:

1
2
3
4
5
from collections import deque

names = deque([1, 2, 3, 4, 'haha'])
names.popleft() # 从左边弹出
names.appendleft('mark') # 从左边插入

3.12 字典的几个常用写法

遍历键值对用 items(),它返回视图对象,不会额外复制一份数据:

1
2
3
d = {1: 'haha', 2: 'xixi'}
for key, value in d.items():
print(key, '+', value)

取值时用 get() 可以指定默认值,键不存在也不会抛 KeyError

1
2
3
d = {'name': 'foo'}
d.get('name', 'unknown') # 'foo'
d.get('age', 'unknown') # 'unknown'

按键分组时,setdefault()defaultdict 都能免去”先判断键在不在”的样板代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
data = [('a', 1), ('a', 2), ('b', 3)]

groups = {}
for key, value in data:
groups.setdefault(key, []).append(value)

from collections import defaultdict

groups = defaultdict(list)
for key, value in data:
groups[key].append(value)

print(groups) # defaultdict(<class 'list'>, {'a': [1, 2], 'b': [3]})

3.13 推导式

列表推导式是最常用的一种:

1
[i for i in range(10)]

字典和集合也有对应写法:

1
2
3
4
numbers = [1, 2, 3, 4]

my_dict = {number: number * 2 for number in numbers} # {1: 2, 2: 4, 3: 6, 4: 8}
my_set = {number % 3 for number in numbers} # {0, 1, 2}

把方括号换成圆括号得到的是生成器表达式,它不会一次性构建出全部元素:

1
squares = (i * i for i in range(10))   # 生成器,边迭代边算

4. 函数进阶

4.1 为什么要写函数

  1. 给一组语句起一个名字,表达意图;
  2. 减少重复代码;
  3. 把一长段程序拆成多个函数再组合起来;
  4. 一次书写,多次调用。

调用时实参会赋值给形参。没有 return 的函数返回 None,它只产生副作用:

1
2
3
4
5
6
7
def print_twice(x):
print(x)
print(x)


a = print_twice('haha')
print(a is None) # True

4.2 增量开发

每次只增加并测试一小部分代码,避免最后陷入长时间调试。例如已知圆心坐标 (x1, y1) 和圆上一点 (x2, y2),求圆的面积。

第一步,先写面积:

1
2
3
4
5
6
7
8
import math


def area(radius):
return math.pi * radius ** 2


print(area(2)) # 12.566370614359172

第二步,写两点间距离,也就是半径:

1
2
3
4
5
6
def distance(x1, y1, x2, y2):
"""计算两点之间的距离"""
dx = x2 - x1
dy = y2 - y1 # 临时变量,保存中间结果
dsquared = dx ** 2 + dy ** 2
return math.sqrt(dsquared)

函数体开头的字符串是文档字符串(docstring),用来简明地说明函数做什么。调试期间那些为了看中间值而写的临时变量属于”脚手架代码”,写完可以撤掉。

第三步,把两步串起来并封装:

1
2
3
def circle_area(x1, y1, x2, y2):
radius = distance(x1, y1, x2, y2)
return area(radius)

确认无误后再简化成一行:

1
2
3
4
5
def circle_area(x1, y1, x2, y2):
return area(distance(x1, y1, x2, y2))


circle_area(0, 0, 2, 2) # 25.132741228718352

4.3 递归

调用自己的函数称为递归函数。没有终止条件的递归会一直调下去,直到超出递归深度上限:

1
2
3
4
5
def recurse():
recurse()


recurse() # RecursionError: maximum recursion depth exceeded

正确的递归一定有一个能返回的出口:

1
2
3
4
5
6
7
8
def print_n(s, n):
if n <= 0:
return
print(s)
print_n(s, n - 1)


print_n('haha', 5)

参数可能过大时,可以在函数开头加一道”守卫”,先把非法输入挡回去:

1
2
3
4
5
6
7
8
9
10
11
12
def countdown(n):
if n > 100000: # 守卫,保护后面的代码不被撑爆
print('over limit')
return
elif n <= 0:
print('haha')
else:
print(n)
countdown(n - 1)


countdown(10000000000000) # over limit

4.4 递归还是循环

同样的逻辑用 while 写不占用调用栈,数据量大时更稳:

1
2
3
4
5
6
7
8
def countdown(n):
if n > 100000:
print('over limit')
return
while n > 0:
print(n)
n -= 1
print('haha')

Python 没有尾递归优化,默认递归深度只有一千层左右。所以”能自然写成循环的就写循环”,把递归留给树、图这类天生分叉的结构。循环里用 break 提前退出:

1
2
3
4
5
while True:
line = input('>')
if line == 'done':
break
print(line)

4.5 装饰器:把与业务无关的代码抽出去

装饰器可以把计时、缓存、日志这类和业务逻辑无关的代码抽离出来,让主逻辑保持干净,而且能重复利用。下面这个 cache 装饰器把抓过的网页存起来,同一个 URL 第二次就直接返回缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import urllib.request


def cache(func):
saved = {} # 闭包变量,跟着 wrapper 一起活着

def wrapper(url):
if url in saved:
return saved[url]
page = func(url)
saved[url] = page
return page

return wrapper # 必须和 def wrapper 同级!


@cache
def web_lookup(url):
return urllib.request.urlopen(url).read()

最容易踩的坑是把 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__() 常被叫作构造函数,但它并不是最先执行的那个:

  1. __new__(cls, *args, **kwargs):真正创建实例的方法,最先被调用;
  2. __init__(self, *args, **kwargs):初始化实例属性,绝大多数情况只需要写它;
  3. __del__(self):对象被垃圾回收时的行为,也叫析构方法。

需要澄清一点:参数不是由 __new__ “转交”给 __init__ 的,串起这两步的是元类上的 type.__call__Cls(*args) 实际展开成这样:

1
2
3
obj = Cls.__new__(Cls, *args)
if isinstance(obj, Cls):
obj.__init__(*args) # 收到的是同一份原始参数

也就是说 __new__ 内部怎么折腾参数,都不影响 __init__ 收到什么,两边各自从调用处拿了一份原样的参数。由此还能推出一个可以实测的结论:如果 __new__ 返回的不是本类(或其子类)的实例,那句 isinstance 判断不成立,__init__ 根本不会被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class SimpleInit:
def __new__(cls, *args, **kwargs):
print('__new__ is called')
return super().__new__(cls)

def __init__(self, value=10):
print('__init__ is called')
print('self is:', self)
self._list = [value]

def __del__(self):
print('__del__ is called', self._list)
del self._list


a = SimpleInit()
a.__del__() # 手动调用只是执行了方法,对象并没有真的销毁
1
2
3
4
5
6
__new__ is called
__init__ is called
self is: <__main__.SimpleInit object at 0x0000017e8a132470>
__del__ is called [10]
Exception ignored in: <function SimpleInit.__del__ at 0x0000017e8a0c5378>
AttributeError: 'SimpleInit' object has no attribute '_list'

后面那段异常说明了两件事:__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
2
3
4
5
6
7
8
9
10
11
12
13
14
class User:
def __init__(self, age):
self.age = age

def __repr__(self):
return 'User(%d)' % self.age

def __add__(self, other):
return User(self.age + other.age)


user_a = User(10)
user_b = User(20)
print(user_a + user_b) # User(30)

5.3 比较运算

__eq__==)、__ne__!=)、__lt__<)、__gt__>)、__le__<=)、__ge__>=)。

Python 3.7 起,@dataclass(order=True) 能自动生成这一整套比较方法,@dataclass 也会顺带生成 __init____repr__,不必手写。

5.4 类型转换

  1. __int__(self)int()
  2. __float__(self)float()
  3. __complex__(self)complex()
  4. __bool__(self):真值判断
  5. __index__(self):转成整数,对象被用作切片下标时调用

Python 2 里的 __long____oct____hex__ 在 Python 3 中已经移除:long 类型不存在了,oct()hex() 转而依赖 __index__

5.5 最常用的一批

  1. __str__(self):给人看的字符串
  2. __repr__(self):给开发者看的字符串,交互式环境和容器打印时用它
  3. __hash__(self)hash() 的行为,决定对象能否作字典键
  4. __len__(self):容器长度
  5. __getitem__ / __setitem__ / __delitem__:下标读、写、删
  6. __iter__(self):返回一个迭代器,让对象能被 for 遍历
  7. __contains__(self, item)in 运算
  8. __call__(self):让实例可以像函数一样被调用

__call__() 不影响实例的构造和析构,但可以在调用时读写实例内部的状态:

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


@dataclass
class X:
a: int
b: int
limit: int

def __call__(self):
print('__call__ with ({}, {})'.format(self.a, self.b))


x = X(1, 2, 3)
print(x) # X(a=1, b=2, limit=3)
x() # 把实例直接当函数调用:__call__ with (1, 2)

迭代相关的 __iter__ / __next__ 以及生成器,见 10. 迭代对象、迭代器与生成器

6. 鸭子类型与协议

“如果它走起来像鸭子、叫起来像鸭子,那它就是鸭子。” 鸭子类型说的是:调用方不关心对象属于哪个类,只关心它有没有需要的方法。下面三个类之间毫无继承关系,但都有 fly(),于是都能被 lift_off() 使唤——不管传进来的是类本身还是实例:

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
class Duck:
@classmethod
def fly(cls):
print('Duck flying')


class Airplane:
@staticmethod
def fly():
print('Airplane flying')


class Bird:
def fly(self):
print('Bird is flying')


def lift_off(entity):
entity.fly()


if __name__ == '__main__':
lift_off(Duck) # 传类,靠 classmethod
lift_off(Airplane) # 传类,靠 staticmethod
lift_off(Bird()) # 传实例,普通方法

换成静态类型语言,这三者得先抽出一个公共接口或基类才能统一调用。Python 把这件事交给了运行时:能调通就行,调不通再抛 AttributeError

上一节的特殊方法正是 Python 内置协议的实现方式——实现 __len__ 就能被 len() 调用,实现 __iter__ 就是可迭代对象,实现 __getitem__ 就能用下标取值。所谓”像序列一样的对象”,指的就是它实现了序列协议要求的那几个魔术方法,而不是它继承自 list

7. 元编程:type 与元类

7.1 type 既能查类型,也是内置元类

元类是”类的类”:普通类描述实例的行为,元类描述类本身的行为。type 就是 Python 内置的元类,想改变类的行为(而不是实例的行为),就继承 type 定义自己的元类——这是 Python 里做元编程的主要方式。

1
2
3
4
5
6
7
8
class SomeClass:
pass


some_object = SomeClass()

type(some_object) # <class '__main__.SomeClass'>
type(SomeClass) # <class 'type'>

inspect.isclass() 可以看得更清楚:实例不是类,而实例的类型是类。

1
2
3
4
5
6
7
>>> import inspect
>>> inspect.isclass(SomeClass)
True
>>> inspect.isclass(some_object)
False
>>> inspect.isclass(type(some_object))
True

再往上追溯,type 的类型就是 type 自己,这条链到这里闭合:

1
2
3
4
5
6
>>> type(type(SomeClass))
<class 'type'>
>>> inspect.isclass(type(type(SomeClass)))
True
>>> type(type(type(SomeClass)))
<class 'type'>

type 之外,Python 中的一切都是对象,它们要么是类的实例,要么是元类的实例:

1
2
3
4
5
6
>>> isinstance(some_object, SomeClass)
True
>>> isinstance(SomeClass, type) # 类是元类 type 的实例
True
>>> isinstance(some_object, type) # 实例不是
False

7.2 用 type 动态创建类

用一个参数调用 type 是查询类型,用三个参数调用则是创建一个新的类对象,参数依次是类名、基类元组、以及包含全部字段和方法的命名空间字典。

1
SomeClass = type('SomeClass', (), {})

它等价于最普通的类定义:

1
2
class SomeClass:
pass

带基类和成员的版本:

1
2
3
4
5
6
7
8
9
def some_function(self):
print('Hello!')


ParentClass = type('ParentClass', (), {})
SomeClass = type('SomeClass',
(ParentClass,),
{'some_function': some_function,
'some_var': 5})

等价于:

1
2
3
4
5
6
7
8
9
class ParentClass:
pass


class SomeClass(ParentClass):
some_var = 5

def some_function(self):
print('Hello!')

区别在于前者的类名、基类、成员都可以在运行时算出来,这正是框架做代码生成时需要的能力。

7.3 自定义元类的用途

编写自定义元类分两步:先写一个 type 的子类,再通过 metaclass= 把它挂到类的创建流程上。

由于元类会被子类继承,它天然适合消除重复代码(DRY);同时它能在生成类对象的过程中插入额外操作,把与类创建有关的复杂逻辑收拢到一处。常见用例有:

  1. 抽象基类;
  2. 类的自动注册;
  3. 在库和框架中构建 API。

7.4 抽象基类

抽象基类只能被继承,不能被实例化,用来约定子类必须实现哪些方法:

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


class Vehicle(metaclass=ABCMeta):

@abstractmethod
def refill_tank(self, litres):
pass

@abstractmethod
def move_ahead(self):
pass

子类把抽象方法全部实现之后才能正常实例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Truck(Vehicle):
def __init__(self, company, color, wheels):
self.company = company
self.color = color
self.wheels = wheels

def refill_tank(self, litres):
print('refill %d litres' % litres)

def move_ahead(self):
print('%s is moving' % self.company)


mini_truck = Truck('Tesla Roadster', 'Black', 4)
mini_truck.move_ahead() # Tesla Roadster is moving

Vehicle() # TypeError: Can't instantiate abstract class Vehicle ...

metaclass=ABCMeta 也可以简写成继承 abc.ABC,两者等价。

本节原先参考的 IBM developerWorks 文章链接已失效(developerWorks 已于 2019 年前后下线)。相同主题可改看官方文档 Metaclassesabc 抽象基类

8. 优雅写法小结

8.1 字符串拼接优先用 join

1
2
names = ['tom', 'jerry']
print('. '.join(names))

join 全程只产生一个字符串对象,而每次 + 都会生成一个新的字符串。不过现在 CPython 对循环里的 += 做了优化,少量拼接直接用加号并不会拖慢程序,不必被过时的经验绑住手脚;拼接大量片段时 join 依然是首选。

值得知道这个优化的成立条件,因为它随时会失效。它的原理是:当左侧字符串的引用计数为 1(除了这个变量没人再持有它),解释器就可以直接 realloc 原地扩容,不必新建对象再整体拷贝。一旦这个字符串还被别处引用住,就只能退回”每次新建 + 全量拷贝”,复杂度立刻变成 O(n²):

1
2
3
4
5
6
7
8
9
10
11
# 快:s 只有一个引用,能原地扩容
s = ''
for i in range(20000):
s += 'x'

# 慢:每轮的 s 都被 list 存了一份,引用计数不再是 1
s = ''
keep = []
for i in range(20000):
s += 'x'
keep.append(s)

两万次拼接实测(已经对齐 list append 的开销):前者 0.85 ms,后者 24.31 ms,差 28.7 倍。同理,self.s += x 这种写在实例属性上的也命中不了优化。

另外两个限制:这是 CPython 的实现细节,不是语言保证,PyPy 上完全没有这回事;所以在库代码里还是老老实实收集片段再 join,别把性能押在解释器的实现上。

8.2 打开文件用 with

1
2
with open('xxx.xxx', 'r') as f:
data = f.read()

离开 with 代码块时文件一定会被关闭,中途抛异常也一样。

8.3 链式比较

1
2
3
age = 18
if 18 <= age < 60: # 写成 18 < age 的话,18 岁这个边界就被漏掉了,什么都不会打印
print('young man')

链式比较会被展开成用 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
2
if a:
do_something()

空字符串、空容器、0None 都是假值,非空即真,不需要写 if len(a) != 0 或者 if a is not None and a != ''

8.6 f-string 格式化

1
2
3
4
5
a, b = 'haha', [1, 2, 3]
s = f'str is {a}, list is {b}'

PI = 3.141592653
print(f'PI is {PI:.4f}') # PI is 3.1416

f-string 需要 Python 3.6 及以上,冒号后面跟的是格式说明符,数字的位数、对齐、千分位都在这里控制。

8.7 for/else

Python 特有的写法:else 分支只在 for 正常跑完(没有被 break 打断)时执行,适合表达”找遍了也没找到”。

1
2
3
4
5
6
7
mylist = [1, 2, 3, 'theflag', 4, 5, 6]
for i in mylist:
if i == 'theflag':
break
print(i)
else:
raise ValueError('list argument missing terminal flag')

有了 for/else,就不用再额外维护一个 flag_found 变量了。

8.8 生成器

生成器不会一次性把所有元素加载进内存,只有迭代到某个元素时才计算并返回它,而列表是预先把全部元素都建好。函数里出现 yield 就会让它变成生成器函数,执行到 yield 处暂停并交出控制权,下次迭代再从这里继续:

1
2
3
4
5
6
7
8
def fib(n):
a, b = 0, 1
while a < n:
yield a
a, b = b, a + b


print(list(fib(50))) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

概念上的细分(容器、可迭代对象、迭代器、生成器)见 10. 迭代对象、迭代器与生成器

8.9 数字里的下划线分隔符

Python 3.6 起,数字字面量中可以插入 _ 来分组,不影响实际数值:

1
2
3
a = 11_22_33_44        # 11223344
big = 1_000_000 # 一百万,比数零好使
error = 0xbad_c0ffee # 十六进制也能分组

8.10 类型注解

给参数和返回值加上类型注解,能让编辑器和 mypy 这类工具帮你查错:

1
2
def my_add(a: str, b: int) -> str:
return a + b

注意注解只是提示,解释器并不做检查,my_add(1, 2) 照样会返回 3 而不报错。真正的校验要靠静态检查工具或运行时的显式判断。

9. 模块与导入

9.1 给 import 添加搜索路径

import 只在 sys.path 列出的目录里找模块。要导入的模块不在其中时,可以在运行时把目录追加进去:

1
2
3
4
import sys

sys.path.append('..') # 将上层目录加入路径
sys.path.append('/home') # 将 /home 加入路径

这属于临时手段,路径写死在代码里不利于迁移;长期方案是把项目做成包,或者用 PYTHONPATH、虚拟环境来管理。

9.2 一个模块只会被导入一次

同一个模块无论被 import 多少次,模块里的代码只在第一次导入时执行,之后都直接复用 sys.modules 中的缓存。所以在交互式环境里改完模块源码,再 import 一遍是没有效果的,得显式重新加载:

1
2
3
4
import importlib
import some_module

importlib.reload(some_module) # 重新导入某模块

⚠️ 注:老代码里常见的 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
2
3
assert False, 'error'
assert True, 'error'
assert 1 in [1, 2, 3, 4],'error'

大多数容器提供了某种方式来获取其中每一个元素,(是可迭代对象赋予了容器这种能力)

可迭代对象 (iterable)

凡是可以返回一个迭代器的对象都可以称之为可迭代对象

可迭代对象只需要实现 __iter__,而且它返回的是一个新的迭代器,不是自己;迭代器才额外实现 __next__,并且它自己的 __iter__ 返回 self

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
x = [1, 2, 3]  # 列表,可迭代对象
hasattr(x, '__next__') # False, 列表根本没有 __next__
y = iter(x)
z = iter(x) # 两个不同的迭代器
y is x # False, iter(列表) 拿到的是新的 list_iterator
iter(y) is y # True, 迭代器的 __iter__ 返回自己
next(y)
next(y)
next(z)
type(x) # list 可迭代对象
type(y) # list_iterator 具体的迭代类型

a = 'hahaha'
b = iter(a)
type(b) # str_iterator

这也正是”列表能被 for 反复遍历,迭代器只能走一遍”的原因:每次 for 一个列表,都会向它要一个全新的游标;而 for 一个迭代器,用的还是那个已经走过一遍的游标。

怎么判断一个对象到底可不可迭代

collections.abc 里的 ABC 做 isinstance 是最直接的判据:

1
2
3
4
5
from collections.abc import Iterable, Iterator

isinstance([1, 2, 3], Iterable) # True
isinstance([1, 2, 3], Iterator) # False,列表不是迭代器
isinstance(iter([1, 2, 3]), Iterator) # True

但这里有个坑:只实现了 __getitem__ 的旧式序列协议同样能被 for 迭代,而 ABC 判不出来。

1
2
3
4
5
6
7
8
9
class Seq:
def __getitem__(self, i):
if i > 2:
raise IndexError
return i * 10

list(Seq()) # [0, 10, 20],能迭代
hasattr(Seq(), '__iter__') # False
isinstance(Seq(), Iterable) # False —— 判据在这里失效了

iter() 拿不到 __iter__ 时会退回这条老路:从 0 开始依次取 obj[0]obj[1]……直到抛 IndexError 为止。所以”可迭代”其实有两条通路,isinstance(x, Iterable) 只覆盖了新式那条;真想判准就直接 try: iter(x)

还有一个不太常见但很好用的两参形式 iter(callable, sentinel):反复调用第一个参数,直到返回值等于哨兵就停。

1
2
it = iter(lambda c=iter([1, 2, 3, 99]): next(c), 99)
list(it) # [1, 2, 3],读到 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
2
sum(i for i in range(100_000_000))    # 生成器表达式,边算边丢,内存几乎不涨
sum([i for i in range(100_000_000)]) # 列表推导式,先把一亿个数全塞进内存

生成器是一种特殊的迭代器,只需要yield关键字。生成器一定是迭代器(反之不成立)。

1
2
3
4
5
def someting():
result = []
for x in ..:
result.append(x)
return result

都可以改写

1
2
3
def iter_something():
for x in ..:
yield x

生成器还有另一面:控制流

上面讲的都是”省内存”这一面——生成器作为数据源被动地被取值。但 yield 是个表达式,它还能接收值,这就跨到协程那一侧去了:

1
2
3
4
5
6
7
8
9
10
11
12
def accumulator():
total = 0
while True:
x = yield total # yield 既往外送 total,也接收 send 进来的 x
if x is None:
break
total += x

acc = accumulator()
next(acc) # 0,先跑到第一个 yield 处停下(预激)
acc.send(10) # 10
acc.send(5) # 15

配套的三个方法: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
2
3
4
5
6
def is_subsequence(a, b):
b = iter(b)
return all(i in b for i in a)

print(is_subsequence([1, 3, 5], [1, 2, 3, 4, 5])) # True
print(is_subsequence([1, 4, 3], [1, 2, 3, 4, 5])) # False

11. 装饰器

绝大多数装饰器都是基于函数和 闭包 实现的,但这并非制造装饰器的唯一方式.

Python 对某个对象是否能通过装饰器(@decorator)形式使用只有一个要求:decorator 必须是一个“可被调用(callable)的对象

1
2
3
def foo(): pass
type(foo) # function
callable(foo) # True

只要自定义类的 __call__ 魔法方法即可让任意类变成可被调用

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

@dataclass()
class X:
a:int
b:int
range:int

def __call__(self): # 定义类的 `__call__` 方法
print('__call__ with ({}, {})'.format(self.a, self.b))

def __del__(self):
del self.a
del self.b
del self.range

x = X(1,2,3)
x() # 像函数一样调用
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
import time
import random
import functools

def timer(wrapped):
"""装饰器:记录并打印函数耗时"""

@functools.wraps(wrapped) # 可以使函数保持原有签名
def decorated(*args, **kwargs):
st = time.time()
ret = wrapped(*args, **kwargs)
print('execution take: {} seconds'.format(time.time() - st))
return ret
return decorated

def counter(func):
"""装饰器:记录并打印调用次数"""
count = 0
@functools.wraps(func)
def decorated(*args, **kwargs):
# 次数累加
nonlocal count
count += 1
print(f"Count: {count}")
return func(*args, **kwargs)
return decorated

@counter
@timer
def random_sleep():
'德玛西亚'
time.sleep(random.random())


random_sleep()
random_sleep()
print(random_sleep.__name__)
print(random_sleep.__doc__)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20


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)

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
23
24
25
26
27
28
29
30
import wrapt
import random


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 wrapper

@provide_number(1,100)
def number(num):
print(num)

number()
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
import time
import functools


class DelayFunc:
def __init__(self, duration, func):
self.duration = duration
self.func = func

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

def eager_call(self, *args, **kwargs):
print('Call without delay')
return self.func(*args, **kwargs)


def delay(duration):
"""装饰器:推迟某个函数的执行。同时提供 .eager_call 方法立即执行
"""
# 此处为了避免定义额外函数,直接使用 functools.partial 帮助构造
# DelayFunc 实例
return functools.partial(DelayFunc, duration)

@delay(duration=2)
def add(a, b):
return a + b


# 这次调用将会延迟 2 秒
print(add(1, 2))
# 这次调用将会立即执行
print(add.eager_call(1, 2)) # 还是进入了类,duration=2, 但调用类里面的eager_call()方法直接返回函数
1
2
3
4
5
6
def add(a, b):
return a + b

import functools
add_3 = functools.partial(add, 3)
print(add_3(1))

参考

  • 序列与容器一节的部分内容整理自微信公众号文章《Python 列表操作》
  • 优雅写法一节是阅读《Python Cookbook》时的笔记
  • 元编程一节参考的 IBM developerWorks 原文已随站点下线,替代资料见该节末尾的官方文档链接