객체 지향 언어의 특징
어떤 기능을 가져야 언어를 객체 지향이라고 부를 수 있는지에 대해서는 프로그래밍 커뮤니티 안에 합의가 없습니다. 러스트는 OOP를 포함한 여러 프로그래밍 패러다임의 영향을 받았습니다. 예를 들어 13장에서는 함수형 프로그래밍에서 온 기능들을 살펴보았습니다. 보통 OOP 언어는 몇 가지 공통 특징을 공유한다고 볼 수 있는데, 대표적으로 객체, 캡슐화, 상속이 있습니다. 이제 각각이 무엇을 의미하는지, 그리고 러스트가 이를 지원하는지 보겠습니다.
객체는 데이터와 동작을 함께 가진다
Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides 의 책 Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994), 흔히 Gang of Four 책이라고 불리는 책은 객체 지향 디자인 패턴의 대표적인 카탈로그입니다. 이 책은 OOP를 다음과 같이 정의합니다.
객체 지향 프로그램은 객체들로 이루어진다. 객체 는 데이터와 그 데이터에 대해 작동하는 절차를 함께 묶는다. 이 절차들은 보통 메서드 또는 연산 이라고 부른다.
이 정의에 따르면 러스트는 객체 지향입니다. 구조체와 enum은 데이터를 가지고 있고,
impl 블록은 구조체와 enum에 메서드를 제공합니다. 비록 구조체와 enum에 메서드가
있다고 해서 그것을 꼭 객체 라고 부르지는 않지만, Gang of Four가 말한 의미에서의
객체 기능은 분명 제공합니다.
구현 세부를 숨기는 캡슐화
객체 지향과 함께 자주 언급되는 또 다른 측면은 캡슐화(encapsulation) 입니다. 캡슐화란 객체의 구현 세부가 그 객체를 사용하는 코드에서 직접 접근되지 않는다는 뜻입니다. 따라서 객체와 상호작용하는 유일한 방법은 그 공개 API를 통하는 것이며, 사용하는 코드는 객체 안으로 파고들어 데이터를 직접 바꾸거나 동작을 직접 바꾸면 안 됩니다. 이런 구조 덕분에 프로그래머는 객체를 사용하는 코드를 바꾸지 않고도, 객체 내부 구현을 변경하고 리팩터링할 수 있습니다.
캡슐화를 어떻게 제어하는지는 7장에서 설명했습니다. 우리는 pub 키워드를 사용해
코드 안 어떤 모듈, 타입, 함수, 메서드를 공개할지 정할 수 있고, 기본적으로 다른 모든
것은 private 입니다. 예를 들어 정수 벡터를 필드로 가진 AveragedCollection
구조체를 정의할 수 있습니다. 또한 이 구조체는 그 벡터 안 값들의 평균도 필드로
가지게 만들 수 있습니다. 이렇게 하면 누군가가 평균을 필요로 할 때마다 매번
계산하지 않아도 됩니다. 즉, AveragedCollection 이 평균값을 캐시해 두는 것입니다.
목록 18-1이 이 구조체 정의를 보여 줍니다.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
AveragedCollection 구조체구조체 자체는 pub 이라 다른 코드도 사용할 수 있지만, 구조체 내부 필드는 여전히
private 입니다. 이 예에서는 이것이 중요합니다. 리스트에 값을 추가하거나 제거할 때마다
평균도 함께 갱신되게 만들고 싶기 때문입니다. 그래서 목록 18-2처럼 add, remove,
average 메서드를 구현하고, 각 메서드 안에서 private 한 update_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;
}
}
AveragedCollection 에 대해 public 메서드 add, remove, average 구현하기AveragedCollection 인스턴스 안의 데이터에 접근하거나 수정할 수 있는 방법은 이
public 메서드뿐입니다. add 메서드로 list 에 항목을 추가하거나 remove 로
제거할 때, 각 구현은 내부에서 private 한 update_average 를 호출해 average
필드도 함께 갱신합니다.
우리는 list 와 average 필드를 private 으로 유지하기 때문에, 외부 코드는
list 필드 안에 직접 항목을 추가하거나 제거할 수 없습니다. 만약 그렇게 되면
list 가 바뀔 때 average 필드가 서로 어긋날 수 있기 때문입니다. average
메서드는 average 필드 값을 반환해서 외부 코드가 평균을 읽을 수 있게 하되, 직접
수정하지는 못하게 합니다.
이처럼 AveragedCollection 구조체의 구현 세부를 캡슐화해 두었기 때문에, 나중에
예를 들어 내부 자료구조를 Vec<i32> 에서 HashSet<i32> 로 바꾸는 일도 쉽습니다.
add, remove, average public 메서드 시그니처만 유지된다면, AveragedCollection
을 사용하는 코드는 전혀 바꿀 필요가 없습니다. 반대로 list 를 public 으로 노출했다면
이렇게 하기 어려웠을 것입니다. HashSet<i32> 와 Vec<i32> 는 항목 추가/제거
메서드가 다르므로, 외부 코드도 함께 바꿔야 했을 가능성이 큽니다.
만약 언어가 객체 지향으로 간주되려면 캡슐화가 필수라고 본다면, 러스트는 그 요건을
충족합니다. 코드의 각 부분에 대해 pub 를 붙일지 말지를 선택할 수 있기 때문에,
구현 세부를 충분히 캡슐화할 수 있습니다.
타입 시스템과 코드 공유 측면의 상속
상속(inheritance) 은 한 객체가 다른 객체 정의의 요소를 물려받아, 부모 객체의 데이터와 동작을 다시 정의하지 않고도 그대로 얻게 하는 메커니즘입니다.
만약 어떤 언어가 객체 지향으로 인정받으려면 상속도 반드시 가져야 한다면, 러스트는 그런 언어는 아닙니다. 매크로를 사용하지 않는 한, 부모 구조체의 필드와 메서드 구현을 그대로 물려받는 구조체를 정의하는 방법은 없습니다.
하지만 여러분이 프로그래밍 도구 상자 안에 “상속”을 갖고 있는 것에 익숙하다면, 상속을 왜 쓰고 싶었는지에 따라 러스트에서는 다른 해결책을 사용할 수 있습니다.
상속을 고르는 이유는 크게 두 가지입니다. 하나는 코드 재사용 입니다. 어떤 타입에
특정 동작을 구현해 두고, 상속을 통해 다른 타입에서도 그 구현을 재사용하고 싶은 것입니다.
러스트에서는 이것을 제한적인 방식으로, 기본 트레이트 메서드 구현으로 할 수 있습니다.
목록 10-14에서 Summary 트레이트의 summarize 메서드에 기본 구현을 추가했던 것을
떠올려 보세요. Summary 를 구현하는 어떤 타입이든, 추가 코드 없이 summarize
메서드를 바로 사용할 수 있습니다. 이는 부모 클래스가 메서드 구현을 제공하고, 그
메서드를 상속받은 자식 클래스가 그대로 갖게 되는 것과 비슷합니다. 또한 Summary
트레이트를 구현할 때 기본 summarize 구현을 덮어쓸 수도 있는데, 이는 자식 클래스가
부모에게서 물려받은 메서드를 재정의하는 것과 비슷합니다.
상속을 사용하는 다른 이유는 타입 시스템 과 관련이 있습니다. 즉, 자식 타입을 부모 타입이 쓰이는 자리 어디에든 쓸 수 있게 하려는 것입니다. 이것은 다형성(polymorphism) 이라고도 하며, 특정 특징을 공유하는 여러 객체를 런타임에 서로 대체할 수 있다는 뜻입니다.
다형성
많은 사람은 다형성을 곧 상속과 같은 뜻으로 사용합니다. 하지만 실제로는 더 일반적인 개념으로, 여러 타입의 데이터에 대해 동작할 수 있는 코드를 뜻합니다. 상속 문맥에서는 그 타입들이 보통 하위 클래스들입니다.
러스트는 대신, 여러 가능한 타입을 추상화하기 위해 제네릭을 사용하고, 그 타입들이 반드시 어떤 기능을 제공해야 하는지를 제한하기 위해 트레이트 바운드를 사용합니다. 이런 방식을 제한된 매개변수적 다형성(bounded parametric polymorphism) 이라고 부르기도 합니다.
러스트는 상속을 제공하지 않는 대신 다른 트레이드오프를 선택했습니다. 상속은 실제보다 더 많은 코드를 공유하게 만들 위험이 있습니다. 하위 클래스는 언제나 부모 클래스의 모든 특성을 공유해야 하는 것이 아니지만, 상속을 사용하면 그렇게 되어 버리기 쉽습니다. 이 때문에 프로그램 설계 유연성이 떨어질 수 있습니다. 또한 어떤 메서드는 하위 클래스에 의미가 없거나 오히려 오류를 일으킬 수 있음에도, 상속 때문에 메서드 호출 가능성이 열려 있다는 문제도 생깁니다. 게다가 어떤 언어는 단일 상속 만 허용하여(즉 자식 클래스가 부모 클래스 하나만 상속할 수 있어) 설계 유연성을 더 제한하기도 합니다.
이런 이유로, 러스트는 런타임 다형성을 위해 상속 대신 트레이트 객체(trait objects)를 사용하는 다른 접근을 취합니다. 이제 트레이트 객체가 어떻게 동작하는지 보겠습니다.