RustBook - 업다운게임 만들기 (Programming a Guessing Game)
업다운게임을 만들어보면서 러스트의 기본 문법을 익혀보겠습니다
프로젝트 만들기
cargo new guessing_game
cd guessing_game
cargo new로 새로운 프로젝트를 생성할 수 있습니다.
cargo new로 프로젝트를 생성하면 아래 파일이 기본적으로 생성됩니다:
- Cargo.toml
프로젝트의 메타데이터 (이름, 버전, 의존성)에 대한 정보가 들어가있습니다.
js의 package.json과 같은 역할을 합니다.
- src/main.rs
메인 파일입니다. 실행 시 첫 진입점이 됩니다.
- .gitignore
Git을 사용할경우 gitignore를 만들어줍니다. 기본적으로 .target/ 디렉토리가 무시되도록 설정되어있습니다
입출력
아래 코드에 대한 설명을 하나하나 해보겠습니다.
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);
}
use std::io
표준 라이브러리 std의 입출력 모듈 io를 사용합니다
::는 범위연산자(scope resolution operator)로,
Rust에서 특정 범위 내의 항목 (함수, 모듈, 타입 등)을 참조하거나 접근하는 데 사용됩니다.
다양한 상황에서 사용될 수 있으며, 여기서는 라이브러리(std) 내 항목(io)를 참조하도록 사용되었습니다.
fn main(){
프로그램의 진입점입니다. Rust에서는 함수를 fn으로 선언합니다. 짧아서 좋네요
println!("Guess the number!");
이름에서 유추 가능하듯이 콘솔출력을 한 뒤 한줄을 띄웁니다.
println 뒤에 붙은 느낌표 (!)는 뭘까요?
나중에 자세하게 설명하므로 간단히 말해보자면 함수가 아닌 "매크로"입니다.
Rust는 함수와 유사하지만 더 유연한 방식으로 동작하는 "코드생성도구" 인 "매크로"를 지원합니다.
런타임에 실행되는 함수와 다르게 "매크로"는 컴파일타임에 작동하며,
가변인자, 패턴매칭, 동적구조생성 등 유연한 구조를 가지고 있습니다.
let mut guess = String::new();
let으로 변수를 선언합니다.
Rust에서는 기본적으로 모든 변수는 "불변(immutable)"으로 선언됩니다.
한번 값을 넣으면 바꿀 수 없다는 뜻입니다. 왜 이것이 기본값으로 채택되었는지는 나중에 자세하게 설명합니다.
변수를 변경 가능하게 만들기 위해, mut 키워드를 추가해 "가변(mutable)" 변수를 생성할 수 있습니다.
변수의 이름은 guess 입니다. guess 변수는 이제 프로그램 실행 중 값을 바꿀 수 있습니다.
만약 mut를 사용하지 않는다면, 컴파일단계에서 에러가 발생합니다
String은 가변적이고 소유권을 가지는 문자열 타입으로, 크기를 동적으로 변경할 수 있습니다.
Rust에서 문자를 다루는 방식은 두 가지가 있는데, String과 &str입니다.
둘다 문자열 데이터를 표현하지만, Rust의 엄격한 메모리관리와 소유권 개념때문에 각 타입은 특정한 용도와 상황에 맞게 설계되었습니다.
String은 힙(Heap)메모리에 저장되는, 소유권을 가지는 가변 문자열 타입입니다.
데이터를 저장하거나 수정할 수 있으며, 문자열 크기를 동적으로 변경할 수 있습니다.
&str는 문자열 슬라이스(String slice) 타입으로, 문자열 데이터를 참조하기위한 읽기 전용 뷰(view)입니다.
원본은 문자열(String 또는 문자열 리터럴)의 데이터를 가리키며, 소유권을 가지지 않습니다.
&str는 불변이며, 크기를 변경하거나 데이터를 수정할 수 없습니다.
String::new()를 사용해 빈 문자열을 생성합니다.
io::stdin().read_line(&mut guess).expect("Failed to read line)"
io::stdin()으로 표준입력을 받을 준비를 하도록 합니다.
반환값은 표준 입력 스트림의 핸들로, 이를 통해 입력을 읽을 수 있습니다.
.read_line()은 표준 입력에서 문자열 데이터를 읽어 변수에 저장합니다.
인자로 가변참조 (&mut)을 받아 입력값을 해당 변숟에 저장합니다.
&mut guess를 통해 가변변수 guess의 참조를 전달합니다.
.read_line()은 Result<usize> 타입을 반환합니다. Result 열거형(enum)으로, 두개의 변형(variant)를 가집니다.
enum Result<T, E> {
Ok(T), // 작업이 성공했을 때 결과를 포함
Err(E), // 작업이 실패했을 때 오류 정보를 포함
}
이를 통해 값 읽기를 실패했을때를 제어할 수 있습니다.
Result 인스턴스는 expect 메서드를 가지고 있으며, 이를 호출할 수 있습니다.
expect()는 Result 인스턴스가 Err값을 가지면, 프로그램을 크래시시키고 전달된 메세지를 출력합니다.
expect()를 호출하지 않게되면, 컴파일은 가능하지만 warning이 출력됩니다.
프로그램이 발생 가능한 잠재적 오류를 처리하지 않았으므로, 컴파일러가 이를 경고해주는겁니다.
println!("You guessed: {}", guess);
{}는 place holder입니다. 변수값을 출력할때는 변수 이름을 {} 안에 넣을 수 있습니다.
표현식(expression)의 결과를 출력할때는 {}를 포맷문자열에 사용하고, 뒤에 출력할 표현식의 결과를 쉼표로 구분하여 나열하면 출력할 수 있습니다.
let x = 5;
let y = 10;
println!("x = {x} and y + 2 = {}", y + 2);
물론, 변수의 값은 표현식으로 간주될 수 있습니다.
여기서는 {guess}로 사용하던지, ..{}", guess 로 사용할지는 프로그래머가 정할 수 있습니다.
테스팅
프로젝트의 루트폴더에서
cargo run
을 통해 프로그램을 실행시킵니다.
cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 6.44s
Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6
랜덤 숫자 생성
이제 컴퓨터가 랜덤 숫자를 생성하도록 만들겠습니다. 범위는 1~100으로 하겠습니다.
더 많은 기능을 위해 Crate 사용하기
크레이트(crate)는 Rust 소스 코드 파일들의 모음입니다.
우리가 작성 중인 프로젝트는 실행 가능한 바이너리 크레이트(binary crate)입니다.
반면, rand 크레이트는 다른 프로그램에서 사용하도록 작성된 코드로 구성된 라이브러리 크레이트(library crate)입니다. 라이브러리 크레이트는 독립적으로 실행될 수 없습니다.
Cargo의 외부 크레이트 관리 기능은 Cargo의 가장 큰 강점 중 하나입니다.
rand를 사용한 코드를 작성하기 전에, Cargo.toml 파일을 수정하여 rand 크레이트를 의존성(dependency)으로 추가해야 합니다.
이 파일을 열어 아래와 같이 [dependencies] 섹션 아래에 다음 줄을 추가하세요.
[dependencies]
rand = "0.8.5"
혹은 프로젝트 루트에서 다음 명령어를 입력하여 최신 버전의 라이브러리를 추가할 수 있습니다.
cargo add rand
이 명령어는 자동으로 최신버전의 라이브러리를 Cargo.toml에 추가해줍니다.
Cargo.toml 파일에서, 헤더 다음에 나오는 모든 내용은 새로운 섹션이 시작될 때까지 해당 섹션에 포함됩니다.
[dependencies] 섹션에서는 Cargo에 프로젝트가 어떤 외부 크레이트를 의존하는지와 해당 크레이트의 버전을 명시합니다.
위에서는 rand 크레이트를 버전 0.8.5로 지정하고 있습니다.
Cargo는 Semantic Versioning(의미적 버전 관리) 규칙을 이해합니다.
이 규칙에 따르면, 0.8.5는 실제로 ^0.8.5의 축약형입니다.
이는 0.8.5 이상이고 0.9.0 미만인 모든 버전을 포함한다는 의미입니다.
Cargo는 이 버전들이 버전 0.8.5와 호환되는 공용 API를 제공한다고 간주합니다.
이 규칙 덕분에 다음의 코드 예제가 여전히 작동할 수 있도록 가장 최신의 패치 릴리스를 가져오게 됩니다.
반면, 0.9.0 이상의 버전은 동일한 API를 보장하지 않으므로 포함되지 않습니다.
프로젝트 빌드
코드에 아무런 변경을 가하지 않은 상태에서 프로젝트를 빌드해 봅시다.
$ cargo build
Updating crates.io index
Downloaded rand v0.8.5
Downloaded libc v0.2.127
Downloaded getrandom v0.2.7
Downloaded cfg-if v1.0.0
Downloaded ppv-lite86 v0.2.16
Downloaded rand_chacha v0.3.1
Downloaded rand_core v0.6.3
Compiling libc v0.2.127
Compiling getrandom v0.2.7
Compiling cfg-if v1.0.0
Compiling ppv-lite86 v0.2.16
Compiling rand_core v0.6.3
Compiling rand_chacha v0.3.1
Compiling rand v0.8.5
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 2.53s
Listing 2-2: rand 크레이트를 의존성으로 추가한 후 cargo build 명령어를 실행했을 때의 출력 결과.
출력에서 보이는 것처럼, rand 크레이트를 의존성으로 추가하자 Cargo는 rand가 작동하는 데 필요한 다른 크레이트도 함께 다운로드합니다.
Cargo는 Crates.io라는 레지스트리에서 이러한 크레이트들을 가져옵니다.
Crates.io는 Rust 생태계에서 개발자들이 공개적으로 Rust 프로젝트를 게시하고 공유하는 플랫폼입니다.
Cargo는 레지스트리를 업데이트한 후, [dependencies] 섹션을 확인하고, 아직 다운로드되지 않은 크레이트를 가져옵니다.
이 경우, 우리가 rand만 의존성으로 명시했지만, Cargo는 rand가 의존하는 다른 크레이트들도 함께 다운로드했습니다.
크레이트를 다운로드한 후에는 Rust가 이를 컴파일하고, 의존성이 사용 가능한 상태로 프로젝트를 컴파일합니다.
코드나 의존성에 아무런 변경 사항이 없을 경우, cargo build를 다시 실행하면 다음과 같은 출력만 보입니다:
$ cargo build
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Cargo는 이미 크레이트를 다운로드하고 컴파일했으며, Cargo.toml 파일이나 코드에 변경 사항이 없음을 인식합니다.
따라서 재컴파일 없이 작업을 종료합니다.
Cargo는 프로젝트 빌드 결과를 항상 동일하게 재현할 수 있도록 Cargo.lock 파일을 사용합니다.
이 파일은 특정 빌드에 사용된 크레이트 버전을 고정하여, 동일한 프로젝트를 다시 빌드할 때 같은 버전을 사용하도록 보장합니다.
예를 들어, 다음 주에 rand 크레이트의 버전 0.8.6이 출시되었다고 가정합시다.
이 버전이 중요한 버그 수정을 포함하고 있지만, 동시에 코드를 깨트릴 수 있는 회귀(regression)도 포함하고 있다면 어떻게 될까요?
이를 처리하기 위해, Cargo는 cargo build를 처음 실행할 때 Cargo.lock 파일을 생성합니다.
이후로는 Cargo.lock 파일에 명시된 버전을 사용합니다.
이는 프로젝트가 0.8.5 버전을 계속 사용하도록 보장하며, 사용자가 명시적으로 업그레이드를 요청하기 전까지는 다른 버전으로 업그레이드되지 않습니다.
업데이트가 필요할 경우, Cargo는 cargo update 명령어를 제공합니다. 이 명령어는 Cargo.lock 파일을 무시하고, Cargo.toml 파일의 명시된 조건에 따라 최신 버전을 가져옵니다.
$ cargo update
Updating crates.io index
Updating rand v0.8.5 -> v0.8.6
이 경우, rand 크레이트의 0.8.5 버전이 0.8.6으로 업데이트됩니다.
그러나 rand의 0.9.0 버전으로 업데이트되지는 않습니다. 0.9.0 버전을 사용하려면, Cargo.toml 파일을 직접 수정해야 합니다:
[dependencies]
rand = "0.9.0"
Cargo는 Rust에서 외부 크레이트를 효율적으로 관리할 수 있도록 돕습니다.
이를 통해 Rust 개발자는 여러 패키지로 구성된 작은 프로젝트를 작성하여 코드 재사용성을 극대화할 수 있습니다. Crates.io와 같은 플랫폼과 Cargo의 강력한 의존성 관리 덕분에 Rust 생태계는 매우 효율적으로 작동합니다.
rand 사용하기
이제 진짜 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}");
}
use rand::Rng;
Rng 트레이트는 랜덤숫자 생성기가 구현하는 메서드를 정의하며, 이 트레이트가 사용가능한 범위에 있어야 해당 메서드를 사용할 수 있습니다. 트레이트에 대해서는 추후 자세히 다룹니다.
rand::thread_rng
이 함수는 현재 실행중인 스레드에 로컬로 작동하며 운영체제에 의해 seed가 설정된 랜덤숫자 생성기를 제공합니다.
이 메서드는 Rng 트레이트에 정의되어 있습니다.
gen_range(1..=100);
gen_range 메서드는 범위표현식을 인자로 받아 해당 범위 안의 랜덤숫자를 생성합니다.
여기서 사용한 범위표현식은 (start..=end) 형태입니다.
cargo run을 통해 숫자가 제대로 생성되는지 확인해봅시다.
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 2.53s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5
입력한 숫자와 랜덤생성된 숫자 비교
// [이 코드는 컴파일되지 않습니다!]
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
// --snip--
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타입은 또 다른 열거형(enum)이며, 변형(variant)로 Less, Great, Equal을 포함하고 있습니다.
cmp 메서드는 두 값을 비교하며, 비교할 값의 참조를 인자로 받습니다.
guess와 secret_number를 비교하게 됩니다.
match는 함수형 프로그래밍에서 아주 중요한 개념 "패턴매칭"을 구현합니다.
match는 주어진 값이 가질 수 있는 다양한 패턴 중 어떤것과 일치하는지 확인하고, 각 패턴에 따라 코드를 실행하게됩니다.
match 표현식은 여러 arms(가지)로 구성됩니다. 각 arm은 비교할 패턴과 해당 값이 패턴에 일치할 때 실행할 코드로 이루어져 있습니다. Rust는 제공된 값과 각 arm의 패턴을 차례로 비교합니다.
switch-case의 타입버전이라고 보면 되겠습니다.
guess.cmp() 앞서 봤듯이 세개의 값(less, great, equal)을 반환할 수 있습니다.
예를들어, 사용자가 50을 입력하고, 생성된 비밀 숫자가 38이라고 가정해 봅시다.
코드에서 50과 38을 비교하면, cmp 메서드는 Ordering::Greater를 반환합니다.
match 표현식은 반환된 Ordering::Greater 값을 검사합니다.
첫 번째 arm에서 Ordering::Less와 비교하지만 일치하지 않으므로 넘어갑니다.
두 번째 arm에서 Ordering::Greater와 비교하고, 일치하므로 해당 코드를 실행합니다. 화면에 "Too big!"이 출력됩니다.
match 표현식은 첫 번째로 일치한 arm을 실행한 후 종료되므로, 마지막 arm은 실행되지 않습니다.
컴파일이 안되는데요?
$ cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
--> src/main.rs:22:21
|
22 | match guess.cmp(&secret_number) {
| --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
| |
| arguments to this method are incorrect
빌드하면 이런결과가 나옵니다.
expected &String, found &integer. 타입 불일치입니다.
Rust는 정적인 타입 시스템을 사용하나, 타입추론 또한 지원합니다.
앞서 "let mut guess = String::new();"를 작성했을때, 타입을 명시해주지 않아도 컴파일러는 guess를 String타입으로 추론합니다.
반면 secret_number는 숫자타입입니다. Rust에는 기본적인 숫자 타입 i32를 사용합니다.
즉, 타입이 다르기때문에 비교 자체가 불가능하다는 말입니다.
입력값을 숫자로 변환하기
사용자가 입력한 String을 숫자 타입으로 변환하여 비교할 수 있도록 변경해야 합니다.
아래 코드를 추가합니다.
let guess: u32 = guess.trim().parse().expect("Please type a number!");
let guess로 변수를 섀도잉(Shadowing)합니다. 새로운 값을 이전 guess 변수에 덮어쓰는겁니다.
앞서 guess는 mut string으로 선언되었기에, guess = 새로운숫자 로 작성할 수 없습니다. 따라서 타입을 바꿔 재선언해줍니다.
섀도잉은 동일한 이름의 변수를 재선언해 새로운 타입이나 값을 할당할 때 유용합니다.
trim()
문자열의 앞뒤 공백 및 줄바꿈 문자를 제거합니다.
\n, \r\n과 같은 숫자로 변환하는 데 있어 문제되는 부분이 제거됩니다.
parse()
문자열을 숫자 타입으로 변환합니다.
결과를 저장하는 변수의 타입을 명시 ( : u32) 했으므로 u32타입으로 변환됩니다
최종 코드
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;
}
}
}
}
loop {} 는 무한루프를 수행합니다.
break;는 loop를 종료시킵니다.
최종 코드의 실행 결과는 아래와 같습니다
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished dev [unoptimized + debuginfo] target(s) in 4.45s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!