C++:一篇文章讲清楚类和对象
目录
一、类的概念
类是C++里的一种新的数据类型,其类似于C语言中的结构体,不同的是,类里面可以定义函数,称为类的成员函数。简单来说,类是一种将属性(变量)和行为(函数)打包在一起的数据类型。
二、类的格式
1、类定义格式
class为定义类的关键字,后面加上类的名称,本文将使用日期类(Date)来进行讲解,{}内为类的主体,括号后的分号;不能省略。类体中的变量称为类的属性或成员变量,类中的函数被称为行为或成员函数。
定义在类里面的变量,为了与正常的变量区分开,一般会在变量名的前面加上一个下划线(_),如下图所示。
class Date
{
int _year;
int _month;
int _day;
};
特别的是,在C++里面,struct也可以用于定义类,因为C++本身兼容C语言内的用法,同时struct升级成了类,比较明显的区别是struct内可以定义函数了。
2、访问限定符
C++将属性和方法封装在一起,通过访问权限选择性的将其内容给类外的函数使用。类里面有三个修饰成员的关键字,分别是public、private、protected,由public修饰的成员可以在类外直接访问,而private、protected修饰的成员则无法在类外使用。在class定义的类里面,若成员没有被访问限定符修饰,默认为private成员,在struct内则为public成员。
访问限定符的修饰规则是:从访问限定符修饰的位置开始,到下一个访问限定符出现为止,若无下一个访问限定符则到类的结尾为止。
一般在类里面,我们都会将成员变量用private修饰,因为这样可以防止我们的变量被改变,而需要给别人使用的成员函数一般都用public。
class Date
{
public:
void A(...)
{
...
}
private:
int _year;
int _month;
int _day;
};
三、类的实例化
1、实例化概念
用类类型在物理存储中创建出对象的过程,称为类实例化出对象。在创建类类型时,类内部的成员变量和成员函数都只是声明,并没有在内存中开辟存储空间,只有在创建对象时才开辟空间。就比如Date d(...)这个d就是被实例化出来的对象。
2、对象大小
首先,我们先要了解,对象内的哪些成员是需要开辟空间的,很明显成员变量是需要开辟空间的,不然怎么存储数据。那类里面的函数呢?我们都知道函数是无法被存储的,它在被编译后只是一段指令,那难道是在类里面存储一个指向函数的指针吗?可是我们不管创立多少个对象,它们所指向的函数指针都是同一个,这样每创建一个对象都要重复存储一次,就有点过于浪费了。所以对象在创建时是不存储函数的信息的,在使用时编译器会自动跳转到函数的位置。
现在我们知道了对象内存储的内容,可以开始计算对象的大小了,类的空间也是按照内存对齐的规则计算的。首先,第一个成员在与对象偏移量为0的位置,其余变量要对齐到对齐数的整数倍的地址处,对齐数是编译器默认的对齐数与该变量大小中的较小值,类的总大小为最大对齐数的整数倍。
四、this指针
this指针是类的成员函数在访问对象时所只用的一种特殊指针,为什么要有这个指针呢?我们来想一种情况:如果一个类创建了多个对象,在对象调用成员函数时,函数该如何确定该访问哪个指针呢?毕竟在我们调用函数时并不会把对象作为参数传过去。那么此时this指针就发挥作用了,this指针就是编译器在编译后,在函数形参的第一个位置增加一个当前类类型的指针,函数在访问对象时,就会通过this指针精确访问到对象。值得注意的是:这个this指针在定义函数时不能将其写在括号里,编译器会自动处理,但是在函数里面可以使用这个this指针。
class Date
{
public:
void Print()
{
//第一种写法
cout << _year << endl;
//第二种写法
cout << this->_year << end;
...
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
Date d2;
d2.Print();
return 0;
}
就如上图所示,Print函数在编译后会加上一个参数,变为Print(Date* const this),则在函数调用里面_year就默认是访问对象的成员变量,或者也可以用this->_year,这两种写法都是可以的。
五、默认成员函数
默认成员函数是在类里面,若用户没有显示实现,编译器会自动生成的成员函数被称为是默认成员函数。一个类共有六个默认成员函数,其中比较重要的四个分别是构造函数、析构函数、拷贝构造函数、运算符重载。接下来我来讲解一下这四个函数。
1、构造函数
构造函数虽然名叫构造,但它并不负责开辟空间,它的主要功能是在实例化对象时进行初始化,就类似与初始化函数。
构造函数有一下几个特点:
1、构造函数的函数名与类名相同,且没有返回值,不用写void。
2、对象实例化时系统会自动调用构造函数。
3、构造函数可以重载。
4、若用户没有在类里显示的写出构造函数,编译器会自动生成一个无参的默认构造函数。
构造函数也可以使用缺省参数,在日常使用时,我们怎么设置构造函数最好呢?答案是用默认构造函数最好,默认构造函数就是只所有不用传参也可以使用的函数,分别是无参构造函数、全缺省构造函数、编译器默认生成的构造函数,需要注意的是,默认构造函数只可以存在一种,若同时存在无参和全缺省两种构造函数,在调用时会存在歧义。而在这三种里面,全缺省构造函数最好,因为全缺省构造函数最方便,无论是不传参、只传个别参数或是传全部参数都可以完美适配,而编译器默认生成的构造函数,在不传参时生成的数是随机的,不过这取决于编译器,大部分编译器都是用随机数来初始化的。
日期类的构造函数大致如下图所示:
class Date
{
public:
//全缺省构造函数
Date(int year = 2026, int month = 1, int day = 1)
{
//_year,_month,_day就是要初始化对象里的成员变量
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
说了这么多,为什么我们最好写默认构造函数呢?因为在编译器编译的时候,如果一个类里面定义了一个自定义类型的数,那么在其初始化时,会去调用那个类里面的默认构造函数,若没有默认构造函数,编译器就会报错,所以说最好写默认构造函数。如果我们没有写默认函数,但我们又想初始化这个成员变量,有没有其他办法呢?答案也是有的,那就是用初始化列表。
初始化列表也是用于初始化对象的一种方式,使用方法是从冒号开始,逗号分隔,每个变量后面跟个括号,括号里放一个表达式来表示初始值,写在构造函数的下面,花括号外,如下所示:
class Date
{
public:
Date(...)
:_year(2026)
,_month(1)
,_day(1)
{}
private:
int _year;
int _month;
int _day;
};
需要注意的是,每个成员变量在初始化列表中只能初始化一次,括号里不止能填具体的值,也可以给一个表达式,比如_day(_month)也是可以的。并且初始化列表初始化变量是按照声明时的顺序进行的,而不是按初始化列表写的顺序。引用成员变量、const成员变量、没有默认构造函数的自定义类型变量都必须在初始化列表初始化,否则编译器会报错。原因是,引用成员变量在定义时就必须绑定变量,不能空置和改绑,所以必须初始化;const成员也是类似,在定义后被const修饰的变量就无法再进行修改,所以必须进行初始化。
在C++11里,编译器允许在声明变量时给成员变量给缺省值,这样如果该变量在初始化列表里没有进行初始化,编译器就会自动使用声明时给的缺省值进行初始化。
这时候就有一个疑问了:这构造函数和初始化列表的功能好像是一样的啊,写两次不是浪费了吗?其实是不一样的,它们两个有一个本质的区别,初始化列表是表示创建对象,同时对对象进行初始化,而在构造函数表示对象已经存在了,对对象进行赋值。也就是说,初始化列表其实是编译器默认必须要走的一个步骤,哪怕你没有写初始化列表,或者有些变量没有写在初始化列表里,编译器还是会把所有变量在初始化列表里初始化一遍。所以一般来说比较建议把所有的变量都在初始化列表里写出来,因为如果有些变量在初始化列表里没有,编译器对其初始化的值是没有要求的,也就是编译器会随手塞进去一个随机值,所以尽量还是都写在初始化列表里最好。
2、析构函数
析构函数负责在对象生命周期结束时释放创建对象时所申请的资源,简单来说就是,比如我们创建了一个栈类型,则这个栈类型的对象肯定要通过malloc开辟空间,析构函数的功能就是负责释放对象所开辟的空间,而我们今天用于讲解的例子里并没有申请任何资源,所以严格来说Date类是不需要写析构函数的。
析构函数的特点:
1、析构函数的函数名就是在类名前面加上一个“~”符号,同时与构造函数一样,析构函数也没有返回值,不用写void。
2、一个类只能有一个析构函数,若没显示定义,则系统会自动生成默认的析构函数。
3、在对象生命周期结束时,编译器会自动调用析构函数。
4、对于自定义类型的变量,编译器会调用它本身的析构函数,不管在当前类的析构函数里写没写,都会自动调用它的析构函数。
析构函数相较于构造函数就简单的多,只要不涉及申请资源的类我们都可以不写析构函数,编译器生成的默认析构函数就够用了。
3、拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,其余所有参数都有默认值,则我们称这个构造函数为拷贝构造函数。
拷贝构造函数的特点:
1、拷贝构造函数是构造函数的一个重载。
2、拷贝构造函数的第一个参数必须是自身类类型的引用,不能进行传值传参,具体原因在下面进行详解。
3、C++规定对自定义类型的拷贝必须通过拷贝构造函数实现。
4、若没有显示定义拷贝构造函数,则编译器会自动生成一个默认的拷贝构造函数,编译器默认生成的拷贝构造函数对内置类型成员变量会采取值拷贝/浅拷贝,也就是一个一个字节的进行拷贝,对于自定义类型则会调用它的拷贝构造。
接下来我们来详细解释一下为什么拷贝构造函数的第一个参数必须是自身类类型的引用,先贴一张代码看看传值传参和引用的区别:
class Date
{
...
//正确写法
Date(Date& d)
{
...
}
//错误写法
Date(Date d)
{
...
}
...
};
int main()
{
Date d1(...);
Date d2(d1);
return 0;
}
首先我们上面已经了解了,C++规定自定义类型的拷贝都一定要通过拷贝构造函数实现,而我们也知道,传值传参就相当于是一次对原对象的拷贝,所以如果是使用第二种写法的话,Date(Date d)括号里这一段就相当于是又拷贝了一次原对象,所以又再调用一次拷贝构造,拷贝构造在生成形参时又进行拷贝,就又调用拷贝构造,就进入了一个死循环,所以这种写法是错误的。而第一种写法,我们直接传一个引用过去,就避免了对原对象的再次拷贝,就不会出现刚刚出现的问题。
拷贝构造存在的意义是什么呢?拷贝构造顾名思义,就是将某个对象的所有值拷贝并生成一个新的变量,就类似与我们经常使用的int b = a这种句子,但是我们为什么不直接写成这样呢?因为在类的里面不是所有的成员变量都是可以直接赋值过去的,如果我的类里面某个成员变量是动态申请了空间的呢?值可以复制但是空间怎么办?总不可能共用一块空间吧?所以说拷贝构造是有它存在的理由的。在一般情况下,拷贝构造函数也可以不写,如果类的内置类型里面没有需要动态申请空间的就没有必要专门写,直接使用编译器默认的拷贝构造进行浅拷贝就行了。
4、运算符重载
类里一般都会有多个变量,比如日期类里面就有_year、_month、_day三个变量,这就意味着我们在进行自定义类型的运算时,原本的运算符是无法支持我们进行运算的,这时候我们就需要重载运算符,让运算符能适配我们自定义类型间的计算。
运算符重载的特点:
1、运算符重载的函数名是operator + 任意运算符。
2、运算符重载有返回值,毕竟需要得到运算后的结果,如果是判断类的运算符返回类型就是bool,计算类的返回类型就是对应的类类型的引用,返回引用可以减少在返回时的拷贝构造。
3、因为我们之前提到的关于this指针的概念,所以在参数位置,如果是双目运算符,就只写一个参数,单目运算符就不写参数。
4、不能通过结合原先没有的运算符来创造新运算符。
5、在使用时不用写出operator,只需写出运算符本身,编译器就会自动调用对应的运算符重载函数。
首先我们先来讲解一下最基础的运算符重载,也就是赋值运算符重载,也就是等于号(=),这个和我们上面所讲的拷贝构造很像,在日期类里就只是将三个变量依次赋值给另一个对象而已。大部分运算符重载都没什么需要特别注意的,在所有运算符重载里,比较需要特别注意的一般就两种,第一种是“++”类型,第二种是“<<”和“>>”。
“++”型运算符重载。我们都知道,自加符号有两种用法,一种是前置++,一种是后置++,两者虽然都是自加,但在实际运算时还是有着不小的差别的,那我们该如何区分什么时候用前置++什么时候用后置++呢?为了区分这两种用法,C++在设计时规定了,后置++的重载函数只需要在参数位置加上一个“int”就行,这样编译器在处理的时候就会自动调用对应的重载函数。这两种用法的重载写起来也很简单,前置++就是先自加再返回,后置++就是先拷贝一份当前值,自加,然后返回拷贝的值就行。大致代码如下:
class Date
{
...
Date& operator++();
Date operator++(int);
};
//前置++
Date& Date::operator++()
{
...
_day++;
return *this;
}
//后置++
Date Date::operator++(int)
{
...
Date tmp = *this;
_day++;
return tmp;
}
需要注意的是后置++的返回类型就不能是引用了,因为我们要返回的是原对象的拷贝,函数调用结束后tmp就会被销毁,如果我们返回的是引用的话,就会导致出现野引用的问题,所以直接返回类类型的就行。
“<<”和“>>”运算符重载。一般情况下,运算符重载的声明都会写在类里面,但是<<和>>的运算符重载不能在类里面声明,应声明为全局函数,因为上文有说过,类的内置成员函数都默认第一个参数为访问对象的this指针,但我们一般在使用时cout << ... cout都是在前面的,如果要声明在类里面,为了符合C++的语法规定,要输出的对象就必须写在第一个,就变成了... << cout这并不符合我们的日常使用习惯,所以最好将其设置为全局函数,然后第一个参数设置ostream/istream类型的参数就行这样就可以正常的写成cout << ... 和cin << ...。大致代码如下:
class Date
{
...
};
void operator<<(ostream out, const Date& d)
{
...
}
void operator>>(istream in, Date& d)
{
...
}
在重载<<时我们最好用const修饰一下,因为只读不改,而>>因为要改变值所以就不用const修饰了。在所有运算符重载里,大部分情况下参数都最好写成const 类类型& 这种形式,const是在很多不用修改原对象的情况下用于防止原对象被修改,使用引用是可以减少一次拷贝,在比如重载判断类运算符时使用就是最适合的。
六、static成员
用static修饰的成员变量,称之为静态成员变量,静态成员变量的初始化一定要在类的外面,静态成员变量为所有类对象共享,不存在类中,存放在静态区里。static也可以修饰成员函数,被修饰的函数没有this指针,静态成员函数中可以访问其他静态成员,但是因为没有this指针,所以无法访问非静态的成员。但是反过来,非静态的成员函数可以访问任意静态成员变量或成员函数,突破类域也可以访问静态成员,只要使用域访问限定符就可以访问,如类名::静态成员或对象.静态成员来访问。
七、友元和内部类
友元和内部类比较简单,我们合在一起讲。友元分为友元函数和友元类,友元是一种突破类访问限定符的方式,比如说我想在某个外部函数里用到某类里的私有成员,就可以在类里面增加一条友元声明,声明方式就是在函数声明前面加上一个friend,声明后的友元函数就可以访问类里的所有成员。
友元的特点:
1、在函数声明或类声明前加上关键字friend,并将其放在类里即为设定友元。
2、友元声明仅仅只是声明,并不是类里的一个成员。
3、友元可以在类里的任何一个地方声明,不受类访问限定符影响。
4、一个函数或类可以同时是多个类的友元。
5、友元不可传递,如b是a的友元,c是b的友元,但c不是a的友元。
6、友元是单向的,如b是a的友元,但a不是b的友元。
友元虽然在一定程度上提供了便利,但是同时它也破坏了封装,提高了耦合度,如果图方便把所有类和函数都设置为友元,那为什么不最开始就把类的所有成员都设置为公开,所以不建议滥用友元。
内部类顾名思义就是嵌套在一个类里的另一个类,但是需要注意的是,内部类它也是一个独立的类,外部类定义的对象中是不包含内部类的,跟定义在全局相比,它只受类域限制和访问限定符限制。值得注意的是,内部类默认是外部类的友元。内部类本质也是一种封装,如果A类在定义时就与B类紧密相连,那就可以考虑将A类定义为B类的内部类,如果放到了private/protected位置,那么A类就是B类的专属内部类。
八、匿名对象
匿名对象是指在创建时没有明确赋予变量名的对象,通常用于简化代码或临时使用场景。这类对象在创建后无法通过变量名再次引用,通常仅用于单次操作。
匿名对象的写法是类名(参数),匿名对象就是一个临时对象,它的生命周期就只存在于创建的这一行,一般使用场景就是如果你某个对象创建出来只想使用一次,就可以使用匿名对象,这样不仅写起来更加方便,也可以减少对空间的使用,简单来说,匿名对象被创造出来就是为了图个方便的。匿名对象也比较简单,在这就不过多赘述了。
九、总结
类和对象作为C++里最大、最重要又是最难的章节,内容还是比较多的,理解起来也较为抽象,希望我的文章能帮大家更好的理解关于类和对象里的各种概念,如果有错误的地方也欢迎大家指出。
更多推荐

所有评论(0)