找回密码
 立即注册
首页 业界区 安全 C++高性能:优化代码运行效率的艺术1 C++简介 ...

C++高性能:优化代码运行效率的艺术1 C++简介

撒阗奕 昨天 21:59
1 C++ 简介

本书旨在为您提供编写高效应用程序的坚实基础,并深入了解在现代 C++ 中实现库的策略。我尝试以实用的方法来解释当今 C++ 的工作原理,其中从 C++ 11 到 C++ 20 的现代特性已成为该语言的自然组成部分,而不是从历史的角度来看待 C++。
在本章中,我们将:

  • 介绍一些对于编写健壮、高性能应用程序至关重要的 C++ 特性
  • 讨论 C++ 相对于其他竞争语言的优缺点
  • 介绍本书中使用的库和编译器
1.1 为什么选择 C++?

让我们首先探讨当今使用 C++ 的一些原因。简而言之,C++ 是一种高度可移植的语言,提供零成本的抽象。此外,C++ 还使程序员能够编写和管理大型、富有表现力且健壮的代码库。在本节中,我们将探讨零成本抽象的含义,比较 C++ 抽象与其他语言的抽象,并讨论可移植性和健壮性,以及这些特性为何如此重要。
让我们先来了解一下零成本抽象。
1.1.1 零成本抽象(Zero-cost abstractions)

活跃的代码库不断增长。开发代码库的开发人员越多,代码库就越庞大。为了管理日益增长的代码库复杂性,我们需要变量、函数和类等语言特性,以便能够创建具有自定义名称和接口的抽象,从而隐藏实现细节。
C++ 允许我们定义自己的抽象,但它也自带一些内置抽象。例如,C++ 函数的概念本身就是一种控制程序流的抽象。基于范围的 for 循环是另一个内置抽象的例子,它使得更直接地迭代一系列值成为可能。作为程序员,我们在开发程序的过程中不断添加新的抽象。同样,新版本的 C++ 也为语言和标准库引入了新的抽象。但不断添加抽象和新的间接层级是有代价的——效率。零成本抽象正是为此而生。C++ 提供的许多抽象在运行时空间和时间方面的成本非常低。
使用 C++,您可以根据需要自由地使用内存地址和其他与计算机相关的低级术语。然而,在大型软件项目中,最好使用能够处理应用程序正在执行的操作的术语来表达代码,而让库来处理与计算机相关的术语。图形应用程序的源代码可能处理铅笔、颜色和滤镜,而游戏可能处理吉祥物、城堡和蘑菇。在性能至关重要的 C++ 库代码中,可以隐藏与计算机相关的低级术语(例如内存地址)。
1.1.1.1 编程语言与机器码抽象

为了减轻程序员处理计算机相关术语的负担,现代编程语言使用了抽象的概念,例如,字符串列表可以被处理并视为字符串列表,而不是地址列表,因为地址列表一旦出现哪怕是最轻微的拼写错误,我们很容易就会忘记它的含义。抽象不仅使程序员避免了错误,还通过使用应用程序领域的概念,使代码更具表达力。换句话说,代码的表达方式比使用抽象的编程关键字更接近口语。
如今,C++ 和 C 是两种完全不同的语言。尽管如此,C++ 与 C 高度兼容,并继承了 C 的许多语法和惯用语。为了举例说明 C++ 抽象的概念,我将展示如何在 C 和 C++ 中解决一个问题。
请看以下 C/C++ 代码片段,它们分别对应以下问题:“这份书单中有多少本《哈姆雷特》?”
我们将从 C 版本开始:
  1. // C version
  2. struct string_elem_t { const char* str_; string_elem_t* next_; };
  3. int num_hamlet(string_elem_t* books) {
  4.   const char* hamlet = "Hamlet";
  5.   int n = 0;
  6.   string_elem_t* b;
  7.   for (b = books; b != 0; b = b->next_)
  8.     if (strcmp(b->str_, hamlet) == 0)
  9.       ++n;
  10.   return n;
  11. }
复制代码
使用 C++ 的等效版本如下所示:
  1. // C++ version
  2. int num_hamlet(const std::forward_list<std::string>& books) {
  3.   return std::count(books.begin(), books.end(), "Hamlet");
  4. }
复制代码
虽然 C++ 版本仍然更像一种机器人语言而非人类语言,但由于更高层次的抽象,许多编程术语已被摒弃。以下是前两段代码片段之间的一些显著区别:

  • 指向原始内存地址的指针完全不可见
  • std::forward_liststd::string 容器使用 string_elem_t 替换了手动构建的链表
  • std::count() 函数替换了 for 循环和 if 语句
  • std::strng 类提供了比 char* 和 strcmp() 更高级别的抽象。
    基本上,两个版本的 num_hamlet() 都会转换为大致相同的机器码,但 C++ 的语言特性使得库可以隐藏与计算机相关的术语,例如指针。许多现代 C++ 语言特性都可以看作是 C 语言基本功能之上的抽象。
1.1.1.2 其他语言中的抽象

大多数编程语言都基于抽象,这些抽象会被转换为机器码并由 CPU 执行。C++ 已经发展成为一种高度表达的语言,就像当今许多其他流行的编程语言一样。C++ 与大多数其他语言的区别在于,其他语言以牺牲运行时性能为代价来实现这些抽象,而 C++ 始终致力于在运行时以零成本实现其抽象。这并不意味着用 C++ 编写的应用程序默认比用 C# 等编写的等效应用程序更快。相反,这意味着通过使用 C++,您可以根据需要对发出的机器码指令和内存占用进行细粒度的控制。
公平地说,如今极少需要追求最佳性能,而像其他语言那样为了缩短编译时间、进行垃圾回收或提高安全性而牺牲性能,在很多情况下更为合理。
1.1.1.3 零开销原则

“零开销抽象”是一个常用术语,但它也存在一个问题——大多数抽象通常都会产生开销。即使不是在程序运行过程中,也几乎总是会在后续的某个阶段产生开销,例如较长的编译时间、难以解读的编译错误消息等等。通常更有趣的话题是零开销原则。C++ 的发明者 Bjarne Stroustrup 对零开销原则的定义如下:

  • 你不使用的,无需付费
  • 你使用的代码,你不可能写得更好
这是 C++ 的核心原则,也是该语言发展过程中非常重要的一个方面。你可能会问,为什么呢?基于此原则构建的抽象将被注重性能的程序员广泛接受和使用,尤其是在性能至关重要的环境中。找到许多人认同并广泛使用的抽象,可以使我们的代码库更易于阅读和维护。
相反,C++ 语言中不完全遵循零开销原则的特性往往会被程序员、项目和公司抛弃。这类特性中最值得注意的两个特性是异常(不幸的是)和运行时类型信息 (RTTI)。即使不使用,这两个特性也会对性能产生影响。但我强烈建议使用异常,除非您有充分的理由不使用。与使用其他错误处理机制相比,在大多数情况下,异常的性能开销可以忽略不计。
1.1.2 可移植性(Portability)

C++ 长期以来一直是一种流行且功能全面的语言。它与 C 高度兼容,并且语言中几乎没有被弃用的功能,无论好坏。C++ 的历史和设计使其成为一种高度可移植的语言,而现代 C++ 的演进确保了它将在未来很长一段时间内保持这种状态。 C++ 是一种充满活力的语言,编译器供应商目前正在快速实现新的语言特性,这令人瞩目。
1.1.3 健壮性(Robustness)

除了性能、表达能力和可移植性之外,C++ 还提供了一系列语言特性,使程序员能够编写健壮的代码。
根据作者的经验,健壮性并非指编程语言本身的强度——任何语言都可以编写健壮的代码。更确切地说,C++ 提供的一些特性,例如严格的资源所有权、常量正确性、值语义、类型安全以及对象的确定性析构,使编写健壮代码变得更加容易。也就是说,能够编写易于使用且难以滥用的函数、类和库。
1.1.4 当今的 C++

