Rust - Structured Data Types
Rust는 struct
(C-like structs)와 enum
(Ocaml-like)이라는 두 가지 간단한 structured data types를 제공합니다. 이 두 가지 타입을 어떻게 활용하는지 다룰 것입니다.
Rust 맛보기
- 1. Rust - 입문하기
- 2. Rust - 타입 모음
- 3. Rust - Memory Ownership
- 4. Rust - Control Flow
- 5. Rust - Structured Data Types
- 6. Rust - Project Organization
- 7. Rust - Error Handling
- 8. Rust - Collections
- 9. Rust - Generics
- 10. Rust - Test Automation
- 11. Rust - Functional Programming
- 12. Rust - Memory Management
Structs 정의 및 Instantiate
Structs 정의
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
- 필드는
name: type
형식으로 선언됩니다.
Instantiate
let user1 = User {
email: String::from("
[email protected]"),
username: String::from("user1"),
active: true,
sign_in_count: 1,
};
- 필드 데이터는
name: value
형식으로 초기화됩니다.
이외에는 C의 구조체와 유사합니다.
다양한 유형의 Structs
Tuple structs
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
-
필드의 이름이 없는 구조체입니다. 굳이 필드의 이름을 정의할 필요가 없을 때 사용합니다.
-
위 코드에서
black
과origin
은 각각 다른 타입인Color
와Point
타입의 인스턴스입니다.
Unit-like structs
필드가 없는 struct
입니다. 데이터를 저장하지 않고 trait를 구현하고 싶을 때에 유용합니다.
struct UnitLikeStruct;
Associated Functions
Methods
Rust의 struct
는 메소드를 가질 수 있습니다. 메소드를 정의하려면, 먼저 impl <구조체명>
으로 시작하는 블록을 열고, 그 안에 함수를 정의하는데, 첫 번째 parameter로 &self
(사실은 self: &Self
의 축약임)를 받아야 합니다.
impl User {
fn get_username(&self) -> &String {
self.username
}
}
메소드가 아닌 associated functions
self
를 사용하지 않는 associated function을 정의할 수 있습니다.
이러한 함수들은 메소드라고 불리지 않습니다. 그리고 주로 constructor를 구현할 때 사용됩니다.
이러한 함수를 호출할 때에는 .
가 아니라 ::
를 사용하여 호출합니다.
impl User {
fn new(email: &str, username: &str) -> User {
User {
email: String::from(email),
username: String::from(username),
active: true,
sign_in_count: 1,
}
}
}
// User::new("email", "username")로 인스턴스 생성
Enumeration
Rust의 enum은 C의 enum과 이름은 같지만, 훨씬 강력한 사실상 별개의 존재라고 보시면 됩니다. 그 기능을 한 마디로 정의하자면 custom data type을 생성하는 것입니다.
enum은 이름과 연관된 variant로 구성됩니다.
enum IpAddrKind {
V4,
V6,
}
IpAddrKind
라는 새로운 enum
타입이 정의되었습니다.
enum
내에 정의된 V4
와 V6
은 variant
라고 불립니다.
위에서 정의한 enum
의 인스턴스를 생성해 봅시다.
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
이런 식으로 ::
를 사용해 enum
의 variant를 참조할 수 있습니다.
enum
은 다른 타입과 마찬가지로 함수의 인자나 구조체의 필드로 사용될 수 있습니다.
데이터를 갖는 enum
의 variant
enum IpAddr {
V4(String),
V6(String),
}
let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));
위와 같이 variant가 특정한 타입의 데이터를 갖도록 할 수 있습니다. C에서는 단지 정수만을 가질 수 있었는데 말이죠…
심지어 각 variant는 서로 다른 타입의 데이터를 가질 수 있습니다.
enum Message {
Quit,
Move { x: i32, y: i32 }, // 구조체 형식으로도 데이터를 가질 수 있다
Write(String),
ChangeColor(i32, i32, i32),
}
만약 위 타입을 구조체로 구현하려 했었다면 Message
의 유형마다 따로따로 정의해야 했을 것입니다. 그러면 서로 다른 타입이 4개나 생성되기 때문에 Message
타입을 하나로 일반화해서 다루기가 매우 까다로웠을 거예요.
enum
의 메소드 정의
struct
와 마찬가지로 enum
도 메소드를 가질 수가 있습니다.
impl Message {
fn call(&self) {
// method body would be defined here
}
}
let m = Message::Write(String::from("hello"));
m.call();
struct
와 완전히 동일한 형식으로 메소드 정의가 가능함을 알 수 있습니다.
Option
enum
Rust의 표준 라이브러리에는 Option
이라는 enum
이 정의되어 있습니다.
두 가지의 variant를 갖는데, Some
과 None
입니다.
Option
이 등장한 배경은 다른 프로그래밍 언어의 Null과 관련이 있습니다.
Rust에는 Null이 없습니다. 대신 Option
을 사용하여 Null을 대체할 수 있습니다.
enum Option<T> {
Some(T),
None,
}
위는 Option
의 정의입니다. T
는 generic type을 나타내며, 이는 인스턴스를 생성할 때 임의의 타입을 지정 가능함을 의미합니다. Some
variant는 T
타입의 데이터를 갖습니다.
그리고 None
은 아무런 데이터도 가지지 않습니다. 이 점은 Null과 비슷하지만 중요한 차이는, Null은 실제로 유효하지 않은 메모리 공간 등을 가리키는 반면, Option
의 None
은 그 자체로 유효하며 단지 데이터가 없다는 의미를 명시적으로 나타내는 것입니다. 따라서 Null과 달리 Option
은 NullPointerException
과 같은 오류를 발생시키지 않습니다.
Option
의 Some
variant는 당연하지만 원시적 타입(즉 T
에 해당하는 원본 타입)과 호환이 되지 않습니다. Some
variant에 담긴 T
타입 값을 이용하기 위해서는 unwrap()
또는 unwrap_or()
등의 메소드를 사용해야 합니다.
이때 unwrap()
은 Some
variant에 담긴 값을 반환하고, None
이라면 프로그램을 종료시킵니다. 따라서 unwrap()
을 사용할 때에는 Some
variant에 값이 들어있을 것이라는 확신이 있어야 합니다. 반면 unwrap_or()
는 None
이라면 인자로 전달된 기본값을 반환합니다.
또는 match
를 활용하여 Option
의 variant에 따라 다른 동작을 수행할 수도 있습니다. 이는 마치 Result
타입을 다룰 때와 비슷합니다.
fn main() {
let some_number = Some(5);
let some_string = Some("a string");
let absent_number: Option<i32> = None;
let x: i32 = 5;
let y: Option<i32> = Some(5);
match y {
Some(i) => println!("i: {}", i),
None => println!("None"),
}
}
그런데 여기서 만약 Some
안에 있는 타입이 Copy
trait을 갖지 않는, 소유권이 이전되는 타입이라면 어떻게 될까요?
물론, match
블록 내부로 소유권이 넘어가고, 끝나는 순간 해당 변수는 소멸됩니다. 이를 방지하기 위해 ref
키워드를 사용할 수 있습니다.
let maybe_name = Some(String::from("Alice"));
match maybe_name {
Some(n) => println!("Hello, {}", n),
None => println!("No name provided"),
}
println!"maybe_name: {:?}", maybe_name); // error: value borrowed here after move
위 코드는 컴파일되지 않습니다. maybe_name
이 match
블록 내부로 소유권이 넘어가기 때문입니다.
이제 ref
키워드를 사용해 봅시다.
let maybe_name = Some(String::from("Alice"));
match maybe_name {
Some(ref n) => println!("Hello, {}", n),
None => println!("No name provided"),
}
println!"maybe_name: {:?}", maybe_name); // Ok
위와 같이 ref
키워드를 명시하면 매칭이 발생했을 때, 해당 변수의 소유권이 넘어가지 않습니다. 따라서 match
블록을 빠져나와도 해당 변수를 사용할 수 있습니다.
예제: Linked List
struct Node {
value: i32,
next: Option<Box<Node>>,
}
impl Node {
fn append(&mut self, elem: i32) {
match self.next {
Some(ref mut n) => n.append(elem),
None => {
let node = Node {
value: elem,
next: None,
};
self.next = Some(Box::new(node));
}
}
}
fn list(&self) {
print!("{}, ", self.value);
match self.next {
Some(ref n) => n.list(),
None => (),
}
}
}
fn main() {
let mut root = Node {
value: 0,
next: None,
};
root.append(1);
root.append(2);
root.append(3);
root.list();
}
설명한 적 없는 Box
라는 타입이 등장하는데, 이는 Rust의 heap allocation을 위한 포인터 타입입니다. Box
는 heap에 데이터를 저장하고, stack에는 heap에 저장된 데이터의 주소를 저장합니다. 이를 통해 heap에 저장된 데이터를 stack에서 참조할 수 있습니다.
참고 문헌
-
고려대학교 컴퓨터학과 오상은 교수님의 시스템 프로그래밍(COSE322) 과목 강의자료
Rust 맛보기