Функциональное мышление. Часть 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 функций для частичного применения
Функции библиотеки базовых классов (base class library — BCL) .NET легко доступны из F#, но они спроектированы без расчёта на использование в функциональных языках, таких как F#. Например, большинство функций требует параметр данных вначале, в то время как в 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
. Фактически передаваемая функция f
уже частично применена и ожидает лишь один параметр — входное значение для конвейеризации (т е x
).
Вот аналогичный пример, переписанный с целью частичного применения
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 // псевдоинфиксная запись