欢迎来到繁星的优快云。本期内容主要包括类和对象,C++最重要的一个组成部分。
目录
一、类的定义
1.1类的示例
个人理解,“类”类似于C语言中struct的进阶扩展版本,尽管差别巨大。
示例:
#include<iostream>
using namespace std;
class Stack
{
public:
// 成员函数
void Init(int n = 4)
{
array = (int*)malloc(sizeof(int) * n);
if (nullptr == array)
{
perror("malloc申请空间失败");
return;
}
capacity = n;
top = 0;
}
void Push(int x)
{
// ...扩容
array[top++] = x;
}
int Top()
{
assert(top > 0);
return array[top - 1];
}
void Destroy()
{
free(array);
array = nullptr;
top = capacity = 0;
}
private:
// 成员变量
int* array;
size_t capacity;
size_t top;
};
1、在C++中,我们常常使用class作为定义类的关键字,示例中的Stack是类的名字,Stack后的大括号中间是类的主体。类体中的内容都叫做“成员”,函数就叫做成员函数,变量就叫做成员变量。
2、我们仍然可以用struct定义结构体,这是C++兼容C语言的地方。但在C++中,我们可以使用struct定义类。和class的区别将在访问限定符处讲到。一般情况下还是使用class为主。
3、定义在类里面的成员函数,默认为inline(内联)。
4、当定义了一个结构体后,我们不必再像C语言中那样多写一个struct来实例化一个结构体,而是直接用名字命名即可,且不必typedef。
struct LTNode {
LTNode* next;
int val;
};
1.2访问限制符
在刚刚的示例中,想必大家已经见到了“public”、“private"这两个关键字了,再加上protected,这三个就构成了C++中的访问限制符。
1、public修饰的成员可以在类外被直接访问,而private和protected修饰的成员不可以。
2、private(个人,私有)和protected(保护)的区别在继承的部分将会叙述到,在目前阶段可以认为是相同的。
3、访问限定符的作用域从使用该符号的这一行开始,直到下一个访问限定符出现,如果没有出现,则一直作用到类结束。
4、用class定义时如果没有使用访问限定符,则默认为private,而struct则默认为public。
5、访问限制符的意义在于封装,使得必要的数据不那么容易地被更改,所以我们尽量将成员变量放在private里面,并且惯常使用class。
1.3类域
有了类,也新定义了一个作用域——类域。当在类外定义成员的时候,我们仍然使用::两个冒号来指定类域,否则编译器将认为这一函数为全局函数,而非类中的函数。
//代码示例:
class Stack {
public:
void init(int n = 4);
private:
int* _ptr;
int _n;
};
void Stack::init(int n) {
_ptr = nullptr;
_n = n;
}
两个冒号就让编译器知道init是Stack这一类域中的了。
二、实例化
2.1什么是实例化?
1、如结构体一般,我们可以利用一个结构来定义多个实际的变量。在类中,我们称为实例化。
2、在声明定义处的类,不占用物理上的空间,原因是没有实例化。类只是一个设计图(不能直接称为模板的原因是C++中对模板有另外的定义)而一旦用这张设计图将某个建筑落地的时候,这个”建筑“就占用了空间,也就是已经完成了实例化。
class Stack{
//定义成员
};
int main(){
Stack s1;
return 0;
}
这里的s1就是被实例化的对象。
2.2对象
被实例化后,s1就变成了对象。下列内容被储存到对象中:
1、成员变量。
2、成员函数无需储存。原因是如果储存了成员函数,那么每个实例化的对象都需要存储,浪费空间。那么指针呢?以同样的逻辑,每次运行时都要在每个对象处调用一遍成员函数,浪费栈帧空间。
注意:这不意味着成员函数真的无需储存,只是在一般情况下,编译器在编译链接的时候就找到了函数指针,将这些函数放在了公共代码区,并在每个对象处化作了”call“指令,只有比较特殊的动态多态才会在运行的过程中找函数地址,后续会展开讲解。
2.3内存对齐
C++规定类实例化对象也需要满足内存对齐规则
- 第一个成员在与结构体偏移量为0的地址处。
其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。 注意:对齐数 = 编译器默认的一个对齐数与该成员大小的较小值。 VS中默认的对齐数为8。 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
值得注意的是:
如果类中没有成员变量,对象的内存大小为1,意为表示该对象存在。如有成员变量,则按照上述规则来计算内存大小。
特殊情况
观察以下代码:
#include<iostream>
using namespace std;
class A
{
public:
void Print()
{
cout << "A::Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
按照一般思路,对nullptr解引用会导致报错,但这种情况下,程序正常运行。
原因是:
我们之前阐述过,在实例化过后,对象只会储存对成员函数的call指令。也就是说,这里的指针p,程序没有解引用它,而是直接call了指令。
但如果在Print函数中,调用了有关成员变量_a的内容的话,程序会报错,因为这个操作对p这个空指针解引用了。
三、this指针
class Date{
public:
void Init(int year,int month,int day) {
_year = year;
_month = month;
_day = day;
}
void Print() {
cout << _year << "-" << _month << "-" << _day<<endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1;
Date d2;
d1.Init(2024, 7, 18);
d1.Print();
d2.Init(2024, 7, 17);
d2.Print();
}
有如上的一段关于日期的类,其中包含初始化(Init)和输出(Print)这样两个成员函数。
d1和d2已被实例化。
当d1和d2在使用Init和Print两个函数的时候,为了使得编译器分清楚哪个是d1哪个是d2,C++中引入了this指针,用以指向对象。
如下图所示:(上下两段代码没有区别)
this指针比较特殊,我们不能在实参和形参处显式地写明this指针,但编译器会在每个成员函数的第一个形参位置默默地放入this指针,这也是为什么我们能在成员函数中使用this指针的原因。
类中成员函数调用成员变量,本质都是用了this指针(只不过被隐藏了),所以在写代码的时候,我们可写可不写(当然后续也有特殊应用)
this指针的储存位置我们认为放置在栈中,尽管比较主流和先进的编译器会对这件事做出优化。
四、类的默认成员函数
默认成员函数就是用户没有显式实现,编译器会自动实现的函数。其中比较重要的是前四个,也就是图片上的四个函数。剩余的两个函数主要实现普通对象和const对象取地址,不那么重要,在此就不过多讲解。
4.1构造函数
构造函数是特殊的成员函数。尽管名字是构造,实际上是起到初始化对象的作用,也就是在C语言中常写的Init函数。如此取代的原因是,实例化对象的时候,编译器会自动调用构造函数,而不用我们再人为调用。
构造函数具有以下特点:
1、名字和类名相同。
2、无返回值,也不需要写返回值类型。(函数名前什么都不用加)
3、对象实例化时,构造函数将会自动调用。
4、构造函数可以重载,但仍然需要满足重载的条件。
5、如果没有显式写出构造函数,系统将会默认生成一个无参的默认构造函数并且调用,但一旦写出,系统将会调用显式写出的构造函数,并且不会再生成默认构造函数。
6、默认构造函数:个人命名为无实参构造函数,原因是默认构造函数分为无参构造函数,全缺省构造函数,以及系统自动生成的构造函数。三种情况的默认构造函数只能且必须存在一个,否则会出现编译器不知道调用哪一个的情况。
7、当没有显式写出构造函数的时候,系统默认生成的默认构造函数将会对内置类型的成员变量进行初始化,但是不会直接对自定义类型的成员变量进行初始化,而是调用自定义类型中的默认构造函数,如果没有合适的构造函数,将报错。
上代码,看的更清晰:
class Date {
public:
//无参构造函数
Date() {
_year = 1970;
_month = 1;
_day = 1;
}
//全缺省构造函数
Date(int year=1970,int month=1,int day=1) {
_year = year;
_month = month;
_day = day;
}
//带参构造函数
Date(int year,int month,int day) {
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
值得注意的是:
如果将无参和全缺省构造函数均注释掉,只留下带参构造函数。在使用无参调用的时候,系统将会报错。原因是显式写出了构造函数,系统不再会自己生成默认构造函数,而留下的带参构造函数不是默认构造函数,所以会报错。
Date d1;//在调用默认构造函数的时候,不需要加()表示无参,
//否则,编译器会不知道是函数声明还是实例化对象.
Date d2(2024,7,18);
实际应用上,个人认为如果成员里没有需要申请空间的(如new,malloc等),可以不写默认构造函数,尽管系统的默认构造函数初始化的成员变量值不定。(因为C++标准里没有规定)
所以能写尽量写,将不确定因素控制在自己能控制的范围内。
4.2析构函数
析构函数与构造函数概念正好相反,是用于销毁对象的。在对象的生命周期结束的时候,系统会自动调用析构函数。
析构函数具有以下特点:
1、析构函数名也是类名,但在类名前加"~"表示这是析构函数。
2、无参,无返回值。同构造函数
3、系统在对象生命周期结束的时候,会自动调用析构函数,因此只需要在类中写好析构函数即可,不需要手动调用了。
4、如果没有显式写出析构函数,那么系统会生成一个默认的析构函数。
5、对于自定义类型的成员变量,即使我们显式写出析构函数,系统也会直接调用这一自定义类型的析构函数,如果没有,则报错。
6、类中没有申请空间的,可以不写析构函数。如有,请务必写出析构函数,否则会造成内存泄漏。
7、C++定义,后定义的先析构。
~Date(){
_year = 0;
_month = 0;
_day = 0;
}
4.3拷贝构造函数
拷贝构造函数,一种特殊的构造函数。其第一个参数一定是对自身类这一类型的引用,且其他额外参数都有默认值。
拷贝构造函数的特点如下:
1、拷贝构造函数是构造函数的一个重载。
2、拷贝构造函数的返回值类型就是类类型。
3、拷贝构造函数的第一个参数必须是对自身类类型的引用,否则会发生无限递归。
4、C++规定自定义类型对象进行拷贝行为的时候,必须调用拷贝构造,所以自定义类型传值传参和传值返回都需要拷贝构造(换句话说,能用引用传参/返回,就用引用,在这一细节上提升程序效率)
5、未显式定义拷贝构造函数,系统自动生成的拷贝构造函数会对内置类型采用值拷贝,对自定义类型调用它的拷贝构造函数。
类似于上面举例使用的Date类,可以不写拷贝构造函数,因为均是内置类型。
Date(Date& d){
_year=d._year;
_month=d._month;
_day=d._day;
}
int main(){
Date d1;//默认构造
Date d2(d1);//拷贝构造
Date d3=d1;//这样写也是拷贝构造
}
五、运算符重载
5.1运算符重载
当运算符运用于类类型的情况下,C++允许程序员对运算符赋予新的意义。
当运算符重载作为成员函数的时候,运算符参数个数比作用对象数量少一个,因为this指针指向第一个对象,而作为全局函数的时候,运算符参数个数和作用对象数量相同。
运算符重载后,优先级和结合性与原先相同。
示例:(放在类中)
bool operator==(const Date& d) {
return this->_year==d._year
&& this->_month=d._month
&& this->_day=d._day;
}
其中bool是返回类型,operator是独特的运算符重载类型,this指针指向第一个对象。
(全局情况)
bool operator==(const Date&d1,const Date&d2){
return d1._year==d2._year
&& d1._month==d2._month
&& d1._day==d2._day;
}
Date d1;
Date d2(2024,7,18);
d1==d2;
d1.opeator==(d2);//运算符重载是成员函数的时候,这两种都可以。
//如果写成全局,则以下两种可以
d1==d2;
operator==(d1,d2);
需要注意的是,以下五个运算符不能重载:
.* :: sizeof ?: .
还需要注意的是:
重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,无法很好的区分。 C++规定,后置++重载时,增加一个int形参,跟前置++构成函数重载,方便区分。
重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第一个形参位置,第一个形参位置是左侧运算对象,调用时就变成了 对象<<cout,不符合使用习惯和可读性。重载为全局函数把ostream/istream放到第一个形参位置就可以了,第二个形参位置当类类型对象。
5.2赋值运算符重载
赋值运算符是运算符重载里面比较特殊的情况,只能定义为成员函数,用于给两个已经存在的对象进行赋值操作(区别于拷贝构造函数,拷贝构造函数本质仍是构造,所以拷贝构造针对于用一个已经存在的对象拷贝给一个未实例化的对象)
Date& operator=(const Date& d) {
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
赋值运算符重载由于有返回值,且返回值不会被因函数空间销毁而销毁(*this),所以建议利用引用返回来减少拷贝。同时传参时也建议使用引用减少拷贝。
没有显式实现时,编译器会默认生成一个运算符重载,并实现值拷贝,对于自定义类型,编译器会调用其重载的赋值运算符。(也就是示例)
值得注意的是,像示例中的运算符重载可以不显式写出(因为Date类的成员变量均为内置类型)。但如果申请空间,请务必写出对应的运算符重载。
5.3const成员函数
const成员函数就是用const修饰的成员函数。
唯一需要注意的是,如何对this指针使用const?
void Print()const {
cout << _year << "-" << _month << "-" << _day << endl;
}
将const写在括号后,就代表了对this指针使用了const。
隐含的Date*const this指针(this指针不能改指向)就变成了const Date*const this了,内容和指向均不能改。
5.4取地址运算符重载
取地址运算符重载并不需要显式实现,系统自动生成的足够我们使用。
除非你想恶搞你的室友,或者故意写出bug。
Date* operator&()
{
return this;//正常情况
return nullptr;//bug情况
}
六、初始化列表
之前说构造函数的时候,我们说构造函数是为了初始化一个类,免去手动初始化的情况,但实际上,初始化还有一种方式:初始化列表。
Date(int year)
:_year(year),_month(0),_day(0)
{
_year = 1970;
_month = 1;
_day = 1;
}
初始化列表写在默认构造函数的形参后,主体前。以冒号开头,逗号连接多个参数。括号内代表初始化的结果。
尽量使用初始化列表初始化,因为那些你不在初始化列表初始化的成员也会走初始化列表,如果这个成员在声明位置给了缺省值,初始化列表会用这个缺省值初始化。如果你没有给缺省值,对于没有显式在初始化列表初始化的内置类型成员是否初始化取决于编译器,C++并没有规定。对于没有显式在初始化列表初始化的自定义类型成员会调用这个成员类型的默认构造函数,如果没有默认构造会编译错误。
给缺省值情况如下:
class Date {
public:
Date(int year)
:_year(year),_month(0)
{
}
private:
int _year=0;
int _month=0;
int _day=0;
};
此时_day会被初始化为0。
总结初始化顺序情况:
1、写在初始化列表里的,就直接按初始化列表来初始化。
2、未显式写在初始化列表里的,分两种情况:
(一)、在声明处给缺省值的,按缺省值初始化。如没有,则编译器自己决定如何初始化。
(二)、自定义类型成员自行默认构造,如没有,报错。
Date(int year,int month,int day)
:_year(year),_month(0),_day(0)
{
_year = year;
_month = month;
_day = day;
}
//肯定有同学想知道这种情况下如何初始化。对此已进行过测试
//会按照传参进行初始化,尽管先走了初始化列表,但后续的赋值使对象的内置类型的值是传参的值。
需要注意的是:
初始化列表中按照成员变量在类中声明顺序进行初始化,跟成员在初始化列表出现的先后顺序无关。建议声明顺序和初始化列表顺序保持一致。(会有部分恶心人的面试题出这个概念,请务必当心)
部分成员变量在初始化列表中一定要写,如引用和const代表的常量,否则会报错。
七、类型转换
C++支持内置类型隐式类型转换为类类型对象,需要有相关内置类型为参数的构造函数,构造函数前面加explicit就不再支持隐式类型转换。
Date d1=1;
实际上这就是一个类型转换,表达式左侧为Date类型,右侧为int类型。
Date(int n)
:_year(n),_month(0),_day(0)
{
}
上面的代码为对应的构造函数,初始化结果就变成了1-0-0。
八、static成员
用static修饰的成员变量,称为静态成员变量。初始化一定要在类外初始化。
static成员具有以下特点:
1、static不属于具体的某个对象,而属于所有类对象。
2、static修饰的成员函数叫做静态成员函数,没有this指针。
3、静态成员函数不可以访问非静态的成员变量,因为没有this指针。
4、突破类域就可以访问static成员。
5、static成员受访问限制符限制。
6、static成员变量不走初始化列表。
九、友元
友元应用于友元类和友元函数。
友元函数正如其名,我们用friend来声明某一函数后,该函数就可以突破类的限制,来访问另一个类的成员。
class Date{
friend class A;
friend void print(A& aa);
}
这样写,可以使得print这一并非在Date类中的成员函数,得以使用Date类的成员。
友元有以下特点:
1、友元函数可以在类的任何一个位置声明,且不受访问限制符的限制。声明时,只需要在前面增加一个friend关键字即可。同样,友元类也只需要在类的某个位置声明,前面加一个friend即可。
2、友元函数可以访问类的private和protected的成员。
3、一个函数可以是多个类的友元函数。
4、友元类不具有相互性和传递性。如A是B的友元,B在未声明的时候并不是A的友元。
再如A是B的友元,B是C的友元,但A不是C的友元。
5、友元虽然好用,但不利于封装。切勿贪杯。
十、内部类
内部类,顾名思义,是定义在一个类内部的类。有点类似于命名空间的嵌套定义。
内部类默认是外面一层类的友元。
而在类的内部定义一个类的目的是封装,因为这个类收到外面一层类的类域和访问限制符限制。
尽管如此,内部类仍然是一个独立的类,外面一层类定义时,内部类不需要定义。
如果一个类设计出来,就是为了给另一个类主要使用的,我们就可以考虑设定其为内部类。
class A{
class B{
};
};
这样写,B就是A的内部类。
十一、匿名对象
匿名对象,和结构体中的匿名结构体类似。
匿名对象的生命周期只在实例化的那一行里,通常我们将其用作临时定义一个对象,且懒得起名字的时候。
class Solution {
public:
void Print(int n) {
cout<<n<<endl;
}
};
int main(){
Solution().Print(10);
return 0;
}
匿名对象现在看起来还不那么好用,但用途在后续的操作中一定会显现出来,敬请期待。
本篇内容到此结束,谢谢大家的观看!
觉得写的还不错的可以点点关注,收藏和赞,一键三连。
我们下期再见~
往期栏目: