$ sudo teach IT
Модуль 3 · Владение · Урок 3.3

Строки и срезы

Вы научитесь отличать String от &str, резать строку на слова и не ломать русский текст.

Теория + практика~30 минутСредний

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

Срез: ссылка на часть

Представьте длинный кассовый чек. Чтобы показать коллеге три нужные строки, вы не перепечатываете их на новый листок, а обводите рамкой кусок чека и говорите: «Смотри только сюда». Рамка не создаёт новых данных, она лишь показывает на часть уже существующих.

Именно так работает срез (slice) — ссылка на несколько элементов, лежащих подряд. Для массива чисел u32 тип среза пишется &[u32], то есть «ссылка на какое-то количество чисел u32 подряд». Срез берут записью &имя[от..до]: номер «от» входит в срез, номер «до» — нет. Если опустить «от», срез начнётся с начала (&a[..2]), если опустить «до» — дойдёт до конца (&a[2..]).

Rust · main.rs
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 считала ровно три траты, теперь напишем на любое число.

Rust · main.rs
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 срез берётся ссылкой, &note или &note[0..4].

Rust · main.rs
fn note_len(text: &str) -> usize {
    text.len()
}

fn main() {
    let note = String::from("обед");
    println!("{}", note_len(&note));
    println!("{}", note_len("обед"));
    println!("{}", note_len(&note[0..4]));
}
Вывод
8
8
4

Разбор по строкам

  • fn note_len(text: &str) -> usize — функция просто читает текст, поэтому берёт &str. Результат — число, usize (размер или номер; тип для длин).
  • let note = String::from("обед"); — строка-владелец; нужна, чтобы показать, что &str принимает и её.
  • note_len(&note) — ссылка на String сама превращается в &str.
  • note_len("обед") — литерал уже и есть &str, ничего делать не нужно.
  • note_len(&note[0..4]) — срез String: первые четыре байта (почему байта, — в следующем разделе).

Почему в параметрах берём &str, а не &String

Попробуем написать то же самое с &String в параметре.

Rust · main.rs
fn note_len(text: &String) -> usize {
    text.len()
}

fn main() {
    let note = String::from("обед");
    println!("{}", note_len(&note));
    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() считает, сколько их выдано.

Rust · main.rs
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"44
"еда"63

Теперь главное следствие. Номера в срезе строки — это номера байтов, а не букв. Режем так, чтобы граница попала между символами (в документации это называется char boundary). Если попасть внутрь буквы, программа остановится аварийно, это называется паникой.

Rust · main.rs
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
Rust · main.rs
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 — кусок исходной строки, без копирования.

Rust · main.rs
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(&note) менять не нужно:

Rust · main.rs
fn print_note(note: &str) {
    println!("Заметка: {note}");
}

Шаг 2. Две новые функции

Вставьте first_word и rest_after_first над fn main, рядом с остальными функциями:

Rust · main.rs
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(&note); у вас уже есть в начале main. Сразу после них, до объявления spent, добавьте вызов с литералом и строки, которые разбирают тестовую команду на части:

Rust · main.rs
    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(&note). Что скажет компилятор и что предложит исправить?
  • Предскажите, а потом проверьте: сколько байт и сколько букв в "Копилка"?

Частые ошибки

Срез посреди русской буквы

Что видно: программа запускается и падает.

Вывод
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 еда обед в столовой» на части целиком.

На следующий урок