C++高级特性--函数模板

面向对象编程

面向对象编程(OOP)和泛型编程都能处理在编写程序时不知道类型的情况。不同之处在于:OOP能处理类型在程序运行之前都未知的情况;而在泛型编程中,在编译时候就能知道类型了。
比如,标准库为每一个容器提供了单一的,泛型的定义,如vector。我们可以使用这个泛型定义来定义很多类型的vector,它们的差异在于包含的元素类型不同。
模板是C++中泛型编程的基础。一个模板就是一个创建类或函数的蓝图或者说公式。当使用一个vector这样一个泛型类,或者find这样的泛型函数时,我们提供一个足够的信息,将蓝图转换为特定的类或者函数。这种转换发生在编译时。下面我们将介绍如何定义模板。

定义模板

假定我们希望编写一个函数来比较两个值,并指出第一个值是大于、小于还是等于第二个值。在实际中,我们可能要定义多个函数,每个函数比较一种给定类型的值。我们的初次尝试可能定义多个重载函数。

// 如果两个值相等返回0,如果lhs小,返回-1,如果rhs小,返回1
int Compare(const std::string &lhs, const std::string &rhs)
{
    int iRet = 0;

    if (lhs < rhs)
    {
        iRet = -1;
    }
    else if (rhs < lhs)
    {
        iRet = 1;
    }

    return iRet;
}

int Compare(const int &lhs, const int &rhs)
{
    int iRet = 0;

    if (lhs < rhs)
    {
        iRet = -1;
    }
    else if (rhs < lhs)
    {
        iRet = 1;
    }

    return iRet;
}

这两个函数几乎是相同的,唯一的不同,就是参数的类型,函数体是完全一样的。
如果对每一种希望比较的类型都不得不重复定义完全一样的函数体,是非常繁琐且容易出错的。更麻烦的是,在编写程序的时候,我们就要确定可能要比较的所有类型。如果希望能在用户提供的类型上使用此函数,这种策略就失效了。

函数模板

我们可以定义一个通用的函数模板(function template),而不是为每个类型都定义一个新函数。一个函数模板就是一个公式,可用来生成针对特定类型的函数版本。Compare的模板版本可能想下面这样:

template <typename T>
int Compare(const T &lhs, const T &rhs)
{
    int iRet = 0;

    if (lhs < rhs)
    {
        iRet = -1;
    }
    else if (lhs > rhs)
    {
        iRet = 1;
    }

    return iRet;
}

模板定义以关键字template开始,后跟一个模板参数列表(template parameter list),这是一个逗号分割的一个或多个模板参数(template parameter)的列表,用小于号(<)和大于号(>)包围起来。

注意:在模板定义中,模板参数列表不能为空!

模板参数列表的作用很像函数的参数列表。函数参数列表定义了若干特定类型的局部变量,但并未指出如何初始化它们。在运行时,调用者提供实参来初始化形参。
类似的,模板参数表示在类或函数定义中用到的类型或值。当使用模板时,我们(隐式地或显示地)制定模板实参(template argument),将其绑定到模板参数上。
我们的Compare函数声明了一个名为T的类型参数。在Compare中,我们用名字T表示一个类型,而T表示的实际类型则在编译时根据Compare的使用情况来确定。

实例化函数模板

当我们调用一个函数模板时,编译器(通常)用函数实参来为我们推断模板实参。即,当我们调用Compare时,编译器使用实参的类型来确定绑定到模板参数T的类型,例如,在下面的调用中:

std::cout << Compare(1, 2) << std::endl;

实参类型是int,编译器会推断出模板实参是int,并将它绑定到参数T。
编译器用推断出的模板参数为我们实例化(instantiate)一个特定版本的函数。当编译器实例化一个模板时,它使用实际的模板实参代替对应的模板实参来创建出模板的一个新“实例”。例如,给定下面的调用:

    // 实例化出int Compare(const int &, const int &)
    std::cout << Compare(1, 2) << std::endl;    // T为int
    // 实例化出int Compare(const vector<int> &, const vector<int> &)
    std::vector<int> vec1{ 1, 2, 3 };
    std::vector<int> vec2{ 1, 2, 4 };
    std::cout << Compare(vec1, vec2) << std::endl;  // T为vector<int>

编译器会实例化出两个不同版本的Compare函数,对于第一个调用,编译器会编写并编译一个Compare版本,其中T被替换为int:

int Compare(const int &lhs, const int &rhs)
{
    int iRet = 0;

    if (lhs < rhs)
    {
        iRet = -1;
    }
    else if (rhs < lhs)
    {
        iRet = 1;
    }

    return iRet;
}

对于第二个调用,编译器会生成另一个Compare版本,其中T被替换为vector。这些编译器生成的版本通常被称为模板的实例(instantiate)。

模板类型参数

我们的Compare函数有一个模板类型参数(type ;parameter)。一般来说,我们可以将类型参数看做类型说明符,就像内置类型或者类型说明符一样使用。特别是,类型参数可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换:

// 正确:返回类型和参数类型相同
template <typename T>
T foo(T *p)
{
    T tmp = *p; // tmp的类型将是指针p指向的类型
    // ...
    return tmp;
}

类型参数前必须使用关键字class或者typename:

// 错误:U之前必须加上class或typename
template <typename T, U>
T calc(const T &, const U &)
{
    T tmp;
    return tmp;
}

在模板参数列表中,这两个关键字的含义相同,可以互换使用。一个模板参数列表中可以同时使用这两个关键字:

// 错误:U之前必须加上classtypename
template <typename T, class U>
T calc(const T &, const U &)
{
    T tmp;
    return tmp;
}

看起来用关键字typename来制定模板参数类型比用class更为直观。毕竟,我们可以用内置(非类)类型作为模板类型实参。而且,typename更清楚地指出随后的名字是一个类型名。但是,typename是在模板已经广泛使用之后才引入C++语言的,某些程序员仍然只用class。

非类型模板参数

除了定义类型参数,还可以在模板中定义非类型参数(nonetype parameter)。一个非类型参数表示一个值而非一个类型。我们通过一个特定的类型名而非关键字class或typename来指定非类型参数。
当一个模板被实例化时,非类型参数被一个用户提供或编译器推断出的值所代替。这些值必须是常量表达式,从而允许编译器在编译时实例化模板。
例如,我们可以编写一个Compare版本处理字符串字面常量。这种字面常量是const char的数组。由于不能拷贝一个数组,所以我们将自己的参数定义为数组的引用。由于我们希望能比较不同长度的字符串字面常量,因此,为模板定义了两个非类型的参数。第一个模板参数表示第一个数组的长度,第二个参数表示第二个数组的长度:

template <unsigned N, unsigned M>
int Compare(const char(&p1)[N], const char(&p2)[M])
{
    return strcmp(p1, p2);
}

当我们调用这个版本的Compare时:

std::cout << Compare("hello", "world") << std::endl;

编译器会使用字面常量的大小来代替N和M,从而实例化模板。记住编译器会在一个字符串字面常量的末尾插入一个空字符作为一个终结符,因此,编译器会实例化出如下版本:

int Compare(const char (&p1)[5], const char (&p2)[5])
{
    return strcmp(p1, p2);
}

一个非类型参数可以是一个整型,或者是一个指向对象或函数类型的指针或(左值)引用。绑定到非类型整型参数的实例必须是一个常量表达式。绑定到指针或引用非类型参数的实参必须具有静态的生存周期。我们不能用一个普通(非static)局部变量或动态对象作为指针或引用非类型模板参数的实参。指针参数也可以用nullptr或一个值为0的常量表达式来实例化。
在模板定义内,模板非类型参数是一个常量值。在需要常量表达式的地方,可以使用非类型参数,例如,制定数组的大小。

注意:非类型模板参数的模板实参必须是常量表达式!

inline和constexpr的函数模板

函数模板可以声明为inline或constexpr的,如同非模板函数一样。inlne或constexpr说明符放在模板参数列表之后,返回类型之前:

// 正确:inline说明符跟在模板参数列表之后
template<typename T> inline T min(const T &, const T &);
// 错误:inline说明符的位置不正确
inline template<typename T> T min(const T &, const T &);

