finish item26; item27 to be continued

This commit is contained in:
wendajiang 2020-10-26 00:19:41 +08:00
parent 233e990a12
commit c0e9b94ee4
2 changed files with 359 additions and 1 deletions

View File

@ -1,3 +1,186 @@
## Item 26: Avoid overloading on universal references
## Item 26: 避免在通用引用上重载
## Item 26: 避免在通用引用上重载
假定你需要写一个函数它使用name这样一个参数打印当前日期和具体时间到日志中然后将name加入到一个全局数据结构中。你可能写出来这样的代码
```cpp
std::multiset<std::string> names; // global data structure
void logAndAdd(const std::string& name)
{
auto now = std::chrono::system_lock::now(); // get current time
log(now, "logAndAdd"); // make log entry
names.emplace(name); // add name to global data structure; see Item 42 for info on emplace
}
```
这份代码没有问题,但是同样的也没有效率。考虑这三个调用:
```cpp
std::string petName("Darla");
logAndAdd(petName); // pass lvalue std::string
logAndAdd(std::string("Persephone")); // pass rvalue std::string
logAndAdd("Patty Dog"); // pass string literal
```
在第一个调用中,`logAndAdd`使用变量作为参数。在`logAndAdd`中`name`最终也是通过`emplace`传递给`names`。因为`name`是左值,会拷贝到`names`中。没有方法避免拷贝,因为是左值传递的。
在第三个调用中,参数`name`绑定一个右值,但是这次是通过"Patty Dog"隐式创建的临时`std::string`变量。在第二个调用总,`name`被拷贝到`names`,但是这里,传递的是一个字符串字面量。直接将字符串字面量传递给`emplace`,不会创建`std::string`的临时变量,而是直接在`std::multiset`中通过字面量构建`std::string`。在第三个调用中,我们会消耗`std::string`的拷贝开销,但是连移动开销都不想有,更别说拷贝的。
我们可以通过使用通用引用参见Item 24重写第二个和第三个调用来使效率提升按照Item 25的说法`std::forward`转发引用到`emplace`。代码如下:
```cpp
template<typename T>
void logAndAdd(T&& name)
{
auto now = std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string petName("Darla"); // as before
logAndAdd(petName); // as before , copy
logAndAdd(std::string("Persephone")); // move rvalue instead of copying it
logAndAdd("Patty Dog"); // create std::string in multiset instead of copying a temporary std::string
```
非常好,效率优化了!
在故事的最后我们可以骄傲的交付这个代码但是我没有告诉你client不总是有访问`logAndAdd`要求的`names`的权限。有些clients只有`names`的下标。为了支持这种client`logAndAdd`需要重载为:
```cpp
std::string nameFromIdx(int idx); // return name corresponding to idx
void logAndAdd(int idx)
{
auto now = std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}
```
之后的两个调用按照预期工作:
```cpp
std::string petName("Darla");
logAndAdd(petName);
logAndAdd(std::string("Persephone"));
logAndAdd("Patty Dog"); // these calls all invoke the T&& overload
logAndAdd(22); // calls int overload
```
事实上这只能基本按照预期工作假定一个client将`short`类型当做下标传递给`logAndAdd`:
```cpp
short nameIdx;
...
logAndAdd(nameIdx); // error!
```
之后一行的error说明并不清楚下面让我来说明发生了什么。
有两个重载的`logAndAdd`。一个使用通用应用推导出T的类型是`short`,因此可以精确匹配。对于`int`参数类型的重载`logAndAdd`也可以`short`类型提升后匹配成功。根据正常的重载解决规则,精确匹配优先于类型提升的匹配,所以被调用的是通用引用的重载。
在通用引用中的实现中,将`short`类型`emplace`到`std::string`的容器中,发生了错误。所有这一切的原因就是对于`short`类型通用引用重载优先于`int`类型的重载。
使用通用引用类型的函数在C++中是贪婪函数。他们机会可以精确匹配任何类型的参数极少不适用的类型在Item 30中介绍。这也是组合重载和通用引用使用是糟糕主意的原因通用引用的实现会匹配比开发者预期要多得多的参数类型。
一个更容易调入这种陷阱的例子是完美转发构造函数。简单对`logAndAdd`例子进行改造就可以说明这个问题。将使用`std::string`类型改为自定义`Person`类型即可:
```cpp
class Person
{
public:
template<typename T>
explicit Person(T&& n) :name(std::forward<T>(n)) {} // perfect forwarding ctor; initializes data member
explicit Person(int idx): name(nameFromIdx(idx)) {}
...
private:
std::string name;
};
```
在`logAndAdd`的例子中传递一个不是int的整型变量比如`std::size_t, short, long`等会调用通用引用的构造函数而不是int的构造函数这会导致编译错误。这里这个问题甚至更糟糕因为`Person`中存在的重载比肉眼看到的更多。在Item 17中说明在适当的条件下C++会生成拷贝和移动构造函数即使类包含了模板构造也在合适的条件范围内。如果拷贝和移动构造被生成Person类看起来就像这样
```cpp
class Person
{
public:
template<typename T>
explicit Person(T&& n) :name(std::forward<T>(n)) {} // perfect forwarding ctor
explicit Person(int idx); // int ctor
Person(const Person& rhs); // copy ctor(complier-generated)
Person(Person&& rhs); // move ctor (compiler-generated)
...
};
```
只有你在花了很多时间在编译器领域时,下面的行为才变得直观(译者注:这里意思就是这种实现会导致不符合人类直觉的结果,下面就解释了这种现象的原因)
```cpp
Person p("Nancy");
auto cloneOfP(p); // create new Person from p; this won't compile!
```
这里我们视图通过一个`Person`实例创建另一个`Person`显然应该调用拷贝构造即可p是左值我们可以思考通过移动操作来消除拷贝的开销。但是这份代码不是调用拷贝构造而是调用完美转发构造。然后该函数将尝试使用Person对象p初始化`Person`的`std::string`的数据成员,编译器就会报错。
“为什么”你可能会疑问“为什么拷贝构造会被完美转发构造替代我们显然想拷贝Person到另一个Person”。确实我们是这样想的但是编译器严格遵循C++的规则,这里的相关规则就是控制对重载函数调用的解析规则。
编译器的理由如下:`cloneOfP`被`non-const`左值p初始化这意味着可以实例化模板构造函数为采用`Person`的`non-const`左值。实例化之后,`Person`类看起来是这样的:
```cpp
class Person {
public:
explicit Person(Person& n) // instantiated from
: name(std::forward<Person&>(n)) {} // perfect-forwarding // template
explicit Person(int idx); // as before
Person(const Person& rhs); // copy ctor (compiler-generated)
...
};
```
在`auto cloneOfP(p);`语句中p被传递给拷贝构造或者完美转发构造。调用拷贝构造要求在p前加上const的约束而调用完美转发构造不需要任何条件所以编译器按照规则采用最佳匹配这里调用了完美转发的实例化的构造函数。
如果我们将本例中的传递的参数改为const的会得到完全不同的结果
```cpp
const Person cp("Nancy");
auto cloneOfP(cp); // call copy constructor!
```
因为被拷贝的对象是const是拷贝构造函数的精确匹配。虽然模板参数可以实例化为完全一样的函数签名
```cpp
class Person {
public:
explicit Person(const Person& n); // instantiated from template
Person(const Person& rhs); // copy ctor(compiler-generated)
...
};
```
但是无所谓,因为重载规则规定当模板实例化函数和非模板函数(或者称为“正常”函数)匹配优先级相当时,优先使用“正常”函数。拷贝构造函数(正常函数)因此胜过具有相同签名的模板实例化函数。
如果你想知道为什么编译器在生成一个拷贝构造函数时还会模板实例化一个相同签名的函数参考Item17
当继承纳入考虑范围时,完美转发的构造函数与编译器生成的拷贝、移动操作之间的交互会更加复杂。尤其是,派生类的拷贝和移动操作会表现的非常奇怪。来看一下:
```cpp
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) :Person(rhs)
{...} // copy ctor; calls base class forwarding ctor!
SpecialPerson(SpecialPerson&& rhs): Person(std::move(rhs))
{...} // move ctor; calls base class forwarding ctor!
};
```
如同注释表示的,派生类的拷贝和移动构造函数没有调用基类的拷贝和移动构造函数,而是调用了基类的完美转发构造函数!为了理解原因,要知道派生类使用`SpecialPerson`作为参数传递给其基类,然后通过模板实例化和重载解析规则作用于基类。最终,代码无法编译,因为`std::string`没有`SpecialPerson`的构造函数。
我希望到目前为止已经说服了你如果可能的话避免对通用引用的函数进行重载。但是如果在通用引用上重载是糟糕的主意那么如果需要可转发大多数类型的参数但是对于某些类型又要特殊处理应该怎么办存在多种办法。实际上下一个ItemItem27专门来讨论这个问题敬请阅读。
### 需要记住的事
- 对通用引用参数的函数进行重载,调用机会会比你期望的多得多
- 完美转发构造函数是糟糕的实现,因为对于`non-const`左值不会调用拷贝构造而是完美转发构造,而且会劫持派生类对于基类的拷贝和移动构造

View File

@ -1,2 +1,177 @@
## Item 27: Familiarize yourself with alternatives to overloading on universal references
## Item27:熟悉通用引用重载的替代方法
Item 26中说明了对使用通用引用参数的函数无论是独立函数还是成员函数尤其是构造函数进行重载都会导致一系列问题。但是也提供了一些示例如果能够按照我们期望的方式运行重载可能也是有用的。这个Item探讨了几种通过避免在通用引用上重载的设计或者通过限制通用引用可以匹配的参数类型的方式来实现所需行为的方案。
讨论基于Item 26中的示例如果你还没有阅读Item 26请先阅读在继续本Item的阅读。
### Abandon overloading
在Item 26中的第一个例子中`logAndAdd`代表了许多函数,这些函数可以使用不同的名字来避免在通用引用上的重载的弊端。例如两个重载的`logAndAdd`函数,可以分别改名为`logAndAddName`和`logAndAddNameIdx`。但是这种方式不能用在第二个例子Person构造函数中因为构造函数的名字本类名固定了。此外谁愿意放弃重载呢
### Pass by const T&
一种替代方案是退回到C++98然后将通用引用替换为const的左值引用。事实上这是Item 26中首先考虑的方法。缺点是效率不高会有拷贝的开销。现在我们知道了通用引用和重载的组合会导致问题所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。
### Pass by value
通常在不增加复杂性的情况下提高性能的一种方法是将按引用传递参数替换为按值传递这是违反直觉的。该设计遵循Item 41中给出的建议即在你知道要拷贝时就按值传递因此会参考Item 41来详细讨论如何设计与工作效率如何。这里在Person的例子中展示
```cpp
class Person {
public:
explicit Person(std::string p) // replace T&& ctor; see
: name(std::move(n)) {} // Item 41 for use of std::move
explicit Person(int idx)
: name(nameFromIdx(idx)) {}
...
private:
std::string name;
};
```
因为没有`std::string`构造器可以接受整型参数,所有`int`或者其他整型变量(比如`std::size_t、short、long`等)都会使用`int`类型重载的构造函数。相似的,所有`std::string`类似的参数(字面量等)都会使用`std::string`类型的重载构造函数。没有意外情况。我想你可能会说有些人想要使用0或者NULL会调用`int`重载的构造函数但是这些人应该参考Item 8反复阅读指导使用0或者NULL作为空指针让他们恶心。
### Use Tag dispatch
传递`const`左值引用参数以及按值传递都不支持完美转发。如果使用通用引用的动机是完美转发,我们就只能使用通用引用了,没有其他选择。但是又不想放弃重载。所以如果不放弃重载又不放弃通用引用,如何避免咋通用引用上重载呢?
实际上并不难。通过查看重载的所有参数以及调用的传入参数,然后选择最优匹配的函数----计算所有参数和变量的组合。通用引用通常提供了最优匹配但是如果通用引用是包含其他非通用引用参数列表的一部分则不是通用引用的部分会影响整体。这基本就是tag dispatch 方法,下面的示例会使这段话更容易理解。
我们将tag dispatch应用于`logAndAdd`例子下面是原来的代码以免你找不到Item 26的代码位置
```cpp
std::multiset<std::string> names; // global data structure
template<typename T> // make log entry and add
void logAndAdd(T&& name)
{
auto now = std::chrono::system_clokc::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
```
就其本身而言,功能执行没有问题,但是如果引入一个`int`类型的重载就会重新陷入Item 26中描述的麻烦。这个Item的目标是避免它。不通过重载我们重新实现`logAndAdd`函数分拆为两个函数,一个针对整型值,一个针对其他。`logAndAdd`本身接受所有的类型。
这两个真正执行逻辑的函数命名为`logAndAddImpl`使用重载。一个函数接受通用引用参数。所以我们同时使用了重载和通用引用。但是每个函数接受第二个参数表征传入的参数是否为整型。这第二个参数可以帮助我们避免陷入到Item 26中提到的麻烦中因为我们将其安排为第二个参数决定选择哪个重载函数。
是的,我知道,“不要在啰嗦了,赶紧亮出代码”。没有问题,代码如下,这是最接近正确版本的:
```cpp
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(std::forward<T>(name),
std::is_integral<T>()); // not quite correct
}
```
这个函数转发它的参数给`logAndAddImpl`函数但是多传递了一个表示是否T为整型的变量。至少这就是应该做的。对于右值的整型参数来说这也是正确的。但是如同Item 28中说明如果左值参数传递给通用引用`name`类型推断会使左值引用。所以如果左值int被传入`logAndAdd`T将被推断为`int&`。这不是一个整型类型,因为引用不是整型类型。这意味着`std::is_integral<T>`对于左值参数返回false即使确实传入了整型值。
意识到这个问题基本相当于解决了它因为C++标准库有一个类型trait参见Item 9`std::remove_reference`,函数名字就说明做了我们希望的:移除引用。所以正确实现的代码应该是这样:
```cpp
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(std::forward<T>(name),
std::is_instegral<typename std::remove_reference<T>::type>());
}
```
这个代码很巧妙。在C++14中你可以通过`std::remove_reference_t<T>`来简化写法参看Item 9
处理完之后,我们可以将注意力转移到名为`logAndAddImpl`的函数上了。有两个重载函数,第一个仅用于非整型类型(即`std::is_instegral<typename std::remove_reference<T>::type>()`是`false`
```cpp
template<typename T>
void logAndAddImpl(T&& name, std::false_type) // 高亮为std::false_type
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
```
一旦你理解了高亮参数的含义代码就很直观。概念上,`logAndAdd`传递一个布尔值给`logAndAddImpl`表明是否传入了一个整型类型,但是`true`和`false`是运行时值,我们需要使用编译时决策来选择正确的`logAndAddImpl`重载。这意味着我们需要一个类型对应`true``false`同理。这个需要是经常出现的,所以标准库提供了这样两个命名`std::true_type and std::false_type`。`logAndAdd`传递给`logAndAddImpl`的参数类型取决于T是否整型如果T是整型它的类型就继承自`std::true_type`,反之继承自`std::false_type`。最终的结果就是当T不是整型类型时这个`logAndAddImpl`重载会被调用。
第二个重载覆盖了相反的场景当T是整型类型。在这个场景中`logAndAddImpl`简单找到下标处的`name`,然后传递给`logAndAdd`
```cpp
std::string nameFromIdx(int idx); // as in item 26
void logAndAddImpl(int idx, std::true_type) // 高亮std::true_type
{
logAndAdd(nameFromIdx(idx));
}
```
通过下标找到对应的`name`,然后让`logAndAddImpl`传递给`logAndAdd`,我们避免了将日志代码放入这个`logAndAddImpl`重载中。
在这个设计中,类型`std::true_type`和`std::false_type`是“标签”其唯一目的就是强制重载解析按照我们的想法来执行。注意到我们甚至没有对这些参数进行命名。他们在运行时毫无用处事实上我们希望编译器可以意识到这些tag参数是无用的然后在程序执行时优化掉它们至少某些时候有些编译器会这样做。这种在`logAndAdd`内部的通过tag来实现重载实现函数的“分发”因此这个设计名称为**tag dispatch**。这是模板元编程的标准构建模块你对现代C++库中的代码了解越多,你就会越多遇到这种设计。
就我们的目的而言tag dispatch的重要之处在于它可以允许我们组合重载和通用引用使用而没有Item 26中提到的问题。分发函数---`logAndAdd`----接受一个没有约束的通用引用参数,但是这个函数没有重载。实现函数---`logAndAddImpl`----是重载的一个接受通用引用参数但是重载规则不仅依赖通用引用参数还依赖新引入的tag参数。结果是tag来决定采用哪个重载函数。通用引用参数可以生成精确匹配的事实在这里并不重要。译者注这里确实比较啰嗦如果理解了上面的内容这段完全可以没有。
### Constraining templates that take universal references约束使用通用引用的模板
tag dispatch的关键是存在单独一个函数没有重载给客户端API。这个单独的函数分发给具体的实现函数。创建一个没有重载的分发函数通常是容易的但是Item 26中所述第二个问题案例是`Person`类的完美转发构造函数是个例外。编译器可能会自行生成拷贝和移动构造函数所以即使你只写了一个构造函数并在其中使用tag dispatch编译器生成的构造函数也打破了你的期望。
实际上真正的问题不是编译器生成的函数会绕过tag diapatch设计而是不总会绕过tag dispatch。你希望类的拷贝构造总是处理该类型的`non-const`左值构造请求但是如同Item 26中所述提供具有通用引用的构造函数会使通用引用构造函数被调用而不是拷贝构造函数。还说明了当一个基类声明了完美转发构造函数派生类实现自己的拷贝和移动构造函数时会发生错误的调用调用基类的完美转发构造函数而不是基类的拷贝或者移动构造
这种情况采用通用引用的重载函数通常比期望的更加贪心但是有不满足使用tag dispatch的条件。你需要不同的技术可以让你确定允许使用通用引用模板的条件。朋友你需要的就是`std::enable_if`。
`std::enable_if`
### Trade-offs (权衡,折中)
本Item提到的前三个技术---abandoning overloading, passing by const T&, passing by value---在函数调用中指定每个参数的类型。后两个技术----tag dispatch和 constraing template eligibility----使用完美转发,因此不需要指定参数类型。这一基本决定(是否指定类型)有一定后果。
通常,完美转发更有效率,因为它避免了仅处于符合参数类型而创建临时对象。在`Person`构造函数的例子中,完美转发允许将`Nancy`这种字符串字面量转发到容器内部的`std::string`构造器,不使用完美转发的技术则会创建一个临时对象来满足传入的参数类型。
但是完美转发也有缺点。·即使某些类型的参数可以传递给特定类型的参数的函数也无法完美转发。Item 30中探索了这方面的例子。
第二个问题是当client传递无效参数时错误消息的可理解性。例如假如创建一个`Person`对象的client传递了一个由`char16_t`一种C++11引入的类型表示16位字符而不是`char``std::string`包含的):
```cpp
Person p(u"Konrad Zuse"); // "Konrad Zuse" consists of characters of type const char16_t
```
使用本Item中讨论的前三种方法编译器将看到可用的采用`int`或者`std::string`的构造函数,并且它们或多或少会产生错误消息,表示没有可以从`const char16_t`转换为`int`或者`std::string`的方法。
但是,基于完美转发的方法,`const char16_t`不受约束地绑定到构造函数的参数。从那里将转发到`Person`的`std::string`的构造函数,在这里,调用者传入的内容(`const char16_t`数组)与所需内容(`std::string`构造器可接受的类型)发生的不匹配会被发现。由此产生的错误消息会让人更容易理解在我使用的编译器上会产生超过160行错误信息。
在这个例子中,通用引用仅被转发一次(从`Person`构造器到`std::string`构造器),但是更复杂的系统中,在最终通用引用到达最终判断是否可接受的函数之前会有多层函数调用。通用引用被转发的次数越多,产生的错误消息偏差就越大。许多开发者发现仅此问题就是在性能优先的接口使用通用引用的障碍。(译者注:最后一句话可能翻译有误,待确认)
在`Person`这个例子中,我们知道转发函数的通用引用参数要支持`std::string`的初始化,所以我们可以用`static_assert`来确认是不是支持。`std::is_constructible` type trait执行编译时测试一个类型的对象是否可以构造另一个不同类型的对象所以代码可以这样
```cpp
class 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))
{
//assert that a std::string can be created from a T object(这里到...高亮)
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);
... // the usual ctor work goes here
}
... // remainder of Person class (as before)
};
```
如果client代码尝试使用无法构造`std::string`的类型创建`Person`,会导致指定的错误消息。不幸的是,在这个例子中,`static_assert`在构造函数体中,但是作为成员初始化列表的部分在检查之前。所以我使用的编译器,结果是由`static_assert`产生的清晰的错误消息在常规错误消息最多160行以上那个后出现。
### 需要记住的事
- 通用引用和重载的组合替代方案包括使用不同的函数名通过const左值引用传参按值传递参数使用tag dispatch
- 通过`std::enable_if`约束模板,允许组合通用引用和重载使用,`std::enable_if`可以控制编译器哪种条件才使用通用引用的实例
- 通用引用参数通常具有高效率的优势,但是可用性就值得斟酌