NO3.C++语言基础|堆和栈|函数传参|newdeletemallocfree|volatileextern|defineconst|三大特性|虚函数|构造函数|析构函数

![[Pasted image 20250308224523.png]]

15. 堆和栈区别

由编译器进⾏管理,在需要时由编译器⾃动分配空间,在不需要时候⾃动回收空间,⼀般保存的是局部变量和函数参数等。

连续的内存空间,在函数调⽤的时候,⾸先⼊栈的主函数的下⼀条可执⾏指令的地址,然后是函数的各个参数。

⼤多数编译器中,参数是从右向左⼊栈(原因在于采⽤这种顺序,是为了让程序员在使⽤C/C++的“函数参数⻓度可变”这个特性时更⽅便。如果是从左向右压栈,第⼀个参数(即描述可变参数表各变量类型的那个参数)将被放在栈底,由于可变参的函数第⼀步就需要解析可变参数表的各参数类型,即第⼀步就需要得到上述参数,因此,将它放在栈底是很不⽅便的。) 本次函数调⽤结束时,局部变量先出栈,然后是参数,最后是栈顶指针最开始存放的地址,程序由该点继续运⾏,不会产⽣碎⽚。

栈是⾼地址向低地址扩展,栈低⾼地址,空间较⼩。

由程序员管理,需要⼿动 new malloc delete free 进⾏分配和回收,如果不进⾏回收的话,会造成内存泄漏的问题。

不连续的空间,实际上系统中有⼀个空闲链表,当有程序申请的时候,系统遍历空闲链表找到第⼀个⼤于等于申请⼤⼩的空间分配给程序,⼀般在分配程序的时候,也会空间头部写⼊内存⼤⼩,⽅便 delete 回收空间⼤⼩。当然如果有剩余的,也会将剩余的插⼊到空闲链表中,这也是产⽣内存碎⽚的原因。

堆是低地址向⾼地址扩展,空间交⼤,较为灵活。

16. 函数传递参数的⼏种⽅式

值传递:

形参是实参的拷⻉,函数内部对形参的操作并不会影响到外部的实参。

指针传递:

也是值传递的⼀种⽅式,形参是指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进⾏操作。

引⽤传递:

实际上就是把引⽤对象的地址放在了开辟的栈空间中,函数内部对形参的任何操作可以直接映射到外部的实参上⾯。

17. new / delete , malloc / free 区别

都可以⽤来在堆上分配和回收空间。
new /delete 是操作符, malloc/free 是库函数。

执⾏ new 实际上执⾏两个过程:

  1. 分配未初始化的内存空间(malloc)
  2. 使⽤对象的构造函数对空间进⾏初始化;返回空间的⾸地址。
    如果在第⼀步分配空间中出现问题,则抛出std::bad_alloc异常,或被某个设定的异常处理函数捕获处理;如果在第⼆步构造对象时出现异常,则⾃动调⽤ delete 释放内存。

执⾏ delete 实际上也有两个过程:

  1. 使⽤析构函数对对象进⾏析构;
  2. 回收内存空间(free)。

以上也可以看出 new 和 malloc 的区别, new 得到的是经过初始化的空间,⽽ malloc 得到的
是未初始化的空间。所以 new 是 new ⼀个类型,⽽ malloc 则是malloc ⼀个字节⻓度的空间。
delete 和 free 同理, delete 不仅释放空间还析构对象, delete ⼀个类型, free ⼀个字节⻓度的空间。

为什么有了 malloc/ free 还需要 new/ delete?
因为对于⾮内部数据类型⽽⾔,光⽤ malloc/ free ⽆法满⾜动态对象的要求。对象在创建的同时需要⾃动执⾏构造函数,对象在消亡以前要⾃动执⾏析构函数。由于 mallo/ free 是库函数⽽不是运算符,不在编译器控制权限之内,不能够把执⾏的构造函数和析构函数的任务强加于 malloc/ free,所以有了 new/ delete 操作符。

18. volatile 和 extern 关键字

volatile 三个特性
  1. 易变性:
    在汇编层⾯反映出来,就是两条语句,下⼀条语句不会直接使⽤上⼀条语句对应的volatile 变量的寄存器内容,⽽是重新从内存中读取。
  2. 不可优化性:
    volatile 告诉编译器,不要对我这个变量进⾏各种激进的优化,甚⾄将变量直接消除,保证程序员写在代码中的指令,⼀定会被执⾏。
  3. 顺序性:
    能够保证 volatile 变量之间的顺序性,编译器不会进⾏乱序优化。
extern

在 C 语⾔中,修饰符 extern ⽤在变量或者函数的声明前,⽤来说明 “此变量/函数是在别处定义的,要在此处引⽤”。
注意 extern 声明的位置对其作⽤域也有关系,如果是在 main 函数中进⾏声明的,则只能在main 函数中调⽤,在其它函数中不能调⽤。其实要调⽤其它⽂件中的函数和变量,只需把该⽂件⽤ #include 包含进来即可,为啥要⽤ extern?因为⽤ extern 会加速程序的编译过程,这样能节省时间。
在 C++ 中 extern 还有另外⼀种作⽤,⽤于指示 C 或者 C++函数的调⽤规范。⽐如在 C++ 中调⽤ C 库函数,就需要在 C++ 程序中⽤ extern “C” 声明要引⽤的函数。这是给链接器⽤的,告诉链接器在链接的时候⽤C 函数规范来链接。主要原因是 C++ 和 C 程序编译完成后在⽬标代码中命名规则不同,⽤此来解决名字匹配的问题

19. define 和 const 区别(编译阶段、安全性、内存占⽤等)

对于 define 来说,

宏定义实际上是在预编译阶段进⾏处理,没有类型,也就没有类型检查,仅仅做的是遇到宏定义进⾏字符串的展开,遇到多少次就展开多少次,⽽且这个简单的展开过程中,很容易出现边界效应,达不到预期的效果。因为 define 宏定义仅仅是展开,因此运⾏时系统并不为宏定义分配内存,但是从汇编 的⻆度来讲, define 却以⽴即数的⽅式保留了多份数据的拷⻉。

对于 const 来说,

const 是在编译期间进⾏处理的, const 有类型,也有类型检查,程序运⾏时系统会为 const 常量分配内存,⽽且从汇编的⻆度讲, const 常量在出现的地⽅保留的是真正数据的内存地址,只保留了⼀份数据的拷⻉,省去了不必要的内存空间。⽽且,有时编译器不会为普通的 const 常量分配内存,⽽是直接将 const 常量添加到符号表中,省去了读取和写⼊内存的操作,效率更⾼。

20. 计算下⾯⼏个类的⼤⼩

class A{}; sizeof(A) = 1; //空类在实例化时得到⼀个独⼀⽆⼆的地址,所以为 1.  
class A{virtual Fun(){} }; sizeof(A) = 4(32bit)/8(64bit) //当 C++ 类中有虚函数的时候,会有⼀个指向虚函数表的指针(vptr)  
class A{static int a; }; sizeof(A) = 1;  
class A{int a; }; sizeof(A) = 4;  
class A{static int a; int b; }; sizeof(A) = 4;

21. ⾯向对象的三⼤特性,并举例说明

C++ ⾯向对象的三⼤特征是:封装、继承、多态。

所谓封装

就是把客观事物封装成抽象的类,并且类可以把⾃⼰的数据和⽅法只让信任的类或者对象操作,对不可信的进⾏信息隐藏。⼀个类就是⼀个封装了数据以及操作这些数据的代码的逻辑实体。在⼀个对象内部,某些代码或某些数据可以是私有的,不能被外界访问。通过这种⽅式,对象对内部数据提供了不同级别的保护,以防⽌程序中⽆关的部分意外的改变或错误的使⽤了对象的私有部分。

所谓继承

是指可以让某个类型的对象获得另⼀个类型的对象的属性的⽅法。它⽀持按级分类的概念。继承是指这样⼀种能⼒:它可以使⽤现有类的所有功能,并在⽆需重新编写原来的类的情况下对这些功能进⾏扩展。通过继承创建的新类称为“⼦类”或者“派⽣类”,被继承的类称为“基类”、“⽗类”或“超类”。继承的过程,就是从⼀般到特殊的过程。要实现继承,可以通过“继承”和“组合”来实现。

继承概念的实现⽅式有两类:
  1. 实现继承:
    实现继承是指直接使⽤基类的属性和⽅法⽽⽆需额外编码的能⼒。
  2. 接⼝继承:
    接⼝继承是指仅使⽤属性和⽅法的名称、但是⼦类必需提供实现的能⼒。
所谓多态

就是向不同的对象发送同⼀个消息,不同对象在接收时会产⽣不同的⾏为(即⽅法)。即⼀个接⼝,可以实现多种⽅法。
多态与⾮多态的实质区别就是函数地址是早绑定还是晚绑定的。如果函数的调⽤,在编译器编译期间就可以确定函数的调⽤地址,并产⽣代码,则是静态的,即地址早绑定。⽽如果函数调⽤的地址不能在编译器期间确定,需要在运⾏时才确定,这就属于晚绑定。

22. 多态的实现

多态其实⼀般就是指继承加虚函数实现的多态,
对于重载来说,实际上基于的原理是,编译器为函数⽣成符号表时的不同规则,重载只是⼀种语⾔特性,与多态⽆关,与⾯向对象也⽆关,但这⼜是 C++中增加的新规则,所以也算属于 C++,所以如果⾮要说重载算是多态的⼀种,
那就可以说: 多态可以分为静态多态和动态多态。

静态多态

其实就是重载,因为静态多态是指在编译时期就决定了调⽤哪个函数,根据参数列表来决定;

动态多态

是指通过⼦类重写⽗类的虚函数来实现的,因为是在运⾏期间决定调⽤的函数,所以称为动态多态,⼀般情况下我们不区分这两个时所说的多态就是指动态多态。

动态多态的实现与虚函数表,虚函数指针相关。

扩展: ⼦类是否要重写⽗类的虚函数?
⼦类继承⽗类时, ⽗类的纯虚函数必须重写,否则⼦类也是⼀个虚类不可实例化。 定义纯虚函数是为了实现⼀个接⼝,起到⼀个规范的作⽤,规范继承这个类的程序员必须实现这个函数。

23. 虚函数相关(虚函数表,虚函数指针),虚函数的实现原理

⾸先我们来说⼀下, C++中多态的表象,在基类的函数前加上 virtual 关键字,在派⽣类中重写该函数,运⾏时将会根据对象的实际类型来调⽤相应的函数。如果对象类型是派⽣类,就调⽤派⽣类的函数,如果是基类,就调⽤基类的函数。

实际上,当⼀个类中包含虚函数时,编译器会为该类⽣成⼀个虚函数表,保存该类中虚函数的地址,同样,派⽣类继承基类,派⽣类中⾃然⼀定有虚函数,所以编译器也会为派⽣类⽣成⾃⼰的虚函数表。当我们定义⼀个派⽣类对象时,编译器检测该类型有虚函数,所以为这个派⽣类对象⽣成⼀个虚函数指针,指向该类型的虚函数表,这个虚函数指针的初始化是在构造函数中完成的。

后续如果有⼀个基类类型的指针,指向派⽣类,那么当调⽤虚函数时,就会根据所指真正对象的虚函数表指针去寻找虚函数的地址,也就可以调⽤派⽣类的虚函数表中的虚函数以此实现多态。
补充:
如果基类中没有定义成 virtual,那么进⾏
Base B; Derived D; Base *p = D; p->function();
这种情况下调⽤的则是 Base 中的 function()。因为基类和派⽣类中都没有虚函数的定义,那么编译器就会认为不⽤留给动态多态的机会,就事先进⾏函数地址的绑定(早绑定),详述过程就是,定义了⼀个派⽣类对象,⾸先要构造基类的空间,然后构造派⽣类的⾃身内容,形成⼀个派⽣类对象,那么在进⾏类型转换时,直接截取基类的部分的内存,编译器认为类型就是基类,那么(函数符号表[不同于虚函数表的另⼀个表]中)绑定的函数地址也就是基类中函数的地址,所以执⾏的是基类的函数。

24. 编译器处理虚函数表应该如何处理

对于派⽣类来说,编译器建⽴虚函数表的过程其实⼀共是三个步骤:

  1. 拷⻉基类的虚函数表,如果是多继承,就拷⻉每个有虚函数基类的虚函数表
  2. 当然还有⼀个基类的虚函数表和派⽣类⾃身的虚函数表共⽤了⼀个虚函数表,也称为某个基类为派⽣类的主基类
  3. 查看派⽣类中是否有重写基类中的虚函数, 如果有,就替换成已经重写的虚函数地址;查看派⽣类是否有⾃身的虚函数,如果有,就追加⾃身的虚函数到⾃身的虚函数表中。
    Derived *pd = new D(); B *pb = pd; C *pc = pd;
    其中 pb, pd, pc 的指针位置是不同的,要注意的是派⽣类的⾃身的内容要追加在主基类的内存块后。
    ![[Pasted image 20250308221409.png]]

25. 析构函数⼀般写成虚函数的原因

直观的讲:是为了降低内存泄漏的可能性。举例来说就是,⼀个基类的指针指向⼀个派⽣类的对象,在使⽤完毕准备销毁时,如果基类的析构函数没有定义成虚函数,那 么编译器根据指针类型就会认为当前对象的类型是基类,调⽤基类的析构函数 (该对象的析构函数的函数地址早就被绑定为基类的析构函数),仅执⾏基类的析构,派⽣类的⾃身内容将⽆法被析构,造成内存泄漏。

如果基类的析构函数定义成虚函数,那么编译器就可以根据实际对象,执⾏派⽣类的析构函数,再执⾏基类的析构函数,成功释放内存。

