面试真题 | B站C++渲染引擎
一、基础与语法
-
自我介绍
- 请简要介绍自己的背景、专业技能和工作经验。
-
实习介绍
- 详细描述你在实习期间参与的项目、职责和成果。
二、智能指针相关问题回答
unique_ptr
是如何实现的?它有哪些特点和优势?
unique_ptr
是C++11引入的一种智能指针,用于管理动态分配的内存资源。其实现基于独占所有权的概念,即每个 unique_ptr
实例拥有对其所指向对象的唯一所有权。
特点:
- 独占所有权:在任何给定的时刻,只能有一个
unique_ptr
实例管理特定的内存资源。这确保了内存资源的安全性和唯一性。 - 自动释放内存:当
unique_ptr
超出作用域或被重新赋值时,它所管理的内存会自动释放,从而避免了内存泄漏的问题。 - 指针语义:
unique_ptr
的使用方式与原始指针相似,可以通过指针操作符(->)和解引用操作符(*)来访问所指向对象的成员。
优势:
- 安全性:通过独占所有权和自动释放内存的特性,
unique_ptr
提供了比原始指针更高的安全性。 - 易用性:
unique_ptr
的使用方式简单直观,减少了手动管理内存带来的复杂性和出错率。 - 性能:由于
unique_ptr
不需要维护引用计数,因此在某些情况下,它的性能可能比shared_ptr
更高。
shared_ptr
是如何实现的?它如何实现资源共享和自动管理?
shared_ptr
是C++11引入的另一种智能指针,用于管理动态分配的内存资源,并允许多个指针共享同一个资源。
实现:
shared_ptr
的实现基于引用计数的技术。每个 shared_ptr
对象都会维护一个引用计数器,用于记录有多少个指针指向同一个对象。当引用计数器为0时,表示没有任何指针指向该对象,此时会自动释放该对象的内存空间。
资源共享和自动管理:
- 共享所有权:多个
shared_ptr
实例可以同时指向同一个对象,并共享对所指向对象的所有权。这通过引用计数器来实现,每次复制或赋值shared_ptr
时,引用计数器都会增加。 - 自动释放内存:当最后一个指向对象的
shared_ptr
超出作用域或被重新赋值时,引用计数器会减为0,此时会自动释放所管理的内存资源。
shared_ptr
是否线程安全?为什么?(计数器是线程安全的,但指针本身不是)
shared_ptr
的引用计数器是线程安全的,这意味着在多线程环境中,多个线程可以安全地同时访问和修改同一个 shared_ptr
的引用计数器。然而,shared_ptr
的指针本身并不是线程安全的。
线程安全性:
- 引用计数器线程安全:
shared_ptr
的实现通常会使用原子操作或互斥锁来保护引用计数器的访问和修改,从而确保线程安全。 - 指针本身非线程安全:虽然引用计数器是线程安全的,但
shared_ptr
所管理的对象指针本身并不是线程安全的。如果多个线程同时访问和修改同一个对象,需要额外的同步机制来确保线程安全。
面试官追问:
-
追问一:
unique_ptr
和shared_ptr
在性能上有何差异?- 回答:
unique_ptr
通常比shared_ptr
性能更高,因为它不需要维护引用计数。而shared_ptr
需要维护引用计数,并在每次复制或赋值时进行原子操作或互斥锁保护,这会增加一定的开销。然而,在需要共享所有权的场景中,shared_ptr
是更好的选择,因为它可以自动管理内存并避免内存泄漏。
- 回答:
-
追问二:如何解决
shared_ptr
的循环引用问题?- 回答:循环引用是指两个或多个对象相互持有
shared_ptr
,形成一个循环链,导致引用计数永远不会变为0,从而引发内存泄漏。为了解决这个问题,可以使用weak_ptr
。weak_ptr
是一种不增加引用计数的智能指针,它只观察对象而不拥有对象。通过weak_ptr
,可以破坏循环引用链,让所有的shared_ptr
都能够正常析构并释放所管理的内存。
- 回答:循环引用是指两个或多个对象相互持有
-
追问三:在什么情况下应该使用
unique_ptr
而不是shared_ptr
?- 回答:在以下情况下应该使用
unique_ptr
而不是shared_ptr
:- 当只有一个所有者需要管理内存资源时,使用
unique_ptr
可以提供更高的安全性和性能。 - 当不希望多个指针共享同一个资源时,使用
unique_ptr
可以避免不必要的引用计数开销。 - 当对象的生命周期与单个所有者的生命周期紧密相关时,使用
unique_ptr
可以更直观地表达这种关系。
- 当只有一个所有者需要管理内存资源时,使用
- 回答:在以下情况下应该使用
三. 虚函数与构造函数/析构函数
-
在构造函数中调用虚函数会发生什么?
在构造函数中调用虚函数会导致静态绑定(也称为早绑定),而不是动态绑定(晚绑定)。这意味着即使虚函数在派生类中被重写,在基类的构造函数中调用该虚函数时,调用的仍然是基类中的版本。这是因为在对象构造过程中,派生类的部分还没有被完全构造,对象的实际类型(即派生类类型)还没有完全形成,因此无法正确调用派生类中的虚函数版本。
示例代码:
class Base { public: Base() { VirtualFunction(); // 调用的是 Base::VirtualFunction } virtual void VirtualFunction() { std::cout << "Base class virtual function" << std::endl; } }; class Derived : public Base { public: void VirtualFunction() override { std::cout << "Derived class virtual function" << std::endl; } }; int main() { Derived d; // 构造 Derived 对象时,Base 的构造函数中调用的是 Base::VirtualFunction return 0; }
-
为什么析构函数通常需要设置为虚函数?
析构函数通常需要设置为虚函数,以确保通过基类指针删除派生类对象时,能够正确调用派生类的析构函数,从而避免资源泄漏和未定义行为。如果析构函数不是虚函数,那么通过基类指针删除派生类对象时,只会调用基类的析构函数,派生类特有的资源不会被正确释放。
示例代码:
class Base { public: virtual ~Base() { // 基类析构函数设置为虚函数 std::cout << "Base class destructor" << std::endl; } }; class Derived : public Base { public: ~Derived() { // 派生类析构函数 std::cout << "Derived class destructor" << std::endl; } }; int main() { Base* b = new Derived(); delete b; // 正确调用 Derived 的析构函数,然后调用 Base 的析构函数 return 0; }
如果基类析构函数不是虚函数,则只会调用基类的析构函数,派生类的资源不会被释放:
class Base { public: ~Base() { // 基类析构函数不是虚函数 std::cout << "Base class destructor" << std::endl; } }; // Derived 类定义同上 int main() { Base* b = new Derived(); delete b; // 只调用 Base 的析构函数,不会调用 Derived 的析构函数 return 0; }
面试官追问及回答
追问1: 在构造函数中调用虚函数的具体影响是什么?
回答: 在构造函数中调用虚函数的具体影响是,会导致虚函数的静态绑定(早绑定),即调用的是当前正在构造的对象的基类中定义的版本。这可能导致派生类特有的行为不被执行,甚至可能产生逻辑错误或未定义行为,因为派生类的成员可能还没有被初始化。
追问2: 如果析构函数不是虚函数,会导致什么问题?
回答: 如果析构函数不是虚函数,当通过基类指针删除派生类对象时,会导致只调用基类的析构函数,而派生类的析构函数不会被调用。这会导致派生类特有的资源(如动态分配的内存、文件句柄等)没有被正确释放,从而产生资源泄漏。此外,如果派生类析构函数中有重要的清理逻辑(如关闭网络连接、解锁资源等),这些逻辑也不会被执行,可能导致程序崩溃或未定义行为。
追问3: 在多继承的情况下,虚析构函数如何处理?
回答: 在多继承的情况下,每个有虚函数的基类都应该有一个虚析构函数。如果一个类从多个有虚析构函数的基类继承,那么该类的析构函数将自动成为虚函数,并且会按照C++的析构函数调用顺序(先调用派生类的析构函数,然后依次调用各基类的析构函数,按照构造的反向顺序)来调用所有基类的析构函数。这样可以确保所有基类特有的资源都被正确释放。
示例代码(多继承):
class Base1 {
public:
virtual ~Base1() {
std::cout << "Base1 destructor" << std::endl;
}
};
class Base2 {
public:
virtual ~Base2() {
std::cout << "Base2 destructor" << std::endl;
}
};
class Derived : public Base1, public Base2 {
public:
~Derived() override {
std::cout << "Derived destructor" << std::endl;
}
};
int main() {
Base1* b1 = new Derived();
delete b1; // 正确调用 Derived 的析构函数,然后依次调用 Base1 和 Base2 的析构函数
return 0;
}
四、 内存地址问题
class A {
int a;
int d;
};
class B {
int b;
};
class C : public A, public B {
int b;
};
C* c = new C;
A* a = c;
B* b = c;
- 问
a
、b
、c
指向的地址是否相同?为什么?
回答
在C++中,对象指针的行为和它们所指向的对象的内存布局密切相关。在你的代码示例中,类 C
是通过多重继承从类 A
和类 B
派生出来的。下面是对问题的详细分析:
- 类A 和 类B 各自包含一个
int
成员,假设每个int
成员占用4个字节(实际大小依赖于编译器和平台)。 - 类C 作为
A
和B
的派生类,也包含一个int
成员b
。由于C
通过多重继承自A
和B
,类C
的内存布局通常会按如下方式排列(这取决于编译器的具体实现,但大多数现代编译器遵循类似规则):- 首先是
A
的成员(a
和d
),假设每个int
4个字节,则共8个字节。 - 然后是
B
的成员(b
),也是4个字节。 - 最后是
C
自己独有的成员(b
),也是4个字节。
- 首先是
所以,假设没有对齐或其他编译器特性干扰,类 C
的对象 c
至少需要 8(A)+ 4(B)+ 4(C独有)= 16
个字节。
接下来看指针:
C* c = new C;
:c
是一个指向C
对象实例的指针,指向对象的起始地址。A* a = c;
:a
是一个指向A
的指针,并将其初始化为c
。由于A
是C
的第一个基类,a
指向的地址与c
相同。B* b = c;
:b
是一个指向B
的指针,并将其初始化为c
。这里b
指向的地址同样是c
的起始地址,但是要通过B
的视角去访问对象,实际上,从C
对象起始地址到B
的成员b
会有偏移(跳过A
的部分),但指针转换仅改变了解释内存的方式,并没有改变指向的内存地址。
因此,a
、b
和 c
指向的地址是相同的,因为它们都指向同一个 C
对象。不过,当通过 a
、b
和 c
访问成员时,编译器会根据它们的类型(A
、B
或 C
)来解释该地址处的内存内容。
面试官追问及回答
追问1: 如果 A
和 B
中各自有多个成员变量,a
和 b
指针访问的偏移会如何变化?
回答: 如果 A
和 B
中各自有多个成员变量,a
和 b
指针在访问这些成员时仍然会指向同一个 C
对象的基础地址。不过,通过 a
访问 A
的成员时,会基于 A
的布局解释内存;通过 b
访问 B
的成员时,会基于 B
的布局并考虑 A
的大小作为偏移来解释内存。编译器会在编译时处理这些偏移。
追问2: 如果在类 C
中,我们将 int b;
成员移动到类定义的开始位置,那么内存布局会发生什么变化?
回答: 如果将 int b;
成员移动到类 C
的定义开始位置,则 C
的内存布局将发生变化。现在 C
的内存布局会先放置 C
独有的 int b
(4个字节),然后是 A
的成员(a
和 d
,共8个字节),最后是 B
的成员(b
,4个字节)。这种情况下,虽然 c
指针仍然指向 C
对象的起始地址,但通过 a
和 b
访问时,相对于 c
指针的偏移会不同,因为 A
和 B
的成员在 C
对象中的位置发生了改变。
追问3: 在多重继承中,使用虚继承会对内存布局和指针转换产生什么影响?
回答: 在多重继承中,如果 A
或 B
(或两者)通过虚继承方式被继承,那么编译器会引入一个额外的指针(通常称为虚基类指针)来管理 A
或 B
(或两者)的实例。这个虚基类指针指向一个包含实际 A
或 B
对象的表(虚基类表),以确保正确访问。这会导致对象 C
的内存布局变得更大,因为需要额外的空间来存储虚基类指针。在指针转换时,编译器会通过虚基类指针和虚基类表来处理偏移,确保访问正确的成员。这可能会增加访问基类成员的开销,但避免了由于多重继承引起的菱形继承问题(钻石问题)。
五、STL与数据结构
STL 中使用 vector
要注意的问题
内存分配与扩容策略
在STL中,vector
是一个表示可变大小数组的序列容器,它采用连续存储空间来存储元素,这意味着可以采用下标对vector
的元素进行高效访问。然而,当新元素插入时,如果当前内存空间无法容纳,vector
需要进行内存重新分配和元素复制。
内存分配策略:
vector
在初始化时会分配一定的内存空间,这个空间大小通常与元素的类型和容器的初始大小有关。- 当元素数量增加到当前内存空间无法容纳时,会按照特定的扩容策略重新分配内存。常见的
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
【C/C++面试必考必会】专栏,直击面试核心,精选C/C++及相关技术栈中面试官最爱的必考点!从基础语法到高级特性,从内存管理到多线程编程,再到算法与数据结构深度剖析,一网打尽。助你快速构建知识体系,轻松应对技术挑战。希望专栏能让你在面试中脱颖而出,成为技术岗的抢手人才。