关于dll:向C#方法添加虚拟化是否会破坏旧版客户端?

Does adding virtual to a C# method may break legacy clients?

问题很简单,

如果我有以下课程:

1
2
3
4
5
public class ExportReservationsToFtpRequestOld
{
    public int A { get; set; }
    public long B { get; set; }
}

并将其更改为:

1
2
3
4
5
public class ExportReservationsToFtpRequestOld
{
    public virtual int A { get; set; }
    public virtual long B { get; set; }
}

它能破坏一个legay客户端DLL吗?


戴的回答很好,但有点难读懂,它埋葬了乐得。我们不要埋了莱德。好的。

can making a non-virtual instance method virtual break a legacy client dll?

Ok.

对。破碎是微妙的,不太可能,但它是可能的。当您使依赖项的成员成为虚拟的时,应该重新编译旧客户端。好的。

更一般地说:如果要更改有关基类的公共或受保护的表面积的任何内容,请重新编译生成派生类的所有程序集。好的。

让我们看看这个特定的场景如何打破传统客户机。假设我们有一个依赖程序集:好的。

1
2
3
public class B {
  public void M() { }
}

然后我们在客户机程序集中使用它:好的。

1
2
3
4
5
6
class C {
  static void Q() {
    B b = new B();
    b.M();
  }
}

生成了什么IL?好的。

1
2
3
    newobj instance void B::.ctor()
    callvirt instance void B::M()
    ret

完全合理的代码。C生成一个对非虚拟调用的callvirt,因为这意味着我们不需要发出一个检查来确保接收器不为空。这使代码保持较小。好的。

如果我们将B.M更新为虚拟,则调用站点不需要更改;它已经在进行虚拟调用。所以一切都很好,对吧?好的。

现在,假设在新版本的依赖关系发布之前,一些超级天才出现了,并说:"哦,我们可以将此代码重构为明显更好的代码:好的。

1
2
3
  static void Q() {
    new B().M();
  }

重构肯定不会改变什么,对吧?好的。

错了。现在生成的代码是:好的。

1
2
3
  newobj instance void B::.ctor()
  call instance void B::M()
  ret

C reasons"我正在调用一个非虚拟方法,我知道接收器是一个从不产生空值的new表达式,因此我将保存纳秒并跳过空值检查"。好的。

为什么不在第一个案例中这样做?因为C在第一个版本中不进行控制流分析,并且推断在每个控制流上,接收器都是非空的。它只是做了一个廉价的检查,看看接收器是否是少数已知不可能为空的表达式之一。好的。

如果现在将依赖项B.M更改为虚拟方法,并且不使用调用站点重新编译程序集,则调用站点中的代码现在无法验证,因为它违反了clr安全规则。只有当调用直接位于派生类型的成员中时,对虚拟方法的非虚拟调用才是合法的。好的。

请参阅我对另一个激发此安全设计决策的方案的答案的评论。好的。

旁白:规则甚至适用于嵌套类型!也就是说,如果我们有class D : B { class N { } },那么N内的代码不允许对B的虚拟成员进行非虚拟调用,尽管D内的代码是!好的。

因此,我们已经有了一个问题;我们正在将不属于自己的另一个程序集中的可验证代码转换为不可验证的代码。好的。

但等等,情况更糟。好的。

假设我们有一个稍微不同的场景。我怀疑这真的是激发你改变的场景。好的。

1
2
3
4
5
// Original code
public class B {
  public void M() {}
}
public class D : B { }

和客户好的。

1
2
3
4
5
class C {
  static void Q() {
    new D().M();
  }
}

现在生成了什么代码?答案可能会让你吃惊。和以前一样。C不生成好的。

1
  call instance void D::M()

而是它产生好的。

1
  call instance void B::M()

因为毕竟,这就是要调用的方法。好的。

现在我们将依赖关系改为好的。

1
2
3
4
5
6
7
// New code
class B {
  public virtual void M() {}
}
class D : B {
  public override void M() {}
}

新代码的作者合理地认为,所有对new D().M()的调用都应该发送到D.M,但是正如我们看到的那样,未重新编译的客户机仍将对B.M执行无法验证的非虚拟发送!因此,这是一个没有破坏性的变化,从这个意义上说,客户机仍然得到了他们以前得到的行为(假设他们忽略了验证失败),但是这个行为不再正确,并且在重新编译后会发生变化。好的。

这里的基本问题是,非虚拟调用可以出现在您不希望出现的地方,然后,如果您更改了使调用成为虚拟调用的要求,则只有重新编译后才会出现这种情况。好的。

让我们来看看我们刚刚做的另一个版本的场景。我们像以前一样依赖好的。

1
2
public class B { public void M() {} }
public class D : B {}

在我们的客户中,我们现在有:好的。

1
2
3
4
5
interface I { void M(); }
class C : D, I {}
...
I i = new C();
i.M();

一切都很好,C继承了D的财产,给了它一个公共成员B.M,实现了I.M,我们都准备好了。好的。

只是有个问题。clr要求实现I.M的方法B.M是虚拟的,而B.M不是虚拟的。与其拒绝这个计划,C假装你写了:好的。

1
2
3
4
5
6
7
class C : D, I
{
  void I.M()
  {
    base.M();
  }
}

其中base.M()编译为对B.M()的非虚拟调用。毕竟,我们知道this是非空的,B.M()不是虚拟的,所以我们可以用call代替callvirt。好的。

但是现在,当我们在不重新编译客户端的情况下重新编译依赖项时会发生什么:好的。

1
2
3
4
5
6
class B {
  public virtual void M() {}
}
class D : B {
  public override void M() {}
}

现在,调用i.M()将对B.M执行可验证的非虚拟调用,但D.M的作者希望在这种情况下调用D.M,在重新编译客户机时,它将被调用。好的。

最后,还有更多可能涉及到显式base.调用的场景,在这些场景中,更改类层次结构的"中间"依赖项可能会产生奇怪的意外结果。有关该场景的详细信息,请参阅https://blogs.msdn.microsoft.com/ericlippert/2010/03/29/putting-a-base-in-the-middle/。这不是您的场景,但它进一步说明了对虚拟方法进行非虚拟调用的危险。好的。好啊。


  • C编译为CIL(以前称为MSIL)。
  • 属性访问和分配被编译为方法调用:
    • value = foo.Bar变为value = foo.get_Bar()
    • foo.Bar = value变为foo.set_Bar( value )
  • 方法调用编译到callcallvirt操作码。
  • callcallvirt操作码的第一个操作数是按名称命名的"符号"/标识符,因此将类的成员从非虚拟更改为virtual不会中断JIT编译。
  • callcallvirt都可以用来调用行为不同的virtual和非虚方法,并且编译器出于各种原因选择了操作码,重要的是编译器可以使用callvirt调用非虚方法(http://www.levibotelho.com/development/call-and-callvirt-in-cil/)
    • .call NonVirtualMethod
      • 直接调用NonVirtualMethod
    • .callvirt NonVirtualMethod
      • 直接调用NonVirtualMethod(使用空检查)。
    • .call VirtualMethod
      • 直接调用VirtualMethod,即使当前对象覆盖了它。
    • .callvirt VirtualMethod
      • 调用当前对象对VirtualMethod的重写。

因此,虽然编译后的应用程序在与virtual成员交换旧的二进制程序集后仍将启动和jit,但仍有一些场景需要考虑程序集使用者的行为,具体取决于使用者编译器使用的操作码(callcallvirt

  • 用户二进制程序集具有.call ExportReservationsToFtpRequestOld::get_A

    如果不向使用者传递带有被重写成员的ExportReservationsToFtpRequestOld的任何子类,则将调用正确的属性。如果确实传递了一个子类,其中包含被重写的virtual成员,则不会调用重写版本:


    It is valid to call a virtual method using call (rather than callvirt); this indicates that the method is to be resolved using the class specified by method rather than as specified dynamically from the object being invoked.

    (令我惊讶的是,C不允许使用者类型显式地这样做,只有继承树中的类才能使用base关键字)。

  • 用户二进制程序集具有.callvirt ExportReservationsToFtpRequestOld::get_A

    如果使用者正在使用子类,那么将调用子类对get_A的覆盖,而不一定调用ExportReservationsToFtpRequestOld的版本。

  • 消费者已经将ExportReservationsToFtpRequestOld子类化,并添加了get_Aget_B的影子(new版本),然后调用这些属性:

    1
    2
    3
    4
    5
    class Derived : ExportReservationsToFtpRequestOld {

        public new int A { get; set; }
        public new long B { get; set; }
    }

    甚至:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class Derived : ExportReservationsToFtpRequestOld {

        public new virtual int A { get; set; }
        public new virtual long B { get; set; }
    }

    // with:

    class Derived2 : Derived {

        public override int A { get; set; }
        public override long B { get; set; }
    }

    由于Derived的成员具有不同的内部标识符,因此不会调用ExportReservationsToFtpRequestOldget_Aget_B。即使使用者的编译器使用.callvirt而不是.call,虚拟方法查找也将从其子类开始,而不是从ExportReservationsToFtpRequestOld开始。但是,Derived2的情况会变得复杂,并且发生的情况取决于它是如何消费的,请看这里:"public new virtual void method()"是什么意思?

  • DR:

    如果你确定没有人从ExportReservationsToFtpRequestOld衍生出影子+虚拟成员,那么继续把它改为virtual,你不会破坏任何东西。