Skip to content

Latest commit

 

History

History
679 lines (516 loc) · 30.3 KB

ch11.md

File metadata and controls

679 lines (516 loc) · 30.3 KB

11. 泛型库(Generic)

到目前为止,我们讨论模板时,主要关注其特定的功能、能力和限制,重点放在即时任务和应用(即我们作为应用程序员经常遇到的情况)。然而,模板在用于编写通用库和框架时最为有效,此时我们的设计需要考虑到潜在的使用场景,这些场景事先通常是广泛不受约束的。虽然本书中的内容几乎都适用于这种设计,但在编写可移植的组件时,需要考虑以下几个一般性问题,以确保这些组件能够适用于未来尚未设想的类型。

这里提出的问题列表并不完整,但它总结了一些已介绍的功能,介绍了一些额外的功能,并提到了本书稍后会讨论的功能。我们也希望这些问题能激励你阅读接下来的诸多章节。

11.1 可调用对象(Callables)

许多库包含允许客户端代码传递某种必须被“调用”的实体接口。例如,一项需要在另一个线程上调度的操作、一个描述如何对值进行哈希以便将其存储在哈希表中的函数、一个描述集合中元素排序顺序的对象,以及一个提供某些默认参数值的通用包装器。标准库在这方面也不例外:它定义了许多接收此类可调用实体的组件

在这种上下文中有一个术语叫做“回调”(callback)。传统上,这个术语专门指通过函数调用参数传递的实体(而非例如模板参数),我们将继续遵循这一传统。例如,排序函数可能包含一个回调参数作为排序判断,它通过调用来确定一个元素是否在期望的排序顺序中先于另一个元素。

在 C++ 中,有几种类型适合作为回调,因为它们既可以作为函数调用的参数传递,又可以通过 f(...) 语法直接调用:

  • 指向函数的指针类型;
  • 拥有重载的 operator() 的类类型(有时称为函数对象或仿函数),包括 lambda 表达式;
  • 拥有转换函数,返回指向函数的指针或函数的引用的类类型;

这些类型统称为函数对象类型,此类类型的值称为函数对象。C++ 标准库引入了一个稍微更广泛的概念,即可调用类型,它包括函数对象类型或指向成员的指针。可调用类型的对象称为可调用对象,为了方便,我们将其称为可调用体(callable)。

通用代码通常受益于能够接受任何类型的可调用体,而模板使得这一点成为可能。

11.1.1 支持函数对象

让我们看看标准库的 for_each() 算法是如何实现的(使用我们自己的名称 foreach 来避免名称冲突,并为简单起见省略返回值):foreach.hpp

template<typename Iter, typename Callable>
void foreach (Iter current, Iter end, Callable op)
{
    while(current != end)   // 只要没有遍历到最后一个元素
    {
        op(*current);       // 对每一个遍历的元素调用传递的函数对象
        ++current;          // 向下一个元素移动
    }
}

下面这个程序演示了如何使用这个模板与各种函数对象:foreach.cpp

#include <iostream>
#include <vector>
#include "foreach.hpp"

// 函数调用
void func(int i)
{
    std::cout << "func() called for: " << i << '\n';
}

// 函数对象类型(可以作为函数使用的对象)
class FuncObj
{
public:
    void operator()(int i) const    // 常量成员函数
    {
        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(),   // 处理范围
        func);                              // 函数本身可调用的(退化为指针)

    foreach(primes.begin(), primes.end(),   // 处理范围
        &func);                             // 函数指针也是可调用的

    foreach(primes.begin(), primes.end(),   // 处理范围
        FuncObj());                         // 函数对象可调用的

    foreach(primes.begin(), primes.end(),   // 处理范围
        [](int i) {                         // lambda 表达式是可调用的
            std::cout << "lambda called for: " << i << '\n';
        });
}

让我们详细看看每种情况:

  • 当我们将函数名称作为函数参数传递时,我们实际上并没有传递函数本身,而是传递了一个指针引用。和数组一样(参见第 7.4 节),函数参数在通过值传递时会衰减为指针,对于参数类型为模板参数的情况,会推导出一个指向函数的指针类型

    和数组一样,函数可以通过引用传递而不会发生衰减。然而,函数类型实际上不能被 const 修饰。如果我们将 foreach() 的最后一个参数声明为 Callable const&,那么 const 会被忽略。(一般来说,在主流的 C++ 代码中,很少使用指向函数的引用。)

  • 我们的第二次调用通过传递函数名称的地址,显式地接收了一个函数指针。这与第一次调用等效(其中函数名隐式衰减为指针值),但可能更清晰一些。

  • 传递一个函数对象时,我们传递的是一个类类型的对象作为可调用体。通过类类型调用通常意味着调用它的 operator()。因此,调用

op(*current);

通常会被转换为

op.operator()(*current);  // 使用参数 *current 调用 op 的 operator()

请注意,当定义 operator() 时,通常应将其定义为 const 成员函数。否则,当框架或库期望此调用不会更改传递对象的状态时,可能会出现一些微妙的错误信息(参见第 9.4 节)。

  • Lambda 表达式生成函数对象(称为 closures),因此这种情况与函数对象没有区别。然而,lambda 是引入函数对象的非常方便的简写符号,因此自 C++11 以来,lambda 表达式在 C++ 代码中非常常见。

11.1.2 处理成员函数和额外参数

在前面的示例中,我们没有使用的一个可调用实体是成员函数。原因是调用非静态成员函数通常需要指定应用此调用的对象,语法为 object.memfunc(...)ptr->memfunc(...),这与常见的 function-object(...) 调用模式不符。

幸运的是,自 C++17 起,C++ 标准库提供了一个实用工具 std::invoke(),它将这种情况与普通的函数调用语法统一起来,从而允许通过单一形式调用任何可调用对象。下面是使用 std::invoke()foreach() 模板的实现:foreachinvoke.hpp

#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) {  // 当还未遍历到元素末尾时
        std::invoke(op,       // 使用传入的可调用体调用
                    args...,  // 任何额外的参数
                    *current); // 以及当前的元素
        ++current;
    }
}

在这里,除了可调用参数之外,我们还接受任意数量的额外参数。foreach() 模板通过 std::invoke() 调用传入的可调用体,依次传递额外的参数和被引用的元素。std::invoke() 按如下方式处理:

  • 如果可调用体是指向成员的指针,它使用第一个额外的参数作为 this 对象。剩余的额外参数作为调用该可调用体的参数传递。
  • 否则,所有额外参数作为调用该可调用体的参数传递。

注意,这里我们不能对可调用体或额外参数使用完美转发:第一次调用可能会“窃取”它们的值,从而导致在后续迭代中调用 op 时出现意外行为。

使用这种实现,我们仍然可以编译前面对 foreach() 的调用。此外,我们还可以将额外的参数传递给可调用体,并且可调用体可以是成员函数[1]。以下的客户端代码演示了这一点:foreachinvoke.cpp

#include <iostream>
#include <vector>
#include <string>
#include "foreachinvoke.hpp"

// 成员函数会被调用的类
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};
    // 传递 lambda 作为函数体,并传递一个额外参数
    foreach(primes.begin(), primes.end(),       // 作为 lambda 表达式的第二个参数
        [](std::string const prefix, int i){    // lambda
        std::cout << prefix << i << '\n';
    }, "- value: ");                            // lambda 表达式的第一个参数

    // 对 primes 中的每个元素调用 obj.memfunc()
    MyClass obj;
    foreach(primes.begin(), primes.end(),       // 作为参数使用的元素
        &MyClass::memfunc,                      // 被调用的成员函数
        obj);                                   // 调用 memfunc() 的对象
}

第一次对 foreach() 的调用将其第四个参数(字符串字面量 "- value: ")传递给 lambda 的第一个参数,而向量中的当前元素绑定到 lambda 的第二个参数。第二次调用将成员函数 memfunc() 作为第三个参数传递,而 obj 作为第四个参数传递给 memfunc()

参见第 D.3.1 节,了解有关判断可调用体是否可以被 std::invoke() 使用的类型特性。

11.1.3 封装函数调用

std::invoke() 的一个常见应用是包装单个函数调用(例如,用来记录调用日志、测量调用时长,或准备一些上下文,如为函数调用启动新线程)。现在,我们可以通过完美转发来支持移动语义,将可调用体和所有传递的参数都完美转发:

#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),  // 转发可调用体
                       std::forward<Args>(args)...);  // 以及任何附加的参数
}

另一个有趣的方面是如何处理被调用函数的返回值,并将其“完美转发”回调用方。为了支持返回引用(例如 std::ostream&),需要使用 decltype(auto) 而不是单纯的 auto

template<typename Callable, typename... Args>
decltype(auto) call(Callable&& op, Args&&... args)

decltype(auto)(自 C++14 起可用)是一种占位符类型,它根据关联表达式的类型(初始化器、返回值或模板参数)来确定变量、返回类型或模板参数的类型。详见第 15.10.3 节。

如果你想将 std::invoke() 的返回值临时存储在一个变量中,做一些其他操作后再返回(例如处理返回值或记录调用结束),你也需要使用 decltype(auto) 声明临时变量:

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

注意,用 auto&& 声明 ret 是不正确的。作为引用,auto&& 会将返回值的生命周期延长到其作用域的末尾(参见第 11.3 节),但不会延长到返回给函数调用者的那一刻。

然而,使用 decltype(auto) 也存在一个问题:如果可调用体的返回类型是 void,则不能用 decltype(auto) 初始化 ret,因为 void 是不完整类型。你有以下几种选择:

  • 在声明 ret 的语句之前,声明一个对象,其析构函数执行你希望实现的行为。例如:
struct cleanup {
    ~cleanup() {
        ...  // 在返回时执行的代码
    }
} dummy;
return std::invoke(std::forward<Callable>(op),
                   std::forward<Args>(args)...);
#include <utility>      // std::invoke()
#include <functional>   // std::forward()
#include <type_traits>  // 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>)
    {
        // 返回类型为 void
        std::invoke(std::forward<Callable>(op), std::forward<Args>(args)...);
        ...
        return;
    }
    else
    {
        // 返回类型不为 void
        decltype(auto) ret{std::invoke(std::forward<Callable>(op), std::forward<Args>(args)...)};
        ...
        return ret;
    }
}

使用 if constexpr(std::is_same_v<std::invoke_result_t<Callable, Args...>, void>) 可以在编译时测试调用可调用体时返回的类型是否为 void。关 std::invoke_result<> 的详细信息[2],参见第 D.3.1 节。

未来的 C++ 版本可能会避免这种 void 特殊处理的需要(参见第 17.7 节)。

11.2 实现泛型库的其他工具

std::invoke() 是 C++ 标准库中用于实现泛型库的实用工具之一,接下来我们将探讨一些其他重要的工具。

11.2.1 类型特性 (Type Traits)

C++ 标准库提供了多种称为类型特性的工具,允许我们对类型进行评估和修改。这些工具支持在泛型代码中根据类型的特性做出适应或反应。例如:

#include <type_traits>

template<typename T>
class C {
    // 确保 T 不是 void(忽略 const 或 volatile)
    static_assert(!std::is_same_v<std::remove_cv_t<T>, void>,
                  "invalid instantiation of class C for void type");

public:
    template<typename V>
    void f(V&& v) {
        if constexpr (std::is_reference_v<T>) {
            // 如果 T 是引用类型,执行特殊代码
        }
        if constexpr (std::is_convertible_v<std::decay_t<V>, T>) {
            // 如果 V 可转换为 T,执行特殊代码
        }
        if constexpr (std::has_virtual_destructor_v<V>) {
            // 如果 V 有虚拟析构函数,执行特殊代码
        }
    }
};

如上述示例所示,通过检查某些条件,我们可以在模板的不同实现之间进行选择。这里我们使用了 C++17 引入的编译时 if 功能(参见第 8.5 节),但我们也可以使用 std::enable_if、部分特化或 SFINAE(参见第 8 章的详细信息)来启用或禁用辅助模板。

不过,使用类型特性时必须特别小心,因为它们的行为可能与预期不同。例如:

std::remove_const_t<int const&>  // 结果是 int const&

在这种情况下,因为引用本身不是 const,所以调用 std::remove_const_t 没有任何效果,结果还是传入的类型。

因此,删除引用和 const 的顺序很重要:

std::remove_const_t<std::remove_reference_t<int const&>>  // int
std::remove_reference_t<std::remove_const_t<int const&>>  // int const

你也可以直接调用:

std::decay_t<int const&>  // 结果是 int

不过,std::decay_t 也会将原始数组和函数转换为对应的指针类型。

有些类型特性有要求,未满足这些要求会导致未定义行为[3]。例如:

make_unsigned_t<int>           // unsigned int
make_unsigned_t<int const&>    // 未定义行为(希望抛出错误)

有时,结果可能出乎意料。例如:

add_rvalue_reference_t<int>        // int&&
add_rvalue_reference_t<int const>  // int const&&
add_rvalue_reference_t<int const&> // int const& (左值引用仍然是左值引用)

我们可能会认为 add_rvalue_reference 总是生成右值引用,但 C++ 的引用折叠规则(参见第 15.6.1 节)会导致左值引用和右值引用的组合产生左值引用。

再举个例子:

is_copy_assignable_v<int>       // 结果为 true(通常可以将 int 赋值给 int)
is_assignable_v<int, int>       // 结果为 false(不能调用 42 = 42)

is_copy_assignable 只是检查你是否能将一个 int 赋值给另一个 int,而 is_assignable 则考虑了值类别,检查是否可以将右值赋值给右值。第一个表达式等同于:

is_assignable_v<int&, int&>     // 结果为 true

因此:

is_swappable_v<int>             // 结果为 true(假设是左值)
is_swappable_v<int&, int&>      // 结果为 true(与前面的检查等效)
is_swappable_with_v<int, int>   // 结果为 false(考虑值类别)

因此,使用类型特性时要仔细了解它们的定义。附录 D 详细介绍了标准类型特性。

11.2.2 std::addressof()

std::addressof<>() 函数模板用于获取对象或函数的实际地址。即使对象类型重载了 & 操作符,它也能正常工作。虽然这种情况较为罕见,但可能发生(例如,在智能指针中)。因此,如果你需要获取任意类型对象的地址,建议使用 addressof()

template<typename T>
void f(T&& x) {
    auto p = &x;               // 如果 `&` 操作符被重载,可能会失败
    auto q = std::addressof(x); // 即使 `&` 被重载也能正常工作
    ...
}

11.2.3 std::declval()

std::declval<>() 函数模板可以作为特定类型对象引用的占位符。该函数没有定义,因此不能被调用(也不会创建对象)。因此,它只能用于未求值的操作数(例如 decltypesizeof 的表达式)。通过使用 declval(),我们可以假设存在某种类型的对象,而不需要真正创建它。

例如,以下声明从传递的模板参数 T1T2 中推导默认返回类型 RT

#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;
}

为了避免调用 T1T2 的默认构造函数来调用 operator ?: 初始化 RT,我们使用 std::declval() 来“使用”相应类型的对象,而不创建它们。不过,这只能在 decltype 的未求值上下文中使用。

不要忘记使用 std::decay<> 类型特性,以确保默认返回类型不是引用类型,因为 std::declval() 本身会生成右值引用。否则,调用 max(1, 2) 会导致返回类型为 int&&。详见第 19.3.4 节。

11.3 完美转发临时对象

如第6.1节所示,我们可以使用转发引用和 std::forward<> 来“完美转发”泛型参数:

template<typename T>
void f (T&& t) // t 是转发引用
{
    g(std::forward<T>(t)); // 将传递的参数 t 完美转发到 g()
}

然而,有时我们需要在泛型代码中完美转发数据,而这些数据并不是通过参数传递的。在这种情况下,我们可以使用 auto&& 来创建一个可以转发的变量。例如,假设我们有 get()set() 函数的链式调用,get() 的返回值需要完美转发到 set()

template<typename T>
void foo(T x)
{
    set(get(x));
}

进一步假设我们需要更新代码,以对 get() 生成的中间值进行某些操作。我们可以通过使用 auto&& 声明一个变量来保存该中间值:

template<typename T>
void foo(T x)
{
    auto&& val = get(x);
    ...
    // 将 get() 的返回值完美转发到 set():
    set(std::forward<decltype(val)>(val));
}

这样可以避免中间值的额外拷贝。

11.4 模板参数中的引用类型

虽然不常见,模板的类型参数也可以是引用类型。例如:tmplparamref.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 也会被推导为引用类型的基础类型(因为对于一个引用变量 v,表达式 v 的类型是被引用的类型;表达式的类型从来不会是引用类型)。然而,我们可以通过显式指定 T 的类型来强制引用情况:

tmplParamIsReference<int&>(r);
tmplParamIsReference<int&>(i);

这样做可以从根本上改变模板的行为,往往会导致模板未考虑这种可能性,进而触发错误或产生意外行为。请看以下示例:referror1.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: 非法的默认值
    RefMem<int&, 0> rm4;        // ERROR: 非法的默认值

    extern int null;
    RefMem<int&, null> rm5, rm6;
    rm5 = rm6;                  // ERROR: 对于引用成员,operator= 运算符是被 delete 掉的
}

