Метод drop и устойчивость к панике

А вы заметили этот комментарий:

#![allow(unused)]
fn main() {
// Обратите внимание, что мы больше не должны беспокоиться
// о `take`, потому что копирование сырых указателей не приводит к
// вызову деструкторов, если мы что-то напутаем... правда? :)
// Праааавда? :)))
}

Это правда?

Простите, вы забыли, какую книгу читаете? Конечно, нет! (В каком-то смысле.)

Снова заглянем внутрь pop_front:

// Возвращаем к жизни Box, так что можно извлечь и уничтожить его
// значение (Box магически делает это за нас).
let boxed_node = Box::from_raw(node.as_ptr());
let result = boxed_node.elem;

// Делаем следующий узел новым передним узлом.
self.front = boxed_node.back;
if let Some(new) = self.front {
    // Убираем ссылку переднего узла на удалённый узел.
    (*new.as_ptr()).front = None;
} else {
    // Если передний узел равен null, значит, список пустой!
    debug_assert!(self.len == 1);
    self.back = None;
}

self.len -= 1;
result
// Здесь Box неявным образом освобождается, потому что больше нет T.

Видите ошибку? Ужасно, но она в этой строчке:

debug_assert!(self.len == 1);

Серьёзно? Проклятая проверка целостности, сделанная для тестов — ошибка? Да!!! Впрочем, если коллекция реализована корректно, ошибка не должна возникнуть. Но может получиться так, что вполне безобидная штука вроде «некорректного обновления len» превратится в Уязвимость, вызванную ошибкой безопасности памяти! Почему? Потому что может возникнуть паника! Большую часть времени вам не стоит беспокоиться о панике, но однажды вы начинаете писать поистине небезопасный код и вольно обращаетесь с «инвариантностью», и вам приходится проявлять повышенную бдительность!

Нам нужно поговорить об устойчивости к исключениям (ИЛИ устойчивости к панике, ИЛИ устойчивости к раскрутке стека...).

Итак, паника по умолчанию приводит к раскрутке стека (unwinding). Раскрутка — это красивый способ сказать «немедленно завершить каждую функцию». Вы можете подумать «Ладно, если всё завершается, значит, программа тоже завершается, так что не о чем беспокоиться?», но вы не правы!

Мы должны беспокоиться по двум причинам: из-за запуска деструкторов, и возможного перехвата раскрутки. В обоих случаях, код будет выполняться после паники, так что нам надо быть очень осторожными и убедиться, что наши небезопасные коллекции находятся в каком-то когерентном состоянии независимо от паники, потому что паника неявно означает ранний возврат (early return)!

Подумайте, в каком состоянии находится коллекция, когда мы доходим до строки с ошибкой.

У нас есть boxed_node на стеке, и мы извлекли из него элемент. Если бы мы завершили функцию в этой точке, Box должен был быть уничтожен, и узел должен был быть освобождён. Теперь вы видите?.. self.back всё ещё указывает на освобождённый узел! Как только мы реализуем оставшиеся функции и начнём использовать self.back для разных вещей, это приведёт к ошибке использование-после-освобождения (use-after-free)! Кошмар!

Интересно, что в этой строке встречается похожая проблема, но гораздо более безопасная:

self.len -= 1;

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

Я уже несколько раз употребляла термин «инвариант», а всё потому, что это действительно полезная концепция для разговора про устойчивость к панике! В каком-то смысле, для стороннего наблюдателя у нашей коллекции есть определённые свойства, которые всегда соблюдаются. Для связного списка одним из таких свойств является следующее: для любого достижимого узла в нашем списке выделена и проинициализирована память.

Внутри реализации нам доступно чуть больше гибкости, чтобы временно нарушать инварианты, пока мы уверены, что восстановим их до того, как кто-нибудь заметит. По сути, это одно из «главных преимуществ» системы владения и заимствования в Rust для коллекций: если операции нужен параметр &mut Self, мы гарантированно получаем эксклюзивный доступ к нашей коллекции и свободно можем временно нарушить инварианты, зная, что никто не сможет незаметно нам помешать.

Возможно, наилучшим воплощением этой идеи является Vec::drain, который, фактически, позволяет вам полностью нарушить основной инвариант Vec и начать извлекать значения не только из начала Vec, но и из середины. Причина, по которой это правильно заключается в том, что итератор Drain, который мы возвращаем, хранит &mut на Vec, поэтому любой доступ к нему ограничен! Никто не сможет пользоваться Vec до тех пор, пока итератор Drain не завершится, и тогда деструктор «восстановит» Vec до того, как кто-либо это заметит, что идеа—

