面试速通宝典——2
21. malloc的内存分配的方式,有什么缺点?
malloc并不是系统调用,而是C库中的函数,用于动态内存分配,在使用malloc分配内存的时候会有两种方式向操作系统申请堆内存。
方式1:当用户分配的内存小于128KB时通过brk()系统调用从堆
分配内存。
实现方式:将堆顶指针向高地址移动,获取内存空间,如果使用free释放空间,并不会将内存归还给操作系统,而是会缓存在malloc的内存池中,待下次使用。
方式2:当用户分配的内存大于128KB时通过mmap()系统调用在文件映射区域
分配内存。
实现方式:使用私有匿名映射的方式,在文件映射区分配一块内存,也就是从文件映射区拿了一块内存,free释放内存的时候,会把内存归还给操作系统,内存得到真正释放。
缺点:容易造成内存泄漏和过多的内存碎片,影响系统的正常运行,还得注意判断内存是否分配成功,而且内存释放后(使用free函数之后指针变量p本身保存的地址并没有改变),需要将p的赋值为NULL拴住野指针。
知识扩展:
- 内存动态分配:这是一种编程实践,在程序运行时动态的分配或者释放内存。在C语言中,malloc函数就被用来分配指定大小的连续内存空间,它返回一个指针,这个指针指向分配内存的首地址。
- 系统调用:系统调用是程序直接请求操作系统进行的高级操作(如读写文件、分配释放内存等)的机制。不过,malloc并不是一个系统调用,而是C库中的一个函数。在linux系统中,malloc可能会通过brk或者sbrk系统调用(改变数据大小以分配内存)或者mmap系统调用(映射匿名或者文件的内存区域)来申请内存。
- 内存泄漏:当程序在不再需要使用一块动态分配的内存时,应该使用free函数来释放这块内存,以供程序后续使用。如果忘记释放,会导致被称为“内存泄漏”的问题,即使这块内存永远不会被再次使用,它仍然会预占系统资源,长时间运行可能会导致内存耗尽。
- 内存碎片:当频繁分配和释放不同大小的内存块时,会导致内存碎片化。碎片化的内存并不能有效利用,这可能会导致申请大块连续内存空间失败,尽管总的可用内存还相对充足。
- 内存分配失败的处理:当malloc无法从系统申请到足够的内存时,它会返回NULL。一般来说,程序应该检查malloc的返回值,以确保内存分配成功。如果不进行检查,可能会引发NULL指针解引用错误,这常常会导致内存泄漏。
- 野指针问题:当使用free释放内存后,指向该内存的指针并不会被自动置为NULL,也就是说那个指针仍然保存着已被释放的内存地址,这种指针被称为野指针。对这个野指针进行解引用操作将会导致未定义行为,此类错误往往难以调试。为了避免野指针问题,我们习惯上会在释放完内存后立即将指针设置为NULL。
- 解引用:解引用操作通常指的是通过指针访问其指向的内存区域。在C和C++中,我们使用星号(* )操作符来进行解引用。
int a = 10;
int *p = &a; // p 是指向 a 的指针
int b = *p; // 通过解引用 p, 得到 a 的值
/*
在这个例子中,`*p`就是解引用指针p的操作,也就是获取p指向的内存空间中的值。`b`的值就会被赋值为10,根据`p`所指向的`a`。
因此,当我们说“对一个指针解引用”时,我们是指获取该指针所指向的内存区域的内容。
然鹅,如果指针是野指针,他将会指向一个无效的内存区域,解引用野指针将会导致未定义的行为——这可能是程序崩溃,也可能产生不可预测的副作用。出于这个原因,避免创建野指针,在释放内存后将指针置为NULL以避免误用。
*/
22. 为什么不全部使用mmap来分配内存?
因为向操作系统申请内存的时候,是要通过系统调用的,执行系统调用要进入内核态,然后再回到用户态,状态的切换会耗费不少时间,所以申请内存的操作应该避免频繁的系统调用,如果每次都使用mmap来分配内存,等于每次都要执行系统调用。另外,因为mmap分配的内存每次释放的时候都会归还给操作系统,于是每次mmap分配的虚拟地址都是缺页状态,然后再第一次访问该虚拟地址的时候就会触发缺页中断。
23. 为什么不全部使用brk?
如果全部使用brk申请内存,那么随着程序频繁的调用malloc和free,尤其是小块内存,堆内将产生越来越多的不可用的内存碎片。
24. 传入一个指针,它如何确定具体要清理多少空间呢?
我们在申请内存的时候,会多分配出16字节的内存,里面保存了内存块的详细信息,free会对传入的内存地址向左偏移16字节,然后分析出当前内存块的大小,就知道要释放多大的内存空间了。
这些额外的内存是用来存储管理信息的,比如内存的大小和是否被分配出去等。这些信息通常被存放在分配出去的内存块前面(低地址处)。所以当我们调用free()或delete时,我们其实是传递给这些函数一个指向内存块管理信息后面的指针,这些函数会自动向后查找管理信息,找到这块内存的大小等信息,然后进行释放。
这个设计是非常巧妙的,它让我们可以在不知道内存块大小的情况下释放内存。这是由低级语言(如C、C++)内存管理的复杂性决定的。
25. define和const的区别是什么?
编译阶段
:define是在预编译阶段进行简单的文本替换,const是在编译阶段确定其值。安全性
:define定义的宏常量没有数据类型,只是进行简单的替换,不会进行类型安全检查;const定义的常量是有类型的,是要进行类型判断的。内存占用
:define定义的宏常量,在程序中使用多少次就会进行多少次替换,内存中有多个备份,占用的是代码段的内存;const定义常量占用静态存储区的空间,程序运行过程中只有一份。调试
:define定义的宏常量不能调试,因为在预编译阶段就已经进行替换了;const定义的常量是可以进行调试的。
#define
所定义的宏在预处理期间仅仅进行文本替换而不进行任何类型检查,由此可能会导致一些难以发现的错误。例如,使用#define
定义的宏在复杂的表达式中可能会导致优先级错误。
#define SQUARE(x) x*x
int main() {
int y = 5;
int z = SQUARE(y + 1); // z = 11, not 36!
return 0;
}
在上述示例中,因为宏仅进行了直接替换,所以SQUARE(y + 1)
实际上变成了y + 1*y + 1
,这显然并非我们预期的结果。
26. 程序运行的步骤是什么?
- 预编译 : 将头文件编译,进行宏替换,输出.i文件。
- 编译 : 将其转化为汇编语言文件,主要做词法分析,语义分析以及检查错误,检查无误后将代码翻译成汇编语言,生成.s文件。
- 汇编 : 汇编器将汇编语言文件翻译成机器语言,生成.o文件。
- 链接 : 将目标文件和库链接到一起,生成可执行文件.exe。
27. 锁的底层原理是什么?
锁的底层是通过CAS,astomic机制实现。
CAS机制:全称为Compare And Swap(比较相同再交换)可以将比较和交换操作转换为原子操作。
CAS操作依赖于三个值:内存中的值V,旧的预估值X,要修改的新值B
,如果旧的预估值X等于内存中的值V,就将新的值B保存在内存中。(就是每一个线程从主内存复制一个变量副本后,进行操作,然后对其进行修改,修改完后,再刷新会主内存前。再取一次主内存的值,看拿到的主内存的新值与当初保存的快照值,是否一样,如果不一样,说明有其他线程修改,本次修改放弃,重试。)
astomic机制:如下一问。
举例说明:
假设有两个线程T1和T2,它们都想要更新同一个变量var,初始值为10。
- T1读取var的值为10(这是旧的预期值),并开始进行某些运算。例如,我们可以让T1把var加10变成20。
- 同样,T2也读取var的值为10(这也是旧的预期值),并开始另一种运算。例如,我们可以让T2把var减去5变成5。
- 如果T1先完成运算并尝试写回新值20到var,那么CAS函数会首先检查var的当前值是否仍然为10(即与旧的预期值相同)。如果是,那么T1的运算结果20被成功地写回到var中,此时var的值变为20。
- 随后,T2也完成了运算并尝试写回新值5到var。然而,此时var的值为20,而不是旧的预期值10,所以T2的CAS操作失败了。失败后,T2有几种选择:它可以重试,采用新的值重新进行运算,或者简单地忽略失败。
28. 原子操作是什么?
原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会切换到另一个线程。
原理是:在X86的平台下,CPU提供了在指令执行期间对总线加锁的手段,CPU中有一根引线#HLOCK pin连接到北桥,如果汇编语言的程序在程序中的一条指令前面加了前缀“LOCK”,经过汇编之后的机器码就使CPU在执行这条指令的时候把#HLOCKpin的点评拉蒂持续到这条指令结束的时候放开,从而把总线锁住,这样别的CPU就暂时不能通过总线访问内存了,保证了多处理器环境中的原子性。
举例说明:
在执行原子操作期间,CPU会暂时阻止多线程,确保操作完全执行完毕。这是因为在多线程环境中,如果一个操作被分为多个步骤执行,那么在执行的过程中有可能会产生线程切换,从而导致数据的不一致性,我们称这种现象为“线程安全问题”。
举一个简单例子,比如有两个线程A和B,它们都尝试同时向一个共享的计数器加1。假如计数器初始值为0,期望的结果应该是计数器值为2。但是假如加1这个操作不是原子性的,也就是说这个操作被分为了"读取当前值"和"写入新值"两步。线程A和B都可能在读取到了当前值之后(都是0),由于线程切换等各种原因,在写入新值(也就是1)之前没能立即执行。结果就是,两个线程A和B都读到的当前值是0,最后也都各自写入了1,计数器的结果就变成了1,而不是我们期望的2。
这就是非原子操作可能导致的问题,所以在多线程编程中,一些关键的操作,比如修改共享数据,必须设定为原子操作,以防止数据竞争和一致性问题。
29. class和struct的区别
默认继承权不同:class默认继承的是private继承
,struct默认是public继承
。
class还可以用于定义模板参数,但是关键字struct不能用于定义模板参数,C++保留struct关键字,原因是保证与C语言的向下兼容性,为了保证百分之百的与C语言中的struct向下兼容,C++把最基本的对象单元规定为class而不是struct,就是为了避免各种兼容性的限制。
30. 内存对齐是什么?为什么要进行内存对齐?内存对齐有什么好处?
内存对齐是处理器为了提高处理性能而对存取数据的起始地址所提出的一种要求
。
有些CPU可以访问任意地址上的任意数据,而有些CPU只能在特定的地址访问数据,因此不同的硬件平台具有差异性,这样的代码就不具有移植性,如果在编译的时候进行对齐,这就具有平台的移植性。
CPU每次寻址有时需要消耗时间的,并且CPU访问内存的时候并不是逐个字节访问,而是以字长为单位访问,所以数据结构应该尽可能地在自然边界上对齐,如果访问未对齐内存,处理器需要做多次内存访问,而对其的内存访问可以减少访问次数,提升性能。
好处:提高程序的运行效率
,增强程序的可移植性
。
解释说明:
内存对齐就是将数据存放在内存的某个特定地址,其地址是某个数(通常为2的幂)的整数倍。主要是由于硬件设计上的一种优化,确保了内存数据可以快速地被CPU取出。
例如,假设我们有一块内存块,每个单元代表1个字节,地址从0开始。如果我们要存储一个4字节的int类型数据,如果采用4字节对齐,那么这个int数据可能会被存放在地址0,4,8……等这样的位置,而不会被存放在1,2,3等地址。
这是因为,CPU在读取内存数据时,如果数据的地址符合其对齐条件,通常可以一次性读取,比如一次读取4字节。而如果数据的地址未对齐,可能会需要多次读取操作,比如先读取在一个4字节块里的部分,再读取下一个4字节块的部分,这就会增加CPU的处理时间,降低程序性能。
所以,合理的内存对齐可以有效提高程序的运行效率。但是,过度的内存对齐可能会造成内存空间的浪费,这就需要我们在实际编程中进行合理的权衡。
31. 进程之间的通信方式有哪些?
管道:管道分为匿名管道和命名管道,管道本质上是一个内核中的一个缓存
,当进程创建管道后会返回两个文件描述符,一个写入端一个输出端。
缺点:半双工通信,一个管道只能一个进程写,一个进程读。不适合进程间频繁的交换数据。
消息队列:可以边发边收,但是每个消息体都有最大长度限制,队列所包含的消息体的总数量也有上限并且在通信过程中存在用户态和内核态之间的数据拷贝问题。
共享内存:解决了消息队列存在的内核态和用户态之间的数据拷贝问题。
信号量:本质上是一个计数器,当使用共享内存的通信方式时,如果有多个进程同时往共享内存中写入数据,有可能先写的进程的内容被其他进程覆盖了,信号量就用于实现进程间的互斥和同步PV操作,不限于信号量+1,而且可以任意加减正整数。
信号
套接字
32. 线程之间的通信方式有哪些
信号量
条件变量
互斥量
-
信号量(Semaphore):正如我们谈到的那样,信号量是一种计数器,可以用于控制多个线程对共享资源的访问。这是一种很有用的方式,用于解决线程同步和互斥问题。
-
条件变量(Condition Variables):这是一个可以阻塞多个线程,直到满足某种条件的同步原语。当线程判断某个条件不满足时,它可以通过条件变量进入睡眠状态,直到另一个线程修改了条件并唤醒它。
-
互斥量(Mutex):这是用于实现线程间互斥访问共享资源的工具。互斥量保证一次只有一个线程可以执行一段特定的代码,这段代码通常是访问或修改共享资源的代码。
-
读写锁(Read-Write Lock): 这是一种特殊的锁,允许多个读者同时访问共享资源,但在写者访问资源时进行互斥。这种锁非常适用于读多写少的情况,可以提高程序的并发性。
33. 介绍一下Socket中的多路复用,以及他们的优缺点,epoll的水平和边缘触发模式
select、poll、epoll都是IO多路复用的一种机制,可以监视多个文件描述符,一旦某个文件描述符进入读或写就绪状态,就能够通知系统进行相应的读写操作。
Select优点:
可移植性好
,因为在某些Unix系统中并不支持poll和epoll
对于超时时间提供了更好的精度:微秒
,而poll和epoll都是毫秒级。
【这句话是指select函数在设置超时时间时,可以提供微秒级别的精度。当你在使用select函数监听多个文件描述符时,你可以设置一个超时时间。超过这个时间,如果没有任何文件描述符变得活跃(也就是说,没有数据可读或可写),select函数就会返回。这个超时时间可以精确到微秒级别,这使得你可以进行精细化的超时控制。】
Select缺点:
支持监听的文件描述符fd的数量有限制,最大数量默认是1024个。
Select需要维护一个用来存放文件描述符的数据结构,每次调用Select都需要把fd集合从用户区拷贝到内核区,而Select系统调用后又需要把fd集合从内核区拷贝到用户区,这个系统开销在fd数量很多的时候会很大。
Poll优点(相对于Select而言):
没有最大文件描述符数量的限制,Poll基于链表存储主要解决了这个最大文件描述符数量的限制(当然,他还是有限制的,上限为操作系统能支持的能开启的最大文件描述符数量),优化了编程接口,减少了函数调用参数,并且,每次调用select函数时,都必须重置该函数的三个fd_set类型的参数值,而Poll不需要重置。
Poll缺点:
Poll和select一样同样都需要维护一个用来存放文件描述符的数据结构,当注册的文件描述符无限多时,会使得用户态和内核态之间传递该数据结构的复制开销很大。每次Poll系统调用的时候,需要把文件描述符fd从用户态拷贝到内核区,然后Poll系统调用返回前,又需要把文件夹描述符fd集合从内核区拷贝到用户区,这个内存拷贝的系统开销在fd数量很多的时候会很大。
Epoll优点:
和Poll一样没有最大文件描述符数量的限制,epoll虽然也需要维护用来存放文件描述符的数据结构(epoll_event),但是它只需要将讲述结构拷贝一次,不需要重复拷贝,并且它只在调用epoll_ctl系统调用时拷贝一次要将监听的文件描述符数据结构到内核区,在调用epoll_wait的时候不需要再把所有的要监听的文件描述符重复拷贝进内核区,这就解决了Select和Poll中内存复制开销的问题。
Epoll缺点:
目前只有Linux操作系统支持Epoll,不支持跨平台使用,而Unix操作系统上使用的是kqueue。
Epoll水平触发(LT):
对于读操作,只要缓冲区内容不为空,LT模式返回读就绪。
对于写操作,只要缓冲区还不满,LT模式会返回写就绪。
Epoll边缘触发(ET):
对于读操作,当缓冲区由不可读变为可读的时候,有新数据到达时,进程修改了EPOLL_CTL_MOD修改EPOLLIN事件时
在ET模式下,缓冲区从不可读变成可读,会唤醒应用进程,缓冲区数据变少的情况,则不会再唤醒应用进程。
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如缓冲区太小),那么下次调用epoll_wait()时,他不会通知你,也就是他只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。通常配合将文件描述符设置为非阻塞状态一起用,这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
解释:
IO多路复用是网络编程中的一个重要概念,它允许单个线程同时处理多个I/O操作(如TCP连接,文件读写等)。这带来了很高的效率,因为它允许一个线程服务于多个并发的I/O请求,而不需要为每一个请求创建一个新的线程。
多路复用的“多路”是指多个网络连接,“复用”是指使用一个线程就能处理多个连接。在没有IO多路复用的情况下,如果你想要同时处理多个客户端的请求,可能需要为每一个连接创建一个新的线程,这会带来极大的系统开销。
一般情况下,我们会使用select、poll或epoll等函数来实现IO多路复用。这些函数可以让我们给定一组文件描述符(每个I/O操作都有一个描述符),然后等待其中任何一个进入可读、可写或异常状态。一旦有描述符就绪(即有数据可读、可写或者异常),函数就会返回,我们就可以对这些就绪的描述符进行相应的处理。
IO多路复用的主要优点就是提高了程序的并发性和效率,在处理大量连接时尤为重要,特别适用于编写高性能、高并发的网络服务器。
34. 类的生命周期
类从被加载到内存中开始,到卸载出内存为止,他的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中,验证、准备、解析三个部分统称为连接。
- 全局对象在main开始前被创建,main退出后被销毁。
- 静态对象在第一次进行作用域时被创建,在main退出后被销毁。
- 局部对象在进入作用域时被创建,在退出作用域时被销毁。
- New创建的对象直到内存被释放的时候都存在。
解释:
当你使用 new
操作符在堆上创建对象时,该对象会一直存在,直到你显式地使用 delete
操作符释放它占用的内存。这种在堆上动态分配内存的方式,使得你可以控制对象的生命周期,即使离开了对象创建的作用域,对象依然可以存在。
但是,你也需要注意管理这种动态分配的内存。如果忘记使用 delete
去释放已经用 new
创建的对象,就会导致所谓的内存泄露。内存泄露是一种程序错误,如果遗留的内存泄露很多,可能会消耗掉系统的所有空闲内存,导致程序甚至系统的崩溃。
因此,一旦用完动态分配的内存,一定要记得用 delete
释放。或者,可以使用智能指针(如 std::unique_ptr
或 std::shared_ptr
)来自动管理在堆上创建的对象,避免内存泄露。智能指针在其作用域结束时,会自动删除其所指向的对象。
35. 父类的构造函数和析构函数是否能成为虚函数?这样操作导致的结果?
构造函数不能成为虚函数
,虚函数的调用是通过虚函数表来查找的,而虚函数表由类的实例化对象的vptr指针指向,该指针存放在对象的内部空间中,需要调用构造函数完成初始化,如果构造函数为虚函数,那么调用构造函数就需要去寻找vptr,但此时vptr还没有完成初始化,导致无法构造对象。
析构函数可以且经常为虚函数
:当我们使用父类指针指向子类时,只会调用父类的析构函数,子类的析构函数不会调用,容易造成内存泄漏。
解释:
- 构造函数是类内的一种特殊函数,他在我们创建类的对象时自动调用。构造函数主要用于初始化对象的属性。在C++中,构造函数的名称与类的名称相同,并且他们还不返回任何类型。在下面例子中,
MyClass(int val)
就是一个构造函数,它接受一个参数然后进行初始化。
class MyClass {
int data;
public:
MyClass(int val) { // 这就是一个构造函数
data = val;
}
};
- 析构函数与构造函数恰恰相反,他在对象销毁时被自动调用。主要用来释放对象可能占用的资源。比如如果在构造函数中分配了动态内存,那么就应该在析构函数中释放那部分内存,避免产生内存泄漏。析构函数的名称与类的名称相同,但前面有一个波浪符~。在下面例子中,
~MyClass()
就是一个析构函数,它在对象被销毁时自动调用,你可以在里面释放资源。
class MyClass {
public:
~MyClass() { // 这就是一个析构函数
// 这里写释放资源的代码
}
};
- 虚函数:
在面向对象的编程语言中,虚函数是一种允许派生类覆盖基类中定义的函数的语法特性。虚函数的存在是为了支持多态性,它们是在基类中声明,并在派生类中重新被定义(或者说被重写或覆盖)。
在 C++ 中,你可以通过在函数声明前加上关键字 virtual
来创建虚函数。这个声明告诉编译器,该函数在基类中是可以被派生类重写的。
在多态性的上下文中,虚函数的作用是允许在派生类中有一个函数,它在基类中有一个同样的声明,但是不同的实现(这就是重新定义或重写)。通过基类指针或引用可以调用派生类的这个函数,即便指针或引用的类型实际上是基类。那么调用哪个函数将根据实际的对象类型在运行时进行确定,这就是动态绑定或延迟绑定。
举个例子,假设我们有一个基类 Animal
和两个派生类 Dog
和 Cat
。Animal
类中有一个虚函数 makeSound()
,Dog
类和 Cat
类都覆盖了这个函数,给出了不同的实现。
class Animal {
public:
virtual void makeSound() {
cout << "The animal makes a sound\n";
}
};
class Dog : public Animal {
public:
void makeSound() override {
cout << "The dog barks\n";
}
};
class Cat : public Animal {
public:
void makeSound() override {
cout << "The cat meows\n";
}
};
通过基类指针调用虚函数,可以看到,实际调用的是派生类的函数,这就是多态:
Animal* myAnimal = new Cat;
myAnimal->makeSound(); // Outputs "The cat meows"
简而言之,虚函数一般用于基类与派生类间的函数重载,使用虚函数可以实现程序在运行时的多态性。
- 析构函数通常为虚函数:
让我们来具体看一下。当你有一个基类和一个从基类派生出的子类,并且你用一个指向基类的指针来操作一个子类的实例,这是很常见的情况。当你不再需要这个实例时,你可能会想要删除它。你可能这样写:
Animal *animal = new Cat; // 创建一个 Cat 实例
// ...使用 animal...
delete animal; // 删除这个实例
在这个例子中,Cat
是 Animal
的子类。这是一个问题,因为 delete animal
将只调用 Animal
的析构函数,而不会去调用 Cat
的析构函数。这意味着,如果 Cat
的析构函数有清理资源的任务(例如释放内存,解除网络连接等),那么这些清理工作将不会被执行,从而导致资源的浪费。
然而,如果我们将 Animal
的析构函数声明为虚函数,情况就会改变:
class Animal {
public:
virtual ~Animal() { /*...*/ }
};
现在,delete animal
将会首先调用 Cat
的析构函数,然后再调用 Animal
的析构函数。所有的清理工作都将被正确执行。这就是析构函数需要(并且通常)被声明为虚函数的原因。
总的来说,这段话是在强调当基类含有虚函数的时候,为了避免可能的资源泄露,析构函数也应该声明为虚函数。这样当通过基类指针删除派生类对象时,就能保证派生类的析构函数也被正确调用。
36. 多线程为什么会发生死锁?死锁是什么?死锁产生的条件?如何解决死锁?
因为在多进程中容易发生多进程对资源进行竞争。
如果一个进程集合里面的每一个进程都在等待这个集合中的其它一个进程才能继续往下执行,若无外力他们将无法推进,这种情况就是死锁。
产生死锁的四个条件:互斥条件、请求和保持条件、不可剥夺条件、环路等待条件。
解决死锁的方法就是破坏上述任意一种条件。
解释:
- 互斥条件:至少有一个资源必须处于非共享模式,也就是说,一次只有一个进程可以使用。如果其他进程请求该资源,那么请求进程必须等到占有资源的进程释放资源。
- 请求和保持条件:进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已经被其他进程占有,此时请求进程阻塞,但对自己已获得的资源保持不放。
- 不可剥夺条件:进程已经获得的资源在未使用完之前不能剥夺,只能在使用完时由自己释放。
- 环路等待条件:在发生死锁时,必然存在一个进程-资源的环形链,即进程集合{P0,P1,P2,…,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已经被P0占用的资源。
37. 描述一下面向过程和面向对象
面向对象:就是将问题分解为各个对象,建立对象的目的不是为了完成一个步骤,而是为了描述某个事物在整个解决问题的步骤中的行为,相比面向过程,代码更容易维护和复用。但是代码效率相对较低。
面向过程:就是将问题分析出解决问题的步骤,然后将这些步骤一步一步的实现,使用的时候一个一个调用就好。代码效率更高但是代码复用率低,不易维护。
解释:
面向过程编程是一种编程范式,它将一个程序看作一系列的步骤(或者说,过程)。这些过程通常被表示为一系列函数,每个函数完成一个具体的任务。一个程序就是一系列函数的集合,这些函数按照一定的顺序执行,以解决特定的问题或完成特定的任务。面向过程编程强调的是任务的执行顺序。
面向对象编程(Object-oriented Programming, OOP)则是另一种编程范式,它将程序看作一系列互相交互的对象。一个对象可以包含数据(称为属性)和可以操作这些数据的代码(称为方法)。编程的任务变成了设计并实现这些对象,以及规划这些对象之间如何交互。面向对象编程强调的是数据和操作数据的代码的封装。
让我们通过一个简单的例子来理解这两种范式的区别。假设我们正在编写一个电子商务网站的购物车功能:
-
在面向过程编程中,我们可能会创建一些表示购物车和商品的变量,然后编写一些函数来操作这些变量,比如一个函数用于增加商品,一个函数用于移除商品,等等。
-
在面向对象编程中,我们可能会创建一个购物车对象,这个对象有自己的属性(比如商品列表)和方法(比如增加商品、移除商品)。于是,处理购物车就变成了调用这个对象的方法。
38. C++中左值和右值是什么?++i是左值还是右值?++i和i++那个效率更高?
在C++中,左值(Lvalue)和右值(Rvalue)是两种表达式的类型,它们基本上表示的是对象在内存中的位置。
左值是指在内存中有明确地址的对象,因此可以被引用,也可以被赋值。你可以把它看做是在内存中已经存在并可以被操作的一个实体。这是因为它通常出现在赋值运算符的左边,所以被称为"左值"。
int x = 10; // x 就是一个左值
x = 20; // 我们可以给它赋值
右值则通常是临时的、不能被引用的值,直白点讲,就是没有存放地址的值,它只代表了一个值,而不表示一个实体。右值通常出现在赋值运算符的右边,所以被称为"右值"。
int x = 10;
int y = x + 20; // 这里的 x + 20 就是一个右值
在这里,x + 20
不会持久在内存中存储,它只是一个临时的值,所以它是一个右值。
总的来说,左值和右值的核心区别在于:左值有持久的内存位置,而右值则没有或者不能直接访问。
++i是左值,因为++i返回的是一个左值,没有发生拷贝,所以效率更高。
解释:
对于现代编译器来说,++i
和i++
的效率是没有区别的,都是相同的。编译器已经足够智能,能对这两种情况进行优化,使得二者在运行时具有同样的效率。
然而在语义上,两者是有区别的:
++i
(前置自增)首先增加i的值,然后返回新的值。i++
(后置自增)首先返回i的当前值,然后再增加i的值。
如果你的代码不依赖于这个差别(即你不在意是先自增还是后自增),你应当使用++i
或i++
其中你觉得更易读的那一个。
在某些编程语言中(例如C++),如果你正在处理的是对象(例如迭代器),而不是基本类型,那么++i
可能会比i++
效率更高,因为i++
可能需要创建一个临时对象。但是在Python这种编程语言中,这个问题并不存在。
39. 介绍一下vector、list的底层实现原理和优缺点
Vector优点:
- 随机访问:Vector支持随机访问,这是由它的内部实现决定的。在内部,所有元素都是连续存储的,像数组一样。因此,我们可以通过下标随时访问任何元素,而这个操作的复杂度为 O(1),也就是说,无论元素在何处,获取元素的速度都差不多。
- 尾部插入/删除效率高:向Vector的尾部添加或删除元素通常是一项非常高效的操作,因为不需要移动任何已有元素。只有在内部缓冲区不足以容纳新元素时,才需要进行一次重新分配,但这样的事件是相对罕见的。
Vector缺点: - 前部插入/删除效率低:由于vector内部的元素是连续存储的,因此在vector的前部或者中间位置插入或者删除元素会导致大量元素的移动,这将花费较多的时间。这种操作的时间复杂度是O(n)。
- 扩容有消耗:当vector装满所有元素后,再添加新元素就需要分配新的内存空间,并将原有元素逐个复制到新的空间,然后释放原空间。这样的内存分配和数据复制过程是有时间和资源消耗的,特别是在vector非常大的时候。
- 可能存在的空间浪费:vector在开辟空间的时候,往往会预留一些额外的空间用于容纳未来可能添加的新元素。这是为了减小每次扩容时的时间和资源消耗。然而,如果这些预留的空间没有被充分利用的话,就可能造成一定的空间浪费。
底层是有一块连续的内存空间组成,由三个指针实现的分别是头指针(表示目前使用空间的头),尾指针(表示目前使用空间的尾)和可用空间尾指针实现。
List优点:
- 按需申请内存:List的每个元素都是独立的节点,每当需要添加新元素时,就会为该元素申请内存空间,也就是说,它能按需申请所需的存储空间。
- 不需要扩容:这是一个跟上面一点密切相关的优点。对于Vector来说,当内存空间不足以储存额外的元素时,就需要进行内存重新分配,也就是所谓的扩容。但是对于List,由于其每个元素都有自己独立的存储空间,所以无需担心扩容问题。
- 不会造成空间浪费:List与Vector的一个主要区别在于,List不预留额外的储存空间。也就是说,List仅为其元素分配必要的内存。因此,与Vector相比,List的空间效率更高。
- 在任意位置的插入/删除效率高:由于List的复杂节点结构,它可以很高效地在任何位置插入或删除元素,无需移动其他元素。
List缺点: - 不支持随机访问:List的一个主要缺点是它不支持随机访问,这是由其内部实现决定的。在List中,元素是通过节点与节点之间的链接进行存储的,所以我们不能像在数组或者Vector中那样通过索引直接访问元素。如果我们想访问List中的某个元素,我们就必须从头节点开始,逐个遍历节点,这样的操作相对比较慢,其时间复杂度为 O(n)。
- 空间利用率较低:尽管我们前面提到List不会因预留空间而造成浪费,但值得注意的是,由于List的节点除了存储数据外,还需要额外的空间去存储指向下一个和上一个节点的指针,所以相对于连续存储的数据结构,比如Vector,List在存储相同数量的元素时,所需要的总空间是更大的。
底层是由双向链表实现的。
解释:
首先,确实,list不支持下标随机访问,也就是说,我们不能像访问数组那样直接用下标访问list中的某个元素。如果我们需要访问list中的其中一个元素,那么无论这个元素在哪个位置,我们都需要从list的头部开始,逐个遍历每个元素,知道找到了我们需要的元素。这也是我们说list不支持随机访问的原因。
但是,当我们说"在任意位置插入和删除的效率高"的时候,我们指的是一旦你已经找到了你要插入或删除的位置,进行插入和删除操作的效率是非常高的。这是因为在链表中插入或删除元素只需要改变周围几个节点的指向关系,而不需要移动任何其他元素。对比之下,在数组或者顺序存储的数据结构中,如果你想在中间位置进行插入或删除操作,你可能需要移动许多元素以保持元素的连续性,这会消耗更多的时间。
40. 静态变量在哪里初始化?在哪一个阶段初始化?(都存放在全局区域)
静态变量、全局变量、常量都在编译阶段完成初始化和内存分配,其他变量都是在编译阶段进行初始化,运行阶段内存分配。
解释:
- 静态变量:静态变量的生命周期是程序运行期间。不论函数是否被调用,静态变量都会在程序一开始运行时被初始化。在C/C++中,静态变量的初始化和内存分配都在编译阶段完成。
- 全局变量:全局变量是在全局范围内定义的变量,其生命周期同样是程序运行期间,并在程序开始运行时被初始化。全局变量同样是在编译阶段进行内存分配的。
- 常量:根据不同的编程语言,常量可能会在编译阶段或运行阶段进行初始化。在C/C++中,大部分常量都在编译阶段完成初始化和内存分配。
- 局部变量:局部变量是在某个函数或代码块内定义的变量,其生命周期只在函数或代码块执行时。局部变量在进入函数或代码块时进行内存分配,在离开函数或代码块时释放内存。在Python中,由于存在垃圾回收机制,局部变量的内存分配和释放由Python的内存管理机制掌控。
41. 如何实现多线程?
- 在Linux中C++使用fork函数来创建进程。
- 在Windows中C++使用createprocess来创建进程。
解释:
在Linux中,我们常常使用fork()
函数来创建一个新的进程。当fork()
函数被调用时,它将创建一个新的子进程。这个新创建的子进程是父进程的完全复制品,拥有与父进程相同的环境变量、程序计数器,代码,数据等等。
在Windows中,创建进程的方式比Linux更为复杂一些,Windows不提供Linux中fork()
函数这样方便的调用,而是需要使用CreateProcess()
函数。CreateProcess()
函数需要一组复杂的参数,例如,应用程序的名称,命令行字符串,进程安全性和线程安全性属性,是否继承句柄标志,创建标志,新环境块,当前目录的名称,启动信息,以及一个新的进程信息结构体。
以上两种方法都是跨平台的,这意味着你不能在Windows下使用fork()
函数,也不能在Linux下使用CreateProcess()
函数。
42. 空对象指针为什么能调用函数?
在类的初始化的时候,编译器会将它的函数分配到类的外部,这也包括静态成员函数,这样做主要是为了节省内存,如果我们在调用类中的成员函数时没有使用类中的任何成员变量,他不会使用到this指针,所以可以正常调用这个函数。
解释:
在C++中,成员函数(包括静态成员函数)都不会直接存储在每个对象中。相反,所有对象都共享同一个成员函数的副本,这种机制能够有效的减少内存的使用。
- 成员函数:当我们在一个类中定义一个成员函数时,这个函数只会在内存中有一个副本,并且所有的对象都会共享这个函数的副本。这是因为每个对象对于函数的行为都是相同的,所以没有必要为每个对象都保留一份函数的副本。当成员函数需要访问特定对象的数据时,才会用到
this
指针来访问调用对象的内部数据。 - 静态成员函数:静态成员函数也是一样,所有的对象都会共享同一个静态成员函数的副本。不过,值得注意的是,静态成员函数不能访问类的非静态成员变量,也不能调用非静态的成员函数,因为静态成员函数没有
this
指针。
此种设计方式可以有效的节省内存,因为不需要为每个对象都存储一份相同的函数。只要函数需要使用到对象的特定数据时,才会用到 this
指针,否则,函数可以被任何对象正常调用,而不需要知道是哪一个对象在调用。
解释:
空对象指针是不能调用成员函数的。当你试图通过一个空指针去调用成员函数时,不管在哪种编程语言中,都会产生错误,例如在C++中会抛出一个段错误(segmentation fault)。
不过有一点例外,就是在C++中,空指针是可以调用类的静态成员函数的。因为静态成员函数并不依赖于类的实例,它们属于类本身,而不是类的任何一个对象。在C++中,调用静态成员函数实际上并不需要类的实例,可以直接在类名后用双冒号(::)来调用,例如MyClass::myStaticMethod()
。
如果你在有些情况下看到了空指针调用了非静态的成员函数而没有报错,那可能是因为这个函数没有访问类的任何非静态成员。然而这是非常不安全的,即使这种情况在某些特定的编译器和环境下没有直接导致问题,也不能保证在其他环境下依然安全。
43. shared_ptr线程安全么?
智能指针中的引用计数是线程安全的,但是智能指针所指向的对象的线程安全问题,智能指针没有做任何保障线程不安全。也就是说,他所管理的资源可以线程安全的释放,只保证线程安全的管理资源的生命期,不保证其资源可以线程安全的访问。
解释:
准确地说,std::shared_ptr的实现在多线程环境下是线程安全的,其中的引用计数的自增和自减操作都是原子的,这意味着我们可以在多个线程之间安全地拷贝、赋值和析构std::shared_ptr。所以在这个意义上,我们可以说std::shared_ptr是线程安全的。
但是,std::shared_ptr并不保证它所指向的对象(即它管理的资源)的线程安全。也就是说,如果你在一个线程中修改shared_ptr所指向的对象,同时在另一个线程中读取或者修改同一个对象,那么你就需要采取一定的同步措施(如使用互斥锁或者其他同步手段)来防止产生数据冲突。
简单来说,shared_ptr确保的是"你和你的朋友都可以安全地把同一个书放回书架上,但是不保证你们一起阅读这本书的时候不会产生吵架"。也就是说,它确保的是资源的生命周期可以在多线程环境下正确的管理,但不保证资源的使用是线程安全的,这部分需要开发者自行保证。
44. push_back()左值和右值的区别是什么?
如果push_back()的参数是左值,则使用它拷贝构造新的对象。如果是右值,则使用它移动构造新对象。
解释:
确实如此,这就是C++11后引入的左值和右值的区别以及移动语义(Move Semantics)的基本思想。
对于std::vector::push_back()函数来说,它有两个版本:
- 一种是接受
左值引用
: push_back(const T&),这个版本会进行拷贝构造,也就是说它会创建一个新的对象来复制参数对象的值。 - 另一种是接受
右值引用
: push_back(T&&),这个版本进行的是移动构造,也就是说它会直接把参数对象的内部资源(例如堆内存,文件描述符等)转移到新的对象上,而原对象则被置为一个安全的、可析构的状态。这种操作通常比拷贝构造性能更好,因为它避免了不必要的内存拷贝。
这种设计使得我们可以根据需要来选择适当的操作,提高代码的执行效率。所以,当你调用push_back()的时候,C++会依据你传入的值是左值还是右值来选择对应的版本,从而执行拷贝或者移动操作。
想象你有一个装满书的书架(“vector”),你想在书架的尾部(“back”)添加(“push”)一本书(我们来说它是"T"类型的对象)。
首先,如果这本书实际就只有一本,你知道它的位置(也就是你有一个指向它的左值)。在这种情况下,你不能仅仅移动这本书到你的书架上,因为这样这本书的原本位置就没有书了。所以,你需要创建这本书的一个复制品(通过拷贝构造函数),然后添加到你的书架上。这就是push_back(const T&)版本,它接受一个左值引用,然后做一个拷贝。
然后,如果你要添加一本书,但你不关心这本书来自哪里,或者这本书只是临时的,你明白这本书可以随意移动。这是一个比拷贝一个全新的书更有效的方式。这就是push_back(T&&)版本,这个版本接受一个右值引用,然后将书("T"类型的对象)移动到你的书架上。
总的来说,左值版本和右值版本的区别在于,左值版本进行的是拷贝操作,它更安全但可能更耗费资源(因为需要复制新的对象);而右值版本进行的是移动操作,它通常更有效,但需要确认移动操作不会对原数据造成不良影响。
进一步解释右值的移动语义:
什么是右值?
在C++中,右值是指那些没有名称且不能被赋值的临时对象。例如:
int a = 10;
int b = 20;
int c = a + b; // a + b 的结果是一个右值
在这段代码中,a + b
的结果就是一个右值,它没有名称,只是一个临时的计算结果。
右值引用 (Rvalue Reference)
C++11引入了右值引用,使用&&
表示,它专门用于绑定右值,从而能够方便地实现移动语义。右值引用允许我们捕获将要销毁的临时对象,并“搬走”其资源,而不是进行拷贝。
int &&x = 10; // x 是一个右值引用,它绑定到临时右值10
移动语义 (Move Semantics)
移动语义的基本思想是将资源(如动态内存、文件句柄等)的所有权从一个对象“移动”到另一个对象,而不进行深拷贝。这个过程通常使用右值引用来实现。
示例:
考虑一个简单的动态数组类,实现支持移动语义:
#include<iostream>
#include <utility> // for std::move
class DynamicArray {
private:
int* data;
std::size_t size;
public:
// 构造函数
DynamicArray(std::size_t size)
: size(size), data(new int[size]) {}
// 析构函数
~DynamicArray() {
delete[] data;
}
// 移动构造函数
DynamicArray(DynamicArray&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr; // 将other置于一个有效但“空”的状态
other.size = 0;
}
// 移动赋值运算符
DynamicArray& operator=(DynamicArray&& other) noexcept {
if (this != &other) {
// 释放当前对象的资源
delete[] data;
// 移动资源
data = other.data;
size = other.size;
// 将other置于一个有效但“空”的状态
other.data = nullptr;
other.size = 0;
}
return *this;
}
void print() const {
for (std::size_t i = 0; i < size; ++i) {
std::cout << data[i] << ' ';
}
std::cout << '\n';
}
};
int main() {
DynamicArray arr1(5);
// 动态数组的状态从arr1“移动”到了arr2
DynamicArray arr2 = std::move(arr1);
arr2.print(); // OK, 打印数组
// arr1.print(); // 不安全,arr1的数据已被移动
return 0;
}
解释:
-
移动构造函数:
DynamicArray(DynamicArray&& other) noexcept
- 这是一个移动构造函数,它接收一个右值引用。
- 它将
other.data
和other.size
的值转移到新的对象中,然后将other.data
设置为nullptr
,other.size
设置为0,以确保other
处于安全的销毁状态。
-
移动赋值运算符:
DynamicArray& operator=(DynamicArray&& other) noexcept
- 它也是接收一个右值引用。
- 它首先释放当前对象的资源,然后将
other
的资源转移过来,并将other
重置为一个有效但空的状态。
关键点:
- 避免深拷贝:通过转移内存资源的所有权,可以避免不必要的深拷贝,从而提高性能。
- 安全性:通过将原对象(右值)置于一个有效但空的状态,确保程序在销毁时不会出现问题。
总结
- 移动构造: 用于将一个临时对象的内存所有权转移到一个新的对象,而不进行深拷贝。
- 移动赋值: 用于将一个临时对象的内存所有权转移到已有对象,而不进行深拷贝。
移动语义是现代C++中的重要特性,通过它可以显著提高程序的性能,特别是在处理大量资源管理的情况下。
45. move底层是怎么实现的?
Move的功能是将一个左值引用强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义,从实现原理上讲基本等同一个强制类型转换。
优点:
可以将左值变成右值而避免拷贝构造,将对象的状态所有权从一个对象转移到另一个对象,只是转移,没有内存搬迁或者内存拷贝。
解释:
std::move() 函数就是用来将左值转换为右值。其核心目的就是为了能够在确保不影响原来资源的情况下,转移对象的所有权。换句话说,就是为了实现“移动语义”。
在 C++ 中,我们通常通过 std::move() 函数来将一个对象标记为可以被 “移动”,而不是被 “拷贝”。这样,我们就可以利用在标准模板库(STL)中那些支持移动语义的函数(比如 std::vector::push_back 等)来传递右值引用,从而实现更高效的数据处理。
需要注意的是,std::move() 并不会执行实际的移动操作,它只是产生一个右值引用,真正的移动操作是由接收右值引用的函数(例如移动构造函数或移动赋值运算符)完成的。所以,std::move() 更像是一种类型转换,只是将对象的类型暂时从左值转换为右值,并不能真正意义上的 “移动” 数据。
另外,一旦我们对一个对象执行了 std::move(),最好假设这个对象已经处于一个无效的状态,避免再次使用它,以防数据的非法访问。当然,这也依赖于具体对象的移动构造函数或移动赋值运算符的具体实现。
46. 完美转发的原理是什么?
template <typename T>
void function(T&& arg) {
other_function(std::forward<T>(arg));
}
完美转发是指函数模板可以将自己的参数完美的转发给内部调用的其他函数,完美是指不仅能够准确的转发参数的值,还能保证被转发的参数的左、右值属性不变,使用引用折叠的规则,将传递进来的左值以左值传递出来,将传递进来的右值以右值的方式传出。
解释:
完美转发 (Perfect Forwarding) 是一个十分实用的技术,它让我们能够在函数中保持参数的原有特性(左值、右值,以及其他const/volatile修饰等),并将它们按原样转发到其他函数。
为了实现这个,我们使用了两个特性,即右值引用 (T&&) 和模板参数推导。当一个函数的形参是一个通用的右值引用 (also known as forwarding reference),并且当我们以std::forward< T >来转发参数,我们可以完美地转发所有的参数类型。
比如下面的函数模板就实现了完美转发:
template <typename T>
void function(T&& arg) {
other_function(std::forward<T>(arg));
}
这里,无论other_function的参数是左值还是右值,或者是否带有const/volatile修饰,function模板都能保持这些特性并安全地转发给other_function。
但应注意的是,尽管这样可以让函数保持参数特性并转发,但附带的复杂性和潜在的风险也增加了。因此,在编写使用完美转发的代码时,必须要格外小心。
47. 空类中有什么函数?
默认构造函数、默认拷贝函数、默认析构函数、默认赋值运算符、取值运算符、const取值运算符。
解释:
- 默认构造函数:当对象被创建时自动执行。如果没有定义,编译器会生成一个。
- 默认拷贝构造函数:当使用现有的对象来初始化新对象时,会调用这个函数。如果没有定义,编译器会生成一个。
- 默认析构函数:当对象被销毁(离开作用域或被
delete
)时,这个函数会自动执行。如果没有定义它,编译器会为我们生成一个。 - 默认赋值运算符:当对对象进行赋值操作时,会自动调用这个函数。如果没有定义,编译器会生成一个。
- 取值运算符:这个运算符允许我们访问类对象的非静态成员。
- const取值运算符:它允许我们访问const对象的非静态成员,注意,通过const取值运算符返回的成员值是不能修改的。
其中,取值运算符和const取值运算符会在我们尝试访问对象的成员时生成。这些函数提供了类的基本操作,有了这些,空类也可以进行一些基本的操作,如创建对象,进行赋值等。至于其他如拷贝赋值运算符或移动构造函数等,编译器在需要时也会自动生成。
48. explicit用在哪里?有什么作用?
只能用于修饰只有一个参数的类构造函数(有一个例外就是,当除了第一个参数以外的其他参数都有默认值的时候此关键字依然有效)。它的作用是表明该构造函数是显示的,而非隐式的。跟他对应的另一个关键字是implicit,意思是隐藏的,类构造函数默认情况下声明为implicit。
作用是防止类构造函数的隐式自动转换。
解释:
关键字 explicit
是C++中的一个关键字,主要用于防止不希望发生的隐式类型转换。
当你定义了一个构造函数,这个构造函数可以接受一个参数,那么这个构造函数就可以作为一个隐式类型转换操作符。例如,考虑以下的代码:
class A
{
public:
A (int x) { }
};
void foo (A a)
{
//...
}
int main()
{
foo (10); // 与foo(A(10))等效,发生隐式转换
return 0;
}
在这段代码中,由于我们有一个接受一个int
参数的构造函数,编译器就可以使用这个构造函数将一个int
隐式地转换为一个A
对象。
这可能会导致一些我们不希望发生的行为。在这种情况下,我们可以使用explicit
关键字
class A
{
public:
explicit A (int x) { }
};
一旦构造函数被声明为explicit
,就不能使用这个构造函数进行隐式类型转换了:
foo (10); // 错误:不能将 'int' 转换为 'A'
foo (A(10)); // 正确
这样可以避免一些可能导致错误的隐式转换,提高代码的安全性和可读性。
49. 成员变量初始化的顺序是什么?
成员变量在使用初始化列表初始化时,与构造函数中初始化成员列表的顺序无关,只与定义成员变量的顺序有关。
如果不使用初始化列表初始化,在构造函数内初始化时,此时与成员变量在构造函数中的位置有关。
类中const成员常量必须在构造函数初始化列表中初始化。类中static成员变量,只能在类外初始化。
解释:
在C++中,成员变量的初始化顺序确实是由它们在类中声明的顺序决定的,而不是它们在初始化列表中出现的顺序。即使你在初始化列表中改变了成员的顺序,它们仍然会按照在类中定义的顺序进行初始化。这一规则适用于所有的成员变量,包括常量、引用以及普通的变量。
此外,你也正确地指出,在构造函数内部进行初始化和使用初始化列表不同。在构造函数内部进行赋值实际上是在对象创建后,执行赋值操作。所以,这样的操作并不是初始化,而是对其重新赋值。
对于const成员变量,由于其特性,只能被初始化一次,因此必须在构造函数的初始化列表中设定其初始值。
对于static成员变量,它们是类的所有实例共享的,不属于任何特定的实例,所以它们的初始化必须在类的外部进行,通常在源文件中进行。
初始化列表是C++中在构造函数的声明之后使用的一种特殊语法,用于初始化或赋值给类的成员变量。这种特性允许我们在对象被创建时就初始化其成员变量,而不是在对象创建完毕后再去赋值。
以下是一个使用初始化列表的简单示例:
class Test {
int a;
int b;
public:
Test(int x, int y) : a(x), b(y) { // 初始化列表开始
// 构造函数的主体
}
};
在上述代码中,Test
类有两个成员变量:a
和b
。在类的构造函数中,我们使用冒号(:
)开始初始化列表,然后列出了每个成员变量及其对应的初始值。a(x), b(y)
,就是初始化列表,表示将x
赋值给a
,将y
赋值给b
。
使用初始化列表有以下几个优点:
- 允许初始化const成员和引用类型成员,这两种成员在构造函数体内无法像普通成员变量一样进行赋值操作。
- 当成员是类对象时,可以调用对应类的构造函数,以避免在构造函数体内部再进行赋值操作,减少了一次不必要的构造和析构过程,提高了效率。
- 成员初始化的顺序完全由成员在类中的声明顺序决定,与初始化列表中的顺序无关,从而明确了初始化顺序,减少了出错的可能性。
不使用初始化列表,而在构造函数内部初始化,意味着我们是在构造函数的主体部分,也就是在大括号 {}
里面,给成员变量赋值。这里是一种更为传统的初始化方式,就像我们在一般的函数内部给变量赋值一样。
举个例子,假设我们有一个类Test
,它有两个成员变量a
和b
。如果我们在构造函数内部初始化,代码可能会这样写:
class Test {
int a;
int b;
public:
Test(int x, int y) {
a = x;
b = y;
}
};
在这种场景中,a = x;
和b = y;
这两行代码就是在构造函数内部初始化成员变量。
然而,这种方式有两个缺点:
-
首先,成员变量会在进入构造函数主体之前就被默认初始化,然后在构造函数主体中被再次赋值。这样的操作可能会导致一些效率问题,尤其在处理大型数据或者复杂对象时。
-
其次,
const
成员变量和引用类型的成员变量,无法在构造函数体内进行赋值,这是因为const
变量一旦被初始化就不能改变,引用变量在定义之后就不能改变引用的对象。对于这两种情况,只能使用初始化列表。
所以,尽管在一些简单的场景下在构造函数内部初始化也可以,但还是建议在可能的情况下使用初始化列表。
在C++中,静态成员变量(或称为类变量)需要在类体外进行初始化。这是因为静态成员属于类本身,而非类的任何特定实例。因此,它们在所有对象中都是共享的,只有一份内存,且只需要被初始化一次。
以下是一个简单的示例:
class MyClass {
public:
// 声明静态成员变量
static int myStaticVar;
};
// 在类外初始化静态成员变量
int MyClass::myStaticVar = 10;
在这个示例中,首先在类体中声明了一个静态整型成员变量 myStaticVar
。然后,在类体外部初始化这个成员变量,将其设为10。
需要注意的是:
-
对于静态成员变量,类外初始化时一定要记住使用类名和作用域解析运算符(
::
)。 -
类外初始化时,不能再指定数据类型,因为在类体内部已经声明过了。
-
非
const
的静态成员变量可以在类外部初始化和修改,对于static const
成员,其实在C++11之后,也是可以直接在类内部初始化的。
50. 指针占用的大小是多少?
64位电脑上占8字节,32位的电脑上占4字节,我们平时所说的计算机多少位是指计算机CPU中通用寄存器一次性处理、传输、暂时保存的信息的最大长度。即CPU在单位时间内能一次处理的二进制位数,因此CPU所能访问的内存所有地址由多少位组成,而8比特位表示1字节,就可以得出在不同位数的机器中指针的大小。
解释:
我们说的32位或64位计算机通常指的就是CPU的位数,也称作指令集架构。这个位数决定了数据通用寄存器一次性处理的数据量,以及CPU所能访问的最大内存地址。
在32位系统中,通常情况下,一个指针的大小为4字节(即32位)。因为32位系统的内存寻址范围约为4GB(2的32次方字节)
而在64位系统中,一个指针的大小则为8字节(即64位)。64位系统的内存寻址范围则远大于4GB,理论上可以达到16EB(Exabytes,即10亿亿字节)
值得注意的是,虽然64位系统的内存寻址能力远大于32位系统,但是实际上很少有系统会使用到这么大的内存,因此在实际运用中,经常选择的是处理器数据通道的宽度,即一次能处理的数据量,这也就是说64位处理器可以更快地处理更大的数据,提高系统的运行效率。