C#基础学习路线与视频课程设计:运行时、异步、LINQ与项目实战

我最早带人学 C# 的时候,犯过一个很典型的错误:把语法清单从头到尾念了一遍,变量、循环、类、继承、接口,一个不落,学员笔记记得满满当当,结果让他写一个「读一个文本文件、按某列排序、输出到另一个文件」的小工具,他卡在文件路径和异常处理上整整一下午。那次之后我就明白,C# 基础这件事,最怕的不是知识点讲不全,而是讲成了语法字典。后来我把自己重新整理的一套 C# 基础视频课程结构、配套示例和录制过程复盘了一遍,沉淀成这份记录。它既是一份 C# 基础学习路线的拆解,也是一份做技术教学内容的实操笔记,适合两类人看:正在啃 C# 基础、想少走弯路的初学者,以及打算自己做一套技术教程、想知道怎么把抽象概念讲明白的人。下面这些内容,全部来自我在实际录制和陪练过程中反复修正过的做法,没有一条是照本宣科。

1. 这套 C# 基础视频的整体设计思路

动手录第一集之前,我花了差不多两周时间只做一件事:把「基础篇」的边界划清楚。这件事听起来像务虚,但它决定了后面几十集的内容密度和节奏。我见过太多课程,前五集讲变量和运算符,讲得极细,到第十集突然开始讲泛型约束和协变逆变,中间断层严重,学员在第一集积累的耐心,被第十集的抽象概念直接消耗完。

1.1 先定边界:基础篇到底该讲什么、坚决不讲什么

我给这套课程定了一条硬规则:凡是「没有它,你写不出一个能跑起来的小工具」的内容,必须讲;凡是「没有它,你只是写得不够优雅」的内容,一律推到进阶篇。这条规则听起来简单,执行起来要顶住很大压力,因为总有人问「为什么不讲反射」「为什么不上 EF Core」。

下面这张表是我最终敲定的内容边界,也是我在课程第一集开头就直接投在屏幕上的,先让学员心里有数,知道自己在哪、要去哪。

模块纳入基础篇明确排除排除理由
语言语法变量、运算符、流程控制、数组模式匹配的高级形态初学阶段用不上,增加记忆负担
类型系统值类型/引用类型、装箱拆箱、可空类型泛型协变逆变需要先有继承和多态的扎实底子
面向对象类、封装、继承、多态、接口抽象类模板方法模式属于设计层面,不是语法层面
数据组织List、Dictionary、HashSet、LINQ 常用算子自定义集合、迭代器手写实现先用明白,再谈造轮子
解耦机制委托、事件、Lambda、闭包基础表达式树表达式树是框架作者的领域
并发async/await 基本用法、Task 等待线程池调优、锁的粒度设计基础篇只解决「不阻塞界面」
运行时编译流程、IL、JIT、GC 概念、DisposeGC 分代算法细节、LOH 调优知道「有这回事」即可
工程化项目文件结构、NuGet 引用、调试断点单元测试框架、CI 流水线属于团队协作阶段的课题

这张表最大的价值在于「排除列」。学员看到我明确说「这个不讲,因为讲了你也用不上」,反而更信任后面的内容。我做内容这几年最大的体会是:一份教程的可信度,往往不来自它讲了多少,而来自它敢不敢明确告诉你哪些不讲、以及为什么。

顺带说一句实操心得:这张表的形态我在录制过程中改过三次。第一版列了二十多行,太密,投屏根本看不清;第二版删到八行,又显得单薄;最后定在八个模块、每模块三列信息,配合口述解释,观感刚好。如果你也要做类似的内容,建议表格行数控制在十行以内,超出的部分用口述带过,不要指望观众在暂停键上做笔记。

1.2 为什么把运行时概念提前到第三集,而不是留到最后

这是我做的一个反常规决定,也是整套课程里我自己最满意的一处设计。绝大多数 C# 入门内容会把「什么是 .NET」「什么是 CLR」放在最前面当背景介绍,讲得云里雾里,学员听完只记得几个缩写;或者干脆放到最后,当作进阶彩蛋。

我的做法是放在第三集,而且只讲三件事,用时不超过十八分钟。

第一件事是编译链路:你写的.cs文件,先被编译器翻译成一种中间语言(IL),这个中间产物不针对任何具体硬件,然后运行时在真正执行时,再把 IL 现场翻译成机器指令。我用的类比是中央厨房:源码是你写的菜谱,IL 是中央厨房提前备好的半成品料理包,最后一步的现场加热才对应真正的机器指令。这个类比的好处是它能解释「为什么同一份程序换个平台也能跑」——料理包是通用的,加热设备不同而已。

第二件事是内存的两块区域:一块放小而短命的数据,一块放需要长期存活、由运行时统一回收的数据。这里我只画一张示意图,讲清楚「变量的值到底存在哪」,绝对不展开分代回收算法。

第三件事是「谁负责释放」:大部分对象由运行时自动回收,但有一类资源(文件句柄、数据库连接、网络流)必须由你自己显式关闭,这就是后面要讲的IDisposable和using语句。

为什么非要提前?因为这三件事是后面十几个知识点的「锚」。讲到装箱拆箱,学员脑子里要有「值被搬到了另一块区域」的画面;讲到闭包捕获变量,要知道「变量被提升成了长期存活的对象」;讲到using,要知道「运行时不会帮你关文件」。如果这些概念留到最后讲,前面所有涉及它们的章节都只能靠死记硬背,学员会不断产生「为什么是这样」的疑问却得不到解答,耐心就在这些悬而未决的疑问里被磨掉了。

注意:讲运行时概念时,我给自己定的红线是不出现任何内存地址、字节数、十六进制图示。初学者看到0x00007FF这类东西,第一反应是「我不适合学编程」,而不是「我要深入理解」。用方框和箭头足够了。

1.3 章节编排用螺旋上升,而不是一次讲透

整套课程我一共排了四十二集,单集十二到十八分钟,平均十五分钟。这个时长不是我拍脑袋定的,是我统计了自己看技术视频的完播率之后倒推出来的:超过二十分钟,我在手机上的完播率会掉到一半以下;低于十分钟,又讲不完一个完整的小闭环,观众会觉得「刚进入状态就结束了」。

编排逻辑上,我放弃了「一个知识点一次讲透」的线性思路,改用螺旋:同一个概念在不同集数里出现三次,每次深一层。

以「接口」为例。第一次出现在面向对象那一章,只讲「接口是一份约定,规定了必须有哪些方法」,配一个极简的例子;第二次出现在委托那一章,讲「接口和委托都能实现解耦,区别在哪」,这时候学员已经有委托的底子了,能听懂对比;第三次出现在最后的示例项目重构里,讲「为什么我把这个类改成实现接口,是为了让测试时能替换掉真实实现」。三次出现,间隔十几集,每次都是被真实需求逼出来的,而不是为了复习而复习。

这种编排的代价是每一集不能自成闭环,必须按顺序看。我一开始担心观众会跳着看,后来发现恰恰相反:因为每集结尾都会埋一个「下一集我们要解决一个麻烦,这个麻烦来自今天写的代码」,观众反而更愿意连看。我在课程简介里也明确写了「建议顺序观看,跳集可能导致某些设计决策看不懂」,把预期先管理好。

2. 录制环境与工具链的选型

工具这部分我要多说几句,因为它直接决定了学员能不能「跟着敲出一样的 result」。我踩过的最大的坑,是第一期试录时用了自己电脑上最新的预览版开发工具,结果学员装正式版之后,菜单项位置不一样、模板生成的项目文件多了一个我没讲过的配置项,弹幕里全是「我这里没有这个选项」。从那以后我就定了一条规矩:录制环境必须是学员最可能装到的那个版本,一个字母都不能差。

2.1 SDK 版本怎么选:稳定优先于新

C# 的版本和 .NET 的版本是两个概念,很多初学者在这里绕不过来。我的讲法是:语言是「写法」,框架是「能用的零件库」,两者是配套发布的,但你可以用新写法写老框架的项目,只是有些零件用不了。

具体到我录制时选的组合,是长期支持版(LTS)的 .NET 加上与之配套的 C# 版本。选 LTS 而不是最新版,理由很实际:LTS 有三年的支持周期,学员学完之后半年一年内不会遇到「教程里的写法在新版本里报错」的情况。最新版虽然特性多,但生命周期短,对教学是负资产。

环境验证我给学员的第一步永远是这一条命令:

dotnet --info

输出里我会逐行解释,重点讲三行:SDK 版本(你装了几个、默认用哪个)、运行时版本(程序真正跑在什么上面)、操作系统与架构。这里有个高频困惑必须先讲清楚:SDK 和运行时不是一回事。SDK 是开发工具包,包含编译器、模板、命令行工具;运行时只是执行程序的引擎。你在开发机上装 SDK 就够了,但如果要把程序拷到别人机器上跑,对方只需要运行时。我见过有学员把整个 SDK 拷到服务器上,多占了好几个 G 的空间,就是因为没搞清这个区别。

如果机器上装了多个 SDK,用下面这条命令看当前生效的是哪个:

dotnet --list-sdks dotnet --list-runtimes

项目里可以锁定版本,办法是在项目根目录放一个global.json,内容大致是这样的:

{ "sdk": { "version": "8.0.100", "rollForward": "latestFeature" } }

rollForward这个字段我要专门解释一下,因为它经常被忽略。它的作用是:当机器上没有精确匹配8.0.100这个版本时,允许向上滚动到哪个范围。设成latestFeature意味着允许用 8.0.x 里较新的补丁版本,但不跨大版本。这个设置对团队协作特别有用,能避免「我这能编译他那编译不了」的扯皮。新手阶段其实用不上,但我在视频里讲了一次,因为这是那种「不知道也无所谓,知道了能省很多事」的知识。

2.2 项目模板与目录结构:从第一天就像个正经项目

用命令行建项目这一步,我在视频里坚持手打,不让学员用图形界面点。原因是图形界面会隐藏掉很多信息,而手打的过程本身就是在学习。

dotnet new console -n LedgerDemo -o ./src/LedgerDemo cd ./src/LedgerDemo dotnet run

第一行建一个控制台项目,-n是项目名,-o是输出目录。我特意把项目放到src子目录下,就是为了从第一天起就建立「源码放 src、测试放 tests、文档放 docs」的肌肉记忆。学员可能一开始只有一个项目,但等到课程后期拆出多个项目时,这个习惯能救他。

生成的项目文件.csproj我很认真地讲了,因为它里面每一行都有用:

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> </Project>

OutputType是Exe说明这是个可执行程序;TargetFramework决定你用哪套零件库;ImplicitUsings打开之后,常见的命名空间会自动引入,少写一堆using;Nullable打开之后,编译器会帮你检查可能的空引用,这是 C# 里性价比最高的一个开关,强烈建议所有新项目都开着。

Nullable这一项我讲了整整六分钟,因为它是初学者最容易关掉、关掉之后最后悔的东西。开了之后,声明一个「可能为空的字符串」必须写成string?,而声明string就表示「我保证它不为空」。编译器会在你忘记判空的地方画波浪线警告。这个机制的价值在于,它把一类运行时才炸的错误,提前到了编译期。我当时的原话是:这个开关等于给你配了一个免费的代码审查员,而且它不会累。

2.3 录制参数:这些数字是试出来的

这部分纯属经验,网上很难搜到系统的说法,我把自己反复调过的参数列出来。

参数项我的取值调整理由
录制分辨率1920×1080再高会导致代码在手机端看不清,再低则在大屏播放时发虚
编辑器字号18 至 20 号15 号在手机竖屏下几乎不可读,22 号一屏放不下二十行
终端字号16 号终端字符密度高,字号要比编辑器小一号才不会频繁换行
编辑器主题深色中等对比纯黑背景在压缩后会出现色带,纯白长时间看眼睛疼
光标样式高亮块状 + 轻微放大观众需要随时知道你在哪一行
单集时长12 至 18 分钟完播率与信息密度的平衡点
打字速度刻意放慢约三成正常速度下观众跟不上,会直接放弃跟练
鼠标移动用键盘导航代替鼠标轨迹在视频里非常干扰视线

最后一条我要展开说说。我一开始也用鼠标点菜单、点文件树,后来回看录像发现,鼠标移动的那些路径完全是无信息的噪声,观众的眼睛会不自觉地跟着鼠标跑,反而忽略了代码。后来我全程改用快捷键:Ctrl+P搜文件,Ctrl+Shift+F全局搜,F12跳定义,Ctrl+.调快速修复。这些快捷键本身就是知识点,我在视频里也会顺口报一遍按键,算是附赠。

注意:录制前一定清空桌面、关闭所有通知、退出所有即时通讯软件。我有一次录到第四十分钟,屏幕右下角弹出一条私人消息预览,那一段只能整段重录。现在我的流程是:开录前断网一次再连回(清掉离线通知),开启系统专注模式,桌面图标全部隐藏。

