Элегантные API библиотеки в Rust
Перевод | Автор оригинала: Pascal Hertleif
Наличие библиотек с красивыми, удобными интерфейсами - один из важнейших факторов при выборе языка программирования. Вот несколько советов о том, как писать библиотеки с хорошими API в Rust. (Многие пункты применимы и к другим языкам.)
Вы также можете посмотреть мой доклад на Rustfest 2017 об этом!
Обновление 2017-04-27: С момента написания этого поста @brson из команды Rust Libs опубликовал довольно подробный документ с рекомендациями по Rust API, который включает мои советы здесь и многое другое.
Обновление 2020-06-01: этому посту уже несколько лет! Большинство шаблонов все еще действительны и активно используются в Rust сегодня, но язык также претерпел значительные изменения и позволил использовать новые шаблоны, которые здесь не обсуждаются. Я обновил некоторые рекомендации по синтаксису и крэйтам, но в остальном сохранил пост, как это было в 2016 году.
Что делает API элегантным?
- Код, использующий API, легко читается благодаря очевидным и понятным названиям методов.
- Понятные имена методов также помогают при использовании API, поэтому меньше необходимости читать документацию.
- У всего есть хоть какая-то документация и небольшой пример кода.
- Пользователям API необходимо написать небольшой шаблонный код, чтобы использовать его, как
- методы принимают широкий диапазон допустимых типов ввода (где преобразования очевидны), и
- доступны ярлыки для быстрого выполнения «обычных» задач.
- Типы грамотно использованы для предотвращения логических ошибок, но не мешайте слишком сильно.
- Возвращаемые ошибки полезны, а случаи паники четко задокументированы.
Методы
Согласованные имена
Есть несколько RFC Rust, которые описывают схему именования стандартной библиотеки. Вы должны следовать им, чтобы API вашей библиотеки был знаком для пользователей.
- RFC 199 объясняет, что вы должны использовать mut, move или ref в качестве суффиксов, чтобы различать методы на основе изменчивости их параметров.
- RFC 344 определяет некоторые интересные соглашения, например
- как ссылаться на типы в именах методов (например, &mut [T] становится mut_slice, а * mut T становится mut_ptr),
- как вызывать методы, возвращающие итераторы,
- методы получения должны называться field_name, а методы установки должны быть set_field_name,
- и как называть трэйты характера: «Отдавайте предпочтение (переходным) глаголам, существительным, а затем прилагательным; избегайте грамматических суффиксов (например, способный) », но также« если есть единственный метод, который является доминирующей функциональностью трэйта, подумайте об использовании того же имени для самого трэйта ».
- RFC 430 описывает некоторые общие соглашения о регистрах (tl;dr CamelCase для материалов уровня типа, snake_case для материалов уровня значений).
- RFC 445 требует, чтобы вы добавили суффикс Ext к трэйтам расширения.
Дополнительные соглашения об именах методов
В дополнение к тому, что определяют RFC 199 и RFC 344 (см. Выше), существует еще несколько соглашений о выборе имен методов, которые, похоже, не представлены в RFC (пока). Они в основном задокументированы в старых правилах стиля Rust и в публикации @ llogiq Rustic Bits, а также в строке invalid_self_convention от clippy. Вот краткое изложение:
Method name | Parameters | Notes | Examples |
---|---|---|---|
new | no self, usually ≥ 11 | Конструктор, также Default | Box::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) | Вероятно, должен вернуть bool | slice::is_empty, Result::is_ok |
has_... | &self (or none) | Вероятно, должен вернуть bool | regex_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
Использовать особенности преобразования
Хорошей практикой является никогда не использовать &string или &Vec
Мы можем применить ту же идею на более абстрактном уровне: вместо использования конкретных типов для входных параметров попробуйте использовать универсальные типы с точными ограничениями. Обратной стороной этого является то, что документация будет менее читабельной, поскольку она будет полна обобщений со сложными ограничениями!
У std::convert есть для этого кое-что:
- AsMut: дешевое, изменяемое преобразование ссылки в изменяемую ссылку.
- AsRef: дешевое преобразование ссылки в ссылку.
- От: сконструировать себя через преобразование.
- В: преобразование, которое потребляет самого себя, что может быть дорогостоящим, а может и не стоить.
- TryFrom: попытка сконструировать себя посредством преобразования. (Нестабильно с Rust 1.10)
- TryInto: попытка преобразования, потребляющая самого себя, что может быть дорогостоящим, а может и нет. (Нестабильно с Rust 1.10)
Вам также может понравиться эта статья об удобных и идиоматических преобразованиях в 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
До:
// 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!
}
Замечание о возможно долгом времени компиляции
Если у вас есть:
- множество параметров типа (например, для трэйтов преобразования)
- на сложной / большой функции
- который используется много
тогда 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, например:
- Futures::Stream: Как написано в учебнике по футурам, где Iterator::next возвращает OptionSelf::Item, Stream::poll возвращает асинхронный результат OptionSelf::Item (или ошибку).
Взять закрытие
Если потенциально дорогостоящее значение (например, типа 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 реализует макрос, который сделает это за вас (хотя для этого требуются нестабильные функции).
Удобства
Вот некоторые особенности, которые вы должны попробовать реализовать, чтобы упростить / согласовать использование ваших типов для ваших пользователей:
- Внедрить или получить "обычные" трэйты, такие как Debug, Hash, PartialEq, PartialOrd, Eq, Ord.
- Реализовать или получить значение по умолчанию вместо написания нового метода без аргументов
- Если вы обнаружите, что реализуете метод для типа, чтобы возвращать некоторые данные типа в качестве итератора, вам также следует подумать о реализации IntoIterator для этого типа. (Это работает только тогда, когда есть только один очевидный способ перебора данных вашего типа. Также см. Раздел об итераторах выше.)
- Если ваш настраиваемый тип данных можно рассматривать аналогично примитивному типу данных T из std, рассмотрите возможность реализации Deref <Target = T>. Но, пожалуйста, не переусердствуйте - Дереф не предназначен для имитации наследования!
- Вместо написания метода конструктора, который принимает строку и создает новый экземпляр вашего типа данных, реализуйте FromStr.
Пользовательские характеристики для входных параметров
В 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");
Типы сессий
Вы можете закодировать конечный автомат в системе типов.
- Каждое состояние отличается от другого.
- Каждый тип состояния реализует разные методы.
- Некоторые методы используют тип состояния (принимая владение им) и возвращают другой тип состояния.
Это очень хорошо работает в 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 для перехода между состояниями.
Дополнительная информация:
- В статье «За пределами безопасности памяти с помощью типов» описывается, как эту технику можно использовать для реализации красивого и безопасного интерфейса для протокола IMAP.
- Статья Томаса Брахта Лауманна Джесперсена, Филипа Мунксгаарда и Кена Фрииса Ларсена «Типы сессий для Rust» (PDF). DOI.
- В сообщении Эндрю Хобдена «Симпатичные шаблоны конечных автоматов в Rust» показано несколько способов реализации конечных автоматов в системе типов Rust: использование одного перечисления для всех состояний, явных структур, базовой структуры, обобщенной над структурами состояний, и переходов с использованием Into.
Правильно используйте время жизни
Определение ограничений типа и трэйтов для вашего API имеет важное значение для разработки API на статически типизированном языке и, как написано выше, помогает вашим пользователям предотвращать логические ошибки. Система типов Rust также может кодировать другое измерение: вы также можете описывать время жизни ваших данных (и писать ограничения на время жизни).
Это может позволить вам (как разработчику) более спокойно относиться к выдаче заемных ресурсов (вместо более дорогостоящих с точки зрения вычислений собственных данных). Использование ссылок на данные там, где это возможно, определенно является хорошей практикой в Rust, поскольку высокая производительность и библиотеки с «нулевым распределением» являются одним из преимуществ языков.
Однако вам следует попытаться написать хорошую документацию по этому поводу, поскольку понимание времени жизни и работа со ссылками может представлять проблему для пользователей вашей библиотеки, особенно когда они плохо знакомы с Rust.
По какой-то причине (вероятно, для краткости) многие жизни называются «a», «b» или чем-то подобным бессмысленным. Если вы знаете ресурс, в течение которого действительны ваши ссылки, вы, вероятно, сможете найти лучшее имя. Например, если вы читаете файл в память и работаете со ссылками на эту память, вызовите файл времени жизни. Или, если вы обрабатываете TCP-запрос и анализируете его данные, вы можете вызвать его время жизни 'req.
Поместите код финализатора в drop
Правила владения Rust работают не только с памятью: если ваш тип данных представляет внешний ресурс (например, TCP-соединение), вы можете использовать трейт Drop, чтобы закрыть / освободить / очистить ресурс, когда он выходит за пределы области видимости. Вы можете использовать это так же, как и финализаторы (или попробовать… поймать… наконец) на других языках.
Примеры из реальной жизни:
- Типы счетчика ссылок Rc и Arc используют Drop для уменьшения счетчика ссылок (и освобождения внутренних данных, если счетчик достигает нуля).
- MutexGuard использует Drop для снятия блокировки мьютекса.
- В дизельном крэйте реализована функция Drop для закрытия соединений с базой данных (например, в SQLite).
Примеры использования
Возможные библиотеки Rust, которые используют несколько хороших трюков в своих API:
- гипер: типы сеансов (см. выше)
- дизель: кодирует запросы SQL как типы, использует трэйты со сложными связанными типами
- футуры: очень абстрактный и хорошо задокументированный крэйт
Другие шаблоны проектирования
Я попытался описать здесь шаблоны проектирования интерфейсов, то есть 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), что не подразумевает этого значения.
Спасибо за чтение.