后台开发:核心技术与应用实践2.1 类与对象-阿里云开发者社区

开发者社区> 华章出版社> 正文
登录阅读全文

后台开发:核心技术与应用实践2.1 类与对象

简介:

第2章


面向对象的C++

学习C++,一定要学会面向对象编程。首先讲下“面向对象”产生的历史原因,主要有以下两点。

(1)计算机只会按照人所写的代码,一步一步地执行下去,最终得到结果。无论程序多么复杂,计算机总是能轻松应付。结构化编程,就是按照计算机的思维写出的代码,但是人看到这么复杂的逻辑,无法进行维护和扩展。

(2)结构化设计是以功能为目标来构造应用系统,这种做法导致程序员设计程序时,不得不将客体所构成的现实世界映射到由功能模块组成的解空间中,这种转换过程,背离了人们观察和解决问题的基本思路。

可见,结构化设计在构造系统的时候,无法解决重用、维护、扩展的问题,而且会导致逻辑过于复杂,代码晦涩难懂。于是人们就想,能不能让计算机直接模拟现实的环境,用人类解决问题的思路、习惯、步骤来设计相应的应用程序?这样的程序,人们在读它的时候,会更容易理解,也不需要再把现实世界和程序世界之间来回做转换。于是面向对象的编程思想就产生了。

本章主要从面向对象的封装、继承和多态三大特征来带读者进入面向对象的C++世界。


2.1 类与对象


1.?类与对象的概念

面向对象编程的主要思想是把构成问题的各个事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描述一个事物在解决问题中经过的步骤和行为。对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性。类,是创建对象的模板,一个类可以创建多个相同的对象;对象,是类的实例,是按照类的规则创建的。类与对象的关系如图2-1所示。

图2-1中,人和学生属于类,张三和学生李四都是对象,姓名、身高、地址、年龄、性别、血型都是人这一类的属性,跑步、吃饭,这都是人这个类的方法。属性是一个变量,用来表示一个对象的特征;方法是一个函数,用来表示对象的操作。对象的属性和方法统称为对象的成员。

每一个实体都是对象,有一些对象是具有相同的结构和特性的。每个对象都属于一个特定的类型,这个特定的类型称为类。正如结构体类型和结构体变量一样,需要先声明一个结构体类型,再用它去定义结构体变量。在C++中也是先声明一个类的类型,然后用它去定义若干个同类型的对象。可以说,对象是类类型的一个变量,类则是对象的模板。类是抽象的,不占用存储空间的;而对象是具体的,占用存储空间。

类类型的声明形式如下:

class类名{                   // class,声明一个类必须有的关键字

    private:

        私有的数据和成员函数;

    public:

        公用的数据和成员函数;

};                          // 类的声明以分号结束

其中,private和public称为成员访问限定符。

下面再来看下结构体和类的不同之处。例2.1展示了在C++中声明一个结构体类型的方法;例2.2则是声明一个类的方法。

【例2.1】 声明一个结构体类型。

struct SStudent{

    int num;

    char name[20];

    int age;

};

SStudent st_stu1,st_stu2;           // 定义了两个结构体变量

【例2.2】 声明一个类。

class CStudent{

    int num;

    char name[20];

    int age;            // 这些是数据成员,也称为成员变量

    void display(){        // 这是成员函数

        cout<<"num:"<<num<<endl;

        cout<<"name:"<<name<<endl;

        cout<<"age:"<<age<<endl;

    }

};

CStudent cstu1,cstu2;          // 定义了两个对象

例2.1中声明了一个SStudent结构体,结构体内有三个数据成员:num、name[20]和age;还定义了两个SStudent结构体类型的变量st_stu1和st_stu1。例2.2中声明了一个CStudent类,类中有三个数据成员和一个成员函数display,还定义了两个对象cstu1和cstu2。

从上面结构体的声明方法和类的声明方法来看,貌似只有关键字不一样,结构体的关键字是struct,类的关键字是class。事实上,声明类的方法是由声明结构体类型的方法发展而来的。但是,struct中的成员访问权限默认是public,而class中则默认是private的。在C语言里,struct中不能定义成员函数,而在C++中,增加了class类型后,扩展了struct的功能,struct中也能定义成员函数了。

【例2.3】 struct中定义成员函数。

struct SStudent{

public:

    void display(){        // 这是成员函数

        cout<<"num:"<<num<<endl;

        cout<<"name:"<<name<<endl;

        cout<<"age:"<<age<<endl;

    }                     // 这里没有分号

private:

    int num;

    char name[20];

    int age;

};

例2.3中在结构体SStudent中定义了一个display的public的成员函数,3个private的成员变量。请注意,一个成员函数如果在类中定义,在定义结束的}之后是不需要加分号的。

在一个类中,关键字private和public可以分别出现多次,从每个部分的有效范围到出现另一个访问限定符或者类体结束为止。为了使程序清晰,建议大家养成良好的习惯,使每一种成员访问限定符在类体中只出现一次,并且先写public部分,把private部分放在类体的后部,这样可以使得用户将注意力集中在能被外界调用的成员上,使得阅读者的思路更加清晰。

一个对象的声明方式有以下几种。

(1)class 类名 对象名。

(2)类名 对象名。

这两种方法是等效的,但显然第二种方法更为简洁与方便。

2.?成员函数

类的成员函数是函数的一种,与第1章介绍过的函数一样,具有返回值和函数类型,它与一般函数的区别在于,它是属于类的成员,出现在类体中。它可以被指定为private(私有的)、protected(受保护的)和public(公用的)。

在使用成员函数时,要注意它的权限以及作用域。比如,私有的成员函数只能被本类中其他成员函数使用,而不能在类外被调用。成员函数中可以使用类中的任何成员,包括私有的和公用的。

成员函数可以在类体中定义,也可以在类外定义。

【例2.4】 成员函数在类外被定义。

class CStudent {

public:

    void display();         // 这里需要分号

private:

    int num;

    char name[20];

    int age;

};

void CStudent::display(){

    cout<<"num:"<<num<<endl;

    cout<<"name:"<<name<<endl;

    cout<<"age:"<<age<<endl;

}

例2.4中在类CStudent外定义了display成员函数。注意,在类外定义成员函数时,必须加上类名,予以限定。“::”是作用域限定符或作用域运算符,用它声明函数是属于哪个类的。如果没有写类名或者没有写类名和作用域限定符,则这个函数不属于任何类,而是一个普通函数。成员函数必须先在类中声明,然后再在类外定义,即类体的位置应在函数定义之前,否则编译时会出错。

3.?类的封装性

C++中通过类实现封装性,把数据和这些数据有关的操作封装在一个类里。但是,人们在使用时,往往不关心类中的具体实现,而只需知道调用哪个函数会得到什么结果,能实现什么功能即可。

为了实现类对象的封装性(数据隐藏和提供访问接口),类类型定义为类成员提供了私有、公有和受保护的3种基本访问权限供用户选择,具体内容如下所述。

(1)私有成员

1)访问权限:只限于类成员访问。

2)关键字:private。

3)私有段:从private关键字开始至其他访问权限声明之间所有成员组成的代码段。

4)成员种类:数据成员和成员函数。

(2)公有成员

1)访问权限:允许类成员和类外的任何访问。

2)关键字:public。

3)私有段:从public关键词开始至其他访问权限声明之间所有成员组成的代码段。

4)成员种类:数据成员和成员函数。

(3)受保护成员

1)访问权限:允许类成员和派生类成员访问,不运行类外的任何访问。

2)关键字:protect。

3)私有段:从protect关键词开始至其他访问权限声明之间所有成员组成的代码段。

4)成员种类:数据成员和成员函数。

除了限制访问权限,在写代码时经常要注意“将接口与实现分离”,这也是隐蔽信息的一个重要手段。接口与实现分离,有以下两个好处:①如果想修改或者扩充类的功能,只需要修改类中的实现,类外部分可以不用修改;②如果发现类中数据成员数据有错,则只需要在类内检查访问这些数据成员的成员函数。

一般是将类的声明放在指定的头文件中,用户如果想使用这个类,直接包含这个头文件即可。因为在头文件中有类的声明,所以可以直接在程序中用这个类来定义对象。为了实现信息隐蔽,会把类成员函数的定义放在另一个文件中,而不放在头文件中。

例如,可以将类Student的声明放在student.h中。

【例2.5】 类的定义与使用。

student.h中的代码为:

class CStudent{

public:

    void display();

private:

    int num;

    char name[20];

    int age;

};

student.cpp中的代码为:

#include<iostream>

#include "student.h"       // 这里需要include这个头文件,否则无法找到Student类

using namespace std;

void CStudent::display(){           // 这里要注明是Student类的

    cout<<"num:"<<num<<endl;

    cout<<"name:"<<name<<endl;

    cout<<"age:"<<age<<endl;

}

main.cpp中的代码为:

#include<iostream>

#include "student.h"       // 注意这里是双引号

int main(){

    CStudent stu1;         // 定义stu1对象

    stu1.display();         // 指向stu1对象的成员函数

    return 0;

}

执行以下这3行命令即可编译成功:

g++ -c student.cpp

g++ -c main.cpp

g++ -o main main.o student.o

编译后,会生成main文件,执行./main命令,获得程序的执行结果为:

num:0

name:

age:0

这种结果是由于还没有对数据成员进行初始化导致的,下面的一节会介绍如何初始化一个对象。

例2.5中定义了一个类CStudent,所有的数据成员和成员函数都声明在头文件student.h中,而成员函数的定义则是在.cpp文件student.cpp中。main.cpp中要使用CStudent类时需要把头文件student.h包含进来。编译时,编译器g++会把main.cpp和student.cpp分别编译成main.o和student.o,再把main.o和student.o链接成可执行文件main,图2-2展示了这一过程。

编译与链接的相关知识会在第4章详细展开,这里只作简单介绍。

4.?构造函数

数据成员是不能在类中初始化的,而构造函数,正是为此而生,主要用来处理数据成员的初始化。它不需要用户调用,而是在建立对象时自动执行的。

构造函数的名字必须与类名相同,而不能由用户任意命名,以便编译系统能识别它并把它作为构造函数处理。它是一个没有返回值的函数,构造函数在类中定义如例2.6所示。

【例2.6】 构造函数的定义。

class Time{

public:

    Time(){            // 这就是构造函数

        hour=0;

        minute=0;

        second=0;

    }

    set_time();

    get_time();

private:

    int hour,minute,second;

};

构造函数也可以在类外定义,如例2.7所示。

【例2.7】 在类外定义构造函数。

class Time{

public:

    Time();            // 对构造函数进行声明

    set_time();

    get_time();

private:

    int hour,minute,second;

};

Time::Time(){               // 定义构造函数,需要加上类名和域限定符"::"

    hour=0;

    minute=0;

    second=0;

}

在构造函数的函数体中,不仅可以对数据成员赋值,也可以包含其他语句。不过不提倡在构造函数中加入与初始化无关的内容,以保证程序清晰。如果用户自己没有定义构造函数,那么C++系统就会自动为其生成一个构造函数,只是这个构造函数的函数体是空的,什么也不做,当然也不进行初始化。

构造函数分为不带参数的构造函数与带参数的构造函数。不带参数的构造函数使该类的每一个对象都得到相同的初始值;带参数的构造函数则可以方便地实现对不同的对象进行不同的初始化。

【例2.8】 带参数的构造函数的使用举例。

#include<iostream>

using namespace std;

#define pi 3.1415

class Circle{

public:

    Circle(int r);       // 形参列表

    double Area();

private:

    int radius;               // 数据成员

};

Circle::Circle(int r){

    radius=r;

}

double Circle::Area(){

    return pi*radius*radius;

}

int main(){

    Circle cir1(10);

    cout<<"cir1's area: "<<cir1.Area()<<endl;

    Circle cir2(1);

    cout<<"cir2's area: "<<cir2.Area()<<endl;

    return 0;

}

程序的执行结果是:

cir1's area: 314.15

cir2's area: 3.1415

例2.8中的构造函数带了一个整型的参数,并在构造函数中将r参数赋值给了成员变量radius。定义对象时可利用构造函数直接对成员变量赋值。

另外,C++还提供另一种初始化数据成员的方法:参数初始化表。这种方法不在函数体内对数据成员初始化,而是在函数首部实现。例2.8中的构造函数可以改写为下列形式:

Circle::Circle(int r):radius(r){} // 后面没有分号

在C++中,一个类可以同时定义多个构造函数,以提供不同的初始化方法。这些构造函数的参数个数不同或参数的类型不同,这就是构造函数的重载。

【例2.9】 构造函数重载的使用。

#include <iostream>

using namespace std;

class Box{

public:

    Box();                         // 声明一个无参的构造函数

    /*声明一个有参的构造函数,并用参数的初始化列表对数据成员初始化*/

    Box(int h,int w,int l):height(h),width(w),length(l){}

    int volume();

private:

    int height,width,length;

};

Box::Box(){                  // 定义无参的构造函数

    height=1;

    width=2;

    length=3;

}

int Box::volume(){

    return height*width*length;

}

int main(){

    Box box1;                    // 不指定实参

    cout<<"box1's volume: "<<box1.volume()<<endl;

    Box box2(2,5,10);               // 指定实参

    cout<<"box2's volume: "<<box2.volume()<<endl;

    return 0;

}

程序的执行结果是:

box1's Volume: 6

box2's Volume: 100

例2.9中定义了两个构造函数,一个是无参的,一个是有参的,两个函数的函数名一样,但参数格式不一样,所以是函数重载。若定义对象时不指定实参,则调用无参的构造函数。

调用构造函数时不必给出实参的构造函数,称为默认构造函数。无参的构造函数属于默认构造函数。一个类只能有一个默认构造函数。即使一个类中有多个构造函数,但建立对象时,都只执行其中一个构造函数。

构造函数中的参数的值,可以通过实参传递,也可以指定为某些默认值。

【例2.10】 构造函数默认参数的使用。

#include <iostream>

using namespace std;

class Box{

public:

    Box(int h=2,int w=2,int l=2); // 在声明构造函数时指定默认参数

    int volume();

private:

    int height,width,length;

};

Box::Box(int h,int w,int len){       // 在定义函数时可以不指定默认参数

    height=h;

    width=w;

    length=len;

}

int Box::volume(){

    return height*width*length;

}

int main(){

    Box box1(1);                      // 不指定第2、3个实参

    cout<<"box1's volume: "<<box1.volume()<<endl;

    Box box2(1,3);             // 不指定第3个实参

    cout<<"box2's volume: "<<box2.volume()<<endl;

    return 0;

}

程序的执行结果是:

box1's volume:4

box2's volume:6

例2.10中定义了一个带有默认参数的构造函数,是在声明时指定默认参数,而定义时则可以不指定默认参数。定义对象时,可以传0~3个参数,传了几个参数,就替换前面的几个参数,其余都还是使用默认参数。

使用默认参数的好处在于:调用构造函数时就算没有提供参数也不会出错,且对每一个对象能有相同的初始化状况。

不过,应该在声明构造函数默认值时指定默认参数值,而不能只在定义构造函数时指定默认参数值。如果构造函数中的参数全指定了默认值,则在定义对象时,可给一个实参或多个实参,也可以不给实参。

一个类中如果定义了全是默认参数的构造函数后,就不能再定义重载构造函数了。假设Box类定义了以下3个构造函数:

Box(int =10,int =10,int =10);

Box();

Box(int,int);

若有以下定义语句,思考注释中的问题:

Box box1;         // 是调用上面的第一个默认参数的构造函数,还是第二个默认构造函数?

Box box2(15,30);     // 是调用上面的第一个默认参数的构造函数,还是第三个构造函数?

5.?析构函数

析构函数的名字是类名的前面加一个“~”符号。在C++中,“~”符号是位取反运算符,类似地,析构函数的作用与构造函数相反。它也不需要用户来调用它,不过,它是在对象声明周期结束时自动执行的。

程序执行析构函数的时机有以下4种。

(1)如果在函数中定义了一个对象,当这个函数调用结束时,对象会被释放,且在对象释放前会自动执行析构函数。

(2)static局部对象在函数调用结束时对象不释放,所以也不执行析构函数,只有在main函数结束或调用exit函数结束程序时,才调用static局部对象的析构函数。

(3)全局对象则是在程序流程离开其作用域(如main函数结束或调用exit函数)时,才会执行该全局对象的析构函数。

(4)用new建立的对象,用delete释放该对象时,会调用该对象的析构函数。

析构函数的作用不是删除对象,而是在撤销对象占用的内存前完成一些清理工作,使得这些内存可以供新对象使用。析构函数的作用也不限于释放资源方面,它还可以被用来执行用户希望在最后一次使用对象之后所执行的任何操作。

【例2.11】 析构函数的使用方法举例。

#include<iostream>

using namespace std;

class Box{

public:

    Box(int h=2,int w=2,int l=2);

    ~Box(){                            // 析构函数

        cout<<"Destructor called."<<endl;    // 析构函数里的内容

    }

    int volume(){

        return height*width*length;

    }

private:

    int height,width,length;

};

Box::Box(int h,int w,int len){

    height=h;

    width=w;

    length=len;

}

int main(){

    Box box1;

    cout<<"The volume of box1 is "<<box1.volume()<<endl;

    cout<<"hello."<<endl;

    return 0;

}

程序的运行结果:

The volume of box1 is 8

hello.

Destructor called.

例2.11中定义了类Box的析构函数,析构函数中输出Destructor called.,也就是析构函数被执行时就会输出这条消息。由程序的执行结果可以看出,析构函数是对象释放内存前执行的。

如果用户没有编写析构函数,编译系统会自动生成一个默认的析构函数,但不进行任何操作,所以许多简单的类中没有用显式的析构函数。

6.?静态数据成员

有时需要为某个类的所有对象分配一个单一的存储空间。在C语言中,可以使用全局变量,但这样很不安全。全局数据可以被任何人修改,而且在一个项目中,它很容易和其他名字冲突。如果可以把数据当成全局变量那样去存储,但又被隐藏在类的内部,而且清楚地与这个类相联系,这种处理方法就是最理想的。这个可以用类的静态数据成员来实现。类的静态成员拥有一块单独的存储区,而不管创建了多少个该类的对象。所有这些对象的静态数据成员都共享这一块静态存储空间,这就为这些对象提供了一种互相通信的方法。静态数据成员是属于类的,它只在类的范围内有效,可以是public、private或protected的范围。

因为不管产生了多少对象,类的静态数据成员都有着单一的存储空间,所以存储空间必须定义在一个单一的地方。如果一个静态数据成员被声明而没有被定义,链接器会报告一个错误:“定义必须出现在类的外部而且只能定义一次”。因此静态数据成员的声明通常会放在一个类的实现文件中。举例如下所示。

xxx.h类型文件中:

class base{

public:

    static int var;           // 声明静态数据成员

};

xxx.cpp类型文件中:

int base::var=10;                // 定义静态数据成员,不必在初始化语句里加上static

在头文件中定义(初始化)静态成员容易引起重复定义的错误,比如这个头文件同时被多个.cpp文件所包含的时候。即使加上#ifndef #def?ine #endif或者#pragma once也不行。

C++静态数据成员被类的所有对象所共享,包括该类的派生类的对象。派生类对象与基类对象共享基类的静态数据成员。静态的数据成员在内存中只占一份空间。如果改变它的值,则在各个对象中这个数据成员的值同时都改变了,这样可以节约空间,提高效率。下面程序展示了修改基类的静态数据成员,同时影响派生类对象和基类对象的情况。

【例2.12】 静态的数据成员基类和派生类对象共享。

#include<iostream>

using namespace std;

class Base{

public:

    static int var;

};

int Base::var=10;

class Derived:public Base{

};

int main(){

    Base base1;

    base1.var++;               // 通过对象名引用

    cout<<base1.var<<endl;     // 输出11

    Base base2;

    base2.var++;

    cout<<base2.var<<endl;     // 输出12

    Derived derived1;

    derived1.var++;

    cout<<derived1.var<<endl;  // 输出13

    Base::var++;                // 通过类名引用

    cout<<derived1.var<<endl;  // 输出14

    return 0;

}

程序的执行结果是:

11

12

13

14

例2.12中在基类Base中定义了一个静态数据成员var,类Derived继承了类Base。无论是通过基类对象,或者是派生类对象,都可以改变静态数据成员var的值。

如果只声明了类而未定义对象,类的一般数据成员是不占内存空间的,只有在定义对象时才会为对象的数据成员分配空间。但是静态数据成员不属于某一个对象,所以在为对象所分配的空间中不包括静态数据成员所占的空间,静态数据成员是在所有对象之外单独开辟一段空间来存放。只要在类中定义了静态数据成员,即使不定义对象,也为静态数据成员分配了空间,它可以被引用。

在一个类中可以有一个或多个静态数据成员,所有对象都共享这些静态数据成员,都可以引用它。

如果在一个函数中定义了静态变量,在函数结束时该静态变量并不被释放,仍然存在并保留其值。静态数据成员也类似,它不随对象的建立而分配空间,也不随对象的撤销而释放。静态数据成员是程序在编译时被分配空间,到程序结束时释放空间。

静态数据成员可以通过对象名引用,也可以通过类名来引用。

7.?静态成员函数

与数据成员类似,成员函数也可以定义为静态的,在类中声明函数的前面加static关键字就成了静态成员函数,如:

static int volume();

和静态数据成员一样,静态成员函数也是类的一部分,而不是对象的一部分。如果要在类外调用公用的静态成员函数,要用类名和域运算符“::”,如:

Box::volume( );

实际上也允许通过对象名调用静态成员函数,如:

a.volume( );

但这并不意味着此函数是属于对象a的,而只是用a的类型而已。

与静态数据成员不同,静态成员函数的作用不是为了对象之间的沟通,而是为了能处理静态数据成员。

当调用一个对象的成员函数(非静态成员函数)时,系统会把该对象的起始地址赋给成员函数的this指针。而静态成员函数并不属于某一对象,它与任何对象都无关,因此静态成员函数没有this指针。既然它没有指向某一对象,也就无法对一个对象中的非静态成员进行默认访问(即在引用数据成员时不指定对象名)。

可以说,静态成员函数与非静态成员函数的根本区别是:非静态成员函数有this指针,而静态成员函数没有this指针。由此决定了静态成员函数不能访问本类中的非静态成员。

静态成员函数可以直接引用本类中的静态数据成员,因为静态成员同样是属于类的,可以直接引用。在C++程序中,静态成员函数主要用来访问静态数据成员,而不访问非静态成员。

假如在一个静态成员函数中有以下语句:

cout<<height<<endl;     // 若height已声明为static,则引用本类中的静态成员,合法

cout<<width<<endl;      // 若width是非静态数据成员,不合法

但是,并不是绝对不能引用本类中的非静态成员,只是不能进行默认访问,因为无法知道应该去找哪个对象。如果一定要引用本类的非静态成员,应该加对象名和成员运算符“.”,如:

cout<<a.width<<endl;   // 引用本类对象a中的非静态成员

假设a已定义为Box类对象,且在当前作用域内有效,则此语句合法。不过,最好养成这样的习惯:只用静态成员函数引用静态数据成员,而不引用非静态数据成员。这样思路更清晰、逻辑更清楚,不易出错。

【例2.13】 静态成员函数的使用方法举例。

#include<iostream>

using namespace std;

class CStudent{

public:

    CStudent (int n,int s):num(n),score(s){}   // 定义构造函数

    void total();

    static double average();

private:

    int num;

    int score;

    static int count;

    static int sum;                    // 这两个数据成员是所有对象共享的

};

int CStudent::count=0;                    // 定义静态数据成员

int CStudent::sum=0;

void CStudent::total(){                    // 定义非静态成员函数

    sum+=score;                           // 非静态数据成员函数中可使用静态数据成

                                       // 员、非静态数据成员

    count++;

}

 

double CStudent::average(){            // 定义静态成员函数

    return sum*1.0/count;                     // 可以直接引用静态数据成员,不用加类名

}

int main(){

    CStudent stu1(1,100);

    stu1.total();                       // 调用对象的非静态成员函数

    CStudent stu2(2,98);

    stu2.total();

    CStudent stu3(3,99);

    stu3.total();

    cout<< CStudent::average()<<endl;       // 调用类的静态成员函数,输出99

}

程序的执行结果是:

99

例2.13中声明了一个CStudent类,类中有静态成员函数average、静态数据成员count和sum。静态数据成员count和sum必须在类的外部定义,在非静态成员函数total中可以使用静态数据成员、非静态数据成员,而在静态成员函数中则可以不加类名直接引用静态数据成员。

8.?对象的存储空间

很多C++书籍中都介绍过一个对象需要占用多大的内存空间,最权威的结论是:非静态成员变量总和加上编译器为了CPU计算做出的数据对齐处理和支持虚函数所产生的负担的总和。下面分别看看数据成员、成员函数、构造函数、析构函数、虚函数的空间占用情况。

先来看看一个空类的存储空间是多少个Byte呢?可以看例2.14的程序。

【例2.14】 空类存储空间的计算。

#include<iostream>

using namespace std;

class CBox{

};

int main(){

    CBox boxobj;

    cout<<sizeof(boxobj)<<endl;// 输出1

    return 0;

}

程序的执行结果是:

1

例2.14中定义了一个空类CBox,里面既没有数据成员,也没有成员函数。程序执行结果显示它的大小为1。

空类型对象中不包含任何信息,应该大小为0。但是当声明该类型的对象的时候,它必须在内存中占有一定的空间,否则无法使用这些对象。至于占用多少内存,由编译器决定。C++中每个空类型的实例占1Byte空间。

【例2.15】 只有成员变量的类的存储空间计算。

#include<iostream>

using namespace std;

class CBox{

    int length,width,height;

};

int main(){

    CBox boxobj;

    cout<<sizeof(boxobj)<<endl;

    return 0;

}

程序的执行结果是:

12

例2.15中,类CBox中只有3个成员变量,由于整型变量占4Byte,所以对象所占的空间就是12Byte。那静态成员变量是否也占存储空间呢?

【例2.16】 有成员变量和静态成员变量的类的存储空间计算。

#include<iostream>

using namespace std;

class CBox{

    int length,width,height;

    static int count;

};

int main(){

    CBox boxobj;

    cout<<sizeof(boxobj)<<endl;

    return 0;

}

程序的执行结果是:

12

例2.16中,类CBox中有3个普通数据成员和1个静态数据成员,比例2.14中多了一个静态数据成员,但是程序的执行结果还是12,也就证明了静态数据成员是不占对象的内存空间的。

【例2.17】 类中只有1个成员函数的存储空间计算。

#include<iostream>

using namespace std;

class CBox{

    int foo();

};

int main(){

    CBox boxobj;

    cout<<sizeof(boxobj)<<endl;

    return 0;

}

程序的执行结果是:

1

例2.17中类CBox中只有一个成员函数,类CBox的对象boxobj的大小却只有1Byte,和空类对象是一样的,所以可以得出,成员函数是不占空间的。

【例2.18】 类中构造函数、析构函数的空间占用情况。

#include<iostream>

using namespace std;

class CBox{

public:

    CBox(){};

    ~CBox(){};

};

int main(){

    CBox boxobj;

    cout<<sizeof(boxobj)<<endl;

    return 0;

}

程序的执行结果是:

1

例2.18中类CBox中只有构造函数和析构函数,类CBox的对象boxobj的大小也只有1Byte,和空类对象是一样的,所以可以得出,构造函数和析构函数也是不占空间的。

【例2.19】 类中有虚的析构函数的空间计算。

#include<iostream>

using namespace std;

class CBox{

public:

    CBox(){};

    virtual ~CBox(){};

};

int main(){

    CBox boxobj;

    cout<<sizeof(boxobj)<<endl;          // 输出4

    return 0;

}

程序的执行结果是:

8

例2.19中,类CBox中有1个构造函数和1个虚的析构函数,程序的执行结果是8。事实上,编译器为了支持虚函数,会产生额外的负担,这正是指向虚函数表的指针的大小。(指针变量在64位的机器上占8Byte。)如果一个类中有一个或者多个虚函数,没有成员变量,那么类相当于含有一个指向虚函数表的指针,占8Byte。

【例2.20】 继承空类和多重继承空类存储空间的计算。

#include<iostream>

using namespace std;

class A{

};

class B{

};

class C:public A{

};

class D:public virtual B{

};

class E:public A,public B{

};

int main(){

    A a;

    B b;

    C c;

    D d;

    E e;

    cout<<"sizeof(a):"<<sizeof(a)<<endl;

    cout<<"sizeof(b):"<<sizeof(b)<<endl;

    cout<<"sizeof(c):"<<sizeof(c)<<endl;

    cout<<"sizeof(d):"<<sizeof(d)<<endl;

    cout<<"sizeof(e):"<<sizeof(e)<<endl;

    return 0;

}

程序的执行结果是:

sizeof(a):1

sizeof(b):1

sizeof(c):1

sizeof(d):8

sizeof(e):1

例2.20中定义了一个空类A和B,类C继承了类A,类D继承了虚基类B,类E继承了类A和类B。这些类的对象所占的空间都是1Byte。由此可见,单一继承的空类空间也是1,多重继承的空类空间还是1,但是虚继承涉及虚表(虚指针),所以sizeof(d)=8。

综上所述,每个对象所占用的存储空间只是该对象的非静态数据成员的总和,其他都不占用存储空间,包括成员函数和静态数据成员。函数代码是存储在对象空间之外的,而且,函数代码段是公用的,即如果对同一个类定义了10个对象,这些对象的成员函数对应的是同一个函数代码段,而不是10个不同的函数代码段。

9.?this指针

每个对象中的数据成员都分别占有存储空间,如果对同一个类定义了n个对象,则有n组同样大小的空间以存放n个对象中的数据成员。不同对象都调用同一个函数代码段。那么,当不同对象的成员函数引用数据成员时,怎么能保证所引用的是所指定的对象的数据成员呢?

假设,对于上述例子中定义的Box类,定义了3个同类对象a、b、c。如果有a.volume( ),应该是引用对象a中的height、width和length,以计算出箱子a的体积;如果有b.volume( ),应该是引用对象b中的height、width和length,计算出箱子b的体积。而现在都用同一个函数段,系统怎样使它分别引用a或b中的数据成员呢?

在每一个成员函数中都包含一个特殊的指针,这个指针的名字是固定的,称为this指针。它是指向本类对象的指针,它的值是当前被调用的成员函数所在的对象的起始地址。例如,当调用成员函数a.volume时,编译系统就把对象a的起始地址赋给this指针,在成员函数引用数据成员时,就按照this的指向找到对象a的数据成员。例如volume函数要计算height*width*length的值,实际上是执行:

(this->height)*(this->width)*(this->length)

由于当前this指向a,因此相当于执行:

(a.height)*(a.width)*( a.length)

这就计算出箱子a的体积。同样如果有b.volume(),编译系统就把对象b的起始地址赋给成员函数volume的this指针,显然计算出来的是箱子b的体积。

this指针是隐式使用的,它是作为参数被传递给成员函数。本来,成员函数volume的定义如下:

int Box::volume(){

    return (height*width*length);

}

C++把它处理为:

int Box::volume(Box *this){

    return (this->height * this->width * this->length);

}

即在成员函数的形参表列中增加一个this指针。在调用该成员函数时,实际上是用以下方式调用的:

a.volume(&a);

将对象a的地址传给形参this指针,然后按this的指向去引用其他成员。

需要说明的是,这些都是编译系统自动实现的,不必人为地在形参中增加this指针,也不必将对象a的地址传给this指针,但在需要时也可以显式地使用this指针。

例如在Box类的volume函数中,下面两种表示方法都是合法的、相互等价的:

return (height * width * length);              // 隐含使用this指针

return (this->height * this->width * this->length); // 显式使用this指针

可以用*this表示被调用的成员函数所在的对象,*this就是this所指向的对象,即当前的对象。例如在成员函数a.volume( )的函数体中,如果出现*this,它就是对象a。上面的return语句也可写成:

return((*this).height * (*this).width * (*this).length);

*this两侧的括号不能省略,不能写成*this.height。因为成员运算符“.”的优先级别高于指针运算符“*”,因此,* this.height就相当于* (this.height),而this.height是不合法的,编译会出错。

所谓“调用对象a的成员函数f”,实际上是在调用成员函数f时使this指针指向对象a,从而访问对象a的成员。在使用“调用对象a的成员函数f”时,应当对它的含义有正确的理解。

this指针有以下特点。

(1)只能在成员函数中使用,在全局函数、静态成员函数中都不能使用this。

(2)this指针是在成员函数的开始前构造,并在成员函数的结束后清除。

(3)this指针会因编译器不同而有不同的存储位置,可能是栈、寄存器或全局变量。

(4)this是类的指针。

(5)因为this指针只有在成员函数中才有定义,所以获得一个对象后,不能通过对象使用this指针,所以也就无法知道一个对象的this指针的位置。不过,可以在成员函数中指定this指针的位置。

(6)普通的类函数(不论是非静态成员函数,还是静态成员函数)都不会创建一个函数表来保存函数指针,只有虚函数才会被放到函数表中。

10.?类模板

有时,两个或多个类的功能是相同的,但仅仅因为数据类型不同,就要分别定义两个类,如下面的例2.21声明了一个类。

【例2.21】 操作整数的类。

class Operation_int{

public:

    Operation_int(int a,int b):x(a),y(b){}

    int add(){

        return x+y;

    }

    int subtract(){

        return x-y;

    }

private:

    int x,y;

};

这个类的作用是两个整数的加减,如果要对两个浮点数做加减,就又得新定义一个类,比如例2.22所示。

【例2.22】 操作浮点数的类。

class Operation_double{

public:

    Operation_double(double a, double b):x(a),y(b){}

    double add(){

        return x+y;

    }

    double subtract(){

        return x-y;

    }

private:

    double x,y;

};

因为参数的类型不同,所以不能复用,这也使得代码量剧增。为了解决这类问题,C++中提供了类模板的功能。可以先声明一个通用的类模板,这个类模板可以有一个或多个虚拟的类型参数,对以上例子中的两个类,可以定义如例2.23这样的类模板。

【例2.23】 操作两个数的类模板。

template<class T>        // 声明一个模板,虚拟类型名为T

class Operation {

public:

    Operation (T a, T b):x(a),y(b){}

    T add(){

        return x+y;

    }

    T subtract(){

        return x-y;

    }

private:

    T x,y;

};

例2.23这个类模板与上面的类相比,有以上两个不同点。

(1)声明类模板时增加了下面这一行代码:

template <class 类型参数名>

其中,template是声明类模板时必须写的关键字,意思是“模板”。关键字class后的类型参数名可以是任意的合法标识符,本例中T就是一个类型参数名。

(2)原有的类型名int换成虚拟类型参数名T。

在建立类对象时,如果将实际类型指定为int型,编译系统就会用int取代T;如果指定为double,编译系统就会用double取代T。

声明一个类模板的对象时,要用实际类型名去取代虚拟的类型,这样才能使它变成一个实际的对象,如:

Operation <int> opobj(1,2);

在类模板名之后的尖括号里指定实际的类型名,这样在编译时,编译系统就用int取代类模板中的类型参数T,这样就把类模板具体化了,或者说实际化了。例2.24中实现了一个类模板,利用它可以分别对两个整数和两个浮点数进行加、减操作。

【例2.24】 用类模板实现对两个数的加、减操作。

#include <iostream>

using namespace std;

