Почему функциональное программирование так сложно?

В: Я слышал много хорошего о функциональном программировании, но мне это очень трудно понять. У меня есть многолетний опыт работы с C ++ / Java / C # / Javascript / и т. Д., Но это не помогает, мне кажется, что я снова научусь кодировать с нуля. С чего мне начать?

Переход на стиль FP действительно требует изменения мышления. У вас больше нет ваших обычных примитивов, таких как классы, изменяемые переменные, циклы и т. Д. Первые пару месяцев вы не будете продуктивны, вы будете зацикливаться на несколько часов или дней на некоторых простых вещах, которые обычно занимали минуты. Будет тяжело, и ты почувствуешь себя глупо. Мы все сделали. Но после того, как он щелкнет, вы получите суперсилы. Я не знаю ни одного человека, который переключился бы обратно с FP на OOP после ежедневного выполнения FP. Вы можете переключиться на язык, который не поддерживает FP, но вы все равно будете писать, используя концепции FP, это хорошо.

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

  1. Классов нет
  2. Все, что вам нужно, это функция
  3. Нет, вы не можете изменить переменную
  4. Нет, вы не можете делать циклы for
  5. Ваш код больше не является списком инструкций
  6. О null и исключениях
  7. Функторы, монады, аппликативы?

1. Нет классов

Q: Нет классов? Как мне тогда структурировать свой код?

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

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

Боковое примечание: во многих языках программирования есть понятие типовой класс, которое не следует путать с пониманием класса в ООП. Назначение классов типов - обеспечить полиморфизм . Поначалу не стоит особо беспокоиться об этом, но если вам интересно, прочтите эту статью: Объяснение классов типов.

В: А как насчет данных? У меня часто есть данные и функции, которые изменяют эти данные в одном классе.

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

Вы можете думать об этом как о классе, в котором есть только конструктор и ничего больше. Используя терминологию FP, они называются «Типами», а конструкторы называются «Конструкторами типов». Вот как вы создаете типы и получаете из них значения:

Обратите внимание, что в Haskell name и age на самом деле являются функциями, которые принимают значение типа Person и возвращают его поля.

В: Хорошо, но как мне, например, изменить возраст человека?

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

Стоит знать два типа ADT: тип продукта и тип суммы.

  • Тип продукта: набор полей, все должны быть указаны для создания типа:
  • Тип суммы: представляет собой необязательность. Либо ваш тип - что-то , либо что-то другое. Например, Shape может быть Circle или Square.

ADT также могут быть вложенными: Shape - это тип суммы, где каждая опция может быть суммой или произведением сама по себе. Любая модель предметной области может быть представлена ​​как комбинация типов суммы и продукта.

В: Почему суммы и продукты такие особенные?

Помимо того, что они являются базовыми строительными блоками для моделирования, они изначально поддерживаются большинством языков программирования FP. Типы продуктов можно деконструировать и статически проверить, в то время как типы сумм можно использовать для сопоставления с образцом:

2. Все, что вам нужно, это функция

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

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

Функция должна иметь 3 основных свойства:

  1. Чистый: без побочных эффектов. Функции не могут делать больше, чем указано в их типе. Например, функция, которая принимает Int и возвращает Int, не может изменять глобальные переменные, обращаться к файловой системе, выполнять сетевые запросы и т. Д. Она может * только * выполнять некоторые преобразования на входе и возвращать какое-то значение.
  2. Итого: возвращает значения для всех входных данных. Функции, которые вызывают сбой или создают исключения для некоторых входных данных, не являются полными или частичными. Например, объявление div function: type обещает, что оно принимает Int и возвращает Int, однако, если вторым аргументом является 0, оно вызовет исключение «деление на ноль», следовательно, оно не является итоговым.
  3. Детерминированный: возвращает тот же результат для тех же входных данных. Для детерминированной функции не имеет значения, как и когда она вызывается - она ​​всегда возвращает одно и то же значение. Функции, зависящие от текущей даты, часов, часового пояса или какого-либо внешнего состояния, не являются детерминированными.

Большинство языков программирования не могут применять эти свойства статически, поэтому программист обязан удовлетворить эти свойства. Например, компилятор 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, это более высокий тип, который предоставляет интерфейс сопоставления. Но не беспокойтесь о Functors слишком сильно - просто подумайте о 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.