Нет, это не идеально. К сожалению вы не можете полагаться на выполнение деструкторов в коде, который вы не контролируете, так что даже с Drain нам нужно делать небольшую дополнительную работу, чтобы инварианты нашего типа всегда сохранялись, но в несколько нелепой манере: в начале мы просто сбрасываем длину Vec в 0, так что если у кого-то утечёт Drain, он получит безопасный Vec... потеряв при этом все данные. Ты устроил утечку мне? Я устрою утечку тебе! Око за око! Истинная справедливость!

Чтобы разобраться, когда для устойчивости при панике действительно можно использовать деструкторы, познакомьтесь с учебным примером использования BinaryHeap::sift_up.

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

В этом случае у нас есть две возможности сделать код надёжнее:

  • Чаще использовать такие операции, как Option::take, потому что они более «транзакционные» и имеют тенденцию сохранять инварианты.
  • Удалить все debug_assert и доверять себе в написании лучших тестов со специальными функциями «проверки целостности», которые никогда не будут выполняться в пользовательском коде.

В принципе мне нравится первый вариант, но на самом деле он не сработает для двусвязного списка, потому что нам одновременно требуются два значения, а не одно. Option::take не решит нашей проблемы, а вот перенос debug_assert на строку ниже — решит. Но, правда, зачем усложнять себе жизнь? Давайте просто удалим все debug_assert и убедимся, что любая паника может возникнуть только в начале или конце наших методов, где, как известно, выполняются все инварианты.

(Возможно про них следует думать, как о предусловиях и постусловиях, но на самом деле к ним правильнее относиться, как к инвариантам!)

Вот наша полная текущая реализация:

#![allow(unused)]
fn main() {
use std::ptr::NonNull;
use std::marker::PhantomData;

pub struct LinkedList<T> {
    front: Link<T>,
    back: Link<T>,
    len: usize,
    _boo: PhantomData<T>,
}

type Link<T> = Option<NonNull<Node<T>>>;

struct Node<T> {
    front: Link<T>,
    back: Link<T>,
    elem: T, 
}

impl<T> LinkedList<T> {
    pub fn new() -> Self {
        Self {
            front: None,
            back: None,
            len: 0,
            _boo: PhantomData,
        }
    }

    pub fn push_front(&mut self, elem: T) {
        // БЕЗОПАСНОСТЬ: это связный список, что вы делаете?
        unsafe {
            let new = NonNull::new_unchecked(Box::into_raw(Box::new(Node {
                front: None,
                back: None,
                elem,
            })));
            if let Some(old) = self.front {
                // Вставляем новый передний узел перед старым
                (*old.as_ptr()).front = Some(new);
                (*new.as_ptr()).back = Some(old);
            } else {
                // Если переднего узла нет, у нас пустой список и нам надо
                // установить и значение заднего узла. Кроме того, мы добавили
                // несколько проверок, на случай, если что-то напутали.
                self.back = Some(new);
            }
            // Этот код выполняется всегда!
            self.front = Some(new);
            self.len += 1;
        }
    }

    pub fn pop_front(&mut self) -> Option<T> {
        unsafe {
            // Будем что-то делать, только если у списка есть передний узел.
            self.front.map(|node| {
                // Возвращаем к жизни Box, так что можно извлечь и уничтожить его
                // значение (Box магически делает это за нас).
                let boxed_node = Box::from_raw(node.as_ptr());
                let result = boxed_node.elem;

                // Делаем следующий узел новым передним узлом.
                self.front = boxed_node.back;
                if let Some(new) = self.front {
                    // Убираем ссылку переднего узла на удалённый узел.
                    (*new.as_ptr()).front = None;
                } else {
                    // Если передний узел равен null, значит, список пустой!
                    self.back = None;
                }

                self.len -= 1;
                result
                // Здесь Box неявным образом освобождается, потому что больше нет T.
            })
        }
    }

    pub fn len(&self) -> usize {
        self.len
    }
}
}

Что здесь может привести к панике? Чтобы ответить на этот вопрос, надо быть экспертом по Rust, но, к счастью, я им являюсь!

Единственные места, которые я вижу, где код может вызвать панику (за исключением совершенно безумной идеи перекомпилировать стандартную библиотеку с включенными debug_assert, чего никогда не следует делать) — это Box::new (в случае нехватки памяти) и арифметические операции с len. Всё это находится или в самом конце или в самом начале наших методов, так что да, наш код красивый и безопасный!

...удивились, что Bos::new может вызывать панику? Паника может достать вас самым непредсказуемым образом! Держите свои инварианты в целости, чтобы вас это не беспокоило!