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

Ric*_*ves 9 c# dll

问题非常简单,

如果我有以下课程:

public class ExportReservationsToFtpRequestOld 
{
    public int A { get; set; }
    public long B { get; set; }
}
Run Code Online (Sandbox Code Playgroud)

并将其更改为:

public class ExportReservationsToFtpRequestOld 
{
    public virtual int A { get; set; }
    public virtual long B { get; set; }
}
Run Code Online (Sandbox Code Playgroud)

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

Eri*_*ert 15

戴的回答很好,但有点难以阅读,它掩盖了lede.我们不要埋葬lede.

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

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

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

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

public class B {
  public void M() { }
}
Run Code Online (Sandbox Code Playgroud)

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

class C {
  static void Q() {
    B b = new B();
    b.M();
  }
}
Run Code Online (Sandbox Code Playgroud)

IL产生了什么?

    newobj instance void B::.ctor()
    callvirt instance void B::M()
    ret
Run Code Online (Sandbox Code Playgroud)

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

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

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

  static void Q() {
    new B().M();
  }
Run Code Online (Sandbox Code Playgroud)

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

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

  newobj instance void B::.ctor()
  call instance void B::M()
  ret
Run Code Online (Sandbox Code Playgroud)

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

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

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

有关激发此安全设计决策的方案,请参阅我对另一个答案的评论.

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

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

但等等,情况变得更糟.

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

// Original code
public class B {
  public void M() {}
}
public class D : B { }
Run Code Online (Sandbox Code Playgroud)

还有一个客户

class C {
  static void Q() {
    new D().M();
  }
}
Run Code Online (Sandbox Code Playgroud)

现在生成什么代码?答案可能会让你大吃一惊.它和以前一样.C#不生成

  call instance void D::M()
Run Code Online (Sandbox Code Playgroud)

而是产生

  call instance void B::M()
Run Code Online (Sandbox Code Playgroud)

因为毕竟,这就是被调用的方法.

现在我们将依赖关系更改为

// New code
class B {
  public virtual void M() {}
}
class D : B { 
  public override void M() {}
}
Run Code Online (Sandbox Code Playgroud)

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

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

让我们看一下我们刚刚做的场景的另一个版本.在我们依赖的依赖中

public class B { public void M() {} }
public class D : B {}
Run Code Online (Sandbox Code Playgroud)

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

interface I { void M(); }
class C : D, I {}
...
I i = new C();
i.M();
Run Code Online (Sandbox Code Playgroud)

一切都很好; C继承自D,它给了一个B.M实现的公共成员,I.M我们都设置好了.

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

class C : D, I 
{
  void I.M()
  {
    base.M();
  }
}
Run Code Online (Sandbox Code Playgroud)

base.M()被编译成非虚拟呼叫B.M().毕竟,我们知道这this是非空的并且B.M()不是虚拟的,所以我们可以做一个call而不是一个callvirt.

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

class B {
  public virtual void M() {}
}
class D : B { 
  public override void M() {}
}
Run Code Online (Sandbox Code Playgroud)

现在调用i.M()将执行一个可验证的非虚拟调用,B.M但是D.M预期的作者D.M将在此场景中被调用,并且在重新编译客户端时,它将是.

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


Dai*_*Dai 6

  • C#编译为CIL(以前称为MSIL).
  • 属性访问和赋值被编译为方法调用:
    • value = foo.Bar成为value = foo.get_Bar().
    • foo.Bar = value成为foo.set_Bar( value ).
  • 方法调用被编译为callcallvirt操作码.
  • callcallvirt操作码的第一个操作数是一个'符号’的名称/标识符,那么改变你的类的成员非虚,以virtual不会打破JIT编译.
  • call并且callvirt可以用于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的成员,则overridding版本不会被调用:

    使用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; }
    }
    
    Run Code Online (Sandbox Code Playgroud)

    甚至:

    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; }
    }
    
    Run Code Online (Sandbox Code Playgroud)

    Derived的成员具有不同的内部标识符然后ExportReservationsToFtpRequestOldget_Aget_B将不被调用.即使使用消费者的编译器.callvirt代替.call,虚拟方法查找也将以其子类开始,而不是ExportReservationsToFtpRequestOld.事情变得复杂Derived2然而发生的事情取决于它的消耗方式,请参见此处:什么是"public new virtual void Method()"是什么意思?

TL; DR:

如果你确定没有人来自ExportReservationsToFtpRequestOld阴影+虚拟成员,那么继续将其改为virtual- 你不会破坏任何东西.