Rust - Generics
모든 프로그래밍 언어는 컨셉이 중복되는 경우를 다루기 위한 도구를 제공합니다. Rust에서는 generics가 이에 해당합니다.
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
이 글에서는 Generic에 관련된 세 가지 컨셉을 다룹니다.
-
Generic Types:
Option<T>
,Vec<T>
,HashMap<K, V>
등 -
Traits: 특정한 행위 또는 기능을 일반적인 방식으로 정의하는 방법
-
Lifetime: reference 간의 관계에 대한 정보를 컴파일러에게 제공하는 방법
Generic Types
함수 정의할 때
Generic type을 써서 서로 다른 타입에 대한 같은 동작을 수행하는 함수를 여러 번 작성할 필요가 없습니다.
fn largest<T>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
Generic type을 사용한 함수 정의 문법을 일반적인 타입과 비교해 봅시다.
fn largest<T>(list: &[T]) -> T {
fn largest_int(list: &[i32]) -> i32 {
위와 같이, 타입명 정의를 <>
안에 넣어, 함수 이름과 parameter list 사이에 위치시킵니다. 이제 parameter나 리턴 타입에 여기서 정의한 generic 타입 이름을 사용할 수 있습니다.
하지만 이 함수는 컴파일되지 않습니다. 왜냐하면 T
가 어떤 타입인지 알 수 없으니, 해당 타입에 대한 >
연산자를 정의할 수가 없기 때문입니다. 이를 해결하기 위해 뒤에서 다룰 trait
를 사용합니다. T
를 PartialOrd
trait를 구현하는 타입으로 제한하면 >
연산자가 사용 가능함을 보장할 수 있습니다.
struct
정의할 때
struct
이름 뒤에 <타입명>
을 붙여서 generic type을 정의할 수 있습니다.
struct Point<T> {
x: T,
y: T,
}
struct Point<T, U> {
x: T,
y: U,
}
위와 같이 여러 개의 서로 다른 타입을 가지는 generic type을 정의할 수도 있습니다.
enum
도 마찬가지 방식으로 generic type이 정의 가능합니다.
메소드 정의할 때
impl
블록 내부에서도 generic type을 사용할 수 있습니다.
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
Point<T>
타입에 대한 메소드를 정의하기 위해, impl
키워드 바로 뒤에 generic type선언을 해야 한다는 점에 주의합시다.
메소드를 정의할 때 generic type을 특정 타입으로 제한할 수도 있습니다.
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
위의 경우 가능한 모든 Point<T>
타입 중 T
가 f32
인 경우에만 해당하는 메소드를 정의합니다.
메소드 시그니쳐에 쓰는 generic type은 꼭 struct
정의 시 사용한 generic type parameter와 같을 필요는 없습니다.
struct Point<T, U> {
x: T,
y: U,
}
impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c' };
let p3 = p1.mixup(p2); // p1, p2 소유권 이전 발생 -> 소멸
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
Generic type을 사용한다고 해서 프로그램 실행이 느려지지 않습니다. 즉, Zero-cost Abstraction을 제공합니다.
Rust가 이것을 가능하게 하는 원리는 Monomorphization입니다. 컴파일러는 generic type을 실제 타입으로 치환하여 컴파일합니다. 따라서 실행 시점에는 generic type이 사용된 것이 아니라 실제 타입이 사용됩니다.
Traits: 공통적인 행위 정의하기
trait은 특정 타입이 갖는 기능을 정의하며 다른 타입과 공유할 수가 있습니다.
공유되는 behavior(메소드)를 추상적인 방식으로 정의하기 위해 trait을 사용할 수 있습니다.
trait bounds를 사용하여 어떤 generic type이 특정 행위를 수행하는 타입이어야 함을 명시할 수 있습니다.
Trait는 다른 프로그래밍 언어에서 interface나 abstract class와 유사한 개념입니다.
Trait 정의하기
타입의 행위는 그 타입에 대해 호출 가능한 메소드로 이루어져 있습니다.
Trait 정의란 동일한 목적을 수행하기 위해 필요한 행위들을 정의하여 메소드 시그니쳐를 그룹짓는 방법에 해당합니다.
Triat는 trait
키워드로 정의합니다. 이때 pub
키워드 역시 사용가능하며, 이는 해당 trait이 다른 모듈에서 사용 가능함을 의미합니다.
pub trait Summary {
fn summarize(&self) -> String;
}
한 trait은 여러 개의 메소드 시그니처를 body에 가질 수 있습니다. 이 trait를 구현하는 타입은 해당 trait이 정의한 모든 메소드를 구현해야 합니다.
Trait 구현하기
이제 각 타입에 대해 trait을 구현해 봅시다.
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
(aggregator/src/lib.rs
)
aggregator는 library crate라고 가정합시다.
위와 같이 trait를 구현할 때에는 impl
키워드 뒤에 정의된 trait명을 쓰고, for
키워드 뒤에 그 trait를 구현할 타입을 씁니다.
이제 impl
블록 내부에 trait이 정의한 메소드를 모두 구현합니다.
이렇게 trait 구현이 완료되면, 해당 타입은 trait이 정의한 모든 메소드를 사용할 수 있습니다.
Trait 구현의 제한 사항
Trait를 구현하려면 해당 타입 또는 trait가 crate 내부에 정의되어 있어야 합니다. 즉, 외부 crate에서 정의된 타입이나 trait에 대해 trait를 구현할 수 없습니다. 예를 들어 standard library에 정의된 Vec<T>
에 대해 Display
trait을 구현할 수 없습니다. 이러한 규칙을 orphan rule이라고 합니다.
Default Implementation
Trait 정의 시 해당 trait을 구현하는 모든 타입에 적용되는 default implementation을 제공할 수 있습니다.
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
위와 같이 default implementation을 제공하면, 해당 trait을 구현하는 타입이 해당 메소드를 구현하지 않을 경우 default implementation이 사용됩니다. 물론, 따로 구현하게 되면 default implementation은 무시됩니다.
Default implementation 내에서는 해당 trait 내 다른 메소드를 호출하는 것도 가능합니다. 그 메소드가 default implementation을 구현하고 있지 않더라도 가능합니다.
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
Traits as Parameters
Trait을 사용하여 함수의 parameter로 다양한 타입을 받을 수 있습니다.
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
item: &impl Summary
의 의미는 Summary
trait를 구현한 타입의 reference를 받는다는 의미입니다.
Trait Bound Syntax
사실 impl Trait
문법은 trait bound라고 하는 문법의 syntax sugar입니다.
Trait bound를 사용하려면 generic type parameter 선언 뒤에 :
을 붙이고, 이어서 해당 generic type이 구현해야 하는 trait을 씁니다.
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
impl Trait
은 편리하지만 trait bound에 비해 제약이 있기 때문에, 일반적으로 trait bound를 쓰는 것이 권장됩니다.
예를 들어, 두 파라미터가 같은 타입을 갖도록 제한하는 것이 impl Trait
으로는 불가능합니다.
이제 앞에서 다룬 largest
함수를 trait bound를 사용하여 다시 작성해 봅시다.
fn largest<T: PartialOrd>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
이제 T
타입은 PartialOrd
trait을 구현한 타입으로 제한됩니다. 이는 >
연산자를 사용할 수 있음을 보장합니다.
+
연산자를 사용하여 여러 개의 trait bound를 지정할 수도 있습니다.
fn some_function<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 {
0
}
fn some_function(item: impl Display + Clone, item2: impl Clone + Debug) -> i32 {
0
}
where
Clause
Trait bound가 길어지면 가독성이 떨어질 수 있습니다. 이럴 때 where
clause를 사용하여 trait bound를 분리할 수 있습니다.
fn some_function<T, U>(t: T, u: U) -> i32
where T: Display + Clone,
U: Clone + Debug
{
0
}
Return 타입에 Trait Bound를 정의하다
함수의 return 타입에도 trait bound를 사용할 수 있습니다.
fn returns_summarizable() -> impl Summary {
Tweet {
username: String::from("horse_ebooks"),
content: String::from("of course, as you probably already know, people"),
reply: false,
retweet: false,
}
}
참고로, 이 문법은 나중에 다뤄 볼 closure와 iterator를 다룰 때 특히 유용합니다. Closure와 iterator는 compiler만 알 수 있는 타입이거나 명시하기에 매우 긴 타입을 생성합니다. 이 문법은 리턴 타입을 간결하게 명시하는 데 유용합니다.
그러나 impl Trait
문법으로 리턴할 때에는 단일 타입을 리턴할 때에만 사용 가능하다는 제약이 있습니다.
impl
block에 trait bound를 사용하면 메소드를 구현할 때, 특정 trait을 갖는 타입에 해당하는 generic type을 사용하는 구조체에만 적용되는 메소드 등을 구현할 수 있습니다.
struct Pair<T> {
x: T,
y: T,
}
impl <T> Pair<T> { // T가 무슨 타입이든 상관 없음
fn new(x: T, y: T) -> Self {
Self {
x,
y,
}
}
}
impl<T: Display + PartialOrd> Pair<T> { // T가 Display와 PartialOrd를 구현한 타입만 가능
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
Conditional Method Implementation
다른 trait를 구현하는 타입에 대해 조건에 따라서 trait를 구현할 수도 있습니다.
이는 blanket implementation이라고 불립니다.
예를 들어 Rust 표준 라이브러리에서 ToString
trait는 Display
trait를 구현한 타입에 대해 자동으로 구현됩니다. 즉, ToString
에 정의된 to_string()
메소드는 Display
trait를 구현한 타입이라면 자동으로 사용 가능합니다.
let s = 3.to_string();
위와 같이 i32
타입은 Display
trait를 구현하고 있으므로 ToString
trait를 구현한 것으로 간주되어 to_string()
메소드가 사용 가능합니다.
Trait과 Trait Bound의 장점
Trait과 Trait Bound는 컴파일러에게 generic type parameter가 특정한 행위를 수행 가능함이 보장되어야 함을 알려줍니다. Python과 같은 dynamically typed language에서는 이러한 보장이 안 되기 때문에 정의되지 않은 메소드를 호출하는 등의 경우 runtime error가 발생할 수 있습니다. 하지만! Rust는 compile time에 이러한 문제를 미리 방지할 수 있는 것이죠.
Lifetime: Reference의 Validation
Lifetime은 Rust의 또 다른 종류의 generic으로써, reference의 수명을 결정합니다. 모든 reference는 lifetime을 가지며, 이는 각 reference가 유효한 범위를 나타냅니다.
주로, lifetime은 암시적으로 결정되며 추론됩니다. 타입 추론과 비슷하게 말이죠.
하지만 type annotation과 마찬가지로, reference들의 lifetime이 몇몇 방식으로 관계가 생길 때에는 lifetime을 명시해야 할 때가 있습니다.
Generic lifetime parameter를 활용하여 실제 runtime에 사용되는 reference가 유효함을 보장할 수 있습니다.
Lifetime을 명시한다는 개념은 다른 프로그래밍 언어에는 없는 Rust만의 특징입니다.
Dangling References
Lifetime의 주된 목적은 dangling reference를 방지하는 것입니다.
The Borrow Checker
Rust는 borrow checker를 통해 reference의 scope를 검사하고, dangling reference가 발생하지 않도록 합니다.
참조당하는 변수가 해당 변수를 가리키는 reference 보다 lifetime이 짧을 경우, dangling reference가 발생할 수 있기 때문에, borrow checker는 컴파일을 거부합니다.
그러나 때때로 lifetime을 확정하기 어려운 경우가 있습니다. 예를 들어, 함수의 parameter로 reference를 받았는데, 그 중 한 reference만 살아남아 리턴되는 경우입니다.
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
위 함수를 보면 x
또는 y
둘 중 하나는 살아남고, 하나는 죽습니다. 하지만 둘 중 누가 살아남는지 Rust는 컴파일 타임에 알 수 없습니다.
컴파일러는 이를 확인할 때 함수의 내용은 검사하지 않고 오직 함수의 signature만 보고 판단합니다.
즉, 리턴되는 reference가 항상 유효한지 확인하기 어렵습니다. 이런 이유로 Rust는 이 함수를 컴파일하지 않습니다.
이 문제를 해결하기 위해 generic lifetime parameter를 사용합니다.
Lifetime Annotation Syntax
Generic lifetime parameter에 사용되는 lifetime annotation은 reference의 lifetime을 명시하는 방법입니다.
Lifetime annotation은 reference의 수명을 바꾸지는 않습니다. 단지 reference의 lifetime이 유효한지 컴파일러에게 알려주는 역할을 합니다.
Lifetime annotation은 &
뒤에 '<이름>
을 사용해 표기합니다. 그 뒤에 공백을 하나 추가하여 타입 이름과 구분되도록 합니다.
&i32 // reference to i32
&'a i32 // reference to i32 with lifetime 'a
&'a mut i32 // mutable reference to i32 with lifetime 'a
함수의 시그니처에 lifetime annotation을 사용하려면, Generic type 선언이 없는 경우 그냥 <>
안에 lifetime annotation만 씁니다.
Generic type 선언이 함께 있는 경우 쉼표로 구분하여 <'a, T>
와 같이 씁니다.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
위 함수의 경우 lifetime annotation은 두 parameter가 모두 valid할 때 리턴되는 reference가 valid함을 보장합니다. 더 정확하게는, x
와 y
중 더 짧은 lifetime이 리턴되는 reference의 lifetime과 같다는 것입니다.
위 함수에서는 두 parameter의 lifetime을 같게 명시했기 때문에 전달되는 reference의 lifetime이 서로 다를 경우에도 두 parameter의 lifetime 역시 둘 중 더 짧은 lifetime으로 통일됩니다.
lifetime을 명시해야 하는 상황은 함수의 행위에 따라 다릅니다. 예를 들어 리턴하는 reference가 단 하나로 특정되는 함수는 lifetime을 명시할 필요가 없습니다.
reference를 갖는 struct
에 lifetime annotation 사용하기
struct
의 멤버가 reference를 갖는 경우, lifetime annotation은 필수적입니다.
struct ImportantExcerpt<'a> {
part: &'a str, // lifetime parameter
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt { part: first_sentence };
}
위와 같이 구조체 이름 뒤에 <>
를 추가하고 lifetime parameter를 씁니다. 그러면 구조체 정의 부분에서 lifetime parameter를 사용할 수 있습니다.
위 코드의 경우 ImportantExcerpt
구조체는 part
의 reference가 갖는 lifetime과 같은 lifetime을 갖는 것을 의미합니다.
또한 위와 같이 lifetime parameter가 명시된 구조체는 impl
키워드 뒤에도 lifetime parameter가 선언되어야 하며, 구조체 이름 뒤에서도 항상 명시되어야 합니다.
impl
블록에서 정의되지 않은 lifetime parameter가 함수에서 새롭게 정의되면 lifetime annotation을 해당 함수 signature에서 명시해야 합니다.
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
위 코드의 경우 메소드의 lifetime은 명시될 필요가 없었습니다. 그 이유는 아래에서 다루겠습니다.
Lifetime Elision
Rust는 lifetime elision이라는 문법을 제공하여 lifetime annotation을 생략할 수 있습니다.
사전 정의
-
함수나 메소드 파라미터의 lifetime은 input lifetime이라고 합니다.
-
리턴 타입의 lifetime은 output lifetime이라고 합니다.
Lifetime Elision 규칙
-
컴파일러는 타입이 reference인 각 parameter에 대해 unique한 lifetime parameter를 부여합니다.
-
만약 하나의 input lifetime만 있다면, 그 lifetime은 모든 output lifetime에 대해 적용됩니다.
-
만약 함수가
&self
나&mut self
를 parameter로 갖는 메소드라면,self
의 lifetime이 모든 output lifetime에 적용됩니다.
위 규칙은 impl
블록과 함수 정의 모두에 적용됩니다.
컴파일러가 위 규칙을 적용한 이후에 몇몇 reference에 대해 lifetime을 결정할 수 없을 때에는 컴파일 에러가 발생할 것이고, 이때는 lifetime annotation을 명시해야 합니다.
컴파일 에러가 발생할 것인지 손으로 예측하려면 직접 위 규칙을 순서대로 적용해 보고, lifetime parameter가 누락된 reference가 존재하는지 확인하면 됩니다.
그리고 Elision 규칙에 의해 자동으로 정해진 lifetime과 실제 함수가 리턴하는 reference의 lifetime이 다를 경우, lifetime annotation을 명시해야 합니다.
Static Lifetime
'static
은 프로그램 전체의 라이프타임을 의미합니다. 프로그램이 종료될 때까지 유효합니다.
예를 들어 모든 string 리터럴은 내부적으로 프로그램 바이너리에 저장되므로 'static
lifetime을 갖습니다.
static한 값을 리턴할 때에는 lifetime annotation을 'static
으로 명시해야 합니다.
fn static_number() -> &'static i32 {
&10
}
fn main() {
println!("{}", static_number());
}
All-in-One Example
이 글에서 다룬 모든 개념이 등장하는 단일 함수 예제를 살펴보며, 마치겠습니다.
use std::fmt::Display;
fn main() {
let string1 = String::from("long string is long");
let string2 = "xyz"
let result = longest_with_an_announcement(string1.as_str(), string2, "Today is someone's birthday!");
println!("The longest string is {}", result);
}
fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
where T: Display
{
println!("Announcement! {}", ann);
if x.len() > y.len() {
x
} else {
y
}
}
참고 문헌
-
고려대학교 컴퓨터학과 오상은 교수님의 시스템 프로그래밍(COSE322) 과목 강의자료
Rust 맛보기