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

Вы наверняка слышали о функциональном стиле программирования, о таких языках как Haskell, Erlang, Scala, F#, OCaml. Для многих из вас эти технологии могут показаться модной выдумкой для IT-хипстеров или же очень непонятной вещью, отсутствующей в реальном мире. На самом деле, эти языки программирования, как и функциональный подход в целом, предоставляют программисту свои преимущества (выходящие за рамки лямбда-выражений) для написания реальных программ, .

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

Недостатки ООП

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

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

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

Функциональный подход

В функциональном программировании решили подойти с другой стороны. Во-первых, решили отказаться от возможности изменять переменные настолько, насколько это возможно (существуют фундаментальные ограничения, не позволяющие сделать это полностью). Функция в смысле информатики не является функцией в смысле математики: например, функция датчика случайных чисел (на основе физического датчика) не является математической функцией — она не принимает параметров, и потому, с точки зрения математики, должна быть константой, но каждый раз эта функция выдаёт разный результат. Такого в математике не бывает. Если же мы запретим переменным изменяться, то есть уничтожим глобальное изменяемое состояние, то функции станут действительно математическими, и можно будет построить строгую математическую теорию. Такая теория была построена и ныне носит название λ-исчисления. Эта теория описывает типы и полиморфизм.

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

Монады бывают двух видов. Первый вид — «чистая» монада, она представляет корректные данные. Второй вид — некорректная монада, она нужна для представления некорректных данных. Над этими двумя типами данных определены операции:

  1. pure — функция создания «чистой» монады.
  2. flatMap — применение преобразования, которое может привести к некорректному результату. К примеру, операция деления целых чисел будет корректно обрабатывать все данные, кроме деления на 0. В этом случае будет создана некорректная монада и применение к ней операции flatMap её не изменит.
  3. map — применение к данным операции, которая не может вызвать исключений по своей природе, например, операции сложения.

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

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

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

О Scala

Изначально язык Scala был разработан Мартином Одерски (который также работал над Generics в Java и над компилятором Javac) в EPFL. Первая версия языка была выпущена в мир в 2004 году на платформе Java. Нынешняя версия языка (2-я) имеет версии для платформ JavaScript (Scala-js) и под LLVM (Scala Native).

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

Язык был оценен по достоинству крупными корпорациями: например, он активно используется в Twitter, LinkedIn, Netflix, Sony и других.

Установка IDE и SBT

Чтобы использовать Scala под платформу Java, вам понадобятся установленные JRE и JDK. Для знакомства с языком разумно использовать IDE IntelliJ IDEA Community Edition со специальным плагином Scala Plugin (доступен прямо при установке IDE) и систему построения проектов SBT (есть под большинство ОС, скачивается при создании проекта).

Примеры из статьи можно запускать, используя онлайн-компилятор Scalastie.

Для того чтобы создать ваш первый проект, нужно сделать следующее:

new project → scala → sbt

scala

В меню создания проекта нужно выбрать версию SBT и Scala, последними на момент написания статьи являются 1.2.1 и 2.12.6 соответственно.

После нажатия кнопки создания проекта IDEA отдаст указание о создании проекта SBT (это можно понять по надписи dump project structure from SBT), поэтому структура папок проекта появится не сразу.

После формирования проекта вы можете увидеть подобную структуру папок:

scala

Нас в основном будут интересовать папка src и её подпапки main/scala и test/scala, а также файл build.sbt. Файл build.sbt описывает всю структуру проекта и используется для определения модулей, подмодулей, управления зависимостями и версиями библиотек. Сюда же стоит дописывать импорты сторонних библиотек.

В папке main/scala нужно создать new scala class и выбрать там object (о том, что это такое и какую роль он играет в Scala, будет сказано ниже). Обратите внимание, что имя обджекта должно совпадать с именем файла, в котором он расположен, иначе вы можете столкнуться с ошибками при попытке запустить вашу первую программу.

После создания обджекта вы увидите следующий код:

object HelloWorld {

}

В теле обджекта нужно создать точку входа в программу, а именно метод main() (в Scala методы объявляются ключевым словом def), автодополнение IDEA предложит вам сгенерировать метод как только вы начнёте набирать def main. В итоге должен получиться такой код:

object HelloWorld {
        def main(args: Array[String]): Unit = {

        } 
}

Если же вам не нужны параметры командной строки args, вы можете сократить свой код до:

object HelloWorld {
	   def main: Unit = {

            }
}

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

Добавив строчку println("Hello world"), вы сможете запустить эту программу, нажав на зелёный треугольник напротив метода main. Если всё сделано правильно, в консоли должна появиться строка «Hello world».

object HelloWorld extends App{
          println("Hello world")
}

