Предыдущая заметка получилась не такая, как я задумывал. Но вызвала небольшую дискуссию. Может быть и в этот раз получится подискутировать. Или получится не так. В любом случае хотелось бы продолжить воровать тексты у богатых и переводить их бедным, т.е. делиться с общественностью пусть даже иногда для кого-то очевидными вещами. «Поговорим за» динамическую память? Отчасти Rust интересен из-за ряда непопулярных решений в дизайне языка, которые позволяют достичь такого же, а иногда и лучшего результата, чем в других языках. Хорошим примером является управление памятью, а именно управление динамической памятью. Управление памятью может осуществляться двумя способами — явно или автоматически.
Явное управление памятью поддерживается в языках системного программирования, таких как C и C++. При явном управлении памятью программист обладает большой свободой. Но с большой властью приходит большая ответственность. При необходимости память может быть динамически выделена в куче и должна быть освобождена, когда в ней больше нет необходимости. При этом важное значение имеет время освобождения памяти — слишком раннее освобождение может привести к использованию недействительных ссылок, позднее освобождение приводит к потере памяти. Программист также должен быть осторожен, чтобы не освободить память более одного раза. Если эти требования не выполняются, могут возникнуть следующие ошибки.
1. Утечка памяти
Утечка памяти (memory leak) происходит в случае, когда память освобождается слишком поздно (или не освобождается вовсе). Ниже приведён пример, в котором память вообще не освобождается. Оператор new выделяет память в куче и переменная p указывает на адрес массива. Память массива должна быть освобождена, когда массив больше не нужен, но в коде примера этого не происходит. В конечном итоге память, конечно, освобождается, так как ОС очищает ресурсы после завершения программы. Проблема становится более очевидной в случае, если код многократно повторяется в долго работающей программе или на устройстве с ограниченной памятью.
#include <iostream>
using namespace std;
int main()
{
cout << "Before allocate" << endl;
int * p = new int[10];
// ... other program logic
cout << "After allocate" << endl;
// p is not deleted
}
2. Недействительный указатель
Недействительный указатель (dangling pointer) — это ссылка на память, которая была освобождена. В приведенном ниже фрагменте функция returns_pointer() возвращает указатель на локальную переменную c. Так как c является выделенной на стеке локальной переменной, то, в соответствии с идиомой RAII, её память освобождается, когда переменная выходит из области видимости при завершении функции. Поэтому возвращаемый указатель является недействительным и его использование может вызвать сбой. Недействительные указатели также могут приводить к непредсказуемому поведению и создавать лазейки в безопасности. Эти ошибки обычно сложно отлаживать и исправлять.
#include <iostream>
using namespace std;
char *returns_pointer()
{
char c = 'a';
return &c;
}
int main()
{
char *cp = returns_pointer();
cout << "Result: " << *cp << endl;
}
3. Повреждение памяти вследствие двойного освобождения
Подобно недействительным указателям, двойное освобождение (double free) может причинить вред из-за использования недопустимых ссылок. В приведенном ниже примере показано, как это может привести к непредсказуемому результату. Поскольку память для B выделяется вскоре после освобождения памяти того же размера из-под A, переменной B назначается тот же адрес, что ранее назначался переменной A. Вторая попытка освобождения A не имеет последствий, поскольку она фактически освобождает память B. Программа завершается аварийно при освобождении B, так как память уже была освобождена. Отладка такой ошибки может быть сложной.
#include <iostream>
using namespace std;
int main()
{
int *A = new int[10];
cout << "After delete A: " << A << endl;
delete A;
int *B = new int[10];
delete A;
cout << "After second delete A: " << A << endl;
delete B;
cout << "After delete B: " << B << endl;
}
Обнаружить подобные ошибки может быть очень трудно даже при должной осмотрительности, обзорах кода и бесчисленных тестах. Это приводит к необходимости автоматического управления памятью.
Автоматическое управление памятью позволяет абстрагироваться при работе с памятью, предоставляя программисту возможность сосредоточиться на логике приложения. В большинстве языков программирования это достигается при помощи сборщика мусора. Во время своей работы сборщик мусора приостанавливает выполнение программы для очистки объектов, которые больше не используются. Это устраняет ошибки, продемонстрированные выше, но ценой накладных расходов. Наиболее значительными затратами является производительность, так как выполнение программы периодически приостанавливается. Более того, очистка не является детерминированной и предоставляет меньше возможностей для настройки деструкторов. Такой подход допустим для высокоуровневых языков, таких как C# и Java.
Беспроигрышная ситуация это золотая середина между безопасностью и функциональностью. Rust выполняет автоматическое управление памятью без сборщика мусора через владение. Для этого используются простые, но мощные принципы владения:
Все объекты динамической памяти (в куче) принадлежат только одной переменной-владельцу.
Когда переменная выходит из области видимости, объект удаляется.
Когда объект присваивается другой переменной, происходит передача (перемещение) права собственности.
Следующий фрагмент кода иллюстрирует эти принципы:
fn main()
{
let s1 = String::from("hello");
let s2 = s1;
// println!("s1 is: {}, s2 is: {}", s1, s2);
let s3 = s2.clone();
println!("s2 is: {}, s3 is: {}", s2, s3);
print_if_not_empty(s3);
// println!("is s3 still valid? {}",s3);
let s4 = returns_string();
println!("Is s4 valid? {}", s4);
}
fn print_if_not_empty(s : String)
{
if ! s.is_empty()
{
println!("String s: {}", s)
}
}
fn returns_string() -> String
{
String::from("hello world!")
}
Этот код работает правильно, но не будет компилироваться, если убрать символы комментариев со с трок с операторами печати. И вот почему:
Когда s1 присваивается s2, значение s1 фактически перемещается в s2. Переменная s2 указывает на адрес динамической памяти s1, и s1 больше не является допустимой переменной. Обращение к s1, как в первом аргументе оператора печати, приводит к ошибке компиляции.
Создание s3 демонстрирует создание полной (глубокой) копии без необходимости передачи владения. Переменная s3 является клоном s2 и располагается в другой области памяти. Право собственности не передаётся, поэтому и s2, и s3 будут действительными. В большинстве языков программирования глубокие копии делаются по умолчанию при присваивании, что может быть затратно для больших объектов. Передача владения по умолчанию вместо глубокой копии не приводит к снижению производительности.
Аналогично присваиванию, владение передаётся при передаче объекта в функцию. Когда s3 передается в print_if_not_empty(), его значение перемещается в локальную переменную s, когда s выходит из области действия в конце функции, память освобождается. Как и следовало ожидать, попытка печати s3 после этого приводит к ошибке компиляции. Если после вызова функции требуется s3, существуют альтернативы, которые мы сейчас не стали рассматривать.
Те же правила применяются при возврате значений из функций. При возврате значения передаётся владение из области действия локальной функции. Поэтому память не освобождается в конце функции. В приведённом выше примере последовательность «hello world!» перемещается в s4.
В конце программы будут удалены только s2 и s4, т.к. s1, s3 и s были перемещены. С гарантией только одного владельца, все значения детерминированно очищаются, как только они больше не нужны. Такой подход даёт ещё одно преимущество — выявления ошибок во время компиляции.
При первом знакомстве с принципами владения, они показались мне избыточными (мне же, в отличие от автора оригинальной заметки, так не показалось). Если функция вызывает перемещение объектов, то кодировать в Rust становится сложнее. Может показаться странным странным, что в примере для управления памятью не используются указатели. Вместо сырых указателей в Rust используются ссылки, но это тема для отдельной заметки. В Rust также есть и умные указатели, о которых тоже стоит сказать отдельно. В общем становится ясно, что Rust старается быть воплощением безопасности, производительности и выявлять ошибки при каждом удобном случае. А вы что думаете по сказанному выше?
Другие заметки цикла:
С лёгким налётом ржавчины или немного о владении