这段内容讲的是 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
的例子,演示了一个"迷惑"或"不安全"的用法:
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 % 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 概念,操作简单,拷贝、赋值、比较都没问题,算"安全"。
- 但错误使用(如隐式转换和混合不同类型运算)会让程序表现"疯狂",不安全。
- 所以,即使是基础类型,也需要程序员理解其行为规则,才能写出正确代码。
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.0
是 NaN(不是一个数字)x / 0.0
是 inf(无穷大)- 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
你会看到 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
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_t
、size_type
: 实际语义是"元素个数",应是自然数(包含0),即绝对值ptrdiff_t
、difference_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_type 和 difference_type ,避免隐式转换 |
函数参数中多个相同类型 | 避免 int,int,int 这样的签名,用结构体替代 |
标准库与用户类型互操作 | 使用 explicit 构造函数防止隐式类型转换 |
防止 unsigned/signed 比较警告 | 明确类型转换,避免混用 size_t 和 int |
示例:更好的函数签名与类型
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(单位与语义的安全性)
这段话想表达的是:
用
int
、double
、unsigned
、std::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(完整值模式)**的主张是:
"不要用
int
、double
、string
这样的原始类型来表达业务中具有语义的数量、参数或单位,而是使用专门的值类型(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. "最底层的单位"(如 int
、string
、double
)是 不安全的
这些基本类型可以表示任何东西 ,所以本身并不表达任何具体含义。
这是 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_t
、int
参数 - 封装成带有含义的结构体
Wait
和Notify
- 明确了业务语义:"等待次数"、"通知次数",让调用更清晰、更安全
示例:
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
(时间间隔) vstime_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; // 可行,但我们需要知道方向 != 坐标
使用两个强类型 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 实现无开销强类型
cpp
struct WaitC : strong<unsigned, WaitC>, ops<WaitC, Eq, Inc, Out> {};
WaitC
是unsigned
的强类型封装- 继承了
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_t
和 std::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_t
和 nullptr
类似 in_place_t
的还有内建类型:
std::nullptr_t
是nullptr
的类型- 用于函数重载决策和模板特化
例如:
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::tuple
或 std::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::chrono 、std::tuple 、std::optional 、std::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_type
和false_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 不生效的情况:
- 基类和成员有相同类型时,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