Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Тип срезов

Срезы позволяют ссылаться на непрерывную последовательность элементов в коллекции. Срез — это вид ссылки, поэтому он не владеет данными.

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

Примечание: для знакомства со срезами в этом разделе мы предполагаем только ASCII; более подробное обсуждение обработки UTF-8 находится в разделе «Хранение текста в кодировке UTF-8 с помощью строк» Главы 8.

Чтобы понять проблему, которую решают срезы, разберём, как мы написали бы сигнатуру этой функции без использования срезов:

fn first_word(s: &String) -> ?

Функция first_word имеет параметр типа &String. Владение нам не нужно, поэтому это нормально. (В идиоматичном Rust функции не забирают владение своими аргументами, если им это не требуется, и причины этого будут становиться понятнее по мере продвижения.) Но что нам вернуть? У нас на самом деле нет способа говорить о части строки. Однако мы могли бы вернуть индекс конца слова, на который указывает пробел. Попробуем сделать это, как показано в листинге 4-7.

Filename: src/main.rs
fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}
Listing 4-7: Функция first_word, которая возвращает значение байтового индекса внутри параметра String

Поскольку нам нужно пройти по String элемент за элементом и проверить, является ли значение пробелом, мы преобразуем наш String в массив байтов с помощью метода as_bytes.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Затем мы создаём итератор по массиву байтов с помощью метода iter:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Мы подробнее обсудим итераторы в Главе 13. Пока достаточно знать, что iter — это метод, возвращающий каждый элемент коллекции, а enumerate оборачивает результат iter и вместо этого возвращает каждый элемент как часть кортежа. Первый элемент кортежа, возвращаемого enumerate, — это индекс, а второй — ссылка на элемент. Это немного удобнее, чем вычислять индекс самостоятельно.

Поскольку метод enumerate возвращает кортеж, мы можем использовать шаблоны, чтобы деструктурировать этот кортеж. Подробнее о шаблонах мы поговорим в Главе 6. В цикле for мы указываем шаблон, где i — это индекс в кортеже, а &item — один байт в кортеже. Поскольку из .iter().enumerate() мы получаем ссылку на элемент, в шаблоне используется &.

Внутри цикла for мы ищем байт, представляющий пробел, используя синтаксис байтового литерала. Если мы находим пробел, возвращаем его позицию. Иначе возвращаем длину строки с помощью s.len().

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Теперь у нас есть способ узнать индекс конца первого слова в строке, но есть проблема. Мы возвращаем отдельный usize, но это число имеет смысл только в контексте &String. Другими словами, поскольку это значение отделено от String, нет гарантии, что оно останется действительным в будущем. Рассмотрим программу в листинге 4-8, которая использует функцию first_word из листинга 4-7.

Filename: src/main.rs
fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word will get the value 5

    s.clear(); // this empties the String, making it equal to ""

    // word still has the value 5 here, but s no longer has any content that we
    // could meaningfully use with the value 5, so word is now totally invalid!
}
Listing 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, и выглядит он так:

fn main() {
    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-7 показывает это на схеме.

Три таблицы: таблица, представляющая данные s в стеке, указывает
на байт с индексом 0 в таблице строковых данных "hello world" в куче.
Третья таблица представляет данные среза world в стеке, имеет значение длины 5
и указывает на байт 6 таблицы данных в куче.

Рисунок 4-7: Строковый срез, ссылающийся на часть String

В синтаксисе диапазонов Rust с .., если вы хотите начать с индекса 0, можно опустить значение перед двумя точками. Другими словами, эти варианты эквивалентны:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

По той же причине, если ваш срез включает последний байт String, можно опустить конечное число. Это означает, что эти варианты эквивалентны:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

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

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

Примечание: индексы диапазона строкового среза должны попадать на допустимые границы символов UTF-8. Если вы попытаетесь создать строковый срез в середине многобайтового символа, ваша программа завершится с ошибкой.

Учитывая всё это, перепишем first_word, чтобы она возвращала срез. Тип, обозначающий «строковый срез», записывается как &str:

Filename: 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[..]
}

fn main() {}

Индекс конца слова мы получаем так же, как в листинге 4-7: ищем первое вхождение пробела. Когда мы находим пробел, возвращаем строковый срез, используя начало строки и индекс пробела как начальный и конечный индексы.

Теперь при вызове first_word мы получаем одно значение, связанное с базовыми данными. Это значение состоит из ссылки на начальную точку среза и количества элементов в срезе.

Возврат среза также подошёл бы для функции second_word:

fn second_word(s: &String) -> &str {

Теперь у нас есть простой API, в котором гораздо труднее ошибиться, потому что компилятор проследит, чтобы ссылки внутрь String оставались действительными. Помните ошибку в программе из листинга 4-8, когда мы получили индекс конца первого слова, а затем очистили строку, так что наш индекс стал недействительным? Тот код был логически неверным, но не показывал немедленных ошибок. Проблемы проявились бы позже, если бы мы продолжили использовать индекс первого слова с очищенной строкой. Срезы делают такую ошибку невозможной и гораздо раньше сообщают нам, что в коде есть проблема. Использование версии first_word со срезом вызовет ошибку времени компиляции:

Filename: 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[..]
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {word}");
}

Вот ошибка компилятора:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
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

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Вспомните из правил заимствования: если у нас есть неизменяемая ссылка на что-то, мы не можем одновременно взять изменяемую ссылку. Поскольку clear нужно усечь String, ей нужна изменяемая ссылка. println! после вызова clear использует ссылку в word, поэтому неизменяемая ссылка должна всё ещё быть активной в этот момент. Rust запрещает изменяемой ссылке в clear и неизменяемой ссылке в word существовать одновременно, и компиляция завершается ошибкой. Rust не только сделал наш API проще в использовании, но и устранил целый класс ошибок во время компиляции!

Строковые литералы как срезы

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

#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

Тип s здесь — &str: это срез, указывающий на конкретное место бинарного файла. Именно поэтому строковые литералы неизменяемы; &str — неизменяемая ссылка.

Строковые срезы как параметры

Знание того, что можно брать срезы литералов и значений String, приводит нас к ещё одному улучшению first_word: её сигнатуре.

fn first_word(s: &String) -> &str {

Более опытный разработчик Rust написал бы сигнатуру, показанную в листинге 4-9, потому что она позволяет использовать одну и ту же функцию и со значениями &String, и со значениями &str.

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}
Listing 4-9: Улучшение функции first_word с помощью строкового среза для типа параметра s

Если у нас есть строковый срез, мы можем передать его напрямую. Если у нас есть String, мы можем передать срез String или ссылку на String. Эта гибкость использует преимущества deref-приведений — возможности, которую мы рассмотрим в разделе «Использование deref-приведений в функциях и методах» Главы 15.

Определение функции, принимающей строковый срез вместо ссылки на String, делает наш API более общим и полезным без потери какой-либо функциональности:

Filename: src/main.rs
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Другие срезы

Строковые срезы, как вы можете представить, специфичны для строк. Но существует и более общий тип срезов. Рассмотрим этот массив:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

Так же как нам может понадобиться сослаться на часть строки, нам может понадобиться сослаться на часть массива. Мы сделали бы это так:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

Этот срез имеет тип &[i32]. Он работает так же, как строковые срезы: хранит ссылку на первый элемент и длину. Вы будете использовать такой вид срезов для самых разных коллекций. Мы подробно обсудим эти коллекции, когда будем говорить о векторах в Главе 8.

Итоги

Концепции владения, заимствования и срезов обеспечивают безопасность памяти в программах на Rust во время компиляции. Язык Rust даёт вам контроль над использованием памяти так же, как и другие системные языки программирования. Но поскольку владелец данных автоматически очищает эти данные, когда выходит из области видимости, вам не нужно писать и отлаживать дополнительный код, чтобы получить этот контроль.

Владение влияет на работу многих других частей Rust, поэтому мы ещё будем говорить об этих концепциях на протяжении остальной книги. Перейдём к Главе 5 и посмотрим, как группировать фрагменты данных вместе в struct.