Skip to content

Latest commit

 

History

History
1443 lines (1036 loc) · 68.7 KB

03.md

File metadata and controls

1443 lines (1036 loc) · 68.7 KB

三、不允许鸭子——模板和推导(二)

学习目标

本章结束时,您将能够:

  • 使用继承和多态性开发自己的类,以获得更大的效果
  • 实现一个别名,使您的代码更容易阅读
  • 使用 SFINAE 和 constexpr 开发模板来简化代码
  • 使用 STL 实现您自己的解决方案,以利用泛型编程
  • 描述类型演绎的背景和基本规则

本章将向您展示如何通过继承、多态性和模板来定义和扩展您的类型。

简介

在前一章中,我们学习了如何在单元测试的帮助下开发我们自己的类型(类),并使它们像内置类型一样工作。我们被介绍了函数重载、三/五法则和零法则。

在本章中,我们将学习如何进一步扩展类型系统。我们将学习如何使用模板创建函数和类,并重新访问函数重载,因为它受到模板使用的影响。我们将被介绍一项新技术 SFINAE ,并使用它来控制我们模板中包含在生成代码中的部分。

继承、多态性和接口

到目前为止,在我们面向对象设计和 C++ 的旅程中,我们一直专注于抽象和数据封装。我们现在将注意力转向遗传多态性。什么是继承?什么是多态性?我们为什么需要它?考虑以下三个对象:

Figure 2B.1: Vehicle objects

图 2B.1:车辆物体

在上图中,我们可以看到有三个非常不同的对象。他们有一些共同点。它们都有轮子(不同的数量)、发动机(不同的尺寸、功率或配置)、启动发动机、驱动、踩刹车、停止发动机等等,使用它们我们可以做一些事情。

因此,我们可以把它们抽象成一种叫做载体的东西,来展示这些属性和一般行为。如果我们将其表示为 C++ 类,它可能如下所示:

class Vehicle
{
public:
  Vehicle() = default;
  Vehicle(int numberWheels, int engineSize) : 
          m_numberOfWheels{numberWheels}, m_engineSizeCC{engineSize}
  {
  }
  bool StartEngine()
  {
    std::cout << "Vehicle::StartEngine " << m_engineSizeCC << " CC\n";
    return true;
  };
  void Drive()
  {
    std::cout << "Vehicle::Drive\n";
  };
  void ApplyBrakes()
  {
    std::cout << "Vehicle::ApplyBrakes to " << m_numberOfWheels << " wheels\n";
  };
  bool StopEngine()
  {
    std::cout << "Vehicle::StopEngine\n";
    return true;
  };
private:
  int m_numberOfWheels {4};
  int m_engineSizeCC{1000};
};

车辆类是摩托车汽车卡车的更广义(或抽象)的表达。我们现在可以通过重用车辆类中已经可用的东西来创建更专门的类型。我们将通过使用继承来重用 Vehicle 的属性和方法。继承的语法如下:

class DerivedClassName : access_modifier BaseClassName
{
  // Body of DerivedClass
};

我们之前遇到过公共受保护私有等访问修饰符。它们控制我们如何访问基类的成员。摩托车等级将按如下方式推导:

class Motorcycle : public Vehicle
{
public:
  Motorcycle(int engineSize) : Vehicle(2, engineSize) {};
};

在这种情况下,车辆类被称为基类超类,而摩托车类被称为衍生类子类。我们可以用图形表示如下,箭头从派生类指向基类:

Figure 2B.2: Vehicle class hierarchy

图 2B.2:车辆等级体系

但是摩托车的驾驶方式不同于普通车辆。因此,我们需要修改摩托车类,使其行为不同。更新后的代码如下:

class Motorcycle : public Vehicle
{
public:
  Motorcycle(int engineSize) : Vehicle(2, engineSize) {};
  void Drive()
  {
    std::cout << "Motorcycle::Drive\n";
  };
};

如果我们考虑面向对象的设计,这是关于用协作的对象来建模问题空间。这些对象通过消息相互通信。现在,我们有两个类以不同的方式响应相同的消息(驱动方法)。消息的发送者不知道会发生什么,也不真正关心,这就是多态的本质。

注意

多态来自希腊语 poly 和 morph,其中poly表示多,morph表示形式。所以,多态性意味着有多种形式

我们现在可以使用这些类来测试多态性:

#include <iostream>
int main()
{
  Vehicle vehicle;
  Motorcycle cycle{1500};
  Vehicle* myVehicle{&vehicle};
  myVehicle->StartEngine();
  myVehicle->Drive();
  myVehicle->ApplyBrakes();
  myVehicle->StopEngine();
  myVehicle = &cycle;
  myVehicle->StartEngine();
  myVehicle->Drive();
  myVehicle->ApplyBrakes();
  myVehicle->StopEngine();
  return 0;
}

如果我们编译并运行这个程序,我们会得到以下输出:

Figure 2B.3: Vehicle program output

图 2B.3:车辆程序输出

上图截图中车辆::StartEngine 1500 cc后的行都与摩托车有关。但是驱动线仍然显示车辆::驱动而不是预期的摩托车::驱动。这是怎么回事?问题是我们没有告诉编译器Vehicle类中的Drive方法可以被派生类修改(或者覆盖)。我们需要在代码中做一个改变:

virtual void Drive()
{
  std::cout << "Vehicle::Drive\n";
};

通过在成员函数声明前添加virtual关键字,我们告诉编译器,派生类可以(但不必)重写或替换该函数。如果我们进行这种更改,然后编译并运行程序,我们会得到以下输出:

Figure 2B.4: Vehicle program output with virtual methods

图 2B.4:使用虚拟方法的车辆程序输出

现在,我们已经了解了遗传和多态性。我们使用指向车辆类的指针来控制摩托车类。作为最佳实践,应该对代码进行另一次更改。我们还应该将摩托车驱动功能的声明更改如下:

void Drive() override
{
  std::cout << "Motorcycle::Drive\n";
};

C++ 11 引入了override关键字作为对编译器的提示,声明一个特定的方法应该具有与其父树中某处的方法相同的函数原型。如果找不到,编译器将报告错误。这是一个非常有用的特性,可以节省您几个小时的调试时间。如果编译器有办法报告错误,就使用它。越早发现缺陷,越容易修复。最后一个变化是,每当我们向一个类添加一个虚函数时,我们必须声明它的析构函数为虚函数:

class Vehicle
{
public:
  // Constructors - hidden 
  virtual ~Vehicle() = default;  // Virtual Destructor
  // Other methods and data -- hidden
};

在虚拟化之前,我们通过Drive()功能看到了这一点。当通过指向车辆的指针调用析构函数时,它需要知道要调用哪个析构函数。因此,虚拟化可以实现这一点。如果做不到这一点,那么最终可能会出现资源泄漏或拼接对象。

继承和访问说明符

正如我们前面提到的,从超类继承一个子类的一般形式如下:

class DerivedClassName : access_modifier BaseClassName

当我们从车辆类派生摩托车类时,我们使用以下代码:

class Motorcycle : public Vehicle

访问修饰符是可选的,也是我们之前遇到过的修饰符之一:公共受保护私有。在下表中,您可以看到基类成员的可访问性。如果省略了 access_modifier,则编译器会假定指定了 private。

Figure 2B.5: Accessibility of base class members in derived classes

图 2B.5:派生类中基类成员的可访问性

抽象类和接口

到目前为止,我们讨论的所有类都是具体类——它们可以被实例化为一个变量的类型。还有另一种类型的类——一个抽象类,它包含至少一个纯虚拟成员函数。纯虚函数是类中没有定义(或实现)的虚函数。并且因为它没有实现,所以类的格式不正确(或者是抽象的),不能被实例化。如果你试图创建一个抽象类型的变量,那么编译器会产生一个错误。

