C++ 专家编程:成为熟练的程序员(五)

原文:annas-archive.org/md5/f9404739e16292672f830e964de1c2e4

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:使用设计模式设计策略游戏

游戏开发是软件工程中最有趣的话题之一。C++在游戏开发中被广泛使用,因为它的效率。然而,由于该语言没有 GUI 组件,因此它被用于后端。在本章中,我们将学习如何在后端设计策略游戏。我们将整合几乎所有我们在之前章节中学到的内容,包括设计模式和多线程。

我们将设计的游戏是一个名为读者和扰乱者的策略游戏。在这里,玩家创建单位,称为读者,他们能够建造图书馆和其他建筑物,以及士兵,他们保卫这些建筑物免受敌人的攻击。

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

  • 游戏设计简介

  • 深入游戏设计的过程

  • 使用设计模式

  • 设计游戏循环

技术要求

在整个本章中,将使用带有-std=c++2a选项的 g++编译器来编译示例。您可以在github.com/PacktPublishing/Expert-CPP找到本章中将使用的源文件。

游戏设计简介

在本章中,我们将设计一个策略游戏的后端,玩家可以创建单位(工人、士兵)、建造建筑物,并与敌人战斗。无论您设计的是策略游戏还是第一人称射击游戏,都有一些基本组件是相同的,例如游戏物理,用于使游戏对玩家更真实和沉浸。

在几乎所有游戏中都有一些重复的游戏设计组件,例如碰撞检测机制、音频系统、图形渲染等。在设计游戏时,我们可以区分引擎和游戏,或者开发一个紧密联系的应用程序,代表引擎和游戏作为一个单一的结果。将游戏引擎单独设计允许它在后续版本中进行扩展,甚至用于其他游戏。毕竟,游戏具有相同的机制和相同的流程。它们主要通过情节线有所不同。

在设计游戏引擎时,您应该仔细规划将使用引擎设计的游戏类型。虽然大多数基本功能是相同的,独立于游戏类型,但在 3D 射击游戏和策略游戏中有区别。在策略游戏中,玩家会在一个大的游戏场地上进行单位的战略部署。游戏世界是从俯视角度显示的。

读者和扰乱者游戏简介

游戏的基本理念很简单:玩家拥有有限的资源。这些资源可以用来为游戏角色创建建筑物。我们称这些角色单位,分为读者和士兵。读者是聪明的角色,他们建造图书馆和其他建筑物。每个建成的图书馆可以容纳多达 10 名读者。如果玩家将 10 名读者移入图书馆,经过一定时间后,图书馆会产生一名教授。教授是一个强大的单位,可以一次摧毁三名敌方士兵。教授可以为玩家的士兵制造更好的武器。

游戏从一个已建好的房子开始,有两名士兵和三名读者。房子每 5 分钟产生一个新的读者。读者可以建造新的房子,然后产生更多的读者。他们还可以建造兵营,生产士兵。

玩家的目标是建造五座图书馆,每座图书馆至少产生一名教授。玩家在游戏过程中必须保卫自己的建筑物和读者免受敌人的攻击。敌人被称为扰乱者,因为他们的目标是打扰读者的主要目标:在图书馆里学习。

策略游戏组件

正如我们之前提到的,我们的策略游戏将包括基本组件-读者和士兵(我们将称它们为单位),建筑物和地图。游戏地图包含游戏中每个对象的坐标。我们将讨论游戏地图的简化版本。现在,让我们利用我们的项目设计技能来分解游戏本身。

游戏包括以下角色单位:

  • 一位读者

  • 一名士兵

  • 一位教授

它还包括以下建筑:

  • 一座图书馆

  • 一座房子

  • 一座兵营

现在,让我们讨论游戏的每个组件的属性。游戏角色具有以下属性:

  • 生命点数(一个整数,在每次来自敌方的攻击后减少)

  • 力量(一个整数,定义单位对敌方单位造成的伤害量)

  • 类型(读者,士兵或教授)

生命属性应该有一个基于单位类型的初始值。例如,读者的初始生命点数为 10,而士兵的生命点数为 12。在游戏中互动时,所有单位都可能受到敌方单位的攻击。每次攻击都被描述为生命点数的减少。我们减少生命点数的数量取决于攻击者的力量值。例如,士兵的力量值设定为 3,这意味着士兵发动的每次攻击都会使受害者的生命点数减少 3。当受害者的生命点数变为零时,角色单位将被摧毁。

建筑物也是如此。建筑物有一个完全建成的建造持续时间。完整的建筑物也有生命点数,敌方部队造成的任何损害都会减少这些生命点数。以下是建筑物属性的完整列表:

  • 生命点数

  • 类型

  • 建造持续时间

  • 单位生产持续时间

单位生产持续时间是生产新角色单位所需的时间。例如,一个兵营每 3 分钟生产一个士兵,一座房子每 5 分钟生产一个读者,一座图书馆在最后一个缺失的读者进入图书馆时立即产生一名教授。

现在我们已经定义了游戏组件,让我们讨论它们之间的互动。

组件之间的互动

读者和扰乱者游戏设计中的下一个重要事项是角色之间的互动。我们已经提到读者可以建造建筑物。在游戏中,这个过程应该得到照顾,因为每种类型的建筑都有其建造持续时间。因此,如果读者忙于建筑过程,我们应该测量时间,以确保建筑物在指定时间后准备好。然而,为了使游戏变得更好,我们应该考虑到不止一个读者可以参与建筑过程。这应该使建筑物的建造速度更快。例如,如果一名读者在 5 分钟内建造一座兵营,那么两名读者应该在 2 分半钟内建造一座兵营,依此类推。这是游戏中复杂互动的一个例子,并可以用以下图表来描述:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

复杂互动

接下来是攻击处理。当一个单位受到敌人的攻击时,我们应该减少被告的生命点数。被告本身可以攻击攻击者(为了自卫)。每当有多个攻击者或被告时,我们应该相应地处理每个受攻击单位的生命点数减少。我们还应该定义每个单位的攻击持续时间。一个单位不应该很快地攻击另一个单位。为了使事情更加自然,我们可以在每次攻击之间引入 1 秒或 2 秒的暂停。以下图表描述了简单的攻击互动。这将在本章后面用类互动图表替换:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

简单攻击互动

在游戏中发生了更大的互动。游戏中有两个组,其中一个由玩家控制,另一个由游戏自动控制。这意味着我们作为游戏设计者有责任定义敌方力量的生命周期。游戏将自动创建读者,他们将被分配创建图书馆、兵营和房屋的任务。每个士兵都应该负责保卫建筑和读者(人们)。而士兵们也应该不时地组成小组进行进攻任务。

我们将设计一个平台,让玩家创建一个帝国;然而,游戏也应该创建敌人以使游戏完整。玩家将面临来自敌人的定期攻击,而敌人将通过建造更多建筑和生产更多单位来发展。总的来说,我们可以用以下图表来描述这种互动:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

玩家和自动玩家之间的互动

在设计游戏时,我们将经常参考上述类图。

设计游戏

虽然游戏不是典型的软件,但其设计与常规应用程序设计并无太大不同。我们将从主要实体开始,并进一步分解为类及其关系。

在前一节中,我们讨论了所有必要的游戏组件及其交互。我们进行了项目开发生命周期的需求分析和收集。现在,我们将开始设计游戏。

设计角色单位

以下类图表示了一个读者:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

当我们浏览其他角色单位时,我们将为每个角色单位创建一个基类。每个特定单位将继承自该基类,并添加其特定的属性(如果有)。以下是角色单位的完整类图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

注意基类-它是一个接口,而不是一个常规类。它定义了要在派生类中实现的纯虚函数。以下是代码中CharacterUnit接口的样子:

class CharacterUnit
{
public:
  virtual void attack(const CharacterUnit&) = 0;
  virtual void destroy() = 0;
  virtual int get_power() const = 0;
  virtual int get_life_points() const = 0;
};

attack()方法减少角色的生命点数,而destroy()摧毁角色。摧毁意味着不仅从场景中移除角色,还停止了单位正在进行的所有交互(如建筑建造、自卫等)。

派生类为CharacterUnit接口类的纯虚函数提供了实现。让我们来看一下Reader角色单位的代码:

class Reader : public CharacterUnit
{
public:
  Reader();
  Reader(const Reader&) = delete;
  Reader& operator=(const Reader&) = delete;

public:
  void attack(const CharacterUnit& attacker) override {
    decrease_life_points_by_(attacker.get_power());
  }

  void destroy() override {
    // we will leave this empty for now
  }

  int get_life_points() const override {
    return life_points_;
  }

  int get_power() const override {
    return power_;
  }

private:
  void decrease_life_points_(int num) {
    life_points_ -= num;
    if (life_points_ <= 0) {
      destroy();
    }
  }

private:
  int life_points_;
  int power_;
};

现在,我们可以通过以下任何一种方式声明Reader单位:

Reader reader;
Reader* pr = new Reader();
CharacterUnit* cu = new Reader();

我们将主要通过它们的基接口类来引用角色单位。

注意复制构造函数和赋值运算符。我们故意将它们标记为删除,因为我们不希望通过复制其他单位来创建单位。我们将使用Prototype模式来实现这一行为。这将在本章后面讨论。

在需要对不同类型的单位执行相同操作的情况下,具有CharacterUnit接口至关重要。例如,假设我们需要计算两名士兵、一名读者和一名教授对建筑物造成的完整伤害。我们可以自由地将它们都称为CharacterUnits,而不是保留三个不同的引用来引用三种不同类型的单位。以下是具体操作:

int calculate_damage(const std::vector<CharacterUnit*>& units)
{
  return std::reduce(units.begin(), units.end(), 0, 
            [](CharacterUnit& u1, CharacterUnit& u2) {
                return u1.get_power() + u2.get_power();
            }
  );
}

calculate_damage()函数抽象出了单位类型;它不关心读者或士兵。它只调用CharacterUnit接口的get_power()方法,这个方法保证了特定对象的实现。

随着进展,我们将更新角色单位类。现在,让我们继续设计建筑物的类。

设计建筑物

建筑类与角色单位类似,具有共同的接口。例如,我们可以从以下定义房屋类开始:

class House
{
public:
  House();
  // copying will be covered by a Prototype
  House(const House&) = delete;
  House& operator=(const House&) = delete;

public:
  void attack(const CharacterUnit&);
  void destroy();
  void build(const CharacterUnit&);
  // ...

private:
  int life_points_;
  int capacity_;
  std::chrono::duration<int> construction_duration_;
};

在这里,我们使用std::chrono::duration来保持House施工持续时间的时间间隔。它在<chrono>头文件中定义为一定数量的滴答和滴答周期,其中滴答周期是从一个滴答到下一个滴答的秒数。

House类需要更多细节,但我们很快会意识到我们需要一个所有建筑的基本接口(甚至是一个抽象类)。本章将描述的建筑共享某些行为。Building的接口如下:

class IBuilding
{
public:
  virtual void attack(const CharacterUnit&) = 0;
  virtual void destroy() = 0;
  virtual void build(CharacterUnit*) = 0;
  virtual int get_life_points() const = 0;
};

注意Building前面的I前缀。许多开发人员建议为接口类使用前缀或后缀以提高可读性。例如,Building可能已被命名为IBuildingBuildingInterface。我们将对先前描述的CharacterUnit使用相同的命名技术。

HouseBarrackLibrary类实现了IBuilding接口,并且必须为纯虚方法提供实现。例如,Barrack类将如下所示:

class Barrack : public IBuilding
{
public:
  void attack(const ICharacterUnit& attacker) override {
    decrease_life_points_(attacker.get_power());
  }

  void destroy() override {
    // we will leave this empty for now
  }

  void build(ICharacterUnit* builder) override {
    // construction of the building
  }

  int get_life_points() const override {
    return life_points_;
  }

private:
  int life_points_;
  int capacity_;
  std::chrono::duration<int> construction_duration_;
};

让我们更详细地讨论施工持续时间的实现。在这一点上,std::chrono::持续时间点,作为一个提醒,告诉我们施工应该需要指定的时间。还要注意,类的最终设计可能会在本章的过程中发生变化。现在,让我们找出游戏组件如何相互交互。

设计游戏控制器

为角色单位和建筑设计类只是设计游戏本身的第一步。游戏中最重要的事情之一是设计这些组件之间的交互。我们应该仔细分析和设计诸如两个或更多角色建造一个建筑的情况。我们已经为建筑引入了施工时间,但我们没有考虑到一个建筑可能由多个读者(可以建造建筑的角色单位)来建造。

我们可以说,由两个读者建造的建筑应该比一个读者建造的建筑快两倍。如果另一个读者加入建设,我们应该重新计算持续时间。然而,我们应该限制可以在同一建筑上工作的读者数量。

如果任何读者受到敌人的攻击,那应该打扰读者建造,以便他们可以集中精力进行自卫。当一个读者停止在建筑上工作时,我们应该重新计算施工时间。攻击是另一种类似于建筑的情况。当一个角色受到攻击时,它应该通过反击来进行自卫。每次攻击都会减少角色的生命值。一个角色可能会同时受到多个敌方角色的攻击。这将更快地减少他们的生命值。

建筑有一个计时器,因为它会周期性地产生角色。设计最重要的是游戏动态-也就是循环。在每个指定的时间段,游戏中会发生一些事情。这可能是敌人士兵的接近,角色单位建造某物,或其他任何事情。一个动作的执行并不严格地与另一个无关的动作的完成相关。这意味着建筑的施工与角色的创建同时进行。与大多数应用程序不同,即使用户没有提供任何输入,游戏也应该保持运行。如果玩家未执行任何操作,游戏不会冻结。角色单位可能会等待命令,但建筑将不断地完成它们的工作-生产新的角色。此外,敌方玩家(自动化的)力求胜利,从不停顿。

并发动作

游戏中的许多动作是同时发生的。正如我们刚才讨论的,建筑的建造不应该因为一个没有参与建造的单位被敌人攻击而停止。如果敌人发动攻击,建筑也不应该停止生产新角色。这意味着我们应该为游戏中的许多对象设计并发行为。

在 C++中实现并发的最佳方法之一是使用线程。我们可以重新设计单位和建筑,使它们包括一个可以在其基类中重写的动作,该动作将在单独的线程中执行。让我们重新设计IBuilding,使其成为一个抽象类,其中包含一个额外的run()虚函数:

class Building
{
public:
  virtual void attack(const ICharacterUnit&) = 0;
  virtual void destroy() = 0;
  virtual void build(ICharacterUnit*) = 0;
  virtual int get_life_points() const = 0;

public:  
 void run() {
 std::jthread{Building::background_action_, this};
 }

private:
  virtual void background_action_() {
 // no or default implementation in the base class 
 }
};

注意background_action_()函数;它是私有的,但是虚的。我们可以在派生类中重写它。run()函数不是虚的;它在一个线程中运行私有实现。在这里,派生类可以为background_action_()提供一个实现。当一个单位被分配来建造建筑时,将调用build()虚函数。build()函数将计算建造时间的工作委托给run()函数。

游戏事件循环

解决这个问题的最简单方法是定义一个事件循环。事件循环如下所示:

while (true)
{
  processUserActions();
  updateGame();
}

即使用户(玩家)没有任何操作,游戏仍会通过调用updateGame()函数继续进行。请注意,上述代码只是对事件循环的一般介绍。正如你所看到的,它会无限循环,并在每次迭代中处理和更新游戏。

每次循环迭代都会推进游戏的状态。如果用户操作处理时间很长,可能会阻塞循环。游戏会短暂地冻结。我们通常用每秒帧数FPS)来衡量游戏的速度。数值越高,游戏越流畅。

我们需要设计游戏循环,使其在游戏过程中持续运行。设计它的重要之处在于用户操作处理不会阻塞循环。

游戏循环负责游戏中发生的一切,包括 AI。这里的 AI 指的是我们之前讨论过的敌方玩家的自动化。除此之外,游戏循环处理角色和建筑的动作,并相应地更新游戏的状态。

在深入游戏循环设计之前,让我们先了解一些设计模式,这些模式将帮助我们完成这个复杂的任务。毕竟,游戏循环本身也是一个设计模式!

使用设计模式

使用面向对象OOP编程范式来设计游戏是很自然的。毕竟,游戏代表了一组对象,它们之间进行了密集的互动。在我们的策略游戏中,有单位建造的建筑。单位会抵御来自敌方单位的攻击等等。这种相互通信导致了复杂性的增长。随着项目的发展和功能的增加,支持它将变得更加困难。很明显,设计是构建项目中最重要的(如果不是最重要的)部分之一。整合设计模式将极大地改善设计过程和项目支持。

让我们来看一些在游戏开发中有用的设计模式。我们将从经典模式开始,然后讨论更多与游戏相关的模式。

命令模式

开发人员将设计模式分为创建型、结构型和行为型三类。命令模式是一种行为设计模式。行为设计模式主要关注对象之间通信的灵活性。在这种情况下,命令模式将一个动作封装在一个包含必要信息以及动作本身的对象中。这样,命令模式就像一个智能函数。在 C++中实现它的最简单方法是重载一个类的operator(),如下所示:

class Command
{
public:
  void operator()() { std::cout << "I'm a smart function!"; }
};

具有重载operator()的类有时被称为函数对象。前述代码几乎与以下常规函数声明相同:

void myFunction() { std::cout << "I'm not so smart!"; }

调用常规函数和Command类的对象看起来很相似,如下所示:

myFunction();
Command myCommand;
myCommand();

这两者之间的区别在于,当我们需要为函数使用状态时,这一点就显而易见了。为了为常规函数存储状态,我们使用静态变量。为了在对象中存储状态,我们使用对象本身。以下是我们如何跟踪重载运算符的调用次数:

class Command
{
public:
  Command() : called_(0) {}

  void operator()() {
    ++called_;
    std::cout << "I'm a smart function." << std::endl;
    std::cout << "I've been called" << called_ << " times." << std::endl;
  }

private:
  int called_;
};

每个Command类的实例的调用次数是唯一的。以下代码声明了两个Command的实例,并分别调用了两次和三次:

Command c1;
Command c2;
c1();
c1();
c2();
c2();
c2();
// at this point, c1.called_ equals 2, c2.called_ equals 3

现在,让我们尝试将这种模式应用到我们的策略游戏中。游戏的最终版本具有图形界面,允许用户使用各种按钮和鼠标点击来控制游戏。例如,要让一个角色单位建造一座房子,而不是兵营,我们应该在游戏面板上选择相应的图标。让我们想象一个带有游戏地图和一堆按钮来控制游戏动态的游戏面板。

游戏为玩家提供以下命令:

  • 将角色单位从 A 点移动到 B 点

  • 攻击敌人

  • 建造建筑

  • 安置房屋

游戏命令的设计如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

每个类封装了动作逻辑。客户端代码不关心处理动作。它操作命令指针,每个指针将指向具体的Command(如前图所示)。请注意,我们只描述了玩家将执行的命令。游戏本身使用命令在模块之间进行通信。自动命令的示例包括RunDefendDieCreate。以下是游戏中命令的更广泛的图表:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

前述命令执行游戏过程中出现的任何事件。要监听这些事件,我们应该考虑使用观察者模式。

观察者模式

观察者模式是一种允许我们订阅对象状态变化的架构机制。我们说我们观察对象的变化。观察者模式也是一种行为设计模式。

大多数策略游戏都包含资源的概念。这可能是岩石、黄金、木材等。例如,在建造建筑时,玩家必须花费 20 单位的木材、40 单位的岩石和 10 单位的黄金。最终,玩家将耗尽资源并必须收集资源。玩家创建更多角色单位并指派它们收集资源 - 几乎就像现实生活中发生的情况一样。

现在,假设我们的游戏中有类似的资源收集或消耗活动。当玩家指派单位收集资源时,他们应该在每次收集到一定数量的资源时通知我们。玩家是“资源收集”事件的订阅者。

建筑也是如此。建筑物生产角色 - 订阅者会收到通知。角色单位完成建筑施工 - 订阅者会收到通知。在大多数情况下,订阅者是玩家。我们更新玩家仪表板,以便在玩游戏时保持游戏状态最新;也就是说,玩家在玩游戏时可以了解自己拥有多少资源、多少单位和多少建筑物。

观察者涉及实现一个存储其订阅者并在事件上调用指定函数的类。它由两个实体组成:订阅者和发布者。如下图所示,订阅者的数量不限于一个:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

例如,当角色单位被指定建造建筑时,它将不断努力建造,除非它被停止。可能会有各种原因导致这种情况发生:

  • 玩家决定取消建筑施工过程。

  • 角色单位必须保护自己免受敌人的攻击,并暂停施工过程。

  • 建筑已经完成,所以角色单位停止在上面工作。

玩家也希望在建筑完成时收到通知,因为他们可能计划在建筑完成后让角色单位执行其他任务。我们可以设计建筑过程,使其在事件完成时通知其监听者(订阅者)。以下类图还涉及一个 Action 接口。将其视为命令模式的实现:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

根据观察者开发类,我们会发现游戏中几乎所有实体都是订阅者、发布者或两者兼而有之。如果遇到类似情况,可以考虑使用中介者-另一种行为模式。对象通过中介者对象相互通信。触发事件的对象会让中介者知道。然后中介者将消息传递给任何与对象状态“订阅”相关的对象。以下图表是中介者集成的简化版本:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

每个对象都包含一个中介者,用于通知订阅者有关更改的信息。中介者对象通常包含彼此通信的所有对象。在事件发生时,每个对象通过中介者通知感兴趣的各方。例如,当建筑施工完成时,它会触发中介者,中介者会通知所有订阅的各方。为了接收这些通知,每个对象都应该事先订阅中介者。

Flyweight 模式

Flyweight 是一种结构设计模式。结构模式负责将对象和类组装成更大、更灵活的结构。Flyweight 允许我们通过共享它们的共同部分来缓存对象。

在我们的策略游戏中,屏幕上渲染了许多对象。在游戏过程中,对象的数量会增加。玩家玩得越久,他们创建的角色单位和建筑就越多(自动敌人也是如此)。游戏中的每个单位都代表一个包含数据的单独对象。角色单位至少占用 16 字节的内存(用于其两个整数数据成员和虚拟表指针)。

当我们为了在屏幕上渲染单位而向单位添加额外字段时,情况变得更糟;例如,它们的高度、宽度和精灵(代表渲染单位的图像)。除了角色单位,游戏还应该有一些补充物品,以提高用户体验,例如树木、岩石等装饰物品。在某个时候,我们会得出结论,我们有大量对象需要在屏幕上渲染,每个对象几乎代表相同的对象,但在其状态上有一些小差异。Flyweight 模式在这里发挥了作用。对于角色单位,其高度、宽度和精灵在所有单位中存储的数据几乎相同。

Flyweight 模式建议将一个重对象分解为两个:

  • 一个不可变的对象,包含相同类型对象的相同数据

  • 一个可变对象,可以从其他对象中唯一标识自己

例如,移动的角色单位有自己的高度、长度和精灵,所有这些对于所有角色单位都是重复的。因此,我们可以将这些属性表示为具有相同值的单个不可变对象,对于所有对象的属性都是相同的。然而,角色单位在屏幕上的位置可能与其他位置不同,当玩家命令单位移动到其他位置或开始建造建筑时,单位的位置会不断变化直到达到终点。在每一步,单位都应该在屏幕上重新绘制。通过这样做,我们得到以下设计:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

左侧是修改前的CharacterUnit,右侧是使用享元模式进行了最近修改。游戏现在可以处理一堆CharacterUnit对象,而每个对象都将存储对几个UnitData对象的引用。这样,我们节省了大量内存。我们将每个单位独有的值存储在CharacterUnit对象中。这些值随时间变化。尺寸和精灵是恒定的,所以我们可以保留一个具有这些值的单个对象。这些不可变数据称为内在状态,而对象的可变部分(CharacterUnit)称为外在状态

我们有意将数据成员移动到CharacterUnit,从而将其从接口重新设计为抽象类。正如我们在第三章中讨论的那样,抽象类几乎与可能包含实现的接口相同。move()方法是所有类型单位的默认实现的一个例子。这样,派生类只提供必要的行为,因为所有单位共享生命点和力量等共同属性。

在优化内存使用之后,我们应该处理复制对象的问题。游戏涉及大量创建新对象。每个建筑物都会产生一个特定的角色单位;角色单位建造建筑物,游戏世界本身渲染装饰元素(树木、岩石等)。现在,让我们尝试通过整合克隆功能来改进CharacterUnit。在本章的早些时候,我们有意删除了复制构造函数和赋值运算符。现在,是时候提供一个从现有对象创建新对象的机制了。

原型模式

这种模式让我们能够独立于它们的类型创建对象的副本。以下代码代表了CharacterUnit类的最终版本,关于我们最近的修改。我们还将添加新的clone()成员函数,以便整合原型模式:

class CharacterUnit
{
public:
  CharacterUnit() {}
  CharacterUnit& operator=(const CharacterUnit&) = delete;
  virtual ~Character() {}

 virtual CharacterUnit* clone() = 0;

public:
  void move(const Point& to) {
    // the graphics-specific implementation
  }
  virtual void attack(const CharacterUnit&) = 0;
  virtual void destroy() = 0;

  int get_power() const { return power_; }
  int get_life_points() const { return life_points_; }

private:
  CharacterUnit(const CharacterUnit& other) {
    life_points_ = other.life_points_;
    power_ = other.power_;
  }

private:
  int life_points_;
  int power_;
};

我们删除了赋值运算符,并将复制构造函数移到了私有部分。派生类重写了clone()成员函数,如下所示:

class Reader : public CharacterUnit
{
public:
 Reader* clone() override {
 return new Reader(*this);
 }

 // code omitted for brevity
};

原型模式将克隆委托给对象。通用接口允许我们将客户端代码与对象的类解耦。现在,我们可以克隆一个角色单位,而不知道它是Reader还是Soldier。看下面的例子:

// The unit can have any of the CharacterUnit derived types
CharacterUnit* new_unit = unit->clone();

动态转换在我们需要将对象转换为特定类型时非常有效。

在本节中,我们讨论了许多有用的设计模式。如果您对这些模式还不熟悉,可能会感到有些不知所措;然而,正确使用它们可以让我们设计出灵活和易维护的项目。让我们最终回到之前介绍的游戏循环。

设计游戏循环

策略游戏拥有最频繁变化的游戏玩法之一。在任何时间点,许多动作会同时发生。读者完成他们的建筑;兵营生产士兵;士兵受到敌人的攻击;玩家命令单位移动、建造、攻击或逃跑;等等。游戏循环处理所有这些。通常,游戏引擎提供了一个设计良好的游戏循环。

当我们玩游戏时,游戏循环运行。正如我们已经提到的,循环处理玩家的动作,更新游戏状态,并渲染游戏(使状态变化对玩家可见)。它在每次迭代中都这样做。循环还应该控制游戏的速率,即其 FPS。游戏循环的一次迭代的常见术语是帧,这就是为什么我们强调 FPS 作为游戏速度的原因。例如,如果你设计一个以 60FPS 运行的游戏,这意味着每帧大约需要 16 毫秒。

在本章早些时候用于简单游戏循环的以下代码:

while (true)
{
  processUserActions();
  updateGame();
}

如果没有长时间的用户操作需要处理,上述代码将运行得很快。在快速的机器上运行得更快。你的目标是坚持每帧 16 毫秒。这可能需要我们在处理操作和更新游戏状态后稍微等待一下,就像下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

每次更新都会按固定的数量推进游戏时间,这需要固定的现实时间来处理。另一方面,如果处理时间超过了帧的指定毫秒数,游戏就会变慢。

游戏中发生的一切大部分都在游戏的更新部分中涵盖,就像前面的图表所示。大多数情况下,更新可能需要同时执行多个操作。此外,正如我们之前提到的,我们必须为游戏中发生的一些操作保持计时器。这主要取决于我们想要使游戏变得多么详细。例如,建造一个建筑物可能被表示为两种状态:初始和最终。

在图形设计方面,这两种状态应该代表两种不同的图像。第一张图像包含建筑的一些基本部分,可能包括周围的一些岩石,就像它刚准备开始施工一样。下一张图像代表最终建成的建筑。当一个角色单位刚开始建造建筑时,我们向玩家展示第一张图像(基础部分和周围的一些岩石)。当建筑完成时,我们用包含最终建筑的图像替换第一张图像。为了使过程更加自然(更接近现实世界),我们人为地延长了时间。这意味着我们在两个图像状态之间保持一个持续 30 秒或更长的计时器。

我们描述了最简单的情况,细节最少。如果我们需要使游戏更加详细,例如在建筑物施工过程中渲染每一个变化,我们应该在很多图像之间保持很多计时器,每个图像代表施工的每一步。再次看一下前面的图表。更新游戏后,我们等待N毫秒。等待更多毫秒会使游戏的流程更接近现实生活。如果更新花费的时间太长,导致玩家体验滞后怎么办?在这种情况下,我们需要优化游戏,使其适应最优用户体验的时间框架。现在,假设更新游戏需要执行数百个操作;玩家已经建立了一个繁荣的帝国;现在正在建造大量建筑,并用许多士兵攻击敌人。

每个角色单位的每个动作,比如从一个点移动到另一个点,攻击一个敌人单位,建造一个建筑等,都会及时显示在屏幕上。现在,如果我们一次在屏幕上渲染数百个单位的状态会怎样?这就是我们使用多线程方法的地方。每个动作都涉及独立修改对象的状态(对象可以是游戏中的任何一个单位,包括静态建筑)。

总结

设计游戏是一项复杂的任务。我们可以将游戏开发视为一个独立的编程领域。游戏有不同的类型,其中之一是策略游戏。策略游戏设计涉及设计单位和建筑等游戏组件。通常,策略游戏涉及收集资源、建立帝国和与敌人战斗。游戏过程涉及游戏组件之间的动态交流,比如角色单位建造建筑和收集资源,士兵保卫土地免受敌人侵袭等。

为了正确设计策略游戏,我们需要结合面向对象设计技能和设计模式。设计模式在设计整个游戏以及其组件之间的交互方面起着重要作用。在本章中,我们讨论了命令模式,它将动作封装在对象下;观察者模式,用于订阅对象事件;以及中介者模式,用于将观察者提升到组件之间复杂交互的水平。

游戏最重要的部分是其循环。游戏循环控制渲染、游戏状态的及时更新以及其他子系统。设计它涉及使用事件队列和定时器。现代游戏使用网络,允许多个玩家通过互联网一起玩游戏。

在下一章中,我们将介绍 C++中的网络编程,这样你就会拥有将网络编程融入游戏中所需的技能。

问题

  1. 重写私有虚函数的目的是什么?

  2. 描述命令设计模式。

  3. 飞行权重模式如何节省内存使用?

  4. 观察者模式和中介者模式有什么区别?

  5. 为什么我们将游戏循环设计为无限循环?

进一步阅读

第十二章:网络和安全

网络编程变得越来越受欢迎。大多数计算机都连接到互联网,越来越多的应用程序现在依赖于它。从可能需要互联网连接的简单程序更新到依赖稳定互联网连接的应用程序,网络编程已经成为应用程序开发的必要部分。

直到最近的标准更新,C++语言才开始支持网络。网络支持已经推迟到了后续的标准,很可能要等到 C++23。然而,我们可以通过处理网络应用程序来为发布做好准备。我们还将讨论网络的标准扩展,并看看语言中支持网络会是什么样子。本章将集中讨论网络的主要原则和驱动设备之间通信的协议。设计网络应用程序是作为程序员技能的重要补充。

开发人员经常面临的一个主要问题是应用程序的安全性。无论是与正在处理的输入数据相关还是使用经过验证的模式和实践进行编码,应用程序的安全性必须是首要任务。对于网络应用程序来说尤为重要。在本章中,我们还将深入探讨 C++中安全编程的技术和最佳实践。

本章将涵盖以下主题:

  • 计算机网络简介

  • C++中的套接字和套接字编程

  • 设计网络应用程序

  • 了解 C++程序中的安全问题

  • 利用安全编程技术进行项目开发

技术要求

在本章的示例中,将使用 g++编译器以-std=c++2a选项进行编译。

您可以在github.com/PacktPublishing/Expert-CPP找到本章的源文件。

在 C++中发现网络编程

两台计算机通过网络进行交互。计算机使用特殊的硬件组件称为网络适配器网络接口控制器连接到互联网。安装在计算机上的操作系统提供驱动程序以与网络适配器一起工作;也就是说,为了支持网络通信,计算机必须安装有支持网络堆栈的操作系统。通过堆栈,我们指的是数据在从一台计算机传输到另一台计算机时经历的一系列修改层。例如,在浏览器上打开网站会呈现通过网络收集的数据。该数据以一系列零和一接收,然后转换为对 Web 浏览器更易理解的形式。分层在网络中是至关重要的。如今的网络通信由符合我们将在此讨论的 OSI 模型的几个层组成。网络接口控制器是支持开放系统互连OSI)模型的物理和数据链路层的硬件组件。

OSI 模型旨在标准化各种设备之间的通信功能。设备在结构和组织上有所不同。这涉及硬件和软件。例如,使用英特尔 CPU 运行 Android OS 的智能手机与运行 macOS Catalina 的 MacBook 电脑是不同的。不同之处不在于上述产品背后的名称和公司,而在于硬件和软件的结构和组织。为了消除网络通信中的差异,OSI 模型提出了一套标准化的协议和互联功能。我们之前提到的层如下:

  • 应用层

  • 表示层

  • 会话层

  • 传输层

  • 网络层

  • 数据链路层

  • 物理层

