当前位置: 首页 > 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.gsyq.cn/news/1595889.html

相关文章:

  • 国家社科基金项目申报资料(含申报书范本,立项清单、各阶段报告及申报经验)
  • AI写论文有妙招!4款AI论文生成工具,解决你的写作难题!
  • QMCDecode:macOS上快速解密QQ音乐加密音频的终极指南
  • 山东先进网上阅卷公司有哪些
  • CAD Electrical 2027安装教程(2026年保姆级超详解)【附安装包+电气符号原理图指南】
  • 从Kac-Moody代数到群概形:构造、完备化与仿射型实现
  • 传统食品企业数字化转型案例:河北康贝尔的直播破局之路
  • 大厂Agent架构我拆了三遍,发现一人公司只需要3个文件(附模板)
  • Moto 手机自带天气不会用?桌面插件一键添加城市,不用下载第三方 APP
  • 半年估值暴增2.5倍!Baseten融资15亿美元,成AI推理时代基础设施宠儿
  • Visual C++ Redistributable AIO:一站式解决Windows运行库缺失问题的终极指南
  • 源头厂家优势凸显!无锡百瑞德TIG热丝堆焊设备厂家实力解读
  • 按键精灵实现HMAC-SHA512加密:突破自动化脚本加密验证瓶颈
  • Photoshop下载教程Photoshop PS2026 保姆级安装步骤(附安装包)
  • 1985-2024年各省市区县绿色低碳专利申请与授权量
  • 无线感知与分布式LLM:边缘计算下的高效智能决策系统
  • LosslessCut无损视频剪辑:3分钟掌握专业级无损编辑技巧,告别画质损失烦恼
  • 终极文本到图像生成工具:NMKD Stable Diffusion GUI深度解析
  • QMCDecode终极指南:一键解锁QQ音乐加密音频,让音乐自由播放
  • C++跨平台(三):平台检测与条件编译
  • 如何在IDEA中优雅阅读:Thief-Book插件深度解析
  • 智慧转型AI与AR的革命
  • 如何在5分钟内为你的网站集成专业3D可视化:Online 3D Viewer终极实战指南
  • 小爱音箱终极解锁方案:三步实现永久免费听歌自由
  • Zabbix联动深信服防火墙实现攻击IP自动封禁:Python脚本与自动化运维实战
  • 如何零代码实现抖音直播间数据实时监控?DouyinLiveWebFetcher终极指南
  • ETS2LA:欧洲卡车模拟2自动驾驶终极指南 - 重新定义卡车驾驶体验
  • 判断力:钱学森说的“性智”,今天终于可以工程化了
  • 技术问答自动整理:用 OpenClaw 爬取并整理 Stack Overflow/CSDN 优质问答
  • 5分钟上手!在IDEA中打造你的专属阅读空间:Thief-Book插件完全指南