[AI가 씀] Rust 기본

러스트 기본 한다 한다 몇년째인지 모르겠는데 일단 클로드 코드를 이용해서 기본적인 excercise를 만들어서 내가 풀어보고 냅다 마크다운 만들어달라고 함.

Ch.01 – 변수와 가변성 (Variables & Mutability)

Rust에서 변수를 다루는 기본 규칙을 알아봅니다.

  • 변수는 기본적으로 불변(immutable) 입니다.
  • mut 키워드를 붙이면 가변 변수가 됩니다.
  • let으로 같은 이름을 다시 선언하면 섀도잉(shadowing) 이 됩니다 — 타입까지 바꿀 수 있습니다.
  • const는 컴파일 타임 상수이며, 반드시 타입을 명시해야 합니다.

연습 1: 불변 변수 선언

값이 42인 변수를 선언하고 반환합니다.

pub fn declare_variable() -> i32 {
    let answer = 42;
    answer
}

let으로 선언한 변수는 기본적으로 불변입니다. answer = 100;처럼 재할당하면 컴파일 에러가 발생합니다.


연습 2: 가변 변수 (mut)

가변 변수를 10으로 선언한 뒤, 20으로 재할당하고 반환합니다.

pub fn mutable_variable() -> i32 {
    let mut answer = 10;
    answer = 20;
    answer
}

mut 키워드를 붙이면 같은 타입의 값으로 재할당할 수 있습니다. Rust는 의도적으로 변경이 필요한 변수만 mut으로 표시하게 합니다.


연습 3: 섀도잉 (Shadowing)

변수 x를 정수 5로 선언한 뒤, 같은 이름으로 문자열 "five"로 섀도잉합니다.

pub fn shadowing() -> &'static str {
    let x = 5;
    let x = "five";
    x
}

섀도잉은 mut과 다릅니다. let을 다시 쓰면 새로운 변수가 만들어지기 때문에 타입 자체가 바뀔 수 있습니다. 여기서 xi32에서 &str로 바뀌었습니다.


연습 4: 상수와 타입 명시

상수 MAX_SCORE를 100으로, 상수 PI를 3.14로 선언하고 튜플로 반환합니다.

pub fn constants_and_types() -> (i32, f64) {
    const MAX_SCORE: i32 = 100;
    const PI: f64 = 3.14;
    (MAX_SCORE, PI)
}

constlet과 달리 반드시 타입을 명시해야 하고, 컴파일 타임에 값이 결정됩니다. 관례적으로 SCREAMING_SNAKE_CASE로 이름을 짓습니다.

Ch.02 – 기본 타입 (Primitive Types)

Rust가 제공하는 기본 타입들을 살펴봅니다.

  • 정수: i8 ~ i128 (부호 있음), u8 ~ u128 (부호 없음)
  • 부동소수점: f32, f64 (기본값은 f64)
  • 불리언: booltrue 또는 false
  • 문자: char — 유니코드 스칼라 값 (4바이트)
  • 튜플: 서로 다른 타입을 묶을 수 있는 고정 크기 컬렉션
  • 배열: 같은 타입의 고정 크기 컬렉션 [T; N]

연습 1: 정수 타입

i8, i32, u64 세 가지 정수 타입을 선언하고 튜플로 반환합니다.

pub fn integer_types() -> (i8, i32, u64) {
    let a: i8 = -1;
    let b: i32 = 1000;
    let c: u64 = 42;
    (a, b, c)
}

i8은 -128~127, u64는 0~18,446,744,073,709,551,615 범위입니다. 필요한 범위에 맞는 타입을 골라 쓰면 됩니다.


연습 2: 부동소수점과 불리언

f64 값 3.14와 booltrue를 반환합니다.

pub fn float_and_bool() -> (f64, bool) {
    let a: f64 = 3.14;
    let b = true;
    (a, b)
}

Rust에서 소수점이 있는 숫자 리터럴은 기본적으로 f64로 추론됩니다.


연습 3: char와 문자열 슬라이스

한글 char와 문자열 슬라이스를 반환합니다.

pub fn char_and_string() -> (char, &'static str) {
    let a: char = '가';
    let b = "안녕";
    (a, b)
}

Rust의 char는 유니코드 스칼라 값이라 한글, 이모지 등도 담을 수 있습니다. &str는 UTF-8로 인코딩된 문자열의 참조입니다.


연습 4: 튜플 접근

튜플 (10, 20, 30)에서 두 번째 요소를 반환합니다.

pub fn tuple_access() -> i32 {
    let tuple = (10, 20, 30);
    tuple.1
}

튜플의 각 요소는 .0, .1, .2 … 인덱스로 접근합니다. 구조 분해(destructuring)도 가능합니다: let (a, b, c) = tuple;


연습 5: 배열과 합계

배열 [1, 2, 3, 4, 5]의 모든 요소 합을 반환합니다.

pub fn array_sum() -> i32 {
    let arr = [1, 2, 3, 4, 5];
    arr.iter().sum()
}

.iter()로 이터레이터를 만들고 .sum()으로 합산합니다. 배열은 크기가 타입의 일부이므로 [i32; 5][i32; 3]은 서로 다른 타입입니다.

Ch.03 – 함수 (Functions)

Rust 함수의 기본 규칙을 익힙니다.

  • fn 키워드로 선언합니다.
  • 매개변수의 타입은 반드시 명시해야 합니다.
  • 반환 타입은 -> 뒤에 씁니다.
  • 함수 본문의 마지막 표현식(세미콜론 없이)이 반환값이 됩니다.
  • 함수 자체를 다른 함수의 인자로 전달할 수 있습니다 (함수 포인터 fn(T) -> U).

연습 1: 두 수의 합

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

세미콜론 없이 a + b를 쓰면 이 표현식의 결과가 그대로 반환됩니다. return a + b;와 동일하지만, Rust에서는 마지막 표현식 형태가 관용적입니다.


연습 2: 짝수 판별

pub fn is_even(n: i32) -> bool {
    n % 2 == 0
}

% 연산자(나머지)를 사용해 2로 나눈 나머지가 0인지 확인합니다. 비교 표현식 자체가 bool을 반환하므로 if문 없이 바로 반환할 수 있습니다.


연습 3: 팩토리얼 (재귀)

n!을 재귀로 계산합니다. 0! = 1, 5! = 120.

pub fn factorial(n: u32) -> u32 {
    if n < 2 {
        return 1
    }
    n * factorial(n - 1)
}

재귀 호출로 n * (n-1) * ... * 1을 계산합니다. u32이므로 음수 걱정 없이 n - 1이 안전합니다. 다만 큰 수에서는 오버플로우에 주의해야 합니다.

Tip: 반복문 버전도 가능합니다: (1..=n).product()


연습 4: 함수 포인터

함수 fx에 두 번 적용합니다 — f(f(x)).

pub fn apply_twice(f: fn(i32) -> i32, x: i32) -> i32 {
    f(f(x))
}

fn(i32) -> i32함수 포인터 타입입니다. 일반 함수를 값처럼 전달할 수 있습니다. 클로저를 받으려면 Fn 트레이트를 사용합니다(Ch.14에서 다룹니다).

Ch.04 – 제어 흐름 (Control Flow)

조건문과 반복문, 그리고 Rust의 강력한 match 표현식을 다룹니다.

  • if/else표현식이므로 값을 반환할 수 있습니다.
  • loop, while, for 세 가지 반복문이 있습니다.
  • match는 패턴 매칭으로, 모든 경우를 빠짐없이 처리해야 합니다.
  • for i in 0..n은 범위(Range) 기반 반복입니다.

연습 1: 절대값

if/else를 사용해 절대값을 반환합니다.

pub fn abs_value(x: i32) -> i32 {
    if x < 0 {
        -x
    } else {
        x
    }
}

Rust에서 if는 표현식이므로 마지막 값이 반환됩니다. 별도의 return이 필요 없습니다.


연습 2: FizzBuzz

3과 5의 배수 여부에 따라 다른 문자열을 반환하는 고전적인 문제입니다.

pub fn fizzbuzz(n: u32) -> String {
    if n % 15 == 0 {
        String::from("FizzBuzz")
    } else if n % 5 == 0 {
        String::from("Buzz")
    } else if n % 3 == 0 {
        String::from("Fizz")
    } else {
        n.to_string()
    }
}

15의 배수(= 3과 5의 공배수)를 먼저 검사해야 합니다. 순서가 바뀌면 "Fizz""Buzz"가 먼저 매칭되어 버립니다.


연습 3: 범위 합계

start부터 end까지(양 끝 포함) 정수의 합을 반환합니다.

pub fn sum_range(start: i32, end: i32) -> i32 {
    let mut sum = 0;
    for i in start..=end {
        sum += i
    }
    sum
}

start..=end는 양 끝을 포함하는 inclusive range입니다. start..endend를 포함하지 않습니다.

Tip: 이터레이터로 더 간결하게 쓸 수 있습니다: (start..=end).sum()


연습 4: 모음 세기

문자열에서 영어 모음(a, e, i, o, u)의 개수를 셉니다. 대소문자를 모두 포함합니다.

pub fn count_vowels(s: &str) -> usize {
    let vowels = ['a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U'];
    let mut vowel_count: usize = 0;
    for ch in s.chars() {
        if vowels.contains(&ch) {
            vowel_count += 1;
        }
    }
    vowel_count
}

.chars()로 문자 단위 순회 후, 배열의 .contains()로 모음 여부를 판별합니다.

Tip: 이터레이터 체이닝으로도 작성할 수 있습니다:

s.chars().filter(|c| "aeiouAEIOU".contains(*c)).count()

연습 5: 콜라츠 추측

n이 1이 될 때까지의 단계 수를 반환합니다. 짝수이면 n/2, 홀수이면 3n+1.

pub fn collatz_steps(n: u32) -> u32 {
    let mut number = n;
    let mut count = 0;
    while number != 1 {
        if number % 2 == 0 {
            number /= 2;
        } else {
            number = 3 * number + 1;
        }
        count += 1;
    }
    count
}

예시: 6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1 = 8단계. 모든 자연수가 결국 1에 도달하는지는 아직 증명되지 않은 유명한 수학 문제입니다.

Ch.05 – 소유권 (Ownership)

Rust의 가장 핵심적인 개념입니다. 가비지 컬렉터 없이 메모리 안전성을 보장하는 비결이죠.

  • 각 값은 하나의 소유자(owner) 만 가집니다.
  • 소유자가 스코프를 벗어나면 값은 자동으로 해제(drop) 됩니다.
  • 변수를 다른 변수에 대입하면 소유권이 이동(move) 됩니다.
  • .clone()으로 깊은 복사를 할 수 있습니다.
  • 스택에 저장되는 타입(i32 등)은 Copy 트레이트로 자동 복사됩니다.

연습 1: 소유권 이동과 반환

String의 소유권을 받아서 그대로 반환합니다.

pub fn take_and_return(s: String) -> String {
    s
}

함수에 String을 넘기면 소유권이 이동합니다. 호출 후 원래 변수는 사용할 수 없고, 반환값을 다시 받아야 합니다.

let s = String::from("hello");
let s2 = take_and_return(s);
// s는 여기서 더 이상 사용 불가 — 소유권이 이동됨
// s2로 다시 받았으므로 s2를 사용

연습 2: 첫 번째 단어의 끝 위치

문자열에서 첫 공백의 위치를 찾습니다. 공백이 없으면 전체 길이를 반환합니다.

pub fn first_word_end(s: &String) -> usize {
    let mut find = 0;
    for ch in s.chars() {
        if ch == ' ' {
            break;
        }
        find += 1;
    }
    find
}

&String으로 빌려 받아 소유권을 가져가지 않습니다. .chars()로 문자 단위 순회하며 공백을 찾습니다.

Tip: 더 간결한 방법도 있습니다:

s.find(' ').unwrap_or(s.len())

연습 3: clone과 수정

원본을 복제한 뒤 " world"를 추가합니다. 원본은 변경되지 않습니다.

pub fn clone_and_modify(s: &String) -> String {
    let mut result = String::from(s);
    result += " world";
    result
}

String::from(s)로 새로운 String을 만들어 수정합니다. &String(불변 참조)으로 받았으므로 원본에는 영향이 없습니다.


연습 4: Move semantics

Vec을 생성하고, 다른 변수로 이동한 뒤, 값을 추가하고 반환합니다.

pub fn move_semantics() -> Vec<i32> {
    let mut result: Vec<i32> = vec![];
    result.extend(vec![1, 2]);

    let mut result2 = result;
    // result는 여기서부터 사용 불가 — 소유권이 result2로 이동됨

    result2.push(3);
    result2
}

let mut result2 = result; 시점에서 소유권이 이동합니다. 이후 result를 사용하면 컴파일 에러가 발생합니다. 힙에 할당된 데이터(Vec, String 등)는 대입 시 복사되지 않고 이동합니다.

Ch.06 – 참조와 빌림 (References & Borrowing)

소유권을 넘기지 않고 값을 사용하는 방법입니다.

  • &T불변 참조(shared reference): 읽기만 가능, 동시에 여러 개 가능
  • &mut T가변 참조(mutable reference): 수정 가능, 동시에 하나만 가능
  • &[T], &str슬라이스: 컬렉션의 일부분을 참조하는 뷰
  • 참조는 항상 유효한 데이터를 가리켜야 합니다 (댕글링 참조 금지)

연습 1: 불변 참조

문자열 슬라이스의 길이를 반환합니다. 소유권을 가져가지 않습니다.

pub fn string_length(s: &str) -> usize {
    s.len()
}

&str로 빌려 받으므로 호출 후에도 원본을 계속 사용할 수 있습니다. .len()바이트 수를 반환합니다 — 한글 "안녕"은 6바이트(UTF-8에서 한 글자당 3바이트)입니다.


연습 2: 가변 참조

가변 참조를 통해 벡터에 값을 추가합니다.

pub fn push_value(v: &mut Vec<i32>, val: i32) {
    v.push(val)
}

&mut Vec<i32>로 받으면 벡터를 직접 수정할 수 있습니다. 가변 참조는 동시에 하나만 존재할 수 있으며, 불변 참조와도 동시에 존재할 수 없습니다.

let mut v = vec![1, 2, 3];
push_value(&mut v, 4);  // &mut로 빌려줌
// v는 이제 [1, 2, 3, 4]

연습 3: 슬라이스에서 최대값

슬라이스를 순회하며 가장 큰 값을 찾습니다.

pub fn largest(list: &[i32]) -> i32 {
    let mut max = list[0];
    for item in list {
        if *item > max {
            max = *item;
        }
    }
    max
}

&[i32]슬라이스 타입으로, Vec<i32>나 배열의 일부/전체를 참조할 수 있습니다. for item in list에서 item&i32 타입이므로 *item으로 역참조합니다.


연습 4: 값 교환 (swap)

두 가변 참조의 값을 서로 교환합니다.

pub fn swap_values(a: &mut i32, b: &mut i32) {
    let temp = *a;
    *a = *b;
    *b = temp;
}

임시 변수를 사용한 고전적인 swap 패턴입니다. *a로 역참조하여 값을 읽고, *a = ...로 값을 씁니다.

Tip: 표준 라이브러리의 std::mem::swap(a, b)를 사용해도 됩니다.

Ch.07 – 구조체 (Structs)

관련된 데이터를 하나로 묶는 사용자 정의 타입입니다.

  • struct 키워드로 정의합니다.
  • impl 블록으로 메서드를 추가합니다.
  • &self — 불변 참조로 호출, &mut self — 가변 참조로 호출
  • 튜플 구조체 struct Color(u8, u8, u8) — 필드에 이름이 없는 간결한 형태

연습 1: 구조체 정의와 생성

Rectangle 구조체를 정의하고 생성 함수를 작성합니다.

pub struct Rectangle {
    pub width: f64,
    pub height: f64,
}

pub fn create_rect(width: f64, height: f64) -> Rectangle {
    Rectangle { width, height }
}

필드 이름과 매개변수 이름이 같으면 width: width 대신 width로 축약할 수 있습니다 (field init shorthand).


연습 2: 메서드 — 넓이 계산

impl 블록 안에 메서드를 정의합니다. &self로 구조체를 빌려 사용합니다.

impl Rectangle {
    pub fn area(&self) -> f64 {
        self.width * self.height
    }
}

&selfself: &Self의 축약형입니다. 소유권을 가져가지 않으므로 호출 후에도 구조체를 계속 사용할 수 있습니다.


연습 3: 정사각형 판별

impl Rectangle {
    pub fn is_square(&self) -> bool {
        self.width == self.height
    }
}

연습 4: 포함 가능 여부

selfother를 완전히 포함할 수 있는지 판별합니다.

impl Rectangle {
    pub fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

메서드의 매개변수로 같은 타입의 참조를 받을 수 있습니다. other는 불변 참조로 빌려 받습니다.


연습 5: 튜플 구조체 — 색상 믹싱

Color의 각 RGB 채널 평균을 계산합니다.

pub struct Color(pub u8, pub u8, pub u8);

pub fn mix(c1: &Color, c2: &Color) -> Color {
    let Color(r1, g1, b1) = c1;
    let Color(r2, g2, b2) = c2;

    fn arithmetic_mean(a: &u8, b: &u8) -> u8 {
        ((*a as u16 + *b as u16) / 2) as u8
    }

    Color(
        arithmetic_mean(r1, r2),
        arithmetic_mean(g1, g2),
        arithmetic_mean(b1, b2),
    )
}

u8(0~255)끼리 더하면 오버플로우가 발생할 수 있으므로, u16으로 캐스팅한 뒤 계산하고 다시 u8로 변환합니다. 튜플 구조체는 let Color(r, g, b) = c; 형태로 구조 분해가 가능합니다.

Ch.08 – 열거형과 패턴 매칭 (Enums & Pattern Matching)

열거형과 match는 Rust의 가장 강력한 기능 중 하나입니다.

  • enum — 여러 가지 변형(variant) 중 하나의 값을 가지는 타입
  • 각 변형은 데이터를 포함할 수 있습니다: Unit, Tuple, Struct 형태
  • match모든 변형을 빠짐없이 처리해야 하는 패턴 매칭
  • Option<T> — 값이 있을 수도(Some), 없을 수도(None) 있는 내장 열거형

연습 1: 방향 판별

수평 방향(East, West)이면 true를 반환합니다.

pub enum Direction {
    North,
    South,
    East,
    West,
}

pub fn is_horizontal(d: &Direction) -> bool {
    match d {
        Direction::East | Direction::West => true,
        _ => false
    }
}

| 패턴으로 여러 변형을 하나의 분기에서 처리할 수 있습니다. _는 나머지 모든 경우를 잡는 와일드카드입니다.

Tip: matches! 매크로를 사용하면 더 간결합니다:

matches!(d, Direction::East | Direction::West)

연습 2: 데이터 없는 열거형 — 동전 값

pub enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

pub fn coin_value(c: &Coin) -> u32 {
    match c {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25
    }
}

match는 모든 변형을 처리해야 합니다. 하나라도 빠지면 컴파일 에러가 발생합니다. 이것이 Rust가 버그를 컴파일 타임에 잡는 방법 중 하나입니다.


연습 3: 데이터를 포함하는 열거형 — 도형 넓이

pub enum Shape {
    Circle(f64),         // 반지름
    Rect(f64, f64),      // 가로, 세로
}

pub fn shape_area(s: &Shape) -> f64 {
    match s {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Rect(width, height) => width * height
    }
}

패턴 매칭에서 변형 안의 데이터를 바로 변수로 바인딩할 수 있습니다. Shape::Circle(r)에서 r은 반지름 값에 바인딩됩니다.


연습 4: match 가드 (guard)

숫자를 설명하는 문자열을 반환합니다.

pub fn describe_number(n: i32) -> &'static str {
    match n {
        x if x < 0 => "음수",
        0 => "영",
        _ => "양수",
    }
}

if x < 0 부분이 match 가드입니다. 패턴 매칭 후 추가 조건을 검사할 수 있습니다. 0은 리터럴 패턴으로 직접 매칭하고, 나머지는 _로 처리합니다.


연습 5: Option 반환 — 안전한 나눗셈

b가 0이면 None, 아니면 Some(a / b)를 반환합니다.

pub fn safe_divide(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None
    } else {
        Some(a / b)
    }
}

Option<T>은 Rust에서 null 대신 사용하는 타입입니다. 값이 없을 수 있는 상황을 타입 시스템으로 명시하여, null pointer exception 같은 런타임 에러를 방지합니다.

Ch.09 – 컬렉션 (Collections)

Rust의 주요 컬렉션 타입을 다룹니다.

  • Vec<T> — 크기가 가변인 배열. push, pop, remove, len
  • String — UTF-8 문자열. + 연산자, format!, push_str
  • HashMap<K, V> — 키-값 쌍의 해시 맵. insert, get, entry
  • HashSet<T> — 중복 없는 값의 집합
use std::collections::{HashMap, HashSet};

연습 1: Vec 기본 조작

Vec에 1, 2, 3을 넣고, 두 번째 요소를 제거합니다.

pub fn vec_operations() -> Vec<i32> {
    let mut result: Vec<i32> = vec![];

    result.push(1);
    result.push(2);
    result.push(3);

    result.remove(1);  // 인덱스 1의 요소(2)를 제거

    result  // [1, 3]
}

.remove(index)는 해당 인덱스의 요소를 제거하고, 뒤의 요소들을 앞으로 당깁니다. 반환값은 제거된 요소입니다.


연습 2: String 연결

"Hello", " ", "World"를 하나의 String으로 연결합니다.

pub fn string_operations() -> String {
    let hello = "Hello";
    let space = " ";
    let world = "World";

    format!("{}{}{}", hello, space, world)
}

format! 매크로는 소유권을 가져가지 않아서 편리합니다. + 연산자를 쓰면 왼쪽의 String 소유권이 이동하므로 주의가 필요합니다.


연습 3: HashMap으로 단어 세기

텍스트에서 각 단어의 출현 횟수를 셉니다.

pub fn word_count(text: &str) -> HashMap<String, usize> {
    let words = text.split_whitespace();
    let mut count_map: HashMap<String, usize> = HashMap::new();

    for word in words {
        *count_map.entry(String::from(word)).or_insert(0) += 1;
    }

    count_map
}

핵심은 entry().or_insert() 패턴입니다:

  1. entry(key) — 해당 키의 엔트리를 가져옵니다
  2. or_insert(0) — 키가 없으면 0을 삽입하고, 값의 가변 참조(&mut usize)를 반환합니다
  3. *참조 += 1 — 역참조하여 직접 값을 증가시킵니다

다른 언어처럼 get → +1 → put할 필요 없이 한 번에 처리합니다.


연습 4: 중복 제거 (순서 유지)

슬라이스에서 중복을 제거하되 원래 순서를 유지합니다.

pub fn unique_elements(v: &[i32]) -> Vec<i32> {
    let mut found: Vec<i32> = vec![];

    for el in v {
        if !found.contains(el) {
            found.push(*el)
        }
    }

    found
}

.contains()로 이미 추가된 값인지 확인합니다.

Tip: HashSet을 사용하면 조회가 O(1)이 되어 성능이 좋아집니다:

let mut seen = HashSet::new();
v.iter().filter(|x| seen.insert(**x)).copied().collect()

연습 5: 길이별 그룹화

단어들을 길이에 따라 HashMap으로 그룹화합니다.

pub fn group_by_length(words: &[&str]) -> HashMap<usize, Vec<String>> {
    let mut result: HashMap<usize, Vec<String>> = HashMap::new();

    for word in words {
        let length = word.len();
        result.entry(length)
            .or_insert(vec![])
            .push(word.to_string())
    }

    result
}

entry().or_insert(vec![])로 해당 길이의 벡터가 없으면 빈 벡터를 만들고, .push()로 바로 추가합니다. entry API의 강점이 잘 드러나는 패턴입니다.

Ch.10 – 에러 처리 (Error Handling)

Rust는 예외(exception) 대신 OptionResult 타입으로 에러를 처리합니다.

  • Option<T> — 값이 있으면 Some(T), 없으면 None
  • Result<T, E> — 성공이면 Ok(T), 실패이면 Err(E)
  • ? 연산자 — Result/Option의 에러를 간결하게 상위로 전파
  • unwrap_or, map, and_then 등 조합 메서드로 우아하게 처리

연습 1: Option — 첫 번째 짝수 찾기

슬라이스에서 첫 번째 짝수를 찾아 Option으로 반환합니다.

pub fn find_first_even(v: &[i32]) -> Option<i32> {
    for n in v {
        if n % 2 == 0 {
            return Some(*n);
        }
    }
    None
}

찾으면 Some(값), 못 찾으면 None을 반환합니다. 호출하는 쪽에서 matchif let으로 처리합니다.

Tip: 이터레이터로도 가능합니다:

v.iter().find(|n| *n % 2 == 0).copied()

연습 2: Result — 문자열 파싱

문자열을 i32로 파싱하고, 실패 시 에러 메시지를 포함한 Err를 반환합니다.

pub fn parse_number(s: &str) -> Result<i32, String> {
    str::parse::<i32>(s).map_err(|_| format!("파싱 실패: {}", s))
}

.map_err()Err 값을 변환합니다. 원래 parse()가 반환하는 ParseIntError를 사람이 읽기 좋은 String으로 바꿉니다.

parse_number("42")   // Ok(42)
parse_number("abc")  // Err("파싱 실패: abc")

연습 3: Result — 리스트 나눗셈

슬라이스의 모든 요소를 divisor로 나눈 결과를 반환합니다.

pub fn divide_list(nums: &[f64], divisor: f64) -> Result<Vec<f64>, String> {
    if divisor == 0.0 {
        Err(String::from("0으로 나눌 수 없습니다"))
    } else {
        Ok(nums.iter().map(|n| n / divisor).collect())
    }
}

0으로 나누기를 먼저 검사하고, 성공 시 Ok(변환된_벡터)를 반환합니다. 에러 체크를 앞에서 하는 early return 패턴입니다.


연습 4: unwrap_or — 기본값 처리

Option 값의 합을 반환합니다. None은 0으로 취급합니다.

pub fn unwrap_or_default_chain(a: Option<i32>, b: Option<i32>) -> i32 {
    a.unwrap_or(0) + b.unwrap_or(0)
}

unwrap_or(기본값)Some이면 안의 값을, None이면 기본값을 반환합니다. unwrap()과 달리 panic하지 않아 안전합니다.


연습 5: ? 연산자 — 에러 전파 체인

문자열을 파싱 → 2를 곱함 → 다시 String으로 변환합니다.

pub fn safe_chain(input: &str) -> Result<String, String> {
    let n = input.parse::<i32>().map_err(|e| e.to_string())?;
    Ok((n * 2).to_string())
}

? 연산자는 Ok면 값을 꺼내고, Err면 즉시 함수를 종료하며 에러를 반환합니다. match로 일일이 처리하는 것보다 훨씬 간결합니다.

// ? 없이 작성하면:
match input.parse::<i32>() {
    Ok(v) => Ok((2 * v).to_string()),
    Err(e) => Err(e.to_string())
}

Ch.11 – 트레이트 (Traits)

트레이트는 타입이 구현해야 하는 동작을 정의합니다. 다른 언어의 인터페이스와 유사합니다.

  • trait — 메서드 시그니처(또는 기본 구현)를 정의
  • impl Trait for Type — 특정 타입에 트레이트를 구현
  • 기본 구현 — 트레이트에서 기본 동작을 제공, 타입이 오버라이드 가능
  • 트레이트 바운드fn foo<T: MyTrait>(x: T) 형태로 제네릭에 제약

연습 1: 커스텀 트레이트 구현

Greet 트레이트를 정의하고 Korean 구조체에 구현합니다.

pub trait Greet {
    fn greet(&self) -> String;
}

pub struct Korean {
    pub name: String,
}

impl Greet for Korean {
    fn greet(&self) -> String {
        format!("안녕하세요, {}님!", self.name)
    }
}

트레이트를 구현하면 해당 타입에서 트레이트의 메서드를 호출할 수 있습니다:

let k = Korean { name: "철수".to_string() };
println!("{}", k.greet()); // "안녕하세요, 철수님!"

연습 2: 표준 트레이트 구현 — Display

fmt::Display를 구현하면 println!("{}", point) 형태로 출력할 수 있습니다.

use std::fmt;

pub struct Point {
    pub x: f64,
    pub y: f64,
}

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

Display는 사용자에게 보여줄 형태, Debug({:?})는 개발자용 디버그 형태입니다. Debug#[derive(Debug)]로 자동 구현할 수 있지만, Display는 직접 구현해야 합니다.


연습 3: 기본 구현과 오버라이드

Summary 트레이트에 기본 구현을 제공하고, 타입별로 오버라이드합니다.

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(자세히 보기...)")  // 기본 구현
    }
}

pub struct Article {
    pub title: String,
    pub content: String,
}

pub struct Tweet {
    pub username: String,
    pub text: String,
}

// Article은 기본 구현을 그대로 사용
impl Summary for Article {}

// Tweet은 오버라이드
impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("@{}: {}", self.username, self.text)
    }
}
article.summarize()  // "(자세히 보기...)"
tweet.summarize()    // "@rustlang: Rust is great!"

impl Summary for Article {}처럼 빈 블록을 쓰면 기본 구현이 사용됩니다.


연습 4: 트레이트 바운드

PartialOrd 바운드를 사용해 슬라이스에서 가장 큰 요소를 찾습니다.

pub fn largest_with_trait<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list.iter().skip(1) {
        if item > largest {
            largest = item
        }
    }

    largest
}

T: PartialOrd는 "T는 비교 가능해야 한다"는 제약입니다. 이 바운드가 없으면 item > largest에서 컴파일 에러가 발생합니다.

참조(&T)를 반환하므로 CopyClone 바운드가 필요 없습니다.

Ch.12 – 제네릭 (Generics)

코드 중복 없이 여러 타입에 대해 동작하는 코드를 작성합니다.

  • fn foo<T>(x: T)T는 타입 매개변수
  • struct Pair<T> — 제네릭 구조체
  • impl<T: Trait> Pair<T> — 특정 트레이트를 만족하는 T에 대해서만 메서드 구현
  • Rust의 제네릭은 단형화(monomorphization) 로 컴파일 타임에 구체 타입으로 변환되어 런타임 비용이 없습니다

연습 1: 제네릭 함수

슬라이스의 첫 번째 요소를 Option으로 반환합니다.

pub fn first<T>(list: &[T]) -> Option<&T> {
    if list.is_empty() {
        None
    } else {
        Some(&list[0])
    }
}

<T>는 어떤 타입이든 받을 수 있다는 뜻입니다. i32, String, 커스텀 구조체 등 모든 타입에 대해 동작합니다.

Tip: 표준 라이브러리의 list.first()와 동일한 기능입니다.


연습 2: 제네릭 구조체

같은 타입의 두 값을 가지는 Pair를 정의합니다.

pub struct Pair<T> {
    pub first: T,
    pub second: T,
}

impl<T> Pair<T> {
    pub fn new(first: T, second: T) -> Pair<T> {
        Pair { first, second }
    }
}

impl<T><T>는 "이 impl 블록이 제네릭이다"라는 선언입니다. 모든 T에 대해 new를 사용할 수 있습니다.


연습 3: 조건부 메서드 구현

PartialOrd + Copy를 만족하는 T에 대해서만 max 메서드를 제공합니다.

impl<T: PartialOrd + Copy> Pair<T> {
    pub fn max(&self) -> T {
        if self.first > self.second {
            self.first
        } else {
            self.second
        }
    }
}

T: PartialOrd + Copy는 두 가지 제약입니다:

  • PartialOrd — 비교 가능 (>, < 연산)
  • Copy — 값을 복사하여 반환 가능

Pair<String>Copy가 아니므로 .max()를 호출할 수 없습니다. 컴파일 타임에 잡아줍니다.


연습 4: 정렬된 슬라이스 병합

두 정렬된 슬라이스를 하나의 정렬된 Vec으로 병합합니다 (Merge Sort의 merge 단계).

pub fn merge_sorted<T: Ord + Clone>(a: &[T], b: &[T]) -> Vec<T> {
    let mut a_idx = 0;
    let mut b_idx = 0;
    let mut result: Vec<T> = vec![];

    while a_idx < a.len() && b_idx < b.len() {
        if a[a_idx] < b[b_idx] {
            result.push(a[a_idx].clone());
            a_idx += 1;
        } else {
            result.push(b[b_idx].clone());
            b_idx += 1;
        }
    }

    // 남은 요소 추가
    while a_idx < a.len() {
        result.push(a[a_idx].clone());
        a_idx += 1;
    }
    while b_idx < b.len() {
        result.push(b[b_idx].clone());
        b_idx += 1;
    }

    result
}

두 인덱스로 양쪽을 동시에 순회하며 작은 값부터 추가합니다. Ord는 전순서(total order) 비교, Clone은 슬라이스에서 값을 복제하기 위해 필요합니다.

Ch.13 – 라이프타임 (Lifetimes)

라이프타임은 참조가 유효한 범위를 나타냅니다. Rust가 컴파일 타임에 메모리 안전성을 보장하는 핵심 장치입니다.

  • 'a — 라이프타임 매개변수. 여러 참조의 유효 범위를 연결합니다
  • 컴파일러가 자동으로 추론할 수 없을 때 명시적으로 지정해야 합니다
  • 핵심 규칙: 출력 참조의 라이프타임은 반드시 입력 참조 중 하나와 연결되어야 합니다
  • 구조체가 참조를 필드로 가지면 라이프타임 매개변수가 필요합니다

연습 1: 더 긴 문자열 반환

두 문자열 슬라이스 중 더 긴 것을 반환합니다. 길이가 같으면 첫 번째를 반환합니다.

pub fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() >= s2.len() {
        s1
    } else {
        s2
    }
}

'a가 필요한 이유: 반환값이 s1일 수도, s2일 수도 있기 때문에 컴파일러가 어떤 참조의 수명을 따라야 하는지 알 수 없습니다. 'a로 "두 입력 중 더 짧은 수명만큼 유효하다"고 명시합니다.

// 라이프타임이 없으면 컴파일 에러:
// fn longer(s1: &str, s2: &str) -> &str  // ← 어느 참조를 따르지?

연습 2: 첫 번째 단어 슬라이스

문자열에서 첫 공백 전까지의 슬라이스를 반환합니다.

pub fn first_word_slice<'a>(s: &'a str) -> &'a str {
    let first_space_idx = s.find(' ');
    match first_space_idx {
        None => s,
        Some(idx) => &s[..idx]
    }
}

입력이 하나뿐이면 Rust의 라이프타임 생략 규칙(elision rules) 에 의해 'a를 생략할 수 있습니다:

// 이것도 동일하게 동작합니다:
pub fn first_word_slice(s: &str) -> &str { ... }

하지만 학습 목적으로 명시하는 것이 라이프타임 이해에 도움됩니다.


연습 3: 참조를 포함하는 구조체

구조체가 참조를 필드로 가지면 라이프타임을 명시해야 합니다.

pub struct Excerpt<'a> {
    pub text: &'a str,
}

pub fn make_excerpt<'a>(s: &'a str) -> Excerpt<'a> {
    let first_dot_idx = s.find('.');
    Excerpt {
        text: match first_dot_idx {
            None => s,
            Some(idx) => &s[..idx]
        }
    }
}

Excerpt<'a>는 "text 필드가 라이프타임 'a만큼 유효하다"는 의미입니다. Excerpt가 원본 문자열보다 오래 살 수 없다는 것을 컴파일러가 보장합니다.

let text = String::from("Rust is great. It is fast.");
let excerpt = make_excerpt(&text);
assert_eq!(excerpt.text, "Rust is great");
// text가 drop되면 excerpt.text도 사용 불가

연습 4: 가장 긴 공통 접두사

두 문자열의 공통 접두사를 슬라이스로 반환합니다.

pub fn longest_common_prefix<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    let mut idx = 0;
    let mut s1_chars = s1.chars();
    let mut s2_chars = s2.chars();

    loop {
        let (left, right) = (s1_chars.next(), s2_chars.next());
        match (left, right) {
            (Some(x), Some(y)) if x == y => {
                idx += x.len_utf8();  // 바이트 단위로 계산
            },
            _ => break
        }
    }

    &s1[..idx]
}

두 이터레이터를 동시에 순회하며 문자가 같은 동안 바이트 인덱스를 누적합니다. &s1[..idx]로 원본의 슬라이스를 반환하므로 새로운 String을 만들 필요가 없습니다.

longest_common_prefix("interstellar", "internet")  // "inter"
longest_common_prefix("abc", "xyz")                 // ""
longest_common_prefix("rust", "rusty")              // "rust"

주의: 바이트 슬라이싱(&s[..idx])에 문자 개수 대신 바이트 수를 사용해야 합니다. ASCII만 다룬다면 문자 수 = 바이트 수이지만, 멀티바이트 문자(한글 등)에서는 char::len_utf8()으로 바이트 수를 정확히 계산해야 합니다.

Ch.14 – 클로저와 이터레이터 (Closures & Iterators)

Rust의 함수형 프로그래밍 기능을 다룹니다.

  • 클로저|x| x + 1 형태의 익명 함수. 환경의 변수를 캡처할 수 있습니다
    • Fn — 불변 캡처
    • FnMut — 가변 캡처
    • FnOnce — 소유권 캡처 (한 번만 호출)
  • 이터레이터.iter(), .into_iter(), .iter_mut()로 생성
  • map, filter, fold, collect, sum 등의 어댑터를 체이닝
  • Iterator 트레이트를 직접 구현하여 커스텀 이터레이터 생성 가능

연습 1: 클로저를 인자로 받기

클로저 fx에 적용한 결과를 반환합니다.

pub fn apply<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
    f(x)
}

F: Fn(i32) -> i32는 "i32를 받아 i32를 반환하는 클로저"라는 트레이트 바운드입니다. Ch.03의 함수 포인터(fn(i32) -> i32)와 달리, Fn 트레이트는 환경을 캡처하는 클로저도 받을 수 있습니다.

let offset = 10;
apply(|x| x + offset, 5)  // 15 — offset을 캡처

연습 2: map으로 변환