3. 核心知识点的拆解与演示方式

这一章是整套课程的主体,也是我改动最多的地方。每一个知识点我都问自己同一个问题:如果我是一个完全没接触过编程的人,看到这段代码,我的第一反应会是什么误解?然后我就专门针对那个误解去设计演示。

3.1 类型系统:把「值放哪」画出来,别用嘴说

值类型和引用类型是 C# 基础里第一个真正的坎。大部分教程的讲法是「值类型存在栈上,引用类型存在堆上」,讲完就过,学员记住了一句话,但遇到下面这段代码照样懵:

struct Point { public int X; public int Y; } class PointRef { public int X; public int Y; } static void Main() { Point a = new Point { X = 1, Y = 2 }; Point b = a; b.X = 99; Console.WriteLine(a.X); // 输出 1 PointRef c = new PointRef { X = 1, Y = 2 }; PointRef d = c; d.X = 99; Console.WriteLine(c.X); // 输出 99 }

同样的赋值写法,结果一个不变一个变。我在视频里的处理是:先在白板上画两个方框,左边一个方块写a,里面填 1 和 2;赋值给b的时候,是把 1 和 2 抄了一份放到新的方框里,所以改b不影响a。右边则画一个方框写「对象」,里面是 1 和 2,然后c和d两个方块各自画一个箭头指向同一个对象框,改d指向的内容,c看到的当然也变了。

这个图的价值在于,它把「复制」和「指向同一处」这两个概念区分开了。我见过太多人把b = a理解成「b 变成了 a 的别名」,这在引用类型里是对的,在值类型里完全错。

紧接着必须讲字符串,因为字符串是最大的一个例外:

string s1 = "hello"; string s2 = s1; s2 += " world"; Console.WriteLine(s1); // hello

字符串是引用类型,但表现像值类型。原因是不变性:任何看起来像修改的操作,实际上都是新建了一个字符串对象。我在视频里用了一个类比:字符串就像打印出来的纸,你想改一个字,不能拿橡皮擦(因为别人可能也在看这张纸),只能重新打印一张。这个类比的直接后果就是,在循环里拼接字符串会不断「重新打印」,性能很差,应该用StringBuilder。这一句我放在讲字符串的那一集,等到后面讲 LINQ 和性能时再回扣一次。

装箱和拆箱我只用一段代码讲完,不展开:

int n = 42; object boxed = n; // 装箱:把值搬进一个对象容器 int back = (int)boxed; // 拆箱:再取出来

讲法是:int是个小方块,object是个大箱子,装箱就是把小方块塞进大箱子,要拿出来还得先开箱再取。装箱本身不复杂,复杂的是「在循环里频繁装箱会导致大量临时对象」,这一点我只提一句,把具体影响留到讲集合性能的时候再算。

可空类型我是这么引入的:先问一个问题——「如果我要存一个年龄,但这个年龄我不知道,怎么办?」有人的直觉是设成 0,但 0 是个合法的年龄,会和「不知道」混淆。于是引出int?,一个能在值之上再加一个「空」状态的类型。这里顺手讲了HasValue和GetValueOrDefault,以及最常用的一行写法:

int? age = null; int realAge = age ?? 0; // 空值合并运算符

3.2 面向对象:扔掉 Animal 和 Dog,用能算钱的例子

我坚决不用「动物-狗-叫」这个经典例子。理由是它太顺从了,继承关系完美得像为教学量身定做,学员一看就懂,一用就废。真实的业务里,继承关系往往没那么清晰,硬套继承会把人绕进死胡同。

我用的例子是一个订单打折场景,从最朴素的一版开始写:

public class Order { public decimal Amount { get; set; } public decimal GetFinalAmount() { if (Amount >= 300) return Amount * 0.85m; if (Amount >= 100) return Amount * 0.95m; return Amount; } }

这段代码能跑,但问题很明显:折扣规则一变就得改这个类。第二集里我加了一条新需求——「新用户额外九折」——然后把代码改成这样:

public class Order { public decimal Amount { get; set; } public bool IsNewUser { get; set; } public decimal GetFinalAmount() { decimal result = Amount; if (result >= 300) result *= 0.85m; else if (result >= 100) result *= 0.95m; if (IsNewUser) result *= 0.9m; return result; } }

注意这里我故意踩了一个坑:新用户折扣是在满减之后乘上去的,如果反过来先乘 0.9 再判断满 300,可能就差几块钱导致不满足条件。我在视频里让学员自己先想想哪种顺序合理,然后我用三组数字现场算出来对比,一组Amount = 320、一组Amount = 305、一组Amount = 340,算完学员自己就看出来了。这个「用数据说话」的演示方式比讲一堆道理有效得多。

第三集才引出封装和多态:把折扣规则抽成一个接口,让不同的规则类去实现它。

public interface IDiscountRule { string Name { get; } decimal Apply(decimal amount); } public class AmountDiscount : IDiscountRule { public string Name => "满减"; public decimal Apply(decimal amount) => amount switch { >= 300m => amount * 0.85m, >= 100m => amount * 0.95m, _ => amount }; } public class NewUserDiscount : IDiscountRule { public string Name => "新客"; public decimal Apply(decimal amount) => amount * 0.9m; }

订单类变成一个规则列表的容器,按顺序跑一遍:

public class Order { public decimal Amount { get; set; } private readonly List<IDiscountRule> _rules = new(); public void AddRule(IDiscountRule rule) => _rules.Add(rule); public decimal GetFinalAmount() { decimal result = Amount; foreach (var rule in _rules) result = rule.Apply(result); return result; } }

这个演进过程我讲了三集,每一集都是被新需求逼出来的,不是我先讲完所有概念再让你用。学员走完这个过程,会自然理解为什么要有接口:不是因为它高级,而是因为每一次加需求都去改老代码,迟早改出 bug。

3.3 委托、事件和 Lambda:一条线串起来讲,不拆成三集

这三个东西在教材里通常分成三章,中间隔着好几集,等讲到事件的时候,委托已经忘光了。我的做法是压成一集,因为它本来就是一条演进线。

起点是个很具体的痛点:订单算完之后要通知别人,比如打日志、发消息。最笨的办法是在方法里直接调用:

public void Complete() { // 算账逻辑... Console.WriteLine("订单已完成"); }

问题是「通知」这件事以后会变,可能改成写文件、可能改成发消息,直接调用就把账算逻辑和通知逻辑焊死了。于是引出委托:委托就是「一个方法的占位符」,你可以把方法当参数传进来。

public delegate void OrderCompletedHandler(Order order); public class Order { public OrderCompletedHandler? OnCompleted; public void Complete() { // 算账逻辑... OnCompleted?.Invoke(this); } }

调用方:

var order = new Order(); order.OnCompleted = o => Console.WriteLine($"订单 {o.Amount} 已完成"); order.Complete();

到这里立刻讲一个坑,这个坑我在实际项目里踩过:如果有人给OnCompleted赋了两次值,第二次会覆盖第一次,第一个方法神不知鬼不觉地丢了。解决办法是把委托字段改成事件:

public event OrderCompletedHandler? OnCompleted;

改成事件之后,外部只能+=和-=,不能直接赋值,覆盖的问题就没了。我在视频里现场演示了「用字段时两次赋值只剩一个」和「用事件时两次订阅都在」的对比,学员看完基本不会再用错。

最后是 Lambda。o => Console.WriteLine(...)这个写法前面已经出现了,我在这一集才正式解释:=>左边是参数,右边是方法体,编译器会帮你生成一个匿名方法。顺带提了闭包的一个经典陷阱:

var actions = new List<Action>(); for (int i = 0; i < 3; i++) { actions.Add(() => Console.WriteLine(i)); } foreach (var a in actions) a(); // 现代 C# 输出 0 1 2

这里我必须强调版本差异:在早期版本的 C# 里,这段会输出三个 3,因为循环变量在循环结束后只有一个实例,所有闭包共享它。现代 C# 已经改成每次迭代创建新的变量,所以输出 0 1 2。我讲这一段的目的不是让学员记住哪个版本输出什么,而是让他知道「闭包捕获的是变量本身,不是变量的值」,这个原理在任何支持闭包的语言里都成立。

3.4 异步与并发:先讲餐厅,再上代码

async/await是初学者最容易「能抄会用但完全不懂」的一块。我的开场先不给代码,先讲一个场景:你去餐厅点菜,服务员下单之后,不会站在厨房门口等菜做好,而是转头去服务下一桌。等菜好了,厨房按单号叫他,他再去端。

对应关系我列得明明白白:服务员是线程,下单是把任务交给厨房,厨房是 I/O 操作(读文件、发请求),等菜好的过程里服务员去干别的,这就是「异步不阻塞」。

然后才上代码,用最简单的文件读取:

public async Task<string> ReadAllAsync(string path) { using var reader = new StreamReader(path); return await reader.ReadToEndAsync(); }

调用处:

string content = await ReadAllAsync("data.txt"); Console.WriteLine(content.Length);

第一个要讲的规则是:async方法返回Task或Task<T>,不是void。为什么?我用了一个很直白的解释:Task是一个「取货凭证」,调用方拿着凭证,可以在需要结果的时刻await它,也可以先干别的。如果返回void,调用方什么都拿不到,就没法知道什么时候完成、有没有出错。

第二条规则:async void只应出现在事件处理方法里,其它地方一律不要用。原因是async void里抛出的异常没法被调用方捕获,会直接导致程序崩溃,而且堆栈信息会很难看。这一条我在三个不同的集数里重复提了三次,因为它是实际项目里最常见的翻车点之一。

第三条规则:不要在async方法里用.Result或.Wait()去同步等待。这段代码我在视频里写了,然后现场运行,演示它在某些环境下会死锁。死锁的原因牵扯到上下文同步,基础篇我不深挖,只给结论和替代写法:一路await到底,不要中途卡住。

第四条规则:并发和并行的区别。我用了两个词来区分:并发是「同时有很多事在进行」,并行是「同一时刻真的有几个东西在同时干」。Task.WhenAll是并发地等待多个任务,它们是否真的并行,取决于底层有没有那么多执行单元。这条我只讲概念,不做线程调度实验,因为基础篇的目标是「不把程序写卡住」,不是「把性能榨干」。

3.5 集合与 LINQ:先把复杂度摆出来,再谈语法糖

集合这块,我给学员的第一张表不是 API 列表,而是复杂度表:

集合类型按索引取按内容找插入删除典型场景
List<T>快慢(要遍历)尾部快,中间慢有序、需要按下标访问
Dictionary<K,V>不适用极快快按唯一键查找
HashSet<T>不适用极快快去重、判断是否存在
Queue<T>不适用不适用两端操作快先进先出排队
Stack<T>不适用不适用两端操作快后进先出

这张表我强调了一句话:选错集合,代码能跑,但会慢得莫名其妙。我在视频里做过一次现场演示,往List里做一万次「判断是否存在」,再往HashSet里做同样的事,用计时器把耗时打在屏幕上。差距是肉眼可见的数量级。这种演示的说服力远大于口头说「Dictionary 更快」。

LINQ 部分我只讲六个高频算子:Where、Select、OrderBy、GroupBy、FirstOrDefault、Any。每一个都配一个「不用 LINQ 怎么写」的对照版本,让学员看到省了多少行。然后立刻讲延迟执行这个陷阱:

var query = list.Where(x => x > 10); list.Add(999); foreach (var x in query) Console.WriteLine(x);

这段代码里,query定义的时候并没有真的去筛选,直到foreach才开始遍历,而那时候列表已经被改了,999 也被筛进去了。我在视频里把这一句打在屏幕上:LINQ 查询是「计划」,不是「结果」。要立刻拿到结果,就加一个.ToList()。

4. 实操:一个贯穿全篇的示例项目

零散的示例代码看多了会散,所以我给整套课程配了一个从头写到尾的项目。它简单到不需要任何框架,但足够复杂到能串起前面所有知识点。

4.1 需求与演进路线

需求很朴素:一个记账小工具,能添加一笔支出、按类别汇总、按金额排序、把结果存到文件、再从文件读回来。

演进路线我拆成五步,每一步对应课程的一个阶段:

第一步,全部写在Main里,用数组存数据。目的是让学员体验「不用类也能写,但写着写着就崩了」。

第二步,抽出Record类型,用List<Record>存数据。这一步引入类、属性、集合。

第三步,把「存储」抽成接口,先实现一个内存版,再实现一个文件版。这一步引入接口和多态,也让学员第一次感受到「换实现不用改调用方」。

第四步,加一个「统计报告」功能,用 LINQ 做分组汇总。这一步引入GroupBy和聚合。

第五步,把文件读写改成异步,并加上异常处理。这一步引入async/await、try/catch/finally、using。

4.2 关键代码:存储接口的两种实现

接口定义得很克制,只有三个方法:

public interface IRecordStore { void Add(Record record); IReadOnlyList<Record> LoadAll(); void Save(); }

内存实现:

public class InMemoryStore : IRecordStore { private readonly List<Record> _records = new(); public void Add(Record record) => _records.Add(record); public IReadOnlyList<Record> LoadAll() => _records.AsReadOnly(); public void Save() { /* 什么都不做 */ } }

文件实现,用每行一条的纯文本格式,字段之间用竖线分隔:

public class FileStore : IRecordStore { private readonly string _path; private readonly List<Record> _records = new(); public FileStore(string path) { _path = path; if (File.Exists(_path)) _records.AddRange(ParseLines(File.ReadAllLines(_path))); } public void Add(Record record) => _records.Add(record); public IReadOnlyList<Record> LoadAll() => _records.AsReadOnly(); public void Save() { var lines = _records.Select(r => $"{r.Date:yyyy-MM-dd}|{r.Category}|{r.Amount}"); File.WriteAllLines(_path, lines); } private static IEnumerable<Record> ParseLines(IEnumerable<string> lines) { foreach (var line in lines) { var parts = line.Split('|'); if (parts.Length != 3) continue; if (!DateTime.TryParse(parts[0], out var date)) continue; if (!decimal.TryParse(parts[2], out var amount)) continue; yield return new Record { Date = date, Category = parts[1], Amount = amount }; } } }

这段代码里我重点讲了三个地方。

一是IReadOnlyList<Record>作为返回类型。为什么不直接返回List<Record>?因为返回List意味着调用方可以往里加东西,而调用方根本没有权限改存储。用只读接口表达「你可以看,但不能改」,这是接口设计的基本功。

二是文件格式的选择。我没用 JSON,虽然用现成的序列化库一行就能搞定。理由是要让学员亲手处理「分割字符串、解析日期、解析金额、处理坏数据」这一整套流程。用 JSON 一行搞定的话,这一整套解析和容错的思维就学不到了。等后面进阶篇再引入序列化库,那时候学员才知道库替他省了什么。

三是decimal而不是double。这个我专门用一集讲了:double是二进制浮点数,0.1 + 0.2不等于0.3;decimal是十进制,专门为金额设计。我在视频里现场打印了0.1 + 0.2的结果,屏幕上出现一长串 9 结尾的数字,学员一片哗然。这个演示的效果比任何解释都好。

4.3 统计报告:一段代码看清 LINQ 的价值

最后一步的功能是输出每个类别的合计:

public static void PrintReport(IReadOnlyList<Record> records) { var report = records .GroupBy(r => r.Category) .Select(g => new { Category = g.Key, Total = g.Sum(x => x.Amount), Count = g.Count() }) .OrderByDescending(x => x.Total); foreach (var row in report) Console.WriteLine($"{row.Category,-10} {row.Count,4} 笔 {row.Total,10:F2}"); }

这一集我的做法是:先把这段代码全部注释掉,让学员用最笨的foreach加Dictionary手写一遍,写完之后再把 LINQ 版本打开,对比行数。手写版本大概二十行,LINQ 版本十行出头,而且手写版本里那个「字典里没有这个 key 就先建一个初始值」的判空逻辑,是初学者最容易写错的地方。对比完之后,学员自然就理解 LINQ 到底在解决什么问题。

5. 常见问题与排查实录

这一章的素材几乎全部来自学员的提问和弹幕。我把出现频率最高的整理成了一张表,然后在视频里逐个演示怎么排查。

5.1 高频报错速查表

报错信息关键词真实原因处理方式
NullReferenceException对一个空引用调用了成员打开可空检查,顺着堆栈找到第一个空的地方
IndexOutOfRangeException下标等于或超过元素个数循环条件用< Count而不是<= Count
FormatException字符串转数字失败,比如输入了字母用TryParse代替Parse
InvalidOperationException: 集合已修改在foreach里增删了集合改用for倒序,或先ToList()再遍历
CS0161 并非所有代码路径都返回值有if分支没写return补全分支,或末尾加throw
CS0246 找不到类型或命名空间缺using或包没装用快速修复自动补using,再确认项目引用
IOException: 文件被占用上一次的流没关闭用using包裹StreamReader/StreamWriter
async 方法缺少 await方法标了async但没await检查是不是漏了await,或者该方法本不该是异步

这张表我建议初学者打印出来贴在屏幕边上。不是为了背,而是为了在报错的时候知道去哪个方向找,而不是慌乱地到处改。

5.2 我自己踩过的三个坑,值得单独说

第一个坑是集合修改。我当年写第一版报表功能,代码长这样:

foreach (var r in records) { if (r.Amount < 0) records.Remove(r); // 直接抛异常 }

运行时直接抛出「集合已修改」。当时的我完全不懂为什么,明明只是删了一个元素。后来才明白:foreach背后拿的是一个枚举器,枚举器会记录一个版本号,你改了集合版本号就变了,枚举器发现版本不一致就立刻报错,防止你拿到未定义的结果。修法有两种,一种是倒序for:

for (int i = records.Count - 1; i >= 0; i--) { if (records[i].Amount < 0) records.RemoveAt(i); }

倒序的原因是删除元素会让后面的元素整体前移,正序遍历会跳过元素。第二种是先筛再赋值:

records = records.Where(r => r.Amount >= 0).ToList();

我在视频里推荐第二种,因为它意图更明确,也不容易写错下标。

第二个坑是文件占用。早期我写文件保存是这么写的:

var writer = new StreamWriter(path); writer.WriteLine("..."); // 忘了 writer.Dispose()

结果第二次保存时抛异常说文件被占用。原因是StreamWriter持有一个文件句柄,不释放的话系统层面就一直锁着。修法是用using:

using (var writer = new StreamWriter(path)) { writer.WriteLine("..."); }

或者用 C# 8 之后的简化写法,作用域结束时自动释放:

using var writer = new StreamWriter(path); writer.WriteLine("...");

我在这一集专门讲了IDisposable接口的意义:它就是一个「我需要你手动打扫」的标记,看到实现了这个接口的类型,用using包起来准没错。

第三个坑是日期解析的隐式文化差异。DateTime.Parse("03/04/2024")到底是三月四号还是四月三号?取决于当前系统的区域设置。这个坑我在给一个跨地区团队做培训时被问到过,同一个程序在两台机器上跑出不同结果,查了半天才发现是这个原因。解决办法是显式指定格式和文化:

DateTime.ParseExact("2024-03-04", "yyyy-MM-dd", CultureInfo.InvariantCulture);

注意:凡是涉及日期、数字格式化的地方,只要数据要跨机器、跨地区流转,就必须显式指定格式和文化,不能依赖系统默认。这一条我在课程里重复了不止一次,因为它的表现形式太隐蔽——程序不报错,只是结果不对。

5.3 给自学者的一条排查方法论

最后分享一个我自己一直在用的排查思路,比任何具体的报错表都管用。

遇到问题,先做减法。把出问题的代码从大程序里抠出来,放到一个全新的、只有十几行的控制台项目里,能复现就说明问题在这十几行里,不能复现就说明问题在环境或者上下文里。这个动作看起来笨,但它把「无限大的搜索空间」压缩成了「有限的小空间」。

然后是二分。如果十几行还是复现不了,那就把可疑代码分两半,注释掉一半再跑,看问题还在不在,一次砍掉一半,最多七八次就能定位到具体的行。

最后是读堆栈。堆栈信息是从下往上读的:最底下是程序入口,最上面是真正出错的那一行。很多初学者只看最上面一行就开始猜,其实真正的线索往往在中间几层,那里会告诉你「是谁调用了出错的这个方法」。

我用这套方法排查过最久的一个 bug 花了三个小时,最后发现是一个int除法被截断了小数。如果一开始就把计算部分抠出来单独跑一遍,五分钟就能发现。这就是为什么我现在写任何涉及除法的代码,都会下意识地把其中一个操作数转成decimal或double。

整套课程录完,我自己最大的收获不是把 C# 语法又过了一遍,而是被迫想清楚了每一个知识点「为什么是这样」。很多概念我以前是「会用但说不清」,为了录视频必须说清,才发现有些地方我自己的理解也是模糊的。比如装箱对性能的影响到底有多大、闭包捕获的到底是变量还是值、async方法里异常是怎么传递的,这几个问题我都重新查了文档、写了验证代码,才敢在视频里下结论。如果你也在准备类似的内容,我的建议是:别急着讲,先写一段最小验证代码跑一遍,屏幕上出现的真实结果,永远比记忆里的结论可靠。至于后面还能往下走多远,我个人的打算是先把这个记账小工具改成带界面的版本,让那批跟着写完控制台的学员,能看到自己写的东西真正变成一个能点、能看的程序,那个瞬间的成就感,比再多讲十个语法点都有用。