当前位置: 首页 > news >正文

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_backinsert(it, val)erase(remove(...))std::findreserve
QVector<T>appendinsert(i, val)removeAllcontainsreserve
std::unordered_set<T>insertinsert(val)无索引erasecountreserve(桶)
QSet<T>insertinsert(val)无索引removecontainsreserve

如果每次操作都要区分容器类型,代码会爆炸。但不能改标准库的容器——它们不可能去继承某个"公共接口"。

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 还是 set

2.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_traitsstd::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_dstnoexcept赋值裸指针,不抛
get_src/get_dstnoexcept返回裸指针,不抛
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 源码后。

http://www.jsqmd.com/news/1083943/

相关文章:

  • 深度剖析 Musl libc 线程私有数据 (TSD):极简的 Key 管理与析构机制
  • AI写论文有妙招!4款AI论文生成工具,解决你的写作难题!
  • 开源游戏加速工具OpenSpeedy的3大突破性架构解析
  • [Android] Blackmagic Camera_3.3.2专业录制-解锁手机的强大功能
  • 探索SDR++:5个让无线电世界触手可及的核心优势
  • Kubernetes ConfigMap 与 Secret 实战指南
  • 国家社科基金项目申报资料(含申报书范本,立项清单、各阶段报告及申报经验)
  • QMCDecode:macOS上快速解密QQ音乐加密音频的终极指南
  • 计算机游戏的事件-数据库架构:2025-2026出版新书的《人月神话》引用(5)
  • CST电磁仿真如何将GRIN透镜天线用于6G
  • 树莓派官方触屏Touch Display 2硬件解析与实战避坑指南
  • 2026年国内GEO培训机构深度研究,算法迭代教学详解:为什么学旧打法3个月必失效
  • 女性肠道养护与全维度养生科普,莱香发酵膳食辅助调理知识分享
  • 【JetBrains认证专家实测】:Eclipse项目导入IDEA成功率提升97.3%的6项关键配置
  • 自然科考研学,理科生专属高阶社会实践项目
  • Web 安全实战:身份验证与会话管理漏洞全方位攻防测试笔记
  • 从零掌握Locust:Python协程驱动的高并发负载测试实战指南
  • 每日 Agent 核心知识Day12:安全与合规核心知识(Agent 生命线)
  • 山东先进网上阅卷公司有哪些
  • 从Kac-Moody代数到群概形:构造、完备化与仿射型实现
  • 阴阳师自动化脚本终极指南:智能游戏管家解放你的双手
  • 终极指南:如何用QMCDecode快速解锁QQ音乐加密文件
  • CAD Electrical 2027安装教程(2026年保姆级超详解)【附安装包+电气符号原理图指南】
  • 【JAVA毕设源码分享】基于springboot小型哺乳类宠物诊所管理系统的设计与实现(程序+文档+代码讲解+一条龙定制)
  • 把GPT-5.5摁进真实开发环境跑了7天:代码、多模态、长文本全维度实测,这可能是2026年最值得升级的模型
  • 【图像分割】nnUnetV2的Windows部署与应用命令(保姆级图文教程)
  • 传统食品企业数字化转型案例:河北康贝尔的直播破局之路
  • Photoshop PS2026下载安装教程(附安装包)2026最新版(Photoshop PS2026)
  • CapCut钓鱼攻击深度解析:从恶意应用到账户安全防御
  • Open X-Embodiment数据集深度解析与微调实战