슬라이스의 모든 요소를 2배로 만듭니다.

pub fn double_all(v: &[i32]) -> Vec<i32> {
    v.iter().map(|x| 2 * x).collect()
}

이터레이터 체이닝의 기본 패턴:

  1. .iter() — 이터레이터 생성 (각 요소의 참조 &i32)
  2. .map(|x| ...) — 각 요소를 변환
  3. .collect() — 결과를 Vec으로 수집

이터레이터는 지연 평가(lazy) 입니다. .collect()를 호출할 때까지 실제 연산이 수행되지 않습니다.


연습 3: map + sum

각 요소를 제곱한 뒤 합을 구합니다.

pub fn sum_of_squares(v: &[i32]) -> i32 {
    v.iter().map(|x| x * x).sum()
}
[1, 2, 3] → [1, 4, 9] → 14

.sum()Iterator 트레이트의 소비 어댑터(consuming adapter) 로, 이터레이터를 소모하며 합계를 반환합니다.


연습 4: filter + map 체이닝

짝수만 골라내고 문자열로 변환합니다.

pub fn filter_and_transform(v: &[i32]) -> Vec<String> {
    v.iter()
        .filter(|x| **x % 2 == 0)
        .map(|x| x.to_string())
        .collect()
}
[1, 2, 3, 4] → filter → [2, 4] → map → ["2", "4"]

.filter()의 클로저는 &&i32를 받습니다 (이터레이터가 &i32를 주고, filter가 다시 참조를 전달). 그래서 **x로 두 번 역참조합니다.


연습 5: 커스텀 이터레이터

Iterator 트레이트를 직접 구현하여 1부터 5까지 세는 카운터를 만듭니다.

pub struct Counter {
    count: u32,
}

impl Counter {
    pub fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        self.count += 1;
        if self.count > 5 {
            None
        } else {
            Some(self.count)
        }
    }
}

pub fn custom_counter() -> Vec<u32> {
    Counter::new().collect()  // [1, 2, 3, 4, 5]
}

Iterator 트레이트는 next() 하나만 구현하면 됩니다. map, filter, sum, collect 등 수십 개의 메서드가 자동으로 제공됩니다.

// 커스텀 이터레이터를 다른 어댑터와 조합
let sum: u32 = Counter::new().sum();           // 15
let doubled: Vec<u32> = Counter::new()
    .map(|x| x * 2)
    .collect();                                // [2, 4, 6, 8, 10]
let evens: Vec<u32> = Counter::new()
    .filter(|x| x % 2 == 0)
    .collect();                                // [2, 4]

대충 보니 빨간색으로 한 애들이 좀 생소한 애들이고 열거형/패턴매칭은 그거보단 쉬운데 많이 쓰는 애 인거 같음


이건 내가 대충 짜본 UP&DOWN 게임. 내가 15년전에 C를 처음 배웠을 때 맨처음으로 한 게 이거였음.

use std::error::Error;
use std::fmt::{Debug, Display, Formatter};
use rand::RngExt;

pub fn updown_game() {
    let answer = generate_answer();

    loop {
        match get_user_guess() {
            Ok(num) if (1..=100).contains(&num) => {
                if num == answer {
                    println!("Congratulations! You guessed the correct number.");
                    break;
                } else if num < answer {
                    println!("Too low! Try again.");
                } else {
                    println!("Too high! Try again.");
                }
            }
            Ok(_) => println!("Invalid input. Please enter a number between 1 and 100."),
            Err(err) => eprintln!("{}", err),
        }
    }
}

enum GetUserGuessError {
    IoError(std::io::Error),
    ParseIntError(std::num::ParseIntError),
}

impl Error for GetUserGuessError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            GetUserGuessError::IoError(err) => Some(err),
            GetUserGuessError::ParseIntError(err) => Some(err),
        }
    }
}

impl From<std::io::Error> for GetUserGuessError {
    fn from(err: std::io::Error) -> Self {
        GetUserGuessError::IoError(err)
    }
}

impl From<std::num::ParseIntError> for GetUserGuessError {
    fn from(err: std::num::ParseIntError) -> Self {
        GetUserGuessError::ParseIntError(err)
    }
}

impl Debug for GetUserGuessError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            GetUserGuessError::IoError(err) => write!(f, "IoError: {}", err),
            GetUserGuessError::ParseIntError(err) => write!(f, "ParseIntError: {}", err),
        }
    }  
}

impl Display for GetUserGuessError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        (self as &dyn Debug).fmt(f)
    }
}

fn get_user_guess() -> Result<u32, GetUserGuessError> {
    println!("Enter your guess: ");
    let mut guess = String::new();
    std::io::stdin().read_line(&mut guess).map_err(GetUserGuessError::IoError)?;
    let result = guess.trim().parse::<u32>().map_err(GetUserGuessError::ParseIntError)?;
    Ok(result)
}

fn generate_answer() -> u32 {
    rand::rng().random_range(1..=100)
}

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

    #[test]
    fn test_generate_answer() {
        let answer = generate_answer();
        assert!((1..=100).contains(&answer));
    }
}

그래도 트레이트나 패턴매칭 같은건 잘 들어가있음.

그리고 생소한게 ?연산자인데 Result<T, E>나 Option<T>에 붙여서 쓸 수 있다.

반환형이 Result나 Option일 때 같은 Result나 Option의 뒤에 붙어서 Err나 None일때 그 즉시 반환을 해버린다.

섞어서 쓰려면 서로 변환 후 사용해야하는데 Option -> Result는 ok_or(Err), Result -> Option은 ok()를 사용한다고 한다.

답글 남기기

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