Функциональное мышление
Давайте обсудим основы функционального программирования. Что в действительности означает «программировать функционально», и чем этот подход отличается от объектно-ориентированного или императивного программирования?
Смена образа мышления (Intro)
Важно понимать, что функциональное программирование — это не просто отдельный стиль программирования. Это совсем другой способ мышления в программировании, который отличается от «традиционного» подхода так же значительно, как настоящее ООП (в стиле Smalltalk) отличается от традиционного императивного языка — такого, как C.
F# позволяет использовать нефункциональные стили кодирования, и это искушает программиста сохранить его существующие привычки. На F# вы можете программировать так, как привыкли, не меняя радикально мировоззрения, и даже не представляя, что при этом упускаете. Однако, чтобы получить от F# максимальную отдачу, а также научиться уверенно программировать в функциональном стиле вообще, очень важно научиться мыслить функционально, а не императивно.
Цель данной серии статей — помочь читателю понять подоплёку функционального программирования и изменить его способ мышления.
Это будет довольно абстрактная серия, хотя я буду использовать множество коротких примеров кода для демонстрации некоторых моментов. Мы рассмотрим следующие темы:
- Математические функции. Первая статья знакомит с математическими представлениями, лежащими в основе функциональных языков и преимуществами, которые приносит данный подход.
- Функции и значения. Следующая знакомит с функциями и значениями, объясняет чем «значения» отличаются от переменных, и какие есть сходства между функциями и простыми значениями.
- Типы. Затем мы перейдем к основным типам, которые работают с функциями: примитивные типы, такие как string и int, тип unit, функциональные типы, и обобщённые типы (generic).
- Функции с несколькими параметрами. Далее я объясню понятия «каррирования» и «частичного применения». В этом месте чьим-то мозгам будет больно, особенно если у этих мозгов только императивное прошлое.
- Определение функций. Затем несколько постов будут посвящены множеству различных способов определения и комбинирования функций.
- Сигнатуры функций. Далее будет важный пост о критическом значении сигнатур функций, что они значат, и как использовать сигнатуры для понимания содержимого функций.
- Организация функций. Когда станет понятно, как создавать функции, возникнет вопрос: как можно их организовать, чтобы сделать доступными для остальной части кода?
Математические функции
Функциональное программирование вдохновлено математикой. Математические функции имеют ряд очень приятных особенностей, которые функциональные языки пытаются претворить в жизнь.
Давайте начнем с математической функции, которая добавляет 1 к числу.
Add1(x) = x+1
Что на самом деле означает это выражение? Выглядит довольно просто. Оно означает, что существует такая операция, которая берет число и прибавляет к нему 1.
Добавим немного терминологии:
- Множество допустимых входных значений функции называются domain(область определения). В данном примере, это могло быть множество действительных чисел, но сделаем жизнь проще и ограничимся здесь только целыми числами.
- Множество возможных результатов функции (область значений) называется range (технически, изображение codomain-а). В данном случае также множество целых.
- Функцией называют отображение (в оригинале map) из domain-а в range. (Т.е. из области определения в область значений.)
Вот как это определение будет выглядеть на 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»:
- Возьми некоторое значение из domain (области определения).
- Используй имя «
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.
Это была бы «константная» операция.
Как можно было бы описать это в 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#, приберегая к нему для обращения к полноценным классам, или другим значениям, предоставляющим методы.
Именование значений
Стандартные правила именования, используемые для имён значений и функций, в основном, это алфавитно-цифровая строка + символы подчеркивания. Но есть пара дополнений:
- Можно добавлять апостроф в любой части имени, исключая первый символ.
A'b'c begin' // валидные имена
- Последний случай часто используется как метка для «различных» версий какого-либо значения:
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. Однако типы и модули используют заглавные буквы (в начале)
