Skip to content

Latest commit

 

History

History
203 lines (150 loc) · 12.9 KB

File metadata and controls

203 lines (150 loc) · 12.9 KB

Ссылочные и владеюшие типы в аргументах функций

Система типов в 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-- ссылка на T
  • MutexGuard<'_, T> -- ссылка на T, с захваченой блокировкой мьютекса. (Это также пример типа, который является ссылочным по отношению к T, но при этом владеющим по отношению к блокировке -- он ответственен за освобождение блокировки (вызов mutex_unlock))

Общее свойство всех [безопасных]1 ссылочных типов -- у них есть параметр: время жизни (lifetime) -- область видимости, где ссылка остается валидной

Далее я позволю себе опускать lifetime-параметр, где возможно, поскольку во многих случаях в коде его явное указание не требуется

Из примеров выше, очевидно, существует проблема: &String и &str -- два варианта ссылки на строку. &Vec<T> и &[T] -- два варианта ссылки на вектор

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

У многих Owned типов в Rust есть ассоцированные с ними Borrowed<'_> типы, которые предпочтительно использовать вместо &Owned, если нужен read-only / shared доступ к их ресурсам.

В чем проблема &Owned ссылки?

Рассмотрим такой простой пример:

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

  1. Сырые указатели *const T, *mut T, обертки над ними NonNull<T> -- тоже являются ссылочными типами. Они свободны от вездесущих времен жизни. Однако для работы с ними уже требуется unsafe {}. Нет lifetime параметра -- полномочия компилятора по проверке их валидности на этом заканчиваются.

  2. Лишнее копирование и аллокация памяти в общем случае оказывается нежелательным и приводит к трате ресурсов CPU. Однако существуют случаи, в которых лишнее копирование совсем не лишнее: например, если на вход функции предоставлена ссылка на большой участок памяти, аллоцированном на другом NUMA (Non-uniform-memory-access) узле. Тогда внутри функции, перед множественными проходами по этой памяти, может быть выгодно аллоцировать копию на локальном NUMA узле.

  3. Возьмем JavaScript: new String("hello world") -- аллоцирует ли конструктор String-обертки новый буфер под копию строки или же просто берет ссылку на существующий константный литерал? Я не знаю. Нужно смотреть конкретную реализацию. Эта деталь реализации никак не влияет на обозреваемое поведение (кроме производительности)

  4. А еще 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, тут нужно чувствовать.