4. 封装与抽象:封装全局变量、封装条件判断、用多态取代条件表达式
好,咱们今天聊一个实战中特别常见的问题——代码里的“坏味道”。
你打开一个老项目,满屏的全局变量,if-else 嵌套得像俄罗斯套娃。改一个功能,牵一发动全身。说实话,我早期接手过一个通信协议栈的项目,里面有个全局状态变量,被 20 多个函数直接读写。每次加新功能,我都得先花半天时间梳理这个变量到底在哪些地方被改了。那感觉,就像在雷区里跳舞。
这一章,我们就来解决这类问题。核心思路就四个字:封装与抽象。
4.1 封装全局变量:别让数据裸奔
全局变量的危害,不用我多说了吧?任何地方都能改,出了 bug 你都不知道是谁干的。
我个人习惯是:能用局部变量,绝不用全局变量。如果实在躲不开(比如系统配置、硬件寄存器映射),那就把它封装起来。
核心原则:全局变量必须通过函数接口访问,禁止直接 extern。
来看个反面教材:
// 坏味道:全局变量裸奔
int g_system_mode = 0; // 0: 正常, 1: 调试, 2: 升级
void task_a() {
if (g_system_mode == 1) {
// 调试逻辑
}
}
void task_b() {
g_system_mode = 2; // 直接修改,没人知道
}
这种代码,我见过太多。改起来心惊胆战。怎么封装?用类或者命名空间把它包起来:
// 封装后的版本
class SystemMode {
public:
enum Mode { Normal = 0, Debug, Upgrade };
static Mode get() { return s_mode; }
static void set(Mode mode) {
if (mode != s_mode) {
s_mode = mode;
onModeChanged(); // 可以加回调通知
}
}
private:
static Mode s_mode;
static void onModeChanged() {
// 模式切换时的统一处理
}
};
// 使用
void task_a() {
if (SystemMode::get() == SystemMode::Debug) {
// 调试逻辑
}
}
void task_b() {
SystemMode::set(SystemMode::Upgrade);
}
你看,改动很小。但好处很明显:读写入口统一了。你想加日志、加权限检查、加回调,都只需要改这一个地方。
我的经验:我曾经把一个 30 多个全局变量的模块,全部封装成 Singleton 类。重构完,代码量没增加多少,但 bug 率直接降了一半。因为所有修改都经过同一个门,出了问题,查起来快多了。
4.2 封装条件判断:把“如果”藏起来
条件判断本身不是问题。问题是条件判断散落在代码的各个角落,而且判断逻辑经常重复。
举个例子,你有个业务逻辑:用户是 VIP 并且今天不是节假日,才能享受折扣。于是你在 5 个地方都写了:
if (user.isVIP() && !Calendar::isHoliday()) {
// 折扣逻辑
}
突然有一天,产品说:VIP 在节假日也要打折。你怎么办?满世界找这 5 个 if?漏一个就出 bug。
更好的做法是:把条件判断封装成一个函数。
// 封装条件
bool canGetDiscount(const User& user) {
return user.isVIP(); // 逻辑集中在这里
// 以前是: return user.isVIP() && !Calendar::isHoliday();
}
// 使用
if (canGetDiscount(user)) {
// 折扣逻辑
}
改动就一行。而且你还可以给这个函数加单元测试,确保逻辑正确。
注意:封装条件判断,不是让你把所有 if 都藏起来。而是把业务规则相关的、重复出现的条件判断,提炼成有业务含义的函数名。
我个人习惯给这类函数起名时,用 canXxx、isXxx、shouldXxx 开头。这样读代码的人一眼就知道:哦,这里是在问“能不能做某件事”。
4.3 用多态取代条件表达式:消灭 if-else 链
这是本章的重头戏。也是很多 C++ 开发者从“会用”到“会设计”的分水岭。
你想想看,什么时候你会写一长串 if-else 或者 switch-case?
- 根据类型做不同处理
- 根据状态做不同行为
- 根据策略做不同计算
这些场景,本质上都是:同一件事,有不同的实现方式。多态就是为这个而生的。
来看个典型例子。假设你要处理不同格式的日志:
// 坏味道:if-else 链
void logMessage(const std::string& msg, const std::string& type) {
if (type == "console") {
std::cout << msg << std::endl;
} else if (type == "file") {
// 写入文件
} else if (type == "network") {
// 发送网络
} else {
// 默认处理
}
}
每加一种日志类型,你都得改这个函数。而且这个函数会越来越长,越来越难维护。
用多态重构一下:
// 抽象基类
class Logger {
public:
virtual ~Logger() = default;
virtual void log(const std::string& msg) = 0;
};
// 具体实现
class ConsoleLogger : public Logger {
void log(const std::string& msg) override {
std::cout << msg << std::endl;
}
};
class FileLogger : public Logger {
void log(const std::string& msg) override {
// 写入文件
}
};
class NetworkLogger : public Logger {
void log(const std::string& msg) override {
// 发送网络
}
};
// 使用
std::unique_ptr<Logger> logger = createLogger("file");
logger->log("Hello, world!");
现在,加一种日志类型,只需要新增一个类。原有的代码一行都不用改。这就是开闭原则——对扩展开放,对修改关闭。
关键点:多态不是银弹。如果你的条件判断只有两三个分支,而且不太可能扩展,那用 if-else 完全没问题。多态适用于:分支数量多、经常变化、每个分支逻辑复杂的场景。
我记得有一次重构一个支付模块,里面有 7 种支付方式,每种支付方式的校验、扣款、回调逻辑都不一样。原来的代码是一个 800 行的 switch-case。我用策略模式(多态的一种)重构后,每种支付方式一个类,总共 7 个文件,每个文件 100 行左右。后来加第 8 种支付方式,新同事半天就搞定了。
4.4 本章知识体系
下面这张图,帮你理清封装与抽象的层次关系:
4.5 避坑指南
最后,分享几个我踩过的坑:
- 不要过度封装。我曾经把一个只有两个分支的 if-else 也改成多态,结果代码量翻倍,可读性反而下降了。封装要有度,为未来可能的变化做准备,但不要为永远不会发生的变化做准备。
- 封装全局变量时,注意线程安全。如果你的全局变量在多线程环境下被访问,get/set 接口里要加锁。我早期就吃过这个亏,线上偶发崩溃,查了两天才发现是全局变量读写冲突。
- 多态要配合工厂模式使用。如果你在调用方还是写 if-else 来创建对象,那等于没重构。工厂模式把“创建哪个对象”的逻辑也封装起来,才算完整。
一句话总结:封装是手段,抽象是目标。把变化隔离起来,把稳定暴露出去。代码会感谢你的。