Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

패턴 및 완전성 검사

Rust에서 패턴 매칭과 바인딩에는 매우 유용한 몇 가지 속성이 있습니다. 컴파일러는 바인딩이 만들어질 때 반박 불가능한지, 그리고 match 갈래가 완전한지 검사합니다.

패턴 유용성

유용성 검사가 답하는 핵심 질문은 “이 match 표현식에서 저 분기는 중복되는가?“입니다. 더 정확히 말하면, 이 질문은 이미 살펴본 패턴 목록이 주어졌을 때 주어진 새 패턴이 새로운 값과 일치할 수 있는지를 계산하는 문제로 귀결됩니다.

예를 들어, 다음 match 표현식에서는 각 패턴이 그 위의 패턴들과 일치하지 않았던 무언가와 일치할 수 있는지를 차례로 묻습니다. 여기서 4번째 패턴은 1번째 패턴과 중복됨을 알 수 있으며, 해당 분기에는 “unreachable” 경고가 발생합니다. 3번째 패턴은 Foo에 Bar 이외의 다른 배리언트가 있는지에 따라 유용할 수도 있고 그렇지 않을 수도 있습니다. 마지막으로, 와일드카드 패턴(_)이 해당 match의 모든 패턴 목록에 대해 유용한지를 물음으로써 match 전체가 완전한지 물을 수 있습니다. 여기서 _가 유용함을 알 수 있으며((false, None)을 잡아낼 것이므로), 따라서 이 표현식에는 “non-exhaustive match” 오류가 발생합니다.

#![allow(unused)]
fn main() {
// x: (bool, Option<Foo>)
match x {
    (true, _) => {} // 1
    (false, Some(Foo::Bar)) => {} // 2
    (false, Some(_)) => {} // 3
    (true, None) => {} // 4
}
}

이처럼 유용성은 두 가지 목적으로 사용됩니다. 하나는 도달할 수 없는 코드를 탐지하는 것(사용자에게 유용함)이고, 다른 하나는 match가 완전함을 보장하는 것(match 표현식은 값을 반환할 수 있으므로 건전성을 위해 중요함)입니다.

수행되는 위치

이 검사는 패턴을 작성할 수 있는 모든 곳에서 수행됩니다. match 표현식, if let, let else, 일반 let, 그리고 함수 인자가 이에 해당합니다.

#![allow(unused)]
fn main() {
// `match`
// Usefulness can detect unreachable branches and forbid non-exhaustive matches.
match foo() {
    Ok(x) => x,
    Err(_) => panic!(),
}

// `if let`
// Usefulness can detect unreachable branches.
if let Some(x) = foo() {
    // ...
}

// `while let`
// Usefulness can detect infinite loops and dead loops.
while let Some(x) = it.next() {
    // ...
}

// Destructuring `let`
// Usefulness can forbid non-exhaustive patterns.
let Foo::Bar(x, y) = foo();

// Destructuring function arguments
// Usefulness can forbid non-exhaustive patterns.
fn foo(Foo { x, y }: Foo) {
    // ...
}
}

알고리즘

완전성 검사는 check_match에서 MIR 구축 전에 실행됩니다. 이 검사는 rustc_pattern_analysis 크레이트에 구현되어 있으며, 알고리즘의 핵심은 usefulness 모듈에 있습니다. 해당 파일에는 알고리즘에 대한 자세한 설명이 담겨 있습니다.

중요한 개념

생성자와 필드

값 Pair(Some(0), true)에서 Pair는 이 값의 생성자라고 하며, Some(0)과 true는 이 값의 필드입니다. 매칭 가능한 모든 값은 이러한 방식으로 분해할 수 있습니다. 생성자의 예로는 Some, None, (,)(2-튜플 생성자), Foo {..}(구조체 Foo의 생성자), 2(숫자 2의 생성자)가 있습니다.

각 생성자는 고정된 개수의 필드를 받으며, 이를 생성자의 항수(arity)라고 합니다. Pair와 (,)의 항수는 2이고, Some의 항수는 1이며, None과 42의 항수는 0입니다. 각 타입에는 알려진 생성자 집합이 있습니다. 어떤 타입은 생성자가 많거나(u64처럼) 심지어 무한히 많습니다(&str과 &[T]처럼).

패턴도 이와 비슷합니다. Pair(Some(_), _)는 생성자 Pair와 두 개의 필드를 가집니다. 차이점은 패턴에만 있는 추가 생성자들이 있다는 것입니다. 바로 와일드카드 _, 변수 바인딩, 0..=10 같은 정수 범위, [_, .., _] 같은 가변 길이 슬라이스입니다. or-패턴은 별도로 다룹니다.

이제 값 v가 패턴 p와 일치하는지 검사하려면, v의 생성자가 p의 생성자와 일치하는지 확인한 다음 필요하면 각 필드를 재귀적으로 비교합니다. 대표적인 예시 몇 가지는 다음과 같습니다.

  • matches!(v, _) := true
  • matches!((v0, v1), (p0, p1)) := matches!(v0, p0) && matches!(v1, p1)
  • matches!(Foo { a: v0, b: v1 }, Foo { a: p0, b: p1 }) := matches!(v0, p0) && matches!(v1, p1)
  • matches!(Ok(v0), Ok(p0)) := matches!(v0, p0)
  • matches!(Ok(v0), Err(p0)) := false (호환되지 않는 배리언트)
  • matches!(v, 1..=100) := matches!(v, 1) || ... || matches!(v, 100)
  • matches!([v0], [p0, .., p1]) := false (호환되지 않는 길이)
  • matches!([v0, v1, v2], [p0, .., p1]) := matches!(v0, p0) && matches!(v2, p1)

이 개념은 패턴 분석의 절대적인 핵심입니다. constructor 모듈은 생성자를 추출하고, 나열하고, 조작하는 함수를 제공합니다. 이 개념은 충분히 유용해서 match 표현식의 MIR 로워링이나 일부 clippy 린트처럼 컴파일러의 다른 곳에서도 그 변형을 찾아볼 수 있습니다.

생성자 묶기와 분할

패턴 전용 생성자(_, 범위, 변수 길이 슬라이스)는 각각 일반 생성자의 집합을 나타냅니다. 예를 들어 _: Option<T>는 집합 {None, Some}을 나타내고, [_, .., _]는 인자 개수가 2 이상인 슬라이스 생성자의 무한 집합 {[,], [,,], [,,,], …}을 나타냅니다.

이러한 생성자를 관리하기 위해 우리는 생성자를 가능한 한 묶어서 유지합니다. 예를 들면 다음과 같습니다.

#![allow(unused)]
fn main() {
match (0, false) {
    (0 ..=100, true) => {}
    (50..=150, false) => {}
    (0 ..=200, _) => {}
}
}

이 예시에서 0, 1, .., 49는 모두 같은 갈래에 매칭되므로 하나의 묶음으로 취급할 수 있습니다. 실제로 이 match에서 고려해야 할 범위는 0..50, 50..=100, 101..=150,151..=200, 201..뿐입니다. 마찬가지로 다음과 같습니다.

#![allow(unused)]
fn main() {
enum Direction { North, South, East, West }
let wind = (Direction::North, 0u8);
match wind {
    (Direction::North, 50..) => {}
    (_, _) => {}
}
}

여기서는 North가 아닌 모든 생성자를 하나의 묶음으로 취급할 수 있으므로, 처리해야 할 경우는 North와 그 밖의 모든 것, 이렇게 두 가지뿐입니다.

이를 “생성자 분할“이라고 하며, 완전성 검사가 적절한 시간 안에 실행되도록 하는 데 매우 중요합니다.

빈 타입이 있을 때의 유용성과 도달 가능성

이는 아마도 완전성 검사에서 가장 미묘한 측면일 것입니다. 정확히 말하면, match는 값이 아니라 장소(place)에 대해 동작합니다. 특정한 unsafe 상황에서는 장소가 자신의 타입에 맞는 유효한 데이터를 담고 있지 않을 수 있습니다. 이는 빈 타입에 미묘한 영향을 미칩니다. 다음을 살펴보십시오.

#![allow(unused)]
fn main() {
enum Void {}
let x: u8 = 0;
let ptr: *const Void = &x as *const u8 as *const Void;
unsafe {
    match *ptr {
        _ => println!("Reachable!"),
    }
}
}

이 예시에서 ptr은 유효하지 않은 데이터가 있는 장소를 가리키는 유효한 포인터입니다. _ 패턴은 장소 *ptr의 내용을 살펴보지 않으므로, 이 코드는 문제가 없으며 해당 갈래가 선택됩니다. 다시 말해, 검사하는 장소의 타입이 Void임에도 도달 가능한 갈래가 존재합니다. 하지만 갈래에 바인딩이 있다면 다음과 같습니다.

#![allow(unused)]
fn main() {
#[derive(Copy, Clone)]
enum Void {}
let x: u8 = 0;
let ptr: *const Void = &x as *const u8 as *const Void;
unsafe {
match *ptr {
    _a => println!("Unreachable!"),
}
}
}

여기서 바인딩은 *ptr 플레이스에서 Void 타입의 값을 읽어 옵니다. 이 예제에서는 데이터가 유효하지 않으므로 UB가 발생합니다. 일반적인 경우에는 이것이 *ptr에 있는 데이터가 유효하다고 단정합니다. 어느 쪽이든 이 갈래는 결코 선택되지 않습니다.

마지막으로 빈 match인 match *ptr {}를 살펴봅시다. 이것을 완전하다고 간주한다면 *ptr에 유효하지 않은 데이터가 있는 것 자체가 유효하지 않습니다. 다시 말해, 빈 match는 의미상 _a => ... match와 동등합니다. 명시성을 위해 저희는 갈래가 있는 쪽을 선호하므로, 사용자에게 _a 갈래를 제거하라고 알리지 않습니다. 다시 말해, _a 갈래는 도달할 수 없지만 중복된 것은 아닙니다. 이 때문에 린트 메시지에는 “unreachable“이라고 표시되지만, 저희는 도달할 수 없는 갈래가 아니라 중복된 갈래에 대해 린트를 냅니다.

이러한 고려 사항은 특정 플레이스, 즉 UB 없이 유효하지 않은 데이터를 담을 수 있는 플레이스에만 영향을 미칩니다. 해당 플레이스는 포인터 역참조, 참조 역참조, 그리고 union 필드 접근입니다. 저희는 완전성 검사 중에 주어진 플레이스가 유효한 데이터를 담고 있다고 알려져 있는지를 추적합니다.

그렇기는 하지만, 현재의 완전성 검사 구현은 위의 고려 사항을 따르지 않습니다. 안정 버전에서는 빈 타입이 대부분 비어 있지 않은 타입으로 취급됩니다. exhaustive_patterns 기능은 반대쪽으로 잘못을 범합니다. 즉, unsafe 상황에서 도달할 수도 있는 갈래를 생략하도록 허용합니다. never_patterns 실험적 기능은 이를 바로잡아 패턴에서 빈 타입이 올바르게 동작하도록 하는 것을 목표로 합니다.