객체 지향 언어의 특성

프로그래밍 커뮤니티에서는 어떤 언어가 객체 지향으로 간주되기 위해 반드시 갖춰야 하는 기능에 대한 합의가 이루어지지 않았습니다. 러스트는 OOP를 포함한 많은 프로그래밍 패러다임의 영향을 받았습니다; 예를 들어, 13장에서는 함수형 프로그래밍에서 나온 기능들을 탐구해봤습니다. OOP 언어라면 거의 틀림없이 몇가지 공통된 특성을 공유하는데, 여기에는 객체 (object), 캡슐화 (encapsulation), 그리고 상속 (inheritance) 이 있습니다. 각 특성이 무엇을 의미하는지와 이를 러스트가 지원하는지를 살펴봅시다.

객체는 데이터와 동작을 담습니다

속칭 4인조 책이라고도 불리는 에리히 감마 (Erich Gamma), 리처드 헬름 (Richard Helm), 랄프 존슨 (Ralph Johnson), 그리고 존 블리시데스 (John Vlissides) 의 책 디자인 패턴 (Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley Professional, 1994) 은 객체 지향 디자인 패턴에 대한 카탈로그입니다. 이 책에서는 OOP를 다음과 같이 정의합니다:

객체 지향 프로그램은 객체로 구성됩니다. 객체는 데이터 및 이 데이터를 활용하는 프로시저를 묶습니다. 이 프로시저들을 보통 메서드 혹은 연산 (operation) 이라고 부릅니다.

이 정의에 따르면, 러스트는 객체 지향적입니다: 구조체와 열거형에는 데이터가 있고, impl 블록은 그 구조체와 열거형에 대한 메서드를 제공하죠. 설령 메서드가 있는 구조체와 열거형이 객체라고 호칭되지는 않더라도, 4인조의 객체에 대한 정의에 따르면 이들은 동일한 기능을 제공합니다.

상세 구현을 은닉하는 캡슐화

일반적으로 OOP와 연관된 또 다른 측면은 캡슐화 (encapsulation) 라는 개념으로, 그 의미는 객체를 이용하는 코드에서 그 객체의 상세 구현에 접근할 수 없게 한다는 것입니다. 따라서, 객체와 상호작용하는 유일한 방법은 해당 객체의 공개 API를 통하는 것입니다; 객체를 사용하는 코드는 직접 객체의 내부에 접근하여 데이터나 동작을 직접 변경시켜서는 안 됩니다. 이는 프로그래머가 객체를 사용하는 코드의 변경 없이 이 객체 내부를 변경하거나 리팩터링할 수 있도록 해줍니다.

7장에서 어떻게 캡슐화를 제어하는지에 대해 논의했습니다: pub 키워드를 사용하여 어떤 모듈, 타입, 함수, 그리고 메서드가 공개될 것인가를 결정할 수 있으며, 기본적으로 다른 모든 것들은 비공개입니다. 예를 들면, i32 값의 벡터를 필드로 가지고 있는 AveragedCollection 구조체를 정의할 수 있습니다. 또한 이 구조체는 벡터의 값에 대한 평균값을 담는 필드도 가질 수 있으므로, 평균값이 필요한 순간마다 매번 이를 계산할 필요는 없습니다. 바꿔 말하면, AveragedCollection은 계산된 평균값을 캐시할 것입니다. 예제 17-1은 이 AveragedCollection 구조체에 대한 정의를 나타냅니다:

파일명: src/lib.rs

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

예제 17-1: 컬렉션 내의 정수 아이템들과 그의 평균값을 관리하는 AveragedCollection 구조체

구조체는 pub으로 표시되어 다른 코드가 이를 사용할 수 있지만, 구조체 안에 존재하는 필드들은 여전히 비공개입니다. 이는 이번 사례에 매우 중요한데, 그 이유는 하나의 값이 리스트에 추가되거나 제거될 때마다 평균도 업데이트되는 것을 보장하고 싶기 때문입니다. 예제 17-2와 같이 구조체에 add, remove, 그리고 average 메서드를 구현하여 이를 수행합니다:

파일명: src/lib.rs

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

예제 17-2: AveragedCollection의 공개 메서드 add, remove, 그리고 average의 구현

공개 메서드 add, remove, 그리고 averageAveragedCollection 인스턴스의 데이터에 접근하거나 수정할 수 있는 유일한 방법입니다. add 메서드를 사용하여 list에 아이템을 추가하거나 remove 메서드를 사용하여 제거하면, 각 구현에서는 average 필드의 업데이트을 처리하는 비공개 메서드 update_average도 호출합니다.

listaverage 필드는 비공개로 하였으므로 외부 코드가 list 필드에 직접 아이템을 추가하거나 제거할 방법은 없습니다; 그렇지 않으면 average 필드는 list가 변경될 때 동기화되지 않을 수 있습니다. average 메서드는 average 필드의 값을 반환하므로, 외부 코드가 average를 읽을 수 있도록 하지만 변경할 수는 없습니다.

AveragedCollection의 세부 구현은 캡슐화되었기 때문에, 향후에 데이터 구조와 같은 측면을 쉽게 변경할 수 있습니다. 예를 들면, list 필드에 대해서 Vec<i32>가 아닌 HashSet<i32>를 사용할 수 있습니다. add, remove, average 공개 메서드의 시그니처가 그대로 유지되는 한, AveragedCollection를 사용하는 코드들은 변경될 필요가 없습니다. 대신 list를 공개했다면 반드시 그렇지는 않았을 것입니다: HashSet<i32>Vec<i32>는 아이템들을 추가하거나 제거하기 위한 메서드들이 다르므로, 만약 외부 코드가 list에 직접 접근하여 변경했더라면 모두 변경되어야 할 가능성이 높겠지요.

캡슐화가 객체 지향 언어로 간주하기 위해 필요한 측면이라면, 러스트는 해당 요구 사항을 충족합니다. 코드의 서로 다른 부분들에 대해 pub을 사용할지 여부를 선택하는 옵션을 통해 구현 세부 사항을 캡슐화할 수 있습니다.

타입 시스템과 코드 공유로서의 상속

상속은 어떤 객체가 다른 객체의 정의로부터 요소를 상속받을 수 있는 메커니즘으로, 이를 통해 객체를 다시 정의하지 않고도 부모 객체의 데이터와 동작을 가져올 수 있습니다.

만약 객체 지향 언어가 반드시 상속을 제공해야 한다면, 러스트는 그렇지 않은 쪽입니다. 매크로를 사용하지 않고 부모 구조체의 필드와 메서드 구현을 상속받는 구조체를 정의할 방법은 없습니다.

하지만 여러분이 상속에 익숙하다면, 애초에 이를 사용하고자 하는 이유에 따라 러스트의 다른 솔루션들을 이용할 수 있습니다.

상속을 선택하는 이유는 크게 두 가지입니다. 하나는 코드를 재사용하는 것입니다: 어떤 타입의 특정한 동작을 구현할 수 있고, 상속을 통하여 다른 타입에 대해 그 구현을 재사용할 수 있습니다. 러스트 코드에서는 대신 기본 트레이트 메서드의 구현을 이용하여 제한적으로 공유할 수 있는데, 이는 예제 10-14에서 Summary 트레이트에 summarize 메서드의 기본 구현을 추가할 때 봤던 것입니다. Summary 트레이트를 구현하는 모든 타입은 추가 코드 없이 summarize 메서드를 사용할 수 있습니다. 이는 어떤 메서드의 구현체를 갖는 부모 클래스와 그를 상속받는 자식 클래스 또한 그 메서드의 해당 구현체를 갖는 것과 유사합니다. 또한 Summary 트레이트를 구현할 때 summarize의 기본 구현을 오버라이딩할 수 있고, 이는 자식 클래스가 부모 클래스에서 상속받는 메서드를 오버라이딩하는 것과 유사합니다.

상속을 사용하는 또 다른 이유는 타입 시스템과 관련된 것입니다: 자식 타입을 부모 타입과 같은 위치에서 사용할 수 있게 하기 위함입니다. 이를 다형성 (polymorphism) 이라고도 부르는데, 이는 여러 객체가 일정한 특성을 공유한다면 이들을 런타임에 서로 대체하여 사용할 수 있음을 의미합니다.

다형성

많은 사람이 다형성을 상속과 동일시합니다. 하지만 다형성은 여러 타입의 데이터로 작업할 수 있는 코드를 나타내는 더 범용적인 개념입니다. 상속에서는 이런 타입들이 일반적으로 하위클래스에 해당합니다.

러스트는 대신 제네릭을 사용하여 호환 가능한 타입을 추상화하고 트레이트 바운드를 이용하여 해당 타입들이 반드시 제공해야 하는 제약사항을 부과합니다. 이것을 종종 범주 내 매개변수형 다형성 (bounded parametric polymophism) 이라고 부릅니다.

최근 많은 프로그래밍 언어에서는 상속이 프로그래밍 디자인 솔루션으로써 선호되지 않고 있는데 그 이유는 필요 이상으로 많은 코드를 공유할 수 있는 위험이 있기 때문입니다. 하위클래스가 늘 그들의 부모 클래스의 모든 특성을 공유할 필요가 없어도 상속한다면 그렇게 됩니다. 이는 프로그램 설계의 유연성을 저하시킬 수 있습니다. 또한 하위클래스에서는 타당하지 않거나 적용될 수 없어서 에러를 유발하는 메서드들이 호출될 수 있는 가능성을 만듭니다. 게다가, 어떤 언어들은 단일 상속 (하위클래스가 하나의 클래스로부터만 상속받을 수 있음을 의미) 만을 허용하기 때문에 프로그램 디자인의 유연성을 더욱 제한하게 됩니다.

이러한 이유로, 러스트는 상속 대신에 트레이트 객체를 사용하는 다른 접근법을 택합니다. 트레이트 객체가 러스트에서 어떻게 다형성을 가능하게 하는지 살펴봅시다.