Ссылки вы уже умеете делать. Но «Копилке» нужны не только числа, а ещё и текст: команда добавить 350 еда обед приходит одной строкой, и её нужно разрезать на кусочки. В этом уроке мы научимся смотреть на часть данных, не копируя её, и разберёмся, почему с русским текстом в Rust надо обращаться бережно.
Срез: ссылка на часть
Представьте длинный кассовый чек. Чтобы показать коллеге три нужные строки, вы не перепечатываете их на новый листок, а обводите рамкой кусок чека и говорите: «Смотри только сюда». Рамка не создаёт новых данных, она лишь показывает на часть уже существующих.
Именно так работает срез (slice) — ссылка на несколько элементов, лежащих подряд. Для массива чисел u32 тип среза пишется &[u32], то есть «ссылка на какое-то количество чисел u32 подряд». Срез берут записью &имя[от..до]: номер «от» входит в срез, номер «до» — нет. Если опустить «от», срез начнётся с начала (&a[..2]), если опустить «до» — дойдёт до конца (&a[2..]).
fn main() {
let amounts = [350, 1200, 99, 40];
let middle = &amounts[1..3];
println!("Середина: {:?}", middle);
println!("Длина среза: {}", middle.len());
}
Середина: [1200, 99]
Длина среза: 2
Разбор по строкам
let middle = &amounts[1..3];— рамка вокруг элементов с номерами 1 и 2 (номер 3 уже не входит). Это и есть срез, в переменной лежит ссылка, а не копия чисел.middle.len()— длина среза — два элемента. У массива и у среза методlen()один и тот же.
Главная польза среза в функциях. Функция, которая принимает &[u32], работает с массивом любой длины и с любым его кусочком. Раньше total считала ровно три траты, теперь напишем на любое число.
fn sum(amounts: &[u32]) -> u32 {
let mut result = 0;
for i in 0..amounts.len() {
result += amounts[i];
}
result
}
fn main() {
let amounts: [u32; 4] = [350, 1200, 99, 40];
println!("Все: {}", sum(&amounts));
println!("Первые две: {}", sum(&amounts[0..2]));
println!("Последние две: {}", sum(&amounts[2..]));
}
Все: 1689
Первые две: 1550
Последние две: 139
Разбор по строкам
fn sum(amounts: &[u32]) -> u32— принимаем срез: ссылку на любое количество чисел подряд, возвращаем одно число.for i in 0..amounts.len()— знакомый цикл по номерам: от 0 до длины среза (саму длину не включаем).result += amounts[i];— берём элемент по номеру, как у массива, и прибавляем к накопленной сумме.sum(&amounts)— ссылка на весь массив тоже подходит там, где ждут срез.sum(&amounts[0..2])— первые две траты: 350 + 1200 = 1550. Срез[2..]даёт хвост: 99 + 40 = 139.
Срез ничего не копирует
Внутри среза хранятся лишь начало и длина, поэтому он дешёвый, как закладка в книге.
&str и String: два вида строк
Строки в Rust бывают двух видов, и это самая частая путаница новичков. Сравним с тетрадями. String — ваша личная тетрадь: записи принадлежат вам, их можно дописывать и стирать. &str — окошко, через которое вы читаете чью-то тетрадь: смотреть можно, владеть и менять нельзя.
Официально: String — строка-владелец. Она лежит в куче и умеет расти. &str (читается «стр-срез») — строковый срез: ссылка на кусок текста, который лежит где-то ещё. Текст в кавычках в коде, "еда", тоже имеет тип &str, точнее &'static str: он вшит в саму программу и живёт, пока программа работает.
Перевести &str в String, то есть сделать собственную копию, можно через "еда".to_string() или String::from("еда"). Обратно ничего конвертировать не нужно: из String срез берётся ссылкой, ¬e или ¬e[0..4].
fn note_len(text: &str) -> usize {
text.len()
}
fn main() {
let note = String::from("обед");
println!("{}", note_len(¬e));
println!("{}", note_len("обед"));
println!("{}", note_len(¬e[0..4]));
}
8
8
4
Разбор по строкам
fn note_len(text: &str) -> usize— функция просто читает текст, поэтому берёт&str. Результат — число,usize(размер или номер; тип для длин).let note = String::from("обед");— строка-владелец; нужна, чтобы показать, что&strпринимает и её.note_len(¬e)— ссылка наStringсама превращается в&str.note_len("обед")— литерал уже и есть&str, ничего делать не нужно.note_len(¬e[0..4])— срезString: первые четыре байта (почему байта, — в следующем разделе).
Почему в параметрах берём &str, а не &String
Попробуем написать то же самое с &String в параметре.
fn note_len(text: &String) -> usize {
text.len()
}
fn main() {
let note = String::from("обед");
println!("{}", note_len(¬e));
println!("{}", note_len("обед"));
}
error[E0308]: mismatched types
--> src/main.rs:8:29
|
8 | println!("{}", note_len("обед"));
| -------- ^^^^^^ expected `&String`, found `&str`
| |
| arguments to this function are incorrect
|
= note: expected reference `&String`
Функция с &String согласна только на строки-владельцы, а литерал ей не подходит. Функция с &str берёт и то и другое. Поэтому правило простое: если функции нужно только прочитать текст, пишите в параметре &str.
UTF-8: почему кириллица особенная
Компьютер хранит текст байтами. Английская буква занимает один байт, а русская — два. Это как посылки: при перевозке считают вес, а не количество вещей внутри. Метод len() у строки в Rust считает именно этот «вес» в байтах, а не буквы.
Официально: строки в Rust хранятся в кодировке UTF-8, символ занимает от одного до четырёх байт. Число символов (char) считается отдельно: text.chars().count(). Метод chars() выдаёт символы по одному, а count() считает, сколько их выдано.
fn main() {
let word = "еда";
println!("Байт: {}", word.len());
println!("Букв: {}", word.chars().count());
let latin = "food";
println!("Байт: {}, букв: {}", latin.len(), latin.chars().count());
}
Байт: 6
Букв: 3
Байт: 4, букв: 4
| Строка | len() — байт | chars().count() — букв |
|---|---|---|
"food" | 4 | 4 |
"еда" | 6 | 3 |
Теперь главное следствие. Номера в срезе строки — это номера байтов, а не букв. Режем так, чтобы граница попала между символами (в документации это называется char boundary). Если попасть внутрь буквы, программа остановится аварийно, это называется паникой.
fn main() {
let word = "еда";
println!("{}", &word[0..2]);
println!("{}", &word[2..]);
println!("{}", &word[0..1]);
}
е
да
thread 'main' (2058313) panicked at src/main.rs:5:25:
end byte index 1 is not a char boundary; it is inside 'е' (bytes 0..2 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Разбор по строкам
&word[0..2]— байты 0 и 1 — это ровно буква «е», срез получился. Печатаетсяе.&word[2..]— всё после первой буквы: «да».&word[0..1]— граница посреди буквы «е» (она занимает байты 0 и 1). Паника с сообщениемend byte index 1 is not a char boundary. Номер в скобках после словаthread 'main'и строка с номером у вас будут другие.
Не режьте русский текст по номерам
Поэтому в «Копилке» мы режем строку по пробелам: пробел занимает ровно один байт и всегда стоит на границе символов.
Методы строк, которые пригодятся
У строк много готовых методов. Нам сейчас нужны вот эти:
| Метод | Что делает | Что возвращает |
|---|---|---|
trim() | убирает пробелы по краям | &str (кусок исходной строки) |
split_whitespace() | делит текст на слова | слова по очереди |
to_lowercase() | делает буквы строчными | новая String |
starts_with("доб") | начинается ли строка с этого куска | bool |
contains("350") | есть ли кусок внутри | bool |
to_string() | делает из &str собственную String | новая String |
format!(...) | как println!, но собирает строку, а не печатает | новая String |
fn main() {
let line = " Добавить 350 еда обед в столовой ";
let clean = line.trim();
println!("[{}]", clean);
println!("{}", clean.to_lowercase());
println!("{}", clean.starts_with("Доб"));
println!("{}", clean.contains("350"));
let note = format!("{} руб., {}", 350, "еда");
println!("{}", note);
let owned = "еда".to_string();
println!("{}", owned);
}
[Добавить 350 еда обед в столовой]
добавить 350 еда обед в столовой
true
true
350 руб., еда
еда
Разбор по строкам
clean.to_lowercase()— создаёт новую строку, потому что у строчной версии может измениться длина в байтах; поэтому результат —String.clean.starts_with("Доб")— начинается ли строка с «Доб»; регистр важен.format!("{} руб., {}", 350, "еда")— то же, чтоprintln!, но результат остаётся в переменной."еда".to_string()— копия литерала в видеString.
Шаг проекта: первое слово и остаток
Команда пользователя выглядит как добавить 350 еда обед в столовой. Первое слово — что делать, остальное — подробности. Сделаем две функции с таким смыслом. Обе берут &str и возвращают &str — кусок исходной строки, без копирования.
fn first_word(line: &str) -> &str {
line.split_whitespace().next().unwrap_or("")
}
fn rest_after_first(line: &str) -> &str {
let line = line.trim();
match line.find(' ') {
Some(i) => {
let tail = &line[i + 1..];
tail.trim_start()
}
None => "",
}
}
fn main() {
let line = "добавить 350 еда обед";
println!("[{}]", first_word(line));
println!("[{}]", rest_after_first(line));
println!("[{}] [{}]", first_word("помощь"), rest_after_first("помощь"));
println!("[{}] [{}]", first_word(""), rest_after_first(" "));
}
[добавить]
[350 еда обед]
[помощь] []
[] []
Разбор по строкам
fn first_word(line: &str) -> &str— читаем строку, возвращаем её кусок. Компилятор сам видит, что результат — часть аргумента.line.split_whitespace().next()— делим по пробелам и берём первое слово. Результат — «слово или ничего»: если строка пуста, слова нет..unwrap_or("")— одна фраза: «если слова нет, возьми пустую строку». Подробно — в модуле про перечисления.let line = line.trim();— новая переменная с тем же именем затеняет параметр: дальше работаем с обрезанной версией.match line.find(' ')—findищет пробел и отвечает «число или ничего»:Some(i)— пробел найден на байтеi,None— пробела нет.let tail = &line[i + 1..];— срез всего, что идёт после пробела. Резать здесь безопасно: пробел занимает один байт, иi + 1ровно граница следующего символа.tail.trim_start()— убирает лишние пробелы слева, если между словами их было несколько.None => ""— пробела нет, значит остатка нет: возвращаем пустую строку.
Практика: добавляем разбор в Копилку
Работаем в проекте kopilka, файл src/main.rs.
Шаг 1. Принимаем &str
Сейчас print_note принимает &String. Замените параметр на &str; вызовы print_note(¬e) менять не нужно:
fn print_note(note: &str) {
println!("Заметка: {note}");
}
Шаг 2. Две новые функции
Вставьте first_word и rest_after_first над fn main, рядом с остальными функциями:
fn first_word(line: &str) -> &str {
line.split_whitespace().next().unwrap_or("")
}
fn rest_after_first(line: &str) -> &str {
let line = line.trim();
match line.find(' ') {
Some(i) => {
let tail = &line[i + 1..];
tail.trim_start()
}
None => "",
}
}
Шаг 3. Проверка в main
Строка let note = String::from("обед"); и два вызова print_note(¬e); у вас уже есть в начале main. Сразу после них, до объявления spent, добавьте вызов с литералом и строки, которые разбирают тестовую команду на части:
print_note("кофе");
let sample = " добавить 350 еда обед в столовой ";
let rest = rest_after_first(sample);
println!("Команда: [{}]", first_word(sample));
println!("Остаток: [{}]", rest);
println!("Сумма: [{}]", first_word(rest));
let rest = rest_after_first(rest);
println!("Категория: [{}]", first_word(rest));
println!("Заметка: [{}]", rest_after_first(rest));
Шаг 4. Запуск
Выполните в папке проекта:
cargo run
Вводить можно сразу стоп: нас интересуют строки после шапки и двух строк «Заметка: обед». Они должны быть такими (первая — от print_note("кофе")):
Заметка: кофе
Команда: [добавить]
Остаток: [350 еда обед в столовой]
Сумма: [350]
Категория: [еда]
Заметка: [обед в столовой]
Разбор по строкам
print_note("кофе");— литерал подходит, потому что параметр —&str.let rest = rest_after_first(sample);— остаток после команды:350 еда обед в столовой.let rest = rest_after_first(rest);— новаяrestзатеняет старую: внутри всё после суммы. Категорию и заметку достаём тем же приёмом.
Проверьте себя
- Что напечатает
first_word(" ")и что —rest_after_first("помощь")? Проверьте, угадали ли. - Допишите в
mainстрокуprintln!("{}", &sample[0..1]);. Что произошло? - Уберите
&в вызовеprint_note(¬e). Что скажет компилятор и что предложит исправить? - Предскажите, а потом проверьте: сколько байт и сколько букв в
"Копилка"?
Частые ошибки
Срез посреди русской буквы
Что видно: программа запускается и падает.
thread 'main' panicked at src/main.rs:5:25:
end byte index 1 is not a char boundary; it is inside 'е' (bytes 0..2 of string)
Почему: номера в срезе считаются в байтах, а русская буква занимает два. Как исправить: не режьте по готовым числам, ищите пробелы через find(' ') или берите слова через split_whitespace().
&String вместо &str в параметре
Что видно: error[E0308]: mismatched types ... expected &String, found &str. Как исправить: замените &String на &str, остальное менять не нужно.
Передали String без &
error[E0308]: mismatched types
--> src/main.rs:3:25
|
3 | let size = note_len(note);
| -------- ^^^^ expected `&str`, found `String`
| |
Почему: мы отдаём строку целиком, а функция ждёт ссылку. Как исправить: добавьте &, как и подсказывает help: consider borrowing here.
len() считают буквами
Что видно: ошибки нет, но для «еда» получается 6 вместо 3. Почему: len() считает байты. Как исправить: для числа букв используйте chars().count().
Что важно запомнить
- Срез
&a[от..до]— ссылка на кусок подряд идущих элементов; «до» не входит, копирования нет. - Функция с параметром
&[u32]работает с массивом любой длины и с его частями. String— владелец,&str— ссылка на кусок текста. Литерал в кавычках имеет тип&'static str.- Если функция только читает текст, пишите в параметре
&str: ей подойдут и литерал, и&String. - Строки хранятся в UTF-8: русская буква занимает 2 байта.
len()считает байты,chars().count()— символы. - Номера в срезе строки — байтовые. Попадание внутрь символа вызывает панику
is not a char boundary. Режем по пробелам, а не по числам. - Полезные методы:
trim,split_whitespace,to_lowercase,starts_with,contains,to_string,format!. - В «Копилке» теперь есть
first_wordиrest_after_first.
Две функции готовы. В следующем уроке разберём команду «добавить 350 еда обед в столовой» на части целиком.