트레이트 객체를 사용하여 공통 동작을 추상화하기
8장에서 벡터의 한계 중 하나로 “한 가지 타입의 요소만 담을 수 있다”는 점을
이야기했습니다. 우리는 목록 8-9에서 SpreadsheetCell enum을 정의해 정수, 실수,
텍스트를 담는 variant를 만들고, 그 덕분에 서로 다른 타입의 데이터를 각 셀에 담으면서도
하나의 벡터로 스프레드시트 행을 표현할 수 있었습니다. 이것은 컴파일 시점에 이미
서로 바꿔 넣을 수 있는 타입 집합을 알고 있을 때는 충분히 좋은 해결책입니다.
하지만 어떤 상황에서는 라이브러리 사용자가 “그 상황에서 허용되는 타입 집합” 자체를
확장할 수 있게 하고 싶을 수도 있습니다. 이를 보여 주기 위해, 항목 목록을 순회하며
각 항목의 draw 메서드를 호출해 화면에 그리는 간단한 GUI 도구 예제를 만들어 보겠습니다.
이는 GUI 도구에서 아주 흔한 기법입니다. 우리는 gui 라는 라이브러리 크레이트를
만들고, 그 안에 GUI 라이브러리의 뼈대를 둘 것입니다. 이 크레이트는 Button 이나
TextField 같은 타입을 제공할 수 있습니다. 하지만 gui 사용자들은 그 외에도
자기만의 그릴 수 있는 타입을 만들고 싶어질 것입니다. 어떤 사람은 Image 를,
어떤 사람은 SelectBox 를 추가하고 싶을 수 있습니다.
이 라이브러리를 작성하는 시점에는, 다른 프로그래머가 만들고 싶어 할 모든 타입을 우리가
미리 알 수도 없고 정의할 수도 없습니다. 하지만 gui 가 여러 서로 다른 타입 값을
추적해야 하고, 각 값에 대해 draw 메서드를 호출해야 한다는 사실은 알고 있습니다.
라이브러리는 draw 메서드를 호출했을 때 구체적으로 무슨 일이 일어나는지 알 필요는
없습니다. 중요한 것은 그 값에 draw 메서드가 존재한다 는 점뿐입니다.
상속이 있는 언어라면, draw 메서드를 가진 Component 클래스 하나를 정의하고,
Button, Image, SelectBox 같은 다른 클래스들이 그것을 상속받는 식으로 만들 수
있었을 것입니다. 그런 클래스들은 각자 draw 를 재정의할 수 있지만, 프레임워크 입장에서는
이 모든 타입을 Component 인스턴스처럼 다루며 draw 를 호출할 수 있습니다. 하지만
러스트에는 상속이 없기 때문에, gui 라이브러리 사용자가 라이브러리와 호환되는 새 타입을
만들 수 있도록 다른 구조를 잡아야 합니다.
공통 동작을 위한 트레이트 정의하기
우리가 gui 에서 원하는 동작을 구현하려면, draw 라는 메서드 하나를 가지는 Draw
트레이트를 정의하면 됩니다. 그리고 나서 트레이트 객체를 담는 벡터를 정의할 수 있습니다.
트레이트 객체(trait object) 는, 지정한 트레이트를 구현하는 어떤 타입의 인스턴스와
그 타입의 트레이트 메서드를 런타임에 찾아 호출하기 위한 테이블을 함께 가리키는 값입니다.
트레이트 객체를 만들려면 참조나 Box<T> 같은 포인터 종류 뒤에 dyn 키워드와
트레이트 이름을 적습니다. (트레이트 객체가 왜 반드시 포인터를 통해 써야 하는지는
20장의 “동적 크기 타입과 Sized 트레이트”
절에서 설명합니다.) 제네릭 타입이나 구체 타입을 쓸 수 있는 많은 위치에 트레이트
객체도 사용할 수 있습니다. 트레이트 객체를 사용하면, 러스트의 타입 시스템은 그 문맥에
들어가는 값이 반드시 해당 트레이트를 구현한다는 것을 컴파일 시점에 보장합니다.
따라서 가능한 모든 타입을 미리 알 필요가 없습니다.
우리는 구조체와 enum을 다른 언어의 객체와 구분하기 위해 “객체”라고 부르지 않는다고
앞에서 언급했습니다. 구조체나 enum에서는 데이터는 필드 안에, 동작은 impl 블록에
분리되어 있습니다. 반면 다른 언어의 객체는 데이터와 동작이 하나의 개념 안에 함께
묶여 있는 경우가 많습니다. 트레이트 객체는 그런 객체와도 약간 다릅니다. 트레이트
객체에는 데이터를 추가로 넣을 수 없기 때문입니다. 트레이트 객체는 다른 언어의
객체처럼 범용적인 도구라기보다는, “공통 동작을 기준으로 타입을 추상화”하기 위한
특수한 목적에 쓰입니다.
목록 18-3은 draw 메서드 하나를 가진 Draw 트레이트를 정의하는 코드입니다.
pub trait Draw {
fn draw(&self);
}
Draw 트레이트 정의이 문법은 10장에서 트레이트를 정의할 때 보았던 것과 익숙하게 느껴질 것입니다.
이제 새로운 부분으로 넘어가면, 목록 18-4는 components 라는 벡터를 가진
Screen 구조체를 정의합니다. 이 벡터의 타입은 Box<dyn Draw> 인데, 이것이
바로 트레이트 객체입니다. 즉 Draw 트레이트를 구현하는 어떤 타입이든 Box 로 감싼
값을 넣을 수 있습니다.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Draw 트레이트를 구현하는 트레이트 객체 벡터를 담는 components 필드를 가진 Screen 구조체 정의그리고 Screen 구조체에는 run 이라는 메서드를 정의할 수 있습니다. 이 메서드는
자신이 가진 components 각각에 대해 draw 메서드를 호출합니다. 목록 18-5를
보세요.
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();
}
}
}
draw 를 호출하는 Screen 의 run 메서드이 방식은 트레이트 바운드를 가진 제네릭 타입 매개변수로 구조체를 정의하는 것과는
다르게 동작합니다. 예를 들어 목록 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();
}
}
}
Screen 구조체와 run 메서드의 대안적 구현하지만 이렇게 하면, 한 Screen 인스턴스 안에는 모두 Button 이거나 모두
TextField 인 식으로 하나의 구체 타입 만 들어갈 수 있습니다. 컬렉션 안의 모든
요소가 언제나 같은 타입일 것이라면 제네릭과 트레이트 바운드가 더 좋은 선택입니다.
컴파일 시점에 구체 타입으로 단형화되기 때문입니다.
반대로 트레이트 객체를 사용하면, 하나의 Screen 인스턴스 안에 Box<Button> 와
Box<TextField> 를 함께 담을 수 있습니다. 이제 이것이 어떻게 가능해지는지 보고,
그에 따른 런타임 성능 특성도 함께 이야기해 봅시다.
트레이트 구현하기
이제 Draw 트레이트를 구현하는 타입 몇 개를 추가해 봅시다. 우선 Button 타입을
제공하겠습니다. 물론 실제 GUI 라이브러리를 구현하는 것은 이 책의 범위를 훨씬 벗어나므로,
draw 메서드 본문은 실제로는 아무 의미 있는 GUI 코드를 가지지 않습니다. 대충 어떤
모양일지 상상해 보면, Button 구조체는 목록 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
}
}
Draw 트레이트를 구현하는 Button 구조체Button 의 width, height, label 필드는 다른 컴포넌트 타입의 필드와는 다를
수 있습니다. 예를 들어 TextField 는 비슷한 필드에 더해 placeholder 필드를
추가로 가질 수도 있겠지요. 우리가 화면에 그리고 싶은 각 타입은 Draw 트레이트를
구현하고, 자기 타입을 그리는 방식에 맞는 서로 다른 코드를 draw 안에 가지게 될
것입니다. 여기서는 실제 GUI 코드는 생략했지만, Button 도 마찬가지입니다.
또한 Button 타입은 별도의 impl 블록 안에 “버튼을 클릭했을 때 일어나는 일” 같은
메서드를 더 가질 수도 있습니다. 이런 메서드는 TextField 같은 타입에는 적용되지
않겠지요.
만약 우리 라이브러리 사용자가 width, height, options 필드를 가진
SelectBox 구조체를 직접 만들기로 했다면, 그 타입에도 목록 18-8처럼 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() {}
gui 를 사용하며 SelectBox 구조체에 Draw 구현하기이제 라이브러리 사용자는 main 함수 안에서 Screen 인스턴스를 만들 수 있습니다.
그리고 SelectBox, Button 을 각각 Box<T> 로 감싸 트레이트 객체로 만든 뒤,
Screen 안에 넣을 수 있습니다. 이후 Screen 인스턴스의 run 메서드를 호출하면,
components 안의 각 항목에 대해 draw 가 호출됩니다. 목록 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 트레이트를 구현했기 때문에, Screen
구현은 이 새로운 타입도 문제없이 다루고 그릴 수 있습니다.
이 개념은 동적 타입 언어에서 흔히 덕 타이핑(duck typing) 이라고 부르는 개념과
비슷합니다. “오리처럼 걷고 오리처럼 꽥꽥거리면, 그것은 오리다” 같은 사고방식입니다.
목록 18-5의 Screen::run 구현은 각 컴포넌트의 구체 타입이 무엇인지 알 필요가
없습니다. 그것이 Button 인지 SelectBox 인지를 검사하지 않고, 그냥 draw
메서드를 호출합니다. 우리는 components 벡터의 타입을 Box<dyn Draw> 로 지정함으로써,
Screen 이 “draw 메서드를 호출할 수 있는 값들”만 필요로 한다고 정의한 것입니다.
트레이트 객체와 러스트 타입 시스템을 사용하는 장점은, 런타임에서 어떤 값이 특정 메서드를 구현했는지 검사하거나, 메서드가 없는 값에 대해 잘못 호출할까 봐 걱정할 필요가 없다는 것입니다. 트레이트 객체가 요구하는 트레이트를 값이 구현하지 않으면, 러스트는 그 코드를 아예 컴파일하지 않습니다.
예를 들어 목록 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 이 그것에 대해 draw
를 호출할 수 있게 해야 한다는 점을 알려 줍니다.
동적 디스패치 수행하기
10장의 “제네릭을 사용하는 코드의 성능” 절에서, 컴파일러가 제네릭 코드에 대해 단형화를 수행한다고 이야기했습니다. 즉, 제네릭 타입 매개변수 자리에 우리가 실제로 사용하는 구체 타입을 넣은 함수와 메서드를 컴파일러가 각각 생성해 준다는 뜻입니다. 이렇게 컴파일 결과물이 호출할 메서드를 컴파일 시점에 이미 알고 있는 형태를 정적 디스패치(static dispatch) 라고 합니다. 반대로 호출할 메서드를 컴파일 시점에 알 수 없는 경우를 동적 디스패치(dynamic dispatch) 라고 부릅니다. 동적 디스패치에서는 런타임에 어떤 메서드를 호출해야 할지 결정하는 코드가 추가로 실행됩니다.
트레이트 객체를 사용할 때는 러스트가 반드시 동적 디스패치를 사용해야 합니다. 컴파일러는 트레이트 객체를 사용하는 코드와 함께 실제로 어떤 타입이 들어올지 모두 알 수 없기 때문에, “어떤 타입에 구현된 어느 메서드”를 호출해야 하는지 컴파일 시점에 결정할 수 없습니다. 그래서 러스트는 런타임에 트레이트 객체 안의 포인터를 사용해 호출할 메서드를 찾아냅니다. 이 조회에는 런타임 비용이 들며, 정적 디스패치에서 가능한 메서드 인라이닝과 그에 따른 최적화도 막습니다. 또한 동적 디스패치에는 사용 가능한 위치에 대한 몇 가지 규칙, 즉 dyn compatibility 규칙도 있습니다. 그 규칙은 여기서 다루지는 않지만 reference 문서 에서 더 읽어볼 수 있습니다. 대신 이 비용을 치르는 대가로, 우리는 목록 18-5에서 작성한 코드와 목록 18-9에서 보여 준 사용 방식을 통해 훨씬 더 큰 유연성을 얻게 되었습니다. 이것이 바로 고려해야 할 트레이드오프입니다.