C++语言相较于C语言增加了许多有应用价值的语法和函数,其中最为关键的就是在C面向过程编程的基础上,引入了class关键字来使C++支持面向对象的编程,这使得开发效率得到了很好的提升。这篇文章中,我想简单介绍一下class类的使用方法,当然我们略去说什么结构化编程的缺点以及什么四个基本特点这种话术,直接讲该怎么写代码,另外我也是这学期才开始学习C++编写程序的,难免有一些缺漏之处,希望大家海涵。QWQ

本节的主要内容是类的定义和类内成员的访问,以及类的特殊成员函数——构造函数和析构函数,学习理解它们的机制以及执行顺序。

类的定义和访问

在C++中,类的基本定义方法如下:

1
2
3
4
5
6
7
8
class 类名{
访问范围说明符:
成员变量
成员函数(声明)
访问范围说明符:
更多成员变量
更多成员函数(声明)
};

注意class的定义一定要以;结束。

“访问范围说明符”包含3种:publicprivateprotected,在public下的成员变量和函数可以被在class定义以外使用,后面我们会介绍如何进行访问;privateprotected下的成员变量和函数在class外都是无法被访问的,但是protect下的变量和函数可以被本类的派生类访问和使用,后面我们还会提到。

“成员变量”和普通变量的定义方式完全相同,“成员函数声明”和正常函数定义的声明也是完全相同的,我们一般称成员变量为类的属性,而成员函数为类的方法。一个类的成员函数之间可以相互调用,且成员函数可以进行重载,也可以设置默认参数值。为了区分类之外的函数,我们称他们为全局函数(实际上在编译时public的成员函数和全局函数的区别仅仅时参数表多一个this指针,我们后面会谈到)。成员函数可以在类内声明时直接使用{}进行定义,也可以先声明,后再类外进行定义,但需要遵守以下格式:

1
2
3
返回值类型 类名::函数名(){
语句组
}

在主函数中,对于定义好的类,我们就可以定义对象了,基本方法如下:

1
类名 对象名;

接下来我们说说如何访问一个类的成员变量以及成员函数。一般情况下,我们会使用对象名.成员名对类的成员进行访问,当然我们也可以定义类的指针,这样我们就可以通过指针->成员名的方式完成相同的操作,假设我们现在已经定义了一个复数类Complex,它具有两个public成员变量realimage,当我们定义了Complex c以及Complex* pc时,我们就可以通过c.realpc->real来访问real变量的值了。当然我们还可以通过引用名.成员名的方式对成员进行访问。

值得注意的是,在对运算符进行重载之前,只有=是可以在类变量中使用的,对象之间可以通过“=”互相赋值,但是这个赋值一般是所谓的浅拷贝,即两个对象指向同一片存储空间,两个对象并非独立。

下面给一个矩形类的示例,希望可以加深对于类的理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include<iostream>
using namespace std;
class CRectangle{
public:
int w,h;//成员变量,矩形的长和宽
void init(int w_,int h_){
w=w_;
h=h_;
}//初始化,在class内定义成员函数
int area();//求面积,在class外定义成员函数
int perimeter();//求周长
};
int CRectangle::area(){
return w*h;
}
int CRectangle::perimeter(){
return 2*(w+h);
}
int main(){
int w,h;
CRectangle r;
CRectangle* pr=&r;
CRectangle& rr=r;
cin>>w>>h;
r.init(w,h);
cout<<"w="<<r.w<<endl;//通过对象直接访问
cout<<"h="<<pr->h<<endl;//通过对象的指针访问
cout<<"Its area is"<<rr.area()<<endl;//通过对象的引用访问
cout<<"Its perimeter is"<<r.perimeter()<<endl;
return 0;
}

构造函数和析构函数

构造函数

对象在生成时,总是要进行初始化的,这样的程序才会更加安全。在C++中,我们定义了一类特殊的函数,成为构造函数(constructor),其名称和类的名称完全相同,不用声明返回值类型,且可以进行重载。值得注意的是,如果类的设计者没有为这个类写构造函数,那么编译器会为它自动生成一个无参构造函数,这个函数没有任何输入,也不执行任何操作,如果我们为一个类写了任何其他构造函数,这个无参构造函数都将不再自动生成,如果需要需要自行声明,这是十分容易出现错误的。

对象只有在生成时使用构造函数,在使用=进行赋值时并不会再次调用构造函数,这也是值得注意的。另外构造函数还可以写成参数表的形式,这样写比较简单,对于后面有成员对象的封闭类也更加友好,其一般形式为:

1
2
3
类名::构造函数名(参数表):成员变量1(参数表),成员变量2(参数表),...{
...
}

可以通过下面的例子对上述内容进行理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Complex{
private:
double real,image;
public:
Complex(double r,double i=0):real(r),image(i){
cout<<"constructor"<<endl;
}
};
int main(){
Complex c1;//错,class中没有无参构造函数
Complex* pc=new Complex;//错,同上
Complex c2(2);//对
Complex c3(2,4),c4(3,5);//对
Complex* pc2=new Complex(3,4);//对
}

如果想让上述main中全部语句正确,则需要在class的public中加上构造函数:

1
Complex(){}

这样就有了人为写的无参构造函数,程序就可以正常编译运行了。

关于对象数组,在生成和内存分配时也会调用初始化函数,不过和上面介绍的大同小异,我们通过一个例子来理解一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include<iostream>
using namespace std;
class Complex{
public:
double real,image;
Complex(){cout<<'a'<<endl;}//无参构造函数,并输出a字符
Complex(double r,double i=0):real(r),image(i){
cout<<'b'<<endl;
}//有默认值得构造函数,并输出b字符
};
int main(){
Complex c1[2];//调用无参构造函数
cout<<"-----------"<<endl;
Complex* pc=new Complex[3];
cout<<"-----------"<<endl;
Complex c2[2]={Complex(1.,2.),Complex(3.,4.)};//这里{}中不写Complex也能编译运行,至少vscode可以
cout<<"-----------"<<endl;
Complex c3[2]={1.};//只调用一次第二个构造函数,第二个元素没有声明则使用无参构造函数
delete [] pc;//最后记得把动态存储的空间删去
return 0;
}

输出的结果为:

1
2
3
4
5
6
7
8
9
10
11
12
a
a
-----------
a
a
a
-----------
b
b
-----------
b
a

这是符合我们的预期的。另外有一个可能比较迷惑的,我们单独拿出来说说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<iostream>
using namespace std;
class Complex{
public:
double real,image;
Complex(){cout<<'a'<<endl;}
Complex(double r,double i=0):real(r),image(i){
cout<<'b'<<endl;
}
};
int main(){
Complex* parr[3]={new Complex(3,4),new Complex(2)};
// delete [] parr; 这行如果不注释掉,将会报错
for(int i=0;i<3;++i){
delete parr[i];
}
return 0;
}

输出值将是:

1
2
b
b

不会输出bba,由于parr是一个指针数组,其元素是Complex类型的指针,第12行对parr[0]和parr[1]进行了初始化,使之成为指向动态分配的Complex对象的指针,这样输出bb。parr[2]没有初始化,其值是随机的,所以这个数组内生成了2个Complex对象,所以不会出现a。但是需要注意的是,对于第13行,编译将会报错,parr不是通过new一并进行定义的,所以这样的语句并不合法。使用14到16行逐个释放是正确的,其中14行最好写成i<2,但是我试了一下,写成3也可以运行,可能是vscode比较先进?最好不要出现释放一个没有定义的指针的语句。

复制构造函数

赋值构造函数是一类特别的构造函数,它只有一个参数,且只能是本类的引用或者const的本类的引用(这两可以重载,最好写const的,非const的也可以使用)。如果你不写这个函数,编译器会自动为这个类生成复制构造函数,其作用是从源对象逐个字节复制到新的对象中(需要注意如果成员变量中有指针时,并不能做到把指针中的内容也重新复制一遍,也就是说新旧对象的指针将会指向同一个位置)。值得注意的是,如果你不写复制构造函数,编译器是一定要为你生成这个函数的,这和无参构造函数不太相同,当然你自己只要写了这种参数表的构造函数,编译器就不会在生成自带的复制构造函数了。可以看如下示例加深一下理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include<iostream>
using namespace std;
class Complex{
public:
double real,image;
Complex(){cout<<'a'<<endl;}
Complex(double r,double i=0):real(r),image(i){
cout<<'b'<<endl;
}
Complex(const Complex& c):real(c.real),image(c.image){
cout<<'c'<<endl;
}//自定义的复制构造函数
};
int main(){
Complex c1(2,1);
Complex c2(c1);
return 0;
}
//程序输出:
//b
//c

注意构造函数不能以本类的对象作为唯一参数,这是甲鱼的臀部,所以不要在你的code中出现:

1
Complex(Complex c){...}

这是不正确的。

复制构造函数会在一些你意想不到的时候运行,所以我们总结一些复制构造函数被调用的三种情况:

  1. 当用一个对象去初始化同类的另一个对象时,会引起复制构造函数的调用

    1
    2
    Complex c2(c1);
    Complex c2=c1;

    都会调用复制构造函数,但注意,如下赋值语句不会调用复制构造函数,因为构造初始化的过程已经完成:

    1
    2
    Complex c1,c2;
    c1=c2;
  2. 作为形参的对象,是通过复制构造函数进行初始化的,调用复制构造函数的参数就是调用本函数时所给的实参。即如果函数F的参数时A的对象,如果F被使用,则A的复制构造函数被调用。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    #include<iostream>
    using namespace std;
    class Complex{
    public:
    double real,image;
    Complex(){cout<<'a'<<endl;}
    Complex(double r,double i=0):real(r),image(i){
    cout<<'b'<<endl;
    }
    Complex(const Complex& c):real(c.real),image(c.image){
    cout<<'c'<<endl;
    }
    };
    void Func(Complex c){
    cout<<"function run"<<endl;
    }
    int main(){
    Complex c;//无参构造函数
    Func(c);//先调用复制构造函数,再执行函数
    return 0;
    }
    //输出为:
    //a
    //c
    //function run

    注意使用对象作为函数形参一定会调用类的复制构造函数,这会在一定程度上降低执行效率,所以我们尽可能的使用对象的引用作为函数的形参,如果确保形参的值不在函数执行过程中发生变化,这种办法是安全且高效的,但是一旦函数中的形参发生变化,主函数中的实参也会跟着改变,解决办法是使用const:

    1
    2
    3
    void Function(const Complex& c){
    ... //函数中如果有修改c的值的语句就会编译错误
    }
  3. 作为函数返回值的对象也是用复制构造函数进行初始化的,调用复制构造函数的参数就是return语句所返回的对象。即函数返回值是类A的对象,函数返回时就会调用复制构造函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    #include<iostream>
    using namespace std;
    class Complex{
    public:
    double real,image;
    Complex(){cout<<'a'<<endl;}
    Complex(double r,double i=0):real(r),image(i){
    cout<<'b'<<endl;
    }
    Complex(const Complex& c):real(c.real),image(c.image){
    cout<<'c'<<endl;
    }
    };
    Complex Func(){
    cout<<"function run"<<endl;
    return Complex(3);
    }
    int main(){
    cout<<Func().real<<endl;
    return 0;
    }
    //理论上编译结果应该是
    //function run
    //b
    //c
    //3

    不过如果你使用vscode的话可能不会出现这个c的输出,这是因为先进的编译器可能将这一机制优化了,毕竟再使用复制构造函数会浪费执行效率。

类型转换构造函数

除复制构造函数外,只接受一个参数的构造函数可以被成为类型强制转换构造函数,这样的构造函数可以起到类型自动转换的作用。例如以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include<iostream>
using namespace std;
class Complex{
public:
double real,image;
Complex(){cout<<'a'<<endl;}
Complex(double r,double i):real(r),image(i){
cout<<'b'<<endl;
}
Complex(const Complex& c):real(c.real),image(c.image){
cout<<'c'<<endl;
}
Complex(double r):real(r),image(0){
cout<<"d"<<endl;
}
};
int main(){
Complex c1(7,8);
Complex c2=12;
c1=9;
return 0;
}
//输出为
//b
//d
//d

注意,第18行使用接受两个参数的构造函数进行对c1的初始化,这是c1已经存在,后续再做任何操作也不会再对其进行初始化。第19行对c2进行初始化,这样写就表示直接使用一个参数的构造函数。第20行的执行过程实际上是先使等号右侧的9自动转换成一个临时的Complex对象,再赋值给c1,所以不会调用复制构造函数。注意,一般优化过的编译器执行第19行时不会复杂的将12先转为临时对象后再通过复制构造函数进行初始化,一般会直接执行一个参数的复制构造函数。

析构函数

析构函数(destructor)是指在对象消亡时执行的语句,和构造函数一样是一类特殊的成员函数,它的名字与类名相同,不过要在前面加一个~号,且这个函数没有参数和返回值,也不用写void之类的返回类型声明,一个类有且仅有一个析构函数,如果没有写析构函数,则编译器会自动生成一个。析构函数的主要作用时执行对象消亡后的善后工作,例如执行构造是使用new进行空间分布,在消亡时应该在析构函数中delete这部分内存,再如使用静态变量记录对象数量时,在对象消亡时也应该在析构函数中将静态变量减1。例如:

1
2
3
4
5
6
7
8
9
10
11
class string{
private:
char* p;
public:
string(int n){
p=new char[n];
}
~string(){
delete [] p;// 释放p中动态分配的内存空间
}
};

对于掌握析构函数,我们应该明白对象何时消亡,例如在使用delete删除指针时需要调用析构函数,main函数最后return 0时也会将主函数中所有实参删除,如果存在对象,也会调用对应类的析构函数,当然函数的参数对象以及作为函数返回值的对象,在消亡或者说在函数执行结束后,都会调用析构函数。结合构造函数,我们看下面的这个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include<iostream>
using namespace std;
class CLASS{
public:
CLASS(){cout<<"constructor 1"<<endl;}
CLASS(const CLASS& d){cout<<"constructor 2"<<endl;}
~CLASS(){cout<<"destructor"<<endl;}
};
void Fun(CLASS obj){
cout<<"Fun run"<<endl;
}
CLASS d1; // 首先执行无参构造函数
CLASS Test(){
cout<<"Test run"<<endl;
return d1;
}
int main(){
CLASS d2; //第二次使用无参构造函数
Fun(d2); //使用复制构造函数传入参数->运行Fun函数->函数运行结束,传入的形参消亡,调用析构函数
Test(); //运行Test函数->使用复制构造函数作为返回值->函数执行结束,调用析构函数
cout<<"after test"<<endl;
return 0; //main中的对象d2消亡,调用一次析构函数
}//文件执行结束,d1消亡,再次调用一次析构函数

输出为:(分析就在上面注释出来了)

1
2
3
4
5
6
7
8
9
10
11
constructor 1
constructor 1
constructor 2
Fun run
destructor
Test run
constructor 2
destructor
after test
destructor
destructor

为了明白构造函数和析构函数的调用以及变量的生存期,我们再举一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include<iostream>
using namespace std;
class xisense{
int id;//如果不进行任何声明,就是private类型的
public:
xisense(int i):id(i){
cout<<"id="<<id<<" constructed"<<endl;
}
~xisense(){
cout<<"id="<<id<<" destructed"<<endl;
}
};
xisense d1(1);
void Func(){
static xisense d2(2);
xisense d3(3);
cout<<"func"<<endl;
}
int main(){
xisense d4(4);
d4=6;
cout<<"main"<<endl;
{
xisense d5(5);
}
Func();
cout<<"main ends"<<endl;
return 0;
}

输出结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
id=1 constructed
id=4 constructed
id=6 constructed
id=6 destructed
main
id=5 constructed
id=5 destructed
id=2 constructed
id=3 constructed
func
id=3 destructed
main ends
id=6 destructed
id=2 destructed
id=1 destructed

分析一下:程序首先构建全局变量d1,其id=1,调用构造函数,输出第一行结果。进入main函数中,首先构造一个id=4的d4,输出第二行结果。第21行生成一个初始值为6的临时对象,调用构造函数,输出第三行结果,注意这里没有调用复制构造函数,在赋值结束后,这个临时对象被析构,输出第四行结果。执行第22行,输出”main”。在主函数中局部生成d5对象,在}出现后就进行析构,所以输出第六七行结果。随后执行Func函数,首先创造一个静态局部变量(我们后面会谈到,不要着急,其特性就是一旦产生,就不再消亡,直到整个程序结束)d2,所以输出第8行结果。随后构造d3,并执行输出”func”的动作,在函数执行结束后,函数内的非静态变量全部被析构,输出第11行结果。回到主函数后,输出“main ends”,mian函数执行结束,首先析构main中的局部变量d4。然后整个程序执行结束,按顺序局部静态变量d2和全局变量d1。