第47章:代码坏味道:基本类型偏执 - 实战以对象取代基本类型
说实话,基本类型偏执这个坏味道,我见过太多次了。我自己早期写代码时也犯过这毛病。说白了,就是咱们太习惯用String、int、boolean这些基本类型来表达一切。电话号码用String存,金额用double算,状态用int表示……嗯,看起来没什么问题,对吧?
但等你项目大了,维护个两三年,你就会发现——这些基本类型根本承载不了业务含义。你想想看,一个String类型的“phoneNumber”,它到底是手机号还是座机号?要不要校验格式?能不能为空?这些信息,基本类型统统告诉不了你。
什么是基本类型偏执
基本类型偏执,指的是过度使用语言内置的基本类型(如String、int、boolean、double等)来表示领域概念,而不是为这些概念创建专门的类型。
举个最简单的例子:
// 坏味道:基本类型偏执
public class Order {
private String orderId; // 订单号
private String status; // 订单状态
private double amount; // 金额
private String currency; // 币种
private String customerId; // 客户ID
private String createTime; // 创建时间
}
这段代码里,所有字段都是基本类型。看起来简洁,但问题一大堆。我在项目中遇到过这样的代码,维护起来简直想哭——你永远不知道status字段里存的是“PAID”还是“paid”还是“1”。
为什么这是个问题
基本类型偏执带来的问题,我总结了几点:
- 缺乏类型安全:编译器无法帮你检查错误。比如你把金额和数量搞混了,编译器不会报错。
- 业务逻辑散落各处:校验、转换、计算的逻辑散落在各个service里,改一处漏三处。
- 可读性差:看到String status,你根本不知道它有哪些合法值。
- 难以扩展:想给订单状态加个“已取消”的关联信息?基本类型做不到。
核心原则:每个领域概念,都应该有一个对应的类型来承载。哪怕它内部只包装了一个String。
实战:以对象取代基本类型
好,咱们直接上实战。假设你有一个用户系统,用户有邮箱、电话号码、地址。传统写法是这样的:
// 重构前:基本类型满天飞
public class User {
private String email;
private String phone;
private String address;
private String addressCity;
private String addressZipCode;
public boolean isValidEmail() {
return email != null && email.contains("@");
}
public boolean isValidPhone() {
return phone != null && phone.matches("\\d{11}");
}
}
你看,校验逻辑散落在User类里。如果其他地方也用到了邮箱校验,还得再写一遍。我曾经在一个项目里看到,同一个邮箱正则校验写了7遍,而且有3个版本不一样……
重构思路很简单:为每个领域概念创建专属类型。
// 重构后:用对象取代基本类型
public class Email {
private final String value;
public Email(String value) {
if (value == null || !value.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + value);
}
this.value = value;
}
public String getValue() {
return value;
}
public String getDomain() {
return value.substring(value.indexOf("@") + 1);
}
@Override
public String toString() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Email email = (Email) o;
return value.equals(email.value);
}
@Override
public int hashCode() {
return value.hashCode();
}
}
public class PhoneNumber {
private final String number;
public PhoneNumber(String number) {
if (number == null || !number.matches("\\d{11}")) {
throw new IllegalArgumentException("Invalid phone number: " + number);
}
this.number = number;
}
public String getNumber() {
return number;
}
public String getMasked() {
return number.substring(0, 3) + "****" + number.substring(7);
}
}
public class Address {
private final String fullAddress;
private final String city;
private final String zipCode;
public Address(String fullAddress, String city, String zipCode) {
// 校验逻辑封装在构造函数中
if (zipCode != null && !zipCode.matches("\\d{6}")) {
throw new IllegalArgumentException("Invalid zip code");
}
this.fullAddress = fullAddress;
this.city = city;
this.zipCode = zipCode;
}
// getters...
}
// 重构后的User类
public class User {
private Email email;
private PhoneNumber phone;
private Address address;
public User(Email email, PhoneNumber phone, Address address) {
this.email = email;
this.phone = phone;
this.address = address;
}
// 不再需要校验方法,因为对象创建时已经校验过了
}
我的习惯:创建值对象时,我通常会把它设计成不可变的(所有字段用final)。这样能避免很多并发问题,也符合函数式编程的思想。你想想看,一个Email对象创建后就不该被修改,对吧?
什么时候该用对象取代基本类型
不是所有基本类型都需要替换。我个人总结了几个判断标准:
| 场景 | 建议 | 例子 |
|---|---|---|
| 有校验逻辑 | 必须封装 | 邮箱、手机号、身份证 |
| 有行为逻辑 | 建议封装 | 金额(加减乘除)、日期(格式化) |
| 有多个相关字段 | 建议封装 | 地址(省市区街道)、坐标(经纬度) |
| 只是简单传递 | 可以保留 | 用户ID、配置项的值 |
避坑指南:我曾经在一个项目里过度封装,把用户ID也包成了对象。结果发现这个对象除了getValue()什么都没做,反而增加了代码复杂度。记住,封装要有业务价值,不要为了封装而封装。
知识体系图
下面这张图展示了基本类型偏执的识别、重构思路和收益:
更多实战场景
除了上面说的邮箱、电话、地址,还有几个常见场景值得封装:
- 金额:封装金额+币种,避免不同币种直接相加。我在金融项目里吃过这个亏,美元和人民币直接相加,算出来的报表简直离谱。
- 时间区间:封装开始时间和结束时间,并提供校验(开始不能晚于结束)。
- 百分比:封装0-100的校验,并提供加减运算。
- 颜色:封装RGB值,提供格式转换(#FF0000 转 RGB(255,0,0))。
// 金额封装示例
public class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
if (amount == null || currency == null) {
throw new IllegalArgumentException("Amount and currency must not be null");
}
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount must be non-negative");
}
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public String format() {
return currency.getSymbol() + amount.setScale(2, RoundingMode.HALF_UP).toString();
}
}
我的建议:刚开始重构时,别想着一步到位。先挑一两个最常用的概念(比如Email、PhoneNumber)做封装,跑通流程。等团队习惯了这种写法,再逐步推广。我见过有人一口气重构了20个类,结果测试全崩了……
好了,基本类型偏执这个坏味道,说白了就是偷懒的代价。你省了写一个类的功夫,后面可能要花十倍的时间去排查bug。用对象取代基本类型,看起来代码多了几行,但换来的是类型安全、逻辑内聚和可维护性。这笔账,怎么算都划算。