Строки

Мы уже говорили о строках в предыдущих главах. Сейчас мы рассмотрим работу с этим типом данных более подробно. Этот тип данных в Rust сложен для понимания начинающими программистами, т.к. это комбинация трёх(!) элементов. Строки являются коллекциями байтов, интерпритируемых как текст. Кроме того, строки имеют свои специфические методы. В этой главе мы рассмотрим отличительныe черты данной коллекции, а также обратим внимание на то как строки интерпретируются.

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

Что же значит строка. В самом Rust есть только один строковый тип данных str. Это отрезок данных, обычно получаемый по ссылке &str. Это ссылка на текстовые данные формата UTF-8. Код типа данных String входит в состав стандартной библиотеки. Этот тип может изменяться, можно использовать при владении. Когда в Rust говорят о работе со строками то, обычно, имеют ввиду String и срез строковых данных &str. Оба этих типа данных манипулируют данными в кодировке UTF-8. В этой секции мы поподробнее остановимся на String.

В стандартную библиотеку Rust также входят и другие типы, которые манипулируют строковыми данными. Это OsString, OsStr, CString, и CStr. Кроме того сторонние библиотеки могут предлагать ещё больше опций. Если вы хотите узнать о других типах данных, которые работаю со строками, пожалуйста, обратитесь к документации.

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

Те операции, которые возможны в Vec также возможны в String. Метод new создаёт новую строку:


# #![allow(unused_variables)]
#fn main() {
let mut s = String::new();
#}

Это выражение создаёт новую стоку s, в которую потом можно будет загрузить данные.

Часто, в каком-нибудь типе данных нам надо получить состояние объекта. Для этого используется метод to_string, который реализован во многих типах данных, которые реализовали поведение Display:


# #![allow(unused_variables)]
#fn main() {
let data = "initial contents";

let s = data.to_string();

// the method also works on a literal directly:
let s = "initial contents".to_string();
#}

Эти выражения создают строку с initial contents.

Мы также можем использовать функцию String::from для создания String из литерала. Это эквивалент использованию фикции to_string:


# #![allow(unused_variables)]
#fn main() {
let s = String::from("initial contents");
#}

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

Так как строки закодированы в кодировке UTF-8, мы можем использовать тексты на различных языках:


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

Обновление строковых данных

Данные переменой типа String в процессе своей жизни могут изменять своё содержание, также как Vec. Кроме того, String могут быть объединены с помощью операции +.

Добавление данных с помощью push

Мы можем добавить данные с помощью метода push_str:

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
    println!("{}", s);
}

Результатом работы кода будет вывода на экран foobar. Метод push_str получает отрезок символьных данных в качестве параметра. Переменная s будет содержать строку “foobar”.

Метод push_str получает срез в качестве параметра, т.к. для использования данного типа данных владение не нужно. К примеру, было бы очень жаль, если бы вы не имели бы возможности использовать данные переменной s2 после добавления её содержания в переменную s1:

fn main() {
    let mut s1 = String::from("foo");
    let s2 = String::from("bar");
    s1.push_str(&s2);
    println!("{}", s1);
    println!("{}", s2);
    println!("{}", s2);
}

Метод push имеет в качестве параметра символьную переменную и добавляет её в массив символов строки String:


# #![allow(unused_variables)]
#fn main() {
let mut s = String::from("lo");
s.push('l');
#}

После этого, переменная s будет содержать “lol”.

Объединение с помощью оператора + или макроса format!

Весьма часто приходится объединять строки. Один из возможных способов - использование оператора +:

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // Note that s1 has been moved here and can no longer be used
    println!("{}", s3);
}

Результат - вывод строки Hello, world!. Причина такой вот жёсткой конструкции оператора объединения - использование метода add:

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

Это не точное определение метода add стандартной библиотеки. Этот метод использует обобщения (т.н. дженерики). К String мы можем добавлять только &str. Также метод add использует получение данных по ссылке так что если даже второй параметр имеет тип String всё равно он преобразуется в str.

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

Для объединения множества строк оператор + не очень нагляден:

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

    let s = s1 + "-" + &s2 + "-" + &s3;
    println!("{}", s);
}

Есть лучшее решение - макрос format!:

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

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

Такое решение более предпочтительно, т.к. в последующих строках все переменые можно использовать.

Индексация в String

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

fn main() {
    let s1 = String::from("hello");
    let h = s1[0];
    println!("{}", h);
}

Описание ошибки:

error[E0277]: the trait bound `std::string::String: std::ops::Index<{integer}>` is not satisfied
 -->
  |
3 |     let h = s1[0];
  |             ^^^^^ the type `std::string::String` cannot be indexed by `{integer}`
  |
  = help: the trait `std::ops::Index<{integer}>` is not implemented for `std::string::String`

Глубинная причина не реализованности этой опции в системе хранения строк в памяти.

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

Тип String это объертка Vec<u8>. Прежде всего, рассмотрим пример:

fn main() {
    let len = String::from("Hola").len();
    println!("{}", len);
}

len содержит 4. Это значит, что вектор Vec содержит строку “Hola” состоящую из 4 байт. Рассмотрим другой пример:

fn main() {
    let len = String::from("Здравствуйте").len();
    println!("{}", len);
}

В данном случает len содержит 24. Каждый символ закодирован двумя байтами.

В качестве примера, рассмотрите этот некорректный Rust код:

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

Этот код также не скомпилируется.

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

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

Если мы посмотрим на слова в хинди “नमस्ते”, в векторном виде (в виде байт) оно будет выглядеть следующим образом:

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

Это 18 байт. Если мы посмотрим на скалярные данные Rust char, то они будут выглядеть слудующим образом:

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

Это 6 симвлов char, но 4 и 6 - это не символы, это диакртики (вспомогательные символы). И наконец, посмотрим на графемные кластеры:

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

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

Ещё одна причина по которой Rust не позволяет получать символ по индексу, это постоянная сложность данной операции (O(1)). Это ухудшает производительность программ и поэтому не используется.

Срезы строк

Если Вам действительно нужно массив байтов из сроки - используйте срезы:


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

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

Здесь s типа данных &str, которая будет содержать четыре первых байта. В данном случае это будет “Зд”.

А что произойдет при такой выборке данных &hello[0..1]? Ответ - ошибка времени выполнения, такая же если бы Вы попытались получить значение несуществующего индекса вектора:

thread 'main' panicked at 'index 0 and/or 1 in `Здравствуйте` do not lie on
character boundary', ../src/libcore/str/mod.rs:1694

Пожалуйста, используйте срезы строковых данных с осторожностью, тщательно тестируйте подобные участки кода!

Методы итерации

Сейчас поговорим о предпочтительных способах доступа к элементам строки.

Если Вам необходимо производить операции над юникод-элементами строки, наилучший способ - использовать метод chars. Вызов chars из “नमस्ते” разделяет и возвращает 6 значений типа char. Далее, вы можете производить итерации для получения элементов этой строки:

fn main() {
    for c in "नमस्ते".chars() {
        println!("{}", c);
    }
}

Будет напечатано:

न
म
स
्
त
े

Метод bytes возвращает очередной байт при каждой итерации:

fn main() {
    for b in "नमस्ते".bytes() {
        println!("{}", b);
    }
}

Этот код напечатает 18 байт, из которых состоит данные строки:

224
164
168
224
// ... etc

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

Получение графемных кластеров из строки весьма сложное дело, поэтому этот функционал не входит в стандартную библиотеку. Но существуют дополнительные библиотеки, которые решают эту задачу.

Строки - это сложно

Это, действительно, сложно. Каждый язык программирования старается найти своё решение трудной задачи обработки, работы со строками. Методология Rust призвана сократить ошибки, поэтому функционал работы со строками реализован подобным образом.

Далее будет рассмотрена менее сложная тема - hash maps!