摘要

这篇笔记把类与对象、继承、多态、接口、字符串和结构体放在同一条学习路径中,通过少量可运行的 C# 示例梳理它们之间的关系,并记录我目前最容易混淆的几个问题。

这部分内容是我学习 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") 才真正创建了一个对象。对象既保存 NameLevel 这些状态,也提供 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】会自动回收不再被引用的托管对象,但回收时机不由程序精确控制。因此,析构函数(终结器)不适合承担“用完立刻释放”的工作。

文件、数据库连接、网络流等资源需要及时释放时,应使用 IDisposableusing

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. thisbase

  • 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. virtualoverridebase

父类用 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;
    }
}

抽象方法只有契约,没有方法体,具体子类必须实现;抽象子类则可以把实现责任继续留给下一层。

abstractvirtual 的区别很直接:抽象方法没有默认实现,子类必须重写;虚方法已经有默认实现,子类可以选择是否重写。

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 的不可变性

字符串创建后内容不能原地修改。ReplaceRemoveSubstring 等方法都会返回新字符串,原变量不会自动改变。

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();

常用成员有 AppendAppendLineInsertRemoveReplaceClearLength 是当前字符数,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. 抽象类还是接口

这是我最容易混淆的一组概念,可以先从设计意图判断:

问题 抽象类 接口
主要表达 一组对象“是什么” 对象“会做什么”
状态 可以保存实例字段 通常不保存实例状态
构造函数 可以有 没有实例构造函数
普通实现 可以有普通和抽象成员 以契约为主,也可有默认实现
数量限制 一个类只能直接继承一个类 一个类可以实现多个接口

例如,EnemyPlayer 都可以继承 Character,因为它们共享生命值、名字等基础状态;两者还可以分别实现 IDamageableIMovableISavable 等接口,因为这些是可以组合的能力。

当我需要共享状态和基础实现时,会先考虑抽象类;当我只想约定某种能力、让不同继承体系的类型都能参与时,会先考虑接口。两者也可以一起使用,并不是二选一。

六、一个贯穿示例

下面的小例子把抽象类、接口、继承和多态放在一起。调用者只关心 CharacterIDamageable 的约定,不需要为每种角色写一套分支。

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() 会根据实际对象调用不同实现。
  • WarriorMage 已经足够具体,不准备继续派生,因此声明为 sealed

结语:目前最需要记牢的几件事

学完这一段后,我不再把面向对象理解成一串关键字,而是把它看成对变化边界的安排:

  1. 用私有字段和受控属性保护对象状态,不让无效数据随意进入。
  2. 用构造函数保证对象创建完成时就能正常工作。
  3. 只有确实存在“是一个”的关系时才使用继承。
  4. 用多态消除针对具体类型的重复判断,让调用者依赖共同约定。
  5. 抽象类负责共享状态和骨架,接口负责组合能力。
  6. 字符串少量拼接优先可读性,大量循环拼接再用 StringBuilder
  7. 选择结构体或类时先判断值语义和引用语义,不要只背“栈和堆”。

这些规则还不足以覆盖所有设计场景,但已经能帮助我写出边界更清楚、也更容易继续维护的 C# 代码。