Вчера я посетил хакерский дом DSRV в Сеуле, где мой друг Итан Фрей проводил большое практическое занятие по созданию приложений CosmWasm — от смарт-контрактов до базового dApp. Во время сеанса из зала прозвучал вопрос — Так что, одолжить указатель? После некоторого колебания Итан ответил: Да, это так. Я понимаю, почему он так отреагировал — в подавляющем большинстве случаев вы можете легко относиться к заимствованиям как к указателям. Но я также полностью поддерживаю его колебания — заимствования (или, скорее, ссылки — заимствование — это просто акт их получения) в большинстве случаев реализуются как указатели, но их семантика отличается. А еще — большинство случаев — это не все случаи.

Отказ от ответственности

В статье подробно рассматривается вопрос о том, является ли заимствование (имеется в виду ссылка) указателем, заданным новичком в Rust из другого технического стека. В Rust есть свой собственный тип указателя, но он ведет себя немного иначе, чем мы ожидаем, учитывая указатели из C++ или других распространенных языков. Но чтобы было понятно — под «указателем» в этой статье я подразумеваю переменную, содержащую адрес с обычными операциями, которые нужно выполнить над ней. Цель статьи состоит в том, чтобы подчеркнуть, что ссылки и заимствования в Rust — это гораздо больше, и в некоторых случаях представление о ссылках как об адресах может ввести в заблуждение.

Что такое указатель?

Давайте установим некоторые определения указателя, чтобы обсудить отношения между указателем и заимствованием. Указатель — это переменная, содержащая адрес некоторых данных в памяти. Обычно это может быть любое число без знака, соответствующее машинному слову — u32для 32-разрядных машин и u64для 64-разрядных машин. Одной из стандартных операций над указателями является разыменование, которое достигает памяти, адресованной указателем, и извлекает из нее данные. Во всех известных мне языках нет причин, по которым указатель не может принимать произвольное значение — в частности, часто приходится иметь указатель, содержащий 0, обычно называемый nullили nil. Такой указатель является специальным значением для «этот указатель не указывает ни на какие данные». Кроме того, указатели, являющиеся просто числами, могут указывать на данные, которые никогда не будут действительными. Простой случай — возврат указателя на локальные переменные функции — когда функция выполнена, указатель фрейма закрывается, а данные недействительны. Большинство языков могут обнаруживать такие очевидные случаи — C++ выдает предупреждение — но обычно получение недопустимого указателя, вызывающего поведение «использовать после освобождения», не представляет сложности. Также есть проблема, что произойдет, если мы разыменуем такой нулевой или недопустимый указатель? В большинстве языков существует неопределенное поведение — либо (если повезет) это вызовет сбой из-за ошибки сегментации (из-за сбоя защиты памяти). Тем не менее, часто такая операция завершалась успешно, предоставляя пользователю неверные и/или противоречивые данные. Такое поведение имеет тенденцию вызывать серьезные логические ошибки, которые очень трудно обнаружить.

Последнее, что мы обычно ожидаем от указателей, — это так называемая арифметика указателей. Поскольку указатели — это просто числа, должен быть способ просто складывать или вычитать их. Этот метод часто используется для эффективного перебора непрерывной памяти объектов одного типа (также известных как массивы или срезы). Некоторые языки изначально не поддерживают его (думаю о Nim прямо сейчас), но даже в этом случае часто можно легко обойти это — в Nim вы просто приводите указатель к целому числу, выполняете арифметические действия и возвращаете его обратно без компилятора. жалобы.

Очевидно, я знаю, что есть языки, обрабатывающие указатели по-разному — например, большинство языков, управляемых сборщиком мусора, не позволяют иметь «висячие указатели», — но также их обычно называют «ссылками», а не «указателями» по уважительной причине — они не совсем указатели. Тот же аргумент для «умного указателя» — конечно, они решают некоторые проблемы с указателями, сохраняя при этом свою основную функциональность «разыменования», но они являются «умными указателями», а не «указателями». Заимствование в Rust не является ни тем, ни другим — у них другая семантика, тесно связанная с правилами заимствования и временем жизни. Теперь пришло время рассмотреть их поближе.

Получение справки

Во-первых, начнем с того, что ссылка на Rust не может быть построена из произвольного целочисленного значения. Единственный способ создать ссылку в безопасном Rust — это позаимствовать ее из какой-нибудь существующей переменной. Тогда ссылка будет ссылаться на эту переменную. Важно подчеркнуть, что заемщик будет ссылаться на эту переменную, а не на память. Может показаться, что никакой разницы нет, но она есть. Каждая ссылка в Rust сохраняет дополнительную ценность в виде времени жизни — то есть, как долго это заимствование может существовать. И это время жизни «пока жив исходный объект, из которого создан заимствование». Это время жизни проверяется компилятором Rust и никогда не позволит использовать заимствование после того, как исходная переменная может быть мертва. Заметьте, я пишу «может быть мертва», а не «мертва» — проверка выполняется во время компиляции, поэтому нет накладных расходов во время выполнения, но это означает, что если какой-то путь, ведущий к точке в программе, уничтожит переменную, кредит нельзя использовать. Взгляните на некоторые примеры:

В первом примере мы видим, что Rust настаивает на том, чтобы мы никоим образом не позволяли вернуть заимствование в локальную переменную. И, как я уже говорил, некоторые языки обрабатывают это с помощью статического анализа кода, но в Rust это встроено в систему типов. Полная проверка заимствования никогда не позволит нам вернуть заимствование несуществующему объекту, и она может обнаруживать довольно сложные случаи. Вы можете видеть это в примерах в основных функциях — если мы попытаемся использовать заимствование после того, как drop вызывается для исходной переменной, возникает ошибка компилятора. Функция drop — это утилита для уничтожения переменной до того, как она выйдет из области видимости, поэтому использование заимствования после этого будет незаконным.

