비욘드 JS: 러스트 - Generics

dante Yoon·2022년 12월 28일
0

beyond js

목록 보기
10/20
post-thumbnail

글을 시작하며

안녕하세요, 단테입니다.
벌써 단테의 비욘드 JS 10번째 강의입니다.

여기까지 오시느라 수고 너무 많으셨습니다.
절반을 훌쩍 넘게 되었습니다.

시작이 반이라구요? 아닙니다.

어디 감히 시작을 진정 강의의 반을 넘은 여러분과 비교하십니까
여러분이 최고입니다.

이제 단테와 함께하는 러스트 제너릭, 시작해보겠습니다.

제너릭은 친숙한 개념입니다.

자바, 타입스크립트등 정적 타입을 제공하는 언어에서 제너릭은 매우 익숙한 개념입니다.

제너릭은 타입을 구체적인 특정 타입으로 정의하는 것이 아닌 일반화할 수 있게 도와주는 타입입니다.
데이터구조나 함수를 작성함에 있어 실제 해당 데이터구조, 함수가 구현되거나 호출되는 부분에서 타입을 지정할 수 있으며, 이때 선언되는 제너릭의 위치에 따라 함수의 경우 argument와 리턴 타입이 긴밀하게 연결되는 등의 유연하면서도 관계를 나타내는데 유용한 타입 정보를 제공합니다.

제너릭을 사용하는 법은 간단합니다.

fn add<T>(x: T, y:T) -> T {
  x + y
}

함수 add는 제너릭 타입 T를 가지고 있습니다. add함수 호출에 앞서 제너릭 T에 specific한 타입을 명시하게되면 이 함수의x,y,그리고 반환 타입이 정해지는 것입니다. expression을 이용한 패턴 바인딩 예제 코드를 보겠습니다.

let result = add<i32>(5, 10);

다음 처럼 여러 개의 제너릭을 사용할 수 있습니다.

fn zip<T, U>(x: T, y: U) -> (T,U) {
  (x,y)
}

함수 zip은 TU의 두 개의 제너릭을 사용하고 있습니다.

trait과 제너릭

이전의 강의에서 다룬 주제이지만, 러스트에서 trait은 type이 가질 수 있는 행위에 대해 정의하는 것입니다. 이 행동은 메소드를 통해서 정의됩니다.

제너릭을 활용해 trait을 구현해보겠습니다.

trait Add {
  fn add (&self, other: &Self) -> Self;
}

fn add_generic<T: Add>(x: T, y:T) -> T {
  x.add(y)
}

impl Add for i32 {
  fn add(&self, other: &Self) -> Self {
    self + other
  }
}

let x = 1; 
let y = 2;
let sum = add_generic(x, y);
println!("{}", sum); // Output: 3

위의 예제에서 Add trait은 add 메소드를 이용해 정의되었습니다. add 메소드는 특정 동일 타입의 값을 argument로 받아들여 동일한 타입을 반환합니다. i32 타입은 Add trait을 구현하며 add 메소드를 실제 구현하고 있습니다.

add_generic 함수는 Add 을 이용해 제너릭 타입 T를 제한할 수 있습니다. 무슨 말이냐면
T 제너릭은 Add trait의 구현체의 타입이라는 것으로 제한할 수 있다는 것입니다. add_generic함수는 T 타입의 두 가지 값을 인수로 받아들여 add 메소드를 호출해야 합니다.

두 i32 값을 사용해 add_generic이 호출될 때 i32 타입은 T에대한 구체적인 타입으로 사용됩니다.
그리고. i32의 add 메소드가 제너릭 T 타입의 메소드로 다른 제너릭 타입 T와 함께 연산에 사용됩니다.

중복 내용을 없애는 함수

제너릭이 함수 선언에 있어 얼마나 유연한 구현을 할 수 있게 도와주는지 봅시다.

제너릭이 없이 여러 타입에 대한 중복 구현을 해야 하는 경우

fn add_i32(x: i32, y: i32) -> i32 {
  x + y
}

fn add_f32(x: f32, y: f32) -> f32 {
  x + y
}

위의 예제에서 i32, f32 타입에 대한 연산을 하기 위해 두 가지의 동일 기능을 하는 함수를 반복해 작성했습니다.

