C# 接口用法总结

1,529 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第37天,点击查看活动详情

前言

面向对象编程中最重要的原则之一就是面向接口编程。而C#作为一个精心设计的面向对象高级语言,对接口有着良好的支持。

接口和类的区别

  • 接口提供设计规范而非实现,而类主要是为了实现具体的功能。
  • 接口中所有的方法都是隐式的抽象方法。而类即可以提供抽象方法也可以提供包含实现的具体方法。
  • 一个类或结构体可以实现多个接口。而类只能继承自唯一的一个基类,结构体甚至不能自由继承(除了结构体本身继承自System.ValueType之外)。

接口的定义

举个例子:

public interface IWeapon
{
    float GetDamage();    
    void Use();    
}
  • 接口使用interface关键字定义。
  • 接口中只能包含方法属性事件索引器这几种成员。而它们也是类中可以定义为抽象的类成员。

接口成员的访问级别

接口成员总是隐式的声明为public,并且不能显式的声明任何访问修改符。因此,实现接口时必须为所有成员提供public实现,例如:

public class Sword : IWeapon
{
    private SwordConfig config;
    
    public float GetDamage() => config.swordDamage;    
    pubiic void Use()
    {
        Debug.Log("Sword Attacks");
    }
}

使用接口引用对象

接口的存在就是为了引用实现它的类的对象实例,使用接口变量而不是直接使用类变量,才是基于接口编程。例如:

IWeapon w = new Sword();
w.Use();

这是从具体类型Sword隐式转换为它所实现的接口IWeapon。 另外即便Sword实现了多个接口,也还是可以直接隐式转换为任意一个其实现的接口。

扩展接口

接口可以继承其他接口,但C#不说继承,而称之为扩展(Extending)。例如:

public interface IGameProperty
{
    float GetPrice();
    float GetWeight();
}

public interface IWeapon : IGameProperty
{
    float GetDamage();    
    void Use();
}

实现IWeapon接口的类也必须实现IGameProperty接口中所有成员。

显式接口实现

当一个类实现了多个接口时,如果这些接口中有相同签名的成员,那么就会产生冲突。解决这个问题,可以通过显式的实现会产生冲突的接口成员。举个例子:

interface IWeapon { float GetWeight(); }
interface IArmor { int GetWeight(); }

public class MagicArmor : IArmor, IWeapon
{
    public int GetWeight()
    {
        return 100;
    }
    
    public float IWeapon.GetWeight()
    {
        return 100.0f;
    }
}


这个例子中,当在一个MagicArmor对象上调用 GetWeight()方法时,调用的是IArmor.GetWeight(),如果要调用IWeapon.GetWeight(),则需要显式转型:

MagicArmor ma = new MagicArmor();
ma.GetWeight(); //call IArmor.GetWeight()
((IWeapon)ma).GetWeight(); //call IWeapon.GetWeight()

显式接口实现除了是为了解决冲突之外,也可能是为了隐藏部分接口实现。

接口成员实现为虚函数

默认情况下,类实现接口的成员时,该成员是一个sealed成员。如果该类的子类需要改写该成员,则需要将其声明为virtualabstract

子类重新实现父类实现的接口

例如:

public interface IFoo { void Foo(); }

public class Bar: IFoo
{
    void IFoo.Foo() { Debug.Log("Bar.Foo"); }
}

public class SubBar: Bar, IFoo
{
    public void Foo() { Debug.Log("SubBar.Foo"); }
}

子类SubBar在继承父类Bar的同时,又声明其实现了接口IFoo,而父类Bar也实现了接口IFoo。在子类SubBar中对IFoo的实现将覆盖父类中的IFoo实现。

SubBar sb = new SubBar();
sb.Foo();          // SubBar.Foo
((IFoo)sb).Foo();  // SubBar.Foo

更优雅的做法

虽然子类重新实现可以覆盖父类实现的接口,但这么做有两个问题:

  • 子类不能调用被覆盖的父类方法
  • 父类被覆盖的方法可能并不适合被覆盖,覆盖它可能会引起潜在的bug。比如父类方法可能修改了一些内部状态,而子类并不知道这些内部状态需要设置,甚至这些内部状态是不可以被子类设置的。

为了解决这个问题,我们需要更优雅的做法,即在设计父类时就应该避免子类需要覆盖父类的方法。父类将子类可能要修改的方法定位为虚函数。

  • 当父类是隐式实现接口时,将接口方法定义为虚函数。
  • 当父类是显示实现接口时,另外定义一个虚函数并提供实现,然后在接口实现方法中调用它:
public class Bar: IFoo
{
    void IFoo.Foo() => Foo();
    protected virtual void Foo() { Debug.Log("Bar.Foo");}
}

public class SubBar: Bar
{
    protected override void Foo() { Debug.Log("SubBar.Foo");}
}

当然了,这其实是设计问题,如果你掌握了这个设计技巧,就不会面临需要子类强行覆盖父类的接口实现的问题。但是如果你在使用一个第三方代码,遇到子类需要改写接口实现,那么覆盖是最后的方法。

接口和装箱

  • 结构体转型为接口时会造成装箱。
  • 但是在结构体上调用隐式实现的接口方法则不会引起装箱。 例如:
interface IFoo
{
    void Foo();
}

struct Bar : IFoo
{
    public void Foo(){}
}

Bar b = new Bar();
b.Foo(); // 不会发生装箱

IFoo i = b; //这儿的隐式转型会造成装箱
i.Foo();