Введение
Добро пожаловать в Тип среза. Эта лабораторная работа является частью Rust Book. Вы можете практиковать свои навыки Rust в LabEx.
В этой лабораторной работе мы решим задачу программирования, написав функцию, которая принимает строку слов, разделенных пробелами, и возвращает первое слово, которое она находит в этой строке, а затем мы обсудим ограничения использования индексов для представления подстрок и решение этой проблемы с использованием срезов строк в Rust.
Тип среза
Срезы позволяют ссылаться на непрерывную последовательность элементов в коллекции, а не на всю коллекцию. Срез является типом ссылки, поэтому он не имеет владения.
Вот небольшая задача программирования: напишите функцию, которая принимает строку слов, разделенных пробелами, и возвращает первое слово, которое она находит в этой строке. Если функция не находит пробела в строке, вся строка должна быть одним словом, поэтому вся строка должна быть возвращена.
Давайте разберем, как бы мы написали сигнатуру этой функции без использования срезов, чтобы понять проблему, которую срезы будут решать:
fn first_word(s: &String) ->?
Функция first_word имеет &String в качестве параметра. Мы не хотим владения, поэтому это нормально. Но что мы должны вернуть? Мы не имеем способа говорить о части строки. Однако мы можем вернуть индекс конца слова, обозначенный пробелом. Давайте попробуем это, как показано в Листинге 4-7.
Имя файла: src/main.rs
fn first_word(s: &String) -> usize {
1 let bytes = s.as_bytes();
for (2 i, &item) in 3 bytes.iter().enumerate() {
4 if item == b' ' {
return i;
}
}
5 s.len()
}
Листинг 4-7: Функция first_word, которая возвращает значение байтового индекса в параметре String
Поскольку нам нужно пройти по элементам String по одному и проверить, является ли значение пробелом, мы преобразуем нашу String в массив байтов с использованием метода as_bytes [1].
Далее мы создаем итератор по массиву байтов с использованием метода iter [3]. Мы поговорим о итераторах более подробно в главе 13. На данный момент просто запомните, что iter - это метод, который возвращает каждый элемент в коллекции, а enumerate оборачивает результат iter и возвращает каждый элемент в виде кортежа. Первый элемент кортежа, возвращаемый из enumerate, - это индекс, а второй элемент - это ссылка на элемент. Это немного удобнее, чем вычислять индекс самостоятельно.
Поскольку метод enumerate возвращает кортеж, мы можем использовать шаблоны для разбора этого кортежа. Мы поговорим о шаблонах более подробно в главе 6. В цикле for мы указываем шаблон, который имеет i для индекса в кортеже и &item для одного байта в кортеже [2]. Поскольку мы получаем ссылку на элемент из .iter().enumerate(), мы используем & в шаблоне.
Внутри цикла for мы ищем байт, представляющий пробел, с использованием синтаксиса литерала байта [4]. Если мы находим пробел, мы возвращаем позицию. В противном случае мы возвращаем длину строки с использованием s.len() [5].
Теперь у нас есть способ узнать индекс конца первого слова в строке, но есть проблема. Мы возвращаем usize самостоятельно, но это имеет смысл только в контексте &String. Другими словами, поскольку это отдельное значение от String, не гарантируется, что оно по-прежнему будет действительным в будущем. Рассмотрите программу в Листинге 4-8, которая использует функцию first_word из Листинга 4-7.
// src/main.rs
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word получит значение 5
s.clear(); // это очищает String, делая его равным ""
// word по-прежнему имеет значение 5 здесь, но больше нет строки,
// с которой мы могли бы смыслfully использовать значение 5. word теперь совершенно недействителен!
}
Листинг 4-8: Сохранение результата вызова функции first_word и затем изменение содержимого String
Эта программа компилируется без ошибок и также это сделает, если мы будем использовать word после вызова s.clear(). Поскольку word не связан с состоянием s вовсе, word по-прежнему содержит значение 5. Мы могли бы использовать это значение 5 с переменной s, чтобы попробовать извлечь первое слово, но это будет ошибкой, потому что содержимое s изменилось с тех пор, как мы сохранили 5 в word.
Быть вынужденным беспокоиться о том, чтобы индекс в word не выйти из синхронизации с данными в s, - это утомительно и подвержено ошибкам! Управление этими индексами еще более хрупко, если мы напишем функцию second_word. Его сигнатура должна выглядеть так:
fn second_word(s: &String) -> (usize, usize) {
Теперь мы отслеживаем начальный и конечный индексы, и у нас есть еще больше значений, которые были вычислены из данных в определенном состоянии, но не связаны с этим состоянием вовсе. У нас есть три не связанных переменные, которые нужно синхронизировать.
К счастью, у Rust есть решение этой проблемы: строковые срезы.
Строковые срезы
Строковый срез - это ссылка на часть String, и он выглядит так:
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
Вместо ссылки на всю String, hello - это ссылка на часть String, указанную в дополнительном фрагменте [0..5]. Мы создаем срезы с использованием диапазона в квадратных скобках, указывая [starting_index..ending_index], где starting_index - это первая позиция в срезе, а ending_index - на единицу больше последней позиции в срезе. Внутри структуры данных среза хранится начальная позиция и длина среза, которая соответствует ending_index минус starting_index. Таким образом, в случае let world = &s[6..11];, world будет срезом, содержащим указатель на байт с индексом 6 в s с значением длины 5.
Рисунок 4-6 показывает это в виде схемы.
Рисунок 4-6: Строковый срез, ссылающийся на часть String
С использованием синтаксиса диапазона .. в Rust, если вы хотите начать с индекса 0, вы можете опустить значение перед двумя точками. Другими словами, эти записи равны:
let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2];
По тем же причинам, если ваш срез включает последний байт String, вы можете опустить конечное число. Это означает, что эти записи равны:
let s = String::from("hello");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..];
Вы также можете опустить оба значения, чтобы взять срез всей строки. Поэтому эти записи равны:
let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];
Примечание: Индексы диапазона строкового среза должны находиться на допустимых границах UTF-8-символов. Если вы попытаетесь создать строковый срез в середине многобайтового символа, ваша программа будет завершаться с ошибкой. В целях введения строковых срезов в этом разделе мы предполагаем только ASCII; более подробное обсуждение обработки UTF-8 находится в разделе "Сохранение текста, закодированного в UTF-8, с использованием строк".
С учетом всей этой информации, давайте перепишем first_word, чтобы возвращать срез. Тип, означающий "строковый срез", записывается как &str:
Имя файла: src/main.rs
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
Мы получаем индекс конца слова так же, как и в Листинге 4-7, ищем первое вхождение пробела. Когда мы находим пробел, мы возвращаем строковый срез, используя начало строки и индекс пробела в качестве начальных и конечных индексов.
Теперь, когда мы вызываем first_word, мы получаем обратно одно значение, которое связано с базовыми данными. Значение состоит из ссылки на начальную точку среза и количества элементов в срезе.
Возвращение среза также будет работать для функции second_word:
fn second_word(s: &String) -> &str {
Теперь у нас есть простой API, который гораздо сложнее испортить, потому что компилятор обеспечит, чтобы ссылки в String оставались валидными. Помните об ошибке в программе из Листинга 4-8, когда мы получили индекс до конца первого слова, а затем очистили строку, так что наш индекс стал недействительным? Код был логически неправильным, но не показывал никаких ошибок сразу. Проблемы могли бы появиться позже, если мы продолжали пытаться использовать индекс первого слова с пустой строкой. Срезы делают эту ошибку невозможной и позволяют нам узнать о проблеме в нашем коде гораздо раньше. Использование срезной версии first_word вызовет ошибку компиляции:
Имя файла: src/main.rs
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // ошибка!
println!("the first word is: {word}");
}
Вот ошибка компилятора:
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as
immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // error!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("the first word is: {word}");
| ---- immutable borrow later used here
Помните из правил заимствования, что если у нас есть неизменяемая ссылка на что-то, мы не можем одновременно получить изменяемую ссылку. Поскольку clear должен обрезать String, ему нужно получить изменяемую ссылку. println! после вызова clear использует ссылку в word, поэтому неизменяемая ссылка должна по-прежнему быть активной в этот момент. Rust не позволяет существовать одновременно изменяемой ссылке в clear и неизменяемой ссылке в word, и компиляция завершается с ошибкой. Не только Rust упростил использование нашего API, но и избавился от целого класса ошибок на этапе компиляции!
Строковые литералы в качестве срезов
Помните, мы говорили, что строковые литералы хранятся внутри бинарника. Теперь, когда мы знаем про срезы, мы можем правильно понять строковые литералы:
let s = "Hello, world!";
Тип s здесь - это &str: это срез, указывающий на那个特定的二进制点。Вот почему строковые литералы неизменяемы; &str - это неизменяемая ссылка.
Строковые срезы в качестве параметров
Знание того, что можно брать срезы литералов и значений String, приводит нас к еще одному улучшению в first_word, а именно к его сигнатуре:
fn first_word(s: &String) -> &str {
Более опытный Rustacean вместо этого напишет сигнатуру, показанную в Листинге 4-9, потому что это позволяет использовать ту же функцию как для значений &String, так и для значений &str.
fn first_word(s: &str) -> &str {
Листинг 4-9: Улучшение функции first_word путем использования строкового среза для типа параметра s
Если у нас есть строковый срез, мы можем передать его напрямую. Если у нас есть String, мы можем передать срез String или ссылку на String. Эта гибкость использует преобразования по неявному разыменованию, функцию, которую мы рассмотрим в разделе "Неявные преобразования по разыменованию с функциями и методами".
Определение функции, которая принимает строковый срез вместо ссылки на String, делает наш API более общим и полезным, не теряя при этом никакой функциональности:
Имя файла: src/main.rs
fn main() {
let my_string = String::from("hello world");
// `first_word` работает с срезами `String`, как частичными,
// так и целыми
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` также работает с ссылками на `String`, которые
// эквивалентны целым срезам `String`
let word = first_word(&my_string);
let my_string_literal = "hello world";
// `first_word` работает с срезами строковых литералов,
// как частичными, так и целыми
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// Поскольку строковые литералы *уже являются* строковыми срезами,
// это также работает, без синтаксиса среза!
let word = first_word(my_string_literal);
}
Другие срезы
Строковые срезы, как вы, вероятно, догадываетесь, специфичны для строк. Но есть и более общий тип среза. Возьмите этот массив:
let a = [1, 2, 3, 4, 5];
Точно так же, как мы можем ссылаться на часть строки, мы можем ссылаться на часть массива. Мы сделаем это так:
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
Этот срез имеет тип &[i32]. Он работает так же, как и строковые срезы, храня ссылку на первый элемент и длину. Вы будете использовать этот тип среза для всех sorts of других коллекций. Мы подробно обсудим эти коллекции, когда будем говорить о векторах в главе 8.
Резюме
Поздравляем! Вы завершили лабораторную работу по типу среза. Вы можете практиковаться в других лабораторных работах в LabEx, чтобы улучшить свои навыки.