% Как относятся Безопасный и Небезопасный Rust

Итак, каковы отношения между Безопасным и Небезопасным Rust? Как они взаимодействуют между собой?

Rust разделяет Безопасный и Небезопасный Rust с помощью ключевого слова unsafe, которое можно трактовать как интерфейс внешних функций (foreign function interface) (FFI) для взаимодействия Безопасного и Небезопасного Rust. Это магия, благодаря которой можно сказать, что Безопасный Rust - действительно безопасен: вся работа со страшными небезопасными частями языка, как и в других безопасных языках, отводится исключительно FFI.

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

На данный момент unsafe может появиться в Rust только в определенных местах, которые грубо можно разделить на две категории:

  • Перед объявлением непроверенных контрактов. Я, как автор контракта, требую писать unsafe, чтобы убедиться, что вы, как пользователь, поняли следующее:

    • Перед функциями unsafe показывает, что ее вызывать небезопасно. Вы должны посмотреть документацию, чтобы определить, в чем конкретно это выражается, и написать unsafe, чтобы подтвердить, что вы поняли опасность вызова такой функции.
    • Перед объявлением типажей unsafe показывает, что реализация типажа является небезопасной операцией, потому что у типажа есть контракты, которым другой небезопасный код имеет право слепо доверять. (Больше об этом ниже.)
  • Перед использованием непроверенных контрактов. Я в меру своих знаний заявляю, что придерживаюсь следующей логики использования непроверенных контрактов:

    • В реализации типажей unsafe показывает, что соблюдается unsafe контракт типажа.
    • В блоке unsafe показывает, что любая работа с небезопасными операциями должна обрабатываться внутри, и, следовательно, родительская функция безопасна.

В языке есть особый случай, флаг #[unsafe_no_drop_flag], присутствующий по историческим причинам и находящийся на пути к выпиливанию. Смотрите для уточнения раздел флаги удаления.

Примеры небезопасных функций:

  • slice::get_unchecked выполняет непроверенное индексирование, позволяющее свободно нарушить безопасность памяти.
  • любой сырой указатель на тип фиксированного размера обладает внутренним методом offset, который вызывает Неопределенное Поведение, если находится "вне границ", определенных LLVM.
  • mem::transmute интерпретирует значение полученного типа как другого типа, самовольно обходя безопасность типов. (смотрите для уточнения преобразования типов)
  • Все функции FFI являются unsafe, потому что могут выполнять произвольные сценарии. Часто очевидным виновником этого является Си, но вообще-то любой язык может сделать что-то, от чего Rust не будет в восторге.

В Rust 1.0 есть ровно два небезопасных типажа:

  • Send - это маркерный типаж (у него нет своего API), который обещает, что типы, реализующие его, можно можно безопасно посылать (перемещать) в другой поток.
  • Sync - это маркерный типаж, который обещает, что потоки могут безопасно делить между собой типы, реализующие его, используя общую ссылку на них.

Необходимость в небезопасных типажах кроется в основных свойствах безопасного кода:

Каким бы убогим ни был Безопасный код, он не сможет вызвать неопределенное поведение.

Это означает, что Небезопасный Rust, как передовой отряд неопределенного поведения, должен очень подозрительно относиться к обобщенному безопасному коду. Для ясности, Небезопасный Rust доверяет конкретному безопасному коду абсолютно. Другое поведение выродилось бы для него в бесконечные спирали параноидального отчаяния. В частности, доверять корректности стандартной библиотеки абсолютно нормально. std - это, по сути, расширение языка, и вам, действительно, следует доверять ему. Если std нарушает свои гарантии, тогда это точно ошибка в языке.

Тем не менее, лучше минимизировать напрасные надежды на железобетонность безопасного кода. Ошибки случаются! Я ещё раз подчеркну: беспокоиться надо только за Небезопасный код. Безопасный код может слепо верить всему, что не нарушает безопасность памяти.

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

Например, в Rust есть типажи PartialOrd и Ord, нужные для того, чтобы можно было отличить типы, которые можно "только" сравнивать, от тех, значения которых находятся в отношении строгого порядка. В большинстве своем, каждое API, которое хочет работать с упорядоченными данными, хочет иметь Ord. Например, упорядоченный словарь BTreeMap не имеет никакого смысла создавать для частично упорядоченных данных. Если вы объявите, что тип реализует Ord, но не предоставите значения, которые действительно упорядочены, BTreeMap попадет в просак, ему станет очень плохо. Вставленные данные будет уже невозможно найти!

Но это еще нормально. BTreeMap безопасен, поэтому он гарантирует, что даже если вы дадите ему абсолютно бредовую реализацию Ord, он будет все равно делать что-то безопасное. Он не начнет читать неинициализированную или невыделенную память. На самом деле BTreeMap даже не потеряет ваши данные. После его удаления, все деструкторы будут вызваны успешно! Ура!

Только надо помнить, что BTreeMap реализован с использованием маленькой щепотки Небезопасного Rust (как и большинство коллекций). Поэтому не всегда можно утверждать, что плохая реализация Ord не приведёт к небезопасному поведению BTreeMap. BTreeMap не должен полагаться на Ord, ставя под угрозу безопасность. Ord предоставляется безопасным кодом, а безопасный код считает всё безопасным.

Но правда было бы здорово, если бы небезопасный код мог бы в каких-нибудь местах доверять контрактам какого-либо типажа? Эта проблема, которой занимаются небезопасные типажи: помечая небезопасность реализации самого типажа, другой небезопасный код может доверять реализациям контракта такого типажа. Хотя она может быть неправильна во всех произвольных случаях.

Например, имея гипотетический типаж UnsafeOrd, технически такая реализация будет правильна:


#![allow(unused)]
fn main() {
use std::cmp::Ordering;
struct MyType;
unsafe trait UnsafeOrd { fn cmp(&self, other: &Self) -> Ordering; }
unsafe impl UnsafeOrd for MyType {
    fn cmp(&self, other: &Self) -> Ordering {
        Ordering::Equal
    }
}
}

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

Rust традиционно не делает типажи небезопасными по умолчанию, потому что это сделало бы небезопасность повсеместной, что абсолютно нежелательно. Send и Sync небезопасны, потому что потокобезопасность - это фундаментальное свойство, от которого небезопасный код даже не может попытаться защититься таким же образом, как он защитился бы от плохой реализации Ord. Единственный способ защититься от потоконебезопасности - не использовать потоки вообще. Сделать каждую загрузку и сохранение атомарными недостаточно, потому что могут существовать сложные варианты, задействующие отдельные области памяти. Например, указатель и размер у Vec должны быть синхронизированы.

Даже такая парадигма параллельности как обмен сообщениями, которая традиционно считается Абсолютно Безопасной, неявно опирается на потокобезопасность - действительно ли вы используете обмен сообщениями, если передаете указатель? Для Send и Sync, таким образом, требуется базовый уровень доверия, который Безопасный код не может предоставить, поэтому их реализацию необходимо сделать небезопасной. Чтобы избежать небезопасности, проникающей везде, возникнувшей вследствие этого, Send и Sync автоматически выводятся для всех типов, состоящих из значений типов, реализующих Send и Sync. 99% типов реализуют Send и Sync, и 99% из них никогда не сообщают об этом (оставшийся 1% - это по большей части примитивы синхронизации).