Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

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

Книга «Приёмы объектно-ориентированного проектирования. Паттерны проектирования» (Design Patterns: Elements of Reusable Object-Oriented Software) Эриха Гаммы, Ричарда Хелма, Ральфа Джонсона и Джона Влиссидеса (Addison-Wesley, 1994), известная как «Банда четырёх», является каталогом паттернов объектно-ориентированного проектирования. Она определяет ООП следующим образом:

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

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

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

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

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

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}
Listing 18-1: Структура AveragedCollection, которая поддерживает список целых чисел и среднее значение элементов в коллекции

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

Filename: 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;
    }
}
Listing 18-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 вместо этого использует обобщения для абстрагирования над различными возможными типами и ограничения типажей для наложения ограничений на то, что эти типы должны предоставлять. Это иногда называется ограниченным параметрическим полиморфизмом.

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

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