要点
这篇是给有 Java 或 TypeScript 经验的人的 C# 落地指南,挑六个”不一样才值得讲”的点:属性、可空引用类型、LINQ、record 与模式匹配、async/await、内建依赖注入。每个点配一段可运行代码和一张对照表——相似的部分直接跳过,篇幅全部花在差异和坑上。
一、属性 property:字段的门卫
Java 里字段私有、暴露 getter/setter 是铁律;C# 把这对方法升格为语言特性——属性(property):
public class Order
{
public string Id { get; init; } // init:只能在对象初始化时赋值,之后只读
public decimal Amount { get; set; } // 自动属性,编译器生成隐藏字段
public decimal Tax => Amount * 0.13m; // 表达式属性:只有 get,每次访问现算
private int _stock; // 需要写校验逻辑时才手写字段
public int Stock
{
get => _stock;
set => _stock = value >= 0 ? value : throw new ArgumentException("库存不能为负");
}
}
var o = new Order { Id = "A1" }; // 对象初始化器配 init 属性,没有构造函数爆炸
| 能力 | C# 属性 | Java |
|---|---|---|
| 只读暴露 | get; init; 或 get; private set; | 手写 getter,不给 setter |
| 去样板 | 自动属性一行 | Lombok @Getter/@Setter 或 IDE 生成 |
| 校验逻辑 | set 访问器里写,调用方无感 | setter 方法里写 |
从外部看属性长得像字段(o.Amount),从实现看它是方法——将来加校验、加日志不破坏调用方。C# 14 的 field 关键字让”带校验的半自动属性”不再需要手写字段,属于锦上添花。
二、可空引用类型:编译期防空
.NET 10 模板默认开启可空引用类型(nullable reference types,NRT):string 表示”我不为 null”,string? 表示”我可能为 null”。这是编译器警告级别的静态分析,不是运行时检查:
void Greet(string name) { ... } // 契约:name 不为 null
Greet(null!); // 编译警告 + ! 是"我强行压掉警告",出事自负
string? nick = GetUserNick();
Console.WriteLine(nick?.Trim()); // ?. null 时不调用,整体为 null
var len = nick?.Length ?? 0; // ?? 给默认值
| 概念 | C# | 对照 |
|---|---|---|
| 可空标记 | T?(引用与值类型统一语法) | TS 的 string | null / strictNullChecks |
| 值类型可空 | int? 即 Nullable,装箱结构 | Java 的 Integer(陷阱更多) |
| “可能没有”的表达 | T? 直用,或 TryGet 模式 out 参数 | Java Optional 包装 |
| 压制警告 | null! 或禁用整文件 #nullable disable | TS 的 as / @ts-ignore |
关键差异:Java 用 Optional 在运行时表达缺席,C# 选择在编译期用类型系统标注缺席,运行时 string? 与 string 是同一个类型。所以 NRT 不是护栏而是安全带——跨边界数据(HTTP、JSON)仍要运行时校验。
三、LINQ:集合操作的统一语言
LINQ 的方法语法(method syntax)是一套集合标准库,与 Java Stream、JS 数组方法三者神似,命名对照着记最快:
var adults = people
.Where(p => p.Age >= 18) // filter
.OrderByDescending(p => p.Age) // sort
.Select(p => new { p.Name, p.Age }) // map,匿名类型
.Take(10) // slice/limit
.ToList(); // 终结:立即执行(否则只是"查询定义")
| 操作 | C# | Java Stream | JS 数组 |
|---|---|---|---|
| 过滤 | Where | filter | filter |
| 映射 | Select | map | map |
| 分组 | GroupBy(返回懒序列) | Collectors.groupingBy | Object.groupBy(较新) |
| 终结 | ToList/ToArray/Sum/First | collect/toList | 数组方法本就立即执行 |
最大的坑是延迟执行(deferred execution):Where/Select 只是在组装查询,遍历或 ToList 才真正跑。枚举两次就查两次;每遍历一次才求值一次。不确定时立即 ToList 固化结果。
四、record 与模式匹配:数据建模的正确姿势
record 是不可变数据类型,一句话定义,自动获得值相等、解构、with 拷贝。与模式匹配(pattern matching)配合是 C# 处理”数据 + 分支”的杀手锏:
public record Link(string Code, string Url, int Clicks);
var bumped = link with { Clicks = link.Clicks + 1 }; // 非破坏性更新
string Describe(object obj) => obj switch // switch 表达式
{
Link { Clicks: > 1000 } l => $"热门短链 {l.Code}", // 属性 + 关系模式
Link l => $"短链 {l.Code}",
string s => $"普通字符串 {s}",
null => "空值", // null 模式显式处理
_ => "其他" // 丢弃模式 = default
};
| 能力 | C# | Java |
|---|---|---|
| 不可变数据载体 | record(C# 9 起),值相等内建 | record(Java 16 起) |
| 非破坏更新 | with 表达式,任意字段 | 无对应,手写复制构造 |
| 模式匹配 | switch 表达式 + 属性/关系/列表模式(C# 8-11 渐进) | Java 21 起模式匹配 for switch |
站内《Java 类与 Record》讲过 Java 侧的取舍,两边结论一致:DTO、API 契约、值对象优先 record;有生命周期和行为的实体才用 class。
五、async/await 与 Task:像 JS 的语法,不像 JS 的语义
async Task<string[]> FetchAllAsync(string[] urls)
{
var tasks = urls.Select(FetchAsync); // 发起,不等待
return await Task.WhenAll(tasks); // 并发聚合,等价 Promise.all
}
| 方面 | C# | JS / TS |
|---|---|---|
| await 的本质 | 真暂停方法,释放线程;延续可能回到原上下文 | 单线程事件循环的回调注册 |
| 任务类型 | Task / Task / ValueTask | Promise / thenable |
| 并发聚合 | Task.WhenAll / WhenAny | Promise.all / race |
| 同步等结果 | .Result 会死锁(有同步上下文时) | 不存在此问题(本就单线程) |
| 忘记 await | 编译警告 CS4014,任务静默丢弃 | 浮悬 Promise 无警告 |
两个必踩坑:反模式一:async void——异常没人接、无法等待,只允许事件处理器用;要”发后不理”就返回 Task 并由调用方决定。反模式二:在异步代码里调 .Result / .Wait()——桌面程序里典型死锁,服务器里白白占线程;从上到下全 async 才是正解。
六、依赖注入为何内建
Spring 用户到 C# 最惊讶的一件事:ASP.NET Core 的依赖注入(dependency injection,DI)容器在运行时里,不在框架里:
// 注册:寿命三档,语义直白
builder.Services.AddSingleton<IShortCode, ShortCodeGenerator>(); // 全进程一份
builder.Services.AddScoped<LinkDbContext>(); // 每个 HTTP 请求一份
builder.Services.AddTransient<IReportBuilder, ReportBuilder>(); // 每次解析新建
// 消费:构造器注入,无注解、无反射扫描
app.MapGet("/{code}", async (string code, LinkDbContext db) =>
await db.Links.FirstOrDefaultAsync(l => l.Code == code));
| 方面 | .NET(Microsoft.Extensions.DependencyInjection) | Spring |
|---|---|---|
| 容器来源 | 运行时自带库,控制台程序 3 行也能用 | 框架核心,依托容器 |
| 注册方式 | 显式逐条注册(编译期可见) | 注解 + 类路径扫描 |
| 生命周期 | Singleton/Scoped/Transient 三档显式声明 | 默认单例,作用域靠注解配置 |
| 漏注册的暴露时机 | 第一次解析时抛异常 | 容器启动早期即失败 |
为什么内建:.NET 团队把”配置、日志、DI、选项”做成共享底座 Microsoft.Extensions.*,Web 框架、云库、后台服务全建在其上——学一次,全生态通用。代价是显式注册到几百行时需要组织(扩展方法按模块分文件),Spring 的自动装配在工程效率上仍有优势。
小结与下一篇
六个点的共同线索:C# 大量把 Java 靠惯例、靠库、靠注解处理器的做法收进语言与运行时——属性收编 getter/setter,NRT 收编 Optional,LINQ 收编 Stream,DI 收编容器。代价是语言更大,收益是惯例统一。逐条速查、含常见报错对照的内容,收在下一篇《C# 基础知识参考字典》;Java 侧的现代特性背景可回看《Java 现代语法特性》。
版本核实(以官方文档为准):本文基准为 .NET 10(当前 LTS,2028-11 前受支持)与 C# 14;record 与顶级语句为 C# 9 起,模式匹配演进至 C# 11(列表模式),C# 14 新增 field 关键字与扩展成员(本文未展开);可空引用类型自 .NET 6 起模板默认开启;Java 侧对照事实:record 为 Java 16 起、switch 模式匹配为 Java 21 起、紧凑源文件为 Java 25 起。具体以官方文档为准。