在这个例子中,我们有一个类,其成员是模板参数类型 T,并通过一个具有零初始化默认值的非类型模板参数 Z 进行初始化。用 int 类型实例化类时,一切如预期运行。然而,当尝试用引用类型实例化它时,问题就出现了:

  • 默认初始化不再有效。
  • 不能再仅仅用 0 作为 int 的初始化值。
  • 最令人惊讶的是,赋值运算符不再可用,因为具有非静态引用成员的类删除默认赋值运算符

此外,使用引用类型作为非类型模板参数也是棘手且危险的。请看这个例子:referror2.cpp

#include <vector>
#include <iostream>

template<typenmae T, int& SZ>   // SZ 为一个引用
class Arr
{
private:
    std::vector<T> elems;
public:
    Arr() : elems(SZ){}         // 使用 SZ 初始化向量大小
    void print() const
    {
        for(int i = 0; i < SZ, ++i)
        {
            // 循环遍历 SZ 个元素
            std::cout << elems[i] << ' ';
        }
    }
};

int size = 10;

int main()
{
    Arr<int&, size> y;  // 编译时 ERROR

    Arr<int, size> x;   // 初始化里面 10 个元素
    x.print();          // OK
    size += 100;        // 修改了 Arr<> 中的 SZ 大小
    x.print();          // 运行时 ERROR:数组原本只有 10 个元素,修改 SZ 后变成了 120 个,非法访问
}

在这个例子中,尝试用引用类型的元素实例化 Arr 会导致错误,该错误出现在 std::vector<> 的深层代码中,因为 std::vector<> 不能用引用类型作为元素:

Arr<int&, size> y; // 编译时错误:出现在 std::vector<> 的深层代码中

该错误通常会导致长篇错误,正如第 9.4 节所述,编译器会提供从初始错误原因到实际模板定义的整个模板实例化历史。

更糟糕的是,将 size 参数设为引用时会导致运行时错误:它允许记录的 size 值在容器不知情的情况下发生变化(即,size 值可能变得无效)。因此,使用 size 的操作(如 print() 成员函数)必然会遇到未定义行为(导致程序崩溃,甚至更严重):

int size = 10;
...
Arr<int, size> x; // 使用 10 个元素初始化内部 vector
size += 100; // 哎呀:修改了 Arr<> 中的 SZ
x.print(); // 运行时错误:无效内存访问:循环遍历 120 个元素

注意,将模板参数 SZ 更改为 int const& 类型并不能解决这个问题,因为 size 本身仍然是可修改的。

这个例子可能看起来牵强,但在更复杂的情况下,这类问题确实会发生。此外,在 C++17 中,非类型参数可以被推导;例如:

template<typename T, decltype(auto) SZ>
class Arr;

使用 decltype(auto) 很容易产生引用类型,因此在这种上下文中通常应避免(默认使用 auto)。有关详细信息,请参见第 15.10.3 节。

因此,C++ 标准库在某些情况下有一些令人惊讶的规范和限制。例如:

  • 为了即使模板参数实例化为引用类型时仍能拥有赋值运算符,类 std::pair<>std::tuple<> 实现了赋值运算符,而不是使用默认行为。例如:
namespace std {
template<typename T1, typename T2>
struct pair {
    T1 first;
    T2 second;
    ...
    // 即使对于引用类型,默认的拷贝/移动构造函数仍然可以:
    pair(pair const&) = default;
    pair(pair&&) = default;
    ...
    // 但是赋值运算符必须定义,以便引用类型可以使用:
    pair& operator=(pair const& p);
    pair& operator=(pair&& p) noexcept(...);
    ...
};
}
  • 由于可能产生的复杂副作用,在 C++17 中,标准库类模板 std::optional<>std::variant<> 实例化为引用类型时是非法的(至少在 C++17 中是如此)。

要禁用引用类型,只需一个简单的静态断言即可:

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

总之,引用类型与其他类型有很大不同,并且受到多种独特语言规则的约束。例如,这会影响函数参数的声明方式(见第 7 节),以及我们定义类型特征的方式(见第 19.6.1 节)。

11.5 延迟计算(Defer Evaluations)

在实现模板时,有时会遇到代码是否能处理不完全类型的问题(参见第 10.3.1 节)。考虑以下类模板:

template<typename T>
class Cont {
private:
    T* elems;
public:
    ...
};

到目前为止,这个类可以与不完全类型一起使用。例如,在某些类中引用自己的类型元素时,这非常有用:

struct Node {
    std::string value;
    Cont<Node> next; // 只有当 Cont 接受不完全类型时才可能
};

然而,仅仅通过使用一些类型特征,您可能会失去处理不完全类型的能力。例如:

template<typename T>
class Cont {
private:
    T* elems;
public:
    ...
    typename std::conditional<std::is_move_constructible<T>::value,
                               T&&, T&>::type foo();
};

在这里,我们使用类型特征 std::conditional(参见第 D.5 节)来决定成员函数 foo() 的返回类型是 T&& 还是 T&,该决定取决于模板参数类型 T 是否支持移动语义。

问题在于,std::is_move_constructible 要求它的参数是一个完全类型(并且不是 void 或未知边界的数组,参见第 D.3.2 节)。因此,使用这个 foo() 声明时,Node 结构的声明会失败。

我们可以通过将 foo() 替换为一个成员模板来解决这个问题,这样 std::is_move_constructible 的求值会被推迟到 foo() 实例化时

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();
};

现在,类型特征依赖于模板参数 D(默认值为 T,这正是我们想要的),编译器必须等到 foo() 被调用且传入具体类型(如 Node)时才会对类型特征求值(此时 Node 已经是完全类型,在定义期间它只是个不完全类型)。

11.6 编写泛型库时的注意事项

以下是一些在实现通用库时需要记住的事项(其中一些可能会在本书后续章节介绍):

  • 使用转发引用在模板中转发值(参见第 6.1 节)。如果这些值不依赖于模板参数,请使用 auto&&(参见第 11.3 节)。
  • 当参数声明为转发引用时,注意当传递左值时模板参数可能为引用类型(参见第 15.6.2 节)。
  • 并非所有编译器都会报错,如果 std::is_move_constructible 用于不完全类型时。因为这种错误不需要诊断信息,因此这至少是个可移植性问题。
  • 当需要获取依赖模板参数的对象地址时,使用 std::addressof(),以避免与重载 operator& 的类型发生意外(参见第 11.2.2 节)。
  • 对于成员函数模板,确保它们的匹配优先级不高于预定义的拷贝/移动构造函数或赋值运算符(参见第 6.4 节)。
  • 当模板参数可能是字符串字面量并且不通过值传递时,考虑使用 std::decay(参见第 7.4 节和第 D.4 节)。
  • 如果模板参数依赖于 out 或 inout 参数,准备好处理 const 模板参数(参见第 7.2.2 节)。
  • 准备好处理模板参数是引用的副作用(参见第 11.4 节,和第 19.6.1 节的示例)。特别是,确保返回类型不会变为引用(参见第 7.5 节)。
  • 准备好处理不完全类型,以支持递归数据结构(参见第 11.5 节)。
  • 为所有数组类型重载,而不仅仅是 T[SZ](参见第 5.4 节)。

11.7 总结

  • 模板允许您将函数、函数指针、函数对象、仿函数和 lambda 作为可调用对象传递。
  • 在定义带有重载 operator() 的类时,将其声明为 const(除非调用会更改其状态)。
  • 使用 std::invoke() 实现能够处理所有可调用对象的代码,包括成员函数。
  • 使用 decltype(auto) 完美地转发返回值。
  • 类型特征是检查类型属性和能力的类型函数。
  • 当需要获取模板中的对象地址时,使用 std::addressof()
  • 使用 std::declval() 在未计算的表达式中创建特定类型的值。
  • 如果对象的类型不依赖模板参数,使用 auto&& 在通用代码中完美转发对象。
  • 准备好处理模板参数为引用的副作用。
  • 可以使用模板来延迟表达式的求值(例如,支持在类模板中使用不完全类型)。

[1]: std::invoke() 还允许使用指向数据成员的指针作为回调类型。此时,它不会调用函数,而是返回由额外参数引用的对象中,对应数据成员的值。

[2]: std::invoke_result<> 自 C++17 起可用。自 C++11 起,你可以通过调用以下方式获取返回类型:typename std::result_of<Callable(Args...)>::type

[3]: 曾有一个提议要求在 C++17 中,类型特性预条件的违反必须总是导致编译时错误。然而,由于某些类型特性具有过度约束的要求,例如总是需要完全类型,这一变更被推迟了。