上一篇拆解了 graph_property_impl 和观察者体系的三五原则模式。本文聚焦 gtpo::edge<> 和 container_adapter,以及贯穿整个库的 noexcept。


一、gtpo::edge<> —— 极简却暗藏玄机

1.1 类的完整定义

template <class edge_base_t, class graph_t, class node_t>
class edge : public edge_base_t,
             public graph_property_impl<graph_t>
{
public:
    edge(edge_base_t* parent = nullptr) noexcept : edge_base_t{parent} {}
    explicit edge(const node_t* src, const node_t* dst);
    virtual ~edge();

    edge(const edge&) = delete;          // 禁止拷贝构造
    // 拷贝赋值、移动构造、移动赋值 —— 一个字都没写

    auto set_src(node_t* src) noexcept -> void { _src = src; }
    auto set_dst(node_t* dst) noexcept -> void { _dst = dst; }
    auto get_src() noexcept -> node_t* { return _src; }
    auto get_src() const noexcept -> const node_t* { return _src; }
    auto get_dst() noexcept -> node_t* { return _dst; }
    auto get_dst() const noexcept -> const node_t* { return _dst; }

    auto get_serializable() const -> bool { return _serializable; }
    auto set_serializable(bool s) -> void { _serializable = s; }

private:
    node_t* _src = nullptr;
    node_t* _dst = nullptr;
    bool    _serializable = true;
};

非常短——核心就两个裸指针(src/dst)加一个序列化标记。但你仔细看拷贝控制的写法,暗藏了编译期生成规则的组合拳。

1.2 只写了一个 = delete,剩下的靠编译器规则

edge(const edge&) = 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.4 explicit — 别把两个指针偷偷变成一条边

explicit edge(const node_t* src, const node_t* dst);

C++11 之前 explicit 只能用于单参数构造函数。C++11 起扩展到多参数——防止列表初始化的隐式转换:

// 没有 explicit 的话,以下都是合法的:
Node* a = ...;
Node* b = ...;

Edge e = {a, b};           // 拷贝列表初始化 —— 看起来像结构体赋值
void foo(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 <typename container_t>
struct container_adapter { };

// 全特化 std::vector<T>
template <typename T>
struct container_adapter<std::vector<T>> {
    inline static void insert(T t, std::vector<T>& c) { c.push_back(t); }
    inline static void insert(T t, std::vector<T>& c, int i) { c.insert(i, t); }
    inline static void remove(const T& t, std::vector<T>& c) {
        c.erase(std::remove(c.begin(), c.end(), t), c.end());  // erase-remove idiom
    }
    // ...
};

// 全特化 std::unordered_set<T>
template <typename T>
struct container_adapter<std::unordered_set<T>> {
    inline static void insert(T t, std::unordered_set<T>& c) { c.insert(t); }
    inline static void remove(const T& 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<typename T>
void insert(T val, std::vector<T>& c);

template<typename T>
void insert(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 它是什么

void func() noexcept;         // 承诺:绝不抛异常
void func() noexcept(false);  // 可能抛异常(默认)
void func();                  // 等价于 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 扩容核心逻辑(伪代码)
if constexpr (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++ 就永远不会消失。


五、本系列文章


2026年,某个深夜读完 GTpo 源码后。

更多推荐