List
Search
1. 들어가기 앞서
Devcon 2024를 갔다와서 작성하는 2번째 세션 후기 포스팅이다. 발표자는 옥찬호님으로, 현재 42dot 에서 임베디드 개발자로 근무중이시며 마이크로소프트 MVP라는 이력을 갖고 계신 분이다. 대학교 등에서 Rust 관련 특강을 많이 진행한 경험이 있으시다고 한다. 강의를 많이 해보신 분 답게 발표 내용을 아주 일목요연하게 잘 설명하고 시간도 딱 맞춰서 잘 쓰시는 노련함이 느껴졌다.
Rust에 대해서는 매년 개발자들에게 인기 많은 언어로 들어서 종종 “어떻게 생겨먹었으려나? 궁금하다”고 생각은 했었는데, 공부할 기회가 닿지 않아 Rust가 어떠한 언어이고 어떠한 패러다임 및 특성을 갖고 있는지 전혀 알지 못했다. 마침 이번 세션을 통해, Rust가 언어 레벨에서 얼마나 안정성을 보장하고 보수적으로 설계되어있는지 어깨 너머라도 알 수 있었다. 평소같았으면 전혀 알지도 못했을 내용들을 세션을 통해 알게 되는거 보면, 이게 컨퍼런스가 가지는 좋은 영향력인 것 같다고 생각이 들었다. 기회가 닿는다면 많은 컨퍼런스를 참여하고 싶어졌던 계기가 되기도 했다.
마찬가지로, 이하의 내용은 Devcon 2024 세션을 바탕으로, 추가적으로 조사한 내용을 조금 더 추가하여 개인적으로 정리한 것이다. 세션의 원본 내용 및 옥찬호님의 깃헙 주소는 아래에서 확인 가능하다.
•
옥찬호님 깃헙
•
발표 자료
2. Rust의 메모리 관리 방식
2.1. 소유권 (Ownership)
2.1.1. C, C++
•
클래스의 인스턴스가 자신이 가리키는 다른 어떤 객체를 소유한다는 표현
•
소멸 전에 포인터 할당 해제 필요
2.1.2. Rust
•
소유의 개념이 언어 자체에 내장. 컴파 타임 검사때 검증
◦
모든 값은 소유자가 하나
◦
소유자 해제(드롭)될때 그 소유한 값도 함께 드롭
•
변수가 자신 값을 소유하듯, 구조체는 자신의 필드들을 소유
◦
마찬가지로, 튜플, 배열, 벡터 등도 자신의 요소들을 소유
•
소유자와 이들이 소유한 값은 트리 구조를 이룸
◦
소유자가 없어질때, 소유자가 가진 필드들도 함께 제거됨
◦
메모리 관련된 오류 사전 방지 가능
2.2. 이동 (Moves)
2.2.1. 개요
•
값을 변수에 대입, 함수에 전달, 함수에서 반환하는 등이 연산에서 대부분 그 값을 복사하지 않고 이동됨
◦
이때 원래 주인은 값의 소유 값을 새 주인에게 양도하고 미초기화 상태가 됨
◦
이후 값의 수명은 새 주인이 통제
◦
Rust 프로그램은 값을 하나씩 쌓아서 복잡한 구조를 만들기도 하고, 또 하나씩 옮겨서 이를 허물기도 함
2.2.2. 언어별 대입 처리 방식 비교
•
Python
◦
Python 로컬 변수
▪
s, t, u라는 변수가 리스트를 참조하고 있음
▪
이 변수들은 각기 다른 파이썬 리스트의 요소를 가리키고 있으며, 메모리에서의 참조 관계를 나타냄
◦
리스트 객체 (PyListObject)
▪
list는 PyListObject로 표시되며, 리스트의 길이와 용량, 그리고 각 요소의 참조를 관리
▪
리스트의 요소들이 메모리에서 어떻게 연결되는지 확인할 수 있음
•
length: 리스트에 실제로 들어있는 요소의 개수를 나타냄 (여기서는 3)
•
capacity: 리스트가 할당된 메모리 공간의 크기를 나타 (여기서는 4)
•
reference count: 각 리스트 요소가 참조되는 횟수를 나타냄 (여기서는 1)
◦
리스트 요소 (list elements)
▪
리스트 내부에 실제로 들어있는 요소들이며, 각 요소는 문자열 객체를 참조하고 있음
▪
위 예시에서는 3개의 문자열 객체(각각 'soba', 'ramen', 'udon')를 참조하고 있음
◦
문자열 객체 (PyASCIIObject)
▪
리스트 안의 각 요소는 문자열 객체로 이루어져 있으며, 각각은 문자열의 길이, 참조 횟수, 실제 텍스트 데이터를 저장하고 있음
▪
length: 문자열의 길이를 나타냄
▪
reference count: 문자열이 참조된 횟수를 나타냄
▪
text: 문자열 자체를 나타냄. 'soba', 'ramen', 'udon' 문자열이 각각 저장되어 있음
•
C++
◦
스택
▪
힙에 있는 실제 데이터를 참조
▪
용량 혹은 힙 주소 (4), 길이 (3) 등을 나타냄
◦
힙
▪
실제 데이터가 저장됨
▪
참조 포인터(힙 내 어디 위치한지)와 실제 데이터(예. 4,4)를 가지고 있음
•
Rust
◦
C++과 유사
◦
소유권 이동을 통해 메모리 관리 진행
◦
스택 프레임에 저장된 s → t 로 소유권이 이전되었으므로 s 는 더이상 힙 메모리 데이터를 참조 X
2.3. 레퍼런스 (Reference)
2.3.1. 개요
•
소유권이 매번 이동되는게 번거롭다면, 레퍼런스를 통해 잠시 소유권을 빌려줄 수 있음
•
Rust는 이때 레퍼런스 규칙을 적용시킴
•
불변 참조와 가변 참조 동시 사용 금지 규칙 (둘 중 하나만 가능)
◦
변경 가능한 레퍼런스 하나만 만들 수 있음 (가변 참조)
▪
&mut T - 값의 읽기와 쓰기 권한 모두 제공
◦
변경 불가능한 여러개의 레퍼런스를 만들 수 있음 (불변 참조)
▪
&T - 값의 읽기 권한만 제공
•
레퍼런스는 그 소유자보다 더 오래 살 수 없음
2.3.2. 예시
•
a를 불변 참조하는 b가 이미 존재하는 상태에서, a를 가변 참조하는 c를 생성하려고 함
•
Rust는 하나의 변수에 대해 동시에 불변 참조와 가변 참조를 허용하지 않으므로, 이 부분에서 컴파일 오류가 발생
fn main() {
let mut a = 10;
let b = &a; // 불변 참조. b는 a의 값의 읽기 권한만 가능
// Error: cannot borrow `a` as mutable because
// it is also borrowed as immutable
{
let c = &mut a; // 불변참조가 가능한 상태에서 가변 참조를 만드려고 함 -> 불가능
*c = 20;
}
println!("a : {a}");
println!("b : {b}");
}
Rust
복사
2.4. 수명 (Lifetime)
레퍼런스는 C / C++에 있는 포인터와 비슷하다. 그러나 포인터는 안전하지 않음.
Rust는 과연 어떤 식으로 레퍼런스를 통제하고 있는 걸까?
2.4.1. 정의 및 개요
•
실행문, 표현식, 변수범위 등 프로그램에서 레퍼런스가 안전하게 쓰일 수 있는 구간
•
변수의 수명은 자신에게 차용된 레퍼런스의 수명을 반드시 포함하거나 에워싸야함
•
Rust는 프로그램에 있는 모든 레퍼런스 타입을 대상으로 각 타입의 쓰임새에 맞는 제약조건이 반영된 수명을 부여하려고 함 (컴파일 시점에서만 존재하는 가상 개념)
2.4.2. 지역변수 빌려오기
•
지역변수의 레퍼런스를 빌려올때는 레퍼런스를 그 변수의 범위 밖으로 가지고 나갈 수 없음
•
댕글링 참조
◦
메모리 안전성을 보장하기 위해 참조가 유효하지 않게 될 수 있는 상황을 컴파일 타임에 차단
◦
x는 스코프가 끝나면서 메모리에서 해제되었음
◦
여전히 r이 x를 참조하려고 시도하면서 문제가 발생
fn main() {
{
let r;
{
let x = 1;
r = &x; // `x` does not live long enough
}
assert_eq!(*r, 1); // Bad: Reads memory `x` used to occupy
}
}
Rust
복사
error: `x` does not live long enough
--> references_dangling.rs:8:5
|
7 | r = &x;
| ^^ borrowed value does not live long enough
8 | }
| - `x` dropped here while still borrowed
9 | assert_eq!(*r, 1); // bad: reads memory `x` used to occupy
10| }
Rust
복사
•
정상적인 예시
fn main() {
{
let r;
{
let x = 1;
r = &x;
assert_eq!(*r, 1);
}
}
}
Rust
복사
2.4.2. Rust의 변경과 공유에 관한 규칙
•
공유된 접근은 읽기 전용 접근이다.
◦
공유된 레퍼런스로 빌려온 값은 읽을 수만 있다.
◦
공유된 레퍼런스가 살아 있는 동안에는 그 무엇도 참조 대상을 통해 도달할 수 있는 다른 대상을 변경할 수 없다. 소유자 역시 읽기 전용으로 설정되기 때문에 이 구조에 관여된 대상의 변경할 수 있는 레퍼런스가 아래에 존재할 수 없다.
◦
한마디로 동결 상태라고 보면 된다.
•
변경할 수 있는 접근은 배타적인 접근이다.
◦
변경할 수 있는 레퍼런스로 빌려온 값은 그 레퍼런스를 통해서만 접근할 수 있다.
◦
변경할 수 있는 레퍼런스가 살아 있는 동안에는 참조 대상을 통해 도달할 수 있는 다른 대상이나 참조할 수 있는 경로가 없다.
◦
변경할 수 있는 레퍼런스와 수명이 걸릴 수 있는 유일한 레퍼런스는 변경할 수 있는 레퍼런스 그 자체에만 빌려온 것들 뿐이다.
•
레퍼런스는 유형에 따라 참조 대상에 이르는 소유 경로상의 값들과 참조 대상을 통해 도달할 수 있는 값들을 가지고 할 수 있는 일이 다르다.
◦
공유된 참조(&)
▪
읽기 전용. 값 수정 불가
▪
다른 참조를 통해 읽는 것만 가능. 공유된 참조가 있는 동안 값 변경 불가
◦
변경 가능한 참조(&mut)
▪
해당 값을 수정할 수 있는 유일한 참조
▪
자기 값 + 하위 값만 수정 가능. 부모 값은 수정 불가
3. Rust가 실수를 막는 방법
3.1. 타입 변환
•
암시적인 타입 변환은 없음
•
as 키워드를 통해 명시적인 타입 변환만 가능
fn main() {
let a = 10;
let b = 30.4;
// Error: mismatched types
// let c = a + b;
// Use explicit type conversion
let c = a as f64 + b;
println!("{c}");
}
Rust
복사
3.2. 열거체 (Enum)
•
데이터를 가질 수도 있고, 타입이 꼭 같을 필요도 없음
enum Status {
Idle,
Run(i32),
Attack { damage: i32 },
Defend { defense: i32 },
}
fn main() {
let mut status = Status::Idle;
status = Status::Run(10);
status = Status::Attack { damage: 20 };
status = Status::Defend { defense: 5 };
}
Rust
복사
3.3. Option
•
Rust에는 NULL이 존재하지 않음
•
하지만 NULL이 필요할 때가 있는데 이를 위해 만들어진 타입
enum Option<T> {
Some(T),
None,
}
fn main() {
let x: Option<i32> = Some(5);
let y: Option<i32> = None;
println!("{:?}", x);
println!("{:?}", y);
println!("{}", x.unwrap_or(0));
match y {
Option::Some(v) => println!("Value: {}", v),
Option::None => println!("No value"),
}
}
Rust
복사
3.4. Result
•
특정 함수의 동작 결과를 성공, 실패로 나타내기 위한 열거체 타입
enum Result<T, E> {
Ok(T),
Err(E),
}
fn square_if_even(num: i32) -> Result<i32, String> {
if num % 2 == 0 {
Ok(num * num)
} else {
Err(String::from("Not even"))
}
}
fn main() {
let num1 = 4;
let num2 = 5;
match square_if_even(num1) {
Ok(v) => println!("Result: {}", v),
Err(e) => println!("Error: {}", e),
}
match square_if_even(num2) {
Ok(v) => println!("Result: {}", v),
Err(e) => println!("Error: {}", e),
}
}
Rust
복사
3.5. 패턴 매칭 (Pattern Matching)
•
switch-case문과 유사한 동작
•
Rust의 match 표현식은 값을 표현할 수 있는 모든 범위를 처리해야함
fn main() {
let num = 100;
match num {
0 => println!("Zero"),
1 | 3 | 5 | 7 | 9 => println!("1-digit Odd"),
2..=8 => println!("1-digit Even"),
num @ 10..=99 => println!("2-digit: {num}"),
_ => println!("3-digit or more"),
}
}
Rust
복사
3.6. Copy와 Clone
3.6.1. C++ 댕글링 포인터 이슈
•
C++에서는 얕은 복사로 인해 댕글링 포인터 문제 발생 가능
◦
깊은 복사 사용 필요
int main()
{
SpreadSheet sheet1(1, 10, 5);
{
SpreadSheet sheet2(sheet1);
sheet1.SetCell(0, 0, 42);
// sheet2.GetCell(0, 0) == 42
std::cout << sheet2.GetCell(0, 0) << '\n';
}
// sheet1.GetCell(0, 0) == 42
std::cout << sheet1.GetCell(0, 0) << '\n';
return 0;
}
Rust
복사
SpreadSheet(const SpreadSheet &src)
: SpreadSheet(src.m_id, src.m_rows, src.m_cols)
{
for (int i = 0; i < m_rows; ++i)
{
for (int j = 0; j < m_cols; ++j)
{
m_data[i][j] = src.m_data[i][j];
}
}
}
Rust
복사
3.6.2. Rust의 Copy와 Clone
•
얕은 복사와 깊은 복사를 위한 트레잇 copy와 clone을 구분
•
얕은 복사를 했을 때 문제가 생길 수 있는 타입에 대해 Copy 트레잇 구현 하지 않고, Clone 트레잇만 구현해 깊은 복사만 할 수 있도록 강제함
fn hello(name: String) {
println!("Hello, {name}");
}
fn square(num: i32) -> i32 {
num * num
}
fn main() {
// Only implement Clone trait, not Copy
let name = String::from("Chris");
hello(name.clone());
hello(name);
// Implement Copy and Clone traits
let num = 5;
println!("square({num}): {}", square(num));
println!("square({}): {}", num + 5, square(num + 5));
}
Rust
복사
3.7. 부동소숫점과 정렬
3.7.1. 부동소숫점 정렬 에러 예시
•
다음 코드는 컴파일이 되지 않는다. 왜 그럴까?
fn main() {
let mut arr = vec![1.2, 4.5, 3.1, -5.7, 6.3];
arr.sort();
println!("{:?}", arr);
}
Rust
복사
•
sort() 를 사용하기 위해서는 Ord 트레잇이 구현된 타입이어야함.
◦
하지만 부동소숫점 타입인 f32와 f64는 Ord 트레잇이 구현되어 있지 않다. 왜 그럴까?
error[E0277]: the trait bound `{float}: Ord` is not satisfied
--> .\2 - Examples\f64_sort.rs:5:9
|
5 | arr.sort();
| ^^^ the trait `Ord` is not implemented for `{float}`
|
= help: the following other types implement trait `Ord`:
isize
i8
i16
i32
i64
i128
usize
u8
and 4 others
note: required by a bound in `slice::<impl [T]>::sort`
--> C:\Users\utilForever\.rustup\...\rust\library\alloc\src\slice.rs:209:12
|
207 | pub fn sort(&mut self)
| ---- required by a bound in this associated function
208 | where
209 | T: Ord,
| ^^^ required by this bound in `slice::<impl [T]>::sort`
Rust
복사
3.7.2. 동치 관계
동치 관계는 우리가 두 값을 "같다"라고 말할 때, 그 "같음"이 가져야 할 세 가지 성질을 정의한 것
•
반사 관계 (Reflexive)
◦
어떤 값 x가 있을 때, 그 값은 항상 자기 자신과 같다는 것을 의미
◦
쉽게 말하면, 어떤 값은 자기 자신과 같아야 한다는 당연한 사실을 의미
◦
수식적 표현
▪
임의의 값 x가 어떤 집합 X에 속해 있다면, x는 반드시 자기 자신과 같아야 함
▪
예) 숫자 5는 항상 자기 자신인 5와 같아야 함
◦
수학적 표현
▪
x ∈ X에 대해, x ~ x
•
대칭 관계 (Symmetric)
◦
만약 어떤 값 x가 다른 값 y와 같다면, 그 반대로도 y가 x와 같다는 것을 의미
◦
쉽게 말하면, A가 B와 같다면, B도 A와 같아야 한다는 논리
◦
예) x = 3이고 y = 3이면, x와 y가 같다는 것은 y와 x가 같다는 것을 의미해야
◦
수학적 표현
▪
만약 x ~ y라면, 반드시 y ~ x여야 함
•
추이적 관계 (Transitive)
◦
만약 x가 y와 같고, y가 z와 같다면, x와 z도 같다는 것을 의미
◦
쉽게 말하면, A가 B와 같고, B가 C와 같다면, A와 C도 같아야 한다는 논리
◦
예) x = 4이고 y = 4이며, z = 4라면, x와 z는 당연히 같아야 함
◦
수학적 표현
▪
만약 x ~ y이고 y ~ z라면, x ~ z가 성립해야 함
3.7.3. 동치관계와 Rust의 연관성
•
Rust는 각 연산마다 대부분 트레잇이 하나 존재함 (예: Add, Sub 등)
◦
하지만 비교 연산은 트레잇이 2개 있음
▪
일치 연산: Eq, PartialEq
▪
비교 연산: Ord, PartialOrd
◦
이렇게 만든 이유는 동치 관계(Equivalence Relation) 때문임
•
대부분의 기본 타입은 동치 관계 조건을 모두 만족함 (완전 동치 관계: Full Equivalence Relation)
◦
하지만 부동소숫점은 동치 관계 조건 중 반사 관계를 만족하지 않음
(부분 동치 관계: Partial Equivalence Relation)
◦
그 이유는 부동소숫점 연산 과정에서 발생할 수 있는 NaN 때문임
▪
NaN 은 수학적으로 특수한 값 (Not a Number)
▪
비교 연산에서 동치 관계를 만족시키지 못함
•
NaN == NaN → false
◦
이로 인해 Rust는 부동소숫점인 f32, f64 타입은 Eq, Ord 트레잇을 구현하지 않음
•
따라서, Rust에서 부동소숫점 타입이 저장된 컨테이너를 정렬하려면 다음과 같이 해야 함
fn main() {
let mut arr = vec![1.2, 4.5, 3.1, -5.7, 6.3];
// Can't use arr.sort() because f64 doesn't implement Ord
// arr.sort();
// Instead, use sort_by
arr.sort_by(|a, b| a.partial_cmp(b).unwrap());
println!("{:?}", arr);
}
Rust
복사
4. Rust의 메모리 관리 활용
4.1. 클로저(Closure)
•
익명 함수로 람다 표현식이라고 말하기도 함
•
Rust는 클로저에 작성하는 코드에 따라 클로저의 트레잇 구현을 달리함
◦
예를 들어, 드롭이 들어간 클로저가 있는 경우, 두번 실행되면 곤란 해진다는 걸 내부적으로 감지
fn call_twice<F>(closure: F) where F: Fn() {
closure();
closure();
}
fn main() {
let name = String::from("Chris");
let hello = || {
println!("Hello, {name}");
drop(name);
};
call_twice(hello);
}
Rust
복사
error[E0525]: expected a closure that implements the `Fn` trait, but this closure only implements `FnOnce`
--> .\14_closure.rs:8:17
|
8 | let hello = || {
| ^^ this closure implements `FnOnce`, not `Fn`
9 | println!("Hello, {name}");
10| drop(name);
| ---- closure is `FnOnce` because it moves the variable `name` out of its environment
...
13| call_twice(hello);
| ------------ the requirement to implement `Fn` derives from here
|
| required by a bound in `call_twice`
--> .\14_closure.rs:1:39
|
1 | fn call_twice<F>(closure: F) where F: Fn() {
| ^^^ required by this bound in `call_twice`
error: aborting due to 1 previous error
Rust
복사
4.2. 동시성 (Concurrency)
Rust의 안전성은 멀티 스레드 프로그래밍에서 빛을 발함
4.2.1. 멀티스레드 - 스레드 세이프한 타입
•
멀티 스레드에서는 스레드 세이프한 타입만 사용할 수 있게 제한
◦
잘못된 코드 → 오류 발생
use std::rc::Rc;
use std::thread;
fn main() {
let rc1 = Rc::new("Hyundai".to_string());
let rc2 = rc1.clone();
thread::spawn(move || {
// Error
rc2.clone();
});
}
Rust
복사
error[E0277]: `Rc<String>` cannot be sent between threads safely
--> .\15_concurrency.rs:8:19
|
8 | thread::spawn(move || {
| ^^^^^^ within this `{closure@.\15_concurrency.rs:8:19: 8:26}`
| required by a bound introduced by this call
9 | // Error
10 | rc2.clone();
11 | });
| ^ `Rc<String>` cannot be sent between threads safely
|
= help: within `{closure@.\15_concurrency.rs:8:19: 8:26}`, the trait `Send` is not implemented for `Rc<String>`, which is required by `{closure@.\15_concurrency.rs:8:19: 8:26}: Send`
Rust
복사
◦
올바른 코드 (Arc 사용 예시)
▪
Arc는 Atomic Reference Counting의 약자로, Rust에서 멀티 스레드 환경에서 안전하게 참조 카운팅을 관리하기 위해 사용되는 타입. 기본적인 개념은 Rc(Reference Counted)와 비슷하지만, 중요한 차이는 Arc는 원자적(atomic) 연산을 사용하여 스레드 세이프하게 만들어졌다는 점
use std::sync::Arc;
use std::thread;
fn main() {
let arc1 = Arc::new("Hyundai".to_string());
let arc2 = arc1.clone();
thread::spawn(move || {
// No error
let _ = arc2.clone();
});
let _ = arc1.clone();
}
Rust
복사
4.2.2. 스레드 패닉 전파
•
자식 스레드가 패닉에 빠졌을 때 다른 스레드로 전파할 것인가 여부를 제어할 수 있음
◦
thread::spawn()으로 생성한 스레드는 독립적으로 실행됨
◦
해당 스레드에서 패닉이 발생하면 메인 스레드로 패닉이 자동으로 전파되지는 않음
◦
따라서 메인 스레드는 이 패닉을 join()을 통해 감지할 수 있음
◦
패닉 발생 시 적절한 에러 처리 또는 복구 작업을 수행할 수 있음
use std::{thread, time::Duration};
fn main() {
let handle1 = thread::spawn(|| {
for i in 1..=5 {
println!("Thread 1: Running - {i}");
thread::sleep(Duration::from_millis(500));
}
42
});
let handle2 = thread::spawn(|| {
println!("Thread 2: Running");
panic!("Thread 2: Panic!");
});
match handle1.join() {
Ok(result) => println!("Thread 1 is completed: {result}"),
Err(e) => println!("Panic occurs in Thread 1: {e:?}"),
}
match handle2.join() {
Ok(_) => println!("Thread 2 is completed"),
Err(e) => println!("Panic occurs in Thread 2: {e:?}"),
}
}
Rust
복사
Thread 1: Running - 1
Thread 2: Running
Panic occurs in Thread 2: Any
Thread 1: Running - 2
Thread 1: Running - 3
Thread 1: Running - 4
Thread 1: Running - 5
Thread 1 is completed: 42
TypeScript
복사