为什么C#编译器可以“看到”未被引用的DLL中的静态属性,但是无法看到实例方法?

13

我的问题的前提是:

  • 有一个名为Foo的库,它依赖于一个名为Bar的库
  • Foo中的一个类继承了Bar中的一个类
  • Foo定义了一些属性/方法,只是简单地通过Bar进行传递
  • 一个名为FooBar的应用程序仅依赖于Foo

考虑以下示例:

class Program
{
    static void Main(string[] args)
    {
        Foo foo = Foo.Instance;

        int id = foo.Id; // Compiler is happy
        foo.DoWorkOnBar(); // Compiler is not happy
    }
}
Foo 的定义如下。
public class Foo : Bar
{
    public new static Foo Instance { get => (Foo)Bar.Instance; }

    public new int Id { get => Bar.Id; }

    public void DoWorkOnBar()
    {
        Instance.DoWork();
    }
}

Bar的定义如下

public class Bar
{
    public static Bar Instance { get => new Bar(); }

    public static int Id { get => 5; }

    public void DoWork() { }
}

这个完全让我无法理解的部分:

没有引用Bar

  • FooBar 可以检索到由Bar提供的ID(至少它可以编译)
  • FooBar 不能请求Foo执行最终由Bar完成的工作

foo.DoWorkOnBar();相关的编译器错误是:

类型“Bar”在未被引用的程序集中定义。您必须添加对程序集“Bar、版本1.0.0.0、Culture=Neutral、PublicKeyToken=null”的引用。

为什么编译器似乎存在差异?

我原本以为,如果没有FooBar添加对Bar的引用,这两个操作都不会编译。


因为该属性直接在foo上定义,所以编译器很高兴。但是当您想要使用Instance.DoWork()并且DoWork在bar上定义时,编译器需要知道它可以找到该DoWork方法的位置。因此,它需要对bar的引用。 - Steve
1
如果您将实现减少到 public class Bar { }public class Foo : Bar { public static Foo Instance => null; public int Id => 42; public DoWorkOnBar() { } },问题是否仍然存在?猜测:这可能与方法可以重载而属性不能有关。 - user4003407
1
可以用来重现问题的最小“Foo”是public class Foo:Bar {public static int P => 0; public static int M()=> 0;}。调用Foo.P没有问题;调用Foo.M()会使编译器要求使用Bar。为什么?这可能需要一个编译器编写者进一步解释。(但是@PetSerAl的猜测“责怪过载分辨率”是安全的,因为这是语言中迄今为止最复杂的部分,并且有许多有趣的黑暗角落。) - Jeroen Mostert
1
@Matt,我建议你[编辑]你的问题,并将FooBar的实现减少到最小必要版本以重现错误,这样其他人就不会因为额外的干扰而走错路。 - user4003407
1
感谢您提出这个优秀的问题。我花了很长时间才找到它,因为即使是搜索问题也很难 :D - v01pe
显示剩余2条评论
1个回答

5
首先,请注意Foo.IdFoo.DoWorkOnBar实现是无关紧要的;即使这些实现没有访问Bar,编译器仍会将foo.Idfoo.DoWorkOnBar()视为不同。
// In class Foo:
public new int Id => 0;
public void DoWorkOnBar() { }
< p > foo.Id 可以成功编译,但 foo.DoWorkOnBar() 不能的原因是编译器在查找属性和方法时使用了不同的逻辑¹。

对于 foo.Id,编译器首先在 Foo 中查找名为 Id 的成员。当编译器看到 Foo 有一个名为 Id 的属性时,编译器停止搜索,不再查看 Bar。编译器可以执行此优化,因为派生类中的属性会隐藏所有与基类中相同名称的成员,因此无论在 Bar 中可能有哪些成员被命名为 Idfoo.Id 总是指向 Foo.Id

对于foo.DoWorkOnBar(),编译器首先在Foo中查找名为DoWorkOnBar的成员。当编译器看到Foo有一个名为DoWorkOnBar的方法时,编译器会继续搜索所有基类中名为DoWorkOnBar的方法。编译器这样做是因为(与属性不同)方法可以重载,并且编译器实现了重载解析算法,基本上是按照C#规范中描述的方式进行的:
  1. 从在Foo及其基类中声明的所有DoWorkOnBar的重载集合开始。
  2. 将集合缩小为“候选”方法(基本上是参数与提供的参数兼容的方法)。
  3. 删除任何被更派生类中的候选方法所遮蔽的候选方法。
  4. 选择剩余候选方法中的“最佳”方法。
第1步触发需要向程序集Bar添加引用的要求。
C#编译器能否以不同的方式实现该算法?根据C#规范:
上述解析规则的直观效果如下:为了定位方法调用所调用的特定方法,请从方法调用指示的类型开始,并沿着继承链向上移动,直到找到至少一个适用的、可访问的、非覆盖方法声明。然后对该类型中声明的适用的、可访问的、非覆盖方法集进行类型推断和重载决策,并调用所选的方法。 因此,在我看来,答案是“是”:C#编译器理论上可以看到Foo声明了一个适用的DoWorkOnBar方法,而不必查看Bar。然而,对于Roslyn编译器来说,这将涉及到重写编译器的成员查找和重载解析代码——鉴于开发人员可以轻松解决这个错误,这可能不值得努力。
当您调用一个方法时,编译器需要您引用基类程序集,因为这是编译器的实现方式。

¹ 请参阅Microsoft.CodeAnalysis.CSharp.Binder类的LookupMembersInClass方法。

² 请参阅Microsoft.CodeAnalysis.CSharp.OverloadResolution类的PerformMemberOverloadResolution方法。


我同意这是由于需要重载决议而引起的,但如果派生类中的任何一个重载适用,是否真的有必要查看基类定义呢?据我所知,即使基类中定义的重载匹配更好,派生类中定义的重载也严格优先。例如:https://ideone.com/SBpJuD 在这个例子中,它调用了派生类中的long重载,即使基类中的int重载更匹配。 - user4003407
@PetSerAl:你可能是对的。看一下源代码,编译器似乎按照规范中所述的算法实现了重载决策:首先构造候选方法集(需要查看基类),然后丢弃在较不派生的类中声明的方法。 - Michael Liu
哇!我撞了墙,因为它对我来说毫无意义。现在我明白了,但很生气,因为它不起作用:D - v01pe

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