Modern C++ 学习笔记
类别推导
C++98有用于函数模板的推导规则,C++11和C++14增加了用于auto和deltype的推导规则。
模板类型推导
1 | // 函数模板 |
编译器会根据expr推导ParamType和T,两者有所差别。
情况1:ParamType是指针or引用
T的推导结果会忽略expr的指针or引用,其他类型修饰如const
会被保留。
1
2
3
4
template<typename T>void f(T& Param);
f(x); // T: int param: int&
f(cx); // T: const int param: const int&
f(rc); // T: const int param: const int&
1 | template<typename T>void f(T& Param); |
情况2:ParamType是万能引用
如果expr是左值,T和Param都会被推导为左值引用
(唯一情况)。
如果expr是右值,和情况1
相同。
1
2
3
4
5
template<typename T>void f(T&& Param);
f(x); // T: int& param: int&
f(cx); // T: const int& param: const int&
f(rc); // T: const int& param: const int&
f(27); // T: int param: int&&
1 | template<typename T>void f(T&& Param); |
情况3:按值传递
param是一个全新的对象。引用、const、volatile性质都会忽略。1
2
3
4template<typename T>void f(T Param);
f(x); // T: int param: int
f(cx); // T: int param: int
f(rc); // T: int param: int
考虑一个特殊情况,const指针在传递中,自身const会被忽略,指向的对象const会被保留。
1
const int* const ptr = &x;
1 | const int* const ptr = &x; |
情况4:数组实参与函数实参
数组指针在传参过程中会退化成指向数组首元素的指针,可以通过把形参声明成数组的引用,得到实际的数组类别。1
2
3
4
5
6
7template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{
return N;
}
int keyVals[] = {1,3,5,7,9};
std::array<int, arraySize(keyVals)> mappedVals;
函数类型也会退化成指针。处理方式和上面一样。
auto推导
首先,auto处理数组实参与函数实参也会退化。
其次,auto在初始化时,如果表达式是{}
括起来的,会按照std::initializer_list<T>
推导,如果T
推导失败,模板推导也会失败。
在函数返回值使用auto
,不会推导std::initializer_list<T>
而是常规的模板推导。
decltype
decltype
主要用在声明那些返回值依赖形参类型的函数。
C++14允许对一切lambda式
和一切函数进行推导,不过会有隐患,所以需要decltype
。1
2
3
4
5
6
7template<typename Container, typename Index>
auto
authAndAccess(Container&& c, Index i)
-> decltype(std::forward<Container>(c)[i])
{
return std::forward<Container>(c)[i];
}decltype(x)
结果为int
,decltype((x))
结果为int&
,小心decltype(auto)
,为了保证推导完全没有隐患,可以看第四节。
类型推导结果
这算是奇技淫巧吧,通过编译器诊断信息。1
2template<typename T>class TD; // 只声明
TD<decltype(x)> xType; // 诱发编译器产生类型错误
运行时输出类型,涉及到std::type_info::name
,不保证输出任何有意义的内容。1
std::cout << typeid(x).name() << std::endl;
std::type_info::name
处理类型的方式类似于函数模板按值传递,因此得到的类型可能不准确。Boost.TypeIndex
可以产生精确的类型信息,函数模板boost::typeindex::type_id_with_cvr
接受一个类型实参,而且不会移除const
、volatile
和引用,返回一个boost::typeindex::type_index
对象,最后调用pretty_name()
。
auto
使用auto
- 使用
std::function
声明、储存一个闭包的变量是std::function
的一个实例,占有固定内存,空间不够的时候会分配堆上内存。 - 使用
auto
声明、储存一个闭包的变量和该闭包是一个类型,要求的内存一样。比std::function
更优。 - 像
std::vector<int>::size_type
这样的类型跟平台有关,建议auto
- 像
std::unordered_map<const std::string, int>
这样的类型,显式指定容易引起不想要的类型转换,建议auto
显式初始化
auto
的结果不能总是满足期望,会有意外。
例子:std::vector<bool>
对象执行std::vector::operator[]
后,返回std::vector<bool>::reference
类型,这是嵌套在std::vector<bool>
里的类,然后做了一个向bool
的隐式转换。
原理:
因为过特化,bool
被压缩形式表示,std::vector::operator[]
返回T&
,但是C++不允许比特引用。std::vector<bool>::reference
要保证能用到bool&
的地方它也能用,所以做了一个向bool
的隐式转换,但不是bool&
。
意外:
使用auto会导致容器元素被推导成std::vector<bool>::reference
,这样再使用下标[]
就是返回第几个比特,而不是第几个元素。
后果:std::vector<bool>::reference
对象的一种实现是含有一个指针,指向一个机器字,该Word有那个被引用的比特,再加上基于那个比特对应的字的偏移量。1
processWidget(w, highPriority); // Error, highPriority含有悬空指针
“隐形”代理类(还有表达式模板)和auto
无法和谐相处,这种类的对象往往会设计成仅仅维持到单个语句之内存在。
使用显式的强制转换,得到想要的类型,避开代理类的暗坑。1
2auto highPriority = static_cast<bool>(feature(w)[5]);
auto sum = static_cast<Matrix>(m1+m2+m3+m4);
modern C++
关于大括号
- 大括号禁止内建类型之间进行隐式窄化转换。
- C++任何能够解析为声明的都会解析为声明,用
{}
调用默认构造函数初始化对象可以避免被当成函数声明。 - 在构造函数被调用时,形参中没有
std::initialier_list
,那么大小括号没有区别;如果有,则{}
会优先使用带std::initialier_list
的构造函数。 - 空的大括号表示“没有实参”,而不是空的
std::initialier_list
。如果要调用一个带有std::initialier_list
的构造函数,并且传入一个空的std::initialier_list
,可以这样写:{ { } }
。 - 在设计构造函数的时候,
std::vector
是个反例,不要学它。 - 更具有弹性的设计,允许调用者自行决定使用大括号还是小括号,Intuitive interface, Andrzej
使用nullptr
0
和NULL
都不具备指针的类别,在指针型和整型之间进行重载时容易发生意外。nullptr
的实际类型的std::nullptr_t
,而std::nullptrd
的类型被指定为nullptr
,nulllptr
可以隐式转换到所有的裸指针上。
将nullptr用于模板,适当的互斥量锁定,调用,解锁1
2
3
4
5
6
7
8
9
10
11
12template<typename FuncType,
typename MuxType,
typename PtrType>
auto lockAndCall(FuncType func,
MuxType& mutex,
PtrType ptr) -> decltype(func(ptr))
{
MuxGuard g(mutex);
return func(ptr);
}
//调用
auto result = lockAndCall(f,fm,nullptr);
使用using代替typedef
typedef
不支持模板化,但别名声明支持。- 别名模板可以免写
::type
后缀,在模板内,对于内嵌typedef
的引用经常要加上typename
前缀。
1 | // 处理函数指针 |
对编译器来讲,MyAllocList<T>
别名模板命名了一个类型,是非依赖性的,所以typename
不要求也不允许。而MyAllocList<Widget>::type
不能确定是否是一个类型,在某个特化中,代表并非类型而是其他什么的东西,所以要加typename
。
Note:从模板类型形参出发创建其修正类型1
2
3
4
5
6
7
8
9
10
11
12
//c++11
std::remove_const<T>::type
std::remoeve_reference<T>::type
std::add_lvalue_reference<T>::type
//c++14
std::remove_const_t<T>
std::remoeve_reference_t<T>
std::add_lvalue_reference_t<T>
// using
template<class T>
using remove_const_t = std::remove_const<T>::type;
限定作用域的枚举
关于C++98的枚举:
- 容易造成命名空间的污染。
- 可以隐式转换到
int
,甚至可以进一步转换到float
,算个隐患。 - 不能前置声明(在C++11中可以了),增加了编译依赖性。
- 为了节约使用内存,编译器通常会为枚举分配刚好够用的最小底层类型。
关于C++11的枚举:
- 通过
enum class
声明,枚举类。 - 枚举类型更强,不允许隐式转换。
- 可以前置声明了,而且可以指定枚举的底层类型,比如
enum class Color: std::uint8_t;
1 | using UserInfo = std::tuple<std::string, std::string, std::size_t>; |
删除函数
- 声明
private
函数,用delete
代替,无法通过任何方式访问。 - 任何函数都能成为删除函数,在函数重载中可以避免不想要的重载,在函数模板中,可以避免不想要的具体化。
- 模板特化必须在命名空间作用域,在类作用于不允许。因此,类内部的函数模板不想要的特化用
delete
。
override声明
在派生类声明一个函数,意在重写基类虚函数时,加上override
声明。
C++对重写有严格要求,很容易就声明了一个新函数:
- 基类的函数必须是虚函数。
- 函数名字必须完全一样(析构函数除外)。
- 形参类型必须完全一样
- 函数的后缀性质完全一样。
- 函数返回值和一场规格必须兼容。
C++11新增成员函数引用特性,为了给*this
加一些区分度,原理同const
。1
2
3
4
5
6class Widget{
public:
using DataType = std::vector<double>;
DataType& data() & {return values;}
DataType data() && {return std::move(values);} // 移动语义
};
const_iterator
在C++98中,很难从一个非const
容器得到对应的const
容器,插入删除只能以iterator
指定,而不接受const_iterator
。从const_iterator
到iterator
不存在可移植的类型转换。C++11解决了这些问题,并且指示位置的迭代器都更换成了const_iterator
。
写最通用化的库代码,需要考虑以非成员函数提供接口的情况,对于非成员函数版本的支持:
begin
、end
(c++11)cbegin
、cend
、rbegin
、rend
、crbegin
、crend
(c++14)
写一个cbegin
的实现1
2
3
4
5template<class C>
auto cbegin(const C& container)->decltype(std::begin(container))
{
return std::begin(container);
}
这里通过const引用
类型产生一个类似const_iterator
的效果。
noexcept声明
如果函数f运行期出发了异常,
C++98:调用栈会开解到f的调用者,然后执行一些瞎操作,程序执行终止。
C++11:程序终止之前,栈只是可能会
开解。
在带有noexcept
声明的函数中,优化器不需要将执行期栈保持在可以开解的状态,也不需要在异常溢出的前提下,保证里面的对象按照构造顺序逆序析构。1
2ReType func(params) noexcept; // 最优化
ReType func(params) throw(); // 优化不够
std::vector的push_back操作是异常安全保证的(遗留代码会依赖这样的特性),std::vector::push_back
调用std::move_if_noexcept
,接着向std::is_nothrow_move_constructible
(模板特征)求助。
类似这样的接口都使用“能移动则移动,必须复制才复制”的策略,也就是push_back
是否noexcept
取决于push对象的移动构造函数是否是noexcept
的。
另一个例子是swap
,这些函数带有条件式的noexcept
声明,高阶数据结构的swap
行为要依赖低阶数据结构的swap
行为,以此类推。
大多数函数都是异常中立的,自身不抛出异常,但内部调用的函数可能会发生异常,发生异常时,会允许异常经由它传递到调用栈更深的一层,就像路过一样。不具备noexcept
。
C++98:允许内存释放函数(operator、delete、析构)触发异常,允许但是糟糕。
C++11:默认所有的内存释放函数和析构函数都是noexcept
,除非显式声明noexcept(false)
。
宽约束函数:没有调用的限制条件,也不会出现未定义行为。
窄约束函数:对调用有条件限制,就能异常。
使用constexpr
constexpr
是对象和函数接口的组成部分,constexpr
对象比const
对象更“常量”,符合编译期常量的语境。constexpr
函数在调用时若传入的是编译器常量,则返回的也是常量,如果传入的是直到运行期才知晓的值,就和普通函数一样,但如果所有实参都在编译期未知,那么代码无法通过编译。1
2
3constexpr int pow(int base, int exp) noexcept {...}
constexpr auto num = 5;
std::array<int, pow(3,num)> result;
在C++11中,constexpr
函数不得包含多于一个可执行语句,C++14解除了限制。constexpr
可以让更多运行期进行的工作在编译期完成。
const成员函数的线程安全
对于单个要求同步的变量或内存区域,使用std::atomic
就足够了。如果有更多的内存区域需要同步,就要使用std::mutex
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const
{
std::lock_guard<std::mutex> g(m); // 加上互斥量
if (!rootsAreValid)
{
rootsAreValid = true;
}
return rootVals;
}
private:
mutable std::mutex m;
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{};
};
成员函数的生成机制
1 | class Widget{ |
对于移动操作:
- 移动操作和复制操作一样,仅作用于非静态成员,同时也会相应地构造/赋值基类部分。
- 移动操作不一定真的成功,而是一种请求。对于不可移动得类型,会按照复制操作实现“移动”。
- 移动操作的核心在于把
std::move
应用于每个对象,其返回值被用于函数重载,最终决定执行移动还是复制。
两种复制操作彼此独立:
- 声明了一个,并不会阻止编译器生成另一个。
- 一旦显式声明了移动操作,编译器就会废除复制操作,通过
=delete
。
两种移动操作不独立:
- 声明了其中一个,编译器就不会生成另一个。因为只要声明了移动操作,就表示移动操作的实现方式会和编译器默认生成的行为多少有些不同。
- 一旦显式声明了复制操作,这个类也不会默认生成移动操作了,理由同上。
大三律原则:
- 如果有改写复制操作的需求,往往意味着该类需要执行某种资源管理。默认生成的操作不适用,而且需要正确的析构。
- 标准库中用以管理内存的类都会遵从原则。
- 声明了析构函数,那么默认生成的复制可能不适用,或者说此时复制操作就不该默认生成,但是从C++98到C++11,保留了这一特性。
- 只要声明了析构函数,就不会生成移动操作。
移动操作生成的条件:该类没有任何复制/移动/析构操作。这些标准可以延伸到复制操作上,在已经存在复制/析构条件下,仍然自动生成复制操作已经成为被废弃的特性,在代码中应该尽可能消除这样的依赖。
C++11可以通过=default
来显式表达这个想法:1
2
3
4
5
6class Widget{
public:
~Widget();
Widget(const Widget&) = default;
Widget& operator=(const Widget&) = default;
};
这种写法在多态基类中常见。
一旦声明了析构函数,移动操作的生成就会被抑制,加上=default
能够再给编译器一次机会,声明移动又会废除复制,可以再加一轮=default
。这个没啥用,但是啥都不写可能一不注意就引发性能问题。
函数模板不会影响到成员函数的生成。
智能指针
std::unique_ptr
std::unique_ptr
和裸指针拥有相同的尺寸,只能移动不能复制,移动一个std::unique_ptr会移动所有权,原指针自动挂空。执行析构时,由非空的std::unique-ptr
内部的裸指针完成。std::unique_ptr
随对象主体的析构而被析构,如果是异常或非典型流程,最终调用该资源的析构函数析构。析构默认通过delete,也可以自定义析构器。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 自定义析构器
auto delInvmt = [](Investment* pInvestment)
{
makeLogEntry(pInvestment); // 删除前先写入日志
delete pInvestment;
};
// 工厂函数
template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)>
makeInvestment(Ts&... params)
{
std::unique_ptr<Investment, decltype(delInvmt)>
pInv(nullptr, delInvmt);
// 一些创建对象的操作
pInv.reset(new something(std::forward<Ts>(params)...));
return pInv;
}
// 创建对象
auto pInvestment = makeInvestment(arguments); // std::unique_ptr指针
将一个裸指针赋给std::unique_ptr
不会编译通过,因为裸指针到智能指针的隐式类型转换有问题,所以需要reset
。在C++14中,自定义析构器可以定义在makeInvestment
内部了。
对于自定义析构器:
- 指定为
std::unique_ptr
的第二个实参。 - 接受Investment*的形参最后删除,等价于通过一个基类指针删除派生类对象,因此
Investment
要有虚析构函数。 - 若析构器为函数指针,则
std::unique_ptr
长度一般会增长一到两个字长。 - 若析构器为函数对象,则
std::unique_ptr
长度增长取决于函数对象中储存了多少状态。 - 无捕获的lambda表达式属于无状态的函数对象。
std::unique_ptr
区分std::unique_ptr<T>
和std::unique_ptr<T[]>
,这种区分对指向的对象类型没有二义性。对单个对象没有operator[]
,对数组形式没有opeartor*
和operator->
。
std::unique_ptr
可以很轻松转换成std::shared_ptr
。1
std::shared_ptr<Investment> sp = makeInvestment(arguments);
std::shared_ptr
std::shared_ptr
可以通过访问某资源的引用计数来确定自己是否是最后一个指针,例如sp1 = sp2
代表sp2
引用计数递增,而sp1
引用计数递减,递减为零就会释放。
std::shared_ptr
尺寸是裸指针的两倍(指针资源的裸指针+指向引用计数的裸指针)- 引用计数与资源关联,但是不知道对象是谁(内建类型也可以用
std::shared_ptr
)。需要动态分配,若由std::make_ptr
分配,可以避免动态分配的成本。 - 引用计数的递增递减必须是原子操作,因为可能会有并发读写。
- 移动操作会把原指针置空,当前新指针不需要计数。只有复制操作会增加引用计数。
- 支持自定义析构器,但不是类型的一部分,析构器不同会影响
std::unique_ptr
但不会影响std::shared_ptr
。 std::shared_ptr
的尺寸不会受到自定义析构器的影响。
析构器可能是函数对象有更多数据,这时std::shared_ptr
不得不使用更多内存,但这并不属于自身的一部分,而是把这些内存放在堆上。每个由std::shared_ptr
管理的对象都有一个控制块。
在控制块上,如果自定义析构器被指定,就会包含一份它的复制。如果自定义内存分配器被指定,也会有一份复制。还包括很多附加数据。
控制块中的引用计数会跟踪有多少个std::shared_ptr
指向该控制块,控制块还包含第二个引用计数,对std::weak_ptr
进行计数(弱计数)。std::weak_ptr
通过检查控制块内的引用计数来校验自己是否失效,假设引用计数为0,没有std::shared_ptr
指向对象,对象已经被析构,则std::weak_ptr
会失效,如果是使用std::make_shared
创建的内存块,此时std::shared_ptr
已经析构,但是std::weak_ptr
依然存在并会指向到控制块(弱计数大于零),所以控制块会持续存在,包含它的内存也会持续存在。
一般情况,对象的控制块由首个创建指向它的std::shared_ptr
的函数来确定。但是正在创建指向某对象的std::shared_ptr
的函数是不知道是否由其他的std::shared_ptr
已经指向了该对象的。因此:
std::make_shared
总是创建一个控制块。- 从
std::unique_ptr
或std::auto_ptr
出发构造std::shared_ptr
时,会创建一个控制块。 - 当
std::shared_ptr
构造函数使用裸指针作为实参来调用时,会创建一个控制块。 - 如果从已经有控制块的对象出发,传入
std::shared_ptr
或者std::weak_ptr
就不会创建新的控制块。
从同一个裸指针出发构造不止一个std::shared_ptr
就会多重的控制块,多重的引用计数,也会多重的析构,Boom~!
但是可以这样写:1
2
3
4
5
6
7
8std::shared_ptr<Widget> spw(new Widget, loggingDel); // 直接new
// 容易出现上面错误的时this指针,例如:
std::vector<std::shared_ptr<Widget>> processWidgets;
void Widget::procsee()
{
processWidgets.emplace_back(this);
// this是裸指针,传入`std::shared_ptr`容器会创建内存块
}
不过也有解决方法:The Curiously Recurring Template Pattern1
2
3
4
5
6class Widget: public std::enable_shared_form_this<Widget>{
public:
void process(){
processWidgets.emplace_back(shared_form_this());
}
};std::enable_shared_form_this<T>
是一个基类,它有一个成员函数是shared_form_this()
会从this创建一个std::shared_ptr
,这样的设计依赖于当前对象已有一个关联的std::shared_ptr
控制块,否则该行为未定义,通常会shared_form_this()
抛出异常。
为了解决这个顺序问题,继承自std::enable_shared_form_this<T>
的类可以把构造函数声明为私有,只允许通过工厂函数创建对象。1
2
3
4
5
6
7class Widget: public std::enable_shared_form_this<Widget>{
public:
template<typename... Ts>
static std::shared_ptr<Widget> create(Ts&&... params);
private:
// 构造函数
};
一个控制块通常只有几个字节,但自定义析构器和内存分配器可能会使其变得更大。控制块的实现原理涉及到继承,甚至还会有虚函数(仅在析构的时候使用一次),而进行一项引用计数需要一个或两个原子化操作,映射到单个机器指令,这些都是std::shared_ptr
性能上的成本。
在使用一切默认+std::shared_ptr
和std::make_shared
时,控制块就三个字长,分配操作零成本。这就是C++动态分配资源,自动生存期管理的温和成本。
最后,不存在std::shared_ptr<T[]>
,这和std::unique_ptr
不同。
std::weak_ptr
std::weak_ptr
并不是一种独立的智能指针,而是std::shared_ptr
的扩充。它可以像std::shared_ptr
一样运作,同时不影响其指向对象的引用计数,而且要能跟踪指针何时悬空。1
2
3auto spw = std::make_shared<Widget>(); // spw构造完成,Widget引用计数为1
std::weak_ptr<Widget> wpw(spw); // wpw和spw指向同一个widget,引用计数保持为1
spw = nullptr; // 引用计数0,widget被析构,wpw悬空
关于std::weak_ptr
的使用场景
std::weak_ptr
是否失效的校验(为了线程安全需要原子操作),以及在未失效的条件下提供所指涉到的对象的访问1
2std::shared_ptr<Widget> spw1 = wpw.lock(); // 若wpw失效,则spw为空
std::shared_ptr<Widget> spw2(wpw); // 若wpw失效,抛出std::bad_weak_ptr异常- 带缓存的工厂函数,缓存管理器
1
2
3
4
5
6
7
8
9
10
11std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
// C++11散列表容器,缓存
static std::unorderd_map<WidgetID, std::weak_ptr<const Widget>> cache;
auto objPtr = cache[id].lock(); // 如果对象不在缓存中,返回空指针
if(!objPtr){
objPtr = loadWidget(id); // 加载
cache[id] = objPtr; // 缓存
}
return objPtr; // 缓存中失效的std::weak_ptr会不断积累,可以优化
} - 观察者模式——可以改变状态的对象,观察者(对象状态发生改变后通知的对象)。
- 避免
std::shared_ptr
的指针环路,如果A和B相互指向对方,这种环路会阻止析构,资源得不到回收。
最后,std::weak_ptr
和std::shared_ptr
对象尺寸相同,也使用同样的控制块。
std::make_unique和std::make_shared
std::make_shared
来自C++11,std::make_unique
来自C++14,不过可以用C++11简易实现,参考创建make_unique, N3656, Stephan T.Lavavej, 2013-4-181
2
3
4
5
6// 这个简易实现不支持数组和自定义析构器
template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params)
{
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}
make系列函数会把一个任意实参集合完美转发给动态分配内存对象的构造函数,并返回一个指向该对象的智能指针,分别是std::make_unique
、std::make_shared
、std::allocate_shared
(动态分配器)。
优先使用make的原因之一与异常安全有关,例如:1
2
3void processWidget(std::shared_ptr<Widget> spw, int priority);
processWidget(std::shared_ptr<Widget>(new Widget), computePriority()); // 潜在的资源泄露
processWidget(std::make_shared<Widget>(), computePriority()); // 安全
这里的风险来自编译器从源代码到目标代码的翻译过程,在运行时,传递给函数的实参必须在函数调用被发起之前完成评估求值。因此在这里:
- 表达式
new Widget
必须完成评估求值,在堆上创建 - 由new产生的裸指针的托管对象
std::shared_ptr<Widget>
的构造函数必须执行。 computeProprity()
必须运行
但是编译器不必要按照这个顺序生成代码,最糟糕的情况是先运行new后运行computeProprity
最后运行std::shared_ptr
构造函数,这样如果computeProprity
产生异常,第一步创建的new永远不会被储存到第三步才接管的std::shared_ptr
,但是使用std::make_shared
就没有这个问题。
优先使用make的原因之二是性能提升1
2std::shared_ptr<Widget> spw(new Widget); // 1
auto spw = std::make_shared_ptr<Widget>(); // 2
情况1会多一次内存分配,第一次分配是new分配,第二次是std::share_ptr
的构造函数对控制块的分配。
情况2只有1次内存分配,对象+控制块,会分配在一个同一块内存上(单块内存)。
但是make函数有许多限制:
- 不允许使用自定义析构器,只能用构造函数实现。
1
2
3auto widgetDeleter = [](Wiget* pw){...};
std::unique_ptr<Widget, decltype(widgetDeleter)> upw(new Widget, widgetDleter);
std::shared_ptr<Widget> spw(new Widget, widgetDeleter); - make函数对形参进行完美转发使用的是圆括号,大括号会优先匹配
std::initializer_list
类型的构造函数,因此假如要使用大括号初始化就必须使用new了。不能够完美转发大括号初始化物,但是可以尝试auto推导创建一个std::initializer_list
对象。对1
2auto initList = {10,20};
auto spv = std::make_shared<std::vector<int>>(initList);std::unique
而言,仅在上面两种情景下会存在问题。而对std::shared_ptr
和其他make函数而言,还有其他两种更边缘的场景: - 有些类会定义自身版本的
operator new
和operator delete
,全局版本的内存分配策略不适用于这些类。通常,类自定义的这两种函数被设计成仅用来分配和是犯法该类精确尺寸的内存块,就不适于用std::shared_ptr
所用的自定义分配器(通过std::allocate_shared
)和自定义析构器了。因为std::allocate_shared
所需要的内存并不等于动态分配对象的尺寸,所以这种情况推荐new。 - 使用
std::make_shared
创建的内存块,此时std::shared_ptr
已经析构,但是std::weak_ptr
依然存在并会指向到控制块(弱计数大于零),所以控制块会持续存在,包含它的内存也会持续存在。这么一来,假设对象的尺寸很大,且最后一个std::shared_ptr
和std::weak_ptr
析构之间的时间间隔不能忽略,在对象的析构和内存的释放之间就会产生延迟。
如果是使用new表达式,则对象内存可以在最后一个指向它的std::shared_ptr
析构时就释放。而使用new表达式时,要避开之前提到的异常安全问题。1
2
3
4void processWidget(std::shared_ptr<Widget> spw, int priority);
void cusDel(Widget *ptr); // 自定义析构器
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(std::move(spw), computePriority()); // std::move 右值传递,节省开销使用Pimpl习惯用法
Pimpl习惯用法是一种可以在类实现和类使用之间减少编译依赖性的方法。
对采用std::unique_ptr
来实现的Pimpl指针,需在头文件中声明特种成员函数,但在实现文件中实现他们,即使默认函数实现有着正确的行为,必须要这样做,这对std::shared_ptr
并不适用。
移动语义
右值引用是把移动语义和完美转发两种语言特性粘合的底层语言机制。一开始看山是山,看水是水;了解的越多,看山不是山,看水不是水,最后山还是山,水还是水。
std::move和std::forward
这两者在运行期都无所作为,不会生成任何可执行代码。std::move
并不进行任何移动,而是把实参强制转换成右值。在一个对象上实施std::move
是告诉编译器这个对象具备可移动的属性。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// C++11中std::move的示例实现
template<typename T>
typename remove_reference<T>::type&& // 确保返回右值引用
move(T&& param)
{
using ReturnType =
typename remove_reference<T>::type&&;
return static_cast<ReturnType>(param);
}
// C++14中std::move的示例实现
template<typename T>
decltype(auto) move(T&& param)
{
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}
移动操作不能违反维持常量正确性的原则,所以不允许常量对象进行移动。如果想取得某个对象执行移动操作的能力,不要将其声明为常量。考虑以下情况:1
2
3
4
5class string{
public:
string(const string& rhs); // 无法std::move
string(string&& rhs); // 只能接受非常量
}std::move
无条件将实参强制转换为右值,而std::forward则仅在某个特定条件满足时才执行同一个强制转换。std::forward
最常见的一个使用场景是某个函数模板有万能引用的形参,随后将其传递给另一个函数。因为一切函数形参皆左值,所以为了避免这种结果,就需要一种机制,左值保持不变,传递左值;右值传递右值。1
2
3
4void process(const Widget& lvalArg);
void process(Widget&& rvalArg);
template<typename T>
void logAndProcess(T&& param) { process(std::forward<T>(param)); }
这里,std::forward
可以分辨出param是通过左值还是右值完成初始化的,该信息被编码到模板形参T中。详细参见“引用折叠”。std::move
只取用一个实参,而std::forward
需要同时取用类型+实参,两者的含义也有很大的不同。
万能引用和右值引用
万能引用可以绑定到右值引用、左值引用、const、volatile,一般指出现在函数模板的形参和auto声明,也就是说需要涉及到类型推导才行。1
2
3
4
5
6
7
8
9
10
11
12
13
14// 万用引用
auto&& var2 = var1;
template<typename T> void f(T&& param);
template<class... Args> void emplace_back(Args&&... args);
auto timeFuncInvocation =
[](auto&& func, auto&&... params)
{
std::forward<decltype(func)>(func)( // func
std::forward<decltype(params)>(params).. // params
);
}
// 右值引用
template<typename T> void f(std::vector<T>&& param);
template<typename T> void f(cosnt T&& param);
对右值引用实施std::move,对万能引用实施std::forward
右值引用仅会绑定到可供移动的对象上,所以需要std::move
把对象转换为右值。万能引用只有在使用右值初始化才会是右值,对应std::forward
。如果对万能引用施加std::move
就可能有问题:1
2
3
4
5class Widget{
public:
template<typename T>void setName(T&& newName) {name = std::move(newName);}
};
w.setName(n); // n的值移入了w,n的值未知
如果不使用万能引用,分别写成两个函数可能会遇到效率问题。1
2
3
4
5
6class Widget{
public:
void setName(const std::string& newName){ name = newName; }
void setName(std::string&& newName){ name = std::move(newName); }
};
w.setName("Adela Novak"); // 对这个调用,重载版本会比万能引用多创建一次临时对象用来传参
分成函数写,当参数变多甚至可变形参时,就显得不太现实。因此,万能引用+std::forward
是解决问题的唯一方法。
有些情况,在单一函数内一个对象会不止一次地绑定到右值引用或万能引用,这时仅在最后一次使用引用即可。1
2
3
4
5
6
7
8
9
10
11
12
13// 对矩阵加法
Matrix opeartor+(MAtrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return std::move(lhs);
}
// 约分
template<typename T>
Function reduceAndCopy(T&& frac)
{
frac.reduce();
return std::forward<T>(frac);
}
RVO(return value optimization)
编译器对函数返回值自带优化,需要满足两个条件:
- 局部对象类型和函数返回值类型相同。
- 返回的就是局部对象本身。这里返回的不是局部对象,而是局部对象的引用,因此编译器无法实施
1
2
3
4
5Widget makeWidget()
{
Widget w;
return std::move(w);
}RVO
,但编译器不选择执行RVO
的时候,返回对象必须作为右值处理。即要么发生复制忽略,要么std::move
被隐式实施于返回的局部对象。
万能引用的重载
一旦万能引用作为重载候选,它就会吸走大批的实参类型,完美转发构造函数尤其严重,因为对于非常量的左值类型,它们一般都会形成相对于复制构造函数的更加匹配,并且还会劫持派生类中对基类的复制和移动构造函数的调用。1
2
3
4
5
6
7
8
9
10class Person{
public:
template<tyepname T> explicit Person(T&& n): name(std::forward<T>(n)){}
};
class SpecialPerson: public Person{
public:
// 都是调用基类的完美转发构造函数
SpecialPerson(const SpecialPerson& rhs): Person(rhs) {...}
SpecialPerson(SpecialPerson&& rhs): Person(std::move(rhs) {...}
};
为了解决这些问题,要么就直接弃用重载,要么通过区分传递const T&
,要么通过传值操作(把按引用传递换成按值传递,尽管这反直觉,当知道肯定需要复制形参时,考虑按值传递)
兼顾特性的做法是标签分派,以下是示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19template<typename T>void logAndAdd(T&& name)
{
logAndAddImpl(
std::forward<T>(name),
std::is_intergal<typename std::remove_reference<T>::type()
);
}
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::string nameFormIdx(int idx);
void logAndAddImpl(int idx, std::true_type)
{
logAndAdd(nameFromIdx(idx));
}
标签分派的思想是这样的,如果万能引用仅是形参列表的一部分,列表中还有其他非万能引用的形参,那么只要这个非万能引用不匹配,这个重载函数就不会匹配。
通用的做法是重写万能引用的函数,函数内部再委托给另外两个函数,比如上面用std::is_intergal
区分整型or非整型,因为推导以及引用类型int&不是int,所以还要加上std::remove_reference
。然后std::is_intergal
会得到std::true_type
和std::false_type
两种结果。
true和false都是运行期的值,这里我们需要利用的是重载决议(处于编译期)来选择正确的重载版本,所以需要std::true_type
和std::false_type
。这就是所谓的“标签
”。
标签分派能够发挥作用的关键在于,存在一个单版本函数作为API,该函数会把待完成的任务分派到实现函数,创建无重载的分派函数并不难。但是这解决不了劫持派生类中对基类的复制和移动构造函数的调用的问题。
std::enable_if
可以强制编译器禁用模板,默认时所有的模板都是启用的,但是施加了std::enable_if
的模板只会在满足了std::enable_if
指定的条件才会启用,更深入的可以参考:C++SHINAE机制 — 知乎
以下是一个示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class Person{
public:
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): name(std::forward<T>(n))
{
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);
}
};
这里想指定的条件是T不是Person类型时才启用这个模板函数,通过!std::is_same<Person,T>::value
,深入思考,在得到T时,要移除它是否为一个引用(这个简单),也要移除它是否带有const或volatile,这时需要使用std::decay<T>::type
或者std::decay_t<T>(c++14)
,这么一来就成了!std::is_same<Person,typename std::decay<T>::type>::value
。
在最开始的例子,派生类会给基类的构造函数传递对象,因为派生类和基类不同,所以这里的构造函数仍会被启用。std::is_same
要换成std::is_base_of
,来判断是否类型有继承。
万能引用转发次数越多,某些地方出错时给出的错误信息就越难懂。可以通过写一些断言来缓解这个问题。参考:type_traits
引用折叠
当初始化形参为万能引用时,实参传递给函数模板时,推导出来的模板形参会将实参时左值还是右值的信息编码到结果类型中。
如果传递的实参是个左值,T推到结果为左值引用。
如果传递的实参是个右值,T推导结果是个非引用。
因为C++禁用引用的引用,所以折叠。
A& & 变成 A&
A& && 变成 A&
A&& & 变成 A&
A&& && 变成 A&&
引用折叠会在四种语境中发生:模板实例化、auto类型推导、创建和运用typedef和别名声明、decltype。
万能引用并不是新的引用,而是满足条件的右值引用:
- 类型推导会区别左值和右值。
- 会发生引用折叠。
假定移动操作不存在、成本高、未使用
C++98的代码原封不动地在C++11编译器上编译,也会有性能优化。但是有很多场景移动操作并不高效。在这几个场景,C++11移动语义不会带来任何好处:
- std::array是STL数组,数据直接存在对象上而不是堆上。
- std::string有SSO(small string optimization),即小型字符串会储存在缓存区而不是堆上。
- 一些看似万无一失的移动场景,没有加上noexcept的话,编译器会强制调用复制。要求移动不可发射异常,必须加上noexcept声明。
- 没有移动操作,移动请求就变成了复制请求。
- 移动还不如复制更快。
- 原对象是个左值,除了极少数例外,只有右值可以移动。
完美转发的失败情况
完美转发的失败,是源自模板推导的失败,或者推导结果错误。会导致完美转发失败的实参种类有大括号初始化物
、以0或NULL表达的空指针
、仅有声明的整型static const成员变量、模板或重载函数名字
、以及位域
。
完美转发不仅转发对象、还会转发类型、左值右值、是否嗲有const、volatile。1
2
3
4template<typename... Ts>void fwd(Ts&&... params)
{
f(std::forward<Ts>(param)...);
}
常规情况下,编译器先得到调用端的实参类型,再得到f所声明的形参类型,比较两者是否兼容,(之后通过隐式类型转换)来调用成功。而经由完美转发,编译器采用推导的手法得到调用端的实参类型与所声明的形参类型比较,会在以下任何一种情况成立时失败:
- 编译器无法为一个或多个fwd的形参推导出结果。
- 编译器为一个或多个fwd的形参推导出了“错误”的结果。
大括号初始化物
的问题在于向未声明std::initializer_list
类型的函数模板传递了大括号,叫作“非推导语境”,所以会被编译器禁止。但是可以先用auto
推导,然后传递给完美转发函数。
若尝试把0或NULL传给模板,类型推导的结果就是整型,传递nullptr
即可。
static cosnt
成员变量仅需声明,不必保留内存。一般调用直接当作常数处理,完美转发会失败,因为隐含了取地址,毕竟引用和指针实现类似。
重载的函数名字和模板名字,因为没有任何关于类型的信息,编译器不知道应该传递哪个版本。
位域1
2
3
4
5
6
7
8
9
10
11struct IPv4Header {
std::uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16;
};
// 可以这么做
IPv4Header h;
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length);
C++标准禁止非const引用绑定到位域。位域是由机器字的若干任意部分组成的,没办法对其直接去地址。指针指向的最小实体是一个字节。
lambda表达式
lambda是表达式的一种,闭包是lambda式创建的运行期对象,根据不同的捕获模式,闭包会持有数据的副本或引用。
闭包类就是实例化闭包的类,每个lambda都会触发编译器生成独一无二的闭包类,而闭包中的语句会变成成员函数可执行语句。
避免默认捕获模式
C++11有两种默认捕获方式:按引用或按值。
按引用的默认捕获模式可能会导致空悬引用,一旦lambda式所创建的闭包越过了生存周期,引用就会空悬。该局部变量或形参1
2auto divisor = computeDivisor();
filters.emplace_back([&divisor](int value) { return value % divisor == 0; } );
闭包会被立即使用(例如STL算法)并且不会被复制的场景,引用比原对象的生命期更长就不存在风险。1
2
3
4
5
6if(std::all_of(std::begin(container), std::end(container),
[&](const ContElemT& value){return value % divisor == 0;}))
// c++14 已经可以用auto了
if(std::all_of(std::begin(container), std::end(container),
[&](auto& value){return value % divisor == 0;}))
另一种是按值1
filters.emplace_back([=](int value) { return value % divisor == 0; } );
按值并不能避免空悬,问题在于经过复制,闭包中得到是副本,如果是指针什么的还是可能空悬的。1
2
3
4
5
6
7
8
9class Widget{
public:
void addFilter() const
{
filters.emplace_back([=](int value) { return value % divisor == 0; } );
}
private:
int divisor;
}
lambda式只能捕获作用域内可见的非静态局部变量和形参,以上代码无法编译通过。这里,lambda捕获的其实是this指针,lambda闭包的存活与它含有其this指针副本的对象的生命期式绑定的。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22using FilterContainer = std::vector<std::function<bool>>;
FilterContainer filters;
void doSomeWork()
{
auto pw = std::make_unique<Widget>();
pw->addFilter();
}
void Widget::addFilter() const
{
auto divisorCopy = divisor;
filters.emplace_back([divisorCopy](int value){return value % divisorCopy == 0;});
filters.emplace_back([=](int value){return value % divisorCopy == 0;}); // 这样也行
}
// c++14,lambda广义捕获
void Widget::addFilter() const
{
auto divisorCopy = divisor;
filters.emplace_back(
[divisor = divisor](int value) // 将divisor复制入闭包
{return value % divisorCopy == 0; }
);
}
使用默认值捕获另一缺点是,给人感觉lambda与闭包外数据绝缘,但其实并不是。除了依赖作用域内可见的非静态局部变量和形参,其实还会依赖静态存储期对象,这样的对象定义在全局或命名空间作用域中,又或在类中、在函数中、在文件中以static声明,这些玩意儿都不能被捕获。1
2
3
4
5
6
7
8
9
10
11void addDivisorFilter()
{
static auto calc1 = computeSomeValue1();
static auto calc2 = computeSomeValue2();
static auto divisor = computeDivisor(calc1, calc2);
filters.emplace_back(
[=](int value) // 没有捕获到任何东西,看上去是按值,其实是按引用
{ return value % divisor == 0; } // 指涉到static对象
);
++divisor;
}
使用初始化捕获将对象移入闭包
C++11没有办法移动对象到闭包,C++14则有云泥之别,即通过初始化捕获来弥补C++11移动捕获的缺失。这样就可以在lambda使用只移对象以及大部分的标准库(移动廉价、复制昂贵)。
使用初始化捕获,可以得到机会指定:
- 由lambda生成的闭包类中的成员变量的名字。
- 一个表达式,用以初始化该成员变量。
1 | class Widget{ |
以上,初始化捕获也就是广义lambda捕获(generalized lambda capture
)。
假如编译器只支持到C++11,多敲键盘也能达到目的,以下:1
2
3
4
5
6
7
8
9
10class IsValAndArch {
public:
using DataType = std::unique_ptr<Widget>;
explicit isValAndArch(DataType&& ptr): pw(std::move(ptr)){}
bool operator()() const
{ return pw->isValidated() && pw->isArchisved(); }
private:
DataType pw;
};
auto func = IsValAndArch(std::make_unique<Widget>());
如果非要使用lambda式,按移动捕获可以在C++11中模拟做到:
- 把需要捕获的对象移动到std::bind产生的函数对象中。
- 给到lambda式一个指向欲“捕获”的对象的引用。
1 | // c++14 |
和lambda式类似,std::bind
返回函数对象并成为绑定对象(bind ojbect)。std::bind
的第一个实参是一个可调用对象,接下来所有的实参都表示传给该对象的值。绑定对象含有传递给std::bind
所有实参的副本,对于每个左值实参都会复制,每个右值实参都会移动。
默认情况下,lambda生成的闭包类中的operator()会带有const,结果闭包里的所有成员变量在lambda式的函数体内都会带有const。但是绑定对象里移动构造得到的data副本并不带有const,所以为了防止该data副本在lambda内被意外修改,形参需要为const T。但如果lambda声明带有mutable,闭包里的operator()就不会带const了,相应的形参应该略去const:1
2
3
4
5auto func = std::bind(
[](std::vector<double>& data) mutable
{ /*对数据加以运用*/ },
std::move(data)
);
使用std::bind模拟移动捕获,再举一例:1
2
3
4
5
6
7
8// c++14
auto func = [pw = std::make_unique<Widget>()]
{ return pw->isValidated() && pw->isArchived(); }
// c++11
auto func = std::bind([](const std::unqiue_ptr<Widget>& pw)
{ return pw->isValidated() && pw->isArchived(); },
std::make_unique<Widget>()
);
对auto&&形参使用dectltype
1 | auto f = [](auto x){ return func(normalize(x)); }; |
这里应该把x完美转发给normalize(),但是泛型lambda式却没有可用的T可以用。改进后的代码:1
2
3
4
5
6
7
8auto f = [](auto&& param)
{
return func(normalize(std::forward<decltype(param)>(param)));
};
auto f = [](auto&&... params)
{
return func(normalize(std::forward<decltype(params)>(params)...));
}
std::bind
std::bind是C++98中std::bind1st
和std::bind2nd
的后继特性。作为非标准特性,在05年就成为标准库的组成部分(那时标准委员会刚接受了C++ Technical Report 1 (TR1)文档)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24using Time = std::chrono::steady_clock::time_point;
emum class Sound {Beep, Siren, Whisstle};
using Duration = std::chrono::steady_clock::duration;
// 在时刻t,发出声音s,持续d
void setAlarm(Time t, Sound s, Duration d);
// lambda
auto setSoundL = [](Sound s)
{
using namespace std::chrono;
setAlarm(steady_clock::now() + hours(1), // 一小时后
s, // 发出声音
seconds(30)); // 响30秒
};
// c++14提供了ms,s,h
auto setSoundL = [](Sound s)
{
using namespace std::chrono;
setAlarm(steady_clock::now() + 1h, s, 30s);
};
// std::bind
using namepsace std::chrono;
using namepsace std::literals;
using namepsace std::placeholders;
auto setSoundB = std::bind(setAlarm, steady_clock::now() + 1h, _1, 30s);
使用std::bind
存在一些问题,我们想要的是在setAlarm被调用的时刻之后1小时报警,但是这里是调用std::bind
一小时后报警,为了解决这个问题需要延迟表达式的评估求值调用setAlarm的时刻。1
2
3
4
5
6
7
8
9// c++14: 标准运算符的模板实参大多数情况下可以省略不写
auto setSoundB = std::bind(setAlarm,
std::bind(std::plus<>(), steady_clock::now(), 1h),
_1, 30s);
// c++11 还不支持这样的特性
auto setSoundB = std::bind(setAlarm,
std::bind(std::plus<steady_clock::time_point>(),
steady_clock::now(), 1h),
_1, 30s);
一旦函数进行重载,新的问题又会出现,之前的lambda式没有问题,但是std::bind
会无法编译通过。为了使得std::bind
的调用能够通过编译,需要强制转换类型到合适的函数指针。1
2
3
4
5
6
7using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);
auto setSoundB = std::bind(static_cast<SetAlarm3ParamType>(setAlarm),
std::bind(std::plus<steady_clock::time_point>(),
steady_clock::now()
1h),
_1, 30s );
这样又带出来lambda式和std::bind
的另一个不同之处,lambda式式常规的函数唤起方式,编译器可以用惯用手法将其内联。可是,std::bind
的调用传递了一个函数指针,几乎无法内联。此外,随着想做的事情越来越复杂,使用lambda式的好处会扩大。1
2
3
4
5
6
7auto betweenL = [lowVal, highVal](const auto& val)
{ return lowVal <= val && val <= highVal; };
// std::bind
auto betweenB =
std::bind(std::logical_and<bool>(),
std::bind(std::less_equal<int>(), lowVal, std::placeholders::_1),
std::bind(std::less_equal<int>(), std::placeholders::_1, highVal));std::bind
总是按值复制,不过可以通过std::ref()
达成按引用传递,lambda式要更直观一些。在C++11中,仍需要std::bind
的场景:
- 移动捕获。C++11的lambda式不能移动捕获,可以通过std::bind和lambda模拟移动捕获。
- 多态函数对象。因为绑定对象的函数调用运算符利用了完美转发,呀就可以接受任何类型的实参。这样,boundPW就可以通过任意类型的实参加以调用,C++11 lambda做不到这一点,但是C++14可以。因此,std::bind在C++14已经没啥用处了。
1
2
3
4
5
6
7
8class PolyWidget {
public:
template<typename T>
void operator()(const T& param);
};
// 使用std::bind绑定PolyWidget对象
PolyWidget pw;
auto boundPW = std::bind(pw, _1);1
auto boundPW = [pw](const auto& param) { pw(param); };
并发API
基于任务的程序设计