JS, TS랑 비교하며 Rust 공부하기 (1)
하던 공부를 계속하다보면 질리고 지칠때가 있는데, 그럴땐 딴짓을 하면 좀 기분이 괜찮아진다.
그렇다고 딴짓으로 유희를 즐기면 뭔가 남는게 없는 듯해서 다른 언어를 공부하며 기분 전환을 해왔다.
지금까지 이것저것 다른 언어들을 슬며시 공부해왔지만 시간이 지나면 다까먹어서 왜 공부한거지? 라는 생각을 계속 해왔는데,
물론 공부를 위한 공부가 아닌 딴짓을 위한 공부라 시간 낭비라고 생각하진 않지만 이번에는 좀 제대로 정리하고 꾸준히 써보자는 결심을 했다. 그래서 이번에 선택한게 Rust!
이전 부터 Rust에 관심을 가지고 있었는데 그 이유를 나열해보자면,
- C, C++ 보다 안전하다고 들었고
- Deno를 Rust로 작성했고
- WebAssembly를 Rust로 작성할 수 있고
- 기존의 코드를 Rust로 작성해서 돌리면 몇배나 빠르다는 주워들은 이야기
- 함수형 프로그래밍 관련 책에 등장
- TypeScript랑 비슷한 부분이 있다.
이런 이유들이 있다.
그럼 한글로 변역된 문서를 보면서 정리한 글을 공유해보겠다!
설치
최신 stable 버전 러스트 설치 명령어는 아래와 같다.
$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
설치 확인
설치가 됐는지 확인할땐 버전을 통해 확인한는건 관습인건가..?!
$ rustc --version
rustc x.y.z (abcabcabc yyyy-mm-dd)
업데이트 및 삭제
업데이트 및 삭제할 일은 많이 없지만 알아두자.
$ rustup update
$ rustup self uninstall
Cargo
Cargo는 러스트 빌드 시스템 및 패키지 매니저이다.
npm을 생각하면 편하다.
프로젝트 생성
새로운 디렉터리를 생성하고 프로젝트를 생성하고 싶다면
cargo new 디렉터리명
원하는 디렉터리 내부에서 프로젝트를 생성하고 싶다면
cargo init
프로젝트 디렉터리 구조
├── src
│ └── 소스 코드
└── Cargo.toml
Cargo는 소스 파일이 src
디렉터리 내에 있다고 예상한다.
Cargo.toml
은 package.json
과 비슷한 것이다.
빌드와 실행
JS와는 다르게 컴파일 언어는 실행 파일을 만들어서 실행해야 한다.
많이 다르지만 친숙하게 설명하면 TS에서 JS로 트랜스파일링 해준다고 생각하자.
빌드 진행
실행 파일은 ./target/debug 안에 실행 파일이 생성된다.
cargo build
릴리즈 빌드 진행
실행 파일은 ./target/release 안에 실행 파일이 생성된다.
컴파일이 오래 걸리는 대신 러스트 코드가 더 빠르게 작동한다.
cargo build --release
빌드와 실행을 한 번에 진행
매번 빌드하고 실행하면 귀찮으니 run
명령어를 쓰면 된다.
cargo run
빌드되는지 확인
실행 파일은 생성하지 않고 소스 코드가 문제없이 빌드되는지 확인할 수 있다.
cargo check
문법
변수
변수를 선언하면 기본적으로 불변이며, mut
키워드와 함께 선언하면 가변으로 만들 수 있다. JS에 비유하면, 기본 변수는 const
, mut
와 함께 선언한 변수는 let
과 비슷하다.
가변 변수라도 타입은 고정되기 때문에, 다른 타입의 값을 대입하면 에러가 발생한다. 이는 TS에서 타입이 다른 값을 재할당할 때 에러가 발생하는 것과 같다.
반면, 같은 이름으로 변수를 다시 선언하는 섀도잉을 사용하면 기존과 다른 타입의 값도 대입할 수 있다. 섀도잉은 JS에서 var
를 사용해 같은 이름의 변수를 다시 선언하는 것과 유사하다.
let a = 1;
let mut b = 2;
b = "b"; // error
let a = "a"; // 섀도잉
상수
상수는 항상 불변이고 타입을 반드시 명시해야 한다.
런타임에서만 계산될 수 있는 결과값은 할당할 수 없다.
JS의 const
로 상수화해주는 느낌이다.
const NUMBER_ONE: u32 = 1;
const MAX_POINTS: u32 = 100_000;
구문과 표현식
- 구문은 어떤 동작을 수행하지만 값을 반환하지 않는다.
- 표현식은 값을 반환한다.
함수 호출, 매크로 호출, 스코프 블록 등은 모두 표현식이다.
스코프 블록의 마지막 표현식에 세미콜론이 없으면 그 표현식이 반환된다. 세미콜론을 붙이면 그 값은 무시되고 ()
로 표현되는 유닛 타입이 반환된다.
구문은 값을 반환하지 않기에 유닛 타입으로 간주된다. JS에서 함수의 반환값이 없으면 undefined
가 반환되는 것과 비슷한 듯하다.
let y = {
let x = 3;
x + 1 // return을 생략할 수 있다!
};
함수
러스트는 함수 위치를 고려하지 않으며, 호출하는 쪽에서 볼 수 있는 스코프 어딘가에 정의만 되어있으면 된다.
함수 시그니처에는 각 매개변수의 타입과 반환값의 타입을 반드시 명시해야 한다. 예외적으로 반환값이 없는 함수에는 반환 타입을 명시하지 않아도 된다.
fn add_five(num: i32) -> i32 {
num + 5
}
함수에 다른 함수를 전달하려면 매개변수 타입에 fn
키워드와 함께 함수의 시그니처를 작성하면 된다. 이때 fn
은 함수 포인터 타입을 의미한다.
fn add_one(x: i32) -> i32 {
x + 1
}
fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
f(arg) + f(arg)
}
let answer = do_twice(add_one, 5);
if
표현식
러스트는 부울린 타입이 아닌 값을 부울린 타입으로 자동 변환하지 않기 때문에, 항상 명시적으로 부울린 타입의 조건식을 제공해야 한다.
JS와는 다르게 소괄호로 감싸지 않는다.
if number % 4 == 0 {
println!("number is divisible by 4");
} else if number % 3 == 0 {
println!("number is divisible by 3");
} else if number % 2 == 0 {
println!("number is divisible by 2");
} else {
println!("number is not divisible by 4, 3, or 2");
}
러스트에는 삼항 연산자가 존재하지 않지만, if
는 표현식으로 동작하기 때문에 값을 반환할 수 있다. 이때 모든 분기는 동일한 타입의 값을 반환해야 한다.
let number = if condition { 5 } else { "six" }; // error
반복문
loop
반복문은 무한 반복을 수행한다. (루프 라벨도 있다.)
break
키워드 다음에 반환하고자 하는 값을 작성하면 그 값이 반환된다.
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 10 {
break counter * 2;
}
};
while
반복문은 JS와 같다.
let mut number = 3;
while number != 0 {
println!("{number}"); // 3 2 1
number -= 1;
}
for..in
반복문을 사용하여 컬렉션의 각 아이템에 대해 코드를 수행할 수 있다.
JS의 for..of
문으로 생각하면 된다.
let a = [10, 20, 30, 40, 50];
for element in a {
println!("the value is: {element}");
}
Range
타입을 이용하면 특정 횟수만큼 반복하는 반복문을 구현할 수 있다.
Range
는 어떤 숫자에서 시작하여 다른 숫자 종료 전까지의 모든 숫자를 차례로 생성해준다.
for number in (1..4).rev() {
println!("{number}"); // 3 2 1
}
자리표시자
자리표시자는 {}
로 출력할 값을 넣을 자리를 나타내며, 중괄호 안에 변수나 형식 옵션을 지정해 원하는 방식으로 출력할 수 있다.
let x = 5;
let y = 10;
println!("x = {x} and y + 2 = {}", y + 2);
연산자
대부분 JS와 동일한 연산자들을 사용할 수 있다.
증감 연산자는 제공되지 않는다.
정수 나눗셈은 버림이 발생한다.
let x = 5;
let y = 2;
println!("{}", x / y); // 2
match
표현식
주어진 값이 특정 패턴과 매칭되면 해당 패턴의 코드를 실행한다.
JS의 switch
문과 비슷하지만, 러스트의 컴파일러는 가능한 모든 경우가 처리되는지 검사한다.
match value {
패턴1 => 코드1,
패턴2 => 코드2,
패턴3 => 코드3,
}
포괄 패턴을 사용하면 나머지 가능한 값들을 처리할 수 있다.
_
는 어떤 값이든 매칭되지만 값을 사용하지 않을 때 사용된다.
마지막 갈래에 변수를 사용하면 나머지 모든 경우를 처리하면서 값을 바인딩해서 사용할 수 있다.
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => reroll(),
}
match num {
2 => two(),
4 => four(),
x => number(x),
}
특정 패턴에 매칭되는 값을 변수에 바인딩해서 사용할 수 있다.
let x = Some(5);
match x {
Some(50) => println!("Got 50"),
Some(y) => println!("Matched, y = {y}"), // 변수 바인딩
_ => println!("Default case, x = {:?}", x),
}
같은 패턴이라도 if
로 조건을 추가해 세부적으로 분기할 수 있다.
let num = Some(4);
match num {
Some(x) if x % 2 == 0 => println!("The number {} is even", x),
Some(x) => println!("The number {} is odd", x),
None => (),
}
if let
if let
문법은 if
와 let
을 조합하여 하나의 패턴만 매칭시키고 나머지 경우는 무시하도록 값을 처리한다.
아래와 같은 match
문으로 작성된 코드의 문법 설탕이며, match
가 강제했던 철저한 검사를 안하게 되므로 주의해서 써야 한다.
let config_max = Some(3u8);
match config_max {
Some(max) => println!("The maximum is configured to be {}", max),
_ => (),
}
else
도 사용할 수 있으며, else
뒤에 나오는 코드 블록은 match
표현식에서 _
케이스 뒤에 나오는 코드 블록과 동일하다.
let config_max = Some(3u8);
if let Some(max) = config_max {
println!("The maximum is configured to be {}", max);
} else {
println!("else");
}
패턴
패턴은 복잡하거나 단순한 타입의 구조와 매칭을 위한 러스트의 특수 문법이다.
match
, if let
, while let
, for
, let
, 함수 매개변수에서 패턴을 사용할 수 있다.
패턴에는 가능한 모든 값에 반드시 매칭되는 irrefutable 패턴과 일부 값에만 매칭되고 나머지 경우엔 실패할 수 있는 refutable 패턴이 있다.
let x = 5 // 항상 성공하므로 irrefutable
let Some(x) = some_option_value // None일 경우 실패하므로 refutable
|
나 ..=
를 사용해서 여러 패턴에 매칭시킬 수 있다.
..=
로 범위를 매칭할 때는 숫자 또는 char
값만 허용한다.
@
을 사용하면 범위에 매칭되는 어떤 값이든 변수에 바인딩할 수 있다.
let x = 5;
match x {
0 | 1 => println!("zero or one"),
2..=5 => println!("one through five"),
num @ 6..=9 => println!("{} is in six ~ nine", num),
_ => println!("something else"),
}
구조체, 열거형, 튜플을 분해하여 값을 변수에 바인딩하기 위해 패턴을 사용할 수 있다.
JS의 구조 분해 할당과 비슷하다.
struct Point {
x: i32,
y: i32,
}
let p = Point { x: 0, y: 7 };
let Point { x: a, y: b } = p; // 다른 변수명 지정
// let Point { x, y } = p; // 변수명이 필드명과 동일할 때는 생략 가능
let ((feet, inches), Point { x, y }) = ((3, 10), Point { x: 3, y: -10 });
_
로 값을 무시하거나 _
를 변수 앞에 붙여 변수를 무시할 수 있다.
_
가 붙은 변수는 바인딩이 발생하지만 _
는 바인딩이 발생하지 않는다.
fn foo(_: i32, y: i32) {
println!("This code only uses the y parameter: {}", y);
}
foo(3, 4);
let s = Some(String::from("Hello!"));
if let Some(_s) = s { // String이 move되므로 아래에서 s 사용 불가
println!("found a string");
}
println!("{:?}", s); // error
..
로 구조체, 튜플, 배열 등에서 나머지 필드나 요소를 무시할 수 있다.
@
을 사용해 나머지 모든 요소를 변수에 바인딩할 수 있다.
struct Point {
x: i32,
y: i32,
z: i32,
}
let origin = Point { x: 0, y: 0, z: 0 };
match origin {
Point { x, .. } => println!("x is {}", x),
}
let v = [1, 2, 3, 4];
let [a, b @ ..] = v; // a: i32, b: [i32; 3]
ref
로 매칭된 값을 소유권 이동 없이 참조로 바인딩할 수 있다.
let opt = Some(String::from("hello"));
if let Some(ref s) = opt {
println!("{}", s); // s: &String
}
println!("{:?}", opt); // opt는 여전히 사용 가능
데이터 타입
스칼라 타입
스칼라 타입은 하나의 값을 표현하며, 네 가지 스칼라 타입이 있다.
- 정수
- 부동 소수점 숫자
- 부울린
- 문자
정수 타입
길이 | 부호 있음 (signed) | 부호 없음 (unsigned) |
---|---|---|
8-bit | i8 | u8 |
16-bit | i16 | u16 |
32-bit (기본) | i32 | u32 |
64-bit | i64 | u64 |
128-bit | i128 | u128 |
arch (컴퓨터 환경) | isize | usize |
숫자 리터럴 | 예 |
---|---|
Decimal | 98_222 |
Hex | 0xff |
Octal | 0o77 |
Binary | 0b1111_0000 |
Byte (u8 only) | b’A’ |
정수 오버플로우
러스트는 코드를 디버그 모드에서 컴파일하는 경우, 런타임에서 정수 오버플로우가 발생했을 때 패닉을 발생시키는 검사를 포함시킨다. 이와 다르게 릴리즈 모드로 컴파일하는 경우에는 패닉을 발생시키는 정수 오버플로우 검사를 실행 파일에 포함시키지 않는다.
부동 소수점 타입
f32
f64
(기본)
let x = 2.0; // f64
let y: f32 = 3.0; // f32
부울린 타입
bool
let t = true;
let f: bool = false;
문자 타입
char
: 4바이트, 유니코드, 이모지 포함
let c = 'z';
let z: char = 'ℤ';
let heart_eyed_cat = '😻';
복합 타입
튜플 타입
고정된 길이를 가지며, 튜플 내의 타입들은 달라도 된다.
비어있는 튜플은 유닛으로 불리며 빈 값이나 비어있는 반환 타입을 나타낸다.
표현식이 어떠한 값도 반환하지 않는다면 암묵적으로 유닛 값을 반환한다.
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
let x: (i32, f64, u8) = (500, 6.4, 1);
let five_hundred = x.0;
let six_point_four = x.1;
let one = x.2;
배열 타입
러스트의 배열은 고정된 길이를 가지며, 모든 요소는 같은 타입이어야 한다.
스택에 저장되기 때문에 성능이 중요할 때 적합하다.
유효하지 않은 배열 요소에 접근하면 패닉을 일으키며 즉시 실행을 종료한다.
길이에는 상수를 넣어줘야 하며, 만약 길이가 동적으로 결정된다면 벡터를 사용해야 한다.
let a: [i32; 5] = [1, 2, 3, 4, 5];
let a = [3; 5]; // [3, 3, 3, 3, 3];
let first = a[0];
기본적인 내용을 확실히 숙지해야 그 다음으로 넘어가도 시간을 절약할 수 있다.
다음에 공유할 내용의 시작은 Rust의 특별한 개념인 소유권이다.