[rust] 2. 추리 게임

About_work·2024년 5월 30일
0

rust

목록 보기
4/16
use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

0. 들어가기 전에

  • 실습 프로젝트를 통해 러스트를 사용해 봅시다.
  • 이 과정에서 let, match, 메서드, 연관 함수 (associated functions), 외부 크레이트 (external crates) 등의 활용 방법을 배울 수 있습니다.
    • 이런 개념들은 다음 장들에서 더 자세히 다뤄질 것입니다.

  • 여기서는 고전적인 입문자용 프로그래밍 문제인 추리 게임을 구현해 보려 합니다.
    • 먼저 프로그램은 1~100 사이에 있는 임의의 정수를 생성합니다.
    • 다음으로 플레이어가 프로그램에 추리한 정수를 입력합니다.
    • 프로그램은 입력받은 추릿값이 정답보다 높거나 낮음을 알려줍니다.
  • 추릿값이 정답이라면 축하 메시지를 보여주고 종료됩니다.

2. 추릿값을 처리하기

// 사용자 입력을 받고 결괏값을 표시하기 위해서는
// io 입출력 라이브러리를 스코프로 가져와야 합니다.
// io 라이브러리는 std라고 불리는 표준 라이브러리에 있습니다: 
use std::io;

// main 함수는 프로그램의 진입점
fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}
  • 러스트(Rust) 프로그래밍 언어에서는 모든 프로그램이 시작될 때 자동으로 몇 가지 유용한 기능들을 사용할 수 있도록 해줍니다.
    • 이런 기능들은 표준 라이브러리에 포함되어 있으며, 특히 "프렐루드(prelude)"라고 불리는 부분에 모여 있습니다.
  • 프렐루드(prelude)에는 자주 사용되는 타입들과 기능들이 미리 정의되어 있어서, 프로그래머가 매번 이들을 수동으로 불러오지 않아도 됩니다.
  • 만약 여러분이 원하는 타입이 프렐루드에 없다면, use문을 활용하여 명시적으로 그 타입을 가져와야 합니다.

  • fn 문법은 새로운 함수를 선언하며, 괄호 ()는 매개변수가 없음을 나타내고 중괄호 {는 함수 본문의 시작을 나타냅니다.

3. 변수에 값 저장하기

  • 변수를 만드는 데에는 let 구문을 사용
  • let mut guess = String::new();
    • String::new의 결괏값인 새로운 String 인스턴스가 묶일 대상이 됩니다.
    • String은 표준 라이브러리에서 제공하는 확장 가능한 (growable) UTF-8 인코딩의 문자열 타입
  • String::new에 있는 ::는 new가 String 타입의 연관 함수 (associated function) 임을 나타냄
    • 연관 함수란 어떤 타입에 구현된 함수고, 위의 경우에는 String 타입에 만들어진 함수
  • 이 new 함수는 비어있는 새 문자열을 생성
    • new는 어떤 새로운 값을 만드는 함수 이름으로 흔히 사용되는 이름이기 때문에, 여러 타입에서 new 함수를 찾아볼 수 있을 겁니다.

4. 사용자 입력 받기

    io::stdin()
        .read_line(&mut guess)
  • 프로그램 시작 지점에서 use std::io를 통해 io 라이브러리를 가져오지 않았더라도, 함수 호출 시 std::io::stdin처럼 작성하는 것으로 이 함수를 이용할 수 있습니다.
  • stdin 함수는 터미널의 표준 입력의 핸들 (handle) 을 나타내는 타입인 std::io::Stdin의 인스턴스를 돌려줍니다.
  • 코드의 다음 부분인 .read_line(&mut guess)는 사용자로부터 입력받기 위해 표준 입력 핸들에서 read_line 메서드를 호출합니다.
  • 여기에 &mut guess를 read_line의 인수로 전달하여 사용자 입력이 어떤 문자열에 저장될 것인지 알려줍니다.
  • read_line의 전체 기능은 사용자가 표준 입력 장치에 입력할 때마다 입력된 문자들을 받아서 문자열에 추가하는 것이므로 문자열을 인수로 넘겨준 것입니다.
  • 메서드가 문자열의 내용물을 바꿀 수 있기 때문에 이 문자열 인수는 가변이어야 합니다.

  • &는 코드의 여러 부분에서 데이터를 여러 번 메모리로 복사하지 않고 접근하기 위한 방법을 제공하는 참조자 (reference) 임을 나타냅니다.
    • 참조는 복잡한 기능이고, 러스트의 큰 이점 중 하나가 바로 참조자를 사용할 때의 안전성과 편의성입니다.
  • 이 프로그램을 작성하기 위해 참조에 대한 자세한 내용을 알 필요는 없습니다.
    • 지금 당장은 참조자&가 변수처럼 기본적으로 불변임을 알기만 하면 됩니다.
  • 따라서 &guess가 아니라 &mut guess로 작성하여 가변으로 만들 필요가 있습니다. (4장에서 참조자에 대해 전체적으로 설명할 것입니다.)

5. Result 타입으로 잠재적 실패 다루기

  • .method_name() 문법으로 어떤 메서드를 호출할 때는 줄 바꿈과 다른 공백문자로 긴 라인을 쪼개는 것이 보통 현명한 선택입니다.
  • 이전에 언급한 것처럼 read_line은 우리가 인수로 넘긴 문자열에 사용자가 입력한 것을 저장할 뿐만 아니라 하나의 Result 값을 돌려줍니다.
    • Result는 enum이라고도 일컫는 열거형 (enumeration)인데, 여러 개의 가능한 상태(variant) 중 하나의 값이 될 수 있는 타입입니다.
    • 이러한 가능한 상태 값을 배리언트 (variant) 라고 부릅니다.
  • Result의 가능한 상태 값(variant)는 Ok와 Err입니다.
    • Ok는 처리가 성공했음을 나타내며 내부에 성공적으로 생성된 결과를 가지고 있습니다.
    • Err는 처리가 실패했음을 나타내고 그 이유에 대한 정보를 가지고 있습니다.
  • 다른 타입들처럼 Result 타입의 값에도 메서드가 있습니다. Result 인스턴스에는 expect 메서드가 있습니다.
    • 만약 Result 인스턴스가 Err일 경우 expect 메서드는 프로그램의 작동을 멈추고 expect에 인수로 넘겼던 메시지를 출력하도록 합니다.
      • 만약 read_line 메서드가 Err를 돌려줬다면 그 에러는 운영체제로부터 발생한 에러일 경우가 많습니다.
    • 만약 Result가 Ok 값이라면 expect는 Ok가 가지고 있는 결괏값을 돌려주어 사용할 수 있도록 합니다. 위의 경우 결괏값은 사용자가 표준 입력으로 입력했던 바이트의 개수입니다.
  • 만약 expect를 호출하지 않는다면 컴파일은 되지만 경고가 나타납니다.
    • 러스트는 read_line가 돌려주는 Result 값을 사용하지 않았음을 경고하며 일어날 수 있는 에러를 처리하지 않았음을 알려줍니다.
    • 이 경고를 없애는 옳은 방법은 에러 처리용 코드를 작성하는 것이지만, 지금의 경우에는 문제가 발생했을 때 프로그램이 종료되는 것을 원하므로 expect를 사용할 수 있습니다.

6. println! 자리표시자(placeholder)를 이용한 값 출력하기

    println!("You guessed: {guess}");
  • {}는 자리표시자 (placeholder) 입니다:
    • {}를 어떤 위치에 값을 자리하도록 하는 작은 집게발이라고 생각하면 됩니다.
    • 어떤 변수의 값을 출력할 때라면 해당 변수 이름을 이 중괄호 안에 넣을 수 있습니다.
let x = 5;
let y = 10;

println!("x = {x} and y + 2 = {}", y + 2);

\\ x = 5 and y + 2 = 12

8. 비밀번호를 생성하기

  • 러스트는 아직 표준 라이브러리에 임의의 값을 생성하는 기능을 포함시키지 않았습니다.
  • 하지만 러스트 팀에서는 해당 기능을 가지고 있는 rand 크레이트 (crate)를 제공

9. 크레이트를 사용하여 더 많은 기능 가져오기

  • 크레이트는 러스트 코드 파일들의 모음이라는 점을 기억하세요.
  • 우리가 만들고 있는 프로젝트는 실행이 가능한 바이너리 크레이트 (binary crate)
  • rand 크레이트는 자체적으로 실행될 수는 없고 다른 프로그램에서 사용되기 위한 용도인 라이브러리 크레이트 (library crate)

  • 카고의 외부 크레이트 조정 기능은 정말 멋진 부분
  • rand를 사용하는 코드를 작성하기 전에 Cargo.toml 을 수정하여 rand 크레이트를 의존성으로 추가해야 합니다.
  • [dependencies] 섹션 에서는 여러분의 프로젝트가 의존하고 있는 외부 크레이트와 각각의 요구 버전을 카고에게 알려주게 됩니다.
  • 지금의 경우에는 rand 크레이트의 유의적 버전인 0.8.5을 지정했습니다.
  • 카고는 버전 명시의 표준인 (종종 SemVer라고 불리는) 유의적 버전 (Semantic Versioning)을 이해합니다.
  • 지정자 0.8.5는 실제로는 ^0.8.5의 축약형인데, 이는 최소 0.8.5 이상이지만 0.9.0 아래의 모든 버전을 의미
  • 카고는 이 버전들이 0.8.5와 호환되는 공개 API를 갖추고 있다고 간주하며, 이러한 버전 지정은 이 장의 코드와 함께 컴파일이 되도록 하는 최신 패치판을 받게 될 것임을 보장합니다.
  • 0.9.0 버전 혹은 그 이상의 버전은 이후의 예제에서 사용할 때 동일한 API가 있음을 보장하지 못합니다.
# Cargo.toml

[dependencies]
rand = "0.8.5"
  • 외부 의존성을 포함시키게 되면, 카고는 Crates.io로부터 데이터의 복사본인 레지스트리 (registry) 에서 해당 의존성이 필요로 하는 모든 것들의 최신 버전을 가져옵니다.
    • Crates.io는 러스트 생태계의 개발자들이 다른 사람들도 이용할 수 있도록 러스트 오픈 소스를 공개하는 곳
  • 레지스트리를 업데이트한 후 카고는 [dependencies] 섹션을 확인하고 아직 다운로드하지 않은 크레이트들을 다운로드합니다.
  • 지금의 경우에는 rand만 의존한다고 명시했지만 카고는 rand가 동작하기 위해 의존하고 있는 다른 크레이트들도 가져옵니다.
  • 이것들을 다운로드한 후 러스트는 이들을 컴파일한 다음, 사용 가능한 의존성과 함께 프로젝트를 컴파일합니다.

10. Cargo.lock으로 재현 가능한 빌드 보장하기

  • Cargo.lock은 처음 프로젝트를 빌드(cargo build) 할 때 생성됨. (의존성의 버전들을 기록)
  • 카고는 여러분뿐만이 아니라 다른 누구라도 여러분의 코드를 빌드할 경우 같은 산출물이 나오도록 보장하는 방법을 가지고 있습니다:
    • 카고는 여러분이 다른 의존성을 추가하지 전까지는 여러분이 명시한 의존성을 사용합니다. (매우 중요!!)
    • 예를 들어 rand 크레이트의 다음 버전인 0.8.6에서 중요한 버그가 고쳐졌지만, 여러분의 코드를 망치는 변경점 (regression) 이 있다고 칩시다.
    • 이러한 문제를 해결하기 위해서 러스트는 여러분이 cargo build를 처음 실행할 때 Cargo.lock 파일을 생성하여 guessing_game 디렉터리에 가지고 있습니다.

  • 여러분이 처음 프로젝트를 빌드할 때, **카고는 기준을 만족하는 모든 의존성의 버전을 확인하고 Cargo.lock 에 이를 기록합니다.
  • 매우 중요: 나중에 프로젝트를 빌드하게 되면 카고는 모든 버전을 다시 확인하지 않고 Cargo.lock 파일이 존재하는지 확인하여 그 안에 명시된 버전들을 사용
    • 이는 자동적으로 재현 가능한 빌드를 가능하게 해줍니다.
    • 즉 Cargo.lock 덕분에 여러분의 프로젝트는 여러분이 명시적으로 업그레이드하지 않는 이상 0.8.5를 이용합니다.
    • Cargo.lock 파일은 재현 가능한 빌드를 위해 중요하기 때문에, 흔히 프로젝트의 코드와 함께 소스 제어 도구에서 확인됩니다.

11. 크레이트를 새로운 버전으로 업데이트하기

  • 만약 여러분이 정말 크레이트를 업데이트하고 싶은 경우를 위해 카고는 update 명령어를 제공합니다.
  • update 명령은 Cargo.lock 파일을 무시하고 Cargo.toml 에 여러분이 명시한 요구사항에 맞는 최신 버전을 확인합니다.
  • 확인되었다면 카고는 해당 버전을 Cargo.lock에 기록합니다.
  • 하지만 카고는 기본적으로 0.8.5보다 크고 0.9.0보다 작은 버전을 찾을 것입니다.
  • 만약 rand 크레이트가 새로운 버전 0.8.6과 0.9.0 두 가지를 배포했다면 여러분이 cargo는 0.9.0 버전을 무시합니다.

12. 임의의 숫자 생성하기

use std::io;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

  • 첫 번째 라인에서는 우리가 사용할 특정 난수 생성기를 제공하는 rand::thread_rng 함수를 호출합니다:
    • OS가 시드 (seed) 를 정하고 현재 스레드에서만 사용되는 난수 생성기
  • 다음으로는 gen_range 메서드를 호출합니다.
    • 이 메서드는 use rand::Rng; 구문을 통해 스코프로 가져온 Rng 트레이트에 정의되어 있습니다.
    • gen_range 메서드는 범위 표현식을 인수로 받아서 해당 범위 내 임의의 숫자를 생성
    • 여기서 사용하고자 하는 범위 표현식은 start..=end이고 이는 상한선과 하한선을 포함하므로,
      • 1부터 100 사이의 숫자를 생성하려면 1..=100이라고 지정해야 합니다.

  • Note:
    • 어떤 크레이트에서 어떤 트레이트를 사용할지, 그리고 어떤 메서드와 함수를 호출해야 할지 모를 수도 있으므로, 각 크레이트는 사용법을 담고 있는 문서를 갖추고 있습니다.
    • 카고의 또 다른 멋진 기능에는 cargo doc --open 명령어를 사용하여 의존하는 크레이트의 문서를 로컬에서 모두 빌드한 다음,
      • 브라우저에서 열어주는 기능이 있습니다.
    • 예를 들어 rand 크레이트의 다른 기능이 궁금하다면, cargo doc --open을 실행하고, 왼쪽 사이드바에서 rand를 클릭해 보세요.

13. 비밀번호와 추릿값을 비교하기

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    // --생략--

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}
  • 먼저 use를 구문을 하나 더 사용하여 표준 라이브러리로부터 std::cmp::Ordering이라는 타입을 가져옵니다.
    • Ordering은 열거형이고 Less, Greater, Equal이라는 배리언트들을 가지고 있습니다.
    • 이들은 여러분이 어떤 두 값을 비교할 때 나올 수 있는 세 가지 결과입니다.

  • 그런 다음에는 Ordering 타입을 이용하는 다섯 줄을 끝부분에 추가했습니다.
    • cmp 메서드는 두 값을 비교하며, 비교 가능한 모든 것들에 대해 호출
    • 이 메서드는 비교하고 싶은 값들의 참조자를 받습니다:
      • 여기서는 guess와 secret_number를 비교하고 있습니다.
    • cmp는 Ordering 열거형을 돌려줍니다.
      • match 표현식을 이용하여 cmp가 guess와 secret_number를 비교한 결과인 Ordering의 값에 따라 무엇을 할 것인지 결정합니다.

  • match 표현식은 갈래 (arm) 들로 이루어져 있습니다.
    • 하나의 갈래는 하나의 패턴과 match 표현식에서 주어진 값이 패턴과 맞는다면 실행할 코드로 이루어져 있습니다.
    • 러스트는 match에 주어진 값을 갈래의 패턴에 맞는지 순서대로 확인합니다.
    • match 생성자와 패턴들은 여러분의 코드가 마주칠 다양한 상황을 표현할 수 있도록 하고 모든 경우의 수를 처리했음을 확신할 수 있도록 도와주는 강력한 특성들입니다.

  • 여기서 사용된 match 표현식에서 무슨 일이 일어나는지 예를 들어 살펴봅시다. 사용자가 50을 예측했고 임의로 생성된 비밀번호는 38이라 칩시다.

  • 50과 38을 비교하면 cmp 메서드의 결과는 Ordering::Greater입니다. match 표현식은 Ordering::Greater 값을 받아서 각 갈래의 패턴을 확인합니다. 처음으로 마주하는 갈래의 패턴인 Ordering::Less는 Ordering::Greater와 매칭되지 않으므로 첫 번째 갈래는 무시하고 다음으로 넘어갑니다. 다음 갈래의 패턴인 Ordering::Greater는 확실히 Ordering::Greater와 매칭됩니다! 이 갈래와 연관된 코드가 실행될 것이고 Too big!가 출력될 것입니다.

    // --생략--

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
  • 위 코드에서, let guess: u32 = guess.trim().parse().expect("Please type a number!"); 가 추가됨
  • 위 guess 재정의를 shadow라고 하고, type을 바꿀 수 있다.
  • String 인스턴스의 trim 메서드는 처음과 끝부분의 공백문자들을 제거하는데, 이는 숫자형 데이터만 저장할 수 있는 u32와 문자열을 비교할 수 있게 하기 위해 꼭 필요합니다. 사용자들은 추릿값을 입력한 뒤 read_line을 끝내기 위해 enter키를 반드시 눌러야 하고, 이것이 개행문자를 문자열에 추가시킵니다. 예를 들어 사용자가 5를 누르고 enter키를 누르면 guess는 5\n처럼 됩니다. \n은 ‘새로운 라인’을 나타냅니다. trim 메서드는 \n을 제거하고 5만 남도록 처리합니다.
  • 문자열의 parse 메서드는 문자열을 다른 타입으로 바꿔줍니다. 여기서는 문자열을 숫자로 바꾸는 데 사용합니다. let guess: u32를 사용하여 필요로 하는 정확한 숫자 타입을 러스트에 알려줄 필요가 있습니다. guess 뒤의 콜론(:)은 변수의 타입을 명시했음을 의미합니다.
  • parse 메서드의 호출은 에러가 발생하기 쉽습니다. 예를 들어 A👍%과 같은 문자열이 포함되어 있다면 정수로 바꿀 방법이 없습니다. parse 메서드는 실패할 수도 있으므로, ‘Result 타입으로 잠재적 실패 다루기’에서 다루었던 read_line와 마찬가지로 Result 타입을 반환합니다. 이 Result는 expect 메서드를 사용하여 같은 방식으로 처리하겠습니다. 만약 parse 메서드가 문자열로부터 정수를 만들어 낼 수 없어 Err Result 배리언트를 반환한다면, expect 호출은 게임을 멈추고 제공한 메시지를 출력합니다. 만약 parse 메서드가 성공적으로 문자열을 정수로 바꾸었다면 Result의 Ok 배리언트를 돌려받으므로 expect에서 Ok에서 얻고 싶었던 값을 결과로 받게 됩니다.

15. 정답을 맞힌 후 종료하기

    // --생략--

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        // --생략--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

16. 잘못된 입력값 처리하기

        // --생략--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --생략--
  • expect 메서드 호출을 match 표현식으로 바꾸어 에러 발생 시 즉시 종료가 아닌 에러 처리로 바꾸었습니다.
  • parse 메서드가 Result 타입을 반환한다는 점, 그리고 Result는 Ok나 Err 배리언트를 가진 열거형임을 기억하세요.
  • 만약 parse가 성공적으로 문자열을 정수로 변환할 수 있으면 결괏값이 들어있는 Ok를 반환합니다. 이 Ok 값은 첫 번째 갈래의 패턴에 매칭되고, match 표현식은 parse가 생성하여 Ok 값 안에 넣어둔 num 값을 반환합니다. 그 값은 새로 만들고 있는 guess 변수에 바로 위치될 것입니다.
  • 만약 parse가 문자열을 정수로 바꾸지 못한다면, 에러에 대한 더 많은 정보를 담은 Err를 반환합니다.
  • Err는 첫 번째 갈래의 패턴인 Ok(num)과는 매칭되지 않지만, 두 번째 갈래의 Err(_)와는 매칭됩니다.
  • 밑줄 _은 캐치올 (catchall) 값이라 합니다;
    • 모든 값에 매칭될 수 있으며, 이 예제에서는 Err내에 무슨 값이 있던지 상관없이 모든 Err를 매칭하도록 했습니다.
    • 따라서 프로그램은 두 번째 갈래의 코드인 continue를 실행하며, 이는 loop의 다음 반복으로 가서 또 다른 추릿값을 요청하도록 합니다.
    • 따라서, 프로그램은 효과적으로 parse에서 발생할 수 있는 모든 에러를 무시합니다!
profile
새로운 것이 들어오면 이미 있는 것과 충돌을 시도하라.

0개의 댓글