C++ 正在快速向前发展,所以想要紧跟其脚步并不是一件容易的事。我们在之前的文章中讨论过这个问题,讨论了 C++ 的演变以及如何实现遗留 C++ 代码现代化。在这篇文章中,我们将重点介绍经验丰富的 C++ 开发人员可以跟上的高级主题列表。该列表并不详尽,而且有点主观(我们可能已经放弃了一些您认为实际上非常重要的项目,或者一些您认为对您来说不那么重要的项目)。尽管我们的目标是列出高级 C++ 主题,对一些人来说可能是高级主题,但对其他人来说可能只是基础主题。此外,新的 C++ 特性也有不同级别的复杂性。有些适用于所有人,有些适用于图书馆和基础设施维护人员。我们将尝试涵盖我们认为相关的内容,而不限于特定的 C++ 用法。在我们开始之前的最后一个注意事项:高级 C++ 内容并不一定意味着新的 C++ 特性。对于有些应当给予适当关注并且从 C++98 开始我们就一直关注的高级 C++ 主题,我们也会在列表中列出其中一部分。
需要注意的是,这篇文章并不是教程,它的目的不是教东西,而是指出您应该放入所需的 C++ 技能列表并添加到您的 C++ 学习路径中的相关高级 C++ 主题。
接下来我们就开始了。
模板
模板是 C++ 提供的最强大的工具之一,但在某些情况下,它们并没有得到应有的利用。有些公司将模板认为是基础设施团队的工具,而“常规”C++ 团队只是在使用它们。我认为,任何团队的高级 C++ 开发人员都应该能够在任何相关的地方实现模板,以实现代码重用、获取更高效的代码和更好的 API。
您不必了解模板的所有细节(除非您确实编写了通用模板库),但您应该从掌握简单模板函数和模板类的细节开始,然后是类型和非类型模板参数的规则(您可以将模板参数先放在一边,至少在开始时)。
C++11 增加了可变参数模板,您还应该知道如何根据需要使用这些模板。请记住,诸如 emplace、make_tuple、make_unique 和 make_shared 之类的函数全部都依赖于可变参数模板,您可能需要使用可变参数模板自己实现类似的工厂方法,这并不是理论上的。
您还可以使用模板特化,这是一种可以追溯到 C++98 的技术。无论是完全特化还是部分特化,您都会发现这种技术对于特定类型或类型族实现更有效的算法来说很有用。
另一种旧的(同样基于 C++98)但是同样有用的模板技术是标签调度。本文中也介绍了如何使用 C++20 概念来代替标签调度。此外,C++17 if-constexpr 有时可以成为标签调度的相关替代品。
C++17 增加了类模板参数推导 (CTAD),它可以更轻松地将模板类对象实例化,而无需提供模板参数,例如:
std::vector v1{1, 2, 3}; // std::vector<int>
std::tuple p1{1, "wahad", "one"s}; //std::tuple<int, const char*, std::string>
静态多态性这一术语也很重要。当您在编译时知道某些代码是否应该使用 TCPConnection 或 UDPConnection 时,与基于虚拟函数的动态多态性相比,正确使用模板来管理不同的实现将获得更好的运行时性能。
为了总结我们的模板技术列表,我们添加了 CRTP,它经常被用作静态多态性背后的工具,但不是唯一的工具(有关在基类中实现克隆方法,请参见本例)。
请注意,本文中未列出 SFINAE,因为虽然它在 C++20 之前非常相关,但现在它已被一些概念所取代,使它看起来过时了。
推荐阅读:
- C++ 模板——完整指南,第 2 版,作者 David Vandevoorde、Nicolai Josuttis 和 DouglasGregor——涵盖了 C++17 之前(包含 C++17)的所有新内容。
- Notebook C++——模板使用技巧和窍门,作者 Andreas Fertig
模板练习:
假设我们需要管理一个大多数条目 (99%) 为假的布尔数组。为“大布尔数组”和“小布尔数组”实现基于模板的策略,这样用户只需创建一个布尔数组,并根据请求的大小选择底层实现:
BoolArray<5> b1; // all values are initialized to false
// ^ the underlying internal data structure would be bool arr[5]
BoolArray<1005> b2; // all values are initialized to false
// ^ the underlying internal data structure is std::set<size_t>
// holding only all the “true” indices (which are expected to be a few)
// following operations shall be supported:
// [1] simple assignment
b1[0] = true;
b2[0] = true;
// [2] toggle values using range-based-for
template<size_t SIZE>
void toggle_a_few(BoolArray<SIZE>& b) {
for(auto&& val: b) {
if(some_rare_case)
val != val;
}
}
您可以点击此处查看上述练习的解决方案(不要偷看,先尝试自己解决)。
右值和移动语义
在我们进行的两项调查中(CoreCpp 2021 和 CppCon 2021),右值和移动语义在被引入 C++ 10 年后被认为是一个复杂的话题。
你应该知道什么是右值,并且知道左值和右值的重载解析。
至于移动语义和 std::move,不使用移动语义意味着放弃性能提升。而错误地使用它则意味着潜在错误。
这里有一些例子:
在需要时忘记使用 std::move :
class Pesron {
std::string name;
public:
Person(const std::string& p_name) : name(p_name) {}
Person(std::string&& p_name) : name(p_name) {} // oops
// ...
};
您能说出标有 “oops” 的那行有什么问题吗?
适当时不使用 std::move 的另一个示例:
// popping the last element from a vector
auto val = a.back(); // allow call of move ctor if available
a.pop_back();
您能告诉我们如何使上面的代码更有效吗?
好吧,请看这里:
auto val = std::move(a.back()); // allow call of move ctor if available
a.pop_back();
您应该注意的其他问题包括实现移动忘记noexcept
的问题、在能够使用的时候不使用零规则、丢失良好的默认移动操作而不用 =default 取回它们、在不应该使用时使用 std::move(从您仍在使用的对象中窃取)、在相关时不使用 std::forward。
定位 new
定位 new 的概念是,我们可以在特定的给定内存位置创建一个对象,方法是调用该内存位置上的构造函数,而无需实际分配内存。当一个新项目被添加到一个已经分配的容量中时,std::vector 会使用这个项目。了解和理解定位 new 对于理解 std::vector 的实现方式非常重要。
强类型
使用用户定义的文字类型,现在可以轻松直接地使用像 std::chrono 这样的强类型。其主要思想是将数据与其测量单位联系起来。测量单位的错误解释是导致卫星坠毁的已知原因(您可以单击此处和此处了解有关 Ariane 5 坠毁的更多信息,也可以单击此处了解有关火星气候轨道飞行器坠毁的更多信息)。
如果您还没有用过强类型,您可能需要进一步了解。例如,可参阅 Joe Boccara 的强类型库,可点击此处了解详细内容。其他强类型库实现包括:
- https://github.com/nholthaus/units
- https://github.com/pierreblavy2/unit_lite
- https://github.com/bernedom/SI
- https://github.com/mpusz/units
智能指针
同样,根据我们进行的调查(CoreCpp 2021 和 CppCon 2021),内存泄漏和调试内存错误在 2022 年仍然是一个问题。这可以通过更好地使用 C++ 智能指针来解决。
了解 unique_ptr、shared_ptr 和 weak_ptr 很重要。正确使用智能指针将有助于使类遵循零规则。
使用智能指针实现适当的 API 对于实现系统设计目标至关重要。每种智能指针类型都有自己的语义和特定用例。Herb Sutter 有几篇关于这个主题的经典帖子,您可能想要关注,例如:GotW #89 智能指针和 GotW #91 智能指针参数。
容器和算法
无需引入 std::vector,但您可能仍希望确保避免 std::vector 陷阱。您还应该了解 C++17 新增的 std::optional、std::variant、std::any 和 std::string_view 以及何时使用它们。此外还有 C++20 新增的 std::span 和范围库。
知道 std::array 是一种聚合类型并且它不重视元素初始化也很重要(即,当创建整数的 std::array 时,整数值默认不会自动初始化为零,这与初始化为零的 std::vector 正好相反)。
问题:
如何创建一个包含 100 个整数的 std::array 并将它们全部初始化为零?(注意:std::array 没有适当的构造函数,实际上它没有任何构造函数。)
答案:
std::array<int, 100> arr {}; // aggregate initialization, all ints are initialized to zero
最后,布尔向量的秘密行为虽然不是必须要掌握的东西,但是了解它会让您进入专家阵营。
至于算法,重要的是要在实现自己的算法之前检查标准算法(包括范围算法)。在许多情况下,您会发现您要实现的算法要么是现有的,要么可以使用现有的算法来实现。
Lambdas
C++11 将 Lambda 表达式添加到 C++ 中,而 C++14、C++17 和 C++20 中则添加了新的重要语法选项(请参阅关于 lambda 演变的两部分博客文章:第 1 部分和第 2 部分)。
如果您使用 C++11 或更高版本,您很可能知道 lambda 表达式。确保您也了解其中的新增内容(参见上面的链接,以及其他资源,如 Jason Turner 的 C++ Weekly 中关于 lambda 的播放列表。如果您全部观看完,您将真正掌握 lambda!)。
持续评估
C++11 添加了 constexpr 关键字,它允许定义在编译时分配有已知值的常量,以及可以在编译时执行的函数。随着 C++ 不同版本的演变,constexpr 的可能性显着发展,消除了 C++11 中 constexpr 函数的许多限制(您可以在 constexpr cppreference 页面和关于该主题的这篇非常详尽的博客文章中了解演变内容)。
了解在编译时可以做什么对性能很有用。使用 C++17 if constexpr,您可以编写更好的通用代码,避免 SFINAE 或概念中不必要的复杂重载。从C++20 开始,std::string 和 std::vector 都具有 constexpr 构造函数,因此可以在编译时创建。
C++20 还添加了 consteval 和 constinit,您可能想要了解它们(例如,Jason Turner 的 C++ Weekly 中关于 consteval 和 constinit 的视频)。
多线程和并发
多线程和并发通常是一个很大的话题,特别是在 C++ 中。从如何使用 std::thread 的基本语法开始将是一个很好的起点。了解 C++20 std::jthread 可使您具备基础知识。
在编写并发代码时,知道如何以及在何处使用互斥锁和锁守卫当然是至关重要的。这包括用于多个互斥锁的有用 C++17 互斥锁包装器,即 scoped_lock。在上述知识的基础上,最好再了解 C++20 新锁、counting_semaphore 和二进制信号量以及 std::latch 和 std::barrier。
了解容器的线程安全规则很重要,因为在某些时候您会很自然地在多线程应用程序中使用标准容器。
知道如何使用原子变量和诸如 compare_exchange 之类的原子操作可以让您实现无锁算法。
了解 std::promise、std::future 和 std::packaged_task 后,您能够实现更好的基于多线程的异步操作。
最后,了解如何以及何时使用 thread_local 变量也很重要。
std::conditional_variable 的使用可以留给库实现人员,但如果您处于那个位置,那么也应该了解其使用。
与本文的其他部分一样,这不是所有 C++ 并发类和实用程序的完整列表(可以点击此处进行了解)。但它应该会为覆盖大部分重要内容铺平道路。
新的 C++20 特性
我们已经在前一篇文章中介绍了 C++20 的四大方面:模块、概念、协程和范围。在上一篇博文中,我们还提到了 C++20 中添加的 spaceship 运算符。我们还专门发布了一篇有关协程的文章。您应该熟悉这些新增内容,但是如果您还没有使用 C++20,那么您当然不需要掌握它们。
总结
我们在这篇文章中尝试列出我们认为 C++ 高级开发人员应该知道的和通常使用的 C++ 主题。当然,C++ 中还有其他高级和重要的特性和主题没有涉及到。我们跳过了我们认为基本的内容。但我们也可能会忽略可以添加到列表中的高级功能。这绝不是一个详尽的列表,几乎可以肯定的是,您可以想到其他可以添加的高级 C++ 项目。
C++ 是一门非常丰富的语言,而且还在不断发展!全部掌握它几乎是不可能的。每个 C++ 开发人员的目标应该是不断学习,持续关注新特性(同时确保您对旧特性不会有知识空白)——确保您不会落后。