第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()什么都没做,反而增加了代码复杂度。记住,封装要有业务价值,不要为了封装而封装。

知识体系图

下面这张图展示了基本类型偏执的识别、重构思路和收益:

以对象取代基本类型 - 知识体系 识别坏味道 基本类型偏执 问题表现 校验散落 · 类型不安全 重构目标 领域对象封装 步骤一 识别领域概念 Email、Phone、Address 步骤二 创建值对象 封装校验 + 行为 步骤三 替换使用处 User类中替换字段类型 类型安全 编译器帮你检查 逻辑内聚 校验行为集中管理 可扩展性 加功能不改老代码 核心原则:每个领域概念,一个专属类型

更多实战场景

除了上面说的邮箱、电话、地址,还有几个常见场景值得封装:

  • 金额:封装金额+币种,避免不同币种直接相加。我在金融项目里吃过这个亏,美元和人民币直接相加,算出来的报表简直离谱。
  • 时间区间:封装开始时间和结束时间,并提供校验(开始不能晚于结束)。
  • 百分比:封装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。用对象取代基本类型,看起来代码多了几行,但换来的是类型安全、逻辑内聚和可维护性。这笔账,怎么算都划算。


公众号:蓝海资料掘金营,微信 deep3321