Характеристики объектно-ориентированных языков

В сообществе разработчиков отсутствует согласие относительно того, какие особенности языка делают его объектно-ориентированным. На Rust повлияли многие парадигмы программирования, включая ООП; например, в главе 13 мы изучили вещи, присущие функциональному программированию. Возможно, ООП языки имеют некоторые общие характеристики, а именно объекты, инкапсуляцию и наследование. Давайте посмотрим, что означает каждая из этих характеристик и поддерживает ли её Rust.

Объекты содержат данные и поведение

Книга «Приёмы объектно-ориентированного проектирования. Паттерны проектирования» (1994), называемая также «книгой банды четырёх», является каталогом объектно-ориентированных шаблонов проектирования. Объектно-ориентированные программы определяются в ней следующим образом:

Объектно-ориентированные программы состоят из объектов. Объект объединяет данные и процедуры, которые работают с этими данными. Эти процедуры обычно называются методами или операциями.

В соответствии с этим определением, Rust является объектно-ориентированным языком: в структурах и перечислениях содержатся данные, а в блоках impl определяются методы. Хотя структуры и перечисления, имеющие методы, не называются объектами, они обеспечивают такую же функциональность, которую предоставляют объекты, соответствующие определению в книге банды четырёх.

Инкапсуляция скрывающая детали реализации

Другим аспектом, обычно связанным с объектно-ориентированным программированием, является идея инкапсуляции: детали реализации объекта недоступны для кода, использующего этот объект. Единственный способ взаимодействия с объектом — через его публичный интерфейс; код, использующий этот объект, не должен иметь возможности взаимодействовать с внутренними свойствами объекта и напрямую изменять его данные или поведение. Инкапсуляция позволяет изменять и реорганизовывать внутренние свойства объекта без необходимости изменять код, который использует объект.

Как мы обсудили в главе 7, мы можем использовать ключевое слово pub чтобы решить, какие модули, типы, функции и методы в нашем коде должны быть публичными; по умолчанию все остальное является приватным. Например, мы можем определить структуру AveragedCollection, которая имеет поле, содержащее вектор значений типа i32. Структура также может иметь поле содержащее среднее значение в векторе, так что всякий раз, когда кто-либо захочет получить среднее значение элементов вектора, нам не нужно вычислять его заново, Другими словами, AveragedCollection будет кэшировать рассчитанное среднее значение для нас. В примере 17-1 приведено определение структуры AveragedCollection:

Файл: src/lib.rs


#![allow(unused)]
fn main() {
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}
}

Листинг 17-1: Структура AveragedCollection содержит список целых чисел и среднее значение элементов в коллекции.

Обратите внимание, что структура помечена ключевым словом pub, что позволяет другому коду её использовать, однако, поля внутри структуры остаются недоступными. Это важно, потому что мы хотим гарантировать обновление среднего значения при добавлении или удалении элемента из списка. Мы можем получить нужное поведение, определив в структуре методы add, remove и average, как показано в примере 17-2:

Файл: src/lib.rs


#![allow(unused)]
fn main() {
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: Реализация публичных методов add, remove и average структуры AveragedCollection

Публичные методы add, remove и average являются единственным способом получить или изменить данные в экземпляре AveragedCollection. Когда элемент добавляется в list методом add, или удаляется с помощью метода remove, код реализации каждого из этих методов вызывает приватный метод update_average, который позаботится об обновлении поля average.

Мы оставляем поля list и average недоступными, чтобы внешний код не мог добавлять или удалять элементы непосредственно в поле list; в противном случае average поле может оказаться не синхронизировано при изменении list. Метод average возвращает значение в поле average, что позволяет внешнему коду читать значение average, но не изменять его.

Поскольку мы инкапсулировали детали реализации структуры AveragedCollection, мы можем легко изменить такие аспекты, как структура данных, в будущем. Например, мы могли бы использовать HashSet<i32> вместо Vec<i32> для поля list. Благодаря тому, что сигнатуры публичных методов add, remove и average остаются неизменными, код, использующий AveragedCollection, также не будет нуждаться в изменении. У нас бы не получилось этого достичь, если бы мы сделали поле list доступным внешнему коду: HashSet<i32> иVec<i32> имеют разные методы для добавления и удаления элементов, поэтому внешний код, вероятно, должен измениться, если он модифицирует list напрямую.

Если инкапсуляция является обязательным аспектом для определения языка как объектно-ориентированного, то Rust соответствует этому требованию. Возможность использования модификатора доступа pub для различных частей кода позволяет построить публичный интерфейс и инкапсулировать детали реализации.

Наследование как система типов и способ совместного использования кода

Наследование — это механизм, предоставляемый другими языками программирования, с помощью которого объект может быть определён, унаследовав данные и поведение от родительского объекта без необходимости их повторного определения.

Если язык должен иметь наследование для того, чтобы считаться объектно-ориентированным, тогда Rust не является таковым. Не существует способа определить структуру, которая бы наследовала поля и методы от другой структуры. Однако, если вы привыкли использовать наследование в списке своих инструментов, то в Rust можно использовать альтернативные решения.

Вы выбираете наследование по двум основным причинам. Одна из них - возможность повторного использования кода: вы можете реализовать определённое поведение для одного типа, а наследование позволяет вам повторно использовать эту реализацию для другого типа. Вместо этого в Rust можно делиться кодом, используя реализацию метода типажа по умолчанию, который вы видели в листинге 10-14, когда мы добавили реализацию по умолчанию в методе summarize типажа Summary. Любой тип, реализующий свойство Summary будет иметь доступный метод summarize без дополнительного кода. Это аналогично родительскому классу, имеющему реализацию метода и аналогично наследующему дочернему классу, также имеющему реализацию метода. Мы также можем переопределить реализацию по умолчанию для метода summarize, когда реализуем типаж Summary, что похоже на дочерний класс, переопределяющий реализацию метода, унаследованного от родительского класса.

Вторая причина использования наследования относится к системе типов: чтобы иметь возможность использовать дочерний тип в тех же места, что и родительский. Эта возможность также называется полиморфизм и означает возможность подменять объекты во время исполнения, если они имеют одинаковые характеристики.

Полиморфизм

Для многих людей полиморфизм является синонимом наследования. Но на самом деле это более общая концепция, которая относится к коду, который может работать с данными разных типов. Для наследования эти типы обычно являются подклассами. Вместо этого Rust использует обобщённые типы для абстрагирования от типов, и ограничения типажей (trait bounds) для указания того, какие возможности эти типы должны предоставлять. Это иногда называют ограниченным параметрическим полиморфизмом.

Наследование в последнее время утратило популярность, как подход к разработке, на многих языках программирования, поскольку часто существует риск совместного использования большего количества кода, чем необходимо. Подклассы не всегда должны иметь все общие характеристики родительского класса, но будут получать их через наследование. Это может сделать дизайн программы менее гибким. Это также даёт возможность вызова методов у подклассов, которые не имеют смысла или вызывают ошибки, потому что методы неприменимы к подклассу. Кроме того, в некоторых языках подкласс может наследоваться только от одного класса, что ещё больше ограничивает гибкость разработки программы.

По этим причинам Rust выбрал альтернативный подход с использованием типажей-объектов вместо наследования. Давайте посмотрим как типажи-объекты реализуют полиморфизм в Rust.