Строки в Rust: 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, остальные разбираются в тренажёре.
- Почему s[0] не компилируется для String и чем берут первый символ?A)Индексация запрещена из-за borrow checker; берут s.get_mut(0)B)Компилируется, но возвращает u8 — нужно привести к char через asC)Индексация по числу запрещена: байт — не символ; берут 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] возможен, но только по границам символов.
- Что сделает этот код?
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 вместо паники.
- Почему функция fn f(s: &str) принимает и &String без явного преобразования?A)Работает Deref-коэрция: &String автоматически приводится к &strB)String и str — один тип с разными именами, приведение не нужноC)Компилятор молча клонирует String в новый &strD)Срабатывает трейт From, вызывая String::from на месте аргумента
показать ответ и разбор
+A)Работает Deref-коэрция: &String автоматически приводится к &str// разбор: String реализует Deref<Target = str>, и на месте аргумента компилятор применяет коэрцию &String → &str без затрат в рантайме. Поэтому идиома «принимай &str, возвращай String»: такая сигнатура берёт и литералы, и владеющие строки, а обратной коэрции нет — из &str String делают явно.
- Собираем строку из 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! каждый раз выделяет новый буфер и копирует накопленное, давая квадратичную работу.
- Почему этот код не собирается?
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, "ет"): второй вариант выделяет новый буфер и оригинал не трогает.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.