案例
最近在走读某一老产品的代码,发现存在一个普遍不好的实践,代码类似如下:
public class Class1 {
private Map<String, String> store = new HashMap<>();
private List<Class2> queue = new ArrayList<>();
//... 省略其它的字段与其Getter/Setter方法
}
此类的特点是:只有一些集合字段与其Getter/Setter,而对字段的使用却是如下:
public void method1() {
// ... 省略其它逻辑
// 在其它的类中的方法实现中,却通过Getter方法获取集合对象加锁来处理
synchronized(class1.getStore()) {
String value = class1.getStore().get(key);
if (value == null) {
// ... 省略其它逻辑
value = createValue();
class1.getStore().put(key, value);
}
}
// ... 省略其它逻辑
}
代码的问题是很明显:
- Class1中的成员直接被Get出去,散落在各个类中操作,缺少对其操作的方法封装,破坏了类的封装性,带来了数据的耦合。
- 同步加锁在Owner对象之外,其出发点是以其它方法逻辑为切入,而不是从Owner对象的数据全生命周期安全来思考,很容易造成加锁不全。
背后的知识
在面向对象设计中,会提到如下特征:
- 封装性
- 抽象性
- 继承性
- 多态性
封装性,顾名思义,对用户隐藏其实现细节,使用该类的用户可以通过该类提供的接口来访问该类,使用户不能轻易的操作此数据结构,只能执行类允许公开的数据的一个小系统。
抽象性,就是找出一些事物的相似和共性之处,然后将这些事物归为一个类,这个类只考虑这些事物的相似和共性之处,并且会忽略与当前主题和目标无关的那些方面,将注意力集中在与当前目标有关的方面。
显然,案例中类设计违背了封装性,抽象也不够,只是一堆的数据集合,数据之间并没有共性,要说共性这个类的责职就是用来存储数据的。只不过这它承担的责职也太少太少。
在领域驱动设计中对领域模型提到如下概念:
- 失血模型:模型仅仅包含数据的定义和getter/setter方法,业务逻辑和应用逻辑都放到服务层中。
- 贫血模型:贫血模型中包含了一些业务逻辑,但不包含依赖持久层的业务逻辑。这部分依赖于持久层的业务逻辑将会放到服务层中。
- 充血模型:充血模型中包含了所有的业务逻辑,包括依赖于持久层的业务逻辑。
- 胀血模型:胀血模型就是把和业务逻辑不想关的其他应用逻辑(如授权、事务等)都放到领域模型中。
显然,案例中的代码是失血模型的类设计。
上述案例的中代码,可反映出当时写代码的思考方式:以面向过程自顶向下的方式来解决业务流程问题,数据只是流程中的附属品。而不是以面向领域模型的设计方式,系统中存在哪些概念模型,模型以什么方式来提供能力(接口/方法),再把这此能力组织串起来,形成业务流程。
数据类
在现实开发中,的确并不是所有类都要有业务逻辑,有一种类只是数据的容器,用于聚合数据,这种类通常称为数据类(data class)。在JVM体系中,像Scala与Kotlin在语言层面都存在这种显式概念:
- Scala: 叫case class,其特性就是支持类的模式匹配,应用于数据的解构使用场景,定义如下
case class User(name: String, age: Int)
- Kotlin:叫data class,与JavaBean相似,提供原生关键字,定义如下:
data class User(val name: String, val age: Int)
- Java:在Java14中也坐不住了,引用叫记录类(record)的概念,定义如下:
public record User(String name, int age) {}
各种语言概念略有差别,但都是数据类,他们共同的特点:
- 都会自动生成 equals、hashCode 和 toString 方法
- 都会自动生成 Getter 方法
- 强调数据的不变性,字段在构建方法中声明,Scala 与 Kotlin 可以通过指定val来标识数据只读,record类的字段隐式都是final的
- Kotlin与Java的数据类不可继承
从Java的命名Record(也是抄C#的概念),更能看见对数据类的使用场景:
- 是数据记录的承载,可用数据库表结构字段的映射,如JPA/MyBatis中Entity类定义。
- 是数据格式的描述,可用于网络消息包的定义,如Rest接口请求/响应消息的结构体定义。
这种类对数据的使用,一个重要的特点是:
- 数据一旦生成,它们是不可变的,只能只读。
- 类对数据透明持有,不理解数据,不需要有对数据的行为。
看完上面说明,相信大家也清楚了上述案例中的Class1并不满足数据类的要求,因为外部对数据进行了修改。
再说封装
封装是面向对象设计中的重要特征,其目标就是要实现软件部件的“高内聚、低耦合”,防止相互依赖性而带来的变动影响。封装具有黑盒性质,使得用户不用关注其内部细节,从而保证软件具有优良模块性基础。
像Java是完全面向对象的编程语言,面向对象的封装比传统语言(如C的struct)的封装更为清晰、更为有力。封装就是把描述一个对象的属性和行为的代码封装在一个“模块”中,也就是一个类中,属性用变量定义,行为用方法进行定义,方法可以直接访问同一个对象中的属性。通常情况下,让变量和访问这个变量的方法放在一起,将一个类中的成员变量全部定义成私有的,只有这个类自己的方法才可以访问到这些成员变量,这就基本上实现对象的封装。
封装设计原则:把对同一事物进行操作的方法和相关的方法放在同一个类中,把方法和它操作的数据放在同一个类中。对象封装成一个高度自治和相对封闭的个体,对象状态(属性)由这个对象自己的行为(方法)来读取和改变。
封装带来好处也是显而易见的:
- 代码复用:提供对行为方法的打包,调用者在使用的时候,只需调用方法即可,提升代码的易用性。
- 信息隐蔽:将其成员通过Private隐蔽起来,对成员访问权限的合理控制,提高了数据的安全性。
- 封装变化:将对其状态的改变控制在封闭的范围,后续修改调整在可见范围内,提升了代码的可维护性。
我们再回到案例中的代码,所有散落在外部对Class1的数据操作,应该提供相应的行为方法,把对其的改变控制在自己相对封闭的范围内部。
当一个类即有行为方法,又提供某一些字段的Getter方法时,一定要考虑Getter是其数据的Copy以及必要性,把改变控制在自己手上。
结语
在面向对象编程语言中,类是最为基础的单元,设计一个类,最先要考虑是它的封装性。把对一事物操作的方法和它操作的数据放在同一个类中,将其成员通过Private隐蔽起来,对外提供相应的方法,对其成员访问权限的合理控制,并把外部引起的变化都能控制在本类的方法中。以面向对象的思维来设计模块,通过类的行为方法来组织实现业务流程,避免直接对数据的耦合,这样我们不仅可以提升代码易用性,安全性与可维护性。