Перейти к основному содержимому

Управляющие конструкции и операторы Scala

Разработчику Архитектору

Перед чтением: Операторы — общие понятия оператора, операнда, приоритетов и типов операций без привязки к языку.

Сначала: Циклы в коде — общая идея повторений, виды циклов и типичные ошибки без привязки к синтаксису языка.


Управляющие конструкции и операторы Scala

В этой главе главное понять сдвиг мышления: в Scala управление потоком часто строится как вычисление выражений, а не как последовательность команд с ручным изменением состояния. Поэтому if, match, for и операции коллекций нужно читать как "построение результата".

Практический ориентир:

  • для простого выбора — if;
  • для ветвления по форме данных — match;
  • для обхода коллекций — map / filter / fold;
  • для сложных цепочек — for-comprehension.

Play ITЗагрузка интерактивного демо…


Выражения и значения

В Scala почти всё является выражением. Даже такие привычные элементы, как условные конструкции или циклы, возвращают значение. Это свойство позволяет избегать промежуточных переменных и писать более декларативный код. Например, результат if-выражения — это значение, полученное в одной из его веток. Это значение имеет тип, который выводится автоматически на основе типов обеих веток. Если обе ветки возвращают целые числа, то и всё выражение будет иметь тип Int. Если одна ветка возвращает строку, а другая — число, то общий тип будет Any, что указывает на потерю точности типизации и требует внимания со стороны разработчика.


Условные конструкции — if и match

Конструкция if в Scala работает аналогично многим другим языкам, но с важным отличием: она всегда возвращает значение. Синтаксис прост:

val result = if (условие) значение1 else значение2

Разбор:

  • if в Scala — выражение, оно возвращает значение выбранной ветки.
  • условие должно иметь тип Boolean, неявные приведения не применяются.
  • Оба результата должны быть совместимы по типу, иначе выражение "поднимется" к слишком общему типу.
  • Такой формат позволяет сразу присваивать результат в val.

Если условие истинно, возвращается значение1, иначе — значение2. Отсутствие ключевого слова else допустимо, но в этом случае тип результата становится Unit, так как отсутствующая ветка интерпретируется как () — единственное значение типа Unit. Это полезно в тех случаях, когда результат выражения не используется, а важно только выполнение побочного эффекта.

val sideEffect = if (debug) println("trace") // тип Unit, значение ()
val label = if (n > 0) "positive" else "non-positive" // тип String

Разбор:

  • В первой строке if без else возвращает Unit, потому что выражение используется ради эффекта печати.
  • Во второй строке обе ветки возвращают String, поэтому итоговый тип точный и полезный.
  • Пример иллюстрирует разницу между "вычислить значение" и "выполнить действие".
  • Явное понимание типов в ветках упрощает чтение и рефакторинг условной логики.

Более мощной альтернативой if является конструкция match. Она реализует сопоставление с образцом — один из ключевых механизмов Scala. match позволяет не только сравнивать значения, но и распаковывать составные структуры данных, проверять типы, извлекать поля и применять охранные выражения. Каждый случай в match начинается с ключевого слова case, за которым следует шаблон и, при необходимости, условие-охранник. Конструкция match также возвращает значение — результат того случая, который совпал первым.

Пример базового использования:

val message = x match {
case 0 => "ноль"
case 1 => "один"
case _ => "другое число"
}

Разбор:

  • x match выполняет ветвление по шаблонам, а не только по булевому условию.
  • case 0 и case 1 обрабатывают конкретные литеральные значения.
  • case _ — универсальная ветка-фолбэк для всех остальных случаев.
  • Результатом всего блока является строка из первой совпавшей ветки.

Здесь символ _ обозначает "любое значение", и он используется как универсальный фолбэк. Scala требует, чтобы все возможные случаи были покрыты, либо явно, либо через такой обобщённый шаблон. В противном случае компилятор выдаст предупреждение о неполном сопоставлении.

Сопоставление с образцом особенно эффективно при работе с алгебраическими типами данных, такими как Option, Either, или пользовательские sealed trait и case class. Оно позволяет безопасно и читаемо обрабатывать различные состояния программы без использования исключений или проверок на null.

Интерактивное демо — пошаговый цикл на примере JavaScript (for, while). В Scala чаще map/foreach, но императивные while/for работают по той же схеме. Обобщённо: циклы в коде.

Play ITЗагрузка интерактивного демо…


Циклы и итерации

Scala поддерживает традиционные циклы while и do-while, но они используются редко в функциональном стиле программирования. Эти циклы не возвращают значимых данных — их результат всегда имеет тип Unit. Они применяются в тех случаях, когда требуется выполнить побочный эффект определённое количество раз или до достижения условия.

Основной способ итерации в Scala — использование методов коллекций. Коллекции предоставляют богатый набор функций высшего порядка — map, filter, flatMap, foreach, fold, reduce и многие другие. Эти методы инкапсулируют логику перебора и позволяют сосредоточиться на преобразовании данных, а не на управлении индексами или счётчиками.

Например, вместо написания цикла для удвоения каждого элемента списка, достаточно вызвать:

val doubled = list.map(_ * 2)

Разбор:

  • map применяет функцию к каждому элементу коллекции и возвращает новую коллекцию.
  • _ * 2 — сокращённая лямбда: текущий элемент умножается на 2.
  • Исходный list остаётся неизменным, что делает операцию безопасной для композиции.
  • Такой стиль заменяет ручной цикл и снижает вероятность индексных ошибок.

Этот подход не только короче, но и безопаснее, поскольку исключает ошибки, связанные с выходом за границы массива или некорректным обновлением счётчика.

Для более сложных итерационных сценариев Scala предлагает конструкцию for. Однако это синтаксический сахар над комбинацией map, flatMap и filter. Конструкция for называется "комprehension" и позволяет писать вложенные итерации в читаемой форме.

Пример:

val pairs = for {
x <- List(1, 2, 3)
y <- List("a", "b")
} yield (x, y)

Разбор:

  • for-comprehension разворачивается в комбинацию flatMap и map.
  • x <- ... и y <- ... задают вложенные источники данных.
  • yield (x, y) формирует результирующий элемент для каждой комбинации.
  • Итог — декартово произведение списков в удобной декларативной форме.

Этот код эквивалентен цепочке flatMap и map, но записан в декларативном стиле, близком к математической нотации. Конструкция for также поддерживает условия-фильтры с помощью ключевого слова if внутри блока, что позволяет отсеивать нежелательные элементы без дополнительного вызова filter.


Операторы и приоритеты

Группа (по первому символу)ПримерыЗаметка
Высокий приоритет*, /, %Арифметика
Средний+, -Арифметика, конкатенация строк
Низкий (буквы)::, +:, mapМетоды-коллекции
Присваивание=, +=Только с var

В Scala операторы — это методы. Любой символ, используемый между двумя значениями, интерпретируется как вызов метода у левого операнда с правым в качестве аргумента. Например, выражение a + b на самом деле означает a.+(b). Это позволяет создавать собственные операторы и переопределять поведение существующих.

Приоритет операторов определяется по первому символу. Например, все операторы, начинающиеся с *, имеют более высокий приоритет, чем те, что начинаются с +. Операторы, начинающиеся с букв, имеют самый низкий приоритет. Это правило позволяет строить сложные выражения без избыточных скобок, сохраняя читаемость.

Ассоциативность также определяется по последнему символу оператора. Если оператор заканчивается на :, он правоассоциативен, и вызов происходит на правом операнде. Например, a :: b интерпретируется как b.::(a), что удобно при построении списков, где :: добавляет элемент в начало.

Практическое правило для чтения чужого кода: если видите много символьных операторов подряд, временно раскрывайте их в обычные вызовы методов (a.+(b), list.::(x)) и только затем возвращайтесь к короткой записи.


Логические и побитовые операторы

Scala предоставляет стандартный набор логических операторов — && (логическое И), || (логическое ИЛИ), ! (логическое НЕ). Эти операторы работают с типом Boolean и поддерживают короткое замыкание: если результат выражения можно определить по первому операнду, второй не вычисляется.

Побитовые операторы (&, |, ^, ~, <<, >>, >>>) применяются к целочисленным типам и выполняют соответствующие операции на уровне битов. Они полезны при работе с низкоуровневыми данными, флагами или оптимизациях.


Операторы сравнения и равенства

Операторы сравнения (<, <=, >, >=) доступны для числовых типов и любых типов, реализующих трейт Ordered. Для произвольных объектов Scala предоставляет метод ==, который вызывает метод equals и корректно обрабатывает null. Это отличается от поведения в Java, где == сравнивает ссылки. Метод != является отрицанием ==.

Важно отметить, что в Scala рекомендуется переопределять equals и hashCode вместе, чтобы обеспечить корректную работу с хеш-таблицами и множествами. Для case class эти методы генерируются автоматически, что делает их особенно удобными для использования в качестве неизменяемых данных.


Присваивание и изменяемость

Оператор присваивания = используется для инициализации переменных. Ключевое слово val создаёт неизменяемую переменную, а var — изменяемую. После инициализации val нельзя переприсвоить новое значение. Это способствует написанию безопасного и предсказуемого кода, особенно в многопоточной среде.

Оператор += и ему подобные (-=, *=, /=) могут использоваться с var и представляют собой синтаксический сахар. Например, x += 1 эквивалентно x = x + 1. Если x имеет метод с именем +=, будет вызван именно он, что позволяет реализовывать мутабельные коллекции с естественным синтаксисом.


Обработка исключений

Интерактивное демо — часть сценариев на Python (try / except); в Scala — try / catch, в идиоматичном коде чаще Option/Either. Стек и раскрутка те же. Подробнее: ошибки и исключения.

Play ITЗагрузка интерактивного демо…

Scala поддерживает механизм исключений, похожий на Java, но с функциональным уклоном. Конструкция try-catch-finally позволяет перехватывать исключения и обрабатывать ошибки. Однако в функциональном стиле предпочтение отдаётся использованию типов Option и Either для представления неудачных вычислений. Эти типы позволяют избежать исключений и сделать ошибки частью типа, что повышает надёжность программы.

СитуацияИдиоматичный подход
Значение может отсутствоватьOption[T]
Ожидаемая ошибка с описаниемEither[String, T]
Вызов Java/legacy с исключениямиTry[T]
Неожиданный сбой, прервать потокthrow / try-catch

Конструкция try сама по себе является выражением. Её значение — это результат успешного блока или значение, возвращённое в блоке catch. Блок finally не влияет на результат выражения, но гарантирует выполнение определённых действий, таких как закрытие ресурсов.

Мини-шаблон, который удобно держать в голове:


import scala.util.{Try, Success, Failure}

def parseIntSafe(s: String): Either[String, Int] =
Try(s.toInt) match
case Success(v) => Right(v)
case Failure(_) => Left("not an integer")

Разбор:

  • Try(s.toInt) перехватывает возможное исключение парсинга и превращает его в типизированный результат.
  • Success(v) извлекает корректно распарсенное число.
  • Failure(_) перехватывает ошибку и переводит её в доменное сообщение.
  • Возврат Either[String, Int] делает ошибку частью контракта функции.
  • Такой переход от исключений к Either упрощает последующую композицию логики.

Так вы постепенно уходите от аварийной логики к типобезопасному потоку обработки данных.


Сопоставление с образцом — расширенные возможности

Сопоставление с образцом в Scala — это не просто замена условным операторам. Это мощный инструмент деконструкции данных, который позволяет одновременно проверять структуру значения, извлекать его компоненты и применять дополнительные условия. Шаблоны могут быть вложенными, что особенно полезно при работе с древовидными структурами или вложенными коллекциями.

Рассмотрим пример с обработкой JSON-подобной структуры:

