目录
11.1 可调用对象(Callables)
11.1.1 函数对象的支持
11.1.2 处理成员函数以及额外的参数
11.1.3 函数调用的包装
11.2 其他一些实现泛型库的工具
11.2.1 类型萃取
11.2.2 std::addressoff()
11.2.3 std::declval()
11.3 完美转发临时变量
11.4 作为模板参数的引用
11.5 推迟计算(Defer Evaluation)
11.6 在写泛型库时需要考虑的事情
11.7 总结
参考:GitHub - Walton1128/CPP-Templates-2nd--: 《C++ Templates 第二版》中文翻译,和原书排版一致,第一部分(1至11章)以及第18,19,20,21、22、23、24、25章已完成,其余内容逐步更新中。 个人爱好,发现错误请指正
一些库包含这样一种接口,客户端代码可以向该类接口传递一个实体,并要求该实体必须被 调用。相关的例子有:必须在另一个线程中被执行的操作,一个指定该如何处理 hash 值并 将其存在 hash 表中的函数(hash 函数),一个指定集合中元素排序方式的对象,以及一个 提供了某些默认参数值的泛型包装器。标准库也不例外:它定义了很多可以接受可调用对象 作为参数的组件。
这里会用到一个叫做回调(callback)的名词。传统上这一名词被作为函数调用实参使用, 我们将保持这一传统。比如一个排序函数可能会接受一个回调参数并将其用作排序标准,该 回调参数将决定排序顺序。
在 C++中,由于一些类型既可以被作为函数调用参数使用,也可以按照 f(...)的形式调用,因 此可以被用作回调参数:
函数指针类型
重载了 operator()的 class 类型(有时被称为仿函数(functors)),这其中包含 lambda 函数
包含一个可以产生一个函数指针或者函数引用的转换函数的 class 类型
这些类型被统称为函数对象类型(function object types),其对应的值被称为函数对象 (function object)
template
void foreach (Iter current, Iter end, Callable op)
{
while (current != end) { //as long as not reached the end
op(*current); // call passed operator for current element
++current; // and move iterator to next element
}
}
#include #include
#include "foreach.hpp"
// a function to call:
void func(int i)
{
std::cout << "func() called for: " << i << ’\n’;
}
// a function object type (for objects that can be used as functions):
class FuncObj {
public:
void operator() (int i) const { //Note: const member function
std::cout << "FuncObj::op() called for: " << i << ’\n’;
}
};
int main()
{
std::vector primes = { 2, 3, 5, 7, 11, 13, 17, 19 };
foreach(primes.begin(), primes.end(), // range
func); // function as callable (decays to pointer)
foreach(primes.begin(), primes.end(), // range
&func); // function pointer as callable
foreach(primes.begin(), primes.end(), // range
FuncObj()); // function object as callable
foreach(primes.begin(), primes.end(), // range
[] (int i) { //lambda as callable
std::cout << "lambda called for: " << i << ’\n’;
});
}
详细看一下以上各种情况:
当把函数名当作函数参数传递时,并不是传递函数本体,而是传递其指针或者引用。和 数组情况类似(参见 7.4 节),在按值传递时,函数参数退化为指针,如果参数类型是 模板参数,那么类型会被推断为指向函数的指针。 和数组一样,按引用传递的函数的类型不会 decay。但是函数类型不能真正用 const 限 制。如果将 foreach()的最后一个参数的类型声明为 Callable const &,const 会被省略。 (通常而言,在主流 C++代码中很少会用到函数的引用。)
在第二个调用中,函数指针被显式传递(传递了一个函数名的地址)。这和第一中调用 方式相同(函数名会隐式的 decay 成指针),但是相对而言会更清楚一些。
如果传递的是仿函数,就是将一个类的对象当作可调用对象进行传递。通过一个 class 类型进行调用通常等效于调用了它的 operator()。因此下面这样的调用: op(*current);
会被转换成: op.operator()(*current);
注意在定义 operator()的时候最好将其定义成 const 成员函数。否则当一些框架或者库 不希望该调用会改变被传递对象的状态时,会遇到很不容易 debug 的 error。
Lambda 表达式会产生仿函数(也称闭包),因此它与仿函数(重载了 operator()的类) 的情况没有不同。不过 Lambda 引入仿函数的方法更为简便,因此它们从 C++11 开始变 得很常见。 有意思的是,以[]开始的 lambdas(没有捕获)会产生一个向函数指针进行转换的运算 符。但是它从来不会被当作 surrogate call function,因为它的匹配情况总是比常规闭包 的 operator()要差。
在以上例子中漏掉了另一种可以被调用的实体:成员函数。这是因为在调用一个非静态成员 函数的时候需要像下面这样指出对象:object.memfunc(...)或者 ptr->memfunc(...),这和常规 情况下的直接调用方式不同:func(...)。
从 C++17 开始,标准库提供了一个工具:std::invlke(),它非常方便的统一了上面 的成员函数情况和常规函数情况,这样就可以用同一种方式调用所有的可调用对象。:
#include
#include
template
void foreach (Iter current, Iter end, Callable op, Args const&…args)
{
while (current != end) { //as long as not reached the end of the
elements
std::invoke(op, //call passed callable with
args…, //any additional args
*current); // and the current element
++current;
}
}
#include
#include
#include
#include "foreachinvoke.hpp"
// a class with a member function that shall be called
class MyClass {
public:
void memfunc(int i) const {
std::cout << "MyClass::memfunc() called for: " << i << ’
\n’;
}
};
int main()
{
std::vector primes = { 2, 3, 5, 7, 11, 13, 17, 19 };
// pass lambda as callable and an additional argument:
foreach(primes.begin(), primes.end(), //elements for 2nd arg of
lambda
[](std::string const& prefix, int i) { //lambda to call
std::cout << prefix << i << ’\n’;
},
"- value:"); //1st arg of lambda
// call obj.memfunc() for/with each elements in primes passed as
argument
MyClass obj;
foreach(primes.begin(), primes.end(), //elements used as args
&MyClass::memfunc, //member function to call
obj); // object to call memfunc() for
}
第一次调用 foreach()时,第四个参数被作为 lambda 函数的第一个参数传递给 lambda,而 vector 中的元素被作为第二个参数传递给 lambda。第二次调用中,第三个参数 memfunc() 被第四个参数 obj 调用。
Std::invoke()的一个常规用法是封装一个单独的函数调用。此时可以通过完美转发可调 用对象以及被传递的参数来支持移动语义:
#include // for std::invoke()
#include // for std::forward()
template
decltype(auto) call(Callable&& op, Args&&… args)
{
return std::invoke(std::forward(op), //passed callable with
std::forward(args)…); // any additional args
}
为了能够返回引用(比如 std::ostream&),需要使用 decltype(auto)而不是 auto:
主要还是因为auto作为返回值,会导致类型退化,decltype(auto) 避免了这种退化。
auto (可有 cv 限定符)一定会推导出返回类型为对象类型。并且应用数组到指针、函数到指针隐式转换。
auto 加上 & 或 && (可有 cv 限定符)一定会推导出返回类型为引用类型。
decltype(auto) 可以推导出对象类型,也可以推导出引用类型。具体取决于 decltype 应用到 return 语句中表达式的结果。
decltype(auto)(在 C++14 中引入)是一个占位符类型,它根据相关表达式决定了变量、返回值、或者模板实参的类型。
如果你想暂时的将 std::invoke()的返回值存储在一个变量中,并在做了某些别的事情后将其 返回(比如处理该返回值或者记录当前调用的结束),也必须将该临时变量声明为decltype(auto)类型:
decltype(auto) ret{std::invoke(std::forward(op),
std::forward(args)…)}; …
return ret;
如果可调用对象的返回值是 void, 那么将 ret 初始化为 decltype(auto)是不可以的,这是因为 void 是不完整类型。
分别实现 void 和非 void 的情况:
#include // for std::invoke()
#include // for std::forward()
#include // for std::is_same<> and
invoke_result<>
template
decltype(auto) call(Callable&& op, Args&&… args)
{
if constexpr(std::is_same_v, void>) {// return type is void:
std::invoke(std::forward(op),
std::forward(args)…); …
return;
} else {
// return type is not void:
decltype(auto) ret{std::invoke(std::forward(op),
std::forward(args)…)}; …
return ret;
}
}
使用类型萃取的时候需要额外小心:其行为可能和程序员的预期不同。比如:
std::remove_const_t //
这里由于引用不是 const 类型的(虽然你不可以改变它),这个操作不会有任何效果。
这样,删除引用和删除 const 的顺序就很重要了:
std::remove_const_t> // int
std::remove_reference_t> // int const
另一种方法是,直接调用:
std::decay_t // yields int
但是这同样会让裸数组和函数类型退化为相应的指针类型。
当然还有一些类型萃取的使用是有要求的。这些要求不被满足的话,其行为将是未定义的。 比如:
make_unsigned_t // unsigned int
make_unsigned_t // undefined behavior
某些情况下,结果可能会让你很意外。比如:
add_rvalue_reference_t // int const&&
add_rvalue_reference_t // int const& (lvalueref remains lvalue-ref)
这里我们期望 add_rvalue_reference 总是能够返回一个右值引用,但是 C++中的引用塌缩 (reference-collapsing rules,参见 15.6.1 节)会令左值引用和右值引用的组合返回一个左值 引用。
函数模板 std::addressof<>()会返回一个对象或者函数的准确地址。即使一个对象重载了运算 符&也是这样。虽然后者中的情况很少遇到,但是也会发生(比如在智能指针中)。因此, 如果需要获得任意类型的对象的地址,那么推荐使用 addressof():
template
void f (T&& x)
{
auto p = &x; // might fail with overloaded operator &
auto q = std::addressof(x); // works even with overloaded operator
& …
}
函数模板 std::declval()可以被用作某一类型的对象的引用的占位符。该函数模板没有定义, 因此不能被调用(也不会创建对象)。因此它只能被用作不会被计算的操作数(比如 decltype 和 sizeof)。也因此,在不创建对象的情况下,依然可以假设有相应类型的可用对象。
比如在如下例子中,会基于模板参数 T1 和 T2 推断出返回类型 RT:
#include
template() :
std::declval())>>
RT max (T1 a, T2 b)
{
return b < a ? a : b;
}
为了避免在调用运算符?:的时候不得不去调用 T1 和 T2 的(默认)构造函数,这里使用了 std::declval,这样可以在不创建对象的情况下“使用”它们。不过该方式只能在不会做真正 的计算时(比如 decltype)使用。
不要忘了使用 std::decay<>来确保返回类型不会是一个引用,因为 std::declval<>本身返回的 是右值引用。否则,类似 max(1,2)这样的调用将会返回一个 int&&类型。
使用转发引用(forwarding reference)以及 std::forward<> 来完美转发泛型参数:
template
void f (T&& t) // t is forwarding reference
{
g(std::forward(t)); // perfectly forward passed argument t to g()
}
但是某些情况下,在泛型代码中我们需要转发一些不是通过参数传递进来的数据。此时我们 可以使用 auto &&创建一个可以被转发的变量。比如,假设我们需要相继的调用 get()和 set() 两个函数,并且需要将 get()的返回值完美的转发给 set():
templatevoid foo(T x)
{
set(get(x));
}
假设以后我们需要更新代码对 get()的返回值进行某些操作,可以通过将 get()的返回值存储 在一个被声明为 auto &&的变量中实现:
template
void foo(T x)
{
auto&& val = get(x); …
// perfectly forward the return value of get() to set():
set(std::forward(val));
}
这样可以避免对中间变量的多余拷贝。
#include
template
void tmplParamIsReference(T) {
std::cout << "T is reference: " << std::is_reference_v << '\n';
}
int main()
{
std::cout << std::boolalpha;
int i;
int& r = i;
tmplParamIsReference(i); // false
tmplParamIsReference(r); // false
tmplParamIsReference(i); // true
tmplParamIsReference(r); // true
}
即使传递给 tmplParamIsReference()的参数是一个引用变量,T 依然会被推断为被引用的类型。
为什么tmplParamIsReference(r); // false?
(因为对于引用变量 v,表达式 v 的类型是被引用的类型,表达式(expression)的类型永远 不可能是引用类型)。--不明白
个人理解:模板参数按值传递进行类别推导,引用被去除。
显示指定 T 的类型化为引用类型。这样做可能 会触发错误或者不可预知的行为。考虑如下例子:
template
class RefMem {
private:
T zero;
public:
RefMem() : zero{Z} {
}
};
int null = 0;
int main()
{
RefMem rm1, rm2;
rm1 = rm2; // OK
RefMem rm3; // ERROR: invalid default value for N
RefMem rm4; // ERROR: invalid default value for N extern
int null;
RefMem rm5, rm6;
rm5 = rm6; // ERROR: operator= is deleted due to reference member
}
用 int 实例化该模板会 获得预期的行为。但是如果尝试用引用对其进行实例化的话,情况就有点复杂了:
非模板参数的默认初始化不在可行。
不再能够直接用 0 来初始化非参数模板参数。
最让人意外的是,赋值运算符也不再可用,因为对于具有非 static 引用成员的类,其默 赋值运算符会被删除掉。(赋值运算符的默认实现是逐成员赋值,而引用成员是不能被重新绑定的。因此,如果一个类有非静态引用成员,编译器会自动删除其默认赋值运算符,防止意外的引用重新绑定。)
而且将引用类型用于非类型模板参数同样会变的复杂和危险。考虑如下例子:
#include
#include
template // Note: size is reference
class Arr {
private:
std::vector elems;
public:
Arr() : elems(SZ) { //use current SZ as initial vector size
}
void print() const {
for (int i=0; i y; // compile-time ERROR deep in the code of class
std::vector<>
Arr x; // initializes internal vector with 10 elements
x.print(); // OK
size += 100; // OOPS: modifies SZ in Arr<>
x.print(); // run-time ERROR: invalid memory access: loops over 120 elements
}
基于这一原因,C++标准库在某些情况下制定了很特殊的规则和限制。比如:
在模板参数被用引用类型实例化的情况下,为了依然能够正常使用赋值运算符, std::pair<>和 std::tuple<>都没有使用默认的赋值运算符,而是做了单独的定义。比如:
namespace std {
template
struct pair {
T1 first;
T2 second; …
// default copy/move constructors are OK even with references:
pair(pair const&) = default;
pair(pair&&) = default; …
// but assignment operator have to be defined to be available with
references:
pair& operator=(pair const& p);
pair& operator=(pair&& p) noexcept(…); …
};
}
由于这些副作用可能导致的复杂性,在 C++17 中用引用类型实例化标准库模板 std::optional<>和 std::variant<>的过程看上去有些古怪:
为了禁止用引用类型进行实例化,一个简单的 static_assert 就够了:
template
class optional
{
static_assert(!std::is_reference::value, "Invalid
instantiation of optional for references"); …
};
在实现模板的过程中,有时候需要面对是否需要考虑不完整类型(参见 10.3.1 节)的问题。
该 class 可以被用于不完整类型。这很有用,比如可以让其成员指向其自身的 类型。
template
class Cont {
private:
T* elems;
public:
};
struct Node
{
std::string value;
Cont next; // only possible if Cont accepts incomplete types
};
编译运行成功。
template
class Cont {
private:
T* elems;
public:
typename
std::conditional::value, T&&,
T& >::type foo();
};
struct Node
{
std::string value;
Cont next; // only possible if Cont accepts incomplete types
};
编译报错:
错误 C2139 “Node”: 未定义的类不允许作为编译器内部类型特征“__is_constructible”的参数
这里通过使用 std::conditional来决定 foo()的返回类型是 T&&还是 T&。决策标准 是看模板参数 T 是否支持 move 语义。
问题在于 std::is_move_constructible 要求其参数必须是完整类型。
使用这 种类型的 foo(),struct node 的声明就会报错。
template
class Cont {
private:
T* elems;
public:
template
typename
std::conditional::value, T&&,
T&>::type foo();
};
struct Node
{
std::string value;
Cont next; // only possible if Cont accepts incomplete types
};
编译运行成功。
为了解决这一问题,需要使用一个成员模板代替现有 foo()的定义,这样就可以将 std::is_move_constructible 的计算推迟到 foo()的实例化阶段:
其实就是利用“模板只有在被调用时才会被实例化”和“两阶段编译检查”的特性。
现在,类型萃取依赖于模板参数 D(默认值是 T),并且编译器会一直等到 foo()被以完整类 型(比如 Node)为参数调用时,才会对类型萃取部分进行计算(此时 Node 是一个完整类 型,其只有在定义时才是非完整类型)。
1. 在模板中使用转发引用来转发数值(参见 6.1 节)。
template
void f (T&& val) {
g(std::forward(val)); // perfect forward val to g()
}
如果数值不依赖于模板参数,就使 用 auto &&(参见 11.3)。
template
void foo(T x)
{
auto&& val = get(x); …
// perfectly forward the return value of get() to set():
set(std::forward(val));
}
2.如果一个参数被声明为转发引用,并且传递给它一个左值的话,那么模板参数会被推断 为引用类型。
3.在需要一个依赖于模板参数的对象的地址的时候,最好使用 std::addressof()来获取地址, 这样能避免因为对象被绑定到一个重载了 operator &的类型而导致的意外情况。
4.对于成员函数,需要确保它们不会比预定义的 copy/move 构造函数或者赋值运算符更 能匹配某个调用。
5.如果模板参数可能是字符串常量,并且不是被按值传递的,那么请考虑使用 std::decay。
template
constexpr pair::type, typename
decay::type>
make_pair (T1&& a, T2&& b)
{
return pair::type, typename
decay::type>(forward(a), forward(b));
}
6. 如果你有被用于输出或者即用于输入也用于输出的、依赖于模板参数的调用参数,请为 可能的、const 类型的模板参数做好准备。
如果想禁止向非 const 应用传递 const 对象,有如下选择:
使用 static_assert 触发一个编译期错误:
通过使用 std::enable_if<>(参见 6.3 节)禁用该情况下的模板:
或者是在 concepts 被支持之后,通过 concepts 来禁用该模板(参见 6.5 节以及附录 E):
template
requires !std::is_const_v
void outR (T& arg) {
……
}
7. 请为将引用用于模板参数的副作用做好准备(参见 11.4 节)。尤其是在你需要确保返 回类型不会是引用的时候(参见 7.5 节)。
8.请为将不完整类型用于嵌套式数据结构这一类情况做好准备(参见 11.5 节)。
9.为所有数组类型进行重载,而不仅仅是 T[SZ](参见 5.4 节)
可以将函数,函数指针,函数对象,仿函数和 lambdas 作为可调用对象(callables)传 递给模板。
如果需要为一个 class 重载 operator(),那么就将其声明为 const 的(除非该调用会修改 它的状态)。
通过使用 std::invoke(),可以实现能够处理所有类型的、可调用对象(包含成员函数) 的代码。 使用 decltype(auto)来完美转发返回值。
类型萃取是可以检查类型的属性和功能的类型函数。 当在模板中需要一个对象的地址时,使用 std::addressof().
在不经过表达式计算的情况下,可以通过使用 std::declval()创建特定类型的值。
在泛型代码中,如果一个对象不依赖于模板参数,那么就使用 auto&&来完美转发它。
可以通过模板来延迟表达式的计算(这样可以在 class 模板中支持不完整类型)