And Brain said,

Rust, 프로그래밍 언어의 코페르니쿠스적 전환 - 1 본문

IT/Rust

Rust, 프로그래밍 언어의 코페르니쿠스적 전환 - 1

The Man 2023. 5. 18. 16:48
반응형

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. 끝으로

 

1. 소개

 
Rust는 2015년에 정식 발표되어 나오자마자 가장 사랑받는 언어 3위, 이후 2016년부터 2022년까지 무려 7년 동안 1위를 차지하고 있는,
 

https://survey.stackoverflow.co/2022/#most-loved-dreaded-and-wanted-language-love-dread

현시점에서 가장 사랑받는 개발 언어 중 하나로 그 효율성과 안전성으로 인해, 마이크로소프트부터 아마존에 이르기까지 많은 대기업들이 러스트를 미래의 핵심 언어로 인식하고 있습니다.
 
디스코드는 자체 시스템의 Go 언어를 Rust로 교체하여 성능을 획기적으로 개선했으며, 드롭박스는 사용자 컴퓨터와의 파일 동기화를 위해 Rust를 활용하고 있습니다. 또한 클라우드플레어는 Rust를 이용해 전체 인터넷 트래픽의 20% 이상을 처리하고 있습니다.
 
마이크로소프트는 Rust를 Windows, Azure, 그리고 여러 다른 프로젝트에서 활용하고 있으며, Rust가 메모리 관련 버그를 방지하는 뛰어난 방법임을 인식하여 새로운 코드 작성에 Rust를 우선적으로 사용하고, C와 C++ 사용을 점차 줄이는 방향으로 가고 있습니다.

Google은 Rust를 자체 개발 중인 새로운 운영체제인 Fuchsia OS 개발에 활용하고 있고, Facebook은 블록체인 프로젝트인 Libra에서 Rust를 채택하였습니다.

그중에서도 특히 AWS는 Rust에 대해 특히 관심이 많은데, Amazon S3(Amazon Simple Storage Service), Amazon EC2, Amazon CloudFront, 그리고 AWS Lambda와 같은 주요 서비스에서 Rust를 사용하고 있습니다. 최근에는 Rust로 작성된 Linux 기반 컨테이너 운영체제인 보틀로켓(Bottlerocket)을 출시하였고, Nitro Enclaves와 같은 민감한 애플리케이션을 포함한 새로운 AWS Nitro 시스템 컴포넌트의 언어로 Rust를 선택하였습니다. 또한, 2020년 11월에는 Rust 컴파일러의 공동 리드였던 펠릭스 클록(Felix Klock)을 개발자로 영입하였습니다.
 
Rust는 이와 같이 단기간에 다양한 산업과 플랫폼에서 그 가치를 인정받고 있습니다.
 
어떻게 Rust는 혜성같이 등장해 단기간에 이 정도로 주목받을 수 있었을까요?
이는 Rust가 기존 프로그래밍 언어들이 가지고 있던 인식을 전환했기 때문인데, 과거의 언어들은 동적 메모리 할당 기능이 전혀 없거나, C 계열 언어와 같이 프로그래머가 직접 메모리를 할당한 후 수동으로 해제해야 했고, 이후의 언어들은 프로그래머가 직접 메모리 할당과 해제를 수행하지 않고 가비지 컬렉터가 제공하는 메모리 할당 및 해제 기능을 사용하는 언어를 사용했지만, 이런 언어들은 궁극적으로 가비지 컬렉터를 아무리 튜닝한다 해도 C 계열 언어들만큼의 퍼포먼스가 나오진 못했습니다.
 
이렇게 기존의 언어들은 안정성과 퍼포먼스 둘 중 하나를 트레이드 오프하면서 발전해 왔고 하나를 얻으면 하나를 포기해야한다는 인식이 당연시 되어있었습니다. 하지만, Rust는 소유권이라는 새로운 개념을 통해 바로 이 인식을 전환시켰습니다. 이제, Rust는 C 계열 언어들과 동등한 퍼포먼스를 보이며, 메모리 안정성까지 가지고 있는 언어입니다.
 
아직은 성장 중인 신생 언어긴 하지만, Rust는 현재 가장 주목받고 있는 미래의 언어입니다.
자 그럼, 어떤 언어보다 독특하면서 혁신적인 Rust의 세계로 떠나볼까요.
 
 

2. Rust 개발 환경 구축하기

https://rust-kr.github.io/doc.rust-kr.org/ch01-01-installation.html

 

러스트 설치 - The Rust Programming Language

우선 러스트를 설치해야겠죠. 설치는 rustup 이라는 러스트 버전 및 러스트 관련 도구를 관리하는 커맨드 라인 도구를 이용할 겁니다. 인터넷이 연결되어 있어야 하니 미리 인터넷 연결을 확인해

rust-kr.github.io

https://rust-kr.github.io/doc.rust-kr.org/ch01-02-hello-world.html
https://rust-kr.github.io/doc.rust-kr.org/ch01-03-hello-cargo.html
추가로 작성하려 했지만, 이곳에 가이드가 매우 친절하게 돼있어서 링크로 대체하겠습니다.

 
 

3. 일반적인 프로그래밍 문법

 

1. 변수와 가변성 (Variables and Mutability)
 

Rust는 기본적으로 불변성(Immutability)을 중요시하는 언어입니다. 이는 메모리 안전성과 관련된 문제를 예방하는 데 크게 기여합니다. 변수 선언은 let 키워드를 사용하며, 기본적으로 이렇게 선언된 변수는 불변입니다.

let x = 5;
println!("The value of x is: {}", x);
x = 6; // x는 불변성을 가진 변수이기에 컴파일 에러 발생
println!("The value of x is: {}", x);

 
가변성이 필요한 경우 'mut' 키워드를 사용하여 변수를 가변으로 선언할 수 있습니다.

let mut x = 5;
println!("The value of x is: {}", x);
x = 6; // x는 가변성을 가진 변수
println!("The value of x is: {}", x);

 

2. 데이터 타입(Data Types)

 
Rust는 정적 타이핑 언어이므로, 컴파일 시 모든 변수의 타입이 결정되어야 합니다. Rust는 스칼라 타입(단일 값: 정수형, 부동소수형, 불리언, 문자)과 복합 타입(여러 값: 튜플, 배열)을 제공합니다.

let guess: u32 = "42".parse().expect("Not a number!"); // u32 type
let x = 2.0; // f64 by default
let y: f32 = 3.0; // f32 by explicit annotation
let t = true;
let f: bool = false; // with explicit type annotation
let tup: (i32, f64, u8) = (500, 6.4, 1); // tuple type
let arr = [1, 2, 3, 4, 5]; // array type

 

3. 제어 흐름(Control Flow)

 
기존 언어들과 동일한 if와 else를 이용한 조건문과 loop, while, for를 이용한 반복문을 제공합니다.

let number = 3;
if number < 5 {
    println!("condition was true");
} else {
    println!("condition was false");
}

let mut counter = 0;
let result = loop {
    counter += 1;
    if counter == 10 {
        break counter * 2;
    }
};
println!("The result is {}", result);

let a = [10, 20, 30, 40, 50];
for element in a.iter() {
    println!("the value is: {}", element);
}

 

4. 함수(Functions)

 
Rust에서 함수는 `fn` 키워드를 사용하여 정의하며, 괄호 안에 매개변수를 적어주고 중괄호로 함수의 본문을 작성합니다. Rust는 명시적인 반환 타입을 지정하며, `->` 기호를 사용하여 반환 타입을 지정합니다. 마지막 표현식은 자동으로 함수의 반환 값이 됩니다.

fn plus_one(x: i32) -> i32 {
    x + 1 // no semicolon here, so this is an expression, not a statement
}

let five = plus_one(4);
println!("The value of five is: {}", five);

 
 

4. Rust의 메모리 관리

 
이제, 프로그래밍 언어의 코페르니쿠스적 전환을 살펴봅시다.
https://theworldaswillandidea.tistory.com/135

 

Stack & Heap, 기억에 대한 프로그래밍적 인식

오늘은 프로그램이 운영체제로부터 할당받는 메모리 영역에 대해서 알아보도록 하자. 엄밀히 따지면 코드 영역과 데이터 영역 또한 메모리 공간에 할당되지만, 스택(Stack) 영역과 힙(Heap) 영역에

theworldaswillandidea.tistory.com

Stack과 Heap에 대한 설명은 위 포스팅으로,
 
소유권은 러스트 프로그램의 메모리 관리법을 지배하는 규칙 묶음으로, 모든 프로그램은 작동하는 동안 컴퓨터의 메모리 사용 방법을 관리해야 합니다. 몇몇 언어는 가비지 컬렉션으로 프로그램에서 더 이상 사용하지 않는 메모리를 정기적으로 찾는 방식을 채용했고, 다른 언어는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제하는 방식을 택했습니다. 
 
이제, Rust는 소유권이라는 개념을 통해 메모리 관리에 대한 인식의 전환을 가져왔습니다. '소유권(ownership)'이라는 시스템을 만들어 컴파일러가 컴파일 중 검사할 여러 규칙을 정해 메모리를 관리합니다. 이 규칙 중 하나라도 위반하면 프로그램은 컴파일되지 않습니다. 소유권의 어떠한 특성도 프로그램 실행 속도를 느리게 하지 하지 않을 것입니다.
 
 

소유권(Ownership)

 
앞으로 여러분들은 이 세 가지 규칙을 반드시 명심하셔야 합니다.

1. 각각의 값은 소유자(owner)가 정해져 있다.

2. 한 번에 하나의 소유자만 존재할 수 있다.

3. 소유자가 스코프를 벗어나면, 값은 폐기(Drop)된다.

 
1번 규칙부터 살펴봅시다.
 
 

1. 각각의 값은 소유자(owner)가 정해져 있다.

 
Rust에서 모든 값은 반드시 소유자를 가지며, 그 소유자는 변수일 수 있습니다. 이 변수는 값에 대한 모든 권한을 갖고 있으며, 그 값을 필요에 따라 조작할 수 있습니다. 스택 메모리에서의 소유는 간단한데, 예를 들어, 스택에 저장된 변수가 있을 때 그 변수는 그 값의 소유자입니다.

let x = "이리오너라"; // "x"는 여기서 생성되고, 값 "이리오너라"의 소유권을 갖습니다.

 
힙 메모리의 경우는 이보단 좀 더 복잡합니다. 힙에 저장된 데이터는 보통 스마트 포인터라는 특별한 유형의 구조체를 통해 접근합니다. 스마트 포인터는 값이 힙에 저장된 위치를 추적하며, 그 값의 소유권을 갖습니다.
 
Rust에서는 고정된 크기를 가진 데이터가 스택에 저장되고, 크기가 런타임에 변경될 수 있는 데이터는 힙에 저장되는데, String 타입은 크기가 런타임에 변경될 수 있기에 힙에 저장됩니다. 이와 달리 위에서 본 "이리오너라" 리터럴 문자열은 고정된 크기를 가지므로 스택에 저장됩니다.

let s = String::from("이리오너라"); // "s"는 문자열 "이리오너라"의 소유권을 갖습니다.
// 이 문자열은 힙에 저장됩니다.

 

2. 한 번에 하나의 소유자만 존재할 수 있다.

 
Rust에서는 한 번에 하나의 변수만이 특정 값을 소유할 수 있음을 의미합니다. 이를 통해 경쟁 상태(Race Condition)나 중복 해제(double free)와 같은 메모리 안정성 문제를 방지할 수 있습니다.

let s1 = String::from("이리오너라");
let s2 = s1; // "s1"의 소유권이 "s2"로 이동합니다. 이제 "s1"은 사용할 수 없습니다.

 

3. 소유자가 스코프를 벗어나면, 값은 폐기(Drop)된다.

 
변수의 스코프가 끝나면 Rust는 그 변수가 소유하던 값을 메모리에서 자동으로 해제합니다. 이렇게 해서 메모리 누수 문제를 방지하게 됩니다.

{
    let s = String::from("이리오너라"); // "s"는 여기서 생성됩니다.
} // "s"의 스코프가 끝나며, "s"가 소유하던 문자열 "이리오너라"는 메모리에서 해제됩니다.

 
 

함수와 소유권

 
함수에 변수를 전달하면 값이 함수로 이동(move)합니다. 즉, 원래 변수가 더 이상 그 값을 소유하지 않게 됩니다. 이는 함수가 값의 소유권을 가져가기 때문입니다. 이렇게 하면 함수가 종료될 때 자동으로 그 값이 메모리에서 해제되므로, 누수를 방지할 수 있습니다.

fn takes_ownership(s: String) { // s는 이제 소유권을 가집니다.
    println!("{}", s);
} // s는 여기서 out of scope이므로 drop됩니다.

let s = String::from("이리오너라");
takes_ownership(s);
// s를 다시 사용하려고 하면 오류가 발생합니다. 왜냐하면 s의 값은 takes_ownership에 의해 소유권이 이동되었기 때문입니다.

 
함수는 값을 반환함으로써 그 값을 호출자에게 이동시킬 수 있습니다. 이렇게 하면 함수 내에서 생성된 값이 함수 외부에서도 계속 사용될 수 있게 됩니다.

fn gives_ownership() -> String { // 반환 값의 소유권이 함수 외부로 이동합니다.
    let s = String::from("이리오너라");
    s // s를 반환합니다.
}

let s = gives_ownership();
// s는 이제 gives_ownership에서 반환된 값을 소유합니다.

 
 

참조자와 빌림

 
앞서 소유권을 배우면서 함수에 변수를 전달하는 것만으로, 소유권이 넘어간다는 것은 꽤 불편할 것 같다고 생각하셨을 수 있습니다. 그래서 Rust에는 참조자와 빌림이라는 개념이 추가적으로 존재합니다. 참조자와 빌림은 소유권의 일부를 일시적으로 다른 변수에게 주는 방법을 말합니다. 이를 통해 소유권을 완전히 넘기지 않고도 값을 다른 코드에서 사용할 수 있게 됩니다.
 
참조자를 만드는 것은 변수 앞에 '&' 기호를 붙이는 것으로, 이렇게 하면 변수의 값을 읽을 수 있지만 수정할 수는 없습니다. 이것을 불변 참조자라고 부릅니다.

let s = String::from("이리오너라");

let r = &s; // s를 참조하여 r을 만듭니다. 이제 r은 s의 값을 읽을 수 있습니다.

println!("{}", r); // 이것은 허용됩니다.

r.push_str(", Rust야!"); // 이것은 허용되지 않습니다. r은 불변 참조자이므로 s를 수정할 수 없습니다.

 
만약 참조자를 통해 값을 수정하려면 가변 참조자를 사용해야 합니다. 이는 '&mut' 기호를 이용하여 생성할 수 있습니다. 하지만 한 스코프 내에서 하나의 가변 참조자만을 허용하므로, 데이터 경쟁 상황을 방지할 수 있습니다.

let mut s = String::from("이리오너라");

let r = &mut s; // s를 가변 참조하여 r을 만듭니다.

r.push_str(", Rust야!"); // 이것은 허용됩니다. r은 가변 참조자이므로 s를 수정할 수 있습니다.

 
"빌림(borrow)"은 소유권을 넘기지 않고 값을 참조하거나 사용할 수 있게 해주는 행위를 뜻합니다. 함수에 값을 전달할 때 빌림을 사용하면, 함수가 값을 사용하지만, 그 값을 소유하지는 않습니다.

// String 타입의 참조자를 인자로 받아, 그 길이를 반환하는 함수
fn calculate_length(s: &String) -> usize { 
    s.len() // s가 참조하는 String의 길이를 반환합니다.
} 
// 여기서 s는 범위(scope)를 벗어났지만, s는 참조자만을 가지고 있었으므로,
// 원본 값에는 아무런 변화도 일어나지 않습니다.

fn main() {
    let s1 = String::from("이리오너라"); // s1이라는 이름의 String 변수를 생성합니다.

    // &s1은 s1의 참조자를 만들어냅니다. 이 참조자는 calculate_length 함수에게 "빌려" 줍니다.
    // 함수는 이 참조를 통해 String의 길이를 계산하지만, String의 소유권은 갖지 않습니다.
    let len = calculate_length(&s1); 

    // s1은 여전히 유효하고, 그 값을 출력할 수 있습니다.
    println!("The length of '{}' is {}.", s1, len); 
}

 
이런 방식으로 Rust는 참조자와 빌림을 통해 메모리 안정성을 보장하면서도 효율적인 코드 실행을 가능하게 합니다.
 
 

* Rust의 참조자 규칙

1. 오직 하나의 가변 참조자만 갖거나, 여러 개의 불변 참조자를 가질 수 있습니다.

2. 불변 참조자와 가변 참조자를 동시에 가질 수 없습니다.

3. 참조자는 항상 유효해야 합니다.

 
이 규칙들은 컴파일 타임에 체크됩니다.
 
 

슬라이스

 
슬라이스는 배열이나 다른 데이터 컬렉션의 연속된 시퀀스에 대한 참조를 나타냅니다. 원본 데이터를 소유하지 않으므로, 원본 데이터에 대한 "뷰" 또는 "창"으로 생각할 수 있습니다. 슬라이스는 데이터의 특정 부분을 처리하거나 공유하는데 유용합니다.
 
슬라이스는 변수 이름과 대괄호([]) 안에 범위를 지정하여 만듭니다. 범위는 시작 인덱스와 끝 인덱스로 구성되며, 이 범위는 포함되는 시작 인덱스부터, 제외되는 끝 인덱스까지를 참조합니다.

let arr = [1, 2, 3, 4, 5];
let slice = &arr[1..4]; // arr의 2번째 요소부터 4번째 요소까지의 슬라이스

 

let s = String::from("이리오너라 Rust야!");
let come = &s[0..15];  // "이리오너라"에 대한 슬라이스
let rust = &s[15..]; // " Rust야!"에 대한 슬라이스

 
슬라이스를 사용하면, 함수에 배열이나 문자열의 일부를 쉽게 전달할 수 있습니다. 또한, 슬라이스는 원본 데이터를 소유하지 않고 참조만 하므로, 메모리를 효율적으로 사용할 수 있습니다.
 
 
소유권은 러스트의 처음과 끝을 관통하는 핵심 개념이므로, 반드시 숙지하시길 바랍니다.
 
 

반응형

5. 구조체, 열거형, 패턴 매칭

 
 

구조체(Struct)

 
Rust의 구조체(struct)는 여러 관련 데이터를 하나의 타입으로 묶는 데 사용됩니다. C언어의 구조체와 비슷하지만, Rust의 구조체는 메서드, 트레잇, 제네릭 등과 같은 기능을 가지고 있습니다. 구조체를 정의하려면 'struct' 키워드를 사용합니다.
 

struct Point {
    x: i32,
    y: i32,
}

'Point'라는 이름의 구조체를 정의하였고, 이 구조체는 'x'와 'y'라는 두 개의 필드를 가지고 있습니다. 각 필드는 'i32' 타입입니다.
 

let p = Point { x: 0, y: 0 };

 
 

열거형(Enum)

 
Rust의 열거형(enum)은 여러 변수 중 하나의 값을 가질 수 있는 타입입니다. 각 변수는 타입과 값을 가질 수 있으며, 다른 프로그래밍 언어의 열거형보다 더 강력한 기능을 제공합니다.
 

// `Message`라는 이름의 열거형(enum)을 정의합니다.
// 각각의 variant는 다른 종류의 메시지를 나타냅니다.
enum Message {
    Quit,  // 아무런 추가 데이터 없이 'Quit' 메시지를 나타냅니다.
    ChangeColor(i32, i32, i32),  // RGB 색상 값을 가진 'ChangeColor' 메시지를 나타냅니다.
    Move { x: i32, y: i32 },  // x, y 좌표를 가진 'Move' 메시지를 나타냅니다.
    Write(String),  // 문자열을 가진 'Write' 메시지를 나타냅니다.
}

 
'Message'라는 이름의 열거형을 정의하였고, 이 열거형은 Quit, Move, Write, ChangeColor라는 네 가지 변수를 가질 수 있습니다. 각 변수는 다른 타입과 값을 가질 수 있습니다.
 
 

패턴 매칭(Pattern Matching)

 
Rust에서 패턴 매칭은 데이터의 구조를 분해하고 그 구조에 따라 코드를 실행하는 아주 강력한 기능입니다.  'match' 키워드를 사용하여 패턴 매칭을 수행할 수 있습니다.
 

let x = 1;

match x {
    1 => println!("one"),
    2 => println!("two"),
    3 => println!("three"),
    _ => println!("anything"),
}

 
이 코드는 x의 값에 따라 다른 코드를 실행합니다. _ 패턴은 모든 가능한 값을 매치합니다. 이는 일종의 '기본' 또는 '그 외' 조건으로 볼 수 있습니다.
 
 
패턴 매칭은 더 복잡한 데이터 구조에도 사용할 수 있는데, 예를 들어, Option<T> 타입의 값에 대해 패턴 매칭을 수행할 수 있습니다.
 

let optional = Some(5);

match optional {
    Some(i) => println!("This is a really long string and {}", i),
    None => (),
}

 
이 코드는 optional이 Some(i)인지 None인지에 따라 다른 코드를 실행합니다. Some(i) 패턴은 optional이 Some이면 i에 그 값을 바인딩합니다.

또한, 패턴 매칭은 구조체, 열거형 등 복잡한 데이터 타입에서도 사용할 수 있습니다. 예를 들어, 아래의 코드는 Message라는 열거형에 대해 패턴 매칭을 수행합니다.
 

// `Message`라는 이름의 열거형(enum)을 정의합니다.
// 각각의 variant는 다른 종류의 메시지를 나타냅니다.
enum Message {
    Quit,  // 아무런 추가 데이터 없이 'Quit' 메시지를 나타냅니다.
    ChangeColor(i32, i32, i32),  // RGB 색상 값을 가진 'ChangeColor' 메시지를 나타냅니다.
    Move { x: i32, y: i32 },  // x, y 좌표를 가진 'Move' 메시지를 나타냅니다.
    Write(String),  // 문자열을 가진 'Write' 메시지를 나타냅니다.
}

// `Quit` 메시지를 처리하는 함수
fn quit() { /* ... */ }

// `ChangeColor` 메시지를 처리하는 함수
fn change_color(r: i32, g: i32, b: i32) { /* ... */ }

// `Move` 메시지를 처리하는 함수
fn move_cursor(x: i32, y: i32) { /* ... */ }

// 메시지를 처리하는 함수
fn process_message(msg: Message) {
    // msg의 값에 따라 다른 동작을 수행합니다.
    match msg {
        // msg가 `Quit`이면 `quit` 함수를 호출합니다.
        Message::Quit => quit(),

        // msg가 `ChangeColor(r, g, b)`이면 `change_color` 함수를 호출합니다.
        // 이 때, r, g, b는 `ChangeColor` 메시지의 RGB 색상 값입니다.
        Message::ChangeColor(r, g, b) => change_color(r, g, b),

        // msg가 `Move { x, y }`이면 `move_cursor` 함수를 호출합니다.
        // 이 때, x, new_name_for_y는 `Move` 메시지의 x, y 좌표입니다.
        Message::Move { x, y: new_name_for_y } => move_cursor(x, new_name_for_y),

        // msg가 `Write(text)`이면 text를 출력합니다.
        // 이 때, text는 `Write` 메시지의 문자열입니다.
        Message::Write(text) => println!("{}", text),
    };
}

 
Message 열거형의 각 variant에 따라 다른 동작을 수행하도록 설계되었습니다. match 표현식은 각 variant를 패턴으로 사용하여 msg의 값에 따라 해당 패턴에 매치되는 코드를 실행합니다.
 
 

6. 크레이트, 패키지, 모듈

 
먼저, 크레이트와 패키지, 모듈의 기본 개념을 살펴보고 실습을 통해 익혀봅시다.  
 

크레이트(Crate)

크레이트는 Rust의 기본 컴파일 단위로, 바이너리나 라이브러리를 생성하는 최상위 모듈입니다. Rust에서 모든 실행 파일과 라이브러리는 각각 단일 크레이트로부터 생성됩니다. 크레이트는 각각의 고유한 이름 공간을 가지며, 다른 크레이트와 이름이 중복될 수 없습니다. 크레이트는 패키지의 구성요소이며, Cargo.toml 파일에 의해 정의됩니다. 또한, 각 크레이트는 독립적으로 컴파일될 수 있으며, 컴파일된 크레이트는 라이브러리나 실행 파일로 출력됩니다.
 

패키지(Package)

패키지는 Cargo의 기본 단위로, Cargo.toml라는 설정 파일과, 하나 이상의 크레이트를 포함합니다. 최대 하나의 라이브러리 크레이트와 여러 개의 바이너리 크레이트를 포함할 수 있습니다. 패키지 내 모든 크레이트는 서로 공유하는 공통의 의존성 목록을 가집니다.
 

모듈(Module)

모듈은 Rust에서 코드를 구성하고, 코드의 범위와 이름 공간을 정의하는 방법을 제공합니다. 모듈은 코드를 논리적인 단위로 분리하고, 재사용성을 높이며, 이름 충돌을 방지하는데 도움이 됩니다. 크레이트 내에서 모듈은 계층적으로 구성될 수 있습니다.
 
 
자, 그럼 먼저 간단한 프로젝트를 만들어 봅시다.

cargo new andbrainsaid
cd andbrainsaid

 
위의 명령어를 실행하면 Cargo가 기본적인 프로젝트 구조를 만들어 줍니다. 그리고 Cargo.toml 파일을 열어보면 다음과 같은 내용을 확인할 수 있습니다.

[package]
name = "andbrainsaid"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

 
여기에서 패키지의 이름, 버전 그리고 의존성을 관리합니다.
 
이제 이 Cargo.toml 파일에 'rand'라는 크레이트를 추가해봅시다.

cargo search rand

rand = "0.8.5"              # Random number generators and other randomness functionality.
tinyrand = "0.5.0"          # Lightweight RNG specification and several ultrafast implementations in Rust.
bevy_rand = "0.1.0"         # A plugin to integrate rand for ECS optimised RNG for the Bevy game engine.
random_derive = "0.0.0"     # Procedurally defined macro for automatically deriving rand::Rand for structs and enums
faker_rand = "0.1.1"        # Fake data generators for lorem ipsum, names, emails, and more
rand_derive2 = "0.1.18"     # Generate customizable random types with the rand crate
fake-rand-test = "0.0.0"    # Random number generators and other randomness functionality.
ndarray-rand = "0.14.0"     # Constructors for randomized arrays. `rand` integration for `ndarray`.
rand_derive = "0.5.0"       # `#[derive(Rand)]` macro (deprecated).
rand_core = "0.6.4"         # Core random number generator traits and tools for implementation.
... and 1077 crates more (use --limit N to see more)

 
cargo search 명령어를 통해 의존성을 검색할 수 있습니다.
 
[dependencies] 섹션에 다음과 같이 추가합니다.

[dependencies]
rand = "0.8.5"

 
이제 'src/main.rs' 파일을 다음과 같이 수정합니다.

use rand::Rng;

fn main() {
    let secret_number = rand::thread_rng().gen_range(1..101);
    println!("The secret number is: {}", secret_number);
}

 
이제 이 프로그램을 cargo run으로 실행하면  1에서 100 사이의 임의의 숫자를 출력합니다.
 
이번엔 이 코드의 구조화를 위해 모듈 개념을 활용해봅시다.
 
 
우선 'main.rs' 파일의 'secret_number' 생성 로직을 'number_generator'라는 이름의 별도의 모듈로 분리해보겠습니다. 'src' 디렉토리 안에 'number_generator.rs' 파일을 생성하고, 그 안에 'secret_number' 생성 로직을 넣습니다.

// src/number_generator.rs

use rand::Rng;

pub fn generate_secret_number() -> i32 {
    rand::thread_rng().gen_range(1..101)
}

 
이렇게 하면 'generate_secret_number' 함수가 'number_generator' 모듈의 일부가 되며, 이 함수는 pub 키워드로 외부에서 호출할 수 있는 public 함수가 됩니다.
 
다음으로, 'main.rs' 파일을 수정해봅시다.

// src/main.rs

mod number_generator; // number_generator 모듈을 사용하겠다는 선언

fn main() {
    let secret_number = number_generator::generate_secret_number();
    println!("The secret number is: {}", secret_number);
}

 
이렇게 'number_generator::generate_secret_number' 호출을 통해, 모듈 내의 함수를 사용하여 비밀 숫자를 생성할 수 있습니다. 이처럼 모듈을 사용하면 코드를 논리적으로 분리하고, 재사용할 수 있게 되므로 코드의 가독성이 향상되고 유지 보수도 용이해집니다.
 
 

7. 컬렉션, 그리고 제네릭

 

컬렉션(Collection)

Rust는 표준 라이브러리에서 다양한 컬렉션 타입을 제공합니다. 컬렉션은 여러 값을 하나의 구조로 그룹화하는 것을 가능하게 합니다. Rust에서 가장 많이 사용되는 컬렉션 타입 벡터(Vector), 문자열(String), 해시맵(HashMap) 세 가지를 살펴봅시다.
 
 

벡터(Vector)

 
벡터는 같은 타입의 여러 값을 담을 수 있는 동적 배열로, 요소를 추가하거나 제거함에 따라 크기가 변경될 수 있습니다. 벡터는 자동으로 메모리를 관리하므로, 사용자가 별도로 메모리 관리에 신경쓰지 않아도 됩니다. 벡터는 'Vec<T>'라는 타입으로 표현되며, 여기서 'T'는 벡터에 저장되는 값의 타입을 나타냅니다. 벡터를 생성하는 가장 간단한 방법은 'Vec::new()'함수를 사용하는 것입니다.
 

let v: Vec<i32> = Vec::new(); // 빈 벡터를 생성합니다.

 
아래의 코드는 'i32' 타입의 빈 벡터를 만듭니다. 벡터는 빈 상태로 시작할 수 있으며, 'push' 메서드를 사용해 요소를 추가할 수 있습니다.

let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);

 
벡터에는 초기 값이 있는 경우를 위한 'vec!' 매크로도 있습니다.

let v = vec![1, 2, 3, 4, 5]; // 초기 값이 있는 벡터를 생성합니다.

 
벡터에 저장된 값에 접근하는 방법은 두가지로, 인덱스 문법을 사용하거나 'get' 메서드를 사용할 수 있습니다.

let v = vec![1, 2, 3, 4, 5];

let third: &i32 = &v[2]; // 인덱스 문법을 사용합니다.
println!("The third element is {}", third);

match v.get(2) { // `get` 메서드를 사용합니다.
    Some(third) => println!("The third element is {}", third),
    None => println!("There is no third element."),
}

 
벡터는 연속된 메모리 공간에 데이터를 저장하므로, 요소에 대한 랜덤 액세스(인덱스를 사용한 액세스)는 매우 빠릅니다. 그러나 중간에 있는 요소를 제거하면, 나머지 요소들이 메모리에서 이동하여 빈 공간을 채우므로, 이 연산은 느릴 수 있습니다. 이 점은 사용 시 고려해야 합니다.
 
 

문자열(String)

 
Rust에서 'String' 타입은 기본적으로 바이트의 컬렉션으로 볼 수 있습니다. 'String'은 UTF-8 인코딩된 문자열을 저장하며, 이는 한 개 이상의 바이트로 이루어진 문자들의 연속된 컬렉션이라고 할 수 있습니다. 이 때문에 'String' 타입은 컬렉션에 대한 설명에 포함될 수 있습니다.
 
'String::from' 함수를 통해 생성할 수 있습니다.

let s = String::from("initial contents");

 
'String'은 변경 가능한 컬렉션으로, 'push_str'이나 'push' 메서드를 이용해 문자열을 추가하거나 변경할 수 있습니다. 'push_str' 메서드는 문자열 슬라이스를 파라미터로 받아 문자열 끝에 추가하며, 'push' 메서드는 단일 문자를 추가합니다.

let mut s = String::from("foo");
s.push_str("bar"); // s는 이제 "foobar"입니다.

s.push('l'); // s는 이제 "foobarl"입니다.

 
'len' 메서드를 통해 길이를 확인할 수 있습니다. 길이는 바이트 수를 의미합니다. 또한 'capacity' 메서드는 'String'이 메모리 상에서 차지하는 용량(바이트 단위)를 반환합니다.

let s = String::from("이리오너라");

println!("{}", s.len()); // 15를 출력합니다.
println!("{}", s.capacity()); // 최소 15를 출력합니다.

 
반복문을 사용하여 문자열의 각 문자에 접근할 수 있습니다.

    for c in "이리오너라 Rust야!".chars() {
        println!("{}", c);
    }

 
 

해시맵(HashMap)

 
해시맵은 키와 값을 서로 연결하는 컬렉션으로, Rust의 표준 라이브러리에서 제공합니다. 표준 라이브러리의 HashMap에 정의되어 있는 함수 중에는 더 좋은 것들이 숨어있습니다. 더 많은 정보를 원하신다면 표준 라이브러리 문서를 확인하세요.

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

 
두 개의 팀 'Blue'와 'Yellow'를 각각 점수 10과 50으로 매핑하는 'HashMap'을 생성합니다.
 
'HashMap'에서 값을 가져오려면 'get' 메서드를 사용할 수 있습니다.

let team_name = String::from("Blue");
let score = scores.get(&team_name);

 
'score'는 'Some(&10); 이라는 Option 값을 가지게 됩니다.
 
 
또한, 'HashMap'에 있는 모든 키-값 쌍을 반복하려면 아래와 같이 할 수 있습니다.

for (key, value) in &scores {
    println!("{}: {}", key, value);
}

 
'HashMap'에는 여러가지 유용한 메서드들이 많습니다. 예를 들어, 'entry' 메서드는 키에 따라 값이 있으면 그 값을 반환하고, 없으면 새로운 값을 삽입하는 기능을 제공합니다.

scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(10);

 
'or_insert' 메서드는 값이 없을 때만 값을 삽입하고, 항상 현재 값을 가리키는 뮤터블한 참조를 반환합니다.
 
해시맵은 이외에도 여러 유용한 메서드들이 존재하는 강력한 컬렉션입니다.
 
 

제네릭(Generic)

 
제네릭은(Generic)은 코드를 추상화하고 재사용하는 또 다른 방법입니다. 제네릭을 사용하면 하나의 타입 대신 여러 타입에 대해 동작하는 함수나 구조체를 정의할 수 있습니다.
 
두 개의 정수를 비교하는 함수를 생각해봅시다.

fn largest_i32(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

 
이 함수는 정수의 배열을 받아 가장 큰 값을 반환합니다. 그러나 이 함수는 정수에 대해서만 작동합니다. 만약 우리가 동일한 작업을 'f64'타입의 배열에 대해 수행하려면 어떻게 해야할까요? 똑같은 로직의 새로운 함수를 만들어야 할까요? 이런 중복은 비효율적입니다.
 
제네릭을 사용하면, 함수나 구조체가 특정 타입에 국한되지 않고 여러 타입에 대해 동작할 수 있습니다. 여기에 제네릭 버전의 'largest' 함수가 있습니다.

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

 
여기서 T는 제네릭 타입 매개변수로, 어떤 타입이든 될 수 있습니다. 'PartialOrd'와 'Copy'는 트레잇입니다. 이는 제네릭 함수가 어떤 타입에 대해 작동할 수 있는지 제한하는 역할을 합니다. 트레잇은 다음 장에서 설명하겠습니다.
 
 

8. 트레잇(Trait)

 
 
Rust에서 트레잇(Trait)은 공통적인 행동을 공유하는 타입을 정의하는 방법입니다. 즉, 트레잇은 메소드 시그니처를 그룹화하는 방법으로, 해당 트레잇을 구현하는 타입이 제공해야하는 행동을 설명합니다. 트레잇은 상속이 아니라 인터페이스와 유사하게 동작합니다.
 

trait Animal {
    fn noise(&self) -> String;
}

struct Dog;
struct Cat;

impl Animal for Dog {
    fn noise(&self) -> String {
        String::from("Woof!")
    }
}

impl Animal for Cat {
    fn noise(&self) -> String {
        String::from("Meow!")
    }
}

fn make_noise<T: Animal>(animal: T) {
    println!("{}", animal.noise());
}

fn main() {
    let dog = Dog;
    let cat = Cat;

    make_noise(dog);
    make_noise(cat);
}

위 코드에서 'Animal' 트레잇은 'noise' 메소드를 정의하며, 'Dog'와 'Cat' 구조체는 각각 'Animal' 트레잇을 구현합니다. 'make_noise' 함수는 제네릭을 사용하여 어떤 'Animal' 트레잇을 구현하는 타입도 받을 수 있습니다.
 
이렇게 Rust의 트레잇은 Java나 TypeScript 등의 인터페이스와 유사합니다. 하지만 Rust의 트레잇은 몇 가지 추가적인 기능을 제공하는데, 우리의 Animal 트레잇으로 몇 가지 추가적인 개념들을 살펴봅시다.
 
 

기본 구현(Default Implementation)

 
트레잇 내의 메서드에 기본 구현을 제공할 수 있습니다. 이렇게 하면, 트레잇을 구현하는 타입이 선택적으로 메서드 구현을 제공하거나 기본 구현을 사용할 수 있습니다.

trait Animal {
    fn noise(&self) -> String {
        String::from("Some generic animal noise")
    }
}

 
 

트레잇 바운드(Trait Bound)

 
트레잇 바운드를 사용하여 제네릭 함수에서 허용되는 타입을 제한할 수 있습니다. 이를 통해 함수가 특정 트레잇을 구현하는 타입만 받도록 보장할 수 있습니다.

fn make_noise<T: Animal>(animal: T) {
    println!("{}", animal.noise());
}

 
 

제네릭 타입에서의 트레잇 구현

 
제네릭 타입에 트레잇을 구현할 수도 있습니다. 이를 통해 특정 트레잇을 구현하는 타입에만 동작하는 제네릭 타입을 만들 수 있습니다.

struct AnimalBox<T: Animal> {
    animal: T,
}

impl<T: Animal> AnimalBox<T> {
    fn new(animal: T) -> Self {
        Self { animal }
    }
}

 
 

다중 트레잇 바운드(Multiple Trait Bound)

 
여러 트레잇 바운드를 사용하여 제네릭 함수나 타입이 여러 트레잇을 모두 구현하는 타입만 허용하도록 할 수 있습니다.

trait Walk {
    fn walk(&self);
}

fn make_noise_and_walk<T: Animal + Walk>(animal: T) {
    animal.noise();
    animal.walk();
}

 
 

*라이프타임(Lifetime)과 함께 사용

 
트레잇은 라이프타임을 사용하여 객체가 참조하는 리소스의 생명주기를 관리할 수 있습니다.

trait Animal<'a> {
    fn parent(&'a self) -> Option<&'a Self>;
}

 
 

9. 라이프타임(Lifetime)과 빌림 검사기(Borrow Checker)

 
 

라이프타임(Lifetime)

 
Rust에서 라이프타임은 참조자의 유효 기간을 의미합니다. Rust는 메모리 안정성을 보장하기 위해 라이프타임이라는 개념을 도입했습니다. Rust의 모든 참조자는 라이프타임을 갖고 있습니다. 대부분의 상황에서 라이프타임은 암묵적으로 추론되지만, 참조자의 수명이 여러 방식으로 서로 연관될 경우, 라이프타임을 명시해주어야 합니다. 이를 통해 참조가 항상 유효한 대상을 가리키도록 할 수 있습니다.
 
라이프타임을 명시하는 것은 다른 프로그래밍 언어에서는 보기 힘든 개념이어서 익숙하지 않을 것입니다. 라이프타임은 작은 따옴표와 이름으로 표현됩니다.

// 'a라는 라이프타임을 갖는 참조자 x와 y를 입력으로 받아, 길이가 더 긴 참조자를 반환합니다.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

 
라이프타임을  명시하는 주목적은 댕글링 참조자(dangling reference)를 방지하기 위함인데, 댕글링 참조자란 참조가 메모리의 해제된 위치를 가리킬 때 발생하는 문제로, 댕글링 참조자는 다른 언어에서도 발생하는 메모리 안정성 문제입니다. 댕글링 참조자가 발생하게되면 런타임에 에러가 발생하거나 의도하지 않은 데이터를 읽게될 수 있습니다. 그러나 Rust는 컴파일 시점에 이런 종류의 문제를 방지합니다.
 

// 'a라는 라이프타임을 갖는 참조자 x와 y를 입력으로 받아, 길이가 더 긴 참조자를 반환합니다.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

 
이 예제를 다시 한 번 봐볼까요? 'longest' 함수에 대한 입력으로 'a 라이프타임을 갖는 두 개의 참조자가 주어지며, 반환 값 역시 같은 라이프타임을 가집니다. 이는 함수의 반환값이 입력 참조자 중 하나를 가리키므로, 이 참조자들이 유효한 동안 반환 값 역시 유효하다는 것을 의미합니다.
 
 

빌림 검사기(Borrow Checker)

 
Rust의 빌림 검사기는 프로그램이 컴파일 시점에 참조의 안정성을 보장하는 메커니즘으로, 이전 참조자 규칙을 기억해보시면, 이런 규칙이 있었습니다. '오직 하나의 가변 참조자만 갖거나, 여러 개의 불변 참조자를 가질 수 있다, 불변 참조자와 가변 참조자를 동시에 가질 수 없다.'
 
빌림 검사기는 이러한 규칙들을 컴파일 시점에 강제합니다.
 

let mut s = String::from("이리오너라");

let r1 = &s; // no problem
let r2 = &s; // no problem
println!("{} and {}", r1, r2);
// r1과 r2는 더 이상 사용되지 않습니다.

let r3 = &mut s; // no problem
println!("{}", r3);

위 예제는 성공적으로 컴파일되고 실행됩니다. 'r1'과 'r2'로 String에 대한 두 개의 불변 참조가 만들어졌지만, 그 다음에는 더 이상 사용되지 않습니다. 그 후 'r3' 가변 참조가 만들어졌습니다.
 
 
하지만 다음의 예제는 컴파일 오류를 발생시킵니다.

let mut s = String::from("hello");

let r1 = &s; // no problem
let r2 = &s; // no problem
let r3 = &mut s; // BIG PROBLEM

println!("{}, {}, and {}", r1, r2, r3);

 
'r1'과 'r2'에 대한 불변 참조와 'r3'에 대한 가변 참조가 동시에 존재하는 상황은 Rust의 참조자 규칙을 위배했으므로, 컴파일러가 컴파일 오류를 발생시킵니다.
 
Rust는 이런 빌림 검사기 덕분에 컴파일 타임에 메모리 안정성을 보장하고, 데이터 경쟁 조건을 원천적으로 방지합니다.
 
 

다음으로 ->

 
 

Thanks for watching, Have a nice day.

 

References

https://www.technologyreview.kr/how-rust-went-from-a-side-project-to-the-worlds-most-loved-programming-language/
https://rust-kr.github.io/doc.rust-kr.org/

 

The Rust Programming Language - The Rust Programming Language

Steve Klabnik, Carol Nichols 지음. 기여해주신 러스트 커뮤니티 여러분과 한국어 번역에 참여해주신 분들께 감사드립니다. 이 텍스트 버전은 여러분이 (2023년 2월 9일에 출시된) 러스트 1.67.1 혹은 이후

rust-kr.github.io

 
Rust 가이드를 번역해주신 분들에게 정말 감사드립니다.
 

반응형
Comments