面经-计算机基础

本文最后更新于:2025年1月21日 凌晨

C++

关键字

引用类型和值类型

值类型包括整数、浮点、枚举、结构体;引用类型包括指针、引用、动态数组、类对象的指针。前者在栈上分配,后者在堆上分配;前者自动内存管理,后者需要手动管理。

inline

inline是先将内联函数编译完成⽣成了函数体直接插⼊被调⽤的地⽅,减少了压栈,跳转和返回的操作。没有普通函数调⽤时的额外开销;

内联函数是⼀种特殊的函数,会进行类型检查;对编译器的⼀种请求,编译器有可能拒绝这种请求;

C++中inline编译限制:

  1. 不能存在任何形式的循环语句
  2. 不能存在过多的条件判断语句
  3. 函数体不能过于庞⼤
  4. 内联函数声明必须在调⽤语句之前

和difine相比有什么优点?

  1. 类型安全
  2. 可以访问类成员
  3. 调试友好,可以设置断电
  4. 参数求值一次,比如下面这种用define可能会求值多次,造成错误。
1
2
3
4
5
6
7
inline int square(int x) {
return x * x; // x只被求值一次
}

// 使用
int a = 5;
int result = square(a++); // 安全的行为

static

  1. 修饰变量
    修改了变量的作用域和生命周期,存储在静态区域。生命周期和程序相同,作用域分为全局变量和局部变量。局部变量仅在函数内可用,全局变量仅在当前源文件中可用。
  2. 修饰函数
    表明函数的作用域仅在当前源文件中。
  3. 修饰成员变量
    静态成员变量为全局类对象所共享,仅有一份拷贝。类中声明,类外定义和初始化。所有实例共享一份数据,不依赖类的实例存在,可以通过类名访问
  4. 修饰成员函数
    静态成员函数为全局类对象所共享。没有this指针,仅能访问静态成员变量和函数,虚函数不能为静态成员函数。【虚函数运行时绑定,静态成员函数编译时绑定】

extern

  1. 修饰变量:变量声明,表明变量在此处引用,在其他源文件中定义。

  2. 修饰函数:表明函数在其他源文件中定义。

  3. extern “C”:编译器用C的命名规范去编译函数,链接器用C的命名规范进行链接。因为C++支持函数重载,而C不支持。

    例如,假设某个函数的原型为:void

    foo( int x, int y);该函数被C编译器编译后在符号库中的名字为 _ foo,而C++编译器则会产生像_foo_int_int之类的名字。这样的名字包含了函数名、函数参数数量及类型信息,C++就是靠这种机制来实现函数重载的。

constexpr 和 const

constexpr:告诉编译器我可以是编译期间可知的,尽情的优化我吧。

const:告诉程序员没人动得了我,放心的把我传出去;或者放心的把变量交给我,我啥也不动就瞅瞅。

修饰对象的时候两者之间最基本的区别是:

  • const修饰一个对象表示它是常量。这暗示对象一经初始化就不会再变动了,并且允许编译器使用这个特点优化程序。这也防止程序员修改了本不应该修改的对象。
  • constexpr是修饰一个常量表达式。但请注意constexpr不是修饰常量表达式的唯一途径。

修饰函数的时候两者之间最基本的区别是:

  • const只能用于非静态成员的函数而不是所有函数。它保证成员函数不修改任何非静态数据。
  • constexpr可以用于含参和无参函数。constexpr函数适用于常量表达式,只有在下面的情况下编译器才会接受constexpr函数:
  • 1.函数体必须足够简单,除了typedef和静态元素,只允许有return语句。如构造函数只能有初始化列表,typedef和静态元素 (实际上在C++14标准中已经允许定义语句存在于constexpr函数体内了) 2.参数和返回值必须是字面值类

sizeof

sizeof计算的是在栈中分配的内存大小。

(1) sizeof不计算static变量占的内存;

(2) 32位系统的指针的大小是4个字节,64位系统的指针是8字节,而不用管指针类型;

(3) char型占1个字节,int占4个字节,short int占2个字节

long int占4个字节,float占4字节,double占8字节,string占4字节

一个空类占1个字节,单一继承的空类占1个字节,虚继承涉及到虚指针所以占4个字节

(4) 数组的长度:

若指定了数组长度,则不看元素个数,总字节数=数组长度*sizeof(元素类型)

若没有指定长度,则按实际元素个数类确定

Ps:若是字符数组,则应考虑末尾的空字符。

(5) 结构体对象的长度

在默认情况下,为方便对结构体内元素的访问和管理,当结构体内元素长度小于处理器位数的时候,便以结构体内最长的数据元素的长度为对齐单位,即为其整数倍。若结构体内元素长度大于处理器位数则以处理器位数为单位对齐。

(6) unsigned影响的只是最高位的意义,数据长度不会改变,所以sizeof(unsigned int)=4

(7) 自定义类型的sizeof取值等于它的类型原型取sizeof

(8) 对函数使用sizeof,在编译阶段会被函数的返回值的类型代替

(9) sizeof后如果是类型名则必须加括号,如果是变量名可以不加括号,这是因为sizeof是运算符

(10) 当使用结构类型或者变量时,sizeof返回实际的大小。当使用静态数组时返回数组的全部大小,sizeof不能返回动态数组或者外部数组的尺寸

为什么空类大小不是0?

为了确保两个不同对象的地址不同,必须如此。

类的实例化是在内存中分配⼀块地址,每个实例在内存中都有独⼀⽆⼆的地址。

同样,空类也会实例化,所以编译器会给空类隐含的添加⼀个字节,这样空类实例化后就有独⼀⽆⼆的地址了。

所以,空类的sizeof为1,⽽不是0。

为什么不计算函数的大小?

函数代码存储在代码段

所有实例共享函数代码

sizeof只计算实例数据成员

虚函数只计算vptr大小

strlen

不会计算字符串最末尾的’/0’,sizeof会计算

#pragma pack()

#pragma pack()不带参数时,可以取消之前自定义的字节对齐方式,恢复默认的自然对齐。

强制类型转换

int i = (int)p; 等同于 int i = static_cast(p);

(int)p是C风格的强制类型转换

static_cast(p)是C++的类型转换

对于指针到整数的转换,使用static_cast

各种类型转换:

const_cast:去除const属性

dynamic_cast:安全的向下转型

static_cast:编译时静态转换

reinterpret_cast:重新解释底层内存

dynamic_cast

dynamic_cast 是 C++中的一个类型转换操作符,它主要用于处理多态类型的安全向下转换(也就是父类向子类转换)。 如果转换不合法,对于指针类型,dynamic_cast 会返回空指针 nullptr ; 对于引用类型,它会抛出 std::bda_cast 异常

注意:dynamic_cast是在运行时检查,并且 使用dynamic_cast 转换时,涉及的类通常至少需要有一个虚函数(比如虚析构函数),这样编译器才能再运行时使用类型信息和执行转换。
另外在转换前,也得 Base* base =new Derived1; 指向这个对象,不然会存在 和reinterpret_cast一样的问题

static_cast和dynamic_cast的区别

  1. 类型检查时机
  • static_cast:在编译时进行类型检查。它根据转换语句中提供的信息(尖括号中的类型)进行转换,不执行运行时类型检查。
  • dynamic_cast:在运行时进行类型检查。它通过检查对象的实际类型来确定转换是否安全。如果转换不安全,dynamic_cast会返回空指针(对于指针类型)或抛出std::bad_cast异常(对于引用类型)。
  1. 安全性
  • static_cast:不如dynamic_cast安全,因为它不进行运行时类型检查,可能会在类型不匹配的情况下导致未定义行为。例如,将一个子类对象强制转换为父类对象是安全的,但将一个父类对象强制转换为子类对象可能会导致错误。
  • dynamic_cast:在类层次结构中用于安全的下行转换(从基类指针或引用转换为派生类指针或引用)。它依赖于虚函数表(vtable)来确保转换的安全性。如果基类没有虚函数,dynamic_cast将无法进行类型检查,从而无法保证转换的安全性。

funture和promise

1. 核心概念

  • **std::promise**:
    • 用于在一个线程中设置值或异常。
    • std::future 配对使用。
  • **std::future**:
    • 用于在另一个线程中获取由 std::promise 设置的值或异常。

2. 工作机制

  1. 绑定关系
    • 一个 std::promise 与一个 std::future 成对使用。
    • 当通过 std::promise 设置值时,绑定的 std::future 可以访问该值。
  2. 线程间通信
    • std::promise 通常由生产者线程持有,用于设置数据。
    • std::future 通常由消费者线程持有,用于获取数据。
  3. 延迟获取
    • 使用 future.get() 阻塞当前线程,直到 promise 提供值。

3. 通信方式

  • 传递值:通过 promise.set_value() 设置值,future.get() 获取值。
  • 传递异常:通过 promise.set_exception() 传递异常,future.get() 捕获异常。

引用

引用和指针的区别?从底层角度考虑

引用必须初始化,不能改变引用的指向,从汇编的角度来看,引用就是一个const指针

智能指针

C++中的智能指针有哪些,各自有什么作用?

C++智能指针weak_ptr详解

智能指针主要解决一个内存泄露的问题,它可以自动地释放内存空间。因为它本身是一个类,当函数结束的时候会调用析构函数,并由析构函数释放内存空间。智能指针分为共享指针(shared_ptr), 独占指针(unique_ptr)和弱指针(weak_ptr):

(1)shared_ptr ,多个共享指针可以指向相同的对象,采用了引用计数的机制,当最后一个引用销毁时,释放内存空间;

(2)unique_ptr,保证同一时间段内只有一个智能指针能指向该对象(可通过move操作来传递unique_ptr);

(3)weak_ptr,用来解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和shared_ptr之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调用lock函数来获得shared_ptr。

shared_ptr的实现原理是什么?构造函数、拷贝构造函数和赋值运算符怎么写?

(1)shared_ptr是通过引用计数机制实现的,引用计数存储着有几个shared_ptr指向相同的对象,当引用计数下降至0时就会自动销毁这个对象;

(2)具体实现:

1)构造函数:将指针指向该对象,引用计数置为1;

2)拷贝构造函数:将指针指向该对象,引用计数++;

3)赋值运算符:=号左边的shared_ptr的引用计数-1,右边的shared_ptr的引用计数+1,如果左边的引用技术降为0,还要销毁shared_ptr指向对象,释放内存空间。

shareptr引用计数数据类型

long

多态

多态的原理

静态多态和动态多态的区别?

何为静态多态

又称编译期多态,即在系统编译期间就可以确定程序将要执行哪个函数。例如:函数重载,通过类成员运算符指定的运算。

何为动态多态?

动态多态是利用虚函数实现运行时的多态,即在系统编译的时候并不知道程序将要调用哪一个函数,只有在运行到这里的时候才能确定接下来会跳转到哪一个函数。
动态多态是在虚函数的基础上实现的,而实现的条件有:
(1) 在类中声明为虚函数

(2) 函数的函数名,返回值,函数参数个数,参数类型,全都与基类的所声明的虚函数相同(否则是函数重载的条件)

(3) 将子类对象的指针(或以引用形式)赋值给父类对象的指针(或引用),再用该指向父类对象的指针(或引用)调用虚函数
如此,便可以实现动态多态,程序会按照实际对象类型来选择要实行的函数具体时哪一个。

C++11

C++11、C++14、C++17、C++20新特性总结 - cpp后端技术的文章 - 知乎

auto

C++11使用using定义别名(替代typedef)

函数模板的更改

支持默认参数

支持可变参数

元组tuple

新的std

tuple 最大的特点是:实例化的对象可以存储任意数量、任意类型的数据。

tuple 的应用场景很广泛,例如当需要存储多个不同类型的元素时,可以使用 tuple;当函数需要返回多个数据时,可以将这些数据存储在 tuple 中,函数只需返回一个 tuple 对象即可。

lamda表达式

Lambda可以很方便的定义函数列表的个数,以及获取方式

1
2
3
4
5
6
7
8
9
10
11
12
13
[] 什么也不捕获,无法lambda函数体使用任何

[=] 按值的方式捕获所有变量

[&] 按引用的方式捕获所有变量

[=, &a] 除了变量a之外,按值的方式捕获所有局部变量,变量a使用引用的方式来捕获。这里可以按引用捕获多个,例如 [=, &a, &b,&c]。这里注意,如果前面加了=,后面加的具体的参数必须以引用的方式来捕获,否则会报错。

