После небольшого экскурса в базовые типы, мы можем снова вернуться к функциям. В частности, к ранее упомянутой загадке: если математическая функция может принимать только один параметр, то как в 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                 // Возвращаем подфункцию

Рассмотрим этот процесс подробнее:


  1. Объявляется функция с названием "printTwoParameters", но принимающая только один параметр: "x".
  2. Внутри неё создаётся локальная функция, которая также принимает только один параметр: "y". Заметим, что локальная функция использует параметр "x", но x не передается в нее как аргумент. "x" находится в такой области видимости, что вложенная функция может видеть его и использовать без необходимости в его передаче.
  3. Наконец, возвращается только что созданная локальная функция.
  4. Возвращенная функция затем применяется к аргументу "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#! Если у вас возникнут вопросы по изучению языка, мы будем рады обсудить их в чатах:



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


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

Комментарии (5)


  1. Szer
    26.11.2018 11:35

    Уточнение:


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

    Скобки необязательны потому что "стрелка" правоассоциативна.


    запись a -> b -> c -> d эквивалентна записи a -> (b -> (c -> (d)))


    а вот ((a -> b) -> c) -> d означало бы СОВСЕМ другое.


    1. samsergey
      26.11.2018 13:12

      Очень существенное замечание. Вы меня опередили :)


  1. samsergey
    26.11.2018 13:20

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


    1. Neftedollar
      26.11.2018 20:56

      А можно подробностей?


      1. samsergey
        27.11.2018 00:27

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