切分包含Unicode字符的字符串

30

我有一段文本,其中包含不同字节长度的字符。

let text = "Hello привет";

我需要根据给定的起始(包括)和结束(不包括)字符索引来截取字符串。我尝试了这个:


let slice = &text[start..end];

获得以下错误

thread 'main' panicked at 'byte index 7 is not a char boundary; it is inside 'п' (bytes 6..8) of `Hello привет`'

我猜这是因为 Cyrillic 字母是多字节的,[..] 表示法使用的是 字节 索引。如果我想使用 字符 索引进行切片,就像在 Python 中一样:

slice = text[start:end] ,我可以使用什么方法?

我知道我可以使用 chars() 迭代器并手动遍历所需的子字符串,但是否有更简洁的方法?


3
我认为 chars() 是这里的最佳选择:text.chars().take(end).skip(start) - Tim Diekmann
1
@TimDiekmann,如果API需要它,我该如何将Take<Chars>转换为&str - Sasha Tsukanov
你应该调用 collect()。请参考这个问题:https://dev59.com/9VoU5IYBdhLWcg3wvIqp - ozkriff
3
collect() 方法的结果是 String 类型,而不是 &str 类型。这就是我没有将此标记为您链接的问题的重复原因。 - Tim Diekmann
3个回答

46

代码点切片的可能解决方案

我知道可以使用 chars() 迭代器并手动遍历所需的子字符串,但是否有更简洁的方法?

如果你知道确切的字节索引,你可以对字符串进行切片:

let text = "Hello привет";
println!("{}", &text[2..10]);

这将打印出 "llo пр"。因此问题是找到确切的字节位置。您可以使用 char_indices() 迭代器轻松地完成这个任务(或者您也可以使用 chars()char::len_utf8()):

let text = "Hello привет";
let end = text.char_indices().map(|(i, _)| i).nth(8).unwrap();
println!("{}", &text[2..end]);

作为另一种选择,您可以先将字符串收集到Vec<char>中。然后,索引很简单,但要将其打印为字符串,您必须再次收集它或编写自己的函数来执行此操作。

let text = "Hello привет";
let text_vec = text.chars().collect::<Vec<_>>();
println!("{}", text_vec[2..8].iter().cloned().collect::<String>());

为什么这不那么容易?

正如您所看到的,这两种解决方案都不太好。这是有意为之的,原因有两个:

由于str是一个简单的UTF8缓冲区,按Unicode代码点进行索引是O(n)操作。通常,人们希望[]运算符是O(1)操作。Rust使这个运行时复杂度明确,并且不试图隐藏它。在上面的两个解决方案中,您可以清楚地看到它不是O(1)。

但更重要的原因是:

Unicode代码点通常不是有用的单位

Python所做的(以及您认为需要的)并不是非常有用。这完全取决于语言的复杂性以及Unicode的复杂性。Python对Unicode 代码点进行切片。这就是Rust的char所表示的。它有32位(几个较少的位就足够了,但我们向上舍入到2的幂次)。

但您实际想要做的是切割用户感知字符。但这是一个明确的松散定义的术语。不同的文化和语言将不同的事物视为“一个字符”。最接近的近似是“字形群集”。这样的聚集可以由一个或多个Unicode代码点组成。考虑以下Python 3代码:

>>> s = "Jürgen"
>>> s[0:2]
'Ju'

令人惊讶,不是吗?这是因为上面的字符串是:

  • 0x004A 大写字母 J
  • 0x0075 小写字母 u
  • 0x0308 连接变音符
  • ...

这是一个作为前一个字符的一部分呈现的连接字符的示例。在这里,Python切片做了“错误”的事情。

另一个例子:

>>> s = "fire"
>>> s[0:2]
'fir'

同样不是您预期的内容。这次,fi实际上是连字号,它是一个码位。

Unicode有更多令人惊讶的行为示例。有关更多信息和示例,请参见底部的链接。

因此,如果您想使用可在任何地方正常工作的国际字符串,请勿进行码位切片!如果您确实需要将字符串语义视为一系列字符,请使用字符簇。为此,crate unicode-segmentation非常有用。


此主题的其他资源:


为了使 let end = text.char_indices().map(|(i, _)| i).nth(8).unwrap(); 在我们想要切片到字符串中的最后一个代码点(例如,使用12作为排除边界)时能够正常工作,我们需要更多的工作。可以添加类似于 let end = if end_codepoint_idx == text.chars().count() {text.len()} else { text.char_indices().map(|(i, _)| i).nth(end_codepoint_idx).unwrap();}; 的内容。 - Sasha Tsukanov
4
unicode-segmentation库不能解决“fire”这个例子-它不能将连字分成两个字符。(我并不是说它应该这样做-只是澄清一下,因为这个答案可能会给人留下不同的印象。) - Sven Marnach

12

一个UTF-8编码的字符串可能包含由多个字节组成的字符。在你的情况下,п从第6个位置(包括)开始,并在第8个位置(不包括)结束,所以索引7不是字符的起始位置。这就是为什么出现了错误。

你可以使用str::char_indices()来解决这个问题(记住,访问UTF-8字符串中的某个位置是O(n)的):

fn get_utf8_slice(string: &str, start: usize, end: usize) -> Option<&str> {
    assert!(end >= start);
    string.char_indices().nth(start).and_then(|(start_pos, _)| {
        string[start_pos..]
            .char_indices()
            .nth(end - start - 1)
            .map(|(end_pos, _)| &string[start_pos..end_pos])
    })
}

游乐场

如果您愿意得到一个String,则可以使用str::chars()

let string: String = text.chars().take(end).skip(start).collect();

3
这个函数不能像预期那样工作,出现了紧急情况例子:get_utf8_slice("héllo", 2, 3)。通过将最后一个范围 [start_pos..end_pos] 替换为 [start_pos..start_pos+end_pos] 可以解决这个问题。 - yolenoyer
这适用于字形簇吗? - nullspace

4
下面是一个函数,用于检索UTF8字符片段,具有以下优点:
  • 处理所有边缘情况(空输入、0宽度输出范围、超出范围的范围);
  • 不会引起恐慌;
  • 使用起始包含,结束不包含的范围。
pub fn utf8_slice(s: &str, start: usize, end: usize) -> Option<&str> {
    let mut iter = s.char_indices()
        .map(|(pos, _)| pos)
        .chain(Some(s.len()))
        .skip(start)
        .peekable();
    let start_pos = *iter.peek()?;
    for _ in start..end { iter.next(); }
    Some(&s[start_pos..*iter.peek()?])
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接