В предыдущей статье (Заберите свои скобки), мы попытались избавиться от скобочек с помощью нового оператора для передачи аргументов. На основе своего опыта пользования оператором, можно конечно подобрать нужный приоритет, но он все равно будет конфликтовать в случаях, которые мы не предусмотрели. Что же делать? У меня есть идея.

Вспомним оператор применения, который обозначается долларом (как поговаривают знающие люди, это единственное место, где Haskell-программисты могут его увидеть):

($) :: (a -> b) -> a -> b
f $ x = f x

Он выглядит каким-то бесполезным - без него не можем что ли функцию применить к аргументу, оставив между ними обычный пробел? Вы будете абсолютно правы, потому что он нужен нам за его определенный приоритет и ассоциативность (хорошо про ассоциативность написано в этой статье). Оператор правоассоциативный (на что указывает буква r в infixr), поэтому и скобки будут группироваться справа:

infixr 0

f $ (g $ (h $ (x)))

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

А что если мы попробуем изменить его ассоциативность на противоположную?

infixl 0

(((f) $ x) $ y) $ z

Вуаля, теперь это оператор передачи аргументов!

Но мы не можем объявить один и тот же оператор с разными ассоциативностями, поэтому пока возьмем то самое имя, которое мы придумали в прошлой статье - #.

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

($) :: (a -> b) -> a -> b
(#) :: (a -> b) -> a -> b

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

  • Для применения это работает, потому что мы по сути каждый раз применяем вновь к одному аргументу - результату предудущего применения справа:

    (f :: c -> d) $ (g :: b -> c) $ (h :: a -> b) $ (x :: a)
  • А для передачи аргументов это работает благодаря частичному применению функции (ведь функции в Haskell уже находятся в каррированной форме)!

    a -> b -> c -> d = a -> (b -> (c -> d))
    
    (f :: a -> b -> c -> d) # (x :: a) # (y :: b) # (z :: c)

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

bigger # smaller # a # b # c -- не сработает

Но можно создать еще одни оператор, у которого будет меньше приоритет, а значит, он не будет принимать во внимание операторы с крепче связанными аргументами:

infixl 5 #
infixl 4 ##

bigger ## smaller # a # b # c

Но вообще хорошо, когда операторы своими символами тебе дают подсказку об их назначении. Не секрет, что мы очень любим стрелки. Этот случай не станет исключением - будем использовать стрелки, чтобы не забыть: применения - справа, аргументы - слева.

infixr --> _
(-->) :: (a -> b) -> a -> b

infixl <-- _
(<--) :: (a -> b) -> a -> b

Мало того, что в Haskell многие авторы библиотек любят выдумывать новые операторы (прямо как мы сейчас), которые бывает тяжело запомнить; так еще и приоритеты они выбирают исходя из их собственного опыта, который может отличаться от вашего. Операторы выглядят красиво в готовом коде, но при готовке нужно постоянно думать об их приоритетах.

Думать больше не придется! У меня есть немного безумная идея: что если мы сможем определять приоритет оператора, просто посмотрев на сам оператор? Нет, мы не будет включать число (которое нельзя использовать вместе с другими символами). Просто операторы с приоритетом пониже, будут длиннее, а значит, их использование будет уместно лишь в крайней необходимости.

Раз так, то мы можем просто насоздать много таких операторов (максимум 9) с разными приоритетами и использовать их вместе. Закодируем приоритет операторов передачи аргументов с тире:

infixl 1 <---------
infixl 2 <--------
infixl 3 <-------
infixl 4 <------
infixl 5 <-----
infixl 6 <----
infixl 7 <---
infixl 8 <--

Приоритет оператора можно посчитать так: 10 - количество тире в операторе. Благодаря наглядному приоритету, мы можем применять операторы с постепенным повышением приоритета, чтобы использовать их в каких-нибудь сложных выражениях.

Было бы неплохо провернуть тоже самое и с применениями:

infixr 1 --------->
infixr 2 -------->
infixr 3 ------->
infixr 4 ------>
infixr 5 ----->
infixr 6 ---->
infixr 7 --->
infixr 8 -->

А теперь давайте применим их на практике. Пример программы: пытаемся прочесть число из строки пользовательского ввода, если не получается, возвращаем 0. Но обязательно указываем, каким образом мы получили итоговое число.

handle :: String -> String
handle input = ("Result: " ++) 
  <---- either
  <--- (++ " (default)") . show
  <--- (++ " (by user)") . show
  <--- maybe
  <-- Left 0
  <-- Right
  <-- readMaybe @Int input

main = print . handle =<< getLine

Это вполне себе работает, но читать код будет намного легче, если мы добавим отступов:

handle :: String -> String
handle input = ("Result: " ++)
    <---- either
        <--- (++ " (default)") . show
        <--- (++ " (by user)") . show
        <--- maybe
            <-- Left 0
            <-- Right
            <-- readMaybe @Int input

main = print . handle =<< getLine
$ 123
"Result: 123 (by user)"

$ hello
"Result: 0 (default)"

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

main = getLine {- 1 -} >>= print {- 3 -} . handle {- 2 -}
main = print {- 3 -} . handle {- 2 -} =<< getLine {- 1 -}

Первый вариант можно конечно переписать так, чтобы выражения выполнялись в правильно порядке, но для этого придется пожертвовать . и, как следствие, лаконичностью.

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

print {- 4 -} =<< (,,,) <$> getLine {- 1 -} <*> getLine {- 2 -} <*> getLine {- 3 -}

Я попробовал так же закодировать операторы функторов/монад/сопряжений в своей экспериментальной базовой библиотеке (да-да, все только ради того, чтобы избавиться от скобочек) и это выглядит даже читаемо.

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


  1. Kakadout
    25.01.2022 20:27
    +4

    У меня есть немного безумная идея: что если мы сможем определять приоритет оператора, просто посмотрев на сам оператор? 

    Ровно так и поступили разработчики языка OCaml: ассоциативность и приоритет определяется по первому символу оператора (захардкожено в компиляторе). Мотивация точно такая же как у Вас


  1. Shadasviar
    26.01.2022 08:30

    А мне очень не хватает возможности добавлять свои операторы с приоритетами в с++, приходиться крутиться с теми что есть.