给你这样一个需求:实现一个函数,我们将用这个函数来计算用户在注册账号时输入的用户名长度,当用户名长度超过限制5时,我们会阻止用户使用这个用户名。函数输入为UTF-8编码的std::string
。
这听起来似乎很容易,一行代码就能够搞定:
cpp
int stringLength(const std::string& s) { return s.size(); }
注册我们账号的第一位用户叫做Mary,stringLength
不负众望地给我们返回了4,这让她通过了用户名长度检测,顺利注册了账号。
第二位用户叫做东方不败,他的名字看起来也只有4个字,应该也能够顺利通过用户名长度检测。然而事与愿违,用户界面提示用户名超长,通过一番调试,我们发现stringLength
返回了12。
虽然初看起来有点奇怪,但是作为一个程序员,经过短暂的思考以后,我们不难弄清楚,一个英文字母和一个汉字虽然都对应一个Unicode code point,但是在使用UTF-8编码时,英文字母和汉字由于code point的范围不同,它们的"地位"是不同的。
First code point | Last code point | Byte 1 | Byte 2 | Byte 3 | Byte 4 |
---|---|---|---|---|---|
U+0000 | U+007F | 0yyyzzzz |
|||
U+0080 | U+07FF | 110xxxyy |
10yyzzzz |
||
U+0800 | U+FFFF | 1110wwww |
10xxxxyy |
10yyzzzz |
|
U+010000 | U+10FFFF | 11110uvv |
10vvwwww |
10xxxxyy |
10yyzzzz |
之所以会得到12,是因为常用汉字的Unicode Block "CJK Unified Ideographs" code point范围为U+4E00
~U+9FFF
,使用UTF-8编码时占3个字节,3x4=12:
东方不败
U+4E1C U+65B9 U+4E0D U+8D25
0xE4 0xB8 0x9C 0xE6 0x96 0xB9 0xE4 0xB8 0x8D 0xE8 0xB4 0xA5
但是作为一名对Unicode/UTF-8一窍不通的普通用户,东方不败大概不会满意我们的解释,他只知道自己心爱的名字"东方不败"4个字因为超出长度限制5而无法注册,这太荒谬了。为了让这名用户满意,我们需要实现一个stringLength_v2
来纠正这个问题。
在stringLength_v2
中,std::string::size
将派不上用场,它代表的是字符串占用了几个字节,而不是里面有几个文字。由于C++标准库中并没有可靠的Unicode编解码接口,此时我们最好依赖三方库,使用icu
将UTF-8编码的std::string
转换成icu::UnicodeString
,然后"数一数"里面到底有几个code point,按我们目前的理解,一个code point就是一个字,有几个code point就是有几个字。
cpp
int stringLength_v2(const std::string& s) {
icu::UnicodeString ustr = icu::UnicodeString::fromUTF8(s);
int32_t length = 0;
for (int32_t i = 0; i < ustr.length();) {
UChar32 c;
U16_NEXT(ustr.getBuffer(), i, ustr.length(), c);
if (c >= 0) {
++length;
}
}
return length;
}
需要注意的是,icu::UnicodeString
内部使用UTF-16存储数据,因此ustr.length()
并不是code point的个数,我们必须自己来数。
将"东方不败"4个字传给stringLength_v2
以后,我们如愿得到了4,用户东方不败也顺利注册了自己的账号。
又来了一位用户,他的名字叫做 👨👩👧👦,你不要觉得奇怪,这个世界发展很快,也许以后每个人都会把emoji放进自己的名字,毕竟emoji也是Unicode,而且还是很酷的Unicode。这个用户名看起来只有一个"字",但他也没能顺利地通过用户名长度检测,我们惊奇地发现,stringLength_v2
给我们返回了7。
这次又是哪里弄错了呢?stringLength_v2
数出来有7个code point,但是这个用户的名字看起来只有一个"字"。把7个code point都打印出来,它们分别是:
👨👩👧👦
U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466
他的名字确实有7个code point,但显示时又只占据了一个字符的空间。经过一番学习,我们又了解到了grapheme cluster和combining character的概念,简而言之,用户看到的一个"字",并不对应着一个code point,而是对应着一个grapheme cluster。而一个grapheme cluster可能是由多个code point组成的。除了emoji,还有下面这些例子,它们都是包含combining character的grapheme cluster,我们可以使用C++的\u
转义来构造它们:
"\u092b\u093f" -> फि
"\u0061\u0308\u0303\u0323\u032d" -> ạ̭̈̃
"\u0e02\u0e36\u0e49" -> ขึ้
为了让用户👨👩👧👦能够顺利注册账号,我们需要再实现一个stringLength_v3
,这个版本不再计算code point的个数,而是计算grapheme cluster的个数,它才是用户看到的字符的个数。
cpp
int stringLength_v3(const std::string& s) {
UErrorCode status = U_ZERO_ERROR;
icu::UnicodeString ustr = icu::UnicodeString::fromUTF8(s);
std::unique_ptr<icu::BreakIterator> bi(
icu::BreakIterator::createCharacterInstance(icu::Locale::getDefault(),
status));
if (U_FAILURE(status) || !bi) {
return -1;
}
bi->setText(ustr);
int count = 0;
for (int32_t start = bi->first(), end = bi->next();
end != icu::BreakIterator::DONE; start = end, end = bi->next()) {
++count;
}
return count;
}
在stringLength_v3
的帮助下,用户👨👩👧👦成功注册了账号,他对stringLength_v3
表示了感谢。不过需要注意的是,Unicode并没有规定一个grapheme cluster中code point数量的上限,恶意用户完全可以构造一个超长的单一grapheme cluster来消耗我们宝贵的资源,因此有必要为s.size()
也加上一个限制,比如说64。如果真的有善良的用户通过了stringLength_v3
考验,但是没能通过s.size()
的考验,我们可以考虑把他介绍给我们的竞争对手,他们拥有丰富的资源。
stringLength_v3
让所有用户顺利注册了账号,皆大欢喜的结局!
一些有用的URL:
- https://www.compart.com/en/unicode/plane/U+0000: 拥有整套的Unicode数据集,可以让你获取到每个Unicode code point你想了解的所有信息
- https://www.compart.com/en/unicode/block/U+4E00: Unicode Block "CJK Unified Ideographs"
- https://home.unicode.org/: Unicode官网
- https://www.unicode.org/versions/latest/: Unicode标准最新版本
- https://unicode.org/emoji/charts/full-emoji-list.html: Emoji全集
- https://icu.unicode.org/: icu官网
- https://en.wikipedia.org/wiki/Unicode: Unicode维基百科
- https://en.wikipedia.org/wiki/UTF-8: UTF-8维基百科
最后附上完整的代码和输出。
代码
cpp
#include <unicode/brkiter.h>
#include <unicode/locid.h>
#include <unicode/unistr.h>
#include <unicode/utypes.h>
#include <iostream>
#include <string>
#include <vector>
int stringLength(const std::string& s) { return s.size(); }
int stringLength_v2(const std::string& s) {
icu::UnicodeString ustr = icu::UnicodeString::fromUTF8(s);
int32_t length = 0;
for (int32_t i = 0; i < ustr.length();) {
UChar32 c;
U16_NEXT(ustr.getBuffer(), i, ustr.length(), c);
if (c >= 0) {
++length;
}
}
return length;
}
int stringLength_v3(const std::string& s) {
UErrorCode status = U_ZERO_ERROR;
icu::UnicodeString ustr = icu::UnicodeString::fromUTF8(s);
std::unique_ptr<icu::BreakIterator> bi(
icu::BreakIterator::createCharacterInstance(icu::Locale::getDefault(),
status));
if (U_FAILURE(status) || !bi) {
return -1;
}
bi->setText(ustr);
int count = 0;
for (int32_t start = bi->first(), end = bi->next();
end != icu::BreakIterator::DONE; start = end, end = bi->next()) {
++count;
}
return count;
}
int main() {
std::vector<std::string> names = {"Mary",
"东方不败",
"👨👩👧👦",
"\u0061\u0308\u0303\u0323\u032d",
"\u0e02\u0e36\u0e49",
"\u092b\u093f"};
for (const std::string& name : names) {
printf("%2d %2d %2d %s\n", stringLength(name), stringLength_v2(name),
stringLength_v3(name), name.c_str());
}
return 0;
}
编译
bash
g++ -licuuc main.cpp
输出
4 4 4 Mary
12 4 4 东方不败
25 7 1 👨👩👧👦
9 5 1 ạ̭̈̃
9 3 1 ขึ้
6 2 1 फि