6.2.2 使用类创建对象
完成某个类的声明并且定义其成员函数之后,这个类就可以使用了。一个定义完成的类就相当于一种新的数据类型,我们可以用它来定义变量,也就是创建这个类所描述的对象,表示现实世界中的各种实体。比如前面完成了Teacher类的声明和定义,就可以用它来创建一个Teacher类的对象,用它来表示某一位具体的老师。创建类的对象的方式跟定义变量的方式相似,只需要将定义完成的类当作某种数据类型,用之前我们定义变量的方式来定义对象,而定义得到的变量就是这个类的对象。其语法格式如下:
类名 对象名;
其中,类名就是定义好的类的名字,对象名就是要定义的对象的名字,例如:
// 定义一个Teacher类型的对象MrChen,代表陈老师 Teacher MrChen;
这样就得到了一个Teacher类的对象MrChen,它代表学校中具体的某位陈老师。得到类的对象后,就可以通过“.”操作符访问这个类提供的公有成员,包括读写其公有成员变量和调用其公有成员函数,从而访问其属性或者是完成其动作。其语法格式如下:
对象名.公有成员变量;对象名.公有成员函数;
例如,要让刚才定义的对象MrChen进行“上课”的动作,就可以通过“.”调用它的表示上课行为的成员函数:
// 调用对象所属类的成员函数,表示这位老师开始上课 MrChen.GiveLesson();
这样,该对象就会执行Teacher类中定义的GiveLesson()成员函数,完成上课的具体动作。
除了直接使用对象之外,跟普通的数据类型可以使用相应类型的指针来访问它所指向的数据一样,对于自己定义的类,我们同样可以把它当作数据类型来定义指针,把它指向某个具体的对象,进而通过指针来访问该对象的成员。例如:
// 定义一个可以指向Teacher类型对象的指针pMrChen,初始化为空指针 Teacher* pMrChen = nullptr; // 用“&”操作符取得MrChen对象的地址赋值给指针pMrChen, // 也就是将pMrChen指针指向MrChen对象 pMrChen = &MrChen;
这里,我们首先把Teacher类当作数据类型,使用像普通数据类型定义指针一样的形式,定义了一个可以指向Teacher类型对象的指针pMrChen,然后通过“&”操作符取得MrChen对象的地址并赋值给该指针,这样就将该指针指向了MrChen对象。
除了可以使用“&”操作符取得已有对象的地址,并用这个地址对指针进行赋值来将指针指向某个对象之外,还可以使用“new”关键字直接创建一个对象并返回该对象的地址,再用这个地址对指针进行赋值,同样可以创建新的对象并将指针指向这个新的对象。例如:
// 创建一个新的Teacher对象 // 并让pMrChen指针指向这个新对象 Teacher* pMrChen = new Teacher();
这里,“new”关键字会负责完成Teacher对象的创建,并返回这个对象的地址,然后再将这个返回的对象地址赋值给pMrChen指针,这样就同时完成了对象的创建和指针的赋值。
有了指向对象的指针,就可以利用“->”操作符(这个操作符是不是很像一根针?)通过指针访问该对象的成员。例如:
// 通过指针访问对象的成员 pMrChen->GiveLesson();
这里需要特别注意的是,跟普通的变量不同,使用“new”关键字创建的对象无法在其生命周期结束后自动销毁,所以我们必须在对象使用完毕后,用“delete”关键字人为地销毁这个对象,释放其占用的内存。例如:
// 销毁指针所指向的对象 delete pMrChen; pMrChen = nullptr; // 指向的对象销毁后,重新成为空指针
“delete”关键字首先会对pMrChen所指向的Teacher对象进行一些Teacher类所特有的清理工作,然后释放掉这个对象所占用的内存,整个对象也就销毁了。当对象被销毁后,原来指向这个对象的指针就成了一个指向无效地址的“野指针”,为了防止这个“野指针”被错误地再次使用,在用delete关键字销毁对象后,紧接着我们通常将这个指针赋值为nullptr,使其成为一个空指针,避免它的再次使用。
最佳实践:无须在“new”之后或者“delete”之前测试指针是否为nullptr
很多有经验的C++程序员都会强调,为了增加代码的健壮性,我们在使用指针之前,应该先判断指针是否为nullptr,确定其有效之后才能使用。应当说,在使用指针访问类的成员时,这样的检查是必要的。而如果是在“new”创建对象之后和“delete”销毁对象之前进行检查,则完全是画蛇添足。
一方面,使用“new”创建新对象时,如果系统无法为创建新的对象分配足够的内存而导致创建对象失败,则会抛出一个std::bad_alloc异常,“new”操作永远不会返回nullptr。另一方面,C++语言也保证,如果指针p是一个nullptr,则“delete p”不作任何事情,自然也不会有错误发生。所以,在使用“new”创建对象之后和“delete”关键字销毁对象之前,都无需对指针的有效性进行检查,直接使用就好。
// 创建对象 Teacher* p = new Teacher(); // 直接使用指针p访问对象… // 销毁对象 delete p; // 销毁对象之后,才需要将指针赋值为nullptr,避免“野指针”的出现 p = nullptr;
6.2.3 一个对象的生与死:构造函数和析构函数
在现实世界中,每个事物都有其生命周期,会在某个时候出现也会在另外一个时候消亡。程序是对现实世界的反映,其中的对象就代表了现实世界的各种事物,自然也就同样有生命周期,也会被创建和销毁。一个对象的创建和销毁,往往是其一生中非常重要的时刻,需要处理很多复杂的事情。例如,在创建对象的时候,需要进行很多初始化工作,设置某些属性的初始值;而在销毁对象的时候,需要进行一些清理工作,最重要的是把申请的资源释放掉,把打开的文件关闭掉,就像一个人离开人世时,应该把该还的钱还了,干干净净地走。为了完成对象的生与死这两件大事,C++中的类专门提供了两个特殊的函数——构造函数(Constructor)和析构函数(Destructor),它们的特殊之处就在于,它们会在对象创建和销毁的时候被自动调用,分别用来处理对象的创建和销毁的复杂工作。
由于构造函数会在对象创建的时候被自动调用,所以我们可以用它来完成很多不便在对象创建完成后进行的事情,比如可以在构造函数中对对象的某些属性进行初始化,使得对象一旦被创建就有比较合理的初始值。这就像人的性别是在娘胎里确定的,一旦出生就有了明确的性别。C++规定每个类都必须有构造函数,如果一个类没有显式地声明构造函数,那么编译器也会为它产生一个默认的构造函数,只是这个默认构造函数没有参数,也不做任何额外的事情而已。而如果我们想在构造函数中完成一些特殊的任务,就需要自己为类添加构造函数了。可以通过如下的方式为类添加构造函数:
class 类名 { public: 类名(参数列表) { // 对类进行构造,完成初始化工作 } };
因为构造函数具有特殊性,所以它的声明也比较特殊。
首先,在大多数情况下构造函数的访问级别应该是公有(public)的,因为构造函数需要被外界调用以创建对象。只有在少数的特殊用途下,才会使用其他访问级别。例如,在后文6.4.4小节介绍的单件模式中,我们就将构造函数设置为私有(private)的,从而防止外界直接通过构造函数创建对象。
其次是返回值类型,构造函数只是完成对象的创建,并不需要返回数据,自然也就无所谓返回值类型了。
再其次是函数名,构造函数必须跟类同名,也就是用类的名字作为构造函数的名字。
最后是参数列表,跟普通函数一样,在构造函数中我们也可以拥有参数列表,利用这些参数传递进来的数据来完成对象的初始化工作,从而可以用不同的参数创建得到有差别的对象。根据参数列表的不同,一个类可以拥有多个构造函数,以适应不同的构造方式。
上文中的Teacher类就没有显式(所谓显式,是相对于隐式而言的,它通常指的是我们用代码明确地表达我们的意图而产生的事物。比如,用户自己定义的构造函数。而隐式则是用户并没有用代码明确定义,由编译器自动为其生成的事物。比如,一个默认的构造函数)地声明构造函数,而是使用了编译器为它生成的默认构造函数,所以其创建的对象都是千篇一律一模一样的,所有新创建对象的m_strName成员变量都是那个在类声明中给出的固定初始值。换句话说,也就是所有“老师”都是同一个“名字”,这显然是不合理的。下面改写这个Teacher类,为它添加一个带有string类型参数的构造函数,使其可以在创建对象的时候通过构造函数来完成对成员变量的合理初始化,创建有差别的对象:
class Teacher { public: // 构造函数 // 参数表示Teacher类对象的名字 Teacher(string strName) // 带参数的构造函数 { // 使用参数对成员变量赋值,进行初始化 m_strName = strName; }; void GiveLesson(); // 备课 protected: string m_strName = "ChenLiangqiao"; // 类声明中的初始值 // 姓名 private: };
现在就可以在定义对象的时候,将参数写在对象名之后的括号中,这种定义对象的形式会调用带参数的构造函数Teacher(string strName),进而给定这个对象的名字属性。
// 使用参数,创建一个名为“WangGang”的对象 Teacher MrWang("WangGang");
在上面的代码中,我们使用字符串“WangGang”作为构造函数的参数,它就会调用Teacher类中需要string类型为参数的Teacher(string strName)构造函数来完成对象的创建。在构造函数中,这个参数值被赋值给了类的m_strName成员变量,以代替其在类声明中给出的固定初始值“ChenLiangqiao”。当对象创建完成后,参数值“WangGang”就会成为MrWang对象的名字属性的值,这样我们就通过参数创建了一个有着特定“名字”的Teacher对象,各位“老师”终于可以有自己的名字了。
在构造函数中,除了可以使用“=”操作符对对象的成员变量进行赋值以完成初始化之外,还可以使用“:”符号在构造函数后引出初始化属性列表,直接利用构造函数的参数或者其他的合理初始值对成员变量进行初始化。其语法格式如下:
class 类名 { public: // 使用初始化属性列表的构造函数 类名(参数列表) : 成员变量1(初始值1),成员变量2(初始值2)… // 初始化属性列表 { } // 类的其他声明和定义 };
在进入构造函数执行之前,系统将完成成员变量的创建并使用其后括号内的初始值对其进行赋值。这些初始值可以是构造函数的参数,也可以是成员变量的某个合理初始值。如果一个类有多个成员变量需要通过这种方式进行初始化,那么多个变量之间可以使用逗号分隔。例如,可以利用初始化属性列表将Teacher类的构造函数改写为:
class Teacher { public: // 使用初始化属性列表的构造函数 Teacher(string strName) // 初始化属性列表,使用构造函数的参数strName创建并初始化m_strName : m_strName(strName) { // 构造函数中无需再对m_strName赋值 } protected: string m_strName; };
使用初始化属性列表改写后的构造函数,利用参数strName直接创建Teacher类的成员变量m_strName并对其进行初始化,这样就省去了使用“=”对m_strName进行赋值时的额外工作,可以在一定程度上提高对象构造的效率。另外,某些成员变量必须在创建的同时就给予初始值,比如某些使用const关键字修饰的成员变量,这种情况下使用初始化属性列表来完成成员变量的初始化就成了一种必须了。所以,在可以的情况下,最好是使用构造函数的初始化属性列表中完成类的成员变量的初始化。
这里需要注意的是,如果类已经有了显式定义的构造函数,那么编译器就不会再为其生成默认构造函数。例如,在Teacher类拥有显式声明的构造函数之后,如果还是想采用如下的形式定义对象,就会产生一个编译错误。
// 试图调用默认构造函数创建一个没有名字的老师 Teacher MrUnknown;
这时编译器就会提示错误,因为这个类已经没有默认的构造函数了,而唯一的构造函数需要给出一个参数,这个创建对象的形式会因为找不到合适的构造函数而导致编译错误。因此在实现类的时候,一般都会显式地写出默认的构造函数,同时根据需要添加带参数的构造函数来完成一些特殊的构造任务。
在C++中,根据初始条件的不同,我们往往需要用多种方式创建一个对象,所以一个类常常有多个不同参数形式的构造函数,分别负责以不同的方式创建对象。而在这些构造函数中,往往有一些大家都需要完成的工作,一个构造函数完成的工作很可能是另一个构造函数所需要完成工作的一部分。比如,Teacher类有两个构造函数,一个是不带参数的默认构造函数,它会给Teacher类的m_nAge成员变量一个默认值28,而另一个是带参数的,它首先需要判断参数是否在一个合理的范围内,然后将合理的参数赋值给m_nAge。这两个构造函数都需要完成的工作就是给m_nAge赋值,而第一个构造函数的工作也可以通过给定参数28,通过第二个构造函数来完成,这样,第二个构造函数的工作就成了第一个构造函数所要完成工作的一部分。为了避免重复代码的出现,我们只需要在某个特定构造函数中实现这些共同功能,而在需要这些共同功能的构造函数中,直接调用这个特定构造函数就可以了。这种方式被称为委托调用构造函数(delegating constructors)。例如:
class Teacher { public: // 带参数的构造函数 Teacher(int x) { // 判断参数是否合理,决定赋值与否 if (0 < x && x <= 100) m_nAge = x; else cout<<"错误的年龄参数"<<endl; } // 构造函数Teacher()委托调用构造函数Teacher(int x) // 这里我们错误地把出生年份当作年龄参数委托调用构造函数Teacher(int x), // 直接实现了参数合法性验证并赋值的功能 Teacher() : Teacher(1982) { // 完成特有的创建工作 } // ... private: int m_nAge; // 年龄 };
在这里,我们在构造函数之后加上冒号“:”,然后跟上另外一个构造函数的调用形式,实现了一个构造函数委托调用另外一个构造函数。在一个构造函数中调用另外一个构造函数,把部分工作交给另外一个构造函数去完成,这就是委托的意味。不同的构造函数各自负责处理自己的特定情况,而把最基本的共用的构造工作委托给某个基础构造函数去完成,实现分工协作。
当一个使用定义变量的形式创建的对象使用完毕离开其作用域之后,这个对象会被自动销毁。而对于使用new关键字创建的对象,则需要在使用完毕后,通过delete关键字主动销毁对象。但无论是哪种方式,对象在使用完毕后都需要销毁,也就是完成一些必要的清理工作,比如释放申请的内存、关闭打开的文件等。
跟对象的创建比较复杂,需要专门的构造函数来完成一样,对象的销毁也比较复杂,同样需要专门的析构函数来完成。同为类当中负责对象创建与销毁的特殊函数,两者有很多相似之处。首先是它们都会被自动调用,只不过一个是在创建对象时,而另一个是在销毁对象时。其次,两者的函数名都是由类名构成,只不过析构函数名在类名前加了个“~”符号以跟构造函数名相区别。再其次,两者都没有返回值,两者都是公有的(public)访问级别。最后,如果没有必要,两者在类中都是可以省略的。如果类当中没有显式地声明构造函数和析构函数,编译器也会自动为其产生默认的函数。而两者唯一的不同之处在于,构造函数可以有多种形式的参数,而析构函数却不接受任何参数。下面来为Teacher类加上析构函数完成一些清理工作,以替代默认的析构函数:
class Teacher { public: // 公有的访问级别 // … // 析构函数 // 在类名前加上“~”构成析构函数名 ~Teacher() // 不接受任何参数 { // 进行清理工作 cout<<"春蚕到死丝方尽,蜡炬成灰泪始干"<<endl; }; // … };
因为Teacher类不需要额外的清理工作,所以在这里我们没有定义任何操作,只是输出一段信息表示Teacher类对象的结束。一般来说,会将那些需要在对象被销毁之前自动完成的事情放在析构函数中来处理。例如,对象创建时申请的内存资源,在对象销毁后就不能再继续占用了,需要在析构函数中进行合理地释放,归还给操作系统。就像一个有信誉的人在离开人世之前,要把欠别人的钱还清一样,干干净净地离开。