1.17k likes | 1.28k Views
第三章 字符串 —— 数据封装技术. C ++ 语言没有把字符串定义为基本数据类型,而是直接采用 C 语言的以指针和字符数组方式定义的字符串。这是非常不安全的。 本章通过实际地定义一种更可靠安全的字符串类型,解决了 C ++ 本身的缺陷。同时也以字符串作为构造抽象数据类型的例子,讨论与数据抽象和封装有关的各种问题。. 3.1 C 语言的字符和字符串. C 把字符简单地作为一种整数类型,一个字符被看作是一个整数,所有对整数可用的操作都可以用于字符,包括加减乘除运算。
E N D
C++语言没有把字符串定义为基本数据类型,而是直接采用C语言的以指针和字符数组方式定义的字符串。这是非常不安全的。 本章通过实际地定义一种更可靠安全的字符串类型,解决了C ++本身的缺陷。同时也以字符串作为构造抽象数据类型的例子,讨论与数据抽象和封装有关的各种问题。
C把字符简单地作为一种整数类型,一个字符被看作是一个整数,所有对整数可用的操作都可以用于字符,包括加减乘除运算。C把字符简单地作为一种整数类型,一个字符被看作是一个整数,所有对整数可用的操作都可以用于字符,包括加减乘除运算。 • C语言中没有预定义的字符串类型,字符串被处理为由字符指针指向的、存储在字符数组里的字符序列。加上了一个空字符‘\0’,作为字符串的结束标志。字符串字面量被编译程序自动转换成具有这种形式的数组,这种数组的开始地址被作为字符指针值使用。
c = c ‘A’ + ‘a’;char p, q; p = “good morning”; 最后这个赋值语句执行时并不做任何字符赋值操作,它仅是使指针p指向了保存有相应字符串信息的数组。对以字面量形式写出来的字符串求值的结果得到的就是这种指针值。
指针值(也就是一个地址)可以赋值给另一个具有同样类型的指针,这样做的结果可能导致多个指针指向同一个数据项。例如,经过下面的赋值操作,指针p和q将指向上面的同一个字面字符串:q = p;
如果在程序中要建立一个存放字符串的缓冲区,那么就应该定义一个字符数组。定义字符数组可以同时用一个字面字符串赋初始值。这样做的时候必须注意,作为初始值给出的字符串的大小绝不能超过数组定义的范围,否则就会出现越界问题,可能造成不可预料的破坏。如果在程序中要建立一个存放字符串的缓冲区,那么就应该定义一个字符数组。定义字符数组可以同时用一个字面字符串赋初始值。这样做的时候必须注意,作为初始值给出的字符串的大小绝不能超过数组定义的范围,否则就会出现越界问题,可能造成不可预料的破坏。
char buf[30] = “good morning”;这里定义的字符数组buf可以容纳长度不超过29的字符串。在上面例子里,被定义字符数组里只有前13个位置存放了有效字符,后面的部分完全没有触及。未触及部分的数组元素当时的值依赖于数组变量的种类。
C 语言里允许把字符数组的起始地址赋给指针,例如可以写:q = buf;这个语句使指针q指向数组buf的开始位置。由于在C里对指针也可以使用下标表达式,语句执行之后,由q[0]可以得到字符g,而q[5]将得到字符m。当一个指针指在某字符串中间的位置时,用负数作为下标也是合理的,基本要求是被引用的位置不超出那个字符串的范围。
C语言里对指针下标表达式使用的合法性不进行任何检查,如果一个下标表达式实际访问的位置超出了字符数组范围,取值操作将可能得到一个无法预期的值,而对该位置赋值所造成的后果则完全无法预料。也就是说,C语言把保证对字符数组的访问不超出合法范围的责任完全托付给设计程序的人。C语言里对指针下标表达式使用的合法性不进行任何检查,如果一个下标表达式实际访问的位置超出了字符数组范围,取值操作将可能得到一个无法预期的值,而对该位置赋值所造成的后果则完全无法预料。也就是说,C语言把保证对字符数组的访问不超出合法范围的责任完全托付给设计程序的人。
指针赋值(或用数组名给指针赋值)还引起数据的隐含依赖关系,例如在上面例子的语句执行后,buf[3]和q[3]指示的是同一个字符。这种关系使程序的语义模糊,给程序的阅读理解和修改都造成比较大的困难。指针赋值(或用数组名给指针赋值)还引起数据的隐含依赖关系,例如在上面例子的语句执行后,buf[3]和q[3]指示的是同一个字符。这种关系使程序的语义模糊,给程序的阅读理解和修改都造成比较大的困难。
下面的函数可计算参数字符串中字符的个数:unsigned cstrLen (const char str[ ]) { // 计算字符串长度unsigned i = 0; while (str[i] != '\0') i++; return i;}
字符串抽象要解决的问题: 1)各种串操作都应当对字符串的界限进行检查和处理, 2)避免不同指针共享字符串的情况,提高数据独立性. 3)用各种合适的操作符号定义字符串的操作. 4) 定义一些高层次的操作,例如子串操作、模式匹配等。 3.2 字符串数据抽象的描述和实现
应当能用给初始值的方式直接定义一个串, 也可以通过指定大小的方式定义存放字符串的缓冲区. string b(20); string c("a string"), d = "another string"; string e = 'a', f = d; 用字符串常量或变量给字符串变量赋值。 赋值之后对一个变量值的修改不应影响另一个变量。 c = "3rd string"; // 原有的值被取消 f = c; 能够对新定义类型的字符串使用下标表达式,而且应当允许把这种表达式放在赋值符号左边,以便修改字符串中的字符. c[0] = '2'; c[1] = 'n'; // c 的内容变成 "2nd string" 对下标的每次使用都应该进行检查。 用户使用字符串希望的方式:
两个字符串应当能够用语言中提供的关系运算符做比较操作两个字符串应当能够用语言中提供的关系运算符做比较操作 string asia = "asia"; string africa = "africa"; string america = "america"; string europe = "europe"; 我们则可得到: assert(asia == asia); assert(asia >= africa); assert(america < europe); 应能够把一个字符串连接到另一个串后面。还可以用二元运算符+表示连接两个字符串的操作, africa += "n"; 一个字符串的子串应该能通过给出其首字符下标和子串长度的方式得到, 还应该能够作为赋值对象, america(1, 5) = "si"; 完成了这个赋值之后,变量america的值就变成了"asia",出现的位置。
class string { public: // 构造函数 string ( ); string (char); string (unsigned); string (const char ); string (const string &); // 析构函数 ~string( ); // 允许子串类访问 friend class substring; // 赋值 void operator = (const string & right); void operator += (const string & right); // 取子串 substring operator( ) (unsigned start, unsigned len); // 行输入 istream & getline (istream &); unsigned length( ) const; // 访问单个字符 char & operator [] (unsigned index) const; // 字符串比较 int compare (const string &) const; // 到普通 C 字符串的转换 operator const char ( ) const; private: unsigned buflen; char buffer; }; 3.2.1 字符串类的定义
字符串缓冲区实现方法与一般变量的情况不同 • 在程序中,变量大都是“自动的”,也就是说在程序执行进入一个变量定义所在的过程(函数)时,该变量被自动分配存储。当程序执行退出有关过程时,这些存储单元被自动释放。这种自动变量的大小是固定的,根据源程序里对该变量的说明确定。 • 对于字符串表示,由于字符串的大小可能在程序执行过程中动态变化,因此无法采用固定大小的字符数组作为缓冲区,也不能用一般变量那种自动分配和回收储存的方式。所以,在字符串类定义里用字符指针作为对象数据域的组成部分,而实际的字符数组缓冲区通过动态存储管理的方式进行分配和回收。
3.2.2 构造函数的定义 string::string(unsigned size) { assert(size >= 0); // 设置数据域值 buflen = 1 + size; buffer = new char[buflen]; assert(buffer != 0); // 对缓冲区做初始化 for (unsigned i = 0; i < buflen; i++) buffer[i] = '\0'; }
string::string(const char inittext) { // 初始化数据域 buflen = 1 + cstrLen(inittext); buffer = new char[buflen]; assert(buffer != 0); // 字符串内容复制 for (unsigned i = 0; inittext[i] != '\0'; i++) buffer[i] = inittext[i]; buffer[i] = '\0'; }
string::string (const string & initstr) { buflen = 1 + cstrLen(initstr.buffer); buffer = new char[buflen]; assert(buffer != 0); for (unsigned i = 0; initstr.buffer[i] != '\0'; i++) buffer[i] = initstr.buffer[i]; buffer[i] = '\0'; } C++程序里以类作为参数类型和结果类型的函数在做值传递时,都需要用复制构造函数产生临时的对象值,因此每个用户定义类都应提供一个复制构造函数。
string::string (char c) { // 建立一个单字符的字符串 buflen = 2; buffer = new char[buflen]; assert(buffer != 0); buffer[0] = c; buffer[1] = '\0'; } 这个函数不但经常要用,它的建立也能帮助写程序的人避免一类常见错误。如果没有这个函数,当人们以一个字符作为参数构造字符串时,该字符会被隐式地转换为整数值(注意本章开始的讨论,C++语言里字符被作为整数看待),得到的是一个具有由这个整数值所确定大小的缓冲区,其中放了空字符串(读者应仔细想一想,弄清楚这个问题)。
string::string( ) { // 建立一个空字符串 buflen = 1; buffer = new char[buflen]; assert(buffer != 0); buffer[0] = '\0'; }
3.2.3 析构函数string::~string( ){ // 释放缓冲区delete [] buffer; // 指针置空buffer = 0;}
void getCommand ( ) { int i; string prompt = "command>"; double x; … // 语句部分}对象创建构造函数。。。析构函数对象消亡
3.2.4 基本成员函数的实现字符串长度unsigned string::length( ) const{ // 计算串中非空字符个数return cstrLen(buffer);}
赋值函数void string::operator = (const string & right){ // 字符串赋值const unsigned rightLength = right.length( ); // 如果缓冲区不够大,申请一个新缓冲区if (right.length( ) >= buflen) { // 释放原缓冲区delete [ ] buffer; // 申请新缓冲区buflen = 1 + rightLength; buffer = new char[buflen]; assert(buffer != 0); } // 实际复制for (unsigned i = 0; right.buffer[i] != '\0'; i++) buffer[i] = right.buffer[i]; buffer[i] = '\0';}
虽然赋值操作定义为只对赋值号右边是字符串类的对象适用,由于有数据类型转换,只要一个值可以转换为字符串,那么它就可以被用于赋值。string name;name = "zhang";字符串文字量"zhang"先被转换,构造出一个匿名的临时字符串(通过调用构造函数完成),随后用这个字符串给变量name赋值,最后这个临时字符串被删除(包括自动调用析构函数释放缓冲区的存储空间)。上述写法的执行与直接在变量说明时初始化的差别很大。如果写:string name = "zhang";对这个描述,程序执行时只调用一次构造函数,实际上不做任何字符串赋值。
下标运算我们希望下标表达式能够用在赋值号左边,这样就可以通过它实现对字符串中字符进行赋值的操作。例如:string s("abcde");s[2] = 'o';s[3] = 'v';通过上述操作,字符串变量s的内容应该变成above。如果下标操作直接返回字符值,那么它就不能用在赋值符号左边。下标表达式应当与变量类似,应返回一个地址,这样接下去做求值或者赋值就都没问题了。完成这件事最方便的方法是令这个函数返回一个引用。
对于超范围访问采取了特殊处理方法,返回对一个全局变量nothing的引用,这样就能防止字符串用户修改字符串中表示结束的空字符。char nothing; // 用于放空字符的全局变量char & string::operator [](unsigned index) const{ // 首先检查范围if (index >= cstrLen(buffer)) { // 如果超出范围,返回对全局变量 nothing 的引用nothing = '\0'; return nothing; } return buffer[index];}
例1 大小写转换下面是一个使用下标表达式的例子。这里定义了一个函数,把字符串中所有的小写字母都转换为大写字母。void toUpper(string & word){ // 把字符串中所有小写字母转换为大写for (unsigned i = 0; word[i] != '\0'; i++) if (isLowerCase(word[i])) word[i] = (word[i] 'a') + 'A'; }其中辅助函数isLowCase的实现非常简单:int isLowerCase(char c){ // 判断字符是否小写return (c >= 'a') && (c <= 'z');}
例2 元音计数*下面是一个计算字符串中元音字母出现次数的函数,它以字符串为参数,逐个检查串中的字符,在遇到元音字母时增加计数值。最后输出计数结果。void vowelcount ( const string & str) { long count = 0; unsigned len = str.length( ); char c; for (unsigned i = 0; str[i] != '\0'; i++) if ( ((c = str[i]) == 'a') || (c == 'e') || (c == 'i') || (c == 'o') || (c == 'u') ) count++; cout << "The string has " << count << " vowels " << '\n'; cout << "Ratio of vowels in the string is " << (count 100.0) / len << '\n';}
3.2.5 比较运算符在字符串类中,具体比较运算(例如>=等)没有定义为类的成员函数,如果把它们定义为成员函数,左参数就会与右参数地位不同,尤其是对左参数, 将不能进行自动类型转换。用普通函数实现,两个参数的地位完全相同了。这样(假定str是字符串)下面两个比较就都能够处理(注意,在两个比较前对参数“beijing”都要进行转换):str >= "beijing""bejing" > str
由于六个比较操作的实现非常类似,如果分别实现,这六个函数的定义将会有大量重复代码。这里把它们的共性抽取出来,建立为一个公用成员函数,所有比较运算符的实现都调用该函数。这样就可以避免重复代码。int string::compare (const string & val) const{ // 比较字符串缓冲区里的字符序列char p = buffer; char q = val.buffer; for (; (p != '\0') && (p == q); p++, q++) ; // 空循环体,字符相同就继续比较return p q; // 左字符串:小、大、相同;返回:正、负、零}
int operator <= (const string & left, const string & right){ return left.compare(right) <= 0; }其他比较运算符的定义类似。
void string::operator += (const string & v){ unsigned i; // 求出并置后字符串的总长 unsigned conLen = length( ) + v.length( ); if (conLen >= buflen) { // 原缓冲区不够大 char newbuf = new char [1 + conLen]; assert(newbuf != 0); // 复制原有字符 for (i = 0; buffer[i] != '\0'; i++) newbuf[i] = buffer[i]; // 删除原缓冲区,重置缓冲 //区指针和长度 delete [ ] buffer; buflen = 1 + conLen; buffer = newbuf; } else i = cstrLen(buffer); // 把字符串v的内容 //接在后面 for (unsigned j = 0; v.buffer[j] != '\0'; i++, j++) buffer[i] = v.buffer[j]; buffer[i] = '\0'; } 3.2.6 串连接
string operator + (const string & left, const string & right) { // 求出由并置两个字符串而得到的字符串 string result(left); result += right; return result; } 下面是这个函数使用的例子: string s1("interest"), s2("ing"), s3; … s3 = s1 + s2; cout << s1 << " + " << s2 << " = " << s3 << '\n';
3.2.7 输入和输出*在一个类里同样也可以定义把自己的实例转换为其他类型的运算符(或一般成员函数)。做此事时应该把作为转换结果的类型当作运算符的名字,同时也不需要给出结果类型(与构造函数类似),这种运算符称为“转换运算符”。string::operator const char ( ) const{ // 把一个 string 值转换为指向常量字符数组的字符指针return buffer;}输出字符串时可以用这个函数把字符串转换为字符指针,然后就能直接用以普通字符指针为参数的流操作函数。
以字符指针为参数的输入以词为单位进行。在流输入过程中,输入序列被看作由空格(包括空格符、换行符、制表符等)分隔的词序列。istream & operator >> (istream & in, string & str){ // 字符串输入:读入流中的一个词,存放到参数字符串中char inbuffer[1000]; if (in >> inbuffer) //字符数组的输入>> str = inbuffer; else str = ""; return in;}前面求输入流中最大最小词的例子里已经使用了这个字符串输入函数。
例 求最小和最大的单词程序由标准输入读入单词,最后输出结果。void words ( ) { string maxword, minword, word; cin >> maxword; minword = maxword; while (cin >> word) if (word > maxword) maxword = word; else if (word < minword) minword = word; cout << "The smallest word is " << minword << '\n'; cout << "The largest word is " << maxword << '\n'; }
实际工作中常需要整行地进行输入,C++流I/O库里有个名为getline的系统函数,其作用就是读入一个整行。用这个函数可以非常方便地实现字符串的行读入函数:istream & string::getline (istream & in){ // 读入一个行到字符串in.getline(buffer, buflen, ‘\n’); //getline的重载return in;}函数in.getline由流in读入一个完整的行,直到行结束,或者读入字符数达到了由第二个参数确定的数目。上面函数定义里用当前字符串缓冲区长度作为读入字符数的限制,保证输入动作不会超出界限。getline的第三个参数指明作为行结束的字符,这样做完全是提供一种灵活性。
3.3 子 串一个字符串的子串是由该字符串中连续的一段字符构成的串。指明一个子串的方式是给出子串的起始位置和子串长度。string ind1 = “indicative”; string ind2 = “induce”; ind1(3, 3) = ind2(3,2); //ind1 的内容应该变成“inductive”这个例子说明在子串处理中有两个问题必须解决:首先,被赋值操作修改的应该是原来的字符串;其次,赋值字符串可能与原子串的长短不同,可能短也可能长。
解决这些问题必须定义一个新类,这里称为substring。我们希望能由一个substring值得到原字符串缓冲区的一个指针,对substring类的赋值操作将定义一个新方法,完成所需动作。字符串类的用户不需要知道子串类的存在,他们也不会用到这个类。像substring这种为完成另一个类的功能而定义的类通常称为辅助类。实际中在定义功能复杂的类时,常常需要定义辅助类。字符串类定义里已经把子串类定义为一个友类(用friend class说明),这使子串类的操作可以访问字符串类的内部数据成分。友类可以看作是C++语言数据封装机制中为实现一些特殊功能而提供的一个接口。类substring的定义见类3.2。这个类需要提供的操作主要是对子串的赋值,以及由子串到普通字符串的转换等。
class substring{public: // 构造函数substring(string & base, unsigned start, unsigned len); substring(const substring & source); // 字符串到子串的赋值void operator = (const string &) const; // 子串到字符串的转换operator string ( ) const;private: string & base; const unsigned start; const unsigned len;};类3.2 substring类的规范说明
首先看字符串类中的取子串操作,它返回的是一个子串。为了能把当前字符串作为函数的实际参数,这里要使用C++的伪变量this。任何类的成员函数中出现的关键字this总表示指向当前类实例的一个指针,该指针可以像其他指针一样使用,但显然不能被重新赋值。由于子串构造函数需要一个对字符串的引用参数,下面函数里对this用了“间接”操作:substring string::operator ( )(unsigned start, unsigned len){ // 求出指定子串if (start >= length( )) { start = 0; len = 0; } int maxlen = length( ) start; if (len > maxlen) len = maxlen; // 构造子字符串return substring(this, start, len);}
子串的构造函数有两个:substring::substring(string & str, unsigned s, unsigned l) : base(str), start(s), len(l){ // 无须其他动作}substring::substring(const substring & source) : base(source.base), start(source.start), len(source.len) { // 无须其他动作 }
对子串的赋值由于子串表达式形成了对原字符串实例的缓冲区的一个描述,对子串进行赋值就能够实际改变原来那个字符串实例。为了处理更简单,在下面函数实现里,一但原字符串的大小有变化,就建立一个新缓冲区,并且在新缓冲区里建立新串。子串赋值操作可以如下实现:对子串的赋值由于子串表达式形成了对原字符串实例的缓冲区的一个描述,对子串进行赋值就能够实际改变原来那个字符串实例。为了处理更简单,在下面函数实现里,一但原字符串的大小有变化,就建立一个新缓冲区,并且在新缓冲区里建立新串。子串赋值操作可以如下实现:
// 复制前缀部分for (i = 0; i < start; i++) newdata[i] = base[i]; // 复制中部for (unsigned j = 0; rstr[j] != '\0'; j++) newdata[i++] = rstr[j];// 复制后缀部分for (unsigned j = start + len; base[j] != '\0'; j++) newdata[i++] = base[j]; newdata[i] = '\0'; // 替换缓冲区delete [ ] base.buffer; base.buflen = newlen; base.buffer = newdata; return;} void substring::operator = (const string & rstr) const{ // 如果复制长度相同, //可直接进行unsigned i; if (len == rstr.length( )) { for (i = 0; i < len; i++) base[start + i] = rstr[i]; return; } // 计算新串长度unsigned newlen = rstr.length( ) + base.length( ) len; char * newdata = new char[newlen + 1];
下一步考虑把子串转换为字符串的操作。substring::operator string ( ) const{ // 首先把字符序列复制到一个临时缓冲区char buf = new char[len + 1]; //len是什么?assert(buf != 0); for (unsigned i = 0; i < len; i++) buf[i] = base[start + i]; //base start是什么?buf[len] = ‘\0’; // 构造一个字符串string result(buf); delete [ ] buf; //删除什么?为什么return result;}