27、重构技巧:大型类拆分,接口提取,依赖反转,代码异味检测
说实话,做了十几年C++,我见过最让人头疼的代码,不是算法写得烂,而是一个类塞了三千行,什么活都干。你想想看,一个类既管数据库连接,又管界面渲染,还兼职做日志——这哪是类,分明是个“上帝对象”。
今天我们就来聊聊怎么给这种“巨无霸”类做减脂手术。核心就四个字:拆、提、反、检。
核心四步法:
- 拆(大型类拆分)—— 把上帝类拆成多个单一职责的小类
- 提(接口提取)—— 把公共行为抽象成接口或抽象类
- 反(依赖反转)—— 让高层模块不依赖低层细节
- 检(代码异味检测)—— 用工具和直觉发现坏味道
一、大型类拆分:别让一个类干所有事
我接手过一个遗留项目,有个类叫 SystemManager,足足 4500 行。它管配置、管网络、管数据库、管日志、还管用户权限。每次改需求,光找到改哪里就要花半小时。
怎么拆?我一般遵循三步:
- 职责清单:把类里所有方法列出来,按功能分组
- 找边界:哪些方法操作同一组数据?哪些方法调用同一批外部资源?
- 提取新类:每组独立职责,提取成一个新类
我的经验:拆类时别追求一步到位。先拆出最明显的“数据管理”和“业务逻辑”两部分,跑通测试再继续拆。我曾经一次拆出 8 个类,结果编译过了,逻辑全乱了——教训啊。
// 重构前:上帝类
class SystemManager {
public:
void loadConfig(const std::string& path);
void saveConfig();
void connectDatabase(const std::string& connStr);
void executeQuery(const std::string& sql);
void logInfo(const std::string& msg);
void logError(const std::string& msg);
void addUser(const std::string& name, const std::string& role);
void removeUser(const std::string& name);
// ... 还有 30 多个方法
};
// 重构后:拆成三个类
class ConfigManager {
public:
void load(const std::string& path);
void save();
};
class DatabaseManager {
public:
void connect(const std::string& connStr);
void execute(const std::string& sql);
};
class UserManager {
public:
void addUser(const std::string& name, const std::string& role);
void removeUser(const std::string& name);
};
二、接口提取:面向接口编程,别面向实现
拆完类之后,下一步就是提取接口。说白了,就是让调用方只依赖“你能干什么”,而不依赖“你怎么干的”。
我个人习惯用纯虚类做接口。C++ 里没有 Java 的 interface 关键字,但我们可以用只有纯虚函数的类来模拟。
// 定义接口
class IStorage {
public:
virtual ~IStorage() = default;
virtual void save(const std::string& key, const std::string& value) = 0;
virtual std::string load(const std::string& key) = 0;
};
// 实现1:文件存储
class FileStorage : public IStorage {
public:
void save(const std::string& key, const std::string& value) override {
// 写入文件
}
std::string load(const std::string& key) override {
// 从文件读取
return {};
}
};
// 实现2:数据库存储
class DatabaseStorage : public IStorage {
public:
void save(const std::string& key, const std::string& value) override {
// 写入数据库
}
std::string load(const std::string& key) override {
// 从数据库查询
return {};
}
};
这样做的好处是什么?调用方只需要 IStorage* 指针,具体用文件还是数据库,由外部注入。哪天想从文件切到数据库,一行代码都不用改调用方。
注意:接口提取不是越多越好。我见过有人每个类都配一个接口,结果接口数量比实现类还多,维护成本反而上升。接口只提取那些“可能有多种实现”的行为。
三、依赖反转:让高层模块说了算
依赖反转原则(DIP)是 SOLID 里的“D”。它的核心思想:高层模块不应该依赖低层模块,两者都应该依赖抽象。
嗯,这句话有点绕。我换个说法:你的业务逻辑代码,不应该直接 new 一个具体的数据库对象或文件对象。应该通过接口来操作。
举个例子,一个订单处理系统:
// 违反 DIP:高层直接依赖低层
class OrderService {
FileStorage storage_; // 直接依赖具体实现
public:
void processOrder(const Order& order) {
storage_.save("order_" + order.id, order.toJson());
}
};
// 符合 DIP:依赖抽象
class OrderService {
IStorage& storage_; // 依赖接口
public:
explicit OrderService(IStorage& storage) : storage_(storage) {}
void processOrder(const Order& order) {
storage_.save("order_" + order.id, order.toJson());
}
};
看到区别了吗?第二个版本通过构造函数注入接口,OrderService 不再关心底层是文件还是数据库。这就是依赖反转——控制权反转给了调用者。
避坑指南:我曾经在一个项目里用依赖反转玩过了头,把每个小工具类都搞成接口+注入,结果一个简单的配置读取,要经过 5 层抽象才能找到实际代码。记住:依赖反转是为了解耦,不是为了炫技。
四、代码异味检测:用鼻子闻,用工具查
代码异味不是 bug,但它是 bug 的温床。我总结了几种最常见的 C++ 代码异味:
| 异味类型 | 表现 | 检测方法 | 推荐工具 |
|---|---|---|---|
| 长函数 | 一个函数超过 50 行 | 代码审查 + 静态分析 | cppcheck, clang-tidy |
| 上帝类 | 一个类超过 500 行 | 代码度量工具 | SonarQube, Understand |
| 重复代码 | 相同逻辑出现 3 次以上 | 重复代码检测 | PMD CPD, Simian |
| 过度耦合 | 一个类依赖超过 10 个其他类 | 依赖图分析 | Doxygen + Graphviz |
| 霰弹式修改 | 改一个需求要改 5 个以上文件 | 版本历史分析 | git log + 脚本 |
我个人最常用的组合是 clang-tidy + SonarQube。clang-tidy 跑本地,SonarQube 做持续集成。每次提交代码,SonarQube 自动扫描,如果代码异味超标,CI 直接失败。
我的检测清单(每次重构前必查):
- 是否有超过 100 行的函数?—— 拆
- 是否有超过 500 行的类?—— 拆
- 是否有 switch-case 超过 5 个分支?—— 考虑多态
- 是否有大量重复的 if-else?—— 考虑策略模式
- 是否有全局变量被多处修改?—— 封装
五、实战:一个完整的重构案例
最后,我拿一个真实案例串一下今天的内容。假设我们有一个 ReportGenerator 类,它负责生成报表、保存到文件、发送邮件。典型的“什么都干”。
// 重构前:上帝类 + 紧耦合
class ReportGenerator {
public:
void generateAndSend(const std::string& data) {
// 1. 生成报表
std::string report = "Report: " + data;
// 2. 保存到文件
std::ofstream file("report.txt");
file << report;
// 3. 发送邮件
system(("echo '" + report + "' | mail -s 'Report' admin@example.com").c_str());
}
};
这个类的问题很明显:生成、存储、发送全耦合在一起。测试时你不想真的发邮件吧?
重构步骤:
- 提取接口:定义
IReportSaver和IReportSender - 拆分职责:生成逻辑单独一个类
- 依赖反转:通过构造函数注入 saver 和 sender
// 接口
class IReportSaver {
public:
virtual ~IReportSaver() = default;
virtual void save(const std::string& report) = 0;
};
class IReportSender {
public:
virtual ~IReportSender() = default;
virtual void send(const std::string& report) = 0;
};
// 实现
class FileSaver : public IReportSaver {
public:
void save(const std::string& report) override {
std::ofstream file("report.txt");
file << report;
}
};
class EmailSender : public IReportSender {
public:
void send(const std::string& report) override {
// 实际发邮件逻辑
}
};
// 重构后的核心类
class ReportGenerator {
IReportSaver& saver_;
IReportSender& sender_;
public:
ReportGenerator(IReportSaver& saver, IReportSender& sender)
: saver_(saver), sender_(sender) {}
void generateAndSend(const std::string& data) {
std::string report = "Report: " + data;
saver_.save(report);
sender_.send(report);
}
};
现在测试就简单了。我可以写一个 MockSaver 和一个 MockSender,只验证生成逻辑是否正确,不用真的写文件或发邮件。
最后说一句:重构不是一蹴而就的。我习惯每次改 bug 或加小功能时,顺手做一点重构。比如看到一个长函数,就拆出一个小函数。日积月累,代码质量自然就上去了。
公众号:蓝海资料掘金营,微信deep3321