Как Rust узнает, запускать ли деструктор во время раскрутки стека?

В документации для mem::uninitialized указано, почему это опасно / небезопасно использовать эта функция: вызов drop в неинициализированной памяти является неопределенным поведением.

Итак, я считаю, что этот код должен быть неопределенным:

let a: TypeWithDrop = unsafe { mem::uninitialized() };
panic!("=== Testing ==="); // Destructor of `a` will be run (U.B)

Однако я написал этот фрагмент кода, который работает в безопасном Rust и, похоже, не страдает неопределенным поведением:

#![feature(conservative_impl_trait)]

trait T {
    fn disp(&mut self);
}

struct A;
impl T for A {
    fn disp(&mut self) { println!("=== A ==="); }
}
impl Drop for A {
    fn drop(&mut self) { println!("Dropping A"); }
}

struct B;
impl T for B {
    fn disp(&mut self) { println!("=== B ==="); }
}
impl Drop for B {
    fn drop(&mut self) { println!("Dropping B"); }
}

fn foo() -> impl T { return A; }
fn bar() -> impl T { return B; }

fn main() {
    let mut a;
    let mut b;

    let i = 10;
    let t: &mut T = if i % 2 == 0 {
        a = foo();
        &mut a
    } else {
        b = bar();
        &mut b
    };

    t.disp();
    panic!("=== Test ===");
}

Кажется, что всегда выполняется правильный деструктор, игнорируя другой. Если я попытался использовать a или b (например, a.disp() вместо t.disp()), он правильно выдает ошибку, говоря, что я, возможно, использую неинициализированную память. Что меня удивило, так это то, что при panicking он всегда запускает правильный деструктор (выводит ожидаемую строку), независимо от значения i.

Как это произошло? Если среда выполнения может определить, какой деструктор запускать, следует ли удалить часть памяти, обязательную для инициализации для типов с реализованным Drop, из документации mem::uninitialized(), как указано выше?


person ustulation    schedule 28.09.2016    source источник
comment
Как любит указывать Рэймонд Чен, поскольку неопределенное поведение означает, что все может случиться и оставаться в силе, одним из возможных последствий является то, что все работает правильно.   -  person Mason Wheeler    schedule 28.09.2016
comment
А.К.А. ошибочно принимают отсутствие доказательств за доказательства отсутствия - в печально известном Черном лебеде.   -  person MickeyfAgain_BeforeExitOfSO    schedule 28.09.2016
comment
@mickeyf @MasonWheeler: Извините, если я вас не понял, но в Rust я ожидал бы, если бы я не использовал код unsafe (которого я не использую в основном примере выше), независимо от наблюдаемого поведения (даже в первом run of it) довольно хорошо определен - это было бы (одной из) основной причиной, по которой многие предпочли бы Rust в первую очередь (по крайней мере, я сделал).   -  person ustulation    schedule 29.09.2016
comment
@ustulation Я вообще не знаю ржавчины, поэтому я не могу сказать, является ли это неопределенным поведением или нет, но чтобы разъяснить, что мы с Мэйсоном говорим: если то, что вы показываете, на самом деле является неопределенным поведением, тогда, даже если это действительно казалось работайте в этот раз или даже следующие 100 раз, это может быть потому, что вам повезло, и это может не сработать каждый раз.   -  person MickeyfAgain_BeforeExitOfSO    schedule 29.09.2016


Ответы (3)


Использование флажков сброса.

Rust (до версии 1.12 включительно) хранит логический флаг в каждом значении, тип которого реализует Drop (и, таким образом, увеличивает размер этого типа на один байт). Этот флаг решает, запускать ли деструктор. Поэтому, когда вы делаете b = bar(), он устанавливает флаг для переменной b и, таким образом, запускает только деструктор b. Наоборот с a.

Обратите внимание, что начиная с версии 1.13 Rust (на момент написания этой статьи - бета-компилятора) этот флаг сохраняется не в типе, а в стеке для каждой переменной или временной. Это стало возможным благодаря появлению MIR в компиляторе Rust. MIR значительно упрощает перевод кода Rust в машинный код и, таким образом, позволяет этой функции перемещать флаги отбрасывания в стек. Оптимизация обычно устраняет этот флаг, если они могут выяснить во время компиляции, когда какой объект будет удален.

Вы можете «наблюдать» за этим флагом в компиляторе Rust до версии 1.12, посмотрев на размер типа:

struct A;

struct B;

impl Drop for B {
    fn drop(&mut self) {}
}

fn main() {
    println!("{}", std::mem::size_of::<A>());
    println!("{}", std::mem::size_of::<B>());
}

печатает 0 и 1 соответственно перед флагами стека и 0 и 0 с флагами стека.

Однако использование mem::uninitialized по-прежнему небезопасно, поскольку компилятор по-прежнему видит присвоение переменной a и устанавливает флаг отбрасывания. Таким образом деструктор будет вызываться в неинициализированной памяти. Обратите внимание, что в вашем примере Drop impl не имеет доступа ни к какой памяти вашего типа (кроме флага drop, но он невидим для вас). Поэтому вы не получаете доступ к неинициализированной памяти (размер которой в любом случае равен нулю, поскольку ваш тип - структура нулевого размера). Насколько мне известно, это означает, что ваш unsafe { std::mem::uninitialized() } код действительно безопасен, потому что впоследствии не может возникнуть небезопасность памяти.

person oli_obk    schedule 28.09.2016
comment
Правильно - мой drop () impl просто показывает распечатки того, какие из них выполняются, но, конечно, я говорил об общих случаях. Один вопрос, почему mem::uninitialized() рассматривается как назначение для установки флажка сброса? Если бы я устанавливал его в определенное место (например, operator new()), тогда это имело бы смысл, но назначение случайного местоположения в памяти (где бы оно ни находилось в этот раз в стеке) не должно было устанавливать флаг сброса? Я имею в виду, когда это будет иметь смысл? (Он может установить его при следующем назначении, как и в случае b, когда i нечетное). - person ustulation; 28.09.2016
comment
ну ... разница в том, что let x = mem::uninitialized() позволяет вам ссылаться на x, а let x; не позволяет этого. Поэтому вы можете передать эту ссылку функции, которая записывает только в эту память. Типичный пример - let mut x: [i8; 42] = mem::uninitialized(); some_slice_function(&mut x) - person oli_obk; 28.09.2016

Здесь скрыты два вопроса:

  1. Как компилятор отслеживает, какая переменная инициализирована или нет?
  2. Почему инициализация с mem::uninitialized() может привести к неопределенному поведению?

Давайте разберемся с ними по порядку.


Как компилятор отслеживает, какая переменная инициализирована или нет?

Компилятор вводит так называемые «флаги отбрасывания»: для каждой переменной, для которой Drop должен выполняться в конце области видимости, в стек вставляется логический флаг, указывающий, нужно ли удалить эту переменную.

Флаг начинается с «нет», переходит в «да», если переменная инициализирована, и обратно в «нет», если переменная перемещается из.

Наконец, когда приходит время отбросить эту переменную, флаг проверяется и при необходимости сбрасывается.

Это не связано с тем, жалуется ли анализ потока компилятора на потенциально неинициализированные переменные: код генерируется только тогда, когда анализ потока удовлетворяется.


Почему инициализация с mem::uninitialized() может привести к неопределенному поведению?

При использовании mem::uninitialized() вы даете обещание компилятору: не волнуйтесь, я определенно инициализирую это.

Что касается компилятора, переменная, таким образом, полностью инициализирована, а флаг отбрасывания установлен на «да» (до тех пор, пока вы не выйдете из него).

Это, в свою очередь, означает, что будет вызвано Drop.

Использование неинициализированного объекта является неопределенным поведением, и компилятор, вызывающий Drop неинициализированный объект от вашего имени, считается «его использованием».


Бонус:

В моих тестах ничего странного не произошло!

Обратите внимание, что Undefined Behavior означает, что все может случиться; все, к сожалению, также включает в себя «кажется, работает» (или даже «работает, как задумано, несмотря на разногласия»).

В частности, если вы НЕ обращаетесь к памяти объекта в Drop::drop (просто печатаете), то очень вероятно, что все будет просто работать. Однако, если вы получите к нему доступ, вы можете увидеть странные целые числа, указатели, указывающие на дикую природу и т. Д.

А если оптимизатор умен, даже без доступа к нему, он может делать странные вещи! Поскольку мы используем LLVM, я предлагаю вам прочитать Что должен каждый программист на C знать о Undefined Behavior Криса Латтнера (отца LLVM).

person Matthieu M.    schedule 28.09.2016
comment
Дальнейший вопрос: если i чётно и компилятор может отслеживать, что b не инициализирован, и не вызывать его dtor при раскручивании, почему он не может отслеживать что-то вроде let u: Type = mem::uninitialized(); тоже неинициализировано (на самом деле я явно указываю это как таковое) и не вызывать его dtor во время раскрутки стека, если он не был назначен? - person ustulation; 28.09.2016
comment
Что касается бонусной части, UB не должно быть возможным без кода unsafe - поэтому, если он, кажется, работает, лучше быть определенным поведением, поскольку я не использовал небезопасный код в основном примере в OP (если вы имеете в виду его ) - person ustulation; 28.09.2016
comment
(кстати, ваш ответ также довольно пояснительный / полезный, но принял другой только потому, что он был поставлен 1-м и был одинаково полезен) - person ustulation; 28.09.2016
comment
@ustulation: UB возникает только в том случае, если вы используете mem::uninitialized(), для которого требуется код unsafe. Я просто комментировал, что ничего странного не произошло, это не означает, что вы держались подальше от UB :) Что касается принятия другого ответа: вы можете принимать все, что хотите! :) - person Matthieu M.; 28.09.2016

Во-первых, есть флаги сброса - информация времени выполнения для отслеживания того, какие переменные были инициализирован. Если переменная не была назначена, drop() не будет выполняться для нее.

В стабильной версии флаг сброса в настоящее время хранится в самом типе. Запись в него неинициализированной памяти может вызвать неопределенное поведение относительно того, будет ли drop() вызываться или нет. Эта информация скоро станет устаревшей, потому что в ночное время флаг удаления перемещается из самого типа.

В ночном Rust, если вы назначаете неинициализированную память переменной, можно с уверенностью предположить, что drop() будет выполнен. Однако любая полезная реализация drop() будет работать со значением. Невозможно определить, правильно ли инициализирован тип или нет в реализации признака Drop: это может привести к попытке освободить недопустимый указатель или любую другую случайную вещь, в зависимости от реализации типа Drop. В любом случае назначать неинициализированную память типу с Drop не рекомендуется.

person Pavel Strakhov    schedule 28.09.2016