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

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

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

Книга Приёмы объектно-ориентированного проектирования. Паттерны проектирования Erich Gamma, Richard Helm, Ralph Johnson, и John Vlissides (Addison-Wesley Professional, 1994), в просторечии называемая Книга банды четырёх, представляет собой сборник примеров объектно-ориентированного проектирования. В ней даётся следующее определение ООП:

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

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

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

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

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

Файл: src/lib.rs

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

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

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

Файл: 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: Реализация публичных методов add,remove, и average для AveragedCollection

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

Мы оставляем поля list и average приватными, чтобы внешний код не мог добавлять или удалять элементы непосредственно в поле list; в противном случае поле average может оказаться не синхронизировано при подобном вмешательстве. Метод 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.