函数与逻辑
3 / 11
引用与类型
自在学
分类课程AI导师创意工坊价格
分类课程AI导师创意工坊价格
编程C#面向对象

面向对象

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

面向对象


对象与类

在我们学习 C# 的过程中,经常会听到“对象”和“类”这两个词。那它们到底是什么呢?我们可以这样来理解:对象就像是现实生活中的一个具体事物,比如说我们班上的小明同学、图书馆里的一本《三体》、 或者你家里的那只小猫。每一个对象都有属于自己的数据(比如小明的年龄、书的名字、猫的颜色),还能做一些事情(比如小明可以自我介绍,书可以被借阅,猫可以喵喵叫)。

而类呢?类就像是制作这些对象的“设计图”或者“模板”。我们先有了“学生”这个类,才能根据它造出很多不同的学生对象; 有了“图书”这个类,才能有成千上万本不同的书。类里会详细说明:这种对象应该有哪些数据(我们叫它“属性”),还能做哪些动作(我们叫它“方法”)。比如“学生”类会规定每个学生都要有名字和年龄,还能自我介绍。

所以,我们可以把类想象成乐高的说明书,而对象就是按照说明书拼出来的每一座乐高小屋。每个对象都是独一无二的,但它们都遵循同一套蓝图——这就是类和对象的关系。

我们先写一个最小的类:

|
// Student.cs using System; namespace OopBasics { // 一个简单的学生类 public class Student { // 字段与属性 private string _name; // 私有字段,存储数据的“真实位置” public int Age { get; private set; } // 只读属性(对外只读),内部可写 // 构造函数:创建对象时必须提供的最小信息 public Student(string name, int age) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("名字不能为空", nameof(name)); if (age < 0 || age > 150) throw new ArgumentOutOfRangeException(nameof(age), "年龄范围不合理"); _name = name; // 把入参保存到字段 Age = age; } // 属性包装:对外公开“名字”,并在设置时做规范化 public string Name { get => _name; // 读取时直接返回 set { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("名字不能为空"); _name = value.Trim(); // 设置时顺便清理两端空格 } } // 方法:让对象做一件事 public void Introduce() { Console.WriteLine($"大家好,我是 {Name},今年 {Age} 岁。"); } } }

让我们来详细拆解一下上面的代码。首先,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("长宽必须为正"); Width = width; Height = height; } public double Area => Width * Height; // 计算属性(表达式体) }

构造函数

构造函数其实就像是我们给新对象举办的“出生典礼”。每当我们用 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, int level) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name 不能为空"); if (level < 0) throw new ArgumentOutOfRangeException(nameof(level)); Name = name.Trim(); City = city.Trim(); Level = level; } // 重载:允许缺省等级 public User(string name, string city) : this(name, city, 0) { } } // 使用对象初始化器(对可写属性直接赋值) var u = new User("Alice", "Shanghai", 3);

常见误区:在构造函数里做“联网下载”或“昂贵操作”。建议分离昂贵动作到显式方法,保持构造稳定快速。


方法

方法命名应像自然语言: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 new ArgumentException("初始余额不能为负"); Owner = owner; Balance = openingBalance; } // 存款:正数,直接相加 public void Deposit(decimal amount) { if (amount <= 0) throw new ArgumentException("存款金额必须为正"); Balance += amount; } // 取款:需要校验余额 public bool TryWithdraw(decimal amount) { if (amount <= 0) return false; if (Balance < amount) return false; Balance -= amount; return true; } public override string ToString() { return $"{Owner}: 余额 {Balance:C}"; // C:货币格式 } }

我们使用 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) => new Temperature((f - 32) * 5 / 9); public double ToFahrenheit() => Celsius * 9 / 5 + 32; } var t = Temperature.FromFahrenheit(98.6);

值类型与引用类型

在 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 p2 = p1; // 复制值 p2 = new Point(9, 9); // 此时 p1 仍然是 (1,2) public class Person { public string Name { get; set; } = string.Empty; } var a = new Person { Name = "Lily" }; var b = a; // 引用同一对象 b.Name = "Lucy"; // 修改 b 同时影响 a // a.Name 现在也是 "Lucy"

若你要表达“像数学上的一个值”,首选 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 不能为空") : name.Trim(); } public void SetEmail(string? email) { if (string.IsNullOrWhiteSpace(email)) { Email = null; // 明确设为无 return; } if (!email.Contains('@')) throw new ArgumentException("Email 不合法"); Email = email.Trim(); } }

封装

在面向对象编程(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, string author, int stock) { if (string.IsNullOrWhiteSpace(isbn)) throw new ArgumentException("ISBN 不能为空"); if (stock < 0) throw new ArgumentOutOfRangeException(nameof(stock)); Isbn = isbn; Title = title; Author = author; _stock = stock; } public bool TryBorrow() { if (_stock <= 0) return false; _stock--; return true; } public void Return() { _stock++; } public override string ToString() => $"{Title} - {Author} (ISBN: {Isbn}) 库存: {_stock}"; }

使用者只需要关心“能不能借、怎么还”,无需知道库存字段如何存放、如何校验。

在 .NET 里,大多数对象由 GC 自动管理,不需要我们手动释放;当对象不再被引用,它会在某个时刻被回收。 对于持有非托管资源(文件句柄、数据库连接等)的类,应实现 IDisposable 并在 Dispose 里释放资源;调用方使用 using 块确保及时释放。

|
using var fs = new FileStream(path, FileMode.Open); // 离开作用域自动 Dispose

小练习

  1. C#中用于定义类型的关键字包括?
  1. C#中的访问修饰符包括?
  1. C#类的成员可以包括?

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 void AddIncome(decimal amount, string description) { if (amount <= 0) throw new ArgumentException("收入金额必须为正"); _records.Add((DateTime.Now, "收入", amount, description)); } public void AddExpense(decimal amount, string description) { if (amount <= 0) throw new ArgumentException("支出金额必须为正"); _records.Add((DateTime.Now, "支出", amount, description)); } public decimal GetBalance() { return _records.Sum(r => r.type == "收入" ? r.amount : -r.amount); } public void Print() { decimal balance = 0; Console.WriteLine("记账明细:"); Console.WriteLine(new string('-', 60)); foreach (var record in _records) { balance += record.type == "收入" ? record.amount : -record.amount; Console.WriteLine($"{record.time:yyyy-MM-dd HH:mm:ss} | {record.type} | {record.amount:C} | {record.description} | 余额: {balance:C}"); } Console.WriteLine(new string('-', 60)); Console.WriteLine($"当前余额: {GetBalance():C}"); } } class DemoLedger { static void Main() { var ledger = new Ledger(); ledger.AddIncome(5000m, "工资"); ledger.AddExpense(1200m, "房租"); ledger.AddExpense(300m, "购物"); ledger.AddIncome(500m, "兼职"); ledger.Print(); } }
|
记账明细: ------------------------------------------------------------ 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} 格式化输出货币格式
  • 每条记录显示时间、类型、金额、描述和累计余额

5. 课程表练习

设计 Course 类和 Schedule 类,实现以下功能:

  • Course 类包含:课程名称(Name)、开始时间(StartTime)、结束时间(EndTime)
  • Schedule 类支持:
    • AddCourse(Course course) 方法:添加课程,如果与已有课程时间冲突则返回 false
    • RemoveCourse(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(string name, TimeSpan startTime, TimeSpan endTime) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("课程名称不能为空"); if (endTime <= startTime) throw new ArgumentException("结束时间必须晚于开始时间"); Name = name; StartTime = startTime; EndTime = endTime; } } public class Schedule { private readonly List<Course> _courses = new(); public bool AddCourse(Course course) { if (course == null) return false; if (HasConflict(course)) return false; _courses.Add(course); return true; } public bool RemoveCourse(string name) { var course = _courses.FirstOrDefault(c => c.Name == name); if (course == null) return false; _courses.Remove(course); return true; } public bool HasConflict(Course newCourse) { return _courses.Any(existing => newCourse.StartTime < existing.EndTime && newCourse.EndTime > existing.StartTime ); } public void Print() { Console.WriteLine("课程表:"); foreach (var course in _courses.OrderBy(c => c.StartTime)) { Console.WriteLine($"{course.StartTime:hh\\:mm} - {course.EndTime:hh\\:mm} | {course.Name}"); } } } class DemoSchedule { static void Main() { var schedule = new Schedule(); var math = new Course("数学", TimeSpan.Parse("09:00"), TimeSpan.Parse("10:30")); var english = new Course("英语", TimeSpan.Parse("10:00"), TimeSpan.Parse("11:30")); var physics = new Course("物理", TimeSpan.Parse("14:00"), TimeSpan.Parse("15:30")); Console.WriteLine($"添加数学: {schedule.AddCourse(math)}"); Console.WriteLine($"添加英语: {schedule.AddCourse(english)}"); // 冲突 Console.WriteLine($"添加物理: {schedule.AddCourse(physics)}"); schedule.Print(); Console.WriteLine($"移除数学: {schedule.RemoveCourse("数学")}"); Console.WriteLine($"添加英语: {schedule.AddCourse(english)}"); // 现在可以添加了 schedule.Print(); } }
|
添加数学: 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.StartTime
  • 使用 List<Course> 存储课程列表
  • FirstOrDefault() 查找课程,Any() 检查是否存在冲突
  • OrderBy() 按开始时间排序显示

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> _items = new(); public void Add(string title, string description) { if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("标题不能为空"); var item = new TodoItem(title, description ?? string.Empty, false, DateTime.Now); _items.Add(item); } public bool Complete(string title) { var item = _items.FirstOrDefault(i => i.Title == title && !i.IsCompleted); if (item == null) return false; var index = _items.IndexOf(item); _items[index] = item with { IsCompleted = true }; return true; } public List<TodoItem> GetPending() { return _items.Where(i => !i.IsCompleted).ToList(); } public List<TodoItem> GetCompleted() { return _items.Where(i => i.IsCompleted).ToList(); } public void Print() { var pending = GetPending(); var completed = GetCompleted(); Console.WriteLine("=".PadRight(50, '=')); Console.WriteLine("待办清单"); Console.WriteLine("=".PadRight(50, '=')); if (pending.Any()) { Console.WriteLine("\n【未完成】"); foreach (var item in pending) { Console.WriteLine($" ☐ {item.Title}"); if (!string.IsNullOrEmpty(item.Description)) Console.WriteLine($" {item.Description}"); } } if (completed.Any()) { Console.WriteLine("\n【已完成】"); foreach (var item in completed) { Console.WriteLine($" ✓ {item.Title}"); } } Console.WriteLine($"\n总计: {_items.Count} 项 | 已完成: {completed.Count} 项 | 待完成: {pending.Count} 项"); } } class DemoTodoList { static void Main() { var todoList = new TodoList(); todoList.Add("学习C#", "完成面向对象章节"); todoList.Add("写作业", "数学作业第3章"); todoList.Add("运动", "跑步30分钟"); todoList.Complete("学习C#"); todoList.Complete("写作业"); todoList.Print(); } }
|
================================================== 待办清单 ================================================== 【未完成】 ☐ 运动 跑步30分钟 【已完成】 ✓ 学习C# ✓ 写作业 总计: 3 项 | 已完成: 2 项 | 待完成: 1 项

说明:

  • record 用于定义不可变数据,自动实现值相等性
  • with 表达式创建修改后的 record 副本
  • GetPending() 和 GetCompleted() 使用 LINQ 筛选
  • Print() 方法格式化输出,区分已完成和未完成
  • record 的不可变性保证了数据安全

7. 打分系统练习

基于 record 设计 ScoreItem,实现以下功能:

  • ScoreItem record 包含:学生姓名(StudentName)、科目(Subject)、分数(Score)
  • record 自动实现值相等性,相同姓名和科目的记录会被视为相等
  • 在 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 = new List<ScoreItem> { new("张三", "数学", 85), new("李四", "数学", 90), new("张三", "数学", 85), // 重复 new("王五", "英语", 88), new("张三", "英语", 92), new("李四", "英语", 87), new("王五", "英语", 88) // 重复 }; Console.WriteLine($"原始记录数: {scores.Count}"); // 使用HashSet去重(record自动实现值相等性) var uniqueScores = new HashSet<ScoreItem>(scores); Console.WriteLine($"去重后记录数: {uniqueScores.Count}"); // 按科目分组统计平均分 Console.WriteLine("\n各科目平均分:"); var avgBySubject = uniqueScores .GroupBy(s => s.Subject) .Select(g => new { Subject = g.Key, Avg = g.Average(s => s.Score) }); foreach (var item in avgBySubject) { Console.WriteLine($"{item.Subject}: {item.Avg:F1}"); } // 显示去重后的所有记录 Console.WriteLine("\n去重后的记录:"); foreach (var score in uniqueScores.OrderBy(s => s.Subject).ThenBy(s => s.StudentName)) { Console.WriteLine($"{score.StudentName} - {score.Subject}: {score.Score}"); } } }
|
原始记录数: 7 去重后记录数: 5 各科目平均分: 数学: 87.5 英语: 89.0 去重后的记录: 李四 - 数学: 90 张三 - 数学: 85 李四 - 英语: 87 王五 - 英语: 88 张三 - 英语: 92

说明:

  • record 自动实现值相等性,相同字段值的记录被视为相等
  • HashSet<T> 自动去重,利用 record 的值相等性
  • GroupBy() 按科目分组,Average() 计算平均分
  • OrderBy() 和 ThenBy() 多级排序
  • record 的值相等性让去重变得简单高效

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; } // 返回新点,不修改原对象 public Point2D MoveBy(int dx, int dy) { return new Point2D(X + dx, Y + dy); } public override string ToString() { return $"({X}, {Y})"; } } class DemoPoint { static void Main() { var p1 = new Point2D(3, 5); Console.WriteLine($"原始点: {p1}"); var p2 = p1.MoveBy(2, -1); Console.WriteLine($"移动后新点: {p2}"); Console.WriteLine($"原始点不变: {p1}"); var p3 = p2.MoveBy(-1, 3); Console.WriteLine($"再次移动: {p3}"); // 验证值类型特性 var p4 = p1; Console.WriteLine($"复制点: {p4}"); Console.WriteLine($"p1 == p4: {p1.Equals(p4)}"); } }
|
原始点: (3, 5) 移动后新点: (5, 4) 原始点不变: (3, 5) 再次移动: (4, 7) 复制点: (3, 5) p1 == p4: True

说明:

  • readonly struct 确保结构体不可变
  • MoveBy() 返回新对象,不修改原对象(不可变性)
  • ToString() 重写提供友好的字符串表示
  • struct 是值类型,赋值时复制值,不是引用
  • 不可变设计让代码更安全,避免意外修改
  • 对象与类
    • 创建对象与调用方法
  • 字段与属性
    • 自动属性与带备份字段的属性
    • 只读与只写、计算属性
  • 构造函数
  • 方法
  • 访问修饰符
  • 实例成员与静态成员
  • 值类型与引用类型
  • 可空性与空引用安全
  • 封装
  • 小练习

目录

  • 对象与类
    • 创建对象与调用方法
  • 字段与属性
    • 自动属性与带备份字段的属性
    • 只读与只写、计算属性
  • 构造函数
  • 方法
  • 访问修饰符
  • 实例成员与静态成员
  • 值类型与引用类型
  • 可空性与空引用安全
  • 封装
  • 小练习
自在学

© 2025 自在学,保留所有权利。

公网安备湘公网安备43020302000292号 | 湘ICP备2025148919号-1

关于我们隐私政策使用条款

© 2025 自在学,保留所有权利。

公网安备湘公网安备43020302000292号湘ICP备2025148919号-1