Продолжаем работать с 10.3.


КМБ.- Двойная жизнь.- Восстание мертвецов.- Ошибка в документации.- Ужасающие подробности из The Rustonomicon.- Архитектурные озарения.- Развязка.



КМБ по структурам


Простой вариант:


#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

#[derive(Debug)]
struct Circle {
    center: Point,
    radius: i32,
}

fn main(){
    let p1 = Point{x:10, y:20};
    println!("p: {:?}", p1);

    let c = Circle{ center: Point{x: 1, y:2}, radius: 3,};
    println!("c: {:?}", c);
}

  • #[derive(Debug)] обеспечивает генерацию кода для красивой печати через {:?}
  • Все поля должны быть явно проинициализированы

Вариант с обобщенными типами:


#[derive(Debug)]
struct Point<T> {
    x: T,
    y: T,
}

#[derive(Debug)]
struct Circle<T> {
    center: Point<T>,
    radius: T,
}

fn main(){
    let p_i32 = Point::<i32>{x:10, y:20};
    println!("p_i32: {:?}", p_i32);

    let p_str = Point::<&str>{x: "I'm x", y: "I'm y"};
    println!("p_str: {:?}", p_str);

    let c_f64 = Circle::<f64>{ center: Point{x: 1.1, y:2.2}, radius: 3.3,};
    println!("c_f64: {:?}", c_f64);
}

Структуры со ссылочными полями


До структур процесс засахаривания еще не дошел, поэтому необходимо явно аннотировать абсолютно все, что относится к временам жизни. Пример:


#[derive(Debug)]
struct Point<'a, T> {
    x: &'a T,
    y: &'a T,
}

#[derive(Debug)]
struct Circle<'a, T> {
    center: Point<'a, T>,
    radius: &'a T,
}

fn main(){
    let p_i32 = Point::<i32>{x: &10, y: &20};
    println!("p_i32: {:?}", p_i32);

    let c_f64 = Circle::<f64>{ center: Point{x: &1.1, y: &2.2}, radius: &3.3,};
    println!("c_f64: {:?}", c_f64);
}

  • &10 — занятно, Go так не может (cannot take the address of 10)
  • Примеры неразрывно связаны с иррациональным ритуалом проставления 'a в параметрах структур и далее везде
  • Как показано ранее, можно сделать Point<T>...Point::<&str>, что в итоге дает структуру с двумя ссылочными полями, явно времена нигде не указаны и ничего — пол не провалился
  • В чем тут сила смысл, брат?

Может быть, смысл появится, если параметров времени жизни будет несколько?


Двойная жизнь


В текущей документации по Rust я не нашел внятного примера на этот счет, но зато он есть в одной из старых версий, 19.2 Advanced Lifetimes. В примере используются конструкции, которые мы еще "не проходили", так что я решил его немного переделать, заодно подправил "архитектуру" по своему вкусу.


Целью было заставить Rust выдавать ошибку компиляции, если используется только одно время жизни. Должен сказать, это удалось не сразу — обычно строгий, до садизма, компилятор в этот раз благодушно прощал мне попытки манипулировать временами. Интересно, что пример из старой версии теперь также прекрасно компилируется. Видимо, технические писатели помыкались, помыкались… да и не стали вообще рассказывать про это в новой версии.


Что ж, мы не привыкли отступать. Общий замысел примера таков — есть короткоживущий ('s) Parser и долгоживущий ('l) Context. Parser берет данные из Context и возвращает результат в рамках жизни Context (т.е. результат работы Parser тоже долгоживущий).


Context:


struct Context<'l> {
    data: &'l str,
}

  • Здесь все живет долго ('l)

Parser:


struct Parser<'s, 'l: 's> {
    ctx: &'s Context<'l>,
    internal_data: &'s str,
}

  • struct Parser<'s, 'l: 's>: означает, что у структуры два параметра времени жизни, время жизни 'l включает в себя 's ('l outlives 's)
  • ctx: &'s Context<'l> означает, что ctx является короткоживущей ссылкой, которая ссылается на экземпляр Context с долгоживущими ссылками внутри
  • На всякий случай — вместо 's и 'l можно использовать другие имена, от этого ничего не изменится, т.е. это не какие-то магические спецслова

Метод Parser.parse():


impl<'s, 'l> Parser<'s, 'l> {
    fn parse(&self) -> &'l str {
        if self.ctx.data.len() > 1{
            &self.ctx.data[1..]
        } else {
            self.ctx.data
        }
    }
}

  • Смысл метода — вернуть подстроку ctx.data начиная со второго байта, если этот второй байт есть

Все вместе:


#![allow(unused)]

struct Context<'l> {
    data: &'l str,
}

struct Parser<'s, 'l: 's> {
    ctx: &'s Context<'l>,
    internal_data: &'s str,
}

impl<'s, 'l> Parser<'s, 'l> {
    fn parse(&self) -> &'l str {
        if self.ctx.data.len() > 0{
            &self.ctx.data[1..]
        } else {
            self.ctx.data
        }
    }
}

fn main(){
    let working_ctx = Context{data: "0123456"};
    let parsing_res: &str;
    {
        let dummy = &String::from("dummy");
        let p = Parser{ctx: &working_ctx, internal_data: dummy};
        parsing_res = p.parse();
    }
    println!("parsing_res: {}", parsing_res)
}

Сломать это можно таким образом:


...
impl<'a> Parser<'a, 'a> {
    fn parse(&self) -> &'a str {
...

Тут как бы разработчик методов для Parser пренебрег указаниями проектировщика структуры и для реализации использовал только один параметр времени жизни. Вот этого компилятор уже простить не может, но сообщение при этом несколько странное:


26 |         let dummy = &String::from("dummy");
   |                      ^^^^^^^^^^^^^^^^^^^^^ creates a temporary which is freed while still in use
...
29 |     }
   |     - temporary value is freed at the end of this statement
30 |     println!("parsing_res: {}", parsing_res)
   |                                 ----------- borrow later used here
...
   = note: consider using a `let` binding to create a longer lived value

А вот и не угадал, dummy вообще не используется! Кроме того, должен признать, что совет про let мне непонятен. Конечно, приятно загнать в угол компилятор, но действует он в правильном направлении, и даже, возможно, по-своему прав.


Строка let p = Parser{ctx: &working_ctx, internal_data: dummy}; связывает с p два времени жизни, короткое от dummy и длинное от working_ctx, но по сигнатуре parse() теперь не понять, с каким из них связан результат, так что компилятор выбирает из двух зол времен наименьшее, т.е. dummy. C коротким временем жизни dummy связывать долгоживущий parsing_res, естественно, нельзя. Сообщение компилятора, конечно, оставляет желать лучшего, с lifetime такая проблема есть, этим даже ребята из Zürich озабочены.


Ладно, посмотрим, как можно дополнительно сломать уже сломанное, чтобы минус на минус дал плюс.


Первый вариант, инициализировать dummy таким образом:


...
// let dummy = &String::from("dummy");
let dummy = "dummy";
...

Все заработало, несмотря на то, что у нас все еще "закорочены" времена (impl<'a> Parser<'a, 'a>). В чем тут дело? Видать, компилятор использует не только сигнатуры, но и data-flow analysis.


Значение переменной dummy теперь связано со временем жизни 'static (возможно, компилятор вообще эту переменную "оптимизирует", в любом случае — ее значение не "тухнет") и переменная p, получается, связана либо со 'static, либо с working_ctx. Соответственно, возвращаемое p.parse() значение можно смело "поднимать" на уровень working_ctx, и код потенциально "сертифицируется" без всякой дополнительной разметки времен.


Тут, кстати, можно начать подозревать, что понятие "время жизни" относится не столько к ссылкам, сколько к значениям, на которые они указывают...


Второй вариант, убрать internal_data из Parser:


...
struct Parser<'s, 'l: 's> {
    ctx: &'s Context<'l>,
}
...

