Понимание и использование методов функционального программирования

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

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

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

  • Вы относитесь к должностным лицам как к первоклассным гражданам.
  • Вы можете передавать их в качестве аргументов другим функциям.
  • Функция может возвращать другую функцию.
  • Вы можете изменять функции.

и более.

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

Вы можете использовать другие стили программирования: объектно-ориентированное, процедурное, метапрограммирование… То, что вы используете, не должно мешать вам изучать функциональное программирование. У вас есть свобода научиться создавать надежные программы, и одной парадигмы может быть недостаточно.

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

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

В функциональном программировании существует ряд концепций, некоторые из них должны быть похожи на вас, если у вас есть опыт работы с ООП. Некоторые из них могут показаться абсурдными, из чего вы узнали, что вам следует избегать такого «неэффективного» стиля. Но теперь вы играете по правилам FP. Сложности алгоритмов и лучшие практики разные. Если вы оставите свои предрассудки и попытаетесь понять, что означают эти правила, вы можете увидеть, что они призваны облегчить вам жизнь. Не все из них могут быть вашим решением, некоторые из них не подходят для некоторых ситуаций. Когда вы читаете передовой опыт, вы всегда должны тестировать в своей среде свой проект. Производительность может легко меняться между языками, и один совет «кажется достаточно хорошим» может превратиться в плохой.

Функции высшего порядка

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

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

Каррирование преобразует нашу стандартную нотацию математических функций f(x, y) -> z в другую форму, поэтому она принимает несколько аргументов один за другим. Каррирование преобразует одну функцию в последовательность функций. Каждая функция может принимать один или несколько аргументов. g(x)(y) -> z - каррированная форма приведенной выше функции f. Вы можете прочитать сигнатуру функции следующим образом: «g - это функция, которая принимает x в качестве единственного аргумента и возвращает функцию из домена Y в домен Z. Затем мы передаем аргумент y этой анонимной промежуточной функции и получаем результат z ». Сигнатура нашей функции (X x Y) -> Z меняется на X -> (Y -> Z), и это позволяет реализовать очень важную концепцию: частичное применение функции.

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

Давайте посмотрим на несколько примеров. Я буду использовать Haskell. Вам не обязательно знать Haskell, я постараюсь объяснить каждую строчку простым языком. Также я буду поддерживать примеры с кодами других языков. Вы можете сравнить коды и понять различия:

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

Пример JavaScript очень похож на пример Python. Разница в том, что в примере JS мы возвращаем анонимную функцию. Мы не дали функции имя, мы просто вернули его.

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

В Haskell вы сначала объявляете подпись своей функции, а затем даете реализацию. Первая строка - это сигнатура функции добавления. Он принимает два Int и возвращает Int. Вы можете видеть, что сигнатура функции каррирована. Фактически это от домена Int к домену (Int в Int). После этой строки реализуется функция добавления. Он принимает в качестве параметров x и y и возвращает сумму двух значений.

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

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

Kotlin поддерживает некоторые функции функционального программирования. В этом примере мы определяем функцию расширения с именем transform. Это не так полезно и очень похоже на стандартную библиотечную функцию map, но это хороший и простой пример. Эта функция определена для типа Список ‹T›. Он принимает другую функцию в качестве аргумента (action) и возвращает список ‹R›. action от Т до Р.

В функции main мы создаем список имен. Затем мы вызываем нашу функцию преобразования. Он ожидает другую функцию, поэтому мы даем ей лямбда-выражение. В Kotlin лямбда-выражения определяются с использованием символов { }, и если выражение является последним аргументом вызова функции, его можно вынести за скобки. В лямбда-выражении вы можете ссылаться на лямбда-параметр как на it. Мы передаем функцию другой функции, и она применяет эту функцию к каждому элементу списка и возвращает новый список. Затем мы вызываем forEach стандартную библиотечную функцию с ::println. println - еще одна встроенная функция, и мы можем дать ссылку на функцию с помощью ::. Это выражение выведет каждый элемент нового списка на стандартный вывод.

Чистые функции

Если функция возвращает одно и то же значение для тех же аргументов и не изменяет состояние, эта функция называется чистой. Это аналог математической функции. (Википедия)

Если функция,

  • Использует нелокальную переменную
  • Возвращает изменяемую ссылку
  • Зависит от статических переменных
  • Выполняет ввод-вывод в потоке без какой-либо концепции упаковки

эта функция является нечистой функцией .

Рассмотрим этот пример:

В этом примере функция f имеет зависимость: глобальная переменная x. Кроме того, когда мы возвращаем значение x, мы увеличиваем переменную и вызываем изменение состояния. Из-за этого последовательные и идентичные вызовы функций приводят к разному.

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

Если данные используются совместно функциями или структурами данных, это называется «общее состояние». В многопоточных программах вы должны определить, какой поток имеет право использовать общий ресурс. Если в любой момент времени доступ к ресурсу будет иметь более одного потока, это вызовет проблемы. Из-за этого приходится использовать запорный механизм. Это увеличивает сложность и станет корнем ваших будущих ошибок. В любом языке и / или рамках мы стараемся избегать состояний или используем систему управления для контроля состояний. FP предлагает вам управляемую структуру программы. Благодаря функциональной чистоте, ссылочной прозрачности и неизменности оценки являются потокобезопасными. Вы избегаете этих «плохих» состояний и пытаетесь решать проблемы, не создавая блокировок и условий гонки.

Ссылочная прозрачность

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

Все математические функции чисты и прозрачны. Функция sin (x) всегда будет давать один и тот же вывод для одного и того же ввода. Он не зависит от какого-либо неявного значения. Следовательно, вы можете удалить выражение и установить постоянное значение, и все в системе продолжит работать, как прежде.

Фактическое выражение каждый раз возвращает одно и то же значение, поэтому на самом деле это константа. Это может быть полезно для запоминания. Дорогостоящие вычисления можно кэшировать, и когда тот же расчет снова понадобится, он может вернуть кешированное значение.

Ленивая оценка

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

При строгой оценке аргументы обрабатываются немедленно. Когда вы вызываете функцию и даете список аргументов, каждый аргумент обрабатывается и вычисляется перед вызовом функции. Обычно языки определяют порядок оценки аргументов (например, слева направо) в своих стандартах. Вы знаете порядок вычисления аргументов и можете видеть поток кода (Источник: Школа Haskell). Если код изменяет состояние, то порядок оценки аргументов имеет значение, потому что следующие оценки могут зависеть от состояния. Поскольку побочные эффекты и состояния для нас не проблема, мы можем перейти к следующей части.

В ленивом вычислении вычисление выражения откладывается. Оценка откладывается до тех пор, пока не потребуется оценить выражение для получения результата. Ленивое вычисление полезно для работы с большими или бесконечными данными. В Haskell вы можете получить список всех четных чисел [0, 2 ..] с этим пониманием списка. Он не получает все числа сразу, но при необходимости выдает числа. Например, вы можете взять первые 10 элементов этого списка с take 10 [0, 2 ..]. Остальные элементы вас не интересуют, поэтому рассчитывается только необходимая часть.

В некоторых языках строгой оценки операторы && и || являются короткозамкнутыми (Подробности: Оценка короткого замыкания). Обычно компьютер должен вычислить оба операнда этих логических операторов. Но если первый аргумент оператора && дает false , все выражение дает false. Точно так же, если первый аргумент оператора || дает true, все выражение дает true. Это короткое замыкание нужно реализовывать по-другому. Ленивая оценка позволяет определять эти операторы изначально из-за сопоставления с образцом.

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

Этот пример демонстрирует основы сопоставления с образцом и отложенного вычисления. Первая строка - это подпись функции. patternMatch - это функция, которая принимает два параметра Integer и возвращает Integer. Во второй строке вы можете увидеть сопоставление с образцом. Когда мы вызываем функцию, если первый параметр равен «0», то Haskell будет использовать это определение функции. Если это не так, он попытается найти другой шаблон для сопоставления. Поскольку есть два возможных шаблона (мы можем определить больше), Haskell будет использовать второй шаблон. Это похоже на обязательный регистр по умолчанию для оператора switch в императивных языках. Haskell сканирует все кейсы (шаблоны) и, когда не удается сопоставить параметры, использует ветвь по умолчанию.

Теперь предположим, что x легко вычислить, а y - сложно. Если мы используем строгое вычисление, мы должны вычислить y перед вызовом patternMatch, и нам может вообще не понадобиться значение. Если мы используем ленивую оценку, y будет оцениваться только тогда, когда это необходимо.

Ленивая оценка полезна для многих вещей, но у нее есть серьезный недостаток. Использование памяти непредсказуемо. Оценка выражения может вызвать переполнение, если программист не будет так осторожен. (Источник: Haskell Wiki)

Рекурсия

Вы имели в виду рекурсию?

Рекурсивная функция - это функция, которая вызывает себя прямо или косвенно. В реализации функции некоторые члены вычисляются путем вызова функции с разными аргументами.

Вы можете узнать определение последовательности по исчислению. Дается один (в данном случае два) начальный срок. Это называется базовым значением или базовым случаем. Каждый следующий термин определяется в соответствии с предыдущими терминами. Чтобы вычислить n -й член последовательности, вы должны вычислить предыдущие члены. Вы вызываете функцию с разными значениями (в данном случае вы вызываете ее с разными индексами).

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

В императивном программировании вы перебираете коллекции с помощью циклов. Во многих языках есть «for», «for-each», «while», «do-while» или аналогичные операторы / выражения потока условного управления. В FP вы не используете циклы, потому что они зависят от состояния. Вы проверяете, является ли логическая переменная истинной или ложной, или у вас есть значение индекса, и сравниваете его с длиной массива и т. Д. Вместо циклов вы выполняете итерацию по коллекции (линейной или нелинейной) с помощью рекурсии. Например, если вы хотите увеличить каждый элемент массива, вы создаете цикл for, чтобы получить элемент текущего индекса и присвоить ему новое значение. Это нарушает многие правила FP. Во-первых, вы должны управлять состоянием. Индекс цикла изменяется, и вы несете ответственность за управление его состоянием. Во-вторых, вы меняете ценность коллекции. Вместо того, чтобы читать коллекцию и создавать новую, вы изменяете исходную коллекцию. Это может вызвать потенциальные проблемы.

Если вы знакомы с алгоритмами, вы можете знать, что многие популярные алгоритмы сортировки используют рекурсию для сортировки коллекции. QuickSort, MergeSort, TimSort - популярные алгоритмы сортировки, которые используют рекурсию для эффективной сортировки коллекции. Бинарный поиск, факториал, НОД, последовательность Фибоначчи - другие алгоритмы, которые можно реализовать с помощью рекурсии.

Интересной рекурсивной функцией является функция Аккермана. Он отличается от всех приведенных выше примеров. Это не примитивная рекурсивная функция. Это означает, что он не может быть вычислен компьютером с помощью обязательных инструментов (например, for-loop).

Неизменность

Неизменяемость - это общая концепция ООП и ФП. После создания объекта / переменной вы не можете изменить его состояние. Это основная концепция FP, потому что общая идея - избегать состояний и изменений. Есть разные типы неизменяемости. Поле может быть постоянным или постоянным или предназначенным только для чтения. Есть слабая неизменность и сильная неизменность.

Значение поля только для чтения вычисляется во время выполнения, но после инициализации значение никогда не изменяется. Это отделяет поля только для чтения от констант. Постоянные поля должны быть известны во время компиляции.

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

Строки - это неизменяемые объекты во многих языках программирования, таких как Java, Python и JS. Вы не можете изменить их отдельные части. Однако вы можете прочитать значение и вычислить новую строку. Это связь между концепциями неизменности и чистоты функций. Чистые функции дают результаты без изменения значений. Ценности неизменны и, как правило, остаются неизменными. Эти концепции дополняют друг друга.

В C # есть поля readonly. Вы можете присвоить значения readonly полям в конструкторах. Но в зависимости от используемого конструктора эти поля могут содержать разные значения. После инициализации поля изменить значение нельзя.

В новых версиях JavaScript есть новые ключевые слова для определения переменных. let и const используются для объявлений, но const имеет интересную особенность: вы не можете переназначить и повторно объявить переменную (MDN). Это предотвращает изменение значений путем переназначения, но не позволяет предотвратить другие способы. Когда вы объявляете объект как const, он будет содержать ссылку на тот же объект, но не препятствует изменению полей объекта. Object.freeze() - еще один инструмент для обеспечения неизменяемости, но его тоже недостаточно. Если у вас есть объект в качестве значения вашего свойства, он не будет неизменным. Object.freeze() повлияет только на одноуровневые свойства.

В Kotlin есть ключевые слова const, val и var для объявления переменной. Если у вас есть переменная примитивного или строкового типа и вы знаете значение до времени компиляции, вы можете использовать const, чтобы сделать его константой. var используется для объявления изменяемых значений. val используется для переменных только для чтения. Его значение неизвестно во время компиляции, поэтому, если оно объявлено в области действия функции, оно может принимать разные значения при каждом вызове функции. Но после инициализации он гарантированно сохранит то же значение в течение всего срока службы.

Kotlin также имеет изменяемые и неизменяемые коллекции. List, Map и Set - это три неизменяемые коллекции стандартных библиотек. Они не предлагают методов модификации. Если вам нужны изменяемые контейнеры, вы можете использовать их изменяемые эквиваленты. MutableList, MutableMap и MutableSet - это изменяемые контейнеры. Разграничение этих контейнеров позволяет писать код в функциональном стиле и цепочки вызовов функций.

Применение функции и композиция функций

Приложение функции применяет функцию к своим аргументам. Сначала это странное определение, потому что мы привыкли его использовать. f (x) - стандартная запись приложения функции. f - это функция, которая применяется к аргументу x. Мы говорили о каррировании, и когда мы преобразовываем нашу нотацию, мы получаем f x. Эти два обозначения можно считать одинаковыми.

Когда мы даем два аргумента функции f, мы можем записать это как f (x, y) или f (x) (y). Второе обозначение не обеспечивает желаемой гибкости. Мы можем записать его как f x y, но для этого требуется, чтобы приложение функции было левоассоциативным. Это означает, что f сначала применяется к x, а затем промежуточный результат применяется к y. Но для этого нам нужен оператор.

Apply - это оператор, который применяет свой операнд LHS к операнду RHS. В Haskell $ используется для функции Применить. Применить является правоассоциативным и имеет низкий приоритет оценки. Когда мы используем $, все, что идет после оператора, будет сначала оценено, тогда к этому результату будет применена левая часть. Выражение, которое должно быть записано как f (g (h (x))) без Apply, может быть записано как f $ g $ h x. Этот оператор увеличивает нашу выразительность, но мы можем добиться большего.

Мы используем функции для создания более сложных функций. Мы вычисляем результат, и он становится входом для другой функции. Чтобы выполнить эту операцию объединения, нам нужен другой оператор. В Haskell есть встроенный оператор, называемый оператором композиции: «.» (точка). Этот оператор принимает две функции (скажем, f и g), аргумент (скажем, x) и выдает результат f (г (х)). Таким образом, сложный пример выше можно упростить до (например, h) (x).

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

Заключение

Функциональное программирование - это интересно и весело. Поскольку его основой является лямбда-исчисление, его концепции более абстрактны и трудны для понимания, чем другие парадигмы программирования. Тем не менее, он становится все более популярным с новыми языками и новыми версиями старых языков. Я хочу подчеркнуть, что понимание этих основных концепций FP может не помочь вам напрямую. Но я думаю, что это может открыть новые двери в вашем уме и повлиять на вашу точку зрения. Быть лучшим программистом - значит мыслить по-другому. Чтобы думать иначе, вам нужно изучить другие точки зрения. Спасибо за уделенное время. Хорошего дня.

использованная литература