Post

[RUST] 러스트 프로그래밍 공식 가이드(제2판) 10장 요약

제네릭 타입, 트레이트, 라이프타임

모든 프로그래밍 언어는 중복되는 개념을 효율적으로 처리하기 위한 도구를 가지고 있습니다. 러스트에서는 제네릭(generic) 이 그 역할을 맡습니다.

  • 제네릭은 여러 가지 타입을 나타내는 자리표시자의 위치에 특정 타입을 집어넣는 것으로 코드 중복을 제거할 수 있게 해줌
  • 함수 본문이 특정한 값 대신 추상화된 타입으로 작동

함수로 추출하여 중복 없애기

  1. 중복된 코드를 식별
  2. 중복된 코드를 함수의 본문으로 분리하고, 함수의 시그니처 내에 해당 코드의 입력값 및 반환값을 명시
  3. 중복됐었던 두 지점의 코드를 함수 호출로 변경

제네릭 데이터 타입

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

제네릭 함수 정의

  • 함수 시그니처 내 매개변수와 반환값의 데이터 타입 위치에 제네릭을 사용하여 제네릭 함수를 정의
    • 함수를 호출하는 쪽에서 더 많은 기능을 사용할 수 있도록 하며 중복을 방지
    • 러스트는 타입 이름을 지어줄 때 UpperCamelCase 를 따르고, 타입 매개 변수 이름은 짧게 짓는 것이 관례 (대부분 ‘type’을 줄인 T 를 사용)
    • 매개변수를 사용하려면 함수 시그니처에 매개변수의 이름을 선언하여 컴파일러에 해당 이름이 무엇을 의미하는지 알려주어야 하는 것 처럼, 타입 매개변수를 사용하기 전에도 타입 매개변수의 이름을 선언해야 함 (예: fn 함수명<T>(매개변수명: &[T]) -> &T {})
      • 러스트 컴파일러는 이 정의를 ‘{함수명} 함수는 어떤 타입 T에 대한 제네릭 함수’라고 함

제네릭 구조체 정의

1
2
3
4
5
6
7
8
9
struct Point<T> {
  x: T,
  y: T,
}

fn main() {
  let integer = Point { x: 5, y: 18 };
  let float = Point { x: 1.0, y: 4.0 };
}
  • 구조체명 바로 뒤 부등호 기호(<>)에 타입 매개변수 이름을 선언
  • 구조체 정의 내 구체적 데이터 타입을 지정하던 곳에 제네릭 타입 사용
  • 같은 T 타입의 다른 필드에 각각의 다른 타입의 값을 넣어주게 되면 컴파일러는 타입 불일치 에러를 발생시킴
  • 각각의 필드가 다른 타입일 수 있도록 정의하고 싶다면 여러 개의 제네릭 타입 매개변수를 사용해야 함
  • 만약 코드에서 많은 수의 제네릭 타입이 필요하다면 코드를 리팩터링해서 작은 부분들로 나누는 것을 고려

제네릭 열거형 정의

1
2
3
4
5
6
7
8
9
enum Option<T> {
  Some(T),
  None
}

enum Result<T,E> {
  Ok(T),
  Err(E),
}
  • 열거형도 배리언트에 제네릭 데이터 타입을 갖도록 정의할 수 있음 (예: Option<T>, Result<T,E>)
    • Option 열거형은 제네릭으로 되어 있는 덕분에 옵션값이 어떤 타입이건 상관없이 추상화하여 사용할 수 있음
    • Result 열거형은 T,E 두 타입을 이용한 제네릭으로, T 타입을 갖는 OkE 타입을 갖는 Err가 있음

