第一次看到“NET 5”这个版本号时,我愣了好一会儿:之前玩.NET Core 3.1玩得好好的,怎么突然就跳到5了?中间那个4去哪了?再往后看,C# 8.0的语法特性刚学会,C# 9.0就来了,连版本对应关系都容易记混。如果你正在学C# 8.0和NET 5的高级编程,这一篇笔记就是给你准备的——不聊炫技,专聊你真正要在代码里落地的编程基础。
既然是笔记系列的第二篇,环境搭建和跑通Hello World就不再重复了。这一篇直接把C# 8.0和.NET 5底下的核心基础拆开揉碎:语言新特性怎么用、项目模型变成了什么样、值类型和引用类型在内存里到底怎么回事、async/await背后的线程模型是什么,最后用一个命令行记事本把前面这些东西串起来。适合已经写过一点C#、但对新版本变化还比较迷糊的开发者,也适合从其他语言转过来、想系统了解C#现代写法的朋友。
1. 为什么是C# 8.0和.NET 5:版本号背后的格局变化
很多初学者看到这个组合会懵,为什么不是.NET Framework 4.8,不是.NET Core 3.1,偏偏是.NET 5?这里先说清楚一个关键背景:.NET 5不是.NET Framework 5,也不是.NET Core的简单续作,而是微软把这两个产品线合并之后的第一个统一版本。
1.1 从.NET Core 3.1到.NET 5:为什么直接跳到5
当时的合并逻辑很简单。.NET Framework只能跑在Windows上,跨平台能力差;.NET Core跑得动Linux和macOS,但很多老库不支持,生态分裂严重。微软的应对策略就是“只保留一个平台”,以后所有新特性、新API、新运行时都往这一个平台上怼,这个平台就是.NET 5。版本号之所以从3.1直接跳到5,是为了避免和“.NET 4.x”这个老产品线混淆,干脆从数字上就和.NET Framework划清界限。
这个决策直接影响我们写代码的方式。以前写类库要纠结“我是给.NET Framework用还是给.NET Core用”,现在只要target到net5.0,Windows、Linux、macOS都能跑。我实际把公司一个老项目从.NET Core 3.1迁移到.NET 5时,几乎没有改业务代码,只调了几个包引用版本,编译一次通过。这种迁移成本是以前想都不敢想的。
1.2 C# 8.0和.NET 5的关系:语言版本与运行时版本
C#是一门语言,.NET是一个运行平台。C#代码最终会被编译成中间语言(IL),然后交给.NET运行时去执行。所以语言版本和平台版本是两套独立的版本号,但两者之间有默认绑定关系。C# 8.0默认配套的是.NET Core 3.x,而.NET 5默认随附的是C# 9.0。
这就产生了一个常见疑问:我这篇笔记叫“C# 8.0和.NET 5”,是不是组合错了?实际上完全能跑。你可以在.NET 5项目里继续写C# 8.0的语法,也可以手动把LangVersion设置成8.0来锁定语法级别;反过来,如果你用上了C# 9.0的record类型,那就必须在.NET 5上才能编译运行。严格来说,.NET 5的默认语言版本是C# 9.0,但C# 8.0的核心新特性——可空引用类型、switch表达式、索引和范围、异步流——在.NET 5上全部完整支持,这也是为什么这个组合在实际项目中非常常见。
| 语言版本 | 默认配套平台 | 重要新特性示例 |
|---|---|---|
| C# 7.x | .NET Core 2.x / .NET Framework 4.7 | 元组、本地函数、out变量 |
| C# 8.0 | .NET Core 3.x / .NET Standard 2.1 | 可空引用类型、switch表达式、范围运算符、异步流 |
| C# 9.0 | .NET 5 | record类型、init访问器、顶级语句 |
| C# 10 | .NET 6 | global using、文件作用域命名空间 |
我的建议是,学习时不要死盯语言版本号,重点看这个项目实际能用哪些语法。用.NET 5创建项目,C# 8.0的特性天然可用,还能顺手体验9.0的record。这一篇聚焦8.0,是因为它是整个现代C#写法的基础,搞懂它,后面的9.0、10.0都是增量。
2. C# 8.0语法更新:抛开教材,用实际代码感受新写法
C# 8.0这一波语法更新,最大的特点是“减少样板代码”。以前很多需要写四五行的逻辑,现在一行甚至一个表达式就能搞定。但新语法容易让初学者产生“花架子”的错觉,觉得这只是语法糖,没必要记。我一开始也这么想,直到在真实项目里发现,这些语法糖能明显压代码行数,更重要的是能减少变量滥用和中间状态,让代码不容易出错。
2.1 switch表达式:把if-else链条压缩成一行
C# 8.0的switch表达式不是原来那个switch语句的简单变形,而是真正可以“返回一个值”的表达式。看这个例子,根据成绩等级返回评语:
// 传统写法 string GetComment(string grade) { switch (grade) { case "A": return "优秀"; case "B": return "良好"; case "C": return "及格"; default: return "需努力"; } } // C# 8.0 switch表达式 string GetComment(string grade) => grade switch { "A" => "优秀", "B" => "良好", "C" => "及格", _ => "需努力" };注意几点:_是弃元,代表“其他所有情况”,这和传统switch的default对应;每条分支后面是=>而不是case和:;整个表达式是返回值的,所以可以直接用在方法体、属性初始化、LINQ查询里。
我在实际项目里最常用switch表达式的场景是状态流转。比如订单状态从Pending到Paid到Shipped,用传统if-else写起来特别啰嗦,用switch表达式可以把整个状态映射放在一个方法里,后续加状态只需加一行分支。不过要注意,switch表达式要求所有可能情况都被覆盖,如果不写_分支,编译器会警告“switch表达式未涵盖所有输入”,这也是它比传统switch安全的地方——强制你考虑边界条件。
2.2 范围运算符和索引:操作数组的新姿势
C# 8.0引入了^(从末尾索引)和..(范围运算符),这俩在处理数组、字符串、集合切片时非常好用。以前取数组最后一个元素要写arr[arr.Length - 1],现在写arr[^1]。
int[] numbers = { 0, 1, 2, 3, 4, 5, 6 }; int first = numbers[0]; // 0 int last = numbers[^1]; // 6,倒数第一个 int secondLast = numbers[^2]; // 5,倒数第二个 int[] head = numbers[..3]; // { 0, 1, 2 },从开头到索引3(不含) int[] tail = numbers[3..]; // { 3, 4, 5, 6 },从索引3到结尾 int[] middle = numbers[1..^1]; // { 1, 2, 3, 4, 5 },去掉首尾这个语法背后有对应的类型:Index和Range。你可以把^1理解成一个Index结构体,把1..^1理解成一个Range结构体,它们可以作为参数传递。比如封装一个分页工具:
public static T[] Page<T>(T[] source, int pageSize, int pageIndex) { Range range = (pageIndex * pageSize)..((pageIndex + 1) * pageSize); return source[range]; }这里有个容易踩的坑:..范围运算符是“左闭右开”的,也就是说[1..^1]包含索引1,但不包含倒数第一个。我第一次用的时候以为[..^1]会取到倒数第一个,结果发现取到的是“去掉最后一个元素后的所有元素”,差点在分页时多算一条数据。建议封装集合操作时,先写单元测试把边界验证一遍。
2.3 using声明与null合并赋值:小语法的大便利
using声明算是C# 8.0里我日常用得最频繁的新特性之一。以前释放资源要写完整的using语句块,多一层缩进,代码嵌套又多一层:
// 旧写法 using (var reader = new StreamReader("data.txt")) { var line = await reader.ReadLineAsync(); Console.WriteLine(line); } // C# 8.0 using声明 using var reader = new StreamReader("data.txt"); var line = await reader.ReadLineAsync(); Console.WriteLine(line);这个写法的作用域是当前代码块,代码块结束时会自动调用Dispose释放资源。注意:不要在循环里用这个写法创建大量资源,因为它要等整个代码块结束才释放,而不是每轮迭代结束就释放。我见过有人在一个大方法里写了好几个using声明,结果资源占用一直居高不下,排查了半天才发现是作用域太长。
??=是null合并赋值运算符,作用和??(null合并运算符)互补:??是“左边为null就取右边”,??=是“左边为null就把右边赋给左边”。
List<string> list = null; // 如果list为null,就创建一个新列表 list ??= new List<string>(); list.Add("a");以前这种防御性判空要写一整个if语句,现在我更倾向于直接用??=,尤其是在缓存初始化的场景里。顺手一提,C# 8.0里还新增了static本地函数,你可以在方法里声明一个不捕获外部变量的本地函数,避免闭包带来的内存分配问题,性能敏感场景会用到。
3. .NET 5项目模型变化:从csproj到跨平台发布
学完语法,下一个卡住很多人的地方是项目文件本身。老一代.NET开发者习惯的csproj是厚厚一坨XML,里面各种Compile Include、Reference写几百行。.NET 5的SDK风格项目文件把这一切压缩到了极致。
3.1 一个csproj文件看懂SDK风格项目
用Visual Studio 2022(或JetBrains Rider)创建一个.NET 5控制台项目,你会看到csproj文件基本长这样:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net5.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>disable</ImplicitUsings> </PropertyGroup> </Project>就这么几行。TargetFramework是net5.0,Nullable是enable(这个后面细说),ImplicitUsings在.NET 5里默认关闭,.NET 6才默认开启。SDK风格的默认行为是“约定优于配置”:所有.cs文件自动纳入编译,所有ProjectReference和PackageReference写在<ItemGroup>里,不用再手动逐个添加。
我建议初学者不要太依赖Visual Studio的图形界面拖拽引用,直接打开csproj文件看依赖关系,心里更有数。比如要安装Newtonsoft.Json,命令行执行dotnet add package Newtonsoft.Json,csproj里会自动多一行<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />。以后换机器、CI/CD拉代码,只要把csproj提交到Git,依赖版本一目了然。
3.2 发布与部署:自包含、单文件、跨平台参数怎么选
.NET 5的部署相比老框架是质变。以前部署ASP.NET网站要在服务器装.NET Framework,版本还要对上。现在只需要两条命令:
# 框架依赖发布:目标机器需要先装.NET 5运行时 dotnet publish -c Release -f net5.0 # 自包含发布:把运行时一起打包,目标机器不用装.NET dotnet publish -c Release -r linux-x64 --self-contained true参数说明:
| 参数 | 作用 | 适用场景 |
|---|---|---|
-c Release | 发布Release版本(优化过的) | 生产环境 |
-f net5.0 | 指定目标框架 | 多框架项目时指定 |
-r linux-x64 | 指定运行时标识 | 跨平台发布时用 |
--self-contained true | 打包.NET运行时 | 目标机器没装.NET时用 |
/p:PublishSingleFile=true | 发布成单文件 | 方便分发和拷贝 |
单文件发布这功能在.NET 5里已经比较成熟了,我经常把一些小工具发布成单个exe,扔到服务器上直接跑。不过注意,单文件发布会把原生依赖解压到临时目录再加载,首次启动会稍慢一点,另外杀毒软件对单文件程序的误报率会高一些,这是没代码签名导致的,部署到内网一般没事。
3.3 版本对应关系别搞混:C# 8.0、C# 9.0和.NET平台
上一节已经说过,.NET 5随附C# 9.0。那标题里的C# 8.0怎么理解?C# 8.0的大部分特性在.NET Core 3.x和.NET 5上都可用,尤其是可空引用类型、范围运算符、异步流这三板斧,已经是现代C#的底座了。C# 9.0的record类型、init访问器则是锦上添花。
如果你在.NET 5项目里想强制使用C# 8.0语法级别,可以在csproj里加一行:
<LangVersion>8.0</LangVersion>但一般情况下没必要锁这么死。我更建议的做法是:项目用默认的C# 9.0,但主力写C# 8.0风格,遇到合适的场景再用9.0的record。这样代码既稳定,又不排斥新特性。语言版本只是工具,别为了用新特性而用新特性,我在代码评审时最怕看到为了炫技把简单逻辑改成一行炫酷但没人读得懂的写法。
4. 值类型、引用类型与可空引用类型:内存模型决定代码行为
这一节是所有C#程序员迟早要补的课。哪怕你已经写了两年代码,只要没搞懂值类型和引用类型的区别,碰到性能问题或者诡异的“对象被改了”问题就会抓瞎。C# 8.0引入的可空引用类型,更是把这个问题从“运行时崩溃”提前到了“编译期警告”。
4.1 栈与堆的划分,以及装箱拆箱隐藏的成本
先记住一个粗略但有用的模型:值类型(int、double、bool、struct、enum)通常分配在栈上,引用类型(class、interface、数组、string)分配在堆上,栈上只保存指向堆对象的引用。
int a = 10; // a的值直接在栈上 string s = "hi"; // s在栈上,指向堆上的字符串对象值类型赋值是复制值,引用类型赋值是复制引用(即复制指针),这是很多“诡异bug”的根源:
var p1 = new Point { X = 1, Y = 2 }; var p2 = p1; // 如果Point是class,p2和p1指向同一个对象 p2.X = 100; // p1.X也变成100点这里容易引出装箱拆箱的问题。把一个值类型转换成object或接口类型,就叫装箱,会在堆上分配一个对象,把值复制进去;反过来叫拆箱,又要做类型检查再复制回来。大量装箱拆箱在性能敏感场景是灾难。
int number = 42; object boxed = number; // 装箱:堆上分配,值为42 int unboxed = (int)boxed; // 拆箱:从堆上拷贝回栈实测下来,在一个每秒处理几十万条数据的循环里做装箱拆箱,性能能差出好几倍。解决办法是用泛型集合List<int>而不是ArrayList,用泛型方法而不是object参数。C# 8.0之后还支持Nullable<T>的装箱优化:有值的可空值类型会被自动装箱成底层类型的装箱对象,不会额外套一层Nullable的壳。
4.2 可空引用类型:编译器怎么帮你抓空引用
C# 8.0最重磅的特性之一,就是可空引用类型(Nullable Reference Types)。注意区分:值类型的可空(int?)是运行时机制,int?真的能存null;引用类型的可空(string?)是编译期检查机制,运行时string和string?没区别,都是引用类型,都可以为null。
这个特性的意义在于把“空引用异常”的排查从运行时提前到编译期。开启方式是在csproj的<Nullable>enable</Nullable>,或者在代码文件顶部写#nullable enable。
#nullable enable string? maybeNull = GetValue(); Console.WriteLine(maybeNull.Length); // 编译警告:可能为null if (maybeNull is not null) { Console.WriteLine(maybeNull.Length); // 警告消除 }编译器通过“流分析”跟踪变量是否为null,在明确的判空之后,变量会被标记为“非空”,允许直接访问成员。还有!(null容忍运算符),用来告诉编译器“我知道这里不可能为null”,但要慎用,滥用等于关闭检查。
我踩过的坑是:一个项目里老代码和新代码混着写,老代码里的string没有标可空,新代码里标了string?,两边一交互,经常出现“明明标了可空但责任不清”的情况。后来我把整个项目的<Nullable>统一打开,花了一天时间把警告清零,之后空引用异常几乎绝迹。强烈建议新项目默认开启,老项目可以逐步开启。
4.3 struct的陷阱:复制语义与默认构造函数
C# 8.0给struct加了一个非常实用但容易忽略的能力:只读结构体成员。在struct里,你可以给单个成员标记readonly,表示这个成员不会修改结构体状态。
public struct Point { public int X { get; } public int Y { get; } public Point(int x, int y) => (X, Y) = (x, y); public readonly double DistanceFromOrigin => Math.Sqrt(X * X + Y * Y); }标记为readonly的成员在调用时会有一个额外好处:编译器可以避免创建防御性副本。这在结构体包含引用类型字段时特别重要,否则每次访问成员都可能悄悄复制整个结构体对象,性能损耗肉眼可见。
struct还有一个经典坑:值类型不能用无参自定义构造函数(这个是老限制,C# 10才放开),也就是说你没法给struct写一个“必须做的初始化逻辑”。C# 8.0之前,struct的默认值就是所有字段清零/置null;8.0之后对只读成员的限制放松了一些,但设计struct时依然要牢记“它是值类型,要尽量不可变”。我在实际项目里更倾向于用只读struct表示坐标、颜色、范围这种轻量级概念,用class表示带状态和行为的实体。
5. 异步编程基础:async/await背后的线程模型和常见误区
异步编程是C#里最容易被误解的主题。很多人以为async/await就是“多线程”,用了就能加快速度,这是完全错误的。async/await主要解决的是“不阻塞调用线程”,而不是“开新线程跑并行任务”。
5.1 async方法内部到底发生了什么
当编译器看到一个async方法,它会把这个方法改造成一个状态机。第一次调用时,方法开始执行,碰到await会检查任务是否已经完成:如果没完成,方法立刻返回一个未完成的任务给调用者,同时把当前状态、局部变量、当前同步上下文保存下来,然后注册一个续延(continuation),等任务完成后接着往下执行。
public async Task<string> FetchDataAsync(HttpClient client) { string json = await client.GetStringAsync("https://api.example.com/data"); return json; }在UI程序里,await之后会回到原来线程(通过SynchronizationContext);在控制台程序里,await之后通常在线程池线程继续执行。这就是为什么WPF/WinForms里用await更新UI控件不会跨线程异常,而如果你手动写Task.Run就很容易踩跨线程的坑。
一个典型的误区是“用async方法就是为了并行”。其实await是顺序的,前一个await等完了才执行下一句。如果想让多个独立任务并行,要用Task.WhenAll:
var task1 = FetchDataAsync(client1); var task2 = FetchDataAsync(client2); var results = await Task.WhenAll(task1, task2);这样两个请求是真正并发发出的,总耗时约等于最慢的那个,而不是两个耗时之和。
5.2 使用async/await最容易踩的四个坑
第一个坑:async void。async void的方法异常无法被调用者捕获,直接抛到同步上下文里,很可能导致程序崩溃。C#官方明确说async void只为事件处理器保留。我见过有人在按钮点击事件里写async void没问题,但有人在生命周期事件、构造函数里写async void,出了问题极难排查。
// 错误示范:async void,异常无法捕获 public async void SaveAsync() { await Task.Delay(1000); throw new Exception("boom"); // 没人能catch到 } // 正确做法:返回Task public async Task SaveAsync() { await Task.Delay(1000); throw new Exception("boom"); }第二个坑:在async方法里同步阻塞。用.Result或.Wait()把异步任务变成同步等待,会导致死锁。尤其在UI线程有SynchronizationContext的情况下,一个线程在等任务完成,任务却等着回到这个线程,两边互相等,死锁。正确做法是全程用await,一路async到底。
第三个坑:Task.WhenAll的异常处理。我踩过:Task.WhenAll里多个任务都抛异常了,直接await Task.WhenAll只能捕获到第一个异常(实际上会抛出一个TaskCanceledException或第一个异常),其他的异常就丢了。正确做法是捕获AggregateException或者逐个遍历任务检查task.Exception:
Task[] tasks = new Task[] { FetchA(), FetchB(), FetchC() }; try { await Task.WhenAll(tasks); } catch (Exception ex) { foreach (var task in tasks) { if (task.Exception != null) { Console.WriteLine(task.Exception.Message); } } }第四个坑:不用ConfigureAwait导致上下文切换开销。在类库里写代码,每个await都默认要回到原同步上下文,如果原上下文是个单线程,后面全部卡住。在非UI的类库代码中,加ConfigureAwait(false)可以避免这一层切换:
var json = await client.GetStringAsync(url).ConfigureAwait(false);注意:ConfigureAwait(false)后面的代码不再回到原先的SynchronizationContext,所以UI代码里不能在await之后直接访问UI控件。
5.3 IAsyncEnumerable :用异步流处理分批数据
C# 8.0带来了异步流(Async Streams),核心接口是IAsyncEnumerable<T>。它解决的问题是:你有一个会持续产生数据的序列,每次产生都要异步等待(比如读数据库游标、读网络流、分页拉取接口),如果每次拉一批就返回,调用方需要不断地“等待生成下一个元素”。
有了异步流,你可以写一个await foreach来消费这种序列:
public async IAsyncEnumerable<string> ReadLinesAsync(string path) { using var reader = new StreamReader(path); string line; while ((line = await reader.ReadLineAsync()) is not null) { yield return line; } } // 消费端 await foreach (var line in ReadLinesAsync("data.txt")) { Console.WriteLine(line); }yield return在async方法里不能直接用,必须配合IAsyncEnumerable<T>才能实现“逐行异步读取”。我第一次拿它处理几GB的日志文件时,体会到了什么叫“边读边处理”,内存占用稳定在几十MB级别,而如果用同步读取整个文件到内存,直接几GB没了。
6. 从零搭一个命令行记事本:把上面这些基础用起来
前面讲了不少知识点,最后用一个完整小项目把它们串起来。这个项目是一个命令行记事本,支持添加笔记、查看列表、搜索笔记、统计字数,用到的正好是C# 8.0和.NET 5的现代特性。
6.1 功能设计与数据模型
需求很简单:程序启动后显示菜单,用户输入1添加笔记,2查看所有笔记,3搜索笔记,4退出。笔记数据保存到本地JSON文件,启动时异步读取,每次操作后异步保存。数据模型用C# 9.0的record顺手定义一下,简洁明了:
public record NoteRecord(string Title, string Content, DateTime CreatedAt);项目文件需要开启可空引用类型:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net5.0</TargetFramework> <Nullable>enable</Nullable> </PropertyGroup> </Project>6.2 核心代码:用新语法把逻辑写清爽
先看笔记仓储部分,这里用到了异步流、可空引用类型和using声明:
using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; public class NoteRepository { private readonly string _filePath; private List<NoteRecord> _notes; public NoteRepository(string filePath) { _filePath = filePath; _notes = new List<NoteRecord>(); } public async Task LoadAsync() { if (!File.Exists(_filePath)) { _notes = new List<NoteRecord>(); return; } await using var stream = File.OpenRead(_filePath); _notes = await JsonSerializer.DeserializeAsync<List<NoteRecord>>(stream) ?? new List<NoteRecord>(); } public async Task SaveAsync() { await using var stream = File.Create(_filePath); await JsonSerializer.SerializeAsync(stream, _notes); } public void Add(NoteRecord note) { _notes.Add(note); } public List<NoteRecord> Search(string keyword) { return _notes .Where(n => n.Title.Contains(keyword) || n.Content.Contains(keyword)) .OrderByDescending(n => n.CreatedAt) .ToList(); } }注意await using是从C# 8.0开始支持的异步释放语法,对应的接口是IAsyncDisposable,StreamReader和FileStream都实现了它。这在处理大文件时能让底层句柄更早释放,比同步using更友好。
主程序的菜单逻辑可以用switch表达式和async/await配合起来:
class Program { static async Task Main(string[] args) { var repo = new NoteRepository("notes.json"); await repo.LoadAsync(); while (true) { Console.WriteLine("1.添加笔记 2.查看全部 3.搜索 4.退出"); string input = Console.ReadLine() ?? string.Empty; string result = input switch { "1" => await AddNoteAsync(repo), "2" => ViewAll(repo), "3" => SearchNotes(repo), "4" => "exit", _ => "未知命令,请重新输入" }; if (result == "exit") break; Console.WriteLine(result); } await repo.SaveAsync(); } static async Task<string> AddNoteAsync(NoteRepository repo) { Console.Write("标题: "); string title = Console.ReadLine() ?? "未命名"; Console.Write("内容: "); string content = Console.ReadLine() ?? string.Empty; repo.Add(new NoteRecord(title, content, DateTime.Now)); await repo.SaveAsync(); return "笔记已保存"; } static string ViewAll(NoteRepository repo) { if (repo.AllNotes.Count == 0) return "还没有笔记"; return string.Join(Environment.NewLine, repo.AllNotes.Select(n => $"[{n.CreatedAt:yyyy-MM-dd HH:mm}] {n.Title}")); } static string SearchNotes(NoteRepository repo) { Console.Write("输入关键字: "); string keyword = Console.ReadLine() ?? string.Empty; var results = repo.Search(keyword); if (results.Count == 0) return "没有找到匹配的笔记"; return string.Join(Environment.NewLine, results.Select(n => $"《{n.Title}》 - {n.Content[..Math.Min(n.Content.Length, 30)]}")); } }这里有一处地方值得注意:在字符串插值里,我用到了n.Content[..Math.Min(n.Content.Length, 30)],这是C# 8.0的范围运算符在真实场景里的应用,取出内容的前30个字符作为预览。如果担心长度不够,先用Math.Min兜底,避免越界。
Main方法返回Task,这是C# 7.1之后支持的async入口点,控制台程序也能用await了,不用再搞.GetAwaiter().GetResult()那种丑陋写法。
6.3 验证与发布:运行效果检查和建议
把代码跑起来,实际体验一下:
- 输入
1,添加一条标题为“学习笔记”的内容“C# 8.0的范围运算符真好用”,程序返回“笔记已保存”。 - 再添加一条“购物清单”,内容是“牛奶、面包、鸡蛋”。
- 输入
3搜索“C#”,程序返回第一条笔记的标题和内容预览。 - 输入
4退出,再看项目目录下生成的notes.json,两笔记都在。
发布命令很简单,在项目目录执行:
dotnet publish -c Release -r win-x64 --self-contained false -o ./publish把publish目录下的exe拷到别的Windows机器上(已装.NET 5运行时)就能跑。如果目标机器没装运行时,把--self-contained false改成true,打包体积大概增加60MB左右,但目标机器啥都不用装。
再分享一个我后来扩展这个项目时的经验:想做“笔记标签”功能时,我没有直接改NoteRecord的字段,而是在外面加了个Dictionary<string, List<NoteRecord>>做标签索引,保持了原数据模型的稳定。这个思维方式是从值类型和引用类型的教训里来的——尽量减少对核心数据结构的修改,用组合而不是修改来扩展功能。
这套基础学完之后,建议你按这个路线继续深入:先去搞懂LINQ的延迟执行和IEnumerable<T>与IQueryable<T>的区别,再去看泛型的协变逆变,最后把依赖注入和中间件管道摸了。C# 8.0和.NET 5只是起点,但把基础打扎实,后面学什么框架都快。