August 5, 2023

Что такое функциональное программирование и почему оно ТЕБЕ нужно? ФПбаза #0.

Логотип функционального языка Haskell, являющийся видоизменённым изображением греческой буквы лямбда. Эта буква часто ассоциируется с функциональным программированием, так как почти всегда в его основе лежит такая математическая концепция, как лямбда-исчисление. У многих функциональных языков программирования в составе логотипа присутствует этот символ в том или ином виде.

Предисловие

Друзья, добро пожаловать в нулевой пост рубрики "ФПбаза". Меня зовут Арсений Алфеев (также известен как Алфений Арсеев, Chell и т.д.), я изучаю функциональное программирование. Мой основной инструмент в этом — язык программирования Haskell — это чисто функциональный язык в классическом его понимании. Для многих именно он в первую очередь ассоциируется с ФП (Функциональным Программированием).

Но целевая аудитория "ФПбазы" — это вовсе не хаскеллисты, а простые императивные работяги, коим вы, скорее всего, и являетесь. Хотя функциональное программирование куда лучше раскрывает свой потенциал при обилии дополнительных условий (которые как раз довольно строго соблюдены в языке Haskell), его элементы также часто можно использовать в таких преимущественно императивных языках программирования, как C++, Java, C#, Python, JavaScript и многих других. Моя цель — показать какие возможности открывает перед вами функциональная парадигма. Функциональному коду обычна характерны гибкость, лаконичность и выразительность. Если вы научитесь работать в этой парадигме, то вы сможете сделать ваш код более читаемым и расширяемым, даже если он не такой уж и "чистый" с функциональной точки зрения. Именно поэтому в "ФПбазе" я буду стараться делать акцент на практическом применении знаний в популярных императивных языках.

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

Благодарю за внимание, переходим к основной части!

И почти везде, если постараться, можно писать функционально!

Вступление

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

Чем ФП точно не является?

1. Функциональное программирование — это всё, кроме ООП.

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

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

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

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

2. Функциональное программирование — это про высшую математику и теорию категорий.

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

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

Вот это вот всё к ФП не имеет никакого отношения.

3. Функциональное программирование устарело, сейчас его никто не использует.

Это утверждение уже имеет под собой больше обоснований и нередко разделяется людьми, что-то знающими о возникновении и эволюции этой парадигмы. Действительно, тот математический фундамент, который заложен в основу ФП, был разработан ещё в 20-30-е годы прошлого века, когда такие учёные, как Хаскелл Карри (именно в честь него был назван язык программирования Haskell) и Алонзо Чёрч работали над комбинаторной логикой и лямбда-исчислением.

Но первым языком, в котором использовались эти идеи, был Lisp, созданный только в 1958 году. Этот язык не является чисто функциональным, да и его синтаксис по современным меркам не очень-то привлекателен, однако на момент его создания он устроил настоящюю революцию. Он предлагал множество новых концепций — динамическая типизация, автоматическое управление памятью (сборщик мусора) и, конечно же, функциональное программирование. Правда последний пункт вовсе не был обязательным. В принципе на Лиспе можно писать программы и в процедурном стиле, а более современные реализации (коих великое множество) позволяют довольно удобно работать с ООП. Конечно мощность функциональной парадигмы там была раскрыта в меньшей степени, чем в том же Haskell, но тогда его ещё не было, поэтому все тут же рванулись писать на Лиспе.

Долгое время он (а также ранее упомянутые его многочисленные диалекты) оставался на вершине топа популярности языков программирования, но позже его сместили более современные языки программирования, в которых (так уж получилось) была реализована императивная модель. В том числе это было связано и с появлением ООП. После этого популярность ФП начала спадать. Стоит также упомянуть о некотором хайпе "ну очень мультипарадигменных" языков программирования, имевших достаточно мощный аппарат функционального программирования, произошедшем в нулевых, но ни к чему особенному это опять же не привело.

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

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

Да и в конце концов, во многих мейнстримовых языках программирования применение тех или иных методов ФП является стандартом. JavaScript, Python и Kotlin — примеры изначально безусловно императивных языков программирования, разработка на которых сейчас без применения ФП в том или ином виде считается просто напросто дурным тоном.

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

Логотип Lisp (и тут тоже лямбды).

Тогда что же такое ФП на самом деле?

Чистые и нечистые

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

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

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

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

Так выглядит модель чёрного ящика для чистой функции. Синим показаны входные данные — аргументы функции, а красным выходные — значения (чаще всего выход у функции один, однако некоторые языки (например Lisp) позволяют явно возвращать несколько значений из функции без объединения их в одно целое; если задуматься, то это не лишено смысла, взять хотя бы всем известный со школы пример — нахождение корней квадратного уравнения).

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

А вот что получится, если попытаться изобразить чёрный ящик для нечистой функции:

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

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

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

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

Давайте же рассмотрим парочку примеров чистых и не очень функций.

Примеры

1. Искусственный пример чистой функции — суммирующая функция

Действительно, такая функция мало кому нужна. Всё что она может — это взять два числа и вернуть их сумму. Но на самом деле, как мы сейчас увидим, всё может оказаться чуть сложнее. Для начала напишим её на Haskell:

sum :: (Int, Int) -> Int
sum (x, y) = x + y

Даже если вы не знакомы с этим языком, думаю синтаксис будет для вас интуитивно понятен. Тип Int в Haskell в общем случае зависит от архитектуры процессора, его размер совпадает с размером машинного слова. Это значит, что при слишком больших входных данных результат может переполниться и мы получим не то число, которое по правилам арифметики является суммой входных чисел. Однако чистоту функции это вовсе не нарушает — результат переполнения точно так же предсказуем. Мы всё ещё можем быть уверены, что функция из раза в раз будет возвращать один и тот же ответ для одинаковых входов.

Чтож, теперь давайте попробуем сделать то же самое на C++:

int sum (int x, int y) {
    return x + y;
}

Всё выглядит идентично с точностью до особенностей синтаксиса. Но так ли это? Оказывается, что нет. Во-первых, в C++ int чаще всего имеет размер именно в 32 бита и не обязательно зависит от архитектуры. Впрочем это лишь уменьшает доступный диапазон значений, но не меняет ситуацию принципиально. Во-вторых, согласно спецификации языка C++ переполнение беззнакового типа (unsigned) предопределено, а вот уже переполнение знакового типа, такого как int — это UB (Undefined Behavior — неопределённое поведение). А функцию с UB уж точно нельзя назвать чистой. Для того, чтобы его избежать, нам придётся написать собственную проверку на переполнение и в зависимости от этого выбирать какой результат возвращать. Это должно выглядеть как-то так:

#include <limits>

template <typename T>
using limits = std::numeric_limits<T>;

int sum (int x, int y) {
    if (x > 0) {
        if (y > limits<int>::max() - x) {
            return limits<int>::min() + (y - (limits<int>::max() - x)) - 1;
        }
    } else {
        if (y < limits<int>::min() - x) {
            return limits<int>::max() - ((limits<int>::min() - x) - y) + 1;
        }
    }
    return x + y;
}

Фух, надеюсь я нигде не ошибся! Такая версия уже точно будет работать как самый первый вариант на Haskell, потому что мы по факту самостоятельно написали гарантированное переполнение для типа int. Вообще, справедливости ради, в C++ переполнение инта является UB не из вредности составителей стандарта. Это позволяет компилятору производить некоторые оптимизации. Но тем не менее факт остаётся фактом — операция сложения для типа int в C++ уже не чиста по умолчанию. За подобными вещами необходимо внимательно следить, если вы хотите сделать вашу функцию чистой.

2. Самая простая нечистая функция — генератор случайных чисел.

Вполне очевидно, что генератор случайных чисел должен зависеть не только от входных данных. Если при каждом вызове функции мы будем получать одно и то же число, будь оно хоть сколь угодно случайное, нас это едва ли устроит. Понятно, что нужно взять какую-то информацию из системы, которая постоянно меняется, и на её основе сгенерировать случайное число. Вот это было бы действительно неожиданно! В самом простом случае в качестве такой информации выступает системное время. Напишем пример программы на C++, возвращающей нам несколько случайных чисел:

#include <iostream>
#include <ctime>

int main() {
    srand(time(NULL));
    std::cout << rand() << ' ' << rand() << ' ' << rand() << std::endl;
}

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

Теперь посмотрим на аналогичный код на Haskell:

import System.Random

main = do
    gen <- getStdGen
    let [x, y, z] = take 3 (randoms gen :: [Int])
    putStrLn (show x ++ " " ++ show y ++ " " ++ show z)

-- Этот код использует библиотеку random, 
-- которая не включена в стандартную поставку компилятора ghc,
-- но она очень просто устанавливается и де-факто
-- является стандартом для генерации случайных значений.

Первая строка в main здесь нужна также для того, чтобы сделать случайность зависимой от внешних условий. По сути мы запрашиваем у системы генератор, в который уже вложена какая-то информация об изменяемых свойствах системы. Дальше, хотя этот код может быть слегка непривычным для императивного программиста, всё-таки довольно очевидно, что мы создаём список (Не важно, какого он размера — достаточного! Не думайте об этом!) случайных чисел на основе полученного ранее генератора и берём из него первые 3 элемента. Ну и в последней строке мы просто превращаем эти числа в строки, разделяем их пробелами и выводим в консоль.

Сразу видно, что этот код устроен несколько сложнее, чем тот, что был на C++. Если углубиться в детали, то он окажется намного сложнее. Но это усложнение в том числе позволяет записать эту программу короче, например так:

import System.Random

main = putStrLn . unwords . map show =<< (take 3 . randoms <gt; getStdGen :: IO [Int])
--                                    ^
--                           Это = << без пробела

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

Заключение

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

Если вы вдруг нашли этот пост не через наш телеграм-канал, то советую вам на него подписаться. Уверен, что вне зависимости от ваших интересов в IT, вы сможете найти что-то там интересное. Кроме меня там есть ещё один автор, который пишет в основном по теме ООП. В том числе он участвовал в редактировании примеров на C++ для этого поста, за что я ему благодарен. Всем спасибо за прочтение, до встречи на следующей итерации!