c++学习记录二:构造函数
(本文来自《c++简明教程》)
##构造函数入门
构造函数(constructor)是c++用于描述“初始化函数”的一个术语,这种函数告诉编译器如何解释下面这样的声明:Fraction a(1, 2); // a = 1/2
基于前面介绍过的有关Fraction类的知识,你或许已经猜到,这个声明等价于以下语句:
1 | Fraction a; |
本章的目的就是让类准确地识别Fraction a(1, 2)这样的语句。但是,计算机没有办法自动猜出我们的意图,不管这个意图看起来有多明显。你必须写相应的代码,告诉计算机如何执行初始化。这正是设计构造函数的目的。
构造函数本质上是一种特殊的成员函数(所以它必须在类的内部声明)。它具有以下语法形式:
1 | class_name(argument_list) { |
这个函数看起来很奇怪。它没有返回类型(就连void都没有)。在某种意义上,类名代替了返回类型,构造函数的作用是创建该类的一个对象。
下面是一个示范性的构造函数声明(一个原型):Fraction(int n, int d)
在类的上下文中,上述声明看起来就像下面这样:
1 | class Fraction { |
当然,这只是一个声明。和其他任何函数意义,构造函数必须在某处进行定义。你可以将定义放在类的声明之外。在这种情况下,必须使用一个Fraction::前缀来澄清它的作用域:
1 | Fraction::Fraction(int n, int d) { |
在类的声明之外定义的一个构造函数具有以下语法形式:
1 | class_name::class_name(argument_list) { |
第一个class_name是名称前缀(class_name::),它指出该函数是类的一部分(换言之,在类的作用域内)。第二个class_name是函数本身的名称。这表面上似乎令人迷惑,但请记住构造函数的名称肯定就是它的类的名称。
构造函数还可以采取内联设计。上述构造函数本身的定义颇为简单,所以很适合进行内联。
1 | class Fraction{ |
多个构造函数(重载)
函数重载(在不同的上下文中使用同一个函数名)是写构造函数时的关键。我们可以使用同一个函数名来创建几个不同的函数,让c++编译器根据参数列表(argument_list)中包含的类型来区分它们。
例如,你可以为Fraction类声明几个构造函数–一个无参数,另一个有两个参数,第三个则只有一个参数。
1 | class Fraction{ |
另外,所有构造函数都可以选择是否定义成内联函数。
如果愿意,上述构造函数列表还可以进一步延长。例如,除了获取一个整数参数的构造函数之外,还可以写一个获取字符串参数的构造函数。编译器能通过参数的类型来判断到底使用哪一个函数的定义:
1 | Fraction(int n); |
但是,就目前这个Fraction类来说,我们实际只需要两个构造函数:一个获取两个整数参数,另一个无任何参数。无参数的构造函数称为“默认构造函数”。默认构造函数非常重要,所以我们准备在下一节专门讲述它。
默认构造函数
每次写一个类时,除非它是一个不重要的类,根本不需要构造函数,否则你都应该为它写一个默认构造函数–也就是无参数的构造函数。
之所以要制定这一规则,是因为c++语言具有下面的特点。如果你忽略这个特点,到时候可能会大吃一惊:
如果不写任何构造函数,编译器将会自动提供一个默认构造函数。但是,只要写了一个构造函数,编译器就不会自动提供默认构造函数。
好了,下面来深入体会一下这个特点。首先,假定你定义了一个没有任何构造函数的类:
1 | class Point { |
由于这个类没有构造函数,所以编译器将自动提供一个默认构造函数,也就是一个无参数的构造函数。正是因为有这个默认构造函数,所以你才能使用类来声明对象。
1 | Point a, b, c; |
到目前为止,似乎一切正常:如果你不提供构造函数,那么系统将自动提供一个,使类的用户能够顺利地声明对象。现在来看你自己定义了一个构造函数之后的情况。
1 | class Point { |
有了这个构造函数,你就可以在声明对象的同时进行初始化:
1 | Point a(1, 2), b(10, -20); |
但是,现在假如继续声明无参数的对象,就会出错:
1 | Point c; //错误!没有默认构造函数 |
这是怎么回事?问题出在前面指出的c++的那个特点。只要写了一个构造函数,编译器就不会自动提供一个默认构造函数。自动帮你生成的,以前不知不觉依赖的默认构造函数,就这样跑掉了!
刚开始写类的代码时,编译器的这个行为会使你不知不觉地“中招”。你之前可能已经习惯于不显式地写构造函数,而且允许类的用户像下面这样声明对象:
Point a, b, c;
但是,一旦你开始写一个构造函数,同时这个构造函数不是默认构造函数,那么上面看起来“清白无辜”的代码就会造成严重错误。
为了避免出问题,你应该养成无论如何都自己写一个默认构造函数的习惯,而不是依赖于编译器。你写的默认构造函数可以尽可能地简单。事实上,它可以不包含任何语句:
Point() {}
对于编译器提供的默认构造函数,它的行为是将所有数据成员设为零。换言之,char字符串中的所有位置都会用null来填充(如果字符串数据直接包含在类中),并将所有指针都设为null指针(也就是不指向任何位置)。这对许多类来说都是一种恰当的初始化行为,但对于我们的Fraction类来说,则是不恰当的。由此可以总结出我们要写一个自己的默认构造函数的另一个原因–确保它采取正确的初始化操作!
小插曲:c++故意用默认构造函数来陷害你吗?
本节描述的c++行为看起来有点古怪:提供默认函数(也就是没有参数的构造函数)来营造一种虚假的安全氛围。一旦你开始动手写自己的构造函数,又悄悄地取消了这一“行为”
这确实是一种古怪的行为,但却并未毫无理由。它之所以成为c++的一个“特点”,完全是因为c++即是一种面向对象的语言,也是一种需要向下兼容C的语言(也并非完全兼容,因为c允许一些松散的声明,c++不允许)。
基于相同的理由,struct关键字看起来也有一点古怪。c++将struct类型视为一个类。与此同时,为了保持向下兼容,像下面这样的c代码必须能在c++中成功编译:struct Point { int x, y; }; struct Point a; a.x = 1;
c语言中没有public或private关键字,所以对于使用struct关键字创建的类型,只有在其中所有成员都默认为publ成员的前提下,上述代码才能编译。另一个问题是,c语言没有构造函数的概念,所以,要在c++中编译上述代码,编译器必须自动提供一个默认构造函数,否则以下语句是不能成功编译的:struct Point a;
它在c++中等价与:Point a;
所以,为了向下兼容C,C++必须提供一个自动生成的默认构造函数。然而,只要你写了一个自己的构造函数,即认为你是在使用C++原生代码来编程(不再设计要和C保持兼容的问题),所以必须由你自己亲自负责所有成员函数和构造函数的编写–包括默认构造函数。
在这种情况下,就不会再找借口不知道构造函数,C++假设你应该能写出所有你需要的成员函数,包括构造函数。
示例:Point类的构造函数
本例修订了上一章的Point类,在其中添加了两个简单的构造函数,一个是默认构造函数,另一个是需要获取两个参数的构造函数。然后,我们用一个简单的程序来测试它们。
Point2.cpp
1 | #include <iostream> |
幕后玄机
本例的类声明没有什么新鲜的内容。声明中用两行代码添加了两个构造函数。由于它们都写成内联函数,所以不再需要对类的代码进行更多的延伸。
1 | public: |
注意,两个构造函数都是在类的public区域声明的。如果把它们声明为private成员,Point类的用户就无法使用它们,从而完全失去了构造函数的意义。
默认构造函数刚开始看起来似乎有点奇怪。它的定义中不包含任何语句,所以它实际上什么事情都不做:Point() {};
main中的代码在两个地方使用了默认构造函数(创建pt1和pt2对象的时候);创建pt3对象的时候,则使用了另一个构造函数。
练习
- 为Point类的两个构造函数添加代码来报告它们的用途。默认构造函数应打印“Using default constructor”,另一个构造函数则应打印“Using (int,int) constructor”。注:如果像保持这些函数的内联性,可以让函数定义跨越多行。
- 添加第3个构造函数,它只获取一个整数参数。该构造函数将x设置成指定的值,将y设为0。
###示例:Fraction类的构造函数
本例和Point类的例子稍有区别,因为Fraction类的默认构造函数必须做更多的事情。如果构造函数不包含不任何语句,那么成员将初始化为0。这对于Fraction类来说是不能接受的行为,因为分母永远不能为0。所以,它的默认构造函数应该将分数设为0/1。
Fract4.cpp
1 | #include <iostream> |
练习
重写默认构造函数,但它不是调用set(0,1),而是直接设置num和den这两个数据成员。这样做的效率是更好还是更差?有必要调用normalize函数吗?
//调用normalize函数并非必需,但却是一个良好的编程习惯
//它有预防出问题的效用,尤其是在normalize函数可能会被子类改成的前提下
//直接设置num和den理论上会更有效,因为它绕过了函数调用
//但是,不要指望它会带来明显的效率提升写出第3个构造函数,它只获取一个int参数,将num设置成这个参数的值,将den设为1。
引用变量和参数(&)
为了理解另一种特殊的构造函数(称为拷贝构造函数),首先必须理解C++的“引用”。
“引用”对C程序员来说或许是一个新概念。但好消息是,“引用”最终简化了编程,而不是让它变得更难。正如下一节要讲到的那样,为了在C++中完成一些特定的操作,“引用”是必需的。
简单地说,C++中的“引用”提供了一个指针的行为,但是不使用指针的语法。这异常重要,所以有必要重申一遍。
引用变量、参数或者返回值在行为上类似一个指针,但不需要使用指针语法。
当然,为了操纵一个变量,最直接的方式就是直接操纵它:
int n;
n =5
操纵变量的另一种方式是使用一个指针:
int n, *p;
p = &n; //让p指向n
*p = 5; //将p指向的东西设为5
在这里,p指向n,所以将*p设为5的话,具有和将n设为5相同的效果。
引用的作用和指针相同,只是它避免了使用指针语法。你首先要声明一个变量(n),然后声明对它的一个引用(r):
int n;
int &r = n;
你几乎立即就会产生疑问:&不是取值操作符吗?确实如此!区别在于,这里的&是在一个声明中使用的。基于这个前提,&创建的就是一个名为r的引用变量,它引用的是变量n。这意味着改动r相当于改动n:
r = 5; //相当于将n设为5
使用指针变量也可以获得相同的效果。换言之,你也可以使用指针p来设置n的值。但是,在使用引用变量r的情况下,就不需要设计到提领操作符(*)。记住,通过指针变量p来操纵变量,需要这样写:
*p = 5;
和指针变量不同,引用变量的目标(本例将r的目标设为n)只能在初始化的时候设置一次,以后不可更改。
简单地引用变量虽然很有趣,但在c++中实际上没有什么用处。更有用的是“引用参数”。
1 | void swap_ref(int &a, int &b) |
这个例子似乎和传统的指针写法发生了冲突。但是,仔细想一想,就会发现两者根本不冲突,因为引用参数具有指针的行为,它只是取消了指针的语法。参数 a 和 b 不是函数输入值的“拷贝”,而是对输入值的“引用”。在运行时,程序使用的实际还是指针,只是我们在编程时隐藏了这一细节。
所以,上述函数的行为和传统的指针写法是一样的:
1 | void swap(int *a, int *b) |
除此之外,两个函数在调用时也有所区别,这是因为编辑器要对参数类型进行检查(顺便要说的是,你可以使用多个模块,并修改头信息,从而来欺骗编译器,但你没有任何理由需要那样做)。要像下面这样调用引用版本的swap:
1 | int big = 100; |
###拷贝构造函数
我们前面已经介绍了一个特殊的构造函数,即默认构造函数。另一个特殊的构造函数是拷贝构造函数(copy constructor)。
拷贝构造函数的特殊性反映在两个方面。首先,这个构造函数会在许多常见的情况下调用。有的时候,你甚至根本没有意识到它的存在。
其次,如果你不自己写一个拷贝构造函数,编译器会自动为你提供一个。不过编译器在提供这个构造函数的时候,不会像提供默认构造函数时那么“吝啬”。它不会因为你写了一个自己的构造函数就停止自动提供一个拷贝构造函数。
下面列出了会自动调用拷贝构造函数的情况:
- 一个函数的返回值是类时。函数创建对象的一个拷贝,然后把它传回调用者。
- 一个参数的类型是类时。这时会创建参数的一个拷贝,并把它传给函数。
- 使用一个对象来初始化另一个对象时,例如:
1
2Fraction a(1, 2);
Fraction b(a);
假如传递的指向一个对象的指针,那么不会调用拷贝构造函数。只有在需要为一个现有的对象创建一个新拷贝时,才会调用这种构造函数。
声明拷贝构造函数时,要采用以下语法形式:
class_name(class_name const &source)
注意其中使用的const关键字。该关键字确保参数不会被函数更改–仔细想想,你就知道这个设计是很有道理的,因为既然是生成拷贝,那么当然不该破坏原来的版本。
在上述语法中,还有一点需要注意,那就是它使用了一个引用参数。因为使用的是引用参数,所以函数实际获得的是一个指针–虽然源代码并没有使用指针语法。
下面是Point类的一个例子。首先必须在类的声明中声明拷贝构造函数。
1 | class Point { |
由于函数定义没有内联,所以必须在类声明外部的某个地方提供函数定义。
1 | Point::Point(Point const &src) |
既然编译器都提供了一个现成的,为什么还要自己写一个?实际上,在本例中,甚至在Fraction类的例子中,确实没必要自己写一个拷贝构造函数。编译器提供的拷贝构造函数的行为是逐个成员地拷贝每一个数据成员。
但这只能算是个别情况。
####小插曲:拷贝构造函数和引用
C++支持“引用”的一个主要目的就是让你能写出一个拷贝构造函数。没有引用语法,写拷贝构造函数就是一个不可能完成的任务。例如,假定你像下面这样声明一个拷贝构造函数,那么会发生什么?Point(Point const src)
编译器根本不允许这样写,你仔细想想就能知道原因。将src这种形式的参数传给一个函数时,就必须生成那个对象的一个拷贝,然后将拷贝放到堆栈中。与此同时,拷贝构造函数为了完成自己的工作,它必须生成对象的一个拷贝–所以,拷贝构造函数最终需要调用自身!这便造成了一个无限循环,所以上述写法是完全不可行的。
再来看看能不能像下面这样声明一个拷贝构造函数:Point(Point const *src)
这在语法上没有问题。而且实际也能生成一个有效的构造函数。唯一的问题是,它不能用作拷贝构造函数,因为它的语法意味着参数是一个正值,而不是一个对象。
相反,只是使用引用,才能写出一个够资格作为拷贝构造函数来使用的成员函数。从语法上来说,它的参数是一个对象,而不是一个指针。然后由于函数调用实际是通过指针来进行的(换言之,本质上传递的仍然是指针),所以不会产生无限循环:Point(Point const *src)
示例:Fraction类的拷贝构造函数
以下代码展示了一个修订过的Fraction类,它加入了程序员自定义的拷贝构造函数。本例会在每次调用拷贝构造函数时打印一条说明消息。
fract5.cpp
1 | #include <iostream> |
幕后玄机
本例新增的内容不多。惟一比较新鲜的设计就是在调用拷贝构造函数时会打印一条消息。但这实际算不上程序的新功能,因为假如你不自己写一个拷贝构造函数,编译器会自动提供一个。
编译器提供的拷贝构造函数和我们自己写的版本几乎完全一致,只是我们的版本要打印一条消息:
1 | Fraction::Fraction(Fraction const &src) { |
运行程序时,你会注意到它多次调用了拷贝构造函数。显然,以下语句会造成对拷贝构造函数的一次调用:
Fraction f2(f1);
但是,它的下一个语句会造成对拷贝构造函数的3次调用:一次是在对象f2作为参数传递的时候,一次是在新对象作为一个返回值传回的时候,最后一次是在将那个对象拷贝到f3的时候:
fraction f3 = f1.add(f2);
显然,这个程序在执行时,会涉及到大量拷贝动作。有的拷贝动作实际是可以避免的,你可以让add函数直接获取一个引用参数(这类似于拷贝构造函数本身的写法)。
练习
重写Fraction的拷贝构造函数,把它变成一个内联函数。去掉打印说明消息的语句
不要在拷贝构造函数单独设置num和den,而是直接调用set函数。这样做的效率是更好还是更差?
//修改过后的效率损失并不打,因为它变成了一个内联函数
//但是,它同时调用了normalize函数,而这或许是不必要的
//因为从一个现有的分数对象拷贝时,可以假定它的值已经正规化
###小结
构造函数是一个类的初始化函数。它具有以下形式:
class_name(argument_list)
如果构造函数没有内联,就必须在程序的某个地方单独给出它的定义:
class_name::class_name(argument_list) {
statements
}
可以提供任意数量的、各不相同的构造函数,所有构造函数具有相同的函数名(也就是类名)。为了进行区分,每个构造函数必须具有不重复的参数数量或类型。
默认构造函数是没有任何参数的构造函数。它要像这样声明
class_name()
如果声明类的一个对象,但不提供任何参数列表,就会调用默认构造函数,例如:
Point a;
如果不提供任何构造函数,编译器会自动提供一个默认构造函数。这个自动提供的构造函数会将所有数据成员设为0值(指针设为null指针)。然而,只要你写了一个构造函数,编译器就不会自动提供默认构造函数。
所以,为了正确地编程,最好养成总是写一个默认构造函数的习惯。如果愿意,它可以不包含任何语句。例如:
Point a(){};
在c++中,引用是使用&来声明的一个变量或参数。运行时,引用变量或参数实际传递的是指针,只是这时不涉及指针语法。换言之,程序表面上传递的是值,实际传递的是指针。
需要拷贝一个对象时,就会调用类的拷贝构造函数。例如,当你将一个对象(而不是指向那个对象的指针)传给一个函数,或者函数的返回值是一个对象时,就会调用拷贝构造函数。
拷贝构造函数必须使用引用参数和一个const关键字。该关键字的作用是防止对参数所引用的对象(原始对象)进行修改。拷贝构造函数具有以下语法形式:
class_name(class_name const &source)
只要发现没有写一个拷贝构造函数,编译器就会自动提供一个。自动提供的拷贝构造函数会逐个拷贝每一个成员。
c++学习记录二:构造函数