트레이트 (Trait)

이 포스트는 Rust를 처음 공부하면서 정리한 내용입니다. 해당 포스트는 개인적으로 기억하기 위한 메모 성격에 가깝습니다. “러스트 프로그래밍 공식 가이드 (2018, 제이펍)” 서적을 참고하였으며, 러스트 공식 사이트 에서 책과 동일한 내용을 찾을 수 있습니다. 따라서 더 자세한 내용을 찾으시면 위의 링크를 참고하시면 좋습니다.

Rust를 공부하면서 어려웠던 부분이 바로 이 Trait와 수명(Lifetimes)에 관련된 부분이었습니다. 다른 프로그래밍 언어에는 없는 조금 생소한 개념이다 보니 이해하는게 조금 시간이 걸렸습니다. 여기에서는 제가 이해한 부분을 바탕으로 조금 더 쉬운 용어를 사용하여 작성해보려 합니다.

트레이트란?

공식 Rust 가이드에 보면 트레이트(Trait)는 “공유 가능한 행위를 정의”한다고 작성되어 있습니다. 그런데 처음 트레이트 개념을 접하기에는 조금 힘든 문구였습니다. 그러나 공부하면서 트레이트를 설명하는 가장 좋은 문구라는 생각도 들었습니다.

Trait 라는 영어 단어는 사전에 아래와 같이 설명되어 있습니다.

sc1

트레이트를 처음 접하는 입장에서는 “공유 가능한 행위”라기 보다는 ‘공통된 특성’이라고 단어 그대로 받아들이는 것이 쉽습니다. Rust By Example 사이트에 이해가 쉽게 갈만한 예제가 나와있어 이와 비슷한 예제를 이용해보겠습니다.

트레이트 선언

pub struct Hound {
    pub name: String,
}

impl Hound {
    pub fn bark(&self) {
        println!("Woof");
    }
}

fn main() {
    println!("Trait Example!!");

    let dog_1: Hound = Hound {
        name: String::from("Doggie A"),
    };

    println!("Hound 1: name - {}", dog_1.name);
    dog_1.bark();
}

위의 예시는 Hound 구조체를 가지고 있고 하나의 메서드인 bark를 가지고 있습니다. 위의 예시를 컴파일 하면 아래와 같은 결과를 보여줍니다.

sc2

다른 추가적인 강아지 종(Breed)을 추가해 보겠습니다. 아래와 같이 Terrier를 추가하겠습니다. 그리고 자신의 이름을 출력하는 함수도 추가해 보겠습니다.

pub struct Hound {
    pub name: String,
}

pub struct Terrier {
    pub name: String,
}

impl Hound {
    pub fn bark(&self) {
        println!("Woof!");
    }

    pub fn show_name(&self) {
        println!("{}", self.name);
    }
}

impl Terrier {
    pub fn bark(&self) {
        println!("Woof!");
    }

    pub fn show_name(&self) {
        println!("{}", self.name);
    }
}

fn main() {
    println!("Trait Example!!");

    let dog_1: Hound = Hound {
        name: String::from("Doggie A"),
    };

    let dog_2: Terrier = Terrier {
        name: String::from("Doggie B")
    };

    println!("Hound 1: name - {}", dog_1.name);
    dog_1.bark();

    println!("Terrier 1: name - {}", dog_2.name);
    dog_2.bark();
}

이를 컴파일 하면 아래의 결과를 보여줍니다.

sc3

그러나 두 강아지의 종을 보면 ‘공유 가능한 행위’를 가지고 있습니다. 조금 더 이해가 쉽게 접근하자면 공통적인 특징(Trait) 을 가지고 있습니다. 두 강아지 모두 Dog 라는 특징을 가지고 있고, 네 다리를 가지고 있으며 동물 범주에 속하는 생명체이고, 따라서 호흡을 하고 화학생물학적 작용을 하며… 등이 예시가 될 수 있을 것입니다.

그러면 공통적인 특징을 정의해보도록 하겠습니다.

타입에 트레이트 구현

우선 두 강아지 모두 동물이라는 공통적인 특징을 가지고 있으므로 Animal 이라는 트레이트(Trait)를 정의해보겠습니다. 트레이트의 선언은 trait 키워드를 사용하여 선언합니다.

pub trait Animal {
    fn breathe(&self);
    fn eat(&self);
    fn poop(&self);
}

