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

Строки: текст в UTF-8

О строках мы уже говорили в главе 4, а теперь разберём их подробнее. Новички в Rust часто спотыкаются о строки по трём причинам сразу: Rust любит показывать возможные ошибки; строки — более сложная структура данных, чем считают многие программисты; и наконец, UTF-8. Вместе это может показаться трудным, если вы пришли из других языков.

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

Что такое строка

Сначала определимся, что мы называем строкой. В самом ядре языка Rust есть только один строковый тип — строковый срез str, который обычно встречается в заимствованной форме, &str. В главе 4 мы говорили о строковых срезах: это ссылки на данные строки в кодировке UTF-8, хранящиеся где-то ещё. Например, строковые литералы хранятся в бинарном файле программы и поэтому являются строковыми срезами.

Тип String предоставляется стандартной библиотекой, а не встроен в ядро языка. Это растущий, изменяемый строковый тип в кодировке UTF-8, который владеет своими данными. Когда растацеаны говорят о «строках» в Rust, они могут иметь в виду как String, так и строковый срез &str, а не какой-то один из них. Хотя этот раздел в основном про String, оба типа активно используются в стандартной библиотеке Rust, и оба — и String, и строковые срезы — хранят текст в UTF-8.

Создание строки

Многие операции, доступные для Vec<T>, есть и у String: на самом деле String реализован как обёртка над вектором байтов с дополнительными гарантиями, ограничениями и возможностями. Пример функции, которая одинаково работает и с Vec<T>, и со String, — функция new, создающая экземпляр, как в листинге 8-11.

fn main() {
    let mut s = String::new();
}
Листинг 8-11: Создаём новую пустую String

Эта строка кода создаёт новую пустую строку s, в которую потом можно загрузить данные. Часто строку хочется сразу создать с какими-то начальными данными. Для этого есть метод to_string, доступный у любого типа, который реализует трейт Display, — в том числе у строковых литералов. В листинге 8-12 два примера.

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // Метод можно вызвать и прямо у литерала:
    let s = "initial contents".to_string();
}
Листинг 8-12: Создаём String из строкового литерала методом to_string

Этот код создаёт строку с содержимым initial contents.

Создать String из строкового литерала можно и функцией String::from. Код в листинге 8-13 равнозначен коду из листинга 8-12 с to_string.

fn main() {
    let s = String::from("initial contents");
}
Листинг 8-13: Создаём String из строкового литерала функцией String::from

Строки используются для очень многих задач, поэтому для них есть множество разных обобщённых API, и выбор вариантов большой. Некоторые из них могут показаться избыточными, но у каждого своё место! В нашем случае String::from и to_string делают одно и то же, так что выбор между ними — вопрос стиля и читабельности.

Помните, что строки хранятся в UTF-8, поэтому в них можно положить любые корректно закодированные данные, как в листинге 8-14.

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}
Листинг 8-14: Храним в строках приветствия на разных языках

Всё это — допустимые значения String.

Изменение строки

String может расти, а её содержимое — меняться, как у Vec<T>, если добавлять в неё данные. Кроме того, значения String удобно склеивать оператором + или макросом format!.

Дописывание через push_str и push

Нарастить String можно методом push_str, который дописывает в конец строковый срез, как в листинге 8-15.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}
Листинг 8-15: Дописываем строковый срез в конец String методом push_str

После этих двух строк в s будет foobar. Метод push_str принимает строковый срез, потому что забирать владение параметром нам вовсе не обязательно. Например, в коде из листинга 8-16 мы хотим пользоваться s2 и после того, как дописали её содержимое в s1.

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}
Листинг 8-16: Пользуемся строковым срезом после того, как дописали его содержимое в String

Если бы метод push_str забирал владение s2, мы не смогли бы вывести её значение в последней строке. Но этот код работает так, как мы и ожидаем!

Метод push принимает параметром один символ и добавляет его в String. В листинге 8-17 мы добавляем в String букву l методом push.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}
Листинг 8-17: Добавляем один символ в значение String методом push

В результате в s будет lol.

Склеивание через + и format!

