两套 API:状态机是 REPL 的遗产
你在网上搜到的 matplotlib 代码,一半以 plt.plot(...) 开头,一半以 fig, ax = plt.subplots() 开头。它们不是「新旧写法」,也不是风格偏好,而是两套语义不同的 API:一套围着一个隐藏的全局状态转,一套不。搞不清这件事,你迟早会遇到那个经典现象——画出来的东西跑到了另一张图上。这一章解释它为什么会这样,以及什么时候该用哪一套。
import matplotlib.pyplot as plt fig1 = plt.figure() fig2 = plt.figure() plt.plot([1, 2, 3])
问:那条线画在哪张图上?
一个 1990 年代的设计决定
matplotlib 诞生时的目标非常明确:让从 MATLAB 转过来的科学家不用改习惯。MATLAB 的绘图是一套命令式的状态机——figure 开一张图,plot 往「当前图」上画,xlabel 给「当前图」加标签。你在命令行里一句一句敲,脑子里始终有一张「我正在画的那张图」。
于是 matplotlib.pyplot 完整地复刻了这套语义。它的每一个函数背后都有一个隐藏的全局变量:
plt.gcf() # get current figure —— 当前图 plt.gca() # get current axes —— 当前坐标系
plt.plot(...) 的实际含义是 plt.gca().plot(...)。而「当前」的定义是:最后一个被创建或被选中的那个。
所以答案是 fig2。本机实测:fig1.axes 是空的(0 个坐标系),fig2 上有 1 条线。
在命令行里这套设计非常好用:你敲一行看一眼,敲一行看一眼,「当前」是什么一目了然。问题出在你把代码写进函数里的那一刻——「当前图」变成了一个跨函数的隐藏全局状态:
def draw_a():
plt.plot(xs, ys)
plt.title("A") # 画在「当前图」上
def draw_b():
plt.plot(us, vs)
plt.title("B") # 也画在「当前图」上
fig = plt.figure()
draw_a()
draw_b() # 两条线画在了同一张图上,标题被覆盖成 B
这段代码没有任何一处是错的,但结果多半不是你要的。而且它的行为取决于调用顺序、取决于别的地方有没有 plt.figure()、取决于你在 Jupyter 里执行过哪些单元格。这是全局可变状态的标准症状。
另一套 API:把图当成对象
面向对象那套写法把「当前」显式化了:
fig, ax = plt.subplots() # 拿到对象,不再有「当前」
ax.plot([1, 2, 3])
ax.set_title("A")
ax.set_xlabel("x")
# 多个子图
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
axes[0].plot(xs, ys)
axes[1].scatter(us, vs)
对照表:
| 状态机(pyplot) | 面向对象 | 说明 |
|---|---|---|
plt.plot(...) | ax.plot(...) | 同一个方法 |
plt.title("x") | ax.set_title("x") | 注意多了 set_ |
plt.xlabel/ylabel | ax.set_xlabel/set_ylabel | 同上 |
plt.xlim(0, 10) | ax.set_xlim(0, 10) | 同上 |
plt.legend() | ax.legend() | 不加 set_ |
plt.savefig(...) | fig.savefig(...) | 在 Figure 上,不在 Axes 上 |
那个 set_ 前缀是最烦人的地方:动作类的方法(plot、scatter、legend)不加,属性类的(title、xlabel、xlim)要加。记不住的话有个统一出口:ax.set(title="A", xlabel="x", xlim=(0, 10)),一次设完。
探索时用 pyplot,写进函数就用面向对象。
判据是「这段代码会不会被第二次调用」。Jupyter 里画一张看一眼,pyplot 更短;一旦你写了 def plot_xxx(...),就该在参数里收一个 ax:
def plot_xxx(data, ax=None):
if ax is None:
_, ax = plt.subplots()
ax.plot(...)
return ax
这几行是 matplotlib 生态的惯例签名——seaborn 的每个函数、pandas 的 df.plot()、statsmodels 的诊断图,全都接受一个 ax= 参数。照着写,你的函数就能和它们拼在同一张图上。
状态机的第二个后果:图不会自己消失
用 plt.figure() 创建的图,pyplot 会一直持有引用(因为它得知道「当前」是谁)。所以它们不会被垃圾回收,除非你显式关掉。本机实测:
for _ in range(25):
plt.figure()
len(plt.get_fignums()) 25 ← 25 张图还开着
rcParams["figure.max_open_warning"] 20
RuntimeWarning: More than 20 figures have been opened. Figures created through the pyplot interface (`matplotlib.pyplot.figure`) are retained until explicitly closed and may consume too much memory.
这个警告在「循环里给每个分组画一张图并保存」的脚本里几乎必然出现,而且如果你不理它,内存会一路涨上去。两个解法:
for g in groups:
fig, ax = plt.subplots()
ax.plot(...)
fig.savefig(f"{g}.png")
plt.close(fig) # ← 关键这一行
# 或者干脆绕过 pyplot 的注册表
from matplotlib.figure import Figure
fig = Figure() # 不进 pyplot 的管理,不用 close
ax = fig.subplots()
顺带认识一下那 339 个旋钮
matplotlib 的所有默认值都放在一个字典里,叫 rcParams。本机上它有 339 个键:
len(plt.rcParams) 339
rcParams["figure.figsize"] [6.4, 4.8] 英寸
rcParams["figure.dpi"] 100.0
→ 默认图是 640 × 480 像素
len(plt.style.available) 28 内置样式
那个 640 × 480 值得知道,因为它解释了一个常见困惑:为什么我的图在屏幕上很清楚,保存下来放进文档就糊了。默认输出就是 640 × 480,放进一页文档里当然不够。改法:
fig.savefig("out.png", dpi=200, bbox_inches="tight")
# 或者全局改
plt.rcParams["figure.dpi"] = 150
plt.rcParams["savefig.dpi"] = 200
关键在于「图的尺寸是英寸,不是像素」——这是从出版排版继承来的单位。像素数 = 英寸 × dpi。想改清晰度就改 dpi,想改版面比例就改 figsize,两件事不要混。
Figure 和 Axes 这两个词的中译很容易误导:
- Figure = 整张画布,也就是最后保存成的那个文件。一个 Figure 可以放很多张子图。
- Axes = 一张子图,不是「坐标轴」。虽然名字是 axis 的复数,但它指的是「一个带坐标系的绘图区」——包括里面的线、点、标题。你 90% 的时间在跟它打交道。
- Axis(单数)= 真正的坐标轴,也就是
ax.xaxis、ax.yaxis。管刻度、管格式化。
Axes 和 Axis 差一个字母,含义完全不同——这是 matplotlib 文档里最容易看错的一处。记住 ax 这个变量名指的是「一张子图」,其余的顺着就清楚了。
那个「画到别人身上」,五行:
import matplotlib
matplotlib.use("Agg") # 无窗口环境;有窗口就删掉这行
import matplotlib.pyplot as plt
fig1 = plt.figure()
fig2 = plt.figure()
plt.plot([1, 2, 3])
print(plt.gcf() is fig2) # True ← 「当前」是最后创建的
print(len(fig1.axes), len(fig2.axes[0].lines)) # 0 1
plt.close("all")
那个警告:
for _ in range(25):
plt.figure()
print(len(plt.get_fignums())) # 25
print(plt.rcParams["figure.max_open_warning"]) # 20
plt.close("all")
print(len(plt.rcParams), len(plt.style.available)) # 339 28
print(plt.rcParams["figure.figsize"], plt.rcParams["figure.dpi"]) # [6.4, 4.8] 100.0
python3 -c "import matplotlib;matplotlib.use('Agg');import matplotlib.pyplot as p;f1=p.figure();f2=p.figure();p.plot([1,2,3]);print(p.gcf() is f2, len(f1.axes))"
装:pip install matplotlib。无图形界面的环境(服务器、CI)记得 matplotlib.use("Agg"),否则可能因为找不到显示后端而报错。
- OpenGL 的上下文,也是同一套设计。
glBindTexture/glDrawArrays全部作用在「当前绑定的对象」上。这套 API 让人写出无数「为什么画到了另一个纹理上」的 bug,以至于现代图形 API(Vulkan、Metal、WebGPU)全部改成了显式传对象。pyplot 到面向对象,走的是同一条路。 - Android 的 Canvas 与 Compose。
canvas.save()/restore()管理的是一个绘图状态栈,忘了 restore 就会污染后续绘制;Compose 的Modifier链则把状态显式传下去。同一个演进方向:隐式状态好写,显式状态好维护。 - Jupyter 里那些「重跑一遍就好了」的怪现象。很多都源于这个:单元格乱序执行,「当前图」是哪张取决于你上次点了哪个单元格。在 notebook 里也用
fig, ax = plt.subplots(),这类怪事会少一大半。 - 为什么库函数都收
ax=。seaborn、pandas、statsmodels、networkx 的绘图函数全都有这个参数——因为只有这样,用户才能把你的图和别人的图拼进同一个 Figure。这是 Python 可视化生态里事实上的协议。
「plt.xxx() 是老写法,fig, ax 是新写法,反正能画出来就行。」
两套 API 都不会被废弃,因为它们服务的场景不同。pyplot 不是「旧」,它是为交互式命令行优化的:少打字、不用管对象、每一句都作用在「你刚才看到的那张图」上。在 Jupyter 里探索数据时,它是对的选择。
真正的区别是有没有隐藏的全局状态。一旦代码进了函数、进了循环、要被别人调用,那个「当前图」就成了函数之间的隐式耦合——症状是「单独跑没问题,和别的代码一起跑就画错地方」。
判据一句话:这段画图代码会不会被第二次调用?会,就用面向对象,并且在参数里收一个 ax=None。不会,随便。另外:写循环画图时,一定记得 plt.close(fig)——本机上 25 张图不关,全都还在内存里。
正确答案是 B:fig2,最后创建的那个。本机实测 fig1.axes 是空的。
plt.figure(1) 可以把当前切回第一张(数字是图的编号),plt.figure(fig1) 也行。「当前」是可以改的,但你得显式去改。
C 「广播到所有图」——不会。pyplot 的每个函数都只作用在一个目标上,这也是它作为状态机的定义:任何时刻只有一个当前。
D 「报错」——恰恰相反,pyplot 的设计目标就是让你不必指定画在哪。plt.plot([1,2,3]) 前面连 plt.figure() 都不用写,它会自动创建一张。这份方便正是全部麻烦的来源。
plt.plot() 画错地方不会报错,图会正常生成——只是内容不对。自己检查:函数里永远收 ax=;循环里永远 plt.close(fig)。
这一章的一句话
pyplot 是为命令行设计的状态机,一切作用在「当前图」上;它在探索时最短、在函数里最危险——而唯一的解药是把那个隐藏的全局变量变成一个显式的参数 ax。
下一章往里再看一层。既然图是对象,那它里面装着什么?本机数了一下:一张最简单的折线图,ax.get_children() 返回 11 个对象——4 条边框、3 个文本、1 条线、1 个背景矩形、2 根坐标轴。而它们有一个共同的父类,这意味着你在图上看到的每一样东西,都能被拿到、被改。