[RUST] 10. 제네릭 타입, 트레이트 그리고 수명

    제네릭을 활용하는 타입, 함수, 메서드를 직접 선언하는 방법을 살펴보자.

    함수로부터 중복 제거하기

    먼저 제네릭 타입을 사용하지 않고 함수로부터 중복된 코드를 제거하는 방법을 알아보자.

    fn main() 
    {
        let number_list = vec![34, 50, 25, 100, 65];
    
        let mut largest = number_list[0];
    
        for number in number_list {
            if number > largest {
                largest = number;
            }
        }
    
        println!("가장 큰 숫자: {}", largest);
    }

    두 개의 리스트에서 가장 큰 값을 찾으려면 위 예제를 복사하여 두번 실행 하면 된다. 이런 중복 코드를 없애려면 정수의 리스트를 매개변수로 전달받아 작업을 실행하는 함수를 정의하면 된다.

    fn largest(list: &[i32]) -> i32 {
        let mut largest = list[0];
    
        for &item in list.iter() {
            if item > largest {
                largest = item;
            }
        }
    
        largest
    }
    
    fn main() {
        let number_list = vec![34, 50, 25, 100, 65];
    
        let result = largest(&number_list);
        println!("가장 큰 숫자: {}", result);
    
        let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];
    
        let result = largest(&number_list);
        println!("가장 큰 숫자: {}", result);
    }

    다음으로는 i32 값들의 슬라이스로 부터 가장 큰 값을 찾는 코드와 char 값의 슬라이스로부터 가장 큰 값을 찾는 코드가 있다고 가정할 때 코드의 중복을 어떻게 제거할지 알아볼 것이다.

    제네릭 데이터 타입

    제네릭은 여러 구체화된 타입을 사용할 수 있는 함수 시그너처나 구조체 같은 아이템을 정의할 때 사용한다.

    함수 정의에서 사용하기

    fn largest_i32(list: &[i32]) -> i32 {
        let mut largest = list[0];
    
        for &item in list.iter() {
            if item > largest {
                largest = item;
            }
        }
    
        largest
    }
    
    fn largest_char(list: &[char]) -> char {
        let mut largest = list[0];
    
        for &item in list.iter() {
            if item > largest {
                largest = item;
            }
        }
    
        largest
    }
    
    fn main() {
        let number_list = vec![34, 50, 25, 100, 65];
    
        let result = largest_i32(&number_list);
        println!("가장 큰 숫자: {}", result);
    
        let char_list = vec!['y', 'm', 'a', 'q'];
    
        let result = largest_char(&char_list);
        println!("가장 큰 문자: {}", result);
    }

    제네릭 타입으로 묶을 때 함수이름과 매개변수 목록 사이에 를 사용하여 사용한다.

    fn largest<T>(list: &[T]) -> T {
        let mut largest = list[0];
    
        for &item in list.iter() {
            if item > largest {
                largest = item;
            }
        }
    
        largest
    }
    
    fn main() {
        let number_list = vec![34, 50, 25, 100, 65];
    
        let result = largest(&number_list);
        println!("가장 큰 숫자: {}", result);
    
        let char_list = vec!['y', 'm', 'a', 'q'];
    
        let result = largest(&char_list);
        println!("가장 큰 문자: {}", result);
    }

    위 코드를 컴파일하면 에러가 발생한다.

    타입 T의 값을 비교하므로 이 값은 반드시 정렬 가능해야 한다. 표준 라이브러리는 비교 연산을 수행할 타입들은 std::cmp::PartialOrd 트레이트를 구현할 것을 요구한다.

    구조체 정의에서 사용하기

    구조체의 필드에도 <> 구문을 이용해 제네릭 타입 매개변수를 사용할 수 있다.

    struct Point<T> {
        x: T,
        y: T,
    }
    
    fn main() {
        let integer = Point { x: 5, y: 10 };
        let float = Point { x: 1.0, y: 4.0 };
    }

    필드 x와 y는 모두 같은 타입이기 때문에 인스턴스를 생성할 때 서로 다른 타입의 값을 사용하면 컴파일되지 않는다.

    struct Point<T> {
        x: T,
        y: T,
    }
    
    fn main() {
        let wont_work = Point { x: 5, y: 4.0 };
    }

    다른 타입의 제네릭 데이터 타입으로 선언하고 싶다면 다중 제네릭 타입 매개변수를 사용하면 된다.

    struct Point<T, U> {
        x: T,
        y: U,
    }
    
    fn main() {
        let both_integer = Point { x: 5, y: 10 };
        let both_float = Point { x: 1.0, y: 4.0 };
        let integer_and_float = Point { x: 5, y: 4.0 };
    }

    열거자 정의에서 사용하기

    enum Option<T> {
        Some(T),
        None,
    }
    
    enum Result<T,E> {
        Ok(T),
        Err(E),
    }

    Option는 타입 T를 일반화한 열거자며, 두 개의 열것값을 가지고 있다. 해당 열거자를 사용하면 선택적인 값의 개념을 추상화할 수 있으며, 제네릭 열거자이므로 선택적인 값의 타입과 무관하게 추상화된 타입을 사용할 수 있다.
    Result 열거자는 두 개의 타입 T와 E를 일반화한 타입이며 두 개의 열것값을 갖는다. Result 열거자는 작업이 성공적으로 실행된 경우와 실패한 경우를 모두 표현할 수 있다. 코드에서 여러개의 구조체나 열거자가 오직 저장하는 값의 타입만 다를 때는 제네릭 타입을 이용해 이런 중복을 제거할 수 있다.

    메서드 정의에서 사용하기

    struct Point<T> {
        x: T,
        y: T,
    }
    
    impl<T> Point<T> {
        fn x(&self) -> &T {
            &self.x
        }
    }
    
    fn main() {
        let p = Point { x: 5, y: 10 };
    
        println!("p.x = {}", p.x());
    }

    위 예제는 Point 구조체에 필드 x의 데이터에 대한 참조를 리턴하는 메서드 x를 정의하고 있다. Point 타입의 메서드를 구현한다는 점을 명시하기 위해 impl 키워드 바로 다음에 타입 매개변수 T를 지정했다는 점에 유의하자. impl 키워드 다음에 타입 T를 지정하면 러스트는 Point 구조체의 꺽쇠 괄호 안에 지정된 타입이 구체화된 타입이 아닌 제네릭 타입이라는 점을 인식한다.

    impl Point<f32> {
        fn distance_from_origin(&self) -> f32 {
            (self.x.powi(2) + self.y.powi(2)).sqrt()
        }
    }

    위 코드는 Point 특정 타입의 인스턴스에만 적용할 메서드를 구현한 것이다. 이때는 impl 키워드 뒤에 타입을 명시할 필요가 없다. 다른 Point 인스턴스는 이 메서드를 사용할 수 없다.

    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);
        println!("p3.x = {}. p3.y = {}", p3.x, p3.y);
    }

    위코드는 자신과 다른 구조체를 매개변수로 하여 혼합된 타입을 적용할수 있는 메서드를 구현한 것이다.

    제네릭의 성능

    러스트가 제네릭을 구현하는 방식은 제네릭 타입을 사용한다고 해서 구체화된 타입을 사용할 떄보다 성능이 떨어지지 않는 다는 점이다. 컴파일 시점에 제네릭을 사용하는 코드를 단일화(Monomorphzation)하기 때문이다. 단일화는 컴파일 시점에 제네릭 코드를 실제로 사용하는 구체화된 타입으로 변환하는 과정이다. 이런 단일화 과정 덕분에 러스트의 제네릭은 런타임에 매우 효율적이다.

    트레이트: 공유 가능한 행위를 정의 하는 방법

    트레이트(trait)는 공유 가능한 행위를 추상화된 방식으로 정의하는 방법이다. 다른 언어에서의 인터페이스라고 부르는 기능과 유사하다

    트레이트 선언하기

    pub trait Summary {
        fn summarize(&self) -> String;
    }

    트레이트를 정의할 땐느 trait 키워드 다음에 트레이트의 이름을 지정한다. 중괄호 안에는 이 트레이트를 구현할 타입의 행위를 설명하는 메서드 시그너처를 정의한다. 메서드 시그너처 다음에는 구현 코드 대신 세미콜론을 덧붙인다. 컴파일러는 모든 타입이 정확히 같은 시그너처를 가진 메서드를 구현하도록 보장한다.

    타입에 트레이트 구현하기

    pub struct NewArticle {
        pub headline: String,
        pub location: String,
        pub author: String,
        pub content: String,
    }
    
    impl Summary for NewArticle {
        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)
        }
    }

    타입에 트레이트를 구현하는 것은 메서드를 구현하는 방법과 유사하다. 위와 같이 트레이트를 구현한 뒤에는 보통의 메서드와 마찬가지로 인스턴스에 대해 해당 메서드를 호출할 수 있다.

    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from("러스트 언어 공부를 시작했습니다."),
        reply: false,
        retweet: false,
    };
    
    println!("새 트윗 1개: {}", tweet.summarize());

    기본 구현

    때로는 일부 혹은 전체 메서드의 기본 동작을 구현해 주는 편이 유용할 때가 있다.

    pub trait Summary {
        fn summarize(&self) -> String {
            String::from("(계속 읽기)")
        }
    }

    impl Summary for NewsArticle {} 블록을 비워두게 된다면 summarize 메서드 기본 구현으로 실행된다.

    let article = NewsArticle {
        headline: String::from("대한민국, 러시아 월드컵 예선에서 독일을 이겼다."),
        location: String::from("카잔 아레나, 러시아"),
        author: String::from("위키백과"),
        content: String::from("2018년 6월 27일 러시아 카잔의 카잔 아레나에서 열린 2018 월드컵 F조 3차전 경기에서 대한민국이 독일에 2:0 승리를 거뒀다."),
    };
    
    println!("새로운 기사: {}", article.summarize());

     

    pub trait Summary {
        fn summarize_author(&self) -> String;
        fn summarize(&self) -> String {
            format!("{}님의 기사 더 읽기", self.summarize_author())
        }
    }
    
    impl Summary for Tweet {
        fn summarize_author(&self) -> String {
            format!("@{}", self.username)
        }
    }
    
    let tweet = Tweet {
        username: String::from("hourse_ebook"),
        content: String::from("러스트 언어 공부를 시작했습니다."),
        reply: false,
        retweet: false,
    };
    
    println!("새 트윗 1개: {}", tweet.summarize());

    위는 summarize_author 메서드만 정의하여 사용하는 경우이다.

    트레이트 매개변수

    pub fn notify(item: impl Summary) {
        println!("속보! {}", item.summarize());
    }

    item 매개변수를 실제 타입을 이용해 정의하는 대신 impl 키워드와 트레이트의 이름을 이용해 정의 했다.

    트레이트 경계 문법

    pub fn notify<T: Summary>(item: T) {
        println!("속보! {}", item.summarize());
    }

    impl Trait 문법은 함수의 정의가 간단한 경우에는 훨씬 편리하며, 깔끔한 코드를 작성할 수 있다. 서로다른 타입의 두가지 매개변수를 전달하려면 impl Trait 문법을 사용하는 것이 적절하다. 같은 타입을 하고자하면 제네릭 타입 T로 정의할 수 있다.

    pub fn notify(item1: impl Summary, item2: impl Summary) {
        //생략
    }
    
    pub fn notify<T: Summary>(item1: T, item2: T) {
        //생략
    }

    + 문법으로 여러 트레이트 경계 정의하기

    하나 이상의 트레이트 경계를 정의하는 것도 가능하다. 제네릭 타입의 트레이트 경계도 가능하다

    pub fn notify(item: impl Summary + Display) {
        //생략
    }
    
    pub fn notify<T: Summary + Display>(item: T) {
        //생략
    }

    where 절을 이용해 트레이트 경계 정리하기

    여러개의 제네릭 타입 매개변수를 갖는 함수에는 함수 이름과 매개변수 목록 사이에 많은 트레이트 경계를 나열하게 되어 가독성이 떨어진다. 그래서 러스트는 트레이트 경계를 함수 시그너처 안에 where 절을 이용해 선언하는 방법을 제공한다.

    fn some_function<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 {
        //생략
    }
    
    fn some_function<T, U>(t: T, u: U) -> i32 {
        where T: Display + Clone,
              U: Clone + Debug
    }

    트레이트를 구현하는 값 리턴하기

    impl Trait 문법은 특정 트레이트를 구현하는 타입을 리턴값으로 사용할 때도 활용할 수 있다.

    fn returns_summarizable() -> impl Summary {
        Tweet {
            username: String::from("hourse_ebooks"),
            content: String::from("러스트 언어 공부를 시작했습니다"),
            reply: false,
            retweet: false,
        }
    }

    리턴 타입으로 impl Summary를 지정했기 때문에 위 함수는 실제 타입 이름을 사용하지 않고도 Summary 트레이트를 구현하는 어떤 타입도 리턴할 수 있게 됐다. 하지만 impl Trait 문법은 하나의 타입을 리턴하는 경우에만 사용할 수 있다. 아래와 같이 NewsArticle이나 Tweet 타입을 리턴하는 코드는 동작하지 않는다.

    fn returns_summarizable() -> impl summary {
        if switch {
            NewsArticle {
                //중략
            }
        } else    {
            Tweet {
                //중략
            }
        }
    }

    트레이트 경계를 이용해 largest 함수 수정하기

    처음으로 largest 함수를 수정해보자

    fn largest<T: PartialOrd>(list: &[T]) -> T {
        //생략
    }

    위와 같이 수정후 컴파일하면 다른 에러가 발생한다.

    Copy 트레이트를 구현하는 타입에 대해서만 호출할 수 있게 하려면 타입 T의 트레이트 경계에 Copy 트레이트를 추가하면 된다.

    fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
        let mut largest = list[0];
        for &item in list.iter() {
            if item > largest {
                largest = item;
            }
        }
    
        largest
    }
    
    fn main() {
        let number_list = vec![34, 50, 25, 100, 65];
    
        let result = largest(&number_list);
        println!("가장 큰 숫자: {}", result);
    
        let char_list = vec!['y', 'm', 'a', 'q'];
    
        let result = largest(&char_list);
        println!("가장 큰 문자: {}", result");
    }

    트레이트 경계를 이용해 조건에 따라 메서드 구현하기

    제네릭 타입 매개변수를 사용하는 impl 블록에 트레이트 경계를 사용하면 타입이 특정 트레이트를 구현하는지에 따라 메서드를 구현할 수 있다.

    use std::fmt::Display;
    
    struct Pair<T> {
        x: T,
        y: T,
    }
    
    impl<T> Pair<T> {
        fn new (x:T, y:T) -> Self {
            Self {
                x,
                y,
            }
        }
    }
    
    impl<T: Display + PartialOrd> Pair<T> {
        fn cmp_display(&self) {
            if self.x >= self.y {
                println!("가장 큰 멤버는 x:", self.x);
            } else {
                println!("가장 큰 멤버는 y = {}", self.y);
            }
        }
    }

    위는 트레이트 경계에 따라 제네릭 타입의 메서드를 조건적으로 구현한 것이다.

    수명을 이용해 참조 유효성 검사하기

    수명이란 참조가 유효한 범위를 말한다. 대부분의 수명은 암묵적이며, 추론을 토대로 동작한다.

    수명을 이용해 죽은 참조의 발생 방지하기

    수명의 주요 목적은 죽은 참조가 발생하는 것을 방지하는 것이다.

    {
        let r;
        {
            let x = 5;
            r = &x;
        }
    
        println!("r: {}", r);
    }

    위 코드는 println시 r이 x에 대한 참조를 대입하였으나 범위를 벗어나서 컴파일 되지 않는다. 러스트는 대여 검사기 덕분에 유효하지 않은지 알 수 있다.

    대여 검사기

    러스트는 컴파일타임에 두 수명의 크기를 비교해서 참조 대상의 수명이 참조의 수명보다 짧으면 컴파일을 허락하지 않는다.

    함수의 제네릭 수명

    두 개의 문자열 슬라이스 중 길이가 더 긴 것을 리턴하는 함수를 작성해보자.

    fn longest(x: &str, y: &str) -> &str {
        if x.len() > y.len(){
            x
        } else {
            y
        }
    }
    
    fn main() {
        let string1 = String::from("abcd");
        let string2 = "xyz";
    
        let result = longest(string1.as_str(), string2);
        println!("더 긴 문자열: {}", result);
    }

    위 코드를 컴파일하면 수명과 관련된 에러가 발생한다.

    이 에러를 수정하려면 대여 검사기가 수명 분석을 할 수 있도록 제네릭 수명 매개변수를 정의해서 참조 간의 관계를 정의해야 한다.

    수명 애노테이션 문법

    수명 애노테이션은 참조의 유효 기간을 변경하지는 않는다. 제네릭 수명 매개변수를 지정하면 어떤 수명의 참조도 전달할 수 있다. 수명 매개변수의 이름은 반드시 작은따옴표(')로 시작해야 하며, 제네릭 타입처럼 짧지만 소문자로 구성된 이름을 지정한다. 대부분 사람은 'a 라는 이름을 사용한다.

    &i32        //참조
    &'a         //명시적인 수명을 가진 참조
    &'a mut i32 //명시적인 수명을 가진 가변 참조

    함수 시그너처의 수명 애노테이션

    이제 longest 함수에서 수명 애노테이션을 활용하는 방법을 살펴보자. 모든 참조의 매개변수와 리턴값이 같은 수명을 가져가도록 해야한다. 따라서 'a를 모든 참조에 지정해 주어야 한다.

    fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
        if x.len() > y.len(){
            x
        } else {
            y
        }
    }

    수명의 관점에서 생각하기

    함수가 참조를 리턴할 때는 리턴 타입의 수명 매개변수는 매개변수 중 하나의 수명 매개변수와 일치해야 한다.

    fn longest<'a>(x: &str, y: &str) -> &'a str {
        let result = String::from("아주 긴 문자열");
        result.as_str()
    }

    위 예제는 리턴 타입에만 수명 매개변수 'a를 지정했다. 이 코드가 컴파일되지 않는 이유는 리턴 값의 수명이 매개변수의 수명과 연관되지 않기 때문이다. 이 코드의 문제점은 변수 result가 범위를 벗어나면서 메모리에서 해제된다는데 있다.

    구조체 정의에서의 수명 애노테이션

    구조체에 참조를 저장할 수도 있다. 구조체 정의에 포함된 모든 참조에 수명 애노테이션을 추가하면 된다.

    struct ImportantExcerpt<'a> {
        part: &'a str,
    }
    
    fn main() {
        let novel = String::from("스타워즈. 오래 전 멀고 먼 은하계에...");
        let first_sentence = novel.split('.')
            .next()
            .expect("문장에서 마침표'.'를 찾을 수 없습니다.");
        let i = ImportantExcerpt { part: first_sentance };
    }

    수명의 생략

    특정 상황에서 같은 수명 애노테이션을 반복해서 적용하는 것이 늘어나자. 몇가지 패턴들에 대해 대여 검사기가 해당 상황에서는 수명을 추론함으로써 명시적인 애노테이션이 필요치 않도록 수정하였다. 러스트의 참조 분석에 추가된 패턴은 수명 생략 규칙(lifetime slision rules)이라고 한다. 함수나 메서드의 매개변수에 적용되는 수명을 입력 수명(input lifetimes)이라고 하며, 리턴 값에 적용되는 수명을 출력 수명(output lifetimes)이라고 한다. 컴파일러는 다음 3가지 규칙을 이용하여 명시적인 애노테이션이 없을때 어떤 수명을 적용할 것인지 판단한다.

    • 각 참조의 매개변수는 각각의 수명 매개변수가 있어야 한다.
    • 명시적으로 하나의 입력 수명 매개변수가 있으면 입력 수명을 모든 출력 수명 매개변수에 적용한다.
    • 입력 수명 매개변수가 하나 이상이며 함수가 메서드로 선언되어서 매개변수 중 하나가 &self나 &mut self 일 때는 self 변수의 수명을 모든 출력 수명 매개변수에 적용한다.

    메서드 정의에서의 수명 애노테이션

    구조체 필드의 수명 이름은 항상 impl 키워드 다음에 선언하며 구조체 이름 다음에 명시해야 한다.

    impl<'a> ImportantExcerpt<'a> {
        fn level(&self) -> i32 {
            3
        }
    }

    수명 매개변수는 impl 키워드 다음에 선언하며 타입 이름 다음에도 지정해 주어야 한다. 하지만 첫 번째 규칙 덕분에 self 매개변수에 수명을 지정할 필요는 없다.

    impl<'a> ImportantExcerpt<'a> {
        fn announce_and_return_part(&self, announcement: &str) -> &str {
            println!("주목해 주세요! {}", announcement);
            self.part
        }
    }

    위 예제는 두 개의 입력 수명이 명시되었으므로 러스트는 첫 번째 수명 생략 규칙을 적용해 &self와 announcement 매개변수에 각각의 수명을 부여한다.

    정적 수명

    정적 수명은 특별한 수명으로 전체 프로그램에 적용된다. 모든 문자열 리터럴은 'static 수명이며 직접 명시할 수도 있다.

    let s: &'static str = "문자열은 정적 수명이다.";

    제네릭 타입 매개변수, 트레이트 경계, 그리고 수명

    use std::fmt::Display;
    
    fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str where T: Display
    {
        println!("주목하세요: {}", ann);
        if x.len() > y.len() {
            x
        } else {
            y
        }
    }

    이 longest 함수는 앞선 예제와 같이 길이가 긴 문자열을 리턴한다. 이번에는 제네릭 타입 T 타입의 ann 이라는 매개변수가 추가되었다. 이 매개변수는 DIsplay 트레이트를 구현하는 타입이라면 어떤 타입도 사용할 수 있다. 문자열 슬라이스의 길이를 비교하기 전에 제네릭 매개변수의 값을 출력하기에 Display 트레이트 경계가 필요하다. 수명 역시 제네릭 타입이므로 수명 매개변수 'a와 제네릭 타입 매개변수 T는 함수 이름 다음의 꺽쇠괄호 안에 함께 추가하면 된다.

    요약

    • 제네릭 타입 매개변수
    • 트레이트와 트레이트 경계
    • 제네릭 수명 매개변수

    위 기능을 바탕으로 반복해야하는 코드를 상당히 줄일 수 있다.

    반응형

    댓글

    Designed by JB FACTORY