纯数据类(Data Class):坏味道识别与重构实战指南

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


1. 开篇场景

你是否遇到过这样的类:它只有数据字段和简单的getter/setter,所有业务逻辑都在使用这个类的地方,就像一个”空壳”,没有自己的行为?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class BadPerson {
public:
std::string firstName;
std::string lastName;
int age;
std::string email;
};

// 所有逻辑都在使用类中
void processPerson(BadPerson& person) {
std::string fullName = person.firstName + " " + person.lastName;
if (person.age < 0 || person.age > 150) {
// 验证逻辑
}
}

这就是纯数据类的典型症状。类只有数据字段和简单的getter/setter,没有行为,就像一个”空壳”,所有业务逻辑都在使用这个类的地方。

当你需要修改业务逻辑时,你必须在多个使用这个类的地方修改。当你需要添加新功能时,你不知道应该放在哪里。这种设计使得代码变得分散,增加了维护的难度。


2. 坏味道定义

纯数据类是指类只有数据字段和简单的getter/setter,没有行为。

就像一个空壳,没有自己的行为,所有逻辑都在外部。

核心问题:数据和行为应该在一起。如果类只有数据没有行为,应该将相关的行为移到类中,提高内聚性。


3. 识别特征

🔍 代码表现:

  • 特征1:类只有数据字段和getter/setter
  • 特征2:类的业务逻辑在使用类的地方
  • 特征3:类没有验证逻辑
  • 特征4:类没有计算方法
  • 特征5:删除类后,逻辑不受影响

🎯 出现场景:

  • 场景1:快速开发时,只创建数据类
  • 场景2:从其他语言迁移时,保留了数据类的习惯
  • 场景3:缺乏设计,没有考虑行为的位置
  • 场景4:重构不彻底,只创建了数据类

💡 快速自检:

  • 问自己:这个类是否有行为?
  • 问自己:相关的业务逻辑是否应该在这个类中?
  • 工具提示:使用代码分析工具检测纯数据类

4. 危害分析

🚨 维护成本:修改业务逻辑需要在多个地方修改,时间成本增加50%

⚠️ 缺陷风险:业务逻辑分散,bug风险增加40%

🧱 扩展障碍:添加新功能时不知道应该放在哪里

🤯 认知负担:需要理解数据和行为的关系,增加了心理负担


5. 重构实战

步骤1:安全准备

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

步骤2:逐步重构

重构前(问题代码)

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
43
// 坏味道:纯数据类,只有数据没有行为
class BadPerson {
public:
std::string firstName;
std::string lastName;
int age;
std::string email;

BadPerson() : age(0) {}
BadPerson(const std::string& first, const std::string& last,
int a, const std::string& em)
: firstName(first), lastName(last), age(a), email(em) {}
};

// 使用这个类的地方需要处理所有逻辑
class BadExample {
public:
void processPerson(BadPerson& person) {
// 所有逻辑都在使用类中
std::string fullName = person.firstName + " " + person.lastName;

if (person.age < 0 || person.age > 150) {
std::cout << "Invalid age for " << fullName << std::endl;
return;
}

if (person.email.find('@') == std::string::npos) {
std::cout << "Invalid email for " << fullName << std::endl;
return;
}

std::cout << "Processing: " << fullName << ", Age: "
<< person.age << ", Email: " << person.email << std::endl;
}

bool isAdult(BadPerson& person) {
return person.age >= 18;
}

std::string getFullName(BadPerson& person) {
return person.firstName + " " + person.lastName;
}
};

问题分析

  • BadPerson 只有数据字段,没有行为
  • 所有业务逻辑都在 BadExample
  • 数据和行为分离,内聚性低

重构后(清洁版本)

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// ✅ 将行为移到数据类中
class GoodPerson {
private:
std::string firstName;
std::string lastName;
int age;
std::string email;

bool isValidEmail(const std::string& em) const {
return em.find('@') != std::string::npos;
}

bool isValidAge(int a) const {
return a >= 0 && a <= 150;
}

public:
GoodPerson() : age(0) {}

GoodPerson(const std::string& first, const std::string& last,
int a, const std::string& em)
: firstName(first), lastName(last), age(a), email(em) {
if (!isValidAge(a)) {
throw std::invalid_argument("Invalid age");
}
if (!isValidEmail(em)) {
throw std::invalid_argument("Invalid email");
}
}

// ✅ 行为方法
std::string getFullName() const {
return firstName + " " + lastName;
}

bool isAdult() const {
return age >= 18;
}

bool isValid() const {
return isValidAge(age) && isValidEmail(email) &&
!firstName.empty() && !lastName.empty();
}

void display() const {
std::cout << "Name: " << getFullName() << ", Age: "
<< age << ", Email: " << email << std::endl;
if (isAdult()) {
std::cout << "This person is an adult." << std::endl;
}
}

// Getters
std::string getFirstName() const { return firstName; }
std::string getLastName() const { return lastName; }
int getAge() const { return age; }
std::string getEmail() const { return email; }

// Setters with validation
void setAge(int a) {
if (!isValidAge(a)) {
throw std::invalid_argument("Invalid age");
}
age = a;
}

void setEmail(const std::string& em) {
if (!isValidEmail(em)) {
throw std::invalid_argument("Invalid email");
}
email = em;
}
};

class GoodExample {
public:
// ✅ 逻辑在 Person 类中,这里只需要调用
void processPerson(GoodPerson& person) {
if (!person.isValid()) {
std::cout << "Invalid person data" << std::endl;
return;
}

person.display();
}
};

关键变化点

  1. 移动方法(Move Method)

    • 将业务逻辑移到 Person 类中
    • 数据和行为在一起
  2. 添加验证

    • 在构造函数和setter中添加验证
    • 确保数据的一致性
  3. 提高内聚性

    • 数据和行为在一起
    • 类的职责更清晰

步骤3:重构技巧总结

使用的重构手法

  • 移动方法(Move Method):将业务逻辑移到数据类中
  • 封装字段(Encapsulate Field):将字段封装为私有,通过方法访问

注意事项

  • ⚠️ 确保行为确实属于数据类
  • ⚠️ 如果行为涉及多个类,考虑使用服务类
  • ⚠️ 重构后要更新所有使用处,确保行为一致

6. 预防策略

🛡️ 编码时:

  • 即时检查

    • 类是否有行为?
    • 相关的业务逻辑是否应该在这个类中?
    • 使用IDE的代码分析工具,检测纯数据类
  • 小步提交

    • 发现纯数据类时,立即添加行为
    • 使用”移动方法”重构,保持数据和行为在一起

🔍 Code Review清单:

  • 重点检查

    • 是否有纯数据类?
    • 相关的业务逻辑是否应该在这个类中?
    • 是否可以添加行为方法?
  • 拒绝标准

    • 只有数据字段和getter/setter的类
    • 业务逻辑在使用类的地方
    • 类没有验证逻辑

⚙️ 自动化防护:

  • IDE配置

    • 使用代码分析工具检测纯数据类
    • 启用数据类警告
  • CI/CD集成

    • 在CI流水线中集成代码分析工具
    • 检测纯数据类,生成警告报告

下一篇预告:被拒绝的遗赠(Refused Bequest)- 如何解决子类拒绝继承的问题