当我们写完若干“函数与逻辑”,自然会问:这些函数该怎样组织?如何让“数据”和“行为”有机地待在一起?这正是面向对象(Object-Oriented)的舞台。

在我们学习 C# 的过程中,经常会听到“对象”和“类”这两个词。那它们到底是什么呢?我们可以这样来理解:对象就像是现实生活中的一个具体事物,比如说我们班上的小明同学、图书馆里的一本《三体》、 或者你家里的那只小猫。每一个对象都有属于自己的数据(比如小明的年龄、书的名字、猫的颜色),还能做一些事情(比如小明可以自我介绍,书可以被借阅,猫可以喵喵叫)。
而类呢?类就像是制作这些对象的“设计图”或者“模板”。我们先有了“学生”这个类,才能根据它造出很多不同的学生对象; 有了“图书”这个类,才能有成千上万本不同的书。类里会详细说明:这种对象应该有哪些数据(我们叫它“属性”),还能做哪些动作(我们叫它“方法”)。比如“学生”类会规定每个学生都要有名字和年龄,还能自我介绍。
所以,我们可以把类想象成乐高的说明书,而对象就是按照说明书拼出来的每一座乐高小屋。每个对象都是独一无二的,但它们都遵循同一套蓝图——这就是类和对象的关系。
我们先写一个最小的类:
|// Student.cs using System; namespace OopBasics { // 一个简单的学生类 public class Student { // 字段与属性 private string _name; // 私有字段,存储数据的“真实位置” public int Age { get; private set; }
让我们来详细拆解一下上面的代码。首先,class 关键字就像是在告诉 C#:“我要造一个新的蓝图!”在这个蓝图里,我们用“字段”来悄悄地存储数据,比如学生的名字,这些字段通常是私有的,外部不能直接访问。
接着,我们用“属性”来当作数据的门卫,别人想读或者改名字,必须通过属性,这样我们就能在背后加上一些检查或者格式化的逻辑。
然后是“构造函数”,它就像是新建小屋时的必备流程,确保每个学生对象一出生就有名字和年龄,而且这些信息都是合理的。没有构造函数的把关,我们可能会得到一些奇怪的学生,比如没有名字或者年龄是负数。
最后,“方法”就是让对象能做事情的地方。比如 Introduce 方法,就是让学生自我介绍。这样一来,Student 这个类就把数据(比如名字和年龄)和行为(比如自我介绍)都装进了一个盒子里。
每当我们用 new 关键字创建一个学生对象时,就像是根据蓝图造出了一座属于自己的小屋,这个小屋既能存放信息,也能对外说话。我们只要拿到这个对象,就能让它做它该做的事情。
|// Program.cs using System; using OopBasics; class Program { static void Main() { // 用构造函数创建对象 var stu = new Student(" 小可 ", 12); // 设置属性(走验证与清理逻辑) stu.Name = "小可"; // 会被 Trim // 调用方法 stu.Introduce();
输出可能是:
|大家好,我是 小可,今年 12 岁。
在 C# 里,我们可以把“字段”想象成藏在仓库深处的货物,这些货物只有仓库管理员(也就是类的内部代码)才能直接接触。这样做的好处是, 外部的人(比如其他类或者外部代码)不能随意动这些货物,保证了数据的安全和完整。而“属性”就像是仓库的大门,门口有门禁和保安, 只有通过合适的方式才能进出。我们可以在属性的 get 和 set 里加上各种检查,比如验证数据是否合法、自动格式化输入,甚至可以设置成只读或者只写。 这样一来,外部想要访问或修改字段里的数据,都必须经过我们设定的“门禁”,既方便又安全。 通常我们会把字段声明为 private,只能在类内部用,然后通过 public 的属性来对外开放访问权限,这样既保护了数据,又能灵活控制外部的操作。
有时候,我们只是单纯地想让外部能够读取和设置某个数据,比如书名、作者这些信息,并不需要在赋值时加上特殊的检查或者处理。 这种情况下,C# 给我们提供了一种非常方便的写法,叫做“自动属性”。自动属性其实就是让编译器帮我们在背后自动生成一个私有字段, 我们只需要简单地写上 get 和 set,代码就会变得非常简洁。 比如说,我们想让每本书都能记录书名和作者,就可以直接用自动属性来实现,这样既省事又清晰,非常适合用在那些不需要额外逻辑的场景。
|public class Book { // 自动属性:编译器自动生成隐藏字段 public string Title { get; set; } = string.Empty; public string Author { get; set; } = string.Empty; public int Year { get; set; } }
当需要在设置时做校验或格式化,我们使用“带备份字段”的属性:
|private string _email = string.Empty; public string Email { get => _email; set { if (string.IsNullOrWhiteSpace(value) || !value.Contains('@')) throw new ArgumentException("Email 不合法"); _email = value.Trim();
只读:
|public int Age { get; private set; } // 对外只读,类内可写
计算属性其实就像是“临时算出来的结果”,它们不会在对象里单独占用存储空间,而是每次访问时, 都会根据当前的其他字段或属性的值动态计算出结果。 举个例子,比如我们有一个长方形类,面积这个属性其实可以通过长和宽实时算出来,不需要额外保存。 这样做的好处是,数据永远不会过时,也不用担心忘记同步更新,非常安全又省心。
|public class Rectangle { public double Width { get; } public double Height { get; } public Rectangle(double width, double height) { if (width <= 0 || height <= 0) throw new ArgumentException("长宽必须为正");
构造函数其实就像是我们给新对象举办的“出生典礼”。每当我们用 new 关键字创建一个类的实例时,构造函数就会被自动调用,帮我们把对象的各个属性都安排妥当。 我们要记住一个核心原则:对象一旦被创建出来,里面的数据就应该是完整且有效的,不能让它处于“半成品”或者“脏数据”的状态。
在 C# 里,构造函数的名字和类名是一样的,没有返回值。我们可以根据需要,写出多个参数不同的构造函数,这叫做“构造函数重载”。 这样一来,别人用我们这个类的时候,可以根据实际情况选择不同的初始化方式,非常灵活。
举个生活中的例子:就像我们给新同学建档案,最起码要有姓名、城市这些基本信息,不能只写一半。构造函数就是帮我们把这些“必填项”一次性填好,保证每个新同学的档案都是完整的。 我们在写构造函数时,还可以加上一些校验,比如不允许名字为空、等级不能为负数等等,这样可以防止无效数据混进来,让我们的对象更安全、更可靠。
下面是一个简单的例子:
|public class User { public string Name { get; private set; } public string City { get; private set; } public int Level { get; private set; } // 主构造函数 public User(string name, string city,
常见误区:在构造函数里做“联网下载”或“昂贵操作”。建议分离昂贵动作到显式方法,保持构造稳定快速。
方法命名应像自然语言:EnrollCourse、Deposit、Withdraw。方法的参数与返回值应传达清晰意图。我们看看一个“银行账户”的行为设计:
|// BankAccount.cs using System; public class BankAccount { public string Owner { get; } public decimal Balance { get; private set; } public BankAccount(string owner, decimal openingBalance) { if (openingBalance < 0) throw
我们使用 TryXxx 风格避免异常驱动正常分支。ToString 提供友好的打印形式,稍后我们会更系统地讨论。
在 C# 里,访问修饰符(Access Modifiers)用来控制类、成员变量和方法的可见范围。我们最常用的有四种,下面我们详细聊聊它们各自的作用和使用场景:
public:公开的,任何地方都能访问。比如我们希望某个方法或属性能被外部代码自由调用,就会用 public。private:私有的,只能在当前类的内部访问。我们通常把不希望外部直接操作的细节(比如内部状态、辅助方法)设为 private,这样可以保护数据,防止被误用。protected:受保护的,当前类和它的子类都能访问。这个修饰符常用于基类和继承场景,比如我们希望子类能复用或扩展父类的某些功能,但又不想让外部直接访问。internal:内部的,只能在同一个程序集(通常是同一个项目)里访问。适合团队内部模块之间的协作,但不希望暴露给外部使用者。举个例子,如果我们写一个用户类,用户名可以公开(public),密码字段就应该是私有的(private);如果有些方法只给子类用,就可以用 protected;而一些只在本项目内部流转的数据结构,可以用 internal。
总的来说,建议我们在写代码时,优先选择最“封闭”的修饰符(比如 private),只有在确实需要对外开放时,再逐步放宽可见性。这样做可以让我们的代码更安全、更易维护,也能减少模块之间的耦合。
|public class Profile { // 只在类内部访问 private string _token = string.Empty; // 提供受控的只读入口 public string Token => _token; // 仅子类可见的帮助方法 protected void ResetToken() => _token = string.Empty; }
在 C# 里,我们经常会遇到“实例成员”和“静态成员”这两个概念。那它们到底有什么区别呢?
首先,实例成员(比如实例字段、属性、方法)是和具体的对象实例绑定在一起的。也就是说,每当我们用 new 关键字创建一个类的对象时,这个对象就会拥有自己独立的一份实例成员。
举个例子,如果我们有一个 Student 类,每个学生对象都有自己的姓名和学号,这些就是典型的实例成员。每个对象的数据互不影响,互相独立。
而静态成员(用 static 关键字修饰)则属于类本身,而不是某个具体的对象。无论我们创建多少个对象,静态成员只有一份,大家共享。
我们可以直接通过类名来访问静态成员,而不需要先创建对象。静态成员常常用来实现一些“全局工具方法”或者“全局状态”,比如数学计算、日志记录、或者工厂方法等。
下面是一个简单的例子:
|public class Temperature { public double Celsius { get; } private Temperature(double c) { Celsius = c; } // 静态工厂方法:更易读的构建入口 public static Temperature FromCelsius(double c) => new Temperature(c); public static Temperature FromFahrenheit(double f) =>
在 C# 里,struct 定义值类型,class 定义引用类型。值类型通常体量小、表示“一个值”(如点、颜色、时间段),在赋值与传参时会“复制一份”;引用类型表示“对象实体”,赋值与传参是“引用同一份”。
|public struct Point { public int X { get; } public int Y { get; } public Point(int x, int y) { X = x; Y = y; } } var p1 = new Point(1, 2); var
若你要表达“像数学上的一个值”,首选 struct(小而不可变更佳);若要表达“有身份的实体”,首选 class。
在启用可空引用类型的项目里(#nullable enable 或项目默认),string? 表示“可能为空”,而 string 表示“非空”。我们应在构造与属性设置时做充分校验,避免空引用异常。
|public class Contact { public string Name { get; } public string? Email { get; private set; } public Contact(string name) { Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("name 不能为空") :
在面向对象编程(OOP)中,封装就像是给我们的对象装上一扇门,把那些容易变化、容易出错的内部细节都藏在门后面,只让外部看到我们精心设计的“按钮”——也就是公开的方法和属性。 这样一来,外部使用者只需要关心“我能做什么”,而不用担心“怎么做”或者“内部怎么变化”。 比如说,我们想实现一个图书借阅的功能,图书的库存怎么存、借书还书时怎么校验,这些细节都应该被封装在类的内部。 外部的人只需要调用“借书”或“还书”的方法,不用操心库存变量怎么变动。接下来,我们就用一个具体的“图书借阅”例子:
|// LibraryBook.cs using System; public class LibraryBook { private int _stock; public string Isbn { get; } public string Title { get; } public string Author { get; } public LibraryBook(string isbn, string title,
使用者只需要关心“能不能借、怎么还”,无需知道库存字段如何存放、如何校验。
在 .NET 里,大多数对象由 GC 自动管理,不需要我们手动释放;当对象不再被引用,它会在某个时刻被回收。
对于持有非托管资源(文件句柄、数据库连接等)的类,应实现 IDisposable 并在 Dispose 里释放资源;调用方使用 using 块确保及时释放。
|using var fs = new FileStream(path, FileMode.Open); // 离开作用域自动 Dispose
4. 记账本练习
设计一个 Ledger 类,实现以下功能:
AddIncome(decimal amount, string description) 方法:记录收入,需要记录金额、描述和时间AddExpense(decimal amount, string description) 方法:记录支出,需要记录金额、描述和时间GetBalance() 方法:返回当前余额(总收入 - 总支出)Print() 方法:打印所有明细,包括时间、类型(收入/支出)、金额、描述和余额List 或 List<(DateTime, string, decimal, string)> 存储明细记录|using System; using System.Collections.Generic; using System.Linq; public class Ledger { private readonly List<(DateTime time, string type, decimal amount, string description)> _records = new(); public
5. 课程表练习
设计 Course 类和 Schedule 类,实现以下功能:
Course 类包含:课程名称(Name)、开始时间(StartTime)、结束时间(EndTime)Schedule 类支持:
AddCourse(Course course) 方法:添加课程,如果与已有课程时间冲突则返回 falseRemoveCourse(string name) 方法:根据课程名称移除课程HasConflict(Course course) 方法:检查新课程是否与已有课程时间冲突(区间重叠)Main 方法中测试:添加多个课程,尝试添加冲突的课程,然后移除课程|using System; using System.Collections.Generic; using System.Linq; public class Course { public string Name { get; } public TimeSpan StartTime { get; } public TimeSpan EndTime { get; } public Course
6. 待办清单练习
设计 TodoItem(使用 record)和 TodoList(使用 class),实现以下功能:
TodoItem record 包含:标题(Title)、描述(Description)、是否完成(IsCompleted)、创建时间(CreatedAt)TodoList 类支持:
Add(string title, string description) 方法:添加待办事项Complete(string title) 方法:标记指定标题的待办为完成GetPending() 方法:返回所有未完成的待办列表GetCompleted() 方法:返回所有已完成的待办列表Print() 方法:打印漂亮的清单,区分已完成和未完成Main 方法中测试:添加多个待办,完成部分,然后打印清单|using System; using System.Collections.Generic; using System.Linq; public record TodoItem(string Title, string Description, bool IsCompleted, DateTime CreatedAt); public class TodoList { private readonly List<TodoItem
7. 打分系统练习
基于 record 设计 ScoreItem,实现以下功能:
ScoreItem record 包含:学生姓名(StudentName)、科目(Subject)、分数(Score)Main 方法中:
ScoreItem 对象(包括重复的)HashSet<ScoreItem> 去重|using System; using System.Collections.Generic; using System.Linq; public record ScoreItem(string StudentName, string Subject, int Score); class DemoScore { static void Main() { var scores =
8. 坐标点练习
设计不可变 struct Point2D,实现以下功能:
Point2D struct 包含:X 坐标(X)、Y 坐标(Y)readonly 或 { get; })MoveBy(int dx, int dy) 方法:返回移动后的新点(不修改原对象)ToString() 方法:返回格式化的字符串,如 "(3, 5)"Main 方法中测试:创建点,移动点,验证原对象不变|using System; public readonly struct Point2D { public int X { get; } public int Y { get; } public Point2D(int x, int y) { X = x; Y = y; } // 返回新点,不修改原对象
|记账明细: ------------------------------------------------------------ 2024-01-15 10:30:00 | 收入 | ¥5,000.00 | 工资 | 余额: ¥5,000.00 2024-01-15 10:30:01 | 支出 | ¥1,200.00 | 房租 | 余额: ¥3,800.00 2024-01-15 10:30:02 | 支出 | ¥300.00 | 购物 | 余额: ¥3,500.00 2024-01-15 10:30:03 | 收入 | ¥500.00 | 兼职 | 余额: ¥4,000.00 ------------------------------------------------------------ 当前余额: ¥4,000.00
说明:
List<(DateTime, string, decimal, string)> 存储明细记录DateTime.Now 获取当前时间戳Sum() 方法计算余额,收入为正,支出为负{amount:C} 格式化输出货币格式|添加数学: True 添加英语: False 添加物理: True 课程表: 09:00 - 10:30 | 数学 14:00 - 15:30 | 物理 移除数学: True 添加英语: True 课程表: 10:00 - 11:30 | 英语 14:00 - 15:30 | 物理
说明:
TimeSpan 用于表示时间段newCourse.StartTime < existing.EndTime && newCourse.EndTime > existing.StartTimeList<Course> 存储课程列表FirstOrDefault() 查找课程,Any() 检查是否存在冲突OrderBy() 按开始时间排序显示|================================================== 待办清单 ================================================== 【未完成】 ☐ 运动 跑步30分钟 【已完成】 ✓ 学习C# ✓ 写作业 总计: 3 项 | 已完成: 2 项 | 待完成: 1 项
说明:
record 用于定义不可变数据,自动实现值相等性with 表达式创建修改后的 record 副本GetPending() 和 GetCompleted() 使用 LINQ 筛选Print() 方法格式化输出,区分已完成和未完成|原始记录数: 7 去重后记录数: 5 各科目平均分: 数学: 87.5 英语: 89.0 去重后的记录: 李四 - 数学: 90 张三 - 数学: 85 李四 - 英语: 87 王五 - 英语: 88 张三 - 英语: 92
说明:
record 自动实现值相等性,相同字段值的记录被视为相等HashSet<T> 自动去重,利用 record 的值相等性GroupBy() 按科目分组,Average() 计算平均分OrderBy() 和 ThenBy() 多级排序|原始点: (3, 5) 移动后新点: (5, 4) 原始点不变: (3, 5) 再次移动: (4, 7) 复制点: (3, 5) p1 == p4: True
说明:
readonly struct 确保结构体不可变MoveBy() 返回新对象,不修改原对象(不可变性)ToString() 重写提供友好的字符串表示