Что значит объектно-ориентированный?

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

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

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

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

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

Сокрытие деталей реализации

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

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

Файл: src/lib.rs


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

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

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

Файл: src/lib.rs


# #![allow(unused_variables)]
#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 перестанет содержать актуальные данные. Метод average возвращает значение поля average, что позволяет внешнему коду прочитать average, но не изменять его.

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

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

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

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

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

У наследования есть два преимущества. Первое — это возможность повторного использования кода. Методы по умолчанию в типаже дают возможность переиспользовать код в типах, реализующих этот типаж, мы это видели в примере 10-15, где мы добавили дефолтную реализацию метода summary для типажа Summarizable. Любой тип, реализующий типаж Summarizable получит метод summary, без написания дополнительного кода. Это похоже на то, как методы родительского класса наследуются его дочерними классами. Мы, также, можем переопределить реализацию по умолчанию метода summary в типах, реализующих типаж Summarizable, что очень похоже на то, как дочерние классы переопределяют методы, унаследованные от родительского класса.

Вторая причина использования наследования — это система типов. Мы можем сообщить о том, что дочерний тип может быть использован в том же месте, что и родительский. Эта возможность также называется полиморфизм, который даёт возможность подменять объекты во время исполнения, если они имеют одинаковую форму.

В то время как многие люди используют слово «полиморфизм» для описания наследования, обычно они имеют в виду определенный вид полиморфизма — «полиморфизм подтипа». Существуют и другие формы полиморфизма. Использование обобщенного параметра в типаже также является видом полиморфизма, имеющим название «параметрический полиморфизм». Детальные различия между этими видами полиморфизма здесь не имеют решающего значения, поэтому не беспокойтесь о них. Просто знайте, что Rust имеет богатую функциональность, связанную с полиморфизмом, в отличие от многих объектно-ориентированных языков.

Для поддержки этого паттерна у Rust есть типажи-объекты (trait objects). Мы можем указать что нам подходят значения любого типа, реализующего определённый типаж.

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

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