HeadlinesBriefing favicon HeadlinesBriefing.com

Парсить, а не валидировать в Rust: непустые векторы

Hacker News •
×

Как и многие программисты, я нахожу статью Алекси Кинга о «Парсить, а не валидировать» увлекательной, потому что она дает имя одному паттерну, который кажется знакомым и важным — такому, который я наблюдал и использовал в прошлом, не называя его явно. Эта статья — обзор паттерна «Парсить, а не валидировать», примененного к языку программирования Rust (в оригинальной статье используется Haskell). Меня особенно заинтересовало найти образовательные примеры этого паттерна в стандартной библиотеке Rust и в других известных проектах. Не повторяя оригинальную статью (пожалуйста, прочитайте её сначала!), вот её суть. Рассмотрим знаменитый Vec; его первый метод возвращает Option<&T>. Почему? Потому что вектор не гарантированно содержит какие-либо элементы, что делать, если first вызывается на пустом векторе? Возвращение Option в этом случае является идиоматическим в Rust [1], с удобным синтаксическим сахаром для принятия результата функций, которые возвращают Option, и принятия решения, что делать дальше. Так в чём проблема? Представьте, что у нас есть функция для чтения некоторых путей конфигурации из переменной окружения, одновременно обеспечивая инвариант, что список не может быть пустым: use anyhow::{Result, ensure}; fn get_configuration_directories() -> Result<Vec<Path Buf>> { let value = env::var("CONFIG_DIRS").context("could not read CONFIG_DIRS")?; let directories: Vec<Path Buf> = value.split(',').map(str::trim).map(Path Buf::from).collect(); ensure!(!directories.is_empty(), "empty CONFIG_DIRS"); Ok(directories) } Пока всё хорошо. Теперь давайте рассмотрим типичное использование этой функции: fn main() -> Result<()> { let config_dirs = get_configuration_directories()?; match config_dirs.first() { Some(cache_dir) => initialize_cache(cache_dir), None => unreachable!("already checked that CONFIG_DIRS is non-empty"); } Ok(()) } Как только get_configuration_directories возвращает успешный результат, мы гарантируем, что вектор не пуст. И всё же, если мы хотим получить первый элемент этого вектора, нам приходится использовать метод first, который возвращает Option<&T>. Таким образом, мы снова вынуждены обрабатывать потенциально пустой случай (где опция равна None). Как утверждает оригинальная статья, это имеет ряд проблем, связанных с ясностью кода, потенциальными последствиями для производительности и тикающей бомбой, если инвариант когда-либо изменится в get_configuration_directories. Основная проблема заключается в том, что Vec — это фундаментально тип, который может быть пустым; мы можем написать комментарий «Этот вектор не может быть пустым, клянусь!» на всём соответствующем коде, но это не проверяется ничем формально. Тип для «непустого» вектора Решение — это использование системы типов для обеспечения нового установленного инварианта. Мы можем использовать отдельный тип для «вектора, который не может быть пустым»; на самом деле, такие типы уже существуют в нескольких crates Rust — например, nonempty: pub struct Non Empty<T> { pub head: T, pub tail: Vec<T>, } Этот тип не имеет конструктора, который бы позволял «нет элементов»; его new принимает один элемент, и его first возвращает &T без Option: pub const fn new(e: T) -> Self { Self::singleton(e) } pub const fn singleton(head: T) -> Self { Non Empty { head, tail: Vec::new() } } pub const fn first(&self) -> &T { &self.head } Остальная часть crate занимается тем, чтобы сделать Non Empty максимально похожим на обычный Vec, реализуя множество полезных трейтов, а также преобразования, такие как: pub fn from_vec(mut vec: Vec<T>) -> Option<Non Empty<T>> { if vec.is_empty() { None } else { let head = vec.remove(0); Some(Non Empty { head, tail: vec }) } } Посмотрим, как выглядела бы наша функция get_configuration_directories, если бы она возвращала Non Empty вместо обычного Vec: fn get_configuration_directories() -> Result<Non Empty<Path Buf>> { let value = env::var("CONFIG_DIRS").context("could not read CONFIG_DIRS")?; let directories = value.split(',').map(str::trim).map(Path Buf::from).collect(); let Some(directories) = Non Empty::from_vec(directories) else { bail!("CONFIG_DIRS cannot be empty"); }; Ok(directories) } Обратите внимание на использование Non Empty::from_vec здесь — именно здесь устанавливается инвариант. Теперь...

Вопрос