第4章 标准库及Qt对字符串的处理

绝大多数C++程序都会涉及字符串的处理。字符串中的字符可能是我们都很熟悉的ASCII字符,也可能是其他自然语言中的文字。为了表示这些文字,在计算机发展历史上出现了各种文字编码方案。4.1节介绍各种字符编码方案,重点介绍被广泛接受的Unicode编码方案。标准C++定义了模板类basic_string来处理字符串。特化后的类string处理字符类型为char的字符串,而特化后的类wstring处理字符类型为wchar_t的字符串,后者可以用来存储Unicode编码的字符串,细节可参见4.2节。wstring本身对Unicode字符串的处理能力偏弱,尚需其他类(比如locale)的支持才能完成较复杂的功能,比如各种字符编码之间的转换。

Qt使用QString来处理字符串。在设计上它就能够存储、处理Unicode字符串。它提供了一组丰富的成员函数,其中包含字符编码转换这样的功能。4.3节将比较QString和wstring的不同,并介绍QString的常用成员函数。

4.1 字符及其编码

字符及其编码设计涉及以下几个基本的概念。

(1)字符。当我们在讨论字符及其编码这个话题时,字符是指一个自然语言中最小的书写单位。对于英语,每个字母就是一个字符;对于汉语,每个汉字就是一个字符。这个概念和程序设计没有任何关系,属于语言学的范畴。

(2)字节。由8个二进制位组成一个存储单元。

(3)编码方案。对于若干个字符组成的一个集合,为其中每个字符指定一个唯一的编号,所形成的标准就被称为一个编码方案,有时也被简称为编码。所涉及的这个集合也被称为字符集。有的编码方案还指定了如何将每个编号映射为一个字节序列。

对于英语,其字符总数不超过256个,因而每个字符可以使用一个字节来表示,所形成的编码方案就是大家熟知的ASCII编码。C++语言中的类型char或者unsigned char占用一个字节,恰好能够表示英语中的一个字符,因而将这种数据类型命名为字符类型。此处的char类型和本节讨论的字符是两个不同的概念,C++中的char实际上就是本节中的字节。因此,读者在本书或者其他文献中看到“字符”这个词时,应该结合上下文确定它表示的是自然语言中的字符,还是计算机中的字节。

对于汉语,其字符总数远远超过256个,显然无法再使用一个字节来表示该语言中的所有字符。一种常见的处理思路为:对于汉语书写系统中出现的英语字符,比如大小写字母、标点符号等,使用一个不超过0x80的字节表示。对于汉字,使用多个连续的、取值大于0x80的字节来表示。沿着这个思路,形成了不同的汉字编码方案。例如,GB 2312编码方案能够表示常见的简体汉字,每个汉字占用2个字节,主要应用在中国大陆以及新加坡。这种编码方式不能够表示古汉字以及许多繁体字。另外一种常见的汉字编码方案为Big5,主要用来表示繁体汉字,在中国台湾、香港、澳门等地区被广泛使用。

常用的英语字母也被纳入这些编码方案中。它们的编号与ASCII方案下的完全相同,因而采用以上汉字编码方案的操作系统、字处理软件、编辑器等,能够正确处理和显示仅含有常用英语字母的文件,比如C++源程序、英文文档等。而对于汉字,这些编码方案则采用2个甚至更多的字节来表示。我们将这种一个字符可能对应1个或者多个字节的编码方案称为多字节编码(multibytes encoding)。

类似于以上汉语的编码思路,日语有JIS编码,其他自然语言也都有着各自的编码方案。这些编码方案中,多数方案只表示一个自然语言中的字符,最好情形下也只能够表示其他自然语言的少数字符。因而,每个方案在设计之初就没有考虑如何兼容其他自然语言的字符,导致不同自然语言中的字符可能被映射为相同的字节序列。所以我们无法在同一个文本文件中存放不同自然语言的字符。当操作系统本身采用这种编码方式时,情况会变得更糟。例如,用户采用Big5编码的Windows 95/98创建了一些文档,当这些文件被传输到基于GB 2312编码的Windows平台时,却无法被正常显示、处理。例如,在1997年香港回归前后,中国大陆地区和香港地区有着频繁的交流。由于当时的主流操作系统Windows 95/98采用了多字节编码方式,这样的不兼容问题频频出现。后来,随着Windows XP的发布和普及,这个问题才被解决,因为XP采用了Unicode编码。

