이 시리즈는 Rust 공식문서를 통해 공부한 흔적임을 밝힙니다.
Rust가 어떤 면에서 함수형 언어의 특징들을 가지고 있던 것과 같이
어떤 면에서는 객체지향 언어의 특징들을 가지고 있다.
Rust의 객체지향적인 특징들에 대해 알아보자.
Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides이 1994년에 저술한
《Design Patterns: Elements of Reusable Object-Oriented Software>라는 책에서는
객체지향 프로그래밍을 다음과 같이 정의하고 있다.
Object-oriented programs are made up of objects. An object packages both data and the procedures that operate on that data. The procedures are typically called methods or operations.
대충 자료와 그것에 수행되는 메서드 또는 연산이라고 불리는 프로시저로 구성된 객체로 이루어진다,
이런 이야기를 하고 있다.
Rust에서는 그것을 '객체'라고 부르지는 않지만 구조체와 열거형이 객체와 같이 작용한다.
이런 면에서 Rust는 객체지향적이라고 볼 수 있다.
객체는 자료와 연산이 결합되어 있지만 Rust의 구조체와 열거형은 그들이 분리되어 있다는 점에서
일반적인 객체지향의 객체와는 조금 차이가 있지만 말이다.
캡슐화는 중요한 객체지향 개념 중 하나로,
객체를 사용하는 코드는 객체 내부의 상세 구현에 접근하지 못하도록 하는 것이다.
코드가 통과하지 못하는 캡슐로 객체를 감싸고 있는 느낌이다.
객체를 사용하고자 하는 코드는 객체가 제공하는 API를 통해서만 객체에 접근할 수 있을 뿐
그 내부의 값을 임의로 접근하거나 변경하지 않는다.
이로서 객체 내부의 코드와 외부의 코드를 별개로 관리하고 리팩토링 할 수 있다.
객체 내부의 변경으로 인해 외부의 코드까지 싹 다 엎어야 하는 상황을 방지하는 것이다.
우리는 모듈에 대해 이야기하며 이러한 개념을 본 적 있다.
pub
키워드를 통해 API로 제공해야만 외부에서 접근할 수 있고
모듈 내 나머지 부분은 외부에서 접근할 수 없다는 것은 객체지향의 캡슐화에 해당한다.
상속은 객체가 다른 객체가 가진 자료나 연산을 물려 받기 위해 사용하는 개념이다.
부모 객체를 상속해서 자식 객체를 구현한다고 하는데,
이로써 공통적인 부분을 다시 구현하지 않고 코드 중복을 줄일 수 있다.
만약 객체지향 언어가 지원해야 할 필수 기능 중에 상속이 있다면
Rust는 객체지향 언어라고 볼 수 없다.
Rust는 상속을 지원하지 않는다.
상속은 코드 재사용과 다형성을 위해 채택된 개념이다.
그런데 코드 재사용과 다형성을 지원할 수 있는 방법이 상속만 있는 것은 아니다.
상속을 사용하면 필요 이상으로 많은 코드를 공유하게 되고
때로는 자식 객체에게 넘어올 필요 없는, 심지어 넘어와서는 안되는 부모 객체의 특성이
자식 객체로 넘어와 버그로 작용하거나 예측하지 못한 오류를 야기할 수 있다.
최근에는 상속을 좋은 프로그래밍 디자인 솔루션이 아니라고 여기는 언어도 많이 있다.
Rust도 그 중 하나로, 상속이 아닌 다른 방법으로 코드 재사용과 다형성을 지원한다.
우리는 그것을 트레이트 객체라고 부른다.
상속과 같은 기능이 필요한 상황과 그것을 Rust에서 트레이트 객체로 어떻게 구현하는지
간단한 예제를 작성해가며 알아보자.
이를 위해 가상의 라이브러리를 가정하겠다.
이 라이브러리는 기능의 구현이 목적이 아니므로 실질적인 기능은 구현하지 않을 것이다.
우리가 구현할 라이브러리는 GUI를 다루는 라이브러리다.
Button
, Image
등의 Component가 존재하고
그것들을 화면에 그려주는 draw
연산이 존재한다.
다음과 같이 새 패키지를 만들어 코드를 조금씩 추가해나가겠다.
peter@hp-laptop:~/rust-practice$ mkdir chapter17
peter@hp-laptop:~/rust-practice/chapter17$ cd chapter17
peter@hp-laptop:~/rust-practice/chapter17$ cargo new draw_gui --lib
Created library `draw_gui` package
peter@hp-laptop:~/rust-practice/chapter17$ cd draw_gui/
peter@hp-laptop:~/rust-practice/chapter17/draw_gui$
상속을 지원하는 언어였다면
draw
연산을 포함하는 Component
라는 부모 객체를 생성하고
Button
, Image
등의 Component가 그것을 재정의하도록 구현할 것이다.
하지만 Rust에서는 draw
메서드를 정의하는 Draw
트레이트를 선언한다.
src/lib.rs
pub trait Draw { fn draw(&self); }
트레이트 객체는 일반 트레이트를 정의하는 것처럼 정의하지만
트레이트는 어떤 자료형에 구현해서 사용해야 하는 반면
트레이트 객체는 있는 그대로 사용하되, &
나 스마트 포인트와 같은 포인터를 이용해야 한다.
그리고 dyn
키워드로 어떤 트레이트인지 명시해주어야 한다.
트레이트 객체는 상속과는 달리 자료를 갖지 않고 행위에 대한 추상화만 제공함을 유의하자.
트레이트 객체를 사용하는 예제로,
Draw
트레이트를 구현하는 어떤 것이든 담을 수 있는 벡터를 가진 Screen
구조체를 생성한다.
화면에 그래픽을 그려주는 기능을 가정한다.
src/lib.rs
// snip pub struct Screen { pub components: Vec<Box<dyn Draw>>, } impl Screen { pub fn run(&self) { for component in self.components.iter() { component.draw(); } } }
언뜻 보기엔 트레이트 경계가 적용된 제네릭 자료형을 사용하는 것과 비슷해보인다.
그러니까, 다음의 코드와 큰 차이가 없어 보일 수 있다.
using generics and trait bounds
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
에는 Draw
를 구현한 동일 자료형만 넣을 수 있다는 것이다.
Button
도 넣고 Image
도 넣고 하고 싶은데
Button
이면 Button
, Image
면 Image
, 이렇게 한 가지만 넣을 수 있게 된다.
우리가 원하는 건 이런 게 아니다.
트레이트 객체를 사용하면 우리가 의도했던 대로 구현할 수 있다.
물론 그것을 구현한 단 하나의 자료형만 사용할 경우 제네릭을 사용하는 쪽이 이득이다.
트레이트의 구현은 우리가 트레이트에 대해 배울 때 했던 것과 같이 하면 된다.
물론 우리는 그 구체적인 기능까지는 구현하지 않을 것이다.
src/lib.rs
// snip pub struct Button { pub width: u32, pub height: u32, pub label: String, } impl Draw for Button { fn draw(&self) { println!("draw button {} (size: {} * {})", self.label, self.width, self.height); } }
Button
에 대한 클릭 메서드와 같은 기능도 생략했다.
그것에 대한 연습은 트레이트에서 충분히 되었으리라 믿는다.
그리고 이런 트레이트 구현은 main.rs
에서도 가능하다.
peter@hp-laptop:~/rust-practice/chapter17/draw_gui$ vi src/main.rs
src/main.rs
use draw_gui::Draw; struct SelectBox { width: u32, height: u32, options: Vec<String>, } impl Draw for SelectBox { fn draw(&self) { println!("draw select box (size: {} * {})", self.width, self.height); for item in &self.options { println!("- option: {}", item); } } }
이제 Screen
값에 Draw
를 구현한 녀석들을 넣어보도록 하자.
src/main.rs
use draw_gui::{Draw, Screen, Button}; // snip 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(); }
peter@hp-laptop:~/rust-practice/chapter17/draw_gui$ cargo run
Compiling draw_gui v0.1.0 (/home/peter/rust-practice/chapter16/draw_gui)
Finished dev [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/draw_gui`
draw select box (size: 75 * 10)
- option: Yes
- option: Maybe
- option: No
draw button Ok (size: 50 * 10)
peter@hp-laptop:~/rust-practice/chapter17/draw_gui$
Screen
은 Draw
를 구현한 모든 자료형을,
심지어 라이브러리 밖에서 제3자가 구현했을 수 있는 SelectBox
마저 담을 수 있다.
Draw
트레이트를 구현했지만 draw
메서드를 구현하지 않았다면 컴파일러가 잡아줄 것이고
Draw
트레이트를 구현하지 않은 녀석을 components
에 넣으려고 해도 그럴 것이다.
즉, 동적 자료형 언어에서 사용하는 duck typing처럼 구체화되지 않은 자료형을 사용할 수 있지만
그것과 달리 모든 자료형 검사가 컴파일 시간에 이루어져 성능 저하를 막을 수 있다.
우리가 제네릭에 대해 배울 때 제네릭은 정적 호출을 하여 성능 저하를 막는다고 했다.
컴파일 시점에 코드를 정적 분석하여 그 제네릭의 실제 자료형으로 치환함으로써
실행 시간에 그것이 무엇인지 분석할 필요가 없게 한다는 것이다.
그런데 트레이트 객체의 경우 이와 반대로 동적 호출을 사용한다.
실행 시간에 트레이트의 객체의 포인터를 통해 호출할 메서드를 찾는 것이다.
코드의 유연성을 향상시키기 위해 어느 정도의 효율성은 포기한 것이다.
트레이트 객체는 object-safe한 트레이트만을 사용할 수 있다.
어떤 트레이트가 object-safe한지 확인하기 위한 복잡한 규칙이 존재하지만
실질적으로 단 두 개의 규칙만 신경쓰면 된다.
트레이트의 모든 메서드가 다음을 만족하면 object-safe하다고 할 수 있다.
Self
가 아니다.예를 들어 Clone
같은 트레이트는 object-safe하지 않아 트레이트 객체로 사용할 수 없다.
Clone
의 메서드 clone
의 시그니처가 다음과 같기 때문이다.
pub trait Clone {
fn clone(&self) -> Self;
}
object-safety에 대한 좀 더 자세한 내용이 알고 싶다면
Rust RFC 255 문서를 참조하도록 하자.
이 포스트의 내용은 공식문서의 17장 1장 Characteristics of Object-Oriented Languages & 17장 2장 Using Trait Objects That Allow for Values of Different Types에 해당합니다.