现代泛型编程指南==》函数模板

1. 基本示例


#include <iostream>
#include <string>
template<typename T>
T max(T a, T b)
{
	return b < a ? a: b;
}
int main() {
    int i = 42;
    std::cout << "max(7, i): " << ::max(7, i) << '\n';

    double f1 = 3.4;
    double f2 = -6.7;
    std::cout << "max(f1, f2): " << ::max(f1, f2) << '\n';

    std::string s1 = "mathematics";
    std::string s2 = "math";
    std::cout << "max(s1, s2): " << ::max(s1, s2) << '\n';

    return 0;
}

代码中,对max0的使用都用:限定。这是为了确保在全局命名空间中找到max0模板。因为标准库中有一个std::max(0,在某些情况下可以使用,但这里可能会产生歧义。

  1. 模板参数:

    • typename T 定义了一个类型参数 T,它是泛型的占位符,表示在调用模板函数时将被替换为实际类型的变量。
    • typename 关键字用来声明类型参数。在某些上下文中,可以省略 typename 关键字 [[#可以省略 typename]]。
  2. 模板函数定义:

    • 模板函数定义使用尖括号 <> 包围类型参数。
    • 模板函数 max 的定义如下:
      template<typename T>
      T max(T a, T b) {
          return b < a ? a : b;
      }
      
    • 这里 T 是类型参数,它将在每次调用时被替换为实际类型。
  3. 类型参数的约束[[#关于 类型参数的约束]]:

    • 类型参数 T 必须支持 < 运算符,因为 max 函数使用了这个运算符来比较 ab
    • 类型 T 的值还必须是可以复制的,因为函数需要返回一个 T 类型的值。(C++17之前,类型T也必须可复制才能传递参数。C++17以后,即使复制构造函数和移动构造函数都无效,也可以传递临时变量(右值 ))
  4. 模板实例化:

    • 当函数 max 被调用时,编译器会根据传入的实际类型实例化模板。
    • 例如,如果调用 max(3, 5),那么 T 将被实例化为 int 类型。
  5. 模板的通用性:

    • 模板函数 max 可以接受任何支持 < 运算符的类型,包括基本类型(如 int, double)和用户定义的类型(如 std::string 或自定义类)。

1.1 两阶段编译

模板处理的两个阶段

  1. 解析阶段 (Parsing):在这个阶段,编译器读取源代码并构建抽象语法树(AST)。在这个过程中,模板定义的语法正确性会被检查,但是模板的具体实例还没有被创建。这意味着在这个阶段,编译器无法评估依赖于模板参数的表达式的值。

  2. 实例化阶段 (Instantiation):当模板被用来创建特定类型的实例时,这个过程被称为模板实例化。在这个阶段,编译器会创建具体的函数或类,并且能够评估模板参数的实际值。模板实例化时,所有依赖于模板参数的表达式都会被评估


#include <iostream>
#include <type_traits>

// 模板函数
template<typename T>
void print(T value)
{
    static_assert(std::is_integral<T>::value, "T must be an integral type"); // 静态断言

    std::cout << value << '\n';
}

int main()
{
    print(5); // 实例化为 int 类型,正常工作

    // 下面这一行会在实例化时失败,因为 float 不满足 is_integral 断言
    print(3.14f); // 编译错误:T must be an integral type

    return 0;
}

错误分析:

  1. 解析阶段:

    • 如果模板定义本身存在语法错误,例如缺少分号或者使用了未定义的标识符等,那么编译器会在解析阶段报错。
    • 在上述示例中,print 函数模板的定义是正确的,因此解析阶段不会产生任何错误。
  2. 实例化阶段:

    • 当模板被具体类型实例化时,如果实例化导致的代码有错误,那么编译器会在实例化阶段报错。
    • 在上述示例中,当我们尝试用浮点数 3.14f 调用 print 时,编译器会尝试实例化模板为 float 类型。由于 float 不满足 std::is_integral<T>::value 的静态断言,因此这里会产生一个编译错误。

要确定模板编译错误发生在解析阶段还是实例化阶段,最直接的方法是查看编译器给出的错误信息以及错误发生的上下文。通常,编译器会报告错误发生的文件名、行号以及错误的具体描述。

让我们根据之前提供的示例代码进一步讨论:

#include <iostream>
#include <type_traits>

template<typename T>
void print(T value)
{
    static_assert(std::is_integral<T>::value, "T must be an integral type");

    std::cout << value << '\n';
}

int main()
{
    print(5); // 实例化为 int 类型,正常工作

    print(3.14f); // 实例化时失败,因为 float 不满足 is_integral 断言

    return 0;
}

错误分析:

  1. 解析阶段:

    • 如果模板定义本身存在语法错误,例如缺少分号或者使用了未定义的标识符等,那么编译器会在解析阶段报错。
    • 在上述示例中,print 函数模板的定义是正确的,因此解析阶段不会产生任何错误。
  2. 实例化阶段:

    • 当模板被具体类型实例化时,如果实例化导致的代码有错误,那么编译器会在实例化阶段报错。
    • 在上述示例中,当我们尝试用浮点数 3.14f 调用 print 时,编译器会尝试实例化模板为 float 类型。由于 float 不满足 std::is_integral<T>::value 的静态断言,因此这里会产生一个编译错误。
如何识别错误阶段:
  • 静态断言错误:

    • 当模板被实例化并且触发了 static_assert 时,编译器通常会报告一个编译错误,并指出错误发生在哪个文件的哪一行。
    • 错误信息可能会显示该行的代码以及静态断言失败的原因。
    • 例如,对于上面的例子,错误信息可能是这样的:
      error: static assertion failed: T must be an integral type
          static_assert(std::is_integral<T>::value, "T must be an integral type");
                                                    ^~~~~~~~~~~~~~~~~~~~~~~~~~
      
      这里的错误明确指出了错误发生在 static_assert 行,因此我们可以判断这是在实例化阶段发生的错误。
  • 语法错误:

    • 如果模板定义中存在语法错误,编译器会在解析阶段报错。
    • 例如,如果我们在模板定义中遗漏了一个分号,编译器会在模板定义所在的行报告错误。
    • 错误信息会显示缺失分号的位置,并提示这是一个语法错误。
    • 例如,如果我们在 print 函数模板中遗漏了一个分号,编译器可能会报告如下错误:
      error: expected ';' before '}' token
      }
      ^
      

1.2 模板参数的推导

您的描述非常准确地概括了 C++ 中模板参数推导的一些重要规则。让我们详细探讨一下这些规则及其背后的逻辑。

按引用声明参数时的模板参数推导规则

当模板函数的参数是按引用声明时(例如 T const&T&),模板参数 T 的推导更加严格。这意味着所有按同一模板参数声明的实参必须具有相同的类型。例如:

template<typename T>
T max(T const& a, T const& b) {
    return b < a ? a : b;
}

int i = 17;
int const c = 42;

// 下面的调用都是合法的,因为所有实参都是 int 类型。
max(i, c);      // T 推导为 int
max(c, c);      // T 推导为 int
int& ir = i;
max(i, ir);     // T 推导为 int

按值声明参数时的模板参数推导规则

当模板函数的参数是按值声明时(例如 T),模板参数 T 的推导稍微宽松一些。它允许进行简单的转换,例如:

  • 忽略 constvolatile 限定符。
  • 引用转换为其所引用的类型。
  • 原始数组或函数转换为相应的指针类型(数组退化的解决方案[[#数组退化的问题]])。

对于使用相同模板参数 T 声明的两个参数,转换后的类型必须匹配。例如:

template<typename T>
T max(T a, T b) {
    return b < a ? a : b;
}

int i = 17;
int const c = 42;
int arr[4];

// 下面的调用都是合法的,因为所有实参经过转换后都是 int 或 int* 类型。
max(i, c);        // T 推导为 int
max(&i, arr);     // T 推导为 int*

反面案例

现在我们来看一些非法的调用示例:

// 下面的调用是非法的,因为 int 和 double 不能相互转换。
max(4, 7.2);     // ERROR: T 不能同时推导为 int 和 double

// 下面的调用是非法的,因为 char const* 和 std::string 不能相互转换。
std::string s;
max("hello", s); // ERROR: T 不能同时推导为 char const* 和 std::string

分析

  1. max(4, 7.2);

    • 这个调用是非法的,因为 4int 类型,而 7.2double 类型。虽然 int 可以隐式转换为 double,但模板参数推导不允许这种类型的转换。在这种情况下,T 不能同时为 intdouble
  2. max(“hello”, s);

    • 这个调用同样是非法的,因为 "hello"char const* 类型,而 sstd::string 类型。char const* 是 C 风格字符串,而 std::string 是 C++ 的字符串类。这两种类型之间没有简单的转换,所以 T 不能同时为 char const*std::string

总结

  • 当参数按引用声明时,所有实参必须具有相同的类型。
  • 当参数按值声明时,允许进行简单的转换,但转换后的类型必须匹配。
  • 如果实参的类型不兼容,或者不能通过简单的转换匹配,那么调用将导致编译错误。

1.2.1 默认参数的类型推导规则

  • 默认参数的目的是提供一种方便的方式来省略某些参数的值,而不是用来推导模板参数的类型。

template <typename T>
void f(T = "") {

};

int main()
{
	f(1);// 传入的参数 推导出了 T 是 int
	f(); // 企图 调用 默认参数的 f("") 推导 T 的类型
}

为了解决这个问题,可以在模板参数列表中为模板形参声明一个默认实参,如下所示:

template<typename T = std::string>
void f(T t = "")
{
	std::cout << t << '\n';
}
#include <iostream>
#include <string>

template<typename T = std::string>
void f(T t = "")
{
    std::cout << t << '\n';
}

int main() {
    f<int>(1);  // 明确指定模板参数为 int
    f();        // 使用默认模板参数 std::string
    f(1);       // 推导出 T 为 int
    f("Hello"); // 推导出 T 为 std::string

    return 0;
}

1.3 多类型的模板参数

当然可以,让我们按照您提供的内容重新排版和组织一下:

函数模板的类型参数

函数模板可以有两组不同的类型参数:

  1. 模板参数:用尖括号声明在函数模板名之前。

    template<typename T>
    // T 是模板参数
    
  2. 调用参数:在函数模板名称后面的圆括号中声明。

    T max(T a, T b)
    // a 和 b 是调用参数
    

实际上,可以有任意多种模板参数。例如,可以为两种不同类型的调用参数定义 max0 模板:

template<typename T1, typename T2>
T1 max(T1 a, T2 b) {
    return b < a ? a : b;
}

你可以这样调用它:

auto m = ::max(4, 7.2);  // OK, 返回类型与第一个参数类型一样

问题描述

将不同类型的参数传递给 max0 模板会引发一个问题。如果使用其中一种形参类型作为返回类型,那么其他参数可能需要向这种类型进行转换。因此,返回类型取决于调用参数的顺序。例如:

  • 66.6642 的最大值将是 double 类型的 66.66
  • 4266.66 的最大值将是 int 类型的 66

解决方案

C++ 提供了几种方法来处理这个问题:

  1. 为返回类型引入第三个模板参数

    • 这种方法允许你显式地指定返回类型,从而更好地控制模板函数的行为。
  2. 让编译器找出返回类型

    • C++14 引入了 auto 关键字来推导返回类型,这样你可以让编译器根据函数体的内容自动推导返回类型。
  3. 将返回类型声明为两个参数类型的“公共类型”

    • 使用 std::common_type_t 来获取两个类型的公共类型,这是一个类型别名,它表示两个类型可以互相转换的类型。

示例代码

下面是一个完整的示例,演示了这三种方法:

#include <iostream>
#include <type_traits>

// 方法1: 为返回类型引入第三个模板参数
template<typename T1, typename T2, typename TReturn>
TReturn max1(TReturn a, T2 b) {
    return b < a ? a : b;
}

// 方法2: 让编译器找出返回类型
template<typename T1, typename T2>
auto max2(T1 a, T2 b) -> decltype((b < a ? a : b)) {
    return b < a ? a : b;
}

// 方法3: 将返回类型声明为两个参数类型的“公共类型”
template<typename T1, typename T2>
std::common_type_t<T1, T2> max3(T1 a, T2 b) {
    return b < a ? a : b;
}

int main() {
    auto m1 = ::max1<double, int, double>(4, 7.2);  // 返回 double 类型
    std::cout << "m1: " << m1 << '\n';

    auto m2 = ::max2(4, 7.2);  // 返回 double 类型
    std::cout << "m2: " << m2 << '\n';

    auto m3 = ::max3(4, 7.2);  // 返回 double 类型
    std::cout << "m3: " << m3 << '\n';

    return 0;
}

解释

  1. 方法1:使用第三个模板参数 TReturn 来指定返回类型。这种方式需要显式指定返回类型,适用于你已经知道返回类型的情况。(后面会介绍函数实参类型推导,并不需要全部指定类型)

  2. 方法2:使用 autodecltype 来让编译器推导返回类型。这种方式简洁,但可能不如显式指定类型那样直观。

  3. 方法3:使用 std::common_type_t 来获取两个类型可以互相转换的公共类型。这是 C++17 引入的类型特质,可以自动选择两个类型之间的公共类型。

总结

  • 方法1:适用于你想要显式指定返回类型的情况。
  • 方法2:适用于你想让编译器推导返回类型的情况。
  • 方法3:适用于你想自动选择两个类型之间的公共类型作为返回类型的情况。

方法二的局限性[[#方法二的局限性]]

方法三的注意事项


问题

关于 类型参数的约束

当我们定义 std::max 使用 a < b ? b : a 时,可能会出现问题的情况如下:

ab 相等时

如果 ab 相等,那么 a < b 为假,因此表达式会返回 a。这本身没有问题,因为 ab 是相等的,所以返回哪一个都是一样的。

b 不小于 aa 也不小于 b

这种情况通常发生在用户定义的类型上,其中 < 运算符可能没有按照预期的方式定义,或者没有定义 > 运算符。假设我们有一个类 MyType,它定义了 < 运算符,但没有定义 > 运算符:

struct MyType {
    bool operator<(const MyType&) const { /* ... */ }
};

如果 b 不小于 aa 也不小于 b,这意味着 a < b 为假,而 b < a 也为假。此时,如果我们使用 a < b ? b : a 的形式,当 a < b 为假时,我们会返回 a。然而,如果我们使用 b < a ? a : b 的形式,当 b < a 为假时,我们也会返回 b。这两种情况都会返回一个值,但问题是:

  • 如果 ab 是不可比较的,即 a < bb < a 都为假,那么使用 a < b ? b : a 的形式可能会导致不确定的结果,特别是如果 ab 实际上是不可比较的或者具有特殊的比较逻辑。
  • 使用 b < a ? a : b 的形式更加稳健,因为它不依赖于 > 运算符的存在,并且在大多数情况下都能正确地返回两个值中的较大者。

示例

假设我们有一个类 MyType,它定义了 < 运算符,但没有定义 > 运算符。并且假设 ab 实例之间没有定义明确的顺序关系(即 a < bb < a 都为假):

struct MyType {
    bool operator<(const MyType& other) const {
        // 假设这里的实现使得 a < b 和 b < a 都为假
        return false;
    }
};

MyType a;
MyType b;

// 使用 a < b ? b : a
std::max(a, b); // 可能返回 a 或 b,取决于 a < b 的结果
// 使用 b < a ? a : b
std::max(a, b); // 返回 b,因为 b < a 为假

在第一个例子中,std::max(a, b) 的行为取决于 a < b 的结果,这可能是不确定的。而在第二个例子中,无论 ab 是否可比较,std::max(a, b) 总是返回 b,因为 b < a 为假。

总结

  • 使用 b < a ? a : b 更加稳健,因为它不依赖于 > 运算符的存在。
  • 对于内置类型和大多数用户定义的类型,使用 b < a ? a : ba < b ? b : a 的区别不大。
  • 在某些特殊情况下,如不可比较的类型或只有 < 运算符定义的类型,b < a ? a : b 更能保证函数的行为正确性。

可以省略 typename

在 C++ 中,typename 关键字用于声明类型参数,尤其是在模板上下文中。但在某些情况下,你可以省略 typename 关键字。以下是为什么可以省略 typename 的原因:

1. 上下文清晰

在模板参数列表中,当你定义类型参数时,上下文已经很清楚地表明你正在声明一个类型,因此 typename 关键字通常是可选的。例如:

template<typename T>
T max(T a, T b)
{
	return b < a ? a: b;
}

在这个例子中,typename 可以省略,因为模板参数列表的上下文已经清楚地表明 T 是一个类型参数。
你可以写为


#include <iostream>
#include <string>

template<typename T>
typename T max( typename T a, typename T b)
{
	return b < a ? a: b;
}


int main() {
    int i = 42;
    std::cout << "max(7, i): " << ::max(7, i) << '\n';

    double f1 = 3.4;
    double f2 = -6.7;
    std::cout << "max(f1, f2): " << ::max(f1, f2) << '\n';

    std::string s1 = "mathematics";
    std::string s2 = "math";
    std::cout << "max(s1, s2): " << ::max(s1, s2) << '\n';

    return 0;
}

当在模板成员函数中引用模板参数时,使用 typename 关键字可以帮助避免混淆。这是因为 C++ 编译器需要明确知道你在引用一个类型,而不是一个变量或函数。

 
#include <iostream>
#include <vector>
#include <list>
#include <set>
using std::cout;
using std::endl;
template<class Container>
void Print(const Container& ctnr)
{
    Container::const_iterator it = ctnr.begin();
    //typename Container::const_iterator it = ctnr.begin();
    while (it != ctnr.end())
    {
        cout << *it << " ";
        ++it;
    }
    cout << endl;
}

int main()
{
    // 使用 vector
    std::vector<int> vec = { 1, 2, 3, 4, 5 };
    Print(vec);

    // 使用 list
    std::list<char> lst = { 'a', 'b', 'c' };
    Print(lst);

    // 使用 set
    std::set<double> st = { 1.1, 2.2, 3.3 };
    Print(st);

    return 0;
}

发生编译错误是因为在模板实例化之前,编译器无法确定 Container::const_iterator 是嵌套类型,还是静态成员变量,亦或是静态成员函数。

除此之外,还有另一种解决办法,即

auto it = ctnr.begin();

确实,从 C++11 开始,你可以使用 auto 关键字来自动推导迭代器的类型,这可以避免显式使用 typename 关键字。这种方法更加简洁,也更容易阅读。

数组退化的问题

在 C++ 中,传统的 C 风格数组在很多情况下会退化为指向数组首元素的指针。这种退化在某些场景下是非常有用的(数组退化为指针后,可以利用指针算术来访问数组元素;动态分配的数组(使用 new[] 创建)可以像指针一样处理),但它也意味着数组的大小信息丢失了,这可能会带来一些问题,尤其是在模板编程中。


#include <iostream>
#include <array>

// 模板函数,接受一个 std::array 并打印其元素
template<typename T, std::size_t N>
void print_array(const std::array<T, N>& arr) {
    for (const auto& element : arr) {
        std::cout << element << ' ';
    }
    std::cout << '\n';
}

int main() {
    // 创建一个 std::array
    std::array<int, 5> my_array = { 1, 2, 3, 4, 5 };

    // 调用模板函数
    print_array(my_array);

    return 0;
}


在这个示例中,我们使用 std::array 保持了数组的完整类型信息。模板函数 print_array 可以通过模板参数 TN 获取数组的类型和大小。


#include <iostream>
#include <initializer_list>

// 模板函数,接受一个 std::initializer_list 并打印其元素
template<typename T>
void print_initializer_list(const std::initializer_list<T>& list) {
    for (const auto& element : list) {
        std::cout << element << ' ';
    }
    std::cout << '\n';
}

int main() {
    // 创建一个 std::initializer_list
    std::initializer_list<int> my_list = { 1, 2, 3, 4, 5 };

    // 调用模板函数
    print_initializer_list(my_list);

    return 0;
}

  • std::initializer_list 本身包含了元素的数量信息,因此在使用它时不需要显式传递大小。
  • 它非常适合用于处理可变数量的元素,可以方便地用于模板函数的参数列表中。

方法二的局限性

当然可以。让我们通过一个具体的示例来演示使用 std::decay 的必要性。

问题背景

当我们定义一个函数模板,其中返回类型通过 decltypeauto 推导时,如果没有使用 std::decay,可能会遇到返回引用类型的问题。这是因为 decltype 会保留表达式的类型,包括引用类型。如果其中一个模板参数是引用类型,那么返回类型也会是引用类型。

示例代码

假设我们有一个函数模板 max,它比较两个参数并返回较大的一个。我们将使用 decltypeauto 来推导返回类型,并通过一个具体的示例来说明使用 std::decay 的必要性。

#include <iostream>
#include <type_traits>

// 定义 max 函数模板
template<typename T1, typename T2>
auto max(T1 a, T2 b) -> decltype(true ? a : b) {
    return b < a ? a : b;
}

int main() {
    int i = 42;
    int const& ir = i;  // ir 引用 i

    // 使用 auto 作为返回类型
    auto a = ir;  // a 的类型为 int

    // 调用 max 函数模板
    auto m = max(ir, 7.2);  // 返回类型为 int const&, 这里有问题

    std::cout << "Max: " << m << '\n';

    return 0;
}

解释

  1. 没有使用 std::decay

    • decltype(true ? a : b) 确定三元操作符的结果类型。
    • 如果 ab 是引用类型,那么返回类型也会是引用类型。
    • 在这个示例中,irint const& 类型,因此 max 函数模板的返回类型是 int const&
  2. 问题

    • 当返回类型是引用类型时,这可能会导致问题。例如,如果 m 被赋值给一个非引用类型的变量,那么编译器可能会发出警告或错误,因为不能将引用赋值给非引用类型。

解决方案:使用 std::decay

为了确保返回类型不是引用类型,我们可以使用 std::decay 来去除返回类型的引用限定。这可以避免上述问题,并确保返回类型始终是值类型。

修改后的示例代码

下面是修改后的示例,其中使用了 std::decay

#include <iostream>
#include <type_traits>

// 定义 max 函数模板
template<typename T1, typename T2>
auto max(T1 a, T2 b) -> typename std::decay<decltype(true ? a : b)>::type {
    return b < a ? a : b;
}

int main() {
    int i = 42;
    int const& ir = i;  // ir 引用 i

    // 使用 auto 作为返回类型
    auto a = ir;  // a 的类型为 int

    // 调用 max 函数模板
    auto m = max(ir, 7.2);  // 返回类型为 int, 而不是 int const&

    std::cout << "Max: " << m << '\n';

    return 0;
}

解释

  1. 使用 std::decay

    • typename std::decay<decltype(true ? a : b)>::type 确保返回类型不是引用类型。
    • 即使 ab 是引用类型,std::decay 也会去除引用限定。
  2. 避免问题

    • 使用 std::decay 后,无论 ab 的类型是什么,max 函数模板的返回类型都将是一个值类型。
    • 这样可以避免返回引用类型带来的问题。

注意:

  1. 使用 typename 关键字

    • typename std::decay<decltype(true ? a : b)>::type 中的 typename 关键字用于访问类型特质的结果类型。
    • 这是因为 std::decay 的结果是一个类型特质,需要 typename 来访问它的 type 成员。

方法三的注意事项

c++ 11中需要这样写

#include <type_traits>

// 使用 std::common_type 来确定两个类型 T1 和 T2 的公共类型
template<typename T1, typename T2>
typename std::common_type<T1, T2>::type max(T1 a, T2 b) {
    return b < a ? a : b;
}

后续还会对 common_type 研究

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

丁金金

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值