C# 与 .NET:运行在 CLR 之上
要理解 C#,先要把它和 .NET 分清:C# 是一门语言,.NET 是承载它的运行时与类库平台。你写的 C# 代码先被编译成中间语言(IL),运行时由公共语言运行时 CLR 把 IL 即时编译(JIT)成机器码再执行。多出来的这一层,换来的是自动内存管理、跨语言互操作和统一的类型系统。
自动内存管理是 CLR 最大的礼包:堆上的对象由垃圾回收器(GC)负责释放,开发者不必手动 delete。代价是“不确定何时回收”——GC 只在内存压力或特定时机触发,所以持有大量非托管资源(文件句柄、数据库连接)时仍需显式释放,这就要用到 IDisposable 与 using。
托管不等于“不用管内存”。托管只解决了“对象作为垃圾时谁来回收”,不解决“宝贵的非托管句柄何时必须交还”。前者交给 GC,后者交给 using。
- CLR把 IL 编译为机器码并负责 GC、类型检查与异常处理。
- 值类型与引用类型前者存数据、后者存引用,决定复制与比较语义。
- LINQ用统一的查询语法操作集合、数据库与 XML。
- async/await以同步写法编写异步逻辑,避免线程阻塞。
语法基础:类型、变量与字符串
C# 是静态强类型语言,变量在声明时就有确定的类型。它把类型分成两大类,理解这个二分法是后面所有行为的钥匙:值类型直接保存数据,赋给另一个变量时会整体复制;引用类型保存的是对象的引用,复制的是“指向”,两者随后仍指向同一个对象。
| 维度 | 值类型 | 引用类型 |
|---|---|---|
| 常见例子 | int、double、bool、char、struct、enum | class、string、数组、委托、接口 |
| 内存位置 | 栈或宿主对象内部 | 堆(变量持有引用) |
| 赋值语义 | 复制整份数据 | 复制引用,指向同一对象 |
| 默认值 | 0 / false 等零值 | null |
using System;
class Program {
static void Main() {
int a = 10;
int b = a;
b = 20;
Console.WriteLine($"a={a} b={b}"); // a=10 b=20,值类型互不影响
string s = "hello";
string u = s.ToUpper(); // 字符串不可变,返回新串
Console.WriteLine($"{s} / {u}"); // hello / HELLO
Console.WriteLine($"{s.Length}, {s[0]}"); // 5, h
}
}
注意 string 是不可变(immutable)的,任何“修改”都产生新字符串,所以循环里频繁拼接字符串应改用 StringBuilder。字符串插值 $"..." 是推荐写法,既清晰又高效。
面向对象:类、接口与继承
C# 的面向对象支持得很完整:类封装状态与行为,接口定义契约,继承复用实现。工程上一条重要原则是“面向接口编程”——调用方依赖抽象,具体实现可以替换,测试时可注入假对象。
using System;
public interface IShape {
double Area();
}
public class Circle : IShape {
public double Radius { get; }
public Circle(double r) => Radius = r;
public double Area() => Math.PI * Radius * Radius;
}
public class Rect : IShape {
public double W { get; set; }
public double H { get; set; }
public double Area() => W * H;
}
class Program {
static void Main() {
IShape[] shapes = { new Circle(1), new Rect { W = 3, H = 4 } };
double total = 0;
foreach (var s in shapes) total += s.Area();
Console.WriteLine($"总面积 = {total:F2}"); // 15.14
}
}
=> 是表达式成员语法,{ get; } 是只读自动属性。多态让 foreach 里的每个对象各按自己的 Area 计算,调用方完全无需区分具体类型——这正是抽象的收益。
集合与 LINQ 查询
LINQ(Language Integrated Query)把查询能力直接做进语言:无论数据来自内存集合、数据库还是 XML,都能用同一套 Where / Select / OrderBy / GroupBy 表达。它背后是扩展方法与延迟执行——查询链在真正枚举时才运行。
using System;
using System.Linq;
class Program {
static void Main() {
var nums = new[] { 5, 12, 8, 20, 3, 17, 9 };
var result = nums
.Where(n => n % 2 == 0) // 只保留偶数
.OrderByDescending(n => n) // 降序
.Select(n => n * n) // 平方
.ToList();
Console.WriteLine(string.Join(", ", result)); // 400, 144, 64
}
}
“延迟执行”既省事也埋坑:如果查询里引用了会被后续修改的变量,结果可能在枚举时才定。需要固定结果时用 ToList() / ToArray() 立即物化。对于数据库查询,LINQ 会被表达式树翻译成 SQL,这时要避免把无法翻译的本地方法写进查询,否则会退化成全表拉取。
异步编程:async 与 await
IO 操作(网络请求、读写文件)真正慢的是“等待”,而不是 CPU 计算。异步的价值在于:等待期间把线程还回线程池,而不是让线程干等,从而用少量线程支撑大量并发请求。async/await 让你用几乎同步的写法表达异步逻辑。
using System;
using System.Net.Http;
using System.Threading.Tasks;
class Program {
static readonly HttpClient http = new HttpClient();
static async Task<int> GetLengthAsync(string url) {
string body = await http.GetStringAsync(url); // 等待时不阻塞线程
return body.Length;
}
static async Task Main() {
int len = await GetLengthAsync("https://example.com/");
Console.WriteLine($"页面长度:{len}");
}
}
两条纪律:异步要“一路异步到底”,不要在异步方法里用 .Result 或 .Wait() 同步阻塞,否则容易死锁并失去并发优势;命名上异步方法以 Async 结尾,让调用方一眼看出需要 await。CPU 密集型的并行计算则应交给 Task.Run 或 Parallel,而不是靠 async。
异常处理与资源的自动释放
C# 用 try / catch / finally 处理异常,但比异常更常见的是“资源必须被释放”的问题——打开的文件、数据库连接、锁。C# 的答案同样是 RAII 的思路:让类型实现 IDisposable,用 using 语句保证退出作用域时自动调用 Dispose,无论正常结束还是抛异常。
using System;
using System.IO;
class Program {
static void Main() {
string path = "notes.txt";
// using 声明:作用域结束即自动释放
using var writer = new StreamWriter(path);
writer.WriteLine("第一行");
writer.WriteLine("第二行");
using (var reader = new StreamReader(path)) {
string line;
int no = 0;
while ((line = reader.ReadLine()) != null) {
Console.WriteLine($"{++no}: {line}");
}
}
}
}
异常处理的通用原则:只捕获你能处理的异常,其余让它向上冒泡;捕获后要么恢复、要么包装上更有意义的上下文再抛出;不要把异常吞掉(空的 catch)——那会让问题在更远的地方以更难看的方式暴露。
实战:一个命令行待办工具
把语法、面向对象、LINQ 与文件持久化串起来,做一个最小可用的待办(Todo)命令行工具:新增、列出未完成、标记完成,并把数据存到本地文本文件。
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
record Todo(int Id, string Text, bool Done);
class Program {
static readonly string File = "todos.txt";
static List<Todo> Load() =>
File.Exists(File)
? File.ReadAllLines(File)
.Select((l, i) => l.Split('|'))
.Select(p => new Todo(int.Parse(p[0]), p[1], bool.Parse(p[2])))
.ToList()
: new List<Todo>();
static void Save(List<Todo> list) =>
File.WriteAllLines(File, list.Select(t => $"{t.Id}|{t.Text}|{t.Done}"));
static void Main() {
var todos = Load();
todos.Add(new Todo(todos.Count + 1, "学习 C# 异步", false));
todos.Add(new Todo(todos.Count + 1, "写一篇技术笔记", true));
var pending = todos.Where(t => !t.Done).ToList();
Console.WriteLine($"未完成 {pending.Count} 项:");
foreach (var t in pending) Console.WriteLine($" #{t.Id} {t.Text}");
Save(todos);
}
}
这个例子把多个知识点收在一处:record 是不可变数据载体,LINQ 完成读取与筛选,File.ReadAllLines 负责 IO,using 虽未显式出现但 File 静态方法内部已保证句柄释放。把它扩成支持增删改的交互程序,就是一个像样的练手项目。
从语法到工程:项目结构与上线
会写语法不等于会做工程。一个规范的 .NET 项目通常包含:解决方案文件(.sln)、项目文件(.csproj)、源码目录、测试项目与配置文件。用 dotnet CLI 三行命令即可搭好骨架:
# 新建解决方案与控制台项目
dotnet new sln -n TodoApp
dotnet new console -n TodoApp.Cli -o src/TodoApp.Cli
dotnet sln add src/TodoApp.Cli
# 运行与发布
dotnet run --project src/TodoApp.Cli
dotnet publish src/TodoApp.Cli -c Release -o ./out
- 用
Directory.Packages.props集中管理依赖版本,避免多项目版本漂移。 - 把可测试的逻辑抽到类库,控制台/接口只做薄薄一层;配套 xUnit 写单元测试。
- 发布前开
-c Release并启用<Nullable>enable</Nullable>,让编译器帮忙查空引用。 - 敏感配置走环境变量或用户机密(user-secrets),不要提交进仓库。
C# 的舒适来自“框架把大量工程细节标准化了”。把项目结构、依赖注入、日志与配置这几件事学顺,你就能把注意力真正放在业务上。
下一篇我们把视角转向 Visual Basic:同样运行在 .NET 之上,它用更贴近自然语言的语法和所见即所得的窗体设计,成为很多人接触桌面应用开发的第一站。