[&, a] 除了变量a之外,按引用的方式捕获所有局部变量,变量a使用值的方式来捕获。这里后面的参数也可以多个,例如 [&, a, b, c]。这里注意,如果前面加了&,后面加的具体的参数必须以值的方式来捕获。

[a, &b] 以值的方式捕获a,引用的方式捕获b,也可以捕获多个。

[this] 在成员函数中,也可以直接捕获this指针,其实在成员函数中,[=]和[&]也会捕获this指针。

如果想要修改外部变量,可以用mutable,但是也只是修改拷贝的那一份变量,真正外部不会修改

本质是一个匿名函数对象,编译器会将其转换为一个带有operator()的类(重新实现了)

1
2
3
4
5
6
7
8
9
10
// Lambda表达式
auto lambda = [](int x) { return x * 2; };

// 编译器实际生成的等价类(简化版)
class CompilerGeneratedName {
public:
int operator()(int x) const {
return x * 2;
}
};

要注意如果引用捕获了局部变量,可能已经销毁了,所以最好值传递。

优点

1.简化代码

1
2
3
4
5
6
7
8
// 传统方式
struct Comparator {
bool operator()(int a, int b) { return a < b; }
};

// Lambda方式
std::sort(vec.begin(), vec.end(),
[](int a, int b) { return a < b; });

2.即时定义和使用

1
2
3
4
5
6
std::vector<int> numbers = {1, 2, 3, 4, 5};
int threshold = 3;

// 直接在使用处定义过滤逻辑
auto it = std::find_if(numbers.begin(), numbers.end(),
[threshold](int n) { return n > threshold; });

3.状态封装

1
2
3
4
5
6
7
8
9
10
11
12
13
class EventHandler {
void handleEvents() {
int errorCount = 0;

// 封装局部状态
auto errorHandler = [&errorCount](const Error& e) {
errorCount++;
std::cout << "Error #" << errorCount << ": " << e.message();
};

// 使用errorHandler...
}
};

缺点

1.某些复杂的匿名函数可能可读性可能比较低。

2.调试比较困难,没法断点。

3.性能开销,如果是值捕获可能造成不必要的拷贝。

for循环的新方式

之前只能三段式,现在可以直接auto来for循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <vector>
using namespace std;
int main() {
char arc[] = "http://c.biancheng.net/cplus/11/";
//for循环遍历普通数组
for (char ch : arc) {
cout << ch;
}
cout << '!' << endl;
vector<char>myvector(arc, arc + 23);
//for循环遍历 vector 容器
for (auto ch : myvector) {
cout << ch;
}
cout << '!';
return 0;
}

constexpr

1
2
3
4
5
6
7
// 1)
int url[10];//正确
// 2)
int url[6 + 4];//正确
// 3)
int length = 6;
int url[length];//错误,length是变量

C++ 程序的执行过程大致要经历编译、链接、运行这 3 个阶段。常量表达式和非常量表达式的计算时机不同,非常量表达式只能在程序运行阶段计算出结果;而常量表达式的计算往往发生在程序的编译阶段,这可以极大提高程序的执行效率,因为表达式只需要在编译阶段计算一次,节省了每次程序运行时都需要计算一次的时间。

constexpr修饰普通变量

C++11 标准中,定义变量时可以用 constexpr 修饰,从而使该变量获得在编译阶段即可计算出结果的能力。

constexpr修饰函数

constexpr 还可以用于修饰函数的返回值,这样的函数又称为“常量表达式函数”。

注意,constexpr 并非可以修改任意函数的返回值。换句话说,一个函数要想成为常量表达式函数,必须满足如下 4 个条件。

1.整个函数的函数体中,除了可以包含 using 指令、typedef 语句以及 static_assert 断言外,只能包含一条 return 返回语句。

const

const最好只用来用作只读的作用,要注意只读不意味不可以修改,可以通过其他的来改

闭包

闭包是当外部函数返回内部函数时,内部函数随后在不同的范围内执行,内部函数继续保持对外部函数变量的访问,即使外部函数不再存在。

就比如一个function再套一个function

1
2
3
4
5
6
7
8
9
10
function hello() {

return function (item) {
console.log(`hello ${item}`);
};
}

const helloWorld = hello();

helloWorld('world');

闭包的概念

1.定义一个函数(outer),该函数存在声明的局部变量(b)

2.该函数的返回值也是一个函数(inner)

3.返回的函数(inner)调用了该函数声明的局部变量(b)

4.该函数被调用(outer)

虚函数

虚函数表

c++虚函数的作用是什么? - 心试的回答 - 知乎

每个子类会生成一个虚函数表,根据这个子类有无重写父类的虚函数,重写了会覆盖对应的内存空间

一个继承一个比较好理解,一个继承了多个可以看下面的图

每一个类会有一个虚函数表,然后这个类的多个对象都会共享这一张虚函数表,新创建的对象会保存虚函数指针。

纯虚函数和抽象类

纯虚函数是指在基类中定义的没有实现的虚函数。使用纯虚函数可以使该函数只有函数原型,而没有具体的实现。注:这里的“=0”表示该函数为纯虚函数。

纯虚函数的作用是让子类必须实现该函数,并且不能直接创建该类对象(即该类为抽象类)。

抽象类是包含纯虚函数的类,它们不能被实例化,只能被继承。抽象类只能用作其他类的基类。如果一个类继承了抽象类,则必须实现所有的纯虚函数,否则该类也会成为抽象类。

抽象函数和接口

抽象类的定义

  1. 最少具有一个纯虚函数
  2. 可以有实现的方法。
  3. 可以进行变量定义。

接口

  1. 所有的函数必须被声明为纯虚函数。
  2. 没有变量的声明。

抽象类是能单继承,接口可以多重继承

1
2
3
4
5
6
7
8
9
10
// 抽象类:单继承
class Circle : public AbstractShape {
void draw() override { /* 实现 */ }
};

// 接口:可以多重继承
class Rectangle : public IDrawable, public IPrintable {
void draw() override { /* 实现 */ }
void print() override { /* 实现 */ }
};

接口使用场景

1.需要定义一组行为规范

2.需要多重继承

3.不需要共享实现代码

4.想要实现松耦合设计

5.依赖依赖注入

抽象类使用场景

1.需要在相关的类之间共享代码

2.需要访问共同的成员变量

3.需要提供默认实现单允许重写

4.需要维护状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 接口示例:定义行为契约
class IRenderer {
public:
virtual void render() = 0;
virtual void setResolution(int width, int height) = 0;
};

// 抽象类示例:提供基础功能
class AbstractRenderer {
protected:
int width;
int height;
bool isInitialized;

public:
// 共享实现
void initialize(int w, int h) {
width = w;
height = h;
isInitialized = true;
}

bool checkInitialized() {
return isInitialized;
}

// 子类必须实现的方法
virtual void render() = 0;
};

// 在实际项目中的选择
class OpenGLRenderer : public AbstractRenderer {
void render() override { /* OpenGL实现 */ }
};

class DirectXRenderer : public AbstractRenderer {
void render() override { /* DirectX实现 */ }
};

依赖注入

比如说你想要写一个database的打印函数,可能会有多个不同的database和打印的对应关系,如果不用接口,就会导致直接在类里面硬编码依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
// 不好的做法:硬编码依赖
class UserService {
private:
// 直接在类内部创建依赖
MySQLDatabase database; // 强耦合到具体的数据库实现
FileLogger logger; // 强耦合到具体的日志实现

public:
void createUser(const std::string& name) {
logger.log("Creating user: " + name);
database.insert("users", name);
}
};

这种难以测试,并且不能切换实现,而且代码耦合度高 ,所以可以用接口+构造函数注入的方式来解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 1. 首先定义接口
class IDatabase {
public:
virtual ~IDatabase() = default;
virtual void insert(const std::string& table, const std::string& data) = 0;
};

class ILogger {
public:
virtual ~ILogger() = default;
virtual void log(const std::string& message) = 0;
};

// 2. 实现具体类
class MySQLDatabase : public IDatabase {
public:
void insert(const std::string& table, const std::string& data) override {
// MySQL实现
}
};

class FileLogger : public ILogger {
public:
void log(const std::string& message) override {
// 文件日志实现
}
};

// 3. 使用依赖注入
class UserService {
private:
IDatabase& database;
ILogger& logger;

public:
// 通过构造函数注入依赖
UserService(IDatabase& db, ILogger& log)
: database(db), logger(log) {}

void createUser(const std::string& name) {
logger.log("Creating user: " + name);
database.insert("users", name);
}
};

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 不同的数据库实现
class PostgreSQLDatabase : public IDatabase {
void insert(const std::string& table, const std::string& data) override {
// PostgreSQL实现
}
};

class MongoDatabase : public IDatabase {
void insert(const std::string& table, const std::string& data) override {
// MongoDB实现
}
};

// 不同的日志实现
class ConsoleLogger : public ILogger {
void log(const std::string& message) override {
std::cout << message << std::endl;
}
};

// 可以轻松切换实现
void configureService() {
// 开发环境
MongoDatabase devDb;
ConsoleLogger devLogger;
UserService devService(devDb, devLogger);

// 生产环境
PostgreSQLDatabase prodDb;
FileLogger prodLogger;
UserService prodService(prodDb, prodLogger);
}

使用智能指针的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class UserService {
private:
std::shared_ptr<IDatabase> database;
std::shared_ptr<ILogger> logger;

public:
UserService(std::shared_ptr<IDatabase> db,
std::shared_ptr<ILogger> log)
: database(std::move(db))
, logger(std::move(log)) {}

void createUser(const std::string& name) {
logger->log("Creating user: " + name);
database->insert("users", name);
}
};

// 使用示例
int main() {
auto db = std::make_shared<MySQLDatabase>();
auto logger = std::make_shared<FileLogger>();
UserService service(db, logger);
}

哪些函数不能声明成虚函数?

非成员函数

非成员函数只能被重载(overload),不能被继承(override),而虚函数主要的作用是在继承中实现动态多态,非成员函数早在编译期间就已经绑定函数了,无法实现动态多态,那声明成虚函数还有什么意义呢?

构造函数

要想调用虚函数必须要通过“虚函数表”来进行的,但虚函数表是要在对象实例化之后才能够进行调用。而在构造函数运行期间,还没有为虚函数表分配空间,自然就没法调用虚函数了。

友元函数

静态成员函数

静态成员函数对于每个类来说只有一份,所有的对象都共享这一份代码,它是属于类的而不是属于对象。虚函数必须根据对象类型才能知道调用哪一个虚函数,故虚函数是一定要在对象的基础上才可以的,两者一个是与实例相关,一个是与类相关。

内联成员函数

内联函数是为了在代码中直接展开,减少函数调用花费的代价,虚函数是为了在继承后对象能够准确的执行自己的动作,并且inline函数在编译时被展开,虚函数在运行时才能动态地绑定函数。

虚析构函数有什么作用?

在Effective C++ 中,Scott Meyers在《条款07:为多态基类声明virtual析构函数》中提到,当derived class对象经由一个base class指针被删除,而该base class带着一个non-virtual析构函数,其结果未有定义——实际执行时通常发生的是对象的derived成分没有被销毁。也就是说,如果派生类继承了父类的情况下,如果父类的析构函数不是虚函数,而在使用中用了多态的写法,就会导致没有调用到派生类的析构函数,导致资源没有释放,造成泄漏。

总的来说虚析构函数是为了避免内存泄露,而且是当子类中会有指针成员变量时才会使用得到的。也就说虚析构函数使得在删除指向子类对象的基类指针时可以调用子类的析构函数达到释放子类中堆内存的目的,而防止内存泄露的
(1)如果父类的析构函数不加virtual关键字
当父类的析构函数不声明成虚析构函数的时候,当子类继承父类,父类的指针指向子类时,delete掉父类的指针,只调动父类的析构函数,而不调动子类的析构函数。
(2)如果父类的析构函数加virtual关键字
当父类的析构函数声明成虚析构函数的时候,当子类继承父类,父类的指针指向子类时,delete掉父类的指针,先调动子类的析构函数,再调动父类的析构函数。

一个指向nullptr的类能调用虚函数吗?

这种情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include<iostream>
#include<string.h>
using namespace std;
class A
{
public:
static void f1(){ cout<<"f1"<<endl; }
void f2(){ cout<<"f2"<<endl; }
void f3(){ cout<<num<<endl; }
virtual void f4() {cout<<"f4"<<endl; }
public:
int num;
};

int main(int argc,char* argv[])
{
A* pa = NULL;
pa->f1(); //正常
pa->f2(); //正常
pa->f3(); //错误,提示段错误
pa->f4(); //错误,提示段错误
return 0;
}

不能,但是可以调用成员函数和static的成员函数(不能使用这个类的成员变量),因为成员函数地址已经确定了,和你的类其实是无关的,

1
2
3
4
5
A *pa = NULL;
pa->func(2);
//在编译器看来就好像是 A_func(pa, 2);且pa==NULL
((A*)NULL)->func(2);
//在编译器看来就好像是 A_func( ((A*)NULL), 2);

类的成员函数并不与具体对象绑定,所有的对象共用同一份成员函数体,当程序被编译后,成员函数的地址即已确定,这份共有的成员函数体之所以能够把不同对象的数据区分开来,靠的是隐式传递给成员函数的this指针,成员函数中对成员变量的访问都是转化成”this->数据成员”的方式。因此,从这一角度说,成员函数与普通函数一样,只是多了一个隐式参数,即指向对象的this指针。而类的静态成员函数只能访问静态成员变量,不能访问非静态成员变量,所以静态成员函数不需要指向对象的this指针作为隐式参数。
有了上面的分析,就可以解释为什么空对象指针对f1, f2的调用成功,对f3的调用不成功。

内存

内存空间有哪些分类?

(1)堆,使用malloc、free动态分配和释放空间,能分配较大的内存;

(2)栈,为函数的局部变量分配内存,能分配较小的内存;

(3)全局/静态存储区,用于存储全局变量和静态变量;

(4)常量存储区,专门用来存放常量;

(5)自由存储区:通过new和delete分配和释放空间的内存,具体实现可能是堆或者内存池。

malloc和new有什么区别?

(1)new分配内存空间无需指定分配内存大小,malloc需要;

(2)new返回类型指针,类型安全,malloc返回void*,再强制转换成所需要的类型;

(3)new是从自由存储区获得内存,malloc从堆中获取内存;

(4)对于类对象,new会调用构造函数和析构函数,malloc不会(核心)。

image-20220309220924444

placement new

直接用new是自动给你分配,但是要用placement new可以在已分配的内存里面创建对象

1
A* p=new (ptr)A

1)用定位放置new操作,既可以在栈(stack)上生成对象,也可以在堆(heap)上生成对象。如本例就是在栈上生成一个对象。

(2)使用语句A* p=new (mem) A;定位生成对象时,指针p和数组名mem指向同一片存储区。所以,与其说定位放置new操作是申请空间,还不如说是利用已经请好的空间,真正的申请空间的工作是在此之前完成的。

(3)使用语句A *p=new (mem) A;定位生成对象时,会自动调用类A的构造函数,但是由于对象的空间不会自动释放(对象实际上是借用别人的空间),所以必须显示的调用类的析构函数,如本例中的p->~A()。

(4)如果有这样一个场景,我们需要大量的申请一块类似的内存空间,然后又释放掉,比如在在一个server中对于客户端的请求,每个客户端的每一次上行数据我们都需要为此申请一块内存,当我们处理完请求给客户端下行回复时释放掉该内存,表面上看者符合c++的内存管理要求,没有什么错误,但是仔细想想很不合理,为什么我们每个请求都要重新申请一块内存呢,要知道每一次内从的申请,系统都要在内存中找到一块合适大小的连续的内存空间,这个过程是很慢的(相对而言),极端情况下,如果当前系统中有大量的内存碎片,并且我们申请的空间很大,甚至有可能失败。为什么我们不能共用一块我们事先准备好的内存呢?可以的,我们可以使用placement new构造对象,那么就会在我们指定的内存空间中构造对象。

函数

构造函数分类

  1. 默认构造函数(Default Constructor):没有参数的构造函数。如果在类中没有定义构造函数,编译器会自动生成一个默认构造函数。默认构造函数用于创建对象时进行默认的初始化操作。

  2. 参数化构造函数(Parameterized Constructor):带有参数的构造函数。参数化构造函数可以接受不同的参数,并根据参数的值来初始化对象的数据成员。

  3. 拷贝构造函数:使用一个对象初始化另一个对象。拷贝构造函数的参数为同类对象的引用。它将被复制的对象的数据成员值复制给新创建的对象。

  4. 移动构造函数(Move Constructor):C++11引入的特性,用于实现对象的移动语义。移动构造函数通过接管另一个对象的资源而避免进行深拷贝,提高了性能。

移动构造函数

传入右值,直接浅拷贝,右值用std::move来生成

STL

stl底层实现

vector:数组

Dequeue(双端队列):二维数组

List:环状双向链表

set(集合):平衡的红黑树

multiset:红黑树

map:平衡二叉树

unordered_map:散列表(哈希表)

而C++ STL 标准库中,不仅是 unordered_map 容器,所有无序容器的底层实现都采用的是哈希表存储结构。更准确地说,是用“链地址法”(又称“开链法”)解决数据存储位置发生冲突的哈希表。

哈希表原理

首先是哈希函数,就是把一个长的二级制数据转换成一个短的二进制数据的函数;然后就是解决哈希冲突的方法,常见的有开放寻址法和链表法,前者是通过探测并占用下一个可用的存储位置,后者是在冲突的位置用链表记录多个值。

哈希函数原理

哈希函数的目标是将任意长度的输入数据转换为固定长度的输出值,同时要尽量避免冲突。下面介绍几种常见的哈希函数实现方法:

除法散列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class DivisionHash 
{
private int tableSize; // 哈希表大小(最好是质数)

public DivisionHash(int size)
{
tableSize = size;
}

public int Hash(int key)
{
return Math.Abs(key) % tableSize;
}

// 字符串的哈希
public int HashString(string str)
{
int hash = 0;
foreach (char c in str)
{
hash = (hash * 31 + c) % tableSize;
}
return Math.Abs(hash);
}
}

乘法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MultiplicationHash 
{
private int tableSize;
private const double A = 0.6180339887; // 黄金分割比例

public MultiplicationHash(int size)
{
tableSize = size;
}

public int Hash(int key)
{
double temp = key * A;
double fractional = temp - Math.Floor(temp); // 取小数部分
return (int)(tableSize * fractional);
}
}

字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class StringHash 
{
private const int BASE = 31; // 基数
private const int MOD = 1000000007; // 大质数

public long ComputeHash(string str)
{
long hash = 0;
long power = 1;

foreach (char c in str)
{
hash = (hash + (c - 'a' + 1) * power) % MOD;
power = (power * BASE) % MOD;
}

return hash;
}
}

string

拼接

拼接原理

  1. 计算拼接后字符串的总长度。
  2. 分配足够的内存以存储新字符串。
  3. 将原字符串和要拼接的字符串的内容复制到新分配的内存中。
  4. 释放原字符串的内存(如果需要)。

大量字符串如何优化

当需要拼接大量字符串(例如一万个字符串)时,可以采取以下优化策略:

预分配内存:

使用 std::string::reserve 方法预分配足够的内存,以避免在拼接过程中多次分配内存。

例如:

1
2
std::string result;
result.reserve(total_length); // total_length 是所有字符串的总长度
  1. 使用 std::ostringstream:

使用 std::ostringstream 来拼接字符串。ostringstream 是一个输出流,可以高效地处理字符串拼接,避免多次内存分配。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
#include <sstream>
#include <string>
#include <vector>

std::vector<std::string> strings = {/* 一万个字符串 */};
std::ostringstream oss;

for (const auto& str : strings) {
oss << str; // 使用流操作符拼接
}

std::string result = oss.str(); // 获取最终拼接的字符串
  1. 使用 std::string::append:

如果不使用 ostringstream,可以使用 std::string::append 方法,它在某些情况下比 operator+ 更高效。

  1. 避免不必要的拼接:

在拼接字符串时,尽量避免在循环中进行不必要的拼接操作。可以先将所有字符串存储在一个容器中,然后一次性拼接。

模板编程

类型

有三种类型

模板实际上分为三类:

类型模板参数(类型模板)

1
2
template <typename T>
class MyClass { };

非类型模板参数(常量模板)

1
2
template <int Size>
class Array { };

模板模板参数(模板的模板参数)

1
2
template <typename T, template <typename> class Container>
class MyClass { };

应用示例

类型模板参数(通用游戏对象)

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
class GameObject {
T position;
void move(T delta) {
position += delta;
}
};

// 可以用于不同类型的位置表示
GameObject<Vector2> player;
GameObject<Vector3> enemy3D;

非类型模板参数(固定大小游戏数组)

1
2
3
4
5
6
7
8
9
template <int MaxUnits = 100>
class ArmyManager {
Unit units[MaxUnits];
int currentUnitCount = 0;
};

// 编译期确定最大单位数
ArmyManager<50> smallArmy;
ArmyManager<200> largeArmy;

模板模板参数(容器策略)

1
2
3
4
5
6
7
8
template <typename T, template <typename> class Container>
class Inventory {
Container<T> items;
};

// 可以使用不同容器
Inventory<Weapon, vector> playerWeapons;
Inventory<Weapon, list> backupWeapons;

编译问题

编译过程

(1)预处理阶段处理头文件包含关系,对预编译命令进行替换,生成预编译文件;包括展开宏定义,处理条件编译指令,包含头文件

(2)编译阶段将预编译文件编译,删除注释,生成汇编文件(编译的过程就是把预处理完的文件进行一系列的词法分析,语法分析,语义分析及优化后生成相应的汇编代码);

(3)汇编阶段将汇编文件转换成机器码,生成可重定位目标文件(.obj文件)(汇编器是将汇编代码转变成机器可以执行的命令,每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,根据汇编指令和机器指令的对照表一一翻译即可);

(4)链接阶段,将多个目标文件和所需要的库连接成可执行文件(.exe文件)

#include<file.h> 与 #include “file.h”的区别?

前者从标准库查找寻找和引用file.h,后者从当前路径寻找和引用

main函数执行之前会执行什么?执行之后还能执行代码吗?

(1)全局对象的构造函数会在main函数之前执行;

(2)可以,可以用_onexit 注册一个函数,它会在main 之后执行;

如果你需要加入一段在main退出后执行的代码,可以使用atexit()函数,注册一个函数。

比如全局变量的初始化,就不是由main函数引起的

举例: class A{};

A a; //a的构造函数限执行

int main() {}

动态库和静态库优缺点

静态库

优点

  1. 代码装载速度快,执行速度比动态链接库略快
  2. 只需要开发者有lib就行,不需要考虑用户电脑上有无lib。

缺点

生成的体积较大,包含相同的公共代码,造成浪费

动态库

优点

  1. 节省内存
  2. dll和exe独立,更换dll就可以改变函数内容,提高可维护性和可拓展性
  3. 不同编程语言只要按照函数调用约定可以用同一个dll
  4. 耦合度小,开发过程独立

缺点

用户电脑里面需要有dll

C#

关键字

unsafe

在C#中,unsafe 关键字用于标识包含不安全代码块的上下文,允许直接使用指针和执行不安全的操作。

优点:

  1. 更高的性能: 使用指针直接操作内存可以提高性能,特别是在处理大量数据或需要高效访问内存的场景下。

  2. 与非托管代码交互: 允许与非托管代码进行更直接的交互,例如调用 Windows API 或者使用一些底层的系统功能。

  3. 灵活性: 可以执行一些 C# 中无法直接实现的操作,如访问特定的内存地址或进行底层的位操作。

缺点:

  1. 安全性风险: 使用 unsafe 可能导致程序出现潜在的安全漏洞,因为绕过了 C# 的类型安全检查和边界检查。

  2. 可读性下降: 使用指针和不安全的操作会增加代码的复杂性,并且降低代码的可读性和可维护性。

  3. 难以调试: 不安全的代码可能更难调试和定位错误,因为涉及到直接操作内存的技术细节。

const和readonly有什么区别?

