openlang

C++처럼 바이트 단위로 자르다가 만난 Rust panic

오래된 C++ 코드를 Rust로 옮기고 있는데, std::string을 바이트 한도에 맞춰 s.substr(0, n)으로 자르는 부분이 있어요. 한 줄이면 될 줄 알았죠. C++에서는 그럭저럭 돌아갔어요. std::string은 UTF-8을 전혀 모르니까 글자를 반으로 잘라도 그냥 넘어가고, 키릴 문자는 한 글자가 2바이트라 한도가 홀수면 끝이 깨졌는데, 그 바이트를 나중에 디코딩하는 곳이 없었는지 아무도 몰랐어요. 그런데 Rust는 첫 러시아어 이름에서 &s[..n]을 하다가 byte index 5 is not a char boundary로 죽었고, 거기서부터 전부 의심스러워졌어요.

방법은 몇 가지 보여요. n에서 거꾸로 가면서 s.is_char_boundary(i)가 참이 될 때까지 내려가는 것. UTF-8 문자는 4바이트를 넘지 않으니까 많아야 세 번이에요. 아니면 char_indices()로 돌면서 문자가 통째로 들어가는 마지막 위치를 잡는 것. 그리고 만약 이 한도가 사실 바이트가 아니라 사람이 보는 글자 기준이라면 둘 다 틀린 거고, 솔직히 이 한도가 애초에 어디서 왔는지도 모르겠어요. 아마 DB 컬럼 크기였던 것 같은데, 그렇다면 세는 일도 DB가 해야 하는 거 아닐까 싶기도 하고요.

일단은 루프로 가요.

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)는 4바이트짜리 Пр를 돌려주고, 한도가 0이면 panic 대신 빈 문자열이 나와요. 줄마다 뭘 하는지 다 말할 수 있을 만큼 짧은데, 지금 저한테는 그게 영리한 것보다 더 중요해요.

이어지는 대화 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이 붙은 스칼라 두 개, 4바이트로 올 수도 있어. 그 사이를 자르면 UTF-8은 멀쩡하고 아무도 안 친 "и"가 남아. floor_char_boundary 문서도 스칼라 여러 개로 된 이모지로 같은 걸 보여 줘.

    싼 해결책은 자르기 전에 입력 이름을 NFC로 정규화하는 거야. 그러면 "й"는 다시 코드 포인트 하나가 돼. 근데 전부 조합형이 있는 건 아니고, 나머지는 자소 클러스터 분할이 필요한데 Rust 표준 라이브러리도 C++ 표준 라이브러리도 그건 안 줘. 짜증 나지만, 적어도 이제 어떤 버그가 아직 네 몫인지는 알잖아.

  4. 고유 링크

    fretboard 님 말대로 예전 덤프를 돌려 봤는데, 생각보다 지저분해요. 깨진 레코드 대부분은 예상대로였어요. error_len()이 None을 돌려주고 valid_up_to()가 끝에서 한 바이트 앞에 있는데, 이건 바이트 단위로 자른 흔적일 뿐 다른 게 아니에요. 그런 건 valid_up_to()에서 자르고 U+FFFD로 바꾸지 않아요. 대체 문자는 1바이트 자리에 3바이트를 쓰니까 값이 다시 한도를 넘을 수 있거든요.

    그런데 몇 개는 문자열 중간에서 Some(1)이 나왔어요. 0x7F보다 큰 바이트 뒤에 올바른 시퀀스가 이어지지 않는 거고, 이건 자른 게 전혀 아니에요. 아마 예전 어떤 경로가 이름을 Latin-1이나 Windows-1251 같은 1바이트 인코딩으로 쓴 것 같은데, 두 인코딩은 같은 바이트를 다른 글자로 읽고, 바이트만 봐서는 어느 쪽이었는지 알 방법이 없어요. 그걸 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),
        }
    }

    5바이트에서 자른 키릴 문자 이름은 Ok(4)가 된다. b"Ren\xe9e"는 error_len: Some(1)이 달린 Err가 된다. 가져오기 전후로 Err 행을 세어 보면 예전 경로가 아직도 그런 걸 쓰고 있는지 알 수 있다.

같은 질문의 다른 갈래

  1. `isdigit()`는 통과시키고 `int()`는 거부한 위첨자 2

    작은 폼의 수량 입력란이 s.isdigit()로 검사한 다음 int(s)를 불러요. 누군가 ²를 붙여 넣었더니, 바로 그 검사가 지켜 주기로 한 줄에서 ValueError가 났어요. 둘은 서로 다른 규칙을 써요. isdigi…

    답글 4python
  2. 이름 조회에 NFC 키를 기본으로 써도 될까요?

    자, 사용자가 이름을 붙여 넣었는데 끝이 e 다음에 U+0301이에요. 나중에 같은 이름을 직접 치면 키보드가 U+00E9를 넣어 줘요. 그 사람한테는 같은 이름이고 캐노니컬 등가이기도 한데, 문자열로는 여전히 달라요. 제…

  3. NFC 조회 키와 JCS 서명

    kim_dh가 앞서 올린 NFC 조회 키 질문은 제출된 이름과 NFC 조회 키를 나눠서 봤는데, 서명이 끼면 더 복잡해져요. JSON 페이로드의 이름이 e 뒤에 U+0301을 붙인 형태이고, 그 문자열이 JCS 기반 서명에…