总而言之,当今的 C++ 使程序员能够编写富有表现力且健壮的代码库,同时仍然可以选择针对几乎任何硬件平台或实时需求。在当今最常用的语言中,只有 C++ 具备所有这些特性。
1.2 C++ 与其他语言的比较

自 C++ 首次发布以来,出现了众多应用程序类型、平台和编程语言。尽管如此,C++ 仍然是一种广泛使用的语言,其编译器适用于大多数平台。截至目前,主要的例外是 Web 平台,JavaScript 及其相关技术是其基础。然而,Web 平台正在发展,能够执行以前仅在桌面应用程序中才有可能,而在此背景下,C++ 已通过 Emscripten、asm.js 和 WebAssembly 等技术进入 Web 应用程序。
在本节中,我们将首先从性能角度比较竞争语言。接下来,我们将比较 C++ 与其他语言相比如何处理对象所有权和垃圾收集,以及如何避免 C++ 中的空对象。最后,我们将介绍 C++ 的一些缺点,用户在考虑该语言是否适合其需求时应牢记这些缺点。
1.2.1 竞争语言和性能

为了理解 C++ 与其他编程语言相比如何实现其性能,让我们讨论一下 C++ 与大多数其他现代编程语言之间的一些根本区别。
为简单起见,本节将重点比较 C++ 与 Java,尽管大部分比较也适用于其他基于垃圾收集器的编程语言,例如Python、 C# 和 JavaScript。
首先,Java 会先编译为字节码,然后在应用程序执行时将其编译为机器码,而大多数 C++ 实现则直接将源代码编译为机器码。虽然字节码和即时编译器理论上可以实现与预编译机器码相同(甚至更好)的性能,但目前来看,它们通常无法达到这一水平。不过,公平地说,在大多数情况下,它们的性能已经足够好了。
其次,Java 处理动态内存的方式与 C++ 完全不同。在 Java 中,内存由垃圾收集器自动释放,而 C++ 程序则手动或通过引用计数机制处理内存释放。垃圾收集器确实可以防止内存泄漏,但会牺牲性能和可预测性。
第三,Java 将所有对象放置在单独的堆分配中,而 C++ 允许程序员将对象同时放置在堆栈和堆上。在 C++ 中,还可以在单个堆分配中创建多个对象。这可以带来巨大的性能提升,原因有二:创建对象时无需始终分配动态内存,并且多个相关对象可以在内存中相邻放置。
以下示例展示了内存的分配方式。C++ 函数使用堆栈来存储对象和整数;而 Java 将对象放置在堆中:
1.png

现在看下一个示例,分别了解使用 C++ 和 Java 时,Car 对象数组在内存中的存放方式:
2.png

C++ 中的 Vector 包含放置在一个连续内存块中的实际 Car 对象,而 Java 中的对应对象是一个包含 Car 对象引用的连续内存块。在 Java 中,这些对象是单独分配的,这意味着它们可以位于堆的任何位置。
这会影响性能,因为在本例中,Java 实际上需要在 Java 堆空间中执行五次分配。这也意味着,每当应用程序迭代列表时,C++ 都会获得性能提升,因为访问附近的内存位置比访问内存中的几个随机位置更快。
1.2.2 与性能无关的 C++ 语言特性

人们很容易认为只有在性能是主要考虑因素时才应该使用 C++。然而,C++ 的手动内存处理是否只会增加代码库的复杂性,从而可能导致内存泄漏和难以追踪的错误?
这在几个 C++ 版本之前可能确实如此,但现代 C++ 程序员依赖于标准库中提供的容器和智能指针类型。过去 10 年新增的大量 C++ 特性使该语言更加强大,也更易于使用。
我想在这里重点介绍一些 C++ 中一些古老但强大的特性,它们与健壮性而非性能相关,并且很容易被忽视:值语义、常量正确性、所有权、确定性析构函数和引用。
1.2.2.1 值语义(Value semantics)

