Реализация объектно-ориентированного шаблона проектирования

Шаблон "Состояние" — это объектно-ориентированный шаблон проектирования. Суть шаблона заключается в том, что мы определяем набор состояний, которые может иметь внутреннее значение. Состояния представлены набором объектов состояния, а поведение элемента изменяется в зависимости от его состояния. Мы рассмотрим пример структуры записи в блоге, в которой есть поле для хранения состояния, которое будет объектом состояния из набора «черновик», «обзор» или «опубликовано».

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

Преимуществом шаблона "Состояние" является то, что при изменении требований заказчика программы не требуется изменять код элемента, содержащего состояние, или код, использующий такой элемент. Нам нужно только обновить код внутри одного из объектов состояния, чтобы изменить его порядок действий, либо, возможно, добавить больше объектов состояния.

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

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

  1. Запись в блоге создаётся как пустой черновик.
  2. Когда черновик готов, запрашивается его проверка.
  3. После проверки происходит публикация записи.
  4. Только опубликованные записи блога возвращают содержимое записи на печать, поэтому сообщения, не прошедшие проверку, не могут быть опубликованы случайно.

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

В листинге 17-11 показан этот поток действий в виде кода: это пример использования API, который мы собираемся реализовать в библиотеке (крейте) с именем blog. Он пока не компилируется, потому что крейт blog ещё не создан.

Файл: src/main.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-11/src/main.rs:all}}

Листинг 17-11: Код, демонстрирующий желаемое поведение, которое мы хотим получить в крейте blog

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

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

Обратите внимание, что единственный тип из крейта, с которым мы взаимодействуем - это тип Post. Этот тип будет использовать шаблон "Состояние" и будет содержать значение, которое будет являться одним из трёх объектов состояний, представляющих различные состояния, в которых может находиться запись: "черновик", "ожидание проверки" или "опубликовано". Управление переходом из одного состояния в другое будет осуществляться внутренней логикой типа Post. Состояния будут переключаться в результате реакции на вызов методов экземпляра Post пользователями нашей библиотеки, но пользователи не должны управлять изменениями состояния напрямую. Кроме того, пользователи не должны иметь возможность ошибиться с состояниями, например, опубликовать сообщение до его проверки.

Определение Post и создание нового экземпляра в состоянии черновика

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

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

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-12/src/lib.rs}}

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

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

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

Хранение текста содержимого записи

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

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-13/src/lib.rs:here}}

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

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

Убедимся, что содержание черновика будет пустым

Даже после того, как мы вызвали add_text и добавили некоторый контент в нашу запись, мы хотим, чтобы метод content возвращал пустой фрагмент строки, так как запись всё ещё находится в черновом состоянии, как это показано в строке 7 листинга 17-11. А пока давайте реализуем метод content наиболее простым способом, который будет удовлетворять этому требованию: будем всегда возвращать пустой фрагмент строки. Мы изменим код позже, как только реализуем возможность изменить состояние записи, чтобы она могла бы быть опубликована. Пока что записи могут находиться только в черновом состоянии, поэтому содержимое записи всегда должно быть пустым. Листинг 17-14 показывает такую реализацию-заглушку:

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-14/src/lib.rs:here}}

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

С добавленным таким образом методом content всё в листинге 17-11 работает, как задумано, вплоть до строки 7.

Запрос на проверку записи меняет её состояние

Далее нам нужно добавить функциональность для запроса проверки записи, который должен изменить её состояние с Draft на PendingReview. Листинг 17-15 показывает такой код:

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-15/src/lib.rs:here}}

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

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

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

Чтобы поглотить старое состояние, метод request_review должен стать владельцем значения состояния. Это место, где приходит на помощь тип 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, обёрнутый в Box. Эта структура будет представлять состояние, в котором запись ожидает проверки. Структура PendingReview также реализует метод request_review, но не выполняет никаких преобразований. Она возвращает сама себя, потому что, когда мы запрашиваем проверку записи, уже находящейся в состоянии PendingReview, она всё так же должна продолжать оставаться в состоянии PendingReview.

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

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

Добавление approve для изменения поведения content

Метод approve ("одобрить") будет аналогичен методу request_review: он будет устанавливать у state значение, которое должна иметь запись при её одобрении, как показано в листинге 17-16:

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-16/src/lib.rs:here}}

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

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

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

Теперь нам нужно обновить метод content для Post. Мы хотим, чтобы значение, возвращаемое из content, зависело от текущего состояния Post, поэтому мы собираемся перенести часть функциональности Post в метод content, заданный для state, как показано в листинге 17.17:

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-17/src/lib.rs:here}}

Листинг 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 устроены таким образом, что после их выполнения, в поле state всегда содержится значение Some. Это один из случаев, про которых мы говорили в разделе "Случаи, когда у вас больше информации, чем у компилятора" главы 9 - случай, когда мы знаем, что значение None никогда не встретится, даже если компилятор не может этого понять.

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

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-18/src/lib.rs:here}}

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

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

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

И вот, мы закончили - теперь всё из листинга 17-11 работает! Мы реализовали шаблон "Состояние", определяющий правила процесса работы с записью в блоге. Логика, связанная с этими правилами, находится в объектах состояний, а не разбросана по всей структуре Post.

Почему не перечисление?

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

Компромиссы шаблона "Состояние"

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

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

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

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

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

Одним из недостатков шаблона "Состояние" является то, что поскольку состояния сами реализуют переходы между собой, некоторые из состояний получаются связанными друг с другом. Если мы добавим другое состояние между PendingReview и Published, например Scheduled ("запланировано"), то придётся изменить код в PendingReview, чтобы оно теперь переходило в Scheduled. Если бы не нужно было менять PendingReview при добавлении нового состояния, было бы меньше работы, но это означало бы, что мы переходим на другой шаблон проектирования.

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

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

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

Кодирование состояний и поведения в виде типов

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

Давайте рассмотрим первую часть main в листинге 17-11:

Файл: src/main.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-11/src/main.rs:here}}

Мы по-прежнему поддерживаем создание новых сообщений в состоянии "черновика" с помощью метода Post::new и возможность добавлять текст к содержимому публикации. Но вместо метода content у чернового сообщения, возвращающего пустую строку, мы сделаем так, что у черновых сообщений вообще не будет метода content. Таким образом, если мы попытаемся получить содержимое черновика, мы получим ошибку компилятора, сообщающую, что метод не существует. В результате мы не сможем случайно отобразить черновик содержимого записи в работающей программе, потому что этот код даже не скомпилируется. В листинге 17-19 показано определение структур Post и DraftPost, а также методов для каждой из них:

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-19/src/lib.rs}}

Листинг 17-19: Структура Post с методом content и структура DraftPost без метода content

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

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

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

Реализация переходов в виде преобразований в другие типы

Так как же получить опубликованный пост? Мы хотим обеспечить соблюдение правила, согласно которому черновик записи должен быть рассмотрен и утверждён до того, как он будет опубликован. Запись, находящаяся в состоянии проверки, по-прежнему не должна отображать содержимое. Давайте реализуем эти ограничения, добавив ещё одну структуру, PendingReviewPost, определив метод request_review у DraftPost, возвращающий PendingReviewPost, и определив метод approve у PendingReviewPost, возвращающий Post, как показано в листинге 17-20:

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-20/src/lib.rs:here}}

Листинг 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 =, чтобы сохранять возвращаемые объекты. Также, теперь мы не можем использовать утверждения (assertions) для проверки того является ли содержимое черновиков и записей, находящихся на рассмотрении, пустыми строками, да они нам и не нужны - теперь стало невозможным скомпилировать код, который бы пытался использовать содержимое записей, находящихся в этих состояниях. Обновлённый код в main показан в листинге 17-21:

Файл: src/main.rs

{{#rustdoc_include ../listings/ch17-oop/listing-17-21/src/main.rs}}

Листинг 17-21: Изменения в main, использующие новую реализацию процесса подготовки записи блога

Изменения, которые нам нужно было внести в main, чтобы переназначить post означают, что эта реализация теперь не совсем соответствует объектно-ориентированному шаблону "Состояние": преобразования между состояниями больше не инкапсулированы внутри реализации Post полностью. Тем не менее, мы получили большую выгоду в том, что недопустимые состояния теперь невозможны из-за системы типов и проверки типов, которая происходит во время компиляции! У нас есть гарантия, что некоторые ошибки, такие как отображение содержимого неопубликованной публикации, будут обнаружены до того, как они дойдут до пользователей.

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

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

Итоги

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

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