C---重构指南-全-
C++ 重构指南(全)
原文:
zh.annas-archive.org/md5/627c8524a8a72cf61d752917eb51d686
译者:飞龙
前言
在高级语言主导技术景观的时代,C++ 仍然是一个基石,推动着从裸机嵌入式平台到分布式、云原生基础设施的广泛系统。它的优势在于能够提供性能敏感的解决方案,同时熟练处理复杂的数据结构。在过去二十年中,C++ 经历了显著的发展,不断适应现代计算的需求。
本书为那些寻求掌握编写清晰、高效 C++ 代码技艺的人提供了一本全面的指南。它深入探讨了 SOLID 原则的实施和利用 C++ 最新特性和方法对遗留代码进行重构。读者将深入理解语言、标准库、广泛的 Boost 库集合以及微软的指南支持库。
从基础知识开始,本书涵盖了编写清晰代码所必需的核心元素,特别强调使用 C++ 进行面向对象编程。它通过使用 Google Test 等流行的单元测试框架的示例,深入探讨了软件测试的设计原则。此外,本书还探讨了用于静态和动态代码分析的自动化工具的应用,展示了 Clang Tools 的强大功能。
到旅程结束时,读者将具备应用行业认可的编码实践的知识和技能,使他们能够为现实应用编写清晰、可持续和可读的 C++ 代码。
本书面向的对象
本书旨在为 C++ 社区中的广泛专业人士提供帮助。如果你是一位希望提升技能并编写更优雅、高效代码的 C++ 工程师,本书将为你提供提升编程实践所需的知识和技巧。它也是那些负责重构和改进现有代码库的人的绝佳资源,提供了使这一过程更易于管理和有效的实用建议和策略。
此外,本书还是技术和管理领导者的宝贵指南,他们旨在提升软件开发流程。无论你是领导一个小团队还是管理一个更大的开发项目,你都会找到有用的提示和方法,使你的工作流程更加顺畅和高效。通过实施本书中概述的最佳实践,你可以营造一个更富有成效和和谐的开发环境,最终导致软件质量更高和项目更成功。
本书涵盖的内容
第一章, C++编码标准,探讨了干净代码的世界及其在成功软件项目中的关键作用。我们讨论了技术债务以及低质量代码如何导致其积累。本章还涵盖了代码格式化和文档的重要性,强调了它们在维护可管理和有效代码库中的作用。我们介绍了 C++社区中使用的常见约定和最佳实践,强调了干净代码和适当文档对任何项目的必要性。
第二章, 主要软件开发原则,涵盖了创建结构良好且易于维护的代码的关键软件设计原则。我们讨论了 SOLID 原则——单一职责、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则,这些原则有助于开发者编写易于理解、测试和修改的代码。我们还强调了抽象层次的重要性、副作用和可变性的概念及其对软件质量的影响。通过应用这些原则,开发者可以创建更健壮、可靠和可扩展的软件。
第三章, 糟糕代码的原因,确定了导致 C++代码质量不佳的关键因素。这些因素包括快速交付的压力、C++允许对同一问题有多个解决方案的灵活性、个人编码风格以及缺乏对现代 C++特性的了解。了解这些原因有助于开发者避免常见的陷阱,并有效地改进现有的代码库。
第四章, 识别适合重写的理想候选者 - 模式与反模式,专注于在 C++项目中识别适合重构的理想候选者。我们将探讨使代码段适合重构的因素,如技术债务、复杂性和可读性差。我们还将讨论常见的模式和反模式,提供改进代码结构、可读性和可维护性的指南和技术,而不改变其行为。本章旨在使开发者具备有效提升其 C++代码库质量和健壮性的知识。
第五章, 命名的重要性,强调了在 C++编程中命名约定的关键作用。为变量、函数和类选择合适的名称可以增强代码的可读性和可维护性。我们讨论了命名的最佳实践、不良命名对代码效率的影响以及一致编码规范的重要性。通过理解和应用这些原则,你将编写更清晰、更有效的代码。
第六章, 在 C++中利用丰富的静态类型系统,探讨了 C++中强大的静态类型系统,强调其在编写健壮、高效和可维护代码中的作用。我们讨论了高级技术,如使用<chrono>
库处理时间长度、not_null
包装器和std::optional
进行更安全的指针处理。此外,我们还探讨了如 Boost 等外部库,以增强类型安全性。通过实际示例,我们展示了如何利用这些工具充分发挥 C++类型系统的潜力,从而实现更具表达性和错误抵抗力的代码。
第七章, C++中的类、对象和面向对象编程,专注于 C++中类、对象和面向对象编程(OOP)的高级主题。我们涵盖了类设计、方法实现、继承和模板使用。关键主题包括优化类封装、高级方法实践、评估继承与组合、以及复杂的模板技术。实际示例说明了这些概念,帮助您创建健壮和可扩展的软件架构。
第八章, 在 C++中设计和开发 API,探讨了设计可维护 API 的原理和实践。我们讨论了在 API 设计中清晰性、一致性和可扩展性的重要性。通过具体示例,我们说明了有助于创建直观、易于使用和健壮 API 的最佳实践。通过应用这些原则,您将开发出满足用户需求且随时间适应的 API,确保您的软件库的长期成功和成功。
第九章, 代码格式化和命名约定,探讨了代码格式化和命名约定在创建健壮和可维护软件中的关键作用。虽然这些主题可能看似微不足道,但它们极大地提高了代码的可读性,简化了维护,并促进了在复杂语言(如 C++)中的有效团队合作。我们深入探讨了代码格式化的重要性,并提供了使用 Clang-Format 和编辑器特定插件等工具实现一致格式化的实用知识。到本章结束时,您将了解这些实践的重要性以及如何在您的 C++项目中有效地应用它们。
第十章, C++中静态分析简介,讨论了静态分析在确保 C++开发中代码质量和可靠性中的关键作用。我们讨论了静态分析如何快速且经济地识别错误,使其成为软件质量保证的关键组成部分。我们深入探讨了 Clang-Tidy、PVS-Studio 和 SonarQube 等流行工具,并指导如何将静态分析集成到您的开发工作流程中。
第十一章,动态分析,探讨了 C++中的动态代码分析,重点关注在程序执行过程中审查程序行为以检测内存泄漏、竞态条件和运行时错误等问题的工具。我们涵盖了基于编译器的清理器,如地址清理器(ASan)、线程清理器(TSan)和未定义行为清理器(UBSan),以及用于彻底内存调试的 Valgrind。通过理解和将这些工具集成到您的开发工作流程中,您可以确保编写更干净、更高效、更可靠的 C++代码。
第十二章,测试,强调了软件测试在确保质量、可靠性和可维护性中的关键作用。我们涵盖了各种测试方法,从单元测试开始以验证单个组件,然后是集成测试以检查集成单元之间的交互。接着我们转向系统测试,对整个软件系统进行全面评估,最后以验收测试来确保软件满足最终用户的需求。通过理解这些方法,您将掌握测试如何支撑稳健和以用户为中心的软件开发。
第十三章,管理第三方库的现代方法,讨论了第三方库在 C++开发中的关键作用。我们探讨了第三方库管理的基础知识,包括静态编译与动态编译对部署的影响。鉴于 C++缺乏标准化的库生态系统,我们检查了 vcpkg 和 Conan 等工具,以了解它们在集成和管理库方面的优势。此外,我们还讨论了使用 Docker 创建一致和可重复的开发环境。到本章结束时,您将能够有效地选择和管理第三方库,从而提高您的开发工作流程和软件质量。
第十四章,版本控制,强调了在软件开发中维护干净的提交历史的重要性。我们讨论了清晰和有目的的提交信息的最佳实践,并介绍了 Git、常规提交规范和提交 linting 等工具。通过遵循这些原则,开发者可以增强沟通、协作和项目的可维护性。
第十五章,代码审查,探讨了代码审查在确保稳健和可维护的 C++代码中的关键作用。虽然自动化工具和方法提供了显著的好处,但它们并非万无一失。由人工审查员进行的代码审查有助于捕捉自动化流程可能遗漏的错误,并确保遵守标准。我们讨论了有效代码审查的策略和实用指南,强调其在预防错误、提高代码质量和培养学习与责任感的协作文化中的作用。
要充分利用这本书
要充分利用这本书,你应该对 C++的基础知识有扎实的理解。熟悉如 Make 和 CMake 等构建系统将有所帮助。此外,具备基本的 Docker 和终端技能可以增强你的学习体验,尽管这些是可选的。
如果您正在使用这本书的数字版,我们建议您自己输入代码或从书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件[github.com/PacktPublishing/Refactoring-with-C-
](https://github.com/PacktPublishing/Refactoring-with-C-)。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在https://github.com/PacktPublishing/找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg
磁盘映像文件作为系统中的另一个磁盘挂载。”
代码块是这样设置的:
html, body, #map {
height: 100%;
margin: 0;
padding: 0
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
任何命令行输入或输出都写作如下:
$ mkdir css
$ cd css
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从管理面板中选择系统信息。”
小贴士或重要注意事项
看起来是这样的。
联系我们
我们欢迎读者提供反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用 C++重构》,我们非常乐意听到您的想法!请点击此处直接转到此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在每购买一本 Packt 图书,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不仅限于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件访问权限
按照以下简单步骤获取这些好处:
- 扫描下方二维码或访问以下链接
packt.link/free-ebook/9781837633777
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件中
第一章:C++ 编程规范
在本章中,我们将深入探讨干净代码的世界,并检查它在成功软件项目中的关键作用。我们将讨论技术债务的概念以及低质量代码如何导致其积累。此外,我们还将探讨通常被低估的代码格式化和文档化实践,这些对于维护可管理和有效的代码库至关重要。通过本章,我们将理解干净的代码不仅仅是锦上添花,而是任何项目的必需品。
我们将讨论编程规范的重要性,并介绍在 C++ 社区中使用的常见约定和最佳实践。到本章结束时,你将更好地理解什么是干净的代码,为什么它至关重要,以及为什么记录代码是至关重要的。
好的代码和坏的代码之间的区别
好的或干净的代码没有严格的定义。此外,没有自动工具可以衡量代码的质量。有代码检查器、代码检查工具和其他分析器可以帮助使代码更好。这些工具非常有价值,并且强烈推荐,但并不足够。人工智能可能会接管并为我们编写代码,但最终,它对代码质量的衡量将基于我们对好代码的人类想法。
编程语言最初是为了在机器和开发者之间提供一个接口而开发的;然而,随着软件产品复杂性的增长,现在很明显,它主要是开发者之间沟通想法和意图的一种方式。众所周知,开发者花在阅读代码上的时间是编写代码的十倍。这意味着为了提高效率,我们必须尽力使阅读变得更容易。使这个过程高效的最成功的方式是使代码可预测,甚至更好,是无聊的。这里的“无聊”是指读者看到代码时就能知道它将如何从功能、性能和副作用方面进行预期。以下是一个从数据库中检索对象的类的接口示例:
class Database {
public:
template<typename T>
std::optional<T> get(const Id& id) const;
template<typename T>
std::optional<T> get(const std::string& name) const;
};
它支持两种查找模式,通过 id
和通过 name
;它不应该因为 const
修饰符而改变 Database
对象的内部状态;并且它只能对数据库实例执行读取操作。它是无聊的,但符合基本预期。想象一下,在关键错误调查期间发现,在每次读取操作中,它都执行更新操作,有时甚至执行删除操作,这会有多令人惊讶?
正如读者所看到的,几个关键因素可以区分好的代码和坏的代码。好的代码通常是写得很好、易于阅读且高效的。它遵循 C++ 语言的约定和标准,并以逻辑和一致的方式组织。好的代码也具有良好的文档,通常包含清晰的注释,解释那些仅从代码阅读中不明显的内容的目的和功能。
为什么编程规范很重要
编码标准的重要性有多个原因。首先,它们有助于最小化技术债务。技术债务,也称为“代码债务”,是一个隐喻,描述了维护和修改设计不佳或编写不佳的代码的成本。正如财务债务会产生利息并需要持续支付一样,技术债务会以维护和修改设计不佳的代码所需的时间和精力为形式产生额外的成本。
技术债务可以通过多种方式积累,例如通过解决问题的快速修复或忽略最佳实践或编码标准。随着技术债务的积累,修改和维护代码变得越来越困难和耗时,这可能会对开发团队的效率和效果产生负面影响。
为了谨慎管理技术债务,避免积累过多的债务至关重要,因为它可能会成为开发团队的沉重负担。管理技术债务的策略包括定期重构代码以改进其设计和可维护性,遵循最佳实践和编码标准,以及积极寻求提高代码质量的机会。总的来说,管理技术债务是良好代码设计和开发的重要方面,有助于确保代码高效、可靠且易于工作。
编码标准有助于确保代码的质量和一致性。通过建立编写代码的指南和惯例,编码标准有助于确保代码编写良好、易于阅读和理解。这使得其他人更容易维护和更新代码,并有助于防止错误和缺陷。
此外,它们有助于提高代码的效率。通过遵循既定惯例和最佳实践,程序员可以编写更高效且性能更好的代码。这可以节省时间和资源,并有助于确保代码可扩展并能处理大量数据和流量。
此外,编码标准促进了程序员之间的协作和团队合作。通过建立一套共同的指南和惯例,编码标准使得程序员团队更容易在项目上合作。这促进了更好的沟通和协调,并有助于确保每个人都处于同一页面上,朝着相同的目标努力。
编码标准通常促进代码的互操作性和可移植性。通过遵循标准化的惯例集,一位程序员编写的代码可以很容易地被另一位程序员理解和使用。这使得代码更容易集成到更大的项目中,并有助于确保它可以在各种不同的平台和操作系统上使用。
C++编程语言可能是功能最丰富的编程语言之一。它起源于“带有类的 C 语言”,提供了高性能和几乎与 C 语言完全兼容的对象支持;后来引入了模板元编程,斯蒂潘诺夫和李共同开发了现在被称为 C++标准库的 C++标准模板库。现代 C++(C++11 及更高版本)为多种编程范式提供了广泛的支持,包括过程式、面向对象、泛型和函数式编程。它提供了诸如 lambda 表达式、基于范围的for
循环、智能指针和类型推断等特性,这些特性使得函数式编程技术得以实现。此外,C++还提供了面向对象编程概念的支持,如继承、封装和多态。它还提供了模板元编程,这使泛型编程成为可能,并允许编译时优化。此外,C++还提供了线程、原子类型和未来等并发支持特性,使得编写并发和并行代码变得更加容易。这种灵活性是语言强大之所在,但往往会导致可维护性问题。
开发者必须理解我们提到的范式概念,如何将它们结合使用,以及它们最终如何影响代码的性能。这时,编码标准可以帮助解释代码库的复杂性。
所有这些因素使得编码规范成为现代 C++项目达到质量标准所必须具备的最低要求。
代码规范
与 Python、Go、Java 和其他许多语言不同,C++没有统一的代码规范。
对于 C++编程语言,存在几种流行的编码规范。以下是一些广泛遵循的常见规范:
-
total_cost
或customer_name
。类变量通常会有前缀或后缀以区分它们与其他变量,例如m_user_count
或user_count_
。函数的命名可以使用camelCase,每个单词的首字母(除了第一个单词)大写,例如calculateTotalCost
或getCustomerName
。类的命名可以使用PascalCase,每个单词的首字母大写,例如Customer
或Invoice
。 -
注释:注释规范规定了如何编写和格式化代码中的注释。注释用于提供代码的解释和文档,应清晰简洁。通常建议使用内联注释来解释特定的代码行,以及使用块注释来提供代码块或函数的概述。
-
for
循环或if
语句,以视觉上表示代码的结构。格式化策略通常包括指针和引用中的星号(*
)和和号(&
)对齐(例如,int* ptr
与int *ptr
或Socket &socket
与Socket& socket
),大括号的位置(同一行、下一行或上下文相关)。本书将在第十三章中涵盖自动化格式化的方面。 -
goto
运算符。
需要注意的是,不同组织和团队之间的编码规范可能会有所不同。重要的是要遵循你的团队或组织建立的规范,或者如果没有指定,则定义你自己的规范。
制定编码规范可能会很繁琐;一些公司更愿意使用现有的规范并将其适应他们的需求。在 C++编程语言中,有几个流行的代码规范被开发者广泛遵循。这些标准旨在提高 C++代码的可读性、可维护性和整体质量。
C++的一个常见代码规范是 C++核心指南(isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines
),它是由 C++的创造者 Bjarne Stroustrup 以及来自工业和学术界的一组专家开发的。这些指南涵盖了广泛的主题,包括命名约定、注释、格式化和编码风格。
另一个流行的 C++代码规范是 Google C++风格指南(google.github.io/styleguide/cppguide.html
),它被许多软件公司,包括谷歌,所采用。该指南提供了关于命名约定、注释、格式化和编码风格的指南,以及使用特定 C++功能和库的建议。
除了这些广泛遵循的标准之外,还有许多其他由个人组织或团队开发的代码规范,例如 LLVM 编码规范、WebKit 和 Mozilla 的风格指南。
如果一个项目符合特定的代码规范,阅读起来会更加容易,而且作为额外的好处,代码库会变得更加可 grep。考虑需要找到名为request_id
的变量被赋值的地方。这可以通过grep
实用程序轻松实现:
$ grep -rn "request_id = " .
./RequestHandler.cpp:25: request_id = new_request_id;
./RequestHandler.cpp:122: request_id = request.getId();
代码审查员过去常常花费数小时在同行评审期间捕捉和评论代码格式的不一致性。幸运的是,今天我们有 Clang-Tidy 和 Clang-Format 这样的工具,允许我们通过代码编辑器和持续集成(CI)自动确保代码格式的统一性。我们将在本书的第十章中更深入地探讨它们的配置。
语言特性限制
C++是一种强大的语言;正如我们所知,强大的力量伴随着巨大的责任。对于工程师来说,尤其是那些没有花费几十年时间编写 C++代码的人来说,掌握这种语言的复杂性并不容易。因此,一些公司决定限制他们在项目中使用的功能。这些限制可能包括禁止多重继承、使用异常以及最小化使用宏、模板和特定的第三方库。此外,这些规定可能来自对遗留库的使用。例如,如果大部分代码不支持 C++异常,那么在没有事先了解结果的情况下,将它们添加到新的代码片段中可能不是一个好主意。
通用指南
对于一个项目来说,拥有一些通用指南总是好的。这些指南通常涵盖了在项目中工作的首选方式:
-
如果允许,原始指针的使用
-
获取器如何返回值以及如何提供给设置器(按值或引用)
-
代码注释的使用:
-
是否允许一般性地使用注释?
-
战略性和战术性注释的使用
-
注释风格:自由、Doxygen 等
-
编码标准是必要的,以确保代码质量、一致性、互操作性、可移植性、效率和协作。通过遵循编码标准,程序员可以编写更好的代码,这些代码更容易理解、维护和使用,并且工作得更好、更高效。
可读性、效率、可维护性和可用性
可读性、效率、可维护性和可用性都是在编写代码时需要考虑的关键因素。
可读性
可读性指的是人类读者理解一段代码的容易程度。编写良好的代码易于阅读,具有清晰、简洁的语句,这些语句以逻辑和一致的方式组织。如果我们考虑到开发者阅读代码的时间是编写代码时间的十倍,这一点就变得非常重要。
让我们来看看以下这段代码:
class Employee {
public:
std::string get_name();
std::string surname();
uint64_t getId() const;
};
这个例子是一个夸张的例子,展示了代码没有遵循任何编码约定。使用Employee
类的开发者可以理解这三个方法都是获取器。然而,名称上的差异使得用户花费更多的时间来理解代码或试图理解名称背后的原因。这些方法名称不同是因为程序员没有关心类的统一性吗?还是因为,例如,没有get
前缀的方法是简单的获取器,而包含get
前缀的方法是从文件或数据库中获取数据?
此外,没有const
的方法是否会改变对象的状态(例如通过缓存),或者这是一个错误?你看到有多少问题可以提出吗?这些问题只有在开发者跳入相应的实现时才能回答,这意味着浪费了时间。使代码在代码库中看起来一致有助于开发者通过查看头文件中的声明或甚至通过现代代码编辑器的自动完成来理解类、方法和函数的含义和复杂性。
效率
效率指的是一段代码以高效的方式执行其预期任务的能力。高效的代码使用很少的资源,例如时间和内存,来完成一项任务,并且能够处理大量数据和流量而不会减慢或崩溃。通过提高代码的效率,程序员可以节省时间和资源,并确保他们的代码可扩展并能处理不断增长的用户群体的需求。
优化 C++代码有可靠的方法,例如通过常量引用传递只读参数以避免不必要的复制,或者在查找单个字符时使用std::string::find
的字符重载版本以避免创建字符串:
my_string.find('A');
my_string.find("A");
然而,实现并维持代码效率的更系统的方法是遵循帕累托原则。当应用于软件工程时,这个原则表明大约 20%的代码完成了 80%的工作。例如,通常在后台守护进程启动时优化代码解析配置文件是没有必要的,因为这仅在程序的生命周期中发生一次。然而,避免在主流程中复制大型数据结构可能很重要。提高效率的最佳方式包括选择这 20%的性能关键代码并为它添加基准测试。这些基准测试预计将作为 CI 过程的一部分运行,以确保没有引入退化。
此外,端到端测试可以衡量应用程序的整体性能。本书在第十三章中讨论了编写单元测试和端到端测试的最佳实践。需要注意的是,自动化工具不能取代工程师对新代码进行代码审查,主要是因为没有工具能够找到那 20%的代码,而这 20%的代码完成了 80%的工作。
可维护性
可维护性指的是代码随时间推移进行更新和修改的容易程度。编写良好的代码易于维护,具有清晰且文档齐全的代码,这些代码以逻辑和一致的方式组织。通过提高代码的可维护性,程序员可以使他人更容易更新和修改他们的代码,并确保代码在长时间内保持相关性和实用性。理想情况下,在开发新组件时,开发者应该考虑代码当前解决的问题以及代码的未来使用和扩展。例如,在开发数据提供者支持时,考虑该提供者是否将是唯一支持的对象可能是有用的。如果不是,考虑数据提供者的标准特性并将它们提取到抽象基类中可能是有帮助的。以下是一个示例:
class BaseDataProvider {
public:
BaseDataProvider() = default;
BaseDataProvider(const BaseDataProvider&) = delete;
BaseDataProvider(BaseDataProvider&&) = default;
BaseDataProvider& operator = (const BaseDataProvider&) =
delete;
BaseDataProvider& operator = (BaseDataProvider&&) =
default;
virtual ~BaseDataProvider() = default;
virtual Data getData() const = 0;
};
class NetworkDataProvider : public BaseDataProvider {
public:
NetworkDataProvider(const Endpoint& endpoint);
Data getData() const override;
};
class FileDataProvider : public BaseDataProvider {
public:
FileDataProvider(const std::string& filename);
Data getData() const override;
};
在这个例子中,DataProvider
类是一个抽象基类,它定义了提供数据时的接口。NetworkDataProvider
和 FileDataProvider
类从 DataProvider
派生出来,并覆盖了 getData
虚拟函数,分别提供从文件或网络端点读取数据的特定实现。这种设计使得通过简单地创建一个从 DataProvider
派生的新的类并提供 getData
虚拟函数的适当实现,可以轻松地添加新的数据源。
从这个例子中可以看出,基接口可能不仅包括功能,还包括对象的复制-移动策略。随后,用户代码可以通过基类引用接收数据提供者,并对提供者的类型一无所知,如下面的代码片段所示:
class DataParser {
public:
DataParser(const BaseDataProvider& provider);
void parse();
};
此外,这种继承可以在为 DataParser
创建单元测试时模拟数据提供者。单元测试在第十三章中详细讨论。
顺便提一下,不要使代码过于复杂,或准备好应对任何变化,这一点至关重要。否则,需要使一切可扩展的需求可能会导致如下所示的怪物:
#define BASE_CLASS(TYPE) \
template <typename T> \
class TYPE { \
public: \
T value; \
TYPE(T val) : value(val) {} \
};
#define DERIVED_CLASS(TYPE, BASE) \
template <typename T> \
class TYPE : public BASE<T> { \
public: \
TYPE(T val) : BASE<T>(val) {} \
T getValue() { return value; } \
};
BASE_CLASS(Base);
DERIVED_CLASS(Derived, Base);
int main() {
Derived<int> obj(5);
std::cout << obj.getValue() << std::endl;
return 0;
}
这个类层次结构不必要地复杂,因为它几乎使用了所有的 C++ 特性:继承、模板和宏。虽然使用模板与继承是常见的做法,但宏现在被视为一种反模式。在这个例子中,Derived
类与 Base
类相比,添加的功能非常少,直接将 getValue
方法添加到 Base
类中会更直接。在某些情况下,使用继承和模板可能是有用的,但重要的是要适当地使用它们,不要过度使用。宏可能特别难以理解和维护,因为它们在代码编译之前由预处理器展开,因此很难看到实际的代码样子。在可能的情况下,通常最好使用函数或模板函数而不是宏。
如果扩展的可能性较低,保持其结构简单且接近基本需求是更好的选择。你如何决定采取哪种方法?嗯,冷静的考虑和代码审查是找到答案的方法。
可用性
可用性指的是其他人使用一段代码的容易程度。编写良好的代码易于使用,具有清晰直观的界面和文档,使其他人容易理解和使用代码。通过提高代码的可用性,程序员可以使他们的代码更容易被他人访问和使用,并确保他们的代码被广泛采用和使用。
总体而言,可读性、效率、可维护性和可用性都是在编写代码时需要考虑的重要因素。通过提高这些因素,程序员可以编写出更容易理解、维护和使用的优质代码。
摘要
在本章中,你了解了良好和糟糕代码的概念。良好的代码编写得很好,效率高,易于理解和维护。它遵循编码标准和最佳实践,并且不太容易出现错误。另一方面,糟糕的代码编写得不好,效率低下,难以理解和维护。
本章还介绍了技术债务的概念,它指的是需要重构或重写的低质量代码的积累。技术债务可能代价高昂且耗时,可能会阻碍新功能或功能的发展。
本章也强调了代码标准的重要性。代码标准是指导或规则,规定了代码应该如何编写、格式化和结构化。遵守代码标准有助于确保代码的一致性、易于理解和维护。它还使多个开发者能够更容易地在同一代码库上工作,并有助于防止错误和缺陷。
总体而言,本章强调了编写高质量代码和遵守代码标准的重要性,以避免技术债务并确保软件项目的长期成功和可维护性。
在下一章中,我们将深入软件设计原则的世界。具体来说,我们将关注 SOLID 原则,这是一组旨在通过使软件系统更易于维护、灵活和可扩展来改进软件设计的指导方针。下一章将详细解释每个原则,并附带它们如何应用于现实世界软件开发场景的示例。
第二章:主要软件开发原则
在本章中,我们将探讨用于创建结构良好且易于维护的代码的主要软件设计原则。其中最重要的原则之一是 SOLID 原则,它代表单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。这些原则旨在帮助开发者创建易于理解、测试和修改的代码。我们还将讨论抽象层次的重要性,这是将复杂系统分解成更小、更易于管理的部分的做法。此外,我们还将探讨副作用和可变性概念及其如何影响软件的整体质量。通过理解和应用这些原则,开发者可以创建更健壮、更可靠和可扩展的软件。
SOLID
SOLID 原则是一组原则,最初由 Robert C. Martin 在他的 2000 年出版的《敏捷软件开发:原则、模式和实践》一书中提出。Robert C. Martin,也被称为 Uncle Bob,是一位软件工程师、作家和演讲家。他被认为是在软件开发行业中最具影响力的人物之一,以其对 SOLID 原则的工作和对面向对象编程领域的贡献而闻名。Martin 作为一名软件工程师已有 40 多年的经验,参与过从小型系统到大型企业系统的各种项目。他也是一位知名的演讲家,在世界各地的许多会议和活动中发表了关于软件开发的演讲。他是敏捷方法的倡导者,对敏捷宣言的发展产生了重要影响。SOLID 原则的开发是为了帮助开发者通过促进良好的设计实践来创建更易于维护和可扩展的代码。这些原则基于 Martin 作为软件工程师的经验以及他观察到许多软件项目因设计不佳而难以理解、更改和维护的观察。
SOLID 原则旨在作为面向对象软件设计的指南,并基于软件应易于理解、随时间变化和扩展的想法。这些原则旨在与其他软件开发实践结合使用,例如测试驱动开发和持续集成。遵循 SOLID 原则,开发者可以创建更健壮、更少出现错误且易于长期维护的代码。
单一职责原则
单一职责原则(SRP)是面向对象软件设计的五个 SOLID 原则之一。它指出,一个类应该只有一个改变的理由,这意味着一个类应该只有一个职责。这个原则旨在促进易于理解、更改和测试的代码。
SRP 背后的理念是一个类应该有一个单一、明确的目的。这使得理解类的行为更加容易,并减少了类变更产生意外后果的可能性。当一个类只有一个职责时,它也更不容易出现错误,并且为其编写自动化测试也更加容易。
应用 SRP 可以通过使系统更加模块化和易于理解来提高软件系统的设计。通过遵循这个原则,开发者可以创建小型、专注且易于推理的类。这使得随着时间的推移维护和改进软件变得更加容易。
让我们看看一个支持通过网络发送多种消息类型的消息系统。该系统有一个Message
类,它接收发送者和接收者 ID 以及要发送的原始数据。此外,它还支持将消息保存到磁盘并通过send
方法发送自身:
class Message {
public:
Message(SenderId sender_id, ReceiverId receiver_id,
const RawData& data)
: sender_id_{sender_id},
receiver_id_{receiver_id}, raw_data_{data} {}
SenderId sender_id() const { return sender_id_; }
ReceiverId receiver_id() const { return receiver_id_; }
void save(const std::string& file_path) const {
// serializes a message to raw bytes and saves
// to file system
}
std::string serialize() const {
// serializes to JSON
return {"JSON"};
}
void send() const {
auto sender = Communication::get_instance();
sender.send(sender_id_, receiver_id_, serialize());
}
private:
SenderId sender_id_;
ReceiverId receiver_id_;
RawData raw_data_;
};
Message
类负责多个关注点,例如从/到文件系统保存消息、序列化数据、发送消息以及持有发送者和接收者 ID 和原始数据。将这些职责分离到不同的类或模块中会更好。
Message
类只负责存储数据和将其序列化为 JSON 格式:
class Message {
public:
Message(SenderId sender_id, ReceiverId receiver_id,
const RawData& data)
: sender_id_{sender_id},
receiver_id_{receiver_id}, raw_data_{data} {}
SenderId sender_id() const { return sender_id_; }
ReceiverId receiver_id() const { return receiver_id_; }
std::string serialize() const {
// serializes to JSON
return {"JSON"};
}
private:
SenderId sender_id_;
ReceiverId receiver_id_;
RawData raw_data_;
};
save
方法可以被提取到一个单独的MessageSaver
类中,拥有单一职责:
class MessageSaver {
public:
MessageSaver(const std::string& target_directory);
void save(const Message& message) const;
};
而send
方法是在一个专门的MessageSender
类中实现的。这三个类都有单一且明确的责任,并且任何对其中任何一个类的进一步更改都不会影响其他类。这种方法允许在代码库中隔离更改。在需要长时间编译的复杂系统中,这一点变得至关重要。
总结来说,SRP(单一职责原则)指出,一个类应该只有一个改变的理由,这意味着一个类应该只有一个职责。这个原则旨在促进易于理解、更改和测试的代码,并有助于创建更模块化、可维护和可扩展的代码库。遵循这个原则,开发者可以创建小型、专注且易于推理的类。
SRP 的其他应用
单一职责原则(SRP)不仅适用于类,也适用于更大的组件,如应用程序。在架构层面,SRP 通常实现为微服务架构。微服务的理念是将软件系统构建为一系列小型、独立的服务的集合,这些服务通过网络相互通信,而不是将其构建为一个单体应用程序。每个微服务负责特定的业务能力,并且可以独立于其他服务进行开发、部署和扩展。这允许有更大的灵活性、可扩展性和易于维护,因为对一个服务的更改不会影响整个系统。微服务还使开发过程更加敏捷,因为团队可以并行工作在不同的服务上,同时也允许对安全、监控和测试采取更细粒度的方法,因为每个服务都可以单独处理。
开放-封闭原则
开放-封闭原则指出,模块或类应该是可扩展的,但应该是封闭的以进行修改。换句话说,应该能够在不修改现有代码的情况下向模块或类添加新功能。这个原则有助于促进软件的可维护性和灵活性。C++中这个原则的一个例子是使用继承和多态。可以编写一个基类,使其能够被派生类扩展,从而在不修改基类的情况下添加新功能。另一个例子是使用接口或抽象类来定义一组相关类的合同,允许添加符合合同的新类,而无需修改现有代码。
开放-封闭原则可以用来改进我们的消息发送组件。当前版本只支持一种消息类型。如果我们想添加更多数据,我们需要修改Message
类:添加字段,保留一个额外的消息类型变量,而且不用说基于这个变量的序列化。为了避免对现有代码的修改,让我们将Message
类重写为纯虚拟的,提供serialize
方法:
class Message {
public:
Message(SenderId sender_id, ReceiverId receiver_id)
: sender_id_{sender_id}, receiver_id_{receiver_id} {}
SenderId sender_id() const { return sender_id_; }
ReceiverId receiver_id() const { return receiver_id_; }
virtual std::string serialize() const = 0;
private:
SenderId sender_id_;
ReceiverId receiver_id_;
};
现在,让我们假设我们需要添加另外两种消息类型:一种支持启动延迟的“启动”消息(通常用于调试目的)和一种支持停止延迟的“停止”消息(可用于调度);它们可以按以下方式实现:
class StartMessage : public Message {
public:
StartMessage(SenderId sender_id, ReceiverId receiver_id,
std::chrono::milliseconds start_delay)
: Message{sender_id, receiver_id},
start_delay_{start_delay} {}
std::string serialize() const override {
return {"naive serialization to JSON"};
}
private:
const std::chrono::milliseconds start_delay_;
};
class StopMessage : public Message {
public:
StopMessage(SenderId sender_id, ReceiverId receiver_id,
std::chrono::milliseconds stop_delay)
: Message{sender_id, receiver_id},
stop_delay_{stop_delay} {}
std::string serialize() const override {
return {"naive serialization to JSON"};
}
private:
const std::chrono::milliseconds stop_delay_;
};
注意,这些实现都不需要修改其他类,每个实现都提供了自己的serialize
方法版本。MessageSender
和MessageSaver
类不需要额外的调整来支持消息的新类层次结构。然而,我们也将对它们进行修改。主要原因是为了使它们可扩展而不需要修改。例如,消息不仅可以保存到文件系统,还可以保存到远程存储。在这种情况下,MessageSaver
变为纯虚拟的:
class MessageSaver {
public:
virtual void save(const Message& message) const = 0;
};
负责保存到文件系统的实现是从 MessageSaver
派生出来的类:
class FilesystemMessageSaver : public MessageSaver {
public:
FilesystemMessageSaver(const std::string&
target_directory);
void save(const Message& message) const override;
};
并且远程存储保存器是层次结构中的另一个类:
class RemoteMessageSaver : public MessageSaver {
public:
RemoteMessageSaver(const std::string&
remote_storage_address);
void save(const Message& message) const override;
};
Liskov 替换原则
Liskov 替换原则(LSP)是面向对象编程中的一个基本原则,它指出,超类对象应该能够被子类对象替换,而不会影响程序的正确性。这个原则也被称为 Liskov 原则,以 Barbara Liskov 的名字命名,她是第一个提出这个原则的人。LSP 基于继承和多态的概念,其中子类可以继承其父类的属性和方法,并且可以与它互换使用。
为了遵循 LSP,子类必须与它们的父类“行为兼容”。这意味着它们应该有相同的方法签名并遵循相同的契约,例如输入和输出类型和范围。此外,子类中方法的行为不应违反父类中建立的任何契约。
让我们考虑一个新的 Message
类型,InternalMessage
,它不支持 serialize
方法。有人可能会倾向于以下方式实现它:
class InternalMessage : public Message {
public:
InternalMessage(SenderId sender_id, ReceiverId
receiver_id)
: Message{sender_id, receiver_id} {}
std::string serialize() const override {
throw std::runtime_error{"InternalMessage can't be
serialized!"};
}
};
在前面的代码中,InternalMessage
是 Message
的一个子类型,但不能被序列化,而是抛出异常。这种设计存在几个问题:
-
InternalMessage
是Message
的一个子类型,那么我们应该能够在期望Message
的任何地方使用InternalMessage
,而不会影响程序的正确性。通过在serialize
方法中抛出异常,我们打破了这一原则。 -
serialize
必须处理异常,这在处理其他Message
类型时可能并不必要。这引入了额外的复杂性,并在调用者代码中引入了错误的可能性。 -
程序崩溃:如果异常没有得到适当的处理,可能会导致程序崩溃,这当然不是期望的结果。
我们可以返回一个空字符串而不是抛出异常,但这仍然违反了 LSP,因为 serialize
方法预期返回一个序列化的消息,而不是一个空字符串。这也引入了歧义,因为不清楚空字符串是没有任何数据的消息成功序列化的结果,还是 InternalMessage
序列化失败的结果。
一个更好的方法是分离 Message
和 SerializableMessage
的关注点,其中只有 SerializableMessage
有 serialize
方法:
class Message {
public:
virtual ~Message() = default;
// other common message behaviors
};
class SerializableMessage : public Message {
public:
virtual std::string serialize() const = 0;
};
class StartMessage : public SerializableMessage {
// ...
};
class StopMessage : public SerializableMessage {
// ...
};
class InternalMessage : public Message {
// InternalMessage doesn't have serialize method now.
};
在这个修正的设计中,基类 Message
不包括 serialize
方法,并引入了一个新的 SerializableMessage
类,其中包含这个方法。这样,只有可以序列化的消息才会从 SerializableMessage
继承,并且我们遵循了 LSP。
遵循 LSP(里氏替换原则)可以使代码更加灵活和易于维护,因为它允许使用多态,并允许用子类对象替换类对象,而不会影响程序的整体行为。这样,程序可以利用子类提供的新功能,同时保持与超类相同的行为。
接口隔离原则
接口隔离原则(ISP)是面向对象编程中的一个原则,它指出一个类应该只实现它使用的接口。换句话说,它建议接口应该是细粒度和客户端特定的,而不是一个单一、庞大且包罗万象的接口。ISP 基于这样一个观点:拥有许多小型接口,每个接口定义一组特定的方法,比拥有一个定义了许多方法的单一大型接口要好。
ISP(接口隔离原则)的一个关键好处是它促进了更模块化和灵活的设计,因为它允许创建针对客户端特定需求的接口。这样,它减少了客户端需要实现的不必要方法数量,同时也减少了客户端依赖于它不需要的方法的风险。
当从 MessagePack 或 JSON 文件创建我们的示例消息时,可以观察到 ISP(接口隔离原则)的一个例子。遵循最佳实践,我们会创建一个提供两个方法from_message_pack
和from_json
的接口。
当前的实现需要实现这两个方法,但如果一个特定的类不需要支持这两种选项怎么办?接口越小越好。MessageParser
接口将被拆分为两个独立的接口,每个接口都需要实现 JSON 或 MessagePack 之一:
class JsonMessageParser {
public:
virtual std::unique_ptr<Message>
parse(const std::vector<uint8_t>& message_pack)
const = 0;
};
class MessagePackMessageParser {
public:
virtual std::unique_ptr<Message>
parse(const std::vector<uint8_t>& message_pack)
const = 0;
};
这种设计允许从JsonMessageParser
和MessagePackMessageParser
派生的对象理解如何分别从 JSON 和 MessagePack 构建自己,同时保持每个函数的独立性和功能性。系统保持灵活性,因为新的更小的对象仍然可以组合起来以实现所需的功能。
遵循 ISP 可以使代码更易于维护且更少出错,因为它减少了客户端需要实现的不必要方法数量,同时也减少了客户端依赖于它不需要的方法的风险。
依赖倒置原则
依赖倒置原则基于这样一个观点:依赖抽象比依赖具体实现要好,因为它提供了更大的灵活性和可维护性。它允许将高级模块与低级模块解耦,使它们更加独立,并减少对低级模块变化的敏感性。这样,它使得在不影响高级模块的情况下轻松更改低级实现,反之亦然。
如果我们尝试通过另一个类使用所有组件,可以说明 DIP(依赖倒置原则)在我们的消息系统中是如何体现的。让我们假设有一个负责消息路由的类。为了构建这样一个类,我们将使用MessageSender
作为通信模块,Message
基于的类,以及MessageSaver
:
class MessageRouter {
public:
MessageRouter(ReceiverId id)
: id_{id} {}
void route(const Message& message) const {
if (message.receiver_id() == id_) {
handler_.handle(message);
} else {
try {
sender_.send(message);
} catch (const CommunicationError& e) {
saver_.save(message);
}
}
}
private:
const ReceiverId id_;
const MessageHandler handler_;
const MessageSender sender_;
const MessageSaver saver_;
};
新的类只提供了一个route
方法,该方法在新的消息可用时被调用一次。如果消息的发送者 ID 与路由器相同,路由器将处理消息到MessageHandler
类。否则,路由器将消息转发到相应的接收者。如果消息传递失败且通信层抛出异常,路由器将通过MessageSaver
保存消息。这些消息将在其他时间传递。
唯一的问题是,如果任何依赖项需要更改,路由器的代码必须相应更新。例如,如果应用程序需要支持多种类型的发送者(TCP 和 UDP)、消息保存器(文件系统与远程)或消息处理逻辑发生变化。为了使MessageRouter
对这种变化无感知,我们可以使用 DIP 原则重写它:
class BaseMessageHandler {
public:
virtual ~BaseMessageHandler() {}
virtual void handle(const Message& message) const = 0;
};
class BaseMessageSender {
public:
virtual ~BaseMessageSender() {}
virtual void send(const Message& message) const = 0;
};
class BaseMessageSaver {
public:
virtual ~BaseMessageSaver() {}
virtual void save(const Message& message) const = 0;
};
class MessageRouter {
public:
MessageRouter(ReceiverId id,
const BaseMessageHandler& handler,
const BaseMessageSender& sender,
const BaseMessageSaver& saver)
: id_{id}, handler_{handler}, sender_{sender},
saver_{saver} {}
void route(const Message& message) const {
if (message.receiver_id() == id_) {
handler_.handle(message);
} else {
try {
sender_.send(message);
} catch (const CommunicationError& e) {
saver_.save(message);
}
}
}
private:
ReceiverId id_;
const BaseMessageHandler& handler_;
const BaseMessageSender& sender_;
const BaseMessageSaver& saver_;
};
int main() {
auto id = ReceiverId{42};
auto handler = MessageHandler{};
auto sender = MessageSender{
Communication::get_instance()};
auto saver =
FilesystemMessageSaver{"/tmp/undelivered_messages"};
auto router = MessageRouter{id, sender, saver};
}
在这个代码的修订版本中,MessageRouter
现在与消息处理、发送和保存逻辑的具体实现解耦。相反,它依赖于由BaseMessageHandler
、BaseMessageSender
和BaseMessageSaver
表示的抽象。这样,任何从这些基类派生的类都可以与MessageRouter
一起使用,这使得代码更加灵活,并便于未来扩展。路由器不关心消息处理、发送或保存的具体细节——它只需要知道这些操作可以执行。
遵循 DIP(依赖倒置原则)可以使代码更易于维护且更不易出错。它将高级模块与低级模块解耦,使它们更加独立,并减少对低级模块变化的敏感性。它还提供了更大的灵活性,使得在不影响高级模块的情况下轻松更改低级实现,反之亦然。本书后面将介绍依赖倒置如何帮助我们开发单元测试时模拟系统的一部分。
KISS 原则
KISS 原则,即“保持简单,傻瓜”,是一种强调保持事物简单直接的设计哲学。这一原则在编程领域尤为重要,因为复杂的代码可能导致错误、困惑和缓慢的开发速度。
下面是一些如何在 C++中应用 KISS 原则的例子:
-
使用
for
循环代替复杂的算法往往同样有效,而且更容易理解。 -
保持函数简洁:C++中的函数应该小巧、专注且易于理解。复杂的函数很快就会变得难以维护和调试,因此尽量保持函数尽可能简单和简洁。一个很好的经验法则是使函数的代码行数不超过 30-50 行。
-
使用清晰简洁的变量名:在 C++中,变量名在使代码可读和理解方面起着至关重要的作用。避免使用缩写,而应选择清晰简洁的名称,准确描述变量的用途。
-
避免深层嵌套:嵌套循环和条件语句会使代码难以阅读和遵循。尽量保持嵌套级别尽可能浅,并考虑将复杂的函数分解成更小、更简单的函数。
-
编写简单、易读的代码:首先,目标是编写易于理解和遵循的代码。这意味着使用清晰简洁的语言,并避免复杂的表达式和结构。简单且易于遵循的代码更有可能易于维护且无错误。
-
避免复杂的继承层次结构:复杂的继承层次结构会使代码更难以理解、调试和维护。继承结构越复杂,跟踪类之间的关系以及确定更改如何影响其余代码就越困难。
总结来说,KISS 原则是一种简单直接的设计理念,可以帮助开发者编写清晰、简洁且易于维护的代码。通过保持简单,开发者可以避免错误和混淆,并加快开发速度。
KISS 原则和 SOLID 原则都是软件开发中的重要设计理念,但它们有时可能会相互矛盾。
SOLID 原则和 KISS 原则都是软件开发中的重要设计理念,但它们有时可能会相互矛盾。
SOLID 原则是一套指导软件开发设计的五个原则,旨在使软件更具可维护性、可扩展性和灵活性。它们侧重于创建一个干净、模块化的架构,遵循良好的面向对象设计实践。
另一方面,KISS 原则的核心是保持简单。它提倡简单直接的方法,避免复杂的算法和结构,这些可能会使代码难以理解和维护。
虽然 SOLID 原则和 KISS 原则都旨在提高软件质量,但它们有时可能会产生冲突。例如,遵循 SOLID 原则可能会导致代码更加复杂且难以理解,以实现更大的模块化和可维护性。同样,KISS 原则可能会导致代码不够灵活和可扩展,以保持其简单和直接。
在实践中,开发者通常需要在 SOLID 原则和 KISS 原则之间取得平衡。一方面,他们希望编写可维护、可扩展和灵活的代码。另一方面,他们希望编写简单且易于理解的代码。找到这种平衡需要仔细考虑权衡,并理解何时采用每种方法最为合适。
当我必须在 SOLID 方法和 KISS 方法之间做出选择时,我会想起我的老板 Amir Taya 说过的话:“当你建造法拉利时,你需要从一辆踏板车开始。”这句话是 KISS 的一个夸张例子:如果你不知道如何构建一个功能,就创建一个最简单的可工作版本(KISS),然后迭代,并在需要时使用 SOLID 原则扩展解决方案。
副作用和不可变性
副作用和不可变性是编程中的两个重要概念,对代码的质量和可维护性有重大影响。
副作用是指由于执行特定函数或代码片段而导致程序状态发生变化。副作用可以是显式的,例如将数据写入文件或更新变量,也可以是隐式的,例如修改全局状态或在代码的其他部分引起意外的行为。
另一方面,不可变性是指变量或数据结构在创建后不能被修改的特性。在函数式编程中,通过使数据结构和变量成为常量并避免副作用来实现不可变性。
避免副作用和使用不可变变量的重要性在于,它们使代码更容易理解、调试和维护。当代码副作用较少时,更容易推理出它做什么以及它不做什么。这使得找到和修复错误以及修改代码更容易,而不会影响系统的其他部分。
相比之下,具有许多副作用的代码更难以理解,因为程序的状态可能会以意想不到的方式发生变化。这使得调试和维护更加困难,并可能导致错误和意外行为。
函数式编程语言长期以来一直强调使用不可变性和避免副作用,但现在使用 C++编写具有这些特性的代码也是可能的。实现它的最简单方法是遵循C++核心指南中的常量和不可变性。
Con.1 – 默认情况下,使对象不可变
您可以将内置数据类型或用户定义数据类型的实例声明为常量,从而产生相同的效果。尝试修改它将导致编译器错误:
struct Data {
int val{42};
};
int main() {
const Data data;
data.val = 43; // assignment of member 'Data::val' in
// read-only object
const int val{42};
val = 43; // assignment of read-only variable 'val'
}
同样适用于循环:
for (const int i : array) {
std::cout << i << std::endl; // just reading: const
}
for (int i : array) {
std::cout << i << std::endl; // just reading: non-const
}
这种方法可以防止难以察觉的值的变化。
可能唯一的例外是按值传递的函数参数:
void foo(const int value);
这样的参数很少作为const
传递,也很少被修改。为了避免混淆,建议在这种情况下不要强制执行此规则。
Con.2 – 默认情况下,使成员函数为 const
成员函数(方法)应当被标记为const
,除非它改变了对象的可观察状态。这样做的原因是为了给出更精确的设计意图声明,更好的可读性,更好的可维护性,编译器能捕获更多错误,以及理论上更多的优化机会:
class Book {
public:
std::string name() { return name_; }
private:
std::string name_;
};
void print(const Book& book) {
cout << book.name()
<< endl; // ERROR: 'this' argument to member
// function
// 'name' has type 'const Book', but
// function is not marked
// const clang(member_function_call_bad_cvr)
}
存在两种类型的 const 属性:物理和逻辑:
const
且不能被更改。
const
但可以被更改。
逻辑常量属性可以通过mutable
关键字实现。通常情况下,这是一个很少用的用例。我能想到的唯一好例子是将数据存储在内部缓存中或使用互斥锁:
class DataReader {
public:
Data read() const {
auto lock = std::lock_guard<std::mutex>(mutex);
// read data
return Data{};
}
private:
mutable std::mutex mutex;
};
在这个例子中,我们需要更改mutex
变量来锁定它,但这不会影响对象的逻辑常量属性。
请注意,存在一些遗留代码/库提供了声明T*
的函数,尽管它们没有对T
进行任何更改。这给试图将所有逻辑上常量方法标记为const
的个人带来了问题。为了强制 const 属性,你可以执行以下操作:
-
更新库/代码以使其符合 const-correct,这是首选解决方案。
-
提供一个包装函数来去除 const 属性。
示例
void read_data(int* data); // Legacy code: read_data does
// not modify `*data`
void read_data(const int* data) {
read_data(const_cast<int*>(data));
}
注意,这个解决方案是一个补丁,只能在无法修改read_data
的声明时使用。
Con.3 – 默认情况下,传递指针和引用到 const
这个很简单;当被调用的函数不修改状态时,推理程序更容易。
让我们看看以下两个函数:
void foo(char* p);
void bar(const char* p);
foo
函数是否修改了p
指针指向的数据?仅通过查看声明我们无法回答,所以我们默认假设它修改了。然而,bar
函数明确指出p
的内容将不会被更改。
Con.4 – 使用 const 定义在构造后值不改变的对象
这条规则与第一条非常相似,强制对象在未来的预期中不被更改的 const 属性。这对于像Config
这样的类非常有帮助,这些类在应用程序开始时创建,并在其生命周期内不发生变化:
class Config {
public:
std::string hostname() const;
uint16_t port() const;
};
int main(int argc, char* argv[]) {
const Config config = parse_args(argc, argv);
run(config);
}
Con.5 – 对于可以在编译时计算出的值使用 constexpr
如果值在编译时计算,将变量声明为constexpr
比声明为const
更可取。它提供了更好的性能、更好的编译时检查、保证的编译时评估以及没有竞争条件发生的可能性。
常量属性和数据竞争
当多个线程同时访问一个共享变量,并且至少有一个尝试修改它时,就会发生数据竞争。有一些同步原语,如互斥锁、临界区、自旋锁和信号量,可以防止数据竞争。这些原语的问题在于它们要么执行昂贵的系统调用,要么过度使用 CPU,这使代码效率降低。然而,如果没有线程修改变量,就没有数据竞争的地方。我们了解到 constexpr
是线程安全的(不需要同步),因为它是在编译时定义的。那么 const
呢?在以下条件下它可以线程安全。
变量自创建以来一直是 const
的。如果一个线程直接或间接(通过指针或引用)对变量有非 const
访问权限,所有读取者都需要使用互斥锁。以下代码片段展示了从多个线程对常量和非常量访问的示例:
void a() {
auto value = int{42};
auto t = std::thread([&]() { std::cout << value; });
t.join();
}
void b() {
auto value = int{42};
auto t = std::thread([&value = std::as_const(value)]() {
std::cout << value;
});
t.join();
}
void c() {
const auto value = int{42};
auto t = std::thread([&]() {
auto v = const_cast<int&>(value);
std::cout << v;
});
t.join();
}
void d() {
const auto value = int{42};
auto t = std::thread([&]() { std::cout << value; });
t.join();
}
在 a
函数中,value
变量由主线程和 t
都以非 const
的方式拥有,这使得代码可能不是线程安全的(如果开发者在主线程中稍后决定更改 value
)。在 b
中,主线程对 value
有“写入”访问权限,而 t
通过一个 const
引用接收它,但仍然不是线程安全的。c
函数是糟糕代码的一个例子:value
在主线程中被创建为一个常量,并通过 const
引用传递,但随后常量性被取消,这使得这个函数不是线程安全的。只有 d
函数是线程安全的,因为主线程和 t
都不能修改这个变量。
变量的数据类型及其所有子类型要么是物理常量,要么它们的逻辑常量实现是线程安全的。例如,在以下示例中,Point
结构体是物理常量,因为它的 x
和 y
字段成员是原始整数,并且两个线程都只有对它的 const
访问权限:
struct Point {
int x;
int y;
};
void foo() {
const auto point = Point{.x = 10, .y = 10};
auto t = std::thread([&]() { std::cout <<
point.x; });
t.join();
}
我们之前看到的 DataReader
类在逻辑上是常量的,因为它有一个可变的变量 mutex
,但这个实现也是线程安全的(由于锁的存在):
class DataReader {
public:
Data read() const {
auto lock = std::lock_guard<std::mutex>(mutex);
// read data
return Data{};
}
private:
mutable std::mutex mutex;
};
然而,让我们看看以下情况。RequestProcessor
类处理一些重量级请求并将结果缓存到一个内部变量中:
class RequestProcessor {
public:
Result process(uint64_t request_id,
Request request) const {
if (auto it = cache_.find(request_id); it !=
cache_.cend()) {
return it->second;
}
// process request
// create result
auto result = Result{};
cache_[request_id] = result;
return result;
}
private:
mutable std::unordered_map<uint64_t, Result> cache_;
};
void process_request() {
auto requests = std::vector<std::tuple<uint64_t,
Request>>{};
const auto processor = RequestProcessor{};
for (const auto& request : requests) {
auto t = std::thread([&]() {
processor.process(std::get<0>(request),
std::get<1>(request));
});
t.detach();
}
}
这个类在逻辑上是安全的,但 cache_
变量以非线程安全的方式更改,这使得即使在声明为 const
的情况下,这个类也不是线程安全的。
注意,当与 STL 容器一起工作时,必须记住,尽管当前实现倾向于线程安全(在物理和逻辑上),但标准提供了非常具体的线程安全保证。
容器中的所有函数都可以被不同容器上的各种线程同时调用。从广义上讲,除非通过函数参数可访问,否则 C++ 标准库中的函数不会读取其他线程可访问的对象,这包括 this
指针。
所有const
成员函数都是线程安全的,这意味着它们可以被多个线程在同一个容器上同时调用。此外,begin()
、end()
、rbegin()
、rend()
、front()
、back()
、data()
、find()
、lower_bound()
、upper_bound()
、equal_range()
、at()
和operator[]
(在关联容器中除外)成员函数在线程安全性方面也表现为const
。换句话说,它们也可以被多个线程在同一个容器上调用。广泛地说,C++ 标准库函数不会修改对象,除非这些对象可以通过函数的非const
参数直接或间接地访问,这包括this
指针。
同一容器中的不同元素可以由不同的线程同时修改,但std::vector<bool>
元素除外。例如,一个std::vector
的std::future
对象可以一次从多个线程接收值。
迭代器上的操作,如递增迭代器,读取底层容器但不修改它。这些操作可以与其他迭代器的操作、const
成员函数或对元素的读取同时进行。然而,会使任何迭代器失效的操作会修改容器,并且不能与任何现有迭代器的操作同时进行,即使是那些未被失效的迭代器。
同一容器的元素可以与那些不访问这些元素的成员函数同时修改。广泛地说,C++ 标准库函数不会通过其参数间接读取对象(包括容器的其他元素),除非其规范要求。
最后,只要用户可见的结果不受影响,容器上的操作(以及算法或其他 C++ 标准库函数)可以在内部并行化。例如,std::transform
可以并行化,但std::for_each
不能,因为它指定了按顺序访问序列中的每个元素。
将对象的单个可变引用作为 Rust 编程语言的一个支柱的想法。此规则旨在防止数据竞争,当多个线程同时访问相同的可变数据时,就会发生数据竞争,导致不可预测的行为和潜在的崩溃。通过一次只允许对对象的一个可变引用,Rust 确保了对同一数据的并发访问得到适当的同步,并避免了数据竞争。
此外,此规则有助于防止可变别名,当存在对同一数据的多个可变引用同时存在时,就会发生可变别名。可变别名可能导致微妙的错误,并使代码难以推理,尤其是在大型和复杂的代码库中。通过只允许对对象的一个可变引用,Rust 避免了可变别名,并有助于确保代码的正确性和易于理解。
然而,值得注意的是,Rust 也允许对对象有多个不可变引用,这在需要并发访问但不需要修改的场景中可能很有用。通过允许多个不可变引用,Rust 可以在保持安全性和正确性的同时提供更好的性能和并发性。
摘要
在本章中,我们介绍了 SOLID 原则、KISS 原则、const 属性和不可变性。让我们看看你学到了什么!
-
SOLID 原则:SOLID 是一组五个原则,帮助我们创建易于维护、可扩展和灵活的代码。通过理解这些原则,你将走向设计出易于工作的代码的梦想之路!
-
KISS 原则:KISS 原则的核心是保持简单。通过遵循这一原则,你可以避免过度复杂化你的代码,使其更容易维护和调试。
-
Const 属性:Const 属性是 C++中的一个特性,它使对象变为只读。通过将对象声明为
const
,你可以确保它们的值不会意外改变,从而使你的代码更加稳定和可预测。 -
不可变性:不可变性确保对象在创建后不能被改变。通过使对象不可变,你可以避免隐蔽的 bug,并使你的代码更具可预测性。
在掌握这些设计原则之后,你将走向编写既健壮又可靠的代码的道路。祝您编码愉快!
在下一章中,我们将尝试理解导致糟糕代码的原因。
第三章:糟糕代码的原因
在前面的章节中,我们讨论了 C++的编码标准和核心开发原则。当我们深入到重构现有代码时,理解导致代码质量低下或糟糕的原因至关重要。识别这些原因使我们能够避免重复相同的错误,解决现有问题,并有效地优先考虑未来的改进。
恶劣代码可能由各种因素造成,从外部压力到内部团队动态。一个重要因素是快速交付产品的需求,尤其是在快节奏的环境,如初创公司。在这里,快速发布功能的压力往往导致代码质量的妥协,开发者可能会为了满足紧迫的截止日期而走捷径或跳过重要的最佳实践。
另一个影响因素是 C++中解决同一问题的多种方式。语言的灵活性和丰富性,虽然强大,但可能导致不一致性和维护连贯代码库的困难。不同的开发者可能会以不同的方式处理相同的问题,导致代码库碎片化且难以维护。
开发者的个人品味也起着作用。个人偏好和编码风格可能会影响代码的整体质量和可读性。一个开发者认为优雅的,另一个可能觉得复杂,导致主观差异影响代码的一致性和清晰度。
最后,对现代 C++特性的缺乏可能导致代码效率低下或存在错误。随着 C++的发展,它引入了新的特性和范式,这些特性需要深入理解才能有效使用。当开发者没有跟上这些进步时,他们可能会退回到过时的做法,错失可以提高代码质量和性能的改进。
通过探讨这些方面,我们的目标是提供一个对导致糟糕代码的因素的全面理解。这种知识对于任何希望有效地重构和改进现有代码库的开发者来说都是必不可少的。让我们深入探讨,揭示 C++开发中糟糕代码的根本原因。
交付产品的需求
当开发者检查现有代码时,他们可能会质疑为什么代码以不那么优雅或缺乏可扩展性的方式编写。批评他人完成的工作通常很容易,但理解原始开发者的背景至关重要。假设项目最初是在一家初创公司开发的。在这种情况下,重要的是要考虑到初创文化显著强调快速产品交付和超越竞争对手的需求。虽然这可能是优势,但也可能导致糟糕的代码。其中一个主要原因是快速交付的压力,这可能导致开发者为了赶工期而走捷径或跳过必要的编码实践(例如,前几章中提到的 SOLID 原则)。这可能导致代码缺乏适当的文档,难以维护,并且可能容易出错。
此外,初创公司有限的资源和小型开发团队可能会加剧对速度的需求,因为开发者可能没有足够的人手来专注于优化和精炼代码库。结果,代码可能会变得杂乱无章且效率低下,导致性能下降和错误增加。
此外,创业文化中注重快速交付的特点可能会让开发者难以跟上 C++ 语言的最新进展。这可能会导致代码过时,缺乏重要功能,使用低效或已弃用的函数,并且没有针对性能进行优化。
开发者的个人品味
导致糟糕代码的另一个重要因素是开发者的个人品味。个人偏好和编码风格可能差异很大,导致主观差异影响代码的一致性和可读性。例如,考虑两位开发者鲍勃和爱丽丝。鲍勃偏好使用简洁、紧凑的代码,利用高级 C++ 特性,而爱丽丝则更喜欢更明确和冗长的代码,优先考虑清晰和简单。
鲍勃可能会使用现代 C++ 特性,如 lambda 表达式和 auto
关键字来编写函数:
auto process_data = [](const std::vector<int>& data) {
return std::accumulate(data.begin(), data.end(), 0L);
};
相反,爱丽丝可能会偏好更传统的方法,避免使用 lambda 表达式并使用显式类型:
long process_data(const std::vector<int>& data) {
long sum = 0;
for (int value : data) {
sum += value;
}
return sum;
}
虽然这两种方法都是有效的,并且可以达到相同的结果,但风格上的差异可能导致代码库中的混淆和不一致。如果鲍勃和爱丽丝在没有遵循共同编码标准的情况下共同工作,代码可能会成为不同风格的大杂烩,使得维护和理解变得更加困难。
此外,鲍勃使用现代特性的做法可能会引入团队成员不熟悉这些特性时难以应对的复杂性,而爱丽丝的冗长风格可能会被那些偏好更简洁代码的人视为过于简单和低效。这些差异源于个人品味,强调了建立和遵循团队编码标准的重要性,以确保代码库的一致性和可维护性。
通过识别和解决个人编码偏好的影响,团队可以共同努力创建一个统一且易于阅读的代码库,与最佳实践保持一致,并提高整体代码质量。
C++中解决相同问题的多种方法
C++是一种多才多艺的语言,提供了多种解决相同问题的方法,这一特性既能赋予开发者力量,也可能使他们感到困惑。这种灵活性往往会导致代码库中的不一致性,尤其是在不同开发者的专业水平和偏好不同时。在本章中,我们将展示一些示例,说明相同问题可以以不同的方式处理,突出每种方法的潜在优点和缺点。正如在开发者的个人品味部分所讨论的,像鲍勃和爱丽丝这样的开发者可能会使用不同的技术来处理相同的问题,从而导致代码库碎片化。
重温鲍勃和爱丽丝的例子
回顾一下,鲍勃使用了现代 C++特性,如 lambda 表达式和auto
来简洁地处理数据,而爱丽丝则更喜欢更明确和冗长的方法。两种方法都能达到相同的结果,但风格上的差异可能导致代码库中的混淆和不一致。虽然鲍勃的方法更紧凑并利用了现代 C++特性,但爱丽丝的方法对那些不熟悉 lambda 的人来说更易于理解。
原始指针和 C 函数与标准库函数
考虑一个项目,它大量使用原始指针和 C 函数来复制数据,这是较老 C++代码库中的常见做法:
void copy_array(const char* source, char* destination, size_t size) {
for (size_t i = 0; i < size; ++i) {
destination[i] = source[i];
}
}
这种方法虽然可行,但容易发生缓冲区溢出等错误,并且需要手动管理内存。相比之下,现代 C++方法会使用标准库函数,例如std::copy
:
void copy_array(const std::vector<char>& source, std::vector<char>& destination) {
std::copy(source.begin(), source.end(), std::back_inserter(destination));
}
使用std::copy
不仅简化了代码,而且利用了经过良好测试的库函数,这些函数可以处理边缘情况并提高安全性。
继承与模板
C++提供多种解决方案的另一个领域是代码重用和抽象。一些项目更喜欢使用继承,这可能导致僵化和复杂的层次结构:
class Shape {
public:
virtual void draw() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
public:
void draw() const override {
// Draw circle
}
};
class Square : public Shape {
public:
void draw() const override {
// Draw square
}
};
class ShapeDrawer {
public:
explicit ShapeDrawer(std::unique_ptr<Shape> shape) : shape_(std::move(shape)) {}
void draw() const {
shape_->draw();
}
private:
std::unique_ptr<Shape> shape_;
};
虽然继承提供了清晰的架构并允许多态行为,但随着层次结构的增长,它可能会变得繁琐。一种替代方法是使用模板来实现多态,而不需要虚拟函数的开销。以下是模板如何实现类似功能的方法:
template<typename ShapeType>
class ShapeDrawer {
public:
explicit ShapeDrawer(ShapeType shape) : shape_(std::move(shape)) {}
void draw() const {
shape_.draw();
}
private:
ShapeType shape_;
};
class Circle {
public:
void draw() const {
// Draw circle
}
};
class Square {
public:
void draw() const {
// Draw square
}
};
在这个例子中,ShapeDrawer
使用模板来实现多态行为。ShapeDrawer
可以与任何提供draw
方法的类型一起工作。这种方法避免了与虚拟函数调用相关的开销,并且可以更高效,尤其是在性能关键的应用中。
示例 - 处理错误
另一个例子是不同方式解决相同问题,比如错误处理。考虑一个项目中鲍勃使用传统的错误码:
int process_file(const std::string& filename) {
FILE* file = fopen(filename.c_str(), "r");
if (!file) {
return -1; // Error opening file
}
// Process file
return fclose(file);
}
另一方面,爱丽丝更倾向于使用异常进行错误处理:
void process_file(const std::string& filename) {
std::ifstream file(filename);
if (!file) {
throw std::runtime_error("Error opening file");
}
// Process file
}
使用异常可以使代码更简洁,因为它将错误处理与主逻辑分离,但需要理解异常安全性和处理。错误代码虽然更简单,但可能会使代码因重复检查而变得杂乱,并且可能提供的信息较少。
采用不同方法的项目
在现实世界的项目中,你可能会遇到这些方法的混合使用,反映了不同开发者的不同背景和偏好,例如以下示例:
-
项目 A在性能关键部分使用原始指针和 C 函数,依赖开发者的专业知识来安全地管理内存
-
项目 B更倾向于使用标准库容器和算法,优先考虑安全性和可读性,而不是原始性能
-
项目 C采用深度继承层次结构来建模其领域,强调实体之间的清晰关系
-
项目 D广泛使用模板来实现高性能和灵活性,尽管学习曲线更陡峭,且可能更复杂
每种方法都有其优缺点,选择正确的方法取决于项目的需求、团队的专长以及要解决的问题的具体性。然而,如果不小心管理,解决同一问题的多种方法可能会导致代码库碎片化和不一致。
C++提供了多种解决同一问题的方法,从原始指针和 C 函数到标准库容器和模板。虽然这种灵活性非常强大,但也可能导致代码库中的不一致性和复杂性。理解每种方法的优缺点,并通过编码标准和团队协议努力保持一致性,对于维护高质量、可维护的代码至关重要。通过采用现代 C++特性和最佳实践,开发者可以编写既高效又健壮的代码,降低错误发生的可能性,并提高整体代码质量。
C++知识不足
导致糟糕代码的一个主要原因是 C++知识不足。C++是一种复杂且不断发展的语言,具有广泛的功能,保持对其最新标准的更新需要持续学习。不熟悉现代 C++实践的开发者可能会无意中编写低效或容易出错的代码。本节探讨了 C++理解上的差距如何导致各种问题,并使用示例来说明常见的陷阱。
考虑两位开发者,鲍勃和爱丽丝。鲍勃对较老版本的 C++有丰富的经验,但 hasn’t kept up with recent updates,而爱丽丝对现代 C++特性非常熟悉。
使用原始指针和手动内存管理
鲍勃可能会使用原始指针和手动内存管理,这是较老 C++代码中的常见做法:
void process() {
int* data = new int[100];
// ... perform operations on data
delete[] data;
}
如果错过或错误地匹配delete[]
与new
,这种方法容易出错,如内存泄漏和未定义行为。例如,如果在分配之后但在delete[]
之前抛出异常,内存将会泄漏。爱丽丝熟悉现代 C++,会使用std::vector
来安全有效地管理内存:
void process() {
std::vector<int> data(100);
// ... perform operations on data
}
使用std::vector
消除了手动内存管理的需要,降低了内存泄漏的风险,并使代码更健壮、更容易维护。
智能指针使用不当
鲍勃试图采用现代实践,但错误地使用了std::shared_ptr
,导致潜在的性能问题:
std::shared_ptr<int> create() {
std::shared_ptr<int> ptr(new int(42));
return ptr;
}
这种方法涉及两个独立的分配:一个用于整数,另一个用于std::shared_ptr
的控制块。爱丽丝了解到std::make_shared
的好处,因此使用它来优化内存分配:
std::shared_ptr<int> create() {
return std::make_shared<int>(42);
}
std::make_shared
将分配合并到单个内存块中,提高了性能和缓存局部性。
移动语义的有效使用
鲍勃可能不完全理解移动语义及其在处理临时对象时提高性能的能力。考虑一个将元素追加到std::vector
的函数:
void append_data(std::vector<int>& target, const std::vector<int>& source) {
for (const int& value : source) {
target.push_back(value); // Copies each element
}
}
这种方法涉及将每个元素从source
复制到target
,这可能效率低下。爱丽丝了解移动语义,通过使用std::move
来优化:
void append_data(std::vector<int>& target, std::vector<int>&& source) {
for (int& value : source) {
target.push_back(std::move(value)); // Moves each element
}
}
通过使用std::move
,爱丽丝确保每个元素是移动而不是复制,这更有效率。此外,如果source
不再需要,爱丽丝还可能考虑对整个容器使用std::move
:
void append_data(std::vector<int>& target, std::vector<int>&& source) {
target.insert(target.end(), std::make_move_iterator(source.begin()), std::make_move_iterator(source.end()));
}
这种方法有效地移动整个容器的元素,利用移动语义来避免不必要的复制。
const 正确性使用不当
鲍勃可能会忽视 const 的正确性,导致潜在的 bug 和不清晰的代码:
class MyClass {
public:
int get_value() { return value; }
void set_value(int v) { value = v; }
private:
int value;
};
没有 const 正确性,get_value
是否修改对象的状态并不明确。爱丽丝应用 const 正确性来阐明意图并提高安全性:
class MyClass {
public:
int get_value() const { return value; }
void set_value(int v) { value = v; }
private:
int value;
};
将get_value
标记为const
确保它不会修改对象,使代码更清晰并防止意外修改。
不高效的字符串处理
鲍勃可能会使用 C 风格的字符数组来处理字符串,这可能导致缓冲区溢出和复杂的代码:
char message[100];
strcpy(message, "Hello, world!");
std::cout << message << std::endl;
这种方法容易出错且难以管理。爱丽丝了解到std::string
的能力,简化了代码并避免了潜在的错误:
std::string message = "Hello, world!";
std::cout << message << std::endl;
使用std::string
提供自动内存管理和丰富的字符串操作函数,使代码更安全、更易于表达。
使用 lambda 表达式时的未定义行为
C++11 中引入的 lambda 函数提供了强大的功能,但如果不正确使用,可能会导致未定义行为。鲍勃可能会编写一个 lambda 函数,通过引用捕获局部变量并返回它,从而导致悬垂引用:
auto create_lambda() {
int value = 42;
return [&]() { return value; };
}
auto lambda = create_lambda();
int result = lambda(); // Undefined behavior
爱丽丝理解风险,通过值捕获变量以确保其有效性:
auto create_lambda() {
int value = 42;
return [=]() { return value; };
}
auto lambda = create_lambda();
int result = lambda(); // Safe
通过值捕获避免了悬垂引用的风险,并确保 lambda 可以安全使用。
对未定义行为的误解
鲍勃可能会无意中编写依赖于未初始化变量的代码,从而导致未定义的行为:
int sum() {
int x;
int y = 5;
return x + y; // Undefined behavior: x is uninitialized
}
访问未初始化的变量可能导致不可预测的行为和难以调试的问题。爱丽丝理解初始化的重要性,确保所有变量都得到适当的初始化:
int sum() {
int x = 0;
int y = 5;
return x + y; // Defined behavior
}
正确初始化变量可以防止未定义的行为,并使代码更可靠。
C 风格数组的误用
使用 C 风格数组可能导致各种问题,例如缺乏边界检查和管理数组大小的困难。考虑以下示例,其中函数在栈上创建一个 C 数组并返回它:
int* create_array() {
int arr[5] = {1, 2, 3, 4, 5};
return arr; // Undefined behavior: returning a pointer to a local array
}
返回指向局部数组的指针会导致未定义的行为,因为数组在函数返回时超出作用域。一种更安全的方法是使用 std::array
,它可以安全地从函数返回。它提供了 size
方法,并与 C++ 算法(如 std::sort
)兼容:
std::array<int, 5> create_array() {
return {1, 2, 3, 4, 5};
}
使用 std::array
不仅避免了未定义的行为,还增强了安全性和与 C++ 标准库的互操作性。例如,排序数组变得简单:
std::array<int, 5> arr = create_array();
std::sort(arr.begin(), arr.end());
指针使用不足
现代 C++提供了如 std::unique_ptr
和 std::shared_ptr
这样的智能指针,以更安全、更有效地管理动态内存。通常,使用 std::unique_ptr
而不是原始指针来拥有独占所有权更好。当多个参与者需要共享资源的所有权时,可以使用 std::shared_ptr
。然而,与 std::shared_ptr
的误用相关的问题很常见。
构建 std::shared_ptr
使用 std::shared_ptr
构造函数创建对象会导致控制块和管理对象分别进行分配:
std::shared_ptr<int> create() {
std::shared_ptr<int> ptr(new int(42));
return ptr;
}
更好的方法是使用 std::make_shared
,它将分配合并到单个内存块中,提高性能和缓存局部性:
std::shared_ptr<int> create() {
return std::make_shared<int>(42);
}
通过值复制 std::shared_ptr
在同一线程栈内通过值复制 std::shared_ptr
不是很高效,因为引用计数是原子的。建议通过引用传递 std::shared_ptr
:
void process_shared_ptr(std::shared_ptr<int> ptr) {
// Inefficient: copies shared_ptr by value
}
void process_shared_ptr(const std::shared_ptr<int>& ptr) {
// Efficient: passes shared_ptr by reference
}
std::shared_ptr
的循环依赖
当两个或更多 std::shared_ptr
实例互相引用时,可能会发生循环依赖,阻止引用计数达到零并导致内存泄漏。考虑以下示例:
struct B;
struct A {
std::shared_ptr<B> b_ptr;
~A() { std::cout << "A destroyed\n"; }
};
struct B {
std::shared_ptr<A> a_ptr;
~B() { std::cout << "B destroyed\n"; }
};
void create_cycle() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b_ptr = b;
b->a_ptr = a;
}
在这种情况下,A
和 B
互相引用,形成一个循环,阻止它们的销毁。这个问题可以使用 std::weak_ptr
来打破循环:
struct B;
struct A {
std::weak_ptr<B> b_ptr; // Use weak_ptr to break the cycle
~A() { std::cout << "A destroyed\n"; }
};
struct B {
std::shared_ptr<A> a_ptr;
~B() { std::cout << "B destroyed\n"; }
};
void create_cycle() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b_ptr = b;
b->a_ptr = a;
}
检查 std::weak_ptr
状态
使用 std::weak_ptr
的一个常见错误是使用 expired()
检查其状态,然后锁定它,这并不是线程安全的:
std::weak_ptr<int> weak_ptr = some_shared_ptr;
void check_and_use_weak_ptr() {
if (!weak_ptr.expired()) {
// This is not thread-safe
auto shared_ptr = weak_ptr.lock();
shared_ptr->do_something();
}
}
正确的做法是锁定 std::weak_ptr
并检查返回的 std::shared_ptr
不是 null
:
void check_and_use_weak_ptr_correctly() {
// This is thread-safe
if (auto shared_ptr = weak_ptr.lock()) {
// Use shared_ptr
shared_ptr->do_something();
}
}
C++知识的缺乏可能导致各种问题,从内存管理错误到低效且难以阅读的代码。通过保持对现代 C++特性和最佳实践的更新,开发者可以编写更安全、更高效且易于维护的代码。持续学习和适应是克服这些挑战并提高整体代码质量的关键。Bob 和 Alice 的例子突出了理解和应用现代 C++实践的重要性,以避免常见陷阱并生成高质量的代码。
摘要
在本章中,我们探讨了 C++中不良代码的多种原因,以及缺乏现代 C++实践知识如何导致低效、易出错或未定义的行为。通过检查具体示例,我们强调了持续学习和适应以跟上 C++功能演变的重要性。
我们首先讨论了使用原始指针和手动内存管理的陷阱,展示了现代 C++实践,如std::vector
如何消除手动内存管理的需求并降低内存泄漏的风险。强调了使用std::unique_ptr
进行独占所有权和std::shared_ptr
进行共享所有权的优势,同时突出了常见问题,如低效的内存分配、不必要的复制和循环依赖。
在std::shared_ptr
的上下文中,我们展示了使用std::make_shared
而非构造函数来减少内存分配并提高性能的优势。由于原子引用计数器,通过引用而非值传递std::shared_ptr
所获得的效率提升也得到了解释。我们还阐述了循环依赖的问题以及如何使用std::weak_ptr
来打破循环并防止内存泄漏。同时,还介绍了通过锁定并检查结果std::shared_ptr
来确保线程安全的正确方式。
讨论了有效使用移动语义来优化性能,通过减少临时对象的非必要复制。使用std::move
和std::make_move_iterator
可以显著提高程序性能。强调了 const 正确性的重要性,展示了将const
应用于方法如何阐明意图并提高代码安全性。
我们讨论了使用 C 风格字符数组的危险,以及std::string
如何简化字符串处理、减少错误并提供更好的内存管理。探讨了 C 风格数组的误用,并介绍了std::array
作为更安全、更健壮的替代方案。通过使用std::array
,我们可以避免未定义的行为,并利用 C++标准库算法,如std::sort
。
最后,我们讨论了 lambda 函数的正确使用,以及通过引用捕获变量可能导致的潜在陷阱,这可能导致悬垂引用。通过值捕获变量确保 lambda 函数的安全使用。
通过这些例子,我们了解到采用现代 C++特性和最佳实践对于编写更安全、更高效和可维护的代码具有至关重要的意义。通过紧跟最新标准并不断深化我们对 C++的理解,我们可以避免常见的陷阱,并生产出高质量的软件。
第四章:确定重构的理想候选者 - 模式和反模式
重构是软件开发中的一个关键技术,它涉及对现有代码进行更改以改进其结构、可读性和可维护性,而不改变其行为。它对于几个原因至关重要。
它有助于消除技术债务并提高代码库的整体质量。开发者可以通过删除冗余或重复的代码、简化复杂的代码和改进代码可读性来实现这一点,从而产生更易于维护和健壮的软件。
重构促进了未来的开发。通过重构代码以使其更模块化,开发者可以更有效地重用现有代码,节省未来开发的时间和精力。这使得代码更具灵活性和适应性,更容易添加新功能、修复错误和优化性能。
结构良好且易于维护的代码使得多个开发者能够更有效地在项目上进行协作。重构有助于标准化代码实践,减少复杂性,并改进文档,使开发者更容易理解和贡献代码库。
最终,重构可以降低长期软件开发相关的成本。通过提高代码质量和可维护性,重构可以帮助减少修复错误、更新和其他维护任务所需的时间和精力。
在本章中,我们将专注于在 C++项目中识别重构的良好候选者。然而,在大型和复杂的系统中,确定适合重构的正确代码段可能具有挑战性。因此,了解使代码段成为重构理想候选者的因素至关重要。在本章中,我们将探讨这些因素,并提供在 C++中识别重构良好候选者的指南。我们还将讨论可以用来提高 C++代码质量的常见重构技术和工具。
哪种代码值得重写?
确定是否值得重写的代码取决于几个因素,包括代码的可维护性、可读性、性能、可扩展性和遵循最佳实践的程度。让我们看看代码可能值得重写的一些情况。
有问题的代码通常是代码需要重写的迹象。这些是设计或实现不佳的迹象,例如方法过长、类过大、代码重复或命名约定不佳。解决这些代码问题可以改善代码库的整体质量,并使其长期维护更容易。
表现出低内聚或高耦合的代码可能值得重写。低内聚意味着模块或类内的元素之间没有紧密关联,并且模块或类有太多的职责。高耦合指的是模块或类之间的高度依赖性,使得代码更难以维护和修改。重构此类代码可以导致更模块化和易于理解的架构。
在前面的章节中,我们讨论了 SOLID 原则的重要性;违反这些原则的代码也值得重写。
另一个重写代码的原因是如果它依赖于过时的技术、库或编程实践。随着时间的推移,此类代码可能越来越难以维护,并且可能无法利用更新、更有效的方法或工具。将代码更新为使用当前技术和实践可以提高其性能、安全性和可维护性。
最后,如果代码存在性能或可扩展性问题,可能值得重写。这可能涉及优化算法、数据结构或资源管理,以确保代码运行得更高效,并能处理更大的工作负载。
代码恶臭及其基本特征
有问题的代码,也称为代码恶臭,指的是代码库中表明潜在设计或实现问题的症状。这些症状并不一定是错误,但它们是潜在问题的指示,这些问题可能会使代码更难以理解、维护和修改。代码恶臭往往是由于糟糕的编码实践或随着时间的推移技术债务的积累造成的。尽管代码恶臭可能不会直接影响程序的功能,但它们可以显著影响代码的整体质量,导致错误风险增加和开发者生产率下降。
解决代码恶臭的一个方面是识别并应用适当的设计模式。设计模式是解决软件设计中常见问题的可重用解决方案。它们为解决特定问题提供了一个经过验证的框架,使开发者能够建立在其他开发者的集体智慧和经验之上。通过应用这些模式,可以将恶臭代码重构为更结构化、模块化和易于维护的形式。让我们看看一些例子。
策略模式允许我们定义一组算法,将每个算法封装在单独的类中,并在运行时使它们可互换。策略模式对于重构具有多个分支或执行类似任务但实现略有不同的条件的代码非常有用。
让我们考虑一个使用不同存储策略保存数据的应用程序的例子,例如保存到磁盘或远程存储服务:
#include <iostream>
#include <fstream>
#include <string>
#include <assert>
enum class StorageType {
Disk,
Remote
};
class DataSaver {
public:
DataSaver(StorageType storage_type) : storage_type_(storage_type) {}
void save_data(const std::string& data) const {
switch (storage_type_) {
case StorageType::Disk:
save_to_disk(data);
break;
case StorageType::Remote:
save_to_remote(data);
break;
default:
assert(false && “Unknown storage type.”);
}
}
void set_storage_type(StorageType storage_type) {
storage_type_ = storage_type;
}
private:
void save_to_disk(const std::string& data) const {
// saving to disk
}
void save_to_remote(const std::string& data) const {
// saving data to a remote storage service.
}
StorageType storage_type_;
};
int main() {
DataSaver disk_data_saver(StorageType::Disk);
disk_data_saver.save_data(“Save this data to disk.”);
DataSaver remote_data_saver(StorageType::Remote);
remote_data_saver.save_data(“Save this data to remote storage.”);
// Switch the storage type at runtime.
disk_data_saver.set_storage_type(StorageType::Remote);
disk_data_saver.save_data(“Save this data to remote storage after switching storage type.”);
return 0;
}
在本课程中,save_data
方法在每次调用时都会检查存储类型,并使用switch-case
块来决定使用哪种保存方法。这种方法可行,但有一些缺点:
-
DataSaver
类负责处理所有不同的存储类型,这使得维护和扩展更困难。 -
添加新的存储类型需要修改
DataSaver
类和StorageType
枚举,增加了引入错误或破坏现有功能的风险。例如,如果由于某种原因提供了错误的枚举类型,代码将终止。 -
与将行为封装在单独类中的策略模式相比,代码的模块化和灵活性较低。
通过实现策略模式,我们可以解决这些缺点,并为DataSaver
类创建一个更易于维护、灵活和可扩展的设计。首先,定义一个名为SaveStrategy
的接口,它代表保存行为:
class SaveStrategy {
public:
virtual ~SaveStrategy() {}
virtual void save_data(const std::string& data) const = 0;
};
接下来,为每种存储类型实现具体的SaveStrategy
类:
class DiskSaveStrategy : public SaveStrategy {
public:
void save_data(const std::string& data) const override {
// ...
}
};
class RemoteSaveStrategy : public SaveStrategy {
public:
void save_data(const std::string& data) const override {
// ...
}
};
现在,创建一个使用策略模式将保存行为委托给适当的SaveStrategy
实现的DataSaver
类:
class DataSaver {
public:
DataSaver(std::unique_ptr<SaveStrategy> save_strategy)
: save_strategy_(std::move(save_strategy)) {}
void save_data(const std::string& data) const {
save_strategy_->save_data(data);
}
void set_save_strategy(std::unique_ptr<SaveStrategy> save_strategy) {
save_strategy_ = std::move(save_strategy);
}
private:
std::unique_ptr<SaveStrategy> save_strategy_;
};
最后,这里是一个如何使用DataSaver
类和不同的保存策略的示例:
int main() {
DataSaver disk_data_saver(std::make_unique<DiskSaveStrategy>());
disk_data_saver.save_data(“Save this data to disk.”);
DataSaver remote_data_saver(std::make_unique<RemoteSaveStrategy>());
remote_data_saver.save_data(“Save this data to remote storage.”);
// Switch the saving strategy at runtime.
disk_data_saver.set_save_strategy(std::make_unique<RemoteSaveStrategy>());
disk_data_saver.save_data(“Save this data to remote storage after switching strategy.”);
return 0;
}
在这个例子中,DataSaver
类使用策略模式将它的保存行为委托给不同的SaveStrategy
实现,这使得它能够轻松地在保存到磁盘和保存到远程存储之间切换。这种设计使代码更加模块化、可维护和灵活,允许以最小的现有代码更改添加新的存储策略。此外,新版本的代码不需要在错误的保存策略类型上终止或抛出异常。
假设我们有两个格式的文件解析实现,CSV 和 JSON:
class CsvParser {
public:
void parse_file(const std::string& file_path) {
std::ifstream file(file_path);
if (!file) {
std::cerr << “Error opening file: “ << file_path << std::endl;
return;
}
std::string line;
while (std::getline(file, line)) {
process_line(line);
}
file.close();
post_process();
}
private:
void process_line(const std::string& line) {
// Implement the CSV-specific parsing logic.
std::cout << “Processing CSV line: “ << line << std::endl;
}
void post_process() {
std::cout << “CSV parsing completed.” << std::endl;
}
};
class JsonParser {
public:
void parse_file(const std::string& file_path) {
std::ifstream file(file_path);
if (!file) {
std::cerr << “Error opening file: “ << file_path << std::endl;
return;
}
std::string line;
while (std::getline(file, line)) {
process_line(line);
}
file.close();
post_process();
}
private:
void process_line(const std::string& line) {
// Implement the JSON-specific parsing logic.
std::cout << “Processing JSON line: “ << line << std::endl;
}
void post_process() {
std::cout << “JSON parsing completed.” << std::endl;
}
};
在这个例子中,CsvParser
和JsonParser
类有parse_file
方法的独立实现,其中包含打开、读取和关闭文件的重复代码。特定格式的解析逻辑在process_line
和post_process
方法中实现。
虽然这种设计可行,但它有一些缺点:共享的解析步骤在两个类中都重复,这使得维护和更新代码更困难,并且添加对新文件格式的支持需要创建具有类似代码结构的新的类,这可能导致更多的代码重复。
通过实现模板方法模式,你可以解决这些缺点,并为文件解析器创建一个更易于维护、可扩展和可重用的设计。FileParser
基类处理常见的解析步骤,而派生类实现特定格式的解析逻辑。
如前例所示,让我们从创建一个抽象基类开始。FileParser
代表通用的文件解析过程:
class FileParser {
public:
void parse_file(const std::string& file_path) {
std::ifstream file(file_path);
if (!file) {
std::cerr << “Error opening file: “ << file_path << std::endl;
return;
}
std::string line;
while (std::getline(file, line)) {
process_line(line);
}
file.close();
post_process();
}
protected:
virtual void process_line(const std::string& line) = 0;
virtual void post_process() = 0;
};
FileParser
类有一个parse_file
方法,它处理打开文件、逐行读取其内容以及关闭文件的常见步骤。特定格式的解析逻辑通过纯虚函数process_line
和post_process
方法实现,这些方法将由派生类覆盖。
现在,为不同的文件格式创建派生类:
class CsvParser : public FileParser {
protected:
void process_line(const std::string& line) override {
// Implement the CSV-specific parsing logic.
std::cout << “Processing CSV line: “ << line << std::endl;
}
void post_process() override {
std::cout << “CSV parsing completed.” << std::endl;
}
};
class JsonParser : public FileParser {
protected:
void process_line(const std::string& line) override {
// Implement the JSON-specific parsing logic.
std::cout << “Processing JSON line: “ << line << std::endl;
}
void post_process() override {
std::cout << “JSON parsing completed.” << std::endl;
}
};
在这个例子中,CsvParser
和JsonParser
类从FileParser
继承,并在process_line
和post_process
方法中实现特定格式的解析逻辑。
下面是一个如何使用文件解析器的示例:
int main() {
CsvParser csv_parser;
csv_parser.parse_file(“data.csv”);
JsonParser json_parser;
json_parser.parse_file(“data.json”);
return 0;
}
通过实现模板方法模式,FileParser
类提供了一个处理文件解析常见步骤的可重用模板,同时允许派生类实现特定格式的解析逻辑。这种设计使得在不修改基类FileParser
的情况下添加对新文件格式的支持变得容易,从而使得代码库更加易于维护和扩展。重要的是要注意,通常实现这种设计模式的复杂部分是识别类之间的共同逻辑。通常,实现需要某种形式的共同逻辑的统一。
另一个值得关注的模式是观察者模式。前一章提到了其技术实现细节(原始、共享或弱指针实现)。然而,在这一章中,我想从设计角度介绍其用法。
观察者模式定义了对象之间的一对多依赖关系,允许在主题状态发生变化时通知多个观察者。当重构涉及事件处理或多个依赖组件更新的代码时,这种模式可能是有益的。
考虑一个汽车系统,其中Engine
类包含汽车当前的速度和每分钟转速(RPM)。有几个元素需要了解这些值,例如Dashboard
和Controller
。仪表盘显示来自发动机的最新更新,而Controller
根据速度和转速调整汽车的行为。实现这一点的直接方法是让Engine
类直接在每个显示元素上调用update
方法:
class Dashboard {
public:
void update(int speed, int rpm) {
// display the current speed
}
};
class Controller {
public:
void update(int speed, int rpm) {
// Adjust car’s behavior based on the speed and RPM.
}
};
class Engine {
public:
void set_dashboard(Dashboard* dashboard) {
dashboard_ = dashboard;
}
void set_controller(Controller* controller) {
controller_ = controller;
}
void set_measurements(int speed, int rpm) {
speed_ = speed;
rpm_ = rpm;
measurements_changed();
}
private:
void measurements_changed() {
dashboard_->update(_speed, rpm_);
controller_->update(_speed, rpm_);
}
int speed_;
int rpm_;
Dashboard* dashboard_;
Controller* controller_;
};
int main() {
Engine engine;
engine.set_measurements(80, 3000);
return 0;
}
这段代码有几个问题:
-
Engine
类与Dashboard
和Controller
紧密耦合,这使得添加或删除可能对汽车速度和转速感兴趣的其它组件变得困难。 -
Engine
类直接负责更新显示元素,这使代码变得复杂,并降低了其灵活性。
我们可以使用观察者模式重构代码,将Engine
与显示元素解耦。Engine
类将成为主题,而Dashboard
和Controller
将成为观察者:
class Observer {
public:
virtual ~Observer() {}
virtual void update(int speed, int rpm) = 0;
};
class Dashboard : public Observer {
public:
void update(int speed, int rpm) override {
// display the current speed
}
};
class Controller : public Observer {
public:
void update(int speed, int rpm) override {
// Adjust car’s behavior based on the speed and RPM.
}
};
class Engine {
public:
void register_observer(Observer* observer) {
observers_.push_back(observer);
}
void remove_observer(Observer* observer) {
observers_.erase(std::remove(_observers.begin(), observers_.end(), observer), observers_.end());
}
void set_measurements(int speed, int rpm) {
speed_ = speed;
rpm_ = rpm;
notify_observers();
}
private:
void notify_observers() {
for (auto observer : observers_) {
observer->update(_speed, _rpm);
}
}
std::vector<Observer*> observers_;
int speed_;
int rpm_;
};
以下代码片段展示了新类层次结构的用法:
int main() {
Engine engine;
Dashboard dashboard;
Controller controller;
// Register observers
engine.register_observer(&dashboard);
engine.register_observer(&controller);
// Update measurements
engine.set_measurements(80, 3000);
// Remove an observer
engine.remove_observer(&dashboard);
// Update measurements again
engine.set_measurements(100, 3500);
return 0;
}
在这个例子中,Dashboard
和Controller
被注册为Engine
主题的观察者。当发动机的速度和转速发生变化时,会调用set_measurements
,触发notify_observers
,进而调用每个注册观察者的update
方法。这使得Dashboard
和Controller
能够接收到更新的速度和转速值。
然后,仪表盘
被取消注册为观察者。当引擎的速度和 RPM 再次更新时,只有控制器
会接收到更新的值。
在这种设置下,添加或删除观察者就像在Engine
上调用register_observer
或remove_observer
一样简单,并且无需在添加新的观察者类型时修改Engine
类。现在,Engine
类与特定的观察者类解耦,使系统更加灵活且易于维护。
另一个伟大的模式是状态机。它不是一个经典模式,但可能是最强大的一种。状态机,也称为有限状态机(FSMs),是计算数学模型。它们用于表示和控制硬件和软件设计中的执行流程。状态机具有有限数量的状态,在任何给定时间,它都处于这些状态之一。它根据外部输入或预定义条件从一个状态转换到另一个状态。
在硬件领域,状态机经常用于数字系统的设计,作为从小型微控制器到大型中央处理器(CPUs)的控制逻辑。它们控制操作序列,确保动作按正确的顺序发生,并且系统能够适当地响应不同的输入或条件。
在软件中,状态机同样有用,尤其是在程序流程受一系列状态及其之间转换影响的系统中。应用范围从嵌入式系统中的简单按钮消抖到复杂的游戏角色行为或通信协议管理。
状态机非常适合那些系统有一个明确的状态集合,并且这些状态通过特定的事件或条件循环切换的情况。它们在系统的行为不仅取决于当前输入,还取决于系统历史的情况下特别有用。状态机通过当前状态的形式封装了这一历史,使其明确且易于管理。
使用状态机可以带来许多好处。它们可以简化复杂的条件逻辑,使其更容易理解、调试和维护。它们还使得在不干扰现有代码的情况下添加新状态或转换变得容易,增强了模块化和灵活性。此外,它们使系统的行为明确且可预测,降低了意外行为的风险。
让我们考虑一个分布式计算系统的真实场景,其中一项工作被提交以进行处理。这项工作会经历各种状态,如 Submitted
、Queued
、Running
、Completed
和 Failed
。我们将使用 Boost.Statechart
库来模拟这个过程。Boost.Statechart
是一个 C++ 库,它提供了一个构建状态机的框架。它是 Boost 库集合的一部分。这个库简化了分层状态机的开发,允许你使用复杂的状态和转换来模拟复杂系统。它的目标是使处理复杂状态逻辑时编写结构良好、模块化和可维护的代码变得更加容易。Boost.Statechart
提供了编译时和运行时检查,以帮助确保状态机行为的正确性。
首先,我们包含必要的头文件并设置一些命名空间:
#include <boost/statechart/state_machine.hpp>
#include <boost/statechart/simple_state.hpp>
#include <boost/statechart/transition.hpp>
#include <iostream>
namespace sc = boost::statechart;
接下来,我们定义我们的事件:JobSubmitted
、JobQueued
、JobRunning
、JobCompleted
和 JobFailed
:
struct EventJobSubmitted : sc::event< EventJobSubmitted > {};
struct EventJobQueued : sc::event< EventJobQueued > {};
struct EventJobRunning : sc::event< EventJobRunning > {};
struct EventJobCompleted : sc::event< EventJobCompleted > {};
struct EventJobFailed : sc::event< EventJobFailed > {};
然后,我们定义我们的状态,每个状态都是一个继承自 sc::simple_state
的类。我们将有五个状态:Submitted
、Queued
、Running
、Completed
和 Failed
:
struct Submitted;
struct Queued;
struct Running;
struct Completed;
struct Failed;
struct Submitted : sc::simple_state< Submitted, Job > {
typedef sc::transition< EventJobQueued, Queued > reactions;
Submitted() { std::cout << “Job Submitted\n”; }
};
struct Queued : sc::simple_state< Queued, Job > {
typedef sc::transition< EventJobRunning, Running > reactions;
Queued() { std::cout << “Job Queued\n”; }
};
struct Running : sc::simple_state< Running, Job > {
typedef boost::mpl::list<
sc::transition< EventJobCompleted, Completed >,
sc::transition< EventJobFailed, Failed >
> reactions;
Running() { std::cout << “Job Running\n”; }
};
struct Completed : sc::simple_state< Completed, Job > {
Completed() { std::cout << “Job Completed\n”; }
};
struct Failed : sc::simple_state< Failed, Job > {
Failed() { std::cout << “Job Failed\n”; }
};
最后,我们定义我们的状态机,Job
,它从 Submitted
状态开始。
struct Job : sc::state_machine< Job, Submitted > {};
在一个 main
函数中,我们可以创建我们的 Job
状态机实例并处理一些事件:
int main() {
Job my_job;
my_job.initiate();
my_job.process_event(EventJobQueued());
my_job.process_event(EventJobRunning());
my_job.process_event(EventJobCompleted());
return 0;
}
这将输出以下内容:
Job Submitted
Job Queued
Job Running
Job Completed
这个简单的例子展示了如何使用状态机来模拟具有多个状态和转换的过程。我们使用事件来触发状态之间的转换。另一种方法是使用状态反应,其中状态可以根据其拥有的条件或数据来决定何时进行转换。
这可以通过在 Boost.Statechart
中使用自定义反应来实现。自定义反应是一个在处理事件时被调用的成员函数。它可以决定要做什么:忽略事件、消费事件而不进行转换,或者转换到新状态。
让我们修改 Job
状态机,使其能够根据工作的完成状态来决定何时从 Running
转换到 Completed
或 Failed
。
首先,我们将定义一个新的事件,EventJobUpdate
,它将携带工作的完成状态:
struct EventJobUpdate : sc::event< EventJobUpdate > {
EventJobUpdate(bool is_complete) : is_complete(is_complete) {}
bool is_complete;
};
然后,在 Running
状态中,我们将为这个事件定义一个自定义反应:
struct Running : sc::simple_state< Running, Job > {
typedef sc::custom_reaction< EventJobUpdate > reactions;
sc::result react(const EventJobUpdate& event) {
if (event.is_complete) {
return transit<Completed>();
} else {
return transit<Failed>();
}
}
Running() { std::cout << “Job Running\n”; }
};
现在,Running
状态将根据 EventJobUpdate
事件的 is_complete
字段来决定何时转换到 Completed
或 Failed
。
在 main
函数中,我们现在可以处理 EventJobUpdate
事件:
int main() {
Job my_job;
my_job.initiate();
my_job.process_event(EventJobQueued());
my_job.process_event(EventJobRunning());
my_job.process_event(EventJobUpdate(true)); // The job is complete.
return 0;
}
这将输出以下内容:
Job Submitted
Job Queued
Job Running
Job Completed
如果我们用 false
处理 EventJobUpdate
:
my_job.process_event(EventJobUpdate(false)); // The job is not complete.
它将输出以下内容:
Job Submitted
Job Queued
Job Running
Job Failed
这展示了状态如何根据其拥有的条件或数据来决定何时进行转换。
作为状态机实现的逻辑可以通过添加新的状态和它们之间的转换规则来轻松扩展。然而,在某个时刻,状态机可能包含太多的状态(比如说,超过七个)。这通常是一个代码有问题的症状。这意味着状态机被太多的状态所压垮,这些状态实现了多个状态机。例如,我们的分布式系统本身可以作为一个状态机来实现。系统可以有自己的状态,例如Idle
(空闲)、ProcessingJobs
(处理作业)和SystemFailure
(系统故障)。ProcessingJobs
状态将进一步包含Job
状态机作为子状态机。System
状态机可以通过处理事件与Job
子状态机通信。当System
转换到ProcessingJobs
状态时,它可以处理一个EventJobSubmitted
事件来启动Job
子状态机。当Job
转换到Completed
或Failed
状态时,它可以处理一个EventJobFinished
事件来通知System
。
首先,我们定义了EventJobFinished
事件:
struct EventJobFinished : sc::event< EventJobFinished > {};
然后,在Job
状态机的Completed
和Failed
状态中,我们处理EventJobFinished
事件:
struct Completed : sc::simple_state< Completed, Job > {
Completed() {
std::cout << “Job Completed\n”;
context< Job >().outermost_context().process_event(EventJobFinished());
}
};
struct Failed : sc::simple_state< Failed, Job > {
Failed() {
std::cout << “Job Failed\n”;
context< Job >().outermost_context().process_event(EventJobFinished());
}
};
在System
状态机的ProcessingJobs
状态中,我们为EventJobFinished
事件定义了一个自定义反应:
struct ProcessingJobs : sc::state< ProcessingJobs, System, Job > {
typedef sc::custom_reaction< EventJobFinished > reactions;
sc::result react(const EventJobFinished&) {
std::cout << “Job Finished\n”;
return transit<Idle>();
}
ProcessingJobs(my_context ctx) : my_base(ctx) {
std::cout << “System Processing Jobs\n”;
context< System >().process_event(EventJobSubmitted());
}
};
在main
函数中,我们可以创建我们的System
状态机的一个实例并启动它:
int main() {
System my_system;
my_system.initiate();
return 0;
}
这将输出以下内容:
System Idle
System Processing Jobs
Job Submitted
Job Queued
Job Running
Job Completed
Job Finished
System Idle
这展示了System
状态机如何与Job
子状态机交互。当System
转换到ProcessingJobs
状态时,它会启动Job
,而当Job
完成时,它会通知System
。这允许System
管理Job
的生命周期并对它的状态变化做出反应。
这可以使你的状态机更加灵活和动态。
通常,状态机是管理复杂行为的一种强大工具,既稳健又易于理解。尽管它们很有用,但状态机并不总是代码结构的首选,可能是因为它们被认为很复杂或缺乏熟悉度。然而,当处理一个以复杂条件逻辑为特征的系统时,考虑使用状态机可能是一个明智的选择。这是一个强大的工具,可以为你的软件设计带来清晰性和稳健性,使其成为 C++或其他任何语言重构工具包的一个基本组成部分。
反模式
与设计模式不同,反模式是长期来看可能产生反效果或有害的常见解决方案。识别和避免反模式对于解决代码问题至关重要,因为应用它们可能会加剧现有问题并引入新的问题。一些反模式的例子包括 Singleton(单例)、God Object(上帝对象)、Copy-Paste Programming(复制粘贴编程)、Premature Optimization(过早优化)和 Spaghetti Code(意大利面代码)。
单例模式众所周知违反了依赖倒置和开闭原则。它创建了一个全局实例,这可能导致类之间的隐藏依赖,使得代码难以理解和维护。它违反了依赖倒置原则,因为它鼓励高层模块依赖于低层模块而不是依赖于抽象。此外,单例模式通常使得在扩展类或进行测试时难以用不同的实现替换单例实例。这违反了开闭原则,因为它要求修改代码以改变或扩展行为。在以下代码示例中,我们有一个单例类 Database
,它被 OrderManager
类使用:
class Database {
public:
static Database& get_instance() {
static Database instance;
return instance;
}
template<typename T>
std::optional<T> get(const Id& id) const;
template<typename T>
void save(const T& data);
private:
Database() {} // Private constructor
Database(const Database&) = delete; // Delete copy constructor
Database& operator=(const Database&) = delete; // Delete copy assignment operator
};
class OrderManager {
public:
void addOrder(const Order& order) {
auto db = Database::get_instance();
// check order validity
// notify other components about the new order, etc
db.save(order);
}
};
将数据库连接表示为单例的想法是非常合理的:应用程序允许每个应用程序实例只有一个数据库连接,数据库在代码的各个地方都被使用。单例的使用隐藏了 OrderManager
依赖于 Database
的这一事实,这使得代码不那么直观和可预测。单例的使用几乎使得通过单元测试测试 OrderManager
的业务逻辑变得不可能,除非运行一个真实的数据库实例。
可以通过在 main
函数的开始处创建一个 Database
实例并将其传递给所有需要数据库连接的类来解决这个问题:
class OrderManager {
public:
OrderManager(Database& db);
// the rest of the code is the same
};
int main() {
auto db = Database{};
auto order_manager = OrderManager{db};
}
注意,尽管 Database
已不再是单例(即其构造函数是公开的),但它仍然不能被复制。技术上,这允许开发者创建新的实例,但这并不是期望的行为。根据我的经验,可以通过团队内的知识共享和代码审查来轻松避免这种情况。那些认为这还不够的开发者可以保持 Database
不变,但确保 get_instance
只被调用一次,并且从那时起通过引用传递:
int main() {
auto db = Database::get_instance();
auto order_manager = OrderManager{db};
}
如果一个代码问题涉及一个具有太多责任的类,应用上帝对象反模式是不合适的,因为这只会使类更加复杂和难以维护。一般来说,上帝类是对单一责任原则的过度违反。例如,让我们看看以下类,EcommerceSystem
:
class ECommerceSystem {
public:
// Product management
void add_product(int id, const std::string& name, uint64_t price) {
products_[id] = {name, price};
}
void remove_product(int id) {
products_.erase(id);
}
void update_product(int id, const std::string& name, uint64_t price) {
products_[id] = {name, price};
}
void list_products() {
// print the list of products
}
// Cart management
void add_to_cart(int product_id, int quantity) {
cart_[product_id] += quantity;
}
void remove_from_cart(int product_id) {
cart_.erase(product_id);
}
void update_cart(int product_id, int quantity) {
cart_[product_id] = quantity;
}
uint64_t calculate_cart_total() {
uint64_t total = 0;
for (const auto& item : cart_) {
total += products_[item.first].second * item.second;
}
return total;
}
// Order management
void place_order() {
// Process payment, update inventory, send confirmation email, etc.
// ...
cart_.clear();
}
// Persistence
void save_to_file(const std::string& file_name) {
// serializing the state to a file
}
void load_from_file(const std::string& file_name) {
// loading a file and parsing it
}
private:
std::map<int, std::pair<std::string, uint64_t>> products_;
std::map<int, int> cart_;
};
在这个例子中,ECommerceSystem
类承担了多个责任,如产品管理、购物车管理、订单管理和持久化(从文件中保存和加载数据)。这个类难以维护、理解和修改。
一个更好的方法是将 ECommerceSystem
分解成更小、更专注的类,每个类处理特定的责任:
-
ProductManager
类管理产品 -
CartManager
类管理购物车 -
OrderManager
类管理订单和相关任务(例如,处理支付和发送确认电子邮件) -
PersistenceManager
类负责从文件中保存和加载数据
这些类可以按以下方式实现:
class ProductManager {
public:
void add_product(int id, const std::string& name, uint64_t price) {
products_[id] = {name, price};
}
void remove_product(int id) {
products_.erase(id);
}
void update_product(int id, const std::string& name, uint64_t price) {
products_[id] = {name, price};
}
std::pair<std::string, uint64_t> get_product(int id) {
return products_[id];
}
void list_products() {
// print the list of products
}
private:
std::map<int, std::pair<std::string, uint64_t>> products_;
};
class CartManager {
public:
void add_to_cart(int product_id, int quantity) {
cart_[product_id] += quantity;
}
void remove_from_cart(int product_id) {
cart_.erase(product_id);
}
void update_cart(int product_id, int quantity) {
cart_[product_id] = quantity;
}
std::map<int, int> get_cart_contents() {
return cart_;
}
void clear_cart() {
cart_.clear();
}
private:
std::map<int, int> cart_;
};
class OrderManager {
public:
OrderManager(ProductManager& product_manager, CartManager& cart_manager)
: product_manager_(product_manager), cart_manager_(cart_manager) {}
uint64_t calculate_cart_total() {
// calculate cart’s total the same as before
}
void place_order() {
// Process payment, update inventory, send confirmation email, etc.
// ...
cart_manager_.clear_cart();
}
private:
ProductManager& product_manager_;
CartManager& cart_manager_;
};
class PersistenceManager {
public:
PersistenceManager(ProductManager& product_manager)
: product_manager_(product_manager) {}
void save_to_file(const std::string& file_name) {
// saving
}
void load_from_file(const std::string& file_name) {
// loading
}
private:
ProductManager& product_manager_;
};
最终,拥有新类并提供其功能代理方法的ECommerce
类:
// include the new classes
class ECommerce {
public:
void add_product(int id, const std::string& name, uint64_t price) {
product_manager_.add_product(id, name, price);
}
void remove_product(int id) {
product_manager_.remove_product(id);
}
void update_product(int id, const std::string& name, uint64_t price) {
product_manager_.update_product(id, name, price);
}
void list_products() {
product_manager_.list_products();
}
void add_to_cart(int product_id, int quantity) {
cart_manager_.add_to_cart(product_id, quantity);
}
void remove_from_cart(int product_id) {
cart_manager_.remove_from_cart(product_id);
}
void update_cart(int product_id, int quantity) {
cart_manager_.update_cart(product_id, quantity);
}
uint64_t calculate_cart_total() {
return order_manager_.calculate_cart_total();
}
void place_order() {
order_manager_.place_order();
}
void save_to_file(const std::string& filename) {
persistence_manager_.save_to_file(filename);
}
void load_from_file(const std::string& filename) {
persistence_manager_.load_from_file(filename);
}
private:
ProductManager product_manager_;
CartManager cart_manager_;
OrderManager order_manager_{product_manager_, cart_manager_};
PersistenceManager persistence_manager_{product_manager_};
};
int main() {
ECommerce e_commerce;
e_commerce.add_product(1, “Laptop”, 999.99);
e_commerce.add_product(2, “Smartphone”, 699.99);
e_commerce.add_product(3, “Headphones”, 99.99);
e_commerce.list_products();
e_commerce.add_to_cart(1, 1); // Add 1 Laptop to the cart
e_commerce.add_to_cart(3, 2); // Add 2 Headphones to the cart
uint64_t cart_total = e_commerce.calculate_cart_total();
std::cout << “Cart Total: $” << cart_total << std::endl;
e_commerce.place_order();
std::cout << “Order placed successfully!” << std::endl;
e_commerce.save_to_file(“products.txt”);
e_commerce.remove_product(1);
e_commerce.remove_product(2);
e_commerce.remove_product(3);
std::cout << “Loading products from file...” << std::endl;
e_commerce.load_from_file(“products.txt”);
e_commerce.list_products();
return 0;
}
通过将责任分配给多个较小的类,代码变得更加模块化,更容易维护,更适合实际应用。对其中一个子类内部业务逻辑的微小更改不需要更新ECommerce
类。在 C++中,由于臭名昭著的编译时间问题,这可能更为重要。单独测试这些类或完全替换其中一个类的实现(例如,将数据保存到远程存储而不是磁盘)更容易。
魔法数字的陷阱——关于数据分块的一个案例研究
让我们考虑以下 C++函数send
,该函数旨在将数据块发送到某个目的地。以下是函数的外观:
#include <cstddef>
#include <algorithm>
// Actually sending the data
void do_send(const std::uint8_t* data, size_t size);
void send(const std::uint8_t* data, size_t size) {
for (std::size_t position = 0; position < size;) {
std::size_t length = std::min(size_t{256}, size - position); // 256 is a magic number
do_send(data + position, position + length);
position += length;
}
}
代码做了什么?
send
函数接受一个指向std::uint8_t
数组的指针(data
)及其大小(size
)。然后它继续将此数据以块的形式发送到do_send
函数,该函数负责实际发送过程。每个块的最大大小为 256 字节,如send
函数中定义的那样。
为什么魔法数字有问题?
数字 256 直接嵌入到代码中,没有解释它代表什么。这是一个经典的魔法数字例子。任何阅读此代码的人都会猜测为什么选择 256。是硬件限制?协议约束?性能调整参数?
constexpr
解决方案
提高此代码清晰度的一种方法是将魔法数字替换为命名的constexpr
变量。例如,代码可以重写如下:
#include <cstddef>
#include <algorithm>
constexpr std::size_t MAX_DATA_TO_SEND = 256; // Named constant replaces magic number
// Actually sending the data
void do_send(const std::uint8_t* data, size_t size);
void send(const std::uint8_t* data, size_t size) {
for (std::size_t position = 0; position < size;) {
std::size_t length = std::min(MAX_DATA_TO_SEND, size - position); // Use the named constant
do_send(data + position, position + length);
position += length;
}
}
使用constexpr
的优点
将魔法数字替换为MAX_DATA_TO_SEND
使得理解这个限制的原因更加容易。此外,如果你有另一个函数,比如read
,它也需要以 256 字节的块读取数据,使用constexpr
变量可以确保一致性。如果块的大小需要更改,你只需在一个地方更新它,从而降低错误和不一致的风险。
当处理糟糕的代码时,理解这些问题的根本原因并应用正确的模式或避免反模式以有效地重构代码是至关重要的。例如,如果一个代码问题涉及重复的代码,应避免复制粘贴编程,而是应用模板方法或策略模式等模式以促进代码重用并减少重复。同样,如果一个代码问题涉及紧密耦合的模块或类,应应用适配器或依赖倒置原则等模式以减少耦合并提高模块化。
重要的是要记住,重构代码异味应该是一个迭代和逐步的过程。开发者应持续审查和评估他们的代码库中的异味,进行小而专注的更改,这些更改会逐渐提高代码的质量和可维护性。这种方法可以更好地进行风险管理,因为它最小化了在重构过程中引入新错误或问题的可能性。实现这一点的最佳方式是单元测试。它们有助于验证重构后的代码仍然满足其原始要求,并在修改其内部结构或组织后仍然按预期运行。在开始重构过程之前,拥有强大的测试集可以让开发者有信心他们的更改不会对应用程序的行为产生负面影响。这使他们能够专注于改进代码的设计、可读性和可维护性,而无需担心无意中破坏功能。我们将在第十三章中探讨单元测试。
总之,“代码异味”这个术语描述的是代码库中表明潜在设计或实现问题的症状。解决代码异味涉及识别和应用适当的设计模式,以及避免可能损害代码质量的反模式。通过理解代码异味的根本原因,并有效地使用模式和反模式,开发者可以将代码库重构得更加易于维护、可读,并能适应未来的变化。持续评估和逐步重构是防止代码异味并确保高质量、高效代码库能够适应不断变化的需求和需求的关键。
旧代码
重构旧版 C++代码是一项重大的任务,它有可能为老化的代码库注入新的活力。通常,旧代码是用 C++98 或 C++03 等旧的 C++方言编写的,这些方言没有利用 C++11、C++14、C++17 和 C++20 引入的新语言特性和标准库改进。
现代化中的一个常见领域是内存管理。传统的 C++代码通常使用原始指针来管理动态内存,这可能导致内存泄漏和空指针解引用等潜在问题。此类代码可以被重构为使用智能指针,例如std::unique_ptr
和std::shared_ptr
,这些智能指针会自动管理它们所指向的对象的生命周期,从而降低内存泄漏的风险。
另一个现代化机会在于采用 C++11 中引入的基于范围的for
循环。可以使用更简洁、更直观的基于范围的循环来替换旧的显式迭代器或索引变量的循环。这不仅使代码更容易阅读,还减少了出现偏移量错误和迭代器无效化错误的可能性。
如前几章所述,遗留的 C++代码库通常大量使用原始数组和 C 风格字符串。此类代码可以重构为使用std::array
、std::vector
和std::string
,这些更安全、更灵活,并提供有用的成员函数。
最后,现代 C++通过引入 C++11 中的std::thread
、std::async
和std::future
以及随后的标准中的进一步增强,在提高并发支持方面取得了重大进展。使用特定平台线程或较旧并发库的遗留代码可以从重构以使用这些现代、可移植的并发工具中受益。
让我们从使用pthread
创建新线程的遗留代码示例开始。此线程将执行一个简单的计算:
#include <pthread.h>
#include <iostream>
void* calculate(void* arg) {
int* result = new int(0);
for (int i = 0; i < 10000; ++i)
*result += i;
pthread_exit(result);
}
int main() {
pthread_t thread;
if (pthread_create(&thread, nullptr, calculate, nullptr)) {
std::cerr << “Error creating thread\n”;
return 1;
}
int* result = nullptr;
if (pthread_join(thread, (void**)&result)) {
std::cerr << “Error joining thread\n”;
return 2;
}
std::cout << “Result: “ << *result << ‘\n’;
delete result;
return 0;
}
现在,我们可以使用 C++11 中的std::async
重构此代码:
#include <future>
#include <iostream>
int calculate() {
int result = 0;
for (int i = 0; i < 10000; ++i)
result += i;
return result;
}
int main() {
std::future<int> future = std::async(std::launch::async, calculate);
try {
int result = future.get();
std::cout << “Result: “ << result << ‘\n’;
} catch (const std::exception& e) {
std::cerr << “Error: “ << e.what() << ‘\n’;
return 1;
}
return 0;
}
在重构版本中,我们使用std::async
启动一个新任务,并使用std::future::get
获取结果。计算函数直接返回一个int
类型的结果,这比在pthread
版本中分配内存要简单和安全得多。有几个需要注意的事项。对std::future::get
的调用会阻塞执行,直到异步操作完成。此外,示例使用std::launch::async
,这确保任务在单独的线程中启动。C++11 标准允许实现决定默认策略:单独的线程或延迟执行。在撰写本文时,Microsoft Visual C++、GCC 和 Clang 默认在单独的线程中运行任务。唯一的区别是,虽然 GCC 和 Clang 为每个任务创建一个新的线程,但 Microsoft Visual C++会重用内部线程池中的线程。错误处理也更简单,因为计算函数抛出的任何异常都将被std::future::get
捕获。
通常,遗留代码使用围绕pthread
和其他平台特定 API 的对象封装。用标准 C++实现替换它们可以减少开发者需要支持的代码量,并使代码更具可移植性。然而,多线程是一个复杂的话题,所以如果现有代码有一些丰富的线程相关逻辑,确保它保持完整是很重要的。
现代 C++提供的内置算法可以提高遗留代码的可读性和可维护性。通常,开发者需要检查数组是否包含某个特定值。C++11 之前的语言允许这样做:
#include <vector>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6};
bool has_even = false;
for (size_t i = 0; i < numbers.size(); ++i) {
if (numbers[i] % 2 == 0) {
has_even = true;
break;
}
}
if (has_even)
std::cout << “The vector contains an even number.\n”;
else
std::cout << “The vector does not contain any even numbers.\n”;
return 0;
}
使用 C++11,我们可以使用std::any_of
,这是一种新算法,用于检查范围中的任何元素是否满足谓词。这允许我们编写更简洁、更具表现力的代码:
#include <vector>
#include <algorithm>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6};
bool has_even = std::any_of(numbers.begin(), numbers.end(),
[](int n) { return n % 2 == 0; });
if (has_even)
std::cout << “The vector contains an even number.\n”;
else
std::cout << “The vector does not contain any even numbers.\n”;
return 0;
}
在这个重构版本中,我们使用 lambda 函数作为std::any_of
的谓词。这使得代码更简洁,意图更清晰。算法如cpp std::all_of``
和std::none_of
允许清晰地表达类似的检查
记住,重构应该逐步进行,每次更改都要彻底测试,以确保不会引入新的错误或回归。这可能是一个耗时的过程,但就提高代码质量、可维护性和性能而言,其好处可能是巨大的。
摘要
在本章中,我们探讨了可以在重构遗留 C++代码中发挥关键作用的一些关键设计模式,包括策略模式、模板方法模式和观察者模式。当谨慎应用时,这些模式可以显著改善代码的结构,使其更加灵活、可维护和适应变化。
尽管我们已经提供了实用的、现实世界的例子来展示这些模式的使用,但这绝对不是一种详尽无遗的处理方式。设计模式是一个庞大而深奥的主题,还有许多更多的模式和变体需要探索。为了更全面地理解设计模式,我强烈推荐您深入研究 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 所著的奠基性作品《设计模式:可复用面向对象软件元素》,这本书通常被称为四人帮书。
此外,为了跟上最新的发展和新兴的最佳实践,可以考虑像 Fedor G. Pikus 所著的《动手学设计模式:使用现代设计模式解决常见的 C++问题并构建健壮的应用程序》和 Anthony Williams 所著的《C++并发实战》这样的资源。这些作品将为您提供更广阔的视角和更深入的理解,了解设计模式在构建高质量 C++软件中扮演的强大角色。
记住,重构和应用设计模式的目标不仅仅是编写出能工作的代码,而是编写出干净、易于理解、易于修改和长期易于维护的代码。
在接下来的章节中,我们将更深入地探讨 C++的世界,特别是关注命名约定、它们在编写干净和可维护的代码中的重要性以及社区建立的最佳实践。
第五章:命名的意义
随着你深入探索 C++ 的世界,或者任何其他编程语言,一个越来越清晰的事实是——名称的力量。在本章中,我们将探讨命名约定在编写干净、可维护和高效的 C++ 代码中的深远重要性。
在计算机编程中,名称被赋予变量、函数、类以及众多其他实体。这些名称作为标识符,在我们作为程序员与代码组件交互中扮演着关键角色。虽然对一些人来说这可能是一件微不足道的事情,但选择正确的名称可以对软件项目的可理解性和可维护性产生深远的影响。我们选择的名称来表示程序的不同元素是我们代码的第一层文档,包括我们未来的自己,当他们接近我们的代码时。
想象一个名叫 Mia 的开发者,她使用一个名为 WeatherData
的类。这个类有两个获取方法——get_temperature()
和 get_humidity()
。前者方法只是简单地返回存储在成员变量中的当前温度值。这是一个 O(1) 操作,因为它只涉及返回一个已存储的值。后者不仅仅返回一个值。它实际上启动了一个与远程天气服务的连接,检索最新的湿度数据,然后返回它。这个操作相当昂贵,涉及到网络通信和数据处理,远非 O(1) 操作。Mia 专注于优化项目中的一个函数,看到这两个获取方法,并假设它们在效率上相似,因为它们的命名。她在循环中使用 get_humidity()
,期望它是一个简单的、高效的存储值检索,类似于 get_temperature()
。由于重复调用 get_humidity()
,函数的性能急剧下降。每次调用中涉及的网络请求和数据处理显著减慢了执行速度,导致资源使用效率低下,并减缓了应用程序的性能。如果方法被命名为 fetch_humidity()
而不是 get_humidity()
,这种情况本可以避免。fetch_humidity()
这个名称会清楚地表明该方法不是一个简单的获取器,而是一个更昂贵的操作,它涉及到从远程服务获取数据。
命名的艺术需要仔细的考虑和对问题域以及编程语言的深入了解。本章提供了对创建和命名变量、类成员、方法和函数在 C++ 中的通用方法的全面讨论。我们将辩论长名称与短名称之间的权衡,以及注释在阐明我们的意图中的作用。
我们将探讨编码约定的重要性以及它们为个人开发者和团队带来的好处。一致地应用经过深思熟虑的命名约定可以简化编码过程,减少错误,并极大地提高代码库的可读性。
到本章结束时,你将理解良好的命名习惯不仅是一个事后考虑,而且是良好软件开发的一个基本组成部分。我们将为你提供策略和约定,帮助你编写其他人(以及你自己,当你几个月或几年后再次查看自己的代码时)都能轻松阅读、理解和维护的代码。
命名的一般原则
无论你使用的是哪种具体的面向对象编程(OOP)语言,某些通用的命名原则可以帮助提高代码的清晰性和可维护性。这些原则旨在确保代码中的名称提供了足够的信息关于它们的使用和功能。
描述性
名称应准确描述变量、函数、类或方法的目的或值。例如,对于函数 getSalary()
比简单地 getS()
更有信息量。
一致性
在命名约定上的一致性是编写清晰且可维护代码的最重要原则之一。当你在整个代码库中保持命名的一致性时,阅读、理解和调试你的代码会变得容易得多。原因在于一旦开发者学会了你的命名模式,他们就可以在整个代码库中应用他们的理解,而无需单独弄清楚每个名称的含义。
一致性适用于许多领域,包括以下内容:
-
使用
snake_case
(例如,employee_salary
),在整个代码库中坚持这种风格。不要在 snake_case、camelCase(例如,employeeSalary
)和 PascalCase(例如,EmployeeSalary
)之间切换。 -
m_
用于成员变量(例如,m_value
),确保在所有地方遵循此规则。 -
使用
num
来表示number
(例如,numEmployees
),然后始终在表示number
时使用num
。 -
类名(例如,
Employee
),方法名是动词(calculateSalary
),布尔变量或方法通常以is
、has
、can
或类似的词缀开头(例如isAvailable
和hasCompleted
)。始终一致地遵循这些约定。
假设你正在处理一个大型代码库,其中类代表公司中的各种员工类型。你已经决定使用 PascalCase 为类命名,使用 snake_case 为方法命名,以及使用 snake_case 为变量命名。
这种命名约定的一个一致实现可能看起来像这样:
class SoftwareEngineer {
public:
void assign_task(std::string task_name) {
current_task_ = std::move(task_name);
}
private:
std::string current_task_;
};
让我们分解这个代码片段:
-
SoftwareEngineer
类是一个单数名词,并使用 PascalCase -
assign_task
方法是一个动词,并使用 snake_case -
变量
current_task
使用 snake_case
与此约定保持一致将有助于任何阅读你代码的人立即识别每个名称所代表的内容。这样,认知负担就会减轻,开发者可以专注于实际的逻辑,而不是被不一致或混淆的名称所分散注意力。
明确性
明确性意味着名称不应具有误导性。避免使用可能被解释为多种方式或与既定约定或期望相矛盾的名字。例如,假设你有一个Document
类和一个名为process
的方法。在没有更多上下文的情况下,该方法名称是模糊的:
class Document {
public:
void process();
};
在这种情况下,process
可能意味着许多事情。我们是解析文档吗?我们要渲染它吗?我们要将其保存到文件中吗?还是我们要执行所有这些操作?这并不明确。
一个更具体的方法名称可以帮助阐明其目的。根据该方法预期要执行的操作,它可以命名为parse
、render
、save
等:
class Document {
public:
void parse(const std::string& content);
void render();
void save(const std::string& file_path);
};
这些方法名称的每个都提供了对方法所做工作的更清晰指示,消除了原始process
方法名称的歧义。
发音性
名称应该易于发音。这有助于开发者之间就代码进行口头交流。
范围和生命周期
范围更大、生命周期更长的变量通常对系统有更大的影响,因此需要更深思熟虑、清晰和描述性的名称。这有助于确保它们在所有使用它们的上下文中都能被理解。以下是一个更详细的分解。
全局变量可以在程序的任何地方访问,并且其生命周期持续整个程序。因此,在命名时需要特别小心。名称应该足够描述性,以清楚地表明其在系统中的作用。此外,全局变量可能会创建意外的依赖关系,这使得程序更难以理解和维护。因此,应尽量减少全局变量的使用:
// Global variable
constexpr double GRAVITATIONAL_ACCELERATION = 9.8; // Clear and descriptive
类成员变量可以从类中的任何方法访问,并且它们的生命周期与类实例的生命周期绑定。它们应该有清晰且描述性的名称,反映其在类中的角色。通常,遵循一个命名约定来区分它们与局部变量是有用的(例如,使用m_
前缀或_
后缀):
class PhysicsObject {
double mass_; // Descriptive and follows naming convention
// ...
};
局部变量仅限于特定的函数或代码块,并且只存在于该函数或代码块执行期间。与全局变量或类成员变量相比,这些变量通常需要更简洁的命名,但它们仍然应该清楚地传达其用途:
double compute_force(double mass, double acceleration) {
double force = mass * acceleration; // 'force' is clear in this context
return force;
}
循环变量和临时变量具有最短的范围和生命周期,通常局限于一个小循环或一小段代码。因此,它们通常具有最简单的名称(如i
、j
和temp
):
for (int i = 0; i < num; ++i) { // 'i' is clear in this context
// ...
}
这里的关键思想是,变量的作用域越广,生命周期越长,对其用途的混淆可能性就越大,因此其名称应该越具有描述性。目标是使代码尽可能清晰易懂。
避免编码类型或作用域信息
在现代编程语言中,将类型或作用域信息编码到名称中(通常称为匈牙利符号法)通常是多余的,并且可能导致混淆或错误,尤其是在重构时。虽然这偶尔可能有所帮助,尤其是在弱类型语言中,但它有几个缺点,使得它不太适合用于像 C++这样的强类型语言:
-
变量的类型可能会在未来改变,但它的名字通常不会。这导致了一些误导性的情况,其中变量的名字暗示了一种类型,但实际上它具有另一种类型。例如,你可能从一个 ID 向量(
std::vector<Id> id_array
)开始,后来将其更改为set<Id>
以避免重复,但变量名仍然暗示它是一个数组或向量。 -
现代开发环境提供了诸如类型推断、显示类型的悬停工具提示和强大的重构工具等功能,这些都使得手动将类型编码到名称中变得几乎不再必要。例如,安装了 clangd 插件并开启了“内联提示”功能的 VS Code 会动态推断类型,包括
auto
:
图 5.1 – VS Code 中的内联提示
这也适用于 JetBrains 的 CLion:
-
匈牙利符号法中的前缀可能会使变量名更难以阅读,尤其是对于那些不熟悉这种符号的人来说。对于新开发者来说,
dwCount
(一个DWORD
,或双字,通常用来表示无符号长整数)的含义可能并不立即明显。 -
强类型语言,如 C++,已经在编译时检查类型安全,减少了在变量名中编码类型信息的需要。在下面的示例中,
integers
被声明为std::vector<int>
,而sentence
被声明为std::string
。C++ 编译器知道这些类型,并将确保对这些变量的操作是类型安全的:
#include <vector>
#include <string>
int main() {
std::vector<int> integers;
std::string sentence;
// The following will cause a compile-time error because
// the type of 'sentence' is string, not vector<int>.
integers = sentence;
return 0;
}
当代码尝试将 sentence
赋值给 integers
时,会产生编译时错误,因为 sentence
的类型不正确(std::vector<int>
)。尽管这两个变量名都没有编码类型信息,这种情况仍然会发生。
编译器的类型检查消除了在变量名中包含类型信息(如 strSentence
或 vecIntegers
)的需要,这在没有执行此类强编译时类型检查的语言中是一种常见的做法。integers
和 sentence
变量名已经足够描述性,无需编码类型信息。
在编程中,你经常会遇到多个逻辑概念使用相同的底层类型来表示的情况。例如,在你的系统中,你可能既有Users
的标识符,也有Products
的标识符,它们都表示为整数。虽然 C++的静态类型检查提供了一定程度的安全性,但它不会区分UserId
和ProductId
——对编译器来说,它们只是整数。
然而,使用相同的类型来表示这些不同的概念可能会导致错误。例如,错误地传递UserId
而不是预期的ProductId
是完全可能的,编译器不会捕获这个错误。
为了解决这个问题,你可以利用 C++丰富的类型系统引入代表这些不同概念的新类型,即使它们具有相同的底层表示。这样,编译器可以在编译时捕获这些错误,增强你软件的健壮性:
// Define new types for User and Product IDs.
struct UserId {
explicit UserId(int id): value(id) {}
int value;
};
struct ProductId {
explicit ProductId(int id): value(id) {}
int value;
};
void process_user(UserId id) {
// Processing user...
}
void process_product(ProductId id) {
// Processing product...
}
int main() {
UserId user_id(1);
ProductId product_id(2);
// The following line would cause a compile-time error because
// a ProductId is being passed to process_user.
process_user(product_id);
return 0;
}
在前面的例子中,UserId
和ProductId
是不同的类型。尽管它们的底层表示相同(int
),但将ProductId
传递给期望UserId
的函数会导致编译时错误。这为你代码增加了额外的类型安全性。
这只是展示了如何利用 C++丰富的静态类型系统来创建更健壮和更安全的代码。我们将在第六章“在 C++中利用丰富的静态类型系统”中更详细地探讨这个话题。
类和方法命名
在面向对象的语言中,类代表概念或事物,它们的实例(对象)是这些事物的具体表现。因此,类名及其实例最恰当地使用名词或名词短语来命名。它们代表系统中的实体,无论是有形的(如Employee
和Invoice
)还是概念性的(如Transaction
和DatabaseConnection
)。
另一方面,类中的方法通常代表该类对象可以执行的动作,或者可以发送给它的消息。因此,它们最有效地使用动词或动词短语来命名。它们作为可以被对象执行的操作指令,允许它以有意义的方式与其他对象进行交互。
考虑一个具有print
方法的Document
类。我们可以说“document, print”或“print the document”,这是一个符合我们日常语言中传达动作方式的清晰、祈使性陈述。
下面是一个例子:
class Document {
public:
void print();
};
Document report;
report.print(); // "report, print!"
在命名类和方法时,名词-动词的一致性与我们自然理解和交流现实世界中的对象和动作的方式非常吻合,有助于提高我们代码的可读性和可理解性。此外,它很好地符合面向对象中的封装原则,其中对象管理自己的行为(方法)和状态(成员变量)。
维持这个约定可以让开发者编写更直观、自文档化和易于维护的代码。它为开发者之间创造了一种共同的语言和理解,减少了阅读代码时的认知负荷,并使代码库更容易导航和推理。因此,在面向对象编程中,建议遵守这些约定。
变量命名
变量名应该反映它们所持有的数据。一个好的变量名描述了变量包含的值的类型,而不仅仅是它在算法中的作用。
避免魔法数字,源代码中具有未解释含义的数值。它们可能导致难以阅读、理解和维护的代码。让我们考虑一个MessageSender
类,它发送消息,如果消息大小超过某个限制,它将消息分割成块:
class MessageSender {
public:
void send_message(const std::string& message) {
if (message.size() > 1024) {
// Split the message into chunks and send
} else {
// Send the message
}
}
};
在前面的代码中,1024
是一个魔法数字。它可能代表一个最大消息大小,但并不立即清楚。它可能会使阅读你代码的人(或未来的你)感到困惑。以下是一个使用命名常量的重构示例:
class MessageSender {
constexpr size_t MAX_MESSAGE_SIZE = 1024;
public:
void send_message(const std::string& message) {
if (message.size() > MAX_MESSAGE_SIZE) {
// Split the message into chunks and send
} else {
// Send the message
}
}
};
在这个重构版本中,我们将魔法数字1024
替换为命名常量MAX_MESSAGE_SIZE
。现在很清楚,1024
是最大消息大小。以这种方式使用命名常量使你的代码更易于阅读和维护。如果将来需要更改最大消息大小,你只需在一个地方更新即可。
利用命名空间
C++中的命名空间在防止命名冲突和正确组织代码方面极其宝贵。命名冲突,或称碰撞,发生在程序中的两个或多个标识符具有相同名称时。例如,你可能在应用程序的两个子系统(网络表示连接 ID 和用户管理中的用户 ID)中都有一个名为Id
的类。如果不使用命名空间使用它们,就会导致命名冲突,编译器将不知道在代码中你指的是哪个Id
。
为了缓解这个问题,C++提供了namespace
关键字来封装一个具有独特名称的功能。命名空间旨在解决名称冲突的问题。通过将你的代码包裹在一个命名空间内,你可以防止它与代码其他部分或第三方库中具有相同名称的标识符发生冲突。
下面是一个示例:
namespace product_name {
class Router {
// class implementation
};
}
// To use it elsewhere in the code
product_name::Router myRouter;
在这种情况下,product_name::Router
不会与你的产品代码或第三方库中的任何其他Router
类冲突。如果你开发库代码,强烈建议将所有实体(如类、函数和变量)都包裹在一个命名空间中。这将防止与其他库或用户代码发生名称冲突。
在 C++中,将项目的目录结构映射到命名空间中是很常见的,这使得理解代码库的不同部分所在的位置变得更容易。例如,如果你有一个位于ProductRepo/Networking/Router.cpp
路径的文件,你可能会这样声明Router
类:
namespace product_name {
namespace networking {
class Router {
// class implementation
};
}
}
然后,您可以使用完全限定名称 product_name::networking::Router
来引用该类。
然而,值得注意的是,直到 C++20 之前,该语言并没有原生支持一个可以替代或增强命名空间提供的功能的模块系统。随着 C++20 中模块的到来,一些实践可能会发生变化,但理解命名空间及其在命名中的使用仍然至关重要。
使用命名空间的另一种方式是表达代码的复杂程度。例如,库代码可能包含预期由库消费者使用的实体和内部实体。以下代码片段展示了这种方法:
// communication/client.hpp
namespace communication {
class Client {
public:
// public high-level methods
private:
using HttpClient = communication::advanced::HttpClient;
HttpClient inner_client_;
};
} // namespace communication
// communication/http/client.hpp
namespace communication::advanced::http {
class Client {
// Lower-level implementation
};
} // namespace communication::advanced
在这个扩展示例中,communication::Client
类提供了一个用于发送和接收消息的高级接口。它使用 advanced::http::Client
类进行实际实现,但这个细节对库的用户来说是隐藏的。除非他们对默认客户端提供的功能不满意并需要更多控制,否则他们不需要了解高级类。
在 communication::http::advanced
命名空间中的 Client
类提供了更多低级功能,使用户能够更多地控制通信的细节。
这种组织方式清楚地说明了大多数用户(Client
)期望的功能以及为更高级使用(HttpClient
)提供的功能。以这种方式使用命名空间也有助于避免名称冲突并保持代码库井然有序。这种方法被许多库和框架所采用——例如,Boost 库通常有一个 detail
命名空间用于内部实现。
使用特定领域的语言
如果问题域中有公认的术语,请在您的代码中使用它们。这可以使熟悉该领域的人更容易理解您的代码。例如,在金融领域,术语“投资组合”、“资产”、“债券”、“股票”、“股票代码”和“股息”是常用的。如果您正在编写与金融相关的应用程序,使用这些术语作为类和变量名称是有益的,因为它们清楚地传达了它们在金融背景下的角色。
考虑以下代码片段:
class Portfolio {
public:
void add_asset(std::unique_ptr<Asset> asset) {
// add the asset to the portfolio
}
double total_dividend() const {
// calculate the total dividends of the portfolio
}
private:
std::vector<std::unique_ptr<Asset>> assets_;
};
using Ticker = std::string;
class Asset {
public:
Asset(const Ticker& ticker, int64_t quantity) :
ticker_{ticker},
quantity_{quantity} {}
virtual Asset() = default;
virtual double total_dividend() const = 0;
auto& ticker() const { return ticker_; }
int64_t quantity() const { return quantity_; }
private:
Ticker ticker_;
int64_t quantity_;
};
class Bond : public Asset {
public:
Bond(const Ticker& ticker, int64_t quantity) :
Asset{ticker, quantity} {}
double total_dividend() const override {
// calculate bond dividend
}
};
class Equity : public Asset {
public:
Equity(const Ticker& ticker, int64_t quantity) :
Asset{ticker, quantity} {}
double total_dividend() const override {
// calculate equity dividend
}
};
在这个示例中,Portfolio
、Asset
、Bond
、Equity
、Ticker
和 total_dividend()
都是直接从金融领域借用的术语。熟悉金融的开发者或利益相关者只需通过它们的名称就能理解这些类和方法的目的。这有助于在开发者、利益相关者和领域专家之间建立共同语言,从而极大地促进沟通和理解。请注意,在现实世界的金融应用中不推荐使用 double
,因为它不足以精确表示货币值进行算术运算时防止舍入误差累积。
记住,这些原则的目标是使代码尽可能清晰易懂。编写代码不仅仅是与计算机交流;它也是与其他开发者交流,包括你未来的自己。
在代码中平衡长名称和注释
合理的命名规范在代码的清晰性和可读性中起着至关重要的作用。类、方法和变量的名称应该足够描述性,以便传达其目的和功能。理想情况下,一个精心挑选的名称可以替代额外的注释,使代码易于理解。
然而,需要找到一个微妙的平衡点。虽然长而描述性的名称可能有所帮助,但过长的名称也可能变得繁琐,并降低代码的可读性。另一方面,过短的名称可能含糊不清,并使代码更难以理解。关键是找到正确的平衡——名称应该足够长以传达其目的,但又不至于过长而难以驾驭。
考虑以下一个假设的网络应用程序的例子:
class Router {
public:
void route(const Message& message, Id receiver) {
auto message_content = message.get_content();
// Code to route the 'message_content' to the appropriate 'receiver'
}
private:
// Router's private members
};
在这种情况下,route
方法名称以及 message
、receiver
和 message_content
变量名称都足够描述性,可以理解方法的作用以及每个变量的含义。不需要额外的注释来解释它们的作用。
话虽如此,有些情况下,语言结构无法完全表达代码的意图或细微差别,例如当依赖于第三方库的特定行为或编写复杂算法时。在这些情况下,需要额外的注释来提供上下文或解释为什么做出了某些决定。
以此为例:
void route(const Message& message, Id receiver) {
auto message_content = message.get_content();
// Note: The routing_library has an idiosyncratic behavior where
// it treats receiver id as one-indexed. Hence we need to increment by 1.
receiver++;
// Code to route the 'message_content' to the appropriate 'receiver'
}
在这种情况下,注释是必要的,以突出第三方路由库的特定行为,这些行为仅从语言结构本身来看并不立即明显。
作为一般规则,应努力通过良好的命名实践使代码尽可能具有自解释性,但在需要提供重要上下文或阐明复杂逻辑时,不要犹豫使用注释。记住,最终目标是创建易于阅读、理解和维护的代码。
探索流行的 C++编码规范——谷歌、LLVM 和 Mozilla
在 C++编程领域,遵循一致的编码规范对于确保代码清晰性和可维护性至关重要。在众多可用的风格中,三种突出的规范因其广泛的使用和独特的做法而脱颖而出——谷歌的 C++风格指南、LLVM 编码标准和 Mozilla 的编码风格。本概述深入探讨了每个规范的关键方面,突出了它们的独特实践和哲学:
-
.cc
和.h
扩展名分别用于实现文件和头文件 -
kCamelCase
-
CamelCase
用于类名 -
*
或&
与变量名一起使用(int* ptr
,而不是int *ptr
) -
限制:避免使用非 const 全局变量,并在可能的情况下优先使用算法而不是循环
-
.cpp
扩展名,以及头文件使用.h
。*camelBack
风格。成员变量有一个尾随下划线。*CamelCase
。**
或&
靠近类型(int *ptr
,而不是int* ptr
)。* 现代 C++用法:鼓励使用现代 C++特性和模式。*.cpp
和.h
扩展名* 变量和函数使用camelCase
,类使用CamelCase
,常量使用SCREAMING_SNAKE_CASE
。* 类名使用CamelCase
。**
或&
靠近类型* 强调性能:鼓励编写注重浏览器性能的效率代码
每个这些约定都有其自身的哲学和理由。谷歌的风格指南强调在庞大的代码库和众多开发者之间保持一致性。LLVM 的标准侧重于编写干净、高效的代码,利用现代 C++特性。Mozilla 的风格在可读性和性能之间取得平衡,反映了其在网络技术发展中的起源。选择与你的项目目标、团队规模以及你工作的具体技术相一致的风格是很重要的。
摘要
在本章中,我们探讨了命名在编程中的关键作用。我们认识到,良好的、一致的命名实践可以提高代码的可读性和可维护性,同时也有助于其自我文档化。
我们思考了使用长描述性名称和较短名称并辅以注释之间的平衡,理解到在不同的上下文中两者都有其位置。建议在命名中使用领域特定语言以提高清晰度,同时由于它们的不可透明性而警告不要使用“魔法数字”。
变量的作用域和生命周期对其命名的影响也得到了讨论,强调了为那些作用域较大、生命周期较长的变量使用更具描述性的名称的必要性。
本章以强调遵循命名编码规范的价值结束,这可以在代码库中建立一致性,从而简化代码的阅读和理解过程。
从本章中获得的认识为即将讨论如何有效地利用 C++丰富的静态类型系统以编写更安全、更干净、更清晰的代码奠定了基础。在下一章中,我们将把重点转移到有效地利用 C++丰富的静态类型系统上。
第六章:在 C++ 中利用丰富的静态类型系统
在现代软件开发中,“类型”的概念已经超越了其原始定义,演变成一种丰富且富有表现力的语言特性,它不仅封装了数据表示,还包含了更多内容。在以性能和灵活性著称的 C++ 语言中,静态类型系统是一个强大的工具,它使开发者能够编写不仅健壮且高效的代码,而且具有自文档化和可维护性的代码。
类型在 C++ 中的重要性不仅限于对数据的分类。通过强制执行严格的编译时检查,语言的类型系统减少了运行时错误,提高了可读性,并促进了代码的直观理解。随着现代 C++ 标准的出现,利用类型的机会进一步扩大,为常见的编程挑战提供了优雅的解决方案。
然而,这些强大的功能往往没有得到充分利用。原始数据类型,如整数,经常被错误地用来表示时间长度等概念,导致代码缺乏表现力且容易出错。指针虽然灵活,但可能导致空指针解引用问题,使代码库变得脆弱。
在本章中,我们将探讨 C++ 静态类型系统的丰富景观,重点关注帮助减轻这些问题的先进和现代技术。从使用 <chrono>
库来表示时间长度到使用 not_null
包装器和 std::optional
进行更安全的指针处理,我们将深入研究体现强类型本质的实践。
我们还将探讨如 Boost 这样的外部库,它们提供了额外的实用工具来增强类型安全性。在整个章节中,现实世界的例子将说明这些工具和技术如何无缝集成到你的代码中,赋予你充分利用 C++ 类型系统的全部潜力。
到本章结束时,你将深入理解如何利用类型来编写更健壮、可读性和表现力更强的代码,挖掘 C++ 真正的潜力。
利用 Chrono 进行时间长度
C++ 类型系统如何被利用来编写更健壮的代码的最好例子之一是 <chrono>
库。自 C++11 引入以来,这个头文件提供了一套表示时间长度和时间点的实用工具,以及执行时间相关操作。
使用普通整数或如 timespec
这样的结构来管理时间相关函数可能是一种容易出错的途径,尤其是在处理不同时间单位时。想象一个接受表示秒数的整数的函数:
void wait_for_data(int timeout_seconds) {
sleep(timeout_seconds); // Sleeps for timeout_seconds seconds
}
这种方法缺乏灵活性,在处理各种时间单位时可能会导致混淆。例如,如果调用者错误地传递了毫秒而不是秒,可能会导致意外的行为。
相比之下,使用 <chrono>
定义相同的函数使代码更加健壮和具有表现力:
#include <chrono>
#include <thread>
void wait_for_data(std::chrono::seconds timeout) {
std::this_thread::sleep_for(timeout); // Sleeps for the specified timeout
}
调用者现在可以使用强类型持续时间传递超时,例如 std::chrono::seconds(5)
,编译器确保使用正确的单位。此外,<chrono>
提供了不同时间单位之间的无缝转换,允许调用者以秒、毫秒或其他任何单位指定超时,而不存在歧义。以下代码片段展示了使用不同单位的用法:
wait_for_data(std::chrono::milliseconds(150));
通过采用 <chrono>
提供的强类型,代码变得更加清晰、易于维护,并且不太可能受到与时间表示相关的常见错误的影响。
使用 not_null
和 std::optional
提高指针安全性
在 C++ 中,指针是语言的基本组成部分,允许直接访问和操作内存。然而,指针提供的灵活性也伴随着一定的风险和挑战。在这里,我们将探讨现代 C++ 技术如何增强指针安全性。
原始指针的陷阱
原始指针虽然强大,但可能是一把双刃剑。它们不会提供关于它们指向的对象所有权的任何信息,并且它们很容易成为“悬空”指针,指向已经被释放的内存。取消引用空指针或悬空指针会导致未定义的行为,这可能导致难以诊断的错误。
使用指南支持库中的 not_null
由 not_null
提供的包装器可以清楚地表明指针不应为空:
#include <gsl/gsl>
void process_data(gsl::not_null<int*> data) {
// Data is guaranteed not to be null here
}
如果用户以如下方式将空指针传递给此函数,应用程序将被终止:
int main() {
int* p = nullptr;
process_data(p); // this will terminate the program
return 0;
}
然而,如果指针作为 process_data(nullptr)
传递,应用程序将在编译时失败:
source>: In function 'int main()':
<source>:9:16: error: use of deleted function 'gsl::not_null<T>::not_null(std::nullptr_t) [with T = int*; std::nullptr_t = std::nullptr_t]'
9 | process_data(nullptr);
| ~~~~~~~~~~~^~~~~~~~~
In file included from <source>:1:
/opt/compiler-explorer/libs/GSL/trunk/include/gsl/pointers:131:5: note: declared here
131 | not_null(std::nullptr_t) = delete;
| ^~~~~~~~
这通过在早期捕获潜在的空指针错误来促进代码的健壮性,从而减少运行时错误。
将 not_null
扩展到智能指针
gsl::not_null
不仅限于原始指针;它还可以与智能指针如 std::unique_ptr
和 std::shared_ptr
结合使用。这允许你结合现代内存管理的优点以及 not_null
提供的额外安全保证。
使用 std::unique_ptr
std::unique_ptr
确保动态分配的对象的所有权是唯一的,并且当不再需要对象时,它会自动删除该对象。通过使用 not_null
与 unique_ptr
结合,你也可以确保指针永远不会为空:
#include <gsl/gsl>
#include <memory>
void process_data(gsl::not_null<std::unique_ptr<int>> data) {
// Data is guaranteed not to be null here
}
int main() {
auto data = std::make_unique<int>(42);
process_data(std::move(data)); // Safely passed to the function
}
使用 std::shared_ptr
类似地,gsl::not_null
可以与 std::shared_ptr
结合使用,这使对象具有共享所有权。这允许你编写接受共享指针的函数,而无需担心空指针:
#include <gsl/gsl>
#include <memory>
void process_data(gsl::not_null<std::shared_ptr<int>> data) {
// Data is guaranteed not to be null here
}
int main() {
auto data = std::make_shared<int>(42);
process_data(data); // Safely passed to the function
}
这些示例展示了 not_null
如何无缝地与现代 C++ 内存管理技术集成。通过强制指针(无论是原始指针还是智能指针)不能为空,你进一步减少了运行时错误的可能性,并使代码更加健壮和易于表达。
利用 std::optional
处理可选值
有时,指针用于表示可选值,其中nullptr
表示值的缺失。C++17 引入了std::optional
,它提供了一种类型安全地表示可选值的方法:
#include <optional>
std::optional<int> fetch_data() {
if (/* some condition */)
return 42;
else
return std::nullopt;
}
使用std::optional
提供清晰的语义并避免使用指针进行此目的时的陷阱。
原始指针与 nullptr 的比较
not_null
和std::optional
都优于原始指针。虽然原始指针可以是 null 或悬空,导致未定义行为,但not_null
在编译时防止 null 指针错误,而std::optional
提供了一种清晰地表示可选值的方法。
考虑以下使用原始指针的示例:
int* findValue() {
// ...
return nullptr; // No value found
}
这段代码可能会导致混淆和错误,尤其是如果调用者忘记检查nullptr
。通过使用not_null
和std::optional
,可以使代码更具表达性且更不易出错。
利用 std::expected 获取预期结果和错误
虽然std::optional
优雅地表示了可选值,但有时你需要传达更多关于值可能缺失的原因的信息。在这种情况下,std::expected
提供了一种返回值或错误代码的方法,使代码更具表达性,错误处理更健壮。
考虑一个场景,你有一个从网络获取值的函数,并且你想处理网络错误。你可能为各种网络错误定义一个枚举:
enum class NetworkError {
Timeout,
ConnectionLost,
UnknownError
};
然后,你可以使用std::expected
定义一个返回int
值或NetworkError
的函数:
#include <expected>
#include <iostream>
std::expected<int, NetworkError> fetch_data_from_network() {
// Simulating network operation...
if (/* network timeout */) {
return std::unexpected(NetworkError::Timeout);
}
if (/* connection lost */) {
return std::unexpected(NetworkError::ConnectionLost);
}
return 42; // Successfully retrieved value
}
int main() {
auto result = fetch_data_from_network();
if (result) {
std::cout << "Value retrieved: " << *result << '\n';
} else {
std::cout << "Network error: ";
switch(result.error()) {
case NetworkError::Timeout:
std::cout << "Timeout\n";
break;
case NetworkError::ConnectionLost:
std::cout << "Connection Lost\n";
break;
case NetworkError::UnknownError:
std::cout << "Unknown Error\n";
break;
}
}
}
在这里,std::expected
同时捕获了成功情况和各种错误场景,允许进行清晰且类型安全的错误处理。这个例子说明了现代 C++类型如std::expected
如何增强表达性和安全性,使你能够编写更精确地模拟复杂操作的代码。
通过采用这些现代 C++工具,你可以增强代码中的指针安全性,减少错误并使你的意图更清晰。
使用 enum class 和范围枚举的强类型
强类型是健壮、可维护的软件的基石,C++提供了多种机制来促进其实现。在这些机制中,C++11 中引入的enum class
是一个特别有效的工具,用于创建强类型枚举,可以使你的程序更健壮且更容易理解。
对 enum class 的回顾
C++中的传统枚举存在一些限制——它们可以隐式转换为整数,如果误用可能会导致错误,并且它们的枚举符被引入到封装作用域中,导致名称冲突。enum class
,也称为范围枚举,解决了这些限制:
// Traditional enum
enum ColorOld { RED, GREEN, BLUE };
int color = RED; // Implicit conversion to int
// Scoped enum (enum class)
enum class Color { Red, Green, Blue };
// int anotherColor = Color::Red; // Compilation error: no implicit conversion
相比传统枚举的优势
范围枚举提供了一些优势:
-
enum class
类型和整数,确保你不会意外地将枚举符用作整数 -
enum class
,减少了名称冲突的可能性 -
enum class
允许你显式指定底层类型,从而让你对数据表示有精确的控制:enum class StatusCode : uint8_t { Ok, Error, Pending };
能够指定底层类型对于将数据序列化为二进制格式特别有用。它确保你可以在字节级别对数据的表示有精细的控制,从而便于与可能具有特定二进制格式要求的系统进行数据交换。
实际应用场景
enum class
的优点使其成为各种场景中的强大工具:
-
enum class
提供了一种类型安全、具有表现力的方式来表示各种可能的状态 -
选项集:许多函数有多种行为选项,可以使用范围枚举清晰地和安全地封装
-
enum class
:enum class NetworkStatus { Connected, Disconnected, Error };
NetworkStatus check_connection() {
// Implementation
}
通过使用 enum class
创建强类型、范围枚举,你可以编写不仅更容易理解而且更不容易出错的代码。这一特性代表了 C++持续向结合高性能与现代编程便利性的语言发展的又一步。无论你是定义复杂的有限状态机还是仅仅尝试表示多个选项或状态,enum class
都提供了一个健壮、类型安全的解决方案。
利用标准库的类型实用工具
现代 C++在标准库中提供了一套丰富的类型实用工具,使开发者能够编写更具有表现力、类型安全和可维护的代码。两个突出的例子是 std::variant
和 std::any
。
std::variant – 一个类型安全的联合体
std::variant
提供了一种类型安全的方式来表示一个值,它可以属于几种可能类型中的一种。与允许程序员将存储的值视为其成员类型之一的传统 union
不同,这可能导致潜在的不确定行为,std::variant
跟踪当前持有的类型并确保适当的处理:
#include <variant>
#include <iostream>
std::variant<int, double, std::string> value = 42;
// Using std::get with an index:
int intValue = std::get<0>(value); // Retrieves the int value
// Using std::get with a type:
try {
double doubleValue = std::get<double>(value); // Throws std::bad_variant_access
} catch (const std::bad_variant_access& e) {
std::cerr << "Bad variant access: " << e.what() << '\n';
}
// Using std::holds_alternative:
if (std::holds_alternative<int>(value)) {
std::cout << "Holding int\n";
} else {
std::cout << "Not holding int\n";
}
相比传统联合体的优势
-
std::variant
相反,跟踪当前类型并通过如std::get
和std::holds_alternative
等函数提供安全访问。 -
std::variant
在你分配新值时自动构造和销毁所持有的对象,正确地管理对象的生命周期。 -
std::get
,如果抛出std::bad_variant_access
异常,使得错误处理更加透明且易于管理。 -
std::variant
可以与标准库函数如std::visit
一起使用,提供优雅地处理各种类型的方法。
std::any – 任何类型的类型安全容器
std::any
是一个可以容纳任何类型的容器,但通过要求显式转换为正确的类型来保持类型安全。这允许灵活地处理数据,同时不牺牲类型完整性:
#include <any>
#include <iostream>
#include <stdexcept>
std::any value = 42;
try {
std::cout << std::any_cast<int>(value); // Outputs 42
std::cout << std::any_cast<double>(value); // Throws std::bad_any_cast
} catch(const std::bad_any_cast& e) {
std::cerr << "Bad any_cast: " << e.what();
}
使用 std::any
的优点包括以下:
-
灵活性:它可以存储任何类型,使其适合异构集合或灵活的 API
-
类型安全:需要显式转换,防止意外误解包含的值
-
封装:允许你传递值而不暴露它们的具体类型,支持更模块化和可维护的代码
高级类型技术
随着你对 C++ 的深入研究,你会发现该语言提供了一系列高级技术来增强类型安全、可读性和可维护性。在本节中,我们将探讨这些高级概念中的几个,并为每个提供实际示例。
模板 – 为类型安全而特化
模板是 C++ 中的一个强大功能,但你可能希望根据类型施加某些约束或特化。一种方法是通过模板特化来实现,这允许你为某些类型定义自定义行为。
例如,假设你有一个用于在数组中查找最大元素的泛型函数:
template <typename T>
T find_max(const std::vector<T>& arr) {
// generic implementation
return *std::max_element(arr.begin(), arr.end());
}
现在,假设你想为 std::string
提供一个不区分大小写的专用实现:
template <>
std::string find_max(const std::vector<std::string>& arr) {
return *std::max_element(arr.begin(), arr.end(),
[](const std::string& a, const std::string& b) {
return strcasecmp(a.c_str(), b.c_str()) < 0;
});
}
使用这个专用版本,调用 find_max
并使用 std::string
将进行不区分大小写的比较。
创建自定义类型特性
有时,标准类型特性可能不足以满足你的需求。你可以创建自己的自定义类型特性来封装基于类型的逻辑。例如,你可能需要一个类型特性来识别一个类是否具有特定的成员函数:
template <typename T, typename = void>
struct has_custom_method : std::false_type {};
template <typename T>
struct has_custom_method<T, std::void_t<decltype(&T::customMethod)>> : std::true_type {};
你可以像使用任何其他类型特性一样使用这个自定义特性:
static_assert(has_custom_method<MyClass>::value, "MyClass must have a customMethod");
类型别名以提高可读性和可维护性
类型别名可以通过为复杂类型提供有意义的名称来提高你代码的可读性和可维护性。例如,你不必反复写出 std::unordered_map<std::string, std::vector<int>>
,你可以创建一个类型别名:
using StringToIntVectorMap = std::unordered_map<std::string, std::vector<int>>;
现在,你可以在你的代码中使用 StringToIntVectorMap
,这使得代码更易于阅读和维护:
StringToIntVectorMap myMap;
类型别名也可以是模板化的,这提供了更大的灵活性:
template <typename Value>
using StringToValueMap = std::unordered_map<std::string, Value>;
通过使用这些高级类型技术,你为你的 C++ 代码增加了另一层安全性、可读性和可维护性。这些方法让你能够更好地控制模板中类型的行为、检查方式和表示方式,确保你可以编写既健壮又高效的代码。
避免高级类型使用中的常见陷阱
通过类型检查编写健壮的代码
类型检查是构成程序健壮性和安全性的支柱之一。虽然 C++ 是强类型语言,但它确实允许一些灵活性(或宽容性,取决于你的观点),如果不小心管理,可能会导致错误。以下是一些技术和最佳实践,通过利用类型检查来编写健壮的 C++ 代码。
使用类型特性进行编译时检查
C++ 标准库在 <type_traits>
头文件中提供了一套类型特性,它允许你在编译时根据类型进行检查和做出决策。例如,如果你有一个只应接受无符号整型类型的泛型函数,你可以使用 static_assert
来强制执行这一点:
#include <type_traits>
template <typename T>
void foo(T value) {
static_assert(std::is_unsigned<T>::value, "foo() requires an unsigned integral type");
// ... function body
}
利用 constexpr if
C++17 引入了 constexpr if
,这使得您能够编写在编译时评估的条件代码。这在模板代码中的类型特定操作中非常有用:
template <typename T>
void bar(T value) {
if constexpr (std::is_floating_point<T>::value) {
// Handle floating-point types
} else if constexpr (std::is_integral<T>::value) {
// Handle integral types
}
}
函数参数的强类型
C++ 允许类型别名,这有时会使理解函数参数的目的变得困难。例如,声明为 void process(int, int);
的函数并不很有信息量。第一个整数是长度吗?第二个是索引吗?减轻这种困难的一种方法是通过使用强类型定义,如下所示:
struct Length { int value; };
struct Index { int value; };
void process(Length l, Index i);
现在,函数签名提供了语义意义,使得开发者更不可能意外地交换参数。
隐式转换和类型强制
意外创建文件的情况
在 C++ 开发中,定义接受各种参数类型的构造函数的类是常见的,这样做可以提供灵活性。然而,这也伴随着无意中发生隐式转换的风险。为了说明这一点,考虑以下涉及 File
类和 clean
函数的代码片段:
#include <iostream>
class File {
public:
File(const std::string& path) : path_{path} {
auto file = fopen(path_.c_str(), "w");
// check if file is valid
// handle errors, etc
std::cout << "File ctor\n";
}
auto& path() const {
return path_;
}
// other ctors, dtor, etc
private:
FILE* file_ = nullptr;
std::string path_;
};
void clean(const File& file) {
std::cout << "Removing the file: " << file.path() << std::endl;
}
int main() {
auto random_string = std::string{"blabla"};
clean(random_string);
}
输出清楚地展示了问题:
File ctor
Removing the file: blabla
由于构造函数中缺少 explicit
关键字,编译器会自动将 std::string
转换为 File
对象,从而触发一个意外的副作用——创建一个新的文件。
明确性的效用
为了减轻这些风险,可以使用 explicit
关键字。通过将构造函数标记为 explicit
,您指示编译器不允许对该构造函数进行隐式转换。以下是修正后的 File
类的示例:
class File {
public:
explicit File(const std::string& path) : path_{path} {
auto file = fopen(path_.c_str(), "w");
// check if file is valid
// handle errors, etc
std::cout << "File ctor\n";
}
// ... rest of the class
};
通过这个更改,clean(random_string);
这一行将导致编译错误,从而有效地防止意外创建文件。
一个轻松的注意事项
虽然我们的示例可能为了教育目的而有所简化(是的,您不需要自己编写 File
类——我们有库可以做到这一点!),但它有助于强调 C++ 中类型安全的一个关键方面。一个看似无害的构造函数,如果没有明确防范隐式转换,可能会导致意外的行为。
因此,请记住,当您定义构造函数时,明确您的意图是值得的。您永远不知道何时可能会意外地开始一个您从未打算举办的“文件派对”。
摘要
在我们穿越了 C++ 丰富的静态类型系统广阔的领域后,值得花点时间反思我们已经走了多远。从 C++ 最早的那些日子里,原始指针和松散类型数组占据主导地位,到现代的 std::optional
、std::variant
和 enum class
时代,语言在处理类型安全的方法上已经发生了显著的变化。
当我们考虑这些进步如何不仅改进单个代码片段,而且改进整个软件系统时,这些进步的真实力量才会显现出来。拥抱 C++ 的强大类型结构可以帮助我们编写更安全、更易读、最终更易于维护的代码。例如,std::optional
和 not_null
包装器可以减少空指针错误的可能性。高级技术,如模板特化和自定义类型特性,提供了对类型行为的无与伦比的控制。这些不仅仅是学术练习;它们是日常 C++ 程序员的实际工具。
展望未来,C++ 的轨迹表明类型系统将变得更加精细和强大。随着语言不断发展,谁知道未来版本的 C++ 将会带来哪些创新类型相关的特性?也许未来的 C++ 将提供更动态的类型检查,或者它们可能会引入我们目前还无法想象的新的结构。
在下一章中,我们将从类型的基础知识转向 C++ 中类、对象和面向对象编程的宏伟架构。虽然类型为我们提供了构建块,但正是这些更大的结构帮助我们将这些块组装成可持续的软件设计的摩天大楼。在此之前,愿你的类型强大,指针永不空,代码永远稳健。
第七章:C++ 中的类、对象和面向对象编程(OOP)
在本章中,我们深入探讨了 C++ 中类、对象和面向对象编程(OOP)的复杂领域。针对高级 C++ 实践者,我们的重点是提高你对类设计、方法实现、继承和模板使用的理解,避免对这些概念进行入门级解释。我们的目标是提高你使用高级面向对象技术构建健壮和高效软件架构的能力。
讨论从检查定义类时必要的复杂考虑开始,引导你通过决策过程来确定类封装的最佳候选者。这包括区分简单数据结构(如结构体)可能更合适的情况,从而优化性能和可读性。
此外,我们探讨了类内方法的设计——突出各种类型的方法,如访问器、修改器和工厂方法,并建立促进代码清晰性和可维护性的约定。特别关注高级方法设计实践,包括 const 正确性和可见性范围,这对于确保和优化对类数据的访问至关重要。
继承,作为面向对象(OOP)的基石,不仅被审查其优点,还被审查其缺点。为了提供一个平衡的视角,我们提出了如组合和接口隔离等替代方案,这些方案可能在某些情况下更好地服务于你的设计目标。这种细微的讨论旨在为你提供必要的洞察力,以便根据项目的具体需求和约束选择最佳的继承策略或其替代方案。
将讨论扩展到泛型编程,我们深入探讨了复杂的模板使用,包括模板元编程等高级技术。本节旨在展示如何利用模板创建高度可重用和高效的代码。此外,我们还将简要介绍使用面向对象原则设计 API,强调精心设计的接口可以显著提高软件组件的可使用性和持久性。
每个主题都配有来自现实应用中的实际例子和案例研究,展示了这些高级技术在现代软件开发中的应用。到本章结束时,你应该对如何利用 C++ 中的面向对象(OOP)特性来构建优雅、高效和可扩展的软件架构有更深入的理解。
类的好候选者
在面向对象(OOP)中识别类的好候选者涉及寻找自然封装数据和行为的实体。
内聚性
一个类应该代表一组紧密相关的功能。这意味着类中的所有方法和数据都直接与其提供的特定功能相关。例如,一个Timer
类是一个很好的候选者,因为它封装了与计时相关的属性和方法(开始、停止、重置时间),保持了高度的聚合性。
封装
具有应从外部干扰或误用中屏蔽的属性和行为实体可以封装在类中。
一个BankAccount
类封装了余额(属性)以及如deposit
、withdraw
和transfer
之类的行为,确保余额操作仅通过受控和安全操作进行。
可重用性
类应该设计成可以在程序的不同部分或甚至在不同程序中重用。
一个管理数据库连接的DatabaseConnection
类可以在需要数据库交互的多个应用程序中重用,处理连接、断开连接和错误管理。
抽象
一个类应该通过隐藏复杂的逻辑来提供一个简化的接口,代表更高层次的抽象。例如,标准库中有如std::vector
之类的类,它们抽象了动态数组的复杂性,为数组操作提供了一个简单的接口。
实际实体
类通常代表与正在建模的系统相关的现实世界中的对象。
在航班预订系统中,如Flight
、Passenger
和Ticket
之类的类是很好的候选者,因为它们直接代表具有清晰属性和行为的现实世界对象。
管理复杂性
类应该通过将大问题分解成更小、更易于管理的部分来帮助管理复杂性。
这里有一个例子——在图形编辑软件中,一个GraphicObject
类可能作为更具体图形对象(如Circle
、Rectangle
和Polygon
)的基类,系统地组织图形属性和功能。
通过封装最小化类的职责
封装是面向对象编程中的一个基本概念,它涉及将数据(属性)和操作数据的方法(函数)捆绑成一个单一单元或类。它不仅隐藏了对象的内部状态,还模块化了其行为,使软件更容易管理和扩展。然而,一个类应该封装多少功能和数据可以显著影响应用程序的可维护性和可扩展性。
类中的过度封装——一个常见的陷阱
在实践中,在单个类中封装过多的功能和数据是一个常见的错误,可能导致多个问题。这通常会导致一个神对象——一个控制应用程序中太多不同部分的类,自己承担了太多工作。这样的类通常难以理解,难以维护,且测试起来有问题。
让我们看看一个封装不良的Car
类的例子。
考虑以下Car
类的示例,它试图管理汽车的基本属性以及其内部系统的详细方面,如发动机、变速箱和娱乐系统:
#include <iostream>
#include <string>
class Car {
private:
std::string _model;
double _speed;
double _fuel_level;
int _gear;
bool _entertainment_system_on;
public:
Car(const std::string& model) : _model(model), _speed(0), _fuel_level(50), _gear(1), _entertainment_system_on(false) {}
void accelerate() {
if (_fuel_level > 0) {
_speed += 10;
_fuel_level -= 5;
std::cout << "Accelerating. Current speed: " << _speed << " km/h, Fuel level: " << _fuel_level << " liters" << std::endl;
} else {
std::cout << "Not enough fuel." << std::endl;
}
}
void change_gear(int new_gear) {
_gear = new_gear;
std::cout << "Gear changed to: " << _gear << std::endl;
}
void toggle_entertainment_system() {
_entertainment_system_on = !_entertainment_system_on;
std::cout << "Entertainment System is now " << (_entertainment_system_on ? "on" : "off") << std::endl;
}
void refuel(double amount) {
_fuel_level += amount;
std::cout << "Refueling. Current fuel level: " << _fuel_level << " liters" << std::endl;
}
};
这个Car
类有问题,因为它试图管理汽车功能太多的方面,这些方面最好由专门的组件来处理。
使用组合进行适当的封装
一个更好的方法是使用组合将责任委托给其他类,每个类处理系统功能的一个特定部分。这不仅遵循单一职责原则,而且使系统更加模块化,更容易维护。
下面是一个使用组合设计良好的Car
类的示例:
#include <iostream>
#include <string>
class Engine {
private:
double _fuel_level;
public:
Engine() : _fuel_level(50) {}
void consume_fuel(double amount) {
_fuel_level -= amount;
std::cout << "Consuming fuel. Current fuel level: " << _fuel_level << " liters" << std::endl;
}
void refuel(double amount) {
_fuel_level += amount;
std::cout << "Engine refueled. Current fuel level: " << _fuel_level << " liters" << std::endl;
}
double get_fuel_level() const {
return _fuel_level;
}
};
class Transmission {
private:
int _gear;
public:
Transmission() : _gear(1) {}
void change_gear(int new_gear) {
_gear = new_gear;
std::cout << "Transmission: Gear changed to " << _gear << std::endl;
}
};
class EntertainmentSystem {
private:
bool _is_on;
public:
EntertainmentSystem() : _is_on(false) {}
void toggle() {
_is_on = !_is_on;
std::cout << "Entertainment System is now " << (_is_on ? "on" : "off") << std::endl;
}
};
class Car {
private:
std::string _model;
double _speed;
Engine _engine;
Transmission _transmission;
EntertainmentSystem _entertainment_system;
public:
Car(const std::string& model) : _model(model), _speed(0) {}
void accelerate() {
if (_engine.get_fuel_level() > 0) {
_speed += 10;
_engine.consume_f
uel(5);
std::cout << "Car accelerating. Current speed: " << _speed << " km/h" << std::endl;
} else {
std::cout << "Not enough fuel to accelerate." << std::endl;
}
}
void change_gear(int gear) {
_transmission.change_gear(gear);
}
void toggle_entertainment_system() {
_entertainment_system.toggle();
}
void refuel(double amount) {
_engine.refuel(amount);
}
};
在这个改进的设计中,Car
类充当其组件之间的协调者,而不是直接管理每个细节。每个子系统——发动机、变速箱和娱乐系统——处理自己的状态和行为,导致一个更容易维护、测试和扩展的设计。这个例子展示了适当的封装和组合如何显著提高面向对象软件的结构和质量。
C++中结构和类的使用
在 C++中,结构和类都用于定义用户定义的类型,可以包含数据和函数。它们之间的主要区别在于它们的默认访问级别:类的成员默认是私有的,而结构体的成员默认是公开的。这种区别微妙地影响了它们在 C++编程中的典型用途。
结构体——理想的被动数据结构
在 C++中,结构体特别适合创建被动数据结构,其主要目的是存储数据而不需要封装太多行为。由于它们的默认公开性质,结构体通常用于当你想要允许直接访问数据成员时,这可以简化代码并减少操作数据所需额外函数的需求。
以下列表概述了你应该使用结构体的实例:
-
数据对象:结构体非常适合创建纯数据(POD)结构。这些是主要持有数据且功能很少或没有的方法简单对象。例如,结构体通常用于表示空间中的坐标、RGB 颜色值或设置配置,在这些情况下,直接访问数据字段比通过获取器和设置器更方便:
struct Color {
int red = 0;
int green = 0;
int blue = 0;
};
struct Point {
double x = 0.0;
double y = 0.0;
double z = 0.0;
};
Fortunately, C++ 11 and C++ 20 provide aggregate initialization and designated initializers, making it easier to initialize structs with default values.
// C++ 11
auto point = Point {1.1, 2.2, 3.3};
// C++ 20
auto point2 = Point {.x = 1.1, .y = 2.2, .z = 3.3};
如果你的项目不支持 C++ 20,你可以利用 C99 指定的初始化器来实现类似的效果:
auto point3 = Point {.x = 1.1, .y = 2.2, .z = 3.3};
-
互操作性:结构体在接口 C 或数据对齐和布局至关重要的系统中很有用。它们确保在底层操作(如硬件接口或网络通信)中的兼容性和性能。
-
轻量级容器:当你需要轻量级容器来组合几个变量时,结构体比类提供更透明和更不繁琐的方法。它们对于封装不是主要关注的小聚合来说很理想。
类 – 封装复杂性
类是 C++面向对象编程的支柱,用于将数据和行为封装成一个单一实体。默认的私有访问修饰符鼓励隐藏内部状态和实现细节,促进遵循封装和抽象原则的更严格设计。
以下列表解释了何时应该使用类:
-
复杂系统:对于涉及复杂数据处理、状态管理和接口控制的组件,类是首选选择。它们提供了数据保护和接口抽象的机制,这对于维护软件系统的完整性和稳定性至关重要:
class Car {
private:
int speed;
double fuel_level;
public:
void accelerate();
void brake();
void refuel(double amount);
};
-
行为封装:当功能(方法)与数据一样重要时,类是理想的。将行为与数据封装到类中可以允许更易于维护和更无错误的代码,因为对数据的操作是紧密控制和明确定义的。
-
继承和多态:类支持继承和多态,能够创建可以动态扩展和修改的复杂对象层次结构。这在许多软件设计模式和高级系统架构中是必不可少的。
在 C++中选择结构体和类应根据预期用途进行指导:结构体用于简单、透明的数据容器,其中直接数据访问是可以接受或必要的,而类用于需要封装、行为和接口控制的更复杂系统。理解和利用每个的优点可以导致更干净、更高效和可扩展的代码。
类中的常见方法类型 – 获取器和设置器
在面向对象编程(OOP)中,尤其是在像 Java 这样的语言中,获取器和设置器是标准方法,它们作为访问和修改类私有数据成员的主要接口。这些方法提供了对对象属性的受控访问,遵循封装原则,这是有效面向对象设计的基石。
获取器和设置器的目的和约定
获取器(也称为访问器)是用于检索私有字段值的函数。它们不会修改数据。设置器(也称为修改器)是允许根据接收到的输入修改私有字段的函数。这些方法通过在设置数据时可能强制执行约束或条件,使对象的内部状态保持一致和有效。
这里是获取器和设置器的约定:
-
x
的 getter 命名为get_x()
,setter 命名为set_x(value)
。这种命名约定在 Java 中几乎是通用的,并且在支持基于类的 OOP 的其他编程语言中也普遍采用。 -
返回类型和参数:属性的 getter 返回与属性本身相同的类型,并且不接受任何参数,而 setter 返回 void,并接受与设置的属性相同类型的参数。
下面是一个 C++中的例子:
class Person {
private:
std::string _name;
int _age;
public:
// Getter for the name property
std::string get_name() const { return _name; }
// Setter for the name property
void set_name(const std::string& name) { _name = name; }
// Getter for the age property
int get_age() const { return _age; }
// Setter for the age property
void set_age(int age) {
if (age >= 0) { // validate the age
_age = age;
}
}
};
有用性和建议
受控访问和验证:getter 和 setter 封装了类的字段,提供了受控访问和验证逻辑。这有助于维护数据的完整性,确保不会设置无效或不适当的值。
灵活性:通过使用 getter 和 setter,开发者可以在不改变类的外部接口的情况下更改数据存储和检索的底层实现。这在维护向后兼容性或需要为优化更改数据表示时特别有用。
一致性:这些方法可以强制执行需要在对象生命周期内持续维护的规则。例如,确保字段永远不会持有 null 值或遵循特定的格式。
何时使用 getter 和 setter,何时不使用
常规做法是在存在封装、业务逻辑或继承复杂性的类中使用 getter 和 setter。例如,对于具有相对复杂逻辑的Car
和Engine
类,getter 和 setter 对于维护数据的完整性和确保系统正确运行是必不可少的。另一方面,对于像Point
或Color
这样的简单数据结构,其主要目的是存储数据而不涉及太多行为,使用具有公共数据成员的结构体可能更合适。请注意,如果结构体是库或 API 的一部分,为了未来的可扩展性,提供 getter 和 setter 可能是有益的。
这种细微的方法允许开发者平衡控制与简单性,为软件组件的具体需求选择最合适的工具。
C++中的继承
继承和组合是 C++中两个基本面向对象编程概念,它们使得创建复杂且可重用的软件设计成为可能。它们促进了代码重用,并有助于模拟现实世界的关系,尽管它们的工作方式不同。
继承允许一个类(称为派生类或子类)从另一个类(称为基类或超类)继承属性和行为。这使得派生类可以重用基类中的代码,同时扩展或覆盖其功能。例如,考虑一个BaseSocket
类及其派生类TcpSocket
和UdpSocket
。派生类继承了BaseSocket
的基本功能,并添加了它们特定的实现:
class BaseSocket {
public:
virtual ssize_t send(const std::vector<uint8_t>& data) = 0;
virtual ~BaseSocket() = default;
};
class TcpSocket : public BaseSocket {
public:
ssize_t send(const std::vector<uint8_t>& data) override {
// Implement TCP-specific send logic here
}
};
class UdpSocket : public BaseSocket {
public:
ssize_t send(const std::vector<uint8_t>& data) override {
// Implement UDP-specific send logic here
}
};
在这个例子中,TcpSocket
和 UdpSocket
类继承自 BaseSocket
,展示了继承如何促进代码重用并建立“是一种”关系。继承还支持多态,允许派生类的对象被当作基类的实例来处理,从而实现动态方法绑定。
另一方面,组合涉及通过包含其他类的对象来创建类。而不是从基类继承,一个类由一个或多个其他类的对象组成,这些对象用于实现所需的功能。这代表了一种“有”的关系。例如,考虑一个可以拥有 BaseSocket
的 CommunicationChannel
类。CommunicationChannel
类使用 BaseSocket
对象来实现其通信功能,展示了组合:
class CommunicationChannel {
public:
CommunicationChannel(std::unique_ptr<BaseSocket> sock) : _socket(sock) {}
bool transmit(const std::vector<uint8_t>& data) {
size_t total_sent = 0;
size_t data_size = data.size();
while (total_sent < data_size) {
ssize_t bytesSent = _socket->send({data.begin() + total_sent, data.end()});
if (bytesSent < 0) {
std::cerr << "Error sending data." << std::endl;
return false;
}
total_sent += bytesSent;
}
std::cout << "Communication channel transmitted " << total_sent << " bytes." << std::endl;
return true;
}
private:
std::unique_ptr<BaseSocket> _socket;
};
int main() {
TcpSocket tcp;
CommunicationChannel channel(std::make_unique<TcpSocket>());
std::vector<uint8_t> data = {1, 2, 3, 4, 5};
if (channel.transmit(data)) {
std::cout << "Data transmitted successfully." << std::endl;
} else {
std::cerr << "Data transmission failed." << std::endl;
}
return 0;
}
在这个例子中,CommunicationChannel
类包含一个 BaseSocket
对象,并使用它来实现其功能。transmit
方法将数据分块发送,直到所有数据发送完毕,并检查错误(当返回值小于 0
时)。这展示了组合如何提供灵活性,允许对象在运行时动态组装。它还通过包含对象并仅暴露必要的接口来促进更好的封装,从而避免类之间的紧密耦合,使代码更模块化且易于维护。
总结来说,继承和组合都是 C++ 中创建可重用和维护性代码的重要工具。继承适用于具有明确层次关系且需要多态的场景,而组合则是从更简单的组件组装复杂行为时的理想选择,提供了灵活性和更好的封装。理解何时使用每种方法对于有效的面向对象设计至关重要。
C++ 中继承的演变
最初,继承被视为一种强大的工具,可以减少代码重复并增强代码的表达性。它允许创建一个派生类,该类从基类继承属性和行为。然而,随着 C++ 在复杂系统中的应用增长,继承作为一刀切解决方案的局限性变得明显。
二进制级别的继承实现
有趣的是,在二进制级别,C++ 中的继承实现与组合类似。本质上,派生类在其结构中包含基类的一个实例。这可以通过一个简化的 ASCII 图表来可视化:
+-------------------+
| Derived Class |
|-------------------|
| Base Class Part | <- Base class subobject
|-------------------|
| Derived Class Data| <- Additional data members of the derived class
+-------------------+
在这种布局中,派生类对象中的基类部分包含属于基类的所有数据成员,并在内存中直接跟在其后的是派生类的附加数据成员。请注意,内存中数据成员的实际顺序可能受到对齐要求、编译器优化等因素的影响。
继承的优缺点
这里是继承的优点:
-
MediaContent
类将作为所有类型媒体内容的基类。它将封装常见的属性和行为,例如title
(标题)、duration
(时长)和基本的播放控制(play
(播放)、pause
(暂停)、stop
(停止)):#include <iostream>
#include <string>
// Base class for all media content
class MediaContent {
protected:
std::string _title;
int _duration; // Duration in seconds
public:
MediaContent(const std::string& title, int duration)
: _title(title), _duration(duration) {}
auto title() const { return _title; }
auto duration() const { return duration; }
virtual void play() = 0; // Start playing the content
virtual void pause() = 0;
virtual void stop() = 0;
virtual ~MediaContent() = default;
};
Audio
类扩展了MediaContent
,添加了与音频文件相关的特定属性,例如比特率:class Audio : public MediaContent {
private:
int _bitrate; // Bitrate in kbps
public:
Audio(const std::string& title, int duration, int bitrate)
: MediaContent(title, duration), _bitrate(bitrate) {}
auto bitrate() const { return _bitrate; }
void play() override {
std::cout << "Playing audio: " << title << ", Duration: " << duration
<< "s, Bitrate: " << bitrate << "kbps" << std::endl;
}
void pause() override {
std::cout << "Audio paused: " << title << std::endl;
}
void stop() override {
std::cout << "Audio stopped: " << title << std::endl;
}
};
同样,
Video
类扩展了MediaContent
并引入了额外的属性,例如resolution
(分辨率):class Video : public MediaContent {
private:
std::string _resolution; // Resolution as width x height
public:
Video(const std::string& title, int duration, const std::string& resolution)
: MediaContent(title, duration), _resolution(resolution) {}
auto resolution() const { return _resolution; }
void play() override {
std::cout << "Playing video: " << title << ", Duration: " << duration
<< "s, Resolution: " << resolution << std::endl;
}
void pause() override {
std::cout << "Video paused: " << title << std::endl;
}
void stop() override {
std::cout << "Video stopped: " << title << std::endl;
}
};
下面是如何在简单的媒体播放器系统中使用这些类:
int main() {
Audio my_song("Song Example", 300, 320);
Video my_movie("Movie Example", 7200, "1920x1080");
my_song.play();
my_song.pause();
my_song.stop();
my_movie.play();
my_movie.pause();
my_movie.stop();
return 0;
}
在这个例子中,
Audio
和Video
都继承自MediaContent
。这使我们能够重用title
和duration
属性,并需要实现针对每种媒体类型的播放控制(play
、pause
、stop
)。这个层次结构展示了继承如何促进代码重用和系统可扩展性,同时在一个统一的框架中为不同类型的媒体内容启用特定的行为。每个类只添加其类型独有的内容,遵循基类提供通用功能,派生类为特定需求扩展或修改该功能的原理。 -
多态性:通过继承,C++ 支持多态性,允许使用基类引用来引用派生类对象。这实现了动态方法绑定和对多个派生类型的灵活接口。我们的媒体内容层次结构可以用于实现一个可以统一处理不同类型媒体内容的媒体播放器:
class MediaPlayer {
private:
std::vector<std::unique_ptr<MediaContent>> _playlist;
public:
void add_media(std::unique_ptr<MediaContent> media) {
_playlist.push_back(std::move(media));
}
void play_all() {
for (auto& media : _playlist) {
media->play();
// Additional controls can be implemented
}
}
};
int main() {
MediaPlayer player;
player.add(std::make_unique<Audio>("Jazz in Paris", 192, 320));
player.add(std::make_unique<Video>("Tour of Paris", 1200, "1280x720"));
player.play_all();
return 0;
}
add
方法接受任何从MediaContent
派生的媒体内容类型,通过使用基类指针来引用派生类对象,展示了多态性。这是通过将媒体项存储在std::vector
的std::unique_ptr<MediaContent>
中实现的。play_all
方法遍历存储的媒体,并对每个项目调用播放方法。尽管实际的媒体类型不同(音频或视频),媒体播放器将它们都视为MediaContent
。正确的播放方法(来自Audio
或Video
)在运行时被调用,这是动态多态性(也称为动态分派)的一个例子。 -
分层结构:它提供了一种自然的方式,以分层的方式组织相关类,从而模拟现实世界的关系。
这里是继承的缺点:
- 紧密耦合:继承在基类和派生类之间创建了一种紧密耦合。基类中的更改可能会无意中影响派生类,导致代码脆弱,当修改基类时可能会崩溃。以下示例通过继承在软件系统中说明了紧密耦合的问题。我们将使用一个涉及在线商店的场景,该商店使用类层次结构管理不同类型的折扣。
基类 – 折扣
Discount
类为所有类型的折扣提供了基本的结构和功能。它根据百分比减少来计算折扣;
#include <iostream>
class Discount {
protected:
double _discount_percent; // Percent of discount
public:
Discount(double percent) : _discount_percent(percent) {}
virtual double apply_discount(double amount) {
return amount * (1 - _discount_percent / 100);
}
};
派生类 – 季节性折扣
SeasonalDiscount
类扩展了Discount
,并根据季节因素修改折扣计算,例如在假日季节增加折扣:
class SeasonalDiscount : public Discount {
public:
SeasonalDiscount(double percent) : Discount(percent) {}
double apply_discount(double amount) override {
// Let's assume the discount increases by an additional 5% during holidays
double additional = 0.05; // 5% extra during holidays
return amount * (1 - (_discount_percent / 100 + additional));
}
};
派生类 – ClearanceDiscount
ClearanceDiscount
类也扩展了Discount
,用于处理折扣可能显著更高的清仓商品:
class ClearanceDiscount : public Discount {
public:
ClearanceDiscount(double percent) : Discount(percent) {}
double apply_discount(double amount) override {
// Clearance items get an extra 10% off beyond the configured discount
double additional = 0.10; // 10% extra for clearance items
return amount * (1 - (_discount_percent / 100 + additional));
}
};
演示和紧耦合问题:
int main() {
Discount regular(20); // 20% regular discount
SeasonalDiscount holiday(20); // 20% holiday discount, plus extra
ClearanceDiscount clearance(20); // 20% clearance discount, plus extra
std::cout << "Regular Price $100 after discount: $" << regular.apply_discount(100) << std::endl;
std::cout << "Holiday Price $100 after discount: $" << holiday.apply_discount(100) << std::endl;
std::cout << "Clearance Price $100 after discount: $" << clearance.apply_discount(100) << std::endl;
return 0;
}
紧耦合问题
以下是一个紧耦合问题的列表:
-
apply_discount
)。任何对基类方法签名或apply_discount
内部逻辑的更改都可能需要修改所有派生类。 -
_discount_percent
。如果基类中的公式发生变化(例如,包含最小或最大限制),所有子类可能需要进行大量修改以符合新的逻辑。 -
不灵活性:这种耦合使得在不影响其他类型的情况下修改一种折扣类型的行为变得困难。这种设计在可能需要独立演变折扣计算策略的地方缺乏灵活性。
解决方案 – 使用策略模式解耦
减少这种耦合的一种方法是通过使用策略模式,它涉及定义一组算法(折扣策略),封装每个算法,并使它们可互换。这允许折扣算法独立于使用它们的客户端而变化:
class DiscountStrategy {
public:
virtual double calculate(double amount) = 0;
virtual ~DiscountStrategy() {}
};
class RegularDiscountStrategy : public DiscountStrategy {
public:
double calculate(double amount) override {
return amount * 0.80; // 20% discount
}
};
class HolidayDiscountStrategy : public DiscountStrategy {
public:
double calculate(double amount) override {
return amount * 0.75; // 25% discount
}
};
class ClearanceDiscountStrategy : public DiscountStrategy {
public:
double calculate(double amount) override {
return amount * 0.70; // 30% discount
}
};
// Use these strategies in a Discount context class
class Discount {
private:
std::unique_ptr<DiscountStrategy> _strategy;
public:
Discount(std::unique_ptr<DiscountStrategy> strat) : _strategy(std::move(strat)) {}
double apply_discount(double amount) {
return _strategy->calculate(amount);
}
};
这种方法通过将折扣计算与使用它的客户端(Discount
)解耦,允许每个折扣策略独立演变而不影响其他策略。减少耦合的其他几种方法包括:
-
HybridFlyingElectricCar
类继承自ElectricCar
和FlyingCar
,每个这些类进一步继承自它们各自的层次结构,导致高度纠缠的类结构。这种复杂性使得系统难以调试、扩展或可靠地使用,同时也增加了在各种场景下测试和维护一致行为所面临的挑战。为了管理由广泛使用继承引入的复杂性,可以推荐几种策略。优先考虑组合而非继承通常提供更大的灵活性,允许系统由定义良好、松散耦合的组件组成,而不是依赖于僵化的继承结构。保持继承链短且可管理——通常不超过两到三级——有助于保持系统清晰性和可维护性。在 Java 和 C#等语言中使用接口提供了一种实现多态行为的方法,而不需要与继承相关的开销。当多继承不可避免时,确保清晰的文档并考虑使用类似接口的结构或混入(mixins)至关重要,这有助于最小化复杂性并增强系统健壮性。
-
Liskov 替换原则 (LSP):我们在本书中较早提到了这个原则;LSP 声明,超类对象应该可以替换为其子类对象,而不会改变程序的可取属性(正确性、执行的任务等)。继承有时可能导致违反此原则,特别是当子类偏离基类预期的行为时。以下各节包括与 LSP 违反相关的典型问题,通过简单的示例进行说明。
派生类中的意外行为
当派生类以改变预期行为的方式覆盖基类的方法时,这些对象被互换使用时可能会导致意外结果:
class Bird {
public:
virtual void fly() {
std::cout << "This bird flies" << std::endl;
}
};
class Ostrich : public Bird {
public:
void fly() override {
throw std::logic_error("Ostriches can't fly!");
}
};
void make_bird_fly(Bird& b) {
b.fly(); // Expecting all birds to fly
}
在这里,将 Bird
对象替换为 Ostrich
对象在 make_bird_fly
函数中会导致运行时错误,因为鸵鸟不能飞,违反了 LSP。Bird
类的用户期望任何子类都能飞行,而 Ostrich
打破了这一期望。
方法先决条件问题
如果派生类对方法施加的先决条件比基类施加的更严格,它可能会限制子类的可用性并违反 LSP:
class Payment {
public:
virtual void pay(int amount) {
if (amount <= 0) {
throw std::invalid_argument("Amount must be positive");
}
std::cout << "Paying " << amount << std::endl;
}
};
class CreditPayment : public Payment {
public:
void pay(int amount) override {
if (amount < 100) { // Stricter precondition than the base class
throw std::invalid_argument("Minimum amount for credit payment is 100");
}
std::cout << "Paying " << amount << " with credit" << std::endl;
}
};
在这里,CreditPayment
类不能替代 Payment
类,否则可能会因为金额低于 100 而抛出错误,尽管这样的金额对于基类来说是完全有效的。
LSP 违反的解决方案
-
以 LSP 为设计理念:在设计你的类层次结构时,确保任何子类都可以替代父类而不改变程序的可取属性
-
使用组合而非继承:如果子类完全遵守基类契约没有意义,请使用组合而非继承
-
明确定义行为契约:记录并强制执行基类的预期行为,并确保所有派生类严格遵循这些契约,不引入更严格的先决条件或改变后置条件
通过密切关注这些原则和潜在陷阱,开发者可以创建更稳健和可维护的面向对象设计。
虽然 C++ 中的继承仍然是一个有价值的特性,但理解何时以及如何有效地使用它至关重要。继承在二进制层面上类似于组合的实现细节突显了它本质上是在对象内存布局中结构和访问数据。从业者必须仔细考虑是否继承或组合(或两者的组合)将最好地服务于他们的设计目标,特别是在系统灵活性、可维护性和对 OOP 原则(如 LSP)的稳健应用方面。与软件开发中的许多特性一样,关键在于为正确的工作使用正确的工具。
模板和泛型编程
模板和泛型编程是 C++的关键特性,它们使得创建灵活且可重用的组件成为可能。虽然本章提供了这些强大工具的概述,但重要的是要注意,模板的主题,尤其是模板元编程,内容丰富到足以填满整本书。对于那些寻求深入探索的人,推荐阅读关于 C++模板和元编程的专门资源。
模板有什么好处?
模板在需要在不同类型的数据上执行相似操作的场景中特别有用。它们允许你编写一段可以与任何类型一起工作的代码。以下小节概述了一些常见的用例和示例。
泛型算法
算法可以在不重写针对每种类型的代码的情况下作用于不同的类型。例如,标准库中的std::sort
函数可以排序任何类型的元素,只要元素可以进行比较:
#include <algorithm>
#include <vector>
#include <iostream>
template <typename T>
void print(const std::vector<T>& vec) {
for (const T& elem : vec) {
std::cout << elem << " ";
}
std::cout << std::endl;
}
int main() {
std::vector<int> int_vec = {3, 1, 4, 1, 5};
std::sort(int_vec.begin(), int_vec.end());
print(int_vec); // Outputs: 1 1 3 4 5
std::vector<std::string> string_vec = {"banana", "apple", "cherry"};
std::sort(string_vec.begin(), string_vec.end());
print(string_vec); // Outputs: apple banana cherry
return 0;
}
容器类
模板在标准库中大量使用,例如std::vector
、std::list
和std::map
,这些容器可以存储任何类型的元素:
#include <vector>
#include <iostream>
int main() {
std::vector<int> int_vec = {1, 2, 3};
std::vector<std::string> string_vec = {"hello", "world"};
for (int val : int_vec) {
std::cout << val << " ";
}
std::cout << std::endl;
for (const std::string& str : string_vec) {
std::cout << str << " ";
}
std::cout << std::endl;
return 0;
}
如果不使用模板,开发者在使用集合时的选择将限于为每种类型的集合创建单独的类(例如,IntVector
、StringVector
等),或者要求使用一个公共基类,这会需要类型转换并失去类型安全性,例如:
class BaseObject {};
class Vector {
public:
void push_back(BaseObject* obj);
};
另一种选择是存储一些void
指针,并在检索时将它们转换为所需的类型,但这种方法更容易出错。
标准库使用模板为智能指针如std::unique_ptr
和std::shared_ptr
,它们管理动态分配对象的生存期:
#include <memory>
#include <iostream>
int main() {
std::unique_ptr<int> ptr = std::make_unique<int>(42);
std::cout << "Value: " << *ptr << std::endl; // Outputs: Value: 42
std::shared_ptr<int> shared_ptr = std::make_shared<int>(100);
std::cout << "Shared Value: " << *shared_ptr << std::endl; // Outputs: Shared Value: 100
return 0;
}
模板通过允许编译器在模板实例化期间检查类型来确保类型安全性,从而减少运行时错误:
template <typename T>
T add(T a, T b) {
return a + b;
}
int main() {
std::cout << add<int>(5, 3) << std::endl; // Outputs: 8
std::cout << add<double>(2.5, 3.5) << std::endl; // Outputs: 6.0
return 0;
}
模板的工作原理
C++中的模板不是实际的代码,而是作为代码生成的蓝图。当模板用特定类型实例化时,编译器会生成一个具体的模板实例,其中模板参数被指定的类型所替换。
函数模板
函数模板定义了一个函数的模式,该函数可以作用于不同的数据类型:
template <typename T>
T add(T a, T b) {
return a + b;
}
int main() {
std::cout << add<int>(5, 3) << std::endl; // Outputs: 8
std::cout << add<double>(2.5, 3.5) << std::endl; // Outputs: 6.0
return 0;
}
模板实例化后实际生成的函数可能如下所示(取决于编译器):
int addInt(int a, int b) {
return a + b;
}
double addDouble(double a, double b) {
return a + b;
}
类模板
类模板定义了一个可以作用于不同数据类型的类的模式:
template <typename T>
class Box {
private:
T content;
public:
void set_content(const T& value) {
content = value;
}
T get_content() const {
return content;
}
};
int main() {
Box<int> intBox;
intBox.set_content(123);
std::cout << intBox.get_content() << std::endl; // Outputs: 123
Box<std::string> stringBox;
stringBox.set_content("Hello Templates!");
std::cout << stringBox.get_content() << std::endl; // Outputs: Hello Templates!
return 0;
}
模板实例化后实际生成的类可能如下所示(取决于编译器):
class BoxInt { /*Box<int>*/ };
class BoxString { /*Box<int>*/ };
模板的实例化方式
当模板与特定类型一起使用时,编译器会创建一个新实例的模板,其中指定的类型替换了模板参数。这个过程被称为模板实例化,可以隐式或显式地发生:
-
隐式实例化:这发生在编译器遇到使用特定类型的模板时:
int main() {
std::cout << add(5, 3) << std::endl; // The compiler infers the type as int
return 0;
}
-
显式实例化:程序员明确指定类型:
int main() {
std::cout << add<int>(5, 3) << std::endl; // Explicitly specifies the type as int
return 0;
}
C++ 中模板使用的真实世界示例
在金融软件领域,以灵活、类型安全和高效的方式处理各种类型的资产和货币至关重要。C++ 模板提供了一种强大的机制,通过允许开发者编写通用和可重用的代码,这些代码可以与任何数据类型一起操作。
想象一下开发一个必须处理多种货币(如 USD 和 EUR)以及管理各种资产(如股票或债券)的金融系统。通过使用模板,我们可以定义操作这些类型的通用类,而无需为每种特定货币或资产类型重复代码。这种方法不仅减少了冗余,还增强了系统的可扩展性和可维护性。
在以下章节中,我们将详细探讨使用 C++ 模板实现的金融系统示例。这个示例将向您展示如何定义和操作不同货币的价格,如何创建和管理资产,以及如何确保操作保持类型安全和高效。通过这个示例,我们旨在说明在现实世界的 C++ 应用中使用模板的实际好处,以及它们如何导致代码更加清晰、易于维护和更健壮。
定义货币
在设计金融系统时,处理多种货币的方式必须防止错误并确保类型安全性。让我们首先定义需求并探讨各种设计选项。
这里是需求:
-
类型安全性:确保不同货币不会意外混合
-
可扩展性:轻松添加新货币而无需大量代码重复
-
灵活性:以类型安全的方式支持对价格进行加法和减法等操作
这里是设计选项:
-
int
或double
。然而,这种方法有显著的缺点。它允许意外混合不同的货币,导致计算错误:double usd = 100.0;
double eur = 90.0;
double total = usd + eur; // Incorrectly adds USD and EUR
这种方法容易出错且缺乏类型安全性。请注意,由于浮点运算中的精度问题,通常不建议使用
double
来表示货币值。 -
Currency
类并从中继承特定的货币。虽然这种方法引入了一些结构,但它仍然允许混合不同的货币,并且需要大量努力来实现每种新货币:class Currency {
public:
virtual std::string name() const = 0;
virtual ~Currency() = default;
};
class USD : public Currency {
public:
std::string name() const override { return "USD"; }
};
class Euro : public Currency {
public:
std::string name() const override { return "EUR"; }
};
// USD and Euro can still be mixed inadvertently
-
struct
,并且操作是通过模板实现的:struct Usd {
static const std::string &name() {
static std::string name = "USD";
return name;
}
};
struct Euro {
static const std::string &name() {
static std::string name = "EUR";
return name;
}
};
template <typename Currency>
class Price {
public:
Price(int64_t amount) : _amount(amount) {}
int64_t count() const { return _amount; }
private:
int64_t _amount;
};
template <typename Currency>
std::ostream &operator<<(std::ostream &os, const Price<Currency> &price) {
os << price.count() << " " << Currency::name();
return os;
}
template <typename Currency>
Price<Currency> operator+(const Price<Currency> &lhs, const Price<Currency> &rhs) {
return Price<Currency>(lhs.count() + rhs.count());
}
template <typename Currency>
Price<Currency> operator-(const Price<Currency> &lhs, const Price<Currency> &rhs) {
return Price<Currency>(lhs.count() - rhs.count());
}
// User can define other arithmetic operations as needed
基于模板的这种方法确保不同货币的价格不能混合:
int main() {
Price<Usd> usd(100);
Price<Euro> euro(90);
// The following line would cause a compile-time error
// source>:113:27: error: no match for 'operator+' (operand types are 'Price<Usd>' and 'Price<Euro>')
// Price<Usd> total= usd + euro;
Price<Usd> total = usd+ Price<Usd>(50); // Correct usage
std::cout << total<< std::endl; // Outputs: 150 USD
return 0;
}
定义资产
接下来,我们定义可以以不同货币计价的资产。使用模板,我们可以确保每个资产都与正确的货币相关联:
template <typename TickerT>
class Asset;
struct Apple {
static const std::string &name() {
static std::string name = "AAPL";
return name;
}
static const std::string &exchange() {
static std::string exchange = "NASDAQ";
return exchange;
}
using Asset = class Asset<Apple>;
using Currency = Usd;
};
struct Mercedes {
static const std::string &name() {
static std::string name = "MGB";
return name;
}
static const std::string &exchange() {
static std::string exchange = "FRA";
return exchange;
}
using Asset = class Asset<Mercedes>;
using Currency = Euro;
};
template <typename TickerT>
class Asset {
public:
using Ticker = TickerT;
using Currency = typename Ticker::Currency;
Asset(int64_t amount, Price<Currency> price)
: _amount(amount), _price(price) {}
auto amount() const { return _amount; }
auto price() const { return _price; }
private:
int64_t _amount;
Price<Currency> _price;
};
template <typename TickerT>
std::ostream &operator<<(std::ostream &os, const Asset<TickerT> &asset) {
os << TickerT::name() << ", amount: " << asset.amount()
<< ", price: " << asset.price();
return os;
}
使用金融系统
最后,我们演示如何使用定义的模板来管理资产和价格:
int main() {
Price<Usd> usd_price(100);
usd_price = usd_price + Price<Usd>(1);
std::cout << usd_price << std::endl; // Outputs: 101 USD
Asset<Apple> apple{10, Price<Usd>(100)};
Asset<Mercedes> mercedes{5, Price<Euro>(100)};
std::cout << apple << std::endl; // Outputs: AAPL, amount: 10, price: 100 USD
std::cout << mercedes << std::endl; // Outputs: MGB, amount: 5, price: 100 EUR
return 0;
}
使用模板在系统设计中的缺点
虽然 C++中的模板提供了一种强大且灵活的方式来创建类型安全的通用组件,但这种方法有几个缺点。这些缺点在处理多种货币和资产的金融系统背景下尤其相关。在决定在设计中使用模板时,了解这些潜在的缺点是至关重要的。
代码膨胀
模板可能导致代码膨胀,这是由于生成多个模板实例化而导致的二进制文件大小增加。编译器为每个唯一的类型实例化生成模板代码的单独版本。在一个支持各种货币和资产的金融系统中,这可能导致编译的二进制文件大小显著增加。
例如,如果我们为 Price
和 Asset
实例化了不同的类型,如 Usd
、Euro
、Apple
和 Mercedes
,编译器将为每个组合生成单独的代码:
Price<Usd> usdPrice(100);
Price<Euro> euroPrice(90);
Asset<Apple> appleAsset(10, Price<Usd>(100));
Asset<Mercedes> mercedesAsset(5, Price<Euro>(100));
每个实例化都会产生额外的代码,从而增加整体二进制文件的大小。随着支持的货币和资产数量的增加,代码膨胀的影响变得更加明显。二进制文件大小会影响应用程序的性能、内存使用和加载时间,尤其是在资源受限的环境中,这主要是由于缓存效率较低。
编译时间增加
模板可以显著增加项目的编译时间。每次模板与新类型的实例化都会导致编译器生成新的代码。在一个支持数百种货币和来自不同国家和证券交易所的资产的金融系统中,编译器必须实例化所有需要的组合,从而导致构建时间更长。
例如,假设我们的系统支持以下内容:
-
50 种不同的货币
-
来自各种证券交易所的 10000 种不同的资产类型
然后,编译器将为每个 Price
和 Asset
的组合生成代码,导致大量的模板实例化。这可能会显著减慢编译过程,影响开发工作流程,并降低反馈循环的效率。
与其他代码的交互不太明显
模板代码可能很复杂,在与其他代码库的交互方面不太明显。对模板不太熟悉的开发者可能会发现理解和维护模板密集型代码具有挑战性。语法可能很冗长,编译器错误信息可能难以理解,这使得调试和故障排除变得更加复杂。
例如,模板参数中的简单错误可能导致令人困惑的错误信息:
template <typename T>
class Price {
// Implementation
};
Price<int> price(100); // Intended to be Price<Usd> but mistakenly used int
在这种情况下,开发者必须理解模板和编译器生成的特定错误信息,以解决问题。这可能成为经验不足的开发者的障碍。
C++ 20 提供了概念来改进模板错误消息和约束,这可以帮助使模板代码更易于阅读和理解。我们可以创建一个名为 BaseCurrency
的基类,并从它派生所有货币类。这样,我们可以确保所有货币类都有一个共同的接口,并且可以互换使用:
struct BaseCurrency {
};
struct Usd : public BaseCurrency {
static const std::string &name() {
static std::string name = "USD";
return name;
}
};
// Define a concept for currency classes
template<class T, class U>
concept Derived = std::is_base_of<U, T>::value;
// Make sure that template parameter is derived from BaseCurrency
template <Derived<BaseCurrency> CurrencyT>
class Price {
public:
Price(int64_t amount) : _amount(amount) {}
int64_t count() const { return _amount; }
private:
int64_t _amount;
};
在这些更改之后,尝试实例化 Price<int>
将导致编译时错误,从而清楚地表明类型必须从 BaseCurrency
派生:
In function 'int main()':
error: template constraint failure for 'template<class CurrencyT> requires Derived<CurrencyT, Currency> class Price'
auto p = Price<int>(100);
^
note: constraints not satisfied
In substitution of 'template<class CurrencyT> requires Derived<CurrencyT, Currency> class Price [with CurrencyT = int]':
C++ 20 之前的版本也提供了一种方法,通过使用 std::enable_if
和 std::is_base_of
的组合来强制模板参数的约束,从而防止意外的模板实例化:
template <typename CurrencyT,
typename Unused=typename std::enable_if<std::is_base_of<BaseCurrency,CurrencyT>::value>::type>
class Price {
public:
Price(int64_t amount) : _amount(amount) {}
int64_t count() const { return _amount; }
private:
int64_t _amount;
};
现在尝试初始化 Price<int>
将导致编译时错误,表明类型必须从 BaseCurrency
派生,然而,错误信息将有点晦涩难懂:
error: no type named 'type' in 'struct std::enable_if<false, void>'
auto p = Price<int>(100);
| ^
error: template argument 2 is invalid
工具支持有限和调试
调试模板代码可能具有挑战性,因为工具支持有限。许多调试器处理模板实例化不佳,使得难以逐步执行模板代码并检查模板参数和实例化。这可能会阻碍调试过程,并使识别和修复问题变得更加困难。
例如,在调试器中检查模板化的 Price<Usd>
对象的状态可能无法提供对底层类型和值的清晰洞察,尤其是如果调试器不完全支持模板参数检查。
大多数自动完成和 IDE 工具与模板配合得不是很好,因为它们无法假设模板参数的类型。这可能会使导航和理解模板密集型代码库变得更加困难。
模板的高级特性可能难以使用
C++ 中的模板提供了编写通用和可重用代码的机制。然而,在某些情况下,需要针对特定类型自定义默认模板行为。这就是模板特化的用武之地。模板特化允许你为特定类型定义特殊行为,确保模板对该类型的行为正确。
为什么使用模板特化?
当通用模板实现对于特定类型不正确或不高效,或者特定类型需要完全不同的实现时,会使用模板特化。这可能是由于各种原因,例如性能优化、对某些数据类型的特殊处理,或符合特定要求。
例如,考虑一个场景,你有一个通用的 Printer
模板类,它可以打印任何类型的对象。然而,对于 std::string
,你可能希望在打印时在字符串周围添加引号。
基本模板特化示例
下面是一个模板特化工作方式的示例:
#include <iostream>
#include <string>
// General template
template <typename T>
class Printer {
public:
void print(const T& value) {
std::cout << value << std::endl;
}
};
// Template specialization for std::string
template <>
class Printer<std::string> {
public:
void print(const std::string& value) {
std::cout << "\"" << value << "\"" << std::endl;
}
};
int main() {
Printer<int> int_printer;
int_printer.print(123); // Outputs: 123
Printer<std::string> string_printer;
string_printer.print("Hello, World!"); // Outputs: "Hello, World!" with quotes
return 0;
}
在这个示例中,通用的Printer
模板类可以打印任何类型。然而,对于std::string
,特化版本在打印字符串时会添加引号。
包含特化头文件
在使用模板特化时,包含包含特化定义的头文件至关重要。如果没有包含特化头文件,编译器将实例化模板的默认版本,从而导致行为不正确。
例如,考虑以下文件:
printer.h
(通用模板定义):
#ifndef PRINTER_H
#define PRINTER_H
#include <iostream>
template <typename T>
class Printer {
public:
void print(const T& value) {
std::cout << value << std::endl;
}
};
#endif // PRINTER_H
printer_string.h
(针对std::string
的模板特化):
#ifndef PRINTER_STRING_H
#define PRINTER_STRING_H
#include "printer.h"
#include <string>
template <>
class Printer<std::string> {
public:
void print(const std::string& value) {
std::cout << "\"" << value << "\"" << std::endl;
}
};
#endif // PRINTER_STRING_H
main.cpp
(使用模板和特化):
#include "printer.h"
// #include "printer_string.h" // Uncomment this line to use the specialization
int main() {
Printer<int> int_printer;
int_printer.print(123); // Outputs: 123
Printer<std::string> string_printer;
string_printer.print("Hello, World!"); // Outputs: Hello, World! without quotes if the header is not included
return 0;
}
在这个配置中,如果main.cpp
中没有包含printer_string.h
头文件,编译器将使用默认的Printer
模板为std::string
,从而导致行为不正确(打印字符串时不加引号)。
模板是 C++编程语言的重要组成部分,提供了创建通用、可重用和类型安全的代码的强大功能。在各种场景中都是必不可少的,例如开发通用算法、容器类、智能指针和其他需要与多种数据类型无缝工作的实用工具。模板使开发者能够编写灵活且高效的代码,确保相同的功能可以应用于不同的类型而无需重复。
然而,模板的强大功能并非没有代价。模板的使用可能导致编译时间增加和代码膨胀,尤其是在支持广泛类型和组合的系统中。语法和产生的错误信息可能很复杂,难以理解,这对经验不足的开发者来说是一个挑战。此外,由于工具支持有限和模板实例化的复杂性质,调试模板密集型代码可能很繁琐。
此外,模板可能会引入与代码库的其他部分不太明显的交互,如果不妥善管理,可能会引起问题。开发者还必须意识到需要谨慎包含特化头文件的高级特性,如模板特化,以避免不正确的行为。
考虑到这些注意事项,开发者在将模板纳入其项目之前必须仔细思考。虽然它们提供了显著的好处,但潜在的缺点需要深思熟虑的方法,以确保优势超过复杂性。正确理解和审慎使用模板可以导致更健壮、可维护和高效的 C++应用程序。
概述
在本章中,我们探讨了高级 C++编程的复杂性,重点关注类设计、继承和模板。我们首先介绍了有效类设计的原则,强调封装最小必要功能和数据以实现更好的模块化和可维护性的重要性。通过实际示例,我们突出了良好的和不良的设计实践。接着转向继承,我们探讨了它的好处,如代码重用、层次结构化和多态性,同时也指出了其缺点,包括紧密耦合、复杂的层次结构和可能违反 LSP(里氏替换原则)的风险。我们提供了何时使用继承以及何时考虑替代方案如组合的建议。在模板部分,我们深入探讨了它们在启用泛型编程中的作用,允许灵活且可重用的组件与任何数据类型一起工作。我们讨论了模板的优势,如代码重用性、类型安全和性能优化,但也指出了它们的缺点,包括编译时间增加、代码膨胀以及理解和调试模板密集型代码的复杂性。在这些讨论中,我们强调了在利用这些强大功能时进行仔细考虑和理解的需要,以确保构建健壮且可维护的 C++应用程序。在下一章中,我们将把重点转向 API 设计,探讨在 C++中创建清晰、高效和用户友好界面的最佳实践。
第八章:在 C++中设计和开发 API
在软件开发的世界里,应用程序编程接口(API)的设计至关重要。好的 API 是软件库的骨架,促进不同软件组件之间的交互,使开发者能够高效有效地利用功能。设计良好的 API 直观、易用且可维护,在软件项目的成功和持久性中扮演着关键角色。在本章中,我们将深入探讨为在 C++中开发的库设计可维护 API 的原则和实践。我们将探讨 API 设计的要点,包括清晰性、一致性和可扩展性,并提供具体示例来说明最佳实践。通过理解和应用这些原则,您将能够创建不仅满足用户当前需求,而且随着时间的推移保持稳健和适应性强的 API,确保您的库既强大又用户友好。
简约 API 设计原则
简约 API 旨在提供执行特定任务所需的必要功能,避免不必要的特性和复杂性。主要目标是提供一个干净、高效且用户友好的界面,便于轻松集成和使用。简约 API 的关键优势包括以下内容:
-
易用性:用户可以快速理解和利用 API,无需进行广泛的学习或查阅文档,从而促进更快的开发周期
-
可维护性:简化的 API 更容易维护,允许进行简单的更新和错误修复,而不会引入新的复杂性
-
性能:由于减少了开销和更高效的执行路径,更轻量级的 API 往往具有更好的性能
-
可靠性:由于组件和交互较少,错误和意外问题的可能性最小化,从而使得软件更加可靠和稳定
简洁和清晰是设计简约 API 的基本原则。这些原则确保 API 保持可访问性和用户友好性,从而提升整体开发体验。简洁和清晰的关键方面包括以下内容:
-
直观界面:设计简单明了的界面有助于开发者快速掌握可用功能,使其更容易集成并有效使用 API
-
降低认知负荷:通过最小化理解和使用 API 所需的脑力劳动,开发者犯错误的可能性降低,从而提高开发过程的效率
-
直观设计:遵循简洁和清晰的 API 与常见的使用模式和开发者期望紧密一致,使其更加直观且易于采用
过度设计和不必要的复杂性会严重削弱 API 的有效性。为了避免这些陷阱,请考虑以下策略:
-
关注核心功能:专注于提供解决主要用例的基本功能。避免添加与 API 核心目的不直接相关的额外功能。
-
迭代设计:从最小可行产品(MVP)开始,并根据用户反馈和实际需求逐步添加功能,而不是基于推测性需求。
-
清晰的文档:提供全面而简洁的文档,重点关注核心功能和常见用例。这有助于防止混淆和误用。
-
一致的命名约定:为函数、类和参数使用一致且描述性的名称,以增强清晰性和可预测性。
-
最小依赖性:减少外部依赖项的数量以简化集成过程并最小化潜在的兼容性问题。
实现极简主义的技术
功能分解是将复杂功能分解成更小、更易于管理的单元的过程。这项技术对于创建极简 API 至关重要,因为它促进了简单性和模块化。通过分解函数,你确保 API 的每个部分都有一个清晰、明确的目的,这增强了可维护性和可用性。
功能分解的关键方面包括以下内容:
-
模块化设计:设计 API,使每个模块或函数处理整体功能的一个特定方面。这种关注点分离(SoC)确保 API 的每个部分都有一个清晰、明确的目的。
-
单一职责原则(SRP):每个函数或类应该只有一个,并且只有一个,改变的理由。这一原则有助于保持 API 简单并专注于目标。
-
可重用组件:通过将函数分解成更小的单元,可以创建可重用组件,这些组件可以以不同的方式组合来实现各种任务,从而增强 API 的灵活性和可重用性。
接口分离旨在保持接口精简并专注于特定任务,避免设计试图覆盖过多用例的单一接口。这一原则确保客户端只需了解与他们相关的方 法,使 API 更容易使用和理解。
接口分离的关键方面包括以下内容:
-
特定接口:而不是一个大型、通用接口,设计多个较小、特定的接口。每个接口应针对功能的一个特定方面。
-
以用户为中心的设计:考虑 API 的最终用户的需要。设计直观的接口,只提供他们完成任务所需的方法,避免不必要的复杂性。
-
减少客户端影响:较小的、专注的接口在需要更改时对客户端的影响最小。使用特定接口的客户端不太可能受到无关功能更改的影响。
让我们考虑一个例子,其中复杂的 API 类负责各种功能,如加载、处理和保存数据:
class ComplexAPI {
public:
void initialize();
void load_data_from_file(const std::string& filePath);
void load_data_from_database(const std::string& connection_string);
void process_data(int mode);
void save_data_to_file(const std::string& filePath);
void save_data_to_database(const std::string& connection_string);
void cleanup();
};
主要问题是该类承担了过多的责任,混合了不同的数据源和目的地,导致复杂性和缺乏专注。让我们从将加载和处理功能提取到单独的类开始:
class FileDataLoader {
public:
explicit FileDataLoader(const std::string& filePath) : filePath(filePath) {}
void load() {
// Code to load data from a file
}
private:
std::string filePath;
};
class DatabaseDataLoader {
public:
explicit DatabaseDataLoader(const std::string& connection_string) : _connection_string(connection_string) {}
void load() {
// Code to load data from a database
}
private:
std::string _connection_string;
};
class DataProcessor {
public:
void process(int mode) {
// Code to process data based on the mode
}
};
下一步是将保存功能提取到单独的类中:
class DataSaver {
public:
virtual void save() = 0;
virtual ~DataSaver() = default;
};
class FileDataSaver : public DataSaver {
public:
explicit FileDataSaver(const std::string& filePath) : filePath(filePath) {}
void save() override {
// Code to save data to a file
}
private:
std::string filePath;
};
class DatabaseDataSaver : public DataSaver {
public:
explicit DatabaseDataSaver(const std::string& connection_string) : _connection_string(connection_string) {}
void save() override {
// Code to save data to a database
}
private:
std::string _connection_string;
};
最小化 API 所需的依赖数量对于实现简约至关重要。更少的依赖导致 API 更稳定、可靠和易于维护。依赖关系可能会复杂化集成,增加兼容性问题风险,并使 API 更难理解。
减少依赖的关键策略包括以下内容:
-
核心功能重点:专注于在 API 内部实现核心功能,除非绝对必要,否则避免依赖外部库或组件。
-
选择性使用库:当需要外部库时,选择那些稳定、维护良好且广泛使用的库。确保它们与 API 的需求紧密一致。
-
解耦设计:尽可能设计 API 使其能够独立于外部组件运行。使用依赖注入(DI)或其他设计模式将实现与特定依赖解耦。
-
版本管理:仔细管理和指定任何依赖的版本,以避免兼容性问题。确保依赖的更新不会破坏 API 或引入不稳定性。
简约 API 设计的现实世界示例
为了巩固我们对这些概念的理解,我们将检查几个 C++中 API 设计的现实世界示例。这些示例将突出常见挑战和有效解决方案,展示如何在实际场景中应用良好的 API 设计原则。通过这些示例,我们旨在提供清晰、可操作的见解,您可以将它们应用于自己的项目,确保您的 API 不仅功能齐全,而且优雅且易于维护。让我们深入了解现实世界 API 设计的复杂性,并看看这些原则如何在实践中发挥作用:
-
现代 C++的 JSON(nlohmann/json):这个库是简约 API 设计的优秀示例。它提供了直观且直接的方法来解析、序列化和操作 C++中的 JSON 数据,并具有以下优点:
-
简洁性:清晰简洁的界面,易于使用。
-
功能分解:每个函数处理与 JSON 处理相关的特定任务。
-
最小依赖:设计为与 C++标准库一起工作,避免不必要的外部依赖:
#include <nlohmann/json.hpp>
nlohmann::json j = {
{"pi", 3.141},
{"happy", true},
{"name", "Niels"},
{"nothing", nullptr},
{"answer", {
{"everything", 42}
}},
{"list", {1, 0, 2}},
{"object", {
{"currency", "USD"},
{"value", 42.99}
}}
};
-
-
SQLite C++接口(SQLiteCpp):这个库为使用 C++与 SQLite 数据库交互提供了一个简约的接口。它有以下优点:
-
简单性:提供直观且清晰的数据库操作 API。
-
接口隔离:为不同的数据库操作(如查询和事务)创建不同的类。
-
最小依赖性:构建用于 SQLite 和 C++ 标准库:
#include <SQLiteCpp/SQLiteCpp.h>
SQLite::Database db("test.db", SQLite::OPEN_READWRITE|SQLite::OPEN_CREATE);
db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)");
SQLite::Statement query(db, "INSERT INTO test (value) VALUES (?)");
query.bind(1, "Sample value");
query.exec();
-
常见陷阱及其避免方法
当 API 设计包含不必要的功能或复杂性时,会发生过度复杂化,使其难以使用和维护。以下是减轻这种情况的方法:
- 避免策略:关注最终用户所需的核心功能。定期审查 API 设计,以消除任何不必要的功能。
功能蔓延发生在不断向 API 添加额外功能时,导致复杂性增加和可用性降低。以下是您可以避免这种情况的方法:
- 避免策略:实施严格的特性优先级排序流程。确保新特性与 API 的核心目的相一致,并且对于目标用户来说是必要的。
在 C++ 中开发共享库的重要注意事项
在 C++ 中开发共享库需要仔细考虑以确保兼容性、稳定性和可用性。最初,共享库旨在促进代码重用、模块化和高效内存使用,允许多个程序同时使用相同的库代码。这种方法预计可以减少冗余、节省系统资源,并能够仅替换应用程序的部分。虽然这种方法对于广泛使用的库(如 libc
、libstdc++
、OpenSSL 等)效果良好,但它对于应用程序来说效率较低。与应用程序一起提供的共享库很少能够完美地替换为较新版本。通常,需要替换整个安装套件,包括应用程序及其所有依赖项。
现在,共享库通常用于实现不同编程语言之间的互操作性。例如,C++ 库可能被用于用 Java 或 Python 编写的应用程序中。这种跨语言功能扩展了库的可用性和范围,但同时也引入了某些复杂性和注意事项,开发者必须考虑。
单个项目内的共享库
如果共享库设计为在单个项目中使用,并且由使用相同编译器编译的可执行文件加载,那么具有 C++ 接口的共享对象(或 DLL)通常是可接受的。然而,这种方法存在一些注意事项,例如单例的使用,这可能导致多线程问题和意外的初始化顺序。当使用单例时,在多线程环境中管理它们的初始化和销毁可能具有挑战性,可能导致潜在的竞争条件和不可预测的行为。此外,确保全局状态初始化和销毁的正确顺序很复杂,这可能导致微妙且难以诊断的错误。
用于更广泛分发的共享库
如果预期共享库将被更广泛地分发,开发者无法预测最终用户使用的编译器,或者如果库可能被用于其他编程语言,那么 C++ 共享库并不是一个理想的选择。这主要是因为 C++ 的 libc
或操作系统系统调用,它们也在 C 中。解决这个问题的常见方法是在 C++ 代码周围开发一个 C 封装器,并附带 C 接口。
示例 - MessageSender
类
下面的示例展示了这种方法,其中我们创建了一个 C++ 的 MessageSender
类,并为它提供了一个 C 封装器。该类有一个构造函数,用于使用指定的接收者初始化 MessageSender
实例,并且有两个重载的 send
方法,允许以 std::vector<uint8_t>
实例或指定长度的原始指针的形式发送消息。实现将消息打印到控制台以展示功能。
下面是 C++ 库的实现:
// MessageSender.hpp
#pragma once
#include <string>
#include <vector>
class MessageSender {
public:
MessageSender(const std::string& receiver);
void send(const std::vector<uint8_t>& message) const;
void send(const uint8_t* message, size_t length) const;
};
// MessageSender.cpp
#include "MessageSender.h"
#include <iostream>
MessageSender::MessageSender(const std::string& receiver) {
std::cout << "MessageSender created for receiver: " << receiver << std::endl;
}
void MessageSender::send(const std::vector<uint8_t>& message) const {
std::cout << "Sending message of size: " << message.size() << std::endl;
}
void MessageSender::send(const uint8_t* message, size_t length) const {
std::cout << "Sending message of length: " << length << std::endl;
}
下面是 C 封装器的实现:
// MessageSender.h (C Wrapper Header)
#ifdef __cplusplus
extern "C" {
#endif
typedef void* MessageSenderHandle;
MessageSenderHandle create_message_sender(const char* receiver);
void destroy_message_sender(MessageSenderHandle handle);
void send_message(MessageSenderHandle handle, const uint8_t* message, size_t length);
#ifdef __cplusplus
}
#endif
// MessageSenderC.cpp (C Wrapper Implementation)
#include "MessageSenderC.h"
#include "MessageSender.hpp"
MessageSenderHandle create_message_sender(const char* receiver) {
return new(std::nothrow) MessageSender(receiver);
}
void destroy_message_sender(MessageSenderHandle handle) {
MessageSender* instance = reinterpret_cast<MessageSender*>(handle);
assert(instance);
delete instance;
}
void send_message(MessageSenderHandle handle, const uint8_t* message, size_t length) {
MessageSender* instance = reinterpret_cast<MessageSender*>(handle);
assert(instance);
instance->send(message, length);
}
在这个示例中,C++ 的 MessageSender
类定义在 MessageSender.hpp
和 MessageSender.cpp
文件中。该类有一个构造函数,用于使用指定的接收者初始化 MessageSender
实例,并且有两个重载的 send
方法,允许以 std::vector<uint8_t>
实例或指定长度的原始指针的形式发送消息。实现将消息打印到控制台以展示功能。
为了使这个 C++ 类可以从其他编程语言或不同的编译器中使用,我们创建了一个 C 封装器。C 封装器定义在 MessageSender.h
和 MessageSenderC.cpp
文件中。头文件使用 extern "C"
块来确保 C++ 函数可以从 C 中调用,防止名称修饰。C 封装器使用不透明的句柄 void*
(定义为 MessageSenderHandle
),在 C 中表示 MessageSender
实例,抽象了实际的 C++ 类。
create_message_sender
函数分配并初始化一个 MessageSender
实例,并返回其句柄。请注意,它使用 new(std::nothrow)
以避免在内存分配失败时抛出异常。即使 C 或其他不支持异常的编程语言也可以无问题地使用此函数。
destroy_message_sender
函数释放 MessageSender
实例,以确保正确清理。send_message
函数使用句柄调用 MessageSender
实例上的相应 send
方法,从而简化消息发送过程。
通过在同一个二进制文件内处理内存分配和释放,这种方法避免了与最终用户使用不同内存分配器相关的问题,这些问题可能导致内存损坏或泄漏。C 包装器提供了一个稳定且一致的接口,可以在不同的编译器和语言中使用,确保更高的兼容性和稳定性。这种方法解决了开发共享库的复杂性,并确保它们的广泛可用性和可靠性。
如果预计 C++库会抛出异常,那么在 C 包装器函数中正确处理这些异常是很重要的,以防止异常传播到调用者。例如,我们可以有以下异常类型:
class ConnectionError : public std::runtime_error {
public:
ConnectionError(const std::string& message) : std::runtime_error(message) {}
};
class SendError : public std::runtime_error {
public:
SendError(const std::string& message) : std::runtime_error(message) {}
};
然后,C 包装器函数可以捕获这些异常,并向调用者返回适当的错误代码或消息:
// MessageSender.h (C Wrapper Header)
typedef enum {
OK,
CONNECTION_ERROR,
SEND_ERROR,
} MessageSenderStatus;
// MessageSenderC.cpp (C Wrapper Implementation)
MessageSenderStatus send_message(MessageSenderHandle handle, const uint8_t* message, size_t length) {
try {
MessageSender* instance = reinterpret_cast<MessageSender*>(handle);
instance->send(message, length);
return OK;
} catch (const ConnectionError&) {
return CONNECTION_ERROR;
} catch (const SendError&) {
return SEND_ERROR;
} catch (...) {
std::abort();
}
}
注意,在遇到未知异常时,我们使用std::abort
,因为将未知异常传播到语言边界是不安全的。
这个例子说明了如何创建一个 C 包装器来确保在开发共享库时的兼容性和稳定性。遵循这些指南,开发者可以创建健壮、可维护且广泛兼容的共享库,确保它们在各种平台和编程环境中的可用性。
摘要
在本章中,我们探讨了设计和开发 C++共享库的关键方面。共享库最初是为了促进代码重用、模块化和高效内存使用而设计的,允许多个程序同时利用相同的库代码。这种方法减少了冗余并节省了系统资源。
我们深入探讨了在不同上下文中开发共享库的细微差别。当共享库打算在单个项目中使用并与相同的编译器编译时,具有 C++接口的共享对象(或 DLL)可能是合适的,尽管需要小心处理单例和全局状态,以避免多线程问题和不可预测的初始化顺序。
然而,对于更广泛的分发,如果最终用户的编译器或编程语言可能不同,由于 C++ ABI 在不同编译器和版本之间的不稳定性,直接使用 C++共享库就不太可取。为了克服这一点,我们讨论了在 C++代码周围创建 C 包装器,利用稳定的 C ABI 以实现更广泛的兼容性和跨语言功能。
我们提供了一个使用MessageSender
类的综合示例,说明了如何创建 C++库及其相应的 C 包装器。示例强调了通过确保在同一个二进制文件内进行分配和释放以及通过在 C 接口中以枚举状态表示来优雅地处理异常来安全地管理内存。
通过遵循这些指南,开发者可以创建健壮、可维护且广泛兼容的共享库,确保它们在各种平台和编程环境中的可用性。本章为开发者提供了解决常见问题并在共享库开发中实施最佳实践所需的知识,从而培养出有效且可靠的软件解决方案。
在下一章中,我们将把我们的重点转向代码格式化,探讨创建清晰、一致和可读代码的最佳实践,这对于协作和长期维护至关重要。
第九章:代码格式化和命名约定
在软件开发的广阔而复杂的领域中,一些主题在第一眼看来可能不那么重要,然而,在创建健壮和可维护的软件的更广泛背景下考虑时,它们却具有巨大的价值。代码格式化就是这样一种主题。虽然它可能看起来只是美学上的关注点,但它对提高代码可读性、简化维护以及促进团队成员之间有效协作起着至关重要的作用。这些方面的意义在 C++等语言中更为突出,在这些语言中,结构和语法可以很容易地变得复杂。
在本章中,我们将深入探讨代码格式的细微差别,为你提供对其重要性的全面理解。但理解“为什么”只是第一步;同样重要的是要知道“如何”。因此,我们还将探讨可用于自动格式化你的 C++代码的各种工具,仔细研究它们的功能和可能性,以及如何配置它们以满足你项目的特定需求。从行业标准工具如 Clang-Format 到特定编辑器的插件,我们将探讨如何让这些强大的实用工具为你所用。
到本章结束时,你不仅将深入理解代码格式化为什么是必要的,还将获得在 C++项目中实施一致和有效格式化的实际知识。因此,让我们翻到下一页,开始这段启发性的旅程。
代码格式化为什么重要?
代码格式化在软件开发中的重要性,尤其是在 C++等语言中,不容小觑。让我们从可读性开始,这是至关重要的,因为代码通常被阅读的次数比被编写的次数多。适当的缩进和间距为代码提供了视觉结构,有助于快速理解其流程和逻辑。在一个格式良好的代码库中,更容易扫描代码以识别关键元素,如循环、条件和部分。这反过来又减少了过度注释的需求,因为代码往往变得自我解释。
当谈到可维护性时,一致的代码格式化是一大福音。结构良好的代码更容易调试。例如,一致的缩进可以迅速突出未关闭的大括号或作用域问题,使错误更容易被发现。格式良好的代码还使开发者能够更有效地隔离代码部分,这对于调试和重构都是至关重要的。此外,可维护性不仅仅是关于现在和未来;它关乎代码的未来保障。随着代码库的发展,一致的格式化风格确保了新添加的内容更容易集成。
协作是另一个领域,其中一致的代码格式化发挥着重要作用。在团队环境中,统一的代码风格可以减少每个团队成员的认知负荷。它允许开发者更多地关注代码的逻辑和实现,而不是被风格上的不一致所分散。这在代码审查期间尤其有益,统一的风格使得审查者可以专注于核心逻辑和潜在问题,而不是被不同的格式化风格所干扰。对于新团队成员来说,一致的代码库更容易理解,有助于他们更快地熟悉情况。
此外,代码格式化在质量保证中发挥着作用,并且在某种程度上可以自动化。许多团队利用自动化格式化工具来确保代码库保持一致的风格,这不仅降低了人为错误的可能性,还可以成为代码质量指标的一个因素。代码格式的自动化检查可以集成到 CI/CD 管道中,使其成为项目整体最佳实践的一部分。
最后,我们不要忘记代码格式化对版本控制的影响。一致的编码风格确保版本历史和 diff 准确反映了代码逻辑的变化,而不仅仅是风格调整。这使得使用如git blame
和git history
等工具跟踪更改、识别问题以及理解代码库随时间演变变得更加容易。
总之,适当的代码格式化既具有功能性又具有美学性。它提高了可读性,简化了维护,并促进了协作,所有这些都对开发健壮和可维护的软件的有效和高效发展做出了贡献。
现有工具概述,以促进遵守编码规范
C++开发的世界一直在不断加强对编写干净、可维护代码的关注。这种方法的一个基石是遵守定义良好的编码规范。幸运的是,有几个工具可以帮助自动化这个过程,使开发者能够更多地关注解决实际问题,而不是担心代码的美观。在本节中,我们将广泛探讨一些在 C++项目中强制执行编码规范的最受欢迎和最广泛使用的工具。
cpplint
cpplint 是一个基于 Python 的工具,旨在检查您的 C++代码是否符合谷歌的风格指南,提供了一个不太灵活但高度集中的工具集,用于维护编码规范。如果您或您的团队欣赏谷歌的 C++编码标准,cpplint 提供了一个简单的方法来确保项目中的合规性。
cpplint 附带了一组基于 Google C++风格指南的预定义检查。这些检查涵盖了从文件头到缩进,从变量命名到包含不必要的头文件等多个方面。该工具从命令行执行,其输出提供了关于哪些代码部分违反了指南的明确指导,通常还提供了如何纠正这些问题的提示。
由于基于 Python,cpplint 享有跨平台的优点。你可以轻松地将它集成到 Windows、macOS 和 Linux 等开发环境中,使其成为多团队便捷的选择。
cpplint 的命令行特性使其能够轻松集成到各种开发流程中。它可以包含在预提交钩子中,作为 CI 系统的一部分,甚至可以在开发过程中设定特定的时间间隔运行。几个 IDE 和文本编辑器也提供了插件,可以在文件保存或构建过程中自动运行 cpplint。
虽然它不像一些其他工具那样提供相同级别的定制化,但 cpplint 的优势在于它由 Google 支持,并遵循一个广受尊敬的风格指南。该工具具有广泛的文档,不仅解释了如何使用 cpplint,还深入探讨了特定编码约定的推理,为编写清晰、可维护的 C++代码提供了宝贵的见解。
cpplint 的主要局限性在于其缺乏灵活性。该工具旨在强制执行 Google 的编码标准,并提供有限的定制范围。如果你的项目有独特的格式化要求,或者你在一个已经采用不同约定集的团队中工作,这可能会成为一个缺点。
总之,cpplint 是 C++开发者希望在其项目中采用 Google C++风格指南的专注工具。虽然它可能不像一些其他工具那样提供广泛的定制化功能,但其简单性、易于集成和遵循广受尊敬的编码标准使其成为许多开发团队的宝贵资产。
更多关于 cpplint 的信息可以在官方页面(github.com/google/styleguide/tree/gh-pages/cpplint
)和由爱好者维护的 GitHub 仓库(github.com/cpplint/cpplint
)中找到。
Artistic Style
在代码格式化工具领域,Artistic Style(Astyle)占据着独特的位置。它被设计成一个快速、小巧且最重要的是简单的工具,支持包括 C++在内的多种编程语言。Astyle 的突出特点之一是易于使用,这使得它特别适合小型项目或初次尝试自动化代码格式化的团队。
Astyle 提供了一系列预定义的样式,如 ANSI、GNU 和 Google 等,这些可以作为你项目编码约定的良好起点。此外,它还提供了调整缩进、对齐变量和指针,甚至排序修饰符等选项。这些可以通过命令行选项或配置文件来控制。
Astyle 的一个主要优点是其跨平台性。它可以在 Windows、macOS 和 Linux 上使用,使其成为具有多样化开发环境的团队的多功能选择。
Astyle 的一个显著优点是它易于集成到各种开发流程中。它可以轻松地集成到预提交脚本中,集成到最流行的文本编辑器中,甚至添加到你的持续集成过程中。
尽管 Astyle 可能没有一些其他工具那样广泛的社区,但它已经存在了相当长的时间,并建立了一个稳固的用户基础。它的文档易于理解,即使对于刚开始接触自动化代码格式化概念的人来说,也能提供清晰的指导。
虽然 Astyle 功能丰富,但值得注意的是,它可能不是最适合需要高度专业化格式化规则的超大型或复杂项目的最佳选择。与其他一些工具相比,它提供的定制选项较少,如果你的项目有非常具体的格式化要求,这可能会成为一个限制。
总结来说,Astyle 是一个强大且易于使用的工具,用于自动化 C++项目的代码格式化。它的简单性、易于集成和跨平台支持使其成为许多开发者的吸引选项。无论你是自动代码格式化的新手还是寻找更简单的替代方案,Astyle 都提供了一种简单直接的方式来确保你的代码库遵循一致的编码约定。有关更多信息,请参阅项目的官方页面:astyle.sourceforge.net/astyle.html
。
Uncrustify
当谈到 C++代码格式化的领域时,Uncrustify 因其令人难以置信的定制选项而脱颖而出。这个强大的工具提供了一种粒度,这是其他格式化工具难以匹敌的,使其成为具有高度特定格式化需求的大型和复杂项目的理想选择。如果你喜欢精细调整代码外观的每一方面,那么 Uncrustify 值得你仔细看看。
Uncrustify 支持广泛的格式化选项,允许开发者从缩进级别和括号样式到注释和代码结构的对齐进行自定义。所有这些选项都可以在配置文件中设置,然后可以在开发团队之间共享,以确保格式的一致性。
Uncrustify 是跨平台兼容的,可以轻松用于 Windows、macOS 和 Linux 上的开发环境。它不受任何特定开发环境的限制,并提供多种集成路径。它可以设置为版本控制系统中的预提交钩子,通过插件集成到流行的 IDE 中,甚至可以作为 CI 管道中的一步。由于其命令行特性,将 Uncrustify 集成到各种工具和工作流程中通常很简单。
Uncrustify 有一个活跃的社区,其文档尽管有时被认为内容密集,但非常全面。这为开发者提供了丰富的信息来源,以了解工具的广泛功能。虽然由于其选项数量庞大,配置可能具有挑战性,但众多在线资源和论坛提供了指导、技巧和最佳实践,以充分利用 Uncrustify 的功能。
Uncrustify 最显著的局限性是其复杂性。这个工具的优势——其众多的定制选项——也可能成为一种劣势,尤其是对于不需要如此高配置级别的较小项目或团队来说。此外,陡峭的学习曲线可能成为寻求快速解决方案以实现一致代码格式的团队的障碍。
总结来说,Uncrustify 为那些希望将 C++代码格式调整到极致的人提供了无与伦比的定制水平。其广泛的功能,加上详尽的文档和活跃的社区,使其成为寻求强制执行非常具体编码标准的团队的稳健选择。如果你愿意接受掌握其众多选项的挑战,Uncrustify 可以作为一个无价的工具,用于维护干净和一致的代码库。如需更详细的信息,请参阅官方 GitHub 页面:github.com/uncrustify/uncrustify
。
编辑器插件
在一个开发团队比以往任何时候都更加多样化的时代,依赖单一 IDE 进行代码格式化可能会出现问题。这不仅迫使开发者适应特定的工作环境——可能阻碍他们的表现——而且还在维护不同 IDE 之间的一致代码风格上造成挑战。此外,这种依赖性在将代码格式化集成到 CI/CD 管道中也会引起复杂性。这就是编辑器插件发挥作用的地方,作为一个更灵活和通用的解决方案。
编辑器插件的一个关键优势是它们在多个文本编辑器和 IDE 中的广泛可用性。无论你的团队更喜欢 Visual Studio Code、Sublime Text、Vim 还是 Emacs,很可能有一个插件可以与你的所选代码格式化工具集成。这意味着每个团队成员都可以在他们最舒适的开发环境中工作,而不会牺牲代码的一致性。
编辑器插件通常作为 Clang-Format、Astyle 和 Uncrustify 等独立格式化工具的包装器。这促进了轻松的过渡,特别是如果您的团队已经在使用这些工具之一。这些工具的配置文件可以共享,确保无论使用哪种编辑器,都应用相同的格式化规则。
由于许多编辑器插件利用独立的命令行工具进行代码格式化,它们自然适合 CI/CD 管道。这消除了依赖于 IDE 特定工具的需要,这些工具可能不易适应 CI/CD 系统。使用独立工具,相同的格式化检查可以在本地由开发者执行,也可以在 CI/CD 管道中自动执行,确保全面的一致性。
虽然编辑器插件提供了代码格式化的灵活方法,但它们也带来了一组自己的限制。首先,并非所有编辑器都支持所有可用的格式化工具的全范围,尽管大多数流行的编辑器都有广泛的插件。其次,尽管安装和配置插件通常很简单,但它确实需要团队中的每个开发者进行初始设置。
编辑器插件提供了一种可访问且通用的解决方案,用于在多样化的开发环境中实现代码格式化。它们的灵活性允许团队成员选择他们偏好的编辑器,而不会牺牲代码的一致性,并且它们与独立格式化工具的兼容性使它们非常适合包含在 CI/CD 管道中。对于既重视开发者自主权又重视代码一致性的团队来说,编辑器插件提供了一种平衡且有效的方法。
Clang-Format
当讨论在 C++社区中获得显著关注的代码格式化工具时,Clang-Format 无疑占据了首位。通常被认为是代码格式化的瑞士军刀,这个工具结合了稳健性和丰富的自定义选项。作为本章的宠儿,我们将深入探讨其复杂性,在后续章节中探索其广泛的功能和配置。
在其核心,Clang-Format 旨在自动重新格式化代码,使其符合一组指定的规则。这些规则可以从处理空白和缩进到更复杂的方面,如代码块对齐和注释重新格式化。配置通常通过.clang-format
文件完成,开发者可以以结构化的方式定义他们的样式偏好。
Clang-Format 提供了出色的跨平台支持,在 Windows、macOS 和 Linux 上无缝运行。这确保了无论开发环境如何,您的团队都可以从一致的代码格式化中受益。
Clang-Format 因其易于集成而备受赞誉。它可以直接从命令行调用,包含在脚本中,或通过几乎任何主要文本编辑器或 IDE 的插件使用。这种灵活性确保每个开发者都可以根据自己的选择将其集成到他们的工作流程中。
Clang-Format 的命令行特性也使其能够轻松地融入 CI/CD 流水线。通过将配置文件存储在代码库旁边并进行版本控制,它确保 CI/CD 系统应用与任何本地开发者相同的格式化规则。
在广泛的开发者社区和丰富的文档支持下,Clang-Format 为新用户和经验丰富的用户都提供了丰富的资源。当您寻求解决问题时,这种社区支持尤其有益,或者当您想要自定义复杂的格式化规则时。
考虑到其功能和我对这个工具的个人偏好,本章的后半部分将更深入地探讨 Clang-Format 的世界。从设置您的第一个 .clang-format
文件到探索其一些更高级的功能,我们将介绍如何充分利用这个强大工具所能提供的一切。
Clang-Format 配置 – 深入了解自定义格式化规则
当涉及到配置 Clang-Format 时,可能性几乎是无限的,允许您调整代码外观的每一个最细微的细节。然而,对于新接触这个工具或希望快速采用广泛接受的规则集的人来说,Clang-Format 允许您从现有预设中派生配置。这些预设作为坚实的基石,您可以在其基础上构建适合项目特定需求的定制格式化风格。
利用现有预设
Clang-Format 提供了几个内置预设,这些预设遵循流行的编码标准。以下是一些:
-
LLVM
: 遵循 LLVM 编码标准 -
Google
: 遵循 Google 的 C++ 风格指南 -
Chromium
: 基于 Chromium 的风格指南,是 Google 风格指南的一个变体 -
Mozilla
: 遵循 Mozilla 编码标准 -
WebKit
: 遵循 WebKit 编码标准
要使用这些预设之一,只需在您的 .clang-format
配置文件中设置 BasedOnStyle
选项,如下所示:
BasedOnStyle: Google
这告诉 Clang-Format 以 Google C++ 风格指南为基础,然后应用您指定的任何附加自定义设置。
扩展和覆盖预设
在选择与您团队编码哲学最接近的预设之后,您可以开始自定义特定规则。.clang-format
文件允许您通过在 BasedOnStyle
选项下列出它们来覆盖或扩展预设的规则。例如,一个扩展的 .clang-format
示例可以展示如何微调代码格式的各个方面。以下是一个示例配置文件,它以 Google 风格为基础,然后自定义了几个特定方面,例如缩进宽度、花括号包装和连续赋值的对齐:
---
BasedOnStyle: Google
# Indentation
IndentWidth: 4
TabWidth: 4
UseTab: Never
# Braces
BreakBeforeBraces: Custom
BraceWrapping:
AfterClass: true
AfterControlStatement: false
AfterEnum: true
AfterFunction: true
AfterNamespace: true
AfterStruct: true
AfterUnion: true
BeforeCatch: false
BeforeElse: false
# Alignment
AlignAfterOpenBracket: Align
AlignConsecutiveAssignments: true
AlignConsecutiveDeclarations: true
AlignOperands: true
AlignTrailingComments: true
# Spaces and empty lines
SpaceBeforeParens: ControlStatements
SpaceInEmptyParentheses: false
SpacesInCStyleCastParentheses: false
SpacesInContainerLiterals: true
SpacesInSquareBrackets: false
MaxEmptyLinesToKeep: 2
# Column limit
ColumnLimit: 80
让我们更详细地看看我们在这里选择的一些选项:
-
IndentWidth
和TabWidth
:这些分别设置缩进和制表符的空格数。在这里,UseTab: Never
指定不使用制表符进行缩进。 -
BreakBeforeBraces
和BraceWrapping
:这些选项自定义在类、函数和命名空间等不同情况下在打开花括号之前何时断行。 -
AlignAfterOpenBracket
、AlignConsecutiveAssignments
等等:这些控制各种代码元素(如开括号和连续赋值)的对齐方式。 -
SpaceBeforeParens
、SpaceInEmptyParentheses
等等:这些管理在不同场景中的空格,例如在控制语句中的括号之前或空括号内。 -
MaxEmptyLinesToKeep
:此选项限制要保留的最大连续空行数。 -
ColumnLimit
:此选项设置每行的列限制,以确保代码不超过指定的限制,从而提高可读性。
.clang-format
文件应放置在您项目的根目录中,并提交到您的版本控制系统,以便每个团队成员和您的 CI/CD 管道可以使用相同的配置进行一致的代码格式化。
使用 Clang-Format 忽略特定行
虽然 Clang-Format 是一个在项目内保持一致编码风格的优秀工具,但有时您可能希望保留某些行或代码块不变。幸运的是,Clang-Format 提供了排除特定行或代码块以进行格式化的功能。这对于原始格式对于可读性至关重要或包含不应更改的生成代码的行尤其有用。
要忽略特定的行或代码块,您可以使用特殊的注释标记。在您想要忽略的行或代码块之前放置 // clang-format off
,然后在行或代码块之后使用 // clang-format on
以恢复正常格式化。以下是一个示例:
int main() {
// clang-format off
int variableNameNotFormatted=42;
// clang-format on
int properlyFormattedVariable = 43;
}
在此示例中,Clang-Format 不会修改 int variableNameNotFormatted=42;
,但会应用指定的格式化规则到 int properlyFormattedVariable =
43;
。
这个功能提供了对格式化过程的精细控制,允许你结合自动格式化的好处和特定编码情况下可能需要的细微差别。请随意将此内容包含在你的章节中,以提供一个完整的 Clang-Format 在代码风格管理方面提供的视图。
无尽的配置选项
由于 Clang-Format 基于 Clang 编译器的代码解析器,它可以提供对源代码的最精确分析,因此提供了最无尽的配置选项。可能的设置完整列表可以在官方页面找到:clang.llvm.org/docs/ClangFormatStyleOptions.html
。
版本控制和共享
通常,将你的 .clang-format
文件包含在你的项目的版本控制系统是一个好的做法。这确保了你的团队每个成员以及你的 CI/CD 系统都使用相同的格式化规则集,从而使得代码库更加一致和易于维护。
将 Clang-Format 集成到构建系统中
在今天的软件开发环境中,CMake 作为构建系统的行业事实标准。它提供了一种强大且灵活的方式来管理不同平台和编译器的构建。将 Clang-Format(一个用于自动格式化 C++ 代码的工具)集成到你的 CMake 构建过程中可以帮助确保项目中的代码格式一致性。在本节中,我们将深入探讨如何有效地实现这一点。
首先,你必须使用 CMake 的 find_program()
函数在你的系统上识别 Clang-Format 可执行文件:
# Find clang-format
find_program(CLANG_FORMAT_EXECUTABLE NAMES clang-format)
接下来,你必须收集你希望格式化的所有源文件。file(GLOB_RECURSE ...)
函数对此很有用:
# Gather all source files from the root directory recursively
file(GLOB_RECURSE ALL_SOURCE_FILES
*.cpp
*.cc
*.c++
*.c
*.C
*.h
*.hpp
*.hxx
)
然而,这里有一个小插曲:这种方法也会包括你的构建目录中的文件,你很可能不希望格式化这些文件。这通常也适用于第三方目录。幸运的是,你可以使用 CMake 的 list(FILTER ...)
函数来过滤掉这些文件:
# Exclude files in the build directory
list(FILTER ALL_SOURCE_FILES EXCLUDE REGEX “^${CMAKE_BINARY_DIR}.*”)
最后,你必须创建一个自定义的 CMake 目标,当构建时,它会运行 Clang-Format 对你的源文件进行格式化:
# Create custom target to run clang-format
if(CLANG_FORMAT_EXECUTABLE)
add_custom_target(
clang-format
COMMAND ${CLANG_FORMAT_EXECUTABLE} -i -style=file ${ALL_SOURCE_FILES}
COMMENT “Running clang-format”
)
else()
message(“clang-format not found! Target ‘clang-format’ will not be available.”)
endif()
通过这样做,你可以创建一个名为 clang-format
的自定义目标,开发者可以运行它来自动格式化项目中的所有源文件,同时忽略构建目录中的任何文件。执行此目标可以通过简单的 make clang-format
或 cmake --build . --target clang-format
命令来完成,确保轻松地保持格式的一致性。
在你的构建过程中包含 Clang-Format 和 CMake 的集成不仅有助于保持一致的编码风格,还便于代码审查和协作开发。请随意将这些见解和代码片段纳入你的项目或你正在工作的任何技术文档中。
Clang-Format 报告示例
让我们准备一个简单的例子来演示 Clang-Format 工具的实际应用。我们将创建一个名为 main.cpp
的基本 C++ 源文件,其中包含一些格式问题。然后,我们将对这个文件运行 Clang-Format 以自动纠正格式并生成更改报告:
#include <iostream>
class Sender {
public:
void send(const std::string& message) {
std::cout << “Sending: “ << message << std::endl;
}
};
class Receiver {
public:
void receive(const std::string& message) {
std::cout << “Receiving: “ << message << std::endl;
}
};
class Mediator {
public:
Mediator(Sender sender, Receiver receiver)
: sender_{std::move(sender)}, receiver_{std::move(receiver)} {}
void send(const std::string& message) {
sender_.send(message);
}
void receive(const std::string& message) {
receiver_.receive(message);
}
private:
Sender sender_;
Receiver receiver_;
};
我们将尝试使用 Clang-Format 工具和我们在 .clang-format
中定义的规则集来分析它:
make check-clang-format
[100%] Checking code format with clang-format
/home/user/clang-format/clang_format.cpp:4:2: error: code should be clang-formatted [-Wclang-format-violations]
{
^
/home/user/clang-format/clang_format.cpp:6:42: error: code should be clang-formatted [-Wclang-format-violations]
void send(const std::string& message){
^
/home/user/clang-format/clang_format.cpp:7:18: error: code should be clang-formatted [-Wclang-format-violations]
std::cout<< “Sending: “ <<message<< std::endl;
^
/home/user/clang-format/clang_format.cpp:7:35: error: code should be clang-formatted [-Wclang-format-violations]
std::cout<< “Sending: “ <<message<< std::endl;
^
/home/user/clang-format/clang_format.cpp:7:42: error: code should be clang-formatted [-Wclang-format-violations]
std::cout<< “Sending: “ <<message<< std::endl;
^
/home/user/clang-format/clang_format.cpp:11:6: error: code should be clang-formatted [-Wclang-format-violations]
class
^
/home/user/clang-format/clang_format.cpp:12:9: error: code should be clang-formatted [-Wclang-format-violations]
Receiver {
^
/home/user/clang-format/clang_format.cpp:12:11: error: code should be clang-formatted [-Wclang-format-violations]
Receiver {
^
/home/user/clang-format/clang_format.cpp:14:36: error: code should be clang-formatted [-Wclang-format-violations]
void receive(const std::string&message){
^
/home/user/clang-format/clang_format.cpp:14:44: error: code should be clang-formatted [-Wclang-format-violations]
void receive(const std::string&message){
^
/home/user/clang-format/clang_format.cpp:14:45: error: code should be clang-formatted [-Wclang-format-violations]
void receive(const std::string&message){
^
/home/user/clang-format/clang_format.cpp:16:6: error: code should be clang-formatted [-Wclang-format-violations]
}};
^
/home/user/clang-format/clang_format.cpp:18:15: error: code should be clang-formatted [-Wclang-format-violations]
class Mediator{
^
/home/user/clang-format/clang_format.cpp:18:16: error: code should be clang-formatted [-Wclang-format-violations]
class Mediator{
^
/home/user/clang-format/clang_format.cpp:20:28: error: code should be clang-formatted [-Wclang-format-violations]
Mediator(Sender sender,Receiver receiver)
^
/home/user/clang-format/clang_format.cpp:21:69: error: code should be clang-formatted [-Wclang-format-violations]
: sender_{std::move(sender)}, receiver_{std::move(receiver)} {}
^
/home/user/clang-format/clang_format.cpp:21:71: error: code should be clang-formatted [-Wclang-format-violations]
: sender_{std::move(sender)}, receiver_{std::move(receiver)} {}
^
/home/user/clang-format/clang_format.cpp:22:44: error: code should be clang-formatted [-Wclang-format-violations]
void send(const std::string& message) {sender_.send(message);}
^
/home/user/clang-format/clang_format.cpp:22:66: error: code should be clang-formatted [-Wclang-format-violations]
void send(const std::string& message) {sender_.send(message);}
^
/home/user/clang-format/clang_format.cpp:24:47: error: code should be clang-formatted [-Wclang-format-violations]
void receive(const std::string& message) {
^
/home/user/clang-format/clang_format.cpp:25:36: error: code should be clang-formatted [-Wclang-format-violations]
receiver_.receive(message);
^
/home/user/clang-format/clang_format.cpp:26:6: error: code should be clang-formatted [-Wclang-format-violations]
}
^
/home/user/clang-format/clang_format.cpp:28:11: error: code should be clang-formatted [-Wclang-format-violations]
Sender sender_;
^
make[3]: *** [CMakeFiles/check-clang-format.dir/build.make:71: CMakeFiles/check-clang-format] Error 1
make[2]: *** [CMakeFiles/Makefile2:139: CMakeFiles/check-clang-format.dir/all] Error 2
make[1]: *** [CMakeFiles/Makefile2:146: CMakeFiles/check-clang-format.dir/rule] Error 2
make: *** [Makefile:150: check-clang-format] Error 2
如您所见,错误描述并不详细。然而,大多数时候,开发者可以理解代码中的问题。该工具不仅能够检测问题,还能修复它们。让我们运行工具来修复格式问题 make clang-format
并查看结果:
#include <iostream>
class Sender
{
public:
void send(const std::string& message)
{
std::cout << “Sending: “ << message << std::endl;
}
};
class Receiver
{
public:
void receive(const std::string& message)
{
std::cout << “Receiving: “ << message << std::endl;
}
};
class Mediator
{
public:
Mediator(Sender sender, Receiver receiver)
: sender_{std::move(sender)}, receiver_{std::move(receiver)}
{
}
void send(const std::string& message) { sender_.send(message); }
void receive(const std::string& message) { receiver_.receive(message); }
private:
Sender sender_;
Receiver receiver_;
};
代码现在格式正确,可以用于项目。这个例子可以包含在你的章节中,以展示 Clang-Format 在实际场景中的实际应用。将来,开发者可能会向 .clang-format
文件添加更多格式规则,并通过运行 make clang-format
命令重新格式化整个项目。
扩展 CI 代码格式检查
在设置 CI 管道时,通常只检查代码是否遵守既定的格式规则,而不是自动修改源文件,这样做往往是有益的。这确保了任何不符合风格指南的代码都会被标记出来,提示开发者手动修复。Clang-Format 通过 --dry-run
和 --Werror
选项支持这种用法,当这两个选项结合使用时,如果任何文件被重新格式化,工具将以非零状态码退出。
您可以扩展现有的 CMake 设置,使其包括一个新自定义目标,该目标仅检查代码格式。以下是这样做的方法:
# Create custom target to check clang-format
if(CLANG_FORMAT_EXECUTABLE)
add_custom_target(
check-clang-format
COMMAND ${CLANG_FORMAT_EXECUTABLE} -style=file -Werror --dry-run ${ALL_SOURCE_FILES}
COMMENT “Checking code format with clang-format”
)
else()
message(“clang-format not found! Target ‘check-clang-format’ will not be available.”)
endif()
在这个扩展设置中,已添加一个名为 check-clang-format
的新自定义目标。--dry-run
选项确保没有文件被修改,而 -Werror
会导致 Clang-Format 在发现任何格式差异时以错误代码退出。此目标可以通过 make check-clang-format
或 cmake --build . --target check-clang-format
运行。
现在,在您的 CI 管道脚本中,您可以调用此自定义目标来强制执行代码风格检查。如果代码没有按照指定的指南进行格式化,构建将失败,提醒团队存在需要解决的格式问题。
例如,在我们的 .clang-format
文件中,我们将缩进宽度设置为四个空格,但 main.cpp
文件只使用了两个:
int main() {
return 0;
}
一旦运行检查器,它会显示有问题的代码,但不会更改它:
make check-clang-format
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/clang-format-tidy/build
[100%] Checking code format with clang-format
/home/user/clang-format-tidy/main.cpp:2:13: error: code should be clang-formatted [-Wclang-format-violations]
int main() {
^
make[3]: *** [CMakeFiles/check-clang-format.dir/build.make:71: CMakeFiles/check-clang-format] Error 1
make[2]: *** [CMakeFiles/Makefile2:137: CMakeFiles/check-clang-format.dir/all] Error 2
make[1]: *** [CMakeFiles/Makefile2:144: CMakeFiles/check-clang-format.dir/rule] Error 2
make: *** [Makefile:150: check-clang-format] Error 2
通过将此自定义目标添加到您的 CMake 设置中,您为项目添加了一个额外的质量保证层。这确保了任何违反既定格式指南的代码都无法未经注意地进入代码库。这在多个开发者可能共同参与同一项目的协作环境中尤其有用。请随意将此高级示例及其理由包含在您的技术内容中。
Clang-Format 在各种编辑器中的支持
Clang-Format 在众多文本编辑器和 IDE 中得到了广泛的支持,简化了代码格式化过程,无论你的开发环境如何。将 Clang-Format 直接集成到你的 IDE 或文本编辑器中的一个显著优势是能够轻松调用它,直接从你的开发环境中调用。更好的是,许多编辑器支持在保存文件时自动触发 Clang-Format。这个功能可以极大地提高生产力和代码质量,因为它确保了每个保存的源文件都遵循项目的编码标准,而无需人工干预。
在 Visual Studio Code 中,有一些插件提供了与 Clang-Format 的集成:
-
C/C++, by Microsoft:
marketplace.visualstudio.com/items?itemName=ms-vscode.cpptools
-
Clang-Format, by Xaver Hellauer:
marketplace.visualstudio.com/items?itemName=xaver.clang-format
-
ClangD, by the LLVM (the creators of Clang, Clang-Format, and other tools):
marketplace.visualstudio.com/items?itemName=llvm-vs-code-extensions.vscode-clangd
Vim 和 NeoVim 用户可以利用如 vim-clang-format
的插件来集成 Clang-Format,甚至将其映射到特定的键盘快捷键以实现快速格式化。此外,通常可以通过 LSP 提供器插件或功能来启用它。
对于使用完整版 Visual Studio 的开发者,Clang-Format 集成是内置的;你可以轻松指定一个 .clang-format
配置文件,IDE 将在格式化代码时使用它。
类似地,JetBrains 的 CLion 默认支持 Clang-Format,允许用户直接将 .clang-format
配置文件导入到项目设置中。这种广泛的编辑器支持使得在多样化的开发团队中保持一致的代码格式变得轻而易举,因为每个团队成员都可以使用他们偏好的工具,而不会影响代码质量。
检查命名风格
在仔细格式化我们的代码以确保空格、星号、对齐和括号位置都正确之后,还剩下最后一个要统一的前沿领域——命名风格。确保类、变量、函数和其他标识符的命名约定一致性通常是一个费力的过程,通常被委派给警觉的同行评审。然而,有一种自动化的方法可以实现这一点,从而减少人工努力和错误。
Clang-Tidy 为此目的提供了帮助。虽然我们将在下一章深入探讨 Clang-Tidy 的各种功能,但值得注意的是,它不仅仅是一个代码检查器。它提供了大量的检查,不仅包括语法糖,还包括语义分析和可读性。在命名约定方面,它最有用的功能之一是标识符命名检查。通过配置这个检查,你可以强制执行项目中各种实体的命名规则。
假设你想让你的类、结构体和枚举的名称使用CamelCase
格式,你的命名空间、变量、函数和方法使用lower_case
格式,而你的常量使用UPPER_CASE
格式。此外,你更喜欢私有和受保护的变量后面有一个尾随下划线_
,而公共变量则没有。所有这些要求都可以在一个简单的.clang-tidy
文件中配置,Clang-Tidy 会读取这个文件来强制执行你的命名规则:
---
Checks: ‘readability-identifier-naming’
FormatStyle: file
CheckOptions:
- key: readability-identifier-naming.NamespaceCase
value: ‘lower_case’
- key: readability-identifier-naming.InlineNamespaceCase
value: ‘lower_case’
- key: readability-identifier-naming.EnumCase
value: ‘CamelCase’
- key: readability-identifier-naming.EnumConstantCase
value: ‘UPPER_CASE’
- key: readability-identifier-naming.ClassCase
value: ‘CamelCase’
- key: readability-identifier-naming.StructCase
value: ‘CamelCase’
- key: readability-identifier-naming.ClassMethodCase
value: ‘lower_case’
- key: readability-identifier-naming.FunctionCase
value: ‘lower_case’
- key: readability-identifier-naming.VariableCase
value: ‘lower_case’
- key: readability-identifier-naming.GlobalVariableCase
value: ‘lower_case’
- key: readability-identifier-naming.StaticConstantCase
value: ‘UPPER_CASE’
- key: readability-identifier-naming.PublicMemberCase
value: ‘lower_case’
- key: readability-identifier-naming.ProtectedMemberCase
value: ‘lower_case’
- key: readability-identifier-naming.PrivateMemberCase
value: ‘lower_case’
- key: readability-identifier-naming.PrivateMemberSuffix
value: ‘_’
- key: readability-identifier-naming.ClassMemberCase
value: ‘lower_case’
这些规则可以无限扩展到最高分辨率。现有检查的完整文档可在clang.llvm.org/extra/clang-tidy/checks/readability/identifier-naming.html
找到。
通过将 Clang-Tidy 集成到你的构建过程和 CI 管道中,你可以自动化这些命名约定的强制执行,使代码库更容易阅读、维护和协作。我们将在下一章深入探讨配置和使用 Clang-Tidy 进行各种其他检查。
将 Clang-Tidy 集成到构建系统中
我们可以调整现有的 CMake 设置,使其包括 Clang-Tidy 检查,类似于我们处理 Clang-Format 的方式。以下是一个示例 CMake 脚本,它为在 C++项目上运行 Clang-Tidy 设置了自定义目标:
# Generate compilation database in the build directory
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Find clang-tidy
find_program(CLANG_TIDY_EXECUTABLE NAMES clang-tidy)
# Gather all source files from the root directory recursively
file(GLOB_RECURSE ALL_SOURCE_FILES
*.cpp
*.cc
*.c++
*.c
*.C
*.h
*.hpp
*.hxx
)
# Exclude files in the build directory
list(FILTER ALL_SOURCE_FILES EXCLUDE REGEX “^${CMAKE_BINARY_DIR}.*”)
# Create custom target to run clang-tidy
if(CLANG_TIDY_EXECUTABLE)
add_custom_target(
clang-tidy
COMMAND ${CLANG_TIDY_EXECUTABLE} -p=${CMAKE_BINARY_DIR} ${ALL_SOURCE_FILES}
COMMENT “Running clang-tidy”
)
else()
message(“clang-tidy not found! Target ‘clang-tidy’ will not be available.”)
endif()
# Create custom target to check clang-tidy
if(CLANG_TIDY_EXECUTABLE)
add_custom_target(
check-clang-tidy
COMMAND ${CLANG_TIDY_EXECUTABLE} -p=${CMAKE_BINARY_DIR} --warnings-as-errors=* ${ALL_SOURCE_FILES}
COMMENT “Checking code quality with clang-tidy”
)
else()
message(“clang-tidy not found! Target ‘check-clang-tidy’ will not be available.”)
endif()
在这个脚本中,我们使用find_program
定位clang-tidy
可执行文件。类似于 Clang-Format 的设置,然后我们递归地收集根目录下的所有源文件,确保排除构建目录中的文件。
在这里添加了两个自定义目标:
-
clang-tidy
:这个目标会在所有收集到的源文件上运行 Clang-Tidy。-p=${CMAKE_BINARY_DIR}
标志指定包含compile_commands.json
文件的构建目录,Clang-Tidy 使用这个文件进行检查。这个 JSON 文件由 CMake 生成,包含有关项目中每个源文件如何编译的信息。它包括编译器选项、包含目录、定义等信息。Clang-Tidy 使用这些信息来理解每个源文件的构建上下文,从而允许它执行更准确和有意义的检查。 -
check-clang-tidy
:这个目标执行相同的操作,但带有--warnings-as-errors=*
标志。这将把所有警告视为错误,这对于 CI/CD 管道确保代码质量特别有用。
与您之前的设置一样,运行这些自定义目标可以通过 make clang-tidy
或 make check-clang-tidy
或其等效的 cmake --build . --target clang-tidy
和 cmake --build . --target check-clang-tidy
来完成。
通过将 Clang-Tidy 集成到您的 CMake 构建过程中,您将提供另一层自动代码质量检查,就像您使用 Clang-Format 所做的那样。请随意将其包含在您的章节中,以全面了解自动代码质量保证。
使用 Clang-Tidy 检查源代码命名风格
现在我们已经成功配置了 Clang-Tidy 的规则并将工具集成到我们的 CMake 构建系统中,是时候进行实际测试了。为此,我们将使用一段故意违反我们已建立的命名约定的 C++ 代码:
#include <string>
#include <vector>
namespace Filesystem { // CamelCase instead of lower_case
enum class Permissions : uint8_t { READ, WRITE, execute };
struct User {
std::string name_; // redundant suffix _ for public member
int Id = 0; // CamelCase instead of lower_case
Permissions permissions;
};
class file { // lower_case instead of CamelCase
public:
file(int id, const std::string &file_name,
const std::vector<User> access_list)
: id{id}, FileName_{file_name}, access_list_{access_list} {}
int GetId() const // CamelCase instead of lower_case
{
return id;
}
auto &getName() const // camelBack instead of lower_case
{
return FileName_;
}
const std::vector<User> &access_list() const { return access_list_; }
private:
int id; // missing suffix _
std::string FileName_; // CamelCase instead of lower_case
std::vector<User> access_list_;
};
} // namespace Filesystem
int main() {
auto user = Filesystem::User{};
user.name_ = “user”;
user.permissions = Filesystem::Permissions::execute;
auto file = Filesystem::file{0, “~/home/user/file”, {user}};
return 0;
}
当我们运行 make clang-tidy
时,Clang-Tidy 将迅速行动,扫描违规代码,并在终端输出中直接标记任何命名问题。这里只提供了部分输出以节省空间:
make check-clang-tidy
[100%] Checking code quality with clang-tidy
9 warnings generated.
/home/user/clang-format-tidy/main.cpp:4:11: error: invalid case style for namespace ‘Filesystem’ [readability-identifier-naming,-warnings-as-errors]
4 | namespace Filesystem { // CamelCase instead of lower_case
| ^~~~~~~~~~
| filesystem
/home/user/clang-format-tidy/main.cpp:6:49: error: invalid case style for enum constant ‘execute’ [readability-identifier-naming,-warnings-as-errors]
6 | enum class Permissions : uint8_t { READ, WRITE, execute };
| ^~~~~~~
| EXECUTE
/home/user/clang-format-tidy/main.cpp:9:17: error: invalid case style for public member ‘name_’ [readability-identifier-naming,-warnings-as-errors]
9 | std::string name_; // redundant suffix _ for public member
| ^~~~~
| name
/home/user/clang-format-tidy/main.cpp:10:9: error: invalid case style for public member ‘Id’ [readability-identifier-naming,-warnings-as-errors]
10 | int Id = 0; // CamelCase instead of lower_case
| ^~
| id
/home/user/clang-format-tidy/main.cpp:14:7: error: invalid case style for class ‘file’ [readability-identifier-naming,-warnings-as-errors]
14 | class file { // lower_case instead of CamelCase
| ^~~~
| File
15 | public:
16 | file(int id, const std::string &file_name,
| ~~~~
| File
/home/user/clang-format-tidy/main.cpp:20:9: error: invalid case style for function ‘GetId’ [readability-identifier-naming,-warnings-as-errors]
20 | int GetId() const // CamelCase instead of lower_case
| ^~~~~
| get_id
9 warnings treated as errors
这个练习展示了将 Clang-Tidy 集成到构建过程中的实际好处。它不仅识别了代码中与既定命名约定的偏差,而且还提供了立即纠正的机会。这是维护一个不仅功能性强而且结构一致的代码库的宝贵步骤。建议将 make clang-tidy
命令包含到您的 CI 流程中。通过这样做,您可以自动验证提交到您的存储库的每个提交的命名约定和其他代码风格规则。这将有助于确保任何对代码库的新贡献都符合既定指南。如果提交未通过 Clang-Tidy 检查,CI 流程可以将其标记为待审阅,从而更容易维护一致、高质量的代码库。这一额外的自动化层消除了对这些问题的手动检查需求,从而简化了代码审查过程,并使您的开发工作流程更加高效。
自动修复命名问题
Clang-Tidy 的真正力量在于它不仅能够识别问题,还能够自动修复它们。手动修复可能耗时且容易出错,在快节奏的开发环境中,自动化变得极其宝贵。幸运的是,Clang-Tidy 在这个领域表现出色。工具建议的大多数修复都可以自动应用,为您节省了大量手动劳动和潜在的错误。要应用这些自动修复,只需在您的终端中运行 make clang-tidy
。该工具将扫描代码中的违规行为,并在可能的情况下自动纠正代码,使其与您配置的指南保持一致:
#include <string>
#include <vector>
namespace filesystem { // CamelCase instead of lower_case
enum class Permissions : uint8_t { READ, WRITE, EXECUTE };
struct User {
std::string name; // redundant suffix _ for public member
int id = 0; // CamelCase instead of lower_case
Permissions permissions;
};
class File { // lower_case instead of CamelCase
public:
File(int id, const std::string &file_name,
const std::vector<User> access_list)
: id_{id}, file_name_{file_name}, access_list_{access_list} {}
int get_id() const // CamelCase instead of lower_case
{
return id_;
}
auto &get_name() const // camelBack instead of lower_case
{
return file_name_;
}
const std::vector<User> &access_list() const { return access_list_; }
private:
int id_; // missing suffix _
std::string file_name_; // CamelCase instead of lower_case
std::vector<User> access_list_;
};
} // namespace filesystem
int main() {
auto user = filesystem::User{};
user.name = “user”;
user.permissions = filesystem::Permissions::EXECUTE;
auto file = filesystem::File{0, “~/home/user/file”, {user}};
return 0;
}
注意,不仅类、方法和变量的定义已更新,而且对它们的引用也已更新。这一功能使 Clang-Tidy 不仅是一个诊断工具,而且在维护代码库的整体质量方面也是一个宝贵的助手。
重要注意事项
使用 Clang-Tidy 时有一些重要的注意事项需要考虑。让我们看看:
-
单个实例与多个实例: 我们讨论的 CMake 配置运行单个 Clang-Tidy 实例来检查和修复所有源文件。虽然这可能对较小的项目足够,但对于具有众多检查的大型代码库来说,可能会成为瓶颈。在这种情况下,将源代码划分为逻辑组并并行运行多个 Clang-Tidy 实例可能更有效率。这种策略可以显著减少扫描整个代码库所需的时间。
-
在修复前提交: 虽然 Clang-Tidy 自动修复问题的能力非常有价值,但建议仅在已提交到您的版本控制系统的代码上使用此功能。Clang-Tidy 提供的一些检查可能不稳定,而且在极少数情况下甚至可能引入错误。提前提交您的代码可以确保您有一个稳定的点可以回退,以防事情出错。
-
User
结构体使用 C++20 指定初始化列表进行初始化,如下例所示:auto user = Filesystem::User{
.name_ = “user”, .permissions = Filesystem::Permissions::execute};
Clang-Tidy 将修复
name_
变量和execute
常量在其定义中,但将完全忽略初始化器,这最终会导致编译错误。
了解这些注意事项可以使您更有效地使用 Clang-Tidy,并利用其优势同时减轻潜在风险。
示例项目
对于那些希望深入了解细节并亲身体验 Clang-Tidy 和 Clang-Format 的配置和使用的人来说,GitHub 上有一个包含 CMake 设置和代码片段的示例项目(github.com/f-squirrel/clang-format-tidy
)。这将使您更好地理解将这些工具集成到您的 C++ 项目中的细微差别和实际应用。您可以自由地克隆存储库,实验代码,甚至进一步贡献以增强它。
Clang-Tidy 在各种编辑器中的支持
Clang-Tidy 和 Clang-Format 的 IDE 和编辑器支持大致相似,这使得它同样易于访问并集成到您的开发工作流程中。这种集成支持的优点是它提供的即时反馈循环。当您编码时,Clang-Tidy 的警告和错误将直接在您的 IDE 中显示,让您无需离开开发环境就能发现潜在的问题。这对于实时维护代码质量而不是作为单独的步骤来说非常有价值。
此外,许多 IDE 也提供了从编辑器内部直接应用 Clang-Tidy 自动修复的界面,这使得遵守您的编码标准比以往任何时候都更容易。例如,以下 Visual Studio Code 的截图显示了内联警告:
图 9.1 – 警告
以下截图显示了可以应用于它们的修复:
图 9.2 – 可应用修复
这种实时、在编辑器中的反馈机制可以显著提高你的生产力和代码质量,使 Clang-Tidy 不仅仅是一个静态代码分析工具,而是你编码过程中的一个重要组成部分。
摘要
在本章中,我们探讨了自动化代码质量维护的基本领域,特别关注代码格式和命名约定。我们首先概述了现有的可以帮助强制执行编码标准的工具,然后聚焦于 Clang-Format 和 Clang-Tidy 作为这些问题的全面解决方案。我们不仅学习了如何使用这些工具自动检查和修复我们的代码,还学习了如何无缝地将它们集成到我们的构建系统、CI 管道和代码编辑器中。
通过这样做,我们为确保我们的代码保持一致并遵循最佳实践奠定了坚实的基础,这一切都只需最少的手动干预。这为下一章的深入探讨静态代码分析领域做好了完美的铺垫,进一步巩固了我们致力于高质量、可维护代码的承诺。
第十章:C++中的静态分析简介
在复杂和需求高的软件开发世界中,确保代码的质量和可靠性不仅是一种必要性,而且本身就是一个学科。作为 C++开发者,我们不断寻求可以帮助我们实现这一目标的方法和工具。本章致力于一种这样的强大方法:静态分析。以其既快又便宜地识别错误而闻名,静态分析在软件质量保证过程中是一个支柱。我们将深入研究其复杂性,探讨 Clang-Tidy、PVS-Studio 和 SonarQube 等流行工具,并了解如何有效地将静态分析集成到您的 C++开发工作流程中。
静态分析的本质
静态分析是对源代码的检查,而不执行它。这个过程通常由各种工具自动化,涉及扫描代码以识别潜在的错误、代码异味、安全漏洞和其他问题。它类似于一个彻底的校对会话,在代码运行之前对代码的质量和可靠性进行审查。
为什么进行静态分析?以下是原因:
-
速度和成本效益:静态分析的主要优势是其速度和成本效益。它可能是找到错误最快和最便宜的方法。自动化问题的检测大大减少了与手动代码审查和其他测试方法相比所需的时间和精力。在开发周期早期发现并解决错误可以显著降低修复成本,如果在生产后期发现错误,修复成本会急剧上升。
-
预执行错误检测:静态分析在代码执行之前进行,使其成为软件质量保证中的主动措施。这种预执行分析允许开发者在不设置测试环境或处理代码运行的复杂性的情况下,识别和纠正问题。
-
编码标准执行:它有助于保持一致的编码标准,确保代码库遵循 C++编程的最佳实践和约定。这种执行不仅提高了代码质量,还增强了可维护性和可读性。
-
全面覆盖:凭借扫描整个代码库的能力,静态分析提供了一种通过手动方法难以达到的彻底性。这种全面的覆盖确保了代码的任何部分都不会被忽视。
-
安全和可靠性:早期发现安全漏洞是另一个关键好处。静态分析通过捕捉可能否则直到被利用才被注意到的漏洞,对应用程序的安全性和可靠性做出了重大贡献。
-
教育方面:它还起到教育作用,增强开发者对 C++的理解,并使他们熟悉常见的陷阱和最佳实践。
在接下来的章节中,我们将探讨如何在 C++项目中充分利用静态分析。在此之后,在下一章中,我们将比较和对比这些见解与动态分析,提供一个完整的 C++软件开发分析景观图。
利用较新版本的编译器进行增强的静态分析
尽管生产环境通常因稳定性、兼容性等原因而要求使用特定、有时较旧的编译器版本,但定期使用较新版本的编译器构建项目具有巨大的价值。这种做法作为一种前瞻性的静态分析策略,利用了最新编译器版本中的进步和改进。
较新的编译器版本通常配备了增强的分析能力、更复杂的警告机制和对 C++标准的更新解释。它们可以识别出较旧编译器可能忽略的问题和潜在代码改进。通过使用这些尖端工具进行编译,开发者可以主动发现并解决代码库中的潜在问题,确保代码保持健壮并符合不断发展的 C++标准。
此外,这种方法还提供了对生产编译器最终更新时可能出现的潜在问题的预览。它提供了对未来证明代码库的机会,使过渡到较新编译器版本更加顺畅和可预测。
在本质上,将较新的编译器版本纳入构建过程,即使它们不用于生产构建,也是一种战略措施。这不仅通过高级静态分析提升了代码质量,而且为代码库未来的技术转变做好了准备,确保了持续改进和为进步做好准备的状态。
加固 C++代码的编译器设置
在追求健壮和安全的 C++代码的过程中,配置编译器设置起着关键作用。编译器标志和选项可以通过启用更严格的错误检查、警告和安全功能显著提高代码质量。本节重点介绍 C++生态系统中的三个主要编译器的推荐设置:GNU 编译器集合(GCC)、Clang 和Microsoft Visual C++(MSVC)。这些设置在静态分析环境中尤其有价值,因为它们能够在编译时检测到潜在问题。
GCC
GCC 以其广泛的选项而闻名,这些选项可以帮助加固 C++代码。关键标志包括以下内容:
-
-Wall -Wextra
:启用大多数警告消息,捕获潜在问题,如未初始化的变量、未使用的参数等 -
-Werror
:将所有警告视为错误,强制它们得到解决 -
-Wshadow
:当局部变量遮蔽另一个变量时发出警告,这可能导致令人困惑的错误 -
-Wnon-virtual-dtor
: 如果一个类有虚函数但没有虚析构函数,将发出警告,这可能导致未定义的行为 -
-pedantic
: 强制执行严格的 ISO C++ 兼容性,拒绝非标准代码 -
-Wconversion
: 对于可能改变值的隐式转换发出警告,对于防止数据丢失很有用 -
-Wsign-conversion
: 对于可能改变值符号的隐式转换发出警告
Clang
Clang,作为 LLVM 项目的组成部分,与 GCC 共享许多标志,但也提供了额外的检查和生成更易读警告的声誉:
-
-Weverything
: 启用 Clang 中所有可用的警告,对代码进行全面检查。这可能会让人感到不知所措,因此通常与选择性禁用不太关键的警告一起使用。 -
-Werror
,-Wall
,-Wextra
,-Wshadow
,-Wnon-virtual-dtor
,-pedantic
,-Wconversion
, 和-Wsign-conversion
:与 GCC 类似,这些标志也适用于 Clang,并服务于相同的目的。 -
-Wdocumentation
: 对于文档不一致性发出警告,这对于维护具有大量注释的大型代码库很有用。 -
-fsanitize=address
,-fsanitize=undefined
: 启用AddressSanitizer
和UndefinedBehaviorSanitizer
来捕获内存损坏和未定义行为问题。
MSVC
MSVC,虽然有一组不同的标志,但也提供了增强代码安全性的强大选项:
-
/W4
: 启用更高的警告级别,类似于 GCC/Clang 中的-Wall
。这包括大多数针对常见问题的有用警告。 -
/WX
: 将所有编译器警告视为错误。 -
/sdl
: 启用额外的安全检查,例如缓冲区溢出检测和整数溢出检查。 -
/GS
: 提供缓冲区安全检查,有助于防止常见的安全漏洞。 -
/analyze
: 启用静态代码分析,以在编译时检测内存泄漏、未初始化变量和其他潜在错误。
通过利用这些编译器设置,开发者可以显著增强他们的 C++ 代码,使其更加安全、健壮,并符合最佳实践。虽然编译器的默认设置可以捕获许多问题,但启用这些附加标志确保了对代码进行更严格和更彻底的分析。需要注意的是,虽然这些设置可以极大地提高代码质量,但它们应该与良好的编程实践和定期的代码审查相结合,以获得最佳结果。在下一章中,我们将把重点转向动态分析,这是确保 C++ 应用程序整体质量和安全性的另一个关键组成部分。
通过多个编译器进行静态分析
在 C++ 开发领域,利用编译器的静态分析功能是一种经常被低估的策略。例如 GCC 和 Clang 这样的编译器配备了大量的编译标志,这些标志能够实现严格的静态分析,有助于在不需要额外工具的情况下识别潜在问题。使用这些标志不仅方便,而且对于提高代码质量非常有效。
我提倡的一个最佳实践是使用多个编译器构建 C++ 项目。每个编译器都有其独特的诊断工具集,通过使用多个编译器,项目可以更全面地了解潜在问题。GCC 和 Clang 在支持的标志相似性以及广泛支持各种架构和操作系统方面特别引人注目。这种兼容性使得将两者集成到项目的构建过程中进行交叉检查成为可能。
然而,在 Windows 环境中实施这些实践可能会带来额外的挑战。虽然 GCC 和 Clang 功能强大,但项目通常也受益于 MSVC 提供的独特诊断。MSVC 与 Windows 生态系统无缝集成,并为代码分析提供了不同的视角,这对于针对 Windows 平台的项目尤其有益。尽管管理多个编译器可能会引入一些复杂性,但识别更广泛潜在问题的回报是宝贵的。通过采用这种多编译器方法,项目可以显著提高其静态分析的严谨性,从而产生更健壮和可靠的 C++ 代码。
突出编译器差异 – GCC 与 Clang 中的未使用私有成员
在 C++ 开发中,对不同编译器的诊断能力有细微的理解可能至关重要。这一点在 GCC 和 Clang 处理未使用的私有成员变量方面得到了体现。考虑以下类定义:
#include <iostream>
class NumberWrapper {
int number;
public:
NumberWrapper() {
}
};
在这里,NumberWrapper
类中的 number
私有成员被初始化但从未使用。这种情况在代码中可能表示潜在的问题,表明存在冗余。
让我们比较 GCC 和 Clang 如何处理未使用的私有成员:
-
number
未使用的私有成员。这种缺少警告可能会导致无意中忽视类设计中效率低下或冗余的问题。 -
warning: private field 'number' is not used
。这个精确的诊断有助于及时识别和解决类实现中潜在的错误。
突出编译器差异 – 编译器对未初始化变量的检查
在处理 C++ 中的类变量时,确保适当的初始化对于防止未定义行为至关重要。这一点在不同编译器检测未初始化但被使用的变量方面得到了强调。考虑以下 NumberWrapper
类的例子:
#include <iostream>
class NumberWrapper {
int number;
public:
NumberWrapper(int n) {
(void)n; // to avoid warning: unused parameter 'n'
std::cout << "init with: " << number << std::endl;
}
};
int main() {
auto num = NumberWrapper{1};
(void) num;
return 0;
}
在此代码中,number
成员变量未初始化,当它在构造函数中使用时会导致未定义的行为。它可能会打印出类似 init
with: 32767
的内容。
我们现在将比较 GCC 和 Clang 在此方面的方法:
-
warning: 'num.NumberWrapper::number' is used uninitialized
。这个警告作为对开发者的一个重要警报,将注意力引向使用未初始化变量的风险,这可能导致程序行为不可预测或出现微妙的错误。 -
Clang 的诊断方法:有趣的是,Clang 版本 17 对同一代码不生成警告,这可能在仅使用 Clang 的环境中导致这种疏忽未被发现。这表明,仅依赖 Clang 可能会错过 GCC 可以捕获的某些错误类别。
之前讨论的两个示例提供了对 GCC 和 Clang 诊断能力的独特优势和细微差别的深刻见解。这些实例——一个突出了 Clang 标记未使用私有字段的能力,另一个展示了 GCC 在警告未初始化类变量方面的熟练程度——证明了在 C++ 开发中使用多编译器策略的重要性。
通过利用 Clang 和 GCC,开发者可以充分利用更全面和多样化的静态分析过程。每个编译器都有一套独特的警告和检查,可以揭示不同的潜在问题或优化。Clang 以其详细和具体的警告而闻名,例如标记未使用的私有字段,这补充了 GCC 对基本但关键问题(如未初始化的变量)的警觉性检查。这种编译器之间的协同作用确保了对代码的更彻底审查,从而导致了更高品质、更可靠和易于维护的软件。
从本质上讲,Clang 和 GCC 的结合并不仅仅是它们各自能力的总和;它为静态分析创造了一个更强大和全面的生态环境。随着 C++ 语言及其编译器的持续发展,保持适应性和开放性,以接受多种静态分析工具,对于追求工艺卓越的开发者来说,这是一种最佳实践。这种方法与软件开发中始终存在的目标相吻合:编写干净、高效且无错误的代码。
探索 Clang-Tidy 的静态分析
随着我们进一步深入静态分析领域,一个因其多功能性和深度而脱颖而出的工具是 Clang-Tidy。由 LLVM 基金会开发,Clang 编译器的背后组织,Clang-Tidy 是一个用于 C++ 代码的代码检查器和静态分析工具。它超越了传统编译器检查的能力,提供了一系列诊断,包括样式错误、编程错误,甚至是在常规代码审查中经常被忽视的微妙错误。之前,我们探讨了 Clang-Tidy 如何擅长代码格式化;现在,我们将探讨其在静态分析方面的能力,揭示其能够以确保不仅符合规范,而且在编码标准上达到卓越水平的能力。
Clang-Tidy 通过使用 Clang 前端解析 C++ 代码来工作,使其能够深入理解代码的结构和语法。这种深入理解使 Clang-Tidy 能够执行复杂的检查,这些检查超越了简单的文本分析,检查代码的语义甚至执行流程。它不仅仅是寻找语法差异;它关于理解代码的行为和意图。
Clang-Tidy 检查的分类
Clang-Tidy 将其检查分为几个组,每个组针对特定类型的问题。让我们分解这些类别,并探索每个类别的示例:
-
性能检查:专注于识别可能导致执行速度降低的代码中的低效模式;例如,不必要的对象复制。Clang-Tidy 可以标记出对象被复制,但可以移动或通过引用传递以避免复制的开销:
#include <vector>
std::vector<int> createLargeVector();
void processVector(std::vector<int> vec);
int main() {
std::vector<int> vec = createLargeVector();
processVector(vec); // Clang-Tidy: Use std::move to avoid copying
return 0;
}
-
使用基于范围的
for
循环的for
循环以提高可读性和安全性:std::vector<int> myVec = {1, 2, 3};
for (std::size_t i = 0; i < myVec.size(); ++i) {
// Clang-Tidy: Use a range-based for loop instead
std::cout << myVec[i] << std::endl;
}
-
错误检测:识别代码中的潜在错误或逻辑错误;例如,检测空指针解引用:
int* ptr = nullptr;
int value = *ptr; // Clang-Tidy: Dereference of null pointer
-
样式检查:强制执行特定的编码风格以保持一致性和可读性;例如,强制执行变量命名约定:
int MyVariable = 42; // Clang-Tidy: Variable name should be lower_case
-
可读性检查:专注于使代码更易于理解和维护;例如,简化复杂的布尔表达式:
bool a, b, c;
if (a && (b || c)) {
// Clang-Tidy: Simplify logical expression
}
-
安全性检查:针对潜在的安全漏洞;例如,突出显示已知可能带来安全风险的危险函数的使用:
strcpy(dest, src); // Clang-Tidy: Use of function 'strcpy' is insecure
通过自定义检查扩展 Clang-Tidy 的功能
Clang-Tidy 的多功能性通过其自定义检查的支持得到进一步增强,允许公司和项目根据其特定的需求和编码标准定制静态分析。这种定制能力导致了各种检查类别的创建,每个类别都与不同组织或项目的指南相一致。接下来,我们将探讨一些显著的例子:
-
google-runtime-references
强制执行 Google 对指针而非非 const 引用的偏好。 -
Google 的 Abseil 检查:Abseil 是由 Google 开发的 C++库代码的开源集合。针对 Abseil 的检查确保遵守库的最佳实践,例如避免某些已弃用的函数或类。
-
Fuchsia 检查:针对 Fuchsia 操作系统定制,这些检查强制执行 Fuchsia 项目的特定编码标准和最佳实践。它们有助于保持贡献给该操作系统的代码库的一致性和质量。
-
Zircon 检查:Zircon 是 Fuchsia OS 的核心平台。Clang-Tidy 包括针对 Zircon 开发的定制检查,重点关注其独特的架构和开发标准。
-
Darwin 检查:这些检查专门为 Darwin 设计,Darwin 是苹果公司发布的开源类 Unix 操作系统。它们确保符合 Darwin 的开发实践。
-
LLVM 检查(llvm-):这些检查旨在强制执行 LLVM 编码标准。它们对于向 LLVM 或其子项目贡献的开发者特别有用。
-
C++ Core Guidelines 检查:Clang-Tidy 包括强制执行 C++ Core Guidelines 的检查,这是一套编写现代 C++的最佳实践。这包括类型安全、资源管理和性能的规则。
-
cppcoreguidelines-pro-type-member-init
确保类成员得到正确初始化。cppcoreguidelines-pro-type-reinterpret-cast
警告使用reinterpret_cast
,鼓励使用更安全的转换替代方案。 -
cppcoreguidelines-non-private-member-variables-in-classes
不鼓励在类中使用非私有成员变量以维护封装性。 -
cppcoreguidelines-avoid-magic-numbers
帮助识别可能没有明显意义的硬编码数字,促进可读性和可维护性。 -
cppcoreguidelines-avoid-c-arrays
和cppcoreguidelines-avoid-non-const-global-variables
促进使用现代 C++构造,如std::array
或std::vector
,而不是 C 风格数组,并鼓励不使用非 const 全局变量。 -
cppcoreguidelines-pro-bounds-array-to-pointer-decay
和cppcoreguidelines-pro-bounds-constant-array-index
警告常见的陷阱,这些陷阱可能导致越界(OOB)错误。 -
cppcoreguidelines-owning-memory
指导开发者何时以及如何使用智能指针,如std::unique_ptr
或std::shared_ptr
。 -
五条规则和零规则检查:Clang-Tidy 在 C++类设计中强制执行五条规则和零规则,确保管理资源的类正确实现拷贝和移动构造函数/赋值运算符,或者分别避免手动管理资源。
-
cppcoreguidelines-special-member-functions
(确保特殊成员函数的正确实现)和cppcoreguidelines-interfaces-global-init
(避免全局初始化顺序问题)。通过 Clang-Tidy 检查遵守 C++ 核心指南可以显著提高 C++ 代码的质量,使其更加健壮、易于维护,并与现代 C++ 实践保持一致。这些检查涵盖了广泛的最佳实践,并且通常被认为对于大多数 C++ 项目来说都是好的遵循,特别是那些旨在有效利用现代 C++ 特性的项目。
-
检查包的标准合规性:Clang-Tidy 提供了帮助确保符合某些高级标准的“包”检查:
-
高性能 C++ (hi-cpp):这些检查侧重于确保代码针对性能进行了优化。
-
认证:对于需要遵守特定认证标准的项目(例如 MISRA、CERT 等),Clang-Tidy 提供了帮助代码与这些标准对齐的检查,尽管需要注意的是,仅使用这些检查可能不足以完全符合此类认证。
-
能够添加自定义检查意味着 Clang-Tidy 不仅是一个静态分析工具,还是一个可以适应各种编码标准和实践的平台。这种适应性使其成为从开源库到商业软件等不同项目的理想选择,每个项目都有其独特的需求和标准。通过利用这些专业检查,团队可以确保他们的代码不仅遵循 C++ 的一般最佳实践,而且与项目或组织的具体指南和细微差别保持一致。
微调 Clang-Tidy 以进行定制静态分析
有效地配置 Clang-Tidy 对于在 C++ 项目中充分利用其全部潜力至关重要。这不仅仅涉及启用和禁用某些检查,还包括控制代码的特定部分如何进行分析。通过自定义其行为,开发者可以确保 Clang-Tidy 的输出既相关又可操作,专注于代码库最重要的方面。让我们更详细地看看这一点:
-
使用
--checks=
选项启用特定的检查,并使用-
作为前缀来禁用其他检查。例如,为了打开性能检查同时关闭特定的一个,你可能使用以下命令:clang-tidy my_code.cpp --checks='performance-*, -performance-noexcept-move-constructor'
-
NOLINT
注释用于抑制特定代码行的所有警告。这是一种宽泛的方法,可能会隐藏比预期更多的内容:int x = 0; // NOLINT
-
NOLINT(check-name)
用于抑制特定的警告。这种方法更可取,因为它可以防止过度抑制可能有用的警告:int x = 0; // NOLINT(bugprone-integer-division)
-
--warnings-as-errors=
选项。这可以应用于全局或特定检查:clang-tidy my_code.cpp --warnings-as-errors='bugprone-*'
- 在项目的根目录下创建
.clang-tidy
文件。此文件应列出启用的检查和其他配置,如下例所示:
Checks: 'performance-*, -performance-noexcept-move-constructor'
WarningsAsErrors: 'bugprone-*'
- 在项目的根目录下创建
正确配置 Clang-Tidy 对于在 C++ 中进行有效的静态分析至关重要。通过选择性启用检查、在必要时特别抑制警告,并将关键警告视为错误,团队可以保持高代码质量标准。通过使用特定的抑制注释逐行微调分析的能力,确保 Clang-Tidy 提供了专注且相关的反馈,使其成为 C++ 开发人员工具箱中的无价之宝。
静态分析工具概述 – 将 PVS-Studio、SonarQube 和其他工具与 Clang-Tidy 进行比较
静态分析工具对于确保代码质量和遵守最佳实践至关重要。虽然 Clang-Tidy 是这个领域的突出工具,尤其是在 C++ 项目中,但还有其他重要的工具,如 PVS-Studio 和 SonarQube,每个工具都有其独特的功能和优势。让我们将这些工具与 Clang-Tidy 进行比较,并提及一些其他值得注意的选项。
PVS-Studio
使用 PVS-Studio 有以下优势:
-
重点:PVS-Studio 以其深入的分析能力而闻名,尤其是在检测潜在错误、安全漏洞和符合编码标准方面。
-
支持的语言:虽然 Clang-Tidy 主要关注 C 和 C++,但 PVS-Studio 支持更广泛的编程语言,包括 C#、Java,甚至混合 C/C++/C# 代码库。
-
集成和使用:PVS-Studio 可以集成到各种 IDE 和 持续集成(CI)系统中。它与 Clang-Tidy 的不同之处在于它是一个独立工具,不依赖于特定编译器,如 Clang。
-
独特功能:它提供了对潜在代码漏洞的广泛检查,并因其详细的诊断信息而经常受到赞誉。
SonarQube
SonarQube 提供以下优势:
-
重点:SonarQube 提供了一套全面的代码质量检查,包括错误、代码异味和安全漏洞
-
支持的语言:它支持广泛的编程语言,使其成为多语言项目的多功能选择
-
集成和使用:SonarQube 以其基于 Web 的仪表板脱颖而出,提供了对代码质量的详细概述,与 Clang-Tidy 相比提供了更全面的视角
-
独特功能:它包括代码覆盖率和技术债务估计功能,这些不是 Clang-Tidy 的主要关注点
其他值得注意的工具
该领域其他值得注意的工具包括以下:
-
Cppcheck:专注于 C 和 C++ 语言,Cppcheck 是一个静态分析工具,强调检测未定义行为、危险的编码结构以及其他其他工具可能错过的微妙错误。它轻量级且可以很好地补充 Clang-Tidy。
-
Coverity:以其高准确性和对广泛编程语言的支持而闻名,Coverity 是一种在商业和开源项目中用于检测软件缺陷和安全漏洞的工具。
-
Visual Studio 静态分析:集成到 Visual Studio IDE 中,此工具提供针对 Windows 开发的特定检查。对于主要在 Windows 生态系统中工作的开发者来说,它非常有用。
与 Clang-Tidy 的比较
现在让我们比较一下上述工具与 Clang-Tidy:
-
语言支持:Clang-Tidy 主要关注 C 和 C++,而像 PVS-Studio、SonarQube 和 Coverity 这样的工具支持更广泛的语言。
-
集成和报告:Clang-Tidy 与 LLVM/Clang 生态系统紧密集成,使其成为使用这些工具的项目的一个优秀选择。相比之下,SonarQube 提供了全面的仪表板,而 PVS-Studio 提供了详细的报告,这对大型项目或团队来说是有益的。
-
特定用例:像 Cppcheck 和 Visual Studio 静态分析这样的工具具有特定的细分市场——Cppcheck 因其轻量级特性和对 C/C++ 的关注,而 Visual Studio 静态分析则因其针对 Windows 的特定检查而闻名。
-
商业与开源:Clang-Tidy 是开源的,免费使用,而像 Coverity 和 PVS-Studio 这样的工具是商业产品,提供企业级功能和支持。
摘要
在本章中,我们深入探讨了 C++ 开发的静态分析世界,探索了各种工具和方法。我们从 Clang-Tidy 的概述开始,Clang-Tidy 是由 LLVM 基金会开发的,它具有检查代码性能问题、现代化、错误、风格、可读性和安全性的广泛功能。我们还涵盖了静态分析领域中的其他重要工具,包括以漏洞检测和多语言支持著称的 PVS-Studio、提供全面代码质量检查和直观仪表板的 SonarQube,以及其他如 Cppcheck、Coverity 和 Visual Studio 静态分析的工具,每个工具都为桌面提供了独特的优势。
重点关注了配置 Clang-Tidy,详细说明了如何根据特定项目需求进行微调,例如启用或禁用诊断、管理警告以及设置配置文件。我们还讨论了工具的可扩展性,强调了针对不同编码标准和符合各种要求的定制检查以及认证包。
这次探索使我们更广泛地了解了 C++ 静态分析领域,揭示了这些工具如何显著提高代码质量。随着本章的结束,我们准备在下一章中转换方向,我们将探索动态分析领域,通过了解运行代码的行为来补充我们对静态分析的知识。这将完成我们对掌握 C++ 代码质量所需工具和技术的全面审视。
第十一章:动态分析
在软件开发的错综复杂世界中,确保代码的正确性、效率和安全性不仅是一个目标,更是一种必要性。这在 C++编程中尤其如此,因为该语言的力量和复杂性既提供了机会,也带来了挑战。在 C++中保持高代码质量的最有效方法之一是动态代码分析——这是一个审查程序在运行时行为的过程,以检测一系列潜在问题。
动态代码分析与静态分析形成对比,后者在执行代码之前检查源代码。虽然静态分析在开发周期早期捕获语法错误、代码异味和某些类型的错误方面非常有价值,但动态分析则更深入。它揭示了仅在程序实际执行过程中才会出现的问题,例如内存泄漏、竞态条件和可能导致崩溃、异常行为或安全漏洞的运行时错误。
本章旨在探索 C++中动态代码分析工具的领域,特别关注行业中一些最强大和最广泛使用的工具:一套基于编译器的清理器,包括AddressSanitizer(ASan)、ThreadSanitizer(TSan)和UndefinedBehaviorSanitizer(UBSan),以及 Valgrind,这是一个以其详尽的内存调试能力而闻名的多功能工具。
编译器清理器,作为 LLVM 项目和 GCC 项目的一部分,为动态分析提供了一系列选项。ASan 因其能够检测各种内存相关错误而著称,TSan 在识别多线程代码中的竞态条件方面表现出色,而 UBSan 有助于捕捉可能导致程序行为不可预测的未定义行为。这些工具因其效率、精确性和易于集成到现有开发工作流程中而受到赞誉。其中大多数都得到了 GCC 和 MSVC 的支持。
另一方面,Valgrind,一个用于构建动态分析工具的仪器框架,凭借其全面的内存泄漏检测和分析二进制可执行文件的能力(无需重新编译源代码),而显得格外耀眼。它是在深入内存分析至关重要的复杂场景下的首选解决方案,尽管这会带来更高的性能开销。
在本章中,我们将深入研究这些工具的每一个,了解它们的优点、缺点和适当的用例。我们将探讨如何有效地将它们集成到你的 C++开发过程中,以及它们如何相互补充,为确保 C++应用程序的质量和可靠性提供一个强大的框架。
到本章结束时,你将全面理解 C++中的动态代码分析,并具备选择和利用适合你特定开发需求工具的知识,最终导致编写更清洁、更高效、更可靠的 C++代码。
基于编译器的动态代码分析
基于编译器的清理器包含两部分:编译器仪器化和运行时诊断:
-
编译器仪器化: 当你使用清理器编译你的 C++ 代码时,编译器会对生成的二进制文件进行额外的检查。这些检查被策略性地插入到代码中,以监控特定类型的错误。例如,ASan 会添加代码来跟踪内存分配和访问,使其能够检测内存误用,如缓冲区溢出和内存泄漏。
-
运行时诊断: 当被仪器化的程序运行时,这些检查会积极监控程序的行为。当一个清理器检测到错误(如内存访问违规或数据竞争)时,它会立即报告,通常还会提供关于错误位置和性质的详细信息。这种实时反馈对于识别和修复难以通过传统测试捕获的隐蔽错误非常有价值。
尽管所有编译器团队都在不断努力添加新的清理器并改进现有的清理器,但基于编译器的清理器仍然存在一些限制:
-
Clang 和 GCC: 大多数清理器,包括 ASan、TSan 和 UBSan,都由 Clang 和 GCC 支持。这种广泛的支持使得它们对大量 C++ 开发社区成员可访问,无论他们偏好的编译器是什么。
-
Microsoft Visual C++ (MSVC): MSVC 也支持一些清理器,尽管其范围和能力可能与 Clang 和 GCC 不同。例如,MSVC 支持 ASan,这对于 Windows 特定的 C++ 开发很有用。
-
跨平台实用工具: 这些工具的跨编译器和跨平台特性意味着它们可以在各种开发环境中使用,从 Linux 和 macOS 到 Windows,增强了它们在多样化的 C++ 项目中的实用性。
ASan
ASan 是一个运行时内存错误检测器,是 LLVM 编译器基础设施、GCC 和 MSVC 的一部分。它作为开发者识别和解决各种内存相关错误的专用工具,包括但不限于缓冲区溢出、悬垂指针访问和内存泄漏。该工具通过在编译过程中对代码进行仪器化来实现这一点,使其能够在运行时监控内存访问和分配。
ASan 的一个关键优势是它能够提供详细的错误报告。当检测到内存错误时,ASan 会输出全面的信息,包括错误类型、涉及的内存位置和堆栈跟踪。这种详细程度大大有助于调试过程,使开发者能够快速定位问题的根源。
将 ASan 集成到 C++ 开发工作流程中非常简单。它需要对构建过程进行最小的更改,通常涉及在编译期间添加编译器标志(-fsanitize=address
)。为了获得更好的结果,使用合理的性能选项(-O1
或更高)是有意义的。为了在错误消息中获得更好的堆栈跟踪,请添加 -fno-omit-frame-pointer
。这种易于集成的便利性,加上其捕获内存错误的有效性,使 ASan 成为开发者增强其 C++ 应用程序可靠性和安全性的不可或缺的工具。
在 ASan 中符号化报告
当使用 ASan 在 C++ 应用程序中检测内存错误时,符号化错误报告至关重要。符号化将 ASan 输出的内存地址和偏移量转换为人类可读的函数名、文件名和行号。这个过程对于有效的调试至关重要,因为它允许开发者轻松地识别内存错误在源代码中的确切位置。
没有符号化,ASan 报告提供的是不太有意义的原始内存地址,这使得难以追踪到源代码中错误发生的确切位置。另一方面,符号化报告提供了清晰且可操作的信息,使开发者能够快速理解和修复代码中的潜在问题。
ASan 符号化的配置通常是自动的,不需要额外的步骤。然而,在某些情况下,你可能需要显式设置 ASAN_SYMBOLIZER_PATH
环境变量,以便指向符号化工具。这在非 Linux Unix 系统上尤其如此,在这些系统上可能需要额外的工具,如 addr2line
,来进行符号化。如果它不能直接工作,请按照以下步骤进行,以确保符号化配置正确:
-
在编译命令中使用
-g
标志。例如:clang++ -fsanitize=address -g -o your_program your_file.cpp
-
使用
-g
编译选项会在二进制文件中包含调试符号,这对于符号化是必不可少的。 -
llvm-symbolizer
工具位于你的系统PATH
中。 -
addr2line
(GNU Binutils 的一部分)可用于符号化堆栈跟踪。 -
将
ASAN_SYMBOLIZER_PATH
环境变量指向符号化工具。例如:export ASAN_SYMBOLIZER_PATH=/path/to/llvm-symbolizer
-
这明确告诉 ASan 使用哪个符号化工具。
-
运行 你的程序:
-
按照常规运行编译后的程序。如果检测到内存错误,ASan 将输出符号化的堆栈跟踪。
-
报告将包括函数名、文件名和行号,这使得更容易定位和解决代码中的错误。
-
越界访问
让我们尝试捕捉 C++ 编程中最关键的错误之一:越界访问。这个问题跨越了内存管理的各个部分——堆、栈和全局变量,每个部分都提出了独特的挑战和风险。
堆中的越界访问
我们首先探讨堆上的越界访问,动态内存分配可能导致指针超出分配的内存边界。考虑以下示例:
int main() {
int *heapArray = new int[5];
heapArray[5] = 10; // Out-of-bounds write on the heap
delete[] heapArray;
return 0;
}
此代码片段演示了越界写入,尝试访问超出分配范围的索引,导致未定义行为和潜在的内存损坏。
如果我们启用 ASan 运行此代码,我们得到以下输出:
make && ./a.out
=================================================================
==3102850==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x603000000054 at pc 0x55af5525f222 bp 0x7ffde596fb60 sp 0x7ffde596fb50
WRITE of size 4 at 0x603000000054 thread T0
#0 0x55af5525f221 in main /home/user/clang-sanitizers/main.cpp:3
#1 0x7f1ad0a29d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
#2 0x7f1ad0a29e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
#3 0x55af5525f104 in _start (/home/user/clang-sanitizers/build/a.out+0x1104)
0x603000000054 is located 0 bytes to the right of 20-byte region 0x603000000040,0x603000000054)
allocated by thread T0 here:
#0 0x7f1ad12b6357 in operator new[ ../../../../src/libsanitizer/asan/asan_new_delete.cpp:102
#1 0x55af5525f1de in main /home/user/clang-sanitizers/main.cpp:2
#2 0x7f1ad0a29d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
SUMMARY: AddressSanitizer: heap-buffer-overflow /home/user/clang-sanitizers/main.cpp:3 in main
Shadow bytes around the buggy address:
0x0c067fff7fb0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c067fff7fc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c067fff7fd0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c067fff7fe0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0c067fff7ff0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0c067fff8000: fa fa 00 00 00 fa fa fa 00 00[04]fa fa fa fa fa
0x0c067fff8010: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c067fff8020: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c067fff8030: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c067fff8040: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x0c067fff8050: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
Shadow gap: cc
==3102850==ABORTING
如您所见,报告包括详细的堆栈跟踪,突出显示源代码中错误的精确位置。这些信息对于调试和修复问题非常有价值。
栈上的越界访问
接下来,我们关注栈。在这里,由于索引错误或不正确的缓冲区溢出,越界访问通常与局部变量有关。例如:
int main() {
int stackArray[5];
stackArray[5] = 10; // Out-of-bounds write on the stack
return 0;
}
在这种情况下,访问 stackArray[5]
超出了范围,因为有效的索引是从 0
到 4
。此类错误可能导致崩溃或可利用的漏洞。ASan 对此示例的输出与上一个示例非常相似:
==3190568==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd166961e4 at pc 0x55b4cd113295 bp 0x7ffd166961a0 sp 0x7ffd16696190
WRITE of size 4 at 0x7ffd166961e4 thread T0
#0 0x55b4cd113294 in main /home/user/clang-sanitizers/main.cpp:3
#1 0x7f90fc829d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
#2 0x7f90fc829e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
#3 0x55b4cd113104 in _start (/home/user/clang-sanitizers/build/a.out+0x1104)
Address 0x7ffd166961e4 is located in stack of thread T0 at offset 52 in frame
#0 0x55b4cd1131d8 in main /home/user/clang-sanitizers/main.cpp:1
This frame has 1 object(s):
[32, 52) ‘stackArray’ (line 2) <== Memory access at offset 52 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
(longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow /home/user/clang-sanitizers/main.cpp:3 in main
Shadow bytes around the buggy address:
0x100022ccabe0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100022ccabf0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100022ccac00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100022ccac10: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100022ccac20: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x100022ccac30: 00 00 00 00 00 00 f1 f1 f1 f1 00 00[04]f3 f3 f3
0x100022ccac40: f3 f3 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100022ccac50: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100022ccac60: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100022ccac70: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x100022ccac80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
Shadow gap: cc
==3190568==ABORTING
全局变量的越界访问
最后,我们检查全局变量。当访问超出其定义边界时,它们也容易受到类似的风险。例如:
int globalArray[5];
int main() {
globalArray[5] = 10; // Out-of-bounds access to a global array
return 0;
}
在这里,尝试写入 globalArray[5]
的操作是一个越界操作,导致未定义行为。由于 ASan 的输出与之前的示例相似,我们在此不包括它。
解决 C++ 中的 Use-After-Free 漏洞
在下一节中,我们将解决 C++ 编程中的一个关键且经常具有挑战性的问题:Use-After-Free 漏洞。此类错误发生在程序在释放内存后继续使用该内存位置时,导致未定义行为、程序崩溃、安全漏洞和数据损坏。我们将从各种上下文中探讨此问题,提供有关其识别和预防的见解。
动态内存(堆)中的 Use-After-Free
Use-After-Free 错误最常见的情况是在堆上的动态分配内存。考虑以下示例:
#include <iostream>
template <typename T>
struct Node {
T data;
Node *next;
Node(T val) : data(val), next(nullptr) {}
};
int main() {
auto *head = new Node(1);
auto *temp = head;
head = head->next;
delete temp;
std::cout << temp->data; // Use-after-free in a linked list
return 0;
}
在此片段中,ptr
所指向的内存在使用 delete
释放后进行访问。这种访问可能导致不可预测的行为,因为已释放的内存可能被分配用于其他目的或被系统修改。
使用对象引用的 Use-After-Free
Use-After-Free 也可以在面向对象编程中发生,尤其是在处理已销毁的对象的引用或指针时。例如:
class Example {
public:
int value;
Example() : value(0) {}
};
Example* obj = new Example();
Example& ref = *obj;
delete obj;
std::cout << ref.value; // Use-after-free through a reference
在这里,ref
指向一个已被删除的对象,并且在删除后对 ref
的任何操作都会导致 Use-After-Free。
在复杂数据结构中的 Use-After-Free
复杂数据结构,如链表或树,也容易发生 Use-After-Free 错误,尤其是在删除或重构操作期间。例如:
struct Node {
int data;
Node* next;
Node(int val) : data(val), next(nullptr) {}
};
Node* head = new Node(1);
Node* temp = head;
head = head->next;
delete temp;
std::cout << temp->data; // Use-after-free in a linked list
在这种情况下,temp
在释放后被使用,这可能导致严重问题,尤其是如果列表很大或属于关键系统组件的一部分。
ASan 可以帮助检测 C++ 程序中的使用后释放错误。例如,如果我们启用 ASan 运行前面的示例,我们得到以下输出:
make && ./a.out
Consolidate compiler generated dependencies of target a.out
[100%] Built target a.out
=================================================================
==3448347==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010 at pc 0x55fbcc2ca3b2 bp 0x7fff2f3af7a0 sp 0x7fff2f3af790
READ of size 4 at 0x602000000010 thread T0
#0 0x55fbcc2ca3b1 in main /home/user/clang-sanitizers/main.cpp:15
#1 0x7efdb6429d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
#2 0x7efdb6429e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
#3 0x55fbcc2ca244 in _start (/home/user/clang-sanitizers/build/a.out+0x1244)
ASan 中的使用后返回检测
使用后返回 是 C++ 编程中的一种内存错误,其中函数返回一个指向局部(栈分配)变量的指针或引用。这个局部变量一旦函数返回就不再存在,通过返回的指针或引用的任何后续访问都是无效且危险的。这可能导致未定义行为和潜在的安全漏洞。
ASan 提供了一种检测使用后返回错误的机制。它可以在编译时使用 -fsanitize-address-use-after-return
标志进行控制,并在运行时使用 ASAN_OPTIONS
环境变量。
以下描述了使用后返回检测的配置:
-
-fsanitize-address-use-after-return=(never|runtime|always)
该标志接受三个设置:
-
never
: 这禁用使用后返回检测 -
runtime
: 这启用检测,但可以在运行时被覆盖(默认设置) -
always
: 这始终启用检测,不受运行时设置的影响
-
-
ASAN_OPTIONS
环境变量:-
ASAN_OPTIONS=detect_stack_use_after_return=1
-
ASAN_OPTIONS=detect_stack_use_after_return=0
-
在 Linux 上,默认启用检测
-
这里是其使用的一个示例:
-
启用 使用后返回检测编译:
clang++ -fsanitize=address -fsanitize-address-use-after-return=always -g -o your_program your_file.cpp
此命令使用 ASan 编译
your_file.cpp
并显式启用使用后返回检测。 -
启用/禁用检测运行:
-
要在启用使用后返回检测的情况下运行程序(在默认情况下不是的平台):
ASAN_OPTIONS=detect_stack_use_after_return=1 ./your_program
-
要禁用检测,即使它在编译时已启用:
ASAN_OPTIONS=detect_stack_use_after_return=0 ./your_program
-
示例代码 演示使用后返回
提供的 C++ 代码示例演示了使用后返回场景,这是一种由于从函数返回局部变量的引用而引起的未定义行为。让我们分析这个示例并了解其影响:
#include <iostream>
const std::string &get_binary_name() {
const std::string name = “main”;
return name; // Returning address of a local variable
}
int main() {
const auto &name = get_binary_name();
// Use after return: accessing memory through name is undefined behavior
std::cout << name << std::endl;
return 0;
}
在给定的代码示例中,get_binary_name
函数被设计为创建一个名为 name
的局部 std::string
对象,并返回对其的引用。关键问题源于 name
是一个局部变量,它在函数作用域结束时就会被销毁。因此,get_binary_name
返回的引用在函数退出时立即变得无效。
在 main
函数中,现在存储在 name
中的返回引用被用来访问字符串值。然而,由于 name
指向一个已经被销毁的局部变量,以这种方式使用它会导致未定义行为。这是一个使用后返回错误的经典例子,其中程序试图访问不再有效的内存。
函数的预期功能似乎是要返回程序名称。然而,为了正确工作,name
变量应该具有静态或全局生命周期,而不是被限制在 get_binary_name
函数中的局部变量。这将确保返回的引用在函数作用域之外仍然有效,避免使用后返回错误。
现代编译器配备了发出关于潜在问题代码模式的警告的能力,例如返回对局部变量的引用。在我们的示例中,编译器可能会将返回局部变量引用标记为警告,表明可能存在使用后返回错误。
然而,为了有效地展示 ASan 捕获使用后返回错误的能力,有时需要绕过这些编译时警告。这可以通过显式禁用编译器的警告来实现。例如,通过在编译命令中添加 -Wno-return-local-addr
标志,我们可以防止编译器发出关于返回局部地址的警告。这样做使我们能够将重点从编译时检测转移到运行时检测,在那里 ASan 在识别使用后返回错误方面的能力可以更加突出和测试。这种方法强调了 ASan 的运行时诊断优势,尤其是在编译时分析可能不足的情况下。
使用 ASan 编译
要使用 ASan 的使用后返回检测编译此程序,您可以使用以下命令:
clang++ -fsanitize=address -Wno-return-local-addr -g your_file.cpp -o your_program
此命令在启用 ASan 的同时抑制了关于返回局部变量地址的特定编译器警告。运行编译后的程序将允许 ASan 在运行时检测并报告使用后返回错误:
Consolidate compiler generated dependencies of target a.out
[100%] Built target a.out
AddressSanitizer:DEADLYSIGNAL
=================================================================
==4104819==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000008 (pc 0x7f74e354f4c4 bp 0x7ffefcd298e0 sp 0x7ffefcd298c8 T0)
==4104819==The signal is caused by a READ memory access.
==4104819==Hint: address points to the zero page.
#0 0x7f74e354f4c4 in std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std::char_traits<char>, std::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) (/lib/x86_64-linux-gnu/libstdc++.so.6+0x14f4c4)
#1 0x559799ab4785 in main /home/user/clang-sanitizers/main.cpp:11
#2 0x7f74e3029d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
#3 0x7f74e3029e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
#4 0x559799ab4504 in _start (/home/user/clang-sanitizers/build/a.out+0x2504)
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV (/lib/x86_64-linux-gnu/libstdc++.so.6+0x14f4c4) in std::basic_ostream<char, std::char_traits<char> >& std::operator<< <char, std::char_traits<char>, std::allocator<char> >(std::basic_ostream<char, std::char_traits<char> >&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
==4104819==ABORTING
这个例子强调了理解 C++ 中对象生命周期的重要性以及误用可能导致未定义行为。虽然编译器警告在编译时捕获此类问题很有价值,但像 ASan 这样的工具提供了额外的运行时错误检测层,这在复杂场景中尤其有用,在这些场景中,编译时分析可能不足以满足需求。
使用后返回检测
C++ 中使用后作用域的概念涉及在作用域结束后访问变量,导致未定义行为。这种类型的错误很微妙,可能特别难以检测和调试。ASan 提供了一种检测使用后作用域错误的功能,可以使用 -fsanitize-address-use-after-scope
编译标志来启用。
理解使用后作用域
使用后作用域发生在程序继续使用超出作用域的变量的指针或引用时。与使用后返回不同,后者的问题在于函数局部变量,使用后作用域可以发生在任何作用域内,例如在代码块中,如 if
语句或循环中。
当一个变量超出作用域时,其内存位置可能仍然保留旧数据一段时间,但这个内存随时可能被覆盖。访问这个内存是未定义行为,可能导致程序行为异常或崩溃。
配置 ASan 以进行 超出作用域检测
-fsanitize-address-use-after-scope
:
-
将此标志添加到您的编译命令中,指示 ASan 对代码进行操作以检测超出作用域错误。
-
重要的是要注意,这种检测默认是未启用的,必须显式启用。
示例代码 展示超出作用域
提供的代码片段展示了 C++中超出作用域错误的经典案例。让我们分析代码并了解问题:
int* create_array(bool condition) {
int *p;
if (condition) {
int x[10];
p = x;
}
*p = 1;
}
在给定的代码片段中,我们首先声明了一个未初始化的p
指针。然后函数进入一个条件作用域,如果condition
为真,则在栈上创建一个x[10]
数组。在这个作用域内,p
指针被分配为指向这个数组的起始位置,实际上使p
指向x
。
关键问题出现在条件块退出之后。此时,作为if
块局部变量的x
数组超出作用域,不再有效。然而,p
指针仍然持有x
之前所在位置的地址。当代码尝试使用*p = 1;
写入这个内存位置时,它试图访问当前作用域内不再有效的x
数组内存。这种行为导致了一个超出作用域错误,其中p
被解引用以访问当前作用域内不再有效的内存。这种错误是超出作用域的经典例子,突出了通过指向超出作用域变量的指针访问内存的危险性。
通过指向超出作用域变量的指针访问内存,如提供的代码片段所示,会导致未定义行为。这是因为一旦x
变量超出作用域,p
指针指向的内存位置就变得不确定。由此场景产生的未定义行为存在几个问题。
首先,这对程序的安全性和稳定性构成了重大风险。行为的未定义性质意味着程序可能会崩溃或行为不可预测。程序执行中的这种不稳定性可能产生深远的影响,尤其是在可靠性至关重要的应用中。此外,如果x
之前占用的内存位置被程序的其它部分覆盖,可能会潜在地导致安全漏洞。这些漏洞可能被利用来损害程序或其运行的系统。
总结来说,通过指向超出作用域变量的指针访问内存导致的未定义行为在软件开发中是一个严重的问题,需要仔细管理变量作用域和内存访问模式,以确保程序的安全性和稳定性。
要编译启用 ASan 使用范围之外检测的程序,你可以使用以下命令之一:
g++ -fsanitize=address -fsanitize-address-use-after-scope -g your_file.cpp -o your_program
使用这些设置运行编译后的程序可以启用 ASan 在运行时检测并报告使用范围之外的错误。
由于它们依赖于程序的运行时状态和内存布局,使用范围之外的错误可能难以察觉和追踪。通过在 ASan 中启用使用范围之外的检测,开发者可以获得一个宝贵的工具,用于识别这些错误,从而创建更健壮和可靠的 C++ 应用程序。理解和预防此类问题是编写安全且正确 C++ 代码的关键。
双重释放和无效释放检查在 ASan 中
ASan 是 LLVM 项目的一部分,提供了强大的机制来检测和诊断 C++ 程序中的两种关键类型的内存错误:双重释放和无效释放。这些错误不仅常见于复杂的 C++ 应用程序,还可能导致严重的程序崩溃、未定义行为和安全漏洞。
理解双重释放和 无效释放
理解双重释放和无效释放错误对于有效地管理 C++ 程序中的内存至关重要。
当尝试使用 delete
或 delete[]
操作符多次释放内存块时,就会发生双重释放错误。这种情况通常发生在相同的内存分配被两次传递给 delete
或 delete[]
的情况下。第一次调用 delete
释放了内存,但第二次调用试图释放已经释放的内存。这可能导致堆损坏,因为程序可能会随后修改或重新分配已释放的内存以供其他用途。双重释放错误可能导致程序出现不可预测的行为,包括崩溃和数据损坏。
另一方面,当使用 delete
或 delete[]
操作一个未使用 new
或 new[]
分配,或已经释放的指针时,就会发生无效释放错误。这一类别包括尝试释放空指针、指向栈内存(这些不是动态分配的)或指向未初始化内存的指针。像双重释放错误一样,无效释放也可能导致堆损坏和不可预测的程序行为。它们尤其危险,因为它们可能会破坏 C++ 运行时的内存管理结构,导致微妙且难以诊断的错误。
这两种错误都源于对动态内存的不当处理,强调了遵守内存管理最佳实践的重要性,例如确保每个 new
都有一个相应的 delete
,并在释放指针后避免其重用。
此列表概述了 ASan 检测机制的功能:
-
执行
delete
操作时,ASan 会检查该指针是否对应一个有效、先前分配且尚未释放的内存块。 -
错误报告:如果检测到双重释放或无效释放错误,ASan 会终止程序的执行并提供详细的错误报告。此报告包括错误发生的代码位置、涉及的内存地址以及该内存的分配历史(如果可用)。
这里有一些示例代码演示双重释放错误:
int main() {
int* ptr = new int(10);
delete ptr;
delete ptr; // Double-free error
return 0;
}
ASan 会报告以下错误:
make && ./a.out
Consolidate compiler generated dependencies of target a.out
[ 50%] Building CXX object CMakeFiles/a.out.dir/main.cpp.o
[100%] Linking CXX executable a.out
[100%] Built target a.out
=================================================================
==765374==ERROR: AddressSanitizer: attempting double-free on 0x602000000010 in thread T0:
#0 0x7f7ff5eb724f in operator delete(void*, unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:172
#1 0x55839eca830b in main /home/user/clang-sanitizers/main.cpp:6
#2 0x7f7ff5629d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
#3 0x7f7ff5629e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
#4 0x55839eca81c4 in _start (/home/user/clang-sanitizers/build/a.out+0x11c4)
0x602000000010 is located 0 bytes inside of 4-byte region [0x602000000010,0x602000000014)
freed by thread T0 here:
#0 0x7f7ff5eb724f in operator delete(void*, unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:172
#1 0x55839eca82f5 in main /home/user/clang-sanitizers/main.cpp:5
#2 0x7f7ff5629d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
previously allocated by thread T0 here:
#0 0x7f7ff5eb61e7 in operator new(unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:99
#1 0x55839eca829e in main /home/user/clang-sanitizers/main.cpp:4
#2 0x7f7ff5629d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
SUMMARY: AddressSanitizer: double-free ../../../../src/libsanitizer/asan/asan_new_delete.cpp:172 in operator delete(void*, unsigned long)
==765374==ABORTING
在这个例子中,ptr
所指向的同一内存被释放了两次,导致双重释放错误。
演示无效释放的示例代码
提供的代码片段演示了一个无效释放错误,这是一种可能在 C++ 编程中发生的内存管理错误。让我们分析这个例子,了解问题及其影响:
int main() {
int local_var = 42;
int* ptr = &local_var;
delete ptr; // Invalid free error
return 0;
}
在给定的代码段中,我们首先声明并初始化一个局部变量 int local_var = 42;
。这创建了一个名为 local_var
的栈分配整数变量。随后,使用 int* ptr = &local_var;
进行指针赋值,其中 ptr
指针被设置为指向 local_var
的地址。这建立了指针和栈分配变量之间的联系。
然而,后续的操作出现了问题:delete ptr;
。此行代码试图释放 ptr
所指向的内存。问题在于 ptr
指向的是栈分配的变量 local_var
,而不是堆上的动态分配内存。在 C++ 中,delete
操作符仅应与使用 new
分配的指针一起使用。由于 local_var
没有使用 new
分配(它是一个栈分配的变量),在 ptr
上使用 delete
是无效的,并导致未定义的行为。在非堆指针上滥用 delete
操作符是 C++ 程序中可能导致严重运行时错误的常见错误。
这里有一些现代编译器的警告:
-
现代 C++ 编译器通常会在使用
delete
操作符对不指向动态分配内存的指针进行操作时发出警告或错误,因为这通常是错误的一个常见来源。 -
为了在不修改代码的情况下编译此代码并展示 ASan 捕获此类错误的能力,你可能需要抑制编译器警告。这可以通过在编译命令中添加
-Wno-free-nonheap-object
标志来实现。
使用 ASan 编译以进行 无效释放检测
要使用 ASan 编译程序以检测无效释放操作,请使用以下命令:
clang++ -fsanitize=address -Wno-free-nonheap-object -g your_file.cpp -o your_program
此命令以启用 ASan 并抑制关于释放非堆对象的特定编译器警告来编译程序。当你运行编译后的程序时,ASan 将检测并报告无效释放操作:
=================================================================
==900629==ERROR: AddressSanitizer: attempting free on address which was not malloc()-ed: 0x7fff390f21d0 in thread T0
#0 0x7f30b82b724f in operator delete(void*, unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:172
#1 0x563f21cd72c7 in main /home/user/clang-sanitizers/main.cpp:4
#2 0x7f30b7a29d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
#3 0x7f30b7a29e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f)
#4 0x563f21cd7124 in _start (/home/user/clang-sanitizers/build/a.out+0x1124)
Address 0x7fff390f21d0 is located in stack of thread T0 at offset 32 in frame
#0 0x563f21cd71f8 in main /home/user/clang-sanitizers/main.cpp:1
This frame has 1 object(s):
[32, 36) ‘local_var’ (line 2) <== Memory access at offset 32 is inside this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
(longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: bad-free ../../../../src/libsanitizer/asan/asan_new_delete.cpp:172 in operator delete(void*, unsigned long)
==900629==ABORTING
如示例所示,尝试删除指向非堆对象的指针是 C++ 中内存管理操作的误用。这种做法可能导致未定义的行为,并可能引发崩溃或其他异常程序行为。ASan 作为一种宝贵的工具,在检测这类错误方面发挥了重要作用,对开发健壮且无错误的 C++ 应用程序做出了重大贡献。
调整 ASan 以增强控制
虽然 ASan 是检测 C++ 程序中内存错误的强大工具,但在某些情况下,其行为需要微调。这种微调对于有效地管理分析过程至关重要,尤其是在处理涉及外部库、遗留代码或特定代码模式的复杂项目时。
抑制来自外部库的警告
在许多项目的背景下,使用外部库是一种常见的做法。然而,这些你可能无法控制的库有时可能包含内存问题。当运行 ASan 等工具时,这些外部库中的问题可能会被标记出来,导致诊断信息中充满了与你的项目代码不直接相关的警告。这可能会成为问题,因为它可能会掩盖你自己的代码库中需要关注的真正问题。
为了减轻这种情况,ASan 提供了一个有用的功能,允许你抑制来自这些外部库的特定警告。这种过滤掉无关警告的能力对于专注于修复自己代码库范围内的问题非常有价值。该功能的实现通常涉及使用 sanitizer 特殊情况列表或在编译过程中指定某些链接器标志。这些机制提供了一种方法,告诉 ASan 忽略某些路径或模式在诊断中的信息,从而有效地减少外部来源的噪音,并有助于更精确和高效的调试过程。
条件编译
在软件开发中,有些情况下你可能希望在编译程序时仅包含与 ASan 相关的特定代码段。这种方法在多种用途上特别有用,例如,整合额外的诊断信息或修改内存分配以使其更兼容或友好地与 ASan 的操作。
要实现这种策略,你可以利用条件编译技术,根据特定条件包含或排除代码的一部分。在 ASan 的情况下,你可以使用 __has_feature
宏来检查其是否存在。这个宏在编译时评估当前编译上下文中是否存在特定的功能(在这种情况下,即 ASan)。如果正在使用 ASan,条件编译块中的代码将被包含在最终的可执行文件中;否则,它将被排除:
#if defined(__has_feature)
# if __has_feature(address_sanitizer)
// Do something specific for AddressSanitizer
# endif
#endif
这种条件编译的方法允许开发者针对 ASan 使用的情况专门调整他们的代码,从而提高清理器的有效性,并可能避免仅在 ASan 存在时出现的问题。它提供了一种灵活的方式来根据构建配置调整程序的行为,这在开发、测试和生产阶段使用不同配置的复杂开发环境中非常有价值。
禁用特定代码行的清理器
在开发复杂软件的过程中,有时可能会故意执行某些操作,即使它们可能会被 ASan 标记为错误。或者,你可能希望出于特定原因将代码库的某些部分排除在 ASan 的分析之外。这可能是由于你的代码中已知的好行为,ASan 可能会错误地将其解释为错误,或者由于 ASan 引入的开销不希望出现在某些代码部分。
为了应对这些场景,GCC 和 Clang 编译器都提供了一种方法,可以针对特定的函数或代码块选择性地禁用 ASan。这是通过使用 __attribute__((no_sanitize(“address”)))
属性来实现的。通过将此属性应用于函数或特定的代码块,你可以指示编译器省略该特定段落的 ASan 仪器设置。
这个特性特别有用,因为它允许对代码的哪些部分受到 ASan 的审查进行细粒度控制。它使开发者能够微调彻底的错误检测与代码行为或性能要求的实际现实之间的平衡。通过审慎地应用此属性,你可以确保 ASan 的分析既有效又高效,将精力集中在最有益的地方。
利用清理器特殊案例列表
-
源文件和函数(src 和 fun):ASan 允许你在指定的源文件或函数中抑制错误报告。这在你想忽略某些已知问题或第三方代码时特别有用。
-
全局变量和类型(global 和 type):此外,ASan 引入了抑制对具有特定名称和类型的全局变量越界访问错误的能力。这个特性对于全局变量和类/结构体类型特别有用,允许更精确的错误抑制。
清理器特殊案例列表的示例条目
微调 ASan 是将其集成到大型、复杂开发环境中的关键方面。它允许开发者根据项目的具体需求自定义 ASan 的行为,无论是通过排除外部库、为 ASan 构建条件化代码,还是忽略某些错误以关注更关键的问题。通过有效利用这些微调能力,团队可以利用 ASan 的全部力量,确保 C++应用程序的稳健和可靠。抑制规则可以按照以下方式设置在文本文件中:
fun:FunctionName # Suppresses errors from FunctionName
global:GlobalVarName # Suppresses out-of-bound errors on GlobalVarName
type:TypeName # Suppresses errors for TypeName objects
此文件可以通过ASAN_OPTIONS
环境变量传递给运行时,例如ASAN_OPTIONS=suppressions=path/to/suppressionfile
。
ASan 的性能开销
在检测内存管理问题,如无效的释放操作时,ASan 的使用对于识别和解决 C++应用程序中潜在的 bug 非常有益。然而,重要的是要意识到使用 ASan 的性能影响。
性能影响、限制和推荐
将 ASan 集成到开发和测试过程中会带来一定程度的性能开销。通常,ASan 引入的减速在 2 倍左右,这意味着经过 ASan 工具化的程序可能比其非工具化版本慢大约两倍。这种增加的执行时间主要是由于 ASan 执行的额外检查和监控,以细致地检测内存错误。每次内存访问,以及每次内存分配和释放操作,都受到这些检查的影响,不可避免地导致额外的 CPU 周期消耗。
由于这种性能影响,ASan 主要在软件生命周期的开发和测试阶段使用。这种使用模式代表了一种权衡:虽然使用 ASan 会有性能成本,但在开发早期阶段捕捉和修复关键内存相关错误的好处是显著的。早期发现这些问题有助于保持代码质量,并且可以显著减少在生命周期后期调试和修复 bug 所需的时间和资源。
然而,在生产环境中部署经过 ASan 工具化的二进制文件通常不推荐,尤其是在性能是关键因素的场景中。ASan 引入的开销可能会影响应用程序的响应性和效率。尽管如此,在某些情况下,尤其是在可靠性和安全性至关重要的应用程序中,并且性能考虑是次要的,为了彻底测试,在类似生产环境中使用 ASan 可能是合理的。在这种情况下,ASan 提供的额外稳定性和安全性保障可能超过性能下降的担忧。
ASan 支持以下平台:
-
Linux i386/x86_64(在 Ubuntu 12.04 上测试)
-
macOS 10.7 – 10.11 (i386/x86_64)
-
iOS Simulator
-
Android ARM
-
NetBSD i386/x86_64
-
FreeBSD i386/x86_64(在 FreeBSD 11-current 上测试)
-
Windows 8.1+ (i386/x86_64)
LeakSanitizer (LSan)
LSan是 ASan 套件的一部分的专用内存泄漏检测工具,也可以独立使用。它专门设计用于识别 C++程序中的内存泄漏——即未释放分配的内存,导致内存消耗随时间增加。
与 ASan 的集成
LSan 通常与 ASan 一起使用。当你在你的构建中启用 ASan 时,LSan 也会自动启用,为内存错误和泄漏提供全面的分析。
独立模式
如果你希望在不使用 ASan 的情况下使用 LSan,可以通过编译程序时加上-fsanitize=leak
标志来启用它。这在只想专注于内存泄漏检测而不想承受其他地址清理开销时特别有用。
内存泄漏检测示例
考虑以下具有内存泄漏的 C++代码:
int main() {
int* leaky_memory = new int[100]; // Memory allocated and never freed
leaky_memory = nullptr; // Memory leaked
(void)leaky_memory;
return 0;
}
在这个例子中,一个整数数组被动态分配但没有释放,导致内存泄漏。
当你使用 LSan 编译并运行此代码时,输出可能看起来像这样:
=================================================================
==1743181==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 400 byte(s) in 1 object(s) allocated from:
#0 0x7fa14b6b6357 in operator new[](unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:102
#1 0x55888aabd19e in main /home/user/clang-sanitizers/main.cpp:2
#2 0x7fa14ae29d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f)
SUMMARY: AddressSanitizer: 400 byte(s) leaked in 1 allocation(s).
这个输出指出了内存泄漏的位置和大小,有助于快速有效地进行调试。
平台支持
根据最新信息,LSan 支持 Linux、macOS 和 Android。支持可能根据工具链和使用的编译器版本而有所不同。
LSan 是 C++开发者识别和解决应用程序中内存泄漏的有价值工具。它能够与 ASan 一起使用,也可以独立使用,这为解决特定的内存相关问题提供了灵活性。通过将 LSan 集成到开发和测试过程中,开发者可以确保更有效的内存使用和整体的应用程序稳定性。
MemorySanitizer (MSan)
MSan是一个动态分析工具,是 LLVM 项目的一部分,旨在检测 C++程序中未初始化内存的使用。未初始化内存的使用是导致不可预测行为、安全漏洞和难以诊断的错误等常见错误的原因。
要使用 MSan,请使用-fsanitize=memory
标志编译你的程序。这指示编译器在代码中插入检查未初始化内存使用的检查。例如:
clang++ -fsanitize=memory -g -o your_program your_file.cpp
展示未初始化内存使用的示例代码
考虑以下简单的 C++示例:
#include <iostream>
int main() {
int* ptr = new int[10];
if (ptr[1]) {
std::cout << “xx\n”;
}
delete[] ptr;
return 0;
}
在此代码中,整数是在堆上分配的但未初始化。
当与 MSan 一起编译和运行时,输出可能看起来像这样:
==48607==WARNING: MemorySanitizer: use-of-uninitialized-value
#0 0x560a37e0f557 in main /home/user/clang-sanitizers/main.cpp:5:9
#1 0x7fa118029d8f (/lib/x86_64-linux-gnu/libc.so.6+0x29d8f) (BuildId: c289da5071a3399de893d2af81d6a30c62646e1e)
#2 0x7fa118029e3f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x29e3f) (BuildId: c289da5071a3399de893d2af81d6a30c62646e1e)
#3 0x560a37d87354 in _start (/home/user/clang-sanitizers/build/a.out+0x1e354) (BuildId: 5a727e2c09217ae0a9d72b8a7ec767ce03f4e6ce)
SUMMARY: MemorySanitizer: use-of-uninitialized-value /home/user/clang-sanitizers/main.cpp:5:9 in main
MSan 检测到未初始化变量的使用,并指向代码中发生此情况的精确位置。
在这种情况下,修复可能只需要初始化数组:
int* ptr = new int[10]{};
微调、性能影响和限制
-
微调:MSan 的微调选项与 ASan 类似。用户可以参考官方文档以获取详细的定制选项。
-
性能影响:通常,使用 MSan 会引入大约 3 倍的运行时减速。这种开销是由于 MSan 执行的额外检查,以检测未初始化内存的使用。
-
支持的平台:MSan 支持 Linux、NetBSD 和 FreeBSD。它在检测未初始化内存使用方面的有效性使其成为在这些平台上工作的开发人员的强大工具。
-
局限性:与其他清理器一样,MSan 的运行时开销使其最适合用于测试环境,而不是生产环境。此外,MSan 要求整个程序(包括它使用的所有库)都进行仪器化。在无法获得某些库的源代码的情况下,这可能是一个限制。
MSan 是检测 C++ 程序中难以捉摸但可能至关重要的未初始化内存使用问题的基本工具。通过提供关于此类问题发生位置和方式的详细报告,MSan 使开发人员能够识别和修复这些错误,显著提高其应用程序的可靠性和安全性。尽管其性能影响,但将 MSan 集成到开发和测试阶段,是一个谨慎的步骤,以确保稳健的软件质量。
TSan
在 C++ 编程领域,有效地管理并发和多线程既是关键也是挑战。与线程相关的问题,尤其是数据竞争,臭名昭著地难以检测和调试。与其他可以通过确定性测试方法(如单元测试)发现的错误不同,线程问题本质上是难以捉摸和非确定性的。这些问题可能不会在程序的每次运行中都表现出来,导致不可预测和混乱的行为,这可能非常难以复制和诊断。
线程相关问题的复杂性
-
非确定性行为:包括数据竞争、死锁和线程泄漏在内的并发问题本质上是非确定性的。这意味着它们在相同条件下并不一致地重现,使它们难以捉摸和不可预测。
-
检测挑战:传统的测试方法,包括全面的单元测试,往往无法检测到这些问题。涉及并发的测试结果可能会因时间、线程调度和系统负载等因素而有所不同。
-
微妙且严重的错误:与线程相关的错误可能处于休眠状态,仅在特定条件下在生产环境中出现,可能导致严重的影响,如数据损坏、性能下降和系统崩溃。
TSan 的必要性
由于 C++ 中管理并发的固有挑战,Clang 和 GCC 提供的 TSan 等工具变得至关重要。TSan 是一个旨在检测线程问题的复杂工具,特别关注数据竞争。
启用 TSan
-
-fsanitize=thread
标志。这指示 Clang 和 GCC 为运行时检测线程问题对你的代码进行仪器化。 -
编译示例:
clang++ -fsanitize=thread -g -o your_program your_file.cpp
此命令将使用 TSan 启用编译your_file.cpp
,以便检测和报告线程问题。请注意,无法同时开启线程和 ASan。
C++中的数据竞争示例
考虑这个简单但具有说明性的例子:
#include <iostream>
#include <thread>
int shared_counter = 0;
void increment_counter() {
for (int i = 0; i < 10000; ++i) {
shared_counter++; // Potential data race
}
}
int main() {
std::thread t1(increment_counter);
std::thread t2(increment_counter);
t1.join();
t2.join();
std::cout << “Shared counter: “ << shared_counter << std::endl;
return 0;
}
在这里,两个线程在不进行同步的情况下修改相同的共享资源,导致数据竞争。
如果我们启用 TSan 并构建和运行此代码,我们将得到以下输出:
==================
WARNING: ThreadSanitizer: data race (pid=2560038)
Read of size 4 at 0x555fd304f154 by thread T2:
#0 increment_counter() /home/user/clang-sanitizers/main.cpp:8 (a.out+0x13f9)
#1 void std::__invoke_impl<void, void (*)()>(std::__invoke_other, void (*&&)()) /usr/include/c++/11/bits/invoke.h:61 (a.out+0x228a)
#2 std::__invoke_result<void (*)()>::type std::__invoke<void (*)()>(void (*&&)()) /usr/include/c++/11/bits/invoke.h:96 (a.out+0x21df)
#3 void std::thread::_Invoker<std::tuple<void (*)()> >::_M_invoke<0ul>(std::_Index_tuple<0ul>) /usr/include/c++/11/bits/std_thread.h:259 (a.out+0x2134)
#4 std::thread::_Invoker<std::tuple<void (*)()> >::operator()() /usr/include/c++/11/bits/std_thread.h:266 (a.out+0x20d6)
#5 std::thread::_State_impl<std::thread::_Invoker<std::tuple<void (*)()> > >::_M_run() /usr/include/c++/11/bits/std_thread.h:211 (a.out+0x2088)
#6 <null> <null> (libstdc++.so.6+0xdc252)
Previous write of size 4 at 0x555fd304f154 by thread T1:
#0 increment_counter() /home/user/clang-sanitizers/main.cpp:8 (a.out+0x1411)
#1 void std::__invoke_impl<void, void (*)()>(std::__invoke_other, void (*&&)()) /usr/include/c++/11/bits/invoke.h:61 (a.out+0x228a)
#2 std::__invoke_result<void (*)()>::type std::__invoke<void (*)()>(void (*&&)()) /usr/include/c++/11/bits/invoke.h:96 (a.out+0x21df)
#3 void std::thread::_Invoker<std::tuple<void (*)()> >::_M_invoke<0ul>(std::_Index_tuple<0ul>) /usr/include/c++/11/bits/std_thread.h:259 (a.out+0x2134)
#4 std::thread::_Invoker<std::tuple<void (*)()> >::operator()() /usr/include/c++/11/bits/std_thread.h:266 (a.out+0x20d6)
#5 std::thread::_State_impl<std::thread::_Invoker<std::tuple<void (*)()> > >::_M_run() /usr/include/c++/11/bits/std_thread.h:211 (a.out+0x2088)
#6 <null> <null> (libstdc++.so.6+0xdc252)
Location is global ‘shared_counter’ of size 4 at 0x555fd304f154 (a.out+0x000000005154)
Thread T2 (tid=2560041, running) created by main thread at:
#0 pthread_create ../../../../src/libsanitizer/tsan/tsan_interceptors_posix.cpp:969 (libtsan.so.0+0x605b8)
#1 std::thread::_M_start_thread(std::unique_ptr<std::thread::_State, std::default_delete<std::thread::_State> >, void (*)()) <null> (libstdc++.so.6+0xdc328)
#2 main /home/user/clang-sanitizers/main.cpp:14 (a.out+0x1484)
Thread T1 (tid=2560040, finished) created by main thread at:
#0 pthread_create ../../../../src/libsanitizer/tsan/tsan_interceptors_posix.cpp:969 (libtsan.so.0+0x605b8)
#1 std::thread::_M_start_thread(std::unique_ptr<std::thread::_State, std::default_delete<std::thread::_State> >, void (*)()) <null> (libstdc++.so.6+0xdc328)
#2 main /home/user/clang-sanitizers/main.cpp:13 (a.out+0x146e)
SUMMARY: ThreadSanitizer: data race /home/user/clang-sanitizers/main.cpp:8 in increment_counter()
==================
Shared counter: 20000
ThreadSanitizer: reported 1 warnings
TSan 的此输出表明 C++程序中存在数据竞争条件。让我们分析此报告的关键元素,以了解它告诉我们什么:
-
WARNING: ThreadSanitizer:
data race
)。 -
0x555fd304f154
内存地址,被识别为全局shared_counter
变量。 -
increment_counter() /home/user/clang-sanitizers/main.cpp:8
。这意味着数据竞争读取发生在increment_counter
函数中,具体在main.cpp
的第8行。 -
报告还提供了导致此读取的堆栈跟踪,显示了函数调用的序列。
-
increment_counter
函数位于main.cpp
的第8行。*main.cpp
在第13和14行,分别)。这有助于理解导致数据竞争的程序流程。*SUMMARY: ThreadSanitizer: data race /home/user/clang-sanitizers/main.cpp:8
in increment_counter()
。这简要指出了检测到数据竞争的函数和文件。
TSan 的微调、性能影响、限制和建议
TSan 通常引入大约 5x-15x 的运行时减速。这种显著的执行时间增加是由于 TSan 执行的综合检查,以检测数据竞争和其他线程问题。除了减速外,TSan 还增加了内存使用,通常约为 5x-10x。这种开销是由于 TSan 使用的额外数据结构来监控线程交互和识别潜在的竞争条件。
此列表概述了 TSan 的限制和当前状态:
-
Beta 阶段:TSan 目前处于 Beta 阶段。虽然它在使用 pthread 的大 C++程序中已经有效,但无法保证其在每个场景中的有效性。
-
支持的线程模型:当使用 llvm 的 libc++编译时,TSan 支持 C++11 线程。这种兼容性包括 C++11 标准引入的线程功能。
TSan 由多个操作系统和架构支持:
-
Android:aarch64, x86_64
-
Darwin (macOS):arm64, x86_64
-
FreeBSD
-
Linux:aarch64, x86_64, powerpc64, powerpc64le
-
NetBSD
主要支持 64 位架构。对 32 位平台的支持存在问题,且未计划支持。
微调 TSan
TSan 的微调与 ASan 的微调非常相似。对详细微调选项感兴趣的用户可以参考官方文档,该文档提供了全面指导,以定制 TSan 的行为以满足特定需求和场景。
使用 TSan 的建议
由于性能和内存开销,TSan 最好在项目的开发和测试阶段使用。在评估性能要求时,应谨慎考虑其在生产环境中的使用。TSan 特别适用于具有大量多线程组件的项目,其中数据竞争和线程问题的可能性更高。将 TSan 集成到 持续集成(CI)管道中可以帮助在开发周期早期捕获线程问题,从而降低这些错误进入生产的风险。
TSan 是处理 C++ 中并发复杂性的开发者的关键工具。它提供了在检测传统测试方法往往忽略的难以捉摸的线程问题时无价的服务。通过将 TSan 集成到开发和测试过程中,开发者可以显著提高其多线程 C++ 应用程序的可靠性和稳定性。
UBSan
UBSan 是一种动态分析工具,旨在检测 C++ 程序中的未定义行为。根据 C++ 标准,未定义行为是指其行为未规定的代码,导致程序执行不可预测。这可能包括整数溢出、除以零或对空指针的误用等问题。未定义行为可能导致程序行为异常、崩溃和安全漏洞。然而,它通常被编译器开发者用于优化代码。UBSan 对于识别这些问题至关重要,这些问题通常很微妙且难以通过标准测试检测到,但可能在软件可靠性和安全性方面引起重大问题。
配置 UBSan
要使用 UBSan,请使用 -fsanitize=undefined
标志编译您的程序。这指示编译器使用对各种形式的未定义行为的检查来对代码进行操作。这些命令使用 Clang 或 GCC 启用 UBSan 编译程序。
展示未定义行为的示例代码
考虑这个简单的例子:
#include <iostream>
int main() {
int x = 0;
std::cout << 10 / x << std::endl; // Division by zero, undefined behavior
return 0;
}
在此代码中,尝试除以零(10 / x
)是未定义行为的实例。
当与 UBSan 一起编译和运行时,输出可能包括如下内容:
/home/user/clang-sanitizers/main.cpp:5:21: runtime error: division by zero
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /home/user/clang-sanitizers/main.cpp:5:21 in
0
UBSan 检测到除以零,并报告代码中发生此情况的精确位置。
微调、性能影响和限制
-
微调:UBSan 提供了各种选项来控制其行为,允许开发者专注于特定类型的未定义行为。对详细定制感兴趣的用户可以参考官方文档。
-
性能影响:与 ASan 和 TSan 等工具相比,UBSan 的运行时性能影响通常较低,但会根据启用的检查类型而变化。典型的减速通常很小。
-
支持的平台:UBSan 支持主要平台,如 Linux、macOS 和 Windows,使其对 C++ 开发者广泛可用。
-
局限性:虽然 UBSan 在检测未定义行为方面非常强大,但它无法捕获每个实例,尤其是那些高度依赖于特定程序状态或硬件配置的实例。
UBSan 是 C++ 开发者的无价之宝,有助于早期发现可能导致软件不稳定和不安全的微妙但关键问题。将其集成到开发和测试过程中是确保 C++ 应用程序健壮性和可靠性的主动步骤。由于其最小的性能影响和广泛的支持平台,UBSan 是任何 C++ 开发者工具包的实用补充。
使用 Valgrind 进行动态代码分析
Valgrind 是一个强大的内存调试、内存泄漏检测和性能分析工具。它在识别内存管理错误和访问错误等常见问题方面至关重要,这些问题在复杂的 C++ 程序中很常见。与基于编译器的工具(如 Sanitizers)不同,Valgrind 通过在类似虚拟机的环境中运行程序来检查内存相关错误。
设置 Valgrind
Valgrind 通常可以通过您的系统包管理器进行安装。例如,在 Ubuntu 上,您可以使用 sudo apt-get install valgrind
命令进行安装。要在 Valgrind 下运行程序,请使用 valgrind ./your_program
命令。此命令在 Valgrind 环境中执行您的程序,并执行其分析。对于 Valgrind 的基本内存检查,不需要特殊的编译标志,但包含调试符号(使用 -g
)可以帮助使其输出更有用。
Memcheck – 全面的内存调试器
Memcheck,Valgrind 套件的核心工具,是一个针对 C++ 应用的复杂内存调试器。它结合了地址、内存和 LSans 的功能,提供了对内存使用的全面分析。Memcheck 检测与内存相关的错误,例如使用未初始化的内存、不正确使用内存分配和释放函数以及内存泄漏。
要使用 Memcheck,不需要特殊的编译标志,但使用带有调试信息(使用 -g
)的编译可以增强 Memcheck 报告的有用性。通过使用 valgrind ./your_program
命令执行您的程序。对于检测内存泄漏,添加 --leak-check=full
以获取更详细的信息。以下是一个示例命令:
valgrind --leak-check=full ./your_program
由于 Memcheck 覆盖了广泛的内存相关问题,我将只展示检测内存泄漏的示例,因为它们通常是最难检测的。让我们考虑以下具有内存泄漏的 C++ 代码:
int main() {
int* ptr = new int(10); // Memory allocated but not freed
return 0; // Memory leak occurs here
}
Memcheck 将检测并报告内存泄漏,指示内存是在哪里分配的以及它没有被释放:
==12345== Memcheck, a memory error detector
==12345== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x...: operator new(unsigned long) (vg_replace_malloc.c:...)
==12345== by 0x...: main (your_file.cpp:2)
...
==12345== LEAK SUMMARY:
==12345== definitely lost: 4 bytes in 1 blocks
...
性能影响,微调和局限性
重要的是要记住,Memcheck 可以显著减慢程序执行速度,通常慢 10-30 倍,并增加内存使用。这是由于对每个内存操作进行的广泛检查。
Memcheck 提供了几个选项来控制其行为。例如,--track-origins=yes
可以帮助找到未初始化内存使用的来源,尽管这可能会进一步减慢分析速度。
Memcheck 的主要局限性是其性能开销,这使得它不适合生产环境。此外,尽管它在内存泄漏检测方面非常彻底,但它可能无法捕捉到所有未初始化内存使用的实例,尤其是在复杂场景或应用特定编译器优化时。
Memcheck 是 C++开发者工具箱中用于内存调试的重要工具。通过提供对内存错误和泄漏的详细分析,它在提高 C++应用程序的可靠性和正确性方面发挥着关键作用。尽管存在性能开销,但 Memcheck 在识别和解决内存问题方面的好处使其在软件开发的开发和测试阶段变得不可或缺。
Helgrind – 线程错误检测器
Helgrind是 Valgrind 套件中的一个工具,专门设计用于检测 C++多线程应用程序中的同步错误。它专注于识别竞争条件、死锁和对 pthreads API 的误用。Helgrind 通过监控线程之间的交互来运行,确保共享资源被安全且正确地访问。它检测线程错误的能力使其与 TSan 相当,但具有不同的底层方法和用法。
要使用 Helgrind,你不需要用特殊标志重新编译你的程序(尽管建议使用-g
标志来包含调试符号)。使用--tool=helgrind
选项运行你的程序。以下是一个示例命令:
valgrind --tool=helgrind ./your_program
让我们考虑我们之前用 TSan 分析过的数据竞争示例:
#include <iostream>
#include <thread>
int shared_counter = 0;
void increment_counter() {
for (int i = 0; i < 10000; ++i) {
shared_counter++; // Potential data race
}
}
int main() {
std::thread t1(increment_counter);
std::thread t2(increment_counter);
t1.join();
t2.join();
std::cout << “Shared counter: “ << shared_counter << std::endl;
return 0;
}
Helgrind 将检测并报告数据竞争,显示线程在没有适当同步的情况下并发修改shared_counter
的位置。除了识别数据竞争外,Helgrind 的输出还包含线程创建公告、堆栈跟踪和其他详细信息:
valgrind --tool=helgrind ./a.out
==178401== Helgrind, a thread error detector
==178401== Copyright (C) 2007-2017, and GNU GPL’d, by OpenWorks LLP et al.
==178401== Using Valgrind-3.18.1 and LibVEX; rerun with -h for copyright info
==178401== Command: ./a.out
==178401== ---Thread-Announcement------------------------------------------
==178401==
==178401== Thread #3 was created
==178401== at 0x4CCE9F3: clone (clone.S:76)
==178401== by 0x4CCF8EE: __clone_internal (clone-internal.c:83)
==178401== by 0x4C3D6D8: create_thread (pthread_create.c:295)
==178401== by 0x4C3E1FF: pthread_create@@GLIBC_2.34 (pthread_create.c:828)
==178401== by 0x4853767: ??? (in /usr/libexec/valgrind/vgpreload_helgrind-amd64-linux.so)
==178401== by 0x4952328: std::thread::_M_start_thread(std::unique_ptr<std::thread::_State, std::default_delete<std::thread::_State> >, void (*)()) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.30)
==178401== by 0x1093F9: std::thread::thread<void (&)(), , void>(void (&)()) (std_thread.h:143)
==178401== by 0x1092AF: main (main.cpp:14)
==178401==
==178401== ---Thread-Announcement------------------------------------------
==178401==
==178401== Thread #2 was created
==178401== ----------------------------------------------------------------
==178401==
==178401== Possible data race during read of size 4 at 0x10C0A0 by thread #3
==178401== Locks held: none
==178401== at 0x109258: increment_counter() (main.cpp:8)
==178401== by 0x109866: void std::__invoke_impl<void, void (*)()>(std::__invoke_other, void (*&&)()) (invoke.h:61)
==178401== by 0x1097FC: std::__invoke_result<void (*)()>::type std::__invoke<void (*)()>(void (*&&)()) (invoke.h:96)
==178401== by 0x1097D4: void std::thread::_Invoker<std::tuple<void (*)()> >::_M_invoke<0ul>(std::_Index_tuple<0ul>) (std_thread.h:259)
==178401== by 0x1097A4: std::thread::_Invoker<std::tuple<void (*)()> >::operator()() (std_thread.h:266)
==178401== by 0x1096F8: std::thread::_State_impl<std::thread::_Invoker<std::tuple<void (*)()> > >::_M_run() (std_thread.h:211)
==178401== by 0x4952252: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.30)
==178401== by 0x485396A: ??? (in /usr/libexec/valgrind/vgpreload_helgrind-amd64-linux.so)
==178401== by 0x4C3DAC2: start_thread (pthread_create.c:442)
==178401== by 0x4CCEA03: clone (clone.S:100)
==178401==
==178401== This conflicts with a previous write of size 4 by thread #2
==178401== Locks held: none
==178401== at 0x109261: increment_counter() (main.cpp:8)
==178401== by 0x109866: void std::__invoke_impl<void, void (*)()>(std::__invoke_other, void (*&&)()) (invoke.h:61)
==178401== by 0x1097FC: std::__invoke_result<void (*)()>::type std::__invoke<void (*)()>(void (*&&)()) (invoke.h:96)
==178401== by 0x1097D4: void std::thread::_Invoker<std::tuple<void (*)()> >::_M_invoke<0ul>(std::_Index_tuple<0ul>) (std_thread.h:259)
==178401== by 0x1097A4: std::thread::_Invoker<std::tuple<void (*)()> >::operator()() (std_thread.h:266)
==178401== by 0x1096F8: std::thread::_State_impl<std::thread::_Invoker<std::tuple<void (*)()> > >::_M_run() (std_thread.h:211)
==178401== by 0x4952252: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.30)
==178401== by 0x485396A: ??? (in /usr/libexec/valgrind/vgpreload_helgrind-amd64-linux.so)
==178401== Address 0x10c0a0 is 0 bytes inside data symbol “shared_counter”
==178401==
Shared counter: 20000
==178401==
==178401== Use --history-level=approx or =none to gain increased speed, at
==178401== the cost of reduced accuracy of conflicting-access information
==178401== For lists of detected and suppressed errors, rerun with: -s
==178401== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)`
性能影响、微调和局限性
使用 Helgrind 可能会显著减慢你的程序执行速度(通常慢 20 倍或更多),这是由于对线程交互的详细分析。这使得它最适合测试环境。Helgrind 提供了几个选项来自定义其行为,例如控制检查级别或忽略某些错误。其主要局限性是性能开销,这使得它在生产中使用不切实际。此外,Helgrind 可能会产生假阳性,尤其是在复杂的线程场景或使用 Helgrind 不完全理解的先进同步原语时。
Helgrind 是开发多线程 C++应用程序的开发者的重要工具,它提供了对具有挑战性的并发问题的见解。它通过检测和帮助解决复杂的同步问题,有助于创建更可靠和线程安全的应用程序。尽管由于其性能开销,其使用可能仅限于开发和测试阶段,但它为提高多线程代码的正确性提供的益处是无价的。
Valgrind 套件中的其他知名工具
除了 Helgrind,Valgrind 套件还包括其他几个工具,每个工具都有独特的功能,旨在满足程序分析和性能分析的不同方面。
数据竞争检测器(DRD)- 线程错误检测器
DRD 是另一个用于检测线程错误的工具,类似于 Helgrind。它专注于在多线程程序中识别数据竞争。虽然 Helgrind 和 DRD 都旨在检测线程问题,但 DRD 在检测数据竞争方面进行了优化,通常比 Helgrind 具有更低的性能开销。在某些情况下,DRD 可能产生较少的误报,但在检测所有类型的同步错误方面可能不如 Helgrind 彻底。
Cachegrind
Cachegrind 是一个缓存和分支预测分析器。它提供了关于您的程序如何与计算机的缓存层次结构交互以及分支预测效率的详细信息。这个工具对于优化程序性能非常有价值,尤其是在 CPU 密集型应用程序中。它有助于识别低效的内存访问模式以及可以通过优化来提高缓存利用率的代码区域。
Callgrind
Callgrind 通过添加调用图生成功能扩展了 Cachegrind 的功能。它记录程序中函数之间的调用历史,使开发者能够分析执行流程并识别性能瓶颈。Callgrind 特别适用于理解复杂应用程序的整体结构和交互。
Massif
Massif 是一个堆分析器,它提供了关于程序内存使用的见解。它帮助开发者理解和优化内存消耗,追踪内存泄漏,并确定程序中内存分配发生的位置和方式。
动态堆分析工具(DHAT)
DHAT 专注于分析堆分配模式。它特别适用于查找堆内存的低效使用,例如过多的微小分配或可能优化的小型短期分配。
Valgrind 套件中的每个工具都提供了分析程序性能和行为不同方面的独特功能。从线程问题到内存使用和 CPU 优化,这些工具为增强 C++应用程序的效率、可靠性和正确性提供了一套全面的函数。它们集成到开发和测试过程中,使开发者能够深入了解其代码,从而得到优化良好且稳健的软件解决方案。
摘要
基于编译器的清理器和 Valgrind 在调试和性能分析过程中带来了不同的优势和挑战。
基于编译器的工具,如 ASan、TSan 和 UBSan,通常更容易访问,并且更容易集成到开发工作流程中。在引入的性能开销方面,它们“更便宜”,配置和使用相对简单。这些清理器直接集成到编译过程中,使开发者能够经常使用它们。它们的主要优势在于能够在开发阶段提供即时反馈,在编写和测试代码时捕捉错误和问题。然而,由于这些工具在运行时进行分析,其有效性直接与测试覆盖范围的程度相关。测试越全面,动态分析就越有效,因为只有执行的代码路径才会被分析。这一点突出了彻底测试的重要性:测试覆盖范围越好,这些工具可以潜在地揭示的问题就越多。
相反,Valgrind 提供了更强大和彻底的分析,能够检测更广泛的问题,尤其是在内存管理和线程方面。其工具套件——Memcheck、Helgrind、DRD、Cachegrind、Callgrind、Massif 和 DHAT——对程序性能和行为的多方面进行了全面分析。然而,这种力量是有代价的:与基于编译器的工具相比,Valgrind 通常更复杂,引入了显著的性能开销。是否使用 Valgrind 或基于编译器的清理器的选择通常取决于项目的具体需求和要解决的问题。虽然 Valgrind 的广泛诊断提供了对程序的深入洞察,但基于编译器的清理器的易用性和较低的性能成本使它们更适合在 CI 管道中常规使用。
总结来说,虽然基于编译器的工具和 Valgrind 在动态分析领域都有其位置,但它们在诊断、易用性和性能影响方面的差异使它们适合软件开发过程的各个阶段和方面。将它们作为常规持续集成(CI)管道的一部分使用是非常推荐的,因为它允许早期检测和解决问题,对软件的整体质量和鲁棒性贡献显著。下一章将深入探讨测量测试覆盖率的工具,提供关于代码库测试有效性的见解,从而补充动态分析过程。
第十二章:测试
软件测试在软件开发的大厦中占据基石地位,在确保软件质量、可靠性和可维护性方面具有至关重要的意义。正是通过细致的测试过程,开发者可以确保他们的作品达到功能性和用户满意度最高的标准。任何软件项目的起点总是与潜在的错误和未预见到的问题交织在一起;正是测试揭示了这些隐藏的陷阱,使开发者能够积极应对,从而增强软件的整体完整性和性能。
软件测试的核心在于一系列多样化的方法论,每种方法都针对软件的不同方面进行定制。在这些方法中,单元测试作为基础层,专注于软件中最小的可测试部分,以确保其正确的行为。这种细粒度方法有助于早期发现错误,通过允许立即纠正来简化开发过程。从微观视角上升到宏观视角,集成测试占据主导地位,其中对集成单元之间的交互进行审查。这种方法在识别组件接口问题中至关重要,确保软件内部通信和功能的顺畅。
进一步推进,系统测试成为对完整和集成软件系统的全面审查。这种方法深入到软件对指定要求的遵守情况,对其行为和性能进行总体评估。这是一个关键阶段,验证软件的部署准备情况,确保它在预期环境中正确运行。最后,验收测试标志着测试过程的完成,其中软件被评估以确定其是否满足交付给最终用户的标准。这一最终阶段对于确保软件与用户需求和期望的一致性至关重要,是软件质量和有效性的最终证明。
开始这一章节,您将引导进入软件测试错综复杂的领域,深入了解它在开发生命周期中扮演的关键角色。探索将涵盖测试方法之间的细微差别,阐明它们的独特目标和应用范围。通过这次旅程,您将获得对如何通过测试支撑创建强大、可靠和以用户为中心的软件的全面理解,为后续章节深入探讨单元测试以及 C++领域的其他具体内容奠定基础。
测试驱动开发
测试驱动开发,通常缩写为TDD,是一种现代软件开发方法,它彻底改变了代码的编写和测试方式。在其核心,TDD 通过提倡在开发实际功能代码之前创建测试来颠覆传统的开发方法。这种范式转变体现在被称为“红-绿-重构”的循环过程中。最初,开发者编写一个测试来定义期望的改进或新功能,这个测试在第一次运行时必然会失败——这是“红”阶段,表示缺少相应的功能。随后,在“绿”阶段,开发者编写必要的最少代码以通过测试,从而确保功能满足指定的要求。这个周期以“重构”阶段结束,在这个阶段,新代码被精炼和优化,而不改变其行为,从而保持测试的成功结果。
采用 TDD(测试驱动开发)带来了众多优势,这些优势有助于构建更健壮和可靠的代码库。其中最显著的益处是代码质量的显著提升。由于 TDD 要求事先定义测试,因此它本质上鼓励更深思熟虑和审慎的设计过程,减少了错误和缺陷的可能性。此外,在 TDD 过程中编写的测试同时起到详细文档的作用。这些测试提供了对代码预期功能和用法的清晰洞察,为当前和未来的开发者提供了宝贵的指导。此外,TDD 通过确保更改不会意外地破坏现有功能,从而促进了既灵活又易于维护的代码库的设计和重构。
尽管 TDD 具有许多益处,但它并非没有挑战和潜在的缺点。采用 TDD 时最初遇到的初步障碍之一是感觉开发过程会减慢。在功能开发之前编写测试可能感觉不合逻辑,可能会延长交付功能的时间,尤其是在采用初期。此外,TDD 需要陡峭的学习曲线,要求开发者掌握新技能并适应不同的思维方式,这可能会在时间和资源上投入巨大。还值得注意的是,TDD 可能并不适用于所有场景,也不是所有情况下都是理想的。某些类型的项目,如涉及复杂用户界面或需要与外部系统进行广泛交互的项目,可能会对 TDD 方法构成挑战,需要更细致或混合的测试方法。
总结来说,虽然 TDD(测试驱动开发)通过强调测试优先的方法为软件开发提供了一种变革性的方法,但在权衡其潜在挑战的同时,其益处也是至关重要的。TDD 的有效性取决于其应用的上下文、开发团队的熟练程度以及手头项目的性质。随着我们深入到后续章节,单元测试的细微差别、与测试框架的集成以及实际考虑因素将进一步阐明 TDD 在塑造高质量、可维护的 C++代码库中的作用。
C++中的单元测试
单元测试是软件工程中 TDD 的基础性方面,在 C++开发过程中发挥着关键作用。它们专注于验证代码的最小部分,称为单元,通常是单个函数、方法或类。通过单独测试这些组件,单元测试确保软件的每个部分都按预期运行,这对于系统的整体功能至关重要。
在 TDD 框架中,单元测试承担着更加重要的角色。它们通常在实际代码编写之前编写,指导开发过程,并确保软件从一开始就考虑到可测试性和正确性。在实现之前编写单元测试的方法有助于在开发周期早期发现错误,从而及时纠正,防止错误变得更加复杂或影响系统的其他部分。这种主动的错误检测不仅节省了时间和资源,而且有助于提高软件的稳定性。
此外,单元测试作为开发者的安全网,使他们能够自信地重构代码,而不用担心破坏现有的功能。这在 TDD 中尤其有价值,因为在 TDD 中,重构是编写测试、使其通过并改进代码的循环中的关键步骤。除了在错误检测和促进重构中的作用外,单元测试还充当有效的文档,提供对系统预期行为的清晰见解。这使得它们成为开发者的宝贵资源,尤其是对于新加入代码库的开发者来说。此外,在 TDD 方法中编写单元测试的过程通常突出了设计改进,从而导致了更健壮和可维护的代码。
C++单元测试框架
C++生态系统拥有丰富的单元测试框架,旨在促进测试的创建、执行和维护。在这些框架中,Google Test 和 Google Mock 因其全面的功能集、易用性和与 C++项目的集成能力而脱颖而出。在本节中,我们将深入探讨 Google Test 和 Google Mock,突出它们的关键功能和语法,并演示如何将它们集成到 CMake 项目中。
Google Test 和 Google Mock
使用 EXPECT_EQ
和 ASSERT_NE
来比较预期结果与实际结果,确保测试条件的精确验证。此外,Google Test 通过测试夹具简化了常见测试配置的管理,夹具定义了设置和清理操作,为每个测试提供一个一致的环境。
另一个重要特性是对参数化测试的支持,允许开发者编写单个测试并在多个输入上运行它。这种方法大大增强了测试覆盖率,而无需重复代码。与此相辅相成的是,Google Test 还支持类型参数化测试,允许在不同数据类型上执行相同的测试逻辑,进一步扩大测试覆盖范围。
Google Test 最用户友好的特性之一是其自动测试发现机制。此功能消除了手动测试注册的需要,因为 Google Test 自动识别并执行项目中的测试,简化了测试过程并节省了宝贵的发展时间。
Google Mock,也称为 gMock,通过提供强大的模拟框架来补充 Google Test,该框架可以无缝集成以模拟复杂对象行为。这种能力在创建模拟真实世界场景的条件时非常有价值,允许更彻底地测试代码交互。使用 Google Mock,开发者可以灵活地设置模拟对象期望,根据特定需求定制,例如函数被调用的次数、接收的参数以及调用顺序。这种控制水平确保测试不仅可以验证结果,还可以验证代码不同部分之间的交互。
此外,Google Mock 特别设计用于与 Google Test 协同工作,简化了创建可以同时利用实际对象及其模拟对应物的全面测试的过程。这种集成简化了编写既广泛又反映真实应用程序行为的测试的过程,从而增强了代码库的可靠性和可维护性。
将 Google Test 集成到 C++ 项目中
我们将演示如何将 Google Test 集成到 CMake 项目中,提供配置 CMake 以与 Google Test 一起进行 C++ 项目的单元测试的逐步指南。
首先,确保 Google Test 包含在您的项目中。这可以通过将 Google Test 添加为项目存储库中的子模块或通过 CMake 下载来实现。一旦 Google Test 成为项目的一部分,下一步就是配置您的 CMakeLists.txt
文件以在构建过程中包含 Google Test。
以下是如何配置您的 CMakeLists.txt
文件以通过子模块集成 Google Test 的示例:
git submodule add https://github.com/google/googletest.git external/googletest
更新 CMakeLists.txt
以在构建中包含 Google Test 和 Google Mock:
# Minimum version of CMake
cmake_minimum_required(VERSION 3.14)
project(MyProject)
# GoogleTest requires at least C++14
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Enable testing capabilities
enable_testing()
# Add GoogleTest to the project
add_subdirectory(external/googletest)
# Include GoogleTest and GoogleMock headers
include_directories(${gtest_SOURCE_DIR}/include ${gmock_SOURCE_DIR}/include)
# Define your test executable
add_executable(my_tests test1.cpp test2.cpp)
# Link GoogleTest and GoogleMock to your test executable
target_link_libraries(my_tests gtest gtest_main gmock gmock_main)
在此配置中,add_subdirectory(external/googletest)
告诉 CMake 将 Google Test 包含在构建中。include_directories
确保 Google Test 的头文件对您的测试文件是可访问的。add_executable
定义了一个新的可执行文件用于您的测试,而 target_link_libraries
将 Google Test 库链接到您的测试可执行文件。
在配置 CMakeLists.txt
之后,您可以使用 CMake 和 make 命令构建和运行您的测试。此设置不仅将 Google Test 集成到您的项目中,而且还利用 CMake 的测试功能来自动运行测试。
以下代码片段演示了另一种配置 CMake 以使用 Google Test 的方法,即通过 CMake 的 FetchContent
模块下载 Google Test。这种方法允许 CMake 在构建过程中下载 Google Test,确保项目的依赖项自动管理:
cmake_minimum_required(VERSION 3.14)
project(MyProject)
# GoogleTest requires at least C++14
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(FetchContent)
FetchContent_Declare(
googletest
URL https://github.com/google/googletest/archive/03597a01ee50ed33e9dfd640b249b4be3799d395.zip
)
# For Windows: Prevent overriding the parent project’s compiler/linker settings
set(gtest_force_shared_crt ON CACHE BOOL “” FORCE)
FetchContent_MakeAvailable(googletest)
虽然这个例子侧重于将 Google Test 与 CMake 集成,但值得注意的是 Google Test 是灵活的,也可以集成到其他构建系统中,例如 Google 自己的 Bazel。对于使用不同构建系统或更复杂配置的项目,请参考官方 Google Test 文档以获取全面指导和建议最佳实践。此文档提供了关于在各个环境和构建系统中利用 Google Test 的宝贵见解,确保您可以在任何开发设置中有效地在您的 C++ 项目中实施单元测试。
在 C++ 项目中使用 Google Test
Google Test 提供了一套全面的函数,以支持 C++ 开发中的各种测试需求。了解如何有效地利用这些功能可以显著提高您的测试实践。让我们通过简单的示例和解释来探讨 Google Test 的使用。
编写一个简单的测试
在 Google Test 中编写一个简单的测试可以使用 TEST
宏,它定义了一个测试函数。在这个函数内部,您可以使用各种断言来验证您代码的行为。以下是一个基本示例:
#include <gtest/gtest.h>
int add(int a, int b) {
return a + b;
}
TEST(AdditionTest, HandlesPositiveNumbers) {
EXPECT_EQ(5, add(2, 3));
}
在此示例中,使用 EXPECT_EQ
断言 add
函数返回两个正数的预期和。Google Test 提供了各种断言,如 EXPECT_GT
(大于)、EXPECT_TRUE
(布尔 true
)等,以适应不同的测试场景。
EXPECT_*
断言和 ASSERT_*
断言之间的关键区别在于它们在失败时的行为。虽然 EXPECT_*
断言允许测试在失败后继续运行,但 ASSERT_*
断言将在失败时立即停止当前测试函数。当测试的后续行不依赖于当前断言的成功时,使用 EXPECT_*
;当断言的失败会使测试的继续进行没有意义或可能引起错误时,使用 ASSERT_*
。
使用测试夹具
对于需要为多个测试用例提供共同设置和清理的测试,Google Test 提供了测试夹具的概念。这是通过定义一个从 ::testing::Test
派生的类,然后使用 TEST_F
宏来编写使用此夹具的测试来实现的:
class CalculatorTest : public ::testing::Test {
protected:
void SetUp() override {
// Code here will be called immediately before each test
calculator.reset(new Calculator());
}
void TearDown() override {
// Code here will be called immediately after each test
calculator.reset();
}
std::unique_ptr<Calculator> calculator;
};
TEST_F(CalculatorTest, CanAddPositiveNumbers) {
EXPECT_EQ(5, calculator->add(2, 3));
}
TEST_F(CalculatorTest, CanAddNegativeNumbers) {
EXPECT_EQ(-5, calculator->add(-2, -3));
}
在这个示例中,SetUp
和 TearDown
被覆盖以提供每个测试用例的共同设置(初始化一个 Calculator
对象)和清理(清理 Calculator
对象)。使用 TEST_F
来定义自动使用此设置和清理的测试函数,确保每个测试从一个全新的 Calculator
实例开始。
主函数
要运行测试,Google Test 需要一个主函数来初始化 Google Test 框架并运行所有测试。以下是一个示例:
#include <gtest/gtest.h>
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
这个主函数初始化 Google Test,将命令行参数传递给它,这允许从命令行控制测试执行。RUN_ALL_TESTS()
运行所有已定义的测试,如果所有测试都通过则返回 0
,否则返回 1
。
通过遵循这些示例和解释,你可以开始使用 Google Test 为你的 C++ 项目编写全面的测试,确保你的代码在各种场景下都能按预期行为。
运行 Google Test 测试
在将 Google Test 与你的 CMake 项目设置好并编译了测试之后,运行测试非常直接。你通过在构建目录中使用 ctest
命令来执行测试,CMake 使用这个命令来运行你在 CMakeLists.txt
文件中定义的测试。
当你直接执行测试二进制文件时,为 Calculator
类运行测试,你的终端的标准输出可能看起来像这样:
$ cd path/to/build
[==========] Running 4 tests from 2 test suites.
[----------] Global test environment set-up.
[----------] 2 tests from AdditionTests
[ RUN ] AdditionTests.HandlesZeroInput
[ OK ] AdditionTests.HandlesZeroInput (0 ms)
[ RUN ] AdditionTests.HandlesPositiveInput
[ OK ] AdditionTests.HandlesPositiveInput (0 ms)
[----------] 2 tests from AdditionTests (0 ms total)
[----------] 2 tests from SubtractionTests
[ RUN ] SubtractionTests.HandlesZeroInput
[ OK ] SubtractionTests.HandlesZeroInput (0 ms)
[ RUN ] SubtractionTests.HandlesPositiveInput
[ OK ] SubtractionTests.HandlesPositiveInput (0 ms)
[----------] 2 tests from SubtractionTests (0 ms total)
[----------] Global test environment tear-down
[==========] 4 tests from 2 test suites ran. (1 ms total)
[ PASSED ] 4 tests.
这个输出详细说明了每个测试套件和测试用例,显示哪些测试被执行了([ RUN ]
)以及它们的结果(通过测试的 [ OK ]
)。它提供了测试过程的清晰分解,包括设置和清理阶段,并在最后汇总结果。
如果你使用 ctest
运行测试,默认情况下输出更简洁:
$ ctest
Test project /path/to/build
Start 1: AdditionTests.HandlesZeroInput
1/4 Test #1: AdditionTests.HandlesZeroInput ...... Passed 0.01 sec
Start 2: AdditionTests.HandlesPositiveInput
2/4 Test #2: AdditionTests.HandlesPositiveInput ... Passed 0.01 sec
Start 3: SubtractionTests.HandlesZeroInput
3/4 Test #3: SubtractionTests.HandlesZeroInput ..... Passed 0.01 sec
Start 4: SubtractionTests.HandlesPositiveInput
4/4 Test #4: SubtractionTests.HandlesPositiveInput .. Passed 0.01 sec
100% tests passed, 0 tests failed out of 4
在这个 ctest
输出中,每一行对应一个测试用例,显示其开始顺序、名称和结果。最后的总结提供了一个快速概览,包括测试总数、通过的数量和失败的数量。这种格式对于快速评估你的测试套件的健康状况非常有用,而不需要 Google Test 输出提供的详细分解。
Google Test 的高级功能
Google Test 提供了一系列旨在处理复杂测试场景的高级功能,为开发者提供了强大的工具,以确保其代码的健壮性。在这些功能中,一个值得注意的能力是支持 死亡测试。死亡测试在验证代码在遇到致命条件(如失败的断言或显式调用 abort()
)时表现出预期的行为特别有用。在需要确保应用程序能够适当地响应不可恢复的错误的情况下,这一点至关重要,从而增强了其可靠性和安全性。
以下是一个死亡测试的简要示例:
void risky_function(bool trigger) {
if (trigger) {
assert(false && “Triggered a fatal error”);
}
}
TEST(RiskyFunctionTest, TriggersAssertOnCondition) {
EXPECT_DEATH_IF_SUPPORTED(risky_function(true), “Triggered a fatal error”);
}
在这个例子中,EXPECT_DEATH_IF_SUPPORTED
检查 risky_function(true)
是否确实导致程序退出(由于失败的断言),并且它与指定的错误消息相匹配。这确保了函数在致命条件下表现出预期的行为。
Google Test 的其他高级功能包括用于模拟复杂对象交互的 mocking,用于使用各种输入运行相同测试逻辑的 参数化测试,以及用于在不同数据类型上应用相同测试逻辑的 类型参数化测试。这些功能使得可以实施全面的测试策略,覆盖广泛的场景和输入,确保对代码进行彻底的验证。
对于寻求充分利用 Google Test 的全部潜力,包括死亡测试等高级功能的开发者来说,官方 Google Test 文档是一个无价资源。它提供了详细的解释、示例和最佳实践,指导你了解 C++ 项目中有效编写和执行测试的细微差别。通过参考此文档,你可以加深对 Google Test 功能的理解,并有效地将其集成到测试工作流程中。
在 C++ 项目中使用 gMock
在软件测试的世界中,尤其是在 TDD 方法论中,模拟对象扮演着至关重要的角色。它通过实现相同的接口来模拟真实对象的行为,允许它在测试中代替实际对象。然而,模拟对象的力量在于其灵活性;开发者可以在运行时指定其行为,包括调用的方法、调用顺序、频率、参数指定和返回值。这种程度的控制使模拟对象成为测试代码中交互和集成的强大工具。
模拟对象解决了测试复杂或相互关联的系统中的几个挑战。在开发原型或测试时,由于外部依赖、执行时间或与真实操作相关的成本等限制,仅依赖真实对象可能不可行或不切实际。在这种情况下,模拟对象提供了一个轻量级、可控制的替代品,它复制了必要的交互,而没有真实实现的开销或副作用。这使得开发者能够专注于组件的行为和集成,而不是其底层实现,从而促进更专注和高效的测试。
伪造对象与模拟对象之间的区别对于理解它们的适当用例至关重要。虽然两者都充当真实对象的替代品用于测试,但它们具有不同的特性和目的:
-
伪造对象:这些是简化实现,模仿真实对象,但通常为了测试效率而采取捷径。一个例子是内存数据库,它复制了真实数据库系统的功能,但没有持久存储。伪造对象适用于那些对真实对象的精确工作原理不是审查重点的测试。
-
模拟对象:与伪造对象不同,模拟对象预先编程了特定的期望,形成了一种如何使用的契约。它们非常适合测试被测试系统及其依赖项之间的交互。例如,当测试一个依赖于服务的类时,可以使用服务的模拟对象来确保该类以预期的方式与服务交互,而无需实际调用服务的真实实现。
gMock 是 Google 为 C++创建模拟类的框架,它提供了一个类似于 jMock 和 EasyMock 为 Java 提供的全面解决方案。使用 gMock,开发者首先使用宏描述要模拟的对象的接口,然后生成模拟类实现。然后,开发者可以实例化模拟对象,使用 gMock 直观的语法设置它们的预期行为和交互。在测试执行期间,gMock 监控这些模拟对象,确保所有指定的交互都符合定义的期望,并将任何偏差标记为错误。这种即时反馈对于识别组件与其依赖项交互中的问题非常有价值。
gMock 的使用示例
在单元测试中,尤其是在与网络操作接口时,模拟是一种非常有价值的技巧。这以Socket
类为例,它是网络通信的基础元素。Socket
类抽象了在网络中发送和接收原始字节数组的功能,提供了send
和recv
等方法。具体的类如TcpSocket
、UdpSocket
和WebSocket
扩展了这个基类以实现特定的网络协议。以下代码显示了Socket
类的定义:
class Socket {
public:
// sends raw byte array of given size, returns number of bytes sent
// or -1 in case of error
virtual ssize_t send(void* data, size_t size) = 0;
// receives raw byte array of given size, returns number of bytes received
// or -1 in case of error
virtual ssize_t recv(void* data, size_t size) = 0;
};
例如,DataSender
类依赖于一个Socket
实例来发送数据。这个类精心设计以管理数据传输,必要时尝试重试,并处理各种场景,如部分数据发送、对等方发起的连接关闭和连接错误。在单元测试DataSender
时的目标是验证它在这些不同场景中的行为,而不进行实际的网络通信。DataSender
类的定义如下:
struct DataSentParitally {};
struct ConnectionClosedByPeer {};
struct ConnectionError {};
// Class under test
class DataSender {
static constexpr size_t RETRY_NUM = 2;
public:
DataSender(Socket* socket) : _socket{socket} {}
void send() {
auto data = std::array<int, 32>{};
auto bytesSent = 0;
for (size_t i = 0; i < RETRY_NUM && bytesSent != sizeof(data); ++i) {
bytesSent = _socket->send(&data, sizeof(data));
if (bytesSent < 0) {
throw ConnectionError{};
}
if (bytesSent == 0) {
throw ConnectionClosedByPeer{};
}
}
if (bytesSent != sizeof(data)) {
throw DataSentParitally{};
}
}
private:
Socket* _socket;
};
这个要求引导我们使用一个从Socket
派生的MockSocket
类来模拟网络交互。以下是MockSocket
的定义:
class MockSocket : public Socket {
public:
MOCK_METHOD(ssize_t, send, (void* data, size_t size), (override));
MOCK_METHOD(ssize_t, recv, (void* data, size_t size), (override));
};
MockSocket
类使用 gMock 的MOCK_METHOD
宏来模拟Socket
类的send
和recv
方法,允许在测试期间指定预期的行为。override
关键字确保这些模拟方法正确地覆盖了Socket
类中的对应方法。
在 gMock 中设置期望使用诸如WillOnce
和WillRepeatedly
之类的构造,这些构造定义了当调用模拟方法时它们的行为:
TEST(DataSender, HappyPath) {
auto socket = MockSocket{};
EXPECT_CALL(socket, send(_, _)).Times(1).WillOnce(Return(32 * sizeof(int)));
auto sender = DataSender(&socket);
sender.send();
}
在这个HappyPath
测试中,EXPECT_CALL
设置了一个期望,即send
将被调用一次,成功地在单次尝试中传输所有数据。
TEST(DataSender, SendSuccessfullyOnSecondAttempt) {
auto socket = MockSocket{};
EXPECT_CALL(socket, send(_, _)).Times(2)
.WillOnce(Return(2 * sizeof(int)))
.WillOnce(Return(32 * sizeof(int)));
auto sender = DataSender(&socket);
sender.send();
}
这个测试期望对send
进行两次调用:第一次只传输部分数据,而第二次完成传输,模拟第二次尝试成功的send
。
其余的测试检查各种错误场景,例如部分数据传输、由对等方关闭连接以及连接错误。以下是一个测试数据部分发送场景的示例:
TEST(DataSender, DataSentParitally) {
auto socket = MockSocket{};
EXPECT_CALL(socket, send(_, _)).Times(2)
.WillRepeatedly(Return(2 * sizeof(int)));
auto sender = DataSender(&socket);
EXPECT_THROW(sender.send(), DataSentParitally);
}
TEST(DataSender, ConnectionClosedByPeer) {
auto socket = MockSocket{};
EXPECT_CALL(socket, send(_, _)).Times(1)
.WillRepeatedly(Return(0 * sizeof(int)));
auto sender = DataSender(&socket);
EXPECT_THROW(sender.send(), ConnectionClosedByPeer);
}
TEST(DataSender, ConnectionError) {
auto socket = MockSocket{};
EXPECT_CALL(socket, send(_, _)).Times(1)
.WillRepeatedly(Return(-1 * sizeof(int)));
auto sender = DataSender(&socket);
EXPECT_THROW(sender.send(), ConnectionError);
}
使用 gMock 运行这些测试并观察输出,使我们能够确认DataSender
类在各种条件下的行为:
[==========] Running 5 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 5 tests from DataSender
[ RUN ] DataSender.HappyPath
[ OK ] DataSender.HappyPath (0 ms)
[ RUN ] DataSender.SendSuccessfullyOnSecondAttempt
[ OK ] DataSender.SendSuccessfullyOnSecondAttempt (0 ms)
[ RUN ] DataSender.DataSentPartially
[ OK ] DataSender.DataSentPartially (1 ms)
[ RUN ] DataSender.ConnectionClosedByPeer
[ OK ] DataSender.ConnectionClosedByPeer (0 ms)
[ RUN ] DataSender.ConnectionError
[ OK ] DataSender.ConnectionError (0 ms)
[----------] 5 tests from DataSender (1 ms total)
[----------] Global test environment tear-down
[==========] 5 tests from 1 test suite ran. (1 ms total)
[ PASSED ] 5 tests.
输出简洁地报告了每个测试的执行和结果,表明DataSender
类成功处理了不同的网络通信场景。有关利用 gMock 的更全面细节,包括其完整功能套件,官方 gMock 文档是一个基本资源,指导开发者通过有效的模拟策略进行 C++单元测试。
通过依赖注入模拟非虚拟方法
在某些场景下,你可能需要模拟非虚拟方法进行单元测试。这可能具有挑战性,因为传统的模拟框架如 gMock 主要针对虚拟方法,这是由于 C++的多态性要求。然而,一种克服这种限制的有效策略是通过依赖注入,结合模板的使用。这种方法通过解耦类依赖来增强可测试性和灵活性。
为了可测试性进行重构
为了说明这一点,让我们重构Socket
类接口和DataSender
类以适应非虚拟方法的模拟。我们将在DataSender
中引入模板,允许注入真实的Socket
类或其模拟版本。
首先,考虑一个没有虚拟方法的Socket
类的简化版本:
class Socket {
public:
// sends raw byte array of given size, returns number of bytes sent
// or -1 in case of error
ssize_t send(void* data, size_t size);
// receives raw byte array of given size, returns number of bytes received
// or -1 in case of error
ssize_t recv(void* data, size_t size);
};
接下来,我们修改DataSender
类以接受一个用于 socket 类型的template
参数,允许在编译时注入真实 socket 或模拟 socket:
template<typename SocketType>
class DataSender {
static constexpr size_t RETRY_NUM = 2;
public:
DataSender(SocketType* socket) : _socket{socket} {}
void send() {
auto data = std::array<int, 32>{};
auto bytesSent = 0;
for (size_t i = 0; i < RETRY_NUM && bytesSent != sizeof(data); ++i) {
bytesSent = _socket->send(&data, sizeof(data));
if (bytesSent < 0) {
throw ConnectionError{};
}
if (bytesSent == 0) {
throw ConnectionClosedByPeer{};
}
}
if (bytesSent != sizeof(data)) {
throw DataSentPartially{};
}
}
private:
SocketType* _socket;
};
使用这种基于模板的设计,DataSender
现在可以用符合Socket
接口的任何类型实例化,包括模拟类型。
使用模板进行模拟
对于Socket
的模拟版本,我们可以定义一个MockSocket
类如下:
class MockSocket {
public:
MOCK_METHOD(ssize_t, send, (void* data, size_t size), ());
MOCK_METHOD(ssize_t, recv, (void* data, size_t size), ());
};
这个MockSocket
类模仿了Socket
接口,但使用 gMock 的MOCK_METHOD
来定义模拟方法。
基于依赖注入的单元测试
当为DataSender
编写测试时,我们现在可以使用模板注入MockSocket
:
TEST(DataSender, HappyPath) {
MockSocket socket;
EXPECT_CALL(socket, send(_, _)).Times(1).WillOnce(Return(32 * sizeof(int)));
DataSender<MockSocket> sender(&socket);
sender.send();
}
在这个测试中,使用MockSocket
实例化DataSender
,允许按需模拟send
方法。这展示了模板和依赖注入如何使非虚拟方法的模拟成为可能,为 C++中的单元测试提供了一种灵活且强大的方法。
这种技术虽然强大,但需要仔细的设计考虑,以确保代码保持整洁和可维护。对于复杂场景或对模拟策略的进一步探索,官方 gMock 文档仍然是一个无价资源,提供了关于高级模拟技术和最佳实践的丰富信息。
模拟 Singleton
尽管由于其可能引入全局状态和软件设计中的紧密耦合而被视为反模式,但 Singleton 模式在许多代码库中仍然很普遍。它确保类只有一个实例的便利性通常导致其在数据库连接等场景中使用,在这些场景中,一个共享资源在逻辑上是合适的。
Singleton 模式限制类实例化和提供全局访问点的特性为单元测试带来了挑战,尤其是在需要模拟单例行为时。
考虑一个实现为单例的Database
类的示例:
class Database {
public:
std::vector<std::string> query(uint32_t id) const {
return {};
}
static Database& getInstance() {
static Database db;
return db;
}
private:
Database() = default;
};
在这种情况下,DataHandler
类与Database
单例交互以执行操作,例如查询数据:
class DataHandler {
public:
DataHandler() {}
void doSomething() {
auto& db = Database::getInstance();
auto result = db.query(42);
}
};
为了便于测试DataHandler
类而不依赖于真实的Database
实例,我们可以引入一个模板变体DataHandler1
,允许注入模拟数据库实例:
template<typename Db>
class DataHandler1 {
public:
DataHandler1() {}
std::vector<std::string> doSomething() {
auto& db = Db::getInstance();
auto result = db.query(42);
return result;
}
};
这种方法利用模板将DataHandler1
与具体的Database
单例解耦,允许在测试期间用MockDatabase
替换:
class MockDatabase {
public:
std::vector<std::string> query(uint32_t id) const {
return {“AAA”};
}
static MockDatabase& getInstance() {
static MockDatabase db;
return db;
}
};
在MockDatabase
就位后,单元测试现在可以模拟数据库交互而不实际接触数据库,如下面的测试用例所示:
TEST(DataHandler, check) {
auto dh = DataHandler1<MockDatabase>{};
EXPECT_EQ(dh.doSomething(), std::vector<std::string>{“AAA”});
}
这个测试实例化了DataHandler1
与MockDatabase
,确保doSomething
方法与模拟对象而不是真实数据库交互。预期的结果是预定义的模拟响应,使测试可预测且与外部依赖隔离。
这个模板解决方案是之前讨论过的依赖注入技术的变体,展示了 C++模板的灵活性和强大功能。它优雅地解决了模拟单例的挑战,从而增强了依赖于单例实例的组件的可测试性。对于更复杂的场景或对模拟策略的进一步探索,建议参考官方 gMock 文档,因为它提供了对高级模拟技术和最佳实践的全面见解。
和善的、严格的和挑剔的
在使用 gMock 进行单元测试的世界中,管理模拟对象的行为及其与被测试系统的交互至关重要。gMock 引入了三种模式来控制这种行为:挑剔的、和善的和严格的。这些模式决定了 gMock 如何处理不感兴趣的调用——那些与任何EXPECT_CALL
不匹配的调用。
挑剔的模拟
默认情况下,gMock 中的模拟对象是“挑剔”的。这意味着虽然它们会警告不感兴趣的调用,但这些调用不会导致测试失败。警告的作用是提醒可能存在与模拟对象的意外交互,但这并不足以引起测试失败。这种行为确保测试专注于预期的期望,而不会对偶然的交互过于宽容或过于严格。
考虑以下测试场景:
TEST(DataSender, Naggy) {
auto socket = MockSocket{};
EXPECT_CALL(socket, send(_, _)).Times(1).WillOnce(Return(32 * sizeof(int)));
auto sender = DataSender(&socket);
sender.send();
}
在这种情况下,如果有一个对recv
的不感兴趣调用,gMock 会发出警告,但测试会通过,标记出未预料的交互而不使测试失败。
和善的模拟
NiceMock
对象通过抑制不感兴趣调用的警告更进一步。这种模式在测试的重点严格限于特定交互时很有用,其他对模拟的偶然调用应该被忽略,而不会在测试输出中添加警告。
在测试中使用NiceMock
的示例如下:
TEST(DataSender, Nice) {
auto socket = NiceMock<MockSocket>{};
EXPECT_CALL(socket, send(_, _)).Times(1).WillOnce(Return(32 * sizeof(int)));
auto sender = DataSender(&socket);
sender.send();
}
在这个和善
模式下,即使有对recv
的不感兴趣调用,gMock 也会默默地忽略它们,保持测试输出干净并专注于定义的期望。
严格的模拟
在光谱的另一端,StrictMock
对象将不感兴趣的调用视为错误。这种严格性确保了与模拟对象的每次交互都通过EXPECT_CALL
得到记录。这种模式在需要精确控制模拟交互的测试中特别有用,任何与预期调用不符的偏差都应导致测试失败。
使用StrictMock
的测试可能看起来像这样:
TEST(DataSender, Strict) {
auto socket = StrictMock<MockSocket>{};
EXPECT_CALL(socket, send(_, _)).Times(1).WillOnce(Return(32 * sizeof(int)));
auto sender = DataSender(&socket);
sender.send();
}
在严格
模式下,任何不感兴趣的调用,例如对recv
的调用,都会导致测试失败,强制遵守定义的期望。
测试输出和推荐设置
这些模拟模式的行为反映在测试输出中:
Program returned: 1
Program stdout
[==========] Running 3 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 3 tests from DataSender
[ RUN ] DataSender.Naggy
GMOCK WARNING:
Uninteresting mock function call - returning default value.
Function call: recv(0x7ffd4aae23f0, 128)
Returns: 0
NOTE: You can safely ignore the above warning unless this call should not happen. Do not suppress it by blindly adding an EXPECT_CALL() if you don’t mean to enforce the call. See https://github.com/google/googletest/blob/master/googlemock/docs/cook_book.md#knowing-when-to-expect for details.
[ OK ] DataSender.Naggy (0 ms)
[ RUN ] DataSender.Nice
[ OK ] DataSender.Nice (0 ms)
[ RUN ] DataSender.Strict
unknown file: Failure
Uninteresting mock function call - returning default value.
Function call: recv(0x7ffd4aae23f0, 128)
Returns: 0
[ FAILED ] DataSender.Strict (0 ms)
[----------] 3 tests from DataSender (0 ms total)
[----------] Global test environment tear-down
[==========] 3 tests from 1 test suite ran. (0 ms total)
[ PASSED ] 2 tests.
[ FAILED ] 1 test, listed below:
[ FAILED ] DataSender.Strict
1 FAILED TEST
在 Naggy
模式下,测试通过时会有关于不感兴趣调用的警告。Nice
模式同样通过测试,但没有警告。然而,Strict
模式如果存在不感兴趣的调用,则会失败测试。
建议从 StrickMock
开始,根据需要逐步放宽模式。这种方法确保测试最初对与模拟对象的交互非常严格,为意外的调用提供安全网。随着测试套件的成熟和预期交互的日益清晰,模式可以放宽到 Naggy
或 Nice
,以减少测试输出中的噪音。
为了进一步探索这些模式和高级模拟技术,官方 gMock 文档提供了全面的见解和示例,指导开发者通过有效的模拟对象管理进行单元测试。
在本节中,我们深入探讨了 Google Test (GTest) 和 Google Mock (GMock) 的功能及其在 C++ 项目测试框架和开发工作流程中的应用。GTest 提供了一个强大的环境,用于创建、管理和执行单元测试,包括用于共享设置和清理例程的测试固定装置,用于不同输入测试的参数化测试,以及用于在不同数据类型上应用相同测试的类型参数化测试。其全面的断言库确保了对代码行为的彻底验证,从而有助于提高软件的稳定性和耐用性。
作为 GTest 的补充,GMock 允许无缝创建和使用模拟对象,通过模拟依赖项的行为来实现隔离组件测试。这在复杂系统中非常有价值,因为直接使用真实依赖项进行测试要么不切实际,要么适得其反。使用 GMock,开发者可以访问一系列功能,包括自动模拟生成、灵活的期望设置和详细的行为验证,从而实现组件交互的深入测试。
通过将 GTest 和 GMock 集成到 C++ 开发生命周期中,开发者可以采用稳健的测试驱动方法,确保代码质量,并促进持续测试和集成实践,最终导致更可靠、更易于维护的软件项目。
其他值得注意的 C++ 单元测试框架
除了 Google Test 和 Google Mock,C++ 生态系统还拥有丰富的单元测试框架,每个框架都提供独特的特性和哲学。这些框架满足各种测试需求和偏好,为开发者提供将单元测试集成到项目中的多种选择。
Catch2
Catch2 以其简洁性和易用性而突出,启动时需要最少的样板代码。它采用头文件只读分发,使得将其集成到项目中变得简单。Catch2 支持多种测试范式,包括 BDD 风格的测试用例,并提供增强测试可读性和意图的表达式断言宏。其突出特点是“部分”机制,它以灵活和分层的方式为测试之间的设置和清理代码提供了一种自然共享方式。
Boost.Test
作为 Boost 库的一部分,Boost.Test 为 C++的单元测试提供了稳健的支持。它提供了一个全面的断言框架、测试组织设施,并与 Boost 构建系统集成。Boost.Test 可以以头文件只读模式或编译模式使用,提供了部署的灵活性。它以其详细的测试结果报告和广泛的内置测试用例管理工具而闻名,使其适用于小型和大型项目。
Doctest
Doctest 的设计重点是简单性和速度,定位为最轻量级的特性丰富 C++测试框架。由于其快速的编译时间,它特别适合 TDD。受到 Catch2 的启发,Doctest 提供类似的语法,但旨在更轻量级和更快地编译,使其非常适合在日常开发中包含测试,而不会显著影响构建时间。
Google Test 与 Catch2 与 Boost.Test 与 Doctest 的比较
-
简单性:Catch2 和 Doctest 在简单性和易用性方面表现出色,Catch2 提供 BDD 风格的语法,而 Doctest 则非常轻量级
-
集成:Google Test 和 Boost.Test 将提供更广泛的集成功能,特别适合具有复杂测试需求的大型项目
-
性能:Doctest 在编译时间和运行时性能方面突出,使其非常适合快速开发周期
-
特性:Boost.Test 和 Google Test 自带更全面的特性集,包括高级测试用例管理和详细报告
选择正确的框架通常取决于项目特定的需求、开发者的偏好以及简单性、性能和特性丰富性之间的期望平衡。鼓励开发者进一步探索这些框架,以确定哪个最适合他们的单元测试需求,从而有助于构建更可靠、可维护和高质量的 C++软件。
适合单元测试的候选者
确定单元测试的最佳候选者对于建立稳健的测试策略至关重要。当应用于适合隔离和细粒度验证的代码库部分时,单元测试表现卓越。以下是一些关键示例和建议:
边界清晰且职责定义明确的类和函数是单元测试的理想候选者。这些组件理想情况下应体现单一职责原则,处理应用程序功能的一个特定方面。测试这些隔离的单元可以精确验证其行为,确保它们在各种条件下正确执行其预期任务。
依赖于其输入参数且不产生任何副作用的无副效应函数是单元测试的绝佳目标。它们的确定性——即给定的输入总是产生相同的输出——使得它们易于测试和验证。无副效应函数通常出现在实用库、数学计算和数据转换操作中。
通过定义良好的接口与依赖项交互的组件更容易进行测试,尤其是当这些依赖项可以轻松模拟或存根时。这有助于在隔离的情况下测试组件,专注于其逻辑而不是其依赖项的实现细节。
封装应用程序核心功能和规则的业务逻辑层通常非常适合单元测试。这一层通常涉及计算、数据处理和决策,这些可以在与用户界面和外部系统隔离的情况下进行测试。
虽然应用程序的许多方面都适合进行单元测试,但认识到具有挑战性的场景是明智的。需要与数据库、文件系统、网络服务等外部资源进行复杂交互的组件可能难以有效地模拟,或者可能由于对外部状态或行为的依赖而导致测试不稳定。虽然模拟可以模拟这些交互的一部分,但复杂性和开销可能并不总是证明在单元测试的背景下付出努力是合理的。
尽管单元测试对于验证单个组件非常有价值,但它们也有其局限性,尤其是在集成和端到端交互方面。对于本质难以隔离或需要复杂外部交互的代码,端到端(E2E)测试变得至关重要。端到端测试模拟真实世界的使用场景,涵盖了从用户界面到后端系统和外部集成的流程。在下一节中,我们将深入探讨端到端测试,探讨其在补充单元测试和提供应用程序功能全面覆盖方面的作用。
软件开发中的端到端测试
E2E 测试是一种全面的测试方法,它从开始到结束评估应用程序的功能和性能。与单元测试不同,单元测试是隔离和测试单个组件或代码单元,E2E 测试则将应用程序作为一个整体进行考察,模拟现实世界的用户场景。这种方法确保了应用程序的所有各种组件,包括其接口、数据库、网络和其他服务,能够和谐地工作,以提供预期的用户体验。
E2E 测试框架
由于 E2E 测试通常涉及从外部与应用程序交互,它并不局限于应用程序所使用的语言。对于可能是更大生态系统的一部分或作为后端系统的 C++ 应用程序,可以使用不同语言的多种框架进行 E2E 测试。以下是一些流行的 E2E 测试框架:
-
Selenium:主要用于 Web 应用程序,Selenium 可以自动化浏览器来模拟用户与 Web 界面的交互,使其成为 E2E 测试的多功能工具
-
Cypress:另一个强大的 Web 应用程序工具,Cypress 提供了一种更现代、更友好的 E2E 测试方法,具有丰富的调试功能和强大的 API
-
Postman:对于暴露 RESTful API 的应用程序,Postman 允许进行全面的 API 测试,确保应用程序的端点在各种条件下都能按预期执行
何时使用 E2E 测试
在应用程序的组件必须以复杂的工作流程进行交互的场景中,E2E 测试尤其有价值,这通常涉及多个系统和外部依赖。对于以下方面至关重要:
-
测试复杂的用户工作流程:E2E 测试在验证跨越多个应用程序组件的用户旅程方面表现出色,确保从用户的角度来看体验无缝
-
集成场景:当应用程序与外部系统或服务交互时,E2E 测试验证这些集成是否按预期工作,捕捉到在隔离情况下可能不明显的问题
-
关键路径测试:对于对应用程序核心功能至关重要的功能和路径,E2E 测试确保在现实使用条件下可靠性和性能
适合进行 E2E 测试的情况
复杂交互
在应用程序的组件进行复杂交互的情况下,可能跨越不同的技术和平台,单元测试可能不足以满足需求。E2E 测试对于确保这些组件的集体行为与预期结果一致至关重要,尤其是在以下情况下:
图表中概述的架构代表了一个典型的 Web 应用程序,其中包含几个相互连接的服务,每个服务在系统中都扮演着独特的角色。
图 12.1 – E2E 测试
在前端,有一个用户界面(UI),这是用户与应用程序交互的图形界面。它设计用来通过API 网关向和从后端服务发送和接收数据。API 网关充当一个中介,将用户 UI 的请求路由到适当的后端服务,并将响应聚合后发送回 UI。
几个后端服务如下所示:
-
账户管理:这项服务处理用户账户,包括身份验证、资料管理以及其他与用户相关的数据。
-
计费:负责管理计费信息、订阅和发票。
-
支付:处理金融交易,例如信用卡处理或与支付网关接口。
-
通知:向用户发送警报或消息,可能由账户管理或计费服务中的某些事件触发。
外部服务,可能是第三方应用程序或数据提供者,也可以与 API 网关交互,提供支持主应用程序的附加功能或数据。
对于该系统的端到端测试,测试将模拟用户在用户 UI 上的操作,例如注册账户或进行支付。然后,测试将验证 UI 是否正确通过 API 网关向后端服务发送适当的请求。随后,测试将确认用户 UI 对从后端接收的数据做出正确响应,确保从用户 UI 到通知的整个工作流程按预期运行。这种全面的测试方法确保每个组件单独以及与系统其他部分协同工作,为用户提供无缝体验。
总结来说,在应用程序的组件进行复杂交互的场景中,尤其是在这些交互跨越不同技术和平台时,考虑端到端测试是至关重要的。端到端测试确保这些组件的整体行为与预期结果一致,从而对应用程序的功能和性能进行全面评估。以下是一些端到端测试有益的常见情况:
-
多层应用:具有多个层或级别的应用,例如客户端-服务器架构,通过端到端测试(E2E testing)可以确保各层之间有效沟通。
-
分布式系统:对于分布在不同环境或服务中的应用程序,端到端测试可以验证这些分布式组件之间的数据流和功能。
真实环境测试
端到端测试的主要优势之一是它能够复制接近生产环境的条件。这包括在实际硬件上测试应用程序,与真实数据库交互,并通过真正的网络基础设施导航。这种测试级别对于以下方面至关重要:
-
性能验证:确保应用程序在预期的负载条件和用户流量下表现最优
-
安全保证:验证应用程序的安全措施在现实环境中是否有效,以防止潜在的安全漏洞
端到端测试(E2E testing)作为软件发布前的最终检查点,提供对应用程序部署准备情况的全面评估。通过模拟真实世界场景,端到端测试确保应用程序不仅满足其技术规范,而且提供可靠且用户友好的体验,使其成为软件开发生命周期的一个基本组成部分。
自动测试覆盖率跟踪工具
在确保软件项目全面测试的探索中,自动测试覆盖率跟踪工具扮演着关键角色。这些工具提供了宝贵的见解,了解应用程序源代码在测试期间执行的程度,突出了测试良好的区域以及可能需要额外注意的区域。
自动测试覆盖率跟踪工具及示例
确保全面的测试覆盖率是可靠软件开发的基础。例如,gcov
和 llvm-cov
等工具自动化了测试覆盖率的跟踪,提供了关于测试如何彻底执行代码的关键见解。
工具概述及示例
在 C++ 项目中用于自动测试覆盖率跟踪的两个主要工具:
-
gcov
分析在测试运行期间代码中采取的执行路径。例如,使用g++ -fprofile-arcs -ftest-coverage example.cpp
编译 C++example.cpp
文件后,运行相应的测试套件会生成覆盖率数据。随后运行gcov example.cpp
会生成一份报告,详细说明每行代码被执行的次数。 -
llvm-cov
与 Clang 合作提供详细的覆盖率报告。使用clang++ -fprofile-instr-generate -fcoverage-mapping example.cpp
进行编译,然后使用LLVM_PROFILE_FILE="example.profraw" ./example
执行测试二进制文件以准备覆盖率数据。接着使用llvm-profdata merge -sparse example.profraw -o example.profdata
,然后使用llvm-cov show ./example -instr-profile=example.profdata
为example.cpp
生成覆盖率报告。
与 C++ 项目集成
将这些工具集成到 C++ 项目中涉及使用覆盖率标志编译源代码,执行测试以生成覆盖率数据,然后分析这些数据以生成报告。
对于包含多个文件的工程,你可能需要使用以下方式进行编译:
g++ -fprofile-arcs -ftest-coverage file1.cpp file2.cpp -o testExecutable
在运行 ./testExecutable
执行测试后,使用 gcov file1.cpp file2.cpp
为每个源文件生成覆盖率报告。
使用 llvm-cov
,过程类似,但针对 Clang 进行了优化。在编译和测试执行后,使用 llvm-profdata
合并配置文件数据,并用 llvm-cov
生成报告,可以提供一个全面的测试覆盖率视图。
解释覆盖率报告
这些工具生成的覆盖率报告提供了几个指标:
-
gcov
报告可能声明Lines executed:90.00% of 100
,意味着在测试中运行了 90 行中的 100 行。 -
gcov
报告如Branches executed:85.00% of 40
显示了 85% 的所有分支都被测试了。 -
gcov
报告中的Functions executed:95.00% of 20
指示在测试中调用了 95% 的函数。
例如,简化的 gcov
报告可能看起来像这样:
File ‘example.cpp’
Lines executed:90.00% of 100
Branches executed:85.00% of 40
Functions executed:95.00% of 20
类似地,llvm-cov
报告提供了详细的覆盖率指标,包括具体的行和分支覆盖情况,增强了定位需要额外测试区域的能力。
这些报告通过突出未测试的代码路径和函数,指导开发者提高测试覆盖率,但它们不应是测试质量的唯一指标。高覆盖率但设计不佳的测试可能会给人一种虚假的安全感。有效使用这些工具不仅需要追求高覆盖率百分比,还需要确保测试是有意义且反映真实世界使用场景的。
利用命中图进行增强的测试覆盖率分析
由 gcov
和 llvm-cov
等测试覆盖率跟踪工具生成的命中图,提供了对测试如何执行代码的细粒度视图,为旨在提高测试覆盖率的开发者提供了详细的指南。这些命中图超越了简单的百分比指标,精确地显示了测试期间执行了哪些代码行以及执行了多少次,从而使得增强测试套件的方法更加明智。
理解命中图
命中图本质上是对源代码的详细注释,每行都伴随着执行次数,表明测试运行了该特定行的次数。这种详细程度有助于识别代码中未测试的部分,以及可能过度测试或需要更多测试场景的区域。
由 gcov
生成的 .gcov
文件和由 llvm-cov
生成的带注释的源代码提供了这些命中图,清晰地展示了行级别的测试覆盖率。
-: 0:Source:example.cpp
-: 0:Graph:example.gcno
-: 0:Data:example.gcda
-: 0:Runs:3
-: 0:Programs:1
3: 1:int main() {
-: 2: // Some comment
2: 3: bool condition = checkCondition();
1: 4: if (condition) {
1: 5: performAction();
...
在这个例子中,第 3 行 (bool condition = checkCondition();
) 执行了两次,而 if
语句内的 performAction();
行只执行了一次,表明在某个测试运行中条件为 true
。
与 gcov
类似,在用 -fprofile-instr-generate -fcoverage-mapping
标志编译 clang++
并执行测试后,llvm-cov
可以使用带有 -instr-profile
标志的 llvm-cov show
命令生成命中图,指向生成的配置文件数据。例如,llvm-cov show ./example -instr-profile=example.profdata example.cpp
输出带有执行次数的带注释的源代码。
输出将类似于以下内容:
example.cpp:
int main() {
| 3| // Some comment
| 2| bool condition = checkCondition();
| 1| if (condition) {
| 1| performAction();
...
在这里,执行次数作为每行的前缀,提供了一目了然地测试覆盖率图。
利用命中图进行测试改进
通过检查击中图,开发者可以识别出没有任何测试用例覆盖的代码部分,这由零执行次数指示。这些区域代表未检测到的潜在风险,应优先考虑进行额外的测试。相反,执行次数异常高的行可能表明测试是冗余的或过度集中的,这表明有机会多样化测试场景或重新聚焦测试努力,以覆盖代码库中覆盖较少的部分。
将击中图分析纳入常规开发工作流程鼓励了一种积极主动的方法来维护和增强测试覆盖率,确保测试保持有效并与不断发展的代码库保持一致。与所有测试策略一样,目标不仅仅是实现高覆盖率数字,而是确保测试套件在各种场景下全面验证软件的功能性和可靠性。
将击中图(hit maps)集成到开发工作流程中,随着集成开发环境(IDE)插件的推出变得更加容易,这些插件将覆盖率可视化直接集成到编码环境中。一个显著的例子是 Markis Taylor 为Visual Studio Code(VSCode)开发的“Code Coverage”插件。此插件将击中图叠加到 VSCode 编辑器中的源代码上,提供关于测试覆盖率的即时视觉反馈。
“Code Coverage”插件处理由gcov
或llvm-cov
等工具生成的覆盖率报告,并在 VSCode 中可视化地注释源代码。被测试覆盖的代码行通常以绿色突出显示,而未覆盖的行则以红色标记。这种即时的视觉表示允许开发者快速识别未测试的代码区域,而无需离开编辑器或通过外部覆盖率报告进行导航。
代码覆盖率建议
代码覆盖率是软件测试领域的一个关键指标,它提供了关于测试套件对代码库执行程度的见解。对于 C++项目,利用gcov
(用于 GCC)和llvm-cov
(用于 LLVM 项目)等工具可以提供详细的覆盖率分析。这些工具不仅擅长跟踪单元测试的覆盖率,还擅长跟踪端到端测试的覆盖率,从而允许对不同测试级别的测试覆盖率进行全面评估。
一个稳健的测试策略涉及集中单元测试的组合,这些测试在隔离的情况下验证单个组件,以及更广泛的端到端(E2E)测试,这些测试评估系统的整体功能。通过使用gcov
或llvm-cov
,团队可以聚合这两种测试类型的覆盖率数据,提供项目测试覆盖率的整体视图。这种结合的方法有助于识别代码中测试不足或根本未测试的区域,指导努力提高测试套件的有效性。
建议密切关注代码覆盖率指标,并努力防止覆盖率百分比下降。覆盖率下降可能表明添加了新代码但没有进行充分的测试,可能会将未检测到的错误引入系统中。为了减轻这种风险,团队应将覆盖率检查集成到他们的持续集成(CI)管道中,确保任何减少覆盖率的更改都能及时识别并解决。
定期分配时间专门用于增加测试覆盖率是有益的,尤其是在被识别为关键或风险区域。这可能涉及为复杂的逻辑、边缘情况或之前被忽视的错误处理路径编写额外的测试。投资于覆盖率改进措施不仅提高了软件的可靠性,而且从长远来看有助于创建更易于维护和健壮的代码库。
摘要
本章全面概述了 C++中的测试,涵盖了从单元测试基础到高级端到端测试的必要主题。你了解了单元测试在确保各个组件正确工作中的作用,以及像 Google Test 和 Google Mock 这样的工具如何帮助有效地编写和管理这些测试。本章还简要介绍了测试中模拟复杂行为的模拟技术。
此外,讨论了使用gcov
和llvm-cov
等工具跟踪测试覆盖率的重要性,强调了随着时间的推移保持和改进覆盖率的需求。端到端测试被强调为检查整个应用程序功能的关键,它补充了更专注的单元测试。
通过探索不同的 C++测试框架,本章为开发者提供了各种工具的见解,帮助他们为项目选择正确的工具。本质上,本章为你提供了在 C++开发中实施全面和有效测试策略的知识,有助于创建可靠和健壮的软件。
在下一章中,我们将探讨 C++中第三方管理的现代方法,包括基于 Docker 的解决方案和可用的包管理器。
第十三章:管理第三方库的现代方法
在现代软件开发中,对第三方库的依赖几乎是不可避免的。从基础组件,如用于安全通信的 OpenSSL 和用于广泛 C++ 库的 Boost,甚至构成 C++ 编程基石的标准库,外部库对于构建功能强大和高效的程序至关重要。这种依赖性突显了理解如何在 C++ 生态系统中管理第三方库的重要性。
由于这些库的复杂性和多样性,开发人员掌握第三方库管理的基本知识至关重要。这种知识不仅有助于将这些库无缝集成到项目中,而且还会影响部署策略。库的编译方法,无论是静态还是动态,都会直接影响部署的文件数量和应用程序的整体影响范围。
与一些受益于标准化库生态系统的其他编程语言不同,C++ 由于缺乏这样的统一系统而面临独特的挑战。本章深入探讨了 C++ 中第三方库管理的现有解决方案,例如 vcpkg、Conan 以及其他工具。通过检查这些工具,我们旨在提供见解,了解哪种解决方案可能最适合您的项目需求,考虑因素包括平台兼容性、易用性和库目录的范围。
随着我们浏览这些解决方案,我们的目标是为您提供知识,以便您在 C++ 项目中集成和管理第三方库时做出明智的决定,从而提高您的开发工作流程和软件质量。
链接和共享 V 线程概述:线程静态库
在 C 和 C++ 开发背景下,第三方实体是开发人员将其集成到项目中的外部库或框架。这些实体旨在提高功能或利用现有解决方案。这些第三方组件在范围上可能差异很大,从最小实用库到提供广泛功能的全面框架。
将第三方库集成到项目中的过程涉及使用概述这些库接口的头文件。这些头文件包含库提供的类、函数和变量的声明,使编译器能够理解成功编译所需的签名和结构。在 C++ 源文件中包含头文件实际上是将头文件的内容连接到包含点,从而允许访问库的接口,而无需在源文件中嵌入实际的实现。
这些库的实现通过编译后的目标代码提供,通常以静态库或共享库的形式分发。静态库是目标文件的归档,由链接器直接合并到最终的可执行文件中,由于库代码的嵌入,导致可执行文件大小更大。另一方面,共享库,在 Windows 上称为动态链接库(DLLs),或在类 Unix 系统上称为共享对象(SOs),不会嵌入到可执行文件中。相反,包括对这些库的引用,操作系统在运行时将它们加载到内存中。这种机制允许多个应用程序利用相同的库代码,从而节省内存。
共享库旨在促进多个应用程序之间共享常见库,如 libc 或 C++标准库。这种做法对于频繁使用的库特别有利。这种设计从理论上也允许用户在不升级整个应用程序的情况下更新共享库。然而,在实践中,这并不总是无缝的,可能会引入兼容性问题,使得应用程序提供其依赖项作为共享库的优势减少。此外,选择共享库而不是静态库可以减少链接器时间,因为链接器不需要将库代码嵌入到可执行文件中,这可以加快构建过程。
链接器在这个过程中扮演着至关重要的角色,它将各种目标文件和库合并成一个单一的可执行文件或库,并在过程中解决符号引用,以确保最终的二进制文件完整且可执行。
在静态链接和动态链接之间的选择对应用程序的性能、大小和部署策略有显著影响。静态链接通过创建自包含的可执行文件简化了部署,但代价是文件大小更大,以及需要重新编译以更新库。动态链接,通过在应用程序之间共享库代码以减少内存使用并简化库更新,在部署中引入了复杂性,以确保满足所有依赖项。
考虑到与链接外部共享对象相关的复杂性以及 C++中模板代码的广泛应用,许多库开发者已经开始倾向于提供“仅头文件”库。仅头文件库是一个完全包含在头文件中的库,没有单独的实现文件或预编译的二进制文件。这意味着所有代码,包括函数和类定义,都包含在头文件中。
这种方法显著简化了集成过程。当开发者从一个仅包含头文件的库中包含头文件时,他们不仅仅是包含接口声明,还包括整个实现。因此,不需要对库的实现进行单独的编译或链接;当包含头文件时,编译器将库的代码直接包含并编译到开发者的源代码中。这种直接包含可能导致编译器进行更有效的内联和优化,从而可能由于消除了函数调用开销而生成更快的可执行代码。
然而,值得注意的是,虽然仅包含头文件的库提供了便利和易于集成的优势,但它们也有一些缺点。由于整个库被包含并编译到包含它的每个源文件中,这可能导致编译时间增加,尤其是在大型库或包含库在多个文件中的项目中。此外,任何对头文件的更改都需要重新编译包含它的所有源文件,这可能会进一步增加开发时间。
尽管存在缺点,但由于其分发和使用简单,C++中的仅包含头文件的方案对许多开发者和用户来说极具吸引力。此外,它有助于避免链接问题,并为模板密集型库提供好处。这种模式在那些大量使用模板的库中尤为普遍,例如提供元编程功能的库,因为模板必须在编译时完整地提供给编译器,这使得仅包含头文件的模型成为一种自然的选择。
从本质上讲,C++项目中第三方依赖项的管理涉及对头文件、静态库和共享库的深入了解,以及链接过程的复杂性。开发者必须仔细考虑在应用需求和部署环境中静态链接和动态链接之间的权衡,平衡性能、大小和维护便利性等因素。
C++中管理第三方库
管理第三方库是 C++开发的关键方面。虽然 C++没有标准化的包管理器,但已经采用了各种方法和工具来简化此过程,每种方法都有自己的实践和受支持的平台。
使用操作系统包管理器安装库
许多开发者依赖于操作系统的包管理器来安装第三方库。在 Ubuntu 和其他基于 Debian 的系统上,通常使用apt
:
sudo apt install libboost-all-dev
对于基于 Red Hat 的系统,yum
或其继任者dnf
是首选选项:
sudo yum install boost-devel
在 macOS 上,Homebrew 是管理包的流行选择:
brew install boost
Windows 用户通常转向 Chocolatey 或vcpkg
(后者也作为 Windows 之外的通用 C++库管理器使用):
choco install boost
这些操作系统包管理器对于常见库来说很方便,但可能并不总是提供最新的版本或开发所需的具体配置。
通过子模块使用 Git 作为第三方管理器
Git 子模块允许开发者直接在其仓库中包含和管理第三方库的源代码。此方法有利于确保所有团队成员和构建系统使用库的确切版本。添加子模块并将其与 CMake 集成的典型工作流程可能如下所示:
git submodule add https://github.com/google/googletest.git external/googletest
git submodule update --init
在CMakeLists.txt
中,您将包括子模块:
add_subdirectory(external/googletest)
include_directories(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR})
此方法将您的项目与特定版本的库紧密耦合,并通过 Git 促进更新跟踪。
使用 CMake FetchContent 下载库
CMake 的FetchContent
模块通过在配置时下载依赖项,而不需要在您的仓库中直接包含它们,提供了一种比子模块更灵活的替代方案:
include(FetchContent)
FetchContent_Declare(
json
GIT_REPOSITORY https://github.com/nlohmann/json.git
GIT_TAG v3.7.3
)
FetchContent_MakeAvailable(json)
此方法与 Git 子模块不同,因为它不需要库的源代码存在于您的仓库中或手动更新它。FetchContent
动态检索指定的版本,使其更容易管理和更新依赖项。
Conan – 高级依赖管理
Conan 是 C 和 C++的强大包管理器,简化了集成第三方库和在各种平台和配置中管理依赖项的过程。它因其能够处理库的多个版本、复杂的依赖图和不同的构建配置而脱颖而出,是现代 C++开发的必备工具。
Conan 配置和功能
Conan 的配置存储在conanfile.txt
或conanfile.py
中,开发者在此指定所需的库、版本、设置和选项。此文件作为项目依赖项的清单,允许对项目中使用的库进行精确控制。
关键特性:
-
多平台支持:Conan 旨在在 Windows、Linux、macOS 和 FreeBSD 上运行,在不同操作系统上提供一致的经验
-
构建配置管理:开发者可以指定设置,如编译器版本、架构和构建类型(调试、发布),以确保项目的兼容性和最佳构建
-
版本管理:Conan 可以管理同一库的多个版本,允许项目根据需要依赖特定版本
-
依赖解析:它自动解析和下载传递依赖项,确保所有必需的库在构建过程中可用
图书馆位置和康南中心
Conan 包的主要仓库是康南中心,这是一个包含大量开源 C 和 C++库的集合。康南中心是查找和下载包的首选地点,但开发者也可以为他们的项目指定自定义或私有仓库。
除了 Conan Center 之外,公司和开发团队可以托管自己的 Conan 服务器或使用 Artifactory 等服务来管理私有或专有包,从而在组织内部实现依赖项管理的集中化方法。
配置静态或动态链接
Conan 允许开发者指定是否为库使用静态或动态链接。这通常通过 conanfile.txt
或 conanfile.py
中的选项来完成。以下是一个示例:
[options]
Poco:shared=True # Use dynamic linking for Poco
Or in conanfile.py:
class MyProject(ConanFile):
requires = “poco/1.10.1”
default_options = {“poco:shared”: True}
这些设置指示 Conan 下载并使用指定库的动态版本。同样,将选项设置为 False
将优先考虑静态库。需要注意的是,并非所有包都支持这两种链接选项,这取决于它们是如何为 Conan 打包的。
通过自定义包扩展 Conan
Conan 的一个优势是其可扩展性。如果所需的库在 Conan Center 中不可用或不符合特定需求,开发者可以创建并贡献他们自己的包。Conan 提供了一个基于 Python 的开发工具包来创建包,其中包括定义构建过程、依赖项和包元数据的工具。
为了创建一个 Conan 包,开发者定义 conanfile.py
文件,该文件描述了如何获取、构建和打包库。该文件包括 source()
、build()
和 package()
等方法,这些方法在包创建过程中由 Conan 执行。
一旦开发了一个包,它可以通过提交以供包含或通过私有仓库进行分发来通过 Conan Center 进行共享,以保持对分发和使用的控制。
Conan 的灵活性、对多个平台和配置的支持以及其全面的包仓库使其成为 C++ 开发者的无价之宝。通过利用 Conan,团队可以简化他们的依赖管理流程,确保在不同环境中构建的一致性和可重复性。能够配置静态或动态链接,以及可以扩展仓库以包含自定义包的选项,突显了 Conan 对不同项目需求的适应性。无论是与广泛使用的开源库还是专用专有代码一起工作,Conan 都提供了一个强大而有效的框架来高效有效地管理 C++ 依赖项。
Conan 是一个专门的 C++ 包管理器,在管理库的不同版本及其依赖项方面表现出色。它独立于操作系统的包管理器,并提供高水平的控制和灵活性。典型的 Conan 工作流程涉及创建 conanfile.txt
或 conanfile.py
来声明依赖项。
CMake 集成
CMake 因其强大的脚本能力和跨平台支持而在 C++ 项目中得到广泛应用。将 Conan 与 CMake 集成可以显著简化依赖项管理的过程。以下是如何实现这种集成的步骤:
-
在项目的
CMakeLists.txt
中由 Conan 生成的conanbuildinfo.cmake
文件:include(${CMAKE_BINARY_DIR}/conanbuildinfo.cmake)
conan_basic_setup(TARGETS)
此脚本设置了必要的
include
路径和库路径,并定义了由 Conan 管理的依赖项,使它们可用于您的 CMake 项目。 -
conan_basic_setup()
中的TARGETS
选项为您的 Conan 依赖项生成 CMake 目标,允许您使用 CMake 中的target_link_libraries()
函数来链接它们:target_link_libraries(my_project_target CONAN_PKG::poco)
此方法提供了一种清晰明确的方式来链接您的项目目标到由 Conan 管理的库。
其他构建系统集成
Conan 的灵活性也扩展到其他构建系统,使其能够适应各种项目需求:
-
可包含在 Makefile 中的
include
路径、库路径和标志:include conanbuildinfo.mak
-
可导入到 Visual Studio 项目的
.props
文件,提供与 MSBuild 生态系统的无缝集成。 -
Bazel, Meson 和其他工具: 尽管对某些构建系统(如 Bazel 或 Meson)的直接支持可能需要自定义集成脚本或工具,但 Conan 社区通常贡献生成器和工具来弥合这些差距,使 Conan 能够扩展到几乎任何构建系统。
自定义集成
对于没有直接支持或对项目有独特要求的构建系统,Conan 提供了自定义生成文件或编写自定义生成器的功能。这允许开发者根据其特定的构建过程定制集成,使 Conan 成为依赖项管理的高度适应性的工具。
结论
Conan 与 CMake 和其他构建系统的集成凸显了其在 C++ 项目包管理器方面的多功能性。通过提供将依赖项纳入各种构建环境的简单机制,Conan 不仅简化了依赖项管理,还增强了不同平台和配置下的构建可重复性和一致性。无论您是在使用广泛使用的构建系统(如 CMake)还是更专业的设置,Conan 的灵活集成选项都能确保您能够保持高效和流畅的开发工作流程。
vcpkg
由微软开发的 vcpkg 是一个跨平台的 C++ 包管理器,简化了获取和构建 C++ 开源库的过程。它旨在与 CMake 和其他构建系统无缝协作,提供一种简单一致的方式来管理 C++ 库依赖项。
与 Conan 的关键区别
虽然 vcpkg 和 Conan 都旨在简化 C++ 项目的依赖项管理,但它们在方法和生态系统方面存在显著差异:
-
起源和支持: vcpkg 由微软创建并维护,确保了与 Visual Studio 和 MSBuild 系统的紧密集成,尽管它在不同的平台和开发环境中仍然完全功能性和有用。
-
包源:vcpkg 专注于从源编译,确保库使用与消费项目相同的编译器和设置进行构建。这种方法与 Conan 形成对比,Conan 可以管理预编译的二进制文件,允许更快地集成,但可能导致二进制不兼容问题。
-
集成:vcpkg 与 CMake 和 Visual Studio 原生集成,提供项目级集成的清单文件。这对于已经使用这些工具的项目尤其有吸引力,可以提供更无缝的集成体验。
-
生态系统和库:这两个包管理器都拥有大量可用的库,但它们的生态系统可能因每个项目的社区和支持而略有不同。
操作系统支持
vcpkg 旨在跨平台,支持以下平台:
-
Windows
-
Linux
-
macOS
这广泛的兼容性使其成为在多样化开发环境中工作的开发者的多功能选择。
使用 vcpkg 配置项目的示例
为了说明在项目中使用 vcpkg,让我们通过一个简单的示例来展示如何将库(例如,现代 C++的 JSON 库nlohmann-json
)集成到 C++项目中,使用 CMake 进行操作。
克隆 vcpkg 仓库并运行引导脚本:
git clone https://github.com/Microsoft/vcpkg.git
cd vcpkg
./bootstrap-vcpkg.sh # Use bootstrap-vcpkg.bat on Windows
Install nlohmann-json using vcpkg:
./vcpkg install nlohmann-json
vcpkg
将下载并编译库,使其可用于项目。
要在 CMake 项目中使用 vcpkg,您可以在配置项目时将CMAKE_TOOLCHAIN_FILE
变量设置为vcpkg.cmake
工具链文件的路径:
cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=[vcpkg root]/scripts/buildsystems/vcpkg.cmake
Replace [vcpkg root] with the path to your vcpkg installation.
In your CMakeLists.txt, find and link against the nlohmann-json package:
cmake_minimum_required(VERSION 3.0)
project(MyVcpkgProject)
find_package(nlohmann_json CONFIG REQUIRED)
add_executable(my_app main.cpp)
target_link_libraries(my_app PRIVATE nlohmann_json::nlohmann_json)
In your main.cpp, you can now use the nlohmann-json library:
#include <nlohmann/json.hpp>
int main() {
nlohmann::json j;
j[“message”] = “Hello, world!”;
std::cout << j << std::endl;
return 0;
}
vcpkg
强调基于源的分发,并与 CMake 和 Visual Studio 集成,为希望有效管理库依赖的 C++开发者提供了一个强大的解决方案。它的简单性,加上微软的支持,使其成为优先考虑与构建环境一致性以及与现有微软工具无缝集成的项目的诱人选择。虽然它与 Conan 在简化依赖管理方面有共同目标,但 vcpkg 和 Conan 之间的选择可能取决于具体的项目需求、首选的工作流程和开发生态系统。
利用 Docker 进行 C++构建
在 C++中,一个显著的不足是其缺乏管理依赖项的内建机制。因此,引入第三方元素是通过一系列异构的方法实现的:利用 Linux 发行版提供的包管理器(例如,apt-get
),通过make install
直接安装,将第三方库作为 Git 子模块包含并随后在项目的源树中编译,或者采用 Conan 或 Vcpkg 等包管理解决方案。
不幸的是,每种方法都有自己的缺点:
-
在开发机器上直接安装依赖通常会影响环境的清洁性,使其与 CI/CD 管道或生产环境不同——这种差异随着第三方组件的每次更新而变得更加明显。
-
确保所有开发者使用的编译器、调试器和其他工具版本的一致性通常是一项艰巨的任务。这种缺乏标准化可能导致一种情况,即构建在个别开发者的机器上成功执行,但在 CI/CD 环境中失败。
-
将第三方库作为 Git 子模块集成并在项目的源目录中编译的做法提出了挑战,尤其是在处理大型库(如 Boost、Protobuf、Thrift 等)时。这种方法可能导致构建过程显著减速,以至于开发者可能会犹豫清除构建目录或在不同分支之间切换。
-
包管理解决方案如 Conan 可能并不总是提供特定依赖项所需版本,包含该版本需要编写额外的 Python 代码,在我看来,这是不必要的负担。
一个单独的隔离且可重复的构建环境
解决上述挑战的最佳方案是制定一个 Docker 镜像,其中包含所有必需的依赖项和工具,如编译器和调试器,以方便在从该镜像派生的容器中编译项目。
这个特定的镜像作为构建环境的基石,被开发者在其各自的工作站以及 CI/CD 服务器上统一使用,有效地消除了“在我的机器上运行正常但在 CI 中失败”的常见差异。
由于容器内构建过程的封装特性,它对任何外部变量、工具或配置都保持免疫,这些变量、工具或配置是特定于个别开发者的本地设置,因此使得构建环境隔离。
在理想情况下,Docker 镜像会仔细标记有意义的版本标识符,使用户能够通过从注册表中检索适当的镜像来无缝地在不同环境之间切换。此外,如果镜像不再在注册表中可用,值得注意的是,Docker 镜像是由 Dockerfile 构建的,这些 Dockerfile 通常维护在 Git 仓库中。这确保了,如果需要,始终有可能从之前的 Dockerfile 版本重新构建镜像。这种 Docker 化构建框架的特性使其具有可重复性。
创建构建镜像
我们将开始开发一个简单的应用程序,并在容器内编译它。该应用程序的本质是利用 boost::filesystem
显示其大小。选择 Boost 进行此演示是有意为之,旨在展示 Docker 与“重量级”第三方库的集成:
#include <boost/filesystem/operations.hpp>
#include <iostream>
int main(int argc, char *argv[]) {
std::cout << “The path to the binary is: “
<< boost::filesystem::absolute(argv[0])
<< “, the size is:” << boost::filesystem::file_size(argv[0]) << ‘\n’;
return 0;
}
CMake 文件相当简单:
cmake_minimum_required(VERSION 3.10.2)
project(a.out)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Remove for compiler-specific features
set(CMAKE_CXX_EXTENSIONS OFF)
string(APPEND CMAKE_CXX_FLAGS “ -Wall”)
string(APPEND CMAKE_CXX_FLAGS “ -Wbuiltin-macro-redefined”)
string(APPEND CMAKE_CXX_FLAGS “ -pedantic”)
string(APPEND CMAKE_CXX_FLAGS “ -Werror”)
# clangd completion
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
include_directories(${CMAKE_SOURCE_DIR})
file(GLOB SOURCES “${CMAKE_SOURCE_DIR}/*.cpp”)
add_executable(${PROJECT_NAME} ${SOURCES})
set(Boost_USE_STATIC_LIBS ON) # only find static libs
set(Boost_USE_MULTITHREADED ON)
set(Boost_USE_STATIC_RUNTIME OFF) # do not look for boost libraries linked against static C++ std lib
find_package(Boost REQUIRED COMPONENTS filesystem)
target_link_libraries(${PROJECT_NAME}
Boost::filesystem
)
注意
在这个例子中,Boost 是静态链接的,因为如果目标机器没有预装正确的 Boost 版本,则必须使用它;此建议适用于 Docker 镜像中预装的所有依赖项。
用于此任务的 Dockerfile 非常简单:
FROM ubuntu:18.04
LABEL Description=”Build environment”
ENV HOME /root
SHELL [“/bin/bash”, “-c”]
RUN apt-get update && apt-get -y --no-install-recommends install \
build-essential \
clang \
cmake \
gdb \
wget
# Let us add some heavy dependency
RUN cd ${HOME} && \
wget --no-check-certificate --quiet \
https://boostorg.jfrog.io/artifactory/main/release/1.77.0/source/boost_1_77_0.tar.gz && \
tar xzf ./boost_1_77_0.tar.gz && \
cd ./boost_1_77_0 && \
./bootstrap.sh && \
./b2 install && \
cd .. && \
rm -rf ./boost_1_77_0
为了确保其名称独特且不与现有的 Dockerfile 冲突,同时清楚地传达其目的,我将其命名为 DockerfileBuildEnv
:
$ docker build -t example/example_build:0.1 -f DockerfileBuildEnv .
Here is supposed to be a long output of boost build
*注意,版本号不是“最新”的,而是有一个有意义的名称(例如,0.1)。
一旦镜像成功构建,我们就可以继续进行项目的构建过程。第一步是启动一个基于我们构建的镜像的 Docker 容器,然后在此容器中执行 Bash shell:
$ cd project
$ docker run -it --rm --name=example \
--mount type=bind,source=${PWD},target=/src \
example/example_build:0.1 \
bash
在这个上下文中,特别重要的参数是 --mount type=bind,source=$
{PWD},target=/src。此指令指示 Docker 将当前目录(包含源代码)绑定挂载到容器内的 /src
目录。这种方法避免了将源文件复制到容器中的需要。此外,正如随后将演示的那样,它还允许直接在主机文件系统上存储输出二进制文件,从而消除了重复复制的需要。为了理解剩余的标志和选项,建议查阅官方 Docker 文档。
在容器内,我们将继续编译项目:
root@3abec58c9774:/# cd src
root@3abec58c9774:/src# mkdir build && cd build
root@3abec58c9774:/src/build# cmake ..
-- The C compiler identification is GNU 7.5.0
-- The CXX compiler identification is GNU 7.5.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Boost found.
-- Found Boost components:
filesystem
-- Configuring done
-- Generating done
-- Build files have been written to: /src/build
root@3abec58c9774:/src/build# make
Scanning dependencies of target a.out
[ 50%] Building CXX object CMakeFiles/a.out.dir/main.cpp.o
[100%] Linking CXX executable a.out
[100%] Built target a.out
Et voilà,项目成功构建了!
生成的二进制文件在容器和主机上都能成功运行,因为 Boost 是静态链接的:
$ build/a.out
The size of “/home/dima/dockerized_cpp_build_example/build/a.out” is 177320
使环境可使用
在这个阶段,面对众多 Docker 命令可能会感到不知所措,并 wonder 如何期望记住它们所有。重要的是要强调,开发者不需要为了项目构建目的而记住这些命令的每一个细节。为了简化这个过程,我建议将 Docker 命令封装在一个大多数开发者都熟悉的工具中 – make
。
为了方便起见,我建立了一个 GitHub 仓库 (github.com/f-squirrel/dockerized_cpp
),其中包含一个通用的 Makefile。这个 Makefile 设计得易于适应,通常可以用于几乎任何使用 CMake 的项目,而无需进行修改。用户可以选择直接从该仓库下载它,或者将其作为 Git 子模块集成到他们的项目中,以确保访问最新的更新。我提倡后者方法,并将提供更多详细信息。
Makefile 已配置为支持基本命令。用户可以通过在终端中执行 make help
来显示可用的命令选项:
$ make help
gen_cmake Generate cmake files, used internally
build Build source. In order to build a specific target run: make TARGET=<target name>.
test Run all tests
clean Clean build directory
login Login to the container. Note: if the container is already running, login into the existing one
build-docker-deps-image Build the deps image.
要将 Makefile 集成到我们的示例项目中,我们首先将其添加为 build_tools
目录内的 Git 子模块:
git submodule add https://github.com/f-squirrel/dockerized_cpp.git build_tools/
下一步是在仓库的根目录下创建另一个 Makefile,并包含我们刚刚检出的 Makefile:
include build_tools/Makefile
在项目编译之前,明智的做法是调整某些默认设置以更好地满足项目的特定需求。这可以通过在包含 build_tools/Makefile
之前在顶级 Makefile 中声明变量来实现。这种预防性声明允许自定义各种参数,确保构建环境和过程针对项目的需求进行了最佳配置:
PROJECT_NAME=example
DOCKER_DEPS_VERSION=0.1
include build_tools/Makefile
By defining the project name, we automatically set the build image name as example/example_build.
Make 现在已准备好构建镜像:
$ make build-docker-deps-image
docker build -t example/example_build:latest \
-f ./DockerfileBuildEnv .
Sending build context to Docker daemon 1.049MB
Step 1/6 : FROM ubuntu:18.04
< long output of docker build >
Build finished. Docker image name: “example/example_build:latest”.
Before you push it to Docker Hub, please tag it(DOCKER_DEPS_VERSION + 1).
If you want the image to be the default, please update the following variables:
/home/dima/dockerized_cpp_build_example/Makefile: DOCKER_DEPS_VERSION
默认情况下,Makefile 将最新标签分配给 Docker 镜像。为了更好的版本控制和与我们的项目当前阶段保持一致,建议使用特定版本标记镜像。在此上下文中,我们将镜像标记为 0.1
。
最后,让我们构建项目:
$ make
docker run -it --init --rm --memory-swap=-1 --ulimit core=-1 --name=”example_build” --workdir=/example --mount type=bind,source=/home/dima/dockerized_cpp_build_example,target=/example example/example_build:0.1 \
bash -c \
“mkdir -p /example/build && \
cd build && \
CC=clang CXX=clang++ \
cmake ..”
-- The C compiler identification is Clang 6.0.0
-- The CXX compiler identification is Clang 6.0.0
-- Check for working C compiler: /usr/bin/clang
-- Check for working C compiler: /usr/bin/clang -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/clang++
-- Check for working CXX compiler: /usr/bin/clang++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Boost found.
-- Found Boost components:
filesystem
-- Configuring done
-- Generating done
-- Build files have been written to: /example/build
CMake finished.
docker run -it --init --rm --memory-swap=-1 --ulimit core=-1 --name=”example_build” --workdir=/example --mount type=bind,source=/home/dima/dockerized_cpp_build_example,target=/example example/example_build:latest \
bash -c \
“cd build && \
make -j $(nproc) “
Scanning dependencies of target a.out
[ 50%] Building CXX object CMakeFiles/a.out.dir/main.cpp.o
[100%] Linking CXX executable a.out
[100%] Built target a.out
Build finished. The binaries are in /home/dima/dockerized_cpp_build_example/build
在检查主机上的构建目录后,你会注意到输出二进制文件已经无缝地放置在那里,便于访问和管理。
Makefile 及其默认值的一个示例项目可以在 GitHub 上找到。这提供了一个实际演示,说明如何将 Makefile 集成到项目中,为寻求在 C++ 项目中实现 Dockerized 构建环境的开发者提供了一个即插即用的解决方案:
-
Makefile 仓库:
github.com/f-squirrel/dockerized_cpp
Dockerized 构建中的用户管理增强
基于 Docker 的构建系统的初始迭代是在 root 用户的权限下执行操作的。虽然这种设置通常不会立即引起问题——开发者有修改文件权限使用chmod
的选项——但从安全角度来看,通常不建议以 root 用户身份运行 Docker 容器。更重要的是,如果任何构建目标修改了源代码,例如代码格式化或通过make
命令应用clang-tidy
修正,这种方法可能会导致问题。此类修改可能导致源文件归 root 用户所有,从而限制从宿主直接编辑这些文件的能力。
为了解决这个担忧,对基于 Docker 的构建系统的源代码进行了修改,使容器能够通过指定当前用户 ID 和组 ID 以宿主用户身份执行。这种调整现在是标准配置,以提高安全性和可用性。如果需要将容器回滚到以 root 用户身份运行,可以使用以下命令:
make DOCKER_USER_ROOT=ON
重要的是要认识到,Docker 镜像并不能完全复制宿主用户的环境——没有对应的家目录,用户名和组也不会在容器内复制。这意味着如果构建过程依赖于访问家目录,这种修改后的方法可能不适用。
摘要
在本章中,我们探讨了管理 C++项目中第三方依赖项的各种策略和工具,这是影响开发过程效率和可靠性的关键方面。我们深入研究了传统方法,例如利用操作系统包管理器和通过 Git 子模块直接合并依赖项,每种方法都有其独特的优点和局限性。
然后,我们转向了更专业的 C++包管理器,重点介绍了 Conan 和 vcpkg。Conan 凭借其强大的生态系统、通过 Conan Center 提供的广泛库支持以及灵活的配置选项,为管理复杂依赖项、无缝集成到多个构建系统和支持静态和动态链接提供了一个全面的解决方案。它处理多个版本库的能力以及开发者扩展存储库以自定义包的简便性,使其成为现代 C++开发的宝贵工具。
由微软开发的 vcpkg 采用了一种略有不同的方法,侧重于基于源的分发,并确保库使用与消费项目相同的编译器和设置来构建。它与 CMake 和 Visual Studio 的紧密集成,加上微软的支持,确保了流畅的使用体验,尤其是在微软生态系统内的项目。强调从源编译可以解决潜在的二进制不兼容性问题,使 vcpkg 成为管理依赖项的可靠选择。
最后,我们讨论了采用 Docker 化构建作为创建一致、可重复的构建环境的高级策略,这在 Linux 系统中尤其有益。这种方法虽然更复杂,但在隔离性、可扩展性和开发、测试和部署阶段的一致性方面提供了显著的优势。
在本章中,我们的目标是为您提供必要的知识和工具,以便在 C++ 项目的依赖管理领域中导航。通过理解每种方法和工具的优势和局限性,开发者可以做出针对项目特定需求的有信息量的决策,从而实现更高效和可靠的软件开发过程。
第十四章:版本控制
在软件开发中,保持清晰的提交历史对于生产持久且连贯的代码至关重要。本章强调,一个良好的提交历史对于稳健的软件工程是基础性的。通过关注版本控制,特别是通过清晰的提交摘要和消息,我们将探讨实现清晰和精确所需的技术和有意为之的实践。
提交代码就像向项目开发的整体叙事中添加个别线索。每个提交,包括其摘要和消息,都为理解项目的历史和未来方向做出了贡献。保持清晰的提交历史不仅仅是组织上的整洁;它体现了开发者之间有效的沟通,促进了无缝的协作,并使快速导航项目开发历史成为可能。
在接下来的章节中,我们将探讨“良好的”提交的特点,重点关注为提交消息带来清晰性、目的性和实用性的属性。这一探索超越了基础层面,深入到代码变更的战略性文档和通过 Git 等工具获得的见解。通过示例,我们将看到精心构建的提交历史如何改变理解,帮助调试,并通过清晰地传达代码变更背后的理由来简化审查流程。
进一步推进,我们将解码常规提交规范,这是一个旨在标准化提交消息的结构化框架,从而赋予它们可预测性和机器可解析的清晰度。本节阐明了提交消息结构与自动化工具之间的共生关系,展示了遵守此类规范如何显著提高项目的可维护性。
随着我们前进,叙事展开,揭示了通过提交 linting 的视角来实施这些最佳实践的实际性。在这里,我们深入探讨自动化工具在持续集成(CI)工作流程中的集成,展示了这些机制如何作为提交质量的警觉守护者,确保一致性和符合既定规范。
本章不仅解释了版本控制的机制;它还邀请你将构建清晰的提交历史视为软件工艺的重要组成部分。通过遵循这里讨论的原则和实践,开发者和团队能够提高代码库的质量,并创造一个促进创新、协作和效率的环境。在我们探索本章时,请记住,清晰的提交历史反映了我们在软件开发中追求卓越的承诺。
什么是良好的提交?
有效的版本控制实践的核心在于“良好的提交”这一概念,它是变化的基本单元,体现了代码库中的清晰性、原子性和目的性原则。理解构成良好提交的要素对于力求保持项目历史清晰、可导航和富有信息性的开发者至关重要。本节深入探讨了定义提交质量的关键属性,并提供了开发者如何提升其版本控制实践的见解。
单一焦点原则
良好的提交遵循原子性原则,意味着它封装了代码库中的单个逻辑变更。这种单一焦点确保每个提交都具有独立的意义,并且可以通过回滚或调整单个提交来安全且轻松地回滚或修改项目。原子提交简化了代码审查过程,使团队成员更容易理解和评估每个变更,而无需处理无关的修改。例如,不应将新功能实现与单独的 bug 修复合并到一个提交中,而应将它们分成两个不同的提交,每个提交都有其明确的目的和范围。
沟通的艺术
良好提交的本质也在于其清晰性,尤其是在提交信息中表现得尤为明显。清晰的提交信息简洁地描述了变更的内容和原因,作为未来参考的简要文档。这种清晰性不仅限于团队内部,还帮助任何与代码库互动的人,包括新团队成员、外部合作者和未来的自己。当在长时间后重新访问代码库时,提交信息作为项目演变的记录尤为重要。这种方法对于开源项目至关重要,因为它允许贡献者理解变更的背景和理由,从而营造一个协作和包容的环境。
结构良好的提交信息通常包括一个简洁的标题行,总结变更内容,随后是一个空行,如果需要,可以是一个更详细的解释。解释可以深入到变更背后的理由,可能产生的影响,以及有助于理解提交目的的任何附加背景信息。建议将主题行控制在 50 个字符以内。这确保信息适合大多数终端的标准宽度,不会被 GitHub 或其他平台截断,并且易于扫描。GitHub 截断小于 72 个字符的主题行,因此 72 将是硬性限制,50 将是软性限制。例如,提交信息feat: added a lots of needed include directives to make things compile properly
将被 GitHub 截断如下:
图 14.1 – 截断的提交信息
GitHub 会截断最后一个单词properly
,为了阅读它,开发者必须点击提交消息。这并不是什么大问题,但这是一个可以通过保持主题行简短来避免的小不便。
更重要的是,这迫使作者简洁并直截了当。
其他有用的实践包括在主题行中使用祈使语气,这是提交消息中的常见约定。这意味着主题行应该以命令或指示的形式表达,例如“修复 bug”或“添加功能”。这种写作风格更直接,与提交代表对代码库应用更改的想法相一致。
不建议在主题行末尾使用句号,因为它不是一个完整的句子,并且不会有助于保持消息简短。提交消息的正文可以提供额外的上下文,例如变更的动机、解决的问题以及与实现相关的任何相关细节。
建议将正文包裹在 72 个字符以内,因为 Git 不会为你自动换行文本。这是一个常见的约定,它使得消息在各种环境中(如终端窗口、文本编辑器和版本控制工具)更易于阅读。这可以通过配置你的代码编辑器轻松实现。
因此,在最终确定提交之前花时间进行反思,不仅是为了确保消息的清晰性,而且是为了重申更改本身的价值和意图。这是一个确保代码库的每个贡献都是深思熟虑、有意义的并与项目目标一致的机会。从这个角度来看,花时间编写精确且信息丰富的提交消息不仅是一种良好的实践,也是开发者对质量和协作承诺的证明。
精益求精的艺术
在将功能分支合并到main
分支之前,开发者考虑提交历史的整洁性和清晰性是明智的。合并中间提交是一个深思熟虑的实践,它简化了提交日志,使其对探索项目历史的任何人来说都更易于阅读和有意义。
当你即将集成你的工作成果时,花点时间反思一下开发过程中积累的提交消息。问问自己,每个提交消息是否为理解项目演变过程增加了价值,或者它只是用冗余或过于详细的细节使历史记录变得杂乱。在许多情况下,你为了达到最终解决方案所采取的迭代步骤(如微小的错误修复、对代码审查的响应调整或单元测试的纠正)可能对其他贡献者或未来的你并没有太大的价值。
考虑一个提交历史,其中包含诸如修复错误
、修复单元测试
或多个修复 cr
条目之类的消息。虽然这些消息表明了开发过程,但并不一定提供对更改或它们对项目影响的深刻见解。将这些中间提交压缩成一个精心制作的单个提交不仅整理了提交日志,还确保历史记录中的每个条目都传达了项目开发中的一个重要步骤。
通过压缩提交,您可以将这些迭代更改整合成一个连贯的故事,突出新功能的引入、重大错误的解决或关键重构的实施。这种精心编排的历史记录有助于当前贡献者和未来的维护者导航和理解项目的进展,提高协作和效率。
总结来说,在合并之前,考虑项目提交历史的更广泛视角。压缩中间提交是一种正念的实践,确保提交日志始终是所有贡献者宝贵的可导航资源,以清晰简洁的方式封装每个更改的精髓。
传统的提交规范
在项目中的提交信息和结构上保持一致性,可以提高可读性和可预测性,使团队成员更容易导航项目历史。遵循预定义的格式或一组约定,如传统的提交规范,确保提交信息结构统一且信息丰富。这些可能包括以祈使语气开始的提交信息,指定更改类型(例如,fix
、feat
或refactor
),以及可选地包括范围以阐明受影响的项目的哪个部分。
将代码与上下文联系起来
一个好的提交通过将代码更改与其更广泛的上下文联系起来,如项目问题跟踪系统中的票据或相关文档,从而增强了可追溯性。在提交信息中包含引用,在技术实现与其解决的问题或需求之间建立了有形的联系,有助于更好地理解和跟踪项目进度。
在提交信息中包含问题跟踪器 ID、票据编号或其他相关标识符可以显著提高更改的可追溯性和它们与项目目标或报告问题相关联的便捷性。它通常看起来类似于fix(FP-1234):修复了用户身份验证流程
,其中FP-1234
是问题跟踪系统中的票据编号。
从本质上讲,一个好的提交在项目开发历史的更广泛叙事中充当一个连贯的、自包含的故事。通过遵循这些原则,开发者不仅有助于提高代码库的可维护性和可读性,而且促进了版本控制实践中严谨性和责任感的文化。通过有纪律地创建良好的提交,项目的历史成为协作、审查和理解软件演变的有价值资产。
创建良好的提交信息的一种最佳方式是遵循常规提交规范。常规提交规范作为一个结构化框架,用于提交信息格式化,旨在简化创建可读提交日志的过程,并使自动化工具能够促进版本管理和发布说明的生成。本规范定义了提交信息的标准化格式,旨在在版本控制系统(如 Git)中清楚地传达变更的性质和意图。
概述和意图
在其核心,常规提交规范规定了包括类型、可选范围和简洁描述的格式。格式通常遵循以下结构:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
类型根据引入变更的性质对提交进行分类,例如feat
表示新功能或fix
表示错误修复。范围虽然可选,但提供了额外的上下文信息,通常指示受变更影响的代码库部分。描述提供了变更的简洁总结,以祈使语气编写。
选项和用法
提交检查,尤其是在遵循常规提交规范时,确保提交以清晰、可预测和有用的方式结构化。以下是一些符合提交检查规则的提交示例,展示了在软件项目中可能发生的各种类型的变化:
-
添加新功能:
feat(authentication): add biometric authentication support
此提交信息表明已添加新功能(具体为生物识别认证支持),并且该功能的范围在应用程序的认证模块内。
-
修复错误:
fix(database): resolve race condition in user data retrieval
在这里,一个错误修复(
fix
)正在提交,解决database
模块中的竞争条件问题,特别是在用户数据检索过程中。 -
改进文档:
docs(readme): update installation instructions
此示例展示了文档更新(
docs
),包括对项目 README 文件的更改,以更新安装说明。 -
代码重构:
refactor(ui): simplify button component logic
在这次提交中,现有代码已被重构(
refactor
),但没有添加任何新功能或修复任何错误。重构的范围是用户界面(UI),具体是简化了button
组件中使用的逻辑。 -
样式调整:
style(css): remove unused CSS classes
此提交信息表示一个样式变更(
style
),其中正在删除未使用的 CSS 类。值得注意的是,此类提交不会影响代码的功能。 -
添加测试:
test(api): add tests for new user endpoint
在这里,为 API 中的新用户端点添加了新的测试(
test
),这表明项目测试覆盖率有所提升。 -
任务:
chore(build): update build script for deployment
此提交代表一个任务(
chore
),通常是一个维护或设置任务,它不会直接修改源代码或添加功能,例如更新部署的构建脚本。 -
破坏性变更:
feat(database): change database schema for users table
BREAKING CHANGE: The database schema modification requires resetting the database. This change will affect all services interacting with the users table.
另一种表示破坏性变更的方法是在提交信息中的类型和作用域之后但冒号之前添加一个感叹号(
!
)。这种方法简洁且视觉上明显:feat!(api): overhaul authentication system
此提交引入了一个与用户表数据库模式相关的新功能(
feat
),但也包括一个破坏性变更。
这些示例说明了由 Conventional Commits 规范指导的提交清理如何促进清晰、结构化和信息丰富的提交信息,从而增强项目的可维护性和协作。
源起和采用
Conventional Commits 规范的灵感来源于简化可读和自动化的变更日志的需求。它建立在 AngularJS 团队早期实践的基础上,并已被寻求标准化提交信息以改善项目可维护性和协作的多个开源和企业项目采用。
Conventional Commits 的优势
遵循 Conventional Commits 规范提供了许多好处:
-
自动化 Semver 处理:通过分类提交,工具可以根据变化的语义意义自动确定版本升级,遵循 语义版本控制(Semver)原则。
-
简化版发布说明:通过解析结构化提交信息,自动化工具可以生成全面且清晰的发布说明和变更日志,显著减少手动工作并增强发布文档。
-
增强可读性:标准化的格式提高了提交历史记录的可读性,使开发者更容易导航和理解项目演变。
-
促进代码审查:清晰的分类和描述有助于代码审查过程,使审查者能够快速掌握变更的范围和意图。
Commitlint – 强制执行提交信息标准
Commitlint 是一个强大且可配置的工具,旨在强制执行提交信息约定,确保项目提交历史的一致性和清晰性。它在维护一个干净、易读且富有意义的提交日志中起着至关重要的作用,尤其是在与如 Conventional Commits 规范等约定一起使用时。本节提供了如何安装、配置和使用 commitlint 检查本地提交信息的全面指南,从而培养对版本控制和协作的纪律性方法。
安装
Commitlint 通常通过 npm 安装,这是 Node.js 的包管理器。要开始,你需要在你的开发机器上安装 Node.js 和 npm。一旦设置好,你可以在项目根目录中运行以下命令来安装 commitlint 和其常规 config
包:
npm install --save-dev @commitlint/{cli,config-conventional,prompt-cli}
此命令将 commitlint 和常规提交配置作为开发依赖项安装到你的项目中,使它们在本地开发环境中可用。
配置
安装后,commitlint 需要一个配置文件来定义它将强制执行的规则。配置 commitlint 最直接的方法是使用常规提交配置,这与常规提交规范相一致。在你的项目根目录中创建一个名为 commitlint.config.js
的文件,并添加以下内容:
module.exports = {extends: ['@commitlint/config-conventional']};
此配置指示 commitlint 使用由常规提交配置提供的标准规则,包括对提交信息结构、类型和作用域的检查。
本地使用
要在本地检查提交信息,你可以使用与 Husky 结合的 commitlint,Husky 是一个用于管理 Git 钩子的工具。Husky 可以配置为在提交之前触发 commitlint 来评估提交信息,为开发者提供即时反馈。
首先,将 Husky 作为开发依赖项安装:
npm install --save-dev husky
让我们使用 commitlint 检查本地提交:
npx commitlint --from HEAD~1 --to HEAD --verbose
⧗ input: feat: add output
✔ found 0 problems, 0 warnings
在此示例中,--from HEAD~1
和 --to HEAD
指定了要检查的提交范围,而 --verbose
提供了详细的输出。如果提交信息不符合指定的规范,commitlint 将输出错误信息,指出需要解决的问题。
让我们添加一个糟糕的提交信息并使用 commitlint 检查它:
git commit -m"Changed output type"
npx commitlint --from HEAD~1 --to HEAD --verbose
⧗ input: Changed output type
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
✖ found 2 problems, 0 warnings
ⓘ Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint
可以通过将以下配置添加到你的 package.json
文件中或创建一个包含相同内容的 .huskyrc
文件来将 commitlint 集成为 Git 钩子:
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
此配置设置了一个预提交钩子,它会在提交即将被提交时调用 commitlint。如果提交信息不符合指定的标准,commitlint 将拒绝提交,开发者需要相应地修改信息。
自定义规则
Commitlint 提供了广泛的配置和自定义选项,允许团队根据他们的特定项目需求和工作流程定制提交信息验证规则。这种灵活性确保了 commitlint 可以适应支持各种提交规范,而不仅仅是标准的常规提交格式,为在多样化的开发环境中强制执行一致且有意义的提交信息提供了一个强大的框架。
基本配置
Commitlint 的基本配置涉及在项目的根目录中设置一个 commitlint.config.js
文件。该文件作为定义 commitlint 将强制执行的规则和约定的中心点。在最简单的情况下,配置可能扩展一个预定义的规则集,例如由 @commitlint/config-conventional
提供的规则,如下所示:
module.exports = {
extends: ['@commitlint/config-conventional'],
};
此配置指示 commitlint 使用常规提交消息规则,强制执行提交消息的标准结构和类型集。
自定义规则配置
Commitlint 的真正力量在于其能够自定义规则以匹配特定项目需求的能力。commitlint 中的每个规则都由一个字符串键标识,并且可以使用一个数组进行配置,指定规则的级别、适用性和在某些情况下,附加选项或值。规则配置数组通常遵循以下格式 [级别,
适用性, 值]
:
-
0
= 禁用,1
= 警告,和2
= 错误) -
'always'
或'never'
) -
值:规则的附加参数或选项,根据规则而异
例如,为了强制提交消息必须以类型后跟冒号和空格开始,您可以按如下方式配置 type-enum
规则:
module.exports = {
rules: {
'type-enum': [2, 'always', ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore']],
},
};
此配置将规则级别设置为错误(2
),指定规则应始终应用,并定义了提交消息的可接受类型列表。
作用域和主题配置
Commitlint 允许对提交消息的作用域和主题进行详细配置。例如,您可以强制执行特定的作用域或要求提交消息的主题不以句号结尾:
module.exports = {
rules: {
'scope-enum': [2, 'always', ['ui', 'backend', 'api', 'docs']],
'subject-full-stop': [2, 'never', '.'],
},
};
此设置强制要求提交必须使用预定义的作用域之一,并且主题行不得以句号结尾。
自定义和共享配置
对于具有独特提交消息约定的项目或组织,可以定义自定义配置,并在需要时跨多个项目共享。您可以为您的 commitlint 配置创建一个专门的 npm 包,使团队能够轻松扩展此共享配置:
// commitlint-config-myorg.js
module.exports = {
rules: {
// Custom rules here
},
};
// In a project's commitlint.config.js
module.exports = {
extends: ['commitlint-config-myorg'],
};
这种方法促进了项目之间的连贯性,并简化了组织内部提交消息规则的管理。
与 CI 集成
通过 CI 确保执行 Commitlint 是维护项目内高质量提交消息的关键实践。虽然本地 Git 钩子,如由 Husky 管理的钩子,通过在开发者的机器上检查提交消息提供了一道防线,但它们并非万无一失。开发者可能有意或无意地禁用 Git 钩子,集成开发环境(IDEs)或文本编辑器可能没有正确配置以强制执行这些钩子,或者可能遇到导致其故障的问题。
考虑到这些本地执行中的潜在差距,CI 充当了权威的真实来源,提供了一个集中、一致的平台,用于验证提交信息是否符合项目标准。通过将 commitlint 集成到 CI 管道中,项目确保每个提交,无论其来源或提交它的方法如何,在合并到主代码库之前都遵循定义的提交信息约定。这种基于 CI 的执行促进了纪律和责任感的氛围,确保所有贡献,无论其来源如何,都符合项目的质量标准。
使用 GitHub Actions 将 commitlint 集成到 CI 中
GitHub Actions 提供了一个简单而强大的平台,用于将 commitlint 集成到您的 CI 工作流程中。以下示例演示了如何设置 GitHub 操作,以使用 commitlint 在每次推送或针对main
分支的 pull request 时强制执行提交信息标准。
首先,在您的仓库中的.github/workflows/commitlint.yml
下创建一个新文件,内容如下:
name: Commitlint
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
with:
# Fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 0
- name: Check Commit Message
uses: wagoid/commitlint-github-action@v5
with:
failOnWarnings: true
此工作流程定义了一个名为commitlint
的作业,它在推送和 pull request 到main
分支时触发。我想强调的唯一配置是failOnWarnings: true
,它配置操作在遇到任何 commitlint 警告时失败。这确保了通过将警告视为与错误相同的严重性来严格遵循提交信息标准。
让我们创建一个糟糕的提交信息并打开一个 PR 来查看操作是如何工作的:
git commit -m"Changed output type"
git checkout -b exit_message
git push origin exit_message
在我们打开一个 PR 之后,我们会看到操作失败了:
图 14.2 – Commitlint 操作失败
日志将以与本地检查相同的格式显示失败原因:
图 14.3 – Commitlint 操作日志失败
通过将此工作流程集成到您的项目中,您确保每个提交在成为main
分支的一部分之前都会被仔细检查是否符合您的提交信息标准。这个基于 CI 的检查充当最后的守门人,强化了良好结构化提交信息的重要性,并维护了项目提交历史的完整性。
Commitlint 的可配置性和定制选项提供了一个强大的平台,用于执行针对项目或组织特定需求的提交信息标准。通过利用这些功能,团队可以确保他们的提交日志保持清晰、一致和有意义,从而提高项目的可维护性和协作性。无论是遵循广泛接受的约定,如常规提交规范,还是定义一组自定义规则,commitlint 都提供了维护高质量提交历史所需的灵活性和控制力。
生成变更日志
自动生成变更日志是一种方法,其中软件工具自动创建项目更改的日志,对更新、修复和功能进行分类和列出。这个过程因其效率和一致性而受到青睐,确保所有重大修改都得到系统性的记录。我们将通过 GitCliff 来探讨这个概念,GitCliff 是一个解析结构化提交消息以生成详细变更日志的工具,有助于透明的项目管理与沟通。GitCliff 在这个过程中所发挥的作用体现了其在自动化和简化项目文档任务中的作用。
安装
GitCliff 是用 Rust 编写的,可以使用 Rust 包管理器 Cargo 进行安装。要安装 GitCliff,请确保您的系统已安装 Rust 和 Cargo,然后运行以下命令:
curl https://sh.rustup.rs -sSf | sh
安装 Rust 后,您可以使用 Cargo 安装 GitCliff:
cargo install git-cliff
最后一步配置是将 GitCliff 初始化到您的项目中:
git cliff --init
这将在您项目的根目录中生成一个默认配置文件,.cliff.toml
。
GitCliff 使用
安装并初始化 GitCliff 后,您可以在项目根目录中运行以下命令来生成变更日志:
git cliff -o CHANGELOG.md
工具会生成一个包含变更日志的 Markdown 文件,如下所示:
图 14.4 – 生成变更日志
日志包含按类型分类的更改列表,并突出显示破坏性更改。
让我们添加一个发布标签并生成发布变更日志:
git tag v1.0.0 HEAD
git cliff
现在的变更日志将包含发布标签和自上次发布以来的更改:
图 14.5 – 包含发布标签的生成变更日志
我们可以引入破坏性更改并提升版本:
git commit -m"feat!: make breaking change"
git cliff --bump
如您所见,GitCliff 已经检测到破坏性更改并将版本提升到 2.0.0:
图 14.6 – 包含破坏性更改的生成变更日志
在前面的章节中,我们已经全面探讨了git-cliff
的重要功能,揭示了它在自动化变更日志生成方面的巨大效用。这个工具不仅因其能够简化文档流程而脱颖而出,还因其与 CI 平台的无缝集成而著称,包括但不限于 GitHub。这种集成确保了变更日志与最新的项目发展保持一致,从而保持了项目文档的准确性和相关性。
git-cliff 的另一个同样值得注意的特性是它在生成变更日志方面提供的广泛定制功能。用户可以灵活地调整变更日志的格式、内容和展示方式,以满足特定项目需求或个人偏好。这种高度的可定制性确保了输出不仅与项目文档标准保持一致,而且还能提升项目文档标准。
考虑到 git-cliff 提供的功能深度和潜在好处,那些希望充分利用此工具的人被鼓励查阅官方文档。这个资源是一个信息宝库,涵盖了与 git-cliff 相关的所有功能、配置和最佳实践的广泛内容。与官方文档的互动不仅将巩固你对工具的理解,还将为你提供在项目中有效实施它的知识。
总结来说,在深入了解了 git-cliff 的主要功能和优势之后,那些希望将此工具集成到他们的开发工作流程中的人,应该通过彻底研究官方文档来继续前进。这次探索承诺将扩展你在使用 git-cliff 方面的熟练度,确保你能够充分利用其功能来增强你项目的变更日志生成和文档流程。
利用 git-bisect 进行错误查找
在软件开发的过程中,识别和修复错误的任务对于确保应用程序的稳定性和可靠性至关重要。在开发者可用的工具中,git-bisect
因其专门用于隔离将错误引入代码库的提交的强大功能而脱颖而出。
Git 版本控制系统内嵌的git-bisect
是一个基于二分搜索算法的实用工具。它帮助开发者筛选大量的提交历史,以确定导致回归或引入错误的精确变更。通过采用分而治之的策略,git-bisect
显著简化了调试过程,使其成为故障排除的高效方法。
使用git-bisect
的旅程从确定项目时间线中的两个关键点开始:一个已知没有错误的提交(称为good
)和一个已知存在错误的提交(称为bad
)。设置这些标记后,git-bisect
将检查出位于good
和bad
提交之间的一个提交。这一步需要开发者测试当前应用程序的状态,以确定是否存在错误。
迭代过程涉及git-bisect
根据开发者的反馈选择一个新的提交,并通过每次将搜索区域减半来不断缩小搜索范围。测试和反馈的循环继续进行,直到git-bisect
成功隔离出引入错误的提交,有效地将注意力集中在根本原因上,并最小化手动审查。
git-bisect
的效率在于其减少需要手动审查的提交数量的能力,从而节省宝贵的发展时间。其系统性的方法确保在识别有问题的提交时精确无误,这对于理解错误的上下文和制定有效的修复方案至关重要。作为 Git 生态系统的一部分,git-bisect
无缝地融入开发者的现有工作流程,提供了一个熟悉且直观的调试界面。
为了优化git-bisect
的有效性,使用一个可靠且准确的测试用例来评估每个提交至关重要。这确保了提供给git-bisect
的反馈正确反映了错误的存不存在,从而防止误识别。保持干净且逻辑清晰的提交历史,其中每个提交封装一个单一的变化,可以增强工具的效率。此外,在可行的情况下,在git-bisect
会话中自动化测试过程,可以加快错误搜索的进程。
考虑这样一个场景,一个之前运行正常的功能检测到回归。这种情况通常发生在某些测试只在夜间运行时发生。任务是使用git-bisect
识别出导致这种回归的提交:
-
使用
git
bisect start
开始bisect
会话。 -
将存在错误的提交标记为
git bisect bad <commit hash>
(通常为HEAD
)。 -
确定一个过去功能正常的工作提交,并使用
git bisect good <commit-hash>
将其标记为good
。 -
然后
git-bisect
将检查位于good
和bad
提交中间的提交。测试这个提交以查看是否存在错误。 -
根据测试结果,将提交标记为
good
或bad
。git-bisect
使用此反馈来缩小搜索范围,并选择一个新的提交进行测试。 -
重复测试和标记过程,直到
git-bisect
识别出引入错误的提交。
一旦确定了有问题的提交,开发者可以检查该提交中引入的变化,以了解错误的起因,并继续开发修复方案。
为了演示其工作原理,我克隆了rapidjson
库的 master 分支,引入了一个错误并将其放在本地仓库的中间。Git 日志如下,其中Bad commit (6 hours ago) <f-squirrel>
是错误的提交:
a85e2979 - (HEAD -> master) Add RAPIDJSON_BUILD_CXX20 option (6 hours ago) <Brian Rogers>
2cd6149d - fix Visual Studio 2022 (using /std:c++20) warning warning C5232: in C++20 this comparison ...
478cd636 - Bad commit (6 hours ago) <f-squirrel>
25edb27a - tests: Only run valgrind tests if valgrind was found (23 hours ago) <Richard W.M. Jones>
606791f6 - Fix static_cast in regex.h (23 hours ago) <Dylan Burr>
5f071d72 - Fix comparision of two doubles (23 hours ago) <Esther Wang>
060a09a1 - Fix schema regex preprocessor include logic (6 weeks ago) <Bryant Ferguson>
6089180e - Use correct format for printf (4 months ago) <Esther Wang>
...
让我们通过标记good
和bad
提交开始二分查找:
$ git bisect start
$ git bisect bad HEAD
$ git bisect good 6089180e
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[606791f6662c136ba34f842313b807114580852d] Fix static_cast in regex.h
我准备了一个脚本,用于检查当前提交中是否存在错误。该脚本名为test.sh
,如下所示:
cmake --build ./build -j $(nproc) || exit 1
./build/bin/unittest || exit 1
每次运行脚本时,我都会将提交标记为good
或bad
。经过几次迭代后,我找到了引入错误的提交:
[ PASSED ] 468 tests.
$ git bisect good
Bisecting: 1 revision left to test after this (roughly 1 step)
[478cd636a813abe76e32154544b0ec793fdc5566] Bad commit
如果我们再次运行测试脚本,我们会看到错误存在于以下提交中:
[ FAILED ] 2 tests, listed below:
[ FAILED ] BigInteger.Constructor
[ FAILED ] BigInteger.LeftShift
2 FAILED TESTS
一旦我们完成错误搜索,可以使用git
bisect reset
重置bisect
会话。
对于用户来说,在提交之间跳转是一个有用的功能,但并非唯一的功能。可以使用脚本自动化git-bisect
,该脚本将运行测试并根据测试结果将提交标记为良好或不良。请注意,如果提交良好,脚本应返回0
;如果提交不良,则返回1
。脚本将一直运行,直到找到错误并将bisect
会话重置。对于我们的仓库,它将如下所示:
$ git bisect start
$ git bisect bad HEAD
$ git bisect good 6089180e
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[606791f6662c136ba34f842313b807114580852d] Fix static_cast in regex.h
$ git bisect run ./test.sh
running './test.sh'
... build and test output ...
[==========] 468 tests from 34 test suites ran. (321 ms total)
[ PASSED ] 468 tests.
478cd636a813abe76e32154544b0ec793fdc5566 is the first bad commit
commit 478cd636a813abe76e32154544b0ec793fdc5566
Author: f-squirrel <dmitry.b.danilov@gmail.com>
Date: Mon Mar 25 15:18:18 2024 +0200
Bad commit
include/rapidjson/internal/biginteger.h | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
bisect found first bad commit
git-bisect
是 Git 套件中不可或缺的调试工具,提供了一种系统化和高效的识别导致错误提交的方法。将其集成到开发工作流程中,结合维护清晰的提交历史和采用自动化测试的实践,使其成为维护代码质量和稳定性的高度有效解决方案。
摘要
在本章关于版本控制的探讨中,我们深入了解了支撑有效软件版本管理的基本原则和实践。我们探索的核心是采用传统提交,这是一种结构化的提交信息方法,它提高了可读性并促进了提交日志的自动化处理。这种基于提交信息标准化格式的实践,使团队能够清晰、精确地传达变更的性质和意图。
我们还深入探讨了 SemVer,这是一种旨在以有意义的方式管理版本号的方法。基于代码库中变更的重要性,SemVer 的系统化版本管理方法提供了关于何时以及如何递增版本号的明确指南。这种方法为版本控制提供了一个透明的框架,确保了项目内和项目间的兼容性,并促进了有效的依赖关系管理。
本章还介绍了变更日志创建工具,特别关注 git-cliff,这是一个多功能的工具,可以从 Git 历史中自动生成详细和可定制的变更日志。这些工具简化了文档过程,确保项目利益相关者充分了解每个新版本引入的变化、功能和修复。
本章的大部分内容都致力于调试技术,突出了git-bisect
在隔离错误过程中的实用性。通过其二分搜索算法,git-bisect
使开发者能够高效地定位引入错误的提交,从而显著减少故障排除所需的时间和精力。
总结来说,本章全面概述了版本控制实践,强调了结构化提交信息、策略性版本管理、自动生成变更日志和高效调试技术的重要性。通过采用这些实践,开发团队能够增强协作,维护代码库的完整性,并确保交付高质量的软件。
在下一章中,我们将关注开发过程中的一个关键方面:代码审查。我们将探讨代码审查在确保代码质量、促进团队协作和提升整体生产力方面的重要性。通过了解进行彻底和建设性代码审查的最佳实践和有效策略,你将充分准备好提升你的代码库标准,并更有效地为团队的成功做出贡献。敬请期待,我们将开始这段探索代码审查艺术与科学的精彩旅程。
第十五章:代码审查
在本书的前几章中,我们已经系统地探索了一系列旨在提高我们 C++代码质量的自动化解决方案。这包括采用清晰的命名约定、利用现代 C++特性、严格实施单元和端到端测试、维护干净的提交和消息,以及有效使用调试技术,如 git bisect 等。这些实践中的每一项都是我们编写稳健、可维护软件工具箱中的关键组成部分。
然而,尽管这些自动化工具和方法提供了实质性的好处,但它们并非完美无缺。它们依赖于正确和一致的实施,并且如果没有勤勉的监督,标准很容易下滑,错误就会悄悄地进入我们的代码库。这就是代码审查的关键作用所在。一个能够理解上下文和细微差别的人类视角对于确保所有这些自动化实践被正确和有效地应用是必不可少的。
在本章中,我们将深入探讨代码审查的实践,这是开发过程中不可或缺的一部分,有助于防止任何机器都无法完全检测到的疏忽。我们将讨论代码审查如何不仅预防潜在的 bug 和提升代码质量,而且培养开发人员之间的学习与合作文化。通过全面探索有效的策略和实际指南,我们旨在为您提供实施稳健代码审查的知识,这对您 C++项目的成功和可靠性有重大贡献。
什么是代码审查以及为什么需要它?
我们今天所理解的代码审查实践起源于 Michael Fagan,他在 20 世纪 70 年代中期开发了软件检查的正式流程。当时,软件工程通常是一种独立的活动,个人开发者作为孤独的牛仔,负责编写、测试和审查自己的代码。这种方法导致了项目之间标准的不一致,以及被忽视的错误发生率更高,因为个人的偏见和盲点没有得到检查。
认识到这种独立方法的局限性,Fagan 引入了一种结构化的方法来系统地检查软件。他的流程不仅旨在发现错误,还旨在检查软件的整体设计和实现。这种转变标志着软件开发的一个重大进步,强调了协作、细致的审查和共同的责任。通过涉及多个审查者,Fagan 的方法为评估过程带来了不同的视角,增强了审查的细致程度和软件的整体质量。
代码审查的好处
代码审查通过确保软件的设计与项目的架构标准和 C++的最佳实践保持一致,显著提高了代码的整体质量。这些审查对于强制执行编码标准和惯例至关重要,从而培养出一个对新老团队成员来说都更加统一且易于理解的代码库。此外,它们通过促进对代码复杂部分的讨论和阐明特定方法背后的理由,有助于保持代码的高可理解性。例如,考虑一个开发者使用了虽然功能正常但难以理解和维护的非传统循环结构的情况。在代码审查过程中,这些问题可以被揭露,并可能提出使用标准 STL 算法重构代码的建议。这不仅简化了代码,还确保了与现代 C++实践的兼容性,提高了可读性和可维护性。
同行评审是在错误进入生产之前捕捉错误的最有效方法之一。总是有另一双眼睛检查代码以寻找错误更好,无论是逻辑错误还是语言使用不当。审阅者可以识别逻辑错误、边界错误、内存泄漏和其他可能对原始作者不是立即明显的常见 C++陷阱。此外,在代码审查过程中对单元测试中的测试用例进行彻底审查同样至关重要。这种做法确保测试覆盖了足够的场景,并在早期阶段捕捉到潜在的错误,从而提高了软件的可靠性和健壮性。例如,开发者可能忘记释放函数中分配的内存,这可能导致内存泄漏。审阅此代码的同行可能会注意到这一疏忽,并建议使用智能指针自动管理内存生命周期,从而在软件进一步开发周期之前有效预防此类问题。
代码审查作为一项教育工具,对作者和审查者都有益,因为它在团队中传播领域知识,并增强对代码库的熟悉度。这一知识转移方面对于确保所有团队成员保持一致并能够有效贡献至关重要。例如,一个初级开发者最初可能会使用原始指针来管理资源,这是一种常见的做法,但容易出错,如内存泄漏和指针相关错误。在代码审查过程中,经验更丰富的开发者可以通过介绍智能指针来指导初级开发者。通过解释智能指针的优势,如自动内存管理和改进的安全性,资深开发者不仅帮助纠正了即时问题,还帮助初级开发者成长,并加深了对现代 C++实践的理解。此外,代码审查为审查者提供了一个独特的机会,以深化对项目特定功能的理解。当他们评估同行的作品时,审查者可以深入了解新的功能和应用复杂区域。这种增强的理解使他们具备了解决未来错误或在这些特定区域实施改进所需的知识。本质上,通过审查他人的代码,审查者不仅为项目的即时改进做出了贡献,还为自己在将来维护和扩展项目功能做好了准备。
互负责任是团队内定期进行代码审查的关键好处。随着团队成员持续审查彼此的工作,他们培养了一种强烈的共同责任感和责任感。这种集体监督鼓励每个成员在编码工作中保持高标准和彻底性。例如,意识到他们的同行将审查他们的代码,会激励开发者最初编写更干净、更高效的代码。这种对编码质量的主动方法减少了未来大规模重写的可能性,简化了开发过程,并提高了整体生产力。
代码审查经常催化出比最初实现更高效、更优雅或更简单的解决方案的讨论。代码审查的这一方面尤其有价值,因为它利用了团队的集体专业知识和经验,从而提升了整体软件设计。例如,考虑一个开发者以低效的方式实现了一个排序向量的函数。在代码审查过程中,另一位团队成员可能会注意到这种低效,并提出一个更有效的排序算法,或者建议利用标准库中的现有工具。这样的建议不仅提高了性能,还简化了代码,减少了复杂性和潜在的错误,从而使软件更加健壮和易于维护。
准备代码审查
在深入到代码审查的协作过程之前,团队必须充分准备,以确保这些审查尽可能有效和高效。这种准备不仅为更顺畅的审查过程奠定了基础,而且最大限度地减少了在可避免问题上的时间消耗,使团队能够专注于更实质性和有影响力的讨论。
清晰的指南
有效的代码审查过程的基础是建立和记录针对 C++ 的明确、具体的编码指南。这些指南应涵盖编码的各个方面,包括风格、实践和语言特定功能的用法。通过设定这些标准,团队创建了一种共同语言,减少了歧义并确保了代码库的一致性。
将尽可能多的自动化融入这些指南可以显著简化审查过程。例如,格式化工具确保一致的编码风格,而静态分析工具可以在人类审查员查看代码之前自动检测潜在的错误或代码异味。此外,确保所有代码提交都附有通过单元测试,以及适用时端到端测试,可以防止许多常见的软件缺陷进入审查阶段。这种自动化水平不仅为审查员节省了时间,还减少了在编码风格或微小疏忽方面的主观偏好争议的可能性。
自我审查
准备代码审查的另一个关键方面是进行自我审查。在提交代码供同行审查之前,开发者应彻底检查自己的工作。这就是工具如 linters 和静态分析发挥作用的地方,帮助捕捉容易被忽视的常见问题。
自我审查鼓励开发者对其代码的初始质量负责,减轻了同行审查者的负担,并培养了一种责任感文化。它还允许开发者反思自己的工作,在涉及他人之前考虑潜在的改进,这可能导致提交更高质量的代码和更高效的审查会议。在提交代码供同行审查之前,开发者应通过以下问题系统地评估自己的工作。这种反思实践有助于完善代码,使其更接近项目目标,并为代码审查过程中的任何后续讨论做好准备:
-
我需要写代码吗?(我的改动合理吗?) 在添加新代码之前,考虑一下这个改动是否必要。评估功能是否关键,并证明添加的合理性,同时考虑到代码库中可能增加的复杂性。
-
我怎样才能避免编写代码?(是否有我可以利用的第三方库或工具?) 总是寻找利用现有解决方案的机会。使用经过良好测试的第三方库或工具通常可以实现所需的功能,而无需添加新代码,从而减少潜在的 bug 和维护开销。
-
我的代码可读吗? 评估你代码的清晰度。好的代码应该对可能不熟悉它的其他工程师来说是自我解释的。使用有意义的变量名,保持整洁的结构,并在必要时包含注释来解释复杂的逻辑。
-
其他工程师需要理解我的代码逻辑吗? 考虑你的代码是否可以被其他开发者独立理解。其他人能够在不需要广泛解释的情况下理解逻辑至关重要,这有助于简化维护和集成。
-
我的代码看起来是否与其他代码库相似? 确保你的代码遵循项目中建立的编码风格和模式。代码库的一致性有助于保持统一性,使软件更容易阅读,并在集成过程中减少错误。
-
我的代码效率如何? 评估你代码的效率。考虑资源使用,如 CPU 时间和内存,特别是在性能关键的应用程序中非常重要。审查你的算法和数据结构,以确保它们对任务来说是最佳的。
-
我的测试是否覆盖了边缘情况? 确认你的测试是全面的,特别是检查它们是否覆盖了边缘情况。健壮的测试对于确保代码在异常或意外条件下的弹性和可靠性至关重要。
通过在自我审查过程中回答这些问题,开发者不仅提高了他们所编写代码的质量,还简化了同行评审过程。这种深思熟虑的方法最小化了在同行评审期间进行重大修订的可能性,并提高了代码审查周期的整体有效性。这种准备可以在代码审查期间导致更明智和建设性的讨论,因为开发者已经意识到并解决了许多潜在的问题。
在编写代码之前与审阅者和代码所有者讨论大功能
成功地导航代码审查过程对于维护软件质量以及培养积极和富有成效的团队环境至关重要。在这里,我们概述了旨在确保他们的代码顺利有效地通过审查的开发者所必需的基本策略。
在编写代码之前与审阅者和代码所有者讨论大功能
在着手开发重大功能之前,建议与代码评审者和所有者进行咨询。这次初步讨论应集中在拟议的设计、实施方法和该功能如何融入现有的代码库。尽早参与这种对话有助于统一预期,减少后期重大修订的可能性,并确保该功能能够与其他项目部分无缝集成。
在发布代码之前仔细检查
在提交代码供同行评审之前,请彻底审查自己的代码。这种自我评估应涵盖逻辑、风格以及对项目编码标准的遵守情况。寻找任何可以改进或简化的区域。确保你的提交尽可能完善,这不仅有助于使评审过程更加顺畅,而且也展示了你的勤奋和对评审者时间的尊重。
确保代码符合代码约定
遵守既定的代码约定至关重要。这些约定涵盖了从命名方案到布局和程序实践的各个方面,确保代码库的一致性。一致性使得代码更容易阅读、理解和维护。在提交评审之前,请确保你的代码严格遵循这些指南,以避免在评审过程中出现不必要的来回沟通。
代码评审是一种对话,而非命令
代码评审应被视为一种对话而非指令。评审者通常提供旨在引发讨论而非单方面命令的评论和建议。特别是对于初级开发者来说,理解你被鼓励参与这些讨论非常重要。如果建议或更正不明确,请寻求澄清,而不是默默地进行更改。这种互动不仅有助于你的职业发展,而且增强了评审过程的协作精神。
记住——你的代码不是你本人
我从前老板 Vladi Lyga 那里学到的宝贵教训是“你的代码不是你本人。”开发者往往在他们的代码上投入了大量的努力和自豪感,接受批评可能具有挑战性。然而,记住,对你代码的反馈并不是对你作为开发者或个人的个人批评。目标是改进项目并确保最高质量的结果,有时这需要建设性的批评。将个人身份与其工作分离,使开发者能够更客观地对待反馈,并将其作为成长的机会。
通过充分准备、参与开放对话并接受建设性的反馈视角,开发者可以有效地应对代码评审过程。这些做法不仅提高了代码质量,而且有助于营造一个更加支持和协作的团队环境。
如何在代码评审中高效地提出异议
争议是代码审查过程中的自然部分。不同的观点可能导致在方法、实现或最佳实践的解释上产生冲突。有效地处理这些争议对于保持高效的审查过程和健康的团队环境至关重要。以下是一些在代码审查期间有效管理争议的关键策略。
变更的明确理由
审阅者不仅需要指出需要改进的领域,而且还需要清楚地解释为什么需要变更。在建议修改时,审阅者应提供基于最佳实践、性能考虑或与项目相关的原则的合理依据。包括对编码标准、文章、文档或其他权威资源的链接可以大大增强论点的清晰度和说服力。这种方法有助于被审阅者理解反馈背后的推理,使他们更有可能看到建议变更的价值。
被审阅者的相互解释
同样,如果被审阅者不同意某个评论或建议,他们也应该清楚地阐述自己的理由。这种解释应详细说明为什么选择了他们的方法或解决方案,并辅以相关的技术依据、文档或项目内的先例。通过提供合理的论点,被审阅者可以促进更明智的讨论,这可能导致更好的理解或改进的解决方案。
直接沟通
当争议涉及多轮来回评论时,建议将对话从书面评论转移到直接对话。这可以通过视频通话、电话或面对面会议来实现,具体取决于可行性。直接沟通可以防止在基于文本的讨论中常见的误解和升级,这些讨论可能会迅速变得无效率和有争议,就像在 Reddit 等平台上看到的漫长线程一样。
引入额外的观点
如果审阅者和被审阅者之间无法达成协议,引入额外的观点可能有益。引入第三位工程师、产品经理、质量保证专家或甚至架构师可以提供新的见解并帮助调解分歧。这些方可能提供替代方案、妥协方法或基于更广泛的项目优先级和影响的决策。他们的意见在打破僵局和确保决策全面并与整体项目目标一致方面可能至关重要。
在代码审查期间进行有效的争议解决对于保持审查过程的建设性和专注于提高代码库的质量至关重要。通过解释反馈背后的理由,鼓励直接沟通,并在必要时涉及额外的观点,团队可以有效地解决分歧,并保持积极、协作的环境。这种方法不仅解决了冲突,还增强了团队共同应对未来挑战的能力。
如何成为一名优秀的审稿人
在代码审查过程中,审稿人的角色至关重要,这不仅是为了确保代码的技术质量,也是为了维护一个建设性、尊重性和教育性的环境。以下是一些定义优秀审稿人的关键实践。
开始对话
通过与被审稿人进行友好的对话开始审查过程。这可以是一条简短的消息,承认他们为拉取请求(PR)所付出的努力,并为即将到来的审查设定积极的基调。友好的开始有助于与被审稿人建立联系,使随后的交流更加开放和协作。
保持礼貌和尊重
在你的评论中始终保持礼貌和尊重。记住,被审稿人已经投入了大量努力到他们的代码中。批评应该是建设性的,专注于代码及其改进,而不是个人。以问题或建议的形式表达反馈,而不是指令,也有助于保持积极的语气和鼓励的基调。
审查可管理的部分
如果可能,一次审查的代码量限制在大约 400 行。审查大量代码可能导致疲劳,从而增加遗漏次要和关键问题的可能性。将审查分解为可管理的部分不仅提高了审查过程的有效性,还有助于保持对细节的高度关注。
避免个人偏见
在审查过程中,区分必须因客观原因更改的代码——例如语法错误、逻辑错误或偏离项目标准——以及反映个人编码偏好的更改非常重要。例如,让我们考虑以下代码片段:
std::string toString(bool done) {
if (done) {
return "done";
} else {
return "not done";
}
}
一些工程师可能更喜欢以下方式重写这个函数:
std::string toString(bool done) {
if (done) {
return "done";
}
return "not done";
}
虽然第二个版本更加简洁,但第一个版本同样有效,并且符合项目的编码标准。如果你对可能增强代码的个人偏好有强烈的看法,请明确将其标记为个人偏好。指出这是一个基于个人偏好的建议,而不是强制性的更改。这种清晰度有助于被审稿人理解哪些更改对于符合项目标准是必要的,哪些是可选的改进。
专注于可理解性
作为审稿人,你最需要问自己的关键问题是代码是否足够易懂,以至于你或团队中的其他人可以在半夜修复它。这个问题直击代码可维护性的核心。如果答案是肯定的,那么讨论提高代码清晰度和简洁性的方法就很重要了。易于理解的代码更容易维护和调试,这对于长期项目健康至关重要。
成为一名优秀的审稿人远不止于仅仅识别代码中的缺陷。它需要发起并维持支持性的对话,尊重并认可同事的努力,有效地管理审稿工作量,并提供清晰、有帮助的反馈,将项目标准置于个人偏好之上。通过营造积极和富有成效的审稿环境,你不仅有助于提高代码质量,还有助于团队的发展和凝聚力。
摘要
本章深入探讨了进行有效代码审查的基本实践和原则,这是 C++软件开发过程中的一个关键组成部分。通过一系列结构化的子章节,我们探讨了代码审查过程的各个方面,共同确保高质量、可维护的代码,同时营造积极的团队环境。
我们首先讨论了代码审查的起源,这是由迈克尔·法根在 20 世纪 70 年代引入的,强调了它将软件开发从孤立的任务转变为增强代码质量和减少错误协作努力的变革性作用。
在准备代码审查部分,我们强调了明确指南和自我审查的重要性。我们鼓励开发者使用诸如代码检查器和静态分析工具等工具来在同行审查之前完善他们的代码,确保遵守编码标准并减少代码修订的迭代周期。
在如何通过代码审查部分,我们概述了开发者确保他们的代码在审查中受到良好接受的战略。这包括在编码前讨论重大变更,将代码审查视为建设性对话,并记住在审查反馈时将个人身份与代码分离,以客观地看待反馈。
在如何高效地在代码审查中提出异议部分,我们讨论了如何有效地处理分歧。我们强调了明确变更理由的重要性,使用直接沟通以避免误解,并在必要时涉及额外观点以解决冲突并达成共识。
最后,在如何成为一名优秀的审稿人部分,我们提供了关于如何以积极互动开始审稿、分块审阅代码、避免个人偏好的影响,以及在关键时刻评估代码的清晰度和易于理解性的指导。
在本章的整个内容中,其核心主题一直是代码审查并不仅仅是关于批评代码,而是关于建立一个支持性的开发者社区,这个社区共享知识、持续改进,并致力于在编码实践中追求卓越。目标是提升软件的技术质量以及参与团队成员的专业发展。通过遵循这些最佳实践,团队可以创造出更加健壮、高效且无错误的代码,这对他们 C++项目的成功贡献显著。