Элегантные API библиотеки в Rust

Перевод | Автор оригинала: Pascal Hertleif

Наличие библиотек с красивыми, удобными интерфейсами - один из важнейших факторов при выборе языка программирования. Вот несколько советов о том, как писать библиотеки с хорошими API в Rust. (Многие пункты применимы и к другим языкам.)

Вы также можете посмотреть мой доклад на Rustfest 2017 об этом!

Обновление 2017-04-27: С момента написания этого поста @brson из команды Rust Libs опубликовал довольно подробный документ с рекомендациями по Rust API, который включает мои советы здесь и многое другое.

Обновление 2020-06-01: этому посту уже несколько лет! Большинство шаблонов все еще действительны и активно используются в Rust сегодня, но язык также претерпел значительные изменения и позволил использовать новые шаблоны, которые здесь не обсуждаются. Я обновил некоторые рекомендации по синтаксису и крэйтам, но в остальном сохранил пост, как это было в 2016 году.

Что делает API элегантным?

Методы

Согласованные имена

Есть несколько RFC Rust, которые описывают схему именования стандартной библиотеки. Вы должны следовать им, чтобы API вашей библиотеки был знаком для пользователей.

Дополнительные соглашения об именах методов

В дополнение к тому, что определяют RFC 199 и RFC 344 (см. Выше), существует еще несколько соглашений о выборе имен методов, которые, похоже, не представлены в RFC (пока). Они в основном задокументированы в старых правилах стиля Rust и в публикации @ llogiq Rustic Bits, а также в строке invalid_self_convention от clippy. Вот краткое изложение:

Method nameParametersNotesExamples
newno self, usually ≥ 11Конструктор, также DefaultBox::new, std::net::Ipv4Addr::new
with_...no self, ≥ 1Альтернативные конструкторыVec::with_capacity, regex::Regex::with_size_limit
from_...1конверсионные трэйтыString::from_utf8_lossy
as_...&selfБесплатное преобразование, дает представление о данныхstr::as_bytes, uuid::Uuid::as_bytes
to_...&selfДорогое преобразованиеstr::to_string, std::path::Path::to_str
into_...self (consumes)Потенциально дорогое преобразование, ср. конверсионные трэйтыstd::fs::File::into_raw_fd
is_...&self (or none)Вероятно, должен вернуть boolslice::is_empty, Result::is_ok
has_...&self (or none)Вероятно, должен вернуть boolregex_syntax::Expr::has_bytes

Док тесты

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

/// Manipulate a number by magic
///
/// # Examples
///
/// ```rust
/// assert_eq!(min( 0,   14),    0);
/// assert_eq!(min( 0, -127), -127);
/// assert_eq!(min(42,  666),   42);
/// ```
fn min(lhs: i32, rhs: i32) -> i32 {
	if lhs < rhs { lhs } else { rhs }
}

Чтобы обеспечить документирование каждого элемента общедоступного API, используйте #! [Deny (missing_docs)]. Возможно, вас заинтересует мой пост, в котором предлагаются соглашения по форматированию документации Rust.

Не вводите "строго" свой API

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

Например: вы хотите, чтобы функция печатала некоторый вводимый текст в цвете, поэтому вы используете цвет параметра типа &str. Это также означает, что вы ожидаете, что ваши пользователи будут писать одно из имен из ограниченного количества названий цветов (например, [«красный», «зеленый», «синий», «светло-золотой стержень желтый»]).

Это не то, что вам следует делать в Rust! Если вы заранее знаете все возможные варианты, используйте перечисление. Таким образом, вам не нужно выполнять синтаксический анализ / сопоставление строки с шаблоном и устранять возможные ошибки, но вы можете быть уверены, что пользователь вашего API может предоставить только допустимые входные данные2.

enum Color { Red, Green, Blue, LightGoldenRodYellow }

fn color_me(input: &str, color: Color) { /* ... */ }

fn main() {
    color_me("surprised", Color::Blue);
}

Модуль, полный констант

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

pub mod output_options {
    pub struct OutputOptions { /* ... */ }
    
