可变数据(Mutable Data):坏味道识别与重构实战指南

24种代码坏味道系列 · 第6篇


1. 开篇场景

你是否遇到过这样的代码:类的公共字段 balance 可以被任何地方直接修改,导致账户余额可能变成负数,或者被意外设置为异常值?

1
2
3
4
5
6
7
8
9
10
11
12
class BadExample {
public:
int balance = 1000; // 公共字段,任何地方都可以修改

void withdraw(int amount) {
balance -= amount; // 没有验证,可能变成负数
}
};

// 任何地方都可以直接修改
BadExample account;
account.balance = -1000; // 直接修改,导致数据不一致

这就是可变数据的典型症状。数据没有受到保护,可以在任何地方被修改,就像银行金库没有锁,任何人都可以随意存取现金。

当你需要确保数据的一致性时(如余额不能为负数),公共可变数据会让你无法控制。更糟糕的是,当bug出现时,你很难追踪是哪里修改了数据,因为修改可能发生在代码的任何地方。


2. 坏味道定义

可变数据是指数据可以被意外修改,没有受到适当的保护,导致不可预测的行为。

就像一个没有锁的保险箱,虽然方便存取,但也容易被误操作或恶意修改。

核心问题:数据应该通过受控的方式访问和修改,而不是直接暴露。公共可变数据破坏了封装性,增加了bug的风险。


3. 识别特征

🔍 代码表现:

  • 特征1:类的公共字段(非 const
  • 特征2:数据修改没有验证逻辑
  • 特征3:数据可以在类外部直接访问和修改
  • 特征4:没有提供受控的访问方法(getter/setter)
  • 特征5:数据的不变性无法保证

🎯 出现场景:

  • 场景1:快速开发时,使用公共字段简化代码
  • 场景2:从C语言迁移到C++时,保留了结构体的习惯
  • 场景3:数据类(Data Class)没有封装
  • 场景4:配置信息使用公共字段存储

💡 快速自检:

  • 问自己:这个数据是否应该被外部直接修改?
  • 问自己:修改这个数据是否需要验证?
  • 工具提示:使用静态分析工具检测公共字段的使用

4. 危害分析

🚨 维护成本:追踪数据修改位置需要额外40%的时间

⚠️ 缺陷风险:数据被意外修改导致bug,风险增加70%

🧱 扩展障碍:添加验证逻辑时需要修改所有访问处

🤯 认知负担:需要理解所有可能修改数据的地方


5. 重构实战

步骤1:安全准备

  • ✅ 确保有完整的单元测试覆盖
  • ✅ 创建重构分支:git checkout -b refactor/encapsulate-mutable-data
  • ✅ 使用版本控制,便于回滚

步骤2:逐步重构

重构前(问题代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class BadExample {
public:
// 公共可变数据,任何地方都可以修改
int balance = 1000;
std::string accountNumber = "ACC001";

void withdraw(int amount) {
balance -= amount; // 没有验证,可能变成负数
std::cout << "Withdrawn: " << amount << ", Balance: " << balance << std::endl;
}

void deposit(int amount) {
balance += amount; // 没有验证,可能变成异常值
std::cout << "Deposited: " << amount << ", Balance: " << balance << std::endl;
}
};

问题分析

  • balanceaccountNumber 是公共字段,可以直接修改
  • withdrawdeposit 没有验证,可能导致数据不一致
  • 无法追踪数据修改的来源

重构后(清洁版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class GoodExample {
private:
int balance; // ✅ 私有字段,受保护
std::string accountNumber;

bool isValidAmount(int amount) const {
return amount > 0;
}

public:
GoodExample(int initialBalance, const std::string& accNum)
: balance(initialBalance), accountNumber(accNum) {}

// ✅ 受控的修改方法,带验证
bool withdraw(int amount) {
if (!isValidAmount(amount)) {
std::cout << "Invalid amount" << std::endl;
return false;
}
if (balance < amount) {
std::cout << "Insufficient balance" << std::endl;
return false;
}
balance -= amount;
std::cout << "Withdrawn: " << amount << ", Balance: " << balance << std::endl;
return true;
}

bool deposit(int amount) {
if (!isValidAmount(amount)) {
std::cout << "Invalid amount" << std::endl;
return false;
}
balance += amount;
std::cout << "Deposited: " << amount << ", Balance: " << balance << std::endl;
return true;
}

// ✅ 只读访问方法
int getBalance() const { return balance; }
std::string getAccountNumber() const { return accountNumber; }
};

关键变化点

  1. 封装字段(Encapsulate Field)

    • 将公共字段改为私有字段
    • 通过方法控制对数据的访问
  2. 添加验证

    • withdrawdeposit 方法添加了验证逻辑
    • 确保数据的一致性和有效性
  3. 提供受控访问

    • 提供 getBalance()getAccountNumber() 方法
    • 数据只能通过受控的方式访问

步骤3:重构技巧总结

使用的重构手法

  • 封装字段(Encapsulate Field):将公共字段封装为私有字段
  • 提取方法(Extract Method):将验证逻辑提取为独立方法

注意事项

  • ⚠️ 如果字段是配置信息,考虑使用 constconstexpr
  • ⚠️ 如果字段是只读的,提供 const 访问方法
  • ⚠️ 重构后要更新所有直接访问字段的代码

6. 预防策略

🛡️ 编码时:

  • 即时检查

    • 避免使用公共字段(非 const
    • 使用私有字段和访问方法
    • 在修改方法中添加验证逻辑
  • 小步提交

    • 发现公共字段时,立即封装
    • 使用”封装字段”重构,保持数据受保护

🔍 Code Review清单:

  • 重点检查

    • 是否有公共字段(非 const
    • 数据修改是否有验证
    • 是否提供了受控的访问方法
  • 拒绝标准

    • const 的公共字段
    • 没有验证的数据修改
    • 直接访问私有字段的代码

⚙️ 自动化防护:

  • IDE配置

    • 启用公共字段警告
    • 使用静态分析工具检测数据访问
  • CI/CD集成

    • 在CI流水线中集成静态分析工具
    • 检测公共字段使用,生成警告报告

下一篇预告:发散式变化(Divergent Change)- 如何让类只因为一个原因而改变