C++ 常见代码异味(Code Smells)
0. 信息来源
https://github.com/arnemertz/presentations/tree/main/IdentifyingCommonCodeSmells
详细的Rule检查可以看这个列表:https://rules.sonarsource.com/cpp/
1. 一段话总结
该文档由软件工程师 Arne Mertz 撰写,聚焦 C++ 中的常见代码异味,首先依据 Martin Fowler 定义明确代码异味是系统深层问题的表面迹象(易识别、非实际问题、可能不构成问题但常违反原则/缺失模式/影响可维护性),随后通过 SFML 网球示例等开源代码片段,详细分析了长函数、过早泛化、深层嵌套控制流、复杂表达式、缺少 const/constexpr、缺失 RAII、违反“五法则”、原生循环这 8 类核心代码异味的表面特征、深层问题及修复方法,同时强调代码异味在各类代码库中普遍存在,并非必然是错误代码,且提及编译器警告、静态分析等工具对检测异味的作用,还推荐了 Jason Turner、Kate Gregory 等人的相关技术分享作为补充学习资源。
2. 思维导图(mindmap)
## 文档基础信息
- 作者:Arne Mertz(软件工程师,嵌入式领域为主,近20年C++学习经验,C++与可维护代码培训师)
- 代码示例来源:开源代码(如SFML网球示例、Qt示例、libsass、LeddarSDK等),非针对特定开发者或代码批评
- 核心定义:代码异味(Martin Fowler)- 系统深层问题的表面迹象,具易识别、非实际问题、可能不构成问题、违反原则、缺失模式/惯用法/抽象、影响可维护性特点
## 8类核心C++代码异味
- 长函数- 表面迹象:函数过长,含单行“功能”注释块- 深层问题:违反单一职责原则、单一抽象层级原则- 长度判断:无固定量化标准(10行可能过长,20行可能合适,100行大概率过长)- 修复方法:提取函数(复用非唯一目的,注释块可作函数名参考)、考虑为复杂功能数据创建类
- 过早泛化- 表面迹象:存在无用/未使用参数/回调、仅单一类型实例化的模板、仅一个派生类的基类(依赖倒置除外)- 深层问题:违反KISS(保持简单)、YAGNI(无需过度设计)原则,设计复杂、维护难、测试用例冗余或缺失- 修复方法:保持设计尽可能简单(不过度简化)
- 深层嵌套控制流- 表面迹象:多层循环/条件嵌套(如while嵌套for再嵌套if)- 深层问题:难追踪代码执行路径、违反单一职责原则与单一抽象层级原则,常与长函数并存- 修复方法:提取函数、条件倒置实现提前返回
- 复杂表达式- 表面迹象:长且多条件的判断表达式(如多变量比较的if条件)- 深层问题:违反单一抽象层级原则- 修复方法:提取中间变量、封装为函数
- 缺少const/constexpr- 表面迹象:可标记为const/constexpr的函数或对象未标记(如返回成员变量的非const函数)- 深层问题:语义模糊、易发生意外修改- 重要性:const可提升代码规范性、避免常见错误、促进算法使用(Jason Turner观点)
- 缺失RAII- 表面迹象:未利用RAII机制管理资源(如手动释放传感器、播放器资源)- 深层问题:资源泄漏、清理/重置错误- 修复方法:使用标准库RAII类(智能指针、锁等)、自定义类中用析构函数清理、编写RAII包装器
- 违反“五法则”- 表面迹象:仅定义“五大函数”(析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符)中的部分,其余依赖编译器生成- 深层问题:编译器生成的函数可能引发意外错误(如浅拷贝问题)- 修复方法:需定义其中一个时,其余优先用=default(默认实现)或=delete(禁用)
- 原生循环- 表面迹象:使用原生for循环(如遍历列表查找元素、拷贝数据),未用标准库算法- 修复方法:优先用基于范围的for循环、<algorithm>库函数(如std::find_if、std::copy、std::transform)、C++20及以上用 ranges(如std::views::filter)
## 工具与补充资源
- 推荐工具:编译器警告(-Wall、-Werror、-pedantic)、优化器与分析器、静态分析工具(clang-tidy、cppcheck)、 sanitizers(测试时使用)、IDE重构工具
- 补充学习资源:Jason Turner(CppCon 2019 “C++ Code Smells”)、Kate Gregory(CppCon 2019 “Naming is Hard: Let’s Do Better”、ACCUConf 2022 “Abstraction Patterns...”)、sourcemaking.com/refactoring/smells(重构与异味参考)
## 核心结论
- 代码异味在所有代码库中普遍存在,示例代码未必是劣质代码
- 代码异味不总是错误,无需立即全部修复
- 即使无法使用C++11及以上版本,代码也可避免异味
3. 详细总结
一、文档基础信息
- 作者背景:Arne Mertz(@arne_mertz),软件工程师,主要专注嵌入式领域,拥有近20年C++学习经验,同时是C++与可维护代码的培训师。
- 代码示例说明:
- 所有示例均来自开源代码(如SFML网球示例、Qt主窗口示例、libsass、LeddarSDK等);
- 示例目的是展示代码异味的普遍性,而非批评特定开发者或代码;
- 多数示例非生产代码(如使用示例),但仍需具备可维护性与可读性。
- 代码异味定义(源自Martin Fowler):
- 本质:系统深层问题的表面迹象;
- 核心特征:
- 相对易识别;
- 并非实际问题本身;
- 不总是构成问题;
- 常违反设计原则;
- 缺失合适的模式、惯用法或抽象;
- 最终导致可维护性问题。
- 参考链接:https://martinfowler.com/bliki/CodeSmell.html
二、8类核心C++代码异味分析(含特征、问题、修复方法)
| 代码异味类型 | 表面迹象 | 深层问题 | 修复方法 | 关键示例/说明 |
|---|---|---|---|---|
| 长函数 | 函数代码行数过多;含标记“功能”的单行注释块(如“// Create the ball”) | 违反单一职责原则、单一抽象层级原则 | 1. 提取独立函数(复用并非函数提取的唯一目的);2. 注释块可作为函数名参考;3. 复杂功能数据考虑封装为类 | Qt主窗口示例中newLetter()函数(含框架设置、格式定义、表格插入等多功能);10行可能过长,100行大概率过长 |
| 过早泛化 | 1. 存在无用/未使用的参数、回调;2. 仅单一类型实例化的模板;3. 仅1个派生类的基类(依赖倒置除外) | 违反KISS(保持简单)、**YAGNI(无需过度设计)**原则;设计复杂、维护难;测试用例冗余或缺失 | 保持设计“尽可能简单,但不过度简化” | 不必要的模板类(仅支持int类型却设计为模板);多余的回调参数(从未被调用) |
| 深层嵌套控制流 | 多层循环与条件嵌套(如while嵌套for,再嵌套if判断按键事件) | 1. 难追踪代码执行路径(“如何到达当前逻辑”);2. 违反单一职责与单一抽象层级原则;3. 常与长函数共存 | 1. 提取嵌套逻辑为独立函数;2. 条件倒置实现提前返回(如if (!isValid) return;) | SFML网球示例中while (window.isOpen())循环内嵌套事件处理、按键判断、游戏状态切换 |
| 复杂表达式 | 长且多条件的判断表达式(如球与球拍碰撞判断含4个变量比较) | 违反单一抽象层级原则;可读性差、易出错 | 1. 提取中间变量(如ballLeftEdge = ball.getPosition().x - ballRadius);2. 封装为独立函数(如ballHitsLeftPaddle()) | 球与左球拍碰撞判断:原表达式含4个比较条件,修复后拆分为多个中间变量与布尔判断 |
| 缺少const/constexpr | 1. 可标记为const的函数/对象未标记(如返回成员变量的非const函数getDbgFile());2. 可constexpr的常量未标记(如paddleSize用普通变量而非constexpr) | 1. 语义模糊(无法判断是否可修改);2. 易发生意外修改 | 1. 成员函数无修改操作时标记为const;2. 编译期确定的常量用constexpr;3. Jason Turner观点:任何缺少const的情况都是代码异味 | libsass示例中SharedObj类的getDbgFile()函数(仅返回成员变量却非const);ballRadius用普通float而非constexpr |
| 缺失RAII | 手动管理资源(如手动delete传感器、播放器对象,未用智能指针);资源释放逻辑重复(try与catch中重复写传感器断开与删除) | 1. 资源泄漏(如异常导致未执行delete);2. 清理/重置错误;3. 代码冗余 | 1. 使用标准库RAII类(std::unique_ptr、std::lock_guard);2. 自定义类用析构函数自动清理资源;3. 编写RAII包装器 | LeddarSDK示例中手动管理lSensor、lPlayer资源,try与catch中重复资源释放代码 |
| 违反“五法则” | 仅定义“五大函数”(析构、拷贝构造、拷贝赋值、移动构造、移动赋值)中的1个或部分,其余依赖编译器生成 | 编译器生成的函数可能引发意外错误(如浅拷贝导致双重释放) | 需定义其中1个时,其余优先用=default(保留默认实现)或=delete(禁用) | LdCanKomodo类仅定义析构函数与Disconnect(),未处理拷贝/移动,可能导致资源重复释放 |
| 原生循环 | 使用原生for循环遍历(如查找列表中指定索引的操纵杆、拷贝员工数据),未用标准库算法 | 代码冗余、可读性差;未利用C++标准库优化 | 1. 优先用基于范围的for循环;2. 使用<algorithm>库函数(std::find_if、std::copy、std::transform);3. C++20+用ranges(std::views::filter) | 查找操纵杆:原for循环遍历joystickList,修复后用std::find_if;拷贝员工数据:原循环push_back,修复后用std::copy |
三、工具与补充资源
- 推荐工具(检测与修复代码异味):
- 编译器警告:启用
-Wall(所有警告)、-Werror(警告视为错误)、-pedantic(严格遵循标准); - 性能与分析工具:优化器、代码分析器;
- 静态分析工具:
clang-tidy、cppcheck; - 测试工具:
sanitizers(如地址 sanitizer,检测内存问题); - IDE工具:重构工具(如函数提取、变量重命名)。
- 编译器警告:启用
- 补充学习资源:
- Jason Turner:CppCon 2019 演讲 “C++ Code Smells”;
- Kate Gregory:CppCon 2019 演讲 “Naming is Hard: Let’s Do Better”、ACCUConf 2022 演讲 “Abstraction Patterns: Making Code Reliably Better Without Deep Understanding”;
- 网站:https://sourcemaking.com/refactoring/smells(重构与代码异味参考)。
四、核心结论
- 普遍性:代码异味在所有代码库中都存在,文档中的示例代码未必是“坏代码”;
- 非错误属性:代码异味不总是“错误”,部分情况无需立即修复(需结合实际维护需求判断);
- 版本兼容性:即使无法使用C++11及以上版本,也可通过合理设计避免代码异味;
- 核心目标:识别与修复代码异味的最终目的是提升代码可维护性与可读性,而非追求“完美代码”。
4. 关键问题
问题1:在C++中,“长函数”作为常见代码异味,其判断标准并非固定行数,实际开发中如何结合代码逻辑准确识别长函数?修复时需遵循哪些核心原则?
答案:
- 识别方法:无需依赖固定行数,重点从两方面判断:1. 功能集中度:若函数内包含多个独立功能(如同时处理窗口创建、资源加载、逻辑计算),即使行数少(如20行)也可能是长函数;2. 注释特征:函数内存在标记“单一功能”的单行注释块(如“// Create the ball”“// Load font”),说明代码可拆分为独立函数,属于长函数特征。
- 修复核心原则:1. 函数提取优先:将注释块对应的逻辑提取为独立函数,函数名直接沿用注释语义(如
createBall()“loadFont()”),且需明确“复用并非函数提取的唯一目的”,提升可读性是关键;2. 抽象层级一致:提取后的函数需保持与原函数抽象层级一致(如高层级的“初始化游戏”函数,内部调用的应是“创建球拍”“加载音效”等同层级函数,而非直接操作像素坐标);3. 复杂数据封装:若函数内涉及多变量协同的复杂功能(如球拍的尺寸、颜色、位置设置),可将数据与操作封装为类(如Paddle类),替代分散的变量与函数调用。
问题2:文档中提及“缺失RAII”是C++特有的代码异味,其可能导致资源泄漏等严重问题,实际开发中如何正确应用RAII机制?对于已有手动资源管理的旧代码,如何逐步重构以引入RAII?
答案:
- 正确应用RAII的方法:1. 优先使用标准库RAII类:资源管理优先选择C++标准库提供的RAII组件,如用
std::unique_ptr/std::shared_ptr管理动态内存(替代new/delete)、std::lock_guard/std::unique_lock管理互斥锁(替代手动lock()/unlock())、std::fstream管理文件句柄(替代fopen()/fclose());2. 自定义RAII类:对于标准库未覆盖的资源(如硬件设备句柄、网络连接),自定义类时需在构造函数中获取资源(如LdCanKomodo类构造时初始化mHandle),析构函数中释放资源(如析构时调用km_close(mHandle)),且需遵循“五法则”避免浅拷贝问题;3. 禁止手动释放:RAII类封装后,禁止在外部手动调用资源释放接口(如Disconnect()),确保资源释放仅由析构函数触发。 - 旧代码重构步骤:1. 识别资源边界:梳理旧代码中资源的“获取-释放”逻辑(如
lSensor的new与delete、Disconnect()调用),标记所有资源操作点;2. 局部封装:先对独立资源(如单个传感器)创建简单RAII包装器(如SensorWrapper类,构造时new LSensor(),析构时Disconnect()+delete),替换旧代码中的手动管理;3. 消除冗余释放:移除try-catch、函数返回前的重复释放逻辑(如原代码中try与catch均调用lSensor->Disconnect()),依赖RAII类的析构自动释放;4. 扩展到复杂资源:逐步将多个关联资源(如传感器+播放器)封装为聚合RAII类(如DeviceManager),统一管理资源生命周期;5. 测试验证:重构后通过sanitizers(如地址sanitizer)检测资源泄漏,确保RAII机制生效。
问题3:文档强调“代码异味不总是错误”,在实际项目中如何判断某一代码异味(如原生循环、长函数)是否需要修复?修复时需平衡哪些因素?
答案:
- 代码异味修复判断标准:1. 维护频率:若异味代码所在模块是高频修改模块(如业务逻辑层的订单处理函数),即使异味轻微(如20行的长函数)也需修复,避免后续修改时引入错误;若为低频修改的工具类(如仅初始化一次的配置读取函数),短期可暂不修复;2. 风险影响:若异味可能引发严重问题(如“缺失RAII”导致内存泄漏、“违反五法则”导致浅拷贝),无论使用频率均需优先修复;若仅影响可读性(如简单的原生循环遍历),可根据团队优先级安排;3. 理解成本:若新人接手时需超过30分钟理解该段代码(如深层嵌套的条件判断),说明异味已影响团队效率,需修复;4. 修改成本:若修复需大量重构(如将旧C风格代码的原生循环改为
ranges),且当前项目周期紧张,可记录为技术债务,待迭代间隙修复;若修复仅需提取1-2个函数(如20行长函数拆分为2个10行函数),可立即处理。 - 修复平衡因素:1. 可读性与性能:修复时避免为追求“无异味”牺牲性能(如将简单原生循环改为复杂
std::transform_if,但导致编译期变长或运行效率下降),需通过** Profiler 验证性能**,确保修复后性能无显著下降;2. 团队一致性:若团队多数成员不熟悉ranges等高级特性,修复“原生循环”时可先选择std::for_each等易理解的算法,而非直接使用复杂语法;3. 兼容性:若项目需兼容C++11以下版本,无法使用constexpr、智能指针等特性,可通过“伪RAII”(如手动管理但封装为函数)降低异味影响,而非强行使用高版本特性导致兼容性问题;4. 业务优先级:修复代码异味需与业务开发任务平衡,避免因修复异味导致业务上线延迟,可采用“小步修复”策略(如每次修改业务代码时顺带修复周边1-2个异味)。
