
直接进入正题。入行这些年我带过不少新人也面试过很多自诩“会C#”的候选人。我发现一个特别普遍的现象很多人语法背得滚瓜烂熟ListT、Dictionary用得飞起lambda表达式写得比谁都溜但你问他“面向对象到底是什么”“你为什么要在项目里用继承”他就开始支支吾吾了。这不能完全怪他。我们很多教材和网课把面向对象讲成了一堆定义和术语。什么“封装就是把数据和行为绑定在一起”什么“多态就是同一操作作用于不同对象产生不同执行结果”——每个字都认识连在一起完全不知道在说什么。这篇文章不准备讲那些花哨的东西。我打算用最直白的语言配合代码实例把C#面向对象这层窗户纸捅破。看完之后你再回头看你手头的WinForms或者WPF项目会发现很多东西的理解完全不一样了。1. 为什么你写了两年代码依然觉得面向对象是个玄学先说个我自己的经历。早年我还在用C写单片机程序的时候整个程序就是一个巨大的main函数配上各种功能的.c文件和.h文件。数据放在全局变量里谁想改就改改到后面自己都不记得哪个变量被谁动了。程序上万个模块交织在一起牵一发动全身改一个功能恨不得把整个工程重新读一遍。后来转C#做上位机接触到了面向对象开始也是一头雾水。我心里就一个疑问不就是写代码吗搞这么复杂干嘛这个疑问其实就是没想明白一个根本问题——面向对象到底在解决什么问题1.1 面向对象不是语法是一种组织代码的思维你可以把写代码想象成做饭。面向过程的方式就像你一个人冲进厨房所有步骤都在你脑子里先洗菜、再切菜、开火热油、下锅翻炒、加盐起锅。每一步都是线性流程所有食材数据堆在灶台上全局变量你想拿就拿。但如果是给五十个人做宴席呢你一个人根本忙不过来。你需要分工有人专门洗菜、有人专门切配、有人专门掌勺、还有人专门管调料。每个人都只管自己那一摊事互相之间通过约定好的流程协作。面向对象就是把一摊大活按“谁负责什么”拆成一个个小块。每个小块就是一个对象。对象内部有自己需要的数据字段有自己能做的事情方法。外部不需要关心它内部怎么实现只需要告诉它“帮我做件事”就行。这就引出了面向对象的第一个核心价值职责划分。你去看看那些写得好的开源库比如HttpClient、SqlConnection、StreamReader无一不是职责清晰的类。你用HttpClient发请求根本不用关心TCP连接怎么管理、HTTP报文怎么拼接你只需要调PostAsync传个URL和内容它就把结果给你返回来。这就是职责划分带来的好处。1.2 面向过程的代码为什么会变质很多人写C#其实写的是“披着类外衣的面向过程”。最典型的表现就是一个窗体Form1.cs里密密麻麻写了几千行代码。从界面初始化、数据校验、数据库操作到业务逻辑、报表生成全都塞在里面。代码在物理上是在类里面但在思想上完全是过程式的——从上往下按按钮点击顺序一条道走到黑。这种代码在初期开发的时候很爽因为不用动脑子设计想到哪写到哪。但项目一旦超过三千行、五千行噩梦就来了添加一个新功能你不知道应该改哪里怕改A的地方影响B的功能。两个窗体都需要一段类似的逻辑你复制粘贴了两份后来改了一处忘了另一处。测试的时候无从下手因为所有逻辑都跟界面绑在一起没法单独验证。人员交接极其困难新来的同事打开这个文件心理直接崩溃。这些痛苦全是职责不清晰导致的。而面向对象就是通过“类”这个工具强制你把代码按职责切块。与其说面向对象是一种技术不如说它是一种对抗代码混乱的纪律。1.3 一句话说清楚C#面向对象的全貌很多讲面向对象的资料都会提“三大特性”——封装、继承、多态。从考试角度这三样确实要背。但理解这三样东西不能死记定义你得明白它们各自是为了解决什么实际问题而存在的。用我做C#上位机的理解来翻译一下封装把复杂度关进笼子里。外部只看到简单的操作按钮内部再复杂也是自己的事。继承把公共的东西抽出来避免重复写同样的代码。猫是动物狗是动物它们都有吃、睡这些共同行为那就先定义个“动物”基类。多态同样的代码面对不同的对象表现出不同的行为。都是“动物”类型你调用“叫”这个行为猫是喵喵狗是汪汪。代码写的是animal.MakeSound()但实际执行的结果取决于具体是猫还是狗。这三条我后面都会用实际代码详细展开。现在你只需要在脑子里有一个模糊的图景知道它们分别是干嘛的就行封装 统筹安排对外简单对内复杂。继承 提取共性避免重复。多态 面向抽象编程代码的扩展性靠它。2. 类和对象先把这张“图纸”和“房子”印在脑子里有了大概的概念现在开始动手。C#里面面向对象最基本的单元就是类class。我见过太多新手在这第一步就栽跟头因为搞不清楚“类”和“对象”的区别。2.1 举个例子让它变具体你去买房子售楼处给你看户型图。那张图纸上讲得很清楚这一间是主卧这一间是客厅阳台朝南面积120平米。但图纸本身不是房子你不住在图纸里你住的是按图纸盖出来的、有实际门牌号的那套房子。这里面的关系类就是那张图纸。它规定了房子应该有什么——有客厅、有卧室、有厨房、有卫生间每个地方长什么样。对象就是按图纸盖出来的具体房子。它真实存在占着具体地址你可以推门进去住。同一张图纸可以盖出无数套一模一样的房子。同一个类可以创建出无数个各自独立的对象。这套比喻对应到代码里就是这样的。假设你要写一个学生管理系统那“学生”就是一个自然存在的业务概念。每个学生都有学号、姓名、年龄每个学生都能做“选课”“交作业”这些动作。用C#代码来表达“学生”这个图纸就是定义一个类// 这就是“图纸”定义了“学生”这种事物应该具有的数据和行为 public class Student { // 字段用来保存数据每个学生对象都有一份自己的数据 public string StudentId; public string Name; public int Age; // 方法用来表达行为每个学生都能做“自我介绍”这件事 public void Introduce() { Console.WriteLine($大家好我是{Name}今年{Age}岁学号是{StudentId}。); } }定义了Student这个类只是声明了“学生应该有这些东西”但此时内存里什么都没有。要想真正创建一个学生出来得用new关键字按照图纸来“盖房子”// 这就是“对象”是按照Student这张图纸实实在在“盖”出来的一个学生 Student student1 new Student(); student1.StudentId 2024001; student1.Name 张小明; student1.Age 20; student1.Introduce(); // 输出大家好我是张小明今年20岁学号是2024001。 // 用同一张图纸再“盖”一套完全独立的房子 Student student2 new Student(); student2.StudentId 2024002; student2.Name 李小红; student2.Age 21; student2.Introduce(); // 输出大家好我是李小红今年21岁学号是2024002。看到了没有Student这个类就是一个模板它本身不携带具体的“张小明”“李小红”这些数据。只有当你new出一个对象之后内存里才真正有了一块空间用来存放这个具体学生的数据。这个过程在面向对象里称为实例化。所以你在各种技术文章里会看到“类的实例”这种说法——“实例”就是“对象”的另一种叫法指的是同一个东西。2.2 字段、属性、方法类里面的三个角色刚才那个Student类里用了public string StudentId;来存数据这在C#里叫字段field。我刚入行的时候分不清“字段”和“属性”有什么区别经常混着用。这里必须掰开讲清楚因为这是C#面向对象里最基础的语法考点也是实际开发中最常用的东西。简单说字段是一个存储位置。它在内存里实实在在分配了一块地方用来保存数据。你可以把它理解成一个公共记事本谁都能往上面写东西。属性是字段的“门卫”。它不直接存储数据通常但是控制别人读写数据的规则——是只能读只能写还是读了之后顺便干点别的举个例子。学生年龄这是一个业务数据但年龄不能是负数也不能超过200。如果用一个裸字段public int Age;那么任何代码都能直接给Age赋一个-999程序也不会报错但业务上就出现了脏数据。有了属性这个“门卫”就可以在赋值时做检查public class Student { // 真正的数据存在这个私有字段里 private int age; // 属性是对外的“门卫”所有对Age的读写都走这里 public int Age { get { return age; } set { if (value 0 || value 150) { throw new ArgumentOutOfRangeException(年龄必须在0到150之间); } age value; } } }看上面这段。外界的代码写student.Age 18实际上走的是set方法数据存到私有字段age里。外界代码读int a student.Age走的是get方法。这样Age就被“门卫”罩住了非法值进不来。现在再回头看“封装”这个概念是不是就通透了封装的两个核心动作一是把数据藏在私有字段里不让人直接碰二是通过属性或方法来提供受控的访问入口。至于C#里更常见的简写public int Age { get; set; }它是编译器帮你自动生成一个匿名字段和默认读写逻辑的语法糖。日常写代码够用了但你要知道背后有“门卫”这个机制存在这样将来遇到需要在赋值时做校验的业务场景才知道该怎么改。2.3 为什么字段通常要设成private新手最常犯的一个错误就是把所有字段都设成public。这么写一时爽写多了就乱套。因为一旦你对外公开了字段所有外部代码都可以绕过你的校验逻辑直接改数据。今天大家约定“先用public凑合以后有时间再改”等到项目一复杂这个字段被10个地方引用了你根本不敢改成属性——改一处就会引发十处报错。所以请从今天开始养成习惯能设private就设private能设public的只有两类东西——对外提供服务的方法以及确实需要公开的属性。记住一条经验法则类内部的事情类自己管。别人只能通过你给的入口属性、方法来与你协作。3. 手把手写第一个“像样”的类完整剖析一个真实例子光看不说假把式接下来我们一起动手从零设计一个稍微完整的类把前面讲的知识串一遍。这次我们做一个银行账户的例子。选择这个案例是因为它特别典型有数据余额、账号、户主有操作存款、取款、查询还有业务规则余额不能为负、取款不能超额。这种东西在面试里经常出现在实际业务系统里更是遍地都是。3.1 先做需求分析再写代码我见过太多新手拿到需求就上手敲代码敲到一半发现设计得不合理又推倒重来。正确的做法是先想清楚这个类要对外提供什么能力要维护什么数据要守住什么规则银行账户的需求用大白话列出来一个账户有账号、户主姓名、当前余额。可以向账户里存钱存的钱必须大于0。可以从账户里取钱取的钱必须大于0而且不能超过当前余额。可以查询当前余额。最好还能记一下交易笔数方便对账用。现在用面向对象的思想把这几条变成代码设计数据_accountNumber、_ownerName、_balance、_transactionCount。这些全是账户自己内部的状态外部不能随便动所以全部设成private。操作Deposit(decimal amount)、Withdraw(decimal amount)、GetBalance()。这是外部可以调用的方法设成public。规则存款金额、取款金额的合法性检查放在各自的方法内部。代码写出来就是这个样子public class BankAccount { // 私有字段只能在类内部访问 private readonly string _accountNumber; private readonly string _ownerName; private decimal _balance; private int _transactionCount; // 构造函数创建对象时执行把必填的初始数据传进来 public BankAccount(string accountNumber, string ownerName, decimal initialBalance) { _accountNumber accountNumber; _ownerName ownerName; _balance initialBalance; _transactionCount 0; } // 对外的方法存钱 public void Deposit(decimal amount) { if (amount 0) { throw new ArgumentException(存款金额必须大于0, nameof(amount)); } _balance amount; _transactionCount; } // 对外的方法取钱 public bool Withdraw(decimal amount) { if (amount 0) { throw new ArgumentException(取款金额必须大于0, nameof(amount)); } if (amount _balance) { return false; // 余额不足取款失败 } _balance - amount; _transactionCount; return true; } // 对外的方法查询余额 public decimal GetBalance() { return _balance; } // 对外的方法查询交易笔数 public int GetTransactionCount() { return _transactionCount; } }然后在Main方法里像这样使用这个类BankAccount account new BankAccount(6222 0212 3456 7890, 王小二, 1000); account.Deposit(500); Console.WriteLine($存款后余额{account.GetBalance()}); // 1500 bool success account.Withdraw(2000); Console.WriteLine($取款2000成功吗{success}); // False余额不足 success account.Withdraw(800); Console.WriteLine($取款800成功吗{success}); // True Console.WriteLine($取款后余额{account.GetBalance()}); // 700 Console.WriteLine($交易笔数{account.GetTransactionCount()}); // 2这个例子麻雀虽小五脏俱全。你把这份代码在Visual Studio里跑一遍然后想一想下面两个问题想清楚了面向对象的基础就算打牢了如果在外界直接写account._balance 99999;能编译通过吗如果不通过Deposit方法还有没有其他方法能让account的余额变化答案分别是“不能”和“没有”。因为余额字段被private保护着唯一的入口就是Deposit和Withdraw而这两个方法里都有业务校验。这就是封装带来的确定性和安全感——你知道数据只能通过你允许的通道被修改所有的规则都会被执行不存在漏网之鱼。3.2 构造函数对象一出生就要执行的初始化逻辑刚才代码里的BankAccount(string accountNumber, string ownerName, decimal initialBalance)这个东西叫做构造函数constructor。构造函数是面向对象里一个非常重要但也容易被新手忽略的概念。很多人会问为什么一定要构造函数我创建对象之后再一个个字段赋值不行吗在Student那个例子里确实可以。但回到银行账户这个例子你会发现_accountNumber是一个readonly字段——意味着它一旦被赋值就不能再改。如果不在构造函数里初始化这个字段就永远是默认值null后面想改也改不了。再往深一层想一个银行账户如果连账号和户主都没有它还能算是一个合法的账户吗显然不能。构造函数存在的意义就是保证“只要对象被创建成功了它就一定是一个合法的、完整的对象”。你可以把构造函数理解成一套“出生程序”——孩子生下来必须哭第一声、打第一针疫苗这些事不是你想不想做而是对象存在的必要条件。C#的构造函数有几个特点实际开发中会经常用到构造函数的名字必须和类名一样而且没有任何返回值。构造函数可以重载。你可以定义一个无参构造函数、一个带账号参数的构造函数、一个全参数构造函数调用new的时候会按你传的参数自动匹配。如果你没有写任何构造函数编译器会生成一个默认的无参构造函数。但只要你写了任何一个带参数的构造函数编译器就不再自动生成那个默认的无参构造函数了。这个细节很多人踩过坑尤其是做序列化、做对象初始化的时候会莫名其妙报错。3.3 对象初始化器偷懒但高效的替代方案刚才说构造函数可以保证对象合法但有时候我们确实不想写一堆参数。比如你在UI界面做了一个“添加学生”的窗口用户可能先填了姓名还没填学号这时候如果构造函数强制要求所有参数你反而不好处理。C#提供了一种语法——对象初始化器让你在new的时候直接用大括号给属性赋值。比如Student s new Student { StudentId 2024010, Name 赵小六, Age 19 };这种方式表面上看起来像“创建之后再赋值”但它的实现原理是在构造函数执行完之后编译器自动生成代码对属性逐个赋值。它有几个好处代码更紧凑不用一行行写赋值语句。可以不给某些属性赋值剩下的保持默认值。不需要为每一种参数组合都写一个构造函数。但要注意对象初始化器只能在构造函数允许你new的前提下使用。如果一个类只有带参构造函数而没有无参构造函数你想用对象初始化器对象得先通过那个带参构造创建出来这就得看具体设计了。面试的时候遇到过这个问题这里提醒一下。4. 封装、继承、多态三大特性的C#落地写法前面零零散散已经提到了封装。这一节把三大特性一次性讲透并且给出C#的具体代码写法。这三个概念相互关联但又各自解决不同的问题。4.1 封装不只是private而是一种“契约精神”封装在C#里的第一层体现就是访问修饰符。C#常用的一共五个访问修饰符可访问范围怎么记public所有代码都能访问全开放private只有自己类内部能访问最封闭protected自身类 派生类能访问子类可以用internal同一程序集内能访问同一项目内部用protected internal同一程序集 派生类以上两种的并集新手阶段你只需要掌握public和private就够了protected在讲继承的时候再说internal和protected internal遇到多项目解决方案的时候再研究。封装的第二层体现是**“属性”这个门卫**。前面在Student的Age属性里已经演示过了。实际开发里属性里常见的逻辑有几种只读属性只写get不写set。这种属性通常用于对外暴露计算出来的结果。带校验的写入在set里检查value的合法性。引发事件数据变化时通知其他代码比如Balance变化后触发一次UI更新。举个例子我们来给BankAccount加一个只读属性Balance替换掉之前的GetBalance()方法这是业界更常用的写法public decimal Balance { get { return _balance; } }外部代码调用的时候更自然decimal b account.Balance;。这就是封装的魅力——外部只感觉到读了一个属性但内部返回的可能是一个私有字段也可能是一个实时计算的结果。外部根本不用关心。4.2 继承代码复用的陷阱和礼物继承解决的问题是消除重复代码。举个例子。你的上位机项目里要控制多种仪表温度计、压力计、流量计。这些仪表都有一些共性——都有设备地址都要建立连接都有读取数据的操作也都有关闭连接的操作。如果每种仪表都单独写一个类你会发现大量重复代码堆在那里连来连去都是那么几句。这时候就可以抽一个基类public class DeviceBase { public string DeviceAddress { get; set; } public void Connect() { Console.WriteLine($正在连接设备{DeviceAddress}); // 这里写通用的连接逻辑 } public virtual void ReadData() { Console.WriteLine(设备数据读取中...); } public void Disconnect() { Console.WriteLine($断开设备连接{DeviceAddress}); } }然后让温度计继承这个基类public class TemperatureMeter : DeviceBase { public override void ReadData() { base.ReadData(); // 先执行基类的通用逻辑 Console.WriteLine(读取温度数据36.5°C); } public void Calibrate() { Console.WriteLine(温度计校准完成); } }这里涉及三个关键字是C#继承的基石:——表示继承关系。TemperatureMeter : DeviceBase读作“温度计继承自设备基类”。virtual——标记基类中的方法“可以被重写”。如果基类方法没加virtual子类是不能override的。override——表示子类要“重写”基类的虚方法实现自己的版本。关于base关键字它是用来在子类中显式调用基类成员的。上面的例子中base.ReadData()意思是先跑基类ReadData里的通用逻辑再跑子类特有的逻辑。这种“先通用后特殊”的写法在实际项目中非常常用。继承的好处很明显共同逻辑写在基类里子类只需要写自己特有的东西。以后要修改连接逻辑只改基类一处所有仪表都生效。但继承也有一个新手容易踩的大坑滥用继承。这里必须单独说一句。很多人在学习阶段看到继承“很香”就什么都想继承一下。比如为了复用GetTotal()方法让ShoppingCart继承Calculator为了让Teacher能Eat()让它继承Human。这种“为了复用而强行继承”的做法会导致类之间的耦合非常严重父类改一个小功能所有子类跟着遭殃。实际上C#里更推荐的做法是“组合优于继承”。简单理解就是如果你想复用一个类的功能可以在自己的类里创建这个类的对象然后在自己的方法里调用它不一定要继承。比如TemperatureMeter里可以有private Logger _logger new Logger();然后在自己方法里调用_logger.WriteLog(...)。这样复用起来灵活得多不会因为继承层级过深而牵一发动全身。4.3 多态让一段代码替无数种行为代言多态是三大特性里最抽象的也是面试问得最多的。我们直接看例子。还是接着上面仪表的例子。现在你在上位机程序里维护了一个设备列表里面有温度计、压力计、流量计各一台。你要做一件事循环遍历它们挨个读取数据。如果没有多态你得这么写if (device is TemperatureMeter temp) { temp.ReadData(); } else if (device is PressureMeter pres) { pres.ReadData(); } else if (device is FlowMeter flow) { flow.ReadData(); }这还只是三种设备换成十种呢每次新增一个设备类型就要来这里加一个else if。这种代码写多了你会觉得很痛苦——不只是累而是每次改都怕把原来的逻辑弄坏。有了多态代码变成这样ListDeviceBase devices new ListDeviceBase { new TemperatureMeter { DeviceAddress 01 }, new PressureMeter { DeviceAddress 02 }, new FlowMeter { DeviceAddress 03 } }; foreach (DeviceBase device in devices) { device.ReadData(); // 具体执行哪个版本由对象的真实类型决定 }这里就是多态的核心编译时编译器只知道device是DeviceBase类型但运行时它会根据device实际指向的对象自动调用对应子类的ReadData。如果三个子类都override了自己的ReadData那么这段遍历代码就分别输出了三种设备的读取结果而你写的只此一行。将来再增加一个新的设备类比如HumidityMeter只要它继承DeviceBase并重写ReadData上面这段遍历代码一行都不用改直接就能工作。这个特性带来的实际价值你已经能感受到了——扩展系统不再需要修改已有代码只需要新增代码。不少讲设计模式的书上把这个称作“开闭原则”对扩展开放对修改关闭。多态是这一切的底层基石。那么多态在C#里具体有哪些语法实现实际开发中常见的有三种虚方法virtualoverride就是上面DeviceBase那样基类有默认实现子类可以覆盖它。抽象方法abstract基类只声明方法签名不做实现强制所有子类必须自己实现。接口interface连“基类”都谈不上完全是一份“合同”规定实现它的人必须有哪些能力但完全不关心你怎么实现。在后面的实战案例里这三种方式都会遇到。这里你先记住一句话面向对象编程很多时候就是在“面向抽象编程”而不是“面向具体实现编程”。多态就是让你写出“只管跟抽象打交道”的代码具体的执行细节由运行时决定。5. 实战小案例用面向对象写一个极简的学生选课系统基础概念讲再多不如撸一个完整的小项目。这一节我们做一个极简的选课系统。需求背景学校里有学生和课程两类核心角色。一个学生可以选择多门课程每门课程有课程名、学分和授课老师。系统需要支持学生选课、退课并且能打印出学生所有课程的总学分。先别急着看代码你按前面讲的分析思路自己在脑子里过一遍有哪些业务概念——学生、课程。可能还有一个“选课记录”但极简系统可以先用集合处理。每个概念有哪些数据——学生有姓名、学号、已选课程列表课程有课程编号、名称、学分。每个概念有哪些行为——学生有选课、退课、算总学分课程基本不需要主动行为先当成纯数据类。哪些数据要对外暴露哪些要受控——学生已选的课程列表肯定不能随便改得通过选课和退课行为来修改。5.1 先定义课程类课程是一个相对简单的对象用类加上几个属性就够了。public class Course { public string CourseId { get; set; } public string CourseName { get; set; } public int Credit { get; set; } // 构造函数确保课程创建时至少要有关键信息 public Course(string courseId, string courseName, int credit) { CourseId courseId; CourseName courseName; Credit credit; } public override string ToString() { return $[{CourseId}] {CourseName}{Credit}学分; } }这里用了一个ToString重写。很多新手看不懂为什么要重写它。其实很简单当你Console.WriteLine(course)时默认打印的是类型全名也就是面向对象基础.Course这种。重写ToString让它返回有意义的信息——这在调试和日志输出时非常有用是一个值得养成的好习惯。5.2 定义学生类学生类比课程类复杂一些因为它有一个行为集合选课、退课、算总学分。public class Student { public string StudentId { get; } public string Name { get; } // 已选课程列表外部只能读不能直接改 private ListCourse _courses new ListCourse(); // 对外暴露只读副本防止外部直接修改内部列表 public IReadOnlyListCourse Courses _courses.AsReadOnly(); public Student(string studentId, string name) { StudentId studentId; Name name; } // 选课先检查是否重复再添加 public bool Enroll(Course course) { if (_courses.Any(c c.CourseId course.CourseId)) { Console.WriteLine(${Name} 已选过课程 {course.CourseName}不能重复选择。); return false; } _courses.Add(course); Console.WriteLine(${Name} 成功选课{course.CourseName}); return true; } // 退课找到对应课程并移除 public bool Drop(Course course) { var target _courses.FirstOrDefault(c c.CourseId course.CourseId); if (target null) { Console.WriteLine(${Name} 并未选择课程 {course.CourseName}退课失败。); return false; } _courses.Remove(target); Console.WriteLine(${Name} 成功退课{course.CourseName}); return true; } // 计算总学分 public int GetTotalCredits() { return _courses.Sum(c c.Credit); } // 打印课表 public void PrintTranscript() { Console.WriteLine($学生{Name}{StudentId}的课表); foreach (var course in _courses) { Console.WriteLine($ - {course}); } Console.WriteLine($总学分{GetTotalCredits()}); } }这里有两个设计细节值得你细品对外暴露的是IReadOnlyListCourse而不是ListCourse。这是封装思想的体现。如果你对外暴露的是ListCourse那么外部代码可以直接student.Courses.Add(new Course(...))来绕过Enroll方法重复选课检查就形同虚设了。用IReadOnlyList把列表变成只读视图外部只能“看”不能“动”一切修改必须走Enroll和Drop方法。这一招面试时提出来能加不少分。方法返回bool表示操作结果而不是直接抛异常。业务上“重复选课”“退不存在的课”属于可预见的业务分支用返回值让调用方决定下一步做什么比直接抛异常更友好。这属于一种约定俗成的设计取舍。再来看Main方法里怎么调用Course math new Course(MATH101, 高等数学, 6); Course cs new Course(CS101, C#程序设计, 4); Course english new Course(ENG101, 大学英语, 3); Student zhang new Student(S2024001, 张小明); zhang.Enroll(math); zhang.Enroll(cs); zhang.Enroll(cs); // 重复选课会提示失败 zhang.Drop(math); zhang.Enroll(english); zhang.PrintTranscript();运行结果如下张小明 成功选课高等数学 张小明 成功选课C#程序设计 张小明 已选过课程 C#程序设计不能重复选择。 张小明 成功退课高等数学 张小明 成功选课大学英语 学生张小明S2024001的课表 - [CS101] C#程序设计4学分 - [ENG101] 大学英语3学分 总学分7代码量虽然不多但整个系统的数据安全性、可扩展性远比把所有东西塞在一个Main函数里要好。你以后做一个WinForms界面直接把Student和Course对象跟界面控件绑定就行业务逻辑完全独立于界面这部分就是可复用、可测试的。5.3 如果要继续扩展你能怎么做现在这个极简系统已经能跑了。想象一下如果你要继续往上加功能可能会遇到什么新增“教师”这个类可以让Teacher关联多个Course这就是在原有类基础上扩展。新增“成绩”功能每一门课程要有分数这时你会发现原来的简单列表不够用了需要设计一个Enrollment类来保存“学生、课程、分数”这三元关系。界面层如果你不想用控制台做一个WinForm界面只需把Student和Course的属性和方法绑定到控件就行。你会发现原来的类不用大改只是往里面加东西或者增加新的类。这就是“对扩展开放对修改关闭”的感觉。面向对象设计得越合理后续扩展就越省力。6. 新手学面向对象最容易踩进去的三个坑基础讲完了再分享三个几乎每个初学者都会踩的坑。这些都是我在实际带人过程中反复见到的写出来帮你提前避雷。6.1 坑一把字段、属性的访问级别全部设置为public前面已经反复强调过了但还是要单独列出来因为它太普遍了。很多人的代码是这样public class Order { public string OrderId; public string CustomerName; public decimal TotalPrice; public string Status; }看起来很简洁但问题在于类一旦被公开你就无法对数据的修改进行任何干预。比如Status字段按业务规则应该经过“待支付 - 已支付 - 已发货 - 已完成”这样的状态流转你直接把它做成public别人随时可以给它赋一个“已取消”没有任何校验。正确姿势业务数据至少要写成属性涉及状态流转的必须使用方法加校验。6.2 坑二为了继承而继承有些人学了继承之后恨不得把自己所有类都塞到一个继承体系里。我见过有人给一台设备写了五层继承object - DeviceBase - CommunicationDevice - SerialDevice - TemperatureMeter。每一层好像都有点抽象道理但实际上绝大多数功能都集中在最后一层前面几层全是空壳。判断继承是否合理的简单标准是子类和基类之间是不是一种“is-a”是一个的关系。比如“温度计是一种设备”这个成立所以继承没问题。“购物车是一种计算器”这个不成立所以不应该继承。如果只是“我想要用到它的方法”那就用组合在新类里new一个旧类对象用就行。组合比继承灵活得多不存在后者的强耦合。6.3 坑三不理解引用类型到处修改同一个对象C#里的类class是引用类型。引用类型的意思是变量里存的不是对象本身而是对象在内存中的“门牌号”。看这个常见的坑Student s1 new Student(S001, 张三); Student s2 s1; // 这里没有复制一份“张三”只是复制了门牌号 s2.Name 李四; // 改的是同一个门牌号指向的房间 Console.WriteLine(s1.Name); // 输出的是“李四”很多人在这里栽跟头以为s2 s1是复制了一份学生对象结果修改s2影响到了s1疯狂找bug。要真正“复制一份独立对象”得自己实现深拷贝逻辑——创建一个新对象把所有字段一个个赋值过去。这也是为什么很多时候我们不喜欢在代码里到处传递对象引用因为一不小心就会在某个角落把共享对象给改了。记住一个原则如果一个对象被多个地方引用你要明确它到底是“共享的、只读的”还是“独占的、可修改的”。如果不确定宁可多new几个对象出来也别让大家共用同一个引用。7. 从面向对象语法到面向对象设计最后再聊几句这篇文章从头到尾没有用太多高深的设计模式名词只是把C#面向对象最基础的东西掰开揉碎讲了一遍。但相信你看到这里应该已经明白了我在开头说的那句话面向对象不只是一套语法规则它更是一套组织代码的思维方式。C#这门语言几乎处处都渗透着面向对象的思想。你写的HttpClient是个类Form是个类StringBuilder也是个类。你每天都在使用别人写好的类库其实就是在跟面向对象打交道——只不过你站在消费端而现在你学会了生产端怎么去设计。学完这些基础之后下一步建议你这样走立刻动手重构一个自己写过的旧程序。把里面堆在界面里的逻辑拆出来按职责分成业务类、数据类、工具类。这是把理论变成手感最重要的一个步骤。学习几个最基础的接口用法。比如IComparableT、IDisposable。接口是C#里面向对象很重要的一个扩展搞懂它你对“面向抽象编程”的理解会上一个台阶。尝试看看设计模式。不用贪多先把单例模式、工厂模式、观察者模式看明白就够了。设计模式是面向对象思想的“套路化总结”有了前面的基础再学有种打通任督二脉的快感。结合你的实际业务场景去思考怎么拆类。如果你是做上位机的可以思考“设备类怎么抽象、通讯协议怎么封装、数据解析和界面显示怎么分离”。如果你是做Web的可以思考“实体类、仓储类、服务类各管什么”。犯过的错、踩过的坑远比背过的定义来得刻骨铭心。动手写才是真正学会的开始。