В предыдущей статье (Заберите свои скобки), мы попытались избавиться от скобочек с помощью нового оператора для передачи аргументов. На основе своего опыта пользования оператором, можно конечно подобрать нужный приоритет, но он все равно будет конфликтовать в случаях, которые мы не предусмотрели. Что же делать? У меня есть идея.
Вспомним оператор применения, который обозначается долларом (как поговаривают знающие люди, это единственное место, где 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)
Shadasviar
26.01.2022 08:30А мне очень не хватает возможности добавлять свои операторы с приоритетами в с++, приходиться крутиться с теми что есть.
Kakadout
Ровно так и поступили разработчики языка OCaml: ассоциативность и приоритет определяется по первому символу оператора (захардкожено в компиляторе). Мотивация точно такая же как у Вас