本文展示了一些经典的软件设计模式在Scala中的实现。
所谓设计模式,就是针对在软件设计过程中出现的一些共性问题,从而产生的一种可重用的解决方案。设计模式不是已完成的代码,而更像是一个可以在不同场景下解决问题的通用模板。
模式是由一些设计的最佳实践组成的,可以帮助我们避免一些问题,并且能增加代码的可读性,及加快开发进度。
经典的设计模式(一般指GoF)都是基于面向对象的。他们展示了类与对象间的关系和行为。这些模式并不能很好的应用到纯函数式编程语言上,但是既然Scala是一种结合了面向对象编程和函数式编程的语言,那Scala还是能够采用这些模式的,甚至是在函数式风格的Scala代码中。
很多时候设计模式被认为是某种语言缺乏一些特性的信号。在此种情况下,当一种语言提供了相关特性以后,这些模式可以被简化或者索性消除。得益于Scala富有表现力的语法,很多经典设计模式都可以直接实现。
尽管Scala还有一些基于语言特性的设计模式,单本文还是着重于介绍大家所周知的经典设计模式,因为这些设计模式被认为是开发者之间交流的工具。
创建型设计模式
1、工厂方法模式
2、延迟加载模式
3、单例模式
结构型模式
1、适配器模式
2、装饰模式
行为型
1、值对象模式
2、空值模式
3、策略模式
4、命令模式
5、责任链模
6、依赖注入模式
一、工厂方法模式
工厂方法模式将对实际类的初始化封装在一个方法中,让子类来决定初始化哪个类。
工厂方法允许:
1、组合复杂的对象创建代码
2、选择需要初始化的类
3、缓存对象
4、协调对共享资源的访问
我们考虑静态工厂模式,这和经典的工厂模式略有不同,静态工厂方法避免了子类来覆盖此方法。
在Java中,我们使用new关键字,通过调用类的构造器来初始化对象。为了实现这个模式,我们需要依靠普通方法,此外我们无法在接口中定义静态方法,所以我们只能使用一个额外的工厂类。
public interface Animal {}
private class Dog implements Animal {}
private class Cat implements Animal {}
public class AnimalFactory {
public static Animal createAnimal(String kind) {
if ("cat".equals(kind)) return new Cat();
if ("dog".equals(kind)) return new Dog();
throw new IllegalArgumentException();
}
}
AnimalFactory.createAnimal("dog");
除了构造器之外,Scala提供了一种类似于构造器调用的特殊的语法,其实这就是一种简便的工厂模式。
trait Animal
private class Dog extends Animal
private class Cat extends Animal
object Animal {
def apply(kind: String) = kind match {
case "dog" => new Dog()
case "cat" => new Cat()
}
}
Animal("dog")
以上代码中,工厂方法被定义为伴生对象,它是一种特殊的单例对象,和之前定义的类或特质具有相同的名字,并且需要定义在同一个原文件中。这种语法仅限于工厂模式中的静态工厂模式,因为我们不能将创建对象的动作代理给子类来完成。
优势:
- 重用基类名字
- 标准并且简洁
- 类似于构造器调用
劣势:
- 仅限于静态工厂方法
二 、延迟初始化模式
延迟初始化是延迟加载的一个特例。它指仅当第一次访问一个值或者对象的时候,才去初始化他们。
延迟初始化可以延迟或者避免一些比较复杂的运算。
在Java中,一般用null来代表未初始化状态,但假如null是一个合法的final值的时候,我们就需要一个独立的标记来指示初始化过程已经进行。
在多线程环境下,对以上提到的标记的访问必须要进行同步,并且会采用双重检测技术(double-check)来保证正确性,当然这也进一步增加了代码的复杂性。
private volatile Component component;
public Component getComponent() {
Component result = component;
if (result == null) {
synchronized(this) {
result = component;
if (result == null) {
component = result = new Component();
}
}
}
return result;
}
Scala提供了一个内置的语法来定义延迟变量.
lazy val x = {
print("(computing x) ")
42
}
print("x = ")
println(x)
// x = (computing x) 42
在Scala中,延迟变量能够持有null值,并且是线程安全的。
优势
- 语法简洁
- 延迟变量能够持有null值
- 延迟变量的访问是线程安全的
劣势
- 对初始化行为缺乏控制
三、单例模式
单例模式限制了一个类只能初始化一个对象,并且会提供一个全局引用指向它。
在Java中,单例模式或许是最为被人熟知的一个模式了。这是java缺少某种语言特性的明显信号。
在java中有static关键字,静态方法不能被任何对象访问,并且静态成员类不能实现任何接口。所以静态方法和Java提出的一切皆对象背离了。静态成员也只是个花哨的名字,本质上只不过是传统意义上的子程序。
public class Cat implements Runnable {
private static final Cat instance = new Cat();
private Cat() {}
public void run() {
// do nothing
}
public static Cat getInstance() {
return instance;
}
}
Cat.getInstance().run()
在Scala中完成单例简直巨简单无比
object Cat extends Runnable {
def run() {
// do nothing
}
}
Cat.run()
优势:
- 含义明确
- 语法简洁
- 按需初始化
- 线程安全
劣势:
- 对初始化行为缺乏控制
四、适配器模式
适配器模式能将不兼容的接口放在一起协同工作,适配器对集成已经存在的各个组件很有用。
在Java实现中,需要创建一个封装类,如下所示:
public interface Log {
void warning(String message);
void error(String message);
}
public final class Logger {
void log(Level level, String message) { /* ... */ }
}
public class LoggerToLogAdapter implements Log {
private final Logger logger;
public LoggerToLogAdapter(Logger logger) { this.logger = logger; }
public void warning(String message) {
logger.log(WARNING, message);
}
public void error(String message) {
logger.log(ERROR, message);
}
}
Log log = new LoggerToLogAdapter(new Logger());
在Scala中,我们可以用隐式类轻松搞定。(注意:2.10后加的特性)
trait Log {
def warning(message: String)
def error(message: String)
}
final class Logger {
def log(level: Level, message: String) { /* ... */ }
}
implicit class LoggerToLogAdapter(logger: Logger) extends Log {
def warning(message: String) { logger.log(WARNING, message) }
def error(message: String) { logger.log(ERROR, message) }
}
val log: Log = new Logger()
最后的表达式期望的得到一个Log实例,而却使用了Logger,这个时候Scala编译器会自动把log实例封装到适配器类中。
优势:
- 含义清晰
- 语法简洁
劣势:
- 在没有IDE的支持下会显得晦涩
五、装饰模式
装饰模式被用来在不影响一个类其它实例的基础上扩展一些对象的功能。装饰者是对继承的一个灵活替代。
当需要有很多独立的方式来扩展功能时,装饰者模式是很有用的,这些扩展可以随意组合。
在Java中,需要新建一个装饰类,实现原来的接口,封装原来实现接口的类,不同的装饰者可以组合起来使用。一个处于中间层的装饰者一般会用来代理原接口中很多的方法。
public interface OutputStream {
void write(byte b);
void write(byte[] b);
}
public class FileOutputStream implements OutputStream { /* ... */ }
public abstract class OutputStreamDecorator implements OutputStream {
protected final OutputStream delegate;
protected OutputStreamDecorator(OutputStream delegate) {
this.delegate = delegate;
}
public void write(byte b) { delegate.write(b); }
public void write(byte[] b) { delegate.write(b); }
}
public class BufferedOutputStream extends OutputStreamDecorator {
public BufferedOutputStream(OutputStream delegate) {
super(delegate);
}
public void write(byte b) {
// ...
delegate.write(buffer)
}
}
new BufferedOutputStream(new FileOutputStream("foo.txt"))
Scala提供了一种更直接的方式来重写接口中的方法,并且不用绑定到具体实现。下面看下如何来使用abstract override标识符。
trait OutputStream {
def write(b: Byte)
def write(b: Array[Byte])
}
class FileOutputStream(path: String) extends OutputStream { /* ... */ }
trait Buffering extends OutputStream {
abstract override def write(b: Byte) {
// ...
super.write(buffer)
}
}
new FileOutputStream("foo.txt") with Buffering // with Filtering, ...
这种代理是在编译时期静态建立的,不过通常来说只要我们能在创建对象时任何组合装饰器,就已经够用了。
与基于组合(指需要特定的装饰类来把原类封装进去)的实现方式不一样,Scala保持了对象的一致性,所以可以在装饰对象上放心使用equals。
优势:
- 含义清晰
- 语法简洁
- 保持了对象一致性
- 无需显式的代理
- 无需中间层的装饰类
劣势:
- 静态绑定
- 没有构造器参数
六、值对象模式
值对象是一个很小的不可变对象,他们的相等性不基于identity,而是基于不同对象包含的字段是否相等。
值对象被广泛应用于表示数字、时间、颜色等等。在企业级应用中,它们经常被用作DTO(可以用来做进程间通信),由于不变性,值对象在多线程环境下使用起来非常方便。
在Java中,并没有特殊语法来支持值对象。所以我们必须显式定义一个构造器,getter方法及相关辅助方法。
public class Point {
private final int x, y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public int getY() { return y; }
public boolean equals(Object o) {
// ...
return x == that.x && y == that.y;
}
public int hashCode() {
return 31 * x + y;
}
public String toString() {
return String.format("Point(%d, %d)", x, y);
}
}
Point point = new Point(1, 2)
在Scala中,我们使用元组或者样例类来申明值对象。当不需要使用特定的类的时候,元组就足够了.
val point = (1, 2) // new Tuple2(1, 2)
元组是一个预先定义好的不变集合,它能够持有若干个不同类型的元素。元组提供构造器,getter方法以及所有辅助方法。
我们也可以为Point类定义一个类型别名
type Point = (Int, Int) // Tuple2[Int, Int]
val point: Point = (1, 2)
当需要一个特定的类或者需要对数据元素名称有更明确的描述的时候,可以使用样例类;
case class Point(x: Int, y: Int)
val point = Point(1, 2)
样例类将构造器参数默认为属性。样例类是不可变的,与元组一样,它提供了所有所需的方法。因为样例类是合法的类,所以它也可以使用继承及定义成员。
值对象模式是函数式编程中一个非常常用的工具,Scala在语言级别对其提供了直接支持。
优势:
- 语法简洁
- 预定义元组类
- 内置辅助方法
劣势:
- 无
七、空值模式
空值模式定义了一个“啥都不干”的行为,这个模式比起空引用有一个优势,它不需要在使用前检查引用的合法性。
在java中,我们需要定义一个带空方法的子类来实现此模式。
public interface Sound {
void play();
}
public class Music implements Sound {
public void play() { /* ... */ }
}
public class NullSound implements Sound {
public void play() {}
}
public class SoundSource {
public static Sound getSound() {
return available ? music : new NullSound();
}
}
SoundSource.getSound().play();
所以,由getSound获得Sound实例再调用play方法,不需要检查Sound实例是否为空。更进一步,我们可以使用单例模式来限制只生成唯一的空对象。Scala也采用了类似的方法,但是它提供了一个Option类型,可以用来表示可有可无的值。
trait Sound {
def play()
}
class Music extends Sound {
def play() { /* ... */ }
}
object SoundSource {
def getSound: Option[Sound] =
if (available) Some(music) else None
}
for (sound <- SoundSource.getSound) {
sound.play()
}
在此场景下,我们使用for推导来处理Option类型(高阶函数和模式匹配也能轻松搞定此事)。
优势:
- 预定义类型
- 明确的可选择性
- 内置结构支持
劣势:
- 比较冗长的用法
八、策略模式
策略模式定义了一组封装好的算法,让算法变化独立于用户调用。需要在运行时选择算法时,策略模式非常有用。
在java中,一般先要定义一个接口,然后新建几个类分别去实现这个接口。
public interface Strategy {
int compute(int a, int b);
}
public class Add implements Strategy {
public int compute(int a, int b) { return a + b; }
}
public class Multiply implements Strategy {
public int compute(int a, int b) { return a * b; }
}
public class Context {
private final Strategy strategy;
public Context(Strategy strategy) { this.strategy = strategy; }
public void use(int a, int b) { strategy.compute(a, b); }
}
new Context(new Multiply()).use(2, 3);
在Scala中,函数是头等公民,可以直接实现如下(不得不说实现起来很爽)。
type Strategy = (Int, Int) => Int
class Context(computer: Strategy) {
def use(a: Int, b: Int) { computer(a, b) }
}
val add: Strategy = _ + _
val multiply: Strategy = _ * _
new Context(multiply).use(2, 3)
假如策略包含很多方法的话,我们可以使用元组或者样例类把所有方法封装在一起。
优势:
- 语法简洁
劣势:
- 通用类型
九、命令模式
命令模式封装了需要在稍后调用方法的所有信息,这些信息包括拥有这些方法的对象和这些方法的参数值。
命令模式适用于延时方法调用,顺序化方法调用及方法调用时记录日志。(当然还有其它很多场景)
在Java中,需要把方法调用封装在对象中。
public class PrintCommand implements Runnable {
private final String s;
PrintCommand(String s) { this.s = s; }
public void run() {
System.out.println(s);
}
}
public class Invoker {
private final List<Runnable> history = new ArrayList<>();
void invoke(Runnable command) {
command.run();
history.add(command);
}
}
Invoker invoker = new Invoker();
invoker.invoke(new PrintCommand("foo"));
invoker.invoke(new PrintCommand("bar"));
在Scala中,我们使用换名调用来实现延迟调用
object Invoker {
private var history: Seq[() => Unit] = Seq.empty
def invoke(command: => Unit) { // by-name parameter
command
history :+= command _
}
}
Invoker.invoke(println("foo"))
Invoker.invoke {
println("bar 1")
println("bar 2")
}
这就是我们怎样把任意的表达式或者代码块转换为一个函数对象。当调用invoke方法的时候才会调用println方法,然后以函数形式存在历史序列中。我们也可以直接定义函数,而不采用换名调用,但是那种方式太冗长了。
优势:
- 语法简洁
劣势:
- 通用类型
十、责任链模式
责任链模式解耦了发送方与接收方,使得有更多的对象有机会去处理这个请求,这个请求一直在这个链中流动直到有个对象处理了它
责任链模式的一个典型实现是责任链中的所有的对象都会继承一个基类,并且可能会包含一个指向链中下一个处理对象的引用。每一个对象都有机会处理请求(或者中断请求),或者将请求推给下一个处理对象。责任链的顺序逻辑可以要么代理给对象处理,要么就封装在一个基类中。
public abstract class EventHandler {
private EventHandler next;
void setNext(EventHandler handler) { next = handler; }
public void handle(Event event) {
if (canHandle(event)) doHandle(event);
else if (next != null) next.handle(event);
}
abstract protected boolean canHandle(Event event);
abstract protected void doHandle(Event event);
}
public class KeyboardHandler extends EventHandler { // MouseHandler...
protected boolean canHandle(Event event) {
return "keyboard".equals(event.getSource());
}
protected void doHandle(Event event) { /* ... */ }
}
KeyboardHandler handler = new KeyboardHandler();
handler.setNext(new MouseHandler());
由于以上的实现有点类似于装饰者模式,所以我们在Scala中可以使用abstract override来解决这个问题。不过Scala提供了一种更加直接的方式,即基于偏函数。
偏函数简单来说就是某个函数只会针对它参数的可能值的自己进行处理。可以直接使用偏函数的isDefinedAt和apply方法来实现顺序逻辑,更好的方法是使用内置的orElse方法来实现请求的传递。
case class Event(source: String)
type EventHandler = PartialFunction[Event, Unit]
val defaultHandler: EventHandler = PartialFunction(_ => ())
val keyboardHandler: EventHandler = {
case Event("keyboard") => /* ... */
}
def mouseHandler(delay: Int): EventHandler = {
case Event("mouse") => /* ... */
}
keyboardHandler.orElse(mouseHandler(100)).orElse(defaultHandler)
注意我们必须使用defaultHandler来避免出现“undefined”事件的错误。
优势:
- 语法简洁
- 内置逻辑
劣质:
- 通用类型
十一、依赖注入模式
依赖注入可以让我们避免硬编码依赖关系,并且允许在编译期或者运行时替换依赖关系。此模式是控制反转的一个特例(用过Spring的同学都对这个模式熟烂了吧)。
依赖注入是在某个组件的众多实现中选择,或者为了单元测试而去模拟组件。
除了使用IoC容器,在Java中最简单的实现就是像构造器参数需要的依赖。所以我们可以利用组合来表达依赖需求。
public interface Repository {
void save(User user);
}
public class DatabaseRepository implements Repository { /* ... */ }
public class UserService {
private final Repository repository;
UserService(Repository repository) {
this.repository = repository;
}
void create(User user) {
// ...
repository.save(user);
}
}
new UserService(new DatabaseRepository());
除了组合(“HAS-A”)与继承(“HAS-A”)的关系外,Scala还增加一种新的关系:需要(“REQUIRES -A”), 通过自身类型注解来实现。(建议大家去熟悉一下自身类型的定义与使用)
Scala中可以混合使用自身类型与特质来进行依赖注入。
trait Repository {
def save(user: User)
}
trait DatabaseRepository extends Repository { /* ... */ }
trait UserService { self: Repository => // requires Repository
def create(user: User) {
// ...
save(user)
}
}
new UserService with DatabaseRepository
不同于构造器注入,以上方式有个要求:配置中的每一种依赖都需要一个单独的引用,这种技术的完整实践就叫蛋糕模式。(当然,在Scala中,还有很多方式来实现依赖注入)。
在Scala中,既然特质的混入是静态的,所以此方法也仅限于编译时依赖注入。事实上,运行时的依赖注入几乎用不着,而对配置的静态检查相对于运行时检查有很大的优势。
优势:
- 含义明确
- 语法简洁
- 静态检查
劣势:
- 编译期配置
- 形式上可能有点冗长