附录A:区域设置和忽略大小写的字符串比较

优质
小牛编辑
137浏览
2023-12-01

条款35解释了怎样使用mismatch和lexicographical_comapre实现忽略大小写的字符串比较,但是它也指出一个真正通用的解决方案必须考虑区域设置。本书是关于STL的,而非国际化的,因此我几乎没有提到任何关于区域设置的东西。不过,Matt Austern,《Generic Programming and the STL》[4]的作者,在2000年5月《C++ Report》[11]的专栏里提到了涉及区域设置的忽略大小写字符串比较。为了完整地讲述这个重要的主题,我很高兴能在这里再版他的专栏,而且我感谢Matt和101communications准许我这么做。

如何进行忽略大小写的字符串比较
Matt Austern

如果你写的程序曾经用到过string(谁没有吗?),有时候可能你需要处理两个除了大小写不同其他都相同的字符串。即,你需要让比较——相等、小于、子串匹配、排序——都忽略大小写。而且,的确,关于标准C++库的最常见问题之一是怎样使string忽略大小写。这个问题已经被回答很多次了。很多答案是错误的。

首先,让我们放弃试图写忽略大小写string类的想法。是的,它在技术上多多少少是可能的。标准库类型std::string其实只是一个模板std::basic_string<char, std::char_traits<char>, std::allocator<char> >的别名。所有的比较都使用了特性参数,所以,通过提供一个正确地重定义了相等和小于的特性参数的方法,你可以实例化basic_string,使<和==操作符是忽略大小写的。你可以这么做,但没必要那么麻烦。

  • 你将不能做I/O,至少不能再没有很多痛苦的情况下进行。标准库里的I/O类,比如std::basic_istream和std::basic_ostream,与std::basic_string一样在字符类型和特性上模板化。(再次强调,std::ostream只是一个std::basic_ostream<char, char_traits<char> >的别名。)特性参数必须匹配。如果你对字符串使用std::basic_string<char, my_traits_class>,你就必须对流使用std::basic_ostream<char, my_traits_class>。你不能使用比如cin和cout那样普通的流对象。
  • 忽略大小写不涉及一个对象,而涉及你怎样使用一个对象。你可能在一些情况下非常需要把一个string当作关注大小写而在其他情况下忽略大小写。(或许取决于用户控制的选项。)为这两种应用定义不同的类型是在它们之间放置人造障碍。
  • 它并不合适。正如所有的特性类[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++里的默认意思。字符串是字符的序列,如果x和y的类型是std::string,表达式x < y等价于这个表达式

std::lexicographical_compare(x.begin(), x.end(), y.begin(), y.end()).

在这个表达式中,lexicographical_compare使用operator<比较单个字符,但还有一个版本的lexicographical_compare让你选择自己的比较字符的方法。那个版本带有五个实参,而不是四个;最后一个实参是一个函数对象,确定两个字符哪个应该在另一个之前的二元判定式。然后,为了用lexicographical_compare进行忽略大小写比较,我们需要把它和忽略大小写的字符比较函数对象结合起来。

在字符的忽略大小写的比较后面的一般的想法是把两个字符都转化成大写字母然后比较结果。这里把那个想法显而易见的转换成一个C++函数对象,使用了来自标准C库的一个广为人知的函数:

struct lt_nocase
	: public std::binary_function<char, char, bool> {
	bool operator()(char x, char y) const {
 		return std::toupper(static_cast<unsigned char>(x)) <
				std::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("sl < 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”不同吗?现在做一个轻微的变化:如果你在printf语句之前插入这行

setlocale(LC_ALL, "de");

,突然输出改变了:

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

忽略大小写的字符串比较比看起来更复杂。这表面上正确的程序非常依赖于大多数人经常忽略的东西:区域设置。

区域设置

一个char真的无异于一个小的整数。我们可以选择把一个小的整数解释成一个字符,但这种解释并不通用。一些特定的数字应该被解释为一个字母、一个标点符号还是一个不能打印的控制字符?

没有一个正确的答案,而且直到关系到C和C++语言核心之前它们没有任何不同。需要靠一些库函数产生那些区别:例如,isalpha确定了一个字符是否是字母,toupper把小写字母转换成大写而对大写字母或不是字母的字符则什么都不做。所有那些都取决于本地文化和语言习惯:字母和非字母之间的区别在英语中是一个意思,在瑞典语则是另一个意思。从小写到大写的转换在罗马和斯拉夫字母表中表示不同的东西,而在希伯来语中则没有任何意义。

默认情况下,字符操作函数适用于简单的英语文字字符集。字符'\374'不受toupper影响,因为它不是一个字母;在一些系统上打印时它可能看起来像ü,但那和操作英语文字的C库程序不相干。在ASCII字符集里没有ü字符。这行

setlocale(LC_ALL, "de");

告诉C库开始根据德语习惯操作。(至少它在IRIX上是那样。区域的名字没有标准化。)德语中有字符ü,因此toupper把ü改为Ü。

如果这还不使你紧张,那么马上就会。虽然toupper可能看起来像带有一个实参的简单函数,但它也依赖于一个全局变量——不好,一个隐藏的全局变量。这引发了所有常见的困难:使用toupper的函数潜在地依赖于整个程序中的任何一个其他函数。

如果你把toupper用于忽略大小写的字符串比较,这可能是灾难性的。如果你有一个依赖于有序list的算法(比如binary_search),然后一个新的区域设置引发了它后面的排序顺序的改变,那将发生什么?像这样的代码不可复用:只是勉强可用。你不能在库里使用它——库可以用于任何种类的程序,不只是从未调用setlocale的程序。你可能在一个大的程序里使用它却侥幸逃过一劫,但你将有一个维护问题:或许你能证明没有其他模块调用了setlocale,但你能证明在程序明年的版本里没有其他模块调用setlocale吗?

这个问题在C里没有好的解决方案。C库只有一个全局的区域设置,没别的了。在C++里有一个解决方案。

C++中的区域设置

C++标准库里的区域设置不是深深地埋藏在库实现里的全局数据。它是一个std::locale类型的对象,而且你可以建立它和把它传给函数,就像其他任何对象一样。例如,你要建立一个表示通常区域的区域设置对象可以这么写

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

或者你通过这么写建立一个德语区域设置

std::locale L("de");

(和C库里的一样,区域的名字没有标准化。检查你的实现文档来查明提供了哪些有名字的区域设置。)

C++里的区域设置分成多个方面(facet),每个方面处理一个国际化的不同方向,而函数std::use_facet从区域设置对象[2]中提取一个特定的方面。ctype方面处理字符分类,包括大小写转换。最后,如果c1和c2是char类型,这段代码将以适合区域设置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)

意思和

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

相同(如果c是char类型)。不过,最小化调用use_facet的次数是值得的,因为它可能相当昂贵。

正如词典比较不能适合于所有应用一样,一个一个字符的大小写转换也不总是适合的。(例如,在德语里,小写字母“ß”对应着大写序列“SS”。)但是,不幸的是,一个一个字符的大小写转换是我们拥有的全部。C和C++标准库都没有提供任何一次用于不止一个字符的字符串转换形式。如果你的目的不能接受这个限制,那么就已经在标准库的范围之外了。

离题一下:另一个方面

如果你已经熟悉C++里的区域设置,你可能想果用另一种方式进行字符串比较:collate方面的存在正好封装了排序的细节,而它有一个接口很像C库函数strcmp的成员函数。甚至有一个方便的特征:如果L是一个区域设置对象,你可以通过写L(x,y)而不是通过讨厌地调用use_facet然后调用collate成员函数来比较两个字符串。

“classic”区域设置有一个进行词典排序的collate方面,和字符串的operator<做的一样,但其他区域设置进行任何种比较都是合适的。如果你的系统正好有一个对任何你感兴趣的语言进行忽略大小写比较的区域设置,你可以使用它。那个区域设置甚至可能做出比一个一个字符比较更智能化的事情!

不幸的是,这个建议,可能是真的,并不能帮助像我们这些没有那样的系统的人。或许有一天一套这样的区域设置可以标准化,但现在它们还没有。如果没有人为你写了一种忽略大小写的比较,你就必须亲自写它。

忽略大小写字符串比较

使用ctype,用忽略大小写字符比较构造忽略大小写字符串比较是很简单的。这个版本不是最优的,但至少它是正确的。它基本上使用和以前相同的技术:使用lexicographical_compare比较两个字符串,而且通过把两个字符都转换成大写来比较它们。不过,这次我们小心地使用区域设置对象而不是全局变量。(另外说一下,把两个字符都转化成大写不一定总是等于把两个字符都变成小写的结果:没有保证操作是可逆的。例如,在法语里,通常忽略大写字符上的重音标记。在法语区域设置中,toupper有理由是有损转换;它可以把“é”和“e”都转换成同样的大写字符,“E”。那么,在这样的区域设置里,使用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)内的字符大小写。不幸的是,这不完全是我们的目标的正确接口。使用它来比较两个字符串要求把两个字符串都拷贝到缓冲区,然后把缓冲区转化成大写。那些缓冲区从哪里来?它们不能是固定大小的数组(多大才足够大?),但动态数组需要昂贵的内存分配。

另一个解决方案是每次对一个字符进行大小写转换并缓存结果。这不是一个完全通用的解决方案——例如,如果你用的是32位UCS 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排序一个23,791个单词的list花费0.86秒,而用lt_str_2花费0.4秒。

我们从所有这些里学到了什么?

  • 忽略大小写的字符串类是错误的抽象层面。C++标准库中的泛型算法是由策略参数化的,而你应该利用这个事实。
  • 词典字符串比较建立在字符比较之上。一旦你有了一个忽略大小写的字符比较函数对象,问题就解决了。(而且你可以把那个函数对象重用于比较其他类型的字符序列,比如vector<char>,或字符串表,或原始的C字符串。)
  • 忽略大小写的字符比较比看起来难。除了在一个特定区域设置的场景之外,它没有意义,所以字符比较函数对象需要储存区域设置信息。如果关系到速度,你应该写避免重复调用昂贵的方面操作的函数对象。

正确的忽略大小写比较花费了大量手段,但是你只须把它写一次。你或许不想考虑locale;大多数人不。(谁想在1990年考虑千年虫?)如果你依赖区域设置的代码正确了,那么你忽视区域设置的可能性将大于你写出消除了这个依赖性的代码。


[1] 参见Andrei Alexandrescu在《C++ Report》2000年4月的专栏[19]。

[2] 警告:use_facet是一个函数模板,它的模板参数值出现在返回类型,而不是任何实参。使用一个叫做显式模板参数特化的语言特性来调用它,而一些C++编译器尚未支持那个特性。如果你使用了一个不支持的编译器,你的库实现可能提供了变通办法,所以你可以用某种方式调用use_facet