객체 지향 언어의 특징

Beginner

This tutorial is from open-source community. Access the source code

소개

객체 지향 언어의 특징에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.

이 랩에서는 객체, 캡슐화, 상속을 포함한 객체 지향 언어의 특징을 살펴보고 Rust 가 이러한 기능을 지원하는지 여부를 검토합니다.

객체 지향 언어의 특징

프로그래밍 커뮤니티에서는 언어가 객체 지향으로 간주되기 위해 어떤 기능을 가져야 하는지에 대한 합의가 없습니다. Rust 는 OOP 를 포함한 많은 프로그래밍 패러다임의 영향을 받았습니다. 예를 들어, 13 장에서 함수형 프로그래밍에서 비롯된 기능을 살펴보았습니다. 객체 지향 언어는 객체, 캡슐화, 상속이라는 특정 공통 특징을 공유한다고 주장할 수 있습니다. 각 특징이 무엇을 의미하는지, 그리고 Rust 가 이를 지원하는지 살펴보겠습니다.

객체는 데이터와 동작을 포함합니다

Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides 의 저서인 Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994) 는 통칭하여 The Gang of Four 책이라고 불리며, 객체 지향 디자인 패턴의 카탈로그입니다. 이 책은 OOP 를 다음과 같이 정의합니다.

객체 지향 프로그램은 객체로 구성됩니다. 객체는 데이터와 해당 데이터를 조작하는 절차를 모두 묶습니다. 절차는 일반적으로 메소드 또는 연산이라고 불립니다.

이 정의를 사용하면 Rust 는 객체 지향적입니다. 구조체 (struct) 와 열거형 (enum) 은 데이터를 가지고 있으며, impl 블록은 구조체와 열거형에 대한 메소드를 제공합니다. 메소드를 가진 구조체와 열거형이 객체라고 불리지는 않지만, Gang of Four 의 객체 정의에 따르면 동일한 기능을 제공합니다.

구현 세부 사항을 숨기는 캡슐화

OOP 와 일반적으로 관련된 또 다른 측면은 *캡슐화 (encapsulation)*의 개념입니다. 이는 객체의 구현 세부 사항이 해당 객체를 사용하는 코드에서 접근할 수 없다는 것을 의미합니다. 따라서 객체와 상호 작용하는 유일한 방법은 공개 API 를 통하는 것입니다. 객체를 사용하는 코드는 객체의 내부로 들어가 데이터나 동작을 직접 변경할 수 없어야 합니다. 이를 통해 프로그래머는 객체를 사용하는 코드를 변경할 필요 없이 객체의 내부를 변경하고 리팩터링할 수 있습니다.

7 장에서 캡슐화를 제어하는 방법을 논의했습니다. pub 키워드를 사용하여 코드에서 어떤 모듈, 타입, 함수, 메소드를 공개할지 결정할 수 있으며, 기본적으로 다른 모든 것은 비공개입니다. 예를 들어, i32 값의 벡터를 포함하는 필드를 가진 AveragedCollection 구조체를 정의할 수 있습니다. 이 구조체는 또한 벡터의 값의 평균을 포함하는 필드를 가질 수 있습니다. 즉, 누군가가 필요할 때마다 평균을 계산할 필요가 없습니다. 다시 말해, AveragedCollection은 계산된 평균을 캐시합니다. Listing 17-1 은 AveragedCollection 구조체의 정의를 보여줍니다.

파일 이름: src/lib.rs

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

Listing 17-1: 정수 목록과 컬렉션의 항목 평균을 유지하는 AveragedCollection 구조체

구조체는 다른 코드가 사용할 수 있도록 pub로 표시되지만, 구조체 내의 필드는 비공개로 유지됩니다. 이 경우, 목록에 값이 추가되거나 제거될 때마다 평균도 업데이트되도록 하려는 경우 중요합니다. Listing 17-2 에 표시된 것처럼 구조체에 add, remove, average 메소드를 구현하여 이를 수행합니다.

파일 이름: src/lib.rs

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

Listing 17-2: AveragedCollection에 대한 공개 메소드 add, remove, average의 구현

공개 메소드 add, remove, averageAveragedCollection의 인스턴스에서 데이터를 접근하거나 수정하는 유일한 방법입니다. add 메소드를 사용하여 list에 항목이 추가되거나 remove 메소드를 사용하여 제거될 때, 각 메소드의 구현은 average 필드도 업데이트하는 비공개 update_average 메소드를 호출합니다.

listaverage 필드를 비공개로 유지하여 외부 코드가 list 필드에 항목을 직접 추가하거나 제거할 수 없도록 합니다. 그렇지 않으면 list가 변경될 때 average 필드가 동기화되지 않을 수 있습니다. average 메소드는 average 필드의 값을 반환하여 외부 코드가 average를 읽을 수 있지만 수정할 수는 없도록 합니다.

AveragedCollection 구조체의 구현 세부 사항을 캡슐화했기 때문에, 데이터 구조와 같은 측면을 쉽게 변경할 수 있습니다. 예를 들어, list 필드에 Vec<i32> 대신 HashSet<i32>를 사용할 수 있습니다. add, remove, average 공개 메소드의 시그니처가 동일하게 유지되는 한, AveragedCollection을 사용하는 코드는 변경할 필요가 없습니다. 만약 list를 공개로 만들었다면, 반드시 그런 것은 아닐 것입니다. HashSet<i32>Vec<i32>는 항목을 추가하고 제거하는 다른 메소드를 가지고 있으므로, 외부 코드가 list를 직접 수정하는 경우 변경해야 할 가능성이 높습니다.

캡슐화가 언어가 객체 지향으로 간주되기 위한 필수적인 측면이라면, Rust 는 해당 요구 사항을 충족합니다. 코드의 다른 부분에 대해 pub를 사용할지 여부를 선택할 수 있으므로 구현 세부 사항을 캡슐화할 수 있습니다.

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

*상속 (Inheritance)*은 객체가 다른 객체의 정의에서 요소를 상속받아 부모 객체의 데이터와 동작을 다시 정의하지 않고 얻을 수 있는 메커니즘입니다.

언어가 객체 지향이 되기 위해 상속이 필수적이라면, Rust 는 그런 언어가 아닙니다. 매크로를 사용하지 않고 부모 구조체의 필드와 메소드 구현을 상속하는 구조체를 정의하는 방법은 없습니다.

그러나 프로그래밍 도구 상자에 상속을 사용하는 데 익숙하다면, 처음부터 상속을 사용하려는 이유에 따라 Rust 에서 다른 솔루션을 사용할 수 있습니다.

상속을 선택하는 주된 이유는 두 가지입니다. 하나는 코드 재사용을 위한 것입니다. 특정 타입에 대한 특정 동작을 구현할 수 있으며, 상속을 통해 다른 타입에 대해 해당 구현을 재사용할 수 있습니다. Summary 트레이트에 summarize 메소드의 기본 구현을 추가했을 때 Listing 10-14 에서 보았듯이, Rust 코드에서 기본 트레이트 메소드 구현을 사용하여 제한적인 방식으로 이를 수행할 수 있습니다. Summary 트레이트를 구현하는 모든 타입은 추가 코드 없이 summarize 메소드를 사용할 수 있습니다. 이는 부모 클래스가 메소드의 구현을 가지고 있고 상속하는 자식 클래스도 메소드의 구현을 갖는 것과 유사합니다. 또한 Summary 트레이트를 구현할 때 summarize 메소드의 기본 구현을 재정의할 수 있는데, 이는 자식 클래스가 부모 클래스에서 상속된 메소드의 구현을 재정의하는 것과 유사합니다.

상속을 사용하는 또 다른 이유는 타입 시스템과 관련이 있습니다. 자식 타입을 부모 타입과 동일한 위치에서 사용할 수 있도록 하기 위해서입니다. 이것을 *다형성 (polymorphism)*이라고도 하며, 이는 런타임에 여러 객체가 특정 특성을 공유하는 경우 서로 대체될 수 있음을 의미합니다.

다형성

많은 사람들에게 다형성은 상속과 동의어입니다. 그러나 실제로 여러 타입의 데이터로 작업할 수 있는 코드를 지칭하는 더 일반적인 개념입니다. 상속의 경우, 해당 타입은 일반적으로 서브클래스입니다.

대신 Rust 는 제네릭 (generics) 을 사용하여 서로 다른 가능한 타입을 추상화하고, 트레이트 바운드 (trait bounds) 를 사용하여 해당 타입이 제공해야 하는 것에 제약을 가합니다. 이것을 때때로 *경계된 매개변수 다형성 (bounded parametric polymorphism)*이라고 합니다.

상속은 최근 많은 프로그래밍 언어에서 프로그래밍 디자인 솔루션으로 선호도가 떨어졌습니다. 이는 종종 필요 이상으로 많은 코드를 공유할 위험이 있기 때문입니다. 서브클래스는 항상 부모 클래스의 모든 특성을 공유할 필요는 없지만, 상속을 사용하면 그렇게 됩니다. 이는 프로그램의 디자인을 덜 유연하게 만들 수 있습니다. 또한 서브클래스에 적용되지 않거나 서브클래스에 적용되지 않아 오류를 발생시키는 메소드를 호출할 가능성을 도입합니다. 또한 일부 언어는 단일 상속 (즉, 서브클래스가 하나의 클래스에서만 상속받을 수 있음) 만 허용하여 프로그램 디자인의 유연성을 더욱 제한합니다.

이러한 이유로 Rust 는 상속 대신 트레이트 객체를 사용하는 다른 접근 방식을 취합니다. Rust 에서 트레이트 객체가 다형성을 어떻게 가능하게 하는지 살펴보겠습니다.

요약

축하합니다! 객체 지향 언어의 특징 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.