sealed trait JsonValue
case class JsonObject(fields: Map[String, JsonValue]) extends JsonValue
case class JsonArray(items: List[JsonValue]) extends JsonValue
case class JsonString(value: String) extends JsonValue
case class JsonNumber(value: Double) extends JsonValue
case object JsonNull extends JsonValue

Разбор:

  • sealed trait JsonValue фиксирует замкнутый набор вариантов JSON в одном файле.
  • case class-варианты описывают структуры с полями (Object, Array, String, Number).
  • case object JsonNull задаёт singleton-вариант без данных.
  • Такая ADT-модель позволяет писать исчерпывающие match без null-проверок.

Теперь можно безопасно извлечь значение по ключу, даже если структура вложена:

def extractName(json: JsonValue): Option[String] = json match {
case JsonObject(fields) =>
fields.get("user") match {
case Some(JsonObject(userFields)) => userFields.get("name").collect { case JsonString(name) => name }
case _ => None
}
case _ => None
}

Разбор:

  • Внешний match сначала проверяет, что корень действительно объект JSON.
  • fields.get("user") возвращает Option, поэтому отсутствие ключа обрабатывается безопасно.
  • Внутренний case Some(JsonObject(userFields)) гарантирует нужную форму вложенного значения.
  • collect { case JsonString(name) => name } извлекает имя только если тип значения строковый.
  • Финальный тип Option[String] честно отражает, что имя может отсутствовать.

Этот код читаем, типобезопасен и не требует явных проверок на null или исключений. Каждый уровень сопоставления гарантирует, что данные имеют ожидаемую форму.

Scala также поддерживает охранники — дополнительные условия, которые проверяются после совпадения шаблона:

val description = x match {
case n if n > 0 => "положительное"
case n if n < 0 => "отрицательное"
case 0 => "ноль"
}

Разбор:

  • Guards if n > 0 и if n < 0 добавляют условие поверх шаблона.
  • Переменная n связывается из входа и сразу используется в проверке.
  • Ветка case 0 закрывает оставшийся сценарий.
  • Итогом match является строковая классификация числа.

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


Частичные функции и управление неопределённостью

В Scala часто возникает необходимость определить функцию, которая применима только к части входных значений. Такие функции называются частичными. Они выражаются через трейт PartialFunction[A, B], который предоставляет метод isDefinedAt, позволяющий проверить, применима ли функция к заданному аргументу.

Пример:

val divide: PartialFunction[(Int, Int), Int] = {
case (x, y) if y != 0 => x / y
}

Разбор:

  • PartialFunction[(Int, Int), Int] явно показывает: функция определена не для всех пар.
  • Шаблон (x, y) декомпозирует кортеж аргументов.
  • Guard if y != 0 ограничивает область определения безопасными значениями.
  • Для пар с нулевым делителем функция не определена, что можно проверить через isDefinedAt.

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

val handleZero: PartialFunction[(Int, Int), Int] = {
case (_, 0) => 0
}

val safeDivide = divide orElse handleZero

Разбор:

  • handleZero покрывает ровно один неуспешный сценарий: делитель равен нулю.
  • (_, 0) игнорирует числитель и фокусируется на проблемном втором элементе.
  • orElse объединяет частичные функции в один общий обработчик.
  • safeDivide сначала пробует divide, а при неприменимости переключается на handleZero.

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

Частичные функции особенно популярны в Akka, где сообщения обрабатываются именно через PartialFunction[Any, Unit]. Это позволяет актору реагировать только на известные типы сообщений, игнорируя остальные, что повышает устойчивость системы.


Ленивые вычисления и отложенное выполнение

Scala поддерживает ленивые вычисления через ключевое слово lazy. Значение, помеченное как lazy val, вычисляется только при первом обращении к нему и кэшируется для последующих вызовов. Это полезно при работе с ресурсоёмкими операциями, которые могут не понадобиться в ходе выполнения программы.

Пример:

lazy val expensiveResult = {
println("Вычисление...")
Thread.sleep(1000)
42
}

println("Готово")
// Вычисление произойдёт только при первом обращении к expensiveResult

Разбор:

  • lazy val откладывает выполнение блока до первого чтения значения.
  • Внутри блока видно побочный эффект (println) и долгую операцию (Thread.sleep).
  • Пока к expensiveResult не обратились, эти действия не выполняются.
  • После первого вычисления результат кэшируется, повторные обращения возвращают готовое значение.

Ленивость также проявляется в коллекциях. Коллекции типа LazyList (ранее Stream) вычисляют свои элементы по мере необходимости. Это позволяет работать с бесконечными последовательностями:

val naturals: LazyList[Int] = LazyList.from(1)
val squares = naturals.map(x => x * x)
println(squares.take(5).toList) // List(1, 4, 9, 16, 25)

Разбор:

  • LazyList.from(1) создаёт потенциально бесконечную последовательность натуральных чисел.
  • map(x => x * x) описывает преобразование, но не вычисляет все элементы сразу.
  • take(5) ограничивает потребление первыми пятью значениями.
  • toList материализует только нужный префикс, демонстрируя практическую ленивость.

Ленивые коллекции особенно эффективны при цепочках трансформаций — фильтрация, отображение и свёртка выполняются только над теми элементами, которые действительно нужны, что экономит память и процессорное время.


Управление побочными эффектами

В функциональном стиле побочные эффекты (изменение состояния, ввод-вывод, сетевые запросы) изолируются. Хотя Scala не запрещает императивный код, он поощряет явное управление эффектами через специализированные типы, такие как Option, Either, Try, а также библиотеки вроде ZIO или Cats Effect.

Например, вместо исключения при делении на ноль можно вернуть Option[Int]:

def safeDiv(a: Int, b: Int): Option[Int] =
if (b != 0) Some(a / b) else None

Разбор:

  • Функция не бросает исключение, а кодирует риск ошибки прямо в типе Option[Int].
  • При валидном делителе возвращается Some с результатом целочисленного деления.
  • При b == 0 возвращается None, и вызывающий код обязан явно учесть этот путь.
  • Такой контракт лучше масштабируется в цепочках map/flatMap и API-слоях.

Такой подход делает ошибки частью типа и заставляет вызывающий код обрабатывать их явно, что повышает надёжность.


Циклы и рекурсия

Хотя while и do-while доступны, функциональный стиль предпочитает рекурсию. Однако обычная рекурсия может привести к переполнению стека. Scala поддерживает хвостовую рекурсию, которую компилятор оптимизирует в цикл. Для этого достаточно, чтобы рекурсивный вызов был последней операцией в функции.

Пример хвостовой рекурсии:

def factorial(n: Int): BigInt = {
@annotation.tailrec
def loop(acc: BigInt, i: Int): BigInt =
if (i <= 1) acc else loop(acc * i, i - 1)
loop(1, n)
}

Разбор:

  • Внешняя функция factorial скрывает внутренний рекурсивный цикл loop.
  • acc хранит накопленный результат, i — текущий множитель.
  • Рекурсивный вызов стоит в хвостовой позиции, поэтому компилятор может оптимизировать его в цикл.
  • @tailrec проверяет это на этапе компиляции и защищает от случайной потери оптимизации.
  • Использование BigInt предотвращает переполнение для больших n.

Аннотация @tailrec гарантирует, что функция действительно хвостово-рекурсивна; в противном случае компилятор выдаст ошибку.

Для большинства задач рекурсия заменяется использованием методов коллекций (fold, reduce, scan), которые уже реализованы эффективно и безопасно.


Управляющие конструкции в акторной модели

В системах на основе Akka управление потоком осуществляется через асинхронную передачу сообщений. Актор получает сообщение, обрабатывает его с помощью частичной функции и, при необходимости, отправляет сообщения другим акторам. Внутри обработчика могут использоваться любые управляющие конструкции Scala, но сама логика распределена во времени и пространстве.

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