Почему конструкция if-then-else малополезна в языках программирования
Введение: 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