QuickQanava 源码阅读笔记(二):edge、容器适配器与 noexcept 的极致
上一篇拆解了
graph_property_impl和观察者体系的三五原则模式。本文聚焦gtpo::edge<>和container_adapter,以及贯穿整个库的noexcept。
一、gtpo::edge<> —— 极简却暗藏玄机
1.1 类的完整定义
template<classedge_base_t,classgraph_t,classnode_t>classedge:publicedge_base_t,publicgraph_property_impl<graph_t>{public:edge(edge_base_t*parent=nullptr)noexcept:edge_base_t{parent}{}explicitedge(constnode_t*src,constnode_t*dst);virtual~edge();edge(constedge&)=delete;// 禁止拷贝构造// 拷贝赋值、移动构造、移动赋值 —— 一个字都没写autoset_src(node_t*src)noexcept->void{_src=src;}autoset_dst(node_t*dst)noexcept->void{_dst=dst;}autoget_src()noexcept->node_t*{return_src;}autoget_src()constnoexcept->constnode_t*{return_src;}autoget_dst()noexcept->node_t*{return_dst;}autoget_dst()constnoexcept->constnode_t*{return_dst;}autoget_serializable()const->bool{return_serializable;}autoset_serializable(bools)->void{_serializable=s;}private:node_t*_src=nullptr;node_t*_dst=nullptr;bool_serializable=true;};非常短——核心就两个裸指针(src/dst)加一个序列化标记。但你仔细看拷贝控制的写法,暗藏了编译期生成规则的组合拳。
1.2 只写了一个= delete,剩下的靠编译器规则
edge(constedge&)=delete;// 显式禁止拷贝构造// 拷贝赋值、移动构造、移动赋值 —— 全没写但实际结果是拷贝、赋值、移动全部不可用。路径各不相同:
| 函数 | 状态 | 原因 |
|---|---|---|
| 拷贝构造 | = delete | 自己显式写了 |
| 拷贝赋值 | 隐式 delete | 基类 QObject 的拷贝赋值= delete,编译器不再为派生类生成 |
| 移动构造 | 不生成 | C++11 规则:声明了拷贝构造 → 移动构造和移动赋值自动被抑制 |
| 移动赋值 | 不生成 | 同上 |
这就是 Effective Modern C++ Item 17 的核心结论:
一旦你显式声明了拷贝构造函数、拷贝赋值运算符、或析构函数中的任意一个,编译器就不会再自动生成移动操作。
所以edge的四个拷贝/移动操作,最终效果是全 delete。但它没有像qan::Graph那样五个全手写,而是用了一个= delete+ 基类的= delete+ C++11 隐式生成规则。少写四行,效果相同。
当然也有代价:如果哪天edge_base_t换成了一个拷贝赋值为= default的非 QObject 基类,拷贝赋值就会"偷偷可用"。这就是为什么更稳健的写法是像Graph那样五个全显式——多写几行,永远不猜。
1.3 为什么析构里要检查_graph != nullptr?
virtual~edge(){if(graph_property_impl<graph_t>::_graph!=nullptr)std::cerr<<"Warning: an edge has been deleted before being ""removed from the graph."<<std::endl;}这是防御性编程:边必须先通过graph::remove_edge()从图中移除,才能 delete。如果在图还持有边的指针时直接 delete 边,图内部的_edges容器就会出现悬空指针。这个析构不阻止你犯错(它不abort()),但会在 stderr 上留一条警告,让你知道 bug 在哪。
这本质上就是 RAII 契约的"温和执行"——析构是最后一道防线。
1.4explicit— 别把两个指针偷偷变成一条边
explicitedge(constnode_t*src,constnode_t*dst);C++11 之前explicit只能用于单参数构造函数。C++11 起扩展到多参数——防止列表初始化的隐式转换:
// 没有 explicit 的话,以下都是合法的:Node*a=...;Node*b=...;Edge e={a,b};// 拷贝列表初始化 —— 看起来像结构体赋值voidfoo(Edge e);foo({a,b});// 临时创建一条边传给函数 —— 用户可能完全不知道边是有语义后果的拓扑操作。创建一条边应该在代码里被显式看到。加了explicit:
Edge e{a,b};// ✅ 可以Edge e={a,b};// ❌ 编译错误foo({a,b});// ❌ 编译错误foo(Edge{a,b});// ✅ 必须显式写出意图二、container_adapter —— 编译期多态的零成本抽象
2.1 问题:五种容器,五套 API
图需要管理节点、边、组、和查重集合。不同场景需要不同容器:
| 容器 | 插入 | 按索引插入 | 删除 | 查找 | 预分配 |
|---|---|---|---|---|---|
std::vector<T> | push_back/emplace_back | insert(it, val) | erase(remove(...)) | std::find | reserve |
QVector<T> | append | insert(i, val) | removeAll | contains | reserve |
std::unordered_set<T> | insert | insert(val)无索引 | erase | count | reserve(桶) |
QSet<T> | insert | insert(val)无索引 | remove | contains | reserve |
如果每次操作都要区分容器类型,代码会爆炸。但不能改标准库的容器——它们不可能去继承某个"公共接口"。
2.2 方案:模板全特化 + static 工具函数
// 主模板:空壳——用了不识别的容器,编译直接报错template<typenamecontainer_t>structcontainer_adapter{};// 全特化 std::vector<T>template<typenameT>structcontainer_adapter<std::vector<T>>{inlinestaticvoidinsert(T t,std::vector<T>&c){c.push_back(t);}inlinestaticvoidinsert(T t,std::vector<T>&c,inti){c.insert(i,t);}inlinestaticvoidremove(constT&t,std::vector<T>&c){c.erase(std::remove(c.begin(),c.end(),t),c.end());// erase-remove idiom}// ...};// 全特化 std::unordered_set<T>template<typenameT>structcontainer_adapter<std::unordered_set<T>>{inlinestaticvoidinsert(T t,std::unordered_set<T>&c){c.insert(t);}inlinestaticvoidremove(constT&t,std::unordered_set<T>&c){c.erase(t);}// 只有三个方法!};// 全特化 QVector<T> ...// 全特化 QSet<T> ...调用端完全统一:
container_adapter<C>::insert(item,container);// 不管 C 是 vector 还是 set2.3 为什么用模板特化而不是函数重载?
C++ 函数模板不支持偏特化。这个语法限制是根本原因:
// ❌ 函数模板偏特化 —— C++ 根本不允许!template<typenameT>voidinsert(T val,std::vector<T>&c);template<typenameT>voidinsert(T val,std::unordered_set<T>&c);// 编译错误!即使绕过,也无法实现"不同容器暴露不同方法集合"的效果。unordered_set不需要size()适配器、不需要reserve()适配器(语义不同,容易误用)。用类模板特化,编译期自动选择正确版本——不支持的操作用了直接编译报错,而不是运行时炸。
这是标准的traits 模式。STL 里的std::iterator_traits、std::allocator_traits都是同一个技法。
2.4 为什么unordered_set只实现了三个方法?
insert insert(i) remove size contains reserve vector ✅ ✅ ✅ ✅ ✅ ✅ unordered_set ✅ ✅ ✅ — — — QVector ✅ ✅ ✅ ✅ ✅ ✅ QSet ✅ ✅ ✅ ✅ ✅ ✅size()— 所有容器都有.size(),直接调用,不需适配。contains()— 调用路径不经过 adapter,不需要。reserve()—unordered_set::reserve(n)预分配的是桶数,不是元素空间。和vector::reserve()语义完全不同。不写是故意防御——让误用变成编译错误。
设计原则:只写实际会被调用的接口。胶水代码多一行就多一个维护点。
2.5 编译期多态 vs 虚函数 —— C++ 的零成本抽象哲学
| 继承 + 虚函数 | 模板全特化(本文件) | |
|---|---|---|
| 需要公共基类 | ✅ 必须 | ❌ 不需要 |
| 对第三方的侵入性 | 必须继承基类 | 零侵入 |
| dispatch 时机 | 运行时(vtable 间接跳转) | 编译期(完全内联) |
| 额外空间开销 | vtable 指针(8 bytes/对象) | 零 |
| 额外时间开销 | 间接跳转 + 分支预测 | 零 —— 等价于直接调用 |
| 适合场景 | 运行时换策略 | 编译期确定的类型 |
用container_adapter<std::vector<node_t*>>::insert(node, vec),编译完成后跟直接写vec.push_back(node)生成的机器码一模一样。
如果 Java 来写,要定义一个
IContainer接口,然后VectorAdapter/SetAdapter分别实现。每次add()都得走虚函数。C++ 给了你另一个选择:让编译器在编译期把适配层"融掉"——高层的整洁接口,低层的零开销指令。这就是 C++ 零成本抽象的含义。
三、noexcept —— C++ 性能追求的密钥
3.1 它是什么
voidfunc()noexcept;// 承诺:绝不抛异常voidfunc()noexcept(false);// 可能抛异常(默认)voidfunc();// 等价于 noexcept(false)noexcept是 C++11 引入的关键字。它不是注释、不是建议——是编译器和标准库严肃对待的契约。违反契约(noexcept 函数内抛异常)不触发 catch、不展开栈——直接std::terminate()终止进程。
3.2 析构函数默认就是 noexcept
C++11 起,所有析构函数隐式声明为noexcept(true)。因为析构抛异常 = 双重异常 = 未定义行为:
{std::vector<Widget>vec(1000);}// 离开作用域,销毁 1000 个 Widget// 如果第 1 个析构抛异常,第 2~1000 个怎么办?// 如果第 2 个也抛 → 两个异常同时存在 → std::terminate这是语言层面的强制,不是可选的"最佳实践"。
3.3 noexcept 如何影响性能 —— std::vector 扩容的秘密
这是noexcept最精妙的应用。当你向std::vector<Foo>追加元素触发扩容时:
std::vector<Foo>vec;vec.push_back(Foo{});// 如果 capacity 不够 → 分配新内存 → 搬元素搬元素时,std::vector内部用std::move_if_noexcept来决定策略:
// std::vector 扩容核心逻辑(伪代码)ifconstexpr(std::is_nothrow_move_constructible_v<Foo>){// ✅ Foo 的移动构造是 noexcept → 放心移动!// 移动 = 偷指针:O(1),三条指令new(new_ptr+i)Foo(std::move(old_ptr[i]));}else{// ⚠️ Foo 的移动构造可能抛异常 → 退化到拷贝!// 拷贝 = 完整深拷贝:O(N)// 因为移动抛异常后无法回滚(源对象已被篡改),// 拷贝抛异常后可以回滚(源对象完好无损)new(new_ptr+i)Foo(old_ptr[i]);}一个noexcept关键字,决定std::vector扩容时是 O(1) 的指针交换还是 O(N) 的完整拷贝。
这就是为什么graph_property_impl那种只有一个裸指针的类,也要显式写:
graph_property_impl(graph_property_impl&&)noexcept=default;graph_property_impl&operator=(graph_property_impl&&)noexcept=default;3.4 noexcept 在此项目中的全景
| 位置 | 声明 | 原因 |
|---|---|---|
~graph_property_impl() | noexcept(隐式) | 析构默认 noexcept |
~node() | noexcept | 内部只清理容器,不抛异常 |
~observable() | noexcept | 清理vector<unique_ptr>不抛 |
graph_property_impl(T&&) | noexcept = default | 只移动裸指针 → 让std::vector扩容时走移动而非拷贝 |
edge(edge_base_t* parent) | noexcept | 传指针不可能抛异常 |
set_src/set_dst | noexcept | 赋值裸指针,不抛 |
get_src/get_dst | noexcept | 返回裸指针,不抛 |
notify_*系列 | noexcept | 遍历调用 observer,自身不抛 |
| 所有 observer 虚函数 | noexcept | 观察者回调不抛异常——保证图的拓扑操作不会在半路崩掉 |
3.5 noexcept 与虚函数
noexcept是函数签名的一部分。C++17 起,基类虚函数不写 noexcept 的,派生类可以加 noexcept("不抛"是"可能抛"的子集)。但如果基类写了 noexcept,派生类override 必须也是 noexcept,否则编译报错。
这在 GTpo 的观察者体系中很重要——如果基类graph_observer::on_node_inserted声明了noexcept,所有自定义观察者的 override 也都必须遵守这个契约:拓扑变更通知绝不抛异常。
四、感慨:C++ 的性能极致,藏在每一个 noexcept 里
写完这一系列的阅读笔记,我最大的感受是:
C++ 跟其他语言的根本区别,不在于语法复杂,而在于它把"性能选择权"完整地交给了程序员。
GC 语言替你做了太多决定:对象一律堆分配、GC 异步回收、拷贝由运行时优化。这些决策让你少写代码,但也封死了你插手优化的通道。
在 QuickQanava 里,我看到一个 C++ 老手对性能的偏执:
container_adapter:编译期多态替代虚函数,每次insert()完全内联,零额外指令。_graph裸指针替代weak_ptr,注释写 “This is the only raw pointer in GTpo”——因为weak_ptr::lock()是原子操作,有 CPU 开销,而这里图的生命周期由 Qt 父子树保证,不需要引用计数。noexcept写在移动构造上,让std::vector扩容时敢移动。千条边的节点,移动是交换一个指针,拷贝是千次push_back。_in_nodes冗余缓存:多存一份指针,换 O(1) vs O(E) 的查询差距。
这就是 C++ 程序员的信仰:不为不用的功能付钱,为必须用的功能付最少的钱。
Java 的ArrayList.add()每次都要查 vtable——你不知道它到底是不是同步包装器。C++ 的std::vector::push_back()编译完就是 4 条 CPU 指令。不会多,不会少。
这种"操控每一纳秒"的自由,代价就是你要学三五原则、学 noexcept、学模板全特化、学编译期多态、学 C++11 隐式生成规则。花了上百年时间踩坑积累下来的这些语法规则,但当你看懂之后,你会发现每一个设计都有它的理由:
= default不是偷懒,是"编译器,默认行为就是我想要的"。= delete不是放弃功能,是"编译期给我拦下所有语义错误"。noexcept不是可有可无的装饰,是"让标准库敢用移动,别退化成拷贝"。
只要这个世界还有人在乎"我的代码跑了 1000 万次调用到底花了多少纳秒",C++ 就永远不会消失。
五、本系列文章
- QuickQanava 源码阅读笔记(一):graph_property_impl、观察者与三五原则的四种模式(前一篇)
- QuickQanava 源码阅读笔记(二):edge、容器适配器与 noexcept 的极致(本文)
2026年,某个深夜读完 GTpo 源码后。
