Разработано Freepik
Принадлежность и заимствование в Rust могут сбить с толку, если мы не понимаем, что происходит на самом деле. Это особенно верно при применении ранее изученного стиля программирования к новой парадигме; мы называем это сменой парадигмы. Право собственности — это новая идея, которую сначала сложно понять, но чем больше мы работаем над ней, тем легче становится.
Прежде чем мы продолжим рассказывать о владении и заимствовании в Rust, давайте сначала разберемся, что такое «безопасность памяти» и «утечка памяти» и как с ними справляются языки программирования.
- Что такое безопасность памяти?
- Висячая ссылка
- Что такое утечка памяти?
- Небезопасность памяти в сравнении с утечками памяти
- Различные типы памяти и принцип их работы
- Стековая память и как она работает
- Куча памяти и как она работает
- Как другие языки программирования гарантируют безопасность памяти?
- Ручное или явное деаллокация памяти
- Автоматическое или неявное удаление памяти
- Как Rust гарантирует безопасность памяти?
- Владение
- Проверка займов
- Правила владения
- копия
- перемещение
- клон
- Правило владения 1
- Правило владения 2
- Правило владения 3
- Как происходит переход права собственности
- Передача значения в функцию
- Возврат из функции
- Заимствование
- Следование за указателем на значение с помощью оператора разыменования
- Ссылки неизменяемы по умолчанию
- Правила заимствования
- Ссылка не должна переживать своего владельца.
- Много неизменяемых ссылок, но разрешена только одна изменяемая ссылка
- Заключительные замечания
Что такое безопасность памяти?
Безопасность памяти — это состояние программного приложения, при котором указатели или ссылки на память всегда ссылаются на действительную память. Поскольку повреждение памяти возможно, существует очень мало гарантий относительно поведения программы, если она не является безопасной для памяти. Проще говоря, если программа не является действительно безопасной для памяти, существует мало гарантий относительно ее функциональности. При работе с программой, небезопасной для памяти, злоумышленник может использовать этот недостаток для чтения секретов или выполнения произвольного кода на чужой машине.
Разработано Freepik
Давайте с помощью псевдокода посмотрим, что такое допустимая память.
// pseudocode #1 - shows valid reference
{ // scope starts here
int x = 5
int y = &x
} // scope ends here
В приведенном выше псевдокоде мы создали переменную x
со значением 10
. Для создания ссылки мы используем оператор или ключевое слово &
. Таким образом, синтаксис &x
позволяет нам создать ссылку, которая ссылается на значение x
. Проще говоря, мы создали переменную x
, которой принадлежит 5
, и переменную y
, которая является ссылкой на x
.
Поскольку обе переменные x
и y
находятся в одном блоке или области видимости, переменная y
имеет действительную ссылку, которая ссылается на значение x
. В результате переменная y
имеет значение 5
.
Взгляните на приведенный ниже псевдокод. Как мы видим, область видимости x
ограничена блоком, в котором она создана. Мы попадаем в висячие ссылки, когда пытаемся получить доступ к x
вне его области видимости. Висячая ссылка…? Что это такое?
// pseudocode #2 - shows invalid reference aka dangling reference
{ // scope starts here
int x = 5
} // scope ends here
int y = &x // can't access x from here; creates dangling reference
Висячая ссылка
Висячая ссылка — это указатель, который указывает на участок памяти, который был передан кому-то другому или освобожден (freed). Если программа (она же процесс) обращается к памяти, которая была освобождена или стерта, это может привести к сбою или недетерминированным результатам.
При этом небезопасность памяти — это свойство некоторых языков программирования, которое позволяет программистам иметь дело с недопустимыми данными. В результате, небезопасность памяти создает целый ряд проблем, которые могут привести к следующим основным уязвимостям безопасности:
- чтение за пределами границ
- Запредельные записи
- Use-After-Free
Уязвимости, вызванные небезопасностью памяти, лежат в основе многих других серьезных угроз безопасности. К сожалению, обнаружение этих уязвимостей может оказаться чрезвычайно сложной задачей для разработчиков.
Что такое утечка памяти?
Важно понимать, что такое утечка памяти и каковы ее последствия.
Разработано Freepik
Утечка памяти — это непреднамеренная форма потребления памяти, при которой разработчик не освобождает выделенный блок памяти кучи, когда он больше не нужен. Это просто противоположность безопасности памяти. Подробнее о различных типах памяти мы расскажем позже, а пока просто знайте, что в стеке хранятся переменные фиксированной длины, известные во время компиляции, тогда как размер переменных, который может измениться позже, во время выполнения, должен быть помещен в кучу.
По сравнению с распределением памяти из кучи, распределение памяти из стека считается более безопасным, так как память автоматически освобождается, когда она больше не актуальна или не нужна, либо программистом, либо самой программой-время выполнения.
Однако, когда программисты генерируют память на куче и не удаляют ее в отсутствие сборщика мусора (в случае C и C++), возникает утечка памяти. Также, если мы теряем все ссылки на кусок памяти без деаллокации этой памяти, то мы имеем утечку памяти. Наша программа будет продолжать владеть этой памятью, но у нее нет возможности использовать ее снова.
Небольшая утечка памяти не является проблемой, но если программа выделяет больший объем памяти и никогда не деаллоцирует ее, то объем памяти программы будет продолжать расти, что приведет к отказу в обслуживании.
Когда программа завершается, операционная система немедленно восстанавливает всю принадлежащую ей память. Таким образом, утечка памяти влияет на программу только во время ее работы; после завершения программы она не имеет никакого эффекта.
Давайте рассмотрим основные последствия утечек памяти.
Утечки памяти снижают производительность компьютера за счет уменьшения объема доступной памяти (памяти кучи). В конечном итоге это приводит к тому, что вся система или ее часть перестает работать правильно или сильно замедляется. С утечками памяти обычно связаны сбои.
Наш подход к решению вопроса о том, как предотвратить утечку памяти, будет зависеть от используемого языка программирования. Утечки памяти могут начинаться как небольшая и почти «незаметная проблема», но они могут очень быстро разрастаться и перегружать системы, на которые они влияют. По возможности, мы должны быть начеку и принимать меры по их устранению, а не оставлять их расти.
Небезопасность памяти в сравнении с утечками памяти
Утечки памяти и небезопасность памяти — это два типа проблем, которым уделяется наибольшее внимание в плане предотвращения и устранения. Важно отметить, что устранение одной из них не приводит к автоматическому устранению другой.
Различные типы памяти и принцип их работы
Прежде чем двигаться дальше, важно понять различные типы памяти, которые будет использовать наш код во время выполнения.
Существует два типа памяти, как показано ниже, и эти типы памяти имеют разную структуру.
- Регистр процессора
- Статический
- Стек
- Куча
Как регистр процессора, так и статические типы памяти выходят за рамки данной статьи.
Стековая память и как она работает
Стек хранит данные в порядке их поступления и удаляет их в обратном порядке. Доступ к элементам из стека осуществляется в порядке «последний вошел — первый вышел» (LIFO). Добавление данных в стек называется «заталкиванием», а удаление данных из стека — «выталкиванием».
Все данные, хранящиеся в стеке, должны иметь известный, фиксированный размер. Данные с неизвестным размером во время компиляции или размером, который может измениться позже, должны храниться в куче.
Как разработчикам, нам не нужно беспокоиться о выделении и деаллокации стековой памяти; выделение и деаллокация стековой памяти «автоматически выполняется» компилятором. Это означает, что когда данные в стеке теряют свою актуальность (выходят из области видимости), они автоматически удаляются без нашего вмешательства.
Этот вид распределения памяти также известен как временное распределение памяти, потому что как только функция завершает свое выполнение, все данные, принадлежащие этой функции, стираются из стека «автоматически».
Все примитивные типы в Rust живут в стеке. Такие типы, как числа, символы, срезы, булевы, массивы фиксированного размера, кортежи, содержащие примитивы, и указатели функций — все они могут располагаться на стеке.
Куча памяти и как она работает
В отличие от стека, когда мы помещаем данные в кучу, мы запрашиваем определенный объем пространства. Распределитель памяти находит достаточно большое незанятое место в куче, помечает его как используемое и возвращает ссылку на адрес этого места. Это называется выделением.
Выделение в куче происходит медленнее, чем выталкивание в стек, потому что выделителю не приходится искать пустое место для размещения новых данных. Кроме того, поскольку для доступа к данным в куче мы должны следовать за указателем, это медленнее, чем доступ к данным в стеке. В отличие от стека, который выделяется и удаляется во время компиляции, память кучи выделяется и удаляется во время выполнения инструкций программы.
В некоторых языках программирования для выделения памяти кучи используется ключевое слово new
. Это ключевое слово new
(оно же оператор) обозначает запрос на выделение памяти в куче. Если на куче достаточно памяти, оператор new
инициализирует память и возвращает уникальный адрес этой вновь выделенной памяти.
Стоит отметить, что память кучи «явно» деаллоцируется программистом или временем выполнения.
Как другие языки программирования гарантируют безопасность памяти?
Когда речь идет об управлении памятью, в частности памятью кучи, мы бы предпочли, чтобы наши языки программирования обладали следующими характеристиками:
- Мы бы предпочли освобождать память как можно быстрее, когда она больше не нужна, без накладных расходов во время выполнения программы.
- Мы никогда не должны сохранять ссылки на данные, которые были освобождены (так называемые висячие ссылки). В противном случае могут возникнуть сбои и проблемы с безопасностью.
Безопасность памяти обеспечивается в языках программирования различными способами:
- Явное деаллокация памяти (принята в C, C++)
- автоматического или неявного удаления памяти (принят в Java, Python и C#)
- Управление памятью на основе регионов
- Линейные или уникальные системы типов
Управление памятью на основе регионов и линейные системы типов выходят за рамки данной статьи.
Ручное или явное деаллокация памяти
При использовании явного управления памятью программисты должны «вручную» освобождать или стирать выделенную память. Оператор «деаллокации» (например, delete
в C) существует в языках с явным деаллокацией памяти.
Сборка мусора слишком затратна в системных языках, таких как C и C++, поэтому явное выделение памяти продолжает существовать.
Возложение ответственности за освобождение памяти на программиста имеет то преимущество, что программист получает полный контроль над жизненным циклом переменной. Однако, если операторы деаллокации используются неправильно, во время выполнения может произойти программный сбой. На самом деле, этот ручной процесс выделения и освобождения памяти подвержен ошибкам. Некоторые распространенные ошибки кодирования включают:
- висячая ссылка
- Утечка памяти
Несмотря на это, мы предпочли ручное управление памятью сборке мусора, поскольку оно дает нам больше контроля и обеспечивает лучшую производительность. Заметим, что целью любого языка системного программирования является максимальная «близость к металлу». Другими словами, в этом компромиссе они отдают предпочтение лучшей производительности, а не удобным функциям.
Мы (разработчики) несем полную ответственность за то, чтобы указатель на освобожденное нами значение никогда не использовался.
В недавнем прошлом было разработано несколько проверенных шаблонов для предотвращения подобных ошибок, но все сводится к поддержанию строгой дисциплины кода, что требует последовательного применения правильного метода управления памятью.
Основные выводы:
- Иметь больший контроль над управлением памятью.
- Меньшая безопасность в результате висячих ссылок и утечек памяти.
- Приводит к увеличению времени разработки.
Автоматическое или неявное удаление памяти
Автоматическое управление памятью стало неотъемлемой чертой всех современных языков программирования, включая Java.
В случае автоматического деаллокации памяти сборщики мусора служат в качестве автоматического менеджера памяти. Эти сборщики мусора периодически просматривают кучу и утилизируют неиспользуемые куски памяти. Они управляют выделением и освобождением памяти от нашего имени. Таким образом, нам не нужно писать код для выполнения задач управления памятью. Это замечательно, поскольку сборщики мусора освобождают нас от ответственности за управление памятью. Еще одно преимущество — сокращение времени разработки.
С другой стороны, сборка мусора имеет ряд недостатков. Во время сборки мусора программа должна сделать паузу и потратить время на определение того, что ей нужно очистить, прежде чем продолжить работу.
Кроме того, автоматическое управление памятью имеет более высокие потребности в памяти. Это связано с тем, что сборщик мусора выполняет деаллокацию памяти за нас, что потребляет как память, так и циклы процессора. В результате автоматическое управление памятью может снизить производительность приложения, особенно в больших приложениях с ограниченными ресурсами.
Основные выводы:
- Устраняет необходимость для разработчиков освобождать память вручную.
- Обеспечивает эффективную безопасность памяти без висячих ссылок и утечек памяти.
- Более простой и понятный код.
- Более быстрый цикл разработки.
- Меньше контроля над управлением памятью.
- Вызывает задержки, поскольку потребляет как память, так и циклы процессора.
Как Rust гарантирует безопасность памяти?
Некоторые языки обеспечивают сборку мусора, которая ищет память, которая больше не используется во время работы программы; другие требуют от программиста явного выделения и освобождения памяти. Обе эти модели имеют свои преимущества и недостатки. Сборка мусора, хотя, возможно, наиболее широко используемая, имеет некоторые недостатки; она облегчает жизнь разработчикам за счет ресурсов и производительности.
Тем не менее, одна из них обеспечивает эффективный контроль над управлением памятью, а другая — более высокую безопасность, устраняя висячие ссылки и утечки памяти. Rust сочетает в себе преимущества обоих миров.
Rust использует иной подход к вещам, чем два других, основанный на модели владения с набором правил, которые компилятор проверяет для обеспечения безопасности памяти. Программа не будет компилироваться, если любое из этих правил нарушено. Фактически, владение заменяет сборку мусора во время выполнения программы проверкой безопасности памяти во время компиляции.
Потребуется некоторое время, чтобы привыкнуть к праву собственности, потому что это новая концепция для многих программистов, например, для меня.
Владение
На данный момент у нас есть базовое понимание того, как данные хранятся в памяти. Давайте рассмотрим владение в Rust более подробно. Самой большой отличительной особенностью Rust является владение, которое обеспечивает безопасность памяти во время компиляции.
Для начала давайте определим понятие «владение» в самом буквальном смысле. Владение — это состояние «владения» и «контроля» законного обладания «чем-то». Исходя из этого, мы должны определить, кто является владельцем и чем он владеет и управляет. В Rust у каждого значения есть переменная, называемая его владельцем. Проще говоря, переменная — это владелец, а значение переменной — это то, чем владеет и управляет владелец.
При модели владения память автоматически освобождается (freed), как только переменная, которой она принадлежит, выходит из области видимости. Когда значения выходят из области видимости или их время жизни заканчивается по какой-либо другой причине, вызываются их деструкторы. Деструктор, особенно автоматический деструктор, — это функция, которая удаляет следы значения из программы, удаляя ссылки и освобождая память.
Проверка займов
Rust реализует право собственности через проверку заимствований, статический анализатор. Проверка заимствований — это компонент компилятора Rust, который отслеживает, где данные используются в программе, и, следуя правилам владения, определяет, где данные должны быть освобождены. Кроме того, проверка заимствований гарантирует, что к деаллоцированной памяти никогда нельзя будет получить доступ во время выполнения программы. Он даже устраняет возможность возникновения гонок данных, вызванных одновременной мутацией (модификацией).
Правила владения
Как было сказано ранее, модель владения строится на наборе правил, известных как правила владения, и эти правила относительно просты. Компилятор Rust (rustc) обеспечивает выполнение этих правил:
- В Rust у каждого значения есть переменная, называемая его владельцем.
- Одновременно может быть только один владелец.
- Когда владелец выходит из области видимости, значение будет уничтожено.
Следующие ошибки памяти защищены этими правилами проверки владения во время компиляции:
- Висячие ссылки: Это когда ссылка указывает на адрес памяти, который больше не содержит данных, на которые ссылался указатель; такой указатель указывает на нулевые или случайные данные.
- Использование после освобождения: В этом случае доступ к памяти осуществляется после ее освобождения, что может привести к аварийному завершению работы. Это место в памяти также может быть использовано хакерами для выполнения кода.
- Двойное освобождение: Это когда выделенная память освобождается, а затем освобождается снова. Это может привести к аварийному завершению программы, потенциально раскрывая конфиденциальную информацию. Это также позволяет хакеру выполнить любой код по своему усмотрению.
- Ошибки сегментации: В этом случае программа пытается получить доступ к памяти, доступ к которой ей запрещен.
- Переполнение буфера: Это когда объем данных превышает емкость буфера памяти, что приводит к аварийному завершению программы.
Прежде чем перейти к деталям каждого правила владения, важно понять разницу между копированием, перемещением и клонированием.
копия
Тип с фиксированным размером (особенно примитивные типы) может храниться в стеке и выгружаться, когда его область видимости заканчивается, и может быть быстро и легко скопирован для создания новой, независимой переменной, если в другой части кода требуется то же значение в другой области видимости. Поскольку копирование стековой памяти дешево и быстро, примитивные типы с фиксированным размером, как говорят, имеют семантику копирования. Оно дешево создает идеальную копию (дубликат).
Стоит отметить, что примитивные типы с фиксированным размером реализуют признак copy для создания копий.
let x = "hello";
let y = x;
println!("{}", x) // hello
println!("{}", y) // hello
В Rust существует два вида строк:
String
(выделяется из кучи и может расти) и&str
(фиксированный размер и не может быть изменен).
Поскольку x
хранится на стеке, копировать его значение для создания другой копии для y
проще. Это не так для значения, которое хранится на куче. Вот как выглядит кадр стека:
Дублирование данных увеличивает время работы программы и потребление памяти. Поэтому копирование не подходит для больших кусков данных.
перемещение
В терминологии Rust «перемещение» означает, что право собственности на память передается другому владельцу. Рассмотрим случай сложных типов, которые хранятся на куче.
let s1 = String::from("hello");
let s2 = s1;
Можно предположить, что вторая строка (т.е. let s2 = s1;
) создаст копию значения в s1
и свяжет его с s2
. Но это не так.
Посмотрите на рисунок ниже, чтобы увидеть, что происходит с String
под капотом. Строка состоит из трех частей, которые хранятся в стеке. Фактическое содержимое (в данном случае привет) хранится в куче.
- Указатель — указывает на память, в которой хранится содержимое строки.
- Длина — сколько памяти в байтах занимает содержимое
String
в данный момент. - Объем — это общий объем памяти в байтах, который
String
получила от распределителя.
Другими словами, метаданные хранятся в стеке, а фактические данные — в куче.
Когда мы присваиваем s1
к s2
, метаданные String
копируются, то есть мы копируем указатель, длину и емкость, которые находятся в стеке. Мы не копируем данные в куче, на которые ссылается указатель. Представление данных в памяти выглядит так, как показано ниже:
Стоит отметить, что это представление не выглядит так, как показано ниже — так выглядела бы память, если бы Rust скопировал и данные кучи. Если бы Rust выполнял это, операция s2 = s1
могла бы быть чрезвычайно медленной с точки зрения производительности во время выполнения, если бы данные кучи были большими.
Обратите внимание, что когда сложные типы больше не находятся в области видимости, Rust вызывает функцию drop
для явного деаллокации памяти кучи. Однако оба указателя данных на рисунке 6 указывают на одно и то же место, а это не то, как работает Rust. В ближайшее время мы разберемся в деталях.
Как было сказано ранее, когда мы присваиваем s1
s2
, переменная s2
получает копию метаданных s1
(указатель, длина и емкость). Но что происходит с s1
после того, как она была присвоена s2
? Rust больше не считает s1
действительным. Да, вы правильно прочитали.
Давайте немного подумаем об этом присвоении let s2 = s1
. Рассмотрим, что произойдет, если Rust все еще будет считать s1
действительным после этого присваивания. Когда s2
и s1
выйдут из области видимости, они оба попытаются освободить одну и ту же память. О-о, это нехорошо. Это называется ошибкой двойного освобождения и является одной из ошибок безопасности памяти. В результате двойного освобождения памяти может произойти повреждение памяти, что создает угрозу безопасности.
Чтобы обеспечить безопасность памяти, Rust считает s1
недействительным после строки let s2 = s1
. Поэтому, когда s1
больше не находится в области видимости, Rust не нужно ничего освобождать. Рассмотрим, что произойдет, если мы попытаемся использовать s1
после создания s2
.
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1); // Won't compile. We'll get an error.
Мы получим ошибку, подобную приведенной ниже, поскольку Rust не позволяет использовать недействительную ссылку:
$ cargo run
Compiling playground v0.0.1 (/playground)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:6:28
|
3 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
4 | let s2 = s1;
| -- value moved here
5 |
6 | println!("{}, world!", s1);
| ^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0382`.
Поскольку Rust «переместил» право собственности s1
на память в s2
после строки let s2 = s1
, он посчитал s1
недействительной. Вот представление памяти после того, как s1 был признан недействительным:
Когда только s2
остается действительным, он один освобождает память, когда выходит из области видимости. В результате в Rust устранена возможность ошибки double free. Это замечательно!
клон
Если мы хотим глубоко скопировать данные кучи String
, а не только данные стека, мы можем использовать метод clone
. Вот пример использования метода clone:
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
При использовании метода clone данные из кучи действительно копируются в s2. Это прекрасно работает и приводит к следующему поведению:
Использование метода clone имеет серьезные последствия; он не только копирует данные, но и не синхронизирует изменения между ними. В общем, клонирование следует планировать тщательно и с полным осознанием последствий.
Теперь мы должны уметь различать понятия копирования, перемещения и клонирования. Давайте теперь рассмотрим каждое правило владения более подробно.
Правило владения 1
Каждое значение имеет переменную, называемую его владельцем. Это означает, что все значения принадлежат переменным. В примере ниже переменная s
владеет указателем на нашу строку, а во второй строке переменная x
владеет значением 1.
let s = String::from("Rule 1");
let n = 1;
Правило владения 2
В определенный момент времени у значения может быть только один владелец. У человека может быть много домашних животных, но когда речь идет о модели владения, в любой момент времени существует только одно значение 🙂
Разработано Freepik
Давайте рассмотрим пример с использованием примитивов, которые имеют фиксированный размер, известный во время компиляции.
let x = 10;
let y = x;
let z = x;
Мы взяли 10 и присвоили его x
; другими словами, x
владеет 10. Затем мы берем x
и присваиваем его y
, а также присваиваем его z
. Мы знаем, что в данный момент времени может быть только один владелец, но мы не получаем здесь никаких ошибок. Итак, здесь происходит то, что компилятор создает копии x
каждый раз, когда мы присваиваем ее новой переменной.
Стековый кадр для этого будет выглядеть следующим образом: x = 10
, y = 10
и z = 10
. Однако это, по-видимому, не так: x = 10
, y = x
, и z = x
. Как мы знаем, x
является единственным владельцем значения 10, и ни y
, ни z
не могут владеть этим значением.
Поскольку копирование стековой памяти дешево и быстро, примитивные типы с фиксированным размером имеют семантику копирования, тогда как сложные типы, как уже говорилось, перемещают права собственности. Таким образом, в данном случае копии делает компилятор.
На этом этапе поведение связывания переменных похоже на поведение других языков программирования. Чтобы проиллюстрировать правила владения, нам понадобится сложный тип данных.
Давайте рассмотрим данные, которые хранятся в куче, и посмотрим, как Rust понимает, когда их нужно очистить; тип String — отличный пример для этого случая использования. Мы сосредоточимся на поведении String, связанном с владением; однако эти принципы применимы и к другим сложным типам данных.
Комплексный тип, как мы знаем, управляет данными на куче, и его содержимое неизвестно во время компиляции. Давайте рассмотрим тот же пример, который мы видели ранее:
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1); // Won't compile. We'll get an error.
В случае типа
String
размер может увеличиться и храниться в куче. Это означает:
- Во время выполнения, память должна быть запрошена у распределителя памяти (назовем это первой частью).
- Когда мы закончим использовать нашу
String
, мы должны вернуть (освободить) эту память обратно в распределитель (назовем это второй частью).Мы (разработчики) позаботились о первой части: когда мы вызываем
String::from
, его реализация запрашивает необходимую ей память. Эта часть является практически общей для всех языков программирования.Однако вторая часть отличается. В языках со сборщиком мусора (GC) GC отслеживает и очищает память, которая больше не используется, и нам не нужно об этом беспокоиться. В языках без сборщика мусора наша обязанность — определить, когда память больше не нужна, и потребовать ее явного освобождения. Правильно сделать это всегда было сложной задачей программирования:
- Мы потратим память впустую, если забудем.
- Мы получим недопустимую переменную, если сделаем это слишком рано.
- Мы получим ошибку, если сделаем это дважды.
Rust обрабатывает деаллокацию памяти новым способом, чтобы облегчить нам жизнь: память автоматически возвращается, как только переменная, которой она принадлежит, выходит из области видимости.
Давайте вернемся к делу. В Rust для сложных типов такие операции, как присвоение значения переменной, передача его в функцию или возврат из функции, не копируют значение, а перемещают его. Проще говоря, сложные типы перемещают права собственности.
Когда сложные типы больше не находятся в области видимости, Rust вызывает функцию
drop
для явного деаллокации памяти кучи.
Правило владения 3
Когда владелец выходит из области видимости, значение будет сброшено. Рассмотрим предыдущий случай еще раз:
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1); // Won't compile. The value of s1 has already been dropped.
Значение s1
уменьшилось после того, как s1
было присвоено s2
(в операторе присваивания let s2 = s1
). Таким образом, s1
перестает быть действительным после этого присвоения. Вот представление памяти после отбрасывания s1:
Как происходит переход права собственности
Существует три способа передачи права собственности от одной переменной к другой в программе Rust:
- Присвоение значения одной переменной другой переменной (это уже обсуждалось).
- Передача значения в функцию.
- Возврат из функции.
Передача значения в функцию
Передача значения в функцию имеет семантику, аналогичную присвоению значения переменной. Как и присвоение, передача переменной в функцию приводит к ее перемещению или копированию. Посмотрите на этот пример, в котором показаны оба варианта использования — копирование и перемещение:
fn main() {
let s = String::from("hello"); // s comes into scope
move_ownership(s); // s's value moves into the function...
// so it's no longer valid from this
// point forward
let x = 5; // x comes into scope
makes_copy(x); // x would move into the function
// It follows copy semantics since it's
// primitive, so we use x afterward
} // Here, x goes out of scope, then s. But because s's value was moved, nothing
// special happens.
fn move_ownership(some_string: String) { // some_string comes into scope
println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called.
// The occupied memory is freed.
fn makes_copy(some_integer: i32) { // some_integer comes into scope
println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.
Если бы мы попытались использовать s
после вызова move_ownership
, Rust выдал бы ошибку компиляции.
Возврат из функции
Возвращаемые значения также могут передавать право собственности. В примере ниже показана функция, возвращающая значение, с аннотациями, идентичными аннотациям в предыдущем примере.
fn main() {
let s1 = gives_ownership(); // gives_ownership moves its return
// value into s1
let s2 = String::from("hello"); // s2 comes into scope
let s3 = takes_and_gives_back(s2); // s2 is moved into
// takes_and_gives_back, which also
// moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
// happens. s1 goes out of scope and is dropped.
fn gives_ownership() -> String { // gives_ownership will move its
// return value into the function
// that calls it
let some_string = String::from("yours"); // some_string comes into scope
some_string // some_string is returned and
// moves out to the calling
// function
}
// This function takes a String and returns it
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
// scope
a_string // a_string is returned and moves out to the calling function
}
Передача прав собственности на переменную всегда происходит по одной и той же схеме: значение перемещается, когда оно присваивается другой переменной. Если право собственности на данные не перешло к другой переменной, то когда переменная, содержащая данные на куче, выходит из области видимости, значение будет очищено командой drop
.
Надеюсь, это дает нам базовое понимание того, что такое модель владения и как она влияет на то, как Rust работает со значениями, например, присваивает их друг другу и передает их в функции и из функций.
Подождите. Еще кое-что…
Модель владения в Rust, как и все хорошее, имеет определенные недостатки. Мы быстро осознаем некоторые неудобства, как только начинаем работать с Rust. Мы могли заметить, что принимать право собственности и затем возвращать его с каждой функцией немного неудобно.
Разработано Freepik
Раздражает, что все, что мы передаем в функцию, должно быть возвращено, если мы хотим использовать это снова, в дополнение к любым другим данным, возвращаемым этой функцией. А что, если мы хотим, чтобы функция использовала значение без права собственности на него?
Рассмотрим следующий пример. Приведенный ниже код приведет к ошибке, поскольку переменная v
больше не может использоваться функцией main
(в println!
), которая первоначально владела ею, как только право собственности перейдет к функции print_vector
.
fn main() {
let v = vec![10,20,30];
print_vector(v);
println!("{}", v[0]); // this line gives us an error
}
fn print_vector(x: Vec<i32>) {
println!("Inside print_vector function {:?}",x);
}
Отслеживание владения может показаться достаточно простым, но это может стать сложным, когда мы начинаем работать с большими и сложными программами. Поэтому нам нужен способ передачи значений без передачи прав собственности, и здесь в игру вступает концепция заимствования.
Заимствование
Заимствование, в буквальном смысле, означает получение чего-либо с обещанием вернуть. В контексте Rust, заимствование — это способ получить доступ к ценности без права собственности на нее, поскольку в определенный момент она должна быть возвращена владельцу.
Разработано Freepik
Когда мы заимствуем значение, мы ссылаемся на его адрес в памяти с помощью оператора &
. Оператор &
называется ссылкой. Сами по себе ссылки не представляют ничего особенного — под капотом это просто адреса. Для тех, кто знаком с указателями в языке C, ссылка — это указатель на память, содержащий значение, принадлежащее другой переменной. Стоит отметить, что в Rust ссылка не может быть нулевой. Фактически, ссылка — это указатель; это самый основной тип указателей. В большинстве языков существует только один тип указателей, но в Rust есть различные типы указателей, а не только один. Указатели и их различные виды — это отдельная тема, которая будет обсуждаться отдельно.
Проще говоря, в Rust создание ссылки на некоторое значение называется заимствованием значения, которое в конечном итоге должно вернуться к своему владельцу.
Давайте рассмотрим простой пример ниже:
let x = 5;
let y = &x;
println!("Value y={}", y);
println!("Address of y={:p}", y);
println!("Deref of y={}", *y);
Вышеприведенный пример дает следующий результат:
Value y=5
Address of y=0x7fff6c0f131c
Deref of y=5
Здесь переменная y
заимствует число, принадлежащее переменной x
, в то время как x
по-прежнему принадлежит значение. Мы называем y
ссылкой на x
. Заимствование заканчивается, когда y
выходит из области видимости, а поскольку y
не владеет значением, оно не уничтожается. Чтобы заимствовать значение, возьмите ссылку с помощью оператора &
. Форматирование p, {:p}
вывод в виде участка памяти, представленного в шестнадцатеричном виде.
Дереференция: В приведенном выше коде символ «» (т.е. звездочка) является оператором * разыменования, который действует на ссылочную переменную. Этот оператор разыменования позволяет нам получить значение, хранящееся в памяти по адресу указателя.
Давайте рассмотрим, как функция может использовать значение без права собственности путем заимствования:
fn main() {
let v = vec![10,20,30];
print_vector(&v);
println!("{}", v[0]); // can access v here as references can't move the value
}
fn print_vector(x: &Vec<i32>) {
println!("Inside print_vector function {:?}", x);
}
Мы передаем ссылку (&v
) (она же pass-by-reference) функции print_vector
, а не передаем право собственности (т.е. pass-by-value). В результате, после вызова функции print_vector
в главной функции, мы можем получить доступ к v
.
Следование за указателем на значение с помощью оператора разыменования
Как было сказано ранее, ссылка — это разновидность указателя, а указатель можно представить как стрелку, указывающую на значение, хранящееся в другом месте. Рассмотрим следующий пример:
let x = 5;
let y = &x;
assert_eq!(5, x);
assert_eq!(5, *y);
В приведенном выше коде мы создаем ссылку на значение типа i32
, а затем используем оператор dereference для перехода по ссылке к данным. Переменная x
содержит значение типа i32
, 5
. Мы устанавливаем y
равным ссылке на x
.
Вот как выглядит стековая память:
Мы можем утверждать, что x
равно 5
. Однако, если мы хотим сделать утверждение для значения в y
, мы должны следовать за ссылкой на значение, на которое она ссылается, используя *y
(поэтому здесь используется разыменование). Как только мы разыменуем y
, мы получим доступ к целочисленному значению, на которое указывает y
, которое мы можем сравнить с 5
.
Если бы мы попытались написать assert_eq!(5, y);
вместо этого, мы бы получили эту ошибку компиляции:
error[E0277]: can't compare `{integer}` with `&{integer}`
--> src/main.rs:11:5
|
11 | assert_eq!(5, y);
| ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
Поскольку это разные типы, сравнение числа и ссылки на число недопустимо. Следовательно, мы должны использовать оператор разыменования, чтобы перейти от ссылки к значению, на которое она указывает.
Ссылки неизменяемы по умолчанию
Как и переменная, ссылка неизменяема по умолчанию — ее можно сделать изменяемой с помощью mut
, но только если ее владелец также является изменяемым:
let mut x = 5;
let y = &mut x;
Неизменяемые ссылки также известны как общие ссылки, в то время как изменяемые ссылки известны как эксклюзивные ссылки.
Рассмотрим следующий случай. Мы предоставляем ссылкам доступ только для чтения, поскольку используем оператор &
вместо &mut
. Даже если источник n
является изменяемым, ref_to_n
и another_ref_to_n
не являются таковыми, поскольку они представляют собой заимствования n только для чтения.
let mut n = 10;
let ref_to_n = &n;
let another_ref_to_n = &n;
Программа проверки заимствований выдаст следующую ошибку:
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
--> src/main.rs:4:9
|
3 | let x = 5;
| - help: consider changing this to be mutable: `mut x`
4 | let y = &mut x;
| ^^^^^^ cannot borrow as mutable
Правила заимствования
Можно задаться вопросом, почему заимствование не всегда предпочтительнее перемещения. Если это так, то почему в Rust вообще есть семантика move, и почему она не заимствует по умолчанию? Причина в том, что заимствование значения в Rust не всегда возможно. Заимствование разрешено только в определенных случаях.
Заимствование имеет свой собственный набор правил, которые строго соблюдаются программой проверки заимствований во время компиляции. Эти правила были введены для предотвращения гонок данных. Они заключаются в следующем:
- Область действия заемщика не может превышать область действия первоначального владельца.
- Может быть несколько неизменяемых ссылок, но только одна изменяемая ссылка.
- Владельцы могут иметь неизменяемые или изменяемые ссылки, но не обе одновременно.
- Все ссылки должны быть действительными (не могут быть нулевыми).
Ссылка не должна переживать своего владельца.
Область видимости ссылки должна находиться в пределах области видимости владельца значения. В противном случае ссылка может ссылаться на освобожденное значение, что приведет к ошибке use-after-free.
let x;
{
let y = 0;
x = &y;
}
println!("{}", x);
Приведенная выше программа пытается разыменовать x
после того, как владелец y
выходит из области видимости. Rust предотвращает эту ошибку use-after-free.
Много неизменяемых ссылок, но разрешена только одна изменяемая ссылка
Мы можем иметь как можно больше неизменяемых ссылок (они же общие ссылки) на определенный фрагмент данных в один момент времени, но только одна изменяемая ссылка (она же эксклюзивная ссылка) разрешена в один момент времени. Это правило существует для устранения гонок данных. Когда две ссылки одновременно указывают на один и тот же участок памяти, по крайней мере одна из них пишет, а их действия не синхронизированы, это называется гонкой данных.
Мы можем иметь сколько угодно неизменяемых ссылок, потому что они не изменяют данные. Заимствование, с другой стороны, ограничивает нас хранением только одной изменяемой ссылки (&mut
) за раз, чтобы предотвратить возможность гонок данных во время компиляции.
Давайте рассмотрим этот пример:
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}", r1, r2);
}
Приведенный выше код, который пытается создать две мутабельные ссылки (r1
и r2
) на s
, потерпит неудачу:
error[E0499]: cannot borrow `s` as mutable more than once at a time
--> src/main.rs:6:14
|
5 | let r1 = &mut s;
| ------ first mutable borrow occurs here
6 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
7 |
8 | println!("{}, {}", r1, r2);
| -- first borrow later used here
Заключительные замечания
Надеюсь, это прояснило понятия владения и заимствования. Я также вкратце коснулся проверки заимствования, основы владения и заимствования. Как я уже говорил в начале, владение — это новая идея, которую поначалу может быть трудно понять даже опытным разработчикам, но она становится все проще и проще, чем больше вы над ней работаете. Это лишь краткое описание того, как обеспечивается безопасность памяти в Rust. Я постарался сделать эту статью как можно более простой для понимания, но при этом предоставить достаточно информации для понимания концепций. Для получения более подробной информации о функции владения в Rust ознакомьтесь с их онлайн-документацией.
Rust — это отличный выбор, когда важна производительность, и он решает болевые точки, которые беспокоят многие другие языки, что приводит к значительному шагу вперед с крутой кривой обучения. Шестой год подряд Rust является самым любимым языком Stack Overflow, что означает, что многие люди, которым довелось его использовать, влюбились в него. Сообщество Rust продолжает расти.
Согласно результатам исследования Rust Survey 2021: 2021 год, несомненно, был одним из самых знаменательных в истории Rust. Он ознаменовался основанием Rust Foundation, выпуском 2021 года и большим, чем когда-либо прежде, сообществом. Похоже, что Rust уверенно движется в будущее.
Счастливого обучения!
Разработано Freepik