And Brain said,
Rust, 프로그래밍 언어의 코페르니쿠스적 전환 - 2 본문
Rust
1. 소개
2. Rust 개발 환경 구축하기
3. 일반적인 프로그래밍 문법
4. Rust의 메모리 관리
5. 구조체, 열거형, 패턴 매칭
6 크레이트, 패키지, 모듈
7. 컬렉션, 그리고 제네릭
8. 트레잇
9. 라이프타임과 빌림 검사기
10. 클로저와 반복자
11. 에러 핸들링
12. 테스트와 문서화
13. I/O, 파일 처리
14. 스마트 포인터
15. 겁을 상실한 동시성 (fearless concurrency)
16. 위험한 Rust (unsafe rust)
17. Final Project: Multi-Thread Web Crawler
18. 끝으로
10. 클로저와 반복자
클로저 : 환경을 캡처하는 익명 함수
러스트의 클로저는 변수에 저장하거나 다른 함수에 인자로 넘길 수 있는 익명 함수입니다. 다른 언어에서 람다 함수라고 불리는 익명함수처럼 클로저는 일급 함수입니다. Rust의 클로저는 정의된 환경 캡처할 수 있다는 특징을 가지고 있는데, 환경을 캡처한다는 것은 클로저가 선언된 스코프의 변수를 잡아서(캡처해서) 사용할 수 있다는 것을 의미합니다. 이는 클로저가 선언된 위치의 컨텍스트를 '기억'한다는 의미와 같습니다.
let x = 4;
let equal_to_x = |z| z == x;
let y = 4;
assert!(equal_to_x(y));
여기서 'equal_to_x' 클로저는 자신이 선언된 스코프의 'x' 변수를 캡처합니다. 따라서 클로저 내부에서 'x'를 사용할 수 있습니다. 그 후 'y' 변수가 선언되고, 'equal_to_x' 클로저에 'y'를 인자로 전달하면, 클로저는 'y'와 캡처한 'x'를 비교합니다.
이러한 방식은 Rust의 일반 함수에서는 불가능한데, 자신이 선언된 스코프 외부의 변수를 직접 참조하거나 캡처할 수 없기 때문입니다.
fn main() {
let num = 5;
let add_num = |x| { x + num }; // 주변 환경의 변수 num을 캡처
let result = add_num(4);
println!("{}", result); // 출력: 9
}
'add_num' 클로저는 'x'라는 인자를 받고, 'x + num'을 반환합니다. 'num'은 클로저가 선언된 환경의 변수로, 클로저에서 캡처됩니다.
클로저의 캡처에 대해 좀 더 자세히 알아볼까요?
Rust의 클로저의 캡처 방식은 'Fn', 'FnMut', 'FnOnce'로 세 가지를 가집니다. 이는 각각 클로저가 환경의 변수를 어떻게 다루는지에 따라 Rust 컴파일러가 클로저에 자동으로 부여하는 트레잇입니다.
1. Fn
'Fn' 트레잇은 클로저가 환경의 변수를 불변 참조로 캡처합니다.
let x = 7;
let print_x = || println!("{}", x); // x를 불변으로 캡처
print_x(); // "7"을 출력
// x 값을 변경하는 것은 불가능함
2. FnMut
'FnMut' 트레잇은 클로저가 환경의 변수를 가변 참조로 캡처합니다.
let mut x = 7;
{
let mut increment_x = || x += 1; // x를 가변으로 캡처
increment_x();
}
println!("{}", x); // "8"을 출력, x의 값이 변경됨
3. FnOnce
'FnOnce' 트레잇은 클로저가 환경의 변수를 소유하며, 이는 클로저가 해당 변수를 소비(한 번만 사용)한다는 의미입니다.
let x = String::from("이리오너라");
let print_x = move || println!("{}", x); // x를 소유
print_x(); // "이리오너라"를 출력
// print_x(); // 또다시 print_x를 호출하면 컴파일 에러 발생, x가 이미 소비되었기 때문
'FnOnce' 트레잇은 'move' 키워드와 함께 사용될 때 특히 유용합니다. 'move' 키워드는 클로저가 캡처하는 환경의 변수를 소유하도록 합니다. 이는 클로저가 해당 변수를 메모리에서 직접 처리하도록 하며, 클로저의 호출이 끝나면 해당 변수는 소비(drop)됩니다.
반복자
반복자는 Rust에서 컬렉션의 아이템을 반복 처리하는데 사용되는 패턴으로, 'Iterator'라는 트레잇을 구현하는 모든 객체를 가리킵니다. 이 트레잇은 'next' 메서드를 가지며, 이 메서드를 호출하면 컬렉션의 다음 아이템을 반환합니다.
let v = vec![1, 2, 3];
let mut iter = v.iter();
assert_eq!(iter.next(), Some(&1));
assert_eq!(iter.next(), Some(&2));
assert_eq!(iter.next(), Some(&3));
assert_eq!(iter.next(), None);
위 예제에서 'v.iter()'는 벡터 'v'에 대한 반복자를 생성합니다. 'next' 메서드는 반복자의 현재 위치를 가리키는 아이템을 반환하고 위치를 다음 아이템으로 이동시킵니다. 모든 아이템을 처리한 후 'next'를 호출하면 'None'을 반환하여 더 이상 처리할 아이템이 없음을 알립니다.
반복자는 'for' 루프와 함께 사용하여 모든 아이템을 처리하는데 매우 편리합니다.
let v = vec![1, 2, 3];
for i in v.iter() {
println!("{}", i);
}
반복자에 대해 조금 더 자세히 알아봅시다.
Rust의 반복자는 매우 강력하며 많은 기능을 제공합니다. 우선, 반복자의 가장 중요한 특징 중 하나는 '지연 계산(Lazy evaluation)'입니다. 이는 반복자의 메서드가 호출되었을 때 즉시 실행되는 것이 아니라, 실제로 값이 필요할 때까지 연산을 지연시킨다는 의미입니다. 이는 계산 비용을 줄이고 성능을 향상시키는데 도움이 됩니다.
다음으로, 반복자는 다양한 메서드를 제공하여 원하는대로 컬렉션을 변형하거나, 필터링하거나, 폴딩하는 등의 작업을 수행할 수 있습니다. 예를 들어 'map' 메서드는 반복자의 각 항목에 함수를 적용하여 새로운 반복자를 생성합니다.
let numbers = vec![1, 2, 3];
let squared: Vec<_> = numbers.iter().map(|x| x * x).collect();
assert_eq!(squared, vec![1, 4, 9]);
여기서 'map' 메서드는 각 숫자를 제곱하는 클로저를 적용하고, 'collect' 메서드는 그 결과를 새로운 벡터에 수집합니다.
또한 'Filter' 메서드를 사용하여 조건을 만족하는 항목만 선택할 수 있습니다.
let numbers = vec![1, 2, 3, 4, 5, 6];
let even_numbers: Vec<_> = numbers.into_iter().filter(|x| x % 2 == 0).collect();
assert_eq!(even_numbers, vec![2, 4, 6]);
여기서 'filter' 메서드는 각 숫자를 확인하고, 숫자가 짝수인 경우만 새로운 벡터에 포합시킵니다.
이외에도 반복자는 'fold', 'all', 'any', 'zip', 'chain', 'enumertae', 'peekable' 등 많은 다른 메서드를 제공하여, 복잡한 반복 연산을 간결하고 효율적으로 처리할 수 있습니다.
11. 에러 핸들링
Rust에서 에러를 처리하는 방법은 크게 두 가지로, 복구 가능한 에러를 위한 'Result<T, E>'와 복구 불가능한 에러를 위한 'panic!'입니다.
Result<T, E>를 사용한 복구 가능한 에러 핸들링
복구 가능한 에러는 프로그램의 일부에 문제가 발생했지만, 이를 적절하게 처리하고 프로그램의 작동을 계속할 수 있는 상황을 말합니다.
Rust에서 'Result'는 함수가 에러 상태를 반환할 수 있는 경우에 사용하는 타입입니다. 예를 들어, 파일을 열거나 웹 API를 호출하는 등의 작업은 실패할 가능성이 있으므로, 이러한 함수는 보통 'Result' 타입을 반환합니다.
'Result'는 'Ok'와 'Err'의 두 개의 variant를 가지는 열거형입니다. 아래의 예제는 'Result'를 사용하여 함수가 성공하거나 실패하는 두 가지 경우를 모두 명시적으로 처리하여 복구 가능한 에러를 처리합니다.
use std::fs::File;
fn open_file(path: &str) -> Result<File, std::io::Error> {
File::open(path)
}
fn main() {
match open_file("somefile.txt") {
Ok(file) => println!("File opened successfully."),
Err(e) => println!("Failed to open the file: {}", e),
}
}
panic! 을 사용한 복구 불가능한 에러 핸들링
복구 불가능한 에러는 프로그램의 작동을 계속할 수 없는 심각한 오류를 나타냅니다.
'panic!' 매크로는 Rust에서 복구 불가능한 에러를 핸들링하는 방법으로, 'panic!'이 호출되면 프로그램은 즉시 멈추고, 실행 중이던 스택을 모두 풀어(unwind) 무엇이 문제였는지를 알려주는 에러 메시지와 함께 프로그램이 종료됩니다. 아래의 예제는 'panic!' 매크로가 호출되면, "crash and burn" 이라는 메시지와 함께 프로그램이 즉시 종료됩니다.
fn main() {
panic!("crash and burn");
}
Rust의 배열에서 범위를 벗어난 인덱스에 접근하려고 시도할 때 'panic!' 이 자동으로 호출됩니다.
fn main() {
let v = [1, 2, 3];
v[99];
}
v[99];
^^^^^ index out of bounds: the length is 3 but the index is 99
두 가지 유형의 에러 처리 방식을 사용함으로써, Rust는 프로그래머가 예상치 못한 문제를 적절히 처리할 수 있도록 합니다. 또한, Rust는 이 두 가지 방식을 명확히 구분함으로써 '실패'에 대한 엄격한 의미를 가지도록 합니다. 이는 Rust 코드가 더욱 안정적이고 예측 가능하게 동작하도록 도와줍니다.
12. 테스트와 문서화
테스트
이번엔 Rust에서 테스트를 작성하고 실행해 봅시다.
#[cfg(test)] // 이 모듈은 cargo test를 실행할 때에만 컴파일됩니다.
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4); // assert_eq! 매크로는 두 값이 같을 것으로 예상합니다.
}
}
$ cargo test
커맨드 라인에서 cargo test를 실행하면, Rust는 '#[test]'로 마크된 모든 함수를 실행합니다.
테스트의 목적은 코드가 예상대로 작동하는지 확인하는 것입니다. Rust에서는 'assert!', 'assert_eq!', 'assert_ne!' 같은 매크로를 사용하여 예상 결과를 검증합니다.
'assert!' 는 주어진 조건이 참인지 검사하고, 'assert_eq!'와 'assert_ne!'는 두 값이 각각 같거나 다른지 검사합니다.
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert!(true); // 성공!
}
#[test]
fn it_fails() {
assert!(false); // 실패!
}
#[test]
fn equality() {
assert_eq!(2, 1+1); // 성공!
assert_ne!(2, 1+2); // 성공!
}
}
테스트 무시하기
'#[ignore]' 속성을 사용하여 특정 테스트를 무시하고 실행하지 않을 수 있습니다. 이는 일시적으로 실패한 테스트를 무시하거나, 시간이 너무 오래 걸리는 테스트를 실행하지 않기 위해 유용합니다.
#[cfg(test)]
mod tests {
#[test]
#[ignore]
fn expensive_test() {
// 코드를 실행하는데 시간이 오래 걸리는 테스트
}
}
사용자 정의 실패 메시지
'assert!'와 'assert_eq!' 매크로는 테스트가 실패할 경우 출력되는 사용자 정의 메시지를 지원합니다.
#[test]
fn it_works() {
assert_eq!(2 + 2, 5, "Two plus two does not equal five!");
}
결과 반환 테스트
테스트에서 'Result<T, E>'를 반환하면, 'Ok(())'는 테스트 성공을 의미하고 'Err(String)'은 실패를 의미합니다. 이 방법을 사용하면 '?' 연산자를 사용하여 쉽게 에러를 처리할 수 있습니다.
use std::fs::File;
use std::io::Read;
fn read_file() -> Result<String, std::io::Error> {
let mut file = File::open("file.txt")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
#[test]
fn it_works() -> Result<(), String> {
let contents = read_file().map_err(|err| format!("Failed to read file: {}", err))?;
if contents == "이리오너라, Rust야!" {
Ok(())
} else {
Err(String::from("Unexpected file content!"))
}
}
이 테스트 케이스에서는, read_file 함수가 에러를 반환하면 연산자가 이를 감지하여 바로 테스트가 실패하도록 만들었습니다. 이는 read_file 함수가 실패하면 이후의 코드를 실행하지 않고 바로 에러를 반환하기 때문입니다. 따라서, 이 코드는 "file.txt"라는 파일이 있고 그 내용이 "이리오너라, Rust야!"라는 것을 확인하는 테스트 케이스가 됩니다. 만약 파일이 없거나 내용이 다르다면, 테스트는 실패하게 될 것입니다.
테스트 설정과 해체
때때로 테스트 전후에 일부 코드를 실행해야 할 수도 있습니다. 이를 설정(setup)과 해체(teardown)라고 합니다. 이러한 코드는 '#[test]' 함수 내에서 직접 실행될 수 있습니다.
#[test]
fn it_works() {
setup();
// 테스트 코드
teardown();
}
fn setup() {
// 실행 전에 필요한 설정 코드
}
fn teardown() {
// 테스트 후에 필요한 정리 코드
}
테스트 모듈과 중첩된 테스트
테스트 함수를 모듈 내부로 이동하여 그룹화하거나 중첩할 수 있습니다. 이렇게 하면 관련된 테스트들을 쉽게 관리할 수 있습니다.
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
mod nested_tests {
#[test]
fn it_works_too() {
assert_eq!(2 * 2, 4);
}
}
}
Rust의 테스팅 프레임워크는 비동기 테스트, 테스트 목록 및 필터링, 테스트 출력 제어 등의 다양한 기능들을 제공하니 여러분들의 다양한 테스트 시나리오를 대응해 보시길 바랍니다.
문서화
Rust 커뮤니티는 코드 내에 문서를 쓰는 것을 권장합니다. 이는 Rust가 자체 문서화 도구인 rustdoc을 통해 문서 주석을 직접 코드의 문서화된 HTML 페이지로 변환할 수 있기 때문입니다.
Rust에서 문서화 주석은 /// 로 시작합니다. 이 주석들은 바로 아래에 있는 항목에 대한 설명을 제공합니다. 주석 안에 Markdown 형태의 텍스트를 쓸 수 있습니다.
또한, //! 를 사용하여 모듈 레벨의 문서화를 작업할 수 있습니다.
//! # Doggy
//!
//! `Doggy`는 간단한 수학 연산을 수행하는 유틸리티 함수를 제공합니다.
/// 이 모듈은 산술 연산에 대한 함수를 제공합니다.
pub mod arithmetic {
/// 두 정수의 합을 반환합니다.
///
/// # 예제
///
/// ```
/// use Doggy::arithmetic;
///
/// assert_eq!(arithmetic::add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// 두 정수의 차를 반환합니다.
///
/// # 예제
///
/// ```
/// use Doggy::arithmetic;
///
/// assert_eq!(arithmetic::subtract(2, 3), -1);
/// ```
pub fn subtract(a: i32, b: i32) -> i32 {
a - b
}
}
/// 이 모듈은 기하학적 연산에 대한 함수를 제공합니다.
pub mod geometry {
/// 두 점 사이의 유클리디안 거리를 계산합니다.
///
/// # 예제
///
/// ```
/// use Doggy::geometry;
///
/// assert_eq!(geometry::distance((0, 0), (3, 4)), 5.0);
/// ```
pub fn distance((x1, y1): (f64, f64), (x2, y2): (f64, f64)) -> f64 {
((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt()
}
}
lib.rs 파일에 여러분들이 원하는 대로 문서화를 한 후에,
cargo doc --open
명령어를 통해 웹브라우저로 작성한 문서를 바로 확인하실 수 있습니다. 이 문서에는 각 함수에 대한 설명뿐만 아니라, 함수를 사용하는 예시 코드도 포함되어 있으므로, 사용자는 이를 참고하여 사용할 수 있습니다.
13. I/O, 파일 처리
Rust에서는 표준 라이브러리로 제공되는 'std::fs'와 'std::io' 모듈을 통해 파일 I/O를 처리할 수 있습니다.
'std::fs' 모듈은 파일 시스템과 관련된 기능을 제공하며, 파일의 생성, 삭제, 읽기, 쓰기 등의 작업을 수행할 수 있습니다. 또한, 디렉토리와 관련된 작업을 수행하는 함수도 제공합니다.
'std::io' 모듈은 입출력 스트림과 관련된 기능을 제공합니다. 이 모듈을 사용하면 표준 입력, 표준 출력, 파일 등의 다양한 입출력 소스로부터 데이터를 읽거나 쓸 수 있습니다.
use std::fs::File;
use std::io::prelude::*;
fn main() -> std::io::Result<()> {
let mut file = File::create("foo.txt")?;
file.write_all("이리오너라, Rust야!".as_bytes())?;
Ok(())
}
위 예제는 'foo.txt' 라는 이름의 파일을 생성하고, "이리오너라, Rust야!" 라는 문자열을 파일에 쓰는 작업을 수행합니다. 'File::create' 함수는 새 파일을 생성하며, 'write_all' 메서드는 파일에 데이터를 쓸 수 있게 합니다.
use std::fs::File;
use std::io::prelude::*;
fn main() -> std::io::Result<()> {
let mut file = File::create("foo.txt")?;
file.write_all("이리오너라, Rust야!".as_bytes())?;
// --위의 예제
let mut file = File::open("foo.txt")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
println!("{}", contents);
Ok(())
}
위 예제는 'foo.txt'라는 이름의 파일에서 데이터를 읽어와서, 그 내용을 표준 출력으로 출력합니다. 'File::open' 함수는 기존의 파일을 열고, 'read_to_string' 메서드는 파일의 내용을 문자열로 읽어옵니다.
이번엔 좀 리팩토링을 해볼까요?
'file_handler' 라는 모듈을 만들어, 파일 작성 및 읽기 함수를 옮기고 추가적으로 파일 복사 함수도 추가해 보겠습니다.
// file_handler.rs
use std::fs;
use std::fs::File;
use std::io::prelude::*;
/// 파일에 주어진 문자열을 작성합니다.
///
/// # Arguments
/// * 'filename' - 작성할 파일의 이름입니다.
/// * 'contents' - 파일에 작성할 내용입니다.
///
/// # Errors
/// 이 함수는 파일 생성 혹은 쓰기 도중에 발생하는 모든 I/O 에러를 반환합니다.
pub fn write_to_file(filename: &str, contents: &str) -> std::io::Result<()> {
let mut file = File::create(filename)?;
file.write_all(contents.as_bytes())?;
Ok(())
}
/// 파일에서 문자열을 읽습니다.
///
/// # Arguments
/// * 'filename' - 읽음 파일의 이름입니다.
///
/// # Erros
/// 이 함수는 파일 열기 혹은 읽기 도중에 발생하는 모든 I/O 에러를 반환합니다.
pub fn read_from_file(filename: &str) -> std::io::Result<String> {
let mut file = File::open(filename)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
/// 한 파일의 내용을 다른 파일로 복사합니다.
///
/// # Arguments
/// * 'src' 소스 파일의 이름입니다.
/// * 'dst' 대상 파일의 이름입니다.
///
/// # Errors
/// 이 함수는 파일 열기, 읽기, 쓰기 도중에 발생하는 모든 I/O 에러를 반환합니다.
pub fn copy_file(src: &str, dst: &str) -> std::io::Result<()> {
let contents = read_from_file(src)?;
write_to_file(dst, &contents)
}
이제 main.rs 에 다음과 같이 모듈을 import 하고 함수를 호출합니다.
mod file_handler;
fn main() -> std::io::Result<()> {
file_handler::write_to_file("foo.txt", "이리오너라, Rust야!")?;
let contents = file_handler::read_from_file("foo.txt")?;
println!("{}", contents);
// 파일 복사
file_handler::copy_file("foo.txt", "bar.txt")?;
Ok(())
}
14. 스마트 포인터
이전 소유권 챕터에서 스마트 포인터에 대해 얘기만 하고 설명하지 않았었는데, 이제 스마트 포인터에 대해 알아봅시다. 스마트 포인터는 기본적으로 데이터에 대한 포인터지만, 추가적인 메타데이터와 기능을 가지고 있습니다. 이 장에서는 가장 일반적인 스마트 포인터 몇 가지만 살펴보도록 하겠습니다.
그전에, 먼저 역참조와 'Deref' 트레잇에 대해 짚고 넘어가 봅시다.
역참조
역참조(Dereferencing)는 포인터나 참조가 가리키는 값을 가져오는 작업을 말합니다. Rust에서는 역참조 연산자로 * 를 사용합니다.
fn main() {
let x = 5;
let y = &x; // y는 x에 대한 참조입니다.
assert_eq!(5, *y); // 이곳에서 y를 역참조합니다.
}
여기서 *y는 y를 역참조한다는 의미로, y가 가리키는 값을 가져오는 것을 의미합니다. 이로써 'assert_eq!' 매크로가 true를 반환하며, 프로그램은 성공적으로 종료됩니다.
Deref
'Deref' 트레잇은 Rust의 참조 및 역참조(*) 연산자의 동작을 오버라이드하는 메서드를 제공합니다. 즉, 특정 유형에 대해 * 연산자가 어떻게 작동하는지 커스텀하게 정의할 수 있습니다.
pub trait Deref {
type Target: ?Sized;
fn deref(&self) -> &Self::Target;
}
이 트레잇은 하나의 메서드인 'deref'만을 요구하며, 이 메서드는 '&self'에 대한 참조를 취하고 '&Self::Target' 에 대한 참조를 반환합니다. 이때 '&Self::Target' 은 트레잇 연관 타입으로, 'Deref' 를 구현하는 타입이 어떤 타입에 대해 역참조될 것인지를 지정합니다.
use std::ops::Deref;
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, *y);
}
여기서 'MyBox' 라는 사용자 정의 타입을 만들고, 이 타입에 'Deref' 트레잇을 구현하였습니다. 이를 통해 'MyBox' 인스턴스를 * 연산자를 사용해 역참조할 수 있게 되었습니다.
자, 이제 Deref 트레잇을 구현하고 있는 스마트 포인터들 'Box<T>', 'Rc<T>', 'RefCell<T>'에 대해 알아봅시다.
Box<T>
Box<T>는 가장 기본적인 스마트 포인터 중 하나로서, 주요 용도는 다음과 같습니다.
1. 컴파일 시점에 크기를 알 수 없는 타입을 가지고 작업할 때: Rust는 컴파일 시점에 대부분의 타입의 크기를 알아야 합니다. 그러나 재귀적인 데이터 구조같은 경우에는 이를 만족시키기 어려울 수 있습니다. 이런 상황에서 Box<T>를 사용하면 컴파일러에게 필요한 공간이 얼마나 될지 명확하게 알려줄 수 있습니다.
2. 큰 데이터를 힙에 저장할 때: 만약 매우 큰 데이터를 스택에 저장하면 오버플로우가 발생할 수 있습니다. 이런 상황에서 Box<T>를 사용하면 힙에 데이터를 안전하게 저장할 수 있습니다.
3. 값을 소유하면서 타입을 추상화할 때: 특정 트레잇을 구현하는 여러 타입 중 하나를 반환하려는 함수가 있을 때, 이를 가능하게 하는 한 가지 방법은 이러한 타입들을 Box로 감싸는 것입니다.
다음은 간단한 'Box<T>'의 사용 예시입니다.
fn main() {
let b = Box::new(5);
println!("b = {}", *b);
}
'Box::new' 함수를 사용해 5라는 값으로 새 'Box<i32>' 를 생성합니다. 'Box<T>'의 데이터는 힙에 저장되지만, 'b' 자체는 스택에 저장됩니다. 'b'가 법위를 벗어나면 'Box<T>'의 'Drop' 트레잇 구현에 의해 힙 데이터도 함께 삭제됩니다.
그러면 'Box<T>'가 왜 필요한지 살펴볼까요? 리스트 데이터 구조를 만드는 방법으로 일반적으로 재귀적인 방식을 사용하는데, 'Box<T>'를 사용하지 않으면 이런 데이터 구조를 만드는 것이 불가능합니다.
enum LinkedList<T> {
Node(T, Box<LinkedList<T>>),
End,
}
use LinkedList::{Node, End};
fn main() {
let list: LinkedList<i32> = Node(1,
Box::new(Node(2,
Box::new(Node(3,
Box::new(End))))));
}
위 예제에서 'LinkedList'는 재귀적으로 정의된 열거형입니다. 각 'Node'는 값과 다음 노드를 가리키는 포인터를 갖습니다. 이때 'Box<T>'를 사용함으로써 런타임에 메모리를 할당하고, 재귀적인 데이터 구조를 만들 수 있게 됩니다. 'End'는 리스트의 끝을 나타내는 역할을 합니다.
Rc<T>
'Rc<T>'는 참조 카운팅(reference counting)의 줄임말로, 여러 곳에서 읽기 전용으로 접근할 수 있는 데이터를 힙에 저장하는 스마트 포인터입니다. 'Rc<T>'는 데이터에 대한 참조가 몇 개인지 추적합니다. 데이터에 대한 참조가 0이 되면( 'Rc<T>'가 모두 범위를 벗어나거나 'drop'이 호출될 때), 'Rc<T>'는 데이터를 정리합니다.
다음은 'Rc<T>'의 간단한 사용 예입니다.
use std::rc::Rc;
let five = Rc::new(5);
let shared_five = Rc::clone(&five);
println!("{} is shared by {} pointers", *shared_five, Rc::strong_count(&five));
'Rc::new'는 새로운 'Rc<T>' 인스턴스를 생성하고, 'Rc::clone'은 참조 카운트를 증가시킵니다(실제 데이터를 복제하지는 않습니다). 'Rc::strong_count'는 특정 'Rc<T>' 인스턴스를 참조하는 포인터의 수를 반환합니다.
'Rc<T>'는 여러 소유자를 필요로 하는 상황에서 사용되는데, 여러분도 알다시피 Rust의 소유권 규칙은 한 번에 하나의 변수만이 데이터를 소유할 수 있도록 규정합니다. 그러나 실제 프로그래밍에서는 특정 데이터를 여러 곳에서 동시에 참조하거나 소유하는 경우가 종종 있는데, 이때 'Rc<T>'를 사용합니다.
use std::rc::Rc;
struct TreeNode<T> {
value: T,
children: Vec<Rc<TreeNode<T>>>,
}
impl<T> TreeNode<T> {
fn new(value: T) -> TreeNode<T> {
TreeNode {
value,
children: Vec::new(),
}
}
}
fn main() {
let leaf = Rc::new(TreeNode::new(5));
let node = TreeNode {
value: 10,
children: vec![Rc::clone(&leaf), Rc::clone(&leaf)],
};
println!("leaf is shared by {} nodes", Rc::strong_count(&leaf));
}
위 예시에서, 두 개의 노드가 동일한 자식 노드 'leaf' 를 참조하고 있습니다. 이때 'Rc<T>'를 사용하여 'leaf '의 소유권을 안전하게 공유할 수 있습니다. 'Rc::strong_count'를 통해 'leaf' 를 참조하는 노드의 수를 확인할 수 있습니다.
주의할 점은 'Rc<T>'는 여러 소유자를 허용하는데 사용되지만, 이는 읽기 전용 데이터에만 해당합니다. 즉, 'Rc<T>' 를 통해 참조하는 데이터를 변경하려면 추가적인 방법이 필요하며, 그것이 'RefCell<T>'라는 또 다른 스마트 포인터의 역할입니다.
RefCell<T>
'RefCell<T>' 는 컴파일 타임이 아닌 런타임에 불변성을 검사합니다. 일반적으로 Rust는 불변성을 컴파일 타임에 검사하여 안전성을 보장합니다. 그러나 'RefCell<T>' 를 사용하면 런타임에 불변성을 검사할 수 있습니다.
'RefCell<T>'는 'borrow'와 'borrow_mut' 메서드를 제공하여 내부 데이터에 대한 불변 참조자와 가변 참조자를 얻을 수 잇습니다. 이 메서드들은 각각 'Ref'와 'RefMut' 타입을 반호나합니다. 이 타입들은 참조 카운트를 내부적으로 유지하므로, 'RefCell<T>'은 한 번에 여러 불변 참조자 또는 단일 가변 참조자가 존재하는지 추적합니다.
다음은 'RefCell<T>'의 간단한 사용 예입니다.
use std::cell::RefCell;
fn main() {
let x = RefCell::new(42);
{
let mut y = x.borrow_mut();
*y += 1;
} // `y` goes out of scope and the mutable reference is dropped here
println!("{}", *x.borrow()); // prints "43"
}
x는 'RefCell'로 래핑된 정수를 가지고 있습니다. 먼저 'borrow_mut'를 사용해 x에 대한 가변 참조를 얻고 값을 증가시킵니다. 이 참조는 y가 스코프를 벗어나는 시점에 자동으로 해제됩니다. 이후 'borrow'를 사용해 x에 대한 불변 참조를 얻고 값을 출력합니다.
이번엔 'Rc<T>'와 'RefCell<T>' 를 함께 사용해봅시다.
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let value = Rc::new(RefCell::new(5));
let a = Rc::clone(&value);
let b = Rc::clone(&value);
let c = Rc::clone(&value);
*a.borrow_mut() += 1;
println!("a: {}", *a.borrow());
println!("b: {}", *b.borrow());
println!("c: {}", *c.borrow());
}
위 에제에서 'value'는 'RefCell'로 래핑된 정수를 'Rc'로 다시 래핑하고 있습니다. 이후 'Rc::clone'을 통해 'value'의 참조 카운트를 증가시키며, a,b,c를 생성합니다. 이 세 변수는 모두 동일한 데이터에 대한 소유권을 공유하며, 'RefCell'을 통해 가변성을 허용합니다. 따라서 a를 통해 값을 변경한 후 b와 c를 통해 그 값을 확인할 수 있습니다.
이렇게, 'Rc<T>'와 'RefCell<T>'를 함께 사용하여 여러개의 소유자에게 가변성을 허용할 수 있습니다.
'RefCell<T>'를 사용하면 런타임에 가변 참조를 안전하게 관리할 수 있지만, 이를 잘못 사용하면 런타임 에러를 유발할 수 있습니다. 따라서 'RefCell<T>'은 필요한 경우에만 사용하도록 합시다.
15. 겁을 상실한 동시성 (fearless concurrency)
대부분의 언어에서 병렬 및 동시 프로그래밍은 매우 복잡하고 버그를 일으키기 쉽습니다. 프로그래머들은 데이터 경쟁, 데드락 등의 문제를 해결하려고 조심스러워집니다. 하지만, 소유권이라는 아주 강력한 체계를 가진 Rust는 컴파일 타임에 이러한 문제들을 잡아내어 프로그래머들이 상남자답게 코드를 작성할 수 있게 해줍니다.
Rust에서는 'thread::spawn' 함수를 사용하여 새로운 스레드를 만들 수 있습니다. 이 스레드는 메인 스레드와 독립적으로 실행됩니다.
use std::thread;
use std::time::Instant;
use std::sync::mpsc::{channel, Sender, Receiver};
// 피보나치 수열의 n 번째 값을 재귀적으로 계산하는 함수입니다.
// n이 0이나 1일 때는 각각 0과 1을 반환하고, 그 외의 경우는 두 개의 이전 값의 합을 반환합니다.
fn fib(n: u32) -> u32 {
match n {
0 => 0,
1 => 1,
_ => fib(n - 1) + fib(n - 2),
}
}
// 각 스레드에서 수행할 작업을 정의하는 함수입니다.
// 채널의 송신자(tx)와 계산할 피보나치 수열의 위치(n)을 인자로 받습니다.
fn worker(tx: Sender<(u32, u32)>, n: u32) {
let result = fib(n); // 피보나치 수열의 n번째 값을 계산합니다.
tx.send((n, result)).unwrap(); // 계산 결과를 메인 스레드로 전송합니다.
}
fn main() {
let start = Instant::now(); // 시작 시간을 측정합니다.
// 채널을 생성합니다. 채널은 스레드 간에 데이터를 전송하는 데 사용됩니다.
let (tx, rx): (Sender<(u32, u32)>, Receiver<(u32, u32)>) = channel();
let mut children = Vec::new(); // 생성된 각 스레드의 핸들을 저장할 벡터를 생성합니다.
// 0부터 9까지, 각각의 i에 대해 스레드를 생성합니다.
for i in 0..10 {
let thread_tx = tx.clone(); // 채널의 송신자(tx)를 복제합니다. 각 스레드가 송신자를 소유할 수 있도록 합니다.
let child = thread::spawn(move || { // 새로운 스레드를 생성하고, 그 스레드에서 worker 함수를 실행합니다.
worker(thread_tx, i);
});
children.push(child); // 생성된 스레드의 핸들을 벡터에 저장합니다.
}
// 각 스레드에서 전송된 모든 결과를 받을 때까지 대기합니다.
for _ in 0..10 {
let (n, result) = rx.recv().unwrap(); // 결과를 수신합니다.
println!("fib({}) = {}", n, result); // 수신한 결과를 출력합니다.
}
// 모든 스레드가 완료될 때까지 메인 스레드가 기다리도록 합니다.
for child in children {
child.join().expect("Oops! The child thread panicked");
}
// 프로그램의 실행에 걸린 시간을 출력합니다.
println!("Elapsed time: {:?}", start.elapsed());
}
이 예제는 스레드를 사용해 복잡한 계산을 병렬로 수행하는 방법을 보여줍니다. 각 스레드는 피보나치 수열의 특정 값을 계산하고, 그 결과를 메인 스레드로 전송합니다. 메인 스레드는 모든 결과를 수신하고 출력한 다음, 모든 스레드가 완료될 때까지 대기합니다.
채널을 통한 메시지 전달은 스레드 간에 안전하게 데이터를 공유할 수 있도록 해줍니다.
use std::thread;
use std::sync::mpsc; // Multiple Producer, Single Consumer (MPSC) 라이브러리를 사용합니다.
fn main() {
let (tx, rx) = mpsc::channel(); // 채널 생성. tx는 'transmitter' (송신자), rx는 'receiver' (수신자)를 의미합니다.
let tx1 = tx.clone(); // 복수의 송신자를 생성하기 위해 채널 송신자를 복제합니다.
// 첫 번째 스레드를 생성합니다.
thread::spawn(move || {
// 스레드에서 데이터를 송신합니다.
tx1.send("이리오너라 Rust thread 1!").unwrap();
});
// 두 번째 스레드를 생성합니다.
thread::spawn(move || {
// 스레드에서 데이터를 송신합니다.
tx.send("이리오너라 Rust thread 2!").unwrap();
});
// 수신자를 통해 메시지를 수신하고 화면에 출력합니다.
// 메시지 순서는 스레드의 실행 순서에 따라 달라질 수 있습니다.
for received in rx {
println!("Received: {}", received);
}
}
두 개의 스레드를 생성하고, 각 스레드는 메시지를 채널을 통해 메인 스레드로 전송합니다. 이는 "메시지 전달" 동시성 패턴의 일종입니다. 스레드 사이에서 데이터를 공유하기 보다는 메시지를 주고 받는 방식을 통해 동시성을 관리합니다.
Mutex 와 Arc
또한, Rust는 'Mutex'와 'Arc'를 사용하여 복수의 스레드에서 상태 정보를 안전하게 공유할 수 있습니다. 'Mutex'는 상호 배제(Mutual Exclusion)를 의미하며, 한 번에 한 스레드만 데이터에 접근하도록 합니다. 'Arc'는 Atomic Reference Counting을 의미하며, 복수의 스레드에서 참조를 안전하게 공유할 수 있습니다.
use std::sync::{Arc, Mutex}; // Arc (Atomic Reference Counting) 와 Mutex (Mutual Exclusion) 라이브러리를 사용합니다.
use std::thread;
fn main() {
// Arc를 사용하여 Mutex를 여러 스레드 간에 안전하게 공유합니다.
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter); // Arc를 복제하여 다른 스레드로 이동시킵니다.
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // lock 메서드를 사용하여 Mutex를 잠그고, unwrap 메서드를 사용하여 결과를 언랩합니다.
*num += 1; // 공유 카운터를 증가시킵니다.
});
handles.push(handle); // 스레드 핸들을 보관합니다.
}
for handle in handles {
handle.join().unwrap(); // 각 스레드가 완료될 때까지 기다립니다.
}
println!("Result: {}", *counter.lock().unwrap()); // 최종 결과를 출력합니다.
}
위 예제는 10개의 스레드를 생성하고, 각 스레드는 공유 카운터를 증가시킵니다. 각 스레드는 'Arc'를 통해 'Mutex'에 대한 참조를 공유하며, 'Mutex'를 통해 카운터에 대한 동시 접근을 제어합니다. 이렇게 함으로써, 서로 다른 스레드에서 공유 데이터를 안전하게 수정할 수 있습니다. 이는 "공유 메모리" 동시성 패턴의 한 예입니다.
이러한 접근 방식을 사용하면 CPU의 모든 코어를 효율적으로 활용하여 복잡한 계산을 빠르게 수행할 수 있습니다. 또한 Rust의 소유권 덕분에, 여러분들은 데이터 경쟁이나 데드락 같은 복잡한 동시성 문제에 대해 조심스러워질 필요가 없습니다. 당신의 마음에 든 동시성에게 용기있게 다가가보세요!
16. 위험한 Rust (unsafe Rust)
이 장에서 배울 것들은 아주 위험합니다. 이번에는 조심스러워지셔야 할 겁니다. Rust는 매우 안전하고 강력한 언어라 기본적인 기능만으로도 대부분의 경우에 충분하지만, 때로는 금지된 욕망이 더 달콤해 보이지요. Rust를 사용해 엄청난 고성능의 작업이나 일부 시스템 레벨의 작업에서는 여러분들에게 Rust의 금지된 힘이 필요할 수 있습니다.
원시포인터의 역참조
Rust는 C와 같은 기타 언어로 작성된 코드와 상호작용하는 데 필요한 FFI(Foreign Function Interface)를 제공합니다. 이 경우, C 스타일 포인터인 원시 포인터를 사용해야 할 수 있습니다. 원시 포인터는 Rust에서 고급 포인터 타입인 참조자(&T, &mut T)와 다르게, 가리키는 메모리의 유효성이나 정당성에 대한 보장이 없는 포인터를 하는데, 이는 C/C++에서의 포인터와 유사한 역할을 수행하며, *const T와 *mut T 두 가지 타입이 있습니다. 또한, Rust의 컴파일러가 안정성 검사를 하지않으니 극강의 퍼포먼스를 뽑아낼 수도 있습니다. 하지만 Rust는 기본적으로도 타 언어들보다 성능이 뛰어납니다. 되도록이면 원시 포인터를 사용하는것은 삼가해주시길 바랍니다.
Rust는 기본적으로 null이거나 dangling 상태인 포인터를 역참조하는 것을 방지합니다. 그러나 unsafe 블록 내에서는 이러한 검사를 우회하고 원시 포인터를 역참조할 수 있습니다.
fn main() {
// 정수 100을 가리키는 불변 원시 포인터를 생성합니다.
let num: i32 = 100;
let raw_p: *const i32 = #
// 원시 포인터를 안전하게 역참조하려면 `unsafe` 블록이 필요합니다.
unsafe {
assert!(*raw_p == 100, "The pointed value is not correct!");
}
// 이제 가변 원시 포인터를 만들어보겠습니다.
let mut mutable_num: i32 = 200;
let mutable_raw_p: *mut i32 = &mut mutable_num;
// 가변 원시 포인터를 역참조하여 값을 변경하려면 `unsafe` 블록이 필요합니다.
unsafe {
*mutable_raw_p += 300;
assert!(*mutable_raw_p == 500, "The pointed value is not correct!");
}
}
여기서 assert! 매크로는 주어진 조건이 참인지 확인하고, 만약 거짓이면 패닉을 유발합니다. 이를 통해 원시 포인터를 올바르게 역참조하고 있는지 확인할 수 있습니다.
다시 강조하지만, 원시 포인터는 메모리 안전성을 깨뜨릴 위험이 있으므로, 가능한 한 사용을 피하고 Rust의 안전한 추상화를 사용하는 것이 좋습니다.
위험한 함수 (unsafe function)
이번엔 위험한 함수를 배워볼까요?
// 이 함수는 포인터를 매개변수로 받아 해당 주소의 값을 증가시키는 함수입니다.
unsafe fn increment(ptr: *mut i32) {
*ptr += 1; // 원시 포인터 역참조는 `unsafe`입니다.
}
fn main() {
let mut x = 5;
let p = &mut x as *mut i32; // 원시 포인터를 얻습니다.
// `unsafe` 블록 내에서 `increment` 함수를 호출합니다.
unsafe {
increment(p);
}
assert_eq!(x, 6); // x 값이 증가했음을 확인합니다.
println!("x = {}", x);
}
'increment' 함수가 원시 포인터를 역참조하여 해당 주소의 값을 증가시킵니다. 앞서 살펴봤듯이 이 작업은 Rust에서 금지된 일이기에, 이 함수는 unsafe로 표시됩니다.
이 unsafe 함수를 호출하려면, 호출하는 코드 역시 unsafe 블록 내에서 이루어져야 합니다. 이렇게 함으로써, 프로그래머가 금지된 힘을 사용한다고 명시적으로 표현하게 됩니다.
내부 가변성을 가지는 정적 변수의 접근
Rust에서는 'static mut'을 사용하여 가변 전역 변수를 생성할 수 있습니다. 그러나 이 변수에 접근하는 것은 unsafe 블록 내에서만 가능하며, 정확히 알고 있는 경우에만 이 기능을 사용해야 합니다. 왜냐하면 여러 스레드에서 동시에 가변 전역 변수를 변경하려고 시도하면 데이터 경쟁이 발생할 수 있기 때문입니다.
// 정적으로 할당된 전역 변수 COUNTER를 선언하고 초기화합니다.
// `mut` 키워드는 이 변수가 가변적임을 나타냅니다.
// `static mut`은 항상 `unsafe`를 사용해야 접근할 수 있으며, 이는 데이터 레이스의 위험 때문입니다.
static mut COUNTER: u32 = 0;
// COUNTER 값을 증가시키는 함수입니다.
// `unsafe` 블록 안에서 COUNTER에 접근하여 값을 증가시킵니다.
// 이 함수는 `unsafe`가 아닌 함수이지만, 내부에서 `unsafe` 블록을 사용합니다.
fn increment() {
unsafe {
COUNTER += 1;
}
}
// COUNTER 값을 출력하는 함수입니다.
// `unsafe` 블록 안에서 COUNTER에 접근하여 값을 출력합니다.
// 이 함수도 `unsafe`가 아닌 함수이지만, 내부에서 `unsafe` 블록을 사용합니다.
fn print_counter() {
unsafe {
println!("COUNTER: {}", COUNTER);
}
}
// main 함수에서 increment와 print_counter를 호출하여
// COUNTER 값을 증가시키고, 증가된 값을 출력합니다.
fn main() {
increment();
print_counter();
}
이 코드에서, 'COUNTER'는 'static mut' 키워드를 사용하여 정의된 가변 전역 변수입니다. 'increment' 함수와 'print_counter' 함수에서는 unsafe 블록을 사용하여 'COUNTER'에 접근하고 있습니다. 이런 방식은 'COUNTER'에 동시에 접근하는 스레드가 없는 경우에만 안전하게 사용할 수 있습니다.
하지만 이 경우에는 'Mutex'나 'AtomicUsize' 같은 타입을 사용하여 가변 전역 변수에 접근할 때 동기화 메커니즘을 사용한다면 데이터 경쟁을 방지할 수도 있습니다.
외부 (C 언어) 함수 호출
Rust에서는 'extern' 키워드를 사용해 C 라이브러리의 함수를 호출할 수 있습니다.
extern "C" {
// C 표준 라이브러리의 puts 함수를 선언합니다.
// 이 함수는 문자열을 받아 출력합니다.
fn puts(s: *const i8);
}
fn main() {
// C 스트링을 Rust에서 생성합니다.
let c_string = std::ffi::CString::new("이리오너라 C언어야!").unwrap();
unsafe {
// puts 함수를 호출합니다.
puts(c_string.as_ptr());
}
}
타 언어를 호출하는 것은 당연히 Rust가 안전성을 검사할 수는 없습니다. 그러니 여러분들이 직접 unsafe 블록을 사용하여 C 함수를 호출하는 책임을 짊어져야 합니다.
위험한 트레잇 (unsafe trait)
Rust에서 트레잇을 unsafe로 구현하는 것은 그 트레잇이 명시한 약속들을 보장하는 책임이 구현자에게 있다는 것을 의미합니다. 그전에 먼저, Rust의 Send와 Sync 트레잇에 대해 간략히 설명하겠습니다.
Send 트레잇: 이 트레잇은 해당 타입의 값이 다른 스레드로 안전하게 이동될 수 있음을 나타냅니다. 즉, 어떤 타입 T가 Send를 구현하면, T의 값은 한 스레드에서 다른 스레드로 전달될 수 있습니다.
Sync 트레잇: 이 트레잇은 해당 타입의 값이 여러 스레드에서 동시에 안전하게 접근될 수 있음을 나타냅니다. 즉, 어떤 타입 T가 Sync를 구현하면, T의 값은 여러 스레드에서 동시에 안전하게 참조될 수 있습니다.
이들 트레잇은 Rust의 타입 시스템의 일부로서, 스레드 간의 데이터 전달 및 공유가 안전하게 이루어질 수 있음을 컴파일 시간에 확인합니다. Send와 Sync 트레잇은 많은 Rust 타입에 대해 자동으로 구현됩니다. 그러나 이들 트레잇은 자동으로 구현되지 않는 타입에 대해서는 수동으로 구현해야 합니다. 이때 unsafe 키워드를 사용하게 됩니다.
// 어떤 타입이 Send인지 또는 Sync인지를 결정하는 것은 그 타입의 성질에 따라 달라집니다.
// 예를 들어, RawPointer라는 가공하지 않은 포인터 타입이 있다고 가정해봅시다.
struct RawPointer(*mut i32);
// RawPointer는 안전하게 다른 스레드로 전달될 수 없으므로 Send 트레잇이 자동으로 구현되지 않습니다.
// 그러나 만약 우리가 RawPointer가 항상 Send를 준수함을 보장할 수 있다면, 우리는 수동으로 Send를 구현할 수 있습니다.
unsafe impl Send for RawPointer {}
이 코드는 RawPointer가 Send를 구현한다고 선언하고 있습니다. 이는 RawPointer 값이 다른 스레드로 안전하게 이동될 수 있음을 의미합니다. 이 선언은 unsafe로 표시되어 있기 때문에, 이것은 프로그래머가 RawPointer 값이 안전하게 다른 스레드로 이동될 수 있음을 직접 보장하겠다는 약속입니다.
이렇게 unsafe 키워드를 통해 Rust의 금지된 힘을 사용할 수 있지만, 금지된 데에는 다 이유가 있는 것입니다. 최후의 비기라고 생각하시고 남용하지 않도록 조심하시길 바랍니다.
자, 이제 Rust의 기본적인 것들을 다 가르쳐드렸습니다. 우리의 여정이 끝이 보입니다. 여기까지 정말 고생 많으셨습니다. 이제 실전입니다. 저와 함께 마지막으로 실전 연습을 해봅시다.
<- 이전으로
다음으로 ->
Thanks for watching, Have a nice day.
References
https://rust-kr.github.io/doc.rust-kr.org/
Rust 가이드를 번역해주신 분들에게 정말 감사드립니다.
'IT > Rust' 카테고리의 다른 글
OS with Rust, OS 개발기 - 2. 운영체제로 거듭나다(Reborn OS) (0) | 2023.06.07 |
---|---|
OS with Rust, OS 개발기 - 1. 운영체제를 넘어서(Beyond OS) (0) | 2023.06.04 |
컴파일러(Compiler) 만들어보기 (with Rust) (0) | 2023.06.02 |
Rust, 프로그래밍 언어의 코페르니쿠스적 전환 - 3 (0) | 2023.05.28 |
Rust, 프로그래밍 언어의 코페르니쿠스적 전환 - 1 (3) | 2023.05.18 |