제너릭을 사용해 단 하나의 함수만 정의하는 경우

fn add_generic<T>(x: T, y: T) -> T
where 
  T: std::ops::Add<Output = T>,
{
   x + y
}


let x = 1;
let y = 2;
let sum = add_generic(x,y);

println!("{}",sum); // output 3

let x = 1.0;
let y = 2.0;
let sum = add_generic(x,y);
println!("{}", sum); // output 3.0

위의 add_generic 함수는 type 파라메터 T를 사용해 앞서 먼저 봤던 두가지의 타입이 들어올 수 있는 경우를 모두 포함합니다. T 타입 파라메터가 std::ops::Add trait 으로 제한되었기 때문에 T에 대한 타입 구현이 Add trait으로 제한됩니다.

제너릭을 활용한다면 다음처럼 어떤 타입에 대해서도 잘 작동하는 Add trait의 구현체를 정의할 수 있습니다. 이는 코드의 중복성을 제거한다는 측면에서 리펙토링이 완성된 이후의 형태를 띄는 구현이라고도 할 수 있습니다.

함수 중복 제거 예시

벡터 엘리먼트 중 가장 큰 숫자와 큰 글자를 구하는 함수를 작성해보겠습니다.

fn largest_i32(list: &[i32]) -> &i32 {
   let mut largest = &list[0];

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

   largest
}

fn largest_char(list: &[char]) -> &char {
   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_i32(&number_list);
   println!("The largest number is {}", result);

   let char_list = vec!['y', 'm', 'a', 'q'];
   
   let result = largest_char(&char_list);

   println!("The largest char is  {}", result);
}

largest_i32, largest_char을 모두 표현할 수 있는 함수를 제너릭으로 만들어봅시다.

