Tech Wiki

TOPICSSERIES

[Rust 실전 로드맵 04] Rust 튜플·배열·패턴 분해로 데이터 다루기

인덱스가 범위를 벗어났을 때 아무 메모리나 읽는 일은 Rust에서 일어나지 않습니다. 대신 array[index]는 패닉을 일으킵니다. 외부 입력으로 정해지는 인덱스라면 get으로 Option을 받는 편이 맞습니다. 길이가 타입에 박힌 고정 데이터는 배열과 패턴 분해를 쓰면 구조 자체를 코드에 드러낼 수 있습니다.

이 글에서는 엔드포인트 한 건을 튜플로 묶고 최근 응답 시간 네 건을 배열로 다룹니다. 예제는 Rust 2024 edition의 독립 Cargo 프로젝트이며 rustfmt, Clippy, 테스트 명령을 포함합니다.

1. 튜플과 배열 선택

튜플과 배열은 모두 길이가 고정됩니다. 차이는 원소 타입입니다.

  • 튜플은 위치마다 타입이 달라도 됩니다. (&str, u16, bool)처럼 서로 다른 값을 한 묶음으로 잠깐 전달할 때 쓸 만합니다.
  • 배열은 모든 원소의 타입이 같아야 합니다. [u32; 4]에서 u32는 원소 타입, 4는 길이입니다. 길이까지 타입의 일부입니다.

튜플 필드는 .0, .1처럼 읽을 수 있지만 필드가 늘어나면 뜻을 놓치기 쉽습니다. 이 글의 세 칸짜리 튜플은 패턴 분해를 보여주기 위한 작은 예제입니다. 도메인 데이터가 커지거나 여러 함수 사이를 오간다면 이름 있는 구조체가 더 낫습니다.

pub type EndpointSample<'a> = (&'a str, u16, bool);

pub fn describe_endpoint(sample: EndpointSample<'_>) -> String {
    let (host, port, uses_tls) = sample;
    let scheme = if uses_tls { "https" } else { "http" };
    format!("{scheme}://{host}:{port}")
}

let (host, port, uses_tls) = sample;의 왼쪽은 값이 아니라 패턴입니다. 세 이름이 튜플의 세 자리에 각각 바인딩됩니다. 튜플 길이가 다르면 컴파일되지 않으므로 분해 시점에 구조를 빠뜨릴 수 없습니다.

2. 배열 길이는 타입에 포함된다

다음 두 타입은 서로 다릅니다.

let short: [u32; 3] = [118, 121, 117];
let window: [u32; 4] = [118, 121, 117, 125];

함수가 [u32; 4]를 받으면 정확히 네 건이 들어온다는 전제를 함수 본문 밖에서 강제합니다. 최근 네 번의 측정치처럼 크기가 고정된 작은 묶음에 잘 맞습니다. 건수가 실행 중 늘고 줄어야 한다면 다음 편에서 다룰 Vec<T>가 자연스럽습니다.

같은 값으로 채울 때는 [value; length] 문법을 씁니다.

let initial_backoff_ms = [250_u64; 4];
assert_eq!(initial_backoff_ms, [250, 250, 250, 250]);

일반적인 반복 피연산자는 한 번 평가된 뒤 필요한 횟수만큼 복사됩니다. 길이가 1보다 클 때 피연산자는 Copy 타입이거나 const 블록 또는 상수 항목이어야 합니다. 초반에는 정수와 불리언 배열에서 사용하면 혼동이 적습니다.

3. 배열 구조 분해

배열도 대괄호 패턴으로 분해할 수 있습니다. 패턴은 튜플과 배열 같은 값을 구성 요소로 나누고 변수에 바인딩합니다. _는 한 항목을 무시하고 ..는 남은 항목을 받거나 건너뜁니다.

pub fn split_window(samples: [u32; 4]) -> (u32, [u32; 2], u32) {
    let [first, middle @ .., last] = samples;
    (first, middle, last)
}

firstlast는 양 끝의 u32를 받습니다. middle @ ..는 사이의 두 값을 [u32; 2]로 묶습니다. 입력 타입이 네 칸으로 고정되어 있어 이 let 패턴은 항상 맞습니다.

일부 값이 필요 없으면 _를 쓸 수 있습니다.

let [first, _, _, last] = [118, 121, 117, 125];
assert_eq!((first, last), (118, 125));

_는 이름을 만들지 않습니다. _middle처럼 밑줄로 시작하는 이름은 바인딩을 만들기 때문에 소유권이 중요한 타입에서는 둘이 같은 뜻이 아닙니다. 이 차이는 소유권 편에서 다시 다룹니다.

4. []get의 실패 계약

상수 인덱스처럼 범위가 코드만 봐도 확실할 때는 samples[0]이 짧고 읽기 좋습니다. 하지만 사용자 입력, 파일, 네트워크 응답에서 온 인덱스는 달라집니다. []는 유효한 원소를 바로 돌려줘야 하므로 범위를 벗어나면 패닉합니다. 배열 인덱스가 길이 이상이면 런타임 검사에서 패닉합니다.

실패가 정상적으로 생길 수 있다면 slice::get을 씁니다. 배열 참조는 슬라이스 메서드를 사용할 수 있고 getOption<&T>를 반환합니다.

pub fn sample_at<const N: usize>(samples: &[u32; N], index: usize) -> Option<u32> {
    samples.get(index).copied()
}

N은 const generic 매개변수라서 이 함수는 길이가 다른 u32 배열도 받습니다. get(index)의 결과는 원소 참조를 담은 Option<&u32>입니다. u32Copy이므로 copied()를 붙여 Option<u32>로 바꿨습니다.

호출하는 쪽에서는 없는 값을 어떻게 처리할지 드러내야 합니다.

let samples = [118, 121, 117, 125];

match sample_at(&samples, 2) {
    Some(milliseconds) => println!("sample[2]={milliseconds}ms"),
    None => println!("sample[2] is missing"),
}

assert_eq!(sample_at(&samples, 9), None);

unwrap_or, if let, let else도 선택할 수 있습니다. 어떤 문법을 쓰든 범위 밖 접근이 함수의 반환 타입에 나타난다는 점이 중요합니다.

5. 패닉 경계 정하기

패닉이 언제나 나쁜 것은 아닙니다. 범위를 벗어난 인덱싱처럼 복구할 방법이 없는 상황도 있습니다. 테스트 코드에서 반드시 지켜져야 할 내부 불변식을 확인하거나, 개발 중 잘못된 가정을 즉시 드러낼 때는 의도된 패닉이 유용합니다.

다만 요청 파라미터나 사용자 입력은 내부 불변식이 아닙니다. 잘못된 인덱스 하나 때문에 프로세스의 현재 작업을 중단시키기보다 None이나 이후 배울 Result로 돌려주는 편이 낫습니다. 라이브러리 함수가 호출자의 입력 때문에 패닉하면 호출자가 복구 정책을 고를 여지도 사라집니다.

예제에는 패닉 경로도 별도 테스트로 가뒀습니다.

#[test]
#[should_panic(expected = "index out of bounds")]
fn indexing_panics_when_the_invariant_is_broken() {
    let samples = [118, 121, 117, 125];
    let impossible_index = samples.len();
    let _ = samples[impossible_index];
}

이 테스트는 패닉을 애플리케이션 흐름에 쓰자는 예가 아닙니다. []의 계약을 확인하고 실패를 테스트 프로세스 안에서 통제합니다. 일반 경로는 sample_atNone 테스트가 맡습니다.

6. 배열 예제 실행

예제는 외부 크레이트를 쓰지 않습니다. 어느 작업 디렉터리에서든 다음 명령으로 독립된 Rust 2024 프로젝트를 만듭니다.

cargo new --name article-04-tuples-arrays article-04-tuples-arrays
cd article-04-tuples-arrays

Cargo.toml을 다음 내용으로 교체합니다.

[package]
name = "article-04-tuples-arrays"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]

전체 라이브러리 코드와 테스트 4개를 src/lib.rs에 작성합니다.

pub type EndpointSample<'a> = (&'a str, u16, bool);

pub fn describe_endpoint(sample: EndpointSample<'_>) -> String {
    let (host, port, uses_tls) = sample;
    let scheme = if uses_tls { "https" } else { "http" };
    format!("{scheme}://{host}:{port}")
}

pub fn split_window(samples: [u32; 4]) -> (u32, [u32; 2], u32) {
    let [first, middle @ .., last] = samples;
    (first, middle, last)
}

pub fn sample_at<const N: usize>(samples: &[u32; N], index: usize) -> Option<u32> {
    samples.get(index).copied()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn destructures_a_tuple() {
        let endpoint = ("status.example.com", 443, true);
        assert_eq!(
            describe_endpoint(endpoint),
            "https://status.example.com:443"
        );
    }

    #[test]
    fn destructures_a_fixed_array() {
        let short: [u32; 3] = [118, 121, 117];
        let window: [u32; 4] = [118, 121, 117, 125];
        assert_eq!(short.len(), 3);
        assert_eq!(window.len(), 4);

        let initial_backoff_ms = [250_u64; 4];
        assert_eq!(initial_backoff_ms, [250, 250, 250, 250]);

        let [first, _, _, last] = window;
        assert_eq!((first, last), (118, 125));
        assert_eq!(split_window(window), (118, [121, 117], 125));
    }

    #[test]
    fn get_makes_out_of_bounds_access_explicit() {
        let samples = [118, 121, 117, 125];
        assert_eq!(sample_at(&samples, 2), Some(117));
        assert_eq!(sample_at(&samples, 9), None);
    }

    #[test]
    #[should_panic(expected = "index out of bounds")]
    fn indexing_panics_when_the_invariant_is_broken() {
        let samples = [118, 121, 117, 125];
        let impossible_index = samples.len();
        let _ = samples[impossible_index];
    }
}

src/main.rs는 다음 내용으로 교체합니다.

use article_04_tuples_arrays::{describe_endpoint, sample_at, split_window};

fn main() {
    let endpoint = ("status.example.com", 443, true);
    println!("{}", describe_endpoint(endpoint));

    let samples = [118, 121, 117, 125];
    let (first, middle, last) = split_window(samples);
    println!("first={first}, middle={middle:?}, last={last}");

    match sample_at(&samples, 2) {
        Some(milliseconds) => println!("sample[2]={milliseconds}ms"),
        None => println!("sample[2] is missing"),
    }
}

새로 만든 article-04-tuples-arrays 디렉터리에서 다음 명령으로 검증하고 실행합니다.

cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

실행 결과는 다음과 같았습니다.

https://status.example.com:443
first=118, middle=[121, 117], last=125
sample[2]=117ms

테스트 4개는 튜플 분해, 배열 분해, get의 성공·실패, 예상된 인덱스 패닉을 각각 확인합니다. 예제 환경은 rustc 1.98.1, Cargo 1.98.1, rustfmt 1.9.0-stable, Clippy 0.1.98, Rust 2024 edition입니다.

7. 배열 접근

튜플은 타입이 다른 소수의 값을 잠깐 묶을 때 편합니다. 배열은 같은 타입의 값이 정확히 몇 개 필요한지 타입으로 고정합니다. 패턴 분해는 그 구조를 변수 바인딩에 그대로 옮깁니다.

인덱스가 내부 불변식으로 보장되면 []가 간결합니다. 값이 외부에서 오거나 없을 가능성이 정상 흐름에 속하면 get으로 Option을 받는 편이 좋습니다. 그러면 호출자는 함수 서명만 보고도 패닉 대신 빈 값을 처리해야 한다는 사실을 알 수 있습니다.

다음 편에서는 크기가 실행 중 바뀌는 데이터에 Vec, 소유한 문자열에 String, 키 기반 조회에 HashMap을 적용합니다.

전체 소스 코드

이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 04 프로젝트에서 확인할 수 있습니다.

출처


답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Tech Wiki

Built with WordPress · Learn in public.