C# - 不能对派生泛型类型的数组使用双重调度访客

C# - Can't use double-dispatch Visitor for array of derived generic types

提问人:Alberto 提问时间:3/20/2023 最后编辑:Alberto 更新时间:3/20/2023 访问量:65

问:

我想知道为什么我无法在 C# 中运行泛型对象的任何 Visitor。

出于某种原因,程序总是选择我的泛型中最不具体的泛型重载。

P.S.:我不是在寻求替代方法的建议(比如不使用访客)。

我怀疑问题是我为我的访问者使用泛型而不是非泛型可访问类型,但不明白为什么(因为泛型的重载应该起作用,特别是考虑到具体类型 TimedValue 的特定实例,其中 T 是 int 或其他例如,是将调用分派给访问者的实例)。

我有一个对性能敏感的问题,我必须根据传入的对象类型(in )处理几种可能的对象类型,这些对象在许多地方派生自通用层次结构(例如),但我不想在整个代码中使用开关或if-elses(设计选择),我也不想使用(出于性能问题)。ITimedValueTTimedValue<T>dynamic

我首先通过添加时间戳来标记该层次结构下的对象,然后进行大量处理,这些对象被传递到许多不同的地方,包括反应式流等。

我认为具有双重调度的访客模式应该可以处理这个问题。但是,泛型在捉弄我,无论我做什么,我都无法让它们调用我的特定重载。

精简代码以举例说明该问题

这是有问题的代码(非常精简到最小的可重现示例)。

基类型和泛型派生类型有一个接口。ITimedValueTimedValue<T>

然后,这些类型的访问者的接口,以及一个非常精简的访问者实现(现在甚至没有积累状态),只是为了表明问题完全在于调用适当的访问者和重载。ITimedValueVisitorTimedValue<T>DemoValueVisitorVisitVisitSpecific

我使用了不同的名称,以便更明显地将具体类型称为具体类型。VisitSpecificVisit

我还尝试将模板化和非模板化重载添加到该方法中,以处理特定的派生类型,如 等,并且两者都具有相同的行为 - 只是被调用。TimedValue<bool>VisitSpecific<T>(TimedValue<T> typedVariant)

到目前为止,唯一有效的方法是摆脱双重调度并通过开关应用强制转换,因此它会在强制转换之后立即调用特定,但这根本不需要(此时这不再真正使用双重调度)。VisitVisitSpecific

    public interface ITimedValue  // Base type (we'll have arrays and streams of these)
    {
        public void Accept(ITimedValueVisitor visitor);
    };

    /**
     * I really wanted to use the generic (instead of having a specific type, per each `T` I want to support).
     * I'd rather avoid non-generic types -- e.g. TimedValueBool instead of TimedValue<bool> -- though I suspect this is the source of all trouble.
     */
    public class TimedValue<T> : ITimedValue  // Concrete types passed on streams
    {
        // Some other special data common to all subtypes...

        public long Time { get; private set; }
        public T Value { get; private set; }

        public TimedValue(T value)
        {
            Value = value;
        }

        public void Accept(ITimedValueVisitor visitor)
        {
            visitor.VisitSpecific<T>(this);  // Also removed this <T>, it's the same.
        }

        public override string ToString()
        {
            return $"TimedValue<{typeof(T)}>({Time}, {Value})";
        }
    }


    public interface ITimedValueVisitor  // A visitor interface
    {
        void Visit(ITimedValue typedVariant);

        void VisitSpecific<T>(TimedValue<T> typedVariant);
        void VisitSpecific<T>(TimedValue<bool> typedVariant);
        void VisitSpecific<T>(TimedValue<string> typedVariant);

        // and many others... (these above are just to make the example simpler)

    }

    public class DemoTimedVisitor : ITimedValueVisitor  // Some dummy visitor to show the problem
    {
        private ITestOutputHelper logger;

        public DemoTimedVisitor(ITestOutputHelper logger)
        {
            this.logger = logger;
        }

        // Do I need this at all? It's so I don't get errors like "can't cast `ITimedValue` to `TimedValue<T>`".
        public void Visit(ITimedValue timedValue)
        {
            timedValue.Accept(this);
        }

        /* I'm forced to have this by the type system. However, the system only calls this
         * independently of the specific type, and if we have more specific overloads. 
         */
        public void VisitSpecific<T>(TimedValue<T> typedVariant)
        {
            logger.WriteLine("Called Visitor GENERIC overload!!!!" + typedVariant.ToString());
        }

        // I also tried without success having VisitSpecific(TimedValue<bool> typedVariant) instead. Never gets called :( 
        public void VisitSpecific<T>(TimedValue<bool> typedVariant)
        {
            logger.WriteLine("Called Visitor BOOL overload! " + typedVariant.ToString());
        }

        // Ditto.
        public void VisitSpecific<T>(TimedValue<string> typedVariant)
        {
            logger.WriteLine("Called Visitor STRING overload!" + typedVariant.ToString());
        }
    }

一些测试代码(使用 XUnit)举例说明了这个问题:

using Xunit.Abstractions;

public class TestDemo
{


    readonly ITestOutputHelper logger;

    public TestDemo(ITestOutputHelper logger)
    {
        this.logger = logger;
    }

    [Fact]
    public void DemoOfTargetAPIUsage()  // Just to show roughly the point.
    {
        DemoTimedVisitor visitor = new DemoTimedVisitor(logger);  // Ideally it would be collecting data in it (but in this question it does nothing for simplicity).


        // Ideally we'd be using:
        List<ITimedValue> timedValues = new List() { new TimedValue<...>(...), ... }

        // Followed by:
        foreach (var timedValue in timedValues)
        {
            visitor.Visit(timedValue);
        }


        visitor.GetState()... // What we'd want
    }

    [Fact]
    public void EvenBasicStuffFails()  // most basic example. 
    {
        DemoTimedVisitor visitor = new DemoTimedVisitor(logger);  // Ideally it would be collecting data in it (but in this question it does nothing for simplicity).

        ITimedValue vbool = new TimedValue<bool>(true);
        ITimedValue vstring = new TimedValue<string>("foo");
        ITimedValue vfloat = new TimedValue<float>(9999.999F);

        vbool.Accept(visitor);    // uses unspecific overload :(
        vstring.Accept(visitor);  // uses unspecific overload :(
        vfloat.Accept(visitor);   // uses unspecific overload as it should (no specific visitor overloads defined) :+1:

        ...
    }

}
        

我不太习惯 C# 的泛型,显然我一定在做一些非常愚蠢的事情,我只是不明白为什么 Accept 总是调用 .它是编译时唯一可用的类型吗?但是在内部,实现应该因类型而异——或者至少这是我想要的:(VisitSpecific<T>(TimedValue<T>)AcceptT

我是否需要删除泛型,例如 -> 或者我是否在此双重调度中遗漏了某些内容?(我宁愿不要)TimedValue<Custom>TimedValueCustom

谢谢!

C# 访客模式

评论


答:

1赞 Jeremy Lakeman 3/20/2023 #1
visitor.VisitSpecific<T>(this);

在这一行中,编译器只知道 是 ,因此在所有情况下都会调用的最佳匹配。C# 编译器将在生成的 IL 中对此方法句柄进行硬编码。尽管如果方法足够“热”,运行时可以对调用站点进行 JIT 专用化以对调用站点进行非虚拟化。thisTimedValue<T>void VisitSpecific<T>(TimedValue<T> typedVariant)

您需要在编译器已知参数类型的某处编写自己的代码,以便在编译时解析正确的方法。也许是开关表达式;case TimedValue<bool> boolValue:

或者,您可以跳过泛型并定义每个 .TimedValueBool : ITimedValue

或者,您可以使用在运行时选择方法,为此付出性能代价。dynamic

评论

0赞 Alberto 3/20/2023
这是因为与 C++ 不同,在 C# 中,泛型只有一个版本吗?在 C++ 中,每个子类型 T 都会获得自己的编译代码,这意味着此时知道它实际上正在调用例如,而不仅仅是 .是这样吗?什么是调度接口?void VisitSpecific<T>(TimedValue<T> typedVariant)void VisitSpecific<T>(TimedValue<int> typedVariant)void VisitSpecific<T>(TimedValue<T> typedVariant)
0赞 Alberto 3/20/2023
此外,这意味着在这种情况下,使用泛型派生类型不如使用非泛型派生类型效率高,因为在后者中,来自 ITimedValue 的每个派生类型都有自己的 Accept 实现,这将导致对该类型的确切调用,从而不需要切换,对吗?VisitSpecific(CustomTimedValue typedVariant)
0赞 Jeremy Lakeman 3/20/2023
更新了答案,希望这能更好地解释与 C++ 的差异。
0赞 Alberto 3/20/2023
嗯,你是说如果方法足够“热”,它就可以开始调用我更具体的重载?那就太好了。是的,我现在将支付微小的交换机开销,因为转向非通用意味着需要管理更多的代码。谢谢杰里米!
1赞 Jeremy Lakeman 3/20/2023
不可以,任何运行时优化都无法更改方法的行为。