CppQuiz 笔记
https://cppquiz.org/
看多了 c++ 的文档和标准,感觉都不会说话了
高中对英语很感兴趣,现在对C++很感兴趣,也许我适合学语言学?
163/163!
手机上也做了一遍!(虽然没什么用)
1. 重载决议
重载决议分为三步:建立候选函数集合;从该集合去除函数,只保留可行函数;分析可行函数集合,以确定唯一的最佳可行函数。
存在函数模板时,重载决议的过程大致如下:
- 对所有函数模板进行模板实参推导、检查显式指定的模板实参,以确定最终可用的模板实参。
- 如果实参推导和检查都成功,通过该实参生成特化(模板实参替换),加入到候选函数 (candidate functions) 集合。
如果推导失败或生成的特化非良构,则不会将其加入到候选集,也不会导致编译失败(即 SFINAE)。 - 将所有同名的非模板函数添加到候选集。
- 检验实参与形参是否合法,将候选集缩减为可行函数 (viable functions) 的集合。
- 分析可行函数集合,确定唯一的最佳可行函数。
注意只有非模板和主模板重载参与重载决议。特化并不是重载,因此不考虑。只有在重载决议选择最佳匹配的主函数模板后,才检查它的特化以确定最佳匹配。
本题中,先对模板 f 进行模板实参推导,得到 T 是 int& 且合法,所以它是候选函数(与 Quiz 里的解释不同,这一步中并不包含对显式特化的推导和决策,因为它不是重载)。
因为不存在其它函数,所以 f 就是最佳匹配的模板。
f 现在包含两个特化,f<int&>(int&)
和f<>(const int&)
,需要根据如下规则决定谁更好。
确定最佳函数:定义 ICSi(F) 为:将第 i 个实参类型转换到函数 F 的第 i 个形参类型所需进行的隐式转换序列。
每个实参到形参的隐式转换序列是影响函数优先级的主要条件(但如果 F 是静态成员函数,那么 ICS1(F) 不会参与优先级的比较。换句话说对于任意函数 G,ICS1(F) 不比 ICS1(G) 更差,也不比它更优,不会影响比较)。
对于每对可行函数 F1 和 F2,当且仅当满足以下条件时称 F1 是比 F2 更好的函数:对于所有实参 i,ICSi(F1) 均不劣于 ICSi(F2),并且满足以下条件之一:
- 存在某个实参 j,ICSj(F1) 优于 ICSj(F2)。(这题只涉及这一点)
- 当前语境是在通过用户定义转换进行初始化,并且将 F1 的返回类型转换到目标类型(即被初始化目标的类型)所需的标准转换序列,优于将 F2 的返回类型转换到目标类型所需的标准转换序列。
- 当前语境是在通过转换函数,对函数类型的引用进行直接引用绑定初始化,并且 F1 的返回类型与被初始化的引用的类型相同(都是左值引用或右值引用),而 F2 不是。
- F1 不是函数模板特化,而 F2 是。
- F1, F2 都是函数模板特化,并且根据模板特化的偏序规则,F1 更特殊。
(简单来说,如果函数模板 A 能接收的类型比函数模板 B 更少,那 A 比 B 更特殊,见 251.) - ...
编译器会对所有可行函数进行逐对比较。如果恰好有一个可行函数优于所有其它函数,那么重载决议成功并调用该函数。否则编译失败。
隐式转换序列有三种:
- 标准转换序列:0或1个标准转换序列。
- 用户定义转换序列:0或1个标准转换序列、1个用户定义转换、0或1个标准转换序列。
- 省略号转换序列:实参匹配到了省略号形参(此时实参会进行称为默认实参提升的转换)。
一个标准转换序列由4部分组成:0或1个值变换(包括:左值到右值转换、数组到指针转换、函数到指针转换)、0或1个数值提升或数值转换(包括:整数提升、浮点提升、整数转换、浮点转换、浮点整数转换、指针转换、成员指针转换、bool 转换)、0或1个函数指针转换、0或1个限定性转换。它们称为标准转换。
用户定义转换指调用一次非 explicit 单实参构造函数或非 explicit 转换函数。标准转换的等级从高到低分为三级:准确匹配、提升、转换。一个标准转换序列的等级是它包含的标准转换中的最低等级。
隐式转换序列的优先级为:标准转换序列 > 用户定义转换序列 > 省略号转换序列。
如果两个 ICS 的类型相同,则按照这里的规则继续进行区分:
如果两个序列 S1, S2 都是标准转换序列,当且仅当满足以下条件之一时称 S1 比 S2 更优:
- S1 是 S2 的真子序列(不考虑左值变换;恒等变换是任何非恒等变换序列的子序列)。
- S1 的等级高于 S2(取决于它包含的标准转换中的最差等级)。
- ...
- S1 与 S2 都包含引用绑定,并且引用的类型除了顶层 cv 限定之外没有区别,并且 S1 引用类型上的 cv 限定比 S2 引用类型上的 cv 限定更少。
f<int&>(int&)
和f<>(const int&)
分别涉及隐式转换序列int左值 -> int&
和int左值 -> const int&
,两者都不需要转换,都是准确匹配等级的标准转换序列,但满足上面的最后一条:S1, S2 都包含引用绑定,S1 引用的类型是 int,而 S2 是 const int,因为 S1 引用的类型的 cv 限定更少,所以 S1 int左值 -> int&
更优。
2. 重载决议
题目中,f(const string&)
与f(const void*)
都是可行函数,所以需要按照 1. 中所述的规则决定谁更好。
称f(const string&)
为 FS,称f(const void*)
为 FP。
对于f("foo")
,实参为const char(&)[4]
,
- ICS1(FS) 是将
const char(&)[4]
转换为const string&
所需进行的隐式转换序列,它必须通过一次用户定义转换basic_string(const CharT* s)
来完成,所以它是一个用户定义转换序列。 - ICS1(FP) 是将
const char(&)[4]
转换为const void*
所需进行的隐式转换序列,它只需要一次函数到指针转换 和一次指针转换:指向任何(可有 cv 限定的)对象类型 T 的指针的纯右值,可转换成指向(有相同 cv 限定的)void 的指针的纯右值,结果指针与原指针表示内存中的同一位置。所以它是一个标准转换序列。
标准转换序列优于用户定义转换序列,所以 ICS1(FP) 优于 ICS1(FS),调用 FP。
f(bar)
同理,FS 需要一次用户定义转换,而 FP 只需要一次指针转换。
3. 重载决议
标准转换的等级从高到低分为三级:准确匹配、提升、转换。
这两个重载都只需要一次浮点到整型的标准转换,这个转换都属于第三级,因此这两个重载优先级相同,会有歧义导致 CE。
4. 浮点字面量
如果浮点字面量没有后缀,则是 double 类型,否则类型取决于后缀:f F 是 float,l L 是 long double。
5. 构造析构顺序
初始化列表中成员初始化器的顺序不会影响成员初始化顺序,只取决于声明顺序。
-Wreorder 或 -Wall 也会在初始化器顺序与声明顺序不一致时给出警告。
6.
没什么好说的。
7. 多态
f 不是虚函数,所以不会有多态效果。
8. 多态
只有指针和引用才会进行虚调用,实现多态。传值不仅不是多态,还会拷贝一个基类、产生派生类的切片。
因此也建议 C.67: 多态类应该抑制复制操作,以避免切片。
9. 严格别名
(这是一个与严格别名相关的例子,但他没有讲)
编译器应该始终假设“两个不同的指针或引用可能指向相同的内存区域,或指向的区域存在重叠”,以保证运行结果符合程序员的预期,就像这道题一样。
但是这会明显影响程序的性能。
假设有一个指向 T1 类型的指针或引用 a,和一个指向 T2 类型的指针或引用 b。
如果编译器需要假设 a, b 可能指向相同内存,那么每次写入 a 指向的内存或缓存后,都必须重新从内存或缓存读取 b 指向的值,即使之前已经把 b 指向的值读到了寄存器里。
如果 a, b 确实可能指向相同内存,即 a 是 b 的别名,那这种麻烦是必要的;但如果 a, b 始终不会指向相同内存,那么重新读取 b 是完全没有意义的。(如果有多个左值指向同一内存区域,那它们之间互称为别名 (alias))
一个经典例子是:假设类 A 有一个指针类型的成员 p,因为 this 是指针,p 即 this->p 也是指针,如果编译器假设 this 和 this->p 可能指向相同内存,那么每次修改 this->p 指向的数据后,都需要重新读取 p,才能再次读写 p 指向的数据,即使 this 和 this->p 毫无关系、p 也从来没有被改变过。如果 p 指向一个数组首地址,然后在成员函数内循环修改数组内的 n 个元素,那么会因为这个检查多出 n 次无意义的访存。
为了减少不必要的检查,C++ 使用基于类型的别名分析,即根据类型决定两个指针或引用可不可能互为别名。
对于动态类型为 T 的对象(即构造时类型为 T),如果类型 P 满足以下条件之一:
- P 和 T 类型相似,即在去掉顶层 cv 后满足以下条件之一:
- P, T 是同一类型。
- P, T 是指针,且其指向的类型相似。
- P, T 都是指向相同类的成员指针,且被指向的成员类型相似。
- P, T 都是数组,且数组元素类型相似。
- P 是 T 的任意 cv 限定版本 或 有/无符号 (signed/unsigned) 版本(cv 与符号性两者可结合)。
- P 是 char、signed char、unsigned char 或 std::byte。
则 P* 类型的指针是 T* 指针的合法别名,编译器将假设它们可能指向同一内存,不做优化。
但如果类型 T 和 P 不满足上述要求,编译器将认为 P* 和 T* 类型的两个指针始终不可能指向同一内存,并以此为基础进行优化,所以通过 P 类型的泛左值读写动态类型为 T 的对象是 UB。
问题一般出现在通过 reinterpret_cast 转换到不合法的别名类型后进行读写,比如下面的代码就是 UB:
int g(int& a, int16_t& b) {
a = 3;
b = 4;
return a + b;
}
int a = 1;
int c = g(a, reinterpret_cast<int16_t&>(a));
cout << a << c;
代码可能输出 47。如果加-fno-strict-aliasing
禁止严格别名优化,那么结果是正确的 48。
在 C 中,如果能够确保相同类型的两个指针绝不会指向同一内存,可以通过关键字 restrict 来说明指针不会被别名使用,允许编译器进行同样的优化、减少访存。
C++ 中只能使用编译器扩展,比如 gcc、clang 的 __restrict。
int f(int& __restrict a, int& __restrict b) {
a = 3;
b = 4;
return a + b;
}
int a = 1;
int c = f(a, a);
cout << a << c;
这个代码是 UB,可能输出 47,但少了一次访存。只要使用时注意别f(a, a)
,就可以用 restrict 提高效率。
11. 静态变量 静态初始化
静态存储期变量的初始化包含两阶段:
-
静态初始化:如果变量满足以下两个条件:
- 它有初始化器,或没有初始化器,但它的默认初始化会初始化其它对象。
- 它的初始化完整表达式是常量表达式,或它是对象,且调用的构造函数和其子对象调用的构造函数都是 constexpr。
则进行常量初始化;否则零初始化。通常发生在编译期。
-
动态初始化:如果静态初始化的效果与变量的初始化表达式不一致(即变量既不是使用常量表达式进行初始化,也不是要进行零初始化,或者初始化表达式有初始化它本身以外的副作用),则在运行期继续进行初始化。
因此如果没给基础类型的静态变量提供初始化器,则保证会被零初始化。
12. 静态变量 静态初始化
同 11.,如果没给基础类型的静态变量提供初始化器,则保证会被零初始化。
13. 静态变量 动态初始化
由 11.,静态初始化阶段中,变量会进行常量初始化或零初始化,因为类 A 不满足常量初始化的条件,所以会进行零初始化。
由于零初始化并不能满足 A 的初始化方式,所以还要在运行时进行动态初始化。B, C 同理。
对于非局部静态变量,动态初始化是比 main 的第一条语句执行得 早 还是 晚(发生延迟),是实现定义,但保证:它一定早于同一翻译单元中的任何静态/线程存储期的变量和函数的首次 ODR 使用(只要翻译单元中 ODR 使用了任何事物,就会初始化所有在初始化或销毁中拥有副作用的非局部变量,即使程序中没有用到它们)。
构造函数也是函数调用,因此 a 的初始化一定早于 c 的初始化。
局部静态变量的动态初始化,会在控制首次经过它的声明时进行。
静态存储期对象在程序退出时进行析构。
14. 静态变量 动态初始化
同 13.,非局部静态变量 a 和局部静态变量 c 都需要在运行时进行动态初始化。
非局部静态变量 a 的动态初始化会在 b 进行初始化前进行;而局部静态变量 c 的动态初始化,会在控制首次经过它的声明时进行,所以 c 晚于 b。
静态存储期对象在程序退出时进行析构。
线程局部和静态变量在析构时保证:所有线程局部对象的析构都早于静态对象的析构;如果线程局部或静态变量 a 的构造或动态初始化,早于线程局部或静态变量 b,则 b 的析构早于 a 的析构,即静态和线程存储期对象的析构顺序与构造顺序相反。
15. 静态变量
同 13.、14.,局部静态变量 b 需要进行动态初始化,这会在控制首次经过它的声明时进行。如果局部静态变量在初始化时抛出异常,则不认为它被初始化,程序会在控制下次经过它的声明时再次尝试初始化。
题目中,初始化 b 时,会先初始化它的成员 a,然后触发异常导致初始化中断。进入 catch 再次调用 foo 后,会再次初始化 b。
如果把 A() 里的 throw 移到 B 的构造函数里,那么在执行 B() 前会成功构造成员 a,在抛出异常时就需要析构成员 a。
16. 构造析构顺序
构造函数先执行类成员的构造函数,再执行函数体;析构相反。
具体初始化顺序:
- 如果有虚基类,按基类声明的从左到右、深度优先顺序,初始化各虚基类(所有虚基类子对象都在非虚基类子对象之前初始化)。
- 按基类声明的从左到右顺序,初始化各直接基类。
- 以类定义中的声明顺序,初始化各个非静态成员。
- 执行构造函数体。
初始化列表中的初始化器的顺序不会影响初始化顺序。
17. 构造析构顺序
子类的构造函数先执行基类的构造函数,再执行函数体;析构相反。
如果没有提供初始化器,则成员进行默认构造;如果在初始化器列表中写明,则成员按初始化器列表中的方式进行初始化。先于构造函数体执行。
18. 多态
指针与引用都可实现多态。
调用虚函数时,访问权限取决于调用虚函数的对象的静态类型,而非动态类型。
比如:对于A* p
,只要 A 中的虚函数 f 声明是 public,就可调用p->f()
,即使子类用 private f 覆盖。
对虚函数的名字的访问规则,在调用点使用(用于代表调用该成员函数的对象的)表达式的类型进行检查。忽略最终覆盖函数的访问权限。
24. 无符号整数溢出
无符号整数溢出时会进行 mod \(2^n\),这是良好定义的。
25. 有符号整数溢出
有符号整数溢出是 UB。虽然大多数情况会回绕,但标准并没有规定。可见 C++ - 未定义行为。
26.
除0和模0都是 UB。
27. 多态
引用可以实现多态,所以定义使用引用为参数的 operator << 就可以实现 << 的多态。
28.
range for 等价于以下循环:
// range for
for (初始化语句(可选) 范围变量声明: 范围表达式)
循环语句
// 等价于
初始化语句
auto && __range = 范围表达式; // 用来确定首表达式和尾表达式
auto __begin = 首表达式; // 比如 begin(__range)
auto __end = 尾表达式; // 比如 end(__range)
for ( ; __begin != __end; ++__begin)
{
范围变量声明 = *__begin;
循环语句
}
所以每次循环都会创建一个变量,将其初始化为当前遍历到的值。如果声明的变量类型非引用,那每次初始化都要进行拷贝构造。
29. 多态
在构造函数或析构函数中调用虚函数时,不会考虑它的派生类中覆盖的虚函数,即使这个构造或析构是通过派生类间接调用的。
设 A 定义了虚函数 f,B 继承 A 并覆盖了虚函数 f。在调用 A 的构造函数时,不管它是不是通过 B 的构造函数间接调用的,执行虚函数 f 时都只会执行 A 定义的 f,不会考虑 B 覆盖的 f。 毕竟在调用 A 的构造函数时,B 和 B 的成员还没有被构造,调用 B 实现的 f 可能出问题。
析构函数同理,在调用 A 的析构函数时,B 和 B 拥有的成员已经被析构了,自然不能调用 B 实现的 f。
30.
X x()
是一条函数声明而非变量定义,见 31.。
31. most vexing parse
当一条语句可以被解析成函数声明时,会优先将该语句视为函数声明,即使该语句的意图不是函数声明。
由于函数的参数名字外可以加多余括号,因此T()
、T(x)
、T(((x)))
均可被视作参数声明(分别是 T 类型的匿名参数和 T 类型的参数 x),T t()
、T t(T())
均可被视作函数声明(前者是没有参数的函数,后者是有一个 T 类型匿名参数的函数,返回类型均为 T)。
因此推荐使用列表初始化 {} 而非直接初始化 ()。
32. 构造与赋值
在声明时初始化(包括直接初始化T a{x}
和复制初始化T a = x
)会调用构造函数,而非赋值运算符;在声明后再进行赋值才是调用赋值运算符。
33. 多态
同 8.,传值没有多态。
35. 列表初始化
简单来说,T 类型对象的列表初始化会依次进行如下检查:
- 如果 T 是聚合体:
- 如果列表包含指派初始化器,那么聚合初始化。
- 如果列表只包含一个相同类型或派生类型的元素,那么从该元素初始化对象。
- 否则聚合初始化。
- 否则,如果 T 是类:
- 如果列表为空,且 T 有默认构造函数,那么值初始化。
- 检查 T 是否有以 initializer_list 为唯一形参(或其它形参都有默认值)并且类型匹配的构造函数,如果有则调用;
否则如果 T 存在一个以 initializer_list为唯一形参(或其它形参都有默认值)的构造函数,并且初始化列表内的每个元素都可以非窄式隐式转换到 X(比如 const char* -> string),则调用;
否则尝试将初始化列表中的元素进行任意非窄化转换,直到匹配某个构造函数(如果是复制列表初始化,则不能选择到 explicit 的构造)(可见 129.)。
- 否则 T 不是类:
- 如果列表只包含一个元素,且 T 要么不是引用、要么是引用并且它引用的类型与列表中的元素类型相同或是它的基类,那么从该元素初始化对象(但不允许窄式转换)。
- 如果列表为空,那么值初始化。
vector<int> v1(1, 2);
是直接初始化,就是调用对应的构造函数vector(size_type count, const T& value, const Allocator& alloc = Allocator());
。
vector<int> v2{1, 2};
是列表初始化,所以优先选择以 initializer_list 为唯一(无默认值)参数的构造函数vector(std::initializer_list<T> init, const Allocator& alloc = Allocator());
进行初始化。
37. decltype
见 38.。
38. decltype
decltype((e)) 与 decltype(e) 的结果可能不同,当 e 为标识符表达式时(左值)前者为 T&,后者为 T。
规则:
- 如果实参是没有括号的标识符表达式或类成员访问表达式,那结果为以表达式命名的实体的类型。如果该表达式命名的实体不存在,或指向一组重载函数,则程序非良构。
- 否则(实参有括号,或不对应任何标识符)设表达式的类型为 T,
- 如果表达式值类别是亡值,则为 T&&。
- 如果表达式值类别是左值,则为 T&。
- 如果表达式值类别是纯右值,则为 T。
41.
E1[E2]
是一个一元表达式,等价于E2[E1]
和*(E1+E2)
。因此1["ABC"]
等价于"ABC"[1]
。
42. 列表初始化
根据列表初始化规则(见 35.),A a{}
会进行值初始化,A a{1}
、A a{1, 2}
都会优先调用以 initializer_list 为参数并且类型匹配的构造函数,不会先考虑其它构造函数。
44. 多态
将子类对象赋值给非引用类型的基类对象时,会发生切片,产生一个新的基类对象,它没有子类的那部分数据,也没有多态。
48. async
async 返回一个 future,它持有 async 创建的共享状态。
async(...) 是一个纯右值,当纯右值被用作弃值表达式时,会实质化创建一个临时对象。
临时对象的生命周期在整个表达式求值完结束(除非将其绑定到某个引用,但此处没有)。因此返回的 future 对象会在 async 语句结束后析构。
future 的析构函数会释放任何共享状态。这些操作不会阻塞等待共享状态变为就绪,但若以下条件全为真,则这些操作可能阻塞:
- 共享状态是由对 std::async 的调用创建的。
- 共享状态尚未就绪。
- 当前对象是到共享状态的最后一个引用。
实践中,仅当任务的运行策略为 std::launch::async 时这些操作才会阻塞,因为运行时系统选择这么做,或者说在 std::async 调用中规定如此。
因此在题目中 async 返回的 future 析构时,会阻塞直到 async 完成异步执行(相当于auto f = std::async(...); f.wait();
),所以对 x 的两次赋值会被顺序执行。
49. 临时量的生存期
临时量的生命周期在整个表达式求值完结束。
但是,一旦临时对象或它的子对象被绑定到某个引用,临时对象的生存期就被延续,以匹配引用的生存期(除了绑定到函数返回值、绑定到函数形参等例外)。因此C(1)
的生存期与 c 一致。
C(2); C(3);
是两个弃值表达式,当纯右值被用作弃值表达式时,会实质化创建一个临时对象,然后在表达式求值完销毁。
52. 友元函数
类和非成员函数的声明,不需要出现在它们的友元声明之前:如果一个名字的第一次出现是在友元声明中,会假定该名字在当前作用域是可见的。但成员函数不同,友元声明前,必须先知道该成员函数的声明。
105. goto
如果 goto 转移控制流后,离开了自动存储期变量的作用域(比如跳转到变量声明前的位置、跳出变量所在的作用域),那么会按与构造相反的顺序析构这些自动存储期变量。如果之后重新遇到了这些变量的定义,则重新构造。
此外,如果 goto 转移控制流时,直接进入了自动存储期变量的作用域(跳过了它们的声明),那么程序不能编译,除非这些自动存储期变量都满足以下条件之一:是标量类型,且声明不带初始化式;或是有平凡默认构造和析构的类类型,且声明不带初始化式;或是上述情况的 cv 限定版本;或是上述情况的数组。
106. 语言链接
直接包含在语言链接中的声明(即不包含在 { } 内),被视为它含有 extern 说明符,以此作为声明的名字的链接以及决定它是否是定义。使用 extern 并且没有初始化器的声明不是定义。
因此extern "C" int x;
等价于extern "C" { extern int x; }
,由于 x 没有初始化器,所以 x 只是声明非定义。
在整个程序中,被 ODR 使用的非内联函数或变量必须有且仅有一个定义。否则是 UB,但不要求诊断。
107. 求值顺序
列表初始化中,大括号内的每个初始化器子句的值计算和副作用,都早于出现在它后面的初始化器子句的值计算和副作用。
简单来说就是:列表初始化花括号内的语句执行顺序,一定是从左到右的。
109. 模板实参推导
lambda 的类型不能匹配任何function<void(T)>
,所以无法推导 T(当形参参与模板推导时,不会对实参进行隐式转换,不然也没法推导了)。
可以通过显式指定模板类型call<int>(...)
解决,也可通过建立不推导语境解决。两者都是让 lambda 对应的形参不参与推导、能够进行隐式转换。
用有限定标识指定的类型的嵌套名说明符(即 :: 左侧的所有内容)是不推导语境之一,所以type_identity_t<形参类型>
可以使该形参不参与类型推导。
111. continue
continue 的作用是跳转到循环末尾,在进行下一轮循环前依然要检查循环条件和执行迭代表达式(只是放在 do while 里不直观)。
112. 值类别
在构造函数中,没有在成员初始化列表中出现、且没有默认成员初始化器的基类和非静态数据成员,会被默认初始化。
所以B() {}
会默认初始化成员 a。
在B(B&& b)
中,b
是一个变量名构成的表达式,所以是左值(它的类型是右值引用和它是不是右值没有关系)。
所以B(B&& b): a(b.a) {}
中的b.a
是一个左值,会调用 A 的拷贝构造函数初始化 a。如果使用亡值std::move(b).a
,就可以调用 A 的移动构造了。
113. 重载决议 函数特化的匹配规则
可能同时存在重载的非模板函数与模板函数。选择要调用哪个函数的规则为:确定模板的实参类型,先找出能够完全匹配的模板和非模板函数(类型模板可以匹配任意类型,所以只要满足约束条件就可能生成最佳匹配);如果有多个,优先选择非模板函数,其次是最特化的模板函数,最后是无特化的(准确来说是最特殊的、可接收类型最少的模板函数最优,见 251.)。
题目中,对于f(0.0)
,模板 f 会产生特化f<double>(double)
,完全匹配实参,所以比非模板函数f(int)
更优。
对于f(1)
,模板 f 产生特化f<int>(int)
,和非模板函数f(int)
一样都是完全匹配,所以选择非模板函数。
f<>(1)
在调用时显式指定了模板实参列表(指定的实参数量可以少于形参数量,所以列表为空也可以;没指定的依然自动推导),所以只会选择模板函数。
114. const
指针的 const 影响的是指针的不可变性,而非其指向的对象的 const(属于 top-level const)。
这也适用于 shared_ptr 与 unique_ptr:get
、operator */&/[]
等方法是 const 的(不会修改 shared_ptr 本身),但返回非 const 的内部对象。
(引用本身就是不可变的,所以不能也没必要附加顶层 const)
115. 浮点字面量
如果浮点字面量没有后缀,则是 double 类型,否则类型取决于后缀:f F 是 float,l L 是 long double。
double 可以最佳匹配,所以不会有重载歧义。
116. 值类别 转发引用
对于一个函数模板 f,设它有一个类型模板形参 T,如果它的某个形参是 T 的无 cv 限定的右值引用类型,即 T&&,则该形参是转发引用/万能引用。
当形参是 T 的左值引用时(T&, const T&),无论实参是左值还是右值,推导出的 T 都不带引用(如 int);但当形参是转发引用(T&&)时,如果实参是左值,则 T 是左值引用(如 int&),否则也不带引用(如 int)。
因此在万能引用中,利用 T 可以进行完美转发:如果实参是 int 左值,则 T 是 int&,forward
118. 空指针常量
值为 0 的整数字面量是一个空指针常量,因此 0 可隐式转换为任何指针类型。
而 0 字面量本身是 int,因此对于实参 0,f(char*)
与f(int)
将匹配后者(最佳匹配),而f(char*)
与f(short)
会导致歧义,因为它们都需要一次隐式转换,并且它们涉及的整数转换与指针转换同级。
此外,char 到 int 的转换属于整数提升,而 char 到 short/long 的转换属于整数转换。
因此对于字符字面量实参,f(short)
与f(int)
将匹配后者,而f(short)
与f(long)
将产生歧义。
119. 变量定义
如果在变量定义时提供初始化器,那么在使用初始化器前,变量就已经定义,但没有进行初始化。
因此,使用包含变量本身的表达式 初始化该变量是可行的,虽然好像没什么意义,还很容易出错(可以说是很迷惑了)。
A* p = p; // 使用未初始化的指针 初始化一个指针
cout << p << '\n'; // 所以 p 是未初始化的
void* p {&p}; // 使用未初始化的指针的地址 初始化一个指针(&p 的类型是 void**,可以转为 void*)
cout << p << '\n'; // 所以应该能正常得到 p 的地址
A a = a; // 这里会调用 A 的拷贝构造,拷贝未进行任何初始化的 a 本身来初始化 a。。
120. 逗号运算符
逗号运算符是所有运算符中优先级最低的,包括赋值运算符。
121. 逗号运算符
逗号运算符表达式E1, E2
中,会先对 E1 进行求值并舍弃它的结果,然后求值 E2 并返回 E2 的结果。
122. 声明 声明说明符
一条简单声明语句的格式为:属性说明符序列(可选) 声明说明符序列 初始化声明符列表
。
比如:[[maybe_unused]] const static unsigned long x = 0;
。
声明说明符序列 (type specifiers, type-specifier-seq) 是以空白分隔的声明说明符的序列,顺序任意。
声明说明符包括:
-
typedef(用于 typedef 声明)。
-
inline, virtual, explicit(可用于函数声明)。
-
inline(C++17 起可用于变量声明)。
-
friend, constexpr, consteval, constinit。
-
存储类说明符。
-
类型说明符序列 (type specifiers, type-specifier-seq) 是类型说明符的序列,顺序任意。
类型说明符包括:- 类说明符、枚举说明符。
- 简单类型说明符。包括:所有的基础类型名、auto、decltype、声明过的类名/枚举名等。
- 详述类型说明符。
- typename 说明符。
- cv 限定符。
每个声明说明符序列中只允许有一个类型说明符且不能重复,除了:cv 可以与任意类型组合;(un)signed 可以与整数类型组合;short, long 可以与 int 组合;long 可以与 double 或 long 组合。
标准规定:在声明语句中,如果在解析声明说明符序列时遇到了一个类型名,那么只有在该类型名的前面没有任何除了 cv 以外的类型说明符时,它才被视作类型说明符的一部分;否则被视为该语句所声明的名字。
所以如果程序定义了类型名 T,那么声明语句const unsigned T
会将 T 作为被声明的名字(覆盖之前声明的 T),它的类型是 const unsigned;而const T unsigned
是一个声明说明符序列,它没有声明任何东西。
所以unsigned ll
是声明一个 unsigned 类型的对象 ll,ll unsigned
、long long unsigned
、unsigned long long
才是三个相同的类型。
注:cppref 的说法与标准有一点不同,标准将上面的所有类型说明符称为 defining-type-specifier,去掉类说明符和枚举说明符剩下的才是类型说明符 type-specifier。
类型名 (type-name) 包括:类名、枚举名、typedef 定义的别名、简单模板标识符 (simple-template-id,就是模板名加上合法的模板实参列表)。
因为顺序任意,以及 long/(un)signed 隐含 int,所以
long int const unsigned long
与const unsigned long long
是同一类型。
再放个例子:
typedef char* Pc;
{
static Pc; // error:Pc 是类型说明符,所以该语句没有声明任何东西
static int Pc; // ok:Pc 是一个 static int 类型的变量
}
void f(const Pc); // 即 void f(char* const)。注意不是 void f(const char*),这不是 #define
void g(const int Pc);
124. 模板默认实参
模板形参 T 可以是一个模板,也能指定它的默认模板参数。
传给 T 的模板实参的默认参数,取决于 T 的声明中有没有指定默认参数,如果 T 有则使用 T 的默认参数,如果 T 没有则它不含默认参数,即使模板实参本身有默认参数。
因此如果删掉函数 g 的模板形参 C 中的默认参数,会编译失败:不能使用C<>
,即使传给 C 的实参有默认参数。
125. 模板实例化
同一模板的不同实例化是独立的,自然有不同的 static 变量。
126. 静态成员定义时的名字查找
https://zh.cppreference.com/w/cpp/language/unqualified_lookup 见静态数据成员的定义
无限定的名字查找中,对于在静态数据成员的定义中所使用的名字,它的查找规则与查找成员函数中使用的名字相同(就是与在成员函数中使用非静态成员一样),即使这个定义可能在类外部。
struct X {
static int x;
static const int n = 1; // 第一个找到
};
int n = 2; // 第二个找到
int X::x = n; // x = 1
127. decltype lambda
当 decltype 的实参有括号时,结果取决于括号内部表达式的值类别(不管有多少个括号),j 是左值所以得到 int&。
此外这是在 lambda 内,值捕获不会改变类型,但 lambda(即 operator())默认 const,所以使用成员变量也会带 const 限定。
129. 列表初始化(易错)
在使用列表初始化 初始化 T 类型的对象时,如果 T 不是聚合体,则进行:
- 如果花括号初始化列表为空,且 T 有默认构造函数,进行值初始化。
- 检查 T 是否有以 initializer_list 为唯一形参(或其它形参都有默认值)并且类型匹配的构造函数,如果有则调用;
否则如果 T 存在一个以 initializer_list为唯一形参(或其它形参都有默认值)的构造函数,并且初始化列表内的每个元素都可以非窄式隐式转换到 X(比如 const char* -> string),则调用;
否则尝试将初始化列表中的元素进行任意非窄化转换,直到匹配某个构造函数(如果是复制列表初始化,则不能选择到 explicit 的构造)。
普通字符串字面量是 const char[N] 类型,因此{",", ";"}
是一个 initializer_list<const char*>。
vector
由于 vector 存在一个构造函数template <class InputIterator> vector(InputIterator first, InputIterator last)
,而 const char* 满足 InputIterator 输入迭代器的要求,并且恰好有两个,因此满足该重载的条件。
所以 vector 会将两个字符串的起始地址当做同一个字符串的起始位置和结束位置,进行复制,效果自然是 UB。
因为没能调用接收 init_list 的构造函数,所以vector<char> v = {"a", "b"};
和直接初始化vector<char> v = vector<char>("a", "b");
没区别,这样看错误就更明显了。
130. 模板中的名字查找
对于在模板定义中使用的非待决名,会在检查该模板的定义时,进行无限定的名字查找(这与普通函数一样,无法找到在当前定义后再声明的东西)。
对于在模板定义中使用的待决名,它的查找会推迟到确定它的模板实参的时候(即推迟到实例化时)(因此有可能找到在它定义后再声明的东西)。
131. explicit
复制初始化选择构造函数时,不会考虑 explicit 函数。
explicit 构造函数和 explicit 用户定义转换函数 只能用于直接初始化和显式类型转换,不能用于复制初始化和隐式类型转换。
(例外:在按语境转换中,如果期望的类型是 bool,那么依然会使用explicit operator bool()
进行隐式转换)
132. 函数的默认实参
函数的默认实参在每次调用时都会进行求值(如果使用到)。
133. 构造函数
同 16.,构造函数的调用顺序依次为:虚基类(按基类声明的从左到右、深度优先顺序)、基类(从左到右)、非静态成员(按声明顺序)、构造函数体。
在自定义的构造函数中,没有在成员初始化列表中出现、且没有默认成员初始化器的基类和非静态数据成员,会被默认初始化(即题目中的基类 ABC)。
拷贝或移动构造函数也是如此。因此如果想要在自定义的拷贝或移动构造函数中,拷贝或移动构造某个数据成员或基类,需要自己在初始化列表中写出,否则都会被默认初始化(即题目中的基类 ABC)。
如果用户没有自定义拷贝构造函数,且没有弃置隐式声明的拷贝构造函数,那么编译器会在使用它时生成一个默认的拷贝构造函数的实现,对于非联合体类,它会逐基类和逐成员地进行拷贝,也就是依次调用基类和成员的拷贝构造;对于联合体,它会通过类似 memmove 的方式复制它的对象表示。移动构造同理。
所以如果 D 的拷贝构造函数是D(const D&) = default;
,就会在拷贝时拷贝构造 ABC 了。
135.
mb 的 Key 是 bool,因此传入的 1, 3, 5 会隐式转为 bool,这属于数值转换:整数、浮点、无作用域枚举、指针和成员指针类型的纯右值,可转换成 bool 类型的纯右值。零值(对于整数、浮点和无作用域枚举)、空指针值和空成员指针值变为 false,所有其它值变为 true。
此外,在使用初始化列表初始化 map 时,如果列表中存在 key 相等的多个元素,那么哪个 value 被插入是未指明的。
140.
如果函数形参的类型是“T 类型的数组”或“函数类型 T”,则会将其替换成“T 类型的指针”。
因此这三个函数:size_t f1(int*)
、size_t f2(int[])
、size_t f3(int[10])
的形参类型都是int*
,函数类型都是size_t (int*)
,对形参使用 sizeof 得到的都是int*
的大小。
如果函数形参的类型是对数组的引用,则不会进行该替换,比如size_t g(int(&)[10])
的类型就是size_t (int(&)[10])
。
对引用使用 sizeof 时,结果是它所引用的类型的大小。
145. 复制消除
即使复制/移动构造有副作用,也会应用复制消除。这是极少违反 as-if rule 的情况。
148. volatile
因为:
- 运算符 + 的两个操作数的求值顺序是无顺序 (unsequenced) 的。
- 每次访问(读或写) volatile 类型的泛左值都会产生副作用。
- 如果某个内存位置上的一项副作用相对于同一个内存位置上的另一副作用是无顺序的,那么它是 UB。
所以a + a
存在两次对相同内存位置的无顺序副作用,所以是 UB。
151. char
char 是否有符号,是实现定义。
152. char
不管 char 是否有符号,char, signed char, unsigned char 始终是三个不同的类型。
153. 限定性转换
“指向 有 cv 限定的类型 T 的指针的纯右值”可以转换为“指向 有更多 cv 限定的同一类型 T 的指针的纯右值”。反之则不可(即可以给指向的类型加更多的 cv,但不能去除)。
由于"abc"
是 const char 数组可被隐式转换为const char*
,因此char* s = "abc"
这样的定义丢掉了 const,导致 CE。
157. typeid
typeid 表达式是左值表达式,指代一个具有静态存储期、const 类型的、多态类型 std::type_info 或它的某个派生类的对象。
不保证对同一类型上的 typeid 表达式的所有求值,都指代同一个 std::type_info 对象,只能保证这些 type_info 对象的 hash_code 和 type_index 相同。type_info 相关的东西都是实现定义的。
158. vector
vector 有一个单形参构造函数explicit vector(size_type n, const Allocator& = Allocator())
,它会在容器内原地构造 n 个值初始化的元素。
不过在 C++11 前,它会值初始化一个临时对象,然后从这个对象拷贝 n 次来初始化容器。
159. 求值顺序
任何参数表达式的求值和副作用,均按顺序早于被调用函数体内的每个表达式或语句。
160. 虚函数的默认实参(易错)
设 A 是 B 的基类,A *x = new B()
,则称 x 的静态类型 (static type) 为其直接指向的基类 A,动态类型 (dynamic type) 为其实际类型 B。
如果虚函数的参数有默认实参,则默认值取决于 x 的静态类型 A 中的定义,即使虚函数的实现取决于 B。毕竟默认实参需要在编译期确定,而动态类型只能在运行时确定。
比如:父类 A 有虚函数virtual void f(int a = 1)
,子类 B 覆盖虚函数virtual void f(int a = 2)
,则x->f()
会调用B::f()
,但参数的默认值来自A::f()
,是 1。
161. switch case
case、default 只是生成一个 label,用于控制跳转,不会影响后续代码的顺序执行和跳转(可参考生成的汇编。这和写 goto 类似)。
所以不加 break 时,程序可以执行多个 case 内的语句。这就是 Duff's device 进行循环展开的原理。
register n = (count + 7) / 8;
switch (count % 8) {
case 0: do { *to = *from++;
case 7: *to = *from++;
case 6: *to = *from++;
case 5: *to = *from++;
case 4: *to = *from++;
case 3: *to = *from++;
case 2: *to = *from++;
case 1: *to = *from++;
} while (--n > 0);
}
162. 模板中的名字查找
如果某个基类取决于某个模板形参,那么无限定名字查找不会检查它的作用域(不管是在定义处还是在实例化时),所以调用f()
时编译器找不到B<T>::f()
。
163. auto
auto 作为占位类型说明符推导类型时,与模板实参推导方式相同。
因此当 auto 不带引用时:对 T 的数组及其引用,auto 会得到 T*;对函数,得到函数指针;否则得到 remove_cvref_t
当 auto 带引用时,使用它引用的类型;此外当 auto 是转发引用时,如果表达式是左值,则得到左值引用。
174. 严格别名
quiz 解析里说的 const& 不是原因。
int 与 const int 类型兼容,因此编译器需要假设两个引用可能指向同一地址,在每次读取时进行访存。
如果将 const int& 改为 const double&,就会违反严格别名导致 ub。
177. char
char, signed char, unsigned char 的大小都是实现定义,标准只保证它们的大小一样,并且足够容纳实现所使用的字符集中的每个字符。此外还保证 sizeof(char)、sizeof(signed char)、sizeof(unsigned char) 的结果均为 1。
178.
最大吞噬 (maximal munch principle):词法分析器会读取尽量多的字符,来构成一个有效的 token,即使这可能导致后续的分析失败。
因此a+++++b
会被解析为a++ ++ +b
,a++
是纯右值,但后置自增运算符要求操作数是可修改的左值,所以会 CE。
其它的例子:
a+++++b;
// 正确:a++ + ++b; 多加空格避免被分析成 ++ ++ +
int m = n/*p;
// 正确:int m = n / *p;
void f(const char *= nullptr);
// 正确:void f(const char * = nullptr);
179.
const_cast 后的引用仍然指向原对象。修改 const 对象是 UB。
184. 无限定的名字查找
一个名字会被嵌套声明域或派生类中对相同名字的显式声明覆盖;基类的某个成员会被派生类声明的同名成员覆盖。
所以如果派生类中声明了某个名字,使用它时就不会再去基类找了。
如果派生类中没有,在基类中查找的规则见这里。
185. 模板
对于模板 f,只要传给 f 的模板实参不同,实例化出的模板对象就不同,即使它们可能有相同的函数类型。
不同的实例化有各自的静态变量。
186. 函数类型
数组和指针是两种东西,只是任何数组类型的左值或右值,都可以隐式转换为指向其首元素的指针右值(称为数组到指针的退化或数组到指针转换)。
所以可以向函数f(int*)
传一个int[]
,它会隐式转换成int*
。
在确定函数类型时,会将“T 类型的数组”和“函数类型 T”这两种参数类型替换成“T 类型的指针”类型,然后去除参数类型的顶层 cv。
所以void f(int array[])
的实际类型为void (int*)
,它的形参类型是int*
而非int[]
。
所以可以向函数f(int[])
传一个int*
,这不需要任何转换。
187.
复制初始化会调用构造函数,而非赋值(operator =)。
188. 字符串字面量
修改字符串字面量是 UB。
此外各个字符串字面量的存储是否重叠、求值相同的字符串字面量是否会产生不同的对象,都是未指明的。
190. 隐式转换
当某个语境使用了 T1 类型的表达式,但语境期望一个 T2 类型的表达式时,会进行隐式转换。当且仅当存在一个从 T1 到 T2 的无歧义隐式转换序列时,程序良构。
一次隐式转换为一个隐式转换序列,包含3部分:0或1个标准转换序列、0或1个用户定义转换、0或1个标准转换序列(如果存在用户定义转换)(具体见 1.)。
标准转换序列由4部分组成:0或1个值变换、0或1个数值提升或数值转换、0或1个函数指针转换、0或1个限定性转换。
用户定义转换包含0或1个非 explicit 单实参构造函数或非 explicit 转换函数。
A 没有重载 operator +,所以 a1 + a2 会使用内建加法运算符,它需要操作数满足:两个操作数都是算术类型,或一个操作数是完整定义类型的指针、另一个操作数是整数类型。(换句话说,对每个算术类型都存在可用的 operator +)
所以 a1, a2 会尝试通过隐式转换转为算术类型或指针和整数。这只能先进行用户定义转换,所以 a1, a2 会先转为 bool。然后尝试标准转换转为算术类型,因此 bool 会通过整数提升转为 int 纯右值,完成转换。
191. 语言链接
extern "C" 声明变量或函数具有 C 语言链接,即满足要与 C 程序进行链接的所有要求,可以与 C 编写的模块进行链接。
因此 C 单元可以调用 C++ 定义的 extern "C" 函数/变量,或链接这些 C++ 库。
想要与 C 进行链接,最基本的要求就是不会进行 name mangling。
比如:对于void func(int, int)
,gcc 为这个函数生成的符号名可能是_Z4funcii
;如果声明它是 extern "C",就不会改变它的名字而是直接使用func
作为符号名。
name mangling 生成的符号名字也会包含它所在的命名空间,以此区分不同命名空间的同名变量。
比如:对于命名空间 A 下的int x
,gcc 可能会生成_ZN1A1xE
作为它的符号名;对命名空间 B 下的int x
,gcc 可能会生成_ZN1B1xE
。
但 C 没有命名空间,所以编译产生的符号名也不会带命名空间,所以不同 namespace 下声明的同名 extern "C" 变量有着相同的符号,实际上是同一个变量。
192. 求值顺序
函数调用中,实参的求值顺序是未指明的,编译器可以以任意顺序求值每个参数,并且可以在再次求值同一表达式时选择另一种顺序。只能保证:它们之间是顺序不确定 (indeterminately sequenced) 的,即它们能够以任意顺序进行,但不会重叠/交错执行。
在 C++17 前这段代码应该是 UB:C++17 前实参的求值顺序是无顺序 (unsequenced) 的,即它们不仅能够任意顺序进行,还可以重叠/交错执行。C++17 才改为 indeterminately sequenced。
这也是经典问题了,比如foo(unique_ptr<A>(new A), unique_ptr<B>(new B));
在 C++17 前是 UB。具体可以看这里。
193. 代用记号
一些 C++ 运算符及标点需要 ISO 646 编码集外的字符,包括:{,},[,],#,\,^,|,~
。为了能够使用不存在这些符号的字符编码,C++ 为这些字符规定了代用记号 (alternative tokens)。也被称为双标符 (digraphs)。
C++17 前还有三标符 (trigraph),它会在分析注释和字符串字面量前就进行字符替换,所以容易出问题。
195. nullptr
nullptr 是 nullptr_t 类型的纯右值,可以隐式转换成任何指针或成员指针。
nullptr_t 是空指针字面量 nullptr 的类型,可以隐式转换成任何指针类型或成员指针类型,但它本身既不是指针类型也不是成员指针类型。
196. ADL
ADL:对于无限定函数的调用,C++ 不仅会在当前命名空间查找其定义,还会在它的实参类型所在的命名空间查找其定义(如果之前没找到)。
还可以找到隐藏的友元声明(hidden friend)。
197.
int i = j, j;
中在 i 进行声明和定义时,这个 j 还不存在,不会被找到。
198. 语言链接
同 191.,A::x
和B::x
实际上是同一个变量,但是这里的A::x
和B::x
都是对 x 的定义导致 redefinition,而 191. 中的A::x
和B::x
只是对 x 的声明。
直接包含在语言链接中的声明(即不包含在 { } 内),被视为它含有 extern 说明符,以此作为声明的名字的链接以及决定它是否是定义。使用 extern 说明并且没有初始化器的声明不是定义,所以 191. 中的extern "C" int x;
不是定义。
包含在extern "C" {...}
内部的声明不会自动附带 extern,因此extern "C" { int x; }
是一个定义。
extern "C" int x; // 声明不定义。外部链接
// 与 extern "C" { extern int x; } 等价
extern "C" int x = 0; // 声明并定义。外部链接
// 与 extern "C" { extern int x = 0; } 等价
extern "C" { int x; } // 声明并定义。无链接(该花括号不会建立作用域)
205. 窄式转换
列表初始化禁止(顶层的)窄式转换,其中包括:
整数/无作用域枚举 到 不能表示其所有值的整数类型的转换,除非来源是常量表达式,且转换后的类型可容纳它的值。
因此 100%3 的值转换到 uchar 不是窄式转换,程序良构。
206.
sizeof 表达式有三种格式:sizeof(类型标识)
、sizeof 一元表达式
(产生表达式的类型的字节数)、sizeof...(形参包)
。
(它们都是类型为 size_t 的一元常量表达式,因此sizeof sizeof(int)
合法,结果为8)
E1[E2]
是一个一元表达式,等价于E2[E1]
和*(E1+E2)
。因此0["abc"]
等价于"abc"[0]
。
207. map::operator[]
对于一个 map 或 unordered_map m,使用m[key];
时,等价于调用m.try_emplace(move(key))
:如果该 key 不存在,则插入pair(key, ValueType())
;如果该 key 存在,则不做任何事。
插入的 value 会被值初始化:对于类,如果有平凡的默认构造则零初始化,否则调用默认构造;对于非类,零初始化。
208. map::operator[]
部分求值顺序规则:
- 用运算符写法进行调用时,每个重载的运算符都会遵循它所重载的内建运算符的定序规则;
- 每个简单赋值表达式
E1 = E2
和每个复合赋值表达式E1 @= E2
中,E2 的每个值计算和副作用都按顺序早于 E1 的每个值计算和副作用; - 内建赋值运算符和所有内建复合赋值运算符的副作用,都按顺序晚于左右操作数的值计算(但非副作用)。
因为m[7] = C(1);
调用了 C 的 operator = 重载,所以它的求值顺序与使用内建赋值运算符时的顺序相同:先计算表达式C(1)
,再计算表达式m[7]
,再发生赋值。
计算表达式C(1)
时会执行C(int)
。
由于 map 的 operator [] 会在对应的 key 不存在时插入一个值初始化的 value,所以表达式m[7]
会调用C()
。
赋值则会调用operator =(const C&)
。
217. 条件运算符(易错)
条件运算符的表达式2 E2 和表达式3 E3 的类型和值类别理应都要相同,以确定整个表达式的类型和值类别。如果不同,则进行一系列转换来将它们变成相同的。
题目中 E2 是 int 左值、E3 是 int 纯右值,因此进行:
(6) 对第二和第三操作数应用左值到右值、数组到指针和函数到指针转换,然后,
(6.1) 如果 E2 和 E3 现在具有相同的类型,那么结果是该类型的纯右值,它的结果对象从求值 E1 后选择的那个操作数进行复制初始化(即进行临时量实质化)(C++17 前直接使用该纯右值指代一个临时对象)。
因此表达式的结果是纯右值,如果选择 E2 i,则会将 i 转为纯右值、然后进行复制初始化生成一个指代临时对象的亡值,它与 i 无关。
219. 模板实参推导
如果指定了模板实参,它就不再进行推导,所以 sum 中的 T 取决于最初的 T。
如果 sum 中没指定 T,则答案为 2.5 2,注意 sum(1, 0.5) 的返回类型是 int、返回值是 1(把 0.5 舍掉了)。
220. 条件运算符
条件运算符(三目运算符)中,第一个表达式的所有值计算和副作用,都早于第二或第三个表达式的值计算和副作用;第二和第三个表达式有且仅有一个会进行求值。
221. 引用
引用被初始化后,对它的修改是修改它引用的对象的值,而非修改它引用哪个对象。
222. variant
variant 的默认构造会保存首个类型值初始化后的值。因此如果想进行默认构造,需保证第一个类型可默认构造。
224. 纯虚函数
可以在类外给出纯虚函数的定义,只要纯说明符= 0
和函数定义不同时出现在声明中即可。
即使给出了定义,类也还是抽象类、不能创建实例,但是抽象类和派生类的成员函数可以通过有限定的函数标识 调用抽象基类的纯虚函数。
在抽象类的构造/析构函数中虚调用纯虚函数(不加限定的直接调用)是 UB。
struct Abstract {
virtual void f() = 0; // 纯虚
virtual void g() {} // 非纯虚
~Abstract() {
g(); // OK:调用 Abstract::g()
// f(); // UB
Abstract::f(); // OK:非虚调用
}
};
void Abstract::f() {
std::cout << "A::f()\n";
}
struct Concrete : Abstract {
void f() override {
Abstract::f(); // OK:调用纯虚函数
}
void g() override {}
~Concrete() {
g(); // OK:调用 Concrete::g()
f(); // OK:调用 Concrete::f()
}
};
使用基类的指针或引用调用虚函数,都属于虚调用。
225.
声明变量时,允许附带额外的括号,所以int(a)
、int(((a)))
都可以声明变量 a。
但是这会与函数风格转换T(expr)
产生歧义。因此规定:如果T(expr)
可以被视为声明,则视为声明,而非转换。
226. mutable(易错)
如果用户声明了某个函数,并且没有在首次声明时标记为 default 或 delete,那么它就是一个用户提供的函数 (user-provided)。
struct A {
// 两个都不是用户提供的,但都是用户声明的 (user-declared)
A() = default; // = default/delete 就是函数定义
A(const A&) = delete;
};
struct B {
// 两个都是用户提供的
B();
B(const B&) {...}
};
B::B() = default;
当存在用户声明的拷贝构造、拷贝赋值、移动赋值、析构函数中的任何一个,或存在用户提供的移动构造时,编译器不会再为类生成隐式定义的移动构造函数(类似的表述是:声明这些函数会抑制移动构造的生成;声明这些函数会使默认的移动构造函数被隐式弃置)。
题目中,Y 声明了Y(const Y &) = default;
,所以 Y 没有隐式定义的移动构造也就是没有移动构造,对于Y y2 = std::move(y1)
,重载决议只能选择调用拷贝构造。
Y(const Y&) = default;
是由编译器定义的拷贝构造函数,对于非联合体类,它会逐基类和逐成员地进行拷贝,也就是依次调用基类和成员的拷贝构造,即调用 x 的拷贝构造。
设 Y 类对象 y 拥有 mutable 的 X 类成员 x,则不管 y 或 *this 有没有 const 限定,成员 x 都不会有 const 限定,所以 x 能够被修改,并且 y.x 会优先匹配类型X&
而非const X&
。
227. 函数类型别名
定义函数类型的别名后(如using F = int()
。注意这不是函数指针),可以通过该别名声明一个对应类型的函数或成员函数,但不能同时给出定义。
using F = int();
F g; // 函数声明
int main() {
cout << g();
}
int g() {
return 2;
}
228. 模板
如果变参模板的每个合法的特化,都要求传入空形参包,那么程序非良构,不要求诊断。
https://timsong-cpp.github.io/cppwp/n4861/temp.res#8
229. lambda
如果变量满足下列条件之一,那么 lambda 表达式在使用它前不需要先捕获:
- 该变量是非局部变量,或具有静态或线程局部存储期。
- 该变量是以常量表达式初始化的引用。
如果变量满足下列条件之一,那么 lambda 表达式在读取它的值前不需要先捕获:
- 该变量有 const 且非 volatile 的整型或枚举类型,并已经用常量表达式初始化。
- 该变量是 constexpr 的且没有 mutable 成员。
捕获列表只能显式捕获自动存储期变量,不能也不需要捕获静态存储期的变量(即题目中的 a)。
230. 位域
不能对位域使用取地址符 &,因此不存在到位域的指针。
此外非 const 引用不能用位域初始化。如果某个 const 引用通过位域初始化,则该引用会绑定到一个与位域的值相同的临时量上,并不会引用位域。
231. 关键字
final, override, import, module 不是关键字,它们是拥有特殊含义的标识符,可以用作对象或函数的名字,但在特定语境拥有特殊含义。
当 override 和 final 出现在非静态成员函数定义中的声明符 (declarator) 后时,会被视作虚声明符序列;除此之外它们也是一个普通的标识符。
233. 函数类型
每个函数都有一个类型,它包括:函数的返回类型,所有形参的类型(要先进行数组到指针和函数到指针的转换,然后去除顶层 cv 得到最终的形参类型),是否为 noexcept(C++17 起),(对于非静态成员函数)是否有 cv 限定和引用限定。
因此成员函数指针 ptr 的类型是int(X::*)() const&&
。
235. initializer_list
initializer_list
通过其它对象创建 initializer_list 时,需要将对象拷贝或移动到它的底层数组中。比如initializer_list<Node> i{node};
需要将 node 拷贝到指定区域。
236. 转换函数
用户定义转换函数中的转换类型可以使用 auto 和 decltype(auto),会像普通函数一样根据返回语句进行推导。
但当转换函数是模板函数时,不能使用 auto。
238. 隐式转换
operator !
期望接收一个 bool 值,但参数""
是 const char[N] 类型,因此会进行隐式转换。
如 190. 所述,一次隐式转换为一个隐式转换序列,包含3部分:0或1个标准转换序列、0或1个用户定义转换、0或1个标准转换序列(如果存在用户定义转换);标准转换序列包含4部分:0或1个值变换、0或1个数值提升或数值转换、0或1个函数指针转换、0或1个限定性转换。
值变换可以将 const char 数组隐式转为 const char* 指针类型的纯右值,数值转换可以将指针类型的纯右值隐式转为 bool 类型的纯右值。因此""
会隐式转为 bool 类型的纯右值,得到一个 true。!!true 还是 true。
如 365. 所述,几乎所有内建算术运算符(包括一元加),都会对算术类型操作数(整数和浮点,包括 bool)进行一般算术转换,这包括整数提升。整数提升时,如果提升的类型 T 不是位域、char8/16/32_t 和宽字符,则如果它的整数转换等级低于 int,如果 int 可表示 T 的所有值,转为 int,否则转为 uint。
因此 bool 纯右值会转为 int 纯右值,true 会转为 1。
239. 异常捕获
发生异常时,会按照 catch 语句的顺序依次尝试捕获异常。只要捕获成功,就不会再尝试后面的 catch,即使它们可能更匹配。
241. 模板
使用模板时,可以从左到右显式指定任意数量的模板实参,指定的模板实参不会再进行推导,未指定的依然进行推导。
如果传递给函数的实参与指定后的模板实参不匹配,则会尝试隐式转换。
当函数形参参与推导时,不会对传入的实参进行隐式转换;形参不参与推导时,可以对实参进行隐式转换,来满足函数形参的要求。
242. 左值右值 转发引用
同 116.。
243. 模板实例化
除非显式特化或实例化,模板只会在使用该模板的某个特化且“需要有完整定义的类型或类型的完整性会影响程序语义”的上下文中被隐式实例化。换句话说,只使用模板某个特化的指针或引用并不会导致模板实例化,自然也不会检验模板实例化后的定义和 static_assert。
244. 模板
模板的合法性可以在进行实例化之前就进行检查:如果满足以下条件之一,则程序非良构且不要求诊断:
- 模板的任何特化都不合法,且程序没有实例化过该模板。
- 不存在一组模板实参,能够使模板的类型约束和 requires 子句是一个合法的表达式。
- 变参模板的每个合法的特化,都要求传入空形参包(228.)。
- 存在一组模板实参,使得:如果在模板定义后立即用这组实参进行实例化,程序会因为一个与模板参数无关的构造变成非良构。
(a hypothetical instantiation of a template immediately following its definition would be ill-formed due to a construct that does not depend on a template parameter.)
比如:定义模板类 Btemplate <int N> class B { static constexpr int a = B<1>::a; };
,即使后面显式特化了B<1>
,也会因为“在 B 定义后立即实例化任意模板都是非良构”使程序非良构。 - the interpretation of such a construct in the hypothetical instantiation is different from the interpretation of the corresponding construct in any actual instantiation of the template.
不是很确定,但从第一点和第四点都可以得知程序非良构,所以是 UB。
248. min/max 返回 const 引用
std::min/max 返回 const T& 而非数值,且在各参数大小相同时返回第一个参数的引用。所以const int &m = max(a, b)
定义的 m 值会随着 a/b 变化。
249. 引用初始化规则
![](https://img2024.cnblogs.com/blog/1143196/202404/1143196-20240426015243725-116358324.png)
根据引用初始化规则,将int
赋值给const char&
时,由于两者不是引用相关、且不是位域、函数和类,所以会通过该int
隐式创建一个 char 临时量,绑定给该引用。因此原int
与该引用无关,修改也不会影响。
见 C++ - 引用初始化。
250. 重载决议
跟在形参列表之后的省略号是 C 风格的变长实参,也叫省略号形参,允许函数接受任意数量的额外实参:
template <class T>
void foo(T x...) {} // 同 foo(T x, ...),x 接收一个实参,其它实参会被放到省略号形参中,通过 va_list 获取
void bar(int x...) {} // 同 bar(int x, ...)
模板形参包允许函数模板接受任意数量的模板实参,它通过省略号在模板形参列表中定义,可以在函数形参列表中使用包名... 形参名(可选)
来接收任意数量的函数实参:
template <class... T>
void foo(T... x) {} // T 是一个形参包,x 是这个形参包的名字,展开 x 就可以得到所有实参
下面称使用变长实参的template<typename T> void foo(T, ...)
为 foo1,使用形参包的template<typename... T> void foo(T...)
为 foo2。
同 1.,重载决议分为三步:建立候选函数集合;从该集合去除函数,只保留可行函数;分析可行函数集合,确定唯一的最佳可行函数。
首先考虑foo(1)
:
-
确定候选函数:编译器找到 foo1、foo2 两个函数模板,然后进行模板实参推导。因为实参只有一个 int,所以它们都会推导出 T = int,所以
foo1(int, ...)
、foo2(int)
都是候选函数。 -
确定可行函数:对于
foo1(int, ...)
,根据可行函数的规则:如果有 M 个实参且候选函数的形参多于 M 个,但是第 M+1 个及以后的所有形参都具有默认实参(变长实参自然也算),那么它可行;对于剩余的重载决议,形参列表被截断到 M。所以 foo1 是可行函数,后面会使用foo1(int)
忽略省略号实参。
foo2(int)
显然也是可行函数。 -
确定最佳可行函数:因为只有1个实参,且 ICS1(foo1) 与 ICS1(foo2) 相同,所以要看其它规则。
这里的相关规则为:如果 F1 与 F2 都是模板特化,且按照模板特化的偏序规则,F1 更特殊,那么 F1 优于 F2。- 首先为 foo1 设定一个虚构类型 X 得到实参模板
foo1(X)
(省略号形参处没有实参,所以忽略),然后以foo2(T...)
为形参模板进行推导,即X
为实参,T...
为形参,得到 T = int。 - 然后为 foo2 设定一个虚构类型 Y 得到实参模板
foo2(Y...)
,然后以foo1(T, ...)
为形参模板进行推导,即Y...
为实参,T, ...
为形参。从类型进行模板实参推导时,如果这是在计算偏序,并且 A 是一个形参包,则要么不存在一个对应形参包的实参,要么实参也是一个形参包,否则推导失败。这里实参是 T 不是形参包,所以推导失败。
综上 foo1 的实参可以从 foo2 的形参进行推导,但反过来不可,所以 foo1 更特殊,选择 foo1。
- 首先为 foo1 设定一个虚构类型 X 得到实参模板
模板特化的偏序规则:为确定任意两个函数模板中哪个更特殊,偏序处理首先对两个模板之一进行以下变换:对于每个类型、非类型及模板形参,包括形参包,生成一个唯一的虚构类型、值或模板,并将其替换到模板的函数类型中。(此处不是所有的类型都进行该处理,取决于当前语境 (#5)。当语境是函数调用时,只会考虑显式写出的实参对应了的那些形参类型,不会考虑其它有默认实参的形参、形参包、省略号形参)
在按上方描述变换两个模板之一后,以变换后的模板为实参模板,以另一模板的原模板类型为形参模板,执行模板实参推导。然后以第二个模板(进行变换后)为实参,以第一个模板的原始形式为形参重复这一过程。
推导开始前,以下列方式对形参模板的每个形参 P 和实参模板的对应实参 A 进行调整:...(差不多就是忽略 P, A 的引用与 cv)。在这些调整后,遵循从类型进行模板实参推导规则,从 A 推导 P。
如果变换后的模板 1 的实参 A 可以用来推导模板 2 的对应形参 P,但反之不可,那么对于从这一对 P/A 所推导的类型而言,这个 A 比 P 更特殊。在考虑每个 P 与 A 后,如果对于所考虑的每个类型,
- 对所有类型,模板 1 至少与模板 2 一样特殊。
- 存在某些类型,模板 1 比模板 2 特殊。
- 对所有类型,模板 2 都不比模板 1 更特殊,或者更不特殊。
那么模板 1 比模板 2 更特殊。
对于foo(1, 2)
:
- 确定候选函数:同样对 foo1、foo2 两个函数模板进行模板实参推导。实参是两个 int,对 foo1 推导出
foo1(int, ...)
,对 foo2 推导出foo2(int, int)
,都是候选函数。 - 确定可行函数:如果有 M 个实参,那么刚好具有 M 个形参的候选函数可行。所以两个都是可行函数。
- 确定最佳可行函数:有2个实参,ICS1(foo1) 与 ICS1(foo2) 都是 int->int 相同,但是 ICS2(foo1) 是省略号转换序列
int->...
,ICS2(foo2) 是标准转换序列int->int
,前者的优先级低于后者,因此 foo2 更优,选择 foo2。
251. 模板特化 重载决议
重载决议本身不会考虑显式特化,只会考虑主模板:假设函数模板 A 有一个显式特化 S,那么 S 不会影响 A 的重载;只有重载决议选择了 A,才会根据推导出的模板实参决定是调用特化 S 还是用非特化。
(只有非模板和主模板重载参与重载决议。特化并不是重载,因此不考虑。只有在重载决议选择最佳匹配的主函数模板后,才检查它的特化以确定最佳匹配)
重载决议开始时,会对两个函数模板进行模板实参推导,得到f<int*>(int*)
与f<int>(int*)
,它们都是可行函数,因此要决定它们谁是最佳可行函数。
根据规则(可以见 2.),多个函数模板特化会按照模板特化的偏序规则决定谁更特殊,最特殊的优先度最高(这里的过程与特化无关,只看模板本身)。
简单来说,如果函数模板 A 能接收的类型比函数模板 B 更少,那 A 比 B 更特殊。f(T)
能接收的类型显然比f(T*)
更多,因此重载会选择后者f<int>(int*)
。(也可以同 250.,分别为两个模板设立一个虚构类型然后互相推导,f(T)
作为形参时可以推导出f(T*)
作为实参,但反之不可,所以f(T*)
更特殊)
然后要确定显式特化f<>(int*)
是对f(T)
还是对被重载决议选择的f(T*)
的显式特化:如果是前者,则没有影响输出3;如果是后者, 则会调用显式特化的实现输出2。
由于显式特化必须在非特化模板的声明后出现,所以只有在显式特化之前出现的模板,才可能是它的主模板。因此f<>(int*)
只能是对f(T)
的特化,不会考虑f(T*)
。
如果将显式特化放在f(T)
和f(T*)
的后面,是选择f(T*)
作为主模板进行特化?不想看了,不知道有没有这块的说明:https://eel.is/c++draft/temp.spec#temp.expl.spec
252. 字符字面量
编码可分为两种:
- 源码字符集:源码文件用什么编码保存。
- 执行字符集:可执行程序内保存的字符串是什么编码。
普通字符字面量的类型是 char,它的值等于该字符在执行字符集中的表示,而程序使用的执行字符集是实现定义的(只是通常是 UTF-8)。
但是,标准保证空字符的值是0,并且数字字符'0'
之后的每个十进制数字字符的值都比上一个数字字符的值大1(不管是在源码字符集还是执行字符集)。因此虽然'3'
的值是实现定义,但'3'-'2'
一定是1。
char8_t、char16_t、char32_t 则明确规定了使用 UTF-8/16/32 作为执行字符集。
254. 函数类型
同 233.,计算函数类型时,会先对形参类型进行数组到指针和函数到指针的转换,然后去除顶层 cv 得到最终的形参类型。
259. 一般算术转换 整数提升(易错)
如 365. 所述,几乎所有内建算术运算符(如二元加、小于),都会对算术类型操作数(整数和浮点,包括 bool)进行一般算术转换,这包括整数提升。
因此 char 会进行提升,如果 int 可表示 char 的所有值,转为 int,否则转为 uint。但 char 有无符号是实现定义,所以转为 int 还是 uint 也是实现定义。
261. stringstream
字符串流 basic_stringstream 是一个支持流输入/输出的字符串,相当于存储了一个 basic_string 并能够流输入/输出到该字符串。
实现上它包含一个 basic_stringbuf 成员作为底层的字符串缓冲区,并通过这个 basic_stringbuf 来初始化基类 basic_iostream 以提供对流输入/输出的支持。basic_iostream 内保存了输入位置指示器(输入指针)和输出位置指示器(输出指针)来记录每次读/写应该从缓冲区的哪个位置开始,在输入/输出时会更新对应的指针,与文件读写一样。
所以对 basic_stringstream 的输入/输出就是通过 basic_iostream 对 basic_stringbuf 进行输入/输出,它们的打开模式是一样的:默认为 in | out 可读写,输出指针会位于缓冲区的起始位置,所以写时会从起始位置开始写,覆盖原内容;ate 会在打开后将输出指针定位到结尾;app 会在每次写入前将输出指针定位到结尾。如果想在输出时不覆盖原内容,需要用 ate 或 app 打开,或者在输出前用 seekp(pos) 修改输出指针的位置。
stringstream::str() 会生成 stringbuf 所保存的字符串的副本,输出或修改它不会对缓冲区产生影响。
264. const 对象的默认初始化
如果程序调用 const T 类型的对象的默认初始化,那么 T 必须是可 const 默认构造的类或它的数组。
T 是可 const 默认构造的,当且仅当:T 的默认初始化会调用用户提供的、不是从基类继承的构造函数;或满足以下所有条件:
- T 的每个直接非变体、非静态数据成员 M 均拥有默认成员初始化器,或如果 M 拥有类类型 X(或它的数组),X 可 const 默认构造。
- 如果 T 是至少拥有一个非静态数据成员的联合体,那么刚好有一个变体成员拥有默认成员初始化器。
- 如果 T 不是联合体,那么对于每个至少拥有一个非静态数据成员的匿名联合体成员(如果存在),刚好有一个非静态数据成员拥有默认成员初始化器。
- T 的每个潜在构造的基类均可 const 默认构造。
题目中 C 的默认构造函数不是用户提供的,成员 i 也没有默认成员初始化器,所以不可 const 默认构造。将默认构造改为用户提供的,或者为 i 提供初始化器都可以解决。
联合体式的类 (union-like class) 是一个联合体,或是一个至少拥有一个匿名联合体成员的类。
如果它是前者,则称它的非匿名联合体的非静态数据成员为它的变体成员 (variant member)。
如果它是后者,则称类的匿名联合体成员中的非静态数据成员为它的变体成员。
265. 值类别
内建的取地址表达式是纯右值表达式,毕竟它只是一个值,不指代任何对象。
273. 不求值表达式
以下操作数是不求值操作数,它们不会被求值:
- 作为 sizeof 运算符的操作数的表达式。
- decltype 说明符的操作数。
- ...
除了不求值操作数、不求值操作数的子表达式,其他表达式都潜在求值。
所以sizeof new A
中的new A
不会进行求值。
275. 类型大小
每个实现关于基础类型的大小所做的选择被统称为数据模型。
因此,基础类型的大小是 implementation-defined 的,标准只规定了一部分,比如:int 至少是 16 位的、int 的大小一定小于等于 long。
见 C++ - 数据模型。
276. 函数返回类型推导
如果返回类型是 auto,那么推导按照模板实参推导规则进行;如果是 decltype(auto),则使用对 return 表达式应用 decltype 得到的类型。
如果函数有多条返回语句,那它们必须推导出相同的类型。因此返回类型一定是第一条返回语句的推导结果,它可被用于推导后续的返回语句;如果第一条返回语句使用了未推导的类型,则会CE。
277. 字符串字面量
普通字符串字面量(无任何前后缀)的类型是 const char[N](并不是 const char*,只是可以隐式转成 const char*),所以它的大小为 N * sizeof(char)。
每个字符串字面量的最后会被添加一个空终止字符\0
,所以""
的类型是 const char[1],大小为 sizeof(char)。
虽然 char 的大小不确定,但标准规定 sizeof(char)、sizeof(signed char)、sizeof(unsigned char)、sizeof(std::byte)、sizeof(char8_t) 的结果均为 1,所以""
的大小为 1。
278. tuple
std::get<T>(t)
要求类型 T 在 tuple t 中恰好出现一次,否则编译失败。
279. variant
variant 的默认构造会保存首个类型 T 值初始化后的值:如果 T 是类,如果它有隐式定义的默认构造函数(即编译器生成的),则零初始化,否则默认初始化(调用默认构造);如果 T 是数组,则值初始化每个元素;否则零初始化。
281.
当存在用户声明的拷贝构造、拷贝赋值、移动赋值、析构函数中的任何一个,或存在用户提供的移动构造时,编译器不会再为类生成隐式定义的移动构造函数。
C() {}
和C(const C&) {}
都是用户声明且用户提供的,所以编译器不会为 C 生成移动构造函数。
根据引用初始化规则,const 左值引用可以绑定到亡值,所以C(const C&)
也可以接受 C 类型的右值表达式进行拷贝构造。
283. 对象销毁顺序
类似栈变量的释放顺序,delete[] 一个数组时与构造它时 (new T[]) 的顺序相反,按照地址从高到低的顺序析构。
284. string
对于 basic_string 的 operator [] 和 at,标准规定在 pos == size 时,结果返回到拥有值 CharT() 的字符(空字符)的引用。pos > size 时才是 ub。
此外,虽然标准没有规定,但为了能常量时间转为 const char*,string 实现中其末尾通常会有空终止字符。
286. 一般算术转换 整数提升(易错)
如 365. 所述,几乎所有内建算术运算符(包括乘法运算符),都会对算术类型操作数(整数和浮点,包括 bool)进行一般算术转换,这包括整数提升。整数提升时,如果提升的类型 T 不是位域、char8/16/32_t 和宽字符,则如果它的整数转换等级低于 int(比如 short),如果 int 可表示 T 的所有值,转为 int,否则转为 uint。
unsigned short 和 int 的大小都是实现定义,标准只保证它们至少是16位。这里只考虑 short 是16或32位、int 是32或64位的情况。
如果 ushort 是16位,int 是32位,那么 int 可以表示 ushort 的所有值,所以 ushort 在运算时会转为 int,由于乘法计算的结果是\(2^{32}\)会有符号溢出,所以是 UB。
如果 ushort 是32位,int 是32位,那么 int 不能表示 ushort 的所有值,所以 ushort 在运算时会转为 uint,在计算时不会溢出,就算无符号溢出了也没事。
如果 ushort 是16/32位,int 是64位,那么 ushort 在运算时会转为 int,并且不会溢出。
类似的问题:在 C++20 前,下面的代码也是 UB,因为有符号负数左移是 UB,右移是实现定义。C++20 就正常了。
unsigned short x = 5;
auto y = -x<<5;
287.
string 接收 const char* 的构造函数和接收其它 string 的构造函数形式不一样:
// 复制 s 指向的字符串的前 count 个字符
basic_string(const CharT* s, size_type count,
const Allocator& alloc = Allocator());
// 从另一个字符串的 pos 位置开始复制
basic_string(const basic_string& other, size_type pos,
const Allocator& alloc = Allocator());
288.
内建逻辑运算符中的 && 和 || 都是短路求值:保证从左向右求值;如果求值第一个操作数后就可以确定表达式的结果,就不再求值第二个操作数。
内建逻辑运算符的操作数和返回值都是 bool。如果操作数不是 bool 就隐式转为 bool。
289. 函数默认参数,lambda
默认参数不需要是常量,它在每次函数调用时,都会进行一次求值。见 函数 - 函数的默认参数。
每个 lambda 表达式会生成一个唯一的类。如果其内部有 static 变量,则每次调用某个 lambda 都会使用相同的 static 变量。
291. 保留字
双下划线__
开头、单下划线+大写字母开头_Xxx
、全局命名空间中使用单下划线开头::_xxx
的标识符是保留字。使用保留字作为标识符是 UB。
293. argv
标准保证 argv 指向的数组大小至少是 argc+1,且 argv[argc] 是一个空指针。
295. 临时量的生存期
第一次 print 的是栈变量 s1,没什么问题。
第二次 print 的是 s2。make_string()
表达式本身是纯右值,这里会因为成员访问实质化成亡值、产生一个临时对象,临时对象的生命周期会在整个表达式求值完后结束。
因此在定义和初始化完 s2 之前,使用make_string()
这个临时对象及其成员是合法的;但定义完 s2 之后,临时对象就会析构,再使用临时对象及其成员就是 UB。这里 s2 指向的是静态存储期变量 a 而非临时对象的成员,所以没问题。
第三次 print 的是临时对象。临时对象的生命周期会在整个 print 调用完成后才结束,所以也没问题。
其它容易出错的情况可以看这里(主要就是两种情况:return 语句中绑定到函数返回值的临时量的生存期不会被延续;在函数调用中绑定到函数形参的临时量,存在到含这次函数调用的全表达式结尾为止)。
296. 聚合体
同 307.,S 是聚合体,对 S 进行列表初始化会进行聚合初始化,顺序初始化聚合体中的每个元素;没有被显式初始化的元素会使用默认成员初始化器(如果有)或空初始化器列表初始化该元素(即值初始化。该元素不能是引用)。
305. 求值顺序
函数调用中,所有形参之间初始化的值计算和副作用的顺序是不确定的(未指明但非未定义)。并且在 C++17 前,它们可能交错进行;C++17 起保证不会交错。
307. 聚合体
聚合体 (aggregate) 包含两种类型:数组,符合下面条件的类:
- 没有用户提供、继承或 explicit 的构造函数(C++20 前);没有用户声明或继承的构造函数(C++20 起)。
- 没有虚函数、没有虚基类。
- 所有基类都是 public。
- 所有非静态数据成员都是 public。
当 T 是聚合体时,对 T 进行列表初始化会进行聚合初始化,顺序初始化聚合体中的每个元素;没有被显式初始化的元素会使用默认成员初始化器(如果有)或空初始化器列表初始化该元素(即值初始化。该元素不能是引用)。
S() = delete;
是用户声明但非提供的构造函数,所以在 C++20 前,S 是聚合体,即使弃值了默认构造还是能进行聚合初始化;C++20 起,S 不是聚合体,会调用弃置的构造函数S()
导致 CE。
类似的还有:即使private: S() = default;
是用户声明的私有构造函数,但在 C++20 前它也是聚合体,即使默认构造私有也还是能进行聚合初始化;C++20 起改了聚合体的定义就正确了。
这个改动在:https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1008r1.pdf
struct S {
private:
S() = default;
};
S s1; // 不可
S s2{}; // C++20 前可
312.
基类有三种继承方式:
- public 公开继承:该访问说明符之后列出的基类的公开和受保护成员在派生类中保持它的成员访问,即不改变可访问性。
- protected 受保护继承:该访问说明符之后列出的基类的公开和受保护成员在派生类中是受保护成员,即 public 成员在派生类中会变为 protected 成员。
- private 私有继承:该访问说明符之后列出的基类的公开和受保护成员在派生类中是私有成员,即 public, protected 成员在派生类中会变为 private 成员。
不管怎样,派生类都不能访问基类的私有成员。
313. 引用初始化规则(易错)
g 的形参是一个模板形参包,在调用g(1.0f, 2)
时,会展开形参包、生成特化void g<float,int>(float&& v1, int&& v2) { f(v1), f(v2); }
。
(f(v), ...)
是一个一元右折叠表达式,如果有两个元素,则展开后为f(v1), f(v2)
。
所以问题在于f(v1)
和f(v2)
分别调用哪个重载。
注意函数f
的形参是右值引用,而v1
、v2
两个表达式都是左值,类型分别为 float 和 int。
根据引用初始化规则(见 249.):假设一个cv1 T1
类型的引用,被一个cv2 T2
类型的表达式初始化,如果 T1 与 T2 引用相关,那么必须满足:T2 必须拥有等于或少于 T1 的 cv 限定;并且如果引用是一个右值引用,那么初始化表达式不能是左值。
如果f(v1)
要选择f(float&&)
,那么引用的类型是float&&
,初始化表达式为float
左值,两者引用相关,且引用是一个右值引用,且初始化表达式是左值,违反了引用初始化的规则,所以f(v1)
肯定不能调用f(float&&)
。
如果f(v1)
要选择f(int&&)
,那么同 249.,可以将初始化表达式v1
隐式转换为int
纯右值,将引用绑定到该int
纯右值实质化后的亡值上。
因此f(v1)
不能匹配f(float&&)
,但可以匹配f(int&&)
(虽然这个 int&& 引用没有绑定到 v1 上),导致了可能与预期相反的输出。
同理,f(v2)
不能匹配f(int&&)
,但可以匹配f(float&&)
。
总之,左值不能绑定到同类型的右值引用上,但可以将隐式转换生成的其它类型的亡值,转换到对应类型的右值引用上。
如果再加个f(double&&)
,那么f(v1)
还是优先匹配f(int&&)
,而f(v2)
会因为f(float&&)
和f(double&&)
重载优先级一致导致 CE。
318. 转换函数
可以定义 转换到相同类型(或它的引用,可有 cv 限定)、转换到基类(或它的引用)、转换到 void 的转换函数,但它们不会在转换过程中被考虑。
但如果转换函数是一个虚函数,那它可以通过基类被调用。它们也可以被显式调用。
struct D;
struct B {
virtual operator D() = 0;
};
struct D : B {
operator D() override { return D(); } // 转换到相同类型的转换函数
};
int main() {
D obj;
D obj2 = obj; // 不会调用 D::operator D()
B& br = obj;
D obj3 = br; // 通过虚派发调用 D::operator D()
D obj4 = obj.operator D(); // 显式调用 D::operator D()
}
319. 隐式转换(易错)
如果条件运算符E1 ? E2 : E3
中,表达式 E2 和 E3 的类型或值类别不同,则会尝试转换 E2 或 E3来让它们相同,以确定整个表达式的类型和值类别。
题目中 E2 和 E3 的类型不同,分别是 A 和 B,所以会尝试通过隐式转换将 A 转为 B,或将 B 转为 A(两者必须有且仅有一个成立,且使用的隐式转换序列不能有歧义)。
隐式转换中包含一次用户定义转换,可以进行1次非 explicit 单实参构造函数或非 explicit 转换函数的调用(可见 190.)。
因此A{}
可以通过非 explicit 构造函数B(const A&)
隐式转换成 B 类型的纯右值,来满足条件运算符的要求。条件运算符表达式的结果和 lambda 的返回类型是一个 B 类型纯右值。
因此在 flag 为 true 时,会创建一个 A 对象,然后调用B(const A&)
转为 B 对象返回。
323. 栈展开
函数返回或抛出异常时,每个尚未析构的自动存储期对象,会按照和构造相反的顺序进行析构,这个过程叫栈展开(stack unwinding,或者叫栈回溯?)。
因此整个过程为:构造完成返回对象 A{c} 后,函数开始进行栈展开(栈展开完成后才算成功返回),析构 b,析构 y,触发异常。由于构造顺序为 a, y, b, c,且 y, b 已析构,因此先析构 c、再析构 a。
因为栈展开被中断,所以函数并没有返回,会进入 catch,然后构造对象 A{d} 并返回后才真正完成返回。
332. []
两个连续的方括号只能出现在 引入属性说明符时 和 属性实参中。
(此外,属性实参列表是一个平衡记号序列 (balanced-token-seq)。平衡记号序列 就是那个常见的 合法的括号序列,这个名字太怪了)
int y[3];
y[ [] { return 0; }() ] = 1; // 错误
int i [[ cats::meow([[({})]]) ]]; // OK
所以 operator [] 里面就别写 lambda 了。
333.
虽然x --> 0
长得很像 x 趋近于0,但并没有-->
这个运算符。
335. 空指针
NULL 是一个实现定义的空指针常量的宏定义,因此可能是一个值为 0 的整数字面量,也可能是 nullptr_t 类型的纯右值。
当值为 0 时,其类型为 int,同 118. f(void*)
与f(nullptr_t)
会产生歧义,因为都要一次同级别的隐式转换。
337. decltype auto
decltype 对于无括号的标识符表达式和其它情况(非标识符表达式,或有括号的标识符表达式)的判断是不同的,前者会获取对应实体的类型,后者则会根据表达式值类别决定返回类型。
然后要注意字符串字面量是左值,其它类型字面量是纯右值。
因此由于"hello"
是 const char[6],所以decltype("hello")
为const char(&)[6]
。
auto 的推导规则与模板实参推导一致,因此传入数组实参时,数组会退化得到 T*,所以decltype(a)
为const char*
。
338. 模板
同 244.,如果存在一组模板实参,使得:如果在模板定义后立即用这组实参进行实例化,程序会因为一个与模板参数无关的构造变成非良构,则程序非良构,不要求诊断。
在定义 f 后,如果实例化f<double>()
,则会因为返回的 string 无法转换到 int 非良构,所以这个程序是非良构的,UB。
339. promise
调用 future 的 get 前,必须保证 valid() 的结果是 true,即 future 必须指代共享状态,否则为 UB(大多数实现会在这种情况下抛出 future_error 异常)。
get 会通过调用 wait 等待,直到共享状态准备就绪后,获取共享状态中存储的值,然后释放自己的共享状态。
因此在调用 get 前 valid() 必须为 true,在调用后 valid() 会变为 false;同一个 future 不能被 get 两次。
340. promise
如果 promise 无共享状态或已调用过 get_future,则再调用 get_future 会抛出异常。
347. 模板实参推导
推导模板实参时,如果形参带引用(如T&
),那么使用形参引用的类型进行推导,对foo(T&)
就是使用 T 进行推导,对bar(const T&)
就是 const T。
对于foo(i)
,实参是 const int 所以推导 T 为 const int。
对于foo(j)
,实参是 int 所以推导 T 为 int。
对于bar(i)
,实参是 const int 所以推导 T 为 int。
对于bar(j)
,实参是 int,怎么推导都不能使 const T 与 int 相同。
如果推导不能得到与实参相同的形参类型,则考虑如下规则:如果形参 P 是引用,那么推导出的实参 A(即 P 引用的类型)可以比 A 有更多的 cv 限定;...。因此我们可以让 T 是 int,推导出 const int 与 int 匹配。
350. function
function 是一个对可调用对象 callable 的包装,其类型为template <typename R, typename... Args> class Function<R(Args...)>
,包含一个成员R operator()(Args... args) const
,它会将传递给 function 的 Args 类型的实参通过std::forward<Args>(args)...
转发给 callable 调用。
所以定义 function 时的模板类型,是它的 operator () 的返回值和参数的类型,并非保存的可调用对象的返回值和参数类型,两者不需要完全匹配。function 能接收的实参类型取决于 function 的定义:只要传入的实参能匹配 Args...,且 Args... 和 Ret 均能隐式转换到可调用对象的参数类型和返回值类型,就不会编译错误。
写一个 function 就清楚了。
354. exit
执行 exit 后,会先析构线程局部对象,然后析构静态存储期对象,然后调用通过 atexit 注册的函数,再进行其它清理。
它不会回溯栈,不会析构自动存储期对象。
当 main 函数因为 return 语句或到达函数尾部而返回时,会析构自动存储期对象,再执行 exit 并将返回语句的实参(默认0)作为 exit_code 传递。
360. 复合类型
复合类型 (compound type) 包括:数组、函数、对象指针、函数指针、成员对象指针、成员函数指针、引用、类、联合体、枚举和上述类型的 cv 限定。
除了数组类型,复合类型复合的那个类型上的 cv 限定,与复合类型本身有没有 cv 限定无关。比如const int*
类型不是 const 的,但指向的类型有 const;int * const
是 const 的(这个 const 称为 top-level const),但指向的类型没有 const。
数组类型被视为与它的元素类型有相同的 cv 限定。
因此:
const int*
:非 const 的指针类型,指向 const int。const int [1]
:数组类型,元素类型是 const int,数组本身也是 const 的。const int **
:非 const 的指针类型,指向指针。const int (*)[1]
:非 const 的指针类型,指向一个 const 的数组。const int *[1]
:数组类型,元素类型是 const int*,数组不是 const 的。const int [1][1]
:数组类型,元素类型是 const int [1],数组元素和数组本身都是 const 的。
365. 一般算术转换 整数提升(易错)
无符号整数的上下溢是良好定义的,但这题和它没关系。
几乎所有内建算术运算符(如二元减、小于),都会对算术类型操作数(包含各类整数和浮点,包括 bool、各类 char)进行一般算术转换:先对操作数进行整数提升,然后转换到公共类型(算术运算符不接受小于 int 的类型作为实参)。
整数提升时,如果提升的类型 T 不是位域、char8/16/32_t 和宽字符,则如果它的整数转换等级低于 int,如果 int 可表示 T 的所有值,转为 int,否则转为 uint。
所以对于实现定义的类型,整数提升的结果也可能是实现定义的:unsigned short 在sizeof(short) < sizeof(int)
时会转为 int,0 - 1 得到 -1;在sizeof(short) = sizeof(int)
时转为 uint,0 - 1 得到 max。
所以也确实该用 uint16 等明确类型?
很久以前的奇怪但现在依旧成立的签名
attack is our red sun $$\color{red}{\boxed{\color{red}{attack\ is\ our\ red\ sun}}}$$ ------------------------------------------------------------------------------------------------------------------------