소개
다양한 타입의 값을 허용하는 트레이트 객체 사용하기에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.
이 랩에서는 트레이트 객체를 사용하여 라이브러리, 특히 그래픽 사용자 인터페이스 (GUI) 도구의 맥락에서 다양한 타입의 값을 허용하는 방법을 살펴봅니다.
다양한 타입의 값을 허용하는 트레이트 객체 사용하기
8 장에서 벡터의 한계 중 하나는 단일 타입의 요소만 저장할 수 있다는 점을 언급했습니다. Listing 8-9 에서 정수, 부동 소수점 숫자, 텍스트를 저장하는 변형을 가진 SpreadsheetCell 열거형을 정의하여 이 문제를 해결했습니다. 이는 각 셀에 서로 다른 타입의 데이터를 저장할 수 있었고, 여전히 셀 행을 나타내는 벡터를 가질 수 있다는 것을 의미했습니다. 이는 교환 가능한 항목이 코드가 컴파일될 때 알고 있는 고정된 타입 집합일 때 완벽하게 좋은 해결책입니다.
그러나 때로는 라이브러리 사용자가 특정 상황에서 유효한 타입 집합을 확장할 수 있도록 하고 싶을 수 있습니다. 이를 어떻게 달성할 수 있는지 보여주기 위해, 항목 목록을 반복하고 각 항목에 대해 draw 메서드를 호출하여 화면에 그리는 예시 그래픽 사용자 인터페이스 (GUI) 도구를 만들 것입니다. 이는 GUI 도구에서 흔히 사용되는 기술입니다. gui라는 라이브러리 크레이트를 생성하여 GUI 라이브러리의 구조를 포함시킬 것입니다. 이 크레이트는 Button 또는 TextField와 같이 사람들이 사용할 수 있는 몇 가지 타입을 포함할 수 있습니다. 또한, gui 사용자는 그릴 수 있는 자체 타입을 만들고 싶어할 것입니다. 예를 들어, 한 프로그래머는 Image를 추가하고 다른 프로그래머는 SelectBox를 추가할 수 있습니다.
이 예제에서는 완전한 GUI 라이브러리를 구현하지 않지만, 구성 요소가 어떻게 함께 연결되는지 보여줄 것입니다. 라이브러리를 작성할 당시에는 다른 프로그래머가 만들 수 있는 모든 타입을 알 수 없고 정의할 수도 없습니다. 하지만 gui가 서로 다른 타입의 많은 값을 추적해야 하고, 이러한 서로 다른 타입의 각 값에 대해 draw 메서드를 호출해야 한다는 것을 알고 있습니다. draw 메서드를 호출할 때 정확히 어떤 일이 일어날지는 알 필요가 없지만, 해당 값에 우리가 호출할 수 있는 해당 메서드가 있다는 것만 알면 됩니다.
상속이 있는 언어에서 이를 수행하려면 draw라는 메서드를 가진 Component라는 클래스를 정의할 수 있습니다. Button, Image, SelectBox와 같은 다른 클래스는 Component에서 상속받아 draw 메서드를 상속받습니다. 각 클래스는 사용자 정의 동작을 정의하기 위해 draw 메서드를 재정의할 수 있지만, 프레임워크는 모든 타입을 Component 인스턴스인 것처럼 처리하고 해당 인스턴스에서 draw를 호출할 수 있습니다. 그러나 Rust 에는 상속이 없으므로 사용자가 새로운 타입으로 확장할 수 있도록 gui 라이브러리를 구성하는 다른 방법이 필요합니다.
공통 동작을 위한 트레이트 정의하기
gui가 갖기를 원하는 동작을 구현하기 위해, draw라는 메서드 하나를 갖는 Draw라는 트레이트를 정의할 것입니다. 그런 다음 트레이트 객체를 사용하는 벡터를 정의할 수 있습니다. 트레이트 객체는 지정된 트레이트를 구현하는 타입의 인스턴스와 런타임에 해당 타입에서 트레이트 메서드를 찾아보는 데 사용되는 테이블을 모두 가리킵니다. & 참조 또는 Box<T> 스마트 포인터와 같은 일종의 포인터를 지정한 다음 dyn 키워드를 지정하고 관련 트레이트를 지정하여 트레이트 객체를 생성합니다. (트레이트 객체가 "동적으로 크기가 조정된 타입과 Sized 트레이트"에서 포인터를 사용해야 하는 이유에 대해 이야기할 것입니다.) 제네릭 또는 구체적인 타입 대신 트레이트 객체를 사용할 수 있습니다. 트레이트 객체를 사용하는 곳마다 Rust 의 타입 시스템은 컴파일 시간에 해당 컨텍스트에서 사용되는 모든 값이 트레이트 객체의 트레이트를 구현하는지 확인합니다. 결과적으로 컴파일 시간에 가능한 모든 타입을 알 필요가 없습니다.
Rust 에서는 다른 언어의 객체와 구별하기 위해 구조체와 열거형을 "객체"라고 부르지 않는다고 언급했습니다. 구조체 또는 열거형에서 구조체 필드의 데이터와 impl 블록의 동작은 분리되어 있는 반면, 다른 언어에서는 데이터와 동작이 하나의 개념으로 결합된 것을 종종 객체라고 합니다. 그러나 트레이트 객체는 데이터와 동작을 결합한다는 점에서 다른 언어의 객체와 더 유사합니다. 하지만 트레이트 객체는 트레이트 객체에 데이터를 추가할 수 없다는 점에서 전통적인 객체와 다릅니다. 트레이트 객체는 다른 언어의 객체만큼 일반적으로 유용하지 않습니다. 그들의 특정 목적은 공통 동작에 대한 추상화를 허용하는 것입니다.
Listing 17-3 은 draw라는 메서드 하나를 가진 Draw라는 트레이트를 정의하는 방법을 보여줍니다.
파일 이름: src/lib.rs
pub trait Draw {
fn draw(&self);
}
Listing 17-3: Draw 트레이트의 정의
이 구문은 10 장에서 트레이트를 정의하는 방법에 대한 논의에서 익숙해야 합니다. 다음은 새로운 구문입니다. Listing 17-4 는 components라는 벡터를 포함하는 Screen이라는 구조체를 정의합니다. 이 벡터는 Box<dyn Draw> 타입이며, 이는 트레이트 객체입니다. Draw 트레이트를 구현하는 Box 내부의 모든 타입에 대한 대리자입니다.
파일 이름: src/lib.rs
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Listing 17-4: Draw 트레이트를 구현하는 트레이트 객체의 벡터를 포함하는 components 필드가 있는 Screen 구조체의 정의
Screen 구조체에서 Listing 17-5 에 표시된 것처럼 각 components에서 draw 메서드를 호출하는 run이라는 메서드를 정의할 것입니다.
파일 이름: src/lib.rs
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
Listing 17-5: 각 구성 요소에서 draw 메서드를 호출하는 Screen의 run 메서드
이는 트레이트 바운드가 있는 제네릭 타입 매개변수를 사용하는 구조체를 정의하는 것과는 다르게 작동합니다. 제네릭 타입 매개변수는 한 번에 하나의 구체적인 타입으로만 대체될 수 있는 반면, 트레이트 객체는 런타임에 여러 구체적인 타입이 트레이트 객체를 채울 수 있도록 허용합니다. 예를 들어, Listing 17-6 과 같이 제네릭 타입과 트레이트 바운드를 사용하여 Screen 구조체를 정의할 수 있습니다.
파일 이름: src/lib.rs
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();
}
}
}
Listing 17-6: 제네릭과 트레이트 바운드를 사용하여 Screen 구조체와 해당 run 메서드의 대체 구현
이렇게 하면 모든 구성 요소가 Button 타입이거나 모두 TextField 타입인 Screen 인스턴스로 제한됩니다. 항상 동질적인 컬렉션만 갖게 된다면, 제네릭과 트레이트 바운드를 사용하는 것이 좋습니다. 왜냐하면 정의가 컴파일 시간에 구체적인 타입을 사용하도록 단형화되기 때문입니다.
반면에 트레이트 객체를 사용하는 메서드를 사용하면 하나의 Screen 인스턴스가 Box<Button>과 Box<TextField>를 모두 포함하는 Vec<T>를 가질 수 있습니다. 이것이 어떻게 작동하는지 살펴보고 런타임 성능 영향에 대해 이야기해 보겠습니다.
트레이트 구현하기
이제 Draw 트레이트를 구현하는 몇 가지 타입을 추가해 보겠습니다. Button 타입을 제공할 것입니다. 다시 말하지만, 실제로 GUI 라이브러리를 구현하는 것은 이 책의 범위를 벗어나므로, draw 메서드는 본문에 유용한 구현을 갖지 않습니다. 구현이 어떻게 보일지 상상하기 위해, Button 구조체는 Listing 17-7 에 표시된 것처럼 width, height, label 필드를 가질 수 있습니다.
파일 이름: src/lib.rs
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
}
}
Listing 17-7: Draw 트레이트를 구현하는 Button 구조체
Button의 width, height, label 필드는 다른 구성 요소의 필드와 다를 것입니다. 예를 들어, TextField 타입은 동일한 필드와 placeholder 필드를 가질 수 있습니다. 화면에 그리려는 각 타입은 Draw 트레이트를 구현하지만, Button이 여기에서 (언급했듯이 실제 GUI 코드가 없는) 와 같이 해당 특정 타입을 그리는 방법을 정의하기 위해 draw 메서드에서 다른 코드를 사용할 것입니다. 예를 들어, Button 타입은 사용자가 버튼을 클릭했을 때 발생하는 것과 관련된 메서드를 포함하는 추가적인 impl 블록을 가질 수 있습니다. 이러한 종류의 메서드는 TextField와 같은 타입에는 적용되지 않습니다.
우리 라이브러리를 사용하는 사람이 width, height, options 필드를 가진 SelectBox 구조체를 구현하기로 결정하면, Listing 17-8 에 표시된 것처럼 SelectBox 타입에 Draw 트레이트를 구현할 것입니다.
파일 이름: src/main.rs
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
}
}
Listing 17-8: gui를 사용하고 SelectBox 구조체에 Draw 트레이트를 구현하는 다른 크레이트
이제 우리 라이브러리의 사용자는 Screen 인스턴스를 생성하기 위해 main 함수를 작성할 수 있습니다. Screen 인스턴스에, 각 항목을 Box<T>에 넣어 트레이트 객체가 되도록 하여 SelectBox와 Button을 추가할 수 있습니다. 그런 다음 Screen 인스턴스에서 run 메서드를 호출할 수 있으며, 이 메서드는 각 구성 요소에서 draw를 호출합니다. Listing 17-9 는 이 구현을 보여줍니다.
파일 이름: src/main.rs
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();
}
Listing 17-9: 동일한 트레이트를 구현하는 서로 다른 타입의 값을 저장하기 위해 트레이트 객체 사용하기
우리가 라이브러리를 작성했을 때, 누군가가 SelectBox 타입을 추가할 수 있다는 것을 몰랐지만, Screen 구현은 SelectBox가 Draw 트레이트를 구현했기 때문에 새 타입에서 작동하고 그릴 수 있었습니다. 즉, draw 메서드를 구현합니다.
이 개념은 - 값의 구체적인 타입보다는 값이 응답하는 메시지에만 관심을 갖는 것 - 동적으로 타입이 지정된 언어의 덕 타이핑 개념과 유사합니다. 오리가 걷고 오리처럼 꽥꽥거린다면, 그것은 오리임에 틀림없습니다! Listing 17-5 의 Screen에서 run의 구현에서, run은 각 구성 요소의 구체적인 타입이 무엇인지 알 필요가 없습니다. 구성 요소가 Button의 인스턴스인지 SelectBox의 인스턴스인지 확인하지 않고, 단순히 구성 요소에서 draw 메서드를 호출합니다. components 벡터의 값의 타입으로 Box<dyn Draw>를 지정함으로써, 우리는 Screen이 draw 메서드를 호출할 수 있는 값을 필요로 하도록 정의했습니다.
트레이트 객체와 Rust 의 타입 시스템을 사용하여 덕 타이핑을 사용하는 코드와 유사한 코드를 작성하는 것의 장점은 런타임에 값이 특정 메서드를 구현하는지 확인할 필요가 없고, 값이 메서드를 구현하지 않지만 어쨌든 호출하는 경우 오류가 발생할까 걱정할 필요가 없다는 것입니다. Rust 는 트레이트 객체가 필요로 하는 트레이트를 값이 구현하지 않으면 코드를 컴파일하지 않습니다.
예를 들어, Listing 17-10 은 String을 구성 요소로 사용하여 Screen을 생성하려고 하면 어떻게 되는지 보여줍니다.
파일 이름: src/main.rs
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
Listing 17-10: 트레이트 객체의 트레이트를 구현하지 않는 타입을 사용하려는 시도
String이 Draw 트레이트를 구현하지 않기 때문에 이 오류가 발생합니다.
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`
|
= note: required for the cast to the object type `dyn Draw`
이 오류는 우리가 Screen에 전달하려는 것을 의도하지 않았고 다른 타입을 전달해야 하거나, Screen이 draw를 호출할 수 있도록 String에 Draw를 구현해야 함을 알려줍니다.
트레이트 객체는 동적 디스패치를 수행합니다
"제네릭을 사용하는 코드의 성능"에서 제네릭에 트레이트 바운드를 사용할 때 컴파일러가 수행하는 단형화 (monomorphization) 프로세스에 대한 논의를 기억하십시오. 컴파일러는 제네릭 타입 매개변수 대신 사용하는 각 구체적인 타입에 대해 함수와 메서드의 비제네릭 구현을 생성합니다. 단형화의 결과로 생성된 코드는 컴파일 시간에 어떤 메서드를 호출하는지 컴파일러가 알고 있는 정적 디스패치(static dispatch) 를 수행합니다. 이는 컴파일 시간에 어떤 메서드를 호출하는지 컴파일러가 알 수 없는 동적 디스패치(dynamic dispatch) 와 반대됩니다. 동적 디스패치의 경우, 컴파일러는 런타임에 어떤 메서드를 호출할지 알아낼 코드를 내보냅니다.
트레이트 객체를 사용할 때 Rust 는 동적 디스패치를 사용해야 합니다. 컴파일러는 트레이트 객체를 사용하는 코드와 함께 사용될 수 있는 모든 타입을 알지 못하므로, 어떤 타입에서 구현된 어떤 메서드를 호출해야 하는지 알 수 없습니다. 대신, 런타임에 Rust 는 트레이트 객체 내부의 포인터를 사용하여 어떤 메서드를 호출해야 하는지 알 수 있습니다. 이 조회는 정적 디스패치에서는 발생하지 않는 런타임 비용을 발생시킵니다. 동적 디스패치는 또한 컴파일러가 메서드의 코드를 인라인하도록 선택하는 것을 방지하여 일부 최적화를 방지합니다. 그러나 Listing 17-5 에서 작성한 코드에서 추가적인 유연성을 얻었고 Listing 17-9 에서 지원할 수 있었으므로, 이는 고려해야 할 트레이드 오프입니다.
요약
축하합니다! 서로 다른 타입의 값을 허용하는 트레이트 객체 사용 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.