泛型多态性 - 奇怪的行为

3

可插拔框架

想象一个简单的可插拔系统,使用继承多态性相当直接:

  1. 我们有一个图形渲染系统。
  2. 有不同类型的图形形状(单色、彩色等)需要渲染。
  3. 渲染由特定于数据的插件完成,例如ColorRenderer将呈现ColorShape。
  4. 每个插件都实现了IRenderer,因此它们都可以存储在IRenderer[]中。
  5. 启动时,IRenderer[]填充为一系列特定的渲染器。
  6. 当接收到新形状的数据时,根据形状的类型从数组中选择插件。
  7. 然后通过调用其Render方法来调用插件,传递形状作为其基本类型。
  8. Render方法在每个派生类中被重写;它将形状强制转换回其派生类型,然后进行渲染。

希望以上内容清晰明了 - 我认为这是一种非常常见的设置。 使用继承多态性和运行时转换非常容易。

无需转换

现在是棘手的部分。响应于this question,我想出了一种方法来完成所有这些事情完全不需要任何转换。这很棘手,因为那个IRenderer[]数组——要从数组中取出插件,通常需要将其强制转换为特定类型,以便使用其类型特定的方法,但我们不能这样做。现在,我们可以通过仅使用其基类成员与插件交互来解决这个问题,但要求的一部分是渲染器必须运行具有类型特定数据包作为参数的类型特定方法,而基类将无法做到这一点,因为没有办法传递类型特定数据包而不将其强制转换为基类然后再返回祖先。棘手。
起初我认为这是不可能的,但经过几次尝试,我发现可以通过操纵C#泛型系统来实现。我创建了一个相对于插件和形状类型都是逆变的接口,然后使用它。渲染器的解析由类型特定的形状决定。Xyzzy,逆变接口使强制转换不必要。
这是我能想到的一个例子,代码最短的版本。它可以编译、运行并正确地执行:
public enum ColorDepthEnum { Color = 1, Monochrome = 2 }

public interface IRenderBinding<in TRenderer, in TData> where TRenderer : Renderer 
                                                  where TData: Shape  
{ 
    void Render(TData data);
}
abstract public class Shape
{
    abstract public ColorDepthEnum ColorDepth { get; }
    abstract public void Apply(DisplayController controller);
}

public class ColorShape : Shape
{
    public string TypeSpecificString = "[ColorShape]";  //Non-virtual, just to prove a point
    override public ColorDepthEnum ColorDepth { get { return ColorDepthEnum.Color; } }

    public override void Apply(DisplayController controller)
    {
        IRenderBinding<ColorRenderer, ColorShape> renderer = controller.ResolveRenderer<ColorRenderer, ColorShape>(this.ColorDepth);
        renderer.Render(this);
    }
}
public class MonochromeShape : Shape
{
    public string TypeSpecificString = "[MonochromeShape]";  //Non-virtual, just to prove a point
    override public ColorDepthEnum ColorDepth { get { return ColorDepthEnum.Monochrome; } }

    public override void Apply(DisplayController controller)
    {
        IRenderBinding<MonochromeRenderer, MonochromeShape> component = controller.ResolveRenderer<MonochromeRenderer, MonochromeShape>(this.ColorDepth);
        component.Render(this);
    }
}


abstract public class Renderer : IRenderBinding<Renderer, Shape>
{
    public void Render(Shape data) 
    {
        Console.WriteLine("Renderer::Render(Shape) called.");
    }
}


public class ColorRenderer : Renderer, IRenderBinding<ColorRenderer, ColorShape>
{

    public void Render(ColorShape data) 
    {
        Console.WriteLine("ColorRenderer is now rendering a " + data.TypeSpecificString);
    }
}

public class MonochromeRenderer : Renderer, IRenderBinding<MonochromeRenderer, MonochromeShape>
{
    public void Render(MonochromeShape data)
    {
        Console.WriteLine("MonochromeRenderer is now rendering a " + data.TypeSpecificString);
    }
}


public class DisplayController
{
    private Renderer[] _renderers = new Renderer[10];

    public DisplayController()
    {
        _renderers[(int)ColorDepthEnum.Color] = new ColorRenderer();
        _renderers[(int)ColorDepthEnum.Monochrome] = new MonochromeRenderer();
        //Add more renderer plugins here as needed
    }

    public IRenderBinding<T1,T2> ResolveRenderer<T1,T2>(ColorDepthEnum colorDepth) where T1 : Renderer where T2: Shape
    {
        IRenderBinding<T1, T2> result = _renderers[(int)colorDepth];  
        return result;
    }
    public void OnDataReceived<T>(T data) where T : Shape
    {
        data.Apply(this);
    }

}

static public class Tests
{
    static public void Test1()
    {
       var _displayController = new DisplayController();

        var data1 = new ColorShape();
        _displayController.OnDataReceived<ColorShape>(data1);

        var data2 = new MonochromeShape();
        _displayController.OnDataReceived<MonochromeShape>(data2);
    }
}

