[Rust] 객체 지향 프로그래밍

16 minute read

러스트는 객체 지향 프로그래밍도 가능하다.

가능은 하다..

객체 지향 언어의 특성

객체 지향 언어는

  • 객체
  • 캡슐화
  • 상속

을 지원한다.

GOF의 OOP 정의

객체-지향 프로그램은 객체로 구성된다. 객체는 데이터 및 이 데이터를 활용하는 프로시저를 묶는다. 이 프로시저들은 보통 메소드 혹은 연산 (operation) 으로 불린다.

이 정의에 따라 러스트도 객체 지향적이라고 할 수 있다.

구조체와 열거형으로 데이터를 표현하고 impl 블럭으로 메서드를 제공한다.

캡슐화

객체 사용자가 그 객체의 세부 구현에 접근하는 것을 막는 것이다

사용자가 코드 내부 데이터나 동작을 변경하는 것을 막고 공개된 API만 사용하게 하면, 사용자 코드는 그대로 두면서 객체 내부를 변경할 수 있다.

러스트의 캡슐화

모듈, 타입, 함수, 메서드는 기본적으로 비공개이고 pub 키워드로 공개 여부를 결정한다.

아래 코드는 벡터의 값의 평균값을 캐시하는 구조체이다.

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;
    }
}

사용자는 공개 메서드인 add, remove, average를 통해서만 객체의 인스턴스를 수정할 수 있다.

나중에 벡터를 해시맵으로 리팩터링한다고 해도, 이 객체를 사용하는 코드는 변경할 필요가 없다.

상속

한 객체가 다른 객체의 데이터와 동작을 재정의할 필요없이 상속해서 가져다 쓸 수 있는 매커니즘이다.

러스트의 구조체는 필드와 메서드 구현을 상속받을 수 없다.

대신에 다른 방법으로 상속의 장점을 취한다.

상속의 두 가지 장점

  1. 코드를 재사용할 수 있다.

러스트는 기본 트레잇 메서드로 코드를 공유할 수 있다.

  1. 자식 타입을 부모 타입처럼 사용할 수 있다.(다형성)

제약이 있는 제네릭 타입인 트레잇 바운드로 매개변수형 다형성을 구현한다.

언어 설계에서 상속의 인기가 하락하는 이유

  1. 하위 클래스가 부모 클래스의 모든 특성을 상속받아 필요보다 많은 코드를 공유하게 된다.

  2. 단일 상속만 허용하는 언어들은 프로그램 디자인의 유연성을 떨어뜨린다.

러스트에서는 상속 대신 트레잇 객체를 사용한다.

트레잇 객체를 사용하여 다른 타입 간의 값을 허용하기

gui 라이브러리 만들기

gui 라이브러리는 Button, TextField 같은 요소를 draw 메서드 호출로 화면에 그린다.

상속 기능이 있는 경우

상속이 있는 언어는 draw 메서드를 가진 Component 클래스를 만든다.

요소들이 이를 상속받아 draw 메서드를 오버라이딩해서 각각의 고유 동작을 정의한다.

라이브러리는 모든 요소를 Component로 다루고 draw 메서드를 호출하기만 한다.

공통 동작을 위한 트레잇 정의

러스트에서 gui 라이브러리를 구현하기 위해 draw 메서드를 갖는 Draw 트레잇을 정의한다.

pub trait Draw {
    fn draw(&self);
}

components 벡터를 가진 Screen 구조체를 정의한다.

Box<Draw>가 트레잇 객체이다.

pub struct Screen {
    pub components: Vec<Box<Draw>>,
}

Screen 구조체의 run 메서드를 정의한다.

run은 각 componentdraw 메서드를 호출한다.

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

위는 트레잇 바운드와 제네릭 타입 파라미터를 사용하는 구조체 정의와 다르게 동작한다.

제네릭 타입 파라미터는 한 개의 구체 타입만 넣을 수 있지만, 트레잇 객체는 런타임에 여러 구체 타입을 넣어 사용 가능하다.

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();
        }
    }
}

위의 구현은 T가 전부 Button이거나 TextField인 컴포넌트 리스트를 갖게 한다.

제네릭은 컴파일 타임에 구체 타입 사용을 위해 단형성화(monomorphize)된다.

트레잇 구현하기

Draw 트레잇을 구현하는 Button 구조체를 만든다.

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
    }
}

사용자가 SelectBox 구조체를 구현한다면 다음과 같을 것이다.

extern crate gui;
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 인스턴스를 만들기 위해 main 함수를 구현한다.

SelectBoxButton가 트레잇 객체가 되도록 하기 위해 각각을 Box<T> 안에 넣는다.

use gui::{Screen, Button};

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();
}

ScreenDraw 트레잇의 draw 메서드가 구현된 구조체 타입 모두에 작동한다.

이 특성은 덕 타이핑이다.

트레잇 객체는 동적 디스패치를 실행한다

제네릭 타입은 컴파일 시 모두 구체타입으로 변경되는 정적 디스패치가 실행된다.

러스트는 트레잇 객체를 사용하는 코드의 타입과 메서드를 컴파일 타임에 알 수 없다.

대신에 트레잇 객체 내 포인터로 어떤 메서드가 호출될 지 런타임에 알아낸다.

또한, 동적 디스패치 사용 시 유연한 코드를 작성할 수 있지만, 메서드 인라인화를 할 수 없어서 몇가지 최적화를 놓친다.

트레잇 객체 사용 시 객체 안전성이 필요하다.

트레잇의 모든 메서드가 다음 속성을 가진다면 해당 트레잇은 객체-안전하다.

  • 반환 타입이 Self가 아니다.
  • 제네릭 타입 매개변수가 없다.

트레잇 객체 사용 시, 트레잇에 구현된 구체 타입을 알 수 없다.

트레잇의 타입의 별칭인 Self 타입도 알 방법이 없다.

제네릭 타입 파라미터도 마찬가지로 트레잇 객체 사용 시 구체 타입을 알 수 없어서 제네릭 타입을 특성할 수 없다.

객체 안전하지 않은 트레잇의 예로 Clone 트레잇이 있다.

pub trait Clone {
    fn clone(&self) -> Self;
}

Screen 구조체의 Draw 트레잇을 Clone 트레잇으로 대체한다면 컴파이 에러가 난다.

pub struct Screen {
    pub components: Vec<Box<Clone>>,
}

반환 타입이 SelfClone은 객체 안전하지 않기 때문이다.

객체 지향 디자인 패턴 구현하기

상태 패턴(State Pattern)을 구현한다.

상태 패턴은 내부 상태에 따라 객체의 동작이 변경된다.

각 상태 객체는 다른 상태로 이전을 담당한다.

블로그 게시물 올리기 작업 흐름

  1. 빈 초안으로 시작
  2. 초안 완료 시 게시물 검토
  3. 게시물 승인 시 게시
  4. 오직 게시된 블로그 게시물만 내용을 반환할 수 있음

구현된 blog API는 아래와 같이 사용된다.

extern crate blog;
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

사용자는 Post 타입으로만 상호작용을 하지만, 내부 상태는 초안, 리뷰 대기 중, 게시됨 중 하나의 상태값을 가진다.

Post를 정의하고 초안 상태의 인스턴스 생성하기

pub struct Post {
    state: Option<Box<State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

Post 구조체, Post 인스턴스를 만드는 new 함수, State 트레잇과 Draft 구조체를 정의한다.

Post 생성 시 내부 상태는 항상 Draft로 시작하게 된다.

게시물 콘텐츠에 글 저장하기

impl Post {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

add_text 메서드는 Post 인스턴스를 변경하기에 가변 참조자 self를 필요로 한다.

초안 게시물의 내용이 비어있음을 보장하기

임시로 빈 스트링 슬라이스를 반환하는 content 메서드를 구현한다.

impl Post {
    // --snip--
    pub fn content(&self) -> &str {
        ""
    }
}

게시물 리뷰 요청으로 내부 상태 변경하기

리뷰 요청을 하는 request_review 메서드로 Draft 상태를 PendingReview 상태로 변경한다.

impl Post {
    // --snip--
    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<State> {
        self
    }
}

State 트레잇의 request_review 메서드는 self:Box<Self>를 인자로 받는다.

이 문법은 해당 타입을 보유한 Box 상에서만 메서드 호출이 허용됨을 뜻한다.

Box<Self>의 소유권을 가져가서 Post 이전 상태를 무효화하고 새 상태로 변화시킨다.

Option 타입은 stateNone 값으로 설정해서 이전 상태를 사용할 수 없게 한다.

content의 동작을 변경하는 approve 메소드 추가하기

impl Post {
    // --snip--
    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}


trait State {
    fn request_review(self: Box<Self>) -> Box<State>;
    fn approve(self: Box<Self>) -> Box<State>;
}

struct Draft {}

impl State for Draft {
    // --snip--
    fn approve(self: Box<Self>) -> Box<State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    fn approve(self: Box<Self>) -> Box<State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<State> {
        self
    }
}

PendingReview 상태일 때만 approve 메서드 호출 시 Published 상태로 이전된다.

Published 상태일 때 content 필드의 값을 반환하게 한다.

impl Post {
    // --snip--
    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(&self)
    }
    // --snip--
}

stateOption<Box<State>>이므로 as_ref를 호출하면 Option<&Box<State>>가 반환된다.

state는 항상 Some 값이므로 unwrap으로 &Box<State>를 가져온다.

&Box<State>content 호출 시 역참조 강제로 인해 content 메서드는 State 트레잇을 구현하는 타입에서 호출된다.

trait State {
    // --snip--
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--
struct Published {}

impl State for Published {
    // --snip--
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

content 메서드 호출 시 DraftPendingReview 상태인 경우 기본 구현이 실행된다.

Published 상태인 경우 post.content의 값을 반환한다.

상태 패턴의 기회비용

상태 패턴 대신 match를 사용한다면, 여러 겹의 match 문을 작성해야 될 것이다.

상태 패턴을 사용하면 기능을 추가하기 쉽다.

하지만, 단점도 존재한다.

상태 간 전환으로 인해, 상태들이 서로 묶이게 된다.

또한, 로직도 중복된다.

상태와 동작을 타입으로 인코딩하기

각 상태를 다른 타입들로 표현할 수 있다.

이 방식은 러스트의 타입 검사 시스템을 이용할 수 있다는 장점이 있다.

pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

PostDraftPost는 더이상 state 필드를 갖지 않는다.

상태가 구조체 그 자체이기 때문이다.

Post::new 함수는 DraftPost 인스턴스를 반환한다.

DraftPostadd_text 함수로 글을 추가할 수 있지만, content 메서드가 없어서 실수로 출력될 일을 원천봉쇄한다.

상태 이전 대신 다른 타입으로 전환하기

impl DraftPost {
    // --snip--

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}

DraftPostrequest_review 메서드 호출로 자신의 내용을 담은 PendingReviewPost를 반환한다.

PendingReviewPost 또한 content 메서드가 없어서 내용을 읽으려는 시도도 할 수 없다.

approve 메서드 호출을 통해서만 내용을 확인할 수 있는 Post 인스턴스를 얻을 수 있다.

blog 크레이트를 사용하는 main 변경

extern crate blog;
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}

메서드들이 내부를 변경하는 대신, 새로운 인스턴스를 반환하기 때문에, let post =를 사용한다.

정리

러스트에서는 코드에 유연성을 주는 객체 지향 패턴을 사용할 수 있다.

하지만, 객체 지향 패턴 사용으로 러스트가 주는 이점을 잃을 수도 있어서 상황에 맞게 코드를 작성해야 한다.

Categories:

Updated: