Обзор

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

Поскольку это фантастическая интеграция ООП и ФП, case-класс является основной рабочей лошадкой в любом проекте по разработке программного обеспечения на Scala. В Scala он в основном разработан и предназначен (но не исключительно) для использования в качестве (иммутабельного) типа-произведения ФП.

К сожалению, DCCP (Default Case Class Pattern. Шаблон case-класса по умолчанию)...

case class Longitude(value: Double)

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

  1. Путаница с расширениями

  2. Повышенная сложность аргументации

  3. Скудный дизайн для ФП

  4. Будущий технический долг

  5. Уязвимости безопасности

  6. Влияние на производительность

Цель этой статьи — предложить несколько новых шаблонов, которые можно использовать для замены стандартных, которые предоставляет компилятор 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)

Scastie Сниппет

Чтобы лучше понять, как 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")
    }

Scastie Сниппет 

Эта реализация выбрасывает исключение при первом неудачном выполнении require.

При таком подходе есть три проблемы:

  1. Когда есть другие параметры, которые также должны быть валидированы, это потребует нескольких проходов для их проверки, хотя они могли бы быть корректно проверены еще в предыдущем проходе, если бы разрешалось возвращать несколько ошибок.

  2. Имплементация require заставляет клиента разбираться с исключениями (избегайте использования исключений для предполагаемых ошибок, как и обычно). В большинстве случаев, оверхед производительности инфраструктуры исключений (как нагрузка на процессор, так и давление на память и ее отток) является значительным и по существу неоптимизируемым (это оспаривается). И даже за рамками причин низкой производительности, имплементации ФП категорически предпочитают "ошибку по значению" в противоположность "ошибке по исключению".

  3. Это не позволяет клиенту "проверять предварительные условия" до инстанцирования. Такое смешение проблем препятствует возможностям для оптимизации, когда конструктор, и, следовательно, выделение памяти для экземпляра, никогда не вызывается, потому что уже известно, что предварительные условия не были выполнены.

Мы можем решить все перечисленные трудности одним махом, используя стандартный шаблон для разделения проблем. В данном случае это паттерн ООП "Фабрика" (он же 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)

Scastie Сниппет 

Несмотря на то, что паттерн уже внедрен, все еще остается брешь, когда клиент может просто использовать оператор new, чтобы обойти метод apply в объекте-компаньоне. Это можно исправить, пометив конструктор case-класса как private. Это выглядит следующим образом:

final case class Longitude private(value: Double)

Scastie Сниппет 

Похоже, что мы закончили, верно?

Упс! Векторы коварных атак на подходе!

Оказывается, есть еще два варианта конструкторов, сгенерированных компилятором, которые мы должны рассмотреть

  1. Метод readResolve — Поддерживает сгенерированный компилятором интерфейс Serializable. Это особенно опасно, поскольку он инстанцирует память для case-класса, а затем напрямую внедряет (возможно, вредоносное) десериализованное содержимое в память экземпляра. При этом полностью обходятся как метод apply, так и конструктор объекта. Таким образом, не происходит никакой проверки.

  2. Метод 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)
    }

Scastie Сниппет 

Если вы знаете, что 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)
    }

Scastie Сниппет 

Имплементация этого зачастую оказывает влияние на следующие категории обзора:

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

Scastie Сниппет 

Имплементация этого зачастую оказывает влияние на данную категорию обзора:

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

Scastie Сниппет 

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

Пожалуйста, используйте один из многих других доступных вариантов. В частности, изучите 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)
    }

Scastie Сниппет 

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

Как уже предлагалось ранее, пожалуйста, используйте один из многих других доступных вариантов, например ScalaCache.

Советы и рекомендации

Резюме

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


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

Комментарии (0)