更简化的模型包括以下四个层:

  • 应用程序:处理特定应用程序的详细信息。

  • 传输:这提供了两个主机之间的数据传输。

  • 网络:这处理网络中数据包的传输。

  • 链路:这包括操作系统中的设备驱动程序,以及计算机内的网络适配器。

链路(或数据链路)层包括操作系统中的设备驱动程序,以及计算机中的网络适配器。

为了理解这些层,让我们假设您正在使用桌面应用程序进行消息传递,比如SkypeTelegram。当您输入一条消息并点击发送按钮时,消息会通过网络传输到其目的地。在这种情况下,假设您正在向安装了相同应用程序的朋友发送文本消息。从高层次的角度来看,这可能看起来很简单,但这个过程是复杂的,即使是最简单的消息在到达目的地之前也经历了许多转换。首先,当您点击发送按钮时,文本消息会被转换为二进制形式。网络适配器使用二进制。它的基本功能是通过介质发送和接收二进制数据。除了实际发送到网络上的数据之外,网络适配器还应该知道数据的目的地地址。目的地地址是附加到用户数据的许多属性之一。通过用户数据,我们指的是您输入并发送给朋友的文本。目的地地址是您朋友计算机的唯一地址。输入的文本与目的地地址和其他必要信息一起打包,以便发送到目标位置。您朋友的计算机(包括网络适配器、操作系统和消息应用程序)接收并解包数据。然后消息应用程序会在屏幕上显示该数据包中的文本。

几乎在本章开头提到的每个 OSI 层都会向通过网络发送的数据添加其特定的标头。以下图表描述了应用层数据在移动到目的地之前如何叠加标头:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

OSI 模型

看一下前面图表中的第一行(应用层)。数据是您在消息应用程序中输入的文本,以便将其发送给您的朋友。在每一层,一直到物理层,数据都会被打包,并附加有 OSI 模型每一层特定的标头。另一边的计算机接收并检索打包的数据。在每一层,它会移除该层特定的标头,并将其余的数据包移动到下一层。最终,数据到达您朋友的消息应用程序。

作为程序员,我们主要关注编写能够在网络上发送和接收数据的应用程序,而不深入了解各层的细节。然而,我们需要对如何在更高层次上使用标头增强数据有一定的了解。让我们学习一下网络应用程序在实践中是如何工作的。

网络应用程序的内部工作

安装在设备上的网络应用程序通过网络与其他设备上安装的应用程序进行通信。在本章中,我们将讨论通过互联网一起工作的应用程序。可以在以下图表中看到这种通信的高层概述:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在通信的最低层是物理层,它通过介质传输数据位。在这种情况下,介质是网络电缆(也考虑 Wi-Fi 通信)。用户应用程序抽象了网络通信的较低层。程序员所需的一切都由操作系统提供。操作系统实现了网络通信的低级细节,比如传输控制协议/互联网协议TCP/IP)套件。

每当应用程序需要访问网络,无论是局域网还是互联网,它都会请求操作系统提供一个访问点。操作系统通过利用网络适配器和特定软件与硬件通信来管理提供网络的网关。

这更详细的说明如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

操作系统提供了一个用于处理其网络子系统的 API。程序员应该关心的主要抽象是套接字。我们可以将套接字视为通过网络适配器发送其内容的文件。套接字是连接两台计算机的访问点,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从程序员的角度来看,套接字是一个允许我们在应用程序中通过网络实现数据传输的结构。套接字是一个连接点,可以发送或接收数据;也就是说,应用程序也可以通过套接字接收数据。操作系统在请求时为应用程序提供套接字。一个应用程序可以拥有多个套接字。客户端应用程序在客户端-服务器架构中通常使用单个套接字。现在,让我们详细了解套接字编程。

使用套接字编程网络应用

正如我们之前提到的,套接字是对网络通信的抽象。我们将它们视为常规文件 - 所有写入套接字的内容都由操作系统通过网络发送到目的地。通过网络接收到的所有内容都会被操作系统写入套接字。这样,操作系统为网络应用程序提供了双向通信。

假设我们运行两个不同的与网络相关的应用程序。例如,我们打开一个网页浏览器来浏览网页,并使用一个消息应用(如 Skype)与朋友聊天。网页浏览器代表了客户端-服务器网络架构中的客户端应用程序。在这种情况下,服务器是响应所请求数据的计算机。例如,我们在网页浏览器的地址栏中输入一个地址,然后在屏幕上看到生成的网页。每当我们访问一个网站时,网页浏览器都会从操作系统请求一个套接字。在编码方面,网页浏览器使用操作系统提供的 API 创建一个套接字。我们可以用更具体的前缀来描述套接字:客户端套接字。为了让服务器处理客户端请求,运行 Web 服务器的计算机必须监听传入的连接;也就是说,服务器应用程序创建一个用于监听连接的服务器套接字。

每当客户端和服务器之间建立连接时,数据通信就可以进行。下图描述了网页浏览器对facebook.com的请求:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

请注意前图中的数字组。这被称为Internet ProtocolIP地址。IP 地址是我们需要的位置,以便将数据传输到设备。有数十亿台设备连接到互联网。为了对它们进行唯一区分,每个设备都会暴露一个代表其地址的唯一数字值。使用 IP 协议建立连接,这就是为什么我们称其为 IP 地址。IP 地址由四组 1 字节长度的数字组成。它的点分十进制表示形式为 X.X.X.X,其中 X 是 1 字节数字。每个位置的值范围从 0 到 255。更具体地说,这是一个版本 4 的 IP 地址。现代系统使用版本 6 地址,这是数字和字母的组合,提供了更广泛的可用地址值范围。

创建套接字时,我们将本地计算机的 IP 地址分配给它;也就是说,我们将套接字绑定到该地址。当使用套接字向网络中的另一设备发送数据时,我们应该设置其目标地址。目标地址由该设备上的另一个套接字持有。为了在两个设备之间创建连接,我们使用两个套接字。可能会出现一个合理的问题——如果设备上运行了多个应用程序怎么办?如果我们运行了多个应用程序,每个应用程序都为自己创建了一个套接字怎么办?哪一个应该接收传入的数据?

要回答这些问题,请仔细查看前面的图表。您应该在 IP 地址末尾的冒号后看到一个数字。这被称为端口号。端口号是一个 2 字节长度的数字,由操作系统分配给套接字。由于 2 字节长度限制,操作系统无法为套接字分配超过 65,536 个唯一的端口号;也就是说,您不能有超过 65,536 个同时运行的进程或线程通过网络进行通信(但是有方法可以重用套接字)。除此之外,还有一些端口号专门为特定应用程序保留。这些端口称为众所周知的端口,范围从 0 到 1023。它们保留用于特权服务。例如,HTTP 服务器的端口号是 80。这并不意味着它不能使用其他端口。

让我们学习如何在 C++中创建套接字。我们将设计一个封装便携操作系统接口POSIX)套接字的包装类,也称为伯克利BSD套接字。它具有用于套接字编程的标准函数集。网络编程的 C++扩展将是语言的巨大补充。工作草案包含有关网络接口的信息。我们将在本章后面讨论这一点。在那之前,让我们尝试为现有和低级库创建我们自己的网络包装器。当我们使用 POSIX 套接字时,我们依赖于操作系统的 API。操作系统提供了一个 API,表示用于创建套接字、发送和接收数据等的函数和对象。

POSIX 将套接字表示为文件描述符。我们几乎可以像处理常规文件一样使用它。文件描述符遵循 UNIX 哲学,提供了一个通用的数据输入/输出接口。以下代码使用socket()函数(在<sys/socket.h>头文件中定义)创建套接字:

int s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

socket()函数的声明如下:

int socket(int domain, int type, int protocol);

因此,AF_INETSOCK_STREAMIPPROTO_TCP都是数值。域参数指定套接字的协议族。我们使用AF_INET来指定 IPv4 协议。对于 IPv6,我们使用AF_INET6。第二个参数指定套接字的类型,即它是面向流的还是数据报的套接字。对于每种特定类型,最后一个参数应相应地指定。在前面的示例中,我们使用IPPROTO_TCP指定了SOCK_STREAM传输控制协议TCP)代表可靠的面向流的协议。这就是为什么我们将类型参数设置为SOCK_STREAM的原因。在实现简单的套接字应用程序之前,让我们更多地了解网络协议。

网络协议

网络协议是一组规则和数据格式,用于定义应用程序之间的互联。例如,Web 浏览器和 Web 服务器通过超文本传输协议HTTP)进行通信。HTTP 更像是一组规则,而不是传输协议。传输协议是每个网络通信的基础。传输协议的一个例子是 TCP。当我们提到 TCP/IP 套件时,我们指的是 TCP 在 IP 上的实现。我们可以将互联网协议IP)视为互联网通信的核心。

它提供主机到主机的路由和寻址。我们通过互联网发送或接收的所有内容都被打包成IP 数据包。以下是 IPv4 数据包的外观:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

IP 头部重量为 20 字节。它结合了从源地址到目的地址传递数据包所需的标志和选项。在 IP 协议领域,我们通常称数据包为数据报。每个层都有其特定的数据包术语。更加细心的专家会谈论将 TCP 段封装到 IP 数据报中。将它们称为数据包是完全可以的*.*

每个更高级别的协议都会向通过网络发送和接收的数据附加元信息;例如,TCP 数据封装在 IP 数据报中。除了这些元信息,协议还定义了应该执行的底层规则和操作,以完成两个或多个设备之间的数据传输。

您可以在称为请求评论RFCs)的特定文档中找到更详细的信息。例如,RFC 791 描述了互联网协议,而 RFC 793 描述了传输控制协议。

许多流行的应用程序 - 文件传输、电子邮件、网络等 - 使用 TCP 作为它们的主要传输协议。例如,HTTP 协议定义了从客户端到服务器和反之亦然传输的消息格式。实际的传输是使用传输协议进行的 - 在这种情况下是 TCP。但是,HTTP 标准并不限制 TCP 成为唯一的传输协议。

下图说明了在将数据传递到较低级别之前,TCP 头被附加到数据中:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

注意源端口号和目标端口号。这些是在操作系统中区分运行进程的唯一标识符。还要看一下序列号和确认号。它们是 TCP 特有的,用于传输可靠性。

在实践中,TCP 由于以下特点而被使用:

  • 丢失数据的重传

  • 按顺序传递

  • 数据完整性

  • 拥塞控制和避免

IP(即互联网协议)是不可靠的。它不关心丢失的数据包,这就是为什么 TCP 处理丢失数据包的重传。它使用唯一标识符标记每个数据包,应该由传输的另一端确认。如果发送方没有收到数据包的确认码ACK),协议将重新发送数据包(有限次数)。正确接收数据包也非常重要。TCP 重新排序接收到的数据包以正确表示排序信息。这就是为什么在线听音乐时,我们不会在歌曲的开头听到结尾。

数据包的重传可能会导致另一个问题,即网络拥塞。当节点无法快速发送数据包时,就会发生这种情况。数据包会被卡住一段时间,不必要的重传会增加它们的数量。TCP 的各种实现采用了拥塞避免算法。

它维护一个拥塞窗口 - 一个确定可以发送的数据量的因素。使用慢启动机制,TCP 在初始化连接后缓慢增加拥塞窗口。尽管该协议在相应的请求评论RFC)中有描述,但在操作系统中实现的机制有很多不同。

在另一边是用户数据报协议UDP)。这两者之间的主要区别是 TCP 是可靠的。这意味着在丢失网络数据包的情况下,它会重新发送相同的数据包,直到它到达指定的目的地。由于其可靠性,通过 TCP 进行的数据传输被认为比使用 UDP 需要更长的时间。UDP 不能保证我们可以正确地传递数据包而且没有丢失。相反,开发人员应该负责重新发送、检查和验证数据传输。需要快速通信的应用程序倾向于依赖 UDP。例如,视频通话应用程序或在线游戏使用 UDP 因为它的速度。即使在传输过程中丢失了几个数据包,也不会影响用户体验。在玩游戏或进行视频聊天时,最好出现小故障,而不是等待下一帧游戏或视频。

TCP 比 UDP 慢的主要原因之一是 TCP 连接初始化过程中步骤较多。下图显示了 TCP 连接建立的过程,也称为三次握手:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

客户端在向服务器发送SYN数据包时选择一个随机数。服务器将该随机数加一,选择另一个随机数,并回复一个SYN-ACK数据包。客户端将从服务器接收的两个数字都加一,并通过向服务器发送最后一个ACK完成握手。成功完成三次握手后,客户端和服务器可以相互传输数据包。这种连接建立过程适用于每个 TCP 连接。握手的细节对网络应用程序的开发者是隐藏的。我们创建套接字并开始监听传入的连接。

注意两种端点之间的区别。其中之一是客户端。在实现网络应用程序时,我们应该明确区分客户端和服务器,因为它们有不同的实现。这也与套接字的类型有关。创建服务器套接字时,我们使其监听传入的连接,而客户端不监听 - 它发出请求。下图描述了客户端和服务器的某些函数及其调用顺序:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在代码中创建套接字时,我们指定协议和套接字的类型。当我们需要两个端点之间的可靠连接时,我们选择 TCP。有趣的是,我们可以使用 TCP 等传输协议来构建自己的协议。假设我们定义了一种特殊的文档格式来发送和接收以使通信有效。例如,每个文档应该以单词 PACKT 开头。HTTP 也是这样工作的。它使用 TCP 进行传输,并定义了其上的通信格式。在 UDP 的情况下,我们还应该为通信设计和实现可靠性策略。前面的图表显示了 TCP 如何在两个端点之间建立连接。客户端向服务器发送SYN请求。服务器用SYN-ACK响应回答,让客户端知道可以继续握手。最后,客户端向服务器发送ACK,表示连接已正式建立。他们可以随意进行通信。

同步SYN)和确认(ACK)是协议定义的术语,在网络编程中变得常见。

UDP 不是这样工作的。它将数据发送到目的地,而不必担心是否建立了连接。如果您使用 UDP 但需要一些可靠性,您应该自己来实现;例如,通过检查一部分数据是否到达了目的地。为了检查它,您可以等待目的地用自定义定义的ACK数据包进行回复。大多数可靠性导向的实现可能会重复已经存在的协议,如 TCP。然而,有许多情况下您不需要它们;例如,您不需要拥塞避免,因为您不需要发送相同的数据包两次。

在上一章中,我们设计了一个策略游戏。假设游戏是在线的,你正在与一个真正的对手而不是一个自动化的敌对玩家进行游戏。游戏的每一帧都是基于通过网络接收的数据进行渲染的。如果我们在使数据传输可靠、增加数据完整性以及确保没有任何数据包丢失方面付出了一些努力,可能会因为玩家的不同步而影响用户体验。这种情况适合使用 UDP。我们可以实现数据传输而不需要重传策略,以便提高游戏的速度。当然,使用 UDP 并不强迫我们避免可靠性。在同样的情况下,我们可能需要确保数据包被玩家成功接收。例如,当玩家投降时,我们应该确保对手收到消息。因此,我们可以根据数据包的优先级进行有条件的可靠性。UDP 在网络应用程序中提供了灵活性和速度。

让我们来看一个 TCP 服务器应用程序的实现。

设计网络应用程序

使用一个需要网络连接的小子系统来设计应用程序的方法与完全与网络相关的应用程序不同。后者的一个例子可能是用于文件存储和同步的客户端-服务器应用程序(如 Dropbox)。它由服务器和客户端组成,其中客户端安装为桌面或移动应用程序,也可以用作文件资源管理器。由 Dropbox 控制的系统中文件的每次更新都将立即与服务器同步。这样,您将始终在云中拥有您的文件,并可以在任何地方通过互联网连接访问它们。

我们将设计一个类似的简化的服务器应用程序,用于文件存储和操作。服务器的主要任务如下:

  • 从客户端应用程序接收文件

  • 在指定的位置存储文件

  • 根据请求向客户端发送文件

参考第十章,设计面向世界的应用程序,我们可以继续进行以下应用程序的顶层设计:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在上图中的每个矩形代表一个类或一组类,涉及特定的任务。例如,存储管理器处理与存储和检索文件相关的所有事务。在这一点上,它使用文件、位置、数据库等类并不那么关心。

客户端管理器是一个类或一组类,用于处理与客户端(指客户端应用程序)相关的所有事务,包括认证或授权客户端,与客户端保持稳定的连接,从客户端接收文件,向客户端发送文件等。

在本章中,我们特别强调了网络作为一个感兴趣的实体。所有与网络连接相关的事情,以及与客户端的数据传输,都是通过网络来处理的。现在,让我们看看我们可以使用什么功能来设计网络类(我们将称之为网络管理器以方便起见)。

使用 POSIX 套接字

正如我们之前提到的,诸如socket()bind()accept()之类的函数在大多数 Unix 系统中默认支持。之前,我们包含了<sys/socket.h>文件。除此之外,我们还需要几个其他头文件。让我们实现经典的 TCP 服务器示例,并将其封装在 Networking 模块中,用于我们的文件传输应用服务器。

正如我们之前提到的,服务器端开发在套接字的类型和行为方面与客户端开发不同。虽然两边都使用套接字,但服务器端套接字不断监听传入的连接,而客户端套接字则与服务器建立连接。为了使服务器套接字等待连接,我们创建一个套接字并将其绑定到服务器 IP 地址和客户端将尝试连接的端口号。以下 C 代码表示了 TCP 服务器套接字的创建和绑定:

int s = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(port);
server.sin_addr.s_addr = INADDR_ANY;

bind(s, (struct sockaddr*)&server, sizeof(server));

第一个调用创建了一个套接字。第三个参数设置为 0,这意味着将根据套接字的类型选择默认协议。类型作为第二个参数传递,SOCK_STREAM,这将使协议值默认等于IPPROTO_TCPbind()函数将套接字绑定到指定的 IP 地址和端口号。我们在sockaddr_in结构中指定了它们,该结构将网络地址相关的细节组合在一起。

虽然我们在前面的代码中跳过了这一点,但你应该考虑检查对socket()bind()函数(以及 POSIX 套接字中的其他函数)的调用是否出现错误。几乎所有这些函数在出现错误时都会返回-1

另外,注意htons()函数。它负责将其参数转换为网络字节顺序。问题隐藏在计算机设计的方式中。一些机器(例如 Intel 微处理器)使用小端字节顺序,而其他一些使用大端顺序。小端顺序将最不重要的字节放在最前面。大端顺序将最重要的字节放在最前面。以下图表显示了两者之间的区别:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

网络字节顺序是与特定机器架构无关的约定。htons()函数将提供的端口号从主机字节顺序(小端大端)转换为网络字节顺序(与机器无关)。

就是这样——套接字已经准备好了。现在,我们应该指定它准备好接收传入的连接。为了指定这一点,我们使用listen()函数:

listen(s, 5);

顾名思义,它用于监听传入的连接。传递给listen()函数的第二个参数指定了服务器在丢弃新的传入请求之前将排队的连接数。在前面的代码中,我们指定了5作为最大数。在高负载环境中,我们会增加这个数字。最大数由<sys/socket.h>头文件中定义的SOMAXCONN常量指定。

backlog 数(listen()函数的第二个参数)的选择基于以下因素:

  • 如果连接请求的速率在短时间内很高,那么 backlog 数应该有一个较大的值。

  • 服务器处理传入连接的持续时间。时间越短,backlog 值就越小。

当连接初始化发生时,我们可以选择放弃它或接受它并继续处理连接。这就是为什么我们在下面的代码段中使用accept()函数:

struct sockaddr_in client;
int addrlen;
int new_socket = accept(s, (struct sockaddr_in*)&client, &addrlen);
// use the new_socket

在前面的代码中需要考虑的两件事如下:

  • 首先,接受的套接字连接信息被写入客户端的sockaddr_in结构中。我们可以从该结构中收集关于客户端的所有必要信息。

  • 接下来,要注意accept()函数的返回值。它是一个新的套接字,用于处理来自特定客户端的请求。下一次调用accept()函数将返回另一个值,代表另一个具有独立连接的客户端。我们应该正确处理这一点,因为accept()调用是阻塞的;也就是说,它等待新的连接请求。我们将修改前面的代码,以便在单独的线程中处理多个连接。

在前面的代码中带有注释的最后一行说明new_socket可以用于接收或发送数据给客户端。让我们看看如何实现这一点,然后开始设计我们的Networking类。要读取套接字接收的数据,我们需要使用recv()函数,如下所示:

char buffer[BUFFER_MAX_SIZE]; // define BUFFER_MAX_SIZE based on the specifics of the server
recv(new_socket, buffer, sizeof(buffer), 0);
// now the buffer contains received data

recv()函数接受一个char*缓冲区来写入数据。它在sizeof(buffer)处停止写入。函数的最后一个参数是我们可以设置用于读取的附加标志。您应该考虑多次调用该函数以读取大于BUFFER_MAX_SIZE的数据。

最后,要通过套接字发送数据,我们调用send()函数,如下所示:

char msg[] = "From server with love";
send(new_socket, msg, sizeof(msg), 0);

通过这样,我们几乎涵盖了实现服务器应用程序所需的所有函数。现在,让我们将它们封装在一个 C++类中,并加入多线程,以便我们可以并发处理客户端请求。

实现一个 POSIX 套接字包装类

让我们设计和实现一个类,它将作为基于网络的应用程序的起点。该类的主要接口如下所示:

class Networking
{
public:
  void start_server();

public:
  std::shared_ptr<Networking> get_instance();
  void remove_instance();

private:
  Networking();
  ~Networking();

private:
  int socket_;
  sockaddr_in server_;
  std::vector<sockaddr_in> clients_;

private:
  static std::shared_ptr<Networking> instance_ = nullptr;
  static int MAX_QUEUED_CONNECTIONS = 1;
};

Networking类作为单例是很自然的,因为我们希望有一个单一的实例来监听传入的连接。同时,拥有多个对象,每个对象代表与客户端的单独连接,也是很重要的。让我们逐渐改进类的设计。之前,我们看到在服务器套接字监听并接受连接请求之后,将创建每个新的客户端套接字。

在那之后,我们可以通过新的客户端套接字发送或接收数据。服务器的操作方式与下图中所示的类似:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

也就是说,在接受每个传入的连接之后,我们将有一个单独的套接字用于连接。我们将它们存储在Networking类的clients_向量中。因此,我们可以在一个函数中编写创建服务器套接字、监听和接受新连接的主要逻辑,如果需要的话,可以并发工作。start_server()函数作为服务器监听传入连接的起点。以下代码块说明了这一点:

void Networking::start_server()
{
  socket_ = socket(AF_INET, SOCK_STREAM, 0);
  // the following check is the only one in this code snippet
  // we skipped checking results of other functions for brevity, 
  // you shouldn't omit them in your code
  if (socket_ < 0) { 
    throw std::exception("Cannot create a socket");
  }

  struct sockaddr_in server;
  server.sin_family = AF_INET;
  server.sin_port = htons(port);
  server.sin_addr.s_addr = INADDR_ANY;

  bind(s, (struct sockaddr*)&server, sizeof(server));
  listen(s, MAX_QUEUED_CONNECTIONS);
 // the accept() should be here
}

现在,我们停在了应该接受传入连接的地方(请参阅前面的代码片段中的注释)。我们在这里有两种选择(实际上,不止两种选择,但我们只讨论其中的两种)。我们可以直接将accept()调用放入start_server()函数中,或者我们可以实现一个单独的函数,Networking类用户在适当时将调用它。

为项目中的每个错误情况拥有特定的异常类并不是一个坏的做法。在考虑自定义异常时,前面的代码可能会被重写。您可以将其作为一个作业项目来完成。

其中一个选择在start_server()函数中有accept()函数,它将每个新连接推送到clients_向量中,如下所示:

void Networking::start_server()
{
  // code omitted for brevity (see in the previous snippet)
  while (true) {
    sockaddr_in client;
    int addrlen;
    int new_socket = accept(socket_, (sockaddr_in*)&client, &addrlen);
    clients_.push_back(client);
  }
}

是的,我们使用了一个无限循环。这听起来可能很糟糕,但只要服务器在运行,它就必须接受新的连接。然而,我们都知道无限循环会阻塞代码的执行;也就是说,它永远不会离开start_server()函数。我们将我们的网络应用程序介绍为一个至少有三个组件的项目:客户端管理器、存储管理器,以及我们正在设计的Networking类。

一个组件的执行不应以不好的方式影响其他组件;也就是说,我们可以使用线程使一些组件在后台运行。在线程的上下文中运行的start_server()函数是一个不错的解决方案,尽管我们现在应该关心我们在第八章中讨论的同步问题,即并发和多线程。

还要注意前面循环的不完整性。在接受连接后,它将客户端数据推送到clients_向量中。我们应该考虑使用另一个结构,因为我们还需要存储套接字描述符,以及客户端。我们可以使用std::undordered_map将套接字描述符映射到客户端连接信息,但简单的std::pairstd::tuple也可以。

然而,让我们更进一步,创建一个表示客户端连接的自定义对象,如下所示:

class Client
{
public:
  // public accessors

private:
  int socket_;
  sockaddr_in connection_info_;
};

我们将修改Networking类,使其存储Client对象的向量:

std::vector<Client> clients_;

现在,我们可以改变设计方法,使Client对象负责发送和接收数据:

class Client
{
public:
  void send(const std::string& data) {
    // wraps the call to POSIX send() 
  }
  std::string receive() {
    // wraps the call to POSIX recv()
  }

  // code omitted for brevity 
};

更好的是,我们可以将std::thread对象附加到Client类,这样每个对象都可以在单独的线程中处理数据传输。然而,你应该小心不要使系统陷入饥饿状态。传入连接的数量可能会急剧增加,服务器应用程序将会变得卡住。在下一节中,当我们讨论安全问题时,我们将讨论这种情况。建议您利用线程池,这将帮助我们重用线程并控制程序中运行的线程数量。

类的最终设计取决于我们接收和发送给客户端的数据类型。至少有两种不同的方法。其中一种是连接到客户端,接收必要的数据,然后关闭连接。第二种方法是实现客户端和服务器之间通信的协议。虽然听起来复杂,但协议可能很简单。

这也是可扩展的,使应用程序更加健壮,因为您可以在项目发展过程中支持更多功能。在下一节中,当我们讨论如何保护网络服务器应用程序时,我们将回到设计用于验证客户端请求的协议。

保护 C++代码

与许多其他语言相比,C++在安全编码方面稍微难以掌握。有许多指南提供了关于如何避免 C++程序中的安全风险的建议。我们在第一章中讨论的最受欢迎的问题之一是使用预处理器宏。我们使用的例子有以下宏:

#define DOUBLE_IT(arg) (arg * arg)

不正确使用这个宏会导致难以发现的逻辑错误。在下面的代码中,程序员期望在屏幕上打印16

int res = DOUBLE_IT(3 + 1);
std::cout << res << std::endl;

输出是7。这里的问题在于arg参数周围缺少括号;也就是说,前面的宏应该重写如下:

#define DOUBLE_IT(arg) ((arg) * (arg))

尽管这个例子很受欢迎,我们强烈建议尽量避免使用宏。C++提供了许多可以在编译时处理的构造,比如constexprconstevalconstinit - 即使语句也有constexpr的替代方案。如果您需要在代码中进行编译时处理,请使用它们。当然,还有模块,这是语言中期待已久的补充。您应该更喜欢使用模块,而不是使用#include和无处不在的包含保护:

module my_module;
export int test;

// instead of

#ifndef MY_HEADER_H
#define MY_HEADER_H
int test
#endif 

这不仅更安全,而且更高效,因为模块只处理一次(我们可以将它们视为预编译头)。

虽然我们不希望您对安全问题变得偏执,但您几乎应该在任何地方小心。通过学习语言的怪癖和奇特之处,您将避免大部分这些问题。此外,一个好的做法是使用替换或修复以前版本的缺点的最新功能。例如,考虑以下create_array()函数:

// Don't return pointers or references to local variables
double* create_array()
{
  double arr[10] = {0.0};
  return arr;
}

create_array() 函数的调用者因为arr具有自动存储期而留下了指向不存在数组的指针。如果需要,我们可以用更好的替代方案来替换前面的代码:

#include <array>

std::array<double> create_array()
{
  std::array<double> arr;
  return arr;
}

字符串被视为字符数组,是许多缓冲区溢出问题的原因。其中最常见的问题之一是在忽略其大小的情况下向字符串缓冲区写入数据。在这方面,std::string类是 C 字符串的一个更安全的替代方案。然而,在支持旧代码时,您在使用strcpy()等函数时应该小心,就像以下示例中所示:

#include <cstdio>
#include <cstring>

int main()
{
  char small_buffer[4];
  const char* long_text = "This text is long enough to overflow small buffers!";
 strcpy(small_buffer, long_text);
}

鉴于法律上,small_buffer应该在末尾有一个空终结符,它只能处理long_text字符串的前三个字符。然而,在调用strcpy()后发生了以下情况:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在实现网络应用程序时,您应该更加小心。大部分来自客户端连接的数据应该得到适当处理,缓冲区溢出并不罕见。让我们学习如何使网络应用程序更加安全。

保护网络应用程序

在本书的前一节中,我们设计了一个使用套接字连接接收客户端数据的网络应用程序。除了大部分渗入系统的病毒来自外部世界这一事实之外,网络应用程序有这种自然倾向,即向互联网上的各种威胁打开计算机。首先,每当您运行一个网络应用程序时,系统中就存在一个开放的端口。知道您的应用程序正在监听的确切端口的人可以通过伪造协议数据侵入。我们将主要讨论网络应用程序的服务器端;然而,这里的一些主题也适用于客户端应用程序。

你应该做的第一件事之一是加入客户端授权和认证。这两个术语很容易混淆。小心不要将它们互换使用;它们是不同的:

  • 认证是验证客户端访问的过程。这意味着并非每个传入的连接请求都会立即得到服务。在与客户端传输数据之前,服务器应用程序必须确保客户端是已知的客户端。几乎与我们通过输入电子邮件和密码访问社交网络平台的方式相同,客户端的认证定义了客户端是否有权访问系统。

  • 授权,另一方面,定义了客户端在系统中可以做什么。这是一组权限,提供给特定的客户端。例如,我们在前一节讨论的客户端应用程序能够上传文件到系统中。迟早,您可能希望加入付费订阅,并为付费客户提供更广泛的功能;例如,允许他们创建文件夹来组织他们的文件。因此,当客户端请求创建文件夹时,我们可能希望授权请求以发现客户端是否有权这样做。

当客户端应用程序与服务器建立连接时,服务器获得的只是连接详细信息(IP 地址,端口号)。为了让服务器知道客户端应用程序背后的是谁(实际用户),客户端应用程序发送用户的凭据。通常,这个过程涉及向用户发送一个唯一标识符(如用户名或电子邮件地址)和密码以访问系统。然后,服务器会检查这些凭据与其数据库,并验证是否应该允许客户端访问。客户端和服务器之间的这种通信形式可能是简单的文本传输或格式化对象传输。

例如,服务器定义的协议可能要求客户端以以下形式发送JavaScript 对象表示JSON)文档:

{
  "email": "myemail@example.org",
  "password": "notSoSIMPLEp4s8"
}

服务器的响应允许客户端进一步进行,或者更新其用户界面以让用户知道操作的结果。在使用任何网络应用程序或网络应用程序时,您可能遇到了几种情况。例如,错误输入的密码可能导致服务器返回“无效的用户名或密码”错误。

除了这一必要的第一步之外,验证来自客户端应用程序的每一条数据都是明智的。如果检查电子邮件字段的大小,就可以很容易地避免缓冲区溢出。例如,当客户端应用程序故意试图破坏系统时,可能会发送一个 JSON 对象,其中的字段具有非常大的值。这个检查是服务器的责任。预防安全漏洞始于数据验证。

另一种安全攻击形式是从单个或多个客户端每秒发出过多的请求。例如,一个客户端应用程序在 1 秒内发出数百个身份验证请求,导致服务器密集处理这些请求,并浪费资源试图为它们提供服务。最好检查客户端请求的速率,例如,将其限制为每秒一个请求。

这些形式的攻击(有意或无意的)被称为拒绝服务DOS)攻击。DOS 攻击的更高级版本采取了从多个客户端向服务器发出大量请求的形式。这种形式被称为分布式 DOSDDOS)攻击。一个简单的方法可能是黑名单 IP 地址,这些 IP 地址试图通过每秒发出多个请求来使系统崩溃。作为网络应用程序的程序员,在开发应用程序时,您应该考虑本书范围之外的所有这些问题以及其他许多问题。

总结

在本章中,我们介绍了在 C++中设计网络应用程序。从其第一个版本开始,C++一直缺乏对网络的内置支持。C++23 标准计划最终在语言中引入对网络的支持。

我们首先介绍了网络的基础知识。完全理解网络需要很长时间,但在实现与网络有关的任何应用程序之前,每个程序员都必须了解一些基本概念。这些基本概念包括 OSI 模型中的分层和不同类型的传输协议,如 TCP 和 UDP。了解 TCP 和 UDP 之间的区别对于任何程序员都是必要的。正如我们所学到的,TCP 在套接字之间建立可靠的连接,而套接字是开发网络应用程序时程序员遇到的下一个东西。这些是两个应用程序实例的连接点。每当我们需要通过网络发送或接收数据时,我们应该定义一个套接字,并且几乎可以像处理常规文件一样处理它。

我们在应用程序开发中使用的所有抽象和概念都由操作系统处理,并最终由网络适配器处理。这是一种能够通过网络介质发送数据的设备。从介质接收数据并不能保证安全。网络适配器接收来自介质的任何东西。为了确保我们正确处理传入数据,我们还应该注意应用程序安全性。本章的最后一节是关于编写安全代码和验证输入,以确保程序不会受到伤害。保护程序是确保程序质量的良好步骤。开发程序的最佳方法之一是彻底测试它们。您可能还记得,在第十章中,设计面向世界的应用程序,我们讨论了软件开发步骤,并解释了一旦编码阶段完成,测试程序是最重要的步骤之一。测试后,您很可能会发现许多错误。其中一些错误很难重现和修复,这就是调试发挥作用的地方。

下一章是关于以正确的方式测试和调试您的程序。

问题

  1. 列出 OSI 模型的所有七层。

  2. 端口号的意义是什么?

  3. 为什么应该在网络应用程序中使用套接字?

  4. 描述在服务器端使用 TCP 套接字接收数据时应执行的操作顺序。

  5. TCP 和 UDP 之间有什么区别?

  6. 为什么不应该在代码中使用宏定义?

  7. 在实现服务器应用程序时,如何区分不同的客户端应用程序?

进一步阅读

第十三章:调试和测试

调试和测试在软件开发过程的流水线中扮演着极其重要的角色。测试帮助我们发现问题,而调试修复问题。然而,如果我们在实施阶段遵循一定的规则,就可以预防许多潜在的缺陷。此外,由于测试过程非常昂贵,如果我们能在需要人工测试之前使用某些工具自动分析软件,那将是非常好的。此外,关于软件何时、如何以及应该测试什么也是很重要的。

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

  • 了解问题的根本原因

  • 调试 C++程序

  • 了解静态和动态分析

  • 探索单元测试、TDD 和 BDD

在本章中,我们将学习如何分析软件缺陷,如何使用 GNU 调试器(GDB)工具来调试程序,以及如何使用工具自动分析软件。我们还将学习单元测试、测试驱动开发(TDD)和行为驱动开发(BDD)的概念,以及如何在软件工程开发过程中进行实践。

技术要求

本章的代码可以在本书的 GitHub 存储库中找到:github.com/PacktPublishing/Expert-CPP

了解问题的根本原因

在医学中,一个好的医生需要理解治疗症状和治愈疾病之间的区别。例如,给一个断臂的病人止痛药只会消除症状;手术可能是帮助骨骼逐渐愈合的正确方式。

根本原因分析(RCA)是一种系统性的过程,用于确定问题的根本原因。借助适当的工具,它试图使用一组特定的步骤来确定问题的根本原因的起源。通过这样做,我们可以确定以下内容:

  • 发生了什么?

  • 它是如何发生的?

  • 为什么会发生?

  • 应该采用什么适当的方法来防止或减少它,使其永远不再发生?

RCA 假设一个地方的行动会触发另一个地方的行动,依此类推。通过追溯行动链到开始,我们可以发现问题的根源以及它如何演变成我们所拥有的症状。啊哈!这正是我们应该遵循的修复或减少软件缺陷的过程。在接下来的小节中,我们将学习基本的 RCA 步骤,如何应用 RCA 过程来检测软件缺陷,以及 C++开发人员应该遵循哪些规则来防止软件中出现这样的缺陷。

RCA 概述

通常,RCA 过程包括以下五个步骤:

  1. 定义问题:在这个阶段,我们可能会找到以下问题的答案:发生了什么?问题的症状是什么?问题发生在什么环境或条件下?

  2. 收集数据:为了制作因果因素图,我们需要收集足够的数据。这一步可能既昂贵又耗时。

  3. 制作因果因素图:因果因素图提供了一个可视化结构,我们可以用它来组织和分析收集到的数据。因果因素图只是一个带有逻辑测试的序列图,解释了导致症状发生的事件。这个图表过程应该驱动数据收集过程,直到调查人员对图表的彻底性感到满意。

  4. 确定根本原因:通过检查因果因素图,我们可以制作一个决策图,称为根本原因图,以确定根本原因或原因。

  5. 推荐和实施解决方案:一旦确定了根本原因或多个原因,以下问题的答案可以帮助我们找到解决方案:我们可以采取什么措施防止问题再次发生?解决方案将如何实施?谁将负责?实施解决方案的成本或风险是什么?

RCA 树图是软件工程行业中最流行的因素图之一。以下是一个示例结构:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

假设我们有一个问题,它有ABC三种症状。症状A可能是由事件A1A2引起的,症状B可能是由事件B1B2B3B4引起的,而症状C是由事件C1C2引起的。在收集数据后,我们发现症状AC从未出现,而我们只有症状B。进一步的分析显示,在问题发生时,事件B1B2并未涉及,因此我们可以确定这个问题的根本原因是由于事件B3B4的发生。

如果软件存在缺陷,我们应该对其应用 RCA,并调查问题的原始根本原因。然后,问题的根本原因可以追溯到需求、设计、实施、验证和/或测试规划和输入数据。当找到并修复了根本原因时,软件的质量可以得到改善,因此维护费用将大大降低。

我们刚刚学会了如何找到问题的根本原因,但请记住,“最好的防御是进攻”。因此,我们可以预防问题的发生,而不是分析和修复问题。

预防胜于治疗——良好的编码行为

从成本的角度来看,IBM 的一项研究表明,假设需求和设计的总成本为 1X,那么实施和编码过程将需要 5X,单元和集成测试将需要约 10X,全面的客户测试成本将需要约 15X,而在产品发布后修复错误的成本将占约 30X!因此,最小化代码缺陷是降低生产成本的最有效方法之一。

尽管找到软件缺陷的根本原因的通用方法非常重要,但如果我们能在实施阶段预防一些缺陷,那将更好。为此,我们需要有良好的编码行为,这意味着必须遵循某些规则。这些规则可以分为低级和高级。低级规则可能包括以下内容:

  • 未初始化变量

  • 整数除法

  • 错误地使用=而不是==

  • 可能将有符号变量分配给无符号变量

  • switch语句中缺少break

  • 复合表达式或函数调用中的副作用

至于高级规则,我们有以下相关主题:

  • 接口

  • 资源管理

  • 内存管理

  • 并发

B. Stroustrup 和 H. Sutter 在他们的实时文档*C++ Core Guidelines (Release 0.8)*中建议遵循这些规则,其中强调了静态类型安全和资源安全。他们还强调了范围检查的可能性,以避免解引用空指针、悬空指针和异常的系统使用。如果开发人员遵循这些规则,它将使他/她的代码在静态类型上是安全的,没有任何资源泄漏。此外,它不仅可以捕获更多的编程逻辑错误,而且还可以运行得更快。

由于页面限制,本小节只会介绍一些示例。如果您想查看更多示例,请访问isocpp.github.io/CppCoreGuidelines

未初始化变量问题

未初始化的变量是程序员可能犯的最常见的错误之一。当我们声明一个变量时,将为其分配一定数量的连续内存。如果未初始化,它仍然具有一些值,但没有确定性地预测它的方法。因此,当我们执行程序时,会出现不可预测的行为:

//ch13_rca_uninit_variable.cpp
#include <iostream>
int main()
{
  int32_t x;
  // ... //do something else but not assign value to x
  if (x>0) {
    std::cout << "do A, x=" << x << std::endl;
  }
  else {
    std::cout << "do B, x=" << x << std::endl;
  }
  return 0;
}

在上面的代码中,当声明x时,操作系统将为其分配 4 个字节的未使用内存,这意味着x的值是驻留在该内存中的任何值。每次运行此程序时,x的地址和值可能都不同。此外,一些编译器(如 Visual Studio)将在调试版本中将x的值初始化为0,但在发布版本中将其保持未初始化。在这种情况下,调试版本和发布版本的输出完全不同。

复合表达式中的副作用

当运算符、表达式、语句或函数完成评估后,它可能会被延长或者可能会持续存在于其复合体内。这种持续存在会产生一些副作用,可能导致一些未定义的行为。让我们看一下以下代码来理解这一点:

//ch13_rca_compound.cpp
#include <iostream>
int f(int x, int y)
{
  return x*y;
}

int main()
{
  int x = 3;
  std::cout << f(++x, x) << std::endl; //bad,f(4,4) or f(4,3)?
}

由于操作数的评估顺序的未定义行为,上述代码的结果可能是 16 或 12。

混合有符号和无符号问题

通常,二进制运算符(+-*/%<<=>>===!=&&||!&|<<>>~^=+=-=*=/=,和%=)要求两个操作数具有相同的类型。如果两个操作数的类型不同,则一个将被提升为与另一个相同的类型。粗略地说,C 标准转换规则在子条款 6.3.1.1 [ISO/IEC 9899:2011]中给出。

  • 当我们混合相同等级的类型时,有符号的类型将被提升为无符号类型。

  • 当我们混合不同等级的类型时,如果较低等级的一方的所有值都可以由较高等级的一方表示,那么较低等级的一方将被提升为较高等级的类型。

  • 如果在上述情况下较低等级类型的所有值都不能由较高等级类型表示,则将使用较高等级类型的无符号版本。

现在,让我们来看一下传统的有符号整数减去无符号整数的问题:

//ch13_rca_mix_sign_unsigned.cpp
#include <iostream>
using namespace std;
int main()
{
 int32_t x = 10;
 uint32_t y = 20;
 uint32_t z = x - y; //z=(uint32_t)x - y
 cout << z << endl; //z=4294967286\. 
}

在上面的例子中,有符号的int将自动转换为无符号的int,结果将是uint32_t z = -10。另一方面,因为−10不能表示为无符号的int值,它的十六进制值0xFFFFFFF6将被解释为UINT_MAX - 9(即4294967286)在补码机器上。

评估顺序问题

以下示例涉及构造函数中类成员的初始化顺序。由于初始化顺序是类成员在类定义中出现的顺序,因此将每个成员的声明分开到不同的行是一个好的做法:

//ch13_rca_order_of_evaluation.cpp
#include <iostream>
using namespace std;

class A {
public:
  A(int x) : v2(v1), v1(x) {
  };
  void print() {
    cout << "v1=" << v1 << ",v2=" << v2 << endl;
  };
protected:
  //bad: the order of the class member is confusing, better
  //separate it into two lines for non-ambiguity order declare   
  int v1, v2; 
};

class B {
public:
  //good: since the initialization order is: v1 -> v2, 
  //after this we have: v1==x, v2==x.
  B(int x) : v1(x), v2(v1) {};

  //wrong: since the initialization order is: v1 -> v2, 
  //after this we have: v1==uninitialized, v2==x. 
  B(float x) : v2(x), v1(v2) {};
  void print() {
    cout << "v1=" << v1 << ", v2=" << v2 << endl;
  };

protected:
  int v1; //good, here the declaration order is clear
  int v2;
};

int main()
{
  A a(10);
  B b1(10), b2(3.0f);
  a.print();  //v1=10,v2=10,v3=10 for both debug and release
  b1.print(); //v1=10, v2=10 for both debug and release
  b2.print(); //v1=-858993460,v2=3 for debug; v1=0,v2=3 for release.
}

在类A中,尽管声明顺序是v1 -> v2,但将它们放在一行中会使其他开发人员感到困惑。在类B的第一个构造函数中,v1将被初始化为x,然后v2将被初始化为v1,因为其声明顺序是v1->v2。然而,在其第二个构造函数中,v1将首先被初始化为v2(此时,v2尚未初始化!),然后v2将被x初始化。这导致调试版本和发布版本中v1的不同输出值。

编译时检查与运行时检查

以下示例显示,运行时检查(整数类型变量云的位数)可以被编译时检查替换:

//check # of bits for int
//courtesy: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines
int nBits = 0; // don't: avoidable code
for (int i = 1; i; i <<= 1){
     ++nBits;
}
if (nBits < 32){
    cerr << "int too small\n";
}

由于int可以是 16 位或 32 位,这个例子取决于操作系统,无法实现其目标。我们应该使用int32_t或者用以下内容替换它:

 static_assert(sizeof(int) >= 4); //compile-time check

另一个例子涉及将 n 个整数的最大数量读入一维数组中:

void read_into(int* p, int n); // a function to read max n integers into *p
...
int v[10];
read_into(v, 100); //bad, off the end, but the compile cannot catch this error.

这可以使用span<int>来修复:

void read_into( span<int> buf); // read into a range of integers
...
int v[10];
read_into(v); //better, the compiler will figure out the number of elements

这里的一般规则是尽可能在编译时进行分析,而不要推迟到运行时。

避免内存泄漏

内存泄漏意味着分配的动态内存永远无法释放。在 C 中,我们使用malloc()和/或calloc()来分配内存,使用free()来释放它。在 C++中,使用new运算符和deletedelete[]运算符来动态管理内存。尽管智能指针和 RAII 的帮助可以减少内存泄漏的风险,但如果我们希望构建高质量的代码,仍然有一些规则需要遵循。

首先,最简单的内存管理方式是你的代码从未分配的内存。例如,每当你可以写T x;时,不要写T* x = new T();shared_ptr<T> x(new T());

接下来,不要使用自己的代码管理内存,如下所示:

void f_bad(){
 T* p = new T() ;
  ...                 //do something with p
 delete p ;           //leak if throw or return before reaching this line 
}

相反,尝试使用 RAII,如下所示:

void f_better()
{
 std::auto_ptr<T> p(new T()) ; //other smart pointers is ok also
 ...                           //do something with p
 //will not leak regardless whether this point is reached or not
}

然后,使用unique_ptr替换shared_ptr,除非你需要共享它的所有权,如下所示:

void f_bad()
{
 shared_ptr<Base> b = make_shared<Derived>();
 ...            
} //b will be destroyed at here

由于b在本地使用而没有复制它,它的refcount将始终为1。这意味着我们可以使用unique_ptr来替换它:

void f_better()
{
 unique_ptr<Base> b = make_unique<Derived>();
 ...            //use b locally
}               //b will be destroyed at here

最后,即使你真的需要自己动态管理内存,如果有std container库类可用,不要手动分配内存。

在本节中,我们学习了如何使用 RCA 定位问题以及如何通过编码最佳实践来预防问题。接下来,我们将学习如何使用调试器工具来控制程序的逐行执行,并在运行时检查变量和表达式的值。

调试 C++程序

调试是找到并解决程序问题或缺陷的过程。这可能包括交互式调试、数据/控制流分析以及单元和集成测试。在本节中,我们只关注交互式调试,这是逐行执行源代码并显示正在使用的变量的值及其相应内存地址的过程。

调试 C/C++程序的工具

根据你的开发环境,在 C++社区中有很多可用的工具。以下列表显示了不同平台上最受欢迎的工具。

  • Linux/Unix:

  • GDB:一个免费的开源命令行界面(CLI)调试器。

  • Eclipse:一个免费的开源集成开发环境(IDE)。它不仅支持调试,还支持编译、性能分析和智能编辑。

  • Valgrind:另一个开源的动态分析工具;它适用于调试内存泄漏和线程错误。

  • Affinic:一个商业的图形用户界面(GUI)工具,专为 GDB、LLDB 和 LLVM 调试器构建。

  • DDD:一个用于 GDB、DBX、JDB、XDB 和 Python 的开源数据显示调试器,它将数据结构显示为图形。

  • Emacs 模式下的 GDB:一个使用 GNU Emacs 查看和编辑源代码的开源 GUI 工具,用于与 GDB 一起调试。

  • KDevelop:一个用于 C/C++、Objective-等编程语言的免费开源 IDE 和调试器工具。

  • Nemiver:一个在 GNOME 桌面环境中运行良好的开源工具。

  • SlickEdit:一个用于调试多线程和多处理器代码的好工具。

  • Windows:

  • Visual Studio:一个商业工具,社区版本免费提供 GUI。

  • GDB:这也可以在 Windows 上运行,借助 Cygwin 或 MinGW 的帮助。

  • Eclipse:它的 C++开发工具(CDT)可以在 Windows 上使用 MinGW GCC 编译器的工具链进行安装。

  • macOS:

  • LLDB:这是 macOS 上 Xcode 的默认调试器,支持桌面和 iOS 设备及其模拟器上的 C/C++和 Objective-C。

  • GDB:这个 CLI 调试器也被用于 macOS 和 iOS 系统。

  • Eclipse:这个使用 GCC 的免费 IDE 适用于 macOS。

由于 GDB 可以在所有平台上运行,我们将在以下子节中向您展示如何使用 GDB。

GDB 概述

GDB 代表 GNU 调试器,允许开发人员在另一个程序执行时看到内部发生了什么,或者在另一个程序崩溃时它正在做什么。GDB 可以做以下四件事情:

  • 启动程序并指定可能影响其行为的任何内容。

  • 使程序在给定条件下停止。

  • 检查程序停止时发生了什么。

  • 在运行程序时更改变量的值。这意味着我们可以尝试纠正一个 bug 的影响和/或继续学习另一个 bug 的副作用。

请注意,涉及两个程序或可执行文件:一个是 GDB,另一个是要调试的程序。由于这两个程序可以在同一台机器上或不同的机器上运行,因此我们可能有三种调试类别,如下所示:

  • 本地调试:两个程序在同一台机器上运行。

  • 远程调试:GDB 在主机上运行,而调试的程序在远程机器上运行。

  • 模拟器调试:GDB 在主机上运行,而调试的程序在模拟器上运行。

根据撰写本书时的最新版本(GDB v8.3),GDB 支持的语言包括 C、C++、Objective-C、Ada、Assembly、D、Fortran、Go、OpenCL、Modula-2、Pascal 和 Rust。

由于 GDB 是调试行业中的一种先进工具,功能复杂且功能丰富,因此在本节中不可能学习所有其功能。相反,我们将通过示例来学习最有用的功能。

GDB 示例

在练习这些示例之前,我们需要通过运行以下代码来检查系统上是否已安装gdb

~wus1/chapter-13$ gdb --help 

如果显示以下类型的信息,我们将准备好开始:

This is the GNU debugger. Usage:
 gdb [options] [executable-file [core-file or process-id]]
 gdb [options] --args executable-file [inferior-arguments ...]

 Selection of debuggee and its files:
 --args Arguments after executable-file are passed to inferior
 --core=COREFILE Analyze the core dump COREFILE.
 --exec=EXECFILE Use EXECFILE as the executable.
 ...

否则,我们需要安装它。让我们看看如何在不同的操作系统上安装它:

  • 对于基于 Debian 的 Linux:
~wus1/chapter-13$ s*udo apt-get install build-essential* 
  • 对于基于 Redhat 的 Linux:
~wus1/chapter-13$***sudo yum install  build-essential***
  • 对于 macOS:
~wus1/chapter-13$***brew install gdb***

Windows 用户可以通过 MinGW 发行版安装 GDB。macOS 将需要 taskgated 配置。

然后,再次输入gdb --help来检查是否成功安装。

设置断点和检查变量值

在以下示例中,我们将学习如何设置断点,继续,步入或跳过函数,打印变量的值,以及如何在gdb中使用帮助。源代码如下:

//ch13_gdb_1.cpp
#include <iostream>
float multiple(float x, float y);
int main()
{
 float x = 10, y = 20;
 float z = multiple(x, y);
 printf("x=%f, y=%f, x*y = %f\n", x, y, z);
 return 0;
}

float multiple(float x, float y)
{
 float ret = x + y; //bug, should be: ret = x * y;
 return ret;
}

正如我们在第三章中提到的面向对象编程的细节,让我们以调试模式构建此程序,如下所示:

~wus1/chapter-13$ g++ -g ch13_gdb_1.cpp -o ch13_gdb_1.out

请注意,对于 g++,-g选项意味着调试信息将包含在输出的二进制文件中。如果我们运行此程序,它将显示以下输出:

x=10.000000, y=20.000000, x*y = 30.000000

现在,让我们使用gdb来查看 bug 在哪里。为此,我们需要执行以下命令行:

~wus1/chapter-13$ gdb ch13_gdb_1.out

通过这样做,我们将看到以下输出:

GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git
 Copyright (C) 2018 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 "aarch64-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) 

现在,让我们详细了解各种命令:

  • breakrun:如果我们输入b mainbreak main并按Enter,则会在主函数中插入一个breakpoint。然后,我们可以输入runr来开始调试程序。在终端窗口中将显示以下信息。在这里,我们可以看到我们的第一个breakpoint在源代码的第六行,调试程序已经暂停以等待新命令:
(gdb) b main
Breakpoint 1 at 0x8ac: file ch13_gdb_1.cpp, line 6.
(gdb) r
Starting program: /home/nvidia/wus1/Chapter-13/a.out
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1". 

Breakpoint 1, main () at ch13_gdb_1.cpp:6
6 float x = 10, y = 20;
  • nextprintquitnnext命令将转到代码的下一行。如果该行调用子例程,则不会进入子例程;相反,它会跳过调用并将其视为单个源行。如果我们想显示变量的值,我们可以使用pprint命令,后跟变量的名称。最后,如果我们想退出gdb,可以使用qquit命令。运行这些操作后,以下是终端窗口的输出:
(gdb) n
 7 float z = multiple(x, y);
 (gdb) p z
 $1 = 0
 (gdb) n
 8 printf("x=%f, y=%f, x*y = %f\n", x, y, z);
 (gdb) p z
 $2 = 30
 (gdb) q
 A debugging session is active.
 Inferior 1 [process 29187] will be killed.
 Quit anyway? (y or n) y
 ~/wus1/Chapter-13$
  • step:现在让我们学习如何进入multiple()函数并找到错误。为此,我们需要使用brn命令首先到达第 7 行。然后,我们可以使用sstep命令进入multiple()函数。接下来,我们使用n命令到达第 14 行,使用p打印ret变量的值,即 30。到目前为止,我们已经发现,通过使用ahha the bug is at line 14!:,而不是x*y,我们有一个拼写错误,即x+y。以下代码块是这些命令的相应输出:
~/wus1/Chapter-13$gdb ch13_gdb_1.out
 ...
 (gdb) b main
 Breakpoint 1 at 0x8ac: file ch13_gdb_1.cpp, line 6.
 (gdb) r
 The program being debugged has been started already.
 Start it from the beginning? (y or n) y
 Starting program: /home/nvidia/wus1/Chapter-13/a.out
 [Thread debugging using libthread_db enabled]
 Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".                                                                                Breakpoint 1, main () at ch13_gdb_1.cpp:6
 6 float x = 10, y = 20;
 (gdb) n
 7 float z = multiple(x, y);
 (gdb) s
 multiple (x=10, y=20) at ch13_gdb_1.cpp:14
 14 float s = x + y;
 (gdb) n
 15 return s;
 (gdb) p s
 $1 = 30
  • help:最后,让我们学习如何使用help命令来结束这个小例子。当启动gdb时,我们可以使用helph命令来获取特定命令的使用信息。例如,以下终端窗口总结了我们到目前为止学到的内容:
