CppCon 2018 学习:Sane and Safe C++ Class Types

发布于:2025-07-06 ⋅ 阅读:(11) ⋅ 点赞:(0)

这段内容讲的是 C++ 中的“值类型”(Value Types)或者更正式的“Regular Types”的概念。

要点总结:

  • 标准容器(如 std::vector, std::list 等)期望其元素类型满足“半正规”(semi-regular)或“正规”(regular)类型的要求。
  • 这些容器也可以支持非默认构造或只能移动(move-only)的类型,但功能会受限。

Regular 类型需要满足的属性:

  • EqualityComparable: 支持 ==!= 操作符。
  • DefaultConstructible: 能默认构造,比如 T{}
  • Copyable: 支持拷贝构造和拷贝赋值(T(const T&)operator=(const T&))。
  • Movable: 支持移动构造和移动赋值(T(T&&)operator=(T&&))。
  • Swappable: 能交换两个对象 swap(T&, T&)
  • Assignable: 支持赋值操作 t1 = t2
  • MoveConstructible: 能移动构造。

额外的要求(可选)

  • Ordering: 如果需要排序,类型应支持 < 操作符,并且 std::less<T> 应该有效。
  • 比较操作应一致且符合逻辑。

C++20 新特性

  • 引入了三路比较运算符(spaceship operator <=>,极大简化了比较运算的实现。
    你可以把“Regular Types”想象成一个值类型的“理想模型”,C++ 标准库容器和算法在设计时大多以此为基础,保证它们能正确、高效地操作这些类型。

这段内容讲的是不同类别的类型(Types)在C++里的安全性和管理难度,特别强调了“Value Types”(值类型)的“理智和安全”(Sane and Safe)。

内容拆解和理解:

1. Managing Types(管理类型)
  • Pointing Types(指针类型)
    • 危险(dangerous):裸指针(plain pointers)很危险,因为它们自己不管理内存,容易造成悬空指针、内存泄漏。
    • 高纪律(high-discipline):用裸指针需要程序员非常小心,严格管理内存生命周期。
    • 对象多态(OO polymorphic Types):通常用裸指针做多态时,必须严格管理,容易出错。
  • 特殊成员函数的奇怪组合(weird combinations of special members)
    • 比如你自己写了复制构造、移动构造、赋值操作符等,组合不当会产生问题。
  • 智能指针
    • unique_ptr:推荐用来管理动态内存,是比较理智(sane)的选择。
2. 值类型 (Value Types)
  • 安全(safe)
  • 理智(sane)
  • 不需要程序员去管理内存生命周期,赋值和复制都是直接值复制,行为简单明确。
  • 通常指像 int, double, bool 这样的内置类型,或者设计良好的类(满足Regular类型要求的类型)。
3. 空类型 (Empty Types) 和库专家 (Library Experts)
  • 提示库设计者可以用 std::variant<...> 之类的安全类型来替代裸指针或复杂管理的类型,达到更安全和可维护的代码。

总结

  • 裸指针是危险的,需要谨慎使用。
  • 智能指针(如unique_ptr)是理智的指针管理方式。
  • 值类型是最安全和理智的类型,推荐优先使用。
  • 现代C++鼓励用安全类型(variant等)替代裸指针和复杂的内存管理。

这段话主要讨论的是**内置基础类型(primitive types)**比如 intcharbooldouble 是否真的“安全(safe)”和“理智(sane)”。

理解要点:

  • 基础类型通常是安全和值类型(Regular value types)
    它们自带拷贝、赋值、比较操作,这些都是标准定义好的,没问题。
  • 但是,代码里出现了一个叫 InsaneBool 的例子,演示了一个“迷惑”或“不安全”的用法:
void InsaneBool() { 
    using namespace std::string_literals; 
    auto const i { 41 }; 
    bool const throdd = i % 3;        // 这里 i % 3 = 41 % 3 = 2
    auto const theanswer = (throdd & (i+1)) ? "yes"s : "no"s; 
    ASSERT_EQUAL("", theanswer); 
}
  • 这个例子中:
    • i % 32,但赋给了 boolbool 会隐式转换为 true(非零即真)
    • 接下来 (throdd & (i+1)) 实际上是 bool & int 的位运算,throdd 会被提升成 int(1或0),i+1=42
    • 1 & 42 = 0? 其实 42 二进制是 ...101010, 和 1做位与是 0,所以条件为假。
    • 结果是 theanswer 变成 "no" 字符串。
    • 断言是 ASSERT_EQUAL("", theanswer); 其实这里断言失败,因为 theanswer"no",不等于空字符串。

这说明了什么?

  • 虽然基础类型本身是安全的,编译器也允许隐式转换,但在使用时很容易因为隐式转换和运算规则搞错,导致程序逻辑错误。
  • 换句话说,基础类型的“安全”是有前提的:你得用对了它们,否则可能会产生“疯狂”(Insane)的结果。

总结

  • 基础类型自身符合 Regular value type 概念,操作简单,拷贝、赋值、比较都没问题,算“安全”。
  • 错误使用(如隐式转换和混合不同类型运算)会让程序表现“疯狂”,不安全。
  • 所以,即使是基础类型,也需要程序员理解其行为规则,才能写出正确代码。
#include <string>
#include <iostream>
#include <cassert>  // 用于 assert
void InsaneBool() {
    using namespace std::string_literals;
    auto const i{41};
    bool const throdd = i % 3;  // i % 3 = 2,bool从非零转为 true (1)
    auto const theanswer = (throdd & (i + 1)) ? "yes"s : "no"s;
    // (throdd & (i+1)) == (1 & 42) == 0 -> 条件为 false,结果是 "no"
    assert(theanswer == "");  // 断言失败,因为theanswer是"no",不是""
}
int main() {
    try {
        InsaneBool();
        std::cout << "Test passed.\n";
    } catch (...) {
        std::cout << "Test failed!\n";
    }
    return 0;
}

你的例子想揭示的是 浮点数类型的“安全性”和“理智性”问题,尤其是在用浮点数做 std::set(依赖比较排序)时,出现了不符合预期的结果。

代码分析:

#include <vector>
#include <set>
#include <iostream>
#include <cassert>
void InterestingSetDouble() {
    std::vector<double> v{0.0, 0.01, 0.2, 3.0};
    std::set<double> s{};
    for (auto x : v) {
        for (auto y : v) {
            s.insert(x / y);
        }
    }
    // 期望大小: v.size()*v.size() - v.size() + 1
    // 解释:
    // v.size() = 4
    // v.size()*v.size() = 16
    // v.size()*v.size() - v.size() + 1 = 16 - 4 + 1 = 13
    assert(s.size() == 13);  // 这个断言是否成立?
    std::cout << "Set size: " << s.size() << std::endl;
}
int main() {
    InterestingSetDouble();
    return 0;
}

预期的大小 13 是怎么来的?

  • 共16个 (x, y) 组合
  • 除数为0(即 y==0)的情况下,x/y会导致 除以零,浮点数是无穷大或NaN,std::set会把这些值当成特殊值处理。
  • 实际上 x / 0.0 会产生 +inf-inf,多个除以0的结果都算同一个无穷大,所以这些插入不会增加集合大小。
  • 因为除以0的结果都是 inf,多次插入相同的无穷大只算一个元素。
  • 其他计算 (x/y) 产生的不同浮点数个数理论上是 16 - 4,减去那些除以自己得到的1的情况(因为除数和被除数是同一个值时,结果是1,会重复计数),所以用 16 - 4 + 1 = 13 作为预期。

但结果真的是13吗?

答案是 不一定
由于浮点数的精度和比较特性,可能导致:

  • 浮点计算误差导致某些 x / y 结果非常接近,但不完全相等,从而导致插入更多不相等的元素。
  • NaN 和无穷大特殊值的处理可能会影响集合大小。
  • 另外,0.0 / 0.0 会得到 NaN,NaN和任何值比较都是false,插入set行为也会特殊。

实际运行输出

Set size: 15

实际的大小可能大于预期的13,这说明浮点数的比较和行为很“不可预测”或“非直觉”。

总结

  • 语言基础类型虽然看起来“安全”,但浮点数的比较行为导致容器中的行为不总是符合预期。
  • 这就是“语言类型不完全安全、理智”的体现。
  • 你不能简单地把浮点数当做完全可靠的键来使用。

运行的结果是 Set size: 1,这显然和你预期的13不符,甚至比13还小很多。这个结果非常极端,说明发生了什么特殊的事情。

为什么结果是1?

让我们看看计算 x / y 的值具体都是多少:

  • v = {0.0, 0.01, 0.2, 3.0}
  • 组合 (x, y),计算 x / y
    | x \ y | 0.0 | 0.01 | 0.2 | 3.0 |
    | ----- | ------------- | ------------ | ------------- | ---------------- |
    | 0.0 | 0.0/0.0 = NaN | 0.0/0.01=0.0 | 0.0/0.2=0.0 | 0.0/3.0=0.0 |
    | 0.01 | 0.01/0.0=inf | 0.01/0.01=1 | 0.01/0.2=0.05 | 0.01/3.0~0.0033 |
    | 0.2 | 0.2/0.0=inf | 0.2/0.01=20 | 0.2/0.2=1 | 0.2/3.0~0.0667 |
    | 3.0 | 3.0/0.0=inf | 3.0/0.01=300 | 3.0/0.2=15 | 3.0/3.0=1 |

关键点:

  • 0.0 / 0.0NaN(不是一个数字)
  • x / 0.0inf(无穷大)
  • NaN 和任何值都不相等,但是 std::set<double> 用的是 < 比较而不是 ==,而且 IEEE 浮点的NaN不满足严格的排序关系,插入行为很怪异
  • 你可能遇到了 NaN 导致集合行为异常:插入 NaN 后,std::set不能正确排序,导致它把所有值都认为相等或者没有插入成功

你的程序为什么只输出1?

大概率是:

  • std::set插入了第一个元素后(可能是NaN),后续插入都失败了,因为NaN比较关系不符合严格弱排序(std::set的要求)
  • 导致最终集合大小是1

如何验证?

打印所有插入的值:

#include <vector>
#include <set>
#include <iostream>
#include <cassert>
#include <cmath>
void InterestingSetDouble() {
    std::vector<double> v{0.0, 0.01, 0.2, 3.0};
    std::set<double> s{};
    for (auto x : v) {
        for (auto y : v) {
            double val = x / y;
            std::cout << "Insert: " << val;
            if (std::isnan(val)) std::cout << " (NaN)";
            if (std::isinf(val)) std::cout << " (inf)";
            std::cout << std::endl;
            s.insert(val);
        }
    }
    std::cout << "Set size: " << s.size() << std::endl;
}
int main() {
    InterestingSetDouble();
    return 0;
}

输出结果:

Insert: -nan (NaN)
Insert: 0
Insert: 0
Insert: 0
Insert: inf (inf)
Insert: 1
Insert: 0.05
Insert: 0.00333333
Insert: inf (inf)
Insert: 20
Insert: 1
Insert: 0.0666667
Insert: inf (inf)
Insert: 300
Insert: 15
Insert: 1
Set size: 1

你会看到 NaNinf 出现的情况,证明NaN破坏了集合的正确排序。

解决方案

  • 不要把NaN作为std::set的元素,它会破坏容器的排序
  • 过滤掉除零情况,或者用一个更健壮的数据结构/比较函数处理浮点特殊值
  • 你可以自己写一个比较器,排除NaN或把NaN当作最大值处理

总结

  • 浮点数中的特殊值NaN会导致std::set<double>行为不符合预期,插入失败或者重复被忽略。
  • 这就体现了浮点类型在容器使用时“非理智”的地方。
  • 你需要特别处理这些特殊值,避免破坏容器的排序和元素唯一性保证。

C++中标准库容器作为“正规”类型(Regular Types)时的安全性和理智性,以及它们底层使用的内建类型(primitive types)导致的一些经典坑

核心点总结:

  1. 容器本身通常是“正规”类型(Regular value types),前提是它们的元素类型和模板参数满足“正规类型”的要求(可复制、可赋值、可比较、可移动等)。
    • 这意味着容器通常有“安全”的复制、赋值和比较语义。
  2. 但容器依赖的内建类型存在各种“怪癖”和陷阱
    • 整型提升(Integral promotion)
      • 遗留自C语言的一套复杂规则,包含boolchar也作为整型处理
      • 混合有符号和无符号整数参与算术时容易产生隐式转换和潜在bug
      • 许多编译器警告被强制转换掩盖掉,导致潜在错误
      • 整数溢出行为不同,可能是环绕(wrap around)未定义行为,或者硬件的**进位位(carry bit)**信号
    • 自动数值转换(Automatic numeric conversions)
      • 整数、浮点数和布尔之间自动转换复杂且容易出错
      • 特别是如果自定义类型有隐式构造函数或隐式转换操作,极易引起歧义和意外转换
      • 建议不要让类类型有隐式转换,应尽量使用explicit防止自动类型转换
    • 浮点数的特殊值问题
      • 浮点数存在+∞-∞NaN,往往被忽略
      • 比较浮点数时要小心,必须保证比较满足严格弱序(strict weak ordering)或更强的要求,否则容器等会出错

代码中printBackwards函数的bug

void printBackwards(std::ostream &out, std::vector<int> const &v) {
    for (auto i = v.size() - 1; i >= 0; --i)
        out << v[i] << " ";
}
  • v.size()size_t类型,无符号整数
  • v为空时,v.size() - 1实际上是一个非常大的无符号数(因为size_t会绕回最大值),导致循环条件永远成立,进而越界访问v,产生未定义行为。
  • 这是整型提升与无符号数陷阱的典型示例
    正确写法:
void printBackwards(std::ostream &out, std::vector<int> const &v) {
    for (auto i = static_cast<int>(v.size()) - 1; i >= 0; --i)
        out << v[i] << " ";
}

或者:

void printBackwards(std::ostream &out, std::vector<int> const &v) {
    for (size_t i = v.size(); i-- > 0; )
        out << v[i] << " ";
}

总结

  • 标准库容器本身在元素满足正规类型要求时是“安全”和“理智”的类型。
  • 但它们依赖的内建类型及其复杂的规则,比如无符号整数的溢出、整型提升、自动转换、浮点特殊值等,容易引入难发现的bug。
  • 应避免隐式类型转换,显式处理特殊值,仔细管理整数和浮点比较。

C++中滥用内建类型(primitive types) 所带来的深层问题,特别是在标准库和用户代码中,以下是详细解释和理解:

问题总结:内建类型的问题不仅仅是“语法上允许”,更是语义上的混乱与脆弱性

1. 内建类型没有表达语义:

例如函数签名:

void fluximate(int, int, int);

你很难从调用 fluximate(3, 2, 1);fluximate(1, 2, 3); 中推断每个 int 的含义(比如是不是某个时间、索引或距离等)。

理解: C++提供了零开销的强类型封装方法,例如使用 struct, class, enum class 来构建语义明确的类型。

示例:用类型包装原始值
struct Row { int value; };
struct Column { int value; };
struct Count { int value; };
void fluximate(Row r, Column c, Count n);
fluximate(Row{3}, Column{2}, Count{1});  // 可读性极大提升

2. “Named Parameters” 不是解决方案

有些语言(如 Python)通过命名参数调用解决这个问题:

fluximate(row=3, col=2, count=1)

但在 C++ 中,这并没有从根本上解决“类型安全”和“可维护性”的问题。封装成有语义的类型,才是更具 C++ 风格且零运行时成本的正确做法。

3. 标准库“错用”了内建类型当作语义类型

示例:
  • size_tsize_type: 实际语义是“元素个数”,应是自然数(包含0),即绝对值
  • ptrdiff_tdifference_type: 表示两个指针/迭代器之间的相对距离,可能为负
问题发生:
size_type __n = std::distance(__first, __last);  // std::distance 返回的是 difference_type(有符号),却赋值给了无符号 size_type!
if (capacity() - size() >= __n) {  // 混合无符号和有符号类型进行比较,警告出现
    std::copy_backward(__position, end(), _M_finish + 10 * difference_type(__n));  // 要强制转回 difference_type
    std::copy(__first, __last, __position);
    _M_finish += difference_type(__n);  // 又一次强转
}

总结这个痛点:

  • 使用了错误的类型表示语义不同的值
  • 导致频繁的强制类型转换 static_cast<>
  • 编译器警告变得难以判断是否合理
  • 若处理不当可能隐藏逻辑 bug

建议与最佳实践:

场景 建议做法
表达明确语义的值 struct X { T value; }; 强类型封装
size vs. difference 区分 size_typedifference_type,避免隐式转换
函数参数中多个相同类型 避免 int,int,int 这样的签名,用结构体替代
标准库与用户类型互操作 使用 explicit 构造函数防止隐式类型转换
防止 unsigned/signed 比较警告 明确类型转换,避免混用 size_tint

示例:更好的函数签名与类型

struct Index { int value; };
struct Count { size_t value; };
void resize_buffer(Index start, Count size);

相比:

void resize_buffer(int, size_t);  // 哪个是起点?哪个是大小?

C++类型系统中“值的安全性与语义”的层次进行分类和批判,特别是针对物理量(dimensions)和语义清晰的类型设计。下面是对这部分内容的理解与扩展解释:

主题:Dimensions Safety and Sanity(单位与语义的安全性)

这段话想表达的是:

intdoubleunsignedstd::string 这样的原始类型来代表有单位/语义的值(如距离、温度、速度、金额等)是危险的,应当使用更强语义的**值类型(Value Types)**进行封装。

分类解释图(文字版)

类别 说明
Dangerous 使用原始类型表达有单位含义的值,比如:int speed = 100;。完全无语义。
High-discipline 理论上能用,但需要极高的人工约束来保证安全:程序员必须靠脑子记住哪些变量代表什么
Ill-advised 使用 unsigned 表示数量,可能出错(比如循环倒着走就崩了)
Sane 封装为带语义的“值类型”,如:struct Speed { int kmph; };
Safe 理想做法。类型系统本身就能表达“这是什么东西”,无需靠注释或命名去区分

举例:危险用法

void travel(int distance, int duration);  // 哪个是距离?哪个是时间?单位是什么?
travel(100, 60);  // 100 米?公里?秒?分钟?全靠猜

理想的语义类型(Whole Value Pattern)

struct DistanceInKm { double value; };
struct TimeInMin { double value; };
void travel(DistanceInKm d, TimeInMin t);
travel(DistanceInKm{100.0}, TimeInMin{60.0});
  • 编译器能帮你防止错误调用(你不能把 Time 当作 Distance 用)
  • 更容易调试、阅读、重构
  • 如果你加单位检查(比如使用 units::km 这样的库)还能做物理量推导

与现实世界的类比

你不会拿“5”这个数字去倒咖啡,你得知道它是“5 杯”、“5 秒”还是“5 厘米”。

在代码中,没有类型语义的数值就是潜在 bug 的温床。

工具与实践

  • C++20 strong typedef(比如 using Distance = StrongType<double, struct DistanceTag>;
  • Boost.Units / mp-units(C++23 草案):让编译器能检测物理量错误
  • struct 封装是最朴素、最通用的做法
  • 禁止 unsigned 表示索引/数量,尽量用 int 或封装过的类型

总结一句话:

**用类型表达程序的意图。**原始类型表达不了你的业务含义时,就别继续用它。

这段内容阐述的是 Whole Value Pattern(完整值模式),它出自 Ward Cunningham 的 CHECKS 模式语言,是面向对象设计中的一种 增强类型安全与表达力的建模思想。我们来逐点理解:

核心理念:Whole Value Pattern 是什么?

**Whole Value Pattern(完整值模式)**的主张是:

“不要用 intdoublestring 这样的原始类型来表达业务中具有语义的数量、参数或单位,而是使用专门的值类型(value types)来封装它们的全部语义。”

不良示例:原始类型滥用

void purchase(int itemCode, int quantity, double price);

问题:

  • 参数毫无语义,含糊不清
  • 容易参数顺序写错、单位错误
  • 不利于阅读、维护、测试

改进方案:Whole Value 模式

struct ItemCode {
    std::string code;
};
struct Quantity {
    int count;
};
struct Price {
    double amount;
};
void purchase(ItemCode code, Quantity qty, Price price);

优势:

  • 明确表达业务语义(可读性高)
  • 更安全,编译器能检查类型是否匹配
  • 更容易扩展(比如以后加税率、折扣等)

关键思想详解

1. “最底层的单位”(如 intstringdouble)是 不安全的

这些基本类型可以表示任何东西,所以本身并不表达任何具体含义

这是 C 风格编程的遗毒:当年为了性能妥协,没有抽象手段。现在有更强的抽象(结构体、类型别名、模板、concepts),我们应该用它。

2. 用 专门的值类型 进行建模

“Construct specialized values to quantify your domain model…”

这些值类型(value objects)应该:

  • 捕捉值的全部语义(不仅仅是数字,还包括单位、有效范围、含义)
  • 保持通用性(不与特定业务绑定)
  • 提供构造函数、格式转换、I/O 接口等
struct WeightKg {
    double value;
    explicit WeightKg(double v) : value(v) {
        assert(v >= 0); // 不允许负质量
    }
};

3. UI 层负责字符串/数值 → 值对象的转换

“Include format converters…”

业务逻辑应当只接收类型安全、结构良好的对象。格式转换应在 输入/输出边界完成,例如:

WeightKg parseWeightFromUserInput(std::string input);
std::string formatWeight(WeightKg w);

4. 禁止业务逻辑处理“裸字符串”或“裸数字”

“Do not expect your domain model to handle string or numeric representations…”

这也是 SRP(单一职责原则)的一部分:解析与验证输入应与业务逻辑分离

总结一句话:

不要让你的程序处理半个值。封装整个含义、限制、格式为值类型,用它来传递信息。

你这段内容是对 Whole Value Pattern(完整值模式) 的最简化实现和实际应用的展示,非常重要、也非常实用。

最简版 Whole Value Pattern

struct Wait {
    size_t count{};
};
void check_counters(Wait w, Notify n);

这就是 Whole Value Pattern 的“最小可行实现(Minimal Viable Product)”:

  • 不直接用 size_tint 参数
  • 封装成带有含义的结构体 WaitNotify
  • 明确了业务语义:“等待次数”、“通知次数”,让调用更清晰、更安全

示例:

check_counters(Wait{0}, Notify{2});

相比:

check_counters(0, 2); // ← 这两个数字代表啥?看不出来

你一眼能看出每个参数的含义,无需查函数声明。这就是 Whole Value 的意义所在

可扩展性示例:重载操作符

void operator++(Wait &w) {
    w.count++;
}

Wait 添加 ++ 操作符,就可以自然地使用:

Wait w{1};
++w;

这种方式将行为内聚到值对象中,减少了错误操作的风险,也使得代码更可读。

聚合初始化(Aggregate Initialization)

Wait w{3};
Notify n{2};

C++ 的聚合初始化机制让这种封装既安全,又不会带来运行时成本。
这是一种零运行时开销的类型安全增强手段

总结:为什么这叫 “最简单的 Whole Value Pattern”

  • 使用 struct 封装原始类型(如 int / size_t
  • 添加行为(如重载 ++)以避免裸值操作
  • 通过聚合初始化保持调用简洁性
  • 提高可读性、安全性、扩展性
    如果你要写业务逻辑中带有含义的数值(如时间、计数、百分比、距离等),都建议用这种方式:
struct DistanceMeters { int value; };
struct TimeSeconds { int value; };
void move_robot(DistanceMeters d, TimeSeconds t);

而不是:

void move_robot(int d, int t); // 什么是距离?什么是时间?危险

你的这段内容讨论的是 是否应该让一个值类型(whole value type)默认构造(default-constructible),也就是是否该写:

T() = default;

以下是对这段内容的逐句解析和理解:

应该默认构造的情况

“Yes, whenever there is a natural default or neutral value in your type’s domain”

也就是说:当这个类型在业务逻辑上有一个合理的“默认值”,你就应该允许它被默认构造。

例子:

int{}      // == 0
std::string{}  // == ""
std::vector<T>{} // 空容器

这些默认值在加法、拼接、扩展等语义下是自然的 “单位元”,所以它们是有意义的默认状态。

谨慎默认构造的情况

“Be aware that the neutral value can depend on the major operation: int{} is not good for multiplication”

比如:

  • 对于乘法来说,int{} 默认值是 0,但乘法的单位元应是 1。
  • 如果你的业务依赖“乘法”,默认构造可能会产生意外行为。

可以考虑默认构造的情况

“May be, when initialization can be conditional and you need to define a variable first”

例如:

MyType x;
if (condition) x = computeA();
else           x = computeB();

在这种情况下,你可能被迫需要默认构造一个变量以便之后赋值。
更好的做法:

  • 使用 ?: 运算符
  • 或者立即调用 lambda:
auto x = [&]() {
  return condition ? computeA() : computeB();
}();

如果你用这些技巧,就不需要默认构造了!

不应该默认构造的情况 #1:没有自然默认值

“No, when there is no natural default value”

比如:

struct PokerCard {
    Suit suit;
    Rank rank;
};

扑克牌没有“空牌”或者“默认牌”。允许默认构造意味着可能出现非法对象状态。
更好的做法是 只允许有意义的构造方式

PokerCard(Suit s, Rank r); // 不提供默认构造函数

不应该默认构造的情况 #2:不满足不变式(invariant)

“No, when the type’s invariant requires a reasonable initialization”

比如:

class CryptographicKey {
public:
    CryptographicKey(std::vector<std::byte> keydata);
    //  不要写 CryptographicKey()=default;
};

一个密码密钥类必须一开始就包含有效的密钥,否则它就是无法安全使用的错误状态

总结:何时应该写 T() = default

情况 是否写默认构造
有自然默认值(如 0, "", 空容器) Yes
业务逻辑上没法定义默认值(如扑克牌、加密密钥) No
必须提前定义变量再赋值,无法用其他方式解决 Maybe(慎用)

深入探讨了 单位安全(unit safety)强类型(strong typing)维度正确性(dimensional correctness) 的问题,尤其聚焦于 C++ 中的库设计、抽象与类型系统。以下是详细的解析和理解:

问题:relative vs. absolute 混用导致的类型不安全

size_type __n = std::distance(__first, __last); // __n 是相对的(difference),但被赋值给 unsigned 类型(size_type)

❶ 错误的类型使用(相对 vs 绝对):

  • std::distance 返回的是 相对值difference_type,可能为负)
  • size_type绝对值,无符号的 → 如果距离是负的就会出错(变成一个巨大的 unsigned 值)

❷ 类型强制转换掩盖了问题:

difference_type(__n)

这里人为地强转回来,但很危险:这种“来回强转”的代码可能隐藏真正的逻辑错误。

正确的做法:区分相对值 vs 绝对值

类似 <chrono> 中的 duration(时间间隔) vs time_point(时间点)

类比:

  • tp1 - tp2 = duration (两个时间点的差值)
  • tp1 + tp2 (两个时间点加起来毫无意义)
  • tp + duration = tp (在时间点上加时间间隔)
    使用这种明确区分单位语义的设计风格,可以大大降低代码出错概率。

示例:位置 vs 位移

Vec3d 既可以表示“位置”,又可以表示“方向”或“位移”,这在物理上是不同单位!

例子:

Vec3d position1{1,2,3};
Vec3d direction{0,0,1};
auto result = position1 + direction; // 可行,但我们需要知道方向 != 坐标

使用两个强类型 Position3DVector3D 可以避免混淆。

使用“强类型”(Strong Typing)来增强类型安全

你提到了一些演讲者和方法:

参考资源:

  • Björn Fahller(ACCU 2018)
  • Jonathan Boccara
  • Jonathan Müller
  • Peter Sommerlad 自己提出的:PSST(Peter’s Simple Strong Typing)

PSST 示例解析:CRTP + Aggregate 实现无开销强类型

struct WaitC : strong<unsigned, WaitC>, ops<WaitC, Eq, Inc, Out> {};
  • WaitCunsigned 的强类型封装
  • 继承了 Eq, Inc, Out 操作(通过 CRTP 混入)
  • 由于 Empty Base Optimization(EBO),没有额外内存开销

断言测试:

WaitC c{};
WaitC const one{1};
ASSERT_EQUAL(WaitC{0}, c);     // 默认初始化为 0
ASSERT_EQUAL(one, ++c);        // 支持前置++
ASSERT_EQUAL(one, c++);        // 支持后置++
ASSERT_EQUAL(2, c.get());      // c 值为 2

非常清晰地定义了使用方式,也让类型在语义上更明确。

总结:为什么需要强类型(Strong Types)

问题 原因
内置类型(int, unsigned, double)容易误用 没有语义约束
相对值与绝对值的混用 导致隐式转换错误
浮点特殊值(NaN, Inf) 破坏等价性与排序
可读性差 fluximate(1,2,3) 是啥?没人知道

最佳实践建议

  • 避免直接使用 int, double, size_t 等内置类型表达含义复杂的值
  • 使用 struct X { T value; }; 包装 —— 构造强类型
  • 利用 CRTP + mixins 生成可组合的操作符而非手写
  • 保留语义清晰的操作,如时间点 + 间隔 = 时间点
  • 避免隐式转换,拒绝使用 .operator T() 除非必要

C++ 中一个非常有趣且强大的语言特性:空类(Empty Class) 及其背后的 EBO(Empty Base Optimization),它确实可以在“你什么都没写”的情况下带来额外收益,下面是逐条解释你贴的内容:

Empty Classes - useful?

“In C++ Empty Class you get something for nothing!”

是的,在 C++ 中,一个不包含任何非静态成员的类称为 空类(Empty Class),例如:

struct Empty {};

你可能以为它什么也没做,也不占用空间。但由于 C++ 要求不同对象的地址不能相同,即使类是空的,仍然 默认占 1 字节空间(除非作为基类时,见下文)。

EBO(Empty Base Optimization)

EBO 指的是 编译器对空基类进行优化,不为其分配空间
例如:

struct Empty {};
struct Derived : Empty {
    int x;
};

这时 sizeof(Derived) 可能是 4(只占 int x 的空间),而不是 5,因为 Empty 作为基类 不占空间。这是 “something for nothing” 的意思。
空类常用于:

  • 类型标签(Tag Dispatching)
  • Traits 模板元编程
  • CRTP(Curiously Recurring Template Pattern)中的 mixin 基类

Tags & Traits

struct InputIteratorTag {};
struct OutputIteratorTag {};

空类用于表示类型信息或行为标签,不需要任何成员,就能在模板中通过特化处理不同逻辑。

危险 VS 安全类型

图中的意思是,将不同的类型分为以下几类:

类型类别 风险程度 说明
int, double, char 危险 无语义信息,容易误用或混用
OO 多态类(虚函数类) 高要求 管理成本高,特别是组合、复制行为复杂
CRTP Mixins / Value Types 推荐 有静态类型信息,不影响大小,语义清晰
Empty Classes 推荐 尤其配合 CRTP,用于无状态的行为扩展
Pointer Types 危险 裸指针需要手动管理生命周期
强类型封装 (Strong Types) 推荐 提供语义安全,防止混用,如 UserId, PixelCoord

小结

  • 空类并不无用,它们可以用作标签、类型标识、mixin 行为注入等元编程场景
  • EBO 是一个重要优化手段,使你可以使用类型安全 + 零成本抽象
  • 在构建更健壮、类型安全的 C++ 代码时,空类 + CRTP + 强类型封装 是非常推荐的工具组合。

提供的内容涵盖了 C++ 中 Tag Types(标签类型) 的使用方式,主要体现在以下几个方面:

什么是 Tag Types?

Tag Types 是一种 空类类型,其唯一目的是提供 类型信息,常用于:

  • 模板函数的 重载选择(Tag Dispatch)
  • 区分语义相似但操作方式不同 的调用
  • 提高代码的可读性与安全性

常见 Tag 类型用法:

1. Iterator Tags(标准库迭代器标签)

用于表示不同迭代器的种类:

std::input_iterator_tag
std::output_iterator_tag
std::forward_iterator_tag
std::bidirectional_iterator_tag
std::random_access_iterator_tag

它们用于 std::iterator_traits<Iter>::iterator_category,可以在算法中实现 不同迭代器种类的特化重载

示例:
template <class BDIter>
void alg(BDIter, BDIter, std::bidirectional_iterator_tag) {
    std::cout << "called for bidirectional iterator\n";
}
template <class RAIter>
void alg(RAIter, RAIter, std::random_access_iterator_tag) {
    std::cout << "called for random-access iterator\n";
}
template <class Iter>
void alg(Iter first, Iter last) {
    // 自动推导出 iterator_category 作为 tag type
    alg(first, last, typename std::iterator_traits<Iter>::iterator_category());
}
使用:
std::vector<int> v;
alg(v.begin(), v.end());  // random-access
std::list<int> l;
alg(l.begin(), l.end());  // bidirectional

2. std::in_place_tstd::in_place

这是一个标签类型 + 常量,用于 控制构造行为,避免默认构造或临时对象:

template <class... Args>
constexpr explicit optional(std::in_place_t, Args&&... args);

使用示例:

std::optional<std::string> o5(std::in_place, 3, 'A');  // 构造 "AAA"

等效于:

std::optional<std::string> o5(std::string(3, 'A'));

但用 in_place原地构造对象,避免临时值、复制和移动,提高效率。

3. nullptr_tnullptr

类似 in_place_t 的还有内建类型:

  • std::nullptr_tnullptr 的类型
  • 用于函数重载决策和模板特化
    例如:
void f(int*);
void f(std::nullptr_t);  // 匹配 nullptr 而不是任何 int*

总结

类型 用途说明
iterator_tag 区分迭代器种类,实现特化版本
in_place_t 用于 optional, variant, any 原地构造
nullptr_t 用于重载中与指针类型区分
自定义 tag 类型 通常为标记特定语义(如策略模式、行为注入等)

推荐用法与理解

  • Tag Types 通常是 空 struct,只看类型不看内容
  • 重载决策更清晰,替代 if constexpr 也很常见
  • 和 CRTP 一样,它是一种典型的 编译期策略注入

这段内容讲的是 C++ 标准库中用于 模板元编程(compile-time metaprogramming) 的核心工具之一 —— std::integral_constant,以及它的几个衍生类型(如 true_type, false_type, ratio, integer_sequence 等),并介绍它们在 C++ 类型系统中的作用。

核心思想:用类型表示值(Values as Types)

在编译期,C++ 不能用运行时变量进行判断或选择,但可以用类型来携带常量值,从而实现:

  • 类型选择
  • 模板重载(SFINAE)
  • 编译期计算

std::integral_constant<T, v>:值 → 类型

template<class T, T v>
struct integral_constant {
    using value_type = T;
    static constexpr T value = v;
    using type = integral_constant;
    constexpr operator T() const noexcept { return value; }
    constexpr T operator()() const noexcept { return value; }
};

它是一个模板类型,但包含一个编译期常量值 value

示例:
using true_type = integral_constant<bool, true>;
using five = integral_constant<int, 5>;
static_assert(true_type::value, "is true");
static_assert(five::value == 5, "is 5");
true_type b{};
if (b) { std::cout << "true\n"; }
five f{};
int x = f();  // 等价于 x = 5;

你可以像使用普通值一样使用它的对象(可隐式转换、调用等),但它本质上是类型。

true_type / false_type

using true_type = integral_constant<bool, true>;
using false_type = integral_constant<bool, false>;

它们是标准库中用于布尔型模板元编程判断的基础类型。

用途 1:SFINAE / 模板重载

template<typename T>
std::enable_if_t<std::is_integral_v<T>, void> f(T) {
    std::cout << "integral\n";
}
template<typename T>
std::enable_if_t<std::is_floating_point_v<T>, void> f(T) {
    std::cout << "floating point\n";
}

这背后正是靠 integral_constant 派生出来的 std::true_type / std::false_type 判断条件。

用途 2:单位换算与比例 std::ratio

using half = std::ratio<1, 2>;
using milli = std::ratio<1, 1000>;

这些类型在 <chrono>duration 中大量使用:

std::chrono::duration<int, std::milli> ms(1);  // 1 毫秒

用途 3:元组和参数包展开 std::integer_sequence

template<typename T, T... Ints>
struct integer_sequence { /* ... */ };
std::make_index_sequence<3> → integer_sequence<size_t, 0, 1, 2>

用于 std::tuplestd::array 等模板元编程中展开参数包访问元素。

所有静态断言解释:

static_assert(integral_constant<bool,true>::value, "");   // 成员访问
static_assert(true_type::value, "member access");         // 同上
static_assert(true_type{}, "auto-conversion");            // 转换为 bool
static_assert(true_type{}(), "call operator");            // 调用操作符
static_assert(std::is_same_v<true_type, true_type::type>,
              "type meta");  // type 别名为自身

这些展示了 integral_constant 的“值-类型融合”能力。

总结

工具类型 说明
integral_constant<T, v> 编译期值封装为类型
true_type / false_type 编译期布尔值判断基础
std::ratio<Num, Den> 编译期比例值,用于 chrono::duration
std::integer_sequence<T, ...> 参数包处理,展开索引
这些类型都是 模板元编程的核心基石,也是泛型库如 std::chronostd::tuplestd::optionalstd::variant 等背后的重要基础。
如果你想我展示一个实际使用 std::integral_constant 写的 SFINAE 筛选或重载示例,也可以告诉我!
#include <iostream>
int main() {
    using true_type = std::integral_constant<bool, true>;
    using five = std::integral_constant<int, 5>;
    static_assert(true_type::value, "is true");
    static_assert(five::value == 5, "is 5");
    true_type b{};
    if (b) {
        std::cout << "true\n";
    }
    five f{};
    int x = f();                                                   // 等价于 x = 5;
    static_assert(std::integral_constant<bool, true>::value, "");  // 成员访问
    static_assert(true_type::value, "member access");              // 同上
    static_assert(true_type{}, "auto-conversion");                 // 转换为 bool
    static_assert(true_type{}(), "call operator");                 // 调用操作符
    static_assert(std::is_same_v<true_type, true_type::type>,
                  "type meta");  // type 别名为自身
}

std::integral_constant 是 C++ 标准库中的一个模板类,主要作用是在编译期将一个值包装成一个类型,让这个值可以作为类型信息被使用。

为什么要这样做?

C++模板元编程中,编译期需要根据某些值做不同处理,但是模板参数只能是类型或者编译期常量。integral_constant 把一个编译期的常量“值”包装成了一个“类型”,这样可以用类型系统来进行选择和分支。

它长这样:

template<class T, T v>
struct integral_constant {
    static constexpr T value = v;       // 常量值
    using value_type = T;               // 值的类型
    using type = integral_constant;    // 自身类型别名
    constexpr operator T() const noexcept { return value; }  // 转换成值
    constexpr T operator()() const noexcept { return value; } // 函数调用也返回值
};

用法示例

using true_type = std::integral_constant<bool, true>;
using false_type = std::integral_constant<bool, false>;
static_assert(true_type::value == true, "");   // 访问常量值
static_assert(false_type{} == false, "");      // 通过转换操作符获得值

常见用途

  • 编译期布尔值判断(true_typefalse_type
  • SFINAE 以及模板特化时的条件分支
  • 标记类型(Tag Dispatch)
  • 作为 std::ratio(比例)和 std::integer_sequence(整数序列)等模板元编程工具的基础

简单总结

  • 把编译期的常量值封装成一个类型
  • 方便编译期“值”的传递和判断
  • 是模板元编程的基础工具

这里是对**Empty Base Optimization (EBO)**的总结和补充说明:

1. 空类的大小至少为1

struct empty{};
static_assert(sizeof(empty) > 0, "there must be something");
  • C++ 标准要求每个不同的对象必须有唯一地址,所以空类的实例大小至少是1字节。

2. 非空类大小为成员大小之和(可能带对齐)

struct plain {
    int x;
};
static_assert(sizeof(plain) == sizeof(int), "no additional overhead");
  • 一个只有一个int成员的类,其大小就是int的大小。

3. 空类作为基类时,编译器可以做优化(EBO)

struct combined : plain, empty {};
static_assert(sizeof(combined) == sizeof(plain), "empty base class should not add size");
  • 当空类作为基类时,编译器可以将其“压缩”,不分配额外空间(除非会导致成员布局冲突)。
  • 这样,空类不会增加派生类的大小

4. EBO的实际用途

  • 标准库如std::unique_ptr利用EBO将空的删除器类型(default_delete)不占空间,避免存储额外的指针或大小。
  • CRTP(Curiously Recurring Template Pattern)风格的Mix-in类用空类基类实现零开销扩展。

5. C++20新特性:[[no_unique_address]]属性

  • 允许非空成员也能进行类似EBO的优化,告诉编译器:这个成员的地址可以和其它成员重叠,以节省空间。
struct S {
    int x;
    [[no_unique_address]] empty e;
};
static_assert(sizeof(S) == sizeof(int)); // 这里也可以不增加额外大小

总结

  • 空类实例自身大小最少1字节以保证唯一地址。
  • 作为基类时可利用EBO消除额外空间。
  • EBO是实现零开销抽象(比如空删除器、策略类等)的关键技术。
  • C++20 [[no_unique_address]]扩展了这个优化的适用范围。
struct empty {};
struct plain {
    int x;
};
struct combined : plain, empty {};
struct S {
    int x;
    [[no_unique_address]] empty e; // [[no_unique_address]] GCC MSVC 好像有区别
};
int main() {
    static_assert(sizeof(empty) > 0, "there must be something");
    static_assert(sizeof(plain) == sizeof(int), "no additional overhead");
    static_assert(sizeof(combined) == sizeof(plain), "empty base class should not add size");
    static_assert(sizeof(S) == sizeof(int));  // 这里也可以不增加额外大小
}

总结一下你的代码和说明中涉及的EBO限制和原则:

代码回顾:

struct empty{};
static_assert(sizeof(empty) > 0 && sizeof(empty) < sizeof(int),
              "there should be something");
struct ebo : empty {
    empty e;  // 成员是 same type (empty)
    int i;    // 对齐到 int
};
static_assert(sizeof(ebo) == 2 * sizeof(int),
              "ebo must not work");
struct noebo : empty {
    ebo e;    // 成员是不同类型 (ebo)
    int i;
};
static_assert(sizeof(noebo) == 4 * sizeof(int),
              "subobjects must have unique addresses");

EBO 不生效的情况:

  1. 基类和成员有相同类型时,EBO不生效
  • ebo里既继承了empty,又含有一个empty成员变量。
  • 编译器不能让基类子对象和成员变量共享同一内存地址,因为每个对象必须有唯一地址
  • 所以ebo大小变大了(没有被压缩)。
  1. 多个子对象类型相同时,也不能共享地址
  • 规则:同类型的多个子对象必须有唯一地址
  • 因此如果你有多个empty类型的子对象(无论是基类还是成员),编译器无法合并它们。
  1. 保证EBO生效的技巧
  • 让空类只作为基类,且只出现一次(避免同类型的多个子对象)。
  • 可以用CRTP模式(模板派生自自身类型),保证每个基类类型都不同,从而避免同类型重复。
  • 也可以保证空类是最前面的基类,避免与成员变量布局冲突。

总结:

情况 是否生效
空类单独作为基类 生效,基类对象大小不会计入
空类作为成员变量 不生效,占用至少1字节
空类作为基类且成员中也有相同类型 不生效,必须唯一地址
多个相同类型基类(如多继承) 不生效,唯一地址限制
使用CRTP产生不同类型空基类 生效,避免同类型冲突

这也解释了为什么标准库中很多空类用作基类并使用CRTP,以最大化利用EBO节省空间。

[build] class empty size(1):
[build] ±–
[build] ±–
[build]
[build] class ebo size(8):
[build] ±–
[build] 0 | ±-- (base class empty)
[build] | ±–
[build] 0 | empty e
[build] | (size=3)
[build] 4 | i
[build] ±–
[build]
[build] class noebo size(12):
[build] ±–
[build] 0 | ±-- (base class empty)
[build] | ±–
[build] 0 | ebo e
[build] 8 | i
[build] ±–

这段代码展示了如何结合CRTP(Curiously Recurring Template Pattern)EBO(Empty Base Optimization)来定义一个“强类型”(strong type),并为它添加一组操作符扩展(如比较、递增和输出),且不增加额外的内存开销

代码关键点解析

1. strong<V, TAG> — 强类型包装

template <typename V, typename TAG> 
struct strong {  
  using value_type = V; 
  V val;  // 实际存储值
};
  • V 包装原始类型。
  • TAG 做区分,防止不同语义的数值被混用。
  • 这是“Whole Value Pattern”中推荐的方式。

2. CRTP 扩展操作符

template <typename U> 
struct Eq {
  friend constexpr bool operator==(U const& l, U const& r) noexcept {
    auto const& [vl] = l;
    auto const& [vr] = r;
    return vl == vr;
  }
  friend constexpr bool operator!=(U const& l, U const& r) noexcept {
    return !(l == r);
  }
};
template <typename U> 
struct Inc {
  friend constexpr auto operator++(U& rv) noexcept {
    auto& [val] = rv;
    ++val;
    return rv;
  }
  friend constexpr auto operator++(U& rv, int) noexcept {
    auto res = rv;
    ++rv;
    return res;
  }
};
template <typename U> 
struct Out {
  friend std::ostream& operator<<(std::ostream& os, U const& r) {
    auto const& [v] = r;
    return os << v;
  }
};
  • 这里用结构化绑定解构 strong 类型,访问其内部成员 val
  • friend 声明运算符重载,依赖模板参数 U,保证这些操作符只为特定类型实例化。
  • 包括:==/!=,前后缀递增,输出流操作。

3. 操作符组合混入模板

template <typename U, template <typename...> class... BS> 
struct ops : BS<U>... {};
  • 多继承多个操作符扩展,混入(mixin)机制。
  • 例如:ops<WaitC, Eq, Inc, Out> 即继承了比较、递增、输出操作。

4. 定义强类型示例 WaitC

struct WaitC : strong<unsigned, WaitC>, ops<WaitC, Eq, Inc, Out> {};
static_assert(sizeof(unsigned) == sizeof(WaitC));
  • WaitC 继承自包装了 unsignedstrong,同时混入操作符扩展。
  • static_assert 验证EBO生效,WaitCunsigned 大小一致,没有额外开销。

5. 测试用例

void testWaitCounter() {
  WaitC c{};
  WaitC const one{1};
  ASSERT_EQUAL(WaitC{0}, c);
  ASSERT_EQUAL(one, ++c);
  ASSERT_EQUAL(one, c++);
  ASSERT_EQUAL(2, c.val);
}
  • 验证默认构造为 0。
  • 测试前缀和后缀递增操作符。
  • 检查内部值 val 是否正确递增。

总结

  • 通过 CRTP + EBO,可以设计零开销的强类型,增强类型安全,避免原始类型混淆。
  • 这种写法也极易扩展,添加更多运算符和功能都很方便。
  • C++17 的结构化绑定,让访问成员更简洁。
  • static_assert 确保运行时内存开销符合预期。
#include <iostream>
template <typename U>
struct Eq {
    friend constexpr bool operator==(U const& l, U const& r) noexcept {
        auto const& [vl] = l;
        auto const& [vr] = r;
        return vl == vr;
    }
    friend constexpr bool operator!=(U const& l, U const& r) noexcept { return !(l == r); }
};
template <typename U>
struct Inc {
    friend constexpr auto operator++(U& rv) noexcept {
        auto& [val] = rv;
        ++val;
        return rv;
    }
    friend constexpr auto operator++(U& rv, int) noexcept {
        auto res = rv;
        ++rv;
        return res;
    }
};
template <typename U>
struct Out {
    friend std::ostream& operator<<(std::ostream& os, U const& r) {
        auto const& [v] = r;
        return os << v;
    }
};
template <typename V, typename TAG>
struct strong {
    using value_type = V;
    V val;  // 实际存储值
};
template <typename U, template <typename...> class... BS>
struct ops : BS<U>... {};
struct WaitC : strong<unsigned, WaitC>, ops<WaitC, Eq, Inc, Out> {};
static_assert(sizeof(unsigned) == sizeof(WaitC));
#define ASSERT_EQUAL(a, b)                                                            \
    do {                                                                              \
        if (!((a) == (b))) {                                                          \
            std::cerr << "ASSERT_EQUAL failed: " << #a << " != " << #b << " (" << (a) \
                      << " != " << (b) << ")\n";                                      \
            std::exit(1);                                                             \
        }                                                                             \
    } while (0)
void testWaitCounter() {
    WaitC c{};
    constexpr WaitC const one{1};
    ASSERT_EQUAL(WaitC{0}, c);
    ASSERT_EQUAL(one, ++c);
    ASSERT_EQUAL(one, c++);
    ASSERT_EQUAL(2, c.val);
}
int main() { testWaitCounter(); }

使用继承标准库容器(如 std::set)来构造适配器类(如 indexableSet)是可能的,但非常需要小心和自律。否则,可能会破坏类的设计原则(比如里氏替换原则),导致不可预料的行为或错误。

一步步理解

示例代码解释:

template<typename T, typename CMP=std::less<T>>  
class indexableSet : public std::set<T,CMP> {
    using SetType = std::set<T,CMP>; 
    using size_type = int;  // 为了支持负数索引
public:
    using std::set<T,CMP>::set;  // 继承构造函数
    T const& operator[](size_type index) const {
        return at(index);
    }
    T const& at(size_type index) const {
        if (index < 0) index += SetType::size();  // 支持负数:从末尾开始索引
        if (index < 0 || index >= SetType::size())  
            throw std::out_of_range{"indexableSet:"};
        return *std::next(this->begin(), index);
    }
    T const& front() const { return at(0); }
    T const& back() const { return at(-1); }
};

这是一个扩展版的 std::set,加了下标访问 [] 和负索引功能,像 Python 列表那样访问最后一个元素 [-1]

为什么需要自律(discipline)

不建议随便继承 STL 容器的原因:

  • STL 容器没有虚析构函数,如果你把 indexableSet 向上转为 std::set,再通过 delete 删除,会导致 未定义行为(UB)
  • 会破坏 Liskov Substitution Principle(LSP):即子类对象应该能够替代父类使用,而行为保持一致
    比如这样会出错:
void printSet(std::set<int> s) { ... }  // 切片发生!indexableSet 的特性丢失!
printSet(indexableSet<int>{1,2,3});

什么时候可以继承 STL?

仅当你“只扩展、不修改”功能,并你绝对不会将子类“当作”父类用(避免 slicing)

也就是说:

  • 只是在加新功能,比如 []front()back(),但不改动已有语义
  • 不会以 std::set<T> 的形式传参
  • 不会进行 slicing(值拷贝切掉子类部分)

小结:关键词对照

原文术语 中文解释
“Empty” Adapters 空适配器类,只有行为改变,没有数据增加
Liskov Substitution Principle 里氏替换原则:子类必须能无害地替代父类使用
inherits constructors C++11 支持继承构造函数,让适配更自然
slicing harmful 值传递切掉子类特性,极易出 bug
better wrap then 如果要改变语义,最好用组合而不是继承

总结建议:

可以继承 STL 容器用于适配器类,但:

  • 不该改变原本行为
  • 永远不要把它转为父类使用
  • 避免对象 slicing
  • 如果你要加强语义,推荐用组合(wrap)而不是继承
#include <set>        // std::set 用于自动排序的集合
#include <iostream>   // std::cout, std::endl
#include <stdexcept>  // std::out_of_range 异常
#include <iterator>   // std::next 用于迭代器偏移
// 定义一个支持下标访问(包括负数索引)的 std::set 子类
template <typename T, typename CMP = std::less<T>>
class indexableSet : public std::set<T, CMP> {
    using SetType = std::set<T, CMP>;
    using size_type = int;  // 使用 int 类型索引,支持负数下标
public:
    // 继承 std::set 的构造函数,允许直接初始化 indexableSet
    using std::set<T, CMP>::set;
    // 支持通过下标访问元素,例如 s[2],底层调用 at()
    T const& operator[](size_type index) const { return at(index); }
    // 安全访问函数,支持负数索引,如 at(-1) 表示倒数第一个元素
    T const& at(size_type index) const {
        if (index < 0)
            index += static_cast<size_type>(SetType::size());  // 负数索引处理:从末尾向前数
        if (index < 0 || index >= static_cast<size_type>(SetType::size()))
            throw std::out_of_range{"indexableSet: invalid index"};  // 越界检查
        return *std::next(this->begin(), index);                     // 获取迭代器位置并解引用
    }
    // 获取第一个元素,相当于 at(0)
    T const& front() const { return at(0); }
    // 获取最后一个元素,相当于 at(-1)
    T const& back() const { return at(-1); }
};
// 示例主函数
int main() {
    // 使用 initializer_list 初始化集合,重复元素会被自动去重并排序
    indexableSet<int> s{3, 1, 4, 1, 5, 9, 2};
    std::cout << "Set contents by index:\n";
    // 正向遍历集合中的元素,通过索引访问
    for (int i = 0; i < static_cast<int>(s.size()); ++i) {
        std::cout << "s[" << i << "] = " << s[i] << "\n";
    }
    // 访问倒数第一个元素
    std::cout << "s[-1] (last) = " << s[-1] << "\n";
    // 使用 front/back 接口访问首尾元素
    std::cout << "front() = " << s.front() << "\n";
    std::cout << "back() = " << s.back() << "\n";
    return 0;
}

你提到的内容是 C++ 中关于 “指向类型”(Pointing Types) 的重要概念,特别是在迭代器和智能指针中经常遇到的问题。下面是对这些内容的逐条解释和深入理解:

什么是“Pointing Types”?

“指向类型” 是指那些不拥有资源本身,而是“引用”或“指向”其他对象的类型。这类对象的行为依赖于它们所指向的其他对象的生命周期。

常见的 Pointing Types 包括:
  • T*(原始指针)
  • std::shared_ptr<T>, std::unique_ptr<T>(智能指针)
  • std::reference_wrapper<T>(引用包装器)
  • std::span<T>(不拥有对象的视图)
  • 迭代器(如 std::vector<int>::iterator

和 Value Type 的对比

特性 Value Type Pointing Type
拥有资源?
独立存在? 通常可独立使用 依赖被指向对象
生命周期易管理? 通常安全 需小心生命周期
拷贝/比较语义? 明确值语义 语义复杂(指向不同对象)

常见风险:悬空和无效访问

  1. Dangling References(悬空引用)
    引用或指针指向一个已经被销毁的对象:
    int* p;
    {
        int x = 42;
        p = &x;
    }  // x 生命周期结束,p 悬空
    
  2. Invalid/Null Pointers(空指针/无效指针)
    指针没有被正确初始化或显式设为 nullptr
  3. Invalidated Iterators(失效迭代器)
    修改容器后(插入/删除/resize),之前获取的迭代器失效。
  4. Past-the-end Iterators(越界迭代器)
    end() 是合法的,但不可解引用。解引用它是 UB:
    auto it = vec.end(); 
    *it;  //  未定义行为
    

关于 Iterators 的特殊说明

  • 迭代器是“值类型的接口 + 指针的语义”
    • ==, !=, ++, *, -> 等操作都支持。
    • 但它实际指向其他对象,因此不是严格意义上的“value type”。
  • 默认构造行为特殊
    • 有些迭代器有默认构造的“空值”表示(如 istream_iterator 的 EOF 状态)。
标准库中可能失效的操作:
std::vector<int> v = {1, 2, 3};
auto it = v.begin();
v.push_back(4);  // 可能 reallocate
*it;  //  it 可能已失效(UB)

如何安全使用这类类型?

  1. 不要缓存迭代器/指针,如果容器可能改变
  2. 使用智能指针管理动态内存,避免悬空
  3. 不要解引用 end(),也不要对“默认构造”迭代器解引用
  4. span/view 类型不可扩展,不应该存储在结构体中长期引用容器内部数据
  5. C++20 提供 [[no_dangling]] 属性(尚未广泛实现)来改善这类问题

总结

分类 示例类型 说明
Value Type int, std::string 拷贝独立、生命周期独立
Pointing T*, std::iterator 指向他物,生命周期受限,可能悬空
Safe Pointer std::unique_ptr<T> 自动释放资源,安全但非全能
View std::span<T> 不拥有对象,依赖外部数据

关于 “Dimensions Safety and Sanity”(维度安全与合理性)主题下的一个关键设计思想 —— 管理类(Monomorphic Object Types)RAII(资源获取即初始化) 以及如何以安全、清晰的方式管理资源、状态和对象生命周期。

以下是对你内容的系统性理解与拆解:

核心思想:管理类 ≠ 值类型(Not Value Types)

这些类在程序中扮演 资源管理者状态封装者 的角色,因此:

  • 它们有显著身份(Identity)
  • 它们不是 Regular Types(规则类型)
    不支持拷贝构造、赋值、比较等通用值语义

管理类的特征(Monomorphic Object Types)

特征 说明
禁止拷贝 / 移动 保证资源唯一、状态不共享
构造后生命周期稳定 通常由高层组件或工厂函数生成
通常是栈或堆上长生命周期对象 被传递时只传引用(或智能指针)
不使用虚函数 / 多态 避免运行时成本和 slicing 问题
包含复杂状态或资源 比如:IO、网络、容器、线程句柄等
用于:Manager / Builder / Context 管理多个子资源的生命周期

示例:ScreenItems 管理器类分析

struct ScreenItems {
    void add(widget w) {
        content.push_back(std::move(w));  // widget 是值语义(或封装指针)
    }
    void draw_all(screen &out) {
        for (auto &drawable : content) {
            drawable->draw(out);  // 多态行为 delegated to widget
        }
    }
private:
    ScreenItems& operator=(ScreenItems&&) noexcept = delete;  // 禁止 move
    widgets content{};  // 内部资源管理
};

设计亮点

  • 禁用拷贝 / 移动:防止资源被复制或被错误转移
  • 默认构造 + 临时返回(支持 RVO)
  • 管理 widget 的集合,但不泄漏资源所有权

安全创建方式(C++17 起支持 NRVO)

ScreenItems makeScreenItems() {
    return ScreenItems{};  // OK,临时对象 + RVO
}

注意:必须作为返回值使用,不能让用户 copy/move。

RAII:管理资源的首选机制

RAII 类型的本质就是:构造即获得资源,析构即释放资源

标准库中的 RAII 类型

  • std::string, std::vector
  • std::fstream, std::ostringstream
  • std::thread, std::unique_lock
  • std::unique_ptr, std::shared_ptr

Boost 中也有丰富的 RAII 类型

  • boost::asio::tcp::iostream
  • boost::lock_guard
  • boost::scope_exit 等等

不建议写自己的通用 RAII 类型!

C++20 已经标准化了 std::unique_resource<T, D>(P0052 提案)
类似的非标准库实现以前存在于 GSL、boost、folly 中。

建议做法:

  • 使用 std::unique_ptr<T, Deleter> 实现自定义资源管理(文件句柄、fd 等)
  • 等待 C++20 或使用第三方实现(Peter Sommerlad 的 GitHub、herbcepp 的 GSL)

总结:管理类 + RAII 的安全组合

方面 建议做法
资源封装类 禁用拷贝,慎用移动,仅由工厂创建
内部资源管理 使用 vector<unique_ptr<T>>
生命周期控制 栈上或智能指针管理(避免裸指针)
RAII 类型推荐使用 标准库(优先)、boost、C++20
避免抽象基类传值 多态用 unique_ptr<Base> 持有

提到的是 面向对象(OO)编程中的多态对象类型(Polymorphic Object Types) 的使用原则与风险,特别是当类涉及 virtual(虚函数)机制时。以下是这段内容的逐句深入理解与扩展解释:

核心主题:使用 virtual 的类,请“三思”!

在 C++ 中引入虚函数意味着你正在构建一个抽象层次结构(class hierarchy),这需要非常谨慎的设计,否则容易引发资源泄露、性能开销、接口混乱等问题。

Polymorphic Object Types 的典型特征

特征 说明
virtual 函数(含析构) 表示你打算做运行时多态
通常不可复制 / 不可赋值 防止 slicing、重复资源释放等
以引用或指针传递 保持多态行为(值传递会切片)
拥有“身份” 不能随意复制;每个实例有唯一生命周期
生命周期较长 通常在调用链上层分配或使用堆分配
用于表达抽象概念 如:Shape, Drawable, IOHandler

正确的类层级结构:以抽象类为根

struct Drawable {
    virtual void draw() const = 0;
    virtual ~Drawable() = 0; // 确保子类析构正确
};
inline Drawable::~Drawable() = default;
  • 抽象类 = 纯虚函数 + 虚析构函数
  • 子类实现接口,但不应继续增加 virtual

为什么不要滥用继承 / 多层虚函数?

原因 说明
接口污染 子类继续引入 virtual 会导致行为不明确或难以维护
多重继承问题 虚继承、多层继承极易导致菱形继承、构造析构顺序复杂化
难以控制资源 多态对象往往需要配合智能指针 + 自定义 deleter 管理生命周期
Slicing 风险 如果使用值传递或容器,Base 的值会切掉 Derived 部分
RTTI 和性能开销 虚表查找需要运行时信息,性能比普通调用慢且增加空间开销

使用策略建议

用途场景 是否推荐使用虚函数
明确表达接口(如 Drawable 是,适合使用虚函数
简单继承但无多态需求 否,用组合代替继承更好
多层次抽象、多种行为 小心设计,不建议层层 virtual
管理状态、资源类 禁止使用虚函数,应禁用复制、使用 RAII

实践建议:写虚基类时请确保

  • 仅设计为接口(纯虚)
  • 提供虚析构函数
  • 不定义状态,不依赖构造顺序
  • 子类不要引入新的虚函数
  • 永远不要值传递多态对象(使用引用或智能指针)

一个典型反例

struct Base {
    virtual void foo();
};
struct Derived : Base {
    virtual void bar(); //  子类引入新虚函数,接口分裂
};

更推荐的替代方案:组合优于继承

  • 使用 std::function
  • 使用类型擦除(如 std::any, std::variant, inplace_function
  • 使用策略模式 + 模板组合
  • 用 CRTP 实现接口扩展而非虚函数

总结

“虚函数是语言层面支持的一种强大机制,但它不是解决一切问题的银弹。”

三思使用 virtual

  • 是否真的需要运行时多态?
  • 是否存在更现代、类型安全的方式(如模板、多态容器、variant 等)?
  • 接口是否简单、稳定、易维护?