c++八股文
游戏客户端开发常见八股文
c++篇
前言
看到了一些关于游戏开发c++笔试、面试题,但是有题目没有答案,作为一个只会一点点c++的小菜鸡就记录一下,以下问题答案都是chatGPT回答以及百度答案
面试问题来源:游戏开发岗面试总结
一、C++内存管理常考察点
1. C++的构造函数,复制构造函数,和析构函数
-
构造函数(Constructor):C++中的构造函数用于初始化对象的数据成员。构造函数的名称与类名相同,没有返回类型,包括参数列表和函数体。构造函数在创建对象时自动调用,并且可以重载,即可以有多个构造函数。
-
复制构造函数(Copy Constructor):复制构造函数用于通过已有对象创建一个新对象。复制构造函数的参数是一个同类的对象引用,它用于初始化新对象的数据成员。如果没有显式定义复制构造函数,C++会自动生成一个默认的复制构造函数。复制构造函数通常使用深拷贝(deep copy)来避免浅拷贝(shallow copy)带来的问题。
-
析构函数(Destructor):析构函数用于在对象销毁时释放资源和做一些清理工作。析构函数的名称与类名相同,前面加上一个波浪号(~)作为前缀。析构函数没有返回类型,没有参数。当对象超出作用域、被删除或程序结束时,析构函数会被自动调用。如果没有显式定义析构函数,C++会自动生成一个默认的析构函数。
代码如下(示例):
#include <iostream>
class MyClass {
public:
// 默认构造函数
MyClass() {
std::cout << "Default constructor called" << std::endl;
}
// 带参数的构造函数
MyClass(int value) {
std::cout << "Parameterized constructor called with value: " << value << std::endl;
}
// 复制构造函数
MyClass(const MyClass& other) {
std::cout << "Copy constructor called" << std::endl;
}
// 析构函数
~MyClass() {
std::cout << "Destructor called" << std::endl;
}
};
int main() {
MyClass obj1; // 调用默认构造函数
MyClass obj2(10); // 调用带参数的构造函数
MyClass obj3 = obj1; // 调用复制构造函数
obj3 = obj2; // 调用赋值运算符(非复制构造函数)
return 0;
}
输出结果:
Default constructor called
Parameterized constructor called with value: 10
Copy constructor called
Destructor called
Destructor called
Destructor called
2. 什么是深复制和浅复制
深复制(deep copy)和浅复制(shallow copy)是在编程中常用的两种对象复制方法。
- 浅复制是指创建一个新的对象,新对象的属性值与原对象相同,但对象内部的引用类型数据(如数组、对象等)仍然指向原对象的引用。也就是说,浅复制只是复制了对象的引用,而不是实际的数据。因此,修改新对象的属性值可能会影响到原对象。
- 深复制是指创建一个全新的对象,新对象的属性值和原对象相同,但是对象内部的引用类型数据也会被复制,而不是简单的引用。这意味着,深复制创建的新对象是完全独立于原对象的,对新对象的修改不会影响原对象。
简而言之,浅复制只复制对象的引用,而深复制复制对象的内容。
3.构造函数和析构函数哪个能写成虚函数,为什么
析构函数可以被写成虚函数,而构造函数不能被写成虚函数。
- 虚函数是用于实现多态的概念,能够在运行时根据对象的实际类型来确定调用的方法。而构造函数在对象创建时被调用,此时对象的实际类型还不确定,因此无法使用虚函数。
- 析构函数是用于释放对象占用的资源,包括内存、文件句柄等。在继承关系中,当基类指针指向派生类对象时,如果析构函数不是虚函数,那么在调用delete操作时只会调用基类的析构函数,而不会调用派生类的析构函数。这会导致派生类对象中的资源无法被正确释放,可能会造成内存泄漏等问题。因此,为了确保派生类对象的析构函数能够正确调用,通常会将基类的析构函数声明为虚函数。
总结:由于构造函数在对象创建时被调用,对象的实际类型还不确定,无法使用虚函数;而析构函数在对象销毁时被调用,对象的实际类型已经确定,可以使用虚函数实现多态。
4.C++数组,链表,二叉树的内存排列是什么样的
-
数组的内存排列是连续的,即所有元素在内存中是相邻存储的。
-
链表的内存排列是非连续的,每个节点包含数据和指向下一个节点的指针,节点在内存中可以分布在不同的位置。
-
二叉树的内存排列是通过指针链接的,每个节点包含数据以及指向左右子节点的指针。
5.结构体占多大内存如何计算,类占用多大空间如何计算,空类的空间是多少,为什么
- 结构体的大小是由其成员变量的大小之和决定的,可以使用sizeof运算符来计算结构体的大小。
struct A {
char y; //char类型,1字节
char* z; //指针类型,在 32 位系统上为 4 字节,在 64 位系统上为 8 字节
int x; //int类型,4字节
};
cout<<sizeof(A)<<endl;
因此,整个结构体 A 的大小为 1 + 4 + 4 = 9 字节。但是,由于内存对齐的原因,编译器会将结构体的大小调整为 12 字节,以确保每个成员的地址都能够对齐到合适的内存边界。
这里是假设在32位系统上,所以为char* z为4字节,取所有成员变量中占内存最大的计算偏移量
char*(4字节)== int(4字节),所以偏移量为4
所以
struct A {
char y; //偏移量:0
char* z; //偏移量:4
int x; //偏移量:8
};
结构体大小 = 0 + 4 + 8
因此,输出语句 cout<<sizeof(A)<<endl; 将会输出 12。
- 类的大小也是由其成员变量的大小之和决定的,可以使用sizeof运算符来计算类的大小。和结构体一样,类的大小可能会受到内存对齐的影响。
- 空类的大小为1字节。这是因为C++要保证每个对象在内存中都有一个独一无二的地址,即使是空类也需要占用一个字节的空间。这样做的目的是为了确保每个对象在内存中的地址都是唯一的,以便于区分不同的对象。
5.虚函数和虚表的原理是什么(重点)
虚函数的原理是通过虚函数表(vtable)来实现的。
- 当一个类中声明了虚函数时,编译器会为该类生成一个虚函数表(vtable),其中存储了虚函数的地址。每个对象都会有一个指向虚函数表的指针,被称为虚函数表指针(vptr)。
- 当通过基类指针或引用调用虚函数时,实际上会通过对象的虚函数表指针找到对应的虚函数表,然后再根据函数的索引(偏移量)找到具体的虚函数地址,最终执行相应的函数。
- 这种机制实现了动态绑定的多态性,即在运行时根据对象的实际类型来确定调用的虚函数,而不是根据指针或引用的静态类型。
- 需要注意的是,虚函数表是每个类的独立的,因此每个类都有自己的虚函数表。当派生类覆盖了基类的虚函数时,派生类将会在自己的虚函数表中存储新的函数地址。这样,在通过派生类对象调用虚函数时,会根据派生类的虚函数表找到相应的函数地址。
总结:虚函数通过虚函数表和虚函数表指针实现动态绑定,能够在运行时根据对象的实际类型来确定调用的虚函数。
6.内存泄漏出现的时机和原因,如何避免
内存泄漏通常发生在动态分配内存后没有正确释放的情况下,导致程序无法再次访问和释放这块内存。内存泄漏的原因可能有以下几种:
- 忘记调用delete或free:在使用new、malloc等动态分配内存的时候,应该在不再使用该内存时调用对应的delete或free函数进行释放,如果忘记调用,就会导致内存泄漏。
- 异常抛出导致未释放:如果在动态分配内存后发生了异常,而没有在异常处理代码中进行释放,就会导致内存泄漏。
- 对象生命周期管理不当:如果对象的生命周期超出了其所分配的内存块的范围,就会导致内存泄漏。比如将对象的指针保存在容器中,但没有在合适的时机从容器中删除,就会导致内存泄漏。
为了避免内存泄漏,可以采取以下几种方法:
- 使用智能指针:C++11引入了std::shared_ptr和std::unique_ptr等智能指针,它们能够自动管理内存的释放,避免内存泄漏。
- 遵循资源获取即初始化(RAII)原则:通过在构造函数中申请资源,在析构函数中释放资源,可以确保在对象生命周期结束时自动释放内存。
- 注意异常安全性:在发生异常时,确保已经分配的资源能够正确释放,可以使用RAII技术或异常安全的编程范式来处理。
- 使用工具进行内存泄漏检测:可以使用一些专门的工具(如Valgrind、Dr. Memory等)来检测程序中的内存泄漏问题。
7.指针的工作原理
指针是一个变量,其值为另一个变量的内存地址。在计算机中,每个变量都存储在内存中的某个位置,而指针则指向这个位置。通过指针,可以直接访问和修改存储在内存中的数据。
指针的工作原理可以简单描述为以下几个步骤:
- 定义指针变量,并为其分配内存空间。
- 将要指向的变量的地址赋值给指针变量。
- 使用指针变量访问和修改指向的变量的值。
8.函数的传值和传址
函数的参数传递方式有两种:传值和传址。
- 传值:传值是指将实际参数的值复制给形式参数,函数内部对形式参数的修改不会影响到实际参数。在函数调用时,会创建形式参数的副本,函数操作的是这个副本而不是原始数据。
传值的优点是简单、安全,不会影响到原始数据,但如果参数较大,会占用较多的内存。 - 传址: 传址是指将实际参数的地址传递给形式参数,函数内部通过指针操作实际参数所在的内存地址,对形式参数的修改会影响到实际参数。
传址的优点是可以节省内存,因为不需要创建副本,同时对形式参数的修改可以直接影响到实际参数,但需要注意指针的安全性,防止指针悬空或越界访问。
传值和传址的选择取决于具体的需求和情况。一般来说,对于简单的数据类型和较小的数据量,可以选择传值;而对于复杂的数据类型和较大的数据量,可以选择传址。
9.new和delete使用解释一下,和malloc和free的区别
new和delete是C++中用于动态内存管理的操作符,用于在堆上分配和释放内存。
- new:
new是C++中用于在堆上分配内存的操作符。它的基本语法是:new 类型名
或new 类型名[数组大小]
。
- 对于单个对象的分配,new会返回分配的对象的指针。
- 对于数组的分配,new会返回数组的首元素的指针。
- delete:
delete是C++中用于释放通过new分配的内存的操作符。它的基本语法是:delete 指针
或delete[] 指针
。
- 对于通过new分配的单个对象的内存,delete会释放该对象的内存。
- 对于通过new分配的数组的内存,delete会释放整个数组的内存。
与malloc和free的区别:
malloc和free是C语言中的函数,用于动态内存管理。与new和delete相比,它们有以下几个区别:
- 语法:
- malloc的语法是:
void* malloc(size_t size)
,返回一个void指针。 - free的语法是:
void free(void* ptr)
,接受一个void指针作为参数。
- 类型安全性:
- new和delete是C++的操作符,可以自动计算所需的内存大小,并在分配和释放内存时自动调用构造函数和析构函数。它们提供了更高的类型安全性。
- malloc和free是C语言的函数,不会自动调用构造函数和析构函数,需要手动管理内存的分配和释放。对于复杂的对象,可能会导致内存泄漏或出现未定义的行为。
- 内存分配方式:
- new和delete是基于C++的运算符重载实现的,它们会调用运算符重载的函数,使用操作符new和delete在堆上分配和释放内存。
- malloc和free是C语言的函数,直接调用操作系统的内存分配函数,使用malloc和free在堆上分配和释放内存。
综上所述,new和delete是C++中用于动态内存管理的操作符,提供了更高的类型安全性和便利性;而malloc和free是C语言中的函数,需要手动管理内存的分配和释放。在C++中,推荐使用new和delete来进行动态内存的分配和释放。
10.C++内存区域划如何分说一下(栈,堆那些)
C++程序在运行时使用的内存可以划分为以下几个区域:
-
栈(Stack):
栈是用于存储局部变量、函数参数、函数返回地址等短期数据的一块内存区域。栈是由编译器自动分配和释放的,具有自动管理的特性。每当函数被调用时,会在栈上分配存储函数的参数、局部变量和返回地址的内存空间;当函数调用结束时,这些内存空间会被自动释放。 -
堆(Heap):
堆是用于存储动态分配的内存的一块内存区域。堆是由程序员手动分配和释放的,具有手动管理的特性。通过new操作符在堆上分配内存,通过delete操作符释放堆上的内存。堆上分配的内存可以在程序的任何地方使用,并且在不同的函数调用之间保持有效。 -
全局/静态存储区(Global/Static Storage):
全局存储区用于存储全局变量和静态变量,这些变量在整个程序的执行过程中都是存在的。全局变量在程序启动时分配内存,在程序结束时释放内存;静态变量在定义时分配内存,在程序结束时释放内存。 -
常量区(Constant Area):
常量区用于存储字符串常量和其他常量数据。这些常量数据在程序运行期间是不可修改的,并且存储在只读内存区域。 -
代码区(Code Area):
代码区存储程序的执行代码,包括函数的二进制代码和其他指令。代码区也是只读的,程序无法修改自身的代码。
这些内存区域在程序运行期间可以根据需要进行动态分配和释放,其中栈和堆是最常用的内存管理方式。栈用于存储函数的局部变量和函数调用的上下文信息,而堆用于存储动态分配的内存,供程序在需要时进行使用和释放。全局/静态存储区、常量区和代码区则用于存储程序的静态数据和执行代码,不会在程序运行期间进行动态的内存分配和释放。
二、C++11 新特性
1.常见的c++11新特性有哪些
C++11引入了许多新特性,以下是其中一些常见的特性:
- 自动类型推断(auto):
使用auto关键字可以让编译器根据变量的初始值自动推断出变量的类型,简化类型声明的过程。
代码如下:
auto x = 10; // x被推断为int类型
auto str = "Hello World"; // str被推断为const char*类型
- 范围for循环(Range-based for loop):
范围for循环可以方便地遍历容器(如数组、容器类等)中的元素,不再需要通过迭代器或索引来访问容器的元素。
代码如下:
std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto& num : vec) {
num *= 2;
}
- 初始化列表(Initializer list):
通过初始化列表可以在创建对象时直接用一组值进行初始化,简化了对象的初始化过程。
代码如下:
std::vector<int> vec = {1, 2, 3, 4, 5};
std::map<std::string, int> map = {{"apple", 1}, {"banana", 2}, {"orange", 3}};
- 空指针常量(nullptr):
nullptr是一个新的空指针常量,可以用于代替NULL或0来表示空指针。
代码如下:
int* ptr = nullptr;
if (ptr == nullptr) {
// 指针为空
}
- 强类型枚举(Scoped enums):
强类型枚举引入了新的enum类,可以将枚举值限定在枚举类型的作用域内,避免了命名冲突和隐式类型转换。
代码如下:
enum class Color {
RED,
GREEN,
BLUE
};
Color color = Color::GREEN;
- Lambda表达式(Lambda expressions):
Lambda表达式是一种用于创建匿名函数的简洁语法,可以在需要函数对象的地方直接使用,并且可以捕获上下文中的变量。
代码如下:
std::vector<int> vec = {1, 2, 3, 4, 5};
int sum = 0;
std::for_each(vec.begin(), vec.end(), [&sum](int num) {
sum += num;
});
- 并发编程支持(Concurrency support):
C++11引入了线程库(std::thread)、原子操作(std::atomic)和互斥量(std::mutex)等并发编程的支持,使得编写多线程程序更加方便和安全。
代码如下:
#include <iostream>
#include <thread>
void printHello() {
std::cout << "Hello from thread!" << std::endl;
}
int main() {
std::thread t(printHello);
t.join();
return 0;
}
- 移动语义(Move semantics):
通过移动语义,可以实现对临时对象的高效移动而不是复制,提高了对象的性能和效率。
代码如下:
std::vector<int> source = {1, 2, 3, 4, 5};
std::vector<int> destination = std::move(source); // source的内容被移动到destination
- 智能指针(Smart pointers):
C++11引入了shared_ptr和unique_ptr等智能指针,可以自动管理动态分配的内存,避免内存泄漏和手动释放内存的问题。
代码如下:
#include <memory>
std::shared_ptr<int> sptr = std::make_shared<int>(10);
std::unique_ptr<int> uptr = std::make_unique<int>(20);
四个智能指针: auto_ptr, shared_ptr, weak_ptr, unique_ptr 其中后三个是
10.右值引用:
右值引用是一种新的引用类型,可以绑定到临时对象或被移动的对象上,支持高效的移动语义。
代码如下:
//使用两个 && 表示这是一个右值引用的类型
void processData(std::vector<int>&& data) {
// 对右值引用的data进行处理
}
std::vector<int> getData() {
std::vector<int> vec;
// 获取数据
return vec;
}
processData(getData()); // 传递getData()的返回值(右值)给processData函数
2.智能指针用过吗,有哪些,他们的区别和各自的优缺点
C++语言提供了几种智能指针,包括:std::unique_ptr
、std::shared_ptr
和std::weak_ptr
。
std::unique_ptr
:- 它是一种独占式智能指针,不能进行复制或共享。
- 只能有一个
std::unique_ptr
指向同一个对象,当它过期(超出作用域)或被手动释放时,它自动删除所管理的对象。 - 适合用于具有独占所有权的场景,例如管理单个资源。
unique_ptr p3 (new string (“auto”));
unique_ptr p4;
p4 = p3; //此时会报错!!
//可以用
unique_ptr ps1, ps2;
ps1 = demo(“hello”);
ps2 = move(ps1);
ps1 = demo(“alexia”);
cout << *ps2 << *ps1 << endl;
std::shared_ptr
:- 它是一种共享式智能指针,可以被多个
std::shared_ptr
共享同一个对象。 - 内部维护引用计数,只有当最后一个
std::shared_ptr
过期时,才会自动删除所管理的对象。 - 具备线程安全的引用计数机制,可以在多线程环境中使用。
- 适合用于需要多个智能指针共享同一个对象的场景。
- 它是一种共享式智能指针,可以被多个
成员函数 | 含义 |
---|---|
use_count | 返回引用计数的个数 |
unique | 返回是否是独占所有权( use_count 为 1) |
swap | 交换两个 shared_ptr 对象(即交换所拥有的对象) |
reset | 放弃内部对象的所有权或拥有对象的变更, 会引起原有对象的引用计数的减少 |
get | 返回内部对象(指针), 由于已经重载了()方法, 因此和直接使用对象是一样的.如 shared_ptr sp(new int(1)); sp 与 sp.get()是等价的 |
std::weak_ptr
:- 它是一种弱引用智能指针,可以共享对象,但不增加引用计数。
- 它不能直接访问被管理的对象,需要通过
std::shared_ptr
来访问或转换。 - 避免了循环引用问题,可以解决
std::shared_ptr
之间的循环引用导致内存泄漏的情况。
这些智能指针各自有不同的优缺点:
std::unique_ptr
的优点是轻量级且性能高,不需要维护引用计数;缺点是不能共享资源。std::shared_ptr
的优点是可以共享资源,线程安全,适用于复杂的拥有关系;缺点是增加了额外的开销,包括引用计数的维护和原子操作等。std::weak_ptr
的优点是避免了循环引用的内存泄漏问题;缺点是不能直接访问被管理的对象,需要转换为std::shared_ptr
使用。
选择智能指针应根据具体的需求和场景来决定。如果你需要独占所有权并且不需要资源共享,则可以使用std::unique_ptr
。如果你需要共享资源,则可以使用std::shared_ptr
。如果你需要解决循环引用问题,则可以考虑使用std::weak_ptr
。
3.auto关键字知道吗,如果全部都用auto声明变量行不行
在使用auto声明变量时,编译器会根据变量的初始化表达式推断出其类型,并将其替换为具体的类型。这样可以简化代码,减少类型的显式声明,提高代码的可读性和可维护性。
虽然使用auto声明变量可以方便地进行类型推断,但并不意味着应该全部都使用auto来声明变量。以下情况不建议过度使用auto:
-
可读性:显式声明变量可以更清晰地传达代码的意图和目的。如果变量的类型对于代码的理解很重要,或者可以提高代码的可读性,建议显式声明变量。
-
复杂表达式:在一些复杂的表达式中,类型推断可能会导致难以理解的推断结果。在这种情况下,显式声明变量可以提高代码的可读性和可维护性。
-
模板编程:在模板编程中,由于模板的参数可能具有多种类型,使用auto可能无法满足需要。在这种情况下,需要显式指定类型。
4.lambda表达式会用吗
lambda表达式是一种匿名函数,允许我们在需要函数对象的地方提供一个简洁、灵活和内联的函数定义。
使用lambda表达式可以带来以下好处:
-
简洁性:lambda表达式可以在不定义独立函数的情况下,直接在代码中定义函数功能。这样可以减少定义函数的代码量,并且更清晰地表达特定的功能。
-
内联性:lambda表达式是内联定义的,可以直接在需要的位置使用,无需额外的函数声明和定义。这对于某些仅在局部范围内使用的函数非常方便。
-
可读性:由于lambda表达式在使用时紧随其后,它们可以直接展示函数的功能和意图,使代码更加易读和易理解。
-
高度灵活性:lambda表达式可以自包含地捕获所需的变量,并且可以在需要时更改捕获方式。这使得lambda表达式在编写回调函数、排序算法、STL算法等场景中特别有用。
以下是一个lambda表达式的示例:
auto sum = [](int a, int b) { return a + b; };
int result = sum(3, 4); // 调用lambda表达式计算结果为7
lambda表达式使用方括号来指定捕获列表,可以选择按值或按引用来捕获变量。在括号内部,定义了函数参数和函数体。
需要注意的是,在某些情况下,lambda表达式可能会使代码变得复杂和难以理解。在这种情况下,考虑将其提取为命名函数可能更合适。合理地使用lambda表达式可以提高代码的灵活性和可读性。
5.override关键字必须吗
不,override关键字并非必需,但在特定情况下使用它可以提高代码的清晰性和可维护性。
在C++中,override关键字用于显式地指示派生类中的成员函数覆盖基类中的虚函数。当我们希望确保派生类中的函数确实是对基类函数的重写时,使用override关键字是一个好的实践。
使用override关键字的好处包括:
-
明确表明意图:使用override关键字可以清楚地表示我们有意重写了基类中的函数。这可以提醒其他开发人员,并且可以在编译时检测到一些潜在的错误,比如函数签名不匹配或遗漏了const修饰符。
-
错误检测:当使用override关键字时,编译器会在派生类中检查是否存在与基类中的虚函数匹配的函数。如果没有找到匹配的函数,或者函数签名不正确,编译器将产生错误。这有助于捕获潜在的错误,避免在派生类中错误地定义了一个新的函数而不是重写基类函数。
-
可读性和可理解性:使用override关键字可以使代码更加清晰和易读。它提供了一种方式来快速识别哪些函数是虚函数的重写,从而提高代码的可维护性和可理解性。
需要注意的是,在以下情况下使用override关键字是无效或不必要的:
- 如果基类中的函数不是虚函数,不应该使用override关键字。
- 如果函数在派生类中是新定义的函数,并不是对基类函数的重写,则不应使用override关键字。
6.右值引用
右值引用是C++11引入的一种新特性,它提供了一种高效的方式来管理临时对象和避免不必要的复制。
右值引用的语法是使用&&符号来定义一个引用,例如:
int&& rvalue_ref = 123; // 右值引用绑定字面量
右值引用最常见的应用场景是将临时对象传递给函数,从而避免不必要的复制或移动:
void foo(std::string&& str) // 右值引用参数
{
// 对str的操作,通常会将其转移(move)到其他地方
}
int main()
{
foo(std::string("Hello, world!")); // 传递临时字符串,避免了复制
std::string str = "Hello";
foo(std::move(str)); // 将str转移为右值引用,避免了复制
}
在这个例子中,我们可以看到右值引用的两种应用方式。首先,在函数foo中,我们将参数定义为一个右值引用,以接收一个临时对象。由于该对象只是暂时存在,并且我们不需要保留它的状态,因此使用右值引用可以更有效地处理它。
另外,在调用foo函数时,我们可以使用一个临时对象或通过std::move函数将一个左值对象转换为右值引用来传递参数。这样可以避免在传递参数时进行额外的复制,从而提高代码的效率。
除了上述示例中的用法之外,右值引用还有其他实际应用场景。例如:
-
移动语义:右值引用是实现移动语义的关键。通过允许对象的状态转移到新的对象中,移动语义可以避免不必要的复制,提高代码效率。
-
完美转发:右值引用也是实现完美转发的核心技术之一。通过在函数模板中使用右值引用,可以将参数按原样转发到另一个函数,从而实现参数类型的完全转移。
总之,右值引用是C++11中非常重要的一个特性,它可以帮助我们管理临时对象、提高代码效率和实现完美转发等。理解和掌握右值引用的使用方式对于编写高效和可维护的C++代码非常重要。
总结
这里只是文章的一部分问题答案,不一定正确,我对C++的了解有限,如果有错误,会有后续修改