一. 类的定义
1.1 类定义格式
1.1.1 内联函数
内联函数(inline)的基本概念
内联函数是一种以空间换时间的优化技术。当一个函数被声明为内联函数时,在编译阶段,编译器会尝试将函数的调用点替换为函数体的内容。这样做的好处是可以减少函数调用的开销,如参数传递、栈帧的建立和销毁等操作所花费的时间。因为函数调用涉及到保存当前执行环境(如程序计数器、寄存器等),跳转到函数的代码位置,执行函数体,然后再恢复之前的执行环境,而内联函数避免了这种频繁的跳转和环境切换。
我们来举个c语言的简单例子,我们之前写Add函数的时候,总是会写一个函数,通过调用函数的方式来实现功能,但是我们也可以通过define方式来定义。
如图所示,直接把这个函数定义成了一个结构,我们来解决一下图片中的三个问题,为什么不能加分号呢?因为加上分号之后,第一个输出语句,我们直接把ADD(1,2)替换成了((a)+(b));了,此时就无法执行后面的换行代码了,此时就会出现编译错误了。
为什么要在外面加括号呢?
因为当我们想实现3*5的时候,我们ADD(1,2)*ADD(2,3)此时替换了就成了1+2*2+3了先算乘除后算加减,此时就出现了问题了,无法实现我们所想要的结果了。
为什么要在里面加入括号呢?
图中也举了个例子就是主函数的第五行,此时我们要求xy都为真和xy其中一个为真,如果我们不加括号就变成了,x&y+x|y了,因为+的运算优先级大于&和|此时就出现问题了。
这是我们c语言中类似于内联函数的代码。
我们在cpp中如何实现呢?
我们要用到一个inline的关键字了。
如图所示,此时我们的inline就是内联函数了,但是有时候即使你加了inline这个关键字,有时候函数也不会展开,因为有时候函数体过大的话,编译器就会识别这个函数快速膨胀了,此时也就不展开了,一般超过10行代码就不展开了。
内联函数的优点:提高程序执行效率
- 内联函数在编译时,编译器会将函数的调用点替换为函数体内容。这样就避免了函数调用时的一些开销,如参数传递、栈帧的建立与销毁、函数返回时的恢复现场等操作。
- 缺点:
-
- 增加代码体积
- 由于内联函数是将函数体内容直接替换到调用点,当内联函数被大量调用时,会导致可执行文件的体积增大。例如,有一个内联函数
printHello()
用于打印 “Hello”,如果这个函数在一个大型程序中被调用了数千次,那么在编译后的代码中,“打印 Hello” 的代码片段就会出现数千次,这会占用更多的磁盘空间和内存空间。
- 由于内联函数是将函数体内容直接替换到调用点,当内联函数被大量调用时,会导致可执行文件的体积增大。例如,有一个内联函数
- 编译时间可能延长
- 编译器需要在编译阶段对每个内联函数的调用点进行替换和优化处理。如果一个项目中有大量复杂的内联函数,编译器的工作量会显著增加,从而导致编译时间变长。而且,当修改了内联函数的定义时,所有调用该内联函数的代码都可能需要重新编译,这在大型项目中会带来维护成本的增加。
- 可能违反程序设计的封装性原则
- 内联函数的代码会在调用点展开,这使得函数的实现细节暴露给了调用者。如果后期需要对函数进行修改,比如改变函数内部的逻辑或者数据结构,那么所有调用该函数的地方都可能受到影响,这与面向对象编程中的封装性原则相违背,因为封装性要求尽量隐藏函数的内部实现细节,只提供稳定的接口。
- 增加代码体积
C++中 struct 不需要用 typedef ,后面的类名就可以代表类型;
传统的c语言的结构体中无法定义一个拥有函数体的函数,cpp的类就解决了这个缺点。
我们在创建结构体的时候,通常会加上_和上面的形参进行区别。
1.2 访问限定符
C++ 中一种实现封装的方式,用类将对象的属性和方法结合在一块,让对象更加完善,通过访问权限,选择性的将其接口提供给外部的用户使用。
public(公开的)修饰的成员在类外可以被直接访问;protected(受保护的) 和 private(私有的)修饰的成员在类外不能被直接访问,protected 和 private 是一样的,后继章节才会体现出他们的区别。
访问权限作用域从该访问限定符出现的位置开始直到出现下一个访问限定符为止,如果后面没有出现访问限定符,作用域就到 } 即类结束为止。
class定义成员没有被访问限定符修饰时默认为 private 修饰,struct 默认为 public 。
一般成员变量都会被限制为private / protected ,需要给别人使用的函数默认为 public。
如图所示,此时我们只把Init这个函数定义位public了,此时其他的默认为private类型的,此时都无法访问的到。
但是我们用struct定义类的时候,所有的变量及其函数都是public类型的,此时都可以访问的到。
1.3 类域
类定义了⼀个新的作⽤域,类的所有成员都在类的作⽤域中,在类体外定义成员时,需要使⽤ :: 作 ⽤域操作符指明成员属于哪个类域。
•
类域影响的是编译的查找规则,下⾯程序中Init如果不指定类域Stack,那么编译器就把Init当成全 局函数,那么编译时,找不到array等成员的声明/定义在哪⾥,就会报错。指定类域Stack,就是知 道Init是成员函数,当前域找不到的array等成员,就会到类域中去查找。
如图所示,我们只在类中声明一下函数,在外部实现的时候,此时就会报错了,无法解析到我们的类中的变量,此时只需要加一个Data::即可。
因为这个函数内部会先在局部变量中找_day变量发现没有,再次去全局中找,还是没有,此时就访问不到了,但是我们加了一个Data::此时它就会去类域中寻找了。
为什么上面的三个变量都是私有的,为什么还可以在外部使用呢?
因为这里只是对Init这个函数写一个体,这个函数本质上还是类里面的,不违反private修饰的只能在类中使用。
二. 实例化
2.1 实例化概念
⽤类类型在物理内存中创建对象的过程,称为类实例化出对象。
•
类是对象进⾏⼀种抽象描述,是⼀个模型⼀样的东西,限定了类有哪些成员变量,这些成员变量只 是声明,没有分配空间,⽤类实例化出对象时,才会分配空间。
•
⼀个类可以实例化出多个对象,实例化出的对象 占⽤实际的物理空间,存储类成员变量。打个⽐ ⽅:类实例化出对象就像现实中使⽤建筑设计图建造出房⼦,类就像是设计图,设计图规划了有多 少个房间,房间⼤⼩功能等,但是并没有实体的建筑存在,也不能住⼈,⽤设计图修建出房⼦,房 ⼦才能住⼈。同样类就像设计图⼀样,不能存储数据,实例化出的对象分配物理内存存储数据。
我们直接通过类名去访问它,这是一个错误的定义,因为此时它还并没有空间,这三个变量只是在结构体声名了一下,并没有物理空间,我们必须先分配空间,再访问它。
这个da就是一个分配过物理内存的类了,此时就可以访问了。
此时就能访问得到了。
你可以理解为Data只是一个图纸,并不是一个真正的房子,我们实例化就是使用这个图纸来建立出来一个真正的房子。
你也可以这样理解。
- 在类中声明的变量是声明而非定义(有一些特殊情况除外)。
- 这种声明只是描述了类的成员变量的类型和名称,并没有为这个变量分配内存空间。当创建类的对象时,才会为这些成员变量分配内存,这时候变量才真正被定义。
- 不过,如果变量是
static
类型的,情况会有所不同。static
成员变量在类中声明时,它只是一个声明,还需要在类外进行定义和初始化,因为static
成员变量是属于类的,而不是属于类的某个对象的,在 C++ 中,类中用static
修饰的成员变量不会走初始化列表。初始化列表主要用于在对象构造时对类的非静态成员变量进行初始化。。 - 类中的函数在类内部声明时也是声明,而函数定义可以在类内部完成(这种函数被称为内联函数),也可以在类外部完成。
-
所以,在一般情况下,类中的变量是声明,函数可以是声明(如果在类内部只写了函数原型)也可以是定义(如果在类内部写了完整的函数体)。
2.2 对象⼤⼩
分析⼀下类对象中哪些成员呢?类实例化出的每个对象,都有独⽴的数据空间,所以对象中肯定包含 成员变量,那么成员函数是否包含呢?⾸先函数被编译后是⼀段指令,对象中没办法存储,这些指令 存储在⼀个单独的区域(代码段),那么对象中⾮要存储的话,只能是成员函数的指针。再分析⼀下,对 象中是否有存储指针的必要呢,Date实例化d1和d2两个对象,d1和d2都有各⾃独⽴的成员变量 _year/_month/_day存储各⾃的数据,但是d1和d2的成员函数Init/Print指针却是⼀样的,存储在对象 中就浪费了。如果⽤Date实例化100个对象,那么成员函数指针就重复存储100次,太浪费了。这⾥需 要再额外哆嗦⼀下,其实函数指针是不需要存储的,函数指针是⼀个地址,调⽤函数被编译成汇编指 令[call 地址], 其实编译器在编译链接时,就要找到函数的地址,不是在运⾏时找,只有动态多态是在 运⾏时找,就需要存储函数地址,这个我们以后会讲解。
我们来解释一下,就是我们实例化的对象,每个对象都有自己成员变量,但是每个类中的函数并不属于自己的内存,成员函数是不算结构体的空间的,它们都被放在一个统一的函数区,所有结构体调用的函数的地址都是相同的,都是同一个地址的函数,都是call地址,要不然每次调用都要给函数分配一块空间,太浪费了,因为函数的功能在每个实例化的类中都是相同的,所以没必要每次都创建一块空间放函数地址,太浪费了。
大家可以猜一下答案。
第一个输出8,第二个输出1,第三个输出1,因为只有成员变量占用内存,第一个有两个int类型的值,一个占四个字节,所以是8。
我们看到没有成员变量的B和C类对象的⼤⼩是1,为什么没有成员变量还要给1个 字节呢?因为如果⼀个字节都不给,怎么表⽰对象存在过呢!所以这⾥给1字节,纯粹是为了占位标识 对象存在。
因为当是0的时候,编译器自己给的值,每个编译器可能给的值都不同。
我们有这样的一个结构体。
对于类中的非静态指针成员,每个对象都会拥有该指针的独立副本,也就是说不同对象的非静态指针不是同一个指针,它们在内存中有着不同的地址。反之。
三. this指针
Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调⽤Init和 Print函数时,该函数是如何知道应该访问的是d1对象还是d2对象呢?那么这⾥就要看到C++给了 ⼀个隐含的this指针解决这⾥的问题
编译器编译后,类的成员函数默认都会在形参第⼀个位置,增加⼀个当前类类型的指针,叫做this 指针。⽐如Date类的Init的真实原型为, void Init(Date* const this, int year, int month, int day)
类的成员函数中访问成员变量,本质都是通过this指针访问的,如Init函数中给_year赋值, this-
>_year = year;
C++规定不能在实参和形参的位置显⽰的写this指针(编译时编译器会处理),但是可以在函数体内显
⽰使⽤this指针。
这是我们定义的结构体。
这是我们方法的实现。
我们将用这个给你讲一下this指针的使用。
这是我们的测试类,定义了两个类对象,然后都进行了初始化,既然我们说它们都是调用的同一个函数,为什么会有不同的结果呢?
这里我们就要讲一个this关键字了,其实这些类的函数里面,每个函数的第一个参数都是一个Data* const this,通过这些this来访问这些变量,完成每个类的成员变量的值不同。
就像我们的Printf()函数,其实是Printf(Data* const this),此时谁调用的它,此时这个this就指向那个类对象。
此时就是通过这个this指针来使值不同的。
当我们a1来调用它的时候,此时就是Printf(Data* const this)此时a1被传过来了,这些事情都是编译器做的{
cout << a1->_year << '-' << a1->_month << '-' << a1->_day << endl;
}此时就是这个样子了,这就使打印出来的值不同了。
四.测试题
下⾯通过两个选择题测试⼀下前⾯的知识学得如何?


当然有很多人看这道题的时候可能都会选B,因为有空指针解引用吗,但是我们可以思考一下,我们在前面说了,函数是在call里面存着,是直接call出来的,并不是通过指针调用的,这里并没有对空指针解引用,而是,我们只是通过这个指针找到这个函数,因为如果没有这个p指针,此时编译器只会在局部和全局去找,而不会去类域中,但是有了这个指针之后,我们编译器才会去类域中去找。
在 C++ 中,当通过一个空指针去调用非虚成员函数(就像代码中通过 nullptr
指向的对象调用 Print
函数这种情况)时,实际上调用成员函数并不依赖于对象本身的内存状态(也就是并不真正去访问对象的数据成员等需要有效内存地址的部分)。
也就是把空指针 p
作为一个隐藏的 this
指针参数传递给函数,而在函数内部如果没有去实际访问 this
指针所指向对象的数据成员(代码中的 _a
是私有数据成员,在 Print
函数里并没有去访问它),那么程序就不会产生因为空指针解引用导致的访问违规(比如去读取不存在的内存地址上的数据等非法操作),也就不会崩溃。
如果 Print
函数是虚函数,情况就不一样了。当调用虚函数时,编译器会通过对象内存中的虚函数表指针(前提是对象是正常分配了内存的有效对象)去查找对应的虚函数地址来实现动态绑定。而当指针为 nullptr
时,去访问这个不存在的虚函数表指针(因为根本没有有效的对象内存空间存在)就会立即导致程序崩溃,产生段错误等访问违规情况。
所以在这段给定的代码中,由于 Print
是非虚函数且在函数内部没有涉及对对象数据成员基于 this
指针的访问操作,尽管通过空指针去调用它,但程序依然能 “看似正常” 地执行函数内的代码(仅仅是输出固定的字符串),而不会崩溃。不过这种写法是不好的编程习惯,很容易隐藏潜在错误,正常情况下应该确保指针指向有效的对象实例后再去调用其成员函数。
虚函数就是用virtual定义的函数。这里就不展开讲了。
因为我们也讲了this关键字吗,此时就把p指针给传过去了,发现他确实也为null,但是,我们并没有对其解引用,所以并不会程序崩溃,A就更不可能了,空指针异常是在运行时检查出来的,并不是在编译时。
你像这样,我们对它解引用了,此时就会程序崩溃了。
2.下⾯程序编译运⾏结果是()
五.结束语
