06【C++ 初阶】类和对象(上篇) --- 初步理解/使用类
文章目录
- 全文总结
- 前言
- 初识面向对象
- 类
- 1. “自定义”一个类
- 1.1 封装
- 1.2 访问限定符
- public、private:
- 1.3 作用域
- 1.4 类成员函数的声明定义分离
- 类的两种定义方式:
- 2. 实例化对象
- 2.1 类的大小计算
- 2.1.2 类对象成员的存储方式
- 类对象为什么不采用第2种方式而采用第3种方式存储
- 2.1.3 当多成员变量时的内存大小计算(内存对齐)
- 3.this指针
- 3.1 this指针的特性
全文总结
- 面向对象是一种思想,将事情划分为一些对象,贴近现实,降低耦合。用有明确责任、边界清晰、相互协作的“整体”(对象)来建模系统,代替零散的全局数据和函数调用。
- 类就是C++面向对象的产物,它是C++中的类型,只不过交给我们去自定义。
- 实现面向对象,就需要做到:封装和抽象,类的封装是实现面向对象的基石;类的抽象就是公开接口,隐藏细节 — 抽象成一个整体,而不是零散的过程。
- 为了实现封装,C++的类中也可以定义成员函数。
- 为了实现抽象,C++给出了三个访问限定符,让工程师自己决定这个类哪些东西可以被访问,哪些东西不行,从而降低耦合度。
- 类的封装和抽象本质上还是一种管理,让使用者不必关注类内部实现细节,只需要知道怎么使用。
- 类也有自己的类域,虽然不会报错,我们为了可读性,还是尽量将类中的变量用“_”区分。
- 类的成员函数也可以声明定义分离,如果直接定义在类中的成员函数,默认是内联的。
- 平常我们对一个类型的定义,其实是在声明它内部的成员变量和成员函数;若成员函数在类内实现(有函数体),则同时完成了该成员函数的定义。
- 当我们真正使用类型去实例化成一个对象的时候,它的成员变量才被定义。
- 类对象采用保存成员变量,但是成员函数存放在公共代码区的存储方式。
因为这种方式的函数调用其实是像我们正常的函数调用一样的,它可以保持最低的访存次数。 - 我们的类的成员变量的存储方式,需要内存对齐,这样以空间换取了内存的访问速度。
- 我们的成员函数,会被编译器自动的加上一个形参(this指针),为的是在调用函数时让函数修改正确的对象。
- 普通函数的调用处,最终都是一个Call地址跳转而已,我们函数的参数,是由函数的调用方存入寄存器或者是栈,在CPU执行到我们的“函数体”的时候,我们的“函数体”直接操作寄存器或者栈中的数据即可,这就是传参的本质。
前言
通过00【C++ 入门基础】前言得知,C++是为了解决C语言在面对大型项目的局限而诞生:
C语言面对的现实工程问题(复杂性、可维护性、可扩展性、安全性)
C语言面临的具体问题:
1.struct 数据公开暴露,函数数据分离,逻辑碎片化。(复杂性、安全性)
2.修改数据结构,如 struct 新增字段,可能导致大量相关函数需要修改。(可维护性)
3.添加新功能常需修改现有函数或结构体,易引入错误。(可扩展性)
4.资源(内存、文件句柄)需手动管理,易泄漏或重复释放。(安全性)
C++的类,解决了上述的所有问题,而本文《类(上篇)》,所讲的是类如何解决上述第1点和第2点问题。
初识面向对象
面向对象OOP,顾名思义就是关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。(C语言是面向过程的)
面向过程: 主要关注整个过程的完成流程,每个过程之间关联度强。
一个对象的属性,分为数据属性和行为属性,即它的特征和它能做什么。
面向对象:将洗衣服这个事情拆分成多个对象,每个对象有它各自的属性,比如人有手搓衣服的行为属性,有分男人女人的数据属性,衣服有干净与否的数据属性,也有被洗、被拧干的行为属性。
这样每个对象之间的耦合度就会非常低,互相之间就互不影响。
因为C语言是面向对象的,所以如果我们要去修改其中一个过程,那么就会变得非常的困难,比如放衣服的过程,我要给放衣服的函数加一个参数,意义为放多少件衣服,那么后续的函数就都需要一起修改,来得知我前面放了多少件衣服。
而采用面向对象的方法,衣服对象里面可以带一个数据属性参数表示衣服的件数,那么我们其他的类要想得知有多少件衣服,就只需要指定的访问衣服对象的属性即可。
类,就是C++实现面向对象的产物。
类
类其实就是C++中的类型(像int一样),只不过是交给我们去自定义的,我们将一个现实对象的所有属性(数据和行为)抽离出来,放到这个我们自定义的类型中去,最后这个类型实例化出的变量,就是一个程序中的对象!
比如,我们抽离出现实中桌子这个对象的所有属性 — 数据属性(长、宽、高、材质、坚硬程度、是否有物体放在我的上面)/ 行为属性(可以放东西,可以被举起来移动)等等,将所有的属性都用C++代码的方式,放到我们的自定义类型Table中,那么最后我们用这个类实例化出的一个变量table001,就是我们程序世界中的一个“桌子”对象。
其实在我们抽离现实对象的属性,并让我们自定义类型,和它最终实例化出的对象更加的贴近现实的过程中,我们已经做到了:封装,即将一个对象的数据属性和行为属性封装在一起;
但是要让我们的类型更像现实,耦合性更低,我们的类型还需要做到:抽象,就是开放可以被别人访问的属性,而隐藏属性具体的实现细节。
比如,我们程序中的对象桌子,不需要知道其长宽高(数据属性),就可以在它的上面放东西(行为属性)。
实现面向对象的基石:
封装,即将所有的事物的行为属性、数据属性集成在一起称为一个“类”;
抽象,即将“类”的行为属性(接口)公开,数据属性(细节)隐藏 — 抽象成一个整体,而不是零散的过程。
接下来来看,C++如何实现封装和抽象的。
1. “自定义”一个类
C++中“定义”类,使用class关键字:
class className
{// 类体:由成员函数和成员变量组成int length;int width;int height;
};
int main()
{className ch1;return 0;
}
class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略。
而且,结构体struct,在C++中,也被升级为了类:
struct Table
{int length;int width;int height;
};int main()
{struct Table Ta1; //C语言的写法Table Ta2; //C++的写法return 0;
}
结构体也保留了C语言的写法,为了向前兼容。
它们之间的区别,看下面的讲解
1.1 封装
C语言结构体中只能定义变量,在C++中,类内不仅可以定义变量,也可以定义函数,也就是将我们的一个现实事物的数据属性和行为属性放在一起,这就是封装。
#include<iostream>
using namespace std;
class Chair
{void init(int L, int W, int H){length = L;width = W;height = H;}void show_information(){cout << "长:" << length << " 宽:" << width << " 高:" << height;}int length;int width;int height;
};struct Table //C++中struct升级为类,所以它也可以做封装操作.
{void init(int L, int W, int H){length = L;width = W;height = H;}void show_information(){cout << "长:" << length << " 宽:" << width << " 高:" << height;}int length;int width;int height;
};
将一个事物的的行为属性也封装进类中。
类体中内容称为类的成员:
类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数。
那么我们访问成员(成员变量、成员方法)的方式,也和我们C语言时访问成员一样:
int main()
{Table Ta;Ta.init(10, 5, 1);Ta.show_information(); //最后打印: "长:10 宽:5 高:1"Ta.length = 52013140;Ta.show_information(); //最后打印: "长:52013140 宽:5 高:1"return 0;
}
int main()
{Chair Ch;Ch.init(10, 5, 1); //报错:“Chair::init”: 无法访问 private 成员(在“Chair”类中声明)Ch.show_information(); //报错:“Chair::show_information”: 无法访问 private 成员(在“Chair”类中声明)return 0;
}
那么我们这里遇到两个问题:
- 为什么我们的struct定义的变量的时候可以访问,而我们class定义的反而不可以访问了呢?
- 我们既然上面的Ta变量可以直接访问它的成员变量,那不是别人想改就改吗?要是改成起奇奇怪怪的值怎么办?
原因就在于,我们C++为了让我们的类更贴近现实,更低耦合,它在封装的基础上,还实现了抽象,看下面的解释。
1.2 访问限定符
抽象,即将“类”的行为属性(接口)公开,数据属性(细节)隐藏 。
为什么说我们的抽象,可以让类更贴近现实,耦合更低呢?
因为在现实生活中,事物之间都是只能看到表面,所以彼此之间有很强的独立性,正如:
我可以和你聊天(开放接口),但是你永远触及不到我内心真正的想法(封装内心),
所以人和人之间感情才如此淡,所以人和人之间的关联度才如此低,所以人和人之间的耦合性才如此低。
C++对于类的做法,也是一样的,它规定了特定的关键字给工程师使用,可以让工程师自己决定,这个类的成员中,哪些东西是可以开放给别人的,哪些东西是需要封存在内心深处的,无法被被人触及的,这样,不同类、变量之间的耦合度就降低了。
三个访问限定符:
我们这里先认为,protected和private是一样的,不做区分所以就先不说protected。
public、private:
我们的这两个访问限定符,都是在类中使用的关键字,正如名字一样,public修饰的,是可以被类外访问的,private修饰的,是不可以被类外访问的,只能在类的内部使用。
我们上面的class定义的
Chair Ch;
变量为什么不能访问它的成员,原因就在于我们的class定义的类,它的所有成员默认就是用private修饰的;而我们struct可以访问,正因为它的所有成员默认是用public修饰的。
#include<iostream>
using namespace std;
class Chair
{
public: //我们将类中的两个成员函数函数用public公开给类外,那么类外就可以访问了.void init(int L, int W, int H){length = L;width = W;height = H;}void show_information(){cout << "长:" << length << " 宽:" << width << " 高:" << height;}
private:int length;int width;int height;
};struct Table //C++中struct升级为类,所以它也可以做封装操作.
{
public:void init(int L, int W, int H){length = L;width = W;height = H;}void show_information(){cout << "长:" << length << " 宽:" << width << " 高:" << height;}
private: //我们将类中的三个成员变量用private私有,那么类外就访问不了了.int length;int width;int height;
};
int main()
{Table Ta;Ta.init(10, 5, 1);Ta.show_information(); //最后打印: "长:10 宽:5 高:1"//Ta.length = 250; //报错: “Table::length”: 无法访问 private 成员(在“Table”类中声明)Chair Ch;Ch.init(10, 5, 1);Ch.show_information(); //最后打印: "长:10 宽:5 高:1"return 0;
}
这样我们就可以正常的访问成员变量,完美的解决了上面的两个问题:
- 为什么我们的struct定义的变量的时候可以访问,而我们class定义的反而不可以访问了呢?
- 我们既然上面的Ta变量可以直接访问它的成员变量,那不是别人想改就改吗?要是改成起奇奇怪怪的值怎么办?
封装和抽象不仅是一种对于耦合度的考虑,还让使用者更加方便:
封装本质上是一种管理,让用户更方便使用类。比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如何设计的,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以及键盘插孔等,让用户可以与计算机进行交互即可。
类的封装,亦是如此。
1.3 作用域
在 01【C++ 入门基础】命名空间/域中我们知道,域(Scope) 是一个核心概念,它定义了程序中标识符(如变量、函数、类名)的可见性与生命周期范围。
而类也有自己的类域,当我们定义了一个新的类,类体{ … } 内的所有声明都属于这个类的作用域。
class MyClass {// 从这里开始,进入 MyClass 的类域int x; // 在 MyClass 域中声明的成员变量void func(); // 在 MyClass 域中声明的成员函数class NestedClass {}; // 在 MyClass 域中声明的嵌套类
}; // MyClass 的类域结束
成员变量命名规则的建议:
class Date
{
public:void Init(int year){// 这里的year到底是成员变量,还是函数形参?year = year;}
private:int year;
};
如果我们类中成员函数的形参和内部成员变量是一样的,那么对于不清楚的使用这来说,就很难区分了,虽然它不是错的。
// 所以一般都建议这样
class Date
{
public:void Init(int year){_year = year;}
private:int _year;
};
将类内的成员,加上一个下划线的前缀,或者其他的区分方式都可以。
1.4 类成员函数的声明定义分离
前面我们知道了,类要做封装,所以,一个类的成员函数和成员变量,是需要放在一起,同属于一个类的,但是其实我们类的成员函数,也是可以声明定义分离的。
类的两种定义方式:
- 声明和定义全部放在类体中。
- 类声明放在.h文件中,成员函数定义放在.cpp文件中,注意:成员函数名前需要加类名和域作用限定符
::
,指定这个函数是属于该类的成员。
两种方式都可以,但是建议使用第二种。
那么如果我想让一个成员函成为内联,我可以声明定义分离,并在类中的声明处使用inline修饰它吗?
不可以,在05【C++ 入门基础】内联、auto、指针空值中我们知道,内联不可以声明定义分离,如果想让一个类的成员函数是内联,那么只能直接将它定义在类中,另外,直接定义在类中的成员函数,本身默认内联。
2. 实例化对象
对于我们函数来说,声明和定义的区别是什么呢?
声明只有函数名、参数、返回值,没有函数体。
对于变量呢?
是有没有开空间,如果我们指定一个变量,但是它实际上没有开空间,那么我们一般就说它是对一个变量的声明!就像我们
extern int A;
这就是声明了一个外部变量。
所以在我们定义类时,成员变量,它是没有开辟空间的,所以定义类的时候,对于内部的成员变量来说,是一种声明,
只有当我们用类创造出一个类的变量,开辟了这个类的变量空间时,类中的成员变量才真正的被定义!
使用我们定义的类,去创造变量的过程,我们称为类实例化成对象,或者称为类的对象的定义。
对象: 用自定义类去定义出的变量。
实例化:用自定义类去定义出变量的这个过程。
注意,定义类和定义类对象,有本质区别:
语句 | 标准术语 | 实体类别 | 内存影响 |
---|---|---|---|
class Point { … }; | 类定义 | 类型 | 无直接分配 |
Point p; | 对象定义 | 对象 | 分配存储空间 |
extern Point p; | 对象声明 | 对象(引用) | 无分配 |
class Point; | 类声明 | 类型(引用) | 无分配 |
所以其实我们平时定义类(定义类型)的过程就是:
同时完成类中成员变量的声明和成员函数的声明;若成员函数在类内实现(有函数体),则同时完成了该成员函数的定义。
做个比方。类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,只设计出需要什么东西,但是并没有实体的建筑存在,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间。
2.1 类的大小计算
问题:类中既可以有成员变量,又可以有成员函数,那么一个类的对象中包含了什么?如何计算一个类的大小?
2.1.2 类对象成员的存储方式
我们下面来猜测一下,类对象的成员变量,到底是存在哪里的:
- 1.对象中包含类的各个成员:
缺陷:每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。那么如何解决呢?
-
2.代码只保存一份,在对象中保存存放代码的地址:
这种方式好像可行? -
3.只保存成员变量,成员函数存放在公共的代码段:
那么对于我们的第2和第3种方法,到底是使用哪一种呢?我们来验证一下:
// 类中既有成员变量,又有成员函数
class A1 {
public:void f1() {}
private:int _a;
};// 类中仅有成员函数
class A2 {
public:void f2() {}
};// 类中什么都没有---空类
class A3
{
};int main()
{cout << sizeof(A1) << " " << sizeof(A2) << " " << sizeof(A3); //输出: 4 1 1return 0;
}
我们可以看到,除了成员变量,没有其他东西参与了类对象的大小计算,所以可以推断:
- 我们类对象的存储方式是第三种,即对象中只保存成员变量,成员函数保存在公共代码区。
- 注意空类的大小,空类比较特殊,虽然没有成员,编译器给了空类一个字节来唯一标识这个类的对象。
类对象为什么不采用第2种方式而采用第3种方式存储
第二种方式,采用的是类似一个函数指针表的方式存储类的成员,
而第三种方式,是像我们普通的函数一样,最后在对应有调用的地方展开函数的地址,
对比两种函数的设计方式:
- 第3种方法,类对象调用成员函数通过直接绑定函数地址:
//当我们采用类成员函数放在公共代码区的方式,最后的成员函数其实是在有调用的地方展开了该函数地址。
//因为这种方式,我们函数的最终存储方式是在代码段,大概率会和执行的指令一起进入CPU缓存,所以大概率不会有访存。
//这种方式和我们普通的函数调用一一样,是通过汇编阶段生成函数的符号,然后在最终调用的地方展开函数的地址。
call 0x401230 ; 目标地址硬编码
- 第2种方法,类对象调用成员函数,需要先找到类的函数指针表:
//这种访问方式,我们函数
//我们需要先找到这个对象的地址,因为对象内部存储了它的成员函数指针表(第一次访存),
//然后找到成员函数指针表的地址(第二次访存),
//通过函数指针表,间接的访问对应的函数(第三次访存)。
mov rax, [obj] ; 指令1:取对象地址 → 访问内存
mov rbx, [rax+8] ; 指令2:取函数表指针 → 访问内存
call [rbx+0x10] ; 指令3:间接调用 → 访问内存
从访问内存的次数来说,无疑是我们直接绑定的方式,效率更加的高效。
2.1.3 当多成员变量时的内存大小计算(内存对齐)
内存对齐规则:
-
第一个成员在与结构体偏移量为0的地址处。
-
其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
-
对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。 VS中默认的对齐数为8
-
结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
-
如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
系统默认对齐数8
double a
是8字节的,8 == 8,所以变量a的对齐数是8,由于它是类中的第一个变量,所以它放在偏移量为0的位置;
char b
是1字节的,1 < 8,所以变量b的对齐数是1,它应该放在偏移量为1的倍数的地址处,8是1的倍数,所以放在8偏移量地址处;
int c
是4字节的,4 < 8,所以变量c的对齐数是4,它应该偏移量为4的倍数的地址处,12是4的倍数,所以放在12偏移量地址处。
我们成员变量中最大的对齐数是8,8与系统默认对齐数取最小值,得8,所以整个S1类的大小应该为8的倍数,我们上图占用了16个字节,是8的倍数,无需补充,所以我们该类的大小是16字节。
如果不内存对齐,会怎么样?
CPU 从内存中读取数据通常是按固定大小的块进行的(称为内存总线宽度或字长,如 4 字节、8 字节)。例如,一个典型的 64 位 CPU 更倾向于一次读取 8 个字节的数据。这也是为什么我们的默认的对齐数是8或者4。
我们假设系统默认对齐是4
-
对齐的情况:
-
未内存对齐的情况:
若不采用内存对齐,我们CPU要获取变量_a,需要读取两次,先读取第一个块的一部分,再读取第二个块的一部分,然后把两个块中的相关部分组合起来才能得到。
这比只需一次读取操作(对齐时)要 慢得多
默认对齐数也是可以改的,它并不影响机器的读取,只是改变了存储方式,
内存对齐,其实是一种以空间换时间的方法。
3.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 a;
};
int main()
{Date d1, d2;d1.Init(2022, 1, 11);d2.Init(2022, 1, 12);d1.Print();d2.Print();return 0;
}
Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Init 函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?
C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,
让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。
只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
说人话就是:
编译器会给类中每个非静态的成员函数,自动加上一个参数,这个参数是该类的指针(this指针),并在成员函数被调用的地方,会自动将调用该函数的对象的地址传给这个this指针,那么成员函数就可以通过这个this指针,知道是哪个对象,从而不会修改错。
3.1 this指针的特性
- this指针的类型:类类型* const,即成员函数中,不能给this指针赋值。
- 只能在“成员函数”的内部使用
- this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。
- this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递
本文章为作者的笔记和心得记录,顺便进行知识分享,有任何错误请评论指点:)。