객체 지향 언어의 특징
객체 지향 언어의 특징 (Characteristics of Object-Oriented Languages)
프로그래밍 커뮤니티에는 어떤 기능을 갖춰야 객체 지향 언어라고 부를 수 있는지에 대한 합의가 없어요. Rust는 OOP를 포함한 많은 프로그래밍 패러다임의 영향을 받았는데, 13장에서 함수형 프로그래밍에서 온 기능들을 살펴봤죠. 다만 OOP 언어들에는 공통된 몇 가지 특징이 있다고 볼 수 있어요. 바로 객체(objects), 캡슐화(encapsulation), 상속(inheritance)이에요. 각 특성이 무엇을 뜻하는지, Rust가 그것을 지원하는지 하나씩 보죠.
객체는 데이터와 행동을 담는다 (Objects Contain Data and Behavior)
Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides가 쓴 Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994)는 흔히 'GoF(Gang of Four) 책'이라고 부르는데, 객체 지향 설계 패턴을 정리한 카탈로그예요. 이 책은 OOP를 이렇게 정의합니다.
객체 지향 프로그램은 객체로 이루어져 있다. 객체는 데이터와 그 데이터를 조작하는 프로시저를 함께 담고 있는데, 이 프로시저를 보통 메서드(methods) 또는 연산(operations)이라고 부른다.
이 정의를 따르면 Rust는 객체 지향적이에요. 구조체(struct)와 열거형(enum)은 데이터를 갖고 있고, impl 블록이 구조체와 열거형에 메서드를 제공하죠. 메서드를 가진 구조체와 열거형을 '객체'라고 부르진 않지만, GoF가 말하는 객체의 기능은 똑같이 제공합니다.
구현 세부를 숨기는 캡슐화 (Encapsulation That Hides Implementation Details)
OOP와 흔히 연결되는 또 다른 측면은 캡슐화 개념이에요. 캡슐화는 객체의 구현 세부 사항을 그 객체를 사용하는 코드가 접근할 수 없게 하는 것을 뜻해요. 따라서 객체와 상호작용하는 유일한 방법은 공개 API를 통하는 것이고, 객체를 쓰는 코드가 객체의 내부에 직접 들어가 데이터나 행동을 바꿀 수 없어야 하죠. 이렇게 되면 프로그래머는 객체를 사용하는 코드를 바꾸지 않고도 객체의 내부를 수정하거나 리팩터링할 수 있어요.
캡슐화를 제어하는 방법은 7장에서 다뤘어요. pub 키워드로 코드의 어떤 모듈·타입·함수·메서드를 공개할지 정하고, 기본적으로 나머지 모든 것은 비공개로 두는 방식이죠. 예를 들어 i32 값들의 벡터를 담는 필드를 가진 AveragedCollection 구조체를 정의할 수 있어요. 이 구조체에는 벡터 값들의 평균을 담는 필드도 있어서, 누군가 필요로 할 때마다 평균을 계산할 필요가 없게 만들 수 있죠. 다시 말해 AveragedCollection이 계산된 평균을 캐시해 둔다는 뜻이에요. Listing 18-1이 AveragedCollection 구조체 정의입니다.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
구조체는 다른 코드가 사용할 수 있게 pub으로 표시하지만, 구조체 안의 필드는 비공개로 남겨요. 이 경우 필드를 비공개로 두는 게 중요한데, 목록에 값이 추가되거나 제거될 때마다 평균도 갱신되도록 보장하고 싶기 때문이에요. Listing 18-2처럼 구조체에 add, remove, 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;
}
}
공개 메서드인 add, remove, average만이 AveragedCollection 인스턴스의 데이터에 접근하거나 수정할 수 있는 유일한 경로예요. add 메서드로 목록에 항목을 추가하거나 remove 메서드로 제거하면, 각 구현이 average 필드를 갱신하는 비공개 update_average 메서드를 함께 호출하죠.
list와 average 필드를 비공개로 남겨두면 외부 코드가 list 필드에 직접 항목을 추가·제거할 방법이 없어요. 만약 그게 가능했다면 목록이 바뀔 때 average 필드가 동기화되지 않을 수 있죠. average 메서드는 average 필드의 값을 반환해서 외부 코드가 평균을 읽을 수는 있게 하지만, 수정은 못하게 합니다.
AveragedCollection 구조체의 구현 세부를 이렇게 캡슐화했기 때문에, 나중에 데이터 구조 같은 측면을 쉽게 바꿀 수 있어요. 예를 들어 list 필드를 Vec<i32> 대신 HashSet<i32>로 바꿀 수도 있죠. add, remove, average 공개 메서드의 시그니처만 그대로라면 AveragedCollection을 쓰는 코드는 바뀔 필요가 없어요. 반대로 list를 공개로 만들었다면 이야기가 달라지는데, HashSet<i32>와 Vec<i32>는 항목을 추가하고 제거하는 메서드가 서로 달라서, list를 직접 수정하던 외부 코드는 바뀌어야 할 가능성이 높죠.
캡슐화가 객체 지향 언어의 필수 조건이라면 Rust는 그 요건을 충족해요. 코드의 여러 부분에 pub를 쓸지 말지 선택하는 것이 구현 세부의 캡슐화를 가능하게 하니까요.
타입 시스템이자 코드 공유 수단으로서의 상속 (Inheritance as a Type System and as Code Sharing)
상속은 객체가 다른 객체의 정의에서 요소를 물려받아, 부모 객체의 데이터와 행동을 다시 정의하지 않고도 얻을 수 있게 해주는 메커니즘이에요.
상속이 객체 지향 언어의 필수 조건이라면 Rust는 그런 언어가 아니에요. 매크로를 쓰지 않고서는 부모 구조체의 필드와 메서드 구현을 물려받는 구조체를 정의할 방법이 없습니다.
하지만 프로그래밍 도구함에 상속이 늘 들어 있던 사람이라면, 상속을 쓰려는 이유에 따라 Rust에서 다른 해결책을 쓸 수 있어요.
사람들이 상속을 선택하는 이유는 크게 두 가지예요. 하나는 코드 재사용이에요. 한 타입을 위해 특정 행동을 구현해 두고, 상속으로 그 구현을 다른 타입에서도 재사용하는 방식이죠. Rust에서는 트레이트의 기본 메서드 구현(default trait method implementations)으로 비슷한 일을 제한적으로 할 수 있어요. Listing 10-14에서 Summary 트레이트에 summarize 메서드의 기본 구현을 추가한 걸 봤죠. Summary 트레이트를 구현하는 어떤 타입이든 추가 코드 없이 summarize 메서드를 갖게 됩니다. 이건 부모 클래스가 메서드 구현을 갖고 상속받은 자식 클래스도 그 메서드 구현을 갖게 되는 것과 비슷해요. 그리고 Summary 트레이트를 구현할 때 summarize의 기본 구현을 오버라이드할 수도 있는데, 이건 자식 클래스가 부모 클래스에서 상속받은 메서드 구현을 오버라이드하는 것과 유사하죠.
상속을 쓰는 또 다른 이유는 타입 시스템과 관련돼요. 자식 타입이 부모 타입과 같은 자리에서 쓰일 수 있게 하는 것이죠. 이를 다형성(polymorphism)이라고도 하는데, 공통된 특성을 공유하는 여러 객체를 런타임에 서로 대신해 쓸 수 있다는 뜻이에요.
다형성 (Polymorphism)
많은 사람이 다형성을 상속과 동의어로 여겨요. 하지만 실제로 다형성은 더 일반적인 개념으로, 여러 타입의 데이터와 함께 동작할 수 있는 코드를 가리킵니다. 상속의 경우 그 타입들은 대체로 서브클래스(subclass)죠.
Rust는 대신 제네릭(generics)으로 다양한 가능한 타입을 추상화하고, 트레이트 바운드(trait bounds)로 그 타입들이 반드시 제공해야 할 것을 강제해요. 이걸 가끔 '경계가 있는 매개변수 다형성(bounded parametric polymorphism)'이라고 부르기도 합니다.
Rust는 상속을 제공하지 않는 대신 서로 다른 트레이드오프를 선택했어요. 상속은 필요한 것보다 더 많은 코드를 공유하게 되는 위험이 자주 있어요. 서브클래스가 늘 부모 클래스의 모든 특성을 공유해야 하는 건 아니지만, 상속을 하면 그렇게 돼버리죠. 이건 프로그램 설계를 덜 유연하게 만들 수 있어요. 또한 서브클래스에 적용되지 않는 메서드를 호출해서 말이 안 되거나 오류를 일으킬 가능성도 생기죠. 게다가 어떤 언어는 단일 상속(single inheritance)만 허용해서(즉 서브클래스가 하나의 클래스에서만 상속받는 것) 프로그램 설계의 유연성이 더 제한되기도 해요.
이런 이유로 Rust는 런타임 다형성을 이루기 위해 상속 대신 트레이트 객체(trait objects)라는 다른 접근을 취해요. 트레이트 객체가 어떻게 동작하는지 살펴볼게요.
출처: The Rust Book