C++ 支持值语义以及引用语义。值语义允许我们通过值传递对象,而不仅仅是传递对象的引用。在 C++ 中,值语义是默认的,这意味着传递类或结构体的实例时,其行为与传递 int、float 或任何其他基本类型相同。要使用引用语义,我们需要显式使用引用或指针。
C++ 类型系统使我们能够显式声明对象的所有权。比较以下 C++ 和 Java 中一个简单类的实现。我们先从 C++ 版本开始:
  1. // C++
  2. class Bagel {
  3. public:
  4.   Bagel(std::set<std::string> ts) : toppings_(std::move(ts)) {}
  5. private:
  6.   std::set<std::string> toppings_;
  7. };
复制代码
Java 中相应的实现可能如下所示:
  1. // Java
  2. class Bagel {
  3.   public Bagel(ArrayList<String> ts) { toppings_ = ts; }
  4.   private ArrayList<String> toppings_;
  5. }
复制代码
在 C++ 版本中,程序员声明 toppings 完全由 Bagel 类封装。如果程序员希望 topping 列表在多个 Bagel 之间共享,则需要将其声明为某种类型的指针:如果所有权在多个 Bagel 之间共享,则声明为 std::shared_ptr;如果其他人拥有 topping 列表并在程序执行时对其进行修改,则声明为 std::weak_ptr。
在 Java 中,对象通过共享所有权相互引用。因此,无法区分 topping 列表是否打算在多个 Bagel 之间共享,或者它是否在其他地方处理,或者(在大多数情况下)它完全由 Bagel 类拥有。
比较以下函数;由于 Java(以及大多数其他语言)中每个对象默认都是共享的,程序员必须对诸如此类的细微错误采取预防措施:
3.png

1.2.2.2 常量正确性

C++ 的另一个强大特性是能够编写完全符合常量规范的代码,而 Java 和许多其他语言都缺乏这种特性。常量正确性意味着类的每个成员函数签名都会明确告知调用者该对象是否会被修改;如果调用者尝试修改声明为 const 的对象,则编译不会通过。在 Java 中,可以使用 final 关键字声明常量,但这缺乏将成员函数声明为 const 的能力。
以下示例展示了如何使用 const 成员函数来防止对象的意外修改。在以下 Person 类中,成员函数 age() 被声明为 const,因此不允许修改 Person 对象;而 set_age() 会修改该对象,因此不能被声明为 const:
  1. class Person {
  2. public:
  3.   auto age() const { return age_; }
  4.   auto set_age(int age) { age_ = age; }
  5. private:
  6.   int age_{};
  7. };
复制代码
还可以区分返回成员的可变引用和不可变引用。在以下 Team 类中,成员函数 leader() const 返回一个不可变的 Person 对象,而 leader() 返回一个可以修改的 Person 对象:
  1. class Team {
  2. public:
  3.   auto& leader() const { return leader_; }
  4.   auto& leader() { return leader_; }
  5. private:
  6.   Person leader_{};
  7. };
复制代码
现在让我们看看编译器如何在我们尝试修改不可变对象时帮助我们发现错误。在以下示例中,函数参数 teams 被声明为 const,明确表明该函数不允许修改它们:
  1. void nonmutating_func(const std::vector<Team>& teams) {
  2.   auto tot_age = 0;
  3.   
  4.   // Compiles, both leader() and age() are declared const
  5.   for (const auto& team : teams)
  6.     tot_age += team.leader().age();
  7.   // Will not compile, set_age() requires a mutable object
  8.   for (auto& team : teams)
  9.     team.leader().set_age(20);
  10. }
复制代码
如果我们想要编写一个可以修改 teams 对象的函数,只需删除 const 即可。这会向调用者发出信号,表明该函数可以修改 teams 对象:
  1. void mutating_func(std::vector<Team>& teams) {
  2.   auto tot_age = 0;
  3.   
  4.   // Compiles, const functions can be called on mutable objects
  5.   for (const auto& team : teams)
  6.     tot_age += team.leader().age();
  7.   // Compiles, teams is a mutable variable
  8.   for (auto& team : teams)
  9.     team.leader().set_age(20);
  10. }
复制代码
1.2.2.3 对象所有权

除了极少数情况外,C++ 程序员应该将内存处理交给容器和智能指针,而不要依赖手动内存处理。
简而言之,垃圾收集器Java 中的 ction 模型几乎可以通过在 C++ 中为每个对象使用 std::shared_ptr 来模拟。需要注意的是,垃圾收集语言使用的分配跟踪算法与 std::shared_ptr 不同。std::shared_ptr 是一个基于引用计数算法的智能指针,如果对象存在循环依赖,它将导致内存泄漏。垃圾收集语言拥有更复杂的方法来处理和释放循环依赖对象。
然而,强制严格所有权并非依赖于垃圾收集器,而是巧妙地避免了默认共享对象可能导致的细微错误,就像 Java 的情况一样。
如果程序员在 C++ 中最小化共享所有权,生成的代码将更易于使用且更难被滥用,因为它可以强制类的用户按照预期使用它。
1.2.2.4 C++ 中的确定性销毁

在 C++ 中,对象的销毁是确定性的。这意味着我们(可以)准确地知道对象何时被销毁。对于像 Java 这样的支持垃圾回收机制的语言来说,情况并非如此。在 Java 中,垃圾回收器会决定何时终止未引用的对象。
在 C++ 中,我们可以可靠地撤销对象生命周期内所做的事情。乍一看,这似乎是一件小事。但事实证明,它对如何在 C++ 中提供异常安全保障和处理资源(例如内存、文件句柄、互斥锁等)有着巨大的影响。
确定性析构也是使 C++ 具有可预测性的特性之一。这在程序员中非常受重视,也是性能关键型应用程序的必备条件。
我们将在本书的后面部分花更多时间讨论对象所有权、生命周期和资源管理。所以,如果现在还不太明白,也不必担心。
1.2.2.5 避免使用 C++ 引用导致空对象

除了严格的所有权之外,C++ 还有引用的概念,这与 Java 中的引用不同。在内部,引用是一个指针,不允许为空或被重新指向;因此,将其传递给函数时不涉及复制。
因此,C++ 中的函数签名可以明确限制程序员传递空对象作为参数。在 Java 中,程序员必须使用文档或注释来指示非空参数。
看一下这两个用于计算球体体积的 Java 函数。如果传递空对象,第一个函数会抛出运行时异常,而第二个函数则会默默地忽略空对象。
第一个 Java 实现如果传递空对象,则会抛出运行时异常:
  1. // Java
  2. float getVolume1(Sphere s) {
  3.   float cube = Math.pow(s.radius(), 3);
  4.   return (Math.PI * 4 / 3) * cube;
  5. }
复制代码
第二个 Java 实现会默默地处理空对象:
  1. float getVolume2(Sphere s) {
  2.   float rad = s == null ? 0.0f : s.radius();
  3.   float cube = Math.pow(rad, 3);
  4.   return (Math.PI * 4 / 3) * cube;
  5. }
复制代码
在 Java 实现的两个函数中,函数调用者必须检查函数的实现,以确定是否允许使用空对象。
在 C++ 中,第一个函数签名通过使用不能为空的引用明确地只接受已初始化的对象。第二个版本使用指针作为参数,明确地表明可以处理空对象。
作为引用传递的 C++ 参数表明不允许使用空值:
  1. auto get_volume1(const Sphere& s) {   
  2.   auto cube = std::pow(s.radius(), 3.f);
  3.   auto pi = 3.14f;
  4.   return (pi * 4.f / 3.f) * cube;
  5. }
复制代码
C++ 参数以指针形式传递时,表示正在处理空值:
  1. auto get_volume2(const Sphere* s) {
  2.   auto rad = s ? s->radius() : 0.f;
  3.   auto cube = std::pow(rad, 3);
  4.   auto pi = 3.14f;
  5.   return (pi * 4.f / 3.f) * cube;
  6. }
复制代码
在 C++ 中,能够使用引用或值作为参数,可以立即告知 C++ 程序员该函数的预期用途。相反,在 Java 中,用户必须检查函数的实现,因为对象始终以指针形式传递,并且有可能为空。
参考资料


  • 软件测试精品书籍文档下载持续更新 https://github.com/china-testing/python-testing-examples 请点赞,谢谢!
  • 本文涉及的python测试开发库 谢谢点赞! https://github.com/china-testing/python_cn_resouce
  • python精品书籍下载 https://github.com/china-testing/python_cn_resouce/blob/main/python_good_books.md
  • Linux精品书籍下载 https://www.cnblogs.com/testing-/p/17438558.html
  • python八字排盘 https://github.com/china-testing/bazi
  • 联系方式:钉ding或V信: pythontesting
1.2.3 C++ 的缺点

如果不提及 C++ 的一些缺点,将 C++ 与其他编程语言进行比较是不公平的。如前所述,C++ 需要学习的概念更多,因此更难以正确使用并充分发挥其潜力。然而,如果程序员能够精通 C++,那么更高的复杂性就会转化为优势,代码库也会变得更加健壮,性能也会更好。
然而,C++ 也存在一些缺点,这些缺点仅仅是缺点而已。其中最严重的缺点是编译时间长和导入库的复杂性。在 C++ 20 之前,C++ 一直依赖于过时的导入系统,导入的头文件只需粘贴到包含它们的文件中即可。C++ 20 中引入的 C++ 模块将解决该系统基于包含头文件的某些问题,并且还将对大型项目的编译时间有积极的影响。
C++ 的另一个明显缺点是缺乏提供的库。其他语言通常包含大多数应用程序所需的所有库,例如图形、用户界面、网络、线程、资源处理等等,而 C++ 几乎只提供了最基本的算法、线程以及(从 C++17 开始的)文件系统处理。对于其他所有功能,程序员都必须依赖外部库。
总而言之,尽管 C++ 的学习曲线比大多数其他语言更陡峭,但如果使用得当,C++ 的稳健性与许多其他语言相比是一个优势。因此,尽管编译时间较长且缺乏提供的库,但我相信 C++ 非常适合大型项目,即使对于性能并非最高优先级的项目也是如此。
1.3 本书中使用的库和编译器

如前所述,C++ 提供的库仅能满足基本需求。因此,在本书中,我们将在必要时依赖外部库。 C++ 世界中最常用的库可能是 Boost 库 (http://www.boost.org)。
本书的某些部分在标准 C++ 库不足的情况下使用了 Boost 库。我们将仅使用 Boost 库中仅包含头文件的部分,这意味着您自己使用它们不需要任何特定的构建设置;您只需包含指定的头文件即可。
此外,我们将使用 Google Benchmark(一个微基准测试支持库)来评估小代码片段的性能。Google Benchmark 将在第 3 章“性能分析和测量”中介绍。
本书的源代码库位于 https://github.com/PacktPublishing/Cpp-High-Performance-Second-Edition,并使用 Google Test 框架,以便您更轻松地构建、运行和测试代码。
还值得一提的是,本书使用了许多 C++20 中的新功能。在撰写本文时,我们使用的编译器(Clang、GCC 和 Microsoft Visual C++)尚未完全实现其中一些功能。文中介绍的一些功能完全缺失或仅处于实验阶段。您可以在 https://en.cppreference.com/w/cpp/compiler_support 找到关于主流 C++ 编译器当前状态的精彩最新摘要。
1.4 小结

在本章中,我重点介绍了 C++ 的一些特性和缺点,以及它是如何发展到今天的状态。此外,我们还从性能和健壮性的角度讨论了 C++ 与其他语言相比的优缺点。
在下一章中,我们将探讨一些对 C++ 发展产生重大影响的现代且重要的特性。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册