参数或返回值为派生类的接口或抽象方法

如果一个interface或abstract method的参数为基类形参,而有另一个类实现了该interface,但却使用了派生类形参,会发生什么?或者考虑另一种情况,一个interface或abstract method的返回值为基类对象,而它的实现返回的却是派生类对象,会发生什么?

这两个问题的答案是相同的,都是编译报错。想来有点费解,这不是违反SOLID原则中的里氏替换原则么?再仔细想想可以理解,此interface或abstract method的定义其实与实现它们的类本身是无关的,这些类对它的参数是否应为派生类的对象并不知晓。

问题定义

上面这段话看起来有点拗口,还是拿实例说话(此处定义了可说明问题的最小实例,例子本身的抽象可能略有不当,会意即可)。考虑下面的类关系:猫会玩毛球,狗要玩狗棒,毛球和狗棒自玩具类继承下来,而猫和狗实现了玩这个接口。对应一下:

有一个IPlay接口,Cat类和Dog类实现了此接口,形参为Toy类,而CatToy(毛球)和DogToy(狗棒)都继承了Toy(玩具)这个基类。但CatToy和DogToy分别有一个派生的属性Size和Length。UML图示意如下:

IPlayInterface

首先定义Toy基类及派生类:

public abstract class Toy
{
}

public class CatToy : Toy
{
    public double Size { get; set; }
}

public class DogToy : Toy
{
    public double Length { get; set; }
}

IPlay的接口定义也简单:

public interface IPlay
{
    void Play(Toy toy);
}

但在实现它的时候有点意思了:

public class Cat : IPlay
{
    public void Play(CatToy toy)
    {
        var woolballSize = toy.Size;
        Console.WriteLine($"Cat plays a woolball with size {woolballSize}");
    }
}

Cat类中报错:

'Cat' does not implement interface member 'IPlay.Play(Toy)'
注意到在实现IPlay时我们的参数写的是派生类CatToy而非IPlay原始定义中的Toy,因此编译器报错。如前文所述,对编译器来说,Cat类并不知道Play方法的参数应该是CatToy,它只能严格地遵从IPlay的定义。试想另一种情况,如果Cat类中有两个方法:
void Play(CatToy);
void Play(DogToy);
那么到底哪个方法实现了IPlay呢?出现了二义性。

解决方案

回过头来看下这个问题可以如何解决,有三种方法。

强制类型转换

public class Cat : IPlay
{
    public void Play(Toy toy)
    {
        var woolballSize = (toy as CatToy).Size;
        Console.WriteLine($"Cat plays a woolball with size {woolballSize}");
    }
}

同理,在Dog类中,将Toy强制转换成DogToy类型再读取Length属性。这种实现虽然解决了编译错误,但能用多态解决的强制转换在OOP中应尽量避免,使用时需要仔细斟酌是否有抽象不合理之处。此处有待商榷,因为对Cat类来说,输入已知为CatToy类,二次转换稍显多余。

使用强制转换的完整代码如下:

public interface IPlay
{
    void Play(Toy toy);
}

public class Cat : IPlay
{
    public void Play(Toy toy)
    {
        var woolballSize = (toy as CatToy).Size;
        Console.WriteLine($"Cat plays a woolball with size {woolballSize}");
    }
}

public class Dog : IPlay
{
    public void Play(Toy toy)
    {
        var stickLength = (toy as DogToy).Length;
        Console.WriteLine($"Dog plays a stick with length {stickLength}");
    }
}

public abstract class Toy
{
}

public class CatToy : Toy
{
    public double Size { get; set; }
}

public class DogToy : Toy
{
    public double Length { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        IPlay cat = new Cat();
        IPlay dog = new Dog();
        Toy woolball = new CatToy { Size = 3 };
        Toy stick = new DogToy { Length = 5};

        cat.Play(woolball);
        dog.Play(stick);

        Console.ReadKey();
    }
}

用泛型重定义接口

可以用C#中的泛型定义一个更通用的接口IPlay:

public interface IPlay<T> where T : class
{
    void Play(T toy);
}

而与之对应的Cat和Dog类实现如下:

public class Cat : IPlay<CatToy>
{
    public void Play(CatToy toy)
    {
        var woolballSize = toy.Size;
        Console.WriteLine($"Cat plays a woolball with size {woolballSize}");
    }
}

public class Dog : IPlay<DogToy>
{
    public void Play(DogToy toy)
    {
        var stickLength = toy.Length;
        Console.WriteLine($"Dog plays a stick with length {stickLength}");
    }
}
注意,此时Cat和Dog类中的参数类型是在编译时就确定为CatToy和DogToy,并非使用了多态。因此,实参也必须是CatToy和DogToy这样的派生类类型。

使用泛型的完整代码如下,注意woolball和stick的类型是CatToy、DogToy而非Toy基类:

public interface IPlay<T> where T : class
{
    void Play(T toy);
}

public class Cat : IPlay<CatToy>
{
    public void Play(CatToy toy)
    {
        var woolballSize = toy.Size;
        Console.WriteLine($"Cat plays a woolball with size {woolballSize}");
    }
}

public class Dog : IPlay<DogToy>
{
    public void Play(DogToy toy)
    {
        var stickLength = toy.Length;
        Console.WriteLine($"Dog plays a stick with length {stickLength}");
    }
}

public abstract class Toy
{
}

public class CatToy : Toy
{
    public double Size { get; set; }
}

public class DogToy : Toy
{
    public double Length { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        IPlay<CatToy> cat = new Cat();
        IPlay<DogToy> dog = new Dog();
        CatToy woolball = new CatToy { Size = 3 };
        DogToy stick = new DogToy { Length = 5};

        cat.Play(woolball);
        dog.Play(stick);

        Console.ReadKey();
    }
}

只使用Toy类

其实还有第三种实现方法,删除CatToy和DogToy类,将Size和Length属性都放到基类Toy中去。

三种方法我觉得都不太完美,这里仅是从实现的角度对其进行了分析,实践当中应根据具体情况进行取舍和选择,相比之下,个人更倾向于使用后两种方法。

Reference

C# base class/Interface with a generic method that accepts the derived class as a parameter