基本类型偏执(Primitive Obsession):坏味道识别与重构实战指南

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


1. 开篇场景

你是否遇到过这样的代码:使用 std::string 表示邮箱地址,但没有验证;使用 std::string 表示电话号码,但没有验证;使用 int 表示年龄,但没有范围检查?

1
2
3
4
5
6
7
8
9
void sendEmail(std::string email, std::string subject, std::string body) {
// 使用 string 表示邮箱,但没有验证
std::cout << "Sending email to: " << email << std::endl;
}

void setAge(int age) {
// age 可能是负数或很大的数,但没有范围检查
std::cout << "Age set to: " << age << std::endl;
}

这就是基本类型偏执的典型症状。过度使用基本类型(如 intstringdouble)表示有意义的领域概念,就像用数字”1”和”0”表示”是”和”否”,虽然可以工作,但语义不清晰,容易出错。

当你需要验证邮箱格式时,你必须在每个使用邮箱的地方添加验证逻辑。当你需要修改年龄范围时,你必须在所有使用年龄的地方修改。这种设计使得代码难以维护,容易产生bug。


2. 坏味道定义

基本类型偏执是指过度使用基本类型表示有意义的领域概念,应该使用更有意义的类型。

就像用数字编码表示所有信息,虽然可以工作,但语义不清晰,容易出错。

核心问题:基本类型没有语义,无法表达领域概念。使用有意义的类型可以提高代码的可读性和可维护性,同时可以在类型层面保证数据的有效性。


3. 识别特征

🔍 代码表现:

  • 特征1:使用基本类型表示有意义的领域概念(如用 string 表示邮箱)
  • 特征2:基本类型需要验证,但验证逻辑分散在多个地方
  • 特征3:基本类型有特殊含义,但没有类型层面的保护
  • 特征4:多个地方使用相同的基本类型,但含义不同
  • 特征5:基本类型需要转换或格式化,但逻辑分散

🎯 出现场景:

  • 场景1:快速开发时,使用基本类型简化代码
  • 场景2:从其他语言迁移时,保留了基本类型的习惯
  • 场景3:缺乏领域建模,没有考虑类型设计
  • 场景4:重构不彻底,只修改了部分代码

💡 快速自检:

  • 问自己:这个基本类型是否有特殊含义?
  • 问自己:这个基本类型是否需要验证?
  • 工具提示:使用代码分析工具检测基本类型的使用模式

4. 危害分析

🚨 维护成本:验证逻辑分散在多个地方,时间成本增加50%

⚠️ 缺陷风险:基本类型没有验证,容易产生无效数据,bug风险增加60%

🧱 扩展障碍:添加新验证规则需要在多个地方修改

🤯 认知负担:需要理解基本类型的特殊含义,增加了心理负担


5. 重构实战

步骤1:安全准备

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

步骤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
// 坏味道:使用基本类型表示有意义的领域概念
class BadExample {
public:
// 使用 string 表示邮箱,但没有验证
void sendEmail(std::string email, std::string subject, std::string body) {
std::cout << "Sending email to: " << email << std::endl;
}

// 使用 string 表示电话号码,但没有验证
void callPhone(std::string phoneNumber) {
std::cout << "Calling: " << phoneNumber << std::endl;
}

// 使用 string 表示货币,容易出错
void processPayment(std::string amount, std::string currency) {
std::cout << "Processing payment: " << amount << " " << currency << std::endl;
}

// 使用 int 表示年龄,但没有范围检查
void setAge(int age) {
// age 可能是负数或很大的数
std::cout << "Age set to: " << age << std::endl;
}
};

问题分析

  • std::string 用于表示邮箱、电话号码、货币,但没有验证
  • int 用于表示年龄,但没有范围检查
  • 验证逻辑分散在多个地方,难以维护

重构后(清洁版本)

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
87
88
89
90
91
92
93
94
95
96
97
98
// ✅ 创建有意义的类型
class Email {
private:
std::string value;

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

public:
Email(const std::string& email) {
if (!isValid(email)) {
throw std::invalid_argument("Invalid email address");
}
value = email;
}

std::string getValue() const { return value; }
};

class PhoneNumber {
private:
std::string value;

bool isValid(const std::string& phone) {
for (char c : phone) {
if (!std::isdigit(c) && c != '-') {
return false;
}
}
return phone.length() >= 10;
}

public:
PhoneNumber(const std::string& phone) {
if (!isValid(phone)) {
throw std::invalid_argument("Invalid phone number");
}
value = phone;
}

std::string getValue() const { return value; }
};

class Money {
private:
double amount;
std::string currency;

public:
Money(double amt, const std::string& curr) : amount(amt), currency(curr) {
if (amount < 0) {
throw std::invalid_argument("Amount cannot be negative");
}
}

double getAmount() const { return amount; }
std::string getCurrency() const { return currency; }

std::string toString() const {
return std::to_string(amount) + " " + currency;
}
};

class Age {
private:
int value;

public:
Age(int age) {
if (age < 0 || age > 150) {
throw std::invalid_argument("Invalid age");
}
value = age;
}

int getValue() const { return value; }
};

class GoodExample {
public:
// ✅ 使用有意义的类型
void sendEmail(const Email& email, const std::string& subject, const std::string& body) {
std::cout << "Sending email to: " << email.getValue() << std::endl;
}

void callPhone(const PhoneNumber& phone) {
std::cout << "Calling: " << phone.getValue() << std::endl;
}

void processPayment(const Money& money) {
std::cout << "Processing payment: " << money.toString() << std::endl;
}

void setAge(const Age& age) {
std::cout << "Age set to: " << age.getValue() << std::endl;
}
};

关键变化点

  1. 用对象替换数据值(Replace Data Value with Object)

    • 将基本类型替换为有意义的类型
    • 每个类型都有自己的验证逻辑
  2. 类型安全

    • 类型层面保证数据的有效性
    • 无效数据无法创建对象
  3. 提高可维护性

    • 验证逻辑集中在类型定义中
    • 修改验证规则只需修改类型定义

步骤3:重构技巧总结

使用的重构手法

  • 用对象替换数据值(Replace Data Value with Object):将基本类型替换为有意义的类型
  • 封装字段(Encapsulate Field):将字段封装为私有,通过方法访问

注意事项

  • ⚠️ 确保类型有清晰的语义
  • ⚠️ 如果类型只在局部使用,考虑使用局部类
  • ⚠️ 重构后要更新所有使用处,确保行为一致

6. 预防策略

🛡️ 编码时:

  • 即时检查

    • 基本类型是否有特殊含义?
    • 基本类型是否需要验证?
    • 是否可以创建有意义的类型?
  • 小步提交

    • 发现基本类型偏执时,立即创建有意义的类型
    • 使用”用对象替换数据值”重构,保持类型有意义

🔍 Code Review清单:

  • 重点检查

    • 基本类型是否表示有意义的领域概念?
    • 基本类型是否需要验证?
    • 是否可以创建有意义的类型?
  • 拒绝标准

    • 使用基本类型表示有意义的领域概念
    • 基本类型需要验证但没有类型层面的保护
    • 验证逻辑分散在多个地方

⚙️ 自动化防护:

  • IDE配置

    • 使用代码分析工具检测基本类型的使用模式
    • 启用类型安全警告
  • CI/CD集成

    • 在CI流水线中集成代码分析工具
    • 检测基本类型使用模式,生成警告报告

下一篇预告:重复的switch(Repeated Switches)- 如何用多态替代重复的switch语句