company_banner

Функциональное мышление. Часть 7

https://fsharpforfunandprofit.com/series/thinking-functionally.html
  • Перевод

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




Определение функций


Мы уже знаем как создавать обычные функции используя "let" синтаксис:


let add x y = x + y

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


Анонимные функции (лямбды)


Если вы знакомы с лямбдами в других языках, следующие абзацы покажутся знакомыми. Анонимные функции (или "лямбда-выражения") определяются следующим образом:


fun parameter1 parameter2 etc -> expression

По сравнению с лямбдами из C# есть два отличия:


  • лямбды должны начинаться с ключевого слова fun, которое в C# не требуется
  • используется одинарная стрелка ->, вместо двойной => из C#.

Лямбда-определение функции сложения:


let add = fun x y -> x + y

Та же функция в традиционной форме:


let add x y = x + y

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


// отдельно описанная функция
let add1 i = i + 1
[1..10] |> List.map add1

// лямбда функция переданная без описания отдельной функции
[1..10] |> List.map (fun i -> i + 1)

Обратите внимание, вокруг лямбд необходимо использовать скобки.


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


// изначальное определение
let adderGenerator x = (+) x

// определение через лямбда функцию
let adderGenerator x = fun y -> x + y

Лямбда-версия немного длиннее, но сразу даёт понять, что будет возвращена промежуточная функция.


Лямбды могут быть вложенными. Еще один пример определения adderGenerator, в этот раз только на лямбдах.


let adderGenerator = fun x -> (fun y -> x + y)

Ясно ли вам, что все три определения эквивалентны?


let adderGenerator1 x y = x + y
let adderGenerator2 x   = fun y -> x + y
let adderGenerator3     = fun x -> (fun y -> x + y)

Если нет, то перечитайте главу о каррировании. Это очень важно для понимания!


Сопоставление параметров с шаблоном


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


Следующий пример демонстрирует использование шаблонов в определении функции:


type Name = {first:string; last:string} // описываем новый тип
let bob = {first="bob"; last="smith"}   // описываем значение

// явно передаем один параметр
let f1 name =                       // передача параметра
   let {first=f; last=l} = name     // деконструируем параметр через шаблон
   printfn "first=%s; last=%s" f l

// использование шаблона
let f2 {first=f; last=l} =          // сопоставление с образцом прямо в описании функции
   printfn "first=%s; last=%s" f l

// тест
f1 bob
f2 bob

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


let f3 (x::xs) =            // используем сопоставление с образцом для списка
   printfn "first element is=%A" x

Компилятор выдаст предупреждение о неполноте сопоставления (пустой список вызовет ошибку в рантайме на входе в эту функцию).


Распространенная ошибка: кортежи vs. множество параметров


Если вы пришли из C-подобного языка, кортеж, использованный в качестве единственного аргумента функции, может до боли напоминать многопараметрическую функцию. Но это не одно и то же! Как я отметил ранее, если вы видите запятую, скорее всего это кортеж. Параметры же разделяются пробелами.


Пример путаницы:


// функция которая принимает два параметра
let addTwoParams x y = x + y

// функция которая принимает один параметр - кортеж
let addTuple aTuple =
   let (x,y) = aTuple
   x + y

// другая функция которая принимает один кортеж как параметр
// но выглядит так будто принимает два параметра
let addConfusingTuple (x,y) = x + y

  • Первое определение, "addTwoParams", принимает два параметра, разделенных пробелом.
  • Второе определение, "addTuple", принимает один параметр. Этот параметр привязывает "x" и "y" из кортежа и суммирует их.
  • Третье определение, "addConfusingTuple", принимает один параметр как и "addTuple", но трюк в том, что этот кортеж распаковывается(сопоставляется с образцом) и привязывается как часть определения параметра при помощи сопоставления с шаблоном. За кулисами все происходит точно так же, как и в "addTuple".

Посмотрим на сигнатуры (всегда смотрите на них если в чём-то не уверены).


val addTwoParams : int -> int -> int        // два параметра
val addTuple : int * int -> int             // tuple->int
val addConfusingTuple : int * int -> int    // tuple->int

А теперь сюда:


//тест
addTwoParams 1 2      // ok -- используются пробелы для разделения параметров
addTwoParams (1,2)    // error - передается только один кортеж
//   => error FS0001: This expression was expected to have type
//                    int but here has type 'a * 'b

Здесь мы видим ошибку во втором вызове.


Во-первых, компилятор трактует (1,2) как обобщенный кортеж вида ('a * 'b), который и пробует передать в качестве первого параметра в "addTwoParams". После чего жалуется, что ожидаемый первый параметр addTwoParams не является int, а была совершена попытка передачи кортежа.


Что бы сделать кортеж, используйте запятую!


addTuple (1,2)           // ok
addConfusingTuple (1,2)  // ok

let x = (1,2)
addTuple x               // ok

let y = 1,2              // нужна запятая,
                         // никаких скобок!
addTuple y               // ok
addConfusingTuple y      // ok

И наоборот, если передать несколько аргументов в функцию ожидающую кортеж, так же получите непонятную ошибку.


addConfusingTuple 1 2    // error -- попытка передать два параметра в функцию принимающую один кортеж
// => error FS0003: This value is not a function and
//                  cannot be applied

В этот раз, компилятор решил, что раз передаются два аргумента, addConfusingTuple должна быть каррируемой. А запись "addConfusingTuple 1" является частичным применением и должна возвращать промежуточную функцию. Попытка вызвать эту промежуточную функцию с параметром "2" выдаст ошибку, т.к. никакой промежуточной функции нет! Мы видим ту же ошибку, что и в главе о каррировании, где мы обсуждали проблемы со слишком большим количеством параметров.


Почему бы не использовать кортежи в качестве параметров?


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


let f (x,y,z) = x + y * z
// тип ф-ции int * int * int -> int

// тест
f (1,2,3)

Следует обратить внимание, что сигнатура отличается от сигнатуры функции с тремя параметрами. Здесь только одна стрелка, один параметр и звездочки, указывающие на кортеж (int*int*int).


Когда надо подавать аргументы отдельными параметрами, а когда кортежем?


  • Когда кортежи значимы сами по себе. Например, для операций в трехмерном пространстве, тройные кортежи будут удобнее чем три координаты по отдельности.
  • Иногда кортежи используются для объединения данные, которые должны сохраняться вместе, в единую структуру. Например, TryParse методы из .NET библиотеки возвращают результат и булеву переменную в виде кортежа. Но для хранения большого количества связанных данных лучше определить класс или запись (record.

Особые случай: кортежи и функции .NET библиотеки


При вызове .NET библиотек запятые встречаются очень часто!


Они все принимают кортежи, и вызовы выглядят так же как в C#:


// верно
System.String.Compare("a","b")

// не верно
System.String.Compare "a" "b"

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


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


let tuple = ("a","b")
System.String.Compare tuple   // error  

System.String.Compare "a","b" // error  

Если есть желание частично применить функции .NET, достаточно написать обёртки над ними, как делалось ранее, или как показано ниже:


// создаем функцию обертку
let strCompare x y = System.String.Compare(x,y)

// частично применяем ее
let strCompareWithB = strCompare "B"

// используем с функцией высшего порядка
["A";"B";"C"]
|> List.map strCompareWithB

Руководство по выбору отдельных и cгруппированных параметров


Обсуждение кортежей ведет к более общей теме: когда параметры должны быть отдельными, а когда cгруппированными?


Следует обратить внимание на то, чем F# отличается от C# в этом отношении. В C# все параметры всегда переданы, поэтому данный вопрос там даже не возникает! В F# из-за частичного применения могут быть представлены лишь некоторые из параметров, поэтому необходимо проводить различие между случаем, когда параметры должны быть объединены, и случаем, когда они независимы.


Общие рекомендации о том, как структурировать параметры при проектировании собственных функций.


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

Другими словами, когда разрабатываете функцию, спросите себя "Могу ли я предоставить это параметр отдельно?". Если ответ нет, то параметры должны быть сгруппированы.


Рассмотрим несколько примеров:


// Передача двух параметров для сложения.
// Числе не зависит друг от друга, поэтому передаем их как два параметра
let add x y = x + y

// Передаем в функцию два числа как географические координаты
// Числа тут зависят друг от друга, поэтому используем кортежи
let locateOnMap (xCoord,yCoord) = //  код

// Задаем имя и фамилию клиента
// Значения зависят друг от друга - группируем их в запись
type CustomerName = {First:string; Last:string}
let setCustomerName aCustomerName = // хорошо
let setCustomerName first last = // не рекомендуется

// Задаем имя и фамилию
// вместе с правами пользователя
// имя и права независимы, можем передавать их раздельно
let setCustomerName myCredentials aName = //хорошо

Наконец, убедитесь, что порядок параметров поможет в частичном применении (смотрите руководство здесь). Например, почему я поместил myCredentials перед aName в последней функции?


Функции без параметров


Иногда может понадобиться функция, которая не принимает никаких параметров. Например, нужна функция "hello world" которую можно вызывать многократно. Как было показано в предыдущей секции, наивное определение не работает.


let sayHello = printfn "Hello World!"     // не то что я хотел

Но это можно исправить, если добавить unit параметр к функции или использовать лямбду.


let sayHello() = printfn "Hello World!"           // хорошо
let sayHello = fun () -> printfn "Hello World!"   // хорошо

После чего функция всегда должна вызываться с unit аргументом:


// вызов
sayHello()

Что происходит достаточно часто при взаимодействии с .NET библиотеками:


Console.ReadLine()
System.Environment.GetCommandLineArgs()
System.IO.Directory.GetCurrentDirectory()

Запомните, вызывайте их с unit параметрами!


Определение новых операторов


Можно определять функции с использованием одного и более операторных символа (смотрите документацию для ознакомления со списком символов):


// описываем
let (.*%) x y = x + y + 1

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


Операторы начинающиеся с * требуют пробел между скобкой и *, т.к. в F# (* выполняет роль начала комментария (как /*...*/ в C#):


let ( *+* ) x y = x + y + 1

Единожды определенная, новая функция может быть использована обычным способом, если будет завернута в скобки:


let result = (.*%) 2 3

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


let result = 2 .*% 3

Можно также определять префиксные операторы начинающиеся с ! или ~ (с некоторыми ограничениями, смотрите документацию)


let (~%%) (s:string) = s.ToCharArray()

//используем
let result = %% "hello"

В F# определение операторов достаточно частая операция, и многие библиотеки будут экспортировать операторы с именами типа >=>и <*>.


Point-free стиль


Мы уже видели множество примеров функций у которых отсутствовали последние параметры, чтобы снизить уровень хаоса. Этот стиль называется point-free стилем или молчаливым программированием (tacit programming).


Вот несколько примеров:


let add x y = x + y   // явно
let add x = (+) x     // point free

let add1Times2 x = (x + 1) * 2    // явно
let add1Times2 = (+) 1 >> (*) 2   // point free

let sum list = List.reduce (fun sum e -> sum+e) list // явно
let sum = List.reduce (+)                            // point free

У данного стиля есть свои плюсы и минусы.


Одним из плюсов является то, что акцент производится на композицию функций высшего порядка вместо возни с низкоуровневыми объектами. Например, "(+) 1 >> (*) 2" — явное сложение с последующим умножением. А "List.reduce (+)" дает понять, что важна операция сложения, безотносительно информации о списке.


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


С другой стороны, чрезмерное использование подобного стиля может сделать код малопонятным. Явные параметры действуют как документация и их имена (такие как "list") облегчают понимание того, что делает функция.


Как и все в программировании, лучшая рекомендация, предпочитайте тот подход, что обеспечивает наибольшую ясность.


Комбинаторы


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


На практике, это означает, что комбинаторные функции ограничены комбинацией их параметров различными способами.


Мы уже видели несколько комбинаторов: "pipe"(конвейер) и оператор композиции. Если посмотреть на их определения, то понятно, что все, что они делают, это переупорядочивают параметры различными способами.


let (|>) x f = f x             // прямой pipe
let (<|) f x = f x             // обратный pipe
let (>>) f g x = g (f x)       // прямая композиция
let (<<) g f x = g (f x)       // обратная композиция

С другой стороны, функции подобные "printf", хоть и примитивны, но не являются комбинаторами, потому-что имеют зависимость от внешнего мира (I/O).


Комбинаторные птички


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


Чтобы узнать больше о комбинаторах и комбинаторной логики, я рекомендую книгу "To Mock a Mockingbird" Raymond-а Smullyan-а. В ней он объясняет другие комбинаторы и причудливо дает им названия птиц. Вот несколько примеров стандартных комбинаторов и их птичьих имен:


let I x = x                // тождественная функция, или Idiot bird
let K x y = x              // the Kestrel
let M x = x >> x           // the Mockingbird
let T x y = y x            // the Thrush (выглядит знакомо!)
let Q x y z = y (x z)      // the Queer bird (тоже знакомо!)
let S x y z = x z (y z)    // The Starling
// и печально известный...
let rec Y f x = f (Y f) x  // Y-комбинатор, или Sage bird

Буквенные имена вполне стандартные, так что можно ссылаться на K-комбинатор всякому, кто знаком с данной терминологией.


Получается, что множество распространенных шаблонов программирования могут быть представлены через данные стандартные комбинаторы. Например, Kestrel является обычным паттерном в fluent интерфейсе где вы делаете что-то, но возвращаете оригинальный объект. Thrush — пайп, Queer — прямая композиция, а Y-комбинатор отлично справляется с созданием рекурсивных функций.


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


Библиотеки комбинаторов


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


Хорошо спроектированная библиотека комбинаторов позволяет сосредоточиться на высокоуровневых функциях, и скрыть низкоуровневый "шум". Мы уже видели их силу в нескольких примерах в серии "why use F#", и модуль List полон таких функций, "fold" и "map" также являются комбинаторами, если вы подумаете над этим.


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


В F# библиотеки комбинаторов доступны для парсинга (FParsec), создания HTML, тестирующих фреймворков и т.д. Мы обсудим и воспользуемся комбинаторами позднее в следующих сериях.


Рекурсивные функции


Часто функции необходимо ссылаться на саму себя из ее тела. Классический пример — функция Фибоначчи.


let fib i =
   match i with
   | 1 -> 1
   | 2 -> 1
   | n -> fib(n-1) + fib(n-2)

К сожалению, данная функция не сможет скомпилироваться:


error FS0039: The value or constructor 'fib' is not defined

Необходимо указать компилятору, что это рекурсивная функция используя ключевое слово rec.


let rec fib i =
   match i with
   | 1 -> 1
   | 2 -> 1
   | n -> fib(n-1) + fib(n-2)

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


Дополнительные ресурсы


Для F# существует множество самоучителей, включая материалы для тех, кто пришел с опытом C# или Java. Следующие ссылки могут быть полезными по мере того, как вы будете глубже изучать F#:



Также описаны еще несколько способов, как начать изучение F#.


И наконец, сообщество F# очень дружелюбно к начинающим. Есть очень активный чат в Slack, поддерживаемый F# Software Foundation, с комнатами для начинающих, к которым вы можете свободно присоединиться. Мы настоятельно рекомендуем вам это сделать!


Не забудьте посетить сайт русскоязычного сообщества F#! Если у вас возникнут вопросы по изучению языка, мы будем рады обсудить их в чатах:



Об авторах перевода


Автор перевода @kleidemos
Перевод и редакторские правки сделаны усилиями русскоязычного сообщества F#-разработчиков. Мы также благодарим @schvepsss и @shwars за подготовку данной статьи к публикации.

Microsoft
410,00
Microsoft — мировой лидер в области ПО и ИТ-услуг
Поделиться публикацией

Комментарии 0

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое