Scala 是一门多范式语言,它的创始人 Martin Odersky 是 javac 的核心开发者之一。因有感于 Java 历史包袱深重,白瞎了 JVM 这么多年的积累,Martin 决定在 JVM 平台上开发一门更加学院派(前卫)的语言,并且要照顾到工业界的诉求(实用),于是 Scala 诞生了。Scala 的定位刚好卡在 Java 和 Clojure 之间,如果你是老板,希望新手程序员尽快出货,Java 是首选;如果你是学者,离开范畴论就不知道怎么表达,Clojure 是解药;但如果你是程序员,不应该错过 Scala。
Scala 在满足工业需要的前提上,引入了大量前卫的特性,这促使其设计模式和 Java 有所不同。本文将分别就创建型、行为型、结构型设计模式,选择几个常用的来比较 Scala、Java 实现,为读者展示 Scala 学院派的一面。
Singleton
通常单例对象都比较重,因此一个理想的单例应该是 lazy 的,并且要能克服4种破坏方式:new、反射、序列化、克隆,前3种的前提是单例拥有一个可访问的 constructor,后1种要求单例继承 Object 的 clone()。
Java 的 enum 被编译为一个 abstract,它受编译器保护,只能继承 Enum。这导致 enum 不能被构造,并且无法继承 Object,可以防止单例破坏。另外枚举常量被编译为 abstract 的静态内部类,因此是 lazy 的。
Scala 在单例模式的实践更进一步,它提供了 object 关键字以声明一个单例对象。其设计哲学是 class 和 object 的严格区分,既然 class 可以被 define 出来,那么为什么 object 不能被 define 呢?
接下来看看 object 如何满足单例模式 lazy 和防守的需求。声明一个 object Single,包含一个求和函数。通过 javap 反编译:
public final class Single {
public static int sum(List var0) {
return Single$.MODULE$.sum(var0);
}
}
public final class Single$ implements Serializable {
public static final Single$ MODULE$ = new Single$();
private Single$() {
}
private Object writeReplace() {
return new ModuleSerializationProxy(Single$.class);
}
public int sum(final List list) {
return BoxesRunTime.unboxToInt(list.sum(.MODULE$));
}
}
可以发现,Single 被编译为不继承 java.lang.Object 的 final class,这堵住了克隆破坏单例的路子。Single 未声明任何 constructor,因此不能够通过 new 创建对象,也不用担心反射、序列化破坏了。
此外,Single 只是个 facade,其真正的实现包含在另外一个类 Single$ 中,只有等 Single#sum 被调用的时候才会延迟加载,因此是 lazy 的。
Template Method
模板模式属于属于行为型模式,一个抽象类公开定义了执行它的方法的方式/模板。它的子类可以按需要重写方法实现,但调用将以抽象类中定义的方式进行。
假设要设计一个模板方法 reportGrades,要求输入一系列分数,并在控制台展示对应的评价等级。
public abstract class Template {
public void reportGrades(List<Double> grades) {
var convertedGrades = grades.stream().map(this::numToLetter).collect(Collectors.toList());
printGradeReport(convertedGrades);
}
protected abstract String numToLetter(Double grade);
protected abstract void printGradeReport(List<String> grades);
}
在 Java 中需要使用 abstract 声明一个抽象类,然后在抽象类内部用 abstract 声明一系列抽象方法,最后在一个 public 方法中调用这些抽象方法。为了实现 reportTemplate,一套下来3个步骤,还搭进去了一个类。
Scala 没有提供 abstract,但作为和 Haskell 同源的函数式编程语言,天然支持 High Order Function 高阶函数。
def reportGrades(numToLetter: Double => String, printGradeReport: Seq[String] => Unit): Seq[Double] => Unit =
(grades: Seq[Double]) => printGradeReport(grades.map(numToLetter))
可以发现,HOF 在表现“行为”的场景中直接明了,真正把行为的定义还给了模板方法,最终效果出来只用2行代码就能实现模板方法模式。
Strategy
策略模式属于行为型模式,允许一个类的行为或其算法可以在运行时,根据环境需要而变更。实际开发当中主要用来抽离 if-else 控制逻辑,避免过多 if-else 增加维护难度,避免新增 if 分支更改 client 代码。
该模式有2个角色:
- Strategy 策略。包含if 控制结构,以及策略的具体实现
- Context 上下文。使用 Strategy 的实现构造,理想情况下 client 只与 Context 打交道
假设实现一个告警系统,要求对 Event 检查,如若匹配发出 Alert。
case class Event(cpuUsage: Option[Int], loadAvg: Option[Int])
def cpuStrategy(event: Event) = event match {
case Event(cpuUsage, loadAvg) => if cpuUsage.isDefined && cpuUsage.get > 90 then println(s"alert cpuUsage= ${cpuUsage.get}")
}
def context(strategy: Event => Unit) = {
(event: Event) => strategy(event)
}
def main(args: Array[String]): Unit = { // client
val alertCpu = context(cpuStrategy)
alertCpu(Event(Some(91), None))
}
Scala 实现策略模式同样依赖 HOF。client 剔除 if-else 结构,并传入策略到 context。context 负责调用策略。策略 cpuStrategy 使用模式匹配解构 event,从中取出 cpuUsage 进行 if 检查、告警。
不妨质疑一下,这里的 context 似乎是多余的,client 可以直接调用 cpuStrategy。实际上 context 在这个案例中被简化了,如果结合工厂模式,client 可以只传入策略名,context 拿着策略名向工厂置换策略,并执行。
Decorator
装饰器模式允许向一个现有的对象添加新的功能,同时又不改变其结构。装饰者的实现同样有两个角色:
- Decoratee 被装饰者。
- Decorator 装饰者。实现 Decoratee 的接口,使用 Decoratee 的实现构造,并增强该实现
尽管是结构型模式,Decorator 和策略模式的 Context 类似,但其要实现 Decoratee,并且无额外的新方法。
假设实现一个审计功能,给方法 add 添加日志:
def add(a: Int, b: Int): Int = a + b
def doLog(calcFn: (Int, Int) => Int) =
(a: Int, b: Int) => {
val result = calcFn(a, b)
println("result is: " + result)
result
}
def main(args: Array[String]): Unit = {
val addWithLog = doLog(add)
addWithLog(1, 2)
}
基于 HOF 的实现并不优雅。高阶函数 doLog 装饰了 add,但 client 需要调用 addWithLog 才能实现审计,并且只能装饰一个方法。相比之下,OOP 的装饰者实现能一次性装饰所有方法,并且对 client 的修改也仅限于创建 Decorator 的一行:
trait Operator {
def add(a: Int, b: Int): Int
def minus(a: Int, b: Int): Int
}
class OperatorImpl extends Operator {
def add(a: Int, b: Int): Int = a + b
def minus(a: Int, b: Int): Int = a - b
}
trait Log extends Operator { // Decorator
abstract override def add(a: Int, b: Int): Int = {
val rst = super.add(a, b)
print(s"result = $rst")
rst
}
}
def main(args: Array[String]): Unit = {
val operator = new OperatorImpl with Log
operator.add(1, 2)
}
可以发现,比起 Java,Scala 对于装饰者模式的 OOP 实现该有的类一个不少。但得益于 trait 和 scalac,Decorator 被抽象为一个真正的行为,因此其无须持有 Decoratee 的引用,只要在 client 中使用 with 关键字把 Log 这个行为 mixin 入 Decoratee 即可完成增强。另外 Decorator 可以选择只增强方法 add,对于不想增强的方法 minus,也不需要 override。
Dependency Injection
严格来说,依赖注入不算是 GOF 的设计模式,但 Spring 能够成为 Java 后端开发的标准,说明基于容器的依赖注入确实方便。Scala 在不依赖三方框架的情况下,有4种方式实现 DI,本文将以展开说说工程中常用的2种。
cake pattern
假设实现一个用户相关的服务,要求保存用户信息写入数据库和缓存。下面先定义一下接口:
case class User(id: Long, name: String, phone: Int)
trait Repository {
def save(user: User): Unit
}
class UserRepository extends Repository {
override def save(user: User): Unit = println(s"save db. $user")
}
class RedisTemplate {
def set(str: String): Unit = println(s"save cache str=$str")
}
现在访问数据库的 Repository 和访问缓存的 RedisTemplate 已经被定义出来,可以尝试注入它们了:
trait RepositoryComponent {
val repository: Repository
}
trait TemplateComponent {
val redisTemplate: RedisTemplate
}
trait ServiceComponent {
this: RepositoryComponent with TemplateComponent =>
val userService: UserService
class UserService {
def create(user: User) = {
println(s"create user=$user")
repository.save(user)
redisTemplate.set(user.toString)
}
}
}
Spring 中可以使用注解 @Component 标识一个类纳入容器管理,来到 Scala 同样需要一个类似的过程。ServiceComponent 通过 self-type 把 RepositoryComponent 和 TemplateComponent 引入进来,那么 UserService 就能够访问数据库和缓存了。距离 client 能真正使用 DI 还差一步:
object Registry extends ServiceComponent with RepositoryComponent with TemplateComponent {
override val userService: Registry.UserService = UserService()
override val repository: Repository = UserRepository()
override val redisTemplate: RedisTemplate = RedisTemplate()
}
单例 Registry 类似 Spring 的 ApplicationContext 应用上下文,实例在这里被构造出来,并且赋值给成员变量。
def main(args: Array[String]): Unit = {
val userService = Registry.userService
userService.create(User(0, "Tom", 110))
}
client 获取实例,只需要访问单例 Registry 的成员变量。cake pattern 依赖注入实现到此为止,其中最重要的概念 self-type 是 scala 为了表示除 has-a 和 is-a 关系而引入的语法,表示 require-a。业界对于 cake pattern 褒贬不一,其在 2018年的一次投票中甚至无人问津,开源计算框架 spark 也只是利用了 self-type,但并未实现一个完整的 cake pattern。
structural typing
如果不想引入三方 DI 框架,还有一个 DI 实现值得注意,那就是基于 structural typing 实现。
class UserService(env: {val repository: Repository
val redisTemplate: RedisTemplate}) {
def create(user: User) = {
println(s"create user=$user")
env.repository.save(user)
env.redisTemplate.set(user.toString)
}
}
object Registry {
lazy val repository = new UserRepository
lazy val redisTemplate = new RedisTemplate
lazy val userService = new UserService(this)
}
对比 cake pattern,该实现不需要声明 Component,因此 Registry 也不需要混入众多 Component,代码相当干净,和 Spring 的容器实现对比,由于不用维护容器,其 DI 效率极高。
后话
放到 2022 年,HOF 早已不是新鲜事物,javascript 自诞生之初就支持函数指针,JDK8 也通过 javac 支持 lambda 表达式以及函数引用,然而对于 FP 的应用,很多程序员还是停留在内循环 foreach 的层面上,甚者 Optional 也没弄明白。这多方面促使的结果,没有了 FP 程序员也能把活办地漂亮。
Scala 的哲学是 compiler 本该承担更多,所以它提供了一系列看似不痛不痒,但场景对了就能发挥巨大作用的特性。有别于 Rust,Scala 不寻求当程序员的保姆(无意冒犯),它把 FP 和 OOP 的进阶用法都隐藏地相当好,以至于开发者把 trait 当 interface 用,并且按命令式的思路写业务也不会有任何问题。
从 JDK17 支持模式匹配能感受到 java 社区已经意识到革命的必要性,match 将不再自动进入下一个 case,这无疑是破坏性更新,但这仅仅是偿还历史债务的开始。时不我待,Scala3 依然坚持自我革命,在认识到抽象不足的缺点后,大胆放弃过去饱受争议的 implict,并提供更纯粹的 type class 方案。如能更积极地融入现有的 java 开发工具链(放弃 sbt 吧),将会发育地更好。
参考文献
Scala 设计模式
Scala之自身类型(Self Type)
The cake pattern is a lie
real-world scala: dependen cy injection (di)