    impl OutputOptions { fn new(/* ... */) -> OutputOptions { /* ... */ } }
    
    pub const DEFAULT: OutputOptions = OutputOptions { /* ... */ };
    pub const SLIM: OutputOptions = OutputOptions { /* ... */ };
    pub const PRETTY: OutputOptions = OutputOptions { /* ... */ };
}

fn output(f: &Foo, opts: OutputOptions) { /* ... */ }

fn main() {
    let foo = Foo::new();
    
    output(foo, output_options::PRETTY);
}

Собственно разбор строки с помощью FromStr

Могут быть случаи, когда у пользователей вашего API действительно есть строки, например считывая переменные среды или принимая их пользовательский ввод, т. е. они не записывали (статические) строки в свой код для передачи в ваш API (что мы пытаемся предотвратить). В таких случаях имеет смысл взглянуть на то, что может дать вам трэйта FromStr, которая абстрагируется от концепции синтаксического анализа строки в тип данных Rust.

Если все, что вы хотите сделать, это сопоставить строку с именем варианта перечисления с правильным вариантом перечисления, вы можете адаптировать этот макрос (из этого твита; для этого также может быть крэйт).

В зависимости от вашего API вы также можете решить, чтобы ваши пользователи занимались синтаксическим анализом строки. Если вы предоставите правильные типы и реализации, это не составит труда (но, тем не менее, это необходимо задокументировать).

// Option A: You do the parsing
fn output_a(f: &Foo, color: &str) -> Result<Bar, ParseError> {
    // This shadows the `options` name with the parsed type
    let color: Color = options.parse()?;

    f.to_bar(&color)
}

// Option B: User does the parsing
fn output_b(f: &Foo, color: &Color) -> Bar {
    f.to_bar(color)
}

fn main() {
    let foo = Foo::new();

    // Option A: You do the parsing, user needs to deal with API error
    output_a(foo, "Green").expect("Error :(");

    // Option B: User has correct type, no need to deal with error here
    output_b(foo, Color::Green);

    // Option B: User has string, needs to parse and deal with parse error
    output_b(foo, "Green".parse().except("Parse error!"));
}

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

В официальной книге есть замечательная глава об обработке ошибок.

Есть несколько крэйтов, чтобы уменьшить шаблон, необходимый для хорошей обработки ошибок, например, так или иначе (динамический тип ошибки с методами аннотирования и связывания ошибок), и thiserror (упрощает создание пользовательских типов ошибок).

Псевдонимы открытого типа

Если ваш внутренний код снова и снова использует универсальные типы с одними и теми же параметрами, имеет смысл использовать псевдоним типа. Если вы также предоставляете эти типы своим пользователям, вы также должны предоставить (и задокументировать) псевдоним типа.

Обычный случай, когда это используется, - это типы Result<T, E>, где случай ошибки (E) фиксирован. Например, std::io::Result - это псевдоним для Result<T, std::io::Error>, std::fmt::Result - это псевдоним для Result<(), std::fmt::Error>, а serde_json::error::Result - это псевдоним для Result<T, serde_json::error::Error>.

Использовать особенности преобразования

Хорошей практикой является никогда не использовать &string или &Vec в качестве входных параметров, а вместо этого использовать &str и &[T], поскольку они позволяют передавать больше типов (в основном, все, что изменяет ссылку на (строковый) фрагмент).

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

У std::convert есть для этого кое-что:

Вам также может понравиться эта статья об удобных и идиоматических преобразованиях в Rust.

Корова

Если вы имеете дело с множеством вещей, которые могут или не могут нуждаться в распределении, вам также следует изучить Cow <'a, B>, который позволяет вам абстрагироваться от заимствованных и собственных данных.

Пример: std::convert::Into

fn foo(p: PathBuf)fn foo<P: Into<PathBuf>>(p: P)
Пользователям необходимо преобразовать свои данные в PathBuf.Библиотека может вызывать для них .into()
Пользователь делает аллокациюМенее очевидно: библиотеке может потребоваться выделение памяти
Пользователь должен заботиться о том, что такое PathBuf и как его получить.Пользователь может просто указать String, OsString или PathBuf и быть счастливым.

Into<Option<_>>

Этот запрос на перенос, который появился в Rust 1.12, добавляет impl From для Option. Хотя это всего несколько строк, это позволяет вам писать API-интерфейсы, которые можно вызывать, не набирая все время Some(…).

До:

// Easy for API author, easy to read documentation
fn foo(lorem: &str, ipsum: Option<i32>, dolor: Option<i32>, sit: Option<i32>) {
    println!("{}", lorem);
}

fn main() {
    foo("bar", None, None, None);               // That looks weird
    foo("bar", Some(42), None, None);           // Okay
    foo("bar", Some(42), Some(1337), Some(-1)); // Halp! Too… much… Some…!
}

После:

// A bit more typing for the API author.
// (Sadly, the parameters need to be specified individually – or Rust would
// infer the concrete type from the first parameter given. This reads not as
// nicely, and documentation might not look as pretty as before.)
fn foo<I, D, S>(lorem: &str, ipsum: I, dolor: D, sit: S) where
    I: Into<Option<i32>>,
    D: Into<Option<i32>>,
    S: Into<Option<i32>>,
{
    println!("{}", lorem);
}

fn main() {
    foo("bar", None, None, None); // Still weird
    foo("bar", 42, None, None);   // Okay
    foo("bar", 42, 1337, -1);     // Wow, that's nice! Gimme more APIs like this!
}

Замечание о возможно долгом времени компиляции

Если у вас есть:

  1. множество параметров типа (например, для трэйтов преобразования)
  2. на сложной / большой функции
  3. который используется много

тогда rustc нужно будет скомпилировать множество перестановок этой функции (он мономорфизирует общие функции), что приведет к длительному времени компиляции.

bluss упомянул на Reddit, что вы можете использовать «де-генерализацию», чтобы обойти эту проблему: ваша (общедоступная) универсальная функция просто вызывает другую (частную) неуниверсальную функцию, которая будет скомпилирована только один раз.

В примерах, которые привел Bluss, была реализация std::fs::OpenOptions::open (исходный код из Rust 1.12) и этот запрос на перенос крэйта изображений, который изменил его функцию open на это:

pub fn open<P>(path: P) -> ImageResult<DynamicImage> where P: AsRef<Path> {
    // thin wrapper function to strip generics before calling open_impl
    open_impl(path.as_ref())
}

Лень (Laziness)

Хотя в Rust нет «лени» в смысле ленивого вычисления выражений, как это реализовано в Haskell, есть несколько методов, которые вы можете использовать, чтобы элегантно опустить ненужные вычисления и выделения.

Использовать итераторы

Одна из самых удивительных конструкций в стандартной библиотеке - это Iterator, свойство, которое позволяет выполнять итерацию значений, подобную генератору, где вам нужно только реализовать следующий метод3. Итераторы Rust ленивы в том смысле, что вам явно нужно вызвать потребителя, чтобы начать итерацию по значениям. Просто напишите "привет" .chars(). Filter (char::is_whitespace) ничего не сделаете, пока вы не вызовете для него что-то вроде .collect::().

Итераторы как параметры

Использование итераторов в качестве входных данных может затруднить чтение вашего API (например, T: Iterator <Item = Thingy> vs &[Thingy]), но позволяет пользователям пропускать выделения.

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

Одним из преимуществ реализации IntoIterator является то, что ваш тип будет работать с синтаксисом цикла for в Rust.

То есть все, что пользователь может использовать в цикле for, они также могут передать вашей функции.

Возврат / реализация итераторов

Если вы хотите вернуть что-то, что ваши пользователи могут использовать в качестве итератора, лучше всего определить новый тип, реализующий итератор. Это может стать проще, когда Im Trait стабилизируется (см. Проблему с отслеживанием). Вы можете найти немного больше информации об этом в учебнике по футурам (поскольку возвращение Future и Iterator имеет схожие характеристики).

Итераторные трэйты

Есть несколько библиотек, которые реализуют трейты, такие как Iterator, например:

Взять закрытие

Если потенциально дорогостоящее значение (например, типа Value) не используется во всех ветвях вашего потока управления, рассмотрите возможность замыкания, которое возвращает это значение (Fn() -> Value).

Если вы разрабатываете трэйту, у вас также могут быть два метода, которые делают то же самое, но в которых один принимает значение, а другой - замыкание, которое вычисляет значение. Реальный пример этого шаблона находится в Result с unwrap_or и unwrap_or_else:

let res: Result<i32, &str> = Err("oh noes");
res.unwrap_or(42); // just returns `42`

let res: Result<i32, &str> = Err("oh noes");
res.unwrap_or_else(|msg| msg.len() as i32); // will call the closure

Ленивые уловки

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

Удобства

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

Пользовательские характеристики для входных параметров

В Rust можно реализовать своего рода «перегрузку функции», используя универсальную трэйту T для одного входного параметра и реализуя T для всех типов, которые функция должна принимать.

Пример: str::find

str::find<P: Pattern>(p: P) accepts a Pattern which is implemented for char, str, FnMut(char) -> bool, etc.

"Lorem ipsum".find('L');
"Lorem ipsum".find("ipsum");
"Lorem ipsum".find(char::is_whitespace);

Расширение черт

Рекомендуется использовать типы и свойства, определенные в стандартной библиотеке, поскольку они известны многим программистам на Rust, хорошо протестированы и хорошо задокументированы. И хотя стандартная библиотека Rust имеет тенденцию предлагать типы с семантическим значением4, методов, реализованных на этих типах, может быть недостаточно для вашего API. К счастью, «сиротские правила» Rust позволяют реализовать трейт для (универсального) типа, если хотя бы один из них определен в текущем крейте.

Декорирование результатов

Как пишет Флориан в «Украшении результатов», вы можете использовать это для написания и реализации трейтов для предоставления ваших собственных методов встроенным типам, таким как Result. Например:

pub trait GrandResultExt {
    fn party(self) -> Self;
}

impl GrandResultExt for Result<String, Box<Error>> {
    fn party(self) -> Result<String, Box<Error>> {
        if self.is_ok() {
          println!("Wooohoo! 🎉");
        }
        self
    }
}

// User's code
fn main() {
    let fortune = library_function()
        .method_returning_result()
        .party()
        .unwrap_or("Out of luck.".to_string());
}

Реальный код Флориана в лазерах использует тот же шаблон для украшения BoxFuture (из крэйта Futures), чтобы сделать код более читабельным (сокращенно):

let my_database = client
    .find_database("might_not_exist")
    .or_create();

Расширение черт

До сих пор мы расширили методы, доступные для типа, путем определения и реализации нашей собственной характеристики. Вы также можете определить трэйты, которые расширяют другие трэйты (трэйта MyTrait: BufRead + Debug {}). Наиболее ярким примером этого является крэйт itertools, который добавляет длинный список методов к итераторам std.

К вашему сведению: RFC 445 хочет, чтобы вы добавили суффикс Ext к трэйтам расширения.

Шаблон строителя

Вы можете упростить выполнение сложных вызовов API, объединив несколько более мелких методов вместе. Это хорошо работает с типами сеансов (см. Ниже). крэйт derive_builder можно использовать для автоматической генерации (более простых) построителей для пользовательских структур.

Пример: std::fs::OpenOptions

use std::fs::OpenOptions;
let file = OpenOptions::new().read(true).write(true).open("foo.txt");

Типы сессий

Вы можете закодировать конечный автомат в системе типов.

  1. Каждое состояние отличается от другого.
  2. Каждый тип состояния реализует разные методы.
  3. Некоторые методы используют тип состояния (принимая владение им) и возвращают другой тип состояния.

Это очень хорошо работает в Rust, поскольку ваши методы могут перемещать ваши данные в новый тип, и вы больше не можете получить доступ к старому состоянию после этого.

Вот произвольный пример отправки пакета по почте:

let p: OpenPackage = Package::new();
let p: OpenPackage = package.insert([stuff, padding, padding]);

let p: ClosedPackage = package.seal_up();

// let p: OpenPackage = package.insert([more_stuff]);
//~^ ERROR: No method named `insert` on `ClosedPackage`

let p: DeliveryTracking = package.send(address, postage);

Хороший пример из реальной жизни был дан /u/ssokolow в этой ветке на /r/rust:

Hyper использует это, чтобы гарантировать во время компиляции, что невозможно попасть в такие ситуации, как «попытка установить заголовки HTTP после начала тела запроса / ответа», которые мы периодически видим на сайтах PHP. (Компилятор может это уловить, потому что в соединении в этом состоянии нет метода «установить заголовок», а признание устаревших ссылок недействительными позволяет ему быть уверенным, что используется только правильное состояние.)

В документации по hyper::server немного подробно рассказывается о том, как это реализовано. Еще одну интересную идею можно найти в крэйте lazers-replicator: он использует std::convert::From для перехода между состояниями.

Дополнительная информация:

Правильно используйте время жизни

Определение ограничений типа и трэйтов для вашего API имеет важное значение для разработки API на статически типизированном языке и, как написано выше, помогает вашим пользователям предотвращать логические ошибки. Система типов Rust также может кодировать другое измерение: вы также можете описывать время жизни ваших данных (и писать ограничения на время жизни).

Это может позволить вам (как разработчику) более спокойно относиться к выдаче заемных ресурсов (вместо более дорогостоящих с точки зрения вычислений собственных данных). Использование ссылок на данные там, где это возможно, определенно является хорошей практикой в Rust, поскольку высокая производительность и библиотеки с «нулевым распределением» являются одним из преимуществ языков.

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

По какой-то причине (вероятно, для краткости) многие жизни называются «a», «b» или чем-то подобным бессмысленным. Если вы знаете ресурс, в течение которого действительны ваши ссылки, вы, вероятно, сможете найти лучшее имя. Например, если вы читаете файл в память и работаете со ссылками на эту память, вызовите файл времени жизни. Или, если вы обрабатываете TCP-запрос и анализируете его данные, вы можете вызвать его время жизни 'req.

Поместите код финализатора в drop

Правила владения Rust работают не только с памятью: если ваш тип данных представляет внешний ресурс (например, TCP-соединение), вы можете использовать трейт Drop, чтобы закрыть / освободить / очистить ресурс, когда он выходит за пределы области видимости. Вы можете использовать это так же, как и финализаторы (или попробовать… поймать… наконец) на других языках.

Примеры из реальной жизни:

Примеры использования

Возможные библиотеки Rust, которые используют несколько хороших трюков в своих API:

Другие шаблоны проектирования

Я попытался описать здесь шаблоны проектирования интерфейсов, то есть API-интерфейсы, доступные пользователю. Хотя я считаю, что некоторые из этих шаблонов применимы только для написания библиотек, многие также применимы для написания общего кода приложения.

Вы можете найти дополнительную информацию по этой теме в репозитории Rust Design Patterns.


1. Если вы можете построить свой тип без каких-либо параметров, вы должны реализовать для него Default и использовать его вместо new. Исключением являются новые типы «контейнеров», такие как Vec или HashMap, где имеет смысл инициализировать пустой контейнер.

2. В других строго типизированных языках есть лозунг «сделать незаконные государства непредставимыми». Хотя я впервые услышал это, когда говорил с людьми о Haskell, это также название этой статьи, написанной F # для развлечения и наживы, и выступления Ричарда Фельдмана, представленного на elm-conf 2016.

3. В этом отношении итераторы Rust очень похожи на интерфейс Iterator в Java или протокол Iteration в Python (а также на многие другие).

4. Например, std имеет тип Result (с вариантами Ok и Err), который следует использовать для обработки ошибок, вместо типа Either (с вариантами Left и Right), что не подразумевает этого значения.

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