CPP-Templates-2nd--第十一章 泛型库

目录

[11.1 可调用对象(Callables)](#11.1 可调用对象(Callables))

[11.1.1 函数对象的支持](#11.1.1 函数对象的支持)

[11.1.2 处理成员函数以及额外的参数](#11.1.2 处理成员函数以及额外的参数)

[11.1.3 函数调用的包装](#11.1.3 函数调用的包装)

[11.2 其他一些实现泛型库的工具](#11.2 其他一些实现泛型库的工具)

[11.2.1 类型萃取](#11.2.1 类型萃取)

[11.2.2 std::addressoff()](#11.2.2 std::addressoff())

[11.2.3 std::declval()](#11.2.3 std::declval())

[11.3 完美转发临时变量](#11.3 完美转发临时变量)

[11.4 作为模板参数的引用](#11.4 作为模板参数的引用)

[11.5 推迟计算(Defer Evaluation)](#11.5 推迟计算(Defer Evaluation))

[11.6 在写泛型库时需要考虑的事情](#11.6 在写泛型库时需要考虑的事情)

[11.7 总结](#11.7 总结)


参考:GitHub - Walton1128/CPP-Templates-2nd--: 《C++ Templates 第二版》中文翻译,和原书排版一致,第一部分(1至11章)以及第18,19,20,21、22、23、24、25章已完成,其余内容逐步更新中。 个人爱好,发现错误请指正

11.1 可调用对象(Callables)

一些库包含这样一种接口,客户端代码可以向该类接口传递一个实体,并要求该实体必须被 调用。相关的例子有:必须在另一个线程中被执行的操作,一个指定该如何处理 hash 值并 将其存在 hash 表中的函数(hash 函数),一个指定集合中元素排序方式的对象,以及一个 提供了某些默认参数值的泛型包装器。标准库也不例外:它定义了很多可以接受可调用对象 作为参数的组件。

这里会用到一个叫做回调(callback)的名词。传统上这一名词被作为函数调用实参使用, 我们将保持这一传统。比如一个排序函数可能会接受一个回调参数并将其用作排序标准,该 回调参数将决定排序顺序。

在 C++中,由于一些类型既可以被作为函数调用参数使用,也可以按照 f(...)的形式调用,因 此可以被用作回调参数:

 函数指针类型

 重载了 operator()的 class 类型(有时被称为仿函数(functors)),这其中包含 lambda 函数

 包含一个可以产生一个函数指针或者函数引用的转换函数的 class 类型

这些类型被统称为函数对象类型(function object types),其对应的值被称为函数对象 (function object)

11.1.1 函数对象的支持

cpp 复制代码
template<typename Iter, typename Callable>
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 <iostream>#include <vector>
#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<int> 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()要差。

11.1.2 处理成员函数以及额外的参数

在以上例子中漏掉了另一种可以被调用的实体:成员函数。这是因为在调用一个非静态成员 函数的时候需要像下面这样指出对象:object.memfunc(...)或者 ptr->memfunc(...),这和常规 情况下的直接调用方式不同:func(...)。

从 C++17 开始,标准库提供了一个工具:std::invlke(),它非常方便的统一了上面 的成员函数情况和常规函数情况,这样就可以用同一种方式调用所有的可调用对象。:

cpp 复制代码
#include <utility>
#include <functional>
template<typename Iter, typename Callable, typename... Args>
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 <iostream>
#include <vector>
#include <string>
#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<int> 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 调用。

11.1.3 函数调用的包装

Std::invoke()的一个常规用法是封装一个单独的函数调用。此时可以通过完美转发可调 用对象以及被传递的参数来支持移动语义:

cpp 复制代码
#include <utility> // for std::invoke()
#include <functional> // for std::forward()
template<typename Callable, typename... Args>
decltype(auto) call(Callable&& op, Args&&... args)
{
return std::invoke(std::forward<Callable>(op), //passed callable with
std::forward<Args>(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)类型:

cpp 复制代码
decltype(auto) ret{std::invoke(std::forward<Callable>(op),
std::forward<Args>(args)...)}; ...
return ret;

如果可调用对象的返回值是 void, 那么将 ret 初始化为 decltype(auto)是不可以的,这是因为 void 是不完整类型。

分别实现 void 和非 void 的情况:

cpp 复制代码
#include <utility> // for std::invoke()
#include <functional> // for std::forward()
#include <type_traits> // for std::is_same<> and
invoke_result<>
template<typename Callable, typename... Args>
decltype(auto) call(Callable&& op, Args&&... args)
{
if constexpr(std::is_same_v<std::invoke_result_t<Callable,
Args...>, void>) {// return type is void:
std::invoke(std::forward<Callable>(op),
std::forward<Args>(args)...); ...
return;
} else {
// return type is not void:
decltype(auto) ret{std::invoke(std::forward<Callable>(op),
std::forward<Args>(args)...)}; ...

return ret;
}
}

11.2 其他一些实现泛型库的工具

11.2.1 类型萃取

使用类型萃取的时候需要额外小心:其行为可能和程序员的预期不同。比如:

cpp 复制代码
std::remove_const_t<int const&> // 

这里由于引用不是 const 类型的(虽然你不可以改变它),这个操作不会有任何效果。

这样,删除引用和删除 const 的顺序就很重要了:

cpp 复制代码
std::remove_const_t<std::remove_reference_t<int const&>> // int
std::remove_reference_t<std::remove_const_t<int const&>> // int const

另一种方法是,直接调用:

cpp 复制代码
std::decay_t<int const&> // yields int

但是这同样会让裸数组和函数类型退化为相应的指针类型。

当然还有一些类型萃取的使用是有要求的。这些要求不被满足的话,其行为将是未定义的。 比如:

cpp 复制代码
make_unsigned_t<int> // unsigned int
make_unsigned_t<int const&> // undefined behavior 

某些情况下,结果可能会让你很意外。比如:

cpp 复制代码
add_rvalue_reference_t<int const> // int const&&
add_rvalue_reference_t<int const&> // int const& (lvalueref remains lvalue-ref)

这里我们期望 add_rvalue_reference 总是能够返回一个右值引用,但是 C++中的引用塌缩 (reference-collapsing rules,参见 15.6.1 节)会令左值引用和右值引用的组合返回一个左值 引用。

11.2.2 std::addressoff()

函数模板 std::addressof<>()会返回一个对象或者函数的准确地址。即使一个对象重载了运算 符&也是这样。虽然后者中的情况很少遇到,但是也会发生(比如在智能指针中)。因此, 如果需要获得任意类型的对象的地址,那么推荐使用 addressof():

cpp 复制代码
template<typename T>
void f (T&& x)
{
auto p = &x; // might fail with overloaded operator &
auto q = std::addressof(x); // works even with overloaded operator
& ...
}

11.2.3 std::declval()

函数模板 std::declval()可以被用作某一类型的对象的引用的占位符。该函数模板没有定义, 因此不能被调用(也不会创建对象)。因此它只能被用作不会被计算的操作数(比如 decltype 和 sizeof)。也因此,在不创建对象的情况下,依然可以假设有相应类型的可用对象。

比如在如下例子中,会基于模板参数 T1 和 T2 推断出返回类型 RT:

cpp 复制代码
#include <utility>
template<typename T1, typename T2,
typename RT = std::decay_t<decltype(true ? std::declval<T1>() :
std::declval<T2>())>>
RT max (T1 a, T2 b)
{
return b < a ? a : b;
}

为了避免在调用运算符?:的时候不得不去调用 T1 和 T2 的(默认)构造函数,这里使用了 std::declval,这样可以在不创建对象的情况下"使用"它们。不过该方式只能在不会做真正 的计算时(比如 decltype)使用。

不要忘了使用 std::decay<>来确保返回类型不会是一个引用,因为 std::declval<>本身返回的 是右值引用。否则,类似 max(1,2)这样的调用将会返回一个 int&&类型。

11.3 完美转发临时变量

使用转发引用(forwarding reference)以及 std::forward<> 来完美转发泛型参数:

cpp 复制代码
template<typename T>
void f (T&& t) // t is forwarding reference
{
g(std::forward<T>(t)); // perfectly forward passed argument t to g()
}

但是某些情况下,在泛型代码中我们需要转发一些不是通过参数传递进来的数据。此时我们 可以使用 auto &&创建一个可以被转发的变量。比如,假设我们需要相继的调用 get()和 set() 两个函数,并且需要将 get()的返回值完美的转发给 set():

cpp 复制代码
template<typename T>void foo(T x)
{
set(get(x));
}

假设以后我们需要更新代码对 get()的返回值进行某些操作,可以通过将 get()的返回值存储 在一个被声明为 auto &&的变量中实现:

cpp 复制代码
template<typename T>
void foo(T x)
{
auto&& val = get(x); ...
// perfectly forward the return value of get() to set():
set(std::forward<decltype(val)>(val));
}

这样可以避免对中间变量的多余拷贝。

11.4 作为模板参数的引用

cpp 复制代码
#include <iostream>
template<typename T>
void tmplParamIsReference(T) {
	std::cout << "T is reference: " << std::is_reference_v<T> << '\n';
}
int main()
{
	std::cout << std::boolalpha;
	int i;
	int& r = i;
	tmplParamIsReference(i); // false
	tmplParamIsReference(r); // false
	tmplParamIsReference<int&>(i); // true
	tmplParamIsReference<int&>(r); // true
}

即使传递给 tmplParamIsReference()的参数是一个引用变量,T 依然会被推断为被引用的类型。

为什么tmplParamIsReference(r); // false?

(因为对于引用变量 v,表达式 v 的类型是被引用的类型,表达式(expression)的类型永远 不可能是引用类型)。--不明白

个人理解:模板参数按值传递进行类别推导,引用被去除。

显示指定 T 的类型化为引用类型。这样做可能 会触发错误或者不可预知的行为。考虑如下例子:

cpp 复制代码
template<typename T, T Z = T{}>
class RefMem {
private:
T zero;
public:
RefMem() : zero{Z} {
}
};
int null = 0;
int main()
{
RefMem<int> rm1, rm2;
rm1 = rm2; // OK
RefMem<int&> rm3; // ERROR: invalid default value for N
RefMem<int&, 0> rm4; // ERROR: invalid default value for N extern
int null;
RefMem<int&,null> rm5, rm6;
rm5 = rm6; // ERROR: operator= is deleted due to reference member
}

用 int 实例化该模板会 获得预期的行为。但是如果尝试用引用对其进行实例化的话,情况就有点复杂了:

 非模板参数的默认初始化不在可行。

 不再能够直接用 0 来初始化非参数模板参数。

 最让人意外的是,赋值运算符也不再可用,因为对于具有非 static 引用成员的类,其默 赋值运算符会被删除掉。(赋值运算符的默认实现是逐成员赋值,而引用成员是不能被重新绑定的。因此,如果一个类有非静态引用成员,编译器会自动删除其默认赋值运算符,防止意外的引用重新绑定。)

而且将引用类型用于非类型模板参数同样会变的复杂和危险。考虑如下例子:

cpp 复制代码
#include <vector>
#include <iostream>
template<typename T, int& SZ> // Note: size is reference
class Arr {
private:
std::vector<T> elems;
public:
Arr() : elems(SZ) { //use current SZ as initial vector size
}
void print() const {
for (int i=0; i<SZ; ++i) { //loop over SZ elements
std::cout << elems[i] << ' ';
}
}
};
int size = 10;
int main()
{
Arr<int&,size> y; // compile-time ERROR deep in the code of class
std::vector<>
Arr<int,size> 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<>都没有使用默认的赋值运算符,而是做了单独的定义。比如:

cpp 复制代码
namespace std {
template<typename T1, typename T2>
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 就够了:

cpp 复制代码
template<typename T>
class optional
{
static_assert(!std::is_reference<T>::value, "Invalid
instantiation of optional<T> for references"); ...
};

11.5 推迟计算(Defer Evaluation)

在实现模板的过程中,有时候需要面对是否需要考虑不完整类型(参见 10.3.1 节)的问题。

该 class 可以被用于不完整类型。这很有用,比如可以让其成员指向其自身的 类型。

cpp 复制代码
template<typename T>
class Cont {
private:
	T* elems;
public: 
};
struct Node
{
	std::string value;
	Cont<Node> next; // only possible if Cont accepts incomplete types
};

编译运行成功。

cpp 复制代码
template<typename T>
class Cont {
private:
	T* elems;
public: 
	typename
	std::conditional<std::is_move_constructible<T>::value, T&&,
	T& >::type foo();
};
struct Node
{
	std::string value;
	Cont<Node> 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 的声明就会报错。

cpp 复制代码
template<typename T>
class Cont {
private:
	T* elems;
public:
	template<typename D = T>
	typename
		std::conditional<std::is_move_constructible<D>::value, T&&,
		T&>::type foo();
};
struct Node
{
	std::string value;
	Cont<Node> next; // only possible if Cont accepts incomplete types
};

编译运行成功。

为了解决这一问题,需要使用一个成员模板代替现有 foo()的定义,这样就可以将 std::is_move_constructible 的计算推迟到 foo()的实例化阶段:

其实就是利用"模板只有在被调用时才会被实例化"和"两阶段编译检查"的特性。

现在,类型萃取依赖于模板参数 D(默认值是 T),并且编译器会一直等到 foo()被以完整类 型(比如 Node)为参数调用时,才会对类型萃取部分进行计算(此时 Node 是一个完整类 型,其只有在定义时才是非完整类型)。

11.6 在写泛型库时需要考虑的事情

1. 在模板中使用转发引用来转发数值(参见 6.1 节)。

cpp 复制代码
template<typename T>
void f (T&& val) {
g(std::forward<T>(val)); // perfect forward val to g()
}

如果数值不依赖于模板参数,就使 用 auto &&(参见 11.3)。

cpp 复制代码
template<typename T>
void foo(T x)
{
auto&& val = get(x); ...
// perfectly forward the return value of get() to set():
set(std::forward<decltype(val)>(val));
}

 2.如果一个参数被声明为转发引用,并且传递给它一个左值的话,那么模板参数会被推断 为引用类型。

 3.在需要一个依赖于模板参数的对象的地址的时候,最好使用 std::addressof()来获取地址, 这样能避免因为对象被绑定到一个重载了 operator &的类型而导致的意外情况。

 4.对于成员函数,需要确保它们不会比预定义的 copy/move 构造函数或者赋值运算符更 能匹配某个调用。

 5.如果模板参数可能是字符串常量,并且不是被按值传递的,那么请考虑使用 std::decay。

cpp 复制代码
template<typename T1, typename T2>
constexpr pair<typename decay<T1>::type, typename
decay<T2>::type>
make_pair (T1&& a, T2&& b)
{
return pair<typename decay<T1>::type, typename
decay<T2>::type>(forward<T1>(a), forward<T2>(b));
}

6. 如果你有被用于输出或者即用于输入也用于输出的、依赖于模板参数的调用参数,请为 可能的、const 类型的模板参数做好准备。

如果想禁止向非 const 应用传递 const 对象,有如下选择:

使用 static_assert 触发一个编译期错误:

通过使用 std::enable_if<>(参见 6.3 节)禁用该情况下的模板:

或者是在 concepts 被支持之后,通过 concepts 来禁用该模板(参见 6.5 节以及附录 E):

cpp 复制代码
template<typename T>
requires !std::is_const_v<T>
void outR (T& arg) {
......
}

7. 请为将引用用于模板参数的副作用做好准备(参见 11.4 节)。尤其是在你需要确保返 回类型不会是引用的时候(参见 7.5 节)。

 8.请为将不完整类型用于嵌套式数据结构这一类情况做好准备(参见 11.5 节)。

 9.为所有数组类型进行重载,而不仅仅是 T[SZ](参见 5.4 节)

11.7 总结

 可以将函数,函数指针,函数对象,仿函数和 lambdas 作为可调用对象(callables)传 递给模板。

 如果需要为一个 class 重载 operator(),那么就将其声明为 const 的(除非该调用会修改 它的状态)。

 通过使用 std::invoke(),可以实现能够处理所有类型的、可调用对象(包含成员函数) 的代码。  使用 decltype(auto)来完美转发返回值。

 类型萃取是可以检查类型的属性和功能的类型函数。  当在模板中需要一个对象的地址时,使用 std::addressof().

 在不经过表达式计算的情况下,可以通过使用 std::declval()创建特定类型的值。

 在泛型代码中,如果一个对象不依赖于模板参数,那么就使用 auto&&来完美转发它。

 可以通过模板来延迟表达式的计算(这样可以在 class 模板中支持不完整类型)

相关推荐
捕鲸叉4 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer4 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
Peter_chq4 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
青花瓷6 小时前
C++__XCode工程中Debug版本库向Release版本库的切换
c++·xcode
幺零九零零7 小时前
【C++】socket套接字编程
linux·服务器·网络·c++
捕鲸叉7 小时前
MVC(Model-View-Controller)模式概述
开发语言·c++·设计模式
Dola_Pan8 小时前
C++算法和竞赛:哈希算法、动态规划DP算法、贪心算法、博弈算法
c++·算法·哈希算法
yanlou2338 小时前
KMP算法,next数组详解(c++)
开发语言·c++·kmp算法
小林熬夜学编程8 小时前
【Linux系统编程】第四十一弹---线程深度解析:从地址空间到多线程实践
linux·c语言·开发语言·c++·算法
阿洵Rain9 小时前
【C++】哈希
数据结构·c++·算法·list·哈希算法