Unicode编码方式为世界上各种语言的每个字符指定一个统一、唯一的编号。由于在设计之初就考虑了各种自然语言文字的兼容问题,一个采用Unicode的文本文件可以存放多种自然语言的文字。虽然每个语言中的每个字符具有唯一的Unicode编号,但是,将这个编号转化为字节序列时,却有着不同的方案,从而形成不同的Unicode编码方案,比如UTF-8、UTF-16等。

UTF-8采用了多字节编码方式,常用的英语字符采用1个字节表示,其他的字符采用2~4个字节表示。具体地说,对于ASCII码值小于0x80的英语字符,UTF-8用一个字节来表示该字符,该字节的最高二进制位为0,其他7个二进制位表示其ASCII码值。对于其他字符,将采用2~4个字节来表示。以二进制描述,第一个字节以多个1打头,1的个数恰好和该字符所占用的总字节数相等,这组1之后必定接着一个0。从第二个字节开始,每个字节的最高两位总是10,所有这些字节左边的6位串接在一起,被用来表示该字符的Unicode编号。

UTF-8编码方案具有一个很明显的特征:如果一个字节的最高位为0,则该字节表示一个英语字符。如果一个字节的最高位含有2个以上1,则一定是表示某个字符的字节序列的首个字节。如果一个字节的最高位是10,则该字节是一个中间字节。依据这个特征,我们能够很容易地在一个UTF-8编码的文件中找到表示每个字符的首字节,而不必从整个文件首字节开始解析。

由于与英语字符对应的UTF-8字节取值和ASCII中的完全相同,这种编码方式很自然地兼容了许多基于ASCII字符集的电子文档,因而被广泛应用在互联网、电子邮件等场合。

而另外一种Unicode编码方案UTF-16使用2个字节来表示常用字符,包括英语中的字符。对于不常用的字符,该方案使用4个字节来表示。由于1个英语字符在这种方案中需要使用2个字节来表示,故该方案和ASCII方案不兼容。

多字节编码方式使用较少的字节来表示使用频率较高的字符,对于频率很低的字符才会使用较多的字节,因而这种方式可以有效地降低保存字符的存储容量,常常被用在文件系统、网络传输等场合。然而,这种方式并不适用于计算机内部对字符串的处理。由于每个字符所占的字节数不同,程序必须从一个字符串的首字节开始解析,以确定每个字符对应字节序列在内存中的位置。毫无疑问这个解析过程会极大降低程序的运行速度。设想我们求取一个字符串“c:\music\卡洛儿\假如爱有天意.mp3”含有多少个字符。如果采用多字节编码方式(比如UTF-8),我们必须遍历整个字节流才能够得到结果,无法简单地从这个字节流的总长度推断出字符个数。

为了提高字符串的处理速度,计算机程序常采用固定长度的存储单元来存放每个字符。如果我们采用Unicode来表示所要处理的字符,由于常用字符的个数不会超过65535,应用程序可以采用2个字节来存放每个字符。如果一个应用程序所要处理的字符超过了65535,该程序就必须使用4个字节来存放一个字符。我们将这种方式存储的字符称为一个宽字符(wide characters)。采用这种存储方式,我们能够很容易地在一个字节流中定位每个字符的起始、终止位置,也能够轻易地求取一个字符串的字符个数。

字符数据常以多字节编码方式被存放在文件系统中。当需要处理这些字符数据时,应用程序将多字节编码方式转换为宽字符方式,以便于快速处理字符数据。处理完毕后,需要做逆向的转换,并将多字节编码方式的字符数据存放到文件系统中,以节省存储空间。

4.2 标准库的类模板basic_string

C++采用了不同于C的方式来处理字符串。C语言将字符串存放在长度固定的内存中,然后使用一组函数来处理该字符串。对于单字节字符串,C程序一般使用char *类型的指针来访问字符串,使用strcpy、strcat、strcmp等函数来处理字符串,使用scanf/printf等函数来输入/输出字符串。对于多字节字符串,C程序一般使用wchar*类型的指针来访问字符串,使用wcscpy、wcscat、wcscmp等函数来处理字符串,使用wscanf/wprintf等函数来输入/输出字符串[16]

C++使用类模板basic_string来存放字符串数据,并提供了一组成员函数来处理该字符串。单字节字符串与多字节字符串在数据存放及处理方式上有诸多的相似之处,类模板basic_string表达了这些相似之处。常用的类string只不过是这个类模板特化后得到的一个类,用来处理单字节字符串。对basic_string进行另外一种形式的模板特化,得到类wstring,用来处理宽字节字符串。实现这种“相似之处”的代码只在basic_string中出现一次,最大限度地实现了代码复用。

和C语言的字符串处理方式相比,basic_string还具有一个明显的优势:自动内存管理功能。一个basic_sting对象可以在程序运行期间申请新的内存,以容纳更长的字符串。比如,设str是string类的一个对象,当我们使用语句cin >> str输入一个字符串时,对象str会依据实际输入字符串的长度管理自己的内存,以容纳所有的输入数据。

4.2.1 basic_string的模板参数与构造函数

basic_string具有多个构造函数,常用的几个如代码段4-1所示。模板参数charT表示字符串中每个字符的类型,string类令该参数为char,而wstring类令该参数为wchar_t。C++标准并未指定wchar_t所占字节数,在VS 2010编程环境下,其长度为2个字节。用户也可以令该参数为其他类型,来处理其他类型的字符串,比如每个字符占用4个字节的字符串。显然,该模板参数将直接影响一个字符串在内存中的存放方式。

模板参数traits表示类型charT的特征信息。比如,具有charT类型的字符是如何比较大小的,对于charT类型的字符,文件结束符(EOF)是如何定义的。string类令该参数为char_traits<char>,而wstring类令该参数为char_traits<wchar_t>。这种技术的细节可参考3.2节。模板参数Allocator是一个堆管理器对象,一般情况下取默认值即可。

代码段4-1,类模板basic_string的构造函数

            template<class charT,class traits=char_traits<charT>, class Allocator
            = allocator<charT>>
            class basic_string {
            public:
            //...
            explicit basic_string(const Allocator& a = Allocator()); ①
      basic_string(const charT* s, const Allocator& a = Allocator());    ②
            basic_string(const charT* s,size_type n, const Allocator& a =
            Allocator());③
      basic_string(const basic_string& str, size_type pos = 0,
              size_type n = npos, const Allocator& a = Allocator()); ④
      //...
            };

如前文所述,类string及wstring是basic_string特化的结果,即:

        typedef  basic_string<char>  string;
        typedef  basic_string<wchar_t>  wstring;

如果用户没有提供任何形式的字符串,行①的构造函数将构造一个空字符串。如果用户提供了一个常量字符串,行②将构造一个basic_string对象,其中所存放的字符串和用户提供的完全相同。行③执行类似的操作,只不过对象中字符串的长度被设定为n。如果n大于形参字符串的长度,则整个形参字符串可以被存放在对象中。否则,只有前n个字符被存放在对象中。而行④是basic_string的复制构造函数,新对象中的字符串是形参字符串str的部分或者全部,也就是str中第pos个字符开始的n个字符组成的字符串。参数n的默认值为basic_string中定义的一个常量,表示“此后所有字符”。

代码段4-2演示了这些构造函数的使用方式。basic_string<char>就是类string,因而我们容易理解对象s1~s4的构造方式。构造basic_string<wchar_t>的对象时,情况会稍微复杂些。如果我们直接将一个双引号括起来的常量字符串传递给basic_string<wchar_t>的构造函数,编译器会报错,因为编译器将这种形式的字符串处理为const char*类型,而basic_string<wchar_t>期望的是const wchar_t *类型。

