Что такое владение?

Владение — это набор правил, регулирующих управление памятью в Rust. Всем программам необходимо иметь способ взаимодействовать с памятью, предоставляемой компьютером. Некоторые языки имеют сборщик мусора — он время от времени собирает неиспользуемую память и высвобождает её. В других языках программист должен явно и самостоятельно выделять и освобождать память. Rust нашёл третий путь: память управляется по правилам системы владения, исполнение которых проверяется компилятором. Если какие-либо правила нарушены, программа не скомпилируется. Кстати: поскольку правила владения проверяются именно на этапе компиляции, они не сделают ваш код медленнее.

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

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

Стек и куча

Многие языки программирования не требуют от вас задумываться о стеке или куче. Но в системных языках программирования (вроде Rust), важно знать разницу между размещением данных на стеке или на куче, знать о том как ведёт себя при этом язык и какие последствия повлечёт ваш выбор. Частично, владение будет рассмотрено в отношении стека, а под конец главы мы коснёмся и кучи; а пока, для подготовки, кратко расскажем о стеке и куче.

И стек, и куча — это участки памяти, доступные вашей программе для использования, однако устроены они по-разному. Стек хранит значения в порядке их добавления и удаляет их в обратном. Это называется принципом "первым вошёл — последним вышел". Думайте о стеке как о стопке тарелок: если вы добавляете больше тарелок, вы ставите их наверх; когда вам нужно взять тарелку, вы берёте её сверху. Добавить или убрать тарелку снизу или из середины не выйдет! Помещение данных в стек называется push, а извлечение — pop. Всё, что хранится на стеке, должно иметь постоянный и известный размер. Если же для ваших данных нельзя узнать длину до компиляции, или если их размер может поменяться, следует воспользоваться кучей.

Куча менее организована: когда вы помещаете данные в кучу, вы получаете немного места в ней и туда размещаете свои данные. Распределитель памяти (синоним: "аллокатор") ищет в памяти свободное место достаточного размера, помечает его как задействованное вашей программой, и возвращает вам указатель на этот участок памяти. Этот процесс называется аллокацией памяти в куче или просто аллокацией (примечание: размещение данных в стеке не считается аллокацией). Поскольку указатель на кучу имеет известный и постоянный размер, вы можете хранить его на стеке; но если вам потребуются непосредственно данные, вам нужно будет обратиться по указателю. Думайте об этом как о порядке рассадки в ресторане: когда вы входите, вы сообщаете о том, сколько человек в вашей группе, и официант ищет для вас столик подходящего размера; если же кто-то из вашей группы опаздывает, вы сообщаете ему, где вас разместили.

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

Доступ к данным в куче медленнее, чем к данным на стеке, поскольку вам нужно сначала отыскать нужный участок памяти по указателю. Современные процессоры работают быстрее, если не нагружать их частыми обращениями к памяти. Продолжая аналогию с ресторанами: подумайте о приёме официантом заказов посетителей. Эффективнее всего будет взять все заказы с одного столика перед тем, как идти к следующему. Если же пытаться взять заказ у столика A, потом у столика B, потом снова у A, а потом ещё раз у B... Это будет значительно медленнее. Точно так же процессору легче и быстрее выполнить свою работу, если необходимые данные будут лежать рядом (как на стеке), а не разрозненно по всей памяти (как в куче).

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

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

Правила владения

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

  • Каждое значение в Rust имеет владельца.
  • В один момент у значения может быть только один владелец.
  • Когда владелец покидает свою область видимости, значение высвобождается.

Область видимости переменной

