Rust использует систему владения для управления использованием памяти. Эта уникальная функция управления памятью позволяет Rust гарантировать безопасность памяти без встроенного механизма сборки мусора во время выполнения. В этой серии я хочу помочь разработчикам с опытом работы с Go¹ лучше понять основные концепции системы владения Rust.
О ржавчине
Уже шестой год Rust считается самым любимым языком в Опросе разработчиков StackOverflow, что указывает на то, что разработчики, которые в настоящее время используют Rust, хотели бы продолжать использовать его.
Есть много причин, по которым разработчики любят Rust. В Rust речь идет не только о том, какой код мы можем написать, но и о том, какой код мы не можем написать. Компилятор проделывает фантастическую работу, гарантируя, что мы не выстрелим себе в ногу, применяя набор правил управления памятью для устранения ошибок памяти, таких как висячие указатели, разыменование нулевого указателя, двойное освобождение, неинициализированный доступ к памяти и т. д. Все вместе эти правила образуют систему владения Rust. Поскольку эти правила оцениваются во время компиляции средством проверки заимствования Rust, они не налагают никаких ограничений на производительность во время выполнения.
В оставшейся части этого поста будет рассмотрена концепция перемещений в контексте назначения переменных, аргументов функции, возвращаемого значения, управления потоком, структур и коллекций. Другие понятия, такие как копирование, заимствование, время жизни и небезопасность, станут темами этой серии в будущем.
Владение данными
Rust хранит данные известного фиксированного размера в стеке. Когда эти данные выходят за рамки², они извлекаются из стека, чтобы освободить основную память. Данные, размер которых неизвестен во время компиляции и может измениться во время выполнения, хранятся в куче. Управление ими требует больше работы, и именно здесь становятся важными правила владения.
Цитируя Язык программирования Rust, это основные правила, которые составляют суть модели владения Rust:
Каждое значение в Rust имеет переменную, которая называется владельцем. Одновременно может быть только один владелец. Когда владелец выходит за пределы области действия, значение будет удалено.
Пример
Давайте на примере продемонстрируем группу проблем, которые могут предотвратить правила владения Rust.
Следующая программа Go объявляет структуру Service
, состоящую из двух строковых полей, фрагмента строк и сопоставления строк в строки. Он делает копию переменной с именем svc
, присваивая ее другой переменной с именем nginx
:
После присвоения переменной в строке 22 обновляются поля nginx
. Некоторых читателей может удивить, что изменения, внесенные в карту mappings
из nginx
, были распространены на mappings
из svc
, хотя изменения в строке namespace
и срезе endpoints
не были:
Независимо от намерения программиста присвоить переменную, это произошло потому, что nginx
и svc
разделяли базовую карту mappings
.
В Rust такой побочный эффект невозможен, потому что компилятор гарантирует, что mappings
имеет только одного владельца. Ниже приведена та же программа, написанная на Rust:
Проверка заимствования не удалась в строке 29, потому что svc
больше нельзя было использовать. Право собственности на все данные svc
было передано nginx
до того, как svc
стало недействительным. Модификация, внесенная в nginx
, не повлияла на svc
.
Только один владелец
Давайте используем простой пример присваивания переменной, чтобы изучить правило «одного владельца»:
В Go строка представлена структурой reflect.StringHeader
, которая имеет указатель на данные и длину этих данных. Когда str1
присваивается str2
, Go копирует базовую структуру str1
в str2
. Это приводит к тому, что и str1
, и str2
указывают на одно и то же Data
(пока одному из них не будет присвоено другое строковое значение):
Строка в Rust также представлена структурой, состоящей из указателя на внутренний буфер, в котором хранятся фактические данные строки, длина содержимого строки и ее емкость. Внутренний буфер всегда находится в куче.
Программа не скомпилировалась 💀💀💀!
Компиляция завершилась неудачно, так как программа пыталась заимствовать значение str1
после того, как оно было перемещено в str2
. По сути, Rust выполнил поверхностную копию str1
, скопировав его указатель, длину и емкость в str2
, а затем сделал недействительным str1
, сделав его непригодным для использования. Право собственности на данные было передано str2
.
На протяжении всего времени существования данных у них был только один владелец: сначала str1
, а затем str2
. Когда str2
вышло за пределы области видимости, Rust освободил данные один раз (и только один раз), вызвав функцию drop
. В Rust такая передача права собственности называется перемещением.
👷🏿 Чтобы исправить ошибку, удалите str1
из оператора println!
.
Аргументы функций и возвращаемые значения
Правила владения также применяются к аргументам функции и возвращаемым значениям. В следующем примере есть функция со строковым параметром:
Программа Go выводит hello
и hello world
, как и ожидалось. Go использует анализ побега, чтобы проверить, являются ли значения s
, str
и s + " world"
общими для кадров стека, чтобы определить, должны ли эти значения храниться в куче или нет.
Между тем, средство проверки заимствования Rust не смогло выполнить код, потому что оно пыталось использовать недействительную переменную str
после того, как она была перемещена в функцию append_suffix()
как s
:
👷 Чтобы исправить ошибку, удалите str
из оператора println!
.
Можно заметить, что была еще одна передача права собственности, обнаруженная в возвращаемом значении функции append_suffix()
. Когда функция завершилась, право собственности на s + " world"
перешло от функции к вызывающей стороне.
Порядок оценки имеет значение
В следующем примере Go есть функция read()
, которая возвращает строку и ее длину:
При запуске программа Go выводит ожидаемые some data
и 9
.
Однако программа Rust не скомпилировалась с той же ошибкой borrow of moved value
:
В этом случае s.len()
было оценено после того, как право собственности на s
было перемещено из функции. Следовательно, компиляция не удалась.
👷♀️Чтобы исправить ошибку, измените оператор возврата на (s.len(), s)
.
Условные операторы и циклы
Сравните следующие программы Go и Rust, которые имеют if/else
условий, использующих одну и ту же переменную:
Можно предположить, что средство проверки заимствования Rust не выполнит код Rust, потому что svc
использовалось как в if
, так и в else
условных выражениях. Интересно, что это оказалось приемлемым, потому что при запуске программы svc
перемещалось только один раз, в одном из условий:
Однако использование svc
после if/else
conditional было запрещено:
Точно так же повторное использование перемещенной переменной в последующих итерациях цикла не разрешалось:
Компилятор поймал попытку повторно использовать перемещенную переменную signal
внутри цикла:
👷🏿 Чтобы исправить ошибку, присвойте новое значение signal
перед следующей итерацией:
Повторное присвоение значений переменным каждый раз после их перемещения не очень эффективно и может привести к созданию многословного и подверженного ошибкам кода. Техника заимствования, обсуждаемая в следующем посте, позволит получателям использовать значения, не заявляя права собственности на них.
Давайте рассмотрим несколько примеров передачи прав собственности в структурах и коллекциях.
Структуры и коллекции
Структура владеет своими полями. В следующем примере структуре svc2
присваивается то же значение пространства имен, что и svc1
:
Без использования ссылок Go делает точную копию svc1
и присваивает ее svc2
.
Та же программа, написанная на Rust, выглядит так:
Rust выполняет частичное перемещение svc1
, делая его непригодным для использования после перемещения. Средство проверки заимствования не удалось код, чтобы гарантировать, что структура всегда имеет полное право собственности на свои поля.
👷 Чтобы исправить эту ошибку, вызовите метод clone()
для svc1.namespace
.
Точно так же коллекции, такие как векторы, владеют своими элементами. Перенос прав собственности на элементы из вектора не допускается.
В следующем примере показано, как получить доступ к элементу в слайсе с помощью индексации в Go:
Go делает копию svcs[0]
и назначает ее nginx
.
Та же программа, написанная на Rust, выглядит так:
Rust прервал компиляцию программы с ошибкой cannot move out of index
:
Запрещая эти перемещения путем индексирования, средство проверки заимствования гарантирует, что вектор всегда будет иметь полное право собственности на все его элементы.
👷 Чтобы исправить ошибку, используйте метод remove()
, чтобы переместить элемент за пределы вектора.
Давайте добавим цикл for
к приведенному выше примеру, чтобы изучить использование итераторов.
При переборе вектора с помощью цикла for
поведение по умолчанию преобразует вектор в итератор IntoIterator
с помощью функции into_iter()
. Этот итератор потребляет элементы, перемещая их из вектора:
После завершения цикла for
вектор svcs
больше нельзя использовать. Следовательно, программа не скомпилировалась в строке 22:
👷♀️Чтобы исправить ошибку, вызовите функцию iter()
для вектора, чтобы получить неизменяемый итератор среза.
Ого, кажется, что много работы только для выполнения простых назначений переменных 😅. Компромисс заключается в том, что, выполняя все эти проверки, программисты уверены, что их программы свободны от многих ошибок памяти, подобных описанным ранее.
В следующем посте мы рассмотрим концепцию заимствования, которая позволяет получателям использовать значения, не вступая в права собственности на них и не аннулируя первоначальных владельцев.
Заключение
Этот пост является первым из серии статей о системе владения Rust. В нем сравниваются некоторые примеры Go с Rust, чтобы продемонстрировать концепцию перемещений в системе владения Rust.
В примерах кода показано, как передача владения данными влияет на оценку назначения переменных, аргументов функций, возвращаемых значений, условий, циклов, структур и коллекций.
Используя средство проверки заимствования для обеспечения соблюдения строгого набора правил владения во время компиляции, Rust гарантирует, что все скомпилированные программы будут освобождены от ошибок памяти, которые могут быть не обнаружены в других языках программирования.
В следующем посте будет рассмотрена концепция заимствования, которую можно использовать для написания кода для безопасного обмена значениями между переменными, не аннулируя первоначальных владельцев.
Сноски
[1] Примеры кода намеренно сделаны тривиальными, чтобы читатели, знакомые с другими языками программирования, тоже могли извлечь из этого пользу.
[2] Концепция области видимости аналогична той, что определена в других языках программирования. Данные считаются действительными, когда они входят в область действия. Он становится недействительным и непригодным для использования, когда выходит за рамки.