От & str к Cow

оригинал: Joe Wilm • перевод: Алексей Сидоров • обучение • поддержите на Patreon

Эта статья — перевод статьи From & str to Cow за авторством Joe Wilm

От & str к Cow

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

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

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

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

1
2
3
4
5
impl<'a> Token<'a> {
    pub fn new(raw: &'a str) -> Token<'a> {
        Token { raw: raw }
    }
}

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

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

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

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

1
2
3
4
5
6
7
8
9
struct Token {
    raw: String,
}

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// функция 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. Определение типажа выглядит так:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 ранее. Финальная реализация токена выглядит так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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. Связанное с токеном время жизни больше не проблема для данных, созданных на стеке. Можно даже пересылать токен между потоками!

1
2
3
4
5
6
7
8
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 временем жизни ссылки потерпит неудачу.

1
2
3
4
5
6
7
8
9
// Сделаем ссылку с нестатическим временем жизни
let raw = String::from("abc");
let s = &raw[..];
let token = Token::new(s);

// Это не будет работать
thread::spawn(move || {
    println!("token: {:?}", token);
}).join().unwrap();

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

1
error: `raw` does not live long enough

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

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

Примечания

1

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

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

2

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