如何忽略大小写比较字符串(译文)

如何忽略大小写比较字符串 Matt Austern hotman_x

如何忽略大小写比较字符串

Matt Austern
hotman_x


hotman_x 曰:趁春节这几天,乱翻一气,也有翻不出来写原文的,也有“以意逆志”乱猜的,总之,姑妄译之,如果有兴趣,不妨姑妄读之。

--2006.02.05

如果你写过用到了字符串的程序(谁没写过?),就有可能遇到过这种情况:视两个仅大小写有差异的字符串为相同。也就是说,你会需要无视大小写的相等性比较、小于比较、子串匹配、排序。而且,说真的,关于标准C++ 库最常见的问题之一就是“如何忽略大小写比较字符串”。这个问题被问了很多次,而且被答错了很多次。

首先,你可能正在琢磨写一个“大小写无关字符串类”的法子,让我们来看看:是的,这在技术上是多少还是有点可能性的。标准库类型std::string 其实不过是这个模板的别名:std::basic_string<char, std::char_traits<char>, std::allocator<char> > 。它用traits 参数进行所有的比较,即:提供“重新定义好的进行相等性比较、小于比较”的traits 参数,你就可以以此方式来实例化basic_string ,这样一来,< == 运算符就是大小写无关的了。你可以这么干,但是如此伤神实在划不来:

  • 你没法做I/O—— 至少不吃点苦头是做不了。标准库中的I/O 类,如std::basic_istream std::basic_ostream ,也和std::basic_string 一样,是用字符类型及相关traits 来参数化的。(再说一次,std::ostream 仅仅是它的别名:std::basic_ostream<char, char_traits<char> > 。)Traits 参数必须完全匹配。如果你的字符串用的是std::basic_string<char, my_traits_class> ,那么你的流输出就得相应的用std::basic_ostream<char, my_traits_class> ,这么一来,你就不能用cin cout 这样常用的流对象。

  • “大小写无关”这个特性其实与对象无涉,而是与“如何使用对象”相关。你可能非常需要在此处不关心大小写而在彼处关心(比如说“大小写无关”是一个用户控制的选项)。为这两种情况搞出两个单独的类型出来,实在是在这两者之间不必要的人为设限。

  • 不大恰当。同其它traits 1 一样,char_traits 是一个小巧、简单、无状态的类。在后面的讨论中我们会看到,能正确的进行大小写无关比较的类决不可能具有这样的特性。

  • 不充分。即使所有basic_string 的成员函数都大小写无关了,当你需要用一个非成员的泛型算法(如std::search std::find_end )时还是用不上劲。如果为了提高效率,你决定将容纳basic_string 对象的容器换成字符串表格,你又用不上劲。

更好的解决之道,也是更符合标准库习惯的解决之道,应该是:在需要时明确的要求进行大小写比较。象string::find_first_of string::rfind 这些成员函数,就别麻烦它们了——所有这些功能在非成员的泛型算法中都有的,并且,泛型算法能足够方便的适应大小写无关字符串。举例来说,如果你需要对容器内的字符串进行大小写无关的排序,你所需要的不过是提供一个合适的比较函数对象:

std::sort(C.begin(), C.end(), compare_without_case);

文章的剩余部分我们就来讨论如何写出这个函数对象。

第一次试验

排列单词的方法非止一途。下次你去书店的时候,注意一下作者的名字是怎么安排的:Mary McCarthy 是在Bernard Malamud 前面呢,还是在后面?(这只是个习惯问题,两种情况我都见过。)不过,最简单的字符串比较还是我们在小学学的:逐字母序比较或曰“字典序”比较。这里,我们的字符串比较就从字符对字符的比较开始。

逐字母序比较可能不适用于特定的应用(没有那个法子能够适用于各种特定应用;对于人名和地名来说,一个专门的库可能更合适些),不过适合很多情况,而且这是C++ 中默认的“字符串比较”的含义。字符串是字符的序列,且若xystd::string 类型的对象,则表达式x<y 等效于表达式:std::lexicographical_compare(x.begin(), x.end(), y.begin(), y.end())

在这个表达式中,lexicographical_compare operator< 比较单个字符,但有个lexicographical_compare 版本允许你选择比较字符的方法。这个版本的lexicographical_compare 有五个参数,多出来的那个参数是一个函数对象——一个用来判定哪个字母排在另一个前面的Binary Predicate2 。为了使用lexicographical_compare 做大小写无关字符串比较,我们要做的就是搞出一个“能进行不关心大小写的字符比较”的函数对象来。

对字符做大小写无关比较,通常的想法就是把两个字符都变成大写的,再来比较。利用众所周知的标准C 库函数,可以直接了当的将这个想法实现为一个函数对象:

struct lt_nocase : public std::binary_function<char, char, bool> {
   bool operator()(char x, char y) const {
      return toupper(static_cast<unsigned char>(x)) <
toupper(static_cast<unsigned char>(y));
   }
};

“所有复杂的问题都存在这样一个解:它简单、整洁,而且错误。”写C++ 书的人特别擅长写这种类,因为可以搞出一个又好又简单的例子。我和其它人一样罪孽深重——这个例子在我的书里出现了好多次。它基本上是对的,但不够好,有点小问题。

这里有个例子,能让你看出问题之所在:

int main()
{
    const char* s1 = "GEW/334RZTRAMINER";
    const char* s2 = "gew/374rztraminer";
    printf("s1 = %s, s2 = %s/n", s1, s2);
    printf(
       "s1 < s2: %s/n",
       std::lexicographical_compare(
          s1, s1 + 14, s2, s2 + 14, lt_nocase()
       ) ? "true" : "false"
    );
}

你可以在你的系统上试一试。在我的系统上(一台运行IRIX 6.5 silicon Graphics O2 ),输出如下:

s1 = GEWÜRZTRAMINER, s2 = gewürztraminer
s1 < s2: true

嗯,好奇怪呀。如果是“大小写无关”的比较,"gewürztraminer" "GEWÜRZTRAMINER" 难道不是一样的吗?现在做一点小小的改动:如果你插入这么一行setlocale(LC_ALL, "de") ;printf 语句之前,输出就变成了这样子:

s1 = GEWÜRZTRAMINER, s2 = gewürztraminer
s1 < s2: false

大小写无关字符串比较远比它表面上看到的要复杂。这个看起来如此单纯的程序严重的依赖着一个我们总是忽略的东西:locale

Locales

一个字符无非是一个小整数而已。我们可以选择把一个小整数解释为一个字符,但这种解释并无统一的法则。一个特定的数字是被解释为一个字母?一个标点?抑或是一个不可见的控制字符?这个问题并不存在唯一正确答案,and it doesn't even make a difference as far as the core C and C++ languages are concerned 。一些库函数需要做出这种区分:

例如,isalpha ,用来决定一个字符是否字母;toupper ,用来将小写字母转换成大写,而对其它字符不做处理;所有这些都依赖于当地文化和语言习惯:字母与非字母的区别,在英语中是一回事,在瑞典语中则是另一回事。从小写转为大写,在罗马字母表与希腊字母表中也不是一回事,而在希伯莱语中则根本没有大小写这一回事。

默认情况下,字符操作函数在“处理简单英语文本”的字符集下工作。字符'/374' 不受toupper 的影响是因为它不是一个字母;在某些系统上打印出来时,它看起来是个ü ,但对于处理英语文本的C 库函数来说毫不相关——在ASCII 字符集中压根就没有ü 这个字母。这一行代码

setlocale(LC_ALL, “de”);

告诉C 库,下面开始按照德语习惯进行处理(至少在IRIX 系统上是这样。Locale 名称没有标准化)。德语中有字母ü ,于是toupper ü 转换为Ü

如果你还没有警惕,这可是时候了。虽然toupper 可能看起来不过是一个仅有一个参数的简单函数,其实它依赖一个全局变量——更有甚者,这是一个隐藏的全局变量。这导致了所有常见的难题:一个调用了toupper 的函数,潜在的依赖于整个程序中的其它每个函数!

如果你用toupper 来做大小写无关的字符串比较,这就会引起灾难。如果你使用了一个依赖于“已排好序的线性表”的算法(如二叉搜索),而一个新的locale 却悄悄的引起排序变化,想想会出现什么事?象这样的代码是不可复用的,亦不堪大用。你不能在“在多种情况下被引用”的库代码中用它,哪怕是在决不调用setlocale 的程序中也不行。你可能会转头又在一个大的程序中使用这些代码,但是你将会遇到一个维护问题:也许你能证明决没有其它模块调用过setlocale ,但你能证明明年这个程序的版本也没有其它模块调用setlocale 吗?

c 中,这个问题没有好的解法。因为C 库只有一个单一的全局locale ,就是这样。但是C++ 中则另有办法。

C++ 中的 Locales

C++ 标准库中,locale 不在是一个深藏于库实现代码中的全局数据。它是一个std::locale 类型的对象,而且你可以创建它,并象其它对象那样把它传递给函数。你可以创建一个locale 对象来代表常用的locale ,例如,象这样:

std::locale L = std::locale::classic();

或者你可以创建一个德语locale

std::locale L("de");

( 由于在 C 库中, locale 的名字没有标准化。查看你的库实现文档来找出可用的 locale 名称。 )

C++ 中的 locale 被分为多个截面( facet ),每个不同截面处理一个国际化的不同方面,而函数 std::use_facet 3 locale 对象中提取特定载面。其中 ctype 截面处理字符分类,包括大小写转换。

最后,如果c1c2char 类型,这个代码片段将会按照指定的locale L ,以大小写无关的方式比较这两个字符。

const std::ctype<char>& ct = std::use_facet<std::ctype<char> >(L);
bool result = ct.toupper(c1) < ct.toupper(c2);

有一个特定的缩写方法,你可以这么写:

std::toupper(c, L);

这(如果cchar 类型的话)与下面等同:

std::use_facet<std::ctype<char> >(L).toupper(c) ;

这可以有效的减少use_facet 的调用次数,不过,可能开销比较大。

题外话:另一个facet

如果你已经熟悉C++locale ,你可能已经想到了另一个比较字符串的方法:collate 截面正是为“封装排序的细节”而设,它有一个接口类似C 库函数strcmp 的成员函数。甚至还有一个小小的方便:如果L 是一个locale 对象,你可以写L(x, y) 来比较两个字符串,省去调用use_facet 再调用collate 成员函数的麻烦。

“经典”的locale 有一个collate 截面,专门来做逐字母比较,就象string < 操作符一样,只不过(与string < 操作符不同的是,)无论进行哪种比较,用的是其它的locale 。如果你的系统恰好有进行大小写无关比较所需的locale ,你可以用它。

不幸的是,这个搞法虽然正确,对于那些没有这种系统的人没什么帮助。可能有一天一组这样的locale 会标准化,但目前还没有。如果还没有人为你写一个大小写无关的比较函数,你只好自已写了。

大小写无关的字符串比较

使用ctype ,通过大小写无关的字符比较还建立大小写无关的字符串比较是相当直接的。这个版本没有优化,不过至少它是正确的,象上文一样,它本质上使用了正确的技术:用lexicographical_compare 比较两个字符串,而比较两个字符则是先将它们都转为大写。不过这一次,我们小心的使用locale 对象取代了全局变量。(作为一个副作用,“将两个字符转为大写”并不能保证与“将两个字符转为小写”给出相同的结果:因为并不能保证转换的结果是可逆的。例如,在法语中,习惯上大写状态是忽略重音标记的。这样,在法语中toupper 就顺理成章的变成了一个有损变换:它可能将'é''e' 都变换成同一个大写字母'E' 。在这样的locale 中,“使用toupper 的大小写无关的比较”会说'é''E' 是一码事,而“使用tolower 的大小写无关的比较”却说不是。哪个对?可能是前者,不过这依赖于语言、当地习俗、你的应用。)

struct lt_str_1
    : public std::binary_function<std::string, std::string, bool>
{
    struct lt_char {
       const std::ctype<char>& ct;
       lt_char(const std::ctype<char>& c) : ct(c) {}
       bool operator()(char x, char y) const {
          return ct.toupper(x) < ct.toupper(y);
    }
};

std::locale loc;
const std::ctype<char>& ct;

lt_str_1(const std::locale& L = std::locale::classic())
    : loc(L), ct(std::use_facet<std::ctype<char> >(loc))
{}

bool operator()(
   const std::string& x, const std::string& y
) const {
   return std::lexicographical_compare(
   x.begin(), x.end(), y.begin(), y.end(), lt_char(ct)
);
}
};

这还没怎么优化,比应有的速度要慢。这个问题是技术性的,令人恼火:我们在循环中调用toupper ,而C++ 标准要求toupper 进行一次虚拟函数调用。可能有些优化器聪明得很,会将虚拟函数调用的负担移到循还外边去,不过大部分优化器不会这么聪明。在循环中,虚拟函数调用的开销应当尽量避免。在本例中,做到这一点的方法不是那么直接。这诱导我们想到:正确答案应该是ctype 的另一个成员函数:

const char* ctype<char>::toupper(char* f, char* l) const ,

这个函数转换区间[f, l) 内所有字符为大写。可惜,对你来说,对你的目标来说,这个函数的接口并不合适。用它的话,得把两个字符串都复制到缓冲区里,然后将缓冲区变换为大写。缓冲区从哪来?这缓冲区不能是固定大小的数组,而动态分配数组则需要进行开销巨大的内存分配。

一个替代方案是:为每个字符做一次转换,缓存结果。这不是一个通用的解决法子,它有可能完全没法运转——比如说你用32UCS-4 字符。如果你用char (在大多数系统上是8 位),那么,在比较函数对象里维护一个256 字节的缓存还是可以的。

struct lt_str_2
: public std::binary_function<std::string, std::string, bool>
{
struct lt_char {
const char* tab;
lt_char(const char* t) : tab(t) { }
bool operator()(char x, char y) const {
return tab[x - CHAR_MIN] < tab[y – CHAR_MIN];
}
};

char tab[CHAR_MAX - CHAR_MIN + 1];
lt_str_2(const std::locale& L = std::locale::classic()) {
const std::ctype<char>& ct
= std::use_facet<std::ctype<char> >(L)
;
for (int i = CHAR_MIN; i <= CHAR_MAX; ++i)
tab[i - CHAR_MIN] = (char) i;
ct.toupper(tab, tab + (CHAR_MAX - CHAR_MIN + 1));
}
bool operator()(
const std::string& x, const std::string& y
) const {
return std::lexicographical_compare(
x.begin(), x.end(), y.begin(), y.end(), lt_char(tab)
);
}
};

如你所见,lt_str_1 lt_str_2 完全不一样。前者有一个直接用ctype 的字符比较函数对象,而后者的字符比较函数对象则用了一个预先计算的大写转换表。如果创建一个lt_str_2 函数对象,用来比较一些短字符串,然后扔掉它,那么可能会慢一些。不过,对于连续使用来说,lt_str_2 会明显的比lt_str_1 要快。在我的系统上,这个差别超过两倍:lt_str_1 排序23791 个单词用的0.86 秒,而lt_str_2 仅用0.4 秒。


从本文我们学到了什么?

  • 大小写无关的字符串类是一种错误的抽象。C++ 标准库中的算法,是以policy 来参数化的,你应该允分利用这一点。

  • 逐字母进行字符串比较建立在字符比较的基础之上。一旦你有了一个大小写无关的字符比较函数对象,这个问题就解决了。(而且,如果你可能重用这个函数对象来比较其它类型的字符序列,如vector<char> ,或者字符串表,如普通的C 字符串。)

  • 大小写无关的字符比较比想象的要困难,如果脱离了特定的locale 环境它就没有意义。如果要考虑速度,你应该写一个函数对象来避免反复调用高开销的截面操作。

  • 正确的大小写无关比较用到了很多机制,不过你只需要写一次。你可能不想考虑locale—— 大多数人都不想(90 年代的时候谁乐意考虑千年虫问题?),但要想清楚, 比于草草的处理locale 依赖问题,如果你把依赖于locale 的代码写对了,你更有可能跳脱于locale 问题之外。

1 参见 Andrei Alexandrescu 在四月号上的文章

2 hotman_x 曰:很想将Binary Predicate 翻译成“二元谓词”,但自已都觉得眼生,乃止。

3 警告:use_facet 是一个函数模板,它的模板参数只出现在返回值中,而不出现在任何参数中。要调用它须使用一个叫“显式模板参数特化”的语言特性,一些C++ 编译器还不支持这个特性。如果你在用的编译器不支持它,你的库实现者可能会提供一个折衷的法子让你可以用其它方式调用use_facet

9

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值