제네릭 메서드 정의

  • 구조체나 열거형 메서드를 구현할 때와 같이 impl 바로 뒤에 T를 선언하여 Point<T> 와 같이 타입에 메서드를 구현한다고 명시 (impl<T> Point<T> {})
  • 타입의 메서드를 정의할때 제네릭 타입에 대한 제약을 지정할 수 있음
    • impl Point<f32> 와 같이 impl 뒤에는 어떤 타입도 선언하지 않고 Point<f32> 인스턴스에 대한 메서드만을 정의
  • 구조체 정의에서 사용한 제네릭 타입 매개변수와, 구조체의 메서드 시그니처 내에서 사용하는 제네릭 타입 매개변수가 항상 같은 것은 아님
    • 1
      2
      3
      
      impl<T> Point<T> { // 구조체 정의에서의 제네릭 : T
          fn mixup<U>(self, other: Point<U>) -> Point<(T, U)> {} //메서드 시그니처에서 사용하는 제네릭 : U
      }
      

제네릭 코드의 성능

  • 제네릭 타입의 사용은 구체적인 타입을 사용했을 떄와 비교해서 전혀 느려지지 않음
  • 러스트는 컴파일 타임에 제네릭을 사용하는 코드를 단형성화(monomorphization)함
    • 단형성화란?
      • 제네릭 코드를 실제 구체 타입으로 채워진 특정한 코드로 바꾸는 과정을 말함
      • 컴파일러는 제네릭 코드가 호출된 곳을 전부 찾고, 제네릭 코드가 호출할 때 사용된 구체 타입으로 코드를 생성

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

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

트레이트 정의하기

1
2
3
pub trait Summary {
    fn summarize(&self) -> String;
}
  • 트레이트의 정의는 메서드 시그니처를 그룹화하여 특정 목적을 달성하는데 필요한 일련의 동작을 정의하는 것
  • trait 키워드 다음 드레이트의 이름을 작성하여 선언
  • 중괄호 안에는 이 트레이트를 구현할 타입의 동작을 묘사하는 메서드 시그니처를 선언
  • 메서드 시그니처는 한줄에 하나씩 나열되며, 각 줄은 세미콜론으로 끝남
  • 컴파일러는 구현한 트레이트가 있는 모든 타입에 정확히 이와 같은 시그니처의 메서드를 가지고 있도록 강제

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

  • impl뒤에 구현하고자 하는 트레이트 이름을 적고, for 키워드와 트레이트를 구현할 타입을 명시 (impl 트레이트명 for 타입 {...})
  • impl블록 안에는 트레이트 정의에서 정의된 메서드 시그니처를 집어넣되, trait 정의에서 처럼 세미콜론을 을 사용하지 않고 중괄호를 사용하여 메서드 본문에 원하는 특정한 동작(즉, 실제 구현)을 채워넣음
  • 어떠한 타입이 트레이트를 구현하면 보통의 메서드를 호출하는 것과 같은 방식으로 트레이트 메서드를 호출할 수 있음
  • 트레잇 메서드를 사용하려면 크레이트 사용자가 타입뿐만 아니라 트레이트도 스코프로 가져와야 함
    • 트레잇이 스코프에 있어야 Rust 컴파일러가 해당 타입이 이 트레잇을 구현했다는 것을 파악하여 메서드를 찾을 수 있음
  • 트레이트 구현의 제약
    • 트레이트나 트레이트를 구현할 타입 둘 중 하나는 반드시 자신의 크레이트의 것이어야 해당 타입에 대한 트레이트를 구현할 수 있음 (고아 규칙)
    • 위 제약은 프로그램의 특성 중 하나인 일관성, 자세히는 고아 규칙에서 나옴
      • 고아 규칙 : 부모타입(연결 고리)이 존재하지 않아 고아 라 부름
    • 이 규칙으로 인해 다른 사람의 코드가 우리의 코드를 망가뜨릴 수 없으며, 우리도 다른사람의 코드를 망가뜨릴 수 없음
  • 트레이트 구현 제약의 이유
    • 충돌 방지
      • 만약 두 개의 다른 크레이트가 같은 외부 타입(예: Vec<T>)에 같은 외부 트레잇(예: Display)을 각각 구현할 수 있다면
        • 어떤 impl을 써야 할지(어떤 구현체가 우선인지) 컴파일러가 결정할 수 없음
        • 서로 다른 크레이트가 서로의 동작을 깨뜨릴 수 있음
        • 예시
          1
          2
          3
          4
          
            // 크레이트 A
            impl std::fmt::Display for Vec<i32> { /* A 방식 */ }
            // 크레이트 B
            impl std::fmt::Display for Vec<i32> { /* B 방식 */ }
          
          • 내 프로젝트에서 use crate_a::*;use crate_b::*;를 같이 쓰게 되면 Vec<i32>에 대해 어떤 Display 구현을 사용해야할 지 컴파일러가 알 수 없음 (모호성)
    • 일관성
      • 모든 트레잇 해석이 전역적으로 단일하게 결정되어야 함 (고아 규칙이 이를 보장해줌)

