Уточняющие типы в 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

Тот же пример в Scastie на Scala 2

case class Name(value: String)
val name: Name = Name("€‡™µ")
// name: Name = Name(value = "€‡™µ")

Пример в Scastie

Тот же пример в 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 = "Алёна"))   

Пример в Scastie

В 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

Пример "взлома" через copy в Scala 2 на Scastie

Уточняющие типы

Ещё одним способом решения заданной проблемы могут стать библиотеки для работы с уточняющими типами:

В теории типов уточняющий тип (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 для iron

Тот же пример в 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 для iron

Тот же пример в 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 для iron

Тот же пример в Scastie на Scala 2 для refined

Предопределенные типы

У библиотек достаточно большой набор предопределенных типов:

Вот несколько примеров использования библиотек:

А в чем разница?

Здесь может возникнуть резонный вопрос: у нас есть два способа определения уточненного типа:

А в чем принципиальная разница между этими двумя способами? Только лишь в удобстве справочника предопределенных типов?

Система типов

Важным преимуществом библиотеки 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 для iron

Тот же пример в 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 для iron

Тот же пример в Scastie на Scala 2 для refined

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

В библиотеке iron уточнение уточненного типа равносильно использованию типа A & B - коньюнкции предикатов A и B.

В библиотеке refined - And[A, B].

Пример в Scastie для iron

Тот же пример в Scastie на Scala 2 для refined

Предельным непустым уточненным типом является литеральный тип, добавленный в версии Scala 2.13.

Пример в Scastie

Тот же пример в 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 для iron

Тот же пример в Scastie на Scala 2 для refined

Итоги обзора библиотек уточенных типов iron и refined

Подведем краткие итоги обзора библиотек iron и refined:

Границы применимости

Методов борьбы с ошибками в программном обеспечении очень много ввиду критичности проблемы. Тестирование программного обеспечения является важным (если не самым важным) этапом выпуска продукта. Почти в каждой компании есть выделенное подразделение QA, порой по численности, знаниям и компетентности не уступающее подразделению разработки. Кодовая база тестов иногда превышает тестируемый код.

Уточнённые типы — безусловно не панацея, но ещё один способ повысить качество выпускаемого продукта. Однако необходимо помнить, что больше — не всегда лучше, когда дело доходит до набора текста. Иногда лучше оставить универсальный тип или обобщить его до чего-то более простого, когда нижестоящая логика на самом деле не требует уточнения этого типа.

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

Эмпирическое правило таково: всегда кодируйте то, что нужно вашей логике, а не только то, что вы можете. Да, и не бойтесь ослаблять ограничения в дальнейшем!


Ссылки (в алфавитном порядке):