精通-C++-编程(全)

精通 C++ 编程(全)

原文:annas-archive.org/md5/0E32826EC8D4CA7BCD89E795AD6CBF05

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

C++是一种有趣的编程语言,已经存在了将近三十年。它用于开发复杂的桌面应用程序、Web 应用程序、网络应用程序、设备驱动程序、内核模块、嵌入式应用程序以及使用第三方小部件框架的 GUI 应用程序;可以说,C++可以在任何领域使用。

自 1993 年开始编程以来,我一直珍惜与许多同事和行业专家进行的良好的技术讨论。在所有的技术讨论中,有一个话题一再重复,那就是,“你认为 C++今天还是一个相关的编程语言吗?我应该继续在 C++上工作,还是应该转向其他现代编程语言,比如 Java、C#、Scala 或 Angular/Node.js?”

我一直觉得应该开放学习其他技术,但这并不意味着要放弃 C++。然而,好消息是,有了新的 C++17 特性,C++已经重生,并将在未来几十年内继续存在和发展,这是我写这本书的动力。

人们一直认为 Java 会取代 C++,但它一直存在。当 C#进入行业时,同样的讨论再次开始,今天当 Angular/Node.js 和 Scala 似乎更具吸引力时,同样的讨论再次开始。然而,C++有自己的位置,迄今为止没有一种编程语言能够取代 C++的位置。

已经有很多 C++书籍帮助你理解这种语言,但很少有书籍涉及在 C++中开发 GUI 应用程序、使用 C++进行 TDD 和 BDD。

C++已经走过了很长的路,现在已经在多个领域得到了应用。它的主要优势在于其软件基础设施和资源受限的应用程序。C++ 17 的发布将改变开发人员编写代码的方式,而本书将帮助您掌握 C++的开发技能。

通过真实世界的实际示例解释每个概念,本书将首先介绍您 C++ 17 的最新特性。它将鼓励在 C++中采用清晰的代码实践,并演示 C++中的 GUI 应用程序开发选项。您将深入了解如何使用智能指针避免内存泄漏。接下来,您将学习多线程编程如何帮助您实现应用程序的并发性。

接下来,您还将深入了解 C++标准模板库。我们将解释在 C++程序中实现 TDD 和 BDD 的概念,以及基于模板的通用编程,为您提供构建强大应用程序的专业知识。最后,我们将以调试技术和最佳实践结束本书。当您读完本书时,您将对语言及其各个方面有深入的了解。

本书涵盖的内容

第一章,“C++17 特性”,解释了 C++17 的新特性以及已被移除的特性。它还通过易于理解的示例演示了关键的 C++17 特性。

第二章,“标准模板库”,概述了 STL,演示了各种容器和迭代器,并解释了如何在容器上应用有用的算法。本章还涉及了使用的内部数据结构及其运行效率。

第三章,“模板编程”,概述了通用编程及其优点。它演示了编写函数模板和类模板,以及重载函数模板。它还涉及了编写通用类、显式类特化和部分特化。

第四章,“智能指针”,解释了使用原始指针的问题,并推动了智能指针的使用。逐渐地,这一章介绍了 auto_ptr,unique_ptr,shared_ptr 和 weak_ptr 的使用,并解释了解决循环依赖问题的方法。

第五章,“在 C++中开发 GUI 应用程序”,概述了 Qt,并为您提供了在 Linux 和 Windows 上安装 Qt 的逐步说明。本章逐渐帮助您开发具有有趣的小部件和各种布局的令人印象深刻的 GUI 应用程序。

第六章,“多线程编程和进程间通信”,介绍了 POSIX pthreads 库,并讨论了本地 C++线程库。它还讨论了使用 C++线程库的好处。随后,它帮助您编写多线程应用程序,探讨了管理线程的方法,并解释了同步机制的使用。本章讨论了死锁和可能的解决方案。在本章末尾,它向您介绍了并发库。

第七章,“测试驱动开发”,简要概述了 TDD,并澄清了 TDD 的常见问题。本章为您提供了逐步说明,以安装 Google 测试框架并将其与 Linux 和 Windows 平台集成。它帮助您以易于理解的教程风格使用 TDD 开发应用程序。

第八章,“行为驱动开发”,概述了 BDD,并指导您在 Linux 平台上安装,集成和配置黄瓜框架。它还解释了 Gherkin,并帮助您编写 BDD 测试用例。

第九章,“调试技术”,讨论了行业中用于调试应用程序问题的各种策略和技术。随后,它帮助您了解使用 GDB 和 Valgrind 工具进行逐步调试,监视变量,修复各种与内存相关的问题,包括内存泄漏。

第十章,“代码异味和清洁代码实践”,讨论了各种代码异味和重构技术。

您需要为本书准备以下工具

在开始阅读本书之前,您需要准备以下工具:

  • g++编译器版本 5.4.0 20160609 或更高版本

  • GDB 7.11.1

  • Valgrind 3.11.0

  • Cucumber-cpp Git 2.7.4

  • Google 测试框架(gtest 1.6 或更高版本)

  • CMake 3.5.1

  • Ruby 2.5.1

  • Qt 5.7.0

  • Bundler v 1.14.6

所需的操作系统是 Ubuntu 16.04 64 位或更高版本。硬件配置至少应为 1GB RAM 和 20GB ROM。具有这种配置的虚拟机也应该足够。

这本书是为谁准备的

这本书是为有经验的 C++开发人员准备的。如果您是初学者 C++开发人员,那么强烈建议您在阅读本书之前对 C++语言有扎实的了解。

约定

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义解释。文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄显示如下:“initialize()方法将deque迭代器pos初始化为存储在deque中的第一个数据元素。”

一块代码设置如下:

#include <iostream>

int main ( ) {

        const int x = 5, y = 5;

        static_assert ( 1 == 0, "Assertion failed" );
        static_assert ( 1 == 0 );
        static_assert ( x == y );

        return 0;
}

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目将以粗体显示:

#include <iostream>
#include <thread>
#include <mutex>
#include "Account.h"
using namespace std;

enum ThreadType {
  DEPOSITOR,
  WITHDRAWER
};

mutex locker;

任何命令行输入或输出都将按以下方式编写:

g++ main.cpp -std=c++17
./a.out

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:“您需要通过导航到新项目| Visual Studio | Windows | Win32 | Win32 控制台应用程序来创建名为 MathApp 的新项目。”

警告或重要说明会出现在这样。

提示和技巧会出现在这样。

第一章:C++17 功能

在本章中,您将学习以下概念:

  • C++17 背景

  • C++17 中有什么新功能?

  • C++17 中弃用或移除的功能是什么?

  • C++17 的关键特性

C++17 背景

如您所知,C++语言是 Bjarne Stroustrup 的心血结晶,他于 1979 年开发了 C++。C++编程语言由国际标准化组织(ISO)标准化。

最初的标准化于 1998 年发布,通常称为 C++98,接下来的标准化 C++03 于 2003 年发布,这主要是一个修复错误的版本,只有一个语言特性用于值初始化。2011 年 8 月,C++11 标准发布,核心语言增加了一些内容,包括对标准模板库STL)的一些重大有趣的更改;C++11 基本上取代了 C++03 标准。C++14 于 2014 年 12 月发布,带有一些新功能,后来,C++17 标准于 2017 年 7 月 31 日发布。

在撰写本书时,C++17 是 C++编程语言的 ISO/IEC 标准的最新修订版。

本章需要支持 C++17 功能的编译器:gcc 版本 7 或更高版本。由于 gcc 版本 7 是撰写本书时的最新版本,因此在本章中我将使用 gcc 版本 7.1.0。

如果您还没有安装支持 C++17 功能的 g++ 7,可以使用以下命令安装:

`sudo add-apt-repository ppa:jonathonf/gcc-7.1

sudo apt-get update

sudo apt-get install gcc-7 g++-7`

C++17 中有什么新功能?

完整的 C++17 功能列表可以在en.cppreference.com/w/cpp/compiler_support#C.2B.2B17_features找到。

为了给出一个高层次的概念,以下是一些新的 C++17 功能:

  • 直接列表初始化的新汽车规则

  • 没有消息的static_assert

  • 嵌套命名空间定义

  • 内联变量

  • 命名空间和枚举器的属性

  • C++异常规范是类型系统的一部分

  • 改进的 lambda 功能,可在服务器上提供性能优势

  • NUMA 架构

  • 使用属性命名空间

  • 用于超对齐数据的动态内存分配

  • 类模板的模板参数推导

  • 具有自动类型的非类型模板参数

  • 保证的拷贝省略

  • 继承构造函数的新规范

  • 枚举的直接列表初始化

  • 更严格的表达式评估顺序

  • shared_mutex

  • 字符串转换

否则,核心 C++语言中添加了许多有趣的新功能:STL、lambda 等。新功能为 C++带来了面貌更新,从C++17开始,作为 C++开发人员,您会感到自己正在使用现代编程语言,如 Java 或 C#。

C++17 中弃用或移除的功能是什么?

以下功能现在已在 C++17 中移除:

  • register关键字在 C++11 中已被弃用,并在 C++17 中被移除

  • ++运算符对bool在 C++98 中已被弃用,并在 C++17 中被移除

  • 动态异常规范在 C++11 中已被弃用,并在 C++17 中被移除

C++17 的关键特性

让我们逐个探讨以下 C++17 的关键功能:

  • 更简单的嵌套命名空间

  • 从大括号初始化列表中检测类型的新规则

  • 简化的static_assert

  • std::invoke

  • 结构化绑定

  • ifswitch局部作用域变量

  • 类模板的模板类型自动检测

  • 内联变量

更简单的嵌套命名空间语法

直到 C++14 标准,C++中支持的嵌套命名空间的语法如下:

#include <iostream>
using namespace std;

namespace org {
    namespace tektutor {
        namespace application {
             namespace internals {
                  int x;
             }
        }
    }
}

int main ( ) {
    org::tektutor::application::internals::x = 100;
    cout << "\nValue of x is " << org::tektutor::application::internals::x << endl;

    return 0;
}

上述代码可以使用以下命令编译,并且可以查看输出:

g++-7 main.cpp -std=c++17
./a.out

上述程序的输出如下:

Value of x is 100

每个命名空间级别都以大括号开始和结束,这使得在大型应用程序中使用嵌套命名空间变得困难。C++17 嵌套命名空间语法真的很酷;只需看看下面的代码,你就会很容易同意我的观点:

#include <iostream>
using namespace std;

namespace org::tektutor::application::internals {
    int x;
}

int main ( ) {
    org::tektutor::application::internals::x = 100;
    cout << "\nValue of x is " << org::tektutor::application::internals::x << endl;

    return 0;
}

上述代码可以编译,并且可以使用以下命令查看输出:

g++-7 main.cpp -std=c++17
./a.out

输出与上一个程序相同:

Value of x is 100

来自大括号初始化列表的类型自动检测的新规则

C++17 引入了对初始化列表的自动检测的新规则,这补充了 C++14 的规则。C++17 规则坚持认为,如果声明了std::initializer_list的显式或部分特化,则程序是非法的:

#include <iostream>
using namespace std;

template <typename T1, typename T2>
class MyClass {
     private:
          T1 t1;
          T2 t2;
     public:
          MyClass( T1 t1 = T1(), T2 t2 = T2() ) { }

          void printSizeOfDataTypes() {
               cout << "\nSize of t1 is " << sizeof ( t1 ) << " bytes." << endl;
               cout << "\nSize of t2 is " << sizeof ( t2 ) << " bytes." << endl;
     }
};

int main ( ) {

    //Until C++14
    MyClass<int, double> obj1;
    obj1.printSizeOfDataTypes( );

    //New syntax in C++17
    MyClass obj2( 1, 10.56 );

    return 0;
}

上述代码可以编译,并且可以使用以下命令查看输出:

g++-7 main.cpp -std=c++17
./a.out

上述程序的输出如下:

Values in integer vectors are ...
1 2 3 4 5 

Values in double vectors are ...
1.5 2.5 3.5 

简化的 static_assert

static_assert宏有助于在编译时识别断言失败。这个特性自 C++11 以来就得到了支持;然而,在 C++17 中,static_assert宏在之前是需要一个强制的断言失败消息的,现在已经变成了可选的。

以下示例演示了使用static_assert的方法,包括消息和不包括消息:

#include <iostream>
#include <type_traits>
using namespace std;

int main ( ) {

        const int x = 5, y = 5;

        static_assert ( 1 == 0, "Assertion failed" );
        static_assert ( 1 == 0 );
        static_assert ( x == y );

        return 0;
}

上述程序的输出如下:

g++-7 staticassert.cpp -std=c++17
staticassert.cpp: In function ‘int main()’:
staticassert.cpp:7:2: error: static assertion failed: Assertion failed
 static_assert ( 1 == 0, "Assertion failed" );

staticassert.cpp:8:2: error: static assertion failed
 static_assert ( 1 == 0 );

从上面的输出中,您可以看到消息Assertion failed作为编译错误的一部分出现,而在第二次编译中,由于我们没有提供断言失败消息,出现了默认的编译器错误消息。当没有断言失败时,断言错误消息不会出现,如static_assert(x==y)所示。这个特性受到了 BOOST C++库中 C++社区的启发。

std::invoke()方法

std::invoke()方法可以用相同的语法调用函数、函数指针和成员指针:

#include <iostream>
#include <functional>
using namespace std;

void globalFunction( ) {
     cout << "globalFunction ..." << endl;
}

class MyClass {
    public:
        void memberFunction ( int data ) {
             std::cout << "\nMyClass memberFunction ..." << std::endl;
        }

        static void staticFunction ( int data ) {
             std::cout << "MyClass staticFunction ..." << std::endl;
        }
};

int main ( ) {

    MyClass obj;

    std::invoke ( &MyClass::memberFunction, obj, 100 );
    std::invoke ( &MyClass::staticFunction, 200 );
    std::invoke ( globalFunction );

    return 0;
}

上述代码可以编译,并且可以使用以下命令查看输出:

g++-7 main.cpp -std=c++17
./a.out

上述程序的输出如下:

MyClass memberFunction ...
MyClass staticFunction ...
globalFunction ...

std::invoke()方法是一个模板函数,可以帮助您无缝地调用可调用对象,无论是内置的还是用户定义的。

结构化绑定

现在您可以使用一个非常酷的语法初始化多个变量,并返回一个值,如下面的示例代码所示:

#include <iostream>
#include <tuple>
using namespace std;

int main ( ) {

    tuple<string,int> student("Sriram", 10);
    auto [name, age] = student;

    cout << "\nName of the student is " << name << endl;
    cout << "Age of the student is " << age << endl;

    return 0;
}

在上述程序中,用粗体突出显示的代码是 C++17 引入的结构化绑定特性。有趣的是,我们没有声明string nameint age变量。这些都是由 C++编译器自动推断为stringint,这使得 C++的语法就像任何现代编程语言一样,而不会失去其性能和系统编程的好处。

上述代码可以编译,并且可以使用以下命令查看输出:

g++-7 main.cpp -std=c++17
./a.out

上述程序的输出如下:

Name of the student is Sriram
Age of the student is 10

If 和 Switch 局部作用域变量

有一个有趣的新功能,允许您声明一个绑定到ifswitch语句代码块的局部变量。在ifswitch语句中使用的变量的作用域将在各自的块之外失效。可以通过以下易于理解的示例更好地理解,如下所示:

#include <iostream>
using namespace std;

bool isGoodToProceed( ) {
    return true;
}

bool isGood( ) {
     return true;
}

void functionWithSwitchStatement( ) {

     switch ( auto status = isGood( ) ) {
          case true:
                 cout << "\nAll good!" << endl;
          break;

          case false:
                 cout << "\nSomething gone bad" << endl;
          break;
     } 

}

int main ( ) {

    if ( auto flag = isGoodToProceed( ) ) {
         cout << "flag is a local variable and it loses its scope outside the if block" << endl;
    }

     functionWithSwitchStatement();

     return 0;
}

上述代码可以编译,并且可以使用以下命令查看输出:

g++-7 main.cpp -std=c++17
./a.out

上述程序的输出如下:

flag is a local variable and it loses its scope outside the if block
All good!

类模板的模板类型自动推断

我相信你会喜欢你即将在示例代码中看到的内容。虽然模板非常有用,但很多人不喜欢它,因为它的语法很难和奇怪。但是你不用担心了;看看下面的代码片段:

#include <iostream>
using namespace std;

template <typename T1, typename T2>
class MyClass {
     private:
          T1 t1;
          T2 t2;
     public:
          MyClass( T1 t1 = T1(), T2 t2 = T2() ) { }

          void printSizeOfDataTypes() {
               cout << "\nSize of t1 is " << sizeof ( t1 ) << " bytes." << endl;
               cout << "\nSize of t2 is " << sizeof ( t2 ) << " bytes." << endl;
     }
};

int main ( ) {

    //Until C++14
    MyClass<int, double> obj1;
    obj1.printSizeOfDataTypes( );

    //New syntax in C++17
    MyClass obj2( 1, 10.56 );

    return 0;
}

上述代码可以编译,并且可以使用以下命令查看输出:

g++-7 main.cpp -std=c++17
./a.out

程序的输出如下:

Size of t1 is 4 bytes.
Size of t2 is 8 bytes.

内联变量

就像 C++中的内联函数一样,现在您可以使用内联变量定义。这对初始化静态变量非常方便,如下面的示例代码所示:

#include <iostream>
using namespace std;

class MyClass {
    private:
        static inline int count = 0;
    public:
        MyClass() { 
              ++count;
        }

    public:
         void printCount( ) {
              cout << "\nCount value is " << count << endl;
         } 
};

int main ( ) {

    MyClass obj;

    obj.printCount( ) ;

    return 0;
}

上述代码可以编译,并且可以使用以下命令查看输出:

g++-7 main.cpp -std=c++17
./a.out

上述代码的输出如下:

Count value is 1

总结

在本章中,您了解了 C++17 引入的有趣的新特性。您学会了超级简单的 C++17 嵌套命名空间语法。您还学会了使用大括号初始化列表进行数据类型检测以及 C++17 标准中引入的新规则。

您还注意到,static_assert可以在没有断言失败消息的情况下完成。此外,使用std::invoke(),您现在可以调用全局函数、函数指针、成员函数和静态类成员函数。并且,使用结构化绑定,您现在可以用返回值初始化多个变量。

您还学到了ifswitch语句可以在if条件和switch语句之前有一个局部作用域的变量。您了解了类模板的自动类型检测。最后,您使用了inline变量。

C++17 有许多更多的特性,但本章试图涵盖大多数开发人员可能需要的最有用的特性。在下一章中,您将学习标准模板库。

第二章:标准模板库

本章将涵盖以下主题:

  • STL 概述

  • STL 架构

  • 容器

  • 迭代器

  • 算法

  • 函数对象

  • STL 容器

  • 序列

  • 关联

  • 无序

  • 适配器

让我们在以下各节中逐个查看 STL 主题。

标准模板库架构

C++ 标准模板库STL)提供了现成的通用容器、可应用于容器的算法以及用于导航容器的迭代器。STL 是用 C++模板实现的,模板允许在 C++中进行通用编程。

STL 鼓励 C++开发人员专注于手头的任务,通过摆脱编写低级数据结构和算法的开发人员。STL 是一个经过时间考验的库,可以实现快速应用程序开发。

STL 是一项有趣的工作和架构。它的秘密公式是编译时多态性。为了获得更好的性能,STL 避免了动态多态性,告别了虚函数。广义上来说,STL 有以下四个组件:

  • 算法

  • 函数对象

  • 迭代器

  • 容器

STL 架构将所有上述四个组件都连接在一起。它具有许多常用的算法,并提供性能保证。有趣的是,STL 算法可以在不了解包含数据的容器的情况下无缝工作。这是由于迭代器的高级遍历 API,它完全抽象了容器中使用的底层数据结构。STL 广泛使用运算符重载。让我们逐个了解 STL 的主要组件,以便对 STL 的概念有一个很好的理解。

算法

STL 算法由 C++模板支持;因此,相同的算法可以处理不同的数据类型,或者独立于容器中数据的组织方式。有趣的是,STL 算法足够通用,可以使用模板支持内置和用户定义的数据类型。事实上,算法通过迭代器与容器进行交互。因此,对算法来说重要的是容器支持的迭代器。话虽如此,算法的性能取决于容器中使用的底层数据结构。因此,某些算法仅适用于选择性的容器,因为 STL 支持的每个算法都期望某种类型的迭代器。

迭代器

迭代器是一种设计模式,但有趣的是,STL 的工作开始得早得多

四人帮将他们与设计模式相关的工作发布给了软件社区。迭代器本身是允许遍历容器以访问、修改和操作容器中存储的数据的对象。迭代器以如此神奇的方式进行操作,以至于我们不会意识到或需要知道数据存储在何处以及如何检索。

以下图像直观地表示了一个迭代器:

从上图中,您可以了解到每个迭代器都支持begin() API,它返回第一个元素的位置,end() API 返回容器中最后一个元素的下一个位置。

STL 广泛支持以下五种类型的迭代器:

  • 输入迭代器

  • 输出迭代器

  • 前向迭代器

  • 双向迭代器

  • 随机访问迭代器

容器实现了迭代器,让我们可以轻松地检索和操作数据,而不需要深入了解容器的技术细节。

以下表格解释了五种迭代器中的每一种:

迭代器的类型 描述
输入迭代器
  • 用于从指向的元素中读取

  • 它适用于单次导航,一旦到达容器的末尾,迭代器将失效

  • 它支持前增量和后增量运算符

  • 它不支持递减运算符

  • 它支持解引用

  • 它支持==!=运算符来与其他迭代器进行比较

  • istream_iterator迭代器是输入迭代器

  • 所有容器都支持这个迭代器

|

输出迭代器
  • 它用于修改指向的元素

  • 它对于单次导航是有效的,一旦到达容器的末尾,迭代器将无效

  • 它支持前增量和后增量运算符

  • 它不支持减量运算符

  • 它支持解引用

  • 它不支持==!=运算符

  • ostream_iteratorback_inserterfront_inserter迭代器是输出迭代器的示例

  • 所有容器都支持这个迭代器

|

前向迭代器
  • 它支持输入迭代器和输出迭代器的功能

  • 它允许多次导航

  • 它支持前增量和后增量运算符

  • 它支持解引用

  • forward_list容器支持前向迭代器

|

双向迭代器
  • 它是一个支持双向导航的前向迭代器

  • 它允许多次导航

  • 它支持前增量和后增量运算符

  • 它支持前减量和后减量运算符

  • 它支持解引用

  • 它支持[]运算符

  • listsetmapmultisetmultimap容器支持双向迭代器

|

随机访问迭代器
  • 元素可以使用任意偏移位置进行访问

  • 它支持前增量和后增量运算符

  • 它支持前减量和后减量运算符

  • 它支持解引用

  • 它是功能上最完整的迭代器,因为它支持先前列出的其他类型迭代器的所有功能

  • arrayvectordeque容器支持随机访问迭代器

  • 支持随机访问的容器自然也支持双向和其他类型的迭代器

|

容器

STL 容器通常是动态增长和收缩的对象。容器在内部使用复杂的数据结构存储数据,并提供高级函数来访问数据,而不需要我们深入了解数据结构的复杂内部实现细节。STL 容器非常高效且经过时间考验。

每个容器使用不同类型的数据结构以有效的方式存储、组织和操作数据。尽管许多容器可能看起来相似,但在内部它们的行为是不同的。因此,选择错误的容器会导致应用程序性能问题和不必要的复杂性。

容器有以下几种类型:

  • 顺序

  • 关联

  • 容器适配器

容器中存储的对象是复制或移动的,而不是引用。我们将在接下来的部分中使用简单而有趣的示例来探索每种类型的容器。

函数对象

函数对象是行为类似于常规函数的对象。美妙之处在于函数对象可以替代函数指针的位置。函数对象是方便的对象,让您在不损害面向对象编码原则的情况下扩展或补充 STL 函数的行为。

函数对象很容易实现;你只需要重载函数运算符。函数对象也被称为函数对象。

以下代码将演示如何实现一个简单的函数对象:

#include <iostream>
#include <vector>
#include <iterator>
#include <algorithm>
using namespace std;

template <typename T>
class Printer {
public:
  void operator() ( const T& element ) {
    cout << element << "\t";
  }
};

int main () {
  vector<int> v = { 10, 20, 30, 40, 50 };

  cout << "\nPrint the vector entries using Functor" << endl;

  for_each ( v.begin(), v.end(), Printer<int>() );

  cout << endl;

  return 0;
}

让我们快速编译程序,使用以下命令:

g++ main.cpp -std=c++17
./a.out

让我们来检查程序的输出:

Print the vector entries using Functor
10  20  30  40  50

我们希望你意识到函数对象是多么简单和酷。

序列容器

STL 支持一系列非常有趣的序列容器。序列容器以线性方式存储同质数据类型,可以按顺序访问。STL 支持以下序列容器:

  • 数组

  • 向量

  • 列表

  • forward_list

  • 双端队列

由于存储在 STL 容器中的对象只是值的副本,STL 从用户定义的数据类型中期望满足一些基本要求,以便将这些对象存储在容器中。存储在 STL 容器中的每个对象都必须提供以下最低要求:

  • 一个默认构造函数

  • 一个复制构造函数

  • 一个赋值运算符

让我们逐个探索序列容器在以下子节中。

数组

STL 数组容器是一个固定大小的序列容器,就像 C/C++内置数组一样,只是 STL 数组是大小感知的,比内置的 C/C++数组要聪明一点。让我们通过一个例子来了解 STL 数组:

#include <iostream>
#include <array>
using namespace std;
int main () {
  array<int,5> a = { 1, 5, 2, 4, 3 };

  cout << "\nSize of array is " << a.size() << endl;

  auto pos = a.begin();

  cout << endl;
  while ( pos != a.end() ) 
    cout << *pos++ << "\t";
  cout << endl;

  return 0;
}

可以使用以下命令编译前面的代码并查看输出:

g++ main.cpp -std=c++17
./a.out 

程序的输出如下:

Size of array is 5
1     5     2     4     3

代码演示

以下行声明了一个固定大小(5)的数组,并用五个元素初始化了数组:

array<int,5> a = { 1, 5, 2, 4, 3 };

一旦声明,大小就不能更改,就像 C/C++内置数组一样。array::size()方法返回数组的大小,不管在初始化列表中初始化了多少个整数。auto pos = a.begin()方法声明了一个array<int,5>的迭代器,并分配了数组的起始位置。array::end()方法指向数组中最后一个元素的下一个位置。迭代器的行为类似于或模拟 C++指针,对迭代器进行解引用会返回迭代器指向的值。迭代器位置可以通过++pos--pos向前和向后移动。

数组中常用的 API

以下表格显示了一些常用的数组 API:

API 描述
at( int index ) 这返回存储在由索引引用的位置的值。索引是从零开始的索引。如果索引超出数组的索引范围,此 API 将抛出std::out_of_range异常。
operator [ int index ] 这是一个不安全的方法,如果索引超出数组的有效范围,它不会抛出任何异常。这比at稍微快一点,因为此 API 不执行边界检查。
front() 这返回数组中的第一个元素。
back() 这返回数组中的最后一个元素。
begin() 这返回数组中第一个元素的位置
end() 这返回数组中最后一个元素的下一个位置
rbegin() 这返回反向开始位置,即返回数组中的最后一个元素的位置
rend() 这返回反向结束位置,即返回数组中第一个元素之前的一个位置
size() 这返回数组的大小

数组容器支持随机访问;因此,给定一个索引,数组容器可以在O(1)或常量时间内获取一个值。

可以使用反向迭代器以反向方式访问数组容器元素:

#include <iostream>
#include <array>
using namespace std;

int main () {

    array<int, 6> a;
    int size = a.size();
    for (int index=0; index < size; ++index)
         a[index] = (index+1) * 100;   

    cout << "\nPrint values in original order ..." << endl;

    auto pos = a.begin();
    while ( pos != a.end() )
        cout << *pos++ << "\t";
    cout << endl;

    cout << "\nPrint values in reverse order ..." << endl;

    auto rpos = a.rbegin();
    while ( rpos != a.rend() )
    cout << *rpos++ << "\t";
    cout << endl;

    return 0;
}

我们将使用以下命令来获取输出:

./a.out

输出如下:

Print values in original order ...
100   200   300   400   500   600

Print values in reverse order ...
600   500   400   300   200   100

Vector

Vector 是一个非常有用的序列容器,它的工作原理与数组完全相同,只是向量可以在运行时增长和缩小,而数组的大小是固定的。但是,在数组和向量下面使用的数据结构是一个简单的内置 C/C++样式数组。

让我们看下面的例子更好地理解向量:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main () {
  vector<int> v = { 1, 5, 2, 4, 3 };

  cout << "\nSize of vector is " << v.size() << endl;

  auto pos = v.begin();

  cout << "\nPrint vector elements before sorting" << endl;
  while ( pos != v.end() )
    cout << *pos++ << "\t";
  cout << endl;

  sort( v.begin(), v.end() );

  pos = v.begin();

  cout << "\nPrint vector elements after sorting" << endl;

  while ( pos != v.end() )
    cout << *pos++ << "\t";
  cout << endl;

  return 0;
}

可以使用以下命令编译前面的代码并查看输出:

g++ main.cpp -std=c++17
./a.out

程序的输出如下:

Size of vector is 5

Print vector elements before sorting
1     5     2     4     3

Print vector elements after sorting
1     2     3     4     5

代码演示

以下行声明了一个向量,并用五个元素初始化了向量:

vector<int> v = { 1, 5, 2, 4, 3 };

然而,向量还允许使用vector::push_back<data_type>( value ) API 将值附加到向量的末尾。sort()算法接受两个表示必须排序的数据范围的随机访问迭代器。由于向量内部使用内置的 C/C++数组,就像 STL 数组容器一样,向量也支持随机访问迭代器;因此,sort()函数是一个高效的算法,其运行时复杂度是对数的,即O(N log2 (N))

常用的向量 API

以下表格显示了一些常用的向量 API:

API 描述
at ( int index ) 这返回索引位置存储的值。如果索引无效,则会抛出std::out_of_range异常。
operator [ int index ] 这返回索引位置存储的值。它比at( int index )更快,因为该函数不执行边界检查。
front() 这返回向量中存储的第一个值。
back() 这返回向量中存储的最后一个值。
empty() 如果向量为空,则返回 true,否则返回 false。
size() 这返回向量中存储的值的数量。
reserve( int size ) 这保留向量的初始大小。当向量大小达到容量时,插入新值需要重新调整向量大小。这使得插入消耗O(N)的运行时复杂度。reserve()方法是对描述的问题的一种解决方法。
capacity() 这返回向量的总容量,而大小是向量中实际存储的值。
clear() 这清除所有值。
push_back<data_type>( value ) 这在向量末尾添加一个新值。

使用istream_iteratorostream_iterator从/向向量读取和打印会非常有趣和方便。以下代码演示了向量的使用:

#include <iostream>
#include <vector>
#include <algorithm>
#include <iterator>
using namespace std;

int main () {
    vector<int> v;

    cout << "\nType empty string to end the input once you are done feeding the vector" << endl;
    cout << "\nEnter some numbers to feed the vector ..." << endl;

    istream_iterator<int> start_input(cin);
    istream_iterator<int> end_input;

    copy ( start_input, end_input, back_inserter( v ) );

    cout << "\nPrint the vector ..." << endl;
    copy ( v.begin(), v.end(), ostream_iterator<int>(cout, "\t") );
    cout << endl;

    return 0;
}

请注意,程序的输出被跳过,因为输出取决于您输入的输入。请随时尝试在命令行上执行这些指令。

代码演示

基本上,复制算法接受一系列迭代器,其中前两个参数表示源,第三个参数表示目标,这恰好是向量:

istream_iterator<int> start_input(cin);
istream_iterator<int> end_input;

copy ( start_input, end_input, back_inserter( v ) );

start_input迭代器实例定义了一个从istreamcin接收输入的istream_iterator迭代器,而end_input迭代器实例定义了一个文件结束符,它默认为空字符串("")。因此,输入可以通过在命令行输入终端中键入""来终止。

同样,让我们了解以下代码片段:

cout << "\nPrint the vector ..." << endl;
copy ( v.begin(), v.end(), ostream_iterator<int>(cout, "\t") );
cout << endl;

复制算法用于将向量中的值逐个复制到ostream中,并用制表符(\t)分隔输出。

向量的陷阱

每个 STL 容器都有其优点和缺点。没有单个 STL 容器在所有情况下都表现更好。向量内部使用数组数据结构,而在 C/C++中数组的大小是固定的。因此,当您尝试在向量大小已经达到最大容量时向向量添加新值时,向量将分配新的连续位置,以容纳旧值和新值在一个连续的位置。然后开始将旧值复制到新位置。一旦所有数据元素都被复制,向量将使旧位置无效。

每当这种情况发生时,向量插入将需要O(N)的运行时复杂度。随着向量大小随时间增长,按需,O(N)的运行时复杂度将表现出相当糟糕的性能。如果你知道所需的最大大小,你可以预留足够的初始大小来克服这个问题。然而,并不是在所有情况下你都需要使用向量。当然,向量支持动态大小和随机访问,在某些情况下具有性能优势,但你可能真的不需要随机访问,这种情况下列表、双端队列或其他一些容器可能更适合你。

列表

列表 STL 容器在内部使用双向链表数据结构。因此,列表只支持顺序访问,在最坏的情况下,在列表中搜索随机值可能需要O(N)的运行时复杂度。然而,如果你确定只需要顺序访问,列表确实提供了自己的好处。列表 STL 容器允许你以常数时间复杂度在最佳、平均和最坏的情况下在末尾、前面或中间插入数据元素,即O(1)的运行时复杂度。

以下图片展示了列表 STL 使用的内部数据结构:

让我们编写一个简单的程序,亲身体验使用列表 STL:

#include <iostream>
#include <list>
#include <iterator>
#include <algorithm>
using namespace std;

int main () {

  list<int> l;

  for (int count=0; count<5; ++count)
    l.push_back( (count+1) * 100 );

  auto pos = l.begin();

  cout << "\nPrint the list ..." << endl;
  while ( pos != l.end() )
    cout << *pos++ << "-->";
  cout << " X" << endl;

  return 0;
}

我相信到现在为止你已经对 C++ STL 有了一些了解,它的优雅和强大。观察到语法在所有 STL 容器中保持不变是不是很酷?你可能已经注意到,无论你使用数组、向量还是列表,语法都保持不变。相信我,当你探索其他 STL 容器时,你会得到同样的印象。

话虽如此,前面的代码是不言自明的,因为我们在其他容器中做了差不多的事情。

让我们尝试对列表进行排序,如下所示的代码:

#include <iostream>
#include <list>
#include <iterator>
#include <algorithm>
using namespace std;

int main () {

    list<int> l = { 100, 20, 80, 50, 60, 5 };

    auto pos = l.begin();

    cout << "\nPrint the list before sorting ..." << endl;
    copy ( l.begin(), l.end(), ostream_iterator<int>( cout, "-->" ));
    cout << "X" << endl;

    l.sort();

    cout << "\nPrint the list after sorting ..." << endl;
    copy ( l.begin(), l.end(), ostream_iterator<int>( cout, "-->" ));
    cout << "X" << endl; 

    return 0;
}

你注意到了sort()方法吗?是的,列表容器有自己的排序算法。列表容器支持自己版本的排序算法的原因是,通用的sort()算法需要一个随机访问迭代器,而列表容器不支持随机访问。在这种情况下,相应的容器将提供自己的高效算法来克服这个缺点。

有趣的是,列表支持的sort算法的运行时复杂度是O(N log2 N)

列表中常用的 API

以下表格显示了 STL 列表的最常用 API:

API 描述
front() 返回列表中存储的第一个值
back()  返回列表中存储的最后一个值
size() 返回列表中存储的值的数量
empty() 当列表为空时返回true,否则返回false
clear() 清除列表中存储的所有值
push_back<data_type>( value ) 这在列表末尾添加一个值
push_front<data_type>( value ) 在列表的前面添加一个值
merge( list ) 这将两个相同类型值的排序列表合并
reverse() 这将列表反转
unique() 从列表中删除重复的值
sort() 这对存储在列表中的值进行排序

前向列表

STL 的forward_list容器建立在单向链表数据结构之上;因此,它只支持向前导航。由于forward_list在内存和运行时方面每个节点消耗一个更少的指针,因此与列表容器相比,它被认为更有效。然而,作为性能优势的额外边缘的代价,forward_list不得不放弃一些功能。

以下图表显示了forward_list中使用的内部数据结构:

让我们来探索以下示例代码:

#include <iostream>
#include <forward_list>
#include <iterator>
#include <algorithm>
using namespace std;

int main ( ) {

  forward_list<int> l = { 10, 10, 20, 30, 45, 45, 50 };

  cout << "\nlist with all values ..." << endl;
  copy ( l.begin(), l.end(), ostream_iterator<int>(cout, "\t") );

  cout << "\nSize of list with duplicates is " << distance( l.begin(), l.end() ) << endl;

  l.unique();

  cout << "\nSize of list without duplicates is " << distance( l.begin(), l.end() ) << endl;

  l.resize( distance( l.begin(), l.end() ) );

  cout << "\nlist after removing duplicates ..." << endl;
  copy ( l.begin(), l.end(), ostream_iterator<int>(cout, "\t") );
  cout << endl;

  return 0;

}

可以使用以下命令查看输出:

./a.out

输出如下:

list with all values ...
10    10    20    30    45    45    50
Size of list with duplicates is 7

Size of list without duplicates is 5

list after removing duplicates ...
10    20   30   45   50

代码演示

以下代码声明并初始化了forward_list容器,其中包含一些唯一值和一些重复值:

forward_list<int> l = { 10, 10, 20, 30, 45, 45, 50 };

由于forward_list容器不支持size()函数,我们使用distance()函数来找到列表的大小:

cout << "\nSize of list with duplicates is " << distance( l.begin(), l.end() ) << endl;

以下forward_list<int>::unique()函数删除重复的整数,保留唯一的值:

l.unique();

forward_list容器中常用的 API

下表显示了常用的forward_list API:

API 描述
front() 返回forward_list容器中存储的第一个值
empty() forward_list容器为空时返回 true,否则返回 false
clear() 清除forward_list中存储的所有值
push_front<data_type>( value ) forward_list的前面添加一个值
merge( list ) 将两个排序的forward_list容器合并为相同类型的值
reverse() 反转forward_list容器
unique() forward_list容器中删除重复的值
sort() forward_list中存储的值进行排序

让我们再探索一个例子,以更好地理解forward_list容器:

#include <iostream>
#include <forward_list>
#include <iterator>
#include <algorithm>
using namespace std;

int main () {

    forward_list<int> list1 = { 10, 20, 10, 45, 45, 50, 25 };
    forward_list<int> list2 = { 20, 35, 27, 15, 100, 85, 12, 15 };

    cout << "\nFirst list before sorting ..." << endl;
    copy ( list1.begin(), list1.end(), ostream_iterator<int>(cout, "\t") );
    cout << endl; 

    cout << "\nSecond list before sorting ..." << endl;
    copy ( list2.begin(), list2.end(), ostream_iterator<int>(cout, "\t") );
    cout << endl;

    list1.sort();
    list2.sort();

    cout << "\nFirst list after sorting ..." << endl;
    copy ( list1.begin(), list1.end(), ostream_iterator<int>(cout, "\t") );
    cout << endl; 

    cout << "\nSecond list after sorting ..." << endl;
    copy ( list2.begin(), list2.end(), ostream_iterator<int>(cout, "\t") );
    cout << endl;    

    list1.merge ( list2 );

    cout << "\nMerged list ..." << endl;
    copy ( list1.begin(), list1.end(), ostream_iterator<int>(cout, "\t") );

    cout << "\nMerged list after removing duplicates ..." << endl;
    list1.unique(); 
    copy ( list1.begin(), list1.end(), ostream_iterator<int>(cout, "\t") );

    return 0;
}

前面的代码片段是一个有趣的例子,演示了sort()merge()unique() STL 算法的实际用法。

可以使用以下命令查看输出:

./a.out

程序的输出如下:

First list before sorting ...
10   20   10   45   45   50   25
Second list before sorting ...
20   35   27   15   100  85   12   15

First list after sorting ...
10   10   20   25   45   45   50
Second list after sorting ...
12   15   15   20   27   35   85   100

Merged list ...
10   10   12   15   15   20   20   25   27   35   45   45  50   85  100
Merged list after removing duplicates ...
10   12   15   20   25   27   35   45   50   85  100

输出和程序都很容易理解。

Deque

deque 容器是一个双端队列,其使用的数据结构可以是动态数组或向量。在 deque 中,可以在前面和后面插入元素,时间复杂度为O(1),而在向量中,插入元素在后面的时间复杂度为O(1),而在前面的时间复杂度为O(N)。deque 不会遭受向量遭受的重新分配问题。然而,deque 具有向量的所有优点,除了在性能方面稍微优于向量,因为每一行都有几行动态数组或向量。

以下图表显示了 deque 容器中使用的内部数据结构:

让我们编写一个简单的程序来尝试 deque 容器:

#include <iostream>
#include <deque>
#include <algorithm>
#include <iterator>
using namespace std;

int main () {
  deque<int> d = { 10, 20, 30, 40, 50 };

  cout << "\nInitial size of deque is " << d.size() << endl;

  d.push_back( 60 );
  d.push_front( 5 );

  cout << "\nSize of deque after push back and front is " << d.size() << endl;

  copy ( d.begin(), d.end(), ostream_iterator<int>( cout, "\t" ) );
  d.clear();

  cout << "\nSize of deque after clearing all values is " << d.size() <<
endl;

  cout << "\nIs the deque empty after clearing values ? " << ( d.empty()
? "true" : "false" ) << endl;

return 0;
}

可以使用以下命令查看输出:

./a.out

程序的输出如下:

Intitial size of deque is 5

Size of deque after push back and front is 7

Print the deque ...
5  10  20  30  40  50  60
Size of deque after clearing all values is 0

Is the deque empty after clearing values ? true

deque 中常用的 API

下表显示了常用的 deque API:

API 描述
at ( int index ) 返回索引位置存储的值。如果索引无效,则抛出std::out_of_range异常。
operator [ int index ] 返回索引位置存储的值。这个函数比at( int index )更快,因为它不执行边界检查。
front() 返回 deque 中存储的第一个值。
back()  返回 deque 中存储的最后一个值。
empty() 如果 deque 为空则返回true,否则返回false
size()  返回 deque 中存储的值的数量。
capacity() 返回 deque 的总容量,而size()返回 deque 中实际存储的值的数量。
clear()  清除所有的值。
push_back<data_type>( value ) 在 deque 的末尾添加一个新值。

关联容器

关联容器以有序方式存储数据,与序列容器不同。因此,关联容器不会保留插入数据的顺序。关联容器在搜索值时非常高效,具有O(log n)的运行时复杂度。每次向容器添加新值时,如果需要,容器将重新排序内部存储的值。

STL 支持以下类型的关联容器:

  • 集合

  • 映射

  • 多重集合

  • 多重映射

  • 无序集合

  • 无序多重集合

  • 无序映射

  • 无序多重映射

关联容器将数据组织为键值对。数据将根据键进行排序,以实现随机和更快的访问。关联容器有两种类型:

  • 有序

  • 无序

以下关联容器属于有序容器,因为它们按特定顺序/排序。有序关联容器通常使用某种形式的二叉搜索树BST);通常使用红黑树来存储数据:

  • 集合

  • 映射

  • 多重集合

  • 多重映射

以下关联容器属于无序容器,因为它们没有按特定顺序排序,并且它们使用哈希表:

  • 无序集合

  • 无序映射

  • 无序多重集合

  • 无序多重映射

让我们在以下子节中通过示例了解先前提到的容器。

集合

集合容器仅以有序方式存储唯一值。集合使用值作为键来组织值。集合容器是不可变的,也就是说,存储在集合中的值不能被修改;但是可以删除值。集合通常使用红黑树数据结构,这是一种平衡二叉搜索树。集合操作的时间复杂度保证为O(log N)

让我们使用一个集合编写一个简单的程序:

#include <iostream>
#include <set>
#include <vector>
#include <iterator>
#include <algorithm>
using namespace std;

int main( ) {
    set<int> s1 = { 1, 3, 5, 7, 9 };
    set<int> s2 = { 2, 3, 7, 8, 10 };

    vector<int> v( s1.size() + s2.size() );

    cout << "\nFirst set values are ..." << endl;
    copy ( s1.begin(), s1.end(), ostream_iterator<int> ( cout, "\t" ) );
    cout << endl;

    cout << "\nSecond set values are ..." << endl;
    copy ( s2.begin(), s2.end(), ostream_iterator<int> ( cout, "\t" ) );
    cout << endl;

    auto pos = set_difference ( s1.begin(), s1.end(), s2.begin(), s2.end(), v.begin() ); 
    v.resize ( pos - v.begin() );

    cout << "\nValues present in set one but not in set two are ..." << endl;
    copy ( v.begin(), v.end(), ostream_iterator<int> ( cout, "\t" ) );
    cout << endl; 

    v.clear();

    v.resize ( s1.size() + s2.size() );

    pos = set_union ( s1.begin(), s1.end(), s2.begin(), s2.end(), v.begin() );

    v.resize ( pos - v.begin() );

    cout << "\nMerged set values in vector are ..." << endl;
    copy ( v.begin(), v.end(), ostream_iterator<int> ( cout, "\t" ) );
    cout << endl; 

    return 0;
}

可以使用以下命令查看输出:

./a.out

程序的输出如下:

First set values are ...
1   3   5   7   9

Second set values are ...
2   3   7   8   10

Values present in set one but not in set two are ...
1   5   9

Merged values of first and second set are ...
1   2   3   5   7   8   9  10

代码演示

以下代码声明并初始化两个集合s1s2

set<int> s1 = { 1, 3, 5, 7, 9 };
set<int> s2 = { 2, 3, 7, 8, 10 };

以下行将确保向量有足够的空间来存储结果向量中的值:

vector<int> v( s1.size() + s2.size() );

以下代码将打印s1s2中的值:

cout << "\nFirst set values are ..." << endl;
copy ( s1.begin(), s1.end(), ostream_iterator<int> ( cout, "\t" ) );
cout << endl;

cout << "\nSecond set values are ..." << endl;
copy ( s2.begin(), s2.end(), ostream_iterator<int> ( cout, "\t" ) );
cout << endl;

set_difference()算法将向量v填充为仅存在于集合s1中而不在s2中的值。迭代器pos将指向向量中的最后一个元素;因此,向量resize将确保向量中的额外空间被移除:

auto pos = set_difference ( s1.begin(), s1.end(), s2.begin(), s2.end(), v.begin() ); 
v.resize ( pos - v.begin() );

以下代码将打印填充到向量v中的值:

cout << "\nValues present in set one but not in set two are ..." << endl;
copy ( v.begin(), v.end(), ostream_iterator<int> ( cout, "\t" ) );
cout << endl;

set_union()算法将合并集合s1s2的内容到向量中,然后向量被调整大小以适应合并后的值:

pos = set_union ( s1.begin(), s1.end(), s2.begin(), s2.end(), v.begin() );
v.resize ( pos - v.begin() );

以下代码将打印填充到向量v中的合并值:

cout << "\nMerged values of first and second set are ..." << endl;
copy ( v.begin(), v.end(), ostream_iterator<int> ( cout, "\t" ) );
cout << endl;

集合中常用的 API

以下表格描述了常用的集合 API:

API 描述
insert( value ) 这将值插入到集合中
clear() 这将清除集合中的所有值
size() 这将返回集合中存在的条目总数
empty() 如果集合为空,则打印true,否则返回false
find() 这将查找具有指定键的元素并返回迭代器位置
equal_range() 这将返回与特定键匹配的元素范围
lower_bound() 这将返回指向第一个不小于给定键的元素的迭代器
upper_bound() 这将返回指向第一个大于给定键的元素的迭代器

映射

映射按键组织值。与集合不同,映射每个值都有一个专用键。映射通常使用红黑树作为内部数据结构,这是一种平衡的 BST,可以保证在映射中搜索或定位值的O(log N)运行效率。映射中存储的值根据键使用红黑树进行排序。映射中使用的键必须是唯一的。映射不会保留输入的顺序,因为它根据键重新组织值,也就是说,红黑树将被旋转以平衡红黑树高度。

让我们编写一个简单的程序来理解映射的用法:

#include <iostream>
#include <map>
#include <iterator>
#include <algorithm>
using namespace std;

int main ( ) {

  map<string, long> contacts;

  contacts["Jegan"] = 123456789;
  contacts["Meena"] = 523456289;
  contacts["Nitesh"] = 623856729;
  contacts["Sriram"] = 993456789;

  auto pos = contacts.find( "Sriram" );

  if ( pos != contacts.end() )
    cout << pos->second << endl;

  return 0;
}

让我们编译并检查程序的输出:

g++ main.cpp -std=c++17
./a.out

输出如下:

Mobile number of Sriram is 8901122334

代码漫步

以下行声明了一个映射,其中string名称作为键,long移动号码作为映射中存储的值:

map< string, long > contacts;

以下代码片段按名称添加了四个联系人:

 contacts[ "Jegan" ] = 1234567890;
 contacts[ "Meena" ] = 5784433221;
 contacts[ "Nitesh" ] = 4567891234;
 contacts[ "Sriram" ] = 8901122334;

以下行将尝试在联系人映射中定位名称为Sriram的联系人;如果找到Sriram,则find()函数将返回指向键值对位置的迭代器;否则返回contacts.end()位置:

 auto pos = contacts.find( "Sriram" );

以下代码验证了迭代器pos是否已经到达contacts.end()并打印联系人号码。由于映射是一个关联容器,它存储一个key=>value对;因此,pos->first表示键,pos->second表示值:

 if ( pos != contacts.end() )
 cout << "\nMobile number of " << pos->first << " is " << pos->second << endl;
 else
 cout << "\nContact not found." << endl;

映射中常用的 API

以下表显示了常用的映射 API:

API 描述
at ( key ) 如果找到键,则返回相应键的值;否则抛出std::out_of_range异常
operator[ key ] 如果找到键,则更新相应键的现有值;否则,将添加一个具有提供的key=>value的新条目
empty() 如果映射为空,则返回true,否则返回false
size() 返回映射中存储的key=>value对的数量
clear() 清除映射中存储的条目
count() 返回与给定键匹配的元素数
find() 查找具有指定键的元素

Multiset

多重集合容器的工作方式与集合容器类似,只是集合只允许存储唯一值,而多重集合允许存储重复值。在集合和多重集合容器的情况下,值本身被用作键来组织数据。多重集合容器就像集合一样;它不允许修改多重集合中存储的值。

让我们使用多重集合编写一个简单的程序:

#include <iostream>
#include <set>
#include <iterator>
#include <algorithm>
using namespace std;

int main() {
  multiset<int> s = { 10, 30, 10, 50, 70, 90 };

  cout << "\nMultiset values are ..." << endl;

  copy ( s.begin(), s.end(), ostream_iterator<int> ( cout, "\t" ) );
  cout << endl;

  return 0;
}

可以使用以下命令查看输出:

./a.out

程序的输出如下:

Multiset values are ...
10 30 10 50 70 90

有趣的是,在前面的输出中,您可以看到 multiset 包含重复的值。

Multimap

多重映射的工作方式与映射完全相同,只是多重映射容器允许使用相同的键存储多个值。

让我们用一个简单的例子来探索 multimap 容器:

#include <iostream>
#include <map>
#include <vector>
#include <iterator>
#include <algorithm>
using namespace std;

int main() {
  multimap< string, long > contacts = {
    { "Jegan", 2232342343 },
    { "Meena", 3243435343 },
    { "Nitesh", 6234324343 },
    { "Sriram", 8932443241 },
    { "Nitesh", 5534327346 }
  };

  auto pos = contacts.find ( "Nitesh" );
  int count = contacts.count( "Nitesh" );
  int index = 0;

  while ( pos != contacts.end() ) {
    cout << "\nMobile number of " << pos->first << " is " <<
    pos->second << endl;
    ++index;
    if ( index == count )
      break;
  }

  return 0;
}

可以编译程序并使用以下命令查看输出:

g++ main.cpp -std=c++17

./a.out

程序的输出如下:

Mobile number of Nitesh is 6234324343
Mobile number of Nitesh is 5534327346

无序集合

无序集合的工作方式类似于集合,只是这些容器的内部行为不同。集合使用红黑树,而无序集合使用哈希表。集合操作的时间复杂度为O(log N),而无序集合操作的时间复杂度为O(1);因此,无序集合往往比集合更快。

无序集合中存储的值没有特定的顺序,不像集合那样以排序的方式存储值。如果性能是标准,那么无序集合是一个不错的选择;然而,如果需要以排序的方式迭代值,那么集合是一个不错的选择。

无序映射

无序映射的工作方式类似于映射,只是这些容器的内部行为不同。映射使用红黑树,而无序映射使用哈希表。映射操作的时间复杂度为O(log N),而无序映射操作的时间复杂度为O(1);因此,无序映射比映射更快。

无序映射中存储的值没有特定的顺序,不像映射中的值按键排序。

无序多重集

无序多重集的工作方式类似于多重集,只是这些容器的内部行为不同。多重集使用红黑树,而无序多重集使用哈希表。多重集操作的时间复杂度为O(log N),而无序多重集操作的时间复杂度为O(1)。因此,无序多重集比多重集更快。

无序多重集中存储的值没有特定的顺序,不像多重集中的值以排序的方式存储。如果性能是标准,无序多重集是一个不错的选择;然而,如果需要以排序的方式迭代值,那么多重集是一个不错的选择。

无序多重映射

无序多重映射的工作方式类似于多重映射,只是这些容器的内部行为不同。多重映射使用红黑树,而无序多重映射使用哈希表。多重映射操作的时间复杂度为O(log N),而无序多重映射操作的时间复杂度为O(1);因此,无序多重映射比多重映射更快。

无序多重映射中存储的值没有特定的顺序,不像多重映射中的值按键排序。如果性能是标准,那么无序多重映射是一个不错的选择;然而,如果需要以排序的方式迭代值,那么多重映射是一个不错的选择。

容器适配器

容器适配器将现有容器适配为新容器。简单来说,STL 扩展是通过组合而不是继承完成的。

STL 容器不能通过继承进行扩展,因为它们的构造函数不是虚拟的。在整个 STL 中,您可以观察到静态多态性在操作符重载和模板方面都得到了使用,而出于性能原因,动态多态性被有意地避免使用。因此,通过对现有容器进行子类化来扩展 STL 不是一个好主意,因为这会导致内存泄漏,因为容器类不是设计成像基类一样行为的。

STL 支持以下容器适配器:

  • 队列

  • 优先队列

让我们在以下小节中探索容器适配器。

栈不是一个新的容器;它是一个模板适配器类。适配器容器包装现有容器并提供高级功能。栈适配器容器提供栈操作,同时隐藏对栈不相关的不必要功能。STL 栈默认使用 deque 容器;然而,我们可以在栈实例化期间指示栈使用满足栈要求的任何现有容器。

双端队列、列表和向量满足栈适配器的要求。

栈遵循后进先出LIFO)的原则。

栈中常用的 API

以下表格显示了常用的栈 API:

API 描述
top() 返回栈中的最顶部值,即最后添加的值
push<data_type>( value ) 这将把提供的值推到栈顶
pop() 这将从栈中移除最顶部的值
size() 返回栈中存在的值的数量
empty() 如果栈为空则返回true;否则返回false

现在是时候动手写一个简单的程序来使用栈了:

#include <iostream>
#include <stack>
#include <iterator>
#include <algorithm>
using namespace std;

int main ( ) {

  stack<string> spoken_languages;

  spoken_languages.push ( "French" );
  spoken_languages.push ( "German" );
  spoken_languages.push ( "English" );
  spoken_languages.push ( "Hindi" );
  spoken_languages.push ( "Sanskrit" );
  spoken_languages.push ( "Tamil" );

  cout << "\nValues in Stack are ..." << endl;
  while ( ! spoken_languages.empty() ) {
              cout << spoken_languages.top() << endl;
        spoken_languages.pop();
  }
  cout << endl;

  return 0;
}

程序可以通过以下命令编译,并查看输出:

g++ main.cpp -std=c++17

./a.out

程序的输出如下:

Values in Stack are ...
Tamil
Kannada
Telugu
Sanskrit
Hindi
English
German
French

从前面的输出中,我们可以看到栈的 LIFO 行为。

队列

队列基于先进先出FIFO)原则工作。队列不是一个新的容器;它是一个模板化的适配器类,包装现有容器并提供队列操作所需的高级功能,同时隐藏对队列无关的不必要功能。STL 队列默认使用 deque 容器;但是,在队列实例化期间,我们可以指示队列使用满足队列要求的任何现有容器。

在队列中,新值可以添加到后面,并从前面移除。双端队列、列表和向量满足队列适配器的要求。

队列中常用的 API

以下表格显示了常用的队列 API:

API 描述
push() 在队列的后面添加一个新值
pop() 移除队列前面的值
front() 返回队列前面的值
back() 返回队列的后面的值
empty() 当队列为空时返回true;否则返回false
size() 返回队列中存储的值的数量

让我们在以下程序中使用队列:

#include <iostream>
#include <queue>
#include <iterator>
#include <algorithm>
using namespace std;

int main () {
  queue<int> q;

  q.push ( 100 );
  q.push ( 200 );
  q.push ( 300 );

  cout << "\nValues in Queue are ..." << endl;
  while ( ! q.empty() ) {
    cout << q.front() << endl;
    q.pop();
  }

  return 0;
}

程序可以通过以下命令编译,并查看输出:

g++ main.cpp -std=c++17

./a.out

程序的输出如下:

Values in Queue are ...
100
200
300

从前面的输出中,您可以观察到值以与它们被推入的相同顺序弹出,即 FIFO。

优先队列

优先队列不是一个新的容器;它是一个模板化的适配器类,包装现有容器并提供优先队列操作所需的高级功能,同时隐藏对优先队列无关的不必要功能。优先队列默认使用 vector 容器;但是,deque 容器也满足优先队列的要求。因此,在优先队列实例化期间,您可以指示优先队列也使用 deque。

优先队列以使最高优先级值首先出现的方式组织数据;换句话说,值按降序排序。

deque 和 vector 满足优先队列适配器的要求。

优先队列中常用的 API

以下表格显示了常用的优先队列 API:

API 描述
push() 在优先队列的后面添加一个新值
pop() 移除优先队列前面的值
empty() 当优先队列为空时返回true;否则返回false
size() 返回优先队列中存储的值的数量
top() 返回优先队列前面的值

让我们编写一个简单的程序来理解priority_queue

#include <iostream>
#include <queue>
#include <iterator>
#include <algorithm>
using namespace std;

int main () {
  priority_queue<int> q;

  q.push( 100 );
  q.push( 50 );
  q.push( 1000 );
  q.push( 800 );
  q.push( 300 );

  cout << "\nSequence in which value are inserted are ..." << endl;
  cout << "100\t50\t1000\t800\t300" << endl;
  cout << "Priority queue values are ..." << endl;

  while ( ! q.empty() ) {
    cout << q.top() << "\t";
    q.pop();
  }
  cout << endl;

  return 0;
}

程序可以通过以下命令编译,并查看输出:

g++ main.cpp -std=c++17

./a.out

程序的输出如下:

Sequence in which value are inserted are ...
100   50   1000  800   300

Priority queue values are ...
1000  800   300   100   50

从前面的输出中,您可以观察到priority_queue是一种特殊类型的队列,它重新排列输入,使最高值首先出现。

总结

在本章中,您学习了现成的通用容器、函数对象、迭代器和算法。您还学习了集合、映射、多重集和多重映射关联容器,它们的内部数据结构,以及可以应用于它们的常见算法。此外,您还学习了如何使用各种容器,并进行了实际的代码示例。

在下一章中,您将学习模板编程,这将帮助您掌握模板的基本知识。

第三章:模板编程

在本章中,我们将涵盖以下主题:

  • 泛型编程

  • 函数模板

  • 类模板

  • 函数模板重载

  • 泛型类

  • 显式类特化

  • 部分特化

现在让我们开始学习泛型编程。

泛型编程

泛型编程是一种编程风格,可以帮助您开发可重用的代码或通用算法,可应用于各种数据类型。每当调用通用算法时,数据类型将以特殊语法作为参数提供。

假设我们想要编写一个sort()函数,它接受一个需要按升序排序的输入数组。其次,我们需要sort()函数来对intdoublecharstring数据类型进行排序。有几种方法可以解决这个问题:

  • 我们可以为每种数据类型编写四个不同的sort()函数

  • 我们也可以编写一个单一的宏函数

嗯,这两种方法都有各自的优点和缺点。第一种方法的优点是,由于为intdoublecharstring数据类型专门有函数,如果提供了不正确的数据类型,编译器将能够执行类型检查。第一种方法的缺点是,尽管所有函数的逻辑保持不变,但我们必须编写四个不同的函数。如果在算法中发现了错误,必须分别在所有四个函数中进行修复;因此,需要进行大量的维护工作。如果我们需要支持另一种数据类型,我们将不得不编写另一个函数,并且随着需要支持更多数据类型,这种情况将不断增加。

第二种方法的优点是我们可以为所有数据类型编写一个宏。然而,一个非常令人沮丧的缺点是编译器将无法执行类型检查,这种方法更容易出现错误,并可能引发许多意外的麻烦。这种方法与面向对象编码原则背道而驰。

C++支持使用模板进行泛型编程,具有以下好处:

  • 我们只需要使用模板编写一个函数

  • 模板支持静态多态

  • 模板提供了前述两种方法的所有优点,没有任何缺点

  • 泛型编程实现了代码重用

  • 结果代码是面向对象的

  • C++编译器可以在编译时执行类型检查

  • 易于维护

  • 支持各种内置和用户定义的数据类型

然而,缺点如下:

  • 并非所有 C++程序员都感到舒适编写基于模板的编码,但这只是最初的阻碍

  • 在某些情况下,模板可能会使您的代码膨胀并增加二进制占用空间,导致性能问题

函数模板

函数模板允许您将数据类型参数化。之所以称之为泛型编程,是因为单个模板函数将支持许多内置和用户定义的数据类型。模板化函数的工作方式类似于C 风格宏,只是 C++编译器在我们在调用模板函数时提供不兼容的数据类型时会对函数进行类型检查。

通过一个简单的示例来理解模板概念会更容易,如下所示:

#include <iostream>
#include <algorithm>
#include <iterator>
using namespace std;

template <typename T, int size>
void sort ( T input[] ) {

     for ( int i=0; i<size; ++i) { 
         for (int j=0; j<size; ++j) {
              if ( input[i] < input[j] )
                  swap (input[i], input[j] );
         }
     }

}

int main () {
        int a[10] = { 100, 10, 40, 20, 60, 80, 5, 50, 30, 25 };

        cout << "\nValues in the int array before sorting ..." << endl;
        copy ( a, a+10, ostream_iterator<int>( cout, "\t" ) );
        cout << endl;

        ::sort<int, 10>( a );

        cout << "\nValues in the int array after sorting ..." << endl;
        copy ( a, a+10, ostream_iterator<int>( cout, "\t" ) );
        cout << endl;

        double b[5] = { 85.6d, 76.13d, 0.012d, 1.57d, 2.56d };

        cout << "\nValues in the double array before sorting ..." << endl;
        copy ( b, b+5, ostream_iterator<double>( cout, "\t" ) );
        cout << endl;

        ::sort<double, 5>( b );

        cout << "\nValues in the double array after sorting ..." << endl;
        copy ( b, b+5, ostream_iterator<double>( cout, "\t" ) );
        cout << endl;

        string names[6] = {
               "Rishi Kumar Sahay",
               "Arun KR",
               "Arun CR",
               "Ninad",
               "Pankaj",
               "Nikita"
        };

        cout << "\nNames before sorting ..." << endl;
        copy ( names, names+6, ostream_iterator<string>( cout, "\n" ) );
        cout << endl;

        ::sort<string, 6>( names );

        cout << "\nNames after sorting ..." << endl;
        copy ( names, names+6, ostream_iterator<string>( cout, "\n" ) );
        cout << endl;

        return 0;
}

运行以下命令:

g++ main.cpp -std=c++17
./a.out

上述程序的输出如下:

Values in the int array before sorting ...
100  10   40   20   60   80   5   50   30   25

Values in the int array after sorting ...
5    10   20   25   30   40   50   60   80   100

Values in the double array before sorting ...
85.6d 76.13d 0.012d 1.57d 2.56d

Values in the double array after sorting ...
0.012   1.57   2.56   76.13   85.6

Names before sorting ...
Rishi Kumar Sahay
Arun KR
Arun CR
Ninad
Pankaj
Nikita

Names after sorting ...
Arun CR
Arun KR
Nikita
Ninad
Pankaj
Rich Kumar Sahay

看到一个模板函数就能完成所有魔术,是不是很有趣?是的,这就是 C++模板的酷之处!

你是否好奇看到模板实例化的汇编输出?使用命令g++ -S main.cpp

代码演示

以下代码定义了一个函数模板。关键字template <typename T, int size>告诉编译器接下来是一个函数模板:

template <typename T, int size>
void sort ( T input[] ) {

 for ( int i=0; i<size; ++i) { 
     for (int j=0; j<size; ++j) {
         if ( input[i] < input[j] )
             swap (input[i], input[j] );
     }
 }

}

void sort ( T input[] )这一行定义了一个名为sort的函数,它返回void并接收类型为T的输入数组。T类型并不表示任何特定的数据类型。T将在编译时实例化函数模板的时候推断出来。

以下代码用一些未排序的值填充一个整数数组,并将其打印到终端上:

 int a[10] = { 100, 10, 40, 20, 60, 80, 5, 50, 30, 25 };
 cout << "\nValues in the int array before sorting ..." << endl;
 copy ( a, a+10, ostream_iterator<int>( cout, "\t" ) );
 cout << endl;

以下一行将为int数据类型实例化一个函数模板的实例。在这一点上,typename T被替换,为int数据类型创建了一个专门的函数。在sort前面的作用域解析运算符,即::sort(),确保它调用我们自定义的sort()函数,该函数定义在全局命名空间中;否则,C++编译器将尝试调用std namespace中定义的sort()算法,或者如果存在这样的函数,则来自任何其他命名空间。<int, 10>变量告诉编译器创建一个函数的实例,用int替换typename T10表示模板函数中使用的数组的大小:

::sort<int, 10>( a );

以下行将实例化另外两个支持5个元素的double数组和6个元素的string数组的实例:

::sort<double, 5>( b );
::sort<string, 6>( names );

如果您想了解有关 C++编译器如何实例化函数模板以支持intdoublestring的更多细节,可以尝试 Unix 实用程序nmc++filtnm Unix 实用程序将列出符号表中的符号,如下所示:

nm ./a.out | grep sort

00000000000017f1 W _Z4sortIdLi5EEvPT_
0000000000001651 W _Z4sortIiLi10EEvPT_
000000000000199b W _Z4sortINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEELi6EEvPT_

正如您所看到的,在二进制文件中有三个不同的重载sort函数;然而,我们只定义了一个模板函数。由于 C++编译器对函数重载进行了名称混编,我们很难解释这三个函数中的哪一个是为intdoublestring数据类型准备的。

然而,有一个线索:第一个函数是为double准备的,第二个是为int准备的,第三个是为string准备的。名称混编函数对于double_Z4sortIdLi5EEvPT_,对于int_Z4sortIiLi10EEvPT_,对于string_Z4sortINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEELi6EEvPT_。还有另一个很酷的 Unix 实用程序,可以帮助您轻松解释函数签名。检查c++filt实用程序的以下输出:

c++filt _Z4sortIdLi5EEvPT_
void sort<double, 5>(double*)

c++filt _Z4sortIiLi10EEvPT_
void sort<int, 10>(int*)

c++filt _Z4sortINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEELi6EEvPT_
void sort<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, 6>(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >*)

希望您在使用 C++模板时会发现这些实用程序有用。我相信这些工具和技术将帮助您调试任何 C++应用程序。

重载函数模板

重载函数模板的工作方式与 C++中的常规函数重载完全相同。不过,我将帮助您回顾一下 C++函数重载的基础知识。

C++编译器对重载函数的规则和期望如下:

  • 重载函数的名称将是相同的。

  • C++编译器将无法区分仅通过返回值不同的重载函数。

  • 重载函数参数的数量、这些参数的数据类型或它们的顺序应该是不同的。除了其他规则之外,当前项目符号表中描述的这些规则中至少应满足一个,但更多的符合也不会有害。

  • 重载函数必须在相同的命名空间或相同的类作用域内。

如果这些前述规则中的任何一个没有得到满足,C++编译器将不会将它们视为重载函数。如果在区分重载函数方面存在任何歧义,C++编译器将立即报告为编译错误。

现在是时候通过下面的示例来探索一下了:

#include <iostream>
#include <array>
using namespace std;

void sort ( array<int,6> data ) {

     cout << "Non-template sort function invoked ..." << endl;

     int size = data.size();

     for ( int i=0; i<size; ++i ) { 
          for ( int j=0; j<size; ++j ) {
                if ( data[i] < data[j] )
                    swap ( data[i], data[j] );
          }
     }

}

template <typename T, int size>
void sort ( array<T, size> data ) {

     cout << "Template sort function invoked with one argument..." << endl;

     for ( int i=0; i<size; ++i ) {
         for ( int j=0; j<size; ++j ) {
             if ( data[i] < data[j] )
                swap ( data[i], data[j] );
         }
     }

}

template <typename T>
void sort ( T data[], int size ) {
     cout << "Template sort function invoked with two arguments..." << endl;

     for ( int i=0; i<size; ++i ) {
         for ( int j=0; j<size; ++j ) {
             if ( data[i] < data[j] )
                swap ( data[i], data[j] );
         }
     }

}

int main() {

    //Will invoke the non-template sort function
    array<int, 6> a = { 10, 50, 40, 30, 60, 20 };
    ::sort ( a );

    //Will invoke the template function that takes a single argument
    array<float,6> b = { 10.6f, 57.9f, 80.7f, 35.1f, 69.3f, 20.0f };
    ::sort<float,6>( b );

    //Will invoke the template function that takes a single argument
    array<double,6> c = { 10.6d, 57.9d, 80.7d, 35.1d, 69.3d, 20.0d };
    ::sort<double,6> ( c );

    //Will invoke the template function that takes two arguments
    double d[] = { 10.5d, 12.1d, 5.56d, 1.31d, 81.5d, 12.86d };
    ::sort<double> ( d, 6 );

    return 0;

}

运行以下命令:

g++ main.cpp -std=c++17

./a.out

前述程序的输出如下:

Non-template sort function invoked ...

Template sort function invoked with one argument...

Template sort function invoked with one argument...

Template sort function invoked with two arguments...

代码演示

以下代码是我们自定义的sort()函数的非模板版本:

void sort ( array<int,6> data ) { 

     cout << "Non-template sort function invoked ..." << endl;

     int size = data.size();

     for ( int i=0; i<size; ++i ) { 
         for ( int j=0; j<size; ++j ) {
             if ( data[i] < data[j] )
                 swap ( data[i], data[j] );
         }
     }

}

非模板函数和模板函数可以共存并参与函数重载。前面函数的一个奇怪行为是数组的大小是硬编码的。

我们的sort()函数的第二个版本是一个模板函数,如下面的代码片段所示。有趣的是,我们在第一个非模板sort()版本中注意到的奇怪问题在这里得到了解决:

template <typename T, int size>
void sort ( array<T, size> data ) {

     cout << "Template sort function invoked with one argument..." << endl;

     for ( int i=0; i<size; ++i ) {
         for ( int j=0; j<size; ++j ) {
             if ( data[i] < data[j] )
                swap ( data[i], data[j] );
         }
     }

}

在前面的代码中,数组的数据类型和大小都作为模板参数传递,然后传递给函数调用参数。这种方法使函数通用,因为这个函数可以为任何数据类型实例化。

我们自定义的sort()函数的第三个版本也是一个模板函数,如下面的代码片段所示:

template <typename T>
void sort ( T data[], int size ) {

     cout << "Template sort function invoked with two argument..." << endl;

     for ( int i=0; i<size; ++i ) {
         for ( int j=0; j<size; ++j ) {
             if ( data[i] < data[j] )
                swap ( data[i], data[j] );
         }
     }

}

前面的模板函数接受 C 风格的数组;因此,它也期望用户指示其大小。然而,数组的大小可以在函数内计算,但出于演示目的,我需要一个接受两个参数的函数。不建议使用前面的函数,因为它使用了 C 风格的数组;理想情况下,我们应该使用 STL 容器之一。

现在,让我们理解主函数代码。以下代码声明并初始化了包含六个值的 STL 数组容器,然后将其传递给我们在默认命名空间中定义的sort()函数:

 //Will invoke the non-template sort function
 array<int, 6> a = { 10, 50, 40, 30, 60, 20 };
 ::sort ( a );

前面的代码将调用非模板sort()函数。需要注意的一个重要点是,每当 C++遇到函数调用时,它首先寻找非模板版本;如果 C++找到匹配的非模板函数版本,它的搜索正确函数定义的过程就结束了。如果 C++编译器无法识别与函数调用签名匹配的非模板函数定义,那么它开始寻找任何可以支持函数调用的模板函数,并为所需的数据类型实例化一个专门的函数。

让我们理解以下代码:

//Will invoke the template function that takes a single argument
array<float,6> b = { 10.6f, 57.9f, 80.7f, 35.1f, 69.3f, 20.0f };
::sort<float,6>( b );

这将调用接收单个参数的模板函数。由于没有接收array<float,6>数据类型的非模板sort()函数,C++编译器将从我们定义的接收单个参数的sort()模板函数中实例化这样一个函数,该函数接收array<float, 6>

同样,以下代码触发编译器实例化模板sort()函数的double版本,该函数接收array<double, 6>

  //Will invoke the template function that takes a single argument
 array<double,6> c = { 10.6d, 57.9d, 80.7d, 35.1d, 69.3d, 20.0d };
 ::sort<double,6> ( c );

最后,以下代码将实例化模板sort()的一个实例,该实例接收两个参数并调用函数:

 //Will invoke the template function that takes two arguments
 double d[] = { 10.5d, 12.1d, 5.56d, 1.31d, 81.5d, 12.86d };
 ::sort<double> ( d, 6 );

如果您已经走到这一步,我相信您会喜欢迄今为止讨论的 C++模板主题。

类模板

C++模板将函数模板概念扩展到类,使我们能够编写面向对象的通用代码。在前面的部分,您学习了函数模板和重载的用法。在本节中,您将学习编写模板类,从而开启更有趣的通用编程概念。

class模板允许您通过模板类型表达式在类级别上对数据类型进行参数化。

让我们通过以下示例理解一个class模板:

//myalgorithm.h
#include <iostream>
#include <algorithm>
#include <array>
#include <iterator>
using namespace std;

template <typename T, int size>
class MyAlgorithm {

public:
        MyAlgorithm() { } 
        ~MyAlgorithm() { }

        void sort( array<T, size> &data ) {
             for ( int i=0; i<size; ++i ) {
                 for ( int j=0; j<size; ++j ) {
                     if ( data[i] < data[j] )
                         swap ( data[i], data[j] );
                 }
             }
        }

        void sort ( T data[size] );

};

template <typename T, int size>
inline void MyAlgorithm<T, size>::sort ( T data[size] ) {
       for ( int i=0; i<size; ++i ) {
           for ( int j=0; j<size; ++j ) {
               if ( data[i] < data[j] )
                  swap ( data[i], data[j] );
           }
       }
}

C++模板函数重载是静态或编译时多态的一种形式。

让我们在以下main.cpp程序中使用myalgorithm.h如下所示:

#include "myalgorithm.h"

int main() {

    MyAlgorithm<int, 10> algorithm1;

    array<int, 10> a = { 10, 5, 15, 20, 25, 18, 1, 100, 90, 18 };

    cout << "\nArray values before sorting ..." << endl;
    copy ( a.begin(), a.end(), ostream_iterator<int>(cout, "\t") );
    cout << endl;

    algorithm1.sort ( a );

    cout << "\nArray values after sorting ..." << endl;
    copy ( a.begin(), a.end(), ostream_iterator<int>(cout, "\t") );
    cout << endl;

    MyAlgorithm<int, 10> algorithm2;
    double d[] = { 100.0, 20.5, 200.5, 300.8, 186.78, 1.1 };

    cout << "\nArray values before sorting ..." << endl;
    copy ( d.begin(), d.end(), ostream_iterator<double>(cout, "\t") );
    cout << endl;

    algorithm2.sort ( d );

    cout << "\nArray values after sorting ..." << endl;
    copy ( d.begin(), d.end(), ostream_iterator<double>(cout, "\t") );
    cout << endl;

    return 0;  

}

让我们快速使用以下命令编译程序:

g++ main.cpp -std=c++17

./a.out

输出如下:


Array values before sorting ...
10  5   15   20   25   18   1   100   90   18

Array values after sorting ...
1   5   10   15   18   18   20   25   90   100

Array values before sorting ...
100   20.5   200.5   300.8   186.78   1.1

Array values after sorting ...
1.1     20.5   100   186.78  200.5  300.8

代码演示

以下代码声明了一个类模板。关键字template <typename T, int size>可以替换为<class T, int size>。这两个关键字可以在函数和类模板中互换使用;然而,作为行业最佳实践,template<class T>只能用于类模板,以避免混淆。

template <typename T, int size>
class MyAlgorithm 

其中一个重载的sort()方法内联定义如下:

 void sort( array<T, size> &data ) {
      for ( int i=0; i<size; ++i ) {
          for ( int j=0; j<size; ++j ) {
              if ( data[i] < data[j] )
                 swap ( data[i], data[j] );
          }
      }
 } 

第二个重载的sort()函数只是在类范围内声明,没有任何定义,如下所示:

template <typename T, int size>
class MyAlgorithm {
      public:
           void sort ( T data[size] );
};

前面的sort()函数是在类范围之外定义的,如下面的代码片段所示。奇怪的是,我们需要为每个在类模板之外定义的成员函数重复模板参数:

template <typename T, int size>
inline void MyAlgorithm<T, size>::sort ( T data[size] ) {
       for ( int i=0; i<size; ++i ) {
           for ( int j=0; j<size; ++j ) {
               if ( data[i] < data[j] )
                  swap ( data[i], data[j] );
           }
       }
}

否则,类模板的概念与函数模板的概念相同。

你想看看模板的编译器实例化代码吗?使用g++ -fdump-tree-original main.cpp -std=c++17命令。

显式类特化

到目前为止,在本章中,你已经学会了如何使用函数模板和类模板进行泛型编程。当你理解了类模板时,一个模板类可以支持任何内置和用户定义的数据类型。然而,有时我们需要对某些数据类型进行特殊处理。在这种情况下,C++为我们提供了显式类特化支持,以处理具有不同处理方式的选择性数据类型。

考虑 STL deque容器;虽然deque看起来适合存储,比如说,stringintdoublelong,但如果我们决定使用deque来存储一堆boolean类型,bool数据类型至少占用一个字节,而根据编译器供应商的实现可能会有所不同。虽然一个位可以有效地表示真或假,但布尔值至少占用一个字节,即 8 位,剩下的 7 位没有被使用。这可能看起来没问题;然而,如果你必须存储一个非常大的布尔值deque,这显然不是一个高效的想法,对吧?你可能会想,有什么大不了的?我们可以为bool编写另一个专门的类或模板类。但这种方法要求最终用户明确为不同的数据类型使用不同的类,这也不是一个好的设计,对吧?这正是 C++的显式类特化派上用场的地方。

显式模板特化也被称为完全模板特化。

如果你还不相信,没关系;下面的例子将帮助你理解显式类特化的需求以及显式类特化的工作原理。

让我们开发一个DynamicArray类来支持任何数据类型的动态数组。让我们从一个类模板开始,如下面的程序所示:

#include <iostream>
#include <deque>
#include <algorithm>
#include <iterator>
using namespace std;

template < class T >
class DynamicArray {
      private:
           deque< T > dynamicArray;
           typename deque< T >::iterator pos;

      public:
           DynamicArray() { initialize(); }
           ~DynamicArray() { }

           void initialize() {
                 pos = dynamicArray.begin();
           }

           void appendValue( T element ) {
                 dynamicArray.push_back ( element );
           }

           bool hasNextValue() { 
                 return ( pos != dynamicArray.end() );
           }

           T getValue() {
                 return *pos++;
           }

};

前面的DynamicArray模板类内部使用了 STL deque类。因此,你可以将DynamicArray模板类视为自定义适配器容器。让我们看看DynamicArray模板类如何在main.cpp中使用,如下面的代码片段所示:

#include "dynamicarray.h"
#include "dynamicarrayforbool.h"

int main () {

    DynamicArray<int> intArray;

    intArray.appendValue( 100 );
    intArray.appendValue( 200 );
    intArray.appendValue( 300 );
    intArray.appendValue( 400 );

    intArray.initialize();

    cout << "\nInt DynamicArray values are ..." << endl;
    while ( intArray.hasNextValue() )
          cout << intArray.getValue() << "\t";
    cout << endl;

    DynamicArray<char> charArray;
    charArray.appendValue( 'H' );
    charArray.appendValue( 'e' );
    charArray.appendValue( 'l' );
    charArray.appendValue( 'l' );
    charArray.appendValue( 'o' );

    charArray.initialize();

    cout << "\nChar DynamicArray values are ..." << endl;
    while ( charArray.hasNextValue() )
          cout << charArray.getValue() << "\t";
    cout << endl;

    DynamicArray<bool> boolArray;

    boolArray.appendValue ( true );
    boolArray.appendValue ( false );
    boolArray.appendValue ( true );
    boolArray.appendValue ( false );

    boolArray.initialize();

    cout << "\nBool DynamicArray values are ..." << endl;
    while ( boolArray.hasNextValue() )
         cout << boolArray.getValue() << "\t";
    cout << endl;

    return 0;

}

让我们快速使用以下命令编译程序:

g++ main.cpp -std=c++17

./a.out

输出如下:

Int DynamicArray values are ...
100   200   300   400

Char DynamicArray values are ...
H   e   l   l   o

Bool DynamicArray values are ...
1   0   1   0

太好了!我们的自定义适配器容器似乎工作正常。

代码演示

让我们放大并尝试理解前面的程序是如何工作的。下面的代码告诉 C++编译器接下来是一个类模板:

template < class T >
class DynamicArray {
      private:
           deque< T > dynamicArray;
           typename deque< T >::iterator pos;

如你所见,DynamicArray类内部使用了 STL deque,并且为deque声明了名为pos的迭代器。这个迭代器posDynamic模板类用于提供高级方法,比如initialize()appendValue()hasNextValue()getValue()方法。

initialize()方法将deque迭代器pos初始化为存储在deque中的第一个数据元素。appendValue( T element )方法允许您在deque的末尾添加数据元素。hasNextValue()方法告诉DynamicArray类是否有进一步存储的数据值--true表示有更多的值,false表示DynamicArray导航已经到达deque的末尾。当需要时,initialize()方法可以用来重置pos迭代器到起始点。getValue()方法返回pos迭代器指向的数据元素。getValue()方法不执行任何验证;因此,在调用getValue()之前,必须与hasNextValue()结合使用,以安全地访问存储在DynamicArray中的值。

现在,让我们了解main()函数。以下代码声明了一个存储int数据类型的DynamicArray类;DynamicArray<int> intArray将触发 C++编译器实例化一个专门用于int数据类型的DynamicArray类。

DynamicArray<int> intArray;

intArray.appendValue( 100 );
intArray.appendValue( 200 );
intArray.appendValue( 300 );
intArray.appendValue( 400 );

100200300400被依次存储在DynamicArray类中。以下代码确保intArray迭代器指向第一个元素。一旦迭代器初始化,存储在DynamicArray类中的值将通过getValue()方法打印出来,而hasNextValue()确保导航没有到达DynamicArray类的末尾。

intArray.initialize();
cout << "\nInt DynamicArray values are ..." << endl;
while ( intArray.hasNextValue() )
      cout << intArray.getValue() << "\t";
cout << endl;

在同样的情况下,在主函数中,创建了一个char DynamicArray类,填充了一些数据,并打印出来。让我们跳过char DynamicArray,直接转到存储boolDynamicArray类。

DynamicArray<bool> boolArray;

boolArray.appendValue ( "1010" );

boolArray.initialize();

cout << "\nBool DynamicArray values are ..." << endl;

while ( boolArray.hasNextValue() )
      cout << boolArray.getValue() << "\t";
cout << endl;

从前面的代码片段中,我们可以看到一切看起来都很好,对吗?是的,前面的代码运行得很好;然而,DynamicArray的设计方法存在性能问题。虽然true可以用1表示,false可以用0表示,只需要 1 位,但前面的DynamicArray类使用 8 位来表示1,另外 8 位来表示0,我们必须修复,而不强迫最终用户选择一个对bool有效率的DynamicArray类。

让我们通过以下代码使用显式类模板特化来解决这个问题:

#include <iostream>
#include <bitset>
#include <algorithm>
#include <iterator>
using namespace std;

template <>
class DynamicArray<bool> {
      private:
          deque< bitset<8> *> dynamicArray;
          bitset<8> oneByte;
          typename deque<bitset<8> * >::iterator pos;
          int bitSetIndex;

          int getDequeIndex () {
              return (bitSetIndex) ? (bitSetIndex/8) : 0;
          }
      public:
          DynamicArray() {
              bitSetIndex = 0;
              initialize();
          }

         ~DynamicArray() { }

         void initialize() {
              pos = dynamicArray.begin();
              bitSetIndex = 0;
         }

         void appendValue( bool value) {
              int dequeIndex = getDequeIndex();
              bitset<8> *pBit = NULL;

              if ( ( dynamicArray.size() == 0 ) || ( dequeIndex >= ( dynamicArray.size()) ) ) {
                   pBit = new bitset<8>();
                   pBit->reset();
                   dynamicArray.push_back ( pBit );
              }

              if ( !dynamicArray.empty() )
                   pBit = dynamicArray.at( dequeIndex );

              pBit->set( bitSetIndex % 8, value );
              ++bitSetIndex;
         }

         bool hasNextValue() {
              return (bitSetIndex < (( dynamicArray.size() * 8 ) ));
         }

         bool getValue() {
              int dequeIndex = getDequeIndex();

              bitset<8> *pBit = dynamicArray.at(dequeIndex);
              int index = bitSetIndex % 8;
              ++bitSetIndex;

              return (*pBit)[index] ? true : false;
         }
};

你注意到模板类声明了吗?模板类特化的语法是template <> class DynamicArray<bool> { };class模板表达式是空的<>,对于所有数据类型都适用的class模板的名称和适用于bool数据类型的类的名称与模板表达式<bool>保持一致。

如果你仔细观察,专门为boolDynamicArray类在内部使用了deque< bitset<8> >,即 8 位的bitsetdeque,在需要时,deque将自动分配更多的bitset<8>位。bitset变量是一个内存高效的 STL 容器,只消耗 1 位来表示truefalse

让我们来看一下main函数:

#include "dynamicarray.h"
#include "dynamicarrayforbool.h"

int main () {

    DynamicArray<int> intArray;

    intArray.appendValue( 100 );
    intArray.appendValue( 200 );
    intArray.appendValue( 300 );
    intArray.appendValue( 400 );

    intArray.initialize();

    cout << "\nInt DynamicArray values are ..." << endl;

    while ( intArray.hasNextValue() )
          cout << intArray.getValue() << "\t";
    cout << endl;

    DynamicArray<char> charArray;

    charArray.appendValue( 'H' );
    charArray.appendValue( 'e' );
    charArray.appendValue( 'l' );
    charArray.appendValue( 'l' );
    charArray.appendValue( 'o' );

    charArray.initialize();

    cout << "\nChar DynamicArray values are ..." << endl;
    while ( charArray.hasNextValue() )
          cout << charArray.getValue() << "\t";
    cout << endl;

    DynamicArray<bool> boolArray;

    boolArray.appendValue ( true );
    boolArray.appendValue ( false );
    boolArray.appendValue ( true );
    boolArray.appendValue ( false );

    boolArray.appendValue ( true );
    boolArray.appendValue ( false );
    boolArray.appendValue ( true );
    boolArray.appendValue ( false );

    boolArray.appendValue ( true );
    boolArray.appendValue ( true);
    boolArray.appendValue ( false);
    boolArray.appendValue ( false );

    boolArray.appendValue ( true );
    boolArray.appendValue ( true);
    boolArray.appendValue ( false);
    boolArray.appendValue ( false );

    boolArray.initialize();

    cout << "\nBool DynamicArray values are ..." << endl;
    while ( boolArray.hasNextValue() )
          cout << boolArray.getValue() ;
    cout << endl;

    return 0;

}

有了类模板特化,我们可以观察到以下的主要代码对于boolchardouble似乎是相同的,尽管主模板类DynamicArray和专门化的DynamicArray<bool>类是不同的。

DynamicArray<char> charArray;
charArray.appendValue( 'H' );
charArray.appendValue( 'e' );

charArray.initialize();

cout << "\nChar DynamicArray values are ..." << endl;
while ( charArray.hasNextValue() )
cout << charArray.getValue() << "\t";
cout << endl;

DynamicArray<bool> boolArray;
boolArray.appendValue ( true );
boolArray.appendValue ( false );

boolArray.initialize();

cout << "\nBool DynamicArray values are ..." << endl;
while ( boolArray.hasNextValue() )
      cout << boolArray.getValue() ;
cout << endl;

我相信你会发现这个 C++模板特化特性非常有用。

部分模板特化

与显式模板特化不同,显式模板特化用自己的完整定义替换特定数据类型的主模板类,部分模板特化允许我们专门化主模板类支持的某个子集的模板参数,而其他通用类型可以与主模板类相同。

当部分模板特化与继承结合时,可以做更多的奇迹,如下例所示。

#include <iostream>
using namespace std;

template <typename T1, typename T2, typename T3>
class MyTemplateClass {
public:
     void F1( T1 t1, T2 t2, T3 t3 ) {
          cout << "\nPrimary Template Class - Function F1 invoked ..." << endl;
          cout << "Value of t1 is " << t1 << endl;
          cout << "Value of t2 is " << t2 << endl;
          cout << "Value of t3 is " << t3 << endl;
     }

     void F2(T1 t1, T2 t2) {
          cout << "\nPrimary Tempalte Class - Function F2 invoked ..." << endl;
          cout << "Value of t1 is " << t1 << endl;
          cout << "Value of t2 is " << 2 * t2 << endl;
     }
};
template <typename T1, typename T2, typename T3>
class MyTemplateClass< T1, T2*, T3*> : public MyTemplateClass<T1, T2, T3> {
      public:
          void F1( T1 t1, T2* t2, T3* t3 ) {
               cout << "\nPartially Specialized Template Class - Function F1 invoked ..." << endl;
               cout << "Value of t1 is " << t1 << endl;
               cout << "Value of t2 is " << *t2 << endl;
               cout << "Value of t3 is " << *t3 << endl;
          }
};

main.cpp文件将包含以下内容:

#include "partiallyspecialized.h"

int main () {
    int x = 10;
    int *y = &x;
    int *z = &x;

    MyTemplateClass<int, int*, int*> obj;
    obj.F1(x, y, z);
    obj.F2(x, x);

    return 0;
}

从前面的代码中,你可能已经注意到主模板类的名称和部分特化类的名称与完全或显式模板类特化的情况相同。然而,在模板参数表达式中有一些语法上的变化。在完全模板类特化的情况下,模板参数表达式将为空,而在部分特化的模板类的情况下,列出的表达式会出现,如下所示:

template <typename T1, typename T2, typename T3>
class MyTemplateClass< T1, T2*, T3*> : public MyTemplateClass<T1, T2, T3> { };

表达式template<typename T1, typename T2, typename T3>是主类模板中使用的模板参数表达式,MyTemplateClass< T1, T2*, T3*>是第二类进行的部分特化。你可以看到,第二类对typename T2typename T3进行了一些特化,因为它们在第二类中被用作指针;然而,typename T1在第二类中被原样使用。

除了迄今为止讨论的事实之外,第二类还继承了主模板类,这有助于第二类重用主模板类的公共和受保护方法。然而,部分模板特化并不会阻止特定类支持其他函数。

当主模板类的F1函数被部分特化的模板类替换时,它通过继承重用了主模板类的F2函数。

让我们使用以下命令快速编译程序:

g++ main.cpp -std=c++17

./a.out

程序的输出如下:

Partially Specialized Template Classs - Function F1 invoked ...
Value of t1 is 10
Value of t2 is 10
Value of t3 is 10

Primary Tempalte Class - Function F2 invoked ...
Value of t1 is 10
Value of t2 is 20

希望你会发现部分特化的模板类有用。

总结

在本章中,你学会了以下内容:

  • 你现在知道使用泛型编程的动机

  • 你现在熟悉了函数模板

  • 你知道如何重载函数模板

  • 你知道类模板

  • 你知道何时使用显式模板特化以及何时使用部分特化的模板特化

恭喜!总的来说,你对 C++的模板编程有很好的理解。

在下一章中,你将学习智能指针。

第四章:智能指针

在上一章中,您了解了模板编程和泛型编程的好处。在本章中,您将学习以下智能指针主题:

  • 内存管理

  • 原始指针的问题

  • 循环依赖

  • 智能指针:

  • auto_ptr

  • 智能指针

  • shared_ptr

  • weak_ptr

让我们探讨 C++提供的内存管理设施。

内存管理

在 C++中,内存管理通常是软件开发人员的责任。这是因为 C++标准不强制在 C++编译器中支持垃圾回收;因此,这取决于编译器供应商的选择。特别是,Sun C++编译器带有一个名为libgc的垃圾回收库。

C++语言拥有许多强大的特性。其中,指针无疑是其中最强大和最有用的特性之一。指针非常有用,但它们也有自己的奇怪问题,因此必须负责使用。当内存管理没有得到认真对待或者没有做得很好时,会导致许多问题,包括应用程序崩溃、核心转储、分段错误、难以调试的问题、性能问题等等。悬空指针或者流氓指针有时会干扰其他无关的应用程序,而罪魁祸首应用程序却悄无声息地执行;事实上,受害应用程序可能会被多次责怪。内存泄漏最糟糕的部分在于,有时会变得非常棘手,即使是经验丰富的开发人员最终也会花费数小时来调试受害代码,而罪魁祸首代码却毫发未损。有效的内存管理有助于避免内存泄漏,并让您开发内存高效的高性能应用程序。

由于每个操作系统的内存模型都不同,因此在相同的内存泄漏问题上,每个操作系统可能在不同的时间点表现不同。内存管理是一个大课题,C++提供了许多有效的方法来处理它。我们将在以下章节讨论一些有用的技术。

原始指针的问题

大多数 C++开发人员有一个共同点:我们都喜欢编写复杂的东西。你问一个开发人员,“嘿,伙计,你想重用已经存在并且可用的代码,还是想自己开发一个?”虽然大多数开发人员会委婉地说在可能的情况下重用已有的代码,但他们的内心会说,“我希望我能自己设计和开发它。”复杂的数据结构和算法往往需要指针。原始指针在遇到麻烦之前确实很酷。

在使用前,原始指针必须分配内存,并且在使用后需要释放内存;就是这么简单。然而,在一个产品中,指针分配可能发生在一个地方,而释放可能发生在另一个地方。如果内存管理决策没有做出正确的选择,人们可能会认为释放内存是调用者或被调用者的责任,有时内存可能不会从任何地方释放。还有另一种可能性,同一个指针可能会从不同的地方被多次删除,这可能导致应用程序崩溃。如果这种情况发生在 Windows 设备驱动程序中,很可能会导致蓝屏。

想象一下,如果出现应用程序异常,并且抛出异常的函数有一堆在异常发生前分配了内存的指针?任何人都能猜到:会有内存泄漏。

让我们看一个使用原始指针的简单例子:

#include <iostream>
using namespace std;

class MyClass {
      public:
           void someMethod() {

                int *ptr = new int();
                *ptr = 100;
                int result = *ptr / 0;  //division by zero error expected
                delete ptr;

           }
};

int main ( ) {

    MyClass objMyClass;
    objMyClass.someMethod();

    return 0;

}

现在,运行以下命令:

g++ main.cpp -g -std=c++17

查看此程序的输出:

main.cpp: In member function ‘void MyClass::someMethod()’:
main.cpp:12:21: warning: division by zero [-Wdiv-by-zero]
 int result = *ptr / 0;

现在,运行以下命令:

./a.out
[1] 31674 floating point exception (core dumped) ./a.out

C++编译器真的很酷。看看警告消息,它指出了问题。我喜欢 Linux 操作系统。Linux 在发现行为不端的恶意应用程序方面非常聪明,并且及时将它们关闭,以免对其他应用程序或操作系统造成任何损害。核心转储实际上是好事,但在庆祝 Linux 方法时却被诅咒。猜猜,微软的 Windows 操作系统同样聪明。当它们发现一些应用程序进行可疑的内存访问时,它们会进行错误检查,Windows 操作系统也支持迷你转储和完整转储,这相当于 Linux 操作系统中的核心转储。

让我们看一下 Valgrind 工具的输出,以检查内存泄漏问题:

valgrind --leak-check=full --show-leak-kinds=all ./a.out

==32857== Memcheck, a memory error detector
==32857== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==32857== Using Valgrind-3.12.0 and LibVEX; rerun with -h for copyright info
==32857== Command: ./a.out
==32857== 
==32857== 
==32857== Process terminating with default action of signal 8 (SIGFPE)
==32857== Integer divide by zero at address 0x802D82B86
==32857== at 0x10896A: MyClass::someMethod() (main.cpp:12)
==32857== by 0x1088C2: main (main.cpp:24)
==32857== 
==32857== HEAP SUMMARY:
==32857== in use at exit: 4 bytes in 1 blocks
==32857== total heap usage: 2 allocs, 1 frees, 72,708 bytes allocated
==32857== 
==32857== 4 bytes in 1 blocks are still reachable in loss record 1 of 1
==32857== at 0x4C2E19F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==32857== by 0x108951: MyClass::someMethod() (main.cpp:8)
==32857== by 0x1088C2: main (main.cpp:24)
==32857== 
==32857== LEAK SUMMARY:
==32857== definitely lost: 0 bytes in 0 blocks
==32857== indirectly lost: 0 bytes in 0 blocks
==32857== possibly lost: 0 bytes in 0 blocks
==32857== still reachable: 4 bytes in 1 blocks
==32857== suppressed: 0 bytes in 0 blocks
==32857== 
==32857== For counts of detected and suppressed errors, rerun with: -v
==32857== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
[1] 32857 floating point exception (core dumped) valgrind --leak-check=full --show-leak-kinds=all ./a.out

在这个输出中,如果你注意粗体部分的文本,你会注意到 Valgrind 工具指出了导致这个核心转储的源代码行号。main.cpp文件中的第 12 行如下:

 int result = *ptr / 0; //division by zero error expected 

main.cpp文件的第 12 行发生异常时,异常下面出现的代码将永远不会被执行。在main.cpp文件的第 13 行,由于异常,将永远不会执行delete语句:

 delete ptr;

在堆栈展开过程中,由于指针指向的内存在堆栈展开过程中没有被释放,因此前面的原始指针分配的内存没有被释放。每当函数抛出异常并且异常没有被同一个函数处理时,堆栈展开是有保证的。然而,只有自动本地变量在堆栈展开过程中会被清理,而不是指针指向的内存。这导致内存泄漏。

这是使用原始指针引发的奇怪问题之一;还有许多其他类似的情况。希望你现在已经相信,使用原始指针的乐趣是有代价的。但所付出的代价并不值得,因为在 C++中有很好的替代方案来解决这个问题。你是对的,使用智能指针是提供使用指针的好处而不付出原始指针附加成本的解决方案。

因此,智能指针是在 C++中安全使用指针的方法。

智能指针

在 C++中,智能指针让你专注于手头的问题,摆脱了处理自定义垃圾收集技术的烦恼。智能指针让你安全地使用原始指针。它们负责清理原始指针使用的内存。

C++支持许多类型的智能指针,可以在不同的场景中使用:

  • auto_ptr

  • unique_ptr

  • shared_ptr

  • weak_ptr

auto_ptr智能指针是在 C++11 中引入的。auto_ptr智能指针在超出范围时自动释放堆内存。然而,由于auto_ptr从一个auto_ptr实例转移所有权的方式,它已被弃用,并且unique_ptr被引入作为其替代品。shared_ptr智能指针帮助多个共享智能指针引用同一个对象,并负责内存管理负担。weak_ptr智能指针帮助解决由于应用程序设计中存在循环依赖问题而导致的shared_ptr使用的内存泄漏问题。

还有其他类型的智能指针和相关内容,它们并不常用,并列在以下项目列表中。然而,我强烈建议你自己探索它们,因为你永远不知道什么时候会发现它们有用:

  • 无主

  • enable_shared_from_this

  • bad_weak_ptr

  • default_delete

owner_less智能指针帮助比较两个或更多个智能指针是否共享相同的原始指向对象。enable_shared_from_this智能指针帮助获取this指针的智能指针。bad_weak_ptr智能指针是一个异常类,意味着使用无效智能指针创建了shared_ptrdefault_delete智能指针指的是unique_ptr使用的默认销毁策略,它调用delete语句,同时也支持用于数组类型的部分特化,使用delete[]

在本章中,我们将逐一探讨auto_ptrshared_ptrweak_ptrunique-ptr

auto_ptr

auto_ptr智能指针接受一个原始指针,封装它,并确保原始指针指向的内存在auto_ptr对象超出范围时被释放。在任何时候,只有一个auto_ptr智能指针可以指向一个对象。因此,当一个auto_ptr指针被赋值给另一个auto_ptr指针时,所有权被转移到接收赋值的auto_ptr实例;当一个auto_ptr智能指针被复制时也是如此。

通过一个简单的例子来观察这些内容将会很有趣,如下所示:

#include <iostream>
#include <string>
#include <memory>
#include <sstream>
using namespace std;

class MyClass {
      private:
           static int count;
           string name;
      public:
           MyClass() {
                 ostringstream stringStream(ostringstream::ate);
                 stringStream << "Object";
                 stringStream << ++count;
                 name = stringStream.str();
                 cout << "\nMyClass Default constructor - " << name << endl;
           }
           ~MyClass() {
                 cout << "\nMyClass destructor - " << name << endl;
           }

           MyClass ( const MyClass &objectBeingCopied ) {
                 cout << "\nMyClass copy constructor" << endl;
           }

           MyClass& operator = ( const MyClass &objectBeingAssigned ) {
                 cout << "\nMyClass assignment operator" << endl;
           }

           void sayHello( ) {
                cout << "Hello from MyClass " << name << endl;
           }
};

int MyClass::count = 0;

int main ( ) {

   auto_ptr<MyClass> ptr1( new MyClass() );
   auto_ptr<MyClass> ptr2( new MyClass() );

   return 0;

}

前面程序的编译输出如下:

g++ main.cpp -std=c++17

main.cpp: In function ‘int main()’:
main.cpp:40:2: warning: ‘template<class> class std::auto_ptr’ is deprecated [-Wdeprecated-declarations]
 auto_ptr<MyClass> ptr1( new MyClass() );

In file included from /usr/include/c++/6/memory:81:0,
 from main.cpp:3:
/usr/include/c++/6/bits/unique_ptr.h:49:28: note: declared here
 template<typename> class auto_ptr;

main.cpp:41:2: warning: ‘template<class> class std::auto_ptr’ is deprecated [-Wdeprecated-declarations]
 auto_ptr<MyClass> ptr2( new MyClass() );

In file included from /usr/include/c++/6/memory:81:0,
 from main.cpp:3:
/usr/include/c++/6/bits/unique_ptr.h:49:28: note: declared here
 template<typename> class auto_ptr;

正如你所看到的,C++编译器警告我们使用auto_ptr已经被弃用。因此,我不建议再使用auto_ptr智能指针;它已被unique_ptr取代。

现在,我们可以忽略警告并继续,如下所示:

g++ main.cpp -Wno-deprecated

./a.out

MyClass Default constructor - Object1

MyClass Default constructor - Object2

MyClass destructor - Object2

MyClass destructor - Object1 

正如你在前面的程序输出中所看到的,分配在堆中的Object1Object2都被自动删除了。这要归功于auto_ptr智能指针。

代码演示 - 第 1 部分

MyClass的定义中,你可能已经了解到,它定义了默认的构造函数复制构造函数和析构函数,一个赋值运算符和sayHello()方法,如下所示:

//Definitions removed here to keep it simple 
class MyClass {
public:
      MyClass() { }  //Default constructor
      ~MyClass() { } //Destructor 
      MyClass ( const MyClass &objectBeingCopied ) {} //Copy Constructor 
      MyClass& operator = ( const MyClass &objectBeingAssigned ) { } //Assignment operator
      void sayHello();
}; 

MyClass的方法只是一个打印语句,表明方法被调用;它们纯粹是为了演示目的而设计的。

main()函数创建了两个auto_ptr智能指针,它们指向两个不同的MyClass对象,如下所示:

int main ( ) {

   auto_ptr<MyClass> ptr1( new MyClass() );
   auto_ptr<MyClass> ptr2( new MyClass() );

   return 0;

}

正如你所理解的,auto_ptr是一个封装了原始指针而不是指针的本地对象。当控制流达到return语句时,堆栈展开过程开始,作为这一过程的一部分,堆栈对象ptr1ptr2被销毁。这反过来调用了auto_ptr的析构函数,最终删除了堆栈对象ptr1ptr2指向的MyClass对象。

我们还没有完成。让我们探索auto_ptr的更多有用功能,如下所示的main函数:

int main ( ) {

    auto_ptr<MyClass> ptr1( new MyClass() );
    auto_ptr<MyClass> ptr2( new MyClass() );

    ptr1->sayHello();
    ptr2->sayHello();

    //At this point the below stuffs happen
    //1\. ptr2 smart pointer has given up ownership of MyClass Object 2
    //2\. MyClass Object 2 will be destructed as ptr2 has given up its 
    //   ownership on Object 2
    //3\. Ownership of Object 1 will be transferred to ptr2
    ptr2 = ptr1;

    //The line below if uncommented will result in core dump as ptr1 
    //has given up its ownership on Object 1 and the ownership of 
    //Object 1 is transferred to ptr2.
    // ptr1->sayHello();

    ptr2->sayHello();

    return 0;

}

代码演示 - 第 2 部分

我们刚刚看到的main()函数代码演示了许多有用的技术和一些auto_ptr智能指针的争议行为。以下代码创建了两个auto_ptr的实例,即ptr1ptr2,它们封装了在堆中创建的两个MyClass对象:

 auto_ptr<MyClass> ptr1( new MyClass() );
 auto_ptr<MyClass> ptr2( new MyClass() );

接下来,以下代码演示了如何使用auto_ptr调用MyClass支持的方法:

 ptr1->sayHello();
 ptr2->sayHello();

希望你注意到了ptr1->sayHello()语句。它会让你相信auto_ptr ptr1对象是一个指针,但实际上,ptr1ptr2只是作为本地变量在堆栈中创建的auto_ptr对象。由于auto_ptr类重载了->指针运算符和*解引用运算符,它看起来像一个指针。事实上,MyClass暴露的所有方法只能使用->指针运算符访问,而所有auto_ptr方法可以像访问堆栈对象一样访问。

以下代码演示了auto_ptr智能指针的内部行为,所以请密切关注;这将会非常有趣:

ptr2 = ptr1;

尽管上述代码看起来像是一个简单的赋值语句,但它在auto_ptr中触发了许多活动。由于前面的赋值语句,发生了以下活动:

  • ptr2智能指针将放弃对MyClass对象 2 的所有权。

  • ptr2放弃了对object 2的所有权,因此MyClass对象 2 将被销毁。

  • object 1的所有权将被转移到ptr2

  • 此时,ptr1既不指向object 1,也不负责管理object 1使用的内存。

以下注释行包含一些信息:

// ptr1->sayHello();

由于ptr1智能指针已经释放了对object 1的所有权,因此尝试访问sayHello()方法是非法的。这是因为ptr1实际上不再指向object 1,而object 1ptr2拥有。当ptr2超出范围时,释放object 1使用的内存是ptr2智能指针的责任。如果取消注释上述代码,将导致核心转储。

最后,以下代码让我们使用ptr2智能指针在object 1上调用sayHello()方法:

ptr2->sayHello();
return 0;

我们刚刚看到的return语句将在main()函数中启动堆栈展开过程。这将最终调用ptr2的析构函数,进而释放object 1使用的内存。美妙的是,所有这些都是自动发生的。在我们专注于手头的问题时,auto_ptr智能指针在幕后为我们努力工作。

然而,由于以下原因,从C++11开始,auto_ptr已经被弃用:

  • auto_ptr对象不能存储在 STL 容器中

  • auto_ptr复制构造函数将从原始源头那里移除所有权,也就是说,auto_ptr

  • auto_ptr复制赋值运算符将从原始源头那里移除所有权,也就是说,auto_ptr

  • auto_ptr的复制构造函数和赋值运算符违反了原始意图,因为auto_ptr的复制构造函数和赋值运算符将从右侧对象中移除源对象的所有权,并将所有权分配给左侧对象

unique_ptr

unique_ptr智能指针的工作方式与auto_ptr完全相同,只是unique_ptr解决了auto_ptr引入的问题。因此,unique_ptrC++11开始的auto_ptr的替代品。unique_ptr智能指针只允许一个智能指针独占拥有堆分配的对象。只能通过std::move()函数将一个unique_ptr实例的所有权转移给另一个实例。

因此,让我们重构我们之前的示例,使用unique_ptr来替代auto_ptr

重构后的代码示例如下:

#include <iostream>
#include <string>
#include <memory>
#include <sstream>
using namespace std;

class MyClass {
      private:
          static int count;
          string name;

      public:
          MyClass() {
                ostringstream stringStream(ostringstream::ate);
                stringStream << "Object";
                stringStream << ++count;
                name = stringStream.str();
                cout << "\nMyClass Default constructor - " << name << endl;
          }

          ~MyClass() {
                cout << "\nMyClass destructor - " << name << endl;
          }

          MyClass ( const MyClass &objectBeingCopied ) {
                cout << "\nMyClass copy constructor" << endl;
          }

          MyClass& operator = ( const MyClass &objectBeingAssigned ) {
                cout << "\nMyClass assignment operator" << endl;
          }

          void sayHello( ) {
                cout << "\nHello from MyClass" << endl;
          }

};

int MyClass::count = 0;

int main ( ) {

 unique_ptr<MyClass> ptr1( new MyClass() );
 unique_ptr<MyClass> ptr2( new MyClass() );

 ptr1->sayHello();
 ptr2->sayHello();

 //At this point the below stuffs happen
 //1\. ptr2 smart pointer has given up ownership of MyClass Object 2
 //2\. MyClass Object 2 will be destructed as ptr2 has given up its 
 // ownership on Object 2
 //3\. Ownership of Object 1 will be transferred to ptr2
 ptr2 = move( ptr1 );

 //The line below if uncommented will result in core dump as ptr1 
 //has given up its ownership on Object 1 and the ownership of 
 //Object 1 is transferred to ptr2.
 // ptr1->sayHello();

 ptr2->sayHello();

 return 0;
}

上述程序的输出如下:

g++ main.cpp -std=c++17

./a.out

MyClass Default constructor - Object1

MyClass Default constructor - Object2

MyClass destructor - Object2

MyClass destructor - Object1 

在上述输出中,您可以注意到编译器没有报告任何警告,并且程序的输出与auto_ptr的输出相同。

代码演示

重要的是要注意main()函数中auto_ptrunique_ptr之间的区别。让我们来看一下以下代码中所示的main()函数。这段代码在堆中创建了两个MyClass对象的实例,分别用ptr1ptr2包装起来:

 unique_ptr<MyClass> ptr1( new MyClass() );
 unique_ptr<MyClass> ptr2( new MyClass() );

接下来,以下代码演示了如何使用unique_ptr调用MyClass支持的方法:

 ptr1->sayHello();
 ptr2->sayHello();

就像auto_ptr一样,unique_ptr智能指针ptr1对象重载了->指针运算符和*解引用运算符;因此,它看起来像一个指针。

以下代码演示了unique_ptr不支持将一个unique_ptr实例分配给另一个实例,只能通过std::move()函数实现所有权转移:

ptr2 = std::move(ptr1);

move函数触发以下活动:

  • ptr2智能指针放弃了对MyClass对象 2 的所有权

  • ptr2放弃了对object 2的所有权,因此MyClass对象 2 被销毁

  • object 1 的所有权已转移到 ptr2

  • 此时,ptr1 既不指向 object 1,也不负责管理 object 1 使用的内存

如果取消注释以下代码,将导致核心转储:

// ptr1->sayHello();

最后,以下代码让我们使用 ptr2 智能指针调用 object 1sayHello() 方法:

ptr2->sayHello();
return 0;

我们刚刚看到的 return 语句将在 main() 函数中启动堆栈展开过程。这将最终调用 ptr2 的析构函数,从而释放 object 1 使用的内存。请注意,unique_ptr 对象可以存储在 STL 容器中,而 auto_ptr 对象则不行。

shared_ptr

当一组 shared_ptr 对象共享堆分配的对象的所有权时,使用 shared_ptr 智能指针。当所有 shared_ptr 实例完成对共享对象的使用时,shared_ptr 指针释放共享对象。shared_ptr 指针使用引用计数机制来检查对共享对象的总引用;每当引用计数变为零时,最后一个 shared_ptr 实例将删除共享对象。

让我们通过一个示例来检查 shared_ptr 的使用,如下所示:

#include <iostream>
#include <string>
#include <memory>
#include <sstream>
using namespace std;

class MyClass {
  private:
    static int count;
    string name;
  public:
    MyClass() {
      ostringstream stringStream(ostringstream::ate);
      stringStream << "Object";
      stringStream << ++count;

      name = stringStream.str();

      cout << "\nMyClass Default constructor - " << name << endl;
    }

    ~MyClass() {
      cout << "\nMyClass destructor - " << name << endl;
    }

    MyClass ( const MyClass &objectBeingCopied ) {
      cout << "\nMyClass copy constructor" << endl;
    }

    MyClass& operator = ( const MyClass &objectBeingAssigned ) {
      cout << "\nMyClass assignment operator" << endl;
    }

    void sayHello() {
      cout << "Hello from MyClass " << name << endl;
    }

};

int MyClass::count = 0;

int main ( ) {

  shared_ptr<MyClass> ptr1( new MyClass() );
  ptr1->sayHello();
  cout << "\nUse count is " << ptr1.use_count() << endl;

  {
      shared_ptr<MyClass> ptr2( ptr1 );
      ptr2->sayHello();
      cout << "\nUse count is " << ptr2.use_count() << endl;
  }

  shared_ptr<MyClass> ptr3 = ptr1;
  ptr3->sayHello();
  cout << "\nUse count is " << ptr3.use_count() << endl;

  return 0;
}

前面程序的输出如下:

MyClass Default constructor - Object1
Hello from MyClass Object1
Use count is 1

Hello from MyClass Object1
Use count is 2

Number of smart pointers referring to MyClass object after ptr2 is destroyed is 1

Hello from MyClass Object1
Use count is 2

MyClass destructor - Object1

代码漫游

以下代码创建了一个指向堆分配的 MyClass 对象的 shared_ptr 对象实例。与其他智能指针一样,shared_ptr 也有重载的 ->* 运算符。因此,可以调用所有 MyClass 对象的方法,就好像使用原始指针一样。use_count() 方法告诉指向共享对象的智能指针的数量:

 shared_ptr<MyClass> ptr1( new MyClass() );
 ptr1->sayHello();
 cout << "\nNumber of smart pointers referring to MyClass object is "
      << ptr1->use_count() << endl;

在以下代码中,智能指针 ptr2 的作用域被包含在花括号括起来的块中。因此,ptr2 将在以下代码块的末尾被销毁。代码块内的预期 use_count 函数为 2:

 { 
      shared_ptr<MyClass> ptr2( ptr1 );
      ptr2->sayHello();
      cout << "\nNumber of smart pointers referring to MyClass object is "
           << ptr2->use_count() << endl;
 }

在以下代码中,预期的 use_count 值为 1,因为 ptr2 已被删除,这将减少 1 个引用计数:

 cout << "\nNumber of smart pointers referring to MyClass object after ptr2 is destroyed is "
 << ptr1->use_count() << endl; 

以下代码将打印一个 Hello 消息,后跟 use_count 为 2。这是因为 ptr1ptr3 现在都指向堆中的 MyClass 共享对象:

shared_ptr<MyClass> ptr3 = ptr2;
ptr3->sayHello();
cout << "\nNumber of smart pointers referring to MyClass object is "
     << ptr2->use_count() << endl;

main 函数末尾的 return 0; 语句将销毁 ptr1ptr3,将引用计数减少到零。因此,我们可以观察到输出末尾打印 MyClass 析构函数的语句。

weak_ptr

到目前为止,我们已经讨论了 shared_ptr 的正面作用,并举例说明。但是,当应用程序设计中存在循环依赖时,shared_ptr 无法清理内存。要么必须重构应用程序设计以避免循环依赖,要么可以使用 weak_ptr 来解决循环依赖问题。

您可以查看我的 YouTube 频道,了解 shared_ptr 问题以及如何使用 weak_ptr 解决该问题:www.youtube.com/watch?v=SVTLTK5gbDc

考虑有三个类:A、B 和 C。类 A 和 B 都有一个 C 的实例,而 C 有 A 和 B 的实例。这里存在一个设计问题。A 依赖于 C,而 C 也依赖于 A。同样,B 依赖于 C,而 C 也依赖于 B。

考虑以下代码:

#include <iostream>
#include <string>
#include <memory>
#include <sstream>
using namespace std;

class C;

class A {
      private:
           shared_ptr<C> ptr;
      public:
           A() {
                 cout << "\nA constructor" << endl;
           }

           ~A() {
                 cout << "\nA destructor" << endl;
           }

           void setObject ( shared_ptr<C> ptr ) {
                this->ptr = ptr;
           }
};

class B {
      private:
           shared_ptr<C> ptr;
      public:
           B() {
                 cout << "\nB constructor" << endl;
           }

           ~B() {
                 cout << "\nB destructor" << endl;
           }

           void setObject ( shared_ptr<C> ptr ) {
                this->ptr = ptr;
           }
};

class C {
      private:
           shared_ptr<A> ptr1;
           shared_ptr<B> ptr2;
      public:
           C(shared_ptr<A> ptr1, shared_ptr<B> ptr2) {
                   cout << "\nC constructor" << endl;
                   this->ptr1 = ptr1;
                   this->ptr2 = ptr2;
           }

           ~C() {
                   cout << "\nC destructor" << endl;
           }
};

int main ( ) {
                shared_ptr<A> a( new A() );
                shared_ptr<B> b( new B() );
                shared_ptr<C> c( new C( a, b ) );

                a->setObject ( shared_ptr<C>( c ) );
                b->setObject ( shared_ptr<C>( c ) );

                return 0;
}

前面程序的输出如下:

g++ problem.cpp -std=c++17

./a.out

A constructor

B constructor

C constructor

在前面的输出中,您可以观察到,即使我们使用了shared_ptr,对象 A、B 和 C 使用的内存从未被释放。这是因为我们没有看到各自类的析构函数被调用。原因是shared_ptr在内部使用引用计数算法来决定是否共享对象必须被销毁。然而,它在这里失败了,因为除非删除对象 C,否则无法删除对象 A。除非删除对象 A,否则无法删除对象 C。同样,除非删除对象 A 和 B,否则无法删除对象 C。同样,除非删除对象 C,否则无法删除对象 A,除非删除对象 C,否则无法删除对象 B。

问题的关键是这是一个循环依赖设计问题。为了解决这个问题,从 C++11 开始,C++引入了weak_ptrweak_ptr智能指针不是一个强引用。因此,所引用的对象可以在任何时候被删除,不像shared_ptr

循环依赖

循环依赖是一个问题,如果对象 A 依赖于 B,而对象 B 又依赖于 A。现在让我们看看如何通过shared_ptrweak_ptr的组合来解决这个问题,最终打破循环依赖,如下所示:

#include <iostream>
#include <string>
#include <memory>
#include <sstream>
using namespace std;

class C;

class A {
      private:
 weak_ptr<C> ptr;
      public:
           A() {
                  cout << "\nA constructor" << endl;
           }

           ~A() {
                  cout << "\nA destructor" << endl;
           }

           void setObject ( weak_ptr<C> ptr ) {
                  this->ptr = ptr;
           }
};

class B {
      private:
 weak_ptr<C> ptr;
      public:
           B() {
               cout << "\nB constructor" << endl;
           }

           ~B() {
               cout << "\nB destructor" << endl;
           }

           void setObject ( weak_ptr<C> ptr ) {
                this->ptr = ptr;
           }
};

class C {
      private:
           shared_ptr<A> ptr1;
           shared_ptr<B> ptr2;
      public:
           C(shared_ptr<A> ptr1, shared_ptr<B> ptr2) {
                   cout << "\nC constructor" << endl;
                   this->ptr1 = ptr1;
                   this->ptr2 = ptr2;
           }

           ~C() {
                   cout << "\nC destructor" << endl;
           }
};

int main ( ) {
         shared_ptr<A> a( new A() );
         shared_ptr<B> b( new B() );
         shared_ptr<C> c( new C( a, b ) );

         a->setObject ( weak_ptr<C>( c ) );
         b->setObject ( weak_ptr<C>( c ) );

         return 0;
}

重构代码的输出如下:

g++ solution.cpp -std=c++17

./a.out

A constructor

B constructor

C constructor

C destructor

B destructor

A destructor

摘要

在本章中,您了解到

  • 由于原始指针而引起的内存泄漏问题

  • 关于赋值和复制构造函数的auto_ptr的问题

  • unique_ptr及其优势

  • shared_ptr在内存管理中的作用及其与循环依赖相关的限制。

  • 您还可以使用weak_ptr解决循环依赖问题。

在下一章中,您将学习如何在 C++中开发 GUI 应用程序。

第五章:在 C++中开发 GUI 应用程序

在本章中,您将学习以下主题:

  • Qt 简要概述

  • Qt 框架

  • 在 Ubuntu 上安装 Qt

  • 开发 Qt 核心应用程序

  • 开发 Qt GUI 应用程序

  • 在 Qt GUI 应用程序中使用布局

  • 理解事件处理的信号和槽

  • 在 Qt 应用程序中使用多个布局

Qt 是一个用 C++开发的跨平台应用程序框架。它支持多个平台,包括 Windows、Linux、Mac OS、Android、iOS、嵌入式 Linux、QNX、VxWorks、Windows CE/RT、Integrity、Wayland、X11、嵌入式设备等。它主要用作人机界面(HMI)或图形用户界面(GUI)框架;但也用于开发命令行界面(CLI)应用程序。正确发音为“cute”。Qt 应用程序框架有两种版本:开源版本和商业许可版本。

Qt 是 Haavard Nord 和 Eirik Chambe-Eng 的心血结晶,他们是最初的开发人员,在 1991 年开发了它。

由于 C++语言本身不支持 GUI,你可能已经猜到了,C++语言本身并不支持事件管理。因此,Qt 需要支持自己的事件处理机制,这导致了信号和槽技术的出现。在底层,信号和槽使用了观察者设计模式,允许 Qt 对象相互通信。这听起来太难理解了吗?别担心!信号只是事件,比如按钮点击或窗口关闭,而槽是事件处理程序,可以以你希望的方式对这些事件做出响应。

为了使我们在 Qt 应用程序开发方面的生活更加轻松,Qt 支持各种宏和 Qt 特定的关键字。由于这些关键字不会被 C++理解,Qt 必须将它们和宏转换为纯粹的 C++代码,以便 C++编译器可以像往常一样工作。为了使这一切更加顺利,Qt 支持一种称为“元对象编译器”的东西,也被称为“moc”。

Qt 是 C++项目的自然选择,因为它是纯粹的 C++代码;因此,作为 C++开发人员,在应用程序中使用 Qt 时会感到宾至如归。一个典型的应用程序将同时具有复杂的逻辑和令人印象深刻的 UI。在小型产品团队中,通常一个开发人员会做多种工作,这既有利也有弊。

一般来说,专业开发人员具有良好的问题解决能力。问题解决能力对于以最佳方式解决复杂问题并选择良好的数据结构和算法至关重要。

开发令人印象深刻的 UI 需要创造性的设计技能。虽然有一定数量的开发人员擅长问题解决或创造性 UI 设计,但并非所有开发人员都擅长这两者。这就是 Qt 的优势所在。

比如,一家初创公司想要为内部目的开发一个应用程序。对于这个目的,一个简单的 GUI 应用程序就足够了,一个看起来不错的 HMI/GUI 可能对团队有用,因为应用程序只是为内部目的而设计的。在这种情况下,整个应用程序可以使用 C++和 Qt Widgets 框架开发。唯一的前提是开发团队必须精通 C++。

然而,在必须开发移动应用程序的情况下,令人印象深刻的 HMI 变得必不可少。同样,移动应用程序可以使用 C++和 Qt Widgets 开发。但现在这个选择有两个部分。好的一面是移动应用程序团队只需要擅长 C++。这个选择的坏处是,并不是所有擅长 C++的开发人员都擅长设计移动应用程序的 HMI/GUI。

假设团队有一两个专门的 Photoshop 专业人员,擅长创建引人注目的图像,可以在 GUI 中使用,还有一两个 UI 设计师,可以使用 Photoshop 专家创建的图像制作出令人印象深刻的 HMI/GUI。通常,UI 设计师擅长前端技术,如 JavaScript、HTML 和 CSS。强大的 Qt 框架可以开发复杂的业务逻辑,而 HMI/GUI 可以在 QML 中开发。

QML 是与 Qt 应用程序框架一起提供的一种声明性脚本语言。它类似于 JavaScript,并具有 Qt 特定的扩展。它非常适合快速应用程序开发,并允许 UI 设计师专注于 HMI/GUI,而 C++开发人员则专注于可以在 Qt 框架中开发的复杂业务逻辑。

由于 C++ Qt 框架和 QML 都是同一 Qt 应用程序框架的一部分,它们可以无缝地配合使用。

Qt 是一个庞大而强大的框架;因此,本章将重点介绍 Qt 的基本要点,以帮助您开始使用 Qt。如果您想了解更多信息,您可能想查看我正在撰写的另一本即将推出的书,名为精通 Qt 和 QML 编程

Qt

Qt 框架是用 C++开发的,因此可以保证对任何优秀的 C++开发人员来说都是易如反掌。它支持 CLI 和基于 GUI 的应用程序开发。在撰写本章时,Qt 应用程序框架的最新版本是 Qt 5.7.0。当您阅读本书时,可能会有不同版本的 Qt 可供您下载。您可以从www.qt.io下载最新版本。

在 Ubuntu 16.04 中安装 Qt 5.7.0

在本章中,我将使用 Ubuntu 16.04 操作系统;但是,本章中列出的程序应该适用于支持 Qt 的任何平台。

有关详细的安装说明,请参阅wiki.qt.io/install_Qt_5_on_Ubuntu

在这一点上,您应该在系统上安装了 C++编译器。如果不是这种情况,请首先确保您安装了 C++编译器,方法如下:

sudo apt-get install build-essential

从 Ubuntu 终端,您应该能够下载 Qt 5.7.0,如下命令所示:

w**get** **http://download.qt.io/official_releases/qt/5.7/5.7.0/qt-
opensource-linux-x64-5.7.0.run** 

为下载的安装程序提供执行权限,如下命令所示:

chmod +x qt-opensource-linux-x64-5.7.0.run 

我强烈建议您安装 Qt 及其源代码。如果您喜欢用极客的方式查找 Qt 帮助,您可以直接从源代码获取帮助。

启动安装程序,如下命令所示:

./qt-opensource-linux-x64-5.7.0.run

由于 Qt 使用 OpenGL,请确保在开始编写 Qt 中的第一个程序之前安装以下内容。要安装libfontconfig1,请运行以下命令:

 sudo apt-get install libfontconfig1

要安装mesa-common-dev,请运行以下命令:

sudo apt-get install mesa-common-dev  

在这一点上,您应该有一个可用的 Qt 设置。您可以通过在 Linux 终端中发出以下命令来验证安装:

图 5.1

如果qmake命令无法识别,请确保导出 Qt 安装文件夹的bin路径,如前面的屏幕截图所示。此外,创建软链接也可能很有用。此命令如下:

 sudo ln -s /home/jegan/Qt5.7.0/5.7/gcc_64/bin/qmake /usr/bin/qmake  

Qt 在您的系统上安装的路径可能与我的不同,请相应地替换 Qt 路径。

Qt Core

Qt Core 是 Qt 支持的模块之一。该模块具有大量有用的类,例如QObjectQCoreApplicationQDebug等。几乎每个 Qt 应用程序都需要此模块,因此它们会被 Qt 框架隐式链接。每个 Qt 类都继承自QObject,而QObject类为 Qt 应用程序提供事件处理支持。QObject是支持事件处理机制的关键部分;有趣的是,即使是基于控制台的应用程序也可以在 Qt 中支持事件处理。

编写我们的第一个 Qt 控制台应用程序

如果您得到了类似于图 5.1所示的输出,那么您已经准备好动手了。让我们编写我们的第一个 Qt 应用程序,如下面的屏幕截图所示:

图 5.2

在第一行中,我们从QtCore模块中包含了 QDebug 头文件。如果您仔细观察,qDebug()函数类似于 C++的cout ostream操作符。在 Qt 世界中,当您调试代码时,qDebug()函数将成为您的好朋友。QDebug类已经重载了 C++的ostream操作符,以支持 Qt 数据类型,这些类型不受 C++编译器支持。

以老派的方式,我对终端有点着迷,可以在编码时实现几乎任何功能,而不是使用一些花哨的集成开发环境IDE)。您可能会喜欢或讨厌这种方法,这是很自然的。好处是在 Qt/C++中,您可以使用简单而强大的文本编辑器,如 Vim、Emacs、Sublime Text、Atom、Brackets 或 Neovim,学习几乎所有 Qt 项目和 qmake 的基本知识;IDE 可以让您的生活变得更轻松,但它们隐藏了许多每个严肃开发人员都必须了解的基本内容。所以这是一个权衡。我把决定权交给您,决定是使用您喜欢的纯文本编辑器、Qt Creator IDE 还是其他花哨的 IDE。我将坚持使用经过重构的 Vim 编辑器 Neovim,看起来真的很酷。图 5.2将给您一个关于 Neovim 编辑器外观和感觉的想法。

让我们回到正题。让我们看看如何以极客的方式在命令行中编译这段代码。在此之前,您可能想了解一下qmake工具。它是 Qt 的专有make实用程序。qmake实用程序不过是一个 make 工具,但它知道 Qt 特定的东西,因此它了解 moc、signals、slots 等,而典型的make实用程序则不知道。

以下命令应该帮助您创建一个.pro文件。.pro文件的名称将由qmake实用程序根据项目文件夹名称决定。.pro文件是 Qt Creator IDE 将相关文件组合为单个项目的方式。由于我们不打算使用 Qt Creator,我们将使用.pro文件创建Makefile,以便编译我们的 Qt 项目,就像编译普通的 C++项目一样。

图 5.3

当您发出qmake -project命令时,qmake 将扫描当前文件夹和当前文件夹下的所有子文件夹,并在Ex1.pro中包含头文件和源文件。顺便说一句,.pro文件是一个纯文本文件,可以使用任何文本编辑器打开,如图 5.4所示:

图 5.4

现在是时候创建Makefile,以Ex1.pro作为输入文件。由于Ex1.pro文件存在于当前目录中,我们不必显式提供Ex1.pro作为自动生成Makefile的输入文件。这个想法是,一旦我们有了.pro文件,我们只需要从.pro文件生成Makefile,发出命令:qmake。这将完成创建一个完整的Makefile的魔术,您可以使用make实用程序构建您的项目,如下面的屏幕截图所示:

图 5.5

这是我们一直在等待的时刻,对吧?是的,让我们执行我们的第一个 Qt Hello World 程序,如下面的屏幕截图所示:

图 5.6

恭喜!您已经完成了您的第一个 Qt 应用程序。在这个练习中,您学会了如何在 Ubuntu 中设置和配置 Qt,以及如何编写一个简单的 Qt 控制台应用程序,然后构建和运行它。最好的部分是您从命令行学会了所有这些。

Qt 小部件

Qt Widgets 是一个有趣的模块,支持许多小部件,如按钮、标签、编辑、组合、列表、对话框等。QWidget是所有小部件的基类,而QObject是几乎每个 Qt 类的基类。许多编程语言称之为 UI 控件,Qt 称之为小部件。尽管 Qt 可以在许多平台上运行,但它的主要平台仍然是 Linux;小部件在 Linux 世界中很常见。

编写我们的第一个 Qt GUI 应用程序

我们的第一个控制台应用程序真的很酷,不是吗?让我们继续探索。这一次,让我们编写一个简单的基于 GUI 的 Hello World 程序。过程基本上是一样的,只是在main.cpp中有一些小的改变。请参考以下完整的代码:

图 5.7

等一下。让我解释一下为什么第 23 行和第 29 行需要QApplication。每个 Qt GUI 应用程序必须有一个QApplication实例。QApplication为我们的应用程序提供了命令行开关的支持,因此需要提供参数计数argc)和参数值argv)。基于 GUI 的应用程序是事件驱动的,因此它们必须响应 Qt 世界中的事件或者更准确地说是信号。在第 29 行,exec函数启动了event循环,这确保应用程序等待用户交互,直到用户关闭窗口。所有用户事件将被接收到QApplication实例的事件队列中,然后通知给它的Child小部件。事件队列确保队列中存放的所有事件按照它们发生的顺序处理,即先进先出FIFO)。

如果你好奇地想要检查一下,如果你注释掉第 29 行会发生什么,应用程序仍然会编译和运行,但你可能看不到任何窗口。原因是main线程或main函数在第 25 行创建了一个QWidget的实例,这就是我们启动应用程序时看到的窗口。

在第 27 行,窗口实例被显示,但在没有第 29 行的情况下,main函数将立即终止应用程序,而不给你检查你的第一个 Qt GUI 应用程序的机会。值得一试,所以继续看看有没有第 29 行会发生什么。

让我们生成Makefile,如下面的截图所示:

图 5.8

现在让我们尝试使用make工具编译我们的项目,如下面的截图所示:

图 5.9

有趣,对吧?我们全新的 Qt GUI 程序无法编译。你注意到致命错误了吗?没关系,让我们了解一下为什么会发生这种情况。原因是我们还没有链接 Qt Widgets 模块,因为QApplication类是 Qt Widgets 模块的一部分。在这种情况下,你可能会想知道为什么你的第一个 Hello World 程序可以编译而没有任何问题。在我们的第一个程序中,QDebug类是QtCore模块的一部分,它隐式地被链接,而其他模块必须显式地被链接。让我们看看如何解决这个问题:

图 5.10

我们需要在Ex2.pro文件中添加QT += widgets,这样qmake工具就能理解在创建最终可执行文件时需要链接 Qt Widgets 的共享对象(在 Linux 中是.so文件),也就是在 Windows 中被称为动态链接库.dll文件)。一旦这个问题得到解决,我们必须运行qmake,这样Makefile就能反映出我们Ex2.pro文件中的新变化,如下面的截图所示:

图 5.11

很好。现在让我们检查我们的第一个基于 GUI 的 Qt 应用程序。在我的系统中,应用程序输出如图 5.12所示;如果一切顺利,您也应该得到类似的输出:

图 5.12

如果我们将窗口的标题设置为Hello Qt,那就太好了,对吧?让我们马上做到这一点:

图 5.13

添加第 26 行呈现的代码,以确保在测试新更改之前使用make实用程序构建项目:

图 5.14

布局

Qt 是跨平台应用程序框架,因此支持诸如布局之类的概念,用于开发在所有平台上看起来一致的应用程序,而不管不同的屏幕分辨率如何。当我们开发基于 GUI/HMI 的 Qt 应用程序时,在一个系统中开发的应用程序不应该在具有不同屏幕大小和分辨率的另一个系统上看起来不同。这是通过布局在 Qt 框架中实现的。布局有不同的风格。这有助于开发人员通过在窗口或对话框中组织各种小部件来设计专业外观的 HMI/GUI。布局在安排其子小部件的方式上有所不同。一个布局以水平方式排列其子小部件,另一个则以垂直或网格方式排列。当窗口或对话框调整大小时,布局会调整其子小部件,以便它们不会被截断或失焦。

使用水平布局编写 GUI 应用程序

让我们编写一个 Qt 应用程序,在对话框中放置一些按钮。Qt 支持各种有用的布局管理器,它们充当一个无形的画布,在那里可以将许多QWidgets排列好,然后再将它们附加到窗口或对话框上。每个对话框或窗口只能有一个布局。每个小部件只能添加到一个布局中;但是,可以组合多个布局来设计专业的用户界面。

现在让我们开始编写代码。在这个项目中,我们将以模块化的方式编写代码,因此我们将创建三个文件,分别命名为MyDlg.hMyDlg.cppmain.cpp

游戏计划如下:

  1. 创建QApplication的单个实例。

  2. 通过继承QDialog创建自定义对话框。

  3. 创建三个按钮。

  4. 创建一个水平框布局。

  5. 将三个按钮添加到不可见的水平框布局中。

  6. 将水平框布局的实例设置为我们对话框的布局。

  7. 显示对话框。

  8. QApplication上启动事件循环。

重要的是,我们遵循清晰的代码规范,以便我们的代码易于理解,并且可以由任何人维护。由于我们将遵循行业最佳实践,让我们在名为MyDlg.h的头文件中声明对话框,在名为MyDlg.cpp的源文件中定义对话框,并在具有main函数的main.cpp中使用MyDlg.cpp。每次MyDlg.cpp需要一个头文件时,让我们养成一个习惯,只在MyDlg.h中包含所有头文件;通过这样做,我们在MyDlg.cpp中看到的唯一头文件将是MyDlg.h

顺便说一句,我有没有告诉过你 Qt 遵循驼峰命名约定?是的,我刚刚提到了。到目前为止,您可能已经注意到所有 Qt 类都以字母Q开头,因为 Qt 的发明者喜欢 Emacs 中的字母“Q”,他们对该字体类型如此着迷,以至于决定在 Qt 中到处使用字母 Q。

最后一个建议。如果文件名和类名相似,其他人是否会更容易找到对话框类?我可以听到你说是的。一切准备就绪!让我们开始编写我们的 Qt 应用程序。首先,参考以下屏幕截图:

图 5.15

在前面的屏幕截图中,我们声明了一个名为MyDlg的类。它有一个布局,三个按钮和一个构造函数。现在请参考这个屏幕截图:

图 5.16

正如您在前面的屏幕截图中所看到的,我们定义了MyDlg构造函数并实例化了布局和三个按钮。在第 27 到 29 行,我们向布局添加了三个按钮。在第 31 行,我们将布局与我们的对话框关联起来。就是这样。在下一个屏幕截图中,我们定义了我们的main函数,它创建了一个QApplication的实例:

图 5.17

接着,我们创建了我们的自定义对话框实例并显示了对话框。最后,在第 27 行,我们启动了event循环,以便MyDlg可以响应用户交互。请参考以下屏幕截图:

图 5.18

前面的屏幕截图演示了构建和执行过程,还有我们可爱的应用程序。实际上,您可以尝试使用对话框来更好地理解水平布局。首先,水平拉伸对话框,注意所有按钮的宽度增加;然后,看看是否可以减小对话框的宽度以注意到所有按钮的宽度减小。这是任何布局管理器的工作。布局管理器安排小部件并检索窗口的大小,并在所有子小部件之间平均分配高度和宽度。布局管理器不断通知所有子小部件有关任何调整大小事件。但是,由各自的子小部件决定他们是否要调整大小或忽略布局调整信号。

要检查这种行为,请尝试垂直拉伸对话框。当您增加对话框的高度时,对话框的高度应该增加,但按钮不会增加其高度。这是因为每个 Qt 小部件都有自己的首选大小策略;根据其大小策略,它们可能会响应或忽略某些布局调整信号。

如果您希望按钮在垂直方向上也能拉伸,QPushButton提供了一种方法来实现这一点。实际上,QPushButton与任何其他小部件一样都是从QWidget继承的。setSizePolicy()方法来自QWidget的基类,即QPushButton

图 5.19

您注意到了第 37 行吗?是的,我在MyDlg的构造函数中设置了窗口标题,以保持我们的main函数简洁和干净。

在启动应用程序之前,请确保使用make实用程序构建了您的项目:

图 5.20

在突出显示的部分,我们覆盖了所有按钮的默认大小策略。在第 27 行,第一个参数QSizePolicy::Expanding是指水平策略,第二个参数是指垂直策略。要查找QSizePolicy的其他可能值,请参考 Qt API 参考中随时可用的助手,如下面的屏幕截图所示:

图 5.21

使用垂直布局编写 GUI 应用程序

在上一节中,您学会了如何使用水平框布局。在本节中,您将看到如何在应用程序中使用垂直框布局。

事实上,水平和垂直框布局只在它们如何排列小部件方面有所不同。例如,水平框布局将以从左到右的水平方式排列其子小部件,而垂直框布局将以从上到下的垂直方式排列其子小部件。

您可以从上一节中复制源代码,因为更改的性质很小。一旦您复制了代码,您的项目目录应该如下所示:

图 5.22

让我从MyDlg.h头文件开始演示更改,如下所示:

图 5.23

我已经用QVBoxLayout替换了QHBoxLayout;就是这样。是的,让我们继续进行与MyDlg.cpp相关的文件更改:

图 5.24

main.cpp中没有要做的更改;但是,我已经展示了main.cpp供您参考,如下所示:

图 5.25

现在我们需要做的就是自动生成Makefile,然后编译和运行程序,如下所示:

图 5.26

让我们执行我们全新的程序并检查输出。以下输出演示了QVBoxLayout以垂直的从上到下的方式排列小部件。当窗口被拉伸时,所有按钮的宽度将根据窗口的拉伸程度增加/减少:

图 5.27

使用框布局编写 GUI 应用程序

在前面的章节中,你学会了如何使用QHBoxLayoutQVBoxLayout。实际上,这两个类都是QBoxLayout的便利类。在QHBoxLayout的情况下,QHBoxLayout类已经成为QBoxLayout的子类,并配置了QBoxLayout::DirectionQBoxLayout::LeftToRight,而QVBoxLayout类已经成为QBoxLayout的子类,并配置了QBoxLayout::DirectionQBoxLayout::TopToBottom

除了这些值,QBoxLayout::Direction还支持其他各种值,如下所示:

  • QBoxLayout::LeftToRight:这将从左到右排列小部件

  • QBoxLayout::RightToLeft:这将从右到左排列小部件

  • QBoxLayout::TopToBottom:这将从上到下排列小部件

  • QBoxLayout::BottomToTop:这将从下到上排列小部件

让我们使用QBoxLayout和五个按钮编写一个简单的程序。

让我们从MyDlg.h头文件开始。我在MyDlg类中声明了五个按钮指针和一个QBoxLayout指针:

图 5.28

让我们看看我们的MyDlg.cpp源文件。如果你注意到截图中的第 21 行,QBoxLayout构造函数需要两个参数。第一个参数是你希望排列小部件的方向,第二个参数是一个可选参数,期望布局实例的父地址。

正如你可能已经猜到的那样,this指针指的是MyDlg实例指针,它恰好是布局的父指针。

图 5.29

再次,正如你可能已经猜到的那样,main.cpp文件不会改变,就像在下面的截图中所示的那样:

图 5.30

让我们编译和运行我们的程序,如下所示:

图 5.31

如果你注意到输出,它看起来像是一个水平框布局的输出,对吧?确实,因为我们已经将方向设置为QBoxLayout::LeftToRight。如果你将方向修改为,比如QBoxLayout::RightToLeft,那么按钮 1 将出现在右侧,按钮 2 将出现在按钮 1 的左侧,依此类推。因此,输出将如下截图所示:

  • 如果方向设置为QBoxLayout::RightToLeft,你会看到以下输出:

图 5.32

  • 如果方向设置为QBoxLayout::TopToBottom,你会看到以下输出:

图 5.33

  • 如果方向设置为QBoxLayout::BottomToTop,你会看到以下输出:

图 5.34

在所有前述的情况下,按钮都是按照相同的顺序添加到布局中,从按钮 1 到按钮 5。然而,根据QBoxLayout构造函数中选择的方向,框布局将安排按钮,因此输出会有所不同。

使用网格布局编写 GUI 应用程序

网格布局允许我们以表格方式排列小部件。这很容易,就像盒式布局一样。我们只需要指定每个小部件必须添加到布局的行和列。由于行和列索引从基于零的索引开始,因此行 0 的值表示第一行,列 0 的值表示第一列。理论够了;让我们开始写一些代码。

让我们声明 10 个按钮,并将它们添加到两行和五列中。除了特定的QGridLayout差异外,其余的东西将与以前的练习保持一致,因此,如果您已经理解了到目前为止讨论的概念,请继续创建MyDlg.hMyDl.cppmain.cpp

让我在以下截图中呈现MyDlg.h源代码:

图 5.35

以下是MyDlg.cpp的代码片段:

图 5.36

main.cpp源文件内容将与我们以前的练习保持一致;因此,我已经跳过了main.cpp的代码片段。由于您熟悉构建过程,我也跳过了它。如果您忘记了这一点,只需查看以前的部分以了解构建过程。

如果您已正确输入代码,则应该获得以下输出:

图 5.37

实际上,网格布局还有更多功能可供使用。让我们探索如何使按钮跨越多个单元格。我保证您将看到的内容更有趣。

我将修改MyDlg.hMyDlg.cpp,并保持main.cpp与以前的练习相同:

图 5.38

这是我们的MyDlg.cpp

图 5.39

注意第 35 到 38 行。现在让我们详细讨论addWidget()函数。

在第 35 行,pLayout->addWidget(pBttn1, 0, 0, 1, 1)代码执行以下操作:

  • 前三个参数将“按钮 1”添加到网格布局的第一行和第一列

  • 第四个参数1指示按钮 1 将仅占用一行

  • 第五个参数1指示按钮 1 将仅占用一列

  • 因此,很明显pBttn1应该呈现在单元格(0,0)处,它应该只占用一个网格单元

在第 36 行,pLayout->addWidget(pBttn2, 0, 1, 1, 2)代码执行以下操作:

  • 前三个参数将“按钮 2”添加到网格布局的第一行和第二列

  • 第四个参数指示“按钮 2”将占用一行

  • 第五个参数指示“按钮 2”将占用两列(即第一行的第二列和第三列)

  • 在底部行,“按钮 2”将呈现在单元格(0,1)处,它应该占用一行和两列

在第 37 行,pLayout->addWidget(pBttn3, 0, 3, 2, 1)代码执行以下操作:

  • 前三个参数将“按钮 3”添加到网格布局的第一行和第四列

  • 第四个参数指示“按钮 3”将占用两行(即第一行和第四列以及第二行和第四列)

  • 第五个参数指示“按钮 3”将占用一列

在第 38 行,pLayout->addWidget(pBttn4, 1, 0, 1, 3)代码执行以下操作:

  • 前三个参数将“按钮 4”添加到网格布局的第二行和第一列

  • 第四个参数指示“按钮 4”将占用一行

  • 第五个参数指示“按钮 4”将占用三列(即第二行的第一列,然后是第二列和第三列)

检查程序的输出:

图 5.40

信号和槽

信号和槽是 Qt 框架的一个重要部分。到目前为止,我们编写了一些简单但有趣的 Qt 应用程序,但我们还没有处理事件。现在是时候了解如何在我们的应用程序中支持事件了。

让我们编写一个简单的应用程序,只有一个按钮。当点击按钮时,检查是否可以在控制台上打印一些内容。

MyDlg.h头文件演示了如何声明MyDlg类:

图 5.41

以下截图演示了如何定义MyDlg构造函数以向对话框窗口添加一个按钮:

图 5.42

main.cpp如下所示:

图 5.43

让我们构建并运行我们的程序,然后添加对信号和槽的支持。如果你正确地按照说明操作,你的输出应该类似于以下截图:

图 5.44

如果你点击按钮,你会注意到什么都没有发生,因为我们还没有在我们的应用程序中添加对信号和槽的支持。好吧,是时候揭示一个秘密指令,这将帮助你使按钮响应按钮点击信号。等一下,是时候获取更多信息了。别担心,这和 Qt 有关。

Qt 信号只是事件,槽函数是事件处理程序函数。有趣的是,信号和槽都是普通的 C++函数;只有当它们被标记为信号或槽时,Qt 框架才能理解它们的目的并提供必要的样板代码。

Qt 中的每个小部件都支持一个或多个信号,也可以选择支持一个或多个槽。因此,在我们编写任何进一步的代码之前,让我们探索一下QPushButton支持哪些信号。

让我们使用 Qt 助手进行 API 参考:

图 5.45

如果你观察前面的截图,它有一个似乎涵盖了公共槽的内容部分,但我们没有看到任何列出的信号。这是很多信息。如果内容部分没有列出信号,QPushButton就不会直接支持信号。然而,也许它的基类,即QAbstractButton,会支持一些信号。QPushButton类部分提供了大量有用的信息,比如头文件名,必须链接到应用程序的 Qt 模块,即必须添加到.pro文件的 qmake 条目等。它还提到了QPushButton的基类。如果你继续向下滚动,你的 Qt 助手窗口应该看起来像这样:

图 5.46

如果你观察到Additional Inherited Members下面的突出部分,显然 Qt 助手暗示QPushButton已经从QAbstractButton继承了四个信号。因此,我们需要探索QAbstractButton支持的信号,以便在QPushButton中支持这些信号。

图 5.47

通过 Qt 助手的帮助,如前面的截图所示,很明显QAbstractButton类支持四个信号,这些信号也适用于QPushButton,因为QPushButtonQAbstractButton的子类。因此,让我们在这个练习中使用clicked()信号。

我们需要在MyDlg.hMyDlg.cpp中进行一些小的更改,以便使用clicked()信号。因此,我已经在以下截图中呈现了这两个文件,并突出显示了更改:

图 5.48

正如你所知,QDebug类用于调试目的。它为 Qt 应用程序提供了类似于cout的功能,但实际上并不需要用于信号和槽。我们在这里使用它们只是为了调试目的。在图 5.48中,第 34 行,MyDlg::onButtonClicked()是我们打算用作事件处理程序函数的槽函数,必须在按钮点击时调用。

以下的屏幕截图应该让你了解在MyDlg.cpp中需要进行哪些更改以支持信号和槽:

图 5.49

如果你观察前面屏幕截图中的第 40 到 42 行,MyDlg::onButtonClicked()方法是一个槽函数,必须在按钮被点击时调用。但是除非按钮的clicked()信号映射到MyDlg::onButtonClicked()槽,否则 Qt 框架不会知道它必须在按钮被点击时调用MyDlg::onButtonClicked()。因此,在 32 到 37 行,我们将按钮信号clicked()MyDlg实例的onButtonClicked()槽函数连接起来。connect 函数是从QDialog继承的。这又从它的最终基类QObject继承了这个函数。

关键是,每个希望参与信号和槽通信的类都必须是QObject或其子类。QObject提供了相当多的信号和槽支持,QObjectQtCore模块的一部分。令人惊奇的是,Qt 框架甚至为命令行应用程序提供了信号和槽支持。这就是为什么信号和槽支持内置到最终基类QObject中的原因,它是QtCore模块的一部分。

好的,让我们构建和运行我们的程序,看看信号在我们的应用程序中是否起作用:

图 5.50

有趣的是,我们并没有得到编译错误,但当我们点击按钮时,突出显示的警告消息会自动出现。这是 Qt 框架的提示,表明我们错过了一个重要的程序,这是使信号和槽工作所必需的。

让我们回顾一下我们在头文件和源文件中自动生成Makefile的过程:

  1. qmake -project命令确保当前文件夹中存在的所有头文件和源文件都包含在.pro文件中。

  2. qmake命令会读取当前文件夹中的.pro文件,并为我们的项目生成Makefile

  3. make命令将调用make实用程序。然后在当前目录中执行Makefile,并根据Makefile中定义的 make 规则构建我们的项目。

在第 1 步中,qmake实用程序扫描我们所有的自定义头文件,并检查它们是否需要信号和槽支持。任何具有Q_OBJECT宏的头文件都会提示qmake实用程序需要信号和槽支持。因此,我们必须在我们的MyDlg.h头文件中使用Q_OBJECT宏:

图 5.51

一旦在头文件中进行了推荐的更改,我们需要确保发出qmake命令。现在qmake实用程序将打开Ex8.pro文件,获取我们的项目头文件和源文件。当qmake解析MyDlg.h并找到Q_OBJECT宏时,它将了解到我们的MyDlg.h需要信号和槽,然后它将确保在MyDlg.h上调用 moc 编译器,以便在一个名为moc_MyDlg.cpp的文件中自动生成样板代码。然后,它将继续添加必要的规则到Makefile中,以便自动生成的moc_MyDlg.cpp文件与其他源文件一起构建。

现在你知道了 Qt 信号和槽的秘密,继续尝试这个过程,并检查你的按钮点击是否打印了“按钮点击...”的消息。我已经根据建议对我们的项目进行了构建。在下面的截图中,我已经突出显示了幕后发生的有趣的事情;这些是在命令行中工作与使用花哨的 IDE 时会得到的一些优势:

图 5.52

现在是时候测试我们支持信号和槽的酷而简单的应用程序的输出了。输出如下截图所示:

图 5.53

恭喜!你可以为自己鼓掌。你已经学会了在 Qt 中做一些很酷的东西。

在 Qt 应用程序中使用堆叠布局

由于你已经了解了信号和槽,所以在这一部分,让我们探讨如何在具有多个窗口的应用程序中使用堆叠布局;每个窗口可以是QWidgetQDialog。每个页面可能有自己的子窗口部件。我们即将开发的应用程序将演示堆叠布局的使用以及如何在堆叠布局中从一个窗口导航到另一个窗口。

图 5.54

这个应用程序将需要相当多的代码,因此很重要的是我们确保我们的代码结构良好,以满足结构和功能质量,尽量避免代码异味。

让我们创建四个可以堆叠在堆叠布局中的小部件/窗口,其中每个页面可以作为一个单独的类分割成两个文件:HBoxDlg.hHBoxDlg.cpp等等。

让我们从HBoxDlg.h开始。由于你熟悉布局,所以在这个练习中,我们将使用一个布局创建每个对话框,这样在子窗口之间导航时,你可以区分页面。否则,堆叠布局和其他布局之间将没有连接。

图 5.55

以下代码片段来自HBoxDlg.cpp文件:

图 5.56

同样,让我们按照以下方式编写VBoxDlg.h

图 5.57

让我们按照以下方式创建第三个对话框BoxDlg.h,使用框布局:

图 5.58

相应的BoxDlg.cpp源文件如下:

图 5.59

我们想要堆叠的第四个对话框是GridDlg,所以让我们看看GridDlg.h可以如何编写,如下截图所示:

图 5.60

相应的GridDlg.cpp将如下所示:

图 5.61

很好,我们已经创建了四个可以堆叠在MainDlg中的小部件。MainDlg将使用QStackedLayout,所以这个练习的关键是理解堆叠布局的工作原理。

让我们看看MainDlg.h应该如何编写:

图 5.62

MainDlg中,我们声明了三个槽函数,每个按钮一个,以支持四个窗口之间的导航逻辑。堆叠布局类似于选项卡小部件,只是选项卡小部件将提供自己的视觉方式来在选项卡之间切换,而在堆叠布局的情况下,由我们提供切换逻辑。

MainDlg.cpp将如下所示:

图 5.63

你可以选择一个框布局来容纳这三个按钮,因为我们希望按钮对齐到右边。然而,为了确保额外的空间被一些看不见的粘合剂占用,我们在第 44 行添加了一个拉伸项。

在 30 到 33 行之间,我们已经将所有四个子窗口添加到堆叠布局中,以便窗口可以逐个显示。HBox对话框添加到索引 0,VBox对话框添加到索引 1,依此类推。

53 到 58 行展示了如何将上一个按钮的点击信号与其对应的MainDlg::onPrevPage()槽函数连接起来。类似的连接必须为下一个和退出按钮配置:

图 5.64

78 行的if条件确保只有在我们处于第二个或更后续的子窗口时才发生切换逻辑。由于水平对话框位于索引 0,所以在当前窗口是水平对话框的情况下,我们无法导航到上一个窗口。类似的验证也适用于在 85 行切换到下一个子窗口。

堆叠布局支持setCurrentIndex()方法以切换到特定的索引位置;或者,如果在您的情况下更有效,也可以尝试setCurrentWidget()方法。

main.cpp看起来简短而简单,如下所示:

图 5.65

我们main函数的最好部分是,无论应用程序逻辑的复杂性如何,main函数都没有任何业务逻辑。这使得我们的代码清晰易懂,易于维护。

编写一个简单的数学应用程序,结合多个布局

在本节中,让我们探讨如何编写一个简单的数学应用程序。作为这个练习的一部分,我们将使用QLineEditQLabel小部件以及QFormLayout。我们需要设计一个 UI,如下面的屏幕截图所示:

图 5.66

QLabel是一个通常用于静态文本的小部件,QLineEdit允许用户提供单行输入。如前面的屏幕截图所示,我们将使用QVBoxLayout作为主要布局,以便以垂直方式排列QFormLayoutQBoxLayoutQFormLayout在需要创建一个表单的情况下非常方便,在左侧将有一个标题,右侧将有一些小部件。QGridLayout也可能适用,但在这种情况下使用QFormLayout更容易。

在这个练习中,我们将创建三个文件,分别是MyDlg.hMyDlg.cppmain.cpp。让我们从MyDlg.h源代码开始,然后再转到其他文件:

图 5.67

在上图中,声明了三种布局。垂直框布局用作主要布局,而框布局用于以右对齐的方式排列按钮。表单布局用于添加标签,即行编辑小部件。这个练习还将帮助您了解如何结合多个布局来设计专业的 HMI。

Qt 没有记录在单个窗口中可以组合的布局数量的限制。然而,如果可能的话,考虑使用最少的布局来设计 HMI 是一个好主意,特别是如果您正在努力开发一个内存占用小的应用程序。否则,在您的应用程序中使用多个布局也没有坏处。

在下面的屏幕截图中,您将了解到MyDlg.cpp源文件应该如何实现。在MyDlg构造函数中,所有按钮都被实例化并在框布局中以右对齐的方式布局。表单布局用于以类似网格的方式容纳QLineEdit小部件和它们对应的QLabel小部件。QLineEdit小部件通常用于提供单行输入;在这个特定的练习中,它们帮助我们提供必须根据用户的选择进行加法、减法等操作的数字输入。

图 5.68

我们main.cpp源文件的最好部分是,它基本上保持不变,无论我们的应用程序的复杂性如何。在这个练习中,我想告诉你一个关于MyDlg的秘密。你有没有注意到MyDlg的构造函数是在堆栈中实例化的,而不是在堆中?这个想法是,当main()函数退出时,main函数使用的堆栈会被解开,最终释放堆栈中存在的所有堆栈变量。当MyDlg被释放时,会导致调用MyDlg的析构函数。在 Qt 框架中,每个窗口部件构造函数都接受一个可选的父窗口部件指针,这个指针被顶层窗口的析构函数用来释放它的子窗口部件。有趣的是,Qt 维护一个类似树的数据结构来管理所有子窗口部件的内存。因此,如果一切顺利,Qt 框架将负责自动释放所有子窗口部件的内存位置。

这有助于 Qt 开发人员专注于应用程序方面,而 Qt 框架将负责内存管理。

图 5.69

你是不是很兴奋地想要检查我们新应用程序的输出?如果你构建并执行应用程序,那么你应该得到类似以下截图的输出。当然,我们还没有添加信号和槽支持,但设计 GUI 满意后再转向事件处理是个好主意:

图 5.70

如果你仔细观察,尽管按钮在QBoxLayout上是从右到左排列的,但按钮并没有对齐到右边。这种行为的原因是当窗口被拉伸时,框布局似乎已经将额外的水平空间分配给了所有的按钮。因此,让我们在框布局的最左边位置添加一个拉伸项,这样拉伸就会占据所有额外的空间,让按钮没有空间可以扩展。这样就可以得到右对齐的效果。添加拉伸后,代码将如下截图所示:

图 5.71

继续检查你的输出是否与以下截图一样。有时,作为开发人员,我们会急于看到输出而忘记编译我们的更改,所以确保项目再次构建。如果你没有看到输出中的任何变化,不用担心;只需尝试水平拉伸窗口,你应该会看到右对齐的效果,如下截图所示:

图 5.72

现在,既然我们有了一个看起来不错的应用程序,让我们添加信号和槽支持来响应按钮点击。我们不要急于现在包括加法和减法功能。我们将使用一些qDebug()打印语句来检查信号和槽是否连接正确,然后逐渐用实际功能替换它们。

如果你还记得之前的信号和槽练习,任何有兴趣支持信号和槽的 Qt 窗口都必须是QObject,并且应该在MyDlg.h头文件中包含Q_OBJECT宏,如下截图所示:

图 5.73

从第 41 行到 45 行开始,私有部分声明了四个槽方法。槽函数是常规的 C++函数,可以像其他 C++函数一样直接调用。然而,在这种情况下,槽函数只打算与MyDlg一起调用。因此它们被声明为私有函数,但如果你认为其他人可能会发现连接到你的公共槽有用,它们也可以被设为公共的。

很好,如果您已经走到这一步,这意味着您已经理解了到目前为止讨论的内容。好吧,让我们继续并在MyDlg.cpp中实现槽函数的定义,然后将“clicked()”按钮的信号连接到相应的槽函数:

图 5.74

现在是将信号连接到它们各自的槽的时间。正如您可能已经猜到的那样,我们需要在MyDlg构造函数中使用connect函数,如下面的屏幕截图所示,以将按钮点击传递到相应的槽中:

图 5.75

我们已经准备好了。是的,现在是展示时间。由于我们已经处理了大部分事情,让我们编译并检查我们小小的 Qt 应用程序的输出:

图 5.76

哎呀!我们遇到了一些链接器错误。这个问题的根本原因是我们在启用应用程序中的信号和槽支持后忘记调用qmake。别担心,让我们调用qmakemake,然后运行我们的应用程序:

图 5.77

太好了,我们已经解决了问题。这次制作工具似乎没有发出任何声音,我们能够启动应用程序。让我们检查信号和槽是否按预期工作。为此,请单击“添加”按钮,看看会发生什么:

图 5.78

哇!当我们点击“添加”按钮时,“qDebug()”控制台消息确认“MyDlg :: onAddButtonClicked()”槽被调用。如果您想要检查其他按钮的槽,请继续尝试点击其他按钮。

我们的应用程序将不完整,没有业务逻辑。因此,让我们将业务逻辑添加到“MyDlg :: onAddButtonClicked()”槽函数中,以执行添加并显示结果。一旦您学会了如何集成添加的业务逻辑,您可以遵循相同的方法并实现其余的槽函数:

图 5.79

在“MyDlg :: onAddButtonClicked()”函数中,业务逻辑已经集成。在第 82 行和第 83 行,我们试图提取用户在QLineEdit小部件中键入的值。QLineEdit中的“text()”函数返回QStringQString对象提供了“toInt()”,非常方便地提取由QString表示的整数值。一旦将值添加并存储在结果变量中,我们需要将结果整数值转换回QString,如第 86 行所示,以便将结果输入到QLineEdit中,如第 88 行所示。

类似地,您可以继续并集成其他数学运算的业务逻辑。一旦您彻底测试了应用程序,就可以删除“qDebug()”控制台的输出。我们添加了“qDebug()”消息以进行调试,因此现在可以清理它们了。

总结

在本章中,您学会了使用 Qt 应用程序框架开发 C ++ GUI 应用程序。以下是要点。

  • 您学会了在 Linux 中安装 Qt 和所需的工具。

  • 您学会了使用 Qt 框架编写简单的基于控制台的应用程序。

  • 您学会了使用 Qt 框架编写简单的基于 GUI 的应用程序。

  • 您学会了使用 Qt 信号和槽机制进行事件处理,以及元对象编译器如何帮助我们生成信号和槽所需的关键样板代码。

  • 您学会了在应用程序开发中使用各种 Qt 布局来开发吸引人的 HMI,在许多 Qt 支持的平台上看起来很棒。

  • 您学会了将多个布局组合到单个 HMI 中,以开发专业的 HMI。

  • 您学会了许多 Qt 小部件,以及它们如何帮助您开发令人印象深刻的 HMI。

  • 总的来说,您学会了使用 Qt 应用程序框架开发跨平台 GUI 应用程序。

在下一章中,您将学习在 C ++ 中进行多线程编程和 IPC。

第六章:多线程编程和进程间通信

本章将涵盖以下主题:

  • POSIX pthreads 简介

  • 使用 pthreads 库创建线程

  • 线程创建和自我识别

  • 启动线程

  • 停止线程

  • 使用 C++线程支持库

  • 数据竞争和线程同步

  • 加入和分离线程

  • 从线程发送信号

  • 向线程传递参数

  • 死锁和解决方案

  • 并发

  • Future、promise、packaged_task

  • 使用线程支持库进行并发

  • 并发应用程序中的异常处理

让我们通过本章讨论的一些有趣且易于理解的示例来学习这些主题。

POSIX pthreads 简介

Unix、Linux 和 macOS 在很大程度上符合 POSIX 标准。Unix 可移植操作系统接口POSIX)是一个 IEEE 标准,它帮助所有 Unix 和类 Unix 操作系统,即 Linux 和 macOS,通过一个统一的接口进行通信。

有趣的是,POSIX 也受到符合 POSIX 标准的工具的支持--Cygwin、MinGW 和 Windows 子系统 for Linux--它们提供了在 Windows 平台上的伪 Unix 样运行时和开发环境。

请注意,pthread 是一个在 Unix、Linux 和 macOS 中使用的符合 POSIX 标准的 C 库。从 C++11 开始,C++通过 C++线程支持库和并发库本地支持线程。在本章中,我们将了解如何以面向对象的方式使用 pthreads、线程支持和并发库。此外,我们将讨论使用本机 C++线程支持和并发库与使用 POSIX pthreads 或其他第三方线程框架的优点。

使用 pthreads 库创建线程

让我们直奔主题。你需要了解我们将讨论的 pthread API,开始动手。首先,这个函数用于创建一个新线程:

 #include <pthread.h>
 int pthread_create(
              pthread_t *thread,
              const pthread_attr_t *attr,
              void *(*start_routine)(void*),
              void *arg
 )

以下表格简要解释了前面函数中使用的参数:

API 参数 注释
pthread_t *thread 线程句柄指针
pthread_attr_t *attr 线程属性
void *(*start_routine)(void*) 线程函数指针
void * arg 线程参数

此函数阻塞调用线程,直到第一个参数中传递的线程退出,如下所示:

int pthread_join ( pthread_t *thread, void **retval )

以下表格简要描述了前面函数中的参数:

API 参数 注释
pthread_t thread 线程句柄
void **retval 输出参数,指示线程过程的退出代码

接下来的函数应该在线程上下文中使用。在这里,retval是调用此函数的线程的退出代码:

int pthread_exit ( void *retval )

这个函数中使用的参数如下:

API 参数 注释
void *retval 线程过程的退出代码

以下函数返回线程 ID:

pthread_t pthread_self(void)

让我们编写我们的第一个多线程应用程序:

#include <pthread.h>
#include <iostream>

using namespace std;

void* threadProc ( void *param ) {
  for (int count=0; count<3; ++count)
    cout << "Message " << count << " from " << pthread_self()
         << endl;
  pthread_exit(0);
}

int main() {
  pthread_t thread1, thread2, thread3;

  pthread_create ( &thread1, NULL, threadProc, NULL );
  pthread_create ( &thread2, NULL, threadProc, NULL );
  pthread_create ( &thread3, NULL, threadProc, NULL );

  pthread_join( thread1, NULL );
  pthread_join( thread2, NULL );

  pthread_join( thread3, NULL );

  return 0;

}

如何编译和运行

可以使用以下命令编译该程序:

g++ main.cpp -lpthread

如您所见,我们需要动态链接 POSIX pthread库。

查看以下截图,可视化多线程程序的输出:

在 ThreadProc 中编写的代码在线程上下文中运行。前面的程序总共有四个线程,包括主线程。我使用pthread_join阻塞了主线程,强制它等待其他三个线程先完成任务,否则主线程会在它们之前退出。当主线程退出时,应用程序也会退出,这会过早地销毁新创建的线程。

尽管我们按照相应的顺序创建了thread1thread2thread3,但不能保证它们会按照创建的确切顺序启动。

操作系统调度程序根据操作系统调度程序使用的算法决定必须启动线程的顺序。有趣的是,线程启动的顺序可能在同一系统的不同运行中有所不同。

C++是否原生支持线程?

从 C++11 开始,C++确实原生支持线程,并且通常被称为 C++线程支持库。C++线程支持库提供了对 POSIX pthreads C 库的抽象。随着时间的推移,C++原生线程支持已经得到了很大的改进。

我强烈建议您使用 C++原生线程而不是 pthread。C++线程支持库在所有平台上都受支持,因为它是标准 C++的正式部分,而不是仅在 Unix、Linux 和 macOS 上直接支持的 POSIX pthread库。

最好的部分是 C++17 中的线程支持已经成熟到了一个新的水平,并且准备在 C++20 中达到下一个水平。因此,考虑在项目中使用 C++线程支持库是一个不错的主意。

如何使用本机 C++线程功能编写多线程应用程序

有趣的是,使用 C++线程支持库编写多线程应用程序非常简单:

#include <thread>
using namespace std;
thread instance ( thread_procedure )

thread类是在 C++11 中引入的。此函数可用于创建线程。在 POSIX pthread库中,此函数的等效函数是pthread_create

参数 注释
thread_procedure 线程函数指针

现在稍微了解一下以下代码中返回线程 ID 的参数:

this_thread::get_id ()

此函数相当于 POSIX pthread库中的pthread_self()函数。请参考以下代码:

thread::join()

join()函数用于阻塞调用线程或主线程,以便等待已加入的线程完成其任务。这是一个非静态函数,因此必须在线程对象上调用它。

让我们看看如何使用上述函数来基于 C++编写一个简单的多线程程序。请参考以下程序:

#include <thread>
#include <iostream>
using namespace std;

void threadProc() {
  for( int count=0; count<3; ++count ) {
    cout << "Message => "
         << count
         << " from "
         << this_thread::get_id()
         << endl;
  }
}

int main() {
  thread thread1 ( threadProc );
  thread thread2 ( threadProc );
  thread thread3 ( threadProc );

  thread1.join();
  thread2.join();
  thread3.join();

  return 0;
}

C++版本的多线程程序看起来比 C 版本简单得多,更清晰。

如何编译和运行

以下命令将帮助您编译程序:

g++ main.cpp -std=c++17 -lpthread

在上一个命令中,-std=c++17指示 C++编译器启用 C++17 特性;但是,该程序将在支持 C++11 的任何 C++编译器上编译,您只需要用c++11替换c++17

程序的输出将如下所示:

在上述屏幕截图中以140开头的所有数字都是线程 ID。由于我们创建了三个线程,pthread库分别分配了三个唯一的线程 ID。如果您真的很想找到操作系统分配的线程 ID,您将需要在 Linux 中发出以下命令,同时应用程序正在运行:

 ps -T -p <process-id>

也许会让你惊讶的是,pthread库分配的线程 ID 与操作系统分配的线程 ID 是不同的。因此,从技术上讲,pthread库分配的线程 ID 只是一个与操作系统分配的线程 ID 不同的线程句柄 ID。您可能还想考虑的另一个有趣工具是top命令,用于探索进程中的线程:

 top -H -p <process-id>

这两个命令都需要您多线程应用程序的进程 ID。以下命令将帮助您找到此 ID:

ps -ef | grep -i <your-application-name>

您还可以在 Linux 中使用htop实用程序。

如果您想以编程方式获取操作系统分配的线程 ID,您可以在 Linux 中使用以下函数:

#include <sys/types.h>
pid_t gettid(void)

但是,如果您想编写一个可移植的应用程序,这并不推荐,因为这仅在 Unix 和 Linux 中受支持。

以面向对象的方式使用 std::thread

如果您一直在寻找类似于 Java 或 Qt 线程中的Thread类的 C++线程类,我相信您会觉得这很有趣:

#include <iostream>
#include <thread>
using namespace std;

class Thread {
private:
      thread *pThread;
      bool stopped;
      void run();
public:
      Thread();
      ~Thread();

      void start();
      void stop();
      void join();
      void detach();
};

这是一个包装类,作为本书中 C++线程支持库的便利类。Thread::run()方法是我们自定义的线程过程。由于我不希望客户端代码直接调用Thread::run()方法,所以我将 run 方法声明为private。为了启动线程,客户端代码必须在thread对象上调用 start 方法。

对应的Thread.cpp源文件如下:

#include "Thread.h"

Thread::Thread() {
     pThread = NULL;
     stopped = false;
}

Thread::~Thread() {
     delete pThread;
     pThread = NULL;
}

void Thread::run() {

     while ( ! stopped ) {
         cout << this_thread::get_id() << endl;
         this_thread::sleep_for ( 1s );
     }
     cout << "\nThread " << this_thread::get_id()
          << " stopped as requested." << endl;
     return;
}

void Thread::stop() {
    stopped = true;
}

void Thread::start() {
    pThread = new thread( &Thread::run, this );
}

void Thread::join() {
     pThread->join();
}

void Thread::detach() {
     pThread->detach();
}

从之前的Thread.cpp源文件中,你会了解到可以通过调用stop方法在需要时停止线程。这是一个简单而体面的实现;然而,在投入生产之前,还有许多其他边缘情况需要处理。尽管如此,这个实现已经足够好,可以理解本书中的线程概念。

很好,让我们看看我们的Thread类在main.cpp中如何使用:

#include "Thread.h"

int main() {

      Thread thread1, thread2, thread3;

      thread1.start();
      thread2.start();
      thread3.start();

      thread1.detach();
      thread2.detach();
      thread3.detach();

      this_thread::sleep_for ( 3s );

      thread1.stop();
      thread2.stop();
      thread3.stop();

      this_thread::sleep_for ( 3s );

      return 0;
}

我已经创建了三个线程,Thread类的设计方式是,只有在调用start函数时线程才会启动。分离的线程在后台运行;通常,如果要使线程成为守护进程,就需要将线程分离。然而,在应用程序退出之前,这些线程会被安全地停止。

如何编译和运行

以下命令可帮助编译程序:

g++ Thread.cpp main.cpp -std=c++17 -o threads.exe -lpthread

程序的输出将如下截图所示:

哇!我们可以按设计启动和停止线程,而且还是面向对象的方式。

你学到了什么?

让我们试着回顾一下我们到目前为止讨论过的内容:

  • 你学会了如何使用 POSIX 的pthread C 库编写多线程应用程序

  • C++编译器从 C++11 开始原生支持线程

  • 你学会了常用的基本 C++线程支持库 API

  • 你学会了如何使用 C++线程支持库编写多线程应用程序

  • 现在你知道为什么应该考虑使用 C++线程支持库而不是pthread C 库了

  • C++线程支持库是跨平台的,不像 POSIX 的pthread

  • 你知道如何以面向对象的方式使用 C++线程支持库

  • 你知道如何编写不需要同步的简单多线程应用程序

同步线程

在理想的世界中,线程会提供更好的应用程序性能。但是,有时会发现应用程序性能因多个线程而下降并不罕见。这种性能问题可能并不真正与多个线程有关;真正的罪魁祸首可能是设计。过多地使用同步会导致许多与线程相关的问题,也会导致应用程序性能下降。

无锁线程设计不仅可以避免与线程相关的问题,还可以提高整体应用程序的性能。然而,在实际世界中,可能会有多个线程需要共享一个或多个公共资源。因此,需要同步访问或修改共享资源的关键代码部分。在特定情况下可以使用各种同步机制。在接下来的章节中,我们将逐一探讨一些有趣和实用的使用案例。

如果线程没有同步会发生什么?

当有多个线程在进程边界内共享一个公共资源时,可以使用互斥锁来同步代码的关键部分。互斥锁是一种互斥锁,只允许一个线程访问由互斥锁保护的关键代码块。让我们通过一个简单的例子来理解互斥锁应用的需求。

让我们使用一个Bank Savings Account类,允许三个简单的操作,即getBalancewithdrawdepositAccount类可以实现如下所示的代码。为了演示目的,Account类以简单的方式设计,忽略了现实世界中所需的边界情况和验证。它被简化到Account类甚至不需要捕获帐号号码的程度。我相信有许多这样的要求被悄悄地忽略了简单性。别担心!我们的重点是学习 mutex,这里展示了一个例子:

#include <iostream>
using namespace std;

class Account {
private:
  double balance;
public:
  Account( double );
  double getBalance( );
  void deposit ( double amount );
  void withdraw ( double amount ) ;
};

Account.cpp源文件如下:

#include "Account.h"

Account::Account(double balance) {
  this->balance = balance;
}

double Account::getBalance() {
  return balance;
}

void Account::withdraw(double amount) {
  if ( balance < amount ) {
    cout << "Insufficient balance, withdraw denied." << endl;
    return;
  }

  balance = balance - amount;
}

void Account::deposit(double amount) {
  balance = balance + amount;
}

现在,让我们创建两个线程,即DEPOSITORWITHDRAWERDEPOSITOR线程将存入 INR 2000.00,而WITHDRAWER线程将每隔一秒提取 INR 1000.00。根据我们的设计,main.cpp源文件可以实现如下:

#include <thread>
#include "Account.h"
using namespace std;

enum ThreadType {
  DEPOSITOR,
  WITHDRAWER
};

Account account(5000.00);

void threadProc ( ThreadType typeOfThread ) {

  while ( 1 ) {
  switch ( typeOfThread ) {
    case DEPOSITOR: {
      cout << "Account balance before the deposit is "
           << account.getBalance() << endl;

      account.deposit( 2000.00 );

      cout << "Account balance after deposit is "
           << account.getBalance() << endl;
      this_thread::sleep_for( 1s );
}
break;

    case WITHDRAWER: {
      cout << "Account balance before withdrawing is "
           << account.getBalance() << endl;

      account.deposit( 1000.00 );
      cout << "Account balance after withdrawing is "
           << account.getBalance() << endl;
      this_thread::sleep_for( 1s );
    }
    break;
  }
  }
}

int main( ) {
  thread depositor ( threadProc, ThreadType::DEPOSITOR );
  thread withdrawer ( threadProc, ThreadType::WITHDRAWER );

  depositor.join();
  withdrawer.join();

  return 0;
}

如果您观察main函数,线程构造函数接受两个参数。第一个参数是您现在应该熟悉的线程过程。第二个参数是一个可选参数,如果您想要向线程函数传递一些参数,可以提供该参数。

如何编译和运行

可以使用以下命令编译该程序:

g++ Account.cpp main.cpp -o account.exe -std=c++17 -lpthread

如果您按照指示的所有步骤进行了操作,您的代码应该可以成功编译。

现在是时候执行并观察我们的程序如何工作了!

不要忘记WITHDRAWER线程总是提取 INR 1000.00,而DEPOSITOR线程总是存入 INR 2000.00。以下输出首先传达了这一点。WITHDRAWER线程开始提取,然后是似乎已经存入了钱的DEPOSITOR线程。

尽管我们首先启动了DEPOSITOR线程,然后启动了WITHDRAWER线程,但看起来操作系统调度程序似乎首先安排了WITHDRAWER线程。不能保证这种情况总是会发生。

根据输出,WITHDRAWER线程和DEPOSITOR线程似乎偶然地交替进行工作。它们会继续这样一段时间。在某个时候,两个线程似乎会同时工作,这就是事情会崩溃的时候,如下所示:

观察输出的最后四行非常有趣。看起来WITHDRAWERDEPOSITOR线程都在检查余额,余额为 INR 9000.00。您可能注意到DEPOSITOR线程的打印语句存在不一致;根据DEPOSITOR线程,当前余额为 INR 9000.00。因此,当它存入 INR 2000.00 时,余额应该总共为 INR 11000.00。但实际上,存款后的余额为 INR 10000.00。这种不一致的原因是WITHDRAWER线程在DEPOSITOR线程存钱之前提取了 INR 1000.00。尽管从技术上看,余额似乎总共正确,但很快就会出现问题;这就是需要线程同步的时候。

让我们使用 mutex

现在,让我们重构threadProc函数并同步修改和访问余额的关键部分。我们需要一个锁定机制,只允许一个线程读取或写入余额。C++线程支持库提供了一个称为mutex的适当锁。mutex锁是一个独占锁,只允许一个线程在同一进程边界内操作关键部分代码。直到获得锁的线程释放mutex锁,所有其他线程都必须等待他们的轮次。一旦线程获得mutex锁,线程就可以安全地访问共享资源。

main.cpp文件可以重构如下;更改部分已用粗体标出:

#include <iostream>
#include <thread>
#include <mutex>
#include "Account.h"
using namespace std;

enum ThreadType {
  DEPOSITOR,
  WITHDRAWER
};

mutex locker;

Account account(5000.00);

void threadProc ( ThreadType typeOfThread ) {

  while ( 1 ) {
  switch ( typeOfThread ) {
    case DEPOSITOR: {

      locker.lock();

      cout << "Account balance before the deposit is "
           << account.getBalance() << endl;

      account.deposit( 2000.00 );

      cout << "Account balance after deposit is "
           << account.getBalance() << endl;

      locker.unlock();
      this_thread::sleep_for( 1s );
}
break;

    case WITHDRAWER: {

      locker.lock();

      cout << "Account balance before withdrawing is "
           << account.getBalance() << endl;

      account.deposit( 1000.00 );
      cout << "Account balance after withdrawing is "
           << account.getBalance() << endl;

      locker.unlock();
      this_thread::sleep_for( 1s );
    }
    break;
  }
  }
}

int main( ) {
  thread depositor ( threadProc, ThreadType::DEPOSITOR );
  thread withdrawer ( threadProc, ThreadType::WITHDRAWER );

  depositor.join();
  withdrawer.join();

  return 0;
}

您可能已经注意到互斥锁是在全局范围内声明的。理想情况下,我们可以将互斥锁声明为类的静态成员,而不是全局变量。由于所有线程都应该由同一个互斥锁同步,确保您使用全局mutex锁或静态mutex锁作为类成员。

main.cpp源文件中重构后的threadProc如下所示;改动用粗体标出:

void threadProc ( ThreadType typeOfThread ) {

  while ( 1 ) {
  switch ( typeOfThread ) {
    case DEPOSITOR: {

      locker.lock();

      cout << "Account balance before the deposit is "
           << account.getBalance() << endl;

      account.deposit( 2000.00 );

      cout << "Account balance after deposit is "
           << account.getBalance() << endl;

      locker.unlock();
      this_thread::sleep_for( 1s );
}
break;

    case WITHDRAWER: {

      locker.lock();

      cout << "Account balance before withdrawing is "
           << account.getBalance() << endl;

      account.deposit( 1000.00 );
      cout << "Account balance after withdrawing is "
           << account.getBalance() << endl;

      locker.unlock();
      this_thread::sleep_for( 1s );
    }
    break;
  }
  }
}

lock()unlock()之间包裹的代码是由互斥锁锁定的临界区。

如您所见,threadProc函数中有两个临界区块,因此重要的是要理解只有一个线程可以进入临界区。例如,如果存款线程已经进入了其临界区,那么取款线程必须等到存款线程释放锁,反之亦然。

从技术上讲,我们可以用lock_guard替换所有原始的lock()unlock()互斥锁方法,因为这样可以确保即使代码的临界区块抛出异常,互斥锁也总是被解锁。这将避免饥饿和死锁情况。

是时候检查我们重构后程序的输出了:

好的,您检查了DEPOSITORWITHDRAWER线程报告的余额了吗?是的,它们总是一致的,不是吗?是的,输出证实了代码是同步的,现在是线程安全的。

虽然我们的代码在功能上是正确的,但还有改进的空间。让我们重构代码,使其面向对象且高效。

让我们重用Thread类,并将所有与线程相关的内容抽象到Thread类中,并摆脱全局变量和threadProc

首先,让我们观察重构后的Account.h头文件,如下所示:

#ifndef __ACCOUNT_H
#define __ACCOUNT_H

#include <iostream>
using namespace std;

class Account {
private:
  double balance;
public:
  Account( double balance );
  double getBalance();
  void deposit(double amount);
  void withdraw(double amount);
};

#endif

如您所见,Account.h头文件并没有改变,因为它已经看起来很整洁。

相应的Account.cpp源文件如下:

#include "Account.h"

Account::Account(double balance) {
  this->balance = balance;
}

double Account::getBalance() {
  return balance;
}

void Account::withdraw(double amount) {
  if ( balance < amount ) {
    cout << "Insufficient balance, withdraw denied." << endl;
    return;
  }

  balance = balance - amount;
}

void Account::deposit(double amount) {
  balance = balance + amount;
}

最好将Account类与与线程相关的功能分开,以保持代码整洁。此外,让我们了解一下我们编写的Thread类如何重构以使用互斥同步机制,如下所示:

#ifndef __THREAD_H
#define __THREAD_H

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
#include "Account.h"

enum ThreadType {
   DEPOSITOR,
   WITHDRAWER
};

class Thread {
private:
      thread *pThread;
      Account *pAccount;
      static mutex locker;
      ThreadType threadType;
      bool stopped;
      void run();
public:
      Thread(Account *pAccount, ThreadType typeOfThread);
      ~Thread();
      void start();
      void stop();
      void join();
      void detach();
};

#endif

在之前显示的Thread.h头文件中,作为重构的一部分进行了一些更改。由于我们希望使用互斥锁来同步线程,Thread类包括了 C++线程支持库的互斥锁头文件。由于所有线程都应该使用相同的mutex锁,因此mutex实例被声明为静态。由于所有线程都将共享相同的Account对象,因此Thread类具有指向Account对象的指针,而不是堆栈对象。

Thread::run()方法是我们将要提供给 C++线程支持库Thread类构造函数的Thread函数。由于没有人预期会直接调用run方法,因此run方法被声明为私有。根据我们的Thread类设计,类似于 Java 和 Qt,客户端代码只需调用start方法;当操作系统调度程序给予run绿灯时,run线程过程将自动调用。实际上,这里并没有什么魔术,因为在创建线程时,run方法地址被注册为Thread函数。

通常,我更喜欢在用户定义的头文件中包含所有依赖的头文件,而用户定义的源文件只包含自己的头文件。这有助于将头文件组织在一个地方,这种纪律有助于保持代码更清晰,也提高了整体可读性和代码可维护性。

Thread.cpp源代码可以重构如下:

#include "Thread.h"

mutex Thread::locker;

Thread::Thread(Account *pAccount, ThreadType typeOfThread) {
  this->pAccount = pAccount;
  pThread = NULL;
  stopped = false;
  threadType = typeOfThread;
}

Thread::~Thread() {
  delete pThread;
  pThread = NULL;
}

void Thread::run() {
    while(1) {
  switch ( threadType ) {
    case DEPOSITOR:
      locker.lock();

      cout << "Depositor: current balance is " << pAccount->getBalance() << endl;
      pAccount->deposit(2000.00);
      cout << "Depositor: post deposit balance is " << pAccount->getBalance() << endl;

      locker.unlock();

      this_thread::sleep_for(1s);
      break;

    case WITHDRAWER:
      locker.lock();

      cout << "Withdrawer: current balance is " << 
               pAccount->getBalance() << endl;
      pAccount->withdraw(1000.00);
      cout << "Withdrawer: post withraw balance is " << 
               pAccount->getBalance() << endl;

      locker.unlock();

      this_thread::sleep_for(1s);
      break;
  }
    }
}

void Thread::start() {
  pThread = new thread( &Thread::run, this );
}

void Thread::stop() {
  stopped = true;
}

void Thread::join() {
  pThread->join();
}

void Thread::detach() {
  pThread->detach();
}

threadProc函数已经移动到Thread类的run方法中。毕竟,main函数或main.cpp源文件不应该有任何业务逻辑,因此它们经过重构以改进代码质量。

现在让我们看看重构后的main.cpp源文件有多清晰:

#include "Account.h"
#include "Thread.h"

int main( ) {

  Account account(5000.00);

  Thread depositor ( &account, ThreadType::DEPOSITOR );
  Thread withdrawer ( &account, ThreadType::WITHDRAWER );

  depositor.start();
  withdrawer.start();

  depositor.join();
  withdrawer.join();

  return 0;
}

之前展示的main()函数和整个main.cpp源文件看起来简短而简单,没有任何复杂的业务逻辑。

C++支持五种类型的互斥锁,即mutextimed_mutexrecursive_mutexrecursive_timed_mutexshared_timed_mutex

如何编译和运行

以下命令可帮助您编译重构后的程序:

g++ Thread.cpp Account.cpp main.cpp -o account.exe -std=c++17 -lpthread

太棒了!如果一切顺利,程序应该可以顺利编译而不会发出任何噪音。

在我们继续下一个主题之前,快速查看一下这里显示的输出:

太棒了!它运行良好。DEPOSITORWITHDRAWER线程似乎可以合作地工作,而不会搞乱余额和打印语句。毕竟,我们已经重构了代码,使代码更清晰,而不修改功能。

死锁是什么?

在多线程应用程序中,一切看起来都很酷和有趣,直到我们陷入死锁。假设有两个线程,即READERWRITER。当READER线程等待已被WRITER获取的锁时,死锁可能发生,而WRITER线程等待读者释放已被READER拥有的锁,反之亦然。通常,在死锁场景中,两个线程将无休止地等待对方。

一般来说,死锁是设计问题。有时,死锁可能会很快被检测出来,但有时可能会非常棘手,找到根本原因。因此,底线是必须谨慎地正确使用同步机制。

让我们通过一个简单而实用的例子来理解死锁的概念。我将重用我们的Thread类,稍作修改以创建死锁场景。

修改后的Thread.h头文件如下所示:

#ifndef __THREAD_H
#define __THREAD_H

#include <iostream>
#include <string>
#include <thread>
#include <mutex>
#include <string>
using namespace std;

enum ThreadType {
  READER,
  WRITER
};

class Thread {
private:
  string name;
  thread *pThread;
  ThreadType threadType;
  static mutex commonLock;
  static int count;
  bool stopped;
  void run( );
public:
  Thread ( ThreadType typeOfThread );
  ~Thread( );
  void start( );
  void stop( );
  void join( );
  void detach ( );
  int getCount( );
  int updateCount( );
};
#endif

ThreadType枚举帮助将特定任务分配给线程。Thread类有两个新方法:Thread::getCount()Thread::updateCount()。这两种方法将以一种共同的mutex锁同步,从而创建死锁场景。

好的,让我们继续并审查Thread.cpp源文件:

#include "Thread.h"

mutex Thread::commonLock;

int Thread::count = 0;

Thread::Thread( ThreadType typeOfThread ) {
  pThread = NULL;
  stopped = false;
  threadType = typeOfThread;
  (threadType == READER) ? name = "READER" : name = "WRITER";
}

Thread::~Thread() {
  delete pThread;
  pThread = NULL;
}

int Thread::getCount( ) {
  cout << name << " is waiting for lock in getCount() method ..." <<
endl;
  lock_guard<mutex> locker(commonLock);
  return count;
}

int Thread::updateCount( ) {
  cout << name << " is waiting for lock in updateCount() method ..." << endl;
  lock_guard<mutex> locker(commonLock);
  int value = getCount();
  count = ++value;
  return count;
}

void Thread::run( ) {
  while ( 1 ) {
    switch ( threadType ) {
      case READER:
        cout << name<< " => value of count from getCount() method is " << getCount() << endl;
        this_thread::sleep_for ( 500ms );
      break;

      case WRITER:
        cout << name << " => value of count from updateCount() method is" << updateCount() << endl;
        this_thread::sleep_for ( 500ms );
      break;
    }
  }
}

void Thread::start( ) {
  pThread = new thread ( &Thread::run, this );
}

void Thread::stop( ) {
  stopped = true;
}

void Thread::join( ) {
  pThread->join();
}

void Thread::detach( ) {
  pThread->detach( );
}

到目前为止,您应该对Thread类非常熟悉。因此,让我们专注于Thread::getCount()Thread::updateCount()方法的讨论。std::lock_guard<std::mutex>是一个模板类,它使我们不必调用mutex::unlock()。在堆栈展开过程中,将调用lock_guard析构函数;这将调用mutex::unlock()

底线是,从创建std::lock_guard<std::mutex>实例的那一刻起,直到方法结束的所有语句都受到互斥锁的保护。

好的,让我们深入研究main.cpp文件:

#include <iostream>
using namespace std;

#include "Thread.h"

int main ( ) {

      Thread reader( READER );
      Thread writer( WRITER );
      reader.start( );
      writer.start( );
      reader.join( );
      writer.join( );
      return 0;
}

main()函数相当不言自明。我们创建了两个线程,即readerwriter,它们在创建后启动。主线程被迫等待,直到读者和写者线程退出。

如何编译和运行

您可以使用以下命令编译此程序:

g++ Thread.cpp main.cpp -o deadlock.exe -std=c++17 -lpthread

观察程序的输出,如下所示:

参考Thread::getCount()Thread::updateCount()方法的代码片段:

int Thread::getCount() {
         cout << name << " is waiting for lock in getCount() method ..." << endl;
         lock_guard<mutex> locker(commonLock);
         cout << name << " has acquired lock in getCount() method ..." << endl;
         return count;
}
int Thread::updateCount() {
        count << name << " is waiting for lock in updateCount() method ..." << endl;
        lock_guard<mutex> locker(commonLock);
        cout << name << " has acquired lock in updateCount() method ..." << endl;
        int value = getCount();
        count = ++value;
        return count;
}

从先前的输出截图图像中,我们可以理解WRITER线程似乎已经首先启动。根据我们的设计,WRITER线程将调用Thread::updateCount()方法,这将调用Thread::getCount()方法。

从输出的截图中,从打印语句可以明显看出,Thread::updateCount()方法首先获取了锁,然后调用了Thread::getCount()方法。但由于Thread::updateCount()方法没有释放互斥锁,因此由WRITER线程调用的Thread::getCount()方法无法继续。同时,操作系统调度程序已启动了READER线程,似乎在等待WRITER线程获取的mutex锁。因此,为了完成其任务,READER线程必须获取Thread::getCount()方法的锁;然而,在WRITER线程释放锁之前,这是不可能的。更糟糕的是,WRITER线程无法完成其任务,直到其自己的Thread::getCount()方法调用完成其任务。这就是所谓的死锁

这要么是设计问题,要么是逻辑问题。在 Unix 或 Linux 中,我们可以使用 Helgrind 工具通过竞争类似的同步问题来查找死锁。Helgrind 工具与 Valgrind 工具一起提供。最好的部分是,Valgrind 和 Helgrind 都是开源工具。

为了获得导致死锁或竞争问题的源代码行号,我们需要以调试模式编译我们的代码,如现在所示,使用-g标志:

g++ main.cpp Thread.cpp -o deadlock.exe -std=c++17 -lpthread -g

Helgrind 工具可用于检测死锁和类似问题,如下所示:

valgrind --tool=helgrind ./deadlock.exe

以下是 Valgrind 输出的简短摘录:

解决问题的一个简单方法是重构Thread::updateCount()方法,如下所示:

int Thread::updateCount() {
        int value = getCount();

        count << name << " is waiting for lock in updateCount() method ..." << endl;
        lock_guard<mutex> locker(commonLock);
        cout << name << " has acquired lock in updateCount() method ..." << endl;
        count = ++value;

        return count;
}

重构后程序的输出如下:

有趣的是,对于大多数复杂的问题,解决方案通常非常简单。换句话说,有时愚蠢的错误可能导致严重的关键错误。

理想情况下,我们应该在设计阶段努力防止死锁问题,这样我们就不必在进行复杂的调试时破费心机。C++线程支持库的互斥锁类提供了mutex::try_lock()(自 C++11 以来)、std::timed_mutex(自 C++11 以来)和std::scoped_lock(自 C++17 以来)以避免死锁和类似问题。

你学到了什么?

让我们总结一下要点:

  • 我们应该在可能的情况下设计无锁线程

  • 与重度同步/顺序线程相比,无锁线程往往表现更好

  • 互斥锁是一种互斥同步原语

  • 互斥锁有助于同步访问共享资源,一次一个线程

  • 死锁是由于互斥锁的错误使用,或者一般来说,由于任何同步原语的错误使用而发生的

  • 死锁是逻辑或设计问题的结果

  • 在 Unix 和 Linux 操作系统中,可以使用 Helgrind/Valgrind 开源工具检测死锁

共享互斥锁

共享互斥锁同步原语支持两种模式,即共享和独占。在共享模式下,共享互斥锁将允许许多线程同时共享资源,而不会出现任何数据竞争问题。在独占模式下,它的工作方式就像常规互斥锁一样,即只允许一个线程访问资源。如果您有多个读者可以安全地访问资源,并且只允许一个线程修改共享资源,这是一个合适的锁原语。有关更多详细信息,请参阅 C++17 章节。

条件变量

条件变量同步原语用于当两个或更多线程需要相互通信,并且只有在它们收到特定信号或事件时才能继续时。等待特定信号或事件的线程必须在开始等待信号或事件之前获取互斥锁。

让我们尝试理解生产者/消费者问题中条件变量的用例。我将创建两个线程,即PRODUCERCONSUMERPRODUCER线程将向队列添加一个值,并通知CONSUMER线程。CONSUMER线程将等待来自PRODUCER的通知。收到来自PRODUCER线程的通知后,CONSUMER线程将从队列中移除条目并打印它。

让我们了解一下这里显示的Thread.h头文件如何使用条件变量和互斥量:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <string>

using namespace std;

enum ThreadType {
  PRODUCER,
  CONSUMER
};

class Thread {
private:
  static mutex locker;
  static condition_variable untilReady;
  static bool ready;
  static queue<int> appQueue;
  thread *pThread;
  ThreadType threadType;
  bool stopped;
  string name;

  void run();
public:
  Thread(ThreadType typeOfThread);
  ~Thread();
  void start();
  void stop();
  void join();
  void detach();
};

由于PRODUCERCONSUMER线程应该使用相同的互斥量和conditional_variable,它们被声明为静态。条件变量同步原语需要一个谓词函数,该函数将使用就绪布尔标志。因此,我也在静态范围内声明了就绪标志。

让我们继续看Thread.cpp源文件,如下所示:

#include "Thread.h"

mutex Thread::locker;
condition_variable Thread::untilReady;
bool Thread::ready = false;
queue<int> Thread::appQueue;

Thread::Thread( ThreadType typeOfThread ) {
  pThread = NULL;
  stopped = false;
  threadType = typeOfThread;
  (CONSUMER == typeOfThread) ? name = "CONSUMER" : name = "PRODUCER";
}

Thread::~Thread( ) {
  delete pThread;
  pThread = NULL;
}

void Thread::run() {
  int count = 0;
  int data = 0;
  while ( 1 ) {
    switch ( threadType ) {
    case CONSUMER: 
    {

      cout << name << " waiting to acquire mutex ..." << endl;

      unique_lock<mutex> uniqueLocker( locker );

      cout << name << " acquired mutex ..." << endl;
      cout << name << " waiting for conditional variable signal..." << endl;

      untilReady.wait ( uniqueLocker, [] { return ready; } );

      cout << name << " received conditional variable signal ..." << endl;

      data = appQueue.front( ) ;

      cout << name << " received data " << data << endl;

      appQueue.pop( );
      ready = false;
    }
      cout << name << " released mutex ..." << endl;
    break;

    case PRODUCER:
    {
      cout << name << " waiting to acquire mutex ..." << endl;
      unique_lock<mutex> uniqueLocker( locker );
      cout << name << " acquired mutex ..." << endl;
      if ( 32000 == count ) count = 0;
      appQueue.push ( ++ count );
      ready = true;
      uniqueLocker.unlock();
      cout << name << " released mutex ..." << endl;
      untilReady.notify_one();
      cout << name << " notified conditional signal ..." << endl;
    }
    break;
  }
  }
}

void Thread::start( ) {
  pThread = new thread ( &Thread::run, this );
}

void Thread::stop( ) {
  stopped = true;
}

void Thread::join( ) {
  pThread->join( );
}

void Thread::detach( ) {
  pThread->detach( );
}

在前面的Thread类中,我使用了unique_lock<std::mutex>conditional_variable::wait()方法需要unique_lock,因此我在这里使用了unique_lock。现在,unique_lock<std::mutex>支持所有权转移、递归锁定、延迟锁定、手动锁定和解锁,而不像lock_guard<std::mutex>那样在删除unique_lock时自动解锁。lock_guard<std::mutex>实例会立即锁定互斥量,并且当lock_guard<std::mutex>实例超出作用域时,互斥量会自动解锁。但是,lock_guard不支持手动解锁。

因为我们没有使用延迟锁定选项创建unique_lock实例,所以unique_lock会立即锁定互斥量,就像lock_guard一样。

Thread::run()方法是我们的线程函数。根据提供给Thread构造函数的ThreadType,线程实例将作为PRODUCERCONSUMER线程来表现。

PRODUCER线程首先锁定互斥量,并将整数附加到队列中,该队列在PRODUCERCONSUMER线程之间共享。一旦队列更新,PRODUCER会在通知CONSUMER之前解锁互斥量;否则,CONSUMER将无法获取互斥量并接收条件变量信号。

CONSUMER线程首先获取互斥量,然后等待条件变量信号。收到条件信号后,CONSUMER线程从队列中检索值并打印该值,并重置就绪标志,以便该过程可以重复,直到应用程序终止。

建议使用unique_lock<std::mutex>lock_guard<std::mutex>scoped_lock<std::mutex>来避免死锁。有时,我们可能不会解锁导致死锁;因此,直接使用互斥量不被推荐。

现在让我们看一下main.cpp文件中的代码:

#include "Thread.h"

int main ( ) {

  Thread producer( ThreadType::PRODUCER );
  Thread consumer( ThreadType::CONSUMER );

  producer.start();
  consumer.start();

  producer.join();
  consumer.join();

  return 0;
} 

如何编译和运行

使用以下命令编译程序:

g++ Thread.cpp main.cpp -o conditional_variable.exe -std=c++17 -lpthread

以下快照展示了程序的输出:

太好了!我们的条件变量演示按预期工作。生产者和消费者线程在条件变量的帮助下合作工作。

你学到了什么?

让我总结一下你在本节学到的要点:

  • 多个线程可以通过使用条件变量相互发信号来共同工作

  • 条件变量要求等待线程在等待条件信号之前获取互斥量。

  • 每个条件变量都需要接受互斥量的unique_lock

  • unique_lock<std::mutex>方法与lock_guard<std::mutex>的工作方式完全相同,还具有一些额外的有用功能,如延迟锁定、手动锁定/解锁、所有权转移等

  • Unique_locklock_guard一样帮助避免死锁,因为被unique_lock包装的互斥量在unique_lock实例超出作用域时会自动解锁

  • 您学会了如何编写涉及相互信号以进行同步的多线程应用程序

信号量

信号量是另一种有用的线程同步机制。但与互斥锁不同,信号量允许多个线程同时访问相似的共享资源。它的同步原语支持两种类型,即二进制信号量和计数信号量。

二进制信号量的工作原理与互斥锁类似,也就是说,任何时候只有一个线程可以访问共享资源。然而,不同之处在于互斥锁只能由拥有它的同一个线程释放;而信号量锁可以被任何线程释放。另一个显著的区别是,一般来说,互斥锁在进程边界内工作,而信号量可以跨进程使用。这是因为它是一种重量级的锁,不像互斥锁。然而,如果在共享内存区域创建,互斥锁也可以跨进程使用。

计数信号量允许多个线程共享有限数量的共享资源。而互斥锁一次只允许一个线程访问共享资源,计数信号量允许多个线程共享有限数量的资源,通常至少是两个或更多。如果一个共享资源必须一次只能被一个线程访问,但线程跨越进程边界,那么可以使用二进制信号量。虽然在同一进程内使用二进制信号量是可能的,但它并不高效,但它也可以在同一进程内工作。

不幸的是,C++线程支持库直到 C++17 才原生支持信号量和共享内存。C++17 支持使用原子操作进行无锁编程,必须确保原子操作是线程安全的。信号量和共享内存允许来自其他进程的线程修改共享资源,这对并发模块来说是相当具有挑战性的,以确保原子操作在进程边界上的线程安全。C++20 似乎在并发方面有所突破,因此我们需要等待并观察其动向。

然而,这并不妨碍您使用线程支持库提供的互斥锁和条件变量来实现自己的信号量。开发一个在进程边界内共享公共资源的自定义信号量类相对容易,但信号量有两种类型:命名和未命名。命名信号量用于同步跨进程的公共资源,这有些棘手。

或者,您可以编写一个围绕 POSIX pthreads 信号量原语的包装类,支持命名和未命名信号量。如果您正在开发跨平台应用程序,编写能够在所有平台上运行的可移植代码是必需的。如果您选择这条路,您可能最终会为每个平台编写特定的代码-是的,我听到了,听起来很奇怪,对吧?

Qt 应用程序框架原生支持信号量。使用 Qt 框架是一个不错的选择,因为它是跨平台的。缺点是 Qt 框架是第三方框架。

总之,您可能需要在 pthread 和 Qt 框架之间做出选择,或者重新设计并尝试使用本机 C++功能解决问题。仅使用 C++本机功能限制应用程序开发是困难的,但可以保证在所有平台上的可移植性。

并发

每种现代编程语言都支持并发,提供高级 API,允许同时执行许多任务。C++从 C++11 开始支持并发,并在 C++14 和 C++17 中进一步添加了更复杂的 API。尽管 C++线程支持库允许多线程,但需要编写复杂的同步代码;然而,并发让我们能够执行独立的任务-甚至循环迭代可以并发运行而无需编写复杂的代码。总之,并行化通过并发变得更加容易。

并发支持库是 C++线程支持库的补充。这两个强大库的结合使用使得在 C++中进行并发编程更加容易。

让我们在名为main.cpp的以下文件中使用 C++并发编写一个简单的Hello World程序:

#include <iostream>
#include <future>
using namespace std;

void sayHello( ) {
  cout << endl << "Hello Concurrency support library!" << endl;
}

int main ( ) {
  future<void> futureObj = async ( launch::async, sayHello );
  futureObj.wait( );

  return 0;
}

让我们试着理解main()函数。Future 是并发模块的一个对象,它帮助调用函数以异步方式检索线程传递的消息。future<void>中的 void 表示sayHello()线程函数不会向调用者传递任何消息,也就是说,main线程函数。async类让我们以launch::asynclaunch::deferred模式执行函数。

launch::async模式让async对象在一个单独的线程中启动sayHello()方法,而launch::deferred模式让async对象在不创建单独线程的情况下调用sayHello()函数。在launch::deferred模式下,直到调用线程调用future::get()方法之前,sayHello()方法的调用将不同。

futureObj.wait()方法用于阻塞主线程,让sayHello()函数完成其任务。future::wait()函数类似于线程支持库中的thread::join()

如何编译和运行

让我们继续使用以下命令编译程序:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

让我们启动concurrency.exe,如下所示,并了解它是如何工作的:

使用并发支持库进行异步消息传递

让我们稍微修改main.cpp,我们在上一节中编写的 Hello World 程序。让我们了解如何可以从Thread函数异步地向调用函数传递消息:

#include <iostream>
#include <future>
using namespace std;

void sayHello( promise<string> promise_ ) {
  promise_.set_value ( "Hello Concurrency support library!" );
}

int main ( ) {
  promise<string> promiseObj;

  future<string> futureObj = promiseObj.get_future( );
  async ( launch::async, sayHello, move( promiseObj ) );
  cout << futureObj.get( ) << endl;

  return 0;
}

在前面的程序中,promiseObjsayHello()线程函数用来异步向主线程传递消息。请注意,promise<string>意味着sayHello()函数预期传递一个字符串消息,因此主线程检索future<string>future::get()函数调用将被阻塞,直到sayHello()线程函数调用promise::set_value()方法。

然而,重要的是要理解future::get()只能被调用一次,因为在调用future::get()方法之后,相应的promise对象将被销毁。

你注意到了std::move()函数的使用吗?std::move()函数基本上将promiseObj的所有权转移给了sayHello()线程函数,因此在调用std::move()后,promiseObj不能从main线程中访问。

如何编译和运行

让我们继续使用以下命令编译程序:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

通过启动concurrency.exe应用程序来观察concurrency.exe的工作方式。

正如你可能已经猜到的,这个程序的输出与我们之前的版本完全相同。但是我们的这个程序版本使用了 promise 和 future 对象,而之前的版本不支持消息传递。

并发任务

并发支持模块支持一种称为任务的概念。任务是跨线程并发发生的工作。可以使用packaged_task类创建并发任务。packaged_task类方便地连接了thread函数、相应的 promise 和 future 对象。

让我们通过一个简单的例子来了解packaged_task的用法。以下程序为我们提供了一个机会,尝试一下使用 lambda 表达式和函数进行函数式编程:

#include <iostream>
#include <future>
#include <promise>
#include <thread>
#include <functional>
using namespace std;

int main ( ) {
     packaged_task<int (int, int)>
        addTask ( [] ( int firstInput, int secondInput ) {
              return firstInput + secondInput;
     } );

     future<int> output = addTask.get_future( );
     addTask ( 15, 10 );

     cout << "The sum of 15 + 10 is " << output.get() << endl;
     return 0;
}

在前面展示的程序中,我创建了一个名为addTaskpackaged_task实例。packaged_task< int (int,int)>实例意味着 add 任务将返回一个整数并接受两个整数参数:

addTask ( [] ( int firstInput, int secondInput ) {
              return firstInput + secondInput;
}); 

前面的代码片段表明这是一个匿名定义的 lambda 函数。

有趣的是,在main.cpp中的addTask()调用看起来像是普通的函数调用。future<int>对象是从packaged_task实例addTask中提取出来的,然后用于通过future对象实例get()方法检索addTask的输出。

如何编译和运行

让我们继续使用以下命令编译程序:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

让我们快速启动concurrency.exe并观察下一个显示的输出:

太棒了!您学会了如何在并发支持库中使用 lambda 函数。

使用线程支持库的任务

在上一节中,您学会了如何以一种优雅的方式使用packaged_task。我非常喜欢 lambda 函数。它们看起来很像数学。但并不是每个人都喜欢 lambda 函数,因为它们在一定程度上降低了可读性。因此,如果您不喜欢 lambda 函数,就没有必要在并发任务中使用它们。在本节中,您将了解如何在线程支持库中使用并发任务,如下所示:

#include <iostream>
#include <future>
#include <thread>
#include <functional>
using namespace std;

int add ( int firstInput, int secondInput ) {
  return firstInput + secondInput;
}

int main ( ) {
  packaged_task<int (int, int)> addTask( add);

  future<int> output = addTask.get_future( );

  thread addThread ( move(addTask), 15, 10 );

  addThread.join( );

  cout << "The sum of 15 + 10 is " << output.get() << endl;

  return 0;
}

如何编译和运行

让我们继续使用以下命令编译程序:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

让我们启动concurrency.exe,如下截图所示,并了解先前程序和当前版本之间的区别:

是的,输出与上一节相同,因为我们只是重构了代码。

太棒了!您刚刚学会了如何将 C++线程支持库与并发组件集成。

将线程过程及其输入绑定到 packaged_task

在本节中,您将学习如何将thread函数及其相应的参数与packaged_task绑定。

让我们从上一节中获取代码并进行修改以了解绑定功能,如下所示:

#include <iostream>
#include <future>
#include <string>
using namespace std;

int add ( int firstInput, int secondInput ) {
  return firstInput + secondInput;
}

int main ( ) {

  packaged_task<int (int,int)> addTask( add );
  future<int> output = addTask.get_future();
  thread addThread ( move(addTask), 15, 10);
  addThread.join();
  cout << "The sum of 15 + 10 is " << output.get() << endl;
  return 0;
}

std::bind()函数将thread函数及其参数与相应的任务绑定。由于参数是预先绑定的,因此无需再次提供输入参数 15 或 10。这些都是packaged_task在 C++中可以使用的便利方式之一。

如何编译和运行

让我们继续使用以下命令编译程序:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

让我们启动concurrency.exe,如下截图所示,并了解先前程序和当前版本之间的区别:

恭喜!到目前为止,您已经学到了很多关于 C++中的并发知识。

并发库的异常处理

并发支持库还支持通过future对象传递异常。

让我们通过一个简单的例子来理解异常并发处理机制,如下所示:

#include <iostream>
#include <future>
#include <promise>
using namespace std;

void add ( int firstInput, int secondInput, promise<int> output ) {
  try {
         if ( ( INT_MAX == firstInput ) || ( INT_MAX == secondInput ) )
             output.set_exception( current_exception() ) ;
        }
  catch(...) {}

       output.set_value( firstInput + secondInput ) ;

}

int main ( ) {

     try {
    promise<int> promise_;
          future<int> output = promise_.get_future();
    async ( launch::deferred, add, INT_MAX, INT_MAX, move(promise_) );
          cout << "The sum of INT_MAX + INT_MAX is " << output.get ( ) << endl;
     }
     catch( exception e ) {
  cerr << "Exception occured" << endl;
     }
}

就像我们将输出消息传递给调用者函数/线程一样,并发支持库还允许您设置任务或异步函数中发生的异常。当调用者线程调用future::get()方法时,将抛出相同的异常,因此异常通信变得更加容易。

如何编译和运行

让我们继续使用以下命令编译程序。叔叔水果和尤达的麦芽:

g++ main.cpp -o concurrency.exe -std=c++17 -lpthread

你学到了什么?

让我总结一下要点:

  • 并发支持库提供了高级组件,可以实现同时执行多个任务。

  • future对象让调用者线程检索异步函数的输出

  • 承诺对象被异步函数用于设置输出或异常

  • FUTUREPROMISE对象的类型必须与异步函数设置的值的类型相同

  • 并发组件可以与 C++线程支持库无缝地结合使用

  • lambda 函数和表达式可以与并发支持库一起使用

总结

在本章中,您了解了 C++线程支持库和 pthread C 库之间的区别,互斥同步机制,死锁以及预防死锁的策略。您还学习了如何使用并发库编写同步函数,并进一步研究了 lambda 函数和表达式。

在下一章中,您将学习作为一种极限编程方法的测试驱动开发。

第七章:测试驱动开发

本章将涵盖以下主题:

  • 测试驱动开发的简要概述

  • 关于 TDD 的常见神话和疑问

  • 开发人员编写单元测试是否需要更多的工作

  • 代码覆盖率指标是好还是坏

  • TDD 是否适用于复杂的遗留项目

  • TDD 是否适用于嵌入式产品或涉及硬件的产品

  • C++的单元测试框架

  • Google 测试框架

  • 在 Ubuntu 上安装 Google 测试框架

  • 将 Google 测试和模拟一起构建为一个单一的静态库的过程,而无需安装它们

  • 使用 Google 测试框架编写我们的第一个测试用例

  • 在 Visual Studio IDE 中使用 Google 测试框架

  • TDD 的实践

  • 测试具有依赖关系的遗留代码

让我们深入探讨这些 TDD 主题。

TDD

测试驱动开发TDD)是一种极限编程实践。在 TDD 中,我们从一个测试用例开始,逐步编写必要的生产代码,以使测试用例成功。这个想法是,我们应该一次专注于一个测试用例或场景,一旦测试用例通过,就可以转移到下一个场景。在这个过程中,如果新的测试用例通过,我们不应该修改生产代码。换句话说,在开发新功能或修复错误的过程中,我们只能修改生产代码的两个原因:要么确保测试用例通过,要么重构代码。TDD 的主要重点是单元测试;然而,它可以在一定程度上扩展到集成和交互测试。

以下图示了 TDD 过程的可视化:

当 TDD 被严格遵循时,开发人员可以实现代码的功能和结构质量。非常重要的是,在编写生产代码之前先编写测试用例,而不是在开发阶段结束时编写测试用例。这会产生很大的区别。例如,当开发人员在开发结束时编写单元测试用例时,测试用例很难发现代码中的任何缺陷。原因是开发人员会下意识地倾向于证明他们的代码是正确的,当测试用例在开发结束时编写时。而当开发人员提前编写测试用例时,由于尚未编写代码,他们会从最终用户的角度开始思考,这会鼓励他们从需求规范的角度提出许多场景。

换句话说,针对已经编写的代码编写的测试用例通常不会发现任何错误,因为它倾向于证明编写的代码是正确的,而不是根据需求进行测试。当开发人员在编写代码之前考虑各种场景时,这有助于他们逐步编写更好的代码,确保代码确实考虑到这些场景。然而,当代码存在漏洞时,测试用例将帮助他们发现问题,因为如果不满足要求,测试用例将失败。

TDD 不仅仅是使用一些单元测试框架。在开发或修复代码时,它需要文化和心态的改变。开发人员的重点应该是使代码在功能上正确。一旦以这种方式开发了代码,强烈建议开发人员还应专注于通过重构代码来消除任何代码异味;这将确保代码的结构质量也很好。从长远来看,代码的结构质量将使团队更快地交付功能。

关于 TDD 的常见神话和疑问

当人们开始他们的 TDD 之旅时,关于 TDD 有很多神话和常见疑问。让我澄清我遇到的大部分问题,因为我咨询了全球许多产品巨头。

开发人员编写单元测试是否需要更多的工作

大多数开发人员心中常常会产生一个疑问:“当我们采用 TDD 时,我应该如何估算我的工作量?”由于开发人员需要作为 TDD 的一部分编写单元和集成测试用例,您对如何与客户或管理层协商额外编写测试用例所需的工作量感到担忧,这并不奇怪。别担心,您并不孤单;作为一名自由软件顾问,许多开发人员向我提出了这个问题。

作为开发人员,您手动测试您的代码;相反,现在编写自动化测试用例。好消息是,这是一次性的努力,保证能够在长期内帮助您。虽然开发人员需要反复手动测试他们的代码,但每次他们更改代码时,已经存在的自动化测试用例将通过在集成新代码时立即给予开发人员反馈来帮助他们。

最重要的是,这需要额外的努力,但从长远来看,它有助于减少所需的努力。

代码覆盖率指标是好还是坏?

代码覆盖工具帮助开发人员识别其自动化测试用例中的空白。毫无疑问,很多时候它会提供有关缺失测试场景的线索,这最终会进一步加强自动化测试用例。但当一个组织开始将代码覆盖率作为检查测试覆盖率有效性的指标时,有时会导致开发人员走向错误的方向。根据我的实际咨询经验,我所学到的是,许多开发人员开始为构造函数和私有和受保护的函数编写测试用例,以展示更高的代码覆盖率。在这个过程中,开发人员开始追求数字,失去了 TDD 的最终目标。

在一个具有 20 个方法的类的特定源代码中,可能只有 10 个方法符合单元测试的条件,而其他方法是复杂的功能。在这种情况下,代码覆盖工具将只显示 50%的代码覆盖率,这完全符合 TDD 哲学。然而,如果组织政策强制要求最低 75%的代码覆盖率,那么开发人员将别无选择,只能测试构造函数、析构函数、私有、受保护和复杂功能,以展示良好的代码覆盖率。

测试私有和受保护方法的麻烦在于它们往往会更改,因为它们被标记为实现细节。当私有和受保护方法发生严重变化时,就需要修改测试用例,这使得开发人员在维护测试用例方面的生活更加艰难。

因此,代码覆盖工具是非常好的开发人员工具,可以找到测试场景的空白,但是否编写测试用例或忽略编写某些方法的测试用例,取决于方法的复杂性,应该由开发人员做出明智的选择。然而,如果代码覆盖率被用作项目指标,它往往会驱使开发人员找到展示更好覆盖率的错误方法,导致糟糕的测试用例实践。

TDD 适用于复杂的遗留项目吗?

当然!TDD 适用于任何类型的软件项目或产品。TDD 不仅适用于新产品或项目;它在复杂的遗留项目或产品中也被证明更有效。在维护项目中,绝大多数时间都是修复缺陷,很少需要支持新功能。即使在这样的遗留代码中,修复缺陷时也可以遵循 TDD。

作为开发人员,您肯定会同意,一旦您能够重现问题,从开发人员的角度来看,问题几乎有一半可以被认为已经解决了。因此,您可以从能够重现问题的测试用例开始,然后调试和修复问题。当您修复问题时,测试用例将开始通过;现在是时候考虑可能会重现相同缺陷的另一个可能的测试用例,并重复这个过程。

TDD 是否适用于嵌入式或涉及硬件的产品?

就像应用软件可以从 TDD 中受益一样,嵌入式项目或涉及硬件交互的项目也可以从 TDD 方法中受益。有趣的是,嵌入式项目或涉及硬件的产品更多地受益于 TDD,因为它们可以通过隔离硬件依赖性来测试大部分代码,而无需硬件。TDD 有助于减少上市时间,因为团队可以在不等待硬件的情况下测试大部分软件。由于大部分代码已经在没有硬件的情况下得到了充分的测试,这有助于避免在板卡启动时出现最后一刻的惊喜或应急情况。这是因为大部分情况已经得到了充分的测试。

根据软件工程的最佳实践,一个良好的设计是松散耦合和高内聚的。虽然我们都努力编写松散耦合的代码,但并不总是可能编写绝对独立的代码。大多数情况下,代码都有某种类型的依赖。在应用软件的情况下,依赖可能是数据库或 Web 服务器;在嵌入式产品的情况下,依赖可能是一块硬件。但是使用依赖反转,被测试的代码CUT)可以与其依赖隔离,使我们能够在没有依赖的情况下测试代码,这是一种强大的技术。只要我们愿意重构代码使其更模块化和原子化,任何类型的代码和项目或产品都将受益于 TDD 方法。

C++的单元测试框架

作为 C++开发人员,在选择单元测试框架时,你有很多选择。虽然还有许多其他框架,但这些是一些流行的框架:CppUnit,CppUnitLite,Boost,MSTest,Visual Studio 单元测试和谷歌测试框架。

尽管这些是较旧的文章,我建议你看一下gamesfromwithin.com/exploring-the-c-unit-testing-framework-jungleaccu.org/index.php/journals/。它们可能会给你一些关于这个主题的见解。

毫无疑问,谷歌测试框架是 C++中最受欢迎的测试框架之一,因为它在各种平台上都得到支持,积极开发,并且最重要的是得到了谷歌的支持。

在本章中,我们将使用谷歌测试和谷歌模拟框架。然而,本章讨论的概念适用于所有单元测试框架。我们将深入研究谷歌测试框架及其安装过程。

谷歌测试框架

谷歌测试框架是一个在许多平台上都可以使用的开源测试框架。TDD 只关注单元测试和在一定程度上的集成测试,但谷歌测试框架可以用于各种测试。它将测试用例分类为小型、中型、大型、忠诚度、弹性、精度和其他类型的测试用例。单元测试用例属于小型,集成测试用例属于中型,而复杂功能和验收测试用例属于大型。

它还将谷歌模拟框架作为其一部分捆绑在一起。由于它们在技术上来自同一个团队,它们可以无缝地相互配合。然而,谷歌模拟框架也可以与其他测试框架一起使用,比如 CppUnit。

在 Ubuntu 上安装谷歌测试框架

你可以从github.com/google/googletest下载谷歌测试框架的源代码。然而,最好的下载方式是通过终端命令行进行 Git 克隆:

git clone https://github.com/google/googletest.git

Git 是一个开源的分布式版本控制系统DVCS)。如果您还没有在系统上安装它,您可以在git-scm.com/上找到更多关于为什么应该安装它的信息。但是,在 Ubuntu 中,可以使用sudo apt-get install git命令轻松安装它。

一旦代码下载完成,如图 7.1所示,您将能够在googletest文件夹中找到 Google 测试框架的源代码:

图 7.1

googletest文件夹中有两个分开的文件夹,分别包含googletestgooglemock框架。现在我们可以调用cmake实用程序来配置我们的构建并自动生成Makefile,如下所示:

cmake CMakeLists.txt

图 7.2

当调用cmake实用程序时,它会检测构建 Google 测试框架所需的 C/C++头文件及其路径。此外,它还会尝试定位构建源代码所需的工具。一旦找到所有必要的头文件和工具,它将自动生成Makefile。一旦有了Makefile,您就可以使用它来编译和安装 Google 测试和 Google 模拟到您的系统上:

sudo make install

以下截图演示了如何在系统上安装 google 测试:

图 7.3

在上图中,make install命令已经在/usr/local/lib文件夹中编译和安装了libgmock.alibgtest.a静态库文件。由于/usr/local/lib文件夹路径通常在系统的 PATH 环境变量中,因此可以从系统中的任何项目中访问它。

如何构建 google 测试和模拟一起作为一个单一的静态库而不安装?

如果您不喜欢在常用系统文件夹上安装libgmock.alibgtest.a静态库文件以及相应的头文件,那么构建 Google 测试框架还有另一种方法。

以下命令将创建三个目标文件,如图 7.4所示:

g++ -c googletest/googletest/src/gtest-all.cc googletest/googlemock/src/gmock-all.cc googletest/googlemock/src/gmock_main.cc -I googletest/googletest/ -I googletest/googletest/include -I googletest/googlemock -I googletest/googlemock/include -lpthread -

图 7.4

下一步是使用以下命令将所有目标文件组合成一个单一的静态库:

ar crf libgtest.a gmock-all.o gmock_main.o gtest-all.o

如果一切顺利,您的文件夹应该有全新的libgtest.a静态库,如图 7.5所示。让我们理解以下命令说明:

g++ -c googletest/googletest/src/gtest-all.cc    googletest/googlemock/src/gmock-all.cc googletest/googlemock/src/gmock_main.cc -I googletest/googletest/ -I googletest/googletest/include 
-I googletest/googlemock  -I googletest/googlemock/include -lpthread -std=c++14

上述命令将帮助我们创建三个目标文件:gtest-all.ogmock-all.ogmock_main.ogoogletest框架使用了一些 C++11 特性,我故意使用了 c++14 以确保安全。gmock_main.cc源文件有一个 main 函数,它将初始化 Google 模拟框架,而后者将在内部初始化 Google 测试框架。这种方法的最大优点是我们不必为我们的单元测试应用程序提供 main 函数。请注意,编译命令包括以下include路径,以帮助 g++编译器定位 Google 测试和 Google 模拟框架中必要的头文件:

-I googletest/googletest
-I googletest/googletest/include
-I googletest/googlemock
-I googletest/googlemock/include

现在下一步是创建我们的libgtest.a静态库,将 gtest 和 gmock 框架捆绑成一个单一的静态库。由于 Google 测试框架使用了多线程,因此必须将pthread库链接为我们静态库的一部分:

ar crv libgtest.a gtest-all.o gmock_main.o gmock-all.o

ar存档命令有助于将所有目标文件组合成一个静态库。

以下图像在终端中实际演示了所讨论的过程:

图 7.5

使用 Google 测试框架编写我们的第一个测试用例

学习 Google 测试框架非常容易。让我们创建两个文件夹:一个用于生产代码,另一个用于测试代码。这样做的想法是将生产代码与测试代码分开。一旦您创建了这两个文件夹,就可以从Math.h头文件开始,如图 7.6所示:

图 7.6

Math类只有一个函数,用于演示单元测试框架的用法。首先,我们的Math类有一个简单的 add 函数,足以理解 Google 测试框架的基本用法。

在 Google 测试框架的位置,您也可以使用 CppUnit,并集成模拟框架,如 Google 模拟框架、mockpp 或 opmock。

让我们在以下Math.cpp源文件中实现我们简单的Math类:

图 7.7

前两个文件应该在src文件夹中,如图 7.8所示。所有的生产代码都放在src文件夹中,src文件夹可以包含任意数量的文件。

图 7.8

由于我们已经编写了一些生产代码,现在让我们看看如何为前面的生产代码编写一些基本的测试用例。作为一般的最佳实践,建议将测试用例文件命名为MobileTestTestMobile,以便任何人都能轻松预测文件的目的。在 C++或 Google 测试框架中,不强制将文件名和类名保持一致,但通常被认为是最佳实践,因为它可以帮助任何人通过查看文件名来定位特定的类。

Google 测试框架和 Google 模拟框架都是同一个团队的产品,因此这种组合在大多数平台上都非常有效,包括嵌入式平台。

由于我们已经将 Google 测试框架编译为静态库,让我们直接开始MathTest.cpp源文件:

图 7.9

图 7.9中,我们在第 18 行包含了来自 Google 测试框架的 gtest 头文件。在 Google 测试框架中,测试用例使用TEST宏编写,该宏接受两个参数。第一个参数,即MathTest,表示测试模块名称,第二个参数是测试用例的名称。测试模块帮助我们将一组相关的测试用例分组到一个模块下。因此,为测试模块和测试用例命名非常重要,以提高测试报告的可读性。

正如您所知,Math是我们打算测试的类;我们在第 22 行实例化了Math对象。在第 25 行,我们调用了数学对象的 add 函数,这应该返回实际结果。最后,在第 27 行,我们检查预期结果是否与实际结果匹配。如果预期和实际结果匹配,Google 测试宏EXPECT_EQ将标记测试用例为通过;否则,框架将标记测试用例的结果为失败。

好的,现在我们已经准备好了。现在让我们看看如何编译和运行我们的测试用例。以下命令应该帮助您编译测试用例:

g++ -o tester.exe src/Math.cpp test/MathTest.cpp -I googletest/googletest 
-I googletest/googletest/include -I googletest/googlemock     
-I googletest/googlemock/include -I src libgtest.a -lpthread

请注意,编译命令包括以下包含路径:

-I googletest/googletest
-I googletest/googletest/include
-I googletest/googlemock
-I googletest/googlemock/include
-I src

另外,重要的是要注意,我们还链接了我们的 Google 测试静态库libgtest.a和 POSIX pthreads 库,因为 Google 测试框架使用了多个。

图 7.10

恭喜!我们已经成功编译并执行了我们的第一个测试用例。

在 Visual Studio IDE 中使用 Google 测试框架

首先,我们需要从github.com/google/googletest/archive/master.zip下载 Google 测试框架的.zip文件。下一步是在某个目录中解压.zip文件。在我的情况下,我已经将其解压到googletest文件夹,并将googletest googletest-master\googletest-master的所有内容复制到googletest文件夹中,如图 7.11所示:

图 7.11

现在是时候在 Visual Studio 中创建一个简单的项目了。我使用的是 Microsoft Visual Studio Community 2015。但是,这里遵循的程序应该对 Visual Studio 的其他版本基本保持一致,只是选项可能在不同的菜单中可用。

您需要通过导航到新建项目| Visual Studio | Windows | Win32 | Win32 控制台应用程序来创建一个名为MathApp的新项目,如图 7.12所示。该项目将成为要测试的生产代码。

图 7.12

让我们将MyMath类添加到MathApp项目中。MyMath类是将在MyMath.h中声明并在MyMath.cpp中定义的生产代码。

让我们来看一下图 7.13中显示的MyMath.h头文件:

图 7.13

MyMath类的定义如图 7.14所示:

图 7.14

由于它是一个控制台应用程序,因此必须提供主函数,如图 7.15所示:

图 7.15

接下来,我们将在相同的MathApp项目解决方案中添加一个名为GoogleTestLib的静态库项目,如图 7.16所示:

图 7.16

接下来,我们需要将来自 Google 测试框架的以下源文件添加到我们的静态库项目中:

C:\Users\jegan\googletest\googletest\src\gtest-all.cc
C:\Users\jegan\googletest\googlemock\src\gmock-all.cc
C:\Users\jegan\googletest\googlemock\src\gmock_main.cc

为了编译静态库,我们需要在GoogleTestLib/Properties/VC++ Directories/Include目录中包含以下头文件路径:

C:\Users\jegan\googletest\googletest
C:\Users\jegan\googletest\googletest\include
C:\Users\jegan\googletest\googlemock
C:\Users\jegan\googletest\googlemock\include

您可能需要根据在系统中复制/安装 Google 测试框架的位置来自定义路径。

现在是时候将MathTestApp Win32 控制台应用程序添加到MathApp解决方案中了。我们需要将MathTestApp设置为StartUp项目,以便可以直接执行此应用程序。在添加名为MathTest.cpp的新源文件到MathTestApp项目之前,请确保MathTestApp项目中没有源文件。

我们需要配置与GoogleTestLib静态库中添加的相同一组 Google 测试框架包含路径。除此之外,我们还必须将MathApp项目目录添加为测试项目将引用的头文件,如下所示。但是,根据您在系统中为此项目遵循的目录结构,自定义路径:

C:\Users\jegan\googletest\googletest
C:\Users\jegan\googletest\googletest\include
C:\Users\jegan\googletest\googlemock
C:\Users\jegan\googletest\googlemock\include
C:\Projects\MasteringC++Programming\MathApp\MathApp

MathAppTest项目中,确保您已经添加了对MathAppGoogleTestLib的引用,以便在它们发生更改时,MathAppTest项目将编译其他两个项目。

太好了!我们快要完成了。现在让我们实现MathTest.cpp,如图 7.17所示:

图 7.17

现在一切准备就绪,让我们运行测试用例并检查结果:

图 7.18

TDD 实践

让我们看看如何开发一个遵循 TDD 方法的逆波兰表达式RPN)计算器应用程序。RPN 也被称为后缀表示法。RPN 计算器应用程序的期望是接受后缀数学表达式作为输入,并将计算结果作为输出返回。

我想逐步演示如何在开发应用程序时遵循 TDD 方法。作为第一步,我想解释项目目录结构,然后我们将继续。让我们创建一个名为Ex2的文件夹,其结构如下:

图 7.19

googletest文件夹是具有必要的gtestgmock头文件的 gtest 测试库。现在libgtest.a是我们在上一个练习中创建的 Google 测试静态库。我们将使用make实用程序来构建我们的项目,因此我已经将Makefile放在项目home目录中。src目录将保存生产代码,而测试目录将保存我们将要编写的所有测试用例。

在我们开始编写测试用例之前,让我们来看一个后缀数学表达式“2 5 * 4 + 3 3 * 1 + /”,并了解我们将应用于评估逆波兰数学表达式的标准后缀算法。根据后缀算法,我们将逐个标记地解析逆波兰数学表达式。每当我们遇到一个操作数(数字)时,我们将把它推入栈中。每当我们遇到一个运算符时,我们将从栈中弹出两个值,应用数学运算,将中间结果推回栈中,并重复该过程,直到在逆波兰表达式中评估所有标记。最后,当输入字符串中没有更多的标记时,我们将弹出该值并将其打印为结果。该过程在下图中逐步演示:

图 7.20

首先,让我们以一个简单的后缀数学表达式开始,并将情景转化为一个测试用例:

Test Case : Test a simple addition
Input: "10 15 +"
Expected Output: 25.0

让我们将前面的测试用例翻译为测试文件夹中的 Google 测试,如下所示:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testSimpleAddition ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "10 15 +" ); 
         double expectedResult = 25.0; 
         EXPECT_EQ ( expectedResult, actualResult ); 
}

为了编译前面的测试用例,让我们在src文件夹中编写所需的最小生产代码,如下所示:

src/RPNCalculator.h

#include <iostream>
#include <string>
using namespace std;

class RPNCalculator {
  public:
      double evaluate ( string );
};

由于 RPN 数学表达式将作为以空格分隔的字符串提供,因此评估方法将接受一个字符串输入参数:

src/RPNCalculator.cpp

#include "RPNCalculator.h"

double RPNCalculator::evaluate ( string rpnMathExpression ) {
    return 0.0;
}

以下Makefile类帮助每次编译生产代码时运行测试用例:

图 7.21

现在让我们构建并运行测试用例,并检查测试用例的结果:

图 7.22

在 TDD 中,我们总是从一个失败的测试用例开始。失败的原因是期望的结果是 25,而实际结果是 0。原因是我们还没有实现评估方法,因此我们已经硬编码为返回 0,而不管任何输入。因此,让我们实现评估方法以使测试用例通过。

我们需要修改src/RPNCalculator.hsrc/RPNCalculator.cpp如下:

图 7.23

在 RPNCalculator.h 头文件中,注意包含的新头文件,用于处理字符串标记化和字符串双精度转换,并将 RPN 标记复制到向量中:

图 7.24

根据标准的后缀算法,我们使用一个栈来保存在逆波兰表达式中找到的所有数字。每当我们遇到+数学运算符时,我们从栈中弹出两个值相加,然后将结果推回栈中。如果标记不是+运算符,我们可以安全地假定它是一个数字,所以我们只需将该值推送到栈中。

有了前面的实现,让我们尝试测试用例,并检查测试用例是否通过:

图 7.25

很好,我们的第一个测试用例如预期地通过了。现在是时候考虑另一个测试用例了。这次,让我们添加一个减法的测试用例:

Test Case : Test a simple subtraction
Input: "25 10 -"
Expected Output: 15.0

让我们将前面的测试用例翻译为测试文件夹中的 Google 测试,如下所示:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testSimpleSubtraction ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "25 10 -" ); 
         double expectedResult = 15.0; 
         EXPECT_EQ ( expectedResult, actualResult ); 
}

通过将前面的测试用例添加到test/RPNCalculatorTest中,现在应该如下所示:

图 7.26

让我们执行测试用例并检查我们的新测试用例是否通过:

图 7.27

正如预期的那样,新的测试失败了,因为我们还没有在应用程序中添加对减法的支持。这是非常明显的,根据 C++异常,因为代码试图将减法-操作符转换为一个数字。让我们在我们的 evaluate 方法中添加对减法逻辑的支持:

图 7.28

是时候测试了。让我们执行测试案例,检查事情是否正常:

图 7.29

酷!你注意到我们的测试案例在这种情况下失败了吗?等一下。如果测试案例失败了,为什么我们会兴奋呢?我们应该高兴的原因是我们的测试案例发现了一个 bug;毕竟,这是 TDD 的主要目的,不是吗?

图 7.30

失败的根本原因是栈是基于后进先出LIFO)操作,而我们的代码假设是先进先出。你注意到我们的代码假设它会先弹出第一个数字,而实际上它应该先弹出第二个数字吗?有趣的是,这个 bug 在加法操作中也存在;然而,由于加法是可结合的,这个 bug 被抑制了,但减法测试案例检测到了它。

图 7.31

让我们按照前面的截图修复 bug,并检查测试案例是否通过:

图 7.32

太棒了!我们修复了 bug,我们的测试案例似乎证明它们已经修复了。让我们添加更多的测试案例。这一次,让我们添加一个测试案例来验证乘法:

Test Case : Test a simple multiplication
Input: "25 10 *"
Expected Output: 250.0

让我们将前面的测试案例翻译成测试文件中的谷歌测试,如下所示:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testSimpleMultiplication ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "25 10 *" ); 
         double expectedResult = 250.0; 
         EXPECT_EQ ( expectedResult, actualResult ); 
}

我们知道这次测试案例将会失败,所以让我们快进并看一下除法测试案例:

Test Case : Test a simple division
Input: "250 10 /"
Expected Output: 25.0

让我们将前面的测试案例翻译成测试文件中的谷歌测试,如下所示:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testSimpleDivision ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "250 10 /" ); 
         double expectedResult = 25.0; 
         EXPECT_EQ ( expectedResult, actualResult );
}

让我们跳过测试结果,继续进行一个涉及许多操作的最终复杂表达式测试案例:

Test Case : Test a complex rpn expression
Input: "2  5  *  4  + 7  2 -  1  +  /"
Expected Output: 25.0

让我们将前面的测试案例翻译成测试文件中的谷歌测试,如下所示:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testSimpleDivision ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "250 10 /" ); 
         double expectedResult = 25.0; 
         EXPECT_EQ ( expectedResult, actualResult );
}

让我们检查一下我们的 RPNCalculator 应用程序是否能够评估一个涉及加法、减法、乘法和除法的复杂逆波兰表达式,这是一个测试案例:

test/RPNCalculatorTest.cpp

TEST ( RPNCalculatorTest, testComplexExpression ) { 
         RPNCalculator rpnCalculator; 
         double actualResult = rpnCalculator.evaluate ( "2  5  *  4  +  7  2 - 1 +  /" ); 
         double expectedResult = 2.33333; 
         ASSERT_NEAR ( expectedResult, actualResult, 4 );
}

在前面的测试案例中,我们正在检查预期结果是否与实际结果匹配,精确到小数点后四位。如果超出这个近似值,那么测试案例应该失败。

现在让我们检查测试案例的输出:

图 7.33

太棒了!所有的测试案例都是绿色的。

现在让我们看看我们的生产代码,检查是否有改进的空间:

图 7.34

代码在功能上是好的,但有很多代码异味。这是一个长方法,有嵌套的if-else条件和重复的代码。TDD 不仅仅是关于测试自动化;它也是关于编写没有代码异味的好代码。因此,我们必须重构代码,使其更模块化,减少代码复杂性。

在这里,我们可以应用多态性或策略设计模式,而不是嵌套的if-else条件。此外,我们可以使用工厂方法设计模式来创建各种子类型。还有使用空对象设计模式的空间。

最好的部分是,我们不必担心在重构过程中破坏我们的代码的风险,因为我们有足够多的测试案例来在我们破坏代码时给我们反馈。

首先,让我们了解如何重构图 7.35中所示的 RPNCalculator 设计:

图 7.35

根据前面的设计重构方法,我们可以将 RPNCalculator 重构如图 7.36所示:

图 7.36

如果您比较重构前后的RPNCalculator代码,您会发现重构后代码的复杂性大大减少。

MathFactory类可以按图 7.37所示实现:

图 7.37

尽可能地,我们必须努力避免if-else条件,或者一般来说,我们必须尽量避免代码分支。因此,STL map 用于避免 if-else 条件。这也促进了相同的 Math 对象的重复使用,无论 RPN 表达式的复杂性如何。

如果您参考图 7.38,您将了解MathOperator Add类的实现方式:

图 7.38

Add类的定义如图 7.39所示:

图 7.39

减法、乘法和除法类可以以类似的方式实现,作为Add类。重点是,在重构后,我们可以将单个RPNCalculator类重构为更小且易于维护的类,可以单独进行测试。

让我们看一下重构后的Makefile类,如图 7.40所示,并在重构过程完成后测试我们的代码:

图 7.40

如果一切顺利,重构后我们应该看到所有测试用例通过,如果没有功能被破坏,如图 7.41所示:

图 7.41

太棒了!所有测试用例都通过了,因此可以保证我们在重构过程中没有破坏功能。TDD 的主要目的是编写既具有功能性又结构清晰的可测试代码。

测试具有依赖关系的遗留代码

在上一节中,CUT 是独立的,没有依赖,因此它测试代码的方式是直接的。然而,让我们讨论一下如何对具有依赖关系的 CUT 进行单元测试。为此,请参考以下图片:

图 7.42

图 7.42中,显然Mobile依赖于Camera,而MobileCamera之间的关联是组合。让我们看看遗留应用程序中Camera.h头文件的实现:

图 7.43

为了演示目的,让我们来看一个简单的Camera类,具有ON()OFF()功能。让我们假设 ON/OFF 功能将在内部与相机硬件交互。查看图 7.44中的Camera.cpp源文件:

图 7.44

出于调试目的,我添加了一些打印语句,这在我们测试移动的powerOn()powerOff()功能时会很有用。现在让我们检查图 7.45中的Mobile类头文件:

图 7.45

我们继续移动实现,如图 7.46所示:

图 7.46

Mobile构造函数的实现中,可以明显看出移动设备具有相机,或者更确切地说是组合关系。换句话说,Mobile类是构造Camera对象的类,如图 7.46第 21 行在构造函数中显示。让我们尝试看一下测试MobilepowerOn()功能所涉及的复杂性;依赖关系与 Mobile 的 CUT 具有组合关系。

让我们编写powerOn()测试用例,假设相机已成功打开,如下所示:

TEST ( MobileTest, testPowerOnWhenCameraONSucceeds ) {

     Mobile mobile;
     ASSERT_TRUE ( mobile.powerOn() );

}

现在让我们尝试运行Mobile测试用例并检查测试结果,如图 7.47所示:

图 7.47

图 7.47中,我们可以理解MobilepowerOn()测试用例已经通过。但是,我们也了解到Camera类的真正ON()方法也被调用了。这反过来将与相机硬件进行交互。归根结底,这不是一个单元测试,因为测试结果并不完全取决于 CUT。如果测试用例失败,我们将无法确定失败是由于移动设备powerOn()逻辑中的代码还是相机ON()逻辑中的代码,这将违背我们测试用例的目的。理想的单元测试应该使用依赖注入隔离 CUT 与其依赖项,并测试代码。这种方法将帮助我们识别 CUT 在正常或异常情况下的行为。理想情况下,当单元测试用例失败时,我们应该能够猜测失败的根本原因,而无需调试代码;只有当我们设法隔离 CUT 的依赖项时,才有可能做到这一点。

这种方法的关键好处是,即使在实现依赖项之前,也可以测试 CUT,这有助于在没有依赖项的情况下测试 60~70%的代码。这自然减少了软件产品上市的时间。

这就是 Google 模拟或 gmock 派上用场的地方。让我们看看如何重构我们的代码以实现依赖注入。虽然听起来非常复杂,但重构代码所需的工作并不复杂。实际上,重构生产代码所需的工作可能更复杂,但这是值得的。让我们来看一下重构后的Mobile类,如图 7.48所示:

图 7.48

Mobile类中,我添加了一个接受相机作为参数的重载构造函数。这种技术称为构造函数依赖注入。让我们看看这种简单而强大的技术如何在测试MobilepowerOn()功能时帮助我们隔离相机依赖。

此外,我们必须重构Camera.h头文件,并将ON()OFF()方法声明为虚拟方法,以便 gmock 框架帮助我们存根这些方法,如图 7.49所示:

图 7.49

现在让我们根据图 7.50进行重构我们的测试用例:

图 7.50

我们已经准备好构建和执行测试用例。测试结果如图 7.51所示:

图 7.51

太棒了!我们的测试用例不仅通过了,而且我们还将我们的 CUT 与其相机依赖隔离开来,这很明显,因为我们没有看到相机的ON()方法的打印语句。最重要的是,您现在已经学会了如何通过隔离其依赖项来对代码进行单元测试。

快乐的 TDD!

总结

在本章中,您学到了很多关于 TDD 的知识,以下是关键要点的总结:

  • TDD 是一种极限编程(XP)实践

  • TDD 是一种自下而上的方法,鼓励我们从测试用例开始,因此通常被称为小写测试优先开发

  • 您学会了如何在 Linux 和 Windows 中使用 Google Test 和 Google Mock 框架编写测试用例

  • 您还学会了如何在 Linux 和 Windows 平台上的 Visual Studio 中编写遵循 TDD 的应用程序

  • 您学会了依赖反转技术以及如何使用 Google Mock 框架隔离其依赖项对代码进行单元测试

  • Google Test 框架支持单元测试、集成测试、回归测试、性能测试、功能测试等

  • TDD 主要坚持单元测试、集成测试和交互测试,而复杂的功能测试必须使用行为驱动开发来完成

  • 您学会了如何将代码异味重构为干净的代码,同时您编写的单元测试用例会给出持续的反馈

你已经学会了 TDD 以及如何以自下而上的方式自动化单元测试用例、集成测试用例和交互测试用例。有了 BDD,你将学会自上而下的开发方法,编写端到端的功能和测试用例,以及我们在讨论 TDD 时没有涵盖的其他复杂测试场景。

在下一章中,你将学习行为驱动开发。

第八章:行为驱动开发

本章涵盖以下主题:

  • 行为驱动开发简介

  • TDD 与 BDD

  • C++ BDD 框架

  • Gherkin 语言

  • 在 Ubuntu 中安装cucumber-cpp

  • 特性文件

  • Gherkin 支持的口语

  • 推荐的cucumber-cpp项目文件夹结构

  • 编写我们的第一个 Cucumber 测试用例

  • 运行我们的 Cucumber 测试用例进行干运行

  • BDD——一种测试驱动的开发方法

在接下来的章节中,让我们以实用的方式逐个讨论每个主题,并提供易于理解和有趣的代码示例。

行为驱动开发

行为驱动开发BDD)是一种从外到内的开发技术。BDD 鼓励将需求捕捉为一组场景或用例,描述最终用户如何使用功能。场景将准确表达输入和功能预期响应。BDD 最好的部分是它使用称为Gherkin领域特定语言DSL)来描述 BDD 场景。

Gherkin 是一种类似英语的语言,被所有 BDD 测试框架使用。Gherkin 是一种可读的业务 DSL,帮助您描述测试用例场景,排除实现细节。Gherkin 语言关键字是一堆英语单词;因此,技术和非技术成员都可以理解涉及软件产品或项目团队的场景。

我告诉过你了吗,用 Gherkin 语言编写的 BDD 场景既可以作为文档,也可以作为测试用例?由于 Gherkin 语言易于理解并使用类似英语的关键词,产品需求可以直接被捕捉为 BDD 场景,而不是无聊的 Word 或 PDF 文档。根据我的咨询和行业经验,我观察到大多数公司在设计在一段时间内重构时从不更新需求文档。这导致了陈旧和未更新的文档,开发团队将不信任这些文档作为参考。因此,为准备需求、高级设计文档和低级设计文档所付出的努力最终将付诸东流,而 Cucumber 测试用例将始终保持更新和相关。

TDD 与 BDD

TDD 是一种从内到外的开发技术,而 BDD 是一种从外到内的开发技术。TDD 主要侧重于单元测试和集成测试用例自动化。

BDD 侧重于端到端的功能测试用例和用户验收测试用例。然而,BDD 也可以用于单元测试、冒烟测试,以及实际上的各种测试。

BDD 是 TDD 方法的扩展;因此,BDD 也强烈鼓励测试驱动开发。在同一产品中同时使用 BDD 和 TDD 是非常自然的;因此,BDD 并不是 TDD 的替代品。BDD 可以被视为高级设计文档,而 TDD 是低级设计文档。

C++ BDD 框架

在 C++中,TDD 测试用例是使用诸如 CppUnit、gtest 等测试框架编写的,这些测试框架需要技术背景才能理解,因此通常只由开发人员使用。

在 C++中,BDD 测试用例是使用一种名为 cucumber-cpp 的流行测试框架编写的。cucumber-cpp 框架期望测试用例是用 Gherkin 语言编写的,而实际的测试用例实现可以使用任何测试框架,如 gtest 或 CppUnit。

然而,在本书中,我们将使用带有 gtest 框架的 cucumber-cpp。

Gherkin 语言

Gherkin 是每个 BDD 框架使用的通用语言,用于各种编程语言的 BDD 支持。

Gherkin 是一种面向行的语言,类似于 YAML 或 Python。Gherkin 将根据缩进解释测试用例的结构。

在 Gherkin 中,#字符用于单行注释。在撰写本书时,Gherkin 支持大约 60 个关键字。

Gherkin 是 Cucumber 框架使用的 DSL。

在 Ubuntu 中安装 cucumber-cpp

在 Linux 中安装 cucumber-cpp 框架非常简单。您只需要下载或克隆 cucumber-cpp 的最新副本即可。

以下命令可用于克隆 cucumber-cpp 框架:

git clone https://github.com/cucumber/cucumber-cpp.git

cucumber-cpp 框架支持 Linux、Windows 和 Macintosh。它可以与 Windows 上的 Visual Studio 或 macOS 上的 Xcode 集成。

以下截图演示了 Git 克隆过程:

cucumber-cpp 依赖于一种 wire 协议,允许在 C++语言中编写 BDD 测试用例步骤定义,因此我们需要安装 Ruby。

安装 cucumber-cpp 框架的先决软件

以下命令可帮助您在 Ubuntu 系统上安装 Ruby。这是 cucumber-cpp 框架所需的先决软件之一:

sudo apt install ruby

以下截图演示了 Ruby 安装过程:

安装完成后,请确保 Ruby 已正确安装,检查其版本。以下命令应该打印出您系统上安装的 Ruby 版本:

ruby --version

为了完成 Ruby 安装,我们需要安装ruby-dev软件包,如下所示:

sudo apt install ruby-dev

接下来,我们需要确保 bundler 工具已安装,以便 bundler 工具无缝安装 Ruby 依赖项:

sudo gem install bundler
bundle install

如果一切顺利,您可以继续检查 Cucumber、Ruby 和 Ruby 工具的正确版本是否已正确安装。bundle install命令将确保安装 Cucumber 和其他 Ruby 依赖项。确保您不要以 sudo 用户身份安装bundle install,这将阻止非 root 用户访问 Ruby gem 软件包:

我们几乎完成了,但还没有完成。我们需要构建 cucumber-cpp 项目;作为其中的一部分,让我们获取 cucumber-cpp 框架的最新测试套件:

git submodule init
git submodule update

在开始构建之前,我们需要安装 ninja 和 boost 库。尽管在本章中我们不打算使用 boost 测试框架,但travis.sh脚本文件会寻找 boost 库。因此,我建议通常安装 boost 库,作为 Cucumber 的一部分:

sudo apt install ninja-build
sudo apt-get install libboost-all-dev

构建和执行测试用例

现在是时候构建 cucumber-cpp 框架了。让我们创建build文件夹。在cucumber-cpp文件夹中,将有一个名为travis.sh的 shell 脚本。您需要执行该脚本来构建和执行测试用例:

sudo ./travis.sh

尽管之前的方法有效,但我个人偏好和建议是以下方法。推荐以下方法的原因是build文件夹应该被创建为非 root 用户,一旦cucumber-cpp设置完成,任何人都应该能够执行构建。您应该能够在cucumber-cpp文件夹下的README.md文件中找到说明:

git submodule init
git submodule update
cmake -E make_directory build
cmake -E chdir build cmake --DCUKE_ENABLE_EXAMPLES=on ..
cmake --build build
cmake --build build --target test
cmake --build build --target features

如果您能够按照先前的安装步骤完全完成,那么您就可以开始使用cucumber-cpp了。恭喜!

特性文件

每个产品特性都将有一个专用的特性文件。特性文件是一个文本文件,扩展名为.feature。一个特性文件可以包含任意数量的场景,每个场景相当于一个测试用例。

让我们看一个简单的特性文件示例:

1   # language: en
2
3   Feature: The Facebook application should authenticate user login.
4
5     Scenario: Successful Login
6        Given I navigate to Facebook login page https://www.facebook.com
7        And I type jegan@tektutor.org as Email
8        And I type mysecretpassword as Password
9        When I click the Login button
10       Then I expect Facebook Home Page after Successful Login

很酷,看起来就像普通的英语,对吧?但相信我,这就是 Cucumber 测试用例的写法!我理解你的疑虑--看起来很简单很酷,但是这样怎么验证功能呢?验证功能的代码在哪里呢?cucumber-cpp框架是一个很酷的框架,但它并不原生支持任何测试功能;因此cucumber-cpp依赖于gtestCppUnit和其他测试框架。测试用例的实现是在Steps文件中编写的,在我们的情况下可以使用gtest框架来编写 C++。然而,任何测试框架都可以使用。

每个特性文件都将以Feature关键字开头,后面跟着一行或多行描述,简要描述该特性。在特性文件中,FeatureScenarioGivenAndWhenThen都是 Gherkin 关键字。

一个特性文件可以包含任意数量的场景(测试用例)对于一个特性。例如,在我们的情况下,登录是特性,可能有多个登录场景,如下所示:

  • 登录成功

  • 登录失败

  • 密码无效

  • 用户名无效

  • 用户尝试登录而没有提供凭据。

在场景后的每一行将在Steps_definition.cpp源文件中转换为一个函数。基本上,cucumber-cpp框架使用正则表达式将特性文件步骤映射到Steps_definition.cpp文件中的相应函数。

Gherkin 支持的口语

Gherkin 支持 60 多种口语。作为最佳实践,特性文件的第一行将指示 Cucumber 框架我们想要使用英语:

1   # language: en

以下命令将列出cucumber-cpp框架支持的所有语言:

cucumber -i18n help

列表如下:

推荐的 cucumber-cpp 项目文件夹结构

与 TDD 一样,Cucumber 框架也推荐了项目文件夹结构。推荐的cucumber-cpp项目文件夹结构如下:

src文件夹将包含生产代码,也就是说,所有项目文件都将在src目录下维护。BDD 特性文件将在features文件夹下维护,以及其相应的Steps文件,其中包含 boost 测试用例或 gtest 测试用例。在本章中,我们将使用cucumber-cppgtest框架。wire文件包含了与 wire 协议相关的连接细节,如端口等。CMakeLists.txt是构建脚本,其中包含构建项目及其依赖项的指令,就像MakeBuild实用程序使用的Makefile一样。

编写我们的第一个 Cucumber 测试用例

让我们写下我们的第一个 Cucumber 测试用例!由于这是我们的第一个练习,我想保持简短和简单。首先,让我们为我们的HelloBDD项目创建文件夹结构。

要创建 Cucumber 项目文件夹结构,我们可以使用cucumber实用程序,如下所示:

cucumber --init

这将确保featuressteps_definitions文件夹按照 Cucumber 最佳实践创建:

一旦基本文件夹结构创建完成,让我们手动创建其余的文件:

mkdir src
cd HelloBDD
touch CMakeLists.txt
touch features/hello.feature
touch features/step_definitions/cucumber.wire
touch features/step_definitions/HelloBDDSteps.cpp
touch src/Hello.h
touch src/Hello.cpp

一旦文件夹结构和空文件被创建,项目文件夹结构应该如下截图所示:

是时候将我们的 Gherkin 知识付诸实践了,因此,让我们首先从特性文件开始:

# language: en

Feature: Application should be able to print greeting message Hello BDD!

   Scenario: Should be able to greet with Hello BDD! message
      Given an instance of Hello class is created
      When the sayHello method is invoked
      Then it should return "Hello BDD!"

让我们来看一下cucumber.wire文件:

host: localhost
port: 3902

由于 Cucumber 是用 Ruby 实现的,因此 Cucumber 步骤的实现必须用 Ruby 编写。这种方法不鼓励在除 Ruby 以外的平台上实现的项目中使用 cucumber-cpp 框架。cucumber-cpp框架提供的wire协议是为了扩展非 Ruby 平台对 Cucumber 的支持而提供的解决方案。基本上,每当cucumber-cpp框架执行测试用例时,它都会寻找步骤定义,但如果 Cucumber 找到一个.wire文件,它将连接到该 IP 地址和端口,以查询服务器是否有步骤描述中的定义.feature文件。这有助于 Cucumber 支持除 Ruby 以外的许多平台。然而,Java 和.NET 都有本地的 Cucumber 实现:Cucumber-JVM 和 Specflow。因此,为了允许用 C++编写测试用例,cucumber-cpp使用了wire协议。

现在让我们看看如何使用 gtest 框架编写步骤文件。

感谢 Google!Google 测试框架(gtest)包括 Google Mock 框架(gmock)。对于 C/C++来说,gtest 框架是我遇到的最好的框架之一,因为它与 Java 的 JUnit 和 Mockito/PowerMock 提供的功能非常接近。对于相对现代的语言 Java 来说,与 C++相比,借助反射支持模拟应该更容易,但是从 C/C++的角度来看,没有 C++的反射功能,gtest/gmock 简直就是 JUnit/TestNG/Mockito/PowerMock。

您可以在以下截图中观察使用 gtest 编写的步骤文件:

以下头文件确保包含了编写 Cucumber 步骤所需的 gtest 头文件和 Cucumber 头文件:

#include <gtest/gtest.h>
#include <cucumber-cpp/autodetect.hpp>

现在让我们继续编写步骤:

struct HelloCtx {
     Hello *ptrHello;
     string actualResponse;
};

HelloCtx结构是一个用户定义的测试上下文,它保存了测试对象实例及其测试响应。cucumber-cpp框架提供了一个智能的ScenarioScope类,允许我们在 Cucumber 测试场景的所有步骤中访问测试对象及其输出。

对于我们在特征文件中编写的每个GivenWhenThen语句,都有一个相应的函数在步骤文件中。相应的 cpp 函数与GivenWhenThen相对应的函数是通过正则表达式进行映射的。

例如,考虑特征文件中的以下Given行:

Given an instance of Hello class is created

这对应于以下的 cpp 函数,它通过正则表达式进行映射。正则表达式中的^字符意味着模式以an开头,$字符意味着模式以created结尾:

GIVEN("^an instance of Hello class is created$")
{
       ScenarioScope<HelloCtx> context;
       context->ptrHello = new Hello();
}

正如GIVEN步骤所说,在这一点上,我们必须确保创建Hello对象的一个实例;相应的 C++代码写在这个函数中,用于实例化Hello类的对象。

同样,以下When步骤及其相应的 cpp 函数由cucumber-cpp映射:

When the sayHello method is invoked

很重要的是正则表达式要完全匹配;否则,cucumber-cpp框架将报告找不到步骤函数:

WHEN("^the sayHello method is invoked$")
{
       ScenarioScope<HelloCtx> context;
       context->actualResponse = context->ptrHello->sayHello();
}

现在让我们看一下Hello.h文件:

#include <iostream>
#include <string>
using namespace std;

class Hello {
public:
       string sayHello();
};

以下是相应的源文件,即Hello.cpp

#include "Hello.h"

string Hello::sayHello() {
     return "Hello BDD!";
}

作为行业最佳实践,应该在源文件中包含的唯一头文件是其相应的头文件。其余所需的头文件应该放在与源文件对应的头文件中。这有助于开发团队轻松定位头文件。BDD 不仅仅是关于测试自动化;预期的最终结果是干净、无缺陷和可维护的代码。

最后,让我们编写CMakeLists.txt

第一行表示项目的名称。第三行确保 Cucumber 头文件目录和我们项目的include_directoriesINCLUDE路径中。第五行基本上指示cmake工具将src文件夹中的文件创建为库,即Hello.cpp及其Hello.h文件。第七行检测我们的系统上是否安装了 gtest 框架,第八行确保编译了HelloBDDSteps.cpp文件。最后,在第九行,创建最终的可执行文件,链接所有包含我们生产代码的HelloBDD库,HelloBDDSteps对象文件以及相应的 Cucumber 和 gtest 库文件。

将我们的项目集成到 cucumber-cpp 的 CMakeLists.txt 中

在我们开始构建项目之前,还有最后一个配置需要完成:

基本上,我已经注释了examples子目录,并在cucumber-cpp文件夹下的CMakeLists.txt中添加了我们的HelloBDD项目,如前所示。

由于我们按照 cucumber-cpp 最佳实践创建了项目,让我们转到HelloBDD项目主目录并发出以下命令:

cmake --build  build

注释add_subdirectory(examples)并不是强制的。但注释确实有助于我们专注于我们的项目。

以下截图展示了构建过程:

执行我们的测试用例

现在让我们执行测试用例。由于我们使用了 wire 协议,这涉及两个步骤。让我们首先以后台模式启动测试用例可执行文件,然后启动 Cucumber,如下所示:

cmake --build build
build/HelloBDD/HelloBDDSteps > /dev/null &
cucumber HelloBDD

重定向到/dev/null并不是真正必需的。重定向到空设备的主要目的是避免应用程序在终端输出中打印语句,从而分散注意力。因此,这是个人偏好。如果你喜欢看到应用程序的调试或一般打印语句,可以自由地发出不带重定向的命令:

build/HelloBDD/HelloBDDSteps &

以下截图展示了构建和测试执行过程:

恭喜!我们的第一个 cucumber-cpp 测试用例已经通过。每个场景代表一个测试用例,测试用例包括三个步骤;由于所有步骤都通过了,因此报告为通过。

运行你的 cucumber 测试用例

你想快速检查功能文件和步骤文件是否正确编写,而不真正执行它们吗?Cucumber 有一个快速而酷炫的功能来实现这一点:

build/HelloBDD/HelloBDDSteps > /dev/null &

这个命令将在后台模式下执行我们的测试应用程序。/dev/null是 Linux 操作系统中的一个空设备,我们将HelloBDDSteps可执行文件中的所有不需要的打印语句重定向到空设备,以确保在执行 Cucumber 测试用例时不会分散我们的注意力。

下一个命令将允许我们干运行 Cucumber 测试场景:

cucumber --dry-run 

以下截图显示了测试执行:

BDD - 一种测试驱动的开发方法

就像 TDD 一样,BDD 也坚持遵循测试驱动的开发方法。因此,在本节中,让我们探讨如何以 BDD 方式遵循测试驱动的开发方法编写端到端功能!

让我们举一个简单的例子,帮助我们理解 BDD 风格的编码。我们将编写一个RPNCalculator应用程序,它可以进行加法、减法、乘法、除法以及涉及多个数学运算的复杂数学表达式。

让我们按照 Cucumber 标准创建我们的项目文件夹结构:

mkdir RPNCalculator
cd RPNCalculator
cucumber --init
tree
mkdir src
tree

以下截图以可视化的方式展示了该过程:

太棒了!文件夹结构现在已经创建。现在,让我们使用 touch 实用程序创建空文件,以帮助我们可视化我们的最终项目文件夹结构以及文件:

touch features/rpncalculator.feature
touch features/step_definitions/RPNCalculatorSteps.cpp
touch features/step_definitions/cucumber.wire
touch src/RPNCalculator.h
touch src/RPNCalculator.cpp
touch CMakeLists.txt

一旦创建了虚拟文件,最终项目文件夹结构将如下截图所示:

像往常一样,Cucumber wire 文件将如下所示。事实上,在本章中,这个文件将保持不变:

host: localhost
port: 3902

现在,让我们从rpncalculator.feature文件开始,如下截图所示:

正如您所看到的,特性描述可能相当详细。您注意到了吗?我在场景的位置使用了Scenario OutlineScenario Outline的有趣之处在于它允许在Examples Cucumber 部分下以表格的形式描述一组输入和相应的输出。

如果您熟悉 SCRUM,Cucumber 场景看起来是否与用户故事非常接近?是的,这就是想法。理想情况下,SCRUM 用户故事或用例可以编写为 Cucumber 场景。Cucumber 特性文件是一个可以执行的实时文档。

我们需要在cucumber-cpp主目录的CMakeLists.txt文件中添加我们的项目,如下所示:

确保RPNCalculator文件夹下的CMakeLists.txt如下所示:

现在,让我们使用cucumber-cpp主目录中的以下命令构建我们的项目:

cmake --build build

让我们使用以下命令执行我们全新的RPNCalculator Cucumber 测试用例:

build/RPNCalculator/RPNCalculatorSteps &

cucumber RPNCalculator

输出如下:

在前面的屏幕截图中,我们在特性文件中编写的每个GivenWhenThen语句都有两个建议。第一个版本适用于 Ruby,第二个版本适用于 C++;因此,我们可以安全地忽略这些步骤建议,具体如下:

Then(/^the actualResult should match the (d+).(d+)$/) do |arg1, arg2|
 pending # Write code here that turns the phrase above into concrete actions
end 

由于我们尚未实现RPNCalculatorSteps.cpp文件,Cucumber 框架建议我们为先前的步骤提供实现。让我们将它们复制粘贴到RPNCalculatorSteps.cpp文件中,并完成步骤的实现,如下所示:

REGEX_PARAMcucumber-cpp BDD 框架支持的宏,它方便地从正则表达式中提取输入参数并将其传递给 Cucumber 步骤函数。

现在,让我们尝试使用以下命令再次构建我们的项目:

cmake --build  build

构建日志如下所示:

每个成功的开发者或顾问背后的秘密公式是他们具有强大的调试和问题解决能力。分析构建报告,特别是构建失败,是成功应用 BDD 所需的关键素质。每个构建错误都教会我们一些东西!

构建错误很明显,因为我们尚未实现RPNCalculator,文件是空的。让我们编写最小的代码,使得代码可以编译:

BDD 导致增量设计和开发,与瀑布模型不同。瀑布模型鼓励预先设计。通常在瀑布模型中,设计是最初完成的,并且占整个项目工作量的 30-40%。预先设计的主要问题是我们最初对特性了解较少;通常我们对特性了解模糊,但随着时间的推移会有所改善。因此,在设计活动上投入更多的精力并不是一个好主意;相反,要随时准备根据需要重构设计和代码。

因此,BDD 是复杂项目的自然选择。

使用这个最小的实现,让我们尝试构建和运行测试用例:

很棒!由于代码编译没有错误,现在让我们执行测试用例并观察发生了什么:

错误以红色突出显示,如前面的截图所示,由 cucumber-cpp 框架。这是预期的;测试用例失败,因为RPNCalculator::evaluate方法被硬编码为返回0.0

理想情况下,我们只需编写最少的代码使其通过,但我假设您在阅读本章之前已经阅读了第七章,测试驱动开发。在那一章中,我详细演示了每一步,包括重构。

现在,让我们继续实现代码以使该测试用例通过。修改后的RPNCalculator头文件如下所示:

相应的RPNCalculator源文件如下所示:

根据 BDD 实践,注意我们只实现了支持加法操作的代码,根据我们当前的 Cucumber 场景要求。像 TDD 一样,在 BDD 中,我们应该只编写满足当前场景的所需代码;这样,我们可以确保每一行代码都被有效的测试用例覆盖。

让我们构建和运行我们的 BDD 测试用例

让我们现在构建和测试。以下命令可用于构建,启动后台中的步骤,并分别使用线协议运行 Cucumber 测试用例:

cmake --build build
 build/RPNCalculator/RPNCalculatorSteps &

cucumber RPNCalculator

以下截图演示了构建和执行 Cucumber 测试用例的过程:

太棒了!我们的测试场景现在全部通过了!让我们继续进行下一个测试场景。

让我们在特性文件中添加一个场景来测试减法操作,如下所示:

测试输出如下:

我们以前见过这种情况,对吧?我相信你猜对了;预期结果是85,而实际结果是0,因为我们还没有添加减法的支持。现在,让我们添加必要的代码来在我们的应用程序中添加减法逻辑:

有了这个代码更改,让我们重新运行测试用例,看看测试结果如何:

很酷,测试报告又变成绿色了!

让我们继续,在特性文件中添加一个场景来测试乘法操作:

现在是时候运行测试用例了,如下截图所示:

你说对了;是的,我们需要在我们的生产代码中添加对乘法的支持。好的,让我们立即做,如下截图所示:

现在是测试时间!

以下命令可帮助您分别构建,启动步骤应用程序,并运行 Cucumber 测试用例。确切地说,第一个命令构建测试用例,而第二个命令以后台模式启动 Cucumber 步骤测试可执行文件。第三个命令执行我们为RPNCalculator项目编写的 Cucumber 测试用例。RPNCalculatorSteps可执行文件将作为 Cucumber 可以通过线协议与之通信的服务器。Cucumber 框架将从step_definitions文件夹下的cucumber.wire文件中获取服务器的连接详细信息:

cmake --build build
 build/RPNCalculator/RPNCalculatorSteps &

cucumber RPNCalculator

以下截图演示了 Cucumber 测试用例的执行过程:

我相信你已经掌握了 BDD!是的,BDD 非常简单和直接。现在让我们根据以下截图添加一个除法操作的场景:

让我们快速运行测试用例并观察测试结果,如下截图所示:

是的,我听到你说你知道失败的原因。让我们快速添加对除法的支持并重新运行测试用例,看看它是否全部变成绿色!BDD 让编码变得真的很有趣。

我们需要在RPNCalculator.cpp中添加以下代码片段:

else if ( *token == "/" ) {
      secondNumber = numberStack.top();
      numberStack.pop();
      firstNumber = numberStack.top();
      numberStack.pop();

      result = firstNumber / secondNumber;

      numberStack.push ( result );
}

通过这个代码更改,让我们检查测试输出:

cmake --build build
build/RPNCalculator/RPNCalculatorSteps &
cucumber RPNCalculator

以下截图以可视化方式演示了该过程:

到目前为止一切都很顺利。到目前为止,我们测试过的所有场景都通过了,这是一个好迹象。但让我们尝试一个涉及许多数学运算的复杂表达式。例如,让我们尝试10.0 5.0 * 1.0 + 100.0 2.0 / -

你知道吗?

逆波兰表示法(后缀表示法)被几乎每个编译器用来评估数学表达式。

以下截图演示了复杂表达式测试用例的集成:

让我们再次运行测试场景,因为这将是迄今为止实施的整个代码的真正测试,因为这个表达式涉及我们简单应用程序支持的所有操作。

以下命令可用于在后台模式下启动应用程序并执行 Cucumber 测试用例:

build/RPNCalculator/RPNCalculatorSteps &
cucumber RPNCalculator

以下截图以可视化方式演示了该过程:

太棒了!如果您已经走到这一步,我相信您已经了解了 cucumber-cpp 和 BDD 编码风格。

重构和消除代码异味

RPNCalculator.cpp代码中的分支太多,这是一个代码异味;因此,代码可以进行重构。好消息是RPNCalculator.cpp可以进行重构以消除代码异味,并有使用工厂方法、策略和空对象设计模式的空间。

总结

在本章中,您学到了以下内容

  • 行为驱动开发简称为 BDD。

  • BDD 是一种自顶向下的开发方法,并使用 Gherkin 语言作为领域特定语言(DSL)。

  • 在一个项目中,BDD 和 TDD 可以并行使用,因为它们互补而不是取代彼此。

  • cucumber-cpp BDD 框架利用 wire 协议来支持非 ruby 平台编写测试用例。

  • 通过实施测试驱动开发方法,您以实际方式学习了 BDD。

  • BDD 类似于 TDD,它鼓励通过以增量方式短间隔重构代码来开发清晰的代码。

  • 您学会了使用 Gherkin 编写 BDD 测试用例以及使用 Google 测试框架定义步骤。

在下一章中,您将学习有关 C++调试技术的知识。

第九章:调试技术

在本章中,我们将涵盖以下主题:

  • 有效的调试

  • 调试策略

  • 调试工具

  • 使用 GDB 调试应用程序

  • 使用 Valgrind 调试内存泄漏

  • 日志记录

有效的调试

调试是一门艺术而不是一门科学,它本身是一个非常庞大的主题。强大的调试技能是一个优秀开发人员的优势。所有专业的开发人员都有一些共同的特点,其中强大的问题解决和调试技能是最重要的。修复错误的第一步是复现问题。高效地捕获复现错误所涉及的步骤至关重要。有经验的 QA 工程师会知道捕获详细的复现步骤的重要性,因为如果无法复现问题,开发人员将很难修复问题。

在我看来,无法复现的错误无法修复。人们可以猜测和打草稿,但如果一开始就无法复现问题,就无法确定问题是否真正被修复。

以下详细信息将帮助开发人员更快地复现和调试问题:

  • 详细的复现问题的步骤

  • 错误的屏幕截图

  • 优先级和严重程度

  • 复现问题的输入和场景

  • 预期和实际输出

  • 错误日志

  • 应用程序日志和跟踪

  • 在应用程序崩溃时转储文件

  • 环境详细信息

  • 操作系统详细信息

  • 软件版本

一些常用的调试技术如下:

  • 使用cout/cerr打印语句非常方便

  • 核心转储、迷你转储和完整转储有助于远程分析错误

  • 使用调试工具逐步执行代码,检查变量、参数、中间值等

  • 测试框架有助于在第一时间防止问题的发生

  • 性能分析工具可以帮助找到性能问题

  • 检测内存泄漏、资源泄漏、死锁等工具

log4cpp开源 C++库是一个优雅且有用的日志实用程序,它可以添加支持调试的调试消息,在发布模式或生产环境中可以禁用。

调试策略

调试策略有助于快速复现、调试、检测和高效修复问题。以下列表解释了一些高级调试策略:

  • 使用缺陷跟踪系统,如 JIRA、Bugzilla、TFS、YouTrack、Teamwork 等

  • 应用程序崩溃或冻结必须包括核心转储、迷你转储或完整转储

  • 应用程序跟踪日志在所有情况下都是一个很好的帮助

  • 启用多级错误日志

  • 在调试和发布模式下捕获应用程序跟踪日志

调试工具

调试工具通过逐步执行、断点、变量检查等帮助缩小问题范围。尽管逐步调试问题可能是一项耗时的任务,但这绝对是确定问题的一种方法,我可以说这几乎总是有效的。

以下是 C++的调试工具列表:

  • GDB:这是一个开源的 CLI 调试器

  • Valgrind:这是一个用于内存泄漏、死锁、竞争检测等的开源 CLI 工具

  • Affinic debugger:这是一个用于 GDB 的商业 GUI 工具

  • GNU DDD:这是一个用于 GDB、DBX、JDB、XDB 等的开源图形调试器

  • GNU Emacs GDB 模式:这是一个带有最小图形调试器支持的开源工具

  • KDevelop:这是一个带有图形调试器支持的开源工具

  • Nemiver:这是一个在 GNOME 桌面环境中运行良好的开源工具

  • SlickEdit:适用于调试多线程和多处理器代码

在 C++中,有很多开源和商业许可的调试工具。然而,在本书中,我们将探索 GDB 和 Valgrind 这两个开源命令行界面工具。

使用 GDB 调试应用程序

经典的老式 C++开发人员使用打印语句来调试代码。然而,使用打印跟踪消息进行调试是一项耗时的任务,因为您需要在多个地方编写打印语句,重新编译并执行应用程序。

老式的调试方法需要许多这样的迭代,通常每次迭代都需要添加更多的打印语句以缩小问题范围。一旦问题解决了,我们需要清理代码并删除打印语句,因为太多的打印语句会减慢应用程序的性能。此外,调试打印消息会分散注意力,对于在生产环境中使用您产品的最终客户来说是无关紧要的。

C++调试assert()宏语句与<cassert>头文件一起使用于调试。C++ assert()宏在发布模式下可以被禁用,只有在调试模式下才启用。

调试工具可以帮助您摆脱这些繁琐的工作。GDB 调试器是一个开源的 CLI 工具,在 Unix/Linux 世界中是 C++的调试器。对于 Windows 平台,Visual Studio 是最受欢迎的一站式 IDE,具有内置的调试功能。

让我们举一个简单的例子:

#include <iostream>
#include <vector>
#include <iterator>
#include <algorithm>
using namespace std; //Use this judiciously - this is applicable throughout the book

class MyInteger {
      private:
           int number;

      public:
           MyInteger( int value ) {
                this->number = value;
           }

           MyInteger(const MyInteger & rhsObject ) {
                this->number = rhsObject.number;
           }

           MyInteger& operator = (const MyInteger & rhsObject ) {

                if ( this != &rhsObject )
                     this->number = rhsObject.number;

                return *this;
           }

           bool operator < (const MyInteger &rhsObject) {
                return this->number > rhsObject.number;
           }

           bool operator > (const MyInteger &rhsObject) {
                return this->number > rhsObject.number;
           }

           friend ostream & operator << ( ostream &output, const MyInteger &object );
};

ostream & operator << (ostream &o, const MyInteger& object) {
    o << object.number;
}

int main ( ) {

    vector<MyInteger> v = { 10, 100, 40, 20, 80, 70, 50, 30, 60, 90 };

    cout << "\nVectors entries before sorting are ..." << endl;
    copy ( v.begin(), v.end() , ostream_iterator<MyInteger>( cout, "\t" ) );
    cout << endl;

    sort ( v.begin(), v.end() );

    cout << "\nVectors entries after sorting are ..." << endl;
    copy ( v.begin(), v.end() , ostream_iterator<MyInteger>( cout, "\t" ) );
    cout << endl;

    return 0;
}

程序的输出如下:

Vectors entries before sorting are ...
10 100 40 20 80 70 50 30 60 90

Vectors entries after sorting are ...
100 90 80 70 60 50 40 30 20 10

然而,我们期望的输出如下:

Vectors entries before sorting are ...
10 100 40 20 80 70 50 30 60 90

Vectors entries after sorting are ...
10 20 30 40 50 60 70 80 90 100

错误是显而易见的;让我们轻松地学习 GDB。让我们首先以调试模式编译程序,也就是启用调试元数据和符号表,如下所示:

g++ main.cpp -std=c++17 -g

GDB 命令快速参考

以下 GDB 快速提示表将帮助您找到调试应用程序的 GDB 命令:

命令 简短命令 描述
gdb yourappln.exe - 在 GDB 中打开应用程序进行调试
break main b main 将断点设置为main函数
run r 执行程序直到达到逐步执行的断点
next n 逐步执行程序
step s 步入函数以逐步执行函数
continue c 继续执行程序直到下一个断点;如果没有设置断点,它将正常执行应用程序
backtrace bt 打印整个调用堆栈
quit qCtrl + d 退出 GDB
-help -h 显示可用选项并简要显示其用法

有了上述基本的 GDB 快速参考,让我们开始调试我们有问题的应用程序以检测错误。让我们首先使用以下命令启动 GDB:

gdb ./a.out

然后,让我们在main()处添加一个断点以进行逐步执行:

jegan@ubuntu:~/MasteringC++Programming/Debugging/Ex1$ g++ main.cpp -g
jegan@ubuntu:~/MasteringC++Programming/Debugging/Ex1$ ls
a.out main.cpp
jegan@ubuntu:~/MasteringC++Programming/Debugging/Ex1$ gdb ./a.out

GNU gdb (Ubuntu 7.12.50.20170314-0ubuntu1.1) 7.12.50.20170314-git
Copyright (C) 2017 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./a.out...done.
(gdb) b main
Breakpoint 1 at 0xba4: file main.cpp, line 46.
(gdb) l
32 
33 bool operator > (const MyInteger &rhsObject) {
34 return this->number < rhsObject.number;
35 }
36 
37 friend ostream& operator << ( ostream &output, const MyInteger &object );
38 
39 };
40 
41 ostream& operator << (ostream &o, const MyInteger& object) {
(gdb)

使用gdb启动我们的应用程序后,b main命令将在main()函数的第一行添加一个断点。现在让我们尝试执行应用程序:

(gdb) run
Starting program: /home/jegan/MasteringC++Programming/Debugging/Ex1/a.out 

Breakpoint 1, main () at main.cpp:46
46 int main ( ) {
(gdb) 

正如您可能已经观察到的,程序执行在我们的main()函数的行号46处暂停,因为我们在main()函数中添加了一个断点。

此时,让我们逐步执行应用程序,如下所示:

(gdb) run
Starting program: /home/jegan/MasteringC++Programming/Debugging/Ex1/a.out 

Breakpoint 1, main () at main.cpp:46
46 int main ( ) {
(gdb) next
48   vector<MyInteger> v = { 10, 100, 40, 20, 80, 70, 50, 30, 60, 90 };
(gdb) next
50   cout << "\nVectors entries before sorting are ..." << endl;
(gdb) n
Vectors entries before sorting are ...51   copy ( v.begin(), v.end() , ostream_iterator<MyInteger>( cout, "\t" ) );
(gdb) n
52   cout << endl;
(gdb) n
10 100 40 20 80 70 50 30 60 90 
54   sort ( v.begin(), v.end() );
(gdb) 

现在,让我们在行号2933处再添加两个断点,如下所示:

Breakpoint 1 at 0xba4: file main.cpp, line 46.Breakpoint 1 at 0xba4: file main.cpp, line 46.(gdb) run
Starting program: /home/jegan/Downloads/MasteringC++Programming/Debugging/Ex1/a.out 
Breakpoint 1, main () at main.cpp:46
46 int main ( ) {
(gdb) l
41 ostream& operator << (ostream &o, const MyInteger& object) {
42    o << object.number;
43 }
44 
45 
46 
int main ( ) {
47 
48   vector<MyInteger> v = { 10, 100, 40, 20, 80, 70, 50, 30, 60, 90 };
49    
50   cout << "\nVectors entries before sorting are ..." << endl;
(gdb) n
48   vector<MyInteger> v = { 10, 100, 40, 20, 80, 70, 50, 30, 60, 90 };
(gdb) n
50   cout << "\nVectors entries before sorting are ..." << endl;
(gdb) n
Vectors entries before sorting are ...
51   copy ( v.begin(), v.end() , ostream_iterator<MyInteger>( cout, "\t" ) );
(gdb) break 29
Breakpoint 2 at 0x555555554f88: file main.cpp, line 29.
(gdb) break 33
Breakpoint 3 at 0x555555554b80: file main.cpp, line 33.
(gdb) 

从中,您将了解到断点可以通过函数名或行号添加。现在让程序继续执行,直到达到我们设置的断点之一:

(gdb) break 29
Breakpoint 2 at 0x555555554f88: file main.cpp, line 29.
(gdb) break 33
Breakpoint 3 at 0x555555554b80: file main.cpp, line 33.
(gdb) continue Continuing.
Breakpoint 2, MyInteger::operator< (this=0x55555576bc24, rhsObject=...) at main.cpp:30 30 return this->number > rhsObject.number; (gdb) 

正如你所看到的,程序执行在行号29处暂停,因为每当sort函数需要决定是否交换两个项目以按升序排序向量条目时,它就会被调用。

让我们探索如何检查或打印变量this->numberrhsObject.number

(gdb) break 29
Breakpoint 2 at 0x400ec6: file main.cpp, line 29.
(gdb) break 33
Breakpoint 3 at 0x400af6: file main.cpp, line 33.
(gdb) continue
Continuing.
Breakpoint 2, MyInteger::operator< (this=0x617c24, rhsObject=...) at main.cpp:30
30 return this->number > rhsObject.number;
(gdb) print this->number $1 = 100 (gdb) print rhsObject.number $2 = 10 (gdb) 

您是否注意到<>操作符的实现方式?该操作符检查小于操作,而实际的实现检查大于操作,并且>操作符重载方法中也观察到了类似的 bug。请检查以下代码:

bool operator < ( const MyInteger &rhsObject ) {
        return this->number > rhsObject.number;
}

bool operator > ( const MyInteger &rhsObject ) {
        return this->number < rhsObject.number;
}

虽然sort()函数应该按升序对vector条目进行排序,但输出显示它是按降序对它们进行排序的,前面的代码是问题的根源。因此,让我们修复问题,如下所示:

bool operator < ( const MyInteger &rhsObject ) {
        return this->number < rhsObject.number;
}

bool operator > ( const MyInteger &rhsObject ) {
        return this->number > rhsObject.number;
}

有了这些更改,让我们编译并运行程序:

g++ main.cpp -std=c++17 -g

./a.out

这是您将获得的输出:

Vectors entries before sorting are ...
10   100   40   20   80   70   50   30   60   90

Vectors entries after sorting are ...
10   20   30   40   50   60   70   80   90   100

很好,我们修复了 bug!毋庸置疑,您已经认识到了 GDB 调试工具的用处。虽然我们只是浅尝辄止了 GDB 工具的功能,但它提供了许多强大的调试功能。然而,在本章中,涵盖 GDB 工具支持的每一个功能是不切实际的;因此,我强烈建议您查阅 GDB 文档以进行进一步学习sourceware.org/gdb/documentation/

使用 Valgrind 调试内存泄漏

Valgrind 是 Unix 和 Linux 平台的一组开源 C/C++调试和性能分析工具。Valgrind 支持的工具集如下:

  • Cachegrind:这是缓存分析器

  • Callgrind:这与缓存分析器类似,但支持调用者-被调用者序列

  • Helgrind:这有助于检测线程同步问题

  • DRD:这是线程错误检测器

  • Massif:这是堆分析器

  • Lackey:这提供了关于应用程序的基本性能统计和测量

  • exp-sgcheck:这检测堆栈越界;通常用于查找 Memcheck 无法找到的问题

  • exp-bbv:这对计算机架构研发工作很有用

  • exp-dhat:这是另一个堆分析器

  • Memcheck:这有助于检测内存泄漏和与内存问题相关的崩溃

在本章中,我们将只探讨 Memcheck,因为展示每个 Valgrind 工具不在本书的范围内。

Memcheck 工具

Valgrind 使用的默认工具是 Memcheck。Memcheck 工具可以检测出相当详尽的问题列表,其中一些如下所示:

  • 访问数组、堆栈或堆越界的边界外

  • 未初始化内存的使用

  • 访问已释放的内存

  • 内存泄漏

  • newfreemallocdelete的不匹配使用

让我们在接下来的小节中看一些这样的问题。

检测数组边界外的内存访问

以下示例演示了对数组边界外的内存访问:

#include <iostream>
using namespace std;

int main ( ) {
    int a[10];

    a[10] = 100;
    cout << a[10] << endl;

    return 0;
}

以下输出显示了 Valgrind 调试会话,准确指出了数组边界外的内存访问:

g++ arrayboundsoverrun.cpp -g -std=c++17 

jegan@ubuntu  ~/MasteringC++/Debugging  valgrind --track-origins=yes --read-var-info=yes ./a.out
==28576== Memcheck, a memory error detector
==28576== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==28576== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==28576== Command: ./a.out
==28576== 
100
*** stack smashing detected ***: ./a.out terminated
==28576== 
==28576== Process terminating with default action of signal 6 (SIGABRT)
==28576== at 0x51F1428: raise (raise.c:54)
==28576== by 0x51F3029: abort (abort.c:89)
==28576== by 0x52337E9: __libc_message (libc_fatal.c:175)
==28576== by 0x52D511B: __fortify_fail (fortify_fail.c:37)
==28576== by 0x52D50BF: __stack_chk_fail (stack_chk_fail.c:28)
==28576== by 0x4008D8: main (arrayboundsoverrun.cpp:11)
==28576== 
==28576== HEAP SUMMARY:
==28576== in use at exit: 72,704 bytes in 1 blocks
==28576== total heap usage: 2 allocs, 1 frees, 73,728 bytes allocated
==28576== 
==28576== LEAK SUMMARY:
==28576== definitely lost: 0 bytes in 0 blocks
==28576== indirectly lost: 0 bytes in 0 blocks
==28576== possibly lost: 0 bytes in 0 blocks
==28576== still reachable: 72,704 bytes in 1 blocks
==28576== suppressed: 0 bytes in 0 blocks
==28576== Rerun with --leak-check=full to see details of leaked memory
==28576== 
==28576== For counts of detected and suppressed errors, rerun with: -v
==28576== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
[1] 28576 abort (core dumped) valgrind --track-origins=yes --read-var-info=yes ./a.out

正如您所注意到的,应用程序由于非法内存访问而崩溃并生成了核心转储。在前面的输出中,Valgrind 工具准确指出了导致崩溃的行。

检测对已释放内存位置的内存访问

以下示例代码演示了对已释放内存位置的内存访问:

#include <iostream>
using namespace std;

int main( ) {

    int *ptr = new int();

    *ptr = 100;

    cout << "\nValue stored at pointer location is " << *ptr << endl;

    delete ptr;

    *ptr = 200;
    return 0;
}

让我们编译前面的程序并学习 Valgrind 如何报告试图访问已释放内存位置的非法内存访问:

==118316== Memcheck, a memory error detector
==118316== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==118316== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==118316== Command: ./a.out
==118316== 

Value stored at pointer location is 100
==118316== Invalid write of size 4
==118316== at 0x400989: main (illegalaccess_to_released_memory.cpp:14)
==118316== Address 0x5ab6c80 is 0 bytes inside a block of size 4 free'd
==118316== at 0x4C2F24B: operator delete(void*) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==118316== by 0x400984: main (illegalaccess_to_released_memory.cpp:12)
==118316== Block was alloc'd at
==118316== at 0x4C2E0EF: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==118316== by 0x400938: main (illegalaccess_to_released_memory.cpp:6)
==118316== 
==118316== 
==118316== HEAP SUMMARY:
==118316== in use at exit: 72,704 bytes in 1 blocks
==118316== total heap usage: 3 allocs, 2 frees, 73,732 bytes allocated
==118316== 
==118316== LEAK SUMMARY:
==118316== definitely lost: 0 bytes in 0 blocks
==118316== indirectly lost: 0 bytes in 0 blocks
==118316== possibly lost: 0 bytes in 0 blocks
==118316== still reachable: 72,704 bytes in 1 blocks
==118316== suppressed: 0 bytes in 0 blocks
==118316== Rerun with --leak-check=full to see details of leaked memory
==118316== 
==118316== For counts of detected and suppressed errors, rerun with: -v
==118316== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

Valgrind 准确指出了尝试访问在第12行释放的内存位置的行号。

检测未初始化内存访问

以下示例代码演示了未初始化内存访问的使用以及如何使用 Memcheck 检测相同的问题:

#include <iostream>
using namespace std;

class MyClass {
    private:
       int x;
    public:
      MyClass( );
  void print( );
}; 

MyClass::MyClass() {
    cout << "\nMyClass constructor ..." << endl;
}

void MyClass::print( ) {
     cout << "\nValue of x is " << x << endl;
}

int main ( ) {

    MyClass obj;
    obj.print();
    return 0;

}

现在让我们编译并使用 Memcheck 检测未初始化内存访问问题:

g++ main.cpp -g

valgrind ./a.out --track-origins=yes

==51504== Memcheck, a memory error detector
==51504== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==51504== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==51504== Command: ./a.out --track-origins=yes
==51504== 

MyClass constructor ...

==51504== Conditional jump or move depends on uninitialised value(s)
==51504== at 0x4F3CCAE: std::ostreambuf_iterator<char, std::char_traits<char> > std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::_M_insert_int<long>(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x4F3CEDC: std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::do_put(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x4F493F9: std::ostream& std::ostream::_M_insert<long>(long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x40095D: MyClass::print() (uninitialized.cpp:19)
==51504== by 0x4009A1: main (uninitialized.cpp:26)
==51504== 
==51504== Use of uninitialised value of size 8
==51504== at 0x4F3BB13: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x4F3CCD9: std::ostreambuf_iterator<char, std::char_traits<char> > std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::_M_insert_int<long>(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x4F3CEDC: std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::do_put(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x4F493F9: std::ostream& std::ostream::_M_insert<long>(long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x40095D: MyClass::print() (uninitialized.cpp:19)
==51504== by 0x4009A1: main (uninitialized.cpp:26)
==51504== 
==51504== Conditional jump or move depends on uninitialised value(s)
==51504== at 0x4F3BB1F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x4F3CCD9: std::ostreambuf_iterator<char, std::char_traits<char> > std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::_M_insert_int<long>(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x4F3CEDC: std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::do_put(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x4F493F9: std::ostream& std::ostream::_M_insert<long>(long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x40095D: MyClass::print() (uninitialized.cpp:19)
==51504== by 0x4009A1: main (uninitialized.cpp:26)
==51504== 
==51504== Conditional jump or move depends on uninitialised value(s)
==51504== at 0x4F3CD0C: std::ostreambuf_iterator<char, std::char_traits<char> > std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::_M_insert_int<long>(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x4F3CEDC: std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::do_put(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x4F493F9: std::ostream& std::ostream::_M_insert<long>(long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==51504== by 0x40095D: MyClass::print() (uninitialized.cpp:19)
==51504== by 0x4009A1: main (uninitialized.cpp:26)
==51504== 
Value of x is -16778960
==51504== 
==51504== HEAP SUMMARY:
==51504== in use at exit: 72,704 bytes in 1 blocks
==51504== total heap usage: 2 allocs, 1 frees, 73,728 bytes allocated
==51504== 
==51504== LEAK SUMMARY:
==51504== definitely lost: 0 bytes in 0 blocks
==51504== indirectly lost: 0 bytes in 0 blocks
==51504== possibly lost: 0 bytes in 0 blocks
==51504== still reachable: 72,704 bytes in 1 blocks
==51504== suppressed: 0 bytes in 0 blocks
==51504== Rerun with --leak-check=full to see details of leaked memory
==51504== 
==51504== For counts of detected and suppressed errors, rerun with: -v
==51504== Use --track-origins=yes to see where uninitialised values come from
==51504== ERROR SUMMARY: 18 errors from 4 contexts (suppressed: 0 from 0)

在前面的输出中,加粗显示的行清楚地指出了访问未初始化变量的确切行号(14):

==51504== by 0x40095D: MyClass::print() (uninitialized.cpp:19)
==51504== by 0x4009A1: main (uninitialized.cpp:26)

 18 void MyClass::print() {
 19 cout << "\nValue of x is " << x << endl;
 20 } 

上面的代码片段是供你参考的;然而,Valgrind 不会显示代码细节。底线是 Valgrind 精确指出了访问未初始化变量的行,这通常很难用其他方法检测到。

检测内存泄漏

让我们来看一个有一些内存泄漏的简单程序,并探索 Valgrind 工具如何在 Memcheck 的帮助下帮助我们检测内存泄漏。由于 Memcheck 是 Valgrind 默认使用的工具,因此在发出 Valgrind 命令时不需要显式调用 Memcheck 工具:

valgrind application_debugged.exe --tool=memcheck

以下代码实现了一个单链表:

#include <iostream>
using namespace std;

struct Node {
  int data;
  Node *next;
};

class List {
private:
  Node *pNewNode;
  Node *pHead;
  Node *pTail;
  int __size;
  void createNewNode( int );
public:
  List();
  ~List();
  int size();
  void append ( int data );
  void print( );
};

正如你可能已经观察到的,前面的类声明有append()一个新节点的方法,print()列表的方法,以及一个size()方法,返回列表中节点的数量。

让我们探索实现append()方法、print()方法、构造函数和析构函数的list.cpp源文件:

#include "list.h"

List::List( ) {
  pNewNode = NULL;
  pHead = NULL;
  pTail = NULL;
  __size = 0;
}

List::~List() {}

void List::createNewNode( int data ) {
  pNewNode = new Node();
  pNewNode->next = NULL;
  pNewNode->data = data;
}

void List::append( int data ) {
  createNewNode( data );
  if ( pHead == NULL ) {
    pHead = pNewNode;
    pTail = pNewNode;
    __size = 1;
  }
  else {
    Node *pCurrentNode = pHead;
    while ( pCurrentNode != NULL ) {
      if ( pCurrentNode->next == NULL ) break;
      pCurrentNode = pCurrentNode->next;
    }

    pCurrentNode->next = pNewNode;
    ++__size;
  }
}

void List::print( ) {
  cout << "\nList entries are ..." << endl;
  Node *pCurrentNode = pHead;
  while ( pCurrentNode != NULL ) {
    cout << pCurrentNode->data << "\t";
    pCurrentNode = pCurrentNode->next;
  }
  cout << endl;
}

以下代码演示了main()函数:

#include "list.h"

int main ( ) {
  List l;

  for (int count = 0; count < 5; ++count )
    l.append ( (count+1) * 10 );
  l.print();

  return 0;
}

让我们编译程序并尝试在前面的程序中检测内存泄漏:

g++ main.cpp list.cpp -std=c++17 -g

valgrind ./a.out --leak-check=full 

==99789== Memcheck, a memory error detector
==99789== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==99789== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==99789== Command: ./a.out --leak-check=full
==99789== 

List constructor invoked ...

List entries are ...
10 20 30 40 50 
==99789== 
==99789== HEAP SUMMARY:
==99789== in use at exit: 72,784 bytes in 6 blocks
==99789== total heap usage: 7 allocs, 1 frees, 73,808 bytes allocated
==99789== 
==99789== LEAK SUMMARY:
==99789== definitely lost: 16 bytes in 1 blocks
==99789== indirectly lost: 64 bytes in 4 blocks
==99789== possibly lost: 0 bytes in 0 blocks
==99789== still reachable: 72,704 bytes in 1 blocks
==99789== suppressed: 0 bytes in 0 blocks
==99789== Rerun with --leak-check=full to see details of leaked memory
==99789== 
==99789== For counts of detected and suppressed errors, rerun with: -v
==99789== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

从前面的输出可以看出,我们的应用泄漏了 80 字节。虽然definitely lostindirectly lost表示我们的应用泄漏的内存,但still reachable并不一定表示我们的应用,它可能是由第三方库或 C++运行时库泄漏的。可能它们并不是真正的内存泄漏,因为 C++运行时库可能使用内存池。

修复内存泄漏

让我们尝试通过在List::~List()析构函数中添加以下代码来修复内存泄漏问题:

List::~List( ) {

        cout << "\nList destructor invoked ..." << endl;
        Node *pTemp = NULL;

        while ( pHead != NULL ) {

                pTemp = pHead;
                pHead = pHead->next;

                delete pTemp;
        }

        pNewNode = pHead = pTail = pTemp = NULL;
        __size = 0;

}

从下面的输出中,你会发现内存泄漏已经被修复:

g++ main.cpp list.cpp -std=c++17 -g

valgrind ./a.out --leak-check=full

==44813== Memcheck, a memory error detector
==44813== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==44813== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==44813== Command: ./a.out --leak-check=full
==44813== 

List constructor invoked ...

List entries are ...
10 20 30 40 50 
Memory utilised by the list is 80

List destructor invoked ...
==44813== 
==44813== HEAP SUMMARY:
==44813== in use at exit: 72,704 bytes in 1 blocks
==44813== total heap usage: 7 allocs, 6 frees, 73,808 bytes allocated
==44813== 
==44813== LEAK SUMMARY:
==44813== definitely lost: 0 bytes in 0 blocks
==44813== indirectly lost: 0 bytes in 0 blocks
==44813== possibly lost: 0 bytes in 0 blocks
==44813== still reachable: 72,704 bytes in 1 blocks
==44813== suppressed: 0 bytes in 0 blocks
==44813== Rerun with --leak-check=full to see details of leaked memory
==44813== 
==44813== For counts of detected and suppressed errors, rerun with: -v
==44813== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

如果你仍然对前面输出中报告的still reachable问题不满意,让我们尝试在simple.cpp中尝试以下代码,以了解这是否在我们的控制之内:

#include <iostream>
using namespace std;

int main ( ) {

    return 0;

} 

执行以下命令:

g++ simple.cpp -std=c++17 -g

valgrind ./a.out --leak-check=full

==62474== Memcheck, a memory error detector
==62474== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==62474== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==62474== Command: ./a.out --leak-check=full
==62474== 
==62474== 
==62474== HEAP SUMMARY:
==62474== in use at exit: 72,704 bytes in 1 blocks
==62474== total heap usage: 1 allocs, 0 frees, 72,704 bytes allocated
==62474== 
==62474== LEAK SUMMARY:
==62474== definitely lost: 0 bytes in 0 blocks
==62474== indirectly lost: 0 bytes in 0 blocks
==62474== possibly lost: 0 bytes in 0 blocks
==62474== still reachable: 72,704 bytes in 1 blocks
==62474== suppressed: 0 bytes in 0 blocks
==62474== Rerun with --leak-check=full to see details of leaked memory
==62474== 
==62474== For counts of detected and suppressed errors, rerun with: -v
==62474== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

正如你所看到的,main()函数除了返回0之外什么也没做,Valgrind 报告说这个程序也有相同的部分:still reachable": 72, 704 bytes in 1 blocks。因此,在Valgrind泄漏摘要中真正重要的是是否有泄漏报告在以下任何或所有部分:definitely lostindirectly lostpossibly lost

new 和 free 或 malloc 和 delete 的不匹配使用

这种问题很少见,但不能排除它们发生的可能性。可能会出现这样的情况,当一个基于 C 的遗留工具被移植到 C++时,一些内存分配被错误地分配,但使用delete关键字或反之亦然被释放。

以下示例演示了使用 Valgrind 检测问题:

#include <stdlib.h>

int main ( ) {

        int *ptr = new int();

        free (ptr); // The correct approach is delete ptr

        char *c = (char*)malloc ( sizeof(char) );

        delete c; // The correct approach is free ( c )

        return 0;
}

以下输出演示了一个 Valgrind 会话,检测到了freedelete的不匹配使用:

g++ mismatchingnewandfree.cpp -g

valgrind ./a.out 
==76087== Memcheck, a memory error detector
==76087== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==76087== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==76087== Command: ./a.out
==76087== 
==76087== Mismatched free() / delete / delete []
==76087== at 0x4C2EDEB: free (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==76087== by 0x4006FD: main (mismatchingnewandfree.cpp:7)
==76087== Address 0x5ab6c80 is 0 bytes inside a block of size 4 alloc'd
==76087== at 0x4C2E0EF: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==76087== by 0x4006E7: main (mismatchingnewandfree.cpp:5)
==76087== 
==76087== Mismatched free() / delete / delete []
==76087== at 0x4C2F24B: operator delete(void*) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==76087== by 0x400717: main (mismatchingnewandfree.cpp:11)
==76087== Address 0x5ab6cd0 is 0 bytes inside a block of size 1 alloc'd
==76087== at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==76087== by 0x400707: main (mismatchingnewandfree.cpp:9)
==76087== 
==76087== 
==76087== HEAP SUMMARY:
==76087== in use at exit: 72,704 bytes in 1 blocks
==76087== total heap usage: 3 allocs, 2 frees, 72,709 bytes allocated
==76087== 
==76087== LEAK SUMMARY:
==76087== definitely lost: 0 bytes in 0 blocks
==76087== indirectly lost: 0 bytes in 0 blocks
==76087== possibly lost: 0 bytes in 0 blocks
==76087== still reachable: 72,704 bytes in 1 blocks
==76087== suppressed: 0 bytes in 0 blocks
==76087== Rerun with --leak-check=full to see details of leaked memory
==76087== 
==76087== For counts of detected and suppressed errors, rerun with: -v
==76087== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)

总结

在本章中,你学习了各种 C++调试工具以及 Valgrind 工具的应用,比如检测未初始化的变量访问和检测内存泄漏。你还学习了 GDB 工具和检测由于非法内存访问已释放内存位置而引起的问题。

在下一章中,你将学习代码异味和清洁代码实践。

第十章:代码异味和干净代码实践

本章将涵盖以下主题:

  • 代码异味简介

  • 干净代码的概念

  • 敏捷和干净代码实践的关系

  • SOLID 设计原则

  • 代码重构

  • 将代码异味重构为干净代码

  • 将代码异味重构为设计模式

干净的代码是在功能上准确运行并且结构良好编写的源代码。通过彻底的测试,我们可以确保代码在功能上是正确的。我们可以通过代码自审、同行代码审查、代码分析,最重要的是通过代码重构来提高代码质量。

以下是一些干净代码的特质:

  • 易于理解

  • 易于增强

  • 添加新功能不需要进行太多的代码更改

  • 易于重用

  • 自解释

  • 在必要时有注释

最后,编写干净代码的最好之处是项目或产品中涉及的开发团队和客户都会很高兴。

代码重构

重构有助于改善源代码的结构质量。它不会修改代码的功能;它只是改善了代码的结构方面的质量。重构使代码更清晰,但有时它可能帮助您改善整体代码性能。但是,您需要明白性能调优与代码重构是不同的。

以下图表展示了开发过程的概述:

如何安全地进行代码重构?这个问题的答案如下:

  • 拥抱 DevOps

  • 适应测试驱动开发

  • 适应行为驱动开发

  • 使用验收测试驱动开发

代码异味

源代码有两个方面的质量,即功能结构。源代码的功能质量可以通过根据客户规格对代码进行测试来实现。大多数开发人员犯的最大错误是他们倾向于在不进行重构的情况下将代码提交到版本控制软件;也就是说,他们一旦认为代码在功能上完成了,就提交了代码。

事实上,将代码提交到版本控制通常是一个好习惯,因为这是持续集成和 DevOps 可能的基础。将代码提交到版本控制后,绝大多数开发人员忽视的是对其进行重构。重构代码非常重要,以确保代码是干净的,没有这一点,敏捷是不可能的。

看起来像面条(意大利面)的代码需要更多的努力来增强或维护。因此,快速响应客户的请求在实际上是不可能的。这就是为什么保持干净的代码对于敏捷至关重要。这适用于您组织中遵循的任何敏捷框架。

什么是敏捷?

敏捷就是快速失败。一个敏捷团队将能够快速响应客户的需求,而不需要开发团队的任何花哨。团队使用的敏捷框架并不是很重要:Scrum、Kanban、XP 或其他什么。真正重要的是,你是否认真地遵循它们?

作为独立的软件顾问,我个人观察并学习了谁通常抱怨,以及他们为什么抱怨敏捷。

由于 Scrum 是最流行的敏捷框架之一,让我们假设一个产品公司,比如 ABC 科技私人有限公司,已决定为他们计划开发的新产品采用 Scrum。好消息是,ABC 科技,就像大多数组织一样,也有效地举行了冲刺计划会议、每日站立会议、冲刺回顾、冲刺回顾等所有其他 Scrum 仪式。假设 ABC 科技已确保他们的 Scrum 主管是 Scrum 认证的,产品经理是 Scrum 认证的产品负责人。太好了!到目前为止一切听起来都很好。

假设 ABC 科技产品团队不使用 TDD、BDD、ATDD 和 DevOps。你认为 ABC 科技产品团队是敏捷的吗?当然不是。事实上,开发团队将面临繁忙和不切实际的日程安排。最终,将会有非常高的离职率,因为团队不会开心。因此,客户也不会开心,产品的质量将遭受严重损害。

你认为 ABC 科技产品团队出了什么问题?

Scrum 有两套流程,即项目管理流程,由 Scrum 仪式涵盖。然后,还有流程的工程方面,大多数组织并不太关注。这可以从 IT 行业对Certified SCRUM Developer(CSD)认证的兴趣或认识程度中看出。IT 行业对 CSM、CSPO 或 CSP 所表现的兴趣几乎不会表现在 CSD 上,而开发人员是需要的。然而,我不认为单凭认证就能使某人成为专家;它只能显示个人或组织在接受敏捷框架并向客户交付优质产品方面的严肃性。

除非代码保持清晰,否则开发团队如何能够快速响应客户的需求?换句话说,除非开发团队的工程师在产品开发中采用 TDD、BDD、ATDD、持续集成和 DevOps,否则任何团队都无法在 Scrum 或其他敏捷框架中取得成功。

底线是,除非你的组织同等重视工程 Scrum 流程和项目管理 Scrum 流程,否则没有开发团队能够声称在敏捷中取得成功。

SOLID 设计原则

SOLID 是一组重要的设计原则的首字母缩写,如果遵循,可以避免代码异味并改善代码质量,无论是在结构上还是在功能上。

如果您的软件架构符合 SOLID 设计原则的要求,代码异味可以被预防或重构为清晰的代码。以下原则统称为 SOLID 设计原则:

  • 单一职责原则

  • 开闭原则

  • 里氏替换原则

  • 接口隔离

  • 依赖反转

最好的部分是,大多数设计模式也遵循并符合 SOLID 设计原则。

让我们逐个在以下部分讨论上述设计原则。

单一职责原则

单一职责原则简称为SRP。SRP 表示每个类必须只有一个责任。换句话说,每个类必须恰好代表一个对象。当一个类代表多个对象时,它往往违反 SRP 并为多个代码异味打开机会。

例如,让我们以一个简单的Employee类为例:

在上述类图中,Employee类似乎代表了三个不同的对象:EmployeeAddressContact。因此,它违反了 SRP。根据这个原则,可以从上述的Employee类中提取出另外两个类,即AddressContact,如下所示:

为简单起见,本节中使用的类图不显示各个类支持的方法,因为我们的重点是通过一个简单的例子理解 SRP。

在上述重构后的设计中,Employee 有一个或多个地址(个人和官方)和一个或多个联系人(个人和官方)。最好的部分是,在重构设计后,每个类都抽象出一个且仅有一个责任。

开闭原则

当设计支持添加新功能而无需更改代码或不修改现有源代码时,架构或设计符合开闭原则OCP)。正如您所知,根据您的专业行业经验,您遇到的每个项目都以某种方式是可扩展的。这就是您能够向产品添加新功能的方式。但是,当这种功能扩展是在不修改现有代码的情况下完成时,设计将符合 OCP。

让我们以一个简单的Item类为例,如下所示。为简单起见,Item类中只捕获了基本细节:

#include <iostream>
#include <string>
using namespace std;
class Item {
       private:
         string name;
         double quantity;
         double pricePerUnit;
       public:
         Item ( string name, double pricePerUnit, double quantity ) {
         this-name = name; 
         this->pricePerUnit = pricePerUnit;
         this->quantity = quantity;
    }
    public double getPrice( ) {
           return quantity * pricePerUnit;
    }
    public String getDescription( ) {
           return name;
    }
};

假设前面的Item类是一个小商店的简单结算应用程序的一部分。由于Item类将能够代表钢笔、计算器、巧克力、笔记本等,它足够通用,可以支持商店处理的任何可计费项目。但是,如果商店老板应该收取商品和服务税GST)或增值税VAT),现有的Item类似乎不支持税收组件。一种常见的方法是修改Item类以支持税收组件。但是,如果我们修改现有代码,我们的设计将不符合 OCP。

因此,让我们重构我们的设计,使其符合 OCP,使用访问者设计模式。让我们探索重构的可能性,如下所示:

#ifndef __VISITABLE_H
#define __VISITABLE_H
#include <string>
 using namespace std;
class Visitor;

class Visitable {
 public:
        virtual void accept ( Visitor * ) = 0;
        virtual double getPrice() = 0;
        virtual string getDescription() = 0;
 };
#endif

Visitable类是一个具有三个纯虚函数的抽象类。Item类将继承Visitable抽象类,如下所示:

#ifndef __ITEM_H
#define __ITEM_H
#include <iostream>
#include <string>
using namespace std;
#include "Visitable.h"
#include "Visitor.h"
class Item : public Visitable {
 private:
       string name;
       double quantity;
       double unitPrice;
 public:
       Item ( string name, double quantity, double unitPrice );
       string getDescription();
       double getQuantity();
       double getPrice();
       void accept ( Visitor *pVisitor );
 };

 #endif

接下来,让我们看一下Visitor类,如下所示。它说未来可以实现任意数量的Visitor子类以添加新功能,而无需修改Item类:

class Visitable;
#ifndef __VISITOR_H
#define __VISITOR_H
class Visitor {
 protected:
 double price;

 public:
 virtual void visit ( Visitable * ) = 0;
 virtual double getPrice() = 0;
 };

 #endif

GSTVisitor类是让我们在不修改Item类的情况下添加 GST 功能的类。GSTVisitor的实现如下所示:

#include "GSTVisitor.h"

void GSTVisitor::visit ( Visitable *pItem ) {
     price = pItem->getPrice() + (0.18 * pItem->getPrice());
}

double GSTVisitor::getPrice() {
     return price;
}

Makefile如下所示:

all: GSTVisitor.o Item.o main.o
     g++ -o gst.exe GSTVisitor.o Item.o main.o

GSTVisitor.o: GSTVisitor.cpp Visitable.h Visitor.h
     g++ -c GSTVisitor.cpp

Item.o: Item.cpp
     g++ -c Item.cpp

main.o: main.cpp
     g++ -c main.cpp

重构后的设计符合 OCP,因为我们将能够在不修改Item类的情况下添加新功能。想象一下:如果 GST 计算随时间变化,我们将能够添加Visitor的新子类并应对即将到来的变化,而无需修改Item类。

里斯科夫替换原则

里斯科夫替换原则LSP)强调子类遵守基类建立的契约的重要性。在理想的继承层次结构中,随着设计重点向上移动类层次结构,我们应该注意泛化;随着设计重点向下移动类层次结构,我们应该注意专门化。

继承契约是两个类之间的,因此基类有责任强加所有子类都能遵守的规则,一旦达成协议,子类同样有责任遵守契约。违反这些设计原则的设计将不符合 LSP。

LSP 说,如果一个方法以基类或接口作为参数,应该能够无条件地用任何一个子类的实例替换它。

事实上,继承违反了最基本的设计原则:继承是弱内聚和强耦合的。因此,继承的真正好处是多态性,而代码重用与继承相比是微不足道的好处。当 LSP 被违反时,我们无法用其子类实例替换基类实例,最糟糕的是我们无法多态地调用方法。尽管付出了使用继承的设计代价,如果我们无法获得多态性的好处,就没有真正使用它的动机。

识别 LSP 违规的技术如下:

  • 子类将具有一个或多个带有空实现的重写方法

  • 基类将具有专门的行为,这将强制某些子类,无论这些专门的行为是否符合子类的兴趣

  • 并非所有的泛化方法都可以被多态调用

以下是重构 LSP 违规的方法:

  • 将基类中的专门方法移动到需要这些专门行为的子类中。

  • 避免强制让关联不大的类参与继承关系。除非子类是基本类型,否则不要仅仅为了代码重用而使用继承。

  • 不要寻求小的好处,比如代码重用,而是寻求在可能的情况下使用多态性、聚合或组合的方法。

接口隔离

接口隔离设计原则建议为特定目的建模许多小接口,而不是建模代表许多东西的一个更大的接口。在 C++中,具有纯虚函数的抽象类可以被视为接口。

让我们举一个简单的例子来理解接口隔离:

#include <iostream>
#include <string>
using namespace std;

class IEmployee {
      public:
          virtual string getDoor() = 0;
          virtual string getStreet() = 0;
          virtual string getCity() = 0;
          virtual string getPinCode() = 0;
          virtual string getState() = 0;
          virtual string getCountry() = 0;
          virtual string getName() = 0;
          virtual string getTitle() = 0;
          virtual string getCountryDialCode() = 0;
          virtual string getContactNumber() = 0;
};

在前面的例子中,抽象类展示了一个混乱的设计。这个设计混乱是因为它似乎代表了许多东西,比如员工、地址和联系方式。前面的抽象类可以重构的一种方式是将单一接口分解为三个独立的接口:IEmployeeIAddressIContact。在 C++中,接口只不过是具有纯虚函数的抽象类:

#include <iostream>
#include <string>
#include <list>
using namespace std;

class IEmployee {
  private:
     string firstName, middleName, lastName,
     string title;
     string employeeCode;
     list<IAddress> addresses;
     list<IContact> contactNumbers;
  public:
     virtual string getAddress() = 0;
     virtual string getContactNumber() = 0;
};

class IAddress {
     private:
          string doorNo, street, city, pinCode, state, country;
     public:
          IAddress ( string doorNo, string street, string city, 
            string pinCode, string state, string country );
          virtual string getAddress() = 0;
};

class IContact {
      private:
           string countryCode, mobileNumber;
      public:
           IContact ( string countryCode, string mobileNumber );
           virtual string getMobileNumber() = 0;
};

在重构后的代码片段中,每个接口都代表一个对象,因此符合接口隔离设计原则。

依赖反转

一个好的设计将是高内聚且低耦合的。因此,我们的设计必须具有较少的依赖性。一个使代码依赖于许多其他对象或模块的设计被认为是一个糟糕的设计。如果依赖反转DI)被违反,发生在依赖模块中的任何变化都会对我们的模块产生不良影响,导致连锁反应。

让我们举一个简单的例子来理解 DI 的威力。一个Mobile类"拥有"一个Camera对象,注意这里的拥有是组合。组合是一种独占所有权,Camera对象的生命周期由Mobile对象直接控制:

正如你在上图中所看到的,Mobile类有一个Camera的实例,使用的是组合的拥有形式,这是一种独占所有权的关系。

让我们看一下Mobile类的实现,如下所示:

#include <iostream>
using namespace std;

class Mobile {
     private:
          Camera camera;
     public:
          Mobile ( );
          bool powerOn();
          bool powerOff();
};

class Camera {
      public:
          bool ON();
          bool OFF();
};

bool Mobile::powerOn() {
       if ( camera.ON() ) {
           cout << "\nPositive Logic - assume some complex Mobile power ON logic happens here." << endl;
           return true;
       }
       cout << "\nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
            << endl;
       return false;
}

bool Mobile::powerOff() {
      if ( camera.OFF() ) {
              cout << "\nPositive Logic - assume some complex Mobile power OFF             logic happens here." << endl;
      return true;
 }
      cout << "\nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
      return false;
}

bool Camera::ON() {
     cout << "\nAssume Camera class interacts with Camera hardware here\n" << endl;
     cout << "\nAssume some Camera ON logic happens here" << endl;
     return true;
}

bool Camera::OFF() {
 cout << "\nAssume Camera class interacts with Camera hardware here\n" << endl;
 cout << "\nAssume some Camera OFF logic happens here" << endl;
 return true;
}

在前面的代码中,MobileCamera有实现级别的了解,这是一个糟糕的设计。理想情况下,Mobile应该通过一个接口或具有纯虚函数的抽象类与Camera进行交互,因为这样可以将Camera的实现与其契约分离。这种方法有助于替换Camera而不影响Mobile,也为支持一系列Camera子类提供了机会,而不是单一的摄像头。

想知道为什么它被称为依赖注入DI)或控制反转IOC)吗?之所以称之为依赖注入,是因为目前Camera的生命周期由Mobile对象控制;也就是说,CameraMobile对象实例化和销毁。在这种情况下,如果没有Camera,几乎不可能对Mobile进行单元测试,因为MobileCamera有硬依赖。除非实现了Camera,否则无法测试Mobile的功能,这是一种糟糕的设计方法。当我们反转依赖时,它让Mobile对象使用Camera对象,同时放弃了控制Camera对象的生命周期的责任。这个过程被称为 IOC。优点是你将能够独立单元测试MobileCamera对象,它们将由于 IOC 而具有强大的内聚性和松散的耦合性。

让我们用 DI 设计原则重构前面的代码:

#include <iostream>
using namespace std;

class ICamera {
 public:
 virtual bool ON() = 0;
 virtual bool OFF() = 0;
};

class Mobile {
      private:
 ICamera *pCamera;
      public:
 Mobile ( ICamera *pCamera );
            void setCamera( ICamera *pCamera ); 
            bool powerOn();
            bool powerOff();
};

class Camera : public ICamera {
public:
            bool ON();
            bool OFF();
};

//Constructor Dependency Injection
Mobile::Mobile ( ICamera *pCamera ) {
 this->pCamera = pCamera;
}

//Method Dependency Injection
Mobile::setCamera( ICamera *pCamera ) {
 this->pCamera = pCamera;
}

bool Mobile::powerOn() {
 if ( pCamera->ON() ) {
            cout << "\nPositive Logic - assume some complex Mobile power ON logic happens here." << endl;
            return true;
      }
cout << "\nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
<< endl;
      return false;
}

bool Mobile::powerOff() {
 if ( pCamera->OFF() ) {
           cout << "\nPositive Logic - assume some complex Mobile power OFF logic happens here." << endl;
           return true;
}
      cout << "\nNegative Logic - assume some complex Mobile power OFF logic happens here." << endl;
      return false;
}

bool Camera::ON() {
       cout << "\nAssume Camera class interacts with Camera hardware here\n" << endl;
       cout << "\nAssume some Camera ON logic happens here" << endl;
       return true;
}

bool Camera::OFF() {
       cout << "\nAssume Camera class interacts with Camera hardware here\n" << endl;
       cout << "\nAssume some Camera OFF logic happens here" << endl;
       return true;
}

在前述代码片段中,对更改进行了加粗标记。IOC 是一种非常强大的技术,它让我们解耦依赖,正如刚才所示;然而,它的实现非常简单。

代码异味

代码异味是指指缺乏结构质量的代码;然而,代码可能在功能上是正确的。代码异味违反了 SOLID 设计原则,因此必须认真对待,因为编写不好的代码会导致长期的高昂维护成本。然而,代码异味可以重构为干净的代码。

注释异味

作为独立的软件顾问,我有很多机会与优秀的开发人员、架构师、质量保证人员、系统管理员、首席技术官和首席执行官、企业家等进行互动和学习。每当我们的讨论涉及到“什么是干净的代码或好的代码?”这个十亿美元的问题时,我基本上在全球范围内得到了一个共同的回答,“好的代码将会有良好的注释。”虽然这部分是正确的,但问题也正是从这里开始。理想情况下,干净的代码应该是不言自明的,不需要任何注释。然而,有些情况下注释可以提高整体的可读性和可维护性。并非所有的注释都是代码异味,因此有必要区分好的注释和坏的注释。看看下面的代码片段:

if ( condition1 ) {
     // some block of code
}
else if ( condition2 ) {
     // some block of code
}
else {
     // OOPS - the control should not reach here ### Code Smell ###
}

我相信你一定遇到过这些评论。毋庸置疑,前述情况是代码异味。理想情况下,开发人员应该重构代码来修复错误,而不是写这样的评论。有一次我在半夜调试一个关键问题,我注意到控制流达到了一个神秘的空代码块,里面只有一个注释。我相信你也遇到过更有趣的代码,可以想象它带来的挫败感;有时候,你也会写这种类型的代码。

一个好的注释会表达代码为什么以特定方式编写,而不是表达代码如何做某事。传达代码如何做某事的注释是代码异味,而传达代码为什么部分的注释是一个好的注释,因为代码没有表达为什么部分;因此,一个好的注释提供了附加值。

长方法

当一个方法被确定具有多个责任时,它就被认为是长的。通常,一个方法如果有超过 20-25 行的代码,往往会有多个责任。话虽如此,代码行数更多的方法就更长。这并不意味着代码行数少于 25 行的方法就不长。看看下面的代码片段:

void Employee::validateAndSave( ) {
        if ( ( street != "" ) && ( city != "" ) )
              saveEmployeeDetails();
}

显然,前述方法有多个责任;也就是说,它似乎在验证和保存细节。在保存之前进行验证并没有错,但同一个方法不应该同时做这两件事。因此,前述方法可以重构为两个具有单一责任的较小方法:

private:
void Employee::validateAddress( ) {
     if ( ( street == "" ) || ( city == "" ) )
          throw exception("Invalid Address");
}

public:
void Employee::save() {
      validateAddress();
}

在前面的代码中显示的每个重构方法都只负责一项任务。将validateAddress()方法变成一个谓词方法可能很诱人;也就是说,一个返回布尔值的方法。然而,如果validateAddress()被写成一个谓词方法,那么客户端代码将被迫进行if检查,这是一种代码异味。通过返回错误代码来处理错误不被认为是面向对象的代码,因此错误处理必须使用 C++异常来完成。

长参数列表

面向对象的方法接收较少的参数,因为一个设计良好的对象将具有较强的内聚性和较松散的耦合性。接收太多参数的方法是一种症状,表明做出决定所需的知识是从外部获得的,这意味着当前对象本身没有所有的知识来做出决定。

这意味着当前对象的内聚性较弱,耦合性较强,因为它依赖于太多外部数据来做出决定。成员函数通常倾向于接收较少的参数,因为它们通常需要的数据成员是成员变量。因此,将成员变量传递给成员函数的需求听起来是不自然的。

让我们看看方法倾向于接收过多参数的一些常见原因。最常见的症状和原因列在这里:

  • 对象的内聚性较弱,耦合性较强;也就是说,它过于依赖其他对象

  • 这是一个静态方法

  • 这是一个放错位置的方法;也就是说,它不属于该对象

  • 这不是面向对象的代码

  • SRP 被违反

重构接收长参数列表(LPL)的方法的方式如下:

  • 避免逐个提取和传递数据;考虑传递整个对象,让方法提取所需的细节

  • 识别提供给接收 LPL 方法的参数的对象,并考虑将方法移至该对象

  • 将参数列表分组并创建参数对象,并将接收 LPL 的方法移至新对象内部

重复代码

重复代码是一个常见的反复出现的代码异味,不需要太多解释。仅仅复制和粘贴代码文化本身不能完全归咎于重复代码。重复代码使得代码维护更加繁琐,因为相同的问题可能需要在多个地方修复,并且集成新功能需要太多的代码更改,这往往会破坏意外的功能。重复代码还会增加应用程序的二进制占用空间,因此必须对其进行重构以获得清晰的代码。

条件复杂性

条件复杂性代码异味是指复杂的大条件随着时间的推移趋于变得更大更复杂。这种代码异味可以通过策略设计模式进行重构。由于策略设计模式涉及许多相关对象,因此可以使用工厂方法,并且空对象设计模式可以用于处理工厂方法中不支持的子类:

//Before refactoring
void SomeClass::someMethod( ) {
      if (  ! conition1 && condition2 )
         //perform some logic
      else if ( ! condition3 && condition4 && condition5 )
         //perform some logic
      else
         //do something 
} 

//After refactoring
void SomeClass::someMethod() {
     if ( privateMethod1() )
          //perform some logic
     else if ( privateMethod2() )
          //perform some logic
     else
         //do something
}

大类

大类代码异味使得代码难以理解,更难以维护。一个大类可能为一个类做太多的事情。大类可以通过将其拆分为具有单一职责的较小类来进行重构。

死代码

死代码是被注释掉或者从未被使用或集成的代码。它可以通过代码覆盖工具来检测。通常,开发人员由于缺乏信心而保留这些代码实例,这在传统代码中更常见。由于每个代码都在版本控制软件工具中进行跟踪,死代码可以被删除,如果需要的话,总是可以从版本控制软件中检索回来。

原始执念

原始执念(PO)是一种错误的设计选择:使用原始数据类型来表示复杂的领域实体。例如,如果使用字符串数据类型来表示日期,虽然起初听起来像一个聪明的想法,但从长远来看,这会带来很多维护麻烦。

假设您使用字符串数据类型来表示日期,将会面临以下问题:

  • 您需要根据日期对事物进行排序

  • 引入字符串后,日期算术将变得非常复杂

  • 根据区域设置支持各种日期格式将会变得复杂,如果使用字符串

理想情况下,日期必须由一个类来表示,而不是一个原始数据类型。

数据类

数据类只提供获取器和设置器函数。虽然它们非常适用于在不同层之间传输数据,但它们往往会给依赖于数据类的类增加负担。由于数据类不提供任何有用的功能,与数据类交互或依赖的类最终会使用数据类的数据添加功能。这样,围绕数据类的类违反了单一职责原则,并且往往会成为一个大类。

特性嫉妒

某些类被称为“特性嫉妒”,如果它们对其他类的内部细节了解过多。一般来说,当其他类是数据类时,就会发生这种情况。代码异味是相互关联的;消除一个代码异味往往会吸引其他代码异味。

总结

在本章中,您学习了以下主题:

  • 代码异味和重构代码的重要性

  • SOLID 设计原则:

  • 单一职责原则

  • 开闭原则

  • 里氏替换

  • 接口隔离

  • 依赖注入

  • 各种代码异味:

  • 注释异味

  • 长方法

  • 长参数列表

  • 重复代码

  • 条件复杂性

  • 大类

  • 死代码

  • 面向对象的代码异味的原始执念

  • 数据类

  • 特性嫉妒

您还学习了许多重构技术,这将帮助您保持代码更清晰。愉快的编码!

posted @ 2024-05-05 00:04  绝不原创的飞龙  阅读(24)  评论(0编辑  收藏  举报