Effective Modern C++笔记
零. 绪论
- 左值和右值
- 概念上,右值对应的是函数返回的临时对象,左值对应的是可指涉的对象(通过名字、指针或左值引用)
- 甄别方式:能取得地址的为左值。
- 右值引用类型的形参,该形参本身也是左值。
- 任何形参都是左值
- 若某对象是依据同一类型的另一对象初始化出来的,则该新对象称为原对象的一个副本。右值的副本通常由移动构造函数创建,左值的副本通常由复制构造函数创建。
- 函数对象
- 指某个对象,其类型支持operator()成员函数。
- lambda表达式
- 创建的函数对象称为闭包。
- 定义和声明
- 定义可以当声明用。
- 函数的形参类型和返回值类型视为函数签名,而函数名字和形参名字不属于函数签名。
一. 类型推导
01. 理解模板类型推导
在编译期,编译器会通过expr推导两个类型:T的类型和ParamType的类型。这两个类型完全往往不一样,因为ParamType通常包括一些修饰词。
1 | // 函数模板 |
T的类型的推导结果需要分三种情况讨论:
- ParamType具有指针或者引用类型,但不是万能引用。
- ParamType是一个万能引用。
- ParamType既不是指针也不是引用。
- 在模板类型推导过程中,具有引用类型的实参会被当作非引用类型来处理,即其引用性会被忽略。
ParamType具有指针或者引用类型,但不是万能引用时,推导过程如下:
若expr有引用类型,则忽略引用。
对expr和ParamType的类型进行模式匹配,得到T的类型。
例如:
1 | // 函数模板 |
向T&类型模板传入const对象是安全的,因为常量性会称为T的类型的一部分。
若是传给右值引用形参,则推导过程与左值引用相同。
若把形参类型加上const,则结果如下:
1 | // 函数模板 |
若param为指针,则推导过程也相同:
1 | // 函数模板 |
- 对万能引用进行推导时,左值实参会进行特殊处理。
ParamType是一个万能引用时,类型推导过程如下:
- 若expr是左值,则T和ParamType都被推导为左值引用。
- 若expr是右值,则推导过程与之前相同。
在expr为左值的情况比较特殊:首先,这是唯一一种T被推导为引用类型的情况;其次,声明的时候采用右值引用语法,但类型推导却是左值引用。
例如:
1 | // 函数模板 |
- 对按值传递的形参进行推导时,若实参类型中带有const或volatile修饰,则忽略该修饰词。
ParamType既不是指针也不是引用时,其实就是按值传递,无论传入什么,param都会是一个副本。推导过程如下:
- 若expr有引用类型,则忽略引用;
- 若expr是const对象,则忽略const;
- 若expr是volatile对象,则忽略volatile。
例如:
1 | // 函数模板 |
虽然cs和rx有const属性,但是param是完全独立于它们的存在,是它们的副本,因此可以忽略const属性。
但是const属性只有在按值传递时被忽略,若形参为引用或者指针则const会被保留。但是要额外考虑expr时指向const对象的const指针这种情况:
1 | // 函数模板 |
也就是说,ptr自身的const属性会被忽略,但是指向对象的const属性会被保留。
- 在模板类型推导过程中,数组或函数类型的实参会退化成对应的指针,除非被用来初始化引用。
某些情况下,数组会退化成指向其首元素的指针。例如:
1 | const char name[] = "J. P. Briggs"; // name类型为const char[13] |
当数组传递给按值形参的模板时,形参T的类型会被推导为指针类型。例如:
1 | // 函数模板 |
但是如果按引用方式传递参数,形参T类型就会被推导为实际的数组类型。例如:
1 | // 函数模板 |
因此可以用这个能力创建出模板推导数组含有的元素个数:
1 | // 该数组形参未起名字,因为不需要 |
除了数组外,函数类型也会退化成函数指针。推导过程与数组相同。例如:
1 | void someFunc(int, double); |
02. 理解auto类型推导
- 一般情况下,auto类型推导和模板推导是一样的。但是auto类型推导会假定使用大括号的初始化表达式代表std::initializer_list,模板类型推导不会。
一般情况下,auto类型推导和模板推导是完全相同的。例如:
1 | auto x = 27; // x类型为int |
但是以下这种情况不同:
1 | // C++98中的初始化方式 |
对于大括号初始化的表达式,auto类型会推导为std::initializer_list<T>类型(T的类型推导为模板推导,也就是需要用到两种类型推导),而模板推导类型则会失败,编译错误。
- 在函数返回值或者lambda表达式形参中使用auto,意思是使用模板推导而不是auto推导。
C++14允许使用auto说明函数返回值或者lambda表达式形参的类型需要推导,但是这里是用模板推导类型的。例如:
1 | // 函数返回值为auto |
03. 理解decltype
- 绝大多数情况下,decltype得出变量或表达式的类型而不做任何修改。
C++11中,decltype的主要用途大概在于,声明返回值类型依赖于参数类型的函数模板。例如C++11中利用返回值类型尾序语法(trailing return type syntax)声明返回值的类型,这样的好处是,在指定返回值类型时可以使用函数形参。例如:
1 | // C++11中使用返回值类型尾序语法(能运行,但是待改进) |
一般来说,含有类型T的对象的容器,其operator[]会返回T&。std::deque是这样,std::vector几乎总是这样,只有std::vector<bool>不返回bool&,而是返回全新的对象。因此,对于第二段代码,operator[]会返回T&,但是auto类型推导的过程中会忽略expr的引用性,这样返回值类型就变成了T。作为函数的返回值,这里的T为右值,因此无法被赋值,因此有错误。
- C++14支持的decltype(auto),这样的类型推导使用decltype的规则
想要authAndAccess返回左值,则可以对返回值使用decltype类型推导。可以通过decltype(auto)来使用decltype的规则进行类型推导。因此可以修改为:
1 | // C++14中可以直接使用auto推导类型(能运行,但是待改进) |
但是这样仍然有改进空间。这是因为,容器的传递方式是对非常量的左值引用,但是右值是无法绑定到左值引用的(除非是对常量的左值引用),这样就不能往函数中传递右值容器。可以通过重载维护两个函数,分别声明左值引用形参和右值引用形参。然而,也可以通过万能引用,这种引用形参既能够绑定到左值也能够绑定到右值。
使用万能引用的话需要应用std::forward(*不知道是什么)。这样可以得到最终修改的版本:
1 | // C++14中可以直接使用auto推导类型(最终版) |
- 对于类型为T的表达式,除非该表达式仅有一个名字,否则decltype得出类型为T&
绝大多数情况下,decltype得出变量或表达式的类型而不做任何修改。但是对于一个不仅仅是T的名字的表达式,decltype得出类型为T&。例如:
1 | // decltype(x)为int,f1返回int |
这不仅仅是返回类型的错误,f2实际上返回了局部变量的引用,这是非常危险的行为。
04. 掌握查看类型推导结果的方法
- 利用IDE编辑器、编译器错误信息和Boost.TypeIndex库常常能查看到推导得到的类型。
- 有些工具可能不准确,因此理解C++类型推导规则是必要的。
采用那种工具查看类型推导结果取决于在开发过程中的哪个阶段需要该信息:
代码撰写阶段:IDE编辑器
鼠标悬停时可以查看。原理是让C++编译器在IDE内执行一轮,因此代码需要处在可编译的状态。例如:
1
2
3const int theAnswer = 42;
auto x = theAnswer; // x类型为int
auto y = &theAnswer; // y类型为const int*编译阶段:编译器诊断信息
可以通过该类型导致某些编译错误,而报告错误的消息几乎肯定会提及导致该错误的类型。例如:
1
2
3// 声明类模板,但是不定义它。
template<typename T>
class TD; 这样就可以通过TD查看x和y的类型:
1
2TD<decltype(x)> xType;
TD<decltype(y)> yType; 这样编译器会报错,错误信息中会指出x和y的类型。
运行阶段:运行时输出
可以通过typeid和std::type_info::name查看类型信息,例如:
1
2std::cout << typeid(x).name() << '\n'; // GNU和Clang:x类型为i,即int
std::cout << typeid(y).name() << '\n'; // GNU和Clang:y类型为PKi,即int const*(PK: pointer to konst const涉及到常量的指针) 但是这样有可能会输出不准确的信息,例如:
1
2
3
4
5
6
7
8template<typename T>
void f(const T& param);
std::vector<Widget> createVec();
if(!vw.empty())
{
f(&vw[0]);
} 通过typeid和std::type_info::name查看T和param的类型:
1
2
3
4
5
6
7template<typename T>
void f(const T& param)
{
using std::cout;
cout << "T = " << typeid(T).name() << '\n'; // T = class Widget const *
cout << "param = " << typeid(param).name() << '\n'; // param = class Widget const *
} 输出结果如上,但是结果是错误的。param的类型为
const Widget * const &
,但是被报告成const Widget *
。这是因为通过这种方式推导得到类型和向函数模板传参是一样的,按值传参时会忽略掉const、volatile和&这些修饰词。 可以通过Boost的TypeIndex库查看,虽然这不是标准C++的一部分(IDE和TD也不是)。使用方法例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
void f(const T& param)
{
using std::cout;
using boost::typeindex::type_id_with_cvr;
cout << "T = " << type_id_with_cvr<T>().pretty_name() << '\n';
// T = Widget const*
cout << "param = " << type_id_with_cvr<decltype(param)>().pretty_name() << '\n';
// param = Widget const* const&
}
二、auto
05. 优先选用auto,而不是显式类型声明
- auto变量必须初始化。使用auto基本可以避免因为类型不匹配而导致的兼容性和效率问题,还可以简化重构流程,并且代码量更少。
- auto类型也存在着一些问题(如02和06)
auto类型可以节省代码量,不用费大心思思考变量类型,例如:
1 | template<typename It> |
另外,对于std::function,使用auto类型也有非常大的优势。例如:
1 | auto derefUPLess = |
std::function是C++11标准库中的一个模板,把函数指针的思想加以推广,可以指向任何可以调用的对象。比如,std::function可以指向lambda表达式。上述用例就可以表示为:
1 | std::function<bool(const std::unique_ptr<Widget>&, |
但是跟auto类型声明相比:
auto声明的变量所要求的内存量与lambda表达式相同,而std::function声明的变量是其实例,所以占有固定内存。因此,std::function一般占用更多内存。
编译器一般会产生间接函数调用,通过std::function调用lambda表达式几乎必然会比auto声明更慢。
另外,auto声明还可以避免一些不明确类型所产生的错误。例如:
1
2std::vector<int> v;
unsigned sz = v.size(); // v.size()为std::vector<int>::size_type,向unsigned隐式转换。 std::vector<int>::size_type是无符号整型。在32位Windows上,unsigned和std::vector<int>::size_type尺寸相同,但是64位Windows上,unsigned是32位而std::vector<int>::size_type是64位,因此可能出错。
再比如:
1
2
3std::unordered_map<std::string, int> m;
for(const std::pair<std::string, int>& p : m){ ... } // 代码有问题
for(const auto& p : m){ ... } // 用auto声明可以避免这个问题 这里std::unordered_map的键值部分是const,也就是里面的键值对类型实际上为std::pair<const std::string, int>。上述代码实际上会对m中每一个对象进行复制操作,将p绑定到产生的临时对象上。
并且,auto可以简化重构流程。比如函数本来声明的返回类型为int,后续改为long时,使用auto声明返回值只需要重新编译便可以直接更新。
06. 当auto推导的类型不符合要求时,使用显式类型的初始化方法
- “隐形”的代理类型可能会导致auto推导出“错误的”类型
举例来说:
1 | std::vector<boo> features(const Widget& w); |
实际上,highPriority的返回值不是bool类型。对于其他类型,std::vector::operator[]都返回容器内一个元素的引用,但是std::vector<bool>返回的是std::vector<bool>::reference对象。这是一个代理类的实例。代理类指的是为了模拟或者增广其他类型的类,比如智能指针也是代理类。有些代理类比较明细那,但是有些是“隐形代理”,难以察觉。这种类的对象往往生命周期只有单个语句,因此容易出现创建这种类型容易出现未定义的行为。
在上述例子中,auto实际推断的类型是std::vector<bool>::reference,是一个临时对象,在语句结束时就被析构,因此之后会产生未定义的行为。
- 带显式类型的初始化方法可以强制推导出想要的类型
可以通过带显式类型的初始化方法来避免上述问题。使用方法如下:
1 | auto highPriority = static_cast<bool>(feature(w)[5]); // std::vector<bool>::reference被强制转换为bool类型 |
除了能够避免隐形代理类产生的问题,还可以用来强调变量进行了类型转换。例如:
1 | double calcEpsilon(); |
三、转向现代C++
07. 创建对象时注意区分()和{}
To be continued
Effective Modern C++笔记