要声明纯虚拟成员函数,以= 0结束函数原型声明。为了使 Drive()成为 Vehicle 类中的纯虚拟函数,我们将声明如下:

virtual void Drive() = 0;

现在,为了能够使用派生类作为变量类型(例如摩托车类),它必须定义驱动()函数的实现。

但是,您可以将变量声明为抽象类的指针或抽象类的引用。无论是哪种情况,它都必须指向或引用从抽象类派生的某个非抽象类。

在 Java 中,有一个关键字接口,允许你定义一个全是纯虚函数的类。在 C++ 中,通过声明一个只声明公共纯虚函数的类(和一个虚拟析构函数),可以实现同样的效果。这样,我们定义了一个接口。

注意

在解决本章中的任何实际问题之前,下载本书的 GitHub 资源库(https://github.com/TrainingByPackt/Advanced-CPlusPlus)并导入 Eclipse 中 2B 课的文件夹,以便您可以查看每个练习和活动的代码。

练习 1:用多态性实现游戏角色

在本练习中,我们将演示继承、接口和多态性。我们将从角色扮演游戏的特别实现开始,并将其发展成更通用和可扩展的。让我们开始吧:

  1. 打开 Eclipse,使用在第 2B 章示例文件夹中找到的文件创建一个名为第 2B 章的新项目。

  2. 由于这是一个基于 CMake 的项目,将当前的构建器改为 Cmake Build(可移植)

  3. 转到项目 | 构建所有菜单构建所有练习。默认情况下,屏幕底部的控制台会显示 CMake 控制台【第 2B 课】

  4. Configure a New Launch Configuration named L2BExercise1 that runs the Exercise1 binary and click on Run to build and run Exercise 1. You will receive the following output:

    Figure 2B.6: Exercise 1 default output

    图 2B.6:练习 1 默认输出
  5. Open Exercise1.cpp in the editor and examine the existing code. You will notice that the three characters are implemented as separate classes and that they are each instantiated and manipulated separately by calling speak() and act() directly. This is fine for a small program. But as the game grew to tens or hundreds of characters, it would become unmanageable. So, we need to abstract all the characters. Add the following Interface declaration to the top of the file:

    class ICharacter
    {
    public:
        ~ICharacter() {
            std::cout << "Destroying Character\n";
        }
        virtual void speak() = 0;
        virtual void act() = 0;
    };

    通常,析构函数是空的,但是在这里,它有日志来显示行为。

  6. 从该接口类中派生巫师治疗者战士类,并在每个类的speak()act()函数的声明末尾添加override关键字:

    class Wizard : public Icharacter { ...
  7. Click the Run button to rebuild and run the exercise. We will now see that the base class destructor is also called after the destructor of the derived class:

    Figure 2B.7: Output of the modified program

    图 2B.7:修改后程序的输出
  8. 创建角色并在一个容器中管理它们,例如向量。在文件中创建以下两种方法,在主()功能之前:

    void createCharacters(std::vector<ICharacter*>& cast)
    {
        cast.push_back(new Wizard("Gandalf"));
        cast.push_back(new Healer("Glenda"));
        cast.push_back(new Warrior("Ben Grimm"));
    }
    void freeCharacters(std::vector<ICharacter*>& cast)
    {
        for(auto* character : cast)
        {
            delete character;
        }
        cast.clear();
    }
  9. main()的内容替换为如下代码:

    int main(int argc, char**argv)
    {
        std::cout << "\n------ Exercise 1 ------\n";
        std::vector<ICharacter*> cast;
        createCharacters(cast);
        for(auto* character : cast)
        {
            character->speak();
        }
        for(auto* character : cast)
        {
            character->act();
        }
        freeCharacters(cast);
        std::cout << "Complete.\n";
        return 0;
    }
  10. Click the Run button to rebuild and run the exercise. Here is the output that is generated:

![Figure 2B.8: Output of the polymorphic version ](img/C14583_02B_08.jpg)

###### 图 2B.8:多态版本的输出

从前面的截图可以看到,**摧毁精灵**等的日志已经消失。问题是容器保存指向基类的指针,并且它不知道如何在每种情况下调用完整的析构函数。
  1. 要解决这个问题,只需将的析构函数声明为虚拟的:
```cpp
virtual ~ICharacter() {
```
  1. 点击运行按钮重建并运行练习。输出内容如下:

Figure 2B.9: Output from the full polymorphic version

图 2B.9:完整多态版本的输出

我们现在已经实现了一个到我们的ICharacter字符的接口,并通过存储在容器中的基类指针简单地调用speak()act()方法来多形态地使用它们。

重新审视类、结构和联合

之前,我们讨论过类和结构之间的区别是默认的访问修饰符——类是私有的,结构是公共的。这种差异更进一步——如果基类没有指定任何内容,它也适用于基类:

class DerivedC : Base  // inherits as if "class DerivedC : private Base" was used
{
};
struct DerivedS : Base // inherits as if "struct DerivedS : public Base" was used
{
};

应该注意的是,联合既不能是基类,也不能从基类派生。如果结构和类本质上没有区别,那么我们应该使用哪种类型呢?本质上,这是一个惯例。一个结构用来捆绑几个相关的元素,而一个可以做事,有责任。结构的示例如下:

struct Point     // A point in 3D space
{
  double m_x;
  double m_y;
  double m_z;
};

在前面的代码中,我们可以看到它将三个坐标组合在一起,这样我们就可以对三维空间中的一个点进行推理。这个结构可以作为一个连贯的数据集传递给需要点的方法,而不是每个点三个单独的参数。另一方面,类对可以执行动作的对象进行建模。看看下面的例子:

class Matrix
{
public:
  Matrix& operator*(const Matrix& rhs)
  {
     // nitty gritty of the multiplication
  }
private:
  // Declaration of the 2D array to store matrix.
};

经验法则是,如果至少有一个私有成员,就使用一个类,因为这意味着实现的细节将在公共成员函数的后面。

可见性、寿命和访问

我们已经讨论了创建自己的类型和声明变量和函数,同时主要关注简单函数和单个文件。我们现在将看看当有多个包含类和函数定义的源文件(翻译单元)时会发生什么。此外,我们将检查哪些变量和函数可以从源文件的其他部分看到,这些变量存在多长时间,并查看内部和外部链接之间的区别。在第 1 章剖析可移植 C++ 软件中,我们看到了工具链是如何编译源文件并生成目标文件的,以及链接器是如何将它们组合在一起形成可执行程序的。

当编译器处理源文件时,它会生成一个目标文件,该文件包含已翻译的 C++ 代码和足够的信息,以便链接器解析从已编译的源文件到另一个源文件的任何引用。在第 1 章解析可移植 C++ 软件CxxTemplate.cpp 中称为sum(),在 SumFunc.cpp 文件中定义。当编译器构造一个目标文件时,它会创建以下段:

  • 代码段(也称文本):这是将 C++ 函数翻译成目标机器指令。

  • 数据段:包含程序中声明的所有变量和数据结构,不是本地的,也不是从堆或栈中分配的,并且是初始化的。

  • BSS 段:包含程序中声明的所有变量和数据结构,不是本地的,也不是从堆或栈中分配的,并且没有初始化(但是将被初始化为零)。

  • 导出符号数据库:该对象文件中变量和函数的列表及其位置。

  • Database of referenced symbols: A list of variables and functions this object file needs from outside itself and where they are used.

    注意

    BSS 用于命名未初始化的数据段,其名称历史上来源于以符号开始的块。

然后,链接器将所有代码段、数据段和 BSS 段收集在一起,形成程序。它使用两个数据库(DB)中的信息将所有引用的符号解析到导出的符号列表中,并用该信息修补代码段,以便它们可以正确运行。从图形上看,这描述如下:

Figure 2B.10: Parts of the object files and the executable file

图 2B.10:部分目标文件和可执行文件

出于以下讨论的目的,基站和数据段将简称为数据段(唯一的区别是基站未初始化)。当一个程序被执行时,它被加载到内存中,并且它的内存看起来有点像可执行文件的布局——它包含文本段、数据段、BSS 段和主机系统分配的空闲内存,后者包含所谓的。堆栈通常从内存顶部开始向下增长,而堆从 BSS 结束的地方开始向上增长,朝向堆栈:

Figure 2B.11: CxxTemplate runtime memory map

图 2B.11: CxxTemplate 运行时内存映射

程序中可访问变量或标识符的部分称为范围。有两大类范围:

  • 本地范围(也称为块范围):这适用于用花括号({})括起来的块内声明的任何内容。变量可以在大括号内访问。就像块可以嵌套一样,变量的范围也可以嵌套。这通常包括局部变量和函数参数,它们通常存储在堆栈中。
  • 全局/文件范围:这适用于在正常函数或类之外声明的变量,也适用于正常函数。如果链接正确,变量可以在文件的任何地方访问,也可以从其他文件(全局)访问。这些变量由数据段中的链接器分配内存。标识符被放入全局命名空间,这是默认命名空间。

命名空间

我们可以把命名空间看作是变量、函数和用户定义类型的字典。对于小程序,使用全局命名空间是可以的,因为创建多个同名变量并产生名称冲突的可能性很小。随着程序变得越来越大,包括了更多的第三方库,名字冲突的机会增加了。因此,库作者将把他们的代码放入一个命名空间(希望是唯一的)。这允许程序员控制对命名空间中标识符的访问。通过使用标准库,我们已经使用了 std 命名空间。命名空间是这样声明的:

namespace name_of_namespace {  // put declarations in here }

命名空间的名称通常很短,命名空间可以嵌套。

注意

在这里的 boost 库中可以看到名称空间的良好使用:https://www.boost.org/

变量还有另一个属性,即寿命。有三种基本寿命;两个由编译器管理,一个由程序员选择:

  • 自动生存期:局部变量在声明时创建,并在退出其所在的范围时销毁。这些由堆栈管理。
  • 永久寿命:全局变量和静态局部变量。编译器在程序开始时(进入 main()函数之前)创建全局变量,并在首次访问静态局部变量时创建静态局部变量。在这两种情况下,当程序退出时,变量都会被销毁。这些变量由链接器放在数据段中。
  • 动态寿命:变量是根据程序员的请求创建和销毁的(通过使用新增删除)。这些变量从堆中分配内存。

我们将考虑的变量的最后一个属性是联动。链接表示如果编译器和链接器遇到具有相同名称(或标识符)的变量和函数,它们会做什么。对于一个函数来说,它实际上就是所谓的变形名——编译器使用函数的名称、返回类型和参数类型来产生一个变形名。有三种类型的链接:

  • 无链接:这意味着标识符只引用自身,适用于局部变量和局部定义的用户类型(即块内部)。
  • 内部链接:这意味着标识符可以在声明它的文件中的任何地方被访问。这适用于静态全局变量、常量全局变量、静态函数以及文件中匿名命名空间中声明的任何变量或函数。匿名命名空间是没有指定名称的命名空间。
  • 外部链接:这意味着通过右向声明,可以从所有文件内部访问。这包括普通函数、非静态全局变量、外部常量全局变量和用户定义的类型。

虽然这些被称为连接,只有最后一个实际上涉及连接。另外两个是通过编译器从导出标识符的数据库中排除信息来实现的。

模板–通用编程

作为一名计算机科学家,或者作为一名编程爱好者,在某个时间点,你可能不得不编写一个(或多个)排序算法。在讨论算法时,您并不特别关心正在排序的数据类型,只是该类型的两个对象可以进行比较,并且该域是一个完全有序的集合(也就是说,如果一个对象与任何其他对象进行比较,您可以确定哪个先出现)。不同的编程语言对此问题提供了不同的解决方案:

  • Python :内置函数排序、列表上成员函数的动态语言。作为一种动态语言,如果能够调用比较运算符和交换函数,就不需要关心类型。

  • C: This has a function in its standard library called qsort that has the following signature:

    void qsort (void* base, size_t num, size_t size,                           int (*compare)(const void*,const void*));

    这处理不同的类型,因为基础是一个空指针size_t size 定义每个对象的大小,而compare()函数定义如何比较两个对象。

  • C++: std::sort() is a function provided in its standard library, where one of its signatures is as follows:

    template< class RandomIt > void sort( RandomIt first, RandomIt last );

    在这种情况下,类型的细节在称为随机化的迭代器类型中捕获,并在编译时传递给方法。

在下一节中,我们将简要定义泛型编程,展示 C++ 如何通过模板实现它们,突出显示该语言已经提供了什么,并讨论编译器如何推导类型,以便它们可以用于模板。

什么是泛型编程?

当您开发排序算法时,您可能最初只关注对普通数字进行排序。但是一旦建立了这种关系,您就可以将它抽象为任何类型,只要该类型表现出某些属性,例如总有序集(也就是说,比较运算符

泛型编程是一种类型不可知的通用算法的开发。通过将类型作为参数传递,可以重用该算法。这样,算法被抽象,并允许编译器基于类型进行优化。

换句话说,泛型编程是一种编程方法,在这种方法中,算法是用类型定义的,而类型是在算法实例化时指定的参数。许多语言支持不同名称的泛型编程。在 C++ 中,泛型编程通过称为模板的语言特性得到支持。

介绍 C++ 模板

模板是 C++ 对泛型编程的支持。把一个模板想象成一个饼干切割器,我们给它的类型作为一个参数,比如饼干面团(可以是巧克力布朗尼,姜片,或者其他美味的味道)。当我们应用 cookie cutter 时,我们最终会得到形式相同但口味不同的 cookie 实例。因此,模板捕获泛型函数或类的定义,当用类型作为参数指定时,编译器开始为我们编写类或函数,就好像类型是由我们手工编码的一样。它有几个优点,例如:

  • 您只需要开发一次类或算法并对其进行进化。
  • 您可以将其应用于许多类型。
  • 您可以将复杂的细节隐藏在简单的接口后面,编译器可以根据类型对生成的代码进行优化。

那么,我们如何编写模板呢?让我们从一个模板开始,该模板允许我们在从lohi的范围内夹紧一个值,并且能够在intfloatdouble或任何其他内置类型上使用该值:

template <class T>
T clamp(T val, T lo, T hi)
{
  return (val < lo) ? lo : (hi < val) ? hi : val;
}

让我们把它分解一下:

  • 第 1 行 : 模板<类 T >声明后面的内容为模板,使用一种类型,模板中有一个T的占位符。
  • 第 2 行:当T被替换时,声明该功能的原型。它声明函数 clamp 接受三个类型为T的参数,并返回一个类型为T的值。
  • 第 4 行:这就是模板的妙处——假设传入的类型有一个<操作符,那么我们就可以对这三个值进行钳制,这样lo < = val < = hi。该算法对所有可排序的类型都有效。

假设我们在下面的程序中使用它:

#include <iostream>
int main()
{
    std::cout << clamp(5, 3, 10) << "\n";
    std::cout << clamp(3, 5, 10) << "\n";
    std::cout << clamp(13, 3, 10) << "\n";
    std::cout << clamp(13.0, 3.0, 10.1) << "\n";
    std::cout << clamp<double>(13.0, 3, 10.2) << "\n";
    return 0;
}

我们将获得以下预期输出:

Figure 2B.12: Clamp program output

图 2B.12:箝位程序输出

在最后一次调用夹钳时,我们已经在<>之间传递了模板的双重类型。但是其他四个电话我们没有遵循同样的规则。为什么呢?事实证明,随着年龄的增长,编译器变得越来越聪明。随着标准的每一次发布,他们改进了所谓的式演绎。因为编译器能够推导出类型,所以我们不需要告诉它使用什么类型。这样做的原因是,没有模板参数的类的三个参数具有相同的类型——前三个都是 int,而第四个是 double。但是我们必须告诉编译器最后一个使用哪种类型,因为它有两个 doubles 和一个 int 作为参数,这导致了一个编译错误,说没有找到函数。但是后来,它给了我们为什么模板不能被使用的信息。这种强制类型的形式被称为显式模板参数规范

C++ 预打包模板

C++ 标准由两个主要部分组成:

  • 语言定义,即关键词、句法、词汇定义、结构等。
  • 标准库,即编译器供应商提供的所有预写的通用函数和类。这个库的一个子集是使用模板实现的,被称为标准模板库 ( STL )。

STL 起源于大卫·穆塞和亚历山大·斯捷潘诺夫开发的 Ada 语言中提供的泛型。斯捷潘诺夫大力提倡使用通用编程作为软件开发的基础。在 90 年代,他看到了用新语言 C++ 来影响主流开发的机会,并向 ISO C++ 委员会提议将 STL 作为语言的一部分。剩下的就是历史。

STL 由四类预定义的通用算法和类组成:

  • 容器:一般序列(向量、列表、德格)和关联容器(集合、多集合、映射)
  • 迭代器:一组遍历容器并定义容器范围的类(范围表示为begin()end())。请注意,STL 中的一个基本设计选择是end()指向最后一项之后的一个位置–数学上,即[ begin()end())。
  • 算法:超过 100 种不同的算法,涵盖排序、搜索、集合运算等。
  • 函数:支持函子(可以像函数一样调用对象的函数对象)。一个用途是模板算法中的谓词,如find_if()

我们之前实现的箝位函数模板过于简单,虽然它适用于任何支持小于运算符的类型,但效率不是很高——如果类型很大,可能会产生非常大的副本。从 C++ 17 开始,STL 包含了一个std::clamp()函数,声明如下:

#include <cassert>
template<class T, class Compare>
const T& clamp( const T& v, const T& lo, const T& hi, Compare comp )
{
    return assert( !comp(hi, lo) ),
        comp(v, lo) ? lo : comp(hi, v) ? hi : v;
}
template<class T>
const T& clamp( const T& v, const T& lo, const T& hi )
{
    return clamp( v, lo, hi, std::less<>() );
}

正如我们所看到的,它使用参数和返回值的引用。将参数更改为使用引用减少了堆栈上必须传递和返回的内容。此外,请注意,设计人员已经制作了一个更通用的模板版本,这样我们就不会依赖于该类型存在的

从前面的例子中,我们已经看到,像函数一样,模板可以采用多个逗号分隔的参数。

类型别名–类型定义和使用

如果你使用了std::string类,那么你就使用了一个别名。有几个与字符串相关的模板类需要实现相同的功能。但是代表一个角色的类型是不同的。例如对于std::string,表示为char,而std::wstring则使用wchar_tchar16_tchar32_t还有其他几个。功能的任何变化都将通过特性或模板专门化来管理。

在 C++ 11 之前,这可能是从std::basic_string基类别名而来的,如下所示:

namespace std {
  typedef basic_string<char> string;
}

这有两个主要作用:

  • 减少声明变量所需的键入量。这是一个简单的情况,但是当您声明一个指向字符串到对象的映射的唯一指针时,它会变得很长,并且您会出错:

    typedef std::unique_ptr<std::map<std::string,myClass>> UptrMapStrToClass;
  • 提高可读性,因为您现在在概念上将它视为一个字符串,不需要担心细节。

但是 C++ 11 引入了一个更好的方法——别名声明,它使用关键字来使用**。前面的代码可以这样实现:**

namespace std {
  using string = basic_string<char>;
}

前面的例子很简单,别名,无论是 typedef 还是 using,都不难理解。但是当别名涉及更复杂的表达式时,它们也可能有点不可读——尤其是函数指针。考虑以下代码:

typedef int (*FunctionPointer)(const std::string&, const Point&); 

现在,考虑以下代码:

using FunctionPointer = int (*)(const std::string&, const Point&);

C++ 11 中的新特性是有原因的,其中别名声明可以很容易地合并到模板中——它们可以被模板化。一个typedef不能被模板化,虽然可以用typedef获得相同的结果,但是别名声明(使用)是首选方法,因为它导致模板代码更简单和更容易理解。

练习 2:实现别名

在本练习中,我们将使用 typedef 实现别名,并了解代码如何通过使用引用变得更容易阅读和更高效。按照以下步骤实施本练习:

  1. 在 Eclipse 中打开第 2B 课项目,然后在项目浏览器中展开第 2B 课,然后展开练习 02 ,双击练习 2.cpp 在编辑器中打开本练习的文件。

  2. 点击启动配置下拉菜单,选择新启动配置……。将 L2BExercise2 配置为以练习 2 的名称运行。完成后,它将是当前选定的启动配置。

  3. Click on the Run button. Exercise 2 will run and produce something similar to the following output:

    Figure 2B.13: Exercise 2 output

    图 2B.13:练习 2 输出
  4. 在编辑器中,在声明打印矢量()函数之前,添加以下行:

    typedef std::vector<int> IntVector;
  5. 现在,用IntVector更改文件中所有出现的std::vector < int >

  6. 点击运行按钮。输出应该和以前一样。

  7. 在编辑器中,将之前添加的行更改为:

    using IntVector = std::vector<int>;
  8. 点击运行按钮。输出应该和以前一样。

  9. 在编辑器中,添加以下行:

    using IntVectorIter = std::vector<int>::iterator;
  10. 现在,将IntVector::iterator的一次出现更改为int vector。

  11. 点击运行按钮。输出应该和以前一样。

在本练习中,typedef 和使用 alias 之间似乎没有什么区别。在这两种情况下,使用一个好名字的别名使代码更容易阅读和理解。当涉及到更复杂的别名时,使用会产生一种更简单的别名书写方式。在 C++ 11 中引入,使用现在是定义别名的首选方法。它比typedef还有其他优势,比如可以在模板里面使用。

模板–不仅仅是通用编程

模板也可以提供比一般编程更多的东西。在泛型编程的情况下,模板作为不能更改的蓝图运行,并为指定的一个或多个类型提供模板的编译版本。

可以根据所涉及的类型编写模板来提供函数或算法的专门化。这被称为模板专门化,从我们之前使用的意义上来说,它不是泛型编程。只有当它使某些类型在给定的上下文中按照我们期望的那样运行时,它才能被称为泛型编程。当用于所有类型的算法被修改时,它不能被称为泛型编程。

检查以下专门化代码示例:

#include <iostream>
#include <type_traits>
template <typename T, std::enable_if_t<sizeof(T) == 1, int> = 0>
void print(T val)
{
    printf("%c\n", val);
}
template <typename T, std::enable_if_t<sizeof(T) == sizeof(int), int> = 0>
void print(T val)
{
    printf("%d\n", val);
}
template <typename T, std::enable_if_t<sizeof(T) == sizeof(double), int> = 0>
void print(T val)
{
    printf("%f\n", val);
}
int main(int argc, char** argv)
{
    print('c');
    print(55);
    print(32.1F);
    print(77.3);
}

它定义了一个使用不同格式字符串调用printf()的模板,基于使用std::enable_if_t < >sizeof()的模板的专门化。当我们运行它时,会生成以下输出:

Figure 2B.14: Erroneous print template program output

图 2B.14:错误的打印模板程序输出

替代失败不是错误–SFINAE

32.1F(-1073741824)打印的数值与该数字没有任何相似之处。如果我们检查编译器为以下程序生成的代码,我们会发现它已经生成了代码,就像我们编写了以下内容(以及更多内容)一样:

template<typename int, int=0>
void print<int,0>(int val)
{
    printf("%d\n",val);
}
template<typename float, int=0>
void print<float,0>(float val)
{
    printf("%d\n", val);
}

它为什么会生成这个代码?前面的模板使用了 C++ 编译器的一个名为 S 的特性来代替 F 故障 I s N ot A n 错误,或 SFINAE 。基本上,在模板的替换阶段,基于类型,如果编译器不能形成有效的代码,那么它只是丢弃定义并继续,而不是产生错误。让我们尝试修复前面的代码,并获得正确的打印结果。为此,我们将介绍STD::enable _ if _ t<>的用法,并访问所谓的类型特征来帮助我们。首先,我们将使用以下代码替换最后一个模板:

#include <type_traits>
template <typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
void print(T val)
{
    printf("%f\n", val);
}

这需要一些解释。首先我们考虑std::enable_if_t的定义,其实是一个类型别名:

template<bool B, class T = void>
struct enable_if {};
template<class T>
struct enable_if<true, T> { typedef T type; };
template< bool B, class T = void >
using enable_if_t = typename enable_if<B,T>::type;

enable_if的第一个模板将导致空结构(或类)的定义。enable_if的第二个模板是 true 的特化,作为第一个模板参数,它将产生一个具有 typedef 定义的类。enable_if_t的定义是一个助手模板,它免去了我们在使用时输入:在模板末尾键入的需要。那么,这是如何工作的呢?考虑以下代码:

template <typename T, std::enable_if_t<condition, int> = 0>
void print(T val) { … }

如果在编译时评估的条件导致为真,则enable_if_t模板将导致如下模板:

template <typename T, int = 0>
void print(T val) { … }

这是有效的语法,该函数作为候选函数添加到符号表中。如果在编译时计算的条件导致为假,那么enable_if_t模板将生成如下所示的模板:

template <typename T, = 0>
void print(T val) { … }

这是格式错误的代码,现在被丢弃了——SFINAE 在工作。

STD::is _ floating _ point _ v<T>是另一个访问STD::is _ floating _ point<T>模板的:值成员的辅助类。它的名字说明了一切——如果 T 是浮点类型(float、double、long double),那就是真的;否则,它将是假的。如果我们进行此更改,编译器(GCC)将生成以下错误:

Figure 2B.15: Compiler error for the modified print template program

图 2B.15:修改后的打印模板程序的编译器错误

现在的问题是,当类型是 float 时,我们有两个模板可以满足:

template <typename T, std::enable_if_t<sizeof(T) == sizeof(int), int> = 0>
void print(T val)
{
    printf("%d\n", val);
}
template <typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
void print(T val)
{
    printf("%f\n", val);
}

原来(通常)sizeof(float)= = sizeof(int),所以我们需要再做一个改动。我们将第一个条件替换为另一个类型特征–STD::is _ integral _ v<>:

template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
void print(T val)
{
    printf("%d\n", val);
}

如果我们进行此更改,编译器(GCC)将生成以下错误:

Figure 2B.16: Second compiler error for the modified print template program

图 2B.16:修改后的打印模板程序的第二个编译器错误

我们修复了浮点模糊性,但是这里的问题是 std::is_integral_v(char) 返回 true,同样有两个函数是由具有相同原型的 char 类型的模板生成的。事实证明,传递给的条件遵循标准的 C++ 逻辑表达式。因此,为了解决这个问题,我们将添加一个排除字符的额外条件:

template <typename T, std::enable_if_t<std::is_integral_v<T> && sizeof(T) != 1, int> = 0>
void print(T val)
{
    printf("%d\n", val);
}

如果我们现在编译程序,它会完成编译并链接程序。如果我们运行它,它现在会产生以下(预期的)输出:

Figure 2B.17: Corrected print template program output

图 2B.17:修正的打印模板程序输出

浮点表示

32.099998不应该是32.1吗?这就是传递给函数的内容。在计算机上执行浮点运算的问题是表示会自动引入错误。实数形成一个连续(无限)的域。如果你考虑实数域中的数字 1 和 2,那么它们之间有无限多的实数。不幸的是,计算机对浮点数的表示量化了这些值,并且不能表示所有的无限数量的数字。用于存储数字的位数越大,该值在实数域上的表示就越好。所以,长双优于双优于浮。关于什么适合存储数据,这实际上取决于您的问题领域。回到32.099998。计算机将单精度数字存储为 2 的幂之和,然后将它们移动一个幂因子。整数通常很容易,因为它们很容易用2^n次幂之和(n > =0)来表示。分数部分,在这种情况下是 0.1,必须表示为2^(-n(n>0)的和。我们增加更多的 2 次方分数,试图使数字更接近目标值,直到我们用完单个精确浮点数的 24 位精度。

注意

如果你想知道更多关于计算机如何存储浮点数的知识,研究一下定义浮点数的 IEEE 754 标准。

常量表达式 if 表达式

C++ 17 在语言中引入了constexpr if表达式,大大简化了模板编写。我们可以重写前面三个使用 SFINAE 作为一个更简单模板的模板:

#include <iostream>
#include <type_traits>
template <typename T>
void print(T val)
{
   if constexpr(sizeof(T)==1) {
      printf("%c",val);
   }
   else if constexpr(std::is_integral_v<T>) {
      printf("%d",val);
   }
   else if constexpr(std::is_floating_point_v<T>) {
      printf("%f",val);
   }
   printf("\n");
}
int main(int argc, char** argv)
{
    print('c');
    print(55);
    print(32.1F);
    print(77.3);
}

对于打印(55)的调用,编译器生成如下函数进行调用:

template<>
void print<int>(int val)
{
    printf("%d",val);
    printf("\n");
}

if/else if 语句怎么了?如果表达式为常量表达式,编译器会根据上下文确定条件的值,并将其转换为布尔值(真/假)。如果计算值为真,则 If 条件和 else 子句被丢弃,只留下 true 子句来生成代码。同样,如果它是 false,那么 false 子句将被留下来生成代码。换句话说,只有计算结果为 true 的第一个 constexpr if 条件将生成其子句的代码,而其余的将被丢弃。

非类型模板参数

到目前为止,我们只看到了属于类型的模板参数。也可以传递整数值作为模板参数。这允许我们防止函数的数组衰减。例如,考虑一个计算的模板函数:

template <class T>
T sum(T data[], int number)
{
  T total = 0;
  for(auto i=0U ; i<number ; i++)
  {
    total += data[i];
  }
  return total;
}

在这种情况下,我们需要在调用中传递数组的长度:

float data[5] = {1.1, 2.2, 3.3, 4.4, 5.5};
auto total = sum(data, 5);

但是如果我们能叫下面这个不是更好吗?

auto total = sum(data);

我们可以通过更改模板来实现,如下面的代码所示:

template <class T, std::size_t size>
T sum(T (&data)[size])
{
  T total = 0;
  for(auto i=0U ; i< size; i++)
  {
    total += data[i];
  }
  return total;
}

在这里,我们将数据更改为对某个特定大小的数组的引用,该大小被传递给模板,因此编译器会计算出来。我们不再需要函数调用的第二个参数。这个简单的例子展示了如何直接传递和使用非类型参数。我们将在模板类型演绎部分对此进行更多探讨。

练习 3:实现 Stringify–专门化与常量表达式

在本练习中,我们将通过使用 constexpr 来实现一个字符串模板,以生成一个更容易阅读和更简单的代码版本。按照以下步骤实施本练习:

注意

字符串化的专门化模板可以在https://isocpp . org/wiki/FAQ/templates # templates-专门化-示例中找到。

  1. 在 Eclipse 中打开第 2B 课项目,然后在项目浏览器中,展开第 2B 课,然后展开练习 03 ,双击练习 3.cpp 在编辑器中打开本练习的文件。

  2. 点击启动配置下拉菜单,选择新启动配置……。将2 练习 3 配置为使用名称练习 3 运行。

  3. Click on the Run button. Exercise 3 will run and produce the following output:

    Figure 2B.18: Exercise 3 specialized template output

    图 2B.18:练习 3 专用模板输出
  4. 练习 3.cpp 中,注释掉字符串模板的所有模板专门化,同时保留原始的通用模板。

  5. Click on the Run button. The output will change to have the boolean printed as a number and the double printed to only two decimal places:

    Figure 2B.19: Exercise 3 general template only output

    图 2B.19:练习 3 仅输出通用模板
  6. 我们现在将再次“专门化”布尔类型的模板。将#包括<类型 _ 特征>指令与其他#包括指令相加,并修改模板,使其内容如下:

    template<typename T> std::string stringify(const T& x)
    {
      std::ostringstream out;
      if constexpr (std::is_same_v<T, bool>)
      {
          out << std::boolalpha;
      }
      out << x;
      return out.str();
    }
  7. Click on the Run button. The output boolean stringify works as before:

    Figure 2B.20: stringify tailored for boolean

    图 2B.20:为布尔函数定制的字符串
  8. 我们现在将再次“专门化”浮点类型的模板(floatdoublelong double)。修改模板,使其如下所示:

    template<typename T> std::string stringify(const T& x)
    {
      std::ostringstream out;
      if constexpr (std::is_same_v<T, bool>)
      {
          out << std::boolalpha;
      }
      else if constexpr (std::is_floating_point_v<T>)
      {
          const int sigdigits = std::numeric_limits<T>::digits10;
          out << std::setprecision(sigdigits);
      }
      out << x;
      return out.str();
    }
  9. Click on the Run button. The output is restored to the original:

    Figure 2B.21: constexpr if version template output

    图 2B.21: constexpr if 版本模板输出
  10. 如果将有多个模板的原始版本与最终版本进行比较,你会发现最终版本更像是一个正常的功能,更容易阅读和维护。

在练习中,我们了解了在 C++ 17 中使用新的 constexpr if 构造时,我们的模板可以变得多么简单和紧凑。

函数重载再探

当我们第一次讨论函数重载时,我们只考虑了函数名来自手工编写的函数列表的场景。现在,我们需要更新这个。我们还可以编写具有相同名称的模板化函数。就像我们之前做的那样,当编译器遇到行print(55)时,它需要计算出要调用哪个先前定义的函数。因此,它执行以下过程(非常简单):

Figure 2B.22: Function overload resolution with templates (simplified)

图 2B.22:使用模板的函数重载解析(简化)

模板类型演绎

当我们第一次引入模板时,我们触及了模板类型演绎。现在,我们将进一步探讨这个问题。我们将首先考虑函数模板的一般声明:

template<typename T>
void function(ParamType parameter);

对此的呼吁可能是这样的:

function(expression);              // deduce T and ParamType from expression

当编译器到达这一行时,它现在必须推导出与模板相关的两个类型–TParamType。由于参数类型中附加到 T 的限定符和其他属性(例如指针、引用、常量等),它们通常是不同的。类型是相关的,但是演绎的进程是不同的,这取决于所使用的表达的形式

显示推导出的类型

在我们研究不同的形式之前,如果我们能让编译器告诉我们它已经推导出的类型,这可能是有用的。这里我们有几个选项,包括显示类型的 IDE 编辑器、生成错误的编译器和运行时支持(由于 C++ 标准,这不一定有效)。我们将使用编译器错误来帮助我们探索一些类型推断。

我们可以通过声明一个没有定义的模板来实现一个类型显示器。任何实例化模板的尝试都会导致编译器生成一条错误消息,因为没有定义以及它试图实例化的类型信息:

template<typename T>
struct TypeDisplay;

让我们尝试编译以下程序:

template<typename T>
class TypeDisplay;
int main()
{
    signed int x = 1;
    unsigned int y = 2;
    TypeDisplay<decltype(x)> x_type;
    TypeDisplay<decltype(y)> y_type;
    TypeDisplay<decltype(x+y)> x_y_type;
    return 0;
}

编译器会抛出以下错误:

Figure 2B.23: Compiler errors showing deduced types

图 2B.23:显示推断类型的编译器错误

请注意,在每种情况下,被命名的聚合都包括被推导的类型——对于 x,它是一个 int,对于 y,它是一个无符号 int,对于 x+y,它是一个无符号 int。另外,请注意,TypeDisplay 模板需要一个类型作为其参数,因此使用decltype()函数让编译器为括号中的表达式提供类型。

也可以使用内置的类型标识(T)在运行时显示推导出的类型。name()运算符,该运算符返回一个 std::string,或者使用名为 type_index 的 boost 库。

注意

更多信息,请访问以下链接:https://www . boost . org/doc/libs/1 _ 70 _ 0/doc/html/boost _ type index . html

因为类型推演规则,内置运算符会给你一个类型的指示,但是会丢失引用(&& &)和任何常量信息(常量或挥发)。如果在运行时需要,那么考虑boost::type_index,这将为所有编译器产生相同的输出。

模板类型演绎-细节

让我们回到通用模板:

template<typename T>
void function(ParamType parameter);

假设电话是这样的:

function(expression);             // deduce T and ParamType from expression

根据所用参数类型的形式,类型推导的进行方式不同:

  • ParamType 是一个值(T) :按值传递函数调用
  • ParamType 是引用或指针(T &或 T)* :通过引用传递函数调用
  • ParamType 是一个右值引用(T & & ) :通过引用传递函数调用或者别的什么

情况 1: ParamType 是传递值(T)

template<typename T>
void function(T parameter);

作为一个按值传递的调用,这意味着参数将是传入的任何内容的副本。因为这是对象的新实例,所以以下规则应用于表达式:

  • 如果表达式的类型是引用,则忽略引用部分。
  • 如果在步骤 1 之后,剩余的类型是常量和/或易失性的,那么也忽略它们。

剩下的就是 t .让我们尝试编译以下文件代码:

template<typename T>
class TypeDisplay;
template<typename T>
void function(T parameter)
{
    TypeDisplay<T> type;
}
void types()
{
    int x = 42;
    function(x);
}

编译器会产生以下错误:

Figure 2B.24: Compiler error showing a deduced type for the pass by type

图 2B.24:编译器错误显示了按类型传递的推断类型

所以,类型推导为int。同样,如果我们声明以下内容,我们会得到完全相同的错误:

const int x = 42;
function(x);

如果我们声明这个版本,也会发生同样的情况:

int x = 42;
const int& rx = x;
function(rx);

根据前面所述的规则,在所有三种情况下,推导出的类型都是int

情况 2: ParamType 是通过引用传递的(T & )

作为一个按引用传递的调用,这意味着参数将能够访问对象的原始存储位置。正因为如此,生成的函数必须尊重我们之前忽略的常量和可变性。以下规则适用于类型扣减:

  • 如果表达式的类型是引用,则忽略引用部分。
  • 模式将表达式类型的剩余部分与参数类型进行匹配,以确定 t

让我们尝试编译以下文件:

template<typename T>
class TypeDisplay;
template<typename T>
void function(T& parameter)
{
    TypeDisplay<T> type;
}
void types()
{
    int x = 42;
    function(x);
}

编译器将生成以下错误:

Figure 2B.25: Compiler error showing the deduced type for pass by reference

图 2B.25:显示通过引用传递的推导类型的编译器错误

由此我们可以看出,编译器把 T 作为 int 从 ParamType 作为int&T3。将 x 更改为常量 int 并不意外,因为从 ParamType 推导出 T 是常量 int** 为常量 int & :**

Figure 2B.26:  Compiler error showing the deduced type for pass by const reference

图 2B.26:编译器错误显示了常量引用传递的推导类型

同样,像以前一样,引入 rx 作为常量 int 的引用,也不会让人感到意外,因为从 ParamType 推导出 T 是常量 int作为常量 int &:

void types()
{
    const int x = 42;
    const int& rx = x;
    function(rx);
}

Figure 2B.27: Compiler error showing the deduced type when passing a const reference

图 2B.27:在传递常量引用时显示推导类型的编译器错误

如果我们将声明更改为包含一个常量,那么编译器将在从模板生成函数时遵守该常量:

template<typename T>
void function(const T& parameter)
{
    TypeDisplay<T> type;
}

这一次,编译器会报告以下内容

  • int x : T 是 int(因为常量会被尊重),而参数的类型是const int &
  • const int x : T 是 int (const 在模式中,保留 int),而参数的类型是const int &
  • const int & rx : T 是 int(引用被忽略,const 在模式中,留下 int),而参数的类型是const int &

如果我们试图编译以下内容,我们期望什么?通常,数组衰减为指针:

int ary[15];
function(ary);

编译器错误如下:

Figure 2B.28:  Compiler error showing the deduced type for the array argument  when passed by reference

图 2B.28:编译器错误,显示了通过引用传递数组参数时推导出的类型

这一次,数组被捕获作为参考,大小也被包括在内。所以,如果 ary 被声明为ary【10】,那么将会产生一个完全不同的函数。让我们将模板还原为以下内容:

template<typename T>
void function(T parameter)
{
    TypeDisplay<T> type;
}

如果我们试图编译数组调用,那么错误报告如下:

Figure 2B.29: Compiler error showing the deduced type for the array argument  when passed by value

图 2B.29:通过值传递数组参数时显示推导出的类型的编译器错误

我们可以看到,在这种情况下,当将数组传递给函数时,数组已经像通常的行为一样衰减了。我们在谈论非类型模板参数时看到了这种行为。

情况 3: ParamType 是右值引用(T & & )

& T 被称为右值引用,而& T 被称为左值引用。C++ 不仅通过类型来表征表达式,还通过名为值类别的属性来表征表达式。这些类别控制编译器中的表达式计算,包括创建、复制和移动临时对象的规则。C++ 17 标准中定义了五个表达式值类别,它们具有以下关系:

Figure 2B.30: C++ value categories

图 2B.30: C++ 值类别

每个的定义如下:

  • 确定对象身份的表达式是glvalue
  • 一个表达式,其求值初始化一个对象或一个运算符的操作数是一个prvalue。示例包括文字(字符串文字除外),如 3.1415、true 或 nullptr、this 指针、后置递增和后置递减表达式。
  • 有资源并且可以重用(因为它的生命即将结束)的 glvalue 对象是xvalue。例如,函数调用的返回类型是对对象的右值引用,如标准::移动()
  • 不是 x 值的 GL 值是左值。示例包括变量名、函数名、数据成员名或字符串。
  • prvalue 或 xvalue 是一个

如果您对下面的解释不完全理解,也没关系——只要知道被认为是左值的表达式可以使用它的地址(使用运算符的地址,即“&”)。以下内容的类型推导规则要求您知道左值是什么,以及它不是什么:

template<typename T>
void function(T&& parameter)
{
    TypeDisplay<T> type;
}

此参数类型表单的类型推导规则如下:

  • 如果表达式是左值引用,那么 T 和 ParamType 都被推导为左值引用。这是类型被推断为引用的唯一场景。
  • 如果表达式是右值引用,则情况 2 的规则适用。

SFINAE 表达式和尾随返回类型

C++ 11 引入了一个名为尾随返回类型的特性,为模板提供了一种机制,这样它们就可以概括返回类型。一个简单的例子如下:

template<class T>
auto mul(T a, T b) -> decltype(a * b) 
{
    return a * b;
}

这里,auto用来表示定义了一个尾随返回类型。尾部返回类型以- >指针开始,在这种情况下,返回类型是通过将ab相乘而返回的类型。编译器将处理 decltype 的内容,如果它的格式不正确,它将像往常一样从函数名的查找中删除定义。该功能提供了许多可能性,因为逗号运算符“”可以在decltype中使用,以检查某些属性。

如果我们想测试一个类实现了一个方法或者包含了一个类型,那么我们可以把它放在 decltype 里面,方法是把它转换成一个 void(以防逗号操作符被重载),然后在逗号操作符的末尾定义一个真正返回类型的对象。下面的程序显示了一个这样的例子:

#include <iostream>
#include <algorithm>
#include <utility>
#include <vector>
#include <set>
template<class C, class T>
auto contains(const C& c, const T& x) 
             -> decltype((void)(std::declval<C>().find(std::declval<T>())), true)
{
    return end(c) != c.find(x);
}
int main(int argc, char**argv)
{
    std::cout << "\n\n------ SFINAE Exercise ------\n";
    std::set<int> mySet {1,2,3,4,5};
    std::cout << std::boolalpha;
    std::cout << "Set contains 5: " << contains(mySet,5) << "\n";
    std::cout << "Set contains 15: " << contains(mySet,15) << "\n";
    std::cout << "Complete.\n";
    return 0;
}

当这个程序被编译和执行时,我们获得以下输出:

Figure 2B.31: Output from the SFINAE expression

图 2 b . 31:SFINAE 表达式的输出

返回类型由以下代码给出:

decltype( (void)(std::declval<C>().find(std::declval<T>())), true)

让我们把它分解一下:

  • decltype的操作数是一个逗号分隔的表达式列表。这意味着编译器将构造但不计算表达式,并使用最右边值的类型来确定函数的返回类型。
  • std::declval < T > ()允许我们将 T 类型转换为引用类型,然后我们可以使用它来访问成员函数,而无需实际构造对象。
  • 与所有基于 SFINAE 的操作一样,如果逗号分隔列表中的任何表达式无效,则该函数将被丢弃。如果它们都有效,则将其添加到函数列表中进行查找。
  • 强制转换为 void 是为了防止用户重载逗号运算符时可能出现的任何问题。
  • 基本上,这是在测试C类是否有一个名为find()的成员函数,该函数以类 T类 T &const 类 T &作为参数。

此方法适用于std::set,它有一个find()方法,该方法接受一个参数,但对于其他容器将失败,因为它们没有find()成员方法。

如果我们只处理一种类型,这种方法效果很好。但是如果我们有一个函数需要基于类型产生不同的实现,就像我们之前看到的那样,if constexpr 的方法要干净得多,通常也更容易理解。要使用if constexpr方法,我们需要在编译时生成评估为truefalse的模板。标准库为此提供了助手类:std::true_typestd::false_type。这两个结构有一个静态常量成员名值,分别设置为。使用 SFINAE 和模板重载,我们可以创建新的检测类,从这些类中的任何一个派生,以给出我们想要的结果:

template <class T, class A0>
auto test_find(long) -> std::false_type;
template <class T, class A0>
auto test_find(int) 
-> decltype(void(std::declval<T>().find(std::declval<A0>())), std::true_type{});
template <class T, class A0>
struct has_find : decltype(test_find<T,A0>(0)) {};

第一个模板test_find创建默认行为,将返回类型设置为std::false_type。注意这个有一个的参数类型。

第二个模板test_find创建了一个特化,用于测试一个类,该类有一个名为find()的成员函数,返回类型为std::true_type。请注意,这有一个参数类型int

具有 _find < T,A0 > 模板通过从 test_find() 函数的返回类型中派生自身来工作。如果 T 类没有 find() 方法,则只生成 std::false_type 版本的 test_find() ,因此有 _find < T,A0>:value值将为 false,如果 constexpr() 可以在中使用。

有趣的部分发生在 T 类有find()方法的情况下,因为两个test_find()方法都是生成的。但是专用版本采用int类型的参数,而默认版本采用long类型的参数。当我们用零(0)来“调用”函数时,它将匹配专用版本并使用它。参数差异很重要,因为您不能让两个函数具有相同的参数类型,并且只有返回类型不同。如果要检查此行为,请将参数从 0 更改为 0L,以强制使用长版本。

类模板

到目前为止,我们只处理了函数模板。但是模板也可以用来为类提供蓝图。模板化类声明的一般结构如下:

template<class T>
class MyClass {
   // variables and methods that use T.
};

模板函数允许我们产生通用算法,而模板类允许我们产生通用数据类型及其相关行为。

当我们介绍标准模板库时,我们强调它包括容器的模板–向量德格堆栈等等。这些模板允许我们存储和管理任何我们想要的数据类型,但是仍然按照我们期望的方式运行。

练习 4:编写班级模板

计算科学中最常用的两种数据结构是堆栈和队列。两者目前在 STL 中都有实现。但是为了熟悉模板类,我们将编写一个可以用于任何类型的堆栈模板类。让我们开始吧:

  1. 在 Eclipse 中打开第 2B 课项目,然后在项目浏览器中,展开第 2B 课,然后展开练习 04 ,双击练习 4.cpp 在编辑器中打开本练习的文件。

  2. 配置一个新的启动配置l2be xerce 4,运行名称为练习 4

  3. 另外,配置一个新的 C/C++ 单元运行配置 L2BEx4Tests ,以运行 L2BEx4tests 。设置谷歌测试运行程序

  4. Click on the Run option for the test, which we have to run for the first time:

    Figure 2B.32: Initial unit test for stacks

    图 2B.32:堆栈的初始单元测试
  5. Open Stack.hpp in the editor. You will find the following code:

    #pragma once
    #include <vector>
    #include <cstddef>
    #define EXERCISE4_STEP    1
    namespace acpp
    {
    template <typename T>
    class Stack
    {
    public:
    private:
        std::vector<T> m_stack;
    };
    } // namespace acpp

    模板定义首先要注意的是,它必须放在一个头文件中,这个头文件可以包含在我们需要重用它的地方。其次,我们使用了一个 pragma 指令(#pragma 一次),它告诉编译器,如果它再次遇到这个要#included 的文件,就不需要了。虽然不是标准的严格组成部分,但几乎所有现代 C++ 编译器都支持它。最后,请注意,出于本练习的目的,我们选择将项目存储在 STL 向量中。

  6. 在编辑器中,在堆栈类的公共部分添加以下声明:

    bool empty() const
    {
      return m_stack.empty();
    }
  7. At the top of the file, change EXERCISE4_STEP to a value of 10. Click on the Run button. The Exercise 4 tests should run and fail:

    Figure 2B.33: Jumping to a Failing test

    图 2B.33:跳到失败的测试
  8. 点击失败测试的名称,即defaultconstructionnitsempty。它将在右侧的消息部分显示失败的原因。双击消息。它将打开测试失败的文件并跳转到违规的行,如前面的截图所示。这个测试有一个错误。在测试中,我们期望堆栈是空的。但是,我们可以看到空()的报道是假的。

  9. 断言 _ 假更改为断言 _ 真并重新运行测试。这一次,它通过了,因为它在测试正确的事情。

  10. 接下来我们要做的是添加一些类型别名,以便在接下来的几个方法中使用。在编辑器中,在空()方法的正上方添加以下行:

```cpp
using value_type = T;
using reference = value_type&;
using const_reference = const value_type&;
using size_type = std::size_t;
```
  1. 点击运行按钮重新运行测试。他们应该通过。在做测试驱动开发时,口头禅是写一个小测试,看到它失败,然后写足够的代码让它通过。在这种情况下,我们实际测试了别名的定义是否正确,因为编译失败是测试失败的一种形式。我们现在准备添加推送功能。
  2. 在编辑器中,通过在**空()**方法的正下方添加以下代码来更改 Stack.hpp
  3. 在文件顶部,将锻炼 4 _ 步骤更改为15的值。点击运行按钮。我们现在有两个测试运行并通过。在 StackTests.cpp 中的新测试pushontostknottempty证明了该推送可以使堆栈不再为空。我们需要添加更多的方法来确保它已经完成了预期的工作。
  4. 在编辑器中,通过在push()方法的正下方添加以下代码来更改 Stack.hpp ,并将execute 4 _ STEP更改为16的值:
```cpp
size_type size() const
{
    return m_stack.size();
}
```
  1. 点击运行按钮运行测试。现在应该有三个通过测试。
  2. 在编辑器中,通过在push()方法的正下方添加以下代码来更改 Stack.hpp ,并将execute 4 _ STEP更改为18的值:
```cpp
void pop()
{
    m_stack.pop_back();
}
```
  1. 点击运行按钮运行测试。现在应该有四个通过测试。
  2. 在编辑器中,通过在pop()方法的正下方添加以下代码来更改 Stack.hpp ,并将execute 4 _ STEP更改为值20 :
```cpp
reference top()
{
    m_stack.back();
}
const_reference top() const
{
    m_stack.back();
}
```
  1. 点击运行按钮运行测试。现在有五个通过测试,我们已经实现了一个堆栈。
  2. 从启动配置下拉菜单中,选择 L2BExercise4 并点击运行按钮。练习 4 将运行并生成类似于以下输出的内容:

Figure 2B.34: Exercise 4 output

图 2B.34:练习 4 输出

检查现在在 Stack.hpp 文件中的代码。在类内部定义类型的方法在整个 STL 中很常见(尽管由于它们的传统,它们可能会使用 typedef)。std::stack模板接受两个参数,第二个参数定义要使用的容器——vector 可能是第一个。检查 StackTests.cpp 中的测试。测试应该被命名,以表明他们的目标是测试什么,他们应该专注于这样做。

活动 1:开发通用“包含”模板函数

编程语言 Python 有一个名为“in”的成员操作符,可以用于任何序列,即列表、序列、集合、字符串等。即使 C++ 有 100 多种算法,它也没有一种等效的方法来实现同样的功能。C++ 20 在std::set上引入了contains()方法,但这还不够。我们需要创建一个contains()模板函数,它与std::setstd::stringstd::vector以及任何其他提供迭代器的容器一起工作。这是由在其上调用 end()的能力决定的。我们的目标是获得最佳性能,因此我们将在任何有成员方法的容器上调用find()成员方法(这将是最有效的),否则我们将返回到在容器上使用std::end()。我们还需要区别对待std::string(),因为它的find()方法返回一个特殊值。

我们可以使用一个通用模板和两个专门化来实现这一点,但是这个活动是使用 SFINAE 和 if constexpr 的技术来实现的。另外,这个模板必须只在支持end(C)的类上工作。按照以下步骤实施本活动:

  1. 第 2B 课/练习 01 文件夹加载准备好的项目。
  2. 使用npos成员定义助手模板函数和类来检测标准:字符串大小写。
  3. 定义辅助模板函数和类,检测该类是否有find()方法。
  4. 定义 contains template 函数,该函数使用 constexpr 在三种实现中进行选择-字符串大小写、has find 方法或一般大小写。

执行上述步骤后,预期输出应该如下所示:

Figure 2B.35: Output from the successful implementation of contains

图 2B.35:成功实现 contains 的 suc 输出

注意

这项活动的解决方案可以在第 653 页找到。

总结

在这一章中,我们学习了接口、继承和多态性,这些扩展了我们对类型的处理技巧。我们第一次尝试使用 C++ 模板进行泛型编程,并接触了该语言从 C++ 标准库中免费提供给我们的东西,其中包括 STL。我们探索了 C++ 的一个刚刚好用的特性,那就是模板类型推演,使用模板的时候让我们的生活变得更加轻松。然后,我们进一步学习了模板,并学习了如何使用 SFINAE 和 if constexpr 来控制编译器包含的模板部分。这些构成了我们进入 C++ 之旅的基石。在下一章中,我们将重新访问堆栈和堆,并了解什么是异常、发生了什么以及何时发生。我们还将学习如何在异常发生时保护我们的程序免受资源损失。