본문 바로가기

Rust로 객체지향 프로그래밍 흉내내기

기술적인 이야기/기타 개발 2020. 10. 26.
반응형

러스트(Rust)는 객체 지향 프로그래밍(OOP - Object Oriented Programming) 언어가 아닙니다. 애초에 클래스(class) 문법 조차 없고 따라서 상속(inheritance) 개념도 없습니다. 그저 구조체에 메서드를 구현할 수 있을 뿐입니다. OOP에서 정말 중요한 개념이 없는데 과연 러스트는 OOP 패러다임으로 코딩하는 것이 가능할지를 그냥 맛만 봅시다.

트레잇(trait)

러스트에는 트레잇(trait)이라는 개념이 있습니다. 이 기능은 인터페이스를 설계하는 기능입니다. 이를 이용해 특정 타입에 인터페이스에 맞는 메서드를 구현하도록 강제하기 위해서 사용할 수 있습니다. 이런 면에서 비교하자면 스위프트(Swift)의 프로토콜(Protocols)과 상당히 닮아 있습니다.

다짜고짜 트레잇을 사용하는 예제부터 봅시다.

trait SpecialType {
    fn do_special_work(&mut self) -> String;
    fn dump(&self);
}

위 코드는 SpecialType이라는 이름의 트레잇을 정의합니다. 이 트레잇에는 do_special_work()dump()의 두 가지 메서드의 인터페이스를 정의하고 있습니다.

아직까지 이 코드 만으로 뭔가를 할 수는 없습니다. 계속해서 살을 덧붙여 봅시다.

구조체에 트레잇 붙여보기

이제 어떤 구조체를 하나 만들어 볼 차례입니다.

struct SomeGoodSpecial {
    name: String,
}

이 구조체는 name이라는 프로퍼티만 존재하고 별 다른 기능(메서드)은 없습니다.

이제 이 구조체에 특정한 기능을 붙여봅시다. 그런데 그냥 메서드를 구현하는 게 아니라 위에서 만든 트레잇에 맞는 인터페이스로 메서드를 구현해 봅시다.

impl SpecialType for SomeGoodSpecial {
    fn do_special_work(&mut self) -> String {
        self.name.push_str(" Worker");
        String::from(format!("{}", self.name))
    }

    fn dump(&self) {
        println!("Current my name is {}", self.name);
    }
}

위처럼 impl - for 구문으로 특정 타입에 트레잇 인터페이스를 부여할 수 있습니다. 이 코드로 인해 이제 SomeGoodSpecial 구조체는 SpecialType 트레잇 규격에 맞는 기능을 가지게 되었습니다.

당연하게도 트레잇을 만족하지 않으면 코드에서 에러가 납니다. 즉 둘 중 하나의 메서드가 빠지거나 이름이나 매개변수가 다르면 빌드가 안 됩니다.

이제 추가한 기능을 시험해 봅시다.

let mut good = SomeGoodSpecial { name: String::from("Conrad") };
good.dump();  
// 콘솔에 "Current my name is Conrad" 표시

good.do_special_work();

good.dump();
// 콘솔에 "Current my name is Conrad Worker" 표시

여기까지는 상당히 직관적인 모습입니다. 그런데 이게 OOP랑 무슨 관계가 있는 것일까요?

다른 타입도 하나 더

이제 좀 더 OOP와의 관련성을 알아보기 SpecialType 트레잇을 따르는 다른 구조체도 추가로 하나 더 정의해 보겠습니다.

struct SomeBadSpecial {
    name: String,
}

impl SpecialType for SomeBadSpecial {
    fn do_special_work(&mut self) -> String {
        String::from(format!("{} Player", self.name))
    }

    fn dump(&self) {
        println!("Current my name is {}", self.name);
    }
}

두 메서드 중 하나의 내용이 살짝 다르지만 앞서 구현한 SomeGoodSpecial 구조체와 비슷하게 트레잇을 구현하고 있습니다. 따라서 당연하게도 비슷하게 잘 돌아갑니다.

let mut bad = SomeBadSpecial { name: String::from("Konrad") };
bad.dump();
// 콘솔에 "Current my name is Konrad" 표시

println!("result: {}", bad.do_special_work());
// 콘솔에 "result: Konrad Player" 표시

bad.dump();
// 콘솔에 "Current my name is Konrad" 표시

앞서 구현했던 SomeGoodSpecial과 이번에 구현한 SomeBadSpecial 구조체는 SpecialType 트레잇을 따르는 동일한 메서드를 가지고 있지만 동작은 다릅니다. 따라서 아직까지 특별한 점이 느껴지지 않을 수 있습니다.

닮은 점을 활용해보기

SomeGoodSpecial 구조체와 SomeBadSpecial 구조체의 메서드 중 dump() 메서드는 기능이 동일합니다. 즉 중복 코드가 존재합니다. 이걸 더 간단하게 하나로 합칠 수 있을까요?

일단 트레잇에도 구현 코드를 넣을 수 있습니다. 아래와 같이 dump() 메서드의 기본 동작을 구현해 볼 수는 있습니다.

trait SpecialType {
    fn do_special_work(&mut self) -> String;
    fn dump(&self) {
        println!("Current my name is {}", self.name);
    }
}

다만 여기에는 한 가지 문제가 있습니다. 트레잇에 구현된 코드는 트레잇 환경 내에서 동작할 수밖에 없습니다. 따라서 위에서 구현된 dump() 메서드 내에서 name을 참조하려는 코드는 컴파일 오류를 일으킵니다. 이 트레잇 내에서는 name이라는 문자열 프로퍼티가 존재하지 않으니깐요.

그렇다면 이를 돌려서 구현해 봅시다. 트레잇을 이용해 이름을 돌려주는 메서드를 구현하도록 하는 것을 생각해 볼 수 있습니다. 아래와 같이 SpecialType 트레잇을 수정했습니다.

trait SpecialType {
    fn do_special_work(&mut self) -> String;
    fn get_name(&self) -> &String;
    fn dump(&self) {
        println!("Current my name is {}", self.get_name());
    }
}

이제 SpecialType 트레잇을 따르는 SomeGoodSpecialSomeBadSpecial 구현부에 get_name() 메서드를 추가합니다. 그리고 기본 구현이 된 dump() 메서드 구현 코드를 각각에서 제거합시다.

impl SpecialType for SomeGoodSpecial {
    fn do_special_work(&mut self) -> String {
        self.name.push_str(" Worker");
        String::from(format!("{}", self.name))
    }

    fn get_name(&self) -> &String {
        &self.name
    }
}

impl SpecialType for SomeBadSpecial {
    fn do_special_work(&mut self) -> String {
        String::from(format!("{} Player", self.name))
    }

    fn get_name(&self) -> &String {
        &self.name
    }
}

이렇게 하면 이제 dump()는 하나의 코드로 동작하는 기본 메서드가 되었습니다.

let good = SomeGoodSpecial { name: String::from("Conrad") };
good.dump();

let bad = SomeBadSpecial { name: String::from("Konrad") };
bad.dump();

이것만 보면 마치 부모 클래스에서 상속받은 메서드를 자식 클래스에서 함께 공유하며 활용하는 모습을 떠 울릴 수 있지 않을까요?

dump()가 없어지고 get_name()이 생겼으니 실질적으로 바뀐 것은 없다고요? 코드 양은 늘어났고요? 어... 음... 그럴지도요. 🤔

닮은 점을 활용해보기 2

트레잇에서 기본 구현을 하는 형태를 살펴봤지만 이제는 외부 함수를 이용해 동일한 트레잇의 다른 타입 인스턴스들이 함께 사용할 수 있는 함수를 한번 구현해 봅시다.

fn dump_special_type(special: &impl SpecialType) {
    special.dump();
}

이 함수는 매개변수로 impl SpecialType 형식을 빌려 받는 형태로 선언되어 있습니다. 쉽게 표현해서 special 매개변수는 SpecialType 트레잇을 구현한 아무 인스턴스를 의미합니다.

이제 각 구조체의 dump() 메서드를 직접 호출하지 말고 대신 위 함수를 이용해 봅시다.

let good = SomeGoodSpecial { name: String::from("Conrad") };
dump_special_type(&good);
// 콘솔에 "Current my name is Conrad" 표시

