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

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

Опираясь на систему типов

Используя наши знания о Scala и типах, мы можем ограничить ID базы данных конкретным специальным типом:

case class DatabaseId(value: String)

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

def retrieveRecord(id: DatabaseId): IO[User] = {
    // ...
}

Если мы позволим, Scala также может нам помочь с некоторыми другими вещами для этого DatabaseId, такими как автоматическое создание сериализаторов и десериализаторов JSON и даже автоматическая генерация тестовых данных специально для ID базы данных.

Но достаточно ли этого? Можем ли мы получить больше?

Байты до самого конца

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

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

Валидация данных

Когда мы создаем case класс, нам обязательно нужно проверять, что параметры конструктора валидны. Представьте, что мы моделируем количество товаров на складе; его можно было бы, следуя вполне очевидной логике, представить как Int или Long, но мы, скорее всего, не хотели бы, чтобы значения могли быть отрицательными или, возможно, исчислялись миллиардами или даже миллионами.

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

case class ProductCount(count: Int) {
    require(count >= 0 && count < 1_000_000, s"$count must not be negative and less than a million")
}

Но по-хорошему мы не хотели бы генерировать исключения во время выполнения! Вместо этого мы могли бы добавить флаг, указывающий, валидно ли это количество:

case class ProductCount(count: Int) {
    val isValid: Boolean = count >= 0 && count < 1_000_000
}

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

Проверка во время создания

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

С case классами компилятор сам пишет для нас много полезного шаблонного кода, позволяя нам рассуждать о нашем коде, не распыляясь на рутинные задачи. Компилятор добавляет интуитивно понятный метод equals, полезный toString, а также apply для упрощения создания и unapply для использования в сопоставлениях с шаблоном.

На возвращаемый тип apply никаких ограничений нет. Если мы напишем функцию с именем apply, компилятор не создаст ее для нас сам. Мы можем просто предоставить в объекте-компаньоне свой собственный вариант, который будет возвращать другой тип, и создавать объект в нем. Возвращаясь к нашему DatabaseId, предположим, что ID базы данных должен содержать ровно 12 символов:

case class DatabaseId(value: String)

object DatabaseId {
    def apply(value: String): Option[DatabaseId] = {
        if(value.length == 12) Some(new DatabaseId(value))
        else None
    }
}

Заметьте, что здесь у нас так и чешутся руки вызвать new DatabaseId, но если опустить new, то выполнение будет передано apply, а это именно та функция, которую мы пишем!

Какой бы полезной она не была, эту проверку все же можно обойти; ничто не мешает нам самим создавать case класс с помощью new.

// обе эти строки компилируются
val validated: Option[DatabaseId] = DatabaseId("0123456789ab")
val invalid: DatabaseId = new DatabaseId("not twelve chars")

Как бы мы ни доверяли нашим коллегам, как бы сильно ни верили, что они будут делать все правильно и всегда будут создавать объект так, как мы задумали, было бы лучше, если бы мы могли защититься от случайного вызова new. Или вы на 100% уверены, что в 100% случаев обнаружите это во время код-ревью? Мы можем сделать конструктор приватным. И пока мы еще находимся здесь, давайте заодно сделаем этот класс final, чтобы никто не мог создавать его производные классы и таким образом обходить нашу проверку:

final case class DatabaseId private (value: String)

Теперь мы просто не можем не использовать функцию apply, ведь значение, создаваемое с использованием new, больше не будет компилироваться.

Подчистим еще немного бэкдоров

Мы все еще можем создавать невалидные объекты, используя метод copy:

val validated: Option[DatabaseId] = DatabaseId("0123456789ab")
val invalid = validated.map(_.copy(value = "not valid"))

Итак, аналогично тому, что мы сделали с apply, мы предоставим свой собственный метод copy. На этот раз в самом классе, а не в объекте, поскольку мы вызываем copy уже для инстансов. И мы также сделаем его private, так как хотим указать, что просто нет никакой необходимости когда-либо вызывать этот метод:

final case class DatabaseId private (value: String) {
    private def copy: Unit = ()
}

Теперь у нас нет возможности создать DatabaseId в обход нашей проверки. Другие подходы, на которые мы, возможно, захотим обратить внимание, — это сделать приватным и метод apply, а затем предоставить более наглядный API для указания того, что вообще происходит. Полностью код теперь будет выглядеть так:

final case class DatabaseId private (value: String) {
    private def copy: Unit = ()
}

object DatabaseId {
    private def apply(value: String): Option[DatabaseId] = {
        if(value.length == 12) Some(new DatabaseId(value))
        else None
    }

    def fromString(value: String): Option[DatabaseId] = apply(value)
}

А значения будут создаваться так:

val validated: Option[DatabaseId] = DatabaseId.fromString("0123456789ab")

У нас все еще остались все преимущества работы с case классом, и при этом мы добавили себе уверенности в том, что все, что содержит наш case класс, будет валидно, а значит и полезно.

Создание литеральных значений

Теперь, когда мы не можем создать голый DatabaseId, может сложиться впечатление, что это выльется в серьезную головную боль при тестирования с литеральными значениями. Но на самом деле нам будет довольно легко работать, не теряя типобезопасности. Мы всеголишь провалим тест, если сконструируем недопустимое литеральное значение. Учитывая, что мы создаем его вручную, при написании теста это должно стать понятно очень быстро:

val testValue: DatabaseId = DatabaseId.fromString("0123456789ab").getOrElse(fail("Unable to construct database ID"))
// … здесь будет остальная часть теста, использующая testValue в качестве DatabaseId

Даже со Scalacheck мы можем быть уверены в создаваемых нами значениях:

val databaseIdGen: Gen[DatabaseId] = for {
    cs <- Gen.listOfN(12, Gen.hexChar)
    id <- DatabaseId.fromString(cs.mkString).fold(Gen.fail[DatabaseId])(Gen.const)
} yield id

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

Дополнительные шаги

Вместо Option, используйте Either и укажите, почему проверка пошла не так, как надо. Тип Either имеет полезную функцию, сочетающую логическую проверку с действиями как в истинном, так и в ложном случаях. Это даст пользователям кода наглядный API для того, чтобы создавать объекты и легко понимать, что может пойти не так, при этом сохраняя типобезопасность:

sealed trait IdError
case object BadLength extends IdError
case object InvalidCharacter extends IdError
// ... еще варианты ошибок валидации...

final case class DatabaseId private (value: String) {
    private def copy: Unit = ()
}

object DatabaseId {
    private def apply(value: String): Either[IdError, DatabaseId] =
        Either.cond(
            value.length == 12,
            new DatabaseId(value),
            BadLength
        )

    def fromString(value: String): Either[IdError, DatabaseId] = apply(value)
}

Это можно использовать с функционалом validated от Cats для объединения сразу нескольких проверок в функциональном стиле.

Пара слов в заключение

Когда Scala был представлен широкой публике, case классы рекламировались как хороший отход от шаблонных “POJO” подходов Java, позволяющий разработчику определять объект предметной области в одной строке и при этом получать все те же преимущества, что и их аналог в Java. Здесь нам все-таки нужно вернуть некоторую степень шаблонности, чтобы получить немного больше уверенности в том, что значения, которые мы создаем для конкретного типа, корректны. Я думаю, это оправданная цена: вся логика валидации в одном месте, вместо того, чтобы быть разбросанной по кодовой базе и надежд на то, что пользователи вашего типа используют ее правильно. Недопустимые типы просто невозможно создать — это хорошо, когда не нужно беспокоиться о недопустимых состояниях.


Приглашаем всех желающих на demo-занятие «REST API при помощи HTTP4S и ZIO». На примере построения простого веб сервиса с REST API разберем основные компоненты (пути, бизнес логика, доступ к данным, документация), а также посмотрим, как взаимодействуют такие функциональные библиотеки, как http4s, cats, zio в рамках одного приложения. Регистрация здесь.

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


  1. VanquisherWinbringer
    17.03.2022 23:02

    Оу, спасибо! Отличная статься. Сам давно использую умные кострукторы в том числе и в C# только там бросаю эксепшены в контсрукторе или в методе set свойства.