学习和理解单一职责,一个比较好的办法,是去看一个真正按这个原则设计的类。比如JDK 17里的java.time.LocalDate。
第一次接触LocalDate时,很容易会认为,既然它负责表示日期,那日期格式化是不是也应该由它来做?但实际上,LocalDate根本不负责格式化。
LocalDate是一个public final class,核心字段只有year、month、day三个字段,而且都是final。至于日期应该怎么显示,则交给了另一个类DateTimeFormatter。
为什么要这么干?
因为日期是什么 和日期怎么显示,本来就是两件不同的事。
这其实就是单一职责原则:一个类应该只有一个引起它变化的原因。
LocalDate关心的是日期本身,比如是哪一年、哪一月、哪一天,以及日期之间怎么计算。这些东西和日历规则有关,通常不会频繁变化。
DateTimeFormatter关心的则是怎么把这个日期展示出来,它根据需求不断的变化,但是LocalDate不需要跟着改。
这就是单一职责真正解决的问题:让变化发生在该发生的地方。
如果把格式化也塞进LocalDate,以后每增加一种显示方式,都要修改这个类。而拆开之后,日期计算和日期展示各自维护,改一边不会影响另一边。
看起来是LocalDate在格式化,其实不是
看格式化的实际写法:
java
// 日期对象和格式规则是两个对象
LocalDate date = LocalDate.now();
String text = date.format(DateTimeFormatter.ofPattern("M月d日"));
表面上看,调用的是LocalDate的format()方法,好像格式化这件事是LocalDate自己负责的。
但翻一下它的实现就知道,format()本身并不做格式化。它只是把LocalDate和DateTimeFormatter交给后者处理,真正按照格式规则拼出字符串的,是DateTimeFormatter。
所以这里其实是两个对象各管一件事:
- LocalDate:保存日期,并负责日期相关的计算。
- DateTimeFormatter:负责把日期转换成指定格式的字符串。
LocalDate提供format(),只是把格式化这个操作交给DateTimeFormatter,自己并不处理具体的格式化逻辑。
这就是单一职责在实际代码里的样子:一个类负责自己的事情,另一件事交给专门的类。
读到这里,你可能还是有疑问,不应该提供format()方法呀,其实不是这么理解的,这个从API的易用性角度来考虑的,它就是一种语法糖,底层依然严格遵守了单一职责的。
老版本Date的教训
java.util.Date是跟着JDK 1.0一起出现的,最早的设计比较简单,但随着时间API不断增加,它慢慢承担了越来越多的事情:表示日期、修改日期、处理时区,还提供toLocaleString()这种直接返回格式化字符串的方法。
问题也就慢慢出来了。
日期本身怎么表示,和日期怎么转换、怎么显示,本来就是不同的变化方向。它们全放在Date里,时间一长,这个类自然会越来越难维护。
所以今天再看Date的源码,会发现不少方法都已经标记了@Deprecated。像getYear()需要自己加上1900,月份从0开始计算,很多接口的语义也已经不符合现在的使用方式。
后来JDK引入了java.time,把日期、时间、时区、格式化等职责重新拆开,LocalDate、LocalDateTime、ZonedDateTime、DateTimeFormatter等类各自负责不同的事情。
当然,Date不能简单当成一个反面教材。它诞生于1996年,当时的设计背景和今天完全不同。
真正值得留下来的,是它后来暴露出来的问题:变化原因不同的事情,尽量不要塞进同一个类。
顺便说一句,除了职责混杂,Date的可变性也让它在多线程环境下危机四伏。java.time在设计时不仅拆分了职责,还全面拥抱了不可变设计,这两者往往是相辅相成的。
怎么判断一个类承担的责任太多了
日常在写业务代码的时候,有个办法可以检验。下次需求变更时留意一下:这次改动迫使哪些类发生了修改。两件不相干的事,比如日期怎么算和日期怎么显示,总在逼着你改同一个类,说明这个类管太多事情了,该分成两个。
小结
单一职责经常被理解成类要写得小,这是个常见误解。它约束的不是体积,是变更原因。