В своем недавнем посте затронул основной философский аспект Rust: уровень владения значением — это часть контракта. Ключевая идея в автоматическом выборе: система по умолчанию задает самый строгий уровень и ослабляет его, лишь когда это необходимо.
В этой статье хочу развить идею и представить её экспериментальную реализацию в коде. Заодно покажу, с какими сложностями столкнулся и почему изначальную идею пришлось пересмотреть.
В чём вообще суть проблемы
Короткая вводная — для тех, кто только присматривается к Rust; кто в теме, может пролистать до следующего заголовка.
Уровень владения в Rust выбирает программист, и каждое ослабление уровня отбирает у компилятора часть проверок. Решение принимается заранее, на этапе проектирования, а ошибка неприятна в обе стороны: возьмёшь слишком строго — будешь рефакторить при первом же втором владельце; слишком слабо — подаришь компил-тайм проверки ни за что. В первой статье я приводил плоскую таблицу уровней и оговаривался, что на самом деле это должно быть дерево. Вот оно — три вопроса: сколько владельцев, сколько потоков, есть ли запись:
Владелец один? ├─ да │ ├─ владею сам → T (нужна куча → Box<T>) │ ├─ даю временно почитать → &T │ └─ даю временно изменить → &mut T └─ нет, владельцев несколько ├─ поток один │ ├─ только чтение → Rc<T> │ └─ есть запись → Rc<RefCell<T>> (Copy-мелочь → Rc<Cell<T>>) └─ потоков несколько ├─ только чтение → Arc<T> └─ есть запись ├─ чтений сильно больше (от ~5:1) → Arc<RwLock<T>> └─ иначе → Arc<Mutex<T>>
Каждый спуск — плата за гибкость: Rc добавляет счётчик и возможность утечки через цикл, RefCell переносит проверку заимствований в рантайм (паника), Mutex/RwLock — туда же, но с блокировкой и атомарщиной.
Подробнее, по колонкам — что именно мы теряем на каждом спуске:
Уровень | Утечка через цикл | Проверка заимствования | При конфликте |
|---|---|---|---|
| нет | компилятор | не бывает |
| нет | компилятор | не бывает |
| да | компилятор (доступ только на чтение) | не бывает |
| да | никто (нет ссылок внутрь, только | не бывает |
| да | рантайм | паника |
| да | компилятор (доступ только на чтение) | не бывает |
| да | рантайм | блокировка; рекурсивный захват — деадлок |
| да | рантайм | блокировка; рекурсивный захват — деадлок или паника |
Пара уточнений к последним двум строкам. Mutex не различает читателей и писателей, так что параллельные чтения он сериализует — отсюда и правило «от ~5:1 берём RwLock». Оба лока в std ещё и отравляются (poisoning): паника в потоке, державшем лок на запись, переводит лок в отравленное состояние, и все последующие lock()/write() возвращают Err — то есть повсеместные .unwrap(), которые превращают одну панику в лавину. И «деадлок или паника» у RwLock — это не оговорка: документация std на повторный захват из одного потока честно пишет «might panic», конкретное поведение зависит от платформенной реализации.
UniPtr: Rc и Arc в одном флаконе
Теперь о том, как идея была пересмотрена спустя время. Замахиваться на замену стандартных указателей я не стал. Вместо этого — попытка статистически обоснованного решения проблемы выбора: на этапе прототипа не выбирать вообще, собрать фактическое использование под реальной нагрузкой — и выбрать узел дерева по данным, а не по предчувствию. Выбирает по-прежнему программист, но уже не вслепую.
Для этого нужен универсальный указатель, который стерпит любую ветку дерева. Первая мысль была совсем простой: а зачем вообще что-то выбирать, берём везде Arc<RwLock> — самый слабый уровень, он покрывает все случаи. Но нет. Накладные расходы — полбеды (атомарный счётчик ссылок вместо обычного, лок на каждый доступ — для однопоточного кода всё это впустую). Хуже другое: поведение при конфликте. RwLock при конфликте блокируется, и в многопоточном коде это осмысленно — подождал и дождался. А в однопоточном ждать некого:
use std::sync::{Arc, RwLock}; let items = Arc::new(RwLock::new(vec![1, 2, 3])); let guard = items.read().unwrap(); // читатель жив if guard.contains(&2) { items.write().unwrap().push(20); // ждём, пока читатель отпустит... } // ...а читатель — мы сами
Один поток, никакой конкуренции — а программа висит намертво (строго говоря, std не обещает даже деадлока: документация на RwLock::write честно пишет «might panic», т. е. поведение зависит от платформы). Причём в живом коде чтение и запись обычно разнесены по разным функциям, и глазами такое не видно. Тот же код на Rc<RefCell>:
use std::{cell::RefCell, rc::Rc}; let items = Rc::new(RefCell::new(vec![1, 2, 3])); let guard = items.borrow(); if guard.contains(&2) { items.borrow_mut().push(20); // panic: already borrowed }
— паника ровно в момент нарушения, со стектрейсом в виновную строчку, ловится первым же тестом. То есть взяв Arc<RwLock> везде, мы меняем громкие ошибки на тихие зависания — плохой размен.
Хотелось совместить: пока поток один — мгновенная семантика RefCell с паникой при конфликте, появился второй поток — честный RwLock, что и было реализовано в экспериментальном проекте (crates.io). Один тип UniPtr<T>, который закрывает сразу две ветки дерева:
Несколько владельцев, один поток, изменение → Rc<RefCell> Несколько владельцев, несколько потоков, изменение → Arc<Mutex> / Arc<RwLock>
Работает так. Пока указатель трогает один поток — это, по сути, Rc<RefCell>: сколько угодно читателей или один писатель, ничего не блокируется, конфликт — немедленная паника (как RefCell::borrow_mut на занятом значении). Как только guard берёт второй поток — указатель необратимо «эскалируется» до настоящего RwLock, и дальше конфликты не паникуют, а блокируются, как у Arc<RwLock>.
use uniptr::UniPtr; let counter = UniPtr::new(0u64); // Один поток: семантика RefCell, ничего не блокируется. let a = counter.read(); let b = counter.read(); // много читателей — можно assert!(counter.try_write().is_none()); // писатель ждёт своей очереди drop((a, b)); *counter.write() += 1; // Появился второй поток — дальше это обычный Arc<RwLock>. let t = { let counter = counter.clone(); std::thread::spawn(move || *counter.write() += 1) }; t.join().unwrap(); assert!(counter.is_multithreaded()); assert_eq!(*counter.read(), 2);
Внутри быстрого пути — одно атомарное слово (бит режима плюс счётчик заимствований), guard выдаётся одним CAS. Эскалация без гонок: поток, который её запускает, засыпает, пока не отвалятся все быстрые guard, и только потом власть переходит к настоящему локу. Переход односторонний — обратно в быстрый режим указатель не возвращается.
Самое интересное — не указатель, а статистика
В процессе выяснилось, что самая полезная часть эксперимента — вовсе не сам адаптивный указатель, а фича stats. Каждый UniPtr считает своё использование: сколько чтений, сколько записей, сколько потоков его трогало, были ли клоны, сколько времени держали guard. От кода нужно два движения: дать указателям имена при создании и в конце напечатать отчёт:
use uniptr::UniPtr; let config = UniPtr::new_named("config", String::from("port=8080")); let counter = UniPtr::new_named("counter", 0u64); // Дальше обычный код: config только читают, counter только пишут. for _ in 0..5 { let _ = config.read().len(); } for _ in 0..10 { *counter.write() += 1; } println!("{}", uniptr::stats::report());
config и counter из этого сниппета — первые две строки таблички ниже. Остальные четыре указателя добавлены из примера в репозитории (cargo run --example stats --features stats), по одному на каждую ветку дерева выбора; реальный отчёт шире (там ещё времена удержания guard и ожидания на локах), здесь сокращённый:
pointer thr reads writes clones suggestion "config" 1 5 0 0 &T "counter" 1 0 10 0 Box<T> / &mut T "model" 1 1 0 1 Rc<T> "cache" 1 1 1 1 Rc<RefCell<T>> "registry" 5 400 1 4 Arc<RwLock<T>> "queue" 5 0 200 4 Arc<Mutex<T>>
Колонка suggestion — это, по сути, проход по дереву выбора из начала статьи, только не по замыслу, а по фактам: сколько потоков указатель видел, клонировали ли его, писал ли в него кто-нибудь. Выбор по-прежнему автоматический и обоснованный, но не до запуска, а после: вот этому указателю хватило бы обычной ссылки, этому — Rc, а вот этот действительно живёт в пяти потоках, тут Arc<RwLock> честно заработан. Обвешиваешь прототип UniPtr-ами, гоняешь, смотришь в таблицу — и заменяешь каждый на самый дешёвый стандартный тип, который выдержал бы наблюдаемую нагрузку. Указатель оказался не столько структурой данных, сколько диагностическим инструментом.
Кстати, LLM-ка, которая любит совать Rc<RefCell> где попало (с этого начинался прошлый пост), по этой таблице отлавливается моментально: указатель с одним потоком, нулём клонов и нулём записей — это просто &T, который зачем-то нарядили.
Немного философии: почему само правило выбросить нельзя
В Rust-мире у него есть каноничное имя — aliasing XOR mutability (либо шаринг, либо мутация, но не одновременно); дальше буду звать его правилом единственного писателя.
Первая версия UniPtr замахивалась на большее и ослабляла само правило: раз поток один и гонок нет — разрешим write-guard одновременно с живыми read-guard, кому от этого плохо. Вот кому:
// старая версия UniPtr: write() при живом читателе разрешался let r = UniPtr::new(Vec::with_capacity(4)); r.write().extend_from_slice(&[10i64, 20, 30, 40]); // заполнен под завязку let g = r.read(); let first: &i64 = &g[0]; // ссылка внутрь буфера вектора r.write().push(50); // буфер полон: push реаллоцирует его println!("{first}"); // use-after-free: читаем освобождённую память
Один поток, никаких гонок — и готовый use-after-free: push-у не хватило места, он перевёз элементы в новый буфер и освободил старый, а first так и смотрит в старый. В текущей версии тот же код паникует на write() — раньше, чем ссылка успеет повиснуть; сценарий сохранён в репозитории как пример (cargo run --example dangling). То есть переносить проверку правила из компил-тайма в рантайм можно, менять наказание с паники на блокировку можно, а отменить само правило нельзя ни при каком дизайне.
Та же проблема — классика C++. Вот она в оригинале:
std::vector<int> v = {1, 2, 3}; int& first = v[0]; v.push_back(4); // реаллокация: буфер переехал std::cout << first; // UB: ссылка указывает в освобождённую память
Механика та же, один в один: в один момент времени существовали ссылка на элемент (чтение) и операция, меняющая контейнер (запись). push_back перевыделил буфер, и first повисла. Та же история с инвалидацией итераторов:
for (int x : v) if (x == 2) v.push_back(x * 10); // UB: итератор цикла инвалидирован
Заметьте, что здесь ломается: не «два потока подрались за память», а «нечто, читающее структуру, молча предполагало, что структура не изменится под ним». Ссылка, итератор, string_view, указатель на элемент map-а — всё это материализованные предположения о том, что объект постоит спокойно. Запись эти предположения рушит.
Теперь тот же код в Rust:
let mut v = vec![1, 2, 3]; let first = &v[0]; v.push(4); println!("{first}");
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
Правило единственного писателя — это и есть формализация того самого предположения. Пока живёт хоть один читатель, никто не имеет права менять объект, поэтому всё, что читатель успел вычислить (адрес элемента, позицию итератора), гарантированно остаётся валидным. C++ полагается на то, что программист держит эти предположения в голове; Rust требует их записать в типах и проверяет. Ослабить правило — значит вернуть висячие ссылки, только теперь без даже теоретической возможности их отловить, потому что язык без GC не знает, жива ли ещё память, на которую вы смотрите.
Потоки лишь поднимают ставки: в однопоточном коде нарушение правила даёт висячую ссылку в конкретном месте, в многопоточном — data race, который воспроизводится по четвергам. Но источник один и тот же. Поэтому и RefCell (один поток), и RwLock (много потоков) охраняют одно и то же правило, различаясь только моментом проверки и видом наказания. Собственно, поэтому их и удалось склеить в один UniPtr — они не два разных механизма, а два режима одного.
А есть ли альтернативы правилу единственного писателя?
Правило выглядит рустовской придумкой, но с той же проблемой сталкивается любой язык, который даёт посмотреть внутрь чужого буфера. Вариантов по большому счёту два: сделать окно не ссылкой, а вторым владельцем буфера — так поступил Swift, и это достаточно любопытный случай, чтобы разобрать его отдельно, следующим разделом. Либо выдавать настоящую ссылку, но подстелить сборщик мусора.
Со вторым вроде бы всё понятно: память жива, пока на неё хоть кто-то смотрит, висячей ссылке взяться неоткуда. Только GC отвечает ровно на один вопрос — жива ли эта память. Вопрос «а описывает ли мой взгляд на неё сегодняшнее состояние» остаётся открытым. Вот C#, язык с GC и с настоящими окнами в буфер:
var list = new List<int>(3) { 1, 2, 3 }; Span<int> span = CollectionsMarshal.AsSpan(list); // окно во внутренний массив списка span[0] = 42; // окно живое: пишем через него — меняется список Console.WriteLine(list[0]); // 42 list.Add(4); // ёмкость кончилась: List выделил новый массив и скопировал данные туда span[0] = 7; // та же строка, но пишем уже в старый массив Console.WriteLine(list[0]); // 42 — запись потерялась Console.WriteLine(span.Length); // 3 — окно застряло в прошлом Console.WriteLine(list.Count); // 4 — а список уехал вперёд
Смысл именно в том, что строка span[0] = ... встречается дважды и означает разное. В первый раз окно смотрит в тот самый массив, которым живёт список, и запись через него список меняет — ради этого CollectionsMarshal.AsSpan и существует: это законный способ править элементы списка на месте, не копируя их. Во второй раз между получением окна и записью успел вклиниться Add, список переехал в свежий массив, и та же самая строка пишет в буфер, на который больше никто не смотрит. Никакого сигнала при этом не поступает: не бросается исключение, не портится память, span продолжает исправно работать и отвечать Length == 3.
Это тот же самый сценарий, что валит C++ (там span смотрел бы в освобождённую память) и не компилируется в Rust. GC действительно спасает: старый массив жив, пока его держит span, выхода за границы нет, UB нет. Но ошибка никуда не делась — она из громкой стала тихой. Программа не падает, она спокойно работает с устаревшим снимком, а расхождение в данных вы обнаружите сильно позже и совсем в другом месте. И заметьте, где именно спрятан баг: не в строке с записью, которая выглядит совершенно невинно, а в Add где-то между — возможно, вообще в другой функции, куда список передали параметром.
Показательно, что C# это отлично понимает и вокруг Span<T> выстроил, по сути, мини-borrow-checker. Span<T> — это ref struct: его нельзя положить в поле обычного класса, нельзя захватить в лямбду, нельзя протащить через await или yield. Все эти запреты про одно и то же: окно не должно пережить кадр стека, в котором получено. А C# 11 добавил scoped и явные правила ref safety — это уже почти lifetime-аннотации, только прибитые к одному семейству типов, а не разлитые по языку.
Там же, где статически не выходит, C# делает ровно то, что RefCell, — проверяет в рантайме:
var list = new List<int> { 1, 2, 3 }; foreach (var x in list) if (x == 2) list.Add(20); // InvalidOperationException: Collection was modified
Механика совпадает с нашей до деталей: у коллекции есть счётчик версий, мутация его увеличивает, MoveNext сверяет значение и бросает исключение при расхождении. В Java то же самое зовётся ConcurrentModificationException. То есть managed-языки правило единственного писателя не отменили — они перенесли проверку в рантайм и смягчили наказание с UB до исключения. Сделка знакомая: это ровно то, что делает RefCell, только реализованное отдельно в каждой коллекции стандартной библиотеки.
Вариант Swift: срез есть, а правило выкуплено копией
Второй путь — Swift. Сборщика мусора нет, borrow checker-а нет, правило единственного писателя программисту в лицо никто не тычет — и висячих ссылок при этом тоже нет. Посмотрим, как это получается.
Сразу договоримся, о чём речь. v[0] в Swift возвращает копию элемента, но это ничем не отличается от Rust: v[0] для Vec<i32> тоже копия, потому что i32: Copy. Ссылка внутрь контейнера в обоих языках получается не индексом, а срезом. В Rust это &v[2..] и &s[i..], в Swift — a[2...] и s[i...], то есть ArraySlice и Substring. Оба разделяют буфер с оригиналом, без копирования. Так что окно в чужую память Swift выдаёт — интересно, что он делает с ним потом.
var a = [1, 2, 3, 4, 5] var tail = a[2...] // ArraySlice: буфер общий с a, копирования нет a.append(6) // буфер разделён → a уезжает в свежую копию, O(n) print(a) // [1, 2, 3, 4, 5, 6] print(Array(tail)) // [3, 4, 5] — старый буфер цел, срез видит снапшот tail[2] = 99 // а теперь пишем в срез (индекс 2, а не 0 — об этом ниже) print(Array(tail)) // [99, 4, 5] print(a) // [1, 2, 3, 4, 5, 6] — оригинал не шелохнулся
Что здесь произошло. Массив в Swift — это обёртка над буфером, у которого есть счётчик ссылок, и перед любой мутацией массив спрашивает isKnownUniquelyReferenced: я один держу этот буфер? Пока срезов нет, ответ «да», и append пишет в буфер на месте, амортизированные O(1). Как только появился tail, ссылок стало две, ответ стал «нет» — и append сначала копирует весь буфер, а пишет уже в копию. Копируется не читатель, а писатель. Старый буфер при этом не освобождается: его удерживает ссылка среза, и ARC отпустит память только когда умрёт последний держатель. Висячей ссылке взяться неоткуда просто по построению.
Это тот же copy-on-write, на котором в Swift стоят все коллекции: var b = a буфер не копирует, а копия отпочкуется, когда b начнут менять. Со срезом он вывернут наизнанку: там копия делалась при записи в дубликат, здесь — при записи в оригинал. Правило единственного писателя не отменено, оно откуплено: вместо «запись запрещена, пока живёт читатель» получается «запись уезжает в новую память, а читатель дочитывает старую». Rust на том же месте просто не скомпилируется — a.push(6) при живом &a[2..] это ошибка заимствования; Swift скомпилируется, отработает и молча заплатит. Обратите внимание на разницу с C#: там окно тоже осталось смотреть в старый массив, но это была ошибка, а здесь — гарантированная семантика значения, ровно то поведение, которое обещано.
И ещё одно отличие, уже про обратное направление: правка среза оригиналу не видна — ни до переезда, ни после. ArraySlice такой же value type, и правило уникальности работает в обе стороны. Если на момент записи буфер ещё общий, копируется уже срез, а массив остаётся при своём; если оригинал успел уехать в новую память, срез оказывается единственным владельцем старого буфера и пишет в него на месте, без копии. То есть алиасинга нет ни в одну сторону. В C# ровно наоборот: span[0] = 42, сделанный до Add, поменял бы и сам список, потому что окно смотрит в живой массив, а не в снимок, — на этом CollectionsMarshal.AsSpan и построен.
И обещанное про индексы: срез правится как tail[2], а не tail[0], и это не опечатка. ArraySlice наследует координаты оригинала, поэтому a[2...][0] — не первый элемент среза, а падение с index out of range. Мелочь, но показательная: срез остаётся окном в исходный буфер со всеми его координатами, даже когда буфер у него уже свой.
Заплатит, впрочем, ощутимо, и в трёх местах сразу.
Во-первых, append стоит O(n) вместо амортизированной O(1), и это невидимо в точке вызова: цена строки зависит не от неё самой, а от того, жив ли где-то в другой функции срез этого буфера. Тот же вызов в цикле то бесплатный, то линейный.
Во-вторых, срез удерживает буфер целиком, а не свой кусочек. Substring от 50-мегабайтной строки держит все 50 МБ, пока жив, даже если сам он длиной в десять символов — поэтому в Swift и рекомендуют не хранить Substring долго, а конвертировать в String, то есть скопировать. В Rust у &str ровно та же арифметика, но там удержание видно в типе и его проверяет компилятор, а не память в проде.
В-третьих, весь механизм оплачен подсчётом ссылок: каждое создание и уничтожение среза — это retain/release, каждая мутация — проверка уникальности буфера, и так на каждый чих, а не только там, где это действительно нужно. Выключить нельзя, оно вшито в модель. Плюс ARC, как и Rc, течёт на циклах — отсюда все эти weak и unowned.
И показательная деталь. Срез в Swift — не ссылка, а второй владелец буфера, поэтому правило ему и не нужно. Но там, где Swift раздаёт настоящую ссылку с монопольным доступом (inout), правило единственного писателя немедленно возвращается — под собственным именем Law of Exclusivity. Что можно, компилятор ловит статически:
var x = 0 func modify(_ a: inout Int, _ b: inout Int) {} modify(&x, &x) // error: overlapping accesses to 'x'
а что статически не видно (доступ через классы, escaping-замыкания) — проверяется в рантайме и роняет программу. Знакомо звучит? Это буквально RefCell::borrow_mut, только встроенный в язык. То есть в единственном месте, где Swift выдаёт голую ссылку, он воспроизводит правило один в один — просто площадь его применения крошечная, потому что всё остальное закрыто value-семантикой и счётчиком.
Итого Swift окно в чужой буфер выдаёт, но правило единственного писателя не отменяет, а выкупает: копией при записи и счётчиком ссылок на каждом срезе. Альтернатива, стало быть, существует и работает: Swift выбрал платить по чуть-чуть везде, чтобы программист не думал о владении почти никогда.
Swift в этом не одинок, хотя компанию ему составляют довольно разные вещи. Ближе всего Hylo (бывший Val) — исследовательский язык, который строится ровно на этой идее, доведённой до предела: mutable value semantics, ссылок как значений нет вообще, а значит нечему и висеть. Тот же приём десятилетиями работает в Qt: QString, QList и компания — это implicit sharing, то есть буфер с атомарным счётчиком и копия при первой мутации разделённого буфера, поверх обычного C++. В PHP массивы и строки устроены так же: refcount плюс copy-on-write. И, наконец, исторический экспонат: в libstdc++ до GCC 5 std::string был COW-строкой, но C++11 такую реализацию фактически запретил — требования к инвалидации итераторов и к потокобезопасности operator[] с ней несовместимы. Nim рядом, но не совсем здесь: seq и string там тоже value types и присваивание копирует, только копия честная и сразу, а не отложенная; лишние копии убирает анализ перемещений, а не счётчик.
Дилемма: либо срез-владелец, либо правило единственного писателя
В сухом остатке выбор для языка без GC такой: либо срез — не ссылка, а совладелец буфера со счётчиком и копией при записи (путь Swift, где редкие настоящие ссылки живут под Law of Exclusivity), либо срез — голый указатель в чужую память, и тогда правило единственного писателя обязательно. Третьего не дано; точнее, третьим был бы GC, но он, как мы видели на C#, снимает лишь UB, а само правило возвращает в виде рантайм-проверок.
Rust на этой развилке выбора не имеет: системному языку нельзя запретить ссылки в чужую память. Банальная задача: приняли из сокета пакет, надо отрезать заголовок и передать тело дальше по стеку:
let mut buf = [0u8; 65_536]; // буфер живёт вечно и переиспользуется let n = socket.read(&mut buf)?; // приняли пакет let frame = &buf[..n]; let header = &frame[..16]; // заголовок let body = &frame[16..]; // тело: ноль копий, указатель плюс длина
body — это срез: указатель внутрь чужого буфера плюс длина. Отрезать заголовок стоит O(1) и не зависит от того, сколько там байт. В мире Swift сама нарезка тоже дешёвая, но у неё есть хвост: каждый срез — это retain/release, а следующий read в тот же буфер при живом срезе предыдущего пакета — это копия всех 64 КБ, потому что буфер разделён. То есть переиспользовать буфер, ради чего он и заведён, не выйдет: копия на каждый пакет. А пакетов пусть будет миллион в секунду, и каждый разбирается слоями: Ethernet отдаёт кадр IP, IP отдаёт датаграмму TCP, TCP отдаёт байты TLS, TLS — HTTP-парсеру, и каждый слой откусывает свой заголовок и передаёт остаток выше. Со срезами вся эта цепочка стоит ноль аллокаций и ноль копирований: наверх едет пара «указатель, длина», а сами байты так и лежат в том буфере, в который их принял сокет. С копиями то же тело переписывается по разу на слой, и производительность определяется уже не задачей, а аллокатором. Для системного языка ссылки и срезы без копий — не удобство, а условие существования: ОС, парсеры, сетевые стеки, базы данных живут тем, что смотрят в чужие буферы, не трогая их.
А как только язык раздаёт голые указатели внутрь чужих буферов, он обязан гарантировать, что буфер не переедет и не умрёт, пока срезы живы. Гарантировать это без GC и без подсчёта ссылок можно ровно одним способом: пока живёт читатель — запись запрещена. Круг замкнулся: ветка «срез как совладелец» для системного языка закрыта, остаётся правило.
Итоги
Получился не новый указатель на замену стандартным — жить на нём в продакшене я не предлагаю: всё, что компилятор доказал бы бесплатно, он проверяет в рантайме. Получилась заготовка инструмента для этапа разработки. Вместо того чтобы угадывать уровень владения на этапе проектирования, обвешиваешь прототип UniPtr-ами, гоняешь под реальной нагрузкой, снимаешь статистику — и расставляешь стандартные указатели по колонке suggestion, уже обоснованно. Проблема выбора решается не заменой дерева, а данными для спуска по нему.
Называть это готовым решением было бы сильным преувеличением. Скорее это идея, которая, как мне кажется, имеет право на жизнь, но которую ещё доводить и доводить. Слабых мест хватает. Статистика показывает ровно тот сценарий, который вы прогнали: ветка, не проехавшая под нагрузкой, в отчёт не попадёт, а редкая гонка может за прогон и не случиться. «Указатель видели пять потоков» ещё не значит, что доступ к нему был конкурентным, а выбор между Mutex и RwLock держится на соотношении чтений и записей — то есть опять же на том, чем именно вы нагрузили прототип. Сам сбор статистики стоит времени и меняет тайминги, то есть наблюдение слегка портит наблюдаемое. И, наконец, есть вещи, которых в счётчиках не видно вообще: цикл, из-за которого понадобится Weak, или планы на код, которых пока нет в коде.
Если хочется потыкать — cargo add uniptr, пример со статистикой — cargo run --example stats --features stats.
А вопрос у меня на этот раз такой. Профилировщик владения как класс инструмента — «запусти и узнай, какой самый дешёвый указатель тут пережил бы реальную нагрузку» — вам бы в работе пригодился? Или выбор уровня владения — это как раз то место, где думать головой полезно и делегировать его автоматике не хочется ни в каком виде?