let bad = SomeBadSpecial { name: String::from("Konrad") };
dump_special_type(&bad);
// 콘솔에 "Current my name is Konrad" 표시

이제 하나의 함수를 이용해 두 가지 타입의 같은 이름의 메서드를 호출할 수 있게 되었습니다. OOP에 대해 좀 아신다면 뭔가 공통된 부모 클래스를 베이스 타입으로 지정하는 것과 비슷한 느낌을 받을 수 있지 않을까요?

좀 더 풍부한(?) 예를 위해 SpecialType 트레잇을 활용하는 함수를 하나 더 만들어 봅시다.

fn work_special_type(special: &mut impl SpecialType) {
    special.dump();
    special.do_special_work();
    special.dump();
}

새로 정의한 함수는 do_special_work() 메서드가 멤버를 변조하는 가변(Mutable) 메서드라는 점 때문에 매개변수에 mut가 붙어있다는 점이 다릅니다. 어쨌든 이 함수도 비슷하게 사용할 수 있습니다.

let mut good = SomeGoodSpecial { name: String::from("Conrad") };
work_special_type(&mut good);

let mut bad = SomeBadSpecial { name: String::from("Konrad") };
work_special_type(&mut bad);

굳이 결과를 적지 않아도 생각하는 그대로 표시가 됩니다.

이렇게 베이스 클래스를 활용하는 것과 비슷한 방법을 러스트 식으로 억지로(?) 구현해서 살펴봤습니다.

728x90

결론

그래서 이 정도면 OOP의 냄새가 느껴질 수도 있을 것 같습니다.

아... 전혀 OOP 느낌이 안 난다고요? 뭐 그것도 틀리진 않죠. 원칙적으로 러스트는 클래스도 없고 상속도 없으니까요.

엄밀히 말해서 이런 식의 디자인은 다형성(polymorphism) 패러다임에 가깝습니다. 즉 트레잇으로 기능 단위를 정의해서 여러 기능을 하나의 구조체에 부여하는 방식으로 디자인하는 방식입니다. 이런 패러다임은 스위프트의 POP(Protocol Oriented Programming) 패러다임과도 유사합니다. 단지 스위프트 방식이 좀 더 유연하고 쉽다는 점이 차이가 있겠네요.

객체 지향과 다형성 지향은 여러 장단점이 있을 수 있습니다. 더 자세한 것은 아직 제 지식의 한계 상 무리이고 이 정도로써 맛보기를 마무리하면 될 것 같습니다.

보너스

앞서 선언한 dump_special_type() 함수와 work_special_type() 함수의 매개변수를 아래와 같은 식으로 바꿀 수도 있습니다.

fn dump_special_type(special: &dyn SpecialType) {
    special.dump();
}

fn work_special_type(special: &mut dyn SpecialType) {
    special.dump();
    special.do_special_work();
    special.dump();
}

앞서 살펴본 코드와 구현 내용 자체는 완전히 동일합니다. 단지 매개변수 선언에서 impl 대신 dyn을 표기한 점이 다릅니다.

dyn이라는 이름에서 느낄 수 있을지도 모르겠는데 이는 트레잇에 다이내믹 디스패치(Dynamic Dispatch)를 적용하는 키워드입니다. 이 글에서는 더 이상 자세히 다루지는 않는데, 기회가 된다면 트레잇에 대해 좀 더 자세히 살펴보면서 다룰 기회가 있을지도 모르겠네요.

이 글과 관련된 글들

 

러스트의 구조체 살펴보기

러스트(Rust)는 다양한 타입을 제공합니다. 원시 타입의 종류도 많지요. 그리고 튜플과 같이 다양한 타입을 묶을 수 있는 그룹형 타입도 있습니다. 그렇다면 러스트의 복잡한 자료구조는 튜플만

seorenn.tistory.com

 

러스트의 가변성(Mutability) 이야기

가변성(Mutability)과 불변성(Immutability)은 현대적인 언어에서는 제법 잘 다뤄지는 주제일지도 모릅니다. 그도 그럴게 코드의 구조나 성능이나 메모리 효율성이나 관리 등 다양한 면에서 영향을 끼

seorenn.tistory.com

728x90
반응형

댓글