Avatar ·

Почему в Rust не рекомендуется принимать &String, &Vec или &Box в качестве аргументов функций?

Всем привет! Я начал изучать Rust недавно и заметил странную вещь. В некоторых примерах кода я вижу, что функции принимают параметры как `&String`, `&Vec` или `&Box`. Это выглядит логично — ведь мы хотим передать ссылку, чтобы не копировать данные. Но потом я наткнулся на рекомендацию, что так делать не стоит. Почему? Ведь это же просто ссылки! Вот пример, который я написал: ```rust fn print_string(s: &String) { println!("{}", s); } fn print_vec(v: &Vec) { for i in v { println!("{}", i); } } fn main() { let my_string = String::from("Hello"); let my_vec = vec![1, 2, 3]; print_string(&my_string); print_vec(&my_vec); } ``` Этот код работает, но я слышал, что лучше использовать `&str` вместо `&String` и `&[T]` вместо `&Vec`. Зачем? Ведь `&String` и `&Vec` — это уже ссылки, они не владеют данными. В чём подвох? Неужели есть разница в производительности или в чём-то ещё? Объясните, пожалуйста, простыми словами, почему это считается плохой практикой и в каких случаях всё-таки можно использовать такие ссылки. <<>>

Проблема: излишнее ограничение и потеря гибкости

Основная причина, по которой не рекомендуется принимать &String, &Vec или &Box — это нарушение принципа универсальности. Когда вы пишете функцию, которая принимает &String, вы автоматически требуете, чтобы вызывающий код владел именно String, а не, например, &str (строковый срез) или String, полученную из другой библиотеки.

В Rust тип String — это владеющий тип (owned type), а &str — это заимствованный срез. Если ваша функция принимает &String, то для передачи строкового литерала или среза вам придётся сначала создать String, что приводит к ненужному копированию данных в кучу. Сравните:

// Плохо: принимает только &String
fn print_string(s: &String) {
    println!("{}", s);
}

// Хорошо: принимает &str
fn print_str(s: &str) {
    println!("{}", s);
}

fn main() {
    let owned = String::from("hello");
    let slice = "world"; // это &str
    
    print_string(&owned); // работает
    // print_string(slice); // ОШИБКА: ожидается &String, получен &str
    
    print_str(&owned); // работает (автоматическое приведение)
    print_str(slice);  // работает
}

То же самое справедливо для Vec и Box. &Vec<T> можно заменить на &[T] (срез), а &Box<T> — на &T.

Почему это работает? Автоматическое приведение (Deref coercion)

В Rust реализован механизм Deref coercion (приведение через трейт Deref). Благодаря ему:

  • &String автоматически преобразуется в &str
  • &Vec<T> — в &[T]
  • &Box<T> — в &T

Это значит, что если ваша функция принимает &str, вы можете передать ей как строковый срез, так и ссылку на String. Вы получаете максимальную гибкость без потери производительности.

Когда можно использовать &String или &Vec?

Есть два законных случая:

  1. Когда вам нужно изменить содержимое через метод, который принимает &mut self и определён только для конкретного типа. Например, Vec::push работает только с &mut Vec, а не со срезом. Но в этом случае вы принимаете &mut Vec, а не &Vec.
  2. Когда вы пишете обобщённый код с трейтами. Например, если вам нужно потребовать, чтобы тип реализовывал AsRef<str> или Deref<Target=str>.

Производительность

С точки зрения производительности &String и &str идентичны — оба занимают 8 байт (указатель + длина в случае среза, или указатель + ёмкость + длина в случае String?). На самом деле &String — это ссылка на структуру из трёх полей (ptr, len, cap), а &str — это fat pointer (ptr + len). Но разница в косвенности минимальна и не является главной причиной.

Главная причина — это эргономика и совместимость. Используя &str или &[T], вы делаете свою функцию пригодной для большего числа ситуаций, не требуя от вызывающего кода лишних преобразований.

Итог

Всегда предпочитайте:

  • &str вместо &String
  • &[T] вместо &Vec<T>
  • &T вместо &Box<T>

Исключение — если вам действительно нужен доступ к специфическим методам владеющего типа (например, Vec::capacity или String::as_mut_vec), но такие случаи редки.

<<>>

Альтернативный взгляд: трейты и обобщения

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

use std::fmt::Display;

fn print_it(s: impl Display) {
    println!("{}", s);
}

fn main() {
    let my_string = String::from("hello");
    let my_str = "world";
    let my_i32 = 42;
    
    print_it(&my_string); // работает
    print_it(my_str);     // работает
    print_it(my_i32);     // тоже работает!
}

Этот подход ещё более универсален, чем &str, потому что позволяет передавать любые типы, реализующие Display. Однако он может быть избыточным, если вам нужны только строки или срезы.

Кроме того, вы можете использовать трейт AsRef:

fn print_as_ref(s: impl AsRef<str>) {
    println!("{}", s.as_ref());
}

fn main() {
    let s = String::from("hello");
    print_as_ref(s);      // работает
    print_as_ref("world"); // работает
}

Это особенно полезно, когда вы пишете библиотеки и хотите, чтобы пользователи могли передавать как String, так и &str без лишних преобразований. Но для большинства простых случаев &str и &[T] — это золотая середина между гибкостью и читаемостью кода.