26. 构造函数为什么⼀般不定义为虚函数

  • 虚函数调⽤只需要知道“部分的”信息,即只需要知道函数接⼝,⽽不需要知道对象的具体类型。但是,我们要创建⼀个对象的话,是需要知道对象的完整信息的。特别是,需要知道要创建对象的确切类型,因此,构造函数不应该被定义成虚函数;
  • ⽽且从⽬前编译器实现虚函数进⾏多态的⽅式来看,虚函数的调⽤是通过实例化之后对象的虚函数表指针来找到虚函数的地址进⾏调⽤的,如果说构造函数是虚的,那么虚函数表指针则是不存在的,⽆法找到对应的虚函数表来调⽤虚函数,那么这个调⽤实际上也是违反了先实例化后调⽤的准则。

27. 构造函数或析构函数中调⽤虚函数会怎样

实际上是不应该在构造函数或析构函数中调⽤虚函数的,因为这样的调⽤其实并不会带来所想要的效果。
举例来说就是,有⼀个动物的基类,基类中定义了⼀个动物本身⾏为的虚函数 action_type(),在基类的构造函数中调⽤了这个虚函数。
派⽣类中重写了这个虚函数,我们期望着根据对象的真实类型不同,⽽调⽤各⾃实现的虚函数,但实际上当我们创建⼀个派⽣类对象时,⾸先会创建派⽣类的基类部分,执⾏基类的构造函数,此时,派⽣类的⾃身部分还没有被初始化,对于这种还没有初始化的东⻄, C++选择当它们还不存在作为⼀种安全的⽅法。
也就是说构造派⽣类的基类部分是,编译器会认为这就是⼀个基类类型的对象,然后调⽤基类类型中的虚函数实现,并没有按照我们想要的⽅式进⾏。即对象在派⽣类构造函数执⾏前并不会成为⼀个派⽣类对象。
在析构函数中也是同理,派⽣类执⾏了析构函数后,派⽣类的⾃身成员呈现未定义的状态,那么在执⾏基类的析构函数中是不可能调⽤到派⽣类重写的⽅法的。所以说,我们不应该在构在函数或析构函数中调⽤虚函数,就算调⽤⼀般也不会达到我们想要的结果。

28. 析构函数的作⽤,如何起作⽤?

构造函数只是起初始化值的作⽤,但实例化⼀个对象的时候,可以通过实例去传递参数,从主函数传递到其他的函数⾥⾯,这样就使其他的函数⾥⾯有值了。规则,只要你⼀实例化对象,系统⾃动回调⽤⼀个构造函数,就是你不写,编译器也⾃动调⽤⼀次。

析构函数与构造函数的作⽤相反,⽤于撤销对象的⼀些特殊任务处理,可以是释放对象分配的内存空间;特点:析构函数与构造函数同名,但该函数前⾯加~。

析构函数没有参数,也没有返回值,⽽且不能重载,在⼀个类中只能有⼀个析构函数。 当撤销对象时,编译器也会⾃动调⽤析构函数。 每⼀个类必须有⼀个析构函数,⽤户可以⾃定义析构函数,也可以是编译器⾃动⽣成默认的析构函数。⼀般析构函数定义为类的公有成员。

29. 构造函数的执⾏顺序?析构函数的执⾏顺序?

构造函数顺序
  1. 基类构造函数。如果有多个基类,则构造函数的调⽤顺序是某类在类派⽣表中出现的顺序,⽽不是它们在成员初始化表中的顺序。
  2. 成员类对象构造函数。如果有多个成员类对象则构造函数的调⽤顺序是对象在类中被声明的顺序,⽽不是它们出现在成员初始化表中的顺序。
  3. 派⽣类构造函数。
析构函数顺序
  1. 调⽤派⽣类的析构函数;
  2. 调⽤成员类对象的析构函数;
  3. 调⽤基类的析构函数。

30. 纯虚函数 (应⽤于接⼝继承和实现继承)

实际上,纯虚函数的出现就是为了让继承可以出现多种情况:

  • 有时我们希望派⽣类只继承成员函数的接⼝
  • 有时我们⼜希望派⽣类既继承成员函数的接⼝,⼜继承成员函数的实现,⽽且可以在派⽣类中可以重写成员函数以实现多态
  • 有的时候我们⼜希望派⽣类在继承成员函数接⼝和实现的情况下,不能重写缺省的实现。

其实,声明⼀个纯虚函数的⽬的就是为了让派⽣类只继承函数的接⼝,⽽且派⽣类中必需提供⼀个这个纯虚函数的实现,否则含有纯虚函数的类将是抽象类,不能进⾏实例化。

对于纯虚函数来说,我们其实是可以给它提供实现代码的,但是由于抽象类不能实例化,调⽤这个实现的唯⼀⽅式是在派⽣类对象中指出其 class 名称来调⽤。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值