目录
这本书旨在为你编写高效应用程序打下坚实的基础,并让你深入了解在现代 C++ 中实现库的策略。我试图采用一种实用的方法来解释 C++ 如今如何工作,其中 C++11 到 C++20 的现代特性是该语言的自然组成部分,而不是从历史角度来看待 C++。
在本章中,我们将:
- 涵盖 C++ 的一些特性,这些特性对于编写健壮、高性能的应用程序非常重要
- 讨论 C++ 相较于竞争语言的优势和劣势
- 介绍本书中使用的库和编译器
为什么选择 C++?
让我们首先探讨一下今天使用 C++ 的一些原因。简而言之,C++ 是一种高度可移植的语言,它提供了零开销抽象。此外,C++ 为程序员提供了编写和管理大型、富有表现力和健壮代码库的能力。在本节中,我们将探讨零开销抽象的含义,将 C++ 抽象与其他语言的抽象进行比较,并讨论可移植性和健壮性,以及为什么这些特性很重要。
让我们从零开销抽象开始。
零开销抽象
活跃的代码库会增长。在代码库上工作的开发人员越多,代码库就越大。为了管理代码库日益增长的复杂性,我们需要像变量、函数和类这样的语言特性,以便能够创建具有自定义名称和接口的自身抽象,从而隐藏实现的细节。
C++ 允许我们定义自己的抽象,但它也带有内置抽象。例如,C++ 函数的概念本身就是一种用于控制程序流程的抽象。基于范围的 for 循环是内置抽象的另一个例子,它使得迭代一系列值更加直接。作为程序员,我们在开发程序时会不断添加新的抽象。同样,C++ 的新版本也会向语言和标准库引入新的抽象。但是,不断添加抽象和新的间接层是有代价的——效率。这就是零开销抽象发挥作用的地方。C++ 提供的许多抽象在空间和时间方面都具有非常低的运行时开销。
使用 C++,你可以在需要时自由地讨论内存地址和其他与计算机相关的底层术语。然而,在一个大型软件项目中,理想的做法是以处理应用程序正在做什么的术语来表达代码,而让库来处理与计算机相关的术语。图形应用程序的源代码可能处理铅笔、颜色和滤镜,而游戏可能处理吉祥物、城堡和蘑菇。像内存地址这样的低级计算机相关术语可以隐藏在性能至关重要的 C++ 库代码中。
编程语言和机器代码抽象
为了将程序员从处理与计算机相关的术语的需求中解脱出来,现代编程语言使用了抽象,例如,一个字符串列表可以被处理和视为一个字符串列表,而不是一个地址列表,如果我们犯了最轻微的输入错误,就很容易失去对其的追踪。这些抽象不仅将程序员从 bug 中解脱出来,而且还通过使用来自应用程序领域的概念,使代码更具表现力。换句话说,代码是以更接近口语的术语来表达的,而不是用抽象的编程关键字来表达。
C++ 和 C 如今是完全不同的两种语言。尽管如此,C++ 与 C 高度兼容,并从 C 继承了许多语法和习惯用法。为了给你一些 C++ 抽象的例子,我将展示一个问题如何在 C 和 C++ 中得到解决。
请看以下 C/C++ 代码片段,它们对应于问题:“这本书列表中有多少本《哈姆雷特》?”
我们将从 C 版本开始:
// C version
struct string_elem_t { const char* str_; string_elem_t* next_; };
int num_hamlet(string_elem_t* books) {
const char* hamlet = "Hamlet";
int n = 0;
string_elem_t* b;
for (b = books; b != 0; b = b->next_)
if (strcmp(b->str_, hamlet) == 0)
++n;
return n;
}
使用 C++ 的等效版本看起来像这样:
// C++ version
int num_hamlet(const std::forward_list<std::string>& books) {
return std::count(books.begin(), books.end(), "Hamlet");
}
尽管 C++ 版本仍然更像是一种机器语言而不是人类语言,但由于更高层次的抽象,许多编程术语都消失了。以下是前面两个代码片段之间一些明显的区别:
- 指向原始内存地址的指针完全不可见
std::forward_list<std::string>容器取代了使用string_elem_t手工创建的链表std::count()函数取代了 for 循环和 if 语句std::string类提供了对char*和strcmp()更高层次的抽象
基本上,num_hamlet() 的两个版本翻译成大致相同的机器代码,但 C++ 的语言特性使得库能够隐藏像指针这样的计算机相关术语。许多现代 C++ 语言特性可以被视为在基本 C 功能之上的抽象。
其他语言中的抽象
大多数编程语言都基于抽象,这些抽象会被转换为机器代码,由 CPU 执行。C++ 已经发展成为一种高度富有表现力的语言,就像当今许多其他流行的编程语言一样。
C++ 与大多数其他语言的区别在于:其他语言是以运行时性能为代价来实现这些抽象的,而 C++ 始终致力于以零运行时开销来实现其抽象。这并不意味着用 C++ 编写的应用程序默认就比同等的(比如)C# 应用程序快。相反,它意味着如果你需要,你可以对生成的机器代码指令和内存占用进行精细控制。
公平地说,如今极少需要追求最佳性能,在许多情况下,像其他语言那样为了更短的编译时间、垃圾回收或安全性而牺牲性能是更合理的。
零开销原则
“零开销抽象”是一个常用术语,但它有一个问题——大多数抽象通常都会产生开销。如果不是在程序运行时产生开销,它几乎总会在后续的某个环节产生开销,比如漫长的编译时间、难以解释的编译错误信息等等。通常更值得谈论的是零开销原则。C++ 的发明者 Bjarne Stroustrup 对零开销原则的定义如下:
- 你没有使用的,你就不必为此付出代价
- 你确实使用的,你无法手动编写得更好
这是 C++ 的一个核心原则,也是该语言发展中一个非常重要的方面。你可能会问,为什么?基于此原则构建的抽象将被关注性能的程序员以及在性能高度关键的上下文中广泛接受和使用。找到被多数人认同并广泛使用的抽象,可以使我们的代码库更容易阅读和维护。
相反,C++ 语言中未能完全遵循零开销原则的特性往往会被程序员、项目和公司放弃。这类最值得注意的两个特性是异常(不幸的是)和运行时类型信息(RTTI)。即使在不使用这些特性时,它们也可能对性能产生影响。不过,我强烈建议使用异常,除非你有充分的理由不这样做。在大多数情况下,与使用其他错误处理机制相比,异常的性能开销可以忽略不计。
可移植性 (Portability)
C++ 长期以来一直是一种流行且全面的语言。无论好坏,它与 C 高度兼容,语言中极少有特性被弃用。C++ 的历史和设计使其成为一种高度可移植的语言,而现代 C++ 的发展确保了它在未来很长一段时间内都将保持这种状态。C++ 是一种活着的语言,编译器厂商目前正在以出色的速度实现新的语言特性。
健壮性 (Robustness)
除了性能、表达性和可移植性之外,C++ 还提供了一套语言特性,赋予程序员编写健壮代码的能力。
在作者看来,健壮性指的不是编程语言本身的强度——用任何语言都可以编写健壮的代码。更确切地说,资源的严格所有权、const正确性、值语义、类型安全以及对象的确定性销毁是 C++ 提供的一些特性,它们使编写健壮的代码更容易。也就是说,更容易编写易于使用且难以误用的函数、类和库。
如今的 C++ (C++ of Today)
总而言之,如今的 C++ 使程序员能够编写富有表现力且健壮的代码库,同时仍可选择针对几乎任何硬件平台或实时要求。在当今最常用的语言中,唯有 C++ 拥有所有这些特性。
我已经简要介绍了 C++ 至今仍是一种相关且广泛使用的编程语言的原因。在下一节中,我们将探讨 C++ 与其他现代编程语言的比较。
C++ 与其他语言的比较 (C++ Compared with Other Languages)
自从 C++ 首次发布以来,已经出现了各种各样的应用程序类型、平台和编程语言。尽管如此,C++ 仍然是一种广泛使用的语言,并且它的编译器适用于大多数平台。迄今为止,主要的例外是网络平台,那里的基础是 JavaScript 及其相关技术。然而,网络平台正在发展,变得能够执行以前只有桌面应用程序才能实现的功能,在这种背景下,C++ 已通过 Emscripten、asm.js 和 WebAssembly 等技术找到了进入网络应用程序的方式。
在本节中,我们将首先从性能的角度来看待竞争语言。接着,我们将比较 C++ 如何处理对象所有权和垃圾回收与其他语言的不同之处,以及如何在 C++ 中避免空(null)对象。最后,我们将讨论 C++ 的一些缺点,用户在考虑该语言是否适合他们的要求时应牢记这些缺点。
竞争语言与性能
为了理解 C++ 如何实现其相对于其他编程语言的性能,让我们讨论 C++ 与大多数其他现代编程语言之间的一些根本区别。
为简单起见,本节将重点比较 C++ 与 Java,尽管这些比较在大多数方面也适用于其他基于垃圾回收器的编程语言,例如 C# 和 JavaScript。
首先,Java 编译为字节码,然后在应用程序执行时将字节码编译为机器代码,而大多数 C++ 实现则直接将源代码编译为机器代码。尽管字节码和即时 (JIT) 编译器理论上可能能够实现与预编译机器代码相同(或者,理论上,甚至更好)的性能,但截至目前,它们通常做不到。不过,公平地说,它们在大多数情况下表现得足够好。
其次,Java 处理动态内存的方式与 C++ 完全不同。在 Java 中,内存是通过垃圾回收器自动释放的,而 C++ 程序则通过手动或引用计数机制来处理内存释放。垃圾回收器确实可以防止内存泄漏,但代价是性能和可预测性。
第三,Java 将其所有对象都放置在单独的堆分配中,而 C++ 允许程序员将对象放置在栈和堆上。在 C++ 中,还可以在一次堆分配中创建多个对象。这可以带来巨大的性能提升,原因有二:无需总是分配动态内存即可创建对象,并且多个相关对象可以在内存中彼此相邻放置。
请看以下示例中内存是如何分配的。C++ 函数将对象和整数都放在栈上;而 Java 将对象放在堆上:
现在请看下一个示例,了解在使用 C++ 和 Java 时,一个 Car 对象数组在内存中是如何分别放置的:
C++ 的 vector 包含实际的 Car 对象,它们被放置在一个连续的内存块中,而 Java 中的等价物是一个包含 Car 对象引用的连续内存块。在 Java 中,这些对象是单独分配的,这意味着它们可能位于堆上的任意位置。
这会影响性能,因为在这个例子中,Java 实际上必须在 Java 堆空间中执行五次分配。这也意味着,无论何时应用程序迭代这个列表,C++ 都会获得性能上的优势,因为访问附近的内存位置比访问内存中多个随机位置要快。
非性能相关的 C++ 语言特性
人们很容易认为 C++ 只有在性能是主要考虑因素时才应该使用。难道不是因为手动内存处理会增加代码库的复杂性,从而可能导致内存泄漏和难以追踪的错误吗?
这可能在几个 C++ 版本之前是事实,但现代 C++ 程序员依赖于标准库提供的容器和智能指针类型。过去 10 年中添加的 C++ 特性有很大一部分使得该语言既更强大又更简单易用。
我想在这里强调 C++ 中一些与健壮性而非性能相关的、强大但容易被忽视的旧有特性:值语义、const 正确性、所有权、确定性销毁和引用。
值语义 (Value Semantics)
C++ 同时支持值语义和引用语义。值语义允许我们按值传递对象,而不仅仅是传递对象的引用。在 C++ 中,值语义是默认行为,这意味着当你传递一个类或结构体的实例时,它的行为方式与传递 int、float 或任何其他基本类型相同。要使用引用语义,我们需要显式使用引用或指针。
C++ 类型系统使我们能够显式声明对象的所有权。比较以下 C++ 和 Java 中一个简单类的实现。我们从 C++ 版本开始:
// C++
class Bagel {
public:
Bagel(std::set<std::string> ts) : toppings_(std::move(ts)) {}
private:
std::set<std::string> toppings_;
};
Java 中对应的实现可能如下所示:
// Java
class Bagel {
public Bagel(ArrayList<String> ts) { toppings_ = ts; }
private ArrayList<String> toppings_;
}
在 C++ 版本中,程序员声明 toppings(配料) 被 Bagel(贝果)类完全封装。如果程序员打算让配料列表在多个贝果之间共享,它将被声明为某种指针:如果所有权在多个贝果之间共享,则使用 std::shared_ptr;如果配料列表由其他人拥有并在程序执行时应该被修改,则使用 std::weak_ptr。
在 Java 中,对象之间使用共享所有权进行引用。因此,无法区分配料列表是否打算在多个贝果之间共享,是否在其他地方被处理,或者(像在大多数情况下那样)是否完全归 Bagel 类所有。
比较以下函数;由于在 Java(以及大多数其他语言)中所有对象默认都是共享的,程序员必须预防诸如此类的微妙错误:
常量正确性(Const correctness)
C++ 的另一个强大特性(而 Java 和许多其他语言所缺乏的),是能够编写完全遵循常量正确性(const correctness)的代码。
所谓常量正确性,指的是:类的每个成员函数的函数签名都会明确告知调用者该函数是否会修改对象;如果调用者试图修改一个被声明为 const 的对象,程序将无法通过编译。
在 Java 中,我们虽然可以使用 final 关键字来声明常量,但它无法像 C++ 那样为成员函数声明 const,从而缺少这种静态保障机制。
下面的示例展示了如何通过 const 成员函数来防止对象被无意修改。在以下的 Person 类中,成员函数 age() 被声明为 const,因此不允许修改 Person 对象;而 set_age() 会修改对象,因此不能被声明为 const:
class Person {
public:
auto age() const { return age_; }
auto set_age(int age) { age_ = age; }
private:
int age_{};
};
C++ 还可以通过返回可变(mutable)或不可变(immutable)的成员引用来加以区分。
在下面的 Team 类中,成员函数 leader() const 返回一个不可变的 Person 引用,而 leader()(没有 const 限定)返回一个可变的 Person 对象:
class Team {
public:
auto& leader() const { return leader_; }
auto& leader() { return leader_; }
private:
Person leader_{};
};
接下来,我们看看编译器如何帮助我们在试图修改不可变对象时发现错误。
在下面的例子中,函数参数 teams 被声明为 const,这明确表示该函数不允许修改传入的对象:
void nonmutating_func(const std::vector<Team>& teams) {
auto tot_age = 0;
// Compiles, both leader() and age() are declared const
for (const auto& team : teams)
tot_age += team.leader().age();
// Will not compile, set_age() requires a mutable object
for (auto& team : teams)
team.leader().set_age(20);
}
如果我们想编写一个可以修改 teams 对象的函数,只需去掉 const 限定即可。
这向调用者发出一个信号:此函数可能会修改传入的 teams 对象。
void mutating_func(std::vector<Team>& teams) {
auto tot_age = 0;
// Compiles, const functions can be called on mutable objects
for (const auto& team : teams)
tot_age += team.leader().age();
// Compiles, teams is a mutable variable
for (auto& team : teams)
team.leader().set_age(20);
}
对象所有权(Object ownership)
除极少数情况外,C++ 程序员应当将内存管理交由容器和智能指针(smart pointers)处理,而不依赖手动的内存管理。
明确地说,如果在 C++ 中为每个对象都使用 std::shared_ptr,几乎可以模拟 Java 的垃圾回收模型。
不过需要注意,垃圾回收语言并不是采用与 std::shared_ptr 相同的分配跟踪算法。
std::shared_ptr 是一种基于引用计数(reference counting)的智能指针,如果对象之间存在循环依赖(cyclic dependency),它就可能导致内存泄漏。
而垃圾回收语言采用了更复杂的算法,能够检测并释放具有循环依赖关系的对象。
然而,与其依赖垃圾回收器,C++ 更倾向于通过强制的严格所有权模型来避免默认共享对象可能引发的微妙错误——而这正是 Java 等语言中常见的问题。
如果程序员在 C++ 中尽量减少对象的共享所有权,那么得到的代码将更易于使用且更难被误用,因为它可以强制使用者按照类的设计意图来使用它。
C++ 中的确定性析构(Deterministic destruction in C++)
在 C++ 中,对象的销毁是确定性的(deterministic)。 这意味着我们确切知道对象何时会被销毁。
而在像 Java 这样的垃圾回收语言中,对象的销毁时机由垃圾回收器决定,程序员无法预测未被引用的对象何时会被回收。
在 C++ 中,我们可以可靠地撤销对象生命周期内所做的操作。 这看似只是一个小特性,但实际上对 C++ 如何提供异常安全保证(exception safety guarantees)以及管理资源(如内存、文件句柄、互斥锁等)都有深远影响。
确定性析构也是让 C++ 具备可预测性(predictability)的关键特性之一。 这种可预测性在程序员中备受重视,并且是高性能应用程序的重要前提。
本书后面将进一步讨论对象所有权、生命周期和资源管理,因此如果目前觉得这些概念还不太清楚,也不必担心。
使用 C++ 引用避免空对象(Avoiding null objects using C++ references)
除了严格的所有权模型,C++ 还引入了引用(reference)的概念,它与 Java 中的引用不同。 在底层实现上,C++ 引用其实是一个不能为 null、也不能被重新指向的指针; 因此,在将引用传递给函数时,不会发生拷贝。
这意味着函数签名可以显式地限制程序员不能传入空对象(null object)作为参数。 而在 Java 中,程序员必须通过文档或注解(annotation)来标明参数是否允许为 null。
例如,下面是两个用于计算球体体积的 Java 函数:
第一个函数在传入 null 对象时会抛出运行时异常(runtime exception):
// Java
float getVolume1(Sphere s) {
float cube = Math.pow(s.radius(), 3);
return (Math.PI * 4 / 3) * cube;
}
第二个函数则静默地忽略传入的空对象。
// Java
float getVolume2(Sphere s) {
float rad = s == null ? 0.0f : s.radius();
float cube = Math.pow(rad, 3);
return (Math.PI * 4 / 3) * cube;
}
在这两个 Java 实现的函数中,函数的调用者必须查看函数的实现代码,才能判断是否允许传入 null 对象。
而在 C++ 中,第一种函数签名通过使用不可为 null 的引用(reference)作为参数,明确表示只能接收已初始化的对象。 第二种函数版本使用指针(pointer)作为参数,则显式表明函数会处理可能为 null 的对象。
在 C++ 中,通过引用传参表示参数不允许为 null:
auto get_volume1(const Sphere& s) {
auto cube = std::pow(s.radius(), 3.f);
auto pi = 3.14f;
return (pi * 4.f / 3.f) * cube;
}
在 C++ 中,通过指针传参表示函数会处理可能为 null 的情况:
auto get_volume2(const Sphere* s) {
auto rad = s ? s->radius() : 0.f;
auto cube = std::pow(rad, 3);
auto pi = 3.14f;
return (pi * 4.f / 3.f) * cube;
}
在 C++ 中,使用引用(reference)或值(value)作为参数能够立即告诉程序员函数的设计意图——是否允许空对象传入。
而在 Java 中,由于所有对象都是以指针方式传递,因此调用者必须查看函数实现才能确定对象是否可能为 null。
C++ 的缺点
如果在比较 C++ 与其他编程语言时不提及它的一些缺点,那是不公平的。正如前文所述,C++ 的概念更多,因此更难以正确使用并充分发挥其潜力。然而,如果程序员能够精通 C++,其较高的复杂性反而会转化为优势,使代码库更加健壮、性能更佳。
尽管如此,C++ 仍然存在一些无法忽视的短板。其中最严重的缺点是编译时间过长以及导入库的复杂性。在 C++20 之前,C++ 一直依赖一种过时的导入系统——在这种机制下,被导入的头文件只是简单地被“粘贴”到引用它们的地方。C++20 引入的 模块(modules) 特性将解决部分依赖头文件机制所导致的问题,同时也将显著改善大型项目的编译时间。
另一个明显的缺点是 标准库提供的功能有限。与其他语言不同,许多语言通常自带丰富的库,涵盖图形、用户界面、网络、线程、资源管理等应用所需的各类功能,而 C++ 几乎只提供最基本的算法、线程,以及自 C++17 起引入的文件系统功能。除此之外,程序员必须依赖外部库来实现更多功能。
总而言之,尽管 C++ 的学习曲线比大多数语言更陡峭,但如果能正确使用,它的健壮性相较其他语言是一大优势。因此,尽管存在编译时间长、标准库功能不足等问题,我仍然认为 C++ 是一种非常适合用于大型项目的编程语言——即使在性能并非首要考虑的项目中,也同样适用。
本书中使用的库与编译器
如前所述,C++ 在标准库层面仅提供最基本的功能。因此,在本书中,我们将在必要时依赖外部库。C++ 领域中最常用的外部库可能是 Boost 库(http://www.boost.org)。
本书的部分章节将在标准库不足的地方使用 Boost 库。我们仅使用 Boost 的头文件版本(header-only)部分,这意味着读者在使用时无需进行额外的构建配置,只需包含相应的头文件即可。
此外,我们将使用 Google Benchmark——一个用于微基准测试(microbenchmark)的支持库,用于评估小段代码的性能。Google Benchmark 将在第 3 章《性能分析与测量》中介绍。
本书的配套源码可在以下仓库获取: 👉 https://github.com/PacktPublishing/Cpp-High-Performance-Second-Edition
该仓库使用 Google Test 框架,以便您更轻松地构建、运行和测试示例代码。
需要说明的是,本书大量使用了 C++20 的新特性。在写作时,这些特性在我们使用的编译器(Clang、GCC、Microsoft Visual C++)中尚未完全实现,有些功能仍处于实验性支持或尚未实现的状态。关于主流 C++ 编译器对标准支持的最新信息,可在以下页面查看: 👉 https://en.cppreference.com/w/cpp/compiler_support
小结
在本章中,我们介绍了 C++ 的一些特性与缺点,并探讨了它的发展历程。同时,我们比较了 C++ 与其他语言在性能和健壮性方面的优劣。
在下一章中,我们将深入探讨一些对 C++ 语言发展影响深远的现代化核心特性。
- 显示Disqus评论(需要科学上网)


