Перейти к основному содержимому

Управляющие конструкции и операторы Haskell

Разработчику Архитектору

Перед чтением: Операторы — общие понятия оператора, операнда, приоритетов и типов операций без привязки к языку.

Сначала: Циклы в коде — общая идея повторений, виды циклов и типичные ошибки без привязки к синтаксису языка.


Управляющие конструкции и операторы Haskell

Выражения как основа управления

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

Короткий пример "всё — выражение":

label :: Int -> String
label n =
let doubled = n * 2
prefix = if doubled > 10 then "большое" else "малое"
in prefix ++ ": " ++ show doubled

Разбор:

  • let ... in ... вводит локальные имена внутри выражения.
  • if ... then ... else ... возвращает строку и участвует в вычислении prefix.
  • show doubled преобразует число в строку для конкатенации.
  • Вся функция label — одно выражение без отдельного return.

Быстрая ориентация перед примерами

Если вы переходите с императивного языка, держите три опоры:

  • if и case выбирают значение, а не "ветку кода ради ветки";
  • повторение почти всегда выражают через рекурсию, map, filter, fold;
  • в IO последовательность действий записывается через do.

С такой рамкой ниже проще читать синтаксис и не путать его с привычными for/while.


Условные выражения — if-then-else

Конструкция if-then-else в Haskell — это выражение, а не оператор. Она всегда требует наличия ветки else, поскольку каждое выражение должно иметь значение при любых условиях. Синтаксис:

if условие then выражение1 else выражение2

Разбор:

  • if в Haskell — это выражение, поэтому оно всегда возвращает значение.
  • условие обязано иметь тип Bool; неявные преобразования к булевому типу не применяются.
  • then и else должны возвращать совместимые типы, иначе компилятор отклонит выражение.
  • Такой формат делает ветвление частью вычисления результата, а не отдельной командой.

Здесь условие — булево значение типа Bool. Если условие истинно, результатом всего выражения становится выражение1; в противном случае — выражение2. Обе ветви обязаны иметь одинаковый тип, так как Haskell строго типизирован, и тип результата должен быть однозначно определён до выполнения.

Пример:

sign x = if x > 0 then 1 else if x < 0 then -1 else 0

Разбор:

  • Функция sign возвращает 1, -1 или 0 в зависимости от знака аргумента.
  • Вложенный if реализует последовательную проверку условий без изменения состояния.
  • x > 0 и x < 0 — булевы выражения, от которых напрямую зависит вычисляемое значение.
  • Это компактный пример "if как выражение": вся строка — единый вычислительный результат.

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


Сопоставление с образцом (Pattern Matching)

Сопоставление с образцом — один из центральных механизмов управления в Haskell. Он позволяет разбирать структуры данных по их форме и связывать компоненты с переменными. Этот процесс происходит на уровне определения функций, в выражениях case, а также в лямбда-выражениях и привязках let/where.

При определении функции можно указать несколько уравнений с разными образцами. Haskell проверяет их сверху вниз и выбирает первое совпадающее. Например:

safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (x:_) = Just x

isZero 0 = True
isZero _ = False

Разбор:

  • safeHead принимает список любого типа [a] и безопасно возвращает первый элемент через Maybe a.
  • Образец [] покрывает пустой список и возвращает Nothing вместо runtime-ошибки.
  • Образец (x:_) декомпозирует непустой список на голову x и игнорируемый хвост _.
  • isZero показывает сопоставление по конкретному литералу 0 и универсальному шаблону _.
  • Порядок уравнений критичен: более узкие случаи ставят выше универсальных.

Образец 0 соответствует только нулю, а _ — универсальный образец, совпадающий с любым значением. Порядок уравнений важен: более специфичные образцы должны идти перед общими.

Сопоставление работает с любыми алгебраическими типами данных. Для списков доступны образцы вида [] (пустой список) и (x:xs) (голова и хвост). Для кортежей — (a, b), для пользовательских типов — конструкторы с аргументами.


Коллекции — неизменяемый список [a]

Стандартный список Haskell неизменяем. "Добавление" строит новый список:

ДействиеСинтаксис
Добавить в началоvalue : list
Объединитьlist1 ++ list2
Прочитать головуhead list (пустой список — ошибка)
Доступ по индексуlist !! index (вне диапазона — ошибка)
Безопасный вариантdrop index list + проверка через listToMaybe

Для изменяемых структур в императивном стиле: Data.Vector.Mutable, IORef, STArray — отдельная тема.

Data.Mapinsert key val map, lookup key map, delete key map.

Пример с пользовательским типом:

data Shape = Circle Float | Rectangle Float Float
deriving (Eq, Show)

area :: Shape -> Float
area (Circle r) = pi * r * r
area (Rectangle w h) = w * h

Разбор:

  • Shape — сумма вариантов: либо Circle, либо Rectangle с разной структурой данных.
  • area :: Shape -> Float фиксирует контракт вычисления площади для любой фигуры этого типа.
  • Pattern matching по конструкторам сразу извлекает параметры — r, w, h.
  • Формулы pi * r * r и w * h выбираются типобезопасно без ручных флагов типа фигуры.

Здесь функция area использует сопоставление с образцом, чтобы определить, какой конструктор использовался при создании значения, и применить соответствующую формулу.


Охранные выражения (Guards)

Охранные выражения расширяют возможности сопоставления с образцом, позволяя добавлять булевы условия к каждому уравнению функции. Они записываются после вертикальной черты | и проверяются последовательно. Первое охранное выражение, вычисляющееся в True, определяет, какое тело функции будет выполнено.

Синтаксис:

функция аргументы
| условие1 = результат1
| условие2 = результат2
...
| otherwise = результатN

Разбор:

  • Вертикальная черта | вводит guard-условия, проверяемые сверху вниз.
  • Первое условие, давшее True, определяет итоговое значение функции.
  • otherwise — обычный синоним True, его ставят последним как гарантированную ветку.
  • Такой формат удобен, когда разбор зависит от диапазонов и логических предикатов.

Ключевое слово otherwise — это просто синоним True, гарантирующий, что хотя бы одно условие выполнится.

Пример:

grade :: Int -> String
grade score
| score >= 90 = "Отлично"
| score >= 75 = "Хорошо"
| score >= 60 = "Удовлетворительно"
| otherwise = "Неудовлетворительно"

Разбор:

  • Сигнатура Int -> String показывает отображение числового балла в текстовую категорию.
  • Guards читаются как каскад правил: от более строгого порога к менее строгому.
  • Поскольку проверки идут сверху вниз, score >= 90 должно стоять раньше score >= 75.
  • otherwise закрывает оставшийся диапазон и делает определение тотальным.

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


let, where и генераторы списков

Локальные имена задают через let (внутри выражения или do) и where (привязка к уравнению функции):

roots :: (Floating a, Ord a) => a -> a -> a -> Maybe a
roots a b c
| d < 0 = Nothing
| otherwise = Just (sqrt d)
where
d = b * b - 4 * a * c -- локальное имя только для этого уравнения

Разбор:

  • Ограничения (Floating a, Ord a) требуют от типа a операций сравнения и корня.
  • d вычисляет дискриминант квадратного уравнения и объявлен локально через where.
  • Если d < 0, функция возвращает Nothing, явно моделируя отсутствие вещественного корня.
  • Иначе возвращается Just (sqrt d), то есть успешное значение в контексте Maybe.
  • Локальное имя d улучшает читаемость и исключает повтор вычисления в ветках.

Генераторы списков — декларативная альтернатива циклу for:

evensUpTo n = [x | x <- [1 .. n], even x]
-- evensUpTo 10 == [2,4,6,8,10]

Разбор:

  • List comprehension строит список по правилу "взять x из диапазона и оставить, если even x".
  • [1 .. n] задаёт источник значений от 1 до n включительно.
  • Предикат even фильтрует только чётные элементы.
  • Результат — новый список без мутации и без ручного счётчика цикла.

В Haskell нет изменяемых счётчиков — повторение выражают рекурсией, map/filter или, для IO, mapM_ и do.


Конструкция case

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

case выражение of
образец1 -> результат1
образец2 -> результат2
...

Разбор:

  • case ... of выполняет разбор значения по шаблонам в любом месте выражения.
  • Каждая ветка образец -> результат связывает форму входа с вычисляемым результатом.
  • Сопоставление идёт сверху вниз до первого совпадения.
  • Все ветки должны приводиться к совместимому типу итогового выражения.

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

Пример:

describeList xs = "Список " ++ case xs of
[] -> "пуст"
[x] -> "содержит один элемент"
(x:_) -> "начинается с " ++ show x

Разбор:

  • Выражение case встроено прямо в конкатенацию строки, что подчёркивает "всё есть выражение".
  • Шаблон [] обрабатывает пустой список, [x] — список из одного элемента.
  • (x:_) ловит любой непустой список и извлекает первый элемент x.
  • show x преобразует элемент в строку, чтобы корректно склеить его через ++.

Выражение case является основой для многих других конструкций. Компилятор часто преобразует if-then-else, охранные выражения и даже определения функций с несколькими уравнениями в древовидные структуры на основе case.


Ленивые вычисления и управление потоком

Haskell использует ленивую стратегию вычислений: выражения вычисляются только тогда, когда их значение действительно требуется. Это влияет на управляющие конструкции, делая их "ленивыми" по своей природе. Например, в выражении if condition then expensive else cheap функция expensive не будет вызвана, если condition ложно, даже если она содержит бесконечный цикл или дорогостоящую операцию.

Ленивость позволяет строить потенциально бесконечные структуры данных, такие как списки, и работать с ними эффективно. Управляющие конструкции автоматически учитывают эту особенность, выбирая только необходимые части данных.

Пример:

ones = 1 : ones
firstFive = take 5 ones -- [1,1,1,1,1]

Разбор:

  • ones задаёт бесконечный список единиц через самоссылку.
  • Оператор : добавляет голову списка, не вычисляя весь хвост заранее.
  • take 5 ones запрашивает только первые 5 значений, что возможно благодаря ленивым вычислениям.
  • Этот пример показывает, как Haskell работает с бесконечными структурами безопасно.

Здесь ones — бесконечный список, но take запрашивает только первые пять элементов, и остальная часть списка не вычисляется.

Интерактивное демо — пошаговый цикл на примере JavaScript (for, while). В Haskell нет классических циклов — повторение через рекурсию и do; демо показывает императивную модель итераций. Обобщённо: циклы в коде.

Play ITЗагрузка интерактивного демо…


Последовательность действий — do-нотация

В контексте монад, особенно IO, Haskell предоставляет do-нотацию для последовательного выполнения действий. Хотя язык остаётся чистым, do-блоки имитируют императивный стиль, связывая результаты предыдущих шагов с последующими.

Синтаксис:

do
действие1
x <- действие2
действие3
return результат

Разбор:

  • do-блок описывает последовательность монадических действий в читаемой линейной форме.
  • Строка x <- действие2 извлекает значение из контекста и связывает его с именем x.
  • Строки без <- выполняются ради эффекта или промежуточной логики без именования результата.
  • return поднимает чистое значение обратно в тот же монадический контекст.

Каждая строка представляет собой монадическое действие. Оператор <- извлекает значение из монадического контекста и связывает его с переменной. Конструкция return помещает значение обратно в монаду, завершая последовательность.

Пример:

greet :: IO ()
greet = do
putStrLn "Как вас зовут?"
name <- getLine
putStrLn ("Привет, " ++ name ++ "!")

Разбор:

  • greet :: IO () показывает, что функция выполняет эффекты ввода-вывода и не возвращает полезное доменное значение.
  • putStrLn печатает строку в консоль, создавая действие внутри IO.
  • name <- getLine читает ввод пользователя и связывает его с переменной name.
  • ("Привет, " ++ name ++ "!") собирает итоговую строку через конкатенацию.
  • Вся логика остаётся в IO, изолируя побочные эффекты от чистого кода.

Под капотом do-нотация преобразуется в цепочку вызовов оператора >>= (bind), что сохраняет чистоту языка и соответствие математической модели монад.


Логические и сравнительные операторы

Haskell предоставляет стандартный набор операторов для логических и сравнительных операций. Все они являются функциями в инфиксной записи:

  • && — логическое И
  • || — логическое ИЛИ
  • not — логическое НЕ (префиксная функция)
  • == — равенство
  • /= — неравенство
  • <, <=, >, >= — числовые сравнения

Эти операторы часто используются в условиях if, охранных выражениях и фильтрах. Благодаря ленивости, && и || вычисляют правый операнд только при необходимости, что позволяет избежать лишних вычислений или ошибок.

Пример:

safeDivide :: Double -> Double -> Maybe Double
safeDivide x y
| y /= 0 = Just (x / y)
| otherwise = Nothing

Разбор:

  • Сигнатура сообщает о потенциальной неуспешности вычисления через Maybe Double.
  • Guard y /= 0 отсекает недопустимое деление до выполнения операции /.
  • В успешной ветке результат упаковывается в Just.
  • В ветке otherwise возвращается Nothing, поэтому вызывающий код обязан учесть отсутствие результата.

Play ITЗагрузка интерактивного демо…


Операторы композиции и применения

Haskell активно использует функциональные операторы для управления потоком на уровне функций:

  • $ — оператор применения функции с низким приоритетом, позволяющий избежать скобок:
f $ g x -- эквивалентно f (g x)

Разбор:

  • $ — оператор применения функции с самым низким приоритетом.

  • Он позволяет убрать лишние скобки справа в цепочках вызовов.

  • Запись f $ g x читается как "сначала g x, потом применить f к результату".

  • Семантически это ровно f (g x), только более удобный синтаксис.

  • . — оператор композиции функций:

(f . g) x -- эквивалентно f (g x)

Разбор:

  • . строит новую функцию из двух функций: сначала применяется g, затем f.
  • (f . g) само по себе значение-функция, которое можно передавать и сохранять.
  • Применение к x показывает, что композиция и обычный вызов дают тот же результат.
  • Такой стиль удобен для построения конвейеров преобразований.

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

Пример:

result = map (+1) . filter even $ [1..10]

Разбор:

  • filter even сначала выбирает чётные числа из диапазона [1..10].
  • map (+1) затем увеличивает каждый отфильтрованный элемент на единицу.
  • Оператор . связывает оба шага в единый конвейер функции.
  • Оператор $ применяет собранный конвейер к входному списку без лишних скобок.

Это выражение сначала фильтрует чётные числа, затем увеличивает каждое на единицу.


Калькулятор обратной польской записи

Пример "одной функции" на композиции и foldl — разбор строки в обратной польской записи (ОПЗ):

calc :: String -> Float
calc = head . foldl f [] . words
where
f :: [Float] -> String -> [Float]
f (x:y:zs) "+" = (y + x) : zs
f (x:y:zs) "-" = (y - x) : zs
f (x:y:zs) "*" = (y * x) : zs
f (x:y:zs) "/" = (y / x) : zs
f (x:y:zs) "FLIP" = y : x : zs
f (x:zs) "ABS" = abs x : zs
f xs y = read y : xs

Разбор:

  • words разбивает входную строку на токены по пробелам.
  • foldl f [] обходит токены слева направо, поддерживая стек чисел f.
  • Для бинарных операторов снимаются два верхних элемента стека; порядок y и x важен для - и /.
  • head возвращает единственное оставшееся значение после разбора корректного выражения.
  • Оператор . связывает этапы в point-free стиле: head . foldl f [] . words.

В GHCi: calc "1 2 3 + 4 * - ABS" даёт 19.0 (сначала 3+2=5, затем 5*4=20, модуль — 19).


Бесконечные списки — числа Фибоначчи и простые

Фибоначчи за линейное время на корекурсивном бесконечном списке:

fibs :: [Integer]
fibs = 0 : 1 : zipWith (+) fibs (tail fibs)

Разбор:

  • zipWith (+) поэлементно складывает fibs и его хвост — каждый следующий член суммы двух предыдущих.
  • Ленивость вычисляет только запрошенный префикс (take 10 fibs).
  • Тот же смысл через list comprehension: [a + b | (a, b) <- zip fibs (tail fibs)] с префиксом 0 : 1 :.

Простые числа — идиома с генератором и проверкой делителей (учебный вариант, не самый быстрый):

primeNums :: [Integer]
primeNums = 2 : [n | n <- [3,5..], isPrime n]
where
isPrime n =
foldr (\p r -> p * p > n || rem n p /= 0 && r) True primeNums

Разбор:

  • primeNums ссылается на себя: для проверки n достаточно делителей среди уже найденных простых.
  • foldr останавливается, когда p * p > n (классическая оптимизация перебора).
  • В промышленном коде чаще используют решето или пакет primes; здесь важна связка ленивого списка и рекурсивного определения.

Палиндром (строки Unicode — буквы без пробелов и регистра):


import Data.Char (toLower, isAlpha)

palindrome :: [Char] -> Bool
palindrome s =
let norm = map toLower (filter isAlpha s)
in norm == reverse norm

Разбор:

  • filter isAlpha оставляет только буквенные символы (в том числе кириллицу и арабицу в UTF-8).
  • toLower нормализует регистр; сравнение с reverse проверяет симметрию.
  • Примеры: "А роза упала на лапу Азора" → True; "Мир не Рим" → False.

Частые ошибки в управляющей логике

  1. Писать частичные ветки в case и получать Non-exhaustive patterns в рантайме.
  2. Использовать head/tail без проверки пустого списка.
  3. Смешивать доменную логику с IO там, где можно оставить чистую функцию.
  4. Заменять читаемую композицию на длинные вложенные if.

Практическое правило — если блок можно протестировать без файлов, сети и консоли, вы на верном пути.


Куда идти дальше по теме


Что читать дальше