做 C# 教学视频这件事,最难的从来不是知识点本身,而是“基础篇”这三个字背后的取舍。录一套C#视频——基础篇,你得面对一个很现实的问题:C# 这门语言从 2002 年走到今天,语法糖堆了二十多年,基础篇的边界到底划在哪儿?划宽了,新手看到泛型约束、协变逆变、表达式树直接劝退;划窄了,学完连一个能跑的小工具都写不出来,观众回头还得再找一套视频补课。我这几年帮人梳理过不少入门课程的结构,也自己踩过“讲太全没人看得下去、讲太浅没人用得上”的两头坑。这篇就把我整理 C# 基础内容时的思路、知识点拆解、环境实操和踩坑记录全部摊开讲一遍,适合两类人看:一类是准备系统学 C# 的初学者,想少走弯路;另一类是已经会写点代码、但基础不牢靠,想回头把地基重新夯一遍的开发者。下面所有示例都可以直接敲进 IDE 跑,不用配任何额外依赖。
1. 基础篇的边界怎么划:先定主线再谈内容
1.1 为什么大多数 C# 入门内容容易变成语法罗列
我见过太多所谓的“基础篇”,打开目录一看,从变量、运算符、if-else 一路排到文件 IO、多线程,整整四十多集,每集讲一个语法点,示例是Console.WriteLine("Hello")这种孤立的片段。这种结构的致命问题在于:观众学完第五章,第一章的东西已经忘干净了,因为知识点之间没有咬合关系,它们像散落一地的零件,缺一张装配图。
真正决定基础篇质量的,是你有没有一条贯穿始终的工程主线。我的做法是选一个足够小、但五脏俱全的目标程序——比如一个“命令行记账小工具”,支持录入、查询、统计三类操作。这个目标的好处是:它天然需要数据结构(存账目)、需要集合(存多条)、需要类(封装一条记录)、需要方法(拆分逻辑)、需要异常处理(用户输入乱敲)、需要一点点 LINQ(按月份汇总)。所有基础知识点都能挂到这条线上,讲一个用一个,观众始终知道“我学这个是为了解决哪个具体问题”。
选主线的判断标准我总结了三条,你可以直接拿去用:
- 可完成性:一个完全的新手跟着做,两三个小时能跑出第一个可交互版本,这个正反馈太重要了。
- 可扩展性:主线做完之后,能自然地往上加东西,比如加持久化、加排序、加导出,后续进阶篇能接上。
- 无外部依赖:不要一上来就接数据库、接网络请求、接第三方 UI 框架,环境越复杂,第一天劝退率越高。
提示:主线项目不要贪大。我试过用“做一个完整的库存管理系统”当主线,结果第二集就得引入分层架构和依赖注入,基础篇直接被撑爆。后来砍到记账工具这个量级,节奏才顺。
1.2 一条能串起全部知识点的主线设计
我把基础篇拆成四段递进,每一段结束时观众都能得到一个“可运行、可演示”的产物,而不是一堆笔记。
第一段叫让程序动起来:装环境、建项目、认识入口方法、会打印、会读输入。这个阶段不讲任何抽象概念,只求跑通。产物是一个能问名字并回一句问候的控制台程序。
第二段叫让程序存住数据:变量、基本类型、类型转换、字符串拼接与格式化、数组。产物是能把用户输入的三笔账存下来并原样打印。
第三段叫让程序有条理:类与对象、属性、方法、访问修饰符、构造与初始化、集合。产物是把账目封装成对象,用List<T>管理,并支持增删查。
第四段叫让程序可靠:异常处理、调试技巧、简单接口与继承、一点点 LINQ 汇总。产物是能扛住乱输入、能按条件统计的完整小工具。
这个分段的妙处在于,每一段的知识密度是递增的,但抽象程度提升得很平缓。观众在第二段还只会写int和string,到第四段已经能写出class Account { public decimal Amount { get; set; } }并解释为什么要用decimal而不是double,进步曲线是肉眼可见的。
再补一句关于时长的经验:单集控制在 12 到 18 分钟最舒服。短于 10 分钟讲不完一个完整的小闭环,观众刚进入状态就结束了;长于 20 分钟,注意力断崖式下滑,尤其是讲到内存模型这种抽象内容时。我一般会在一集里塞一个“明确的小胜利”,比如这集结束时你的程序能按金额排序了,这个即时反馈比任何激励话术管用。
2. 环境准备:把第一天劝退率降到最低
2.1 工具链的三种组合与选型逻辑
新手卡在环境配置上的概率,远高于卡在语法上。我统计过自己带过的人,第一天出问题的十有八九是 SDK 版本混乱、路径没配、IDE 装了一半。所以环境这一段的讲法必须极其具体,不能只丢一句“去官网下载安装”。
目前 C# 开发的工具组合主要有三种,选哪个取决于你后续想做什么:
| 组合 | 组成 | 适合人群 | 主要取舍 |
|---|---|---|---|
| 轻量组合 | .NET SDK + VS Code + C# 扩展 | 想理解命令行、想跨平台、机器配置一般 | 需要手动装扩展,调试体验略逊 |
| 全家桶组合 | .NET SDK + Visual Studio 系列 IDE | 纯 Windows 环境、想开箱即用 | 安装包大,首次安装耗时较长 |
| 命令行优先 | 仅 .NET SDK + 任意编辑器 | 想彻底搞懂项目结构和构建流程 | 前期命令多,但理解最深 |
我的建议是:先用全家桶把第一课跑通,建立信心;第二周切到命令行,强迫自己理解dotnet new、dotnet build、dotnet run到底干了什么。这两个阶段都走一遍,你对项目的掌控感会完全不一样。只会点 IDE 上那个绿色三角的人,遇到项目加载失败基本就束手无策了。
关于版本,这里有个坑必须提前说:不要装预览版 SDK。新手最怕的就是“网上教程说这样写,我这儿报错”,而预览版的语言特性和行为经常和稳定版不一致。装当前最新的长期支持版(LTS)就行,一个版本装完别再动它。
2.2 从零到第一个可运行项目
假设 SDK 已经装好,打开终端,依次执行下面几行命令。我故意把它拆开写,是为了让你看清楚每一步产出什么。
# 确认 SDK 可用,输出应为一串版本号 dotnet --version # 新建控制台项目,-o 指定输出目录名 dotnet new console -o HelloApp # 进入目录 cd HelloApp # 运行 dotnet run跑完之后终端应该输出一行问候语。到这里,你的环境就是通的。
接下来把项目目录打开看一眼。你会看到两个核心文件:一个以.csproj结尾的项目文件,一个Program.cs。.csproj是项目的大脑,长这样:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> </Project>这几个节点值得花两分钟解释清楚,因为后面所有项目都会碰到:
OutputType为Exe表示这是一个可执行程序,改成Library就变成类库,别人引用你而不是直接运行你。TargetFramework决定你能用哪些语法和 API。net8.0意味着可以用到对应的 C# 版本特性,如果写成netstandard2.0,一些新语法就用不了。ImplicitUsings打开后,常用的命名空间会自动引入,Console、List<T>这些不用手写using。方便,但也容易让新手搞不清类型从哪来,所以我通常建议讲基础篇时先关掉它,强制每个文件写全using,对理解命名空间有好处。Nullable是空引用检查开关,它会让你在可能为 null 的地方收到警告而不是运行时崩溃。新手看到一堆黄色波浪线会慌,但这是好事,我后面会专门讲。
注意:
ImplicitUsings和Nullable这两个开关我建议在基础篇阶段保持disable,等观众把命名空间和空值概念吃透了再打开。否则遇到问题时,他们连“这个类型到底在哪定义的”都查不出来,排查成本翻倍。
2.3 读懂入口:一行代码背后的运行链路
默认生成的Program.cs现在用的是顶层语句写法,可能就两三行。这写法很甜,但作为基础篇,我必须让你知道它在底层到底翻译成了什么。等价的手写形式是:
using System; namespace HelloApp { internal class Program { static void Main(string[] args) { Console.WriteLine("Hello, World!"); } } }这么一展开,几件事就清楚了:程序不是凭空执行的,运行时需要一个入口点,也就是那个static void Main(string[] args)。static意味着不用创建对象就能调用;void意味着不返回东西;string[] args是命令行参数,你从终端带参数启动时,它们会进到这个数组里。
这套解释不是学院派的洁癖。我遇到过太多人,项目里出现两个Main方法就不知所措,报“程序有多个入口点”的错误。知道入口点这个概念之后,你立刻就能反应过来:两个类里各写了一个Main,编译器不知道从哪进,删掉一个或显式指定就行。
顺便说下顶层语句和Main的关系:编译器会把顶层语句自动包进一个隐式的Main方法里。所以同一项目里,你不能既有顶层语句又手写Main,那就是两个入口点。这个细节在视频里用一句话点出来,能省掉观众后面半小时的困惑。
3. 类型系统:基础篇最该下狠功夫的一层
3.1 值类型与引用类型:一个类比讲到底
C# 的类型系统分成两大阵营,这是整门语言最重要的分水岭,也是新手最容易糊弄过去、后面付出惨重代价的地方。我的讲法是一个快递类比:
值类型就像寄现金——你把钱塞进信封,信封里装的是钱本身。你把信寄给别人,别人拆开拿到的是属于他自己的那笔钱,他撕着玩也好、存起来也好,跟你手里那笔没有任何关系。
引用类型就像寄地址卡片——你寄的只是一张写着“某小区某栋”的卡片。两个人拿着同一张地址卡片,跑去同一个房间搬东西,一个人把桌子搬走了,另一个人再去就扑空了。
对应到代码上:
// 值类型:赋值即复制 int a = 10; int b = a; b = 20; Console.WriteLine(a); // 输出 10,互不影响 // 引用类型:赋值即复制地址 int[] x = { 1, 2, 3 }; int[] y = x; y[0] = 99; Console.WriteLine(x[0]); // 输出 99,因为指向同一块数据这两段代码的效果差异,如果你不讲透,新手一定会在“为什么我改了这个数组,另一个也变了”这个问题上卡住。而一旦理解了,很多后续问题会自己解开:为什么方法传参时,传数组进去改了内容,外面能看到变化?为什么传int进去改了,外面没变化?
这里有个补充点值得单独说:方法参数传递默认是“按值传递”,但这个“值”对值类型而言是数据本身,对引用类型而言是地址。所以传数组进去,传的是地址的副本,副本和原件指向同一块数据,改了内容两边都能看到;但如果你在方法内部把形参重新赋值为一个新数组,外面的引用不受影响。这个区分我建议视频里用两个例子对照演示,比讲概念有效得多。
3.2 装箱拆箱与字符串不可变:两个隐形性能陷阱
装箱(boxing)这个词听起来吓人,其实过程很简单:把一个值类型的数据塞进一个引用类型的“盒子”里。比如把int赋给object:
int number = 42; object boxed = number; // 装箱:在堆上分配一个盒子,把 42 复制进去 int unboxed = (int)boxed; // 拆箱:把盒子里的值复制出来单个操作开销微乎其微,问题在于这个操作发生在循环里。我见过一段统计代码,循环十几万次往一个ArrayList里塞整数,每次塞都装箱,最后性能比用List<int>慢了一个数量级。这就是为什么泛型集合值得在基础篇就引出——List<int>里存的是真正的int,不装箱。
字符串的情况更微妙。string是引用类型,但它是不可变的:任何看似“修改”的操作,实际上是新建了一个字符串。看这段:
string s = "hello"; string t = s; s = s + " world"; Console.WriteLine(t); // 输出 hello,因为 t 还指着原来那个字符串s = s + " world"看起来是改了s,实际上是在内存里造了个新字符串,然后让s指向它。原来那个"hello"还躺在那儿,被t指着。这在循环拼接时就是灾难:
// 反面例子:循环里反复拼接,每次都产生新字符串 string result = ""; for (int i = 0; i < 1000; i++) { result += i.ToString(); } // 正确做法:用 StringBuilder var sb = new System.Text.StringBuilder(); for (int i = 0; i < 1000; i++) { sb.Append(i); } string result2 = sb.ToString();一千次循环的差距可能感觉不出来,但十万次就是几百毫秒级别的差别。基础篇不需要讲内存分配模型,但“循环里别拼字符串,用 StringBuilder”这一条,属于必须提前告知的工程习惯,讲一次能省掉观众日后无数次性能排查。
3.3 可空类型与默认值:让编译器帮你盯住空引用
空引用异常大概是所有 C# 新手遇到最多的运行时崩溃。语言层面给了两个工具:可空值类型和可空引用类型。
可空值类型解决的是“值类型不能为 null”的问题。int默认值是 0,但业务上“数量未知”和“数量是 0”完全是两回事。这时用int?:
int? stock = null; if (stock.HasValue) { Console.WriteLine($"库存 {stock.Value}"); } else { Console.WriteLine("库存未知"); } // 也可以用空合并运算符给兜底值 int display = stock ?? 0;??这个运算符很实用,意思是“左边是 null 就用右边”。配合?.(空条件运算符)使用更顺手:
string name = null; int length = name?.Length ?? 0; // name 为 null 时不抛异常,直接得 0可空引用类型则是编译期的一个开关,打开后,声明为string的变量被当作“不应该为 null”,声明为string?的被当作“可能为 null”,编译器会在你忘记检查的地方给警告。这个机制不改变运行时行为,纯粹是静态提醒。
我给初学者的建议是:前期别急着打开这个开关,但一定要知道它存在。等你写过几个小项目、被空引用坑过两三次之后,再打开它,你会非常感激那些黄色波浪线。因为那时候你已经能看懂它在提醒什么了。
4. 面向对象:别一上来就讲三大特性
4.1 从“数据和方法分家”的痛苦讲起
传统教材讲面向对象,上来就是封装、继承、多态三大特性,配一个“动物都有叫声”的例子。这套讲法的问题在于,观众不知道不用类会怎样,所以感受不到类的价值。
我改用的切入点是先展示痛苦。假设要管理三条账目,不用类的写法是这样:
string[] titles = new string[3]; decimal[] amounts = new decimal[3]; DateTime[] dates = new DateTime[3];三个平行数组,靠下标关联。这时候要加一个“备注”字段,你就得再开一个数组;要按金额排序,你得同时排三个数组还得保证下标对齐,稍微写错一行就数据错位。这个痛点演示出来,观众自己就会想:能不能把一条账目的三个字段捆在一起?——类就自然出场了。
public class Account { public string Title { get; set; } public decimal Amount { get; set; } public DateTime CreatedAt { get; set; } public override string ToString() { return $"{CreatedAt:yyyy-MM-dd} {Title} {Amount:C}"; } }{ get; set; }这种写法叫自动属性,等价于一个私有字段加一对读写方法。它把“字段”藏起来,只暴露受控的入口,这就是封装最朴素的价值:你可以随时在set里加校验,而所有调用方代码一行都不用改。
private decimal _amount; public decimal Amount { get => _amount; set { if (value < 0) throw new ArgumentException("金额不能为负"); _amount = value; } }这个改造演示一遍,观众立刻就懂“为什么属性比公开字段好”。比空讲封装原则有效十倍。
4.2 继承与接口:什么时候该用哪个
继承和接口是新手最迷惑的一对概念。我的经验是别急着讲定义,先讲他们分别解决什么问题。
继承解决的是“是一类东西”的问题,而且是为了复用已有实现。比如你已经有了Account,现在需要一种“带分类的账目”,它拥有Account的全部字段和行为,只是多一个Category,那就继承:
public class CategorizedAccount : Account { public string Category { get; set; } }接口解决的是“能做某件事”的问题,它只规定必须提供哪些成员,不提供实现。比如“可导出”这件事,账目可以导出,报表也可以导出,它们没有血缘关系,但都得实现同一个方法签名:
public interface IExportable { string Export(); } public class Account : IExportable { // ... 其他成员 public string Export() => $"{Title},{Amount},{CreatedAt:yyyy-MM-dd}"; }我的经验法则是这样,可以直接抄:
| 场景 | 选择 | 理由 |
|---|---|---|
| 子类型确实是父类型的一种,且需要复用父类实现 | 继承 | 复用代码,语义自然 |
| 多个不相关的类型需要提供同一组能力 | 接口 | 解耦,便于替换实现 |
| 只是想把公共字段抽出来 | 优先组合,其次继承 | 继承耦合太强,容易僵化 |
| 需要多态替换(同一调用不同行为) | 接口优先 | 一个类只能继承一个父类,但能实现多个接口 |
我在实际项目里的体会是:继承用得越少,代码越不容易腐化。真正需要继承的场景比教科书暗示的少得多。基础篇讲继承,重点应该放在“看得懂别人的继承结构”,而不是“鼓励你多用继承”。
4.3 record 与不可变对象:一个现代写法的补充
如果视频面向的是较新的语言版本,record值得花三分钟讲一下,因为它能用一行代码替代几十行样板:
public record AccountRecord(string Title, decimal Amount, DateTime CreatedAt); // 使用 var a = new AccountRecord("午饭", 25.5m, DateTime.Now); var b = a with { Amount = 30m }; // 基于 a 复制并改一个字段 Console.WriteLine(a == b); // False,record 默认按值比较record的三个特点和普通类比:按值比较(两个对象字段全等就相等)、自带可读的打印格式、支持with表达式做非破坏性修改。对于“账目”这种天然适合当数据传输载体的东西,record比class更贴切。
不过我不建议在基础篇一上来就用record。先让观众用class手写一遍属性、构造函数、ToString,体会一下样板代码的繁琐,再展示record如何一键消除这些样板,那个“啊,原来可以这么省事”的瞬间,才是学习的钩子。顺序反了,就变成魔法了。
5. 集合、泛型与 LINQ:从能跑到好用
5.1 常用集合的选型对照表
数组是第一个容器,但它长度固定,增删元素极其别扭。这时候引出List<T>就非常自然:它是可变长数组,尾部追加是常数时间,按下标访问也快。
var accounts = new List<Account>(); accounts.Add(new Account { Title = "午饭", Amount = 25.5m, CreatedAt = DateTime.Now }); accounts.Add(new Account { Title = "打车", Amount = 18m, CreatedAt = DateTime.Now }); foreach (var item in accounts) { Console.WriteLine(item); }除了List<T>,基础篇还应该点到几个高频容器,不然观众遇到真实需求时会瞎选:
| 容器 | 特点 | 典型用途 | 注意点 |
|---|---|---|---|
T[]数组 | 长度固定,访问最快 | 数量确定的批量数据 | 增删要重建 |
List<T> | 可变长,尾部追加快 | 绝大多数顺序集合场景 | 头部插入慢 |
Dictionary<K,V> | 按键查找接近常数时间 | 需要按 ID 快速定位 | 键必须唯一 |
HashSet<T> | 自动去重,查找快 | 判断“是否存在” | 无序 |
Queue<T>/Stack<T> | 先进先出 / 后进先出 | 任务排队、撤销栈 | 只能从一端操作 |
Dictionary的用法值得单独演示一次,因为“按 ID 查”是真实业务里出现频率最高的操作:
var byId = new Dictionary<int, Account>(); byId[1] = new Account { Title = "午饭", Amount = 25.5m, CreatedAt = DateTime.Now }; // 安全取值,避免键不存在时抛异常 if (byId.TryGetValue(2, out var found)) { Console.WriteLine(found); } else { Console.WriteLine("没找到"); }TryGetValue这种“尝试型”方法在 .NET 里是个通用范式,int.TryParse、Dictionary.TryAdd都是同一个思路:用返回值告诉你成没成功,用 out 参数把结果带出来,不靠抛异常做流程控制。这个范式点透一次,观众以后看文档会顺畅很多。
5.2 泛型:为什么List<int>比ArrayList靠谱
泛型的价值我用一句话概括:让类型成为参数,从而在编译期就抓住类型错误,同时避免装箱。对比一下就非常直观:
// 非泛型:什么都能塞,取出来要强转,塞整数会装箱 var oldList = new System.Collections.ArrayList(); oldList.Add(1); oldList.Add("字符串"); // 编译不报错 int v = (int)oldList[1]; // 运行时炸 // 泛型:类型确定,编译期就拦住 var newList = new List<int>(); newList.Add(1); // newList.Add("字符串"); // 编译直接报错泛型不只是“集合用起来更爽”,它还能自己定义。基础篇可以给一个极简的通用方法示例:
public static T Max<T>(T a, T b) where T : IComparable<T> { return a.CompareTo(b) >= 0 ? a : b; }where T : IComparable<T>是泛型约束,意思是“T 必须能和自己比较大小”。没有这个约束,编译器不保证T有CompareTo方法,就编译不过。这个约束的讲解价值很高,它让观众第一次意识到泛型不是万能胶,它需要契约。
5.3 LINQ:把循环里的小心思抽出来
LINQ 是我认为基础篇里“投入产出比最高”的一个知识点。它让你用接近自然语言的链式写法代替手写循环,而且延迟执行的特性有很多实用场景。
先看一个手写循环的版本:
var bigAccounts = new List<Account>(); foreach (var a in accounts) { if (a.Amount > 20m) { bigAccounts.Add(a); } } bigAccounts.Sort((x, y) => y.Amount.CompareTo(x.Amount));再看 LINQ 版本:
var bigAccounts = accounts .Where(a => a.Amount > 20m) .OrderByDescending(a => a.Amount) .ToList();三行业务意图一目了然:筛出大于 20 的、按金额降序、转成列表。这就是 LINQ 最大的价值——它把“怎么做”的细节藏起来,让“做什么”浮出来。团队协作中,可读性的提升远超过那一点点性能开销。
延迟执行是必须讲的一个点。Where本身不执行筛选,它只是构造了一个“待办清单”,直到你遍历它或者调用ToList()、Count()这类终结操作时才真正跑一遍:
var query = accounts.Where(a => a.Amount > 20m); // 此时什么也没发生 accounts.Add(new Account { Title = "新账目", Amount = 100m, CreatedAt = DateTime.Now }); foreach (var a in query) // 此刻才执行,而且能看到刚加的那条 { Console.WriteLine(a.Title); }这个特性在循环里反复遍历同一个查询时会导致重复计算,所以我在实操中的习惯是:只要这个查询结果要用不止一次,立刻.ToList()落地。
6. 委托、事件与异步:基础篇的“够用版”
6.1 委托就是“能把方法当参数传”
委托这个词很唬人,本质简单:它是一种类型,这个类型的变量可以装一个方法。有了它,方法就能像数据一样被传递。
// 定义一个委托类型:接收两个 int,返回 int public delegate int Calculator(int a, int b); // 把方法赋给委托变量 Calculator add = (a, b) => a + b; Console.WriteLine(add(3, 5)); // 输出 8实际开发中很少自己声明委托类型,因为框架内置了Action(无返回值)和Func(有返回值)两个泛型委托,够用绝大部分场景:
Func<int, int, int> multiply = (a, b) => a * b; Action<string> log = msg => Console.WriteLine($"[日志] {msg}"); log($"结果 {multiply(3, 5)}");把委托当参数传,是实现“可插入逻辑”的关键手段。比如给账目写一个通用筛选方法:
public static List<Account> Filter(List<Account> source, Func<Account, bool> predicate) { var result = new List<Account>(); foreach (var item in source) { if (predicate(item)) result.Add(item); } return result; } // 调用时逻辑随传随变 var big = Filter(accounts, a => a.Amount > 20m); var recent = Filter(accounts, a => a.CreatedAt > DateTime.Today);事件则是委托的一种受控封装:它只允许外部“订阅”和“退订”,不允许外部直接触发。基础篇点到“+=是订阅、-=是退订”就够了,不必深挖。
6.2 async/await 的最小可用模型
异步编程在基础篇不该展开,但完全躲开也不行,因为很多常用 API 都是异步的。我给初学者准备的解释只有一个类比:点外卖。
同步调用就像你站在店里等着厨师做完,期间什么都干不了。异步调用就像你下完单拿到取餐号,先去干别的,餐好了叫号。代码里的await就是“去干别的,等好了再回来”的那个点。
public static async Task<string> FakeFetchAsync() { await Task.Delay(1000); // 模拟耗时操作 return "数据已就绪"; } // 调用方 async Task RunAsync() { Console.WriteLine("开始请求"); string result = await FakeFetchAsync(); Console.WriteLine(result); }几个必须点明的规则:
async修饰符本身不创建线程,它只是让方法里能用await。- 方法签名返回
Task或Task<T>,而不是void(事件处理器除外)。 await之后的代码会在操作完成后继续执行,但它不一定在原线程上。- 不要写
.Result或.Wait()去同步阻塞一个异步方法,容易死锁,这是新手最常见的坑之一。
注意:基础篇讲异步,目标是“看得懂、敢用”,而不是“会用并发控制”。把
async Task和await的组合讲到能照着抄,剩下的等进阶篇再说。硬要讲ConfigureAwait、同步上下文,只会让初学者彻底迷失。
7. 异常处理与调试:最容易被跳过的一章
7.1 异常不是用来做流程控制的
新手对待异常有两个极端:要么一个try-catch都不写,程序一崩就傻眼;要么把整段代码包进try,然后catch { }吞掉一切,出了问题什么信息都没有。
正确的姿势是分层次处理。能预料到的错误用返回值表达,真正的意外才用异常。
// 可预期:用户输入不是数字,用 TryParse 表达失败 if (!decimal.TryParse(input, out var amount)) { Console.WriteLine("请输入有效金额"); return; } // 真正的异常:参数违反契约,抛出并携带信息 public void AddAmount(decimal amount) { if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount), "金额必须大于 0"); // ... }捕获时也要讲究,别一股脑接住所有类型:
try { AddAmount(-5); } catch (ArgumentOutOfRangeException ex) { Console.WriteLine($"参数有问题:{ex.Message}"); } catch (Exception ex) { Console.WriteLine($"未预期的错误:{ex}"); throw; // 记完日志后重新抛出,不要悄悄吞掉 }几个实操心得值得单独强调:
throw;和throw ex;完全不同。前者保留原始堆栈,后者会把堆栈清空,排查时就看不出错在哪一行了。- 不要在循环内部写
try-catch,异常开销远大于判断。循环外捕获,循环内用条件判断兜住常见情况。 - 自定义异常类要继承
Exception并保留标准构造函数,别搞成只有无参构造的怪胎,序列化和日志工具会不适配。
7.2 断点和监视:比打印日志高效十倍
Console.WriteLine打天下是新手常态,我理解,但它有天花板:加了十几行打印之后,输出混在一起根本分不清哪行对应哪步。
调试器的价值在这里。核心操作就三个:
第一个是普通断点,在行号左边点一下出现红点,程序跑到这行暂停。此时把鼠标悬停在任意变量上就能看到当前值,比打印快得多。
第二个是条件断点,右键断点设置条件,比如a.Amount > 100,只在你关心的数据上停下来。处理几千条数据的循环时,这个功能能救命。
第三个是监视窗口,把关心的变量或表达式加进去,逐行执行时实时看值变化。排查“这个变量到底在哪一步被改坏了”特别有效。
再补一个我非常推荐的技巧:调试时打开“异常设置”,勾选在抛出时中断。默认情况下,异常只有在没人捕获时才让程序停下,被捕获的异常悄无声息。勾上之后,任何异常抛出的瞬间就断下来,你能直接看到现场,很多“不知道为什么结果不对”的问题当场就明了了。
8. 常见问题速查与练习设计
8.1 新手高频报错对照表
下面这张表是我这几年被问得最多的问题汇总,基本都是基础阶段反复出现的:
| 现象或报错 | 根本原因 | 解决思路 |
|---|---|---|
| 找不到类型或命名空间 | 少写了using,或项目未引用对应程序集 | 补using,检查.csproj引用 |
| 空引用异常 | 对象还没创建就访问成员 | 创建实例,或用?.和??兜底 |
| 索引超出范围 | 下标等于或大于元素个数 | 用Count或Length判断边界 |
| 集合已修改,无法继续枚举 | foreach里增删元素 | 改用for倒序遍历,或先收集再统一处理 |
| 金额算出来有小数误差 | 用了double存金额 | 改用decimal,字面量加m后缀 |
| 中文输出乱码 | 终端编码与文件编码不一致 | 统一为 UTF-8,检查控制台代码页 |
| 程序有多个入口点 | 多个类里写了Main,或既有顶层语句又有Main | 删掉多余的,或在项目文件里指定StartupObject |
| 修改了数组内容,原数组也变了 | 引用类型赋值复制的是地址 | 需要独立副本时用ToArray()或Clone() |
关于“金额用 decimal”这条,我想多说两句。很多人觉得差几分钱无所谓,直到写对账逻辑时发现0.1 + 0.2不等于0.3,对不上账才开始查。浮点数在二进制里无法精确表示某些十进制小数,这是原理性的,不是精度设置问题。涉及钱和需要精确小数位的场景,一律decimal。
8.2 练习题的分级设计
光看不练等于没学。我给基础篇配套的练习题分成三档,每档都对应明确的产出:
第一档,改代码。把示例程序改个方向,比如把记账工具改成读书打卡记录,字段从金额换成页数。目标是让手熟悉语法,不用从头设计。
第二档,加功能。在现有程序上增加一个功能,比如“按月份汇总支出”“导出为文本文件”“支持删除某条记录”。这一档最能练到真实能力,因为涉及读现有代码、找插入点、考虑边界情况。
第三档,从零写。给一个全新需求,独立完成。比如“写一个命令行通讯录,支持增删改查和模糊搜索”。这一档不用给答案,给需求清单和验收标准就行,剩下的让人自己踩。
我特别推荐第二档多布置几道,因为它最接近真实工作:你极少从零开始一个项目,绝大多数时候是在别人写好的代码上加东西。
8.3 学完之后往哪走
基础篇结束,我建议的下一步不是立刻去学框架,而是先把格式化输出、文件读写、简单单元测试这三样补上。理由很实在:格式化输出让你的程序结果好看;文件读写让你的数据不会一关就没;单元测试让你改代码时心里有底。这三样都不涉及新概念,纯粹是把已有知识用熟,但带来的“能做出东西”的感觉,是支撑你继续往下走的关键动力。
至于后面走哪个方向,取决于你的目标:想做桌面应用就去看 UI 框架,想做后端就看 Web 框架,想做游戏就看对应的引擎。但不管哪条路,基础篇这些内容——类型、类、集合、委托、异常——都会以各种形式反复出现。我踩过最深的坑就是当年觉得“基础太简单,快点跳到框架”,结果后面每遇到一个诡异的空引用、一个集合遍历报错,都得回头翻书。基础这层,慢就是快。