트레이트를 정의할 때는 trait 키워드 다음에 트레이트의 이름을 지정합니다. 중괄호 안에는 구현할 행위를 설명하는 메서드 시그니쳐를 정의합니다. 우선 여기서는 선언만 해 두겠습니다. 이렇게 자신의 행위를 선언하게 되면, Animal 트리이트를 구현하는 모든 타입은 정확히 같은 시그니쳐를 가진 breathe, eat, poop 메서드를 구현해야만 합니다.

impl Animal for Hound {
    fn breathe(&self) { println!("Hound breathing!"); };
    fn eat(&self) { println!("Hound eating!"); };
    fn poop(&self) { println!("Hound pooping!"); };
}

impl Animal for Terrier {
    fn breathe(&self) { println!("Terrier breating!"); };
    fn eat(&self) { println!("Terrier eating!"); };
    fn poop(&self) { println!("Terrier pooping!"); };
}

이를 HoundTerrier에 적용해보겠습니다.

pub struct Hound {
    pub name: String,
}

pub struct Terrier {
    pub name: String,
}

impl Hound {
    pub fn bark(&self) {
        println!("Woof!");
    }

    pub fn show_name(&self) {
        println!("{}", self.name);
    }
}

impl Terrier {
    pub fn bark(&self) {
        println!("Woof!");
    }

    pub fn show_name(&self) {
        println!("{}", self.name);
    }
}

// 여기에 적용!!
pub trait Animal {
    fn breathe(&self);
    fn eat(&self);
    fn poop(&self);
}

impl Animal for Hound {
    fn breathe(&self) { println!("Hound breathing!"); }
    fn eat(&self) { println!("Hound eating!"); }
    fn poop(&self) { println!("Hound pooping!"); }
}

impl Animal for Terrier {
    fn breathe(&self) { println!("Terrier breating!"); }
    fn eat(&self) { println!("Terrier eating!"); }
    fn poop(&self) { println!("Terrier pooping!"); }
}

fn main() {
    println!("Trait Example!!");

    let dog_1: Hound = Hound {
        name: String::from("Doggie A"),
    };

    let dog_2: Terrier = Terrier {
        name: String::from("Doggie B")
    };

    dog_1.breathe();
    dog_1.eat();
    dog_1.poop();

    dog_2.breathe();
    dog_2.eat();
    dog_2.poop();
}

이를 실행한 결과는 아래와 같습니다.

sc4

Rust 공식 가이드에서는 트레이트를 다른 프로그래밍 언어에서 지원하는 인터페이스(interface)라고 부르는 기능과 유사하다고 소개하고 있습니다. 예제에서 보았듯이, Animal 트레이트를 이용하여 breathe, eat, poop 메서드를 구현하게 강제하면 Animal을 구현하는 인스턴스는 그에 대한 interface를 갖게되는 것과 같습니다.

이는 Java에서 inferface 키워드로 메서드를 구현 후 클래스를 정의하는 것과 비슷합니다.

// Interface
interface Animal {
  public void animalSound(); // interface method (does not have a body)
  public void sleep(); // interface method (does not have a body)
}

// Pig "implements" the Animal interface
class Pig implements Animal {
  public void animalSound() {
    // The body of animalSound() is provided here
    System.out.println("The pig says: wee wee");
  }
  public void sleep() {
    // The body of sleep() is provided here
    System.out.println("Zzz");
  }
}

기본 구현

Java의 클래스 상속을 생각해보면, 자식 클래스는 부모의 모든 메서드를 재정의(Override)하지 않습니다. 일부는 재정의 할 수도 있고, 일부는 부모의 메서드를 그대로 이용할 수도 있죠. 이와 같이 트레이트를 구현하도록 할 때 모든 메서드를 구현하지 않아도 되도록 기본(default) 메서드를 갖게 할 수도 있습니다.

위의 예시에서는 Animal 트레이트에서 선언만 했으나 이번에는 함수 본문도 갖도록 해보겠습니다.

// ... 위와 동일

// 여기에 적용!!
pub trait Animal {
    fn breathe(&self);
    fn eat(&self);
    fn poop(&self) { println!("Default poop!"); } // default
}

impl Animal for Hound {
    fn breathe(&self) { println!("Hound breathing!"); }
    fn eat(&self) { println!("Hound eating!"); }
}

impl Animal for Terrier {
    fn breathe(&self) { println!("Terrier breating!"); }
    fn eat(&self) { println!("Terrier eating!"); }
}

fn main() {

    // ... 위와 동일
}

