【C#】抽象类与接口

前言

算一算很长时间没有写过文章了。不过隔壁出了个Java的抽象类与接口,那我当然很有兴趣来写写C#的抽象类和接口啦。本文意在于介绍抽象类以及接口的配合使用,关于更多细节请参考微软官方文档

抽象类

使用abstract修饰的类即为抽象类,它具有以下语法特征:

1.无法创造实例,也就是说你没法new一个抽象基类出来。

2.只有抽象类可以包含抽象方法,即子类必须重写的方法。

在这里,我们定义一个动物的抽象类Animal,实现获取名称和被食用的功能(虽然这两个功能放一起感觉怪怪的)。

public abstract class Animal
{
    //名称
    protected string name;
    //获取名称
    public abstract string Name { get; }
    //构造方法
    public Animal(string name) => this.name = name;
    //食用
    public abstract void ToEat();
    //打印名称
    public virtual void PrintName()
    {
        System.Console.WriteLine("芝士动物");
    }
}

然后,我们再写一个鸭子Duck类继承Animal类。如果不想让其他类继承Duck类,还可以使用sealed修饰此类为密封类。不过与抽象类才能包含抽象方法不同的是,即使不是密封类也可以包含密封方法。

public class Duck:Animal
{
    //构造方法
    public Duck(string name) : base(name) { }
    //对抽象属性的实现
    public override string Name => "鸭子" + name;
    //使用sealed修饰,可以使得子类无法重写此方法(但是可以new覆盖)
    //也就是说,鸭子只能烤着吃!(暴论)
    public sealed override void ToEat()
    {
        System.Console.WriteLine("做成烤鸭吃掉了");
    }
}

但只使用抽象类是有问题的:

1.并非只有动物才能获取名称和食用。换句话说,获取名称和食用这两个行为是广泛存在的,使用者只关注能不能获取名称以及能不能食用,而不关注它是动物,是水果还是巧克力。

//这里以食用为例
public class People
{
    //吃小动物
    public void Eat(Animal animal)
    {
        animal.ToEat();
    }
    //吃水果
    public void Eat(Fruit fruit)
    {
        fruit.Eat(number: 2);
    }
    //吃巧克力
    public void Eat(Chocolate chocolate)
    {
        chocolate.WhenEat();
    }
    //吃毛豆精
    public void Eat(Zundamon nanoda)
    {
        nanoda.Used(times: 114);
    }
    //有多少种能吃的东西,就要补充多少个方法,每种东西吃的方法名称和参数可能还不一样……
    //多了,少了一种东西,或者吃的方法变了,还得一个一个一个找和改。实在太痛苦了!太痛苦了!
}

2.一个类最多只能继承一个类,而我们往往很难只靠继承去定义一个类。因为继承造成的耦合性是极大的,依赖继承去设计合理的类之间的关系,难度不亚于用勺子吃面。

//小鸡仔是鸟类,但还不会飞,也不会下蛋。是幼崽,需要被照顾,但又会自己觅食。会游泳,但游泳很容易受凉去世。
public class Chick:Bird?FlyAnimal?Baby?SwimAnimal?

接口

不难发现,当我们通过”是什么“和”能做什么“去选择继承的对象时,很容易出问题。因为继承只能在描述”是什么“与”能做什么“之间选一个。于是,我们需要接口来辅助描述一个类。

接口使用interface定义,它具有以下语法特征:

1.无法创造实例,也就是说你没法new一个接口出来。

2.一个类可以同时实现多个接口,接口之间也可以多继承

3.接口中不能包含实例字段

尽管接口中不能包含实例字段,但接口既可以描述”是什么“,也可以描述”能做什么“。

前者是因为接口中虽然不能包含实例字段,但可以通过定义属性去描述,更甚者,可以直接通过接口的名称去判断。后者在于实现接口的类必须实现接口中的所有方法(除默认实现和显式实现外,一般情况下,包含同名同参方法即视为实现)。

C#中的字典IDictionary接口,结构和功能都十分清晰明了
namespace System.Collections.Generic
{
    [DefaultMember("Item")]
    public interface IDictionary<TKey, TValue> : ICollection<KeyValuePair<TKey, TValue>>, IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable
    {
        TValue this[TKey key] { get; set; }

        ICollection<TKey> Keys { get; }
        ICollection<TValue> Values { get; }

        void Add(TKey key, TValue value);
        bool ContainsKey(TKey key);
        bool Remove(TKey key);
        bool TryGetValue(TKey key, out TValue value);
    }
}

可以说,接口的使用可以让我们转变一个思路,即:判断一个类是什么、能做什么不取决于这个类本身,而取决于它实现了什么接口。

使用

实际使用过程中,可以用抽象类配合接口的方式设计。这里,我们以此方式重新设计一下Animal和Duck类。

public interface IEdible
{
    void ToEat();
}
public interface INamed
{
    string Name { get; }
    void PrintName();
}
public abstract class Animal:IEdible,INamed
{
    protected string name;
    //名称
    public virtual string Name { get => name; protected set => name = value; }
    //构造方法
    public Animal(string name) => Name = name;
    //食用
    public abstract void ToEat();
    //打印名称
    public virtual void PrintName()
    {
        System.Console.WriteLine(Name);
    }
}
public class Duck:Animal
{
    //构造方法
    public Duck(string name) : base(name) { }
    //重写
    public override string Name { get => name; protected set { name = "鸭子 " + value; } }
    //怎么吃
    public override void ToEat()
    {
        System.Console.WriteLine("做成烤鸭");
    }
}

让我们做个测试,看看其是否能成功运行。这里我使用Unity进行测试。

//测试代码
Duck d = new Duck("小个子");
Debug.Log(d.Name);
Debug.Log("IsEdible = " + (d is IEdible));
//输出
[23:05:12]鸭子 小个子
[23:05:12]IsEdible = True

那么在经过设计以后,无论以后还有什么动物、水果、巧克力……我们的使用者都可以像这样简单粗暴的去使用,而不需要修改。

public class People
{
    public void DoSomeThing(object obj)
    {
        //实现了IEdiable接口,就说明能吃,吃一下
        if (obj is IEdible food) Eat(food);

        //实现了IName接口,就说明能看,看一下
        if (obj is INamed thing) See(thing);
    }
    public void Eat(IEdible food)=> food.ToEat();
    public void See(INamed thing)=>System.Console.WriteLine($"看见了{thing.Name}");
}

(顺便感慨一下 is 运算符真的是个超级好用的东西,除了判空的时候)

对于小鸡仔,用接口重新设计可以是这样的:

public interface IBird:IFly,IWarble
public interface IChicken:IBird,ISwim,IWalk
public class Chick:Animal,IChicken,IBaby<IChicken>
//当然,这只是万千种方式之一。
//具体怎么设计要看你怎么使用。如果你的设想中小鸡仔和一般鸡只有行为不同,其他类似,那我更推荐使用策略模式,而不要单独设一个类。

更多

关于接口,随C#版本更新也有了些新特性,这也是我上文没有提到其某些”特征“的原因。

public interface IEdible
{
    //C#8.0新特性,允许接口中方法的默认实现
    void NewToEat()
    {
        System.Console.WriteLine("生吃");
    }

    //C#8.0起,接口中允许有静态方法的默认实现
    static void NewStaicFunc() { System.Console.WriteLine("因为有默认实现,开发者反而很容易忽略掉它"); }

    //C#11.0新特性,使得所有实现接口的类,必须实现这个静态方法
    static abstract void NewStaticFunc();
}

另外,和Java一样,接口还有一个令人匪夷所思的设定:类中可以定义接口,接口中可以定义类。当然,类中也可以定义类,接口中也可以定义接口。所以就可以写个这种玩意出来:

public class A1
{
    public class A11 { }
    public interface I12
    {
        public interface I121 { }
        public class A122
        {
            public class A1221 { }
            public interface I1222
            {
                public class A12221
                {
                    //无限套娃……
                }
            }
        }
    }
}

官方这样设计肯定是自有用意。所以只要写代码的人不是我的同事,或者我永远不会处理这段代码,我是很乐意看到他这样套娃的。

除此以外,接口对于泛型类对象有奇效。例如有一个object对象,你只知道它是已知的泛型类型,而不知道具体类型,你就无法调用它的泛型方法(因为你不知道具体类型,无法简单地将它转化为泛型类型对象)。但是,除了写一大段反射以外,你还可以通过转换成接口的形式去调用它的泛型方法,这就是为什么泛型类型往往要实现很多个接口。

总结

抽象基类能对所有派生类实现一定程度上的约束;而接口帮助我们描述一个类,或着多个类之间的共性。如果多个类之间具有相同的功能,便可以提炼出接口。使用抽象类配合接口,可以帮助我们更好地设计自己的程序,减少后期维护的成本。

© 版权声明
THE END
喜欢就点个赞吧
点赞0 分享
评论 共2条

请登录后发表评论

    • Acoloco的头像-水波萌Acoloco等级-LV4-水波萌作者0