Композиция функций

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

Скобкам — бой!

Да, я не люблю круглые скобки. Они делают код визуально избыточным, к тому же нужно следить за симметрией скобок открывающих и закрывающих. Вспомним пример из главы про кортежи:

main :: IO ()
main =
  putStrLn (patientEmail ( "63ab89d"
           ^             , "John Smith"
                         , "[email protected]"
                         , 59
                         ))
                          ^

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

Если применение функции представляет собой выражение, не можем ли мы как-нибудь компоновать их друг с другом? Конечно можем, мы уже делали это много раз, вспомните:

main :: IO ()
main = putStrLn (checkLocalhost "173.194.22.100")

Здесь компонуются две функции, putStrLn и checkLocalhost, потому что тип выражения на выходе функции checkLocalhost совпадает с типом выражения на входе функции putStrLn. Схематично это можно изобразить так:

         ┌──────────────┐            ┌────────┐
String ->│checkLocalhost│-> String ->│putStrLn│-> ...
         └──────────────┘            └────────┘

IP-адрес                    сообщение             текст
                            об этом               в нашем
                            IP-адресе             терминале

Получается эдакий конвейер: на входе строка с IP-адресом, на выходе — сообщение в нашем терминале. Существует иной способ соединения двух функций воедино.

Композиция и применение

Взгляните:

main :: IO ()
main = putStrLn . checkLocalhost $ "173.194.22.100"

Необычно? Перед нами два новых стандартных оператора, избавляющие нас от лишних скобок и делающие наш код проще. Оператор . — это оператор композиции функций (англ. function composition), а оператор $ — это оператор применения (англ. application operator). Эти операторы часто используют совместно друг с другом. И отныне мы будем использовать их чуть ли не в каждой главе.

Оператор композиции объединяет две функции воедино (или компонует их, англ. compose). Когда мы пишем:

putStrLn . checkLocalhost

происходит маленькая «магия»: две функции объединяются в новую функцию. Вспомним наш конвейер:

         ┌──────────────┐            ┌────────┐
String ->│checkLocalhost│-> String ->│putStrLn│-> ...
         └──────────────┘            └────────┘
A                           B                     C

Раз нам нужно попасть из точки A в точку C, нельзя ли сделать это сразу? Можно, и в этом заключается суть композиции: мы берём две функции и объединяем их в третью функцию. Раз checkLocalhost приводит нас из точки A в точку B, а функция putStrLn — из точки B в C, тогда композиция этих двух функций будет представлять собой функцию, приводящую нас сразу из точки A в точку C:

         ┌─────────────────────────┐
String ->│checkLocalhost + putStrLn│-> ...
         └─────────────────────────┘
A                                      C

В данном случае знак + не относится к конкретному оператору, я лишь показываю факт «объединения» двух функций в третью. Теперь-то нам понятно, почему в типе функции, в качестве разделителя, используется стрелка:

checkLocalhost :: String -> String

в нашем примере это:

checkLocalhost :: A -> B

Она показывает наше движение из точки A в точку B. Поэтому часто говорят о «функции из A в B». Так, о функции checkLocalhost можно сказать как о «функции из String в String».

А оператор применения работает ещё проще. Без него код был бы таким:

main :: IO ()
main =
  (putStrLn . checkLocalhost)  "173.194.22.100"

  объединённая функция         аргумент

Но мы ведь хотели избавиться от круглых скобок, а тут они опять. Вот для этого и нужен оператор применения. Его схема проста:

FUNCTION  $            ARGUMENT
вот эта   применяется  вот этому
функция   к            аргументу

Для нашей объединённой функции это выглядит так:

main :: IO ()
main =
  putStrLn . checkLocalhost  $            "173.194.22.100"

  объединённая функция       применяется
                             к            этому аргументу

Теперь получился настоящий конвейер: справа в него «заезжает» строка и движется «сквозь» функции, а слева «выезжает» результат:

main = putStrLn . checkLocalhost $  "173.194.22.100"

     <-         <-               <- аргумент

Чтобы было легче читать композицию, вместо оператора . мысленно подставляем фразу «применяется после»:

putStrLn  .            checkLocalhost

эта       применяется  этой
функция   после        функции

То есть композиция правоассоциативна (англ. right-associative): сначала применяется функция справа, а затем — слева.

Ещё одно замечание про оператор применения функции. Он весьма гибок, и мы можем написать так:

main = putStrLn . checkLocalhost $ "173.194.22.100"

       объединённая функция        └─ её аргумент ─┘

а можем и так:

main = putStrLn $ checkLocalhost "173.194.22.100"

       обычная    └──────── её аргумент ────────┘
       функция

Эти две формы, как вы уже поняли, эквивалентны. Я показываю это для того, чтобы вновь и вновь продемонстрировать вам, сколь гибко можно работать с данными и функциями в Haskell.

Длинные цепочки

Красота композиции в том, что компоновать мы можем сколько угодно функций:

logWarn :: String -> String
logWarn rawMessage =
  warning . correctSpaces . asciiOnly $ rawMessage

main :: IO ()
main = putStrLn $
  logWarn "Province   'Gia Viễn' isn't on the map! "

Функция logWarn готовит переданную ей строку для записи в журнал. Функция asciiOnly готовит строку к выводу в нелокализованном терминале (да, в 2016 году такие всё ещё имеются), функция correctSpaces убирает дублирующиеся пробелы, а функция warning делает строку предупреждением (например, добавляет строку "WARNING: " в начало сообщения). При запуске этой программы мы увидим:

WARNING: Province 'Gia Vi?n' isn't on the map!

Здесь мы объединили в «функциональный конвейер» уже три функции, безо всяких скобок. Вот как это получилось:

warning . correctSpaces . asciiOnly $ rawMessage

                        ^
          └── первая композиция ──┘

        ^
└────── вторая композиция ────────┘
                                      аргумент

Первая композиция объединяет две простые функции, correctSpaces и asciiOnly. Вторая объединяет тоже две функции, простую warning и объединённую, являющуюся результатом первой композиции.

Более того, определение функции logWarn можно сделать ещё более простым:

logWarn :: String -> String
logWarn = warning . correctSpaces . asciiOnly

Погодите, но где же имя аргумента? А его больше нет, оно нам не нужно. Ведь мы знаем, что применение функции можно легко заменить внутренним выражением функции. А раз так, выражение logWarn может быть заменено на выражение warning . correctSpaces . asciiOnly. Сделаем же это:

  logWarn "Province   'Gia Viễn' isn't on the map! "

= (warning
   . correctSpaces
   . asciiOnly) "Province   'Gia Viễn' isn't on the map! "

=   warning
  . correctSpaces
  . asciiOnly $ "Province   'Gia Viễn' isn't on the map! "

И всё работает! В мире Haskell принято именно так: если что-то может быть упрощено — мы это упрощаем.

Справедливости ради следует заметить, что не все Haskell-разработчики любят избавляться от круглых скобок, некоторые предпочитают использовать именно их. Что ж, это лишь вопрос стиля и привычек.

Как работает композиция

Если вдруг вы подумали, что оператор композиции уникален и встроен в Haskell — спешу вас разочаровать. Никакой магии, всё предельно просто. Этот стандартный оператор определён так же, как и любая другая функция. Вот его определение:

(.) f g = \x -> f (g x)

Опа! Да тут и вправду нет ничего особенного. Оператор композиции применяется к двум функциям. Стоп, скажете вы, как это? Применяется к функциям? Да, именно так. Ведь мы уже выяснили, что функциями можно оперировать как данными. А раз так, что нам мешает передать функцию в качестве аргумента другой функции? Что нам мешает вернуть функцию из другой функции? Ничего.

Оператор композиции получает на вход две функции, а потом всего лишь даёт нам ЛФ, внутри которой происходит обыкновенный последовательный вызов этих двух функций через скобки. И никакой магии:

(.)    f        g        =  \x -> f (g x)

берём  эту      и эту       и возвращаем
       функцию  функцию     ЛФ, внутри
                            которой
                            вызываем их

Подставим наши функции:

(.) putStrLn checkLocalhost = \x -> putStrLn (checkLocalhost x)

Вот так и происходит «объединение» двух функций: мы просто возвращаем ЛФ от одного аргумента, внутри которой правоассоциативно вызываем обе функции. А аргументом в данном случае является та самая строка с IP-адресом:

(\x -> putStrLn (checkLocalhost x)) "173.194.22.100" =

putStrLn (checkLocalhost "173.194.22.100"))

Но если я вас ещё не убедил, давайте определим собственный оператор композиции функций! Помните, я говорил вам, что ASCII-символы можно гибко объединять в операторы? Давайте возьмём плюс со стрелками, он чем-то похож на объединение. Пишем:

-- Наш собственный оператор композиции.
(<+>) f g = \x -> f (g x)

...

main :: IO ()
main = putStrLn <+> checkLocalhost $ "173.194.22.100"

Выглядит необычно, но работать будет так, как и ожидается: мы определили собственный оператор <+> с тем же функционалом, что и стандартный оператор композиции. Поэтому можно написать ещё проще:

(<+>) f g = f . g

Мы говорим: «Пусть оператор <+> будет эквивалентен стандартному оператору композиции функций.». И так оно и будет. А можно — не поверите — ещё проще:

f <+> g = f . g

И это будет работать! Раз оператор предназначен для инфиксного применения, то мы, определяя его, можно сразу указать его в инфиксной форме:

f <+> g    =      f . g

           пусть

такое
выражение
           будет
           равно
                  такому
                  выражению

Теперь мы видим, что в композиции функций нет ничего сверхъестественного. Эту мысль я подчёркиваю на протяжении всей книги: в Haskell нет никакой магии, он логичен и последователен.