После небольшого экскурса в базовые типы, мы можем снова вернуться к функциям. В частности, к ранее упомянутой загадке: если математическая функция может принимать только один параметр, то как в F# может существовать функция, принимающая большее число параметров? Подробнее под катом!
Ответ довольно прост: функция с несколькими параметрами переписывается как серия новых функций, каждая из которых принимает только один параметр. Эту операцию компилятор выполняет автоматически, и называется она "каррирование" (currying), в честь Хаскела Карри, математика, который существенно повлиял на разработку функционального программирования.
Чтобы увидеть, как каррирование работает на практике, воспользуемся простейшим примером кода, печатающим два числа:
// нормальная функция
let printTwoParameters x y =
printfn "x=%i y=%i" x y
На самом деле, компилятор переписывает его приблизительно в такой форме:
// каррирование описанное явно
let printTwoParameters x = // Только один параметр
let subFunction y =
printfn "x=%i y=%i" x y // Новая подфункция, принимающая один параметр
subFunction // Возвращаем подфункцию
Рассмотрим этот процесс подробнее:
- Объявляется функция с названием "
printTwoParameters
", но принимающая только один параметр: "x". - Внутри неё создаётся локальная функция, которая также принимает только один параметр: "y". Заметим, что локальная функция использует параметр "x", но x не передается в нее как аргумент. "x" находится в такой области видимости, что вложенная функция может видеть его и использовать без необходимости в его передаче.
- Наконец, возвращается только что созданная локальная функция.
- Возвращенная функция затем применяется к аргументу "y". Параметр "x" замыкается в ней так, что возвращаемая функция нуждается только в параметре "y", чтобы завершить свою логику.
Переписывая функции таким образом, компилятор гарантирует, что каждая функция принимает только один параметр, как и требовалось. Таким образом, используя "printTwoParameters
", можно подумать, что это функция с двумя параметрами, но на самом деле используется функция только с одним параметром. В этом можно убедиться, передав ей лишь один аргумент вместо двух:
// выполним с одним аргументом
printTwoParameters 1
// получим назад функцию
val it : (int -> unit) = <fun:printTwoParameters@286-3>
Если вычислить ее с одним аргументом, мы не получим ошибку — будет возвращена функция.
Итак, вот что на самом деле происходит, когда printTwoParameters
вызывается с двумя аргументами:
- Вызывается
printTwoParameters
с первым аргументом (x) printTwoParameters
возвращает новую функцию, в которой замкнут "x".- Затем вызывается новая функция со вторым аргументом (y)
Вот пример пошаговой и нормальной версий:
// Пошаговая версия
let x = 6
let y = 99
let intermediateFn = printTwoParameters x // вернем ф-цию с
// x в замыкании
let result = intermediateFn y
// однострочная версия пошагового исполнения
let result = (printTwoParameters x) y
// нормальная функция
let result = printTwoParameters x y
Вот другой пример:
//Нормальная версия
let addTwoParameters x y =
x + y
//явно каррированная версия
let addTwoParameters x = // только один параметр!
let subFunction y =
x + y // новая подфункция с одним параметром
subFunction // возвращаем подфункцию
// теперь используем ее в пошаговом варианте
let x = 6
let y = 99
let intermediateFn = addTwoParameters x // возвращаем ф-цию с
// x в замыкании
let result = intermediateFn y
// Нормальная версия
let result = addTwoParameters x y
Опять же, "функция с двумя параметрами" на самом деле является функцией с одним параметром, которая возвращает промежуточную функцию.
Но подождите, а что с оператором "+
"? Это ведь бинарная операция, которая должна принимать два параметра? Нет, она тоже каррируется, как и другие функции. Это функция с именем "+
", которая принимает один параметр и возвращает новую промежуточную функцию, в точности как addTwoParameters
выше.
Когда мы пишем выражение x+y
, компилятор переупорядочивает код таким образом, чтобы преобразовать инфикс в (+) x y
, что является функцией с именем +
, принимающей два параметра. Заметим, что функция "+" нуждается в скобках, чтобы указать на то, она используется как обычная функция, а не как инфиксный оператор.
Наконец, функция с двумя параметрами, называемая +
, обрабатывается как любая другая функция с двумя параметрами.
// используем плюс как функцию вызванную с одним параметром
let x = 6
let y = 99
let intermediateFn = (+) x // вернется функция "добавить" с "х" в замыкании
let result = intermediateFn y
// используем плюс как функцию с двумя параметрами
let result = (+) x y
// нормальное использование плюса как инфиксного оператора
let result = x + y
И да, это работает на все другие операторы и встроенные функции, такие как printf
.
// нормальная версия умножения
let result = 3 * 5
// умножение как унарная ф-ция
let intermediateFn = (*) 3 // вернется "умножить" с 3 в замыкании
let result = intermediateFn 5
// нормальнвя версия printfn
let result = printfn "x=%i y=%i" 3 5
// printfn как унарная ф-ция
let intermediateFn = printfn "x=%i y=%i" 3 // "3" в замыкании
let result = intermediateFn 5
Сигнатуры каррированных функций
Теперь, когда мы знаем, как работают каррированные функции, интересно узнать на что будут похожи их сигнатуры.
Возвращаясь к первому примеру, "printTwoParameter
", мы видели, что функция принимала один аргумент и возвращала промежуточную функцию. Промежуточная функция также принимала один аргумент и ничего не возвращала (т.е. unit
). Поэтому промежуточная функция имела тип int->unit
. Другими словами, domain printTwoParameters
— это int
, а range — int->unit
. Собрав все это воедино мы увидим конечную сигнатуру:
val printTwoParameters : int -> (int -> unit)
Если вычислить явно каррированную реализацию, можно увидеть скобки в сигнатуре, но если вычислить обыкновенную, неявно каррированную реализацию, скобок не будет:
val printTwoParameters : int -> int -> unit
Скобки необязательны. Но их можно представить в уме, чтобы упростить восприятие сигнатур функций.
А в чём разница между функцией, которая возвращает промежуточную функцию, и обычной функцией с двумя параметрами?
Вот функция с одним параметром, возвращающая другую функцию:
let add1Param x = (+) x
// signature is = int -> (int -> int)
А вот функция с двумя параметрами, которая возвращает простое значение:
let add2Params x y = (+) x y
// signature is = int -> int -> int
Их сигнатуры немного отличаются, но в практическом смысле между ними нет особой разницы, за исключением того факта, что вторая функция автоматически каррирована.
Функции с более чем двумя параметрами
Как работает каррирование для функций с количеством параметров, большим двух? Точно так же: для каждого параметра, кроме последнего, функция возвращает промежуточную функцию, замыкающую предыдущий параметр.
Рассмотрим этот нелегкий пример. У меня явно объявлены типы параметров, но функция ничего не делает.
let multiParamFn (p1:int)(p2:bool)(p3:string)(p4:float)=
() //ничего не делаем
let intermediateFn1 = multiParamFn 42 // multoParamFn принимает int и возвращает (bool -> string -> float -> unit)
// intermediateFn1 принимает bool
// и возвращает (string -> float -> unit)
let intermediateFn2 = intermediateFn1 false
// intermediateFn2 принимает string
// и возвращает (float -> unit)
let intermediateFn3 = intermediateFn2 "hello"
// intermediateFn3 принимаетfloat
// и возвращает простое значение (unit)
let finalResult = intermediateFn3 3.141
Сигнатура всей функции:
val multiParamFn : int -> bool -> string -> float -> unit
и сигнатуры промежуточных функций:
val intermediateFn1 : (bool -> string -> float -> unit)
val intermediateFn2 : (string -> float -> unit)
val intermediateFn3 : (float -> unit)
val finalResult : unit = ()
Сигнатура функции может сообщить о том, сколько параметров принимает функция: достаточно подсчитать число стрелок вне скобок. Если функция принимает или возвращает другую функцию, будут еще стрелки, но они будут в скобках и их можно будет проигнорировать. Вот некоторые примеры:
int->int->int // 2 параметра int возвращаем int
string->bool->int // первый параметро string, второй - bool,
// вернется int
int->string->bool->unit // три параметра (int,string,bool)
// ничего не вернется (unit)
(int->string)->int // только один параметр, функция
// (из int в string)
// и вернется int
(int->string)->(int->bool) // принимает функцию (int в string)
// вернет функцию (int в bool)
Трудности с множественными параметрами
Пока вы не поймёте логику, которая стоит за каррированием, она будет приводить к некоторым неожиданным результатам. Помните, что вы не получите ошибку, если запустите функцию с меньшим количеством аргументов, чем ожидается. Вместо этого вы получите частично примененную функцию. Если затем вы воспользуетесь частично примененной функцией в контексте, где ожидается значение, можно получить малопонятную ошибку от компилятора.
Рассмотрим безобидную с первого взгляда функцию:
// создаем функцию
let printHello() = printfn "hello"
Как думаете, что произойдет, если вызвать ее так, как показано ниже? Выведется ли "hello" на консоль? Попробуйте догадаться до выполнения. Подсказка: посмотрите на сигнатуру функции.
// вызываем ее
printHello
Вопреки ожиданиям вызова не будет. Исходная функция ожидает unit
как аргумент, который не был передан. Поэтому была получена частично примененная функция (в данном случае без аргументов).
А что насчет этого случая? Будет ли он скомпилирован?
let addXY x y =
printfn "x=%i y=%i" x
x + y
Если запустить его, компилятор пожалуется на строку с printfn
.
printfn "x=%i y=%i" x
//^^^^^^^^^^^^^^^^^^^^^
//warning FS0193: This expression is a function value, i.e. is missing
//arguments. Its type is ^a -> unit.
Если нет понимания каррирования, данное сообщение может быть очень загадочным. Дело в том, что все выражения, которые вычисляются отдельно (т.е. не используются как возвращаемое значение или привязка к чему-либо посредством "let"), должны вычисляться в unit
значение. В данном случае, оно не вычисляется в unit
значение, но вместо этого возвращает функцию. Это длинный способ сказать о том, что printfn
не хватает аргумента.
В большинстве случаев ошибки, подобные этой, случаются при взаимодействии с библиотекой из мира .NET. Например, метод Readline
класса TextReader
должен принимать unit
параметр. Об этом часто можно забыть, и не поставить скобки, в этом случае нельзя получить ошибку компилятора в момент "вызова", но она появится при попытке интерпретировать результат как строку.
let reader = new System.IO.StringReader("hello");
let line1 = reader.ReadLine // ошибка, но компилятор пропустит
printfn "The line is %s" line1 //но здесь выдаст ошибку
// ==> error FS0001: This expression was expected to have
// type string but here has type unit -> string
let line2 = reader.ReadLine() //верно
printfn "The line is %s" line2 //без ошибок компиляции
В коде выше line1
— просто указатель или делегат на метод Readline
, а не строка, как можно было бы ожидать. Использование ()
в reader.ReadLine()
действительно вызовет функцию.
Слишком много параметров
Можно получить столь же загадочные сообщения, если передать функции слишком много параметров. Несколько примеров передачи слишком большого числа параметров в printf
:
printfn "hello" 42
// ==> error FS0001: This expression was expected to have
// type 'a -> 'b but here has type unit
printfn "hello %i" 42 43
// ==> Error FS0001: Type mismatch. Expecting a 'a -> 'b -> 'c
// but given a 'a -> unit
printfn "hello %i %i" 42 43 44
// ==> Error FS0001: Type mismatch. Expecting a 'a->'b->'c->'d
// but given a 'a -> 'b -> unit
Например, в последнем случае компилятор сообщает, что ожидается форматирующая строка с тремя параметрами (сигнатура 'a -> 'b -> 'c -> 'd
имеет три параметра), но вместо этого получена строка с двумя (у сигнатуры 'a -> 'b -> unit
два параметра).
В тех случаях, где не используется printf
, передача большого количества параметров часто означает, что на определенном этапе вычислений было получено простое значение, которому пытаются передать параметр. Компилятор будет возмущаться, что простое значение не является функцией.
let add1 x = x + 1
let x = add1 2 3
// ==> error FS0003: This value is not a function
// and cannot be applied
Если разбить общий вызов на серию явных промежуточных функций, как делали ранее, можно увидеть, что именно происходит не так.
let add1 x = x + 1
let intermediateFn = add1 2 //вернет простое значение
let x = intermediateFn 3 //intermediateFn не функция!
// ==> error FS0003: This value is not a function
// and cannot be applied
Дополнительные ресурсы
Для F# существует множество самоучителей, включая материалы для тех, кто пришел с опытом C# или Java. Следующие ссылки могут быть полезными по мере того, как вы будете глубже изучать F#:
Также описаны еще несколько способов, как начать изучение F#.
И наконец, сообщество F# очень дружелюбно к начинающим. Есть очень активный чат в Slack, поддерживаемый F# Software Foundation, с комнатами для начинающих, к которым вы можете свободно присоединиться. Мы настоятельно рекомендуем вам это сделать!
Не забудьте посетить сайт русскоязычного сообщества F#! Если у вас возникнут вопросы по изучению языка, мы будем рады обсудить их в чатах:
- комната
#ru_general
в Slack-чате F# Software Foundation - чат в Telegram
- чат в Gitter
Об авторах перевода
Автор перевода @kleidemos
Перевод и редакторские правки сделаны усилиями русскоязычного сообщества F#-разработчиков. Мы также благодарим @schvepsss и @shwars за подготовку данной статьи к публикации.
Комментарии (5)
samsergey
26.11.2018 13:20Жалко, что перечислив трудности, автор оригинальной статьи не упомянул про достоинства такого странного подхода: упрощение вывода типов и редукции лямбда-выражений, бесточечная нотация, упрощение синтаксиса при передаче частично-определённых функций и т.д.
Neftedollar
26.11.2018 20:56А можно подробностей?
samsergey
27.11.2018 00:27В комментарии это будет куце. Вывод типов Хиндли-Милнера, который является базой в других системах сравнительно просто формулируется для унарной функции, а при каррировании все прочие варианты получаются индукцией. Тоже относится и к редукциям. Бета-редукцию определяем для унарной функции и автоматом получаем все мыслимые арности.
Бесточечная нотация — это возможность определения функций без явного указания аргументов, но это-то как раз широко известно и используется.
Szer
Уточнение:
Скобки необязательны потому что "стрелка" правоассоциативна.
запись
a -> b -> c -> d
эквивалентна записиa -> (b -> (c -> (d)))
а вот
((a -> b) -> c) -> d
означало бы СОВСЕМ другое.samsergey
Очень существенное замечание. Вы меня опередили :)