openlang

Обрезал строку по байтам, как в C++, а Rust запаниковал

Переношу на Rust кусок старого C++, который обрезает std::string до лимита в байтах обычным s.substr(0, n), и думал, что это одна строчка. В C++ оно как бы работало, хотя std::string про UTF-8 ничего не знает и спокойно отрезает полбуквы, а в кириллице каждая буква - два байта, так что при нечётном лимите хвост получался битым, и этого никто не замечал, потому что, похоже, дальше эти байты никто не декодировал. А Rust на первом же русском имени сделал &s[..n] и упал с byte index 5 is not a char boundary, и тут я начал сомневаться вообще во всём.

Вариантов вижу несколько. Можно идти назад от n, пока s.is_char_boundary(i) не вернёт true, это максимум три шага, потому что символ в UTF-8 не длиннее четырёх байт. Можно пройти char_indices() и взять последнюю позицию, где символ ещё влезает целиком. А если лимит на самом деле не про байты, а про то, что видит человек, тогда неправильно и то и другое, и я, честно говоря, не знаю, откуда этот лимит вообще взялся, кажется, из ширины колонки в базе, а если из базы, то, может, и считать должна сама база.

Пока что цикл.

fn cut(s: &str, n: usize) -> &str {
    if n >= s.len() {
        return s;
    }
    let mut i = n;
    while !s.is_char_boundary(i) {
        i -= 1;
    }
    &s[..i]
}

cut("Привет", 5) даёт Пр, четыре байта, а нулевой лимит даёт пустую строку вместо паники. Он такой короткий, что я могу объяснить каждую строчку, и сейчас для меня это важнее, чем быть хитрым.

Разговор продолжается 6

Следуя доводам
  1. Ссылка на ответ

    Если эталоном служит сторона C++, полезно посмотреть, что она на самом деле писала. substr считает байты и больше ничего.

    std::string s = "Привет";
    std::string t = s.substr(0, 5);
    std::cout << t.size() << ' ' << std::hex
              << int(static_cast<unsigned char>(t.back())) << '\n';

    Печатается 5 d0. Последний байт - это ведущий байт «и», а продолжения нет. Имя целиком на кириллице, обрезанное по нечётному лимиту, каждый раз кончается так, поэтому битые хвосты предсказуемы, и это помогает, когда их ищешь.

  2. Ссылка на ответ

    Пометки. Известно. С Rust 1.91 в стабильной версии есть str::floor_char_boundary, он делает то же, что ваш цикл назад, то есть находит ближайшую границу не больше n. В документации к нему сразу сказано, что графемы так всё равно можно разрезать.

    Неизвестно. Что лежит в данных, которые старый C++ уже записал. Если хвосты там битые, String::from_utf8 на них вернёт ошибку, а String::from_utf8_lossy молча подставит U+FFFD. Это два разных решения, и выбрать между ними нужно до чтения, а не когда что-то упадёт.

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

  3. Ссылка на ответ

    Граница символа - ещё не граница буквы, так что радоваться рано. «й» может прийти одной кодовой точкой, а может как «и» плюс комбинируемая бреве, U+0306, два скаляра и четыре байта. Режешь между ними, получаешь валидный UTF-8 и «и», которую никто не набирал. В документации floor_char_boundary то же самое показано на эмодзи из нескольких скаляров.

    Дешёвое решение сводится к тому, чтобы нормализовать имена из ввода в NFC до обрезки, тогда «й» снова одна кодовая точка. Но готовая составная форма есть не у всего, а для остального нужна сегментация графем, которой нет ни в стандартной библиотеке Rust, ни в стандартной библиотеке C++. Бесит, но теперь хотя бы понятно, какой баг всё ещё твой.

  4. Ссылка на ответ

    fretboard, прогнал старый дамп, как вы советовали, и там всё грязнее, чем я надеялся. Большинство битых записей такие, как я ждал, error_len() возвращает None, а valid_up_to() стоит за байт до конца, и это след обрезки по байтам и ничего больше. Их я режу по valid_up_to() и U+FFFD не подставляю, потому что замена занимает три байта там, где был один, и может снова вытолкнуть значение за лимит.

    Но несколько записей вернули Some(1) посреди строки, байт больше 0x7F, за которым не идёт правильная последовательность, а это уже совсем не обрезка. Похоже, какой-то старый путь писал имена в однобайтовой кодировке, Latin-1 или Windows-1251, а эти две прочитают один и тот же байт как разные буквы, и по одному байту я никак не пойму, какая была. Если резать такие по valid_up_to(), теряется остаток имени, а если пропустить через from_utf8_lossy, получится U+FFFD посреди чьей-то фамилии.

    Остальные уходят в отдельную таблицу нетронутыми, пока я не выясню, что их записало, и если тот старый путь где-то ещё работает, таблица будет расти. А по целому имени можно отличить Latin-1 от 1251, раз по одному байту нельзя? В 1251 кириллическая фамилия почти целиком состоит из байтов от 0xC0 и выше, а имя в Latin-1 в основном ASCII с редким акцентом, так что выдать может само соотношение. Просто не хочется, чтобы эвристика решала, как кого зовут.

  5. Ссылка на ответ

    Разведите два случая в коде, и вторая таблица будет наполняться сама. Подвох в том, что from_utf8 сообщает, какой у вас случай, только через error_len().

    fn keep_len(bytes: &[u8]) -> Result<usize, std::str::Utf8Error> {
        match std::str::from_utf8(bytes) {
            Ok(_) => Ok(bytes.len()),
            Err(e) if e.error_len().is_none() => Ok(e.valid_up_to()),
            Err(e) => Err(e),
        }
    }

    Кириллическое имя, обрезанное на пяти байтах, даёт Ok(4). b"Ren\xe9e" даёт Err с error_len: Some(1). Считайте строки с Err до и после каждого импорта, и станет ясно, пишет ли их ещё старый путь.

Другие стороны того же вопроса

  1. `isdigit()` пропустил надстрочную двойку, которую `int()` не принял

    Поле количества в небольшой форме проверяет s.isdigit(), а потом вызывает int(s). Кто-то вставил ² и получил ValueError ровно…

    Ответы 4python
  2. Разумно ли по умолчанию искать имена по ключу NFC?

    Так, пользователь вставляет имя, а в конце у него e и следом U+0301. Потом он набирает то же имя руками, и клавиатура даёт U+…

  3. Ключи поиска NFC и подписи JCS

    Подпись усложняет вопрос kim_dh про ключи поиска NFC, где отправленное имя отделено от ключа поиска NFC. В JSON-нагрузке есть…

    Ответы 4typescript