这部分内容是我学习 C# 面向对象编程时的阶段性整理。与其把每个关键字拆成独立词条,我更想回答几个实际问题:对象怎样保存状态,类之间怎样复用代码,多态【1】为什么有用,以及什么时候该用抽象类【2】、接口【3】、结构体或普通类。
文中的示例尽量保持短小,但都按真实 C# 语法重新校对。阅读时不必死记定义,先看对象之间的关系,再回头理解关键字会轻松很多。
一、类与对象:把数据和行为放在一起
类是对一类事物的描述,对象则是根据这份描述创建出来的具体实例。比如“玩家”可以是一种类型,而游戏里的某个玩家才是对象。
public class Player
{
public string Name { get; set; }
public int Level { get; private set; }
public Player(string name)
{
Name = name;
Level = 1;
}
public void LevelUp()
{
Level++;
}
}
Player player = new Player("Mori");
player.LevelUp();
Console.WriteLine($"{player.Name} 当前是 {player.Level} 级");
Player 是类,player 是变量,new Player("Mori") 才真正创建了一个对象。对象既保存 Name、Level 这些状态,也提供 LevelUp 这样的行为。
1. 字段、属性和访问修饰符
字段直接保存数据,属性则在读取和写入之间多了一层控制。实际开发中,我会优先把字段设为 private,再通过属性或方法暴露必要的操作,避免外部代码随意破坏对象状态。
public class BankAccount
{
private decimal _balance;
public string Owner { get; }
public decimal Balance => _balance;
public BankAccount(string owner)
{
Owner = owner;
}
public void Deposit(decimal amount)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(amount));
}
_balance += amount;
}
}
常见的访问修饰符可以这样理解:
private:只允许当前类型内部访问,是隐藏实现细节的默认选择。protected:当前类型和派生类型可以访问,常用于为子类保留扩展点。public:任何能访问该类型的代码都可以使用,适合稳定的对外接口。internal:只在当前程序集内可见,适合项目内部协作而不对外公开的类型或成员。
访问范围并不是越大越方便。公开成员一旦被其他代码依赖,后续修改成本就会明显增加。
2. 构造函数与对象初始化
构造函数【4】负责让对象从一开始就处于有效状态。它没有返回值,名称必须与类名相同,并且可以重载。
public class Character
{
public string Name { get; }
public int Health { get; private set; }
public Character(string name) : this(name, 100)
{
}
public Character(string name, int health)
{
Name = string.IsNullOrWhiteSpace(name)
? throw new ArgumentException("名字不能为空", nameof(name))
: name;
Health = health > 0
? health
: throw new ArgumentOutOfRangeException(nameof(health));
}
}
这里的 this(name, 100) 会调用同一个类里的另一个构造函数,避免重复初始化逻辑。只要自己声明了任意构造函数,编译器就不会再自动提供无参构造函数。
也可以使用对象初始化器,但它只能给允许写入的成员赋值:
public class Profile
{
public string Nickname { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
}
Profile profile = new Profile
{
Nickname = "Mori",
City = "Tokyo"
};
原稿里类似 new Person{} 的写法并非一定错误,但标准、清晰的空对象初始化器应写作 new Person { };如果没有成员需要初始化,直接写 new Person() 更容易读懂。
3. 属性:让状态变化有边界
自动属性适合不需要额外逻辑的场景:
public string Title { get; set; } = string.Empty;
如果写入时需要校验,就使用完整属性:
private int _age;
public int Age
{
get => _age;
set
{
if (value is < 0 or > 150)
{
throw new ArgumentOutOfRangeException(nameof(value));
}
_age = value;
}
}
public int Level { get; private set; } 则表示所有代码都能读取等级,但只有当前类能修改它。相比公开字段,这种写法更能表达对象的使用规则。
4. 索引器:让对象像集合一样访问
索引器使用 this[...] 声明,适合类型本身就代表一组元素的情况。
public class Inventory
{
private readonly List<string> _items = new();
public int Count => _items.Count;
public string this[int index]
{
get => _items[index];
set => _items[index] = value;
}
public void Add(string item)
{
_items.Add(item);
}
}
Inventory inventory = new Inventory();
inventory.Add("木剑");
inventory[0] = "铁剑";
Console.WriteLine(inventory[0]);
索引器不是普通类的必选项。如果一个类型并不具有明显的“容器”含义,使用具名方法通常更清楚。
5. 垃圾回收与资源释放
.NET 的垃圾回收器【5】会自动回收不再被引用的托管对象,但回收时机不由程序精确控制。因此,析构函数(终结器)不适合承担“用完立刻释放”的工作。
文件、数据库连接、网络流等资源需要及时释放时,应使用 IDisposable 和 using:
using FileStream stream = File.OpenRead("data.bin");
byte[] buffer = new byte[stream.Length];
int bytesRead = stream.Read(buffer, 0, buffer.Length);
Console.WriteLine($"读取了 {bytesRead} 字节");
我目前给自己的判断标准是:普通对象交给 GC;持有需要及时归还的资源时,优先寻找或实现 IDisposable,不要依赖终结器碰运气。
二、类的进阶用法
1. 静态成员与静态类
实例成员属于某个对象,静态成员属于类型本身。下面的计数器会记录一共创建过多少个对象:
public class Enemy
{
public static int CreatedCount { get; private set; }
public string Name { get; }
public Enemy(string name)
{
Name = name;
CreatedCount++;
}
}
_ = new Enemy("Slime");
_ = new Enemy("Bat");
Console.WriteLine(Enemy.CreatedCount); // 2
静态方法没有具体对象,所以不能直接访问实例字段或属性。静态构造函数则用于初始化类型级别的数据,由运行时在首次使用类型前调用一次,不能手动调用,也不能带参数。
如果一个类型只负责组织无状态的工具方法,可以声明为静态类:
public static class Temperature
{
public static double CelsiusToFahrenheit(double value)
{
return value * 9 / 5 + 32;
}
}
静态类很方便,但大量共享的可变静态状态会让测试和并发控制变得困难。工具函数适合静态化,业务状态则应谨慎处理。
2. 扩展方法
扩展方法【6】能让现有类型以实例方法的形式调用新功能,但它并没有真正修改原类型。它必须写在非泛型静态类中,并且方法本身也是静态的;第一个参数前的 this 指明要扩展的类型。
public static class StringExtensions
{
public static int WordCount(this string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return 0;
}
return text.Split(
new[] { ' ', '\t', '\r', '\n' },
StringSplitOptions.RemoveEmptyEntries
).Length;
}
}
int count = "C# is fun".WordCount();
当实例方法与扩展方法同名时,实例方法优先。扩展方法更适合补充通用、稳定的操作,不适合拿来隐藏复杂业务流程。
3. 运算符重载
当自定义类型具有明确的数学或领域含义时,运算符重载能让表达式更自然。下面用不可变结构体表示二维向量:
public readonly struct Vector2
{
public double X { get; }
public double Y { get; }
public Vector2(double x, double y)
{
X = x;
Y = y;
}
public static Vector2 operator +(Vector2 left, Vector2 right)
{
return new Vector2(left.X + right.X, left.Y + right.Y);
}
public override string ToString() => $"({X}, {Y})";
}
Vector2 result = new Vector2(1, 2) + new Vector2(3, 4);
Console.WriteLine(result); // (4, 6)
运算符的含义应当符合直觉。为了少写几行代码而重载一个语义含糊的运算符,往往会让调用者更难理解。
4. 嵌套类与分部类
嵌套类定义在另一个类内部,适合只服务于外部类的辅助类型。它不是继承【7】关系,只是作用域上的包含关系。
public class Cache
{
private sealed class Entry
{
public string Key { get; init; } = string.Empty;
public object? Value { get; init; }
}
}
分部类使用 partial,允许同一个类型分散在多个文件中。编译后它仍然是一个类,常见于代码生成器、WinForms 设计器以及需要分离生成代码和手写代码的场景。
// Player.Generated.cs
public partial class Player
{
public int Id { get; set; }
}
// Player.cs
public partial class Player
{
public void Move()
{
Console.WriteLine("移动");
}
}
分部类能拆分文件,但不能代替合理的职责划分。一个类如果本身承担了太多工作,拆成多个文件也不会让设计变好。
三、继承:复用共同特征,而不是复制代码
继承表达的是“子类也是一种父类”。C# 的类只支持单继承,但继承可以继续向下传递。
public class Animal
{
public string Name { get; }
public Animal(string name)
{
Name = name;
}
public void Eat()
{
Console.WriteLine($"{Name} 正在进食");
}
}
public class Dog : Animal
{
public Dog(string name) : base(name)
{
}
public void Bark()
{
Console.WriteLine($"{Name}: 汪!");
}
}
Dog 会继承 Animal 可访问的成员。父类的私有成员依然存在于对象中,但子类不能直接访问它们。base(name) 用来选择父类构造函数,而且父类构造逻辑会先执行。
1. this 和 base
this指向当前对象,也可以在构造函数之间转发参数。base指向当前对象中的父类部分,可用于调用父类构造函数或父类实现。
如果父类没有无参构造函数,子类构造函数就必须通过 base(...) 明确选择一个可用的父类构造函数。
2. 里氏替换与安全类型判断
父类变量可以引用子类对象:
Animal animal = new Dog("Coco");
animal.Eat();
这不仅是语法规则,也提醒我:如果一个子类真的符合父类约定,那么使用父类的地方换成子类后,程序的合理行为不应被破坏。继承不只是“拿到父类代码”,还意味着遵守父类承诺。
需要使用子类特有能力时,可以用模式匹配安全判断:
if (animal is Dog dog)
{
dog.Bark();
}
as 转换失败会得到 null,只适用于引用类型和可空值类型:
Dog? dog = animal as Dog;
dog?.Bark();
相比先用 is 判断再强制转换,is Dog dog 一次完成判断和取值,通常更简洁。
3. object、装箱与拆箱
所有 C# 类型最终都与 object 有关。引用类型转换为 object 只是引用转换;值类型转换为 object 才会发生装箱【8】。
int number = 42;
object boxed = number; // 装箱
int restored = (int)boxed; // 拆箱
拆箱时必须还原为原来的值类型。下面的代码会抛出 InvalidCastException,即使 int 可以数值转换为 long:
object boxed = 42;
long value = (long)boxed; // 运行时错误
正确做法是先拆成 int,再做数值转换:
long value = (int)boxed;
装箱会产生额外对象和复制开销。在高频路径中,优先使用泛型集合(如 List<int>),不要用 ArrayList 一类依赖 object 的旧式容器保存大量值类型。
4. 密封类
sealed 表示类不能继续被继承:
public sealed class GameConfig
{
public int MaxPlayers { get; init; } = 4;
}
密封类仍然可以正常实例化。它适合明确不提供继承扩展、需要固定行为边界的类型,但不应该只因为“暂时没想到子类”就随手密封所有类。
四、多态:让调用者依赖共同能力
继承解决“是什么”,多态解决“同一个调用在不同对象上做不同的事”。
1. virtual、override 与 base
父类用 virtual 提供可替换的默认实现,子类用 override 重写。真正调用哪个版本,由对象的运行时类型决定。
public class Notification
{
public virtual void Send(string message)
{
Console.WriteLine($"默认通知:{message}");
}
}
public class EmailNotification : Notification
{
public override void Send(string message)
{
Console.WriteLine($"发送邮件:{message}");
}
}
Notification notification = new EmailNotification();
notification.Send("构建完成"); // 调用 EmailNotification.Send
子类如果想保留父类逻辑,可以调用 base.Send(message),再补充自己的行为。重写不是定义一个碰巧同名的方法;它必须使用 override,并且父类成员必须允许重写。
2. 抽象类与抽象方法
抽象类用于表达一组具有共同状态和基础行为、但本身不够具体的对象。它不能直接实例化,可以同时包含普通成员和抽象成员。
public abstract class Shape
{
public string Color { get; }
protected Shape(string color)
{
Color = color;
}
public abstract double Area();
public void Print()
{
Console.WriteLine($"{Color} 图形,面积为 {Area():F2}");
}
}
public sealed class Circle : Shape
{
public double Radius { get; }
public Circle(string color, double radius) : base(color)
{
Radius = radius;
}
public override double Area()
{
return Math.PI * Radius * Radius;
}
}
抽象方法只有契约,没有方法体,具体子类必须实现;抽象子类则可以把实现责任继续留给下一层。
abstract 与 virtual 的区别很直接:抽象方法没有默认实现,子类必须重写;虚方法已经有默认实现,子类可以选择是否重写。
3. 接口与多实现
接口更强调“能做什么”。一个类只能继承一个直接父类,却可以实现多个接口,这使它很适合描述可以组合的能力。
public interface ISavable
{
void Save();
}
public interface ILoadable
{
void Load();
}
public class GameState : ISavable, ILoadable
{
public void Save()
{
Console.WriteLine("保存游戏");
}
public void Load()
{
Console.WriteLine("读取游戏");
}
}
调用者只依赖自己真正需要的接口,通常比依赖一个庞大的具体类更灵活:
public static void SaveNow(ISavable target)
{
target.Save();
}
现代 C# 允许接口提供默认实现,但我在学习阶段仍会优先把接口看作能力契约。只有在维护公共接口兼容性等明确场景下,才考虑默认接口成员。
4. 密封重写
子类可以用 sealed override 表示“这次重写到此为止”,后续派生类不能再改变该方法。
public class BaseLogger
{
public virtual void Write(string message)
{
Console.WriteLine(message);
}
}
public class TimestampLogger : BaseLogger
{
public sealed override void Write(string message)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {message}");
}
}
密封方法只能用于重写成员,因此 sealed 必须和 override 一起出现。它与密封类的范围不同:密封类禁止整个类型被继承;密封重写只禁止某个成员继续被改写。
五、代码组织与常用基础类型
1. 命名空间
命名空间用于组织类型并避免命名冲突。using 只是让当前文件可以用短名称引用类型,不会把任何代码“复制”进来。
namespace MyGame.Combat;
public class DamageCalculator
{
public int Calculate(int attack, int defense)
{
return Math.Max(1, attack - defense);
}
}
不同命名空间可以包含同名类型。发生冲突时,可以使用完全限定名或别名:
using GameTimer = MyGame.Core.Timer;
using SystemTimer = System.Timers.Timer;
命名空间通常按项目或功能域组织。它不是文件夹的强制映射,但保持两者大体一致,会更容易定位代码。
2. object 提供的常用方法
所有类型都能使用 object 的基础方法,其中最常见的是:
ToString():返回对象的文本表示,自定义类型通常应重写它。Equals():判断两个对象在语义上是否相等。ReferenceEquals():判断两个引用是否指向同一个对象。GetHashCode():为哈希集合和字典提供哈希值。GetType():取得对象实际的运行时类型。
public sealed class User
{
public int Id { get; }
public string Name { get; }
public User(int id, string name)
{
Id = id;
Name = name;
}
public override string ToString() => $"{Name} (#{Id})";
}
如果重写 Equals,通常也要重写 GetHashCode,保证两个相等对象得到相同哈希值。对值语义【9】明显的数据类型,使用 record 往往比手写整套相等逻辑更省心。
MemberwiseClone() 会做浅复制:值类型字段被复制,引用类型字段仍指向同一个对象。它是 protected 成员,只有类型内部或派生类型能直接调用。遇到包含列表、字典等可变引用成员的对象时,必须先想清楚究竟需要浅复制还是深复制。
3. string 的不可变性
字符串创建后内容不能原地修改。Replace、Remove、Substring 等方法都会返回新字符串,原变量不会自动改变。
string source = "hello world";
string changed = source.Replace("world", "C#");
Console.WriteLine(source); // hello world
Console.WriteLine(changed); // hello C#
常见操作包括:
string text = "red,green,blue";
int length = text.Length;
char first = text[0];
string[] colors = text.Split(',');
string middle = text.Substring(4, 5);
bool contains = text.Contains("green", StringComparison.Ordinal);
索引可以读取字符,但不能写 text[0] = 'R',原因正是字符串不可变。需要少量修改时,接收方法返回的新字符串即可;大量循环拼接时则考虑 StringBuilder。
4. StringBuilder
重复使用 + 拼接字符串可能不断创建中间对象。StringBuilder 维护一个可扩展缓冲区,更适合循环构造较长文本。
using System.Text;
StringBuilder builder = new StringBuilder();
for (int i = 1; i <= 3; i++)
{
builder.Append("第 ")
.Append(i)
.AppendLine(" 行");
}
string result = builder.ToString();
常用成员有 Append、AppendLine、Insert、Remove、Replace 和 Clear。Length 是当前字符数,Capacity 是当前缓冲区容量;一般不需要手动管理容量,除非已经知道最终文本会很大且对分配次数敏感。
普通插值字符串清楚又直接,不必为了几次拼接就换成 StringBuilder。是否使用它,取决于拼接次数和代码可读性。
5. 结构体和类
结构体是值类型,类是引用类型。两者最关键的差异不是一句“一个在栈上、一个在堆上”,而是赋值与传参时采用值语义还是引用语义。
public struct Point
{
public int X { get; set; }
public int Y { get; set; }
}
Point first = new Point { X = 1, Y = 2 };
Point second = first;
second.X = 10;
Console.WriteLine(first.X); // 1
Console.WriteLine(second.X); // 10
second = first 会复制整个结构体,所以修改 second 不影响 first。类变量复制的则是引用,两个变量可能指向同一个对象。
适合结构体的情况通常包括:数据量小、含义完整、天然具有值语义,并且最好设计为不可变类型,例如坐标、颜色、时间范围。对象较大、需要继承、需要共享可变状态或具有明确身份时,类通常更合适。
结构体不能继承另一个类或结构体,但可以实现接口;类可以为 null,普通非可空结构体则始终有一个值。
6. 抽象类还是接口
这是我最容易混淆的一组概念,可以先从设计意图判断:
| 问题 | 抽象类 | 接口 |
|---|---|---|
| 主要表达 | 一组对象“是什么” | 对象“会做什么” |
| 状态 | 可以保存实例字段 | 通常不保存实例状态 |
| 构造函数 | 可以有 | 没有实例构造函数 |
| 普通实现 | 可以有普通和抽象成员 | 以契约为主,也可有默认实现 |
| 数量限制 | 一个类只能直接继承一个类 | 一个类可以实现多个接口 |
例如,Enemy 和 Player 都可以继承 Character,因为它们共享生命值、名字等基础状态;两者还可以分别实现 IDamageable、IMovable、ISavable 等接口,因为这些是可以组合的能力。
当我需要共享状态和基础实现时,会先考虑抽象类;当我只想约定某种能力、让不同继承体系的类型都能参与时,会先考虑接口。两者也可以一起使用,并不是二选一。
六、一个贯穿示例
下面的小例子把抽象类、接口、继承和多态放在一起。调用者只关心 Character 和 IDamageable 的约定,不需要为每种角色写一套分支。
public interface IDamageable
{
bool IsAlive { get; }
void TakeDamage(int amount);
}
public abstract class Character : IDamageable
{
public string Name { get; }
public int Health { get; private set; }
public bool IsAlive => Health > 0;
protected Character(string name, int health)
{
Name = name;
Health = health;
}
public void TakeDamage(int amount)
{
if (amount < 0)
{
throw new ArgumentOutOfRangeException(nameof(amount));
}
Health = Math.Max(0, Health - amount);
}
public abstract void Act();
}
public sealed class Warrior : Character
{
public Warrior(string name) : base(name, 120)
{
}
public override void Act()
{
Console.WriteLine($"{Name} 挥动长剑");
}
}
public sealed class Mage : Character
{
public Mage(string name) : base(name, 80)
{
}
public override void Act()
{
Console.WriteLine($"{Name} 释放法术");
}
}
List<Character> team = new()
{
new Warrior("Aki"),
new Mage("Yuki")
};
foreach (Character member in team)
{
member.Act();
member.TakeDamage(20);
Console.WriteLine($"剩余生命值:{member.Health}");
}
这个例子里有几条值得记住的线索:
Character保存所有角色共有的状态和受伤逻辑。Act没有统一实现,所以声明为抽象方法,交给具体角色完成。IDamageable只描述“可以受到伤害”这项能力,其他不属于角色体系的对象也能实现它。List<Character>同时保存不同子类,循环中的member.Act()会根据实际对象调用不同实现。Warrior和Mage已经足够具体,不准备继续派生,因此声明为sealed。
结语:目前最需要记牢的几件事
学完这一段后,我不再把面向对象理解成一串关键字,而是把它看成对变化边界的安排:
- 用私有字段和受控属性保护对象状态,不让无效数据随意进入。
- 用构造函数保证对象创建完成时就能正常工作。
- 只有确实存在“是一个”的关系时才使用继承。
- 用多态消除针对具体类型的重复判断,让调用者依赖共同约定。
- 抽象类负责共享状态和骨架,接口负责组合能力。
- 字符串少量拼接优先可读性,大量循环拼接再用
StringBuilder。 - 选择结构体或类时先判断值语义和引用语义,不要只背“栈和堆”。
这些规则还不足以覆盖所有设计场景,但已经能帮助我写出边界更清楚、也更容易继续维护的 C# 代码。
Comments NOTHING