Что интересно, так это пример с изменяемым заимствованием. Так как ссылка отслеживает, из какой переменной она заимствована, можно предотвратить одновременное использование нескольких изменяемых заимствований или изменяемых и общих ссылок. Это помогает нам избежать проблем многопоточности, таких как гонка данных, но на самом деле предотвращает даже более простые, очень распространенные ошибки, возникающие в одном потоке. Рассмотрим этот фрагмент C++:

Этот код на первый взгляд подозрительный — все должны увидеть там бесконечный цикл. И да, я знаю, что мог бы использовать диапазон для синтаксиса, но я хотел сделать более очевидным то, что происходит. Но поверьте мне, бесконечный цикл здесь не проблема. В цикле мы модифицируем вектор, добавляя элемент. И это может — и в конечном итоге будет — вызывать перераспределение памяти. В результате it становится недействительным, и технически у нас здесь неопределенное поведение — на практике это всегда приводило к segfault. Так что же делать с подобным кодом?

На этот раз мы получаем ошибку компиляции. Rust жалуется, что мы пытаемся заимствовать datamutable для вызова pushфункции, но data уже заимствована для ее итерации — и итерация заимствования будет освобождена после завершения всего цикла. Это может показаться очень искусственной проблемой, но только потому, что это очень маленький фрагмент. В больших кодовых базах обычно имеется какой-то дескриптор внутренних компонентов объекта (здесь: итератор), но мутации этого объекта делают дескриптор недействительным. Rust предотвращает любые подобные ошибки, так как любой способ сохранить «дескриптор» внутренних компонентов объекта потребует сохранения заимствования объекта.

Типы нулевого размера

Разница в том, как обрабатываются заимствования по сравнению с указателями, приводит к очень интересной вещи — в Rust некоторые типы имеют нулевой размер (не путайте с неразмерными, это другое). Это означает, что независимо от того, сколько из них вы храните в своей памяти, они полностью исчезают после компиляции двоичного файла! Рассмотрим следующий фрагмент:

Эта программа напечатает стандартный вывод 0in. И если вас это не удивляет, открою вам секрет — аналогичный код на C++ вернет ненулевое значение (вероятно 1, но это технически зависит от архитектуры). Причина этого в том, что в C++ должна быть возможность создать указатель для каждого объекта. Кроме того, указатели на два разных объекта должны быть разными. В результате даже объекты без полезной нагрузки должны занимать как минимум один байт памяти. В Русте такого нет. В Rust любой объект может быть заимствован, но заимствование не обязательно должно быть адресом. В случае ZST (типы нулевого размера) заимствование содержит только информацию о типе, относящуюся к системе: его изменчивость и время жизни, но не должно оставлять никакого следа во время выполнения.

Такое поведение вокруг ZST также является очень важным отличием в том, как обрабатываются ссылки в Rust и большинстве других языков программирования. В архитектуре SW мы часто рассматриваем ссылки как указатели на конкретный объект, достоверность которых более или менее гарантирована (например, в собранном GC стеке ссылки продлевают срок службы объекта). Но это приводит к тому, что мы часто ожидаем, что ссылки будут разными для двух разных объектов с абсолютно одинаковыми значениями. Например, в C# и Java, если у нас есть типы без данных, сравнение ссылок на два разных объекта этих типов с == ведет себя точно так же, как сравнение указателей в C++ — объекты разные и различимые. Моя точка зрения заключается в том, что ссылки в Rust не имеют гарантированного понятия адреса или идентификатора объекта. Он просто заимствует из заданной переменной, но разрешается оптимизировать адрес в любой точке. ZST в значительной степени относится к такой оптимизации, и об этом стоит знать.

Если честно — я долгое время выступал за то, чтобы ссылки на ржавчину (как тип) назывались заимствованием — и только недавно меня поправили, что это не имя собственное, используемое в сообществе. Я уважаю это и перейду к общему именованию — но все же подчеркну разницу в том, как они работают в Rust по сравнению с другими стеками.

Небезопасная ржавчина

Теперь пришло время посмеяться над собой — все, что я сказал, — ложь. Есть небезопасный Rust, где я могу создавать указатели (да, в Rust есть понятие необработанного, чистого указателя) из случайных чисел, а затем использовать их для заимствования для создания оборванных ссылок или преобразования времени жизни, чтобы стимулировать использование после освобождения. Но небезопасный Rust — главная тема этой статьи. Когда я говорю о чем-либо в Rust, я всегда рассматриваю его гарантии безопасности, предполагая, что они выполняются. Я также знаю о небезопасном слое. Проблема в том, что идея небезопасного Rust не в том, чтобы дать вам возможность сжечь всю вашу систему. Идея в том, что иногда компилятору очень трудно или невозможно доказать случаи. И в таких случаях у нас есть этот инструмент для использования. Но с большой силой приходит и большая ответственность — при использовании небезопасного Rust разработчик обязан соблюдать все гарантии, которые у нас есть в безопасном Rust. Вы должны убедиться, что ваши времена жизни привязаны должным образом, и вы должны помнить, что ZST заимствует приведение к указателям, не дает уникальных адресов. Я хочу сказать, что пока вы являетесь разработчиком Rust среднего уровня, вы можете думать о заимствованиях, как если бы они были указателями, и вы никогда не пострадаете — компилятор защитит вас. Тем не менее, я думаю, что стоит изучить различия, чтобы шире взглянуть на заимствования и на то, насколько сильно они влияют на язык программирования Rust, особенно если вы хотите делать какие-то небезопасные вещи.

Найдите меня на Github и LinkedIn.
Также ознакомьтесь с моей Книгой CosmWasm и Академией CosmWasm, которую мы создаем.