Обзор
Это руководство, прочитать которое я хотел бы много лет назад, когда только начинал свой путь в Scala. Мне пришлось потратить большое количество времени на бесполезные блуждания вокруг да около, чтобы прийти к простым и действенным советам, описанным ниже.
Поскольку это фантастическая интеграция ООП и ФП, case-класс является основной рабочей лошадкой в любом проекте по разработке программного обеспечения на Scala. В Scala он в основном разработан и предназначен (но не исключительно) для использования в качестве (иммутабельного) типа-произведения ФП.
К сожалению, DCCP (Default Case Class Pattern. Шаблон case-класса по умолчанию)...
case class Longitude(value: Double)
...несмотря на лаконичность, гибкость, удобство и широкое применение, страдает от ряда проблем, которые можно разделить на следующие категории:
Путаница с расширениями
Повышенная сложность аргументации
Скудный дизайн для ФП
Будущий технический долг
Уязвимости безопасности
Влияние на производительность
Цель этой статьи — предложить несколько новых шаблонов, которые можно использовать для замены стандартных, которые предоставляет компилятор Scala для решения вышеперечисленных проблем. Я проведу эволюционный процесс, в результате которого будут появляться все более детализированные шаблоны, любой из которых может стать этапом "апгрейда", который вы предпочтете.
DELAYED (ОТЛОЖЕН) (ошибка в IntelliJ движке для обработки шаблонов).
Всегда помечайте как окончательный (Final)
После многих лет использования case-классов стало очевидно, что расширять их с помощью наследования — плохая идея.
Таким образом, наш самый первый паттерн — это просто добавление в начало final
к DCCP:
final case class Longitude(value: Double)
Это гарантирует, что не будут определены потомки, с благими или дурными намерениями, наследуемые от case-класса, которые случайно или целенаправленно злоупотребляют "принципом подстановки Liskov (LSP)".
Хотя некоторые не станут утруждать себя этим, можно настроить компилятор Scala так, чтобы он выдавал предупреждение или ошибку при встрече с не окончательным case-классом (все еще пытаюсь найти опцию[и] компилятора). IntelliJ предлагает нечто подобное. Я предпочитаю доверять компилятору, чтобы он указал мне на мои ошибки, чем своей способности все время помнить это правило.
Для маркировки (case) класса как final
существует дополнительная причина, связанная с производительностью. Есть оптимизации, как компиляторные, так и в рантайме JVM, которые срабатывают только при наличии (case) класса, помеченного final
.
Почему?
Потому что когда (case) класс не помечен final
, рантайм JVM должен предполагать, что загрузчик может в конечном итоге загрузить еще один (case) класс в качестве расширения.
Современные JVM имеют более сложные стратегии в этой области. То есть, когда вызывается загрузчик классов, любой текущий код, который может быть подвержен воздействию, откатывает все свои оптимизации, а затем оптимизируется заново. Откат и переоптимизация также сказываются на производительности — надеюсь, в меньшей степени.
Имплементация этого зачастую оказывает влияние на следующие категории вышеупомянутого обзора...
2: Повышенная сложность рассуждений
3: Плохой дизайн для ФП
4: Будущий технический долг
5: Уязвимости безопасности
6: Влияние на производительность
Воспроизведение сгенерированного компилятором кода
Одно из самых больших разочарований новичков в Scala — это когда они хотят "улучшить" DCCP, сделав объект-компаньон явным. Например, если наивно добавить в начало object
как здесь:
object Longitude {
}
final case class Longitude(value: Double)
Сгенерированный компилятором код позволил использовать трейт FunctionN
как расширение для "объекта-компаньона по умолчанию". Приведенный выше код этого не делает. И любой код, зависящий от отсутствующего трейта, теперь не скомпилируется (например, метод tupled
).
Для начинающего разработчика программного обеспечения на Scala это может быть довольно сложным и дезориентирующим. Не существует никакого "официального руководства" о том, как сделать объект-компаньон по умолчанию явным. Загуглить это не так уж и просто. Вот как я исследовал этот пробел на StackOverflow в 2014 году.
Итак, решение заключается в расширении объекта-компаньона с помощью FunctionN
, чтобы он выглядел следующим образом:
object Longitude extends (Double => Longitude) {
def apply(value: Double): Longitude =
new Longitude(value)
}
final case class Longitude(value: Double)
Чтобы лучше понять, как FunctionN
представлена в виде (Double => Longitude)
, вот пост 2013 года на StackOverflow, в котором мне самому пришлось изучить этот вопрос более глубоко.
И этот паттерн теперь послужит основой, на которой будет заполняться оставшаяся часть шаблона.
Имплементация этого зачастую оказывает влияние на данную категорию обзора:
1: Путаница с расширениями
Предотвращение создания экземпляров, содержащих недопустимое состояние
Одной из максим в ООП DbC (Design by Contract — проектирование по контракту, зародилось в Eiffel) и ФП является предотвращение представления недопустимых состояний. При успешном достижении этой цели значительно сокращается объем "защитного" кода (то есть проверки предусловий) для любых клиентов, использующих экземпляры case-класса.
Наивная реализация первого прохода (которая также встречается и рекомендуется почти во всех учебниках по Scala) заключается в использовании функциональности require
в конструкторе case-класса. Это выглядит следующим образом:
final case class Longitude(value: Double) {
require(value >= -180.0d, s"value [$value] must greater than or equal to -180.0d")
require(value <= 180.0d, s"value [$value] must be less than or equal to 180.0d")
}
Эта реализация выбрасывает исключение при первом неудачном выполнении require
.
При таком подходе есть три проблемы:
Когда есть другие параметры, которые также должны быть валидированы, это потребует нескольких проходов для их проверки, хотя они могли бы быть корректно проверены еще в предыдущем проходе, если бы разрешалось возвращать несколько ошибок.
Имплементация
require
заставляет клиента разбираться с исключениями (избегайте использования исключений для предполагаемых ошибок, как и обычно). В большинстве случаев, оверхед производительности инфраструктуры исключений (как нагрузка на процессор, так и давление на память и ее отток) является значительным и по существу неоптимизируемым (это оспаривается). И даже за рамками причин низкой производительности, имплементации ФП категорически предпочитают "ошибку по значению" в противоположность "ошибке по исключению".Это не позволяет клиенту "проверять предварительные условия" до инстанцирования. Такое смешение проблем препятствует возможностям для оптимизации, когда конструктор, и, следовательно, выделение памяти для экземпляра, никогда не вызывается, потому что уже известно, что предварительные условия не были выполнены.
Мы можем решить все перечисленные трудности одним махом, используя стандартный шаблон для разделения проблем. В данном случае это паттерн ООП "Фабрика" (он же Builder, Smart Constructor и так далее).
Сначала мы переносим всю логику валидации в свой метод generateInvalidStateErrors
. Затем убеждаемся, что метод apply
вызывает оператор new
и принимает/отклоняет инстанцирование, предварительно валидировав переданное значение(я) параметра(ов). Теперь это должно выглядеть следующим образом:
object Longitude extends (Double => Longitude) {
def generateInvalidStateErrors(value: Double): List[String] =
if (value < -180.0d)
List(s"value of value [$value] must be not be less than -180.0d")
else
if (value > 180.0d)
List(s"value of value [$value] must be not be greater than 180.0d")
else
Nil
def apply(value: Double): Longitude =
generateInvalidStateErrors(value) match {
case Nil =>
new Longitude(value)
case invalidStateErrors =>
throw new IllegalStateException(invalidStateErrors.mkString("|"))
}
}
final case class Longitude(value: Double)
Несмотря на то, что паттерн уже внедрен, все еще остается брешь, когда клиент может просто использовать оператор new
, чтобы обойти метод apply
в объекте-компаньоне. Это можно исправить, пометив конструктор case-класса как private
. Это выглядит следующим образом:
final case class Longitude private(value: Double)
Похоже, что мы закончили, верно?
Упс! Векторы коварных атак на подходе!
Оказывается, есть еще два варианта конструкторов, сгенерированных компилятором, которые мы должны рассмотреть
Метод
readResolve
— Поддерживает сгенерированный компилятором интерфейсSerializable
. Это особенно опасно, поскольку он инстанцирует память для case-класса, а затем напрямую внедряет (возможно, вредоносное) десериализованное содержимое в память экземпляра. При этом полностью обходятся как методapply
, так и конструктор объекта. Таким образом, не происходит никакой проверки.Метод
copy
— Использует операторnew
и может так делать, поскольку метод находится в приватной области видимости конструктора. Это позволяет обойти валидацию, перенесенную в объект-компаньон и вызываемую через методapply
.
В каждом из этих случаев мы хотим перенаправить метод на apply
объекта-компаньона. Это должно выглядеть следующим образом:
final case class Longitude private(value: Double) {
private def readResolve(): Object =
Longitude(value)
def copy(value: Double = value): Longitude =
Longitude(value)
}
Если вы знаете, что case-класс никогда не будет использоваться там, где применяется Java-сериализация, то смело удаляйте метод readResolve
.
Хотя я тоже не переношу сериализацию Java, помните, что некоторые платформы, такие как Kafka и Spark, по-прежнему от нее зависят. (Вы также можете встретить старый код Akka, который ее использует, хотя она не применяется по умолчанию в Akka и даже не рекомендуется.) И это означает, что если метод readResolve
отсутствует, то вы оставили свой case-класс открытым для вредоносной атаки, которая обходит иммутабельный инвариант вашего case-класса, закодированный в проверке предусловия, реализованной в методе generateInvalidStateErrors
.
Итак, мы убедились, что не существует приемлемых способов инстанцировать этот case-класс без прохождения проверки предварительного условия (валидации состояния перед вызовом оверхеда инстанцирования). Существуют патологические пути, которые могут быть связаны с незаконным использованием Java reflection API, и у нас нет реального способа защититься от них.
Теперь паттерн должен выглядеть следующим образом:
object Longitude extends (Double => Longitude) {
def generateInvalidStateErrors(value: Double): List[String] =
if (value < -180.0d)
List(s"value of value [$value] must be not be less than -180.0d")
else
if (value > 180.0d)
List(s"value of value [$value] must be not be greater than 180.0d")
else
Nil
def apply(value: Double): Longitude =
generateInvalidStateErrors(value) match {
case Nil =>
new Longitude(value)
case invalidStateErrors =>
throw new IllegalStateException(invalidStateErrors.mkString("|"))
}
}
final case class Longitude private(value: Double) {
private def readResolve(): Object =
Longitude(value)
def copy(value: Double = value): Longitude =
Longitude(value)
}
Имплементация этого зачастую оказывает влияние на следующие категории обзора:
2: Повышенная сложность рассуждений
4: Будущий технический долг)
5: Уязвимости безопасности
6: Влияние на производительность
Добавление конструктора ошибок по значению
Стратегия по умолчанию в case-классах заключается в использовании "ошибки по исключению". Это то, что представляет собой использование require
. Если условие Boolean
ложно, то выбрасывается исключение, обертывающее предоставленную вами строку ошибки.
С точки зрения правильной разработки ФП, исключения считаются плохим способом управления общеизвестными условиями ошибок, как например, предусловия case-класса. Исключения допустимы для таких чрезвычайных ситуаций, как нехватка памяти или открытие соединения с базой данных. Однако и их следует избегать, в том случае, если ошибка возникает только в пределах одного из разделов метода.
Например, применение исключений для метода квадратного корня при выдаче отрицательного числа является неуместным. Метод квадратного корня должен быть определен так, чтобы возвращать либо ошибку (String
), если входное число отрицательное, либо фактический результат, если число положительное.
Чтобы добавить "ошибку по значению", мы создадим дополнительный метод applyE
(где E — это Error), который использует Either
, чтобы охватить как правильный, так и ошибочный случай входного параметра. Метод выглядит следующим образом:
def applyE(value: Double): Either[List[String], Longitude] =
generateInvalidStateErrors(value) match {
case Nil =>
Right(new Longitude(value))
case invalidStateErrors =>
Left(invalidStateErrors)
}
Это выглядит удивительно схоже с методом apply
. На самом деле, он настолько идентичен, что по сути является дублированием кода. Поэтому, для исключения повтора, мы заново имплементируем метод apply
, используя applyE
, который теперь выглядит следующим образом:
def apply(value: Double): Longitude =
applyE(value) match {
case Right(longitude) =>
longitude
case Left(invalidStateErrors) =>
throw new IllegalStateException(invalidStateErrors.mkString("|"))
}
Имплементация этого зачастую оказывает влияние на данную категорию обзора:
3: Плохой дизайн для ФП
Добавление мемоизации/кэширования
Благодаря этому новому шаблону мы обеспечили проверку всех предварительных условий с помощью одного метода. То же самое с инстанцированием. Предполагая, что иммутабельность была сохранена, это сделало тривиальным добавление стратегии мемоизации (кэширования).
Вот пример объекта-компаньона, модифицированного для инкорпорирования мемоизации.
object Longitude extends (Double => Longitude) {
private var cachedInvalidStateErrorss: Map[Double, List[String]] = Map.empty
private var cachedInstances: Map[Double, Longitude] = Map.empty
def generateInvalidStateErrors(value: Double): List[String] = {
cachedInvalidStateErrorss.get(value) match {
case Some(invalidStateErrors) => invalidStateErrors
case None =>
val invalidStateErrors =
if (value < -180.0d)
List(s"value of value [$value] must be not be less than -180.0d")
else if (value > 180.0d)
List(s"value of value [$value] must be not be greater than 180.0d")
else
Nil
val newItem = (value, invalidStateErrors)
cachedInvalidStateErrorss = cachedInvalidStateErrorss + newItem
invalidStateErrors
}
}
…
def applyE(value: Double): Either[List[String], Longitude] =
generateInvalidStateErrors(value) match {
case Nil =>
Right(
cachedInstances.get(value) match {
case Some(longitude) => longitude
case None =>
val longitude = new Longitude(value)
val newItem = (value, longitude)
cachedInstances = cachedInstances + newItem
longitude
}
)
case invalidStateErrors =>
Left(invalidStateErrors)
}
}
Стратегия мемоизации, показанная в приведенном выше фрагменте кода, предназначена только в качестве примера, потому что это ужасная стратегия по умолчанию.
Пожалуйста, используйте один из многих других доступных вариантов. В частности, изучите ScalaCache. Это отличная обобщенная библиотека кэширования, которая позволяет выбирать между различными специализированными имплементациями поддерживаемой базы.
ScalaCache поддерживает широкий спектр библиотек кэширования. Несколькими такими библиотеками являются Redis, Memcached, Guava Cache, Caffeine и EhCache. Мы можем с легкостью взаимозаменяемо использовать любую из этих библиотек кеширования в ScalaCache с минимальным рефакторингом.
Имплементация этого зачастую оказывает влияние на данную категорию обзора:
6: Влияние на производительность
Пример с несколькими свойствами
Чтобы сократить объем текста для чтения, я ограничил все приведенные выше примеры классов одним свойством (также известное как “член”). Ниже приведена версия, обобщенная до трех свойств.
object GeoCoordinate3d extends ((Double, Double, Double) => GeoCoordinate3d) {
val equatorialRadiusInMeters: Double = 6378137.0d
private var cachedInvalidStateErrorss: Map[(Double, Double, Double), List[String]] = Map.empty
private var cachedInstances: Map[(Double, Double, Double), GeoCoordinate3d] = Map.empty
def generateInvalidStateErrors(
longitude: Double
, latitude: Double
, altitudeInMeters: Double
): List[String] = {
val tuple3 = (longitude, latitude, altitudeInMeters)
cachedInvalidStateErrorss.get(tuple3) match {
case Some(invalidStateErrors) => invalidStateErrors
case None =>
val invalidStateErrors = {
List(
if (longitude < -180.0d)
s"value of longitude [$longitude] must be not be less than -180.0d"
else if (longitude > 180.0d)
s"value of longitude [$longitude] must be not be greater than 180.0d"
else
""
, if (latitude < -90.0d)
s"value of latitude [$latitude] must be not be less than -90.0d"
else if (latitude > 90.0d)
s"value of latitude [$latitude] must be not be greater than 90.0d"
else
""
, if (altitudeInMeters < -equatorialRadiusInMeters)
s"value of altitudeInMeters [$altitudeInMeters] must be not be less than -${equatorialRadiusInMeters}d"
else
""
).filter(_.nonEmpty)
}
val newItem = (tuple3, invalidStateErrors)
cachedInvalidStateErrorss = cachedInvalidStateErrorss + newItem
invalidStateErrors
}
}
def apply(
longitude: Double
, latitude: Double
, altitudeInMeters: Double
): GeoCoordinate3d =
applyE(longitude, latitude, altitudeInMeters) match {
case Right(geoCoordinate3d) =>
geoCoordinate3d
case Left(invalidStateErrors) =>
throw new IllegalStateException(invalidStateErrors.mkString("|"))
}
def applyE(
longitude: Double
, latitude: Double
, altitudeInMeters: Double
): Either[List[String], GeoCoordinate3d] =
generateInvalidStateErrors(longitude, latitude, altitudeInMeters) match {
case Nil =>
val tuple3 = (longitude, latitude, altitudeInMeters)
Right(
cachedInstances.get(tuple3) match {
case Some(geoCoordinate3d) => geoCoordinate3d
case None =>
val geoCoordinate3d = new GeoCoordinate3d(longitude, latitude, altitudeInMeters)
val newItem = (tuple3, geoCoordinate3d)
cachedInstances = cachedInstances + newItem
geoCoordinate3d
}
)
case invalidStateErrors =>
Left(invalidStateErrors)
}
}
final case class GeoCoordinate3d private(
longitude: Double
, latitude: Double
, altitudeInMeters: Double
) {
private def readResolve(): Object =
GeoCoordinate3d(longitude, latitude, altitudeInMeters)
def copy(
longitude: Double = longitude
, latitude: Double = latitude
, altitudeInMeters: Double = altitudeInMeters
): GeoCoordinate3d =
GeoCoordinate3d(longitude, latitude, altitudeInMeters)
}
Повторимся, что стратегия мемоизации, показанная в приведенном выше сниппете, предназначена только в качестве примера, потому что это плохая стратегия по умолчанию.
Как уже предлагалось ранее, пожалуйста, используйте один из многих других доступных вариантов, например ScalaCache.
Советы и рекомендации
-
Никогда не переопределяйте методы equals и hashCode
Если считаете, что вам это необходимо, используйте обычный класс, а затем убедитесь, что вы очень точно следуете нетривиальному шаблону полного переопределения, который описан в этом сообщении StackOverflow.
-
Избегайте использования case-класса, если требуется мутабельность состояния, поскольку по умолчанию предполагается, что case-класс будет представлять иммутабельное значение, безопасное с точки зрения параллелизма .
Если необходима мутабельность состояния, используйте обычный класс и обязательно указывайте, является ли он безопасным для обмена данными.
-
Избегайте использования паттерна запечатанных (изолированных) case- объектов/классов для перечислений (также известных как тип-сумма ФП) и вместо этого используйте автоматическую генерацию кода, так как чем больше кода генерируется именно компилятором, тем меньше количество дефектов, накопления технического долга и областей уязвимости безопасности
В Scala 2.x лучше применить библиотеку Enumeratum.
В Scala 3.x рекомендуется использовать новый тип Enum
Все перечисления являются тип-сумма ФП, но не все тип-сумма ФП являются перечислениями.
Резюме
Даже если некоторые из приведенных выше "бойлерплейтов" были сочтены вами как нежелательные, я надеюсь, что вы все равно остались довольны и узнали что-то новое о case-классах, делающими их более полезными для вас при решении будущих задач по разработке программного обеспечения на Scala.
Завтра вечером состоится открытое занятие «Алгебраические типы данных и сопоставление с образцом», на котором разберем иерархию классов, функциональность сопоставления с образцом для чисел и строк. После занятия участники будут понимать, что такое алгебраические типы данных и смогут использовать их на практике. Регистрация для всех желающих доступна по ссылке.