Характеристики объектно-ориентированных языков
В сообществе программистов нет консенсуса относительно того, какими функциями должен обладать язык, чтобы считаться объектно-ориентированным. 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.
Файл: src/lib.rs
#![allow(unused)] fn main() { pub struct AveragedCollection { list: Vec<i32>, average: f64, } }
Листинг 18-1: Структура AveragedCollection, которая хранит список целых чисел и среднее значение элементов в коллекции
Структура помечена как pub, чтобы другой код мог её использовать, но поля внутри структуры остаются приватными. Это важно в данном случае, потому что мы хотим гарантировать, что всякий раз, когда значение добавляется в список или удаляется из него, среднее значение также обновляется. Мы делаем это, реализуя методы add, remove и average для структуры, как показано в листинге 18-2.
Файл: src/lib.rs
#![allow(unused)] fn main() { 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; } } }
Листинг 18-2: Реализации публичных методов add, remove и average для AveragedCollection
Публичные методы add, remove и average являются единственными способами доступа или изменения данных в экземпляре AveragedCollection. Когда элемент добавляется в list с помощью метода add или удаляется с помощью метода remove, реализации каждого вызывают приватный метод update_average, который обрабатывает обновление поля 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 выбрал другой набор компромиссов, не предлагая наследование. Наследование часто рискует общим использованием большего количества кода, чем необходимо. Подклассы не всегда должны разделять все характеристики своего родительского класса, но при наследовании это происходит. Это может сделать дизайн программы менее гибким. Это также introduces возможность вызова методов в подклассах, которые не имеют смысла или вызывают ошибки, потому что методы не применимы к подклассу. Кроме того, некоторые языки позволяют только одиночное наследование (что означает, что подкласс может наследовать только от одного класса), что еще больше ограничивает гибкость дизайна программы.
По этим причинам Rust использует другой подход, используя трейт-объекты вместо наследования для достижения полиморфизма во время выполнения. Давайте посмотрим, как работают трейт-объекты.