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

Срезы

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

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

Примечание: чтобы проще было объяснить срезы, в этом разделе мы считаем, что строки состоят только из символов ASCII. Подробнее о работе с UTF-8 — в разделе «Строки: текст в UTF-8» главы 8.

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

fn first_word(s: &String) -> ?

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

Файл: 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() {}
Листинг 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.

Файл: 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 получит значение 5

    s.clear(); // очищаем String: теперь она равна ""

    // word по-прежнему равно 5, но в s больше нет текста, к которому это
    // число имело бы смысл, так что word теперь совершенно бесполезно!
}
Листинг 4-8: Сохраняем результат вызова first_word, а затем меняем содержимое String

Эта программа компилируется без ошибок — и скомпилировалась бы, даже если бы мы использовали word после вызова s.clear(). Переменная word никак не связана с состоянием s, поэтому в ней по-прежнему лежит 5. Мы могли бы попытаться вырезать из s первое слово с помощью этой пятёрки, но это был бы баг: с тех пор как мы сохранили 5 в word, содержимое s изменилось.

Следить за тем, чтобы индекс в word не разошёлся с данными в s, — утомительно, и легко ошибиться! А если написать ещё и функцию second_word, всё станет ещё хрупче. Её сигнатура выглядела бы так:

fn second_word(s: &String) -> (usize, usize) {

Теперь нужно отслеживать и начальный, и конечный индекс. Оба числа вычислены по строке в какой-то момент, но если строка потом изменится, сами числа об этом не узнают. Получается три независимые переменные — строка и два индекса, — и следить, чтобы они не разошлись, приходится вручную.

К счастью, у Rust есть решение — строковые срезы.

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

Строковый срез (string slice) — это ссылка на непрерывную часть String. Выглядит он так:

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

    let hello = &s[0..5];
    let world = &s[6..11];
}

Здесь hello ссылается не на всю String, а только на её часть, заданную добавкой [0..5]. Срез создаётся указанием диапазона в квадратных скобках: [начальный_индекс..конечный_индекс], где начальный_индекс — первая позиция среза, а конечный_индекс — позиция, следующая за последней. Внутри срез хранит начальную позицию и длину, равную конечный_индекс минус начальный_индекс. Так, в let world = &s[6..11]; срез world содержит указатель на байт с индексом 6 в s и длину 5.

На рисунке 4-7 это показано схематично.

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

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

Если диапазон .. начинается с индекса 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:

Файл: 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 со срезом приведёт к ошибке компиляции:

Файл: 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(); // ошибка!

    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(); // ошибка!
   |     ^^^^^^^^^ 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

Почему так? Вспомните правило заимствования: пока на значение есть неизменяемая ссылка, изменяемую ссылку на него взять нельзя. Теперь посмотрим, что происходит в коде:

  • word — это срез строки s, то есть неизменяемая ссылка на s.
  • Метод clear очищает строку s, а значит, меняет её. Для этого ему нужна изменяемая ссылка на s.
  • После clear идёт println!, который использует word. Значит, в момент вызова clear неизменяемая ссылка word всё ещё нужна — она «жива».

Выходит, что в момент вызова clear на строку s должны одновременно существовать и неизменяемая ссылка (word), и изменяемая (для clear). Rust это запрещает, и компиляция завершается ошибкой. Так Rust не только сделал наш API удобнее, но и избавил нас от целого класса ошибок — ещё до запуска программы!

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

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

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

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

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

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

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

Более опытный растацеан написал бы сигнатуру из листинга 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` принимает срезы `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);
}
Листинг 4-9: Улучшаем функцию first_word: параметр s теперь имеет тип строкового среза

Если у нас есть строковый срез, его можно передать напрямую. Если есть String, можно передать срез этой String или ссылку на неё. Такая гибкость возможна благодаря неявному разыменованию (deref coercion) — мы разберём его в разделе «Неявное разыменование в функциях и методах» главы 15.

Если функция принимает строковый срез, а не ссылку на String, её API становится универсальнее и полезнее, а возможностей мы при этом не теряем:

Файл: 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` принимает срезы `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);
}

Другие срезы

Строковые срезы, как нетрудно догадаться, бывают только у строк. Но есть и более общий вид среза. Возьмём массив:

#![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).