如果运行Tests.Test1(),输出将是:
ColorRenderer is now rendering a [ColorShape]
MonochromeRenderer is now rendering a [MonochromeShape]

很漂亮,它有效,对吧?然后我想知道...如果ResolveRenderer返回了错误的类型会怎么样?

类型安全?

根据这篇MSDN文章,

相反变性似乎是不合理的...这似乎是倒退的,但它是编译和运行的类型安全代码。代码是类型安全的,因为T指定了一个参数类型。

我在想,这绝对不是类型安全的。

引入一个返回错误类型的错误

所以我故意在控制器中引入了一个错误,使其错误地存储了一个ColorRenderer,而MonochromeRenderer应该被存储,就像这样:

public DisplayController()
{
    _renderers[(int)ColorDepthEnum.Color] = new ColorRenderer();
    _renderers[(int)ColorDepthEnum.Monochrome] = new ColorRenderer(); //Oops!!!
}

我本以为会收到某种类型不匹配的异常。但是没有,程序完成了,并输出了这个神秘的结果:

ColorRenderer is now rendering a [ColorShape]
Renderer::Render(Shape) called.

什么鬼?

我的问题:

首先,为什么MonochromeShape::Apply调用了Renderer::Render(Shape)?它试图调用Render(MonochromeShape),显然这个方法签名不同。

MonochromeShape::Apply方法中的代码只引用了一个接口,具体来说是IRelated<MonochromeRenderer,MonochromeShape>,该接口只公开了Render(MonochromeShape)

虽然Render(Shape)看起来相似,但它是一个不同的方法,有不同的入口点,甚至不在使用的接口中。

其次,

由于每个后代类型都引入了一个具有不同类型特定参数的新的非虚拟、非重载方法,因此没有任何一个Render方法是虚拟的,我本以为入口点在编译时被绑定。方法组中的方法原型实际上是在运行时选择的吗?如果没有VMT分派的条目,这怎么可能工作?它使用某种反射吗?

第三个问题,

c#逆变是否绝对不安全?我得到的是意外行为,而不是无效的转换异常(至少告诉我有问题)。是否有任何方法可以在编译时检测出这样的问题,或者至少让它们抛出异常而不是执行一些意外的操作?


1
如果您的对象实际上无法使用任何渲染器呈现任何形状,则不要实现“IRenderBinding<Renderer,Shape>”,因为这就是您正在实现的内容,因此这就是您声称这些对象可以做到的。由于您声称这些对象知道如何使用任何渲染器呈现任何形状,因此类型系统将允许您使用任何渲染器呈现任何形状。类型系统因此正常工作并且安全。如果您的渲染器实际上无法使用任何渲染器呈现任何形状,则不要实现“IRenderBinding<Renderer,Shape>”。 - Servy
1
顺便说一句,提供一个可运行的最小示例真的很棒。这对我们帮助很大。 - Eric Lippert
2个回答

8

首先,不要像这样编写通用类型。正如您所发现的那样,它很快变得非常混乱。永远不要这样做:

class Animal {}
class Turtle : Animal {}
class BunchOfAnimals : IEnumerable<Animal> {}
class BunchOfTurtles : BunchOfAnimals, IEnumerable<Turtle> {}

OH THE PAIN. 现在我们有两种方法可以从 BunchOfTurtles 获取一个 IEnumerable<Animal>: 要么询问基类获取其实现,要么询问派生类获取其对 IEnumerable<Turtle> 的实现,然后将其协变转换为 IEnumerable<Animal>。后果是:你可以要求一群乌龟提供一系列动物,并且长颈鹿可能会出现。这不是矛盾的;派生类包含基类的所有功能,包括在被要求时生成一系列长颈鹿。
让我再强调一下这一点,以便清楚明了。这个模式在某些情况下会创建实现定义的情况,使得静态地确定实际调用的方法变得不可能。在一些奇怪的角落情况下,你实际上可以让源代码中方法出现的顺序在运行时成为决定因素。只要不去那里就好了。
更多关于这个迷人主题的内容,请阅读我2007年关于此问题的博客文章的所有评论:https://blogs.msdn.microsoft.com/ericlippert/2007/11/09/covariance-and-contravariance-in-c-part-ten-dealing-with-ambiguity/
现在,在你的具体情况中,一切都很好定义,只是没有按照你认为应该的方式定义。
首先,为什么这是类型安全的?
IRenderBinding<MonochromeRenderer, MonochromeShape> component = new ColorRenderer();

因为你说应该这样做。从编译器的角度来看解决它。
  • ColorRenderer 是一个 Renderer
  • Renderer 是一个 IRenderBinding<Renderer, Shape>
  • IRenderBinding 在其参数上是逆变的,因此它始终可以具有更具体的类型参数。
  • 因此,Renderer 是一个 IRenderBinding<MonochromeRenderer, MonochromeShape>
  • 因此,转换是有效的。

完成了。

那么为什么在这里调用了 Renderer::Render(Shape)

    component.Render(this);

你的问题是:
由于每个后代类型都引入了一个新的非虚拟、未重写的方法,带有不同的特定于类型的参数,因此 Render 方法中没有任何虚拟方法,我原以为入口点在编译时绑定。方法组中的方法原型实际上是在运行时选择的吗?如果没有 VMT 条目进行分派,这怎么可能工作?它使用某种反射吗?
让我们来看看。
component 的编译时类型是 IRenderBinding。
this 的编译时类型是 MonochromeShape。
因此,我们正在调用实现 IRenderBinding.Render(MonochromeShape) 的方法,在 ColorRenderer 上。
运行时必须弄清楚实际意义是哪个接口。ColorRenderer 直接实现了 IRenderBinding,并通过其基类实现了 IRenderBinding。前者与 IRenderBinding 不兼容,但后者兼容。
因此,运行时推断出您指的是后者,并执行调用,就好像它是 IRenderBinding.Render(Shape)。
那么调用哪个方法?你的类在基类上实现了 IRenderBinding.Render(Shape),所以被调用的就是它。
请记住,接口定义“槽”,每个方法一个。当对象创建时,每个接口槽都会填充一个方法。IRenderBinding.Render(Shape) 的槽填充了基类版本,而 IRenderBinding.Render(ColorShape) 的槽填充了派生类版本。你选择了前者的槽,所以得到了该槽的内容。
另外一个问题是:
C# 逆变是否绝对不安全?
我向你保证它是类型安全的。正如你应该注意到的:你进行的每个转换都是合法的,没有需要强制转换的情况,并且你调用的每个方法都是使用其期望的类型调用的。例如,你从未使用 MonochromeShape 引用调用了 ColorShape 的方法。
还有一个问题是:
与其得到一个无效的强制转换异常(至少告诉我有问题),我得到了一个意外的行为。
这可能是因为你没有正确处理转换的结果,或者你的代码中存在其他问题。请检查代码并确保所有转换和调用都是正确的。
不,你得到了完全预期的行为。只是你创造了一个极其混乱的类型格子,并且你对类型系统的理解程度不足以理解你编写的代码。不要这样做。

有没有办法在编译时检测到这种问题,或者至少让它们抛出异常而不是执行意外操作?

首先不要编写这样的代码。永远不要实现两个版本的同一接口,使它们可以通过协变或逆变转换统一。这只会带来痛苦和困惑。同样地,永远不要实现一个在通用替换下统一的方法的接口。(例如:interface IFoo<T> { void M(int); void M(T); } class Foo : IFoo<int> { uh oh }
我考虑添加一个警告来实现这一点,但很难看到如何在需要时关闭警告。只能用编译指示符关闭的警告是低效的警告。

非常有帮助!幸运的是,我从未为任何商业目的需要过如此混乱的类型系统。 - John Wu

2

首先,MonochromeShape::Apply 调用 Renderer::Render(Shape) 是因为以下原因:

IRenderBinding<ColorRenderer, ColorShape> x1 = new ColorRenderer();
IRenderBinding<Renderer, Shape> x2 = new ColorRenderer();
// fails - cannot convert IRenderBinding<ColorRenderer, ColorShape> to IRenderBinding<MonochromeRenderer, MonochromeShape>
IRenderBinding<MonochromeRenderer, MonochromeShape> c1 = x1;
// works, because you can convert IRenderBinding<Renderer, Shape> toIRenderBinding<MonochromeRenderer, MonochromeShape>
IRenderBinding<MonochromeRenderer, MonochromeShape> c2 = x2;

简而言之:ColorRenderer继承自Renderer,后者实现了IRenderBinding<Renderer, Shape>。这个接口使得ColorRendered可以隐式转换为IRenderBinding<MonochromeRenderer, MonochromeShape>Renderer类实现了这个接口,所以当你调用MonochromeShape::Apply时会调用Renderer.Render。你传递的是MonochromeShape实例而不是Shape并不是问题,因为TData是逆变的。
至于你的第二个问题。按接口分派在定义上就是虚拟的。实际上,如果方法实现了接口中的某个方法,则在IL中将其标记为虚拟方法。请看以下示例:
class Test : ITest {
    public void DoStuff() {

    }
}

public class Test2 {
    public void DoStuff() {

    }
}

interface ITest {
    void DoStuff();
}

在IL中,方法Test.DoStuff的签名如下(注意virtual):

.method public final hidebysig virtual newslot instance void 
    DoStuff() cil managed 

Test2.DoStuff方法仅仅是:

.method public hidebysig instance void 
    DoStuff() cil managed

对于第三个问题,我认为从上面的内容可以清楚地看出它的行为与预期一致,并且正是因为不可能出现无效的转换异常,所以它是类型安全的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接