31.总结
31.1 知识点

学习的主要内容

注意

强调


31.2 核心要点速览
数据结构存储概念回顾
顺序存储(Array, List)
- 元素紧密连续
- 查:O(1)
增/删(中间):O(n)
链式存储(LinkedList)
- 通过指针链接
- 增/删:O(1)(在已知节点位置)
- 查/改:O(n)(需遍历)
简单数据结构类
命名空间:using System.Collections;。以下均为 object 存储,存在装箱拆箱。
| 容器 | 底层结构 | 存取规则 | 常用操作 | 遍历方式 |
|---|---|---|---|---|
| ArrayList | 可变长 object[] |
任意索引增删改 | - 增:Add、AddRange、Insert- 删: Remove、RemoveAt、Clear- 查改:索引器 list[i] get/set、Contains、IndexOf、LastIndexOf |
- for (i=0…Count-1)- foreach- 查看容量: Count / Capacity |
| Stack | object[] 栈,LIFO |
只能顶端进出 | - Push(item)- Pop()(移除并返回顶)- Peek()(查看顶,不移除)- Contains、Clear、Count |
- foreach(从顶到底)- ToArray() + for- while (Pop()) |
| Queue | object[] 队列,FIFO |
只能队首进队首出 | - Enqueue(item)- Dequeue()(移除并返回队首)- Peek()(查看队首,不移除)- Contains、Clear、Count |
- foreach(从队首到队尾)- ToArray() + for- while (Dequeue()) |
| Hashtable | 键/值对散列表,object |
按键存取 | - Add(key,value)- Remove(key)、Clear- 索引器 ht[key] get/set(key 不存在返 null)- ContainsKey、ContainsValue |
- foreach (DictionaryEntry kv in hashtable)- foreach (var k in Keys) + ht[k]- IDictionaryEnumerator |
Tip: 如类型已知,优先使用泛型版本(
List<T>/Stack<T>/Queue<T>/Dictionary<TKey,TValue>),以避免装箱拆箱并增强类型安全。
泛型
泛型相关分类对比
| 分类 | 语法示例 | 说明 |
|---|---|---|
| 泛型类 | class MyList<T> |
在声明时使用类型占位符T,使用时指定具体类型。 |
| 泛型接口 | interface IRepository<T> |
与泛型类类似,可定义泛型属性/方法。 |
| 泛型方法 | void Swap<T>(ref T a, ref T b) |
方法级别的类型占位,可与泛型类并存;占位符可多个。 |
泛型约束(where)
| 约束符 | 形式 | 含义 | 例子 |
|---|---|---|---|
struct |
where T : struct |
T 必须是值类型(包括所有结构体) | Test1<T> where T: struct |
class |
where T : class |
T 必须是引用类型 | Test2<T> where T: class |
new() |
where T : new() |
T 必须有公共无参构造函数 | Test3<T> where T: new() |
| 类名 | where T : BaseType |
T 必须是指定类或其派生类 | Test4<T> where T: Animal |
| 接口名 | where T : IFly |
T 必须实现指定接口 | Test5<T> where T: IFly |
| 泛型参数 | where T : U |
T 必须是另一个泛型参数 U 或其派生类 | Test6<T,U> where T: U |
组合示例
class Manager<T, K> where T : class, new() where K : struct { }注意:
class须写在new()前,否则报错;struct与new()不可组合(struct 自带无参构造)。
何时用泛型
- 代码复用:同一数据结构/算法,可用于多种类型。
- 类型安全:避免向
object容器中装箱拆箱。 - 性能优化:减少运行时类型转换和垃圾产生。
常用泛型数据结构
| 容器 | 本质 | 常用操作 | 查 | 遍历方式 |
|---|---|---|---|---|
| List<T> | 可变长度的泛型数组 | 增:Add、AddRange、Insert删: Remove、RemoveAt、Clear改: list[i] = x |
索引器 list[i]ContainsIndexOf/LastIndexOf |
for(int i=0;i<Count;i++)foreach(var x in list) |
| Dictionary<K,V> | 泛型的键/值对哈希表 | 增:Add(key,value)删: Remove(key)、Clear改: dict[key] = v |
索引器 dict[key](key 不存在抛异常,Hashtable 返 null)TryGetValue/ContainsKey 避免 |
foreach(var kv in dict)foreach(var k in Keys) |
| LinkedList<T> | 泛型的双向链表 | 增:AddFirst、AddLast、AddBefore、AddAfter删: Remove、RemoveFirst、RemoveLast改: node.Value = x |
First/LastFind(item)Contains |
foreach(var x in ll)for(node=First; node!=null; node=node.Next) |
| Stack<T> | 泛型的后进先出栈(LIFO) | 增:Push(item)取: Pop()看: Peek()清: Clear() |
Contains(item) |
foreach(var x in stack)while(stack.Count>0){ var x=stack.Pop(); } |
| Queue<T> | 泛型的先进先出队列(FIFO) | 增:Enqueue(item)取: Dequeue()看: Peek()清: Clear() |
Contains(item) |
foreach(var x in queue)while(queue.Count>0){ var x=queue.Dequeue(); } |
容器选择:固定一组数据用数组;常按下标查、常增删用 List;常按 key 查用 Dictionary;先进后出用 Stack(如 UI 面板栈);先进先出用 Queue(如消息队列);常中间插入/删除、少按索引查用 LinkedList。
委托(Delegate)
概念
- 委托是“方法的容器”——将方法视为可存储、传递的第一类对象。
- 定义了一种签名(返回值类型 + 参数列表),只有与该签名完全匹配的方法才能被存入。
声明语法
// namespace 或 class 中都可声明 [public] delegate 返回类型 委托名(参数类型1 名称1, 参数类型2 名称2, …);- 如
public delegate void MyAction(int x, string y); - 与方法声明几乎相同,只是前置
delegate关键字。
- 如
绑定与调用
MyAction a1 = new MyAction(SomeMethod); MyAction a2 = SomeMethod; // 语法糖 a1(arg1, arg2); // 等价于 a1.Invoke(arg1, arg2);- 绑定时不带括号;调用时既可使用
Invoke也可直接像方法一样写a1()。
- 绑定时不带括号;调用时既可使用
多播功能
可以向一个委托变量中累加多个方法:
a += MethodA; a += MethodB;调用时会按添加顺序依次执行;用
-=移除首个匹配的方法。执行前要判空:
if (a != null) a();有返回值时多播委托只得到最后一项返回值;若需每一项的返回值,用
GetInvocationList()得到委托数组再逐项Invoke。
作为成员和参数
成员字段:用于类内部或外部的事件/回调存储。
public MyAction OnComplete;方法参数:允许调用者传入自己的逻辑到方法内部延后执行。
void DoSomething(MyAction callback) { … callback(); }
系统内置委托
- Action:无返回值,支持 0–16 个输入参数。
- **Func<…>**:有返回值,支持 0–16 个输入参数,最后一个泛型参数为返回类型。
使用场景
- 事件处理与回调机制(UI 事件、异步完成通知等)
- 策略模式或动态行为切换
- LINQ、异步编程及并行执行的基础
事件(Event)
概念
- 事件是“安全封装”的委托,只能在声明它的类/结构体/接口内部被触发或清空。
- 用于发布—订阅模式,防止外部随意重置或调用委托。
声明语法
[public] event 委托类型 事件名; // 例如: public event Action OnCompleted;与委托的异同
特性 委托(Delegate) 事件(Event) 赋值/清空 在任何地方可用 =只能在类内部可用 +=/-=触发/调用 在任何地方可直接 Invoke()只能在声明它的类内部触发(调用) 作为局部变量 可以 不可(编译器禁止) 主要用途 通用回调、函数指针模拟 发布—订阅、安全的回调管理 使用流程
在类中声明事件:
public event Action DataReady;外部订阅/退订:
obj.DataReady += Handler; // 注册处理器 obj.DataReady -= Handler; // 注销处理器内部触发:
if (DataReady != null) DataReady(); // 或 DataReady.Invoke();
匿名函数(Anonymous Function)
定义
没有名称的方法体,可直接内联声明。
形如:
delegate(参数列表) { // 方法体 };
何时使用
- 作为委托参数:简化回调传递,无需额外命名方法。
- 作为委托返回值:动态构建并返回方法逻辑。
- 典型场景:事件处理器、LINQ 查询、异步回调等。
基本语法变体
// 无参无返回 Action a = delegate() { /*…*/ }; // 有参无返回 Action<int, string> a2 = delegate(int x, string s) { /*…*/ }; // 无参有返回 Func<string> f = delegate() { return "hello"; }; // 直接传递给方法 obj.DoSomething(delegate(int x) { Console.WriteLine(x); });优点
- 内联:无需额外命名、减少样板代码。
- 临时性:回调逻辑就地定义,更具可读性。
缺点
- 无法单独移除:因为没有名称,
delegate { … }无法用-=精确移除单个匿名条目。 - 调试:栈跟踪里看不到方法名,仅显示“anonymous method”。
- 无法单独移除:因为没有名称,
最佳实践
- 小而精的回调逻辑适合匿名函数。
- 复杂或需多次移除的处理器,应使用具名方法并存到变量中,以便管理。
Lambda 表达式
定义:匿名函数的简洁写法,用
=>分隔参数和函数体。语法:
(参数列表) => { /* 函数体 */ };与匿名函数等价:
delegate(参数列表) { /* 函数体 */ };示例:
// 无参无返回 Action a = () => Console.WriteLine("Hello"); // 单参无返回(类型可省略) Action<int> a2 = x => Console.WriteLine(x); // 多参有返回 Func<int, int, int> add = (x, y) => x + y;使用场景:
- 传递给需要委托/事件的 API(如 LINQ、事件绑定)。
- 简化一次性短逻辑,无需额外命名。
优缺点:
- 优点:更简洁、易读,省去
delegate关键字和方法名。 - 缺点:与匿名函数一样,无法精确地从多播委托/事件中移除单个 lambda。
- 优点:更简洁、易读,省去
闭包(Closure)
概念:函数(或委托)连同其定义时所处环境(局部变量)一起“打包”,并保持对这些外部变量的访问能力,即使外层作用域已结束。
核心特点:
- 捕获的是变量本身的引用,而非当时的值。
- 外层局部变量的生命周期被延长,直到所有引用它的闭包都不再使用为止。
示例:
Func<int, int> Counter(int increment) { int count = 0; // 被捕获的外部变量 return number => { // 闭包 lambda count += increment; return number + count; }; } var c = Counter(5); Console.WriteLine(c(10)); // 15 (count从0变为5) Console.WriteLine(c(20)); // 25 (count从5变为10)for循环与闭包陷阱:for (int i = 0; i < 3; i++) { list.Add(() => Console.WriteLine(i)); } // 调用后,所有 lambda 都打印“3”……解法:在循环体内引入新的局部变量:
for (int i = 0; i < 3; i++) { int copy = i; list.Add(() => Console.WriteLine(copy)); // 捕获 copy 而非 i }注意事项:
- 被捕获的局部变量只释放于闭包不再引用它时。
- 过度使用闭包可能隐藏内存泄漏风险。
List.Sort
默认排序
- 方法:
list.Sort() - 要求元素类型:必须实现
IComparable<T>(或非泛型IComparable)。 - 行为:调用元素的
CompareTo,小于 0 放前面,等于 0 保持, 大于 0 放后面;默认升序。
- 方法:
自定义类型实现接口排序
接口:
IComparable<Item>实现:
public class Item : IComparable<Item> { public int money; public int CompareTo(Item other) => this.money.CompareTo(other.money); }调用:
var items = new List<Item>{...}; items.Sort(); // 调用 Item.CompareTo
传委托 Comparison
排序 方法重载:
list.Sort(Comparison<T> comparison)定义 Comparison 函数:
static int CompareShop(ShopItem a, ShopItem b) => a.id.CompareTo(b.id);调用:
shopList.Sort(CompareShop);匿名函数:
shopList.Sort(delegate(ShopItem a, ShopItem b) { return a.id - b.id; });Lambda 写法:
shopList.Sort((a,b) => a.id - b.id);
自定义排序要点
- 返回值:
<0→ 前,0→ 不变,>0→ 后。 - 升序 vs 降序:调换返回值符号(或参数顺序)可实现降序。
- 性能:
List<T>.Sort使用快速排序和插入排序的混合实现,平均 O(n log n)。 - 本系列还包含插入/希尔/归并/快速/堆排序的手写实现;应用层掌握
List.Sort+Comparison<T>即可。
- 返回值:
协变(Covariance) 和 逆变(Contravariance)
| 特性 | 关键字 | 修饰位置 | 用途限制 | 里氏替换示例 |
|---|---|---|---|---|
| 协变 | out |
泛型接口 / 泛型委托 | 只能用于返回值(不能作参数) | TestOut1<Father> f = sonProvider;( Func<Son> 可赋给 Func<Father>) |
| 逆变 | in |
泛型接口 / 泛型委托 | 只能用于参数(不能作返回值) | TestIn1<Son> s = fatherConsumer;( Action<Father> 可赋给 Action<Son>) |
语法示例
协变委托
public delegate T Producer<out T>(); Producer<Son> sonProducer = () => new Son(); Producer<Father> fatherProducer = sonProducer; // OK (Son → Father)逆变委托
public delegate void Consumer<in T>(T arg); Consumer<Father> fatherConsumer = f => { /*…*/ }; Consumer<Son> sonConsumer = fatherConsumer; // OK (Father → Son)
接口中使用
协变接口
interface IReadOnly<out T> { T GetItem(int index); // void Add(T item); // ❌ 不允许 }逆变接口
interface IWriteOnly<in T> { void Add(T item); // T GetItem(); // ❌ 不允许 }
适用范围
- 仅限:泛型 接口 和 泛型委托
- 不适用:泛型类、结构体等(会编译报错)
核心思想:用
out标记“生产者”→只输出;用in标记“消费者”→只输入,二者都借助里氏替换原则让“父⇄子”泛型能够安全转换。
多线程
理解进程 vs 线程
进程(Process):程序在操作系统中的一次运行实例,拥有独立的内存和资源。线程(Thread):进程内可并发执行的最小调度单位,共享所在进程的资源和内存。
启动新线程的关键步骤
声明循环控制标识(若线程内部需要持续运行/可停止):
static bool isRunning = true;封装线程逻辑方法(无参无返回值):
static void NewThreadLogic() { while (isRunning) { // … 执行周期性或耗时任务 … } }创建并启动线程(需
using System.Threading;):Thread t = new Thread(NewThreadLogic); t.IsBackground = true; // 设置为后台线程 t.Start(); // 启动结束线程
安全停止:在主线程中
isRunning = false;,让循环自然退出强制终止(.NET Core 不推荐/可能不支持):
t.Abort(); t = null;
线程控制与调度
Thread.Sleep(ms):让当前线程休眠指定毫秒。IsBackground:将线程标记为后台,主线程结束时后台线程随进程一同退出。
线程间共享数据与加锁
进程内所有线程共享同一内存,并发访问同一资源可能造成冲突。
锁(lock)用法:static object lockObj = new object(); … lock (lockObj) { // 仅允许一个线程进入此代码块 }场景示例:一个线程绘制红色圆,一个线程绘制黄色方块,通过
lock保证绘制命令的原子性,避免光标定位和颜色设置交叉干扰。
多线程的价值
- 将耗时操作(如网络请求、寻路、文件 I/O、复杂计算)放到子线程,避免阻塞主线程,保持界面或逻辑的流畅响应。
编译器与预处理器指令
编译器:将高级语言翻译为机器能识别的中间代码或目标语言(如 DLL 或 EXE)。
预处理器指令:以
#开头,用于编译前的判断与控制,不以分号结尾。常用指令:
#define/#undef:定义或取消符号。#if/#elif/#else/#endif:条件编译,控制不同代码块是否参与编译。#warning/#error:编译时发出警告或终止报错。#region/#endregion:代码折叠用,提升可读性。
用途:
- 实现跨平台或版本适配(如判断 Unity 版本)。
- 控制测试/调试代码是否参与编译。
- 提前在编译前排除无效路径,提高开发效率。
反射
程序集和元数据
- 程序集:编译后的代码集合(DLL/EXE),包含元数据、IL代码等。
- 元数据:程序中的结构信息(类、字段、方法等),用于描述程序自身。
什么是反射
- 反射:程序运行时动态查看、调用、修改自身或其他程序集的结构和行为(类型、构造、字段、方法等)。
- 意义:提升程序扩展性、灵活性,可用于插件、脚本系统、序列化、Unity编辑器拓展等。
反射相关核心类与用途
Type
- 获取类型信息:
typeof(ClassName)、obj.GetType()、Type.GetType("全限定名")(类名须含命名空间,否则找不到) - 获取成员:
GetFields()、GetMethods()、GetConstructors()、GetMembers()(需using System.Reflection) - 获取特定成员并操作:
GetField("name")、GetMethod("name")、使用SetValue/GetValue/Invoke
- 获取类型信息:
ConstructorInfo / FieldInfo / MethodInfo
- 反射构造函数并
Invoke()实例化对象 - 获取/设置字段值
- 调用方法
- 反射构造函数并
Activator
- 快速实例化对象:
Activator.CreateInstance(type, 参数...) - 不需要手动查找构造函数,更简洁
- 快速实例化对象:
Assembly
- 动态加载程序集(DLL):
Load()/LoadFrom()/LoadFile() - 通过程序集获取类型并反射使用其中的类
- 动态加载程序集(DLL):
使用反射的经典场景
- 动态插件加载
- 编辑器工具开发(如自定义 Inspector)
- 自定义序列化/反序列化
- 事件总线、属性注入(依赖注入)
注意事项
- 反射性能略低,不宜频繁使用
- 非公共成员默认无法访问,需指定
BindingFlags或使用私有访问策略 - 参数类型不匹配、类不存在等问题会导致运行时报错(非编译期)
特性
特性概念
- 本质是类,为程序结构添加元数据。
- 可修饰类、方法、字段等,配合反射读取使用。
自定义特性
- 继承自
Attribute。 - 命名惯例:
XXXAttribute(使用时可省略后缀)。 - 构造函数决定传参格式。
特性使用
- 语法:
[特性名(参数)] - 放在声明上一行,实质是构造函数调用。
反射读取特性
IsDefined():判断是否应用某特性。GetCustomAttributes():获取全部特性对象并强转使用。
限制使用范围
[AttributeUsage(AttributeTargets.XX | …, AllowMultiple = bool, Inherited = bool)]:指定可用位置、同一目标是否允许多个实例、是否被派生/重写继承。
常用系统特性
- Obsolete:标记过时成员,支持警告或错误提示。
- 调用者信息:
CallerFilePath/CallerLineNumber/CallerMemberName - Conditional:配合
#define控制方法是否编译。 - DllImport:绑定调用 C/C++ DLL 中的方法。
迭代器
迭代器作用
- 提供统一方式遍历集合,不暴露内部结构。
- 可用
foreach遍历的类型,都实现了迭代器模式。
实现标准迭代器(传统写法)
接口与命名空间:
IEnumerable、IEnumerator在using System.Collections;。实现接口:
IEnumerable(用于遍历) +IEnumerator(用于控制位置)。核心方法:
GetEnumerator():返回自身(IEnumerator);MoveNext():移动光标;Current:返回当前项;Reset():重置光标(避免 foreach 多次使用时无效)。
foreach 本质流程
- 调用对象的
GetEnumerator()。 - 不断调用
MoveNext()→ 若为 true 则读取Current。 - 否则结束循环。
yield return 语法糖实现
- 只需继承
IEnumerable,简化逻辑。 - 系统自动生成
IEnumerator实现。 - 每次
yield return会记录当前位置并在下次继续。
泛型迭代器实现(配合 yield return)
- 自定义类继承
IEnumerable。 yield return返回泛型元素,无需额外泛型接口。- 可用于任意类型集合的遍历。
迭代器总结
迭代器支持
foreach的根本。两种方式实现:
- 传统:实现
IEnumerator+IEnumerable。 - 简洁:使用
yield return+IEnumerable。
- 传统:实现
重点掌握 yield return,写法简单且易维护。
特殊写法
var隐式类型
var只能用于局部变量,且必须初始化。- 推导类型后不可更改;适合临时变量或简化代码。
对象初始化简写
- 使用
{}初始化对象的公共字段和属性。 - 可与构造函数同时使用,花括号内赋值优先级更高。
集合初始化简写
- 数组、List、Dictionary 可在声明时使用
{}初始化元素。 - List 可以包含自定义对象,支持嵌套初始化。
匿名类型
- 使用
var obj = new {}创建临时、无命名的类型。 - 只支持字段,不支持方法。
- 常用于局部临时数据封装。
可空类型 Nullable
值类型后加
?可为空(如int?),底层是Nullable<T>。常用属性方法:
HasValue:是否有值;Value:取值;GetValueOrDefault():空时返回默认值;?.:语法糖,判空后再操作。
空合并操作符 ??
- 表达式:
a ?? b - 若
a为 null,返回b;否则返回a。 - 可用于引用类型和可空值类型的默认值处理。
字符串内插
- 使用
$"{变量}"进行字符串拼接,更清晰简洁。
单句逻辑简写
if/for/while等语句中只有一句代码时可省略{}。支持属性、方法的简写:
get => value;方法名 => 逻辑;
值和引用知识补充
如何判断值类型与引用类型
- 使用
F12查看源码定义:struct是值类型,class是引用类型。
语句块与变量作用域
- 上层语句块:类、结构体;中层:函数;底层:条件分支/循环。
- 成员变量在上层;临时变量在中、底层。
- 代码逻辑主要写在函数、条件、循环中。
变量生命周期
- 成员变量长期存活;临时变量随语句块结束回收。
- 引用类型:栈上地址被释放,堆中数据变垃圾待 GC。
- 减少循环内重复创建临时变量有助于性能。
使用高层变量记录底层变量
- 用成员字段或静态变量记录函数内的中间值,避免被销毁。
结构体中的值类型与引用类型
- 结构体本身是值类型,存在栈中。
- 内部值类型存具体值;引用类型存地址,指向堆中对象。
类中的值类型与引用类型
- 类本身是引用类型,存在堆中。
- 值类型成员与类共存在堆;引用类型成员是指向另一个堆空间的地址。
数组存储规则
数组是引用类型,栈上存地址。
- 值类型数组:堆中存实际值。
- 引用类型数组:堆中存的是其他堆地址(默认值为 null)。
结构体继承接口(装箱/拆箱)
- 结构体实现接口会发生 装箱(复制到堆)。
- 接口变量互赋共享地址,修改其中一个影响另一个。
- 接口强转为结构体会发生 拆箱(复制回栈)。
- 装箱带来性能开销,结构体应谨慎使用接口。
31.3 面试题精选
基础题
1. ArrayList 与 List<T> 的区别与选型
题目
ArrayList 和 List<T> 有什么区别?实际开发中更推荐用哪个,为什么?
深入解析
ArrayList(System.Collections):本质是 object[],元素以 object 存储。存取任意类型方便,但存在装箱拆箱:存 int 时装箱成 object,取出要拆箱,有性能开销和类型安全隐患。
List<T>(System.Collections.Generic):本质是可变长度的泛型数组,编译期确定类型。无装箱拆箱、类型安全,且 API 与 ArrayList 类似(Add、Remove、Insert、索引器等)。
实际开发中**更推荐 List<T>**:类型已知时用泛型集合,避免装箱拆箱、提高性能、减少运行时类型错误。同理,键值对推荐 Dictionary<K,V> 而非 Hashtable;栈、队列推荐 Stack<T>、Queue<T> 而非非泛型版本。
答题示例
ArrayList 底层是 object 数组,什么都能装,但存值类型会装箱、取的时候要拆箱,有性能损耗,而且取出来要自己强转,容易出错。List<T> 是泛型列表,编译期就定好类型,没有装箱拆箱,也更安全。所以只要类型能确定,我们都用 List<T>、Dictionary 这类泛型集合,不用 ArrayList、Hashtable。
参考文章
- 2.简单数据结构类-ArrayList
- 6.泛型-泛型
- 8.常用泛型数据结构类-List
2. 委托与事件的区别
题目
C# 里委托和事件有什么区别?事件为什么不能在类外部赋值和调用?
深入解析
委托:方法的容器,可存储、传递与委托签名匹配的方法。可作为类的成员、方法参数、局部变量;在任意位置都能用 = 赋值、用 +=/-= 增减,以及直接 Invoke() 或 变量() 调用。
事件:基于委托的封装,语法是 event 委托类型 事件名。用法上“委托怎么用事件就怎么用”,但有三点限制:
- 外部不能赋值:不能写
obj.myEvent = null或= 某方法,只能+=/-=订阅/退订。 - 外部不能调用:只能在声明事件的类内部触发(如
if (myEvent != null) myEvent();)。 - 不能作为局部变量:不能写
event Action e = ...。
这样做的目的:防止外部把委托置空或随意调用,保证只有发布方能在内部触发,订阅方只能订阅/退订,符合发布-订阅模式,更安全。
答题示例
委托就是装方法的变量,哪里都能赋值、哪里都能调用。事件是在委托外面包了一层:外部只能用 +=、-= 订阅退订,不能直接用 = 赋值,也不能在类外面 Invoke,只能由类内部触发。这样就不会被外部随便清空或乱调,更适合做事件通知、UI 回调这类“只有发布方能发,订阅方只能听”的场景。
参考文章
- 13.委托和事件-委托
- 14.委托和事件-事件
3. 顺序存储与链式存储的选型
题目
List<T> 和 LinkedList<T> 底层分别是什么结构?在“查多、按索引访问”和“头尾/中间频繁增删”两种场景下,该怎么选?
深入解析
List<T>:顺序存储,底层是数组。按索引查、改为 O(1);在中间插入、删除需要移动元素,为 O(n)。适合按下标访问多、尾部增删多的场景。
LinkedList<T>:链式存储,双向链表。在已知节点位置时,头尾或中间插入、删除为 O(1);但按值查找、按位置访问需要遍历,为 O(n)。适合频繁在头尾或已知节点前后增删、很少按索引随机访问的场景。
选型:固定一组数据、或常按下标查、尾部增删多 → 用 List 或数组;常从中间插入删除、且很少按索引查 → 用 LinkedList。例如 UI 面板栈用 Stack,消息队列用 Queue;需要“中间插入删除多、随机访问少”时再考虑 LinkedList。
答题示例
List 底层是数组,顺序存储,按下标访问是 O(1),中间增删要挪元素是 O(n)。LinkedList 是双向链表,在已知节点处增删是 O(1),但查找、按位置访问要遍历是 O(n)。所以查多、按索引访问多就用 List;头尾或中间频繁增删、而且很少按索引查,可以考虑 LinkedList。
参考文章
- 10.常用泛型数据结构类-顺序存储和链式存储
- 8.常用泛型数据结构类-List
- 11.常用泛型数据结构类-LinkedList
进阶题
1. 泛型约束 where
题目
什么是泛型约束?where T : class、where T : struct、where T : new() 分别表示什么?可以组合使用吗?
深入解析
泛型约束用 where 泛型字母 : 约束 限制类型参数,让泛型内部能安全地使用该类型的特定能力。
- **
where T : struct**:T 必须是值类型(包括 int、float 及自定义 struct)。 - **
where T : class**:T 必须是引用类型(类、接口、委托等)。 where T : new():T 必须有公共无参构造函数,这样泛型里可以写new T()。注意:struct 自带无参构造,但struct和new()不能一起写;class和new()可组合,且 class 要写在 new() 前面,否则报错。- 还可约束为某类/接口:
where T : BaseClass、where T : IComparable;以及where T : U(T 必须是另一泛型参数 U 或其派生类)。
组合示例:where T : class, new() 表示 T 是引用类型且可无参构造。
答题示例
泛型约束用 where 限制 T 能是什么类型。struct 表示只能是值类型,class 表示只能是引用类型,new() 表示必须有公共无参构造,这样泛型里才能 new T()。可以组合,比如 where T : class, new(),但 class 要写在 new() 前面;struct 不能和 new() 一起写,因为结构体本身就有无参构造。
参考文章
- 7.泛型-泛型约束
2. 闭包与 for 循环中的 lambda 陷阱
题目
什么是闭包?在 C# 里 for 循环中写 lambda 并加到委托/事件里,为什么经常出现“都打印同一个数”的问题,如何避免?
深入解析
闭包:一个函数(或委托)连同其引用到的外部变量一起形成的封闭作用域。内层函数可以引用外层变量,且捕获的是变量本身(引用),不是当时的值;外层执行结束后,只要闭包还在用,该变量的生命周期会被延长。
for 循环陷阱:for 里声明的循环变量(如 i)在整个 for 内是同一个变量。lambda 捕获的是这个变量的引用,等循环结束、委托被调用时,i 已经是终值(例如 3),所以多个委托执行时都读到 3。
避免方式:在循环体内引入新的局部变量再被 lambda 捕获,例如 int copy = i;,然后 () => Console.WriteLine(copy)。这样每次循环的 copy 是不同的变量,捕获的是各自的值。
答题示例
闭包就是函数带着它用到的外部变量一起“打包”,捕获的是变量本身不是快照。for 里写的 i 在整个 for 里是同一个变量,lambda 捕获的是这个引用,等真正执行时 i 已经变成最后一次的值了,所以会都打印同一个数。解决办法是在循环里再声明一个局部变量,比如 int copy = i,让 lambda 捕获 copy,每次循环的 copy 不同,就不会串了。
参考文章
- 16.委托和事件-lambda表达式
3. 协变与逆变(out / in)
题目
C# 里协变和逆变是什么?out 和 in 分别修饰泛型的什么位置?只能用在泛型接口和泛型委托上吗?
深入解析
协变:和谐的变化,子类→父类。用 out 修饰泛型参数,表示该类型只能作为返回值,不能作为参数。例如 Func<Son> 可以赋给 Func<Father>,因为返回的是子类,用父类接符合里氏替换。
逆变:逆着来的变化,父类→子类。用 in 修饰泛型参数,表示该类型只能作为参数,不能作为返回值。例如 Action<Father> 可以赋给 Action<Son>,因为能处理父类的委托,传入子类时也能处理。
适用范围:只有泛型接口和泛型委托可以用 out/in;泛型类、结构体等会报错。作用是把“生产者”标成 out(只输出)、把“消费者”标成 in(只输入),在保证类型安全的前提下让父子泛型可以互相赋值。
答题示例
协变用 out,表示泛型只能当返回值,子类可以当父类返回,所以像 Func
可以赋给 Func 。逆变用 in,表示泛型只能当参数,能处理父类的委托也能处理子类,所以 Action 可以赋给 Action 。out 和 in 只能用在泛型接口和泛型委托上,不能用在泛型类上。
参考文章
- 18.协变逆变
深度题
1. 多线程:lock 与 IsBackground
题目
多线程访问同一块共享数据时为什么要加 lock?Thread 的 IsBackground 设为 true 有什么作用?不设会有什么问题?
深入解析
为什么要 lock:多个线程共享进程内存,若同时读写同一变量或同一资源(例如控制台光标、同一集合),执行顺序不确定,会导致数据错乱或结果不符合预期。lock(引用类型对象) 保证同一时刻只有一个线程进入锁内代码,其它线程等待锁释放后再执行,从而避免并发冲突。代价是可能降低并发效率。
IsBackground:线程分为前台线程和后台线程。前台线程会阻止进程退出,只要有一个前台线程没结束,进程就不会关。后台线程不会阻止进程退出;当前台线程都结束时,进程结束,后台线程会被直接终止。把通过 Thread 开出来的、带死循环或长任务的线程设为 IsBackground = true,主线程退出后进程可以正常关闭;否则主线程结束了,子线程还在跑死循环,进程关不掉。
答题示例
多线程共享同一块数据时,如果不加锁,几个线程同时改,顺序乱了就会出错。lock 用一个对象当锁,同一时间只有一个线程能进锁里的代码,其它等着,这样就不会抢同一资源。IsBackground 设为 true 表示这是后台线程,主线程结束时进程会直接退出,后台线程也会被停掉;不设的话前台线程会拖住进程,比如子线程里有个死循环,主线程退出了进程也关不掉。
参考文章
- 19.多线程
2. 反射的概念与常见用法
题目
什么是反射?Type 对象有哪几种常用获取方式?反射一般用在什么场景?
深入解析
反射:程序在运行时查看自身或其它程序集的元数据(类、方法、字段、构造函数等),并可以动态实例化对象、调用方法、读写字段。元数据保存在程序集(DLL/EXE)中;反射提高了扩展性和灵活性,代价是性能比直接调用差。
获取 Type:
1)obj.GetType():从实例获取。
2)typeof(类名):从类型名获取。
3)Type.GetType("完整类名"):从字符串获取,类名必须带命名空间,否则可能找不到。
有了 Type 后,可用 GetConstructors、GetMethods、GetFields 等获取成员,用 Invoke 调用构造或方法,或用 Activator.CreateInstance(type, 参数...) 快速实例化。配合特性可做“带特性的类/方法在运行时被扫描并处理”。
常见场景:插件/模块动态加载、编辑器扩展(如 Unity Inspector)、序列化/反序列化、依赖注入、根据配置或字符串名创建类型等。
答题示例
反射就是在运行时拿到程序集里的元数据,能拿到类型、方法、字段这些信息,还能动态 new 对象、调方法。Type 常用三种方式:对象.GetType()、typeof(类名)、Type.GetType(“命名空间.类名”),字符串那种要带命名空间。反射一般用在插件加载、编辑器工具、序列化、或者根据配置字符串创建类型这类需要“运行时才知道类型”的场景,缺点是比较耗性能。
参考文章
- 21.反射和特性-反射
- 22.反射和特性-特性
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com