原文:《再谈“回调 vs 接口”设计》,公众号 BOTManJL~
http://weixin.qq.com/r/zigHAh3EUtUuKFlhb31K (二维码自动识别)
如何提高感知力和控制力,就像学游泳一样,要到水中练,去亲近水。——《格蠹汇编》张银奎
这两年写过很多类似的文章,先整理这一系列的思路:
- 如何浅显的解释 回调函数
- 从 函数式编程 (functional programming)、一等公民 (first-class function)、高阶函数 (higher-order function) 的角度,介绍回调函数 是什么
- 从 依赖注入 (dependency injection, DI)、控制反转 (Inversion of Control, IoC) 的角度,解释 为什么 要用回调函数
- 最后举了 JavaScript 和 C 语言的例子,简单介绍了同步/异步回调 怎么做
- 回调 vs 接口
- 举了 C++ 的例子,用 继承/组合接口、绑定函数/构造 lambda 的方式,实现 观察者模式 (observer pattern)
- 讨论了使用接口的 局限性、使用回调的 灵活性
- 最后举了 面向过程 C 语言(没有对象和闭包)、面向对象脚本语言 JavaScript(动态类型 + 垃圾回收)、面向对象编译语言 C++(静态类型 + 内存管理)的例子,进一步分析 如何实现 回调机制
- 对 编程范式 的简单思考
- 从 编程范式 (programming paradigm) 的角度,分析了 “回调 vs 接口” 问题的本质
- 提到了可以用 函数式的 闭包 (closure) 替换 面向对象的 设计模式(design patterns)
- 然而,C++/Java 等面向对象语言,本质上借助了 类 (class) 和 对象 (object) 实现
std::function<>
和lambda
- 深入 C++ 回调
- 从 同步 (sync)、异步 (async)、回调次数 等角度,讨论了 C++ 回调中的 所有权 (ownership) 和 生命周期管理 (lifetime management) 问题
- 指出了 C++ 原生的
std::bind
/lambda
+std::function
的 不足,和 Chromium 的base::Bind
+base::Callback
的 优势
本文用两个例子,从 代码设计 (code design) 的角度谈谈一些想法。
回复 TL;DR
最近有位热心的朋友留言:
- 理解 观察者、中介者 模式 最后提到的 如何用回调替换接口 的方案不够详细(建议看看 回调 vs 接口,如果还有问题欢迎指出~ )
- 希望多写写关于 回调和接口 的文章
因为微信不让私信回复了 ,所以写一篇文章来讨论 。
另外,由衷感谢每位读者的支持和讨论~
论道 TL;DR
最近看到一篇关于 iOS 编译优化的文章,里边的一个方案引起了我的注意:
- 因为 模板 的 编译时多态 编译速度过慢,而且会导致代码膨胀
- 所以引入 虚基类,用 运行时多态 的方法 “优化”
- 引入一个
hyper_function<>
全量 drop-in 替换std::function<>
hyper_function<>
(代码链接 / 备份链接)参考自 Arthur O’Dwyer 的 37 percent of HyperRogue’s compilation time is due to std::function
在好奇心的驱使下,看了一下这个方案(以异步下载 DownloadAsync
为例,通过回调 on_done
返回结果):
- 原始方案 基于泛型
Result
的std::function<>
接口:
template <typename Result>
void DownloadAsync(std::function<void(Result*)> on_done);
- 全部改为 基于基类
ResultBase
的hyper_function<>
接口:
class ResultBase {
public:
virtual Json ToJson() const = 0;
};
void DownloadAsync(hyper_function<void(ResultBase*)> on_done);
- 基于上述两个接口,调用者都可以 传递 lambda 表达式 处理结果:
class DerivedResult : public ResultBase {
public:
Json ToJson() const override; // virtual, but unused
std::string ToString() const; // non-virtual
};
DownloadAsync([](DerivedResult* d) {
Print(d->ToString()); // non-virtual call
});
这颠覆了我的认知:
- 调用者传递的
hyper_function<void(Derived*)>
竟然可以 隐式转换 成hyper_function<void(Base*)>
类型的 可调用 (callable) 对象 - 而一般这是 不允许的 —— 只有
std::function<void(Base*)>
可以转换成std::function<void(Derived*)>
,反之不行
因为,std::function<> 构造时会用std::is_invocable<>
检查函数签名(function signature)的兼容性:
- 原始的可调用对象
void(Base*)
可以接受Derived*
参数(Derived*
向上转换 (up cast) 为Base*
),转化后的void(Derived*)
当然也可以接受Derived*
参数 —— 变窄 (narrowed) 可行 - 原始的可调用对象
void(Derived*)
不一定能接受Base*
参数(Base*
向下转换 (down cast) 为Derived*
),故不能转换成void(Base*)
—— 变宽 (widen) 不行
在好奇心的驱使下,看了hyper_function<> 的实现原理(备份链接):
- 先用
std::is_convertible<>
检查参数是否可以 “反向转换” - 再用
reinterpret_cast<>()
对可调用对象的函数签名 类型强转
这有什么危害 —— 绕过类型检查,可能导致 函数调用崩溃,给后人挖坑:
- 之前曾遇到过类似的崩溃 ——
Base* d1 = new Derived1; Derived2* d2 = static_cast<Derived2*>(d1);
导致 对象布局不对齐(参考:崩溃分析笔记) - 基于这个假设,用
hyper_function<>
做了试验,于是出现了 错误的内存访问 https://godbolt.org/z/M4uBjQ
网友评论:滥用向下转换,和用 C 语言的
void*
有什么差别?都是在 拿枪打自己的脚
(shoot yourself in the foot)。
另外,朴素的 hyper_function<>
(包括 Arthur 的版本)虽然提升了编译速度,但增加了 创建时堆分配、使用/销毁时虚函数 的开销。而 std::function<>
利用编译时技巧,内联 (inline) 存储可调用对象,减少运行时开销。(感谢 卜恪 大佬的指正;但这 不是本文的重点)
探讨
其实,在刚学 C++ 的时候,也曾犯过类似的设计错误 —— 试图用面向对象的方法,解决泛型编程的问题,从而引入了无用的虚基类:
- 创建
Derived
对象 - 转换为 基类指针
Base*
存储对象 - 使用对象前,先还原为 派生类指针
Derived*
接下来用 “异步下载” 的例子,从代码设计的角度,阐述这个问题的 由来 和 解法:
- 朴素设计 —— 数据和计算耦合
- 控制反转 —— 解耦发送者和接收者
- 回调闭包 —— 函数签名“替换”类层次结构
- 泛型编程 —— 抽象概念“替换”类层次结构
- 错误设计 —— Derived-Base-Derived
朴素设计 —— 数据和计算耦合
下载完成后 打印结果 可以实现为:
void DownloadAsyncAndPrint() {
// ... download async and construct |result| ...
Print(result);
}
下载完成后 写数据库 可以实现为:
void DownloadAsyncAndWriteToDB() {
// ... download async and construct |result| ...
WriteToDB(result);
}
通过 抽取函数 (extract function) 重构公共逻辑(异步下载的核心逻辑):
std::future<Result> DownloadAsyncImpl();
void DownloadAsyncAndPrint() {
Result result = co_await DownloadAsyncImpl();
Print(result);
}
void DownloadAsyncAndWriteToDB() {
Result result = co_await DownloadAsyncImpl();
WriteToDB(result);
}
存在的问题:无法封装 “异步下载” 模块 ——
- 一方面 不可能针对 所有需求 提供上述接口(有人需要打印结果,有人需要写数据库,还有人需要...)
- 另一方面 需要提供 不涉及实现细节 的接口(比如
DownloadAsyncImpl
基于 C++ 20 的协程,可以改用多线程实现,但调用者并不关心)
—— 本质上,面向过程的结构化设计,导致数据 result
生产和消费的逻辑 耦合 (coupling) 在了一起,不易于扩展。
控制反转 —— 解耦发送者和接收者
为了解决这个问题,需要引入 控制反转 (IoC)。从 纯面向对象 的视角看:
- 一个数据:
result
- 两个角色:发送者(
DownloadAsyncImpl
)和 接收者(Print
/WriteToDB
) - 一个目的:解耦 (decouple) 发送者和接收者
一般把公共逻辑抽象为 框架 (framework),再用以下两种方法实现(参考:控制反转 —— 计算可扩展性)。
1) 使用模板方法模式(template method pattern),通过继承(inheritance),在发送者(虚基类)上重载接收者(protected
纯虚方法)逻辑:
// interface
class Downloader {
public:
virtual ~Downloader() = default;
void DownloadAsync() {
Result result = co_await DownloadAsyncImpl();
Handle(result);
}
protected:
virtual void Handle(const Result& result) const = 0;
};
// client code
class PrintDownloader : public Downloader {
protected:
void Handle(const Result& result) const override {
Print(result);
}
};
auto print_downloader = std::make_unique<PrintDownloader>();
print_downloader->DownloadAsync();
2) 使用策略模式(strategy pattern),通过组合(composition),向发送者(类/函数)传递接收者(派生类)逻辑:
// interface
class Handler {
public:
virtual ~Handler() = default;
virtual void Handle(const Result& result) const = 0;
};
void DownloadAsync(std::unique_ptr<Handler> handler) {
Result result = co_await DownloadAsyncImpl();
handler->Handle(result);
}
// client code
class WriteToDBHandler : public Handler {
public:
void Handle(const Result& result) const override {
WriteToDB(result);
}
};
DownloadAsync(std::make_unique<WriteToDBHandler>());
存在的问题:引入了 基于类的 (class-based) 面向对象 ——
- 模板方法 基于继承,接收者 派生于 发送者,在运行时 不能动态更换 接收者;故有 “组合优于继承” (favor object composition over class inheritance)
- 策略模式 基于组合,但要为 每种类型定义 一个接收者的 接口(虚基类),仍要和 “类” 捆绑在一起(参考:回调 vs 接口)
—— 本质上,面向对象的 封装 (encapsulation) 把 数据 和 对数据的操作(方法)捆绑在类里,引入了复杂的 类层次结构 (class hierarchy)。(参考:对编程范式的简单思考)
王垠的 解密“设计模式”(备份)批判了(面向对象)设计模式的 “历史局限性”:
(设计模式)变成了一种教条,带来了公司里程序的严重复杂化以及效率低下 ... 什么都得放进
class
里 ... 代码弯了几道弯,让人难以理解。
孟岩的 function/bind的救赎(上) 也提到 “类” 脱离了 “对象的本质”:
Simula 和 Smalltalk 最重大的不同,就是 Simula 用方法调用的方式 向对象发送消息,而 Smalltalk 构造了更灵活和更纯粹的消息发送机制 ... C++ 静态消息机制 还引起了更深严重的问题 —— 扭曲了人们对面向对象的理解 ... “面向对象编程” 变成了 “面向类编程”,“面向类编程” 变成了 “构造类继承树”。
Joe Armstrong(Erlang 主要发明者)也批评过 “类” 的 “污染性”:
The problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.
回调闭包 —— 函数签名“替换”类层次结构
其实,可以使用 回调闭包 (callback closure) 实现等效的 依赖注入 (DI) 功能:
// interface
using OnDoneCallable = std::function<void(const Result& result)>;
void DownloadAsync(OnDoneCallable callback) {
Result result = co_await DownloadAsyncImpl();
callback(result);
}
// client code
DownloadAsync(std::bind(&Print));
上述代码去掉了 class
,把 handler
对象改为 callback
闭包,把 虚函数调用 改为 回调闭包的调用,不再需要接口和继承 —— 脱离了 “类” 的束缚,是不是 清晰多了?
实现上:
std::function<>
通过
类型擦除
(type erasure) 将闭包适配到同一类型上。(参考:回调 vs 接口)
正如 Steve Yegge 的 Execution in the Kingdom of Nouns 提到的(面向对象 vs 函数式):
Object Oriented Programming puts the Nouns first and foremost. It's not as if OOP has suddenly made verbs less important in the way we actually think. It's a strangely skewed perspective.
—— 面向对象的错误在于:把名词(对象)作为一等公民,而动词(函数)只能附属于(捆绑在)对象上。
泛型编程 —— 抽象概念“替换”类层次结构
实际上,也可以使用 泛型编程 (generic programming) 进一步化简:
// interface
template <typename OnDoneCallable>
void DownloadAsync(OnDoneCallable callback) {
Result result = co_await DownloadAsyncImpl();
callback(result);
}
// client code
DownloadAsync(std::bind(&Print));
上述代码中:
- 回调闭包 属于一个抽象的 概念 (concept),即一个能处理
Result
的 可调用对象 - 函数模板
DownloadAsync<>
只关心callback
能处理result
,而不需要关心它的实际类型是什么
实现上:泛型编程通过 模板 (template) 的 编译时多态,实现 静态派发 (static dispatch)。(参考:简单的 C++ 结构体字段反射)
正如 Alexander Stepanov(STL 主要设计者)在采访中说的(面向对象 vs 泛型编程):
I find OOP technically unsound. It attempts to decompose the world in terms of interfaces that vary on a single type.
I find OOP philosophically unsound. It claims that everything is an object.
I find OOP methodologically wrong. It starts with classes.
—— 试想一下,如果错误设计了 STL(容器的迭代器基于 Iterator
接口、对象的比较基于 Comparable
接口),会是一番怎样的场景?
错误设计 —— Derived-Base-Derived
所以,§ 论道 TL;DR 提到的设计,问题出在哪?
- 虚基类 破坏泛化
- 回调实际处理的
DerivedResult
不得不继承于ResultBase
接口 - 派生类
DerivedResult
不得不实现 “可能用不到的”ToJson()
纯虚方法
- 回调实际处理的
- 接口的 语义错误
- 从函数签名看
hyper_function<void(ResultBase*)>
能处理ResultBase
对象 - 而实际传入的回调闭包,却只能处理
DerivedResult
对象
- 从函数签名看
- 杂糅的 设计缺陷 —— 试图用 面向对象的方法,解决 泛型编程的问题
1) 基于泛型 的 “直接做法” 是 —— 用 typename OnDoneCallable
直接泛化 可调用对象:
template <typename OnDoneCallable>
void DownloadAsync(OnDoneCallable on_done);
2) 基于泛型和回调 的 “间接做法” 是 —— 先用 std::function<>
擦除 可调用对象的 类型,再用 typename Result
泛化 回调函数的 签名(优化前的原始方案):
template <typename Result>
void DownloadAsync(std::function<void(Result*)> on_done);
3) 不用泛型 的 “正确做法” 是 —— 针对不同类型 分别定义并实现(基于回调的)接口:
void DownloadAsync(std::function<void(HtmlResult*)> on_done);
void DownloadAsync(std::function<void(JsonResult*)> on_done);
4) 不用泛型和回调 的 “正确做法” 是 —— 引入面向对象机制,使用 策略模式 定义不同处理接口(也可以用 模板方法模式):
class HtmlResultHandler {
public:
virtual ~HtmlResultHandler() = default;
virtual void Handle(const HtmlResult& result) const = 0;
};
class JsonResultHandler {
public:
virtual ~JsonResultHandler() = default;
virtual void Handle(const JsonResult& result) const = 0;
};
void DownloadAsync(std::unique_ptr<HtmlResultHandler> handler);
void DownloadAsync(std::unique_ptr<JsonResultHandler> handler);
假设,再进一步 去掉控制反转,我们还可以...(篇幅有限,读者自由发挥)
泛型缺陷 —— 回归类型擦除
对于上述方案 1) 和 2),模板实现 必须在 头文件 里提供,并在调用时需要针对 不同的模板参数 分别 实例化模板,从而导致代码膨胀、编译性能问题。
然而,上述方案 已经缓解 了这两个问题:
- 代码膨胀 —— 用
DownloadAsyncImpl
实现公共逻辑,并将DownloadAsync
实现为 薄模板 (thin template)(参考 § 泛型编程 —— 抽象概念“替换”类层次结构 代码) - 编译性能 —— 如果模板参数可枚举(
Result
只有HtmlResult
和JsonResult
两种),可以通过 显式实例化 (explicit instantiation) 分离定义和声明(仅在头文件声明,并在源文件定义),不需要在调用时再实例化(类似方案 3),但不需要拆分为两个函数)
尽管如此,由于方案 1) 和 2) 使用了模板,这两个问题 仍未解决。
无独有偶,STL 容器类模板std::vector<T, Allocator = std::allocator<T>>
对于不同的Allocator
类型参数,会实例化成 不同的类,而且不同类实例之间 互不兼容。
直到 C++ 17 引入std::pmr::vector
,默认使用 基于接口的 多态分配器 (polymorphic allocator)Allocator = std::pmr::polymorphic_allocator<T>
,才避免了使用不同Allocator
的类实例之间不兼容的问题。
最后,退一步思考 泛型的必要性 —— 与其 给回调闭包传递 不同类型的 预加工结果 HtmlResult
/JsonResult
,不如 直接给回调闭包传递 同一类型的 加工前结果 RawResult
:
- 方案 2) 的 处理泛型结果的闭包
std::function<void(Result*)>
改为 处理原始结果的闭包std::function<void(RawResult*)>
- 方案 2) 中
DownloadAsync
将原始结果RawResult
加工为不同类型Result
的逻辑,放到闭包on_done
自行处理 - 最后得到 类似方案 3) 的接口
void DownloadAsync(std::function<void(RawResult*)> on_done);
原来事情如此简单。
写在最后
先 “空谈” 几点感悟,后续再写文章细聊:
- 学会思辨,而不盲从
- 保持好奇心,不止于了解 how,更需要探究 why(存在即合理)
- 多看多学多练,才能体会不同方案的利弊
- 炼钢者,莫用土法
- 此消彼长:编译时间 vs 运行时间,短期 “ROI” vs 长期可维护性
- 通过交流、学习,写出默认正确的代码 (Write the right code by default)
如果有什么问题,欢迎交流。
Delivered under MIT License © 2019, BOT Man