Effective c++
模板推导
对于传数组,由于c中数组和指针是等价的,因此,数组通过值传递的时候被视为一个指针,模板推导出来也是一个指针。然而,当作为引用传递的时候,其就不和指针等价了,可以推导出其类型和大小。
但实际上,这两个传进去的东西用起来是一样的。都是函数()。
auto
1 |
|
这两个实际上是等价的,除了一种情况。
当使用{}初始化的时候,auto会默认为std::initialize_list,而模板推导则不会默认,模板推导接受{}是必烂的。而如果是构造,如果这个{}能够符合两个类型的构造,那么就是歧义的。
返回类型的auto和lambda的形参auto虽然看着是auto,但实际上使用的规则是模板推导的规则而非auto的规则。
另外,lambda的形参使用auto相当于就是模板函数。
使用auto能够让类型上的依赖自动推导,不用牵一发而动全身。
decltype
decltype不是类型推导,decltype会忠诚的返回变量本身的类型。
然而,对于一个非引用值来说,如果他是复杂的表达式,即便是(param),param是T类型,也会让整个类型的推导变为T&。
由于auto是类型推导,为了让其使用decltype的规则,就应该使用decltype(auto),这个时候会把推导方法使用decltype的规则。
推导
编译器或者typeid产生的推导可能是不正确的,还是靠编译器编译的时候的解释比较靠谱。一种比较简单的方式是
使用auto的好处
- 避免没有初始化
- 省略冗长类型名
- 避免不必要的类型转换(如在不同平台typedef实际不同)及其产生的相关问题
不使用auto的场合
由于c++不允许对bit的引用,但是vector
是按bit存放的,因此,返回的并不是bool&,而是一个代理类,有着类似的功能。
然而,不显式的代理类一般是不被平常使用的,他是隐藏于使用但我们并不知道他被使用了,我们也不会主动去使用他,因为这可能会导致一些错误,就像工具人一样。
而使用auto会让这些代理类被显式使用,会导致错误。
为了贯彻auto的理念,使用 auto a=static_cast
而这也表明了,b可能不是这个类型,而我希望让他转换成type这个类型的意思。
()和{}
-
{}的好处
在任何情况下都可以用
避免了类型变窄的隐式转换
避免了默认初始化可能会被误判成函数声明的问题
-
{}的坏处
如果定义了以initialized_list为参数的构造函数(要避免),那么所有能够匹配到initialized_list的都会使用这个构造函数
当通过模板完美转发的时候,不知道使用者到底是想要使用{}还是()
使用nullptr
使用别名声明
- using看起来比typedef更易懂
- using支持模板,而typedef应该是一个宏,所以不支持
为什么要加typename的原因是::所得的成员不一定是一个类型,可能是一个实际成员。
使用限定域enum
避免了作用域的问题。
避免了隐式转化。
限定域enum默认最为int大小,而非限定没有默认,因此不能声明和定义分开。
而对于用来表示对应数字的问题,如对于tuple的get来说,是比较麻烦的,但为了贯彻限定域enum,可以
使用deleted
deleted能够达到与声明为private一样的效果。
- 能够在编译的时候就检测出来,而非在链接的时候。
- 针对任何函数
- 能够用来对于函数的参数进行过滤,虽然现在大概应该要用concept更好把
使用override
基类和派生类函数名必须完全一样(除非是析构函数),因此构造函数不能继承。
使用const_iterator
使用noexcept
移动,swap和析构都需要是noexcept的。
一个函数时候应该是noexcept应该要衡量noexcept带来的性能上的优势与实现noexcept带来的性能上的劣势。
另外,noexcept函数并不要求其调用的函数是noexcept(这是为了兼容老代码)。
使用constexpr
constexpr保证了变量是编译器可知的,因此编译器就会在编译期把他求出来,一个constexpr变量一定是const的。
constexpr函数则表示,这个函数的返回值可能是编译期可知的,也就是,可以用非constexpr参数去调用这个函数,返回的也不是constexpr的,但是,如果用constexpr的形参去调用,则返回的结果也是constexpr的,但不一定是const的。
所以如果一个函数的调用的形参是constexpr的,那么这个函数就应该被声明为constexpr的。
保证const函数线程安全
大概是为了保证其他函数的const性,所以某些函数也要被生成为const,至少表面看上去是const,而为了在里面修改内容,就需要用mutable
由于const函数是只读的,因此对于多线程是可以并行的。所以如果有mutable,那么就要进行保护,如使用atomic或者mutex。
一般来说atomic比mutex快,但是,对于多个mutable同时操作,atomic就并不能保证安全了,只能用mutex。
默认函数的生成
对于默认生成的移动函数来说,对于所有成员都会进行移动请求,可以移动的就会移动,但如果无法移动就会拷贝
拷贝的两个函数的生成是互不影响的。
移动当且仅当5大函数都没有声明的时候才会生成。
这些的逻辑是,如果声明了,那么就说明他和默认的不一样,那么连带着其他的也可能不一样
unique_ptr
比如说pimpl或者工厂都会使用unique_ptr。
shared_ptr
如果通过一个shared_ptr的this来构造shared_ptr,那么会烂掉。
这种情况要让这个被shared_ptr管理的类继承于enable_shared_from_this。
shared_ptr和unique_ptr都是原子的。
用make
使用make保证了当分配内存的时候他就必然已经被管理了。而new返回的指针来构造则并不能保证这一点。这样才是异常安全的。
但make不能自定义deleter。
make中使用的完美转发也会导致()和{}的内部使用问题。
对于重载了new和delete的类不能使用make。
Pimple和ptr
如果一个类中的成员的类型依赖于其他文件,这样就会导致依赖库的变化会导致整个的变化。
因此一般声明一个struct,并让类拥有一个指向这个struct的指针。
而这个struct的定义在cpp中实现,这样依赖就不在.h中了,大大减少了编译时间。
一般来说,这个指针用unique_ptr。
但是,不能使用默认生成的移动函数和析构函数。因为,这二者都会调用unique_ptr的delete,但是他的delete由于是一个类型参数,因此会去判断删除的类型是不是一个不完整类型,很显然,是的。
因此,二者的定义就必须延迟到struct定义完后才行,因此要在.h中声明,在.cpp中定义。
而shared_ptr就没有这个问题,因为他的删除器并不是一个模板参数。
std::move和std::forward
实际上只是进行了类型转换。std::move是不管左值还是右值都转换为右值。
而std::forward是只有当传入为右值时才将这个左值的右值转换为右值。
然而,这种转换并没有对cv做任何操作。因此,如果原本是一个const对象,并调用了std::move并去初始化对象,调用的会是拷贝构造函数,因为const还在。
通用引用
当带有类型推导的时候的&&,如auto&&和T&&都是通用引用,代表既有可能是左值也有可能是右值。
cv的出现也会让通用引用失效,如const auto&&就是烂的。
对通用引用用forward,对右值引用用move
如果在一个函数中,按值返回一个右值或者通用引用的形参,那么应该对这个返回值进行forward或者move,为了使他进行的是移动而非是拷贝。因为他是一个左值引用或者对右值的左值引用。
而如果返回的是非引用变量,就不应该这么做,因为有复制消除。
不要对通用引用进行重载
有的时候,为了转发一个参数,我们使用了通用引用。然而我们对这个函数进行重载了一个版本,仅仅将这个通用引用换成了一个普通的类型,然而,调用这个函数的时候,仍然会匹配到通用引用的版本。并且,如果一个构造函数只用了通用引用的单参数函数,那么这甚至可能会抢夺拷贝构造函数的饭碗。
解决方案
- 使用const T&
- 值传递,见倒数第二个点
- concept限制类型来进行重载
完美转发可以转发任意类型,因此,如果某种类型是非期待的,可能会由于完美转发导致到达很深的地方才产生错误,导致麻烦,因此可以在完美转发之前使用static_assert对这些情况进行更有效的错误汇报。
引用折叠
只有&& &&是&&,其他都是&。
移动操作的缺点
有的时候移动操作不一定会比普通的拷贝快多少,比如对一个数组的拷贝。
完美转发不能起效的情况
-
一个Args&&…的模板,传入一个{…},会被认为无法推导。
-
在转发的时候想转给一个指针,但是传入了0或者NULL,此时被推导为int。
-
对于一个static const的类成员,它是没有地址的,如果对它转发,也会烂。
-
转发函数的时候如果是直接通过函数名来指定函数,那么,如果这个函数名能代表多个函数,就会推导失败,无法判断是哪一个函数。普通的通过函数名来判断是因为有形参类型作为依据。
因此,要显式的指明这个函数名代表的函数对应的函数类型。
-
位域也不行。
不要使用默认捕获
捕获的变量可能为悬空。
捕获只会对自动存储器变量进行捕获。
- 如果是引用捕获,那么,如果离开了这个作用域而使用这个lambda,那么就会悬空。
- 如果是值捕获,确实没有1中的问题,但如果捕获的是指针,那么也会产生悬空。
如果在成员函数中使用lambda进行捕获,除了局部变量以外,并不会捕捉到成员,即便它是可见的。使用这些成员的方式是,捕获this指针。[this]代表按引用捕获,[*this]代表按值捕获。当使用&默认捕获时,会捕获。但用=时则不会捕获。
解决方法是,初始化捕获。[变量名=初始化值或&变量名=初始化变量],每个之间用,隔开,依然是引用或者值传递,但这样,就可以不限于只捕获局部变量了。
对于静态变量,是不被捕获的,如果在lambda所在作用域可见,那么在lambda中也可见,就像全局变量一样,外部的变化也会影响lambda。
这些依赖如果使用默认捕获不够明确,因此应该显式的捕获。
使用初始化捕获
如果是不支持初始化捕获的,那么可以使用类似bind的方法来实现移动。
对auto&&使用decltype和forward
一个泛型lambda可以放进任何其实例化类型的function中,只不过放入后就类型确定了。
如果要对auto&&捕获所得变量进行转发,由于不存在T类型,但是forward必须要指定对应类型,因此此时可以使用decltype(变量),如果是auto&&… param,那么可以std::forward<decltype(param)>(param)…
优先用lambda而非std::bind
使用基于任务的而非线程的并行
线程就是std::thread
而任务是std::async
std::thread不会去要求其返回值,然而std::async可以通过std::future去获取他的返回值。这样也能够捕获任务的异常,而std::thread就不能。
std::thread是手动开一个线程进行跑,线程的分配是我们自己进行掌管的,然而,当负载很大的时候,我们实际上并不能非常好的进行分配,从而产生性能上的问题,不如将这个任务交给标准库,让他进行管理,通过std::async就能实现,他不一定会开一个新的线程,而是根据情况来决定,而通过策略的改变也能进行调整。
除非需要访问非常基础的线程API或者需要且能够优化应用的线程使用。
如果有异步的必要则要用std::thread::async
std::async
的默认启动策略是异步和同步执行兼有的。- 这个灵活性导致访问
thread_local
s的不确定性,隐含了任务可能不会被执行的意思,会影响调用基于超时的wait
的程序逻辑。 - 如果异步执行任务非常关键,则指定
std::launch::async
。
保证所有线程在结束的时候是不可结合的
不可结合的包括,thread中没有包含内容,或者已经join或detach的。
如果对于一个可结合的线程隐式join或者detach都是非常恐怖的,因此,标准中如果对可结合的线程析构将会直接抛出异常。
可以使用RAII。
而在一个类中存储一个thread的时候,将thread放在最后,保证析构的异常安全。
条款38,待补充
条款39,待补充
volatile和atomic
atomic能保证操作的原子性和对代码重排顺序的限制(一种弱序关系),而volatile不行。
volatile则用来保证对这一块内存的操作不被优化。一般来说,如果两句语句之间没什么关系,那么编译器就会进行重排优化,重排完后,可能出现很多连续对同一个变量进行冗余访问和存储,因此就会被优化。
但是对一些特殊内存,如IO接口,实际上内容一直是在变化的,那么此时这些“冗余”访问和存储就不一定是冗余的了,不能被优化。
对移动成本低的且总是拷贝的形参使用值传递
一般为了处理左值和右值且高效,要重载两个函数或者使用通用引用。
然而有的时候使用值传递可能更便捷且增加不了多少开销。
值传递进来,并移动进去。
- 不可拷贝的值传递相当于进行了构造,不可接受
- 移动成本低,因为会增加一次移动成本
- 总是拷贝,因为如果不总是拷贝,那么按理来说就不会构造一个新的变量,引用确实没有构造,然而,值传递构造了。
有的时候,赋值操作会重用内存空间,但移动会增加内存申请和销毁的费用,这时候也不应该值传递。
在需要高性能的情况下,也不应该使用,因为移动的成本是不好估计的。
使用emplace
更快,但是要保证传入的实参是正确的