前言
C++程序员都应该是对性能执着的人,想要彻底理解C++11和C++14,不可止步于熟悉它们引入的语言特性(例如,auto型别推导、移动语义、lambda表达式,以及并发支持)。挑战在于高效地运用这些特性,从而使你的软件具备正确性、高效率、可维护性和可移植性。这正是本书意欲达成的定位,它不只是教我们应该怎么做,更多的是告诉我们背后发生了什么。
一、类型推导
C++98有一套类型推导的规则:用于函数模板的规则。C++11修改了其中的一些规则并增加了两套规则,一套用于auto,一套用于decltype。C++14扩展了auto
和decltype
可能使用的范围。
1. 理解模板类型推导
C++11的auto/decltype类型推导其实就是基于模板类型推导实现的,模板类型推导分成三个情景,下面我们分别介绍:
template<typename T>
void f(T& param);
int x=27; //x是int
const int cx=x; //cx是const int
const int& rx=x; //rx是指向作为const int的x的引用
f(x); //T是int,param的类型是int&
f(cx); //T是const int,param的类型是const int&
f(rx); //T是const int,param的类型是const int&
如果我们将f的形参类型T&改为const T&,情况有所变化,但不会变得那么出人意料。
template<typename T>
void f(const T& param); //param现在是reference-to-const
int x = 27; //如之前一样
const int cx = x; //如之前一样
const int& rx = x; //如之前一样
f(x); //T是int,param的类型是const int&
f(cx); //T是int,param的类型是const int&
f(rx); //T是int,param的类型是const int&
template<typename T>
void f(T* param); //param现在是指针
int x = 27; //同之前一样
const int *px = &x; //px是指向作为const int的x的指针
f(&x); //T是int,param的类型是int*
f(px); //T是const int,param的类型是const int*
其实规则就是模板类型与传递参数类型作模式匹配,重复部分会被忽略,得到的T类型。
template<typename T>
void f(T&& param); //param现在是一个通用引用类型
int x=27; //如之前一样
const int cx=x; //如之前一样
const int & rx=cx; //如之前一样
f(x); //x是左值,所以T是int&,
//param类型也是int&
f(cx); //cx是左值,所以T是const int&,
//param类型也是const int&
f(rx); //rx是左值,所以T是const int&,
//param类型也是const int&
f(27); //27是右值,所以T是int,
//param类型就是int&&
规则是参数是左值则被推导成左值的引用,参数是右值则被推导成参数本身类型。
template<typename T>
void f(T param); //以传值的方式处理param
int x=27; //如之前一样
const int cx=x; //如之前一样
const int & rx=cx; //如之前一样
f(x); //T和param的类型都是int
f(cx); //T和param的类型都是int
f(rx); //T和param的类型都是int
这是参数传值得形式,param是实参的一个副本,推导时,参数的const,引用会被忽略。
请记住:
2. 理解auto类型推导
如果你已经读过前面item1
的模板类型推导,那么你几乎已经知道了auto
类型推导的大部分内容,至于为什么不是全部是因为有一个例外。
先看下面相同部分,也分三个情形:
auto x = 27; //情景三(x既不是指针也不是引用)
const auto cx = x; //情景三(cx也一样)
const auto & rx=cx; //情景一(rx是非通用引用)
// 情景二
auto&& uref1 = x; //x是int左值,所以uref1类型为int&
auto&& uref2 = cx; //cx是const int左值,所以uref2类型为const int&
auto&& uref3 = 27; //27是int右值,所以uref3类型为int&&
讨论完相同点接下来就是不同点,前面我们已经说到auto
类型推导和模板类型推导有一个例外使得它们的工作方式不同,接下来我们要讨论的就是那个例外。
先看下面这个简单示例,截至目前我们对一个变量的初始化有4中形式,如下:
auto x1 = 27; //类型是int,值是27
auto x2(27); //同上
auto x3 = { 27 }; //类型是std::initializer_list<int>,值是27
auto x4{ 27 }; //同上
当用auto
声明的变量使用花括号进行初始化,auto
类型推导推出的类型则为std::initializer_list
。而对于模板类型推导这样行不通:
auto x = { 11, 23, 9 }; //x的类型是std::initializer_list<int>
template<typename T> //带有与x的声明等价的
void f(T param); //形参声明的模板
f({ 11, 23, 9 }); //错误!不能推导出T
然而如果在模板中指定T
是std::initializer_list<T>
而留下未知T
,模板类型推导就能正常工作:
template<typename T>
void f(std::initializer_list<T> initList);
f({ 11, 23, 9 }); //T被推导为int,initList的类型为std::initializer_list<int>
因此auto
类型推导和模板类型推导的真正区别在于,auto
类型推导假定花括号推导为std::initializer_list
类型而模板类型推导不会这样(确切的说是不知道怎么办)。
知道了上面这个不同,我们接下来看C++14允许auto
用于函数返回值,而且C++14的lambda函数也允许在形参声明中使用auto
。但是在这些情况下auto
实际上使用模板类型推导的那一套规则在工作,而不是auto
类型推导。所以下面代码不会通过编译:
auto createInitList()
{
return { 1, 2, 3 }; //错误!模板类型推导规则不能推导{ 1, 2, 3 }的类型
}
std::vector<int> v;
…
auto resetV =
[&v](const auto& newValue){ v = newValue; }; //C++14
…
resetV({ 1, 2, 3 }); //错误!不能推导{ 1, 2, 3 }的类型
请记住:
3. 理解decltype
在C++11中,decltype
最主要的用途就是用于声明函数模板,而这个函数返回类型依赖于形参类型。
// 返回值decltype(auto)实际上我们可以这样解释它的意义:auto说明符表示这个类型将会被推导,decltype说明decltype的规则将会被用到这个推导过程中。
//C++14版本
template<typename Container, typename Index>
decltype(auto)
authAndAccess(Container&& c, Index i) // 右值引用没有临时对象的拷贝
{
authenticateUser();
return std::forward<Container>(c)[i];
}
//C++11版本
template<typename Container, typename Index>
auto
authAndAccess(Container&& c, Index i)
->decltype(std::forward<Container>(c)[i])
{
authenticateUser();
return std::forward<Container>(c)[i];
}
注意decltype(exp) var;
有个特例就是如果exp是被()
包围,那么推导的类型就是exp的引用。
decltype(auto) f2()
{
int x = 0;
return (x); //decltype((x))是int&,所以f2返回int&
}
请记住:
4. 学会查看类型推导结果
但是有时你要注意,它们不一定都正确,看下面示例:
#include <vector>
#include <iostream>
template <typename T>
void f(const T& param)
{
std::cout << "T = " << typeid(T).name() << 'n'; // int const * __ptr64
std::cout << "param = " << typeid(param).name() << 'n'; // int const * __ptr64
}
int main()
{
std::vector<int> v;
v.push_back(1);
const auto vw = v;
if (!vw.empty()) {
f(&vw[0]);
}
return 0;
}
上面模板函数的T和param的类型输出都是int const *,这明显是错误的,它们不应该是相同的,T应该是int。
请记住:
二、auto
auto
很简单,使用它可以存储类型,但是有时它也会犯一些错误,而且比之手动声明一些复杂类型也会存在一些性能问题。所以我们有必要知道auto
的里里外外。
5. 优先考虑auto而非显式类型声明
下面列举些优先使用auto的原因:
- auto声明变量必须初始化,不会存在未初始化的变量。
int x1; //潜在的未初始化的变量
auto x2; //错误!必须要初始化
auto x3 = 0; //没问题,x已经定义了
- 更加简洁、简单。
template<typename It>
void dwim(It b, It e)
{
while (b != e) {
typename std::iterator_traits<It>::value_type // 类型声明很长
currValue = *b;
…
}
}
template<typename It> //如之前一样
void dwim(It b,It e)
{
while (b != e) {
auto currValue = *b; // 简洁很多了
…
}
}
// 传统写法,这个把int类型赋值给了一个unsigned类型。
// 这在32位机器没问题,但是在64位机器,int是64位,而unsigned是32位。
std::vector<int> v;
unsigned sz = v.size();
// 而使用auto就可以避免上面问题
auto sz =v.size(); //sz的类型是std::vector<int>::size_type
当然使用auto也会带来一些问题,如对源码可读性的影响和item2/6讨论的一些点。而我的观点是在理解的前提下,能用auto的地方就都用它吧。
请记住:
6. auto推导若非己愿,使用显式类型强转
auto在item5我们建议是尽可能使用,但是有个特殊情况需要注意,就是对于返回代理类的场景,auto推导就不正确了。代理类是对现有类型的一种封装,使这个原始类型在特定场景操作更加方便,如智能指针就是原始指针的代理类。
std::vector<bool> features(const Widget& w);
Widget w;
...
auto highPriority = features(w)[5]; // 这里返回的是隐式代理类型std::vector<bool>::reference
...
processWidget(w, highPriority); //未定义行为!processWidget第二个参数要bool,而highPriority并不是
所以像上面这种代理类型就不能使用auto去推导了,一般两种方式处理:
- 不使用auto类型推导,明确指明类型
bool highPriority = features(w)[5]; //这会隐式将std::vector<bool>::reference类型转换为bool类型
- 使用auto,加类型强转
auto highPriority = static_cast<bool>(features(w)[5]); // highPriority也会推导为bool类型
请记住:
三、移步现代C++
说起知名的特性,C++11/14有一大堆可以吹的东西,auto
,智能指针,移动语义,lambda,并发 。每个都是如此的重要,这章将覆盖这些内容。掌握这些特性是必要的,要想成为高效率的现代C++程序员需要小步迈进。
7. 区别使用()和{}创建对象
对于一个变量的初始化,有下面这么多的形式:
int x(0); //使用圆括号初始化
int y = 0; //使用"="初始化
int z{ 0 }; //使用花括号初始化 等效 int z = { 0 };
std::vector<int> v{ 1, 3, 5 }; //v初始内容为1,3,5
还有现在可以直接给非静态的数据成员指定默认初始值,但是()形式不行。
class Widget{
…
private:
int x{ 0 }; //没问题,x初始值为0
int y = 0; //也可以
int z(0); //错误!
}
再者,std::atomic
对象初始化不能用=形式。
std::atomic<int> ai1{ 0 }; //没问题
std::atomic<int> ai2(0); //没问题
std::atomic<int> ai3 = 0; //错误!
double x, y, z;
int sum1{ x + y + z }; //错误!double的和可能不能表示为int
还有如要调用不带参数构造函数,使用()会认为是一个声明,使用{}则没这种问题。
Widget w2(); //最令人头疼的解析!声明一个函数w2,返回Widget
Widget w3{}; //调用没有参数的构造函数构造对象
综上所述,使用{}初始化是适应性最全面,而且还能避免类型变窄和令人头疼的解析。所以大部分情况我是推荐使用统一的{}风格来初始化对象的。
不过有个场景你需要了解,{}初始化返回的其实是std::initializer_list模板类型对象,在item2我们有详细介绍。所以{}作为实参调用重载函数接口,一定会优先匹配带std::initializer_list参数的接口。
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il);
…
};
Widget w1(10, true); //使用圆括号初始化,调用第一个构造函数
Widget w2{10, true}; //使用花括号初始化,调用带std::initializer_list的构造函数
//(10 和 true 转化为long double)
Widget w3(10, 5.0); //使用圆括号初始化,调用第二个构造函数
Widget w4{10, 5.0}; //使用花括号初始化,调用带std::initializer_list的构造函数
//(10 和 5.0 转化为long double)
不过如果{}参数类型与std::initializer_list类型无法隐式转换,则会去匹配其它重载方法。
class Widget {
public:
Widget(int i, bool b); //同之前一样
Widget(int i, double d); //同之前一样
//现在std::initializer_list元素类型为std::string
Widget(std::initializer_list<std::string> il);
… //没有隐式转换函数
};
Widget w1(10, true); // 使用圆括号初始化,调用第一个构造函数
Widget w2{10, true}; // 使用花括号初始化,现在调用第一个构造函数
Widget w3(10, 5.0); // 使用圆括号初始化,调用第二个构造函数
Widget w4{10, 5.0}; // 使用花括号初始化,现在调用第二个构造函数
最后,一个关于容器使用()与{}初始化的区别要注意,这其实是设计的缺陷,我们自己设计应该尽量避免。
std::vector<int> v1(10, 20); //使用非std::initializer_list构造函数
//创建一个包含10个元素的std::vector,
//所有的元素的值都是20
std::vector<int> v2{10, 20}; //使用std::initializer_list构造函数
//创建包含两个元素的std::vector,
//元素的值为10和20
请记住:
8. 优先考虑nullptr而非0和NULL
这点就没太多讨论的了,传统C++98用NULL表示空指针,其实就是一个宏定义,本质就是0,这会与整型0产生歧义。在c++11我们解决了这个问题,用nullptr
来表示任意类型的空指针。
请记住
9. 优先考虑别名声明而非typedef
关于为什么优先考虑使用using别名声明,我列出了下面几点原因:
- 更直观,便于理解
typedef
std::unique_ptr<std::unordered_map<std::string, std::string>>
UPtrMapSS;
using UPtrMapSS =
std::unique_ptr<std::unordered_map<std::string, std::string>>;
// 尤其是函数指针取别名时
//FP是一个指向函数的指针的同义词,它指向的函数带有
//int和const std::string&形参,不返回任何东西
typedef void (*FP)(int, const std::string&); //typedef
//含义同上
using FP = void (*)(int, const std::string&); //别名声明
template<typename T> //MyAllocList<T>是
using MyAllocList = std::list<T, MyAlloc<T>>; //std::list<T, MyAlloc<T>>
//的同义词
MyAllocList<Widget> lw; //用户代码
使用typedef
,你就只能从头开始:
template<typename T> //MyAllocList<T>是
struct MyAllocList { //std::list<T, MyAlloc<T>>
typedef std::list<T, MyAlloc<T>> type; //的同义词
};
MyAllocList<Widget>::type lw; //用户代码
请记住
10. 优先考虑限域枚举而非未限域枚举
不限域枚举作用域属于包含enum的作用域,可能导致命名污染。
enum Color { black, white, red }; //black, white, red在Color所在的作用域
auto white = false; //错误! white早已在这个作用域中声明
enum class Color { black, white, red }; //black, white, red 限制在Color域内
auto white = false; //没问题,域内没有其他“white”
Color c = white; //错误,域中没有枚举名叫white
Color c = Color::white; //没问题
auto c = Color::white; //也没问题(也符合Item5的建议)
使用限域enum
可以减少命名空间污染。而且限域enum
还有第二个吸引人的优点:在它的作用域中,枚举名是强类型,不接受隐式转换。未限域enum
中的枚举名会隐式转换为整型。
enum class Color { black, white, red }; //Color现在是限域enum
std::vector<std::size_t> primeFactors(std::size_t x);
Color c = Color::red;
...
if (c < 14.5) { //错误!不能比较Color和double
auto factors = //错误!不能向参数为std::size_t
primeFactors(c); //的函数传递Color参数
…
}
如果你非要使用,就只能显式类型强转了。
if (static_cast<double>(c) < 14.5) { //奇怪的代码,
//但是有效
auto factors = //有问题,但是
primeFactors(static_cast<std::size_t>(c)); //能通过编译
…
}
还有限域enum
可以前置声明,它默认底层类型是int
,当然你也可以指定修改。而非限域enum
底层类型默认根据枚举值选最大的那个类型作为底层类型,需要定义了才能确认底层类型,所以它不支持前置声明。但是它也可以手动指定类型,这样之后也可以前置声明了,这里本质是要提前确定底层类型。
enum class Status; //前置声明,默认底层类型是int
void continueProcessing(Status s); //使用前置声明enum
// 手动指定底层类型,非限域enum指定也是一样
enum class Status: std::uint32_t; //Status的底层类型
//是std::uint32_t
//(需要包含 <cstdint>)
请记住
11. 优先考虑使用deleted函数而非使用未定义的私有声明
在C++98时,我们要屏蔽某些成员函数不让调用,一般做法是声明为私有且不去实现它们。
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:
…
private:
basic_ios(const basic_ios& ); // not defined
basic_ios& operator=(const basic_ios&); // not defined
};
如果内部或友元函数调用了它们就会在链接时引发缺少函数定义的错误。
在C++11中有一种更好的方式,用= delete
将函数标记为删除,这样如果有地方调用了它,直接在编译时就会报错,比传统方式报错提前了而且报错信息更加友好。
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:
…
basic_ios(const basic_ios& ) = delete;
basic_ios& operator=(const basic_ios&) = delete;
…
};
通常,deleted函数被声明为public
而不是private
,这是有原因的,C++会在检查deleted状态前检查它的访问性。当客户端代码调用一个私有的deleted函数,一些编译器只会给出该函数是private
的错误,这样报错信息就不那么准确了。
deleted函数还有一个重要的优势是任何函数都可以标记为deleted,比如假设我们有这样一个函数,
bool isLucky(int number);
只有传int时才是有意思的,其它类型调用默认会隐式转换,但是可能没有意义。
if (isLucky('a')) … //字符'a'是幸运数?
if (isLucky(true)) … //"true"是?
if (isLucky(3.5)) … //难道判断它的幸运之前还要先截尾成3?
bool isLucky(int number); //原始版本
bool isLucky(char) = delete; //拒绝char
bool isLucky(bool) = delete; //拒绝bool
bool isLucky(double) = delete; //拒绝float和double
请记住:
12. 使用override声明重写函数
所谓重写即派生类的虚函数重写基类同名函数。令人遗憾的是虚函数重写可能一不小心就错了。
比如,下面的代码是完全合法的,咋一看,还很有道理,但是它没有任何虚函数重写——没有一个派生类函数重写了基类函数。你能识别每种情况的错误吗,换句话说,为什么派生类函数没有重写同名基类函数?
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};
class Derived: public Base {
public:
virtual void mf1();
virtual void mf2(unsigned int x);
virtual void mf3() &&;
void mf4() const;
};
mf1
在Base
基类声明为const
,但是Derived
派生类没有这个常量限定符mf2
在Base
基类声明为接受一个int
参数,但是在Derived
派生类声明为接受unsigned int
参数mf3
在Base
基类声明为左值引用限定,但是在Derived
派生类声明为右值引用限定mf4
在Base
基类没有声明为virtual
虚函数
所以从上就可以看出,我们要重写虚函数,传统方式其实很容易犯错。不过现在C++11提供一个方法让你可以显式地指定一个派生类函数是基类版本的重写:将它声明为override
。
class Derived: public Base {
public:
virtual void mf1() override;
virtual void mf2(unsigned int x) override;
virtual void mf3() && override;
virtual void mf4() const override;
};
这样,如果不是重写,就会编译报错。
最后,与override
对应的还有一个关键字final
,向虚函数添加final
可以防止派生类重写。也能用于类,这时这个类不能用作基类。
请记住:
13. 优先考虑const_iterator而非iterator
const_iterator
和 iterator
是用于访问容器元素的两种不同类型的迭代器(其实就是指向容器元素的指针)。它们的主要区别在于是否允许修改容器中的元素。
这里优先考虑const_iterator
与const
的使用目的是一样的,就是当你不需要修改容器元素时,你就应该用const_iterator
,防止意外修改,这种做法有助于防止一些常见的编程错误,提高代码的可读性和可维护性。
请记住:
- 优先考虑
const_iterator
而非iterator
14. 如果函数不抛异常请使用noexcept
如果你能明确函数不会抛异常,把它标记为noexcept
,可以帮助编译器更好的生成优化的代码。
请记住:
15. 尽可能的使用constexpr
关键就是它保证对象或函数返回值在编译期间求值或计算,提高了运行效率。
请记住:
16. 让const成员函数线程安全
主要是讨论多线程环境下如何保证线程安全的话题,其实与const没太多关系。。。
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const
{
std::lock_guard<std::mutex> g(m); //锁定互斥量
if (!rootsAreValid) { //如果缓存无效
… //计算/存储根值
rootsAreValid = true;
}
return rootsVals;
} //解锁互斥量
private:
mutable std::mutex m;
mutable bool rootsAreValid { false };
mutable RootsType rootsVals {};
};
- 单一变量互斥使用
std::atomic
开销更小,性能更高。
class Point { //2D点
public:
…
double distanceFromOrigin() const noexcept //noexcept的使用
{ //参考条款14
++callCount; //atomic的递增
return std::sqrt((x * x) + (y * y));
}
private:
mutable std::atomic<unsigned> callCount{ 0 };
double x, y;
};
请记住:
17. 理解特殊成员函数的生成
特殊成员函数是指C++自己生成的函数。C++98有四个:**默认构造函数,析构函数,拷贝构造函数,拷贝赋值运算符。**C++11新增了两个:移动构造函数和移动赋值运算符。
这些函数仅在需要的时候才会生成,生成的特殊成员函数是隐式public且inline
。
class Widget {
public:
Widget(); // 用户声明的构造函数
~Widget(); // 用户声明的析构函数
Widget(const Widget&) = default; // 显示告诉编译器生成默认拷贝构造函数
Widget& operator=(const Widget&) = default; //显示告诉编译器生成默认拷贝赋值运算符
Widget(Widget&& rhs); //移动构造函数
Widget& operator=(Widget&& rhs); //移动赋值运算符
};
// = default 避开C++11特殊函数生成的规则,显示告诉编译器生成默认的。
请记住:
四、智能指针
原始指针是强大的工具,当然,另一方面几十年的经验证明,只要注意力稍有疏忽,这个强大的工具就会攻击它的主人。
智能指针是解决这些问题的一种办法。智能指针包裹原始指针,它们的行为看起来像被包裹的原始指针,但避免了原始指针的很多陷阱。你应该更倾向于智能指针而不是原始指针。几乎原始指针能做的所有事情智能指针都能做,而且出错的机会更少。
在C++11中存在四种智能指针:std::auto_ptr
,std::unique_ptr
,std::shared_ptr
, std::weak_ptr
。
std::auto_ptr
是来自C++98的已废弃遗留物,它是一次标准化的尝试,后来变成了C++11的std::unique_ptr
。要正确的模拟原生指针需要移动语义,但是C++98没有这个东西。取而代之,std::auto_ptr
拉拢拷贝操作来达到自己的移动意图。这导致了令人奇怪的代码(拷贝一个std::auto_ptr
会将它本身设置为null!)和令人沮丧的使用限制(比如不能将std::auto_ptr
放入容器)。
std::unique_ptr
能做std::auto_ptr
可以做的所有事情以及更多。它能高效完成任务,而且不会扭曲自己的原本含义而变成拷贝对象。在所有方面它都比std::auto_ptr
好。现在std::auto_ptr
唯一合法的使用场景就是代码使用C++98编译器编译。除非你有上述限制,否则你就该把std::auto_ptr
替换为std::unique_ptr
而且绝不回头。
18. 对于独占资源使用std::unique_ptr
std::unique_ptr
智能指针没有拷贝操作,独占资源,它很轻量级,大小等同于原始指针。在工厂函数返回指针对象的场景中常用,它可以很方便的转换为std::shared_ptr
共享指针。
请记住:
19. 对于共享资源使用std::shared_ptr
std::shared_ptr
共享智能指针允许多个std::shared_ptr
对象指向同一块内存资源。
std::shared_ptr
通过引用计数来确保它是否是最后一个指向某种资源的指针,引用计数关联资源并跟踪有多少std::shared_ptr
指向该资源。std::shared_ptr
通常是构造函数递增引用计数值,析构函数递减值,拷贝赋值运算符做前面这两个工作。(如sp1
和sp2
是std::shared_ptr
并且指向不同对象,赋值“sp1 = sp2;
”会使sp1
指向sp2
指向的对象。直接效果就是sp1
引用计数减1,sp2
引用计数加1。)如果std::shared_ptr
在计数值递减后发现引用计数值为零,没有其他std::shared_ptr
指向该资源,它就会销毁资源。
std::shared_ptr
相对std::unique_ptr
更耗性能,它大小更大,内部多了引用计数对象,并且计数的增减必须保证是原子性的,而原子操作通常比非原子操作要慢。
下面是一个非常简单的示例,实际的 shared_ptr
实现比这要复杂得多,因为它需要考虑线程安全、循环引用等。
template <typename T>
class SimpleSharedPtr {
public:
SimpleSharedPtr(T* ptr) : data(ptr), count(new int(1)) {}
SimpleSharedPtr(const SimpleSharedPtr& other) : data(other.data), count(other.count) {
(*count)++;
}
~SimpleSharedPtr()
{
(*count)--;
if (*count == 0) {
delete data;
delete count;
}
}
SimpleSharedPtr& operator=(const SimpleSharedPtr& other)
{
if (this != &other) {
(*count)--;
if (*count == 0) {
delete data;
delete count;
}
data = other.data;
count = other.count;
(*count)++;
}
return *this;
}
private:
T* data;
int* count;
};
接下来我们看一个这样的使用示例:
auto pw = new Widget; //pw是原始指针
…
std::shared_ptr<Widget> spw1(pw); //为*pw创建控制块
…
std::shared_ptr<Widget> spw2(pw); //为*pw创建第二个控制块
用一个原始指针来创建两个shared_ptr
对象,这很糟糕,*pw
有两个引用计数值,每一个最后都会变成零,然后最终导致*pw
销毁两次,第二个销毁会产生未定义行为。
针对这种情况我们的建议是永远不要直接使用原始指针来创建std::shared_ptr
,而应该使用std::make_shared
来创建,如果你非要使用原始指针创建,请使用new
出来的结果。
std::shared_ptr<Widget> spw1(new Widget); //直接使用new的结果
请记住:
20. 当std::shared_ptr可能相互引用时使用std::weak_ptr
std::weak_ptr
通常与std::shared_ptr
配合一起使用,可以shared_ptr
对象赋值给weak_ptr
对象,它不会导致计数器加减,类似一个弱引用。主要用于解决多个std::shared_ptr
对象相互引用无法释放的问题。
#include <memory>
#include <iostream>
class B; // 前向声明
class A {
public:
A() { std::cout << "A constructorn"; }
std::shared_ptr<B> b_ptr; // A中包含B对象的std::shared_ptr
~A() { std::cout << "A destructorn"; }
};
class B {
public:
B() { std::cout << "B constructorn"; }
std::weak_ptr<A> a_weak_ptr; // B中包含A对象用weak_ptr弱引用
~B() { std::cout << "B destructorn"; }
};
int main() {
std::shared_ptr<A> a_ptr = std::make_shared<A>();
std::shared_ptr<B> b_ptr = std::make_shared<B>();
a_ptr->b_ptr = b_ptr;
b_ptr->a_weak_ptr = a_ptr;
// 不会导致循环引用,可以正常释放。
return 0;
}
请记住:
21. 优先考虑使用std::make_unique和std::make_shared而非直接使用new
- 比使用new创建
xx_ptr
代码更简洁,消除了重复代码。
auto upw1(std::make_unique<Widget>()); //使用make函数
std::unique_ptr<Widget> upw2(new Widget); //不使用make函数, 重复写了Widget类型
auto spw1(std::make_shared<Widget>()); //使用make函数
std::shared_ptr<Widget> spw2(new Widget); //不使用make函数
// 接口原型
void processWidget(std::shared_ptr<Widget> spw, int priority);
// 使用new形式
// 潜在的资源泄漏!
/*
这个接口的参数构造时的顺序是不确定的,有可能是下面这样:
1. 执行new Widget
2. 执行computePriority
3. 运行std::shared_ptr构造函数
那么如果在执行第2步时异常了,那么第1步的new内存就泄露了。
*/
processWidget(std::shared_ptr<Widget>(new Widget), computePriority());
// 使用make_xx就不会有上面问题
processWidget(std::make_shared<Widget>(), computePriority());
- 它效率比new更高。
// 直接使用new需要为Widget进行一次内存分配,为控制块再进行一次内存分配。
std::shared_ptr<Widget> spw(new Widget);
// 只有一次内存分配。这是因为std::make_shared分配一块内存,同时容纳了Widget对象和控制块。
auto spw = std::make_shared<Widget>();
// 自定义删除器
auto widgetDeleter = [](Widget* pw) { … };
std::shared_ptr<Widget> spw(new Widget, widgetDeleter);
- vector希望用{}花括号初始化。(make_xx默认是用(),但是也可以通过auto转换后再使用)
// 创建了有10个值为20的数组
auto spv = std::make_shared<std::vector<int>>(10, 20);
// 如果想用{}创建2个值分别为10,20的数组怎么操作了?要么使用new,要么用auto转换下
//创建std::initializer_list
auto initList = { 10, 20 };
//使用std::initializer_list为形参的构造函数创建std::vector
auto spv = std::make_shared<std::vector<int>>(initList);
综上,我整体还是推荐优先使用make_xx来创建智能指针,只是一些特殊场景需要我们了解注意下。
请记住:
22. 用智能指针形式使用Pimpl惯用法
什么是Pimpl惯用法?**它是减少类实现和类使用者之间编译依赖的一种技巧。**通过将类数据成员替换成一个指向包含具体实现的类(或结构体)的指针,并将数据成员移动到这个实现类去,而这些数据成员的访问将通过指针间接访问。
class Widget() { //定义在头文件“widget.h”
public:
Widget();
…
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; //Gadget是用户自定义的类型
};
类Widget
的数据成员包含有类型std::string
,std::vector
和Gadget
, 定义有这些类型的头文件在类Widget
编译的时候,必须被包含进来,这意味着类Widget
的使用者必须要#include <string>
,<vector>
以及gadget.h
。 这些头文件将会增加类Widget
使用者的编译时间,并且让这些使用者依赖于这些头文件。 如果一个头文件的内容变了,类Widget
使用者也必须要重新编译。 这也就是我们为什么建议封装类时,头文件要只包含必要的include的原因:减少依赖,提高编译速度。
如何解决这个问题,我们先用传统C++98方式使用Pimpl技巧:
class Widget //仍然在“widget.h”中
{
public:
Widget();
~Widget(); //析构函数在后面会分析
…
private:
struct Impl; //声明一个 实现结构体
Impl *pImpl; //以及指向它的指针
};
#include "widget.h" //以下代码均在实现文件“widget.cpp”里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { //含有之前在Widget中的数据成员的
std::string name; //Widget::Impl类型的定义
std::vector<double> data;
Gadget g1,g2,g3;
};
Widget::Widget() //为此Widget对象分配数据成员
: pImpl(new Impl)
{}
Widget::~Widget() //销毁数据成员
{ delete pImpl; }
这样我们就把类的数据成员封装到它的实现里面了,减少了自己头文件里的包含文件,这样这个类提供给别人使用时,依赖也就更少了,提高了编译速度。
下面再来看看使用现代C++如何实现Pimple:
#pragma once
#include <memory>
class Widget {
public:
Widget();
// 必须自己实现析构,并把实现放到Impl结构定义的后面,不然编译报错,Impl不是完整类型,没有找到定义
~Widget();
// 由于声明了析构,会使默认移动操作失效,所以这里也一起声明,让它支持移动操作
Widget(Widget&& rhs);
Widget& operator=(Widget&& rhs);
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
#include "widget.h"
#include <string>
#include <vector>
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
};
Widget::Widget() : pImpl(std::make_unique<Impl>())
{
}
// 这些特殊函数行为可以使用默认的,这里只是为了定义到Impl结构下面,让它认识这个定义
Widget::~Widget() = default;
Widget::Widget(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;
#include "widget.h"
int main()
{
Widget w;
return 0;
}
使用std::unique_ptr
当然是自然合理的,而且效率更高,但是你也看到,需要把特殊函数都定义一遍,感觉麻烦的话,可以用std::shared_ptr
,它就不需要定义特殊函数了,编译时也不会报错。(原因是std::unique_ptr与std::shared_ptr内部默认的析构行为不一样导致)
class Widget { //在“widget.h”中
public:
Widget();
… //没有析构函数和移动操作的声明
private:
struct Impl;
std::shared_ptr<Impl> pImpl; //用std::shared_ptr
}; //而不是std::unique_ptr
Widget w1;
auto w2(std::move(w1)); //移动构造w2
w1 = std::move(w2); //移动赋值w1
请记住:
五、右值引用,移动语义,完美转发
移动语义:一般用于拷贝函数,传参由原本的构造复制操作改为右值移动操作。
完美转发:传递一个对象到另外一个函数,保留它原有的左值属性或右值属性。
在本章的这些小节中,非常重要的一点要牢记形参永远是左值,即使它的类型是一个右值引用。比如,
void f(Widget&& w); // 形参w是一个左值
23. 理解std::move和std::forward
std::move
底层只是做类型转换,返回一个右值,无条件转换。移动语义一般就是通过它来传递右值调用特殊的移动拷贝函数,但是有种情况即使你传递了右值也不一定会调用移动函数。
class string {
public:
…
string(const string& rhs); //拷贝构造函数
string(string&& rhs); //移动构造函数
};
const string a = "test";
string(std::move(a)); // 这里其实不会调用移动构造,而是调用拷贝函数
这是因为,移动函数只能接收非const的右值引用参数,但是这个const的右值可以绑定到const的引用上,所以会调用拷贝构造函数。
接下来,我们再看std::forward
这个,它也是做转换,不过是有条件的。它转换的条件是:它的实参用右值初始化时,转换为一个右值。理解这个看下面示例:
void process(const Widget& lvalArg);
void process(Widget&& rvalArg);
template<typename T>
void logAndProcess(T&& param)
{
auto now =
std::chrono::system_clock::now();
makeLogEntry("Calling 'process'", now);
/*
首先开始就讲了一个原则,不管传递的实参是左值还是右值,形参都是左值。
所以这里外部传递一个左值时,一切正常,std::forward什么也没做,
当外部传递一个右值时,param形参会变为左值,这时使用std::froward会把它转换为右值,保证了和外部一致。
*/
process(std::forward<T>(param));
}
Widget w;
logAndProcess(w); //用左值调用
logAndProcess(std::move(w)); //用右值调用
请记住:
24. 区别通用引用和右值引用
什么是通用引用,它表现上像右值引用(即T&&
),但是它可以绑定到左值上,也可以绑定到右值上。此外,它还可以绑定到const
或者non-const
的对象上。它们可以绑定到几乎任何东西上。这种空前灵活的引用值得拥有自己的名字,我把它叫做通用引用。还有一些C++社区的成员已经开始将这种通用引用称之为转发引用。
通用引用常见的场景:
template<typename T>
void f(T&& param); //param是一个通用引用
auto&& var2 = var1; //var2是一个通用引用
这两种情况的共同之处就是都存在类型推导,它们是左值引用还是右值引用取决于它们的初始值是左值还是右值。
template<typename T>
void f(T&& param); //param是一个通用引用
Widget w;
f(w); //传递给函数f一个左值;param的类型
//将会是Widget&,即左值引用
f(std::move(w)); //传递给f一个右值;param的类型会是
//Widget&&,即右值引用
除此之外,如果类型声明的形式不是标准的type&&
,或者如果类型推导没有发生,那么type&&
代表一个右值引用。
请记住:
25. 对于右值引用使用std::move,对于通用引用std::forward
这条没什么说的,就是在右值引用上使用std::move
,在通用引用上使用std::forward
。不要反正来。
26. 避免重载通用引用
使用通用引用做参数很优雅,外部参数可以是左值,右值,它自动匹配。
template<typename T>
void logAndAdd(T&& name)
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string petName("Darla");
logAndAdd(petName); //拷贝左值到multiset
logAndAdd(std::string("Persephone")); //移动右值而不是拷贝它
logAndAdd("Patty Dog"); //在multiset直接创建std::string
//而不是拷贝一个临时std::string
但是注意不要重载通用引用形参的函数,为什么?引用通用引用参数匹配比你想象的要广泛,如果不是精准匹配你添加的重载版本,那它就会优先匹配通用引用版本。
//新的重载版本
void logAndAdd(int idx)
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}
logAndAdd(22); //调用int重载版本,这没问题
如果客户是想调用整数版本,但是传参却给了一个short类型,那么就会有问题了,它会匹配通用引用。
short nameIdx;
… //给nameIdx一个值
logAndAdd(nameIdx); //错误!
而且,构造函数中也注意不要用通用引用完美转发形式,它会劫持一些默认生成函数。
class Person {
public:
template<typename T> //完美转发的构造函数
explicit Person(T&& n)
: name(std::forward<T>(n)) {}
explicit Person(int idx); //int的构造函数
Person(const Person& rhs); //拷贝构造函数(编译器生成)
Person(Person&& rhs); //移动构造函数(编译器生成)
…
};
请记住:
27. 熟悉重载通用引用的替代品
上节讲了为什么要避免重载通用引用形参的函数,不过有些场景可能需要重载,那么我们有那些合理的替代方案了?
-
放弃重载,直接定义不同的函数名。
-
不使用通用引用参数,使用const T&替代。
-
使用传值方式。
-
前面都是常规方式,去掉了通用引用,如果你还是想用通用引用又要重载,可以参考本条实现,还是沿用item26例子。
// 对外还是用通用引用
template<typename T>
void logAndAdd(T&& name)
{
// 内部实现一个新接口,多加一个参数,编译期明确T是否是整数
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>()
);
}
// 非整数版本,std::false_type是编译期的false类型
template<typename T>
void logAndAddImpl(T&& name, std::false_type)
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
// 整数版本,std::true_type是编译期的true类型
std::string nameFromIdx(int idx);
void logAndAddImpl(int idx, std::true_type)
{
logAndAdd(nameFromIdx(idx));
}
-
约束使用通用引用的模板
使用tag dispatch方式无法应对带通用引用形参的构造函数,因为有些对构造函数的调用也被编译器自动生成的函数处理,绕过了分发机制。这时我们可以使用
std::enbale_if
来有条件的约束通用引用模板。
class Person {
public:
// 在编译器检测,如果模板参数T是非Person的派生对象同时非int整数类型,那么模板有效。
// 否则,模板被禁用。
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n) //对于std::strings和可转化为
: name(std::forward<T>(n)) //std::strings的实参的构造函数
{ … }
explicit Person(int idx) //对于整型实参的构造函数
: name(nameFromIdx(idx))
{ … }
… //拷贝、移动构造函数等
private:
std::string name;
};
请记住:
28. 理解引用折叠
29. 认识移动操作的缺点
存在几种情况,C++11的移动语义并无优势:
- 没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作。
- 移动不会更快:要移动的对象提供的移动操作并不比复制速度更快(短字符串string)。
- 移动不可用:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为
noexcept
(容器操作)。
请记住:
- 假定移动操作不存在,成本高,未被使用。
- 在已知的类型或者支持移动语义的代码中,就不需要上面的假设。
30. 熟悉完美转发失败的情况
请记住:
六、Lambda表达式
这个题在以前的C++11语言特性中已经列举比较清楚了,这里不再详细探讨,就列举下注意建议。
31. 避免使用默认捕获模式
请记住:
32. 使用初始化捕获来移动对象到闭包中
在某些场景下,按值捕获和按引用捕获都不是你所想要的。如果你有一个只能被移动的对象(例如std::unique_ptr
或std::future
)要进入到闭包里,如果你要复制的对象复制开销非常高,但移动的成本却不高。
c++14:
std::vector<double> data; //要移动进闭包的对象
… //填充data
auto func = [data = std::move(data)] //C++14初始化捕获
{ /*使用data*/ };
c++11:
std::vector<double> data; //同上
… //同上
auto func =
std::bind( //C++11模拟初始化捕获
[](const std::vector<double>& data)
{ /*使用data*/ },
std::move(data)
);
请记住:
33. 对于std::forward的auto&&形参使用decltype
auto f =
[](auto&&... params)
{
return
func(normalize(std::forward<decltype(params)>(params)...));
};
请记住:
- 对
auto&&
形参使用decltype
以std::forward
它们。
34. 优先考虑lambda表达式而非std::bind
std::bind
常规使用:
#include <iostream>
#include <functional>
// 一个简单的函数
void greet(const std::string& name, const std::string& greeting) {
std::cout << greeting << ", " << name << "!" << std::endl;
}
int main() {
// 使用 std::bind 绑定 greet 函数的第一个参数
auto greetFunction = std::bind(greet, std::placeholders::_1, "Hello");
// 调用绑定后的函数对象
greetFunction("Alice"); // 输出:Hello, Alice!
return 0;
}
请记住:
七、并发API
C++11开始把并发整合到语言标准库中了,开发者首次通过标准库可以写出跨平台的多线程程序。
35. 优先考虑基于任务的编程而非基于线程的编程
我们要异步执行一个函数,通常有两种方式:
int doAsyncWork();
std::thread t(doAsyncWork);
auto fut = std::async(doAsyncWork);
下面列举几点观点:
- 基于任务的方式有返回值,返回一个
std::future
对象,有get
方法,可以使用它来获取异步操作的返回值,而基于线程的方式则不行。 - 基于线程的方式创建线程,系统资源是有限的,需要手动管理好线程,可能会遇到资源超额的麻烦,而使用
std::async
则可以很大程度避免这类问题,它不需要我们手动管理,C++标准库的开发者已经帮我们很好的考虑了。
不过,仍然存在一些场景直接使用std::thread
会更有优势:
- 你需要访问非常基础的线程API。C++并发API通常是通过操作系统提供的系统级API(pthreads或者Windows threads)来实现的,系统级API通常会提供更加灵活的操作方式(举个例子,C++没有线程优先级和亲和性的概念)。为了提供对底层系统级线程API的访问,
std::thread
对象提供了native_handle
的成员函数,而std::future
(即std::async
返回的东西)没有这种能力。 - 你需要且能够优化应用的线程使用。举个例子,你要开发一款已知执行概况的服务器软件,部署在有固定硬件特性的机器上,作为唯一的关键进程。
- 你需要实现C++并发API之外的线程技术,比如,C++标准中未实现的线程池技术。
请记住:
36. 如果有异步的必要请指定std::lauch::async
std::async
异步执行有两种方式,通过std::launch
这个限域enum
枚举名来指定,原型:
auto future = std::async(std::launch::async | std::launch::deferred, func);
std::launch::async
启动策略意味着f
必须异步执行,即在不同的线程。std::launch::deferred
启动策略意味着f
仅当在std::async
返回的future上调用get
或者wait
时才执行。
而如果直接使用的默认方式auto fut = std::async(f);
不指定,那么它的行为这两种都有可能,所以这样会存在不可预测的结果。
请记住:
37. std::threads最后一定要调用join或detach
至于为什么要在std::threads
创建使用完后调用join
或detach
,这与底层设计相关,这里不深入讨论。
我们只需要知道这个原则就好,那么怎么让你一定能遵守这个规则不犯错了?那就只能把这个操作交给RAII对象实现:
class ThreadRAII {
public:
enum class DtorAction { join, detach }; //enum class的信息见条款10
// 传递右值,移到进来
ThreadRAII(std::thread&& t, DtorAction a) //析构函数中对t实行a动作
: action(a), t(std::move(t)) {} //注意这里得形参与初始化列表顺序是精心安排的
~ThreadRAII()
{ //可结合性测试
if (t.joinable()) {
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
}
// 参考Item17
ThreadRAII(ThreadRAII&&) = default; //支持移动
ThreadRAII& operator=(ThreadRAII&&) = default; //支持复制
std::thread& get() { return t; }
private:
DtorAction action;
std::thread t;
};
这样对象析构时就会自动调用了。
请记住:
38. 关注不同线程句柄析构行为
了解下差不多了~没啥实质用!
39. 考虑对于一次性的事件通信使用std::promise
类似这种场景:某个线程等待另一个线程事件发生才继续执行。通常的方式有:
- 使用条件变量
#include <iostream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex m;
std::condition_variable cv;
std::string data;
bool ready = false;
bool processed = false;
void worker_thread()
{
// Wait until main() sends data
std::unique_lock<std::mutex> lk(m);
//子进程的中wait函数对互斥量进行解锁,同时线程进入阻塞或者等待状态。
cv.wait(lk, [] {return ready; });
// after the wait, we own the lock.
std::cout << "Worker thread is processing datan";
data += " after processing";
// Send data back to main()
processed = true;
std::cout << "Worker thread signals data processing completedn";
// Manual unlocking is done before notifying, to avoid waking up
// the waiting thread only to block again (see notify_one for details)
lk.unlock();
cv.notify_one();
}
int main()
{
std::thread worker(worker_thread);
data = "Example data";
// send data to the worker thread
{
//主线程堵塞在这里,等待子线程的wait()函数释放互斥量。
std::lock_guard<std::mutex> lk(m);
ready = true;
std::cout << "main() signals data ready for processingn";
}
cv.notify_one();
// wait for the worker
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [] {return processed; }); // 第二个参数lambda返回true时才能执行,否则继续阻塞
}
std::cout << "Back in main(), data = " << data << 'n';
worker.join();
}
main() signals data ready for processing
Worker thread is processing data
Worker thread signals data processing completed
Back in main(), data = Example data after processing
在很多情况下,使用条件变量进行任务通信非常合适,不过条件变量的使用必须配合互斥锁。
- 使用
std::atomic
原子锁
// 检测任务
std::atomic<bool> flag(false); //共享的flag
… //检测某个事件
flag = true; //告诉反应线程
// 反应任务
… //准备作出反应
while (!flag); //等待事件
… //对事件作出反应
这种方法不存在基于条件变量的设计的缺点。不需要互斥锁,比互斥锁高效。不好的一点是反应任务中轮询的开销。在任务等待flag被置位的时间里,任务基本被阻塞了,但是一直在运行。这样,反应线程占用了可能给另一个任务使用的硬件线程,每次启动或者完成它的时间片都增加了上下文切换的开销,并且保持核心一直在运行状态,本来可以停下来省电。一个真正阻塞的任务不会发生上面的任何情况。这也是基于条件变量的优点,因为wait
调用中的任务真的阻塞住了。
- 使用
std::promise
和std::future
这种应该是最优雅的,通过std::promise
的set_value
发送信号,std::future
的get
阻塞等待信号。不过注意这对收发只能使用一次就释放了。
#include <iostream>
#include <future>
#include <chrono>
void Thread_Fun1(std::promise<int>& p)
{
//为了突出效果,可以使线程休眠5s
std::this_thread::sleep_for(std::chrono::seconds(5));
int iVal = 233;
std::cout << "传入数据(int):" << iVal << std::endl;
//传入数据iVal
p.set_value(iVal);
}
void Thread_Fun2(std::future<int>& f)
{
//阻塞函数,直到收到相关联的std::promise对象传入的数据
auto iVal = f.get(); //iVal = 233
std::cout << "收到数据(int):" << iVal << std::endl;
}
int main()
{
//声明一个std::promise对象pr1,其保存的值类型为int
std::promise<int> pr1;
//声明一个std::future对象fu1,并通过std::promise的get_future()函数与pr1绑定
std::future<int> fu1 = pr1.get_future();
//创建一个线程t1,将函数Thread_Fun1及对象pr1放在线程里面执行
std::thread t1(Thread_Fun1, std::ref(pr1));
//创建一个线程t2,将函数Thread_Fun2及对象fu1放在线程里面执行
std::thread t2(Thread_Fun2, std::ref(fu1));
//阻塞至线程结束
t1.join();
t2.join();
return 1;
}
传入数据(int):233
收到数据(int):233
请记住:
40. 对于并发使用std::atomic,volatile用于特殊内存区
对于std::atomic
的使用,上一节已经有使用示例了,用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。
而volatile
则是告诉编译器不要做内存优化,如:
auto y = x; //读x
y = x; //再次读x
x = 10; //写x
x = 20; //再次写x
编译器会对这种的代码做优化,最终生成如:
auto y = x; //读x
x = 20; //写x
但是有时我们有这样的场景,x是对应外边IO通信的内存映射,每个值都是一条独立有含义的指令,如果优化掉了,相当于指令丢失了,这时可以用volatile
告诉编译器“不要对这块内存执行任何优化”。
volatile int x;
这样,最后生成代码如:
auto y = x; //读x
y = x; //再次读x(不会被优化掉)
x = 10; //写x(不会被优化掉)
x = 20; //再次写x
请记住:
八、微调
41. 对于那些可移动总是被拷贝的形参使用传值方式
这个题本质就是分析拷贝构造、移除构造、模板实例化膨胀这些效率的综合考虑。
class Widget { //方法1:对左值和右值重载
public:
void addName(const std::string& newName)
{ names.push_back(newName); } // rvalues
void addName(std::string&& newName)
{ names.push_back(std::move(newName)); }
…
private:
std::vector<std::string> names;
};
class Widget { //方法2:使用通用引用
public:
template<typename T>
void addName(T&& newName)
{ names.push_back(std::forward<T>(newName)); }
…
};
class Widget { //方法3:传值
public:
void addName(std::string newName)
{ names.push_back(std::move(newName)); }
…
};
考虑这两种调用方式:
Widget w;
…
std::string name("Bart");
w.addName(name); //传左值
…
w.addName(name + "Jenne"); //传右值
方式1:左值一次拷贝,右值一次移动。
方式2:左值一次拷贝,右值一次移动。
方式3:左值一次拷贝一次移动,右值两次移动。
方式3不管是左值还是右值都多了一次移动,但是它的代码更简洁,只需要一个接口。对于这种移动开销小的可以考虑。
请记住:
42. 容器添加考虑优先使用emplace_back而非push_back
一般emplace_back
不会比push_back
效率低,因为某些场景它可以少了临时对象的构造与析构。
std::vector<std::string> vs; //std::string的容器
vs.push_back("xyzzy"); //添加字符串字面量
- 一个
std::string
的临时对象从字面量“xyzzy
”被创建。这个对象没有名字,我们可以称为temp
。temp
的构造是第一次std::string
构造。因为是临时变量,所以temp
是右值。 temp
被传递给push_back
的右值重载函数,绑定到右值引用形参x
。在std::vector
的内存中一个x
的副本被创建。这次构造也是第二次构造——在std::vector
内部真正创建一个对象。(将x
副本拷贝到std::vector
内部的构造函数是移动构造函数,因为x
在它被拷贝前被转换为一个右值,成为右值引用)- 在
push_back
返回之后,temp
立刻被销毁,调用了一次std::string
的析构函数。
而使用emplace_back
,它内部使用的完美转发,直接执行的第2步,没有临时对象的生成。
vs.emplace_back("xyzzy"); //直接用“xyzzy”在vs内构造std::string
所以建议容器相关操作优先使用emplace_xx
的方式,原则上,它不会比push_xx
方式效率差。
请记住:
tile`用在读取和写入不应被优化掉的内存上。是用来处理特殊内存的一个工具。
八、微调
41. 对于那些可移动总是被拷贝的形参使用传值方式
这个题本质就是分析拷贝构造、移除构造、模板实例化膨胀这些效率的综合考虑。
class Widget { //方法1:对左值和右值重载
public:
void addName(const std::string& newName)
{ names.push_back(newName); } // rvalues
void addName(std::string&& newName)
{ names.push_back(std::move(newName)); }
…
private:
std::vector<std::string> names;
};
class Widget { //方法2:使用通用引用
public:
template<typename T>
void addName(T&& newName)
{ names.push_back(std::forward<T>(newName)); }
…
};
class Widget { //方法3:传值
public:
void addName(std::string newName)
{ names.push_back(std::move(newName)); }
…
};
考虑这两种调用方式:
Widget w;
…
std::string name("Bart");
w.addName(name); //传左值
…
w.addName(name + "Jenne"); //传右值
方式1:左值一次拷贝,右值一次移动。
方式2:左值一次拷贝,右值一次移动。
方式3:左值一次拷贝一次移动,右值两次移动。
方式3不管是左值还是右值都多了一次移动,但是它的代码更简洁,只需要一个接口。对于这种移动开销小的可以考虑。
请记住:
42. 容器添加考虑优先使用emplace_back而非push_back
一般emplace_back
不会比push_back
效率低,因为某些场景它可以少了临时对象的构造与析构。
std::vector<std::string> vs; //std::string的容器
vs.push_back("xyzzy"); //添加字符串字面量
- 一个
std::string
的临时对象从字面量“xyzzy
”被创建。这个对象没有名字,我们可以称为temp
。temp
的构造是第一次std::string
构造。因为是临时变量,所以temp
是右值。 temp
被传递给push_back
的右值重载函数,绑定到右值引用形参x
。在std::vector
的内存中一个x
的副本被创建。这次构造也是第二次构造——在std::vector
内部真正创建一个对象。(将x
副本拷贝到std::vector
内部的构造函数是移动构造函数,因为x
在它被拷贝前被转换为一个右值,成为右值引用)- 在
push_back
返回之后,temp
立刻被销毁,调用了一次std::string
的析构函数。
而使用emplace_back
,它内部使用的完美转发,直接执行的第2步,没有临时对象的生成。
vs.emplace_back("xyzzy"); //直接用“xyzzy”在vs内构造std::string
所以建议容器相关操作优先使用emplace_xx
的方式,原则上,它不会比push_xx
方式效率差。
请记住:
- 原则上,置入函数有时会比插入函数高效,并且不会更差。
原文地址:https://blog.csdn.net/u010223072/article/details/134600440
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.7code.cn/show_7639.html
如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱:suwngjj01@126.com进行投诉反馈,一经查实,立即删除!