template<class T>                               // 声明一个模板,虚拟类型名为T

class Operation {

public:

    Operation (T a, T b):x(a),y(b){}

    T add(){

        return x+y;

    }

    T subtract(){

        return x-y;

    }

private:

    T x,y;

};

int main(){

    Operation <int> op_int(1,2);

    cout<<op_int.add()<<" "<<op_int.subtract()<<endl; // 输出3、-1

    Operation <double> op_double(1.2,2.3);

    cout<<op_double.add()<<" "<<op_double.subtract()<<endl;      // 输出3.5、-1.1

    return 0;

}

程序的执行结果是:

3 -1

3.5 -1.1

例2.24中声明了一个类模板,可以对两个相同类型的数进行加、减操作:如果是传入两个整数,则对两个整数进行加减;如果传入两个浮点数,则对两个浮点数进行加减。

如果类模板的成员函数是在类外定义的,则需要这么写:

template<class T>

T Operation <T> :: add(){

    return x+y;

}

综上所述,可以这样声明和使用类模板。

(1)先写一个实际的类。

(2)将此类中准备改变的类型名改用一个自己指定的虚拟类型名。

(3)在类声明前面加入一行,格式为:template<class虚拟类型参数>。

(4)用类模板定义对象时用以下形式:

类模板名<实际类型名>对象名;

类模板名<实际类型名>对象名(实参表列);

(5)如果在类模板外定义成员函数,应写成类模板形式:

template <class 虚拟类型参数>

函数类型 类模板名<虚拟类型参数>::成员函数名(函数形参表列) {…}

类模板是对一批仅数据成员类型不同的类的抽象,只要为这一批类所组成的整个类家族创造一个类模板,即给出一套程序代码,就可以用来生成多种具体的类,从而大大提高编程的效率。

11.?析构函数与构造函数的执行顺序

前面讲了析构函数执行的时机,下面再来对比下构造函数和析构函数的调用时间和调用顺序。首先看下面包含构造函数和析构函数的C++程序的执行结果,以此来判断二者执行的顺序。

【例2.25】 构造函数和析构函数的执行顺序实例。

#include<iostream>

using namespace std;

class CBox{

public:

    CBox (int h,int w,int l){

    height=h;

    width=w;

    length=l;

    cout<<"Constructor called."<<endl;             // 构造函数被执行时输出信息

    }

    ~CBox (){                               // 析构函数

     cout<<"Destructor called."<<length<<endl;       // 析构函数被执行时输出

    }

    int volume(){

        return height*width*length;

    }

private:

    int height,width,length;

};

int main(){

    CBox box1(1,2,3);

    cout<<box1.volume()<<endl;

    CBox box2(2,3,4);

    cout<<box2.volume()<<endl;

    return 0;

}

程序的执行结果为:

Constructor called.

6

Constructor called.

24

Destructor called.4

Destructor called.3

例2.25中声明了类CBox,类中有一个构造函数和一个析构函数,当构造函数运行时会输出一句话,方便判断构造函数执行的时机;同样的,当析构函数运行时也会输出一句话,方便判断析构函数执行的时机。

在一般情况下,调用析构函数的次序正好与调用构造函数的次序相反:最先被调用的构造函数,其对应的(同一对象中的)析构函数最后被调用;而最后被调用的构造函数,其对应的析构函数最先被调用。如上所示,先执行box2的析构函数,再执行box1的析构函数。可以简记为:先构造的后析构,后构造的先析构,它相当于一个栈,先进后出。但是,并不是在任何情况下都是按这一原则处理的。对象可以在不同的作用域中定义,可以有不同的存储类别,这些都会影响调用构造函数和析构函数的时机。下面归纳一下什么时候调用构造函数和析构函数。

(1)在全局范围中定义的对象(即在所有函数之外定义的对象),它的构造函数在文件中的所有函数(包括main函数)执行之前调用。但如果一个程序中有多个文件,而不同的文件中都定义了全局对象,则这些对象的构造函数的执行顺序是不确定的。当main函数执行完毕或调用exit函数时(此时程序终止),调用析构函数。

(2)如果定义的是局部自动对象(如在函数中定义对象),则在建立对象时调用其构造函数。如果函数被多次调用,则在每次建立对象时都要调用构造函数。在函数调用结束、对象释放时先调用析构函数。

(3)如果在函数中定义静态(static)局部对象,则只在程序第一次调用此函数建立对象时调用构造函数一次,在调用结束时对象并不释放,因此也不调用析构函数,只在main函数结束或调用exit函数结束程序时,才调用析构函数。例如,在一个函数中定义了以下两个对象:

void func(){

    Box box1;         // 定义自动局部对象

    static Box box2; // 定义静态局部对象

}

在调用func函数时,先调用box1的构造函数,再调用box2的构造函数。在func调用结束时,box1是要释放的(因为它是自动局部对象),因此要调用box1的析构函数。而box2是静态局部对象,在func调用结束时并不需要释放,因此不需要调用stud2的析构函数,直到程序结束释放stud2时,才调用stud2的析构函数。由此可以看到,stud2是后调用构造函数的,但并不先调用其析构函数,原因是两个对象的存储类别、生命周期都不同。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:

华章出版社

官方博客
官网链接