c++学习记录四:New操作符和StringParser类
(本文来自c++简明教程)
本文要强调的一个重点在于,类和面向对象编程并没有什么神秘之处,和其他某些书强调的不同,类之所以有用,并不是因为它是一个类,而是因为它提供了一系列相互练习的服务,能够解决一般性的编程问题。
一个常见的编程任务是获取输入,并对其进行分析。本章将介绍一个StringParser类,它能将一个输入字符串分解成一系列子串,每个子串中都包含一个单词。
注意:该类的大多数功能已经有strtok库函数提供。但是,你仍然有必要知道如何写一个功能相似的类。另外,strtok库函数的一个缺点在于,它不能同时扫描多个字符串,但是,采取面向对象的编程的方式,这就不是个问题。
在讨论这个类的过程中,本章还讨论了new关键字,它是c++中用于操纵类的最有用的关键字之一。简单地说,使用这个关键字允许你申请内存,为可能要用到的新变量留出足够的空间。
new操作符
以前描述指针操作时,我是分两个步骤来完成的:通过声明一个变量来分配空间,然后将它的地址赋给一个指针。例如:
1 | int n; |
对于简单的程序来说,这样做是无可厚非。但是,c++允许你将上述两个步骤合并成一个。而且正如稍后要讲到的那样,在某些情况下,只能采用这这种方案。为此,你需要使用new关键字,它要使用以下语法:new 类型
c++对这个表达式进行求值时,会在内存中分配一个新的数据项,它具有指定的类型,并返回指向该数据项的一个指针。这意味着你随后可以写像下面的语句:
1 | int *p; |
或者将两个语句合并成一个:int *p = new int;
上述语句的结果是创建一个未命名的整数变量,它只能通过指针p来访问。
利用这个指针,你可以任意操纵整数的值,并在表达式中使用它:
1 | *p = 5; |
但是,创建一个只能通过指针来访问的整数又有什么意义?为什么不像以前那样使用标准的整数声明呢?int n;
答案是:这样做的意义在于,整数值(再次提醒你,它只能通过指针p来访问)不是在程序中声明的,而是在程序运行时 动态创建的。换言之,整数值是在程序执行期间分配的,而不是(像大多数变量那样)在程序加载到内存的时候分配的。
这样一来,程序就能在需要的时候,自由地创建新的数据空间(相当于创建新变量)。
与new操作符对应的还有一个delete操作符,它用于归还new请求的内存。delete操作符要使用以下语法:delete 指针;
上述语法描述的是一个语句,其中的指针是一个地址表达式。该语句的作用是销毁指针所指向的数据项,将它占用的内存归还给操作系统。
下面的例子做了3件事情:(1)动态创建整数存储;(2)用一个指针来操纵整数;(3)将那个整数占用的内存归还给操作系统。
1 | int *p = new int; |
对象和new
在此之前,我们有意回避了面向对象的一个重要概念:指向对象的指针,或者说“对象指针”。在OOP的一些比较高级的主题中,这个概念是至关重要的。
一个程序同图形用户界面(GUI)或者网络环境中的其他程序进行交互时,它通常需要传递或接收指向对象的指针。前一章已经证明了传递对象会影响程序的执行效率:每次拷贝一个对象时,都需要分配新的数据空间,并调用一次拷贝构造函数。如果对象需要频繁地传递,那么连续生成新的对象拷贝,会产生大量多余的开销。
为了弥补效率不足的问题,系统应用程序和库通常采取传递对象指针的方式。一般的做法是由程序创建一个对象,然后将指向该对象的一个指针传给另一个程序:
使用对象指针还有另一些好处,具体在以后讲述。虽然对象可能更具体的类型,但指向对象的一个指针可以具有常规的指针类型(可以是指向一个抽象类的指针,或者是指向一个接口的指针)。这一事实为面向对象编程赋予了出色的灵活性。具体将在以后解释。
目前先来看看new和对象的关系。new操作符即支持对象类型(类),也支持基本数据类型。事实上,设计new的主要目的就是操纵类,虽然它在操纵int和double这样的类型时也相当有用。
例如,你可以使用new来分配一个Fraction对象的空间,并获得指向它的一个指针:Fraction *pFract = new Fraction;
该语句创建一个Fraction对象,并调用默认构造函数(因为没有指定参数)。但是,你在这里也可以参数,以便将Fraction对象声明为一个变量:Fraction *pFract = new Fraction(1, 2); //初始化为1/2
用new和一个参数列表为一个对象分配空间的做法是:new class_name(argument_list)
这个表达式将为指定的类分配一个对象所需的空间,调用恰当的构造函数(由argument_list来决定),并返回对象的地址。
一旦获得指向一个对象的指针,怎样使用它呢?怎样引用对象的成员呢?最容易想到的方法是提领指针(使用*),然后访问它的成员。例如:
1 | Fraction *pFract = new Fraction(1, 2); |
由于这个操作——提领一个指针,然后访问它指向的对象的成员——是如此普遍,以至于c++专门提供了一个操作符来帮助你写出更简洁的代码。下面是具体的语法:
1 | pointer -> member; |
上述语法分别描述了如何访问异构数据成员和一个函数成员。它们等价于:
1 | (*pFract).member; |
对于前面使用指针来调用get_num的例子,它现在可以写为:cout << "The numerator is " << pFract -> get_num();
下面是另一个例子,它调用对象的set函数,将新值赋给函数:pFract -> set(2, 5); //将指针指向的分数设为2/5
为数组数据分配空间
前面解释了如何在运行时动态分配内存,最终的结果就是程序在运行时,会申请比程序最开始申请的更多的内存。这听起来似乎不错,但到底有什么实用价值呢?
一个简单的、而且很常见的情况是:假如你需要为一个数组分配空间,但事先不知道数组有多大,那么应该怎么做?最容易想到的方案就是事先创建一个足够大的数组。它的空间应该大到不可能轻易用完,而你只有凭经验希望一切正常。但是,这显然并不是最好的方案。它可能出错。
c++允许你在运行时使用new操作符来声明一个内存块,语法如下:new type[size]
上述表达式将为size个元素分配空间,其中每个元素都具有指定的type。然后,它计算第一个元素的地址。type可以是一个标准类型(int, double, char等等),也可以是一个类。size值是一个整数。
new的这个版本返回的是指向指定type的一个指针,这和new的其他版本没有区别。
例如:
1 | int *p = new int[50]; |
对于上述语法,重点在于你指定的size不一定是一个常量。你可以在运行时决定需要多大的内存。例如,你可以在程序中询问用户想生成多大的一个数组:
1 | int n; |
这是非常有用的一个技术,将在下一个例子中使用它。
使用任何形式的new操作符时,都要由你来负责内存的创建和回收。所以,到程序结束的时候,记住使用delete操作符来销毁任何新建的内存对象。如果使用本节语法分配了一个内存块,就要用以下语句来回收该内存块:delete [] pointer;
小插曲:解决内存分配问题
使用new操作符时,程序会向操作系统发出一个内存请求,系统的响应是检查可用的内存,判断是否有足够多的空间。
对于当今的计算机平台来说,分配大量内存一般是没有问题的。除非你请求数量巨大的内存,否则一般都能顺利获得你需要的内存。但是,仍然要做好内存不足的心理准备。你的程序应该能够应付这种情况。
如果请求的内存不可用,new操作符会返回一个null指针。你可以测试这种可能性,然后采取相应的行动。
1 | int *p = new int[1000]; |
可能发生的另一个问题是内存泄漏(memory leak)。 使用new成功请求了内存之后,操纵系统会帮你保留内存块,直到使用delete回收。如果你直接终止程序,而没有回收已经动态分配的内存块,那么每运行一次程序,系统都会丢失部分内存。最终,你的计算机丢失大量可用的内存。
当然,并不是说计算机丢失了物理内存;如果你重启计算机,内存可以完全恢复。但是,你不可能要求用户频繁地重新启动计算机。
为了避免这个问题,你一定要记住使用delete回收所有动态分配的内存,然后才能退出程序。有的程序设计语言(比如Java)提供了一个名为“垃圾回收器”的进程,它在后台运行,专门负责寻找不再使用的内存块,并回收它们。但是,C++没有设计这一特性的一个原因是它会耗费处理器资源,另一个原因和C一样,它也假定你知道自己该做的事情,会自己负责回收系统资源。
示例1:动态内存
本例展示了动态内存的简单应用:为用户指定大小的一个数组分配所需的内存。然后,使用这个数组来存储用户输入的数值,打印它们,对它们进行求和,然后打印平均值。
new1.cpp
1 | #include <iostream> |
幕后玄机
本例最有趣的一个特性就是它使用new操作符来动态分配内存。首先,程序提示用户输入数组包含的元素数量:
1 | cout << "Enter number of items: "; |
有了n值之后,程序就开始分配n个元素所需的内存空间:p = new int[n]; //分配n个整数所需的空间
指针p(之前已经声明为int*类型),它包含第一个元素的地址。你可以像使用数组名那样,把它作为索引基地址来使用。其余代码很容易理解,直到最后一个语句,它使用delete[]语法来回收new分配的所有内存:delete [] p; //释放n个整数
注意,方括号([)在这里是必须的,因为你创建的内存块是供容纳多个元素的。否则的话,使用delete的简单形式就足够了。
练习 1.1
修改示例1,让它创建一个浮点数数组(double数组)。注意,尽管n仍然是一个整数,但大多数变量都需要相应地进行修改,以使用基本类型double。
解析器设计(词法分析器)
对于任何一个编程工具来说,它存在的惟一目的就是解决实际的问题。new操作符的作用就是允许你在需要的时候创建一个数据结构。下面研究一下在一个有用的类中我们应该如何利用它。
注意:对于本章的开发的StringParser类来说,它的大多数功能都已经由c++标准库的strtok(即“string tokenizer”)函数提供,虽然后者采用的不是一种完全面向对象的设计思路。我们设计StringParser类的真正目的是让你熟悉写一个类时必须掌握的编程技术。
在本章开头,曾指出最常见编程任务之一就是对输入进行分析,或者称为“词法分析”(lexical analysis)。和Fraction类配合使用,你就会理解词法分析的用处。
例如,你可能希望允许用户提供以下形式的输入:3/4
但是,最好的做法并不是在主程序代码中提取这个字符串,并试图判断一个子字符串的开始和结束位置。相反,更好的做法是让一个对象来帮你做这件事情。
具体说来,你可能通过指定两个字符串的方式来创建一个对象:StringParser parser("3/4", "/");
第一个字符串包括要分析的数据——这通常是一个输入字符串。第二个字符串是定界字符串,它包含了用于区分不同子字符串(或“单词”)的一个或多个字符。其中任何一个字符都可以标志一个子字符串的结束以及另一个字符串的开始。例如以下语句:StringParser parser("3/4/55@10", "/@");
parser对象将能分解出4个子字符串:”3”,”4”,”55”和”10”。
回到原先的例子,假定input_string包含字符串数据”3/4”,而且parser是由以下语句创建的:StringParser parser(input_string, "/");
那么只需调用get函数,就能提取出字符串“3”和“4”:
1 | char *p1, *p2; |
这种写法的优点在于,你惟一要做的就是指示“获取下一个字符串”(具体由get函数来完成),不需要维护一个“当前位置”标志来识别input_string中的当前位置。这个例子很好地诠释了我们使用类的一个主要目的:保护数据。
当然,我们目前保护的数据并不多,只是保护了当前位置标志,以及输入字符串和定界符字符串(定界符虽然需要指定,但只需在程序中指定一次)。一个真正强大的类应该能保护大量数据,或者像下一章要讨论的String那样,虽然只包含一种数据,但允许以复杂的方式来操纵它。
不过,还是让我们先回到StringParser类,get函数创建了一个全新的、以null终止的字符串,并返回指向那个字符串的一个指针,以下两行代码创建了两个不同的字符串:
1 | p1 = parser.get(); |
字符串数据所需的内存是如何分配的呢?当然是使用new操作符分配的(稍后展示类的实现时,你就会看到)。由于新的字符串数据所需的内存是使用new操作符来分配的,所以最后一定要释放这部分内存,否则就会出现前文“小插曲”中说到的“内存泄漏”问题。
1 | delete [] p1; |
由于get函数在每次调用时都返回一个新的字符串,而且不在内部对其进行跟踪,所以必须由调用者负责使用delete回收内存。
注意,你或许已经注意到了这种做法可能产生的一些问题,get函数返回的实际是一个指针,它指向一个全新的、刚刚生成的字符串,而且你不需要关心它如何为字符串分配空间,但这样一来,回收字符串空间的责任就完全落在了对象的用户身上,所以,更好的做法或许是让get函数将字符串数据拷贝到一个现有的、已经由调用这分配好空间的字符串,这种方案将留到下一节的练习题中实现。
当然,有的时候,你只是希望获得一个足够长的子字符串,它能够转换成一个数字。为了添加这种能力,我们可以创建一个get_int函数:
1 | int d = parser.get_int(); |
正如你以后会看到的那样,该函数是很容易实现的。get_int函数只需调用get函数(假定它可用),再调用atoi这个库函数,后者读取字符串数据,并生成一个整数。
StringParser类还需要提供一个能够“检查是否还有更多数据”的函数。如果抵达字符串末尾,它应该返回true(1)。下面展示了这个名为more的函数和get函数在一个程序中的实际应用:
1 | #include <iostream> |
程序声明了一个含有10个指针的数组,每个指针的类型都是char*。注意,这时尚未分配任何实际的字符串数据——只是指针——因为字符串数据将由parser对象来提供。这个数组中的指针只负责存储地址:char *p[10];
第一个循环使用了parser对象。循环条件中调用more函数,并在循环主体中调用get函数,从而每次循环都获取一个新的字符串。这个成员函数的用法很简单:
1 | for(int i = 0; parser.more() && i < 10; i++) { |
第二个循环打印所有字符串,以便验证类的成员函数确实能够正常地工作。注意,针对第一个循环返回的,每一个指针,必须使用delete []来回收该指针指向的字符串所占用的内存空间。回收了所有的内存空间之后,程序才能结束。
下面总结一下StringParser类应该提供的所有函数。首先,它需要支持两个构造函数。
该类还支持以下成员函数。
在开始编写和测试这个类之前,还有一件事情是必须完成的:设计上述每个函数的算法。
get函数写起来似乎最有挑战性,因为这个函数可以采取不止一种方式来写。例如,该函数做的一件事情是“用掉”或者“消费”(consume)定界符。换言之,它需要在读取了定界符之后,跳过它们,不要将它们拷贝到一个目标字符串中。那么,定界符(如果有的话)到底应该在读取了一个子字符串之前“用掉”,还是应该在读取了了一个子字符串之后“用掉”?
我为get函数采用的是以下算法:
1 | 分配新字符串(所需的内存空间) |
get_in函数则比较简单,因为它可用重用get函数来完成它的大多数工作。下面是该函数的算法:
1 | 调用get函数来获取下一个子字符串 |
最后两个函数则更简单。其中,more函数只需做下面的事情:
1 | 如果“当前位置”是null(表面抵达字符串末尾),就返回false(0) |
StringParser类还需要跟踪private数据,其中包括:输入字符串、定界符字符串以及“当前位置”标志(我将其命名为pos)。下图总结了StringParser类的完整设计:
示例2:StringParser类
本节正式实现StringParser类,并对其进行测试。程序运行时,首先会打印以下提示消息:Enter input file:
作为用户,你可以输入任意字符。输入时,可以包含任意数量的斜杠(/)和逗号(,)。程序看到这些字符,会将其识别为子字符串的定界符。例如,假定你输入的是下面这一行:2/3/35//5,1,,,,22
那么程序的输出将是:
1 | 2 |
可以看出,一系列定界符(,,,,)被视为单个的定界符(,)。这正是设计的初衷。完全可以采取不同的方法来写类的函数,使一系列定界符导致程序读取一系列空白字符串。
你可能已经注意到,StringParser类没有默认构造函数,这也是有意的。没有使用一个输入字符串来初始化的parser对象是没有用处的。
parse1.cpp
1 | #include <iostream> |
幕后玄机
程序做的第一件事情就是包容一系列文件。和往常一样,你需要添加对iostream的支持,以便使用cin和cout,stdlib.h和string.h文件也必须包容,从而分别支持atoi和strchar库函数,它们将由StringParser类使用:
1 | #include <iostream> |
main函数使用一个对象来分析用户输入的一行内容,从而对StringParser类进行测试:
1 | cout << "Enter input line: "; |
StringParser类声明了3个private成员:pos,input_str和delimiters。注意,最后两个只是指针,而不是字符串数组。也可以将它们作为字符串数组来实现,但在那种情况下,你需要调用strcpy函数来拷贝数据,而不能仅仅存储一个地址(就像本例这样)。本例的代码简单地假定原始字符串数据(也就是输入行)在类结束对它的使用之前不会被销毁。但要注意:假如input_str在parser对象之前超出作用域,那么对象将获得指向无效数据的一个指针(此处暂不理解)。
1 | int pos; |
类声明了本章前面提到的所有函数。其中大多数函数都非常简单,所以适合进行内联:
1 | StringParser(char *inp, char *delim) { |
get函数是这个类的重点,而且由于代码较多,所以不适合进行内联。get函数做的第一件事情就是为一个新的字符串分配空间。100这个数是我任选的,之所以选它,是因为感觉一个单词不可能超过99个字符。当然,在特定情况下,这个假设是有问题的。真的要解决这个问题,也不是没有办法,我稍后就会讲到具体如何做。new_str = new char[100];
get函数做的下一件事情是“甩掉”它发现的任何定界符。之所以要这样做,是因为前一个get函数调用(如果有的话)可能使“当前位置”标志定位到一个定界符上(首次调用get时,一般不可能在字符串的开头出现任何定界符。如果没有发现定界符,以下代码就什么事情都不做):
1 | while(strchr(delimiters, input_str[pos])) |
上述语句调用了strchr库函数;如果第二个参数指定的字符未在第一个参数(一个字符串)中发现,该函数就返回一个null值。如果在第一个参数(delimiters)中发现了字符,strchr就返回一个非null的值,所以整个表达式求值为true。从本质上说,上诉代码要表达的意思是:
1 | while input_str[pos] 和 delimiters 字符串中的某个字符匹配 |
由于变量pos是用于指示“当前位置”的一个标志,所以input_str[pos]肯定是“当前字符”。
get函数接下来要做的使其是将字符读入新字符串——只要当前字符不是一个定界符,也不是一个null终止字符:
1 | while(input_str[pos] != '\0' && |
最后,程序通过附加null终止字符’\0’来结束新字符串,并将其返回:
1 | new_str[j] = '\0'; |
改进代码
在上述代码StringParser代码中,至少存在一处重大缺陷:它假定每个子字符串的字符串都不会超过99个。如果超出这个限制,get函数就会超过动态分配的字符串(new_str)的大小,导致很难发现的错误,因为get函数在这种情况下会覆盖它不应该覆盖的内存区域。
作为c++程序员, 你应该时刻警惕此类隐患。只要有任何迹象表明一个循环或函数可能覆盖它不应覆盖的内存区域,就必须采取行动来消除这一隐患。
一个显而易见的方案是在写入了99个字符之后,就禁止继续向新字符串写入。有问题的代码是:
1 | while(input_str[pos] != '\0' && |
为了解决问题,你可以在语句的中部插入一个if条件。
1 | while(input_str[pos] != '\0' && |
一个更好的方案是事先确定new_str需要多大的空间。当然,这需要get函数做更多的工作。不过在strcspn库函数的帮助下,做到这一点也是相当容易的。
该函数在第二个参数中查找与第一个参数(假定为s1)的第一个字符匹配的字符,并返回它的索引。如果没有找到匹配的字符,就返回s1的null终止字符的索引:
1 | int substring_size = strcspn(input_str + pos, delimiters); |
将上述代码放到用于“用掉”初始定界符的循环之后就可以了。
练习
- 修订示例2,让它调用get_int函数,而不是get函数。换言之,它只读取数字,但简化了main函数中的部分代码。
- 在StringParser类中,请添加一个get_dbl函数,它能读取浮点数字,并返回double类型的一个值(提示:该函数的代码与get_int类似,但是要调用atof,而不是atoi)。
- 改写get函数,让它将子字符串拷贝到一个现有的字符串中。现有的字符串必须作为参数来指定。get函数的声明要变成:
get(char *dest);
这样一来,分配字符串数据所需的空间(以便容纳子字符串)的任务就移交给了对象的用户。所以,你同时需要修改main函数,在其中声明容纳这个目标字符串所需的空间。 - 为StringParser累添加一个set_size函数,使对象的用户能够指定一个子字符串读取的最大字符数。相应的修改get函数,使其使用这个设置。
小结
下面总结了本章的要点:
- new操作符能够在运行时动态分配新内存。它要使用以下语法,返回的是一个指针,它指向请求的内存。指针的类型是*type。
new type
- 如果type是一个对象类型(也就是一个类),那么你可以指定参数列表,以便调用所需的构造函数:
new type(argument_list)
- 还可以使用new来指定一系列数据项(内存块)。和new的其他版本一样,它返回的是具有*type类型的一个指针:
new type[number_of_elements]
- 程序结束之前,你必须主动释放任何动态分配的内存。为了删除使用new创建的单个数据项,需要使用以下语句:
delete pointer;
- 为了删除用new创建的一个内存块(大小任意),需要使用以下语句:
delete [] pointer;
- 下例简单描述了new和delete的用法:
1
2
3Fraction *pFract = bew Fraction[10];
///...
delete [] pFract; - 操作符 -> 简化了对象指针的使用。下面是一个例子:
pFract->set(1, 2);
它等价于:(*pFract).set(1, 2);
c++学习记录四:New操作符和StringParser类