Функциональное мышление

Давайте обсудим основы функционального программирования. Что в действительности означает «программировать функционально», и чем этот подход отличается от объектно-ориентированного или императивного программирования?

Смена образа мышления (Intro)

Важно понимать, что функциональное программирование — это не просто отдельный стиль программирования. Это совсем другой способ мышления в программировании, который отличается от «традиционного» подхода так же значительно, как настоящее ООП (в стиле Smalltalk) отличается от традиционного императивного языка — такого, как C.

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

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

Это будет довольно абстрактная серия, хотя я буду использовать множество коротких примеров кода для демонстрации некоторых моментов. Мы рассмотрим следующие темы:

  • Математические функции. Первая статья знакомит с математическими представлениями, лежащими в основе функциональных языков и преимуществами, которые приносит данный подход.
  • Функции и значения. Следующая знакомит с функциями и значениями, объясняет чем «значения» отличаются от переменных, и какие есть сходства между функциями и простыми значениями.
  • Типы. Затем мы перейдем к основным типам, которые работают с функциями: примитивные типы, такие как string и int, тип unit, функциональные типы, и обобщённые типы (generic).
  • Функции с несколькими параметрами. Далее я объясню понятия «каррирования» и «частичного применения». В этом месте чьим-то мозгам будет больно, особенно если у этих мозгов только императивное прошлое.
  • Определение функций. Затем несколько постов будут посвящены множеству различных способов определения и комбинирования функций.
  • Сигнатуры функций. Далее будет важный пост о критическом значении сигнатур функций, что они значат, и как использовать сигнатуры для понимания содержимого функций.
  • Организация функций. Когда станет понятно, как создавать функции, возникнет вопрос: как можно их организовать, чтобы сделать доступными для остальной части кода?

Математические функции

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

Давайте начнем с математической функции, которая добавляет 1 к числу.

Add1(x) = x+1

Что на самом деле означает это выражение? Выглядит довольно просто. Оно означает, что существует такая операция, которая берет число и прибавляет к нему 1.

Добавим немного терминологии:

  • Множество допустимых входных значений функции называются domain(область определения). В данном примере, это могло быть множество действительных чисел, но сделаем жизнь проще и ограничимся здесь только целыми числами.
  • Множество возможных результатов функции (область значений) называется range (технически, изображение codomain-а). В данном случае также множество целых.
  • Функцией называют отображение (в оригинале map) из domain-а в range. (Т.е. из области определения в область значений.)
Функциональное мышление, image #1

Вот как это определение будет выглядеть на F#.

let add1 x = x + 1

Если ввести его в F# Interactive (не забудьте про двойные точку с запятой), то можно увидеть результат («сигнатуру» функции):

val add1 : int -> int

Рассмотрим вывод подробно:

  • Общий смысл — это функция add1 сопоставляет целые числа (из области определения) с целыми числами (из области значений).
  • «add1» определена как «val», сокращение от «value» (значения). Хм? что это значит? Мы обсудим значения чуть позже.
  • Стрелочная нотация «->» используется, чтобы показать domain и range. В данном случае, domain является типом 'int', как и range.

Заметьте, что тип не был указан явно, но компилятор F# решил, что функция работает с int-ами. (Можно ли это изменить? Да, и скоро мы это увидим).

Ключевые свойства математических функций

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

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

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

Математические функции всегда возвращают одинаковый результат на заданное значение

В императивном программировании мы думаем, что функции либо что-то «делают», либо что-то «подсчитывают». Математические функции ничего не считают, это чистые сопоставления из input в output. В самом деле, другое определение функции — это простое множество всех отображений. Например, очень грубо можно определить функцию «'add1'» (в C#) как

int add1(int input)
{ 
   switch (input)
   {
   case 0: return 1;
   case 1: return 2;
   case 2: return 3;
   case 3: return 4;
   etc ad infinitum
   }
}

Очевидно, что невозможно иметь по case-у на каждое возможное число, но принцип тот же. При такой постановке никаких вычислений не производится, осуществляется лишь поиск.

Математические функции свободны от побочных эффектов

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

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

Эта «иммутабельность» значений очень тонкая, но в тоже время очень важная вещь. Когда я занимаюсь математикой, я не жду, что числа будут изменяться в процессе их сложения. Например, если у меня дано:

x = 5
y = x+1

То я не ожидаю, что x изменится при добавлении к нему 1. Я ожидаю, что получу другое число (y), и xдолжен остаться нетронутым. В мире математики целые числа уже существуют в неизменяемом множестве, и функция «add1» просто определяет отношения между ними.

Сила чистых функций

Те разновидности функций, что имеют повторяемые результаты и не имеют побочных эффектов называются «чистыми / pure функциями», и с ними можно сделать некоторые интересные вещи:

  • Их легко распараллелить. Скажем, можно бы взять целые числа в диапазоне от 1 до 1000 и раздать их 1000 различных процессоров, после чего поручить каждому CPU выполнить «add1» над соответствующим числом, одновременно будучи уверенным, что нет необходимости в каком-либо взаимодействии между ними. Не потребуется ни блокировок, ни мьютексов, ни семафоров, ни т.п.
  • Можно использовать функции лениво, вычисляя их тогда, когда это необходимо для логики программы. Можно быть уверенным, что ответ будет точно таким же, независимо от того, проводятся вычисления сейчас или позже.
  • Можно лишь один раз провести вычисления функции для конкретного входа, после чего закешировать результат, потому что известно, что данные входные значения будут давать такой же выход.
  • Если есть множество чистых функций, их можно вычислять в любом порядке. Опять же, это не может повлиять на финальный результат.

Соответственно, если в языке программирования есть возможность создавать чистые функции, можно немедленно получить множество мощных приемов. И несомненно, все это можно сделать в F#:

  • Пример параллельных вычислений был в серии «Why use F#?».
  • Ленивое вычисление функций будет обсуждено в серии «Optimization».
  • Кэширование результатов функций называется мемоизацией и также будет обсуждено в серии «Optimization».
  • Отсутствие необходимости в отслеживании порядка выполнения делает параллельное программирование гораздо проще и позволяет не сталкиваться с багами вызванными сменой порядка функций или рефакторинга.

««Бесполезные» свойства математических функций

Математические функции также имеют некоторые свойства кажущиеся не очень полезными при программировании.

  • Входные и выходные значения неизменяемы
  • Функции всегда имеют один вход и один выход

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

Входные и выходные значения неизменяемы

Иммутабельные значения в теории кажутся хорошей идеей, но как можно реально сделать какую-либо работу, если нет возможности назначить переменную традиционным способом.

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

Математические функции всегда имеют один вход и один выход

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

Оказывается, существует путь сделать это, и более того он является абсолютно прозрачным на F#. Называется он «каррированием», и заслуживает отдельного поста, который появится в ближайшее время.

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

Значения и фунции-значения

Ещё раз рассмотрим эту простую функцию.

let add1 x = x + 1

Что здесь означает «x»:

  1. Возьми некоторое значение из domain (области определения).
  2. Используй имя «x» для предоставления этого значения, чтоб к нему можно было обратиться позже.

Использование имени для представления значения называется «привязкой» (binding). Имя «x» «привязано» к входному значению.

Так что если вычислить функцию с входным значением, скажем, равным 5, то произойдёт следующее: везде, где стоит «x» в изначальном определении, ставится значение 5, аналогично функции «найти и заменить» в текстовом редакторе.

let add1 x = x + 1
add1 5
// заменяем «x» with «5»
// add1 5 = 5 + 1 = 6
// результат  6

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

Данный принцип — критически важная часть функционального мышления: нет «переменных», только значения.

Функции как значения

Если поразмыслить над этим чуть подольше, можно увидеть, что имя «add1» само по себе — это просто привязка к «функции, которая увеличивает ввод на единицу». Сама функция независима от имени, которое к ней привязано.

Введя let add1 x = x + 1, мы говорим компилятору F# «каждый раз когда ты видишь имя «add1», замени его на функцию, которая добавляет 1 к вводу». «add1» называется функцией-значением (function value).

Чтобы увидеть, что функция не зависит от своего имени, достаточно выполнить следующий код:

let add1 x = x + 1
let plus1 = add1
add1 5
plus1 5

Как видно, «add'» и «plus» — это два имени, привязанных к одной и той же функции.

Идентифицировать функцию-значение всегда можно по её сигнатуре, которая имеет стандартную форму domain -> range. Обобщенная сигнатура функции-значения:

val functionName : domain -> range

Простые значения

Представим операцию, которая ничего не принимает и всегда возвращает 5.

Функциональное мышление, image #2

Это была бы «константная» операция.

Как можно было бы описать это в F#? Мы хотим сказать компилятору: «каждый раз, когда ты видишь имя c, замени его на 5». Вот так:

let c = 5

При вычислении вернётся:

val c : int = 5

В этот раз нет стрелки сопоставления, всего лишь один int. Из нового — знак равенства с реальным значением, выведенным после него. F# компилятор знает, что эта привязка имеет известное значение, которое будет возвращаться всегда, а именно — число 5.

Другими словами, мы только что определили константу, или, в терминах F#, простое значение.
Всегда можно отличить простое значение от функции-значения, потому все простые значения имеют подобную сигнатуру:

val aName: type = constant     // Заметьте - стрелки отсутствуют

Simple values vs. function values | Простые значение vs. функции-значения

Важно понять, что в F#, в отличии от других языков, таких, как C#, существует очень небольшая разница между простыми значения и функциями-значениями. Оба типа являются значениями, которые могут быть связаны с именами (используя ключевое слово let), после чего они могут быть переданы везде. На самом деле, скоро мы увидим, идея о том, что функции — это значения, которые могут быть переданы как входные данные другим функциям, является одним из ключевых аспектов функционального мышления.

Следует учесть, что существует небольшая разница между простым значением и функцией-значением. Функция всегда имеет domain и range, и должна быть «применена» к аргументу, чтобы вернуть результат. Простое значение не надо вычислять после привязки. Используя пример выше, если мы захотим определить «константную функцию» которая возвращает 5, мы могли бы использовать:

let c = fun()->5    
// or
let c() = 5

Сигнатура таких функций выглядит так:

val c : unit -> int

А не так:

val c : int = 5

Больше информации о unit, синтаксисе функций и анонимных функциях будет дано позднее.

«Значения» vs. «Объекты»

В функциональных языках программирования, таких как F#, большинство вещей называется «значениями». В объектно-ориентированных языках, таких как C#, большинство вещей называются «объектами». Какова разница между «значением» и «объектом»?

Значение, как мы видели выше, является членом domain (домена). Домен целых чисел, домен строк, домен функций, которые сопоставляют целым числам строки, и так далее. В принципе, значения иммутабельны (не изменяемы). И значения не имеют поведения, прикрепленного к ним.

Объекты в каноническом определении являются инкапсуляцией структуры данных с ассоциированным поведением (методами). В общем случае, объекты должны иметь состояние (то есть, быть изменяемыми), и все операции, которые меняют внутреннее состояние, должны предоставляться самим объектом (через «dot»-нотацию).

В F#, даже примитивные значения обладают некоторым объемом «объектного» поведения. Например, можно через точку получить длину строки:

«abc».Length

Но в целом, мы будем избегать термина «объект» для стандартных значений в F#, приберегая к нему для обращения к полноценным классам, или другим значениям, предоставляющим методы.

Именование значений

Стандартные правила именования, используемые для имён значений и функций, в основном, это алфавитно-цифровая строка + символы подчеркивания. Но есть пара дополнений:

  1. Можно добавлять апостроф в любой части имени, исключая первый символ.
A'b'c     begin'  // валидные имена
  1. Последний случай часто используется как метка для «различных» версий какого-либо значения:
let f = x
let f' = derivative f
let f'' = derivative f'

или для переменных одноименных существующим ключевым словам

let if' b t f = if b then t else f

Можно также использовать двойные обратные кавычки для любой строки, чтобы сделать ее допустимым идентификатором.

``this is a name``  ``123``    //валидные имена

Случаи, когда может понадобиться использование трюка с двойными обратными кавычками:

  • Когда необходимо использовать идентификатор, который совпадает с ключевым словом.
let ``begin`` = «begin»
  • Когда необходимо использовать естественные языки для бизнес-правил, модульных тестов, или BBD стиля исполняемой документации типа Cucumber.
let ``is first time customer?`` = true
let ``add gift to order`` = ()
if ``is first time customer?`` then ``add gift to order``

// Юнит-тест
let [<Test>] ``When input is 2 then expect square is 4``=  
   // code here

// BDD clause
let [<Given>] ``I have (.*) N products in my cart`` (n:int) =  
   // code here

В отличие от C#, конвенция именования F# требует, чтобы функции и значения начинались со строчной буквы, а не прописной (camelCase, а не PascalCase), кроме тех случаев, когда они предназначены для взаимодействия с другимиязыками .NET. Однако типы и модули используют заглавные буквы (в начале)

1000 views·48 shares