기본 구현

  • 타입이 트레이트를 구현할 때 마다 모든 메서드를 구현할 필요는 없도록 트레이트의 메서드에 기본 동작을 제공할 수 있음 (기본 동작을 유지할지 오버라이딩 할지 선택할 수 있음)
    1
    2
    3
    4
    5
    
      pub trait Summary {
          fn summarize(&self) -> String {
              String::from("(Read more...)")
          }
      }
    
    • 트레이이트 정의 시 시그니처만 정의 후 ;로 마무리 하는 것이 아닌 {}로 구현부 작성
    • 기본 구현을 오버라이딩 하는 문법과 구현이 없는 트레이트 메서드를 구현하는 문법은 동일
    • 기본 구현 안쪽에서 트레이트의 다른 메서드를 호출할 수 있음 (호출할 다른 메서드가 기본 구현을 제공하지 않는 메서드인지는 상관없음)

매개변수로서의 트레이트

1
2
3
pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}
  • 매개변수의 타입을 명시할 때 impl 키워드와 트레이트 이름을 명시
  • 매개변수에는 지정된 트레이트를 구현하는 타입이라면 어떤 타입이든 전달받을 수 있음
  • 트레이트를 구현하지 않은 타입으로 함수를 호출하는 코드를 작성한다면 컴파일 에러 발생

트레이트 바운드 문법

1
2
3
pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}
  • 트레이트 바운드는 부등호 기호 안의 제네릭 타입 매개변수 선언에 붙은 콜론(:) 뒤에 위치함
  • 제네릭 타입에 “이 타입은 반드시 특정 트레잇을 구현해야 한다”라고 제한을 거는 것을 말함

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

1
2
3
4
// Display 와 Summary를 모두 구현해야 하도록 지정하는 방법

pub fn notify(item: &(impl Summary + Display)) {}
pub fn notify<T: Summary + Display>(item: &T) {}
  • 트레이트 바운드를 + 구문을 통해 여러 개 지정될 수 있음

where 절로 트레이트 바운드 정리하기

1
2
3
4
5
fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{}
  • 트레이트 바운드가 너무 많아지게 되면 가독성을 해치기 때문에 트레이트 바운드를 함수 시그니처 뒤의 where 절에 명시하는 대안을 제공

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

1
2
3
fn returns_summarizable() -> impl Summary {
    Tweet {...}
}
  • 반환 타입에 구체적인 타입명이 아닌 impl Trait 을 작성하여 트레이트를 구현하는 타입을 반환한다고 명시할 수 있음
  • 어떤 구체적인 타입을 반환하는지는 숨기지만, 최소한 해당 Trait을 구현한 타입임을 보장
  • 단, impl Trait 문법을 사용한다고 해서 트레잇을 구현한 다양한 타입중 하나를 반환하는 행위는 할 수 없음 (컴파일러 내에 구현된 방식으로 인한 제약 때문)

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

  • 제네릭 타입 매개변수를 사용하는 impl 블록에 트레이트 바운드를 이용하면, 지정된 트레이트를 구현하는 타입에 대해서만 메서드를 구현할 수도 있음
  • 트레이트 바운드를 만족하는 모든 타입에 대해 트레이트를 구현하는 것을 포괄 구현(blanket implementation) 이라 함
    • Display 트레이트가 구현된 모든 타입에서 ToString트레이트에 정의된 to_string() 메서드를 호출할 수 있는 건 표준 라이브러리의 이 포괄 구현 때문
      1
      
        impl<T: Display> ToString for T {...}
      
    • 포괄 구현은 트레이트 문서마다 하단에 있는 구현자(implementors) 섹션을 참고하면 됨
  • 트레이트와 트레이트 바운드를 사용하면 제네릭 타입 매개변수로 코드 중복을 제거하면서 특정 동작을 하는 제네릭 타입이 필요하다는 사실을 컴파일러에게 전달할 수 있음
  • 컴파일러는 트레이트 바운드를 이용하여 코드에 사용된 구체적인 타입들이 필요한 메서드나 기능을 제대로 구현하고 있는지 검사

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

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

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

  • 바깥쪽 스코프의 변수에 안쪽 스코프의 변수의 참조자를 대입한 경우 “충분히 오래 살지 못했습니다”와 같은 에러를 띄우며 컴파일 되지 않음
  • 라이프타임은 “참조자가 가리키는 데이터가 유효한 동안만 참조자가 존재할 수 있다 (댕글링 참조 방지)” 는 규칙을 컴파일러가 강제하는 장치

대여 검사기

1
2
3
4
5
6
7
8
9
{
    let r;         // --------+-- 'a
    {              //         |
        let x = 5; // -+-- 'b |
        r = &x;    //  |      |
    }              // -+      |
    println!("r: {}", r); //  |
                   // --------+
}
  • 러스트 컴파일러는 대여 검사기로 스코프를 비교하여 대여의 유효성을 판단
  • 러스트는 컴파일 타임에 두 라이프타임의 크기를 비교하고, 참조 대상이 참조자보다 오래 살지 못할 경우 러스트 컴파일러는 이 프로그램을 컴파일하지 않음

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

1
2
3
4
5
6
7
8
9
// 컴파일 에러 발생 ( 반환 타입에 제네릭 라이프타임 매개변수가 필요하다는 내용 )
// 해결 방법 : x와 y 둘 중 어느 것을 반환하든, 반환값의 라이프타임은 입력 참조자 중 더 짧은 쪽과 같다는 걸 알려줘야 함
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
  • Rust의 빌림 검사기는 반환되는 참조자의 라이프타임이 어느 입력값과 연관되는지 알아야만 안전성을 보장할 수 있음
  • 참조자 간의 관계를 제네릭 라이프타임 매개변수로 정의하여 대여 검사기가 분석할 수 있도록 해야 함

라이프타임 명시 문법

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

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

1
2
3
4
// 시그니처 내의 모든 참조자가 동일한 라이프타임 'a를 가져야함을 나타낸 함수 정의
// 이 함수 시그니처는 러스트에게, 함수는 두 매개변수를 갖고 둘 다 적어도 라이프타임 'a 만큼 살아 있는 문자열 슬라이스이며,
// 반환하는 문자열 슬라이스도 라이프타임 'a 만큼 살아있다는 정보를 알려줌
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {...}
  • 함수명과 매개변수 목록 사이의 부등호 기호 안에 제네릭 라이프타임 매개변수를 선언
  • 어떠한 함수가 반환하는 참조자의 라이프타임은 함수 인수로서 참조된 값들의 라이프타임 중 작은것과 동일하다는 것을 알려줌
  • 어떤 값이 제약 조건을 지키지 않았을 때 대여 검사기가 불합격 판정을 내릴 수 있도록 명시하는 것

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

  • 라이프 타임은 참조자끼리의 관계를 명확히 해줘서 함수가 안전하게 어떤 참조를 반환할 수 있는지(또는 없는지)를 컴파일러가 알 수 있게 해줌
  • 라이프타임 파라미터 필요 여부
    • 반환 값이 특정 인자를 그대로 참조한다면 그 인자의 라이프타임만 지정하면 됨 (관련 없는 인자는 라이프타임 지정할 필요 없음)
  • 함수 내에서 새로 만든 값 반환 불가
    • 함수 안에서 생성한 지역 변수의 참조자를 반환하려 하는 경우 함수 종료와 함께 스코프를 벗어나므로 댕글링 참조자가 됨

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

1
2
3
struct ImportantExcerpt<'a> {
    part: &'a str,
}
  • 구조체가 참조자를 들고있으려면 구조체 정의 내 모든 참조자에 라이프타임을 명시해야 함
  • 구조체의 제네릭 라이프타임 매개변수의 선언방법
    • 제네릭 라이프타임 매개변수의 이름을 구조체명 뒤의 부등호 기호 내에 선언하고 구조체 정의 본문에서 라이프타임 매개변수를 이용
  • 이러한 라이프 타임의 명시는 “구조체의 인스턴스는 참조자 필드가 보관하는 참조자의 라이프타임보다 오래살 수 없다”는 의미

라이프타임 생략

  • 러스트 컴파일러의 참조자 분석 기능에 프로그래밍된 여러 패턴들을 라이프타임 생략 규칙 이라고 부름
    • 라이프타임 생략 규칙에 해당될 경우 라이프타임을 명시하지 않아도 컴파일러가 자동으로 추론해줌
    • 위 규칙을 적용했지만 라이프타임이 모호한 참조자가 있다면, 컴파일러는 이 참조자의 라이프 타임을 추측하지 않고 에러를 발생시킴 (완전한 추론 기능을 제공하는 것은 아님)
  • 라이프타임 명시가 없을 때 컴파일러가 참조자의 라이프타임을 알아내는 3가지 규칙
    1
    2
    
      입력 라이프타임 : 함수나 메서드 매개변수의 라이프타임
      출력 라이프타임 : 반환값의 라이프타임
    
    1. 컴파일러가 참조자인 매개변수 각각에게 라이프타임 매개변수를 할당
    2. 입력 라이프타임 매개변수가 딱 하나라면, 해당 라이프타임이 모든 출력 라이프타임에 대입됨
    3. 입력 라이프타임 매개변수가 여러 개인데,그 중 하나가 &self&mut self라면, 즉 메서드라면 self의 라이프타임이 모든 출력 라이프타임 매개변수에 대입됨
      • 위 세 가지 규칙을 모두 적용했음에도 라이프타임을 알 수 없는 참조자가 있다면 컴파일러는 에러와 함께 작동을 멈춤

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

1
impl<'a> ImportantExcerpt<'a> {...}
  • 라이프타임이 구조체 타입의 일부가 되기 떄문에, 구조체 필드의 라이프타임 이름은 impl 키워드 뒤에 선언한 다음 구조체명 뒤에 사용해야 함
  • impl 블록 안에 있는 메서드 시그니처의 참조자들은 구조체 필드에 있는 참조자의 라이프타임과 관련되 있을 수도 있고, 독립적일 수도 있음
  • 라이프타임 생략 규칙으로 인해 메서드 시그니처에 라이프타임을 명시하지 않아도 되는 경우가 있음

정적 라이프타임

  • 정적 라이프타임,즉 'static 라이프타임은 해당 참조자가 프로그램의 전체 생애주기 동안 살아 있음을 의미
  • 모든 문자열 리터럴은 'static 라이프 타임을 가지며, let s: &'static str = "I have a static lifetime."; 과 같이 명시
    • 문자열의 텍스트는 프로그램의 바이너리 내에 직접 저장되기 때문에 언제나 이용할 수 있음(따라서 라이프타임은 'static)
This post is licensed under CC BY 4.0 by the author.