乐于分享
好东西不私藏

手写Qt信号槽后我再也不怕Qt源码了(3个原理)

手写Qt信号槽后我再也不怕Qt源码了(3个原理)

用 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, 0nullptr);
//                                            ↑ 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() constreturn *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() constreturn "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++ 回调方案对比

每种回调方案都有自己的位置。说哪个"最好"都是外行话,得看场景。

方案
耦合度
类型安全
跨模块
多接收者
性能
适用场景
信号槽
✅ 编译期
❌ 需头文件
✅ 一对多
Qt 项目标配
std::function
✅ 编译期
❌ 需头文件
❌ 一对一
回调、异步
函数指针
⚠️ 仅签名
❌ 需头文件
❌ 一对一
最高
C 接口、内核
观察者模式
⚠️ 运行时
✅ 接口抽象
✅ 一对多
事件总线
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 种连接类型吗?AutoConnectionDirectConnectionQueuedConnectionBlockingQueuedConnectionUniqueConnection——你踩过哪个的坑?评论区说说你的经历。

如果你也遇到过"跨模块调不了函数"的问题,说说你是怎么解决的。

如果觉得有帮助,点个在看让更多人看到,也欢迎转发给正在学习 Qt 源码的朋友。


下篇预告

信号槽解决了通信问题,但还有另一个隐藏的挑战——

两个插件不能包含对方的头文件,又想调对方的函数,怎么办?

下一篇,Aether 的方案来了:插件不能直接调别的插件?Aether 用三种方案搞定跨模块通信。 其中一种你一定没想到。