Теперь, поскольку мы освоились с базовым синтаксисом Rust, мы не будем постоянно включать код примеров в fn main() {, так что если вы захотите повторить примеры, не забудьте поместить их в функцию main. Мы опускаем явное упоминание функции main, чтобы сосредоточиться на деталях, на нюансах кода, а не на уже заученных шаблонах.

В качестве первого примера владения, мы изучим границы области видимости некоторых переменных. Область видимости — это участок программы, в котором данные ещё действительны. Вот пример переменной:

#![allow(unused)]
fn main() {
let s = "hello";
}

Переменная s связана с литералом строки, записанным непосредственно в коде нашей программы. Переменная действительна с тех пор, как она объявлена, и до конца её области видимости. Листинг 4-1 содержит программу с комментариями о действительности переменной в разные моменты.

fn main() {
    {                      // s пока не действительна, так как не объявлена
        let s = "hello";   // отсюда и далее s действительна

        // здесь можно использовать s
    }                      // область видимости закончилась, s больше не действительна
}

В общем, здесь есть два важных момента:

  • Когда s оказывается в области видимости, она действительна.
  • Она остаётся действительной, пока не покинет область видимости.

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

Тип String

Чтобы проиллюстрировать правила владения, нам нужен тип данных более сложный, чем те, что мы рассмотрели в разделе "Типы данных" Главы 3. Там мы рассматривали такие типы, которые имеют фиксированный размер; хранятся на стеке и высвобождаются с концом их области видимости; могут быть быстро и просто скопированы, чтобы получить отдельно живущую копию данных, которую можно затем использовать в другой области видимости. Но на этот раз нам нужны данные, которые придётся хранить в куче — чтобы узнать, как Rust выясняет момент для высвобождения этих данных. Тип String нам отлично подойдёт.

Мы сконцентрируемся на тех частях String, которые связаны с владением. Эти аспекты также применимы к другим сложным типам данных, независимо от того, предоставлены они стандартной библиотекой или созданы вами. Более подробно мы обсудим String в Главе 8.

Мы уже видели строковые литералы, где строковое значение явно вписано в нашу программу. Строковые литералы удобны, но они подходят не для каждой ситуации, где мы можем хотеть использовать текст. Одна из причин заключается в том, что они неизменяемы. Кроме того, не всегда строковое значение может быть известно уже во время написания кода: что, если мы захотим принять и сохранить пользовательский ввод? Для таких ситуаций в Rust есть ещё один строковый тип — String. Этот тип управляет данными, выделенными в куче, и поэтому может хранить объём текста, который во время компиляции неизвестен. Вы можете создать String из строкового литерала, используя функцию from; вот так:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

Оператор "двойное двоеточие" (::) позволяет обращаться к данной функции from как к функции над типом String. Он позволяет отказаться от различных специфичных самостоятельных имён, вроде string_from. Мы обсудим этот синтаксис более подробно в разделе "Синтаксис метода" Главы 5, а также в ходе обсуждения пространств имён и модулей в разделе "Пути для ссылки на элемент в дереве модулей" Главы 7.

Подобного рода строки могут быть изменены

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() приписывает литерал к String

    println!("{s}"); // Будет напечатано `hello, world!`
}

В чем же разница? Почему строку String можно изменить, а литералы — нельзя? Разница заключается в том, как эти два типа работают с памятью.

Память и её выделение

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

Чтобы сделать возможным изменяемый, наращиваемый текст типа String, необходимо выделять память в куче для всего его содержимого, объём которого неизвестен во время компиляции. Это означает, что:

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

Первое выполняется нами: когда мы вызываем String::from, его реализация запрашивает необходимую память. Такое довольно знакомо многим языкам программирования.

Однако второй пункт куда интереснее. В языках со сборщиком мусора (GC) память, которая больше не используется, им отслеживается и очищается — нам не нужно об этом думать. В большинстве языков без сборщика мусора мы обязаны сами определять, когда память больше не используется, и вызывать код, явно её освобождающий: точно так же, как мы делали это для её выделения. Правильные ручные запросы на выделение и высвобождение памяти всегда были сложной проблемой программирования. Если мы забудем освободить память, она будет потеряна и без проку забьёт собой место. Если же мы сделаем это слишком рано, у нас будет недействительная переменная. Сделать это дважды — тоже выйдут проблемы. Нам нужно ровно единожды высвободить память, которую мы единожды выделили.

Rust пошёл по своему: память автоматически возвращается системе, как только владеющая памятью переменная выходит из области видимости. Вот версия примера с областью видимости из Листинга 4-1, в котором используется тип String вместо строкового литерала:

fn main() {
    {
        let s = String::from("hello"); // отсюда и далее s действительна

        // здесь можно использовать s
    }                                  // область видимости закончилась,
                                       // и s больше не действительна
}

