- 文档
- 教程
【免费下载链接】cppbestpractices
Collaborative Collection of C++ Best Practices. This online resource is part of Jason Turner's collection of C++ Best Practices resources. See README.md for more information.
导读:本文基于开源协作项目 cppbestpractices(Jason Turner 维护的 C++ 最佳实践合集)中的 Style 章节,系统梳理一套可直接落地执行的 C++ 风格体系:从命名约定、成员/参数前缀、头文件组织,到成员初始化、类型安全与运算符重载原则。读完本文,你将能为自己或团队制定并固化一套统一的 C++ 编码规范,配合.clang-format实现风格自动化,并避免一系列容易引发未定义行为与可维护性灾难的常见写法。
本文是 cppbestpractices 系列的第 3 章(完整目录见 00-Table_of_Contents.md,系列总览见 README.md,写作理念见 01-Preface.md),与安全主题的 04-Considering_Safety.md、可维护性主题的 05-Considering_Maintainability.md 互为补充。
风格的核心原则:一致性优先
风格(Style)最重要的一点是一致性,第二重要的是遵循普通 C++ 程序员习惯阅读的风格。这两点决定了代码的可读性与协作效率:不一致的代码让每个读者都感到困惑,而过于"个性化"的风格即使自洽,也会让不熟悉它的开发者难以快速理解。
C++ 允许任意长度的标识符名称,因此在命名时没有任何理由吝啬。命名应当具有描述性(descriptive),并且在整个代码库中保持一致。常见的两种风格是:
CamelCase(驼峰式,如MyClass、myVariableName)snake_case(下划线式,如my_class、my_variable_name)
其中snake_case有一个独特优势:如果需要,它可以与拼写检查器(spell checker)配合工作,帮助发现命名拼写错误。
用 .clang-format 固化风格
无论你制定了怎样的风格指南,务必落实一个.clang-format文件来指定团队期望的格式。虽然 clang-format 无法约束命名,但它能自动统一缩进、换行、空格、花括号位置等机械性格式,这对开源项目尤其重要——开源项目必须维持一致的风格,因为贡献者来自世界各地,手动保持格式一致几乎不可能。
所有主流 IDE 和编辑器都对 clang-format 提供了内置支持或可通过插件轻松安装:
- VSCode:微软官方 C/C++ 扩展(Microsoft C/C++ extension for VS Code)
- CLion:内置 clang-format 作为替代格式化器
- Visual Studio:LLVM 官方 ClangFormat 扩展
- ReSharper++:支持使用 clang-format 作为代码风格来源
- Vim:
vim-clang-format或vim-autoformat等插件 - Xcode:
ClangFormat-Xcode插件
建议:将
.clang-format文件提交到仓库根目录,并配合编辑器"保存时自动格式化"或 pre-commit 钩子,让格式问题在进入代码评审之前就被机器解决。clang-format 的更多用法可参考仓库 02-Use_the_Tools_Available.md 中关于 ClangFormat 工具的介绍。
命名规范:一套完整可执行的 C++ 命名体系
常见 C++ 命名约定速查
原文档给出了一套社区广泛认可的基线约定:
- 类型(Types)以大写字母开头:
MyClass。 - 函数与变量以小写字母开头:
myMethod。 - 常量全部大写:
const double PI = 3.14159265358979323;。
C++ 标准库以及 Boost 等知名 C++ 库遵循以下约定,可视为事实上的行业标准:
- 宏名称使用大写加下划线:
INT_MAX。 - 模板参数名称使用 PascalCase:
InputIterator。 - 其他所有名称使用 snake_case:
unordered_map。
这套约定之所以值得采用,是因为它让"看名字即知身份"成为可能:看到大写开头的标识符即可判断是类型,看到全大写即可怀疑是常量或宏,看到 snake_case 即可判断是普通函数、变量或标准库实体。
区分私有对象数据:m_ 前缀
为私有数据加上m_前缀,以将其与公有数据区分开。m_代表 "member"(成员):
class PrivateSize { public: int width() const { return m_width; } int height() const { return m_height; } PrivateSize(int t_width, int t_height) : m_width(t_width), m_height(t_height) {} private: int m_width; int m_height; };在成员函数体内,m_前缀让读者一眼就能看出某个名字是对象状态(成员变量),而非局部变量或参数,从而避免赋值方向的误读。
区分函数参数:t_ 前缀
函数参数使用t_前缀,用于与作用域内其他变量区分。t_可以理解为 "the",但含义本身是任意的,要点是为参数提供一致、可辨识的命名策略:
struct Size { int width; int height; Size(int t_width, int t_height) : width(t_width), height(t_height) {} };注意,任何前缀或后缀都可以根据组织自身选择,t_只是一个示例。这一建议存在争议(cppbestpractices 仓库的 issue [#11] 对此有过专门讨论)——原文档也明确标注了这一争议性,因此团队采用前应先达成共识。无论选哪种,代码库内部的一致性才是最重要的。
永远不要以下划线开头命名
如果你这样做,就有可能与编译器及标准库实现保留的标识符发生冲突:
- 以下划线开头、后跟大写字母或另一个下划线的标识符(如
_Foo、__foo),在全局与局部作用域都被 C++ 标准保留给实现使用; - 在全局命名空间中以单个下划线开头的标识符(如
_foo)同样被保留; - 命名空间作用域中的
_foo形式也可能与标准库实现内部使用的名称冲突。
规避方法是:私有成员用m_前缀而不是_前缀,参数用t_前缀,模板参数用 PascalCase——这样既达成了区分目的,又不会踩进保留标识符的雷区。
一份完整的"好风格"示例类
把上述约定整合起来,得到如下自洽的示例:
class MyClass { public: MyClass(int t_data) : m_data(t_data) { } int getData() const { return m_data; } private: int m_data; };特征总结:类型MyClass大写开头;构造参数t_data带t_前缀;成员m_data带m_前缀;getter 命名为getData;const成员函数明确不修改对象状态。
布局与可读性
生成文件与源码目录分离
启用源代码目录外的构建(Out-of-Source-Directory Builds):确保构建生成的产物(目标文件、可执行文件、中间文件)全部输出到与源码分离的目录,而不是混入源码树。这样做的好处包括:源码目录保持干净、多个构建配置(Debug/Release、不同编译器)可并行共存、清理构建产物只需删除输出目录。实践中用 CMake 等构建工具时,应坚持在独立构建目录中执行配置与编译(参见 02-Use_the_Tools_Available.md 中关于构建工具的介绍)。
使用 nullptr 而非 0 或 NULL
C++11 引入了nullptr——一个专门表示空指针的值。应当用它代替0或NULL来表示空指针。理由:0是整型字面量,NULL在 C++ 中通常是0的宏(或0L),在重载解析、模板推导等场景中容易与整型混淆,而nullptr的类型是std::nullptr_t,只能转换为指针类型,语义精确、类型安全。
注释统一使用 //
注释块应使用//,而不是/* */。用//的好处是:调试时更容易成块注释掉代码。例如:
// this function does something int myFunc() { }调试时想暂时禁用该函数,只需用/* */包住整块:
/* // this function does something int myFunc() { } */如果函数头的注释本身用的是/* */,这样的成块注释会因嵌套注释而失败(/*遇到内部的*/会提前结束)。因此统一使用//,把"块注释掉代码"的能力留给/* */。
代码块强制使用 {}
块(block)的花括号不能省略。省略花括号会导致语义错误:
// Bad Idea // This compiles and does what you want, but can lead to confusing // errors if modification are made in the future and close attention // is not paid. for (int i = 0; i < 15; ++i) std::cout << i << std::endl; // Bad Idea // The cout is not part of the loop in this case even though it appears to be. int sum = 0; for (int i = 0; i < 15; ++i) ++sum; std::cout << i << std::endl; // 看似在循环内,实际不在 // Good Idea // It's clear which statements are part of the loop (or if block, or whatever). int sum = 0; for (int i = 0; i < 15; ++i) { ++sum; std::cout << i << std::endl; }第二个 "Bad Idea" 展示了典型的陷阱:无花括号的循环体只有一条语句,后续追加的语句会被"踢出"循环,且编译器在缩进不匹配时(若无额外告警)不会报错。这与-Wmisleading-indentation等告警相关——GCC 6.0 起可用该选项检测"缩进暗示块而实际没有块"的情况(见 02-Use_the_Tools_Available.md 的告警清单)。最稳妥的做法就是:任何控制流语句都写花括号。
行长度控制
保持行的合理长度。对比:
// Bad Idea // hard to follow if (x && y && myFunctionThatReturnsBool() && caseNumber3 && (15 > 12 || 2 < 3)) { } // Good Idea // Logical grouping, easier to read if (x && y && myFunctionThatReturnsBool() && caseNumber3 && (15 > 12 || 2 < 3)) { }许多项目和编码规范有一个软性指南:每行尽量少于 80 或 100 个字符。这样的代码通常更易读,还能让两个文件并排显示在同一屏幕上而无需缩小字体。折行时按逻辑分组断行,便于快速浏览条件构成。
本地文件用 "" 包含
#include的引号与尖括号有明确分工:<>保留给系统头文件,本地文件应使用""。
// Bad Idea. Requires extra -I directives to the compiler // and goes against standards. #include <string> #include <includes/MyHeader.hpp> // Worse Idea // Requires potentially even more specific -I directives and // makes code more difficult to package and distribute. #include <string> #include <MyHeader.hpp> // Good Idea // Requires no extra params and notifies the user that the file // is a local file. #include <string> #include "MyHeader.hpp"用""包含本地文件的好处:编译器优先在包含该文件的源文件所在目录中查找(不需要额外的-I参数),同时从写法上就向读者明确"这是一个项目内文件"。
头文件与编译单元组织
头文件中禁止 using namespace
在头文件中写using namespace,会把该命名空间拉入所有包含此头文件的文件的命名空间中——这污染了命名空间,并可能在将来引发名称冲突。而在实现文件(.cpp)中写using namespace则没有问题,因为其影响范围被限制在该编译单元内。
Include Guard
头文件必须包含命名明确、不易冲突的 include guard,以避免同一头文件被多次包含时重复定义,并防止与其他项目的头文件冲突。推荐的命名模式是"项目名 + 路径 + 文件名":
#ifndef MYPROJECT_MYCLASS_HPP #define MYPROJECT_MYCLASS_HPP namespace MyProject { class MyClass { }; } #endif也可以考虑使用#pragma once指令——它在许多编译器中是准标准(quasi-standard)支持。它更简短,且意图清晰。两种方式都应在项目内统一选择其一。
文件扩展名用 .hpp 和 .cpp
最终这是偏好问题,但.hpp与.cpp被各种编辑器和工具广泛识别,因此这是一个务实的选择。具体来说:Visual Studio 只自动识别.cpp和.cxx作为 C++ 源文件;Vim 不一定把.cc识别为 C++ 文件。值得借鉴的一个大型项目(OpenStudio)的做法是:用户生成的文件用.hpp/.cpp,工具生成的文件用.hxx/.cxx——两类扩展名都被广泛识别,且这种区分很有帮助。
永远不要混用 Tab 和空格
一些编辑器默认用 Tab 与空格的混合缩进。这会让所有未使用完全相同 Tab 宽度设置的人无法阅读代码。应配置编辑器避免这种情况(例如统一"空格缩进 2/4 格"并开启"Tab 键插入空格"),再配合.clang-format强制执行。
成员变量初始化
优先使用成员初始化列表
用成员初始化列表(member initializer list)初始化成员。对于 POD 类型,初始化列表与手动赋值的性能相同;但对于其他类型,初始化列表有明显性能优势:
// Bad Idea class MyClass { public: MyClass(int t_value) { m_value = t_value; } private: int m_value; }; // Bad Idea // This leads to an additional constructor call for m_myOtherClass // before the assignment. class MyClass { public: MyClass(MyOtherClass t_myOtherClass) { m_myOtherClass = t_myOtherClass; } private: MyOtherClass m_myOtherClass; }; // Good Idea // There is no performance gain here but the code is cleaner. class MyClass { public: MyClass(int t_value) : m_value(t_value) { } private: int m_value; }; // Good Idea // The default constructor for m_myOtherClass is never called here, so // there is a performance gain if MyOtherClass is not is_trivially_default_constructible. class MyClass { public: MyClass(MyOtherClass t_myOtherClass) : m_myOtherClass(t_myOtherClass) { } private: MyOtherClass m_myOtherClass; };第二个 "Bad Idea" 中,m_myOtherClass会先被默认构造一次,再被拷贝赋值一次——白白多一次构造调用;初始化列表版本则直接以参数构造成员,省去中间步骤。
默认成员初始化:= 与 {} 的取舍
C++11 起,可以给每个成员直接赋默认值(用=或用{})。这保证了任何构造函数都不可能"忘记"初始化某个成员。
使用=赋值:
// ... // private: int m_value = 0; // allowed unsigned m_value_2 = -1; // narrowing from signed to unsigned allowed // ... //使用花括号初始化:{}初始化在编译期禁止缩窄(narrowing)转换:
// Best Idea // ... // private: int m_value{ 0 }; // allowed unsigned m_value_2 { -1 }; // narrowing from signed to unsigned not allowed, leads to a compile time error // ... //除非有充分理由,否则优先使用{}初始化而不是=。忘记初始化成员是未定义行为(UB)的来源之一,这类 bug 往往极难排查。花括号初始化的编译期缩窄检查能在错误发生前就拦下类型隐患。
不可变成员标记为 const
如果成员变量在初始化之后预期不再改变,就把它标记为const:
class MyClass { public: MyClass(int t_value) : m_value{t_value} { } private: const int m_value{0}; };注意:const成员无法被赋值新值,因此这样的类可能没有有意义的拷贝赋值运算符——这是选择const成员前需要意识到的约束。
命名空间与类型安全
总是使用命名空间
几乎没有任何理由把标识符声明在全局命名空间中。函数和类应存在于命名恰当的命名空间内,或命名空间内的类中。放在全局命名空间的标识符,有与来自其他库(主要是没有命名空间的 C 库)的标识符冲突的风险。
标准库整数类型与 size_t 下溢陷阱
标准库对任何与"大小"相关的东西通常使用std::size_t。size_t的大小是实现定义的。通常使用auto能避免大部分相关问题,但并非全部。
务必坚持使用正确的整数类型,并与 C++ 标准库保持一致。问题可能不会在你当前的平台上告警,但换平台后很可能就会告警。
特别要注意:对无符号值进行某些运算时会发生整数下溢(underflow)。例如:
std::vector<int> v1{2,3,4,5,6,7,8,9}; std::vector<int> v2{9,8,7,6,5,4,3,2,1}; const auto s1 = v1.size(); const auto s2 = v2.size(); const auto diff = s1 - s2; // diff underflows to a very large number这里s1(8)与s2(9)都是size_t(无符号),8 - 9下溢成一个极大值,而不是负数——这种 bug 在循环索引、边界判断中极易造成内存越界。写法上应避免无符号减法,或在计算前显式转换/判断大小关系。
防止常见误用
不要在 assert() 里放带副作用的代码
assert(registerSomeThing()); // make sure that registerSomeThing() returns true上面这段代码在 Debug 构建时能正常工作,但在 Release 构建时该调用会被编译器删除,导致 Debug 与 Release 行为不一致——因为assert()是宏,在 Release 模式下展开为空。正确的做法是把副作用剥离出来:
[[maybe_unused]] const auto success = registerSomeThing(); assert(success);这也与 05-Considering_Maintainability.md 中 "Never UseassertWith Side Effects" 一节给出的建议完全一致,可见该原则在本系列中被反复强调。
不要害怕模板
模板能帮助你坚守 DRY(Don't Repeat Yourself)原则。模板应优先于宏,因为宏不尊重命名空间等作用域规则。将通用逻辑抽象为模板,既避免重复代码,又能获得编译期类型检查。
审慎使用运算符重载
运算符重载的初衷是让语法富有表现力:两个大整数相加写作a + b而不是a.add(b);另一个常见例子是std::string的string1 + string2字符串拼接。
然而,过多或错误地重载运算符很容易产生不可读的表达式。重载运算符时有几条基本规则需要牢记:
- 当类管理资源时,重载
operator=()是必须的(参见下文 考虑 Rule of Zero); - 对于其他运算符,只在它们被普遍用于该上下文时才重载:典型场景是用
+拼接、重载可视为"真/假"的表达式的取反等; - 始终注意[运算符优先级],尽量避免反直觉的构造;
- 不要重载
~、%等冷门运算符,除非在实现数值类型或遵循某个领域公认的语法; - 永远不要重载
operator,()(逗号运算符); - 处理流时使用非成员函数
operator>>()和operator<<()。例如重载operator<<(std::ostream &, MyClass const &)实现把类"写入"流——std::cout、std::fstream或std::stringstream(后者常用于生成值的字符串表示); - 更多常见可重载运算符清单可参考公开的 C++ FAQ 讨论。
避免隐式转换
单参数构造函数:单参数构造函数会在编译期被隐式调用以自动转换类型。这对std::string(const char *)这类场景很方便,但一般应避免,因为它们可能带来意外的运行时开销。正确做法是把单参数构造函数标记为explicit,要求显式调用。
转换运算符:与单参数构造函数类似,转换运算符会被编译器隐式调用并引入意外开销,也应标记为explicit:
// bad idea struct S { operator int() { return 2; } };// good idea struct S { explicit operator int() { return 2; } };explicit转换运算符要求显式写出static_cast<int>(s),避免在if (s)、数值运算等处发生隐式、非预期的类型变换。
考虑 Rule of Zero
Rule of Zero主张:除非你的类在做某种新形式的资源所有权管理,否则不要提供任何编译器能自动生成的函数——包括拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。
目标在于:让编译器提供最优版本,并且在后续添加更多成员变量时自动保持正确维护。自己手写这些特殊成员函数,意味着每次成员变更都必须同步维护它们,极易出错(比如漏了深拷贝、移动构造等),而遵循 Rule of Zero 的类把这些职责交给编译器,也就自动规避了经典的 "Rule of Three/Five" 资源管理错误。
实现 Rule of Zero 的关键技巧是让所有资源都由值语义的 RAII 类型持有(如std::vector、std::string、std::unique_ptr等),而非裸指针。这与 04-Considering_Safety.md 中"避免裸内存访问、使用std::make_unique/std::make_shared"的建议互为表里:当每个成员都自带正确的拷贝/移动/析构语义时,编译器合成的特殊成员函数就是最优且正确的,这也是本系列在风格层面给出的最值得贯彻的现代 C++ 原则之一。
总结:cppbestpractices 的 Style 章提供的不是"唯一正确"的风格,而是一套自洽、可落地、可被工具固化的基线:一致性优先的命名体系(m_/t_前缀、PascalCase 模板参数、snake_case 常规标识符)、//注释与强制花括号、初始化列表与{}默认值、头文件守卫与命名空间纪律、explicit与 Rule of Zero。将这些规则写进.clang-format与团队评审清单,即可在规模化协作中把"风格"从争论对象变成自动执行的基础设施。
- 文档
- 教程
【免费下载链接】cppbestpractices
Collaborative Collection of C++ Best Practices. This online resource is part of Jason Turner's collection of C++ Best Practices resources. See README.md for more information.
相关推荐
流媒体下载保姆级攻略:N_m3u8DL-RE 5分钟搞定点播、直播与解密
流媒体下载保姆级攻略:N_m3u8DL RE 5分钟搞定点播、直播与解密 想把网课存下来、想把公开课的直播留下来?只需要一个工具就够了。N_m3u8DL RE
CLI音视频xiaozhi-esp32 代码风格指南:基于 clang-format 的 C/C++ 格式化规范与实战
xiaozhi esp32 代码风格指南:基于 clang format 的 C/C++ 格式化规范与实战 本指南围绕 xiaozhi esp32(基于 ESP
人工智能大模型语音交互助手嵌入式物联网智能硬件MCP 服务rippled C++ 编码风格指南:从缩进、命名到 clang-format 的完整工程实践
rippled C++ 编码风格指南:从缩进、命名到 clang format 的完整工程实践 本篇技术指南以 XRP Ledger 守护进程 rippled
区块链
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考