都可以标识一个常量。主要有以下区别:
1、初始化位置不同。const必须在声明的同时赋值;readonly即可以在声明处赋值;
2、修饰对象不同。const即可以修饰类的字段,也可以修饰局部变量;readonly只能修饰类的字段
3、const是编译时常量,在编译时确定该值;readonly是运行时常量,在运行时确定该值。
4、const默认是静态的;而readonly如果设置成静态需要显示声明
5、修饰引用类型时不同,const只能修饰string或值为null的其他引用类型;readonly可以是任何类型。

反射和特性

要用到特性就必须要用反射,比如说你要序列化一个类,如果直接写接口来实现,你不知道这个类有哪些属性,而且还要写很多不同的接口,但是用反射可以很优雅地实现,如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
string Serialize(object obj)
{
var res = obj
.GetType()
.GetProperties(BindingFlag.Public | BindingFlags.Instance)
.Where(pi =>
{
var attr = pi.GetCustomAttribute<BrowsableAtrribute>();
if(attr is not null) return attr.Browable;
return true;
})
.Select(pi => new{Key = pi.Name, Value = pi.GetValue(obj)})
.Select(o => $"{o.Key} : {o.Value}");
return string.Join(Environment.NewLine, res);
}

class Student
{
[Browsable(false)]
public int Id{get;set;}
}

装箱和拆箱

引用类型和值类型

C#中定义的值类型包括原类型(Sbyte、Byte、Short、Ushort、Int、Uint、Long、Ulong、Char、Float、Double、Bool、Decimal)、枚举(enum)、结构(struct),引用类型包括:类、数组、接口、委托、字符串等,引用型是在堆中分配内存,初始化为null,引用型是需要GARBAGE COLLECTION来回收内存的,值型不用,超出了作用范围,系统就会自动释放!

结构体和类区别

OS

死锁需要的条件

  1. 互斥条件 :资源是独占的,即同一时间只能被一个进程使用。如果资源正在被一个进程使用,其他请求该资源的进程必须等待,直到资源被释放。
  2. 请求与保持条件 :一个进程在已经持有一个资源的情况下,又请求新的资源,但新的资源已经被其他进程占用,因此请求的进程被阻塞,并且保持已 获得的资源不放。
  3. 不可剥夺条件 :进程已经获得的资源在未使用完毕之前,不能被其他进程强行剥夺,只能由进程自己释放。
  4. 循环等待条件 :存在一个进程等待队列,其中每个进程都在等待下一个进程持有的资源,形成一个循环等待链。

线程和进程

多个进程共享同一个库

当多个进程加载同一个共享库时,操作系统会采用以下策略:

代码段(Text Section)

共享库的代码段会被映射到不同进程的虚拟地址空间中

实际上,代码段在物理内存中只有一份拷贝

多个进程共享这同一份物理内存,达到节省内存的目的

数据段(Data Section)

每个进程会获得共享库数据段的独立副本

这确保了进程间数据的隔离性

包括:

全局变量

静态变量

线程和进程和协程

Unity协程的原理与应用 - 宇亓的文章 - 知乎

(1)进程是运行时的程序,是系统进行资源分配和调度的基本单位,它实现了系统的并发;

(2)线程是进程的子单位,也称为轻量级进程,它是CPU进行分配和调度的基本单位,也是独立运行的基本单位,它实现了进程内部的并发;

(3)一个程序至少拥有一个进程,一个进程至少拥有一个线程,线程依赖于进程而存在;

(4)进程拥有独立的内存空间,而线程是共享进程的内存空间的,自己不占用资源;

(5)线程的优势:线程之间的信息共享和通讯比较方便,不需要资源的切换等.

每一个进程都独立拥有自己的指令和数据,所以称为资源分配的基本单位。其中数据又分布在内存的不同区域,我们在C语言课程中学习过内存四区的概念,一个运行中的进程所占有的内存大体可以分为四个区域:栈区、堆区、数据区、代码区。其中代码区存储指令,另外三个区存储数据。

线程是处理器调度和执行的基本单位,一个线程往往和一个函数调用栈绑定,一个进程有多个线程,每个线程拥有自己的函数调用栈,同时共用进程的堆区,数据区,代码区。操作系统会不停地在不同线程之间切换来营造出一个并行的效果,这个策略称为时间片轮转法。

那么协程在其中又处于什么地位呢? 一切用户自己实现的,类似于线程的轮子,都可以称之为是协程。

线程的独占资源和共享资源

独占资源

  1. 线程就是函数的运行,所以运行时候的信息都是独占的,包括返回值,局部变量,寄存器信息等,每个进程有自己独占的栈区。
  2. 每个线程有自己独立的线程id,独立的调度优先级和错误返回码。

共享资源

  1. 共享进程的代码区
  2. 共享进程的数据区,即全局变量和静态变量。
  3. 共享进程的堆区。
  4. 动态链接库。
  5. 文件,打开的文件信息。
  6. 共享当前工作目录,以及用户id和组id。

线程安全

“线程安全”也不是指线程的安全,而是指内存的安全,在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因。所以线程安全指的是,在堆内存中的数据由于可以被任何线程访问到,在没有限制的情况下存在被意外修改的风险。

如何避免

私有化内存

栈内存

使用多进程与多线程的区别?

(1)线程执行开销小,但不利于资源管理和保护;进程则相反,进程可跨越机器迁移。

(2)多进程时每个进程都有自己的内存空间,而多线程间共享内存空间;

(3)线程产生的速度快,线程间通信快、切换快;

(4)线程的资源利用率比较好;

(5)线程使用公共变量或者资源时需要同步机制。

操作系统如何保证每个进程都有独立的空间?

通过虚拟内存来实现,

首先是虚拟内存分页,

然后是页表映射,给每个进程维护一个页表,记录了虚拟地址和物理地址的映射关系

之后是内存保护,操作系统会给分配的进程页表有一些额外的标志,用于控制进程对内存的访问权限。

之后是上下文切换,当操作系统切换到一个新的进程时,它会保存当前进程的页表以及其他的上下文信息,并加载下一个进程的页表。这样,每个进程在运行时拥有自己独立的虚拟地址空间,与其他进程的内存空间相隔离。

通过虚拟内存机制,操作系统能够为每个进程提供独立的内存空间,无论是代码、数据还是堆栈,每个进程都认为自己独占系统的整个内存空间。这种内存隔离保证了每个进程的数据安全和保密性,并且允许操作系统有效地管理和保护进城间的内存使用

线程同步的方法

线程同步的几种方式 - TOMOCAT的文章 - 知乎

同步指的是按一定的顺序依次执行

互斥锁

读写锁

条件变量

信号量

计网

TCP

tcp和udp的区别

(1)TCP是传输控制协议,UDP是用户数据报协议;

(2)TCP是面向连接的,可靠的数据传输协议,它要通过三次握手来建立连接,UDP是无连接的,不可靠的数据传输协议,采取尽力而为的策略,不保证接收方一定能收到正确的数据;

(3)TCP面向的是字节流,UDP面向的是数据报;

(4)TCP只支持点对点,UDP支持一对一,一对多和多对多;

(5)TCP有拥塞控制机制,UDP没有。

tcp三次握手的过程

三次握手的本质是确认通信双方收发数据的能力

首先,我让信使运输一份信件给对方,对方收到了,那么他就知道了我的发件能力和他的收件能力是可以的。

于是他给我回信,我若收到了,我便知我的发件能力和他的收件能力是可以的,并且他的发件能力和我的收件能力是可以。

然而此时他还不知道他的发件能力和我的收件能力到底可不可以,于是我最后回馈一次,他若收到了,他便清楚了他的发件能力和我的收件能力是可以的。

HTTP和HTTPS

https为什么更加安全?

彻底搞懂HTTPS的加密原理 - 顾伊凡 YGY的文章 - 知乎

http是明文传输,对称加密虽然性能好但有密钥泄漏的风险,非对称加密(2组公钥+2私钥双向传输)安全但性能低下,因此考虑用非对称加密来传输对称加密所需的密钥,然后进行对称加密,但是为了防止非对称过程产生的中间人攻击,需要对服务器公钥和服务器身份进行配对的数字认证,然后引入了CA数字签名+数字证书验证的方式!

https基本采用以下流程,即非对称+对称

  1. 某网站拥有用于非对称加密的公钥A、私钥A’。

  2. 浏览器向网站服务器请求,服务器把公钥A明文给传输浏览器。

  3. 浏览器随机生成一个用于对称加密的密钥X,用公钥A加密后传给服务器。

  4. 服务器拿到后用私钥A’解密得到密钥X。

  5. 这样双方就都拥有密钥X了,且别人无法知道它。之后双方所有数据都通过密钥X加密解密即可。

但是可能会遭受中间人攻击,即在传输过程中把明文的公钥替换,那么如何保证浏览器收到的公钥就是服务器的公钥?所以就需要CA证书,CA证书本身也需要加密生成一个签名来保证没有被掉包。

而且也不用每次传输都传输密钥,服务器会为每个浏览器(或客户端软件)维护一个session ID,在TLS握手阶段传给浏览器,浏览器生成好密钥传给服务器后,服务器会把该密钥存到相应的session ID下,之后浏览器每次请求都会携带session ID,服务器会根据session ID找到相应的密钥并进行解密加密操作,这样就不必要每次重新制作、传输密钥了!

渲染流程

(1)应用程序阶段,该阶段主要是在软件层面上执行的一些工作,包括空间加速算法、视锥剔除、碰撞检测、动画物理模拟等。大体逻辑是:执行视锥剔除,查询出可能需要绘制的图元并生成渲染数据,设置渲染状态和绑定各种Shader参数,调用DrawCall,进入到下一个阶段,GPU渲染管线。

(2)几何阶段,包含顶点着色、投影变换、裁剪和屏幕映射阶段。

a. 顶点处理阶段:这个阶段会执行顶点变换顶点着色的工作。通过模型矩阵、观察矩阵和投影矩阵(也就是MVP矩阵)计算出顶点在裁剪空间下的位置(clip space),以便后续阶段转化为标准化设备坐标系(NDC)下的位置。也可能会计算出顶点的法线(需要有法线变换矩阵)和纹理坐标等。同时,在这个阶段也可能会进行顶点的着色计算,如平面着色 (Flat Shading)和高洛德着色 (Gouraud Shading)都是在顶点着色器中进行着色计算。因为这个阶段是完全可控制的,因此执行什么样的操作由程序员来决定。(此外,在顶点处理阶段的末尾,还有一些可选的阶段,包括曲面细分(tessellation)、几何着色(geometry shading)和流输出(stream output),此处不详细描述)

b. 裁剪阶段:简单来说就是两次裁剪的粒度不同,前者是在物体对象层面的,一般对对象的包围盒做剔除,剔除掉不在视锥体内的物体,NDC裁剪是在三角形层面做的,裁剪掉不在屏幕内的像素。

c. 屏幕映射阶段:主要目的是将之前步骤得到的坐标映射到对应的屏幕坐标系上。

(3)光栅化阶段,包含三角形设置和三角形遍历阶段。

a. 三角形设置(图元装配),计算出三角形的一些重要数据(如三条边的方程、深度值等)以供三角形遍历阶段使用,这些数据同样可用于各种着色数据的插值。

b. 三角形遍历,找到哪些像素被三角形所覆盖,并对这些像素的属性值进行插值。通过判断像素的中心采样点是否被三角形覆盖来决定该像素是否要生成片段。通过三角形三个顶点的属性数据,插值得到每个像素的属性值。此外透视校正插值也在这个阶段执行。

这两个阶段是完全硬件控制的,不可进行任何操作。

(4)像素处理阶段,包括像素着色和测试合并。

a. 像素着色,进行光照计算和阴影处理,决定屏幕像素的最终颜色。各种复杂的着色模型、光照计算都是在这个阶段完成。

b. 测试合并,包括各种测试和混合操作,如裁剪测试、透明测试、模板测试、深度测试以及色彩混合等。经过了测试合并阶段,并存到帧缓冲的像素值,才是最终呈现在屏幕上的图像。

参考资料

面经:https://zhuanlan.zhihu.com/p/417640759

设计模式:https://zhuanlan.zhihu.com/p/23821422

C++面经:https://github.com/huihut/interview?tab=readme-ov-file

https://github.com/guaguaupup/cpp_interview?tab=readme-ov-file


面经-计算机基础
https://rorschachandbat.github.io/找工作/面经-计算机基础/
作者
R
发布于
2024年3月26日
许可协议