Rust: От &str к Cow

от автора

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

Давайте представим себе, что мы делаем какую-то библиотеку для работы с API сайта example.com, при этом каждый вызов мы будем подписывать токеном, который определим следующим образом:

// Token для example.io API pub struct Token<'a> {     raw: &'a str, }

Затем реализуем функцию new, которая будет создавать экземпляр токена из &str.

impl<'a> Token<'a> {     pub fn new(raw: &'a str) -> Token<'a> {         Token { raw: raw }     } }

Такой наивный токен хорошо работает лишь для статических строчек &'static str, которые непосредственно встраиваются в бинарник. Однако представим, что пользователь не хочет встраивать секретный ключ в код или он хочет загружать его из некоторого секретного хранилища. Мы могли бы написать такой код:

// Вообразим, что такая функция существует let secret: String = secret_from_vault("api.example.io"); let token = Token::new(&secret[..]);

Такая реализация имеет большое ограничение: токен не может пережить секретный ключ, а это означает, что он не может покинуть эту область стека.
А что если Token будет хранить String вместо &str? Это поможет нам избавится от указания параметра времени жизни структуры, превратив её во владеющий тип.

Давайте внесем изменения в Token и функцию new.

struct Token {     raw: String, }  impl Token {     pub fn new(raw: String) -> Token {         Token { raw: raw }     } }

Все места, где предоставляется String должны быть исправлены:

// Это работает сейчас let token = Token::new(secret_from_vault("api.example.io"))

Однако это вредит удобству использования &'str. К примеру, такой код не будет компилироваться:

// не собирается let token = Token::new("abc123");

Пользователь этого API должен будет явным образом преобразовать &'str в String.

let token = Token::new(String::from("abc123"));

Можно попробовать использовать &str вместо String в функции new, спрятав String::from в реализацию, однако в случае String это будет менее удобно и потребует дополнительного выделения памяти в куче. Давайте посмотрим как это выглядит.

// функция new выглядит как-то так impl Token {     pub fn new(raw: &str) -> Token {         Token(String::from(raw))     } }  // &str может передана беспрепятственно let token = Token::new("abc123");  // По-прежнему можно использовать String, но необходимо пользоваться срезами // и функция new должна будет скопировать данные из них let secret = secret_from_vault("api.example.io"); let token = Token::new(&secret[..]); // неэффективно!

Однако, существует способ, как заставить new принимать аргументы обоих типов без необходимости в выделении памяти в случае передачи String.

Встречайте типаж Into

В стандартной библиотеке существует типаж Into, который поможет решит нашу проблему с new. Определение типажа выглядит так:

pub trait Into<T> {     fn into(self) -> T; }

Функция into определяется довольно просто: она забирает self (нечто, реализующее Into) и возвращает значение типа T. Вот пример того, как это можно использовать:

impl Token {     // Создание нового токена     //     // Может принимать как &str так и String     pub fn new<S>(raw: S) -> Token         where S: Into<String>     {         Token { raw: raw.into() }     } }  // &str let token = Token::new("abc123");  // String let token = Token::new(secret_from_vault("api.example.io"));

Здесь происходит много интересного. Во первых, функция имеет обобщенный аргумент raw типа S, строка where ограничивает возможные типа S до тех, которые реализуют типаж Into<String>.
Поскольку стандартная библиотека уже предоставляет Into<String> для &str и String, то наш случай уже ей обрабатывается без дополнительных телодвижений. [1]
Хотя теперь этим API стало гораздо удобнее пользоваться, в нем всё ещё присутствует заметный изъян: передача &str в new требует выделения памяти для хранения как String.

Нас спасет типаж Cow [2]

В стандартной библиотеке есть особый контейнер под названием std::borrow::Cow,
который позволяет нам, сохранить с одной стороны удобство Into<String>, а с другой разрешить структуре владеть значениями типа &str.

Вот страшно выглядящее определение Cow:

pub enum Cow<'a, B> where B: 'a + ToOwned + ?Sized {     Borrowed(&'a B),     Owned(B::Owned), }

Давайте разбираться в этом определении:

Cow<'a, B> имеет два обобщённых параметра: время жизни 'a и некоторый обобщённый тип B, который имеет следующие ограничения: 'a + ToOwned + ?Sized.
Давайте рассмотрим их поподробнее:

  • Тип B не может иметь время жизни короче, чем 'a
  • ToOwnedB должен реализовывать типаж ToOwned, который позволяет передавать заимствованные данные во владение, делая их копию.
  • ?Sized — Размер типа B может быть неизвестен во время компиляции. Это не имеет значения в нашем случае, но это означает, что типажи-объекты могут использоваться вместе с Cow.

Существуют два варианта значений, которые способен хранить в себе контейнер Cow.

  • Borrowed(&'a B) — Ссылка на некоторый объект типа B, при этом время жизни контейнера точно такое же, как у связанного с ним значения B.
  • Owned(B::Owned) — Контейнер владеет значением ассоциированного типа B::Owned

enum Cow<'a, str> {     Borrowed(&'a str),     Owned(String), }

Короче говоря, Cow<'a, str> будет либо &str с временем жизни 'a, либо он будет представлять собой String, который не связян с этим временем жизни.
Это звучит круто для нашего типа Token. Он будет иметь возможность хранить как &str, так и String.

struct Token<'a> {     raw: Cow<'a, str> }  impl<'a> Token<'a> {     pub fn new(raw: Cow<'a, str>) -> Token<'a> {         Token { raw: raw }     } }  // создание этих токенов let token = Token::new(Cow::Borrowed("abc123")); let secret: String = secret_from_vault("api.example.io"); let token = Token::new(Cow::Owned(secret));

Теперь Token может быть создан как из владеющего типа, так из заимствованного, но пользоваться API стало не так удобно.
Into может сделать такие же улучшения для нашего Cow<'a, str>, как сделал для простого String ранее. Финальная реализация токена выглядит так:

struct Token<'a> {     raw: Cow<'a, str> }  impl<'a> Token<'a> {     pub fn new<S>(raw: S) -> Token<'a>         where S: Into<Cow<'a, str>>     {         Token { raw: raw.into() }     } }  // создаем токены. let token = Token::new("abc123"); let token = Token::new(secret_from_vault("api.example.io"));

Теперь токен может быть прозрачно создан как из &str так и из String. Связанное с токеном время жизни больше не проблема для
данных, созданных на стеке. Можно даже пересылать токен между потоками!

let raw = String::from("abc"); let token_owned = Token::new(raw); let token_static = Token::new("123");  thread::spawn(move || {     println!("token_owned: {:?}", token_owned);     println!("token_static: {:?}", token_static); }).join().unwrap();

Однако, попытка отправить токен с не-static временем жизни ссылки потерпит неудачу.

// Сделаем ссылку с нестатическим временем жизни let raw = String::from("abc"); let s = &raw[..]; let token = Token::new(s);  // Это не будет работать thread::spawn(move || {     println!("token: {:?}", token); }).join().unwrap();

Действительно, пример выше не компилируется с ошибкой:

error: `raw` does not live long enough

Если вы жаждите больше примеров, пожалуйста, посмотрите на PagerDuty API client, который интенсивно использует Cow.

Спасибо за чтение!

Примечания

1

Если вы пойдете искать реализации Into<String> для &str и String, вы не найдете их. Это потому, что существует обобщенная реализация Into для всех типов, которые реализуют типаж From, выглядит она следующим образом.

impl<T, U> Into<U> for T where U: From<T> {     fn into(self) -> U {         U::from(self)     } }

2

Примечание переводчика: в оригинальной статье ни слова не сказано про принцип работы Cow или же Copy on write семантики.
Если в кратце, при создании копии контейнера, реальные данные не копируются, реальное же разделение производится лишь при попытке изменить значение, хранящееся внутри контейнера.

ссылка на оригинал статьи https://habrahabr.ru/post/282708/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *