С каждым днем ​​я боюсь его немного меньше, а иногда мне нравится, что он существует.

Управление памятью 101

Каждой компьютерной программе требуется некоторая память для работы, и она требует управления (распределение, освобождение и т. д.). Есть два способа добиться этого, а именно. Автоматическое (Сборка мусора) и Ручное управление памятью. Суть в том, чтобы определить, когда блок памяти больше не используется, другими словами, стал мусором, а затем принять меры, чтобы сделать его пригодным для повторного использования (или освободить его).

Представьте себе общественную кухню, в которой есть тарелка для каждого свободного места. Люди могут свободно прийти, занять свободное место (и тарелку, конечно), поесть, вернуть тарелку и уйти. Что теперь с тарелкой?

Если тарелки не вымыть, на кухне они быстро закончатся, и люди больше не смогут есть, пока снаружи не принесут новые тарелки (для аналогии скажем, что это не вариант). Чтобы решить эту проблему, на кухне можно либо назначить кого-то, кто будет мыть тарелки, когда люди кладут их в раковину, либо попросить людей мыть свои собственные тарелки.

Это упрощение, но сборщик мусора подобен первому, а второй — ручному управлению памятью. Основное отличие состоит в том, что требуется агент, который заботится об очистке памяти.

Хорошо, так какой из них лучше?

Как-то сложно ответить. Оба метода имеют свои преимущества и свои недостатки, в зависимости от наших потребностей и ресурсов, которыми мы располагаем. В конце концов, мы пытаемся добиться того, что называется безопасностью памяти, о чем я говорил в своей предыдущей статье.

Сборщик мусора (далее мы будем называть его GC) упрощает работу, и программисту не нужно беспокоиться об управлении ресурсами. Это надежно, но требует некоторых дополнительных ресурсов для работы, что в конечном итоге приведет к снижению производительности.

Ручное управление памятью не влечет за собой таких накладных расходов и дает программисту значительный контроль, но при этом необходимо проявлять особую осторожность, чтобы избежать создания условий, которые приводят к небезопасности памяти.

В таких языках, как C/C++, у нас есть два типа выделения памяти, а именно статическое выделение памяти, где требуемый размер памяти известен во время компиляции. В эту категорию попадают примитивные типы, такие как int, char и т. д. Они создаются при объявлении и уничтожаются, когда выходят за рамки.

Другой тип — динамическое выделение памяти,когда память выделяется во время выполнения. Одним из примеров является структура, которую необходимо выделять и освобождать вручную с помощью таких вызовов, как malloc и free.

Проблема с динамическим выделением памяти заключается в том, что, в отличие от статически выделенных значений, память не освобождается, когда значения выходят за пределы области видимости, и программист должен позаботиться об их освобождении, когда они больше не нужны. Но также нет ограничений на то, когда значение может быть освобождено, и нет проверки, используется ли это значение где-то еще.

Если мы не будем осторожны, мы можем сделать то, что называется двойным освобождением, по сути, вызывая системный вызов free() для одной и той же области памяти более одного раза. А может вообще не освобождать память. Оба они добавляют к проблемам с безопасностью памяти. Есть еще несколько причин memory-unsafety, чуть подробнее о них можно прочитать здесь.

Войдите в средство проверки заимствования

Это было все для начинающих, и теперь мы знаем об опасностях, связанных с отсутствием безопасности памяти. Фактически, в 2019 году Microsoft отметила, что колоссальные 70% уязвимостей безопасности связаны с небезопасным для памяти кодом.

Чтобы справиться с этим и обеспечить безопасность памяти во время компиляции, создателям Rust пришлось придумать что-то новое. Среди прочего, основным компонентом языка является то, что мы теперь знаем как Проверка заимствования.

Это механизм, который делает Rust, Rust. С одной задачей, а именно убедиться, что все ссылки ВСЕГДА действительны, и нет оборванных указателей. Он часто будет жаловаться на то, что вы делаете что-то, что ему не нравится, потому что позже это может привести к проблемам с памятью.

В этой статье мы поговорим только о проверке заимствования, но вы также можете прочитать о трех основных столпах управления памятью в Rust: Владение, Ссылка и заимствование и Время жизни.

Правила (не) предназначены для того, чтобы их нарушать

Средство проверки заимствования — это совершенно другая парадигма по сравнению с управлением памятью. Частично ответственен за то, почему люди поначалу находят Rust трудным/раздражающим (по крайней мере, я). Он отличается как от сборщика мусора, так и от ручного управления памятью, поскольку не требует среды выполнения для очистки мусора и не просит вас освободить память, когда вы закончите.

Borrow Checker работает по двум фундаментальным правилам, которые поначалу несколько неинтуитивны, что заставляет многих людей бороться с ними. Но как только вы хорошо их поймете, вам (возможно) никогда не придется этого делать, и вы найдете это действительно полезным.

В комментарии пользователя Reddit dnkndnts есть хорошая аналогия:

[Проверка заимствований] работает по нескольким простым правилам. Если вы не понимаете или, по крайней мере, не понимаете, что это за правила, то это будет так же полезно, как использование программы проверки орфографии, которая поможет вам писать на языке, которого вы даже не знаете: она просто отклонит все, что вы говорите.

Как только вы узнаете правила, на которых основана проверка заимствования, вы найдете ее полезной, а не угнетающей и раздражающей, как проверка орфографии.

Эти правила, как описано в официальных документах, следующие:

  1. Никакая ссылка на ресурс (ячейка памяти или переменная) не может существовать, если ресурс больше не находится в области действия.
  2. У нас может быть либо одна или несколько неизменяемых (только для чтения) ссылок на ресурс, либо ровно одна изменяемая ссылка, но не обе.

Какими бы простыми они ни казались, они очень эффективны для предотвращения гонки данных и обеспечения гарантий безопасности. Они также заставляют нас думать с точки зрения продолжительности жизни. Взгляните на фрагмент кода ниже.

l̶e̶t̶ ̶m̶u̶t̶ ̶x̶ ̶=̶ ̶5̶;̶

̶l̶e̶t̶ ̶y̶ ̶=̶ ̶&̶m̶u̶t̶ ̶x̶;̶     // -+ &mut borrow of x starts here
                    //  |
̶*̶y̶ ̶+̶=̶ ̶1̶;̶            //  |
                    //  |
̶p̶r̶i̶n̶t̶l̶n̶!̶(̶"̶{̶}̶"̶,̶ ̶x̶)̶;̶  // -+ - try to borrow x here
                    // -+ &mut borrow of x ends here

Изменить: приведенный выше код успешно компилируется, как правильно заметил Ари Огоке. И, честно говоря, я взял его прямо из официальной документации Rust. По моему опыту, Rust будет жаловаться на такой код, но я могу только заключить, что компилятор в последнее время стал умнее и может определить, закончился ли самый последний изменяемый доступ, и разрешены ли неизменяемые заимствования после эта точка.

Rustc 1.25 по-прежнему будет жаловаться на это. В любом случае, следующий блок кода все еще демонстрирует это в последней версии Rust (1.67.1 на момент написания статьи). И я действительно думаю, что Rust Docs должен выделить это изменение.

let mut x = 5;     // 1
let y = &mut x;    // 2
                   // 3
println!("{}", x); // 4
*y += 1;           // 5

Получаем следующую ошибку.

// let y = &mut x;
//        ------ mutable borrow occurs here
//
// println!("{}", x);
//           ^ immutable borrow occurs here
// *y += 1;
// ------- mutable borrow later used here

Конфликт областей видимости: мы не можем сделать &x, пока y находится в области видимости. Но мы можем внести небольшое изменение, чтобы заставить эту работу работать. Обратите внимание, что в зависимости от того, хотим ли мы получить значение x до или после обновления, мы можем переместить println перед строкой 2 или после строки 5. Я поместил его в обе строки. места.

let mut x = 5;
println!("{}", x); // prints 5
let y = &mut x;

*y += 1;
println!("{}", x); // prints 6

Это работает, потому что к тому времени, когда мы попробуем &x, последнее изменяемое заимствование уже закончилось.

Rust также проверяет, чтобы мы не указывали на ресурс, который уже вышел из области видимости, не оставляя висячих ссылок.

let y: &i32;
{
    let x = 5;
    y = &x;
}

println!("{}", y);

Фрагмент кода выше выдает следующую ошибку. Поскольку y объявляется перед x, это означает, что y живет дольше, чем x, что недопустимо.

error[E0597]: `x` does not live long enough
 --> src/main.rs:5:9
  |
5 |     y = &x;
  |         ^^ borrowed value does not live long enough
6 | }
  | - `x` dropped here while still borrowed
7 |
8 | println!("{}", y);
  |                - borrow later used here

Вот хорошая тема Reddit о том, как не бороться с Borrow Checker, иди посмотри. Но для начала все, что вам нужно сделать, это прочитать отзывы компилятора и следовать им, что обычно несложно. В любом случае, по крайней мере во время изучения Rust, вы можете использовать clone() всякий раз, когда сталкиваетесь с проблемами заимствования/времени жизни, и постепенно уменьшать его по мере того, как вы становитесь лучше.

Выводы

Ржавчина, как хороший заботливый родитель, поначалу покажется вам суровой. Вы ненавидите это, вы находите эти правила и положения раздражающими и даже слишком, но годы спустя вы понимаете, что все это было для вашего же блага.

Когда дело доходит до Rust, я не взрослый, возможно, ранний подросток, но я достаточно скоро понял, как он улучшил мою практику кодирования в целом. И в настоящее время я вроде как привык к достоинствам Borrow Checker и Rust в целом. Вы должны попробовать, чтобы поверить в это.

А пока удачного кодирования!