Основы функционального программирования с примерами на Scala — часть 2

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

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

В этой части мы расскажем об ООП-модели Scala и о некотором синтаксическом сахаре, специфичном для языка Scala.

Основы модели ООП

Язык Scala имеет достаточно гибкую ООП-модель, состоящую из обжектов, трейтов, классов и кейс-классов, чем-то похожую на таковую в Java.

Класс в Scala объявляется следующим образом:

class ClassName(field1: Type1, field2: Type2, … , fieldN: TypeN) {
	// class body
}

и инстанцируется при помощи конструкции new ClassName(field1, field2, … , fieldN).

Параметры, введенные скобках после имени класса, являются private полями класса, однако можно изменить их область видимости: если нужно иметь доступ на чтение извне, перед именем поля нужно поставить модификатор val, а если нужно чтение и запись — модификатор var. Классы являются ссылочными типами и неявно наследуют базовый класс AnyRef. Поэтому присваивание объекта с мутирующими (var) полями другой переменной только копирует ссылку.

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

Внутри класса можно вводить многие вложенные структуры для внутреннего использования. По умолчанию все переменные, постоянные и методы, объявленные в теле класса, — публичные. Их можно защитить при помощи модификаторов private и protected. С другой стороны, вложенные классы недоступны извне.

Другой ООП-сущностью является trait. В отличие от класса, trait не может иметь конструктора и не может быть инстанцирован. Кроме того, он может содержать абстрактные методы, в то время как класс — только если помечен модификатором abstract.

Синтаксис трейта таков:

trait TraitName {
	
}

Ещё одна ООП-сущность, с которой вы столкнулись в предыдущей статье, — object.

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

Наследование в Scala имеет некоторые особенности. Во-первых, практически всё может наследовать от нескольких trait при помощи синтаксиса extends trait1 with trait2 with trait3 ... with traitN.

Именно это явление вы видели в начале статьи:

object HelloWorld extends App

Здесь App — предопределённый трейт, оборачивающий содержимое внутрь метода main.

Зачем нужен синтаксис extends … with … with …? Разве нельзя обойтись одним ключевым словом? Увы, нет. Если же разрешить множественное наследование (с наследованием реализации), то появляется проблема ромбовидного наследования. В Scala эта проблема решена определением главного трейта, реализация которого и наследуется. Этот главный трейт идёт первым после слова extends (к слову, это может быть вовсе не трейт, а класс или абстрактный класс).

Кроме того, вы не можете наследовать типы от объекта.

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

trait T {}
val t = new T {
	// имплементация абстрактных методов
}

Но писать такой код бывает достаточно громоздко. Для этого были введены обжекты-компаньоны (companion object). Для них нет специального ключевого слова, поэтому, чтобы их определить, вам нужно создать в файле с классом (или трейтом) обжект с таким же именем, как у класса. У обжекта-комапньона вы можете определить метод apply:

trait Foo {
	def bar: Int
}

object Foo {
	def apply(int: Int) = new Foo {
override def bar = int
}
}

Тогда вы можете создавать «экземпляры» трейта при помощи синтаксиса Foo.apply(1).

Этот код громоздкий, поэтому специально для метода apply сделали возможность не писать его название. Эта «магия» работает для всего что называется apply в любой сущности. Выглядит это так:

Foo(1)

Подобное можно делать и с классами. Отметим, что семантика метода apply может значительно отличаться от приведённого примера, ведь никто вас не ограничивает в том, что будет делать этот метод. Эта особенность активно используется как в стандартных библиотеках, так и в сторонних.

Параметрические типы

В Scala есть возможность параметризовать трейт, класс или его метод некоторым количеством типов. Делается это при помощи такого синтаксиса:

trait traitName [ParameterType1, ParameterType2, … , ParameterTypeN] {
		// тело трейта 
}

class className [ParameterType1, ParameterType2, … , ParameterTypeN] {
		// тело класса 
}

def defName [ParameterType1, ParameterType2, … , ParameterTypeN] (/*список аргументов*/): /*возвращаемое значение*/

Вы можете использовать параметры типа внутри тела метода/класса/трейта разными способами. Например, вы можете написать функцию, которая будет вычислять композицию 2 функций:

def compose[U, V, W](f: U => V, g: V => W): W = (x :U) => g(f(x))

На этом примере мы можем продемонстрировать ещё одну особенность системы типов Scala — она может выводить параметры-типы шаблонов. Например, если мы введём две функции

def fromIntToDouble(d: Int) = d.asInstanceOf[Double]
def fromIntToString(d: Double) = d.toString

то мы можем вычислить их композицию, не указывая цепочку типов:

val f = compose(fromIntToDouble, fromIntToString)

Что на самом деле эквивалентно:

val f: Int => String = compose[Int, Double, String](fromIntToDouble, fromIntToString)

Параметрические типы — мощное средство доказательства свойств программы — в том числе типобезопасности. Например, у вас в базе данных хранятся объекты двух типов A и B. Вы не хотите, чтобы кто-то мог добавить объект типа А в коллекцию элементов B (допустим, ваша БД нереляционная, например, MongoDB, и позволяет такое) или же не пытался записать элемент в несуществующую коллекцию. Вы можете написать метод добавления в коллекцию вот таким образом:

class A {}
class B {}
	trait ColletionNameResolver[T] { def collectionName: String }

	val ColletionNameResolverA = new ColletionNameResolver[A] { 
override def collectionName: String = "collectionA"
}

val ColletionNameResolverB = new ColletionNameResolver[B] { 
override def collectionName: String = "collectionB"
}

def insert[T](item: T, collectionNameResolver: CollectionNameResolver[T]) {
	println("the element " + item.toString + " was inserted to" +
 collectionNameResolver.collectionName)
}

В этом примере вы сможете добавить элемент в коллекцию соответствующего типа. Попытка добавить элемент типа А в коллекцию элементов типа B вызовет ошибку компиляции, и, скорее всего, IDE предупредит вас об этом ещё на этапе написания кода.

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

type String = List[String]

Или же, например, кортеж, один из параметров которого — целое:

type TypInt[T] = (T, Int)

Хотя на этом возможности системы типов Scala и не завершаются, дальнейшее повествование выходит за рамки нашего введения.

Кейс-классы и сопоставление с шаблоном

Оператор switch во многих языках воплощает задумку классификации данных на несколько категорий. Но большинство реализаций не могут поддерживать классификацию объектов, и поэтому редко используются в программах. В Scala есть очень мощная система сопоставления объекта с образцом. Однако экземпляр далеко не каждого класса можно сопоставлять с образцом без лишних телодвижений. Для упрощения этого процесса были введены два специальных вида сущностей: кейс-классы (case classes) и кейс-обжэкты (case objects). Кейс-классы подобны именованным кортежам, используемым для хранения данных. Часто они могут не иметь своих методов, выступая пассивными контейнерами, ведь Scala предоставляет великое множество других методов взаимодействия. Итак, синтаксис кейс-класса таков:

case class CaseClasName(parameterList) { /*опциональное тело класса*/ }

Все поля кейс-класса немутирующие и публичные. Это не является нарушением принципа инкапсуляции, поскольку он неприменим к публичным параметрам кейс-класса. Инкапсуляция полей имеет место, когда поля класса должны меняться некоторым сложным взаимосвязанным образом, и поэтому изменение полей должно производиться через методы класса. Здесь же нет никакого изменяемого состояния (мы крайне не рекомендуем его вводить в теле класса, чревато неожиданными последствиями), и инкапсулировать нечего. Инстанцировать экземпляр кейс-класса можно удобным синтаксисом:

CaseClasName(parameter1, parameter2, … , parameterN)

Примечание Здесь «под капотом» находится автоматически сгенерированный метод apply для этого класса, позволяющий пользоваться таким синтаксисом.

Пример:

case class User(name: Stirng, email: String)
val user = User("FooBar", "foo@bar.baz")

Второй тип сущностей, case object, похож на case class, но не имеет полей. Зачем же он тогда нужен? Для создания строго типизированных перечислений.

Заметим, что кейс-класс не может наследовать от другого кейс-класса, поэтому рекомендуем держать иерархию данных как можно более плоской и как можно менее древоподобной. Всегда можно задать кейс-классам общий надтип и, соответственно, нужный набор полей при помощи трейтов. Например, можно сделать так:

trait Account {
		def username: String
		def email: String
}
case class User(username: String, email: String) extends Account
case class SuperUser(username: String, email: String, privileges: String) extends Account

Каким образом работает сопоставление с шаблоном (pattern matching)? При помощи следующего синтаксиса:

value match {
	case pattern1 if  /*опциональное условие 1*/ => value1
	case pattern2 if  /*опциональное условие 1*/ => value2
...
	case patternN if  /*опциональное условие N*/=> valueN
}

Шаблоны могут быть очень разнообразными в зависимости от используемого типа данных.

Кейс-класс в сопоставлении с шаблоном можно разложить на его параметры при помощи «вызова конструктора» этого кейс-класса. Например, приведенному выше классу User будет соответствовать шаблон User(name, email). Здесь переменные name и user извлекаются из объекта класса User и после этого становятся доступны блоку value, следующему за =>. Кроме того, вы можете ввести переменную для обозначения объекта, сопоставляемого с образцом, при помощи символа @. Например, такой шаблон введёт переменную user для блока value:

user @ User(name, email)

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

Например, мы можем выбирать пользователя только с именем «dave» шаблоном User("dave", email). Если же нам безразличен email пользователя, мы можем не вводить переменную для этого поля шаблоном User(name, _).

Стоит отметить, что сопоставление с образцами происходит в порядке их расположения. Если шаблон User(name, email) находится выше шаблона User("dave", email), то сопоставления с последним никогда не произойдет, потому что первому из шаблонов соответствует любой экземпляр класса User.

Если же вам не нужны поля класса, а нужно просто соответствие по типу, используйте шаблон вида valName: TypeName.

Кроме того, существует шаблон «_», которому удовлетворяет любой объект.

Результат сопоставления с образцом — значение, возвращённое из value, приведенное к общему надтипу всех блоков value1, … valueN.

Если ни одному из шаблонов значение не соответствует, выбрасывается исключение PatternMatchingException.

Приведем пример, объединяющий написанное выше:

trait Account
	case class User(name: Stirng, email: String) extends Account
	case class SuperUser(name: Stirng, email: String, privileges: String) extends Account
case object SomethingStrange extends Account
	
def matcherFunc(acc: Account): Account = 
acc match {
	case usr @ User("dave", email) => 
println("This is dave, his email:" + email)
		usr
	case usr @ User(name, email) if email == "" => 
		println("User " + name + " has no email"
usr
	case usr @ User(name, email) => 
		println("User " + name + " has email " + email)
		usr
	case su: SuperUser => 
		println("It`s a superuser")
		su
	case smth => 
		println("We don’t know what is it")
}
matcherFunc(User("dave", "some@email.com"))
matcherFunc(User("dave", ""))
matcherFunc(User("notadave", ""))
matcherFunc(User("john", "email@any.com"))
matcherFunc(SuperUser("neo", "super@secret.email", "every possible"))
matcherFunc(SomethingStrange)

На самом деле, сопоставление с образцом можно применять не только на кейс-классах. На всех типах-примитивах (числа, строки, символы, …), и типов, для которых определён метод-экстрактор. Например, вы можете сопоставлять строку с регулярным выражением. Просто создайте несколько регулярных выражений и применяйте сопоставление с шаблоном:

val regWord = """([\w]+)""".r
val regTwoWords = """([\w]+) ([\w]+)""".r
def regexMatcher(s: String) = 
	s match {
		case regWord(word) => 
		println("Single word: " + word)
		case regTwoWords(word1, word2) => 
		println("First word: " + word1 + "second word: " + word2)
	}
regexMatcher("hello")
regexMatcher("hello word")

Такие действия можно осуществлять, определяя метод unapply и объект-экстрактор. Подробнее об этом можно прочитать в документации. Экстрактор имеет ещё одно полезное применение: с помощью него можно распаковывать кейс-классы и другие классы, имеющие экстрактор, используя точно такой же синтаксис, как вид шаблона в сопоставлении с образцом:

case class User(name: Stirng, email: String) extends Account
val user = User("john", "john@doe.com")
val User(someName, someEmail) = user

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

Сахар, сахар и ещё раз сахар

В этой части мы опишем дополнительный синтаксический сахар, относящийся к функциям.

Многие функции стандартной библиотеки 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 позволяют сильно сокращать количество написанного кода и делать его более идиоматическим.

Заключение

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

Иван Камышан