Типы данных и паттерн-матчинг
Дальше: Справочник Scala · Основы · операции с коллекциями — сводка ниже
Типы данных и паттерн-матчинг
Базовая иерархия Any/Nothing и примеры Option/Either также разобраны в основах. Здесь — углубление, ADT, union/intersection и типы как документация API.
Сводка операций с коллекциями
Неизменяемые List / Map / Set возвращают новую структуру; изменяемые — mutable.* (те же имена методов).
| Тип | Добавить | Прочитать | Заменить | Удалить |
|---|---|---|---|---|
List | :+, +: | list(i) | обновление через updated(i, v) | filterNot, пересборка |
mutable.ArrayBuffer | +=, append | apply(i) | update(i, v) | remove(i) |
Map | + (key -> value) | map(key) | + (key -> value) | - key |
Set | + elem | contains | — | - elem |
Иерархия типов
В основе системы типов Scala лежит единая корневая точка — тип Any. Все остальные типы являются его подтипами. Это означает, что любое значение в Scala, будь то число, строка, функция или объект, совместимо с типом Any.
Тип Any разделяется на две основные ветви: AnyVal и AnyRef.
-
AnyValпредставляет собой базовый тип для всех значений примитивных типов. Эти типы не являются полноценными объектами в смысле классической объектной модели, а скорее соответствуют машинным представлениям данных, таким как целые числа, символы или логические значения. Компилятор Scala стремится отображать их напрямую на соответствующие примитивы JVM (Java Virtual Machine), что делает работу с ними быстрой и эффективной по памяти. -
AnyRefявляется базовым типом для всех ссылочных типов. Он эквивалентен типуjava.lang.Objectв Java. Любой пользовательский класс, массив, строка или функция в Scala наследуется отAnyRef. Это означает, что все такие значения хранятся в куче (heap) и передаются по ссылке.
Такая двойственность позволяет Scala сочетать эффективность работы с примитивами и гибкость объектной модели без необходимости в специальных обёртках, как это реализовано в некоторых других языках.
Play ITЗагрузка интерактивного демо…
Примитивные типы (AnyVal)
Класс AnyVal имеет девять прямых подтипов, каждый из которых соответствует конкретному примитивному типу:
Double— 64-битное число с плавающей запятой, соответствуетdoubleв Java.Float— 32-битное число с плавающей запятой, соответствуетfloatв Java.Long— 64-битное целое число со знаком, соответствуетlongв Java.Int— 32-битное целое число со знаком, соответствуетintв Java.Short— 16-битное целое число со знаком, соответствуетshortв Java.Byte— 8-битное целое число со знаком, соответствуетbyteв Java.Char— 16-битный символ в кодировке UTF-16, соответствуетcharв Java.Boolean— логическое значение, принимающее одно из двух состояний:trueилиfalse.Unit— специальный тип, имеющий ровно одно значение, обозначаемое как(). Он используется для обозначения отсутствия полезного результата, аналогичноvoidв других языках. Однако в отличие отvoid,Unitявляется полноценным типом, и его значение может быть использовано в выражениях.
Эти типы не требуют явного создания через оператор new. Они создаются автоматически при присваивании литералов или выполнении операций. Например, запись val x = 42 автоматически выводит тип x как Int.
Play ITЗагрузка интерактивного демо…
Ссылочные типы (AnyRef)
Все типы, не входящие в AnyVal, являются подтипами AnyRef. К ним относятся:
- Строки (
String) — неизменяемые последовательности символов, унаследованные от Java. - Массивы (
Array[T]) — контейнеры фиксированного размера, индексируемые целыми числами. - Функции — в Scala функции являются объектами первого класса. Они представлены трейтами
Function0,Function1, ...,Function22, где цифра указывает максимальное количество параметров, поддерживаемых стандартной библиотекой. - Пользовательские классы и трейты — любые определённые программистом структуры данных, включая case-классы, объекты-компаньоны, абстрактные классы и т. д.
Все эти типы имеют общие методы, унаследованные от AnyRef, такие как equals, hashCode, toString, getClass, а также методы для синхронизации — wait, notify, notifyAll.
Тип Nothing и Null
В дополнение к основной иерархии, Scala включает два специальных типа, которые находятся в нижней части дерева наследования:
-
Nothing— это подтип любого другого типа. Он не имеет значений. ТипNothingиспользуется для обозначения выражений, которые никогда не возвращают управление, например, вызовов, выбрасывающих исключение или завершающих программу. ПосколькуNothingявляется подтипом всех типов, его можно использовать в любом контексте, где ожидается значение, но фактически такого значения не будет. Это позволяет сохранять типовую согласованность даже в ситуациях аварийного завершения. -
Null— это подтип всех ссылочных типов (AnyRef), но не примитивных (AnyVal). Он имеет единственное значение —null. Этот тип существует в основном для совместимости с Java, гдеnullшироко используется. В чистом Scala-коде рекомендуется избегатьnullв пользу более безопасных альтернатив, таких какOption.
Параметризованные типы
Scala поддерживает обобщённое программирование через параметризованные типы. Это позволяет создавать структуры данных и алгоритмы, работающие с произвольными типами, сохраняя при этом строгую типизацию. Например, тип List[Int] обозначает список целых чисел, а List[String] — список строк. Компилятор гарантирует, что в список целых нельзя случайно поместить строку.
Параметризация применяется не только к коллекциям, но и к функциям, классам, трейтам. Она лежит в основе многих идиоматических конструкций Scala, таких как Option[T], Either[L, R], Try[T], которые используются для безопасной обработки неопределённых или ошибочных ситуаций.
val ids: List[Int] = List(1, 2, 3)
val names: List[String] = List("Ada", "Grace")
// ids :+ "x" // ошибка компиляции: List[Int] не принимает String
val moreIds: List[Int] = ids :+ 4
Разбор:
List[Int]иList[String]— разные конкретные типы одного конструктораList.- Компилятор не даст смешать
IntиStringв одном списке. :+добавляет элемент в конец и возвращает новый список, исходный не меняется.- Так параметризация ловит ошибки данных ещё до запуска программы.
Типы-объединения и пересечения (Union и Intersection Types)
Начиная с версии Scala 3, язык получил встроенную поддержку типов-объединений (A | B) и типов-пересечений (A & B).
- Тип-объединение
A | Bозначает, что значение может быть либо типаA, либо типаB. Это мощный инструмент для описания вариативности без необходимости вводить общий супертип или использовать наследование:
def describe(x: String | Int): String = x match
case s: String => s"строка: $s"
case i: Int => s"число: $i"
Разбор:
-
Тип
String | Intзадаёт union-контракт: функция принимает один из двух вариантов. -
x matchвыполняет разбор фактического типа и выбирает подходящую ветку. -
case s: Stringиcase i: Intодновременно проверяют тип и связывают значение с переменной. -
Интерполяция
s"..."формирует человекочитаемый результат с данными входа. -
Тип-пересечение
A & Bозначает, что значение одновременно удовлетворяет обоим типамAиB. Это полезно, когда требуется комбинировать поведение нескольких трейтов или интерфейсов. Например, если есть трейтыReadableиWritable, то типReadable & Writableописывает объект, который поддерживает оба этих контракта.
Эти типы расширяют выразительность системы типов, позволяя формулировать более точные и гибкие контракты между компонентами программы.
Типы высших порядков
Scala поддерживает типы высших порядков — типы, параметризованные другими типами. Например, List сам по себе не является конкретным типом, а представляет собой конструктор типов, который становится конкретным только после применения к параметру, например, List[Int].
Это позволяет писать абстрактные алгоритмы, работающие с любыми контейнерами, удовлетворяющими определённым требованиям. Такие возможности активно используются в библиотеках, реализующих функциональные паттерны, такие как Functor, Monad, Applicative.
Play ITЗагрузка интерактивного демо…
Вывод типов
Одной из важных особенностей Scala является мощная система вывода типов. Программисту часто не требуется указывать тип явно — компилятор способен определить его автоматически на основе контекста. Например, в выражении val name = "Scala" компилятор выводит тип name как String. Это уменьшает многословность кода, сохраняя при этом всю строгость статической типизации.
Вывод типов работает не только для простых значений, но и для сложных выражений, включая функции, цепочки вызовов и параметризованные типы. Однако в некоторых случаях, особенно при работе с обобщёнными структурам данными или неоднозначными контекстами, явное указание типа остаётся необходимым для разрешения неопределённости.
val title = "Scala" // String
val count = 3 // Int
val labels = List("a", "b") // List[String]
def pick[T](xs: List[T], index: Int): Option[T] =
if (index >= 0 && index < xs.length) Some(xs(index)) else None
Разбор:
- Первые три
valполучают типы без явных аннотаций. pick— обобщённая функция: тип результата зависит от типа списка.Some(xs(index))возвращаетOption[T],None— тожеOption[T].- Явная аннотация нужна реже, но в публичном API её часто оставляют для читаемости.
Play ITЗагрузка интерактивного демо…
Пользовательские типы и case-классы
В Scala программист может определять собственные типы с помощью ключевых слов class, case class, object и trait. Эти конструкции позволяют моделировать сложные доменные понятия с высокой точностью и выразительностью.
Особое место среди пользовательских типов занимают case-классы. Они предназначены для представления неизменяемых структур данных и автоматически предоставляют набор полезных методов:
- Все поля объявляются как
valпо умолчанию, то есть неизменяемы. - Компилятор генерирует реализации методов
equals,hashCodeиtoString, что делает сравнение и отладку интуитивными. - Поддерживается сопоставление с образцом (pattern matching), что позволяет декомпозировать объект на составляющие части.
- Автоматически создаётся объект-компаньон с методом
apply, позволяющим создавать экземпляры без ключевого словаnew.
Пример:
case class Person(name: String, age: Int)
val p = Person("Alice", 30) // эквивалентно Person.apply("Alice", 30)
Разбор:
case classавтоматически генерирует конструктор,equals,hashCode,toStringиcopy.- Вызов
Person("Alice", 30)идёт через сгенерированныйapplyв компаньоне. - Поля
nameиageстановятсяvalпо умолчанию, что делает модель неизменяемой. - Такой тип особенно удобен для DTO и pattern matching.
Case-классы идеально подходят для функционального стиля, где данные передаются явно, а изменение состояния заменяется созданием новых значений.
Обновление через copy без мутации:
case class User(id: Long, name: String, active: Boolean = true)
val u1 = User(1, "Ada")
val u2 = u1.copy(name = "Ada Lovelace", active = false)
Разбор:
copyгенерируется автоматически для case-класса.- Можно менять только нужные поля, остальные копируются из исходного объекта.
u1остаётся прежним,u2— новое значение.- Так моделируют изменение состояния в иммутабельном стиле.
Алгебраические типы данных (ADT)
Алгебраические типы данных — это мощный способ моделирования всех возможных состояний программы. В Scala они строятся с использованием sealed-иерархий и case-классов (или case-объектов для единичных значений).
Ключевая идея — если все подтипы перечислены в одном файле с помощью модификатора sealed, компилятор может проверить, что при сопоставлении с образцом обработаны все возможные случаи. Это исключает ошибки времени выполнения из-за неполного анализа вариантов.
Пример:
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
case object Empty extends Shape
Разбор:
sealed trait Shapeограничивает набор подтипов текущим файлом.case class-варианты описывают состояния с данными (radius,width,height).case object Emptyзадаёт singleton-вариант без параметров.- Вместе это полноценный ADT, где компилятор может проверять исчерпываемость
match.
Такая иерархия описывает форму как либо круг, либо прямоугольник, либо пустую фигуру. Любой код, работающий с Shape, обязан учитывать все три варианта. При включённом предупреждении о неполном match компилятор укажет на пропущенные ветки:
def area(s: Shape): Double = s match
case Circle(r) => math.Pi * r * r
case Rectangle(w, h) => w * h
// case Empty => 0.0 // без этой ветки — предупреждение
Разбор:
- Функция разбирает
Shapeчерезmatchи вычисляет площадь по конкретному варианту. - Ветка
Circle(r)извлекает радиус и применяет формулу круга. - Ветка
Rectangle(w, h)извлекает ширину/высоту и умножает их. - Отсутствие
case Emptyприводит к предупреждению о неполном сопоставлении. - Такая проверка снижает риск пропущенных состояний при развитии модели.
Этот подход особенно ценен при проектировании бизнес-логики, где важно явно выразить все возможные состояния системы — например, "заказ создан", "оплачен", "отменён", "доставлен".
Обработка ошибок через типы
Scala отказывается от традиционных исключений в пользу типобезопасных обёрток. Это делает ошибки частью сигнатуры функции и заставляет вызывающую сторону обрабатывать их явно.
Основные типы для обработки ошибок:
-
Option[T]— представляет значение, которое может отсутствовать. Имеет два подтипа:Some(value)иNone. Используется, когда результат операции не гарантирован, но причина отсутствия значения не важна. -
Either[L, R]— представляет значение, которое может быть одного из двух типов. По соглашению,Leftиспользуется для ошибок, аRight— для успешного результата. Это позволяет не только фиксировать факт ошибки, но и передавать её описание. -
Try[T]— обёртка над вычислением, которое может выбросить исключение. Имеет два подтипа:Success(value)иFailure(exception). Используется при взаимодействии с Java-кодом или внешними системами, где исключения неизбежны.
Эти типы поддерживают функциональные операции — map, flatMap, fold, что позволяет строить цепочки преобразований без явных условных конструкций.
Пример:
def divide(a: Int, b: Int): Option[Int] =
if (b != 0) Some(a / b) else None
val result = divide(10, 2).map(_ * 3) // Some(15)
Разбор:
divideвозвращаетOption[Int], явно кодируя риск неуспешного деления.Some(a / b)строится только при валидном делителеb != 0.Noneзаменяет исключение и заставляет вызывающий код обработать отсутствие результата.map(_ * 3)применяет преобразование только к успешному значению внутриSome.- При
Noneцепочка остаётсяNoneбез дополнительныхif.
Такой стиль повышает надёжность и читаемость: каждый шаг цепочки честно заявляет о возможности отсутствия результата.
Either с текстом ошибки:
def parsePort(s: String): Either[String, Int] =
s.toIntOption match
case None => Left("порт должен быть целым числом")
case Some(p) if p > 0 && p < 65536 => Right(p)
case Some(_) => Left("порт вне диапазона 1..65535")
val ok = parsePort("8080") // Right(8080)
val bad = parsePort("70000") // Left("порт вне диапазона 1..65535")
Разбор:
toIntOptionбезопасно парсит строку вOption[Int].matchразбирает три сценария — не число, валидный порт, число вне диапазона.Right(p)— успех,Left(msg)— ошибка с понятным текстом.- Так ошибки становятся частью сигнатуры, а не скрытым исключением.
Функции как типы
В Scala функции являются полноценными значениями и имеют собственные типы. Тип функции записывается как A => B, что означает "функция, принимающая аргумент типа A и возвращающая значение типа B".
Под капотом функция — это экземпляр трейта FunctionN, где N — количество параметров. Например, Int => String — это сокращение от Function1[Int, String].
Функции можно передавать как аргументы, возвращать из других функций, сохранять в переменных и коллекциях. Это лежит в основе функционального программирования и позволяет писать высокоабстрактный, переиспользуемый код.
Пример:
val greet: String => String = name => s"Hello, $name!"
val messages = List("Alice", "Bob").map(greet) // List("Hello, Alice!", "Hello, Bob!")
Разбор:
String => String— явный тип функции от имени к приветствию.- Лямбда
name => ...создаёт функцию как значение, которое можно передавать дальше. List(...).map(greet)применяет её ко всем элементам коллекции.- Итог — новый список, сформированный без ручного цикла и мутаций.
Типизация функций обеспечивает безопасность: невозможно передать функцию с несовместимой сигнатурой, и компилятор проверит это заранее.
Типы и композиция
Scala поощряет композицию вместо наследования. Трейты (trait) служат основным механизмом для совместного использования поведения. Они могут содержать как абстрактные, так и конкретные методы, а также типовые параметры и вложенные типы.
С помощью mixin-композиции можно комбинировать несколько трейтов в одном объекте, создавая гибкие и адаптивные структуры. Это особенно полезно при тестировании: зависимости легко заменяются моками без изменения основной логики.
Пример (стекируемые трейты — abstract override и вызов super в mixin):
trait Logger:
def log(msg: String): Unit = println(msg)
trait TimestampLogger extends Logger:
abstract override def log(msg: String): Unit =
super.log(s"[${System.currentTimeMillis()}] $msg")
class ConsoleLogger extends Logger:
override def log(msg: String): Unit = println(msg)
val logger = new ConsoleLogger with TimestampLogger
logger.log("Система запущена")
Разбор:
trait Loggerзадаёт базовый контракт логирования и дефолтную реализацию.TimestampLoggerчерезabstract overrideдекорирует поведение, добавляя метку времени.- Вызов
super.log(...)позволяет стековать несколько mixin-трейтов. new ConsoleLogger with TimestampLoggerсобирает финальный объект через композицию поведения.- Этот паттерн даёт расширяемость без громоздких иерархий наследования.
Такой подход позволяет строить системы из маленьких, тестируемых и переиспользуемых компонентов.
Вариантность (Variance)
Вариантность определяет, как параметризованные типы реагируют на отношения между их аргументами. В Scala это управляется аннотациями + (ковариантность) и - (контравариантность).
-
Ковариантный тип
List[+A]означает: еслиCat— подтипAnimal, тоList[Cat]является подтипомList[Animal]. Это логично для неизменяемых структур: список кошек можно безопасно использовать там, где ожидается список животных. -
Контравариантный тип
Function1[-A, +B]означает — еслиCat— подтипAnimal, тоFunction1[Animal, String]является подтипомFunction1[Cat, String]. Это связано с тем, что функция, принимающая любое животное, может быть использована вместо функции, ожидающей только кошку — она более общая. -
Инвариантный тип (без аннотаций) требует точного совпадения типов. Например,
Array[A]инвариантен, потому что массивы изменяемы — записьDogвArray[Cat], приведённый кArray[Animal], нарушит безопасность. Компилятор отсекает это заранее:
class Animal; class Cat extends Animal; class Dog extends Animal
val cats: Array[Cat] = Array(new Cat)
// val animals: Array[Animal] = cats // ошибка компиляции
Разбор:
Arrayинвариантен, поэтомуArray[Cat]нельзя неявно расширить доArray[Animal].- Ограничение защищает от небезопасной записи
Dogв массив, где ожидаются толькоCat. - Комментарий с ошибкой показывает, как компилятор предотвращает нарушение типовой безопасности.
- Это принципиально отличает инвариантные контейнеры от ковариантных (
List[+A]).
Аннотации вариантности задаются при объявлении типа и строго проверяются компилятором. Нарушение правил вариантности приводит к ошибке компиляции, что предотвращает опасные преобразования.
Зависимые типы и тайп-классы
Scala поддерживает выразительные механизмы, выходящие за рамки простой параметризации. Полноценные зависимые типы (тип зависит от значения аргумента) в языке отсутствуют; зато есть path-dependent types, union/intersection и тайп-классы — инструменты для типобезопасного абстрагирования.
Тайп-классы — это паттерн, позволяющий добавлять поведение к существующим типам без изменения их исходного кода. Они реализуются через трейты и неявные (implicit) параметры или, в Scala 3, через ключевое слово given.
Пример:
trait Show[T] {
def show(value: T): String
}
given Show[Int] with {
def show(value: Int): String = s"Number: $value"
}
def display[T](value: T)(using s: Show[T]): String = s.show(value)
Разбор:
Show[T]задаёт typeclass-контракт для преобразования значения в строку.given Show[Int]предоставляет конкретную реализацию для типаInt.- Параметр
(using s: Show[T])вdisplayзапрашивает неявный экземпляр typeclass. - Вызов
s.show(value)делегирует форматирование выбранной реализации без наследования. - Такой подход расширяет поведение типов модульно и типобезопасно.
Здесь Show — тайп-класс, описывающий способ представления значения в виде строки. Для Int предоставляется конкретная реализация. Функция display работает с любым типом, для которого существует экземпляр Show.
Этот подход лежит в основе многих библиотек — сериализация, сравнение, математические операции. Он обеспечивает расширяемость без наследования и позволяет избегать "божественных" интерфейсов.
Метапрограммирование и типы во время компиляции
Scala предоставляет средства для выполнения вычислений на этапе компиляции, что позволяет генерировать код, проверять инварианты и оптимизировать производительность.
В Scala 2 это достигалось с помощью макросов, но в Scala 3 макросы заменены на inline-функции и quoted expressions. Эти механизмы позволяют анализировать и модифицировать AST (абстрактное синтаксическое дерево) программы, сохраняя при этом полную типовую безопасность.
Пример использования — автоматическая генерация кода для сериализации:
inline def deriveCodec[T]: Codec[T] = ${ CodecMacros.deriveCodecImpl[T] }
Разбор:
inlineпросит компилятор раскрыть вызов на этапе компиляции.${ ... }вставляет сгенерированный макросом код в место вызова.deriveCodecImpl[T]анализирует структуру типаTи строит сериализатор/десериализатор.- Результат — меньше шаблонного кода вручную и без рантайм-рефлексии.
Макрос получает тип T во время компиляции, анализирует его структуру (например, поля case-класса) и генерирует эффективный сериализатор без рантайм-рефлексии.
Такой подход снижает накладные расходы, устраняет классовые ошибки и делает библиотеки более удобными: программист не пишет шаблонный код, а компилятор делает это за него.
Роль типов в современных библиотеках
Современные библиотеки Scala активно используют систему типов для выражения семантики:
-
ZIO моделирует эффекты как параметризованные типы
ZIO[R, E, A], гдеR— окружение,E— возможная ошибка,A— успешный результат. Это позволяет точно описывать зависимости, побочные эффекты и обработку ошибок. -
Cats и Shapeless предоставляют универсальные абстракции (
Functor,Monad,Generic) и инструменты для работы с типами на уровне компиляции. -
fs2 и Akka Streams используют типы для описания потоков данных, гарантируя, что операции над потоками составлены корректно.
В этих библиотеках типы служат не только для проверки, но и для документирования: сигнатура функции часто полностью раскрывает её поведение.
Типы как документация
Одним из важнейших свойств строгой типизации является её способность заменять комментарии. Хорошо спроектированный тип сам по себе объясняет, что делает функция и какие данные она ожидает.
Например, функция с сигнатурой:
def process(input: List[Order], validator: Order => Option[ValidationError]): List[Either[ValidationError, ProcessedOrder]]
Разбор:
- Сигнатура уже описывает весь контракт без чтения реализации.
input: List[Order]показывает пакетную обработку множества заказов.validator: Order => Option[ValidationError]задаёт функцию валидации, которая может вернуть ошибку.- Результат
List[Either[ValidationError, ProcessedOrder]]фиксирует поэлементный итог: ошибка или обработанный заказ. - Такой тип делает поведение API прозрачным и снижает необходимость в дополнительных комментариях.
чётко сообщает:
- принимает список заказов,
- использует валидатор, который может вернуть ошибку,
- возвращает список результатов, каждый из которых либо ошибка, либо обработанный заказ.
Такой код не требует дополнительных пояснений. Типы становятся исполняемой спецификацией.