左值右值、移动语义、完美转发:什么是左值右值?std::move和std::forward的作用?移动构造函数何时被调用?
答:
左值是有身份,可以修改的值,右值是不可修改的值,本质是可移动,将亡值也是右值
std::move是一个将传入的参数强制转换为右值的这么一个接口,std::forward是可以保证参数在传递的过程中性质保持不变的接口,配合实现完美转发
用右值构造对象时会调用移动构造
评分:6.5/10
不足:
- 可以更精确:
std::forward通常用于模板中,根据模板参数类型有条件地将参数转换为右值或保持左值,实现完美转发。- 可以列举具体场景:1)用临时对象初始化;2)用
std::move转换后的对象;3)函数返回局部对象(可能触发RVO/NRVO,不一定调用移动构造);4)容器操作(如push_back(std::move(x)))。
修正:
第一层:左值和右值的精确定义
"在C++中,每个表达式都有两个属性:类型 和值类别。值类别分为三类:
左值(lvalue) :表达式指向一个持久的内存位置,可以取地址。例如:变量名、返回左值引用的函数调用、字符串字面量等。注意,const左值也是左值,虽然不能修改,但它有身份。
纯右值(prvalue) :表达式是临时值 或字面量(如42、true),没有持久身份,通常只能用于初始化或赋值。
将亡值(xvalue) :C++11引入,表示资源可以被回收的右值 ,例如通过
std::move转换的结果、返回右值引用的函数调用。将亡值既有身份(即将消亡的对象),又可以移动资源。在口语中,常将"纯右值"和"将亡值"统称为"右值"。左值持久,右值短暂。"
第二层:std::move 的作用
"
std::move本质上是一个强制类型转换 ,它无条件地将传入的实参转换为右值引用 。它的实现大致是static_cast<typename std::remove_reference<T>::type&&>(t)。重要的是:
std::move本身并不移动任何资源,它只是告诉编译器:"这个对象可以移动了"。真正的移动操作发生在移动构造函数或移动赋值运算符中,它们会从右值中窃取资源。"
第三层:std::forward 的作用(完美转发)
"
std::forward通常用于模板函数 中,实现完美转发------即在函数内部将参数原封不动地转发给另一个函数,保持参数的左值/右值性质不变。它的实现原理:当模板参数为
T时,std::forward<T>(arg)会根据T的类型决定返回左值还是右值引用:
如果
T是左值引用类型,则forward返回左值引用。如果
T是非引用类型,则forward返回右值引用。完美转发结合了模板推导和引用折叠规则,确保参数的值类别在传递过程中不丢失。"
第四层:移动构造函数的调用时机
"移动构造函数在用一个右值初始化同类型对象时被调用。常见场景包括:
临时对象初始化 :如
std::string s = std::string("hello");,临时对象是右值。显式使用
std::move:如std::string s2 = std::move(s1);,将s1转换为右值后,调用移动构造。函数返回局部对象 :当函数返回局部对象时,如果满足**返回值优化(RVO)**条件,编译器可能直接构造到目标位置,不调用移动构造;如果不满足优化条件,则会试图调用移动构造(前提是对象支持移动)。
容器操作 :如
std::vector的push_back(std::move(x)),当空间不足重新分配时,也会移动元素到新内存。异常安全:某些情况下移动构造可能被用来替代拷贝构造以保证强异常安全。"
注意 :移动构造函数通常需要标记为
noexcept,以便标准库容器在重新分配时优先选择移动而非拷贝。
第五层:移动语义的好处
"移动语义允许我们避免不必要的深拷贝 ,特别是对于管理动态资源的类(如
std::string、std::vector)。它将资源的所有权从一个对象转移到另一个对象,通常只是拷贝几个指针,大幅提升了性能。"
说一下你对继承多态的理解
答:
继承就是子类复用和扩展父类的代码和功能,多态就是相同的接口,不同的对象或者参数调用有不同的结果。
编译时多态是通过模板和函数重载实现,运行时多态是通过虚函数实现的。当一个类里面有虚函数时,编译器就会为其生成虚函数表,在对象内部有一个虚表指针指向这个虚表,在调用虚函数时,会触发动态绑定,会先通过对象的虚表指针找到虚表再找到对应的虚函数。
在运行时多态中要注意的是如果父类的析构函数不声明为虚函数,而且有一个父类指针指向子类,调用析构函数时就不会调用子类的析构函数,导致子类资源得不到释放,内存泄露
抽象类(只有纯虚函数)和纯虚函数(只声明接口,不做实现,子类对其全部完成重写后,才可以进行实例化)。
除此之外,c++11新增override(必定重写)和final(禁止继承和重写)关键字对继承和多态进行更好的控制。
评分:7.0/10
- 不足:
- 未涉及继承方式(public/protected/private)、访问权限变化,以及"is-a"关系的含义。
- 定义"相同接口不同结果"稍显笼统,可以更精确:通过基类指针或引用调用虚函数,实际执行的是派生类的版本。
- 未说明vtable是类级别共享的,所有同类对象指向同一个虚表。
- 未涉及多重继承下的虚表情况(可选加分点)。
- 未提及虚析构函数的重要性。
override让编译器检查是否真正重写了虚函数,final阻止进一步重写或继承。- 未说明多态的使用条件(基类指针/引用调用虚函数);未讨论设计层面(继承与组合的选择)和性能开销。
修正:
第一层:基本概念(基础)
"继承 是面向对象中实现代码复用的重要手段。它允许一个类(派生类)继承另一个类(基类)的成员(数据和函数),并可以添加新的成员或重写基类的函数。C++支持多种继承方式:
public继承表示"is-a"关系,即派生类是基类的一种特殊类型;protected和private继承更多用于实现细节复用,不表示概念上的继承关系。""多态 指同一操作作用于不同对象时,可以有不同的解释和执行结果。C++中的多态主要分为编译时多态 (通过函数重载和模板实现)和运行时多态(通过继承和虚函数实现)。通常我们讨论的多态特指运行时多态。"
第二层:运行时多态的底层实现(深入)
"运行时多态依赖于虚函数机制。当一个类中声明了虚函数(或从基类继承了虚函数),编译器会为该类生成一个 虚函数表**(vtable),表中存放了该类所有虚函数的地址。这个表是** 类级别共享**的,所有该类的对象共用同一个虚表。同时,每个对象内部会被插入一个隐藏的指针------**虚指针**(vptr),它在对象构造时被初始化为指向该类虚表的地址。"
"当我们通过基类的指针或引用调用虚函数时,实际调用过程是动态绑定的:先通过对象的vptr找到虚表,再从虚表中取出正确的函数地址进行调用。这个过程直到运行时才确定,因此称为'动态多态'。"
第三层:重要实践与注意事项(体现细节)
"使用运行时多态有几个关键点需要牢记:
虚析构函数 :如果基类指针指向派生类对象,而基类的析构函数不是虚函数,那么
delete基类指针时只会调用基类析构函数,派生类的资源无法释放,导致内存泄漏。因此,凡是设计了继承且可能通过基类指针删除对象的,基类析构函数都应声明为virtual。纯虚函数与抽象类 :纯虚函数(
= 0)使得类成为抽象类,无法实例化,只能作为接口使用。派生类必须实现所有纯虚函数才能成为具体类。override和final关键字(C++11) :
override显式标明派生类的函数意图重写基类虚函数,如果签名不匹配,编译器会报错;final可用于类或虚函数,阻止进一步的继承或重写。它们让代码意图更清晰,减少错误。"**
第四层:多态的条件与设计考量(展示思考深度)
"多态生效必须同时满足三个条件:
存在继承关系,且基类有虚函数。
派生类重写了该虚函数。
通过基类指针或引用调用虚函数。(直接通过对象调用虚函数是静态绑定,没有多态效果。)
设计层面,我们常说"优先使用组合而非继承",因为继承会破坏封装,基类实现的变化可能影响所有派生类。只有当存在明确的"is-a"关系,且需要多态行为时,才使用公有继承。
性能上,虚函数调用比普通函数多一次间接寻址,且每个对象多了一个vptr的开销(通常8字节),但这些代价在大多数场景下可以接受。在性能关键的代码中,可以考虑用CRTP等静态多态技术替代。"**
第五层:扩展知识(可选加分)
"如果涉及多重继承,派生类会有多个vptr,分别指向不同基类的虚表,或者通过调整this指针来处理。现代C++中还引入了
dynamic_cast和类型信息(RTTI)来支持运行时类型识别,但这也会带来额外开销。"
虚函数与多态:虚函数表原理(单继承、多继承情况)、静态绑定与动态绑定、构造/析构中调用虚函数的行为。
答:
当一个类里面有虚函数时,编译器就会为其生成虚函数表,在对象内部有一个虚表指针指向这个虚表,在调用虚函数时,会触发动态绑定,会先通过对象的虚表指针找到虚表再找到对应的虚函数,单继承的情况下有一个虚表,多继承情况下可能有多个虚表
静态绑定即在编译时就已经确定,比如定义一个类对象,调用虚函数的这么一个情况
动态绑定即在运行时绑定,在编译时是不确定的,要到运行时得到对应的对象版本进行调用的时候才确定,就比如我在rpc的项目中,抽象出消息基类,实现不同类型的消息子类,在实际运行时用户请求什么类型,或者不同类型的回复,根据这些类型对应的子类指针调用对应的虚函数版本
构造函数和析构函数调用虚函数都是调用当前构造析构的类的版本,这是由基类子类的构造顺序决定的,先构造基类,然后子类,先析构子类,然后子类,如果在基类构造调用子类虚函数就可能发生未定义行为
评分:7.5/10
- 不足:
- 未说明多继承下虚表的具体布局(如每个基类子对象有自己的vptr,派生类可能调整this指针),也未提及虚表的共享性(同类对象共享一个虚表)。
- 定义不够精确:静态绑定不仅限于对象调用,还包括通过指针/引用调用非虚函数、使用作用域运算符等。
- 可补充动态绑定的实现条件:必须通过基类的指针或引用调用虚函数。
- 可深入解释vptr的变化时机(基类构造时vptr指向基类虚表,派生类构造时才更新),以及为什么这是安全的保证。
- 各部分之间的过渡可以更平滑,术语更规范(如"虚表指针"称为vptr,"虚函数表"称为vtable)。
修正:
第一层:虚函数表的基本原理(单继承)
"当一个类中声明了虚函数(或继承了含有虚函数的基类),编译器会为该类生成一个虚函数表(vtable) 。vtable本质上是一个函数指针数组,存储了该类所有虚函数的地址。这个表是类级别共享的,即该类的所有对象共用同一个vtable。
每个对象内部会被插入一个隐藏的指针------虚指针(vptr),它指向该类对应的vtable。vptr由编译器在构造函数中自动初始化,并在对象构造过程中根据实际类型更新。
在单继承下,派生类通常只有一个vptr(继承自基类,并可能扩展自己的虚函数)。当派生类重写基类虚函数时,vtable中对应位置的函数指针会被替换为派生类函数的地址。如果派生类新增虚函数,它们会被追加到vtable的末尾。"
第二层:多继承下的虚函数表
"在多继承 下,派生类会包含多个基类子对象,每个基类子对象都有自己的vptr(如果基类有虚函数)。因此,派生类对象中会有多个vptr,分别指向各个基类对应的vtable。
当派生类重写某个基类的虚函数时,对应基类的vtable中的函数指针会被更新为派生类的函数地址。如果派生类定义了新的虚函数,这些函数的地址通常会被添加到第一个基类的vtable中(具体取决于编译器实现)。
需要注意的是,多继承下通过不同基类指针调用虚函数时,编译器可能需要进行this指针调整 ,因为指针需要指向正确的基类子对象。例如,通过第二个基类指针调用派生类重写的虚函数时,需要将指针偏移到派生类对象的起始位置,以便正确访问派生类成员。这种调整通常通过thunk技术实现,在vtable中保存调整后的函数入口。"
第三层:静态绑定与动态绑定
"静态绑定 (Static Binding)发生在编译期,编译器根据表达式的静态类型(即声明时的类型)决定调用的函数。常见场景包括:
通过对象 直接调用虚函数(如
obj.func())。通过指针或引用 调用非虚函数。
使用作用域运算符 显式指定类名调用虚函数(如
p->Base::func())。动态绑定 (Dynamic Binding)发生在运行期,通过基类的指针或引用 调用虚函数 时,实际调用的函数版本由对象的动态类型(即实际指向的对象类型)决定。这是多态的核心机制。动态绑定的实现依赖于vptr和vtable:程序在运行时通过对象的vptr找到vtable,再从vtable中取出正确的函数地址进行调用。"
第四层:构造/析构函数中调用虚函数的行为
"在构造函数和析构函数中调用虚函数,不会触发动态绑定,而是调用当前正在构造或析构的类所定义的函数版本。这是C++为了保证对象安全而设计的规则。
原因:对象构造时,先从基类开始,此时派生类部分尚未初始化,vptr指向的是基类的vtable。如果此时调用派生类重写的虚函数,可能会访问到尚未初始化的派生类成员,导致未定义行为。因此,在基类构造期间,任何虚函数调用都会被解析为基类版本。当基类构造完成后,vptr被更新为指向派生类的vtable,然后才执行派生类构造函数体。类似地,析构时先执行派生类析构函数体(此时vptr仍指向派生类vtable),然后vptr被还原为基类vtable,再执行基类析构函数。
所以,无论在基类还是派生类的构造/析构函数中,虚函数调用都遵循当前正在构造/析构的类这一规则,不会向下调用到尚未构造或已销毁的更派生类版本。"
第五层:结合项目的实际应用(可选,延续用户的例子)
"在我的RPC项目中,我利用多态实现了不同消息类型的处理:定义了一个抽象的
Message基类,包含纯虚函数Serialize()和Deserialize(),然后派生出RequestMessage、ResponseMessage等具体类。在运行时,根据接收到的消息类型,通过基类指针调用相应的虚函数,实现了动态绑定。这种设计使得添加新消息类型时无需修改框架代码,符合开闭原则。"