Почему функциональное программирование так сложно?
В: Я слышал много хорошего о функциональном программировании, но мне это очень трудно понять. У меня есть многолетний опыт работы с C ++ / Java / C # / Javascript / и т. Д., Но это не помогает, мне кажется, что я снова научусь кодировать с нуля. С чего мне начать?
Переход на стиль FP действительно требует изменения мышления. У вас больше нет ваших обычных примитивов, таких как классы, изменяемые переменные, циклы и т. Д. Первые пару месяцев вы не будете продуктивны, вы будете зацикливаться на несколько часов или дней на некоторых простых вещах, которые обычно занимали минуты. Будет тяжело, и ты почувствуешь себя глупо. Мы все сделали. Но после того, как он щелкнет, вы получите суперсилы. Я не знаю ни одного человека, который переключился бы обратно с FP на OOP после ежедневного выполнения FP. Вы можете переключиться на язык, который не поддерживает FP, но вы все равно будете писать, используя концепции FP, это хорошо.
В этой статье я постараюсь разбить некоторые концепции и ответить на общие вопросы, которые беспокоили меня, когда я изучал FP.
- Классов нет
- Все, что вам нужно, это функция
- Нет, вы не можете изменить переменную
- Нет, вы не можете делать циклы for
- Ваш код больше не является списком инструкций
- О
null
и исключениях - Функторы, монады, аппликативы?
1. Нет классов
Q: Нет классов? Как мне тогда структурировать свой код?
Оказывается, вам не нужны занятия. Как и в старом добром процедурном программировании, ваша программа - это просто набор функций, за исключением того, что в FP эти функции должны иметь некоторые свойства (обсуждаются позже), и они также должны составлять . Вы часто будете слышать слово композиция, поскольку это одна из основных идей FP.
Я бы посоветовал перестать думать о «создании экземпляров класса» или «вызове методов класса». Ваша программа будет просто набором функций, которые могут вызывать друг друга.
Боковое примечание: во многих языках программирования есть понятие типовой класс, которое не следует путать с пониманием класса в ООП. Назначение классов типов - обеспечить полиморфизм . Поначалу не стоит особо беспокоиться об этом, но если вам интересно, прочтите эту статью: Объяснение классов типов.
В: А как насчет данных? У меня часто есть данные и функции, которые изменяют эти данные в одном классе.
Для этого у нас есть алгебраические типы данных (ADT), которые представляют собой просто причудливое имя для записи, содержащей данные.
Вы можете думать об этом как о классе, в котором есть только конструктор и ничего больше. Используя терминологию FP, они называются «Типами», а конструкторы называются «Конструкторами типов». Вот как вы создаете типы и получаете из них значения:
Обратите внимание, что в Haskell name
и age
на самом деле являются функциями, которые принимают значение типа Person
и возвращают его поля.
В: Хорошо, но как мне, например, изменить возраст человека?
Изменение вещей на месте (в императивном понимании программирования) - это мутация, и вы не можете производить мутацию в FP (подробнее об этом позже). Если хотите что-то изменить - сделайте копию.
Стоит знать два типа ADT: тип продукта и тип суммы.
- Тип продукта: набор полей, все должны быть указаны для создания типа:
- Тип суммы: представляет собой необязательность. Либо ваш тип - что-то , либо что-то другое. Например, Shape может быть Circle или Square.
ADT также могут быть вложенными: Shape - это тип суммы, где каждая опция может быть суммой или произведением сама по себе. Любая модель предметной области может быть представлена как комбинация типов суммы и продукта.
В: Почему суммы и продукты такие особенные?
Помимо того, что они являются базовыми строительными блоками для моделирования, они изначально поддерживаются большинством языков программирования FP. Типы продуктов можно деконструировать и статически проверить, в то время как типы сумм можно использовать для сопоставления с образцом:
2. Все, что вам нужно, это функция
Познакомьтесь со своим новым лучшим другом - функция. Вы можете знать его по разным именам: геттер, сеттер, конструктор, метод, построитель, статическая функция и т. Д. В ООП эти имена связаны с разными контекстами и имеют разные свойства. В FP функция всегда является просто функцией - она принимает значения на входе и возвращает значения на выходе.
Нет необходимости создавать что-либо для использования функций (поскольку нет классов), вы просто импортируете модуль, в котором определена функция, и просто вызываете его. Функциональная программа - это просто набор ADT и функций, как в примере Shape
s выше.
Функция должна иметь 3 основных свойства:
- Чистый: без побочных эффектов. Функции не могут делать больше, чем указано в их типе. Например, функция, которая принимает
Int
и возвращаетInt
, не может изменять глобальные переменные, обращаться к файловой системе, выполнять сетевые запросы и т. Д. Она может * только * выполнять некоторые преобразования на входе и возвращать какое-то значение. - Итого: возвращает значения для всех входных данных. Функции, которые вызывают сбой или создают исключения для некоторых входных данных, не являются полными или частичными. Например, объявление
div
function: type обещает, что оно принимаетInt
и возвращаетInt
, однако, если вторым аргументом является0
, оно вызовет исключение «деление на ноль», следовательно, оно не является итоговым. - Детерминированный: возвращает тот же результат для тех же входных данных. Для детерминированной функции не имеет значения, как и когда она вызывается - она всегда возвращает одно и то же значение. Функции, зависящие от текущей даты, часов, часового пояса или какого-либо внешнего состояния, не являются детерминированными.
Большинство языков программирования не могут применять эти свойства статически, поэтому программист обязан удовлетворить эти свойства. Например, компилятор Scala с радостью принимает нечистые, частичные и недетерминированные функции:
В Haskell, с другой стороны, вы не можете (легко) написать функцию, которая не является чистой или недетерминированной: любая функция побочного эффекта вернет IO
, которое является значением, которое представляет "побочные эффекты" вычисления. Свойство Totality по-прежнему остается за программистом, так как вы можете генерировать исключения или возвращать так называемое дно, которое завершит программу.
В: Почему меня волнует, имеет ли функция эти свойства или нет?
Если функция удовлетворяет этим свойствам, вы получаете ссылочную прозрачность (подробнее в отдельной статье). Короче говоря, вы получите возможность взглянуть на определение типа функции и точно знать, что она может и чего не может. Вы можете безбоязненно реорганизовать свой код, потому что RT гарантирует, что ничего не сломается. RT - это в основном то, что позволяет нам контролировать сложность нашего программного обеспечения. Рефакторинг в ООП может быть кошмаром, поскольку вы не знаете, какие объекты вызывают что и когда, пока вы не запустите программу и не создадите мысленную модель в своей голове. И даже тогда это непростая задача.
3. Нет, вы не можете изменить переменную.
В: Это самая странная часть, как мне сделать что-нибудь полезное, не меняя переменных?
Если у вас есть переменная person
, привязанная к Person("Bob", 42)
, вы не можете переназначить ее Person("Bob",43)
. Что вы можете сделать, так это создать другую переменную, создав копию и указав, что вы хотите изменить (как мы обсуждали ранее). Переменные неизменяемы и используются только для псевдонима или метки значений, а не в качестве физической ссылки или указателя на фактические данные.
В: Почему бы просто не поменять его на место?
Потому что это нарушает ссылочную прозрачность, и, как я сказал ранее, ссылочная прозрачность является ключом к FP. Это сделает вашу жизнь намного проще, а отсутствие изменяемых переменных - это разумная цена. Кроме того, отсутствие изменений означает, что вы получаете потокобезопасный код бесплатно, больше не нужно тратить выходные на ошибки параллелизма, «случающиеся только вечером во вторник».
Неизменяемость - это простая концепция, но ее трудно принять после многих лет опыта ООП. Часто можно увидеть, как люди возвращаются к var
в Scala просто «для того, чтобы заставить эту штуку работать». Поначалу это нормально, но всегда ищите неизменяемую реализацию. Кроме того, в Haskell нет такого «взлома», поэтому вы должны быть неизменными с первого дня.
4. Нет, вы не можете использовать циклы for.
В: Наш хлеб с маслом - цикл for - вы говорите, что в FP его тоже нет? Как вы перебираете массив?
Отсутствие мутации, означающее отсутствие циклов for, так как обычно происходит мутация некоторого счетчика i до тех пор, пока не будет выполнен какой-либо предикат. Однако у нас есть и другие средства достижения того же - рекурсия и функции более высокого порядка.
Рекурсия
Вы должны освоиться с рекурсией, как это везде в FP. Например, сумма всех чисел в списке будет выглядеть так:
Обычно работают с рекурсивными структурами данных, такими как списки или деревья. Так можно выразить даже натуральные числа. Естественный способ обхода этих структур - сопоставление с образцом в конструкторах типов и применение рекурсивных функций к рекурсивным частям структуры данных. Общий шаблон состоит в том, чтобы сначала определить базовый случай, такой как случай пустого списка для завершения рекурсии, а затем определить общий случай.
Функции высшего порядка
Функции высшего порядка принимают в качестве аргумента другие функции. Говоря об итерациях, вы должны знать, как использовать map
и fold
:
В: Что случилось с именами?
map
? Не похоже наforeach
?
Да, но только для списков. Вскоре вы обнаружите, что map
не о преобразовании списка, а имеет разную семантику в зависимости от того, что мы хотим сопоставить. Если вы хотите узнать больше - ищите Functor
, это более высокий тип, который предоставляет интерфейс сопоставления. Но не беспокойтесь о Functor
s слишком сильно - просто подумайте о map
как о функции, которая знает, как перебирать структуры данных, такие как списки, деревья, словари и т. Д.
fold
также имеет более глубокое значение и относится к Foldable
. Интуиция подсказывает, что он берет некоторую структуру данных и выдает одно значение, такое как сумма. Обратите внимание, что map
, в отличие от fold
, применяет функцию к каждому значению независимо, в то время как fold
может нести какой-то аккумулятор, который зависит от предыдущих значений.
Функций гораздо больше, но знание этих двух поможет вам решить большинство итерационных задач.
5. Ваш код больше не является списком инструкций.
В императивном языке вы можете сделать это:
У этих функций есть «побочные эффекты», например они что-то делают. Результатом их действий является изменение состояния всей программы - некоторые файлы были записаны на диск, выведены в консоль, обновлена карта внутренних сущностей и т. Д. Как только вы вызываете такую функцию - она выполнена, завершена, выполнена.
Что ж, ничего нового здесь нет, я обычно так программирую.
Конечно, но в функциональной программе ничего не выполняется до последнего момента. Ваши функции должны принимать значения и возвращать значения, побочные эффекты не допускаются. Выход одной функции является входом для какой-то другой функции, которая, в свою очередь, создает вход для какой-то другой функции и так далее.
Вот как эта программа будет выглядеть в FP:
Обратите внимание на функцию unsafeRun
(допустим, она предоставляется языком). До unsafeRun
все, что мы делали, это склеивали функции вместе, ничего не выполнялось. Мы строим своего рода план выполнения: «сначала должна быть вызвана эта функция, затем на основе ее результатов мы вызовем одну из этих двух функций» и так далее.
Это также непростая концепция для понимания, поскольку мы использовали здесь некоторое дополнительное поведение, которое выполняет что-то, например, регистрирует операторы или устанавливает какой-либо флаг, очищает очередь и т. Д. Вы больше не можете уйти от этого, поскольку эти дополнительные функции должны следовать за типами и сочетаться с другими функциями. И это хорошо - это заставляет нас более принципиально подходить к тому, что делает наша программа, и следить за тем, чтобы все было закодировано в сигнатуре типа функции.
6. О nulls
и исключениях
Нулевые значения присутствуют во всех императивно написанных базах кода. Проблема с null
в том, что это абстракция нижнего уровня просочилась в систему типов более высокого уровня. Если я увижу функцию, которая возвращает Person
, то (если функция является полной) я ожидаю получить Person
с именем, адресом и т. Д. null
- это не человек. null
часто используется для обозначения отсутствия или какого-либо внутреннего сбоя, который не позволяет функции вернуть правильное значение. Если функция может каким-то образом не вернуть Person
, она должна указать это в своем определении типа. В FP мы можем представить отсутствие в виде суммы:
Если функция возвращает Maybe
или anOption
из Person
, она явно говорит - Person
не гарантируется. Вызывающий будет должен проверить, является ли возвращаемое значение Some
или None
, это означает, что больше не будет null
проблем с разыменованием или null
исключений указателя.
Если задуматься, null
- это своего рода примитив низкого уровня, который относится к системе времени выполнения, а не к логике вашей программы. Когда вы пишете на языках более высокого уровня со сборкой мусора, вам все равно, когда и как объекты размещаются в памяти, и каков сгенерированный машинный код для вашей функции. Это то, для чего нужны языки более высокого уровня - они создают абстракцию, поэтому вам не нужно думать о деталях. null
нарушает эту абстракцию, поэтому код становится загрязненным странными p != null
проверками или, что еще хуже, проблемами разыменования.
Точно так же исключения. Нет необходимости в специальном механизме со специальным синтаксисом только для исключительных случаев. В вашей чистой программе можно представить отсутствие, сбои и исключения с обычными значениями. Создание исключений с throw e
делает функцию частичной (не полной), что снова нарушает ссылочную прозрачность и создает проблемы.
Если вы работаете с JVM и используете библиотеки java, вы должны иметь дело с исключениями. Можно использовать исключение в некоторых особых случаях, например IO
, но убедитесь, что оно является частью типа функции - вызывающий должен знать, что функция выбрасывает, какие исключения могут быть сгенерированы и эти обещания могут быть проверены во время компиляции.
7. Функторы, монады, аппликативы?
В: Я слышу, как люди из FP постоянно говорят об этом, но для меня они не имеют никакого смысла. Есть простое объяснение?
Люди открыли общие закономерности и дали им названия из теории категорий. Функторы, монады и обходные объекты - довольно мощные и распространенные абстракции, вы увидите их повсюду. Наверное, это отдельная тема для статьи. Но пока - не беспокойтесь об этом. Со временем вы узнаете о них (или, возможно, даже изобрели их заново). Разберитесь с композицией функций, функциями высшего порядка и полиморфными функциями. Тогда читайте о типовых классах. После этого естественным образом должны появиться функторы и монады. Вывод здесь заключается в том, что здесь нет никакой магии, и в ней нет ничего большего, чем мы уже обсуждали в этой статье - чистые функции и композиция функций.
Надеюсь, это было полезно, а если нет - пришлите мне свой отзыв. Как кто-то сказал, «как только вы поймете монады, вы потеряете способность объяснять это другим», поэтому я надеюсь, что эта статья была не слишком далека от того, что обычно испытывают разработчики ООП. Спасибо за чтение и наслаждайтесь путешествием по FP.