[Rust] 04 추리게임

후유카와·2024년 12월 2일

Rust

목록 보기
4/14

추리 게임

실습 프로젝트를 통해 일반적인 러스트 개념이 어떻게 활용되는지 살펴보자.

다룰 내용

let, match, 메서드, 연관 함수, 외부 크레이트(external crates) 등의 활용 방법

프로그램 개요

  1. 추리 게임은 1 ~ 100 사이에 있는 임의의 정수를 생성한 후, 플레이어가 프로그램에서 추리한 정수를 입력한다.
  2. 프로그램은 입력받은 추릿값이 정답보다 높거나 낮음을 알려준다.
  3. 추릿값이 정답이라면 축하 메시지를 보여주고 종료한다.

새 프로젝트 준비

이번 프로젝트는 cargo를 이용할 것이다.

$ cargo new guessing_game
$ cd guessing_game

첫 번째 명령어는 guessing_game이라는 프로젝트를 생성한다.
두 번째 명령어는 프로젝트 파일로 이동한다.

앞에서 보았듯이 cargo run을 해보면 Hello, world!를 출력하는 기본 프로그램이 작성되어 있다.

추릿값 처리

프로그램의 첫 부분에서는 사용자 입력 요청, 입력값의 처리 후 입력값이 개대하던 형식인지 검증해야 한다.

use std::io;

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}");
}

여기서 살펴봐야 할 점이 몇 가지 있다.
C/C++에서는 라이브러리를 불러올 때 import를 사용한다.
하지만, rust에서는 use를 사용한다.

프렐루드(Prelude)

기본적으로 러스트는 모든 프로그램의 스코프로 가져오는 표준라이브러리에 정의된 아이템 집합을 가지고 있는데, 이 집합을 프렐루드라고 한다.

원하는 타입이 프렐루드에 없다면 use문을 사용해 명시적으로 타입을 가져와야 한다.

이 프로그램의 use std::io에서는 입출력과 관련된 기능을 제공한다.

변수에 값 저장

입력값을 저장할 변수(variable)를 생성하는데, 특이한 점이 있다.
mut라는 키워드가 들어가있는데 이는 가변(mutable) 변수라는 것을 의미한다.

러스트에서는 기본적으로 불변(immutable) 변수로 선언되는데, 이 말은 한 번 값을 쓰면 그 뒤로는 값을 안 바꿀 것이다라는 뜻이다.

만약, 러스트에서 값을 언제나 바꿀 수 있는 변수로 선언하려면 mut 키워드를 사용해야 한다.

let apples = 5; // 불변
let mut bananas = 5; // 가변

여기서는 let mut guessString::new로 선언이 되어 있고, 이 뜻은 guess는 새로운 String 인스턴스가 묶인다는 뜻이다.

사용자 입력 받기

그 다음 줄은

io::stdin().read_line(&mut guess)

로 되어 있는데, 먼저 stdin은 입력을 받는다는 뜻인데, 여기서 &는 reference parameter이고 기본적으로 불변이다.
하지만, guess는 가변 변수이기 때문에 &를 가변으로 바꿔야 한다.

그래서 read_line에 &mut guess가 들어갔다.

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

.으로 연결된 함수들은 사실 한 줄로 이어서 쓸 수 있지만, 가독성을 위해 줄을 구분해 쓰는 것이 좋다.

read_lineResult값을 반환하는데, Result는 enum이라고 하는 열거형이다.
즉, 여러 개의 가능한 상태 중 하나의 값이 될 수 있는 타입을 의미하고 이런 가능한 상태 값을 variant라고 한다.

ResultvariantOk 또는 Err이다. Ok는 처리가 성공했음을 의미하고 Err는 처리에 실패했고 예외를 발생시킨다.
except 메서드는 인수로 넘겨 받은 메시지를 예외가 발생했을 때 출력되도록 한다.

물론 except 처리를 안하더라도 프로그램은 컴파일되지만 컴파일러가 경고해주고 올바르게 작성된 코드라고 보기 어려워진다.

println! 자리표시자

마지막 줄에서는 {}가 있는 것을 볼 수 있는데 이것을 자리표시자(placeholder)라고 한다.
{}를 어떤 위치에 값을 자리하도록 하는 작은 집게발이라고 생각하면 쉽다.

어떤 것은 {} 안에 넣고, 어떤 것은 C언어처럼 쓰기도 한다.

첫 번째 경우는 변수의 값을 그대로 출력할 때 사용하고,
두 번째 경우는 어떤 식 연산의 결과를 출력할 때 사용한다.(이때 연산을 표현식 이라고 한다.)

let x = 5;
let y = 10;

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

이 코드의 결과는 x = 5 and y + 2 = 10을 출력한다.

비밀번호 생성

비밀번호는 랜덤함수를 사용해서 구현하는 것이 일반적이지만 안타깝게도 러스트는 기본적인 랜덤함수가 없다.

대신 이 기능을 가지고 있는 rand 크레이트를 제공한다.

크레이트로 더 많은 기능 가져오기

크레이트: 러스트 코드 파일들의 모음

우리가 만들고 있는 프로젝트: 바이너리 크레이트
다른 프로그램에서 사용되기 위한 용도: 라이브러리 크레이트

카고에서는 직접 다운로드할 필요 없이 Cargo.tomldependencies 부분에 그냥 입력하면 된다.

[dependencies]
rand = "0.8.5"

카고에서는 버전명에 유의적 버전(Semantic Versioning)을 사용한다.
이뜻은 0.8.5는 실제로 ^0.8.5의 축약형을 의미하고, 최소 0.8.5 이상이지만 0.9.0 아래의 모든 버전을 의미한다.

이 방식을 사용하면 직접 Crates.io에서 데이터 복사본인 레지스트리에서 해당 의존성이 필요로 하는 모든 크레이트를 최신 버전으로 가져온다.

Cargo.lock

카고는 Cargo.lock을 통해 프로젝트 빌드할 때 기준을 만족하는 모든 의존성의 버전을 확인하고 기록하기 때문에 나중에 프로젝트를 빌드할 때는 Cargo.lock 파일이 존재하는지 확인하여 그 안의 명시된 버전들만 사용한다.

이 뜻은 어디서 실행하던 상관없이, 개발자의 환경과 동일하게 세팅할 수 있음을 의미하고 명시적으로 업그레이드 하지 않는 이상 원래 개발하던 환경과 동일한 버전의 크레이트를 사용한다.

만약 버전을 올리고 싶다면 다음의 명령어를 사용한다.

$ cargo update

다만, 0.9.x 버전을 사용하고 싶다면 Cargo.toml 파일의 dependencies 부분을 다음과 같이 수정해야 한다.

[dependencies]
rand = "0.9.0"

임의의 숫자 생성

use를 사용하여 rand를 사용한다고 명시한 다음 난수 생성기를 구현한다.

먼저 코드를 보면 다음과 같다.

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()를 통해 호출한다. 이 방식은 운영체제가 시드를 정하고 현재 스레드에서만 사용되는 난수 생성기를 만든다.

gen_range는 난수의 범위를 정할 때 사용하고, 표현식은 start..=end다. 위 경우에서는 1 ~ 100 사이의 숫자를 표현한다.

비밀번호와 추리값 비교

일단 코드부터 보자.

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!"),
    }
}

std::cmp::Ordering 타입을 가져오고 Ordering은 열거형이다. 이 타입은 Less, Greater, Equal 이라는 variant를 가지고 있다.

cmp는 두 값을 비교하여 비교하고자 하는 값의 참조자를 받는다.(즉, 레퍼런스로 받아야 한다는 점이다.)

match는 여러 갈래(arm)들로 이루어져 있고, 하나의 갈래는 하나의 패턴과 match 표현식에서 주어진 값이 패턴과 맞는다면 실행할 코드로 구성되어 있다.

러스트는 갈래를 순서대로 확인한다.

하지만 지금의 코드에서는 에러가 발생할 것이다.

타입 불일치 문제

당연히 비교할 때에는 타입이 동일해야 한다. 지금 문제는 secret_number는 숫자인데, guessString::new()라는 점이다.

즉, 지금 해야할 것은 guess를 정수형으로 받는 것이고 개선한 코드는 다음과 같다.

	// --생략--
    
    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!"),
    }

섀도잉(Shadowing)

러스트는 섀도잉을 허용하는데 그 뜻은 같은 두개의 고유 변수를 만들도록 강제하기 보다는 변수 이름을 재사용하도록 해준다는 것이다.

trim

일단 문자열은 맨 뒤에 개행 문자(\n)가 추가되어 있다. 그렇기 때문에 숫자형으로 바꾸기 위해서는 .trim() 사용하여 정렬해야 한다.

parse

parse는 문자열을 다른 타입으로 바꿔주는 역할을 한다. 이 경우에도 u32 타입으로 변경하도록 해주었다.

이 예제를 통해 한 가지 더 알 수 있는 사실이 있는데, 기본적인 숫자는 u32 타입이라는 점이다.

프로그램 실행

이제 cargo run을 하면 실행되는 데 조금 아쉬운 점이 있다.

정답을 맞을 때까지 실행하고 싶은데, 한 번만 실행되고 끝난다는 점이다.

반복문을 활용해 이 점을 해결할 수 있다.

반복문으로 여러 번 추리 허용하기

rust에서는 loop을 통해 무한 루프를 제공한다.
하지만 문제는 이것을 사용하면 프로그램이 종료되지 않는다는 점이다.

	// --생략--
    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!")
        }
    }
}

물론 quit을 입력하면 프로그램은 종료되기는 하지만, 일반적인 경우는 아닐 것이다.

정답 맞힐 때 종료하게 만들기

사용자가 정답을 맞췄을 때 게임이 종료되도록 break 문을 추가합니다.

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

이렇게 하면 loop를 탈출하면서 프로그램이 정상적으로 종료된다.

잘못된 입력 처리

parse가 입력 문자를 정수로 바꾸지 못하면 에러가 발생한다. parseResult 형이고 동작을 좀 더 다듬어 보자.

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

이렇게 하면 올바른 입력을 받을 때까지 반복 실행이 된다.

_ (catchall)

Err 쪽을 보면 _이 있는데, 이것은 캐치올이라고 하고 모든 값에 매칭이 될 수 있다.
이 예제에서는 숫자가 아니면 무슨 값이 있던지 모든 Err를 매칭하도록 설정한 것이다.

이후에 continue를 실행하여 loop의 다음 반복으로 넘겼다.

최종코드

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;
            }
        }
    }
}
profile
안녕하세요! 저는 전자공학을 전공하며 하드웨어와 소프트웨어 모두를 깊이 있게 공부하고 있는 후유카와입니다. Verilog HDL, C/C++, Java, Python 등 다양한 프로그래밍 언어를 다루고 있으며, 최근에는 알고리즘에 대한 학습에 집중하고 있습니다. 기술적인 내용을 공유하고, 함께 성장할 수 있는 공간이 되기를 바랍니다. 잘못된 내용이나 피드백은 언제나 환영합니다! 함께 소통하며 더 나은 지식을 쌓아가요. 감사합니다!

0개의 댓글