问题很简单,
如果我有以下课程:
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吗?
- 你可以试试看会发生什么?
- 有很多方法可以打破,但唯一确定的方法就是尝试一下。
- 我很肯定这里的答案是否定的。添加覆盖方法的选项对依赖性没有影响。当然,删除覆盖(删除虚拟)选项是一个突破性的改变。出于可测试性的原因,这是我经常做的更改。我从来没有休息过。我在这里也看不到这个列表:stackoverflow.com/questions/1456785/…
- 您是在寻找源代码兼容性、二进制兼容性还是两者都要?如果添加使属性成为虚拟属性会在生成时引发警告,但不是错误,您介意吗?(恐怕兼容性的问题很少很直接。)诚然,我需要努力找到它如何破坏事物的例子,但我强烈怀疑,如果您希望源代码和二进制代码都兼容于反射以外的所有东西,那么就有这样的例子。
- 你没有宣布班级被封条。所以是的,如果客户机代码从您的类派生出自己的类,那么它就是Borken。
- @汉斯帕桑:你能举一个例子说明如何使属性虚中断派生类吗?这正是我现在想说的。
- 另外请注意,这里的内容是属性,而您的问题是关于方法的。由于一些微妙的原因,答案可能会有所不同。你只关心属性,还是属性和方法?
- 密封类中不能有虚拟成员。docs.microsoft.com/en-us/dotnet/csharp/misc/cs0549文件
- @罗尼:它能以什么方式打破?别害羞,列举一下。
- @里卡道尔维斯:你能描述一下你为什么要做出这种改变吗?即使从实例到虚拟机的移动不会立即导致破坏性的更改,也可能存在其他问题或更好的技术。
- @Ericlippert我想的"方式"是,操作意味着使它们虚拟化,在一个子类中派生它们,然后将该子类传递回遗留代码,我相信(您是专家,我没有尝试过),它将调用基类实现,而不是派生的实现,或者我是基于它吗?
- @罗尼:接近。看看我对真实故事的回答。
戴的回答很好,但有点难读懂,它埋葬了乐得。我们不要埋了莱德。好的。
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/。这不是您的场景,但它进一步说明了对虚拟方法进行非虚拟调用的危险。好的。好啊。
- 谢谢你的解释!在我的例子中,我真的不认为它会破坏任何东西,因为我想变成废弃的旧字段,并确保我们的服务器仍然基于重写属性处理旧数据。所以,遗留代码仍然可以使用它们正在使用的任何东西,但是我的服务器必须确保,在读取旧属性时,它们会将其转换为其他属性。
- "[…]仍将执行无法验证的非虚拟调度到B.M!"有个很好的笑话,比如把你的电话变成粪便…一如既往的好解释!
- @Ericlippert:"如果要更改有关基类的任何内容,请重新编译生成派生类的所有程序集。"即使更改只是有关基类的私有成员,这也是必需的吗?
- @卢卡克雷蒙尼:你是对的,我在那里太宽泛了。我已经更新了文字以便更清楚。谢谢!
- C编译为CIL(以前称为MSIL)。
- 属性访问和分配被编译为方法调用:
- value = foo.Bar变为value = foo.get_Bar()。
- foo.Bar = value变为foo.set_Bar( value )。
- 方法调用编译到call或callvirt操作码。
- call和callvirt操作码的第一个操作数是按名称命名的"符号"/标识符,因此将类的成员从非虚拟更改为virtual不会中断JIT编译。
- call和callvirt都可以用来调用行为不同的virtual和非虚方法,并且编译器出于各种原因选择了操作码,重要的是编译器可以使用callvirt调用非虚方法(http://www.levibotelho.com/development/call-and-callvirt-in-cil/)
- .call NonVirtualMethod
- .callvirt NonVirtualMethod
- 直接调用NonVirtualMethod(使用空检查)。
- .call VirtualMethod
- 直接调用VirtualMethod,即使当前对象覆盖了它。
- .callvirt VirtualMethod
因此,虽然编译后的应用程序在与virtual成员交换旧的二进制程序集后仍将启动和jit,但仍有一些场景需要考虑程序集使用者的行为,具体取决于使用者编译器使用的操作码(call或callvirt:
用户二进制程序集具有.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_A和get_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的成员具有不同的内部标识符,因此不会调用ExportReservationsToFtpRequestOld的get_A和get_B。即使使用者的编译器使用.callvirt而不是.call,虚拟方法查找也将从其子类开始,而不是从ExportReservationsToFtpRequestOld开始。但是,Derived2的情况会变得复杂,并且发生的情况取决于它是如何消费的,请看这里:"public new virtual void method()"是什么意思?
DR:
如果你确定没有人从ExportReservationsToFtpRequestOld衍生出影子+虚拟成员,那么继续把它改为virtual,你不会破坏任何东西。
- 关于您的插入语"我很惊讶C不允许消费者类型明确地这样做"——存在安全性和正确性问题。考虑:abstract class B { private B() {} public virtual void M() { Danger(); } private class D : B { public override void M() { SecurityCheck(); base.M(); } public static B GetD() { return new D(); }}我们可以合理地假设,控制D的低信任代码不能通过在D的实例上直接调用B.M来绕过D.M中的安全检查。
- 谢谢你的解释!在我的例子中,我真的不认为它会破坏任何东西,因为我想变成废弃的旧字段,并确保我们的服务器仍然基于重写属性处理旧数据。所以,遗留代码仍然可以使用它们正在使用的任何东西,但是我的服务器必须确保,在读取旧属性时,它们会将其转换为其他属性。