CodeGym /课程 /C# SELF /接口和抽象类的区别

接口和抽象类的区别

C# SELF
第 23 级 , 课程 1
可用

1. 一枚硬币的两面

咱们已经挺深入地聊过抽象这个概念,也看了C#里两个超强的工具:抽象类接口。你可能已经感觉到它们挺像的,毕竟都能帮我们定义“行为骨架”,让具体类去实现。但说真的,这俩就像锤子和螺丝刀:都是工具,但用法完全不一样。

先说重点:为啥一件事要有俩工具?编程里跟生活一样,很少有“纯属巧合”。既然有两个类似的东西,那肯定各有自己的独门绝技和适用场景。

咱们先来个对比表,帮你一眼看明白。算是小抄,抓住最关键的点。

特性 抽象类 接口
实例化 不能直接创建 (
new AbstractClass()
).
不能直接创建 (
new IMyInterface()
).
成员实现 可以有:
– 完全实现的方法/属性。
– 抽象方法/属性(没实现)。
– 字段、构造函数、静态方法。
可以有默认实现的方法、静态抽象和静态非抽象成员。
不能有实例字段和构造函数。
访问修饰符 可以有任意的 (public, protected, internal, private). 接口里的成员默认都是public。不能写访问修饰符(例外:有默认实现和静态成员时)。
继承 只能继承一个抽象类。 可以实现多个接口。
关系类型 “是一个” (is-a)。定义基础类型和通用层级。 “能做” (has-acan-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();
    }
}

如果你的类某个层级的一部分(比如DogAnimal),就用类继承(抽象类或普通类)。如果你的类有某种能力(比如Dog跑,Cat也能跑),但这些能力跟层级没啥关系,就用接口。这就是接口最大的优势——能给完全不相关的类加上同样的“能力契约”。

3. 谁能存数据,谁只是承诺?

抽象类能装下各种东西:

  • 普通(非抽象)方法和属性,直接有实现。
  • 抽象方法和属性(没实现,等子类去写)。
  • 字段(实例变量),能保存对象状态。
  • 构造函数,用来初始化状态。
  • 甚至静态方法和属性。
  • 当然还能用各种访问修饰符:publicprotectedprivate啥的。

这让抽象类成了定义部分实现行为通用状态的利器,所有子类都能用。


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>:任何能和同类型对象比较的对象。

关键是,FileStreamSqlConnection都能实现IDisposableList<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(),根本不用管它到底是CarAirplane还是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还能是IMovableICarnivoreIPredator啥的。你的Animal甚至能给IMovable提供基础实现(比如Move(int speed)),然后具体类像LionFish再重写,按自己的方式移动。


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

在这个例子里,PlayerGameObject(继承,拥有IdPositionDestroy方法)。同时Player还能被保存(实现了ISaveable接口)。这就是超灵活、超强大的设计!

7. 细节和常见错误

试图实例化抽象类:

Animal myAnimal = new Animal();
——新手常犯的错。记住:抽象类只是模板,不是能直接用的对象。编译器会直接报错。

忘了实现抽象方法: 如果你继承了抽象类,而且你的类不是抽象的,必须重写(override)所有基类的抽象方法。不然编译器也会报错。

想在接口里加字段: 这是接口的“铁律”之一。C# 8+可以有静态字段,但实例字段(属于具体对象的)还是不行。接口就是行为契约,不是数据仓库。

搞混virtualabstract

  • virtual方法/属性有默认实现,可以被子类重写。
  • abstract方法/属性没有实现,必须在第一个非抽象子类里重写。
  • 如果用virtual但逻辑总是不同,就得写空实现再重写,这样契约就不如abstract清晰。

理解这些区别,正确选择抽象类还是接口,是每个C#程序员的必备技能。这不仅仅是语法问题,更是架构思维问题。设计系统时,问问自己:“这些类有共同的‘是一个’层级吗?”、“这些类有共同的‘能力’,但类型完全不相关吗?”答案会帮你做出正确选择。

下节课我们会继续深入接口的世界,看看C#新版本里那些更高级的玩法。别走开哦!

2
任务
C# SELF, 第 23 级, 课程 1
已锁定
多媒体播放器的通用接口
多媒体播放器的通用接口
2
任务
C# SELF, 第 23 级, 课程 1
已锁定
为具有不同能力的对象创建接口
为具有不同能力的对象创建接口
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION