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

172 阅读49分钟

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书将向读者提供关于 C++17 和 C++20 标准的 C++程序的细节,以及它们是如何编译、链接和执行的。它还将涵盖内存管理的工作原理,内存管理问题的最佳实践,类的实现方式,编译器如何优化代码,以及编译器在支持类继承、虚函数和模板方面的方法。

本书还将告诉读者如何将内存管理、面向对象编程、并发和设计模式应用于创建面向世界的生产应用程序。

读者将学习高效数据结构和算法的内部细节,并将了解如何衡量和比较它们,以选择最适合特定问题的内容。

本书将帮助读者将系统设计技能与基本设计模式结合到 C++应用程序中。

此外,本书还介绍了人工智能世界,包括使用 C++编程语言进行机器学习的基础知识。

本书结束时,读者应该有足够的信心,能够使用高效的数据结构和算法设计和构建真实的可扩展 C++应用程序。

本书的读者对象

寻求了解与语言和程序结构相关的细节,或者试图通过深入程序的本质来提高自己的专业知识,以设计可重用、可扩展架构的 C++开发人员将从本书中受益。那些打算使用 C++17 和 C++20 的新特性设计高效数据结构和算法的开发人员也将受益。

本书内容

[第一章]《构建 C++应用程序简介》包括对 C++世界、其应用程序以及语言标准的最新更新的介绍。本章还对 C++涵盖的主题进行了良好的概述,并介绍了代码编译、链接和执行阶段。

[第二章]《使用 C++进行低级编程》专注于讨论 C++数据类型、数组、指针、指针的寻址和操作,以及条件语句、循环、函数、函数指针和结构的低级细节。本章还包括对结构(structs)的介绍。

[第三章]《面向对象编程的细节》深入探讨了类和对象的结构,以及编译器如何实现对象的生命周期。本章结束时,读者将了解继承和虚函数的实现细节,以及 C++中面向对象编程的基本内部细节。

[第四章]《理解和设计模板》介绍了 C++模板、模板函数的示例、模板类、模板特化以及一般的模板元编程。特性和元编程将融入 C++应用程序的魔力。

[第五章]《内存管理和智能指针》深入探讨了内存部分、分配和管理的细节,包括使用智能指针来避免潜在的内存泄漏。

[第六章]《深入 STL 中的数据结构和算法》介绍了数据结构及其 STL 实现。本章还包括对数据结构的比较,并讨论了实际应用的适当性,提供了真实世界的例子。

第七章,函数式编程,着重于函数式编程,这是一种不同的编程范式,使读者能够专注于代码的“功能”而不是“物理”结构。掌握函数式编程为开发人员提供了一种新的技能,有助于为问题提供更好的解决方案。

第八章,并发和多线程,着重于如何通过利用并发使程序运行更快。当高效的数据结构和高效的算法达到程序性能的极限时,并发就会发挥作用。

第九章,设计并发数据结构,着重利用数据结构和并发性来设计基于锁和无锁的并发数据结构。

第十章,设计面向世界的应用程序,着重于将前几章学到的知识融入到使用设计模式设计健壮的现实世界应用程序中。本章还包括了理解和应用领域驱动设计,通过设计一个亚马逊克隆来实现。

第十一章,使用设计模式设计策略游戏,将前几章学到的知识融入到使用设计模式和最佳实践设计策略游戏中。

第十二章,网络和安全,介绍了 C++中的网络编程以及如何利用网络编程技能构建一个 dropbox 后端克隆。本章还包括了如何确保编码最佳实践的讨论。

第十三章,调试和测试,着重于调试 C++应用程序和避免代码中的错误的最佳实践,应用静态代码分析以减少程序中的问题,介绍和应用测试驱动开发和行为驱动开发。本章还包括了行为驱动开发和 TDD 之间的区别以及使用案例。

第十四章,使用 Qt 进行图形用户界面,介绍了 Qt 库及其主要组件。本章还包括了对 Qt 跨平台性质的理解,通过构建一个简单的桌面客户端来延续 dropbox 示例。

第十五章,在机器学习任务中使用 C++,涉及了人工智能概念和领域的最新发展的简要介绍。本章还包括了机器学习和诸如回归分析和聚类等任务的介绍,以及如何构建一个简单的神经网络。

第十六章,实现基于对话框的搜索引擎,涉及将之前章节的知识应用到设计一个高效的搜索引擎中,描述为基于对话框,因为它通过询问(和学习)用户的相应问题来找到正确的文档。

为了充分利用本书

基本的 C++经验,包括对内存管理、面向对象编程和基本数据结构和算法的熟悉,将是一个很大的优势。如果你渴望了解这个复杂程序在幕后是如何工作的,以及想要理解 C++应用设计的编程概念和最佳实践的细节,那么你应该继续阅读本书。

书中涉及的软件/硬件操作系统要求
g++编译器Ubuntu Linux 是一个优势,但不是必需的

你还需要在计算机上安装 Qt 框架。相关细节在相关章节中有介绍。

在撰写本书时,并非所有 C++编译器都支持所有新的 C++20 功能,考虑使用最新版本的编译器以测试本章介绍的更多功能。

下载示例代码文件

您可以从您在www.packt.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在“搜索”框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩软件解压文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Expert-CPP。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还提供了来自我们丰富书籍和视频目录的其他代码包,网址为**github.com/PacktPublishing/**。快去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:static.packt-cdn.com/downloads/9781838552657_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:"前面的代码声明了两个readonly属性,并分配了值"。

代码块设置如下:

Range book = 1..4;
var res = Books[book] ;
Console.WriteLine($"\tElement of array using Range: Books[{book}] => {Books[book]}");

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

private static readonly int num1=5;
private static readonly int num2=6;

任何命令行输入或输出都以以下形式书写:

dotnet --info

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中出现。这是一个例子:"从管理面板中选择系统信息"。

警告或重要提示会以这种形式出现。

提示和技巧会以这种形式出现。

第一部分:C++编程的内部机制

在本节中,读者将学习 C++程序的编译和链接的细节,并深入了解面向对象编程(OOP)、模板和内存管理的细节。

本节包括以下章节:

  • 第一章,构建 C++应用程序简介

  • 第二章,使用 C++进行低级编程

  • 第三章,面向对象编程的细节

  • 第四章,理解和设计模板

  • 第五章,内存管理和智能指针

构建 C++应用程序的简介

编程语言通过其程序执行模型而有所不同;最常见的是解释型语言和编译型语言。编译器将源代码转换为机器代码,计算机可以在没有中介支持系统的情况下运行。另一方面,解释型语言代码需要支持系统、解释器和虚拟环境才能工作。

C++是一种编译型语言,使得程序运行速度比解释型语言更快。虽然 C++程序应该为每个平台进行编译,但解释型程序可以跨平台操作。

我们将讨论程序构建过程的细节,从编译器处理源代码的阶段开始,到可执行文件的细节(编译器的输出)结束。我们还将了解为什么为一个平台构建的程序在另一个平台上无法运行。

本章将涵盖以下主题:

  • C++20 简介

  • C++预处理器的细节

  • 源代码编译的底层细节

  • 理解链接器及其功能

  • 可执行文件的加载和运行过程

技术要求

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

C++20 简介

C++经过多年的发展,现在有了全新的版本,C++20。自 C++11 以来,C++标准大大扩展了语言特性集。让我们来看看新的 C++20 标准中的显著特性。

概念

概念是 C++20 中的一个重要特性,它为类型提供了一组要求。概念背后的基本思想是对模板参数进行编译时验证。例如,要指定模板参数必须具有默认构造函数,我们可以如下使用default_constructible概念:

template <default_constructible T>
void make_T() { return T(); }

在上述代码中,我们错过了typename关键字。相反,我们设置了描述template函数的T参数的概念。

我们可以说概念是描述其他类型的类型 - 元类型,可以这么说。它们允许在类型属性的基础上对模板参数进行编译时验证以及函数调用。我们将在第三章和第四章中详细讨论概念,面向对象编程的细节理解和设计模板

协程

协程是特殊的函数,能够在任何定义的执行点停止并稍后恢复。协程通过以下新关键字扩展了语言:

  • co_await 暂停协程的执行。

  • co_yield 暂停协程的执行,同时返回一个值。

  • co_return 类似于常规的return关键字;它结束协程并返回一个值。看看以下经典示例:

generator<int> step_by_step(int n = 0) {
  while (true) {
    co_yield n++;
  }
}

协程与promise对象相关联。promise对象存储和警报协程的状态。我们将在第八章中深入讨论协程,并发和多线程

范围

ranges库提供了一种新的处理元素范围的方式。要使用它们,您应该包含<ranges>头文件。让我们通过一个例子来看ranges。范围是具有开始和结束的元素序列。它提供了一个begin迭代器和一个end哨兵。考虑以下整数向量:

import <vector>

int main()
{
  std::vector<int> elements{0, 1, 2, 3, 4, 5, 6};
}

范围和范围适配器(|运算符)提供了处理一系列元素的强大功能。例如,查看以下代码:

import <vector>
import <ranges>

int main()
{
  std::vector<int> elements{0, 1, 2, 3, 4, 5, 6};
  for (int current : elements | ranges::view::filter([](int e) { return 
   e % 2 == 0; }))
  {
    std::cout << current << " ";
  }
}

在前面的代码中,我们使用ranges::view::filter()过滤了偶数整数的范围。注意应用于元素向量的范围适配器|。我们将在第七章中讨论范围及其强大的功能,函数式编程

更多的 C++20 功能

C++20 是 C++语言的一个新的重大发布。它包含许多使语言更复杂和灵活的功能。概念范围协程是本书中将讨论的许多功能之一。

最受期待的功能之一是模块,它提供了在模块内声明模块并导出类型和值的能力。您可以将模块视为带有现在多余的包含保护的头文件的改进版本。我们将在本章中介绍 C++20 模块。

除了 C++20 中添加的显着功能之外,还有一系列其他功能,我们将在整本书中讨论:

  • 太空船操作符:operator<=>()。现在可以通过利用operator<=>()来控制运算符重载的冗长。

  • constexpr在语言中占据越来越多的空间。C++20 现在有了consteval函数,constexpr std::vectorstd::string,以及许多其他功能。

  • 数学常数,例如std::number::pistd::number::log2e

  • 线程库的重大更新,包括停止令牌和加入线程。

  • 迭代器概念。

  • 移动视图和其他功能。

为了更好地理解一些新功能,并深入了解语言的本质,我们将从以前的版本开始介绍语言的核心。这将帮助我们找到比旧版本更好的新功能的用途,并且还将有助于支持旧版 C++代码。现在让我们开始了解 C++应用程序的构建过程。

构建和运行程序

您可以使用任何文本编辑器来编写代码,因为最终,代码只是文本。要编写代码,您可以自由选择简单的文本编辑器,如Vim,或者高级的集成开发环境IDE),如MS Visual Studio。情书和源代码之间唯一的区别是后者可能会被称为编译器的特殊程序解释(而情书无法编译成程序,它可能会让您心跳加速)。

为了区分纯文本文件和源代码,使用了特殊的文件扩展名。C++使用.cpp.h扩展名(您可能偶尔也会遇到.cxx.hpp)。在深入细节之前,将编译器视为将源代码转换为可运行程序(称为可执行文件或可执行文件)的工具。从源代码生成可执行文件的过程称为编译。编译 C++程序是一系列复杂任务的序列,最终产生机器代码。机器代码是计算机的本机语言,这就是为什么它被称为机器代码。

通常,C++编译器会解析和分析源代码,然后生成中间代码,对其进行优化,最后生成一个名为目标文件的机器代码文件。您可能已经遇到过目标文件;它们在 Linux 中有单独的扩展名.o,在 Windows 中有单独的扩展名.obj。创建的目标文件包含不仅可以由计算机运行的机器代码。编译通常涉及多个源文件,编译每个源文件会产生一个单独的目标文件。然后,这些目标文件由一个称为链接器的工具链接在一起,形成一个单独的可执行文件。链接器使用存储在目标文件中的附加信息来正确地链接它们(链接将在本章后面讨论)。

以下图表描述了程序构建的阶段:

C++应用程序构建过程包括三个主要步骤:预处理编译链接。所有这些步骤都使用不同的工具完成,但现代编译器将它们封装在一个单一的工具中,为程序员提供了一个更简单的接口。

生成的可执行文件保存在计算机的硬盘上。为了运行它,应将其复制到主内存 RAM 中。复制由另一个名为加载器的工具完成。加载器是操作系统的一部分,它知道应从可执行文件的内容中复制什么和复制到哪里。将可执行文件加载到主内存后,原始可执行文件不会从硬盘中删除。

程序的加载和运行由操作系统(OS)完成。操作系统管理程序的执行,优先级高于其他程序,在完成后卸载程序等。程序的运行副本称为进程。进程是可执行文件的一个实例。

理解预处理

预处理器旨在处理源文件,使其准备好进行编译。预处理器使用预处理器指令,如#define#include等。指令不代表程序语句,而是预处理器的命令,告诉它如何处理源文件的文本。编译器无法识别这些指令,因此每当在代码中使用预处理器指令时,预处理器会在实际编译代码之前相应地解析它们。例如,编译器开始编译之前,以下代码将被更改:

#define NUMBER 41 
int main() { 
  int a = NUMBER + 1; 
  return 0; 
}

使用#define指令定义的所有内容都称为。经过预处理后,编译器以这种形式获得转换后的源代码:

int main() { 
  int a = 41 + 1; 
  return 0;
}

如前所述,预处理器只是处理文本,不关心语言规则或其语法。特别是使用宏定义的预处理器指令,如前面的例子中的#define NUMBER 41,除非你意识到预处理器只是简单地将NUMBER的任何出现替换为41,而不将41解释为整数。对于预处理器来说,以下行都是有效的:

int b = NUMBER + 1; 
struct T {}; // user-defined type 
T t = NUMBER; // preprocessed successfully, but compile error 

这将产生以下代码:

int b = 41 + 1
struct T {};
T t = 41; // error line

当编译器开始编译时,它会发现赋值t = 41是错误的,因为从'int'到'T'没有可行的转换。

甚至使用在语法上正确但存在逻辑错误的宏也是危险的:

#define DOUBLE_IT(arg) (arg * arg) 

预处理器将任何DOUBLE_IT(arg)的出现替换为(arg * arg),因此以下代码将输出16

int st = DOUBLE_IT(4);
std::cout << st;

编译器将接收到这段代码:

int st = (4 * 4);
std::cout << st;

当我们将复杂表达式用作宏参数时会出现问题:

int bad_result = DOUBLE_IT(4 + 1); 
std::cout << bad_result;

直观上,这段代码将产生25,但事实上预处理器只是进行文本处理,而在这种情况下,它会这样替换宏:

int bad_result = (4 + 1 * 4 + 1);
std::cout << bad_result;

这将输出9,显然9不是25

要修复宏定义,需要用额外的括号括住宏参数:

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

现在表达式将采用这种形式:

int bad_result = ((4 + 1) * (4 + 1)); 

强烈建议在适用的地方使用const声明,而不是宏定义。

一般来说,应避免使用宏定义。宏容易出错,而 C++提供了一组构造,使得宏的使用已经过时。

如果我们使用constexpr函数,同样的前面的例子将在编译时进行类型检查和处理:

constexpr int double_it(int arg) { return arg * arg; } 
int bad_result = double_it(4 + 1); 

使用constexpr限定符使得能够在编译时评估函数的返回值(或变量的值)。使用const变量重新编写NUMBER定义的示例会更好:

const int NUMBER = 41; 

头文件

预处理器最常见的用法是#include指令,用于在源代码中包含头文件。头文件包含函数、类等的定义:

// file: main.cpp 
#include <iostream> 
#include "rect.h"
int main() { 
  Rect r(3.1, 4.05) 
  std::cout << r.get_area() << std::endl;
}

假设头文件 rect.h 定义如下:

// file: rect.h
struct Rect  
{
private:
  double side1_;
  double side2_;
public:
  Rect(double s1, double s2);
  const double get_area() const;
};

实现包含在 rect.cpp 中:

// file: rect.cpp
#include "rect.h"

Rect::Rect(double s1, double s2)
  : side1_(s1), side2_(s2)
{}

const double Rect::get_area() const {
  return side1_ * side2_;
}

预处理器检查 main.cpprect.cpp 后,将用 main.cpp#include 指令替换为 iostreamrect.h 的相应内容,用 rect.cpp#include 指令替换为 rect.h。C++17 引入了 __has_include 预处理器常量表达式。如果找到指定名称的文件,__has_include 的值为 1,否则为 0

#if __has_include("custom_io_stream.h")
#include "custom_io_stream.h"
#else
#include <iostream>
#endif

在声明头文件时,强烈建议使用所谓的包含保护#ifndef, #define, #endif)来避免双重声明错误。我们将很快介绍这种技术。这些又是预处理指令,允许我们避免以下情况:Square 类在 square*.*h 中定义,它包含 rect.h 以从 Rect 派生 Square

// file: square.h
#include "rect.h"
struct Square : Rect {
  Square(double s);
};

main.cpp 中包含 square.hrect.h 会导致 rect.h 被包含两次:

// file: main.cpp
#include <iostream> 
#include "rect.h" 
#include "square.h"
/* 
  preprocessor replaces the following with the contents of square.h
*/
// code omitted for brevity

预处理后,编译器将以以下形式接收 main.cpp

// contents of the iostream file omitted for brevity 
struct Rect {
  // code omitted for brevity
};
struct Rect {
  // code omitted for brevity
};
struct Square : Rect {
  // code omitted for brevity
};
int main() {
  // code omitted for brevity
}

然后编译器会产生一个错误,因为它遇到了两个类型为 Rect 的声明。头文件应该通过使用包含保护来防止多次包含,方法如下:

#ifndef RECT_H 
#define RECT_H 
struct Rect { ... }; // code omitted for brevity  
#endif // RECT_H 

当预处理器第一次遇到头文件时,RECT_H 未定义,#ifndef#endif 之间的所有内容都将被相应处理,包括 RECT_H 的定义。当预处理器在同一源文件中第二次包含相同的头文件时,它将省略内容,因为 RECT_H 已经被定义。

这些包含保护是控制源文件部分编译的指令的一部分。所有条件编译指令都是 #if, #ifdef, #ifndef, #else, #elif, 和 #endif

条件编译在许多情况下都很有用;其中之一是在所谓的调试模式下记录函数调用。在发布程序之前,建议调试程序并测试逻辑缺陷。您可能想要查看在调用某个函数后代码中发生了什么,例如:

void foo() {
  log("foo() called");
  // do some useful job
}
void start() {
  log("start() called");
  foo();
  // do some useful job
}

每个函数调用 log() 函数,其实现如下:

void log(const std::string& msg) {
#if DEBUG
  std::cout << msg << std::endl;
#endif
}

如果定义了 DEBUGlog() 函数将打印 msg。如果编译项目时启用了 DEBUG(使用编译器标志,如 g++ 中的 -D),那么 log() 函数将打印传递给它的字符串;否则,它将什么也不做。

在 C++20 中使用模块

模块修复了头文件中令人讨厌的包含保护问题。我们现在可以摆脱预处理宏。模块包括两个关键字,importexport。要使用一个模块,我们使用 import。要声明一个模块及其导出的属性,我们使用 export。在列出使用模块的好处之前,让我们看一个简单的使用示例。以下代码声明了一个模块:

export module test;

export int twice(int a) { return a * a; }

第一行声明了名为 test 的模块。接下来,我们声明了 twice() 函数并将其设置为 export。这意味着我们可以有未导出的函数和其他实体,因此它们在模块外部将是私有的。通过导出实体,我们将其设置为模块用户的 public。要使用 module,我们像以下代码中那样导入它:

import test;

int main()
{
  twice(21);
}

模块是 C++ 中期待已久的功能,它在编译和维护方面提供了更好的性能。以下功能使模块在与常规头文件的竞争中更胜一筹:

  • 模块只被导入一次,类似于自定义语言实现支持的预编译头文件。这大大减少了编译时间。未导出的实体对导入模块的翻译单元没有影响。

  • 模块允许通过选择应该导出和不应该导出的单元来表达代码的逻辑结构。模块可以捆绑在一起形成更大的模块。

  • 摆脱之前描述的包含保护等变通方法。我们可以以任何顺序导入模块。不再担心宏的重新定义。

模块可以与头文件一起使用。我们可以在同一个文件中导入和包含头文件,就像下面的例子所示:

import <iostream>;
#include <vector>

int main()
{
  std::vector<int> vec{1, 2, 3};
  for (int elem : vec) std::cout << elem;
}

在创建模块时,您可以在模块的接口文件中导出实体,并将实现移动到其他文件中。逻辑与管理.h.cpp文件相同。

理解编译

C++编译过程由几个阶段组成。一些阶段旨在分析源代码,而其他阶段则生成和优化目标机器代码。以下图表显示了编译的各个阶段:

让我们详细看看这些阶段中的每一个。

标记化

编译器的分析阶段旨在将源代码分割成称为标记的小单元。标记可以是一个单词或一个单一符号,比如=(等号)。标记是源代码的最小单元,对于编译器来说具有有意义的价值。例如,表达式int a = 42;将被分成标记inta=42;。表达式不仅仅是通过空格分割,因为以下表达式也被分成相同的标记(尽管建议不要忘记操作数之间的空格):

int a=42;

使用复杂的方法和正则表达式将源代码分割成标记。这被称为词法分析标记化(分成标记)。对于编译器来说,使用标记化的输入提供了一种更好的方式来构建用于分析代码语法的内部数据结构。让我们看看。

语法分析

在谈论编程语言编译时,我们通常区分两个术语:语法和语义。语法是代码的结构;它定义了标记组合成结构上下文的规则。例如,day nice是英语中的一个语法正确的短语,因为它的标记中没有错误。语义则关注代码的实际含义。也就是说,day nice在语义上是不正确的,应该改为a nice day

语法分析是源代码分析的关键部分,因为标记将被语法和语义地分析,即它们是否具有符合一般语法规则的任何含义。例如,接下来的例子:

int b = a + 0;

对我们来说可能没有意义,因为将零添加到变量不会改变其值,但是编译器在这里并不关心逻辑含义,而是关心代码的语法正确性(缺少分号、缺少右括号等)。检查代码的语法正确性是在编译的语法分析阶段完成的。词法分析将代码分成标记;语法分析检查语法正确性,这意味着如果我们漏掉了一个分号,上述表达式将产生语法错误:

int b = a + 0

g++将报错expected ';' at end of declaration

语义分析

如果前面的表达式是it b = a + 0;,编译器会将其分成标记itb=和其他。我们已经看到it是未知的,但对于编译器来说,这个时候还可以接受。这将导致 g++中的编译错误unknown type name "it"。找到表达式背后的含义是语义分析(解析)的任务。

中间代码生成

在完成所有分析之后,编译器将生成中间代码,这是 C++的轻量级版本,主要是 C。一个简单的例子是:

class A { 
public:
  int get_member() { return mem_; }
private: 
  int mem_; 
};

在分析代码之后,将生成中间代码(这是一个抽象的例子,旨在展示中间代码生成的概念;编译器在实现上可能有所不同)。

struct A { 
  int mem_; 
};
int A_get_member(A* this) { return this->mem_; } 

优化

生成中间代码有助于编译器对代码进行优化。编译器试图大量优化代码。优化不止一次进行。例如,看下面的代码:

int a = 41; 
int b = a + 1; 

在编译期间,这将被优化为这个:

int a = 41; 
int b = 41 + 1; 

这将再次被优化为以下内容:

int a = 41; 
int b = 42; 

一些程序员毫无疑问地认为,如今,编译器编写的代码比程序员更好。

机器代码生成

编译器优化在中间代码和生成的机器代码中都进行。那么当我们编译项目时会是什么样子呢?在本章的前面,当我们讨论源代码的预处理时,我们看到了一个简单的结构,其中包含了几个源文件,包括两个头文件rect.hsquare.h,每个都有其对应的.cpp文件,以及包含程序入口点(main()函数)的main.cpp。预处理后,以下单元作为编译器的输入:main.cpprect.cppsquare.cpp,如下图所示:

编译器将分别编译每个单元。编译单元,也称为源文件,在某种程度上是独立的。当编译器编译main.cpp时,在Rect中调用get_area()函数,它不会在main.cpp中包含get_area()的实现。相反,它只是确信该函数在项目的某个地方被实现。当编译器到达rect*.*cpp时,它并不知道get_area()函数在某处被使用。

这是main.cpp经过预处理阶段后编译器得到的结果:

// contents of the iostream 
struct Rect {
private:
  double side1_;
  double side2_;
public:
  Rect(double s1, double s2);
  const double get_area() const;
};

struct Square : Rect {
  Square(double s);
};

int main() {
  Rect r(3.1, 4.05);
  std::cout << r.get_area() << std::endl;
  return 0;
}

分析main.cpp后,编译器生成以下中间代码(为了简单表达编译背后的思想,许多细节被省略):

struct Rect { 
  double side1_; 
  double side2_; 
};
void _Rect_init_(Rect* this, double s1, double s2); 
double _Rect_get_area_(Rect* this); 

struct Square { 
  Rect _subobject_; 
};
void _Square_init_(Square* this, double s); 

int main() {
  Rect r;
  _Rect_init_(&r, 3.1, 4.05); 
  printf("%d\n", _Rect_get_area(&r)); 
  // we've intentionally replace cout with printf for brevity and 
  // supposing the compiler generates a C intermediate code
  return 0;
}

编译器在优化代码时会删除Square结构及其构造函数(我们将其命名为_Square_init_),因为它在源代码中从未被使用。

此时,编译器仅处理main.cpp,因此它看到我们调用了_Rect_init__Rect_get_area_函数,但没有在同一文件中提供它们的实现。然而,由于我们之前提供了它们的声明,编译器相信我们,并相信这些函数在其他编译单元中被实现。基于这种信任和关于函数签名的最小信息(返回类型、名称以及参数的数量和类型),编译器生成一个包含main.cpp中工作代码的目标文件,并以某种方式标记那些没有实现但被信任稍后解决的函数。解决是由链接器完成的。

在下面的示例中,我们有生成的简化对象文件的变体,其中包含两个部分——代码和信息。代码部分包含每条指令的地址(十六进制值):

code: 
0x00 main
 0x01 Rect r; 
  0x02 _Rect_init_(&r, 3.1, 4.05); 
  0x03 printf("%d\n", _Rect_get_area(&r)); 
information:
  main: 0x00
  _Rect_init_: ????
  printf: ????
  _Rect_get_area_: ????

看看信息部分。编译器用????标记了代码部分中使用但在同一编译单元中找不到的所有函数。这些问号将由链接器替换为其他单元中找到的函数的实际地址。完成main.cpp后,编译器开始编译rect.cpp文件:

// file: rect.cpp 
struct Rect {
  // #include "rect.h" replaced with the contents  
  // of the rect.h file in the preprocessing phase 
  // code omitted for brevity 
};
Rect::Rect(double s1, double s2) 
  : side1_(s1), side2_(s2)
{}
const double Rect::get_area() const { 
  return side1_ * side2_;
} 

按照相同的逻辑,编译此单元产生以下输出(不要忘记,我们仍然提供抽象示例):

code:  
 0x00 _Rect_init_ 
  0x01 side1_ = s1 
  0x02 side2_ = s2 
  0x03 return 
  0x04 _Rect_get_area_ 
  0x05 register = side1_ 
  0x06 reg_multiply side2_ 
  0x07 return 
information: 
  _Rect_init_: 0x00
  _Rect_get_area_: 0x04 

这个输出中包含了所有函数的地址,因此不需要等待某些函数稍后解决。

平台和目标文件

我们刚刚看到的抽象输出在某种程度上类似于编译器在编译单元后产生的实际目标文件结构。目标文件的结构取决于平台;例如,在Linux中,它以ELF格式表示(ELF代表可执行和可链接格式)。平台是程序执行的环境。在这个上下文中,平台指的是计算机架构(更具体地说是指令集架构)和操作系统的组合。硬件和操作系统由不同的团队和公司设计和创建。它们每个都有不同的解决方案来解决问题,这导致平台之间存在重大差异。平台在许多方面有所不同,这些差异也反映在可执行文件的格式和结构上。例如,Windows 系统中的可执行文件格式是可移植可执行文件PE),它具有不同的结构、部分数量和顺序,与 Linux 中的 ELF 格式不同。

目标文件被分成部分。对我们来说最重要的是代码部分(标记为.text)和数据部分(.data)。.text部分包含程序指令,.data部分包含指令使用的数据。数据本身可以分为多个部分,如初始化未初始化只读数据。

除了.text.data部分之外,目标文件的一个重要部分是符号表。符号表存储了字符串(符号)到目标文件中的位置的映射。在前面的示例中,编译器生成的输出有两部分,其中的第二部分标记为information:,其中包含代码中使用的函数的名称和它们的相对地址。这个information:是目标文件的实际符号表的抽象版本。符号表包含代码中定义的符号和代码中需要解析的符号。然后链接器使用这些信息将目标文件链接在一起形成最终的可执行文件。

引入链接

编译器为每个编译单元输出一个目标文件。在前面的示例中,我们有三个.cpp文件,编译器产生了三个目标文件。链接器的任务是将这些目标文件组合成一个单一的目标文件。将文件组合在一起会导致相对地址的变化;例如,如果链接器将rect.o文件放在main.o之后,rect.o的起始地址将变为0x04,而不是之前的0x00的值。

code: 
 0x00 main
  0x01 Rect r; 
  0x02 _Rect_init_(&r, 3.1, 4.05); 
  0x03 printf("%d\n", _Rect_get_area(&r)); 
 0x04 _Rect_init_ 
 0x05 side1_ = s1 
 0x06 side2_ = s2 
 0x07 return 
 0x08 _Rect_get_area_ 
 0x09 register = side1_ 
 0x0A reg_multiply side2_ 
 0x0B return 
information (symbol table):
  main: 0x00
  _Rect_init_: 0x04
  printf: ????
  _Rect_get_area_: 0x08 
 _Rect_init_: 0x04
 _Rect_get_area_: 0x08

链接器相应地更新符号表地址(我们示例中的information:部分)。如前所述,每个目标文件都有其符号表,将符号的字符串名称映射到文件中的相对位置(地址)。链接的下一步是解析目标文件中的所有未解析符号。

现在链接器已经将main.orect.o组合在一起,它知道未解析符号的相对位置,因为它们现在位于同一个文件中。printf符号将以相同的方式解析,只是这次它将链接对象文件与标准库一起。当所有目标文件都组合在一起后(我们为简洁起见省略了square.o的链接),所有地址都被更新,所有符号都被解析,链接器输出一个最终的可执行文件,可以被操作系统执行。正如本章前面讨论的那样,操作系统使用一个称为加载器的工具将可执行文件的内容加载到内存中。

链接库

库类似于可执行文件,但有一个主要区别:它没有main()函数,这意味着它不能像常规程序那样被调用。库用于将可能被多个程序重复使用的代码组合在一起。例如,通过包含<iostream>头文件,您已经将程序与标准库链接起来。

库可以链接到可执行文件中,可以是静态库,也可以是动态库。当将它们链接为静态库时,它们将成为最终可执行文件的一部分。动态链接库也应该由操作系统加载到内存中,以便为程序提供调用其函数的能力。假设我们想要找到一个函数的平方根:

int main() {
  double result = sqrt(49.0);
}

C++标准库提供了sqrt()函数,它返回其参数的平方根。如果编译前面的示例,它将产生一个错误,坚持认为sqrt函数未被声明。我们知道要使用标准库函数,应该包含相应的<cmath>头文件。但是头文件不包含函数的实现;它只是声明函数(在std命名空间中),然后包含在我们的源文件中:

#include <cmath>
int main() {
  double result = std::sqrt(49.0);
}

编译器将sqrt符号的地址标记为未知,链接器应在链接阶段解析它。如果源文件未与标准库实现(包含库函数的目标文件)链接,链接器将无法解析它。

链接器生成的最终可执行文件将包含我们的程序和标准库(如果链接是静态的)。另一方面,如果链接是动态的,链接器将标记sqrt符号在运行时被找到。

现在当我们运行程序时,加载程序的同时也加载了动态链接到我们程序的库。它还将标准库的内容加载到内存中,然后解析sqrt()函数在内存中的实际位置。已加载到内存中的相同库也可以被其他程序使用。

摘要

在本章中,我们涉及了 C++20 的一些新特性,并准备深入了解该语言。我们讨论了构建 C++应用程序及其编译阶段的过程。这包括分析代码以检测语法和语法错误,生成中间代码以进行优化,最后生成将与其他生成的目标文件链接在一起形成最终可执行文件的目标文件。

在下一章中,我们将学习 C++数据类型、数组和指针。我们还将了解指针是什么,并查看条件的低级细节。

问题

  1. 编译器和解释器之间有什么区别?

  2. 列出程序编译阶段。

  3. 预处理器的作用是什么?

  4. 链接器的任务是什么?

  5. 静态链接库和动态链接库之间有什么区别?

进一步阅读

有关更多信息,请参阅www.amazon.com/Advanced-C-Compiling-Milan-Stevanovic/dp/1430266678/中的A**dvanced C and C++ Compiling

LLVM Essentials, www.packtpub.com/application…

使用 C++进行低级编程

最初,C++被视为 C 语言的继承者;然而,自那时以来,它已经发展成为一个庞大的东西,有时甚至令人生畏,甚至难以驾驭。通过最近的语言更新,它现在代表着一个复杂的怪物,需要时间和耐心来驯服。我们将从几乎每种语言都支持的基本构造开始这一章,如数据类型、条件和循环语句、指针、结构体和函数。我们将从低级系统程序员的角度来看待这些构造,好奇地了解即使是一个简单的指令也可以被计算机执行。对这些基本构造的深入理解是建立更高级和抽象主题的坚实基础的必要条件,比如面向对象的编程。

在本章中,我们将学习以下内容:

  • 程序执行的细节及其入口点

  • main()函数的特殊属性

  • 函数调用和递归背后的复杂性

  • 内存段和寻址基础

  • 数据类型和变量在内存中的存储位置

  • 指针和数组

  • 条件和循环的低级细节

技术要求

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

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

程序执行

在第一章中,构建 C++应用程序,我们了解到编译器在编译源代码后生成可执行文件。可执行文件包含可以复制到计算机内存中由中央处理单元CPU)运行的机器代码。复制是由操作系统的内部工具加载器完成的。因此,操作系统OS)将程序的内容复制到内存中,并通过将其第一条指令传递给 CPU 来开始执行程序。

main()

程序执行始于main()函数,作为标准中指定的程序的指定开始。一个简单的输出Hello, World!消息的程序将如下所示:

#include <iostream>
int main() {
  std::cout << "Hello, World!" << std::endl;
  return 0;
}

您可能已经遇到或在您的程序中使用了main()函数的参数。它有两个参数,argcargv,允许从环境中传递字符串,通常称为命令行参数

argcargv的名称是传统的,可以用任何你想要的东西替换。argc参数保存传递给main()函数的命令行参数的数量;argv参数保存参数:

#include <iostream>
int main(int argc, char* argv[]) {
 std::cout << "The number of passed arguments is: " << argc << std::endl;
 std::cout << "Arguments are: " << std::endl;
 for (int ix = 1; ix < argc; ++ix) {
   std::cout << argv[ix] << std::endl;
 }
 return 0;
}

例如,我们可以使用以下参数编译和运行前面的示例:

$ my-program argument1 hello world --some-option

这将在屏幕上输出以下内容:

The number of passed arguments is: 5
Arguments are:
argument1
hello
world
--some-option

当您查看参数的数量时,您会注意到它是5。第一个参数始终是程序的名称;这就是为什么我们在示例中从数字1开始循环的原因。

很少见到一个广泛支持但未标准化的第三个参数,通常称为envpenvp的类型是char指针数组,它保存系统的环境变量。

程序可以包含许多函数,但程序的执行始终从main()函数开始,至少从程序员的角度来看。让我们尝试编译以下代码:

#include <iostream>

void foo() {
  std::cout << "Risky foo" << std::endl;
}

// trying to call the foo() outside of the main() function
foo();

int main() {
  std::cout << "Calling main" << std::endl;
  return 0;
}

g++在foo();调用上引发错误C++需要为所有声明指定类型说明符。该调用被解析为声明而不是执行指令。我们在main()之前尝试调用函数的方式对于经验丰富的开发人员可能看起来很愚蠢,所以让我们尝试另一种方式。如果我们声明一个在初始化期间调用函数的东西会怎样?在下面的示例中,我们定义了一个带有打印消息的构造函数的BeforeMain结构,然后在全局范围内声明了一个BeforeMain类型的对象:

#include <iostream>

struct BeforeMain {
  BeforeMain() {
 std::cout << "Constructing BeforeMain" << std::endl;
 }
};

BeforeMain b;

int main() {
  std::cout << "Calling main()" << std::endl;
  return 0;
}

示例成功编译,并且程序输出以下内容:

Constructing BeforeMain
Calling main()

如果我们向BeforeMain添加一个成员函数并尝试调用它会发生什么?请看以下代码以了解这一点:

struct BeforeMain {
  // constructor code omitted for brevity
 void test() {
 std::cout << "test function" << std::endl;
 }
};

BeforeMain b;
b.test(); // compiler error

int main() {
  // code omitted for brevity
}

test()的调用将不成功。因此我们不能在main()之前调用函数,但我们可以声明变量-对象将被默认初始化。因此,在main()实际调用之前肯定有一些初始化的操作。事实证明,main()函数并不是程序的真正起点。程序的实际起始函数准备环境,即收集传递给程序的参数,然后调用main()函数。这是必需的,因为 C++支持需要在程序开始之前初始化的全局和静态对象,这意味着在调用main()函数之前。在 Linux 世界中,这个函数被称为__libc_start_main。编译器通过调用__libc_start_main来增强生成的代码,然后可能调用其他初始化函数,然后调用main()函数。抽象地说,想象一下上面的代码将被修改为类似以下的内容:

void __libc_start_main() {
  BeforeMain b;
  main();
}
__libc_start_main(); // call the entry point

我们将在接下来的章节中更详细地研究入口点。

main()的特殊属性

我们得出结论,main()实际上并不是程序的入口点,尽管标准规定它是指定的起点。编译器特别关注main()。它的行为类似于常规的 C++函数,但除了是第一个被调用的函数之外,它还具有其他特殊属性。首先,它是唯一一个可以省略return语句的函数:

int main() {
  // works fine without a return statement
}

返回的值表示执行的成功。通过返回0,我们旨在告诉控制main()成功结束,因此如果控制在没有遇到相应的return语句的情况下到达末尾,它将认为调用成功,效果与return 0;相同。

main()函数的另一个有趣属性是它的返回类型不能自动推断。不允许使用auto占位类型说明符,该说明符表示返回类型将从函数的return语句中推断。这是正常函数的工作原理:

// C++11
auto foo() -> int {
  std::cout << "foo in alternative function syntax" << std::endl;
  return 0; } // C++14 auto foo() {
  std::cout << "In C++14 syntax" << std::endl;
  return 0;
}

通过放置auto说明符,我们告诉编译器自动推断return类型。在 C++11 中,我们还在箭头(->)之后放置了类型名称,尽管第二种语法更短。考虑get_ratio()函数,它将标准比率作为整数返回:

auto get_ratio(bool minimum) {
  if (minimum) {
 return 12; // deduces return type int
  }
 return 18; // fine: get_ratio's return type is already deduced to int
}

要成功编译包含 C++11、C++14、C++17 或 C++20 中指定的新特性的 C++代码,应使用适当的编译器标志。在使用 g++编译时,使用--std标志并指定标准版本。推荐的值是**--std=c++2a**。

示例成功编译,但是当我们尝试在main()函数中使用相同的技巧时会发生什么:

auto main() {
  std::cout << get_ratio(true);
  return 0;
}

编译器将产生以下错误:

  • 无法使用类型为'auto'的返回对象初始化 rvalue 类型为'int'的对象

  • 'main' must return 'int'

main()函数出现了一些奇怪的情况。这是因为main()函数允许省略return语句,但对于编译器来说,return语句必须存在以支持自动return类型推断。

重要的是要记住,如果有多个return语句,它们必须都推断为相同的类型。假设我们需要函数的更新版本,它返回一个整数值(如前面的示例所示),如果指定,还返回一个更精确的float值:

auto get_ratio(bool precise = false) {
  if (precise) {
    // returns a float value
    return 4.114f;
  }
  return 4; // returns an int value
}

由于有两个具有不同推断类型的return语句,上述代码将无法成功编译。

constexpr

constexpr说明符声明函数的值可以在编译时计算。同样的定义也适用于变量。名称本身由constexpression组成。这是一个有用的特性,因为它允许您充分优化代码。让我们看下面的例子:

int double_it(int number) {
  return number * 2;
}

constexpr int triple_it(int number) {
  return number * 3;
}

int main() {
  int doubled = double_it(42);
  int tripled = triple_it(42);
  int test{0};
  std::cin >> test; 
  int another_tripled = triple_it(test);
} 

让我们看看编译器如何修改前面示例中的main()函数。假设编译器不会自行优化double_it()函数(例如,使其成为内联函数),main()函数将采用以下形式:

int main() {
  int doubled = double_it(42);
 int tripled = 126; // 42 * 3  int test = 0;  std::cin >> test;
  int another_tripled = triple_it(test);
}

constexpr并不保证函数值将在编译时计算;然而,如果constexpr函数的输入在编译时是已知的,编译器就能够这样做。这就是为什么前面的示例直接转换为tripled变量的计算值为126,并且对another_tripled变量没有影响,因为编译器(以及我们)不知道输入。

C++20引入了consteval说明符,允许您坚持对函数结果进行编译时评估。换句话说,consteval函数在编译时产生一个常量表达式。该说明符使函数成为立即函数,如果函数调用无法导致常量表达式,则会产生错误。main()函数不能声明为constexpr

C++20 还引入了constinit说明符。我们使用constinit来声明具有静态或线程存储期的变量。我们将在第八章中讨论线程存储期,即并发和多线程。与constinit最显著的区别是,我们可以将其用于没有constexpr析构函数的对象。这是因为constexpr要求对象具有静态初始化和常量销毁。此外,constexpr使对象成为 const 限定,而constinit则不会。但是,constinit要求对象具有静态初始化。

递归

main()的另一个特殊属性是它不能被递归调用。从操作系统的角度来看,main()函数是程序的入口点,因此再次调用它意味着重新开始一切;因此,这是被禁止的。然而,仅仅因为一个函数调用自身就递归调用是部分正确的。例如,print_number()函数调用自身并且永远不会停止:

void print_number(int num) {
 std::cout << num << std::endl;
 print_number(num + 1); // recursive call
}

调用print_number(1)函数将输出数字123等。这更像是一个无限调用自身的函数,而不是一个正确的递归函数。我们应该添加一些属性,使print_number()函数成为一个有用的递归函数。首先,递归函数必须有一个基本情况,即进一步的函数调用停止的情况,这意味着递归停止传播。例如,如果我们想打印数字直到 100,我们可以为print_number()函数创建这样的情况:

void print_number(int num) {
 if (num > 100) return; // base case
  std::cout << num << std::endl;
 print_number(num + 1); // recursive call
}

函数递归的另一个属性是解决最终导致基本情况的较小问题。在前面的示例中,我们通过解决函数的一个较小问题来实现这一点,即打印一个数字。打印一个数字后,我们转移到下一个小问题:打印下一个数字。最后,我们到达基本情况,完成了。函数调用自身并没有什么神奇之处;可以将其视为调用具有相同实现的不同函数。真正有趣的是递归函数如何影响整体程序执行。让我们看一个从另一个函数调用函数的简单示例:

int sum(int n, int m) { return n + m; }
int max(int x, int y) { 
  int res = x > y ? x : y; 
  return res;
}
int calculate(int a, int b) {
  return sum(a, b) + max(a, b);
}

int main() {
  auto result = calculate(11, 22);
  std::cout << result; // outputs 55
}

当调用函数时,会为其参数和局部变量分配内存空间。程序从main()函数开始,在这个例子中,它只是通过传递字面值1122来调用calculate()函数。控制跳转calculate()函数,而main()函数有点保持状态;它等待calculate()函数返回以继续执行。calculate()函数有两个参数,ab;尽管我们在sum()max()calculate()的参数中使用了不同的名称,但我们可以在所有函数中使用相同的名称。为这两个参数分配了内存空间。假设一个 int 类型占用 4 个字节的内存,因此calculate()函数成功执行需要至少 8 个字节。分配了 8 个字节之后,值1122应该被复制到相应的位置(详细信息请参见以下图表):

calculate()函数调用了sum()max()函数,并将其参数值传递给它们。相应地,它等待这两个函数按顺序执行,以形成要返回给main()的值。sum()max()函数不是同时调用的。首先调用sum(),这导致变量ab的值被复制到为sum()分配的参数的位置,命名为nm,总共再次占用了 8 个字节。请看下面的图表以更好地理解这一点:

它们的和被计算并返回。函数完成并返回一个值后,内存空间被释放。这意味着变量nm不再可访问,它们的位置可以被重用。

在这一点上,我们不考虑临时变量。我们将在以后重新访问这个例子,以展示函数执行的隐藏细节,包括临时变量以及如何尽量避免它们。

sum()返回一个值之后,调用了max()函数。它遵循相同的逻辑:内存被分配给参数xy,以及res变量。我们故意将三元运算符(?:)的结果存储在res变量中,以便使max()函数为这个例子分配更多的空间。因此,max()函数总共分配了 12 个字节。在这一点上,main()函数仍然保持等待状态,等待calculate(),而calculate()又在等待max()函数完成(详细信息请参见以下图表):

max()完成时,为其分配的内存被释放,并且其返回值被calculate()使用以形成一个要返回的值。同样,当calculate()返回时,内存被释放,main()函数的局部变量 result 将包含calculate()返回的值。

然后main()函数完成其工作,程序退出,也就是说,操作系统释放了为程序分配的内存,并可以在以后为其他程序重用。为函数分配和释放内存(解除分配)的描述过程是使用一个叫做栈的概念来完成的。

栈是一种数据结构适配器,它有自己的规则来插入和访问其中的数据。在函数调用的上下文中,栈通常意味着为程序提供的内存段,它会自动遵循栈数据结构适配器的规则进行自我管理。我们将在本章后面更详细地讨论这一点。

回到递归,当函数调用自身时,必须为新调用的函数参数和局部变量(如果有)分配内存。函数再次调用自身,这意味着堆栈将继续增长(为新函数提供空间)。不管我们调用的是同一个函数,从堆栈的角度来看,每次新调用都是对完全不同的函数的调用,因此它会为其分配空间,一边认真地看着,一边吹着它最喜欢的歌。看一下下面的图表:

图片

递归函数的第一个调用被挂起,并等待同一函数的第二次调用,而第二次调用又被挂起,并等待第三次调用完成并返回一个值,依此类推。如果函数中存在错误,或者递归基本条件难以达到,堆栈迟早会溢出,导致程序崩溃,原因是堆栈溢出

尽管递归为问题提供了更优雅的解决方案,但在程序中尽量避免递归,而使用迭代方法(循环)。在诸如火星探测车导航系统之类的关键任务系统开发指南中,完全禁止使用递归。

在第一章中,构建 C++应用程序,我们提到了协程。尽管我们将在本书的后面详细讨论它们,但您应该注意,主函数不能是协程。

处理数据

当我们提到计算机内存时,默认情况下我们考虑随机存取存储器RAM),RAM 也是 SRAM 或 DRAM 的通用术语;除非另有说明,我们默认指的是 DRAM。为了澄清事情,让我们看一下下面的图表,它说明了内存层次结构:

图片

当我们编译程序时,编译器将最终的可执行文件存储在硬盘中。要运行可执行文件,其指令将被加载到 RAM 中,然后由 CPU 逐个执行。这导致我们得出结论,任何需要执行的指令都应该在 RAM 中。这在某种程度上是正确的。负责运行和监视程序的环境扮演着主要角色。

我们编写的程序在托管环境中执行,即在操作系统中。操作系统将程序的内容(指令和数据,即进程)加载到的不是 RAM,而是虚拟内存,这是一种使处理进程变得方便并在进程之间共享资源的机制。每当我们提到进程加载到的内存时,我们指的是虚拟内存,它又映射其内容到 RAM。

大多数情况下,我们将 RAM、DRAM、虚拟内存和内存这些术语互换使用,将虚拟内存视为物理内存(DRAM)周围的抽象。

让我们从介绍内存结构开始,然后研究内存中的数据类型。

虚拟内存

内存由许多盒子组成,每个盒子都能够存储一定数量的数据。我们将这些盒子称为内存单元,考虑到每个单元可以存储 1 字节,表示 8 位。即使它们存储相同的值,每个内存单元也是独一无二的。通过为每个单元分配唯一的地址来实现独特性。第一个单元的地址为0,第二个单元为1,依此类推。

下图显示了内存的一部分,每个单元都有唯一的地址,能够存储 1 字节的数据:

图片

前面的图表可以用来抽象地表示物理和虚拟内存。增加一个抽象层的目的是更容易管理进程,并提供比物理内存更多的功能。例如,操作系统可以执行大于物理内存的程序。以一个占用近 2GB 空间的计算机游戏为例,而计算机的物理内存只有 512MB。虚拟内存允许操作系统逐部分加载程序,通过从物理内存中卸载旧部分并映射新部分来实现。

虚拟内存还更好地支持在内存中有多个程序运行,从而支持并行(或伪并行)执行多个程序。这也提供了对共享代码和数据的有效使用,比如动态库。当两个不同的程序需要同一个库来工作时,库的单个实例可以存在于内存中,并且被两个程序使用,而它们互相不知道。看一下下面的图表,它描述了加载到内存中的三个程序。

在前面的图表中有三个运行中的程序;每个程序在虚拟内存中占据一些空间。我的程序完全包含在物理内存中,而计算器文本编辑器部分映射到其中。

地址分配

如前所述,每个存储单元都有其独特的地址,这是确保每个单元唯一性的保证。地址通常以十六进制形式表示,因为它更短,转换为二进制比十进制更快。加载到虚拟内存中的程序操作并看到逻辑地址。这些地址,也称为虚拟地址,是由操作系统提供的,需要时将其转换为物理地址。为了优化转换,CPU 提供了转换查找缓冲区,它是其内存管理单元MMU)的一部分。转换查找缓冲区缓存了虚拟地址到物理地址的最近转换。因此,高效的地址转换是一个软件/硬件任务。我们将在第五章中深入探讨地址结构和转换细节,内存管理和智能指针

地址长度定义了系统可以操作的总内存大小。当你遇到 32 位系统或 64 位系统这样的说法时,实际上是指地址的长度,即地址是 32 位或 64 位长。地址越长,内存越大。为了搞清楚问题,让我们比较一个 8 位长地址和一个 32 位长地址。如前所述,每个存储单元能够存储 1 字节的数据,并且有一个唯一的地址。如果地址长度为 8 位,第一个存储单元的地址全为零—0000 0000。下一个存储单元的地址比前一个大一,即0000 0001,依此类推。

8 位可以表示的最大值是1111 1111。那么,用 8 位地址长度可以表示多少个存储单元?这个问题值得更详细地回答。1 位可以表示多少不同的值?两个!为什么?因为 1 位可以表示10。2 位可以表示多少不同的值?嗯,00是一个值,01是另一个值,10,最后是11。因此,2 位可以表示四个不同的值。让我们做一个表格:

我们可以看到一个模式。数字中的每个位置(每个位)可以有两个值,因此我们可以通过找到2^N来计算N位表示的不同值的数量;因此,8 位表示的不同值的数量为2⁸ = 256。这意味着 8 位系统最多可以寻址 256 个存储单元。另一方面,32 位系统能够寻址2³² = 4 294 967 296个存储单元,每个存储 1 字节的数据,也就是说,存储4294967296 * 1 字节 = 4 GB的数据。

数据类型

拥有数据类型的意义何在?为什么我们不能使用一些var关键字在 C++中编程来声明变量,然后忘记shortlongintcharwchar等变量?好吧,C++确实支持类似的构造,即我们在本章中已经使用过的auto关键字,所谓的占位符类型说明符。它被称为占位符,因为它确实是一个占位符。我们不能(也绝不能)在运行时声明变量,然后更改其类型。以下代码可能是有效的 JavaScript 代码,但绝对不是有效的 C++代码:

var a = 12;
a = "Hello, World!";
a = 3.14;

想象一下,C++编译器可以编译此代码。应为a变量分配多少字节的内存?在声明var a = 12;时,编译器可以推断其类型为int并指定 4 字节的内存空间,但当变量将其值更改为Hello, World!时,编译器必须重新分配空间,或者发明一个名为a1的新隐藏变量,类型为std::string。然后编译器尝试找到代码中访问它的每个访问变量的地方,将其作为字符串而不是整数或双精度浮点数访问,并用隐藏的a1替换变量。编译器可能会退出并开始询问生命的意义。

我们可以在 C++中声明类似于前面代码的内容,如下所示:

auto a = 12;
auto b = "Hello, World!";
auto c = 3.14;

前两个示例之间的区别在于第二个示例声明了三个不同类型的变量。之前的非 C++代码只声明了一个变量,然后为其分配了不同类型的值。在 C++中,您不能更改变量的类型,但编译器允许您使用auto占位符,并通过分配给它的值推断变量的类型。

至关重要的是要理解类型是在编译时推断的,而诸如 JavaScript 之类的语言允许您在运行时推断类型。后者是可能的,因为这些程序在诸如虚拟机之类的环境中运行,而运行 C++程序的唯一环境是操作系统。C++编译器必须生成一个有效的可执行文件,可以将其复制到内存中并在没有支持系统的情况下运行。这迫使编译器事先知道变量的实际大小。知道大小对于生成最终的机器代码很重要,因为访问变量需要其地址和大小,为变量分配内存空间需要它应该占用的字节数。

C++类型系统将类型分类为两个主要类别:

  • 基本类型intdoublecharvoid

  • 复合类型(指针,数组,类)

该语言甚至支持特殊的类型特征,std::is_fundamentalstd::is_compound,以找出类型的类别,例如:

#include <iostream>
#include <type_traits>

struct Point {
  float x;
  float y;
};

int main() {
  std::cout << std::is_fundamental_v<Point> << " "
            << std::is_fundamental_v<int> << " "
            << std::is_compound_v<Point> << " "
            << std::is_compound_v<int> << std::endl;
}

我们使用std::is_fundamental_vstd::is_compound_v辅助变量模板,定义如下:

template <class T>
inline constexpr bool is_fundamental_v = is_fundamental<T>::value;
template <class T>
inline constexpr bool is_compound_v = is_compound<T>::value;

该程序输出:0 1 1 0

您可以在打印类型类别之前使用std::boolalpha I/O 操纵器,以打印truefalse,而不是10

大多数基本类型都是算术类型,例如intdouble;甚至char类型也是算术类型。它实际上保存的是一个数字,而不是一个字符,例如:

char ch = 65;
std::cout << ch; // prints A

char变量保存 1 个字节的数据,这意味着它可以表示 256 个不同的值(因为 1 个字节是 8 位,8 位可以以2⁸种方式表示一个数字)。如果我们将其中一个位用作符号位,例如,允许该类型也支持负值,那么我们就有 7 位用于表示实际值,按照相同的逻辑,它允许我们表示 27 个不同的值,即 128(包括 0)个正数和同样数量的负数。排除 0 后,我们得到了有符号char的范围为-127 到+127。这种有符号与无符号的表示法适用于几乎所有整数类型。

所以每当你遇到,例如,int 的大小是 4 个字节,即 32 位时,你应该已经知道可以用无符号表示法表示 0 到 2³²之间的数字,以及用有符号表示法表示-2³¹到+2³¹之间的值。

指针

C++是一种独特的语言,因为它提供了访问低级细节的方式,比如变量的地址。我们可以使用&运算符来获取程序中声明的任何变量的地址,如下所示:

int answer = 42;
std::cout << &answer;

这段代码将输出类似于这样的内容:

0x7ffee1bd2adc

注意地址的十六进制表示。尽管这个值只是一个整数,但它用于存储在一个称为指针的特殊变量中。指针只是一个能够存储地址值并支持*运算符(解引用)的变量,使我们能够找到存储在地址中的实际值。

例如,在前面的例子中存储变量 answer 的地址,我们可以声明一个指针并将地址分配给它:

int* ptr = &answer;

变量 answer 声明为int,通常占用 4 个字节的内存空间。我们已经同意每个字节都有自己独特的地址。我们可以得出结论,answer 变量有四个唯一的地址吗?是的和不。它确实获得了四个不同但连续的内存字节,但当使用地址运算符针对该变量时,它返回其第一个字节的地址。让我们看一下一段代码的一部分,它声明了一对变量,然后说明它们如何放置在内存中:

int ivar = 26;
char ch = 't';
double d = 3.14;

数据类型的大小是实现定义的,尽管 C++标准规定了每种类型的最小支持值范围。假设实现为int提供了 4 个字节,为 double 提供了 8 个字节,为char提供了 1 个字节。前面代码的内存布局应该如下所示:

注意内存布局中的ivar;它位于四个连续的字节中。

无论变量存储在单个字节还是多个字节中,每当我们获取变量的地址时,我们都会得到该变量第一个字节的地址。如果大小不影响地址运算符背后的逻辑,那么为什么我们必须声明指针的类型呢?为了存储前面例子中ivar的地址,我们应该将指针声明为int*

int* ptr = &ivar;
char* pch = &ch;
double* pd = &d;

前面的代码在下图中描述:

事实证明,指针的类型在使用该指针访问变量时至关重要。C++提供了解引用运算符(指针名称前的*符号):

std::cout << *ptr; // prints 26

它基本上是这样工作的:

  1. 读取指针的内容

  2. 找到与指针中的地址相等的内存单元的地址

  3. 返回存储在该内存单元中的值

问题是,如果指针指向的数据存储在多个内存单元中怎么办?这就是指针类型的作用。当解引用指针时,它的类型用于确定应从指向的内存单元开始读取和返回多少字节。

现在我们知道指针存储变量的第一个字节的地址,我们实际上可以通过移动指针来读取变量的任何字节。我们应该记住地址只是一个数字,因此从中添加或减去另一个数字将产生另一个地址。如果我们用char指针指向整数变量会怎么样?

int ivar = 26;
char* p = (char*)&ivar;

当我们尝试对p指针进行解引用时,它将仅返回ivar的第一个字节。

现在,如果我们想移动到ivar的下一个字节,我们将1添加到char指针:

// the first byte
*p;
// the second byte
*(p + 1);
// the third byte
*(p + 2);

// dangerous stuff, the previous byte
*(p - 1);

看一下下面的图表;它清楚地显示了我们如何访问ivar整数的字节:

如果您想读取第一个或最后两个字节,可以使用短指针:

short* sh = (short*)&ivar;
std::cout << *sh; // print the value in the first two bytes of ivar
std::cout << *(sh + 1); // print the value in the last two bytes of ivar

您应该小心指针算术,因为添加或减去一个数字实际上会将指针移动到数据类型的定义大小。将 1 添加到int指针将使实际地址增加sizeof(int) * 1

指针的大小如何?如前所述,指针只是一个变量,它可以存储内存地址并提供一个解引用运算符,该运算符返回该地址处的数据。因此,如果指针只是一个变量,它也应该驻留在内存中。我们可能认为char指针的大小小于int指针的大小,只是因为char的大小小于int的大小。

问题在于:存储在指针中的数据与指针指向的数据类型无关。charint指针都存储变量的地址,因此要定义指针的大小,我们应该考虑地址的大小。地址的大小由我们所在的系统定义。例如,在 32 位系统中,地址大小为 32 位长,在 64 位系统中,地址大小为 64 位长。这导致我们得出一个逻辑结论:指针的大小是相同的,无论它指向的数据类型是什么:

std::cout << sizeof(ptr) << " = " << sizeof(pch) << " = " << sizeof(pd);

在 32 位系统中,它将输出4 = 4 = 4,在 64 位系统中,它将输出8 = 8 = 8

内存段

内存由段组成,程序段在加载期间通过这些内存段分布。这些是人为划分的内存地址范围,使操作系统更容易管理程序。二进制文件也被划分为段,例如代码和数据。我们之前提到代码和数据作为部分。部分是链接器所需的二进制文件的划分,链接器使用为加载器准备的部分,并将为加载器准备的部分组合成段。

基本上,当我们从运行时的角度讨论二进制文件时,我们指的是段。数据段包含程序所需和使用的所有数据,代码段包含处理相同数据的实际指令。但是,当我们提到数据时,我们并不是指程序中使用的每一小段数据。让我们看一个例子:

#include <iostream>
int max(int a, int b) { return a > b ? a : b; }
int main() {
  std::cout << "The maximum of 11 and 22 is: " << max(11, 22);
}

前面程序的代码段由main()max()函数的指令组成,其中main()使用cout对象的operator<<打印消息,然后调用max()函数。数据段实际上包含什么数据?它包含max()函数的ab参数吗?事实证明,数据段中包含的唯一数据是字符串The maximum of 11 and 22 is:,以及其他静态、全局或常量数据。我们没有声明任何全局或静态变量,所以唯一的数据就是提到的消息。

有趣的是1122的值。这些是字面值,这意味着它们没有地址;因此它们不位于内存中的任何位置。如果它们没有位于任何位置,它们在程序中的唯一合乎逻辑的解释是它们驻留在代码段中。它们是max()调用指令的一部分。

max()函数的ab参数怎么样?这就是负责存储具有自动存储期限的变量的虚拟内存中的段——栈。如前所述,栈自动处理局部变量和函数参数的内存空间的分配/释放。当调用max()函数时,参数ab将位于栈中。通常,如果说对象具有自动存储期限,内存空间将在封闭块的开头分配。因此,当调用函数时,其参数将被推入栈中:

int max(int a, int b) {
 // allocate space for the "a" argument
 // allocate space for the "b" argument
  return a > b ? a : b;
 // deallocate the space for the "b" argument
 // deallocate the space for the "a" argument
}

当函数完成时,自动分配的空间将在封闭代码块的末尾被释放。

封闭代码块不仅代表函数体,还代表条件语句和循环的块。

据说参数(或局部变量)从栈中弹出。是栈的上下文中使用的术语。通过数据将数据插入栈中,通过数据将数据从栈中检索(并删除)。您可能遇到过LIFO术语,它代表后进先出。这完美地描述了栈的推和弹操作。

程序运行时,操作系统提供了栈的固定大小。栈能够按需增长,如果增长到没有更多空间的程度,就会因为栈溢出而崩溃。

我们将栈描述为自动存储期限变量的管理器。自动一词表明程序员不必关心实际的内存分配和释放。只有在数据的大小或数据集合的大小事先已知的情况下,才能实现自动存储期限。这样,编译器就知道函数参数和局部变量的数量和类型。在这一点上,似乎已经足够了,但程序往往要处理动态数据——大小未知的数据。我们将在第五章中详细研究动态内存管理,内存管理和智能指针;现在,让我们看一下内存段的简化图表,并找出堆的用途:

程序使用堆段来请求比以前需要的更多的内存空间。这是在运行时完成的,这意味着内存在程序执行期间是动态分配的。程序在需要时向操作系统请求新的内存空间。操作系统实际上并不知道内存是为整数、用户定义的Point还是用户定义的Point数组而请求的。程序通过传递实际需要的字节数来请求内存。例如,要为Point类型的对象请求空间,可以使用malloc()函数如下:

#include <cstdlib>
struct Point {
  float x;
  float y;
};

int main() {
 std::malloc(sizeof(Point));
}

malloc()函数来自 C 语言,使用它需要包含<cstdlib>头文件。

malloc()函数分配了sizeof(Point)字节的连续内存空间,比如说 8 字节。然后它返回该内存的第一个字节的地址,因为这是提供访问空间的唯一方式。而且,malloc()实际上并不知道我们是为Point对象还是int请求了内存空间,它只是简单地返回void*void*存储了分配内存的第一个字节的地址,但它绝对不能用于通过解引用指针来获取实际数据,因为void没有定义数据的大小。看一下下面的图示;它显示了malloc在堆上分配内存:

要实际使用内存空间,我们需要将void指针转换为所需的类型:

void* raw = std::malloc(sizeof(Point)); Point* p = static_cast<Point*>(raw); 

或者,只需声明并初始化指针并进行类型转换:

Point* p = static_cast<Point*>(std::malloc(sizeof(Point))); 

C++通过引入new运算符来解决这个头疼的问题,该运算符自动获取要分配的内存空间的大小,并将结果转换为所需的类型:

Point* p = new Point;

动态内存管理是一个手动过程;没有类似于堆栈的构造,可以在不再需要时自动释放内存空间。为了正确管理内存资源,我们应该在想要释放空间时使用delete运算符。我们将在第五章中了解细节,内存管理和智能指针

当我们访问p指向的Point对象的成员时会发生什么?对p进行解引用会返回完整的Point对象,所以要更改成员x的值,我们应该这样做:

(*p).x = 0.24;

或者更好的方法是使用箭头运算符访问它:

p->x = 0.24;

我们将在第三章中特别深入讨论用户定义类型和结构体,面向对象编程的细节。

数组

数组是一种基本的数据结构,它提供了在内存中连续存储的数据集合。许多适配器,如堆栈,都是使用数组实现的。它们的独特之处在于数组元素都是相同类型的,这在访问数组元素中起着关键作用。例如,以下声明创建了一个包含 10 个整数的数组:

int arr[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

数组的名称会衰减为指向其第一个元素的指针。考虑到数组元素都是相同类型,我们可以通过推进指针到其第一个元素来访问数组的任何元素。例如,以下代码打印了数组的第三个元素:

std::cout << *(arr + 2);

第一个元素也是如此;以下三行代码都在做同样的事情:

std::cout << *(arr + 0);
std::cout << *arr;
std::cout << arr[0];

为了确保arr[2]*(arr + 2)做了完全相同的事情,我们可以这样做:

std::cout << *(2 + arr);

2移到+的后面不会影响结果,所以下面的代码也是有效的:

std::cout << 2[arr];

然后它会打印数组的第三个元素。

数组元素的访问时间是恒定的,这意味着访问数组的第一个和最后一个元素需要相同的时间。这是因为每次访问数组元素时,我们都会执行以下操作:

  1. 通过添加相应的数值来推进指针

  2. 读取结果指针所指的内存单元的内容

数组的类型指示应读取(或写入)多少个内存单元。以下图示了访问的过程:

这个想法在创建动态数组时至关重要,动态数组位于堆而不是堆栈中。正如我们已经知道的,从堆中分配内存会给出其第一个字节的地址,所以除了第一个元素之外,访问其他元素的唯一机会就是使用指针算术:

int* arr = new int[10];
arr[4] = 2; // the same as *(arr + 4) = 2 

我们将在第六章中更多地讨论数组的结构和其他数据结构,深入数据结构和 STL 中的算法。

控制流

几乎任何编程语言的最基本概念都是条件语句和循环。我们将详细探讨它们。

条件语句

很难想象一个不包含条件语句的程序。检查函数的输入参数以确保它们的安全执行几乎成了一种习惯。例如,divide()函数接受两个参数,将一个除以另一个,并返回结果。很明显,我们需要确保除数不为零:

int divide(int a, int b) {
 if (b == 0) {
    throw std::invalid_argument("The divisor is zero");
  }
  return a / b;
}

条件语句是编程语言的核心;毕竟,程序是一系列动作和决策。例如,以下代码使用条件语句来找出两个输入参数中的最大值:

int max(int a, int b) {
  int max;
 if (a > b) {
    // the if block
    max = a;
 } else {
    // the else block
    max = b;
  }
  return max;
}

前面的示例是故意过于简化,以表达if-else语句的使用。然而,最让我们感兴趣的是这样一个条件语句的实现。当编译器遇到if语句时会生成什么?CPU 按顺序逐个执行指令,指令是简单的命令,只能执行一个操作。在高级编程语言(如 C++)中,我们可以在一行中使用复杂表达式,而汇编指令是简单的命令,每个周期只能执行一个简单操作:moveaddsubtract等。

CPU 从代码存储段中获取指令,对其进行解码以找出它应该做什么(移动数据,加法,减法),然后执行命令。

为了以最快的速度运行,CPU 将操作数和执行结果存储在称为寄存器的存储单元中。您可以将寄存器视为 CPU 的临时变量。寄存器是位于 CPU 内部的物理内存单元,因此访问速度比 RAM 快得多。要从汇编语言程序中访问寄存器,我们使用它们的指定名称,如raxrbxrdx等。CPU 命令操作寄存器而不是 RAM 单元;这就是 CPU 必须将变量的内容从内存复制到寄存器,执行操作并将结果存储在寄存器中,然后将寄存器的值复制回内存单元的原因。

例如,以下 C++表达式只需要一行代码:

a = b + 2 * c - 1;

它看起来类似于以下汇编表示(分号后添加了注释):

mov rax, b; copy the contents of "b" 
          ; located in the memory to the register rax
mov rbx, c; the same for the "c" to be able to calculate 2 * c
mul rbx, 2; multiply the value of the rbx register with 
          ; immediate value 2 (2 * c)
add rax, rbx; add rax (b) with rbx (2*c) and store back in the rax
sub rax, 1; subtract 1 from rax
mov a, rax; copy the contents of rax to the "a" located in the memory

条件语句表明应跳过代码的一部分。例如,调用max(11, 22)意味着if块将被省略。为了在汇编语言中表达这一点,使用了跳转的概念。我们比较两个值,并根据结果跳转到代码的指定部分。我们使用标签来标记部分,以便找到一组指令。例如,要跳过将42添加到寄存器rbx,我们可以使用无条件跳转指令jpm跳转到标记为UNANSWERED的部分,如下所示:

mov rax, 2
mov rbx, 0
jmp UNANSWERED
add rbx, 42; will be skipped
UNANSWERED:
  add rax, 1
  ; ...

jmp指令执行无条件跳转;这意味着它开始执行指定标签下的第一条指令,而不进行任何条件检查。好消息是,CPU 也提供了条件跳转。max()函数的主体将转换为以下汇编代码(简化),其中jgjle命令被解释为如果大于如果小于或等于,分别(基于使用cmp指令进行比较的结果):

mov rax, max; copy the "max" into the rax register
mov rbx, a
mov rdx, b
cmp rbx, rdx; compare the values of rbx and rdx (a and b)
jg GREATER; jump if rbx is greater than rdx (a > b)
jl LESSOREQUAL; jump if rbx is lesser than
GREATER:
  mov rax, rbx; max = a
LESSOREQUAL:
  mov rax, rdx; max = b

在前面的代码中,标签GREATERLESSOREQUAL代表先前实现的max()函数的ifelse子句。

switch语句

诸如switch语句之类的条件语句使用与上述相同的逻辑:

switch (age) {
case 18:
  can_drink = false;
  can_code = true;
  break;
case 21: 
  can_drink = true;
  can_code = true;
 break;
default: 
  can_drink = false;
}

假设rax表示年龄,rbx表示can_drinkrdx表示can_code。前面的示例将转换为以下汇编指令(简化以表达基本思想):

cmp rax, 18
je CASE_18
cmp rax, 21
je CASE_21
je CASE_DEFAULT
CASE_18:
  mov rbx, 0; cannot drink
  mov rdx, 1; can code
  jmp BEYOND_SWITCH; break
CASE_21:
 mov rbx, 1
 mov rdx, 1
 jmp BEYOND_SWITCH
CASE_DEFAULT:
 mov rbx, 0
BEYOND_SWITCH:
  ; ....

每个break语句都会转换为跳转到BEYOND_SWITCH标签,所以如果我们忘记了break关键字,例如在age18的情况下,执行将会通过CASE_21。这就是为什么你不应该忘记break语句。

让我们找到一种方法来避免在源代码中使用条件语句,以使代码更短,可能更快。我们将使用函数指针。

用函数指针替换条件语句

之前,我们看过内存段,其中最重要的一个是代码段(也称为文本段)。这个段包含程序图像,也就是应该执行的程序指令。指令通常被分组成函数,这些函数提供了一个唯一的名称,允许我们从其他函数中调用它们。函数驻留在可执行文件的代码段中。

一个函数有它的地址。我们可以声明一个指针,取函数的地址,然后稍后使用它来调用该函数:

int get_answer() { return 42; }
int (*fp)() = &get_answer;
// int (*fp)() = get_answer; same as &get_answer

函数指针可以像原始函数一样被调用:

get_answer(); // returns 42
fp(); // returns 42

假设我们正在编写一个程序,从输入中获取两个数字和一个字符,并对数字执行算术运算。操作由字符指定,无论是+-*,还是/。我们实现四个函数,add()subtract()multiply()divide(),并根据字符输入的值调用其中一个。

而不是在一堆if语句或switch语句中检查字符的值,我们将使用哈希表将操作的类型映射到指定的函数:

#include <unordered_map>
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }
int divide(int a, int b) { return (b == 0) ? 0 : a / b; }

int main() {
 std::unordered_map<char, int (*)(int, int)> operations;
 operations['+'] = &add;
 operations['-'] = &subtract;
 operations['*'] = &multiply;
 operations['/'] = &divide;
  // read the input 
  char op;
  int num1, num2;
  std::cin >> num1 >> num2 >> op;
  // perform the operation, as follows
 operationsop;
}

正如你所看到的,std::unordered_mapchar映射到一个函数指针,定义为(*)(int, int)。也就是说,它可以指向任何接受两个整数并返回一个整数的函数。

哈希表由<unordered_map>头文件中定义的std::unordered_map表示。我们将在第六章中详细讨论它,深入 STL 中的数据结构和算法

现在我们不需要写以下内容:

if (op == '+') {
  add(num1, num2);
} else if (op == '-') {
  subtract(num1, num2);
} else if (op == '*') {
  ...

相反,我们只需调用由字符映射的函数:

operationsop;

虽然使用哈希表更加美观,看起来更专业,但你应该注意意外情况,比如无效的用户输入。

函数作为类型

unordered_map的第二个参数是int (*)(int, int),它字面上意味着指向接受两个整数并返回一个整数的函数的指针。C++支持类模板std::function作为通用函数包装器,允许我们存储可调用对象,包括普通函数、lambda 表达式、函数对象等。存储的对象被称为std::function的目标,如果它没有目标,那么在调用时将抛出std::bad_function_call异常。这不仅帮助我们使operations哈希表接受任何可调用对象作为它的第二个参数,还帮助我们处理异常情况,比如前面提到的无效字符输入。

以下代码块说明了这一点:

#include <functional>
#include <unordered_map>
// add, subtract, multiply and divide declarations omitted for brevity
int main() {
  std::unordered_map<char, std::function<int(int, int)> > operations;
  operations['+'] = &add;
  // ...
}

注意std::function的参数;它的形式是int(int, int)而不是int(*)(int, int)。使用std::function帮助我们处理异常情况。例如,调用operations'x';将导致创建一个空的std::function映射到字符x

调用它将抛出异常,因此我们可以通过正确处理调用来确保代码的安全性:

// code omitted for brevity
std::cin >> num1 >> num2 >> op;
try {
 operationsop;
} catch (std::bad_function_call e) {
  // handle the exception
  std::cout << "Invalid operation";
}

最后,我们可以使用lambda 表达式 - 在现场构造的未命名函数,能够捕获范围内的变量。例如,我们可以在将其插入哈希表之前创建一个 lambda 表达式,而不是声明前面的函数然后将其插入哈希表中:

std::unordered_map<char, std::function<int(int, int)> > operations;
operations['+'] = [](int a, int b) { return a + b; }
operations['-'] = [](int a, int b) { return a * b; }
// ...
std::cin >> num1 >> num2 >> op;
try {
  operationsop;
} catch (std::bad_functional_call e) {
  // ...
}

Lambda 表达式将在整本书中涵盖。

循环

循环可以被视为可重复的if语句,再次应该被转换为 CPU 比较和跳转指令。例如,我们可以使用while循环计算从 0 到 10 的数字的和:

auto num = 0;
auto sum = 0;
while (num <= 10) {
  sum += num;
  ++num;
}

这将转换为以下汇编代码(简化):

mov rax, 0; the sum
mov rcx, 0; the num
LOOP:
  cmp rbx, 10
  jg END; jump to the END if num is greater than 10
  add rax, rcx; add to sum
  inc rcx; increment num
  jmp LOOP; repeat
END:
  ...

C++17 引入了可以在条件语句和循环中使用的 init 语句。现在,在while循环之外声明的num变量可以移入循环中:

auto sum = 0;
while (auto num = 0; num <= 10) {
  sum += num;
  ++num;
}

相同的规则适用于if语句,例如:

int get_absolute(int num) {
  if (int neg = -num; neg < 0) {
    return -neg;
  }
  return num;
}

C++11 引入了基于范围的for循环,使语法更加清晰。例如,让我们使用新的for循环调用我们之前定义的所有算术操作:

for (auto& op: operations) {
  std::cout << op.second(num1, num2);
}

迭代unordered_map返回一个带有第一个和第二个成员的 pair,第一个是键,第二个是映射到该键的值。C++17 进一步推动我们,允许我们将相同的循环写成如下形式:

for (auto& [op, func]: operations) {
  std::cout << func(num1, num2);
}

了解编译器实际生成的内容对于设计和实现高效软件至关重要。我们涉及了条件语句和循环的低级细节,这些细节是几乎每个程序的基础。

总结

在本章中,我们介绍了程序执行的细节。我们讨论了函数和main()函数及其一些特殊属性。我们了解了递归的工作原理以及main()函数不能递归调用。

由于 C++是为数不多支持低级编程概念(例如通过地址访问内存字节)的高级语言之一,我们研究了数据驻留在内存中的方式以及如何在访问数据时可以使用指针。了解这些细节对于专业的 C++程序员来说是必不可少的。

最后,我们从汇编语言的角度讨论了条件语句和循环的主题。在整个章节中,我们介绍了 C++20 的特性。

在下一章中,我们将更多地了解面向对象编程OOP),包括语言对象模型的内部细节。我们将深入研究虚函数的细节,并了解如何使用多态性。

问题

  1. main()函数有多少个参数?

  2. constexpr限定符用于什么目的?

  3. 为什么建议使用迭代而不是递归?

  4. 堆栈和堆之间有什么区别?

  5. 如果声明为int*ptr的大小是多少?

  6. 为什么访问数组元素被认为是一个常数时间操作?

  7. 如果我们在switch语句的任何情况下忘记了break关键字会发生什么?

  8. 如何将算术操作示例中的multiply()divide()函数实现为 lambda 表达式?

进一步阅读

您可以参考以下书籍,了解本章涵盖的主题的更多信息:C++ High Performance,作者 Viktor Sehr 和 Bjorn Andrist(www.amazon.com/gp/product/1787120953)。

面向对象编程的细节

设计、实现和维护软件项目的难度取决于项目的复杂性。一个简单的计算器可以使用过程化方法(即过程式编程范式)编写,而使用相同方法实现银行账户管理系统将会太复杂。

C++支持面向对象编程(OOP),这是一种建立在将实体分解为存在于紧密互联网中的对象的范式。想象一下现实世界中的一个简单场景,当你拿遥控器换电视频道时。至少有三个不同的对象参与了这个动作:遥控器,电视,还有最重要的,你。为了用编程语言表达现实世界的对象及其关系,我们并不强制使用类,类继承,抽象类,接口,虚函数等。提到的特性和概念使得设计和编码过程变得更加容易,因为它们允许我们以一种优雅的方式表达和分享想法,但它们并不是强制性的。正如 C++的创造者 Bjarne Stroustrup 所说,“并非每个程序都应该是面向对象的。”为了理解面向对象编程范式的高级概念和特性,我们将尝试看看幕后发生了什么。在本书中,我们将深入探讨面向对象程序的设计。理解对象及其关系的本质,然后使用它们来设计面向对象的程序,是本书的目标之一。

在本章中,我们将详细了解以下主题:

  • 面向对象编程简介

  • C++对象模型

  • 类关系,包括继承

  • 多态

  • 有用的设计模式

技术要求

在本章中,我们将使用带有-std=c++2a选项的 g++编译器来编译示例。

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

理解对象

大多数时候,我们操作的是以某个名称分组的数据集合,从而形成了抽象。例如is_militaryspeedseats等变量如果单独看并没有太多意义。将它们组合在spaceship这个名称下,改变了我们感知变量中存储的数据的方式。现在我们将许多变量打包成一个单一对象。为此,我们使用抽象;也就是说,我们从观察者的角度收集现实世界对象的各个属性。抽象是程序员工具链中的关键工具,因为它允许他们处理复杂性。C 语言引入了struct作为一种聚合数据的方式,如下面的代码所示:

struct spaceship {
  bool is_military;
  int speed;
  int seats;
};

对于面向对象编程来说,对数据进行分组是有必要的。每组数据都被称为一个对象。

对象的低级细节

C++尽其所能支持与 C 语言的兼容性。虽然 C 结构体只是一种允许我们聚合数据的工具,但 C++使它们等同于类,允许它们拥有构造函数、虚函数、继承其他结构体等。structclass之间唯一的区别是默认的可见性修饰符:结构体是public,类是private。通常使用结构体和类没有太大区别。面向对象编程需要的不仅仅是数据聚合。为了充分理解面向对象编程,让我们看看如果我们只有简单的结构体提供数据聚合而没有其他东西,我们如何将面向对象编程范式融入其中。

像亚马逊或阿里巴巴这样的电子商务市场的中心实体是Product,我们以以下方式表示它:

struct Product {
  std::string name;
  double price;
  int rating;
  bool available;
};

如果需要,我们将向Product添加更多成员。Product类型的对象的内存布局可以像这样:

声明Product对象在内存中占用sizeof(Product)的空间,而声明对象的指针或引用占用存储地址的空间(通常为 4 或 8 个字节)。请参阅以下代码块:

Product book;
Product tshirt;
Product* ptr = &book;
Product& ref = tshirt;

我们可以将上述代码描述如下:

让我们从Product对象在内存中占用的空间开始。我们可以通过总结其成员变量的大小来计算Product对象的大小。boolean变量的大小为 1 个字节。在 C++标准中没有明确规定doubleint的确切大小。在 64 位机器上,double变量通常占用 8 个字节,int变量占用 4 个字节。

std::string的实现在标准中没有指定,因此其大小取决于库的实现。string存储指向字符数组的指针,但也可能存储分配的字符数,以便在调用size()时高效返回。std::string的一些实现占用 8、24 或 32 个字节的内存,但我们将在示例中坚持使用 24 个字节。总结一下,Product的大小如下:

24 (std::string) + 8 (double) + 4 (int) + 1 (bool) = 37 bytes.

打印Product的大小会输出不同的值:

std::cout << sizeof(Product);

它输出40而不是计算出的 37 个字节。冗余字节背后的原因是结构的填充,这是编译器为了优化对对象的各个成员的访问而实践的一种技术。**中央处理单元(CPU)**以固定大小的字读取内存。字的大小由 CPU 定义(通常为 32 位或 64 位)。如果数据从与字对齐的地址开始,CPU 可以一次访问数据。例如,Productboolean数据成员需要 1 个字节的内存,可以直接放在评级成员后面。事实证明,编译器对数据进行了对齐以加快访问速度。假设字大小为 4 个字节。这意味着如果变量从可被 4 整除的地址开始,CPU 将无需冗余步骤即可访问变量。编译器会提前用额外的字节来对齐结构的成员到字边界地址。

对象的高级细节

我们将对象视为代表抽象结果的实体。我们已经提到了观察者的角色,即根据问题域定义对象的程序员。程序员定义这个过程代表了抽象的过程。让我们以电子商务市场及其产品为例。两个不同的程序员团队可能对同一产品有不同的看法。实现网站的团队关心对象的属性,这些属性对网站访问者:购买者至关重要。我们在Product结构中显示的属性主要是为网站访问者而设,比如销售价格、产品评级等。实现网站的程序员接触问题域,并验证定义Product对象所必需的属性。

负责实现帮助管理仓库中产品的在线工具的团队关心对象的属性,这些属性在产品放置、质量控制和装运方面至关重要。这个团队实际上不应该关心产品的评级甚至价格。这个团队主要关心产品的重量尺寸状态。以下插图显示了感兴趣的属性:

程序员在开始项目时应该做的第一件事是分析问题并收集需求。换句话说,他们应该熟悉问题域并定义项目需求。分析的过程导致定义对象及其类型,比如我们之前讨论的Product。为了从分析中得到正确的结果,我们应该以对象的方式思考,而通过以对象的方式思考,我们指的是考虑对象的三个主要属性:状态行为身份

状态

每个对象都有一个状态,可能与其他对象的状态相同也可能不同。我们已经介绍了Product结构,它代表了一个物理(或数字)产品的抽象。product对象的所有成员共同代表了对象的状态。例如,Product包含诸如available之类的成员,它是一个布尔值;如果产品有库存,则等于true。成员变量的值定义了对象的状态。如果给对象成员分配新值,它的状态将会改变:

Product cpp_book; // declaring the object
...
// changing the state of the object cpp_book
cpp_book.available = true;
cpp_book.rating = 5;

对象的状态是其所有属性和值的组合。

身份

身份是区分一个对象与另一个对象的特征。即使我们试图声明两个在物理上无法区分的对象,它们仍然会有不同的变量名称,也就是不同的身份:

Product book1;
book1.rating = 4;
book1.name = "Book";
Product book2;
book2.rating = 4;
book2.name = "Book";

前面例子中的对象具有相同的状态,但它们的名称不同,即book1book2。假设我们有能力以某种方式创建具有相同名称的对象,就像下面的代码所示:

Product prod;
Product prod; // won't compile, but still "what if?"

如果是这样的话,它们在内存中仍然会有不同的地址:

身份是对象的基本属性,也是我们无法创建对象的原因之一,比如下面的情况:

struct Empty {};

int main() {
 Empty e;
  std::cout << sizeof(e);
}

前面的代码不会像预期的那样输出0。空对象的大小在标准中没有指定;编译器开发人员倾向于为这样的对象分配 1 个字节,尽管您可能也会遇到 4 或 8。两个或更多个Empty的实例在内存中应该有不同的地址,因此编译器必须确保对象至少占用 1 个字节的内存。

行为

在之前的例子中,我们将54分配给了rating成员变量。通过给对象分配无效的值,我们可以很容易地使事情出乎意料地出错,就像这样:

cpp_book.rating = -12;

-12在产品评级方面是无效的,如果允许的话会使用户感到困惑。我们可以通过提供setter函数来控制对对象所做更改的行为:

void set_rating(Product* p, int r) {
  if (r >= 1 && r <= 5) {
 p->rating = r;
 }
  // otherwise ignore
}
...
set_rating(&cpp_book, -12); // won't change the state

对象对来自其他对象的请求作出反应。请求是通过函数调用执行的,否则称为消息:一个对象向另一个对象传递消息。在前面的例子中,将相应的set_rating消息传递给cpp_book对象的对象代表我们调用set_rating()函数的对象。在这种情况下,我们假设从main()中调用函数,实际上main()并不代表任何对象。我们可以说它是全局对象,操作main()函数的对象,尽管在 C++中并没有这样的实体。

我们在概念上区分对象,而不是在物理上。这是以对象思考的主要观点。面向对象编程的一些概念的物理实现并不是标准化的,所以我们可以将Product结构命名为类,并声称cpp_bookProduct实例,并且它有一个名为set_rating()的成员函数。C++的实现几乎是一样的:它提供了语法上方便的结构(类、可见性修饰符、继承等),并将它们转换为简单的结构,例如前面例子中的set_rating()全局函数。现在,让我们深入了解 C++对象模型的细节。

模拟类

结构体允许我们将变量分组,命名它们,并创建对象。类的概念是在对象中包含相应的操作,将适用于该特定数据的数据和操作分组在一起。例如,对于Product类型的对象,直接在对象上调用set_rating()函数将是很自然的,而不是使用一个单独的接受Product对象指针并修改它的全局函数。然而,由于我们同意以 C 方式使用结构体,我们无法负担得起拥有成员函数。为了使用 C 结构体模拟类,我们必须声明与Product对象一起工作的函数作为全局函数,如下面的代码所示:

struct Product {
  std::string name;
  double price;
  int rating;
  bool available;
};

void initialize(Product* p) {
  p->price = 0.0;
  p->rating = 0;
  p->available = false;
}

void set_name(Product* p, const std::string& name) {
  p->name = name;
}

std::string get_name(Product* p) {
  return p->name;
}

void set_price(Product* p, double price) {
  if (price < 0 || price > 9999.42) return;
  p->price = price;
}

double get_price(Product* p) {
  return p->price;
}

// code omitted for brevity

要将结构体用作类,我们应该按正确的顺序手动调用函数。例如,要使用具有正确初始化默认值的对象,我们必须首先调用initialize()函数:

int main() {
  Product cpp_book;
 initialize(&cpp_book);
  set_name(&cpp_book, "Mastering C++ Programming");
  std::cout << "Book title is: " << get_name(&cpp_book);
  // ...
}

这似乎是可行的,但如果添加新类型,前面的代码将很快变成一个无组织的混乱。例如,考虑跟踪产品的Warehouse结构体:

struct Warehouse {
  Product* products;
  int capacity;
  int size;
};

void initialize_warehouse(Warehouse* w) {
  w->capacity = 1000;
  w->size = 0;
  w->products = new Product[w->capacity];
  for (int ix = 0; ix < w->capacity; ++ix) {
    initialize(&w->products[ix]); // initialize each Product object
  }
}

void set_size(int size) { ... }
// code omitted for brevity

首先明显的问题是函数的命名。我们不得不将Warehouse的初始化函数命名为initialize_warehouse,以避免与已声明的Productinitialize()函数发生冲突。我们可能会考虑重命名Product类型的函数,以避免将来可能的冲突。接下来是函数的混乱。现在,我们有一堆全局函数,随着我们添加新类型,这些函数的数量将增加。如果我们添加一些类型的层次结构,它将变得更加难以管理。

尽管编译器倾向于将类翻译为具有全局函数的结构体,正如我们之前展示的那样,但 C++和其他高级编程语言解决了这些问题以及其他未提及的问题,引入了将它们组织成层次结构的平滑机制。从概念上讲,关键字(classpublicprivate)和机制(继承和多态)是为了方便开发人员组织他们的代码,但不会使编译器的生活变得更容易。

使用类进行工作

在处理对象时,类使事情变得更容易。它们在面向对象编程中做了最简单必要的事情:将数据与操作数据的函数结合在一起。让我们使用类及其强大的特性重写Product结构体的示例:

class Product {
public:
  Product() = default; // default constructor
  Product(const Product&); // copy constructor
  Product(Product&&); // move constructor

  Product& operator=(const Product&) = default;
  Product& operator=(Product&&) = default;
  // destructor is not declared, should be generated by the compiler
public:
  void set_name(const std::string&);
  std::string name() const;
  void set_availability(bool);
  bool available() const;
  // code omitted for brevity

private:
  std::string name_;
  double price_;
  int rating_;
  bool available_;
};

std::ostream& operator<<(std::ostream&, const Product&);
std::istream& operator>>(std::istream&, Product&);

类声明似乎更有组织性,尽管它公开的函数比我们用来定义类似结构体的函数更多。这是我们应该如何说明这个类的方式:

前面的图像有些特殊。正如你所看到的,它有组织良好的部分,在函数名称之前有标志等。这种类型的图表被称为**统一建模语言(UML)**类图。UML 是一种标准化说明类及其关系的方式。第一部分是类的名称(粗体),接下来是成员变量部分,然后是成员函数部分。函数名称前的+(加号)表示该函数是公共的。成员变量通常是私有的,但如果需要强调这一点,可以使用-(减号)。我们可以通过简单地说明类来省略所有细节,如下面的 UML 图所示:

我们将在本书中使用 UML 图表,并根据需要引入新类型的图表。在处理初始化、复制、移动、默认和删除函数以及运算符重载之前,让我们先澄清一些事情。

从编译器的角度看待类

首先,无论与之前的类相比,类似怪物的类看起来多么庞大,编译器都会将其转换为以下代码(我们稍微修改了它以简化):

struct Product {
  std::string name_;
  bool available_;
  double price_;
  int rating_;
};

// we forced the compiler to generate the default constructor
void Product_constructor(Product&); 
void Product_copy_constructor(Product& this, const Product&);
void Product_move_constructor(Product& this, Product&&);
// default implementation
Product& operator=(Product& this, const Product&); 
// default implementation
Product& operator=(Product& this, Product&&); 

void Product_set_name(const std::string&);
// takes const because the method was declared as const
std::string Product_name(const Product& this); 
void Product_set_availability(Product& this, bool b);
bool Product_availability(const Product& this);

std::ostream& operator<<(std::ostream&, const Product&);
std::istream& operator>>(std::istream&, Product&);

基本上,编译器生成了与我们之前介绍的相同的代码,以模仿使用简单结构体来实现类行为的方式。尽管编译器在实现 C++对象模型的技术和方法上有所不同,但前面的例子是编译器开发人员实践的流行方法之一。它在访问对象成员(包括成员函数)的空间和时间效率之间取得了平衡。

接下来,我们应该考虑编译器通过增加和修改来编辑我们的代码。下面的代码声明了全局create_apple()函数,它创建并返回一个具有特定苹果值的Product对象。它还在main()函数中声明了一个书对象:

Product create_apple() {
 Product apple;
  apple.set_name("Red apple");
  apple.set_price("0.2");
  apple.set_rating(5);
  apple.set_available(true);
  return apple;
}

int main() {
 Product red_apple = create_apple();
 Product book;  Product* ptr = &book;
  ptr->set_name("Alice in Wonderland");
  ptr->set_price(6.80);
  std::cout << "I'm reading " << book.name() 
            << " and I bought an apple for " << red_apple.price()
            << std::endl;
}

我们已经知道编译器修改类以将其转换为结构体,并将成员函数移动到全局范围,每个成员函数都以类的引用(或指针)作为其第一个参数。为了支持客户端代码中的这些修改,它还应该修改对所有对象的访问。

客户端代码是声明或使用已声明的类对象的一行或多行代码。

以下是我们假设编译器修改了前面代码的方式(我们使用了“假设”这个词,因为我们试图引入一个编译器抽象而不是特定于编译器的方法):

void create_apple(Product& apple) {
  Product_set_name(apple, "Red apple");
  Product_set_price(apple, 0.2);
  Product_set_rating(apple, 5);
  Product_set_available(apple, true);
  return;
}

int main() {
  Product red_apple;
 Product_constructor(red_apple);
 create_apple(red_apple);
  Product book;
 Product* ptr;
 Product_constructor(book);
 Product_set_name(*ptr, "Alice in Wonderland");
 Product_set_price(*ptr, 6.80);
  std::ostream os = operator<<(std::cout, "I'm reading ");
  os = operator<<(os, Product_name(book));
  os = operator<<(os, " and I bought an apple for ");
  os = operator<<(os, Product_price(red_apple));
  operator<<(os, std::endl);
  // destructor calls are skipped because the compiler 
  // will remove them as empty functions to optimize the code
  // Product_destructor(book);
  // Product_destructor(red_apple);
}

编译器还优化了对create_apple()函数的调用,以避免临时对象的创建。我们将在本章后面讨论编译器生成的隐式临时对象。

初始化和销毁

正如之前所示,对象的创建是一个两步过程:内存分配和初始化。内存分配是对象声明的结果。C++不关心变量的初始化;它分配内存(无论是自动还是手动)就完成了。实际的初始化应该由程序员完成,这就是我们首先需要构造函数的原因。

析构函数也是同样的逻辑。如果我们跳过默认构造函数或析构函数的声明,编译器应该会隐式生成它们,如果它们是空的话也会移除它们(以消除对空函数的冗余调用)。如果声明了带参数的构造函数,包括拷贝构造函数,编译器就不会生成默认构造函数。我们可以强制编译器隐式生成默认构造函数:

class Product {
public:
 Product() = default;
  // ...
};

我们还可以通过使用delete修饰符来强制不生成编译器,如下所示:

class Product {
public:
 Product() = delete;
  // ...
};

这将禁止默认初始化对象的声明,也就是说,Product p; 不会编译。

析构函数的调用顺序与对象声明的顺序相反,因为自动内存分配由堆栈管理,而堆栈是遵循**后进先出(LIFO)**规则的数据结构适配器。

对象初始化发生在对象创建时。销毁通常发生在对象不再可访问时。当对象在堆上分配时,后者可能会有些棘手。看一下下面的代码;它在不同的作用域和内存段中声明了四个Product对象:

static Product global_prod; // #1

Product* foo() {
  Product* heap_prod = new Product(); // #4
  heap_prod->name = "Sample";
  return heap_prod;
}

int main() {
 Product stack_prod; // #2
  if (true) {
    Product tmp; // #3
    tmp.rating = 3;
  }
  stack_prod.price = 4.2;
  foo();
}

global_prod具有静态存储期,并且放置在程序的全局/静态部分;它在调用main()之前被初始化。当main()开始时,stack_prod被分配在堆栈上,并且在main()结束时被销毁(函数的闭合大括号被视为其结束)。虽然条件表达式看起来很奇怪和太人为,但这是一种表达块作用域的好方法。

tmp对象也将分配在堆栈上,但其存储持续时间限制在其声明的范围内:当执行离开if块时,它将被自动销毁。这就是为什么堆栈上的变量具有自动存储持续时间。最后,当调用foo()函数时,它声明了heap_prod指针,该指针指向在堆上分配的Product对象的地址。

上述代码包含内存泄漏,因为heap_prod指针(它本身具有自动存储持续时间)将在执行到达foo()末尾时被销毁,而在堆上分配的对象不会受到影响。不要混淆指针和它指向的实际对象:指针只包含对象的值,但它并不代表对象。

不要忘记释放在堆上动态分配的内存,可以通过手动调用删除运算符或使用智能指针来实现。智能指针将在第五章中讨论,内存管理和智能指针

当函数结束时,分配在堆栈上的参数和局部变量的内存将被释放,但global_prod将在程序结束时被销毁,也就是在main()函数结束后。当对象即将被销毁时,析构函数将被调用。

复制对象

有两种复制方式:对象的复制和复制。语言允许我们使用复制构造函数赋值运算符来管理对象的复制初始化和赋值。这对程序员来说是一个必要的特性,因为我们可以控制复制的语义。看下面的例子:

Product p1;
Product p2;
p2.set_price(4.2);
p1 = p2; // p1 now has the same price
Product p3 = p2; // p3 has the same price

p1 = p2;这一行是对赋值运算符的调用,而最后一行是对复制构造函数的调用。等号不应该让你困惑,无论是赋值还是复制构造函数调用。每当看到声明后面跟着一个赋值时,都可以将其视为复制构造。新的初始化程序语法(Product p3{p2};)也是如此。

编译器将生成以下代码:

Product p1;
Product p2;
Product_set_price(p2, 4.2);
operator=(p1, p2);
Product p3;
Product_copy_constructor(p3, p2);

复制构造函数(和赋值运算符)的默认实现执行对象的成员逐个复制,如下图所示:

如果成员逐个复制产生无效副本,则需要自定义实现。例如,考虑以下Warehouse对象的复制:

class Warehouse {
public:
  Warehouse() 
    : size_{0}, capacity_{1000}, products_{nullptr}
  {
    products_ = new Products[capacity_];
  }

  ~Warehouse() {
    delete [] products_;
  }

public:
  void add_product(const Product& p) {
    if (size_ == capacity_) { /* resize */ }
    products_[size_++] = p;
  }
  // other functions omitted for brevity

private:
  int size_;
  int capacity_;
  Product* products_;
};

int main() {
  Warehouse w1;
  Product book;
  Product apple;
  // ...assign values to products (omitted for brevity)
  w1.add_product(book);
  Warehouse w2 = w1; // copy
  w2.add_product(apple);
  // something somewhere went wrong...
}

上述代码声明了两个Warehouse对象,然后向仓库添加了两种不同的产品。虽然这个例子有些不自然,但它展示了默认复制实现的危险。以下插图展示了代码中出现的问题:

w1赋给w2会导致以下结构:

默认实现只是将w1的每个成员复制到w2。复制后,w1w2products_成员都指向堆上的相同位置。当我们向w2添加新产品时,w1指向的数组会受到影响。这是一个逻辑错误,可能导致程序中的未定义行为。我们需要进行复制而不是复制;也就是说,我们需要实际创建一个包含 w1 数组副本的新产品数组。

自定义实现复制构造函数和赋值运算符解决了复制的问题:

class Warehouse {
public:
  // ...
  Warehouse(const Warehouse& rhs) {
 size_ = rhs.size_;
 capacity_ = rhs.capacity_;
 products_ = new Product[capacity_];
 for (int ix = 0; ix < size_; ++ix) {
 products_[ix] = rhs.products_[ix];
 }
 }
  // code omitted for brevity
};  

复制构造函数的自定义实现创建一个新数组。然后,它逐个复制源对象的数组元素,从而消除了product_指针指向错误的内存地址。换句话说,我们通过创建一个新数组实现了Warehouse对象的深复制。

移动对象

临时对象在代码中随处可见。大多数情况下,它们是必需的,以使代码按预期工作。例如,当我们将两个对象相加时,会创建一个临时对象来保存operator+的返回值:

Warehouse small;
Warehouse mid;
// ... some data inserted into the small and mid objects
Warehouse large{small + mid}; // operator+(small, mid)

让我们来看看Warehouse对象的全局operator+()的实现:

// considering declared as friend in the Warehouse class
Warehouse operator+(const Warehouse& a, const Warehouse& b) {
  Warehouse sum; // temporary
  sum.size_ = a.size_ + b.size_;
  sum.capacity_ = a.capacity_ + b.capacity_;
  sum.products_ = new Product[sum.capacity_];
  for (int ix = 0; ix < a.size_; ++ix) { sum.products_[ix] = a.products_[ix]; }
  for (int ix = 0; ix < b.size_; ++ix) { sum.products_[a.size_ + ix] = b.products_[ix]; }
  return sum;
}

前面的实现声明了一个临时对象,并在填充必要数据后返回它。在前面的示例中,调用可以被翻译成以下内容:

Warehouse small;
Warehouse mid;
// ... some data inserted into the small and mid objects
Warehouse tmp{operator+(small, mid)};
Warehouse large;
Warehouse_copy_constructor(large, tmp);
__destroy_temporary(tmp);

移动语义,它在 C++11 中引入,允许我们通过移动返回值到Warehouse对象中来跳过临时创建。为此,我们应该为Warehouse声明一个移动构造函数,它可以区分临时对象并有效地处理它们:

class Warehouse {
public:
  Warehouse(); // default constructor
  Warehouse(const Warehouse&); // copy constructor
  Warehouse(Warehouse&&); // move constructor
  // code omitted for brevity
};

移动构造函数的参数是rvalue 引用&&)。

Lvalue 引用

在理解为什么首先引入 rvalue 引用之前,让我们澄清一下关于lvaluesreferenceslvalue-references的事情。当一个变量是 lvalue 时,它可以被寻址,可以被指向,并且具有作用域存储期:

double pi{3.14}; // lvalue
int x{42}; // lvalue
int y{x}; // lvalue
int& ref{x}; // lvalue-reference

ref是一个lvalue引用,相当于可以被视为const指针的变量:

int * const ref = &x;

除了通过引用修改对象的能力,我们还通过引用将重型对象传递给函数,以便优化和避免冗余对象的复制。例如,Warehouseoperator+接受两个对象的引用,因此它复制对象的地址而不是完整对象。

Lvalue引用在函数调用方面优化了代码,但是为了优化临时对象,我们应该转向 rvalue 引用。

Rvalue 引用

我们不能将lvalue引用绑定到临时对象。以下代码将无法编译:

int get_it() {
  int it{42};
  return it;
}
...
int& impossible{get_it()}; // compile error

我们需要声明一个rvalue引用,以便能够绑定到临时对象(包括文字值):

int&& possible{get_it()};

Rvalue引用允许我们尽可能地跳过临时对象的生成。例如,以 rvalue 引用接受结果的函数通过消除临时对象而运行得更快:

void do_something(int&& val) {
  // do something with the val
}
// the return value of the get_it is moved to do_something rather than copied
do_something(get_it()); 

为了想象移动的效果,想象一下前面的代码将被翻译成以下内容(只是为了完全理解移动):

int val;
void get_it() {
  val = 42;
}
void do_something() {
  // do something with the val
}
do_something();

在引入移动之前,前面的代码看起来像这样(带有一些编译器优化):

int tmp;
void get_it() {
  tmp = 42;
}
void do_something(int val) {
  // do something with the val
}
do_something(tmp);

移动构造函数和移动操作符=()一起,当输入参数表示一个rvalue时,具有复制而不实际执行复制操作的效果。这就是为什么我们应该在类中实现这些新函数:这样我们就可以在任何有意义的地方优化代码。移动构造函数可以获取源对象而不是复制它,如下所示:

class Warehouse {
public:
  // constructors omitted for brevity
  Warehouse(Warehouse&& src)
 : size_{src.size_}, 
 capacity_{src.capacity_},
 products_{src.products_}
 {
 src.size_ = 0;
 src.capacity_ = 0;
 src.products_ = nullptr;
 }
};

我们不是创建一个capacity_大小的新数组,然后复制products_数组的每个元素,而是直接获取了数组的指针。我们知道src对象是一个 rvalue,并且它很快就会被销毁,这意味着析构函数将被调用,并且析构函数将删除分配的数组。现在,我们指向新创建的Warehouse对象的分配数组,这就是为什么我们不能让析构函数删除源数组。因此,我们将nullptr赋给它,以确保析构函数不会错过分配的对象。因此,由于移动构造函数,以下代码将被优化:

Warehouse large = small + mid;

+操作符的结果将被移动而不是复制。看一下下面的图表:

前面的图表演示了临时对象如何被移动到大对象中。

运算符重载的注意事项

C++为自定义类型提供了强大的运算符重载机制。使用+运算符计算两个对象的和要比调用成员函数好得多。调用成员函数还涉及在调用之前记住它的名称。它可能是addcalculateSumcalculate_sum或其他名称。运算符重载允许在类设计中采用一致的方法。另一方面,运算符重载会增加代码中不必要的冗长。以下代码片段表示对Money类进行了比较运算符的重载,以及加法和减法:

constexpr bool operator<(const Money& a, const Money& b) { 
  return a.value_ < b.value_; 
}
constexpr bool operator==(const Money& a, const Money& b) { 
  return a.value_ == b.value_; 
}
constexpr bool operator<=(const Money& a, const Money& b) { 
  return a.value_ <= b.value_; 
}
constexpr bool operator!=(const Money& a, const Money& b) { 
  return !(a == b); 
}
constexpr bool operator>(const Money& a, const Money& b) { 
  return !(a <= b); 
}
constexpr bool operator>=(const Money& a, const Money& b) { 
  return !(a < b); 
}
constexpr Money operator+(const Money& a, const Money& b) { 
  return Money{a.value_ + b.value_}; 
}
constexpr Money operator-(const Money& a, const Money& b) { 
  return Money{a.value_ - b.value_}; 
}

正如你所看到的,前面大部分函数直接访问了Money实例的值成员。为了使其工作,我们应该将它们声明为Money的友元。Money将如下所示:

class Money
{
public:
  Money() {}
  explicit Money(double v) : value_{v} {}
  // construction/destruction functions omitted for brevity

public:
  friend constexpr bool operator<(const Money&, const Money&);
 friend constexpr bool operator==(const Money&, const Money&);
 friend constexpr bool operator<=(const Money&, const Money&);
 friend constexpr bool operator!=(const Money&, const Money&);
 friend constexpr bool operator>(const Money&, const Money&);
 friend constexpr bool operator>=(const Money&, const Money&);
 friend constexpr bool operator+(const Money&, const Money&);
 friend constexpr bool operator-(const Money&, const Money&);

private:
  double value_;
}; 

这个类看起来很庞大。C++20 引入了太空船操作符,它允许我们跳过比较运算符的定义。operator<=>(),也被称为三路比较运算符,请求编译器生成关系运算符。对于Money类,我们可以使用默认的operator<=>(),如下所示:

class Money
{
  // code omitted for brevity
 friend auto operator<=>(const Money&, const Money&) = default;
};

编译器将生成==!=<><=>=运算符。太空船运算符减少了运算符的冗余定义,并提供了一种为所有生成的运算符实现通用行为的方法。在为太空船运算符实现自定义行为时,我们应该注意运算符的返回值类型。它可以是以下之一:

  • std::strong_ordering

  • std::weak_ordering

  • std::partial_ordering

  • std::strong_equality

  • std::weak_equality

它们都在<compare>头文件中定义。编译器根据三路运算符的返回类型生成运算符。

封装和公共接口

封装是面向对象编程中的一个关键概念。它允许我们隐藏对象的实现细节,使其对客户端代码不可见。以计算机键盘为例;它有用于字母、数字和符号的按键,每个按键在按下时都会起作用。它的使用简单直观,并隐藏了许多只有熟悉电子设备的人才能处理的低级细节。想象一下一个没有按键的键盘——一个只有裸板和未标记引脚的键盘。你将不得不猜测要按下哪个键才能实现所需的按键组合或文本输入。现在,想象一个没有引脚的键盘——你必须向相应的插座发送正确的信号才能获得特定符号的按键按下事件。用户可能会因为缺少标签而感到困惑,他们也可能会错误地按下或向无效的插座发送信号。我们所知道的键盘通过封装实现了这一点——程序员也通过封装对象来确保用户不会因为冗余成员而负担过重,以及确保用户不会以错误的方式使用对象。

在类中,可见性修饰符通过允许我们定义任何成员的可访问级别来实现这一目的。private修饰符禁止客户端代码使用private成员,这使我们能够通过提供相应的成员函数来控制private成员的修改。一个mutator函数,对许多人来说是一个设置函数,会在测试该特定类的值是否符合指定规则后修改private成员的值。以下代码中可以看到这一点的例子:

class Warehouse {
public:
  // rather naive implementation
  void set_size(int sz) {
 if (sz < 1) throw std::invalid_argument("Invalid size");
 size_ = sz;
 }
  // code omitted for brevity
private:
  int size_;
};

通过mutator函数修改数据成员允许我们控制其值。实际数据成员是私有的,这使得它无法从客户端代码访问,而类本身提供了公共函数来更新或读取其私有成员的内容。这些函数以及构造函数通常被称为类的公共接口。程序员们努力使类的公共接口用户友好。

看一下下面的类,它表示一个二次方程求解器:一个形式为ax² + bx + c = 0的方程。找到判别式并根据判别式(D)的值计算x的值是解决方案之一。以下类提供了五个函数,分别用于设置abc的值,找到判别式,解决并返回x的值:

class QuadraticSolver {
public:
  QuadraticSolver() = default;
  void set_a(double a);
 void set_b(double b);
 void set_c(double c);
 void find_discriminant();
 double solve(); // solve and return the x
private:
  double a_;
  double b_;
  double c_;
  double discriminant_;
};

公共接口包括前面提到的四个函数和默认构造函数。要解决方程2x² + 5x - 8 = 0,我们应该这样使用QuadraticSolver

QuadraticSolver solver;
solver.set_a(2);
solver.set_b(5);
solver.set_c(-8);
solver.find_discriminant();
std::cout << "x is: " << solver.solve() << std::endl;

类的公共接口应该被明智地设计;前面的例子显示了糟糕设计的迹象。用户必须知道协议,也就是确切的调用函数的顺序。如果用户忽略了对find_discriminant()的调用,结果将是未定义或无效的。公共接口强迫用户学习协议,并按正确的顺序调用函数,即设置abc的值,然后调用find_discriminant()函数,最后调用solve()函数以获得x的期望值。一个好的设计应该提供一个直观易用的公共接口。我们可以重写QuadraticSolver,使其只有一个函数,接受所有必要的输入值,计算判别式本身,并返回解决方案:

class QuadtraticSolver {
public:
  QuadraticSolver() = default;
 double solve(double a, double b, double c);
};

前面的设计比之前的更直观。以下代码演示了如何使用QuadraticSolver来找到方程2x2 + 5x - 8 = 0的解:

QuadraticSolver solver;
std::cout << solver.solve(2, 5, -8) << std::endl;

在这里需要考虑的最后一件事是,二次方程可以有多种解法。我们介绍的方法是通过找到判别式来解决的。我们应该考虑,将来我们可能会为这个类添加更多的实现方法。改变函数的名称可能会增加公共接口的可读性,并确保对类的未来更新。我们还应该注意,在前面的例子中,solve()函数接受abc作为参数,我们不需要在类中存储它们,因为解决方案是直接在函数中计算的。

显然,声明一个QuadraticSolver的对象只是为了能够访问solve()函数似乎是一个多余的步骤。类的最终设计将如下所示:

class QuadraticSolver {
public:
  QuadraticSolver() = delete;

  static double solve_by_discriminant(double a, double b, double c);
  // other solution methods' implementations can be prefixed by "solve_by_"
};

我们将solve()函数重命名为solve_by_discriminant(),这也暴露了解决方案的底层方法。我们还将函数设为static,这样用户就可以在不声明类的实例的情况下使用它。然而,我们还将默认构造函数标记为deleted,这再次强制用户不要声明对象:

std::cout << QuadraticSolver::solve_by_discriminant(2, 5, -8) << std::endl;

客户端代码现在使用该类的工作量更少。

C++中的结构体

在 C++中,结构体和类几乎是相同的。它们具有类的所有特性,你可以从结构体继承一个类,反之亦然。classstruct之间唯一的区别是默认可见性。对于结构体,默认可见性修饰符是公共的。它也与继承有关。例如,当你从另一个类继承一个类而不使用修饰符时,它会私有继承。以下类私有地继承自Base

class Base
{
public:
  void foo() {}
};

class Derived : Base
{
  // can access foo() while clients of Derived can't
};

按照相同的逻辑,以下结构体公开继承Base

struct Base
{
  // no need to specify the public section
  void foo() {}
};

struct Derived : Base
{
  // both Derived and clients of Derived can access foo()
};

与继承自结构体的类相关。例如,如果没有直接指定,Derived类会私有地继承Base

struct Base
{
  void foo() {}
};

// Derived inherits Base privately
class Derived: Base
{
  // clients of Derived can't access foo()
};

在 C++中,结构体和类是可以互换的,但大多数程序员更喜欢使用结构体来表示简单类型。C++标准对简单类型给出了更好的定义,并称它们为聚合。如果一个类(结构体)符合以下规则,则它是一个聚合:

  • 没有私有或受保护的非静态数据成员

  • 没有用户声明或继承的构造函数

  • 没有虚拟、私有或受保护的基类

  • 没有虚成员函数

在完成本章后,大多数规则会更加清晰。以下结构是一个聚合的例子:

struct Person
{
  std::string name;
  int age;
  std::string profession;
};

在深入研究继承和虚函数之前,让我们看看聚合在初始化时带来了什么好处。我们可以以以下方式初始化Person对象:

Person john{"John Smith", 22, "programmer"};

C++20 提供了更多初始化聚合的新方法:

Person mary{.name = "Mary Moss", .age{22}, .profession{"writer"}};

注意我们如何通过指示符混合初始化成员。

结构化绑定允许我们声明绑定到聚合成员的变量,如下面的代码所示:

const auto [p_name, p_age, p_profession] = mary;
std::cout << "Profession is: " << p_profession << std::endl;

结构化绑定也适用于数组。

类关系

对象间通信是面向对象系统的核心。关系是对象之间的逻辑链接。我们如何区分或建立类对象之间的适当关系,定义了系统设计的性能和质量。考虑ProductWarehouse类;它们处于一种称为聚合的关系,因为Warehouse包含Products,也就是说,Warehouse聚合了Products

在纯面向对象编程中有几种关系,比如关联、聚合、组合、实例化、泛化等。

聚合和组合

我们在Warehouse类的例子中遇到了聚合。Warehouse类存储了一个产品数组。更一般的说,它可以被称为关联,但为了强调确切的包含性,我们使用聚合组合这个术语。在聚合的情况下,包含其他类的类可以在没有聚合的情况下实例化。这意味着我们可以创建和使用Warehouse对象,而不一定要创建Warehouse中包含的Product对象。

聚合的另一个例子是CarPersonCar可以包含一个Person对象(作为驾驶员或乘客),因为它们彼此相关,但包含性不强。我们可以创建一个没有DriverCar对象,如下所示:

class Person; // forward declaration
class Engine { /* code omitted for brevity */ };
class Car {
public:
  Car();
  // ...
private:
  Person* driver_; // aggregation
  std::vector<Person*> passengers_; // aggregation
  Engine engine_; // composition
  // ...
}; 

强大的包含性由组合来表达。以Car为例,需要一个Engine类的对象才能组成一个完整的Car对象。在这种物理表示中,当创建一个Car时,Engine成员会自动创建。

以下是聚合和组合的 UML 表示:

在设计类时,我们必须决定它们的关系。定义两个类之间的组合关系的最佳方法是有一个关系测试。Car 有一个 Engine,因为汽车有发动机。每当你不能确定关系是否应该以组合的方式表达时,问一下有一个的问题。聚合和组合有些相似;它们只是描述了连接的强度。对于聚合,适当的问题应该是可以有一个;例如,一个Car可以有一个驾驶员(类型为Person);也就是说,包含性是弱的。

继承

继承是一种允许我们重用类的编程概念。编程语言提供了不同的继承实现,但总的规则始终是:类关系应该回答是一个的问题。例如,Car是一个Vehicle,这使我们可以从Vehicle继承Car

class Vehicle {
public:
  void move();
};

class Car : public Vehicle {
public:
  Car();
  // ...
};

Car现在有了从Vehicle继承而来的move()成员函数。继承本身代表了一种泛化/特化的关系,其中父类(Vehicle)是泛化,子类(Car)是特化。

父类可以被称为基类或超类,而子类可以被称为派生类或子类。

只有在绝对必要的情况下才应考虑使用继承。正如我们之前提到的,类应该满足是一个的关系,有时这有点棘手。考虑SquareRectangle类。以下代码以可能的最简形式声明了Rectangle类:

class Rectangle {
public:
  // argument checks omitted for brevity
  void set_width(int w) { width_ = w; }
  void set_height(int h) { height_ = h; }
  int area() const { return width_ * height_; }
private:
  int width_;
  int height_;
};

Square 是一个 Rectangle,所以我们可以很容易地从Rectangle继承它:

class Square : public Rectangle {
public:
  void set_side(int side) {
 set_width(side);
 set_height(side);
  }

 int area() { 
    area_ = Rectangle::area();
    return area_; 
  }
private:
 int area_;
};

Square通过添加一个新的数据成员area_并覆盖area()成员函数的实现来扩展Rectangle。在实践中,area_及其计算方式是多余的;我们这样做是为了演示一个糟糕的类设计,并使Square在一定程度上扩展其父类。很快,我们将得出结论,即在这种情况下,继承是一个糟糕的设计选择。Square是一个Rectangle,所以应该在Rectangle使用的任何地方使用Rectangle,如下所示:

void make_big_rectangle(Rectangle& ref) {
  ref->set_width(870);
  ref->set_height(940);
}

int main() {
  Rectangle rect;
  make_big_rectangle(rect);
  Square sq;
  // Square is a Rectangle
  make_big_rectangle(sq);
}

make_big_rectangle()函数接受Rectangle的引用,而Square继承了它,所以将Square对象发送到make_big_rectangle()函数是完全可以的;Square 是一个 Rectangle。这种成功用其子类型替换类型的示例被称为Liskov 替换原则。让我们找出为什么这种替换在实践中有效,然后决定我们是否通过从Rectangle继承Square而犯了设计错误(是的,我们犯了)。

从编译器的角度来看继承

我们可以这样描述我们之前声明的Rectangle类:

当我们在main()函数中声明rect对象时,函数的本地对象所需的空间被分配在堆栈中。当调用make_big_rectangle()函数时,遵循相同的逻辑。它没有本地参数;相反,它有一个Rectangle&类型的参数,其行为类似于指针:它占用存储内存地址所需的内存空间(在 32 位和 64 位系统中分别为 4 或 8 字节)。rect对象通过引用传递给make_big_rectangle(),这意味着ref参数指的是main()中的本地对象:

以下是Square类的示例:

如前图所示,Square对象包含Rectangle子对象;它部分代表了Rectangle。在这个特定的例子中,Square类没有用新的数据成员扩展矩形。

Square对象被传递给make_big_rectangle(),尽管后者需要一个Rectangle&类型的参数。我们知道在访问底层对象时需要指针(引用)的类型。类型定义了应该从指针指向的起始地址读取多少字节。在这种情况下,ref存储了在main()中声明的本地rect对象的起始地址的副本。当make_big_rectangle()通过ref访问成员函数时,实际上调用的是以Rectangle引用作为第一个参数的全局函数。该函数被转换为以下形式(再次,为了简单起见,我们稍作修改):

void make_big_rectangle(Rectangle * const ref) {
  Rectangle_set_width(*ref, 870);
  Rectangle_set_height(*ref, 940);
}

解引用ref意味着从ref指向的内存位置开始读取sizeof(Rectangle)字节。当我们将Square对象传递给make_big_rectangle()时,我们将sqSquare对象)的起始地址分配给ref。这将正常工作,因为Square对象实际上包含一个Rectangle子对象。当make_big_rectangle()函数解引用ref时,它只能访问对象的sizeof(Rectangle)字节,并且看不到实际Square对象的附加字节。以下图示了ref指向的子对象的部分:

Rectangle继承Square几乎与声明两个结构体相同,其中一个(子类)包含另一个(父类):

struct Rectangle {
 int width_;
 int height_;
};

void Rectangle_set_width(Rectangle& this, int w) {
  this.width_ = w;
}

void Rectangle_set_height(Rectangle& this, int h) {
  this.height_ = h;
}

int Rectangle_area(const Rectangle& this) {
  return this.width_ * this.height_;
}

struct Square {
 Rectangle _parent_subobject_;
 int area_; 
};

void Square_set_side(Square& this, int side) {
  // Rectangle_set_width(static_cast<Rectangle&>(this), side);
 Rectangle_set_width(this._parent_subobject_, side);
  // Rectangle_set_height(static_cast<Rectangle&>(this), side);
 Rectangle_set_height(this._parent_subobject_, side);
}

int Square_area(Square& this) {
  // this.area_ = Rectangle_area(static_cast<Rectangle&>(this));
 this.area_ = Rectangle_area(this._parent_subobject_); 
  return this.area_;
}

上述代码演示了编译器支持继承的方式。看一下Square_set_sideSquare_area函数的注释代码。我们实际上并不坚持这种实现,但它表达了编译器处理面向对象编程代码的完整思想。

组合与继承

C++语言为我们提供了方便和面向对象的语法,以便我们可以表达继承关系,但编译器处理它的方式更像是组合而不是继承。实际上,在适用的地方使用组合而不是继承甚至更好。Square类及其与Rectangle的关系被认为是一个糟糕的设计选择。其中一个原因是子类型替换原则,它允许我们以错误的方式使用Square:将其传递给一个将其作为Rectangle而不是Square修改的函数。这告诉我们是一个关系并不正确,因为Square毕竟不是Rectangle。它是Rectangle的一种适应,而不是Rectangle本身,这意味着它实际上并不代表Rectangle;它使用Rectangle来为类用户提供有限的功能。

Square的用户不应该知道它可以被用作Rectangle;否则,在某个时候,他们会向Square实例发送无效或不支持的消息。无效消息的例子是调用set_widthset_height函数。Square实际上不应该支持两个不同的成员函数来分别修改它的边,但它不能隐藏这一点,因为它宣布它是从Rectangle继承而来的:

class Square : public Rectangle {
  // code omitted for brevity
};

如果我们将修饰符从 public 改为 private 会怎么样?嗯,C++支持公有和私有继承类型。它还支持受保护的继承。当从类私有继承时,子类打算使用父类并且可以访问其公共接口。然而,客户端代码并不知道它正在处理一个派生类。此外,从父类继承的公共接口对于子类的用户来说变成了私有的。似乎Square将继承转化为组合:

class Square : private Rectangle {
public:
  void set_side(int side) {
    // Rectangle's public interface is accessible to the Square
    set_width(side);
 set_height(side);
  }
  int area() {
    area_ = Rectangle::area();
    return area_;
  }
private:
  int area_;
};

客户端代码无法访问从Rectangle继承的成员:

Square sq;
sq.set_width(14); // compile error, the Square has no such public member
make_big_rectangle(sq); // compile error, can't cast Square to Rectangle

通过在Square的私有部分声明一个Rectangle成员也可以实现相同的效果:

class Square {
public: 
  void set_side(int side) {
 rectangle_.set_width(side);
 rectangle_.set_height(side);
  }
  int area() {
 area_ = rectangle_.area();
    return area_;
  }
private:
 Rectangle rectangle_;
  int area_;
};

你应该仔细分析使用场景,并完全回答是一个问题,以便毫无疑问地使用继承。每当你在组合和继承之间做出选择时,选择组合。

在私有继承时,我们可以省略修饰符。类的默认访问修饰符是 private,所以class Square : private Rectangle {};class Square : Rectangle {};是一样的。相反,结构体的默认修饰符是 public。

受保护的继承

最后,我们有protected访问修饰符。它指定了类成员在类体中使用时的访问级别。受保护成员对于类的用户来说是私有的,但对于派生类来说是公共的。如果该修饰符用于指定继承的类型,它对于派生类的用户的行为类似于私有继承。私有继承隐藏了基类的公共接口,而受保护继承使其对派生类的后代可访问。

很难想象一个需要受保护继承的场景,但你应该将其视为一个可能在意料之外的明显设计中有用的工具。假设我们需要设计一个栈数据结构适配器。栈通常是基于向量(一维数组)、链表或双端队列实现的。

栈符合 LIFO 规则,即最后插入栈的元素将首先被访问。同样,首先插入栈的元素将最后被访问。我们将在第六章中更详细地讨论数据结构和 STL 中的数据结构适配器和算法

栈本身并不代表一个数据结构;它位于数据结构的顶部,并通过限制、修改或扩展其功能来适应其使用。以下是表示整数一维数组的Vector类的简单声明:

class Vector {
public:
  Vector();
  Vector(const Vector&);
  Vector(Vector&&);
  Vector& operator=(const Vector&);
  Vector& operator=(Vector&&);
  ~Vector();

public:
  void push_back(int value);
  void insert(int index, int value);
  void remove(int index);
  int operator[](int index);
  int size() const;
  int capacity() const;

private:
  int size_;
  int capacity_;
  int* array_;
};

前面的Vector不是一个具有随机访问迭代器支持的 STL 兼容容器;它只包含动态增长数组的最低限度。可以这样声明和使用它:

Vector v;
v.push_back(4);
v.push_back(5);
v[1] = 2;

Vector类提供operator[],允许我们随机访问其中的任何项,而Stack禁止随机访问。Stack提供pushpop操作,以便我们可以插入值到其底层数据结构中,并分别获取该值:

class Stack : private Vector {
public:
  // constructors, assignment operators and the destructor are omitted for brevity
 void push(int value) {
 push_back(value);
 }
 int pop() {
 int value{this[size() - 1]};
 remove(size() - 1);
 return value;
 }
};

Stack可以以以下方式使用:

Stack s;
s.push(5);
s.push(6);
s.push(3);
std::cout << s.pop(); // outputs 3
std::cout << s.pop(); // outputs 6
s[2] = 42; // compile error, the Stack has no publicly available operator[] defined

适配Vector并提供两个成员函数,以便我们可以访问它。私有继承允许我们使用Vector的全部功能,并且隐藏继承信息,不让Stack的用户知道。如果我们想要继承Stack来创建其高级版本怎么办?假设AdvancedStack类提供了min()函数,以常数时间返回栈中包含的最小值。

私有继承禁止AdvancedStack使用Vector的公共接口,因此我们需要一种方法来允许Stack的子类使用其基类,但是隐藏基类的存在。受保护的继承可以实现这一目标,如下所示:

class Stack : protected Vector {
  // code omitted for brevity
};

class AdvancedStack : public Stack {
  // can use the Vector
};

通过从Vector继承Stack,我们允许Stack的子类使用Vector的公共接口。但是StackAdvancedStack的用户将无法将它们视为Vector

多态

多态是面向对象编程中的另一个关键概念。它允许子类对从基类派生的函数进行自己的实现。假设我们有Musician类,它有play()成员函数:

class Musician {
public:
  void play() { std::cout << "Play an instrument"; }
};

现在,让我们声明Guitarist类,它有play_guitar()函数:

class Guitarist {
public:
  void play_guitar() { std::cout << "Play a guitar"; }
};

这是继承的明显案例,因为Guitarist明显表明它是一个MusicianGuitarist自然不应该通过添加新函数(如play_guitar())来扩展Musician;相反,它应该提供其自己从Musician派生的play()函数的实现。为了实现这一点,我们使用虚函数

class Musician {
public:
  virtual void play() { std::cout << "Play an instrument"; }
};

class Guitarist : public Musician {
public:
  void play() override { std::cout << "Play a guitar"; }
};

现在,显然Guitarist类提供了play()函数的自己的实现,客户端代码可以通过使用指向基类的指针来访问它:

Musician armstrong;
Guitarist steve;
Musician* m = &armstrong;
m->play();
m = &steve;
m->play();

前面的例子展示了多态的实际应用。虚函数的使用虽然很自然,但实际上除非我们正确使用它,否则并没有太多意义。首先,Musicianplay()函数根本不应该有任何实现。原因很简单:音乐家应该能够在具体的乐器上演奏,因为他们不能同时演奏多个乐器。为了摆脱实现,我们通过将0赋值给它将函数设置为纯虚函数

class Musician {
public:
 virtual void play() = 0;
};

当客户端代码尝试声明Musician的实例时,会导致编译错误。当然,这必须导致编译错误,因为不应该能够创建具有未定义函数的对象。Musician只有一个目的:它必须只能被其他类继承。存在供继承的类称为抽象类。实际上,Musician被称为接口而不是抽象类。抽象类是半接口半类,可以具有有和无实现的函数。

回到我们的例子,让我们添加Pianist类,它也实现了Musician接口:

class Pianist : public Musician {
public: 
 void play() override { std::cout << "Play a piano"; }
};

为了表达多态性的全部功能,假设我们在某处声明了一个函数,返回吉他手或钢琴家的集合:

std::vector<Musician*> get_musicians();

从客户端代码的角度来看,很难解析get_musicians()函数的返回值,并找出对象的实际子类型是什么。它可能是吉他手钢琴家,甚至是纯粹的音乐家。关键是客户端不应该真正关心对象的实际类型,因为它知道集合包含音乐家,而音乐家对象具有play()函数。因此,为了让它们发挥作用,客户端只需遍历集合,并让每个音乐家演奏其乐器(每个对象调用其实现):

auto all_musicians = get_musicians();
for (const auto& m: all_musicians) {
 m->play();
}

前面的代码表达了多态性的全部功能。现在,让我们了解语言如何在低级别上支持多态性。

底层的虚函数

虽然多态性不限于虚函数,但我们将更详细地讨论它们,因为动态多态性是 C++中最流行的多态性形式。而且,更好地理解一个概念或技术的最佳方法是自己实现它。无论我们在类中声明虚成员函数还是它具有具有虚函数的基类,编译器都会用额外的指针增强类。指针指向的表通常被称为虚函数表,或者简称为虚表。我们还将指针称为虚表指针

假设我们正在为银行客户账户管理实现一个类子系统。假设银行要求我们根据账户类型实现取款。例如,储蓄账户允许每年取款一次,而支票账户允许客户随时取款。不涉及Account类的任何不必要的细节,让我们声明最少的内容,以便理解虚拟成员函数。让我们看一下Account类的定义:

class Account
{
public:
 virtual void cash_out() {
 // the default implementation for cashing out 
 }  virtual ~Account() {}
private:
  double balance_;
};

编译器将Account类转换为一个具有指向虚函数表的指针的结构。以下代码表示伪代码,解释了在类中声明虚函数时发生的情况。与往常一样,请注意,我们提供的是一般性的解释,而不是特定于编译器的实现(名称修饰也是以通用形式进行的;例如,我们将cash_out重命名为Account_cash_out):

struct Account
{
 VTable* __vptr;
  double balance_;
};

void Account_constructor(Account* this) {
 this->__vptr = &Account_VTable;
}

void Account_cash_out(Account* this) {
  // the default implementation for cashing out
}

void Account_destructor(Account* this) {}

仔细看前面的伪代码。Account结构的第一个成员是__vptr。由于先前声明的Account类有两个虚函数,我们可以将虚表想象为一个数组,其中有两个指向虚成员函数的指针。请参阅以下表示:

VTable Account_VTable[] = {
 &Account_cash_out,
 &Account_destructor
};

有了我们之前的假设,让我们找出当我们在对象上调用虚函数时编译器将生成什么代码:

// consider the get_account() function as already implemented and returning an Account*
Account* ptr = get_account();
ptr->cash_out();

以下是我们可以想象编译器为前面的代码生成的代码:

Account* ptr = get_account();
ptr->__vptr[0]();

虚函数在层次结构中使用时显示其功能。SavingsAccountAccount类继承如下:

class SavingsAccount : public Account
{
public:
 void cash_out() override {
 // an implementation specific to SavingsAccount
 }
  virtual ~SavingsAccount() {}
};

当我们通过指针(或引用)调用cash_out()时,虚函数是根据指针指向的目标对象调用的。例如,假设get_savings_account()SavingsAccount作为Account*返回。以下代码将调用SavingsAccountcash_out()实现:

Account* p = get_savings_account();
p->cash_out(); // calls SavingsAccount version of the cash_out

这是编译器为SavingsClass生成的内容:

struct SavingsAccount
{
  Account _parent_subobject_;
  VTable* __vptr;
};

VTable* SavingsAccount_VTable[] = {
  &SavingsAccount_cash_out,
  &SavingsAccount_destructor,
};

void SavingsAccount_constructor(SavingsAccount* this) {
  this->__vptr = &SavingsAccount_VTable;
}

void SavingsAccount_cash_out(SavingsAccount* this) {
  // an implementation specific to SavingsAccount
}

void SavingsAccount_destructor(SavingsAccount* this) {}

所以,我们有两个不同的虚拟函数表。当我们创建一个Account类型的对象时,它的__vptr指向Account_VTable,而SavingsAccount类型的对象的__vptr指向SavingsAccount_VTable。让我们看一下以下代码:

p->cash_out();

前面的代码转换成了这样:

p->__vptr[0]();

现在很明显,__vptr[0]解析为正确的函数,因为它是通过p指针读取的。

如果SavingsAccount没有覆盖cash_out()函数会怎么样?在这种情况下,编译器会将基类实现的地址放在与SavingsAccount_VTable相同的位置,如下所示:

VTable* SavingsAccount_VTable[] = {
  // the slot contains the base class version 
  // if the derived class doesn't have an implementation
 &Account_cash_out,
  &SavingsAccount_destructor
};

编译器以不同的方式实现和管理虚拟函数的表示。一些实现甚至使用不同的模型,而不是我们之前介绍的模型。我们采用了一种流行的方法,并以通用的方式表示,以简化起见。现在,我们将看看在包含动态多态性的代码底层发生了什么。

设计模式

设计模式是程序员最具表现力的工具之一。它们使我们能够以一种优雅和经过充分测试的方式解决设计问题。当您努力提供最佳的类设计和它们的关系时,一个众所周知的设计模式可能会挽救局面。

设计模式的最简单示例是单例。它为我们提供了一种声明和使用类的唯一实例的方法。例如,假设电子商务平台只有一个Warehouse。要访问Warehouse类,项目可能需要在许多源文件中包含并使用它。为了保持同步,我们应该将Warehouse设置为单例:

class Warehouse {
public:
  static create_instance() {
 if (instance_ == nullptr) {
 instance_ = new Warehouse();
 }
 return instance_;
 }

 static remove_instance() {
 delete instance_;
 instance_ = nullptr;
 }

private:
  Warehouse() = default;

private:
  static Warehouse* instance_ = nullptr;
};

我们声明了一个静态的Warehouse对象和两个静态函数来创建和销毁相应的实例。私有构造函数导致每次用户尝试以旧的方式声明Warehouse对象时都会产生编译错误。为了能够使用Warehouse,客户端代码必须调用create_instance()函数。

Warehouse* w = Warehouse::create_instance();
Product book;
w->add_product(book);
Warehouse::remove_instance();

Warehouse的单例实现并不完整,只是一个引入设计模式的示例。我们将在本书中介绍更多的设计模式。

总结

在本章中,我们讨论了面向对象编程的基本概念。我们涉及了类的低级细节和 C++对象模型的编译器实现。知道如何设计和实现类,而实际上没有类,有助于正确使用类。

我们还讨论了继承的必要性,并尝试在可能适用的地方使用组合而不是继承。C++支持三种类型的继承:公有、私有和保护。所有这些类型都在特定的类设计中有它们的应用。最后,我们通过一个大大增加客户端代码便利性的例子理解了多态性的用途和力量。

在下一章中,我们将学习更多关于模板和模板元编程的知识,这将成为我们深入研究名为概念的新 C++20 特性的基础。

问题

  1. 对象的三个属性是什么?

  2. 将对象移动而不是复制它们有什么优势?

  3. C++中结构体和类有什么区别?

  4. 聚合和组合关系之间有什么区别?

  5. 私有继承和保护继承有什么区别?

  6. 如果我们在类中定义了虚函数,类的大小会受到影响吗?

  7. 使用单例设计模式有什么意义?

进一步阅读

更多信息,请参考: