[RUST] 러스트 프로그래밍 공식 가이드(제2판) 10장 요약
제네릭 타입, 트레이트, 라이프타임
모든 프로그래밍 언어는 중복되는 개념을 효율적으로 처리하기 위한 도구를 가지고 있습니다. 러스트에서는 제네릭(generic) 이 그 역할을 맡습니다.
- 제네릭은 여러 가지 타입을 나타내는 자리표시자의 위치에 특정 타입을 집어넣는 것으로 코드 중복을 제거할 수 있게 해줌
- 함수 본문이 특정한 값 대신 추상화된 타입으로 작동
함수로 추출하여 중복 없애기
- 중복된 코드를 식별
- 중복된 코드를 함수의 본문으로 분리하고, 함수의 시그니처 내에 해당 코드의 입력값 및 반환값을 명시
- 중복됐었던 두 지점의 코드를 함수 호출로 변경
제네릭 데이터 타입
- 제네릭을 사용하면 함수 시그니처나 구조체의 아이템에 다양한 구체적 데이터 타입을 사용할 수 있도록 정의할 수 있음
제네릭 함수 정의
- 함수 시그니처 내 매개변수와 반환값의 데이터 타입 위치에 제네릭을 사용하여 제네릭 함수를 정의
- 함수를 호출하는 쪽에서 더 많은 기능을 사용할 수 있도록 하며 중복을 방지
- 러스트는 타입 이름을 지어줄 때 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
타입을 갖는Ok
와E
타입을 갖는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) 섹션을 참고하면 됨
- Display 트레이트가 구현된 모든 타입에서
- 트레이트와 트레이트 바운드를 사용하면 제네릭 타입 매개변수로 코드 중복을 제거하면서 특정 동작을 하는 제네릭 타입이 필요하다는 사실을 컴파일러에게 전달할 수 있음
- 컴파일러는 트레이트 바운드를 이용하여 코드에 사용된 구체적인 타입들이 필요한 메서드나 기능을 제대로 구현하고 있는지 검사
라이프타임으로 참조자의 유효성 검증하기
- 라이프 타임은 어떤 참조자가 필요한 기간 동안 유효함을 보장하도록 함
- 러스트의 모든 참조자는 라이프타임(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
입력 라이프타임 : 함수나 메서드 매개변수의 라이프타임 출력 라이프타임 : 반환값의 라이프타임
- 컴파일러가 참조자인 매개변수 각각에게 라이프타임 매개변수를 할당
- 입력 라이프타임 매개변수가 딱 하나라면, 해당 라이프타임이 모든 출력 라이프타임에 대입됨
- 입력 라이프타임 매개변수가 여러 개인데,그 중 하나가
&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.