Система типов в Rust довольно богата (хотя и не достаточно для знатоков теории типов) и позволяет моделировать довольно сложные для восприятия отношения между объектами, которых не существуют (за ненадобностью) в более высокоуровневых языках.
В частности, все в Rust построено вокруг семантики владения и заимствования:
Есть типы, которые моделируют объекты, управляющие (владеющие, owners) каким-то ресурсом:
File, OwnedFd-- управляют файловым дескриптором и отвечает за его закрытиеVec<T>-- управляет выделенной памятью под динамический массив объектов<T>; ответственнен за ее освобождениеString-- управляет выделенной памятью под utf-8 строку, освободит эту памятьBox<T>-- управляет памятью в куче, в которой размещен объектT.Mutex<T>-- управляет мьютексом и объектомT. Ответственен за деинициализацию мьютекса. И многие другие.
Владеющий объект отвечает за удаление/освобождением ресурса, когда он более не нужен.
А ненужен ресурс становится, когда покидает свою область видимости.
fn do_work() {
let v : Vec<u8> = vec![1,2,3,5];
// do something with v
// ..
// В конце области видимости v будет уничтожен (вызван drop(v))
// и v освободит память под массив
}А есть типы, которые моделирую ссылки (заимствования, borrows) на этот ресурс. Ссылки предоставляют доступ к ресурсу, но не занимаются его удалением.
&'_ File, &'_ OwnedFd, BorrowedFd<'_>-- ссылки на файл / файловый дескриптор.&'_ Vec<T>, &'_ [T]-- слайс / срез -- ссылка на непрерывную часть массива&'_ String, &'_ str-- ссылка на строку / подстроку&'_ T-- ссылка наTMutexGuard<'_, T>-- ссылка на T, с захваченой блокировкой мьютекса. (Это также пример типа, который является ссылочным по отношению к T, но при этом владеющим по отношению к блокировке -- он ответственен за освобождение блокировки (вызов mutex_unlock))
Общее свойство всех [безопасных]1 ссылочных типов -- у них есть параметр: время жизни (lifetime) -- область видимости, где ссылка остается валидной
Далее я позволю себе опускать lifetime-параметр, где возможно, поскольку во многих случаях в коде его явное указание не требуется
Из примеров выше, очевидно, существует проблема:
&String и &str -- два варианта ссылки на строку.
&Vec<T> и &[T] -- два варианта ссылки на вектор
При наличии более одного варианта, разумеется, возможен правильный и не совсем правильный выбор. И этот неправильный выбор довольно часто встречается на практике в коде новичков в Rust.
У многих Owned типов в Rust есть ассоцированные с ними Borrowed<'_> типы, которые предпочтительно использовать вместо &Owned, если нужен read-only / shared доступ к их ресурсам.
Рассмотрим такой простой пример:
fn count_words(sentence: &String) -> usize {
sentence.split_whitespace().count()
}
fn process_line(file_content: String) {
for (line_no, line) in file_content.split("\n").enumerate() {
println!("{} -> {}", line_no + 1, count_words(line));
}
}Мы получим ошибку компиляции:
error[E0308]: mismatched types
--> src/lib.rs:7:55
|
7 | println!("{} -> {}", line_no + 1, count_words(line));
| ----------- ^^^^ expected `&String`, found `&str`
| |
| arguments to this function are incorrect
Которую обычно решают прямолинейно в месте возниктовения ошибки:
println!("{} -> {}", line_no + 1, count_words(&line.to_string())); Опытным разработчикам должно быть очевидно, что это не очень хорошо2: мы каждый раз делаем лишнее копирование строки, чтоб превратить &str в &String. Разработчикам с поверхностным знакомством с системными языками это может быть не совсем очевидно. 3
Правильное решение -- изменить тип аргумента функции count_words:
// &String может быть неявно преобразован в &str, но не наоборот
fn count_words(sentence: &str) -> usize {
sentence.split_whitespace().count()
}Если у вас есть доступ к исходникам функции count_words, конечно. Иначе -- существует отвратительно жуткое решение, которое мы обсудим как-нибудь позже. Но оно столь ужасно, что лучше все-таки убедить мейнтейнера этой функции внести изменения.
Хорошо, это простой случай. Используем &str вместо &String, &[T] вместо &Vec<T>, BorrowedFd<'_> вместо &OwnedFd - и многие прочие случаи, о каждом из которых рекомендую почитать в соответствующей части документации. Ну или хотя бы попробовать спросить у ChatGPT.
Вам также может помочь clippy -- в последних версиях этот линтер выдает полезное предупреждение
warning: writing `&String` instead of `&str` involves a new object where a slice will do
--> src/lib.rs:1:26
|
1 | fn count_words(sentence: &String) -> usize {
| ^^^^^^^ help: change this to: `&str`
|
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.92.0/index.html#ptr_arg
= note: `#[warn(clippy::ptr_arg)]` on by default
Давайте усложним задачу. Послушавши рекомендацию использовать borrowed тип там где вам нужен лишь read-only доступ, вы написали следующую функцию:
fn process_text(lines: &[&str]) {
for line in lines {
println!("{line}");
}
}А ваш пользователь написал что-то такое в своем сетевом приложении
fn receive_line() -> Option<String> { todo!() }
fn receive_response() {
let mut lines = Vec::<String>::new();
while let Some(line) = receive_line() {
lines.push(line);
}
process_text(&lines)
}И получил ошибку компиляции
error[E0308]: mismatched types
--> src/lib.rs:17:18
|
17 | process_text(&lines)
| ------------ ^^^^^^ expected `&[&str]`, found `&Vec<String>`
| |
| arguments to this function are incorrect
|
= note: expected reference `&[&str]`
found reference `&Vec<String>`
Очевидное решение? Аллоцируем массив ссылок!
let lines = lines
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>();
process_text(&lines)Красиво? Не думаю. А также снова ненужные аллокации. В Rust можно лучше. Но нужно пойти дальше -- использовать обобщенные функции (generics)
Стандартная библиотека Rust имеет замечательный trait AsRef<T>4, который отлично подходит для решения подобных проблем: нам нужен массив чего-то на что можно взять &str ссылку
fn process_text(lines: &[impl AsRef<str>]) {
for line in lines {
let line = line.as_ref();
println!("{line}");
}
}
fn receive_line() -> Option<String> { todo!() }
fn receive_response() {
let mut lines = Vec::<String>::new();
while let Some(line) = receive_line() {
lines.push(line);
}
process_text(&lines)
}И вот, о чудо, оно компилируется безо всяких странных перекладываний ссылок в новую аллокацию со стороны пользователя.
С дженериками можно уйти очень далеко. И пожалеть. Но об этом в других частях.
Footnotes
-
Сырые указатели
*const T,*mut T, обертки над нимиNonNull<T>-- тоже являются ссылочными типами. Они свободны от вездесущих времен жизни. Однако для работы с ними уже требуетсяunsafe {}. Нет lifetime параметра -- полномочия компилятора по проверке их валидности на этом заканчиваются. ↩ -
Лишнее копирование и аллокация памяти в общем случае оказывается нежелательным и приводит к трате ресурсов CPU. Однако существуют случаи, в которых лишнее копирование совсем не лишнее: например, если на вход функции предоставлена ссылка на большой участок памяти, аллоцированном на другом NUMA (Non-uniform-memory-access) узле. Тогда внутри функции, перед множественными проходами по этой памяти, может быть выгодно аллоцировать копию на локальном NUMA узле. ↩
-
Возьмем JavaScript:
new String("hello world")-- аллоцирует ли конструктор String-обертки новый буфер под копию строки или же просто берет ссылку на существующий константный литерал? Я не знаю. Нужно смотреть конкретную реализацию. Эта деталь реализации никак не влияет на обозреваемое поведение (кроме производительности) ↩ -
А еще
Borrow<T>иDeref<Target = T>, которые также подходят в этом конкретном случае, и какой из них здесь стоит использовать -- вопрос теоретичеcкий, витающий в областях глубокой корректности и детальности выражения намерений, и немножечко даже религиозный. Например:fn open_file1(path: impl AsRef<Path>)-- Сюда я могу передать&str,&String,String,&Path,PathBuf,&PathBuf.fn open_file2(path: impl Borrow<Path>)-- A вот сюда ужеString,&Stringи&strне пройдут. Это Rust, тут нужно чувствовать. ↩