В процессе разработки игры Apple я столкнулся с некоторыми проблемами и поэтому написал код определённого типа (я называю его "трусливый код"), чтобы эти проблемы трусливо обойти.
Далее я начал разработку игры RDS и решил сначала разобраться с проблемами, что мне частично удалось. Но чем дальше в лес, тем более изворотливым надо быть, чтобы проскочить между жерновами компилятора Rust. Иногда манипуляции заключаются в том, чтобы поменять местами две строчки кода. Представьте, что смысл абсолютно не меняется, но в одном случае программа компилируется, а в другом нет!
Такая стратегия в общем случае ни к чему не приведёт, так как обязательно возникнет предел, в который всё упрётся и лавировать дальше будет некуда.
Поэтому здесь я хочу разобрать, что именно не так в Rust, и как с этим жить. Я конечно об этом уже писал, но в процессе достиг уровня систематизации, выходящего за пределы какой-то конкретной игры.
Список объектов
Почти в каждой программе, будь то игра или нет, мы будем иметь дело со списком объектов. Здесь я подразумеваю не объекты в ООП-смысле, а любые структуры данных, а список это условно любая коллекция (массив, вектор и т.д.)
Объектами могут быть, например, открытые сокеты на сервере, файлы из файловой системы, строки таблицы, элементы GUI и т.д.
Короче, я не представляю себе сколько-нибудь серьёзной программы, которая не оперировала бы списком объектов. Это основа основ. Если я неправ, поправьте меня, это очень важно.
Borrow Checker был тупой
Концептуальной основой Rust является borrow checker – проверяльщик заимствований. Так как по-русски это очень неудобно пишется, я предлагаю официально называть его "боров" – так смешнее.
Нас интересуют два вида заимствований: shared (общий доступ на чтение) и mutable (эксклюзивный доступ на чтение и запись).
Концепция здесь такая:
- Некая переменная владеет данными (ими всегда владеет кто-то один). Например, x = 5. Это значит, что в некой ячейке памяти лежит число 5, и этой ячейкой владеет переменная x.
- Если мы хотим что-то сделать с ячейкой, в которой лежит 5, и обращаемся к ней не через x, мы должны позаимствовать её у переменной x.
- Мы можем получить ссылку общего доступа: &x. Так мы можем читать значение, но не менять его. Таких ссылок можно получить сколько угодно, так как никакая из них не может изменить значение.
- Мы можем получить мутабельную ссылку эксклюзивного доступа: &mut x. Так мы можем и читать, и записывать значение. Но такую ссылку можно получить только одну в одном промежутке времени.
При этом, если получена общая ссылка, нельзя получить мутабельную, и наоборот. Это всё звучит логично, как забота о целостности данных.
Пока мы не переходим к спискам.
Сделаем очень простой пример:
Объявим массив arr из двух элементов и получим мутабельную ссылку ref0 на первый элемент и мутабельную ссылку ref1 тоже на первый элемент.
Вроде бы мы нарушаем правило "не больше одной мутабельной ссылки", но программа компилируется. Это потому, что с данными ссылками мы ничего не делаем, и компилятор понимает, что их можно создать сколько угодно, пока мы не попытаемся что-то изменить в них:
Хотя мы изменили значение по ссылке ref1, программа компилируется и сейчас. Это потому, что компилятор видит, что ссылка ref0 нигде больше не участвует. На момент появления ссылки ref1 она уже как бы умерла. Это в Rust называется "автоматическое схлопывание времени жизни" или вроде того.
Но если мы вдруг подумали, что всё в этой жизни поняли, и написали вот так:
То программа уже не компилируется, так как время жизни ссылки ref0 теперь явно продлевается до строки *ref0 += 1, а ссылка ref1 создаётся внутри этого времени. Теперь две ссылки как бы существуют одновременно.
Хотя нам абсолютно понятно, что это абсолютно такая же ситуация, как в прошлый раз, и ссылка ref1 также нигде не используется, компилятор этого уже не понимает. Сюрприз.
Хорошо, давайте назначим ссылки на разные элементы массива:
Массив это две ячейки памяти. Ссылкой на первую ячейку владеет ref0, а ссылкой на вторую владеет ref1. Ровно по одной ссылке на каждую отдельную ячейку. Всё честно, ведь честно, да?
Однако программа не компилируется. Причина: получена мутабельная ссылка на массив arr. Обратите внимание: мы делали ссылки на отдельные элементы массива, которые расположены в отдельных ячейках памяти. Но боров не может это проверить даже несмотря на то, что явно указаны разные индексы массива 0 и 1.
Если взята ссылка на элемент массива, то он считает, что взята ссылка на весь массив. И поэтому мы не можем взять ещё одну ссылку на другой элемент.
Опять же, мы прекрасно видим, что такой код не создаёт никаких проблем по правилам заимствования Rust, но боров не даёт нам его компилировать. Он просто... тупой.
Это было моё последнее потрясение и разочарование от Rust. Я представлял себе какие-то замысловатые механизмы проверки, какие-то высоколобые математические алгоритмы. Но нет, всё гораздо примитивнее.
Уже одно это ставит под сомнение возможность эффективного программирования на Rust. Получается, мы не можем писать даже заведомо безопасный код, потому что боров его не понимает. А значит, мы должны вставлять костыли там, где их не предвиделось.
Паттерн обхода списка
Обладая некоторыми знаниями об умственных способностях борова, перепишем программу вот так:
Теперь она компилируется, потому что время жизни ссылки ref0 автоматически схлопывается перед появлением ссылки ref1. И мы опять видим, что принципиально ничего в коде не изменилось, мы просто перенесли одну строчку. Вообще говоря, такой код можно переписать с использованием только одной переназначаемой ссылки ref0:
Мы вынуждены писать, подстраиваясь под тупизну борова. Может быть, это и хорошо, может быть, это и надёжнее.
Напишем цикл обхода списка:
Здесь мы получаем мутабельную ссылку на arr[i], которая рождается и умирает на каждом шаге цикла, поэтому программа компилируется.
Также мы можем использовать встроенный итератор коллекций iter_mut(), который сам возвращает нам мутабельную ссылку на каждом шаге:
Работает и так и так.
В общем, суть в том, что мы получаем временную ссылку на элемент массива в каждом шаге цикла. Мы можем обработать один элемент массива, но что если обработка зависит от других элементов? Например, надо сложить текущий элемент с нулевым:
Нет, не получится, боров бздит! Хотя опять же, ну что мы тут вообще нарушаем? Нет, весь массив уже закрыт от нас, мы можем обрабатывать только текущий элемент. И в этом заключается главная проблема обработки списка объектов.
Насколько вероятна ситуация, что в ходе перебора списка объектов может понадобиться ссылка на другой объект? Здесь нельзя дать точных прогнозов, так как думаю, что большинство списков обрабатываются поэлементно и проблем не возникает.
Но лично у меня такая необходимость возникает в играх. Например, если столкнулись два объекта, то надо обработать и тот, и другой. Это порождает проблемы, когда пишешь на Rust.
Возможно, дело в неправильном паттерне использования списков. Но даже если он неправильный, это не только моя проблема, так как Rust предлагает стандартные костыли, явно направленные на её решение.
RefCell::<T>
Данная структура предназначена для купирования эффектов заблокированной коллекции. Работает она так:
- Элемент коллекции типа T заворачивается в структуру RefCell.
- Когда мы получаем ссылку на элемент коллекции, мы получаем ссылку на RefCell.
- Все ссылки на RefCell – shared, то есть их можно получать сколько угодно.
Сначала сделаем первую часть:
Теперь массив содержит не u32, а RefCell::<u32>. Касаемо накладных расходов, RefCell это структура, которая содержит данные + счётчик заимствований, так что размер данных увеличивается на машинное слово, и в целом жить можно.
Далее я получаю две ссылки rc1 и rc2 на два элемента массива. Это shared-ссылки без возможности записи, поэтому я могу получить их в любом количестве. Интересное будет дальше:
Переменные rc1 и rc2 это ссылки на элементы массива, то есть ссылки на RefCell. Они shared, поэтому боров наелся и спит. Но у RefCell я могу получить мутабельную ссылку на её содержимое с помощью borrow_mut(). Это называется внутренним заимствованием. Оно происходит не в масштабе всей коллекции, а в масштабе одного RefCell. Таким образом, у двух разных RefCell я могу получить две разные мутабельные ссылки на их содержимое. Получить две мутабельные ссылки из одного RefCell по-прежнему нельзя, поэтому какое-то подобие безопасности всё равно сохраняется.
Можно данный код упростить, обращаясь напрямую к RefCell-элементам массива:
Получается, через некоторые костыли и некоторое снижение производительности мы добились результата, который получился бы и так. Ну да ладно, пусть боров будет доволен, ведь мы не знаем его глубинные мотивы и все-все ситуации, которые могут возникнуть. Вот так зато считается безопасно.
Концептуальные изменения обработки
Если мы хотим обойтись совсем без костылей, то направление очевидно: за одну итерацию цикла должен модифицироваться только один элемент, и точка. Если же нам надо модифицировать другие, мы должны сделать это не сразу, а потом. Для этого нужно собрать те изменения, которые мы хотим сделать, и применить их в пакетном режиме.
Сделаем структуру Change, в которую можно записать изменения:
Она содержит индекс элемента массива, который нужно изменить, и значение, которое надо прибавить (допустим) к этому элементу.
Переделываем цикл:
Это очень условный и вырожденный пример. Здесь мы получаем мутабельную ссылку на элемент массива, прибавляем к нему 1 (симуляция какой-то обработки), и затем добавляем в вектор changed элемент с индексом i и изменённым значением, симулируя, опять же, накопление изменений.
Теперь можно перебрать вектор changed и применить изменения к массиву:
Цель достигнута, но какой ценой? Дополнительный вектор в памяти и дополнительный цикл обработки. Зато – несомненно более разделённый и безопасный код, который будет легче в отладке. Так что не обязательно это плохо. Наверняка есть такой паттерн программирования с каким-то названием (очередь команд? отложенные изменения? накорми борова?)
Копирование это не заимствование
Переменная владеет ячейкой памяти. При копировании содержимое ячейки переносится в другую ячейку, которой владеет другая переменная. Таким образом, получаются две ячейки, которыми владеют две переменные. И всё у них хорошо.
Можно копировать не только простые числа вроде 5, но и целые структуры. Если структура занимает до... может быть, 128 байт, то благодаря современным машинным инструкциям копирование оптимизируется до буквально одной команды.
Сделаем какую-нибудь структуру данных:
Обратим внимание на директиву #[derive (Copy, Clone)]. Она наделяет тип Data свойствами копирования. Без этой директивы Data копироваться не будет.
И сделаем массив такого типа:
Теперь будем обрабатывать его, получая копии данных, а не ссылки:
Это опять же супер-вырожденный пример для голой демонстрации. Сначала мы получаем копию элемента arr[0] – ссылка не создаётся. Затем получаем копию arr[i] – ссылка не создаётся. Затем создаём новый объект, в котором производим манипуляции с первыми двумя копиями. И записываем этот объект в arr[i] – опять же через копирование.
Ни в какой момент времени мы не владеем мутабельной ссылкой на массив. Она неявно и временно создаётся лишь тогда, когда мы копируем структуру data3 обратно в элемент массива.
Все три скопированных объекта – data1, data2 и data3 – владеют собственной памятью, временно выделенной для них на стеке.
Данный способ наиболее беспроблемный, если размер хранимых структур достаточно мал, чтобы их можно было копировать. И если эти структуры "плоские", то есть не содержат указателей на другие данные и т.п.
Подведём итог
С обработкой списка объектов можно разобраться, используя следующие техники:
- RefCell::<T> – некоторые накладные расходы, но в целом единственный механизм, который обеспечивает именно первоначальный подход с получением именно мутабельных ссылок на разные элементы коллекции одновременно.
- Отложенная модификация – в некоторых случаях прямо то, что надо, отказываемся от неправильного подхода в пользу правильного. Но логика модификации может быть ограничена доступным контекстом. Если элемент надо модифицировать какими-то сложными алгоритмами-алгоритмами с привлечением множества дополнительных данных, составлять для него список модификаций может быть сложно.
- Копирование – почти аналог RefCell, но также имеет накладные расходы в случае копирования больших структур, и ограничено "плоскими" структурами данных.
Можно ли с этим жить? Думаю, да. Непосредственное применение можно будет увидеть уже в следующих выпусках про игру RDS.
Осталась ещё одна тема. Это передача в метод объекта мутабельной ссылки на самого себя, что тоже в некотором роде распространённый паттерн, или даже анти-паттерн, но от этого не легче.
Здесь уже написано достаточно, а писать придётся едва ли не больше, поэтому данная тема продолжится в следующем выпуске: