Lua 八股复习笔记

速览卡片

概念 一句话说明
8 种类型 nil / boolean / number / string 是值类型,table / function / userdata / thread 是引用类型
值 vs 引用 值类型拷数据(复印件),引用类型拷句柄(递钥匙),改副本先看是不是同一个柜子
元表 贴在 table/userdata 上的”应急指令单”,正常操作不插手,走不通才翻出来看
__index 读兜底:本表查不到键,去它指向的表/函数里查(继承的地基)
__newindex 写拦截:本表没有此键时触发;函数里不 rawset 则赋值落空
深拷贝 递归复制 table 结构 + seen 账本法防循环引用 + 移植元表
继承 委托单连成链;实例是数据表,方法是类表上的函数
闭包 函数 + 捕获的上值(upvalue)打包成的整体,函数出门自带记忆
userdata C/C++ 对象在 Lua 侧的”取件凭据”,本体是一块 Lua 代管的内存
热更本质 Lua 脚本是”可下载的资源”而不是”焊死的程序”,换文本即换逻辑
ToLua C# 对象进 Lua 变 userdata,由 ObjectTranslator 管映射,调用走”元表三跳”
反射 运行时拿字符串名字查类型、调方法;框架的通用性靠它,性能靠缓存救

一、基本数据类型:值与引用的分野

1.1 八类型与口诀

Lua 只有 8 种基本类型,口诀:“nil 布数串,表函用线”——前四个值类型(nil、boolean、number、string),后四个引用类型(table、function、userdata、thread)。

阵营 成员 记忆点
值类型(拷数据) nil、boolean、number、string 全是”死”的:nil 是空、boolean 是常量、number 和 string 不可变
引用类型(递名片) table、function、userdata、thread 全是”活”的:会被增删改、有状态、有生命周期

值类型是复印文件,引用类型是递钥匙——你拿到钥匙的复制品,开的还是同一个柜门。

例 1 · 赋值后的互相影响:local b = a 之后改 b.x,a.x 跟着变(同一柜子);若改成 b = 新表,只是 b 撕掉旧便利贴改贴新柜子,a 纹丝不动。

1.2 三个高频追问

Q:number 为什么不区分 int 和 float?

A:5.3 之前所有数字都是 double;5.3 起每个值带类型标签,number 下分 64 位整型和 double 两个子类型,位运算符(& | ~ << >>)和整除 // 也是 5.3 才引入的。Lua 是动态语言:类型长在值身上,变量只是一张可以贴到任何格子上的便利贴。
Q:string 是值类型还是引用类型?和 C# 的 string 什么关系?

A:值语义:== 比内容、不可变。底层短字符串做了驻留(同一份内存全虚拟机共享),正因不可变,共享才绝对安全。对照:C# 的 string 是引用类型但 immutable、== 被重载为比内容——两者殊途同归。注意 Lua 的 string 像 C# 的 string,不像 C++ 可原地修改的 std::string。
Q:查一个不存在的键会怎样?

A:table 返回 nil("这个槽空着");userdata 没有这个待遇——查无键又无 __index 时直接报错 attempt to index a userdata value。新手经典灵异 bug:键名手误不报错,症状是莫名其妙的 nil。

1.3 table 访问规则(地基中的地基)

  • 点号是糖:t.x 完全等价于 t["x"],只对”合法标识符”字符串键有效;
  • 数字键用中括号:t[1];数组下标从 1 开始;
  • t[1](数字键)和 t["1"](字符串键)是两个不同的键;
  • 多层嵌套像写快递地址:playerData.equips.weapon.enhance,访问和赋值寻路规则相同;
  • 键可以是任何类型(table、函数都能当 key),nil 除外。
⚠️ 易混淆点:洞、长度与遍历顺序
"洞" = 整数键序列中间缺档(如只有 1、2、4)。#t 只统计从 1 连续无洞的键,带洞的表 #t 结果不可靠(规范不承诺)。pairs 遍历全部键值对但顺序不保证;ipairs 只走 1..n 连续段且有序。稀疏数字键字典不要用 #t/ipairs,用 pairs。

二、元表:应急指令单

2.1 定义与基本操作

元表是一张普通 table,贴在 table/userdata 上,扮演”应急指令单”:正常操作走通时完全不插手,走不通才翻出来看。指令单上每行是一个 __ 开头的元方法(metamethod)。

1
2
3
local t = {}
setmetatable(t, { __index = function(table, key) return "default" end })
getmetatable(t) -- 查询元表,没有则返回 nil

raw 前缀四兄弟:rawget / rawset / rawequal / rawlen——绕过元表直接操作本表,相当于”跳过应急指令单,直接开柜门”。

2.2 核心双子:__index 与 __newindex

__index = 读兜底(查不到才去别处找)  __newindex = 写拦截(本表没有此键才触发)
  • __index 指向一张表:本表查不到键 → 去那张表接着查(继承的地基);
  • __index 指向一个函数:调 __index(t, key),返回值即结果(可做默认值、只读保护、日志);
  • __newindex 指向函数:赋值的最后机会,函数里若不 rawset(t, k, v),这次赋值悄悄落空;
  • 经典用法:__newindex 里先校验再 rawset,就是只读/校验层;配合”数据存进内部表”实现真正的私有字段。
Q:__index 查不到时会接着查"元表的元表"吗?

A:会。元表自己也是 table,也能贴元表——委托单可以无限套娃。继承链就是利用这一点:实例的元表是类表,类表的元表 __index 指向父类表。注意区分:查键走 __index 链,查元方法(如 __add)只看本表元表那一层,不会沿 __index 链找别的表的元表。
⚠️ 易混淆点:"赋值不触发 __newindex"的错觉
__newindex 只在本表没有此键时触发。已有键的赋值走正常路径,不打扰元表;想全量拦截写入,数据必须放在另一个内部表里,对外暴露的壳才有"每个键都是新键"的效果。

2.3 运算符元方法全家

__add __sub __mul __div __mod __pow __unm(算术)、__concat(.. 连接)、__len(# 长度)、__eq __lt __le(比较)、__call(把表当函数调)、__tostring(print 时)、__gc(userdata 回收时)。

Q:两个 table 能直接比大小吗?

A:不能,报错 attempt to compare two table values。table 在 Lua 里是引用身份,== 比的是"是不是同一个表";想要值语义的大小比较,必须由元表提供 __lt/__le。这也是为什么"table 没有内置排序规则",table.sort 必须传比较函数。

三、深拷贝

浅拷贝只拷第一层(b = {} 再 for 循环赋值)——嵌套的 table 仍是同一个柜子。深拷贝三要素:

  1. 递归:遇到 table 就继续往下拷;
  2. seen 账本法防循环引用:a.self = a 的结构,不记账会无限递归;
  3. 移植元表:拷完别忘了 setmetatable(copy, getmetatable(orig))。
1
2
3
4
5
6
7
8
9
10
11
function deepCopy(orig, seen)
if type(orig) ~= "table" then return orig end
seen = seen or {}
if seen[orig] then return seen[orig] end -- 账本里有:直接引用,打断循环
local copy = {}
seen[orig] = copy -- 先记账,再递归
for k, v in pairs(orig) do
copy[deepCopy(k, seen)] = deepCopy(v, seen)
end
return setmetatable(copy, getmetatable(orig))
end
⚠️ 易混淆点:深拷贝拷不了 function 和 userdata
函数拷的是引用(同一个函数对象,本来就该共享);userdata 本体在 C 侧,Lua 无权复制——硬拷等于伪造取件凭据。深拷贝只对纯 table 结构有效,配置表、数据表是它的主场。

四、继承

Lua 没有 class 关键字,继承 = 元表 __index 串成的委托链。实例是数据表,方法是类表上的函数,调用时沿链向上找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Base = {}
Base.__index = Base

function Base.new(name)
local o = setmetatable({}, Base) -- 实例的"应急指令单"指向类表
o.name = name
return o
end
function Base:speak() print("Base: " .. self.name) end

-- 继承:子类表的 __index 指向父类表(类表层面再套一层委托)
Derived = setmetatable({}, { __index = Base })
Derived.__index = Derived

function Derived.new(name, level)
local o = Base.new(name) -- 先走父类构造
o.level = level
return setmetatable(o, Derived) -- 元表换成子类表
end
function Derived:speak() -- 重写
print("Derived Lv." .. self.level .. ": " .. self.name)
end
Q:调用未重写的方法为什么会落到父类?重写后想调父类版本怎么办?

A:实例上查不到方法 → __index 指向子类表 → 子类表也没有 → 子类表的元表 __index 指向父类表,一路委托上去。想调父类版本用显式路径:Base.speak(self)(把 self 手动传进去),不能用点号调用——点号会把 o 当第一个参数但查不到时行为不可控,面试里写 Base:speak() 漏传 self 是经典错误。

五、闭包

闭包 = 函数 + 它捕获的上值(upvalue)打包成的整体——函数出门自带记忆,记忆存在函数体外、调用者的视野外。

例 1 · 计数器:局部变量 n 本该在 counter 返回后销毁,被内层函数捕获后一直活着,且外界无法直接摸到它——这就是”私有变量”。

1
2
3
4
5
6
7
8
9
10
11
12
13
function counter()
local n = 0
return function()
n = n + 1
return n
end
end

local c1 = counter()
local c2 = counter()
c1() --> 1
c1() --> 2
c2() --> 1 (两个闭包各记各的账,互不可见)
Q:循环里创建一批闭包,它们的上值是同一个变量吗?

A:看版本。Lua 5.1(含 LuaJIT/ToLua/xLua 项目里跑的全是它):循环变量只有一份,所有闭包共享同一个 upvalue,循环结束后读到的全是终值——经典灵异 bug。Lua 5.4 起每轮迭代循环变量都是新局部,各闭包各捕各的。项目里用 LuaJIT 时,循环体内要捕获就手动 local x = i 复制一份。

六、userdata 与 C 交互

userdata 是 C/C++ 对象在 Lua 侧的取件凭据:Lua 侧只能拿着它、传给它,本体是一块 Lua 代管的内存。分两代:

种类 本体 特点
full userdata Lua 代管的一块内存(lua_newuserdata) 有 __gc 元方法,回收时回调 C 析构,能与 GC 联动
light userdata 只存一个 void* 指针 轻量,不参与 GC 通知,类比”裸指针名片”

C 侧通过 lua_CFunction 与 Lua 通信:参数和返回值都走 lua_State 栈——读参数用 lua_toXXX(L, 索引),返回值是压栈后 return 个数。

Q:C 函数怎么让 userdata"有方法可点"?

A:给 userdata 贴元表,元表里 __index 指向方法表,方法表里每个函数都是 lua_CFunction。于是 obj:method() 的完整链路:查 obj 无此键 → __index 给方法 → 调用进 C → C 从栈上取回 userdata、翻译成真实对象 → 干活。这也是 ToLua 的底座。

七、ToLua:C# ↔ Lua 的”三跳”

C# 对象要进 Lua,由 ObjectTranslator 登记造册:对象 ↔ userdata 一一映射。Lua 调 C# 方法走“三跳”:

Lua 调用 → ① __index 转发 → ② C 侧包装函数查册 → ③ 翻译回 C# 对象执行真实方法
  • 第一跳:Lua 侧 obj:Method(),obj 是 userdata 查无此键 → 触发元表 __index;
  • 第二跳:__index 转发到 C 侧的 lua_CFunction 包装,按名字在注册表里找到对应的 C# 方法信息;
  • 第三跳:ObjectTranslator 把 userdata 翻译回 C# 对象,执行真实方法(Wrap 预生成代码直接调用,没生成走反射兜底)。
Q:为什么要用 userdata 包 C# 对象,直接传一个整数 ID 不行吗?

A:ID 确实轻,但有两个致命伤:① GC 不联动——Lua 侧 ID 还在,C# 对象可能已被回收,再用就是悬垂引用;userdata 的 __gc 能在 Lua 侧回收时通知 C# 解绑。② 没有类型行为——userdata 可以贴元表获得"方法可点"的完整体验,裸 ID 只是一串数字。

八、热更新:方案与本质

热更的本质:Lua 脚本是”可下载的资源”而不是”焊死的程序”——换文本即换逻辑。游戏出 bug 不用发版,下发新脚本就完事。

方案 出品 机制 特点
ToLua + LuaFramework 社区 导出 Wrap 包装类,C# ↔ Lua 互调 老牌稳定,导出配置繁琐
xLua 腾讯 打 [LuaCallCSharp] 等标签自动生成绑定,未生成的走反射兜底 接入快,反射首调慢靠缓存救
huatuo(HybridCLR) 社区/原 Tuanjie 直接解释执行热更 DLL “真·C# 热更”,无 Lua 层,性能最好
Q:为什么 C# 代码不能直接热更,非要在 Lua 层写逻辑?

A:iOS 禁止 JIT(动态生成机器码),上架的 C# 全是 AOT 编译进安装包的死代码,跑错只能发版。Lua 是解释执行的脚本,在苹果眼里属于"资源文件"而非可执行代码,更新脚本钻的就是这个空子。huatuo 更进一步:把热更 DLL 用解释器执行,同样绕开 JIT 禁令,让 C# 也能热。
⚠️ 易混淆点:热更不是万能的
① ToLua/xLua 方案里,C# 框架层的 bug 热更不了(Lua 管不到 C# 的实现),能热的只有写在 Lua 侧的业务逻辑;② 首次加载要解析全部 Lua 文本,有 IO 和编译开销;③ Lua 与 C# 往返有"三跳"成本,高频调用(如每帧 Update 大面积互调)要谨慎。

面试速记

  1. 口诀”nil 布数串,表函用线”——8 类型,前四值后四引用。
  2. 值类型复印文件,引用类型递钥匙;Lua 的 string 像 C# 的 string,不像 C++ 的 std::string。
  3. t[1] ≠ t["1"];数组从 1 开始;带洞的表 #t 不可靠,遍历用 pairs 保全部、ipairs 保连续。
  4. __index 读兜底、__newindex 写拦截(不 rawset 就落空);raw 系列 = 跳过应急指令单。
  5. 继承 = 委托链:实例 → 类表(__index)→ 父类表(元表的 __index);调父类版本写 Base.speak(self)。
  6. 闭包 = 函数 + 上值;LuaJIT(5.1)循环闭包共享 upvalue,捕获前手动 local 复制。
  7. userdata = C 对象的取件凭据;full 有 __gc 与 GC 联动,light 只是裸指针名片;查无键直接报错。
  8. ToLua 三跳:__index 转发 → C 包装查册 → ObjectTranslator 翻译执行;ID 轻但没有 GC 联动。
  9. 热更本质 = 脚本是资源不是程序;iOS 禁 JIT 所以 C# 只能 AOT,huatuo 用解释执行破局。
  10. 热更的边界:C# 框架层 bug 热不了,Lua↔C# 高频互调有三跳成本。

C# 八股复习笔记

C# 八股复习笔记


一、速览卡片

# 考点 一句话说明
① 引用类型 vs 值类型 值类型变量本身装数据(赋值复印);引用类型变量装”指向堆对象的引用”(赋值拷引用,共享对象)
② 装箱与拆箱 装箱 = 值类型套 object 壳上堆(产生垃圾);拆箱 = 检查 + 取值(无分配)。GC 压力全在装箱
③ GC 机制 从四类”根”标记存活,清除 + 压缩(搬运 + 更新引用),分 Gen0/1/2,LOH ≥85KB 不压缩
④ List 与 Array List<T> 底层 = T[] + Count + Capacity;扩容 = 新数组翻倍 + 拷贝 + 旧数组变垃圾
⑤ Dictionary 哈希表 = buckets(门牌)+ entries(仓库 {key, value, hashCode, next}),等长、共扩容
⑥ 委托与事件 delegate = 类型安全的函数指针 + 多播;event = 封装 delegate,外部只许 += / -=
⑦ 协程 IEnumerator 状态机 + 主线程每帧调度;调用零执行,MoveNext 跑一段,yield 暂停交指令

二、逐考点详解

① 引用类型 vs 值类型

值类型:struct、int、float、bool、enum。变量本身就是数据,赋值 = 整体复印,之后两个变量互不干扰。局部变量通常在栈上;但作为类的字段时跟着对象住在堆上。

引用类型:class、string、array、delegate。变量里装的是指向堆上对象的引用(可重新指向别的对象,类似 C++ 指针而非 C++ 引用)。赋值 = 拷贝引用,两个变量指向同一个对象。

Q:"值类型在栈上、引用类型在堆上"对吗?struct 里装了 class 成员会变成引用类型吗?

A:两句话都错。"住哪"取决于声明位置而非类型:值类型做局部变量在栈上、做类的字段就在堆上;引用类型的对象永远在堆,但装引用的变量可栈可堆。
struct 永远、始终是值类型,装任何成员都不改变类别。只是复制这个 struct 时,里面的引用字段连引用一起被复印——多个副本的引用字段指向同一个对象;值成员(int 等)各拷各的,互不影响。

类比:值类型 = 复制粘贴一个文件副本;引用类型 = 创建一个指向同一文件的快捷方式。 给快捷方式换个指向目标(p2 = new Player()),不影响别的快捷方式;但顺着快捷方式改文件内容,大家看到的都是同一个文件。

⚠️ 易混淆点:string 是引用类型
string 分类上属于 class(引用类型),但它是不可变的——任何"修改"操作都创建了新字符串。用起来像值类型,答题时别归错类。另外装箱只对值类型有意义;引用类型与 object 之间是引用拷贝,零成本。

② 装箱与拆箱

装箱 = 值类型 → object(套壳上堆)  拆箱 = object → 值类型(验壳 + 脱壳取值)
  • 装箱:在堆上 new 一个 object,把值拷贝进去,返回引用。每次装箱都制造一个短命堆对象。
  • 拆箱:类型检查(不符抛 InvalidCastException)+ 把值从堆对象拷回栈变量。不申请内存、不释放任何东西,堆上原对象纹丝不动。
Q:GC 压力为什么全在装箱一侧?拆箱呢?

A:装箱制造垃圾:调用结束时装箱产物无人引用,成为"从根不可达"的短命对象,频繁触发 Gen0 回收。拆箱只读值,不产生任何垃圾——"先装箱再拆箱"是白忙一轮还倒贴 N 个盒子的 GC 垃圾。高频装箱场景:object 参数、非泛型容器(ArrayList)、值类型拼字符串。
判别法:看 API 边界给的类型是 int 还是 object——给 int 无事发生,给 object 则值类型必装箱。

例 1 · 造无限序列:while (true) { yield return i++; } —— 调用方要几个给几个,永不主动算完。
例 2 · 省内存遍历:大文件逐行 yield return line,不用一次加载全部。
例 3 · 高频坑:ArrayList.Add(5) —— 每次 Add 都装箱;List<int>.Add(5) —— 泛型,零装箱。


③ GC 机制

垃圾的定义:从所有”根”出发沿引用链走不到的 = 垃圾。C# 没有 C++ 那种确定性生命周期——生死由”是否可达”决定,回收时机不确定。

Q:"从所有根出发"——根那么多,怎么统计?

A:不是运行时统计,是 JIT 编译时记账。根就四类,各有一张表:
① 线程栈:JIT 为每个方法生成栈映射表——"执行到偏移 N 时,第几号槽装着引用",GC 按表只认引用槽;
② 静态字段:类型加载时登记,数量有限直接遍历;
③ GCHandle 表:给 CLR 之外的使用者(C 库)开的寄养登记——"外面还有人攥着这个对象的地址,先别收",固定(pinning)形态还会把对象钉在原地不许搬家;
④ 终结/待终结队列:带 finalizer 的对象由 new 自动"注册",GC 眼里它们也算活着。

标记 → 清除 → 压缩 三步:

  1. 标记:从四类根出发染色,没染到的 = 垃圾。
  2. 清除:回收垃圾内存。
  3. 压缩:存活对象往一端挪动挤紧,消除碎片,并更新所有指向它们的引用。

分代:大部分对象死得很快,所以——

代 放什么 回收频率
Gen 0 新诞生的对象 最高(一满就回收,存活极少、压缩便宜)
Gen 1 从 Gen 0 幸存一次的 中等
Gen 2 老对象 最低(全堆扫描,最贵)

LOH(大对象堆):≥ 85KB 的对象单独放,不压缩(搬一次太贵),易碎片化。

Q:压缩的成本是什么?固定(pinning)为什么造成碎片?

A:两笔账:① memcpy 搬运每个存活对象;② 按引用总数更新地址——一个对象被 N 个引用指着,搬家后 N 个引用全要改(能找到它们靠类型元数据 + 栈映射表,但工作量与引用数成正比)。
固定对象 = 不许动的障碍物,堆在它周围被划得七零八落,空隙什么都塞不进去 → 外部碎片。LOH 整体不压缩,碎片问题更重。
结论:频繁创建短命对象不可怕(死在 Gen0,回收便宜);可怕的是老对象引用关系复杂 + 大对象。
Q:带 finalizer 的对象怎么回收?"终结队列"为何不直接清除?

A:第一轮 GC:标记发现它是垃圾,但在终结队列里挂着 → 本次不收,挪到"待终结队列"(也算根),对象继续活着;终结线程执行完 ~Class() 后,下一轮 GC 才真正回收。
所以带 finalizer 的对象至少经过两轮 GC,且时机、顺序都不保证 → 确定性清理用 Dispose/using,finalizer 只当保险丝。

④ List & Array

List<T> 底层 = T[] 数组 + Count(元素个数)+ Capacity(容量)。

扩容流程:Add 时发现 Count == Capacity → 申请新数组(容量约翻倍)→ 元素逐个拷过去 → 内部指针改指新数组 → 旧数组变 GC 垃圾。

  • 几何翻倍使连续 Add 的总拷贝代价为 O(n);”加 1 扩 1”会退化成 O(n²)。
  • 扩容瞬间有性能毛刺(分配 + 全量拷贝);能预估就用 new List<T>(capacity) 避开。
  • 没有原地扩容:托管堆对象大小创建时定死,realloc 式”续租”在 GC 堆上不存在——永远是”退旧房、买新房、搬家”。

例 1 · 预估容量:战斗开始前 new List<Enemy>(预估波次数量)。
例 2 · 复用容器:每帧 LINQ/临时 List 用对象池复用,避开 GC。


⑤ Dictionary

目标:按键快速找值。键 → 哈希 → 数字 → 下标:

桶下标 = hashCode % capacity

两个数组,永远等长,扩容一起扩:

数组 内容
buckets[] 门牌:存”去 entries 第几号拿”的索引(空链存 -1)
entries[] 仓库:每条 = { key, value, hashCode, next },next = 下一个冲突条目的编号

插入示例(容量 5,”Apple” hashCode=12 → 12%5=2):

1
2
3
4
5
buckets: [ -1, -1,  0, -1, -1 ]     entries[0] = { "Apple", ..., next=-1 }
插入 "Banana"(7%5=2,冲突):
新条目进 entries 空位(1 号),串到链头,2 号桶改指新条目
buckets: [ -1, -1, 1, -1, -1 ]
entries[1] = { "Banana", ..., next=0 } ← 链:1号 → 0号
Q:查找时"比对相等"在比什么?rehash 改的是 hashCode 吗?

A:两道检查:先拿查找键的 hashCode 和链上每条存的 hashCode 比(整数比较快筛)→ 相同再 key.Equals 确认(防不同键撞同一 hashCode)。
hashCode 一辈子不变;rehash = 换模数换桶:扩容后 12%10 落点变了,所有条目按新模数重新排列——hashCode 纹丝不动,变的是"模谁"。

扩容:装载因子过高 → 两数组约翻倍 → 全量 rehash(有毛刺,换来链条重新变短)。删除的条目不缩数组,进 freeList 等待复用。

类比:buckets 是带索引卡的抽屉,entries 是仓库货架。货架扩到 200 格,抽屉必须跟着做 200 格——因为落位公式是”编号 % 200”。


⑥ 委托 & 事件

委托:类型安全的函数指针 + 多播清单。

1
2
3
4
5
6
7
8
9
10
11
12
delegate void OnHitHandler(int damage);   // 声明一种"函数类型"(合同)

void ShowDamageNumber(int damage) { /* UI 飘字 */ }
void PlayHitSound(int damage) { /* 音效 */ }
void ShakeCamera(int damage) { /* 震屏 */ }

OnHitHandler h;
h = ShowDamageNumber; // 语法糖:new OnHitHandler(ShowDamageNumber),把方法装进清单
h += PlayHitSound; // 追加:Delegate.Combine
h += ShakeCamera;

h(50); // 一次调用,三个函数依次执行;50 原样传给每一个
Q:h = 函数 这行到底发生了什么?委托只有声明没有实现?

A:delegate 声明只是定义类型("必须是这种形状的函数"),实现由编译器生成(清单 + 遍历调用)。h = ShowDamageNumber 是"方法 → 委托"的隐式转换:创建一个委托对象,把方法以 {目标, 函数指针} 形式塞进它的清单。从这一刻起 h 是一个装着方法的委托对象,new 那步被语法糖藏起来了。
Q:签名管什么?清单里的函数参数要一样吗?

A:签名 = 参数类型列表 + 返回类型,是挂进清单的准入门槛。参数名随便(damage / dmg 都行),类型、个数、返回类型必须全匹配,否则编译错误。调用时参数原样分发给清单中每个函数。
对照 C:C 的函数指针是裸地址,可以强转签名乱调(UB);C# 委托把签名变成类型的一部分,不匹配在编译期就被拦死——这就是"类型安全"的含义。

事件:给委托上锁。

1
2
3
4
5
6
7
public event OnHitHandler OnHit;   // 发布者类内声明

// 外部代码只能:
OnHit += ShowDamageNumber; // ✅ 订阅
OnHit -= ShowDamageNumber; // ✅ 退订
// OnHit = null; // ❌ 编译错误:不许清空
// OnHit(50); // ❌ 编译错误:只能在声明事件的类内部触发
⚠️ 易混淆点:delegate 是图纸,event 是房子
delegate 出场 = 造类型(定义函数形状);event 出场 = 造成员(类里放一份受控订阅清单)。两个关键字不会出现在同一行——记忆里"同一行"的那个是 public event EventHandler X;:event 关键字 + 预定义委托类型 EventHandler(签名 object sender, EventArgs e)。日常也可直接用 Action / Func,不必自写 delegate。

⑦ 协程

本质:IEnumerator 状态机 + Unity 主线程调度。不是多线程——和 Update 共享同一条时间线,是单线程上的协作式并发。

状态机三要素:MoveNext()(从上次暂停处执行到下一个 yield,返回 false = 走完)、Current(本次 yield return 交出的值)、state(内部状态编号)。

Q:调用协程方法时"不执行"?那第一个暂停点哪来的?

A:调用方法只造状态机对象(函数体零执行,只调用不调 MoveNext 的话一行都不跑);第一次 MoveNext 才从函数体第一行执行到第一个 yield 停住——暂停点是首次推进跑出来的。StartCoroutine = 帮你做"造机器 + 首次 MoveNext + 登记调度器"三件事。
Q:yield return 后面到底能放什么?凭什么?

A:放的是"递出去的值",C# 语言只负责暂停 + Current = X,X 的含义由读者决定。
读者是 foreach:X = 序列的下一个数据(造数列、省内存遍历)。
读者是 Unity 调度器:X 的类型 = "何时恢复"的指令——null 下一帧、WaitForSeconds(t) 到时、WaitUntil(()=>条件) 满足那天、StartCoroutine(另一个) 等它跑完。

等待期间零执行:MoveNext 不被调用,那段代码冻结、不占 CPU(不是空转)。代价:协程里写死循环照样卡死主线程。

1
2
3
4
5
6
7
IEnumerator AttackRoutine() {
Debug.Log("举刀");
yield return null; // 暂停到下帧
yield return new WaitForSeconds(2f); // 暂停 2 秒
yield return new WaitUntil(() => hp < 30); // 每帧检查条件,满足才继续
Debug.Log("收招");
}
Q:WaitUntil 里的 () => hp < 30 是什么?没参数,它"捕获"了什么?

A:是 lambda(和 C++ lambda 同源,C# 省掉捕获列表)。参数 ≠ 捕获:参数是调用方传入,捕获是 lambda 定义点外层变量直接借用——hp 就是这个 lambda 捕获的变量,所以它每帧读到的都是当前最新血量(捕获的是"活的格子",不是定义时快照)。
值类型被捕获时,编译器把变量搬进一个闭包对象(堆上的隐藏 class,住在储物柜里)——int 仍是 int、值语义不变,只是变量搬了家;lambda 和外层通过同一个对象引用摸到同一个字段,"共享"是这么来的。若捕获的是拷贝,血量定格在启动那刻,条件永远不满足,协程卡死。
证明捕获非拷贝:lambda 内部改、外部跟着变;证明非快照:外部改、lambda 读到新值。两个方向合起来才完整。
⚠️ 易混淆点:协程 ≠ 多线程
协程与 Update 读写同一变量不需要锁——交错只发生在明确的 yield 边界,不可能执行到一半被插进来。并发 ≠ 并行 ≠ 多线程:协程是单线程上的交替执行,同一时刻只有一段代码在跑。
停止机制:自然结束(MoveNext 返回 false)、StopCoroutine(本质是从调度列表移除,不是杀线程)、宿主销毁或 SetActive(false)。注意:只把组件 enabled = false 不停协程。

三、易混淆点对比汇总

易混对 关键区别
值类型 vs 引用类型 变量装数据(复印) vs 装引用(共享对象);string 是引用类型
装箱 vs 拆箱 套壳上堆(产生垃圾) vs 验壳取值(无分配);压力全在装箱
野指针式的”悬垂” vs 泄漏(C# 语境) GC 不存在野指针;但”持有已 Dispose 对象””事件未退订”≈ 等效问题
拆箱 vs 引用类型转换 拆箱脱的是值类型的壳;引用转换只是检查类型
foreach 泛型 vs foreach 非泛型接口 结构体枚举器 + int Current(零分配) vs IEnumerator + object Current(值类型逐元素装箱)
捕获拷贝 vs 捕获活变量 定义时快照(条件永远不满足) vs 共享格子(读到最新值)
搬家 vs 变质 值类型进闭包对象是”住进储物柜”,int 没变引用类型
delegate vs event 造类型 vs 造成员;外部只许 += / -=,触发只能类内
并发 vs 并行 vs 多线程 逻辑多执行流 vs 物理同时跑 vs 多线程;协程只占第一个
构造/析构(C# finalizer) C++ 确定性 vs C# 至少两轮 GC、时机不保证

四、流程图

图 1 · GC 标记-清除-压缩

四类根
栈映射表 / 静态字段
GCHandle / 终结队列
→
标记
沿引用链染色
没染到 = 垃圾
→
清除
回收垃圾内存
→
压缩
存活对象挤紧
按引用总数改地址
(LOH 跳过本步)
→
带 finalizer?
挪待终结队列
下轮才收(两轮 GC)

蓝 = 固定环节,绿 = 主流程,黄 = 开销大头,红 = 延迟回收分支

图 2 · Dictionary 查找

查找 key
→
算 hashCode
→
% capacity → 桶号
→
buckets[桶号] → entries 编号
→
沿 next 链走
先比 hashCode(快筛)
相同再 Equals 确认
→
命中

图 3 · 协程每帧调度循环

每帧(Unity 主循环)
↓
遍历登记的所有协程
↓
检查暂停条件:到时间了吗?条件满足了吗?下一帧到了吗?
↓ 满足
MoveNext():执行到下一个 yield
↓ 返回 false
协程结束,从列表移除

全程主线程;条件不满足期间 MoveNext 根本不被调用(零 CPU,不是空转)


五、面试速记

  1. 值类型 = 复制文件,引用类型 = 创建快捷方式;快捷方式换目标不影响别人,顺着改文件内容大家都看见。
  2. 装箱 = 值类型套壳上堆(GC 压力来源),拆箱 = 验壳取值(无分配)。
  3. GC 的根不是运行时统计的,是 JIT 编译时写好的四张表:栈映射、静态字段、GCHandle、终结队列。
  4. 频繁短命对象不可怕(死在 Gen0),可怕的是老对象引用复杂 + 大对象(LOH 不压缩)。
  5. 压缩两笔账:memcpy 搬运 + 按引用总数更新地址;固定对象 → 障碍物 → 碎片。
  6. List = T[] + Count + Capacity;无原地扩容,永远是”退旧房买新房”。
  7. Dictionary:buckets 是门牌、entries 是仓库,等长、共扩容;hashCode 不变,rehash 变的是模数。
  8. 委托 = 类型安全函数指针 + 多播;event = 上锁的清单(只许订阅不许篡改)。
  9. 协程调用零执行,首次 MoveNext 才开工;StartCoroutine = 造机器 + 首次推进 + 登记。
  10. yield return 后面放啥,取决于谁在读 Current——foreach 读数据,Unity 调度器读”何时恢复”的指令。
  11. lambda 捕获值类型 = 变量搬进堆上的闭包对象(储物柜),int 不变引用,共享的是同一个格子。
  12. 协程与主线程共享变量不用加锁——交错只发生在 yield 边界;并发 ≠ 并行 ≠ 多线程。

整理方式:以”提问 → 纠错 → 追问”的复习对话为底稿,按笔记编号归档;每个 Q/A 框对应一次真实的理解拐点。

C++ 八股复习笔记

C++ 八股复习笔记

主线记忆:“指针赋值拷的是地址,值拷贝造的是新对象;move 只贴标签,搬家靠构造。”


一、速览卡片

# 考点 一句话说明
① 虚函数表与 vptr 虚表编译期生成于 .rodata,vptr 在对象首部、构造时写入,运行时经 vptr 决议虚函数
② 左值与右值 move 只贴右值标签,真正搬资源的是移动构造;有名字的右值引用是左值
③ new/delete 与 malloc/free new 自动调构造析构、自动算大小、失败抛异常;malloc 只搬内存、返回 void*、失败返 NULL
④ 智能指针 shared_ptr 引用计数原子操作(安全),但被管理对象不受保护;weak_ptr 只加弱计数,lock() 升级后再用
⑤ struct 与 class 只有默认访问权限和默认继承方式不同,能力完全等价
⑥ delete this 对象自杀合法,仅限堆对象,销毁后只能 return
⑦ 内存对齐 数据按类型大小对齐才能被 CPU 一次取全;64 字节对齐防伪共享
⑧ 进程线程与同步 进程=资源分配单位,线程=调度单位;线程安全单例 = C++11 magic statics
⑨ 编译四阶段 预处理 → 编译 → 汇编 → 链接
⑩ 静态/动态链接 静态把库代码拷进 exe,动态运行时才加载 dll/so
⑪ static 四用法 函数内局部变量 / 文件级 / 类成员变量 / 类成员函数,四种位置四种语义
⑫ inline 与深拷贝 inline 的本义是就地展开,主业是让函数定义能安全写进头文件;有指针成员的类要手写五件套

二、逐考点详解

① 虚函数表与 vptr

动态多态的三要素:继承 + 虚函数重写 + 通过基类指针或引用调用。三者缺一,多态不成立。

  • 虚表(vtbl):编译期由编译器为每个含虚函数的类生成,存在只读数据段(.rodata),全类只有一份,与创建几个对象无关。
  • vptr:存放在对象内存布局的最前面(64 位下占前 8 字节,在成员变量之前),每次构造对象时由构造函数里编译器插入的代码写入——不是链接期初始化,链接器根本接触不到运行时创建的对象。多继承时对象里有多个基类子对象,每个都可能有各自的 vptr,各指向各的虚表。
  • 虚函数代码:和普通成员函数一样在代码段(.text),对象里只存指向虚表的指针。

构造/析构期间的 vptr 切换(面试加分项):构造 Derived 时先执行 Base(),此刻 vptr 指向 Base 的虚表;Base 构造完、进入 Derived() 时改写为 Derived 的虚表。析构正好镜像:Derived 析构函数体执行完后切回 Base 虚表,再执行 ~Base。

构造期间 vptr 写 2 次(Base 版 → Derived 版),析构再写 1 次切回 Base 版

推论:构造/析构函数中调用虚函数不发生多态——此刻 vptr 还指着本类的表,调用的是本类版本。

⚠️ 易混淆点:三种"赋值"的本质区别
指针赋值拷的是地址,访问的还是原对象;值拷贝造的是新对象,新对象的 vptr 由它自己的构造函数写入,与数据来源无关;new 表达式会先构造一个堆对象再返回其地址。

Base* p = &d; —— 无新对象,p 指向原对象(Derived 虚表)
Base b = d; —— 栈上新对象,切片,vptr 指向 Base 虚表
Base* p = new Derived(); —— 堆上新对象,经典多态写法,记得 delete
Q:nullptr 上调用成员函数会怎样?

A:普通成员函数若不访问成员,编译期地址已定,this 只是传进去但从未解引用——可以正常打印;一旦访问成员就要解引用 this(地址 = this + 偏移),立刻段错误。虚函数必崩:调用第一步就要读对象首部的 vptr,nullptr 连对象都没有,第一步就倒下。
记忆:普通函数是"编译器指路",虚函数是"通过 vptr 自己找路"——没有对象就没有路标。
⚠️ 易混淆点:基类析构函数必须是 virtual
Base* p = new Derived(); delete p; —— 若 Base 析构不是虚函数,只会执行 ~Base(),Derived 部分(构造函数里申请的资源)永久泄漏,且严格来说属于未定义行为。口诀:有虚函数的类,析构必 virtual。delete 的析构顺序是从子到父,与构造镜像。

② 左值、右值与移动语义

定义:左值 = 有身份(identity)的表达式——有名字、可取地址、可持续存在;右值 = 临时的、将亡的表达式——字面量、临时对象、std::move(a)、返回值。

Q:std::move(x) 到底做了什么?之后 x 还能用吗?

A:什么都没搬。std::move(x) 等价于 static_cast<T&&>(x),只是给表达式贴上"我是右值"的标签,零开销,x 的内存此刻毫无变化。真正搬家的是之后被选中的移动构造函数/移动赋值。move 之后 x 处于"有效但未指定"状态:不能假设它的值,但可以重新赋值接着用。
Q:参数 void f(std::string&& s) 里,s 是左值还是右值?想把 s 的资源转出去该怎么写?

A:类型是右值引用 ≠ 它是右值。有名字的右值引用是左值——右值引用只是"我准备好了被搬"的入场券,进门之后它是有名字的活人,想用就用。所以 g(s) 走拷贝;想转资源必须 g(std::move(s)),每层传递都要自己重新贴标签。
注意:g(&s) 是错误答案——传地址只是告诉对方"对象在哪",属于借用,不转移所有权;而且类型对不上(string* 不能传给 string 按值参数)。
Q:std::string d = std::string("hi"); 调用了什么构造?

A:C++17 起这是保证的拷贝消除:临时对象直接构造在 d 的内存里,拷贝和移动都不调用,只调一次普通构造函数。C++17 之前理论上会调移动构造。类比:商家不再把货寄到中转仓再转寄,而是直接寄给买家,仓库环节整个砍掉。

输出题(构造列表 + 左值陷阱):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A {
public:
A() { std::cout << "a"; }
A(const A&) { std::cout << "b"; }
A(A&&) { std::cout << "c"; }
};

class B : public A {
public:
B() : A() { std::cout << "1"; }
B(const B& o): A(o) { std::cout << "2"; }
B(B&& o) : A(o) { std::cout << "3"; } // 注意:A(o) 传的是左值
};

B b1; // 输出 a1(先父后子)
B b2 = b1; // 输出 b2(拷贝构造)
B b3(std::move(b1)); // 输出 b3 ← 陷阱!
Q:为什么 B(B&& o) : A(o) 里 A 部分走的是拷贝构造(b)而不是移动构造(c)?

A:初始化列表本质是一串函数调用,A(o) 就是用表达式 o 去调用 A 的构造函数做重载决议。o 有名字、是左值表达式 → A(A&&) 绑不上左值直接出局 → 只剩 A(const A&) 能接(B 隐式转 A),于是拷贝构造胜出。想走移动必须显式写 A(std::move(o)),输出才是 c3。
语言为什么这样设计:若右值引用参数自动是右值,移动构造里连用一下参数都做不到。安全且显式。
拷贝消除之外的补充
对 int、指针这类没有资源可抢的类型,move 退化成一个拷贝——标签贴了,但移动构造本质上就是拷贝内容。是否展开、是否消除,均由编译器按优化等级自行判断。

③ new/delete、malloc/free 与野指针

核心对比:

new / delete malloc / free
身份 C++ 运算符(可重载) C 标准库函数
返回值 带类型的指针 void*,需手动强转
构造/析构 自动调用构造函数,delete 自动调析构 只搬内存,不碰构造析构
大小 编译器按类型自动算 手动传 sizeof(T)
失败 抛 std::bad_alloc 返回 NULL

必须同族配对:new↔delete、new[]↔delete[]、malloc↔free。混用是未定义行为。

Q:free 释放 new 出来的对象会怎样?反过来 malloc + delete 呢?

A:new + free:对象占的堆内存被还了,但析构函数从未执行——构造函数里申请的子资源(内部 new 的内存、句柄)永久泄漏。malloc + delete 更糟:delete 会对一个从未构造过的对象调用析构,对象成员全是随机垃圾,拿着随机地址去 free——崩溃/堆损坏,未定义行为。一句话:new+free 是"忘了打扫",malloc+delete 是"对着毛坯房拆装修"。

malloc 三部曲:

  1. 查空闲账本:free 时小块不还 OS,登记进空闲链表(分箱 bins);申请时按大小查账本,有则拆分返回,顺带合并相邻碎片——全程无系统调用。
  2. 账本没有 → 向 OS 进货:小块走 brk 把堆顶推高,大块(默认 128KB 以上)走 mmap 在映射区开一块,切出所需返回。
  3. OS 也不给 → 才返回 NULL。
Q:为什么 p = realloc(p, newSize) 是危险写法?

A:两个坑。坑一:realloc 失败返回 NULL,直接赋值会让 p 变空,旧块地址丢失——旧数据既用不了也释放不了,泄漏。坑二:成功"搬家扩"时旧块已被释放,其他还存着旧地址的指针全部变野指针。正确姿势:临时指针接收 → 判 NULL → 成功才替换。realloc 的三种结果:原地扩(后面空间够)、新地址(搬家,旧块释放)、NULL(申请失败)。

进程内存布局(低 → 高):

1
2
3
4
5
6
7
8
9
10
11
低地址
┌ .text 代码段(机器指令,虚函数代码在这)
├ .rodata 只读数据(常量字符串、虚函数表)
├ .data 已初始化全局/静态变量
├ .bss 未初始化全局/静态变量
├ 堆 ↑ 向高地址增长(new/malloc,小块走 brk)
│ (大片空隙)
├ mmap 区 动态库 .so、malloc 大块(≥128KB)
├ 栈 ↓ 向低地址增长(局部变量、函数参数)
└ 内核空间(用户程序不可直接访问)
高地址

栈向下、堆向上,相向而长,为彼此腾出空间。栈空间有限(默认约 8MB),但栈帧分配只是挪动栈指针,无系统调用,极快。

⚠️ 易混淆点:野指针 vs 内存泄漏
野指针:指针还在,指向的内存没了(危险,解引用即未定义行为)。内存泄漏:内存还在,指向它的指针没了(不崩,但越用越占内存)。
野指针四大来源:释放后未置空、函数返回局部变量地址、越界、多线程中一个线程释放另一个线程还在用。对应防护:释放即置 nullptr、不返回栈地址、智能指针、线程同步。

④ 智能指针

shared_ptr 结构:对象指针 + 控制块指针(引用计数、弱引用计数、删除器)。多线程同时拷贝/销毁各自的 shared_ptr 是安全的——计数器是原子类型,加 1 减 1 不可分割。

Q:"引用计数线程安全,但对象不是"两句话分别是什么意思?

A:第一句:多个线程各自拷贝/销毁自己那份 shared_ptr,计数不会出错,由原子操作保证。第二句:p-> 解引用之后就是普通对象,两个线程同时 p->push_back() 照样数据竞争——shared_ptr 只保证"几个人在用"的计数正确,"几个人同时读写"是否安全一概不负责。

循环引用与 weak_ptr:A、B 互持 shared_ptr 时,双方强计数互相顶住(外部释放后各自剩 1),谁都到不了 0,双双泄漏。weak_ptr 只加弱计数、不动强计数,A 的生死不再依赖 B,死结解开。

规则:强计数归零 → 对象立即析构(与弱计数无关);弱计数归零 → 控制块才释放(weak_ptr 还要靠它查询状态)。

1
2
3
4
5
6
7
std::weak_ptr<A> pa;
std::shared_ptr<A> tmp = pa.lock(); // 升级:强计数 +1,使用期间对象不死
if (tmp) {
tmp->foo(); // A 还活着
} else {
// A 已死,lock 返回空 shared_ptr,走兜底逻辑
}

weak_ptr 不能直接解引用,必须先 lock() 升级成 shared_ptr 再用——升级同时给对象上了”使用期间不死”的保险。


⑤ struct 与 class

C++ 中两者本质是同一个东西,只有两点语法差别:

struct class
默认成员访问 public private
默认继承方式 public private

习惯上 struct 表达纯数据聚合(POD),class 表达有行为、有封装的对象——这是风格约定,不是能力差异。多态、虚函数、模板,两者完全等价。

补充:template 里的 class 与 typename
template <class T> 与 template <typename T> 完全等价,历史遗留的两个关键字。

⑥ delete this

合法,本质是 delete 一个普通指针——对象自己销毁自己,常用于引用计数归零、连接关闭等”无人认领”的时刻。

三条铁律:

  1. 只能用于 new/malloc 出来的堆对象;栈对象 delete this 是未定义行为。常配合私有化构造函数 + 静态工厂方法(create)强制堆分配。
  2. delete this 之后,严禁再访问任何成员、调用任何成员函数——this 已成悬垂指针,函数里剩下的唯一合法语句是 return。
  3. 调用方此后不得再使用该指针。

类比:安乐死——合法执行,但执行后不存在”再去做什么事”,只能结束。


⑦ 内存对齐与缓存行

对齐的本质:CPU 通过内存总线按块取数(64 位机器一块 8 字节),且 L1/L2/L3 缓存与内存之间以缓存行(64 字节)为最小单位搬运——读 1 字节,整条 64 字节一起进缓存。

  • 数据地址是其类型大小的整数倍 → 恰好落在同一块里,一次取全;
  • 错位(跨两块)→ 两次传输再拼接,慢一倍;老架构直接硬件异常。

malloc 一律返回 16 字节对齐的地址:它不知道调用方会存什么类型,按最严标准保证安全。结构体中每个成员按自身对齐要求摆放,不足填充(padding),所以 sizeof 可能大于成员之和——大成员放前面可少浪费尾部填充。

成员地址 = this 的值 + 成员偏移

64 字节对齐与伪共享:两个线程分别写的变量若挤在同一条缓存行里,缓存一致性会让整条行互相失效——两核抢一行,性能暴跌。这就是伪共享。解法是让热点数据独占一行:

1
struct alignas(64) Data { int a; };   // 一人一行,互不干扰

例 1 · 防伪共享:两个线程高频写的计数器,各自 alignas(64) 分开放置。
例 2 · 配合 SIMD:向量计算要求 16/32/64 字节对齐的加载指令。


⑧ 进程、线程与同步

进程 vs 线程:进程是资源分配的最小单位,线程是 CPU 调度的最小单位。线程共享进程地址空间(堆、全局变量、文件句柄),独享栈和寄存器上下文;线程切换不换页表、不刷 TLB,成本低得多。

同步手段全家桶:

手段 一句话 适用
mutex 独占,同时只允许一个线程 临界区、写操作
信号量 计数器,允许 N 个 连接池限流
读写锁 读共享、写独占 读多写少的面板数据
条件变量 不满足条件就睡,等通知唤醒 生产者消费者(配 mutex)
原子操作/CAS 无锁,硬件保证不可分割 计数器、无锁队列
Q:普通 int 做引用计数为什么线程不安全?

A:count++ 实际执行三步:从内存读到寄存器 → 寄存器加 1 → 写回内存。两个线程穿插执行会读到同一个旧值、各自写回同一个新值——加两次只加了一(丢失更新)。shared_ptr 必须用 std::atomic<int>:硬件把三步锁成一步(x86 上是 lock xadd),别人插不进来。
自旋锁 vs mutex
拿不到锁时:自旋锁原地空转反复检查(while 循环烧 CPU,无系统调用);mutex 睡过去让出 CPU,等唤醒(睡觉+唤醒开销大,等待期不占 CPU)。临界区极短 + 多核 + 等待时间可预期很短 → 自旋锁;否则默认 mutex。自旋锁的典型实现就是 while (flag.test_and_set()) {}——像陀螺一样原地打转。

lock-free 的概念澄清:它不是”代码里没有锁”,而是一条性质——任何线程停摆,系统整体仍有线程在推进。基于锁的代码里,持锁线程被挂起会拖死所有等待者;CAS 循环没有”谁锁谁”的关系,被挂起的线程只是自己慢点,别人照样成功。更强的 wait-free 保证每个线程有限步内完成(lock-free 只保证”有人能走”,wait-free 保证”人人都能走”)。is_lock_free() 问的是”原子操作在硬件上是否真无锁”,与算法层面是两个问题。代价:CAS 反复失败烧 CPU、ABA 问题、内存序复杂——工程默认用锁,只在剖析证明的热点处上无锁。

线程安全单例(标准答案):

1
2
3
4
5
6
7
8
9
10
11
class Singleton {
public:
static Singleton& get() {
static Singleton instance; // Magic Statics:标准保证初始化线程安全
return instance;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() {}
};

C++11 起标准保证:函数内 static 局部变量只初始化一次,多线程同时首次调用时只有一个线程执行初始化,其余自动等待,之后零开销——懒加载、线程安全、无 DCL 指令重排坑,一个写法全部解决。老式懒汉双检锁(DCL)需要处理”分配内存 → 构造 → 写指针”被重排成半成品的问题,要靠内存屏障,易写错,已无必要。饿汉式在程序启动(单线程阶段)即创建,天然无线程安全问题,但启动就付账,可能没人用。


⑨ 编译的四个阶段

阶段 干什么 产物
预处理 #include 展开、#define 替换、#ifdef 裁剪 编译单元(臃肿的纯代码)
编译 语法/类型检查,翻译成汇编 .s 汇编文件
汇编 汇编文本 → 机器码 .o 目标文件(外部符号地址空缺)
链接 符号解析 + 重定位,拼成可执行文件 .exe / a.out

记忆:预处理摊大饼 → 编译变汇编 → 汇编变机器码 → 链接拼全家。类比流水线:备料(拆包缩写)→ 翻译操作手册 → 转成机器人指令(电话号码还空着)→ 汇总装订总手册并查号填电话。


⑩ 静态链接与动态链接

静态链接 动态链接
库代码何时进程序 链接时拷进 exe 运行时由 OS 加载 dll/so
exe 体积 大 小
外部依赖 无(自带) 目标机器需有对应库,缺了报”缺少 xxx.dll”
多程序共享 不能,各拷一份 能,内存里一份大家用
库更新 全部重新链接 换库文件即可

类比:静态是出书和引用文字直接抄进自己书里(单看就够,但永远用旧版);动态是写”详见《XX 手册》第 3 页”(书薄了、手册出新版自动用新版,但读者手里必须有那本手册)。


⑪ static 的四种用法

位置 效果
函数内局部变量 只初始化一次,生命周期延长到程序结束
全局变量/普通函数 限制在本 .cpp 内,其他文件 extern 也链接不到
类成员变量 全类共享一份,不属于任何对象;类内声明类外定义(C++17 可 inline static 一步到位)
类成员函数 没有 this,不能访问非静态成员,可用类名直接调用

文件级 static 是”本文件的私人物品”;类级 static 是”类本身的财产”。


⑫ inline、深拷贝与移动赋值

Q:inline 是性能优化关键字吗?

A:本义是建议编译器就地展开、省掉压栈/跳转开销,但这只是建议——小的简单函数基本总会展开,大的、递归的、虚函数写了也不展开(是否展开由编译器按优化等级自行判断)。它今天真正的主业是让函数定义可以安全写进头文件:普通函数定义写进头文件会被多个 .cpp 包含,链接时报多重定义;inline 函数被特许"多份定义视为同一个"。类内定义的成员函数、模板函数天然带 inline 属性。
注意与 #pragma once 分工不同:pragma once / include guards 管"同一个 .cpp 里头文件别被粘贴两次"(编译期),inline 管"多个 .o 里的机器码算一份"(链接期)。

深拷贝与 Rule of Three/Five:类里有裸指针时,编译器默认生成的拷贝构造是浅拷贝——两个对象的指针指向同一块内存,析构时重复释放,崩溃。深拷贝就是自己写拷贝构造:申请新内存 + 拷内容。

深拷贝/移动赋值/移动构造/析构为什么是一个套餐?

A:需要手写析构 = 类直接管理资源 = 默认拷贝(浅拷贝)必然出错。只写一部分的话,没写的那些编译器会自动生成默认版本,而默认版本全是浅拷贝——只修"释放"环节,"复制"环节还是坏的。所以析构、拷贝构造、拷贝赋值必须一起写(Rule of Three),C++11 起加移动构造、移动赋值(Rule of Five)。现代捷径:用智能指针包装成员,让类不直接碰裸指针,法则自动满足。

移动赋值三步走:① 释放自己原有的资源(否则旧资源泄漏)② 接管对方的资源 ③ 把对方置空(对方析构时不重复释放)。外加自赋值检查 if (this == &other) return *this;——否则 a = std::move(a) 会先删掉自己要接管的资源。移动构造没有"旧资源可放",所以只有接管、置空两步。

三、易混淆点对比汇总

易混对 关键区别
野指针 vs 内存泄漏 指针在、内存没了(会崩) vs 内存在、指针没了(悄悄涨)
std::move vs 移动构造 只贴右值标签、零开销 vs 真正搬资源的人
左值 vs 右值引用类型 表达式有无名字:有名字的右值引用是左值
指针赋值 vs 值拷贝 拷地址(还是原对象) vs 造新对象(vptr 由自己的构造写)
普通成员函数 vs 虚函数 编译期定址 vs 第一步必读 vptr(nullptr 必崩)
new/malloc 混用 忘了调析构(漏扫) vs 对未构造对象调析构(拆毛坯房)
信号量 vs mutex 计数 N 个(限流) vs 二元独占(互斥)
lock-free vs wait-free 整体总有进展 vs 每个线程有限步完成
lock-free 概念 vs is_lock_free() 算法性质 vs 该原子操作硬件上是否真无锁
饿汉 vs 懒汉单例 启动即建(天然安全) vs 首次调用才建(需防并发创建)
重复包含 vs 多重定义 #pragma once 管(编译期) vs inline/定义放 .cpp 管(链接期)
浅拷贝 vs 深拷贝 拷指针(共享内存) vs 拷内容(各有其块)

四、流程图

图 1 · malloc 分配决策流

malloc(size) 入口
↓
① 查空闲链表/分箱 bins
↓
有合适块?→ 拆分返回(顺带合并相邻碎片)
↓ 没有
② 按阈值分流(默认 128KB,可配置)
↓
小块 → brk 推高堆顶   |   大块 → mmap 映射区开块
↓
切出所需大小返回
↓ OS 拒绝
返回 NULL

蓝 = 固定环节,绿 = 运行时分支,黄 = 可配置阈值

图 2 · 编译流水线

.cpp 源码
→
预处理
#include/#define/#ifdef
→
编译
语法类型检查
→
汇编
.s → 机器码
→
.o 目标文件
→
链接
符号解析 + 重定位
(静态/动态两种模式)
→
可执行文件

图 3 · 构造/析构期间 vptr 切换

进入 Base()
vptr = Base 虚表
→
进入 Derived()
vptr 改写为 Derived 虚表
⇄
析构:~Derived 体执行完
vptr 切回 Base 虚表
→
~Base()
对象内存归还

推论:构造/析构中调虚函数 → 永远调到本类版本(无多态)


五、面试速记

  1. 指针赋值拷的是地址,值拷贝造的是新对象;新对象的 vptr 由构造它的构造函数决定,与数据从哪拷来无关。
  2. move 只贴标签,搬家靠构造;有名字的右值引用是左值,每层传递资源要自己重新 move。
  3. 普通成员函数是编译器指路,虚函数是对象自己找路——nullptr 没有路标。
  4. 有虚函数的类,析构必 virtual。
  5. new/delete、new[]/delete[]、malloc/free 必须同族配对;混用是未定义行为。
  6. 野指针是”指针在、内存没了”,泄漏是”内存在、指针没了”。
  7. shared_ptr 保证计数正确,不保证对象读写安全。
  8. malloc 三部曲:查账本 → 向 OS 进货 → 才允许失败。
  9. 需要析构函数 = 需要手写全部拷贝家族(Rule of Three/Five),或者用智能指针让法则自动满足。
  10. C++11 之后,线程安全单例 = 一个函数内 static 变量。
  11. inline 的本义是建议展开,主业是解决头文件多重定义。
  12. 临界区短到比”睡觉+唤醒”还便宜才用自旋锁,否则用 mutex。

整理方式:以”提问 → 纠错 → 追问”的复习对话为底稿,按考点编号归档;所有代码均经对话验证。

渲染流水线:从三维场景到屏幕像素的完整旅程——《Unity Shader 入门精要》第 2 章学习笔记

速览卡片

概念 一句话说明
渲染流水线 从三维场景出发生成一张二维图像,CPU 和 GPU 共同完成
应用阶段 CPU 主导:准备数据 → 设置渲染状态 → 提交 Draw Call,输出渲染图元
几何阶段 GPU 主导:顶点着色器(MVP 变换)→ 裁剪 → 屏幕映射
光栅化阶段 GPU 主导:三角形遍历生成片元 → 片元着色器 → 测试与混合
渲染图元 GPU 唯一能直接处理的几何单位:点、线、三角形
Draw Call CPU 发给 GPU 的命令,只指向图元列表,不含材质信息
齐次裁剪空间 顶点着色器的输出空间,四维坐标 (x,y,z,w),w≠1
屏幕映射 NDC [-1,1]³ → 屏幕像素坐标,z 不处理
片元 光栅化生成的”候选像素”,插值顶点属性的状态集合,≠ 像素
逐片元操作 模板测试 → 深度测试 → 混合,高度可配置但不可编程
Early-Z 深度测试提前到片元着色器之前,与透明度测试冲突
双重缓冲 渲染在后置缓冲,完成后交换前置缓冲,画面连续
命令缓冲区 CPU 添命令、GPU 取命令,二者并行工作的关键
批处理 减少 Draw Call:合并网格,适合静态物体

2.1 综述

洋娃娃工厂例子:流水线各工序并行,产量取决于最慢的工序(bottleneck)。

渲染流程分三个概念阶段:应用阶段、几何阶段、光栅化阶段。划分依据是谁拥有控制权——应用阶段 CPU 控制,后两个阶段 GPU 接管;各阶段之间流水线并行,”应用阶段”在整个帧的时间内持续发生。

Q:图元和片元有什么区别?

A:图元 = GPU 能直接处理的基本几何单位(点/线/三角形,Mesh 和四边形必须拆成这三种);片元 = 光栅化阶段对被覆盖像素生成的状态集合(插值得到的法线、UV、深度等)。链条:CPU 把 Mesh 拆成图元 → GPU 把图元变片元 → 片元过测试变像素。

2.2 CPU 和 GPU 之间的通信

应用阶段三个任务:准备场景数据(摄像机、视锥体、模型、光源);粗粒度剔除(culling);设置渲染状态。输出是渲染图元。

数据流向:硬盘 → 内存(全部渲染数据)→ 显存(GPU 要用的子集)。显卡对显存访问更快、且大多数显卡不能直接访问 RAM;数据进显存后 RAM 副本通常可删,但 CPU 还要做碰撞检测/动画的数据要保留。

Q:内存中的渲染数据 和 GPU 所需的数据相等吗?

A:不相等,是"全集 ⊃ 子集"的关系。第一步是全集(几何、纹理、Shader、材质参数全进 RAM);第二步是子集——只有 GPU 真正消费的数据进显存:顶点/索引(顶点着色器用)、纹理(片元着色器采样)、参数(MVP、光照等)。碰撞体、骨骼、CPU 剔除用的包围盒留在 RAM。判断标准:这数据接下来被 CPU 用还是被 GPU 用。

渲染状态定义网格怎么被渲染:用哪个顶点着色器/片元着色器、光源属性、材质等。不改状态,所有网格用同一种状态渲染。

Draw Call 就是一个命令,发起方是 CPU,接收方是 GPU,只指向图元列表,材质信息上一步已设好。

2.3 GPU 流水线

先给出书中图 2.6 的完整流程,后文小节按此流程逐站展开:

图 2.6 GPU 流水线(几何阶段 → 光栅化阶段)
顶点数据 → 顶点着色器 → 曲面细分
着色器
→ 几何
着色器
→ 裁剪 → 屏幕映射 → 三角形
设置
→ 三角形
遍历
→ 片元着色器 → 逐片元
操作
→ 屏幕图像
绿 可编程  黄 可配置不可编程  蓝 固定实现
实线框 = 必需  虚线框 = 可选(细分、几何着色器)

2.3.2 顶点着色器

顶点着色器(Vertex Shader):每顶点调用一次;不能创建/销毁顶点,无法得知顶点间关系,因此可高速并行。两个主要工作:坐标变换和逐顶点光照。

1
o.pos = mul(UNITY_MVP, v.position);  // 模型空间 → 齐次裁剪空间

MVP = 模型变换(模型空间→世界空间,即 Transform 组件)、V 观察变换(世界→相机空间,相机变换的逆)、P 投影变换(相机→齐次裁剪空间)。输出仍是四维、w≠1,”压扁”发生在后面的透视除法。

Q:顶点着色器做完 MVP 变换,不就得到最终位置了吗?

A:顶点着色器输出只是齐次裁剪空间坐标,不含屏幕分辨率信息,算不出像素位置。之后链路:裁剪(在齐次裁剪空间,裁剪面 x=±w 等六个平面)→ 透视除法(xyz/w → NDC [-1,1]³,固定硬件自动做)→ 屏幕映射。
Q:顶点着色器里"计算顶点颜色"是干什么用的?顶点着色器都能输出哪些数据?

A:"顶点颜色"只是举例——输出统称 varying,"易变"指会被光栅化器插值。顶点着色器可输出颜色、UV、法线、切线等。现代管线颜色多在片元着色器算(纹理×光照),顶点色通道常被挪用:蒙皮权重、动画参数、遮罩。历史原因:固定管线时代属性槽位定死(位置/法线/颜色/UV),颜色槽常空着就借用了;现在可自定义顶点属性(mesh.uv2~uv8),但复用颜色通道零成本、工具链成熟,仍常见。片元颜色 = 顶点属性按重心坐标加权插值的产物。

2.3.3 裁剪

裁剪(Clipping)处理三种关系:完全在内直接传递、完全在外剔除、部分在内则在边界处生成新顶点(位于图元与视野边界的交点)。不可编程,可配置(自定义裁剪平面)。

⚠️ 易混淆点
"裁剪"有两个:CPU 在应用阶段做视锥体剔除(物体级,被剔除的不发 Draw Call)和 GPU 在几何阶段做图元裁剪(部分在内会生成新顶点)。一前一后、一个 CPU 一个 GPU。

2.3.4 屏幕映射

屏幕映射(Screen Mapping):x、y 从 [-1,1] 缩放到窗口分辨率(如 1080×1920)。z 不处理,与屏幕坐标组成窗口坐标系传给光栅化。

坑:OpenGL 以左下角为 (0,0),DirectX 以左上角为 (0,0)——图像倒转常因于此。

⚠️ 易混淆点
屏幕映射只负责 NDC → 屏幕像素坐标。"归一化到 [-1,1]³"是独立的透视除法(裁剪后、屏幕映射前,固定硬件自动完成),不在任何可编程阶段。

2.3.5 & 2.3.6 三角形设置与遍历

进入光栅化概念阶段。三角形设置(Triangle Setup)计算光栅化所需信息(边界表示方式);三角形遍历(Triangle Traversal)判断覆盖哪些像素,并对覆盖区域插值顶点信息(图 2.12 的深度插值例子)。都是固定函数。

重心坐标:插值的权重

定义:三角形 ABC 内一点 P,存在唯一一组 (λ_A, λ_B, λ_C) 满足:
P = λ_A·A + λ_B·B + λ_C·C  (λ_A + λ_B + λ_C = 1)
几何意义是面积比:λ 的下标是谁,分子就是"把谁换成 P"的子三角形面积:
λ_A = S(BCP) / S(ABC)
即对边与 P 围成的小三角形占整个三角形的比例。P 越靠近某顶点,对应 λ 越大;落在对边上则该 λ = 0。

快速计算:2D 叉积 = 2×2 行列式 = 带符号平行四边形面积。分母 cross(B−A, C−A) 算一次,分子各算一次,第三个用 1 减出来。三个 λ 全非负 ⇔ P 在三角形内——覆盖测试与插值权重是同一组计算;硬件用等价的边函数沿扫描线递推,所以快。

任意属性都走同一公式:
属性|P = λ_A·属性_A + λ_B·属性_B + λ_C·属性_C
法线、UV"凭空出现在片元函数参数里",就是在这站搭的便车。

2.3.7 片元着色器

片元着色器(Fragment Shader)实现逐片元(Per-Fragment)着色,完全可编程。最重要技术:纹理采样(UV 是插值来的)。DirectX 里叫像素着色器,但”片元”更准确(此时还不是像素)。

片元着色器"知道"什么

输入 = 插值顶点属性 + 自己的屏幕坐标(SV_POSITION),不知道自己在哪个三角形——无三角形 ID、无索引,每次调用互相独立。定位自己靠:屏幕坐标(像素位置效果);隐式屏幕空间导数(ddx/ddy,GPU 以 2×2 quad 执行可得,mipmap 选级靠它,cross(ddx,ddy) 可重建面法线——书上说的"唯一例外")。理解"信息孤岛"就通了:为什么做不了"到三角形顶点的距离"、为什么透视校正插值重要。

2.3.8 逐片元操作

逐片元操作(Per-Fragment Operations):如修改颜色、深度缓冲、进行混合等,不可编程但可配置性很高。流程:片元 → 模板测试 → 深度测试 → 混合 → 颜色缓冲区。

Q:图 2.15 里模板测试、深度测试那一串到底在干什么?

A:模板测试 = 绘画软件里的剪贴蒙版:上层内容只显示在下层有内容的区域,涂出界的部分自动被裁掉。模板测试就是把这套逻辑搬到每个像素上。

机制:每像素一个模板缓冲区(整数 0~255),相当于每格的"蒙版形状";片元带着参考值来"对蒙版",条件满足 → 内容显示(继续走深度测试、写颜色);不满足 → 被裁掉。

经典配合(两次绘制):第一遍画 A,把 A 的区域"定义为蒙版"(模板=1);第二遍画 B,按条件被这个蒙版裁剪——B 画不进 A 的区域。

例 1 · 描边:剪贴蒙版的反向用法。先正常画角色 = 定义蒙版(模板=1);再画放大 3% 的红色角色,条件设为"模板==1 就丢弃"——等于把红色内容从角色轮廓里裁掉,只剩轮廓外多出来的一圈红边。

例 2 · 镜子:标准正向剪贴。先画镜面 = 铺出蒙版形状(模板=1);再画镜中世界,条件设为"只有模板==1 才许画"——镜像内容被剪贴进镜面的形状里,镜外的部分全部裁掉。

要点:① 模板缓冲全帧共享,用完要恢复状态;② 值是整数,可当计数器(阴影体:正面+1、背面−1,非零即阴影);③ 只能过滤内容,不能生产内容(内部线靠贴图/rim)。

深度测试 = Z-Buffer 算法:近者胜,胜者写颜色+深度。一句话分工:深度测试管前后遮挡(自动),模板测试管区域通行(程序员布局)。
Q:Blend 就是 α-blend 吗?

A:不是。硬件混合是通用公式:C_final = C_src ⊗ S + C_dst ⊗ D,S/D 可配置。α-blend 只是一种配置:S=SrcAlpha、D=OneMinusSrcAlpha(C_src·α + C_dst·(1−α))。其他:加法混合(S=D=One,直接相加,火焰/光效,越加越亮);不混合(直接覆盖)——不透明物体走这个,也是初学者开不出透明的原因。半透明物体必须从后往前画:混合对象是"颜色缓冲区已有的颜色",顺序错了就混无可混(渲染队列的存在原因);透明物体通常关深度写入,否则近的玻璃会挡掉后面的东西。

两个补充:

  • Early-Z 技术:测试逻辑上在片元着色器之后,但 GPU 会尽量提前(避免算完颜色才发现被舍弃)。与透明度测试(clip)冲突——着色器可能推翻提前测试的结果,GPU 检测到冲突就禁用提前测试,这就是透明度测试性能下降的原因。
  • 双重缓冲(Double Buffering):渲染在后置缓冲(Back Buffer),完成后与前置缓冲(Front Buffer)交换,画面连续。

2.4 容易困惑的地方

2.4.1 OpenGL/DirectX 与显卡驱动

OpenGL/DirectX 是图像应用编程接口,架起应用与 GPU 的桥梁:应用发命令 → 接口转给显卡驱动 → 驱动翻译成 GPU 语言 + 把纹理转成 GPU 支持的格式(驱动像”显卡的操作系统”)。

2.4.2 HLSL、GLSL、CG

HLSL(DirectX)、GLSL(OpenGL)、CG(NVIDIA),先编译成与机器无关的中间语言(IL),再由驱动翻译。GLSL 跨平台但编译依赖硬件厂商(结果可能不一致);HLSL 微软统一编译但几乎仅限微软平台;CG 语法近 HLSL、可无缝移植,但可能用不上 OpenGL 新特性。

2.4.3 Draw Call

误区:以为元凶是 GPU 状态切换。其实”拖后腿”的是 CPU。

为什么 Draw Call 多会卡?——命令缓冲区与批处理

CPU/GPU 靠命令缓冲区(Command Buffer)并行:CPU 添命令、GPU 取命令。每次 Draw Call 前 CPU 要做大量准备(检查渲染状态、打包数据),GPU 渲染极快(200 和 2000 个三角网格区别不大),提交速度跟不上渲染速度——Draw Call 太多,CPU 卡在提交里,GPU 空闲。类比:复制 1 万个 1KB 小文件远比复制一个 10MB 文件慢,开销在"动作"不在数据量。对策是批处理(Batching):CPU 内存中合并小网格一次画完。注意:① 合并耗 CPU,适合静态物体(动态物体每帧重合并反而亏);② 避免大量小网格、避免过多材质(材质不同合不了批)。

2.4.4 固定管线渲染

早期 GPU 只能配置不能编程;如今固定管线已退出历史舞台(OpenGL 3.2 Core Profile 完全移除),维护老项目才需要学。

2.5 什么是 Shader

一句话:Shader 就是 GPU 流水线上一些可高度编程的阶段(顶点/片元着色器必写,细分/几何着色器可选)。出色画面需所有阶段配合:渲染状态、混合函数、深度测试/写入的开关。

易混淆点对比汇总

易混淆对 一句话辨析
图元 vs 片元 图元 = GPU 直接处理的几何单位(点/线/三角形);片元 = 光栅化后覆盖像素的状态集合
CPU 视锥剔除 vs GPU 裁剪 前者应用阶段、物体级、不发 Draw Call;后者几何阶段、图元级、会生成新顶点
可编程 vs 可配置 可编程 = 写 Shader 代码;可配置 = 只能填参数(模板/深度/混合都是后者)
深度测试 vs 模板测试 深度管前后遮挡(近者胜,自动);模板管区域通行(挡板,程序员布局)
投影变换 vs 透视除法 投影矩阵只把深度编进 w;压扁成 NDC 的是之后的透视除法
屏幕映射的范围 只负责 NDC → 屏幕像素坐标;透视除法到 NDC 是独立一步
Blend vs α-blend Blend 是通用混合公式;α-blend 只是一种因子配置(还有加法混合、不混合)
模板测试 vs 深度学习 mask 同一思想:逐元素挡板、入口过滤
Gouraud vs Phong 着色 逐顶点光照+插值 vs 插值法线后逐片元光照(主流,避免大三角面明暗跳变)

Godot 4 P2P 联机基础 —— ENetMultiplayerPeer 与 RPC 注解

📇 速览卡片(3 分钟复习)

概念 一句话
ENet 基于 UDP 的可靠传输库,Godot 4 内置封装
ENetMultiplayerPeer 端对端(P2P)联机的核心实例,替代底层 Socket 管理
call_remote RPC 只发远端,本地不执行
call_local RPC 本地与远端同时执行
authority 仅该节点的网络权限所有者(通常是房主/服务器)可调用
any_peer 任意对等体均可调用
reliable 保证送达,适合状态变更、扣血
unreliable 不保证送达,适合高频位置同步
unreliable_ordered 不保证送达,但保证顺序,适合连续动画帧
通道 (Channel) 0~9 共 10 条,类似端口分流,可分别设带宽上限

📝 详细笔记

1. Host 与 ENetMultiplayerPeer

Godot 4 的 P2P 联机基于 ENet 库,底层走 UDP 协议。开发者不需要手动处理 Socket、NAT Punch-through 或数据包序列化,直接通过 ENetMultiplayerPeer 一个高层实例完成端对端管理。

1
2
3
4
5
6
7
var peer = ENetMultiplayerPeer.new()
# 创建主机(监听)
peer.create_server(port, max_clients)
# 或加入房间(客户端)
peer.create_client(address, port)

multiplayer.multiplayer_peer = peer

ENetMultiplayerPeer.new() 即为一个完整的端对端实例,封装了连接管理、心跳、丢包重传等细节。


2. @rpc 注解四参数

Godot 4 使用 @rpc(...) 标记远程调用函数,四个位置参数决定了网络行为的全部策略:

1
2
3
@rpc(A, B, C, D)
func my_remote_func():
pass

A —— 执行范围(本地 vs 远端)

取值 语义
call_remote 仅远端执行,本地不执行。适合”通知别人”
call_local 本地 + 远端全部执行。适合确定性逻辑,确保所有端状态一致

B —— 调用权限(谁能发起)

取值 语义
authority 仅该节点的网络权限所有者(默认是创建它的端,通常是 Host)可以调用。防作弊首选
any_peer 任意对等体均可调用。适合玩家输入、自由交互

C —— 传输可靠性

取值 语义 适用场景
reliable 保证送达,丢包会重传,可能延迟 状态变更、扣血、拾取道具、游戏开始/结束
unreliable 不保证送达,不重传,最低延迟 高频位置同步、旋转、速度
unreliable_ordered 不保证送达,但保证顺序 连续动画帧、弹道轨迹,允许丢帧但不允许乱序

D —— 通道编号 (Channel)

  • 取值范围:0 ~ 9,共 10 条独立通道
  • 作用类似端口分流:不同通道的数据互不阻塞,可分别设置带宽上限
  • 典型用法:
    • 通道 0:reliable 状态同步(低带宽、高优先级)
    • 通道 1:unreliable 位置同步(高频、可丢包)
    • 通道 2:聊天/语音(独立带宽限制)

💻 代码示例

主机创建与 RPC 调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
extends Node

@rpc("call_remote", "authority", "reliable", 0)
func start_game():
# 只有房主能调用,通知所有客户端游戏开始
get_tree().change_scene_to_file("res://Game.tscn")

@rpc("call_local", "any_peer", "unreliable", 1)
func update_position(pos: Vector2):
# 任意玩家都能发,本地也立即执行,高频位置同步
$Character.position = pos

func _ready():
var peer = ENetMultiplayerPeer.new()
peer.create_server(9999, 4)
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_peer_connected)

func _on_peer_connected(id: int):
print("玩家 ", id, " 已连接")
# 房主调用,所有客户端(包括本地)执行 start_game
start_game.rpc()

客户端加入

1
2
3
4
5
6
7
8
func join_game(ip: String, port: int):
var peer = ENetMultiplayerPeer.new()
peer.create_client(ip, port)
multiplayer.multiplayer_peer = peer
multiplayer.connected_to_server.connect(_on_connected)

func _on_connected():
print("已连接到服务器,本机 ID: ", multiplayer.get_unique_id())