Существует естественный момент, когда мы можем вернуть память, необходимую нашей String, обратно распределителю — когда s выходит за пределы области видимости. Когда переменная выходит за пределы области видимости, Rust вызывает для нас специальную функцию. Эта функция называется drop, и именно в ней создатель типа String может разместить код для возврата памяти. Rust автоматически вызывает drop после закрывающей фигурной скобки.

Примечание: В C++ этот шаблон освобождения ресурсов в конце времени жизни данных иногда называется "Получение ресурса есть инициализация" (Resource Acquisition Is Initialization — RAII). Функция drop в Rust покажется вам знакомой, если вы использовали шаблоны RAII.

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

Взаимодействие переменных и данных с помощью перемещения

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

fn main() {
    let x = 5;
    let y = x;
}

Мы можем догадаться, что делает этот код: "привяжи значение 5 к x; затем сделай копию значения в x и привяжи его к y". Теперь у нас есть две переменные: x и y, и обе равны 5. Именно так всё и происходит, потому что целые числа — это простые значения с известным фиксированным размером, так что эти два значения 5 помещаются в стек.

Теперь посмотрим на версию с типом String

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

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

Взгляните на Рисунок 4-1, чтобы понять, что со String происходит под капотом. String состоит из трёх частей (показаны слева): указатель на память, в которой хранится содержимое строки; длина; ёмкость. Эта группа данных хранится на стеке. Справа — память в куче, которая содержит сам текст.

Две таблицы: первая содержит представление s1в
стеке, содержащее её длину (5), ёмкость (5) и указатель на первое
значение во второй таблице. Вторая таблица содержит представление содержимого
строки в куче, байт за байтом.

Рисунок 4-1: Представление в памяти значения типа String, содержащее значение "hello" и связанное с s1

Длина — это объём памяти в байтах, который в настоящее время использует содержимое String. Ёмкость — это общий объём памяти в байтах, который String получил от распределителя. Разница между длиной и ёмкостью имеет значение, но не в этом контексте, поэтому на данный момент можно игнорировать ёмкость.

Когда мы присваиваем s1 значению s2, данные String копируются: под этим имеется в виду, что мы копируем указатель, длину и ёмкость, которые находятся в стеке. Мы не копируем данные в куче, на которые указывает указатель. Иными словами, вид данных в памяти выглядит так, как показано на Рисунке 4-2.

Три таблицы: таблицы s1 и s2 предсталяют эти строки на
стеке, соответственно, и обе указывают на один и тот же текст в куче.

Рисунок 4-2: Вид памяти переменной s2, имеющей копию указателя, длины и ёмкости s1

Вид памяти не будет похож на Рисунок 4-3: так выглядела бы память, если бы вместо этого Rust также копировал данные кучи. Если бы Rust делал это, операция s2 = s1 могла бы быть очень дорогой с точки зрения производительности исполнения, если бы копируемые данные в куче были большими.

Четыре таблицы: две таблицы представляют данные s1 и s2 на стеке,
и каждая указывает на собственную копию текста в куче.

Рисунок 4-3: Другой вариант того, что могла бы делать операцияs2 = s1, если бы Rust также копировал данные кучи

Ранее мы сказали, что когда переменная выходит за пределы области видимости, Rust автоматически вызывает функцию drop и очищает память данной переменной, выделенную в куче. Но на Рисунке 4-2 оба указателя данных указывают на одно и то же место. Это проблема: когда переменные s2 и s1 будут выходить из области видимости, они обе будут пытаться освободить одну и ту же память. Это известно как ошибка двойного освобождения — она является одной из ошибок безопасности памяти, упоминаемых ранее. Повторное свобождение памяти может привести к повреждению памяти, что потенциально может привести к уязвимостям.

Чтобы обеспечить безопасность памяти, после строки let s2 = s1; Rust считает s1 более не инициализированной. Следовательно, Rust не нужно ничего освобождать, когда s1 выходит из области видимости. Посмотрите, что произойдёт, если вы попытаетесь использовать s1 после создания s2:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

Вы получите ошибку как ту, что ниже, поскольку Rust не позволит вам использовать недействительную ссылку:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:15
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |               ^^^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Если вы слышали про термины поверхностное копирование и глубокое копирование, если работали с другими языками, концепция копирования указателя, длины и ёмкости без копирования данных, вероятно, звучит как создание поверхностной копии. Но поскольку Rust также деинициализирует первую переменную, это называется не поверхностным копированием, а перемещением. В примере выше мы бы сказали, что s1 была перемещён в s2. В конечном счёте, истинная картина происходящего показана на Рисунке 4-4.

Три таблицы: таблицы s1 и s2 представляют эти строки на
стеке, соответственно, и обе указывают на один и тот же текст в куче.
Таблица s1 серая, по-скольку s1 больше не действительна; лишь s2 может быть использована для
обращения к данным в куче.

Рисунок 4-4: Вид памяти после того, как s1 была деинициализирована

Это решает нашу проблему! Действительной остаётся только переменная s2. Когда она покинет область видимости, то лишь она одна будет освобождать память в куче.

Этот порядок работы с памятью даёт ещё одно преимущество: Rust никогда не будет автоматически создавать "глубокие" копии ваших данных. Следовательно, любое автоматическое копирование, связанное и перемещением, можно считать недорогим с точки зрения производительности.

Область видимости и присвоение

The inverse of this is true for the relationship between scoping, ownership, and memory being freed via the drop function as well. When you assign a completely new value to an existing variable, Rust will call drop and free the original value’s memory immediately. Consider this code, for example:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

Сначала мы объявляем переменную s и связываем её со значением "hello" типа String. Затем мы немедленно создаем новую строку типа String со значением "ahoy" и присваиваем её переменной s. После этого ничто больше не ссылается на изначальные данные в куче.

Одна таблица, s, представляет данные строки на стеке, указывающие на
вторую часть данных строки (“ahoy”) в куче, вместе с изначальным текстом
строки (“hello”) выкрашенными в серый, поскольку он больше никому не доступен.

Рисунок 4-5: Вид памяти после того, как начальное значение было полностью заменено, вытеснено новым.

Оригинальная строка, в связи с этим, в этот же момент покинула область видимости. Rust вызовет на ней функцию drop и высвободит её память. Когда исполнение дойдёт до строчки печати, будет выведено "ahoy, world!".

Взаимодействие переменных и данных с помощью клонирования

Если мы всё же хотим глубоко скопировать данные String в куче, а не только стека, мы можем использовать часто реализуемый метод, называемый clone. Мы обсудим синтаксис метода в Главе 5, но поскольку методы являются общей чертой многих языков программирования, вы, вероятно, уже знакомы с ними.

Вот пример работы метода clone:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

Это отлично работает и, очевидно, приводит к поведению, показанному на Рисунке 4-3, где данные кучи были скопированы.

Если вы видите вызов clone, вы сразу можете понять, что исполняемый здесь некоторый код наверняка будет затратным. В то же время, использование clone является маркером того, что тут происходит что-то необычное.

Данные, размещающиеся только на стеке, всегда копируются

Это ещё одна особенность, о которой мы ранее не говорили. Этот код, часть которого мы ранее показали в Листинге 4-2, полностью корректен:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

Но в то же время этот код, кажется, противоречит тому, что мы только что узнали: мы не вызываем clone, но x остаётся действительна и её значение не перемещается в y.

Причина в том, что такие типы, как целые числа, размер которых известен во время компиляции, полностью хранятся на стеке, поэтому копии фактических значений создаются быстро. Это означает, что нет причин, по которым мы хотели бы предотвратить доступность x после того, как создадим переменную y. Иными словами, для таких типов нет разницы между глубоким и поверхностным копированием, поэтому вызов clone ничем не отличается от обычного поверхностного копирования, и мы можем его опустить.

В Rust есть специальная аннотация, называемая трейтом Copy, которую мы можем приписывать типам, хранящимся на стеке: как это сделано для целых чисел (подробнее о трейтах мы поговорим в Главе 10). Если тип реализует трейт Copy, переменные, к нему принадлежащие, не перемещаются при присваивании, а просто копируются, что оставляет их действительными после присвоения другой переменной.

Rust не позволит нам аннотировать тип с помощью Copy, если тип или любая из его частей реализует трейт Drop. Если для типа нужно, чтобы произошло что-то особенное, когда значение выходит за пределы области видимости, и мы добавляем аннотацию Copy к этому типу, мы получим ошибку компиляции. Чтобы узнать, как добавить аннотацию Copy к вашему типу для реализации трейта, посмотрите раздел "Выводимые трейты" в Приложении C.

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

  • Все целочисленные типы, такие как u32.
  • Логический тип данных bool, возможные значения которого — true и false.
  • Все типы чисел с плавающей точкой, такие как f64.
  • Символьный тип char.
  • Кортежи; но только если они состоят только из типов, которые также реализуют Copy. Например, (i32, i32) реализует Copy, но кортеж (i32, String) — нет.

Владение и функции

Механизм передачи функции значения схож с тем, что происходит при присвоении переменной значения. Передача переменной в функцию приведёт к перемещению или копированию ровно так же, как при присваивании. В Листинге 4-3 есть пример с некоторыми комментариями, поясняющими, где переменные входят в область видимости и где выходят из неё.

fn main() {
    let s = String::from("hello");  // s входит в область видимости

    takes_ownership(s);             // значение s перемещается в функцию...
                                    // ... а потому оно здесь больше не доступно

    let x = 5;                      // x входит в область видимости

    makes_copy(x);                  // поскольку i32 реализует трейт Copy,
                                    // x НЕ переместится в функцию,
    println!("{}", x);              // так что далее всё ещё можно использовать x

} // Здесь x покидает область видимости, вслед за ней — и s. Но поскольку значение s ранее было перемещено, ничего
  // критического не происходит.

fn takes_ownership(some_string: String) { // some_string входит в область видимости
    println!("{some_string}");
} // Здесь some_string покидает область видимости и вызывается `drop`. Выделенная ей
  // память высвобождается.

fn makes_copy(some_integer: i32) { // some_integer входит в область видимости
    println!("{some_integer}");
} "// Здесь some_integer покидает область видимости. Ничего критического не происходит.

Если попытаться использовать s после вызова takes_ownership, Rust выбросит ошибку компиляции. Такие статические проверки защищают нас от опечаток. Попробуйте в main добавить код, который использует переменные s и x, чтобы увидеть, где их разрешено использовать и где правила владения предотвращают их использование.

Возвращение значений и область видимости

Возвращение значений также может сопровождаться передачей владения. В Листинге 4-4 показан пример функции, возвращающей некоторое значение, с подобными же комментариями, как в Листинге 4-3.

fn main() {
    let s1 = gives_ownership();        // gives_ownership перемещает своё возвращаемое значение
                                       // в s1

    let s2 = String::from("hello");    // s2 входит в область видимости

    let s3 = takes_and_gives_back(s2); // s2 перемещается в
                                       // takes_and_gives_back, которая также
                                       // перемещает в s3 своё возвращаемое значение
} // Здесь s3 покидает область видимости и её память высвобождается. s2 перемещена: ничего
  // не происходит. s1 покидает область видимости и её память высвобождается.

fn gives_ownership() -> String {       // gives_ownership перемещает своё
                                       // возвращаемое значение в то,
                                       // что вызвало её

    let some_string = String::from("твоё"); // some_string входит в область видимости

    some_string                        // some_string возвращается из функции и 
                                       // перемещается в то, что вызвало
                                       // функцию
}

Эта функция принимает String и возвращает String.fn takes_and_gives_back(a_string: String) -> String {
    // a_string входит в область
    // видимости

    a_string  // a_string возвращается из функции перемещается в то, что вызвало функцию
}

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

Хотя всё исправно работает, получать владение, а затем возвращать его из каждой функцией довольно утомительно. Что, если мы хотим, чтобы функция использовала значение, но не становилась владельцем? Очень раздражает, что всё, что мы передаём, также должно быть передано обратно, если мы хотим использовать это снова (помимо любых вычисленных в теле функции данных, которые мы также можем хотеть вернуть).

Rust позволяет нам возвращать из функции несколько значений, используя кортеж, как показано в Листинге 4-5.

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("Длина '{s2}' равна {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() возвращает длину String

    (s, length)
}

Но это слишком многословно и излишне для методики, которая претендует на универсальность и всеприменимость. К счастью, в Rust есть способ использовать значение без передачи владения — ссылки.