안녕하세요 단테입니다. 오늘은 러스트로 OOP 스타일 코드를 작성하는 방법에 대해 알아보겠습니다.
1994년 출간된 Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides
책에서 말한 객체지향 프로그래밍의 정의를 따른다면 러스트는 객체지향 프로그래밍 언어입니다.
객체지향 프로그램은 객체로 이뤄져있다. 객체 패키지들은 데이터와 데이터를 처리하는 절차들로 이뤄져있따. 여기서 절차는 메소드나 operation이라고 부른다.
struct, enum은 데이터를 가질 수 있고 impl 블럭은 struct와 enum에 메소드를 제공할 수 있습니다. 메소드가 정의된 struct, enum을 객체라고 부르지는 않지만 객체와 동일한 기능을 제공합니다.
OOP에서 다루는 개념 중 하나는 캡슐화입니다. 구현의 상세 부분을 감추고 객체를 사용하는 코드에서 상세 부분에 접근하지 못하게 하는 것입니다. 캡슐화가 된 객체와 통신하는 방법은 public api를 사용하는 것 밖에 방법이 없습니다. 객체의 데이터에 직접 접근해서 값을 변경시키면 안됩니다. 캡슐화를 통해 프로그래머는 객체의 구현부를 변경하지 않더라도 객체 내부를 변경할 수 있습니다.
모듈 강의에서 pub 키워드를 사용해 public api를 만드는 방법에 대해 알아봤습니다. 러스트에서 pub 키워드를 사용하지 않는 모든 것은 private합니다.
AveragedCollection
struct 내부에 i32 타입 엘리먼트들로 이뤄진 벡터 리스트와 각 엘리먼트의 평균값을 계산한 필드가 선언되어 있습니다. 캐시된 평균값이 있기 때문에 평균값이 필요할 떄마다 매번 계산해주지 않아도 됩니다.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
pub 키워드로 선언된 객체는 외부에서 사용할 수 있습니다. struct 내부의 필드들은 private 으로 되어 있습니다. 아래 코드에서 add, remove, average 메소드는 public으로 선언했고 이 메소드들을 통해서만 AveragedCollection 객체의 필드를 수정할 수 있습니다. add, remove 메소드를 호출하면 private 메소드인 update_average 메소드가 자동으로 호출되어 average 필드를 업데이트합니다.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
fn main () {
}
캡슐화를 사용해 내부 구현을 숨겼기 때문에 public api인 add, remove, average의 시그니처가 변경되지 않는 조건 하에 내부 필드의 데이터 구조 또한 쉽게 변경할 수 있습니다. HashSet<i32>
를 Vec<i32>
타입 대신에 사용할 수 있습니다.
상속은 다른 객체의 정의부를 사용해 새로운 객체를 구성하는 것을 말합니다.
여기서 객체의 정의부
라고 표현한 객체는 부모 객체라고 합니다.
자녀는 부모가 구현한 정의를 재사용할 수 있기 때문에 코드를 재사용할 수 있다는 장점이 있습니다.
러스트에서는 특정 기능을 제공하는 커스텀 매크로를 사용하지 않는 한 기본적으로 부모 struct의 필드나 메소드를 상속할 수 있는 방법을 제공하지 않습니다.
하지만 상속을 대신할 수 있는 다른 방법을 제공합니다.
상속을 사용하는 두 가지 이유가 있습니다. 부모 객체에 구현된 코드를 자녀 객체에서 재사용하고 싶은 경우입니다.
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
Summary trait의 summarize 메소드를 구현함으로 인해 Summary trait 타입을 구현하는 객체는 summarize 메소드를 다른 코드를 작성하지 않아도 기본적으로 사용할 수 있습니다. 이는 자녀 클래스가 부모클래스를 상속하는 것과 동일한 기능을 제공합니다. 필요에 따라 summarize 메소드를 오버라이딩 할 수도 있으니 상속과 동일한 기능을 제공한다고 할 수 있습니다.
상속을 사용하는 또 다른 이유는 타입 시스템입니다. 자녀 객체의 타입을 부모 객체의 타입과 동일하게 사용할 수 있습니다. 다형성이라고 불리는 이 기능은 다양한 객체 타입을 동일한 런타임 환경에서 사용할 수 있게 합니다.
다형성은 상속보다 조금 더 보편적이고 일반적인 개념입니다. 러스트는 이러한 타입 시스템을 제너릭을 사용함으로 다양한 가능한 유형과 trait의 범위를 추상화하여 특정 유형이 제공해야 하는 것에 제약을 가합니다. 이러한 추상화를 bounded parametric polymorphism
이라고 부릅니다.
상속은 경우에 따라 유연한 설계를 하지 못하는 제약으로 작용하기도 합니다. 서브클래스가 슈퍼클래스의 모든 특성을 상속하면 안되는 경우에도 상속을 사용하면 이러한 제약을 피할 수 없습니다.
러스트에서는 trait 객체를 통해 상속을 대신합니다.
trait을 이제 특성이라고 부르겠습니다.
러스트에서 특성을 사용해 상속을 어떻게 대신하고 다형성을 만족시켰는지 확인해보겠습니다.
콜렉션 챕터를 다룰 떄 벡터의 한계점으로 다른 타입의 값들을 엘리먼트로 가지지 못하는 점을 이야기했었습니다. enum을 통해 다른 타입의 값을 variant로 갖게 해 해당 제약사항을 우회했습니다.
벡터에 사용할 수 있는 값들의 타입이 고정되어 변경되지 않아 컴파일 시점에 이러한 타입 정보들을 모두 유추할 수 있다면 좋은 방법입니다.
하지만 필요에 따라 우리가 만든 코드, 이를테면 라이브러리의 타입을 확장하여 사용하고 싶게 할 수 있습니다.
이러한 상황을 에시로 들기 위해 gui 라는 이름의 라이브러리 크레이트를 만들어보려고 합니다. 이 크레이트는 Button, TextField와 같은 타입을 제공하는데 유저들이 본인의 필요에 따라 Image나 SelectBox와 같은 타입을 추가하고 싶을 수 있습니다.
라이브러리를 제공하는 입장에서 유저가 추가하고 싶은 모든 필요상황을 만족시키는 타입을 모두 제공할 수는 없습니다. gui는 다른 타입들의 값들을 추적해야 하고각 값들에 대해 draw 메소드를 호출해야 한다는 것을 알고 있습니다. 우리는 각 타입에 draw 메소드를 호출할 때 무슨일이 일어나는지에 대해 정확히 알고 있을 필요는 없으며 단지 그 값이 우리가 호출할 수 있는 메서드를 가지고 있음을 알면 됩니다.
앞서 말한 것 러스트에서는 상속을 제공하지 않기 때문에 gui 라이브러리에 상속 대신에 타입을 확장할 수 있는 기능을 제공할 것입니다.
gui 라이브러리 구현을 위해 Draw 특성을 정의하고 draw 메소드를 선언할 것입니다.
그리고 특성 객체를 담을 수 있는 벡터를 정의하고 벡터 엘리먼트인 특성에서는 각 타입을 런타임에 구동시킬 수 있는 특성 메소드를 사용할 수 있게 할 것입니다.
Draw 특성은 draw 메소드를 제공합니다.
pub trait Draw {
fn draw(&self);
}
우리는 특성 객체를 & 레퍼런스나 Box<T>
스마트 포인터와 같은 포인터를 사용해서 생성할 수 있습니다. 그리고 dyn
키워드를 사옹해 유효한 특성을 명시할 것입니다.
특성 객체를 사용할 떄마다 러스트가 컴파일 타임에 유효성 검사를 시행해줍니다.
많은 프로그램에서 데이터와 동작 모두를 한 객체 안에 담기 때문에 객체라는 표현을 특정 역할을 수행하는 코드 블럭을 통칭할 때 보편적인 용어로 사용하고는 합니다.
러스트에서는 구조체(struct)와 열거형(enum)을 객체로 뭉뚱그려서 표현하는 것을 삼가야 합니다. 구조체나 열거형에서 구조체 필드의 데이터와 impl 블럭의 동작이 구분되기 때문입니다.
러스트의 특성 객체는 다른 언어의 객체와 다르게 데이터를 추가할 수 없습니다.
저도 이제부터 구조체와 열거형을 객체라고 부르기보다 구분해서 부르겠습니다.
아래에서 Screen 구조체를 만들 것입니다. 이 구조체는 components라고 부르는 벡터 타입의 필드르 가지고 있습니다. 이 벡터는 특성 객체인 Box<dyn Draw>
타입입니다.
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Screen 구조체에 run 메소드를 선언하고 components의 각 요소에 draw 메소드를 호출할 것입니다.
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw()
}
}
}
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();
}
}
}
위 코드는 제너릭 타입을 사용했는데 Sreen 인스턴스가 여러 개의 버튼 타입이나 TextField 타입을 components의 엘리먼트로 갖게 했습니다.
동일 타입의 콜렉션만 사용하는 경우 제너릭과 특성 범위를 사용하는 것이 컴파일 타임에 특정 타입으로 타입 정의가 단일화 된다는 면에서 더 유용합니다.
반면에 특성 객체를 사용하면 Screen 인스턴스는 Box<Button>
, Box<TextField>
두 타입의 엘리먼트를 포함하고 있는 Vec<T>
를 사용할 수 있습니다.
이제 Draw 특성을 구현한 타입을 추가해보겠습니다. Button 타입은 draw 메소드를 포함해야 하고 width, height, label 필드를 가지고 있습니다.
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
}
}
버튼의 width, height, label 필드는 다른 컴포넌트의 필드들과 다른점을 나타내는 부분입니다. TextField는 placeholder 필드를 추가로 가지고 있고 버튼이나 텍스트 필드등 서로 다른 타입은 스크린에 표시되기 위해 Draw trait을 구현해야 하나 실제 draw 메소드의 구현 코드는 각 타입마다 상이합니다.
예를 들어 Button 타입은 클릭 핸들러를 정의하기 위한 다른 impl 블럭을 가지고 있습니다.
유저가 우리가 작성한 라이브러리를 활용해 SelectBox 구조체를 구현한다고 가정할 떄 아래 예제 코드와 같이 width, height, options와 같은 새로운 필드를 만들 것입니다. 그리고 SelectBox 타입에 Draw trait을 구현할 것입니다.
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
}
}
라이브러리를 사용하는 유저(클라이언트라고 하겠습니다.)는 메인 함수에 Screen 인스턴스를 생성합니다. Screen인스턴스에 SelectBox, Button을 Box<T>
의 각 엘리먼트로 어 특성 객체를 생성합니다. Screen instace인 screen에 메소드를 실행해 각 component에 대해 draw 메소드를 실행합니다.
라이브러리를 작성할 때 어떤 유저가 SelectBox 타입을 추가할지에 대한 여부를 알지 못합니다만 Screen 구현체는 추가되는 타입들이 Draw trait을 구현할 것이라는 사실을 알기 때문에 새로운 타입에 대해서 draw 메소드를 호출할 수 있습니다.
값의 구체적인 타입이 아니라 값이 응답하는 메시지에만 관심을 갖는 이 개념은 덕 타이핑(duck typing) 개념과 유사합니다. 오리처럼 행동하면 오리로 간주한다는 이 용어는 Screen 객체의 run
메소드가 각 컴포넌트의 구체적인 타입을 알지못해도 동작할 필요가 없게 합니다. Box<dyn Draw>
를 components 벡터의 값 타입으로 선언했기 때문에 Screen 은 draw 메소드를 호출할 수 있는 값들만 가지고 있으면 됩니다.
제너릭에 대해 공부할 때 컴파일러가 제너릭의 trait bound를 사용할때 monomorphization process(단일형화)를 수행한다고 배웠습니다. 컴파일러는 제너릭 파라메터를 사용한 곳에 대해 함수나 메소드의 논제너릭 구현체를 생성합니다. 이러한 단일형화는 정적 디스패치(static dispatch)라고 부르며 컴파일러가 어떤 메소드를 호출하는지 컴파일 타임에 파악할 수 있습니다.
동적 디스패치(dynamic dispatch)는 반대로 런타임에 어떤 메소드가 호출될지 파악해야 하는 코드들을 컴파일러가 생성하게 됩니다.
특성객체를 사용할때는 동적 디스패치를 사용해야 합니다. 컴파일러는 특성 객체들을 사용할 때 사용하는 타입들에 대해 알 수 없기 때문에 각 타입에서 호출해야하는 구현된 메소드에 대한 정보를 알지 못합니다. 대신 런타임에 특성객체 내부에 있는 포인터를 이용해 어떤 메소드를 호출해야 할지 결정합니다. 이 탐색 과정은 정적 디스패치와 비교해서 런타임 비용을 소비합니다. 정적 디스패치와 동적 디스패치는 유연성과 성능에 대한 트레이드 오프 관계를 가지고 있습니다.
오늘은 러스트에서 OOP를 지원하는 방법에 대해 알아보았습니다. 다음 포스팅에서 OOP 구현 예제 코드를 보며 실제로 어떻게 사용되는지 알아보겠습니다.