Тип зрізу (The Slice Type)
Зрізи (slices) дають змогу вам посилатися на неперервну послідовність елементів у колекції. Зріз — це вид посилання, тож він не має володіння.
Ось невелика програмна задача: напишіть функцію, яка приймає рядок слів, розділених пробілами, і повертає перше слово, яке вона знаходить у цьому рядку. Якщо функція не знаходить пробіл у рядку, увесь рядок має бути одним словом, тож повинно бути повернено весь рядок.
Примітка: Для цілей запровадження зрізів у цьому розділі ми припускаємо лише ASCII; більш ґрунтовне обговорення обробки UTF-8 є в розділі “Збереження кодування UTF-8 для тексту за допомогою рядків” Розділу 8.
Давайте розглянемо, як ми написали б сигнатуру цієї функції без використання зрізів, щоб зрозуміти проблему, яку зрізи розв’яжуть:
fn first_word(s: &String) -> ?
Функція first_word має параметр типу &String. Нам не потрібна
володіння, тож це підходить. (В ідіоматичному Rust функції не беруть
володіння над своїми аргументами, якщо їм це не потрібно, і причини цього стануть
очевиднішими, коли ми рухатимемось далі.) Але що ми маємо повернути? У нас
справді немає способу говорити про частину рядка. Однак ми могли б повернути індекс
кінця слова, позначеного пробілом. Спробуймо це, як показано в Лістингу (Listing) 4-7.
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() {}
Оскільки нам потрібно пройти через 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() {}
Далі ми створюємо ітератор (iterator) над масивом байтів за допомогою методу 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 we вказуємо шаблон, у якому 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, немає гарантії, що воно
і надалі буде чинним. Розгляньте програму в Лістингу (Listing) 4-8, яка
використовує функцію first_word із Лістингу (Listing) 4-7.
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!
}
Ця програма компілюється без жодних помилок і зробила б це також, якби ми використали 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 slice) — це посилання на неперервну послідовність елементів
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.
Рисунок (Figure) 4-7 показує це на діаграмі.
Рисунок (Figure) 4-7: Рядковий зріз, що посилається на частину
String (A string slice referring to part of a 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:
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() {}
Ми отримуємо індекс кінця слова тим самим способом, що й у Лістингу (Listing) 4-7, — шукаючи перше входження пробілу. Коли ми знаходимо пробіл, ми повертаємо рядковий зріз, використовуючи початок рядка та індекс пробілу як початковий і кінцевий індекси.
Тепер, коли ми викликаємо first_word, ми отримуємо назад одне значення, яке пов’язане з
базовими даними. Це значення складається з посилання на початкову точку
зрізу та кількості елементів у зрізі.
Повернення зрізу також працювало б для функції second_word:
fn second_word(s: &String) -> &str {
Тепер у нас є простий API, який набагато важче зламати, тому що
компілятор забезпечить, щоб посилання на String залишалися чинними.
Пам’ятаєте помилку в програмі з Лістингу (Listing) 4-8, коли ми отримали індекс до
кінця першого слова, а потім очистили рядок, тож наш індекс став недійсним?
Той код був логічно неправильним, але не показував негайних помилок. Проблеми
з’явилися б пізніше, якби ми продовжували намагатися використовувати індекс першого слова
з очищеним рядком. Зрізи роблять цю помилку неможливою і дають нам знати
значно раніше, що в нашому коді є проблема. Використання версії first_word
зі зрізом призведе до помилки під час компіляції:
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 простішим у використанні,
але й усунув цілий клас помилок під час компіляції!
Рядкові літерали як зрізи (String Literals Are Slices)
Згадайте, що ми говорили про те, що рядкові літерали зберігаються всередині бінарного файлу. Тепер, коли ми знаємо про зрізи, ми можемо правильно зрозуміти рядкові літерали:
#![allow(unused)]
fn main() {
let s = "Hello, world!";
}
Тип s тут — &str: це зріз, який вказує на цю конкретну точку
бінарного файлу. Саме тому рядкові літерали є незмінними; &str — це
незмінне посилання.
Рядкові зрізи як параметри (String Slices as Parameters)
Знання того, що ви можете брати зрізи літералів і значень String, приводить нас
ще до одного поліпшення first_word, а саме до її сигнатури:
fn first_word(s: &String) -> &str {
Більш досвідчений растацеанець (Rustacean) написав би сигнатуру, показану в Лістингу (Listing) 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);
}
Якщо в нас є рядковий зріз, ми можемо передати його безпосередньо. Якщо в нас є String, ми
можемо передати зріз String або посилання на String. Ця
гнучкість використовує переваги приведення типів через розіменування (deref coercions) — можливості, яку ми розглянемо в
розділі “Використання приведення типів через розіменування у функціях і методах (Using Deref Coercions with Functions and Methods)” Розділу 15.
Визначення функції, яка приймає рядковий зріз замість посилання на String,
робить наш API більш загальним і корисним, не втрачаючи жодної функціональності:
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);
}
Інші зрізи (Other Slices)
Рядкові зрізи, як ви можете уявити, специфічні для рядків. Але є й більш загальний тип зрізу. Розгляньмо цей масив:
#![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.
Підсумок (Summary)
Поняття володіння, запозичення та зрізів забезпечують безпеку пам’яті в програмах Rust під час компіляції. Мова Rust дає вам контроль над використанням пам’яті так само, як і інші мови системного програмування. Але те, що власник даних автоматично очищає ці дані, коли власник виходить з області видимості, означає, що вам не потрібно писати й налагоджувати додатковий код, щоб отримати цей контроль.
Володіння впливає на те, як працює багато інших частин Rust, тож ми говоритимемо
про ці поняття далі протягом решти книги. Перейдімо до
Розділу 5 і подивімося на групування частин даних разом у struct.