
用 Qt 3 年,connect/disconnect 写过几万次。
但每次面试被问到"信号槽的原理是什么",我只能背那几句标准答案——moc 预编译、元对象系统、事件循环……背完心里是虚的,因为我根本没看过 Qt 源码。
直到在 Aether 项目中,我们需要一个轻量级的信号槽方案来做插件间通信。翻 Qt 源码翻了 3 天,从 moc 的输出文件一路追到 QMetaObject::activate。
看完的那一刻,我长叹一口气——
原来就这?
这篇不讲虚的。从 Qt 信号槽的全链路拆解,到手写一个 30 行的 mini 信号槽,再到 Aether 中实际用的 MethodDispatcher,最后对比 4 种 C++ 回调方案。看完你也能拍着胸脯说:"信号槽,我懂了。"
一、Qt 信号槽完整调用链
先画一下整条链路,心里有个地图:
源代码 编译后
┌──────────────┐ ┌──────────────────┐
│ class Foo { │ moc │ foo.moc 自动生成 │
│ Q_OBJECT │ ──────→ │ QMetaObject │
│ signals: │ │ qt_static_metacall│
│ void sig(); │ │ qt_metacall │
│ }; │ └──────────────────┘
└──────────────┘
│ │
│ ▼
│ ┌──────────────────┐
│ connect() │ Connection 链表 │
├────────────→ │ sender->connections│
│ └──────────────────┘
│
│ emit sig()
▼
┌──────────────────┐
│ QMetaObject:: │ 通过 connections 链表
│ activate() │ 找到所有接收者
│ │ 然后用 qt_metacall()
│ │ 调用对应的 slot
└──────────────────┘
1.1 moc 到底干了啥?
你写 Q_OBJECT,moc 就帮你生成一个 foo.moc 文件。
核心是两样东西:
// ❌ 你以为的 moc:神奇的黑魔法
// 实际 moc 的输出其实很简单
// ✅ moc 生成的关键结构(简化版)
// 1. 元对象描述:每个 signal/slot 的签名和索引
static const uint qt_meta_data_Foo[] = {
// signal: "sig()" → 索引 0
// slot: "onClick()" → 索引 1
};
// 2. 调用函数:所有信号/槽的最终入口
int Foo::qt_metacall(int idx, void **args){
switch (idx) {
case 0: // signal sig()
sig(); // ← 递归调用,触发 QMetaObject::activate
break;
case 1: // slot onClick()
onClick();
break;
}
}
关键顿悟:moc 不是黑魔法。它就是帮你生成了一个索引表 + 分发函数。你写的所有信号都在这个 switch-case 里,编译器能内联优化。
1.2 connect 做了什么?
connect(sender, SIGNAL(sig()), receiver, SLOT(onClick())) 背后就一件事:
// 伪代码:connect 的核心逻辑
void connect(sender, signal_index, receiver, slot_index){
Connection c;
c.signalIndex = signal_index; // 信号的编号
c.receiver = receiver; // 接收者指针
c.slotIndex = slot_index; // 槽的编号
// 把连接信息挂到发送者的链表上
sender->connections[signal_index].append(c);
}
就是往一个链表里插了一条记录。没别的了。
1.3 emit 到底干了什么?
这个最颠覆认知——
emit signal() 本质上就是一次普通的函数调用。
// 你写的:
emit sig();
// moc 把它翻译成:
QMetaObject::activate(this, &staticMetaObject, 0, nullptr);
// ↑ signal 索引
activate() 做的事情也很简单:
// 伪代码:QMetaObject::activate 核心逻辑
void activate(sender, mo, signal_index, argv){
// 1. 找到这个信号对应的 connections 链表
auto conns = sender->connections[signal_index];
// 2. 遍历所有连接
for (auto &c : conns) {
// 3. 通过 qt_metacall 调用接收者的槽
c.receiver->qt_metacall(c.slotIndex, argv);
}
}
真没你想的那么复杂。 就是一个函数调用 + 链表遍历。
二、手写 mini 信号槽
明白了原理,手写一个就很简单了。核心设计:
Signal:持有一个"连接"列表, operator()时遍历调用Slot:包装成 std::function<void()>Connection:关联 signal 和 slot
// ✅ mini 信号槽核心:30 行
#include <functional>
#include <vector>
#include <memory>
// 槽的包装
using Slot = std::function<void()>;
// 连接句柄(用于断开连接)
struct Connection {
std::shared_ptr<bool> alive; // 是否还活着
Connection() : alive(std::make_shared<bool>(true)) {}
bool connected() const{ return *alive; }
void disconnect(){ *alive = false; }
};
// 信号:核心就是这个 operator()
template <typename... Args>
class Signal {
public:
// 注册槽,返回连接句柄
Connection connect(std::function<void(Args...)> slot){
m_slots.push_back({slot, std::make_shared<bool>(true)});
return Connection{ m_slots.back().alive };
}
// 发射信号 —— 遍历所有还在的连接
void emit(Args... args){
for (auto &s : m_slots) {
if (*s.alive) {
s.fn(args...);
}
}
}
// 直接用 operator() 触发
void operator()(Args... args){ emit(args...); }
private:
struct SlotEntry {
std::function<void(Args...)> fn;
std::shared_ptr<bool> alive;
};
std::vector<SlotEntry> m_slots;
};
用法更简单:
Signal<int> valueChanged;
// 接收者注册
auto conn = valueChanged.connect([](int v) {
std::cout << "值变了: " << v << std::endl;
});
// 发射
valueChanged(42); // 输出 "值变了: 42"
// 断开
conn.disconnect();
valueChanged(100); // 啥也不输出
30 行。这就是信号槽的核心本质。
当然 Qt 的信号槽比这个复杂得多——它还要处理跨线程队列连接、参数类型自动编解码、sender() 上下文查询、信号链接信号……但骨架就是这个。你有了这个骨架,Qt 源码再复杂也只是在骨架上加肉。
三、Aether 的 MethodDispatcher
手写信号槽解决了"同一个进程内、头文件能互相看见"的通信问题。
但 Aether 遇到一个更棘手的问题——
插件 A 和插件 B 没有共享头文件,怎么调函数?
你总不能让所有插件都包含同一个头文件吧?那就违背了插件隔离的设计原则了。
Aether 的解法是 MethodDispatcher——通过字符串名字调用函数:
// 服务端:注册方法(需要知道类型)
class FileLogger {
public:
void info(const std::string &msg){ /* ... */ }
std::string level() const{ return "INFO"; }
};
MethodDispatcher disp;
disp.on("info", &FileLogger::info); // 成员函数指针
disp.on("level", &FileLogger::level);
// 调用端:只需要字符串名字 + void* 指针
FileLogger logger;
disp.invoke("info", &logger, {
std::any{std::string("hello")}
});
auto ret = disp.invoke("level", &logger, {});
// ret == std::any{std::string("INFO")}
这比 Qt 的信号槽更灵活——调用方不需要知道接收者的任何类型信息,只需要一个名字和一个 void*。
MethodDispatcher 的实现也不复杂,核心就三样东西:
// ✅ MethodDispatcher 核心 — 类型擦除的方法分发表
class MethodDispatcher {
public:
// 类型擦除的调用签名
using InvokeFn = std::function<
std::any(void* instance, std::vector<std::any> args)
>;
// 注册(成员函数指针版本)
template <typename Svc, typename Ret>
void on(const std::string &name, Ret (Svc::*method)()){
m_methods[name] = [method](void* inst, auto&&) -> std::any {
auto *svc = static_cast<Svc*>(inst);
if constexpr (std::is_void_v<Ret>){
(svc->*method)(); // void 返回
return std::any{};
} else {
return std::any{(svc->*method)()}; // 有返回值
}
};
}
// 调用
std::any invoke(const std::string &name,
void *instance,
std::vector<std::any> args = {}){
return m_methods.at(name)(instance, std::move(args));
}
private:
std::unordered_map<std::string, InvokeFn> m_methods;
};
在 Aether 中,MethodDispatcher 和 IoC 容器配合使用:
// 注册服务时带上 MethodDispatcher
container->bindNamed("logger", []() {
auto logger = std::make_shared<FileLogger>();
auto disp = std::make_shared<MethodDispatcher>();
disp->on("info", &FileLogger::info);
disp->on("level", &FileLogger::level);
return NamedBinding{ logger, disp };
});
// 调用方:不知道 FileLogger 类型,但能调方法
auto handle = container->makeNamed("logger");
handle.call("info", { std::any{std::string("started")} });
auto lvl = handle.get("level");
这解决了 Qt 信号槽做不到的一件事——跨插件、无头文件的函数调用。你只需要一个"协议名字",不需要对方的类定义。
四、4 种 C++ 回调方案对比
每种回调方案都有自己的位置。说哪个"最好"都是外行话,得看场景。
| 信号槽 | ||||||
| std::function | ||||||
| 函数指针 | ||||||
| 观察者模式 | ||||||
| MethodDispatcher |
几个选型建议:
需要零耦合 + 跨模块?用 MethodDispatcher。 这是 Aether 解决插件通信的核心手段。调用方不需要任何头文件,只要知道字符串名字。
需要类型安全 + 一对多?用信号槽。 Qt 项目内部通信,信号槽还是最顺手的选择。编译期检查参数类型,不会出现运行时才发现参数传错。
需要极致性能 + 一对一?用 std::function。 信号槽的遍历和动态分派有开销,高频回调场景(比如每帧更新)用 std::function 更合适。
需要 C 接口兼容?用函数指针。 写 C 接口的库(或者 C ABI 的插件),只能用函数指针,没别的好选。
一个真实的反面教材
之前见过一个项目,在信号槽的槽函数里直接抛异常——
// ❌ 槽里抛异常,引发连环崩溃
connect(device, &Device::error, [](int code) {
if (code == 0x42) {
throw std::runtime_error("致命错误");
// ↑ Qt 信号槽不支持异常传播。跨线程队列连接时,
// 异常会在事件循环中被吞掉,程序直接崩溃。
}
});
// ✅ 正确的做法:在信号槽里捕获异常
connect(device, &Device::error, [](int code) {
try {
handleError(code);
} catch (const std::exception &e) {
// 记录错误,不要抛出去
Log::error("信号槽异常: {}", e.what());
}
});
"信号槽就是普通函数调用,但不是所有普通函数的行为都适合它。" ——特别是异常和阻塞操作。
五、一点个人感悟
写信号槽这 3 天,我最深的感受不是"技术不过如此"。
而是——
好代码和差代码的区别,不在于用了什么高级技术。在于它用最朴实的方式,把一件事做对了。
Qt 的信号槽,从架构层面看很精妙。但从实现层面看,就是 moc 生成的一个 switch + 运行时的一个链表 + activete 里的一次遍历。
没有魔法。
Aether 的 MethodDispatcher 也一样。它的核心就是一个 unordered_map<string, function>。
所以别怕源码。
下次看到 QMetaObject::activate,告诉自己:这就是一个 for 循环。看到 qt_metacall,告诉自己:这就是一个 switch。
代码只是代码。敢打开看,你就已经赢了 90% 的人。
评论区聊两句
你知道信号槽的 5 种连接类型吗?AutoConnection、DirectConnection、QueuedConnection、BlockingQueuedConnection、UniqueConnection——你踩过哪个的坑?评论区说说你的经历。
如果你也遇到过"跨模块调不了函数"的问题,说说你是怎么解决的。
如果觉得有帮助,点个在看让更多人看到,也欢迎转发给正在学习 Qt 源码的朋友。
下篇预告
信号槽解决了通信问题,但还有另一个隐藏的挑战——
两个插件不能包含对方的头文件,又想调对方的函数,怎么办?
下一篇,Aether 的方案来了:插件不能直接调别的插件?Aether 用三种方案搞定跨模块通信。 其中一种你一定没想到。
夜雨聆风