C#初学者必看:从类与对象到封装属性的完整实战指南 今天这篇是 C# 初学者每日分享系列的第 13 篇。如果你是从前面几篇一路跟过来的应该已经见过变量、判断、循环、方法、数组这些基础语法了如果今天才点开也没关系这一篇我会从类与对象最基础的概念讲起一步一步带你把代码写出来。我见过很多初学者学到这一关就卡住不是因为语法记不住而是不太理解为什么要搞出“类”这么个东西。这一期就用一个特别常见的场景——管理一批学生的信息——把类、属性、构造方法、封装、对象集合这些知识点串起来。适合刚学完基础语法、想彻底搞清面向对象的朋友看完之后至少能自己写出一个带校验、能统计、可以复用的控制台小程序。1. 这一期要解决的其实是“怎么描述现实世界”1.1 变量和数组能存数据但存不住“规则”刚开始学 C# 的时候我们习惯用变量和数组存数据。比如要描述一个学生你会写string name 小明; int age 18; double score 90;这没问题。但如果你要处理三个、五个、甚至几十个学生代码很快就变成一堆平行数组一个数组存姓名一个数组存年龄一个数组存成绩。数据少的时候还能硬写数据一多数组下标一旦对不上程序就乱套了。更要命的是数组没有“规则”。你可以在成绩数组里塞一个负数可以在年龄数组里写一个 200 岁编译器不会拦你程序也不会立刻报错。等到你统计平均值、找最高分的时候这些脏数据就会把结果带偏。类要解决的核心问题就是让“数据”和“操作规则”长在一起。1.2 类和对象的关系就像菜谱和菜类是一个模板对象是根据模板造出来的具体东西。拿做菜打比方类就是菜谱上面写好了需要哪些原料、先放什么后放什么对象就是你照着菜谱真正做出来的那道菜。同一个菜谱你可以做十次得到十盘菜它们有相同的结构但具体状态可能不同。写成 C# 代码就是这样public class Student { public string Name; public int Age; public double Score; }当你执行Student stu new Student();时stu就是一个具体的对象。这个概念初学者一定要想明白Student本身不占“人”的名额new Student()之后才真的有一个对象可以存放数据。2. 搭建类的三大块基础结构字段、属性、构造方法2.1 字段和属性怎么选别一上来就 public很多初学面向对象的人看完上面的代码会问public string Name;不就能用了吗为什么还要写属性搞出一堆get和set字段就是你声明在类里的变量它可以直接被外部读写。属性看起来像字段但底层其实是两个方法get负责读取set负责写入。你在外面写stu.Name 小红;实际上调用的是set方法你写Console.WriteLine(stu.Name);调用的是get方法。区别在于属性可以在set里加入校验逻辑。如果你直接暴露 public 字段等于把家门钥匙直接递给所有人属性相当于在门口装了一个保安进来之前先检查一下。真正写项目的时候公共字段是一个很容易制造隐患的设计我建议你从第一天起就养成用属性的习惯。2.2 构造方法到底在什么时候执行构造方法是对象创建时自动执行的方法名字和类名相同没有返回值。你写new Student(小红, 18, 92)的时候new后面的括号就是在调用构造方法。public class Student { public string Name; public int Age; public double Score; public Student(string name, int age, double score) { Name name; Age age; Score score; } }构造方法最大的价值是让对象一出生就处于“合法状态”。如果你不允许一个学生没有姓名那就不要在对象创建后再让外部慢慢赋值而是强制要求创建的时候必须传姓名。这就是为什么建议新手尽量显式写构造方法而不是默默依赖系统给的默认构造方法。2.3 自动属性、只读属性和计算属性如果一个属性不需要额外校验C# 提供“自动属性”简化代码public string Name { get; set; }它看似没有字段实际上编译器在后台帮你生成了字段。还有一个常见技巧是只读属性比如学号一旦确定就不该改public string StudentNo { get; } public Student(string studentNo, string name) { StudentNo studentNo; Name name; }只读属性只能在构造方法中赋值之后外部无法修改。另外你还可以写“计算属性”它没有set只有get返回值是计算出来的比如“是否及格”public bool IsPass Score 60;这类属性让类的表达能力更强调用方只需要关心业务含义不需要自己写判断逻辑。3. 封装不是让所有东西 private而是要留好入口3.1 public、private、protected、internal 到底怎么选封装的字面意思是“隐藏内部细节”但新手很容易走向两个极端要么所有字段全public要么所有成员全private让外部完全无法使用。正确的做法是数据尽量不公开行为按需公开。访问修饰符谁能访问初级阶段的定位public所有地方对外提供服务的方法private仅同一个类内部内部实现细节、私有字段protected本类和子类设计继承体系时使用internal同一个程序集内程序集内部通用能力你只需要记住一条主线默认先private确有必要对外开放的方法再用public。protected和internal不是初学者的重点但看到时要认识它们别在代码里一出现就懵。3.2 什么时候才需要用到 protected 和 internalprotected是给“继承”准备的关键词。如果父类某个方法只想让子类用不向外部开放就标记为protected。比如动物类里有一个Eat方法子类Dog需要调用但外部不需要直接让动物吃东西这时候protected就很合适。internal表示同一个程序集内部可以访问。初学阶段你写的代码基本都在同一个项目里public和internal差别不大但以后做项目拆多个项目时你会慢慢体会它控制“内部共享”的作用。我的建议是现在不要在protected和internal上花太多时间先能用好public、private就够了。等学到继承的时候再返回来看protected会顺畅很多。3.3 属性校验让非法数据进不了对象封装一个很典型的价值就是属性校验。直接给定成绩赋值可能会写入 -5 分通过属性设置就能在入口处拦住private double _score; public double Score { get { return _score; } set { if (value 0 || value 100) throw new ArgumentOutOfRangeException(成绩必须在0到100之间); _score value; } }这段代码的效果是任何地方写stu.Score 120程序会直接抛异常而不是默默存下一个错误数据。很多人觉得抛异常太粗暴但比起“存了坏数据直到最后才发现”程序早一点停住反而更容易排查。记得一个原则发现问题的时间越早修复成本越低。4. 完整实操写一个学生信息管理器理论说得再多不如亲手跑一遍。下面我带你写一个很小的控制台程序不需要数据库不需要界面只把类和对象用起来。4.1 先定清楚需求再决定类的边界动手写代码前先列需求能添加学生包含姓名、年龄、成绩能显示所有学生信息能计算全班平均分能找出成绩最高的学生非法数据不能进入系统我决定拆成两个类Student负责单条学生数据本身的存储和计算StudentService负责管理多个学生的集合。这样职责分开Student不需要知道“全班平均分”怎么算StudentService也不需要关心某个学生成绩是否合法因为创建学生时已经校验过了。这种拆分方式在以后写更大项目时特别重要。4.2 Student 类实现属性校验和计算逻辑using System; public class Student { private string _name; private int _age; private double _score; public string Name { get { return _name; } set { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException(姓名不能为空); _name value; } } public int Age { get { return _age; } set { if (value 0 || value 120) throw new ArgumentOutOfRangeException(年龄必须在0到120之间); _age value; } } public double Score { get { return _score; } set { if (value 0 || value 100) throw new ArgumentOutOfRangeException(成绩必须在0到100之间); _score value; } } public Student(string name, int age, double score) { Name name; Age age; Score score; } public string GetLevel() { if (Score 85) return 优秀; if (Score 60) return 及格; return 待努力; } public override string ToString() { return ${Name}{Age}岁成绩{Score}分{GetLevel()}; } }这里有个很关键的细节构造方法里给Name、Age、Score赋值表面上是在直接赋值实际上调用的还是属性的set方法。也就是说就算有人在创建对象时传一个负年龄也会被拦下来。这就是把校验写在属性里的好处无论从哪个路径赋值都要过同一道安检。4.3 StudentService 类实现用 List 管理对象using System; using System.Collections.Generic; using System.Linq; public class StudentService { private ListStudent _students new ListStudent(); public void AddStudent(Student student) { if (student null) throw new ArgumentNullException(nameof(student)); _students.Add(student); Console.WriteLine($添加成功{student.Name}); } public void ShowAll() { if (_students.Count 0) { Console.WriteLine(当前还没有学生数据。); return; } Console.WriteLine(学生列表); foreach (Student student in _students) { Console.WriteLine(student); } } public void ShowAverageAndTop() { if (_students.Count 0) { Console.WriteLine(没有数据无法统计。); return; } double average _students.Average(s s.Score); Student top _students.OrderByDescending(s s.Score).First(); Console.WriteLine($平均分{average:F1}); Console.WriteLine($最高分{top.Name}{top.Score}分); } }这里Average、OrderByDescending是 LINQ 的写法。如果你还没系统学过不用怕先把它当成两个现成工具一个求平均一个按成绩从高到低排序。s s.Score读作“取一个学生 s 的 Score 属性”。另外注意_students使用了private。外部不能直接拿到这个列表乱改只能通过服务类提供的方法操作。很多新手会忍不住把列表写成 public然后在外面对列表进行各种操作结果数据校验、统计逻辑全部被绕开。这是封装的大忌。4.4 Main 方法串联跑通最小的完整程序using System; class Program { static void Main() { StudentService service new StudentService(); service.AddStudent(new Student(小张, 18, 92.5)); service.AddStudent(new Student(小李, 17, 58)); service.AddStudent(new Student(小周, 18, 76)); service.ShowAll(); service.ShowAverageAndTop(); try { service.AddStudent(new Student(小赵, 18, -5)); } catch (ArgumentException ex) { Console.WriteLine($校验生效{ex.Message}); } } }运行结果大致是添加成功小张 添加成功小李 添加成功小周 学生列表 小张18岁成绩92.5分优秀 小李17岁成绩58分待努力 小周18岁成绩76分及格 平均分75.5 最高分小张92.5分 校验生效成绩必须在0到100之间注意最后一行虽然创建Student(小赵, 18, -5)时抛了异常但前面的数据没有任何影响。程序没有崩溃因为try-catch把异常接住了。这种“非法数据被拦截正常流程继续走”的感觉就是封装和异常处理合作的典型效果。5. 新手最容易踩的四个坑我一个个说5.1 引用类型复制后两个变量指向同一个对象C# 里类属于引用类型。用Student a new Student(小张, 18, 90); Student b a;之后a和b指向的是同一个对象。接着执行b.Name 小李;你再看a.Name会发现它也变成了“小李”。很多新手把对象赋值当成了复制等数据莫名其妙变了排查半天才发现是两个变量指向同一个内存地址。要避免这个问题就要记住引用类型变量存的是“地址”而不是“内容”。真需要独立副本时通常要自己写一个复制类对象的方法或者用记录类型提供的复制功能而不是靠等号。5.2 foreach 里不能 Add 和 Remove只能改元素内部foreach循环看着很方便但它有一个硬限制遍历期间不能修改集合的结构也就是说不能往集合里加元素也不能从集合里删除元素。一旦你这么做程序会抛InvalidOperationException。注意修改集合元素对象的属性比如把每个学生的成绩加 5 分这是允许的但_students.Add(...)或_students.Remove(...)是不允许的。如果你需要在遍历中删除符合条件的元素推荐用for倒序遍历或者先筛选出要删除的项循环结束后再删。// 错误示例 foreach (Student student in _students) { if (student.Score 60) _students.Remove(student); // 会抛异常 } // 正确思路 ListStudent toRemove _students.Where(s s.Score 60).ToList(); foreach (Student student in toRemove) { _students.Remove(student); }5.3 null 进入方法之前就要拦住null是 C# 里非常常见的坑。比如你调用service.AddStudent(null)如果方法内部没有检查后面一旦执行_students.Add(student)或者访问student.Name就会直接抛出NullReferenceException。我见过很多初学者一看到空引用异常就慌其实解决方法很简单在所有接收对象参数的方法入口处先判断是否为 null。if (student null) throw new ArgumentNullException(nameof(student));这一行代码帮你在源头拦截问题。虽然现在 C# 有可空引用类型等特性能够在编译期给提示但“参数先判空”依然是很多项目和团队约定俗成的习惯值得养成。5.4 不会用调试器就像蒙着眼睛写代码写类和对象之后代码里的“套路”越来越多你更需要学会用调试器。在 IDE 里把自己怀疑有问题的那一行代码前面点一个断点程序运行到这里会暂停。此时你可以把鼠标悬停在变量上查看对象的属性值也可以打开“局部变量”窗口一层一层展开对象内部结构看_students集合里到底装了几个对象。很多人遇到 bug 第一反应是到处加Console.WriteLine这没有错但调试器更直接。它能让你看到代码执行到某一刻时所有变量的真实状态。学会打断点、单步执行、查看调用堆栈是学 C# 过程中性价比最高的一笔投资。6. 今天的练习和我攒下的几点心得6.1 今天的三道练习题从易到难第一题把本文的Student类加一个只读属性StudentNo在构造方法里初始化。这题主要是训练只读属性的用法巩固构造方法的知识。第二题给StudentService增加一个RemoveStudentByName(string name)方法删除指定姓名的学生。删除前要判断学生是否存在删除后把结果打印出来。这题需要你处理集合删除和条件判断正好练到上一节提到的坑。第三题自己写一个Book类和一个BookService类模拟图书馆藏书管理。Book包含书名、作者、价格价格必须大于 0BookService能添加、显示、按价格排序。这题要求你完全脱离示例独立从需求出发设计类是真正检验你是否理解“类与对象”的一关。6.2 我给初学者的三点实操建议第一不要背概念而是背最小例子。很多初学者喜欢背“类是对象的模板对象是类的实例”背得很顺写代码却不知道怎么下手。我建议你把本文最精简的那个Student类抄下来然后反复改属性、加方法跑通一次比看十遍理论都有用。第二先写能运行的程序再谈设计。新手很容易陷入“设计癖”一个类还没写几行就开始纠结是不是不够解耦、不够优雅。我的经验是第一版允许很粗糙能跑通就是胜利然后再去想如何校验、如何封装、如何拆分职责。第三异常信息要认真读。你写的代码一旦爆出红字不要急着问别人先把异常类型、异常信息、出错行号这三样东西看清楚。很多时候问题已经写得很直白比如“成绩必须在0到100之间”你需要的是顺着线索查下去而不是直接把整段代码删掉重写。最后再说一个我自己常用的笨办法每次学一个新的 C# 概念先别急着背定义打开编译器敲一个十几行的最小例子跑通之后再往上加需求。类与对象也不例外。你亲手写过、改过、报过错之后再回头看这些概念会觉得它们本来就是那么回事。每一步都算数但前提是每一步都要真的动手去写。