트레이트 객체로 공통 행동 추상화하기
트레이트 객체로 공통 행동 추상화하기 (Using Trait Objects to Abstract over Shared Behavior)
8장에서 벡터의 한계 중 하나가 같은 타입의 요소만 저장할 수 있다는 점이라고 했었죠. Listing 8-9에서 정수·부동소수점·텍스트를 담는 변형(variant)을 가진 SpreadsheetCell 열거형을 정의해 그 제약을 우회했어요. 덕분에 각 셀엔 서로 다른 타입의 데이터를 저장하면서도 한 행의 셀을 표현하는 벡터를 유지할 수 있었죠. 상호 교환 가능한 항목들이 컴파일 시점에 그 타입 집합이 정해져 있는 고정된 타입들이라면 이 방식은 아주 좋은 해결책이에요.
하지만 어떤 때는 라이브러리 사용자가 특정 상황에서 유효한 타입의 집합을 스스로 확장할 수 있기를 바랄 때가 있어요. 어떻게 가능한지 보여드리기 위해, 항목 목록을 순회하며 각 항목의 draw 메서드를 호출해 화면에 그리는 GUI 도구 예시를 만들어 볼게요. GUI 도구에서 흔히 쓰는 기법이죠. gui라는 라이브러리 크레이트를 만들 텐데, 이 크레이트는 GUI 라이브러리의 구조를 담고 있어요. Button이나 TextField처럼 사용자들이 쓸 만한 몇 가지 타입을 포함할 수 있죠. 거기에 더해 gui 사용자들은 자신만의 그릴 수 있는 타입을 만들고 싶어 할 거예요. 예를 들어 어떤 프로그래머는 Image를, 다른 프로그래머는 SelectBox를 추가할 수 있겠죠.
라이브러리를 작성하는 시점에는 다른 프로그래머들이 만들고 싶어 할 모든 타입을 알 수도, 정의할 수도 없어요. 하지만 gui가 서로 다른 타입의 많은 값을 추적해야 하고, 그 각각의 값에 draw 메서드를 호출해야 한다는 사실은 알고 있죠. draw 메서드를 호출했을 때 정확히 어떤 일이 일어날지는 몰라도, 그 값이 우리가 호출할 수 있는 메서드를 갖고 있다는 것만 알면 돼요.
상속이 있는 언어에서라면 draw 메서드를 가진 Component라는 클래스를 정의하겠죠. Button, Image, SelectBox 같은 다른 클래스들은 Component를 상속받아 draw 메서드를 물려받는 방식이에요. 각 클래스는 draw 메서드를 오버라이드해 자신만의 행동을 정의할 수 있고, 프레임워크는 모든 타입을 Component 인스턴스인 것처럼 다루며 draw를 호출할 수 있죠. 하지만 Rust에는 상속이 없으니, 사용자가 라이브러리와 호환되는 새 타입을 만들 수 있게 gui 라이브러리를 구성할 다른 방법이 필요해요.
공통 행동을 위한 트레이트 정의하기 (Defining a Trait for Common Behavior)
gui가 갖길 원하는 행동을 구현하려면 draw라는 메서드 하나를 가진 Draw라는 트레이트를 정의할 거예요. 그러고 나면 트레이트 객체(trait object)를 받는 벡터를 정의할 수 있죠. 트레이트 객체는 우리가 지정한 트레이트를 구현하는 타입의 인스턴스를 가리키는 포인터와, 런타임에 그 타입의 트레이트 메서드를 찾아보는 데 쓰는 테이블을 함께 가리킵니다. 트레이트 객체는 참조(reference)나 Box<T> 스마트 포인터 같은 어떤 포인터를 지정하고, 그다음 dyn 키워드를 쓰고, 마지막으로 관련 트레이트를 지정해 만들 수 있어요. (트레이트 객체가 왜 반드시 포인터를 써야 하는지는 20장의 "동적 크기 타입과 Sized 트레이트(Dynamically Sized Types and the Sized Trait)"에서 다루겠지만, 여기선 포인터를 쓴다는 것만 기억해 두면 돼요.) 트레이트 객체는 제네릭 타입이나 구체 타입 자리에 쓸 수 있어요. 트레이트 객체를 사용하는 곳 어디에서든 Rust의 타입 시스템이 컴파일 시점에 그 문맥에서 쓰이는 어떤 값이 그 트레이트 객체의 트레이트를 구현할 것임을 보장해요. 따라서 컴파일 시점에 가능한 모든 타입을 알 필요가 없죠.
앞서 Rust에서는 구조체와 열거형을 다른 언어들의 객체와 구분하기 위해 '객체'라고 부르지 않는다고 했었죠. 구조체나 열거형에서는 구조체 필드의 데이터와 impl 블록의 행동이 분리되어 있는데, 다른 언어에서 데이터와 행동을 하나의 개념으로 합친 것을 자주 객체라고 부르기 때문이에요. 트레이트 객체는 트레이트 객체에 데이터를 추가할 수 없다는 점에서 다른 언어의 객체와 달라요. 트레이트 객체는 다른 언어의 객체만큼 일반적으로 쓸모 있진 않아요. 그것의 구체적인 목적은 공통 행동에 걸친 추상화를 가능하게 하는 것이죠.
Listing 18-3은 draw라는 메서드 하나를 가진 Draw라는 트레이트를 정의하는 방법을 보여줘요.
pub trait Draw {
fn draw(&self);
}
이 문법은 10장에서 트레이트를 정의하는 방법을 다뤘을 때 봤으니 익숙할 거예요. 이제 새로운 문법이 나옵니다. Listing 18-4는 components라는 벡터를 담은 Screen이라는 구조체를 정의하는데, 이 벡터의 타입은 트레이트 객체인 Box<dyn Draw>예요. 다시 말해 Box 안에 들어 있는 Draw 트레이트를 구현하는 어떤 타입이든 대신할 수 있는 대리인(stand-in)이죠.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Screen 구조체에는 Listing 18-5처럼 각 components에 대해 draw 메서드를 호출하는 run이라는 메서드를 정의할 거예요.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
이 방식은 트레이트 바운드가 있는 제네릭 타입 매개변수를 쓰는 구조체를 정의할 때와 달라요. 제네릭 타입 매개변수는 한 번에 하나의 구체 타입으로만 치환될 수 있는 반면, 트레이트 객체는 런타임에 여러 구체 타입이 그 자리를 채울 수 있기 때문이에요. 예를 들어 Listing 18-6처럼 제네릭 타입과 트레이트 바운드를 써서 Screen 구조체를 정의할 수도 있었을 거예요.
pub trait Draw {
fn draw(&self);
}
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
이렇게 하면 components 목록이 전부 Button 타입이거나 전부 TextField 타입인 Screen 인스턴스로 제한돼요. 항상 동질적인 집합만 쓸 거라면, 정의가 컴파일 시점에 구체 타입을 쓰도록 단일화(monomorphize)되기 때문에 제네릭과 트레이트 바운드를 쓰는 편이 더 낫죠.
반면 트레이트 객체를 쓰는 메서드로 하면 하나의 Screen 인스턴스가 Box<Button>과 Box<TextField>를 함께 담는 Vec<T>를 가질 수 있어요. 어떻게 동작하는지 살펴보고, 런타임 성능에 미치는 영향도 이야기해 볼게요.
트레이트 구현하기 (Implementing the Trait)
이제 Draw 트레이트를 구현하는 타입 몇 개를 추가해 볼게요. Button 타입을 제공할 텐데, 실제 GUI 라이브러리를 구현하는 건 이 책의 범위를 벗어나니 draw 메서드 본문에는 쓸모 있는 구현이 없을 거예요. 구현이 어떤 모습일지 상상해 보면, Button 구조체는 Listing 18-7처럼 width, height, label 필드를 가질 수 있겠죠.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// code to actually draw a button
}
}
Button의 width, height, label 필드는 다른 컴포넌트의 필드와 다를 거예요. 예를 들어 TextField 타입은 같은 필드에 placeholder 필드가 추가로 있을 수 있죠. 화면에 그리고 싶은 각 타입은 Draw 트레이트를 구현하되, 그 특정 타입을 어떻게 그릴지는 draw 메서드 안에서 서로 다른 코드로 정의해요. Button이 여기서 그런 것처럼요(앞서 말했듯 실제 GUI 코드는 없어요). 예를 들어 Button 타입에는 사용자가 버튼을 클릭했을 때 어떤 일이 일어나는지에 관련된 메서드들을 담은 impl 블록이 추가로 있을 수 있어요. 이런 메서드는 TextField 같은 타입에는 적용되지 않죠.
우리 라이브러리를 쓰는 어떤 사람이 width, height, options 필드를 가진 SelectBox 구조체를 만들기로 했다면, Listing 18-8처럼 SelectBox 타입에도 Draw 트레이트를 구현할 거예요.
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
fn main() {}
이제 라이브러리 사용자는 main 함수를 작성해 Screen 인스턴스를 만들 수 있어요. 그 Screen 인스턴스에 각각을 Box<T>에 넣어 트레이트 객체로 만든 SelectBox와 Button을 추가할 수 있죠. 그러고 나서 Screen 인스턴스의 run 메서드를 호출하면 각 components에 대해 draw가 호출됩니다. Listing 18-9가 이 구현을 보여줘요.
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
use gui::{Button, Screen};
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}
라이브러리를 작성할 때는 누군가 SelectBox 타입을 추가할 줄 몰랐지만, SelectBox가 Draw 트레이트를 구현한다는 것, 즉 draw 메서드를 구현한다는 뜻이라서 우리 Screen 구현은 새 타입에도 동작하고 그릴 수 있었어요.
값의 구체 타입보다 값이 응답하는 메시지에만 관심을 둔다는 이 개념은 동적 타입 언어의 덕 타이핑(duck typing)과 비슷해요. 오리처럼 걷고 오리처럼 꽥꽥거리면 그건 오리라는 그 논리죠! Listing 18-5의 Screen에 있는 run 구현에서 run은 각 컴포넌트의 구체 타입이 무엇인지 알 필요가 없어요. 컴포넌트가 Button 인스턴스인지 SelectBox인지 확인하지 않고 draw 메서드만 호출하죠. components 벡터 값들의 타입을 Box<dyn Draw>로 지정함으로써 Screen이 draw 메서드를 호출할 수 있는 값들을 필요로 하도록 정의한 거예요.
트레이트 객체와 Rust의 타입 시스템으로 덕 타이핑과 비슷한 코드를 작성할 때 얻는 장점은, 런타임에 어떤 값이 특정 메서드를 구현하는지 확인할 필요도, 값이 메서드를 구현하지 않는데 호출해서 오류가 나지 않을까 걱정할 필요도 없다는 점이에요. 값들이 트레이트 객체가 요구하는 트레이트를 구현하지 않으면 Rust는 코드를 컴파일하지 않으니까요.
예를 들어 Listing 18-10은 String을 컴포넌트로 가진 Screen을 만들려고 하면 무슨 일이 일어나는지 보여줘요.
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
String이 Draw 트레이트를 구현하지 않기 때문에 이런 오류가 나요.
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src/main.rs:5:26
|
5 | components: vec![Box::new(String::from("Hi"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
= help: the trait `Draw` is implemented for `Button`
= note: required for the cast from `Box<String>` to `Box<dyn Draw>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error
이 오류는 두 가지를 알려줘요. Screen에 의도하지 않은 것을 넘기고 있으니 다른 타입을 넘겨야 하거나, String에 Draw를 구현해서 Screen이 String에 draw를 호출할 수 있게 해야 한다는 거죠.
동적 디스패치 수행하기 (Performing Dynamic Dispatch)
10장의 "제네릭을 사용한 코드의 성능(Performance of Code Using Generics)"에서 컴파일러가 제네릭에 수행하는 단일화 과정을 이야기했었죠. 컴파일러는 제네릭 타입 매개변수 자리에 쓰는 각 구체 타입마다 함수와 메서드의 비제네릭 구현을 생성해요. 단일화 결과로 나온 코드는 정적 디스패치(static dispatch)를 하는데, 이는 컴파일러가 컴파일 시점에 어떤 메서드를 호출하는지 아는 경우예요. 반대는 동적 디스패치(dynamic dispatch)로, 컴파일러가 컴파일 시점에 어떤 메서드를 호출하는지 알 수 없는 경우죠. 동적 디스패치의 경우 컴파일러는 런타임에 어떤 메서드를 호출할지 아는 코드를 만들어 냅니다.
트레이트 객체를 쓰면 Rust는 동적 디스패치를 써야 해요. 컴파일러는 트레이트 객체를 쓰는 코드와 함께 사용될 모든 타입을 알지 못하므로, 어떤 타입에 구현된 어떤 메서드를 호출할지 알 수 없거든요. 대신 런타임에 Rust는 트레이트 객체 안의 포인터들을 이용해 어떤 메서드를 호출할지 알아내죠. 이 조회(lookup)는 정적 디스패치에서는 발생하지 않는 런타임 비용을 수반해요. 동적 디스패치는 또한 컴파일러가 메서드 코드를 인라인(inline)하는 것도 막아서, 결과적으로 일부 최적화를 방해하기도 해요. 그리고 Rust에는 동적 디스패치를 쓸 수 있는 자리와 없는 자리에 관한 규칙이 있는데, 이를 dyn 호환성(dyn compatibility)이라고 불러요. 그 규칙은 이 논의의 범위를 벗어나지만, 러스트 레퍼런스에서 더 읽어볼 수 있어요. 어쨌든 우리는 Listing 18-5에서 작성하고 Listing 18-9에서 지원할 수 있었던 코드에서 추가 유연성을 얻었으니, 이건 고려해 볼 만한 트레이드오프죠.
출처: The Rust Book