代码段4-2,类模板basic_string常用构造函数的使用,取自z:\examples\basic_string_demo\main.cpp

      #include <string>
      #include <iostream>
      using namespace std;
      int main()
      {
            basic_string<char> s1;
            basic_string<char> s2 ("hello world");
            basic_string<char> s3 ("hello world", 5);
            basic_string<char> s4 ( s2, 6, 5);
            cout << s1 << endl << s2 << endl << s3 << endl << s4 << endl;
            basic_string<wchar_t> ws (L"得友难失友易(A friend is easier lost than
            found)");
            wcout.imbue(locale("chs"));
            wcout << "size is: " << sizeof(ws[7]) << endl << ws << endl;
      }

为了解决这个问题,我们可以在一个字符串常量的前面加上前缀“L”。编译器会计算该字符串常量中每个字符的Unicode编码,并存放在一个wchar_t类型的内存单元中。之所以有这个计算过程,是由于不同的编程环境会采取不同的字符编码方式来存放C++源程序。Linux下的编程环境可能采用UTF-8编码方式,而笔者所用的VS 2010编程环境则采用GB2312编码方式存放中文字符。本例中,“L”之后的字符串既含有汉字又含有英文字母。编译器会分析这个字符串,计算每个中文字符以及每个英文字母的Unicode编码,以wchar_t类型存放在内存中。

为了验证这点,该程序用sizeof求取字符串中字符“A”的长度并输出,结果为2。为了输出宽字节字符串,我们应该使用流对象wcout而不是普通的cout。除此之外,还需要调用wcout的imbue()函数,将该流对象的区域文化设置为中文。关于区域文化及其设置,可参见第5章。

4.2.2 basic_string的字符串比较函数

我们可以使用“==”运算符比较两个basic_string对象是否相等,也可以比较一个basic_string对象和一个类型为charT*的字符串是否相等,相关成员函数的声明如如代码段4-3所示。模板参数的含义与前文相同。行①和行②的两个函数都是比较一个basic_string对象和一个charT*类型的字符串,但是在行①的函数中,charT*类型的字符串出现在比较运算符的右侧,而在行②的函数中它出现在左侧。

代码段4-3,basic_string对象和字符串的比较

      template<class charT, class traits, class Allocator> inline①
        bool operator==(
            const basic_string<charT, traits, Allocator>& LString,
            const charT *RCharArray
        );
      template<class charT, class traits, class Allocator> inline②
        bool operator==(
        const charT*LCharArray,const basic_string<charT,traits,Allocator>&
        RString
        );
      template<class charT, class traits, class Allocator> inline③
        bool operator==(
        const basic_string<charT, traits, Allocator>& LString,
        const basic_string<charT, traits, Allocator>& RString
        );

有的读者可能会问一个问题:行①和行②中的函数是不是无用的,因为当我们将一个charT*类型的字符串和一个basic_string对象进行比较时,basic_string的构造函数会将charT*类型的字符串转换为一个basic_string对象,然后调用行③的函数进行比较。

问题的答案是:前两个函数是必要的,因为从一个类型为charT *的字符串构造一个basic_string对象涉及内存分配、字符串复制等操作。比较结束时,还需要析构这个临时的basic_string对象,涉及内存的释放操作。所有这些操作都会降低程序的性能。

4.2.3 basic_string的其他成员函数

有时我们需要直接处理basic_string中的字符串数据,此时可以调用basic_string的成员函数c_str()。该函数返回一个指针,指向basic_string中存放的字符串数据。该成员函数的声明如下:

      const value_type *c_str( ) const;

其中value_type为basic_string对象中字符的类型。对于sting类型,该函数返回char *;对于wstring类型,该函数返回wchar_t *。

basic_string重载了“[]”运算符,返回字符串中指定位置的字符。这种方式不检查下标是否越界,而成员函数at()检查下标的范围,如果越界,函数会抛出一个异常。

由于basic_string被C++程序大量使用,它被精心设计,以具有良好的性能。比如,该类模板有一个成员变量来存放字符串的长度。其成员函数length()直接返回这个成员变量的值,函数的执行时间是一个很小的常量。如果我们仅使用字符数组的方式来存放字符串,就需要使用诸如strlen的函数来求取字符串的长度。所耗费的时间将和字符串的长度成正比。

4.3 Qt的类QString

QString负责存储、操作Unicode编码的字符串。每个字符的类型为QChar,长度为2个字节,存放着该字符在Unicode 4.0标准中的编码。对于编码值大于65535的字符,用两个连续的QChar来表示。

与标准库中的wstring类似,QString能对Unicode字符串进行拼接、查找等操作。但是,QString的功能更加强大,其中最主要的功能是能够完成Unicode编码方式和其他编码方式之间的转换。有的读者可能会问:Qt为什么要创建一个新类,而不是从wstring派生出一个新类来实现附加的功能?

一个原因是QString比wstring出现得早:Haavard Nord和Eirik Chambe-Eng于1991年开始开发Qt,在1993年时完成首个图形核心,因而可以推测QString出现在1993年之前。而basic_string(及模板特化所得的string以及wstring)于1994年才被纳入C++标准[17]

在basic_string出现之后,虽然Qt的开发者可以摒弃QString,转而使用C++标准中的wstring,但是由于Qt库本身大量用到了QString,修改的工作量太大。而且,虽然wstring本身能够存储Unicode编码的字符串,但是为了完成某些操作,比如判断字符串中的某个字符是否为标点符号,还需要C++标准中locale等类的支持。即使到现在,还并不是所有的C++开发平台都能支持得很好的。而QString则不同,它集成了丰富的Unicode字符串处理功能。只要是使用Qt进行开发,这些功能在所有开发平台上都是可用的。

4.3.1节以一个例子展示了QString是如何存放Unicode字符串的,4.3.3节讲述QString如何将内部存放的Unicode字符串转换为其他编码方式的。由于转换的结果被存放在一个QByteArray中,4.3.2节介绍QByteArray的功能和适用场合。4.3.4节简要介绍本书后续章节用到的QString的成员函数。本章主要强调QString和wstring的区别,至于QString的详细介绍,读者可参考文献[8]或者Qt文档。

4.3.1 QString对象的构造

有多种方式来构造一个QString对象。如果待处理字符串仅含英文字符,可以使用下面简单的方式来构造:

      QString str = "Hello";

QString的构造函数会将该字符串转换为Unicode编码的字符串。如果待处理字符串含有中文等其他语言的文字,应该调用QString的静态成员函数fromLocal8Bit(),将该字符串转换为Unicode编码的字符。

代码段4-4比较了这两种不同的构造方式。“中文”这两个汉字在GB 2312方案下编码为0xD6D0、0xCEC4,它们的Unicode为0x4E2D、0x6587。笔者所用的VS 2010编程环境采用GB 2312编码方案存储C++源文件中的中文字符,因而行①的字符串常量在内存中以GB 2312机内码形式存放。行②的前缀“L”将令编译器将GB 2312编码的汉字转换为Unicode值。由于VS 2010运行的机器采用little endian,因而每个Unicode值的高、低位刚好颠倒。行③的字符串常量也以GB 2312编码方式存放。当其作为参数来构造一个QString对象时,每个字节中的内容会被作为一个字符看待,用QChar类型表示,占用2个字节。显然,这种表示方式有问题。为了解决这个问题,行④调用了QString的成员函数fromLocal8Bit(),将GB 2312编码转换为Unicode编码,每个Unicode值用QChar类型表示。QString的成员函数data()返回一个指针,指向这个QChar序列。显示出来的字节序列恰好就是“中文”这两个汉字的Unicode值。

代码段4-4,字符串的不同存放方式,摘自z:\examples\qstring_demo\main.cpp

      QTextStream cin(stdin, QIODevice::ReadOnly);
      QTextStream cout(stdout, QIODevice::WriteOnly);
      void display_data( char * buff, unsigned len )
      {
            for (int i=0; i<len; i++)
                    cout << hex << showbase << uppercasedigits << (unsigned
                    char)buff[i] << " ";
            cout << endl;
      }
      int main(int argc, char *argv[])
      {
      char * buff= "中文";         ①
            display_data( buff, strlen(buff) );     //0xD6 0xD0 0xCE 0xC4
            std::wstring ws  = L"中文";      ②
            display_data((char*)ws.data(),ws.length()*2);//0x2D 0x4E 0x87 0x65
            QString  qs =   "中文";      ③
            display_data( (char*) qs.data(),  qs.length() * 2  );//0xD6 0x0 0xD0
                                                          //0x0 0xCE 0x0 0xC4 0x0
            QString  qs2    = QString::fromLocal8Bit("中文");     ④
            display_data((char*)qs2.data(),qs2.length()*2);//0x2D 0x4E 0x87 0x65
      }

这个程序段说明:对于含有非英语字符的字符串,在C++中应该使用前缀“L”将其中的每个字符转换为Unicode编码,以wchar_t类型存放。而在Qt中应该使用fromLocal8Bit将其中的每个字符转换为Unicode编码,以QChar类型存放。

4.3.2类QByteArray

类QByteArray被用来存放长度可变的字节序列,其中的字节序列被存放在一个地址连续的内存块中。当用户向一个QByteArray对象添加新的数据而该对象内部的内存块无法存放这些数据时,它会申请一块更大的内存。该类总会在字节序列的末尾添加一个额外的“\0”字符,使得所存放的数据总是可以被解释为一个普通的C语言字符串。

如果字节序列中的每个字节表示一个字符,可以使用QByteArray提供的一组成员函数处理这个字符串,比如函数replace()能够替换字符串中的一个子串,函数contains()或者indexOf能够在字符串中查找一个子串,重载的运算符<、<=、==以及>=可以比较两个QByteArray中存放的字符串。

虽然一个Qt应用程序可以使用多种方式来存放字节序列,但是QByteArray有其独到之处。C语言的字符数组,比如unsigned char byte_array[100]可被用来存放字节序列,但是其长度固定。使用C语言的字符指针以及堆管理函数(malloc,free)可以存放长度可变的字节序列,但是需要用户自行管理内存块。C++的vector<unsigned char>可被用来存放长度可变的字节序列,但是字节序列末尾不会被自动添加上“\0”,字节数据并不一定被连续存放,因而其中的数据无法被解释为一个普通的C语言字符串。QString可以存放长度可变的字符串。它采用Unicode编码方式,每个字符占用两个字节。对于那些每个字符只需一个字节来表示的字符集,使用QByteArray可以节省存储空间。

4.3.3 QString的字符编码转换功能

QString能够将内部存放的Unicode字符串转换为以下编码格式:ASCII、Latin1、UCS4、UTF8以及编程环境所用的编码(比如GB 2312),结果存放在一个QByteArray对象中,它也可以做逆向的转换。例如,代码段4-5行①将一个QString对象中的Unicode字符串转换为UTF8格式,行②调用QByteArray的data函数,得到一个指向这个UTF8字节序列的指针,将整个字节序列原封不动地写入到一个文本文件中。使用Notepad++打开这个文本文件,该软件能够自动检测出编码格式,并显示在界面的右下角。

代码段4-5,QString的字符编码转换功能,摘自z:\examples\qstring_merit\main.cpp

      int main()
      {
            char * humor = "Your future depends on your dream.So go to sleep.\n"
                            "你的梦想决定你的未来,所以睡觉去吧。";
            QString qs = QString::fromLocal8Bit( humor );
            QByteArray data = qs.toUtf8();           ①
            ofstream of("utf8.txt", ios::binary );
            of.write( data.data(), data.length() ); ②
      }

4.3.4 QString的其他常用功能

与C的字符串不同,QString不会把“\0”字符作为字符串的结束标志。其成员函数length()返回整个字符串的长度,其中可能包含“\0”字符。

当需要将多个变量转换为字符串并拼接在一起时,可以调用QString的成员函数sprintf()函数,例如:

      QString str;
      str.sprintf("%s %.1f%%", "perfect competition", 100.0);

这种方式不太安全:当第一个参数指定的某个格式(比如例子中的“%.1f”)和该函数对应的参数(例子中的100.0)不符合时,结果不可预知。一种更安全的方式是使用QString的成员函数arg()例如:

      str = QString("%1 %2 (%3s-%4s)").arg("permissive").arg("society").arg(1950).arg(1970);

其中的%1、%2、%3、%4是占位符,将被后面arg()函数中的内容依次替换。也就是说,%1将被替换成“permissive”,%2将被替换成“society”,%3将被替换成“1950”,%4将被替换为“1970”。最后,这句代码输出为:permissive society (1950s-1970s)。