Avatar ·

Почему в Rust превращение первой буквы строки в заглавную — такая нетривиальная задача?

Привет, коллеги! Я относительно недавно начал осваивать Rust, перейдя с Python и JavaScript. Там всё просто: `"hello".capitalize()` или `str[0].toUpperCase() + str.slice(1)`. Но в Rust я столкнулся с тем, что даже такая базовая операция, как сделать первую букву строки заглавной, требует танцев с бубном. Я написал такой код: ```rust fn capitalize_first(s: &str) -> String { let mut chars = s.chars(); match chars.next() { Some(first) => first.to_uppercase().to_string() + chars.as_str(), None => String::new(), } } fn main() { println!("{}", capitalize_first("hello")); // хочу "Hello" println!("{}", capitalize_first("привет")); // хочу "Привет" } ``` На первый взгляд, всё работает. Но когда я попробовал обработать строку `"straße"` (немецкое слово "улица"), я получил `"Straße"`, хотя ожидал `"Straße"` (в немецком ß при капитализации превращается в SS, и длина строки меняется!). А если я передам эмодзи или акцентированные символы, всё становится ещё страннее. Почему Rust не предоставляет простого метода `.capitalize()` как в Python? Почему нужно вручную разбирать символы, и даже тогда легко ошибиться? Есть ли идиоматичный способ, который будет работать корректно для Unicode, включая символы, меняющие длину при изменении регистра? Заранее спасибо за разъяснение! Мне кажется, я упускаю какую-то фундаментальную концепцию дизайна языка. <<>>

Почему в Rust нет простого .capitalize()

Отличный вопрос! Вы столкнулись с одним из ключевых отличий Rust от языков со сборкой мусора: Rust ставит во главу угла корректность и производительность, а не удобство написания кода для 90% случаев. Проблема в том, что "сделать первую букву заглавной" — это на самом деле сложная операция, если учитывать Unicode.

Основные подводные камни

  • Изменение длины строки: как вы заметили с "straße" → "STRASSE" (один символ превращается в два). В Python это скрыто за кулисами, а Rust хочет явности.
  • Графемные кластеры: эмодзи или буквы с диакритикой (например, "é") могут состоять из нескольких Unicode-скаляров. Разбивка по .chars() может разорвать такой кластер.
  • Локаль: в турецком языке буква "i" заглавная — "İ" (с точкой), а не "I". Rust не учитывает локаль по умолчанию.

Идиоматичное решение с крейтом unicode_casing

Лучший способ — использовать специализированный крейт. Вот пример с unicode_casing (часть экосистемы ICU4X):

// Cargo.toml
// [dependencies]
// unicode_casing = "0.1"

use unicode_casing::CharExt;

fn capitalize_first(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        Some(first) => {
            // to_uppercase() возвращает итератор, т.к. результат может быть длиннее 1 символа
            let upper: String = first.to_uppercase().collect();
            upper + chars.as_str()
        }
        None => String::new(),
    }
}

fn main() {
    assert_eq!(capitalize_first("hello"), "Hello");
    assert_eq!(capitalize_first("straße"), "Straße"); // Правильно! ß → SS
    assert_eq!(capitalize_first("привет"), "Привет");
    // Для эмодзи: первый символ-эмодзи остаётся без изменений (как и ожидается)
    assert_eq!(capitalize_first("😀hello"), "😀hello");
}

Почему это работает корректно?

  • to_uppercase() возвращает итератор, а не один char, потому что результат может быть длиннее (как в случае с ß).
  • chars.as_str() возвращает оставшуюся часть строки без выделения новой памяти (это срез исходной строки).
  • Мы не трогаем остальные символы, поэтому графемные кластеры не разрываются.

Что делать, если не хочется добавлять крейт?

Стандартная библиотека Rust намеренно не включает функции, зависящие от Unicode-свойств (кроме базовых), чтобы держать размер стандартной библиотеки минимальным. Но вы можете использовать тот же подход со встроенным to_uppercase() — он уже корректно обрабатывает изменение длины. Главное — не пытайтесь взять s[0] (индексация по байтам сломает многобайтовые символы) и не используйте .nth(0) без учёта графем.

Альтернатива для полного контроля

Если вам нужно учитывать локаль (например, турецкий язык), используйте крейт icu (ICU4X):

use icu::locid::locale;
use icu::casemap::CaseMapper;

let mapper = CaseMapper::new();
let result = mapper.uppercase("istanbul", &locale!("tr").into());
assert_eq!(result, "İSTANBUL");

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

<<>>

Более простой (но менее корректный) способ

Если вы уверены, что ваши строки содержат только ASCII-символы (латиница, цифры, базовые знаки препинания), то можно использовать упрощённый подход без внешних крейтов. Это будет быстрее и не потребует зависимостей, но сломается на любом не-ASCII символе.

fn capitalize_first_ascii(s: &str) -> String {
    let mut bytes = s.as_bytes().to_vec();
    if let Some(first) = bytes.first_mut() {
        if first.is_ascii_lowercase() {
            *first = first.to_ascii_uppercase();
        }
    }
    // Безопасно, т.к. мы не меняем длину и не нарушаем UTF-8
    String::from_utf8(bytes).unwrap()
}

fn main() {
    assert_eq!(capitalize_first_ascii("hello"), "Hello");
    // Некорректно для Unicode:
    assert_eq!(capitalize_first_ascii("straße"), "Straße"); // ß не изменился!
    assert_eq!(capitalize_first_ascii("привет"), "привет"); // Кириллица не тронута
}

Когда это уместно?

  • Вы обрабатываете только английские идентификаторы (например, имена полей в JSON).
  • Производительность критична, и вы готовы пожертвовать корректностью для редких случаев.
  • Вы пишете код для embedded-систем, где каждый крейт на счету.

Итог

Выбирайте подход в зависимости от контекста: для пользовательского ввода всегда используйте Unicode-корректное решение с крейтом. Для внутренних форматов данных (где вы контролируете содержимое) ASCII-версия может быть оправдана. Rust даёт вам выбор, а не навязывает "универсальное" решение, которое может быть неверным в 5% случаев.