c语言回调函数_再谈“回调 vs 接口”设计

d20e42af432286a8d6806137e7b167dc.png

原文:《再谈“回调 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 接口,如果还有问题欢迎指出~ )
  • 希望多写写关于 回调和接口 的文章

569544836f4ad4aae210059383b8678a.png

因为微信不让私信回复了 ,所以写一篇文章来讨论 。

另外,由衷感谢每位读者的支持和讨论~

论道 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 返回结果):

  • 原始方案 基于泛型 Resultstd::function<> 接口:
template <typename Result>
void DownloadAsync(std::function<void(Result*)> on_done);
  • 全部改为 基于基类 ResultBasehyper_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)。(参考:对编程范式的简单思考)

6481e1d9074f2e0e8657589d9e34dca7.png

王垠的 解密“设计模式”(备份)批判了(面向对象)设计模式的 “历史局限性”

(设计模式)变成了一种教条,带来了公司里程序的严重复杂化以及效率低下 ... 什么都得放进 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.

d2b6312c1efdb45c705eb998e1949b14.png

回调闭包 —— 函数签名“替换”类层次结构

其实,可以使用 回调闭包 (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 只有 HtmlResultJsonResult 两种),可以通过 显式实例化 (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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值