Статьи

Банда четырех шаблонов с классами типов и имплицитами в Scala

Классы типов, как их называют в языке Scala, занимают прекрасное место в разработке библиотек. Они делают код открытым для расширения, менее подробным и упрощают API. Я еще не нашел много других шаблонов в языках, которые делают то же самое. Близкая секунда, в зависимости от вашей точки зрения, является одной из концепций генератора или декоратора в языке Python (где последняя представляет собой просто замаскированную композицию функций ). Этот пост весьма самоуверен; после прочтения, если вы не согласны или чувствуете, что я что-то упустил, скажите об этом в разделе комментариев.

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

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

В следующих нескольких статьях я расскажу о трех шаблонах проектирования Gang of Four и о том, как их применять с помощью классов типов:

Я надеюсь, что это даст будущим авторам библиотек, которые приходят с другого языка, понимание того, что, когда и зачем их использовать, но должно указать, что при всех вещах избыток — верный признак того, что вы делаете это неправильно. Шаблон адаптера является наиболее широко известным примером использования классов типов в сообществе Scala. Таким образом, вместо того, чтобы придерживаться чего-то, что сообщество в значительной степени осознает, я начну с чего-то менее известного. Почему? Недавно я использовал шаблон моста в крошечной библиотеке моего нынешнего работодателя Novus Partners . Если вы уже знаете шаблон моста , вы можете пропустить следующую часть и сразу перейти к разделу класса типов. Если нет, продолжайте читать.

Образец моста

Шаблон моста был разработан для решения проблемы множественных независимых концепций, которые должны сосуществовать, не вызывая комбинаторный взрыв типов. В ОО-тяжелых языках (Java, C #, C ++), где люди склонны объединять идеи посредством наследования, а не композиции объектов, этот шаблон регулярно появляется в рефакторинге экскурсий . По сути, объекты соединяются вместе во время выполнения и слабо связаны через общие интерфейсы во время компиляции, часто с интерфейсом одного концепта, переданным конструктору конкретной реализации другого концепта.

Если вы никогда не слышали о композиции, а не о наследовании , то это так. Неудивительно, что практики и сторонники ОО-языков считают, что это не только расширение, но и воплощение лучших принципов хорошего дизайна . Я сам не буду сбрасывать со счетов это достоинства. Как и любой шаблон дизайна, он решает реальную проблему простым и элегантным способом. Для языков FP или гибридных языков FP-OO, таких как Scala, функции более высокого порядка имеют тенденцию использоваться вместо одноразовых абстрактных интерфейсов (поскольку функции предоставляют разумный и хорошо понятный интерфейс самостоятельно).

Так как же это выглядит? Рассмотрим следующие классы:

01
02
03
04
05
06
07
08
09
10
11
class EncryptedFileWriter(cypher: String, key: String) extends FileWriter{
  def write(file: File, content: String) = open(file){ encrypt(content) }
  //more
}
class CompactJsonFileWriter extends FileWriter{
  def write(file: File, content: String) = open(file){
    validate(content)
    compact(content)
  }
  //more
}

Очевидно, что каждый класс выполняет два разных типа записи в файл. Один переводит в зашифрованный формат, а другой переводит JSON в компактное представление. Однако что если я захочу написать в базу данных? Чтобы иметь возможность писать в зашифрованном виде или в формате JSON, мне нужно создать еще 2 класса, специфичных для базы данных, которую я использовал. Видишь, куда это идет? Если бы у меня было M форматов и N назначений контента, мне пришлось бы создавать MN классы.

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

1
2
3
4
5
6
7
class FileWriter(file: File) extends Writer{
  def write(content: String, convert: String => String = identity) = open{ convert(content) }
  //more
}
class JsonConvert extends (String => String){
  def apply(input: String): String = //more
}

тем самым мы взяли на себя дополнительную свободу делать форматирование необязательным. Теперь Writer означает что-то, что записывает, а форматирование — это операция String => String . Ни одному не нужно заботиться или беспокоиться о реализации другого вообще.

Образец Моста в Классах Типа

Я хотел бы думать, что есть два требования для использования шаблона моста на практике с классами типов:

  1. Существуют две взаимодополняющие концепции, которые должны варьироваться независимо
  2. Существует явная зависимость от чего-то, что неявно понимается, но может быть выражено через сигнатуру типа

В данном случае каждая база данных, которую я использовал (PostGRES, HSQLDB, MS SQL и т. Д.), Реализует не только разные версии стандарта SQL ANSI (иногда только 90% стандарта), но также, как правило, содержит свои собственные конкретные расширения, которые не переносится на другие базы данных. При написании запросов я редко видел, чтобы кто-то кодифицировал в своем определении класса или функции это явное отношение, даже если оно присутствует в строках запроса. Команда обычно неявно понимает, что существует привязка к типу базы данных, и эти знания передаются либо из уст в уста, комментариями в коде, либо предполагаются благодаря используемому техническому стеку. Чтобы продвинуться еще дальше, существует также жесткая зависимость от того, как API JDBC используется в контексте типа базы данных. Документация JDBC четко предупреждает о таких вещах, как создание PreparedStatement или получение сгенерированных ключей . Не все драйверы JDBC поддерживают все операции, и не все базы данных поддерживают одни и те же хуки API .

Взятые вместе (или даже отдельно), мы выполнили второе условие. Чтобы удовлетворить первое, нам нужно поговорить о пуле соединений . Есть несколько фантастических пулов соединений, доступных для JVM ( BoneCP , C3P0 , JDBC-Pool и DBCP, чтобы назвать несколько, хотя я хотел бы услышать о большем количестве в активной разработке.) Каждая из этих библиотек достаточно различна, чтобы гарантировать ее собственную экземпляр обработчика соединения, ведение журнала поведения / производительности и механизмы инициализации. Для поддержки нескольких пулов и баз данных в одной и той же библиотеке необходимо будет гармонично сосуществовать две разные концепции (пулы и вызовы JDBC API).

Пример использования

Давайте предположим, что нам нужен очень простой интерфейс для выполнения ваших четырех типов запросов DML (иначе называемых операциями CRUD .) Давайте рассмотрим что-то вроде:

1
2
3
4
5
6
trait Query{
  def insert(query: String, args: AnyRef*): List[Int]
  def delete(query: String, args: AnyRef*): Int
  def update(query: String, args: AnyRef*): Int
  def select(query: String, args: AnyRef*): ResultSet
}

Этот тип интерфейса предоставляет прямой, унифицированный и понятный API. Даже вне контекста очень мало путаницы в отношении того, что должно произойти. Тем не менее, если бы мы разработали против него без изменений, Query лучше всего выразить как абстрактный класс с двумя аргументами конструктора, принимающими интерфейс для пулов соединений, и один для логики, специфичной для БД. Короче говоря, это будет выглядеть очень похоже на Java. Здесь мы обращаемся к типам классов.

Как я отмечал выше, любые запросы, которые мы пишем, сталкиваются с неявным предположением о том, какую базу данных они будут использовать. Почему бы явно не кодировать это в сигнатуру типа? И если мы хотим использовать классы типов для упрощения API, я объявляю, что класс типов должен следовать этому правилу: класс типов должен быть ссылочно прозрачным , описывая как и ничего более. Таким образом, при выборе между тем, чтобы сделать пул типом класса или оператором, выполняющим операторы, типом класса, я думаю, что выбор делает сам. Ни. Оба являются побочными действиями. Однако, если бы мы были так склонны, позднее можно было бы переписать, чтобы вернуть IO Monad и таким образом остаться «чистым».

Сначала мы перенесем всю обработку пула соединений в сам интерфейс Query :

1
2
3
4
5
6
7
trait Query[DB]{
  def insert(query: String, args: AnyRef*)(implicit con: StatementConstructor[DB]) = pool{ con.insert(query, args: _*) }
  def delete(query: String, args: AnyRef*)(implicit con: StatementConstructor[DB]) = pool{ con.delete(query, args: _*) }
  def update(query: String, args: AnyRef*)(implicit con: StatementConstructor[DB]) = pool{ con.update(query, args: _*) }
  def select(query: String, args: AnyRef*)(implicit con: StatementConstructor[DB]) = pool{ con.select(query, args: _*) }
  protected def pool[A](f: Connection => A): A
}

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

1
2
3
4
5
6
trait StatementConstructor[DB]{
  def insert(query: String, args: AnyRef*)(connection: Connection): List[Int]
  def delete(query: String, args: AnyRef*)(connection: Connection): Int
  def update(query: String, args: AnyRef*)(connection: Connection): Int
  def select(query: String, args: AnyRef*)(connection: Connection): ResultSet
}

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

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

Теоретически код, использующий эту систему, будет выглядеть так:

1
2
3
4
5
val queryPool = new DBCPBackedQuery[MySQL]("myConfigFile")
 
def activeUsers(query: Query[MySQL]) = {
  val resultSet = query.select("SELECT * FROM active_users")
  //more

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

Вывод

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

Я надеюсь, что пример был достаточно наглядным, чтобы подчеркнуть некоторые преимущества как этого шаблона проектирования в Scala, так и гибкости классов типов в целом. В следующем посте я расскажу о неявных классах типов, используя шаблон адаптера ; демонстрируя, как его можно использовать для специального полиморфизма и ограничения функциональности класса определенным типом, очень похожим на шаблонную конструкцию C ++ enable_if . Не волнуйтесь, C ++ не будет, только Scala.

Ссылка: Банда четырех шаблонов с классами типов и имплицитами в Scala от нашего партнера JCG Овейна Риза в блоге со статической типизацией .