Мы настоятельно рекомендуем всем любителям чистых текстовых редакторов на этапе знакомства с языком пользоваться IDEA (пусть это и противоречит философии Unix). Scala содержит большое количество достаточно хитрых конструкций, поддержку которых редко когда можно встретить в редакторе. Кроме того, у IDEA есть много полезных языко-специфических функций. С их помощью IDEA может спасти вас от некоторых глупых ошибок ещё во время написания кода.

Переменные

В языке Scala есть 2 типа переменных — val и var.

val — это неизменяемая переменная, ей можно присвоить значение только при инициализации. Синтаксис введения постоянной выглядит так:

val valueName: TypeName = value

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

Для того чтобы объявить переменную, вам нужно использовать ключевое слово var:

var valueName: TypeName = value

Как и в случае постоянной, тип в большинстве случаев можно опускать.

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

val tuple: (Type1, Type2, ... , TypeN) = (val1, val2, … , valN )

И распаковки из кортежа:

val (val1: Type1, … valN: TypeN) = tuple

Здесь типы тоже можно опускать, сокращая код до:

val tuple = (val1, val2, … , valN )
val (val1, … val2) = tuple

Методы

Для определения методов используется ключевое слово def. Синтаксис для определения метода выглядит так:

def methodName (parameter1: Type1, parameter2: Type2, … , parameterN: TypeN): returnType = {
      // method body
}

В определении вы наверняка не увидели слово return. Это потому, что последнее значение в функции является возвращаемым. То же самое касается и оператора if: вопреки принятой в императивных языках логике, оператор if (condition) value_if_true else value_if_false имеет возвращаемое значение, имеющее тип, общий над value_if_true/value_if_false и значение, в зависимости от условия равное value_if_true/value_if_false.

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

def five = 5

Хотелось бы отметить, что функция может быть значением и при этом будет иметь функциональный тип, который записывается как SourceType => ResultType.

К примеру, функция, которая увеличивает число на 1 (здесь num + 1 — возвращаемое значение, а Int — его тип):

def inc (num: Int) = num +1

будет иметь тип Int => Int.

Тогда мы можем ввести постоянные, значениями которых будут наши функции:

val incFunc: Int=>Int = inc

В чём же разница между val и def? Дело в том, что значение val вычисляется однажды и дальше используется при каждом упоминании, а значение типа def вычисляется каждый раз при упоминании. Проверить это можно следующим фрагментом кода:

val valrand = Random.nextInt()
def defrand = Random.nextInt()

println(valrand)
println(valrand)
println(defrand)
println(defrand)

Первые две строчки вывода будут совпадать, а вторые две — различаться.

Если же у функции много входных параметров, тип можно записать так:

(Type1, Type2, … TypeN) => ResultType

К примеру, функция сложения двух чисел:

def add(a: Int, b: Int) = a+b

будет иметь тип: (Int, Int) => Int.

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

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

(argument_list) => value

value вполне может быть блоком, в котором вы можете вводить дополнительные переменные и совершать некоторые действия. Примеры:

val sum: (Int, Int) => Int = (a,b) => a+b

val f2: Int => Int = a =>{
  	val v1 = Random.nextInt()
  	val v2 = v1 % 10 -5
  	a - v2
	}

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

def transform (a: Int, b: Int, f: (int, Int) => Int): Int = f(a,b)

Есть ещё один нюанс, связанный с функциями. Существует 2 семантики передачи параметров: call by name и call by value. Call by value используется по умолчанию: значение сначала вычисляется, а потом передаётся в функцию. Call by name, наоборот, сначала передаётся в функцию, а потом вычисляется в каждом месте упоминания. Для указания этой семантики перед типом ставится знак =>. Продемонстрировать это можно следующим примером:

var i =0
def inc = {
    i += 1
    i
}
def callByValueDemonstration (num: Int) = {
    println(num)
    println(num)
    println(num)
}
def callByNameDemonstration (num: =>Int) = {
    println(num)
    println(num)
    println(num)
}

callByValueDemonstration(inc)
callByNameDemonstration(inc)

Вы должны получить следующий вывод: 1 1 1 2 3 4. Таким образом, если вы хотите передать в функцию генератор случайных чисел, вы должны использовать семантику callByName.

Заключение

Мы познакомились с основами языка программирования Scala: рассмотрели, как настроить окружение, как вводить переменные, функции. Мы также рассмотрели два вида семантики методов — callByName и callByValue.

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

Иван Камышан

Подобрали три теста для вас:
— А здесь можно применить блокчейн?
Серверы для котиков: выберите лучшее решение для проекта и проверьте себя.
Сложный тест по C# — проверьте свои знания.

Также рекомендуем: