在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
基本上所有主流的编程语言都有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的。 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(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前面。 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()这个方法来解决。 这些函数用于搜索字符串,结果都是返回一个指向匹配字符或者字符串的指针,如果没有找到,则会返回NULL。strrchr()是从字符串的末尾开始搜索,找到的是最后一个ch,而其他都是从开头开始找到第一个ch或者s2。 上面都是一些基本的函数,它们在设计上都存在自己的缺陷,导致它们无法完全符合我们的需求,所以我们需要利用它们来重写写一些函数以实现我们要的功能,这些函数叫做转换函数,其实也就是相当于我们面向对象编程中的接口概念。 1.获取String长度 strlen()最大的问题就是它并没有检查字符串是否为空,所以我们必须提供一个转换函数,封装这层检查: int getStringLength(char* s){ if(s == NULL){ Error("Null string!"); } return(strlen(s)); } 检查是否为NULL,在字符串操作中是非常普遍的需求,像是比较字符串,我们也可以用一个转换函数将检查工作封装进来。 原本我们就可以利用数组选择符号来进行这项操作,但是它有一个问题:无法检测越界问题,这在数组操作中经常发生。 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语言最大的问题就是它的报错机制实在是太不完善,几乎将这个责任完全交给程序员,虽然这样是为了效率,但实践证明,程序员完全是不可靠的生物,要想保证他们随时都能写出可靠的代码实在是太难了,所以,最好是在一开始头脑清醒的时候就将这个检测任务封装起来,否则,以后要是出错了,就真的是很难排查了。 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; } 这个代码更多的工作就是各种检查,当然更好的情况就是我们报出错误提示而不是我们帮助客户进行修正,这样使得我们的转换函数更加清晰。 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; } 值得注意的是,我们并不改变原来的字符串,这也是非常重要的设计,因为字符串应该是不可变的,才能保证程序的正确,但如果真的需要改变原来的字符串,只要一个赋值语句就行。 我们经常需要将数值转换为对应的字符串,其原理就是利用字符串格式化命令: 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语言真是一门神奇的语言,我们真的可以在这里找到很多主流语言的很多特性的设计基础!!毕竟万变不离其宗啊!!! (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("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语言中有关字符串的内容就大概讲到这里,其余的东西已经是心有余而力不足了。 我们先从定义一个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之前我们就已经检查了问题,而不是等到用户得到一个无效的字符串时才知道。 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的内存,所以我们不用在额外的地方做检查。 我们来试着增强这类的功能。 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 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; } 我们在设计类的时候,有一个重要的问题是必须考虑的:隐藏实现。隐藏实现之所以重要,是因为它能够给设计者带来一定的灵活性,我们可以在完全不需更改提供给用户的接口的情况下对接口进行修改,而且用户在使用接口的时候也完全不需要了解接口的具体实现,也能在一定程度上防止用户使用出错,像是调用一些不改调用的函数等等。 (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; } |
请发表评论