C#编程入门指南:学习顺序、环境对齐与类型系统实践
简介《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 走的。同一个.csprojLangVersion写 7.3 和写 12能用到的语法差出一大截。书里覆盖到的上限大致是 C# 7 系列模式匹配、元组、本地函数这些都在但switch表达式、记录类型、init访问器这些都没有。把语言版本压回去你看到的报错就和书上对得上了。差异点书上的写法C# 7 / VS2017新版模板默认处理办法入口点class Programstatic void Main顶级语句Program.cs只有一行建项目加--use-program-main或手写Mainusing 指令每页显式手写ImplicitUsings自动引入一批项目属性里关掉可空引用类型不存在这个概念默认 enablestring s null报警告Nullabledisable/Nullable命名空间块式namespace X { }文件范围namespace X;手写块式语言版本7.012 / 13LangVersion7.3/LangVersion改csproj是最省事的做法一次改完后面所有示例都能按书上的语境跑Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeExe/OutputType !-- net48 最贴近书上环境想用新 SDK 就留 net8.0但语言版本要压回去 -- TargetFrameworknet8.0/TargetFramework !-- 关掉这三样代码表现才和书上一致 -- ImplicitUsingsdisable/ImplicitUsings Nullabledisable/Nullable LangVersion7.3/LangVersion !-- 溢出检查默认关闭和 C# 规范一致想全局开启改成 true -- CheckForOverflowUnderflowfalse/CheckForOverflowUnderflow /PropertyGroup /ProjectImplicitUsings关掉的意义比你想象的大。开着的时候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 章把十几条类型列了个遍实际项目里真正需要记的是下面这张表。类型范围或精度典型场景不建议用在哪byte0 ~ 255字节流、颜色分量、二进制协议算年龄、算数量short/ushort±3.2 万 / 0 ~ 6.5 万大量小整数、节省内存一般业务字段int±21 亿默认整数类型绝大多数计数时间戳、文件大小long±9.2×10^18时间戳毫秒、文件字节数循环计数char单个 UTF-16 码元单字符处理存中文代理对会拆开float约 7 位有效数字图形、物理模拟金额、坐标累加double约 15 ~ 16 位科学计算小数默认类型金额decimal28 ~ 29 位有效数字金额、税率、结算高频运算慢一个量级booltrue / 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); // Decimalm 后缀 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); // 44300 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 是 31.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必须breakforeach遍历时不能改集合大小数组越界会抛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放进ListT或者当接口参数传会发生装箱反而更慢。第 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 ListShape { 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 章讲泛型。用ListT谁都会难的是自己写泛型类型时约束怎么定。约束写在where后面决定你能在类型参数上调用哪些成员。using System; using System.Collections.Generic; // 约束组合必须是引用类型、有无参构造函数、实现 IComparableT public class RepositoryT where T : class, IComparableT, new() { private readonly ListT _items new ListT(); 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 : IComparableVersion2 { 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 RepositoryVersion2(); 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()。IComparableT这种接口约束让编译器知道 T本文还有配套的精品资源点击获取