C++类和对象详细笔记
- 一、类的定义
- 1.1 类的定义格式
- 1.2 访问限定符
- 1.3 类域
- 二、实例化
- 2.1 实例化的概念
- 2.2 对象大小
- 三、this指针
- 四、类的默认成员函数
- 五、构造函数
- 六、析构函数
一、类的定义
1.1 类的定义格式
类的定义格式跟结构体是很相像的,首先是定义类的关键字class,然后是类的名字加{};括号内的变量称为成员变量,函数和实现方法称为成员函数。形如:
class stack
{};
为了区分成员变量,可以在变量的左右加一些特殊的符号,比如“year”可以在前面加“_”变成“_year”,这是根据自己的个人习惯或喜好定的。加符号是为了避免以下的情况发生:
/需避免的情况
class stack
{
public://成员函数void Init(int year,int month,int day){year=year;month=month;day=day;}
private://成员函数int year;int month;int day;
};
/正确使用方法(个人习惯)
class stack
{
public:void Init(int year,int month,int day){_year=year;_month=month;_day=day;}
private:int _year;int _month;int _day;
};
C++中struct升级成了类,但是我们用的比较多的还是class,因为在没有加访问限定符的时候struct默认为公有,而class默认为私有(访问限定符就是以上代码中的“private”和“public”,在下面我们会讲到)
定义在类里面的成员函数默认为inline(inline在前一篇博客《C++入门基础》中讲到)
1.2 访问限定符
C++的封装模式,需要用类和访问限定符共同进行操作,在类里面的成员变量和成员函数选择性地提供给用户使用,若想让外部直接访问或修改的,可以用访问限定符“public”进行公有化,而不想给外部访问和修改的可以使用“private”和“protected”进行限制,让其变为私有的。
- 访问限定符的权限 作用域:
从第一个访问限定符开始到下一个限定符为止,就是该访问限定符的作用域。若访问限定符后面没有其他访问限定符,那么该限定符的作用域就直到类结束。
例:
class stack
{
public:void Init(int year,int month,int day){_year=year;_month=month;_day=day;}
private:int _year;int _month;int _day;
};
上面的代码块public的作用域就是成员函数,private的作用域就是成员变量。前面说到class默认为私有,就是不加任何访问限定符时,整个类域都是私有的;而struct就是公有的。
1.3 类域
C++有多个域,命名空间域、全局域、局部域,其中也包括类域。类定义了一个新的作用域,域中所有成员和函数都在作用域内,想要在类外访问成员,必须用域作用访问限定符“::”,指明成员在哪个域内。若不加域作用访问限定符,编译器会自动默认在全局域找,找不到就会报错。
正确写法:
void Stack::Init(int n)
{array = (int*)malloc(sizeof(int) * n);if (nullptr == array){perror("malloc申请空间失败");return;}capacity = n;top = 0;
}
所以当函数声明和定义分离时,要指定类域并使用域作用访问限定符“::”。
二、实例化
2.1 实例化的概念
类和对象的关系是一对多,一个类可以实例化出n多个对象。怎么去理解呢?类就像一张建房子的图纸,它仅仅是一张图纸,我能知道图纸里房子的构造但是不能住人;而实例化呢就是将图纸里的房子一比一地复原,里面是可以住人的。而类呢就像图纸一样,我可以知道里面的参数有哪些数据,有哪些方法,但是还不能使用。所以类的实例化就像按照图纸一样一比一地造出实物。没有实例化的类不会分配空间,用类实例化出的对象才会分配空间。
#include<iostream>
using namespace std;
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()
{// d1和d2就是Date实例化出来的对象Date d1;Date d2;d1.Init(2024, 3, 31);d1.Print();d2.Init(2024, 7, 5);d2.Print();return 0;
}
程序运行结果:
2.2 对象大小
前面提到了实例化的概念,那么实例化出的对象的大小是多大?对象大小的计算要不要加上成员函数?答案是不要加上。因为如果我要实例化出n多个对象,并且每个对象都要调用该函数,那么就显得有点太扯淡了。我要存n多次同一个函数,太浪费空间了。我们通过汇编层面来分析也可以得出此结论:
汇编代码是按照上述实例化代码通过反汇编得到的
这里我们就可以看出,函数的地址是一样的
所以从上面就可以总结出:实例化出的对象的大小只包含成员变量而不包含成员函数。
对象大小该如何计算? 这时候就涉及到C语言之前学到的内存对齐规则。
- 第一个成员在与结构体偏移量为0的地址处。
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
- 对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
- 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
所通过以上内存对齐规则,计算如下对象大小:
//示例一
class A
{
public:void Print(){cout << _ch << endl;}
private:char _ch;int _i;
};
那么为什么要内存对齐?
因为读取数据是跟硬件的设计相关的,是从整数倍的位置开始读。如果没有内存对齐,那么读取数据时就不能一次性准确地读取每一个数据,有些数据有可能会读两到三次才能读取完成,这就降低了程序运行的效率。有了内存对齐就能一次性准确地读取每一个数据,但是会像如上示例一解析图中浪费空间,等同于用空间来换时间。
//示例二
class B
{
public:void Print(){//...}
};
示例三
class C
{};
上面示例二和示例三的对象大小不是0,而是1;像这样没有成员变量的类叫作空类,由于定义就要开空间,而且定义了类就应该能证明它确实存在过,所以像这样的空类大小就是1个字节。给一个字节纯粹是为了占位标识存在。
三、this指针
前面实例化概念的运行结果显示:
实例化出的d1和d2调用的都是同一个print函数,凭什么两次调用打印出来的日期不同呢?这时就不得不提C++引入的一个隐含的this指针了。
#include<iostream>
using namespace std;
class Date
{
public://void Init(Date* const this,int year,int month,int day)//实际void Init(int year, int month, int day)//表象{_year = year;_month = month;_day = day;}//void Print(Date* const this)//实际void Print()//表象{cout << _year << "/" << _month << "/" << _day << endl;}
private:int _year;int _month;int _day;
};
int main()
{// d1和d2就是Date实例化出来的对象Date d1;Date d2;d1.Init(2024, 3, 31);//表象d1.Print();//d1.Init(&d1,2024, 3, 31);//实际//d1.Print(&d1);d2.Init(2024,7,5);//表象d2.Print();//d2.Init(&d2,2024, 7, 5);实际//d2.Print(&d2);return 0;
}
this指针是c++一个隐含的指针,说它隐含其实就是它不需要显示地写出来。this指针默认为函数第一个参数,位置是固定的。this指针不需要自己写,编译器会自己处理,不能显示地写出来。
**但是可以显示地使用this指针:
class Date
{
public:void Init(int year, int month, int day){this->_year = year;this->_month = month;this->_day = day;}void Print(){cout << this->_year << "/" << this->_month << "/" << this->_day << endl;}
private:int _year;int _month;int _day;
};
四、类的默认成员函数
默认成员函数就是用户不需要显示地去实现,编译器会自动默认生成的成员函数。类里面我们不写编译器可以直接生成的成员函数一共有六个,其中前四个是比较重要的。C++11又多添加了两个,移动赋值和移动构造,C++11之后一共是八个,这里重点解释C++11之前的六个。
五、构造函数
构造函数是一个特殊的成员函数,它的功能不是开空间构造一个对象,而是实例化时初始化对象,也就是在实现如Stack时完成初始化,功能和Init函数相同,可以替代Init函数。
构造函数的特点:
- 函数名和类名相同
- 没有返回值(不需要加void)
- 实例化对象时会自动调用对应的构造函数
- 构造函数可以重载
#include<iostream>
using namespace std;
class Date
{
public:// 1.⽆参构造函数Date(){_year = 1;_month = 1;_day = 1;}void Print(){cout << _year << "/" << _month << "/" << _day << endl;}
private:int _year;int _month;int _day;
};
int main()
{Date d1; // 调⽤默认构造函数d1.Print();return 0;
}
运行结果:
构成函数重载:
// 1.⽆参构造函数Date(){_year = 1;_month = 1;_day = 1;}// 2.带参构造函数Date(int year, int month, int day){_year = year;_month = month;_day = day;}// 3.全缺省构造函数Date(int year = 1, int month = 1, int day = 1){_year = year;_month = month;_day = day;}
按照如上情况,有了全缺省函数构造就不需要带参函数构造了,而无参构造又和全缺省构造构成函数重载,存在调用歧义,所以只写全缺省函数构造就可以了:
#include<iostream>
using namespace std;
class Date
{
public:Date(int year = 1, int month = 1, int day = 1){_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(2025); d1.Print();d2.Print();return 0;
}
运行结果:
- 如果类中没有定义构造函数,那么编译器会自动生成一个无参的默认构造函数,用户一旦定义了编译器就不会自动生成了。
- 默认构造函数不只是编译器自定生成的那个,无参构造函数、全缺省构造函数、编译器自动生成的构造函数统称为默认构造函数,默认构造函数是以上三者的统称。总结一下就是:不需要传参的构造就叫默认构造,自己定义一个方便记的名字就叫“无实参构造”。
- 编译器自动生成的默认构造函数,对内置类型成员变量的初始化没有要求,也就是说否会初始化主要看编译器的行为。而对于自定义类型成员变量,需要调用该成员变量的默认构造,如果该成员变量没有对应的默认构造可以调用,那么编译器就会报错。这时我们要对成员变量进行初始化就要用到初始化列表,在下一篇文章《类和对象(下)》中讲到。
对于自定义类型能使用编译器自动生成的构造函数的实例:
#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:Stack(int n = 4){_a = (STDataType*)malloc(sizeof(STDataType) * n);if (nullptr == _a){perror("malloc申请空间失败");return;}_capacity = n;_top = 0;}// ...
private:STDataType * _a;size_t _capacity;size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public://编译器默认?成MyQueue的构造函数调?了Stack的构造,完成了两个成员的初始化
private:Stack pushst;Stack popst;
};
int main()
{MyQueue mq;return 0;
}
通过调试看MyQueue(通过两个栈实现队列(代码不完全))有没有初始化:
调试结果显示MyQueue已经完成了初始化,但是前面代码显示MyQueue并没有写构造函数,说明这时调用的默认构造是编译器自己生成的,此时它完成了初始化的功能。因为Stack有自己的默认构造,而编译器给MyQueue生成的构造就可以去调用Stack的构造,所以MyQueue就不需要自己去写构造函数(编译器自动生成的就可以用了)。
对构造函数进行概括:大多数情况下编译器自动生成的默认构造不满足用户对构造函数的需求,所以大多数构造函数都需要我们自己去实现,只有少数情况如MyQueue且Stack有默认构造时,编译器自动生成的默认构造函数就可以使用。
六、析构函数
析构函数的功能与构造函数是恰恰相反的,构造函数是完成初始化,而析构函数完成的是销毁,功能与Stack里的Destroy函数是一样的。但它不是对对象本身进行销毁(局部对象时存在于栈桢中的,函数结束栈桢销毁时对象就会自动销毁,不需要我们去销毁),而是对对象中的资源进行清理释放。
析构函数的特点:
- 析构函数的名字就是类名前加“~”符。
- 没有返回值也没有参数(不需要加void)。
- 一个类只能有一个析构函数;若未显示定义,编译器会自动生成默认的析构函数。
- 在对象生命周期结束时,编译器会自动调用析构函数。
- 与构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员变量不作处理,对自定义类型成员变量无论什么情况都会去调用它的析构。
- 如果有资源申请就一定要写析构(Stack);若没有资源申请就不用写析构(Date);有少数如MyQueue编译器自动生成的析构就可以使用,不用自己去写:
#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:Stack(int n = 4){_a = (STDataType*)malloc(sizeof(STDataType) * n);if (nullptr == _a){perror("malloc申请空间失败");return;}_capacity = n;_top = 0;}~Stack(){cout << "~Stack()" << endl;free(_a);_a = nullptr;_top = _capacity = 0;}
private:STDataType* _a;size_t _capacity;size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public://编译器默认⽣成MyQueue的析构函数调⽤了Stack的析构,释放的Stack内部的资源//即便显⽰写析构,也会⾃动调⽤Stack的析构/*~MyQueue(){}*/
private:Stack pushst;Stack popst;
};
int main()
{Stack st;MyQueue mq;return 0;
}
通过调试就能看出编译器为MyQueue生成的析构调用了Stack的析构。