Черты объектно-ориентированных языков
В сообществе программистов нет согласия о том, какие возможности должны быть у языка, чтобы он считался объектно-ориентированным. На Rust повлияли многие парадигмы программирования, включая ООП; например, в главе 13 мы разбирали возможности, пришедшие из функционального программирования. Можно сказать, что у ООП-языков есть общие черты — объекты, инкапсуляция и наследование. Посмотрим, что означает каждая из них и поддерживает ли её Rust.
Объекты содержат данные и поведение
Книга Design Patterns: Elements of Reusable Object-Oriented Software («Приёмы объектно-ориентированного проектирования. Паттерны проектирования») Эриха Гаммы, Ричарда Хелма, Ральфа Джонсона и Джона Влиссидеса (Addison-Wesley, 1994), которую в обиходе называют книгой «Банды четырёх» (The Gang of Four), — это каталог объектно-ориентированных шаблонов проектирования. ООП в ней определяется так:
Объектно-ориентированные программы состоят из объектов. Объект объединяет данные и процедуры, которые работают с этими данными. Процедуры обычно называют методами или операциями.
По этому определению Rust объектно-ориентированный: у структур и перечислений
есть данные, а блоки impl дают им методы. Хотя структуры и перечисления с
методами не называют объектами, по определению «Банды четырёх» они дают ту же
функциональность.
Инкапсуляция скрывает детали реализации
Ещё одна черта, которую обычно связывают с ООП, — инкапсуляция (encapsulation): детали реализации объекта недоступны коду, который этот объект использует. Взаимодействовать с объектом можно только через его публичный API; код, использующий объект, не должен залезать во внутренности объекта и напрямую менять данные или поведение. Благодаря этому программист может менять и рефакторить внутренности объекта, не трогая код, который его использует.
Как управлять инкапсуляцией, мы обсуждали в главе 7: ключевым словом pub
можно решать, какие модули, типы, функции и методы в коде будут публичными, а
всё остальное по умолчанию приватно. Например, можно определить структуру
AveragedCollection с полем, содержащим вектор значений i32. У структуры
может быть и поле со средним значением элементов вектора — тогда среднее не
придётся вычислять каждый раз, когда оно кому-то понадобится. Иначе говоря,
AveragedCollection будет кешировать вычисленное среднее. Определение структуры
AveragedCollection — в листинге 18-1.
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 или удалять их оттуда; иначе при
изменении list поле average могло бы рассогласоваться с ним. Метод
average возвращает значение из поля average, позволяя внешнему коду читать
среднее, но не менять его.
Раз детали реализации структуры AveragedCollection инкапсулированы, в будущем
можно легко поменять кое-что внутри — например, структуру данных. Скажем, для
поля list можно взять HashSet<i32> вместо Vec<i32>. Пока сигнатуры
публичных методов add, remove и average остаются прежними, менять код,
использующий AveragedCollection, не придётся. А будь list публичным, так
было бы не всегда: у HashSet<i32> и Vec<i32> разные методы добавления и
удаления элементов, и внешний код, напрямую менявший list, скорее всего,
пришлось бы переписать.
Если инкапсуляция — обязательная черта объектно-ориентированного языка, то
Rust этому требованию отвечает. Возможность использовать или не использовать
pub для разных частей кода позволяет инкапсулировать детали реализации.
Наследование: система типов и повторное использование кода
Наследование (inheritance) — механизм, при котором объект может унаследовать элементы определения другого объекта, то есть получить данные и поведение родительского объекта без повторного определения.
Если для объектно-ориентированности язык обязан поддерживать наследование, то Rust не такой язык. Определить структуру, которая наследовала бы поля и реализации методов родительской структуры, без макросов нельзя.
Но если вы привыкли к наследованию, в Rust можно воспользоваться другими решениями — в зависимости от того, зачем вам вообще понадобилось наследование.
Наследование выбирают по двум основным причинам. Первая — повторное
использование кода: поведение реализуют один раз для одного типа, а благодаря
наследованию пользуются им и в другом типе. В Rust это
можно сделать в ограниченном виде с помощью реализаций методов трейта по
умолчанию — вы видели такое в листинге 10-14, когда мы добавили реализацию по
умолчанию метода summarize в трейт Summary. У любого типа, реализующего
трейт Summary, метод summarize будет доступен без всякого дополнительного
кода. Это похоже на то, как у родительского класса есть реализация метода, и
наследующий дочерний класс тоже её получает. А при реализации трейта Summary
реализацию summarize по умолчанию можно переопределить — это похоже на то,
как дочерний класс переопределяет метод, унаследованный от родителя.
Вторая причина связана с системой типов: чтобы дочерний тип можно было использовать там же, где родительский. Это называют полиморфизмом (polymorphism): во время выполнения несколько объектов можно подставлять друг вместо друга, если у них есть общие черты.
Полиморфизм
Для многих полиморфизм — синоним наследования. Но на самом деле это более общее понятие: код, который может работать с данными разных типов. При наследовании такие типы обычно — подклассы.
Rust же использует обобщения, чтобы абстрагироваться от разных возможных типов, и ограничения трейтами, чтобы задать, что эти типы должны уметь. Иногда это называют ограниченным параметрическим полиморфизмом (bounded parametric polymorphism).
Отказавшись от наследования, Rust выбрал другой набор компромиссов. При наследовании легко получить больше общего кода, чем нужно. Подклассу не всегда нужны все характеристики родительского класса, но при наследовании он получает их все. Из-за этого дизайн программы может стать менее гибким. К тому же появляется возможность вызывать у подклассов методы, которые не имеют для них смысла или приводят к ошибкам, потому что к подклассу не применимы. А некоторые языки допускают только одиночное наследование (подкласс может наследоваться лишь от одного класса), что ещё сильнее ограничивает гибкость дизайна.
Поэтому Rust идёт другим путём и для полиморфизма во время выполнения использует вместо наследования трейт-объекты. Посмотрим, как они работают.