fn largest<T>(list: &[T]) -> &T {
  let mut largest = &list[0];
  
  for item in list {
    if item > largest { // <- 에러 발생 
    	largest = item;
    } 
    
  largest
}

제너릭을 사용해 위 코드를 작성했지만 아래처럼 에러가 발생합니다.

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` due to previous error

여기서 타입 T가 ordered 비교가 가능하지 않을 수도 있기 떄문에 std::cmp::PartialOrd trait 타입이 아닐 수 있다는 에러 문구가 발생했습니다. (trait은 곧 알아보겠습니다.)

도움말을 보면 onsider restricting type parameter 'T' 을 표시하는데요,
T 타입에 들어갈 수 있는 타입은 PartialOrd 를 구현한 타입만 가능한 것은 알 수 있습니다.

Struct에 generic 사용하기

다음 처럼 Point struct를 정의할 때 memeber 타입 정의에 제너릭 T를 사용할 수 있습니다.
Point<T>의 정의에 따르면 x,y는 동일 타입이어야 하네요.

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, 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에 generic 사용하기

enum Option, Result 정의에 제너릭을 사용했습니다.

enum Option <T> {
    Some(T),
    None,
}

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

메소드 정의 generic

struct의 내부 메소드 정의에 제너릭을 사용하는 사례를 보겠습니다.

Point<T> struct의 메소드 x에 제너릭을 사용했습니다.

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());
}

struct를 구현할 때는 impl 바로 뒤에 제너릭을 붙여써야 합니다. 이렇게 작성해야 러스트가 Point의 angle bracket <> 내부에 있는 T가 특정 타입이 아닌 제너릭 타입으로 사용되었음을 이해할 수 있습니다.

이때 Point struct를 사용했던 T 와 다른 이름을 사용해도 되지만 관행상 동일 이름을 사용하는 경우가 많습니다.

제너릭을 활용한 특정 타입에만 유효한 메소드 선언

흥미로운 주제입니다.
아래 코드에서 impl 뒤에 제너릭을 붙이지 않고 Point<f32> 처럼 Point<T>가 아닌 f32 타입에 대해서만 유효한 메소드 fn distance_from_origin을 구현했습니다.

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

struct 구현할 때 선언한 제너릭과 struct 내부 멤버인 메소드에 선언한 메소드의 제너릭은 독립적으로 선언할 수도 있습니다. 다음 코드를 보면 struct 구현에 사용한 제너릭과 메소드 시그니처에 사용한 제너릭이 다름을 알 수 있습니다.

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)
}

IDE에서 보면 아래와 같습니다.

제너릭을 사용하면 퍼포먼스에 어떤 영향을 미칠까

제너릭 파라메터는 런타임에 결정되기 때문에 퍼포먼스에 영향을 미칠까봐 걱정이 될 수 있습니다.
제너릭을 사용하는 것은 구체적인 특정 타입을 사용하는것과 비교해 아무런 영향을 미치지 않습니다.

그 이유는 러스트가 컴파일에 Monomorphization 를 수행하기 때문입니다.

모노모파이제이션, 모노모파이제이션.. 발음이 어렵네요.

모노모파이제이션은 컴파일 타임에 제너릭을 특정 타입으로 변환하는 작업을 말합니다. 이 프로세스를 거침으로 인해서 컴파일러는 모든 제너릭 코드를 살펴보고 이 제너릭 타입을 특정 타입으로 치환합니다.

아래 코드는 standard library 제너릭인 Option<T> enum인데요

let integer = Some(5);
let float = Some(5.0);

러스트가 이 코드를 컴파일 할 때 모노모파이제이션을 합니다. 이 프로세스 동안에 컴파일러는 Option<T>에 사용된 값을 읽고 i32와 f64가 사용되었음을 파악합니다.

모노모파이제이션을 수행한 코드는 마치 아래 코드와 유사합니다.

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);
}

위에서 Option<T>는 컴파일러에 의해 두 인스턴스의 타입을 구체적인 타입으로 치환했습니다. 따라서 이러한 과정 때문에 제너릭을 사용한다고 해서 런타임에 악영향을 미치지는 않습니다.

Traits

Trait은 다른 언어의 인터페이스와 유사하며 특정 타입의 역할을 추상화하여 다른 타입들에게 공유하는 기능을 제공합니다.

한 타입의 역할은 해당 타입으로 호출할 수 있는 메소드와 연관되어 있습니다. 이 메소드는 곧 이 타입의 행동인 것인데요, 타입 A,B,C,D가 특정 메소드를 공유한다면 이 4개의 타입은 모두 동일한 행동을 할 수 있고 특정 역할을 수행한다고 말할 수 있습니다.

중요합니다. 다시 한번 읽어보세요.

여기서 A,B,C,D는 trait가 될 수 있습니다. 그리고 멤버로 텍스트를 들고있다고 합시다.

이제 예시를 보겠습니다. NewsArticle 스트럭트는 여러 개의 뉴스기사를 가지고 있고 Tweet스트럭트는 여러 개의 뉴스피드를 가지고 있습니다.

예시 1

미디어 집계 라이브러리 agreegator trait는 NewsArticle, Tweet 인스턴스의 데이터를 요약해서 보여주는 역할을 해야합니다. 이를 위해서 우리는 각 타입의 요약 데이터가 필요하며 이 데이터는 summarize 메소드를 호출해서 얻을 수 있습니다.

다음과 같이 Summary trait를 선언했습니다. trait 키워드를 사용한게 보이시죠?

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

summarize의 메소드 시그니처는 fn summarize(&self) -> String 입니다.
위에서 summarize 하나의 메소드만 trait에 선언했지만 여러 개를 선언해도 무방합니다.

이제 여러 타입에 대한 Trait를 구현해봅시다.

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

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)
    }
}


fn main() {
    
}

코드를 같이 작성하시면서 따라하시면 더 좋습니다.

한 타입에 대한 trait을 구현하는 것은 평범한 메소드를 구현하는 것과 유사합니다. 차이점은 impl 다음에 trait의 이름을 작성했다는 것입니다. for 키워드는 trait가 구현될 대상의 type을 지정하기 위해 사용합니다.

이제 구현한 Summary trait을 두 인스턴스에서 사용해볼까요?

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());
}

Tweet trait를 인스턴스로 만들고 summarize를 호출했습니다. 이 때 summarize 메소드는 Summary trait에서 만들어진 것입니다.

default implementation

trait의 메소드를 기본 구현해둘 수 있습니다.이 경우 해당 trait를 구현하는 모든 타입의 trait에서 의무적으로 trait의 메소드를 구현하지 않아도 되는 장점이 있습니다.

Before

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

After

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

default implementation (기본 구현)을 사용하기 위해서 다음 처럼 메소드 시그니처만 선언하는 것이 아닌 시그니처의 반환 타입과 일치하는 디폴트 String 값을 반환하게 해주면 됩니다.

 let article = 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.",
        ),
    };

 println!("New article available! {}", article.summarize());

이를 응용해 trait 내부에 있는 다른 메소드를 호출하게 하는 default implementation을 만들 수도 있습니다.

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

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

위 Summary trait을 사용하기 위해서는 다음처럼 구현하면 됩니다.

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

파라메터로 사용하는 trait

item 파라메터의 특정 타입대신에 impl 키워드와 trait 이름을 작성했습니다.
파라메터는 Summary trait을 구현했기 때문에 notify 함수 내부에서 summarize 메소드를 호출할 수 있습니다.

pub fn notify(item: &impl Summary) {
  println!("Breaking news! {}", item.summarize());
}

위처럼 함수 파라메터로 trait을 사용한것을 길게 늘어서 작성한다면 아래처럼 작성하는 것과 같습니다.
이렇게 작성하는 것을 trait bound form이라고 하는데요, 앞서 봤었던 notify(item: &impl Summary)와 같이 작성하는 것은 imple Trait 문법이라고 합니다.

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

impl Trait은 더 편리한 문법을 제공합니다.

notify 함수에서 두 개의 Summary implement 파라메터를 사용한다면 다음처럼 impl Trait 문법을 사용해서 표현할 수 있습니다.

impl Trait

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

만약 item1, item2가 다른 타입을 사용하길 원한다면 impl Trait를 사용하는 것이 더 편리합니다.
하지만 item1, item2가 동일한 타입을 쓰도록 강제해야 한다면, trait bound 를 사용하는게 편합니다.

pub fn notify<T: Summary>(item1: &T, item2: &T) {

하나의 파라메터에 여러 개의 trait bound를 사용할 수 있습니다.

pub fn notify(item: &(impl Summary + Display)) {

+를 사용하여 파라메터 item이 Display, Summary 두 가지 trait을 모두 구현해야 함을 나타냈습니다.

trait bound 스타일로 작성해볼까요?

pub fn notify<T: Summary + Display>(item: &T) {

where 문

trait bound를 너무 많이 사용하면 시그니처를 읽는 것이 너무 복잡해집니다. 제너릭 정보가 너무 많아지기 때문입니다. 이를 위해 러스트에서는 trait bound를 대체할 수 있는 where 문을 제공합니다.

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

위의 trait bound 스타일은 아래처럼 where문을 사용해 작성할 수 있습니다.

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

이전 코드보다 좀 더 직관적입니다.

반환 타입에 trait 사용하기

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,
  }
}

impl Summary를 반환 타입으로 사용했습니다. returns_summarizable은 Tweet을 반환하는데, 이 함수를 호출하는 측에서는 이러한 사실을 몰라도 되며 Summary trait이 반환된다는 사실만 알면 됩니다.

하지만 다음처럼 여러 가지 타입을 동시에 반환하는 함수는 작성할 수 없습니다.

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,
        }
    }
}

trait bound를 이용해 조건형 메소드 구현하기

제너릭이 특정 타입을 만족할 때만 동작하는 메소드를 구현할 수 있습니다.

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);
    }
  }
}

cmp_display 메소드는 Pair<T>가 오직 PartialOrd trait과 Display trait을 구현했을 때 호출이 가능합니다.

넓은 구현 - blanket implementations

아래의 ToString은 standard library입니다. blanket implementation을 구현했기 때문에 Display trait을 구현한 어떤 타입도 ToString trait의 to_string 메소드를 사용할 수 있습니다.

impl<T: Display> ToString for T {

아래에서 integer 3은 Display trait을 구현했기 때문에 to_string메소드를 사용할 수 있습니다.

let s = 3.to_string();

글을 마치며

오늘 많은 내용을 제너릭 챕터에서 다뤘습니다.
당장 이해가 안되거나 머리속에 전부 소화하기 어려운 양이라고 하더라도 한번 빠르게 훑고 지나가셨으면 좋겠습니다. 이번 비욘드 JS 시리즈 다음에 좀 더 자세하게 러스트를 공부할 수 있는 기회가 있으니 큰 부담 가지지 말고 쭉 읽어보시길 바랍니다.

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글