(gdb) h b      
 Set breakpoint at specified location.
 break [PROBE_MODIFIER] [LOCATION] [thread THREADNUM] [if CONDITION]
 PROBE_MODIFIER shall be present if the command is to be placed in a
 probe point. Accepted values are `-probe' (for a generic, automatically
 guessed probe type), `-probe-stap' (for a SystemTap probe) or
 `-probe-dtrace' (for a DTrace probe).
 LOCATION may be a linespec, address, or explicit location as described
 below.
  ....

 (gdb) h r
 Start debugged program.
 You may specify arguments to give it.
 Args may include "*", or "[...]"; they are expanded using the
 shell that will start the program (specified by the "$SHELL" environment
 variable). Input and output redirection with ">", "<", or ">>"
 are also allowed.

 (gdb) h s
 Step program until it reaches a different source line.
 Usage: step [N]
 Argument N means step N times (or till program stops for another reason).

 (gdb) h n
 Step program, proceeding through subroutine calls.
 Usage: next [N]
 Unlike "step", if the current source line calls a subroutine,
 this command does not enter the subroutine, but instead steps over
 the call, in effect treating it as a single source line.

 (gdb) h p
 Print value of expression EXP.
 Variables accessible are those of the lexical environment of the selected
 stack frame, plus all those whose scope is global or an entire file.

 (gdb) h h
 Print list of commands.
 (gdb) h help
 Print list of commands.
 (gdb) help h
 Print list of commands.
 (gdb) help help
 Print list of commands.

到目前为止,我们已经学习了一些基本命令,可以用来调试程序。这些命令是breakrunnextprintquitstephelp。我们将在下一小节学习函数和条件断点、观察点,以及continuefinish命令。

函数断点、条件断点、观察点,以及继续和完成命令

在这个例子中,我们将学习如何设置函数断点、条件断点,并使用continue命令。然后,我们将学习如何在不需要逐步执行所有代码行的情况下完成函数调用。源代码如下:

//ch13_gdb_2.cpp
#include <iostream>

float dotproduct( const float *x, const float *y, const int n);
int main()
{
 float sxx,sxy;
 float x[] = {1,2,3,4,5};
 float y[] = {0,1,1,1,1};

 sxx = dotproduct( x, x, 5);
 sxy = dotproduct( x, y, 5);
 printf( "dot(x,x) = %f\n", sxx );
 printf( "dot(x,y) = %f\n", sxy );
 return 0;
}

float dotproduct( const float *x, const float *y, const int n )
{
 const float *p = x;
 const float *q = x;  //bug: replace x by y
 float s = 0;
 for(int i=0; i<n; ++i, ++p, ++q){
        s += (*p) * (*q);
 }
 return s;
}

再次,构建并运行ch13_gdb_2.cpp后,我们得到以下输出:

~/wus1/Chapter-13$ g++ -g ch13_gdb_2.cpp -o ch13_gdb_2.out
~/wus1/Chapter-13$ ./ch13_gdb_2.out
dot(x,x) = 55.000000
dot(x,y) = 55.000000

由于dot(x,x)dot(x,y)都给我们相同的结果,这里一定有问题。现在,让我们通过学习如何在dot()函数中设置断点来调试它:

  • 函数断点:要在函数的开头设置断点,我们可以使用b function_name命令。和往常一样,在输入时可以使用制表符补全。例如,假设我们输入以下内容:
(gdb) b dot<Press TAB Key>

如果我们这样做,以下命令行将自动弹出:

(gdb) b dotproduct(float const*, float const*, int)

如果它是一个类的成员函数,它的类名应该包括在内,如下所示:

(gdb) b MyClass::foo(<Press TAB key>
  • 条件断点:有几种设置条件断点的方法:
(gdb) b f.cpp:26 if s==0 //set a breakpoint in f.cpp, line 26 if s==0
(gdb) b f.cpp:20 if ((int)strcmp(y, "hello")) == 0 
  • 列出和删除断点:一旦我们设置了几个断点,我们可以列出或删除它们,如下所示:
(gdb) i b (gdb) delete breakpoints 1 (gdb) delete breakpoints 2-5
  • 删除使断点无条件:由于每个断点都有一个编号,我们可以删除断点的条件,如下所示:
(gdb) cond 1         //break point 1 is unconditional now
  • 观察点:观察点可以在表达式的值发生变化时停止执行,而不必预测它可能发生的位置(在哪一行)。有三种观察点:

  • watch:当写入发生时,gdb将中断。

  • rwatch:当读取发生时,gdb将中断。

  • awatch:当发生写入或读取时,gdb将中断。

以下代码显示了一个例子:

(gdb) watch v                 //watch the value of variable v
(gdb) watch *(int*)0x12345678 //watch an int value pointed by an address
(gdb) watch a*b + c/d         // watch an arbitrarily complex expression
  • 继续:当我们在断点处检查变量的值后,我们可以使用continuec命令来继续程序执行,直到调试器遇到断点、信号、错误或正常进程终止。

  • 完成:一旦我们进入一个函数,我们可能希望连续执行它,直到返回到其调用行。这可以使用finish命令来完成。

现在,让我们将这些命令组合在一起来调试ch13_gdb_2.cpp。以下是我们终端窗口的输出。为了方便起见,我们将其分为三部分:

//gdb output of example ch13_gdb_2.out -- part 1
~/wus1/Chapter-13$ gdb ch13_gdb_2.out                     //cmd 1
 ...
 Reading symbols from ch13_gdb_2.out ... done.

 (gdb) b dotproduct(float const*, float const*, int)      //cmd 2
 Breakpoint 1 at 0xa5c: file ch13_gdb_2.cpp, line 20.
 (gdb) b ch13_gdb_2.cpp:24 if i==1                        //cmd 3
 Breakpoint 2 at 0xa84: file ch13_gdb_2.cpp, line 24.
 (gdb) i b                                                //cmd 4
 Num Type Disp Enb Address What
 1 breakpoint keep y 0x0000000000000a5c in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:20
 2 breakpoint keep y 0x0000000000000a84 in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:24
 stop only if i==1
 (gdb) cond 2                                            //cmd 5
 Breakpoint 2 now unconditional.
 (gdb) i b                                               //cmd 6
 Num Type Disp Enb Address What
 1 breakpoint keep y 0x0000000000000a5c in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:20
 2 breakpoint keep y 0x0000000000000a84 in dotproduct(float const*, float const*, int) at ch13_gdb_2.cpp:24 

第一部分,我们有以下六个命令:

  • cmd 1:我们使用构建的可执行文件ch13_gdb_2.out启动gdb。这简要显示了它的版本和文档和使用信息,然后告诉我们读取符号的过程已经完成,并且正在等待下一个命令。

  • cmd 2:我们设置了一个断点函数(在dotproduct()处)。

  • cmd 3:设置了一个条件断点

  • cmd 4: 它列出了关于断点的信息,并告诉我们有两个断点。

  • cmd 5: 我们将breakpoint 2设置为无条件的

  • cmd 6: 再次列出断点信息。此时,我们可以看到两个断点。它们分别位于ch13_gdb_2.cp文件的第 20 行和第 24 行。

接下来,让我们看看第二部分的gdb输出:

//gdb output of example ch13_gdb_2.out -- part 2 
(gdb) r                                                //cmd 7
 Starting program: /home/nvidia/wus1/Chapter-13/ch13_gdb_2.out
 [Thread debugging using libthread_db enabled]
 Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".

 Breakpoint 1, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:20
 20 const float *p = x;
 (gdb) p x                                            //cmd 8
 $1 = (const float *) 0x7fffffed68
 (gdb) c                                              //cmd 9 
 Continuing.

 Breakpoint 2, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
 24 s += (*p) * (*q);
 (gdb) p i                                           //cmd 10
 $2 = 0
 (gdb) n                                             //cmd 11
 23 for(int i=0; i<n; ++i, ++p, ++q){
 (gdb) n                                             //cmd 12

 Breakpoint 2, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
 24 s += (*p) * (*q);
 (gdb) p s                                           //cmd 13 
 $4 = 1
 (gdb) watch s                                       //cmd 14 
 Hardware watchpoint 3: s

第二部分有以下命令:

  • cmd 7: 通过给出run命令,程序开始运行,并在第 20 行的第一个断点处停止。

  • cmd 8: 我们打印x的值,显示其地址。

  • cmd 9: 我们继续程序。一旦继续,它会在第 24 行的第二个断点处停止。

  • cmd 10: 打印i的值,为0

  • cmd 11-12: 我们两次使用next命令。在这一点上,执行s += (*p) * (*q)语句。

  • cmd 13: 打印s的值,为1

  • cmd 14: 我们打印s的值。

最后,第三部分如下:

//gdb output of example ch13_gdb_2.out -- part 3 
(gdb) n                                             //cmd 15 
  Hardware watchpoint 3: s

 Old value = 1
 New value = 5
 dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:23
 23 for(int i=0; i<n; ++i, ++p, ++q){
 (gdb) finish                                       //cmd 16
 Run till exit from #0 dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:23

 Breakpoint 2, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
 24 s += (*p) * (*q);
 (gdb) delete breakpoints 1-3                       //cmd 17
 (gdb) c                                            //cmd 18
 Continuing.

 dot(x,x) = 55.000000
 dot(x,y) = 55.000000
 [Inferior 1 (process 31901) exited normally]
 [Inferior 1 (process 31901) exited normally]
 (gdb) q                                           //cmd 19
 ~/wus1/Chapter-13$

在这一部分,我们有以下命令:

  • cmd 15: 我们使用next命令来查看如果执行下一次迭代时s的值是多少。它显示旧值为s1(s = 11),新值为5(s=11+2*2)。到目前为止,一切顺利!

  • cmd 16: 使用finish命令继续运行程序,直到退出函数。

  • cmd 17: 删除断点 1 到 3。

  • cmd 18: 使用continue命令。

  • cmd 19: 我们退出gdb,回到终端窗口。

将 gdb 记录到文本文件中

处理长堆栈跟踪或多线程堆栈跟踪时,从终端窗口查看和分析gdb输出可能会不方便。然而,我们可以先将整个会话或特定输出记录到文本文件中,然后稍后离线使用其他文本编辑工具进行浏览。为此,我们需要使用以下命令:

(gdb) set logging on

当我们执行此命令时,gdb将把所有终端窗口输出保存到名为gdb.txt的文本文件中,该文件位于当前运行的gdb文件夹中。如果我们想停止记录,只需输入以下内容:

(gdb) set logging off

关于 GDB 的一大好处是,我们可以随意多次打开和关闭日志记录命令,而不必担心转储文件名。这是因为所有输出都被连接到gdb.txt文件中。

以下是返回ch13_gdb_2.out并将gdb输出转储的示例:

~/wus1/Chapter-13$ gdb ch13_gdb_2.out           //cmd 1
 ...
Reading symbols from ch13_gdb_2.out...done.
 (gdb) set logging on                           //cmd 2
 Copying output to gdb.txt.
 (gdb) b ch13_gdb_2.cpp:24 if i==1              //cmd 3 
 Breakpoint 1 at 0xa84: file ch13_gdb_2.cpp, line 24.
 (gdb) r                                        //cmd 4 
 ...
 Breakpoint 1, dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
 24 s += (*p) * (*q);
 (gdb) p i                                      //cmd 5 
 $1 = 1
 (gdb) p s                                      //cmd 6 
 $2 = 1
 (gdb) finish                                   //cmd 7 
 Run till exit from #0 dotproduct (x=0x7fffffed68, y=0x7fffffed68, n=5) at ch13_gdb_2.cpp:24
 0x00000055555559e0 in main () at ch13_gdb_2.cpp:11
 11 sxx = dotproduct( x, x, 5);
 Value returned is $3 = 55
 (gdb) delete breakpoints 1                    //cmd 8
 (gdb) set logging off                         //cmd 9
 Done logging to gdb.txt.
 (gdb) c                                       //cmd 10 
 Continuing.
 dot(x,x) = 55.000000
 dot(x,y) = 55.000000
 [Inferior 1 (process 386) exited normally]
 (gdb) q                                      //cmd 11
 ~/wus1/Chapter-13$ cat gdb.txt               //cmd 12

在前面的代码中使用的命令如下:

  • cmd 1: 启动gdb

  • cmd 2: 我们将日志标志设置为打开。此时,gdb表示输出将被复制到gdb.txt文件中。

  • cmd 3: 设置条件断点

  • cmd 4: 我们运行程序,当它到达第 24 行的条件断点时停止。

  • cmd 5cmd 6: 我们分别打印is的值。

  • cmd 7: 通过执行函数步出命令,显示sxx55(在调用sxx=dotproduct(x, x, 5))后),程序停在sxy *=* dotproduct(x, y, 5)行。

  • cmd 8: 我们删除breakpoint 1

  • cmd 9: 我们将日志标志设置为关闭。

  • cmd 10: 一旦给出继续指令,它就会从main函数中运行出来,gdb等待新命令。

  • cmd 11: 我们输入q退出gdb

  • cmd 12: 当返回到终端窗口时,通过在操作系统中运行cat命令打印已记录的gdb.txt文件的内容。

到目前为止,我们已经学会了足够的 GDB 命令来调试程序。正如你可能已经注意到的,这是耗时的,因此非常昂贵。有时,由于在错误的地方调试,情况变得更糟。为了高效地调试,我们需要遵循正确的策略。我们将在下一小节中介绍这一点。

实用调试策略

由于调试是软件开发生命周期中成本最高的阶段,发现错误并修复它们是不可行的,特别是对于大型复杂系统。然而,有一些策略可以在实际过程中使用,其中一些如下:

  • 使用 printf()或 std::cout:这是一种老式的做法。通过将一些信息打印到终端,我们可以检查变量的值,并执行进一步分析的位置和时间种类的日志配置文件。

  • 使用调试器:虽然学习使用 GDB 这类调试器工具不是一蹴而就的事情,但它可以节省大量时间。因此,逐步熟悉它,并逐渐掌握。

  • 重现错误:每当在现场报告错误时,记录运行环境和输入数据。

  • 转储日志文件:应用程序应将日志消息转储到文本文件中。发生崩溃时,我们应首先检查日志文件,以查看是否发生异常事件。

  • 猜测:粗略猜测错误的位置,然后证明它是对还是错。

  • 分而治之:即使在最糟糕的情况下,我们对存在什么错误一无所知,我们仍然可以使用二分搜索策略设置断点,然后缩小范围,最终定位它们。

  • 简化:始终从最简化的情景开始,逐渐添加外围设备、输入模块等,直到可以重现错误。

  • 源代码版本控制:如果一个错误突然出现在一个发布版上,但之前运行正常,首先检查源代码树。可能有人做了改变!

  • 不要放弃:有些错误真的很难定位和/或修复,特别是对于复杂和多团队参与的系统。暂时搁置它们,回家的路上重新思考一下,也许会有灵光一现

到目前为止,我们已经学习了如何使用 RCA 进行宏观问题定位,以及我们可以遵循的良好编码实践,以防止问题发生。此外,通过使用诸如 GDB 之类的最先进的调试器工具,我们可以逐行控制程序的执行,以便我们可以在微观级别分析和解决问题。所有这些活动都是程序员集中和手动的。是否有任何自动工具可以帮助我们诊断程序的潜在缺陷?我们将在下一节中看一下静态和动态分析。

理解静态和动态分析

在前几节中,我们学习了根本原因分析过程以及如何使用 GDB 调试缺陷。本节将讨论如何分析程序,无论是否执行。前者称为动态分析,而后者称为静态分析。

静态分析

静态分析评估计算机程序的质量,而无需执行它。虽然通常可以通过自动工具和代码审查/检查来完成,但本节我们只关注自动工具。

自动静态代码分析工具旨在分析一组代码与一个或多个编码规则或指南。通常,人们可以互换使用静态代码分析、静态分析或源代码分析。通过扫描每个可能的代码执行路径的整个代码库,我们可以在测试阶段之前找到许多潜在的错误。然而,它也有一些限制,如下所示:

  • 它可能会产生错误的阳性和阴性警报。

  • 它只应用于扫描算法内部实施的规则,其中一些可能会被主观解释。

  • 它无法找到在运行时环境中引入的漏洞。

  • 它可能会产生一种虚假的安全感,认为一切都在得到解决。

在商业和免费开源类别下,有大约 30 个自动 C/C++代码分析工具[9]。这些工具的名称包括 Clang、Clion、CppCheck、Eclipse、Visual Studio 和 GNU g++等。作为示例,我们想介绍内置于 GNU 编译器 g++[10]中的**-**Wall-Weffcc++-Wextra选项:

  • -Wall:启用所有构造警告,对于某些用户来说是有问题的。这些警告很容易避免或修改,即使与宏一起使用。它还启用了一些在 C++方言选项和 Objective-C/C++方言选项中描述的特定于语言的警告。

  • -Wextra:正如其名称所示,它检查一些-Wall未检查的额外警告标志。将打印以下任何情况的警告消息:

  • 将指针与整数零使用<<=>>=操作数进行比较。

  • 非枚举和枚举在条件表达式中出现。

  • 虚拟基类不明确。

  • register类型数组进行下标操作。

  • 使用register类型变量的地址。

  • 派生类的复制构造函数未初始化其基类。注意(b)-(f)仅适用于 C++。

  • -Weffc++:它检查了 Scott Meyers 所著的*Effective and More Effective C++*中建议的一些准则的违反。这些准则包括以下内容:

  • 为具有动态分配内存的类定义复制构造函数和赋值运算符。

  • 在构造函数中,优先使用初始化而不是赋值。

  • 在基类中使析构函数虚拟。

  • 使=运算符返回对*this的引用。

  • 当必须返回对象时,不要尝试返回引用。

  • 区分增量和减量运算符的前缀和后缀形式。

  • 永远不要重载&&||,

为了探索这三个选项,让我们看下面的例子:

//ch13_static_analysis.cpp
#include <iostream>
int *getPointer(void)
{
    return 0;
}

int &getVal() {
    int x = 5;
    return x;
}

int main()
{
    int *x = getPointer();
    if( x> 0 ){
        *x = 5;
   }
   else{
       std::cout << "x is null" << std::endl;
   }

   int &y = getVal();
   std::cout << y << std::endl;
   return 0;
}

首先,让我们不使用任何选项来构建它:

g++ -o ch13_static.out ch13_static_analysis.cpp 

这可以成功构建,但是如果我们运行它,预期会出现段错误核心已转储)消息。

接下来,让我们添加-Wall-Weffc++-Wextra选项并重新构建它:

g++ -Wall -o ch13_static.out ch13_static_analysis.cpp
g++ -Weffc++ -o ch13_static.out ch13_static_analysis.cpp
g++ -Wextra -o ch13_static.out ch13_static_analysis.cpp

-Wall-Weffc++都给出了以下消息:

ch13_static_analysis.cpp: In function ‘int& getVal():
ch13_static_analysis.cpp:9:6: warning: reference to local variable ‘x’ returned [-Wreturn-local-addr]
int x = 5;
 ^

在这里,它抱怨在int & getVal()函数(cpp文件的第 9 行)中返回了对局部变量的引用。这不起作用,因为一旦程序退出函数,x就是垃圾(x的生命周期仅限于函数的范围内)。引用一个已经失效的变量是没有意义的。

-Wextra给出了以下消息:

 ch13_static_analysis.cpp: In function ‘int& getVal():
 ch13_static_analysis.cpp:9:6: warning: reference to local variable ‘x’ returned [-Wreturn-local-addr]
 int x = 5;
 ^
 ch13_static_analysis.cpp: In function ‘int main():
 ch13_static_analysis.cpp:16:10: warning: ordered comparison of pointer with integer zero [-Wextra]
 if( x> 0 ){
 ^

前面的输出显示,*-*Wextra不仅给出了-Wall的警告,还检查了我们之前提到的六件事。在这个例子中,它警告我们代码的第 16 行存在指针和整数零的比较。

现在我们知道了如何在编译时使用静态分析选项,我们将通过执行程序来了解动态分析。

动态分析

动态分析动态程序分析的简称,它通过在真实或虚拟处理器上执行软件程序来分析软件程序的性能。与静态分析类似,动态分析也可以自动或手动完成。例如,单元测试、集成测试、系统测试和验收测试通常是人为参与的动态分析过程。另一方面,内存调试、内存泄漏检测和 IBM purify、Valgrind 和 Clang sanitizers 等性能分析工具是自动动态分析工具。在本小节中,我们将重点关注自动动态分析工具。

动态分析过程包括准备输入数据、启动测试程序、收集必要的参数和分析其输出等步骤。粗略地说,动态分析工具的机制是它们使用代码插装和/或模拟环境来对分析的代码进行检查。我们可以通过以下方式与程序交互:

  • 源代码插装:在编译之前,将特殊的代码段插入原始源代码中。

  • 目标代码插装:将特殊的二进制代码直接添加到可执行文件中。

  • 编译阶段插装:通过特殊的编译器开关添加检查代码。

  • 它不会改变源代码。相反,它使用特殊的执行阶段库来检测错误。

动态分析有以下优点:

  • 没有错误预测的模型,因此不会出现假阳性或假阴性结果。

  • 它不需要源代码,这意味着专有代码可以由第三方组织进行测试。

动态分析的缺点如下:

  • 它只能检测与输入数据相关的路径上的缺陷。其他缺陷可能无法被发现。

  • 它一次只能检查一个执行路径。为了获得完整的图片,我们需要尽可能多地运行测试。这需要大量的计算资源。

  • 它无法检查代码的正确性。可能会从错误的操作中得到正确的结果。

  • 在真实处理器上执行不正确的代码可能会产生意想不到的结果。

现在,让我们使用 Valgrind 来找出以下示例中给出的内存泄漏和越界问题:

//ch13_dynamic_analysis.cpp
#include <iostream>
int main()
{
    int n=10;
    float *p = (float *)malloc(n * sizeof(float));
    for( int i=0; i<n; ++i){
        std::cout << p[i] << std::endl;
    }
    //free(p);  //leak: free() is not called
    return 0;
}

要使用 Valgrind 进行动态分析,需要执行以下步骤:

  1. 首先,我们需要安装valgrind。我们可以使用以下命令来完成:
sudo apt install valgrind //for Ubuntu, Debian, etc.
  1. 安装成功后,我们可以通过传递可执行文件作为参数以及其他参数来运行valgrind,如下所示:
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
 --verbose --log-file=valgrind-out.txt ./myExeFile myArgumentList
  1. 接下来,让我们构建这个程序,如下所示:
g++ -o ch13_dyn -std=c++11 -Wall ch13_dynamic_analysis.cpp
  1. 然后,我们运行valgrind,如下所示:
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
 --verbose --log-file=log.txt ./ch13_dyn

最后,我们可以检查log.txt的内容。粗体和斜体行表示内存泄漏的位置和大小。通过检查地址(0x4844BFC)及其对应的函数名(main()),我们可以看到这个mallocmain()函数中:

... //ignore many lines at begining
 by 0x108A47: main (in /home/nvidia/wus1/Chapter-13/ch13_dyn)
 ==18930== Uninitialised value was created by a heap allocation
 ==18930== at 0x4844BFC: malloc (in /usr/lib/valgrind/vgpreload_memcheck-arm64-linux.so)
 ... //ignore many lines in middle
 ==18930== HEAP SUMMARY:
 ==18930== in use at exit: 40 bytes in 1 blocks
 ==18930== total heap usage: 3 allocs, 2 frees, 73,768 bytes allocated
 ==18930==
 ==18930== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
 ==18930== at 0x4844BFC: malloc (in /usr/lib/valgrind/vgpreload_memcheck-arm64-linux.so)
 ==18930==
 ==18930== LEAK SUMMARY:
 ==18930== definitely lost: 40 bytes in 1 blocks
 ==18930== indirectly lost: 0 bytes in 0 blocks
 ==18930== possibly lost: 0 bytes in 0 blocks
 ==18930== still reachable: 0 bytes in 0 blocks
 ==18930== suppressed: 0 bytes in 0 blocks

在这里,我们可以看到malloc()被调用来在地址0x4844BFC分配一些内存。堆摘要部分表明我们在0x4844BFC处有 40 字节的内存泄漏。最后,泄漏摘要部分显示肯定有一个 40 字节的内存泄漏。通过在log.txt文件中搜索0x4844BFC的地址值,我们最终发现原始代码中没有调用free(p)行。取消注释此行后,我们重新进行valgrind分析,以便泄漏问题现在已经不在报告中。

总之,借助静态和动态分析工具,程序的潜在缺陷可以自动大大减少。然而,为了确保软件的质量,人类必须参与最终的测试和评估。现在,我们将探讨软件工程中的单元测试、测试驱动开发和行为驱动开发概念。

探索单元测试、TDD 和 BDD

在上一节中,我们了解了自动静态和动态程序分析。本节将重点介绍人为参与(准备测试代码)的测试,这是动态分析的另一部分。这些是单元测试、测试驱动开发和行为驱动开发。

单元测试假设如果我们已经有了单个代码单元,那么我们需要编写一个测试驱动程序并准备输入数据来检查其输出是否正确。之后,我们进行集成测试来测试多个单元一起,然后进行验收测试,测试整个应用程序。由于集成和验收测试比单元测试更难维护且与项目更相关,因此在本书中很难覆盖它们。有兴趣的人可以通过访问www.iso.org/standard/45142.html了解更多信息。

与单元测试相比,TDD 认为我们应该先有测试代码和数据,快速开发一些代码并使其通过,最后进行重构,直到客户满意。另一方面,BDD 认为我们不应该测试程序的实现,而是测试其期望的行为。为此,BDD 强调应该建立一个软件生产相关人员之间的沟通平台和语言。

我们将在以下小节中详细讨论这些方法。

单元测试

单元是更大或更复杂应用程序中的一个单独组件。通常,一个单元有自己的用户界面,例如函数、类或整个模块。单元测试是一种软件测试方法,用于确定代码单元是否按照其设计要求的预期行为。单元测试的主要特点如下:

  • 它小巧简单,编写和运行速度快,因此可以在早期开发周期中发现问题,因此问题可以很容易地修复。

  • 由于它与依赖项隔离,因此每个测试用例都可以并行运行。

  • 单元测试驱动程序帮助我们理解单元接口。

  • 当测试单元后集成时,它极大地帮助集成和验收测试。

  • 通常由开发人员准备和执行。

虽然我们可以从头开始编写一个单元测试包,但社区中已经开发了许多单元测试框架UTFs)。Boost.Test、CppUnit、GoogleTest、Unit++和 CxxTest 是最受欢迎的。这些 UTF 通常提供以下功能:

  • 只需要最少的工作来设置一个新的测试。

  • 它们依赖于标准库并支持跨平台,这意味着它们易于移植和修改。

  • 它们支持测试固定装置,允许我们为多个不同的测试重用相同的对象配置。

  • 它们很好地处理异常和崩溃。这意味着 UTF 可以报告异常,但不能崩溃。

  • 它们具有良好的断言功能。每当断言失败时,应打印其源代码位置和变量的值。

  • 它们支持不同的输出,这些输出可以方便地由人类或其他工具进行分析。

  • 它们支持测试套件,每个套件可能包含多个测试用例。

现在,让我们来看一个 Boost UTF 的例子(自 v1.59.0 起)。它支持三种不同的使用变体:仅单头文件变体、静态库变体和共享库变体。它包括四种类型的测试用例:无参数的测试用例、数据驱动的测试用例、模板测试用例和参数化的测试用例。

它还有七种检查工具:BOOST_TEST()BOOST_CHECK()BOOST_REQUIRE()、BOOST_ERROR()BOOST_FAIL()BOOST_CHECK_MESSAGE( )BOOST_CHECK_EQUAL()。它还支持固定装置,并以多种方式控制测试输出。编写测试模块时,我们需要遵循以下步骤:

  1. 定义我们的测试程序的名称。这将在输出消息中使用。

  2. 选择一个使用变体:仅头文件、链接静态文件或作为共享库。

  3. 选择并添加一个测试用例到测试套件中。

  4. 对被测试代码执行正确性检查。

  5. 在每个测试用例之前初始化被测试的代码。

  6. 自定义测试失败报告的方式。

  7. 控制构建测试模块的运行时行为,也称为运行时配置。

例如,以下示例涵盖了步骤 1-4。如果您感兴趣,可以在www.boost.org/doc/libs/1_70_0/libs/test/doc/html/index.html获取步骤 5-7的示例:

//ch13_unit_test1.cpp
#define BOOST_TEST_MODULE my_test //item 1, "my_test" is module name
#include <boost/test/included/unit_test.hpp> //item 2, header-only

//declare we begin a test suite and name it "my_suite "
BOOST_AUTO_TEST_SUITE( my_suite ) 

//item 3, add a test case into test suit, here we choose 
//        BOOST_AUTO_TEST_CASE and name it "test_case1" 
BOOST_AUTO_TEST_CASE(test_case1) {
 char x = 'a';
 BOOST_TEST(x);        //item 4, checks if c is non-zero
 BOOST_TEST(x == 'a'); //item 4, checks if c has value 'a'
 BOOST_TEST(x == 'b'); //item 4, checks if c has value 'b'
}

//item 3, add the 2nd test case
BOOST_AUTO_TEST_CASE( test_case2 )
{
  BOOST_TEST( true );
}

//item 3, add the 3rd test case
BOOST_AUTO_TEST_CASE( test_case3 )
{
  BOOST_TEST( false );
}

BOOST_AUTO_TEST_SUITE_END() //declare we end test suite

为了构建这个,我们可能需要安装 boost,如下所示:

sudo apt-get install libboost-all-dev

然后,我们可以构建并运行它,如下所示:

~/wus1/Chapter-13$ g++ -g  ch13_unit_test1.cpp 
~/wus1/Chapter-13$ ./a.out

上述代码的结果如下:

Running 3 test cases...
 ch13_unit_test1.cpp(13): error: in "my_suite/test_case1": check x == 'b' has failed ['a' != 'b']
 ch13_unit_test1.cpp(25): error: in "my_suite/test_case3": check false has failed

 *** 2 failures are detected in the test module "my_test"

在这里,我们可以看到test_case1test_case3中存在失败。特别是在test_case1中,x的值不等于b,显然在test_case3中,一个错误的检查无法通过测试。

TDD

如下图所示,TDD 流程从编写失败的测试代码开始,然后添加/修改代码使测试通过。之后,我们对测试计划和代码进行重构,直到满足所有要求[16,17]。让我们看看下面的图表:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

步骤 1是编写一个失败的测试。TDD 不是先开发代码,而是开始编写测试代码。因为我们还没有代码,所以我们知道,如果我们运行测试,它会失败。在这个阶段,定义测试数据格式和接口,并想象代码实现细节。

步骤 2的目标是尽快使测试通过,开发工作量最小。我们不想完美地实现一切;我们只希望它通过测试。一旦测试通过,我们就有东西可以展示给客户,并告诉客户,此时客户可能在看到初始产品后完善需求。然后,我们进入下一个阶段。

第三阶段是重构。在这个阶段,我们可能会进去,看看,看看我们想要改变什么以及如何改变它。

对于传统的开发人员来说,TDD 最困难的地方是从编码->测试模式转变为测试->编码模式的心态变化。为了对测试套件有一个模糊的概念,J. Hartikainen 建议开发人员考虑以下五个步骤[18]来开始:

  1. 首先确定输入和输出。

  2. 选择类/函数签名。

  3. 只决定功能的一个小方面进行测试。

  4. 实现测试。

  5. 实现代码。

一旦我们完成了这个迭代,我们可以逐渐重构它,直到实现整体的综合目标。

TDD 的例子

接下来,我们将通过实施一个案例研究来演示 TDD 过程。在这个研究中,我们将开发一个 Mat 类来执行 2D 矩阵代数,就像我们在 Matlab 中所做的那样。这是一个类模板,可以容纳所有数据类型的 m×n 矩阵。矩阵代数包括矩阵的加法、减法、乘法和除法,它还具有元素操作能力。

让我们开始吧。

步骤 1 - 编写一个失败的测试

首先,我们只需要以下内容:

  • 从给定的行数和列数创建一个Mat对象(默认应为 0×0,即空矩阵)。

  • 按行打印其元素。

  • rows()cols()获取矩阵大小。

根据这些要求,我们可以有失败的单元测试代码来提升 UTF,如下所示:

// ch13_tdd_boost_UTF1.cpp
#define BOOST_TEST_MODULE tdd_test
#include <boost/test/included/unit_test.hpp>
#include "ch13_tdd_v1.h"

BOOST_AUTO_TEST_SUITE(tdd_suite)  //begin a test suite: "tdd_suite"

BOOST_AUTO_TEST_CASE(test_case1) {
  Mat<int> x(2, 3);            //create a 2 x 3 int matrix
  x.print("int x=");
  BOOST_TEST(2 == x.rows());
  BOOST_TEST(3 == x.cols());

  Mat<float> y;              //create a 0 x 0 empty float matrix
  y.print("float y=");
  BOOST_TEST(0 == y.rows());
  BOOST_TEST(0 == y.cols());

  Mat<char> z(1,10);       //create a 1 x 10 char matrix
  z.print("char z=");
  BOOST_TEST(1 == z.rows());
  BOOST_TEST(10 == z.cols());
}
BOOST_AUTO_TEST_SUITE_END() //end test suite

现在我们的测试代码准备好了,我们准备开发代码。

步骤 2 - 开发代码使测试通过

实现一个最小的代码段来通过前面的测试,如下所示:

//file: ch13_tdd_v1.h
#ifndef __ch13_TDD_V1__
#define __ch13_TDD_V1__
#include <iostream>
#include <assert.h>
template< class T>
class Mat {
public:
  Mat(const uint32_t m=0, const uint32_t n=0);
  Mat(const Mat<T> &rhs) = delete;
  ~Mat();

  Mat<T>& operator = (const Mat<T> &x) = delete;

  uint32_t rows() { return m_rows; }
  uint32_t cols() { return m_cols; }
  void print(const char* str) const;

private:
  void creatBuf();
  void deleteBuf();
  uint32_t m_rows; //# of rows
  uint32_t m_cols; //# of cols
  T* m_buf;
};
#include "ch13_tdd_v1.cpp"
#endif

一旦我们有了前面的头文件,我们就可以开发其相应的cpp文件,如下所示:

//file: ch13_tdd_v1.cpp
#include "ch13_tdd_v1.h"
using namespace std;

template< class T>
Mat<T>::Mat(const uint32_t m, const uint32_t n)
 : m_rows(m)
 , m_cols(n)
 , m_buf(NULL)
{
 creatBuf();
}

template< class T>
Mat<T> :: ~Mat()
{ 
 deleteBuf(); 
}

template< class T>
void Mat<T>::creatBuf()
{
 uint32_t sz = m_rows * m_cols;
 if (sz > 0) {
 if (m_buf) { deleteBuf();}
 m_buf = new T[sz];
 assert(m_buf);
 }
 else {
 m_buf = NULL;
 }
}

template< class T>
void Mat<T>::deleteBuf()
{
 if (m_buf) {
 delete[] m_buf;
 m_buf = NULL;
 }
}

template< class T>
void Mat<T> ::print(const char* str) const
{
 cout << str << endl;
 cout << m_rows << " x " << m_cols << "[" << endl;
 const T *p = m_buf;
 for (uint32_t i = 0; i<m_rows; i++) {
 for (uint32_t j = 0; j < m_cols; j++) {
 cout << *p++ << ", ";
 }
 cout << "\n";
 }
 cout << "]\n";
}

假设我们使用支持-std=c++11或更高版本的 g++进行构建和执行:

~/wus1/Chapter-13$ g++ -g ch13_tdd_boost_UTF1.cpp~/wus1/Chapter-13$ a.out 

这将导致以下输出:

Running 1 test case...
 int x=2 x 3[
 1060438054, 1, 4348032,
 0, 4582960, 0,
 ]
 float y=0 x 0[
 ]
 char z=1 x 10[
 s,s,s,s,s,s,s,s,s,s,
 ]

test_case1中,我们创建了三个矩阵并测试了rows()cols()print()函数。第一个是一个 2x3 的int类型矩阵。由于它没有初始化,其元素的值是不可预测的,这就是为什么我们可以从print()中看到这些随机数。在这一点上,我们也通过了rows()cols()的测试(两个BOOST_TEST()调用没有错误)。第二个是一个空的浮点类型矩阵;它的print()函数什么也不输出,它的cols()rows()都是零。最后,第三个是一个 1x10 的char类型未初始化矩阵。同样,这三个函数的所有输出都是预期的。

步骤 3 - 重构

到目前为止,一切顺利 - 我们通过了测试!然而,在向客户展示前面的结果后,他/她可能会要求我们添加另外两个接口,如下所示:

  • 为所有元素创建一个给定初始值的 m x n 矩阵。

  • 添加numel()以返回矩阵的总元素数。

  • 添加empty(),如果矩阵既有零行又有零列,则返回 true,否则返回 false。

一旦我们向测试套件添加了第二个测试用例,整体重构后的测试代码将如下所示:

// ch13_tdd_Boost_UTF2.cpp
#define BOOST_TEST_MODULE tdd_test
#include <boost/test/included/unit_test.hpp>
#include "ch13_tdd_v2.h"

//declare we begin a test suite and name it "tdd_suite"
BOOST_AUTO_TEST_SUITE(tdd_suite)

//add the 1st test case
BOOST_AUTO_TEST_CASE(test_case1) {
  Mat<int> x(2, 3);
  x.print("int x=");
  BOOST_TEST(2 == x.rows());
  BOOST_TEST(3 == x.cols());

  Mat<float> y;
  BOOST_TEST(0 == y.rows());
  BOOST_TEST(0 == y.cols());

  Mat<char> z(1, 10);
  BOOST_TEST(1 == z.rows());
  BOOST_TEST(10 == z.cols());
}

//add the 2nd test case
BOOST_AUTO_TEST_CASE(test_case2)
{
  Mat<int> x(2, 3, 10);
  x.print("int x=");
  BOOST_TEST( 6 == x.numel() );
  BOOST_TEST( false == x.empty() );

  Mat<float> y;
  BOOST_TEST( 0 == y.numel() );
  BOOST_TEST( x.empty() ); //bug x --> y 
}

BOOST_AUTO_TEST_SUITE_END() //declare we end test suite

下一步是修改代码以通过这个新的测试计划。为了简洁起见,我们不会在这里打印ch13_tdd_v2.hch13_tdd_v2.cpp文件。您可以从本书的GitHub存储库中下载它们。构建并执行ch13_tdd_Boost_UTF2.cpp后,我们得到以下输出:

Running 2 test cases...
 int x=2x3[
 1057685542, 1, 1005696,
 0, 1240624, 0,
 ]
 int x=2x3[
 10, 10, 10,
 10, 10, 10,
 ]
 ../Chapter-13/ch13_tdd_Boost_UTF2.cpp(34): error: in "tdd_suite/test_case2": che
 ck x.empty() has failed [(bool)0 is false]

在第一个输出中,由于我们只定义了一个 2x3 的整数矩阵,并且没有在test_case1中初始化它,所以会打印出未定义的行为 - 也就是六个随机数。第二个输出来自test_case2,其中x的所有六个元素都初始化为10。在我们展示了前面的结果之后,我们的客户可能会要求我们添加其他新功能或修改当前存在的功能。但是,经过几次迭代,最终我们会达到快乐点并停止因式分解。

现在我们已经了解了 TDD,我们将讨论 BDD。

BDD

软件开发最困难的部分是与业务参与者、开发人员和质量分析团队进行沟通。由于误解或模糊的需求、技术争论和缓慢的反馈周期,项目很容易超出预算、错过截止日期或完全失败。

(BDD) [20]是一种敏捷开发过程,具有一套旨在减少沟通障碍和其他浪费活动的实践。它还鼓励团队成员在生产生命周期中不断地使用真实世界的例子进行沟通。

BDD 包含两个主要部分:故意发现和 TDD。为了让不同组织和团队的人了解开发软件的正确行为,故意发现阶段引入了示例映射技术,通过具体的例子让不同角色的人进行对话。这些例子将成为系统行为的自动化测试和实时文档。在其 TDD 阶段,BDD 规定任何软件单元的测试应该以该单元的期望行为为基础。

有几种 BDD 框架工具(JBehave、RBehave、Fitnesse、Cucumber [21]等)适用于不同的平台和编程语言。一般来说,这些框架执行以下步骤:

  1. 在故意发现阶段,阅读由业务分析师准备的规范格式文档。

  2. 将文档转换为有意义的条款。每个单独的条款都可以被设置为质量保证的测试用例。开发人员也可以根据条款实现源代码。

  3. 自动执行每个条款场景的测试。

总之,我们已经了解了关于应用开发流程中什么、何时以及如何进行测试的策略。如下图所示,传统的 V 形[2]模型强调需求->设计->编码->测试的模式。TDD 认为开发过程应该由测试驱动,而 BDD 将来自不同背景和角色的人之间的沟通加入到 TDD 框架中,并侧重于行为测试:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

此外,单元测试强调在编码完成后测试单个组件。TDD 更注重如何在编写代码之前编写测试,然后通过下一级测试计划添加/修改代码。BDD 鼓励客户、业务分析师、开发人员和质量保证分析师之间的合作。虽然我们可以单独使用每一个,但在这个敏捷软件开发时代,我们真的应该将它们结合起来以获得最佳结果。

总结

在本章中,我们简要介绍了软件开发过程中与测试和调试相关的主题。测试可以发现问题,根本原因分析有助于在宏观层面上定位问题。然而,良好的编程实践可以在早期阶段防止软件缺陷。此外,命令行界面调试工具 GDB 可以帮助我们设置断点,并在程序运行时逐行执行程序并打印变量的值。

我们还讨论了自动分析工具和人工测试过程。静态分析评估程序的性能而不执行它。另一方面,动态分析工具可以通过执行程序来发现缺陷。最后,我们了解了测试过程在软件开发流程中应该如何、何时以及如何参与的策略。单元测试强调在编码完成后测试单个组件。TDD 更注重如何在开发代码之前编写测试,然后通过下一级测试计划重复这个过程。BDD 鼓励客户、业务分析师、开发人员和质量保证分析师之间的合作。

在下一章中,我们将学习如何使用 Qt 创建跨平台应用程序的图形用户界面(GUI)程序,这些程序可以在 Linux、Windows、iOS 和 Android 系统上运行。首先,我们将深入了解跨平台 GUI 编程的基本概念。然后我们将介绍 Qt 及其小部件的概述。最后,通过一个案例研究示例,我们将学习如何使用 Qt 设计和实现网络应用程序。

进一步阅读

除此之外,你还可以查看以下来源(这些在本章中没有直接提到):

练习和问题

  1. 使用 gdb 函数断点、条件断点和 watchpointcontinuefinish 命令,调试 ch13_gdb_2.cpp

  2. 使用 g++ -c -Wall -Weffc++ -Wextra  x.cpp -o x.out 来构建 cpp 文件 ch13_rca*.cpp。你从他们的警告输出中看到了什么?

  3. 为什么静态分析会产生误报,而动态分析不会呢?

  4. 下载 ch13_tdd_v2.h/.cpp 并执行下一阶段的重构。在这个阶段,我们将添加一个拷贝构造函数、赋值运算符,以及诸如 +-*/ 等的逐元素操作运算符。更具体地,我们需要做以下事情:

  5. 将第三个测试用例添加到我们的测试套件中,即 ch13_tdd_Boost_UTF2.cpp

  6. 将这些函数的实现添加到文件中;例如,ch13_tdd_v2.h/.cpp

  7. 运行测试套件来测试它们。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值