编写类型无关的代码

我们最初的Compare函数虽然简单但是它说明了编写泛型代码的两个重要原则:

  • 模板中的函数参数是const的引用。
  • 函数体中的条件判断仅使用<比较运算。

通过将函数参数设定为const的引用,我们保证了函数可以用于不能拷贝的类型。大多数类型,包括内置类型和我们已经用过的标准库类型(除unique_ptr和IO类型之外),都是允许拷贝的。但是不允许拷贝的类型也是存在的。通过将参数设定为const的引用,保证了这些类型的引用可以用我们的Compare函数来处理。而且,如果Compare用于处理大对象,这种设计策略还能使函数运行的更快。
你可能认为既使用<运算符又使用>运算符来进行比较操作会更为自然:

    if (lhs < rhs)
    {
        iRet = -1;
    }
    else if (lhs > rhs)
    {
        iRet = 1;
    }

但是,如果编写代码时只是用<运算符,我们就降低了Compare函数对要处理的类型的要求。这些类型支持<,但不必支持>。
实际上,如果我们真的关心类型无关和可移植性,可能需要用less来定义我们的函数:

// 即使用于指针也正确的Compare版本
template <typename T>
int Compare(const T &lhs, const T &rhs)
{
    int iRet = 0;

    if (std::less<T>()(lhs, rhs))
    {
        iRet = -1;
    }
    else if (std::less<T>()(rhs, lhs))
    {
        iRet = 1;
    }

    return iRet;
}

原始版本存在的问题是,如果用户调用它比较两个指针,且两个指针指向相同的数组,则代码的行为是未定的(据查资料,less的默认实现用的就是<,所以这其实并未起到这种比较有一个良好定义的作用–译者注)。
注意:模板程序应该尽量减少对实参类型的要求!

模板编译

当编译器遇到一个模板定义时,它并不生成代码。只有当我们实例化出模板的一个特定版本时,编译器才会生成代码。当我们使用(而不是定义)模板时,编译器才生成代码,这一特性影响了我们如何组织代码以及错误何时被检测到。
通常,当我们调用一个函数时,编译器只需要掌握函数的声明。类似的,当我们使用一个类类型的对象时,类定义必须是可用的,但成员函数的定义不必已经出现。因此,我们将类定义和函数声明放在头文件中,而普通函数和类的成员函数的定义放在源文件中。
模板则不同:为了生成一个实例化版本,编译器需要掌握函数模板或类模板成员函数的定义。因此,与非模板代码不同,模板的头文件通常既包括声明也包括定义。
注意:函数模板和类模板成员函数的定义通常放在头文件中!

关键概念:模板和头文件

模板包含两种名字:

  • 那些不依赖于模板参数的名字;
  • 那些依赖于模板参数的名字。

当使用模板时,所有不依赖于模板参数的名字都必须是可见的。这是由模板的提供者来保证的。而且,模板的提供者必须保证,当模板被实例化时,模板的定义,也必须是可见的。
用来实例化模板的所有函数、类型以及与类型关联的运算符的声明都必须是可见的,这是由模板的用户来保证的。
通过组织良好的程序结构,恰当还用头文件,这些要求都很容易满足。模板的设计者应该提供一个头文件,包含模板定义以及在类模板或成员定义中用到的所有名字的声明。木板的用户必须包含模板的头文件,以及用来实例化模板的任何类型的头文件。

大多数编译错误在实例化期间报告

模板直到实例化时才会生成代码,这一特性影响了我们何时才会获知模板内代码的编译错误。通常,编译器会在三个阶段报告错误。
第一个阶段是编译模板本身时。在这个阶段,编译器通常不会发现很多错误。编译器可以检查语法错误,例如忘记分号或者变量名拼写错误,但也就这么多了。
第二个阶段是编译器遇到模板使用时。在此阶段,编译器仍然没有很多可检查的。对于函数模板调用,编译器通常会检查实参数目是否正确。它还能检查参数类型是否匹配。对于类模板,编译器可以检查用户是否提供了正确数目的模板实参,但也仅限于此了。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值