Синтаксический сахар в функциях Scala

В этой статье мы опишем дополнительный синтаксический сахар, относящийся к функциям. Как известно, многие функции стандартной библиотеки Scala принимают в качестве параметра другие функции. Например, у списка (List) есть функция map, которая совершает преобразование над каждым его элементом, сигнатура которой упрощенно выглядит так:

def map[B](f: A=>B): List[B]

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

val list = List(1, 2, 3, 4, 5)
list.map { int => int + 1 }

И получили бы список:

List(2, 3, 4, 5, 6)

Но Scala для подобных случаев имеет более лаконичный синтаксис:

list.map {_ + 1}

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

Например, там, где требуется функция типа (Int, Int) => Int, вы можете передать суммирование при помощи синтаксиса _ + _. Однако, не всюду такой сахар будет работать, и тогда нужно будет пользоваться обычными лямбда-выражениями. Лямбда-выражения хорошо работают, когда у функции аргументы не запакованы ни в какие обёртки. Если же мы рассмотрим функцию, принимающую функцию из кортежа целых чисел в целое:

def f(g: Tuple[Int, Int] => Int) = ???

То мы не сможем сделать вызов при помощи удобного синтаксиса:

f { (i1, i2) => i1 + i2 } // ошибка компиляции

Функция должна переводить из одного параметра кортежа в целое:

f { t => t._1 + t._2 } // верно

Но такой синтаксис недостаточно выразителен, поэтому для этих целей существует некоторого вида сопоставление с шаблоном. Вообще, синтаксис case <шаблон> if => value задает частично определённую функцию (partial function). Частично определённая функция, действующая из некоторого набора данных A в набор данных B, отличается от обычной функции тем, что она может быть определена не для всех возможных значений из A. Например, функция извлечения арифметического квадратного корня является частично определённой для всех вещественных чисел от 0 до +∞. Компилятор Scala умеет определять, является ли частичная функция обычной. Если же он обнаружит, что в сопоставлении с шаблоном вы описали не все случаи, он выдаст вам предупреждение с примером входных данных, которые могут выбросить PatternMatchingException.

Следующая порция синтаксического сахара Scala — ассоциативность операторов. Оператором в Scala называется нестатический метод, который принимает один параметр. Существует два типа операторов: правоассоциативные и левоассоциативные, если вызывать их через точку, то различия нет, но зато, при использовании сокращенного синтаксиса без точки, семантика записи a op b меняется. Правоассоциативные операторы можно вызывать подобным образом:

a1 op1 a2 op2 a3  opN-1 aN

Что эквивалентно записи:

( ... ((a1.op1(a2)).op2(a3)) ).opN-1(aN)

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

List(1, 2, 3) map { _ + 1 } map { _ * 2 } map { _ - 1 }

И это будет эквивалентно записи:

((List(1, 2, 3).map { _ + 1 }).map { _ * 2 }).map { _ - 1 }

Левоассоциативные операторы можно вызывать похожим способом:

a1 op1 a2 op2 a3  opN-1 aN

Но это будет эквивалентно совсем другой записи:

(...((aN.opN-1(aN-1)).opN-2(aN-2))...).op1(a1)

Левоассоциативные операторы отличаются от правоассоциативных наличием символа «:» на конце. Например, вы можете создавать список, вызывая метод пустого списка (Nil) :::

1 :: 2 :: 3 :: 4 :: 5 :: Nil,

Что будет эквивалентно:

((((Nil.::(5)).::(4)).::(3)).::(2)).::(1)

И создаст список:

List(1, 2, 3, 4, 5)

Для унарных и некоторых бинарных операторов существует постфиксная нотация, например, когда у вас нет никаких параметров к методу, вы можете написать value methodName вместо value.methodName.

Так, например, в стандартной библиотеке в пакете scala.concurrent.duration устроены конструкторы промежутков времени. Вы можете написать:

1 second
12 minutes
100 milis

На самом деле, вы будете вызывать методы:

1.second
12.minutes
100.milis

Другое дело, что у обычного Int нет методов second, minutes и milis. Эти методы добавлены к нему при помощи специального механизма методов расширения.

Некоторые разработчики критикуют возможность создания DSL (Domain Specific Language) прямо внутри языка. Один из основных пунктов критики — код становится непонятным и нечитаемым, разобраться в стрелочках и прочих закорючках становится довольно сложно.

На самом деле эта критика происходит из неправильного использования DSL: разработчики пытаются создавать предметно-специфический язык, чётко не выделив предметную область.

Для того чтобы строить полезные и понятные DSL, нужно строго определить область, для которой создаётся язык. Например, стандартная библиотека предоставляет DSL для работы с последовательностями. Далее нужно использовать отличимые и интуитивно понятные символы, и если таких нет, не стесняться использовать слова. Например, использовать -~> и ~-> в одном языке — не самая хорошая идея.

На самом деле, правильно написанные DSL позволяют сильно сокращать количество написанного кода и делать его более идиоматическим.