面试速通宝典——4
87. 编译器是如何实现重载的?
在编译时,编译器如果遇到了函数,就会在符号表里面命名一个符号来存放函数的地址。
如果函数的使用在定义之前编译,无法在符号表里找到对应的函数地址,则先标记为"?“(暂时未知),在全部编译结束后的链接过程将”?"在符号表里找到并替代为相应的函数地址。
如果函数的定义在使用之前编译,则可以直接在符号表里找到对应函数地址直接使用。
而在C语言中的符号表是以函数名为符号来存储函数地址,函数名相同的重载函数的地址应该不同,于是符号表中存在两个同符号的函数地址,在查找使用时会存在歧义和冲突。
而C++符号表中的符号不是以函数名命名的,称为函数名修饰规则,虽然函数名相同,但是函数参数等其他属性不同,取得符号也不同,所以不会产生查询歧义的问题,使得函数可以重载。
88. 使用条件变量的时候需要注意什么?
当signal先于wait时,该信号会丢失,不会被后续的wait捕获
条件变量wait时,条件的判断和wait操作需要锁来保证原子性,要保证这一点,需要生产者和生产资源、cond signal时加和cond wait相同的锁,这样就会保证cond wait和cond signal先后顺序不会有问题,无论是谁先执行,都不会存在问题。
解释:
- 条件变量:
条件变量是一种同步原语,通常用于在多线程编程中,使一个线程在特定条件满足之前等待,同时允许其他线程在该条件发生更改时通知等待的线程。- “等待”:当条件不满足时(例如,某个资源还未准备好),线程可以选择等待。调用条件变量的
wait
方法会将线程置于阻塞状态,并释放已获取的互斥锁,让其他线程有机会修改条件。 - “通知”:当条件发生变化时(例如,资源已经准备好),线程可以通过条件变量来通知其他等待这个条件的线程。这可以通过
notify_one
(唤醒一个等待线程)或notify_all
(唤醒所有等待线程)方法实现。 - “重检”:当被通知并从
wait
返回时,线程应重新检查条件以确定其是否真正满足。这是因为存在所谓的"虚假唤醒",即线程可能在条件实际满足之前被唤醒。
- “等待”:当条件不满足时(例如,某个资源还未准备好),线程可以选择等待。调用条件变量的
例如,我们可以使用条件变量来同步一个生产者线程和一个消费者线程。生产者线程负责生成数据并将其放入缓冲区,消费者线程则从缓冲区中取出数据进行处理。用条件变量可以使消费者线程在缓冲区为空(即数据不足)时等待,而在生产者线程向缓冲区添加数据后,消费者线程会被唤醒并开始处理新数据。
条件变量通常与互斥锁一起使用,以确保线程在检查条件和决定等待之间不会被打断,即这两个操作是原子的。同时,在修改条件(比如修改共享数据)时,一般也会使用互斥锁来保证数据一致性。
- 条件变量的要点:
- 检查所等待的条件:在调用
wait
方法之前,我们通常会在一个循环中检查所等待的条件。循环的目的是防止虚假唤醒,即wait
由于未知原因返回但条件并未满足。 - 使用正确的锁:在调用
wait
,notify_one
或notify_all
时,我们需要使用一个 unique_lock 来保护条件变量。在调用wait
时,锁会被释放,使其他线程有机会修改条件。等wait
返回时,锁会被重新获得。 - 避免死锁:在使用条件变量时,我们需要留意不要引入死锁。例如,如果两个线程都在等待对方发送信号,但它们都无法发送信号(例如,因为它们都在等待对方释放某个资源),那么就会发生死锁。
- 避免滞后唤醒:你已经提到了如果
signal
先于wait
发生,则该信号会丢失。为了防止这种情况,我们可以在修改条件的同时调用notify_one
或notify_all
。这样,如果有其他线程正在等待,它们将立即被唤醒。如果没有线程正在等待,那么这个通知将被忽视。 - 注意
notify_all
和notify_one
的区别:notify_all
会唤醒所有等待的线程,而notify_one
只唤醒一个。如果多个线程都在等待同一个条件,那么notify_all
可能更合适。
- 检查所等待的条件:在调用
89. 什么是函数调用约定?
函数调用约定就是对函数调用的一个约束和规定,描述了函数参数是怎么传递
和由谁清除堆栈
的。它决定了,函数参数传递的方式(是否采用寄存器传递参数,采用哪个寄存器传递参数,参数压栈的顺序等),函数调用结束后栈指针由谁恢复(被调用的函数恢复还是调用者恢复),函数修饰名的产生方法。
_ stdcall:__ 是standardcall的缩写,是C++的标准调用方式,规则如下:所有参数从右到左依次入栈,如果是调用类成员的话,最后一个入栈的是this指针。被调用函数自动清理堆栈,返回值在EAX。函数修饰名约定:VC将函数编译后会在函数名前面加上下划线前缀,在函数名后加上"@"和参数的字节数。
_ cdecl:__ 是CDECLaration的缩写(declaration,声明),表示C语言的默认函数调用方法,规定如下:所有参数从右往左依次入栈,所有参数由调用者清除,成为手动清栈。返回值在EAX中。函数修饰名约定:VC将函数编译后会在函数名前面加上下划线前缀,由于由调用者清理栈,所以允许可变参数函数的存在。
_ fastcall:__ 是快速调用约定,通过寄存器来传送参数,规定如下:用ECX和EDX传送前两个双字(DWORD)或更小的参数,剩下的参数仍然自右向左压栈传送。被调用函数在返回前清理传送参数的内存栈,返回值在EZX中。函数修饰名约定:VC将函数编译后会在函数名前面加上“@”前缀,在函数名后面加上“@”和参数的字节数。
_ thiscall:__ 是唯一一个不能明确指明的函数修饰符,thiscall只能用于处理C++类成员函数的调用,同时thiscall也是C++成员函数缺省的调用约定,由于成员函数调用还有一个this指针,因此必须特殊处理,规定如下:采用栈传递参数,参数从右向左入栈,如果参数个数确定,this指针通过TCX传递给被调用者,如果参数个数不确定,this指针在所有参数压栈后压入堆栈。对参数个数不确定的,调用者清理堆栈,否则由被调函数清理堆栈,__thiscall 不是关键字,程序员不能使用。
_ pascal:__ 与stdcall一样,在VC中已经被废弃。
90. 类内普通成员函数可以调用类内静态成员变量嘛?类内静态成员函数可以访问类内普通变量么?
类内普通成员函数可以调用类内静态变量,因为类内静态变量在编译时就已经完成了初始化和内存分配,类内普通函数调用类内静态变量说明类已经完成了实例化,所以可以调用。
静态函数可以直接访问静态变量,静态函数不能直接访问非静态变量,但是可以通过将类实例化对象后,静态函数去访问对象的非静态变量。
解释:
-
静态成员变量:只有一份存储空间,属于类,不属于任何对象,所有该类对象共享。无论创建多少个实例,静态成员都只有一份拷贝。无论是否有类的对象存在,静态数据成员都已经分配空间。静态成员需要在类外进行单独初始化。
-
静态成员函数:没有$this指针,所以只能访问静态成员(包括静态成员变量和这个静态成员函数,不能访问非静态成员变量和不能调用非静态成员函数。
-
普通成员函数都有一个默认参数,即this指针,它可以被用来访问调用对象的成员。所以,普通成员函数可以直接调用静态成员也可以调用非静态成员,因为对象已经存在。
-
静态成员变量: 静态成员变量不与类的任何对象关联,它只有一份副本,被所有类的对象共享。我们无需创建类的对象就可以访问静态成员变量,因为它存放在全局数据段。
-
静态成员函数: 跟静态成员变量一样,静态成员函数也不需要创建类的对象即可调用。并且在静态成员函数内部,只能访问属于同一个类的静态成员变量,不能访问非静态成员变量,这是因为非静态成员变量是与类的具体对象关联的,而静态成员函数在没有传入具体对象的情况下是无法确定应该访问哪个对象的非静态成员变量。
所以,“静态函数可以直接访问静态变量,静态函数不能直接访问非静态变量,但是可以通过将类实例化对象后,静态函数去访问对象的非静态变量”这句话的意思是:
静态函数可以直接访问该类的静态变量,它无需知道具体哪个对象,因为静态变量并不属于任何一个具体的对象。
但如果静态函数想要访问非静态变量,就需要某种方式知道要访问哪个对象的非静态变量,这通常通过在静态函数中创建或接收该类的对象实例来实现。这样,通过这个类的对象实例,我们就可以访问其非静态变量了。
91. 强制类型转化有哪几种类型?分别有什么特点?原理是什么?
Static_cast:用于数据类型的强制转换,强制将一种数据类型转化为另一种数据类型。
主要用法
:
1. 用于类层次结构中基类和派生类之间指针或引用的切换,进行上行切换(把派生类的指针或引用转换成基类表示)是安全的,进行下行转换(把=基类的指针或引用转换为派生类表示),由于没有动态类型检查,所以是不安全的。
2. 用于基本类型之间的转换,如把int转换成char,这种类型的转换也需要开发人员来保证
3. 把空指针转换成目标类型的空指针
4. 把任意类型的表达式转换成void类型
5. 涉及到类时,只能在有相互联系的类型中进行相互转换,不一定包含虚函数
注意:不能转换掉表达式中的const、volitate、__unaligned属性
Const_cast:用于强制去除类似于const这种不能被修改的常数特性
主要用法
:
- 用来修改类型的const或者volatile属性,除了const或volatile修饰之外,type_id和expression的类型是一样的
- 常量指针被转化为非常量指针,并且仍然指向原来的对象
- 常量引用被转换为非常量引用,并且仍然指向原来的对象,常量对象被转换成非常量对象
注意
:
const_cast不适用于去除变量的常量性,而是去除指向阐述对象的指针或者引用的常量性,即去除常量性的对象必须为指针或者引用。
Reinterpret_cast:用于改变指针或引用的类型,将指针或引用类型转换成一个足够长的整形,将整形转换为指针或引用
主要用法
:
- 传入类型必须是一个指针,引用,算术类型,函数指针,成员函数或成员指针
- 它可以把一个指针转换成一个整数,也可以把一个整数转换成一个指针
注意
:
在强制转换的过程中只是比特位的拷贝,在使用中必须特别谨慎
解释:
- 基本数据类型转换:
int i = 42;
double d = static_cast<double>(i); // 将整数转换为浮点数
在这个示例中,我们使用static_cast
将整数 i
转换为浮点数 d
。
- 在类层次结构中进行上向转换(从派生类转换为基类):
class Base { }; // 基类
class Derived : public Base { }; // 派生类
Derived derived_instance;
Base* b = static_cast<Base*>(&derived_instance); // 安全
这里创建了一个派生类 Derived
的实例,然后将它的地址向上转换为基类 Base
的指针。
- 在类层次结构中进行下向转换(从基类转换为派生类):
但需要注意的是这种转换是不安全的,因为我们不能保证基类指针b
确实指向了一个派生类对象。
Base* b2 = new Base;
Derived* d = static_cast<Derived*>(b2); // 不安全,假设b2实际上指向的是Derived对象
- NULL指针的转换:
int* p = 0;
void* vp = static_cast<void*>(p);
这个示例中,我们将NULL指针 p
转换为 void*
类型的指针 vp
。
- 将任意表达式转为void类型:
int i = 42;
static_cast<void>(i); // 结果被丢弃
这个示例中,我们将 int
值强制转换为 void
。这种转换通常在忽略函数返回值时使用。
表达式中的 const
、volatile
和 __unaligned
是在C++中用来修饰变量或函数的关键字。
-
const
:该关键字可以用来修饰变量,表示变量的值是常量,不能被修改。例如,const int a = 10;
表示a是一个整型常量,不能对其进行修改。如果尝试修改const
修饰的变量,编译器将会报错。 -
volatile
:该关键字,告诉编译器这个变量可能会被难以预料的修改,防止编译器对代码进行优化,每次获取变量的值的时候,都直接从它所在的内存读取,而不是使用保存在寄存器中的备份。常用于多线程编程,或者硬件编程。 -
__unaligned
:这是一个MSVC特定的关键字,它仅在Windows平台的Microsoft Visual C++编译器中有效。当我们希望一个数据进行非对齐访问的时候,我们可以使用__unaligned
关键字。对于非对齐的数据访问,常用于网络编程、文件系统编程等领域。
const_cast
主要用于移除变量的 const
或 volatile
属性。这在某些场合是有用的,例如当我们需要在某些函数中使用const指针或引用,但又需要在某些地方改变它引用的值。
const_cast
可以修改类型的const
或volatile
属性。这意味这你可以使用const_cast
来移除一个变量的const
或volatile
修饰。
const int a = 10;
int* p = const_cast<int*>(&a); // 把const int转为int指针
*p = 20; // 尝试修改a的值,但实际上是未定义行为
在这个例子中,我们首先定义了一个const整数 a
,然后使用 const_cast
创建了一个指向 a
的普通(非const)整数指针 p
。然后,我们尝试通过这个指针修改 a
的值。但是这样做是未定义行为,因为我们实际上是在修改一个const对象,即使我们通过一个非const指针来进行。
- “const_cast不适用于去除变量的常量性,而是去除指向阐述对象的指针或者引用的常量性,即去除常量性的对象必须为指针或者引用。”
这段话的意思是 const_cast
不能用来改变一个本身是const或volatile的对象,它只能改变一个指针或引用所指向的对象的const或volatile属性。
const int a = 10;
const_cast<int>(a) = 20; // 这是错误的,不能直接对const对象进行const_cast和赋值操作
在这个例子中,我们尝试直接对一个const对象进行 const_cast
和赋值操作,这是错误的。const_cast
只能用来改变指针或引用所指向的对象的 const
或 volatile
属性。这意味着你不能直接改变一个const对象,你需要通过一个非const指针或引用来进行。
92. 回调函数是什么?为什么要有回调函数?有什么优缺点?回调的本质是什么?
回调函数是指使用者自己定义一个函数,实现这个函数的程序内容,然后别人把这个函数(入口地址)作为参数传入别人的函数中,由别人的函数在运行时来调用的函数,简单地说就是发生某种事件时,系统或其他函数将会自动调用你定义的一段函数。
可以把调用者和被调用者分开,调用者不关心谁是被调用者,所以它只需要知道的,只是一个存在某种特定类型原型,某些限制条件的被调用函数。
优点:
- 可以让实现方根据回调方的多种形态进行不同的处理和操作
- 可以让实现方,根据自己的需要定制回调方的不同形态
- 可以将耗时的操作隐藏在回调方,不影响实现方其他信息的展示
- 让代码的逻辑更加集中,更加易读
缺点:
- 回调函数过多会导致代码难以维护
- 回调函数容易造成资源竞争:如果回调函数中有共享资源访问,容易出现资源争抢,导致程序出错
- 代码可读性差,可能会破坏代码的结构和可读性
本质:
是将函数当作参数使用,目的是为了使程序更加普适
解释:
回调函数是一种常见的编程模式,广泛应用于许多不同的上下文中。它们常被用在图形用户界面处理、异步编程、操作系统与硬件的交互和其他事件驱动的编程场景中。
让我们通过一个简单的Python伪代码示例来更深入地理解它:
def process_data(data, callback):
# 对数据进行某种处理
result = ...
# 调用回调函数,将结果作为参数
callback(result)
def my_callback(result):
# 定义处理结果的方式
print("结果为:", result)
# 调用函数并传入回调函数
process_data(my_data, my_callback)
在上述代码中,process_data
函数接受两个参数:data
和callback
。data
是需要处理的数据,callback
则是一个函数,这个函数定义了如何处理process_data
函数返回的结果。在这个例子中,my_callback
函数简单地打印出结果。
此外,回调函数可以是任何可以调用的对象,例如Python中的类实例方法。这使得回调函数为编程提供了很大的灵活性和扩展性。只要你确保你提供的函数/方法符合调用方的接口要求(参数类型和数量),你就可以使用回调来自定义处理逻辑。
93. Linux中的信号有哪些?
SIGINT: 终端中断符,默认动作:终止。当用户按中断键(Ctrl+C)时,终端驱动程序产生此信号并发送至前台进程组中的每一个进程,当一个进程在运行失控,特别是在终端输出大量信息时,常用此信号终止他。
SIGQUIT: 终端退出符,默认动作:终止+core。当用户在终端按退出键(Ctrl+\)时,终端驱动程序产生此信号,并发送给前台进程中所有进程,此信号不仅终止前台进程租,同时产生一个core文件。
SIGILL: 非法硬件指令,默认动作:终止+core。此信号表示进程已执行一条非法硬件指令
SIGRAP: 硬件故障,默认动作:终止+core。指示一个是实现定义的硬件故障。
SIGBUG: 硬件故障,默认动作:终止+core。指示一个实现定义的硬件故障,当出现某些类型的内存故障时,常产生此信号。
SIGKILL: 终止,默认动作:终止。这是两个不能被捕获或忽略的信号之一,他向系统管理员提供一个可以杀死任意进程的可靠方法。
SIGSEGV: 无效的内存引用,默认动作:终止+core。指示进程进行了一次无效的内存引用,通常说明程序有错,比如访问了一个未经初始化的指针。
SIGALRM: 定时器超时,默认动作:终止。如果在管道的读进程终止时写管道,则产生此信号,当类型为SOCK_STREAM的套接字已不再连接时,进程写该套接字也产生此信号。
SIGTERM: 终止,默认动作:终止。这是由kill命令发出的系统默认终止信号,由于该信号是由应用程序捕获的,所以使用SIGTERM也让程序有机会在退出之前做好清理工作,与SIGKILL不同的是,SIGKILL不能捕捉。
SIGCONT: 使暂停进程继续,默认动作:忽略。此进程发送给需要运行但是目前状态是暂停的1进程,如果接受到此信号的进程处于暂停状态则继续运行,否则忽略。
SIGURG: 紧急情况,默认动作:忽略。通知进程发生一个紧急情况,在网络上接到带外的数据时,可以选择产生此信号。
SIGPOLL: 可轮询事件,默认动作:终止。产生条件当一个可轮询设备上发生一个特定事件时产生
SIGIO: 异步IO,默认动作:终止。产生异步IO时产生。
解释:
SIGINT和SIGQUIT都是可以发送给程序的中断信号,但它们的应用场景和结果有所不同:
-
SIGINT:这是一个“中断”信号,当用户按下
Ctrl+C
键时,终端将发送SIGINT信号给正在运行的进程。大多数程序会在收到SIGINT信号后终止运行,但也有一些程序(如文本编辑器vim等)会捕获这个信号并进行一些特定的处理,比如保存工作状态等。总的来说,SIGINT是一个相对温和的中断信号。 -
SIGQUIT:这是一个“退出”信号,当用户按下
Ctrl+\
键时,终端将发送SIGQUIT信号给正在运行的进程。与SIGINT不同,SIGQUIT信号不仅会终止进程,还会生成一个core文件。一个core文件是一个用于存储进程当前状态的文件,包括进程的内存、寄存器状态等信息,它的主要用途是帮助开发者对程序进行调试。因此,如果你的程序突然崩溃,你可能会希望能够获取一个core文件用来分析问题出在哪里。然而,同时也要注意core文件可能会占用大量的磁盘空间。
总的来说,这两个信号都被设计为用于中断程序,但是SIGQUIT信号可能会更“猛烈”一些,因为它会终止程序并生成一个core文件,而这个文件可能会占用大量的磁盘空间。所以,在日常使用中,我们通常只使用Ctrl+C
发送SIGINT信号来中断程序。
94. 什么是尾递归?
尾递归是递归的一种特殊情形,尾递归是一种特殊的尾调用,即在尾部直接调用自身的递归函数。核心思想是边调用边产生结果。
原理:
当编译器检测到一个函数调用是尾递归的时候,他会覆盖当前的活动记录而不是在栈中创建一个新的。编译器可以做到这一点,因为递归调用是当前活跃期内最后一条待执行语句,于是当这个调用返回栈帧中并没有其他事可做,因此也就没有保存栈帧的必要了,通过覆盖当前的栈帧而不是在其之上再重新添加一个,这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高。
特点:
在尾部调用的是函数自身,可通过优化使得计算仅占常量栈空间。
解释:
尾递归,如你所说,是一种特殊的递归,它的特点是在函数返回的时候调用自身,并且,return语句不能包含任何表达式。这样做的好处是,编译器或解释器可以将尾递归做优化,从而使递归的深度不再影响栈的深度,即,它消除了深度与栈大小的线性关系,这样就不会出现栈溢出的风险了。
为了解释这个概念,让我们来看一个实际的例子:计算阶乘。标准的递归实现如下:
def factorial(n):
if n == 1:
return 1
else:
return n * factorial(n - 1)
这段代码虽然可以正确计算阶乘,但是它不是尾递归,因为在return n * factorial(n - 1)
语句中,我们在递归调用返回后还有一个额外的乘法操作。
下面是尾递归的版本:
def factorial(n, acc=1):
if n == 1:
return acc
else:
return factorial(n - 1, n * acc)
这段代码中,我们引入了一个新的参数acc
来累积阶乘的结果,这样我们实际的递归调用factorial(n - 1, n * acc)
就变成了函数的最后一个动作,没有额外的运算,满足了尾递归的条件。
95. 为什么会有栈溢出?为什么栈会设置容量?
栈空间是预设的,它通常用于存放临时变量,如果你在函数内部定义一个局部变量,空间超出了设置的栈空间的大小,就会溢出。不仅如此,如果函数嵌套太多,也会发生栈溢出,因为函数没有结束前,函数占用的变量也不会被释放,占用了栈空间。
原因:
是栈的地址空间必须连续,如果任其任意成长,会给内存管理带来困难。对于多线程程序来说,每个线程都必须分配一个栈,因此没办法让默认值太大。
96. 二叉树和平衡二叉树的区别
二叉树没有平衡因子的限制,而平衡二叉树有。
二叉树可能退化成链表,而平衡二叉树不会。
97. 平衡二叉树的优缺点
优点:避免了二叉排序树可能出现的最极端的情况(退化成链表),其平均查找时间是logN。
缺点:对AVL树做一些结构修改的操作,性能非常低下,比如:插入时要维护其绝对平衡,旋转的次数比较多,更差的是在删除的时候,有可能一直让旋转持续到根的位置。
98. 什么是this指针?为什么存在this指针?
类和对象中的成员函数存储在公共的代码段,不同的对象调用成员函数时编译器为了知道具体操作是哪一个对象给每个“非静态的成员函数”增加了一个隐藏的指针参数,让该指针指向当前对象,在函数体中所有成员变量的操作,都是通过这个指针来完成的由编译器自动完成。
解释:
在面向对象编程中,类是创建对象的模板,而对象则是类的实例。当我们在类中定义了方法(成员函数)后,这些代码实际上是存在于代码段(而非每个对象中)的,因此所有对象会共享它们。
然后,当我们调用某个对象的方法时,我们需要知道是哪个对象正在调用这个方法 - 毕竟不同的对象可能会有不同的属性值。这就是为什么编译器会给每个非静态成员函数添加一个隐藏的指针参数,我们通常称这个隐藏的指针为this
指针。
this
指针指向正在调用成员函数的那个对象。因此,当你在成员函数中看到诸如this->someVariable
的代码,它实际上是指当前对象的someVariable
属性。这种机制让我们可以在成员函数中访问和操作对应对象的属性。
因此,你可以把this
指针看作是一个特殊的参数,它自动将当前的对象作为参数传递给成员函数。其机制被编译器自动完成,这使得程序员可以更直观地操作对象的属性,不需要显式地将对象作为参数提供给函数。
99. 什么是重载、重写、隐藏?
重载: 函数名相同,函数参数不同,两个参数在同一作用域。
重写: 两个函数分别在子类和父类中,函数名,返回值,参数均相同,函数必须为虚函数。
隐藏: 在继承关系中,子类实现了一个和父类名字一样的函数。这样子类的函数就把父类的同名函数隐藏了。隐藏只与函数名有关。
解释:
-
重载(Overloading):在同一作用域中,如果有两个或者多个函数名相同但参数列表不同(参数类型,参数顺序或者参数个数),我们说这些函数是重载的。重载使得我们可以使用一样的函数名,但根据提供的参数类型和数量的不同,调用不同的函数。
-
重写(Overriding):在类的继承关系中,子类可以提供一个与父类中同名的函数,这个函数的返回值类型和参数列表都与父类中的函数完全相同,这种情况我们说子类的函数重写了父类的函数。重写让子类有机会在继承父类的同时,提供自己特有的实现。
-
隐藏(Hiding):在类的继承关系中,如果子类提供了一个与父类同名的函数,但函数参数或返回值型与父类的不一样,那么在访问子类对象时,我们将无法通过子类对象直接调用那个父类中的函数,我们说子类的函数隐藏了父类的函数。我们只能通过使用父类名字或者类型转换来显式调用父类的函数。
1. 重载 (Overloading)
函数重载是指在同一作用范围内,可以有一组具有相同名称但参数不同(参数个数、类型或者顺序)的函数:
#include<iostream>
using namespace std;
void display(int num) {
cout << "Displaying int: " << num << endl;
}
void display(double num) {
cout << "Displaying double: " << num << endl;
}
int main() {
display(5); # 输出: Displaying int: 5
display(5.5); # 输出: Displaying double: 5.5
return 0;
}
2. 重写 (Overriding)
函数重写,或者说方法覆盖,发生在继承关系中,子类重写父类的虚函数:
#include<iostream>
using namespace std;
class Base {
public:
virtual void show() {
cout << "Base's show" << endl;
}
};
class Derived : public Base {
public:
void show() override {
# 这里是函数重写
cout << "Derived's show" << endl;
}
};
int main() {
Derived d;
d.show(); # 输出: Derived's show
return 0;
}
3. 隐藏 (Hiding)
如果在子类中定义了与父类同名的方法(参数不同也算),子类中的方法就会隐藏父类中所有的同名方法:
#include<iostream>
using namespace std;
class Base {
public:
void display() {
cout << "Base's display" << endl;
}
void display(int num) {
cout << "Base's display: " << num << endl;
}
};
class Derived : public Base {
public:
# 这里就发生了函数隐藏
void display() {
cout << "Derived's display" << endl;
}
};
int main() {
Derived d;
d.display(); # 输出: Derived's display
# d.display(5); # 这行代码会出错,因为子类中的 display() 隐藏了父类中的所有 display()
return 0;
}
其中重写和隐藏的意义比较相近,为避免你混淆,这里详细解释一下:
重写:
- 只有在基类中定义的函数为虚函数时,派生类才可以重写这个函数。
- 重写后的函数可以通过基类的指针或引用来调用,而调用的实际上是派生类的版本。这就是多态的实现。
隐藏:
- 如果派生类中定义了一个与基类中同名的函数,无论函数参数是否相同,无论基类函数是否为虚函数,均视为隐藏。
- 隐藏后,如果你通过基类的指针或引用调用此函数,将会调用基类的版本,而不是派生类的。
class Base {
public:
virtual void func() { cout << "Base func"; } // 虚函数
void func2() { cout << "Base func2"; } // 非虚函数
};
class Derived : public Base {
public:
void func() override { cout << "Derived func"; } // 重写基类的虚函数
void func2() { cout << "Derived func2"; } // 隐藏基类的非虚函数
};
int main() {
Base* b = new Derived(); // 虽然b是一个基类指针,但指向的是派生类对象
b->func(); // 输出 "Derived func",因为发生了重写
b->func2(); // 输出 "Base func2",因为发生了隐藏
return 0;
}
以上代码展示了C++中重载、重写和隐藏的概念,希望能帮助你更好地理解这些概念。
100. 构造函数可以是虚函数么?为什么?
虚表指针是存储在对象的内存空间,当调用虚函数的时候,是通过虚表指针指向的虚表里的函数地址进行调用的。
如果将构造函数定义为虚函数,就要通过虚表指针指向的虚表的构造函数地址来调用,而构造函数是实例化对象,定义为虚函数后,对象空间还没有实例化,那就没有虚表指针,自然无法调用构造函数,那构造函数就失去意义,所以不能将构造函数定义为虚函数。
101. 静态成员函数可以是虚函数么?为什么?
它不属于类中的任何一个对象或示例,属于类共有的一个函数,不依赖于对象调用,静态成员函数没有this指针,无法放进虚函数表。
102. make_shared函数的优点、缺点?
优点:
减少了内存分配的次数,降低了系统开销,提高了效率,使用new构造的话至少会进行两次内存分配,(一次为智能指针本身,一次为共享指针的控制块)。
缺点:
当构造函数是保护或私有的时候无法使用make_shared函数。
解释:
std::make_shared
是一种用来构造 std::shared_ptr
智能指针的实用工具。它被设计成替代直接使用 shared_ptr
构造函数的方法,具有更好的性能。
make_shared
用传入的参数来构造给定类型的对象,并返回一个包含新创建对象的 shared_ptr
。该函数模版会为对象分配内存,并进行初始化。
下面是一个使用std::make_shared
构造shared_ptr的例子:
std::shared_ptr<int> p = std::make_shared<int>(42);
std::cout << *p << std::endl; // 输出:42
在这个例子中,我们创建了一个int类型的shared_ptr,并用42作为其初始化值。使用make_shared
可以省去调用new
的步骤并且能保证对象的安全删除。这样当所有的shared_ptr都不再指向这个对象时,这个对象会被自动删除。
同时,使用std::make_shared
来创建 shared_ptr
可以提升性能和内存使用效率,因为它在一次动态内存分配调用中同时创建了管理的对象和控制块(包含引用计数及其他信息)。直接使用 shared_ptr
构造函数则需要两次调用,一次为对象,一次为控制块。
因此,在大部分情况下,推荐使用std::make_shared
来创建 std::shared_ptr
。
优点:
-
make_shared
减少了内存分配的次数。当你使用new
关键字来创建shared_ptr
时,内存会被分配两次:一次是为目标对象,一次是为shared_ptr
的控制块(包含引用计数器和删除器等信息)。但是,make_shared
只会进行一次内存分配,创建对象和控制块在同一块内存中。这会减少系统的内存开销,提高程序的效率。 -
make_shared
返回的shared_ptr
对象会保证即使在异常情况下,也会正确地删除内存。当使用new
来构造shared_ptr
时,如果在将new
返回的原始指针封装到shared_ptr
的过程中发生异常,则可能会导致内存泄漏。make_shared
函数避免了这种问题。
缺点:
-
如果类的构造函数是私有的或保护的,你不能使用
make_shared
。这是因为make_shared
需要在给定的类上调用构造函数,如果构造函数不是公开的,就无法调用。 -
可能会导致内存占用更多。当所有的
shared_ptr
都不再指向对象后,make_shared
返回的shared_ptr
会保持控制块的内存不被释放,直到所有的weak_ptr
也都不再指向对象。如果有大量的std::weak_ptr
,这可能会导致无法及时地释放内存。
103. 函数调用进行的操作有哪些?
- 将函数压栈:按照参数顺序的逆序进行,如果参数中有对象则先进行拷贝构造
- 保存返回地址:即函数调用结束后返回后接着执行的语句的地址
- 保护维护函数栈帧信息的寄存器内容,如SP(堆栈指针),FP(栈帧指针)等等。
- 保存一些通用寄存器的内容:因为有些通用寄存器会被所有函数用到,所以在函数调用之前,这些寄存器就可能放置了对函数有用的信息。
- 调用函数,函数执行完毕
- 恢复通用寄存器的值
- 恢复保存函数栈帧信息的那些寄存器的值
- 通过移动栈指针,销毁函数的栈帧
- 将保存的返回地址出栈,并赋给寄存器
- 通过移动栈指针,回收传给函数的参数所占用空间
104. 变量的声明和定义有什么区别?
为变量分配地址和存储空间的成为定义,不分配地址的成为声明。
一个变量可以在很多地方声明,但是只能在一个地方定义。
加入extern修饰的是变量的声明,说明此变量将在文件以外或在文件后面部分定义。
注:很多时候一个变量,只是声明不分配内存空间,直到具体使用时才初始化,分配内存空间,如外部变量。
105. 写出bool、int、float、指针变量与“零值”比较的if语句
bool型数据:
if( flag ) { A; } else { B; }
int型数据:
if( 0 != flag ) { A; } else { B; }
指针型数:
if( NULL == flag ) { A; } else { B; }
float型数据:
if ( ( flag >= NORM ) && ( flag <= NORM ) ) { A; }
请注意:
应特别注意在int、指针型变量和“零值”比较的时候,把“零值”放左边,这样当把“ == ”误写成“=”时,编译器可以报错,否则这种逻辑错误不容易被发现,并且可能导致很严重的后果。
106. sizeof和strlen的区别
sizeof和strlen有以下区别:
1. sizeof是一个操作符,strlen是库函数
2. sizeof的参数可以是数据的类型,也可以是变量,而strlen只能以’\0’的字符串做参数。
3. 编译器在编译时就计算出了sizeof的结果,而strlen函数必须在运行时才能计算出来。
4. sizeof计算的是数据类型占内存的大小,而strlen计算的是字符串实际的长度。
5. 数组做sizeof的参数不退化,传递给strlen就退化为指针了。
请注意:
有些是操作符看起来像是函数,而有些函数名看起来又像操作符,这类容易混淆的名称一定要加以区分,否则遇到数组名这类特殊的数据类型做参数时就很容易出错。最容易混淆为函数的操作符就是sizeof。
107. C语言的关键字static和C++关键字static有什么区别?
在C
中static用来修饰局部静态变量和外部静态变量、函数。
C++
中除了上述功能外,还用来定义类的成员变量和函数。即静态成员和静态成员函数。
请注意:
编程时static的记忆性和全局性的特点可以让在不同时期调用的函数进行通信,传递信息,而C++静态成员则可以在多个对象实例间进行通信,传递信息。
108. C中的malloc和C++中的new有什么区别
malloc
和new
有以下不同:
- new、delete是操作符,可以重载,只能在C++中使用
- malloc、free是函数,可以覆盖,C和C++都可以使用
- new可以调用对象的构造函数,对应的delete可以调用相应的析构函数
- malloc仅仅分配内存,free仅仅回收内存,并不执行构造和析构函数
- new、delete返回的是某种数据类型的指针,malloc、free返回的是void指针
请注意:
malloc申请的内存空间要用free释放,而new申请的内存空间要用delete函数释放,不要混用。因为两者实现的机理不同。
109. 写一个“标准”宏MIN
#define min(a,b)((a)<=(b) ? (a) : (b))
请注意:
在调用时一定要注意这个宏定义的副作用,如下调用:
((++* p)<=(x)?(++* p):(x)。
p指针就自加了两次,违背了MIN的本意。
110. 一个指针可以是volatile吗?
可以,因为指针和普通变量一样,有时也有变化程序的不可控性。常见例子:子中断服务子程序修改一个指向一个buffer的指针时,必须用volatile来修饰这个指针。
请记住:
指针是一种普通的变量,从访问上没有什么不同于其他变量的特性。其保存的数值是个整形数据、和整型变量不同的是,这个整型数据指向的是一段内存地址。
解释:
在C++中,volatile
是一个类型修饰符,用于告诉编译器这个变量可以被程序之外的因素修改,如:操作系统、硬件或者其他线程。在许多情况下,使用 volatile
关键字可以确保代码的正确性。
为了优化性能,编译器在不改变输出的前提下会进行很多优化,比如表达式求值的优化、指令重排等。但是,当这样一个变量被 volatile
修饰后,编译器就不再假设值的不变性,即使在没有显式的代码修改这个变量,编译器也会认为这个变量的值可能会被改变。因此,每次引用该变量的时候,系统都会从该变量所在的内存中重新读取数据,而不是使用保存在寄存器中的备份。
你提到的例子是一个很好的用 volatile
的场景。在你的例子中,中断服务程序可能会在任何时候修改指向buffer的指针。如果不使用 volatile
,编译器可能会做出错误的假设,认为在主程序运行过程中,这个值不会改变,从而可能采用不安全的优化。而使用 volatile
可以确保每次在程序中使用该指针时,都会直接从内存中读取其值,这样就不会因为编译器的优化而读到错误的值。
再简单总结一下,volatile
的主要作用就是防止编译器对被 volatile
修饰的变量进行过度优化,确保每次访问这个变量时都能获得最新的、正确的值。
111. a 和 &a 有什么区别?
这里通过一段代码来阐释两者之间的区别:
#include<stdio.h>
void main( void )
{
int a[5]={1,2,3,4,5};
int *ptr=(int *)(&a+1);
printf("%d,%d",*(a+1),*(ptr-1));
return;
}
输出结果:2,5。
&a
的类型是int(*)[5]
,不是int*
。那么,&a+1
的操作其实是越过了整个数组a
,跳到了数组a
之后的内存位置。
下面是具体的解释:
-
先看
*(a+1)
,这里的a
是数组首元素的地址,a+1
就是数组第二个元素的地址,所以*(a+1)
的值就是数组的第二个元素的值,也就是2。 -
再来看
*(ptr-1)
,ptr
是&a+1
的结果转化为int*
的地址,它实际指向的是在数组a
之后的内存位置。(ptr-1)
就回到了数组的最后一个元素的位置(因为每个元素的长度是sizeof(int)
),所以*(ptr-1)
的值就是数组的最后一个元素的值,也就是5。
将原式的int * ptr=(int * )(&a+1);改为int * ptr=(int * )(a+1);时输出结果将是什么呢?
输出结果:2,1。
请注意:
数组名a可以作数组的首地址,而&a是数组的指针。
112. 简述C,C++程序编译的内存分配情况
C,C++中内存分配方式可以分为以下三种:
- 从静态存储区分配:内存在程序编译时就已经分配好,这块内存在程序的整个运行期间都存在。速度快,不容易出错,因为有系统会善后。例如全局变量、static变量等。
- 从栈上分配:在执行函数时,函数内局部变量的存储单元都在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于这些处理器的指令集中,效率很高,但是分配的内存容量有限。
- 从堆上分配:即动态分配内存。程序在运行时用malloc或new申请任意大小内存,程序员自己负责在何时用free或delete释放内存。动态内存的生存期由程序员决定,使用非常灵活。如果在堆上分配了空间,就有责任回收他,否则运行的程序会出现内存泄漏,另外频繁地分配和释放不同大小的堆空间将会产生堆内碎块。
113. 简述strcpy、sprintf与memcpy的区别
三者主要有以下的不同之处:
- 操作对象不同,strcpy的两个操作对象均为字符串,sprintf的操作源对象可以是多种数据类型,目的操作对象是字符串,memcpy的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。
- 执行效率不同,memcpy最高,strcpy次之,sprintf的效率最低
- 实现功能不同,strcpy主要实现字符串变量之间的拷贝,sprintf主要实现其他数据类型格式到字符串的转化,memcpy主要是内存块之间的拷贝。
请注意:
strcpy、sprintf和memcpy都可以实现拷贝的功能,但是针对的对象不同,根据实际需求,来选择合适的函数实现拷贝功能。
解释:
这三个函数都是C语言中常见的用于处理字符串或内存的函数。
-
strcpy(char *dest, const char *src)
: 这个函数用于复制字符串。函数的作用是将字符串src
复制到dest
中。必须保证dest
有足够的空间来存放src
的内容。函数会复制src
中的所有内容,包括结束符’\0’。 -
sprintf(char *str, const char *format, ...)
: 此函数用于格式化字符串。它将格式化数据从format
写入字符串str
。即将format
中定义的格式应用于后续的参数并将结果存储在str
中。注意保证str
有足够的空间来存放结果。 -
memcpy(void *dest, const void *src, size_t n)
: 这个函数用于内存复制,它将src
指向的内存的前n
个字节复制到dest
指向的内存上。dest
和src
所指向的内存应该是不重叠的,并且必须保证dest
有足够的空间来存放被复制的内容。与strcpy
不同的是,memcpy
是对内存的复制,它不关心内容是否是字符串,不会因为遇到’\0’结束符而停止复制。
114. 设置地址为0x67a9的整型变量的值为0xaa66
int *ptr;
ptr = (int *)0x67a9;
*ptr = 0xaa66;
请注意:
这道题就是强制类型转换的典型例子,无论在什么平台地址长度和整形数据的长度是一样的,即一个整型数据可以强制转换成地址指针类型,只要有意义即可。
解释:
在 C 语言中,指针是一个变量,它的值为另一个变量的地址。你可以通过指针来获取或修改它所指向的内存地址上的值。
-
int *ptr;
这行代码定义了一个整型指针ptr
,这时候ptr
的值是未知的,因为我们没有给它初始化。 -
ptr = (int *)0x67a9;
这行代码将ptr
的值设置为0x67a9
。这里的0x67a9
是一个内存地址,(int *)
是一个类型转换操作,它告诉编译器我们现在把0x67a9
当作一个整型指针来处理。 -
*ptr = 0xaa66;
这行代码将ptr
所指向的内存地址(也就是0x67a9
)上的值设置为0xaa66
。
因此,这段代码可以实现设置地址为 0x67a9
的整型变量的值为 0xaa66
。需要注意的是,我们通常不建议直接设定硬编码的内存地址,除非你确切知道你在做什么,否则这可能会带来严重的问题,比如破坏其他数据或者是引发安全问题。
115. 面向对象的三大特征
面向对象的三大特征是封装性、继承性和多态性。
封装性:将客观事物抽象成类,每个类对自身的数据和方法实行protection(private、protected、public)。
继承性:广义的继承有三种实现方式:实现继承(仅使用属性和方法而无需额外编码的能力)、可视继承(子窗体使用父窗体的外观和实现代码)、接口继承(仅使用属性和方法,实现滞后到子类实现)。
多态性:是将父类对象设置成为和一个或更多它的子对象相等的技术。
说明:面向对象的三个特征是实现面向对象技术的关键,每一个特征的相关技术都非常的复杂,程序员应该多看多练。
解释:
封装性
封装是一种把数据和数据的操作方法绑定在一起,以保护数据的方法,防止外部访问和操作引发错误。封装性有助于维护代码,提高可读性以及降低因代码更改引发错误的可能性。
例如,让我们设想一个“银行帐户”类。这个类具有数据(如账户余额)和方法(如存款和取款)。确保只有通过定义的方法才能访问数据,这就是封装的应用。
继承性
继承指的是一个类(称为子类或派生类)可以继承另一个类(称为超类或基类)的属性和方法。这允许我们创建类并在此基础上为特定的需求添加或修改功能,而无需每次从零开始。
- 实现继承:派生类继承了基类的实现(即代码),而无需自己重新编写。例如,如果有一个动物类具有"移动"的方法,那么我们可以创建一个继承动物类的狗类,不需要重新编写"移动"的方法即可使用。
- 可视继承:在图形用户界面编程中,一个子窗体可能继承了父窗体的外观和实现代码,如窗口的大小,颜色等。
- 接口继承:派生类仅继承基类的方法的签名,而实现由派生类自身封装。这允许设计灵活制定"契约",约束子类行为。
多态性
多态性是指允许您将父对象设置成为和它的一个或多个子对象等同,这样就可以更通用地编写代码,并能在运行时动态改变对象的行为。
多态性的实现方式主要是通过方法覆写和接口实现。例如,如果你有一个“动物”基类和几个派生类如“猫”和“狗”,并且这些类都有一个“叫”方法,那么你可以创建一个“动物”引用来指向“猫”或“狗”对象,然后通过这个引用调用“叫”方法,实际执行的是相应对象的“叫”方法,降低代码的复杂度,提高了可重用性和可维护性。
116. C++空类中有哪些成员函数?
缺省构造函数、缺省拷贝构造函数、缺省析构函数、缺省赋值运算符、缺省取址运算符、缺省取址运算符const。
解释:
有些书上只是简单的介绍了一下前面四个函数,没有提及后面的两个函数。但后面的两个函数也是空类的默认函数。另外需要注意的是,只有当实际使用了这些函数,编译器才会定义他们。
117. 谈谈你对拷贝构造函数和赋值运算符的认识
拷贝构造函数和赋值运算符重载有以下两个不同之处:
- 拷贝构造函数生成新的类对象,而赋值运算符不能。
- 由于拷贝构造函数是直接构造一个新的类对象,所以在初始化这个对象之前不用检验原对象是否和新建对象相同。而赋值运算符则需要这个操作,另外赋值运算符中如果原来的对象中有内存分配要先内存释放掉。
请注意:
当有类中有指针类型的成员变量时,一定要重写拷贝函数和赋值运算符,不能使用默认的。
解释:
在C++中,拷贝构造函数和赋值运算符都是用于复制对象的,但是它们的用法和操作略有不同。
拷贝构造函数是用来构建一个新对象并用另一对象的值初始化它。因为它是直接创建一个全新的对象,所以不需要考虑两个对象是否是同一个对象。例如,我们可以创建一个新对象B,并用已存在的对象A来初始化它。编译器不需要检查B和A是否是同一个对象,因为此时的B对象是新创建的,不可能与A相等。
ClassType objA;
ClassType objB = objA; // 使用拷贝构造函数
然而,赋值运算符"="则是在对象已经存在的情况下,用另一对象的值来改变这个对象的值。在这个过程中,需要确保我们不将一个对象赋值给它自身,因为这样的操作没有意义,并且在某些情况下可能会导致错误(比如在涉及到内存管理的对象赋值时)。也就是说,如果我们试图执行类似objA = objA;
这样的操作,需要在赋值运算符的逻辑中加入检查,避免自我赋值。
ClassType objA, objB;
objA = objB; // 使用赋值运算符
此外,如果对象中包含了动态分配的内存,那么在赋值操作时,需要先释放原有的内存,然后重新分配内存并复制新的值,以防止内存泄露。
当你的类中有成员变量为动态分配的内存(通常由指针表示)时,使用编译器提供的默认拷贝构造函数和赋值运算符可能会导致一些问题。
首先,让我们解释下默认的拷贝构造函数和赋值运算符的行为:
- 默认拷贝构造函数:对类的每个非static成员进行拷贝,对于类类型的成员变量,会调用其拷贝构造函数;对于内置类型的成员变量,会进行简单的值拷贝。如果成员变量是指针,它会复制指针的值(也就是地址),而不是复制指针所指向的内容。
- 默认赋值运算符:的行为与默认拷贝构造函数类似,复制类的非static成员。
现在,假设你的类中有一个指针成员变量指向动态分配的内存,使用默认的复制操作会导致浅拷贝问题。因为默认复制只复制指针的值,这就导致拷贝后新旧对象的该成员变量指向同一块内存,当其中一个对象释放这段内存时,另一个对象的指针成员就会变为悬挂指针,再访问就是非法的,这可能引发程序崩溃。
为了解决这个问题,你需要自行实现拷贝构造函数和赋值运算符,使之执行深拷贝,也就是说,不仅复制指针的值(地址),还要复制指针所指向的内容,确保每个对象有自己独立的、相同的副本,这样一个对象的修改不会影响到其他对象,也避免了由于内存释放导致的悬挂指针。
118. 用C++设计一个不能被继承的类
template <typename T>
class A{
friend T;
private:
A(){};
~A(){};
};
class B : virtual public A<B>{
public:
B(){};
~B(){};
};
class C : virtual public B{
public:
C(){};
~C(){};
};
void main(){
B b;//C c;
return ;
}
请注意:
构造函数是继承实现的关键,每次子类对象构造时,首先调用的是父类构造函数,然后才是自己的。
解释:
在这段代码中,类模板A是一个不能被直接构造或析构的类,因为它的构造函数和析构函数都是私有的。然而,由于B类被声明为A的友元类,所以它可以访问A的私有成员,因此可以继承A。
在这段代码中,B是类模板A的friend
,这是在类模板A中亲自声明的。关键字friend
告诉编译器,尽管A的构造函数和析构函数都是私有的,但是B可以访问它们。
你可以看到,在类模板A中,我们有一条这样的语句:friend T;
。当我们这样写,T
被替换为具体的参数时(在这个示例中,T
是B
),这个参数对应的类型就会成为A的友元。所以在我们的例子中,当我们写A<B>
,B
就成了A<B>
的友元。
于是,这样就创造了一个特殊的情况,即B是A的友元(能访问A的私有成员),可以继承私有构造函数和析构函数的A类。这样设计的目的是为了实现这个特殊的需求:B可以实例化,但无法被进一步继承,因为其继承链中的基类A的构造函数和析构函数不能被其他类访问。
重要的是,B类是通过虚继承的方式继承A的,这意味着对于任何继承自B的类(例如C类),它必须在其构造函数中显式或隐式地调用A的构造函数。但是A的构造函数是私有的,只有B类可以调用,其他任何类(包括B的派生类)都不能调用,所以C类(或其它任何尝试继承B的类)将无法编译。
这样,我们就创造了一个可以实例化但不能被进一步派生的类B。换句话说,这段代码实现了一个不能被继承的类。所以试图使用类C去继承B并创建一个C类的对象都会在编译时失败。
119. 访问基类的私有虚函数
写出以下程序的输出结果:
#include<iostream.h>
class A{
virtual void g(){
cout << "A::g" << endl;
}
private:
virtual void f(){
cout << "A::f" << endl;
}
};
class B : public A{
void g(){
cout << "B::g" << endl;
}
virtual void h(){
cout << "B::h" << endl;
}
};
typedef void (*Fun )( void );
void main(){
B b;
Fun pFun;
for(int i = 0 ; i < 3 ; i++){
pFun = ( Fun )*((int*) * (int*) (&b) + i);
pFun();
}
}
输出结果:
B::g A::f B::h
请注意:
本题主要考察了面试者对虚函数的理解程度。一个对虚函数不了解的人很难正确做出本题。在学习面向对象的多态性时一定要深刻理解虚函数表的工作原理。
解释:
首先,这程序中使用了C++的一种特性:虚函数表。在C++中,当类中声明了虚函数,编译器就会为这个类生成一个虚函数表,即vtable,vtable中存放的是该类的虚函数的地址。每个含有虚函数的类的对象中都有一个指向该类vtable的指针。
在这个程序中,类A和类B都有虚函数,所以都有各自的虚函数表。A的虚函数表中有一个虚函数A::g和A::f,B来自A,并且重写了g函数,B的虚函数表中有A::f,B::g和B::h。
编译器写入虚拟函数表的顺序是根据代码中虚函数出现的顺序来定的。类A中有函数g和f,同样也是在虚函数表中的顺序。在类B中,虽然重写了函数g,但是g的地址在虚函数表中的相对位置是不会改变的。那么,对于B,虚函数表的布局就如下所示:
- B::g
- A::f
- B::h
在main函数中创建了一个B的实例b,对于对象b,b的地址处存放的是指向B的vtable的指针。所以(int*)&b实际上获取的是这个vtable的地址。那么,*(int*)&b
就是vtable的地址,即虚函数表的首地址,把它转换为(int*)是因为int类型占4个字节(这很重要, 因为这决定了偏移的步长),vtable是一个个地址,每个地址占4字节,通过这样的转换,就能以步长为1按序访问vtable中的函数地址了。
然后循环执行(int*)_(int_)(&b)+i
,原理是先通过上述讲的方式获取虚函数表的首地址,然后每次偏移4个字节,取得对应虚函数的地址并调用。
那么现在执行结果就很明显了:
- 当i = 0: 执行
Fun = (Fun)_((int_)_(int_)(&b)),
得到的是B::g的地址,所以输出"B::g"; - 当i = 1: 执行
Fun = (Fun)_((int_)_(int_)(&b)+1)
,得到的是A::f的地址,所以输出"A::f"; - 当i = 2: 执行
Fun = (Fun)_((int_)_(int_)(&b)+2)
,得到的是B::h的地址,所以输出 “B::h”。
所以,最后的输出结果就是"B::g A::f B::h"。
120. 简述类成员函数的重写、重载和隐藏的区别
- 重写和重载主要有以下几点不同:
- 范围的区别:被重写的和重写的函数在两个类中,而重载和被重载的函数在同一个类中。
- 参数的区别:被重写函数和重写函数的参数列表一定相同,而被重载函数和重载函数的参数列表一定不同。
- virtual的区别:重写的基类中被重写的函数必须要有virtual修饰,而重载函数和被重载函数可以被virtual修饰,也可以没有。
- 隐藏和重写、重载有以下几点不同:
- 与重载的范围不同:和重写一样,隐藏函数和被隐藏函数不在同一个类中。
- 参数的区别:隐藏函数和被隐藏函数的参数列表可以相同,也可以不同,但是函数名肯定要相同。当参数不相同时,无论基类中的参数是否被virtual修饰,基类的函数都是被隐藏,而不是被重写。
请注意:
虽然重载和覆盖都是实现多态的基础,但是两者实现的技术完全不相同,达到的目的也是完全不同的,覆盖是动态绑定的多态,而重载是静态绑定的多态。
解释:
这段话主要是讲述C++的两种多态性的实现方式:函数重载和虚函数覆盖。他们的具体行为差别是根据编译时和运行时确定调用的函数是哪一个。让我详细解释一下:
-
函数重载(Overloading):这是静态绑定的多态,也就是在编译阶段就确定具体会调用的函数。函数重载的实现主要依据函数的声明,包括函数名、参数列表(也就是参数的数量和类型)来确定。换句话说,当你在编写代码时,函数的调用就已经决定了。这就是我们说的“静态绑定”。
-
虚函数覆盖(Override):这是动态绑定的多态,就是说具体调用哪个函数是在程序运行的时候确定的。这种情况一般发生在有继承关系的类中,子类覆盖了父类中的虚函数。编译器会根据对象的实际类型(注意,这里是实际类型,而不仅仅是引用或指针的类型),在运行的时候决定调用哪一个函数。这就是我们说的“动态绑定”。
综上所述,虽然函数重载和虚函数覆盖都是实现多态性的方式,但是他们的实现机制和目的是不同的。函数重载是在编译阶段确定,而虚函数覆盖是在运行时根据对象的实际类型动态确定的。