Предыдущие части: Время жизни, Графическая прокладка, Дженерики, Композиция, Модули, Начальное проектирование, Итоги про память, Что там с памятью, Колхозим интерфейсы, Где у него классы, Поддержка SDL2, Полируем ржавчину
В ситуации, когда возникающие дыры затыкаются подручными средствами, необходимо собраться и подумать – откуда собственно берутся дыры? Поэтому вернёмся немного назад, к первым выпускам про Rust, и освежим материал с новым подходом к проблеме.
Что точно известно
- Все данные, размер которых фиксирован, размещаются на стеке
- У любых данных может быть только один владелец в виде переменной
- Передача владения происходит через перемещение данных (не копирование)
Это три слона, на которых стоит Rust. Из них появляется абсолютно всё остальное. Поясню каждый из пунктов.
1. Стек
Вот структура, размер которой известен, потому что она содержит 2 поля по 4 байта, т.е. всего 8 байт:
Вот мы порождаем данные этого типа в функции main():
В области стека функции main() резервируется 8 байт и они заполняются значениями, указанными для инициализации полей.
2. Один владелец
Сейчас данными на стеке владеет переменная dob. Если написать так:
let dob2 = dob;
То владение этими данными перейдёт к переменной dob2, а переменная dob станет невалидной, пользоваться ею больше будет нельзя (пока она не начнёт владеть чем-то другим).
3. Перемещение
И вот тут очень важно понять, как именно происходит передача владения.
Происходит она через перемещение данных. Для начала рассмотрим простое копирование.
- Для переменной dob2 выделяется ещё 8 байт на стеке.
- В эти 8 байт копируются данные из переменной dob (предыдущие 8 байт на стеке)
После этого в dob2 есть физически отдельная копия данных из dob. Так происходит, явно или неявно, во многих языках, но Rust на этом не останавливается и освобождает область стека, занятую переменной dob, а саму переменную делает невалидной.
Теперь у данных есть только один владелец – dob2, а сами данные существуют в единственном экземпляре, то есть были именно перемещены, а не скопированы.
Перемещение работает как физическая пересылка байтов из одного места в другое.
Rust может оптимизировать код и не делать лишних перемещений (скорее всего в данном примере так и происходит). Но для нас неважно, что именно он делает, для нас это всегда считается перемещением и передачей владения.
Исключение
Данные примитивных типов не перемещаются и владение не передаётся. Здесь всё просто. Если
let a: i32 = 5;
то это примитивный тип, значение которого (число 5) занимает одну ячейку памяти. Написав:
let b: i32 = a;
мы копируем значение 5 в переменную b, но не забираем владение у переменной a, так как это бессмысленно.
Прояснение
Теперь, держа в уме стек, владение и перемещение, мы можем понять, что, когда и как происходит, а также что принципиально нельзя сделать.
Попробуем в функции создать объект и вернуть указатель на него:
В области стека функции create_display_object() создаётся временный объект типа DisplayObject (то есть резервируются те же 8 байт), и затем возвращается указатель на него, который является ничем иным как адресом в стеке.
Но как только функция заканчивается, её стек освобождается, и значит указатель мы вернули, но данных, на которые он указывает, уже нет. Значит, так делать нельзя. Rust не будет компилировать такой код.
Итак, функция не может вернуть указатель ни на какой объект, созданный внутри функции, если он создан на стеке. Ни при каких условиях.
Тогда на какой объект можно вернуть указатель из функции? Либо на тот, который был создан вне этой функции (но тогда указатель на существующий объект нужно передать в функцию, иначе она его никак не получит), либо функция может сама создать объект, но разместить его не в стеке, а в куче, то есть в области динамически выделяемой памяти.
Для размещения в куче у Rust есть специальная структура Box<T>, но этим займёмся позже.
Теперь посмотрим на функцию, которая возвращает не указатель, а сам объект:
Этот пример прекрасно компилируется.
Функция создаёт в своей области стека временный объект DisplayObject (8 байт). Затем она возвращает не указатель на него, а сами эти 8 байт. Происходит это так: функция main() резервирует в своей области стека 8 байт под переменную dob, и временный объект копируется из функции create_display_object() в эту переменную. Функция create_display_object() завершилась, её область стека освобождена, временного объекта больше нет, но данные из него были скопированы в dob и теперь ими владеет dob.
Это буквально открывает глаза на то, как работает Rust. Я за свою жизнь весьма привык к передаче указателей, поэтому копирование кусков памяти из одного в другое место просто ради того, чтобы их передать кому-то, вызывает у меня шок. Я сразу представляю себе, как это должно сказываться на производительности.
Но реальность такова, что это происходит, и происходит очень часто, и не только в Rust. Многие языки делают то же самое, просто не афишируют эти механизмы (и также нужно учитывать возможности оптимизации).
Поэтому первое, что нужно понять и принять, работая с Rust: копирования неизбежны, а указатели приносят проблемы. Это нормально, этого не надо бояться. Если же есть данные, которые слишком дорого или нецелесобразно копировать, то храниться они должны в куче или в статической памяти.
Само собой теперь будет получаться так, что программы нужно будет особым образом перестраивать, чтобы они эффективнее работали именно в Rust. И в целом есть ощущение, что архитектура программы, спроектированная под Rust, даст некоторые преимущества и в других языках.
Так что в следующем выпуске я попробую переписать объекты Stage и DisplayObject по-новому.
Читайте дальше: