Характеристики объектно-ориентированных языков
В сообществе программистов нет единого мнения о том, какие возможности должен иметь язык, чтобы считаться объектно-ориентированным. На 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.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
AveragedCollection, которая поддерживает список целых чисел и среднее значение элементов в коллекцииСтруктура помечена pub, чтобы другой код мог ее использовать, но поля внутри
структуры остаются приватными. В данном случае это важно, потому что мы хотим
гарантировать: всякий раз, когда значение добавляется в список или удаляется
из него, среднее значение тоже обновляется. Мы делаем это, реализуя для
структуры методы add, remove и average, как показано в листинге 18-2.
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;
}
}
add, remove и average для AveragedCollectionПубличные методы add, remove и average — единственные способы получить
доступ к данным в экземпляре AveragedCollection или изменить их. Когда
элемент добавляется в list с помощью метода add или удаляется с помощью
метода remove, реализации каждого из них вызывают приватный метод
update_average, который также отвечает за обновление поля average.
Мы оставляем поля list и average приватными, чтобы у внешнего кода не было
способа напрямую добавлять элементы в поле list или удалять их из него; в
противном случае поле average могло бы рассинхронизироваться при изменении
list. Метод average возвращает значение из поля average, позволяя
внешнему коду читать average, но не изменять его.
Поскольку мы инкапсулировали детали реализации структуры AveragedCollection,
мы можем легко изменить ее части в будущем, например структуру данных. К
примеру, для поля list можно было бы использовать HashSet<i32> вместо
Vec<i32>. Пока сигнатуры публичных методов add, remove и average
остаются теми же, код, использующий AveragedCollection, менять не придется.
Если бы вместо этого мы сделали list публичным, это не обязательно было бы
так: у HashSet<i32> и Vec<i32> разные методы для добавления и удаления
элементов, поэтому внешний код, вероятно, пришлось бы изменить, если бы он
напрямую модифицировал list.
Если инкапсуляция является обязательным аспектом, чтобы язык считался
объектно-ориентированным, то Rust соответствует этому требованию. Возможность
использовать или не использовать pub для разных частей кода позволяет
инкапсулировать детали реализации.
Наследование как система типов и как совместное использование кода
Наследование — это механизм, с помощью которого объект может наследовать элементы из определения другого объекта, получая данные и поведение родительского объекта без необходимости определять их заново.
Если язык должен иметь наследование, чтобы быть объектно-ориентированным, то Rust таким языком не является. В Rust нет способа определить структуру, которая наследует поля и реализации методов родительской структуры, без использования макроса.
Однако если вы привыкли иметь наследование в своем наборе инструментов программирования, в Rust можно использовать другие решения, в зависимости от того, по какой причине вы изначально обращаетесь к наследованию.
Вы выбрали бы наследование по двум основным причинам. Первая — повторное
использование кода: можно реализовать определенное поведение для одного типа,
а наследование позволяет повторно использовать эту реализацию для другого типа.
В ограниченном виде это можно сделать в Rust с помощью реализаций методов
трейта по умолчанию, которые вы видели в листинге 10-14, когда мы добавили
реализацию метода summarize по умолчанию в трейте Summary. Любой тип,
реализующий трейт Summary, будет иметь доступный для него метод summarize
без дополнительного кода. Это похоже на то, как у родительского класса есть
реализация метода, и наследующий дочерний класс также получает реализацию этого
метода. Мы также можем переопределить реализацию метода summarize по
умолчанию при реализации трейта Summary, что похоже на то, как дочерний
класс переопределяет реализацию метода, унаследованного от родительского
класса.
Другая причина использовать наследование связана с системой типов: она позволяет использовать дочерний тип в тех же местах, где используется родительский тип. Это также называется полиморфизмом, что означает возможность подставлять несколько объектов друг вместо друга во время выполнения, если у них есть определенные общие характеристики.
Полиморфизм
Для многих людей полиморфизм является синонимом наследования. Но на самом деле это более общая концепция, относящаяся к коду, который может работать с данными нескольких типов. В случае наследования эти типы обычно являются подклассами.
Вместо этого Rust использует обобщения, чтобы абстрагироваться над разными возможными типами, и ограничения трейтов, чтобы наложить требования на то, что эти типы должны предоставлять. Иногда это называется ограниченным параметрическим полиморфизмом.
Rust выбрал другой набор компромиссов, не предлагая наследование. Наследование часто несет риск совместного использования большего количества кода, чем необходимо. Подклассы не всегда должны разделять все характеристики своего родительского класса, но при наследовании они будут это делать. Это может сделать дизайн программы менее гибким. Это также создает возможность вызывать у подклассов методы, которые не имеют смысла или вызывают ошибки, потому что эти методы неприменимы к подклассу. Кроме того, некоторые языки разрешают только одиночное наследование (то есть подкласс может наследовать только от одного класса), что еще сильнее ограничивает гибкость дизайна программы.
По этим причинам Rust использует другой подход: трейт-объекты вместо наследования для достижения полиморфизма во время выполнения. Давайте посмотрим, как работают трейт-объекты.