简介:《C# 玩家指南》第三版是一份面向零基础及刚接触 .NET 的编程初学者的系统教程 PDF,帮助读者在 Visual Studio 2017 环境中完成从语法到项目实践的过渡。全书分入门、基础、面向对象、高级主题与工具应用五个层次,依次展开:从环境安装、Hello World 起步,讲解变量、类型系统、运算与用户输入、分支、switch、循环、数组、枚举和方法,再深入类与对象、继承、多态、泛型,并延伸到文件读写、异常处理、线程等进阶内容,末尾给出利用 .NET 平台与 Visual Studio 提升开发效率的建议。压缩包内仅有 1 个 PDF 文件,约 9.27MB,单文件便于通读,正文采用中英对照、目录按章节编号,方便检索定位。已有 131 人学习,适合边敲代码边理解原理、逐章推进,也可作为课程辅助读物用于查漏补缺。
1. 从目录就能看出学习顺序:这份 C# 编程入门指南的用法
翻完五十多章的目录,你会发现这份 C# 编程入门指南真正的价值不在正文,而在它规定的先后顺序:先装 Visual Studio 写 Hello World,再碰变量和类型,面向对象编程被推迟到第 17 章,高级主题压到第 27 章之后,最后五章才讲 .NET 平台、项目文件组织和调试。很多人学 C# 卡住不是语法难,是顺序错了——一上手就啃委托和异步,回头连值类型和引用类型的差别都说不清。这份资源属于跟练型教材,每章配可编译的示例,中间还夹着一个贯穿的井字棋项目。适合零基础但愿意动手的人,也适合写过别的语言、想把 C# 类型系统和工具链补齐的转岗开发者。它不负责让你读完就能写框架,负责的是让你在 Visual Studio 里不再靠猜。
2. 环境与版本:Visual Studio 装完还要对齐的三件事
第三版成书时的环境是 Visual Studio 2017 加 C# 7.0,现在装到手的是 Visual Studio 2022 加 .NET 8。模板默认值几乎换了一遍:入口点变成顶级语句,命名空间变成文件范围,可空引用类型默认打开。很多人的第一天就报销在这里——书上第 3 章那段class Program加static void Main,照着敲进新模板里,编译器直接告诉你已经有一个入口点。版本对齐是最先要做的事,做完之后才谈得上跟练。
2.1 先把版本坐标固定下来:C# 7.3 与目标框架
语言特性是跟着编译器版本走的,不是跟着 IDE 走的。同一个.csproj,LangVersion写 7.3 和写 12,能用到的语法差出一大截。书里覆盖到的上限大致是 C# 7 系列,模式匹配、元组、本地函数这些都在,但switch表达式、记录类型、init访问器这些都没有。把语言版本压回去,你看到的报错就和书上对得上了。
| 差异点 | 书上的写法(C# 7 / VS2017) | 新版模板默认 | 处理办法 |
|---|---|---|---|
| 入口点 | class Program+static void Main | 顶级语句,Program.cs只有一行 | 建项目加--use-program-main,或手写Main |
| using 指令 | 每页显式手写 | ImplicitUsings自动引入一批 | 项目属性里关掉 |
| 可空引用类型 | 不存在这个概念 | 默认 enable,string s = null报警告 | <Nullable>disable</Nullable> |
| 命名空间 | 块式namespace X { } | 文件范围namespace X; | 手写块式 |
| 语言版本 | 7.0 | 12 / 13 | <LangVersion>7.3</LangVersion> |
改csproj是最省事的做法,一次改完,后面所有示例都能按书上的语境跑:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <!-- net48 最贴近书上环境;想用新 SDK 就留 net8.0,但语言版本要压回去 --> <TargetFramework>net8.0</TargetFramework> <!-- 关掉这三样,代码表现才和书上一致 --> <ImplicitUsings>disable</ImplicitUsings> <Nullable>disable</Nullable> <LangVersion>7.3</LangVersion> <!-- 溢出检查默认关闭,和 C# 规范一致;想全局开启改成 true --> <CheckForOverflowUnderflow>false</CheckForOverflowUnderflow> </PropertyGroup> </Project>ImplicitUsings关掉的意义比你想象的大。开着的时候,using System;不写也能编译,你根本不知道Console属于哪个命名空间;关掉之后每缺一个 using 都会红,正好逼你把第 27 章命名空间那套东西学扎实。Nullable关掉是为了避免string?和string的警告噪音盖住真正的错误。LangVersion压到 7.3 有个副作用:顶级语句本身就编译不过了,所以你必须显式写Main,这反而是好事。
2.2 安装时勾什么:工作负载只留必要的那一个
Visual Studio 的安装器默认会推荐一堆工作负载,全勾上轻松吃掉三四十个 G。学这套教程只需要控制台和类库,勾“.NET 桌面开发”一个就够,它带上了 .NET Framework 目标包、.NET SDK 和 C# 编译器。ASP.NET、移动开发、游戏开发这些全不勾,需要的时候随时能从安装器里补。
命令行验证一遍比在 IDE 里点半天可靠:
# 看清楚装了哪些 SDK,版本号决定你能用哪些 TargetFramework dotnet --list-sdks # 建一个和书上结构最接近的控制台项目 # --use-program-main 需要 .NET 7 及以上的 SDK dotnet new console -o HelloCSharp --use-program-main cd HelloCSharp # 确认 csproj 里的 TargetFramework,必要时手动改成 net8.0 并补上 LangVersion cat HelloCSharp.csproj dotnet rundotnet new console在 .NET 6 之后默认生成顶级语句,--use-program-main这个开关是 .NET 7 SDK 才加的。如果你的 SDK 是 6.x,没有这个参数,只能建完项目手动把Program.cs改成class Program { static void Main() { } }的形式。dotnet run会自动先编译再执行,第一次跑会慢几秒,那是 NuGet 还原和编译的开销,不是卡死。
2.3 Hello World 跑通之后必须确认的三件事
跑出Hello World只说明工具链通了,不说明工程结构对了。教程第 3 章专门讲“项目、解决方案、程序集”这三个概念,很多人一带而过,结果第 46 章讲多项目依赖时完全跟不上。这三者的关系是:.csproj是项目,描述编译哪些文件、目标框架是什么;.sln是解决方案,把若干项目组织在一起;程序集是编译产物,落在bin/Debug/net8.0/下面。
using System; using System.Reflection; class Program { static void Main(string[] args) { // 打印当前真正在跑的程序集,确认执行的是你以为的那个产物 var asm = Assembly.GetExecutingAssembly(); Console.WriteLine($"Assembly : {asm.GetName().Name}"); Console.WriteLine($"Version : {asm.GetName().Version}"); Console.WriteLine($"Location : {asm.Location}"); // 运行时版本与框架描述,用来判断是 .NET Framework 还是 .NET Console.WriteLine($"Runtime : {Environment.Version}"); Console.WriteLine($"Framework: {RuntimeInformation_FrameworkDescription()}"); } static string RuntimeInformation_FrameworkDescription() { // .NET Framework 上没有 System.Runtime.InteropServices.RuntimeInformation 时降级处理 try { return System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; } catch { return "unknown"; } } }Assembly.GetExecutingAssembly()拿的是当前代码所在的程序集,Location会直接告诉你 dll 落在哪个目录——这个路径如果指向的是旧的bin,说明你改了代码但没重新生成,后面排查章还会遇到。Environment.Version在 .NET Framework 上返回 4.0.30319 这样的值,在 .NET 8 上返回 8.x,一眼就能分辨跑的是哪套运行时。
要确认的三件事是:第一,csproj里的TargetFramework和LangVersion是不是你要的那套;第二,bin下的产物类型对不对,.NET Framework 产出.exe,.NET 产出.dll加一个 apphost;第三,如果解决方案里有第二个项目,右键菜单里的“设为启动项目”有没有设对,否则 F5 跑的是另一个。
注意:第三版的数字版只在原作者官方渠道分发,邮件转发和网盘流传的副本经常有缺页、目录裁切。页码对不上会让“第 17 章”“第 44 章”这类索引失效,学到一半找不回上下文。来源不明的副本,先核对完整性再决定要不要跟练。
3. 基础语法落地:类型系统的选型表与整数除法的三个陷阱
第二部分是整本书密度最高的地方,第 5 到第 16 章,从变量一路讲到值类型和引用类型。这段内容看着简单,实际上新手项目里 80% 的逻辑 bug 都出在这里:类型选错导致精度丢失,转换写法不同导致差 1,整数除法把小数据算成 0。下面把这几件事拆开,每条都配可运行的代码。
3.1 类型选型表:byte 到 decimal 各自该用在哪
C# 的类型系统是强类型加显式转换,选错类型的代价不是编译不过,而是悄悄丢精度。第 6 章把十几条类型列了个遍,实际项目里真正需要记的是下面这张表。
| 类型 | 范围或精度 | 典型场景 | 不建议用在哪 |
|---|---|---|---|
byte | 0 ~ 255 | 字节流、颜色分量、二进制协议 | 算年龄、算数量 |
short/ushort | ±3.2 万 / 0 ~ 6.5 万 | 大量小整数、节省内存 | 一般业务字段 |
int | ±21 亿 | 默认整数类型,绝大多数计数 | 时间戳、文件大小 |
long | ±9.2×10^18 | 时间戳毫秒、文件字节数 | 循环计数 |
char | 单个 UTF-16 码元 | 单字符处理 | 存中文(代理对会拆开) |
float | 约 7 位有效数字 | 图形、物理模拟 | 金额、坐标累加 |
double | 约 15 ~ 16 位 | 科学计算,小数默认类型 | 金额 |
decimal | 28 ~ 29 位有效数字 | 金额、税率、结算 | 高频运算(慢一个量级) |
bool | true / false | 条件判断 | 用 0/1 代替 |
string | 不可变引用类型 | 文本 | 循环里做拼接 |
using System; class Program { static void Main() { Console.WriteLine($"int : {int.MinValue} ~ {int.MaxValue}"); Console.WriteLine($"byte : {byte.MinValue} ~ {byte.MaxValue}"); Console.WriteLine($"long : {long.MinValue} ~ {long.MaxValue}"); Console.WriteLine($"double : {double.MinValue:E2} ~ {double.MaxValue:E2}"); Console.WriteLine($"decimal : {decimal.MinValue:E2} ~ {decimal.MaxValue:E2}"); // 字面量的默认类型:整数字面量是 int,小数字面量是 double // 注意这里必须加括号,直接写 42.GetType() 会编译不过 Console.WriteLine((42).GetType().Name); // Int32 Console.WriteLine((3.14).GetType().Name); // Double Console.WriteLine((3.14m).GetType().Name); // Decimal,m 后缀 Console.WriteLine((3.14f).GetType().Name); // Single Console.WriteLine((100L).GetType().Name); // Int64 } }凡是带小数点的字面量,编译器默认按double处理,想要decimal必须加m后缀。这一条在金额计算里是灾难源头:decimal price = 19.99;会直接编译报错,因为double到decimal没有隐式转换;而double price = 19.99m;反过来能编译,但会丢精度。我一般规定项目里所有金额字段一律decimal,字面量一律带m,看到不带后缀的小数就当 bug 处理。
类型推断那部分也要留意。var不是动态类型,它在编译期就定死了:var x = 1;里x就是int,后面x = 1.5;编译不过。书里第 6 章末尾专门讲了这个,很多人把它当成弱类型语言来用,结果在后面泛型和 LINQ 上反复翻车。
3.2 四种取整写法:结果真的能差出 1
第 9 章“更多数学”里有半页讲类型转换,看着不起眼,实际是业务代码里最常见的偏差来源。同样是把3.9变成整数,四种写法给出不同的值;更麻烦的是2.5这种正好在中间的值,默认行为是银行家舍入(四舍六入五取偶),不是四舍五入。
using System; class Program { static void Main() { double d = 3.9; int cast = (int)d; // 直接截断小数位 -> 3 int conv = Convert.ToInt32(d); // 四舍六入五取偶 -> 4 int round = (int)Math.Round(d); // 默认也是银行家舍入 -> 4 int floor = (int)Math.Floor(d); // 向下取整 -> 3 Console.WriteLine($"cast={cast}, conv={conv}, round={round}, floor={floor}"); // 正好 .5 的时候,默认行为不是四舍五入 Console.WriteLine(Convert.ToInt32(2.5)); // 2 Console.WriteLine(Convert.ToInt32(3.5)); // 4 Console.WriteLine(Math.Round(2.5, MidpointRounding.AwayFromZero)); // 3 // 负数上的截断方向:向零截断,不是向下取整 Console.WriteLine((int)(-3.9)); // -3 Console.WriteLine((int)Math.Floor(-3.9)); // -4 // 溢出:默认 unchecked 直接截断,checked 抛异常 int big = 300; byte b = (byte)big; Console.WriteLine(b); // 44,300 & 0xFF try { byte c = checked((byte)big); // 显式 checked 上下文 Console.WriteLine(c); } catch (OverflowException ex) { Console.WriteLine("溢出:" + ex.Message); } } }(int)d的语义是舍入到最近的整数、中间值取偶(.NET 的Conv.i4指令行为),和很多人以为的“直接抹掉小数”不一样;Math.Floor才是真正的向下取整,负数上会和截断差 1。checked是个表达式上下文,可以写成checked { ... }包住一段代码块,也可以在项目属性里全局打开CheckForOverflowUnderflow——一般业务项目不开,性能敏感和协议解析项目会开。
300 & 0xFF = 44这条在协议解析里非常常见:长度字段只有一个字节,对面发 300 过来,你按byte收就是 44,帧长度直接错位。所以凡是做二进制解析,转换前先判范围,别指望unchecked帮你兜底。
3.3 整数除法与除零:为什么 7/2 是 3,1.0/0 是 Infinity
第 9 章开头整页都在讲整数除法,这不是啰嗦。同一个表达式,参与运算的类型不同,结果完全不同,而且不报错,只是悄悄错。
using System; class Program { static void Main() { Console.WriteLine(7 / 2); // 3,整数除法向零截断 Console.WriteLine(-7 / 2); // -3,不是 -4 Console.WriteLine(7 % 2); // 1 Console.WriteLine(-7 % 2); // -1,余数符号跟着被除数 Console.WriteLine(7 / 2.0); // 3.5,有一个 double 就按 double 算 double wrong = 7 / 2; // 3.0,先整除了再隐式转 double Console.WriteLine(wrong); // 整数除零直接抛异常 try { int z = 1 / 0; } catch (DivideByZeroException ex) { Console.WriteLine("整数除零:" + ex.GetType().Name); } // 浮点除零不抛异常,得到 Infinity / NaN double inf = 1.0 / 0; double nan = 0.0 / 0.0; Console.WriteLine($"{inf}, {nan}"); Console.WriteLine(double.IsNaN(nan)); // true Console.WriteLine(nan == double.NaN); // false,永远 false } }double wrong = 7 / 2;是最容易写错的一行:右侧两个操作数都是int,先算出3,再隐式转成3.0,赋给double变量。正确写法是7 / 2.0或者7 / (double)2,把其中一个操作数提前变成浮点。
NaN的判断必须用double.IsNaN,任何和NaN的比较都返回 false,包括NaN == NaN。这条在数据清洗里是个隐形炸弹:if (value != double.NaN)永远为真,脏数据一路穿过去。
decimal除以零又是另一种行为,直接抛DivideByZeroException,不走Infinity那套。三种类型三种表现,书里第 9 章有一张小表,值得抄在便签上。
3.4 控制流与数组的最小练习组合
第 10 到 13 章讲if、switch、循环、数组,单独看都很简单,合起来才是能写程序的起点。练习不用搞复杂,一个控制台菜单加数组统计就够,重点是把手感练出来:switch里每个case必须break,foreach遍历时不能改集合大小,数组越界会抛IndexOutOfRangeException。
using System; class Program { static void Main() { // 数组长度在创建时固定,后面不能扩容 int[] scores = { 88, 92, 57, 76, 61 }; Console.WriteLine($"人数:{scores.Length}"); int sum = 0, pass = 0, fail = 0; foreach (int s in scores) { sum += s; if (s >= 60) pass++; else fail++; } // 注意:sum 和 scores.Length 都是 int,除法结果是整数 double avg = (double)sum / scores.Length; Console.WriteLine($"平均分:{avg:F2},及格 {pass} 人,不及格 {fail} 人"); // switch 支持的类型有限:整型、char、string、enum、bool string level = GetLevel(scores[2]); Console.WriteLine($"57 分等级:{level}"); } static string GetLevel(int score) { // C# 7 的 switch 可以用 when 子句做条件细分 switch (score) { case int n when n >= 90: return "A"; case int n when n >= 75: return "B"; case int n when n >= 60: return "C"; default: return "D"; } } }(double)sum / scores.Length里的强制转换不能省,省掉就是整数除法,平均分永远是整数。这个写法在所有统计代码里都通用:先把被除数转成double,再除。
case int n when ...是 C# 7 的模式匹配,书里第 31 章专门讲,第 11 章会先铺垫一次。switch各分支之间没有隐式贯穿,写case "a": case "b":共用逻辑没问题,但忘了break会直接编译报错——这一点比 C 系的老语言安全得多。
4. 面向对象与泛型:类、继承、接口的落地写法
第三部分从第 17 章到第 26 章,是整本书的分水岭。前面都是语法,这里开始讲设计。很多自学者在“类和对象”这关卡很久,原因是概念记住了但写不出能用的类。下面的顺序是按“能跑、能改、能复用”排的,每个小节都给一段能直接粘贴运行的代码。
4.1 值类型与引用类型:一次赋值就能看出差别
第 16 章和第 21 章反复讲这件事,因为它是后面所有 bug 的根源。struct是值类型,赋值时整个复制;class是引用类型,赋值时复制的是引用,两个变量指向堆上的同一个对象。
using System; struct PointStruct { public int X; public int Y; } class PointClass { public int X; public int Y; } class Program { static void Main() { var s1 = new PointStruct { X = 1, Y = 2 }; var s2 = s1; // 值类型:整块复制 s2.X = 99; Console.WriteLine($"s1.X = {s1.X}"); // 1,互不影响 var c1 = new PointClass { X = 1, Y = 2 }; var c2 = c1; // 引用类型:复制引用 c2.X = 99; Console.WriteLine($"c1.X = {c1.X}"); // 99,同一块内存 // 引用为 null 时访问成员 PointClass c3 = null; try { Console.WriteLine(c3.X); } catch (NullReferenceException) { Console.WriteLine("NullReferenceException"); } } }struct没有继承,不能写析构函数,默认构造函数不能被覆盖,字段必须全部可赋值才能离开构造函数。这些约束换来的是没有 GC 压力,所以坐标、颜色、金额这种小数据适合做成struct。但要注意:把struct放进List<T>或者当接口参数传,会发生装箱,反而更慢。第 16 章末尾专门讨论这个取舍。
NullReferenceException那一行在新版 C# 里会被可空引用类型提前警告,但项目里把Nullable关掉了就回到“运行时才炸”的年代,这也是我建议在正式项目里把它打开的原因。
4.2 属性与封装:字段、自动属性、私有 set 怎么选
第 18、19 章用两章讲类、字段和属性。核心就一句话:字段是状态,属性是对外接口。公开字段在 C# 里是合法的,但一旦发版就没法加校验逻辑,所以惯例是字段一律private,对外全用属性。
using System; public class BankAccount { // 私有字段:内部状态,外部碰不到 private decimal _balance; // 只读属性:构造后不可改 public string AccountNo { get; } // 自动属性 + 私有 set:外部可读,类内部可写 public string Owner { get; private set; } // 完整属性:带校验,余额不允许为负 public decimal Balance { get { return _balance; } private set { if (value < 0) throw new ArgumentOutOfRangeException(nameof(value), "余额不能为负"); _balance = value; } } public BankAccount(string accountNo, string owner, decimal initial) { AccountNo = accountNo ?? throw new ArgumentNullException(nameof(accountNo)); Owner = owner; Balance = initial; } public void Deposit(decimal amount) { if (amount <= 0) throw new ArgumentException("金额必须大于 0", nameof(amount)); Balance += amount; } public bool Withdraw(decimal amount) { if (amount > Balance) return false; Balance -= amount; return true; } } class Program { static void Main() { var acc = new BankAccount("A001", "测试账户", 1000m); acc.Deposit(200m); Console.WriteLine(acc.Withdraw(5000m)); // False Console.WriteLine(acc.Balance); // 1200 } }public string AccountNo { get; }这种只读自动属性只在构造函数里能赋值,等价于readonly字段加一个 getter。Owner用了private set,类内部的方法能改,外部不行。Balance写了完整的 getter 和 setter,因为要加校验;实际项目里如果校验逻辑简单,也可以在set里只写一行。
nameof(value)在参数校验里很实用,重构改变量名的时候字符串会跟着变。?? throw new ArgumentNullException是空值合并加抛异常的组合写法,比if (x == null) throw ...短一行。
4.3 抽象类与接口:什么时候必须用接口
第 22 到 24 章连着讲继承、多态、接口。这三样经常被混着用,其实判断标准很清楚:抽象类表达“是什么”,只能单继承;接口表达“能做什么”,可以多实现。真要设计一个可替换的组件,接口的扩展性永远比抽象类好。
using System; using System.Collections.Generic; // 抽象类:共享部分实现,强制子类给出面积算法 public abstract class Shape { public string Name { get; protected set; } public abstract double Area(); public virtual string Describe() => $"{Name},面积 {Area():F2}"; } // 接口:只约定能力,不约定实现 public interface IPrintable { string ToPrintLine(); } public class Circle : Shape, IPrintable { public double Radius { get; set; } public Circle(double r) { Radius = r; Name = "圆"; } public override double Area() => Math.PI * Radius * Radius; public string ToPrintLine() => $"[{Name}] r={Radius}"; } public class Rectangle : Shape, IPrintable { public double W { get; set; } public double H { get; set; } public Rectangle(double w, double h) { W = w; H = h; Name = "矩形"; } public override double Area() => W * H; public string ToPrintLine() => $"[{Name}] {W}x{H}"; } class Program { static void Main() { var shapes = new List<Shape> { new Circle(2), new Rectangle(3, 4) }; foreach (Shape s in shapes) { Console.WriteLine(s.Describe()); // 多态调用,走子类实现 if (s is IPrintable p) // 模式匹配 + 类型判断 Console.WriteLine(" 可打印:" + p.ToPrintLine()); } } }abstract方法没有方法体,子类必须override。virtual方法有默认实现,子类可以覆盖也可以不覆盖。s.Describe()在编译期看的是Shape,运行期走的是实际类型的方法,这就是多态。if (s is IPrintable p)是 C# 7 的模式匹配写法,一步完成类型检查和变量声明,比as加判空干净。
接口命名惯例是I前缀,方法只写签名。有个边界要注意:默认接口实现是 C# 8 才有的特性,第三版覆盖的语言版本里没有,所以接口里不能带方法体,写上去会编译报错。
4.4 泛型:从 List 到自己写带约束的容器
第 25、26 章讲泛型。用List<T>谁都会,难的是自己写泛型类型时约束怎么定。约束写在where后面,决定你能在类型参数上调用哪些成员。
using System; using System.Collections.Generic; // 约束组合:必须是引用类型、有无参构造函数、实现 IComparable<T> public class Repository<T> where T : class, IComparable<T>, new() { private readonly List<T> _items = new List<T>(); public int Count => _items.Count; public void Add(T item) { if (item == null) throw new ArgumentNullException(nameof(item)); _items.Add(item); } // 没有约束的话,这里连 CompareTo 都调不了 public T Max() { if (_items.Count == 0) throw new InvalidOperationException("集合为空"); T max = _items[0]; foreach (T item in _items) if (item.CompareTo(max) > 0) max = item; return max; } public T CreateDefault() => new T(); // new() 约束带来的能力 } public class Version2 : IComparable<Version2> { public int Major { get; set; } public int CompareTo(Version2 other) => Major.CompareTo(other.Major); public override string ToString() => "v" + Major; } class Program { static void Main() { var repo = new Repository<Version2>(); repo.Add(new Version2 { Major = 3 }); repo.Add(new Version2 { Major = 7 }); Console.WriteLine(repo.Max()); // v7 Console.WriteLine(repo.CreateDefault()); // v0 } }where T : class限制成引用类型,struct则限制成值类型,两个不能同时写。new()约束要求类型有公开无参构造函数,加了它才能写new T()。IComparable<T>这种接口约束让编译器知道 `T
本文还有配套的精品资源,点击获取