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

这段内容讲的是 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 的例子,演示了一个"迷惑"或"不安全"的用法:
cpp 复制代码
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 概念,操作简单,拷贝、赋值、比较都没问题,算"安全"。
  • 错误使用(如隐式转换和混合不同类型运算)会让程序表现"疯狂",不安全。
  • 所以,即使是基础类型,也需要程序员理解其行为规则,才能写出正确代码。
cpp 复制代码
#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(依赖比较排序)时,出现了不符合预期的结果。

代码分析:

cpp 复制代码
#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行为也会特殊。

实际运行输出

bash 复制代码
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

如何验证?

打印所有插入的值:

cpp 复制代码
#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

cpp 复制代码
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,产生未定义行为。
  • 这是整型提升与无符号数陷阱的典型示例
    正确写法:
cpp 复制代码
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] << " ";
}

或者:

cpp 复制代码
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. 内建类型没有表达语义:

例如函数签名:

cpp 复制代码
void fluximate(int, int, int);

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

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

示例:用类型包装原始值
cpp 复制代码
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)通过命名参数调用解决这个问题:

python 复制代码
fluximate(row=3, col=2, count=1)

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

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

示例:
  • size_tsize_type: 实际语义是"元素个数",应是自然数(包含0),即绝对值
  • ptrdiff_tdifference_type: 表示两个指针/迭代器之间的相对距离,可能为负
问题发生:
cpp 复制代码
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

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

cpp 复制代码
struct Index { int value; };
struct Count { size_t value; };
void resize_buffer(Index start, Count size);

相比:

cpp 复制代码
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 理想做法。类型系统本身就能表达"这是什么东西",无需靠注释或命名去区分

举例:危险用法

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

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

cpp 复制代码
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)来封装它们的全部语义。"

不良示例:原始类型滥用

cpp 复制代码
void purchase(int itemCode, int quantity, double price);

问题:

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

改进方案:Whole Value 模式

cpp 复制代码
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 接口等
cpp 复制代码
struct WeightKg {
    double value;
    explicit WeightKg(double v) : value(v) {
        assert(v >= 0); // 不允许负质量
    }
};

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

"Include format converters..."

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

cpp 复制代码
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

cpp 复制代码
struct Wait {
    size_t count{};
};
void check_counters(Wait w, Notify n);

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

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

示例:

cpp 复制代码
check_counters(Wait{0}, Notify{2});

相比:

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

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

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

cpp 复制代码
void operator++(Wait &w) {
    w.count++;
}

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

cpp 复制代码
Wait w{1};
++w;

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

聚合初始化(Aggregate Initialization)

cpp 复制代码
Wait w{3};
Notify n{2};

C++ 的聚合初始化机制让这种封装既安全,又不会带来运行时成本。

这是一种零运行时开销的类型安全增强手段

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

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

而不是:

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

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

cpp 复制代码
T() = default;

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

应该默认构造的情况

"Yes, whenever there is a natural default or neutral value in your type's domain"

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

例子:

cpp 复制代码
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"

例如:

cpp 复制代码
MyType x;
if (condition) x = computeA();
else           x = computeB();

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

更好的做法:

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

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

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

"No, when there is no natural default value"

比如:

cpp 复制代码
struct PokerCard {
    Suit suit;
    Rank rank;
};

扑克牌没有"空牌"或者"默认牌"。允许默认构造意味着可能出现非法对象状态。

更好的做法是 只允许有意义的构造方式

cpp 复制代码
PokerCard(Suit s, Rank r); // 不提供默认构造函数

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

"No, when the type's invariant requires a reasonable initialization"

比如:

cpp 复制代码
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 混用导致的类型不安全

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

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

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

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

cpp 复制代码
difference_type(__n)

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

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

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

类比:

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

示例:位置 vs 位移

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

例子:

cpp 复制代码
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 实现无开销强类型

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

断言测试:

cpp 复制代码
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),例如:

cpp 复制代码
struct Empty {};

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

EBO(Empty Base Optimization)

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

例如:

cpp 复制代码
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

cpp 复制代码
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(标准库迭代器标签)

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

cpp 复制代码
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,可以在算法中实现 不同迭代器种类的特化重载

示例:
cpp 复制代码
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());
}
使用:
cpp 复制代码
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

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

cpp 复制代码
template <class... Args>
constexpr explicit optional(std::in_place_t, Args&&... args);

使用示例:

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

等效于:

cpp 复制代码
std::optional<std::string> o5(std::string(3, 'A'));

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

3. nullptr_tnullptr

类似 in_place_t 的还有内建类型:

  • std::nullptr_tnullptr 的类型
  • 用于函数重载决策和模板特化
    例如:
cpp 复制代码
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>:值 → 类型

cpp 复制代码
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

示例:
cpp 复制代码
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

cpp 复制代码
using true_type = integral_constant<bool, true>;
using false_type = integral_constant<bool, false>;

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

用途 1:SFINAE / 模板重载

cpp 复制代码
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

cpp 复制代码
using half = std::ratio<1, 2>;
using milli = std::ratio<1, 1000>;

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

cpp 复制代码
std::chrono::duration<int, std::milli> ms(1);  // 1 毫秒

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

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

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

所有静态断言解释:

cpp 复制代码
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 筛选或重载示例,也可以告诉我!
cpp 复制代码
#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 把一个编译期的常量"值"包装成了一个"类型",这样可以用类型系统来进行选择和分支。

它长这样:

cpp 复制代码
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; } // 函数调用也返回值
};

用法示例

cpp 复制代码
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

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

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

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

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

cpp 复制代码
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的优化,告诉编译器:这个成员的地址可以和其它成员重叠,以节省空间。
cpp 复制代码
struct S {
    int x;
    [[no_unique_address]] empty e;
};
static_assert(sizeof(S) == sizeof(int)); // 这里也可以不增加额外大小

总结

  • 空类实例自身大小最少1字节以保证唯一地址。
  • 作为基类时可利用EBO消除额外空间。
  • EBO是实现零开销抽象(比如空删除器、策略类等)的关键技术。
  • C++20 [[no_unique_address]]扩展了这个优化的适用范围。
cpp 复制代码
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限制和原则:

代码回顾:

cpp 复制代码
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` --- 强类型包装 ```cpp template struct strong { using value_type = V; V val; // 实际存储值 }; ``` * 用 `V` 包装原始类型。 * 用 `TAG` 做区分,防止不同语义的数值被混用。 * 这是"Whole Value Pattern"中推荐的方式。 #### 2. CRTP 扩展操作符 ```cpp template 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 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 struct Out { friend std::ostream& operator<<(std::ostream& os, U const& r) { auto const& [v] = r; return os << v; } }; ``` * 这里用**结构化绑定** 解构 `strong` 类型,访问其内部成员 `val`。 * 用 `friend` 声明运算符重载,依赖模板参数 `U`,保证这些操作符只为特定类型实例化。 * 包括:`==`/`!=`,前后缀递增,输出流操作。 #### 3. 操作符组合混入模板 ```cpp template class... BS> struct ops : BS... {}; ``` * 多继承多个操作符扩展,**混入**(mixin)机制。 * 例如:`ops` 即继承了比较、递增、输出操作。 #### 4. 定义强类型示例 `WaitC` ```cpp struct WaitC : strong, ops {}; static_assert(sizeof(unsigned) == sizeof(WaitC)); ``` * `WaitC` 继承自包装了 `unsigned` 的 `strong`,同时混入操作符扩展。 * `static_assert` 验证**EBO** 生效,`WaitC` 与 `unsigned` 大小一致,没有额外开销。 #### 5. 测试用例 ```cpp 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` 确保运行时内存开销符合预期。 ```cpp #include template 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 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 struct Out { friend std::ostream& operator<<(std::ostream& os, U const& r) { auto const& [v] = r; return os << v; } }; template struct strong { using value_type = V; V val; // 实际存储值 }; template class... BS> struct ops : BS... {}; struct WaitC : strong, ops {}; 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`)是可能的,但非常需要小心和自律**。否则,可能会破坏类的设计原则(比如里氏替换原则),导致不可预料的行为或错误。 ### 一步步理解 #### 示例代码解释: ```cpp template> class indexableSet : public std::set { using SetType = std::set; using size_type = int; // 为了支持负数索引 public: using std::set::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)** :即子类对象应该能够替代父类使用,而行为保持一致 比如这样会出错: ```cpp void printSet(std::set s) { ... } // 切片发生!indexableSet 的特性丢失! printSet(indexableSet{1,2,3}); ``` ### 什么时候可以继承 STL? > **仅当你"只扩展、不修改"功能,并** 且**你绝对不会将子类"当作"父类用(避免 slicing)**。 也就是说: * 只是在加新功能,比如 `[]`、`front()`、`back()`,但不改动已有语义 * 不会以 `std::set` 的形式传参 * 不会进行 slicing(值拷贝切掉子类部分) ### 小结:关键词对照 | 原文术语 | 中文解释 | |-----------------------------------|-----------------------| | **"Empty" Adapters** | 空适配器类,只有行为改变,没有数据增加 | | **Liskov Substitution Principle** | 里氏替换原则:子类必须能无害地替代父类使用 | | **inherits constructors** | C++11 支持继承构造函数,让适配更自然 | | **slicing harmful** | 值传递切掉子类特性,极易出 bug | | **better wrap then** | 如果要改变语义,最好用组合而不是继承 | ### 总结建议: 可以继承 STL 容器用于适配器类,但: * 不该改变原本行为 * 永远不要把它转为父类使用 * 避免对象 slicing * 如果你要加强语义,**推荐用组合(`wrap`)而不是继承** ```cpp #include // std::set 用于自动排序的集合 #include // std::cout, std::endl #include // std::out_of_range 异常 #include // std::next 用于迭代器偏移 // 定义一个支持下标访问(包括负数索引)的 std::set 子类 template > class indexableSet : public std::set { using SetType = std::set; using size_type = int; // 使用 int 类型索引,支持负数下标 public: // 继承 std::set 的构造函数,允许直接初始化 indexableSet using std::set::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(SetType::size()); // 负数索引处理:从末尾向前数 if (index < 0 || index >= static_cast(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 s{3, 1, 4, 1, 5, 9, 2}; std::cout << "Set contents by index:\n"; // 正向遍历集合中的元素,通过索引访问 for (int i = 0; i < static_cast(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`, `std::unique_ptr`(智能指针) * `std::reference_wrapper`(引用包装器) * `std::span`(不拥有对象的视图) * **迭代器** (如 `std::vector::iterator`) #### 和 Value Type 的对比 | 特性 | Value Type | Pointing Type | |----------|------------|---------------| | 拥有资源? | 是 | 否 | | 独立存在? | 通常可独立使用 | 依赖被指向对象 | | 生命周期易管理? | 通常安全 | 需小心生命周期 | | 拷贝/比较语义? | 明确值语义 | 语义复杂(指向不同对象) | #### 常见风险:悬空和无效访问 1. **Dangling References** (悬空引用) 引用或指针指向一个已经被销毁的对象: ```cpp 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: ```cpp auto it = vec.end(); *it; // 未定义行为 ``` #### 关于 Iterators 的特殊说明 * **迭代器是"值类型的接口 + 指针的语义"** * `==`, `!=`, `++`, `*`, `->` 等操作都支持。 * 但它实际指向其他对象,因此不是严格意义上的"value type"。 * **默认构造行为特殊** * 有些迭代器有默认构造的"空值"表示(如 `istream_iterator` 的 EOF 状态)。 ##### 标准库中可能失效的操作: ```cpp std::vector 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` | 自动释放资源,安全但非全能 | | View | `std::span` | 不拥有对象,依赖外部数据 | ## 关于 **"Dimensions Safety and Sanity"** (维度安全与合理性)主题下的一个关键设计思想 ------ **管理类(Monomorphic Object Types)** 、**RAII(资源获取即初始化)** 以及如何以安全、清晰的方式管理资源、状态和对象生命周期。 以下是对你内容的系统性理解与拆解: ### 核心思想:管理类 ≠ 值类型(Not Value Types) 这些类在程序中扮演 *资源管理者* 或 *状态封装者* 的角色,因此: * **它们有显著身份(Identity)** * **它们不是 Regular Types(规则类型)** 不支持拷贝构造、赋值、比较等通用值语义 #### 管理类的特征(Monomorphic Object Types) | 特征 | 说明 | |--------------------------------|---------------------| | 禁止拷贝 / 移动 | 保证资源唯一、状态不共享 | | 构造后生命周期稳定 | 通常由高层组件或工厂函数生成 | | 通常是栈或堆上长生命周期对象 | 被传递时只传引用(或智能指针) | | 不使用虚函数 / 多态 | 避免运行时成本和 slicing 问题 | | 包含复杂状态或资源 | 比如:IO、网络、容器、线程句柄等 | | 用于:Manager / Builder / Context | 管理多个子资源的生命周期 | ### 示例:`ScreenItems` 管理器类分析 ```cpp 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) ```cpp 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`** (P0052 提案) 类似的非标准库实现以前存在于 GSL、boost、folly 中。 #### 建议做法: * 使用 `std::unique_ptr` 实现自定义资源管理(文件句柄、fd 等) * 等待 C++20 或使用第三方实现(Peter Sommerlad 的 GitHub、herbcepp 的 GSL) ### 总结:管理类 + RAII 的安全组合 | 方面 | 建议做法 | |-------------|------------------------------| | 资源封装类 | 禁用拷贝,慎用移动,仅由工厂创建 | | 内部资源管理 | 使用 `vector>` 等 | | 生命周期控制 | 栈上或智能指针管理(避免裸指针) | | RAII 类型推荐使用 | 标准库(优先)、boost、C++20 | | 避免抽象基类传值 | 多态用 `unique_ptr` 持有 | ## 提到的是 **面向对象(OO)编程中的多态对象类型(Polymorphic Object Types)** 的使用原则与风险,特别是当类涉及 `virtual`(虚函数)机制时。以下是这段内容的逐句深入理解与扩展解释: ### 核心主题:使用 `virtual` 的类,请"三思"! 在 C++ 中引入虚函数意味着你正在构建一个**抽象层次结构(class hierarchy)**,这需要非常谨慎的设计,否则容易引发资源泄露、性能开销、接口混乱等问题。 ### Polymorphic Object Types 的典型特征 | 特征 | 说明 | |-------------------|------------------------------------| | `virtual` 函数(含析构) | 表示你打算做运行时多态 | | 通常不可复制 / 不可赋值 | 防止 slicing、重复资源释放等 | | 以引用或指针传递 | 保持多态行为(值传递会切片) | | 拥有"身份" | 不能随意复制;每个实例有唯一生命周期 | | 生命周期较长 | 通常在调用链上层分配或使用堆分配 | | 用于表达抽象概念 | 如:`Shape`, `Drawable`, `IOHandler` | ### 正确的类层级结构:以抽象类为根 ```cpp struct Drawable { virtual void draw() const = 0; virtual ~Drawable() = 0; // 确保子类析构正确 }; inline Drawable::~Drawable() = default; ``` * 抽象类 = 纯虚函数 + 虚析构函数 * **子类** 实现接口,但不应继续增加 `virtual` 层 ### 为什么不要滥用继承 / 多层虚函数? | 原因 | 说明 | |------------|--------------------------------------| | 接口污染 | 子类继续引入 `virtual` 会导致行为不明确或难以维护 | | 多重继承问题 | 虚继承、多层继承极易导致菱形继承、构造析构顺序复杂化 | | 难以控制资源 | 多态对象往往需要配合智能指针 + 自定义 deleter 管理生命周期 | | Slicing 风险 | 如果使用值传递或容器,`Base` 的值会切掉 `Derived` 部分 | | RTTI 和性能开销 | 虚表查找需要运行时信息,性能比普通调用慢且增加空间开销 | ### 使用策略建议 | 用途场景 | 是否推荐使用虚函数 | |----------------------|-----------------------| | 明确表达接口(如 `Drawable`) | 是,适合使用虚函数 | | 简单继承但无多态需求 | 否,用组合代替继承更好 | | 多层次抽象、多种行为 | 小心设计,不建议层层 `virtual` | | 管理状态、资源类 | 禁止使用虚函数,应禁用复制、使用 RAII | ### 实践建议:写虚基类时请确保 * 仅设计为接口(纯虚) * 提供虚析构函数 * 不定义状态,不依赖构造顺序 * 子类不要引入新的虚函数 * 永远不要值传递多态对象(使用引用或智能指针) ### 一个典型反例 ```cpp struct Base { virtual void foo(); }; struct Derived : Base { virtual void bar(); // 子类引入新虚函数,接口分裂 }; ``` ### 更推荐的替代方案:组合优于继承 * 使用 `std::function` * 使用类型擦除(如 `std::any`, `std::variant`, `inplace_function`) * 使用策略模式 + 模板组合 * 用 CRTP 实现接口扩展而非虚函数 ### 总结 > "虚函数是语言层面支持的一种强大机制,但它不是解决一切问题的银弹。" **三思使用 `virtual`:** * 是否真的需要运行时多态? * 是否存在更现代、类型安全的方式(如模板、多态容器、variant 等)? * 接口是否简单、稳定、易维护?