上一篇拆解了graph_property_impl和观察者体系的三五原则模式。本文聚焦gtpo::edge和container_adapter以及贯穿整个库的noexcept。一、gtpo::edge —— 极简却暗藏玄机1.1 类的完整定义templateclassedge_base_t,classgraph_t,classnode_tclassedge:publicedge_base_t,publicgraph_property_implgraph_t{public:edge(edge_base_t*parentnullptr)noexcept:edge_base_t{parent}{}explicitedge(constnode_t*src,constnode_t*dst);virtual~edge();edge(constedge)delete;// 禁止拷贝构造// 拷贝赋值、移动构造、移动赋值 —— 一个字都没写autoset_src(node_t*src)noexcept-void{_srcsrc;}autoset_dst(node_t*dst)noexcept-void{_dstdst;}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{_serializables;}private:node_t*_srcnullptr;node_t*_dstnullptr;bool_serializabletrue;};非常短——核心就两个裸指针src/dst加一个序列化标记。但你仔细看拷贝控制的写法暗藏了编译期生成规则的组合拳。1.2 只写了一个 delete剩下的靠编译器规则edge(constedge)delete;// 显式禁止拷贝构造// 拷贝赋值、移动构造、移动赋值 —— 全没写但实际结果是拷贝、赋值、移动全部不可用。路径各不相同函数状态原因拷贝构造 delete自己显式写了拷贝赋值隐式 delete基类 QObject 的拷贝赋值 delete编译器不再为派生类生成移动构造不生成C11 规则声明了拷贝构造 → 移动构造和移动赋值自动被抑制移动赋值不生成同上这就是 Effective Modern C Item 17 的核心结论一旦你显式声明了拷贝构造函数、拷贝赋值运算符、或析构函数中的任意一个编译器就不会再自动生成移动操作。所以edge的四个拷贝/移动操作最终效果是全 delete。但它没有像qan::Graph那样五个全手写而是用了一个 delete 基类的 delete C11 隐式生成规则。少写四行效果相同。当然也有代价如果哪天edge_base_t换成了一个拷贝赋值为 default的非 QObject 基类拷贝赋值就会偷偷可用。这就是为什么更稳健的写法是像Graph那样五个全显式——多写几行永远不猜。1.3 为什么析构里要检查_graph ! nullptrvirtual~edge(){if(graph_property_implgraph_t::_graph!nullptr)std::cerrWarning: 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);C11 之前explicit只能用于单参数构造函数。C11 起扩展到多参数——防止列表初始化的隐式转换// 没有 explicit 的话以下都是合法的Node*a...;Node*b...;Edge e{a,b};// 拷贝列表初始化 —— 看起来像结构体赋值voidfoo(Edge e);foo({a,b});// 临时创建一条边传给函数 —— 用户可能完全不知道边是有语义后果的拓扑操作。创建一条边应该在代码里被显式看到。加了explicitEdge e{a,b};// ✅ 可以Edge e{a,b};// ❌ 编译错误foo({a,b});// ❌ 编译错误foo(Edge{a,b});// ✅ 必须显式写出意图二、container_adapter —— 编译期多态的零成本抽象2.1 问题五种容器五套 API图需要管理节点、边、组、和查重集合。不同场景需要不同容器容器插入按索引插入删除查找预分配std::vectorTpush_back/emplace_backinsert(it, val)erase(remove(...))std::findreserveQVectorTappendinsert(i, val)removeAllcontainsreservestd::unordered_setTinsertinsert(val)无索引erasecountreserve(桶)QSetTinsertinsert(val)无索引removecontainsreserve如果每次操作都要区分容器类型代码会爆炸。但不能改标准库的容器——它们不可能去继承某个公共接口。2.2 方案模板全特化 static 工具函数// 主模板空壳——用了不识别的容器编译直接报错templatetypenamecontainer_tstructcontainer_adapter{};// 全特化 std::vectorTtemplatetypenameTstructcontainer_adapterstd::vectorT{inlinestaticvoidinsert(T t,std::vectorTc){c.push_back(t);}inlinestaticvoidinsert(T t,std::vectorTc,inti){c.insert(i,t);}inlinestaticvoidremove(constTt,std::vectorTc){c.erase(std::remove(c.begin(),c.end(),t),c.end());// erase-remove idiom}// ...};// 全特化 std::unordered_setTtemplatetypenameTstructcontainer_adapterstd::unordered_setT{inlinestaticvoidinsert(T t,std::unordered_setTc){c.insert(t);}inlinestaticvoidremove(constTt,std::unordered_setTc){c.erase(t);}// 只有三个方法};// 全特化 QVectorT ...// 全特化 QSetT ...调用端完全统一container_adapterC::insert(item,container);// 不管 C 是 vector 还是 set2.3 为什么用模板特化而不是函数重载C 函数模板不支持偏特化。这个语法限制是根本原因// ❌ 函数模板偏特化 —— C 根本不允许templatetypenameTvoidinsert(T val,std::vectorTc);templatetypenameTvoidinsert(T val,std::unordered_setTc);// 编译错误即使绕过也无法实现不同容器暴露不同方法集合的效果。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_adapterstd::vectornode_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是 C11 引入的关键字。它不是注释、不是建议——是编译器和标准库严肃对待的契约。违反契约noexcept 函数内抛异常不触发 catch、不展开栈——直接std::terminate()终止进程。3.2 析构函数默认就是 noexceptC11 起所有析构函数隐式声明为noexcept(true)。因为析构抛异常 双重异常 未定义行为{std::vectorWidgetvec(1000);}// 离开作用域销毁 1000 个 Widget// 如果第 1 个析构抛异常第 21000 个怎么办// 如果第 2 个也抛 → 两个异常同时存在 → std::terminate这是语言层面的强制不是可选的最佳实践。3.3 noexcept 如何影响性能 —— std::vector 扩容的秘密这是noexcept最精妙的应用。当你向std::vectorFoo追加元素触发扩容时std::vectorFoovec;vec.push_back(Foo{});// 如果 capacity 不够 → 分配新内存 → 搬元素搬元素时std::vector内部用std::move_if_noexcept来决定策略// std::vector 扩容核心逻辑伪代码ifconstexpr(std::is_nothrow_move_constructible_vFoo){// ✅ Foo 的移动构造是 noexcept → 放心移动// 移动 偷指针O(1)三条指令new(new_ptri)Foo(std::move(old_ptr[i]));}else{// ⚠️ Foo 的移动构造可能抛异常 → 退化到拷贝// 拷贝 完整深拷贝O(N)// 因为移动抛异常后无法回滚源对象已被篡改// 拷贝抛异常后可以回滚源对象完好无损new(new_ptri)Foo(old_ptr[i]);}一个noexcept关键字决定std::vector扩容时是 O(1) 的指针交换还是 O(N) 的完整拷贝。这就是为什么graph_property_impl那种只有一个裸指针的类也要显式写graph_property_impl(graph_property_impl)noexceptdefault;graph_property_imploperator(graph_property_impl)noexceptdefault;3.4 noexcept 在此项目中的全景位置声明原因~graph_property_impl()noexcept(隐式)析构默认 noexcept~node()noexcept内部只清理容器不抛异常~observable()noexcept清理vectorunique_ptr不抛graph_property_impl(T)noexcept default只移动裸指针 → 让std::vector扩容时走移动而非拷贝edge(edge_base_t* parent)noexcept传指针不可能抛异常set_src/set_dstnoexcept赋值裸指针不抛get_src/get_dstnoexcept返回裸指针不抛notify_*系列noexcept遍历调用 observer自身不抛所有 observer 虚函数noexcept观察者回调不抛异常——保证图的拓扑操作不会在半路崩掉3.5 noexcept 与虚函数noexcept是函数签名的一部分。C17 起基类虚函数不写 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、学模板全特化、学编译期多态、学 C11 隐式生成规则。花了上百年时间踩坑积累下来的这些语法规则但当你看懂之后你会发现每一个设计都有它的理由 default不是偷懒是编译器默认行为就是我想要的。 delete不是放弃功能是编译期给我拦下所有语义错误。noexcept不是可有可无的装饰是让标准库敢用移动别退化成拷贝。只要这个世界还有人在乎我的代码跑了 1000 万次调用到底花了多少纳秒C 就永远不会消失。五、本系列文章QuickQanava 源码阅读笔记一graph_property_impl、观察者与三五原则的四种模式前一篇QuickQanava 源码阅读笔记二edge、容器适配器与 noexcept 的极致本文2026年某个深夜读完 GTpo 源码后。