Какой вклад внесло функциональное программирование в современные языки?

в 15:27, , рубрики: functional programming, haskell, programming, Блог компании Typeable, парадигмы, парадигмы программирования, Программирование, ФП, функциональное программирование, языки программирования, ЯП

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

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

Какой вклад внесло функциональное программирование в современные языки? - 1

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

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

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

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

Какой вклад внесло функциональное программирование в современные языки? - 2

Функция высшего порядка (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 были заимствованы из теории категорий и других областей математики. Один из таких терминов — "Монада" стал предметом многих мемов и шуток про функциональное программирование. В сети существуют множество статей с объяснением, что такое "Монада" в функциональных языках и как их использовать.

Какой вклад внесло функциональное программирование в современные языки? - 4

Если же попытаться дать определение в общепонятных терминах, "Монада" — это просто интерфейс с двумя методами, которые позволяют связывать вычисления в цепочку как это делается на примере цепочки промисов. Промисы сами тоже являются примером реализации монадическиго интерфейса. В разных языках монадические вычисления могут иметь разные названия, например, 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

Автор:
apache2

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js