Синтаксические размышления о выражениях match

— 2025-04-29 Yoshua Wuyts Источник

  • введение
  • логическое И
  • логическое ИЛИ
  • порядок вычисления в if let
  • заключительные слова

Введение

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

В этом посте я буду показывать примеры управления потоком на основе очереди, которая может быть либо онлайн, либо офлайн. Это определяется перечислением QueueState, которое хранит длину очереди как u32:

#![allow(unused)]
fn main() {
enum QueueState {
    Online(u32),
    Offline(u32),
}
}

В зависимости от того, активно ли добавляются новые данные в очередь (online), мы захотим по-разному рассуждать о пределах очереди (u32). Мы также хотим изменить поведение в зависимости от того, полна очередь или нет, и для этого нам нужно будет вызвать функцию:

#![allow(unused)]
fn main() {
fn is_full(n: u32) -> bool { .. }
}

Логическое И

Возьмём скромный if-guard (охранник). Мы можем использовать его в ветках match для объединения условий, что полезно, когда мы хотим что-то сделать со значением, содержащимся внутри структуры. Здесь метод is_full возвращает bool. Но чтобы вызвать его, нам сначала нужно получить доступ к числу, содержащемуся в варианте Online:

#![allow(unused)]
fn main() {
match state {
    QueueState::Online(count) if !is_full(count) => {
    //                        ^^^^^^^^^^^^^^^^^^
    //                            if-guard
        println!("queue is online and not full");
    }
    _ => println!("queue is in an unknown state"),
}
}

if-guards — это по сути просто логическое И. В выражениях match мы находимся в процессе добавления цепочек if let в Rust посредством RFC 2497 (стабильно в Rust 1.88). С этим мы сможем записать это же условие match следующим образом:

#![allow(unused)]
fn main() {
if let QueueState::Online(count) = state && !is_full(count) {
//                                       ^^^^^^^^^^^^^^^^^^
//                                           && chaining
    println!("queue is online and not full");
} else {
    println!("queue is in an unknown state");
}
}

Это, по сути, одна и та же возможность, представленная двумя разными способами. Вместо этого мне бы очень хотелось иметь возможность использовать && в обоих случаях, сблизив их:

#![allow(unused)]
fn main() {
match state {
    QueueState::Online(count) && !is_full(count) => {
    //                        ^^^^^^^^^^^^^^^^^^
    //                            && chaining
        println!("queue is online and not full");
    }
    _ => println!("queue is in an unknown state"),
}
}

Я могу гарантировать, что почти все, кто сталкивался с языком, имеющим C-подобный синтаксис (а это почти все программисты), без труда поймут, как это работает. if-guards, для сравнения, обычно труднее интуитивно понять.

Логическое ИЛИ

RFC 3637 представляет функцию «guard patterns»: расширение нотации паттернов Rust, которое выносит if-guards из выражений match во все паттерны. Функционально это даёт нам способ объединять булевы выражения И с логическими выражениями ИЛИ во всех паттернах. Скажем, мы взяли наш предыдущий пример и хотим вывести сообщение, если is_full вернул false, независимо от того, онлайн очередь или офлайн. Используя цепочки if..let, мы бы написали это так:

#![allow(unused)]
fn main() {
if let QueueState::Online(count) = state && !is_full(count) {
    println!("queue is online and not full");
} else if let QueueState::Online(count) = state && is_full(count) {
    println!("queue is full"); // 1. Это утверждение...
} else if let QueueState::Offline(count) = state && is_full(count) {
    println!("queue is full"); // 2. ...дублируется здесь.
} else {
    println!("queue is in an unknown state");
}
}

Это немного раздражает, потому что в идеале мы бы хотели, чтобы второе и третье условия были одним большим условием, разделённым логическим ИЛИ. Guard patterns — это как раз функция, которая позволит нам сделать именно это, добавив if-guards и цепочки паттернов на основе ИЛИ в выражения. С ней мы перепишем это так:

#![allow(unused)]
fn main() {
if let QueueState::Online(count) if !is_full(count) = state {
    //                           ^^^^^^^^^^^^^^^^^^
    //                           теперь используем if-guard, для разнообразия
    println!("queue is online and not full");
} else if let ((QueueState::Online(count) if is_full(count) = state) | // логическое ИЛИ
    //        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                   Либо совпадает с паттерном 1...
    (QueueState::Offline(count) if is_full(count) = state)
    // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                   ...либо с паттерном 2.
{
    println!("queue is full"); // Больше никакого дублирования!
} else {
    println!("queue is in an unknown state");
}
}

Здесь много всего происходит. Во-первых, это делает if-guards допустимыми в выражениях if..let. А также позволяет использовать несколько паттернов в одном условии с семантикой ИЛИ, если они соответствующим образом заключены в скобки. Продолжим и используем это с выражением match:

#![allow(unused)]
fn main() {
match state {
    QueueState::Online(count) if !is_full(count) => {
        println!("queue is online and not full");
    }
    ((QueueState::Online(count) if is_full(count)) |
    (QueueState::Offline(count) if is_full(count))) => {
        println!("queue is full"); // Больше никакого дублирования!
    }
    _ => println!("queue is in an unknown state"),
}
}

Это намного лучше, поскольку поток понятнее. Не повторяя явное = state в каждой строке, мы теперь можем читать все утверждения слева направо. Это хорошо. Нам это нравится. Перенос из версии с if..let в версию с match также был очень простым. Нам вообще не пришлось менять условия if..let, за исключением обрезки их концов. Это хорошо!

Однако что менее хорошо, так это то, что мы добились этого, усложнив выражения if..else. Взаимодействие цепочек if..let из RFC 2497 и guard patterns из RFC 3637 кажется особенно способным застать людей врасплох. Рассмотрим, например, эту строку:

#![allow(unused)]
fn main() {
if let Foo::Bar(x) if cond(x) = bar() && let Bin::Baz = baz() { .. }
}

Порядок, в котором вычисляются выражения в этой строке, здесь 2-3-1-5-4. Это означает, что, вероятно, нецелесообразно смешивать обе функции. Причина, по которой эти взаимодействия таковы, заключается в том, что оба RFC исходят из принципиально противоположных предпосылок:

  • RFC 2497 считает, что мы должны позволить булевым операторам, таким как &&, работать в большем количестве мест.
  • RFC 3637 считает, что мы должны позволить операторам, специфичным для match, таким как if-guards, работать в большем количестве мест.

В предыдущем разделе мы уже рассмотрели замену if-guards оператором &&, вдохновлённую цепочками if..let. Недалеко ушла идея использовать оператор || в цепочках if..let. Это выглядело бы примерно так:

#![allow(unused)]
fn main() {
if let QueueState::Online(count) = state && !is_full(count) {
    //                                   ^^^^^^^^^^^^^^^^^^
    //                                   if-let цепочка
    println!("queue is online and not full");
} else if let QueueState::Online(count) = state && is_full(count) ||
    //        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                   Либо совпадает с паттерном 1...
    let QueueState::Offline(count) = state && is_full(count)
    //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                   ...либо с паттерном 2.
{
    println!("queue is full"); // Больше никакого дублирования!
} else {
    println!("queue is in an unknown state");
}
}

Это та же функциональность, что и раньше, но предоставленная как естественное расширение цепочек if..let на основе &&. Теперь снова попробуем переписать это как выражение match:

#![allow(unused)]
fn main() {
match state {
    QueueState::Online(count) && !is_full(count) => {
        println!("queue is online and not full");
    }
    QueueState::Online(count) && is_full(count) || // <- логическое ИЛИ!
    QueueState::Offline(count) && is_full(count) => {
        println!("queue is full");
    }
    _ => println!("queue is in an unknown state"),
}
}

Что здесь замечательно, так это то, что поскольку логическое И (&&) имеет приоритет над логическим ИЛИ (||) (ссылка), нам не нужно прибегать к обёртыванию наших паттернов, чтобы объединить их в цепочку. О, и, конечно, также приятно, что почти любой программист, независимо от знакомства с Rust, должен быть способен понять, что здесь происходит.

Но что ещё важнее: посмотрите на это! Это код, который я хочу писать. Он не пытается быть умным или изобретать что-то совершенно новое. Его определяющая черта — просто то, насколько обычным он выглядит. И это действительно соответствует моему чувству того, каким должен быть язык общего назначения.

Порядок вычисления в if let

Мне не нравится читать операторы if-let, потому что они меняют порядок чтения справа-налево. Мне вдвойне не нравится читать операторы if-let, когда они объединены в цепочки, потому что их нужно читать центр-лево-право. Мне нравится оператор is, потому что он позволяет нам это исправить. Позвольте показать вам, как это сделать, взяв наш последний пример с if let..else, убрав все комментарии:

#![allow(unused)]
fn main() {
if let QueueState::Online(count) = state && !is_full(count) {
    println!("queue is online and not full");
} else if let QueueState::Online(count) = state && is_full(count) ||
    let QueueState::Offline(count) = state && is_full(count)
{
    println!("queue is full");
} else {
    println!("queue is in an unknown state");
}
}

Насколько мне ни нравятся здесь булевы операторы, мне не нравится порядок, в котором вычисляются утверждения. Первая строка в нашем примере выше вычисляется следующим образом:

#![allow(unused)]
fn main() {
if let QueueState::Online(cond) = state && !is_full(count) { .. }
//     ^^^^^^^^^^^^^^^^^^^^^^^^   ^^^^^    ^^^^^^^^^^^^^^^
//                |                 |              |
//                |       это вычисляется первым  |
//                |                                |
//     это вычисляется вторым                      |
//                                                 |
//                                        это вычисляется последним
}

RFC 3573 предлагает добавить в язык оператор is, вдохновлённый одноимённой функцией в C#. Это исправит порядок вычисления, заставив тот же код читаться сверху вниз, слева направо:

#![allow(unused)]
fn main() {
if state is QueueState::Online(count) && !is_full(count) {
    println!("queue is online and not full");
} else if state is QueueState::Online(count) && is_full(count) ||
    state is QueueState::Offline(count) && is_full(count)
{
    println!("queue is full");
} else {
    println!("queue is in an unknown state");
}
}

Для меня это Rust таким, каким он должен быть. Смысл всегда заключался в том, чтобы создать язык, максимально практичный в использовании, без ущерба для корректности, производительности и контроля. Сделать порядок вычислений более простым для понимания кажется вполне соответствующим этой цели. Вот та же строка с аннотацией, но с использованием нотации is:

#![allow(unused)]
fn main() {
if state is QueueState::Online(cond) && !is_full(count) { .. }
// ^^^^^    ^^^^^^^^^^^^^^^^^^^^^^^^    ^^^^^^^^^^^^^^^
//   |                         |                |
//  это вычисляется первым     |                |
//                             |                |
//                  это вычисляется вторым      |
//                                              |
//                                    это вычисляется последним
}

Это также даёт дополнительное преимущество: рефакторинг кода из формы if..else в форму match даёт невероятно маленькую разницу. В основном это тот же код, но с обрезанным началом каждого условия:

#![allow(unused)]
fn main() {
match state {
    QueueState::Online(count) && !is_full(count) => {
        println!("queue is online and not full");
    }
    QueueState::Online(count) && is_full(count) ||
    QueueState::Offline(count) && is_full(count) => {
        println!("queue is full");
    }
    _ => println!("queue is in an unknown state"),
}
}

Это позволило бы объяснять условное управление потоком более постепенно. Я вижу потенциал для создания небольшой дидактической лестницы на знаменитом крутом пути обучения Rust, которая выглядела бы примерно так:

  1. Начните с представления if..else, который большинство программистов уже знают.
  2. Затем представьте is как более мощную версию typeof в других языках.
  3. Наконец, представьте match как более эргономичную версию if..else + is. Как switch в других языках, но с исчерпывающей проверкой всех случаев.

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

Заключительные слова

Ещё одна вещь, о которой я не был до конца уверен, когда говорить в контексте RFC guard patterns, — это его взаимодействие с типами паттернов. Внесение if-guards в паттерны кажется, что меняет нотацию паттернов с довольно управляемого/ограниченного подмножества, которое мы можем оценить за разумное время, в систему для произвольных предусловий/постусловий. Если, конечно, мы не запретим такие паттерны в типах паттернов — что создаст расходящиеся нотации паттернов.

В целом, я чувствую, что языку есть много чего выиграть, сделав выражения match более согласованными с остальными условными выражениями. После написания основной части этого поста я вспомнил, что то, чем я делюсь здесь, на самом деле не является уникальным прозрением. RFC 3796 cfg_logical_ops Джейкоба Пратта предлагает добавление булевых операторов &&, || и ! в атрибутах cfg. Фактически заменяя существующие доменно-специфичные операторы all(), any() и not().

#![allow(unused)]
fn main() {
// текущий вариант
#[cfg(all(any(a, b), any(c, d), not(e)))]
struct MyStruct {}

// предложенный вариант
#[cfg((a || b) && (c || d) && !e)]
struct MyStruct {}
}

Мне нравится то, что предлагает этот RFC. Мне вдвойне нравится, когда мы рассматриваем это как часть более широких усилий по сокращению количества микросинтаксисов, разбросанных по всему языку. В свою очередь, делая Rust более простым языком благодаря тому, что он меньше и более последователен.