8、封装(上):封装记录、封装集合、以对象取代基本类型
封装这件事,说白了就是「藏好不该让别人看到的东西」。我刚开始写代码那几年,总觉得封装就是加个 private 关键字,后来被坑了几次才明白——封装远不止这么简单。
今天咱们聊三个最常见的封装场景:封装记录、封装集合、还有用对象取代基本类型。这三个东西,几乎每天都在跟它们打交道。
8.1 封装记录(Encapsulate Record)
先说说记录。记录是什么?就是那种 key-value 结构的数据。在 JavaScript 里是对象,在 Java 里可能是 Map,在 C# 里可能是 Dictionary。
很多人喜欢直接拿 Map 或者 JSON 对象到处传。你想想看,一个订单数据,里面有用户信息、商品列表、金额……全塞在一个 Map 里。代码里到处都是 map.get("userName")、map.get("totalPrice")。这样写,其实就是在给自己埋坑。
为什么?因为这种写法把数据的结构暴露给了所有调用方。哪天字段名改了,你得满世界找调用处。更麻烦的是,你没法控制别人怎么用这些数据。
核心原则:把记录封装成类,对外只暴露行为,不暴露数据结构。
来看个例子。假设我们有个用户记录:
// 不好的写法:直接暴露数据结构
const user = {
firstName: "张",
lastName: "三",
email: "zhangsan@example.com"
};
function getUserFullName(user) {
return user.firstName + user.lastName;
}
这样写有什么问题?你想想看,如果哪天需求变了,要把 firstName 和 lastName 合并成一个 fullName 字段,所有用到 user.firstName 的地方都得改。
封装之后是这样的:
class User {
constructor(data) {
this._firstName = data.firstName;
this._lastName = data.lastName;
this._email = data.email;
}
get fullName() {
return this._firstName + this._lastName;
}
get email() {
return this._email;
}
}
// 调用方只关心行为,不关心数据结构
const user = new User(userData);
console.log(user.fullName);
我在项目中遇到过类似的情况。有一次重构一个老系统,里面有个订单记录用了三层嵌套的 Map。改一个字段名,牵扯了十几个文件。后来我花了两天时间,把所有 Map 访问都封装成了类方法。从那以后,改数据结构再也不用提心吊胆了。
小技巧:封装记录时,不要一开始就想着把所有字段都暴露 getter/setter。先看看调用方真正需要什么行为,按需暴露。
8.2 封装集合(Encapsulate Collection)
集合的封装,是很多人容易忽略的点。我见过不少代码,类里面有个 List 或者数组,直接暴露 getter 返回引用。调用方拿到引用后,想怎么改就怎么改。
这有什么问题?你想想看,你辛辛苦苦维护的业务规则,别人一个 list.add() 就绕过去了。更可怕的是,你根本不知道谁在什么时候改了集合的内容。
// 不好的写法:直接暴露集合引用
class Order {
constructor() {
this._items = [];
}
get items() {
return this._items; // 直接返回引用
}
}
// 调用方可以随意修改
const order = new Order();
order.items.push({ name: "商品A", price: 100 }); // 绕过了所有校验
正确的做法是:永远不要直接暴露集合的引用。要么返回只读视图,要么返回副本。
class Order {
constructor() {
this._items = [];
}
get items() {
return this._items.slice(); // 返回副本
}
addItem(item) {
// 在这里可以做校验、触发事件等
this._items.push(item);
}
removeItem(itemId) {
// 同样,在这里控制删除逻辑
this._items = this._items.filter(item => item.id !== itemId);
}
get itemCount() {
return this._items.length;
}
}
我曾经在一个项目中吃过这个亏。有个购物车类,items 集合直接暴露了 getter。结果有个同事在业务代码里直接调用了 cart.items.clear(),导致购物车清空时没有触发价格重算。排查了半天才发现问题。
避坑指南:我曾经以为返回 Collections.unmodifiableList() 就万事大吉了。但要注意,这只保证了集合本身不可修改,集合里的对象还是可以改的。如果需要深度不可变,得做深拷贝或者用不可变对象。
8.3 以对象取代基本类型(Replace Primitive with Object)
这个重构手法,说白了就是「别用字符串和数字表示一切」。我见过太多代码,用 String 表示电话号码、用 int 表示金额、用 String 表示状态……
为什么不好?因为基本类型没有业务语义。你看到一个 String,不知道它是不是合法的电话号码。你看到一个 int,不知道它是不是金额,单位是什么。
来看个例子:
// 不好的写法:用基本类型表示业务概念
class Order {
constructor(price, quantity) {
this._price = price; // 这是美元还是人民币?含不含税?
this._quantity = quantity; // 单位是什么?个?箱?
}
}
封装成对象之后:
class Money {
constructor(amount, currency) {
this._amount = amount;
this._currency = currency;
}
get amount() { return this._amount; }
get currency() { return this._currency; }
add(other) {
if (this._currency !== other._currency) {
throw new Error("货币单位不一致");
}
return new Money(this._amount + other._amount, this._currency);
}
toString() {
return `${this._currency} ${this._amount.toFixed(2)}`;
}
}
class Order {
constructor(price, quantity) {
this._price = price; // 现在是 Money 对象
this._quantity = quantity; // 还是基本类型?也可以封装
}
get totalPrice() {
return this._price.multiply(this._quantity);
}
}
这样做的好处很明显:类型安全、语义清晰、行为内聚。你没法把一个字符串传给需要 Money 的地方。而且 Money 对象自己知道怎么加减、怎么格式化。
我记得有一次重构一个支付系统,里面到处都是 amount 字段,有的是分,有的是元,有的是美元。后来我把所有金额都换成了 Money 对象,加上了货币单位和精度校验。嗯,从那以后再也没有出现过「把分当成元」的 bug。
什么时候该用?当你发现一个基本类型在代码里频繁出现,而且总是伴随着相同的校验逻辑或转换逻辑时,就该考虑封装成对象了。比如:电话号码、邮箱、金额、日期范围、状态码。
8.4 本章知识体系
下面这张图总结了本章的三个核心知识点,以及它们之间的关系:
这三个手法,本质上都是在做同一件事:把数据和行为绑在一起,对外隐藏实现细节。封装记录是藏数据结构,封装集合是藏集合操作,以对象取代基本类型是藏类型校验和转换逻辑。
嗯,今天就聊到这儿。这三个手法看起来简单,但用好了,代码质量能提升一大截。下次写代码的时候,多问自己一句:「这个数据真的应该这样暴露吗?」