C#第九章核心概念预习实战包:委托、事件与异步编程精讲
简介:《C#编程基础:深入理解第九章》是王文峰老师课程中的关键章节,涵盖C#面向对象编程的高级特性。本预习资料包“15_王文峰C#ch09预习.zip”包含课件、笔记、练习题及录屏讲解,系统梳理了委托与事件、Lambda表达式、LINQ、匿名类型、扩展方法、异步编程、多线程、泛型、集合框架和异常处理等核心内容。通过本章学习,学生将掌握C#中实现高效、安全、可维护代码的关键技术,为后续复杂项目开发打下坚实基础。
委托、事件与Lambda:C#中函数式编程的基石
想象这样一个场景:你正在开发一个智能家居系统,主控模块需要根据传感器状态动态调用不同的处理逻辑。温度过高时启动风扇,湿度过大时开启除湿机——但这些行为在编译时根本无法确定。这时候你会怎么做?硬编码一堆 if-else ?还是让每个设备都实现统一接口然后反射调用?
其实,C#早就为我们准备了更优雅的解法: 把方法当成数据来传递 。
这听起来有点玄乎,但在现代C#开发中早已司空见惯。我们每天都在使用的 button.Click += (s,e) => ShowMessage(); ,背后就是一套精巧的机制在支撑。而这套机制的核心,正是 委托(Delegate) 。
委托不是“引用”,而是“类型化的函数指针” 🎯
很多人第一次接触委托时,总会把它理解成“方法的引用”。这种说法不算错,但容易让人误以为它和普通变量引用是一回事。实际上,委托是一种 类型安全的函数指针 ——关键词是“类型安全”。
什么意思呢?看这个例子:
public delegate int Calculator(int x, int y);
这行代码定义了一个名为 Calculator 的委托类型,它只能指向那些 返回 int 且接受两个 int 参数 的方法。就像你不能把一只猫塞进狗窝一样,编译器会阻止任何不匹配的赋值。
比如下面这段代码就能顺利通过:
private static int Add(int a, int b) => a + b;
private static int Multiply(int a, int b) => a * b;
// ✅ 合法:签名完全匹配
Calculator calc = Add;
Console.WriteLine(calc(3, 4)); // 输出 7
calc = Multiply;
Console.WriteLine(calc(3, 4)); // 输出 12
但如果我试图绑定一个参数数量或类型不符的方法:
private static string GetName() => "Alice";
// ❌ 编译错误!无法将 string () 转为 int (int, int)
Calculator wrong = GetName;
立马就会被拦下来。这就是所谓的“类型安全”——它不是运行时才发现问题,而是在写代码的时候就被揪出来了。
那么,委托到底怎么工作的?本质上,当你实例化一个委托时,CLR会创建一个对象,里面存了两样东西:
- 目标方法的内存地址
- 所属实例的引用(如果是实例方法的话)
你可以把它想象成一张“任务卡”:上面写着“去哪执行”、“用谁的数据”、“传什么参数”。当你调用 calc(3,4) 时,这张卡片就会被交给运行时系统,由它精确地跳转到对应的方法并传参执行。
值得一提的是,除了直接调用,你还可以显式使用 Invoke() :
calc.Invoke(3, 4); // 和 calc(3,4) 效果相同
不过没人会这么干——太啰嗦了 😅。倒是有个冷知识:如果你要在一个高并发环境下频繁调用委托,可以考虑缓存其 Method 和 Target 属性以避免重复查表,但这属于极致优化范畴了。
事件:封装好的观察者模式 🔔
如果说委托是“方法的搬运工”,那事件就是“受保护的委托”——它是基于委托构建的一种语言级封装,专为实现 发布-订阅模式 而生。
最常见的应用场景莫过于 GUI 编程中的按钮点击事件:
button.Click += OnButtonClick;
这里的 Click 就是一个事件。关键点在于: 外部代码只能通过 += 和 -= 来订阅或取消订阅,而不能直接触发它,也不能清空整个事件链 。
这是怎么做到的?答案就在 event 关键字上。
当我们这样声明一个事件:
public event EventHandler<DataEventArgs> DataUpdated;
编译器会自动生成两个隐藏的访问器方法: add_DataUpdated 和 remove_DataUpdated 。它们的作用就像门卫一样,只允许你“添加监听者”或“移除监听者”,而不让你随意篡改内部的委托链。
这也意味着,以下操作都是非法的:
// ❌ 错误:不能直接赋值给事件
logger.DataUpdated = null;
// ❌ 错误:不能直接调用事件(除非在同一类内)
DataUpdated(this, args);
唯一的例外是,在定义事件的那个类内部,你可以自由触发它:
protected virtual void OnDataUpdated(DataEventArgs e)
=> DataUpdated?.Invoke(this, e);
这里用了个技巧:先判断 DataUpdated 是否为空(即有没有人订阅),再安全调用。这也是为什么推荐所有事件触发都写成 xxx?.Invoke(...) 的原因——防止空引用异常。
💡 小贴士 :为什么叫 OnXXX ?这是一种约定俗成的命名规范。 OnDataUpdated 表示“当数据更新时应该做什么”,通常用于封装事件触发逻辑,便于子类重写。
Lambda表达式:匿名函数的终极简化 💡
还记得早期Web开发中满屏的 delegate { ... } 吗?那种写法虽然功能完整,但实在不够清爽。直到 C# 3.0 引入了 Lambda 表达式,才真正让函数式风格流行起来。
来看对比:
// 旧式匿名方法
Func<int, int, int> add1 = delegate(int a, int b) { return a + b; };
// Lambda写法
Func<int, int, int> add2 = (a, b) => a + b;
光这一行就少了十几个字符,而且语义更清晰。 => 这个符号读作“goes to”,直白地说就是:“输入 (a,b) ,输出 a+b ”。
但别被它的简洁骗了——Lambda 不只是语法糖那么简单。它的强大之处在于:
- 类型推断 :编译器能根据上下文自动推导参数类型
- 闭包支持 :可以捕获外部变量
- 表达式树转换 :某些情况下会被编译为
Expression<Func<T>>而非纯方法
举个实用的例子:你想筛选出年龄大于18的学生名单。
var adults = students.Where(s => s.Age >= 18).ToList();
这短短一行里, s => s.Age >= 18 是一个典型的谓词函数,被传给了 Where 方法。如果没有 Lambda,就得提前定义一个委托实例或者写个完整的方法,代码瞬间臃肿起来。
更妙的是,Lambda 还能自动升级为表达式树,供 LINQ to SQL 等框架翻译成 SQL 查询:
// 下面这句会被 EF Core 翻译为 SELECT * FROM Students WHERE Age > 18
var query = dbContext.Students.Where(s => s.Age > 18);
如果换成普通方法或匿名委托,就没法做这种翻译了。所以说,Lambda 不仅提升了可读性,还打开了通往“代码即数据”的大门。
构建高效数据管道:LINQ与泛型集合的艺术
现在让我们换个战场:假设你是一家电商平台的技术负责人,每天要处理数百万条订单记录。老板要求你快速统计“本月销售额前10的已发货订单”,并且希望这个查询尽可能高效、易读、可维护。
传统做法可能是嵌套循环加条件判断,但那样既难调试又容易出错。幸运的是,C# 提供了一整套现代化的数据操作体系: 泛型集合 + LINQ = 数据处理神器 。
这套体系之所以强大,是因为它融合了三个核心理念:
- 类型安全 (泛型保障)
- 声明式编程 (LINQ提供)
- 延迟执行 (性能优化基础)
接下来我们就一层层揭开它的面纱。
LINQ的本质:从SQL幻觉到方法链真相 🪄
第一次看到 from...where...select 这种写法的人,往往会误以为 C# 内置了一个小型数据库引擎。其实不然—— 所有的 LINQ 查询最终都会被编译器转换为对标准查询操作符的调用 。
比如这个看起来很像 SQL 的查询:
var query = from student in students
where student.Age >= 18
orderby student.Name
select new { student.Name, student.Age };
你以为它在执行 SQL?错!它等价于:
var query = students
.Where(s => s.Age >= 18)
.OrderBy(s => s.Name)
.Select(s => new { s.Name, s.Age });
两者生成的 IL 指令几乎一模一样。唯一的区别是前者需要经过一次“语法重写”过程,后者则是直接调用扩展方法。
为了更清楚地展示这个过程,来看下面这个流程图 👇
graph TD
A[原始查询表达式] --> B{解析 from 子句}
B --> C[确定数据源 students]
C --> D{处理 where 条件}
D --> E[调用 Where(student => student.Age >= 18)]
E --> F{处理 orderby 子句}
F --> G[调用 OrderBy(student => student.Name)]
G --> H{处理 select 投影}
H --> I[调用 Select(student => new { Name = student.Name, Age = student.Age })]
I --> J[返回最终查询对象]
看到了吗?所谓“查询语法”,不过是编译器帮你把高层语义拆解成了底层方法调用而已。整个过程发生在编译期,零运行时开销。
这也解释了为什么有些复杂的查询用方法语法反而更容易理解。例如多表连接:
var result = from c in customers
join o in orders on c.Id equals o.CustomerId
where o.Total > 1000
select new { c.Name, o.OrderDate, o.Total };
对应的链式调用是:
var result = customers
.Join(orders,
c => c.Id,
o => o.CustomerId,
(c, o) => new { c, o })
.Where(x => x.o.Total > 1000)
.Select(x => new { x.c.Name, x.o.OrderDate, x.o.Total });
虽然略显繁琐,但它揭示了一个重要事实: Join 其实是个四参数函数!中间那个 (c,o)=>new{} 是结果选择器,决定了合并后的结构。
所以什么时候该用哪种语法?我的建议是:
| 场景 | 推荐语法 |
|---|---|
| 简单过滤/映射 | 方法语法( .Where().Select() ) |
| 多表连接、分组聚合 | 查询语法(可读性强) |
| 动态拼接条件 | 方法语法(灵活控制流程) |
| 团队成员有SQL背景 | 查询语法(降低认知成本) |
没有绝对的好坏,只有是否适合当前上下文。
扩展方法:给IEnumerable “打补丁”的魔法 ✨
你有没有想过, List<T> 本身并没有 Where 或 OrderBy 方法,为什么可以直接调用?
秘密就在于 扩展方法 。这些看似属于集合类的方法,其实是定义在静态类 System.Linq.Enumerable 中的:
public static class Enumerable
{
public static IEnumerable<TSource> Where<TSource>(
this IEnumerable<TSource> source,
Func<TSource, bool> predicate)
{
// 实现细节...
}
}
关键字 this IEnumerable<TSource> source 告诉编译器:“这个静态方法可以当作 IEnumerable<T> 的实例方法来用”。
这就像是给老房子装新电梯——不用改动原有结构,就能获得全新体验。
更重要的是,这些方法大多返回 IEnumerable<T> ,形成了流畅的 方法链 (fluent interface)。每一个操作都不立即执行,而是等待最后被消费时才开始流动。
这就是传说中的“ 延迟执行 ”(Deferred Execution)。
延迟执行:像水管一样输送数据 💧
想象一条长长的水管,源头是你的数据集合,末端是你调用 ToList() 或 foreach 的地方。中间的各种 .Where() 、 .OrderBy() 就像一个个过滤器和泵站。
只要你还没拧开水龙头(即枚举结果),水就不会流动。也就是说,下面这行代码其实啥也没干:
var query = dbContext.Products.Where(p => p.Price > 100);
它只是“描述”了一个查询意图,并没有真正去数据库拿数据。
只有当你写下:
var results = query.ToList(); // ← 此刻才执行
水流才会启动,请求发往数据库,结果返回填充列表。
这种设计的好处显而易见:
- 节省资源 :避免不必要的计算
- 组合灵活 :可以在任意阶段插入新条件
- 提高性能 :尤其在 Entity Framework 中,多个
.Where()会被合并为一条 SQL
但也带来一个陷阱: 同一个查询多次枚举会产生多次实际调用 !
Console.WriteLine(query.Count()); // 第一次访问,执行查询
Console.WriteLine(query.Any()); // 第二次访问,再次执行!
如果你连着查了五次,那就等于跑了五遍数据库……😱
解决办法很简单:一旦决定要复用结果,就立刻物化它:
var list = query.ToList(); // 只执行一次
Console.WriteLine(list.Count);
Console.WriteLine(list.Any);
记住一句话: 延迟执行是利器,也是双刃剑 。
标准查询操作符全解析 📊
为了让你们对 LINQ 的能力有全面认识,我整理了一份常用操作符对照表:
| 方法名 | 功能描述 | 是否延迟执行 | 返回类型 |
|---|---|---|---|
Where |
按条件过滤 | 是 | IEnumerable<T> |
Select |
元素映射 | 是 | IEnumerable<R> |
OrderBy |
排序 | 是 | IOrderedEnumerable<T> |
GroupBy |
按键分组 | 是 | IEnumerable<IGrouping<K,T>> |
First |
获取首个元素 | 否 | T |
Any |
判断是否存在匹配元素 | 否 | bool |
Count |
统计元素数量 | 否 | int |
ToArray |
强制立即执行并转数组 | 否 | T[] |
注意区分哪些是“终止操作”(eager evaluation),哪些是“中间操作”(lazy evaluation)。一般来说,凡是返回具体值而非 IEnumerable 的,基本都是立即执行的。
比如 First() ,哪怕数据源有十亿条记录,它也会马上拉取第一条并停止后续遍历。这在某些场景下非常有用,比如判断用户是否存在:
bool exists = users.Any(u => u.Email == email); // 比 FirstOrDefault() + null check 更高效
因为 Any() 一旦找到符合条件的项就立刻返回 true ,不会继续扫描。
异步编程:如何写出不会卡死UI的代码?
咱们来聊点刺激的:你刚上线了一个电商App,用户反馈“点击‘下单’按钮后界面卡住好几秒”。一查日志发现,原来是支付网关API响应慢导致的。
这时候你能怎么办?把所有代码改成多线程?手动创建 Thread ?拜托,那可是上世纪的做法了。
现代C#的正确姿势是: async/await 。它让你用同步的方式写异步代码,既不阻塞主线程,又能保持逻辑清晰。
但很多人只知道“加个 async 和 await 就行了”,却不知道背后发生了什么。一旦遇到死锁或异常丢失,就傻眼了。
别担心,今天我就带你看穿它的本质。
async/await 不是多线程,而是“状态机驱动的任务切换” ⚙️
当你写下:
public async Task<int> CalculateAsync()
{
await Task.Delay(1000);
return 42;
}
看起来像是一段连续执行的代码,但实际上,编译器会把它重写成一个 有限状态机 (Finite State Machine)。
什么意思?简单说,就是把这个方法拆成多个片段,每遇到一个 await 就暂停一下,把控制权交回去。等异步操作完成后再恢复执行。
伪代码大概是这样:
[CompilerGenerated]
private sealed class <CalculateAsync>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder<int> <>t__builder;
private TaskAwaiter <>u__1;
private void MoveNext()
{
switch (<>1__state)
{
case 0:
// 恢复点:延迟结束后继续执行
break;
default:
// 初始执行
<>u__1 = Task.Delay(1000).GetAwaiter();
if (!<>u__1.IsCompleted)
{
<>1__state = 0;
<>t__builder.AwaitOnCompleted(ref <>u__1, ref this);
return; // ← 关键!释放控制权
}
break;
}
// 继续执行后续代码
<>t__builder.SetResult(42);
}
void SetStateMachine(IAsyncStateMachine stateMachine) =>
<>t__builder.SetStateMachine(stateMachine);
}
看到了吗? await 并没有创建新线程,而是利用了 回调机制 。当 Task.Delay 完成后,会通知这个状态机重新调用 MoveNext() ,从而从断点处继续执行。
整个过程可以用下面的状态图表示:
stateDiagram-v2
[*] --> Created
Created --> Running: Start()
Running --> Suspended: await incomplete Task
Suspended --> Resumed: Task completed
Resumed --> Running: MoveNext() called
Running --> Completed: SetResult()
Running --> Faulted: Exception thrown
Completed --> [*]
Faulted --> [*]
note right of Suspended
Callback registered via INotifyCompletion.
Thread is released during suspension.
end note
最关键的一点是: Suspended 状态下线程被释放了 !它可以去处理其他请求、响应UI事件,真正做到“非阻塞”。
这也是为什么 async/await 特别适合 I/O 密集型操作(如网络、文件、数据库),因为这些操作大部分时间都在等,根本不需要占用CPU。
Task异常传播:别让错误悄悄溜走 🐞
很多人以为只要用了 try-catch 包住 await ,就能抓住所有异常。可惜现实没那么简单。
来看这个例子:
try
{
await Task.Run(() => throw new Exception("Boom!"));
}
catch (Exception ex)
{
Console.WriteLine($"Caught: {ex.Message}");
}
没问题,能正常捕获。但如果换成多个任务并发呢?
var t1 = Task.Run(() => throw new InvalidOperationException("One"));
var t2 = Task.Run(() => throw new ArgumentException("Two"));
try
{
await Task.WhenAll(t1, t2);
}
catch (AggregateException aggEx)
{
foreach (var inner in aggEx.Flatten().InnerExceptions)
{
Console.WriteLine($"Inner: {inner.Message}");
}
}
注意到没?抛出来的是 AggregateException !因为它要打包所有失败的任务异常。
如果不调用 Flatten() ,你还可能遇到嵌套的 AggregateException ——想想都头疼。
所以最佳实践是:
catch (AggregateException ex)
{
var flattened = ex.Flatten();
foreach (var err in flattened.InnerExceptions)
{
Log.Error(err, "Background task failed");
}
}
另外还有一个隐患: 未观察的异常 (Unobserved Exceptions)。如果你启动了一个 Task 却从不 await 它,里面的异常可能会默默消失,直到进程退出时才触发崩溃。
为了避免这种情况,建议注册全局处理器:
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
Log.Fatal(e.Exception, "Unobserved exception in background task");
e.SetObserved(); // 防止程序终止
};
虽然 .NET 默认会在最终化时忽略未观察异常,但最好还是主动监控,毕竟谁也不想半夜被报警电话吵醒 😅。
ConfigureAwait(false):打破死锁的钥匙 🔑
最经典的死锁案例出现在 WinForms/WPF 应用中:
private void button_Click(object sender, EventArgs e)
{
var result = GetDataAsync().Result; // ⚠️ 死锁!
}
public async Task<string> GetDataAsync()
{
await Task.Delay(1000);
return "Hello";
}
表面看没问题,但运行起来你会发现:点了按钮后界面直接卡死!
原因如下:
- 主线程进入
GetDataAsync - 遇到
await Task.Delay(1000),任务未完成,返回Task - 调用
.Result阻塞主线程,等待Task完成 - 延迟结束,运行时尝试将后续代码 调度回UI线程
- 但UI线程正被
.Result占着,无法处理消息循环 - → 形成死锁,永远等不到结果
破解方法只有一个: 告诉运行时不必要回到原上下文 。
public async Task<string> GetDataAsync()
{
await Task.Delay(1000).ConfigureAwait(false); // ← 关键!
return "Hello";
}
ConfigureAwait(false) 的作用就是切断对当前 SynchronizationContext 的依赖,让延续(continuation)可以在任意线程池线程上执行。
当然,也不是哪儿都能乱加。以下是使用建议:
| 场景 | 是否使用 |
|---|---|
| 类库项目 | ✅ 强烈推荐 |
| UI应用业务层 | ✅ 推荐 |
| 更新UI控件的代码 | ❌ 禁止 |
| ASP.NET Core | 可选(默认无上下文) |
| 单元测试中 Wait()/Result | ✅ 必须 |
总结一句话: 在类库中一律使用 ConfigureAwait(false) ,在应用层按需决定是否恢复上下文 。
下面是死锁全过程的序列图,帮助你直观理解:
sequenceDiagram
participant UI as UI Thread
participant Async as Async Method
participant Pool as ThreadPool
UI->>Async: Call GetDataAsync()
Async->>Pool: Start Task.Delay (captures context)
Pool-->>Async: Delay not complete
Async-->>UI: Return incomplete Task
UI->>UI: Block on .Result
Pool->>Pool: Delay completes
Pool->>UI: Post continuation to UI context
Note over UI,Poll: UI thread is blocked!<br/>Cannot process posted message
loop Waiting forever
UI->>UI: Stuck in .Result
end
看到了吗?问题出在“Post continuation”这一步。如果用了 ConfigureAwait(false) ,这一步就会直接扔给线程池执行,不再依赖UI线程,自然也就不会卡住了。
工程化实践:打造稳定可靠的大型系统
到了最后阶段,我们要把前面所有技术串起来,落地到真实项目中。
无论是企业级后台服务,还是复杂桌面应用,稳定性、可维护性和扩展性永远是第一位的。
下面这三个实战模式,我已经在多个千万级用户产品中验证过有效性。
模块解耦:用事件驱动取代硬依赖 🔄
在一个典型的WPF/MVVM项目中,ViewModel之间经常需要通信。比如“用户点击‘刷新’按钮后,日志面板要更新内容,状态栏要显示进度”。
如果采用传统方式,你会怎么做?让 MainViewModel 直接持有 LogPanelViewModel 和 StatusBarViewModel 的引用?不行!这样一来模块之间就紧紧绑死了,改一个会影响一堆。
正确的做法是: 事件驱动 + 发布订阅 。
我们可以设计一个简单的事件聚合器,或者直接使用 Prism/MVVM Toolkit 提供的消息总线。
结构如下:
flowchart TD
A[MainWindowVM] -- "DataLoaded" --> B(LogListVM)
A -- "ProgressUpdated" --> C(StatusBarVM)
D[SettingsVM] -- "ConfigChanged" --> A
D -- "ThemeChanged" --> E(MainView)
每个模块只关心自己感兴趣的事件,完全不知道是谁发布的。这种“松耦合”设计带来的好处包括:
- 支持独立开发和测试
- 易于替换实现(比如换主题引擎)
- 可插拔架构(后期加监控模块不影响主流程)
当然,也要防范副作用:事件太多会导致“谁发了什么”变得难以追踪。建议配合事件命名规范 + 文档注释使用。
自定义 EventArgs:不只是传递数据,更是契约 📜
别小看 EventArgs 。它不仅是数据容器,更是一种 版本兼容性保障机制 。
比如你定义了一个日志事件:
public class LogEntryEventArgs : EventArgs
{
public string Message { get; }
public DateTime Timestamp { get; }
public LogLevel Level { get; }
public string Source { get; }
public LogEntryEventArgs(string message, LogLevel level, string source)
{
Message = message;
Level = level;
Source = source;
Timestamp = DateTime.Now;
}
}
以后就算你要新增字段(比如 CorrelationId ),也不会破坏现有订阅者的兼容性。
相比之下,如果直接传 (string, LogLevel, string) 这样的 tuple,后期扩展几乎不可能。
此外,构造函数中设置 Timestamp 也体现了良好的设计习惯: 事件发生的时间应由发布者决定,而不是由接收者自行记录 ,否则会出现时钟偏差。
WeakEvent模式:终结内存泄漏的利器 🧹
GUI开发中最隐蔽的坑之一就是 事件导致的内存泄漏 。
比如你在页面加载时订阅了某个全局事件,但忘了在卸载时取消订阅。结果即使页面关闭了,GC 也无法回收它,因为事件源仍持有它的引用。
解决方案? Weak Event Pattern 。
原理很简单:不用强引用保存事件处理器,而是用 WeakReference<T> 包一层。
public class WeakEvent<TEventArgs> where TEventArgs : EventArgs
{
private readonly List<WeakReference<EventHandler<TEventArgs>>> _listeners =
new List<WeakReference<EventHandler<TEventArgs>>>();
public void Subscribe(EventHandler<TEventArgs> handler)
{
_listeners.Add(new WeakReference<EventHandler<TEventArgs>>(handler));
}
public void Raise(object sender, TEventArgs args)
{
var aliveHandlers = new List<EventHandler<TEventArgs>>();
for (int i = _listeners.Count - 1; i >= 0; i--)
{
if (_listeners[i].TryGetTarget(out var target))
{
aliveHandlers.Add(target);
}
else
{
_listeners.RemoveAt(i); // 清理已释放的监听器
}
}
foreach (var handler in aliveHandlers)
{
handler(sender, args);
}
}
}
这样即使订阅者被 GC 回收,也不会影响事件系统的正常运作。
特别适用于 WPF 中的 INotifyPropertyChanged 、MVVM 框架、插件系统等长生命周期场景。
总结:从“会用”到“懂原理”的跨越 🚀
今天我们聊了很多,从委托的本质到 async 状态机,从 LINQ 编译原理到工程化实践。你会发现,真正优秀的开发者,从来不满足于“能跑就行”。
他们想知道:
- 为什么加了个
await就不卡界面了? - 为什么有时候
.Result会导致死锁? - 为什么同样的查询语法和方法语法性能一样?
这些问题的答案,不在文档的第一章,而在深夜调试崩溃的日志里,在反编译工具打开的 IL 代码中,在一次次踩坑后的反思中。
而你现在,已经比大多数人更接近真相了 💪。
记住: 掌握原理,才能驾驭变化 。语言会升级,框架会迭代,但底层思想永远闪光。
愿你在代码的世界里,既能仰望星空,也能脚踏实地 🌟
简介:《C#编程基础:深入理解第九章》是王文峰老师课程中的关键章节,涵盖C#面向对象编程的高级特性。本预习资料包“15_王文峰C#ch09预习.zip”包含课件、笔记、练习题及录屏讲解,系统梳理了委托与事件、Lambda表达式、LINQ、匿名类型、扩展方法、异步编程、多线程、泛型、集合框架和异常处理等核心内容。通过本章学习,学生将掌握C#中实现高效、安全、可维护代码的关键技术,为后续复杂项目开发打下坚实基础。
更多推荐


所有评论(0)