Предлагаю читателям «Хабрахабра» перевод статьи «16 Months of Functional Programming». Все мои замечания будут выделены курсивом.
В этой статье я хочу поделиться с вами моим опытом в функциональном программировании. Я чувствую, что в целом за прошедшие 16 месяцев стал лучше разбираться в информатике и компьютерах, чем за предыдущие 10 лет и всё это благодаря моему погружению в Scala и мир функционального программирования. Причина по которой функциональное программирование побуждает вас к постоянному развитию заключается в том, что каждую задачу необходимо переосмысливать заново. Порой невозможно поверить в то, что большинство стандартных задач могут быть решены иным путём и — бум! — функциональный подход предлагает лучшее решение и это шокирует.
Так что же случилось со мной за эти 16 месяцев? Закрыв свой стартап, я стал подыскивать интересный проект для работы. Получил временную подработку консультантом в 2lemetry, что позднее вылилось в полный рабочий день с погружением в функциональное программирование, работу с MQTT брокерами и распределёнными системами. Во время работы в 2lemetry Scala оказала на меня сильнейшее влияние. Приходилось отбрасывать свои представления о программировании и обучаться всему с нуля.
В этой статье я хочу рассказать о некоторых концепциях функционального программирования, которые меня впечатлили. Моя цель — зажечь искру в умах программистов, которые уже имеют опыт работы с такими языками как Java, Ruby и Python. Не беспокойтесь о том, что некоторый код на Scala, который я привожу в этой статье, может быть вам непонятен. Цель этого кода — продемонстрировать концепции функционального программирования в общих чертах. Так же стоит упомянуть, что Scala — это не чистый функциональный язык, т.е. некоторые вещи могут показатся неудобными при смешивании их с ООП. Я постараюсь указывать на такие моменты.
Содержание:
Мой первый опыт работы со Scala был достаточно типичным: я начал работать над большим проектом с ощущением того, что Scala — это как Java с некоторыми крутыми дополнениями и некоторыми возможностями из Ruby. Как же сильно я ошибался! Мой код содержал изменяемое состояние везде, где это только возможно и я не понимал для чего могут пригодиться неизменяемые списки и переменные. Как изменить значения в неизменяемых списках? Как изменить значения в неизменяемых отображениях (map)? Как работать с неизменяемым состоянием в циклах?
Для демонстрации преимущества от неизменямого состояния я покажу две версии одной и той же программы, одна на Java, другая на Scala. Следующий код на Java фильтрует список пользователей по флагу активности, сортирует его по ID, затем получает список имён у этих отфильтрованных пользователей:
Автору оригинальной статьи в комментариях указали, что на Java 8 этот код был бы проще. Он ответил, что его задача показать общепринятый императивный подход при работе с данными.
Это типичный код для Java до 8-ой версии: каждая коллекция изменяется некоторым набором действий. Кроме того, весь код несколько многословен, в каждой части кода activeById вы говорите компьютеру что вы хотите, чтобы он сделал с данными вместо того, чтобы описать как данные должны быть обработаны от начала и до конца. Вот та же самая программа написанная в функциональном стиле:
В отличие от примера на Java, код приведён полностью, т.е. можно его запускать в таком виде и он будет работать.
Код выглядит чище и короче по сравнению с императивным подходом, потому что здесь нет состояния которое надо отслеживать. Функция activeById принимает один аргумент (список пользователей) и пропускает его через цепочку функций, которые являются часть языка. Важно отметить, что функции filter, sortBy и map выбраны не случайно. Эти функции отлично описаны и изучены приверженцами функционального программирования.
Рубисты должны были заметить, что это�� пример кода очень похож на то, что они пишут на Ruby. Да, этот код может выглядеть похоже, но механизм неизменного состояния, лежащий в основе, сильно отличается. Проблема Ruby в том, что он не поддерживает неизменное состояние. Каждая переменная и структура данных потенциально может быть изменена и это приводит к тому, что нельзя ничему доверять. В Scala есть vals (read-only переменные) и неизменяемые коллекции, которые действительно неизменны.
В итоге, что даёт нам неизменяемость? С практической точки зрения ваш код будет чище, будет меньше подвержен ошибкам (вы всегда знаете что находится в ваших коллекциях и read-only переменных) и лучше абстрагируемым. Другая большая выгода от неизменности состояния заключается в том, что при написании конкурентных (concurrent) программ вас не будет беспокоить то, что один поток может повредить данные использующиеся в другом потоке.
Функциональное программирование — это о функциях (сюрприз?). Существуют различные виды функций и техник функциональной композиции, которые используются в функциональном программировании.
Чистые функции являются одним из столпов функционального программирования. Чистая функция — это функция, которая зависит только от её входных параметров. Она возвращает результат без изменения внешнего состояния. Функции sin(x: Double) или md5(s: String) отличные примеры чистых функций, которые зависят только от входных параметров и всегда возвращают ожидаемый результат, т.к. они не полагаются на состояние окружающего мира. Эти факторы делают чистые функции легко тестируемыми и менее склонным к багам.
Очевидно, что не все задачи могут быть реализованы с использованием чистых функций. Например, операции ввода/вывода, логирования, чтения и записи в БД и т.д. В функциональном программировании существуют модели и абстракции, которые позволяют реализовать эти нечистые абстракции в чистом виде, что приводит к более чистому и компонуемому коду.
Функции в Scala являются объектами первого класса. Это означает, что не только методы класса могут быть объявлены и вызваны. Кроме того, они могу использоваться как самостоятельный тип данных. Вы можете передавать функцию в другую функцию и возвращать из функции другую функцию. Вы можете сохранять функцию в переменную или в структуру данных. Вы можете работать с ней, как с литералом без того, чтобы как-то её называть (лямбда-функция). Пример:
Посмотрите на n => n % 2 == 0, полная форма (n) => { n % 2 == 0 }, это функция без имени (лямбда-функция), которая проверяет, является ли число чётным. Вы можете передавать лямбда-функции как аргумент другой функции или использовать её как возвращаемое значение.
Функции могут быть вложенными в другие функции. Это полезная возможность, когда вам понадобится рекурсивно вызывать подпрограмму (subroutines), которую вы не хотите помещать в область видимости выходящую за пределы вашей функции.
В этом примере мы не хотели бы, чтобы функция go была доступна в той же области видимости, что и функция toList, потому что её реализация необходима только для функции toList и в области видимости, в которой находится функция toList, может оказаться другая функция с названием go.
Каррирование и частичное применение функции — это чисто математическая концепция, которая прекрасно применяется в функциональных языках. Это позволяет нам сохранять частично вызванные функции в переменные и передавать их в другие функции.
??? — означает объявление без определения
В этом примере у нас есть две шаблонные функции loadRecordsFor и formatRecords. Мы частично применили loadRecordsFor для некоторого пользователя и сохранили результат в переменную userRecordsLoader. Затем вызываем formatRecords с параметром userRecordsLoader, т.к. эта переменная соответствует сигнатуре функции formatRecords (т.е. вызываем функцию formatRecords с первым аргументом — частично применённой функцией). Этот вид функциональной композиции достаточно удобен во многих ситуациях и делает код менее жёстким.
Этот пример не очень показательный. Считаю, что тему каррирования автор не раскрыл.
Тип данных Option — это абстракция, которая представляет опциональные значения. Может показаться, что он не особо необходим, но в повседневной работе это черезвычайно мощный механизм для представления null, empty, битых объектов и переменных значений.
Тип Option — это контейнер, который содержит значение определённого типа, представляемого как Some[T] или ничего не содержащего, представляемого как None. Применение этого типа в нашем коде позволяет забыть о бесчисленных случаях возникновения исключений при обращении к null-указателю (null pointer exceptions) или какую-либо несовместимость типов, всякий раз, когда извлекаются null-значения.
Давайте рассмотрим пример:
Здесь мы пытаемся получить запись о проекте из БД, но мы не знаем существует ли проект с таким ID. Вместо того, чтобы возвращать null или выбрасывать исключение, мы возвращаем Some[Project] или None, т.к. при объявлении метода find мы указали тип возвращаемого значения как Option[Project].
Контейнерные типы позволяют нам использовать другой мощный инструмент — сопоставление с образцом (pattern matching). Сопоставление с образцом — это подход к обработке данных на основе их структуры. Eсли мы захотим обработать результат вызова метода find из примера выше и получить название проекта мы можем сделать что-то вроде:
Мы сопоставляем с образцом результат работы метода find для пр��верки существования проекта. Если он существует, то возвращается его название, иначе возвращаем пустую строку. На первый взгляд, это может выглядеть как оператор switch-case в Java, но на самом деле они сильно различаются. При сопоставлении с образцом вы можете добавлять нетривиальную логику в шаблоны:
Так же вы можете сопоставлять результат на основе текущей структуры объекта:
Этот подход к обработке сложных логических конструкций более компактный и прямолинейный по сравнению с if-операторами и громоздким switch-case.
Одна из замечательных возможностей функциональной композиции — это функциональные цепочки. Вместо того, чтобы постоянно повторять однообразные действия над коллекциями с использованием циклов, можно сделать это одним элегантным выражением или однострочником. Например:
В этом однострочнике мы собрали всех подписчиков чей результат меньше 50 и которые до сих пор активны, затем мы изменили статус у выбранных подписчиков на false. В конечном итоге мы получили List(Participant(Jack, 34, false)). Есть достаточно большое кол-во ситуаций в которых подобные однострочники сохраняют программистам время и резко сокращают количество кода.
Если однострочник становится слишком непрозрачным, то его всегда можно разбить с помощью for-генератора. Пример выше можно переписать в эквивалентное выражение:
Это выражение более многословно чем однострочник, но в тех случаях, когда логика становится слишком непрозрачной, это техника действительно помогает улучшить читаемость кода при сохранении всех преимуществ неизменяемости и функциональных цепочек.
В Scala for-генератор – это синтаксический сахар для цепочки функций.
После программирования на Ruby, строгая типизация в Scala ощущалась как бремя, потому что я использовал её как джавист. Я добавлял подробное описание типов везде и не использовал обобщённые (generic) функции. Излишне сказать, что это был неправильный подход к работе. Некоторые функциональные языки обладают продвинутой системой типов с такими свойствами, которые не используются программистами на популярных языках. Эти свойства позволяют коду быть более гибким и компонуемым. Давайте пройдёмся по некоторым из них.
Вывод типов является способностью компилятора догадаться о типе выражения без явного указания его программистом. В некторых случаях Scala не может догадаться о типе без подсказки. Давайте посмотрим на пример:
Этот пример показывает обе стороны вывода типов в Scala: вы обязаны явно указывать типы, но в некоторых случаях, вроде того, когда вы передаёте лямбда-функцию в качестве аргумента (l, r) => ..., типы можно опускать. В чистых функциональных языках таких, как Haskell, вам вряд ли когда-либо придётся указывать тип в программах (спорное утверждение). Компилятор достаточно умён, чтобы вывести их.
Cтоит отметить, что при объявлении функции не обязательно было указывать тип возвращаемого значения, компилятор Scala выведет его сам.
Ограничение типов — это ещё одна важная концепция в функциональном программировании. В общем случае это означает, что вы можете указать иерархию класса при объявлении обобщенного типа. В Java вы можете использовать обобщения для определния типа во время исполнения и по-прежнему считать свой код типобезопасным. Для примера, чтобы объявить обобщённый список элементов некоторого типа вы должны использовать интерфейс (код на Java): public interface MyList<T>. Если вы хотите объявить список, скажем, содержащий отображения (Map), но при этом вы не знаете какая реализация отображений будет ис��ользована, то вам необходимо указать верхнюю границу для типа в вашем интерфейсе public interface MyList<T extends Map>. Теперь вы можете использовать список заполненный такими типами как Hashtable, LinkedHashMap, HashMap и TreeMap или другими словами всех потомков интерфейса Map. При этом никакой другой тип не может быть использован, т.к. обозначена верхняя граница. Вот пример использования верхней границы в Scala:
AnyVal является родителем таких классов как Double, Float, Int и многих других (скалярных типов). В этой функции мы просто объявили, что тип T будет потомком AnyVal. Так же можно указать нижнюю границу типа, вроде [T >: Int] это будет соответствовать родителям типа Int. Вы так же можете смешивать границы типов для различных обобщений в сигнатуре функции: [T >: Int <: Any].
Одно из важных свойств продвинутой системы типов является ковариантность и контравариантность. В Java, если у вас есть class List<T>, List<Object> и List<String>, то они будут не связанны или инвариантны. С другой стороны существуют ковариантные массивы, т.е. String[] это подтип типа Object[]. Так как массивы являются изменяемыми в некоторых случаях вы можете получить исключение ArrayStoreException в рантайме. В Scala массивы инвариантны по-умолчанию и неизменяемые коллекции (или контейнерные типы) ковариантны [+A]. Так как они неизменяемые все потенциальные ошибки будут обнаружены во время компиляции, а не в рантайме. Другая возможность — указать контейнер контрвариантным [-A]. Контрвариантность означает то, что контейнер с родительским типом является подтипом контейнера с дочерним типом. Вот как это работает:
Ковариантность и контрвариантность широко используется в реализации коллекций и хотросплетениях при указании типа функции.
Последняя продвинутая возможность типов, которую я хотел бы затронуть, это границы вида (view bounds). Предположим, что вам нужно выполнить операции над числами, но некоторые из них представлены в виде строк (т.е. «123» или 123). Как вы сделаете это? В простом случае, как этот, вы можете сконвертировать строки в числа вручную или, в более сложных случаях, написать собственный конвертер, а затем явно вызывать его для конвертации данных из одного типа в другой. В слаботипизированных языках, таких как Ruby, интерпретатор будет динамически конвертировать строки в числа. Возможно, вы будете удивлены тем, что в Scala есть способ реализовать подобное поведение без потери типобезопасности.
Насколько я понимаю, автор ошибается с Ruby, например, такой код работать не будет [10, «20»].min. В данном случае корректнее было бы привести в пример PHP.
Чтобы это заработало в Scala (давайте используем стандартную функцию math.min() для примера) всё что необходимо сделать — это определить неявный конвертер для ваших типов:
Тут Scala будет искать неявные преобразования из строки в число. После нахождения функции strToInt, основанной на её сигнатуре, она будет применена ко всем строкам переданным в math.min без явного вызова strToInt. Если вы не объявили неявную конвертацию, то компилятор выкинет исключение. А если объявили и попытаетесь передать строку, которая не является числом, то получите исключение в рантайме.
Что если мы хотим написать магическую функцию, которая найдёт неявную конвертацию для себя самой? Это очень просто! Всё что вам нужно — это объявить границу вида, которая укажет компилятору производить поиск неявной конвертации:
Результатом вызова halfIt(«20») будет 10 как и ожидалось. Граница вида [A <% Int] ожидает Int или всё, что может рассматриваться как Int. В нашем случае это строка, которая может быть неявно преобразованна в число.
Концепция ленивых вычислений непосредственно не существует в нефункциональных языках, но её легко уловить. Посмотрите на обычное условие:
В большинстве императивных языков оператор || вычисляет предикаты (a == b) и expensiveOperation() лениво, т.е. expensiveOperation() не будет вызван до тех пор пока if (a == b) равен true (т.е. до тех пор, пока a равно b). И expensiveOperation() будет вызван если if (a == b) вернёт false. Ленивые вычисления в Scala позволяют определить подобное поведение в разных конекстах.
Вы можете определить переменные как lazy, т.е. они не будут вычислены до тех пор, пока к ним не обратились в первый раз, в отличие от обычных переменных, которые вычисляются при объявлении. После однократного вычисления ленивая переменная запоминает своё значение.
В этом примере у нас есть case class для представления пользователя, который содержит в себе списки заказов. В отличие от обычных свойств, cheapOrders и expensiveOrders не будут вычислены в момент инициализации класса (т.е. в момент создания объекта из класса). Они вычисляются, когда мы обратимся к ним напрямую. Почему бы не использовать метод? Проблема в том, что у нас могут быть дорогостоящие вычисления или каждый раз происходить обращения к БД, когда мы вызываем метод. Ленивые переменные сохраняют свой результат при первом обращении, что может привести к эффективным оптимизациям в некоторых случаях.
В других языках используют технику мемоизации, что можно также рассматривать как ленвые вычисления.
Другой пример отложенных вычислений — это передача аргументов по имени (call-by-name). Обычно, аргументы функции вычисляются перед тем, как они будут в неё переданы. Тем не менее, в некоторых случаях бывает полезно вычислять переданный аргумент отложено тогда, когда это потребуется.
Здесь у нас три параметра by-name с потенцаильно дорогостоящими операциями обращения к БД. Мы не хотели бы, чтобы все они были выполнены до того, как попадут в наш метод. Стрелка => означает, что мы передаём внутрь саму функцию, а не возвращаемое ею значение. Теперь мы можем вызывать её когда нам это потребуется.
Ленивые вычисления и параметры by-name используются для реализации одной из мощнейших конструкций функционального программирования — бесконечных структур данных. В императивном программировании все ваши структуры данных имеют определённый размер, что на самом деле отлично для большинства случаев, но иногда неизвестно какого размера будет структура данных. С отложенными вычислениями есть возможность объявить структуру данных в общем виде без её полного содержимого до тех пор, пока оно вам не потребуется.
Всё это хорошо звучит в теории, но будет ли это работать? Давайте воспользуемся бесконечной структурой данных, называемой stream для генерации простых чисел. В Java для генерации простых чисел можно написать функцию, которая генерировала бы простые числа до некоторого предела. Затем вы могли бы вызвать эту функцию для генерации списка из N простых чисел и передать его куда-нибудь. Если вам понадобится другой список простых чисел, то вам придётся пересчитать свой список с нуля. В Scala мы можем сделать что-то вроде:
Синтаксис может показаться вам непонятным, но в данном случае это не имеет значения. Главное то, что вы можете сделать с этой структурой данных. Скажем, что вам необходимо получить первые 5 простых чисел, больших 100. Это легко, используя наш stream:
Эта функциональная цепочка вернёт List(101, 103, 107, 109, 113) — как и ожидалось. Крутая вещь заключается в том, что вы можете передавать primes в любые другие функции без необходимости её всякий раз собирать заново. Так же вы можете применять цепочки функций (как filter в примере выше) и передавать уже это выражение без генерации промежуточного результата. Это позволяет нам компоновать абстракции и лепить программы как из пластилина.
Надеюсь, я заинтересовал вас функциональным программированием. Для меня писать об этом было очень увлекательно. Я признаю, что ещё многое предстоит изучить и усвоить в этой области. Моя цель — начать глубже копать теорию функционального программирования, такую как типизированное лямбда-исчисление, теорию типов и теорию категорий. Я так же хочу изучить Haskell — чистый функциональный язык программирования, и надеюсь что это принесёт плоды.
Всем дочитавшим выражаю благодарность!
В этой статье я хочу поделиться с вами моим опытом в функциональном программировании. Я чувствую, что в целом за прошедшие 16 месяцев стал лучше разбираться в информатике и компьютерах, чем за предыдущие 10 лет и всё это благодаря моему погружению в Scala и мир функционального программирования. Причина по которой функциональное программирование побуждает вас к постоянному развитию заключается в том, что каждую задачу необходимо переосмысливать заново. Порой невозможно поверить в то, что большинство стандартных задач могут быть решены иным путём и — бум! — функциональный подход предлагает лучшее решение и это шокирует.
Так что же случилось со мной за эти 16 месяцев? Закрыв свой стартап, я стал подыскивать интересный проект для работы. Получил временную подработку консультантом в 2lemetry, что позднее вылилось в полный рабочий день с погружением в функциональное программирование, работу с MQTT брокерами и распределёнными системами. Во время работы в 2lemetry Scala оказала на меня сильнейшее влияние. Приходилось отбрасывать свои представления о программировании и обучаться всему с нуля.
В этой статье я хочу рассказать о некоторых концепциях функционального программирования, которые меня впечатлили. Моя цель — зажечь искру в умах программистов, которые уже имеют опыт работы с такими языками как Java, Ruby и Python. Не беспокойтесь о том, что некоторый код на Scala, который я привожу в этой статье, может быть вам непонятен. Цель этого кода — продемонстрировать концепции функционального программирования в общих чертах. Так же стоит упомянуть, что Scala — это не чистый функциональный язык, т.е. некоторые вещи могут показатся неудобными при смешивании их с ООП. Я постараюсь указывать на такие моменты.
Содержание:
- Неизменяемое состояние
- Функции
- Тип Option и сопоставление с образцом
- Однострочники и for-генераторы
- Система типов
- Ленивые вычисления и бесконечные структуры данных
- Что дальше?
Неизменяемое состояние
Мой первый опыт работы со Scala был достаточно типичным: я начал работать над большим проектом с ощущением того, что Scala — это как Java с некоторыми крутыми дополнениями и некоторыми возможностями из Ruby. Как же сильно я ошибался! Мой код содержал изменяемое состояние везде, где это только возможно и я не понимал для чего могут пригодиться неизменяемые списки и переменные. Как изменить значения в неизменяемых списках? Как изменить значения в неизменяемых отображениях (map)? Как работать с неизменяемым состоянием в циклах?
Для демонстрации преимущества от неизменямого состояния я покажу две версии одной и той же программы, одна на Java, другая на Scala. Следующий код на Java фильтрует список пользователей по флагу активности, сортирует его по ID, затем получает список имён у этих отфильтрованных пользователей:
public class User {
private final int id;
private final String firstName;
private final String lastName;
private final Boolean active;
// Для краткости я пропустил конструкторы, геттеры и сеттеры
}
public static List<String> activeById(List<User> us) {
List<User> users = new ArrayList<User>();
for (User u: us) {
if (u.getActive()) users.add(u);
}
Collections.sort(users, new Comparator<User>() {
public int compare(User a, User b) {
return a.getId() - b.getId();
}
});
List<String> finalUsers = new ArrayList<String>();
for (User u: users) {
finalUsers.add(u.getLastname());
}
return finalUsers;
}
List<User> inputUsers = new ArrayList<User>();
inputUsers.add(new User(11, "Nick", "Smith", false));
inputUsers.add(new User(89, "Ken", "Pratt", true));
inputUsers.add(new User(23, "Jack", "Sparrow", true));
List<User> activeUsersById = activeById(inputUsers)
Автору оригинальной статьи в комментариях указали, что на Java 8 этот код был бы проще. Он ответил, что его задача показать общепринятый императивный подход при работе с данными.
Это типичный код для Java до 8-ой версии: каждая коллекция изменяется некоторым набором действий. Кроме того, весь код несколько многословен, в каждой части кода activeById вы говорите компьютеру что вы хотите, чтобы он сделал с данными вместо того, чтобы описать как данные должны быть обработаны от начала и до конца. Вот та же самая программа написанная в функциональном стиле:
case class User(id: Int, firstname: String, lastname: String, active: Boolean)
def activeById(us: Seq[User]) = us.filter(_.active).sortBy(_.id).map(_.lastname)
val activeUsersById = activeById(Seq(
User(11, "Nick", "Smith", false),
User(89, "Ken", "Pratt", true),
User(23, "Jack", "Sparrow", true)
))
В отличие от примера на Java, код приведён полностью, т.е. можно его запускать в таком виде и он будет работать.
Код выглядит чище и короче по сравнению с императивным подходом, потому что здесь нет состояния которое надо отслеживать. Функция activeById принимает один аргумент (список пользователей) и пропускает его через цепочку функций, которые являются часть языка. Важно отметить, что функции filter, sortBy и map выбраны не случайно. Эти функции отлично описаны и изучены приверженцами функционального программирования.
Рубисты должны были заметить, что это�� пример кода очень похож на то, что они пишут на Ruby. Да, этот код может выглядеть похоже, но механизм неизменного состояния, лежащий в основе, сильно отличается. Проблема Ruby в том, что он не поддерживает неизменное состояние. Каждая переменная и структура данных потенциально может быть изменена и это приводит к тому, что нельзя ничему доверять. В Scala есть vals (read-only переменные) и неизменяемые коллекции, которые действительно неизменны.
В итоге, что даёт нам неизменяемость? С практической точки зрения ваш код будет чище, будет меньше подвержен ошибкам (вы всегда знаете что находится в ваших коллекциях и read-only переменных) и лучше абстрагируемым. Другая большая выгода от неизменности состояния заключается в том, что при написании конкурентных (concurrent) программ вас не будет беспокоить то, что один поток может повредить данные использующиеся в другом потоке.
Функции
Функциональное программирование — это о функциях (сюрприз?). Существуют различные виды функций и техник функциональной композиции, которые используются в функциональном программировании.
Чистые функции являются одним из столпов функционального программирования. Чистая функция — это функция, которая зависит только от её входных параметров. Она возвращает результат без изменения внешнего состояния. Функции sin(x: Double) или md5(s: String) отличные примеры чистых функций, которые зависят только от входных параметров и всегда возвращают ожидаемый результат, т.к. они не полагаются на состояние окружающего мира. Эти факторы делают чистые функции легко тестируемыми и менее склонным к багам.
Очевидно, что не все задачи могут быть реализованы с использованием чистых функций. Например, операции ввода/вывода, логирования, чтения и записи в БД и т.д. В функциональном программировании существуют модели и абстракции, которые позволяют реализовать эти нечистые абстракции в чистом виде, что приводит к более чистому и компонуемому коду.
Функции в Scala являются объектами первого класса. Это означает, что не только методы класса могут быть объявлены и вызваны. Кроме того, они могу использоваться как самостоятельный тип данных. Вы можете передавать функцию в другую функцию и возвращать из функции другую функцию. Вы можете сохранять функцию в переменную или в структуру данных. Вы можете работать с ней, как с литералом без того, чтобы как-то её называть (лямбда-функция). Пример:
val ints = Seq(1, 2, 3, 4, 5)
ints.filter(n => n % 2 == 0)
Посмотрите на n => n % 2 == 0, полная форма (n) => { n % 2 == 0 }, это функция без имени (лямбда-функция), которая проверяет, является ли число чётным. Вы можете передавать лямбда-функции как аргумент другой функции или использовать её как возвращаемое значение.
Функции могут быть вложенными в другие функции. Это полезная возможность, когда вам понадобится рекурсивно вызывать подпрограмму (subroutines), которую вы не хотите помещать в область видимости выходящую за пределы вашей функции.
def toList: List[A] = {
@annotation.tailrec
def go(s: Stream[A], l: List[A]): List[A] = s match {
case Empty => l
case Cons(h, t) => go(t(), h() +: l)
}
go(this, List[A]()).reverse
}
В этом примере мы не хотели бы, чтобы функция go была доступна в той же области видимости, что и функция toList, потому что её реализация необходима только для функции toList и в области видимости, в которой находится функция toList, может оказаться другая функция с названием go.
Каррирование и частичное применение функции — это чисто математическая концепция, которая прекрасно применяется в функциональных языках. Это позволяет нам сохранять частично вызванные функции в переменные и передавать их в другие функции.
trait Resource
case class User(id: Int) extends Resource
case class Record()
case class FormattedRecord()
def loadRecordsFor(r: Resource, since: Long): List[Record] = ???
def formatRecords(f: Long => List[Record], since: Long): List[FormattedRecord] = ???
val userRecordsLoader = loadRecordsFor(User(36), _: Long)
formatRecords(userRecordsLoader, System.currentTimeMillis - 86400000)
??? — означает объявление без определения
В этом примере у нас есть две шаблонные функции loadRecordsFor и formatRecords. Мы частично применили loadRecordsFor для некоторого пользователя и сохранили результат в переменную userRecordsLoader. Затем вызываем formatRecords с параметром userRecordsLoader, т.к. эта переменная соответствует сигнатуре функции formatRecords (т.е. вызываем функцию formatRecords с первым аргументом — частично применённой функцией). Этот вид функциональной композиции достаточно удобен во многих ситуациях и делает код менее жёстким.
Этот пример не очень показательный. Считаю, что тему каррирования автор не раскрыл.
Тип Option и сопоставление с образцом
Тип данных Option — это абстракция, которая представляет опциональные значения. Может показаться, что он не особо необходим, но в повседневной работе это черезвычайно мощный механизм для представления null, empty, битых объектов и переменных значений.
Тип Option — это контейнер, который содержит значение определённого типа, представляемого как Some[T] или ничего не содержащего, представляемого как None. Применение этого типа в нашем коде позволяет забыть о бесчисленных случаях возникновения исключений при обращении к null-указателю (null pointer exceptions) или какую-либо несовместимость типов, всякий раз, когда извлекаются null-значения.
Давайте рассмотрим пример:
case class Project(id: String, name: String, priority: Int, description: Option[String])
object ProjectsAccessor {
find(id: String): Option[Project] = ???
}
val project = ProjectsAccessor.find(123)
Здесь мы пытаемся получить запись о проекте из БД, но мы не знаем существует ли проект с таким ID. Вместо того, чтобы возвращать null или выбрасывать исключение, мы возвращаем Some[Project] или None, т.к. при объявлении метода find мы указали тип возвращаемого значения как Option[Project].
Контейнерные типы позволяют нам использовать другой мощный инструмент — сопоставление с образцом (pattern matching). Сопоставление с образцом — это подход к обработке данных на основе их структуры. Eсли мы захотим обработать результат вызова метода find из примера выше и получить название проекта мы можем сделать что-то вроде:
ProjectsAccessor.find(123) match {
case Some(p) => p.name
case None => ""
}
Мы сопоставляем с образцом результат работы метода find для пр��верки существования проекта. Если он существует, то возвращается его название, иначе возвращаем пустую строку. На первый взгляд, это может выглядеть как оператор switch-case в Java, но на самом деле они сильно различаются. При сопоставлении с образцом вы можете добавлять нетривиальную логику в шаблоны:
ProjectsAccessor.find(123) match {
case Some(p) if 1 until 5 contains p.priority => p.name
case Some(p) if name == "Default Project" => p.name
case Some(p) => None
case None => ""
}
Так же вы можете сопоставлять результат на основе текущей структуры объекта:
def descriptionWrapper(p: Project) = p match {
case Project(_, _, _, None) => "No description."
case Project(id, _, _, Some(d)) => s"Project $id's description: $d"
}
Этот подход к обработке сложных логических конструкций более компактный и прямолинейный по сравнению с if-операторами и громоздким switch-case.
Однострочники (One-Liners) и for-генераторы
Одна из замечательных возможностей функциональной композиции — это функциональные цепочки. Вместо того, чтобы постоянно повторять однообразные действия над коллекциями с использованием циклов, можно сделать это одним элегантным выражением или однострочником. Например:
case class Participant(name: String, score: Int, active: Boolean)
val ps = Seq(Participant("Jack", 34, true), Participant("Tom", 51, true),
Participant("Bob", 90, false))
ps.filter(_.score < 50).filter(_.active).map(_.copy(active = false))
В этом однострочнике мы собрали всех подписчиков чей результат меньше 50 и которые до сих пор активны, затем мы изменили статус у выбранных подписчиков на false. В конечном итоге мы получили List(Participant(Jack, 34, false)). Есть достаточно большое кол-во ситуаций в которых подобные однострочники сохраняют программистам время и резко сокращают количество кода.
Если однострочник становится слишком непрозрачным, то его всегда можно разбить с помощью for-генератора. Пример выше можно переписать в эквивалентное выражение:
for {
loser <- ps if loser.score < 50
activeLoser <- Some(loser) if activeLoser.active
deactivatedLoser <- Some(activeLoser.copy(active = false))
} yield deactivatedLoser
Это выражение более многословно чем однострочник, но в тех случаях, когда логика становится слишком непрозрачной, это техника действительно помогает улучшить читаемость кода при сохранении всех преимуществ неизменяемости и функциональных цепочек.
В Scala for-генератор – это синтаксический сахар для цепочки функций.
Система типов
После программирования на Ruby, строгая типизация в Scala ощущалась как бремя, потому что я использовал её как джавист. Я добавлял подробное описание типов везде и не использовал обобщённые (generic) функции. Излишне сказать, что это был неправильный подход к работе. Некоторые функциональные языки обладают продвинутой системой типов с такими свойствами, которые не используются программистами на популярных языках. Эти свойства позволяют коду быть более гибким и компонуемым. Давайте пройдёмся по некоторым из них.
Вывод типов является способностью компилятора догадаться о типе выражения без явного указания его программистом. В некторых случаях Scala не может догадаться о типе без подсказки. Давайте посмотрим на пример:
// Вы всегда должны указывать типы передаваемых в метод аргументов
def nameStartsWith(ns: Seq[String], n: String): Seq[String] =
// Scala не может вывести тип для обобщённых коллекций, т.е. вы не можете сказать Seq.empty
ns.foldLeft(Seq.empty[String]) {
// Но в лямбда-функции не требуется указыать тип явно
(l, r) => if(r.startsWith(n)) r +: l else l
}
// Вывод типов хорошо работает при создании списков
val names = Seq("Bob", "Alice", "Ann")
nameStartsWith(names, "A") // возвратит List(Ann, Alice)
Этот пример показывает обе стороны вывода типов в Scala: вы обязаны явно указывать типы, но в некоторых случаях, вроде того, когда вы передаёте лямбда-функцию в качестве аргумента (l, r) => ..., типы можно опускать. В чистых функциональных языках таких, как Haskell, вам вряд ли когда-либо придётся указывать тип в программах (спорное утверждение). Компилятор достаточно умён, чтобы вывести их.
Cтоит отметить, что при объявлении функции не обязательно было указывать тип возвращаемого значения, компилятор Scala выведет его сам.
Ограничение типов — это ещё одна важная концепция в функциональном программировании. В общем случае это означает, что вы можете указать иерархию класса при объявлении обобщенного типа. В Java вы можете использовать обобщения для определния типа во время исполнения и по-прежнему считать свой код типобезопасным. Для примера, чтобы объявить обобщённый список элементов некоторого типа вы должны использовать интерфейс (код на Java): public interface MyList<T>. Если вы хотите объявить список, скажем, содержащий отображения (Map), но при этом вы не знаете какая реализация отображений будет ис��ользована, то вам необходимо указать верхнюю границу для типа в вашем интерфейсе public interface MyList<T extends Map>. Теперь вы можете использовать список заполненный такими типами как Hashtable, LinkedHashMap, HashMap и TreeMap или другими словами всех потомков интерфейса Map. При этом никакой другой тип не может быть использован, т.к. обозначена верхняя граница. Вот пример использования верхней границы в Scala:
def convertToInts[T <: AnyVal](es: Seq[T], f: T => Int): Seq[Int] = {
es.map(f(_))
}
AnyVal является родителем таких классов как Double, Float, Int и многих других (скалярных типов). В этой функции мы просто объявили, что тип T будет потомком AnyVal. Так же можно указать нижнюю границу типа, вроде [T >: Int] это будет соответствовать родителям типа Int. Вы так же можете смешивать границы типов для различных обобщений в сигнатуре функции: [T >: Int <: Any].
Одно из важных свойств продвинутой системы типов является ковариантность и контравариантность. В Java, если у вас есть class List<T>, List<Object> и List<String>, то они будут не связанны или инвариантны. С другой стороны существуют ковариантные массивы, т.е. String[] это подтип типа Object[]. Так как массивы являются изменяемыми в некоторых случаях вы можете получить исключение ArrayStoreException в рантайме. В Scala массивы инвариантны по-умолчанию и неизменяемые коллекции (или контейнерные типы) ковариантны [+A]. Так как они неизменяемые все потенциальные ошибки будут обнаружены во время компиляции, а не в рантайме. Другая возможность — указать контейнер контрвариантным [-A]. Контрвариантность означает то, что контейнер с родительским типом является подтипом контейнера с дочерним типом. Вот как это работает:
case class InvariantContainer[A]()
case class CovariantContainer[+A]()
case class ContravariantContainer[-A]()
class Person
class User extends Person
class Admin extends User
val inv1: InvariantContainer[User] = InvariantContainer[User] // works
val inv2: InvariantContainer[User] = InvariantContainer[Admin] // doesn't work
val cov1: CovariantContainer[User] = CovariantContainer[User] // works
val cov2: CovariantContainer[User] = CovariantContainer[Admin] // works
val con1: ContravariantContainer[User] = ContravariantContainer[User] // works
val con2: ContravariantContainer[User] = ContravariantContainer[Admin] // doesn't work
val con3: ContravariantContainer[User] = ContravariantContainer[Person] // works
Ковариантность и контрвариантность широко используется в реализации коллекций и хотросплетениях при указании типа функции.
Последняя продвинутая возможность типов, которую я хотел бы затронуть, это границы вида (view bounds). Предположим, что вам нужно выполнить операции над числами, но некоторые из них представлены в виде строк (т.е. «123» или 123). Как вы сделаете это? В простом случае, как этот, вы можете сконвертировать строки в числа вручную или, в более сложных случаях, написать собственный конвертер, а затем явно вызывать его для конвертации данных из одного типа в другой. В слаботипизированных языках, таких как Ruby, интерпретатор будет динамически конвертировать строки в числа. Возможно, вы будете удивлены тем, что в Scala есть способ реализовать подобное поведение без потери типобезопасности.
Насколько я понимаю, автор ошибается с Ruby, например, такой код работать не будет [10, «20»].min. В данном случае корректнее было бы привести в пример PHP.
Чтобы это заработало в Scala (давайте используем стандартную функцию math.min() для примера) всё что необходимо сделать — это определить неявный конвертер для ваших типов:
implicit def strToInt(x: String) = x.toInt
math.min("10", 1)
Тут Scala будет искать неявные преобразования из строки в число. После нахождения функции strToInt, основанной на её сигнатуре, она будет применена ко всем строкам переданным в math.min без явного вызова strToInt. Если вы не объявили неявную конвертацию, то компилятор выкинет исключение. А если объявили и попытаетесь передать строку, которая не является числом, то получите исключение в рантайме.
Что если мы хотим написать магическую функцию, которая найдёт неявную конвертацию для себя самой? Это очень просто! Всё что вам нужно — это объявить границу вида, которая укажет компилятору производить поиск неявной конвертации:
implicit def strToInt(x: String) = x.toInt
def halfIt[A <% Int](x: A) = x / 2
halfIt("20")
Результатом вызова halfIt(«20») будет 10 как и ожидалось. Граница вида [A <% Int] ожидает Int или всё, что может рассматриваться как Int. В нашем случае это строка, которая может быть неявно преобразованна в число.
Ленивые вычисления и бесконечные структуры данных
Концепция ленивых вычислений непосредственно не существует в нефункциональных языках, но её легко уловить. Посмотрите на обычное условие:
def expensiveOperation() = ???
val a = "foo"
val b = "foo"
if ((a == b) || expensiveOperation()) true else false
В большинстве императивных языков оператор || вычисляет предикаты (a == b) и expensiveOperation() лениво, т.е. expensiveOperation() не будет вызван до тех пор пока if (a == b) равен true (т.е. до тех пор, пока a равно b). И expensiveOperation() будет вызван если if (a == b) вернёт false. Ленивые вычисления в Scala позволяют определить подобное поведение в разных конекстах.
Вы можете определить переменные как lazy, т.е. они не будут вычислены до тех пор, пока к ним не обратились в первый раз, в отличие от обычных переменных, которые вычисляются при объявлении. После однократного вычисления ленивая переменная запоминает своё значение.
case class Order(name: String, price: Int)
case class User(id: Int, orders: Seq[Order]) {
lazy val cheapOrders: Seq[Order] = orders.filter(_.price < 50)
lazy val expensiveOrders: Seq[Order] = orders.filter(_.price >= 50)
}В этом примере у нас есть case class для представления пользователя, который содержит в себе списки заказов. В отличие от обычных свойств, cheapOrders и expensiveOrders не будут вычислены в момент инициализации класса (т.е. в момент создания объекта из класса). Они вычисляются, когда мы обратимся к ним напрямую. Почему бы не использовать метод? Проблема в том, что у нас могут быть дорогостоящие вычисления или каждый раз происходить обращения к БД, когда мы вызываем метод. Ленивые переменные сохраняют свой результат при первом обращении, что может привести к эффективным оптимизациям в некоторых случаях.
В других языках используют технику мемоизации, что можно также рассматривать как ленвые вычисления.
Другой пример отложенных вычислений — это передача аргументов по имени (call-by-name). Обычно, аргументы функции вычисляются перед тем, как они будут в неё переданы. Тем не менее, в некоторых случаях бывает полезно вычислять переданный аргумент отложено тогда, когда это потребуется.
trait Person
case class User() extends Person
case class Admin() extends Person
def loadAdminsOrUsers(needAdmins: => Boolean, loadAdmins: => Seq[Admin],
loadUsers: => Seq[User]): Seq[Person] = {
if (needAdmins) loadAdmins
else loadUsers
}
Здесь у нас три параметра by-name с потенцаильно дорогостоящими операциями обращения к БД. Мы не хотели бы, чтобы все они были выполнены до того, как попадут в наш метод. Стрелка => означает, что мы передаём внутрь саму функцию, а не возвращаемое ею значение. Теперь мы можем вызывать её когда нам это потребуется.
Ленивые вычисления и параметры by-name используются для реализации одной из мощнейших конструкций функционального программирования — бесконечных структур данных. В императивном программировании все ваши структуры данных имеют определённый размер, что на самом деле отлично для большинства случаев, но иногда неизвестно какого размера будет структура данных. С отложенными вычислениями есть возможность объявить структуру данных в общем виде без её полного содержимого до тех пор, пока оно вам не потребуется.
Всё это хорошо звучит в теории, но будет ли это работать? Давайте воспользуемся бесконечной структурой данных, называемой stream для генерации простых чисел. В Java для генерации простых чисел можно написать функцию, которая генерировала бы простые числа до некоторого предела. Затем вы могли бы вызвать эту функцию для генерации списка из N простых чисел и передать его куда-нибудь. Если вам понадобится другой список простых чисел, то вам придётся пересчитать свой список с нуля. В Scala мы можем сделать что-то вроде:
val primes: Stream[Int] = {
def generatePrimes (s: Stream[Int]): Stream[Int] =
s.head #:: generatePrimes(s.tail filter (_ % s.head != 0))
generatePrimes(Stream.from(2))
}
Синтаксис может показаться вам непонятным, но в данном случае это не имеет значения. Главное то, что вы можете сделать с этой структурой данных. Скажем, что вам необходимо получить первые 5 простых чисел, больших 100. Это легко, используя наш stream:
primes.filter(_ > 100).take(5).toList
Эта функциональная цепочка вернёт List(101, 103, 107, 109, 113) — как и ожидалось. Крутая вещь заключается в том, что вы можете передавать primes в любые другие функции без необходимости её всякий раз собирать заново. Так же вы можете применять цепочки функций (как filter в примере выше) и передавать уже это выражение без генерации промежуточного результата. Это позволяет нам компоновать абстракции и лепить программы как из пластилина.
Что дальше?
Надеюсь, я заинтересовал вас функциональным программированием. Для меня писать об этом было очень увлекательно. Я признаю, что ещё многое предстоит изучить и усвоить в этой области. Моя цель — начать глубже копать теорию функционального программирования, такую как типизированное лямбда-исчисление, теорию типов и теорию категорий. Я так же хочу изучить Haskell — чистый функциональный язык программирования, и надеюсь что это принесёт плоды.
Всем дочитавшим выражаю благодарность!
