前言
上一节描述了针对universal reference作重载的函数可能出现的各种问题,本节将在上一节讨论的基础之上将提出各种解决方案。
Abandon overloading
简单地来说,可以根据参数名设置不同的函数,例如将logAndAdd写为logAndAddName与logAndAddNameIdx,不过这种方法并不适用于Person,此外,放弃重载并不明智。
Pass by const T&
事实上Item26中提出过这种写法,不过对于string来说它可能会生成一个临时对象从而降低效率,权衡利弊后这不失为一种解决方案。
Pass by value
Itemn41建议说,如果你明确知道会发生copy操作,请使用pass by value。因此我们试以Person为例执行pass by value:1
2
3
4
5
6
7
8class Person {
public:
explicit Person(std::string n): name(std::move(n)) {} // Item 41 for use of std::move
explicit Person(int idx):name(nameFromIdx(idx)){}
…
private:
std::string name;
};
因为没有std::string构造函数只接受一个整数,因此传递给Person构造函数的所有int和intlike参数(例如,std::size_t,short,long)都会触发int重载函数调用。类似地,类型为std::string的所有参数(以及可以创建std::strings的东西,例如const char *)都会触发string重载函数调用。
Use Tag dispatch
无论是pass by const T&还是pass by value均不支持forward。如果我们需要使用forward,那么必须配合使用universal reference。有什么方法可以在不放弃使用universal reference的前提下使用重载吗?
解决方法是,查看所有可能使用的实参类型,然后根据它们的类型依次建立最佳重载版本。universal reference通常可以为所有实参提供精确匹配,但如果universal reference是包含非universal refence的其他参数的参数列表的一部分,其匹配优先级将会降低(此即是Tag dispath的技术基础),下文将以实例举证。
首先,这是logAndAdd的实现:1
2
3
4
5
6
7std::multiset<std::string> names; // global data structure
template<typename T>
void logAndAdd(T&& name){
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
这个函数能够正常运行,但如果需要增加int型重载则将陷入Item26所述困境,本节我们将重新实现logAndAdd,以委托两个其他函数的方式(一个接受int,一个接受别的),logAndAdd本身可以接受一切类型的参数。
执行实际工作的两个函数将命名为logAndAddImpl(依然使用了重载),其中一个以universal reference作为参数,因此我们仍然在执行重载与universal reference并行的策略。但本次每个函数都存在一个第二参数,该存在用以表征传入参数是否是一个int类型。也就是说,logAndAddImpl大概长这样:1
2
3
4
5template<typename T>
void logAndAdd(T&& name){
logAndAddImpl(std::forward<T>(name),
std::is_integral<T>()); // not quite correct
}
该代码并不保证正确,原因在于当一个左值被传递至universal reference name,那么T将被推衍成为一个左值引用,因此如果一个int型左值作为实参传入,当导致T被推衍为int&,这并非是一个int类型,因此is_integral将永远返回false。当我们意识到这个问题的时候就等价于解决了这个问题,Item9中提及的type trait(std::remove_reference)几乎为此而生(C++14可以使用std::remove_reference_t<T>):1
2
3
4
5
6
7template<typename T>
void logAndAdd(T&& name){
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>()
);
}
至此,我们可以将注意力集中至logAndAddImpl,针对非整数类型的重载版本如下所示:1
2
3
4
5
6template<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));
}
其中的一大难点在于true和false都是运行期值,而我们需要在编译期明确调用函数,这意味着我们需要一个对应于true的类型和一个对应于false的类型,针对这种常见需求,标准库提供了std::true_type和std::false_type。
第二个重载版本如下所示:1
2
3
4std::string nameFromIdx(int idx);
void logAndAddImpl(int idx, std::true_type){
logAndAdd(nameFromIdx(idx));
}
在此实现中,我们避免了在logAndAddImpl中执行log(这一设计十分巧妙)。
在此设计中,类型std::true_type和std::false_type是“tag”,其唯一目的在于强制执行我们需要的重载函数。为了优化性能,我们甚至没有给这些参数命名。这种设计常见于TMP之中,当代C++库函数源码中经常出现这种设计。
抑制带有universal references的模板实例化
Tag dispatch的使用前提是客户端具备单一(非重载)函数,这些非重载函数内涵某些重载函数。但在Item26中的Person类中,默认生成的函数将自发与开发者定义的函数形成重载,因此tag dispatch并非完美无缺。针对这种universal refernce总被触发的情况,我们还有一招:std::enable_if。
std::enable_if可以强制令编译器认为某个特化模板并不存在(视作被禁用)。在默认情况下,所有模板都已启用,但仅当满足std::enable_if指定的条件时,才会启用使用std::enable_if的模板。以上一节的实例而言,只有当传递的类型不是Person时,我们才想启用Person forward构造函数。如果传递的类型是Person,我们则需要禁用forward构造函数(即令编译器忽视该函数),以copy ctor或move ctor触发调用。std::enable_if的使用范例如下所示:1
2
3
4
5
6class Person {
public:
template<typename T,typename = typename std::enable_if<condition>::type>
explicit Person(T&& n);
…
}
本书并不介绍关于std::enable_if的具体细节,而是将注意力转向其启用条件表达式。我们需要指定的条件是:仅在T并非Person类型时才会触发模板实例化。type traits能够表征两个类型是否相同:1
!std::is_same<Person,T>::value
但这并不正确,如果universal refernce被左值初始化,那么T必然是一个左值引用,这导致下述代码:1
2Person p("Nancy");
auto cloneOfP(p); // initialize from lvalue
此时T将会被推衍为Person&,从而导致is_same返回false。
显然,针对推衍得到的T类型,我们应当忽略其:
- reference
为了确定是否应当启用universal reference构造函数,我们应当将Person&、Person&&统一视为Person。 - const and volatile
我们应当将const Person、volatile Person以及const volatile Person都视为Person。
type traits再一次立了大功,它提供了std::decay<T>以消去refernce、cv限定符(实际上它还能负责将数组、函数退化为指针)。综上,我们可以得到condition表达式:1
!std::is_same<Person, typename std::decay<T>::type>::value
最终,我们得到转发函数如下所示:1
2
3
4
5
6
7
8
9
10
11class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_same<Person,typename std::decay<T>::type>::value
>::type
>
explicit Person(T&& n);
…
};
继承
假设派生自Person的类以常规方式实现copy与move操作:1
2
3
4
5
6class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs):Person(rhs){ … }
SpecialPerson(SpecialPerson&& rhs):Person(std::move(rhs)){ … }
…
};
在Item26中我们曾经提及,上述copy、move操作可能并不调用基类对应的函数,而是会选择调用转发函数,原因在于universal reference具备更高的匹配程度(足以匹配const)。对于这种情况,发生在基类之中,确切地来说,我们当前认定Person将仅在传入参数不为Person或其派生类型时才会触发forward构造函数。
type traits再次发挥了作用,std::std::is_base_of&T1, T2>::value将在T2为T1的派生类时返回true(T自身亦可视为T的派生类),因此,我们可以利用它改写刚才的condition:1
2
3
4
5
6
7
8
9
10
11class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_base_of<Person,typename std::decay<T>::type>::value
>::type
>
explicit Person(T&& n);
…
};
利用C++14可以保证我们的代码更加优雅:1
2
3
4
5
6
7
8
9
10
11class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person,std::decay_t<T> >::value
>
>
explicit Person(T&& n);
…
};
区分int与non-int
最终,我们将实践如何用enable_if区分int与non-int,我们需要做的只有两步:
- 添加一个Person构造函数以处理int类型(以重载的形式)
- 限制模板实例化,对某些参数禁用该模板
实践效果如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class 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)){ … }
explicit Person(int idx):name(nameFromIdx(idx)){ … }
… // copy and move ctors, etc.
private:
std::string name;
};
比较
本节所介绍的前三种技术:abandoning overloading, passing by const T&, and passing by value都需要明确为被调用的函数指明所有潜在参数类型,而后两种技术:tag dispatch,constraining template eligibility则使用转发函数,因此不必说明参数类型。是否说明参数类型这一行为直接决定了这些技术的特性。
一般而言,forward函数更为高效,因为它不必创建临时对象,但它亦存在缺点,例如并非所有参数都能做到完美转发(Item30见对此作出详细探讨),此外,forward函数可能会误将错误信息传递至对应的构造函数,举例而言,假设创建Person对象的客户端传递由char16_t(C++11中引入的类型,代表16位字符)而不是char组成的字符串常量:1
Person p(u"Konrad Zuse"); // "Konrad Zuse" consists of characters of type const char16_t
前三种技术将会发现当前仅可采用int或string构造Person,因此产生明确的错误信息。而forward函数会自发将char16_t传入string构造函数,从而导致雪崩。一般来说,系统越复杂,转发层级越多,因此故障的排查也将愈加困难,一般而言,保留univeral reference的原因往往在于当前需要效率至上。
对于Person而言,我们明确universal reference应当构成string的初始化器,因此我们可以使用static_assert配合type traits std::is_constructible加以判断,最终形成的判断句如下所示: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"
);
… // the usual ctor work goes here
}
… // remainder of Person class (as before)
};