Rust что такое trait

Обновлено: 07.07.2024

Реализации C++ подчиняются принципу нулевой стоимости: ты не платишь за то, что не используешь [Страуструп, 1994]. Более того: то, что ты используешь, кодируется наилучшим образом.

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

Центральное понятие абстракции в расте — типажи (traits).

  • Типажи в расте играют роль интерфейса. Типаж могут реализовывать несколько типов, а новые типажи могут предоставлять реализации для существующих типов. С другой стороны, если вы хотите абстрагироваться от неизвестного типа, типажи дают возможность указать конкретные требования к этому типу, определяющие ожидаемое от него поведение.
  • К типажам может применяться статическая диспетчеризация. Как и с шаблонами в C++, компилятор может сгенерировать реализацию абстракции для каждого случая её использования. Возвращаясь к мантре C++ — «то, что ты используешь, кодируется наилучшим образом» — абстракция полностью удаляется из скомпилированного кода.
  • К типажам может применяться и динамическая диспетчеризация. Иногда вам действительно нужна косвенная адресация, так что нет смысла «удалять» абстракцию из скомпилированного кода. То же самое представление интерфейса — типаж — может быть использовано для диспетчеризации во время выполнения программы.
  • Типажи решают множество разнообразных проблем помимо простого абстрагирования. Они используются как «маркеры» для типов, например маркер Send , описанный в предыдущем посте. Они же используются для объявления «дополнительных методов», то есть, чтобы добавить новые методы к уже определённому где-то типу. Они заменяют собой традиционную перегрузку методов. А ещё они предоставляют простой способ перегрузки операторов.

Этот обзорный пост, без погружения в детали, пройдётся по каждому указанному пункту, давая представление о том, как этот подход позволяет достигнуть указанных целей.

Основа: методы в расте

Перед погружением в типажи, мы рассмотрим маленькую, но очень важную деталь языка: разницу между методом и функцией.

В расте есть как методы, так и самостоятельные функции, и они тесно связаны друг с другом:


Методы, наподобие to_string , называются «собственными» потому, что они:

  • привязаны к конкретному типу «self» (указанному в заголовке блока impl ),
  • автоматически доступны для всех значений этого типа, то есть, в отличие от функций, собственные методы всегда «в области видимости».


Методы и авто-заимствование — важные аспекты эргономичности раста, поддерживающие простоту API, например интерфейса создания процесса:

Типажи как интерфейс

Интерфейсы описывают ожидания одного кода по отношению к другому, позволяя каждой из частей изменяться независимо друг от друга. В случае типажей это описание вертится вокруг методов.

Возьмём, например, следующий простой типаж, описывающий хеширование:


Чтобы реализовать этот типаж для какого-либо типа, мы должны написать метод hash с соответствующей сигнатурой:

В отличие от собственных методов, методы типажей находятся в области видимости только тогда, когда их типаж в области видимости. Но если предположить, что типаж Hash уже находится в нашей области видимости, вы можете написать true.hash() . Таким образом, реализация типажа расширяет набор методов, доступный для данного типа.

Ну и… это всё! Определение и реализация типажа — не более чем абстрагирование общего интерфейса, которому удовлетворяют несколько типов.

Статическая диспетчеризация

Всё становится интереснее с другой стороны — для пользователей типажей. Самый частый способ использования типажей ­— через использование типового параметризма:


Функция print_hash параметризована неизвестным типом T , но требует, чтобы этот тип реализовал типаж Hash . Что означает, что мы можем использовать её для значений типов bool и i64 :


Параметризованные типами функции после компиляции разворачиваются в конкретные реализации, в результате получаем статическую диспетчеризацию. Здесь, как и с шаблонами C++, компилятор сгенерирует две копии функции print_hash : по версии для каждого используемого вместо типового аргумента типа. В свою очередь, это означает, что внутренний вызов к t.hash() — то место, где используется абстракция — имеет нулевую стоимость, так как он будет скомпилирован в прямой статический вызов к соответствующей реализации метода hash :


Такая модель компиляции не очень полезна для функции вроде print_hash , но весьма удобна для более реалистического использования хеширования. Предположим, что у нас так же есть типаж для сравнение на равенство:


(Тип Self здесь будет заменён на тип, для которого реализован данный типаж; в случае impl Eq for bool он будет соответствовать типу bool .)

Мы можем определить тип-словарь, параметризованный типом T , для которого должны быть реализованы типажи Eq и Hash :


Тогда модель статической компиляции для параметрических типов даст несколько преимуществ:

Каждое использование HashMap с конкретными типами Key и Value приведёт к созданию отдельного конкретного типа HashMap , что означает, что HashMap может содержать ключи и значения непосредственно в своих бакетах, без использования косвенной адресации. А это экономит место, уменьшает количество разименований указателей и позволяет более полно использовать память кеша.

Каждый метод HashMap также скомпилируется в специализированный для заданных типов код. Так что нет дополнительных расходов на диспетчеризацию при вызовах методов hash и eq . Это так же означает, что оптимизатор сможет работать с полностью конкретным кодом — то есть с точки зрения оптимизатора абстракций нет. В частности статическая диспетчеризация позволяет инлайнить параметризованные типами методы.

Вместе с тем, как и в случае шаблонов C++, эти свойства параметрических типов означают, что вы можете писать достаточно высокоуровневые абстракции, которые компилируются в полностью конкретный машинный код, «закодированный наилучшим образом».

Однако, в отличие от шаблонов C++, использование типажей полностью проверяется на корректность типов. То есть когда вы компилируете HashMap сам по себе, его код проверяется на типы только один раз, на корректное использование абстрактных типажей Hash и Eq , а не каждый раз при применении конкретных типов. Что означает как более ясные и ранние ошибки компиляции для авторов библиотек, так и меньшие затраты на проверку типов для пользователей языка (читать «более быстрая компиляция»).

Динамическая диспетчеризация

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

Например, GUI фреймворки часто используют колбеки для реакции на события, вроде клика мышкой:


Для элементов GUI часто характерна поддержка регистрации нескольких колбеков для одного события. С помощью параметрических типов вы могли бы написать что-то такое:


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

Одна из сложностей при этом состоит в том, что мы имеем дело с группой разнородных типов, каждый из которых может иметь разный размер. Так как же нам их расположить в векторе? Решение стандартно: с помощью косвенной адресации. Мы сохраним в векторе указатели на колбеки.


Здесь мы используем типаж так, как будто это тип. Вообще-то в расте типажи — это «безразмерные» типы, что примерно означает, что их можно использовать только через указатели, например с помощью Box (указатель на кучу) или & (любой указатель куда угодно).

В расте тип &ClickCallback или Box называется «объект-типаж» и включает в себя указатель на экземпляр типа T , который реализует заданный типаж ( ClickCallback ), и указатель на таблицу виртуальных методов с указателями на все методы типажа, реализованные для типа T (в нашем случае только метод on_click ). Этой информации достаточно, чтобы корректно определить вызываемый метод во время выполнения программы, при этом сохранить единое представление для всех возможных T . Так что тип Button будет скомпилирован только один раз, а абстракции будут существовать и во время выполнения.

Статическая и динамическая диспетчеризация — дополняющие друг друга инструменты, каждый из которых подходит для своих случаев. Типажи в расте дают единую простую систему интерфейсов, которая может быть использована в обоих случаях с минимальной предсказуемой ценой. Объекты-типажи удовлетворяют принципу Страуструпа «плати по необходимости»: у вас есть таблицы виртуальных методов тогда, когда они нужны, но тот же самый типаж может быть статически развёрнут и убран во время компиляции, когда эта сложность не нужна.

Множество способов использовать типажи

Мы увидели то, как могут работать типажи, и их основные способы применения, но они играют и другие важные роли в расте. Несколько примеров:

  • Замыкания. Как и типаж ClickCallback , замыкания в расте просто отдельные типажи. Подробнее о том, как они устроены, можно почитать в блоге Хуона Вилсона (Huon Wilson) в этом подробном посте.
  • Условные API. Параметрические типы дают возможность реализовать типажи по условию:


Здесь тип Pair реализует типаж Hash тогда, и только тогда, когда его компоненты тоже реализуют этот типаж. Это позволяет использовать один и тот же тип в разных контекстах, при этом поддерживая наиболее широкое API, возможное в каждом контексте. Это настолько обычный для раста подход, что есть даже поддержка для автоматического создания некоторых типажей:

Будущее

Один из основных путей эволюции языка — через существующие в нём абстракции, и раст — не исключение: многие из наших приоритетов после версии 1.0 включают расширение системы типажей тем или иным способом. Вот некоторые примеры:

  • Статическая диспетчеризация по выходным типам. Сейчас можно использовать типовые параметры для входных аргументов методов, но не для выходного типа: нельзя сказать «эта функция возвращает значение какого-то типа, реализующего типаж Iterator », и при этом получить развёртывание абстракции во время компиляции. Это особенно становится проблемой, когда вы хотите вернуть замыкание, и получить для него статическую диспетчеризацию. Это невозможно в современном расте. Мы хотим сделать это возможным, и у нас уже есть некоторые идеи по этому поводу.
  • Специализация. Раст не позволяет перекрываться реализациям типажей, так что двусмысленности по поводу вызываемого метода не возникает. С другой стороны, есть некоторые случаи, когда вы можете написать более общую реализацию, покрывающую множество типов, но потом захотеть сделать более конкретную реализацию для некоторых случаев (что часто требуется, например, при оптимизациях). Мы надеемся, что сможем предложить способы реализовать такое поведение в ближайшем будущем.
  • Типы высшего порядка (ТВП, Higher-kinded types, HKT). Типажи сейчас могут быть применены только к типам, а не конструкторам типов (то есть для Vec реализовать типаж можно, а просто для Vec — нет). Это ограничение делает очень сложным предоставить хороший набор типажей для контейнеров, которые поэтому и отсутствуют в стандартной библиотеке. ТВП — очень большая и важная фича, которая даст огромный толчок к развитию абстракций в расте.

Обработка ошибок в Rust

В Rust есть два перечисления на которых строится, практически, вся обработка ошибок: Option и Result . Рассмотрим их подробнее.

Option

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

Result

В отличие от Option , Result позволяет установить не только отсутствие данных, но и причину, в связи с которой они отсутствуют.

Рассмотрим теперь, как в Rust выразить три действия при ошибке, которые мы перечислили в начале статьи:

Завершить работу приложения.

Пропустить ошибку на более высокий уровень.

Завершаем работу приложения

Так как для обработки ошибок, обычно, используются Option и Result , для завершения работы программы нужно писать что-то вроде:

Для удобства, Option и Result содержат ассоциированную функцию unwrap() , позволяющую не повторять приведённый выше код. Если перечисление находится в состоянии успеха, то unwrap() достаёт данные из перечисления и позволяет с ними работать. В случае ошибки, unwrap() вызывает панику. У unwrap() есть аналог, позволяющий добавить произвольный текст к выводу: expect() .

Обрабатываем ошибку

Вызывая функцию, которая может не сработать, мы получаем в качестве результата Option или Result . Если нам известно, что делать в случае неудачи, мы должны выразить свои намерения через конструкции языка. Рассмотрим пример:

В данном примере мы используем разные способы замены строки настроек, в случае неудачи при её получении:

s1 - явно сопоставляем Option с шаблоном и указываем альтернативу.

s2 - используем функцию unwrap_or_default() , которая в случае отсутствия данных возвращает значение по умолчанию (пустую строку).

s3 - используем unwrap_or() , возвращающую свой аргумент в случае отсутствия данных.

s4 - используем unwrap_or_else() , возвращающую результат вызова переданного в неё функтора в случае отсутствия данных. Такой подход позволяет вычислять значение резервного варианта не заранее, а только в случае пустого Option .

Перечисление Result предоставляет аналогичные методы.

Пропускаем ошибку выше

Для начала, сделаем это вручную. Для Option :

Как видно в примерах, такой подход требует большого количества match конструкций. Это усложняет код, ухудшает его читабельность и добавляет разработчику дополнительной рутинной работы. Во избежание всего этого, создатели языка ввели оператор ? . Расположенный после Option или Result , он заменяет собой match конструкцию. В случае наличия значения, он возвращает его для дальнейшего использования. В случае ошибки, возвращает её из функции. Воспользуемся им в наших примерах. Для Option всё очевидно:

Для Result всё обстоит немного сложнее. Ведь в случае, если происходит LoadDllError , то компилятору нужно как-то преобразовать её в InitModuleError для возврата из функции. Для этого оператор ? пытается найти способ преобразования для этих ошибок. Для того, чтобы создать такой способ, в стандартной библиотеке существует трейт From . Воспользуемся им:

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

Динамические ошибки

Как видно из определения, он требует реализации трейтов Debug и Display . Таким образом, Rust вводит требования для всех типов реализующих Error : уметь выводить отладочную и текстовую информацию о себе. Рассмотрим на примере:

Содержание

The Rust Programming Language

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

Примечание: Типажи похожи на функциональность часто называемую интерфейсами в других языках, хотя и с некоторыми отличиями.

Определение типажа

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

Мы хотим создать библиотеку медиа-агрегатора, которая может отображать сводку данных сохранённых в экземплярах структур NewsArticle или Tweet . Чтобы этого достичь, нам необходимо иметь возможность для каждой структуры сделать короткую сводку на основе имеющихся данных: надо, чтобы обе структуры реализовали общее поведение. Мы можем делать такую сводку вызовом метода summarize у экземпляра объекта. Пример листинга 10-12 иллюстрирует определение типажа Summary , который выражает данное поведение:

Листинг 10-12: Определение типажа Summary , который содержит поведение предоставленное методом summarize

Здесь мы объявляем типаж с использованием ключевого слова trait , а затем его название, которым является Summary в данном случае. Внутри фигурных скобок объявляются сигнатуры методов, которые описывают поведения типов, реализующих данный типаж, в данном случае поведение определяется только одной сигнатурой метода: fn summarize(&self) -> String .

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

Типаж может иметь несколько методов в описании его тела: сигнатуры методов перечисляются по одной на каждой строке и должны закачиваться символом ; .

Реализация типажа у типа

Теперь, после того как мы определили желаемое поведение используя типаж Summary , можно реализовать его у типов в нашем медиа-агрегаторе. Листинг 10-13 показывает реализацию типажа Summary у структуры NewsArticle , которая использует для создания сводки в методе summarize заголовок, автора и место публикации статьи. Для структуры Tweet мы определяем реализацию summarize используя пользователя и полный текст твита, полагая содержание твита уже ограниченным 280 символами.

Код программы 10-13: Реализация типажа Summary для структур NewsArticle и Tweet

Реализация типажа у типа аналогична реализации обычных методов. Разница в том, что после impl мы ставим имя типажа, который мы хотим реализовать, затем используем ключевое слово for , а затем указываем имя типа, для которого мы хотим сделать реализацию типажа. Внутри блока impl мы помещаем сигнатуру метода объявленную в типаже. Вместо добавления точки с запятой в конце, после каждой сигнатуры используются фигурные скобки и тело метода заполняется конкретным поведением, которое мы хотим получить у методов типажа для конкретного типа.

После того, как мы реализовали типаж, можно вызвать его методы у экземпляров NewsArticle и Tweet тем же способом, что и вызов обычных методов, например так:

Данный код напечатает: 1 new tweet: horse_ebooks: of course, as you probably already know, people .

Обратите внимание, что поскольку мы определили типаж Summary и типы NewsArticle и Tweet в одном и том же файле lib.rs примера 10-13, все они находятся в одной области видимости. Допустим, что lib.rs предназначен для крейта, который мы назвали aggregator и кто-то ещё хочет использовать функциональность нашего крейта для реализации типажа Summary у структуры, определённой в области видимости внутри их библиотеки. Им нужно будет сначала подключить типаж в их область видимости. Они сделали бы это, указав use aggregator::Summary; , что позволит реализовать Summary для их типа. Типажу Summary также необходимо быть публичным для реализации в других крейтах, потому мы поставили ключевое слово pub перед trait в листинге 10-12.

Одно ограничение, на которое следует обратить внимание при реализации типажей это то, что мы можем реализовать типаж для типа, только если либо типаж, либо тип являются локальным для нашего крейта. Например, можно реализовать типажи из стандартной библиотеки, такие как Display для пользовательского типа Tweet являющимся частью функциональности крейта aggregator , потому что тип Tweet является локальным в крейте aggregator . Мы также можем реализовать типаж Summary для Vec<T> в нашем крейте aggregator , потому что типаж Summary является локальным для крейта aggregator .

Но мы не можем реализовать внешние типажи для внешних типов. Например, мы не можем реализовать функцию Display для Vec<T> в нашем крейте aggregator , потому что и типаж Display и тип Vec<T> определены в стандартной библиотеке, а не локально в нашем крейте aggregator . Это ограничение является частью свойства программы называемое согласованность, а точнее сиротское правило (orphan rule), называемое так, потому что родительский тип не представлен. Это правило гарантирует, что код других людей не может сломать ваш код и наоборот. Без этого правила два крейта могли бы реализовать один типаж для одинакового типа и Rust не будет знать, какой реализацией пользоваться.

Реализация поведения по умолчанию

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

В примере 10-14 показано, как указать строку по умолчанию для метода summarize из типажа Summary вместо определения только сигнатуры метода, как мы сделали в примере 10-12.

Листинг 10-14. Определение типажа Summary с реализацией метода summarize по умолчанию

Для использования реализации по умолчанию при создании сводки у экземпляров NewsArticle вместо определения пользовательской реализации, мы указываем пустой блок impl с impl Summary for NewsArticle <> .

Хотя мы больше не определяем метод summarize непосредственно в NewsArticle , мы предоставили реализацию по умолчанию и указали, что NewsArticle реализует типаж Summary . В результате мы всё ещё можем вызвать метод summarize у экземпляра NewsArticle , например так:

Этот код печатает New article available! (Read more. ) .

Создание реализации по умолчанию для метода summarize не требует от нас изменений чего-либо в реализации Summary для типа Tweet в листинге 10-13. Причина заключается в том, что синтаксис для переопределения реализации по умолчанию является таким же, как синтаксис для реализации метода типажа, который не имеет реализации по умолчанию.

Реализации по умолчанию могут вызывать другие методы в том же типаже, даже если эти другие методы не имеют реализации по умолчанию. Таким образом, типаж может предоставить много полезной функциональности и только требует от разработчиков указывать небольшую его часть. Например, мы могли бы определить типаж Summary имеющий метод summarize_author , реализация которого требуется, а затем определить метод summarize который имеет реализацию по умолчанию, которая внутри вызывает метод summarize_author :

Чтобы использовать такую версию типажа Summary , нужно только определить метод summarize_author , при реализации типажа для типа:

После того, как мы определим summarize_author , можно вызвать summarize для экземпляров структуры Tweet и реализация по умолчанию метода summarize будет вызывать определение summarize_author которое мы уже предоставили. Так как мы реализовали метод summarize_author типажа Summary , то типаж даёт нам поведение метода summarize без необходимости писать код.

Этот код печатает 1 new tweet: (Read more from @horse_ebooks. ) .

Обратите внимание, что невозможно вызвать реализацию по умолчанию из переопределённой реализации того же метода.

Типажи как параметры

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

Например, в листинге 10-13 мы реализовали типаж Summary для типов структур NewsArticle и Tweet . Можно определить функцию notify которая вызывает метод summarize с параметром item , который имеет тип реализующий типаж Summary . Для этого можно использовать синтаксис &impl Trait , например так:

Вместо конкретного типа у параметра item указывается ключевое слово impl и имя типажа. Этот параметр принимает любой тип, который реализует указанный типаж. В теле notify мы можем вызывать любые методы у экземпляра item , которые должны быть определены при реализации типажа Summary , например можно вызвать метод summarize . Мы можем вызвать notify и передать в него любой экземпляр NewsArticle или Tweet . Код, который вызывает данную функцию с любым другим типом, таким как String или i32 , не будет компилироваться, потому что эти типы не реализуют типаж Summary .

Синтаксис ограничения типажа

Синтаксис impl Trait работает для простых случаев, но на самом деле является синтаксическим сахаром для более длинной формы, которая называется ограничением типажа; это выглядит так:

Эта более длинная форма эквивалентна примеру в предыдущем разделе, но она более многословна. Мы помещаем объявление параметра обобщённого типа с ограничением типажа после двоеточия внутри угловых скобок.

Синтаксис impl Trait удобен и делает более выразительным код в простых случаях. Синтаксис ограничений типажа может выразить большую сложность в других случаях. Например, у нас может быть два параметра, которые реализуют типаж Summary . Использование синтаксиса impl Trait выглядит следующим образом:

Если бы мы хотели, чтобы эта функция позволяла иметь item1 и item2 разных типов, то использование impl Trait было бы уместно (до тех пор, пока оба типа реализуют Summary ). Если мы хотим форсировать, чтобы оба параметра имели одинаковый тип, то это можно выразить только с использованием ограничения типажа, например так:

Обобщённый тип T указан для типов параметров item1 и item2 и ограничивает функцию так, что конкретные значения типов переданные аргументами в item1 и item2 должны быть одинаковыми.

Задание нескольких границ типажей с помощью синтаксиса +

Также можно указать более одного ограничения типажа. Скажем, мы хотели бы использовать в методе notify для параметра item с форматированием отображения, также как метод summarize : для этого мы указываем в определении notify , что item должен реализовывать как типаж Display так и Summary . Мы можем сделать это используя синтаксис + :

Синтаксис + также допустим с ограничениями типажа для обобщённых типов:

При наличии двух ограничений типажа, тело метода notify может вызывать метод summarize и использовать <> для форматирования item при его печати.

Более ясные границы типажа с помощью where

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

можно использовать предложение where , например так:

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

Возврат значений типа реализующего определённый типаж

Также можно использовать синтаксис impl Trait в возвращаемой позиции, чтобы вернуть значение некоторого типа реализующего типаж, как показано здесь:

Используя impl Summary для возвращаемого типа, мы указываем, что функция returns_summarizable возвращает некоторый тип, который реализует типаж Summary без обозначения конкретного типа. В этом случае returns_summarizable возвращает Tweet , но код, вызывающий эту функцию, этого не знает.

Возможность возвращать тип, который определяется только реализуемым им признаком, особенно полезна в контексте замыканий и итераторов, которые мы рассмотрим в Главе 13. Замыкания и итераторы создают типы, которые знает только компилятор или типы, которые очень долго указывать. Синтаксис impl Trait позволяет кратко указать, что функция возвращает некоторый тип, который реализует типаж Iterator без необходимости писать очень длинный тип.

Однако, impl Trait возможно использовать, если возвращаете только один тип. Например, данный код, который возвращает значения или типа NewsArticle или типа Tweet , но в качестве возвращаемого типа объявляет impl Summary , не будет работать:

Возврат либо NewsArticle либо Tweet не допускается из-за ограничений того, как реализован синтаксис impl Trait в компиляторе. Мы рассмотрим, как написать функцию с таким поведением в разделе "Использование объектов типажей, которые разрешены для значений или разных типов" Главы 17.

Исправление кода функции largest с помощью ограничений типажа

Теперь, когда вы знаете, как указать поведение, которое вы хотите использовать для ограничения параметра обобщённого типа, давайте вернёмся к листингу 10-5 и исправим определение функции largest . В прошлый раз мы пытались запустить этот код, но получили ошибку:

В теле функции largest мы хотели сравнить два значения типа T используя оператор больше чем ( > ). Так как этот оператор определён у типажа std::cmp::PartialOrd из стандартной библиотеки как метод по умолчанию, то нам нужно указать PartialOrd в качестве ограничения для типа T : благодаря этому функция largest сможет работать со срезами любого типа, значения которого мы можем сравнить. Нам не нужно подключать PartialOrd в область видимости, потому что он есть в авто-импорте. Изменим сигнатуру largest , чтобы она выглядела так:

На этот раз при компиляции кода мы получаем другой набор ошибок:

Ключевая строка в этой ошибке cannot move out of type [T], a non-copy slice . В нашей необобщённой версии функции largest мы пытались найти самый большой элемент только для типа i32 или char . Как обсуждалось в разделе "Данные только для стека: Копирование" Главы 4, типы подобные i32 и char , имеющие известный размер, могут храниться в стеке, поэтому они реализуют типаж Copy . Но когда мы сделали функцию largest обобщённой, для параметра list стало возможным иметь типы, которые не реализуют типаж Copy . Следовательно, мы не сможем переместить значение из переменной list[0] в переменную largest , в результате чего появляется эта ошибка.

Чтобы вызывать этот код только с теми типами, которые реализуют типаж Copy , можно добавить типаж Copy в список ограничений типа T ! Листинг 10-15 показывает полный код обобщённой функции largest , которая будет компилироваться, пока типы значений среза передаваемых в функцию, реализуют одновременно типажи PartialOrd и Copy , как это делают i32 и char .

Листинг 10-15: Объявление функции largest работающей с любыми обобщёнными типами, которые реализуют типажи PartialOrd и Copy

Если мы не хотим ограничить функцию largest типами, которые реализуют типаж Copy , мы можем указать, что T имеет ограничение типажа Clone вместо Copy . Затем мы могли бы клонировать каждое значение в срезе, если бы хотели чтобы функция largest забирала владение. Использование функции clone означает, что потенциально делается больше операций выделения памяти в куче для типов, которые владеют данными в куче, например для String . В то же время стоит помнить о том, что выделение памяти в куче может быть медленным, если мы работаем с большими объёмами данных.

Ещё один способ, который мы могли бы реализовать в largest - это создать функцию возвращающую ссылку на значение T из среза. Если мы изменим возвращаемый тип на &T вместо T , то тем самым изменим тело функции, чтобы она возвращала ссылку, тогда нам были бы не нужны ограничения входных значений типажами Clone или Copy и мы могли бы избежать выделения памяти в куче. Попробуйте реализовать эти альтернативные решения самостоятельно!

Использование ограничений типажа для условной реализации методов

Используя ограничение типажа с блоком impl , который использует параметры обобщённого типа, можно реализовать методы условно, для тех типов, которые реализуют указанный типаж. Например, тип Pair<T> в листинге 10-16 всегда реализует функцию new . Но Pair<T> реализует метод cmp_display только если его внутренний тип T реализует типаж PartialOrd (позволяющий сравнивать) и типаж Display (позволяющий выводить на печать).

Листинг 10-17: Условная реализация методов у обобщённых типов в зависимости от ограничений типажа

Мы также можем условно реализовать типаж для любого типа, который реализует другой типаж. Реализации типажа для любого типа, который удовлетворяет ограничениям типажа называются общими реализациями и широко используются в стандартной библиотеке Rust. Например, стандартная библиотека реализует типаж ToString для любого типа, который реализует типаж Display . Блок impl в стандартной библиотеке выглядит примерно так:

Поскольку стандартная библиотека имеет эту общую реализацию, то можно вызвать метод to_string определённый типажом ToString для любого типа, который реализует типаж Display . Например, мы можем превратить целые числа в их соответствующие String значения, потому что целые числа реализуют типаж Display :

Общие реализации приведены в документации к типажу в разделе "Implementors".

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

Полезные библиотеки

Рассмотрим две популярные библиотеки, упрощающие обработку ошибок: thiserror и anyhow.

thiserror

Данная библиотека предоставляет макросы, позволяющие упростить рутинные действия: описание способов конвертации ошибок через From , и реализация трейтов Error и Display . Рассмотрим на примере:

Данные макросы значительно сокращают объём boilerplate кода для обработки ошибок.

anyhow

Данную библиотеку удобно использовать, когда единственное, что интересует нас в ошибке - её текстовое описание. anyhow предоставляет структуру Error . В неё может быть сконвертирован любой объект, реализующий трейт std::Error , что значительно упрощает распространение ошибки по иерархии вызовов. Помимо этого, anyhow::Error позволяет добавлять текстовое описание контекста, в котором произошла ошибка. Эта библиотека сочетается с thiserror. Пример:

Макрос anyhow::bail!() в примере создаёт anyhow::Error с заданным описанием и возвращает её из функции. Псевдоним anyhow::Result определяется так:

Трейты

Трейты схожи с концепцией интерфейсов в других языках. Их можно реализовывать на типах, расширяя их функционал. Также, функции могут накладывать ограничение на трейты принимаемых аргументов. Ограничения проверяются при компиляции. Например:

В данном примере мы определили трейт Print и реализовали его для встроенного целочисленного типа i32 . Также, мы определили функцию print_value() , принимающую обобщённый (generic) аргумент value , ограничив варианты его типа только теми, которые реализуют трейт Print . Поэтому в main() мы можем вызвать print_value() только с i32 аргументом.

Более того, при определённых условиях, можно создавать трейт объекты (trait objects). Это динамический объекты, которые могут быть созданы из любого типа, реализующего данный трейт. Конкретная реализация метода трейта выбирается динамически (dynamic dispatch). Например:

В данном коде нет необходимости делать функцию say_something() обобщённой, так как конкретная реализация, скрытая за трейт объектом разрешается во время выполнения программы, а не при компиляции.

Также, стоит упомянуть о том, что трейты могут наследоваться. То что трейт Mammal унаследован от трейта Animal означает, что реализовать трейт Mammal может только тип, реализующий Animal .

Данный код не компилируется, так как мы пытаемся реализовать трейт Mammal на типе Dog , не реализовав Animal , от которого Mammal унаследован.

Перечисления с данными

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

Ключевое слово match позволяет описать действия для различных вариантов перечисления и их содержимого.

Перечисления могут быть обобщенными:

Разобравшись с типажами и перечислениями, можно переходить к механизму обработки ошибок.

Что делать с ошибкой?


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

Завершить работу программы. Это самый простой вариант, не требующий больших усилий от разработчика. Он применим в случаях, когда ошибка не позволяет программе корректно выполнять свои функции. В качестве примера можно рассмотреть приложение, представляющее собой обёртку над некоторой динамической библиотекой. Скажем, графический интерфейс. Приложение поставляется с этой библиотекой и не несёт какой-либо пользы в отрыве от неё. Разумно предположить, что приложение не должно работать без этой библиотеки. Поэтому, вполне обосновано, при ошибке загрузки библиотеки, прерывать работу приложения.

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

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

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

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

Заключение

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

Благодарю за внимание. Поменьше вам ошибок!

Статья написана в преддверии старта курса Rust Developer. Приглашаю всех желающих на бесплатный урок, в рамках которого на примере построения простого веб сервиса рассмотрим популярный веб-фреймворк actix-web в связке с MongoDB + Redis и другие полезные библиотеки для backend разработки.

Rust что такое trait

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

Trait как тип параметров

Формальная установка трейта в качестве типа данных для параметра:

После двоеточия идет оператор impl , а затем указывается название трейта.

Причем обычно передается не просто объект трейта, а ссылка на него, чтобы не менять владельца данных. Поэтому перед оператором impl указывается амперсанд &

Каждая из структур по своему реализует этот метод.

Затем в функции display() мы можем получить ссылку на объект трейта:

Причем для этой функции не важно, ссылка на какой объект будет передаваться параметру. Главное, чтобы он реализовал методы трейта Printer. Сообственно запись &impl Printer и указывается, что это должна быть ссылка на объект, который реализует трейт Printer.

Затем в функции main создаются два объекта, ссылки на которые передаются в функцию display() :

Консольный вывод программы:

Trait как тип результата

Трейт как тип результата определяется следующим образом:

После оператора -> указывается ключевое слово impl , а затем идет название трейта. То есть, как бы говориться, что возвращаемый объект должен реализовать указанный трейт.

Определенная здесь функция create_message() в качестве результата возвращает объект трейта Sender:

Поскольку структура TextMessage реализует трейт Sender, то функция может возвратить объект этой структуры.

В функции main вызываем функцию create_message() , получаем ее результат - объект структуры TextMessage и выполняем ее метод send() :

Однако, эта возможность имеет существенные ограничения. Так, функция должна возвращать объкты только одного и того же типа. Например, в следующем случае мы получим ошибку на этапе компиляции:

В данном случае добавлена структура VoiceMessage, которая тоже реализует трейт Sender. А функция create_message() теперь в зависимости от второго параметра возвращает либо объект TextMessage, либо объект VoiceMessage. И вроде бы все выглядит нормально, так как обе структуры реализуют трейт Sender. Однако при компиляции компилятор радостно сообщит нам, что "if and else have incompatible types". То есть подвыражения в if и else возвращают объекты разных типов, что не допускается.

Обработка ошибок в Rust


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

Немного о синтаксисе Rust


Механизм обработки ошибок включает себя две особенности языка Rust: перечисления с данными и трейты.

Читайте также: