сеньорчикОткрыть в Telegram
← вся теориятеория к собесу

Строки в Rust: String и &str

String и &str

Строки - первое место, где новичок в Rust спотыкается. Спрашивают, чем String отличается от &str, почему s[0] не компилируется и чему равен len у русского слова. За этим стоит одна проверка: понимаешь ли ты, что строка здесь это UTF-8-байты, а не массив символов.

Стержень: String владеет буфером в куче и растёт, &str - заимствованный вид на кусок UTF-8; длина считается в байтах, а доступ по индексу запрещён намеренно.

// Формулировки: «в чём разница String и &str?», «чему равен len у „привет“?», «почему нельзя взять s[0]?»

Владеющая и заимствованная

String это три слова: указатель на буфер в куче, длина и ёмкость. Она владеет данными, умеет расти, и при выходе владельца из области видимости буфер освобождается. &str - два слова: указатель и длина, без ёмкости и без владения. Он смотрит либо внутрь String, либо на литерал, вшитый в бинарник.

Отсюда идиома API: аргументы принимай как &str, возвращай String. Такая сигнатура берёт и литерал, и срез чужой строки - благодаря Deref-коэрции &String автоматически приводится к &str на месте аргумента. Обратного приведения нет: чтобы получить владение, строку создают явно.

fn greet(name: &str) -> String {
    format!("привет, {name}")
}
let owned = String::from("мир");
greet(&owned); // &String -> &str
&str
заимствованный срез UTF-8: указатель и длина
Deref-коэрция
автоматическое приведение &String к &str

Байты, символы и границы

len() возвращает число байт: у «привет» это 12, потому что каждая кириллическая буква в UTF-8 занимает два байта. Символы считает chars().count(), и это уже проход по всей строке. Даже он не отвечает на вопрос «сколько знаков видит человек»: e с комбинирующим акутом - два char и один видимый символ.

Индексации s[0] нет по той же причине: вернуть байт почти всегда не то, что имели в виду, а вернуть символ это O(n), и такую цену язык не прячет за квадратные скобки. Срез по диапазону разрешён, но обязан попадать на границы символов, иначе паника прямо в рантайме.

// Проверено на компиляторе: &"привет"[0..1] падает с «byte index 1 is not a char boundary». Безопасный вариант - get(0..1), он вернёт None.

let s = "привет";
s.len();            // 12
s.chars().count();  // 6
s.chars().next();   // Some('п')
char boundary
граница символа UTF-8; срез не по ней паникует
chars()
итератор по символам; count() стоит O(n)

Сборка строк без лишних аллокаций

Склейка в цикле через format! - типовая причина квадратичной работы: каждая итерация выделяет новый буфер и копирует туда всё накопленное. Оператор + ведёт себя лучше, он дописывает в уже существующую String, но ёмкость всё равно растёт скачками с перевыделением.

Правильный путь - String::with_capacity под ожидаемый размер и push_str. Один буфер, никакого копирования на каждом шаге. Если строка то и дело нужна как заимствованная, но иногда требует изменения, смотри в сторону Cow: он держит ссылку и копирует только там, где данные реально правятся.

push_str
дописывание в существующий буфер без новой аллокации
Cow
clone-on-write: заимствование, копия только при изменении

Как отвечать: «В чём разница между String и &str?»

String - владеющий буфер в куче: указатель, длина и ёмкость, умеет расти и освобождается вместе с владельцем. &str - заимствованный срез UTF-8, только указатель и длина; он смотрит внутрь String или на литерал в бинарнике. Практическое следствие для API: аргументы принимаю как &str, потому что туда подойдёт и литерал, и срез чужой строки, а возвращаю String, когда нужно отдать владение. И там, и там данные хранятся в UTF-8, поэтому len считает байты, а не символы.

Ты не пересказываешь определение, а сразу переводишь его в правило проектирования сигнатур, и заодно закрываешь вопрос про len, который обычно идёт следующим.

На чём валятся

  • Отвечают, что len() возвращает количество символов. Он возвращает байты UTF-8.
  • Говорят, что &str указывает только на литералы. Он может смотреть и внутрь String в куче.
  • Пытаются взять s[0] и объясняют запрет borrow checker'ом - на самом деле дело в UTF-8.
  • Режут строку по произвольному индексу и получают панику на кириллице.
  • Собирают строку через format! в цикле - на длинных данных это квадратичная работа.

Проверьте себя

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

  1. #rs_strings1 / 5
    Почему s[0] не компилируется для String и чем берут первый символ?
    A)Индексация запрещена из-за borrow checker; берут s.get_mut(0)
    B)Компилируется, но возвращает u8 — нужно привести к char через as
    C)Индексация по числу запрещена: байт — не символ; берут s.chars().next()
    D)Индексация есть только у &str; у String берут s.as_str()[0]
    показать ответ и разбор
    +C)Индексация по числу запрещена: байт — не символ; берут s.chars().next()

    // разбор: У str нет Index<usize>: вернуть байт было бы почти всегда не тем, что человек имел в виду, а вернуть символ — это O(n) по UTF-8, и такая цена не должна прятаться за квадратными скобками. Первый символ берут через chars().next(), байты — через as_bytes(), а срез s[0..2] возможен, но только по границам символов.

  2. #rs_strings2 / 5
    Что сделает этот код?
    let s = "привет";
    let part = &s[0..1];
    A)Не скомпилируется: срез строки требует usize-границ
    B)Паникует в рантайме: 1 — не граница символа
    C)Вернёт пустую строку: диапазон 0..1 не покрывает двухбайтовый символ
    D)Вернёт "п": срез берёт первый символ строки
    показать ответ и разбор
    +B)Паникует в рантайме: 1 — не граница символа

    // разбор: Срез строки режет по байтам, но обязан попадать на границы символов. Байт 1 находится внутри двухбайтовой «п», поэтому проверка в рантайме валит поток: byte index 1 is not a char boundary. Безопасные пути — chars(), char_indices() или get(0..1), который вернёт None вместо паники.

  3. #rs_strings3 / 5
    Почему функция fn f(s: &str) принимает и &String без явного преобразования?
    A)Работает Deref-коэрция: &String автоматически приводится к &str
    B)String и str — один тип с разными именами, приведение не нужно
    C)Компилятор молча клонирует String в новый &str
    D)Срабатывает трейт From, вызывая String::from на месте аргумента
    показать ответ и разбор
    +A)Работает Deref-коэрция: &String автоматически приводится к &str

    // разбор: String реализует Deref<Target = str>, и на месте аргумента компилятор применяет коэрцию &String → &str без затрат в рантайме. Поэтому идиома «принимай &str, возвращай String»: такая сигнатура берёт и литералы, и владеющие строки, а обратной коэрции нет — из &str String делают явно.

  4. #rs_strings4 / 5
    Собираем строку из 10 000 кусков в цикле. Какой способ даст меньше всего аллокаций?
    A)acc = acc + part в цикле
    B)acc = format!("{}{}", acc, part)
    C)parts.iter().fold(String::new(), |a, p| a + p)
    D)String::with_capacity + push_str
    показать ответ и разбор
    +D)String::with_capacity + push_str

    // разбор: push_str дописывает в уже выделенный буфер, а with_capacity убирает и промежуточные перевыделения при росте. Оператор + берёт String по значению и дописывает в него — это лучше format!, но ёмкость всё равно растёт скачками. format! каждый раз выделяет новый буфер и копирует накопленное, давая квадратичную работу.

  5. #rs_strings5 / 5
    Почему этот код не собирается?
    let s1 = String::from("прив");
    let s2 = s1 + "ет";
    println!("{} {}", s1, s2);
    A)Сложение строк требует format!: оператор + тут не работает
    B)s2 заимствует s1, и печать обеих в одном вызове конфликтует
    C)Оператор + берёт левую строку по значению, s1 перемещена
    D)Кириллица не складывается посимвольно, нужен push_str
    показать ответ и разбор
    +C)Оператор + берёт левую строку по значению, s1 перемещена

    // разбор: Сложение объявлено как Add<&str> для String и принимает self по значению: буфер левой строки переиспользуется и дописывается на месте, а старое имя становится недоступным — E0382, borrow of moved value. Если s1 нужна дальше, пишут s1.clone() + "ет" или format!("{}{}", s1, "ет"): второй вариант выделяет новый буфер и оригинал не трогает.

дальше

Теорию прочитали. Навык ставится повторением

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