[rust] 10. 제네릭 타입, 트레이트, 라이프타임

About_work·2024년 7월 20일
0

rust

목록 보기
12/16

0. 들어가기 전에

  • 제네릭은 구체 (concrete) 타입 혹은 기타 속성에 대한 추상화된 대역
  • 컴파일과 실행 시점에 제네릭들이 실제로 무슨 타입으로 채워지는지 알 필요 없이
    • 제네릭의 동작이나 다른 제네릭과의 관계를 표현할 수 있습니다.
  • Option<T>, Vec<T>, HashMap<K, V>, Result<T, E> 제네릭
  • 매개변수의 타입만 다른 두 함수가 생기면, 제네릭 함수를 사용해 코드 중복을 한 번 더 줄일 수 있음
  • 또한, 제네릭 타입을 구조체 및 열거형 정의에 사용하는 방법도 살펴보자.

  • 트레이트 (trait)를 이용해 동작을 제네릭한 방식으로 정의하는 법을 배워보겠습니다.
  • 트레이트를 제네릭 타입과 함께 사용하면,
    • 아무 타입이나 허용하는 것이 아니라
    • 특정 동작을 하는 타입만 허용할 수 있음

  • 라이프타임은 제네릭의 일종이며, 컴파일러에게 참조자들이 서로 어떤 관계에 있는지를 알려주는 데에 사용
  • 작성자의 추가적인 도움 없이도, 참조자의 여러 가지 상황에 대한 유효성 검증을 할 수 있게 해줌

0.1. 함수를 추출하여 중복 없애기

  • 제네릭은 여러 가지 타입을 나타내는 자리표시자의 위치에 특정 타입을 집어넣는 것으로 코드 중복을 제거할 수 있게 해 줍니다.
fn largest(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

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

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {}", result);
}
  • 제네릭으로 이 과정을 그대로 진행하여 중복된 코드를 제거해 보겠습니다.
  • 만약 i32 슬라이스에서 최댓값을 찾는 함수char 슬라이스에서 최댓값을 찾는 함수를 따로 가지고 있다면 어떨까요?

1. 제네릭 데이터 타입

  • 제네릭을 사용하면 함수 시그니처나 구조체의 아이템에 다양한 구체적 데이터 타입을 사용할 수 있도록 정의할 수 있습니다.

1.1. 제네릭 함수 정의

  • 새 단일 함수의 시그니처 내 타입을 매개변수화하려면 타입 매개변수의 이름을 지어줄 필요
  • 방법은 함수 매개변수와 비슷
  • 타입 매개변수의 이름에는 아무 식별자나 사용할 수 있지만, 여기서는 T를 사용하겠습니다.
  • 러스트에서는 타입 이름을 지어줄 때는 대문자로 시작하는 낙타 표기법 (UpperCamelCase) 을 따르고,
  • 타입 매개변수의 이름은 짧게 (한 글자로만 된 경우도 종종 있습니다) 짓는 것이 관례
  • 타입 매개변수를 사용하기 전에도 타입 매개변수의 이름을 선언해야 함
fn largest<T>(list: &[T]) -> &T {
  • ‘largest 함수는 어떤 타입 T에 대한 제네릭 함수’

  • 아래 코드는 애러남 (binary operation > cannot be applied to type &T)
fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

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

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {}", result);
}
  • 함수 본문에서 T 타입 값들에 대한 비교가 필요하므로, 여기에는 값을 정렬할 수 있는 타입에 대해서만 동작할 수 있습니다.
  • 도움말의 제안을 따라서 T가 PartialOrd를 구현한 것일 때만 유효하도록 제한을 두면 이 예제는 컴파일되는데,
    • 이는 표준 라이브러리가 i32와 char 둘 모두에 대한 PartialOrd를 구현하고 있기 때문

1.2. 제네릭 구조체 정의

  • 구조체 필드에서 제네릭 타입 매개변수를 사용하도록 구조체를 정의
  • <T>: Type 매개변수의 이름 선언
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 };
}
  • Point<T> 선언에 하나의 제네릭 타입만 사용했으므로,
  • 이 선언은 Point<T>가 어떤 타입 T에 대한 제네릭이며
    • x, y 필드는 실제 타입이 무엇이건 간에 둘 다 동일한 타입이라는 것을 의미

  • 아래 코드는 mismatched types 에러가 남.
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}
  • x에 정숫값 5를 대입할 때 Point<T> 인스턴스의 제네릭 타입 T를 정수 타입으로 인지
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 };
}
  • 제네릭 타입 매개변수는 원하는 만큼 여러 개를 정의할 수 있지만, 많으면 많아질수록 코드 가독성은 떨어집니다.
  • 만약 코드에서 많은 수의 제네릭 타입이 필요함을 알게 되었다면, 코드를 리팩터링해서 작은 부분들로 나누는 것을 고려해야 할 수도 있겠습니다.

1.3. 제네릭 열거형 (enum) 정의

enum Option<T> {
    Some(T),
    None,
}
  • Option<T> 열거형은 T 타입에 대한 제네릭
enum Result<T, E> {
    Ok(T),
    Err(E),
}

1.4. 제네릭 메서드 정의

  • 구조체나 열거형에 메서드를 구현할 때도 제네릭 타입을 이용해 정의할 수 있습니다.
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());
}
  • impl:
    • 이 부분은 T 타입에 대해 제네릭으로 구현된 메서드를 정의한다는 것을 의미
  • Point 구조체가 어떤 타입 T로 인스턴스화되든,
  • 이 메서드는 그 타입에 대해 동작할 수 있습니다.
  • impl 바로 뒤에 T를 선언하여, Point<T> 타입에 메서드를 구현한다고 명시했음을 주의
  • 참고: 함수에는 아래와 같이, 사용
fn largest<T>(list: &[T]) -> &T {
  • 구조체 정의에 선언된 제네릭 매개변수와는 다른 제네릭 매개변수를 선택할 수도 있었겠지만, 같은 이름을 사용하는 것이 관례
  • 이 타입의 메서드를 정의할 때, 제네릭 타입에 대한 제약을 지정할 수도 있습니다.
    • 예를 들면,
      • 임의의 제네릭 타입 Point<T> 인스턴스가 아닌
      • Point<f32> 인스턴스에 대한 메서드만을 정의할 수 있습니다.
  • 예제 10-10에서는 구체적 타입 f32을 사용하였는데, impl 뒤에는 어떤 타입도 선언하지 않았습니다.
impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}
  • Point<f32> 타입 인스턴스는 distance_from_origin 메서드를 갖게 될 것;
    • T가 f32 타입이 아닌 Point<T> 인스턴스는 이 메서드가 정의되지 않습니다.
  • 구조체 정의에서 사용한 제네릭 타입 매개변수와, 구조체의 메서드 시그니처 내에서 사용하는 제네릭 타입 매개변수가 항상 같은 것은 아닙니다.
struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        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);
}
  • 이 예제는 제네릭 매개변수 중 일부가 impl 에 선언되고 일부는 메서드 정의에 선언되는 경우를 보여주기 위한 예제
  • 여기서 제네릭 매개변수 X1, Y1는 구조체 정의와 한 묶음이니 impl 뒤에 선언했지만,
    • 제네릭 매개변수 X2, Y2는 mixup 메서드에만 연관되어 있으므로 fn mixup 뒤에 선언

1.5. 제네릭 코드의 성능

  • 제네릭 타입의 사용이 구체적인 타입을 사용했을 때와 비교해서 전혀 느려지지 않는다는 것
  • 컴파일 타임에 제네릭을 사용하는 코드를 단형성화
    • 제네릭 코드를 실제 구체 타입으로 채워진 특정한 코드로 바꾸는 과정
let integer = Some(5);
let float = Some(5.0);
  • 단형성화된 코드는 다음과 비슷합니다. (여기 사용된 이름은 예시를 위한 것이며 컴파일러에 의해 생성되는 이름은 다릅니다):
enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

2. 트레이트로 공통된 동작을 정의하기

  • 트레이트 (trait): 특정한 타입이 가지고 있으면서 다른 타입과 공유할 수 있는 기능을 정의
  • 트레이트를 사용하면 공통된 기능을 추상적으로 정의
  • 트레이트 바운드 (trait bound): 어떤 제네릭 타입 자리에 특정한 동작을 갖춘 타입이 올 수 있음을 명시할 수 있음
  • 트레이트는 다른 언어에서 흔히 인터페이스 (interface) 라고 부르는 기능과 유사

2.1. 트레이트 정의하기

  • 타입의 동작: 해당 타입에서 호출할 수 있는 메서드로 구성됨
  • 만약 다양한 타입에서 동일한 메서드를 호출할 수 있다면, 이 타입들은 동일한 동작을 공유한다고 표현할 수 있음
  • 트레이트 정의는 메서드 시그니처를 그룹화하여, 특정 목적을 달성하는 데 필요한 일련의 동작을 정의하는 것
pub trait Summary {
    fn summarize(&self) -> String;
}
  • 이 크레이트에 의존하는 다른 크레이트가, 이 트레이트를 사용할 수 있도록 하였습니다.
  • 메서드 시그니처 뒤에는 중괄호로 시작하여 메서드를 구현하는 대신 세미콜론을 집어넣었습니다.
  • 이 트레이트를 구현하는 각 타입이 메서드에 맞는 동작을 직접 제공해야 합니다.

2.2. 특정 타입에 트레이트 구현하기

  • Summary 트레이트의 메서드 시그니처를 원하는 대로 정의했으니, 종합 미디어 크레이트의 각 타입에 Summary 트레이트를 구현해 봅시다.
  • impl Summary for NewsArticle / impl Summary for Tweet은 NewsArticle, Tweet struct 타입에 summary trait를 구현한 것
  • 아래코드는 aggregator 크레이트
// src/lib.rs

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)
    }
}
  • 어떤 타입에 대한 트레이트를 구현하는 것은 평범한 메서드를 구현하는 것과 비슷
  • 라이브러리가 NewsArticle과 Tweet에 대한 Summary 트레이트를 구현했으니,
    • 크레이트 사용자는 NewsArticle과 Tweet 인스턴스에 대하여 보통의 메서드를 호출하는 것과 같은 방식으로 트레이트 메서드를 호출할 수 있습니다.
  • 유일한 차이점: 크레이트 사용자가 타입 뿐만 아니라, 트레이트도 스코프로 가져와야 한다는 점
  • 바이너리 크레이트가 aggregator 라이브러리 크레이트를 사용하는 방법에 대한 예제가 아래에 있음
use aggregator::{Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}
  • aggregator 크레이트에 의존적인 다른 크레이트들 또한 Summary 트레이트를 스코프로 가져와서 자신들의 타입에 대해 Summary를 구현할 수 있습니다.
  • 트레이트 구현에는 한 가지 제약사항이 있는데, 이는
    • 트레이트나 트레이트를 구현할 타입 둘 중 하나는 반드시 자신의 크레이트 것이어야 해당 타입에 대한 트레이트를 구현할 수 있다.
  • 예를 들어, 우리가 만든 aggregator 크레이트의 일부 기능으로 Tweet 타입에 표준 라이브러리 트레이트인 Display 등을 구현할 수 있습니다.
  • Tweet 타입이 우리가 만든 aggregator 크레이트의 타입이기 때문
  • 또한 aggregator 크레이트에서 Vec<T> 타입에 Summary 트레이트를 구현할 수도 있습니다.
  • 마찬가지로 Summary 트레이트가 우리가 만든 aggregator 크레이트의 트레이트가기 때문

  • 하지만 외부 타입에 외부 트레이트를 구현할 수는 없습니다.
    • 예를 들어, 우리가 만든 aggregator 크레이트에서는 Vec<T>에 대한 Display 트레이트를 구현할 수 없습니다.
    • Vec<T>, Display 둘 다 우리가 만든 크레이트가 아닌 표준 라이브러리에 정의되어 있기 때문
  • 이 제약은 프로그램의 특성 중 하나인 일관성 (coherence), 보다 자세히는 고아 규칙 (orphan rule) 에서 나옵니다. (부모 타입이 존재하지 않기 때문에 고아 규칙이라고 부릅니다.)
    • 이 규칙으로 인해 다른 사람의 코드가 여러분의 코드를 망가뜨릴 수 없으며 반대의 경우도 마찬가지
  • 이 규칙이 없다면 두 크레이트가 동일한 타입에 동일한 트레이트를 구현할 수 있게 되고, 러스트는 어떤 구현체를 이용해야 할지 알 수 없게 됩니다.

2.3. 기본 구현

  • 트레이트의 메서드에 기본 동작을 제공할 수도 있습니다.
  • src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}
  • NewsArticle 인스턴스에 기본 구현을 사용하려면, impl Summary for NewsArticle {}처럼 비어있는 impl 블록을 명시합니다.
  • NewsArticle 에 summarize 메서드를 직접적으로 정의하지는 않았지만, NewsArticle은 Summary 트레이트를 구현하도록 지정되어 있으며,
    • Summary 트레이트는 summarize 메서드의 기본 구현을 제공합니다.
  • 기본 구현을 생성한다고 해서 예제 10-13 코드의 Tweet 의 Summary 구현을 변경할 필요는 없습니다.
  • 기본 구현을 오버라이딩하는 문법과 기본 구현이 없는 트레이트 메서드를 구현하는 문법은 동일하기 때문
  • 트레이트는 구현자에게 작은 부분만 구현을 요구하면서 유용한 기능을 많이 제공할 수 있습니다.
pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

2.4. 매개변수로서의 트레이트

  • 트레이트를 이용하여, 어떤 함수가 다양한 타입으로 작동하게 만드는 법
  • impl Trait 문법을 사용
pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}
  • item: Summary trait를 구현한 타입
  • item 매개변수의 구체적 타입을 명시하는 대신 impl 키워드와 트레이트 이름을 명시
  • 이 매개변수에는 `지정된 트레이트를 구현하는 타입이라면 어떤 타입이든 전달받을 수 있습니다.₩
  • notify 본문 내에서는 item에서 summarize와 같은 Summary 트레이트의 모든 메서드를 호출할 수 있음

2.4.1. 트레이트 바운드 문법

  • 참고
fn largest<T>(list: &[T]) -> &T {
  • impl Trait 문법은 간단하지만, 이는 트레이트 바운드 (trait bound) 로 알려진, 좀 더 기다란 형식의 문법 설탕
    • 트레이트 바운드 (trait bound)는 generic에 trait bound를 걸어준다고 보면 됨
pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}
  • 앞서 본 예시와 동일한 코드지만, 더 장황
  • impl Trait 문법이 단순한 상황에서는 편리하고 코드를 더 간결하게 만들어 주는 반면, 트레이트 바운드 문법은 더 복잡한 상황을 표현할 수 있습니다.
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
  • item1 과 item2가 (둘 다 Summary를 구현하는 타입이되) 서로 다른 타입이어도 상관없다면 impl Trait 문법 사용도 적절합니다.
  • 하지만 만약 두 매개변수가 같은 타입으로 강제되어야 한다면, 이는 아래와 같이 트레이트 바운드를 사용해야 합니다:
pub fn notify<T: Summary>(item1: &T, item2: &T) {

2.4.2. + 구문으로 트레이트 바운드를 여럿 지정하기

  • notify의 정의를 할때 item이 Display, Summary trait를 모두 구현해야 하도록 지정해야 함.
pub fn notify(item: &(impl Summary + Display)) {
pub fn notify<T: Summary + Display>(item: &T) {

2.4.3. 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,
{
  • 함수 시그니처를 읽기 쉬워집니다.

2.5. 트레이트를 구현하는 타입을 반환하기

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,
    }
}
  • 구현되는 트레이트로 반환 타입을 명시하는 기능은 13장에서 다룰 클로저 및 반복자의 컨텍스트에서 굉장히 유용합니다.
  • 클로저와 반복자는 컴파일러만 아는 타입이나, 직접 명시하기에는 굉장히 긴 타입을 생성합니다.
  • impl Trait 문법을 사용하면 굉장히 긴 타입을 직접 작성할 필요 없이 Iterator 트레이트를 구현하는 어떤 타입이라고 간결하게 지정할 수 있습니다.

  • 하지만, impl Trait 문법을 쓴다고 해서 다양한 타입을 반환할 수는 없습니다.
  • 다음은 반환형을 impl Summary로 지정하고 NewsArticle, Tweet 중 하나를 반환하는 코드 예시입니다. 이 코드는 컴파일할 수 없습니다:
fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        Tweet {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            retweet: false,
        }
    }
}
  • 함수가 이런 식으로 동작하도록 만드는 방법은 17장의 ‘트레이트 객체를 사용하여 다른 타입의 값 허용하기’절에서 알아볼 예정입니다.

2.6. 트레이트 바운드를 사용해 조건부로 메서드 구현하기

  • 제네릭 타입 매개변수를 사용하는 impl 블록에 트레이트 바운드를 이용하면, 지정된 트레이트를 구현하는 타입에 대해서만 메서드를 구현할 수도 있습니다.
  • impl 블록에서는
    • 어떤 T 타입이 비교를 가능하게 해주는 PartialOrd 트레이트와 출력을 가능하게 만드는 Display 트레이트를 모두 구현한 타입인 경우에 대해서만
    • cmp_display 메서드를 구현하고 있습니다.
  • <T: Display + PartialOrd>: 두 트레이트를 구현한 타입에 대해서만,
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!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}
  • 타입이 특정 트레이트를 구현하는 경우(<T: Display>)에만, 해당 타입(T)에 트레이트(ToString)를 구현할 수도 있습니다.
  • 트레이트 바운드(<T: Display>)를 만족하는 모든 타입에 대해 트레이트를 구현하는 것을 포괄 구현 (blanket implementations) 이라 하며,
    • 이는 러스트 표준 라이브러리 내에서 광범위하게 사용됩니다.
  • 예를 들어, 표준 라이브러리는 Display 트레이트를 구현하는 모든 타입에 ToString 트레이트도 구현합니다.
  • 표준 라이브러리의 impl 블록은 다음과 비슷하게 생겼습니다:
impl<T: Display> ToString for T {
    // --생략--
}
  • <T: Display>: 타입이 특정 트레이트를 구현하는 경우

  • 트레이트와 트레이트 바운드를 사용하면, 제네릭 타입 매개변수로 코드 중복을 제거하면서 특정 동작을 하는 제네릭 타입이 필요하다는 사실을 컴파일러에게 전달할 수 있습니다.
  • 컴파일러는 트레이트 바운드를 이용하여, 코드에 사용된 구체적인 타입들이 올바른 동작을 제공하는지 검사합니다.
  • 동적 타입 언어에서는 해당 타입이 정의하지 않은 메서드를 호출하면 런타임에 에러가 발생합니다.
  • 하지만 러스트는 컴파일 시점에 에러를 발생시켜 코드를 실행하기도 전에 문제를 해결하도록 강제
  • 따라서 런타임에 해당 동작을 구현하는지 검사하는 코드를 작성할 필요가 없습니다.
  • 컴파일 시점에 이미 다 확인했기 때문이죠. 러스트는 제네릭의 유연성과 성능 둘 다 놓치지 않습니다.

3. 라이프타임으로 참조자의 유효성 검증하기

  • 어떤 참조자가 필요한 기간 동안 유효함을 보장하도록 함
  • 러스트의 모든 참조자는 라이프타임(참조자의 유효성을 보장하는 범위)를 갖는다.
  • 대부분의 상황에서 타입이 암묵적으로 추론되듯, 라이프타임도 암묵적으로 추론됩니다.
  • 하지만 여러 타입이 될 가능성이 있는 상황에서는 타입을 명시해 주어야 하듯,
    • 참조자의 수명이 여러 방식으로 서로 연관될 수 있는 경우에는 라이프타임을 명시해 주어야 합니다.
  • 러스트에서 런타임에 사용되는 실제 참조자가 반드시 유효할 것임을 보장하려면, 제네릭 라이프타임 매개변수로 이 관계를 명시해야 합니다.

3.1. 라이프타임으로 댕글링 참조 방지하기

  • 아래 코드는 에러남
fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {}", r);
}
  • Note:
    • 예제는 변수를 초깃값 없이 선언하여, 스코프 밖에 변수명을 위치시킵니다.
    • 널 값을 갖지 않는 러스트가 이런 형태의 코드를 허용하는 게 이상하다고 생각하실 수도 있지만,
      • 만약 값을 넣기 전에 변수를 사용하는 코드를 실제로 작성할 경우에는, 러스트가 컴파일 에러를 발생시킴
    • 널 값이 허용되는 것은 아닙니다.
  • 그렇다면 러스트는 이 코드가 유효한지를 어떻게 검사할까요? 정답은 대여 검사기입니다.

3.2. 대여 검사기 (borrow checker)

  • 아래 코드 에러남.
fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {}", r); //          |
}                         // ---------+
  • 아래 코드 정상적 작동
fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {}", r); //   |       |
                          // --+       |
}                         // ----------+
  • 이제 함수 매개변수와 반환 값에 대한 제네릭 라이프타임을 알아봅시다.

3.3. 함수에서의 제네릭 라이프타임

  • longest 함수가 매개변수의 소유권을 얻지 않도록, 문자열 대신 참조자인 문자열 슬라이스를 전달한다는 점을 주목
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}
  • longest 함수가 매개변수의 소유권을 얻지 않도록, 문자열(String) 대신 참조자인 문자열 슬라이스(&str)를 전달한다는 점을 주목
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
  • error[E0106]: missing lifetime specifier
  • 반환 타입에 제네릭 라이프타임 매개변수가 필요하다는 내용
    • 반환할 참조자가 x인지, y인지 러스트가 알 수 없기 때문
  • 이 함수를 정의하는 시점에서는 함수가 전달받을 구체적인 값을 알 수 없으니, if의 경우가 실행될지 else의 경우가 실행될지 알 수 없습니다.
  • 전달받은 참조자의 구체적인 라이프타임도 알 수 없습니다.
  • 그러니 예제 10-17, 예제 10-18에서처럼 스코프를 살펴보는 것만으로는 반환할 참조자의 유효성을 보장할 수 없습니다.
  • 대여 검사기도 x, y 라이프타임이 반환 값의 라이프타임과 어떤 연관이 있는지 알지 못함
  • 따라서, 참조자 간의 관계를 제네릭 라이프타임 매개변수로 정의하여 대여 검사기가 분석할 수 있도록 해야 합니다.

3.4. 라이프타임 명시 문법

  • 라이프타임을 명시한다고 해서 참조자의 수명이 바뀌진 않습니다.
  • 그보다는 여러 참조자에 대한 수명에 영향을 주지 않으면서, 서로 간 수명의 관계가 어떻게 되는지에 대해 기술하는 것입니다.
  • 함수에 제네릭 라이프타임 매개변수를 명시하면 어떠한 라이프타임을 갖는 참조자라도 전달할 수 있습니다.
  • 라이프타임 매개변수의 이름은 어퍼스트로피(')로 시작해야 하며, 보통은 제네릭 타입처럼 매우 짧은 소문자로 정합니다.
  • 대부분의 사람들은 첫 번째 라이프타임을 명시할 때 'a를 사용합니다.
  • 라이프타임 매개변수는 참조자의 & 뒤에 위치하며, 공백을 한 칸 입력하여 참조자의 타입과 분리합니다.
&i32        // 참조자
&'a i32     // 명시적인 라이프타임이 있는 참조자
&'a mut i32 // 명시적인 라이프타임이 있는 가변 참조자
  • 자신의 라이프타임 명시 하나만 있는 것으로는 큰 의미가 없습니다.
  • 라이프타임 명시는 러스트에게 여러 참조자의 제네릭 라이프타임 매개변수가 서로 어떻게 연관되어 있는지 알려주는 용도이기 때문

3.5. 함수 시그니처에서 라이프타임 명시하기

  • 라이프타임 명시를 함수 시그니처에서 사용하기 위해서, 꺾쇠괄호 안에 제네릭 라이프타임 매개변수를 선언할 필요
  • 두 매개변수의 참조자 모두가 유효한 동안에는 반환된 참조자도 유효할 것이라는 점이지요.
    • 이는 매개변수들과 반환 값 간의 라이프타임 관계
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
  • 이것의 실제 의미는, longest 함수가 반환하는 참조자의 라이프타임은, 함수 인수로서 참조된 값들의 라이프타임 중 작은 것과 동일하다는 의미
  • longest 함수는 x와 y가 얼마나 오래 살지 정확히 알 필요는 없고, 이 시그니처를 만족하는 어떤 스코프를 'a로 대체할 수 있다는 점만 알면 됩니다.
  • 라이프타임 명시는 함수 시그니처의 타입들과 마찬가지로 함수에 대한 계약서의 일부가 됩니다.
  • 'a에 대응되는 구체적인 라이프타임은 x 스코프와 y 스코프가 겹치는 부분입니다.
    • 바꿔 말하면, x 라이프타임과 y 라이프타임 중 더 작은 쪽이 제네릭 라이프타임 'a의 구체적인 라이프타임이 됩니다.
  • 아래 코드는 동작하지만,
fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result);
    }
}
  • 아래처럼 수정한 예제 10-23 코드는 컴파일할 수 없습니다.
fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {}", result);
}
  • 에러는 println! 구문에서 result가 유효하려면 string2 가 바깥쪽 스코프가 끝나기 전까지 유효해야 한다는 내용입니다.

3.6. 라이프타임의 측면에서 생각하기

  • longest 함수를 제일 긴 문자열 슬라이스를 반환하는 게 아니라, 항상 첫 번째 매개변수를 반환하도록 바꾸었다고 가정해 봅시다.
  • 그러면 이제 y 매개변수에는 라이프타임을 지정할 필요가 없습니다. 다음 코드는 정상적으로 컴파일됩니다:
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}
  • y의 라이프타임은 x나 반환 값의 라이프타임과 전혀 관계없으므로, 매개변수 y에는 'a를 지정하지 않았습니다.
  • 참조자를 반환하는 함수를 작성할 때는, 반환 타입의 라이프타임 매개변수가 함수 매개변수 중 하나와 일치해야 합니다.
  • 반환할 참조자가 함수 매개변수중 하나를 참조하지 않을 유일한 가능성은 함수 내부에서 만들어진 값의 참조자를 반환하는 경우입니다.
  • 하지만 이 값은 함수가 끝나는 시점에 스코프를 벗어나므로 댕글링 참조가 될 것입니다.
  • 다음과 같이 longest 함수를 구현하면 컴파일할 수 없습니다:
fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}
  • 반환 타입에 'a를 지정했지만, 반환 값의 라이프타임이 그 어떤 매개변수와도 관련 없으므로 컴파일할 수 없음
  • 여기서 댕글링 참조가 발생하지 않도록 라이프타임 매개변수를 지정할 방법은 없습니다.
  • 그리고 러스트는 댕글링 참조를 생성하는 코드를 눈감아주지 않죠.
  • 이런 상황을 해결하는 가장 좋은 방법은, 참조자 대신 값의 소유권을 갖는 데이터 타입을 반환하여 함수를 호출한 함수 측에서 값을 정리하도록 하는 것입니다.
  • 라이프타임 문법의 근본적인 역할은, 함수의 다양한 매개변수와 반환 값의 라이프타임을 연결하는 것

3.7. 구조체 정의에서 라이프타임 명시하기

  • 여태껏 정의해 본 구조체들은 모두 소유권이 있는 타입을 들고 있었습니다.
  • 구조체가 참조자를 들고 있도록 할 수도 있지만, 이 경우 구조체 정의 내 모든 참조자에 라이프타임을 명시해야합니다.
struct ImportantExcerpt<'a> {
    part: &'a str,
}

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,
    };
}
  • 예제 10-25의 라이프타임 명시는 ImportantExcerpt 인스턴스는 part 필드가 보관하는 참조자의 라이프타임보다 오래 살 수 없다라는 의미
  • novel 데이터는 ImportantExcerpt 인스턴스가 생성되기 전부터 존재하며, ImportantExcerpt 인스턴스가 스코프를 벗어나기 전에는 novel이 스코프를 벗어나지도 않으니, ImportantExcerpt 인스턴스는 유효합니다.

3.8. 라이프타임 생략

  • 모든 참조자는 라이프타임을 가지며, 참조자를 사용하는 함수나 구조체는 라이프타임 매개변수를 명시해야 함을 배웠습니다.
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}
  • 초기 버전(1.0 이전) 러스트에서는 이 코드를 컴파일할 수 없었습니다. 모든 참조자는 명시적인 라이프타임이 필요했었죠.
  • 수많은 러스트 코드를 작성하고 난 후, 러스트 팀은 러스트 프로그래머들이 특정한 상황에서 똑같은 라이프타임 명시를 계속 똑같이 작성하고 있다는 걸 알아냈습니다.
  • 이 상황들은 예측 가능한 상황들이었으며, 몇 가지 결정론적인 (deterministic) 패턴을 따르고 있었습니다.
  • 따라서 러스트 팀은 컴파일러 내에 이 패턴들을 프로그래밍하여, 이러한 상황들에서는 라이프타임을 명시하지 않아도 대여 검사기가 추론할 수 있도록 하였습니다.
  • 러스트의 참조자 분석 기능에 프로그래밍 된 이 패턴들을 라이프타임 생략 규칙 (lifetime elision rules) 이라고 부릅니다.
  • 함수나 메서드 매개변수의 라이프타임은 입력 라이프타임 (input lifetime) 이라 하며, 반환 값의 라이프타임은 출력 라이프타임 (output lifetime)
  • 라이프타임 명시가 없을 때 컴파일러가 참조자의 라이프타임을 알아내는 데 사용하는 규칙은 3 개
  • 이 규칙은 fn 정의는 물론 impl 블록에도 적용됩니다.
  • 1:
    • 컴파일러가 참조자인 매개변수 각각에게 라이프타임 매개변수를 할당한다는 것입니다.
    • fn foo<'a>(x: &'a i32)처럼 매개변수가 하나인 함수는 하나의 라이프타임 매개변수를 갖고,
    • fn foo<'a, 'b>(x: &'a i32, y: &'b i32)처럼 매개변수가 두 개인 함수는 두 개의 개별 라이프타임 매개변수를 갖는 식
  • 2:
    • 만약 입력 라이프타임 매개변수가 딱 하나라면, 해당 라이프타임이 모든 출력 라이프타임에 대입된다는 것
  • 3:
    • 입력 라이프타임 매개변수가 여러 개인데, 그중 하나가 &self나 &mut self라면, 즉 메서드라면 self의 라이프타임이 모든 출력 라이프타임 매개변수에 대입됩니다.

3.9. 메서드 정의에서 라이프타임 명시하기

  • 라이프타임을 갖는 메서드를 구조체에 구현하는 문법은, 제네릭 타입 매개변수 문법과 같습니다.
  • 라이프타임 매개변수의 선언 및 사용 위치는, 구조체 필드나 메서드 매개변수 및 반환 값과 연관이 있느냐 없느냐에 따라 달라집니다.
  • 라이프타임이 구조체 타입의 일부가 되기 때문에,
    • 구조체 필드의 라이프타임 이름은
      • impl 키워드 뒤에 선언한 다음 구조체 이름 뒤에 사용해야 합니다.
  • impl 블록 안에 있는 메서드 시그니처의 참조자들은
    • 구조체 필드에 있는 참조자의 라이프타임과 관련되어 있을 수도 있고, 독립적일 수도 있습니다.
  • 또한 라이프타임 생략 규칙으로 인해 메서드 시그니처에 라이프타임을 명시하지 않아도 되는 경우도 있습니다.
  • 예제 10-24의 ImportantExcerpt 구조체로 예시를 들어보겠습니다.
impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}
  • 첫 번째 생략 규칙으로 인해 self 참조자의 라이프타임을 명시할 필요는 없습니다.
impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}
  • 두 개의 입력 라이프타임이 있으니, 러스트는 첫 번째 라이프타임 생략 규칙대로 &self, announcement에 각각의 라이프타임을 부여합니다.
  • 그다음, 매개변수 중 하나가 &self이니 반환 타입에 &self의 라이프타임을 부여합니다.
  • 이제 모든 라이프타임이 추론되었네요.

3.10. 정적 라이프타임

  • 'static 라이프타임은 해당 참조자가 프로그램의 전체 생애주기 동안 살아있음을 의미
let s: &'static str = "I have a static lifetime.";
  • 이 문자열의 텍스트는 프로그램의 바이너리 내에 직접 저장되기 때문에 언제나 이용할 수 있습니다.
  • 따라서 모든 문자열 리터럴의 라이프타임은 'static
  • 'static 라이프타임을 이용하라는 제안이 담긴 에러 메시지를 보시게 될 수도 있습니다.
    • 하지만 어떤 참조자를 'static으로 지정하기 전에
    • 해당 참조자가 반드시 프로그램의 전체 라이프타임동안 유지되어야만 하는 참조자인지,
    • 그리고 그것이 진정 원하는 것인지 고민해 보라고 당부하고 싶습니다.
  • 'static 라이프타임을 제안하는 에러 메시지는
    • 대부분의 경우 댕글링 참조를 만들다가 발생하거나,
    • 사용 가능한 라이프타임이 잘못 짝지어져서 발생합니다.
  • 이러한 경우 바람직한 해결책은 그런 문제를 고치는 것이지, 'static 라이프타임이 아닙니다.

3.11. 제네릭 타입 매개변수, 트레이트 바운드, 라이프타임을 한 곳에 사용해 보기

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!("Announcement! {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
profile
새로운 것이 들어오면 이미 있는 것과 충돌을 시도하라.

0개의 댓글