Одной из первых вещей, которые я написал на 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
ToOwned
—B
должен реализовывать типаж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 семантики.
Если в кратце, при создании копии контейнера, реальные данные не копируются, реальное же разделение производится лишь при попытке изменить значение, хранящееся внутри контейнера.