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)**比如 int
、char
、bool
、double
是否真的“安全(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 = 2auto const theanswer = (throdd & (i+1)) ? "yes"s : "no"s; ASSERT_EQUAL("", theanswer);
}
- 这个例子中:
i % 3
是2
,但赋给了bool
,bool 会隐式转换为 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 = 13assert(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.0
是 NaN(不是一个数字)x / 0.0
是 inf(无穷大)- 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
你会看到 NaN
和 inf
出现的情况,证明NaN破坏了集合的正确排序。
解决方案
- 不要把NaN作为
std::set
的元素,它会破坏容器的排序 - 过滤掉除零情况,或者用一个更健壮的数据结构/比较函数处理浮点特殊值
- 你可以自己写一个比较器,排除NaN或把NaN当作最大值处理
总结
- 浮点数中的特殊值NaN会导致
std::set<double>
行为不符合预期,插入失败或者重复被忽略。 - 这就体现了浮点类型在容器使用时“非理智”的地方。
- 你需要特别处理这些特殊值,避免破坏容器的排序和元素唯一性保证。
C++中标准库容器作为“正规”类型(Regular Types)时的安全性和理智性,以及它们底层使用的内建类型(primitive types)导致的一些经典坑。
核心点总结:
- 容器本身通常是“正规”类型(Regular value types),前提是它们的元素类型和模板参数满足“正规类型”的要求(可复制、可赋值、可比较、可移动等)。
- 这意味着容器通常有“安全”的复制、赋值和比较语义。
- 但容器依赖的内建类型存在各种“怪癖”和陷阱:
- 整型提升(Integral promotion)
- 遗留自C语言的一套复杂规则,包含
bool
和char
也作为整型处理 - 混合有符号和无符号整数参与算术时容易产生隐式转换和潜在bug
- 许多编译器警告被强制转换掩盖掉,导致潜在错误
- 整数溢出行为不同,可能是环绕(wrap around)、未定义行为,或者硬件的**进位位(carry bit)**信号
- 遗留自C语言的一套复杂规则,包含
- 自动数值转换(Automatic numeric conversions)
- 整数、浮点数和布尔之间自动转换复杂且容易出错
- 特别是如果自定义类型有隐式构造函数或隐式转换操作,极易引起歧义和意外转换
- 建议不要让类类型有隐式转换,应尽量使用
explicit
防止自动类型转换
- 浮点数的特殊值问题
- 浮点数存在
+∞
、-∞
、NaN
,往往被忽略 - 比较浮点数时要小心,必须保证比较满足严格弱序(strict weak ordering)或更强的要求,否则容器等会出错
- 浮点数存在
- 整型提升(Integral promotion)
代码中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_t
、size_type
: 实际语义是“元素个数”,应是自然数(包含0),即绝对值ptrdiff_t
、difference_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_typestd::copy(__first, __last, __position);_M_finish += difference_type(__n); // 又一次强转
}
总结这个痛点:
- 使用了错误的类型表示语义不同的值
- 导致频繁的强制类型转换
static_cast<>
- 编译器警告变得难以判断是否合理
- 若处理不当可能隐藏逻辑 bug
建议与最佳实践:
场景 | 建议做法 |
---|---|
表达明确语义的值 | 用 struct X { T value; }; 强类型封装 |
size vs. difference | 区分 size_type 和 difference_type ,避免隐式转换 |
函数参数中多个相同类型 | 避免 int,int,int 这样的签名,用结构体替代 |
标准库与用户类型互操作 | 使用 explicit 构造函数防止隐式类型转换 |
防止 unsigned/signed 比较警告 | 明确类型转换,避免混用 size_t 和 int |
示例:更好的函数签名与类型
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(单位与语义的安全性)
这段话想表达的是:
用
int
、double
、unsigned
、std::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(完整值模式)**的主张是:
“不要用
int
、double
、string
这样的原始类型来表达业务中具有语义的数量、参数或单位,而是使用专门的值类型(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. “最底层的单位”(如 int
、string
、double
)是 不安全的
这些基本类型可以表示任何东西,所以本身并不表达任何具体含义。
这是 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_t
、int
参数 - 封装成带有含义的结构体
Wait
和Notify
- 明确了业务语义:“等待次数”、“通知次数”,让调用更清晰、更安全
示例:
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
(时间间隔) vstime_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; // 可行,但我们需要知道方向 != 坐标
使用两个强类型 Position3D
和 Vector3D
可以避免混淆。
使用“强类型”(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> {};
WaitC
是unsigned
的强类型封装- 继承了
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 typealg(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_t
和 std::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_t
和 nullptr
类似 in_place_t
的还有内建类型:
std::nullptr_t
是nullptr
的类型- 用于函数重载决策和模板特化
例如:
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::tuple
或 std::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::chrono 、std::tuple 、std::optional 、std::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"); // 转换为 boolstatic_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_type
和false_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 不生效的情况:
- 基类和成员有相同类型时,EBO不生效
ebo
里既继承了empty
,又含有一个empty
成员变量。- 编译器不能让基类子对象和成员变量共享同一内存地址,因为每个对象必须有唯一地址。
- 所以
ebo
大小变大了(没有被压缩)。
- 多个子对象类型相同时,也不能共享地址
- 规则:同类型的多个子对象必须有唯一地址。
- 因此如果你有多个
empty
类型的子对象(无论是基类还是成员),编译器无法合并它们。
- 保证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
继承自包装了unsigned
的strong
,同时混入操作符扩展。static_assert
验证EBO生效,WaitC
与unsigned
大小一致,没有额外开销。
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 的构造函数,允许直接初始化 indexableSetusing 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 |
---|---|---|
拥有资源? | 是 | 否 |
独立存在? | 通常可独立使用 | 依赖被指向对象 |
生命周期易管理? | 通常安全 | 需小心生命周期 |
拷贝/比较语义? | 明确值语义 | 语义复杂(指向不同对象) |
常见风险:悬空和无效访问
- Dangling References(悬空引用)
引用或指针指向一个已经被销毁的对象:int* p; {int x = 42;p = &x; } // x 生命周期结束,p 悬空
- Invalid/Null Pointers(空指针/无效指针)
指针没有被正确初始化或显式设为nullptr
。 - Invalidated Iterators(失效迭代器)
修改容器后(插入/删除/resize),之前获取的迭代器失效。 - 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)
如何安全使用这类类型?
- 不要缓存迭代器/指针,如果容器可能改变
- 使用智能指针管理动态内存,避免悬空
- 不要解引用
end()
,也不要对“默认构造”迭代器解引用 - span/view 类型不可扩展,不应该存储在结构体中长期引用容器内部数据
- 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; // 禁止 movewidgets 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 等)?
- 接口是否简单、稳定、易维护?