객체 지향 언어의 특성
데이터와 동작의 결합·캡슐화·상속이라는 객체 지향 특성을 Rust의 구조체·impl·트레이트 설계와 비교합니다.
객체 지향 프로그래밍(object-oriented programming, OOP)은 프로그램을 모델링하는 한 방식입니다. 프로그래밍 개념으로서 객체는 1960년대에 프로그래밍 언어 Simula에서 처음 도입되었습니다. 그 객체들은 객체끼리 서로 메시지를 주고받는 앨런 케이(Alan Kay)의 프로그래밍 구조에 영향을 주었습니다. 앨런 케이는 이 구조를 설명하려고 1967년에 객체 지향 프로그래밍이라는 용어를 만들었습니다. OOP가 무엇인지를 두고 여러 정의가 서로 겨루며, 그 가운데 어떤 정의로는 러스트가 객체 지향이지만 다른 정의로는 그렇지 않습니다. 이번 장에서는 흔히 객체 지향이라고 여겨지는 몇 가지 특성과, 그 특성들이 러스트다운 표현으로 어떻게 옮겨지는지 살펴봅니다. 그다음 러스트에서 객체 지향 설계 패턴을 구현하는 방법을 보이고, 그것과 러스트의 강점을 살린 해결책 사이의 트레이드오프를 이야기합니다.
객체 지향 언어의 특성
어떤 언어가 객체 지향이라고 여겨지려면 어떤 기능이 있어야 하는지에 대해 프로그래밍 커뮤니티의 합의는 없습니다. 러스트는 OOP를 비롯한 많은 프로그래밍 패러다임의 영향을 받았습니다. 예를 들어 클로저: 자신의 환경을 캡처하는 익명 함수에서는 함수형 프로그래밍에서 온 기능을 살펴보았습니다. OOP 언어들은 대부분 몇 가지 공통된 특성을 가집니다. 객체, 캡슐화(encapsulation), 상속(inheritance)입니다. 이 특성들이 각각 무엇을 뜻하는지, 러스트가 그것을 지원하는지 봅니다.
객체는 데이터와 동작을 담습니다
에릭 감마(Erich Gamma), 리처드 헬름(Richard Helm), 랄프 존슨(Ralph Johnson), 존 블리시디스(John Vlissides)가 쓴 『Design Patterns: Elements of Reusable Object-Oriented Software』(Addison-Wesley, 1994)는 흔히 GoF(Gang of Four) 책이라고 불리며, 객체 지향 설계 패턴의 목록을 담고 있습니다. 이 책은 객체 지향 프로그램이 객체들로 이루어지며, 객체는 데이터와 그 데이터를 다루는 프로시저(procedure)를 함께 묶은 것이고, 그 프로시저를 흔히 메서드나 연산이라고 부른다고 정의합니다.
이 정의를 따르면 러스트는 객체 지향입니다. 구조체와 열거형은 데이터를 가지고, impl 블록은 구조체와 열거형에 메서드를 제공합니다. 메서드가 있는 구조체와 열거형을 객체라고 부르지는 않지만, GoF의 객체 정의에 따르면 같은 기능을 제공합니다.
상세 구현을 은닉하는 캡슐화
OOP와 흔히 함께 이야기되는 또 다른 면은 캡슐화라는 생각입니다. 캡슐화는 객체의 구현 세부 사항을 그 객체를 쓰는 코드가 접근할 수 없게 한다는 뜻입니다. 그래서 객체와 상호작용하는 유일한 방법은 객체의 공개 API를 통하는 것이며, 객체를 쓰는 코드는 객체 내부에 손을 뻗어 데이터나 동작을 직접 바꿀 수 없어야 합니다. 이렇게 하면 프로그래머가 객체를 쓰는 코드를 바꾸지 않고도 객체 내부를 바꾸거나 리팩터링할 수 있습니다.
캡슐화를 조절하는 방법은 모듈 스코프와 공개 범위에서 이야기했습니다. pub 키워드로 코드의 어느 모듈, 타입, 함수, 메서드를 공개할지 정할 수 있으며, 기본으로는 모두 비공개입니다. 예를 들어 i32 값의 벡터를 담는 필드를 가진 AveragedCollection이라는 구조체를 정의할 수 있습니다. 이 구조체에는 벡터의 값들의 평균을 담는 필드도 둘 수 있어, 누군가 평균이 필요할 때마다 계산하지 않아도 됩니다. 곧 AveragedCollection은 계산한 평균을 저장해 둡니다. 다음은 AveragedCollection 구조체의 정의입니다.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}이 구조체는 다른 코드가 쓸 수 있도록 pub으로 표시했지만, 구조체 안의 필드는 비공개로 남아 있습니다. 이 경우에는 값이 목록에 더해지거나 목록에서 빠질 때마다 평균도 갱신되게 하고 싶으므로 이것이 중요합니다. 다음 코드처럼 구조체에 add, remove, average 메서드를 구현해 그렇게 합니다.
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만이 AveragedCollection 인스턴스의 데이터에 접근하거나 바꾸는 방법입니다. add 메서드로 list에 항목을 더하거나 remove 메서드로 빼면, 각 메서드의 구현이 비공개 update_average 메서드를 불러 average 필드도 갱신합니다.
list와 average 필드는 비공개로 두어, 바깥의 코드가 list 필드에 항목을 직접 더하거나 빼지 못하게 합니다. 그렇지 않으면 list가 바뀔 때 average 필드가 맞지 않게 될 수 있습니다. average 메서드는 average 필드의 값을 돌려주므로, 바깥의 코드가 평균을 읽을 수는 있지만 바꿀 수는 없습니다.
AveragedCollection 구조체의 구현 세부 사항을 캡슐화했으므로, 나중에 자료 구조 같은 부분을 쉽게 바꿀 수 있습니다. 예를 들어 list 필드에 Vec<i32> 대신 HashSet<i32>를 쓸 수 있습니다. add, remove, average 공개 메서드의 시그니처가 그대로라면, AveragedCollection을 쓰는 코드는 컴파일되려고 바뀔 필요가 없습니다. 반대로 list를 공개했다면 반드시 그렇지는 않습니다. HashSet<i32>와 Vec<i32>는 항목을 더하고 빼는 메서드가 서로 다르므로, 바깥의 코드가 list를 직접 바꾸고 있었다면 고쳐야 할 것입니다.
캡슐화가 언어를 객체 지향이라고 여기는 데 필요한 면이라면, 러스트는 그 요구를 만족합니다. 코드의 서로 다른 부분에 pub을 쓸지 말지를 고를 수 있어, 구현 세부 사항을 캡슐화할 수 있습니다. 다만 러스트의 비공개는 모듈 단위이므로, 같은 모듈과 그 하위 모듈의 코드는 비공개 필드에도 접근할 수 있다는 점은 알아 두세요.
타입 시스템과 코드 공유로서의 상속
상속은 객체가 다른 객체의 정의에서 요소를 물려받아, 다시 정의하지 않고도 부모 객체의 데이터와 동작을 얻는 방법입니다. 자바에서 extends로 부모 클래스를 물려받고 메서드를 덮어쓰는 모습은 상속과 메서드 오버라이딩에서 볼 수 있습니다.
언어가 객체 지향 언어이려면 상속이 있어야 한다면, 러스트는 그런 언어가 아닙니다. 매크로를 쓰지 않고서는, 부모 구조체의 필드와 메서드 구현을 물려받는 구조체를 정의할 방법이 없습니다.
하지만 프로그래밍 도구 상자에 상속을 두는 데 익숙하다면, 애초에 상속을 쓰려는 까닭에 따라 러스트의 다른 해결책을 쓸 수 있습니다.
상속을 고르는 까닭은 크게 두 가지입니다. 하나는 코드의 재사용입니다. 어떤 타입에 특정 동작을 구현하면, 상속으로 다른 타입에서 그 구현을 다시 쓸 수 있습니다. 러스트 코드에서는 이것을 기본 트레이트 메서드 구현으로 제한된 방식으로 할 수 있습니다. 트레이트와 공통 동작에서 Summary 트레이트에 summarize 메서드의 기본 구현을 더했을 때 이것을 보았습니다. Summary 트레이트를 구현하는 타입은 더 코드를 쓰지 않아도 summarize 메서드를 쓸 수 있습니다. 이것은 부모 클래스에 메서드의 구현이 있고, 상속한 자식 클래스도 그 메서드의 구현을 가지는 것과 비슷합니다. 또 Summary 트레이트를 구현할 때 summarize 메서드의 기본 구현을 덮어쓸 수 있으며, 이것은 자식 클래스가 부모 클래스에서 물려받은 메서드의 구현을 덮어쓰는 것과 비슷합니다.
상속을 쓰는 다른 까닭은 타입 시스템과 관련이 있습니다. 부모 타입이 쓰이는 곳에 자식 타입을 쓸 수 있게 하는 것입니다. 이것을 다형성(polymorphism)이라고도 부르며, 런타임에 어떤 특성을 공유하는 여러 객체를 서로 바꿔 쓸 수 있다는 뜻입니다.
많은 사람에게 다형성은 상속과 같은 말입니다. 하지만 다형성은 사실 여러 타입의 데이터로 동작할 수 있는 코드를 가리키는 더 일반적인 개념입니다. 상속에서는 그 타입들이 대개 하위 클래스입니다. 러스트는 대신 제네릭으로 여러 가능한 타입을 추상화하고, 트레이트 바운드로 그 타입들이 제공해야 하는 것에 제약을 겁니다. 이것을 바운드된 매개변수 다형성(bounded parametric polymorphism)이라고 부르기도 합니다.
최근 많은 프로그래밍 언어에서 상속은 프로그래밍 설계 해결책으로서 인기를 잃었습니다. 필요 이상으로 많은 코드를 공유할 위험이 있기 때문입니다. 하위 클래스가 부모 클래스의 모든 특성을 공유해서는 안 되는 경우가 많은데, 상속하면 그렇게 됩니다. 그러면 프로그램의 설계가 덜 유연해질 수 있습니다. 또 하위 클래스에서 말이 되지 않거나 오류를 일으키는 메서드를 부를 가능성이 생깁니다. 게다가 어떤 언어는 단일 상속만 허용해, 하위 클래스가 한 클래스에서만 상속받을 수 있게 하므로 프로그램 설계의 유연함이 더 제한됩니다. 아래 그림은 흔히 객체 지향이라고 부르는 특성을 러스트가 각각 어떤 기능으로 제공하는지 정리한 것입니다.
ARCHITECTURE · CAPABILITY MAP
Rust는 상속 대신 각 특성을 다른 메커니즘으로 제공한다
Rust는 struct·enum과 impl로 상태와 행동을 결합하고 모듈 가시성으로 불변식을 보호한다. trait, generic, trait object는 서로 다른 행동 공유와 다형성 수단이며 필드 구현 상속 대신 합성을 사용한다.
상태와 행동
struct와 enum이 데이터를 표현하고 impl이 그 타입의 메서드를 묶습니다.
캡슐화
module 가시성과 public method가 외부 접근 경계를 만들고 불변식을 지킬 수 있습니다.
행동 공유
trait 기본 메서드는 trait 행동을 공유하지만 필드를 상속하지 않습니다.
다형성
generic은 정적 dispatch, trait object는 동적 dispatch를 제공하는 서로 다른 선택입니다.
재사용
구현 상속 대신 composition과 명시적 trait 계약으로 기능을 조합합니다.
본문의 캡슐화 · AveragedCollection
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}정의 모듈 밖의 사용자는 비공개 필드에 직접 접근하지 못합니다. 공개 add/remove가 list를 바꿀 때 비공개 update_average도 호출하여 캐시 평균을 갱신합니다. 같은 모듈과 하위 모듈의 접근은 별도로 허용됩니다.
객체 지향에는 하나의 보편적 정의가 없다. Rust가 선택된 특성을 어떤 메커니즘으로 제공하는지 비교해야 하며, trait 기본 메서드는 필드 상속이나 임의의 superclass 구현을 제공하지 않는다.
러스트는 구조체와 열거형으로 데이터를 나타내고 impl로 그 타입의 메서드를 묶어 상태와 동작을 결합합니다. 모듈의 공개 범위와 공개 메서드는 바깥에서 접근할 수 있는 범위를 정해 불변 조건을 지키며, 이 글의 AveragedCollection이 그 예입니다. 정의한 모듈 밖의 사용자는 비공개 필드에 직접 접근하지 못하고, 공개 add와 remove가 list를 바꿀 때 비공개 update_average도 불러 저장해 둔 평균을 갱신합니다. 트레이트의 기본 메서드는 동작만 공유할 뿐 필드를 물려주지 않습니다. 제네릭 T: Trait는 같은 타입에 대해 단형성화(monomorphization)로 정적 디스패치를, dyn Trait는 서로 다른 타입의 값에 대해 런타임 호출인 동적 디스패치를 제공하는 서로 다른 선택입니다. 재사용은 구현 상속 대신 합성(composition)과 위임, 명시적인 트레이트로 합니다.
이런 까닭으로 러스트는 상속 대신 트레이트 객체를 쓰는 다른 방식을 택해, 런타임에 다형성을 가능하게 합니다. 트레이트 객체가 어떻게 동작하는지 봅니다.
정리
- 객체를 데이터와 그 데이터를 다루는 메서드의 묶음으로 정의한다면, 구조체와 열거형,
impl블록을 가진 러스트는 객체 지향입니다. - 러스트는
pub으로 공개 범위를 정해 구현 세부 사항을 캡슐화하며, 비공개 필드를 바꾸는 길을 공개 메서드로만 열어 불변 조건을 지킵니다. - 러스트에는 부모의 필드와 메서드 구현을 물려받는 상속이 없으며, 코드 재사용은 트레이트의 기본 메서드 구현과 합성으로 합니다.
- 다형성은 제네릭과 트레이트 바운드로 정적으로, 트레이트 객체로 동적으로 얻습니다.
연습 문제
1. 평균이 어긋날 수 있을까
AveragedCollection의 list 필드를 pub list: Vec<i32>로 공개하면 어떤 문제가 생길 수 있나요? 항목이 하나 남은 컬렉션에서 그 마지막 항목을 remove하면 average는 무엇이 되나요?
해설 보기
list를 공개하면 바깥의 코드가 collection.list.push(10)처럼 update_average를 거치지 않고 목록을 바꿀 수 있으므로, 저장해 둔 average가 실제 평균과 어긋납니다. 또 마지막 항목을 빼면 update_average가 0을 0으로 나누게 되어, 0.0 / 0.0의 결과인 NaN이 average에 저장됩니다. 비공개 필드를 바꾸는 길이 메서드뿐이므로, 빈 경우를 처리하도록 update_average 하나만 고치면 됩니다.
2. 상속 없이 재사용하기
여러 타입이 같은 describe 동작을 쓰되, 한 타입만 다르게 동작해야 합니다. 러스트에서는 어떻게 하나요?
해설 보기
trait Describe { fn name(&self) -> String; fn describe(&self) -> String { format!("This is {}", self.name()) } }처럼 describe에 기본 구현을 둔 트레이트를 정의합니다. 각 타입은 name만 구현하면 기본 describe를 쓰고, 다르게 동작해야 하는 타입은 impl 블록에서 describe를 다시 정의해 기본 구현을 덮어씁니다. 필드가 아니라 동작만 공유된다는 점이 상속과 다릅니다.
다음 글 트레이트 객체와 다형성에서는 서로 다른 타입의 값을 dyn Trait로 한데 모아 같은 방식으로 다루는 방법을 살펴봅니다.