Часто нужно соединить две существующие строки. Один из способов — оператор +, как в листинге 8-18.

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // обратите внимание: s1 перемещена сюда, и использовать её больше нельзя
}
Листинг 8-18: Соединяем два значения String в новое значение String оператором +

В строке s3 будет Hello, world!. Почему после сложения s1 перестаёт быть действительной и почему мы взяли ссылку на s2 — объясняется сигнатурой метода, который вызывается при использовании оператора +. Оператор + вызывает метод add, сигнатура которого выглядит примерно так:

fn add(self, s: &str) -> String {

В стандартной библиотеке add определён через обобщения и ассоциированные типы. Здесь мы подставили конкретные типы — именно они получаются, когда этот метод вызывается со значениями String. Обобщения мы обсудим в главе 10. А эта сигнатура даёт нам подсказки, нужные, чтобы разобраться в хитростях оператора +.

Во-первых, перед s2 стоит &, то есть к первой строке мы прибавляем ссылку на вторую. Так получается из-за параметра s в функции add: к String можно прибавить только строковый срез, сложить два значения String напрямую нельзя. Но постойте: тип &s2 — это &String, а не &str, как указано во втором параметре add. Почему же листинг 8-18 компилируется?

Использовать &s2 в вызове add можно потому, что компилятор умеет привести аргумент &String к типу &str. При вызове add Rust применяет неявное разыменование (deref coercion), которое здесь превращает &s2 в &s2[..]. Подробнее о неявном разыменовании мы поговорим в главе 15. Метод add не забирает владение параметром s, поэтому после этой операции s2 остаётся действительной String.

Во-вторых, из сигнатуры видно, что add забирает владение self: перед self нет &. Значит, s1 из листинга 8-18 перемещается в вызов add и после него становится недействительной. Так что хотя let s3 = s1 + &s2; выглядит так, будто копирует обе строки и создаёт новую, на самом деле эта инструкция забирает владение s1, дописывает в её конец копию содержимого s2 и возвращает владение результатом. Иначе говоря, кажется, что тут много копирования, а его нет: реализация эффективнее, чем копирование.

Если нужно склеить несколько строк, с оператором + становится неудобно:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

В итоге в s будет tic-tac-toe. Из-за всех этих + и " трудно понять, что происходит. Для более сложного соединения строк лучше подходит макрос format!:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{s1}-{s2}-{s3}");
}

Этот код тоже записывает в s значение tic-tac-toe. Макрос format! работает как println!, только не выводит результат на экран, а возвращает String с этим содержимым. Версию с format! читать гораздо проще, а код, который генерирует макрос format!, использует ссылки, так что этот вызов не забирает владение ни одним из своих параметров.

Обращение к строке по индексу

Во многих языках программирования обращение к отдельным символам строки по индексу — нормальная и распространённая операция. Но если в Rust попытаться обратиться к части String через индекс, получится ошибка. Посмотрите на некорректный код из листинга 8-19.

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}
Листинг 8-19: Пытаемся обратиться к String по индексу

Этот код приведёт к ошибке:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
help: `usize` implements trait `SliceIndex<T>`
 --> /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/slice/index.rs:179:0
  |
  = note: `SliceIndex<[T]>`
 --> /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/bstr/traits.rs:197:0
  |
  = note: `SliceIndex<ByteStr>`
  = note: required for `String` to implement `Index<{integer}>`

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

Ошибка говорит сама за себя: строки в Rust не поддерживают индексацию. Но почему? Чтобы ответить на этот вопрос, нужно разобраться, как Rust хранит строки в памяти.

Внутреннее представление

String — это обёртка над Vec<u8>. Посмотрим на несколько корректно закодированных в UTF-8 строк из листинга 8-14. Сначала на эту:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

Здесь len будет равно 4: вектор, хранящий строку "Hola", занимает 4 байта. Каждая из этих букв в UTF-8 занимает 1 байт. А вот следующая строка может вас удивить:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

На вопрос, какой длины эта строка, вы, скорее всего, ответите: 12. А Rust ответит: 24. Столько байтов нужно, чтобы закодировать «Здравствуйте» в UTF-8: каждое скалярное значение Юникода в этой строке занимает 2 байта. Поэтому индекс байта в строке не всегда соответствует целому скалярному значению Юникода. Чтобы это показать, рассмотрим такой некорректный код на Rust:

let hello = "Здравствуйте";
let answer = &hello[0];

Вы уже знаете, что answer не будет равно З, первой букве. В UTF-8 первый байт З — это 208, а второй — 151. Казалось бы, тогда answer должно быть 208, но 208 сам по себе — не символ. Пользователь, который попросил первую букву строки, вряд ли хотел получить 208, но других данных по индексу 0 у Rust нет. Обычно пользователям не нужно значение байта, даже если в строке только латиница: если бы &"hi"[0] был допустимым кодом, возвращающим байт, он вернул бы 104, а не h.

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

Байты, скалярные значения и графемные кластеры

Ещё одна особенность UTF-8: с точки зрения Rust на строку можно смотреть тремя способами — как на байты, как на скалярные значения и как на графемные кластеры (они ближе всего к тому, что мы называем буквами).

Возьмём слово на хинди «नमस्ते», записанное письмом деванагари. Оно хранится как вектор значений u8, который выглядит так:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

Это 18 байт — именно так компьютер в конечном счёте хранит эти данные. Если смотреть на них как на скалярные значения Юникода, то есть на значения типа char в Rust, байты выглядят так:

['न', 'म', 'स', '्', 'त', 'े']

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

["न", "म", "स्", "ते"]

Rust даёт разные способы интерпретировать «сырые» данные строки, которые хранит компьютер, чтобы каждая программа могла выбрать нужную интерпретацию — на каком бы человеческом языке ни были данные.

И последняя причина, по которой Rust не позволяет получить символ из String по индексу: от операции индексации ожидается, что она всегда выполняется за константное время (O(1)). Но со String такую производительность гарантировать невозможно: Rust пришлось бы пройти содержимое от начала до индекса, чтобы определить, сколько там корректных символов.

Срезы строк

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

Вместо [] с одним числом можно использовать [] с диапазоном — так получится строковый срез, содержащий определённые байты:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

Здесь s будет &str с первыми 4 байтами строки. Мы уже говорили, что каждый из этих символов занимает 2 байта, значит, s будет равно Зд.

Если попытаться вырезать только часть байтов символа, например &hello[0..1], Rust запаникует во время выполнения — так же, как при обращении к вектору по недопустимому индексу:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`

thread 'main' (6017738) panicked at src/main.rs:4:19:
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

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

Перебор строк

Работать с частями строк лучше всего, явно указывая, что вам нужно — символы или байты. Для отдельных скалярных значений Юникода есть метод chars. Вызов chars у «Зд» разделяет строку и возвращает два значения типа char, а результат можно перебрать и обратиться к каждому элементу:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

Этот код выведет:

З
д

Можно воспользоваться и методом bytes, который возвращает каждый «сырой» байт, — возможно, в вашей задаче нужно именно это:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

Этот код выведет 4 байта, из которых состоит строка:

208
151
208
180

Но обязательно помните, что корректное скалярное значение Юникода может состоять из нескольких байтов.

Получить из строки графемные кластеры, как в случае с деванагари, — сложная задача, поэтому стандартная библиотека этого не умеет. Если вам нужна такая функциональность, поищите подходящий крейт на crates.io.

Строки — это непросто

Подведём итог: строки — штука сложная. Разные языки программирования по-разному решают, как показать эту сложность программисту. Rust выбрал путь, при котором корректная обработка данных String — это поведение по умолчанию во всех программах на Rust. Значит, программистам приходится заранее продумывать работу с UTF-8. Из-за этого сложность строк в Rust заметнее, чем в других языках, зато вам не придётся потом, на поздних этапах разработки, разбираться с ошибками из-за не-ASCII-символов.

Хорошая новость: в стандартной библиотеке много функциональности на основе типов String и &str, которая помогает правильно справляться с такими сложными ситуациями. Обязательно загляните в документацию — там есть полезные методы вроде contains для поиска в строке и replace для замены частей строки другой строкой.

А теперь перейдём к чему-нибудь чуть попроще — к хеш-таблицам!