Merge pull request #32 from wendajiang/master

add Item41
This commit is contained in:
kelthuzadx 2020-06-28 20:37:43 +08:00 committed by GitHub
commit 867efc9162
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 348 additions and 2 deletions

View File

@ -0,0 +1,54 @@
# Item29: Assume that move operations are not present, not cheap, and not used
移动语义可以说是C++11最主要的特性。你可能会见过这些类似的描述“移动容器和拷贝指针一样开销小” “拷贝临时对象现在如此高效编码避免这种情况简直就是过早优化”这种情绪很容易理解。移动语义确实是这样重要的特性。它不仅允许编译器使用开销小的移动操作代替大开销的复制操作而且默认这么做。以C++98的代码为基础使用C++11重新编译你的代码然后你的软件运行的更快了。
移动语义确实令人振奋但是有很多夸大的说法这个Item的目的就是给你泼一瓢冷水保持理智看待移动语义。
让我们从已知很多类型不支持移动操作开始这个过程。为了升级到C++11C++98的很多标准库做了大修改为很多类型提供了移动的能力这些类型的移动实现比复制操作更快并且对库的组件实现修改以利用移动操作。但是很有可能你工作中的代码没有完整地利用C++11。对于你的应用中或者代码库中没有适配C++11的部分编译器即使支持移动语义也是无能为力的。的确C++11倾向于为缺少移动操作定义的类默认生成但是只有在没有声明复制操作移动操作或析构函数的类中才会生成移动操作参考Item17。禁止移动操作的类中通过delete move operation 参考Item11编译器不生成移动操作的支持。对于没有明确支持移动操作的类型并且不符合编译器默认生成的条件的类没有理由期望C++11会比C++98进行任何性能上的提升。
即使显式支持了移动操作结果可能也没有你希望的那么好。比如所有C++11的标准库都支持了移动操作但是任务移动所有容器的开销都非常小是个错误。对于某些容器来说压根就不存在开销小的方式来移动它所包含的内容。对另一些容器来说开销真正小的移动操作却使得容器元素移动含义事与愿违。
考虑一下`std::array`这是C++11中的新容器。`std::array`本质上是具有STL接口的内置数组。这与其他标准容器将内容存储在堆内存不同。存储具体数据在堆内存的容器本身只保存了只想堆内存数据的指针真正实现当然更复杂一些但是基本逻辑就是这样。这种实现使得在常数时间移动整个容器成为可能的只需要拷贝容器中保存的指针到目标容器然后将原容器的指针置为空指针就可以了。
```cpp
std::vector<Widget> vm1;
auto vm2 = std::move(vm1); // move vm1 into vm2. Runs in constant time. Only ptrs in vm1 and vm2 are modified
```
`std::array`没有这种指针实现,数据就保存在`std::array`容器中
```cpp
std::array<Widget, 10000> aw1;
auto aw2 = std::move(aw1); // move aw1 into aw2. Runs in linear time. All elements in aw1 are moved into aw2.
```
注意`aw1`中的元素被移动到了`aw2`中,这里假定`Widget`类的移动操作比复制操作快。但是使用`std::array`的移动操作还是复制操作都将话费线性时间的开销,因为每个容器中的元素终归需要拷贝一次,这与“移动一个容器就像操作几个指针一样方便”的含义想去甚远。
另一方面,`std::strnig`提供了常数时间的移动操作和线性时间的复制操作。这听起来移动比复制快多了,但是可能不一定。许多字符串的实现采用了*small string optimization(SSO)*。"small"字符串比如长度小于15个字符的存储在了`std::string`的缓冲区中,并没有存储在堆内存,移动这种存储的字符串并不必复制操作更快。
SSO的动机是大量证据表明短字符串是大量应用使用的习惯。使用内存缓冲区存储而不分配堆内存空间是为了更好的效率。然而这种内存管理的效率导致移动的效率并不必复制操作高。
即使对于支持快速移动操作的类型某些看似可靠的移动操作最终也会导致复制。Item14解释了原因标准库中的某些容器操作提供了强大的异常安全保证确保C++98的代码直接升级C++11编译器不会不可运行仅仅确保移动操作不会抛出异常才会替换为移动操作。结果就是即使类提供了更具效率的移动操作编译器仍可能被迫使用复制操作来避免移动操作导致的异常。
因此存在几种情况C++11的移动语义并无优势
- **No move operations**:类没有提供移动操作,所以移动的写法也会变成复制操作
- **Move not faster**:类提供的移动操作并不必复制效率更高
- **Move not usable**:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为`noexcept`
值得一提的是,还有另一个场景,会使得移动并没有那么有效率:
- **Source object is lvalue**:除了极少数的情况外(例如 Item25只有右值可以作为移动操作的来源
但是该Item的标题是假定不存在移动操作或者开销不小不使用移动操作。存在典型的场景就是编写模板代码因为你不清楚你处理的具体类型是什么。在这种情况下你必须像出现移动语义之前那样保守地考虑复制操作。不稳定的代码也是如此类的特性经常被修改导致可能移动操作会有问题。
但是通常你了解你代码里使用的类并且知道是否支持快速移动操作。这种情况你无需这个Item的假设只需要查找所用类的移动操作详细信息并且调用移动操作的上下文中可以安全的使用快速移动操作替换复制操作。
## 需要记住的事
- Assume that move operations are not present, not cheap, and not used.
- 完全了解的代码可以忽略本Item

292
8.Tweaks/Item41.md Normal file
View File

@ -0,0 +1,292 @@
# CHAPTER8 Tweaks
-------
对于C++中的通用技术总是存在适用场景。除了本章覆盖的两个例外描述什么场景使用哪种通用技术通常来说很容易。这两个例外是传值pass by value和 emplacement。决定何时使用这两种技术受到多种因素的影响本书提供的最佳建议是在使用它们的同时仔细考虑清除尽管它们都是高效的现代C++编程的重要角色。接下来的Items提供了是否使用它们来编写软件的所需信息。
## Item41.Consider pass by value for copyable parameters that are cheap to move and always copied 总是考虑直接按值传递,如果参数可拷贝并且移动操作开销很低
有些函数的参数是可复制的。比如说,`addName`成员函数可以拷贝自己的参数到一个私有容器。为了提高效率,应该拷贝左值,移动右值。
```cpp
class Widget {
public:
void addName(const std::string& newName) {
names.push_back(newName);
}
void addName(std::string&& newName) {
names.push_back(std::move(newName));
}
...
private:
std::vector<std::string> names;
};
```
这是可行的,但是需要编写两个同名异参函数,这有点让人难受:两个函数声明,两个函数实现,两个函数文档,两个函数的维护。唉。
此外你可能会担心程序的目标代码的空间占用当函数都内联inlined的时候会避免同时两个函数同时存在导致的代码膨胀问题但是一旦存在没有被内联inlined目标代码就是出现两个函数。
另一种方法是使`addName`函数成为具有通用引用的函数模板:参考Item24
```cpp
class Widget {
public:
template<typename T>
void addName(T&& newName) {
names.push_back(std::forward<T>(newName));
}
...
};
```
这减少了源代码的维护工作,但是通用引用会导致其他复杂性。作为模板,`addName`的实现必须放置在头文件中。在编译器展开的时候,可能会不止为左值和右值实例化为多个函数,也可能为`std::string`和可转换为`std::string`的类型分别实例化为多个函数参考Item25。同时有些参数类型不能通过通用引用传递参考Item30而且如果传递了不合法的参数类型编译器错误会令人生畏。参考Item27
是否存在一种编写`addName`的方法左值拷贝右值移动而且源代码和目标代码中都只有一个函数避免使用通用模板这种特性答案是是的。你要做的就是放弃你学习C++编程的第一条规则,就是用户定义的对象避免传值。像是`addName`函数中的`newName`参数,按值传递可能是一种完全合理的策略。
在我们讨论为什么对于`addName`中的`newName`参数按值传递非常合理之前,让我们来考虑如下实现:
```cpp
class Widget {
public:
void addName(std::string newName) {
names.push_back(std::move(newName));
}
...
}
```
该代码唯一可能令人困惑的部分就是`std::move`这里。`std::move`典型的应用场景是用在右值引用但是在这里我们了解到的信息1`newName`是完全复制的传递进来的对象换句话说改变不会影响原值2`newName`的最终用途就在这个函数里,不会再做他用,所以移动它不会影响其他代码。
事实就是我们只编写了一个`addName`函数避免了源代码和目标代码的重复。我们没有使用通用引用的特性不会导致头文件膨胀odd failure cases(这里不知道咋翻译),或者令人困惑的错误问题(编译)。但是这种设计的效率如何呢?按值传值会不会开销很大?
在C++98中可以肯定的是无论调用者如何调用参数`newName`都是拷贝传递。但是在C++11中`addName`就是左值拷贝,右值移动,来看如下例子:
```cpp
Widget w;
...
std::string name("Bart");
w.addName(name); // call addName with lvalue
...
w.addName(name + "Jenne"); // call addName with rvalue
```
第一处调用,`addName`的参数是左值因此是拷贝构造参数就像在C++98中一样。第二处调用参数是一个临时值是一个右值因此`newName`的参数是移动构造的。
就像我们想要的那样,左值拷贝,右值移动,优雅吧?
优雅,但是要牢记一些警示,回顾一下我们考虑过的三个版本的`addName`:
```cpp
class Widget { // Approach 1
public:
void addName(const std::string& newName) {
names.push_back(newName);
}
void addName(std::string&& newName) {
names.push_back(std::move(newName));
}
...
private:
std::vector<std::string> names;
};
class Widget { // Approach 2
public:
template<typename T>
void addName(T&& newName) {
names.push_back(std::forward<T>(newName));
}
...
};
class Widget { // Approach 3
public:
void addName(std::string newName) {
names.push_back(std::move(newName));
}
...
};
```
本书将前两个版本称为“按引用方法”,因为都是通过引用传递参数,仍然考虑这两种调用方式:
```cpp
Widget w;
...
std::string name("Bart");
w.addName(name); // call addName with lvalue
...
w.addName(name + "Jenne"); // call addName with rvalue
```
现在分别考虑三种实现中,两种调用方式,拷贝和移动操作的开销。会忽略编译器对于移动和拷贝操作的优化。
- **Overloading重载**:无论传递左值还是传递右值,调用都会绑定到一种`newName`的引用实现方式上。拷贝和复制零开销。左值重载中,`newName`拷贝到`Widget::names`中,右值重载中,移动进去。开销总结:左值一次拷贝,右值一次移动。
- **Using a universal reference通用模板方式**:同重载一样,调用也绑定到`addName`的引用实现上,没有开销。由于使用了`std::forward`,左值参数会复制到`Widget::names`,右值参数移动进去。开销总结同重载方式。
Item25 解释了如果调用者传递的参数不是`std::string`类型,将会转发到`std::string`的构造函数(几乎是零开销的拷贝或者移动操作)。因此通用引用的方式同样有同样效率,所以者不影响本次分析,简单分析`std::string`参数类型即可。
- **Passing by value按值传递**:无论传递左值还是右值,都必须构造`newName`参数。如果传递的是左值,需要拷贝的开销,如果传递的是右值,需要移动的开销。在函数的实现中,`newName`总是采用移动的方式到`Widget::names`。开销总结:左值参数,一次拷贝一次移动,右值参数两次移动。对比按引动传递的方法,对于左值或者右值,均多出一次移动操作。
再次回顾本Item的内容
```
总是考虑直接按值传递,如果参数可拷贝并且移动操作开销很低
```
这样措辞是有原因的:
1. 应该仅*consider using pass by value*。是的,因为只需要编写一个函数,同时只会在目标代码中生成一个函数。避免了通用引用方式的种种问题。但是毕竟开销会更高,而且下面还会讨论,还会存在一些目前我们并未讨论到的开销。
2. 仅考虑对于*copable parameters*按值传递。不符合此条件的的参数必须只有移动构造函数。回忆一下“重载”方案的问题,就是必须编写两个函数来分别处理左值和右值,如果参数没有拷贝构造函数,那么只需要编写右值参数的函数,重载方案就搞定了。
考虑一下`std::unique_ptr<std::string>`的数据成员和其`set`函数。因为`std::unique_ptr`是仅可移动的类型,所以考虑使用“重载”方式编写即可:
```cpp
class Widget {
public:
...
void setPtr(std::unique_ptr<std::string>&& ptr) {
p = std::move(ptr);
}
private:
std::unique_ptr<std::string> p;
};
```
调用者可能会这样写:
```cpp
Widget w;
...
w.setPtr(std::make_unique<std::string>("Modern C++"));
```
这样,传递给`setPtr`的参数就是右值,整体开销就是一次移动。如果使用传值方式编写:
```cpp
class Widget {
public:
...
void setPtr(std::unique_ptr<std::string> ptr) {
p = std::move(ptr);
}
private:
std::unique_ptr<std::string> p;
};
```
同样的调用就会先使用移动构造函数移动到参数`ptr`,然后再移动到`p`,整体开销就是两次移动。
3. 按值传递应该仅应用于哪些*cheap to move*的参数。当移动的开销较低,额外的一次移动才能被开发者接受,但是当移动的开销很大,执行不必要的移动类似不必要的复制时,这个规则就不适用了。
4. 你应该只对*always copied肯定复制*的参数考虑按值传递。为了看清楚为什么这很重要,假定在复制参数到`names`容器前,`addName`需要检查参数的长度是否过长或者过短,如果是,就忽略增加`name`的操作:
```cpp
class Widget { // Approach 3
public:
void addName(std::string newName) {
if ((newName.length() >= minLen) && (newName.length() <= maxLen)) {
names.push_back(std::move(newName));
}
}
...
private:
std::vector<std::string> names;
};
```
即使这个函数没有在`names`添加任何内容,也增加了构造和销毁`newName`的开销,而按引用传递会避免这笔开销。
即使你编写的函数是移动开销小的参数而且无条件复制,有时也可能不适合按值传递。这是因为函数复制参数存在两种方式:一种是通过构造函数(拷贝构造或者移动构造),还有一种是赋值(拷贝赋值或者移动赋值)。`addName`使用构造函数,它的参数传递给`vector::push_back`,在这个函数内部,`newName`是通过构造函数在`std::vector`创建一个新元素。对于使用构造函数拷贝参数的函数,上述分析已经可以给出最终结论:按值传递对于左值和右值均增加了一次移动操作的开销。
当参数通过赋值操作进行拷贝时,分析起来更加复杂。比如,我们有一个表征密码的类,因为密码可能会被修改,我们提供了`setter`函数`changeTo`。用按值传递的策略,我们实现一个密码类如下:
```cpp
class Password {
public:
explicit Password(std::string pwd) : text(std::move(pwd)) {}
void changeTo(std::string newPwd) {
text = std::move(newPwd);
}
...
private:
std::string text;
};
```
将密码存储为纯文本格式恐怕将使你的软件安全团队抓狂,但是先忽略这点考虑这段代码:
```cpp
std::string initPwd("Supercalifragilisticexpialidocious");
Password p(initPwd);
```
`p.text`被给定的密码构造,用按值传递的方式增加了一次移动操作的开销相对于重载或者通用引用,但是这无关紧要,一切看起来如此美好。
但是,该程序的用户可能对初始密码不太满意,因为这段密码`"Supercalifragilisticexpialidocious"`在许多字典中可以被发现。他或者她因此修改密码:
```cpp
std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);
```
不用关心新密码是不是比就密码更好,那是用户关心的问题。我们对于`changeTo`函数的按值传递实现方案会导致开销大大增加。
传递给`changeTo`的参数是一个左值(`newPassword`),所以`newPwd`参数需要被构造,`std::string`的拷贝构造函数会被调用,这个函数会分配新的存储空间给新密码。`newPwd`会移动赋值到`text`,这会导致释放旧密码的内存。所以`changeTo`存在两次动态内存管理的操作:一次是为新密码创建内存,一次是销毁旧密码的内存。
但是在这个例子中,旧密码比新密码长度更长,所以本来不需要分配新内存,销毁就内存的操作。如果使用重载的方式,两次动态内存管理操作可以避免:
```cpp
class Password {
public:
...
void changeTo(std::string& newPwd) {
text = newPwd;
}
...
private:
std::string text;
};
```
这种情况下,按值传递的开销(包括了内存分配和内存销毁)可能会比`std::string`的`move`操作高出几个数量级。
有趣的是,如果旧密码短于新密码,在赋值过程中就不可避免要重新分配内存,这种情况,按值传递跟按引用传递的效率是一样的。因此,参数的赋值操作开销取决于具体的参数的值,这种分析适用于动态分配内存的参数类型。
这种潜在的开销增加仅在传递左值参数时才适用,因为执行内存分配和释放通常发生在复制操作中。
结论是,使用按值传递的函数通过赋值复制一个参数的额外开销取决于传递的类型中左值和右值的比例,即这个值是否需要动态分配内存,以及赋值操作符的具体实现中对于内存的使用。对于`std::string`来说,取决于实现是否使用了小字符串优化(SSO 参考Item 29)如果是值是否匹配SSO缓冲区。
所以,正如我所说,当参数通过赋值进行拷贝时,分析按值传递的开销是复杂的。通常,最有效的经验就是“在证明没问题之前假设有问题”,就是除非已证明按值传递会为你需要的参数产生可接受开销的执行效率,否则使用重载或者通用引用的实现方式。
到此为止,对于需要运行尽可能快的软件来说,按值传递可能不是一个好策略,因为毕竟多了一次移动操作。此外,有时并不能知道是不是还多了其他开销。在`Widget::addName`例子中,按值传递仅多了一次移动操作,但是如果加入值的一些校验,可能按值传递就多了创建和销毁类型的开销相对于重载和通用引用的实现方式。
可以看到导致的方向,在调用链中,每次调用多了一次移动的开销,那么当调用链较长,总体就会产生无法忍受的开销,通过引用传递,调用链不会增加任何开销。
跟性能无关总是需要考虑的是按值传递不像按引用传递那样会收到切片问题的影响。这是C++98的问题在此不在详述但是如果要设计一个函数来处理这样的参数基类或者其派生类如果不想声明为按值传递因为你就是要分割派生类型
```cpp
class Widget{...};
class SpecialWidget: public Widget{...};
void processWidget(Widget w);
...
SecialWidget sw;
...
processWidget(sw);
```
如果不熟悉**slicing problem**可以先通过搜索引擎了解一下。这样你就知道切片问题是另一个C++98中默认按值传递名声不好的原因。有充分的理由来说明为什么你学习C++编程的第一件事就是避免用户自定义类型进行按值传递。
C++11没有从根本上改变C++98按值传递的基本盘通常按值传递仍然会带来你希望避免的性能下降而且按值传递会导致切片问题。C++11中新的功能是区分了左值和右值实现了可移动类型的移动语义尽管重载和通用引用都有其缺陷。对于特殊的场景复制参数总是会被拷贝而且移动开销小的函数可以按值传递这种场景通常也不会有切片问题这时按值传递就提供了一种简单的实现方式同时实现了接近引用传递的开销的效率。
## 需要记住的事
- 对于可复制,移动开销低,而且无条件复制的参数,按值传递效率基本与按引用传递效率一致,而且易于实现,生成更少的目标代码
- 通过构造函数拷贝参数可能比通过赋值拷贝开销大的多
- 按值传递会引起切片问题,所说不适合基类类型的参数

View File

@ -46,7 +46,7 @@
4. Item 26:避免重载通用引用
5. Item 27:熟悉重载通用引用的替代品
6. Item 28:理解引用折叠
7. Item 29:认识移动操作的缺点
7. [Item 29:认识移动操作的缺点](https://github.com/kelthuzadx/EffectiveModernCppChinese/blob/master/5.RvalueReferences_MovingSemantics_And_PerfectForwarding/item29.md) __由 @wendajiang贡献__
8. Item 30:熟悉完美转发失败的情况
6. Lambda表达式
1. [Item 31:避免使用默认捕获模式](https://github.com/kelthuzadx/EffectiveModernCppChinese/blob/master/6.Lambda%20Expressions/item31.md) __由 @LucienXian贡献__
@ -61,7 +61,7 @@
5. Item 39:考虑对于单次事件通信使用void
6. Item 40:对于并发使用std::atomicvolatile用于特殊内存区
8. 微调
1. Item 41:对于那些可移动总是被拷贝的形参使用传值方式
1. [Item 41:对于那些可移动总是被拷贝的形参使用传值方式](https://github.com/kelthuzadx/EffectiveModernCppChinese/blob/master/8.Tweaks/item41.md) __由 @wendajiang贡献__
2. Item 42:考虑就地创建而非插入
## 贡献者