那两个类不解决「怎么调库」,只解决「谁负责收尾」。 沙箱没有编译器,所以这一节讲设计,再用 Python 把同一个思路跑一遍。
13.3 把推流的字节亲手送出去了,这一节把代码收进类里。 这是全书唯一一节「只讲设计不跑 C++」的地方,原因在第一节就说清。
一、沙箱没有编译器
先把这个前提摆出来,省得读者以为下面的代码跑过。 工具 本机
cl 没有 g++ 没有 gcc 没有 clang++ 没有 make 没有 cmake 没有
六个全没有,所以这一节不编那两个类,只讲它们为什么长成那样。 讲完之后用一个 Python 的最小封装跑一遍,逻辑是一回事。 为什么敢这么说:封装类要解决的四个问题(下一节那张表)与语言无关, Python 里同样存在,只是有些由语言代劳了。 把 Python 版写出来跑一遍,等于把设计验了一遍。
二、那两个类各管一半
教材里的两个类,分工是一刀切的。 类 管什么 对应的 C 接口
第一个 初始化与错误 注册、错误码转文字 第二个 一条流的开与读 打开、找流、读包、解码
为什么这么分:初始化只做一次,流可以开很多条。 把「全局的」与「每条的」分开,才不会有「关一条流把全局也关了」这种事。 这个分法还有一个好处:第一个类是单例,第二个类可以随便复制(真实实现里是禁止复制、 只许移动,但语义上是「一条流一个对象」)。两类东西的生命周期不一样,就不该塞进一个类里。
三、封装类要解决的四个问题
抄这类代码之前,先看它是不是真解决了这四件事。 要解决 不封装会怎样
资源释放 中途异常就漏,句柄越攒越多 错误码变异常 每步都要 if 判返回值 状态合法 没开就读、关了还写 拷贝与移动 两个对象指同一份,双释放
第四条是 C++ 独有的:默认的拷贝构造会把指针复制一份,
两个对象析构时各释放一次,就是双重释放——这种崩溃现场离出错的地方很远,最难查。
标准做法是把拷贝构造与赋值都删掉,只留移动(移动之后源对象置空,不会再释放一次)。
这一条是「封装」与「包一层」的分界线:只包不封的类,恰恰会把双释放的坑留在外面。
Python 里没有双释放(有引用计数兜着),但「谁负责关」这个设计问题是同一个,
所以下面那个 Python 版仍然要显式写 close。
四、用 Python 写一遍同一个思路
一个类、四件事:打开、读帧、关闭、异常安全。 项 值
读到的帧数 50 块内还开着吗 True 块外还开着吗 False 总耗时 8.6 毫秒
with 退出时一定调 close,就算中间抛异常也一样。
这正是 C++ 里析构函数要做的事,Python 用上下文管理器做到了同一件事。
注意「块内还开着、块外已经关了」这两行:
它们证明关闭发生在离开 with 的那一刻,不是等到程序结束。
这个「确定的时机」就是 RAII 的全部价值——不靠垃圾回收,靠作用域。
五、忘了关会怎样
这一条要真跑,因为它最能说明问题。 时刻 文件在不在 字节
写完 20 帧没 close 不在 0 对照组 close 之前 在 0 对照组 close 之后 在 2071
不 close 的那一份,文件压根没建出来——因为数据全在库的缓冲区里,一个字节都还没往下写。 忘了关不会报错,只会悄悄留下一个不存在、或者空的文件。 对照组更值得看:close 之前 0 字节、之后 2071 字节, 那 2071 字节全是 close 写下去的——包括收尾的结束标记与索引,也包括之前攒着的整段数据。 所以「写完就能用」这个直觉在流式写文件上是不成立的: mp4 那类容器更狠,索引在最后,没 close 的文件根本播不了。 这也是为什么「录制中断」经常留下一个坏文件。
六、这一节能给的结论
那两个类的代码在书里,值得抄,但抄之前先想清四件事。 想什么 答案该是什么
谁释放 对象自己,用析构或 with 错误怎么传 统一转成异常,别返回码 状态怎么查 内部记一个,别靠指针判空 能不能拷贝 不能,删掉只留移动
这四条想不清,封装类只会把 bug 藏得更深。 因为它把「哪一步出的错」变成了「对象状态不对」。 13.5 与 13.6 换到另一个库(OpenCV),但你会看到同样的痕迹: Mat 自己管内存、numpy 数组与帧共享内存谁负责复制。 「谁负责这块内存」是这一整章反复出现的主题。
七、动手 Demo:两个封装类要解决什么,用 Python 写一遍同一个思路
先查六个编译器在不在,再写一个带 with 的最小封装读 50 帧,最后比一次关与不关。
import shutil
import av
for n in ("cl", "g++", "gcc", "clang++", "make", "cmake"):
print(n, "有" if shutil.which(n) else "没有")
class RtStream(object):
def __init__(self, path, mode="r"):
self.path, self.mode, self.c, self.n = path, mode, None, 0
def __enter__(self):
self.c = av.open(self.path, self.mode)
return self
def __exit__(self, *a):
self.close()
return False
def frames(self):
st = self.c.streams.video[0]
for pk in self.c.demux(st):
if pk.size == 0:
break
for fr in pk.decode():
self.n += 1
yield fr
def close(self):
if self.c is not None:
self.c.close()
self.c = None
with RtStream("tiny.mp4") as rs:
n = sum(1 for _ in rs.frames())
print(n, rs.c is not None) # 块内:开着
print(rs.c is not None) # 块外:已关
# 忘了 close:文件连建都没建出来
o = av.open("noclose.flv", "w", format="flv")
vs = o.add_stream("libx264", rate=25)
vs.width, vs.height, vs.pix_fmt = 160, 120, "yuv420p"
for i in range(20):
f = av.VideoFrame(160, 120, "yuv420p")
f.pts = i
for op in vs.encode(f):
o.mux(op)
import os
print(os.path.exists("noclose.flv"), os.path.getsize("noclose.flv"))
实跑输出:
--------------------------------------------------------------------
两个封装类要解决什么,用 Python 写一遍同一个思路
--------------------------------------------------------------------
**① 沙箱没有 C++ 编译器**
工具 本机
--------------------
cl 没有
g++ 没有
gcc 没有
clang++ 没有
make 没有
cmake 没有
**所以这一节不编那两个类,只讲它们为什么长成那样。**
**讲完之后用一个 Python 的最小封装跑一遍**,逻辑是一回事。
**② 那两个类各管一半**
类 管什么 对应的 C 接口
----------------------------------------------------------
第一个 初始化与错误 av_register/av_strerror
第二个 一条流的开与读 avformat_open_input 一串
**为什么这么分**:**初始化只做一次,流可以开很多条。**
**把「全局的」与「每条的」分开,才不会有「关一条流把全局也关了」这种事。**
**③ 封装类真正要解决的四件事**
要解决 不封装会怎样
---------------------------------------------------
资源释放 中途异常就漏,句柄越攒越多
错误码变异常 每步都要 if 判返回值
状态合法 没开就读、关了还写
拷贝与移动 两个对象指同一份上下文,双释放
**第四条是 C++ 独有的**:
**默认的拷贝构造会把指针复制一份,两个对象析构时各释放一次,就是双重释放。**
**标准做法是把拷贝构造与赋值都删掉,只留移动。**
**Python 里没这个问题,但「谁负责关」这个设计问题是同一个。**
**④ 用 Python 写一遍同一个思路**
项 值
-----------------------------
读到的帧数 50
块内还开着吗 True
块外还开着吗 False
总耗时 9.9 毫秒
**`with` 退出时一定调 `close`,就算中间抛异常也一样。**
**这正是 C++ 里析构函数要做的事**,**Python 用上下文管理器做到了同一件事。**
**⑤ 忘了关会怎样:真跑一次**
时刻 文件在不在 字节
--------------------------------------------------
写完 20 帧、还没 close 不在 0
close 之前(对照组) 在 0
close 之后(对照组) 在 2071
**不 close 的那一份,文件压根没建出来**——**因为数据全在库的缓冲区里,一个字节都还没往下写。**
**这一点最能说明封装类的价值**:**忘了关不会报错,只会悄悄留下一个不存在、或者空的文件。**
**对照组也一样**:close 之前 0 字节,close 之后 2071 字节,
**那 2071 字节全是 close 写下去的**——**包括收尾的结束标记与索引,也包括之前攒着的整段数据。**
**⑥ 这一节能给的结论**
**那两个 C++ 类的代码在书里,值得抄,但抄之前先想清四件事**:
**谁释放、错误怎么传、状态怎么查、能不能拷贝。**
**这四条想不清,封装类只会把 bug 藏得更深。**
六个编译器全没有,所以这一节只讲设计,不编 C++。 不 close 的文件 0 字节:数据全在缓冲区,close 才真正往下写。
30 秒实验
- 在
frames()里抛一个异常,看文件句柄有没有被放掉(提示:__exit__照样执行)。 - 把
__exit__的返回值改成True,看异常会不会被吞掉。 - 对比
close前后同一个 flv 的字节数,算清收尾那段占多少。
常见误区
- 以为封装类只是把函数包一层。 它要解决的是生命周期与状态。
- 写完就以为文件能用。 不 close,数据还在缓冲区里。
- 让类可拷贝。 两个对象指同一份上下文,析构时双释放。
自测三问(点开对答案)
1. 两个类为什么按「全局/每条」来分? —— 初始化只做一次、流可以很多条,生命周期不同就不该塞一个类里。 2. 为什么封装类必须禁拷贝? —— 默认拷贝会复制指针,两个对象各释放一次就是双释放。 3. 不 close 会怎样? —— 不报错,但数据留在缓冲区,文件可能是空的甚至不存在。- ☐ (本节完,下一节 13.5 OpenCV4 基础操作及磨皮美颜)