Современные языки программирования обладают большим набором разнообразных средств и удобных фишек, что позволяет писать совершенно разный код на одном и том же языке для одной и той же задачи.
Парадигма программирования — это в первую очередь стиль
В своей недавней статье мы рассказывали о практических применениях Лиспа и упомянули, что он сильно повлиял на развитие других языков программирования, но не стали вдаваться в детали. Пришло время более подробно раскрыть эту тему и разобраться, какой вклад функциональное программирование в целом (не только Лисп!) внесло в развитие других языков. Поскольку мы используем 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.
В качестве примера рассмотрим код на 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!
В комментариях будем рады услышать про ваш опыт использования этих или каких-то других функциональных фишек в повседневной разработке.
Вам может быть интересно:
Автор:
apache2