Уточняющие типы в Scala
Статья вышла в блоге компании "Криптонит" на хабре.
Ни один программист не застрахован от ситуации, в которой его программа принимает на вход невалидные значения. Будь то пользовательский ввод или сырой массив данных — неважно. Главное, что в Scala есть ряд приёмов, позволяющих минимизировать возможность появления таких ошибок. Одним из них является использование уточнённых типов данных. Они имеют огромное значение, когда речь идёт о надёжности кода, и часто могут использоваться без дополнительных тестов.
Уточнение даёт более строгие, ограниченные типы данных для использования во всём нашем коде. Во время синтаксического анализа они гарантируют, что ни одной переменной не будет присвоено невалидное (непригодное для дальнейшей обработки) значение. Эта идея хорошо разобрана здесь.
По умолчанию в этой статье все примеры разбираются в Scala 3 (версия 3.2.2
)
и большинство из них дублируются на Scala 2 (версия 2.13.10
).
Стандартные типы данных и «чистые» классы значений
Набор стандартных типов весьма ограничен и покрывает только самые распространенные ситуации.
Каждый стандартный тип данных - это достаточно общее множество значений и операций над этими значениями.
Возьмем для примера String
- строковый тип в Scala.
Какое множество значений он представляет?
Да все что угодно: практически все алфавиты мира и всевозможные символы,
абсолютное большинство которых никто никогда в процессе разработки не встречает. Например: "€‡™µ"
val str: String = "€‡™µ"
Вспомним фразу Ken Scambler, процитированную вот в этом видео: валиден ли китайский перевод книг Шекспира в качестве входящего параметра типа String
?!
В абсолютном большинстве случаев такая "свобода" не нужна.
Давайте, представим, что нам нужно создать множество, которое представляло бы собой всевозможные имена людей, написанные кириллицей и начинающиеся с заглавной буквы, для дальнейшего использования.
Например, следующий вариант позволителен - Алёна
,
а вот такие варианты - нет: €‡™µ
, 12345
, Alyona
, Алёна18
, алёна
.
Но все перечисленные невалидные варианты - это String
.
Получается, что этот тип описывает не только нужное нам множество, но ещё и множество невалидных вариантов,
что нас не устраивает.
Как можно более четко сформулировать необходимое множество значений?
Псевдонимы типов и "чистые" case классы (или классы значений)
не подходят, потому что они представляют собой только "оболочку" над String
,
и по-прежнему позволяют "подложить" невалидное значение.
opaque type Name = String
val name: Name = "€‡™µ"
// name: String = "€‡™µ"
Тот же пример в Scastie на Scala 2
case class Name(value: String)
val name: Name = Name("€‡™µ")
// name: Name = Name(value = "€‡™µ")
Тот же пример в Scastie на Scala 2
"Стандартное" решение - регулировать создание Name
путем ограничения видимости конструктора по умолчанию
и определения метода создания экземпляра Name
в сопутствующем объекте:
import scala.util.matching.Regex
final case class Name private (value: String)
object Name:
private val pattern: Regex = "[А-ЯЁ][а-яё]+".r
def fromString(str: String): Option[Name] =
if pattern.matches(str) then Some(Name(str))
else None
В этом случае нельзя создать невалидное имя напрямую, используя стандартный конструктор:
val name: Name = Name("€‡™µ") // не скомпилируется
А его использование происходит так, как было задумано:
Name.fromString("€‡™µ")
// res2: Option[Name] = None
Name.fromString("12345")
// res3: Option[Name] = None
Name.fromString("Alyona")
// res4: Option[Name] = None
Name.fromString("Алёна18")
// res5: Option[Name] = None
Name.fromString("алёна")
// res6: Option[Name] = None
Name.fromString("Алёна")
// res7: Option[Name] = Some(value = Name(value = "Алёна"))
В Scala 2 этот способ можно "взломать" через метод
copy
(в Scala 3 эту лазейку убрали):
Name.fromString("Алёна").map(_.copy("€‡™µ")) // Some(Name(€‡™µ))
Для запрета на использование метода
copy
или переопределения через наследование в Scala 2 требовалось объявлять класс какsealed abstract
, вот так:
sealed abstract case class Name private (value: String) extends AnyVal
Уточняющие типы
Ещё одним способом решения заданной проблемы могут стать библиотеки для работы с уточняющими типами:
В теории типов уточняющий тип (refinement type) — это тип, снабженный предикатом, который предполагается верным для любого элемента уточняемого типа. Например, натуральные числа больше 5 могут быть описаны так:
\(f: \mathbb{N} \to \{ n \in \mathbb{N} | n > 5 \}\)
Т.о. уточняющий тип - это базовый тип + предикат, а значения уточняющего типа - это все значения базового типа, удовлетворяющие определенному предикату.
Концепция уточняющих типов была впервые введена Фриманом и Пфеннингом в работе 1991 года "Уточняющие типы для ML", в которой представлена система типов для языка Standard ML.
Самая идея выражения ограничений на уровне типов в виде библиотеки Scala была впервые исследована Flavio W. Brasil в библиотеке bond.
И довольно сильно усовершенствована в библиотеке refined, которая начиналась как переработка библиотеки на Haskell Никиты Волкова.
Библиотека iron - это дальнейшее развитие идеи уточненных типов в Scala 3.
Уточнение - это достаточно распространенная и естественная процедура в программировании.
Достаточно взглянуть на примитивные типы в Scala:
Long
(от \(-2^{63}\) до \(2^{63} - 1\)) ->
Int
(от \(-2^{31}\) до \(2^{31} - 1\)) ->
Short
(от \(-2^{15}\) до \(2^{15} - 1\)) ->
Byte
(от \(-2^{7}\) до \(2^{7} - 1\))
Каждый следующий тип в этом списке уточняет предыдущий.
Знакомство с библиотеками iron и refined
Давайте рассмотрим решение исходной задачки с помощью iron (Scala 3) и refined (Scala 2).
Вот так можно объявить уточненный тип с помощью iron:
import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.string.*
opaque type Name = String :| Match["[А-ЯЁ][а-яё]+"]
А вот так - с помощью refined:
import eu.timepit.refined.api.Refined
import eu.timepit.refined.string.MatchesRegex
type Name = String Refined MatchesRegex["[А-ЯЁ][а-яё]+"]
Явное присваивание невалидного значения вызовет ошибку компиляции:
val name0: Name = "€‡™µ" // Ошибка компиляции: Should match [А-ЯЁ][а-яё]+
val name1: Name = "12345" // Ошибка компиляции: Should match [А-ЯЁ][а-яё]+
val name2: Name = "Alyona" // Ошибка компиляции: Should match [А-ЯЁ][а-яё]+
val name3: Name = "Алёна18" // Ошибка компиляции: Should match [А-ЯЁ][а-яё]+
val name4: Name = "алёна" // Ошибка компиляции: Should match [А-ЯЁ][а-яё]+
val name5: Name = "Алёна" // Компиляция проходит успешно
Тот же пример в Scastie на Scala 2 для refined
Библиотеки уточнения также позволяют преобразовывать базовое значение для более удобной работы
в Option[T]
(refineOption
для iron / unapply
в refined):
val name0: Option[Name] = "€‡™µ".refineOption // None
val name1: Option[Name] = "12345".refineOption // None
val name2: Option[Name] = "Alyona".refineOption // None
val name3: Option[Name] = "Алёна18".refineOption // None
val name4: Option[Name] = "алёна".refineOption // None
val name5: Option[Name] = "Алёна".refineOption // Some("Алёна")
Тот же пример в Scastie на Scala 2 для refined
и в Either[String, T]
,
где слева будет ошибка валидации, (refineEither
для iron / from
в refined):
val name0: Either[String, Name] = "€‡™µ".refineEither
// Left("Should match [А-ЯЁ][а-яё]+")
val name1: Either[String, Name] = "12345".refineEither
// Left("Should match [А-ЯЁ][а-яё]+")
val name2: Either[String, Name] = "Alyona".refineEither
// Left("Should match [А-ЯЁ][а-яё]+")
val name3: Either[String, Name] = "Алёна18".refineEither
// Left("Should match [А-ЯЁ][а-яё]+")
val name4: Either[String, Name] = "алёна".refineEither
// Left("Should match [А-ЯЁ][а-яё]+")
val name5: Either[String, Name] = "Алёна".refineEither
// Right("Алёна")
Тот же пример в Scastie на Scala 2 для refined
Предопределенные типы
У библиотек достаточно большой набор предопределенных типов:
Вот несколько примеров использования библиотек:
А в чем разница?
Здесь может возникнуть резонный вопрос: у нас есть два способа определения уточненного типа:
- "стандартный" ---
case class Name private (value: String) extends AnyVal
- через библиотеку refined ---
type Name = String Refined MatchesRegex["[А-ЯЁ][а-яё]+"]
А в чем принципиальная разница между этими двумя способами? Только лишь в удобстве справочника предопределенных типов?
Система типов
Важным преимуществом библиотеки refined является то, что "типы врать не могут".
При использовании case класса значение имени - это по-прежнему строка:
Name.fromString("Алёна").get.value
// val res0: String = Алёна
Мы опять получили слишком "широкое" множество значений.
И дальнейшее использование Name.value
в коде возможно только в качестве String
.
При этом отброшена потенциально полезная информация о том, какая это строка.
Уточняющий тип же - это конкретный тип:
val name: Name = "Алёна"
// val name: Name = Алёна
И дальше по коду его можно использовать именно в качестве типа.
Уточненный тип расширяет базовый (в данном случае - String
)
и его можно использовать там, где ожидается дочерний для базового тип:
val name: Name = "Алёна"
def printT[T >: String](t: T): Unit = println(t)
printT(name) // Печатает "Алёна"
Есть очень хорошая статья по поводу важности системы типов.
Проверка во время компиляции
Ещё одним значительным преимуществом является возможность проверки типов во время компиляции.
Как уже было рассмотрено выше:
val name0: Name = "€‡™µ" // Ошибка компиляции: Should match [А-ЯЁ][а-яё]+
val name1: Name = "12345" // Ошибка компиляции: Should match [А-ЯЁ][а-яё]+
val name2: Name = "Alyona" // Ошибка компиляции: Should match [А-ЯЁ][а-яё]+
val name3: Name = "Алёна18" // Ошибка компиляции: Should match [А-ЯЁ][а-яё]+
val name4: Name = "алёна" // Ошибка компиляции: Should match [А-ЯЁ][а-яё]+
val name5: Name = "Алёна" // Компиляция проходит успешно
Скомпилируется только последний вариант, потому что строка "Алёна"
удовлетворяет предикату уточненного типа.
Тот же пример в Scastie на Scala 2 для refined
Проверка во время компиляции открывает довольно обширные возможности: как минимум, значительную часть проверок можно переложить с модульных тестов на компилятор. Что в свою очередь может сэкономить общее время разработки. По этому поводу написана отличная статья.
Уточнение произвольного типа
Уточняющий тип можно создать для любого типа.
Допустим у нас есть тип и некий предикат для значений заданного типа:
opaque type Packed = Any
val predicate: Packed => Boolean =
case str: String => Option(str).exists(_.trim.nonEmpty)
case num: Int => num > 0
case _ => false
Уточняющий тип NonEmpty
для Packed
можно определить по предикату:
import io.github.iltotore.iron.{given, *}
import io.github.iltotore.iron.constraint.all.*
final class NonEmpty
given Constraint[Packed, NonEmpty] with
override inline def test(value: Packed): Boolean = predicate(value)
override inline def message: String = "Should be non empty"
Здесь в методе test
определяется предикат, который предполагается верным для всех значений заданного типа.
Метод message
определяет сообщение об ошибке, если переданное значение не удовлетворяет предикату.
Пример использования уточняющего типа для Packed
:
(null: Packed).refineEither[NonEmpty] // Left(Should be non empty)
("": Packed).refineEither[NonEmpty] // Left(Should be non empty)
(" ": Packed).refineEither[NonEmpty] // Left(Should be non empty)
(" ": Packed).refineEither[NonEmpty] // Left(Should be non empty)
(0: Packed).refineEither[NonEmpty] // Left(Should be non empty)
(-42: Packed).refineEither[NonEmpty] // Left(Should be non empty)
(true: Packed).refineEither[NonEmpty] // Left(Should be non empty)
("value": Packed).refineEither[NonEmpty] // Right(value)
(42: Packed).refineEither[NonEmpty] // Right(42)
Тот же пример в Scastie на Scala 2 для refined
Уточнить можно любой тип, в том числе уточненный - в этом случае он становится базовым для другого типа, который будет его "уточнять".
В библиотеке iron уточнение уточненного типа
равносильно использованию типа A & B
- коньюнкции предикатов A
и B
.
В библиотеке refined - And[A, B]
.
Тот же пример в Scastie на Scala 2 для refined
Предельным непустым уточненным типом является литеральный тип, добавленный в версии Scala 2.13.
Тот же пример в Scastie на Scala 2
Накопление ошибок валидации
Вариант использования refineEither
, рассмотренный выше, довольно прост,
и с ним часто сталкиваются разработчики: получение данных от некоего входящего потока.
Но прерывание процесса на первой обнаруженной ошибке нежелательно.
Ведь на ошибки можно быстро реагировать и решать проблемы входящего потока пачками.
По этой причине просто монадическая композиция цепочек Either
не подходит.
Другими словами, "ориентированная на железную дорогу" проверка,
которая останавливается на ошибочном предикате, будет недостаточной.
Давайте рассмотрим проблему накопления ошибок синтаксического анализа/валидации. Нежелательно связывать этапы проверки одного сообщения с другими монадическими способами, потому что этот способ возвращал бы только первую из них. Вместо этого было бы идеально, если бы эти шаги находились на одном уровне. Надо сохранять все ошибки валидации, а затем агрегировать результат либо в желаемую форму, если все прошло хорошо, либо в список ошибок, если хотя бы один шаг не пройден.
Это именно то, что можно сделать с ValidatedNec
из библиотеки cats.
К счастью, iron предоставляет расширение iron-cats,
которое позволяет возвращать шаги проверки ValidatedNec[String, A]
вместо Either[String, A]
:
Для библиотеки refined есть аналогичное расширение refined-cats:
import cats.data.ValidatedNec
import cats.syntax.all.*
import io.github.iltotore.iron.*
import io.github.iltotore.iron.cats.*
import io.github.iltotore.iron.constraint.all.*
import java.util.UUID
opaque type Name = String :| Match["[А-ЯЁ][а-яё]+"]
opaque type Age = Int :| Interval.Open[7, 77]
opaque type Id = String :| ValidUUID
final case class Person(name: Name, age: Age, id: Id)
object Person:
def refine(name: String, age: Int, id: String): ValidatedNec[String, Person] =
(
name.refineValidatedNec[Match["[А-ЯЁ][а-яё]+"]],
age.refineValidatedNec[Interval.Open[7, 77]],
id.refineValidatedNec[ValidUUID]
).mapN(Person.apply)
Метод Person.refine
делает именно то, что нужно:
применяет все предикаты к входным данным для их проверки,
а также возвращает более конкретные типы, с которыми можно более безопасно работать в будущем:
Person.refine("Андрей", 50, UUID.randomUUID().toString)
// Valid(Person(Андрей,50,fccec68b-cefd-45e8-ae57-b8cdd3fa3cb8))
А так как мы используем Applicative,
то всегда будут выполняться все этапы "уточнения",
и в случае неудачи некоторых из них их ошибки будут накапливаться в NonEmptyChain
:
Person.refine("Andrew", 150, "id")
// Invalid(Chain(
// "Should match [А-ЯЁ][а-яё]+",
// "Should be included in (7, 77)",
// "Should be an UUID"
// ))
Тот же пример в Scastie на Scala 2 для refined
Итоги обзора библиотек уточенных типов iron и refined
Подведем краткие итоги обзора библиотек iron и refined:
-
К основным преимуществам библиотек относится:
- Система типов
- Отлов ошибок во время компиляции
- Единая декларативная валидация
-
К недостаткам:
-
При использовании нотации инфиксного типа необходимо проявлять осторожность
и использовать скобки или неинфиксное определение типа (только у refined):
String Refined XXX And YYYString Refined And[XXX, YYY]
String Refined (XXX And YYY)
- Сообщения об ошибках валидации не всегда понятны
-
При использовании нотации инфиксного типа необходимо проявлять осторожность
и использовать скобки или неинфиксное определение типа (только у refined):
- У библиотек iron и refined есть альтернативы:
- Интеграция с другими библиотеками:
Границы применимости
Методов борьбы с ошибками в программном обеспечении очень много ввиду критичности проблемы. Тестирование программного обеспечения является важным (если не самым важным) этапом выпуска продукта. Почти в каждой компании есть выделенное подразделение QA, порой по численности, знаниям и компетентности не уступающее подразделению разработки. Кодовая база тестов иногда превышает тестируемый код.
Уточнённые типы — безусловно не панацея, но ещё один способ повысить качество выпускаемого продукта. Однако необходимо помнить, что больше — не всегда лучше, когда дело доходит до набора текста. Иногда лучше оставить универсальный тип или обобщить его до чего-то более простого, когда нижестоящая логика на самом деле не требует уточнения этого типа.
В противном случае ваша логика становится тесно связанной с тем, что вы делаете в данный момент, и вы теряете возможность повторного использования, которая так дорога функциональным программистам.
Эмпирическое правило таково: всегда кодируйте то, что нужно вашей логике, а не только то, что вы можете. Да, и не бойтесь ослаблять ограничения в дальнейшем!
Ссылки (в алфавитном порядке):
- iron lib
- refined lib
-
Видео:
- Better types = fewer tests - Raúl Raja
- Combining Refined Types with Type Class Derivation in Scala - Lawrence Carvalho
- Decorate your types with refined - Frank Thomas
- Defusing the configuration time bomb with PureConfig and Refined - Leif Wickland
- Enhancing the type system with Refined Types - Juliano Alves
- How to Build a Functional API - Julien Truffaut
- Getting Started with #refined - DevInsideYou
- Let The Compiler Help You: How To Make The Most Of Scala’s Typesystem - Markus Hauck
- Literal types, what they are good for? - Tamer Abdulradi
- Refined types for validated configurations – Viktor Lövgren
- Refined types in Scala - Rock the JVM
- Refinement Types - Tipagem ainda mais forte para Scala - Marcelo Gomes
- Security with Scala: Refined Types and Object Capabilities - Will Sargent
- Strings are Evil: Methods to hide the use of primitive types - Noel Welsh
- Why types matter - Gabriel Volpe
-
Статьи:
- A simple trick to improve type safety of your Scala code - Marcin Kubala
- How we used Refined to improve type safety and error reporting in Scala - Bertrand Junqua
- Lightweight Non-Negative Numerics for Better Scala Type Signatures - Erik Erlandson
- On Eliminating Error in Distributed Software Systems - Colin Breck
- Parse, don’t validate - Alexis King
- Refined types in Scala - Daniel Ciocîrlan
- Refined types in Scala: the Good, the Bad and the Ugly - Manuel Rodríguez
- Refined types, what are they good for? - Malcolm
- Refined типы в Scala - hakain
- Refinement types in practice - Peter Mortier
- Refining your data from configuration to database - Pere Villega
- Safe, Expressive Code with Refinement Types - Gordon Rennie
- Tests - can we have too many? - Jack Low
- Type safety with Iron - Michał Pawlik
- Type safety with refined - Michał Pawlik
- Validate Service Configuration in Scala - Alexey Novakov
- Wtf is Refined? - Methrat0n