Теперь p у нас инициализируется так: let p = Parser{ctx: &working_ctx};, т.е. она связана только с долгоживущим working_ctx и возвращаемое любыми методами Parser значения можно использовать там же, где и working_ctx.


Все? Нет, не все.


Структуры со ссылками в параметрах функций


Приготовьтесь, сейчас будет грустно.


Обратимся к документации по поводу неявного выведения времен жизни (lifetime elision):


Первое правило говорит, что каждый параметр являющийся ссылкой, получает свой собственный параметр времени жизни. Другими словами, функция с одним параметром получит один параметр времени жизни: fn foo<'a>(x: &'a i32); функция с двумя аргументами получит два различных параметра времени жизни: fn foo<'a, 'b>(x: &'a i32, y: &'b i32) — и так далее.

Второе правило говорит, что если существует точно один входной параметр времени жизни, то его время жизни назначается всем выходным параметрам: fn foo<'a>(x: &'a i32) -> &'a i32.

Рассмотрим такой код:


...
struct Circle<'a, T> {
    center: Point<'a, T>,
    radius: &'a T,
}

fn get_radius<T>(c: &Circle<T>) -> &T {
    c.radius
}

Теперь следите за руками. Входной параметр один? Да. Ergo — будет один входной параметр времени жизни. Согласно второму правилу, время жизни этого параметра назначается всем выходным параметрам. Так почему же...:


15 | fn get_radius<T>(c: &Circle<T>) -> &T {
   |                     ----------     ^ expected named lifetime parameter

А потому, что в документации написано неправильно. Может быть, это проблема перевода? Нет, это не проблема перевода, вот оригинал:


The first rule is that each parameter that is a reference gets its own lifetime parameter. In other words, a function with one parameter gets one lifetime parameter: fn foo<'a>(x: &'a i32); a function with two parameters gets two separate lifetime parameters: fn foo<'a, 'b>(x: &'a i32, y: &'b i32); and so on.

The second rule is if there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters: fn foo<'a>(x: &'a i32) -> &'a i32.

Эта ошибочная формулировка пошла гулять по другим руководствам, немного мутируя по пути, см., например, тут: "...only one input parameter passes by reference...".


Где правда? Вестимо, в "старом учебнике" получше, тогда деревья были большие, и все такое — но все равно туманно, без примеров надлежащего качества. Относительно неплохо также изложено в страшном и ужасном The Rustonomicon, но уже несколько иными словами. Эти слова цитирует ув. PsyHaSTe тут, правда, при их интерпретации использует ошибочную формулировку:


… в случае статических функций время жизни всех аргументов полагаются равными...

Может быть это и опечатка, одну букву всего-то заменить… но ведь кто-то должен, наконец, сформулировать нормально на русском языке, да и примеров подходящих все равно нигде нет… Итак:


Правило 1. Неуказанные времена жизни во входных параметрах функции автоматически проставляются и полагаются разными.
Правило 2. Если во входных параметрах функции можно указать максимум одно время жизни, то оно используется для всех неуказанных времен в выходных параметрах.
Правило 3. Если во входных параметрах есть &self или &mut self, то время жизни этого параметра используется для всех неуказанных времен в выходных параметрах.

Теперь опять рассмотрим непонятный пример. Сколько времен жизни можно максимально указать во входящих параметрах? Два:


fn get_radius<'a, 'b, T>(c: &'a Circle<'b, T>) -> &T {
    c.radius
}

Собственно, как-то так они проставляются компилятором "для себя" по Правилу 1, соответственно, Правило 2 не работает. Как починить?


У нас два варианта — связать результат со временем жизни ссылки c или со временем жизни ссылок внутри того, на что указывает с.


Первый вариант:


fn get_radius<'a, 'b, T>(c: &'a Circle<'b, T>) -> &'a T {

или:


fn get_radius<'c, T>(c: &'c Circle<T>) -> &'c T {

Второй вариант:


fn get_radius<'a, 'b, T>(c: &'a Circle<'b, T>) -> &'b T {

или


fn get_radius<'circle, T>(c: &Circle<'circle, T>) -> &'circle T {

И последний пример::


...
fn get_radius<T>(c: Circle<T>) -> &T {
    c.radius
}

Во входящих параметрах нет ни ссылок ни времен, почему работает? А потому, что можно указать только одно время жизни, соответственно, срабатывает Правило 2:


fn get_radius<'a, T>(c: Circle<'a, T>) -> &T {
    c.radius
}

Размышления про архитектуру приложения


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


  • Структуры должны быть простыми, с одним параметром времени жизни (S — SOLID)
  • "Агрегировать" структуры следует на уровне функций/методов
  • Есть даже такое мнение: You're not allowed to use references in structs until you think Rust is easy...Use Box or Arc to store things in structs "by reference" (про Box и Arc будет в следующих частях)

В свете сказанного Parser можно переписать так:


struct Context<'a> {
    data: &'a str,
}

struct Parser<'a>  {
    internal_data: &'a str,
}

impl<'s, 'l: 's> Parser<'s> {
    fn parse(&self, ctx: &'l Context) -> &'l str {
...

Или, используя anonymous lifetime, так (но пропадает соотношение времен):


...
impl Parser<'_> {
    fn parse<'l>(&self, ctx: &'l Context) -> &'l str {
...

  • Мы больше не храним ссылку на Context внутри Parser, а передаем прямо в метод parse()
  • В реализации методов для Parser мы указываем компилятору, что возвращаемое значение parse() связано параметром ctx
  • В принципе, соотносить времена не требуется, достаточно того, что они разные. Parser живет дольше Context? — Ну ок, почему нет. Главное, не использовать результат в более "широкой" области, чем Context, но тут-то компилятор за всем проследит!

Заключение


Конспект получился длиннее лекции, но это ничего — зато теперь можно смело идти получать "зачет"по 10.3.


Провидению препоручаю вас, дети мои, и заклинаю: остерегайтесь использовать ссылки в структурах, особенно в сложных структурах. Это, конечно, была шутка. Тем не менее — praemonitus praemunitus, forewarned is forearmed и так далее.


Stay tuned.


PS: Далее: Работа с кучей

Комментарии (2)


  1. PsyHaSTe
    28.07.2021 20:46

    Спасибо за статью! И за то что обратили внимание на радикальную опечатку в моей изначальной статье.

    Честно говоря, статья вышла кмк довольно суховатой: вроде все профессионально, по делу, но не очень понятна цель. То есть получается, взяли параграф из учебник и начинаем по-разному крутить код который его затрагивает, получая разные ошибки компиляции. Но читателю, возможно, не очень интересно получать ошибки) Ему интереснее что-нибудь новое узнать, выполнить какую-то цель. А в таком виде больше похоже на справочник, но его можно найти и просто в перечне ошибок rustc.

    То есть технически к статье вопросов нет, а вот с точки зрения "продать" - кажется, что тяжело заинтересовать читателя ворохом технических подробностей, но без особой видимой цели. Лично мне по крайней мере было довольно тяжело продраться сквозь текст, а я вроде не первый день с языком знаком. По отдельности все просто и понятно, а вместе - уже хуже, и не отпускает вопрос "зачем?" - т.е. зачем мы это пишем, чего хотим добиться?

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


    1. maxim_ge Автор
      28.07.2021 22:19

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

      Параграф-то ошибочный, и тема валидации ссылок в целом в учебнике (The Rust Programming Language) не раскрыта. Надо читать что-то другое, например, The Rustonomicon — как это сделали, например, Вы, и как это сделал я. Много чего еще пришлось прочитать и осмыслить — ни с одним языком такого не припомню.


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