Функциональное мышление. Часть 5
Это математически корректное решение, однако есть и другие причины так поступать — это также приводит к очень мощной технике, называемой частичное применение функций. В предыдущем посте о каррировании мы увидели, как функции с несколькими параметрами дробятся на функции поменьше, с одним параметром. Такой стиль очень широко используется в функциональном программировании, и очень важно его понимать.
Из обсуждения каррирования можно было увидеть, как частичное применение происходит естественным образом.
Несколько простых примеров для иллюстрации: Идея частичного применения заключается в том, что если зафиксировать первые N параметров функции, получится новая функция с оставшимися параметрами.
// Создаем "сумматор" с помощью частичного применения к функции + аргумента 42
let add42 = (+) 42 // само частичное применение
add42 1
add42 3 // создаем новый список через применение функции
// к каждому элементу исходного списка
[1;2;3] |> List.map add42 // создаем предикатную функцию с помощью частичного применения к функции "меньше"
let twoIsLessThan = (<) 2 // частичное применение
twoIsLessThan 1
twoIsLessThan 3 // отфильтруем каждый элемент с функцией twoIsLessThan
[1;2;3] |> List.filter twoIsLessThan // создаем функцию "печать" с помощью частичного применения к функции printfn
let printer = printfn "printing param=%i" // итерируем список и вызываем функцию printer для каждого элемента
[1;2;3] |> List.iter printer
В каждом случае мы создаем частично примененную функцию, которую можно повторно использовать в разных ситуациях.
Вот несколько примеров: И конечно, частичное применение позволяет так же легко фиксировать параметры-функции.
// пример использования List.map
let add1 = (+) 1
let add1ToEach = List.map add1 // фиксируем функцию "add1" с List.map // тестируем
add1ToEach [1;2;3;4] // пример с использованием List.filter
let filterEvens = List.filter (fun i -> i%2 = 0) // фиксируем фильтр функции // тестируем
filterEvens [1;2;3;4]
Следующий, более сложный пример иллюстрирует то, как тот же подход может использоваться для того, чтобы прозрачно создать "встраиваемое" поведение.
- Создаем функцию, которая складывает два числа, но в дополнение она принимает функцию логирования, которая будет логировать эти числа и результат.
- Функция логирования принимает два параметра: (string) "name" и (generic) "value", поэтому имеет сигнатуру
string->'a->unit
. - Затем мы создаем различные реализации логирующей функции, такие как консольный логгер или логгер на основе всплывающего окна.
- И наконец, мы частично применяем основную функцию для создания новой функции, с замкнутым логгером.
// создаем сумматор который поддерживает встраиваемый логгер-функцию
let adderWithPluggableLogger logger x y = logger "x" x logger "y" y let result = x + y logger "x+y" result result // создаем логгер-функцию которая выводит лог на консоль
let consoleLogger argName argValue = printfn "%s=%A" argName argValue // создаем сумматор с логером на консоль через частичное применение функции
let addWithConsoleLogger = adderWithPluggableLogger consoleLogger
addWithConsoleLogger 1 2
addWithConsoleLogger 42 99 // создаем логгер-функцию с выводом во всплывающее окно
let popupLogger argName argValue = let message = sprintf "%s=%A" argName argValue System.Windows.Forms.MessageBox.Show( text=message,caption="Logger") |> ignore // создаем сумматор с логгер-фукцией во всплывающее окно через частичное применение
let addWithPopupLogger = adderWithPluggableLogger popupLogger
addWithPopupLogger 1 2
addWithPopupLogger 42 99
Например, мы можем создать частичное применение для прибавления 42, и затем передать его в списочную функцию, как мы делали для простой функции "add42
". Эти функции с замкнутым логгером могут быть использованы как любые другие функции.
// создаем еще один сумматор с частично примененным параметром 42
let add42WithConsoleLogger = addWithConsoleLogger 42
[1;2;3] |> List.map add42WithConsoleLogger
[1;2;3] |> List.map add42 //сравниваем с сумматором без логгера
Мы можем создать гибкие (хоть и сложные) библиотечные функции, причем легко сделать их по умолчанию пригодными для повторного использования, так что сложность будет сокрыта от клиентского кода. Частично примененные функции — это очень полезный инструмент.
Проектирование функций для частичного применения
Например, большинство функций в List
таких как List.map
и List.filter
имеют схожую форму, а именно: Очевидно, что порядок параметров может серьезно влиять на удобство частичного применения.
List-function [function parameter(s)] [list]
Несколько примеров в полной форме: Список всегда является последним параметром.
List.map (fun i -> i+1) [0;1;2;3]
List.filter (fun i -> i>1) [0;1;2;3]
List.sortBy (fun i -> -i ) [0;1;2;3]
Те же самые примеры с использованием частичного применения:
let eachAdd1 = List.map (fun i -> i+1)
eachAdd1 [0;1;2;3] let excludeOneOrLess = List.filter (fun i -> i>1)
excludeOneOrLess [0;1;2;3] let sortDesc = List.sortBy (fun i -> -i)
sortDesc [0;1;2;3]
Если бы библиотечные функции были реализованы с другим порядком аргументов, частичное применение было бы намного менее удобным.
Как и во всех вопросах проектирования, здесь нет "правильного" ответа, но есть несколько общепринятых рекомендаций. Когда вы пишете свою функцию с многими параметрами, вы можете задуматься о наилучшем их порядке.
- Ставьте в начало параметры, которые скорее всего будут статичными
- Ставьте последними структуры данных или коллекции (или другие изменяющиеся параметры)
- Для лучшего восприятия операций, таких как вычитание, желательно соблюдать ожидаемый порядок
Параметры, которые скорее всего будут "зафиксированы" частичным применением должны идти первыми, как в примерах с логгером выше. Первый совет прост.
Мы уже наблюдали это много раз в примерах с функциями над списками. Следование второму совету облегчает использование оператора конвейеризации и композиции.
// использование конвейерной функции со списком и функциями обработки списков
let result = [1..10] |> List.map (fun i -> i+1) |> List.filter (fun i -> i>5)
параметр-список может быть опущен: Аналогично, частичное примененные функции над списками легко подвергаются композиции, т.к.
let compositeOp = List.map (fun i -> i+1) >> List.filter (fun i -> i>5)
let result = compositeOp [1..10]
Оборачивание BCL функций для частичного применения
NET легко доступны из F#, но они спроектированы без расчёта на использование в функциональных языках, таких как F#. Функции библиотеки базовых классов (base class library — BCL) . Например, большинство функций требует параметр данных вначале, в то время как в F# параметр данных в общем случае должен быть последним.
В примере ниже строковые . Однако, достаточно легко можно написать обертки, чтобы сделать эти функции более идиоматичными. NET функции переписаны так, чтобы целевая строка использовалась последней, а не первой:
// создает обертку вокруг стандартного .NET метода
let replace oldStr newStr (s:string) = s.Replace(oldValue=oldStr, newValue=newStr) let startsWith lookFor (s:string) = s.StartsWith(lookFor)
После того, как строка стала последним параметром, можно использовать эти функции в конвейерах, как обычно:
let result = "hello" |> replace "h" "j" |> startsWith "j" ["the"; "quick"; "brown"; "fox"] |> List.filter (startsWith "f")
или в композиции функций:
let compositeOp = replace "h" "j" >> startsWith "j"
let result = compositeOp "hello"
Понимание конвейерного оператора
После того, как вы увидели частичное применение в деле, вы можете понять, как работают конвейерные функции.
Функция конвейеризации определена так:
let (|>) x f = f x
Всё, что она делает, это позволяет поставить аргумент перед функцией, а не после.
let doSomething x y z = x+y+z
doSomething 1 2 3 // все параметры после функции
Фактически передаваемая функция f
уже частично применена и ожидает лишь один параметр — входное значение для конвейеризации (т е x
). В случае, когда функция f
имеет несколько параметров, а в качестве входного значения x
конвейеризации будет выступать последний параметр функции f
.
Вот аналогичный пример, переписанный с целью частичного применения
let doSomething x y = let intermediateFn z = x+y+z intermediateFn // возвращаем intermediateFn let doSomethingPartial = doSomething 1 2
doSomethingPartial 3 // теперь только один параметр после функции
3 |> doSomethingPartial // тоже что и выше, но теперь последний параметр конвейеризован в функцию
Еще несколько примеров, которые вы возможно встречали: Как вы уже видели, конвейерный оператор чрезвычайно распространен в F#, и используется всякий раз, когда требуется сохранить естественный поток данных.
"12" |> int // парсит строку "12" в int
1 |> (+) 2 |> (*) 3 // арифметическая цепочка
Обратный конвейерный оператор
Время от времени можно встретить обратный конвейерный оператор "<|".
let (<|) f x = f x
Кажется, что эта функция ничего не делает, так почему же она существует?
Причина заключается в том, что когда обратный конвейерный оператор используется как бинарный оператор в инфиксном стиле, он снижает потребность в скобках, что делает код чище.
printf "%i" 1+2 // ошибка
printf "%i" (1+2) // использование скобок
printf "%i" <| 1+2 // использование обратного конвейера
Можно использовать конвейеры сразу в двух направлениях для получения псевдоинфиксной нотации.
let add x y = x + y
(1+2) add (3+4) // ошибка
1+2 |> add <| 3+4 // псевдоинфиксная запись