从C,C++,JAVA和C#看String库的发展(一)----C语言和C++篇
转自: http://www.cnblogs.com/wenjiang/p/3266305.html
基本上所有主流的编程语言都有String的标准库,因为字符串操作是我们每个程序员几乎每天都要遇到的。想想我们至今的代码,到底生成和使用了多少String!标题上所罗列的语言,可以看成是一脉相承的,它们的String类库基本上也是一脉相承下来的,但是,在关于String的类库设计中却可以充分看出面向过程和面向对象,以及面向对象语言的抽象程度这些区别,也是我们认识这些语言之间区别的一个很好的入口。
首先从C语言和C++开始。
C语言几乎是现在程序员的程序入门语言,当然,也有不少人不是,比如说我,倒是先从JAVA开始,C语言大学时候基本上没怎么学。。。言归正传,C语言是最伟大的语言之一,在它的基础上诞生了很多主流的面向对象语言,像是C++和JAVA等,并且直到现在,世界上有多少设备至今仍在运行C语言!!
字符串String,究其本质而言,就是字符序列。学习C语言的字符串,我们可以从归约和整体来思考。归约就是关注数据表示的内部细节,像是理解字符在计算机中的内存中是如何存储的,这些字符序列如何被存储为一个字符串,以及200个字符的字符串如何放入保存两个字符的字符串的变量中这些和内存存储相关的问题,这就是自下而上的思考方法。而整体,就是理解如何将字符串作为一个逻辑单位来操作,通过关注字符串的抽象行为,我们可以学会如何有效的使用它,而不必沉溺于细节问题,这就是自顶而下的思考方法。
对于面向对象编程的程序员来说,自顶而下的思考方法才能发挥面向对象的真正威力,通过抽象,我们可以在编码的一开始就已经构建好整个框架,这样有利于我们工作进度的把握和测试。但自下而上的思考方法也是有它的可取之处,像是一些代码设计,更多是与底层打交道,我们更多的时间是花在底层上的话,自下而上也是不错的选择,但总体而言,自顶向下更加能够锻炼程序员的抽象能力,这在接口设计中非常重要,而接口的设计就充分体现了面向对象程序员的价值。
所以我们这里将会从整体的角度上来看待C语言和C++的字符串,之所以放在一起讲,是因为C语言定义了String的存在,而C++提供了完善的String类库。也许我这里的知识已经严重落后了,因为我的C和C++的基础知识还是好几年前(虽然现在我还是准大四生),现在有关String这方面肯定已经大大完善了。
首先是从String的基本概念,也就是从归约的角度开始下手。
在计算机内部,字符串被表示为字符数组,只要我们将一个字符串存储到内存中,这个字符串中的字符就都被分配到连续的字符中。但是,这还不够,因为我们需要知道这些连续字节的内存空间到底什么时候结束,也就是确定字符串的结尾,因为像是这样的字符串:"hello"和"world",如果我们无法确定字符串的结尾,那么这两个字符串在内存中将会是连续的,就会变成"helloworld",但是我们要的明明是两个字符串啊!所以,C语言编译器会在字符串的结尾放一个空字符作为结尾标记,也就是\0,ASCI码为0。这是非常重要的,因为很多入门者都会遗忘这个事实,认为"hello"这个字符串的长度只有5,实际上是6。
以数组表示字符串,在C++中被称为C-风格字符串,所以我们可以像是这样来声明一个string:
char s[] = "hello";
于是我们可以使用数组选择符号来从一个字符串中选出字符,像是s[0]表示的是'h'。
我们可以使用strlen()函数来获得字符串的长度,但这里有个让入门者非常苦恼的问题:strlen("hello")得到的竟然是5!明明"hello"在内存中的真正长度是6!!这样的事实简直难以相信,但是想到strlen()函数的内部实现,就会发现这是必然的。
我们之所以会在字符串的末尾添加空字符,是为了方便我们检测字符串的结束,那么,在strlen()的内部实现中,会是这样子的:
int i, index; for(i = 0; s[i] != '\0'; i++){ index++; }
这样自然就不会将末尾的空字符算在里面了,而且这样的实现是合理的,空字符的存在只是为了识别字符串的结束,它不应该算在字符串里。
不了解空字符的意义,自然就无法知道strlen()为什么会是这样的行为。
当然,如果想要知道字符串的真正长度,我们可以使用sizeof()操作符,记住,它是操作符而不是函数,来知道一个字符串的真正内存长度。
如果想要知道为什么作为字符数组的字符串可以用"hello"表示,我们就要知道字符串常量(string constrant),也就是所谓的字符串字面值(string literal)。
我们知道,使用单引号表示的字符,像是'A',就是字符常量,它们代表的是字符的数值编码,像是'A',就是65,也就是'A'的ASCII码。这种表示的最大好处就是方便清晰,毕竟还是有极少数的计算机内部并不以ASCI作为字符编码的,但是我们仍然可以用'A'来表示'A'。
但是问题也来了:字符是有符号整数还是无符号整数?因为C和C++中的整数分为有符号整数和无符号整数,自然就会有这样的问题产生。
大多数的现代编译器都把字符实现为8位整数,但是并非所有的编译器都是这样。一般的情况下我们是不需要考虑这样的问题,但是如果我们需要将字符值转化为一个较大的整数时,这个问题就变得非常重要。如果是作为有符号数,编译器在将char类型的数扩展到int类型时,会同时复制符号位,但如果是无符号数,只需在多余的位上直接填充0就可以。但如果一个字符的最高位是1,那么怎么办?编译器的选择非常重要,它决定着一个8位字符的取值范围是从-128到127还是0到255。
我们可以将这个字符声明为无符号数(unsigned char),这样无论什么编译器都只会将多余的位填充0,但是如果声明为一般的字符变量,那么在不同编译器之间进行移植的时候就可能出现问题。
提到这个问题,还有一个更加隐晦的问题:如果使用(unsigned)来强制转型一个字符变量,将会得到一个与该字符变量等价的无符号整数。这是一个严重的错误,因为在这之前,该字符变量会先被转换为int型整数,因此得到的结果可能就不是我们想要的。正确的方式应该是使用(unsigned char)先将该字符变量转化为一个unsigned char,这时就无需转换为int型了。
这些问题都是非常隐晦的,但又经常在现实生活中发生,因为C语言考虑的更多是移植性,因此在大部分的设计上都有这些问题的讨论。
字符串常量使用双引号表示,它不需要显式的包括空字符,编译器会自动在末尾添加上去。
使用数组来表示字符串,首先面临的最大问题就是数组的长度。必须确保数组的长度可以容纳所有的字符,如果采用字符数组的表示方式,我们必须显式的指定数组的长度,但这样会带来一定的问题:像是strlen()这样的字符串处理函数,大部分都是根据空字符来处理字符串,而且考虑到我们根本就无法知道确切的大小,于是我们在声明字符数组的时候,比实际需要的大小更大是必然的,这样做在逻辑上没有任何问题,但对内存来说却是不友好的。使用字符串常量就可以完美的解决这个问题,因为字符串常量是让编译器计算数组数目,这样就能保证数组的长度不会比字符串的长度长。
如果说字符常量表示的就是字符的编码值,那么字符串常量表示的到底是什么呢?实际上,字符串常量表示的是字符串的内存地址,因为字符串常量实际上就是指向字符串内存地址的指针。
在C中对待字符串有两种表示方法:字符数组和指针。那么问题也就来了:在具体的情景中,像是传参,我们应该采用何种方式呢?在编译器看来,无论是char[],还是char*,它们的处理都是一样的,唯一的区别就是代码的编写。一般来说,如果我们强调字符串是字符数组,并且打算采用数组选择符号来选择字符串中的某个字符的时候,我们应该将字符串作为数组来看待,但如果我们想要使用指针来表示,并且采用*来间接引用这个指针的话,我们应该将它看成指针。
采用哪一种方式,取决于我们具体的编码环境。
虽然从上面来看,数组和指针是可以互换的,在C中也的确是如此,但这也仅限于上面传参的情况,实际上,当我们声明一个数组和声明一个指针的差别是非常大的,因为内存的分配方式完全不同。如果是数组,我们会显式的分配每个元素的内存,但如果是指针的话,像是char* p,则完全没有分配内存,它只是一个指针变量,具体的内容不定。未初始化的指针变量是我们C语言中大部分错误的来源,因为它无法被编译器捕获。
扯到指针,永远绕不开的话题就是上面提到的初始化问题。如果我们使用字符串常量,这个问题非常好解决,但实际上我们有时候也需要将字符串作为数组来处理,但是我们在声明的时候却是采用指针的形式,那这时该怎么办呢?第一感觉就是先声明一个字符数组,然后将数组的指针赋值给该指针,但更好的方法应该是动态的分配内存:
char* p = malloc(MAX);
这种做法的好处就是字符的内存是从堆中动态分配来的,而且我们可以像使用字符数组一样来使用p。
可能会有入门者会这样来声明一个字符串:
char array[6]; array = "hello";
这种做法是错的,但这样就非常奇怪了,数组名不是指针吗?这就是大部分入门者的误解。这样的误解是基于这样的事实:
int a[10], *p = a, *q; q = a + 1; q = p + 1;
表面上来看,使用数组名和使用指针是等效的,并且数组名和指针之间是可以转换的,但就是这点,数组名其实是先隐式的转换为指针(数组名指向的是数组第一个元素的地址,所以等效于int*),所以这样的等效表达式无法说明数组名就是指针。再说了,数组名是存放数组第一个元素的内存地址,我们能将整个字符串都存到一个元素里面吗?而指针不一样,指针指向的是字符串第一个元素的地址,它并不存放整个字符串。也许我们会想到,为什么这里数组名不能隐式的转换为指向字符串第一个元素的指针呢?这是不可能的,数组名就是指向第一个元素的内存地址,这点是不会改变的,我们能做的就只是修改这个元素,而不能修改这个指向性。
对于C语言中的数组,总是让人感到特别头疼,因为它使用起来特别绑手绑脚,像是函数返回一个数组就是一个错误!对于习惯面向对象编程的我们来说,返回一个数组也是经常做的事情,但C就是不行,因为C语言中数组不是类型,它代表一个连续的内存单元(java之所以可以返回数组,是因为我们返回的其实是引用,也就是对象),但我们可以选择返回一个指针。但就算是这样,我们也只能返回动态分配的数组,如果返回的是一个指向在函数中声明的数组的指针,就会出现问题,因为分配给声明成局部变量的数组的内存在函数返回的时候就自动释放了。
因此,将指针作为函数结果返回的时候,要确保指针指向的内存地址不是当前栈帧的一部分。
上面的讨论已经超过了字符串这个话题,但这些都是必要的,如果没有掌握这些知识,我们很容易在C或者C++中使用字符串的时候犯下难以察觉的错误。
使用字符串的时候,有些问题很令人感到头疼,像是:
char* p = "hello"; p[2] = 'x';
这个在C中是完全合法的,但是在C++中却是个错误!因为C++认为一个字符串常量应该是常量,是不可变的,但是这个错误编译器却无法捕捉!这就是当初我们学习C++时候的痛苦:明明是在学习一门与C在核心思想上已经完全不同的语言,但是因为考虑到C的兼容性,编译器在处理的时候是偏向C的。
在C++中,应该这样写:
const char* p = "hello";
这样就可以保证字符串常量不会被修改。
这点保证是必须的,因为我们在做文本处理的时候,经常将字符串常量作为参数传来传去,如果字符串常量是可变的,就会导致在传参中可能会发生变化以致后面的编码出现错误。
但如果真要修改字符串内容呢?两种方式:声明为字符数组或者使用C++提供的替换函数。
在结束有关指针的讨论前,我们来思考一个问题:空指针是否空字符串?
答案当然不是。但是,还是会有人会感到困惑的,因为上面讲过,字符串常量其实就是一个指针,指向字符串的第一个字符所在的内存空间,因为字符串在内存中是连续的内存单元,所以也等同于指向整个字符串所在的内存空间,那么,什么都没指向的空指针是否代表字符串呢,肯定会有人会这样想。
编译器在处理0的时候,保证由0转换而来的指针不等于任何有效的指针,虽然出于代码文档化的考虑,0经常用NULL这个符号表示。什么叫有效指针呢?通俗点讲,就是可以被解除引用(dereference)的指针。当我们将0赋值给一个指针变量时,根本就无法使用该指针所指向的内存中存储的内容,因为它根本就不指向任何对象。但可怕的是,这个在有些C编译器那里根本就不会报错!而且该死的是,它甚至会有数据出来!虽然那也只是一些垃圾数据而已。这就是使用指针的悲剧啊,就算是使用空指针,我们根本就无法奢望编译器会提示我们,它只会导致我们的操作都是未定义的!
但是空字符串却是实际上内存为一个字节也就是'\0'的字符串,所以它和空指针根本就不是一回事!
接下来我们要讨论的就是一些常见的函数的使用。
ANSI C有一个标准的字符串库--string.h,但实践证明,这个库里的函数非常难用,部分需要经过改进才能满足实际工作需要,但我们还是必须熟悉它们。
1.strcpy(char* dst, char* src)
这个函数是将一个源(source)字符串中的字符复制到另一个目标(destination)字符串中,为了保证和赋值运算符一致,复制操作是从右向左进行,strcpy()会将目标参数作为第一个参数。
这个函数的作用就是当我们想要操作一个字符串,但又想保留它的原值。在C语言中,直接对字符串进行操作是非常危险的,所以我们常常需要重新复制出一个字符串出来。使用这个函数的时候,我们需要声明一个保存数据中间副本的数组,也就是我们俗称的缓冲区(buffer),这个缓冲区的大小必须足够存放实际应用中可能遇到的最大字符串,所以我们常常会这样声明一个缓冲区:
char buffer[MAX + 1];
多余的1就是为了末尾的空字符而准备的。
然后我们这样使用strcpy()函数:
strcpy(buffer, source);
strcpy()函数的存在意义是因为在C中数组无法作为左值使用。
我们完全可以自定义自己的strcpy()函数:
void strcpy(char dst[], char src[]){ int i; for(i = 0; src[i] != '\0'; i++){ dst[i] = src[i]; } dst[i] = '\0'; }
但是有经验的程序员却不会这样干,通常我们都是将字符串作为指针来考虑,所以另一种形式就是这样:
void strcpy(char* dst, char* src){ while(*dst++ = *src++); return dst;
}
这种形式在C语言中称为空语句(Null Statement),函数的所有工作在while语句的测试中就已经全部完成。它之所以能够运行,是因为在C语言中,所有非0值都会被解释成TRUE,所以while循环会一直下去,直到末尾的空字符串为止。
这种形式的代码非常紧凑,但是可读性很差,而我们程序员应该尽量使自己的代码具有最大的可读性,而不是无谓的秀能力,写出一大堆难读的代码。
我们在使用strcpy函数的时候,必须确保足够的内存空间,如果无法保证,我们必须明确的捕捉这个错误的代码:
if(strlen(source) > MAX){ Error(...); } strcpy(buffer, source);
如果我们无法确保足够的内存空间,就会发生缓冲区溢出。
如果是使用指针的话,我们必须确保该指针已经正确的初始化,否则字符串就会被复制到内存中无法预知的区域。
2.strncpy(char* dst, char* src, int n)
为了避免上面提到的缓冲区溢出,ANSI字符串库还包含了另一种形式的strcpy,就是strncpy(),它允许我们指定一个长度限制。这样的好处就是防止写入的字符超过字符数组的大小,但是这个函数在设计上是有缺陷的:
(1)只有src长度少于n个时,dst才会以空字符终止,如果src刚好包含n个字符,就会将那些字符复制到dst中,但不会再结束处保存一个空字符。为了保证dst的正确终止,必须为dst分配一个额外的元素,并显式的将dst[n]初始化为空字符。
(2)复制src后,会在每个字符的位置中都写上空字符,直到填满n个位置。这就大大降低了strncpy()的效率。
3.strcat(char* dst, char* src)和strncat(char* dst, char* src, int n)
strcat()常常用于将较小的字符串组合成较大的字符串。假设变量head和tail分别为"src"和"am",我们想要组合成"amscr",可以这样写:
char newWord[MAX + 1]; strcpy(newWord, tail); strcat(newWord, head);
我们首先要将tail复制到缓冲数组中,然后再用strcat()将head放在tail前面。
使用strcat()和使用strcpy()同样存在溢出的危险,所以我们必须避免连接过多的字符。strncat()虽然有一定的防止作用,但并没有我们想象中那么大,为了确定n值,我们还需要检查dst的当前长度,这样我们才能知道缓冲区还剩余多少空间,这同样是牺牲效率来换取安全的做法。
4.int strcmp(char* s1, char* s2)和int strncmp(char* s1, char* s2, int n)
strcmp()用于比较两个字符串,这并不仅仅只是比较两者是否相等,它要比较的是字符串的字母顺序。计算机在比较字符时,会用它们在字符代码集中的数值进行比较,这种次序我们称之为词典顺序,但是又于传统的词典不一样,因为大小写在字符代码集中的数值是不一样的。strcmp()就是根据这样的顺序来返回相应的整数值:0表示两者相等,正整数表示s1在s2前面,负整数表示s1在s2后面。更多的时候,程序员会将0作为FALSE来使用,以便作为条件测试语句的条件:
if(strcmp(s1, s2)){ }
strncmp()和strcmp()的区别在于,它最多只比较前面n个字符。
说到这个函数,我们就会想到:==这个运算符呢?实际上,==比较的是两个字符串的指针值,这样是不靠谱的,因为某些实现可能会将所有相同内容的字符串只保存一份。这个问题在JAVA中也存在,不过在JAVA中我们无法重载==,所以像是字符串这种对象类型,我们都是重载equals()这个方法来解决。
5.char* strchr(char* s, char ch), char* strrchr(char* s, char ch)和char* strstr(char* s1, char* s)
这些函数用于搜索字符串,结果都是返回一个指向匹配字符或者字符串的指针,如果没有找到,则会返回NULL。strrchr()是从字符串的末尾开始搜索,找到的是最后一个ch,而其他都是从开头开始找到第一个ch或者s2。
上面都是一些基本的函数,它们在设计上都存在自己的缺陷,导致它们无法完全符合我们的需求,所以我们需要利用它们来重写写一些函数以实现我们要的功能,这些函数叫做转换函数,其实也就是相当于我们面向对象编程中的接口概念。
1.获取String长度
strlen()最大的问题就是它并没有检查字符串是否为空,所以我们必须提供一个转换函数,封装这层检查:
int getStringLength(char* s){ if(s == NULL){ Error("Null string!"); } return(strlen(s)); }
检查是否为NULL,在字符串操作中是非常普遍的需求,像是比较字符串,我们也可以用一个转换函数将检查工作封装进来。
2.从一个字符串中选择字符
原本我们就可以利用数组选择符号来进行这项操作,但是它有一个问题:无法检测越界问题,这在数组操作中经常发生。
char getChar(char* s, int i){ int len; if(s == NULL){ Error("Null string!"); } len = strlen(s); if(i < 0 || i > len){ Error("Index outside of range!"); } return (s[i]); }
C语言最大的问题就是它的报错机制实在是太不完善,几乎将这个责任完全交给程序员,虽然这样是为了效率,但实践证明,程序员完全是不可靠的生物,要想保证他们随时都能写出可靠的代码实在是太难了,所以,最好是在一开始头脑清醒的时候就将这个检测任务封装起来,否则,以后要是出错了,就真的是很难排查了。
3.连接
char* contact(char* s1, char* s2){ char* s; int len1, len2; if(s1 == NULL || s2 == NULL){ Error("Null string"); } len1 = strlen(s1); len2 = strlen(s2); s = CreateString(len1 + len2); strcpy(s, s1); strcpy(s + len1, s2); return s; }
其中CreateString()的源码如:
static string CreateString(int len){ return ((char*)GetBlock(len + 1)); } void* GetBlock(size_t nbytes){ void* result; result = malloc(nbytes); if(result == NULL){ Error("Null string!"); } return result; }
其中我们这里注意到了一个void*,这个让人很惊奇,尤其是在面向对象世界里遨游很久后重新回归C后的我来说,这个不就是泛型吗!void*表示任意类型的指针,如果作为返回值,表示它可以返回任意类型的指针,同样也可以作为参数使用。但是,我们在获得该返回值后必须进行强制类型转换,即使实际上我们无需这样做也可以将任意类型的指针赋值给void*,但反过来就不行。
void在C语言中是非常神奇的关键字,以致很多程序员根本就没有彻底的了解这个东西。
在C语言中,凡是不加返回值类型限定的函数,编译器都会默认是返回整型值,而不是我们经常误以为的void,这也是为什么我们要显式的指定void为该函数的返回类型的原因。
在C语言中,我们可以给无参数的函数传递任意类型的参数,这点在C++中已经修正了:若函数不接受任何参数,必须指明参数为void。
我们前面讲过的void*,它是无法按照一般指针那样进行算法操作,因为能够进行算法操作的指针必须是确定知道其指向的数据类型的大小。上面我们将一片内存赋值给void*,这充分体现了内存操作函数的真正意义:我们操作的对象仅仅是一片内存,不论这片内存的类型是什么。像是memcpy()和memset()也是同样的返回值。
上面的讨论似乎在说明一件骇人耸闻的事情:void似乎是一个类型!实际上,void是一种抽象,对应的是"无类型",它不存在任何真实的变量,而其他变量都是"有类型"的。
在重新回归C语言后,我们就会发现很多过去被我们忽略的东西,实际上却是以后各种语言特性的基石,再次证明:抽象先于设计,像是泛型的思想,其实早在面向过程的语言中就已经实现了,只是面向对象语言将它们封装进语言实现中而已。
在GetBlock()这个函数中,我们发现它的参数是size_t类型。size_t是标准C语言库stddef.h中声明的类型,实际上就是unsigned int,在64位系统就是long unsigned int,它就是用来记录大小的数据类型,全称是size type,像是我们使用的sizeof(),得到的就是size_t。它的出现是为了适应多个平台,增强可移植性,就像字符常量一样,在32位系统,它是4个字节,在64位系统,它就是8个字节。
这里有个疑问:为什么使用strcpy(s + len1, s2)而不是直接使用strcat(s, s2)呢?因为strcpy()避免了搜寻到字符串末尾,如果字符串非常长,那么 这样效率就会很低了!
4.将字符转换为字符串
我们很多时候都有这样的需求:将一个字符添加到一个字符串中,但这样我们需要对字符进行转换:
char* CharToString(char ch){ char* result; result = CreateString(1); result[0] = ch; result[1] = '\0'; return result; }
道理很简单,我们只需要在该字符后面添加一个空字符就行,但每次都需要写这么一串代码那真的是和痛苦!虽然C语言是面对过程的,但是我们就不能想想办法让这个过程更加智能吗?于是就有一大堆的转换函数出现,封装了这些底层的库函数的调用,一步一步的发展,直到面向对象语言的出现,将这种行为正式封装进实现中。
5.分解字符串
char* SubString(char* s int p1, int p2){ int len; char* result; if(s == NULL){ Error("Null string!"); } len = strlen(s); if(p1 < 0){ p1 = 0; } if(p2 >= len){ p2 = len - 1; } len = p2 - p1 + 1; if(len < 0){ len = 0; } result = CreateString(len); strncpy(result, s + p1, len); result[lem] = '\0'; return result; }
这个代码更多的工作就是各种检查,当然更好的情况就是我们报出错误提示而不是我们帮助客户进行修正,这样使得我们的转换函数更加清晰。
6.在一个字符串内搜索
int FindChar(char ch, char* text, int start){ char* cptr; if(text == NULL){ Error("Null string!"); } if(start < 0){ start = 0; } if(start > strlen(text)){ return -1; } cptr = strchr(text + start, ch); if(cptr == NULL){ return -1; } return ((int)(cptr - text)); }
这个函数最大的疑惑就是我们如何得到索引量。道理其实很简单,利用指针之间的运算就可以了,两个指针进行相减,就可以得到它们对应的内存块的偏移量,这里再一次强调了字符串变量其实就是指向字符串的第一个字符的内存地址。
同样的道理我们可以实现在一个字符串中搜索子串的转换函数。
7.大小写转换
这个要求就非常普遍了,但是C语言的标准库只是提供了单个字符的大小写转换,所以我们需要封装一个转换函数:
char* ConverToLowerCase(char* s){ char* result; int i; if(s == NULL){\ Error("Null string!"); } result = CreateString(strlen(s)); for(i = 0; s[i] != '\0'; i++){ result[i] = tolower(s[i]); } result[i] = '\0'; return result; }
值得注意的是,我们并不改变原来的字符串,这也是非常重要的设计,因为字符串应该是不可变的,才能保证程序的正确,但如果真的需要改变原来的字符串,只要一个赋值语句就行。
8.数值转换
我们经常需要将数值转换为对应的字符串,其原理就是利用字符串格式化命令:
char* IntToString(int n){ char buffer[MaxDigits]; sprintf(buffer, "%d", n); return CopyString(buffer); } char* CopyString(char* s){ char* newStr; if(s == NULL){ Error("Null string!"); } newStr = CreateString(strlen(s)); strcpy(newStr, s); return newStr; }
sprintf()是在stdio.h中定义的字符串格式化命令函数,它的功能就是把格式化的数据写入某个字符串中。sprintf()是个变参函数,使用时一旦出现问题就会导致程序崩溃,但遗憾的是,我们经常使用错误。
首先,第一个可能出现的问题就是缓冲区溢出,我们在设计这个转换函数的时候,最大的问题就是我们永远也不知道客户会输入怎样的整数,所以我们将缓冲区的大小设置为最大。其次,就是变参对应出现问题,这是最容易犯的问题。除了前面两个参数类型是固定的之外,后面可以接受任意多个参数,所以它的原型是这样的:
int sprintf(char* buffer, const char* format, [argument]...);
这就是JAVA的可变参数列表啊!C语言真是一门神奇的语言,我们真的可以在这里找到很多主流语言的很多特性的设计基础!!毕竟万变不离其宗啊!!!
对于format,一般是这样的格式:%[指定参数][标识符][宽度][精度]
(1)指定参数是用于处理字符方向的,负号表示从后向前处理;
(2)标识符是用于填充字元,0的话表示多余的位填0,空格表示用空格填充;
(3)宽度是用于规定字符总宽度;
(4)精确度就是指小数点后的浮点数位数,通常的形式就是.n。
常见的format如下:
%%:百分比符号;
%c:对应的ASCII 字元;
%d:对应的十进制数;
%f:对应的浮点数;
%o对应额八进制位数;
%s:对应的字符串;
%x:对应的小写16进制数;
%X:对应的大写16进制数。
当然,我们可以反过来将字符串转换为数值:
int StringToInteger(char* s){ int result; char dummy; if(s == NULL){ Error("Null string!"); } if(sscanf(s, " %d %c", &result, &dummy) != 1){ Error("StringToInteger called on illegal number %s", s); } return result; }
这里我们需要理解一下sscanf()这个函数。
sscanf()是从一个字符串中读取与指定格式相符的数据,然后存进相应的内存单位中。表面上,它似乎是sprintf()反过来使用,但实际上它具有更加强大的适用性:它支持正则表达式,像是这样:
sscanf("123abc", "%[0-9]", buffer);
结果就是"123"。
以上就是大概的转换函数,其实还有很多实用的功能没有设计出来。我们只要将这些转换函数整理进一个头文件,它就是一个库了,所以,对于库函数的设计,需要考虑的东西实在是太多了,像是安全检查等,但毫无疑问的是,这些库函数的使用远比直接使用标准库函数的效率低下,但是它们的可读性更好。
C语言中没有Error()这个函数,我们就在这里贴出它的源码,但就不解释了:
void Error(char* msg, ...){ va_list args; va_start(args, msg); fprintf(stderr, "Error:"); vfprintf(stderr, msg, args); fprintf(stderr, "\n"); va_end(args); exit(1); }
C语言中有关字符串的内容就大概讲到这里,其余的东西已经是心有余而力不足了。
接下来我们开始进入C++,但是很多内容我们已经在前面涉及到了,所以只会讲一些C++独有的东西。
我们先从定义一个String这个类开始,因为C++是面对对象的,类的设计是它最重要的东西。
class String{ public: String(char* p){ sz = strlen(p); data = new char[sz + 1]; strcpy(data, p); } ~String(){ delete[] data; } operator char* (){ return data; } private: int sz; char* data; };
这个类使得我们声明一个变长字符串成为可能,但是它完全不能满足进一步的要求,因为它就只有一个构造器。
我们来为这个类添加更多的职责。
首先,这个类虽然使变长字符串成为可能,但是它并没有任何有关错误情况的检查,像是最经常出现的溢出。
C语言的做法就是将这个责任完全交给用户解决,但又不强制用户进行检查,因此出现了很多错误,C++必须修正这个问题。
最直接的方法就是在构造函数中国检查内存分配是否成功,如果失败,就采取强硬的措施:
class String{ public: String(char* p){ sz = strlen(p); data = new char[sz + 1]; if(data == 0){ error(); }else{ strcpy(data, p); } } //....
};
实际上我们是在帮用户检查内存分配的问题,但是这里有一个重大的问题:error()能够返回吗?就算它能返回,但是用户得到的却是一个无效的字符串!所以,我们应该是在operator char* ()里面检查这个问题:
operator char* (){ if(data == 0){ error(); } return data; }
这样用户在访问String之前我们就已经检查了问题,而不是等到用户得到一个无效的字符串时才知道。
但这个方案还是有问题:如果不调用error(),用户就根本无法检查问题,而且这个方式会导致程序无条件终止,这是很多用户所反感的。因此我们应该是增加一个成员函数专门处理这个问题:
int String :: valid(){ return data != 0; }
这样用户会在创建字符串前先调用这个函数,然后再调用error()来终止程序。
之所以这样做,是因为我们无法代替用户做出准确的选择,因此折中的方案就是我们提供检查的方法,然后由用户来显式的调用,这是一种信任,但很多时候这种信任都会被打破,但至少责任是在用户那里。
这里就有一个性能上的问题:每次访问String的时候,都要检查data是否等于0。使用异常就可以死解决这个问题:
String(char* p){ sz = strlen(p); data = new char[sz + 1]; if(data == 0){ throw std :: bad_alloc(); }else{ strcpy(data, p); } //.... };
这样做的好处就是throw语句会在检测到错误发生时无条件退出出错环境,并且用户可以利用try...catch子句来捕获这个错误并做相应的处理,于是就能确保一件事:只要String存在,就能保证我们已经成功分配了String的内存,所以我们不用在额外的地方做检查。
虽然在java中对异常处理机制有点厌烦,因为这样会使得我们的代码变得特别臃肿,充斥了大量而且无意义的处理语句,但是不可否认的是,异常处理机制是相当实用的机制。前面之所以纠结,是因为就算内存分配失败,我们还是能得到字符串,只是这个字符串是无效的,并且不管error()是否返回,我们都必须检查这个String是否是有效的,但是通过异常处理机制,我们就能确定我们成功创建的字符串是没有错的。
我们来试着增强这类的功能。
String被创建出来后,就会有人想要去复制这个String,这时会怎样呢?我们的类并没有定义复制构造函数和赋值操作符,因此,复制一个String,就相当于复制String的sz和data这两个成员的值。这是一个非常重大的问题!原来的成员和副本现在都指向同一块内存!!也就是说,当这两个String被释放的时候,该内存会被释放两次!!!释放已经被释放的内存就是一个非常隐晦的错误。
我们来修正这个问题。
修正的方案非常简单:我们通过将复制构造函数和赋值操作符规定为私有,这样就能禁止用户对它进行复制:
private: int sz; char* data; String(const String&); String& operator=(const String&);
现在我们可以看到我们JAVA人最熟悉的引用的出场了(String&就是声明一个String类型的引用)。
但仔细想想,就会发现禁止用户复制String是一个愚蠢的选择,所以我们还是老老实实的想想怎样在提供给用户复制这项功能的时候又能保证不出现问题。
我们先来想象一个经典的用户情景:将某个长度的String赋值给一个长度不同的String。这种行为非常常见,我们不能简单的将它认为是一种错误的使用情况,而应该保证用户可以做到这点。
方案很简单:改变目标String的长度。
这种方案是最自然的,它能够让下面的情况成为可能:
String x = y;
x = z;
等价于:
String x = z;
复制构造函数和赋值操作符在这里的行为其实非常相像,它们都是传进来一个String,然后复制该String到当前String中,唯一的区别就是赋值操作符复制新值进来前必须删除旧值。也许只学过JAVA的人很难理解这样的行为,因为在JAVA中,我们拥有的只是对象的引用,并不涉及到具体的内存分配,但是在C++中就不一样,凡是涉及到内存分配,都必须谨慎。
我们可以将赋值操作符中的复制操作交给其他函数处理:
private: void assign(const char* s, unsigned len){ data = new char[len + 1]; if(data == 0){ throw std :: bad_alloc(); } sz = len; strcpy(data, s); }
现在我们的复制构造函数只需要调用该函数就可以了:
String(const String& s){ assign(s, data, s.sz); }
但是现在我们的赋值操作符还是有点问题:我们无法在先删除数据后再调用assign()函数,因为这时如果把一个String赋值给它本身就会发生失败,因为我们已经将它本身的data删除掉了!
最简单的方法就是将这种情况作为特例:
String& operator=(const String& s){ if(this != &s){ delete[] data; assign(s.data, s.sz); } return *this; }
我们在设计类的时候,有一个重要的问题是必须考虑的:隐藏实现。隐藏实现之所以重要,是因为它能够给设计者带来一定的灵活性,我们可以在完全不需更改提供给用户的接口的情况下对接口进行修改,而且用户在使用接口的时候也完全不需要了解接口的具体实现,也能在一定程度上防止用户使用出错,像是调用一些不改调用的函数等等。
但是我们上面的例子并没有很好的遵守这个规定,即使我们的成员函数并没有一个是public,但是仔细看看我们的赋值操作符,就会发现一个隐晦的问题:它会返回一个char*!这样做就会暴露出三个问题:
(1)用户可以获得一个指针,然后用它修改保存在data中的字符,这就意味着String没有真正控制它自己的资源;
(2)释放String时,它所占用的内存也被释放,因此,任何指向该String的指针都会失效,这也是所有指针都会有的问题,但我们必须提醒用户这点;
(3)我们可以决定通过释放和重新分配目标String使用的内存来将一个String的赋值实现为另一个,但是这样的赋值可能会导致任何指向String内部的指针失效。
这些问题都是因为我们返回的是一个指针,指针在C中就已经是非常难用,在C++中仍然没有解决这个问题,因为它使用的仍然是C的指针,最多就是提供了一个指针的代替品:引用。
我们来一个一个的解决上面的问题。
先是第一个。我们可以通过返回一个const char*,而不是char*,这样我们就能确保用户在获得该指针后无法修改保存在data中的字符。
接着我们来处理第二个问题。这个问题是所有指针都会有的问题,而且最可怕的是,用户是在自己不知道的情况下就获得该指针。所以,我们必须消除这样的错误:
public: const char* make_cstring() const{ return data; }
这样用户就能知道他们是什么时候获得该错误的指针了。
这种方式是通过一个非操作符函数来取代操作符的使用,因为从String到char*的转换是隐式的,用户根本就无从得知。这里我们使用了两个const,第一个const修饰的是返回值,表示我们无法修改该返回值,也就是无法赋值给该返回值。第二个const表示该函数并不会修改数据成员,任何企图对数据成员进行修改的动作都会报错,在C++中,如果该函数涉及到数据成员的操作,哪怕是简单的返回动作,我们都应该将该函数限定为const。
这样当用户在释放该String后如果还继续获取该指针,就会报错,而且同时也解决了第三个问题。
但是我们可以继续深入一点:既然我们的问题是因为我们使用了指针,那么我们为何不放弃使用指针呢?
要做到这点,我们就必须代替用户进行内存管理。
我们可以帮用户分配内存,但是内存的释放问题却应该交给用户自己决定。这是明智的选择,如果连这个工作都帮用户做了,那么就会出现不正确的释放内存的问题。但可怕的是,用户常常会忘记释放并非他们显式分配的内存,所以更明智的做法就是让用户提供将data复制进去的空间,首先就是给用户一个判断data长度的方法:
int length() const{ return sz; } void make_cstring(char* p, int len) const{ if(sz <= len){ strcpy(p, data); }else{ throw("Not enough memory supplied"); } }
我们在设计的时候依然要考虑到用户可能提供错误的空间大小来存储data的副本,所以我们要进行检查。这里我们规定用户可以提供过大的空间,而空间过小是一种错误,这是一般的做法。而且我们还可以从data中复制len个字符。
表面上我们的确是帮用户做了很多工作,但是我们无法确定哪项决定是正确的:shiite分配刚刚好的内存呢,还是分配过大的内存呢?所以我们将这两个方式都放在一起,只要将文档写好就行。这并不是一种逃避责任的行为,相反,这是避免承担过多无谓责任的明智做法,我们永远也无法知道我们的用户到底会以什么样的方式来使用我们的接口,但是我们可以规定好他们大概的使用方式。
就算是将这些棘手的问题都解决了,但是我们的类还是存在问题:如果不知道String的缺省值,就无法创建它,也就是说,String s这样的语句是一个错误,这样就会导致我们这样的行为同样也是个错误:String s_arr[20],因为我们并没有为我们的数组中的20个String类型的元素赋予初始值。
这个问题很严重,吗?很难说,我们的确是可以为每一个String赋予一个初始值,但应该是多少呢?
最简单的方法就是声明一个缺省构造函数:
public: String() : data(new char[1]){ sz = 0; *data = '\0'; }
这样默认的缺省值就是一个空字符串。
缺省构造函数,也就是默认的构造函数,在构造类的时候发挥了很大的作用,很多难以察觉的问题都是我们没有恰当的赋予一个对象适当的初始值,就像指针一样,所以,默认构造函数能够避免这种情况,尤其是在我们的类中有一个有参的构造函数的时候,编译器会强制性的要求我们提供默认构造函数,这在C++和JAVA中都是一样的,JAVA更是提倡所有的变量都应该有初始值。
上面的代码都是为了解决创建String实例的问题,当然,一个类不可能只有创建它本身这样简单的职责,我们需要为该类提供更多的功能,但是记得,我们这里实际上是在设计一个String库,而不是单纯的一个类,所以我们理所当然的可以提供更多的成员函数方便用户使用,但是如果真的只是设计一个类,考虑到面向对象中的一个很重要的原则:单一职责原则,我们就有必要思考我们这个类是否真的需要承担这个职责,还是有必要专门有个新类来处理。这些在面向对象编程中都是一个永恒的话题,围绕这个话题衍生出了许多技术出来,像是设计模式。
我们现在只是简单的为String的用户提供一个连接功能:
String& String :: operator+=(const String& s){ char* odata = data; assign(data, sz + s.sz + 1); strcat(data, s.data); delete[] odata; return *this; }
连接最需要考虑到的问题就是释放原先String所占用的内存,这是必要的,尤其是在C++并没有JAVA的垃圾回收机制的前提下,释放对象内存就是我们程序员的工作。我们这里在assign()之前,先保留一个指向旧值的指针,这样在分配新的内存之后,就可以通过delete来删除原先String所占用的内存。
既然我们可以使用+=来连接String,而+呢?我们是否应该将它声明为成员函数呢?最好是将二元操作符定义为非成员函数,这样做就能允许对两个操作数进行类型转换。如果我们的+是成员函数,那么该操作符的第一个操作数就必须是String,这样的话:
String s; char* p; s + p;
就是错误!这样也未免太令人匪夷所思了吧!!所以,我们应该是这样的声明operator+操作符:
String operator+(const String& op1, const String& op2){ String ret(op1); ret += op2; return ret; }
这里我们只使用了String的public接口,并不涉及到其他类的接口,所以没有必要声明为friend。另外,注意到我们返回的是一个String,而不是String&。合并不应该改变任何操作数,实际上也不能,因为它们都被声明为const类型,而且我们肯定也不想返回一个引用。更重要的是,返回一个值而不是引用就与内建类型的操作方式保持一致,就像我们把两个int对象相加,得到的是一个新的int对象而不是引用一样自然。
如果需要实现其他操作符,像是==,我们需要用到strcmp(),这时我们就需要将函数声明为友元函数了。
围绕如何自定义一个String,我们讲到了许多C++的知识,这些都是方便我们了解C++在对String这个类库到底是以什么的思想在设计,即使它的内部实现并不是我们上面那样,但是基本思想是一致的。C++在原先C的基础上,将我们之前讨论的那些转换函数正式封装进一个类里:String,这就是面向对象编程的核心所在。
C++的String库和C的String库相比,最大的区别就是C++帮我们用户封装了底层操作并且提供了更好的接口,使得这些函数的调用更加方便和符合人的习惯,当然,C++还保留了之前C语言的String库,也就是cstring这个库,但是它本身也提供了一个全新的String库:string。
使用string这个库,我们除了要添加库文件之外,还要声明它的命名空间:
#include <string> using namespace std;
当然,声明整个命名空间有个坏处,就是会将其他不必要的东西导进来,所以如果可以的话,可以采取这种方式:
std :: string
在C++中使用string,比起在C中使用字符数组或者是指针都要更加方便,因为它的实现本身就是我们上面展示的可变长的字符串,所以我们可以直接声明一个string变量,然后将任意长度的字符串赋值给它。更加重要的是,我们可以将一个string对象赋值给另一个string对象,这点字符数组就做不到了,因为数组之间无法相互赋值,而且我们的拼接和附加操作都更加简单,可以简单的通过"+"和"+="来实现,就像是基本数据类型一样。
有关这些的实现,可以参考我们上面的String的定义。
C++的string库同时也提供了获取字符串长度的函数:size(),但是在函数调用上与C完全不一样:
string s = "hello"; int len = s.size();
这就是面向对象!size()是s这个对象实例的方法,我们要想调用这个方法,必须发送消息给s,然后再获得这个消息的结果。
这样的调用方式使得我们的程序设计逻辑更加清晰和人性化。如果是C语言,我们能做的就是设计一个库,然后将这些函数全部塞进去,在使用的时候再导入这个库,但这些函数在逻辑上并没有任何关系,它们是相互独立分开的,但类就不一样,我们将这个函数声明为这个类的方法,也就表示这是该类的职责,在逻辑上是属于该类的,就像是人会说话,应该是属于人这个类的行为,但在C中,是不分人和动物的,只要调用这个函数就行,只要不出错,哪怕感冒病毒都能说话!
实际上C++的构造方法一共有六种方式:
(1)
string one("hello");
这种方式其实等同于:
string one = "hello";
但两者在行为上还是有差异的:一个是构造,另一个是初始化,但实际效果是等效的。
(2)
string two(20, 'h');
这样会产生一个包含20个h的字符串,这样的好处就是当我们想要输入一个重复元素的字符串的时候,可以完全交给这个函数处理而不是自己手动处理。
(3)
string three(one);
实际的效果还是等同于:
string three = one;
有时候就是这样,尤其是在C++中,总是存在一些别扭但就是允许的形式存在。
(4)
char ch[] = "hello"; string four(ch, 6);
这个就相当于我们将ch中的前6个字符复制到four里面。
(5)
char ch[] = "hello"; string five(ch + 2, ch + 4);
这个就像是获取子串一样,five将会是"ll"。
值得注意的是,我们传进的必须是一个指针或者一个地址值,否则就无法发挥作用。
以上的方式都特别别扭,尤其对于JAVA程序员而言,这简直就是不可理喻!但它们就是最原始的实现,我们之所以能用那么方便的形式来声明一个字符串,是因为语言提供了这样的语法糖,这也正是我们程序员的工作:基于程序语言的基本实现,为自己,为他人,提供方便的语法糖。
在C++中,最神奇的就是两个字符串可以互相比较大小,像是s1 < s2,是成立的,比较的是字符串中字符的ASCI码,如果在前,视为小。这些都要归功与重载函数的可能,导致我们可以以更加自由的方式来编写和调用函数,更加重要的是,我们程序员的发挥空间更加大了,而不是像C一样,必须一一组织标准库提供的函数才能实现我们想要的功能。这就是面向对象和面向过程的一个很好的对比,允许我们对象有自己的行为。
string这个类库还提供了寻找给定的子串或者字符的函数:find(),具体的用法我们不讲,我们只讲它的返回值:string :: npos,它代表的就是字符串的最大字符数,通常是无符号int或者无符号long的最大取值,是在没有搜索到目标的时候才会返回。
之所以要返回这个值,是因为我们常常需要知道find()函数的结果,但为什么不是布尔值而是这个值,我就不是很清楚了。
find()函数很多变体,像是find_first_of(),find_first_not_of()和find_last_of(),这些函数的功能名字已经说得很清楚了。
我们接下类将重点放在自动调整大小这个功能上。
想想当我们将一个新的字符附加到字符串上的时候,到底会发生什么呢?我们不能仅仅简单的将已有的字符串加大,因为相邻的内存可能已经被占用了。因此,可能需要分配一个新的内存块,并将原来的内容复制到新的内存单元中,实际上,JAVA也是这样干的。但是这样的操作就会引起效率的问题:如果大量执行这样的操作,内存的负担太重了,尤其是C++没有JAVA那样方便的垃圾回收机制,内存管理完全交给程序员!所以,C++的真正实现是分配一个比实际大小更大的内存块,这样就能为字符串提供增大的空间。但是这样还是不够的,因为字符串会越来越大,直到超过这个大小,这时程序分配的内存块实际上是原来的两倍大,避免不断分配新的内存块,但是这样依然解决不了问题,因为我们永远也不知道到底需要多少内存,实际上我们的操作就是不断分配之前两倍大的内存!
C++提供了两个函数让我们程序员显式的进行这样的操作:capacity()和reserve()。capacity()返回的是当前分配给字符串的内存块的大小,而reserve()能够让我们请求内存块的最小长度。
由于C++保留了很多之前C的库,所以这样就会引来一个问题:之前的函数要求的是C风格的字符串,我们的string该怎么办呢?C++做出了一个转换:c_str(),调用这个方法我们可以将string转换为C风格的字符串
C++是string库实际上是基于一个模板类:
template<class charT, class traits = char_traits<charT>, class Allocator = allocator<charT>> basic_string{...};
这个类包含下面两个typedef:
typedef basic_string<char> string; typedef basic_string<wchar_t> wstring;
模板是C++的一个精髓,也是难点,非常难使用,如果要讲解这个模板类,花费的时间非常大,而且都是与本文主题完全无关的东西,所以具体的东西还是交给读者自行学习吧。