要点
从 dotnet new console 到一个真正有用的命令行小工具,只需要一个 Program.cs。现代 C# 的入口是顶级语句(top-level statements)——没有类、没有 Main 的裸代码,但 Main 一直都在,只是编译器替你写。理解这层语法糖与入口的关系,是从 Java 转过来最值得先搞清楚的一件事。
创建工程
dotnet new console -n wordcount
cd wordcount
dotnet run
三秒后输出 Hello, World!。目录里只有三个值得看的东西:
| 文件/目录 | 是什么 |
|---|---|
| Program.cs | 入口源码,默认就两行顶级语句 |
| wordcount.csproj | 项目文件,SDK 风格,日常几乎不用手改 |
| obj/ | 构建中间产物(restore 结果、自动生成的 using),不要提交 |
没有 src/、没有包结构、没有清单文件——C# 的文件名与类型名不需要对应(这点与 Java 完全不同),一个项目里 .cs 文件怎么组织全凭团队约定。
核心概念:顶级语句与 Main 的关系
默认模板里的 Program.cs 长这样:
// 一行 using 都没有:ImplicitUsings 把常用命名空间隐式引入了
Console.WriteLine("Hello, World!");
编译器把它翻译成一个合成的 Program.Main(string[] args)——入口从未消失,只是不必手写。与 Java 的三种写法对照:
| 写法 | C# | Java |
|---|---|---|
| 经典入口 | class Program { static void Main(string[] args) { … } } | public class Hello { public static void main(String[] args) { … } } |
| 免类声明入口 | 顶级语句(C# 9 起,模板默认) | 紧凑源文件 void main()(Java 25 起) |
| 入口取参数 | 顶级语句里直接用 args 标识符 | main(String[] args) 声明参数 |
两条规矩要记:一个项目只能有一个文件用顶级语句(它就是入口);顶级语句必须是该文件最前面的语句,类型声明只能排在它后面。反模式:在同一个项目里既写顶级语句又写显式 Main——编译器直接报 CS8805(顶级语句与显式入口点不能共存),网上不少旧教程混着抄,一抄就中。
第一个应用:词频统计 CLI
目标:读一个文本文件,输出出现次数最多的前 N 个单词。功能小,但覆盖了真实 CLI 的全部要素——参数解析、文件 IO、错误处理、输出格式。完整代码,可直接替换 Program.cs:
// Program.cs —— 顶级语句就是入口,args 由编译器注入的 Main 传入
// 用法: dotnet run -- <文件> [前N,默认10]
if (args.Length < 1)
{
Console.Error.WriteLine("用法: wordcount <文件> [前N]");
return 1; // 顶级语句最后的 return 值 = 进程退出码
}
var path = args[0];
var top = args.Length > 1 && int.TryParse(args[1], out var n) ? n : 10;
if (!File.Exists(path))
{
Console.Error.WriteLine($"文件不存在: {path}");
return 2;
}
var text = await File.ReadAllTextAsync(path); // IO 是异步的,入口天然支持 await
var topWords = text
.Split([' ', '\n', '\r', '\t'], StringSplitOptions.RemoveEmptyEntries)
// Trim 不认标点,这里按字符过滤:只保留字母数字,"dog." 和 "dog" 才会算同一个词
.Select(w => new string(w.Where(char.IsLetterOrDigit).ToArray()))
.Where(w => w.Length > 0)
.GroupBy(w => w, StringComparer.OrdinalIgnoreCase)
.Select(g => (Word: g.Key, Count: g.Count())) // 投影成元组,下游直接点出属性
.OrderByDescending(x => x.Count)
.ThenBy(x => x.Word, StringComparer.OrdinalIgnoreCase)
.Take(top);
foreach (var (word, count) in topWords) // 元组解构
Console.WriteLine($"{count,5} {word}"); // 字符串内插,,5 表示右对齐宽度
return 0;
这几行代码里已经出现了 LINQ 方法链、元组投影与解构、字符串内插、out var 模式——都是下一篇语法篇的主角,这里先混个脸熟。
跑起来:
# 造个测试文件
printf 'the quick brown fox the lazy dog the end\n' > sample.txt
dotnet run -- sample.txt 3
1. 输出:
1. 3 the
1. 1 brown
1. 1 dog
注意 --:它之后的内容才传给程序,之前的留给 dotnet 自己(比如 dotnet run -c Release)。这是 CLI 惯例,与 npm run 的 -- 相同。
退出码与返回值
顶级语句里最后的 return 整数 映射为进程退出码——shell 脚本据此判断成败。惯例:0 成功,非零按错误类别区分(上面用 1 表示用法错误、2 表示文件不存在)。在 IDE 里调试时给参数,靠 Properties/launchSettings.json 的 commandArgs 字段,或直接终端 dotnet run。
发布成可执行文件
dotnet publish -c Release -o publish
1. 三种产物口径
1. 1. 框架依赖(默认): publish/ 里的 dll 需要目标机器装有 .NET 10 运行时,产物小
1. 2. 自包含单文件: 加 --self-contained -p:PublishSingleFile=true,免装运行时,一个可执行文件
1. 3. Native AOT: 加 -p:PublishAot=true,编成原生机器码,启动最快、体积最小,限制最多
选型一句话:公司内机器默认有运行时就用框架依赖;发给用户的小工具用自包含单文件;追求极致启动且依赖简单再考虑 AOT。跨平台注意:自包含产物按发布时的操作系统架构生成,给 Linux 服务器发布要加 -r linux-x64。
最佳实践
- 入口薄,逻辑厚:顶级语句只做参数解析与顶层编排,超过五十行就拆出静态类或服务类
- 错误输出走 Console.Error,退出码区分错误类别——这是 CLI 与管道协作的礼貌
- 文件 IO 一律用 Async 版本(File.ReadAllTextAsync 而非 ReadAllText),CLI 也不嫌小
- 正式工程把 obj/、bin/ 进 .gitignore,csproj 提交
常见问题
CS8805:顶级语句与显式 Main 同时存在
删掉一个。保留顶级语句(推荐,模板默认),或者全项目走 class + Main 风格(老团队规范可能如此),别混。
CS5001:未找到入口点
项目里没有任何顶级语句文件,也没有合法 Main。常见原因:把 Main 写成了小写 main(C# 大小写敏感),或者入口文件被排除出编译(csproj 的 Compile Remove)。
改了代码没生效
dotnet run 每次都重新构建,不存在旧产物问题;但如果你直接运行 bin/ 下的旧 dll,那就是在跑上次编译的结果。开发期统一用 dotnet run 或 dotnet watch run。
练习与实践
- 练习一:给 wordcount 加 -i 参数控制是否忽略大小写,体会 args 解析膨胀后为什么该引入 System.CommandLine 或 Spectre.Console.Cli 这类库
- 练习二:用自包含单文件发布,把产物拷到一台没装 .NET 的机器上跑通,对比产物大小与框架依赖版的差异
- 练习三:把 GroupBy 换成手动遍历 Dictionary 累加计数,体会 LINQ 与手写命令式代码的取舍
延伸阅读
环境没搭好的先回《C# 与 .NET 环境搭建与工具链》;Java 背景的读者推荐同场对照《第一个 Java 程序》——那篇讲了 Java 入口的三次进化(经典 main、单文件直跑、Java 25 紧凑源文件),和本篇的顶级语句是一对镜子。下一篇《C# 语法要点》把本文代码里一闪而过的 LINQ、元组、可空类型逐个讲透。
版本核实(以官方文档为准):顶级语句自 C# 9 / .NET 5 起可用,.NET 10 控制台模板默认启用,对应语言版本 C# 14;Native AOT 与自包含单文件发布为当前 SDK 稳定能力;Java 25 紧凑源文件用于对照(其 GA 为 2025-09)。文件式应用(dotnet run 单文件)为 .NET 10 新增,本文从项目起步、未依赖它。具体以官方文档为准。