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

时间:2018-04-18 17:22:37

标签: c# dll

问题非常简单,

如果我有以下课程:

public class ExportReservationsToFtpRequestOld 
{
    public int A { get; set; }
    public long B { get; set; }
}

并将其更改为:

public class ExportReservationsToFtpRequestOld 
{
    public virtual int A { get; set; }
    public virtual long B { get; set; }
}

可以打破一个合法的客户端dll吗?

2 个答案:

答案 0 :(得分:15)

戴的答案很好,但有点难以阅读,它掩盖了lede。我们不要埋葬这个地方。

  

可以使非虚拟实例方法虚拟破坏遗留客户端DLL吗?

即可。破损是微妙的,不太可能,但它可能当您成为依赖关系虚拟的成员时,应重新编译旧客户端。

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

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

public class B {
  public void M() { }
}

然后我们在客户端程序集中使用它:

class C {
  static void Q() {
    B b = new B();
    b.M();
  }
}

生成什么IL?

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

完全合理的代码。 C#为非虚拟调用生成callvirt,因为这意味着我们不必发出检查以确保接收方为非空。这使代码变小。

如果我们将B.M更新为虚拟,则呼叫站点无需更改;它已经在进行虚拟通话了。所以一切都很好,对吗?

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

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

当然,重构没有任何改变,对吧?

错误的。生成的代码现在是:

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

C#原因"我正在调用非虚方法,我知道接收器是new表达式,永远不会产生空,所以我和#39;将保存纳秒并跳过空检查"。

为什么不在第一种情况下这样做呢?因为C#不在第一个版本中进行控制流分析并且在每个控制流上推导出,所以接收器已知为非空。它只是做一个便宜的检查,看看接收器是否是一些已知不可能为空的表达式之一。

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

请参阅我对另一个答案的评论,以了解促成此安全设计决策的方案。

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

所以我们已经有了问题;我们将另一个我们不拥有的程序集中的可验证代码转换为无法验证的代码。

但等等,情况变得更糟。

假设我们的情景略有不同。我怀疑这是实际激励你改变的场景。

// Original code
public class B {
  public void M() {}
}
public class D : B { }

和客户

class C {
  static void Q() {
    new D().M();
  }
}

现在生成什么代码?答案可能会让你大吃一惊。 与以前相同。 C#不生成

  call instance void D::M()

而是生成

  call instance void B::M()

因为毕竟被称为的方法。

现在我们将依赖项更改为

// New code
class B {
  public virtual void M() {}
}
class D : B { 
  public override void M() {}
}

新代码的作者合理地认为所有对new D().M()的调用都应该发送到D.M,但正如我们所见,未重新编译的客户端仍然会进行无法验证的非虚拟调度到B.M!所以这是一个不断变化的意义,即客户端仍然获得他们曾经获得的行为(假设他们忽略了验证失败),但该行为不再正确,并且会在重新编译时发生变化

这里的基本问题是,非虚拟呼叫可以显示在您不期望的位置,然后如果您更改要求使呼叫成为虚拟,则在重新编译之前不会发生这种情况。

让我们看看我们刚刚做的另一个版本的场景。在我们以前的依赖中

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

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

interface I { void M(); }
class C : D, I {}
...
I i = new C();
i.M();

一切都很好; C继承自D,它为其提供了一个实施B.M的公共成员I.M,我们已全部设置。

除了有问题。 CLR要求实现B.M的方法I.M是虚拟的,而B.M则不是。{1}}。而不是拒绝这个程序,C#假装你写了:

class C : D, I 
{
  void I.M()
  {
    base.M();
  }
}

base.M()编译为非虚拟调用B.M()的位置。毕竟,我们知道this非空,B.M()不是虚拟的,因此我们可以call代替callvirt

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

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/。这不是您的方案,但它进一步说明了对虚拟方法的非虚拟调用的危险。

答案 1 :(得分:6)

  • 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)消费者使用的编译器:

  1. 消费者二进制程序集具有.call ExportReservationsToFtpRequestOld::get_A

    如果您没有将带有重写成员的ExportReservationsToFtpRequestOld的任何子类传递给使用者,那么将调用正确的属性。如果您确实传递了具有被覆盖的virtual成员的子类,则覆盖的版本为will not be invoked

      

    使用call(而不是callvirt)调用虚方法是有效的;这表明该方法是使用方法指定的类而不是从被调用的对象动态指定的。

    (我很惊讶C#没有让消费者类型明确地这样做,只有继承树中的类可以使用base关键字。)

  2. 消费者二进制程序集具有.callvirt ExportReservationsToFtpRequestOld::get_A

    如果消费者正在使用子类,则将调用子类的get_A覆盖,而不一定是ExportReservationsToFtpRequestOld的版本。

  3. 消费者已经将ExportReservationsToFtpRequestOld作为子类并添加了阴影new)版本get_Aget_B,然后调用这些属性:

    class Derived : ExportReservationsToFtpRequestOld {
    
        public new int A { get; set; }
        public new long B { get; set; }
    }
    

    甚至:

    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的成员具有不同的内部标识符,因此ExportReservationsToFtpRequestOld get_Aget_B将不会被调用。即使消费者的编译器使用.callvirt而不是.call,虚拟方法查找也将从其子类开始,而不是ExportReservationsToFtpRequestOld。事情变得复杂Derived2然而发生的事情取决于它的消费方式,请参见此处:what is "public new virtual void Method()" mean?

  4. TL; DR:

    如果您确定没有人从带有影子+虚拟成员的ExportReservationsToFtpRequestOld派生,那么请继续将其更改为virtual - 您不会破坏任何内容。