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


Парадигма программирования — это в первую очередь стиль мышления: то, как программист думает о представлении данных и процессе их обработки. Другими словами, парадигма живёт в голове программиста, а не является свойством языка. Разные языки могут в той или иной степени поддерживать определённую парадигму. Если сейчас зайти на Википедию и начать читать про самые популярные ЯП, мы увидим, что многие из них заявлены как "мультипарадигменные": на них можно писать в разных стилях, но какие-то из них использовать будет удобнее.



В своей недавней статье мы рассказывали о практических применениях Лиспа и упомянули, что он сильно повлиял на развитие других языков программирования, но не стали вдаваться в детали. Пришло время более подробно раскрыть эту тему и разобраться, какой вклад функциональное программирование в целом (не только Лисп!) внесло в развитие других языков. Поскольку мы используем Haskell как основной язык разработки, и наша команда разработчиков состоит из ФП-энтузиастов, мы не смогли пройти мимо такой темы.


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


Функции первого класса


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



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


void update_user_balance(int user_id, double (*update_fn)(double)) {
  // ...
  user->balance = update_fn(user->balance);
  // ...
}

Функция первого класса (first-class function) — те, которыми можно манипулировать как со всеми другими значениями: передавать как аргументы, возвращать в качестве результата, присваивать переменным и полям структур.


Безымянная функция (lambda function) — это функция без названия ????. Кроме отсутствия имени поддержка безымянных функций снимает другие ограничения языка на объявление функций (в некоторых языках, например, в стандарте C99, объявления функции могут встречаться только на верхнем уровне). Поддержка безымянных функций предполагает, что функция может быть объявлена в любом месте где валидны обычные выражения. Безымянные функции чаще всего используются в функционалах, их совместное использование очень удобно и позволяет сильно сократить код.


// Пример использования безымянной функции для печати содержимого 
// std::vector
int main() {
  std::vector<int> v;
  v.push_back(1);
  v.push_back(2);
  std::for_each(v.begin(), v.end(), [] (int x) { 
    std::cout << x << '\n'; 
  });
}

Замыканиe (closure) — функция может захватить некоторые переменные из контекста в котором она была объявлена, не позволяя сборщику мусора уничтожить данные которые могут быть использованы в этой функции до тех пор пока в приложении существует ссылка на саму функцию. Пример на TypeScript:


function createClosures() {
  // Переменная видна внутри функций ниже
  let counter = 0;
  // Значения полей inc и print являются замыканиями
  return {
    inc: () => { counter++; },
    print: () => console.log('counter value: ' + counter),
  };
}

const c = createClosures();
c.inc();
c.inc();
c.print(); // >>> "counter value: 2"

Абстракция списков


Абстракция списков (list comprehension) позволяет компактно записывать обработку или генерацию списков из уже существующих. Одним из первых языков, в котором использовался такой синтаксис, была Miranda, из которой его позаимствовал Haskell, а затем подобные конструкции стали появляться в "менее функциональных" языках, таких как Python, C#, Ruby.


image


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


# Пример на python
nouns = ["waterfall", "moon", "silence"]
adjectives = ["late", "divine", "blue"]
phrases = [a + " " + n for n in nouns for a in adjectives]
# >>> ['late waterfall', 'divine waterfall', 'blue waterfall', 'late moon', 'divine moon', 'blue moon', 'late silence', 'divine silence', 'blue silence']

-- То же самое на haskell
nouns = ["waterfall", "moon", "silence"]
adjectives = ["late", "divine", "blue"]
phrases = [a ++ " " ++ n | n <- nouns, a <- adjectives]
-- >>> ['late waterfall', 'divine waterfall', 'blue waterfall', 'late moon', 'divine moon', 'blue moon', 'late silence', 'divine silence', 'blue silence']

Алгебраические типы данных


Также эти типы могут называться ADT-типами, типами-суммами, дискриминированными объединениями, дизъюнктивными объединениями, копроизведениями, а может ещё какими-то умными словами. Вы можете быть знакомы с идеей таких типов под разными названиями, но, если говорить коротко, то это составной тип, который содержит поле-дискриминант (можно назвать тегом) вместе с ассоциированными с ним данными. Ниже пример на Haskell такого типа-объединения, описывающего возможные действия пользователя в гипотетической реализации приложения TodoMVC. Часть действий несут в себе "полезную нагрузку" (строку или ID элемента).


