В число огромных преимуществ использования Scala входит безопасность типов. Если мы четко и внимательно относимся к используемым нами типам, компилятор способен направить нас в правильном направлении и указать, где мы можем ошибиться.
Существуют способы, с помощью которых мы можем положиться на систему типов и язык в целом, для большей уверенности в создаваемом коде.
Опираясь на систему типов
Используя наши знания о Scala и типах, мы можем ограничить идентификатор базы данных определенным типом:
case class DatabaseId(value: String)
Теперь, когда у нас есть явный признак для функции, требующей идентификатор базы данных, он будет четко обозначен, чтобы отличаться от произвольной строки. В такую функцию трудно по ошибке передать строку, предназначенную для чего-то другого.
def retrieveRecord(id: DatabaseId): IO[User] = {
// ...
}
Мы также можем попросить Scala помочь с другими вещами для этого DatabaseId, например, автоматически генерировать сериализаторы и десериализаторы JSON, и даже автоматически генерировать тестовые данные специально для Database ID.
Этого достаточно? Можем ли мы пойти дальше?
Байты на каждом шагу
Что бы ни делало наше приложение, существует некая граница между нашим кодом и внешним миром, будь то программа, зависящая от аргументов командной строки, или HTTP-сервис, принимающий POST-данные. Это не особенность Scala; у каждого приложения есть общая черта: взаимодействие с внешним миром заключается в приеме и отправке байтов.
Внутри нашего приложения мы хотим преобразовать эти байты во что-то валидное. Языки высокого уровня уже частично делают это, предоставляя примитивные типы, например, целые числа, числа с плавающей точкой и строки. Естественно, мы хотим иметь больше знаний о том, что представляют собой эти значения. Отличным первым шагом в Scala является обертывание данных байтов в кейс-классы. Но как мы узнаем, что данные внутри этих кейс-классов корректны?
Проверка достоверности данных
Когда мы создаем кейс-класс, то хотим убедиться, что параметры конструктора валидны. Предположим, мы моделируем количество товаров на складе; понятно, что оно может быть представлено в виде 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
всякий раз, когда возникнет необходимость. Если тот забудет или сделает ошибку в логике, то компилятор не поможет.
Валидация на этапе конструирования
Забавно, но мы были ближе к приемлемому решению с подходом require
для проверки рантайма, чем с нашим булевым флагом: при создании экземпляра взамен мы даем пользователю другой объект, который может отобразить недопустимую конструкцию.
С кейс-классами компилятор свободно пишет для нас множество дополнительных шаблонов, предоставляя возможность без лишних помех обдумывать код. Компилятор добавляет интуитивно понятный метод equals
, toString
, что имеет смысл, а также apply
для простоты построения и unapply
, для сопоставления с образцом.
На возвращаемый тип метода apply
не накладывается никаких ограничений. Если мы сами предоставим функцию apply
, то компилятор не станет ее создавать. Мы можем просто предоставить собственную функцию в объекте-компаньоне, возвращающую другой тип, и осуществить создание объекта там. Возвращаясь к нашему классу DatabaseId
, предположим, что идентификатор базы данных должен содержать ровно 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
, а это именно та функция, которую мы пишем!
Как бы полезно это ни было, все равно можно обойти такую проверку; ничто не мешает нам самим сконструировать кейс-класс с new
.
// both these lines compile
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
. На этот раз для класса, а не для объекта, поскольку мы вызываем копирование для экземпляров. И мы сделаем его 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")
У нас остаются все преимущества работы с кейс-классом, но при этом добавляется уверенность в том, что все то, что содержит наш кейс-класс, полезно и правильно.
Конструирование литералов
Теперь, когда мы не можем сконструировать чистый DatabaseId
, может показаться, что тестирование с литералами будет головной болью. Но на самом деле это легко сделать без ущерба для безопасности типа. Мы просто не пройдем тест, если сконструируем недопустимое литеральное значение. Учитывая, что мы создаем все вручную, это становится понятным очень быстро при написания теста:
val testValue: DatabaseId = DatabaseId.fromString("0123456789ab").getOrElse(fail("Unable to construct database ID"))
// ... the rest of the test here, using testValue as a 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
Просто завершите работу генератора при недопустимом состоянии, и он повторит попытку. Теперь, когда мы используем это в наших тестах, то получаем DatabaseIds
без отвлекающей Option
рядом.
Еще несколько шагов
Вместо Option
используйте Either
и укажите, почему валидация прошла неправильно. Тип Either
имеет полезную функцию, объединяющую проверку булевых значений с указанием того, что делать в случаях true и false. Благодаря этому для пользователей кода предоставляется удобный API с информацией о том, как конструировать объекты и что может пойти не так, оставаясь при этом безопасным для типов:
sealed trait IdError
case object BadLength extends IdError
case object InvalidCharacter extends IdError
// ... more validation error cases here...
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)
}
Это может быть использовано вместе с валидированной функциональностью из Cats для объединения нескольких проверок валидности в рамках рабочего формата.
Заключительные слова
Когда Scala только появился, кейс-классы преподносились как приятное отступление от перегруженных бойлерплейтами методик Java "POJO", позволяя разработчику определить объект домена в одной строке и при этом получить все те же преимущества, что и их аналоги в Java. Здесь нам приходится заново вводить некоторые шаблоны, чтобы получить немного больше уверенности в том, что значения, которые мы создаем для этого типа, верны. Считаю, что это приемлемая цена: у вас есть вся логика проверки в одном месте, а не разбросанная по кодовой базе без уверенности в том, что пользователи вашего типа используют ее правильно. Создать невалидные типы просто невозможно — вы находитесь в завидном положении, поскольку не способны представлять недопустимые состояния.
Всех желающих приглашаем на открытое занятие «Функциональные конструкции языка Scala». На уроке рассмотрим неизменяемые структуры данных, а также рекурсивные методы для итерирования с состоянием. Узнаем, как использовать функции и функции высших порядков. Результатом занятия станут несколько простых алгоритмов с использованием рекурсивных методов и функций. Регистрация на урок.
hakain
Чем эта статья отличается от этой? Вы бы хоть перепроверили чтоли перед тем как опубликовать.