Программирование PC Rust: объектно-ориентированное программирование Fri, October 11 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.

Rust: объектно-ориентированное программирование Печать
Добавил(а) microsin   

Объектно-ориентированное программирование (ООП) это способ моделирования программ. Объекты как концепция программирования впервые появилась на языке программирования Simula в 1960-х годах. Эти объекты повлияли на архитектуру программирования Алана Кея, в которой объекты передают сообщения друг другу. Для описания этой архитектуры он ввел в 1967 году термин объектно-ориентированное программирование. Многие конкурирующие определения описывают, что такое ООП, и по некоторым из этих определений Rust можно отнести к ООП-языкам, а по другим определениям - нельзя. В этой главе (перевод документации [1]) мы рассмотрим определенные характеристики, которые обычно считаются объектно-ориентированными, и как они преобразуются в идиоматику Rust. Затем мы покажем, как реализовать ООП-шаблоны дизайна на Rust, и обсудим некоторые компромиссы между ними и реализацией аналогичного решения с использованием сильных сторон языка Rust.

[Характеристики языков ООП]

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

Объекты, содержащие данные и поведение. Книга "Design Patterns: Elements of Reusable Object-Oriented Software" за авторством Erich Gamma, Richard Helm, Ralph Johnson и John Vlissides (Addison-Wesley Professional, 1994), в разговорной речи именуемая как "The Gang of Four book" (книга банды четырех), считается каталогом шаблонов дизайна объектно-ориентированного программирования. Она определяет ООП так:

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

По этим определениям Rust объектно-ориентированный: в структурах и перечислениях встроены данные, а блоки impl предоставляют методы, встроенные в структуры и перечисления. Несмотря на то, что структуры и перечисления с методами не называются объектами, они предоставляют тот же самый функционал, что и объекты по их каноническому определению.

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

Мы обсуждали, как управлять инкапсуляцией в главе 7 [3]: с помощью ключевого слова pub можно определить, какие модули, типы, функции и методы в кода должны быть публичными, а все остальное по умолчанию будет приватным. Например, мы можем определить структуру AveragedCollection, в которой будет поле, содержащее вектор значений i32. Структура может также иметь поле, содержащее среднее от значений в векторе, т. е. среднее значение не нужно рассчитывать по требованию, когда кому-нибудь это нужно. Другими словами, AveragedCollection будет кешировать вычисленное для нас среднее значение. Листинг 17-1 показывает определение такой структуры (файл src/lib.rs):

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

Листинг 17-1. Структура AveragedCollection, хранящая список целых чисел и среднее значение от них.

Эта структура помечена ключевым словом pub, так что другой код может её использовать, но поля в структуре остаются приватными. Это важный момент, поскольку мы хотим иметь такое поведение, что при добавлении значения в список list или удалении значения из него также должно обновляться и среднее значение average. Мы сделаем это реализацией на структуре методов add, remove и average, как показано в листинге 17-2 (файл src/lib.rs):

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

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

Мы оставили поля list и average по умолчанию приватными, так что нет никакого способа напрямую добавлять или удалять элементы списка поля list; иначе поле average осталось бы не синхронизированным с изменениями в list. Метод average возвратит значение в поле average, позволяя внешнему коду прочитать среднее значение, но не позволяя его модифицировать.

Поскольку мы инкапсулировали детали реализации структуры AveragedCollection, то упрощается возможность менять в будущем внутренние аспекты структуры. Например для поля list мы могли бы использовать HashSet< i32> вместо Vec< i32>. Пока сигнатуры публичных методов add, remove и average остаются неизменными, не надо будет менять внешний код, использующий AveragedCollection. Если же мы бы сделали list публичным, то тогда в случае прямого доступа к list внешний код пришлось бы исправлять, потому что типы HashSet< i32> и Vec< i32> имеют разные методы для добавления и удаления элементов.

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

Наследование как система типов и как совместное использование кода. Наследование (inheritance) это механизм, посредством которого объект может наследовать элементы из определения другого объекта, получая таким образом данные и поведение родительского объекта без необходимости их повторного определения.

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

Однако если вы привыкли к наследованию как одному из инструментов в вашем наборе методов программирования, то можете использовать другие решения в Rust, в зависимости от вашей причины использования наследования в первую очередь.

Вы выбрали бы наследование по двум причинам. Одна из них это повторное использование кода: вы можете реализовать определенное поведение для одного типа, а наследование позволяет повторно использовать эту реализацию для другого типа. В коде Rust вы можете сделать это ограниченным образом, используя реализации метода трейта по умолчанию, которые вы видели в листинге 10-14, когда мы добавили реализацию по умолчанию для метода summarize трейта Summary [4]. Любой тип, реализующий трейт Summary, будет иметь доступный метод summarize без какого-либо дополнительного кода. Это похоже на родительский класс, в котором есть реализация метода, и наследующий дочерний класс, также имеющий реализацию метода. Мы можем также переназначить (override) реализацию по умолчанию метода summarize, когда мы реализуем трейт Summary, который похож на дочерний класс, переназначающий реализацию метода, унаследованного из родительского класса.

Другая причина использовать наследования относится к системе типов: чтобы разрешить использовать дочерний тип в тех же местах, что и родительский тип. Это также называется полиморфизмом, что означает возможность замены нескольких объектов друг на друга runtime, если они имеют определенные общие характеристики.

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

Вместо этого Rust использует generics [4] для абстрагирования различных возможных типов и границ трейтов (trait bounds), чтобы наложить ограничения на то, что должны предоставлять эти типы. Это иногда называют ограниченным параметрическим полиморфизмом.

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

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

[Использование трейт-объектов, допускающих значения различных типов]

В главе 8 [5] мы упоминали, что одно из ограничений вектора состоит в том, что он позволяет сохранять элементы только одного типа. Мы создали обходное решение в листинге 8-9, где определили перечисление SpreadsheetCell, в котором были варианты для хранения int, float и text. Это означает, что мы могли хранить разные типы данных в каждой ячейке, м все еше могли иметь вектор, который представляет строку ячеек. Это очень хорошее решение, когда наши взаимозаменяемые элементы представляют собой фиксированный набор типов, которые известны в момент компиляции кода.

Однако иногда мы хотим, чтобы пользователь нашей библиотеки мог расширить набор типов, которые допустимы в какой-то частной ситуации. Чтобы показать, как этого можно достичь, создадим пример утилиты с графическим интерфейсом пользователя (graphical user interface, GUI), которая просматривает список элементов, вызывая метод draw на каждом элементе, чтобы нарисовать его на экране - обычная техника для утилит GUI. Мы создадим библиотечный крейт с именем gui, который содержит структуру библиотеки GUI. Этот крейт может включать некоторые типы для использования другими программистами, такие как Button или TextField. Дополнительно пользователи gui захотят создать свои собственные типы, которые можно рисовать: например, один программист может добавить Image, а другой программист может добавить SelectBox.

Мы не будем реализовывать полноценную библиотеку GUI для этого примера, однако покажем, как отдельные части кода сочетаются друг с другом. На момент написания библиотеки мы не можем знать и заранее определить все типы, которые могут захотеть создать сторонние программисты. Однако нам известно, что библиотека gui должна отслеживать множество значений различных типов, и в ней должен быть метод draw на каждом из этих значений различного типа (эти типы элементов GUI будут добавлять пользователи библиотеки). Не нужно точно знать, что произойдет, когда мы вызовем метод draw, просто у значения внешнего типа будет метод, доступный нам для вызова.

Чтобы сделать это на языке с наследованием, мы можем определить класс с именем Component, у которое есть метод с именем draw. Другие классы, такие как Button, Image и SelectBox, наследуются от Component, и таким образом наследуют метод draw. Каждый из них может переопределить (override) родительский метод draw, чтобы определить свое собственное поведение, однако фреймворк может обрабатывать все типы, как если они были бы экземплярами класса Component, и может вызвать draw на них. Однако поскольку у Rust нет наследования, нам нужен другой способ структурировать библиотеку gui, чтобы пользователи могли расширить её новыми типами.

Определение трейта для общего поведения. Чтобы реализовать необходимое нам поведение библиотеки gui, определим трейт с именем Draw, который будет иметь один метод с именем draw. Затем мы можем определить вектор, который принимает объект трейта. Объект трейта указывает как на экземпляр типа, реализующего наш указанный трейт, так и таблицу, используемую для runtime-поиска методов трейта на этом типе. Мы создаем объект трейта указанием указателя некоторого вида, такого как ссылка & или smart-указатель Box< T>, затем ключевое слово dyn, и затем указанием соответствующего трейта (мы поговорим в главе 19 о причине, по которой трейт-объекты должны использовать указатель, см. секцию "Dynamically Sized Types and the Sized Trait" этой главы). Мы можем использовать трейт-объекты вместо generic-типа или конкретного типа. Где бы мы ни использовали трейт-объект, система типов Rust будет гарантировать во время компиляции, что любое значение, используемое в этом контексте, будет реализовать трейт-объект. Следовательно, нам не нужно знать все возможные типы во время компиляции.

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

Листинг 17-3 показывает, как определить трейт с именем Draw, у которого один метод draw (файл src/lib.rs):

pub trait Draw {
    fn draw(&self);
}

Листинг 17-3. Определение трейта Draw.

Этот синтаксис выглядит подобно нашему обсуждению, как определять трейты, из главы 10 [4]. Далее будет новый синтаксис: листинг 17-4 определяет структуру с именем Screen, которая хранит вектор components. Этот вектор типа Box< dyn Draw>, который является трейт-объектом; это для любого типа внутри Box, который реализует трейт Draw.

pub struct Screen {
    pub components: Vec< Box< dyn Draw>>,
}

Листинг 17-4. Определение структуры Screen с полем components, в котором хранится вектор трейт-объектов, реализующих трейт Draw (файл src/lib.rs).

На структуре Screen мы определим метод run, который будет вызывать метод draw на каждом из её компонентов, как показано в листинге 17-5 (файл src/lib.rs):

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Листинг 17-5. Метод run на структуре Screen, вызывающий метод draw на каждом component структуры.

Это отличается от определения структуры, которая использует параметр универсального (generic) с границами трейта (trait bounds). Параметр generic-типа может быть заменен только одним конкретным типом за раз, в то время как трейт-объекты позволяют заполнять несколько конкретных типов для трейт-объекта runtime. Например, мы могли бы определить структуру Screen, используя generic-тип и trait bound, как в листинге 17-6 (файл src/lib.rs):

pub struct Screen< T: Draw> {
    pub components: Vec< T>,
}

impl< T> Screen< T>
where T: Draw, { pub fn run(&self) { for component in self.components.iter() { component.draw(); } } }

Листинг 17-6. Альтернативная реализация структуры Screen и метода run с использованием generic-типов и trait bounds.

Это ограничит нас с экземпляром Screen со списком компонентов, которые все типа Button, или все типа TextField. Если у вас только гомогенные коллекции, то использование generic-типов и trait bounds предпочтительнее, потому что определения окажутся мономорфны во время компиляции (используются только конкретные типы).

С другой стороны, при использовании трейт-объектов один экземпляр Screen может хранить Vec< T>, содержащий как Box< Button>, так и Box< TextField>. Давайте посмотрим, как это работает, и затем поговорим про накладные runtime-расходы по производительности.

Реализация трейта. Теперь мы добавим некоторые типы, которые реализуют трейт Draw. Мы предоставим тип Button. Повторимся, что реальная реализация библиотеки GUI выходит за рамки этой документации, так что метод draw не будет иметь в своем теле никакой полезной реализации. Чтобы представить, как может выглядеть реализация, структура Button может иметь поля для ширины (width), высоты (height) и текстовой метки (label), как показано в листинге 17-7 (файл src/lib.rs):

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button { fn draw(&self) { // здесь должен быть код, который рисует кнопку } }

Листинг 17-7. Структура Button, которая реализует трейт Draw.

Поля width, height и label на структуре Button отличаются от полей на других компонентах; например, тип TextField может иметь те же поля плюс поле placeholder. Каждый из типов, который мы хотим рисовать на экране, будет реализовывать трейт Draw, но с отличающимся кодом в методе draw, чтобы определить, как рисовать определенный тип, как Button в этом примере (без реального кода GUI, как упоминалось - для простоты). Тип Button, например, может иметь дополнительный блок impl, содержащий методы, относящиеся к тому, что произойдет при клике пользователя на кнопку. Эти виды методов неприменимы к типам наподобие TextField.

Если кто-нибудь, использующий нашу библиотеку, решит реализовать свою структуру SelectBox с полями width, height и options, то он также реализует трейт Draw на типе SelectBox, как показано в листинге 17-8 (файл src/main.rs):

use gui::Draw;

struct SelectBox { width: u32, height: u32, options: Vec< String>, }

impl Draw for SelectBox { fn draw(&self) { // здесь должен быть код, который рисует список выбора } }

Листинг 17-8. Другой крейт, использующий библиотеку gui, и реализующий трейт Draw на структуре SelectBox.

Пользователи нашей библиотеки могут теперь написать свою функцию main, чтобы создать в ней экземпляр Screen. К экземпляру Screen они могут добавить SelectBox и Button, поместив их каждый в Box< T>, чтобы они стали трейт-объектомt. Они могут затем вызывать метод run на экземпляре Screen, который будет вызывать draw на каждом компоненте. Листинг 17-9 показывает эту реализацию (файл src/main.rs):

use gui::{Button, Screen};

fn main() { let screen = Screen { components: vec![ Box::new(SelectBox { width: 75, height: 10, options: vec![ String::from("Yes"), String::from("Maybe"), String::from("No"), ], }), Box::new(Button { width: 50, height: 10, label: String::from("OK"), }), ], };
screen.run(); }

Листинг 17-9. Использование трейт-объектов для сохранения значений различных типов, которые реализуют один и тот же трейт.

Когда мы пишем библиотеку, мы не знаем, что кто-то захочет добавит тип SelectBox, однако наша реализация Screen может работать на новом типе, и рисовать его, потому что SelectBox реализует трейт Draw, что означает, что он реализует метод draw.

Эта концепция - быть заинтересованным только сообщениями, на которые реагирует значение, вместо того чтобы быть связанным со значением  конкретного типа - подобна концепции "типизации утки" (duck typing) в языках программирования с динамической типизацией: если что-то ходит как утка и крякает как утка, то это утка! В реализации run на Screen в листинге 17-5, коду run не нужно знать конкретный тип каждого компонента. Не нужно проверять, является ли компонент экземпляром Button или SelectBox, run просто вызовет метод draw на компоненте. Путем указания Box< dyn Draw> как типа значений в векторе components, мы определили, что для Screen требуются значения, на которых мы можем вызвать метод draw.

Преимущество использования трейт-объектов и системы типов Rust для написания кода, подобного коду, использующему "duck typing", заключается в том, что нам никогда не нужно проверять runtime, реализует ли значение конкретный метод, и не нужно беспокоиться об ошибках, если значение не реализует метод, но мы вызовем его в любом случае. Rust не скомпилирует наш код, если значения не реализуют трейты, необходимые трейт-объектам.

Для примера листинг 17-10 показывает, что произойдет, если мы попытаемся создать Screen с типом String в качестве компонента (файл src/main.rs, этот код не скомпилируется):

use gui::Screen;

fn main() { let screen = Screen { components: vec![Box::new(String::from("Hi"))], };
screen.run(); }

Листинг 17-10. Попытка использовать тип, который не реализует необходимый трейт-объект.

Мы получим ошибку, потому что тип String не реализует трейт Draw:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not
  |                          implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `Box< String>` to `Box< dyn Draw>`
For more information about this error, try `rustc --explain E0277`. error: could not compile `gui` (bin "gui") due to 1 previous error

Эта ошибка дает нам понять, что либо мы передаем что-то Screen, что не хотели бы передавать, и поэтому должны передать другой тип, либо мы должны реализовать Draw на String, чтобы Screen мог на экземпляре String вызывать draw.

Трейт-объекты выполняют динамическое связывание. Вспомните секцию "Производительность кода, использующего generic-и" главы 10 [4], где мы обсуждали процесс мономорфизации, выполняемый компилятором, когда мы использовали границы трейта (trait bounds) на универсальных (generic) типах: компилятор генерирует nongeneric-реализации функций и методов для каждого конкретного типа, который мы используем вместо параметра generic-типа. Код, полученный в результате мономорфизации, выполняет статическое связывание (static dispatch), когда компилятор знает во время компиляции, какой метод вы вызываете. Это отличается от динамического связывания (dynamic dispatch), когда компилятор во время компиляции не может знать, какой метод вы вызываете. В случаях динамического связывания компилятор выдает код, который выяснит runtime, какой метод следует вызвать.

Когда мы используем трейт-объекты, Rust должен использовать динамическое связывание (dynamic dispatch). Компилятор не знает все типы, которые могут использоваться с кодом, использующим trait-объекты, так что он не знает для вызова метода, какой метод реализован на каком типе. Вместо этого Rust во время выполнения кода (runtime) использует указатели внутри трейт-объекта, чтобы узнать, какой метод вызвать. Просмотр таблицы указателей (lookup) для поиска необходимого для вызова метода имеет некоторую цену в плане производительности, чего нет в случае статического связывания (static dispatch). Динамическое связывание также не дает компилятору возможности применять в коде inline-методы, что в свою очередь не дает применить некоторые оптимизации кода. Однако мы получаем дополнительную гибкость кода, который мы написали в листинге 17-5, и тем самым можем поддерживать код в листинге 17-9, так что этот компромисс следует учитывать.

[Реализация ООП-шаблона дизайна]

State-модель это ООП-шаблон дизайна. Суть шаблона в том, что мы определяем набор состояний, которые значение может иметь внутри. Состояния представлены набором объектов состояния (state objects), и поведение значения меняется в зависимости от его состояния. Мы собираемся поработать с примером структуры сообщений блога, в которой есть поле для хранения его состояния, которое будет state-объектом из набора состояний "draft" (черновик), "review" (ревью) или "published" (опубликовано).

State-объекты используют общий функционал: конечно, в Rust мы используем структуры и трейты, а не объекты и наследование. Каждый state-объект отвечает за свое поведение и за управление, когда он должен перейти в другое состояние. Значение, которое содержит state-объект, ничего не знает о различном поведении состояний, или о том, когда нужно переходить между состояниями.

Достоинство использования state-шаблона состоит в том, что при изменении бизнес-требований к программе нам не нужно будет менять код значения, содержащего state, или код, который использует значение. Нам нужно будет только обновить код внутри одного из state-объектов, чтобы изменить его правила или, возможно, добавить больше state-объектов.

Сначала мы собираемся реализовать state-шаблон традиционным объектно-ориентированным способом, затем мы будем использовать подход, который немного более естественен для Rust. Давайте по шагам разберемся в реализации рабочего процесса блог-поста, используя state-шаблон.

Конечный функционал будет выглядеть примерно так:

1. Блог пост начинается с пустого черновика (empty draft).
2. Когда черновик поста закончен, запрашивается его ревью (review).
3. Когда пост принят как допустимый (approved), он становится опубликованным (published).
4. Только опубликованные посты возвращают свое содержимое для печати, так что не принятые как допустимые посты не могут быть случайно опубликованы.

Любые другие предпринятые попытки изменения поста не должны иметь никакого эффекта. Например, если мы попытаемся принять пост как допустимый до его прохождения ревью, то пост должен оставаться в состоянии не опубликованного черновика (unpublished draft).

Листинг 17-11 показывает этот рабочий процесс в форме кода: это пример использования API, который мы реализуем в библиотечном крейте blog. Этот код пока не скомпилируется, потому что мы не реализовали еще крейт blog.

use blog::Post;

fn main() { let mut post = Post::new();
post.add_text("I ate a salad for lunch today"); assert_eq!("", post.content());
post.request_review(); assert_eq!("", post.content());
post.approve(); assert_eq!("I ate a salad for lunch today", post.content()); }

Листинг 17-11. Код, который демонстрирует желаемое поведение нашего крейта blog (файл src/main.rs, этот код пока не скомпилируется).

Мы хотим разрешить пользователю создать новый черновик блог поста через вызов Post::new. Мы хотим разрешить добавить текст в блог пост. Если мы попытаемся получить содержимое поста немедленно, до того как он принят как допустимый, мы не должны получить какой-либо его текст, потому что пост все еще находится в состоянии черновика. Мы добавили в код assert_eq! только в демонстрационных целях. Отличным unit-тестом для этого был бы assert, что черновик блог поста возвратил пустую строку из метода content, но мы не собираемся для этого примера писать тесты.

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

Обратите внимание, что единственным типом, с которым мы взаимодействуем из крейта, является тип Post. Этот тип будет использовать state-шаблон, и будет хранить значение, которое будет одним из трех state-объектов, представляющих разные состояния поста, который может быть черновиком (in—draft), в ожидании ревью (review) или быть опубликованным (published). Переход из одного состояния в другое будет управляться внутри типа Post. Состояния меняются в ответ на методы, вызываемые пользователями нашей библиотеки на экземпляре Post, но пользователям не нужно управлять изменениями состояния напрямую. Также пользователи не могут ошибиться с состояниями, наподобие публикации поста до того, как он прошел ревью.

Определение типа Post и создание его нового экземпляра в состоянии Draft. Давайте начнем реализовывать нашу библиотеку! Мы знаем, что нам нужна публичная структура Post, которая хранит некий content, так что мы начнем с определения структуры и связанной с ней публичной функции new для создания экземпляра Post, как показано в листинге 17-12. Мы также создадим приватный трейт State, который будет определять поведение всех state-объектов, которые должны быть для Post.

Тогда Post будет хранить trait-объект Box< dyn State> внутри Option< T> в приватном поле state, чтобы содержать state-объект. Вы увидите через некоторое время, почему необходим параметр Option< T>.

pub struct Post {
    state: Option< Box< dyn State>>,
    content: String,
}

impl Post { pub fn new() -> Post { Post { state: Some(Box::new(Draft {})), content: String::new(), } } }

trait State {}

struct Draft {}

impl State for Draft {}

Листинг 17-12. Определения структуры Post и функции new, которая создает новый экземпляр Post, трейта State и структуры Draft (файл src/lib.rs).

Трейт State определяет поведение, совместно используемое различными состояниями post. State-объекты это Draft, PendingReview и Published, и они все будут реализовать трейт State. Сейчас трейт не имеет никаких методов, и мы начнем определять только состояние черновика (Draft state), потому что это то состояние, с которого мы хотим начинать пост.

Когда мы создали новый Post, мы установим его поле state в значение Some, которое хранит Box. Этот Box указывает на новый экземпляр структуры Draft. Это гарантирует, что всякий раз, когда мы создаем новый экземпляр Post, он будет начинаться как черновик. Поскольку поле state в Post приватное, то нет никакого способа создать Post в каком-либо другом состоянии! В функции Post::new мы установим содержимое поля content в новую, пустую строку String.

Сохранение текста в содержимом Post. В листинге 17-11 мы увидели, что хотим иметь возможность вызвать метод add_text и передать ему &str строки текста, который добавится как содержимое в блок пост. Мы реализовали это как метод вместо того, чтобы выставлять поле content как публичное (к нему не применено ключевое слово pub), так что позже мы реализуем метод, который будет управлять тем, как считываются данные поля content. Метод add_text довольно прост, его реализация добавлена в блок impl Post, см. листинг 17-13 (файл src/lib.rs):

impl Post {
    // -- вырезано --
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

Листинг 17-13. Реализация метода add_text для добавления текста в содержимое (поле content) поста.

Метод add_text принимает мутируемую ссылку на self, потому что мы меняем экземпляр Post, на котором вызывается add_text. Затем мы вызываем push_str на String в content, и передаем аргумент text, чтобы добавить текст в сохраненный content. Это поведение не зависит от состояния, в котором находится пост, так что это не часть state-шаблона. Метод add_text вообще не взаимодействует с полем state, однако это часть поведения, которое мы хотим поддерживать.

Гарантия, что содержимое черновика возвращается пустым. Даже после того, как мы вызвали add_text и добавили некое содержимое в наш пост, мы все еще хотим, чтобы метод content возвратил пустой слайс строки, потому что пост все еще находится в состоянии черновика (draft state), как показано в строке 7 листинга 17-11. Теперь давайте реализуем метод content с простейшим функционалом, который будет удовлетворять этому требованию: всегда будет возвращать пустой слайс строки. Мы поменяем это позже, как только реализуем возможность изменения состояния поста, чтобы его можно было опубликовать. Пока что посты могут находиться только в состоянии черновика, так что содержимое поста должно всегда возвращаться пустым. Листинг 17-14 показывает эту реализацию заполнителя (файл src/lib.rs):

impl Post {
    // -- вырезано --
    pub fn content(&self) -> &str {
        ""
    }
}

Листинг 17-14. Добавление реализации заполнителя для метода content на Post, которая всегда возвращает пустой слайс строки.

С этим добавленным методом content все в листинге 17-11 до строки 7 работает так, как должно.

Запрос ревью поста, меняющий его состояние. Далее нам нужно добавить функционал для запроса ревью поста, который поменяет его состояние Draft на состояние PendingReview. Листинг 17-15 показывает этот код (файл src/lib.rs):

impl Post {
    // -- вырезано --
    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State { fn request_review(self: Box< Self>) -> Box< dyn State>; }

struct Draft {}

impl State for Draft { fn request_review(self: Box< Self>) -> Box< dyn State> { Box::new(PendingReview {}) } }

struct PendingReview {}

impl State for PendingReview { fn request_review(self: Box< Self>) -> Box< dyn State> { self } }

Листинг 17-15. Реализация методов request_review на Post и трейте State.

Мы даем Post публичный метод request_review, который принимает мутируемую ссылку на self. Затем мы вызываем внутренний метод request_review на текущем состоянии Post, и этот второй метод request_review берет текущее состояние и возвращает новое состояние.

Мы добавили метод request_review method для трейта State; все типы, которые реализуют этот трейт, теперь должны реализовать метод request_review. Обратите внимание, что вместо того, чтобы иметь self, &self или &mut self в качестве первого параметра метода, у нас используется self: Box< Self>. Этот синтаксис означает, что этот метод действителен только при вызове на Box, содержащем тип. Этот синтаксис берет во владение Box< Self>, делая недействительным старое состояние, так чтобы значение state на Post могло трансформироваться в новое состояние.

Чтобы потребить старое состояние, методу request_review нужно взять во владение значение state. Вот где появляется Option в поле state на Post: мы вызвали метод take, чтобы взять значение Some из поля state, и оставить None на своем месте, потому что Rust не позволит нам иметь не заполненные поля в структурах. Это позволит нам переместить значение state из Post вместо того, чтобы заимствовать его. Затем мы установим значение state поста на результат этой операции.

Нам временно нужно установить state в None, вместо того чтобы устанавливать его напрямую кодом наподобие self.state = self.state.request_review();, чтобы получить во владение значение state. Это гарантирует, что Post не сможет использовать старое значение state после того, как мы трансформируем его в новое состояние.

Метод request_review на Draft возвратит новый, упакованный экземпляр новой структуры PendingReview, которая представляет состояние, когда пост ждет ревью. Структура PendingReview также реализует метод request_review, однако не делает никаких трансформаций. Вместо этого она возвратит себя, потому что тогда мы запрашиваем ревью на посте, уже находящемся в состоянии PendingReview, и он должен оставаться в состоянии PendingReview.

Теперь мы можем начинать осознавать достоинства state-шаблона: метод request_review на Post такой же, независимо от значения его состояния. Каждое состояние отвечает за свои собственные правила.

Мы оставим метод content на Post как есть, возвращающим пустой слайс строки. Теперь у нас Post делает одно и то же как в состоянии PendingReview, так и в состоянии Draft. Листинг 17-11 теперь работает до строки 10!

Добавление метода approve для изменения поведения метода content. Метод approve будет подобен методу request_review: он установит значение state в значение, которое по текущему состоянию должно быть постом, принятым к публикации (прошедшим проверку, approved), как показано в листинге 17-16 (файл src/lib.rs):

impl Post {
    // -- вырезано --
    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State { fn request_review(self: Box< Self>) -> Box< dyn State>; fn approve(self: Box< Self>) -> Box< dyn State>; }

struct Draft {}

impl State for Draft { // -- вырезано -- fn approve(self: Box< Self>) -> Box< dyn State> { self } }

struct PendingReview {}

impl State for PendingReview { // -- вырезано -- fn approve(self: Box< Self>) -> Box< dyn State> { Box::new(Published {}) } }

struct Published {}impl State for Published { fn request_review(self: Box< Self>) -> Box< dyn State> { self }
fn approve(self: Box< Self>) -> Box< dyn State> { self } }

Листинг 17-16. Реализация метода approve на Post и трейте State.

Мы добавили метод approve в трейт State, и добавили новую структуру, которая реализует State, состояние Published.

Подобно тому, как работает request_review на PendingReview, если мы вызовем метод approve на Draft, он не будет давать никакого эффекта, потому что approve возвратит self. Когда мы вызовем approve на PendingReview, он возвратит новый, упакованный экземпляр структуры Published. Структура Published реализует трейт State, и для обоих методов request_review и approve возвратит саму себя, потому что пост в этих случаях должен оставаться в состоянии Published.

Теперь нам нужно обновить метод content на Post. Мы хотим, чтобы возвращаемое из content значение зависело от текущего состояния Post, так что у нас будет делегат Post для метода content, определенном не его состоянии state, как показано в листинге 17-17 (файл src/lib.rs, этот код не скомпилируется):

impl Post {
    // -- вырезано --
    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // -- вырезано --
}

Листинг 17-17. Обновление метода content на Post, чтобы делегировать метод content на State.

Поскольку цель состоит в том, чтобы сохранить все эти правила внутри структур, которые реализуют State, мы вызовем метод content на значении в state, и передадим экземпляр поста (т. е. self) в качестве аргумента. Затем мы возвратим значение, возвращенное при использовании метода content на значении state.

Мы вызываем метод as_ref на Option потому что хотим получить ссылку на значение внутри Option вместо того, чтобы владеть значением. Поскольку state это Option< Box< dyn State>>, при вызове as_ref возвращается Option< &Box< dyn State>>. Если бы мы не вызывали as_ref, то получили бы ошибку, потому что мы не можем переместить state из заимствованного &self параметра функции.

Затем мы вызываем метод unwrap, который как мы знаем никогда не будет паниковать, потому что методы на Post гарантируют, что всегда будут содержать значение Some, когда эти методы будут выполнены. Это один из случаев, о которых мы говорили в секции "Случаи, когда у вас больше информации, чем у компилятора" главы 9 [6], когда мы заранее знаем, что значение None никогда не появится, даже если компилятор не сможет это определить.

В этом месте, когда вызовем content на &Box< dyn State>, применится разыменование (deref coercion) для & и Box, так что метод content в конечном итоге будет вызван на типе, который реализует трейт State. Это означает, что нам нужно добавить метод content к определению трейта State, и именно здесь мы поместим логику для того, какое содержимое должен возвращать метод content в зависимости о нашего состояния state, как показано в листинге 17-18 (файл src/lib.rs):

trait State {
    // -- вырезано --
    fn content< 'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// -- вырезано --
struct Published {}

impl State for Published { // -- вырезано -- fn content< 'a>(&self, post: &'a Post) -> &'a str { &post.content } }

Листинг 17-18. Добавление метода content к трейту State.

Мы добавили реализацию по умолчанию метода content, который возвращает пустой слайс строки. Это означает, что нам не нужно реализовывать метод content на структурах Draft и PendingReview. Структура Published переопределить метод content, и возвратит значение в post.content.

Обратите внимание, что на этом методе нам понадобились аннотации времени жизни, что обсуждалось в главе 10 [4]. Мы берем ссылку на post в качестве аргумента, и возвращаем ссылку на часть этого post, так что время жизни возвращенной ссылки связано с временем жизни аргумента post.

Мы закончили, листинг 17-11 теперь работает! Мы реализовали state-шаблон с правилами рабочего процесса блог поста. Логика, связанная с правилами, находится в state-объектах вместо того, чтобы быть разбросанной по реализации Post.

Почему не использовалось перечисление? Возможно, вам было интересно, почему в показанном выше примере реализации блог поста не использовалось перечисление с различными возможными состояниями поста в качестве вариантов. Это безусловно было бы возможным решением, попробуйте его и сравните конечные результаты, чтобы понять, что вам больше подходит! Одним из недостатков использования перечисления вместо структуры является то, что каждое место, которое проверяет значение перечисления, нуждается в match-выражении или нечто подобном для обработки каждого варианта перечисления. Это могло бы быть более повторяющимся, чем показанное выше решение на основе trait-объекта.

Компромиссы модели State. Мы показали, что Rust способен реализовывать объектно-ориентированный state-шаблон, чтобы инкапсулировать различные виды поведения поста, которые должны быть для каждого состояния. Методы на Post ничего не знают об этих различных поведениях. С методом, которым мы организовали наш код, мы должны смотреть только в одно место, чтобы знать про различные варианты поведения поста: реализацию трейта State на структуре Published.

Если бы мы создали альтернативную реализацию, которая не использовала бы state-шаблон, то могли бы вместо этого использовать match-выражения в методах на Post или даже в коде main, которые проверяли бы состояние поста и меняли бы поведение в этих местах. Это означало бы, что нам пришлось бы искать в нескольких местах, чтобы понять все реализации поведения поста в опубликованном состоянии! Это только увеличит количество добавленных нами состояний: каждое из этих match-выражений понадобится новое ветвление (arm).

С применением state-шаблона методы Post и места, где мы используем Post, не нуждаются в match-выражениях, и для добавления нового состояния нам понадобится только добавить новую структуру и реализовать методы трейта на этой одной структуре.

Реализация с использованием state-шаблона проста для расширения и более функциональна. Чтобы увидеть простоту обслуживания кода, использующего state-шаблон, попробуйте несколько их этих предложений:

• Добавьте метод reject, который меняет состояние поста из PendingReview обратно в Draft.
• Потребуйте два вызова approve перед тем, как состояние можно будет поменять на Published.
• Разрешите пользователям добавлять содержимое текста только когда пост в состоянии Draft. Подсказка: state-объект отвечает за то, что может измениться в содержимом, но не отвечает за модификацию Post.

Один из недостатков state-шаблона состоит в том, что поскольку состояния реализуют переходы между состояниями, некоторые состояния связаны друг с другом. Если мы добавим другое состояние между PendingReview и Published, такое как Scheduled, то нам понадобится менять код в PendingReview, чтобы в нем теперь осуществлялся переход в Scheduled. Было бы меньше работы, если бы не нужно было менять PendingReview с добавлением нового состояния, но это означало бы необходимость переключиться на другой шаблон дизайна.

Другой недостаток в том, что мы дублируем некоторую логику. Чтобы устранить некоторые дублирования, мы можем попытаться сделать реализации по умолчанию для методов request_review и approve на трейте State, которые возвратят self; однако это нарушило бы безопасность объекта, поскольку трейт не знает, чем конкретно будет self. Мы хотим иметь возможность использовать State как трейт-объект, так что нам нужно, чтобы его методы были безопасны для объектов.

Другие дублирования включают подобные реализации методов request_review и approve на Post. Оба метода делегируют поведение на реализацию такого же метода в поле state Option, и устанавливают новое значение поля state для результата. Если бы у нас было бы много методов на Post, которые следовали бы этому шаблону, то мы могли бы рассмотреть определение макроса для устранения повтора (см. секцию "Macros" в главе 19).

Путем реализации state-шаблона точно так, как это определено в ООП-языках программирования, мы не в полной мере используем сильные стороны Rust, как могли бы. Давайте рассмотрим некоторые изменения, которые мы можем внести в крейт blog, которые могут привести к недопустимым состояниям и ошибкам времени компиляции.

Кодирование состояний и поведения как типов. Покажем, как переосмыслить state-шаблон, чтобы получить другой набор компромиссов в кодировании. Вместо того, чтобы инкапсулировать состояния и переходы полностью, чтобы внешний код ничего про них не знал, мы закодируем состояния в различные типы. Следовательно, система проверки типов Rust не позволит попытки использовать черновики, где разрешены только опубликованные посты, выдавая ошибку компиляции.

Рассмотрим первую часть функции main в листинге 17-11 (файл src/main.rs):

fn main() {
    let mut post = Post::new();
post.add_text("I ate a salad for lunch today"); assert_eq!("", post.content()); }

Мы по-прежнему разрешаем создание новых постов в состоянии черновика, используя Post::new и возможность добавлять текст к содержимому поста. Однако вместо того, чтобы иметь метод content на черновике поста, который возвращает пустую строку, мы сделаем так, что черновики поста вообще не будут иметь метода content. Таким образом, если мы попытаемся получить содержимое черновика поста, то получим ошибку компилятора, говорящую о том, что метод не существует. В результате будет невозможным случайное отображение содержимого черновика поста в релизе программы, потому что подобный код даже не скомпилируется. Листинг 17-19 показывает определение как структур Post и DraftPost, как и методов на каждой из них (файл src/lib.rs):

pub struct Post {
    content: String,
}

pub struct DraftPost { content: String, }

impl Post { pub fn new() -> DraftPost { DraftPost { content: String::new(), } }
pub fn content(&self) -> &str { &self.content } }

impl DraftPost { pub fn add_text(&mut self, text: &str) { self.content.push_str(text); } }

Листинг 17-19. Post с методом content и DraftPost без метода content.

Обе структуры Post и DraftPost содержат приватное поле content, хранящее текст блог поста. В этих структурах больше нет поля state, потому что мы переместили кодирование state в типы структур. Структура Post будет представлять опубликованный пост, и она будет содержать метод content, который возвратит содержимое поста (поле content).

У нас все еще есть функция Post::new, но вместо того, чтобы возвратить экземпляр Post, она возвратит экземпляр DraftPost. Поскольку поле content приватное, и нет ни одной функции, которая возвращает Post, сейчас невозможно создать экземпляр Post.

В структуре DraftPost есть метод add_text, так что мы можем добавить текст к content, как и раньше, но обратите внимание, что DraftPost не имеет определения метода content! Так что теперь программа гарантирует, что все посты, начинающиеся как черновик, и черновики не будут доступны для отображения. Любая попытка обойти эти ограничения приведет к ошибку компилятора.

Реализация переходов и трансформаций как разных типов. Итак, как мы получаем опубликованный пост? Мы хотим обеспечить соблюдение правила, что черновик поста должен быть рассмотрен (прошел ревью) и принят как допустимый (approved) перед тем, как может быть опубликован. Пост, ожидающий в состоянии ревью, все еще не должен отображать какое-либо содержимое. Давайте реализуем эти ограничения добавлением другой структуры PendingReviewPost, определяющей метод request_review на DraftPost, чтобы возвратить PendingReviewPost, и определяющей метод approve на PendingReviewPost для возврата Post, как показано в листинге 17-20 (файл src/lib.rs):

impl DraftPost {
    // -- вырезано --
    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost { content: String, }

impl PendingReviewPost { pub fn approve(self) -> Post { Post { content: self.content, } } }

Листинг 17-20. PendingReviewPost, который создается вызовом request_review на DraftPost, и метод approve, который превращает PendingReviewPost в опубликованный Post.

Методы request_review и approve берут во владение self, тем самым потребляя экземпляры DraftPost и PendingReviewPost, и трансформируя их в PendingReviewPost и опубликованный Post соответственно. Таким образом у нас не будет никаких устаревших экземпляров DraftPost посте того, как мы вызвали request_review на них, и так далее. Структура PendingReviewPost не имеет определенного на ней метода content, так что попытка прочитать её содержимое обернется ошибкой компиляции, как с DraftPost. Поскольку единственный способ получить экземпляр опубликованного Post, у которого есть определенный метод content, это вызвать метод approve на PendingReviewPost, и единственный способ получить PendingReviewPost это вызывать метод request_review на DraftPost, то мы теперь закодировали рабочий процесс блок поста в систему типов.

Но мы также должны внести некоторые небольшие изменения в main. Методы request_review и approve возвращают новые экземпляры вместо модификации структуры, на которой вызываются, так что нам нужно добавить еще теневое присваивание let post =, чтобы сохранить возвращенные экземпляры. Мы также не можем применить assert для черновика и ожидания ревью, чтобы проверить их на пустые строки, и они нам не нужны: мы больше не сможем скомпилировать код, который попытается использовать содержимое постов в этих состояниях. Обновленный код main показан в листинге 17-21 (файл src/main.rs):

use blog::Post;

fn main() { let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
let post = post.request_review();
let post = post.approve();
assert_eq!("I ate a salad for lunch today", post.content()); }

Листинг 17-21. Модификации main для использования новой реализации рабочего процесса блог поста.

Изменения, которые нам нужно было внести в main, чтобы переназначить post, означают, что эта реализация больше не соответствует ООП-модели state-шаблона: трансформации между состояниями больше не инкапсулированы полностью в реализацию Post. Однако наш выигрыш теперь в том, что недопустимые состояния теперь невозможны, потому что система типов обеспечивает проверку типов во время компиляции! Это дает гарантию, что определенные баги, такие как отображение содержимого не опубликованного поста, будут выявлены на ранней стадии разработки и не попадут в релиз программы.

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

Мы увидели, что хотя Rust может реализовать ООП-модели дизайна программы, также доступны и другие модели реализации, такие как кодирование состояния в систему типов. Эти модели имеют другие компромиссы. Хотя вы можете быть хорошо знакомы с ООП-моделями, переосмысление проблемы, чтобы задействовать сильные стороны Rust, могут дать определенные выгоды, потому что потенциально устранят некоторые баги уже во время компиляции. ООП-модели не всегда лучшее решение в Rust из-за его определенных особенностей, таких как система владения (ownership), чего нет в ООП-языках.

[Общие выводы]

Независимо от того, что вы думаете по поводу объектно-ориентированности языка Rust после прочтения этой главы, вы теперь знаете, что можно использовать trait-объекты для получения неких ООП-фич в Rust. Динамическое связывание (dynamic dispatch) может предоставить вашему коду гибкость в обмен на небольшое снижение runtime-производительности. Вы можете использовать эту гибкость, чтобы реализовать ООП-шаблоны, помогающие вам в поддержке кода. У Rust также есть другие фичи, наподобие ownership, которых нет в ООП-языках программирования. ООП-шаблоны не всегда самый эффективный способ получить все сильные стороны программирования на Rust, но это все еще доступная опция.

Далее мы рассмотрим шаблоны, которые являются еще одной особенностью Rust, обеспечивающей большую гибкость. Мы уже раньше кратко их рассматривали, но еще не увидели полных возможностей.

[Ссылки]

1. Object-Oriented Programming Features of Rust rust-lang.org.
2. Rust: итераторы и замыкания.
3. Rust: управление проектами с помощью пакетов, крейтов и модулей.
4. Rust: generic-типы, traits, lifetimes.
5. Rust: коллекции стандартной библиотеки.
6. Rust: обработка ошибок.

 

Добавить комментарий


Защитный код
Обновить

Top of Page