CPP思维导图
编译和链接
从符号表深入理解C++ hook的妙用基础类型
数值类型转换
一个是long double(double,float),另外一个提升
否则都是整数,都是有符号或者无符号的,按级别进行提升
否则一个有符号一个无,无符号级别大于等于有符号,转换为无符号
否则一个有符号一个无,无符号级别小于有符号,转换为有符号
总结下来:
- 涉及符号,且级别相同,按无符号提升
- 否则按级别高提升
引用类型转换(TODO,缺右值引用)
const &
类型匹配右值: 指向右值,延长它的生命周期
类型不匹配右值:能转换会转换为临时值,否则报错
类型匹配左值: 可以,如果没有const为其转换
类型不匹配左值,能转换会转换为临时值,否则报错
临时值的作用域只存在于这个被传参的函数内,不能出作用域
总结:
类型匹配
左值,添加const
右值,延长生命周期
类型不匹配
能转换临时值就转换
注:临时值的作用域只存在于这个被传参的函数内,不能出作用域
&
类型匹配右值: 报错
类型不匹配右值:报错
类型匹配左值: 可以
类型不匹配左值,如果是派生类到基类转换可以,否则报错
总结:
左值
类型匹配
类型不匹配(只能派生类到基类)
右值不行
强制转换
static_cast
const_cast
dynamic_cast
只能把指向派生类的基类指针,转向派生类,否则返回nullptr
reinterpert_cast(不推荐)
原生指针
数组指针
int (p*)[10]
short tell[10];
p1 = tell;p2 = &tell;
p1是指向数组第一个元素的指针
类型是short
p2是指向数组的指针,表示一整个数组是一块
类型是short (*)[10]
因此&tell + 2 = p2 + 2是将地址加20
因为数组的地址和数组第一个元素的地址是一样的,因此p1和p2值是一样的
指针数组
int *p[10]
函数指针
void (*p)()
函数指针数组
void (*p[5])()
函数指针数组指针
void (*(*p)[5])()
const
变量
const优先作用左边,左边没东西就作用于右边
const int* const
:- 第一个
const
右边有int
,所以它修饰int
。 - 第二个
const
左边有一个*
,所以它修饰指针。 - 结论:这是一个常量指针,指向一个常量整数。不可改变指针本身所指向的地址,也不可通过指针改变其指向的内容。
- 第一个
int const *
:const
左边有int
,所以它修饰int
。- 结论:这是一个指针,指向一个常量整数。不可通过该指针改变其指向的内容,但可改变指针本身所指向的地址。
int* const
:const
的左边是*
,所以const
修饰指针。- 结论:这是一个常量指针,指向一个整数。可以通过指针改变其所指向的内容,但只能指向该地址,不可指向别的地址。
const int* const
:- 第一个
const
的右边有int
,所以修饰int
。 - 第二个
const
的左边有*
,所以修饰指针。 - 结论:这是一个常量指针,指向一个常量整数。不可改变指针本身所指向的地址,也不可通过指针改变其指向的内容。
- 第一个
int const * const
:- 第一个
const
左边有int
,所以修饰int
。 - 第二个
const
左边有*
,所以修饰指针。 - 结论:这也是一个常量指针,指向一个常量整数。不可改变指针本身所指向的地址,也不可通过指针改变其指向的内容。
- 第一个
成员函数
const对象只能调用const成员函数
函数后的const表示只调用不改变任意数值的重载函数,一旦改变任何值
编译器就会报错,解决这个报错的办法是mutable关键词
返回值
引用不能返回局部变量
如果一定要返回一个函数内变量的引用,返回一个作为参数传递给函数的引用
返回值对象
1
2
3
4
5
6
7X func() {
X x;
return x;
}
int main() {
X xs = func();
}普通的返回对象需要三次构造函数:
- 局部变量x的默认构造
- x到临时变量的复制构造
- 临时变量到xs的复制构造
RVO(Return Value Optimization):
- 局部变量的默认构造
- x到xs的复制构造
NRVO(Named Return Value Optimization):
- xs的默认构造
参数名称查找又叫ADL
诸如 func(a, b, c)
这样, 命名不带 ::
作用域运算符的函数, 在 C++ 中被称作是无限定的(unqialified)。当 C++
代码通过无限定名称引用函数时,编译器会搜索匹配的函数声明。而这其中,会有一个令人意外(并且和其他语言不一样)的情况是,除了调用词法的范围,搜索范围还囊括了一些与函数参数类型关联的命名空间。这个额外的查找,被称为实参依赖查找(argument-dependent
lookup,ADL)。ADL
一定也会发生在您的代码中,因而您最好对其原理能够有基本的理解。
名字查找
首先,会先执行名字查找(name lookup),在一些范围内,搜索到一些名字匹配的重载。接着,执行重载决议(overload resolution),尝试从名字查找中获得的结果中,找到一个最合适的。
当遇到无限定的函数调用时,函数名可能会对应几组独立的搜索结果,每组搜索结果都会尝试将函数名字与重载进行匹配。最明显的一个搜索过程是从调用点的词法范围开始向外搜索:
1 | namespace b { |
这里的名字查找过程,还不涉及到
ADL(毕竟func()
没有参数)。它只是从函数调用的位置向外搜索,从本地函数作用域(若有),到类作用域,所属类作用域和基类(若有),然后再到当前命名空间作用域,再到所属的命名空间,最终到达全局
::
命名空间。
1 | namespace b { |
我们很容易陷入一种错误的理解:对于表达式
func(s)
,应当跳过明显不对的
b::internal::func(int)
,并应该继续向外查找,直到找到
b::func(const string&)
。
然而,名字查找完全不考虑参数类型。它只会找到一个叫做的 func
的东西,并在 b::internal
就停止,将这个“明显不好”的评估,留到重载决议阶段。至于外侧的
b::func(const string&)
,甚至没有机会进入重载决议的候选中。
作用域搜索顺序给我们带来的重要启示是,在搜索顺序中较早出现的作用域中的方法重载,将会隐藏掉之后出现的作用域下的方法重载。
ADL
如果函数调用中有传递参数,则会启动更多并行的名字查找。这些额外的查找,会关注每个函数调用的每个关联的命名空间。与词法范围名字查找不同,这些依赖于参数的查找不会遮蔽作用域范围。
词法名称查找的结果和所有 ADL 合并在一起,形成最终的函数重载集合。
1 | namespace aspace { |
这里会启动两个名字查找,来解析对 func(a)
的调用。词法范围名字查找 从 bspace::test()
的本地函数作用域开始。此作用域没有找到
func
,于是进入命名空间 bspace
的作用域,并在其中找到 func(int)
后停止。ADL
的另一个名字查找,从参数 a
关联的命名空间开始。
这种情况下,只有命名空间 aspace
。此次查找找到
aspace::func(const aspace::A&)
并停止。因此,重载决议有两个候选,分别是词法名字查找中得到的
bspace::func(int)
和单个 ADL 查找中的
aspace::func(const aspace::A&)
。在重载解析中,func(a)
的调用解析为
aspace::func(const aspace::A&)
。bspace::func(int)
重载与参数类型不匹配,因此被重载决议拒绝。
最佳实践
常用于operator <<
跨命名空间打印数据,以及Copy-and-swap模式
这个模式不应该滥用,否则只有编译器才知道发生了什么
重载决议
https://zh.cppreference.com/w/cpp/language/overload_resolution
重载决议开始前,由名称查找选择和模板实参推导选择函数,组成候选函数的集合。
根据最佳可达函数规则选择函数
最佳可达函数规则:
- 完全匹配,非模板优于模板
- 提升转换
- 隐式转换
- 用户定义转换
lambada
构造期间不要泄漏this指针(二段式建立回调)
最好捕获weak_ptr
友元
访问基类方法需强转
尽量不用,除非是<<>>或者测试代码
类
运算符重载
中括号运算符一般要提供两个重载
运算符重载限制
至少一个类,不能违反原来句法,不能创建新运算符
不能重载
sizeof
|.
|.*
|::
|?
|:
|typeid
|4个cast
= () [] -> 只能通过成员函数重载
可交换性下选择
友元+隐式转换,代码量少
友元+重载,性能高
特殊成员函数
默认构造,默认析构,复制构造,赋值运算符,移动构造,移动赋值(为了解决复制构造无法分辨左值和临时值,导致复制行为降低性能的问题)
构造函数
初始化顺序依赖变量声明顺序
析构函数
会被继承要写成虚函数
默认成员函数生成规则
https://blog.csdn.net/LeoLei8060/article/details/139505849
https://accu.org/conf-docs/PDFs_2014/Howard_Hinnant_Accu_2014.pdf
EffectiveModernCpp的条款十七:理解特殊成员函数的生成
默认构造函数
会在任意用户定义的构造函数(普通构造,拷贝构造和移动构造)后不再隐式生成
复制语义
在用户定义移动语义后,隐式delete
以下标准中建议不太重要,遵循rule of 3就行:
在用户定义了析构函数后,标准建议应该rule of 3在用户定义了任何一个复制语义后,标准建议应该定义另外一个
移动语义
在用户定义了rule of 3的成员(析构或者复制语义),就不再隐式生成(也就是对其move其实会被拷贝)
不仅取决于用户显式定义
编译器是否隐式定义某函数,不仅仅取决于用户显式定义了什么,还与类成员对应类型和基类是否支持对应函数有关。比如基类不支持默认构造,那么派生类的默认构造函数会被标记为delete。
复制/移动语义的模板构造函数不会被视为真正的构造函数
即使存在复制/移动语义的模板构造函数,依然会隐式声明默认的复制/移动语义构造函数
例1,模板复制构造函数没被调用:
1 |
|
编译结果
1 | /root/cpp_test/temp.cpp: In function ‘int main()’: |
这是因为在C++标准中:
[class.copy]
A non-template constructor for class X is a copy constructor if its first parameter is of type
X&
,const X&
,volatile X&
, orconst volatile X&
and either there are no other parameters or else all other parameters have default arguments (8.3.6).一个类X的非模板构造函数是复制构造函数,如果其第一个参数是类型X&,const X&,volatile X& 或者const volatile X&,并且没有其他参数,或者其他所有参数都有默认参数。
所以复制构造函数的定义是包含了非模板构造函数的(赋值构造同理)
所以依然会隐式声明复制构造函数,由于定义了移动语义的构造函数,隐式声明复制构造函数被delete了
相当于
1 | struct Obj { |
而重载决议的候选函数,是忽略了可见性(有一个情况例外,见例3)的,因此被删除的拷贝构造函数在重载决议中胜出,并且导致了编译失败
例2,模板复制构造函数被调用:
改一点就能让编译通过
1 |
|
编译通过,运行输出
1 | T& |
这是因为非const的模板复制构造函数在重载决议中胜出,因此编译成功,并且被调用
例3,模板移动构造函数被调用:
1 |
|
编译通过,运行输出
1 | T&& |
虽然复制构造函数会让默认的移动语义构造函数不再生成,但是在重载决议中
Defaulted move constructors and move assignment operators that are defined as deleted are excluded in the set of candidate functions.
这里说被delete,是包含了不再生成的情况的,因此重载决议中去掉了不可用的移动语义函数
使得模板移动函数被正确编译并且调用
PS:如果这里手动加上Obj(Obj&&) = delete;
会编译失败,就和例1的情况一样了
小结
复制/移动语义的模板构造函数不会被视为真正的构造函数,依然可以隐式生成对应的复制/移动构造函数
在重载决议的规则中,当其他条件完全一致(例如都是const &)时,非模板会高于模板,因此会出现即使可用的模板复制构造函数,也会导致编译失败的情况
但是模板移动构造函数不会造成这种情况(这也是为了兼容性考虑吧)
模板构造函数还有副作用:EffectiveModernCpp的条款二十六:避免在通用引用上重载
参考资料:
https://blog.csdn.net/heroesjun/article/details/48226769
n3337-12.8/6
A member function template is never instantiated to produce such a constructor signature.
exceptional c++ item5
iso-1998-12.8/2
https://www.cnblogs.com/springlie/archive/2012/11/14/template-copy-constructor.html
本文的结论大致正确,除了重载函数选择(Overload resolution)动作要先于存取权限检查(access checking),移动语义不遵循这个规则
https://stackoverflow.com/questions/22164789/template-constructor-cannot-be-selected
三/五/零法则
任何自己定义的构造函数(普通构造,拷贝构造和移动构造)都会阻止默认构造函数的生成
开发人员决定编写一个构造函数,也许他们不想要默认的构造函数
rule of 0(任何版本):
有自定义析构函数、复制/移动构造函数或复制/移动赋值运算符的类应该专门处理所有权(这遵循单一责任原则)。其他类都不应该拥有自定义的析构函数、复制/移动构造函数或复制/移动赋值运算符
这条法则也在 C++ 核心指南(C++ Core Guidelines)中出现—— C.20:一旦可以避免定义默认操作就应当施行。
rule of 3(C++98):
如果某个类需要用户定义的析构函数、用户定义的复制构造函数或用户定义的复制赋值运算符,那么它几乎肯定需要全部三者。
因为如果开发人员花时间编写其中一个,那么该类的资源管理一定有些复杂
rule of 5(C++11):
如果某个类需要用户定义的移动语义,那么它几乎肯定需要全部五者。
C++ 98没有强制要求rule of 3,C++11为了兼容旧代码,在rule of 3和rule of 5中取得了一个均衡,部分情况下才会删除默认成员函数
移动语义没有兼容性问题,可以部分应用rule of 5,所以标准设定了一旦有移动语义就默认delete掉复制语义(但是没有delete掉析构函数)
多态基类的特殊成员函数
基类必须自己声名一个虚析构函数,因此会导致移动语义全部被删除,但是没有删除复制语义
问题是多态基类的复制方法有可能会导致不小心的复制,使得只按基类去拷贝,导致派生类的部分被截断掉
根据Cpp核心指南:C.67: 多态类应当抑制公开的移动/复制操作
并且应该生成一个Clone函数,用于派生类继承后复制
Copy-and-swap模式
当使用rule of 5进行资源管理时,一旦新增一个需要管理的成员,就需要修改全部5个函数,很容易漏改
进行代码复用的方式就是Copy-and-swap模式
核心的3个函数是
1 | class foo { |
实现copy
也就是复制构造函数
实现swap:
这里的swap方法使用了参数依赖查找又叫ADL的特性,使用
using std::swap
将其引入当前作用域的名称查找然后参数first.p也会从他的作用域开始进行名称查找,最后合并结果进行重载决议。
std::swap
会是兜底选项赋值函数copy-and-swap:
赋值函数的参数是按值传递,在这里进行了copy
赋值函数调用了swap,从而完成了copy-and-swap过程
这里的赋值函数同时实现了赋值构造和移动赋值构造
所以只需要实现剩下的3个特殊成员函数:移动构造函数,默认构造,析构函数
1 | foo(foo&& other) { |
其中移动构造函数是swap的简单封装
默认构造和析构函数涉及到初始化和反初始化,独立于copy-and-swap的部分
PS:这里有一个重点是析构函数需要完整的清理干净数据,否则移动构造可能会出坑(other被移动过后指针没有值nullptr)
参考资料:
https://cpppatterns.com/patterns/copy-and-swap.html
https://stackoverflow.com/questions/19841626/move-assignment-incompatible-with-standard-copy-and-swap
普通函数
成员函数地址公有,空指针可调用
返回值:
返回类内部const对象引用
返回类内部const对象智能指针
类的局部对象在其他线程会被销毁,那要返回一个const智能指针而不能是引用
成员变量
const &成员变量不能配合const &构造函数使用,会因为临时值导致失效
nocopyable类保存只能使用引用类型
继承
私有继承和多重继承不推荐,略
虚表
cpp primer 504
对象添加隐藏成员,指向函数地址数组的指针,称为虚函数表。
每个类共享同一个虚函数表,存储了为类对象进行声明的虚函数地址。
如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址。
(这一页有图,记忆图会清晰很多)
虚函数的成本
1 每个对象会增大,增大虚表指针
2 每个类会创建一个虚函数地址表(数组)
3 每个函数调用,需要执行额外的操作(至少两次解地址)
方法隐藏(不推荐,理由在下面)
无论参数列表,将隐藏所有同名方法(基类和派生类的构造函数相当于同名方法),C++11可用using拉上来
例外:返回类型是基类引用或指针,可以改为派生类(返回类型协变)
基类有重载的方法被隐藏,那需要重写定义所有重载版本
公有继承
当遇到“是一个”场景的时候,使用继承,是增加了一部分函数或者实现了一部分接口
当遇到“有一个”或者“用一个。。来实现”场景的时候,使用组合
不要重新定义继承来的非虚函数,如果遇到这种场合,说明不应该“是一个”,而是“有一个”
不要反向转换继承关系,应该在基类提供一个虚函数来解决反向转换
模板
类型
- 函数模板
- 类模板
- 类型模板
- 别名模板
其中类型模板和别名模板可以独立,也可以在函数模板和类模板中使用
模板参数类型
- 类型模板参数
- 模板模板参数
- 非类型模板参数
SFINAE
SFINAE (Substitution Failure Is Not An Error) 是 C++ 中的一种高级模板编程技术,其核心原则是:在模板参数替换过程中如果发生错误,这种错误不会导致编译失败,而是简单地导致该模板特化被忽略。这使得编译器可以继续查找其他可能的模板特化或重载函数。
函数模板应用
函数模板test判断类型模板参数是否是Foo类型,是的话输出Foo,不是的话什么都不做
1 | template <typename T, |
成员函数模板应用
1 | template <typename T> |
二阶段查找
为什么需要
1 | template<typename T> |
在主模板的#1
处,x
的类型为一个enum
。在全特化模板的#3
处,x
是一个int
的alias。因此在#2
处的有限定的待决名Trap<T>::X
在Trap<T>
未实例化时无法被决议。如果T
是一个void
,则该语句生命了一个int*
,如果T
是其他类型,则该语句为一个乘法运算。在#2
的只能确定该标识符是一个有限定的待决名,其他都不可知。只有等到Trap<T>
实例化以后,我们才能对其进行决议。
因此两阶段查找是用来解决模板特化时依赖的模板参数还未知的问题的,通过分成两阶段:
- 模板定义阶段:刚被定义时,只有模板中独立的名字(可以理解为和模板参数无关的名字)参加查找
- 模板实例化阶段:实例化模板代码时,非独立的名字才参加查找
两阶段查找导致的报错
1 |
|
这里的 B 本身是模板,需要进行二段式名字查找。
首先进入 B 的模板定义阶段,此时 B 的基类 A<T>
依赖于模板参数 T,所以是一个「非独立」的名字。所以在这个阶段,对于 B
来说 A<T>
这个名字是不存在的,于是
A<T>::f()
也不存在。但此时这段代码仍旧是合法的,因为此时编译器可以认为 f
是一个非成员函数。
当稍晚些时候进入 B 的模板实例化阶段时,编译器已经坚持认为f
是非成员函数,纵使此时已经可以查到
A<T>::f()
,编译器也不会去这么做。
「查非成员函数为什么要去基类里面查呢?」于是就找不到了。
那我们回过头来看 this->f()
:
模板定义阶段:尽管没法查到 A<T>::f()
,但明晃晃的
this->
告诉编译器,f 是一个成员函数,不是在 B
类里,就是在 B 类的基类里,于是编译器记住了
模板实例化阶段:此时编译器查找的对象是一个「成员函数」,首先在 B
中查,没有找到;然后在其基类里查,于是成功找到
A<T>::f()
,功德圆满。
特化
全特化
explicit(full) specialization
定义位置
C++03-C++11:必须在类外定义
C++17:可以在任何地方定义
n3376-14.7.3/2:An explicit specialization shall be declared in a namespace enclosing the specialized template
https://stackoverflow.com/questions/49707184/explicit-specialization-in-non-namespace-scope-does-not-compile-in-gcc
偏特化
partial template specialization
组合默认模板参数实现类型判断
1 |
|
实例化
默认模板参数
可变模版参数
...
总是跟在类型或者表达式的后面:
1 | template <typename... T> |
可变模板参数函数
递归函数方式展开参数包(不推荐)
1 | void print() {} //递归终止函数 |
逗号表达式展开参数包(C++17以前推荐)
1 | template <class T> |
(printarg(args), 0)
会依次执行,整个表达式返回右边的值也就是0
而int dummy[] = {...}
利用了初始化列表,对表达式进行展开
这个用法的print函数解耦了展开逻辑,逻辑更清晰,这个用法和C++17以后相当接近了
可变模板参数类(很少用到)
递归+偏特化展开
可变模板参数类是很少用到的语法特性,因为一般都在tuple还有其周围组件都实现好了
integer_sequence
例如integer_sequence,可以用make_index_sequence<3>
创建出一个index_sequence<0, 1, 2>
的类型
1 | template <int... data> |
这里的依赖关系如下
1 | my_make_index_sequence_imp<3>::type |
apply用法
最常见的就是用于实现apply
1 | template <typename Func, typename Tuple, int... S> |
用过创建一个index_sequence
,就可以用来进行std::get<S>
的可变模板参数展开
通用用法
从这个场景可以发散开,用于编译器遍历tuple的任何参数
例如传入的tuple的每一个元素都是一个pair,要把pair中第二个参数组成一个tuple
1 | template <typename Tuple, int... S> |
或者是遍历tuple的元素,打印到ostream里面
1 | template <typename T> |
递归+偏特化+继承展开
tuple
tuple的实现就是继承展开的
例如tuple<A, B, C>,实际上是
1 | struct tuple<A, B, C> : tuple<B,C> {A value}; |
因此很容易想到tuple的实现大致如下
1 | template <typename... Args> //和非继承展开不同,由于需要继承这个类自己,所以需要先定义出这个类来 |
正如注释里提到的那样,继承会更严格,所以需要定义好主体类参数typename... Args
而不能使用主体类参数<typename T, typename... Args>
,然后对其偏特化
可以对比my_make_index_sequence_imp的主体类参数发现区别
my_tuple_element
这个是用来取出某个下标元素的
1 | template <int index, typename... Args> //和非继承展开不同,由于需要继承这个类自己,所以需要先定义出这个类来 |
例如my_tuple_element<2, tuple<A, B, C>
,实际上是
1 | struct my_tuple_element<2, tuple<A, B, C> : my_tuple_element<1, tuple<B,C>> {}; |
可以发现这个实现和my_typle是类似的,通过引入一个额外的类my_tuple_element,消耗index的同时消耗tuple内参数
最后得到想要的元素
用这个就可以实现常见的get方法了,具体思路就是得到了具体tuple_type后,利用继承关系转换类型到指定tuple<...>,从而获取到它的value
1 | template <int index, typename... Args> |
参考资料
可变模板参数的实现多种多样,我的tuple实现是自己原创的,也有别的如下:
https://www.cnblogs.com/qicosmos/p/4325949.html
https://zhuanlan.zhihu.com/p/715025973
模板类和友元
非模板友元(不推荐)
约束友元
友元的类型取决于模板类实例化的类型
每个友元的实例化一一对应于每个模板类的实例化
必须类外声明,类内显式实例化
1 | template <typename T> class Test; |
非约束友元
每个友元的实例化可以对应于多个模板类的实例化
在友元需要调用同一个模板类的多个实例化时很方便
可以像成员模板一样类内声明,不需要类内显式实例化
1 | template <typename T> |