Теория языков программирования

Ownership и lifetimes

В 2019 году Microsoft опубликовала статистику: 70% security-уязвимостей в Windows за последние 12 лет - это memory safety bugs. Те же 70% - в Chromium, в Android. C и C++ десятилетиями оставались стандартом системного программирования, и индустрия научилась жить с use-after-free как с погодой. Rust появился с тезисом: эти баги - не неизбежность, их можно исключить системой типов, без сборщика мусора и без потери производительности.

  • **Linux kernel**: с версии 6.1 (2022) принимает драйверы на Rust - первый язык, добавленный в ядро за 30 лет
  • **Android**: с 2021 года новый системный код пишется на Rust; уязвимости memory safety в нативном коде упали с 76% до 24%
  • **Microsoft**: переписывает критичные части Windows на Rust - DWriteCore, GDI - с целью ликвидировать memory bugs как класс
  • **Cloudflare**: prox-сервер Pingora на Rust обслуживает >1 trillion запросов в день - заменил nginx без runtime overhead

Линейные типы и move-семантика

Жан-Ив Жирар в 1987 году сформулировал линейную логику: ресурс нельзя ни дублировать, ни уничтожать незаметно - его обязательно нужно использовать ровно один раз. Через тридцать лет эта идея легла в основу системы типов Rust. Каждое значение имеет одного владельца; присваивание не копирует - оно **перемещает** владение. Старая переменная после move становится невалидной, и компилятор отказывается её использовать. Drop при выходе из scope детерминирован: память освобождается в той же точке программы, что и в C, но без необходимости вызывать free вручную.

Move - это не runtime-операция, а статическая проверка. Битов в памяти при `let b = a` копируется ровно столько же, сколько и при copy: смещение указателя в стеке. Разница только в том, что компилятор помечает `a` как 'consumed' и больше не позволяет к нему обращаться. Это zero-cost abstraction: проверка происходит в типизаторе, ничего не остаётся на runtime.

Trait `Copy` помечает типы, для которых move заменён на побитовое копирование. Этот trait может быть автоматически выведен (`#[derive(Copy, Clone)]`) только если все поля сами Copy. Любой тип, владеющий heap-памятью (String, Vec, Box), не может реализовать Copy - иначе нарушился бы инвариант single ownership и при drop возник бы double-free.

Почему `String` не реализует trait `Copy`, а `i32` реализует?

Borrow checker: общие и эксклюзивные ссылки

Move - слишком жёсткое ограничение для повседневного кода: функция чтения не должна забирать значение себе. Rust добавляет **borrowing** - временные ссылки, не переносящие владения. Существует два вида: `&T` (shared, immutable) и `&mut T` (exclusive, mutable). Главный инвариант borrow checker: в каждой точке программы для каждого значения существует либо любое число `&T`, либо ровно одна `&mut T` - но не оба одновременно. Этот инвариант называется aliasing XOR mutability и формально доказывает отсутствие data race на этапе компиляции.

С 2018 года borrow checker использует **non-lexical lifetimes** (NLL): время жизни ссылки определяется не блоком `{}`, а последним её использованием в графе потока управления. До NLL код приходилось переписывать с искусственными scope-блоками; после NLL компилятор сам определяет, что ссылка больше не нужна и можно создать новую.

Aliasing XOR mutability - это не просто практическое правило, а инвариант, делающий валидным целый класс оптимизаций. Компилятор может предполагать, что данные за `&T` не меняются никем другим, а за `&mut T` - не наблюдаются никем. Это позволяет применять оптимизации, эквивалентные `restrict` в C, на любой Rust-программе бесплатно.

Что предотвращает правило 'aliasing XOR mutability' в одном потоке (без многопоточности)?

Lifetimes и elision

Любая ссылка имеет **lifetime** - область кода, на которой она гарантированно валидна. Lifetime является обычным параметром типа (как generic), но обозначается апострофом: `&'a T` читается как 'ссылка с временем жизни 'a на T'. Компилятор требует, чтобы lifetime ссылки не превышал lifetime данных, на которые она указывает. Возврат ссылки на локальную переменную из функции отвергается: локальная переменная уничтожается при возврате, а ссылка пережила бы её - dangling pointer.

В большинстве случаев lifetimes выводятся автоматически по правилам **lifetime elision**: (1) каждый параметр-ссылка получает собственный lifetime; (2) если входной lifetime один - он переносится на все выходные ссылки; (3) если есть `&self`, lifetime self переносится на выход. Явная аннотация нужна только когда правила elision неоднозначны - чаще всего при возврате ссылки, выбранной из нескольких входных.

Lifetime - не runtime-понятие; ничего не аллоцируется и не освобождается под капотом. Это только маркер для проверки в типизаторе. После monomorphization все lifetime-параметры исчезают и в машинном коде ничего о них не остаётся. Цена системы - ноль байт и ноль тактов.

Почему функция `fn longest<'a>(x: &'a str, y: &'a str) -> &'a str` использует один lifetime для обоих параметров и результата?

Box, Rc, Arc и shared ownership

Single ownership хорошо описывает дерево владения, но реальные структуры данных бывают графами. Rust решает это через **умные указатели** - типы, владеющие данными в heap и реализующие специальное поведение через trait Drop. `Box<T>` - простейший: один владелец, heap-аллокация, нулевой overhead по сравнению с new в C++. `Rc<T>` добавляет non-atomic счётчик ссылок; при `clone()` счётчик увеличивается, при drop последней - данные освобождаются. `Arc<T>` - то же, но счётчик атомарный, что делает его пригодным для передачи между потоками.

Rc/Arc дают shared ownership, но всё ещё запрещают мутацию через shared-ссылку. Для контролируемой мутации применяется **interior mutability** - типы `Cell<T>`, `RefCell<T>` (single-threaded), `Mutex<T>`, `RwLock<T>` (thread-safe). RefCell переносит проверку borrow в runtime: при попытке получить &mut при живой & паника. Это компромисс: гибкость в обмен на отказ от статических гарантий.

Циклы Rc/Arc приводят к утечкам: счётчик никогда не достигнет нуля. Для этого существует **Weak** - неувлядеющая ссылка, которая не учитывается в счётчике и может стать dangling без UB (`Weak::upgrade` возвращает Option). Классический паттерн дерева - parent держит Rc на children, child держит Weak на parent.

Ownership делает Rust непригодным для графов и циклических структур данных

Графы реализуются через индексы в Vec (arena allocation) или через Rc<RefCell<T>> + Weak. Стандартная библиотека и крейты типа petgraph широко применяют эти паттерны.

Single ownership формально несовместим с произвольными графами, но это решается на уровне коллекций. Arena pattern даёт O(1) доступ по индексу, безопасность типов и отсутствие циклических ссылок - один владелец-арена.

В чём практическая разница между `Rc<T>` и `Arc<T>`?

Ключевые идеи

  • **Линейные типы**: каждое значение имеет одного владельца; присваивание - move, не copy; drop детерминирован при выходе из scope
  • **Borrow checker**: aliasing XOR mutability - в каждой точке либо любое число &T, либо одна &mut T; data race и iterator invalidation невозможны
  • **Lifetimes**: время жизни ссылки - параметр типа, проверяемый статически; ничего не остаётся в runtime, цена системы - ноль
  • **Shared ownership**: Rc/Arc дают reference counting; interior mutability через RefCell/Mutex; циклы разрываются Weak-ссылками

Связанные темы

Ownership Rust - не изолированная фича, а пересечение нескольких теоретических направлений:

  • Субтипирование — Lifetimes образуют решётку подтипов: 'static <: 'a для любого 'a. Variance lifetime-параметров (covariant, contravariant, invariant) - применение тех же правил, что и для обычных типов
  • Системы эффектов — Move-семантика - частный случай линейной типизации; полные effect systems в Koka и Frank обобщают идею до отслеживания произвольных эффектов
  • Управление памятью — RAII в C++ был источником идеи; разница в том, что C++ не проверяет валидность shared-ссылок статически, а Rust - проверяет

Вопросы для размышления

  • Если 70% уязвимостей Windows - memory safety, почему индустрия 30 лет принимала это как норму, а не переходила на языки с GC?
  • Lifetime - статическое понятие без runtime-следа. Какие ещё runtime-проверки можно было бы перенести в систему типов по аналогичному принципу?
  • Borrow checker превращает design-задачу владения данными в обязательную часть архитектуры. Где это помогает, а где становится препятствием?

Связанные уроки

  • plt-10-linear-types — Ownership - практическая реализация линейных типов в Rust
  • plt-12-subtyping — Lifetime variance строится поверх правил subtyping
  • os-07-memory — Управление памятью в ОС - та же задача, но runtime вместо compile-time
  • db-13-transactions — Транзакции ACID решают те же конфликты доступа, что borrow checker
Ownership и lifetimes

0

1

Войти