Съдържание на курса

Урок 4 — Колекции и грешки

От Dorian Chávez · основател на Hábil и архитект на интеграции ·

Време: 2 × 45 мин.

Какво изграждаш: списъкът със услуги и докладът, с истински грешки

Какво научаваш: Vec, HashMap, String срещу &str, Result и ?, panic! срещу Result, anyhow и thiserror

The Rust Book, глави 8 и 9. Rustlings: vecs, hashmaps, strings, error_handling.

След края ще можеш да

  • Съхраняваш списък от Servicio във Vec<Servicio> и да го обхождаш, без да се бориш с ownership.
  • Избираш между индексиране на една колекция и използване на методи, които връщат Option.
  • Съхраняваш състояния по име в HashMap<String, Estado> и да произвеждаш подреден доклад.
  • Обясниш защо една функция обикновено получава &str, докато един struct обикновено съхранява String.
  • Разпространяваш грешки от файлове и от валидиране с Result и ?.
  • Разграничаваш грешка, която потребителят на програмата може да поправи, от нарушен инвариант, която оправдава panic!.
  • Добавяш контекст към грешка на приложение с anyhow и да разпознаваш кога една библиотека се нуждае от thiserror.

Защо, преди как

В урок 3 моделира една услуга и различните резултати от нейната проверка. Този модел все още трябва да живее някъде. Програмата получава много услуги, не само една; трябва да ги запази, докато пита всеки URL адрес, да свързва всеки резултат с неговата услуга и после да отпечата доклад. В малка програма можеш да напишеш две или три променливи ръчно. В истинския revisor тази стратегия престава да върши работа в момента, в който YAML файлът донесе променлив брой услуги.

Колекциите решават частта „колко стойности има“, но сами не решават какво означава да липсва една стойност или какво трябва да направи програмата, когато нещо външно се провали. Един файл може да не съществува, един ред може да има невалиден формат, името на една услуга може да се повтори и един URL може да няма схема. Нито един от тези случаи не е рядък или невъзможен: всички се случват, защото програмата получава данни отвън. Важната разлика е, че Rust ти иска тази възможност да е представена в типа на върнатата стойност.

Vec<Servicio> представя подреден списък от услуги. HashMap<String, Estado> представя асоциация по ключ: при дадено име "catalogo" търси състоянието му. String е текст, който притежава паметта си; &str е заета гледка към текст, който притежава някой друг. А Result<T, E> представя операция, която може да завърши със стойност T или с грешка E. Това не са четири несвързани теми: те са частите, които придават явна форма на състоянието на програмата.

Курсът по Go изгражда същия revisor. В Go четенето на несъществуващ ключ от map връща нулевата стойност и те принуждава да помниш формата с два резултата, за да различиш „не съществува“ от „съществува и е нула“. Rust избира друг договор: HashMap::get връща Option<&V>. Отсъствието се появява в типа и не може да се обърка с реално състояние. Цената е, че трябва да решиш какво да правиш с None; ползата е, че това решение не може да се забрави, без кодът да го направи видимо.

С грешките става нещо подобно. Go използва конвенцията if err != nil след всяка операция, която може да се провали. Rust използва Result и позволява разпространението да се пише с ?. Няма универсален отговор кой стил е по-четим: Go повтаря много явна структура; Rust концентрира същото решение в един оператор. Важното е, че и двата те задължават да се погрижиш за грешката. Rust не превръща липсващ файл в празен низ и не оставя неуспешно преобразуване да продължи, сякаш е валидно.

Този урок не търси да използваш unwrap(), за да накараш компилатора да мълчи. Търси да четеш сигнатурата на всяка функция като договор. Ако една функция връща Option, трябва да помислиш какво означава отсъствието. Ако връща Result, трябва да решиш дали грешката се решава там, трансформира се или се разпространява. Ако получава &str, само трябва да чете текст; ако получава String, вероятно възнамерява да го запази. Сигнатурите описват потока на данните и потока на грешките, преди програмата да се изпълни.

Понятията

Vec<T>: списък, собственик на стойности от един и същ тип

Vec<T> е векторът на Rust: колекция с променлив размер, която притежава елементите си. Параметърът T казва какъв тип стойности може да съхранява. Vec<Servicio> съхранява само услуги; Vec<Estado> съхранява само състояния. Това ограничение не е случайно неудобство. Позволява на компилатора да знае как трябва да управлява всеки елемент, кои методи са валидни и кои операции биха могли да местят или заемат стойности.

Празният вектор се нуждае от анотация на типа, ако Rust не може да го изведе. Затова фигурата пише let mut v: Vec<Servicio> = Vec::new();. Компилаторът още не е видял нито един елемент и не може да налучка какво ще има вътре. Ако създадеш вектора с vec![...] или ако контекстът вече определя типа, обикновено не е нужно да го пишеш. Думата mut е необходима, защото push променя колекцията: добавя елемент и може да накара вектора да резервира повече място.

Векторът е собственик на всяко Servicio, което получава. При v.push(s) променливата s се премества във вектора. Това прилага правилата на ownership от урок 2: след като преместиш едно Servicio, не можеш да продължиш да използваш предишната променлива, сякаш все още го притежава. Това не е неявно копиране. Ако трябва да запазиш друга независима версия, трябва да проектираш операцията да заема или да клонираш съзнателно, когато цената и семантиката го оправдават.

Има два начина да прочетеш един елемент. &v[0] произвежда референция и предполага, че индексът съществува. Ако не съществува, програмата влиза в panic!. v.get(0) връща Option<&Servicio>: Some(референция), ако съществува, и None, ако е извън диапазона. Вторият начин е подходящ, когато индексът идва от файл, аргумент, заявка или каквато и да е данна, която не контролираш напълно. Първият е разумен, когато чупенето на програмата разкрива програмна грешка, която е трябвало да бъде избегната чрез предишно валидиране.

Фиг. 4.1 | Колекциите и безопасният достъп до тях.

// fig04_01.rs
use std::collections::HashMap;

struct Servicio {
    nombre: String,
}

#[derive(Debug)]
enum Estado {
    Ok,
    Falla,
}

fn main() {
    let s = Servicio { nombre: "catalogo".to_string() };
    let mut v: Vec<Servicio> = Vec::new();
    v.push(s);
    let primero = &v[0];                    // si no existe: panic
    println!("{}", primero.nombre);
    let primero = v.get(0);                 // devuelve Option<&Servicio>
    println!("{}", primero.is_some());

    let mut m: HashMap<String, Estado> = HashMap::new();
    m.insert("catalogo".to_string(), Estado::Ok);
    println!("{:?}", m.get("catalogo"));
    println!("{:?}", m.get("pagos"));       // Option<&Estado>: no hay valor cero silencioso
    println!("{:?}", Estado::Falla);

    let mut conteo: HashMap<String, u32> = HashMap::new();
    *conteo.entry("reportes".into()).or_insert(0) += 1;   // el patrón para contar
    *conteo.entry("reportes".into()).or_insert(0) += 1;
    println!("{:?}", conteo.get("reportes"));
}
$ rustc --edition 2024 fig04_01.rs && ./fig04_01
catalogo
true
Some(Ok)
None
Falla
Some(2)

Не използвай индекси, за да обхождаш вектор по навик. Когато единственото, което ти трябва, е да посетиш всеки елемент, for servicio in &servicios изразява по-добре намерението и избягва изчисленията с индекси. Когато ти трябва номерът на позицията, използвай enumerate(): for (i, servicio) in servicios.iter().enumerate(). Стойността i остава свързана с правилния елемент и няма риск да напишеш случайно i + 1 при четене.

Важно е и че референцията към елемент на вектора е заемане на целия вектор. Добавянето на елементи може да изисква преместване на цялото хранилище в друга зона на паметта. Затова Rust не позволява да запазиш let primero = &v[0], после да извикаш v.push(...) и отново да използваш primero. Ограничението избягва висящи референции: адреси, които преди са сочели към валиден елемент, а сега биха сочели към освободена памет.

revisor поддържа списъка със услуги като вектор, защото файлът декларира последователност, а докладът трябва да запази този концептуален ред. Функциите за доклад получават заети slice-ове, &[Servicio] и &[Estado], вместо да вземат векторите. Един slice дава достъп до последователност, без да прехвърля собствеността на колекцията. Така main може да отпечата доклада и после да прегледа състоянията, за да избере изходния код.

pub fn tabla(servicios: &[Servicio], estados: &[Estado]) -> String {
    let filas = ordenadas(servicios, estados);
    let ancho = filas
        .iter()
        .map(|(s, _)| s.nombre.chars().count())
        .max()
        .unwrap_or(0)
        .max("SERVICIO".len());

Стойността на filas е Vec<Fila<'a>>: нов списък от двойки референции. Не клонира нито услугите, нито състоянията, за да може да ги подреди; създава референции към двете. Това различие има значение в програма, която може да обработва големи списъци. Притежаването на нови данни струва памет и работа по копиране; заемането на съществуващи данни запазва един източник на истина и показва, че докладът само наблюдава.

HashMap<K, V>: търсене по ключ, без да измисляш отсъствия

HashMap<K, V> съхранява асоциации между ключ и стойност. За revisor естествен ключ би било името на услугата, а стойността — нейното състояние: "catalogo" -> Estado::Ok { ... }. Това е полезна колекция, когато знаеш какво искаш да търсиш, но не знаеш на коя позиция в един списък е. Линейното търсене във Vec означава да преглеждаш елементи, докато намериш един; търсенето по ключ в една карта изразява директно въпроса.

Операцията insert поема притежанието на ключа и на стойността. Затова фигурата строи "catalogo".to_string(): картата трябва да притежава един String, който да оцелее след края на извикването. get, напротив, заема. Типът на m.get("catalogo") е Option<&Estado>, не Estado, защото ключът може да не съществува и защото картата запазва собствеността на състоянието.

Това е важна разлика с Go. Достъп като m["pagos"] в map[string]Estado на Go връща нулевата стойност, ако ключът не съществува. Ако Estado съдържа числа, тази нула може да изглежда като реален отговор. В Rust None съобщава отсъствие, което трябва да обработиш. Можеш да използваш match, if let Some(estado) = ... или методи като unwrap_or, когато стойност по подразбиране е наистина правилна за домейна.

Шаблонът entry(...).or_insert(0) избягва да се правят две търсения, когато искаш да обновиш брояч. entry представя позицията на ключ, която може да е заета или свободна. or_insert(0) оставя съществуващата стойност или вмъква нула и връща променлива референция към брояча. * дереференцира тази променлива референция, за да може да се приложи += 1. Това не е декоративен синтаксис: Rust разделя точно съхранената стойност от заемането, което позволява да се променя.

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

Текущият проект не използва HashMap за своя доклад. Използва два паралелни вектора: услуги и състояния, и двата в един и същ ред. Това позволява на една услуга да запази позицията си от YAML до резултата от запитването. В момента на отпечатване функцията ordenadas образува заети двойки и ги подрежда по nombre. Понятието, което споделя с HashMap, е решаващо: хранилището може да има реда, удобен за работа, но публичният изход трябва да налага собствения си ред изрично.

fn ordenadas<'a>(servicios: &'a [Servicio], estados: &'a [Estado]) -> Vec<Fila<'a>> {
    let mut filas: Vec<Fila<'a>> = servicios.iter().zip(estados).collect();
    filas.sort_by(|a, b| a.0.nombre.cmp(&b.0.nombre));
    filas
}

Lifetime-ът 'a ще се появи подробно в урок 5. Засега стига да го четеш като гаранция: всяка двойка Fila съдържа референции, които не могат да живеят по-дълго от входните slice-ове. Векторът filas е собственик на двойките, но не е собственик на услугите нито на състоянията. Когато tabla приключи, временните референции изчезват; оригиналните вектори си остават собственост на main.

String и &str: да притежаваш текст срещу да четеш гледка

Rust различава текста, който притежава данни, от текста, който само заема гледка. String е UTF-8 низ, променлив и с променлив размер; обикновено живее в хийпа и е собственик на байтовете си. &str е референция към UTF-8 последователност, която вече съществува някъде другаде. Литерал като "catalogo" има тип &'static str: това е гледка към текст, съхранен в бинарния файл и достъпен през цялото изпълнение.

Практическото правило е просто: получавай &str, съхранявай String. Функция, която само ще чете едно име, не трябва да получава собствеността, нито да задължава извикващия да създаде копие. Struct, който трябва да запази името, след като приключи извикването, наистина трябва да е собственик на един String. Това правило не е абсолютно, но избягва две обичайни грешки: да приемаш String по рефлекс и да завършиш със ненужно местене на стойности, или да се опитваш да запазиш референция към текст, чийто собственик ще изчезне.

Фиг. 4.2 | Получавай &str, приема и двата.

// fig04_02.rs
fn saludar(n: &str) { println!("hola, {n}"); }

fn main() {
    let propio = String::from("catalogo");
    saludar("pagos");
    saludar(&propio);
}
$ rustc --edition 2024 fig04_02.rs && ./fig04_02
hola, pagos
hola, catalogo

Извикването saludar(&propio) работи чрез принудително преобразуване (coercion): референция към String може да се използва там, където се очаква &str. Не копирай низа, нито пиши propio.to_string(), за да „излезе сметката“. Функцията иска само четене, така че заемането е правилната операция. Освен това propio остава достъпен след извикването.

fn saludar(n: String) { }

Тази сигнатура се компилира, но съобщава нещо друго: този, който извиква, трябва да предаде собствеността на един String. Не приема литерал без преобразуване и не позволява да продължиш да използваш оригиналния String след извикването. Такъв API би могъл да е правилен, ако функцията ще запази, трансформира или върне текста като собственик, но е лош избор за функция, която само отпечатва или сравнява.

String не допуска индексиране с цели числа, сякаш всеки символ заема един байт. Rust използва UTF-8; една видима буква може да заема няколко байта. Да се позволи nombre[3] би било двусмислено: четвъртият байт, четвъртата Unicode стойност или четвъртата видима група? Затова трябва да решиш мерната единица, която ти трябва. s.as_bytes() работи с байтове, s.chars() работи със стойности char, а s.get(rango) връща Option<&str>, когато диапазонът може да попадне по средата на кодиране. Това ограничение не позволява да се разцепи един UTF-8 низ на невалидно място.

revisor съхранява nombre като String, защото стойността идва от YAML и трябва да живее във всяко Servicio. Когато изчислява ширината на една колона, не брои байтове: използва chars().count(). Това не решава всички подробности за визуалната ширина в Unicode, но избягва третирането на многобайтов символ като няколко символа при броенето.

        .iter()
        .map(|(s, _)| s.nombre.chars().count())
        .max()
        .unwrap_or(0)
        .max("SERVICIO".len());

Не превръщай всичко в String „за всеки случай“. Едно преобразуване може да задели памет и, преди всичко, да скрие кой трябва да притежава текста. Започни от сигнатурата: ако функцията само чете, &str; ако резултатът трябва да оцелее независимо, String. Ако трябва да приемаш няколко типа, които могат да се гледат като текст, ще опознаеш AsRef<str> и generic trait-ове в урок 5, но не ги използвай, преди един API наистина да ги изисква.

Result<T, E> и ?: да направиш видим пътя на грешката

Result<T, E> е enum на стандартната библиотека с два варианта: Ok(T) и Err(E). Функция, която връща Result<String, std::io::Error>, обещава едно от две неща: ще върне успешно прочетен текст или ще върне входно-изходната грешка, която е попречила да се прочете. Не връща празен текст, за да сигнализира провал, и не отпечатва грешка вътре във функция, която може би ще се използва от друго място.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Операторът ? работи върху Result. Ако получи Ok(valor), извлича valor и изпълнението продължава. Ако получи Err(error), прекратява текущата функция с тази грешка, като я преобразува към декларирания тип грешка, когато съществува валидно преобразуване. Не игнорира грешката и не я превръща в паника. Това е компактен начин да се напише решение, което си остава задължително.

Фиг. 4.3 | Операторът ? връща грешката на извикващия.

// fig04_03.rs
fn leer_config(ruta: &str) -> Result<String, std::io::Error> {
    let contenido = std::fs::read_to_string(ruta)?;   // si falla, retorna el error
    Ok(contenido)
}

fn main() {
    match leer_config("servicios-que-no-existe.txt") {
        Ok(texto) => println!("{texto}"),
        Err(e) => println!("error: {e}"),
    }
}
$ rustc --edition 2024 fig04_03.rs && ./fig04_03
error: No such file or directory (os error 2)

Функцията leer_config не знае дали един липсващ файл е фатален за цялото приложение, възстановим по друг път или очакван от тест. Затова връща грешката. main наистина решава как да я съобщи: на тази фигура я отпечатва. В продукционен бинарен файл обикновено би я записал в изхода за грешки и би завършил с ненулев код, така че човек и автоматизация да могат да различат успех от провал.

Сравнението с Go е пряко. Тези четири реда Rust:

let a = paso1()?;
let b = paso2(a)?;
let c = paso3(b)?;
Ok(c)

изразяват верига от операции, която в Go обикновено се пише с проверка if err != nil след всяка стъпка. Rust намалява повторението, но не намалява отговорността. Всяко ? бележи място, където функцията може да излезе по-рано. Ако по-нататък програмата трябва да почисти ресурси, да трансформира грешка или да вземе алтернатива, трябва да го решиш преди или след тази точка.

revisor използва ?, за да чете файла и да десериализира YAML. И двата провала са част от стартирането на програмата: няма валиден списък със услуги без четим файл нито без валиден YAML. Функцията връща anyhow::Result<Vec<Servicio>>, което позволява да се обединят грешки от различни типове, без да се губят съобщенията им.

use anyhow::{Context, Result};
pub fn cargar(ruta: &str) -> Result<Vec<Servicio>> {
    // with_context agrega a qué archivo se refería el error, como el %w de Go
    let txt = std::fs::read_to_string(ruta).with_context(|| format!("leyendo {ruta}"))?;
    Ok(yaml_serde::from_str(&txt)?)
}

Едно пояснение за името: yaml_serde::from_str е същият from_str, който предлагаше serde_yaml, предишният crate, който вече не се поддържа. В урок 6 ще видиш защо revisor използва първия.

with_context добавя информация, която операционната система не знае. Първоначалната грешка може да казва „No such file or directory“, но контекстът пояснява кой файл се е опитвал да прочете revisor. Това е разликата между технически правилна диагностика и диагностика, по която може да се действа. Операторът ? запазва тази верига от причини, когато връща грешката.

Забележи също, че не всички провали на едно запитване са Err. Функцията revisar на проекта връща Estado, дори когато една услуга не отговаря. Това е правилно, защото „една услуга се провали“ е данна, която докладът трябва да покаже, а не невъзможност да продължи програмата. Result представя, че самата програма не е могла да завърши една необходима операция; Estado::Falla представя нормален резултат от домейна на revisor. Изборът между двете зависи от това кой трябва да реши какво да се прави и дали програмата все още може да произведе полезен резултат.

panic!: спиране за бъгове, не заместител на Result

panic! прекратява нормалния поток на изпълнение, защото програмата е срещнала условие, което собствените ѝ предположения обявяваха за невъзможно. Индексирането на вектор извън диапазона предизвиква паника. Извикването на unwrap() върху None или върху Err също. Тези механизми съществуват, защото има инварианти, които, ако се нарушат, разкриват програмна грешка, а не ситуация, която един потребител трябва да поправи.

Липсващият файл не е нарушен инвариант: може да липсва заради грешно написан път, права, непълно разгръщане или решение на този, който изпълнява бинарния файл. Трябва да е Result. HTTP отговор 500 също не е причина да се влезе в паника: това е състояние, което revisor е построен да докладва. Индекс, изчислен от файл, също не бива да се използва с [] без валидиране; използвай get и върни грешка, която обяснява проблема.

Фиг. 4.4 | Уловена паника, за да се покаже, че не е Result.

// fig04_04.rs
fn main() {
    let previo = std::panic::take_hook();
    std::panic::set_hook(Box::new(|_| {}));

    let resultado = std::panic::catch_unwind(|| {
        panic!("la lista validada no puede estar vacia");
    });

    std::panic::set_hook(previo);
    println!("hubo panico: {}", resultado.is_err());
}
$ rustc --edition 2024 fig04_04.rs && ./fig04_04
hubo panico: true

Фигурата улавя паниката само за да изолира демонстрацията. Това не е нормалният шаблон на едно приложение. catch_unwind не превръща паниките в здрав контрол на потока и не гарантира, че една структура ще остане в състояние, годно да продължи да се използва. В ежедневния код, ако мислиш да възстановяваш panic!, предизвикан от външни данни, почти винаги трябва да преработиш функцията, за да връща Result.

.unwrap() и .expect("mensaje") са потенциални паники. expect е за предпочитане, когато има конкретна и неизменна причина да се вярва, че няма да се провали, защото съобщението му документира тази причина. Не пиши expect("debe funcionar"): не обяснява нищо. Полезното съобщение назовава предположението, като „семафорът никога не се затваря“, и дава ясно да се разбере какво трябва да се проучи, ако се случи.

Проектът използва expect, за да вземе разрешение от един вътрешен семафор. Това не е грешка, предизвикана от YAML файла или от някой URL; би била противоречие в координацията, която самата програма е построила. Затова е едно от малкото места, където паниката има смисъл.

            let _turno = turnos.acquire().await.expect("el semáforo nunca se cierra");

Не копирай този шаблон за входно-изходни операции. File::open(ruta).expect(...) превръща несъществуващ път в прекратяване на процеса и елиминира възможността main да отпечата файла, да използва друга стойност или да избере подходящ изходен код. Първо попитай дали случаят може да се случи с валидни данни отвън. Ако отговорът е да, върни Result.

anyhow и thiserror: две различни роли за собствени грешки

Стандартната библиотека стига за много малки програми: можеш да връщаш Result<T, std::io::Error>, когато всеки съществен провал е входно-изходен. Една реална програма обикновено комбинира няколко типа: std::io::Error, грешка на YAML, невалиден URL, аргумент на командния ред или правило за валидиране. Ако всеки слой трябва да познава всички тези конкретни типове, сигнатурите стават трудни за поддръжка.

anyhow решава добре границата на едно приложение. Неговият Result<T> е съкратен начин да върнеш динамична грешка, която може да съдържа различни причини и допълнителен контекст. revisor е бинарен файл: чете конфигурация, стартира запитвания и представя съобщения на човек. На тази граница приоритетът е да се обясни коя операция се е провалила и да се запази веригата от причини. Затова config::cargar използва anyhow::{Context, Result}.

Това не означава, че anyhow е разрешение да се заличава значение. Ако една функция връща състояние, което друга част от програмата трябва да различава, за да взема решения, собствен enum може да е по-добър. Например, една библиотека, която трябва да позволи на извикващия да различава NombreRepetido, UrlSinEsquema и TimeoutCero, не бива да връща само низ. Трябва да публикува тип грешка с варианти, които представят тези причини.

thiserror помага да се декларира такъв собствен тип грешка, без да се пишат ръчно повтарящи се имплементации на Display, Error и преобразувания от вътрешни грешки. Използва се най-вече в библиотеки, където типът на грешката е част от публичния API. Cargo.lock на проекта съдържа thiserror, но Cargo.toml на revisor не го декларира като пряка зависимост и сегашният му код не излага собствен enum на грешки. Затова revisor не го използва: грешките му са текстови съобщения, които anyhow съпровожда с контекст.

#[derive(Debug, thiserror::Error)]
enum ErrorConfiguracion {
    #[error("el nombre «{0}» está repetido")]
    NombreRepetido(String),
    #[error("la URL «{0}» no tiene esquema HTTP")]
    UrlSinEsquema(String),
    #[error("no se pudo leer la configuración")]
    Lectura(#[from] std::io::Error),
}

Този фрагмент илюстрира API на библиотека, не е част от сегашния revisor. #[from] позволява автоматично да се преобразува std::io::Error в ErrorConfiguracion, така че ? да остане полезен. Останалите варианти запазват данни, които извикващият може да инспектира чрез match. В приложение с един слой да превърнеш накрая тези грешки в anyhow::Error може да е удобно; в библиотека да ги скриеш твърде рано отнема възможности на този, който я ползва.

Практическата граница е тази: anyhow за изпълнение на приложение и обясняване на цялостен провал; thiserror за предлагане на договор за грешки, които други програми трябва да обработват по вариант. Можеш да комбинираш двете, но не ги добавяй по мода. Започни с типа, който позволява на следващия слой да вземе правилното решение.

Грешката, която ще видиш

E0277: използване на ? във функция, която не може да върне грешка

Най-честата грешка при започване на работа с ? се появява, когато функцията декларира просто връщане, като String, но вътре се опитва да разпространи Result. Rust не може да измисли къде да запази грешката, нито как да я съобщи на извикващия. Следващото реално изпълнение, с rustc 1.98.1, чете програмата от стандартния вход, затова компилаторът назовава файла <anon>; с файл на диск ще видиш името му вместо <anon>.

fn leer() -> String {
    let texto = std::fs::read_to_string("faltante.txt")?;
    Ok(texto)
}

fn main() {}
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> <anon>:2:56
  |
1 | fn leer() -> String {
  | ------------------- this function should return `Result` or `Option` to accept `?`
2 |     let texto = std::fs::read_to_string("faltante.txt")?;
  |                                                        ^ cannot use the `?` operator in a function that returns `String`

error[E0308]: mismatched types
 --> <anon>:3:5
  |
1 | fn leer() -> String {
  |              ------ expected `String` because of return type
2 |     let texto = std::fs::read_to_string("faltante.txt")?;
3 |     Ok(texto)
  |     ^^^^^^^^^ expected `String`, found `Result<String, _>`
  |
  = note: expected struct `String`
               found enum `Result<String, _>`

error: aborting due to 2 previous errors

Some errors have detailed explanations: E0277, E0308.
For more information about an error, try `rustc --explain E0277`.

E0277 казва, че ? се нуждае от функция, способна да върне остатък от грешка. E0308 е следствието: Ok(texto) е Result, но сигнатурата е обещавала String. Поправката не е да махнеш ? и да използваш unwrap(). Трябва да поправиш договора, така че да описва реалната възможност за провал.

fn leer() -> Result<String, std::io::Error> {
    let texto = std::fs::read_to_string("faltante.txt")?;
    Ok(texto)
}

Ако си в main, можеш също да върнеш Result, когато грешката трябва да прекрати програмата. Въпреки това сегашният revisor трябва да контролира какво се отпечатва и какъв изходен код се връща, затова main превръща резултата от config::cargar във видим изход и ExitCode::from(2). Съобщението отива в stderr; успешната таблица или JSON остават достъпни в stdout.

Паника поради индекс извън диапазона

Достъпът v[indice] не е грешка на компилация, ако indice е променлива. Rust не може да знае по време на компилация какво число ще дойде. Ако числото попадне извън диапазона, програмата влиза в паника по време на изпълнение. Диагностиката споменава поисквания индекс и реалната дължина на вектора. Например, искането на позиция 3 от списък с три елемента се проваля, защото валидните позиции са 0, 1 и 2.

Поправката зависи от произхода на индекса. Ако е константа, написана до фиксиран списък, и индексът е грешен, поправи програмата. Ако идва от файл, заявка или опция на потребител, използвай get(indice) и превърни None в Result, който обяснява кой е бил валидният диапазон. Не оставяй възстановим вход да прекрати процеса с backtrace.

Диагностицирай, преди да поправяш

Първо прочети сигнатурата на функцията и конкретния тип на израза, който се е провалил. Ако пише Option, реши какво означава отсъствието. Ако пише Result, прочети варианта на грешката и реши дали трябва да се разпространи, да получи контекст или да се обработи там. Ако се появи panic!, попитай кое предположение на програмата се е нарушило. Копирането на .clone(), .unwrap() или as, докато се компилира, обикновено изтрива полезна информация и измества проблема към изпълнението.

rustc --explain E0277 разширява общото значение на кода, но локалната диагностика остава основният източник. Стрелките ѝ сочат очаквания тип, получения тип и реда, където договорът е престанал да съвпада. Да се научиш да следваш тези три следи струва повече от запаметяването на списък с кодове.

Какво се прави погрешно

  • Използване на v[i] за индекси, които идват отвън. Директният индекс твърди, че позицията съществува. Ако това твърдение зависи от файл или от човешки вход, използвай get; None е данна, която трябва да превърнеш в полезно обяснение.

  • Обхождане на HashMap и публикуване на случайния му ред. Доклад, който променя реда си, е труден за четене, тестване и сравнение. Извлечи ключове или редове, подреди ги и чак тогава отпечатай. Изходът на revisor трябва да е възпроизводим, дори вътрешното хранилище да се промени.

  • Използване на String във всички параметри. Задължава да се прехвърля собственост или да се създават ненужни заделяния. Ако една функция само чете, декларирай &str; запази String за структури и резултати, които трябва да притежават текст.

  • Индексиране на String по байт или предполагане, че len() брои видимите букви. Rust съхранява UTF-8 текст. Използвай chars, bytes или диапазони с get според мерната единица, която наистина ти трябва. Име, което днес има само ASCII, утре може да съдържа валидни символи от повече от един байт.

  • Превръщане на всички грешки в unwrap() или expect(). Прави програмата да изглежда къса, докато елиминира пътищата за възстановяване и контекста. unwrap е приемлив в тест, когато провалът прави невалиден самия тест; не е нормалният начин за четене на файлове или обработка на аргументи.

  • Използване на panic! за невалидни конфигурационни данни. Неправилен URL, липсващ файл или повтарящо се име са грешки, които един човек може да поправи. Върни Result с липсващата данна и разбираема причина.

  • Загубване на първоначалната причина при създаване на ново съобщение. Текст като "no se pudo cargar" не казва кой път се е провалил, нито защо. Използвай with_context, за да добавиш операция и локални данни, без да изхвърляш причината, върната от системата или от парсера.

  • Използване на anyhow в библиотека, която се нуждае от различими грешки. Ако извикващият трябва да реагира различно на невалиден URL и на повторено име, публикувай собствен enum, обикновено с thiserror. Удобството на един низ не бива да заличава решения от домейна.

Упражнения

Упражнение 1 — Честен достъп до списък

Създай Vec<Servicio> с две услуги. Напиши функция nombre_en(servicios: &[Servicio], indice: usize) -> Option<&str>, която връща името на услугата, когато съществува, и None, когато не. Изпробвай я с индексите 0, 1 и 2. Не използвай [] във функцията.

Обясни писмено защо връщането на Option<&str> е по-честно от връщането на празен низ. Помисли какво би станало, ако празно име беше позволена данна.

Упражнение 2 — Състояния по име и детерминиран доклад

Създай HashMap<String, Estado> с три имена, включително една грешка. Напиши функция, която произвежда Vec<String> с имената, подредени по азбучен ред. После обходи тези имена и генерирай редове с формат nombre: estado.

Изпълни програмата няколко пъти. Изходът трябва да запази точно същия ред. Не подреждай HashMap: не се подрежда. Подреди отделна колекция от ключове или от редове.

Упражнение 3 — Зареждане, валидиране и добавяне на контекст

Напиши функция cargar(ruta: &str) -> Result<Vec<Servicio>, ...>, която чете текстов файл с по един ред на услуга. Всеки ред трябва да съдържа име и URL, разделени със запетая. Отхвърли ред без две полета, URL без http:// или https:// и празен списък. Разпространявай грешките при четене с ?.

После адаптирай функцията да използва anyhow::Context и да добавя пътя към съобщението за четене. Накарай main да отпечатва грешките в изхода за грешки и да завършва с код 2; ако всички данни са валидни, отпечатай подредения списък и завърши с код 0.

Решения

Решение 1

Функцията трябва да заема slice-а, не да взема вектора. get вече връща Option<&Servicio>, а map трансформира съдържанието на Some, без да пипа None.

fn nombre_en(servicios: &[Servicio], indice: usize) -> Option<&str> {
    servicios.get(indice).map(|servicio| servicio.nombre.as_str())
}

as_str() превръща референцията към String в референция към str; не заделя памет и не клонира текст. Животът на &str е ограничен от живота на заетия slice, което е точно правилният договор. Празен низ би бил двусмислен: би могъл да означава „не намерих този индекс“ или „намерих услугата и името ѝ е празно“.

Решение 2

Решението се нуждае от разделяне на структурата, полезна за търсене, от структурата, полезна за представяне. Картата запазва асоциацията; векторът от ключове получава реда, който изисква докладът.

let mut nombres: Vec<&str> = estados.keys().map(String::as_str).collect();
nombres.sort();

for nombre in nombres {
    let estado = &estados[nombre];
    println!("{nombre}: {estado:?}");
}

Достъпът estados[nombre] е разумен тук, защото nombre идва директно от estados.keys(): програмата вече е доказала, че ключът съществува. Ако nombre идваше от файл или от аргумент, тази форма отново би твърдяла нещо, което не си валидирал, и трябва да използваш get.

Решение 3

Сигнатурата на зареждането трябва да остави външните проблеми да се издигат като Result. Правилата за формат също трябва да се превърнат в грешки, не в паники. Ако използваш anyhow, една имплементация може да следва тази форма:

fn cargar(ruta: &str) -> anyhow::Result<Vec<Servicio>> {
    let texto = std::fs::read_to_string(ruta)
        .with_context(|| format!("leyendo {ruta}"))?;

    let mut servicios = Vec::new();
    for (numero, linea) in texto.lines().enumerate() {
        let (nombre, url) = linea
            .split_once(',')
            .with_context(|| format!("línea {} sin coma", numero + 1))?;

        anyhow::ensure!(
            url.starts_with("http://") || url.starts_with("https://"),
            "línea {}: URL sin esquema: {url}",
            numero + 1
        );

        servicios.push(Servicio::new(nombre, url));
    }

    anyhow::ensure!(!servicios.is_empty(), "el archivo no declara servicios");
    Ok(servicios)
}

Решението не използва unwrap, защото всеки провал може да произхожда от външно съдържание. split_once връща Option; with_context го превръща в обяснителна грешка. ensure! прекратява функцията с Err, ако условието не е изпълнено. Контекстът включва номер на ред или път, за да не се налага на този, който поправя файла, да налучква откъде да започне.

Как да разбера, че съм успял

  • Изпълняваш rustc --edition 2024 fig04_01.rs && ./fig04_01 и получаваш точно шест реда, включително None за pagos и Some(2) за брояча.
  • Изпълняваш rustc --edition 2024 fig04_02.rs && ./fig04_02 и можеш да обясниш защо един и същ параметър &str приема литерал и референция към String.
  • Изпълняваш rustc --edition 2024 fig04_03.rs && ./fig04_03 и получаваш грешка за липсващ файл без паника.
  • Твоето решение на упражнение 1 връща None за индекс извън диапазона и не съдържа достъп с servicios[indice].
  • Твоят доклад от упражнение 2 произвежда същите редове, в същия ред, след поне десет изпълнения.
  • Твоето решение на упражнение 3 назовава пътя, когато файлът не съществува, назовава реда, когато форматът е грешен, и не използва unwrap в нормалния път на изпълнение.
  • От programas/revisor cargo test, cargo clippy --all-targets -- -D warnings и cargo fmt --check завършват успешно.

За по-нататъшно четене

Да обсъдим вашия проект

Предпочитате имейл? Пишете ни на hola@habil.mx