위와 다르게 Animal 트레이트의 poop 함수만 trait 범위에 구현하고 이외의 breatheeat 함수는 각 HoundTerrier 범위 안에 정의하고 있습니다. 실행 결과는 아래와 같습니다.

sc5

트레이트를 구현할 때 주의해야 할 점이 있습니다. 그것은 바로 트레이트나 트레이트를 구현할 타입이 현재 트레이트의 로컬 타입이어야 한다는 점입니다. 예를 들어, Display와 같은 표준 트레이트는

트레이트 경계 (Trait Bound)

경계(Bound)란 아이템이 만족해야 하는 어떠한 조건이라고 해석하면 됩니다. 그렇다면 트레이트 경계라는 용어는 아이템이 만족해야 하는 트레이트라고 해석할 수 있겠네요. 예제 부터 보겠습니다.

// impl <Trait> 문법
fn animal_eat(creature: impl Animal) {
    creature.eat();
}

위의 animal_eat 함수는 creature을 인수로 받으면서 impl Animal 을 명시하고 있습니다. 이는 실제 타입을 이용하는 대신 트레이트를 지정함으로써, Animal 트레이트를 구현하는 타입만을 인수로 가질 수 있습니다. 즉, String이나 i32와 같은 타입을 전달하면 이 티압은 Animal 트레이트를 구현하고 있지 않기 때문에 컴파일이 되지 않습니다.

제네릭 함수(Generic Functions): 매개변수

위의 방식으로 함수를 만들 수도 있지만 인수가 많아지면 impl 키워드를 모두 붙여야 하니 코드가 못생겨집니다.

fn animals_eat(c1: impl Animal, c2: impl Animal, c3: impl Animal) {
    c1.eat();
    c2.eat();
    c3.eat();
}

또한 impl [Trait] 구문은 매개변수가 하나만의 트레이트만을 구현하는 경우에만 사용할 수 있습니다. 따라서 + 문법으로 다음과 같이 제네릭 형식을 사용할 수 있습니다. 매개변수는 + 으로 지정된 트레이트를 모두 구현하고 있어야 합니다.

// 하나의 매개변수가 존재하는 경우
fn animals_eat_1<T: Animal>(c1: T) {
    // ...
}

// 세 개의 매개변수가 존재하는 경우
fn animals_eat_2<T: Animal>(c1: T, c2: T, c3: T) {
    // ...
}

// + 으로 여러 개의 트레이트를 지정하는 경우
fn animals_eat_3<T: Dog + Cat>(c1: T) {
    // ...
}

animals_eat_1animals_eat_2는 모든 매개변수가 타입 T로 정의되어 있으므로 Animal 트레이트를 가지고 있어야 합니다.

animals_eat_3 함수는 + 문법으로 DogCat 트레이트를 지정하고 있습니다. 즉, T로 지정된 매개변수는 DogCat 모두를 구현해야 합니다.

또한 너무 많은 트레이트 경계를 나열하게 되는 경우에는 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_poopable() -> impl Animal {
    Hound {
        name: String::from("Doggie C")
    }
}

구조체와 메서드

구조체 또한 마찬가지로 트레이트 경계를 명시할 수 있습니다.

struct S<T: Display>(T);

// Error! `Vec<T>` does not implement `Display`. This
// specialization will fail.
let s = S(vec![1]);

위의 구조체 SDisplay 트레이트를 구현하고 있는 타입 T를 제네릭으로 가지고 있으나 Vec<T>Display 트레이트를 구현하고 있지 않기 때문에 변수 s는 컴파일 되지 않습니다.

마찬가지로 구조체 메서드 또한 트레이트 경계를 명시할 수 있습니다.

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 {
            // ...
        } else {
            // ...
        }
    }
}

위의 cmp_display 함수는 DisplayPartialOrd 트레이트를 구현하는 경우에만 구현되는 함수입니다.

Blanket Implementations (덮개 구현)

Blanket Implementation 이란 타입이 원하는 트레이트를 구현하는 경우에만 다른 트레이트를 조건적으로 구현하는 것을 의미합니다.

예를 들어, 아래의 코드는 Display 구현하는 타입 T에는 ToStrin 트레이트도 함께 구현합니다. 즉, Display 트레이트를 구현하는 모든 타입에 대해 ToString 트레이트가 정의한 to_string 메서드를 호출할 수 있습니다.

impl<T: Display> ToString for T {
    // ...
}

태그:

카테고리:

업데이트: