🧠 Управление памятью в Rust.
Часть 3.4. Stack. Итоги.
Теперь, когда мы детально рассмотрели принцип работы стека, можно резюмировать его преимущества и ограничения.
Преимущества стека:
🟢 Работа с памятью стека происходит очень быстро, т.к.:
- память выделяется программе при старте, в процессе работы со стеком нет дорогостоящих вызовов к функциям ОС.
- нет фрагментации памяти, нет необходимости в поиске свободного места для записи новых переменных. Запись происходит всегда по адресу, указывающему на вершину стека.
- для освобождения память нужно всего лишь передвинуть указатель вершины стека на адрес начала стек фрейма.
🟢 Нет риска утечки памяти, т.к. освобождение происходит автоматически при завершении выполнения функции без участия разработчика или каких-либо других процессов.
🟢 Работа с данными в стеке потокобезопасна, т.к. у каждого потока есть свой стек.
🟢 Компактное расположение данных в стеке и сам принцип работы этой структуры данных с постоянной очисткой и переиспользованием памяти позволяет в сумме потреблять меньше памяти.
Ограничения стека:
🟠 Размер всего стека фиксирован и должен быть определён на этапе компиляции программы.
🟠 Размер переменных, помещаемых в стек, должен быть фиксированным и известным на этапе компиляции программы.
Нельзя помещать в стек динамические структуры данных, размер которых может варьироваться во время работы приложения.
🟠 После завершения функции нет возможности получить доступ к её данным, так как её стек фрейм "удаляется".
Глядя на эти пункты, можно сделать вывод, что стек хорош для всего, кроме задач, когда:
⚠️ нужно много памяти
⚠️ нужно сохранять данные, размер которых меняется динамически в процессе выполнения программы
⚠️ нужно предоставлять разным функциям доступ к одной и той же переменной
И действительно, Rust обычно старается сохранить переменные в стек всегда, когда может это сделать.
А для решения задач, где стек не может помочь, используется другая область памяти – Heap.
О ней мы поговорим в следующей части.
#rust #rustlang #RustMemoryManagement
Часть 3.4. Stack. Итоги.
Теперь, когда мы детально рассмотрели принцип работы стека, можно резюмировать его преимущества и ограничения.
Преимущества стека:
🟢 Работа с памятью стека происходит очень быстро, т.к.:
- память выделяется программе при старте, в процессе работы со стеком нет дорогостоящих вызовов к функциям ОС.
- нет фрагментации памяти, нет необходимости в поиске свободного места для записи новых переменных. Запись происходит всегда по адресу, указывающему на вершину стека.
- для освобождения память нужно всего лишь передвинуть указатель вершины стека на адрес начала стек фрейма.
🟢 Нет риска утечки памяти, т.к. освобождение происходит автоматически при завершении выполнения функции без участия разработчика или каких-либо других процессов.
🟢 Работа с данными в стеке потокобезопасна, т.к. у каждого потока есть свой стек.
🟢 Компактное расположение данных в стеке и сам принцип работы этой структуры данных с постоянной очисткой и переиспользованием памяти позволяет в сумме потреблять меньше памяти.
Ограничения стека:
🟠 Размер всего стека фиксирован и должен быть определён на этапе компиляции программы.
🟠 Размер переменных, помещаемых в стек, должен быть фиксированным и известным на этапе компиляции программы.
Нельзя помещать в стек динамические структуры данных, размер которых может варьироваться во время работы приложения.
🟠 После завершения функции нет возможности получить доступ к её данным, так как её стек фрейм "удаляется".
Глядя на эти пункты, можно сделать вывод, что стек хорош для всего, кроме задач, когда:
⚠️ нужно много памяти
⚠️ нужно сохранять данные, размер которых меняется динамически в процессе выполнения программы
⚠️ нужно предоставлять разным функциям доступ к одной и той же переменной
И действительно, Rust обычно старается сохранить переменные в стек всегда, когда может это сделать.
А для решения задач, где стек не может помочь, используется другая область памяти – Heap.
О ней мы поговорим в следующей части.
#rust #rustlang #RustMemoryManagement
🧠 Управление памятью в Rust.
Часть 4.1. Heap.
Давайте сразу рассмотрим пример:
Здесь мы создаём динамический массив для элементов типа i32 и заполняем его нулями до тех пор, пока текущее системное время в секундах не будет делиться на 2 без остатка.
В зависимости от времени запуска в конце вектор может быть как пустым, так и содержащим несколько миллионов нулей.
Получается, что программа не сможет определить сколько места нужно выделить под переменную в момент её объявления. При этом само добавление элемента происходит в другой функции, когда в стек сверху легла переменная
Всё это делает невозможным использование стека для такого рода задач.
Поэтому возникла необходимость в ещё одном сегменте памяти, который устроен иначе – Heap.
☁️ Heap (хип) – программная область памяти, используемая для динамической аллокации и деаллокации памяти под переменные.
В отличие от стека, хип никак не связан с одноимённой структурой данных.
При сохранении переменной в Heap приложение запросит необходимое место у операционной системы. Но, как я писал ранее, вызовы функций ОС для работы с памятью довольно дорогостоящие, поэтому обычно ОС возвращает сразу большой кусок памяти, выделенный нашей программе.
Такой кусок называется страницей (memory page) и обычно равен 4 КБ.
Затем программа сохранит данные переменной в какую-то часть этой страницы, а в стек положит адрес на ячейку памяти в Heap с этими данными. Таким образом функция будет работать с этой переменной на стеке, как и с другими переменными, но сами данные читать или записывать по адресу, ведущему на другое место.
Далее по мере сохранения других переменных в Heap, программа сначала будет искать свободное место в уже выделенной ей странице памяти, и только в случае нехватки там места запрашивать у ОС новую страницу.
Освобождение памяти тоже сначала происходит только внутри программы: какие-то переменные удаляются, какие-то могут добавляться на их место. И лишь когда вся страница памяти становится пустой, программа запросит освобождение этой страницы у ОС.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
Часть 4.1. Heap.
Давайте сразу рассмотрим пример:
use std::time::{SystemTime, UNIX_EPOCH};
fn time_is_odd() -> bool {
let now = SystemTime::now();
let seconds = now.duration_since(UNIX_EPOCH).unwrap().as_secs();
seconds % 2 != 0
}
fn fill(v: &mut Vec<i32>) {
let element = 0;
while time_is_odd() {
v.push(element);
}
}
fn main() {
let mut v: Vec<i32> = Vec::new();
fill(&mut v);
println!("vec len = {}", v.len());
}
Здесь мы создаём динамический массив для элементов типа i32 и заполняем его нулями до тех пор, пока текущее системное время в секундах не будет делиться на 2 без остатка.
В зависимости от времени запуска в конце вектор может быть как пустым, так и содержащим несколько миллионов нулей.
Получается, что программа не сможет определить сколько места нужно выделить под переменную в момент её объявления. При этом само добавление элемента происходит в другой функции, когда в стек сверху легла переменная
element
, а значит новые данные не могут быть добавлены просто в конец стека. Всё это делает невозможным использование стека для такого рода задач.
Поэтому возникла необходимость в ещё одном сегменте памяти, который устроен иначе – Heap.
☁️ Heap (хип) – программная область памяти, используемая для динамической аллокации и деаллокации памяти под переменные.
В отличие от стека, хип никак не связан с одноимённой структурой данных.
При сохранении переменной в Heap приложение запросит необходимое место у операционной системы. Но, как я писал ранее, вызовы функций ОС для работы с памятью довольно дорогостоящие, поэтому обычно ОС возвращает сразу большой кусок памяти, выделенный нашей программе.
Такой кусок называется страницей (memory page) и обычно равен 4 КБ.
Затем программа сохранит данные переменной в какую-то часть этой страницы, а в стек положит адрес на ячейку памяти в Heap с этими данными. Таким образом функция будет работать с этой переменной на стеке, как и с другими переменными, но сами данные читать или записывать по адресу, ведущему на другое место.
Далее по мере сохранения других переменных в Heap, программа сначала будет искать свободное место в уже выделенной ей странице памяти, и только в случае нехватки там места запрашивать у ОС новую страницу.
Освобождение памяти тоже сначала происходит только внутри программы: какие-то переменные удаляются, какие-то могут добавляться на их место. И лишь когда вся страница памяти становится пустой, программа запросит освобождение этой страницы у ОС.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
👍1
🧠 Управление памятью в Rust.
Часть 4.2. Heap. Продолжение.
❓ В какой момент происходит освобождение памяти в Heap?
Так как в стек программа кладёт не саму переменную, а адрес на неё, то и при удалении стек фрейма удаляется только адрес, в то время как данные продолжают занимать место в Heap.
В языках с ручным управлением памятью (C, C++) задача освобождения памяти в хипе возложена на разработчика.
Но разработчик может забыть вызвать функцию освобождения памяти, что приведёт к утечки памяти – ситуации, когда программа уже не использует какой-то кусок памяти, а он занят и не может быть переиспользован под другие переменные.
При этом отсутствие автоматической очистки памяти в Heap после завершения работы функции даёт определённое преимущество – данные можно использовать в других функциях, если в них каким-то образом передать адрес переменной.
Таким образом можно организовать совместную работу функций с одной и той же переменной или передачу больших данных из функции в функцию без необходимости их копирования.
А значит использование Heap – это не только что-то вынужденное, когда нет возможности использовать Stack из-за его ограничений, но и порой что-то полезное и необходимое, позволяющее решить определённые задачи.
Что касается контроля за очисткой данных в Heap для предотвращения рисков утечки памяти, как раз для этого Rust и вводит концепции владения, заимствования и времени жизни переменных, к детальному рассмотрению которых я перейду уже в следующих частях этой серии постов.
А пока резюмирую плюсы и минусы Heap.
Преимущества Heap:
🟢 Можно сохранять переменные, размер которых динамически меняется или определяется в процессе работы программы.
🟢 Подходит для переменных, которые дорого передавать из функции в функцию с помощью копирования (например, большие массивы).
🟢 Сохраненные в Heap переменные не привязаны к стек фрейму функции, поэтому могут быть использованы другими функциями и после завершения текущей.
Недостатки Heap:
🟠 Работа с переменными в Heap обходится программе немного дороже, чем с переменными в Stack, так как происходят периодические вызовы функций ОС для работы с памятью, а также требуется более сложный алгоритм поиска подходящего места для сохранения переменной.
🟠 Heap подвержен фрагментации памяти, когда в одном сегменте образуются пустоты, не используемые другими переменными. А значит Heap потребляет чуть больше памяти, чем мог бы при более компактном хранении данных.
🟠 Отсутствие автоматической очистки памяти ведёт к риску утечки памяти и требует дополнительных механизмов контроля за переменными.
Как видно, оба сегмента памяти обладают своими преимуществами и недостатками. Какие-то переменные могут быть сохранены только в Stack, какие-то – только в Heap.
Но есть ряд задач, когда разработчик может сам решить в какую область памяти ему лучше разместить ту или иную переменную для максимальной эффективности и быстродействия.
Именно поэтому понимание этих двух концепций так важно для языка Rust, который предоставляет разработчику все необходимые инструменты для низкоуровневой работы с памятью программы.
#rust #rustlang #RustMemoryManagement
Часть 4.2. Heap. Продолжение.
❓ В какой момент происходит освобождение памяти в Heap?
Так как в стек программа кладёт не саму переменную, а адрес на неё, то и при удалении стек фрейма удаляется только адрес, в то время как данные продолжают занимать место в Heap.
В языках с ручным управлением памятью (C, C++) задача освобождения памяти в хипе возложена на разработчика.
Но разработчик может забыть вызвать функцию освобождения памяти, что приведёт к утечки памяти – ситуации, когда программа уже не использует какой-то кусок памяти, а он занят и не может быть переиспользован под другие переменные.
При этом отсутствие автоматической очистки памяти в Heap после завершения работы функции даёт определённое преимущество – данные можно использовать в других функциях, если в них каким-то образом передать адрес переменной.
Таким образом можно организовать совместную работу функций с одной и той же переменной или передачу больших данных из функции в функцию без необходимости их копирования.
А значит использование Heap – это не только что-то вынужденное, когда нет возможности использовать Stack из-за его ограничений, но и порой что-то полезное и необходимое, позволяющее решить определённые задачи.
Что касается контроля за очисткой данных в Heap для предотвращения рисков утечки памяти, как раз для этого Rust и вводит концепции владения, заимствования и времени жизни переменных, к детальному рассмотрению которых я перейду уже в следующих частях этой серии постов.
А пока резюмирую плюсы и минусы Heap.
Преимущества Heap:
🟢 Можно сохранять переменные, размер которых динамически меняется или определяется в процессе работы программы.
🟢 Подходит для переменных, которые дорого передавать из функции в функцию с помощью копирования (например, большие массивы).
🟢 Сохраненные в Heap переменные не привязаны к стек фрейму функции, поэтому могут быть использованы другими функциями и после завершения текущей.
Недостатки Heap:
🟠 Работа с переменными в Heap обходится программе немного дороже, чем с переменными в Stack, так как происходят периодические вызовы функций ОС для работы с памятью, а также требуется более сложный алгоритм поиска подходящего места для сохранения переменной.
🟠 Heap подвержен фрагментации памяти, когда в одном сегменте образуются пустоты, не используемые другими переменными. А значит Heap потребляет чуть больше памяти, чем мог бы при более компактном хранении данных.
🟠 Отсутствие автоматической очистки памяти ведёт к риску утечки памяти и требует дополнительных механизмов контроля за переменными.
Как видно, оба сегмента памяти обладают своими преимуществами и недостатками. Какие-то переменные могут быть сохранены только в Stack, какие-то – только в Heap.
Но есть ряд задач, когда разработчик может сам решить в какую область памяти ему лучше разместить ту или иную переменную для максимальной эффективности и быстродействия.
Именно поэтому понимание этих двух концепций так важно для языка Rust, который предоставляет разработчику все необходимые инструменты для низкоуровневой работы с памятью программы.
#rust #rustlang #RustMemoryManagement
👍2
🧠 Управление памятью в Rust.
Часть 5.1. Ownership.
В предыдущей части серии #RustMemoryManagement мы остановились на том, что данные, аллоцированные в Heap, требуют дополнительных механизмов по контролю и очистке памяти, в отличие от данных на стеке, которые очищаются автоматически.
И первой такой концепцией в Rust является концепция владения (ownership), которая вводит три правила:
1️⃣ У любых данных должен быть владелец – какая-то переменная, ссылающаяся на эти данные.
2️⃣ В один момент времени у каждого значения может быть только один владелец.
3️⃣ Когда переменная, владеющая данными, покидает область видимости, выделенная ей память, очищается.
Ещё раз акцентирую внимание на различие между переменной и данными, сохранёнными в этой переменной:
В этом примере сами значения – 1, 2, 3 – лежат в выделенной под динамический массив области памяти в Heap.
А переменная vector физически содержит в себе ссылку на эту область памяти и сохраняется в стеке.
Согласно концепции ownership, переменная vector как раз и является владельцем для значений массива. И как только переменная покинет область видимости, будет очищена не только память на стеке, выделенная самой переменной для хранения ссылки, но и память в Heap, которая была выделена под значения 1,2,3.
Таким образом Rust как бы унифицирует работу с переменными на стеке и на хипе, очищая память сразу, как только она становится ненужной.
В следующем примере я создам две структуры:
🔸 StackData, которая будет состоять из одного элемента u32, а значит может храниться на стеке.
🔸 HeapData, которая будет содержать String, а значит храниться в Heap (т.к. String в Rust – это по сути динамический массив из UTF-8 символов).
И ещё я реализую для этих структур трейт Drop, который вызывается перед тем, как память переменной будет очищена.
Запустив такой пример, мы увидим, что данные освобождаются после того, как переменные покидают свою область видимости, при этом освобождаются в обратном порядке, т.к. вытаскиваются из стека.
Переменные названы в том порядке, в котором они будут освобождены:
При этом привязка очистки памяти к скоупу переменной автоматически решает потенциальную проблему обращения к уже очищенной памяти по ошибке:
компилятор просто не даст обратиться к переменной вне области её видимости.
---
Итак, с 1м и 3м правилом концепции ownership разобрались, теперь повнимательнее посмотрим на второе правило.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
Часть 5.1. Ownership.
В предыдущей части серии #RustMemoryManagement мы остановились на том, что данные, аллоцированные в Heap, требуют дополнительных механизмов по контролю и очистке памяти, в отличие от данных на стеке, которые очищаются автоматически.
И первой такой концепцией в Rust является концепция владения (ownership), которая вводит три правила:
1️⃣ У любых данных должен быть владелец – какая-то переменная, ссылающаяся на эти данные.
2️⃣ В один момент времени у каждого значения может быть только один владелец.
3️⃣ Когда переменная, владеющая данными, покидает область видимости, выделенная ей память, очищается.
Ещё раз акцентирую внимание на различие между переменной и данными, сохранёнными в этой переменной:
let vector = vec![1, 2, 3];
В этом примере сами значения – 1, 2, 3 – лежат в выделенной под динамический массив области памяти в Heap.
А переменная vector физически содержит в себе ссылку на эту область памяти и сохраняется в стеке.
Согласно концепции ownership, переменная vector как раз и является владельцем для значений массива. И как только переменная покинет область видимости, будет очищена не только память на стеке, выделенная самой переменной для хранения ссылки, но и память в Heap, которая была выделена под значения 1,2,3.
Таким образом Rust как бы унифицирует работу с переменными на стеке и на хипе, очищая память сразу, как только она становится ненужной.
В следующем примере я создам две структуры:
🔸 StackData, которая будет состоять из одного элемента u32, а значит может храниться на стеке.
🔸 HeapData, которая будет содержать String, а значит храниться в Heap (т.к. String в Rust – это по сути динамический массив из UTF-8 символов).
И ещё я реализую для этих структур трейт Drop, который вызывается перед тем, как память переменной будет очищена.
struct StackData(u32);
struct HeapData(String);
impl Drop for StackData {
fn drop(&mut self) {
println!("dropping {}", self.0)
}
}
impl Drop for HeapData {
fn drop(&mut self) {
println!("dropping {}", self.0)
}
}
pub fn main() {
let v6 = StackData(123);
{
let v2 = StackData(234);
let v1 = HeapData("cde".to_string());
}
for i in 100..101 {
let v4 = StackData(i);
let v3 = HeapData((i + 10).to_string());
}
let v5 = HeapData("abcd".to_string());
}
Запустив такой пример, мы увидим, что данные освобождаются после того, как переменные покидают свою область видимости, при этом освобождаются в обратном порядке, т.к. вытаскиваются из стека.
Переменные названы в том порядке, в котором они будут освобождены:
dropping cde <-- v1
dropping 234 <-- v2
dropping 110 <-- v3
dropping 100 <-- v4
dropping abcd <-- v5
dropping 123 <-- v6
При этом привязка очистки памяти к скоупу переменной автоматически решает потенциальную проблему обращения к уже очищенной памяти по ошибке:
компилятор просто не даст обратиться к переменной вне области её видимости.
---
Итак, с 1м и 3м правилом концепции ownership разобрались, теперь повнимательнее посмотрим на второе правило.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
👍1
🧠 Управление памятью в Rust.
Часть 5.2. Ownership. Единственность владельца.
Второе правило концепции ownership говорит о том, что в один момент времени у данных должен быть только один владелец.
Что это означает и зачем нужно такое правило?
Взглянем на такой пример:
Если бы Rust скопировал ссылку из s1 в s2, то в коде образовались бы две переменные, ссылающиеся на один и тот же фрагмент памяти в Heap (два владельца).
Это плохо по множеству причин.
Например, при завершении функции сначала была бы очищена память по ссылке из s2, а затем повторилась бы попытка очистки той же памяти по ссылке из s1.
Или если s2 была бы определена во вложенном скоупе, тогда в конце этого скоупа была бы очищена память по ссылке из s2, а s1 продолжила бы ссылаться на уже очищенную память:
Такая же проблема возникла бы при передаче s1 в другую функцию: при завершении функции данные по ссылке из аргумента были бы зачищены, а s1 стала бы ссылаться на очищенную память.
Именно для разрешения подобных ситуаций и существует второе правило ownership: владелец должен быть только один.
❗️ При операции присваивания одной переменной другой или передаче переменной в другую функцию происходит смена владельца или операция move.
Это значит, что владельцем данных становится другая переменная, а предыдущий владелец становится недоступным для использования.
При попытке обращения к старому владельцу данных компилятор Rust выдаст ошибку:
Объясняя концепцию move, частенько приводят пример с бумажной книгой:
В один момент времени книга принадлежит кому-то одному.
Я могу передать книгу другому человеку, но тогда не смогу читать её сам.
Чтобы пользоваться книгой, мне нужно либо скопировать её и передать другому копию, либо попросить вернуть её после прочтения:
При всех этих манипуляциях (кроме явного вызова clone()) сами данные ("abcd") как легли в Heap в самом начале, так и продолжают лежать по тому же адресу до конца программы. А при смене владельца из переменной в переменную переходит только ссылка на эту область памяти.
По началу такое поведение кажется очень непривычным и неудобным. Вроде как даже есть статистика, которая говорит, что попытка использования перемещённых (moved) переменных – это самая распространённая ошибка при компиляции Rust программ.
Но со временем мышление немного перестраивается, и в большинстве случаев следование этому правилу не вызывает какие-то дополнительные сложности.
Конечно, часто возникают задачи, когда нужно совместно использовать данные в других функциях без смены владельца.
И для этого Rust вводит следующую концепцию – заимствование (borrowing).
Но о ней мы поговорим чуть позже.
А пока рассмотрим ещё несколько примеров, связанных с владением.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
Часть 5.2. Ownership. Единственность владельца.
Второе правило концепции ownership говорит о том, что в один момент времени у данных должен быть только один владелец.
Что это означает и зачем нужно такое правило?
Взглянем на такой пример:
let s1 = String::from("abcd");
let s2 = s1;
Если бы Rust скопировал ссылку из s1 в s2, то в коде образовались бы две переменные, ссылающиеся на один и тот же фрагмент памяти в Heap (два владельца).
Это плохо по множеству причин.
Например, при завершении функции сначала была бы очищена память по ссылке из s2, а затем повторилась бы попытка очистки той же памяти по ссылке из s1.
Или если s2 была бы определена во вложенном скоупе, тогда в конце этого скоупа была бы очищена память по ссылке из s2, а s1 продолжила бы ссылаться на уже очищенную память:
let s1 = String::from("abcd");
{
let s2 = s1;
} // очистка памяти по ссылке из s2
s1 - ???
Такая же проблема возникла бы при передаче s1 в другую функцию: при завершении функции данные по ссылке из аргумента были бы зачищены, а s1 стала бы ссылаться на очищенную память.
Именно для разрешения подобных ситуаций и существует второе правило ownership: владелец должен быть только один.
❗️ При операции присваивания одной переменной другой или передаче переменной в другую функцию происходит смена владельца или операция move.
Это значит, что владельцем данных становится другая переменная, а предыдущий владелец становится недоступным для использования.
При попытке обращения к старому владельцу данных компилятор Rust выдаст ошибку:
fn main() {
let s1 = String::from("abcd");
// владельцем значения "abcd" становится s2
let s2 = s1;
// println!("{s1}"); // s1 больше нельзя использовать, будет ошибка компиляции
print_string(s2); // владение строкой передаётся в функцию
// println!("{s2}"); // s2 больше нельзя использовать, будет ошибка компиляции
}
fn print_string(str: String) {
println!("{str}");
} // очищается память по ссылке из str
Объясняя концепцию move, частенько приводят пример с бумажной книгой:
В один момент времени книга принадлежит кому-то одному.
Я могу передать книгу другому человеку, но тогда не смогу читать её сам.
Чтобы пользоваться книгой, мне нужно либо скопировать её и передать другому копию, либо попросить вернуть её после прочтения:
fn print(str: String) {
println!("{str}");
}
fn print_and_return(str: String) -> String {
println!("{str}");
str
}
fn main() {
let s1 = String::from("abcd");
// создаём новый объект и отдаём владением над ним в функцию
print(s1.clone());
// можно продолжать использовать s1, т.к. владелец не менялся
let s2 = print_and_return(s1);
// теперь нельзя использовать s1, но этот же объект вернулся в s2, можно использовать её
println!("{s2}");
}
При всех этих манипуляциях (кроме явного вызова clone()) сами данные ("abcd") как легли в Heap в самом начале, так и продолжают лежать по тому же адресу до конца программы. А при смене владельца из переменной в переменную переходит только ссылка на эту область памяти.
По началу такое поведение кажется очень непривычным и неудобным. Вроде как даже есть статистика, которая говорит, что попытка использования перемещённых (moved) переменных – это самая распространённая ошибка при компиляции Rust программ.
Но со временем мышление немного перестраивается, и в большинстве случаев следование этому правилу не вызывает какие-то дополнительные сложности.
Конечно, часто возникают задачи, когда нужно совместно использовать данные в других функциях без смены владельца.
И для этого Rust вводит следующую концепцию – заимствование (borrowing).
Но о ней мы поговорим чуть позже.
А пока рассмотрим ещё несколько примеров, связанных с владением.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
🧠 Управление памятью в Rust.
Часть 5.3. Ownership. Ещё примеры.
Давайте посмотрим как работает передача владения применительно к полям структур:
⁉️ Скомпилируется ли такой код?
Нет, Rust будет ругаться на строку
Выражением
И несмотря на то, что сама структура Person не меняла владельца, один из атрибутов ей больше не принадлежит (как бы странно это ни звучало). А значит и передать эту структуру в другую функцию мы уже не можем.
Рассмотрим ещё пример:
Здесь я присваиваю переменной num2 переменную num, затем меняю num2, но в консоль пытаюсь вывести num.
⁉️ Скомпилируется ли такой код?
Да, код скомпилируется, на экран будет выведено
Почему?
Не забываем, что изначально концепция владения нужна для контроля за очисткой памяти, что актуально только для данных в Heap.
А примитивы (и некоторые другие типы) хранятся на стеке и по умолчанию копируются как при передаче между функциями, так и при присваивании другим переменным.
И все эти данные будут зачищены при завершении функции (или выходе из скоупа). А значит для них нет необходимости для отслеживания владельца и контроля за единственностью этого владельца.
Чтобы явно указать на то, какие данные будут скопированы, а какие – перемещены, в Rust есть трейт-маркер Copy.
Все типы, у которых есть этот маркер, при присваивании другим переменным будут скопированы. Если у типа маркера Copy нет, значит переменная будет перемещена.
Для всех примитивов и различных других типов, сохраняемых на стеке, в Rust прописан трейт Copy. Полный список можно посмотреть в документации к этому типажу.
При этом мы можем сами "повесить" этот трейт на свою структуру через атрибут derive, если все поля структуры также помечены Copy:
Про сам трейт Copy я чуть подробнее расскажу в одном из постов серии #RustStandardTraits, а про ownership у меня всё.
#rust #rustlang #RustMemoryManagement
Часть 5.3. Ownership. Ещё примеры.
Давайте посмотрим как работает передача владения применительно к полям структур:
#[derive(Debug)]
struct Person {
name: String,
last_name: String,
age: u8,
}
fn print_person(p: Person) {
println!("{p:?}")
}
fn main {
let p = Person {
name: "Илья".to_string(),
last_name: "Муромец".to_string(),
age: 33,
};
let name = p.name;
println!("Print person with name: {name}");
print_person(p);
}
⁉️ Скомпилируется ли такой код?
Нет, Rust будет ругаться на строку
print_person(p)
из-за попытки сменить владельца у данных, которые уже частично перемещены (partially moved).Выражением
name = p.name
мы передали владение строки "Илья", а значит дальше не можем использовать p.name
.И несмотря на то, что сама структура Person не меняла владельца, один из атрибутов ей больше не принадлежит (как бы странно это ни звучало). А значит и передать эту структуру в другую функцию мы уже не можем.
Рассмотрим ещё пример:
fn main() {
let num = 1;
let mut num2 = num;
num2 = num2 + 1;
println!("Number: {num}");
}
Здесь я присваиваю переменной num2 переменную num, затем меняю num2, но в консоль пытаюсь вывести num.
⁉️ Скомпилируется ли такой код?
Да, код скомпилируется, на экран будет выведено
Number: 1
. То есть выражение num2 = num
копирует значение, а не передаёт владение.Почему?
Не забываем, что изначально концепция владения нужна для контроля за очисткой памяти, что актуально только для данных в Heap.
А примитивы (и некоторые другие типы) хранятся на стеке и по умолчанию копируются как при передаче между функциями, так и при присваивании другим переменным.
И все эти данные будут зачищены при завершении функции (или выходе из скоупа). А значит для них нет необходимости для отслеживания владельца и контроля за единственностью этого владельца.
Чтобы явно указать на то, какие данные будут скопированы, а какие – перемещены, в Rust есть трейт-маркер Copy.
Все типы, у которых есть этот маркер, при присваивании другим переменным будут скопированы. Если у типа маркера Copy нет, значит переменная будет перемещена.
Для всех примитивов и различных других типов, сохраняемых на стеке, в Rust прописан трейт Copy. Полный список можно посмотреть в документации к этому типажу.
При этом мы можем сами "повесить" этот трейт на свою структуру через атрибут derive, если все поля структуры также помечены Copy:
#[derive(Debug, Clone, Copy)]
struct StackData(u32);
fn main() {
let var1 = StackData(0);
let var2 = var1; // копирование
// Выведет var1: StackData(0), var2: StackData(0)
println!("var1: {var1:?}, var2: {var2:?}");
}
Про сам трейт Copy я чуть подробнее расскажу в одном из постов серии #RustStandardTraits, а про ownership у меня всё.
#rust #rustlang #RustMemoryManagement
👍1
🧠 Управление памятью в Rust.
Часть 6.1. Borrowing.
Прежде чем перейти к концепции заимствования (borrowing), кратенько освежим в памяти ключевую информацию из предыдущих частей:
✔️ Данные в программе могут сохраняться в Stack или Heap.
✔️ Всё, что лежит в стеке, автоматически очищается после завершения функции благодаря алгоритму работы самого стека.
Но, благодаря этому же алгоритму, мы не можем сохранять в стек данные с динамическим размером и не можем использовать одни и те же данные в разных функциях.
✔️ Heap позволяет решать задачи, для которых не подходит Stack. При этом мы лишаемся автоматической очистки памяти.
Rust привносит несколько концепций, призванных решить эту задачу, т.е. тоже сделать очистку памяти в Heap без участия разработчика.
✔️ Первая концепция – ownership.
Она вводит понятия владельца данных – это переменная, которая ссылается на данные в Heap.
Когда переменная-владелец покидает область видимости, память, на которую она ссылалась, очищается.
✔️ Для корректной работы такого подхода у каждых данных должен быть только один владелец в один момент времени.
Если мы присваиваем одну переменную другой или передаём переменную в другую функцию, то владение над данными тоже переходит к новой переменной или к аргументу функции (операция move), а переменную ранее владевшую этими данными больше использовать нельзя.
Таким образом Rust унифицирует подход к очистке данных для переменных в Stack и Heap: после выхода переменной из скоупа память освобождается.
Но проблема с невозможностью совместного использования данных осталась нерешённой.
Вдобавок добавилось неудобство: если мы передаём переменную в функцию и после этого хотим продолжить использование этой переменной, то вызванная функция должна в явном виде вернуть эти данные – передать владение обратно:
Частично решить эти проблемы призвана вторая концепция управления памяти в Rust – заимствование (borrowing).
Вместо передачи переменной, что приводит в операции move, можно у переменной взять ссылку (операция borrow) и уже её передать в функцию.
Заимствуя данные (т.е. получая ссылку на переменные), владелец у этих данных не меняется, а значит та же самая переменная может быть использована и дальше:
Как видите, мы можем передавать сколько угодно ссылок в различные функции и использовать их сколько угодно раз, в том числе копируя в другие переменные.
И в целом код сал больше похож на то, что мы привыкли видеть в других языках программирования, при этом память очищается сама.
Но вряд ли простое получение ссылки на переменную стали бы называть "концепция заимствования".
Потому что всё-таки есть один подводный камень.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
Часть 6.1. Borrowing.
Прежде чем перейти к концепции заимствования (borrowing), кратенько освежим в памяти ключевую информацию из предыдущих частей:
✔️ Данные в программе могут сохраняться в Stack или Heap.
✔️ Всё, что лежит в стеке, автоматически очищается после завершения функции благодаря алгоритму работы самого стека.
Но, благодаря этому же алгоритму, мы не можем сохранять в стек данные с динамическим размером и не можем использовать одни и те же данные в разных функциях.
✔️ Heap позволяет решать задачи, для которых не подходит Stack. При этом мы лишаемся автоматической очистки памяти.
Rust привносит несколько концепций, призванных решить эту задачу, т.е. тоже сделать очистку памяти в Heap без участия разработчика.
✔️ Первая концепция – ownership.
Она вводит понятия владельца данных – это переменная, которая ссылается на данные в Heap.
Когда переменная-владелец покидает область видимости, память, на которую она ссылалась, очищается.
✔️ Для корректной работы такого подхода у каждых данных должен быть только один владелец в один момент времени.
Если мы присваиваем одну переменную другой или передаём переменную в другую функцию, то владение над данными тоже переходит к новой переменной или к аргументу функции (операция move), а переменную ранее владевшую этими данными больше использовать нельзя.
Таким образом Rust унифицирует подход к очистке данных для переменных в Stack и Heap: после выхода переменной из скоупа память освобождается.
Но проблема с невозможностью совместного использования данных осталась нерешённой.
Вдобавок добавилось неудобство: если мы передаём переменную в функцию и после этого хотим продолжить использование этой переменной, то вызванная функция должна в явном виде вернуть эти данные – передать владение обратно:
fn main() {
let v = vec![1, 2, 3];
let v = print_vector(v);
// продолжаем использовать v
}
fn print_vector(v: Vec<i32>) -> Vec<i32> {
println!("{v:?}");
v
}
Частично решить эти проблемы призвана вторая концепция управления памяти в Rust – заимствование (borrowing).
Вместо передачи переменной, что приводит в операции move, можно у переменной взять ссылку (операция borrow) и уже её передать в функцию.
Заимствуя данные (т.е. получая ссылку на переменные), владелец у этих данных не меняется, а значит та же самая переменная может быть использована и дальше:
fn main() {
let v = vec![1, 2, 3];
print_vector(&v);
print_vector_twice(&v);
// продолжаем использовать v
}
fn print_vector(v: &Vec<i32>) {
println!("{v:?}");
}
fn print_vector_twice(v: &Vec<i32>) {
println!("{v:?}");
let v2 = v;
println!("{v2:?}");
}
Как видите, мы можем передавать сколько угодно ссылок в различные функции и использовать их сколько угодно раз, в том числе копируя в другие переменные.
И в целом код сал больше похож на то, что мы привыкли видеть в других языках программирования, при этом память очищается сама.
Но вряд ли простое получение ссылки на переменную стали бы называть "концепция заимствования".
Потому что всё-таки есть один подводный камень.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
🧠 Управление памятью в Rust.
Часть 6.2. Borrowing. Продолжение.
В прошлом примере, передавая ссылки на вектор в различные функции, мы использовали их только для чтения данных.
Если же мы хотим изменять данные по ссылке, должны указывать ключевое
И именно с изменяемыми ссылками связан риск некорректной работы с данными.
Один из примеров – это гонка данных (data race).
Передав в один поток ссылку для чтения, а в другой – ссылку для изменения, мы можем получить ситуацию, когда изменяемые в текущий момент времени данные кем-то в этот же момент читаются.
Если не позаботиться о синхронизации этих операций, то будет неопределённое поведение, чего Rust всячески пытается избегать.
Но проблемы могут быть и в рамках одного потока:
В этом примере мы пытаемся вывести в консоль данные, которые уже удалены.
К счастью, такой код не будет скомпилирован.
Во избежание подобных проблем Rust вводит правило в рамках концепции borrowing:
❗️ В один момент времени может существовать либо сколько угодно ссылок на чтение данных, либо только одна ссылка на изменение этих данных.
Так как метод
Но если честно, мне не очень нравится это определение, так как нужно дополнительно объяснять что такое "один момент времени".
Возьмём этот же пример и немного его модифицируем:
Скомпилируется ли такой код?
Да.
При выполнении программы будет паника с ошибкой:
Получается, что в рамках одной функции у нас существуют обе ссылки: на чтение и на запись, что противоречит правилу.
Дальше приходится объяснять, что так как после удаления элемента из вектора, ссылка на
Но я хотел бы предложить другое определение, которое может быть не совсем точно отражает суть того, что именно проверяет Rust, но, на мой взгляд, более понятно с точки зрения того, когда и как можно использовать различные ссылки.
Определение звучит так:
‼️ Как только мы получаем или используем mutable ссылку на переменную, все до этого полученные ссылки становятся невалидными для использования.
Если в последней строке поменять f3 на f2 или f1, то будет ошибка компиляции. Но в текущем виде всё будет работать, так как после определения f3 не было никаких получений или использований mut ссылок на вектор.
На этом про borrowing всё.
Как показывает практика, понимания ownership и borrowing достаточно, чтобы полноценно решать подавляющее количество задач.
Но остался ещё небольшой ряд проблем при работе с памятью, для решения которых потребовалась третья концепция – Lifetimes. О ней мы поговорим в следующих частях.
#rust #rustlang #RustMemoryManagement
Часть 6.2. Borrowing. Продолжение.
В прошлом примере, передавая ссылки на вектор в различные функции, мы использовали их только для чтения данных.
Если же мы хотим изменять данные по ссылке, должны указывать ключевое
mut
. При этом для получения мутабельной ссылки на переменную, сама переменная тоже должна быть объявлена как mutable:
fn main() {
let mut v = vec![1, 2, 3];
add_zero(&mut v);
println!("{v:?}");
}
fn add_zero(v: &mut Vec<i32>) {
v.push(0);
}
И именно с изменяемыми ссылками связан риск некорректной работы с данными.
Один из примеров – это гонка данных (data race).
Передав в один поток ссылку для чтения, а в другой – ссылку для изменения, мы можем получить ситуацию, когда изменяемые в текущий момент времени данные кем-то в этот же момент читаются.
Если не позаботиться о синхронизации этих операций, то будет неопределённое поведение, чего Rust всячески пытается избегать.
Но проблемы могут быть и в рамках одного потока:
fn main() {
let mut v = vec![1];
let first = &v[0]; // ссылка на первый элемент в массиве
v.remove(0); // удаляем первый элемент массива
println!("{first:?} "); // ???
}
В этом примере мы пытаемся вывести в консоль данные, которые уже удалены.
К счастью, такой код не будет скомпилирован.
Во избежание подобных проблем Rust вводит правило в рамках концепции borrowing:
❗️ В один момент времени может существовать либо сколько угодно ссылок на чтение данных, либо только одна ссылка на изменение этих данных.
Так как метод
remove
вызывается через mutable ссылку на vector v, то срабатывает ограничение: нельзя одновременно иметь и ссылку на vector для записи и ссылку на первый элемент этого же вектора для чтения.Но если честно, мне не очень нравится это определение, так как нужно дополнительно объяснять что такое "один момент времени".
Возьмём этот же пример и немного его модифицируем:
fn main() {
let mut v = vec![1];
let first = &v[0];
v.remove(0);
let first = &v[0]; // <--- добавил только эту строку
println!("{first:?} ");
}
Скомпилируется ли такой код?
Да.
При выполнении программы будет паника с ошибкой:
index out of bounds: the len is 0 but the index is 0
, но код компилируется.Получается, что в рамках одной функции у нас существуют обе ссылки: на чтение и на запись, что противоречит правилу.
Дальше приходится объяснять, что так как после удаления элемента из вектора, ссылка на
v
больше не используется, её область действия как бы закончилась, а значит можно дальше опять иметь сколько угодно ссылок на чтение.Но я хотел бы предложить другое определение, которое может быть не совсем точно отражает суть того, что именно проверяет Rust, но, на мой взгляд, более понятно с точки зрения того, когда и как можно использовать различные ссылки.
Определение звучит так:
‼️ Как только мы получаем или используем mutable ссылку на переменную, все до этого полученные ссылки становятся невалидными для использования.
pub fn main() {
let mut v = vec![1, 2];
let f1 = &v[0]; // ссылка на чтение
v.remove(0); // используем &mut v, значит f1 стала невалидна
let f2 = &v[0]; // но можно брать новые ссылки
v.push(1); // теперь и f2 невалидна
let f3 = &v[0];
println!("{f3:?}");
}
Если в последней строке поменять f3 на f2 или f1, то будет ошибка компиляции. Но в текущем виде всё будет работать, так как после определения f3 не было никаких получений или использований mut ссылок на вектор.
На этом про borrowing всё.
Как показывает практика, понимания ownership и borrowing достаточно, чтобы полноценно решать подавляющее количество задач.
Но остался ещё небольшой ряд проблем при работе с памятью, для решения которых потребовалась третья концепция – Lifetimes. О ней мы поговорим в следующих частях.
#rust #rustlang #RustMemoryManagement
🧠 Управление памятью в Rust.
Часть 7.1. Lifetimes.
И снова быстренько вспомним логическую цепочку, объясняющую необходимость появления концепций управления памятью в Rust:
✔️ Для данных, сохранённых в Heap, нужен какой-то механизм освобождения памяти.
✔️ Таким механизмом стала концепция Ownership, которая назначает ответственного за очистку данных.
✔️ Решив проблему с освобождением памяти, мы получили большое неудобство в связи с необходимостью передавать владение над данными из функции в функцию и обратно.
✔️ Этого можно избежать, передавая в функции не сами переменные, а ссылки на них.
✔️ И снова решив одну проблему, мы получили другую: нужно каким-то образом защититься от "висячих" указателей (dangling pointers) – ссылок, ведущих на уже удалённые данные.
✔️ Частично от этого спасает правило концепции borrowing, позволяющее иметь только одну изменяемую (mutable) ссылку.
Благодаря этому мы не можем сохранить ссылку на какие-то данные, затем, например, удалить их, а затем снова попытаться прочитать их по ранее сохранённой ссылке.
✔️ Но в общем за контролем над такими ситуациями ответственна концепция Lifetime.
Lifetime (время жизни) – это характеристика ссылок, отвечающая за то, сколько "живут" ссылки, т.е. когда их можно использовать, а когда они уже невалидны.
Помимо этой характеристики, под термином lifetime ещё подразумевают параметры, с помощью которых разработчик может указывать взаимосвязь между разными ссылками.
Об этих параметрах я поговорю чуть позже.
А пока остановлюсь только на самой характеристике времени жизни.
Документация Rust (и другие обучающие материалы, основывающиеся на ней) в какой-то момент от разбора времени жизни ссылок переходит ко времени жизни переменных, чем, на мой взгляд, может запутать.
Возьмём стандартный пример:
С точки зрения того, как устроен компилятор Rust, действительно можно рассуждать о времени жизни переменной r, о времени жизни переменной x, о сравнении этих времён жизни и так далее.
Но всё-таки это уже описание механизма, контролирующего использование ссылок.
А с точки зрения понимания сути происходящего, важно, что Rust не даёт нам по ссылке r обратиться к данным переменной x, так как они уже были очищены из-за выхода x из своего скоупа видимости.
Поэтому, пытаясь понять, можно ли использовать ссылку в определённом месте в коде или нет, обратите внимание на сами данные: могли ли они быть уже удалены или нет.
– Если переменная вышла из своего скоупа видимости, то ссылку на неё использовать нельзя, так как память уже освобождена.
– Если владелец данных сменился, то ссылку на старого владельца использовать нельзя, так как данные могли быть очищены новым владельцем.
– Если была использована другая мутабельная ссылка на данные, то нашу ссылку тоже уже использовать нельзя, так как по другой ссылке данные могли быть уже удалены.
То есть по большей части характеристика времени жизни – это не что-то новое, а скорее объединение всех ранее рассмотренных правил.
Но помимо самой характеристики, есть ещё lifetime параметры, про которые теперь и поговорим.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
Часть 7.1. Lifetimes.
И снова быстренько вспомним логическую цепочку, объясняющую необходимость появления концепций управления памятью в Rust:
✔️ Для данных, сохранённых в Heap, нужен какой-то механизм освобождения памяти.
✔️ Таким механизмом стала концепция Ownership, которая назначает ответственного за очистку данных.
✔️ Решив проблему с освобождением памяти, мы получили большое неудобство в связи с необходимостью передавать владение над данными из функции в функцию и обратно.
✔️ Этого можно избежать, передавая в функции не сами переменные, а ссылки на них.
✔️ И снова решив одну проблему, мы получили другую: нужно каким-то образом защититься от "висячих" указателей (dangling pointers) – ссылок, ведущих на уже удалённые данные.
✔️ Частично от этого спасает правило концепции borrowing, позволяющее иметь только одну изменяемую (mutable) ссылку.
Благодаря этому мы не можем сохранить ссылку на какие-то данные, затем, например, удалить их, а затем снова попытаться прочитать их по ранее сохранённой ссылке.
✔️ Но в общем за контролем над такими ситуациями ответственна концепция Lifetime.
Lifetime (время жизни) – это характеристика ссылок, отвечающая за то, сколько "живут" ссылки, т.е. когда их можно использовать, а когда они уже невалидны.
Помимо этой характеристики, под термином lifetime ещё подразумевают параметры, с помощью которых разработчик может указывать взаимосвязь между разными ссылками.
Об этих параметрах я поговорю чуть позже.
А пока остановлюсь только на самой характеристике времени жизни.
Документация Rust (и другие обучающие материалы, основывающиеся на ней) в какой-то момент от разбора времени жизни ссылок переходит ко времени жизни переменных, чем, на мой взгляд, может запутать.
Возьмём стандартный пример:
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {}", r);
}
С точки зрения того, как устроен компилятор Rust, действительно можно рассуждать о времени жизни переменной r, о времени жизни переменной x, о сравнении этих времён жизни и так далее.
Но всё-таки это уже описание механизма, контролирующего использование ссылок.
А с точки зрения понимания сути происходящего, важно, что Rust не даёт нам по ссылке r обратиться к данным переменной x, так как они уже были очищены из-за выхода x из своего скоупа видимости.
Поэтому, пытаясь понять, можно ли использовать ссылку в определённом месте в коде или нет, обратите внимание на сами данные: могли ли они быть уже удалены или нет.
– Если переменная вышла из своего скоупа видимости, то ссылку на неё использовать нельзя, так как память уже освобождена.
– Если владелец данных сменился, то ссылку на старого владельца использовать нельзя, так как данные могли быть очищены новым владельцем.
– Если была использована другая мутабельная ссылка на данные, то нашу ссылку тоже уже использовать нельзя, так как по другой ссылке данные могли быть уже удалены.
То есть по большей части характеристика времени жизни – это не что-то новое, а скорее объединение всех ранее рассмотренных правил.
Но помимо самой характеристики, есть ещё lifetime параметры, про которые теперь и поговорим.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
🧠 Управление памятью в Rust.
Часть 7.2. Lifetime параметры.
Снова возьмём классический пример из документации:
Здесь мы принимаем на вход функции ссылки на две строки и возвращаем ссылку на самую длинную из них.
Согласно концепции Lifetimes Rust должен не допустить ситуации, когда ссылка будет жить дольше, чем данные, на которые она ссылается.
В зависимости от кода, который будет вызывать функцию longest, можно понять какое время жизни у переменных x и y.
Но строка – динамическая структура данных, её длина может меняться в рантайме, а значит в момент компиляции Rust не может определить время жизни возвращаемой ссылки.
Поэтому Rust просит разработчика в явном виде указать: время жизни какой переменной на входе соответствует времени жизни возвращаемого значения.
Разработчик, как и компилятор, тоже не может предсказать, какая именно переменная окажется длиннее, поэтому привязать возвращаемое значение к кому-то одному нельзя.
Но можно задать ограничение: время жизни переданных в функцию ссылок должно быть одинаковым.
С таким ограничением время жизни возвращаемой ссылки становится однозначным: такое же, как у любой из входящих переменных.
Для обозначения такой связи как раз и используются lifetime параметры, которые являются разновидностью generic параметров:
Здесь мы определяем перечень используемых времён жизни в функции longest (в данном случае только один – 'a), и подставляем параметр 'a ко всем ссылкам в сигнатуре функции, таким образом устанавливая связь между ними.
Обозначения могут быть разными, но принято использовать буквы с начала алфавита: a, b, c, ...
И вот здесь важный момент!
Хоть выше я и написал, что мы задаём ограничение, чтобы время жизни входящих ссылок было одинаковым, правильнее трактовать это всё-таки как связь между ссылками, которую можно сформулировать так:
❗️ функция longest возвращает ссылку на строку, которая живёт столько же, сколько живут обе входящие ссылки.
То есть lifetime x и y может быть разным, но lifetime возвращаемой ссылки будет равен минимальному значению lifetime x или y, то есть времени, когда живы обе ссылки.
Отсюда получаем:
Похожим образом параметры lifetime применимы и к структурам данных.
Например, мы можем в программе проинициализировать структуру с конфигурацией и структуру с логикой работы с БД, а затем передать их в App, где будет реализована бизнес логика приложения с использованием обеих структур:
На этом можно было бы остановиться, но для упрощения синтаксиса Rust ввёл некоторые исключения, когда lifetime параметры можно опустить.
Давайте посмотрим на эти исключения и наконец-то подведём итоги.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
Часть 7.2. Lifetime параметры.
Снова возьмём классический пример из документации:
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
Здесь мы принимаем на вход функции ссылки на две строки и возвращаем ссылку на самую длинную из них.
Согласно концепции Lifetimes Rust должен не допустить ситуации, когда ссылка будет жить дольше, чем данные, на которые она ссылается.
В зависимости от кода, который будет вызывать функцию longest, можно понять какое время жизни у переменных x и y.
Но строка – динамическая структура данных, её длина может меняться в рантайме, а значит в момент компиляции Rust не может определить время жизни возвращаемой ссылки.
Поэтому Rust просит разработчика в явном виде указать: время жизни какой переменной на входе соответствует времени жизни возвращаемого значения.
Разработчик, как и компилятор, тоже не может предсказать, какая именно переменная окажется длиннее, поэтому привязать возвращаемое значение к кому-то одному нельзя.
Но можно задать ограничение: время жизни переданных в функцию ссылок должно быть одинаковым.
С таким ограничением время жизни возвращаемой ссылки становится однозначным: такое же, как у любой из входящих переменных.
Для обозначения такой связи как раз и используются lifetime параметры, которые являются разновидностью generic параметров:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
Здесь мы определяем перечень используемых времён жизни в функции longest (в данном случае только один – 'a), и подставляем параметр 'a ко всем ссылкам в сигнатуре функции, таким образом устанавливая связь между ними.
Обозначения могут быть разными, но принято использовать буквы с начала алфавита: a, b, c, ...
И вот здесь важный момент!
Хоть выше я и написал, что мы задаём ограничение, чтобы время жизни входящих ссылок было одинаковым, правильнее трактовать это всё-таки как связь между ссылками, которую можно сформулировать так:
❗️ функция longest возвращает ссылку на строку, которая живёт столько же, сколько живут обе входящие ссылки.
То есть lifetime x и y может быть разным, но lifetime возвращаемой ссылки будет равен минимальному значению lifetime x или y, то есть времени, когда живы обе ссылки.
Отсюда получаем:
// такой код скомпилируется
let s1 = String::from("long string");
{
let s2 = String::from("abcd");
let result = longest(&s1, &s2);
println!("The longest string is {}", result);
}
// а такой – нет
let result;
let s1 = String::from("long string");
{
let s2 = String::from("abcd");
result = longest(&s1, &s2);
}
println!("The longest string is {}", result);
Похожим образом параметры lifetime применимы и к структурам данных.
Например, мы можем в программе проинициализировать структуру с конфигурацией и структуру с логикой работы с БД, а затем передать их в App, где будет реализована бизнес логика приложения с использованием обеих структур:
struct Config {}
struct DbService {}
// задаём общий lifetime 'a
struct App<'a> {
cfg: &'a Config,
db: &'a DbService,
}
fn main() {
let cfg = Config {};
let db = DbService {};
let app = App { cfg: &cfg, db: &db };
// app.do_something();
}
На этом можно было бы остановиться, но для упрощения синтаксиса Rust ввёл некоторые исключения, когда lifetime параметры можно опустить.
Давайте посмотрим на эти исключения и наконец-то подведём итоги.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
🧠 Управление памятью в Rust.
Часть 7.3. Lifetime параметры. Исключения.
Существует ряд исключений, когда Rust позволяет опустить lifetime параметры, чтобы не загромождать код.
Возьмём для примера такую функцию:
В данном случае у нас всего одна ссылка на входе, поэтому и вернуть мы можем ссылку только на те же данные (или на их часть).
А значит проблем с определением времени жизни возвращаемого значения нет.
Такой код скомпилируется.
Если мы захотим в эту функцию вторым атрибутом передать ссылку на строку, которая будет являться разделителем для разбиения строки на части, то уже потребуется указание lifetime параметра.
Но если мы обернём csv_row в структуру, то можно написать вот так без указания времени жизни:
Это связано с тем, что Rust приоритизирует атрибут &self, по умолчанию присваивая его время жизни возвращаемой ссылке.
В общем виде алгоритм выглядит так:
1️⃣ Всем ссылкам в аргументах функции Rust присваивает уникальный lifetime ('a, 'b, 'c, и т.д.).
2️⃣ Если lifetime параметр получился только один, то он же присваивается и возвращаемой ссылке.
3️⃣ Если lifetime параметров несколько, но один из аргументов – &self (или &mut self), то возвращаемой ссылке будет присвоен его lifetime.
4️⃣ В остальных случаях Rust попросит явно указать lifetime для возвращаемого значения.
С непривычки концепция времени жизни и lifetime параметров может показаться какой-то замороченной. В том числе и из-за нового синтаксиса, который не встречался в других языках программирования.
Потребуется какое-то время, чтобы привыкнуть и к новым синтаксическим конструкциям, и к пониманию концепции в целом.
Порой можно решить конкретную задачу и без всего этого, заменив передачу ссылки копированием данных. В каких-то ситуациях это будет действительно оправдано.
Но я бы не советовал брать такой подход за привычку, только чтобы избежать работы с lifetime параметрами. С практикой придёт понимание и уверенность, что позволит добавить этот довольно эффективный инструмент в ваш арсенал знаний.
#rust #rustlang #RustMemoryManagement
Часть 7.3. Lifetime параметры. Исключения.
Существует ряд исключений, когда Rust позволяет опустить lifetime параметры, чтобы не загромождать код.
Возьмём для примера такую функцию:
fn first_field(csv_row: &str) -> &str {
if !csv_row.contains(',') {
return csv_row;
}
csv_row.split_once(',').unwrap().0
}
В данном случае у нас всего одна ссылка на входе, поэтому и вернуть мы можем ссылку только на те же данные (или на их часть).
А значит проблем с определением времени жизни возвращаемого значения нет.
Такой код скомпилируется.
Если мы захотим в эту функцию вторым атрибутом передать ссылку на строку, которая будет являться разделителем для разбиения строки на части, то уже потребуется указание lifetime параметра.
Но если мы обернём csv_row в структуру, то можно написать вот так без указания времени жизни:
struct CsvRow<'a>(&'a str);
impl<'a> CsvRow<'a> {
fn first_field(&self, delimiter: &str) -> &str {
self.0.split_once(delimiter).unwrap().0
}
}
Это связано с тем, что Rust приоритизирует атрибут &self, по умолчанию присваивая его время жизни возвращаемой ссылке.
В общем виде алгоритм выглядит так:
1️⃣ Всем ссылкам в аргументах функции Rust присваивает уникальный lifetime ('a, 'b, 'c, и т.д.).
2️⃣ Если lifetime параметр получился только один, то он же присваивается и возвращаемой ссылке.
3️⃣ Если lifetime параметров несколько, но один из аргументов – &self (или &mut self), то возвращаемой ссылке будет присвоен его lifetime.
4️⃣ В остальных случаях Rust попросит явно указать lifetime для возвращаемого значения.
С непривычки концепция времени жизни и lifetime параметров может показаться какой-то замороченной. В том числе и из-за нового синтаксиса, который не встречался в других языках программирования.
Потребуется какое-то время, чтобы привыкнуть и к новым синтаксическим конструкциям, и к пониманию концепции в целом.
Порой можно решить конкретную задачу и без всего этого, заменив передачу ссылки копированием данных. В каких-то ситуациях это будет действительно оправдано.
Но я бы не советовал брать такой подход за привычку, только чтобы избежать работы с lifetime параметрами. С практикой придёт понимание и уверенность, что позволит добавить этот довольно эффективный инструмент в ваш арсенал знаний.
#rust #rustlang #RustMemoryManagement
Забавно, что сообщение о том, что я не буду какое-то время писать посты, набрало больше всего положительных реакций среди всех публикаций канала.
Вдобавок ещё и количество подписчиков немного подросло.
Может, стоит почаще делать такие перерывы? 😅
Ну а если серьёзно, то планирую постепенно возвращаться к ведению канала.
Сегодня я просматриваю свою RSS ленту и скоро выложу пост со списком интересных на мой взгляд статей, вышедших за время моего отсутствия.
Ну и напоминаю (в том числе и себе), что у меня в самом разгаре две серии постов:
1️⃣ #RustMemoryManagement – публикации про управление памятью в языке Rust.
Мы уже рассмотрели такие важные понятия, как области памяти Stack и Heap, а также ключевые концепции Ownership, Borrowing и Lifetimes.
В ближайшее время планирую написать про ещё одну область памяти – статическую. А далее будем постепенно переходить к умным указателям (smart-pointers).
2️⃣ #RustStandardTraits – серия про трейты в стандартной библиотеке Rust.
Мы рассмотрели уже около 10-ти типажей, в основном касающихся конвертации данных.
Далее буду переходить к трейтам, относящимся к работе с памятью в Rust. Здесь как раз очень помогут публикации из предыдущей серии.
Разбавлять эти публикации как всегда буду ссылками на понравившиеся мне статьи и постами на какие-то общие темы.
Вдобавок ещё и количество подписчиков немного подросло.
Может, стоит почаще делать такие перерывы? 😅
Ну а если серьёзно, то планирую постепенно возвращаться к ведению канала.
Сегодня я просматриваю свою RSS ленту и скоро выложу пост со списком интересных на мой взгляд статей, вышедших за время моего отсутствия.
Ну и напоминаю (в том числе и себе), что у меня в самом разгаре две серии постов:
1️⃣ #RustMemoryManagement – публикации про управление памятью в языке Rust.
Мы уже рассмотрели такие важные понятия, как области памяти Stack и Heap, а также ключевые концепции Ownership, Borrowing и Lifetimes.
В ближайшее время планирую написать про ещё одну область памяти – статическую. А далее будем постепенно переходить к умным указателям (smart-pointers).
2️⃣ #RustStandardTraits – серия про трейты в стандартной библиотеке Rust.
Мы рассмотрели уже около 10-ти типажей, в основном касающихся конвертации данных.
Далее буду переходить к трейтам, относящимся к работе с памятью в Rust. Здесь как раз очень помогут публикации из предыдущей серии.
Разбавлять эти публикации как всегда буду ссылками на понравившиеся мне статьи и постами на какие-то общие темы.
❤1🔥1
🧠 Управление памятью в Rust.
Часть 8.1 Static memory.
Помимо уже рассмотренных областей памяти Stack и Heap, в Rust есть ещё один сегмент памяти – статический (static).
Данная концепция скорее всего знакома вам по другим языкам программирования, тем не менее для полноты картины стоит затронуть эту тему.
Статическая область памяти – это память фиксированного размера, в которой при старте программы соответствующие данные инициализируются и живут на протяжении всего времени работы программы.
По аналогии со стеком, размер данных должен быть неизменным и известным на этапе компиляции программы, чтобы можно было рассчитать необходимый размер статического сегмента памяти.
В статическую память попадают константы, глобальные переменные, статические структуры, статические функции и различные метаданные, необходимые для работы программы.
Для инициализации данных в статической области памяти используется ключевое слово`static`.
Владение над статическими переменными не может быть передано, поэтому в функции можно передавать только ссылки. При этом для обозначения времени жизни подобных ссылок используется зарезервированный lifetime параметр
Обычно статические данные остаются неизменными на протяжении всей жизни программы, но если очень хочется, то можно объявить переменную как
Более безопасный вариант – использовать Atomic структуры:
Вообще, обычно не советуют увлекаться работой со статикой, так как использование глобальных переменных может негативно влиять на читаемость и структурированность программы, вдобавок осложняя написание юнит-тестов.
Но в некоторых случаях статические переменные могут быть очень полезны.
Например, при улучшении быстродействия программы.
Допустим, мы пишем высоконагруженный healthcheck сервис, который возвращает статус программы (HEALTHY / NOT_HEALTHY) и версию приложения.
Строковые значения статусов и версия – это статическая информация, а значит эти данные могут быть объявлены как константы.
В результате Rust не будет при каждом вызове создавать объекты, а затем их зачищать, а просто будет брать ссылку на статические данные.
Структура ответа может выглядеть как-то так:
Схожая концепция применяется и в языках с Garbage Collector для уменьшения количества генерируемого "мусора".
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
Часть 8.1 Static memory.
Помимо уже рассмотренных областей памяти Stack и Heap, в Rust есть ещё один сегмент памяти – статический (static).
Данная концепция скорее всего знакома вам по другим языкам программирования, тем не менее для полноты картины стоит затронуть эту тему.
Статическая область памяти – это память фиксированного размера, в которой при старте программы соответствующие данные инициализируются и живут на протяжении всего времени работы программы.
По аналогии со стеком, размер данных должен быть неизменным и известным на этапе компиляции программы, чтобы можно было рассчитать необходимый размер статического сегмента памяти.
В статическую память попадают константы, глобальные переменные, статические структуры, статические функции и различные метаданные, необходимые для работы программы.
Для инициализации данных в статической области памяти используется ключевое слово`static`.
Владение над статическими переменными не может быть передано, поэтому в функции можно передавать только ссылки. При этом для обозначения времени жизни подобных ссылок используется зарезервированный lifetime параметр
'static
:
struct StaticExample;
const CONST_EX: StaticExample = StaticExample {};
fn main() {
// Тип статической перемменной должен быть явно указан
static EX: StaticExample = StaticExample {};
// Такой вызов функции приведёт к ошибке компиляции
get_ownership(EX);
// А так передавать статические данные можыно
borrow(&EX);
// У констант тоже lifetime 'static
borrow(&CONST_EX);
}
fn get_ownership(ex: StaticExample) {}
fn borrow(ex: &'static StaticExample) {}
Обычно статические данные остаются неизменными на протяжении всей жизни программы, но если очень хочется, то можно объявить переменную как
mut static
и поменять её значение в блоке unsafe
.Более безопасный вариант – использовать Atomic структуры:
fn main() {
static_counter();
static_counter();
static_counter();
}
fn static_counter() {
static counter: AtomicU32 = AtomicU32::new(0);
println!(
"Кол-во вызовов функции: {}",ß
counter.fetch_add(1, Ordering::Relaxed) + 1
);
}
// Вывод программмы:
// Кол-во вызовов функции: 1
// Кол-во вызовов функции: 2
// Кол-во вызовов функции: 3
Вообще, обычно не советуют увлекаться работой со статикой, так как использование глобальных переменных может негативно влиять на читаемость и структурированность программы, вдобавок осложняя написание юнит-тестов.
Но в некоторых случаях статические переменные могут быть очень полезны.
Например, при улучшении быстродействия программы.
Допустим, мы пишем высоконагруженный healthcheck сервис, который возвращает статус программы (HEALTHY / NOT_HEALTHY) и версию приложения.
Строковые значения статусов и версия – это статическая информация, а значит эти данные могут быть объявлены как константы.
В результате Rust не будет при каждом вызове создавать объекты, а затем их зачищать, а просто будет брать ссылку на статические данные.
Структура ответа может выглядеть как-то так:
// версию берём из Cargo.toml
const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
const STATUS_OK: &str = "HEALTHY";
const STATUS_ERROR: &str = "NOT_HEALTHY";
struct HealthcheckResponse {
status: &'static str,
version: &'static str,
}
Схожая концепция применяется и в языках с Garbage Collector для уменьшения количества генерируемого "мусора".
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
🧠 Управление памятью в Rust.
Часть 8.2 Static memory. Продолжение.
Ещё один классический пример – вынос инициализации Regex выражений в статическую память.
Разбор и компиляция регулярного выражения – довольно тяжеловесная операция. Если мы пишем какой-нибудь веб-сервис, внутри которого собираемся валидировать или парсить данные по регулярке, то лучше её инциализировать один раз при старте программы.
Сделать это можно либо путём заведения структуры, внутри которой будет поле типа
❗️ Но, в отличие от примера с AtomicU32, такой код не скомпилируется.
Дело в том, что при инициализации статических переменных можно использовать только функции, помеченные ключевым словом
И если функция
Решаются подобные ситуации с помощью библиотеки lazy_static или её более современного аналога – once_cell. Эти библиотеки позволяют сделать отложенную (ленивую) инициализацию статических переменных:
Конечно же такой подход применим не только для работы с регулярными выражениями, но и для любой инициализации данных в статической области памяти уже после старта программы.
#rust #rustlang #RustMemoryManagement
Часть 8.2 Static memory. Продолжение.
Ещё один классический пример – вынос инициализации Regex выражений в статическую память.
Разбор и компиляция регулярного выражения – довольно тяжеловесная операция. Если мы пишем какой-нибудь веб-сервис, внутри которого собираемся валидировать или парсить данные по регулярке, то лучше её инциализировать один раз при старте программы.
Сделать это можно либо путём заведения структуры, внутри которой будет поле типа
Regex
(этот вариант сейчас рассматривать не будем), либо путём вынесения регулярки в статическое поле:
// для работы с регулярными выражениями в Rust используется библиотека regex
use regex::Regex;
fn verify_date() {
static RE: Regex = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap();
assert!(RE.is_match("2010-03-14"));
}
❗️ Но, в отличие от примера с AtomicU32, такой код не скомпилируется.
Дело в том, что при инициализации статических переменных можно использовать только функции, помеченные ключевым словом
const
, так как результат их выполнения может быть вычислен на этапе компиляции.И если функция
new
для Atomic структур как раз является константной, то для Regex это не так.Решаются подобные ситуации с помощью библиотеки lazy_static или её более современного аналога – once_cell. Эти библиотеки позволяют сделать отложенную (ленивую) инициализацию статических переменных:
use once_cell::sync::Lazy;
use regex::Regex;
fn verify_date() {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap());
assert!(RE.is_match("2010-03-14"));
}
Конечно же такой подход применим не только для работы с регулярными выражениями, но и для любой инициализации данных в статической области памяти уже после старта программы.
#rust #rustlang #RustMemoryManagement
🧠 Управление памятью в Rust.
Часть 9. Box<T> smart pointer.
Мы говорили, что Stack – очень эффективная и производительная область памяти, поэтому Rust всегда старается разместить данные именно в этом сегменте, если это возможно (если размер данных известен на этапе компиляции и не меняется во время работы программы).
Однако существуют ситуации, когда данные могут быть сохранены в стеке, но разработчик хочет разместить их в Heap.
Например, чтобы избежать копирования большого количества данных при передаче их из функции в функцию.
Для этого нужно просто обернуть требуемую структуру в
В данном примере функция
При этом
Т.е. по результатам выполнения строки
в переменной data физически будет лежать адрес памяти типа usize (u32 на 32-битных и u64 на 64-битных процессорах), по которому будет размещён массив чисел.
А это в свою очередь означает, что значение переменной data будет сохранено в стеке функции main.
Если запутались, давайте ещё раз: сами данные массива лежат в Heap ([i32; 1000]), а ссылка на эти данные (Box<[i32; 1000]> или usize) – в Stack.
Таким образом, Box можно использовать ещё и тогда, когда нужно сохранить динамические данные в структурах, которые могут работать только с данными фиксированного размера.
Рассмотрим пример реализации полиморфизма в Rust:
Здесь я объявил две разные структуры (Dog и Cat), которые реализуют общее поведение Pet. И далее я хочу сохранить в вектор разные объекты с общим поведением Pet, чтобы вызывать их метод greet без приведения к конкретному типу.
Внутренняя структура динамического массива Vec устроена таким образом, что сохраняемые в неё объекты должны иметь одинаковый размер.
Структуры Dog и Cat обладают разным размером, но это можно обойти, обернув их в Box. В результате мы получим массив из указателей usize, по которым в дальнейшем данные будут динамически приведены к типу Pet для вызова метода greet.
Ещё один классический пример использования Box – создание структур, ссылающихся сами на себя (по типу связных списков):
Без использования Box Rust не может вычислить требуемый размер памяти под рекурсивную структуру, что приведёт к ошибке компиляции.
Использование Box помогает разорвать циклическую зависимость и превратить Page в структуру фиксированной длины.
#rust #rustlang #RustMemoryManagement #RustSmartPointers
Часть 9. Box<T> smart pointer.
std::boxed::Box<T>
– пожалуй, самый простой умный указатель в Rust, при этом имеющий сразу несколько областей применения.Мы говорили, что Stack – очень эффективная и производительная область памяти, поэтому Rust всегда старается разместить данные именно в этом сегменте, если это возможно (если размер данных известен на этапе компиляции и не меняется во время работы программы).
Однако существуют ситуации, когда данные могут быть сохранены в стеке, но разработчик хочет разместить их в Heap.
Например, чтобы избежать копирования большого количества данных при передаче их из функции в функцию.
Для этого нужно просто обернуть требуемую структуру в
Box<T>
:
fn init_arr() -> Box<[i32; 1000]> {
let arr = [1; 1000];
Box::new(arr)
}
fn sum_elements(arr: Box<[i32; 1000]>) -> i32 {
arr.iter().sum()
}
fn main() {
let data = init_arr();
println!("{}", sum_elements(data);
}
В данном примере функция
init_arr
инициализирует массив из тысячи единиц, обёрнутый в Box<[i32; 1000]>
. Следовательно, этот массив будет сохранён в Heap, и при передаче в функцию main
, а затем в функцию sum_elements
, будет копироваться не сам массив данных, а ссылка на него.При этом
Box<T>
не добавляет никакого оверхэда, а просто сохраняет данные в Heap, вместо Stack.Т.е. по результатам выполнения строки
let data = init_arr();
в переменной data физически будет лежать адрес памяти типа usize (u32 на 32-битных и u64 на 64-битных процессорах), по которому будет размещён массив чисел.
А это в свою очередь означает, что значение переменной data будет сохранено в стеке функции main.
Если запутались, давайте ещё раз: сами данные массива лежат в Heap ([i32; 1000]), а ссылка на эти данные (Box<[i32; 1000]> или usize) – в Stack.
Таким образом, Box можно использовать ещё и тогда, когда нужно сохранить динамические данные в структурах, которые могут работать только с данными фиксированного размера.
Рассмотрим пример реализации полиморфизма в Rust:
struct Dog {
name: String,
age: u8
}
struct Cat {
name: String,
}
trait Pet {
fn greet(&self);
}
impl Pet for Dog {
fn greet(&self) {
println!("Woof! I'm {}.", self.name);
}
}
impl Pet for Cat {
fn greet(&self) {
println!("Meow! I'm {}.", self.name);
}
}
fn main() {
let pets: Vec<Box<dyn Pet>> = vec![
Box::new(Cat {
name: "Mittens".to_string(),
}),
Box::new(Dog {
name: "Rocket".to_string(),
}),
];
for pet in pets {
pet.greet();
}
}
Здесь я объявил две разные структуры (Dog и Cat), которые реализуют общее поведение Pet. И далее я хочу сохранить в вектор разные объекты с общим поведением Pet, чтобы вызывать их метод greet без приведения к конкретному типу.
Внутренняя структура динамического массива Vec устроена таким образом, что сохраняемые в неё объекты должны иметь одинаковый размер.
Структуры Dog и Cat обладают разным размером, но это можно обойти, обернув их в Box. В результате мы получим массив из указателей usize, по которым в дальнейшем данные будут динамически приведены к типу Pet для вызова метода greet.
Ещё один классический пример использования Box – создание структур, ссылающихся сами на себя (по типу связных списков):
struct Page {
page_data: String,
next_page: Option<Box<Page>>,
}
Без использования Box Rust не может вычислить требуемый размер памяти под рекурсивную структуру, что приведёт к ошибке компиляции.
Использование Box помогает разорвать циклическую зависимость и превратить Page в структуру фиксированной длины.
#rust #rustlang #RustMemoryManagement #RustSmartPointers
🧠 Управление памятью в Rust.
Часть 10.1. Rc<T> & Arc<T> smart pointers.
В процессе написания кода Rust разработчику нужно постоянно решать, кто будет владельцем тех или иных данных.
Удобнее всего работать с данными, которыми владеешь сам (owned data). Но это не всегда возможно.
Работа с заимствованными данными (borrowed data) может быть не всегда лёгкой и удобной, особенно когда дело доходит до lifetime параметров.
В таких ситуациях на выручку приходит умный указатель
Он позволяет работать с данными в разных местах программы, не создавая их копии.
Достигается это путём подсчёта ссылок на данные, сохранённые в Heap. Отсюда и название Rc – Reference Counter.
Передавая данные в какую-либо функцию или структуру, Rc увеличивает счётчик ссылок на 1. Когда переменные выходят из области видимости и должны быть очищены, Rc уменьшает счётчик на 1.
И лишь когда счётчик уменьшается до нуля, тогда происходит освобождение памяти, занимаемой данными.
Давайте рассмотрим пример.
Допустим, я хочу вести учёт электронных ключей, выдаваемых посетителям. Для доступа в каждую комнату нужен свой ключ.
В данном примере у меня есть три ключа, первые два я выдал посетителю с именем Bob, а третий – Alice.
Если я захочу выдать второй ключ и Бобу и Элис, то будет ошибка компиляции, т.к. нельзя передать владение над key_2 одновременно в два вектора.
Конкретно в этом маленьком примере можно было бы просто скопировать структуру Key.
Но если я не хочу клонировать данные, то можно воспользоваться умным указателем Rc:
Теперь у структуры Visitor хранится список совместно используемых ключей, а при создании вектора с ключами мы вызываем метод clone у Rc, тем самым увеличивая счётчик ссылок на каждый ключ.
Rc позволяет узнать сколько ссылок есть на структуру в определённый момент времени.
Давайте заменим объявление переменной v2 с посетителей Alice на такой код:
Сначала программа выведет число 3, так как на ключ для второй комнаты ведёт три ссылки: из переменной key_2, из вектора посетителя Боб и из вектора посетителя Элис.
После выхода Элис из скоупа, структура посетителя будет зачищена. При этом данные структуры key_2 останутся в памяти, а количество ссылок будет уменьшено до двух.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement #RustSmartPointers
Часть 10.1. Rc<T> & Arc<T> smart pointers.
В процессе написания кода Rust разработчику нужно постоянно решать, кто будет владельцем тех или иных данных.
Удобнее всего работать с данными, которыми владеешь сам (owned data). Но это не всегда возможно.
Работа с заимствованными данными (borrowed data) может быть не всегда лёгкой и удобной, особенно когда дело доходит до lifetime параметров.
В таких ситуациях на выручку приходит умный указатель
std::rc::Rc<T>
, реализующий концепцию совместного владения (shared ownership).Он позволяет работать с данными в разных местах программы, не создавая их копии.
Достигается это путём подсчёта ссылок на данные, сохранённые в Heap. Отсюда и название Rc – Reference Counter.
Передавая данные в какую-либо функцию или структуру, Rc увеличивает счётчик ссылок на 1. Когда переменные выходят из области видимости и должны быть очищены, Rc уменьшает счётчик на 1.
И лишь когда счётчик уменьшается до нуля, тогда происходит освобождение памяти, занимаемой данными.
Давайте рассмотрим пример.
Допустим, я хочу вести учёт электронных ключей, выдаваемых посетителям. Для доступа в каждую комнату нужен свой ключ.
struct Key {
room: u8,
}
struct Visitor {
name: String,
keys: Vec<Key>,
}
fn main() {
let (key_1, key_2, key_3) = (
Key { room: 1 },
Key { room: 2 },
Key { room: 3 }
);
let v1 = Visitor {
name: "Bob".to_string(),
keys: vec![key_1, key_2],
};
let v2 = Visitor {
name: "Alice".to_string(),
keys: vec![key_3],
};
}
В данном примере у меня есть три ключа, первые два я выдал посетителю с именем Bob, а третий – Alice.
Если я захочу выдать второй ключ и Бобу и Элис, то будет ошибка компиляции, т.к. нельзя передать владение над key_2 одновременно в два вектора.
Конкретно в этом маленьком примере можно было бы просто скопировать структуру Key.
Но если я не хочу клонировать данные, то можно воспользоваться умным указателем Rc:
use std::rc::Rc;
struct Key {
room: u8,
}
struct Visitor {
name: String,
keys: Vec<Rc<Key>>,
}
fn main() {
let (key_1, key_2, key_3) = (
Rc::new(Key { room: 1 }),
Rc::new(Key { room: 2 }),
Rc::new(Key { room: 3 }),
);
let v1 = Visitor {
name: "Bob".to_string(),
keys: vec![Rc::clone(&key_1), Rc::clone(&key_2)],
};
let v2 = Visitor {
name: "Alice".to_string(),
keys: vec![Rc::clone(&key_2), Rc::clone(&key_3)],
};
}
Теперь у структуры Visitor хранится список совместно используемых ключей, а при создании вектора с ключами мы вызываем метод clone у Rc, тем самым увеличивая счётчик ссылок на каждый ключ.
Rc позволяет узнать сколько ссылок есть на структуру в определённый момент времени.
Давайте заменим объявление переменной v2 с посетителей Alice на такой код:
{
let v2 = Visitor {
name: "Alice".to_string(),
keys: vec![Rc::clone(&key_2), Rc::clone(&key_3)],
};
println!("Key_2 references: {}", Rc::strong_count(&key_2));
}
println!("Key_2 references: {}", Rc::strong_count(&key_2));
Сначала программа выведет число 3, так как на ключ для второй комнаты ведёт три ссылки: из переменной key_2, из вектора посетителя Боб и из вектора посетителя Элис.
После выхода Элис из скоупа, структура посетителя будет зачищена. При этом данные структуры key_2 останутся в памяти, а количество ссылок будет уменьшено до двух.
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement #RustSmartPointers
🧠 Управление памятью в Rust.
Часть 10.2. Rc<T> & Arc<T> smart pointers. Продолжение.
Потокобезопасная реализация работает медленнее, но в ситуациях, когда мы хотим получать доступ к общим ресурсам в разных потоках (например, получать доступ к соединению с БД в обработчиках эндпоинтов веб-сервиса), необходимо использовать именно Arc.
Стоит отметить, что обычно Rc и Arc предоставляют совместный доступ только к неизменяемым данным (immutable). То есть у структуры, обёрнутой в эти указатели, нельзя вызвать метод с ресивером &mut self.
Частично обойти это можно, воспользовавшись методом Rc::make_mut или Arc::make_mut, который вернёт mutable ссылку на данные.
Но это будет работать только тогда, когда на данные в текущий момент существует только одна ссылка. Если же на данные ссылается кто-то ещё, то при изменении они будут скопированы. Такой подход называется clone-on-write.
В общем же случае для изменения данных используются другие умные указатели (самостоятельно или совместно с Rc и Arc).
Но об этом мы поговорим в другой раз.
#rust #rustlang #RustMemoryManagement #RustSmartPointers
Часть 10.2. Rc<T> & Arc<T> smart pointers. Продолжение.
std::sync::Arc
(Atomic Reference Counter) используется для тех же целей, что и Rc, но обладает потокобезопасной реализацией, в то время как Rc может использоваться только в однопоточной среде. Потокобезопасная реализация работает медленнее, но в ситуациях, когда мы хотим получать доступ к общим ресурсам в разных потоках (например, получать доступ к соединению с БД в обработчиках эндпоинтов веб-сервиса), необходимо использовать именно Arc.
Стоит отметить, что обычно Rc и Arc предоставляют совместный доступ только к неизменяемым данным (immutable). То есть у структуры, обёрнутой в эти указатели, нельзя вызвать метод с ресивером &mut self.
Частично обойти это можно, воспользовавшись методом Rc::make_mut или Arc::make_mut, который вернёт mutable ссылку на данные.
Но это будет работать только тогда, когда на данные в текущий момент существует только одна ссылка. Если же на данные ссылается кто-то ещё, то при изменении они будут скопированы. Такой подход называется clone-on-write.
В общем же случае для изменения данных используются другие умные указатели (самостоятельно или совместно с Rc и Arc).
Но об этом мы поговорим в другой раз.
#rust #rustlang #RustMemoryManagement #RustSmartPointers
🧠 Управление памятью в Rust.
Часть 12.1. RefCell<T>.
Когда мы рассматривали концепцию Borrowing, я упоминал про правило заимствования, которое Rust проверяет при компиляции: в один момент времени может существовать либо сколько угодно ссылок на чтение данных, либо только одна ссылка на изменение этих данных.
Это помогает избежать некоторых проблем при работе с памятью, например, защищает от обращения к уже очищенной области памяти.
Такой подход, когда мы получаем ссылки на данные для чтения или записи, а корректность работы с ними проверяется на этапе компиляции, называется статическое заимствование.
В Rust также существует динамическое заимствование – подход, при котором мы заворачиваем данные в контейнер
RefCell реализует методы borrow/borrow_mut для получения ссылок для чтения/изменения данных, и если попробовать получить одновременно две mutable ссылки или поработать со ссылкой на чтение, которая была получена до работы со ссылкой на изменение, Rust выбросит runtime ошибку – panic.
⁉️ Зачем может понадобиться такое поведение?
Ключевая ценность RefCell кроется в его методе borrow_mut (или try_borrow_mut, который возвращает Result, чтобы можно было избежать паники). Несмотря на то, что метод возвращает мутабельную ссылку на объект, ресивер у него –
Это значит, что с помощью RefCell можно менять объект внутри метода, который не требует ссылки для изменения.
А раз нет ссылок для изменения, то и по поводу правила заимствования можно не переживать – все ссылки только для чтения, и таких ссылок одновременно можно получать сколько угодно;
Такая концепция называется внутренняя изменчивость (interior mutability): объект можно изменять через ссылку
💡 Давайте разбираться на примере.
Допустим, мы хотим реализовать такую структуру-контейнер, которая бы внутри хранила в себе какие-то данные типа T.
И допустим, мы хотим внутри этой структуры объявить boolean флаг, показывающий обращались ли к данным внутри Holder через метод get хотя бы один раз или нет.
Проблема такого решения в том, что нам пришлось объявить ресивер у метода get как
А это плохо как с точки зрения чтения и понимания кода (зачем методу get для чтения данных мутабельная ссылка?), так и с точки зрения использования Holder-а: придётся учитывать правила заимствования, ограничивающие количество мутабельных ссылок.
В таких ситуациях на помощь приходит interior mutability и RefCell в частности:
В данном случае мы можем воспользоваться методом
Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
Часть 12.1. RefCell<T>.
Когда мы рассматривали концепцию Borrowing, я упоминал про правило заимствования, которое Rust проверяет при компиляции: в один момент времени может существовать либо сколько угодно ссылок на чтение данных, либо только одна ссылка на изменение этих данных.
Это помогает избежать некоторых проблем при работе с памятью, например, защищает от обращения к уже очищенной области памяти.
Такой подход, когда мы получаем ссылки на данные для чтения или записи, а корректность работы с ними проверяется на этапе компиляции, называется статическое заимствование.
В Rust также существует динамическое заимствование – подход, при котором мы заворачиваем данные в контейнер
std::cell::RefCell
, а корректность работы со ссылками на эти данные проверяется в процессе работы программы (в райнтайме).RefCell реализует методы borrow/borrow_mut для получения ссылок для чтения/изменения данных, и если попробовать получить одновременно две mutable ссылки или поработать со ссылкой на чтение, которая была получена до работы со ссылкой на изменение, Rust выбросит runtime ошибку – panic.
⁉️ Зачем может понадобиться такое поведение?
Ключевая ценность RefCell кроется в его методе borrow_mut (или try_borrow_mut, который возвращает Result, чтобы можно было избежать паники). Несмотря на то, что метод возвращает мутабельную ссылку на объект, ресивер у него –
&self
, а не &mut self
.Это значит, что с помощью RefCell можно менять объект внутри метода, который не требует ссылки для изменения.
А раз нет ссылок для изменения, то и по поводу правила заимствования можно не переживать – все ссылки только для чтения, и таких ссылок одновременно можно получать сколько угодно;
Такая концепция называется внутренняя изменчивость (interior mutability): объект можно изменять через ссылку
&T
, а не через &mut T
. Изменения через &mut T
называют наследуемой изменчивостью (inherited mutability).💡 Давайте разбираться на примере.
Допустим, мы хотим реализовать такую структуру-контейнер, которая бы внутри хранила в себе какие-то данные типа T.
И допустим, мы хотим внутри этой структуры объявить boolean флаг, показывающий обращались ли к данным внутри Holder через метод get хотя бы один раз или нет.
struct Holder<T> {
data: T,
touched: bool
}
impl<T> Holder<T> {
fn new(init: T) -> Self {
Holder { data: init, touched: false }
}
fn get(&mut self) -> &T {
self.touched = true;
&self.data
}
}
Проблема такого решения в том, что нам пришлось объявить ресивер у метода get как
&mut self
, а не как &self
.А это плохо как с точки зрения чтения и понимания кода (зачем методу get для чтения данных мутабельная ссылка?), так и с точки зрения использования Holder-а: придётся учитывать правила заимствования, ограничивающие количество мутабельных ссылок.
В таких ситуациях на помощь приходит interior mutability и RefCell в частности:
struct Holder<T> {
data: T,
touched: RefCell<bool>,
}
impl<T> Holder<T> {
fn new(init: T) -> Self {
Holder {
data: init,
touched: RefCell::new(false),
}
}
fn get(&self) -> &T {
self.touched.replace(true);
&self.data
}
}
В данном случае мы можем воспользоваться методом
replace
, который внутри вызывает borrow_mut
, а метод get может быть объявлен с ресивером &self
, не накладывая дополнительных проблем на его использование.Продолжение ⬇️
#rust #rustlang #RustMemoryManagement
🧠 Управление памятью в Rust.
Часть 12.2. RefCell<T>. Продолжение.
Свойство interior mutability, позволяющее изменять данные там, где не предполагается их изменение, хорошо сочетается с умным указателем Rc (Reference Counter).
В прошлой части я упоминал, что Rc предоставляет совместный доступ к данным, которые нельзя изменить, т.к. указатель не даёт вызывать методы с ресивером &mut self.
Но если скомбинировать Rc с RefCell, то это ограничение можно обойти:
❗️ Стоит отметить, что контейнер RefCell не является потокобезопасным, поэтому его нельзя использовать совместно с Arc (Atomic Reference Counter).
Для работы в многопоточной среде вместе с Arc обычно используется RwLock (Read-Write Lock), который тоже позволяет в один момент времени либо читать данные любому количеству потоков, либо изменять данные кому-то одному.
Подводя итог, скажу, что RefCell не стоит использовать просто так, чтобы обойти ограничения компилятора.
Сами правила работы со ссылками никуда не деваются, просто вы их перенесёте с этапа компиляции в рантайм, и приложение всё равно не будет работать.
RefCell позволяет решать ограниченный набор вполне конкретных задач. В остальных ситуациях лучше использовать стандартную концепцию наследуемой изменчивости и дать компилятору сделать свою работу и помочь вам избежать ошибок.
#rust #rustlang #RustMemoryManagement
Часть 12.2. RefCell<T>. Продолжение.
Свойство interior mutability, позволяющее изменять данные там, где не предполагается их изменение, хорошо сочетается с умным указателем Rc (Reference Counter).
В прошлой части я упоминал, что Rc предоставляет совместный доступ к данным, которые нельзя изменить, т.к. указатель не даёт вызывать методы с ресивером &mut self.
Но если скомбинировать Rc с RefCell, то это ограничение можно обойти:
let v1 = Rc::new(vec![1]);
// v1.push(2); // так нельзя
let v2 = Rc::new(RefCell::new(vec![1]));
v2.borrow_mut().push(2); // а так можно!
❗️ Стоит отметить, что контейнер RefCell не является потокобезопасным, поэтому его нельзя использовать совместно с Arc (Atomic Reference Counter).
Для работы в многопоточной среде вместе с Arc обычно используется RwLock (Read-Write Lock), который тоже позволяет в один момент времени либо читать данные любому количеству потоков, либо изменять данные кому-то одному.
Подводя итог, скажу, что RefCell не стоит использовать просто так, чтобы обойти ограничения компилятора.
Сами правила работы со ссылками никуда не деваются, просто вы их перенесёте с этапа компиляции в рантайм, и приложение всё равно не будет работать.
RefCell позволяет решать ограниченный набор вполне конкретных задач. В остальных ситуациях лучше использовать стандартную концепцию наследуемой изменчивости и дать компилятору сделать свою работу и помочь вам избежать ошибок.
#rust #rustlang #RustMemoryManagement
🧠 Управление памятью в Rust.
Часть 13. Cell<T>, OnceCell<T>, LazyCell<T, F>.
Помимо контейнера
Рассмотрим их чуть более подробно.
1️⃣
В то время как
⁉️ Зачем может понадобиться копирование вместо получения ссылки?
Я упоминал, что RefCell при обращении к внутреннему объекту выполняет проверки заимствования, по итогам которых может выбросить runtime ошибку. Эта операция не бесплатна и требует некоторых вычислительных ресурсов и времени.
При копировании объекта выполнять такие проверки не требуется, поэтому в некоторых случаях такой подход будет более производительным.
Здесь логика очень схожа с тем, как объекты передаются из метода в метод: большие и сложные структуры лучше передавать по ссылке, а вот простые типы будет быстрее скопировать.
Для получения копии из контейнера Cell с помощью метода get внутренний тип должен реализовывать трейт Copy.
Второй вариант: тип может реализовывать трейт Default. Тогда вы можете вызвать метод
Таким образом, в прошлом примере, где я использовал RefCell для изменения флага boolean, лучше подошёл бы Cell.
2️⃣
В Rust версии 1.70 добавился новый контейнер –
Это можно сделать либо с помощью метода set (самостоятельно проконтролировав, что метод будет вызван только один раз), либо с помощью метода get_or_init, в который передаётся функция инициализации (она будет вызвана только при первом обращении к контейнеру).
Примеры можно посмотреть в официальной документации.
Ключевым преимуществом OnceCell является то, что он возвращает ссылку на внутренний объект (как RefCell), но не выполняет runtime проверок правил заимствования.
Реализация
3️⃣
Контейнер
LazyCell схож с OnceCell по смыслу и назначению, но позволяет указать функцию инициализации один раз при создании контейнера, вместо того, чтобы указывать её каждый раз в методe get_or_init, как для OnceCell. При этом данная функция тоже будет вызвана один раз при первом обращении к контейнеру для получения ссылки на объект.
Реализация
С появлением контейнеров OnceCell и LazyCell в стандартной библиотеке языка необходимость в таких библиотеках, как lazy_static или once_cell, практически отпала.
Эти библиотеки я упоминал в посте про статическую память, там же есть пример их использования, который теперь можно реализовать только с помощью OnceCel/LazyCell.
#rust #rustlang #RustMemoryManagement
Часть 13. Cell<T>, OnceCell<T>, LazyCell<T, F>.
Помимо контейнера
RefCell
, который мы рассмотрели в предыдущем посте, пакет std::cell
может предложить ещё несколько видов контейнеров для реализации внутренней изменчивости (interior mutability).Рассмотрим их чуть более подробно.
1️⃣
Cell<T>
В то время как
RefCell
позволяет получить мутабельную ссылку на объект, std::cell::Cell
позволяет получить копию внутреннего объекта, которую можно изменить и положить обратно в Cell вместо старой версии.⁉️ Зачем может понадобиться копирование вместо получения ссылки?
Я упоминал, что RefCell при обращении к внутреннему объекту выполняет проверки заимствования, по итогам которых может выбросить runtime ошибку. Эта операция не бесплатна и требует некоторых вычислительных ресурсов и времени.
При копировании объекта выполнять такие проверки не требуется, поэтому в некоторых случаях такой подход будет более производительным.
Здесь логика очень схожа с тем, как объекты передаются из метода в метод: большие и сложные структуры лучше передавать по ссылке, а вот простые типы будет быстрее скопировать.
Для получения копии из контейнера Cell с помощью метода get внутренний тип должен реализовывать трейт Copy.
Второй вариант: тип может реализовывать трейт Default. Тогда вы можете вызвать метод
take
для получения дефолтного объекта.Таким образом, в прошлом примере, где я использовал RefCell для изменения флага boolean, лучше подошёл бы Cell.
2️⃣
OnceCell<T>
В Rust версии 1.70 добавился новый контейнер –
std::cell::OnceCell
, который работает примерно так же, как Cell или RefCell, но позволяет установить значение внутри контейнера только один раз.Это можно сделать либо с помощью метода set (самостоятельно проконтролировав, что метод будет вызван только один раз), либо с помощью метода get_or_init, в который передаётся функция инициализации (она будет вызвана только при первом обращении к контейнеру).
Примеры можно посмотреть в официальной документации.
Ключевым преимуществом OnceCell является то, что он возвращает ссылку на внутренний объект (как RefCell), но не выполняет runtime проверок правил заимствования.
Реализация
OnceCell
не является потокобезопасной. В многопоточной среде нужно использовать соответствующий аналог: std::sync::OnceLock
.3️⃣
LazyCell<T, Func>
Контейнер
std::cell::LazyCell
был добавлен в Rust версии 1.80.LazyCell схож с OnceCell по смыслу и назначению, но позволяет указать функцию инициализации один раз при создании контейнера, вместо того, чтобы указывать её каждый раз в методe get_or_init, как для OnceCell. При этом данная функция тоже будет вызвана один раз при первом обращении к контейнеру для получения ссылки на объект.
Реализация
LazyCell
не является потокобезопасной. В многопоточной среде нужно использовать соответствующий аналог: std::sync::LazyLock
.С появлением контейнеров OnceCell и LazyCell в стандартной библиотеке языка необходимость в таких библиотеках, как lazy_static или once_cell, практически отпала.
Эти библиотеки я упоминал в посте про статическую память, там же есть пример их использования, который теперь можно реализовать только с помощью OnceCel/LazyCell.
#rust #rustlang #RustMemoryManagement