data UserAction
  = TextInput String
  | EnterPressed
  | EscPressed
  | CheckTodoItem ItemId Bool
  | EditTodoItem ItemId String
  | DeleteTodoItem ItemId
  | ApplyFilter TodoFilter

Несмотря на простоту и полезность в моделировании сущностей предметной области, поддержку ADT редко можно встретить в полном объеме в популярных языках и в базах данных. Вот некоторые примеры которые реализуют похожие типы: Enum в Rust, Sealed Classes в Kotlin, std::variant в C++


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


Cопоставление с образцом (Pattern Matching) — это синтаксическая конструкция, которая позволяет получить доступ к данным структуры, состоящей из одного или нескольких вариантов с разным набором полей (те самые ADT, алгебраическая сумма типов, enum, std::variant и т.д., про которые говорится в предыдущем пункте). Pattern Matching напоминает всем знакомый из императивных языков оператор switch-case, но его главное преимущество в том, что доступ к полям вариантов проверяется компилятором статически, используя информацию о типе выражения, в то время как switch-case не позволяет избежать ошибок с некорректным доступом к полям, отсутствующими case'ами или избыточными проверками.


Pattern Matching — ещё один прием который был популяризован в функциональных языках, где показал свою практичность и в настоящее время в разных формах активно заимствуется и интегрируется в Python, Java, C#, Rust и в других популярных языках.


-- Пример функции обновления состояния в гипотетическом TodoMVC
-- написанном в стиле архитектуры Elm. Сопоставление с Образцом
-- используется для анализа события сгенерированного пользователем.
-- Событие имеет тип UserAction, который мы описали выше как пример АТД.
updateTodoList :: UserAction -> TodoState -> TodoState
updateTodoList action oldState = case action of
  TextInput newTitle -> oldState {title = newTitle}
  EnterPressed -> appendNewItem oldState
  EscPressed -> stopEditing oldState
  CheckTodoItem itemId itemChecked ->
    updateItemById itemId (#checked .~ itemChecked)
  EditTodoItem itemId itemTitle ->
    updateItemById itemId (#title .~ itemTitle)
  DeleteTodoItem itemId -> deleteTodoItembyId itemId oldState
  ApplyFilter newFilter -> oldState {filter = newFilter}

Ленивые вычисления


В большинстве ЯП вычисление значения происходит в момент его присвоения переменной, все аргументы вычисляются перед вызовом функции (строгие вычисления). Альтернативный подход — "ленивый", когда вычисление значения откладывается до его использования. Ленивые вычисления позволяют работать с бесконечными структурами данных, писать декларативный код с определениями, организованными в порядке, удобном для чтения, а не в порядке их вычисления. Если вы использыете DSL подход, ленивые вычисления помогают легко реализовать такие конструкции как if-then-else (будет вычисляться значение только в нужной ветке).


История термина уходит корнями в лямбда-исчисление, одну из теоретических основ ФП, поэтому неудивительно, что в основном оно используется в ФП-языках. Например, в Haskell всё вычисляется лениво по-умолчанию.


Элементы "ленивости" можно найти и в других языках, даже в чистом Си логические операторы && и || ленивые: не вычисляют свой второй аргумент, если первый вычислился в 0 или 1 соответственно. В более высокоуровневых языках чаще используется термин "отложенные вычисления", которые реализованы с помощью функций-генераторов и ключевого слова yield. Такие генераторы есть, например, в Python или в Java


Континуации


Континуация (продолжение) представляет собой "остаток вычислений", т.е. для каждого подвыражения в программе описывается то, что осталось сделать с результатом этого выражения. Выражение получает континуацию в виде дополнительного аргумента, и, когда получен результат, текущая функция вызывает переданную континуацию с вычисленым значением вместо прямого возврата результата. Такой стиль передачи результата называется Continuation-passing style (CPS).


// Прямой стиль передачи результата
function getFoo(): Foo {..}

// CPS-стиль
function getFooCPS<A>(cont: (foo: Foo) => A): А {..}

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


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


Futures and promises


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


const getJson = url => fetch(url).then(resp => resp.json());

// Отправка 2-х последовательных запросов
const getUserInfo = getJson('/current-user-id').then(
  userId => getJson(`/user-info/${userId}`).then(
    userInfo => console.log(`Hi ${userInfo.firstName}, your id is ${userId}`)
  )
);

Промисы были популяризованы во многом благодаря их адаптации и широкому применению в браузере. Исполнение JavaScript в браузере ограничено только одним потоком выполнения и ожидание ответа HTTP-запросов в блокирующем стиле, как принято в большинстве платформ, приводило бы к зависанию страницы и раздражению пользователей. По этой причине для обработки ответов HTTP-запросов в браузере используются коллбек-функции. В то же время комбинировать такие запросы не очень удобно, и для описания кода, ставшего нечитаемым из-за большого количества коллбеков, возник термин "Ад обратных вызовов" (Callback hell). Промисы позволили частично решить проблему с отправкой параллельных запросов и последовательной цепочкой запросов:


// Отправка 3-х параллельных запросов
const fetchInParralel = Promise.all([
  getJson('/current-user-info'),
  getJson('/shopping-cart'),
  getJson('/recently-viewed-items'),
  ]).then(([userInfo, cart, viewedItems]) => {
    // Отобразить страницу используя полученную с сервера информацию
    // ...
  })

Во многих популярных языках (например C#, Java, JavaScript) промисы стали основным инструментом для асинхронного программирования.


Монадический интерфейс


Названия многих конструкций и приемов программирования в Haskell были заимствованы из теории категорий и других областей математики. Один из таких терминов — "Монада" стал предметом многих мемов и шуток про функциональное программирование. В сети существуют множество статей с объяснением, что такое "Монада" в функциональных языках и как их использовать.



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


// Пример монады для генерации псевдослучайных значений, параметр А —
// тип генерируемого значения
class Random<A> {
  // Создание Random из произвольного значения
  static of<A>(value: A): Random<A> {...}
  // Метод для реализации цепочки вызовов
  chain<A, B>(this: Random<A>, then: (a: A) => Random<B>): Random<B> {...}
}

declare function randomNumber(min: number, max: number): Random<number>;
declare const randomString: Random<string>;

// Пример использования монадной цепочки
const randomUser: Random<User> = randomString.chain(
  userName => randomNumber(12, 90).chain(
    userAge => Random.of({ name: userName, age: userAge })
  )
);

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


Async


Если вернуться к примерам кода с промисами, можно заметить, что, несмотря на преимущества промисов, цепочка вызовов выглядит немногим лучше использования коллбеков. Синтаксическая конструкция async-await позволяет пойти далее и улучшить код с цепочкой промисов, делая его похожим на код с блокирующими вызовами.


const getJson = url => fetch(url).then(resp => resp.json());

// Отправка 2-х последовательных запросов
async function getUserInfo() {
  const userId = await getJson('/current-user-id');
  const userInfo = await getJson(`/user-info/${userId}`);
  console.log(`Hi ${userInfo.firstName}, your id is ${userId}`);
};

Возникновение async-await можно отнести к исследовательским работам по конкуррентному программированию на Haskell и ML которые подтолкнули к появлению async workflows в F# (2007) и затем C# (2011).


Выводы


Языки программирования не стоят на месте и активно развиваются, обрастая новыми средствами, всё более продвинутыми и удобными. Как видим, в последнее время в популярных языках, таких как Python или С++, стало появляться всё больше фишек, пришедших из функционального программирования. Более молодые языки, например, Scala и Kotlin, изначально создавались с поддержкой функциональных средств.


Функциональное программирование, оказывается, намного ближе, чем может показаться, даже если разработка ведётся на C++ или Java!


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


Вам может быть интересно:


  1. Сильные стороны функционального программирования
  2. Как мы выбираем языки программирования в Typeable
  3. А вы знаете, где сейчас используется Лисп?
  4. 7 полезных инструментов на Haskell

Комментарии (15)


  1. uvelichitel
    15.11.2021 19:12
    +4

    А еще, может быть, рекурсию вместо циклов и рекурсивные декларации типов данных? Ранние Cobol, Fortran, PL / I такое ведь вовсе не умели))


    1. apache2 Автор
      15.11.2021 19:34
      +4

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


      1. csl
        16.11.2021 15:58

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


      1. WASD1
        16.11.2021 21:55

        Мне это видится довольно неудачным примером (когда я столкнулся с этим - начав изучать FP с решения задачек на Haskell).

        Вот сценарий: обычный вложенный цикл (с общей головой), который неудобно сократить в виде map / fold / ....

        При переписывании через рекурсию превращается вот в такое:

        f args = if something then f (prepare_args args) else g args

        Не знаю как людям давно с FP знакомым, но мне это представляется крайне "синтаксически контринтуитивным". Нет понимания того, что функция g() - вложенная относительно f(), т.к. из f() вызывается как она сама, так и g()


        1. csl
          17.11.2021 13:20

          обычный вложенный цикл (с общей головой), который неудобно сократить в виде map / fold / ...

          Можете привести код?


    1. OlegZH
      15.11.2021 22:30
      +7

      Да здравствует FORTH!


      1. mikelangelo
        17.11.2021 08:49

        гослинг изначально собирал команду для создания жавы из фортеров

        потом подключили сишников, просто потому что внутреннее устройство и внешний вид кода никак не связано

        в результате получили монстра.. на форте человек сто на всей планете могут интерпретатор написать, а на сях миллиард (и это не считая индусов и китайцев)

        пришлось нивелировать идею контекстов до классов а кодеров - до программистов


  1. algor_1
    16.11.2021 14:12
    +3

    Спасибо за статью!

    Пользуясь случаем, спрошу у более знающих коллег: а как вообще определить что такое ФП-стиль? Не через признаки, а через суть?

    Я для себя некоторое время назад некое подобие определения сформулировал, но я всё же совсем не специалист в этом всём, поэтому буду рад, если кто-нибудь скажет, прав ли я.

    По мне так императивное программирование основано на машине Тьюринга: мы всё равно мыслим некоего исполнителя и некую ленту-память, и исполнитель исполняет некоторый набор команд, меняя состояние памяти.

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

    Меня смущает то, что, как мне кажется, моё понимание этой всей терминологии противоречит тому, как она обычно применяется, поэтому мне кажется, что я ошибаюсь.


    1. csl
      16.11.2021 15:37
      -2

      императивное программирование основано на машине Тьюринга

      ...

      Функциональное же программирование основывается на лямбда-исчислении.

      https://ko.com.ua/-ischislenie_33286

      Лямбда-исчисление - это тоже абстрактная машина Тьюринга.


      1. csl
        17.11.2021 15:05

        Прокомментирую.

        "[...]Лямбда-исчисление — это не язык программирования, а формальный аппарат, способный определить в своих терминах любую языковую конструкцию или алгоритм. В этом смысле оно созвучно машине Тьюринга, только соответствует функциональной парадигме, а не императивной."

        https://habr.com/ru/post/215807/


    1. apache2 Автор
      16.11.2021 15:39
      +1

      Вы сами очень хорошо описали суть функционального и императивного программирования, я использую такое же интуитивное представление об этих стилях при разработке. Вы также правильно заметили что на практике применение ФП слабо напоминает выражения λ-исчисления. Главная причина в необходимости функциональной программы как-то взаимодействовать с окружением (устройствами ввода/ввывода, сетью, базой данных) которые требуют исполнения императивных команд в программе, поэтому в реальных программах присутствуют оба эти стиля (причем в типичном веб-сервере написанном на Haskell императивный код может занимать значительную часть). Большое количество императивного кода все-же не считается хорошим признаком, общепринятое мнение в ФП-коммьюнити то что чистый функциональный код имеет множество преимуществ поэтому более предпочтителен и необходимо иметь абсолютный минимум императивного кода.

      Еще одна возможная причина почему реальные функциональные программы не похожи на выражения λ-исчисления, потомучто λ-исчисление — очень плохой язык программирования, он имеет минимум конструкций что удобно для индуктивных доказательств каких-нибудь свойств но в реальной программе хорошо иметь язык с синтаксическим сахаром, конструкциями вроде (let, where, case и т.д.) чтобы иметь шанс прочитать и понять программу написанную другими программистами.


      1. csl
        16.11.2021 15:42

        чистый функциональный код

        А под "чистым" подразумевается "с контролем побочных эффектов".


        1. apache2 Автор
          16.11.2021 15:46

          Скорее без побочных еффектов, только вычисление какого-то значение без мутаций


          1. csl
            22.11.2021 15:33

            "Функция f является чистой если выражение f(x) является ссылочно прозрачным для всех ссылочно прозрачных x"

            https://habr.com/ru/post/479238/


  1. mikelangelo
    24.11.2021 17:49
    -1

    нас обучали писать на Вольфраме

    это язык символьных вычислений, почти чистое фп

    для детей которые в лучшем случае что-то понимали в басике или паскале это был ад

    как это - ленивые вычисления? почему эта фигня не делает что я прошу немедленно?

    циклы, ифы, циклы, ифы.. и незачеты.. семестр только половина группы закончила.. им так и не удалось усвоить