Введение: if-then-else

В большинстве языков программирования имеется условная конструкция IF ... THEN ... ELSE .... Она знакома любому школьнику и представляет собой развилку на пути из некоторого абстрактного типа a в тип b. Решение о том, взять первый путь или второй, принимается с помощью предиката, то есть путя из a в булев тип (истина/ложь). Вместе с обоими вариантами b (т.н. ветками условия), предикат также идёт параметром к конструкции IF ... THEN ... ELSE ...:

$$ ite(pred, b1, b2, a) = \begin{cases} b1, &\quad pred(a) = true \
b2, &\quad pred(a) = false \end{cases} $$

Паттерн-матчинг

В правой части формулы выше выполняется паттерн-матчинг против конструкторов типа pred: собран ли результат pred(a) конструктором true или конструктором false?

Haskell предоставляет простой синтаксис для паттерн-матчинга конструкторов и, как мы скоро увидим, данный синтаксис – это единственная встроенная в язык конструкция ветвления. Условная конструкция case ... of ... является более общей, чем конструкция if ... then ... else ... и поэтому используется повсеместно. А вот if, по моим наблюдениям, программисты применяют неохотно.

Формула выше в переводе на язык Haskell выглядит следующим образом:

ifThenElse :: (a -> Bool) -> b -> b -> (a -> b)
ifThenElse pred b1 b2 a =
  case pred a of
    True -> b1
    False -> b2

Из сигнатуры видно, что функция возвращает стрелку (путь) a -> b. Функции даётся предикат над a и два варианта типа b (первый вариант соответствует истине предиката).

Аналогичная нашей функции ite конструкция if ... then ... else ... уже присутствует в Haskell, но является лишь синонимом (синтаксическим сахаром) соответствующей клаузы case .. of:

ifThenElse' pred t f a = if pred a then t else f

Вот так мы используем условную конструкцию:

> ifThenElse (> 10) "big" "small" 6
"small"
> ifThenElse (> 10) "big" "small" 26
"big"
> :t ifThenElse (> 10) "big" "small"
ifThenElse (> 10) "big" "small" :: (Ord a, Num a) => a -> [Char]

На последней строчке мы выясняем конкретный тип “заполненной” конструкции: это путь из любого упорядоченного числового типа в список символов (т.е. в строку).

Изоморфные Bool типы

Вообще же, вместо Bool мы можем использовать для if ... then ... else ... любой изоморфный тип. Пускай имеется следующий тип-сумма:

data B = Constructor1 | Constructor2

Нетрудно заметить “похожую” на Bool форму: ровно два конструктора.

Определим тайпкласс изоморфных структур и составим изоморфизм между типами B и Bool:

class Isomorphic a b where
  forward :: a -> b
  inverse :: b -> a


toBool :: B -> Bool
toBool Constructor1 = True
toBool Constructor2 = False

fromBool :: Bool -> B
fromBool True = Constructor1
fromBool False = Constructor2

instance Isomorphic B Bool where
  forward = toBool
  inverse = fromBool

Теперь мы можем использовать нуше конструкцию if ... then ... else ... с любым похожим на Bool типом c:

ifThenElse :: Isomorphic c Bool => (a -> c) -> b -> b -> (a -> b)
ifThenElse pred t f a =
  case (forward . pred) a of
    True -> t
    False -> f

Ограниченность if-then-else

Обобщая далее на три и более конструкторов, становится ясно, что куда удобнее читать код с одной единственной наиболее общей конструкцией case .. of ..., чем с целым деревом вложенных друг в друга конструкций if ... then ... else ... (если A = X то B1 иначе если A = Y то B2 иначе B3 и т.п.).

Как мы уже выяснили, конструкция if ... then ... else ... сводится к паттерн-матчингу предиката (типа Bool), а не центрального типа (скажем, a), который фактически и играет главную роль в процессе выбора пути из a. Другими словами, каждое употребление выражения if ... then ... else ... с небулевым центральным типом привносит семантически необоснованную зависимость одного типа от другого.

Представим, что у нас есть следующий тип:

data Direction = Left | Right

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

f :: Direction -> String
f d = "Going " ++ if d == Left then "left" else "right"

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

Универсальность case … of …

Окей, мы понимаем и хотим использовать в программе данную идею: два направления равны, когда они собраны одинаковыми конструкторами. Однако причём здесь Bool и предикаты??? Ведь мы не сравниваем два направления в функции f, а соотносим каждому направлению строку! И по смыслу не претендуем ни на что более. Используя общий паттерн-матчинг case ... of ... мы можем запросто выполнить задачу напрямую без всяких дополнительных зависимостей:

f' :: Direction -> String
f' d = "Going " ++ case d of
                     Left -> "left"
                     Right -> "right"

Или даже просто дав компилятору несколько уравнений, по одному на каждый конструктор:

f'' :: Direction -> String
f'' Left = "Going left"
f'' Right = "Going right"

Становится видно, что использование if ... then ... else ... в нашем отдельном случае фактически усложняет процесс: с данной конструкцией мы можем паттерн-матчинг только по True или False, а значит нам необходимо установить некоторую минимальную (мысленную) связь типа Direction с типом Bool. Представим, что if ... then ... else ... - это единственная конструкция ветвления в нашем языке. Нам необходимо каким-то образом связывать с булевым типом все прочие типы нашей программы, что не разумно.

Куда круче иметь в своём арсенале лишь case .. of ... и самому написать упрощённую конструкцию ветвления, если таковая вообще потребуется (синтаксис вполне сравним с if ... then ... else ... по читабельности: f' d = "Going " ++ case d of Left -> "left"; Right -> "right").

Мне кажется, куда проще принимать за отправную точку идею паттерн-матчинга, чем идею эквивалентности предикатов.

Комбинаторы when и unless

Думая в ключе функций высшего порядка, мы запросто можем написать дополнительные вырожденные комбинаторы наподобие if, делающие код программы на Haskell ещё более читабельным.

Два хороших примера - это when и unless из Control.Monad, встраивающие в аппликативный контекст свой первый либо второй аргумент:

when :: Applicative f => Bool -> f () -> f ()
unless :: Applicative f => Bool -> f () -> f ()

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

debug = True

m :: Int -> IO ()
m i = do
  when debug $ putStrLn ("Debugging: " ++ show i)
  unless (i > 5) $ m (succ i)

Запуск монадического действия (любая монада - это аппликативный функтор):

> m 3
Debugging: 3
Debugging: 4
Debugging: 5
Debugging: 6