1. 一枚硬币的两面
咱们已经挺深入地聊过抽象这个概念,也看了C#里两个超强的工具:抽象类 和 接口。你可能已经感觉到它们挺像的,毕竟都能帮我们定义“行为骨架”,让具体类去实现。但说真的,这俩就像锤子和螺丝刀:都是工具,但用法完全不一样。
先说重点:为啥一件事要有俩工具?编程里跟生活一样,很少有“纯属巧合”。既然有两个类似的东西,那肯定各有自己的独门绝技和适用场景。
咱们先来个对比表,帮你一眼看明白。算是小抄,抓住最关键的点。
| 特性 | 抽象类 | 接口 |
|---|---|---|
| 实例化 | 不能直接创建 (). |
不能直接创建 (). |
| 成员实现 | 可以有: – 完全实现的方法/属性。 – 抽象方法/属性(没实现)。 – 字段、构造函数、静态方法。 |
可以有默认实现的方法、静态抽象和静态非抽象成员。 不能有实例字段和构造函数。 |
| 访问修饰符 | 可以有任意的 (public, protected, internal, private). | 接口里的成员默认都是public。不能写访问修饰符(例外:有默认实现和静态成员时)。 |
| 继承 | 类只能继承一个抽象类。 | 类可以实现多个接口。 |
| 关系类型 | “是一个” (is-a)。定义基础类型和通用层级。 | “能做” (has-a 或 can-do)。定义行为/能力契约。 |
| 状态 | 可以保存状态(实例字段)。 | 可以有静态字段,但不能有实例字段。 |
| 扩展 | 可以加新实现方法,不影响子类。 | 可以加默认实现的方法,不会破坏子类。 |
是不是挺直观?下面咱们详细拆解下这些点。
2. 多重继承
这个区别最基础也最好记。C#里(跟Java啥的差不多),一个类只能继承一个父类。不管是普通类还是抽象类——只能有一个!这样做是为了避免著名的“钻石问题”(Diamond Problem),就是多继承时如果有同名方法,编译器不知道该用哪个。
但接口你想实现多少都行!想象下,你的类就像一个人。他可以是“学生”(继承Student类),但还能“会做饭”(ICookable)、“会开车”(IDriveable)、“会唱歌”(ISingable)。超灵活!
abstract class Animal
{
public string Name;
public abstract void MakeSound();
public void Eat() => Console.WriteLine($"{Name} 吃东西。");
}
interface IFlyable { void Fly(); double MaxFlyingAltitude { get; } }
interface ISwimable { void Swim(); }
class Duck : Animal, IFlyable, ISwimable
{
public double MaxFlyingAltitude => 1000;
public override void MakeSound() => Console.WriteLine($"{Name} 呱呱叫!");
public void Fly() => Console.WriteLine($"{Name} 飞起来了!");
public void Swim() => Console.WriteLine($"{Name} 游泳!");
}
class Program
{
static void Main()
{
var duck = new Duck { Name = "唐老鸭" };
duck.MakeSound();
duck.Eat();
duck.Fly();
duck.Swim();
// 用不同类型操作对象:
Animal a = duck; a.Eat();
IFlyable f = duck; f.Fly();
ISwimable s = duck; s.Swim();
}
}
如果你的类是某个层级的一部分(比如Dog是Animal),就用类继承(抽象类或普通类)。如果你的类有某种能力(比如Dog能跑,Cat也能跑),但这些能力跟层级没啥关系,就用接口。这就是接口最大的优势——能给完全不相关的类加上同样的“能力契约”。
3. 谁能存数据,谁只是承诺?
抽象类能装下各种东西:
- 普通(非抽象)方法和属性,直接有实现。
- 抽象方法和属性(没实现,等子类去写)。
- 字段(实例变量),能保存对象状态。
- 构造函数,用来初始化状态。
- 甚至静态方法和属性。
- 当然还能用各种访问修饰符:public、protected、private啥的。
这让抽象类成了定义部分实现行为和通用状态的利器,所有子类都能用。
abstract class Employee
{
public string FirstName, LastName;
public decimal Salary { get; protected set; }
public Employee(string first, string last) { FirstName = first; LastName = last; }
public void GetPaid(decimal sum)
{
Salary += sum;
Console.WriteLine($"{FirstName} {LastName} 收到 {sum:C}。工资: {Salary:C}");
}
public abstract void PerformWork();
public abstract void TakeBreak();
}
class Developer : Employee
{
public Developer(string f, string l) : base(f, l) { }
public override void PerformWork() => Console.WriteLine($"{FirstName} 写代码。");
public override void TakeBreak() => Console.WriteLine($"{FirstName} 喝咖啡。");
}
class Tester : Employee
{
public Tester(string f, string l) : base(f, l) { }
public override void PerformWork() => Console.WriteLine($"{FirstName} 找bug。");
public override void TakeBreak() => Console.WriteLine($"{FirstName} 踢足球。");
}
class Program
{
static void Main()
{
Employee[] team = {
new Developer("伊万", "彼得罗夫"),
new Tester("玛丽亚", "西多罗娃")
};
foreach (var emp in team)
{
Console.WriteLine($"\n--- {emp.FirstName} {emp.LastName} 的工作日 ---");
emp.PerformWork();
emp.GetPaid(2000);
emp.TakeBreak();
}
}
}
接口就完全不一样了。C# 8之前,接口只能有声明,比如方法、属性、索引器、事件。不能有字段,不能有构造函数,不能有方法实现!就是个“签名”,没内容。而且所有成员默认都是public(你就算不写public也一样)。这保证了接口就是纯契约,没实现细节,也没状态。
C# 8以后,接口可以有默认实现方法(Default Interface Methods)和静态成员。这样可以给老接口加新方法,不会“炸掉”一堆实现类。但即便这样,接口还是不能有实例字段和构造函数。这个限制就是为了让接口永远只是“行为契约”,不是“数据仓库”。
interface ISaveable
{
void Save(string file);
bool IsDirty { get; }
}
interface ILoadable
{
void Load(string file);
}
class GameProgress : ISaveable, ILoadable
{
public int Level { get; set; }
public string PlayerName { get; set; }
bool _isDirty = true;
public bool IsDirty => _isDirty;
public GameProgress(string name, int level)
{
PlayerName = name; Level = level;
}
public void Save(string file)
{
Console.WriteLine($"保存: {PlayerName}, 等级 {Level} -> {file}");
_isDirty = false;
}
public void Load(string file)
{
Console.WriteLine($"从 {file} 加载");
PlayerName = "新玩家"; Level = 5; _isDirty = true;
}
public void UpdateProgress(int newLevel)
{
Level = newLevel; _isDirty = true;
Console.WriteLine($"升级到 {Level}。");
}
}
class Program
{
static void Main()
{
var game = new GameProgress("英雄", 1);
game.UpdateProgress(3);
ISaveable saver = game;
if (saver.IsDirty) saver.Save("save.dat");
ILoadable loader = game;
loader.Load("save.dat");
Console.WriteLine($"加载后: {game.PlayerName}, {game.Level}");
}
}
总结:如果你需要一个基础类,不光有契约,还有实现代码(通用逻辑)或保存状态,那就用抽象类。如果你只要“清单”或“行为协议”,不需要实现和状态,那就用接口。
4. 什么时候用接口?
接口特别适合定义能力或行为,让完全不相关的对象都能拥有。比如:
- IDisposable:任何需要“释放资源”的对象(文件、网络连接、数据库)。
- IEnumerable<T>:任何能用foreach遍历的对象。
- IComparable<T>:任何能和同类型对象比较的对象。
关键是,FileStream和SqlConnection都能实现IDisposable,List<T>和Dictionary<TKey, TValue>都能实现IEnumerable<T>。这些类完全不是一个家族,但它们有共同的能力,就是接口定义的。
举个例子:你有个系统,有汽车和飞机。它们都可以是交通工具(也许是抽象类)。但汽车、飞机,甚至船(你要是加的话)——都能移动。这个“能移动”的能力就是接口IMovable的最佳场景。
interface IMovable
{
void Move(int distance);
}
class Car : IMovable
{
public string Brand;
public Car(string brand) => Brand = brand;
public void Move(int d) => Console.WriteLine($"{Brand} 行驶 {d} 公里。");
}
class Airplane : IMovable
{
public string Model;
public Airplane(string model) => Model = model;
public void Move(int d) => Console.WriteLine($"{Model} 飞行 {d} 公里。");
}
class Human : IMovable
{
public string Name;
public Human(string name) => Name = name;
public void Move(int d) => Console.WriteLine($"{Name} 走了 {d} 米。");
}
class Program
{
static void Main()
{
IMovable[] movers = { new Car("丰田"), new Airplane("波音747"), new Human("阿图尔") };
foreach (var item in movers)
item.Move(100);
}
}
看到了吧?我们可以搞个IMovable列表,直接调用Move(),根本不用管它到底是Car、Airplane还是Human。这就是接口多态的威力。
5. 什么时候用抽象类?
抽象类最适合定义一组紧密相关类的通用功能,这些类都是某种东西的变种。它能提供所有子类都需要的代码,还能强制子类实现自己的特殊部分。
比如你有一堆不同类型的银行账户:SavingAccount(储蓄)、CheckingAccount(活期)、CreditAccount(信用)。它们都是BankAccount。都有余额(Balance),都能存钱(Deposit)。但取钱(Withdraw)的规则各不一样。这时候抽象类BankAccount就派上用场了!
abstract class BankAccount
{
public string AccountNumber { get; }
public decimal Balance { get; protected set; }
public BankAccount(string acc) { AccountNumber = acc; }
public void Deposit(decimal sum)
{
if (sum > 0)
{
Balance += sum;
Console.WriteLine($"{AccountNumber}: +{sum:C}, 余额: {Balance:C}");
}
}
public abstract bool Withdraw(decimal sum);
}
class CheckingAccount : BankAccount
{
public CheckingAccount(string acc) : base(acc) { }
public override bool Withdraw(decimal sum)
{
if (Balance >= sum)
{
Balance -= sum;
Console.WriteLine($"{AccountNumber}: -{sum:C}, 余额: {Balance:C}");
return true;
}
Console.WriteLine($"{AccountNumber}: 余额不足");
return false;
}
}
class CreditAccount : BankAccount
{
public decimal CreditLimit { get; }
public CreditAccount(string acc, decimal limit) : base(acc) => CreditLimit = limit;
public override bool Withdraw(decimal sum)
{
if (Balance - sum >= -CreditLimit)
{
Balance -= sum;
Console.WriteLine($"{AccountNumber}: -{sum:C}, 余额: {Balance:C}");
return true;
}
Console.WriteLine($"{AccountNumber}: 超过额度");
return false;
}
}
class Program
{
static void Main()
{
var checking = new CheckingAccount("12345");
checking.Deposit(1000);
checking.Withdraw(300);
checking.Withdraw(800);
Console.WriteLine("\n--- 信用账户 ---");
var credit = new CreditAccount("67890", 500);
credit.Deposit(200);
credit.Withdraw(400);
credit.Withdraw(400);
}
}
这里BankAccount给所有账户提供了通用的存钱逻辑(Deposit),管理账号和余额。但取钱(Withdraw)的逻辑不一样,所以是抽象的。
6. 他们如何配合:完美搭档
最优雅、最强大的设计,往往是抽象类和接口一起用。抽象类自己也能实现一个或多个接口!
想象下:你有个abstract Animal,定义了基础内容。但有些Animal还能是IMovable、ICarnivore、IPredator啥的。你的Animal甚至能给IMovable提供基础实现(比如Move(int speed)),然后具体类像Lion或Fish再重写,按自己的方式移动。
public interface ISaveable
{
void SaveState(string path);
bool HasChanges { get; }
}
public class Vector3
{
public float X, Y, Z;
public Vector3(float x, float y, float z) { X = x; Y = y; Z = z; }
public override string ToString() => $"({X}, {Y}, {Z})";
}
public abstract class GameObject
{
public string Id { get; }
public Vector3 Position { get; }
protected GameObject(string id, Vector3 pos) { Id = id; Position = pos; }
public abstract void Update();
public void Destroy() => Console.WriteLine($"{Id} 被销毁");
}
public class Player : GameObject, ISaveable
{
public int Health { get; private set; }
private bool _hasChanges = true;
public bool HasChanges => _hasChanges;
public Player(string id, Vector3 pos, int health) : base(id, pos) => Health = health;
public override void Update() =>
Console.WriteLine($"{Id} 更新中, HP: {Health}, Pos: {Position}");
public void TakeDamage(int dmg)
{
Health -= dmg;
_hasChanges = true;
Console.WriteLine($"{Id} 受到 {dmg} 伤害。HP: {Health}");
}
public void SaveState(string path)
{
Console.WriteLine($"保存 {Id} (HP:{Health}) 到 {path}");
_hasChanges = false;
}
}
class GameEngine
{
static void Main()
{
var player = new Player("P1", new Vector3(0, 0, 0), 100);
player.Update();
player.TakeDamage(20);
ISaveable saver = player;
if (saver.HasChanges) saver.SaveState("save.json");
GameObject obj = player;
obj.Update();
player.Destroy();
}
}
在这个例子里,Player是GameObject(继承,拥有Id、Position和Destroy方法)。同时Player还能被保存(实现了ISaveable接口)。这就是超灵活、超强大的设计!
7. 细节和常见错误
试图实例化抽象类:
Animal myAnimal = new Animal(); ——新手常犯的错。记住:抽象类只是模板,不是能直接用的对象。编译器会直接报错。
忘了实现抽象方法: 如果你继承了抽象类,而且你的类不是抽象的,必须重写(override)所有基类的抽象方法。不然编译器也会报错。
想在接口里加字段: 这是接口的“铁律”之一。C# 8+可以有静态字段,但实例字段(属于具体对象的)还是不行。接口就是行为契约,不是数据仓库。
搞混virtual和abstract:
- virtual方法/属性有默认实现,可以被子类重写。
- abstract方法/属性没有实现,必须在第一个非抽象子类里重写。
- 如果用virtual但逻辑总是不同,就得写空实现再重写,这样契约就不如abstract清晰。
理解这些区别,正确选择抽象类还是接口,是每个C#程序员的必备技能。这不仅仅是语法问题,更是架构思维问题。设计系统时,问问自己:“这些类有共同的‘是一个’层级吗?”、“这些类有共同的‘能力’,但类型完全不相关吗?”答案会帮你做出正确选择。
下节课我们会继续深入接口的世界,看看C#新版本里那些更高级的玩法。别走开哦!
GO TO FULL VERSION