Menu

Zero Choice и Match: tagged union и pattern matching

Как choice объявляет в Zero tagged union и как match исчерпывающе ветвится по его вариантам — Zero-вариант sum-типов и pattern matching.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Tagged union в стиле Zero

choice объявляет тип, значение которого является одним из нескольких именованных вариантов, и каждый вариант несёт свой payload:

choice Result {
    ok:  i32,
    err: String,
}

Значение Result — это либо ok с i32, либо err со String. Никогда оба сразу и никогда ни то ни другое. Это гарантирует система типов, а match делает удобным реагировать на это.

Это та же идея, которую в других языках называют tagged union, sum type, discriminated union или algebraic data type. Zero пишет это как choice и держит грамматику маленькой.

Объявление choice

choice Name {
    variantA: PayloadTypeA,
    variantB: PayloadTypeB,
}

Каждая строка перечисляет один вариант. Имя — ваше; тип после двоеточия — это payload, который этот вариант несёт. Вариант, которому payload не нужен, использует Void:

choice Token {
    word:   String,
    number: i32,
    eof:    Void,
}

Token.eof — это вариант без полезного payload (его тип Void) — пригодится для случаев-терминаторов.

Конструирование значения choice

Постройте значение, назвав тип, затем вариант, и передав payload:

let success = Result.ok(42)
let failure = Result.err("validation failed")

Тип payload должен совпасть с объявленным у варианта. Result.ok("hello") был бы compile error, потому что ok ожидает i32.

Вывод типов тоже работает. Если правая часть полностью определяет тип, можно написать let success = Result.ok(42), и тип привязки будет Result. Аннотировать тип явно тоже нормально, если хочется задокументировать его прямо у привязки:

let success: Result = Result.ok(42)

Матчинг по choice

match — это то, как вы читаете значение choice. Форма:

match value {
    .variantA => binding { /* тело, когда value это variantA, payload в `binding` */ }
    .variantB => binding { /* тело, когда value это variantB */ }
}

Разбор примера из официального репозитория Zero — нажмите Run и увидите, как срабатывает arm .ok:

Прочитайте match буквально: «в зависимости от того, какой вариант лежит в result, выполни соответствующую arm и привяжи payload к выбранному имени». В arm .ok value — это payload типа i32. В arm .err message — это payload типа String. Каждая arm — отдельная область видимости; привязка видна только внутри её тела.

Исчерпывающесть

Это главный выигрыш match перед цепочкой if/else if: компилятор проверяет, что у каждого варианта есть arm. Если забыть случай .err, не получите тихий fall-through на дефолтную ветку — получите compile error:

{
    "code": "MAT001",
    "message": "match is not exhaustive: missing variant 'err'",
    "line": 9
}

(Код ошибки иллюстративный; принцип — это контракт.)

Добавьте в choice новый вариант — скажем, Result.timeout: Void — и каждый match по Result в кодовой базе станет compile-error, пока вы не обработаете новый случай. Это фича, а не повинность: компилятор показывает вам ровно те места, где новому случаю нужно внимание.

Когда payload не нужен

Если payload варианта — Void или просто не интересен в этой arm, можно проигнорировать привязку — но arm всё равно надо написать, чтобы удовлетворить исчерпывающесть:

match token {
    .word   => w { /* используем w */ }
    .number => n { /* используем n */ }
    .eof    => _ { /* нечего привязывать */ }
}

Точное написание «игнорировать payload» может эволюционировать в pre-1.0 Zero (может встретиться _ или просто пропущенная привязка). Концептуальная суть — у каждого варианта есть arm, с payload или без — это стабильная часть.

Частые паттерны

Тип ошибки в стиле Result

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

choice Result {
    ok:  i32,
    err: String,
}

Функции, которые могут успешно вернуть значение или упасть с сообщением, возвращают Result. Вызывающие паттерн-матчат, чтобы достать либо значение, либо сообщение. Система raises/check в Zero обрабатывает проброс для fallible-операций; Result полезен, когда вы хотите держать success-or-failure значение как данные.

Токен парсера

choice Token {
    word:   String,
    number: i32,
    eof:    Void,
}

Токенизатор производит поток Token. Каждый потребитель матчит по варианту, чтобы решить, что делать — напечатать слово, просуммировать число, выйти на eof.

Конечный автомат

choice State {
    waiting:    Void,
    processing: i32,
    done:       String,
}

processing несёт текущий ID задачи; done несёт финальный результат. Каждый переход — новое значение State, а не разбросанные по shape изменяемые поля.

Choice и дженерики

choice может быть дженериком ровно так же, как shape:

choice Maybe<T> {
    some: T,
    none: Void,
}

Maybe<i32> — это «опциональное целое». Maybe<String> — «опциональная строка». Тот же паттерн встречается в стандартной библиотеке Zero и подходит гораздо лучше null-sentinel значения — забыть случай .none, когда вы match-ите по типу, попросту нельзя.

Когда choice, когда shape, когда enum

Короткое повторение из shapes и enums:

  • Shape — запись с несколькими полями, все присутствуют вместе.
  • Enum — одна из N меток, без дополнительных данных.
  • Choice — один из N вариантов, каждый несёт payload.

Большинство моделей данных в реальной программе — это какая-то комбинация этих трёх. Ясность, которую даёт старт с вопроса «это и, или или или с данными?», — одно из недооценённых преимуществ работы в маленьком языке.

Дальше: World capability

choice и match покрывают «данные» в Zero. Следующая глава — об эффектах, о том, как программы на Zero взаимодействуют с внешним миром. Она начинается с World capability, объекта, который контролирует каждое I/O.

Часто задаваемые вопросы

Что такое choice в Zero?

choice — это tagged-union тип в Zero: значение, которое является одним из нескольких именованных вариантов, и каждый вариант несёт свой тип payload. Пример: choice Result { ok: i32, err: String }. Значение Result — это либо ok с i32, либо err со String. Сконструировать его можно через Result.ok(42) или Result.err("bad").

Как работает match в Zero?

match value { .variantA => binding { ...тело } .variantB => binding { ...тело } } ветвится по тому, какой вариант сейчас в value. Каждая arm паттерн-матчит вариант, именует привязку payload и выполняет своё тело. Компилятор проверяет, что вы покрыли каждый вариант — исчерпывающесть и есть главное преимущество перед if/else if.

Как построить значение choice?

Назовите тип и вариант и передайте payload: let r: Result = Result.ok(42) или let r = Result.err("validation failed"). Тип payload должен совпадать с объявленным у варианта — передача неправильного типа это compile error.

В чём разница между choice и enum?

Варианты enum — это просто метки без payload. У choice каждый вариант несёт значение объявленного типа. Если нужно прицепить данные к одному из случаев (сообщение об ошибке, успешный результат, разобранный токен) — используйте choice. Если случаи — просто метки, берите enum.

Почему для choice предпочтительнее match, а не if-else?

match по построению исчерпывающий — компилятор проверяет, что обработан каждый вариант, поэтому добавление нового варианта позже принуждает обновить каждое место, которое ветвится по этому типу. Цепочка if/else if молча проваливается, скрывая отсутствующий случай до тех пор, пока он не вылезет багом в проде.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