Несколько дней назад я наткнулся на вопрос StackOverflow по этому поводу и, когда писал свой ответ, заметил, что написал приличный кусок текста. Это хороший показатель для сообщения в блоге. Да и кстати, если вы не знакомы с какой-либо из моих предыдущих работ, этот пост будет в основном вращаться вокруг Scala (хотя представленные концепции являются довольно общими для функционального программирования).

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

Если он пуст, он все еще существует

Допустим, у нас есть два метода с творческими названиями:

def methodA(s: String) = ???
def methodB(f: () => String) = ???

Мы хотим видеть, какие вызовы этих методов проходят, а какие терпят неудачу, когда мы вводим в них разные значения параметров.

Начнем с очень простого метода f:

def f = "foo"
methodA(f)
methodB(f) // error!
methodA(f()) // error!
methodB(f()) // error!

Поскольку methodA () принимает String, у него не возникнет проблем с принятием f в качестве параметра. Наш f можно рассматривать не как метод, а как значение, оценка которого выполняется каждый раз при обращении к нему. Если бы вместо этого у нас был val, он бы оценивался только один раз (а lazy val отложил бы эту оценку до точки использования). Это то, чему вас учат в каждой книге по Scala. Если бы f имел побочный эффект, в нашем примере этот эффект был бы выполнен дважды, тогда как использование f в качестве val приведет к тому, что побочный эффект будет выполнен только один раз - при точка определения f. Хорошо, здесь ничего нового.

К методу B. Поскольку он ожидает функцию (хотя и странную, от «ничего» до String), он не может терпеть передачу нашей чистой String. Сообщение компилятора довольно ясное: «Несоответствие типов, ожидаемое () ⇒ String, фактическое: String».

Два других вызова совершенно неверны. Когда мы говорим f (), компилятор думает, что мы пытаемся получить доступ к определенному символу внутри f (потому что f на самом деле является строкой « foo ») и сообщает нам, что методы A и B принимают строку, а не символ. И даже если бы они брали char, нам нужно было бы предоставить индекс char, который мы пытаемся получить из f.

А теперь давайте сделаем вещи более интересными. Давайте добавим, казалось бы, тривиальное и несущественное дополнение к нашему методу f (мы можем назвать новую версию f2):

def f2() = "foo"

Мы добавили пустую скобку. Какие последствия это имеет? Что ж, во-первых, мы можем вызвать наш метод старомодным способом - написав f2 (). Мы не могли сделать это с помощью f, потому что наша f обрабатывалась как строковое значение, но сейчас все немного по-другому. Выражение f2 () ведет себя именно так, как и следовало ожидать - оно вызывает метод f2 и приводит к строке «foo».

Что, если мы напишем просто f2? Погодите, давайте попробуем вызвать наши методы оценки и посмотрим, что произойдет:

methodA(f2())
methodB(f2()) // error!
methodA(f2)
methodB(f2)

Хорошо, первые два вызова должны быть довольно очевидными. Вызов f2 () приводит к появлению строки, которая подходит для метода A, но не подходит для метода B, который принимает функцию.

Однако два других вызова оба работали нормально! Если это вас не удивляет, то или вы уже знакомы с этим механизмом в Scala, или вы не уделяете достаточно внимания :) Я имею в виду, что эти два метода имеют параметры разных типов, и все же они оба могут принимать одно и то же ценить. Является ли наше значение f2 одновременно строкой и функцией? Конечно, нет. На самом деле это ни то, ни другое. Это вообще не ценность; это метод, который возвращает строку. Теоретически ни один из этих двух вызовов не должен был быть успешным. Здесь в игру вступает компилятор Scala.

Первый вызов, methodA (f2), работает, потому что компилятор предполагает, что когда мы говорим f2, то, что мы действительно хотели, было f2 (). Выдает предупреждение метод с пустыми скобками, доступный как без параметров. Наличие метода f и доступ к нему как f () приводит к совершенно другому результату (вызов apply () в строке, что приводит к в Char, как только мы предоставим ему индекс), то есть наоборот - метод f2 (), доступный как f2 - приводит к тому же результату, что и f2 (). Это определено в Руководстве по стилю, но на самом деле не поощряется, и по уважительной причине - это может сбить с толку людей. Чтобы избавить себя от путаницы, я советую всегда вызывать метод в том виде, в котором он определен. Если без параметров, вызывайте его без скобок. Если они заключены в пустые скобки, вызовите их с пустыми скобками. Бывший обычно используется для методов получения полей класса, значение которых необходимо каждый раз пересчитывать (например, currentAccountBalance), а последний - для функций с побочными эффектами (например, println () ).

Эта-конверсии

Это был первый вызов methodA (f2). Теперь о втором, methodB (f2). Здесь все становится немного интереснее. То, что компилятор Scala только что выполнил для нас, называется eta-extension. Как вы скоро увидите, есть два направления для этой «операции eta». В литературе направление эта-расширения также иногда называют эта-абстракцией, противоположное направление называется эта-редукцией, и они оба упоминаются в общий термин эта-конверсия.

Идея эта-расширения довольно проста. Имея функцию f (x), мы обычно называем саму функцию f. Например, представьте, что нам нужно передать его другой функции; мы бы просто пропустили f. Теперь, если вместо передачи f мы передадим x ⇒ f (x), ничего не изменится, не так ли? Возьмем, к примеру, функцию квадрата. Мы даем ему 4, он возвращает 16. Эта функция полностью аналогична функции x ⇒ sqr (x). Мы даем ей 4, она возвращает 16. Мы просто «обернули» ”Наша функция sqr с другим слоем, что приводит к другой функции. Мы можем делать это бесконечно:

val sqr = (x: Int) => x * x
val sqr2 = (x: Int) => sqr(x)
val sqr3 = (x: Int) => sqr2(x)
val sqr3_expandedVersion = (x: Int) => ((x: Int) => sqr(x))(x)

println(sqr(4))
println(sqr2(4))
println(sqr3(4))
println(sqr3_expandedVersion(4))

Все они печатают 16. Эта-редукция просто идет в другом направлении - от sqr3 к sqr. Кстати, не обращайте внимания на «расширенную версию» в коде; это выглядит немного сложным, но если вы присмотритесь, то увидите, что он просто вводит определение sqr2 в sqr3, так что оно зависит только от sqr. Это просто показывает, как функция растет все больше и больше, но сохраняет ту же функциональность.

Эта-расширение - это то, что компилятор делает «за кулисами», когда замечает, что вам нужна функция, но вам предоставляется метод. Вот упрощенная версия того, что он делает:

// given a method:
def someMethod() = ???
// it's easy to convert it to a function using eta-expansion:
val someFunction = () => someMethod()
// if there are parameters, it's still the same principle:
def someMethod(x: Int, y: String) = ???
val someFunction = (x: Int, y: String) => someMethod(x, y)

Просто, но мощно.

Частично применяемые функции

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

def f2() = "foo"
val f2fun = f2 // f2fun is a string, not a function!

Что случилось? Если вы помните то глупое и сбивающее с толку автоматическое преобразование из предыдущего, вы вспомните, что разрешено вызывать метод без параметров (то есть метод в пустых скобках) без скобок. Хорошо, мы договорились, что не будем делать это намеренно в нашем коде, чтобы избежать путаницы, но что нам делать с существующим соглашением в компиляторе, который интерпретирует наш f2 как f2 () ? Как превратить метод f2 () в значение функции f2?

Что ж, мы можем сделать две вещи:

  • явно объявить тип значения функцией
  • рассматривать метод как частично применяемую функцию
def f2() = "foo"
val f2fun1 = f2               // f2fun is a string, not a function
val f2fun2: () => String = f2 // however, this works!
val f2fun3 = f2 _             // this too!

Явное объявление типа значения - хороший способ. Вы говорите компилятору «вырезать его своим автоматическим вызовом без параметров mumbo-jumbo и превратить этот метод в функцию».

Но рассмотрение метода как частично применяемой функции, на мой взгляд, еще более элегантное решение. Если вы не знакомы с частично применяемыми функциями (и близкой техникой, каррированием), то было бы разумно проконсультироваться с некоторыми онлайн источниками.

Короче говоря, частичное применение функции - это способ… ну, частичное применение функции :) Если серьезно, если у нас есть функция, которая принимает три параметра, x, y и z, мы можем применить только первый, и в результате получить функцию двух параметров. Или мы можем применить первые два и получить функцию только от одного параметра. Например, имея функцию, которая берет два целых числа и складывает их, мы можем применить только первое, например 42, и в результате мы получим функцию, которая прибавляет число ввода к 42:

val add: (Int, Int) => Int = (a: Int, b: Int) => a + b
val add42: Int => Int = add(42, _)
println(add42(8)) // 50

Мы «исправили» первый параметр и разрешили пользователю указывать только второй. Обратите внимание, что если вы явно не определяете add42 как тип Int = ›Int, вам нужно будет явно определить тип неиспользуемого параметра: add (42, _: Int) .

Каррирование - аналогичный принцип. Основная идея каррирования - разделить функцию n параметров на n функций одного параметра. Так обрабатываются в Haskell все функции с двумя или более параметрами; у вас не может быть функции более чем одного параметра. Если вам нужна функция, скажем, трех параметров, вы должны определить функцию одного параметра, возвращающую функцию одного параметра, которая возвращает функцию одного параметра. Функция f (a, b, c) в Haskell должна быть определена как функция, возвращающая функцию, которая возвращает функцию, и вызывается как f (a) (b) (c).

Хорошо, небольшое отступление, вернемся к Scala. Каррирование работает точно так же в Scala, но не является обязательным. Обратите внимание: если мы карризуем функцию, в подчеркивании нет необходимости; с частично примененными функциями это необходимо, потому что в противном случае компилятор будет справедливо жаловаться на то, что add () принимает два параметра, но при каррировании мы просто предоставляем только первый параметр, который полностью действителен:

val add: Int => Int => Int = (a: Int) => (b: Int) => a + b
val add42: Int => Int = add(42)
println(add42(8)) // 50
val add42and8: Int = add(42)(8)
println(add42and8) // 50

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

def foo() = "foo"
val foo2 = foo _
val foo3: () => String = foo

def bar = "bar"
val bar2 = bar _
val bar3: () => String = bar // error

Не расстраивайтесь; нет необходимости помнить об этих особенностях компилятора. Важные моменты, которые, я надеюсь, вы вынесете из этой статьи, перечислены ниже.

Резюме

Хорошо, вот краткое изложение информации, представленной в этой статье:

  • Методы не то же самое, что функции: функции - это значения, подобные целым числам, строкам или другим объектам, и их можно передавать, возвращать, хранить внутри коллекций и т. д. с другой стороны - это не ценности; они не имеют типа и не могут существовать сами по себе (они являются атрибутом структуры, в которой они определены, например, класс, объект или признак). Обратите внимание, что методы должны быть определены с помощью ключевого слова def, а функции могут быть определены как любое другое значение - с помощью def, val или lazy val, и в этом случае ключевое слово определяет, будет ли значение оцениваться каждый раз. , только один раз или только один раз, но в момент использования.
  • Существует разница между методами без скобок и методами с пустыми скобками: первые в основном являются значениями, но переоцениваются при каждом доступе, а вторые - это методы, которые мы знаем. . Я настоятельно рекомендую вызывать их так, как они определены (если в определении есть пустые скобки, поместите их также в вызов; таким образом вам не нужно помнить, что произойдет, если f вызывается как f () и наоборот).
  • Eta-extension - это простой метод для переноса функций на дополнительный уровень с сохранением идентичной функциональности (например, от sqr до x ⇒ sqr (x) ), и это выполняется компилятором для создания функций из методов.
  • Когда автоматическое расширение eta не удается, вы можете использовать два метода, чтобы скрыть метод в функции: явно объявить тип значения как функцию или обработать метод как частично применяемую функцию, поставив подчеркивание после имени метода, что означает, что все его параметры передаются в параметры функции.

На этом пока все. Как обычно, жду ваших комментариев и отзывов на [email protected]. Также не стесняйтесь писать мне в Twitter.

Ваше здоровье!