Pull to refresh

Классические паттерны проектирования на Scala

Reading time 14 min
Views 37K
Original author: Pavel Fatin
Об авторе:
Pavel Fatin работает над Scala plugin'ом для IntelliJ IDEA в JetBrains.

Введение



В этой статье будут представлены примеры того, как реализуются классические паттерны проектирования на Scala.

Содержание статьи составляет основу моего выступления на JavaDay конференции (слайды презентации).



Паттерны проектирования



Паттерн проектирования — это общее повторно используемое решение для часто встречающихся проблем при разработке программного обеспечения. Паттерн проектирования — это не законченный код для использования при решении конкретной проблемы, а лишь шаблон, который нужно адаптировать под конкретные нужды. Шаблоны проектирования представляют собой лучшие практики эффективного проектирования, которые помогают улучшить читаемость кода и ускорить процесс разработки.

Паттерны проектирования в функциональном мире



Классические шаблоны проектирования являются объектно-ориентированными. Они показывают отношения и взаимодействия между классами и объектами. Эти модели являются менее применимы в чисто функциональном программировании (см. Haskell’s Typeclassopedia и Scalaz для «функциональных» шаблонов проектирования), однако, так как Scala является объектно-функциональным языком программирования, то эти модели остаются актуальными даже в функциональном мире Scala кода.

Иногда, паттерны проектирования являются заменой необходимых конструкций, отсутствующих в языке программирования. В случаях, когда язык обладает необходимыми возможностями, паттерны можно упростить или вообще убрать. В мире Scala большинство классических паттернов реализуются с помощью выразительных синтаксических конструкций.

В Scala существуют свои дополнительные паттерны, однако в данной статье будут описаны лишь классические паттерны проектирования, ведь именно они являются ключом к взаимопониманию между разработчиками.

Несмотря на то, что приведенные здесь примеры могут быть реализованы более выразительно, используя всю мощь языка Scala, я постарался избегать сложных приемов в пользу простых и наглядных для того чтобы обеспечить более ясное понимание как реализация на Java соотносится с реализацией на Scala.

Обзор паттернов проектирования



Порождающие шаблоны проектирования (Creational patterns):



Структурные шаблоны проектирования (Structural patterns):



Поведенческие шаблоны проектирования (Behavioral patterns):



Далее будут приводиться реализации паттернов проектирования (весь код доступен на Github репозитории).

Реализации паттернов проектирования



Фабричный метод (Factory method)


Паттерн фабричный метод используется для предоставления интерфейса создания объектов, инкапсулируя решение о том, объекты какого класса необходимо создавать.

Паттерн позволяет:
  • совместить логику создания сложного объекта,
  • выбирать класс для создания объекта,
  • кэшировать объекты,
  • координировать доступ к разделяемым ресурсам.


Далее будут приведены реализации паттерна Статический фабричный метод, который слегка отличается от классической версии паттерна Фабричный метод..

В Java, используется оператор new для создания экземпляра класса с помощью вызова его конструктора. При реализации шаблона, мы не будем использовать конструктор на прямую, а будем использовать специальный метод для создания объекта.

public interface Animal {}


public class Dog implements Animal {}


public 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();
    }
}


Пример использования паттерна — создание собаки.

Animal animal = 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")


Фабричный метод определен в так называемом «объекте-компаньоне" — специальный объект синглтон с тем же именем, определенный в том же исходном файле. Такой синтаксис ограничивается «статической» реализацией паттерна, потому как становится невозможным делегировать создание объекта на подклассы.

Плюсы:

  • Повторное использование имени базового класса.
  • Стандартный и лаконичным.
  • Напоминает вызов конструктора.


Минусы:

  • «Статичность» фабричного метода.


Отложеная инициализация (Lazy initialization)


Отложенная инициализация это частный случай отложенных вычислений. Этот паттерн предоставляет механизм инициализации значения (или объекта) только при первом доступе, тем самым позволяя отложить (или избежать) некоторых дорогостоящих вычислений.

Обычно, при реализации паттерна на Java, используется специальное значение null для того чтобы обозначать неинициализированное состояние. Но, если значение null является допустимым инициализированным значением, то необходим дополнительный флаг, указывающий на состояние инициализации. В многопоточном коде доступ к этому флагу должен быть синхронизирован, чтобы избежать состояния гонки. Более эффективная синхронизация может использовать блокировки с двойной проверкой, что еще более усложняет код.

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;
}


Пример использования:
Component component = getComponent();


Scala предоставляет более лаконичный встроенный механизм:

lazy val x = {
  print("(computing x) ")
  42
}


Пример использования:
print("x = ") 
println(x) 

// x = (computing x) 42


Отложенная инициализация в Scala также прекрасно работает и с null значениями. Доступ к отложенным значениям является потокобезопасным.

Плюсы

  • Краткий синтаксис.
  • Отложенные значения могут использоваться и для null значений.
  • Отложенные значения потокобезопасны.


Минусы

  • Меньше контроля над инициализацией.


Синглтон (Singleton)


Паттерн синглтон предоставляет механизм ограничения количества экземпляров класса, а именно, только одним объектом, а также предоставляет глобальную точку доступа к этому объекту. Синглтон, пожалуй, самый широко известный паттерн проектирования в Java, — это явный признак отсутствия необходимой конструкции в языке.

В Java есть специальное ключевое слово static, для обозначения отсутствия связи с каким-либо объектом (экземпляром класса); методы помеченные этим ключевым словом не могут быть переопределены при наследовании. Такая концепция идет вразрез с базовым OOP принципом, что всё является объектом.

Итак, данный паттерн используется когда необходимо иметь доступ к некоторой глобальной реализации какого-то интерфейса (возможно с отложенной инициализацией).

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()


В Scala объекты могут наследовать методы из классов или интерфейсов. На объект можно ссылаться (непосредственно или через унаследованный интерфейс). В Scala, объекты инициализируются отложено, по требованию.

Плюсы

  • Прозрачная реализация.
  • Компактный синтаксис.
  • Отложенная инициализация.
  • Потокобезопасно.


Минусы

  • Меньше контроля над инициализацией.


Адаптер


Паттерн адаптер предоставляет механизм конвертации одного интерфейса к другому, для обеспечения возможности классам реализующих эти разные интерфейсы работать вместе.

Адаптеры удобны для интеграции уже существующих компонентов.

Реализация на 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 существует специальный встроенный механизм адаптирования интерфейсов — неявные классы.

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, Scala компилятор создаст экземпляр класса Logger и обернет его адаптером.

Плюсы

  • Прозрачная реализация.
  • Компактный синтаксис.


Минусы

  • Можно запутаться без использования IDE.


Декоратор (Decorator)


Паттерн декоратор используется для расширения функциональности объекта без влияния на другие экземпляры того же класса. Декоратор предоставляет гибкую альтернативу наследованию. Этот паттерн удобно использовать, когда существует несколько независимых способов расширения функциональности, которые могут быть произвольно объединены вместе.

При реализации на 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 предоставляет прямой путь переопределения методов интерфейса, без привязки к их конкретной реализации.

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 на декорированных объектах.

В Scala такой подход к декорированию называется Stackable Trait Pattern.

Плюсы

  • Прозрачная реализация.
  • Компактный синтаксис.
  • Идентификация объектов сохраняется.
  • Отсутствие явного делегирования.
  • Отсутствие промежуточного класса декоратора.


Минусы

  • Статическое связывание.
  • Конструкторы без параметров.


Объект-значение (Value object)


Объект-значение представляет собой небольшое неизменяемое значение. Объекты-значения равны, если все их поля равны. Объекты-значения широко используются для представления чисел, дат, цветов и т.д. В корпоративных приложениях такие объекты используются в качестве DTO объектов для взаимодействия между процессами, — из-за неизменности, объекты-значения удобны в многопоточном программировании.

В Java нет специального синтаксиса для создания объектов-значений, вместо этого создается класс с конструктором, getter-методами и дополнительными методами (equals, hashCode, toString).

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, можно использовать кортежи или case-классы для объявления объектов-значений. Когда отдельный case-класс не нужен, можно использовать кортежи:

val point = (1, 2) // new Tuple2(1, 2)


Кортежи это предопределенные неизменные «коллекции», которые могут содержать фиксированное количество элементов различных типов. Кортежи предоставляют конструктор, getter-методы, и все вспомогательные методы.

type Point = (Int, Int) // Tuple2[Int, Int]

val point: Point = (1, 2)


В тех случаях, когда всё же выделенный класс необходим, или когда требуются более описательные имена для элементов данных, можно определить case-класс:

case class Point(x: Int, y: Int)

val point = Point(1, 2)


Case-классы делают параметры конструктора класса свойствами. По-умолчанию, case-классы неизменяемы. Как и кортежи, они предоставляют все необходимые методы автоматически. В добавок, case-классы являются валидными классами, а значит с ними можно работать как с обычными классами (например, наследоваться от них).

Плюсы

  • Компактный синтаксис.
  • Предопределенные конструкции языка — кортежи.
  • Встроенные необходимые методы.


Минусы

Отсутствуют.

Null объекты (Null Object)


Null объект представляет собой отсутствие объекта, определяя нейтральное, «бездейственное» поведение.

Такой подход имеет примущество перед использованием null ссылок, потому что нет необходимости явно проверять ссылку перед использованием.

В 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 перед последующим вызовом метода play. В добавок, Null объект можно сделать синглтоном.

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()
}


Плюсы

  • Предопределенный тип.
  • Явная опциональность.
  • Встроенные в язык конструкции для работы с опциональными значениями.


Минусы

  • Многословность.


Стратегия (Strategy)


Паттерн стратегия определяет семейство инкапсулированных алгоритмов и позволяет независимо изменять алгоритм не затрагивая клиентов, которые его используют. Паттерн удобно использовать, когда необходимо сменить алгоритм во времени выполнения.

В 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)


В случае, когда алгоритм-стратегия содержит несколько методов, можно использовать case-классы или кортеж для группирования методов.

Плюсы

  • Компактный синтаксис.


Минусы

  • Обобщенное назначение.


Команда (Command)


Паттерн команда используется для инкапсуляции информации, необходимой для вызова метода в отдаленном моменте времени. Эта информация включает в себя имя метода, объект, которому принадлежит метод и значения для параметров метода.

В 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")
}


Вот так можно конвертировать любое выражение или блок кода в функцию-объект. Вызовы метода println выполняются внутри вызовов invoke метода, и затем сохраняются в последовательности history.

Плюсы

  • Компактный синтаксис.


Минусы

  • Обобщенное назначение.


Цепочка отвественности (Chain of responsibility)


Паттерн цепочка ответственности отделяет отправителя запроса от его получателя, предоставляя возможность нескольким объектам обработать запрос. Запрос обрабатывается цепочке, пока какой-то объект не обрабатывает его.

В типичной реализации паттерна каждый объект в цепочке наследует базовый интерфейс и содержит необязательный ссылку на следующий объект обработки в цепи. Каждому объекту дается возможность либо обработать запрос (и прервать обработку), или передать запрос следующему обработчику в цепочке.

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 предусмотрен более элегантный механизм решения подобных проблем, а именно — частичные функции (partial functions). Частичная функция, — это функция определенная на подмножестве возможных значений своих аргументов.

Хотя для построения цепочки можно использовать комбинацию методов isDefinedAt и apply, более правильным будет использование метода getOrElse

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 для избежания ошибки на «неопределенных» событиях.

Плюсы

  • Компактный синтаксис.
  • Встроенные в язык конструкции.


Минусы

  • Обобщенное назначение.


Внедрение зависимостей (Dependency injection)


Паттерн внедрения зависимостей позволяет избежать жестко заданные зависимости и подставить зависимости либо во время выполнения либо во время компиляции. Паттерн представляет собой особый случай инверсии управления (IoC).

Внедрение зависимостей используется для выбора из множества реализаций конкретного компонента в приложении, или для предоставления mock-компонента для unit-тестирования.

Кроме использования 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”) и наследованию (“IS-A”), Scala предлагает особый вид объектных отношений — требование (“REQUIRES-A”) — выраженный в self-type аннотации. Self-types позволяют указывать дополнительные типы к объекту без применения наследования.

Можно использовать self-type аннотации вместе с traits для реализации паттерна внедрения зависимостей:

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


Полная реализация этой методологии известна как Cake pattern. Однако, это не единственный способ реализации паттерна, существует также множество других способов реализации паттерна внедрения зависимостей в Scala.

Важно напомнить, что «смешивание» traits является в Scala статичным, происходящим во время компиляции. Однако, на практике изменение конфигурации требуется не так уж часто, а дополнительные преимущества от статической проверки на этапе компиляции несет свои существенные преимущества, по сравнению с XML конфигурированием.

Плюсы

  • Прозрачная реализация.
  • Компактный синтаксис.
  • Статическая проверка на этапе компиляции.


Минусы

  • Конфигурирование на этапе компиляции.
  • Конфигурирование может стать «многословным».


Заключение



Я надеюсь, что эта демонстрация поможет преодолеть разрыв между этими двумя языками, помогая Java программистам разобраться в синтаксисе Scala, а Scala программистам помочь в сопоставлении конкретных языковых конструкций на широко известные абстракции.
Tags:
Hubs:
+36
Comments 29
Comments Comments 29

Articles