C#:向后兼容和重载

大家好!

我们提醒大家,我们有一本很棒的Mark Price书,“ C#7和.NET Core。专业人士的跨平台开发 ”。 请注意:这是第三版,第一版是用6.0版编写的,没有以俄语显示,并且第三版于2017年11月以原始版本发布,涵盖了7.1版。


在发布了这样一份简编之后,该简编经过了单独的科学编辑以检查所提供材料的向后兼容性和其他正确性,我们决定翻译John Skeet的一篇有趣的文章,内容涉及C#中可能出现的哪些已知和鲜为人知的困难。 阅读愉快。

早在2017年7月,我就开始撰写有关版本控制的文章。 很快就放弃了该主题,因为该主题过于广泛而无法在一篇文章中进行介绍。 在这样一个主题上,突出显示整个站点/ Wiki /存储库更为有意义。 我希望有一天能回到这个话题,因为我认为它非常重要,而且我认为它受到的关注比应有的少。

因此,在.NET生态系统中,通常欢迎语义版本控制 -听起来很不错,但是要求每个人都平等地理解被视为“根本性变化”的东西。 这是我很长一段时间以来一直在想的。 最近让我印象深刻的方面之一是,在重载方法时避免根本性的改变是多么困难。 与此相关的(主要是)我们将讨论您正在阅读的帖子; 毕竟,这个话题很有趣。
开始-简要定义...

源代码和二进制兼容性

如果我可以使用新版本的库重新编译客户端代码,并且一切正常,那么这就是源代码级别的兼容性。 如果我可以使用新版本的库重新部署客户端二进制文件而无需重新编译,则它与二进制文件兼容。 这些都不是另一个的超集:

  • 某些更改可能同时与源代码和二进制代码不兼容-例如,您不能删除完全依赖的整个公共类型。
  • 某些更改与源代码兼容,但与二进制代码不兼容-例如,如果您将公共只读静态字段转换为属性。
  • 某些更改与二进制文件兼容,但与源代码不兼容-例如,添加了一个重载,在编译期间可能导致歧义。
  • 某些更改与源代码和二进制代码兼容-例如,方法主体的新实现。

那么,我们在说什么呢?

假设我们有一个版本为1.0的公共库,并且我们想对其添加一些重载以最终确定为版本1.1。 我们坚持语义版本控制,因此我们需要向后兼容。 这意味着我们可以做,不能做,这是什么意思,在这里所有问题都可以回答“是”或“否”吗?

在不同的示例中,我将显示版本1.0和1.1中的代码,然后显示“客户端”代码(即,使用该库的代码),该代码可能会由于更改而中断。 方法主体和类声明都不存在,因为它们本质上并不重要-我们主要关注签名。 但是,如果您感兴趣,那么可以轻松地复制所有这些类和方法。 假设这里描述的所有方法都在Library类中。

可以想到的最简单的更改,是将一组方法转换为委托
我想到的最简单的示例是添加一种参数化方法,其中已经存在一个非参数化方法:

  //   1.0 public void Foo() //   1.1 public void Foo() public void Foo(int x) 


即使在这里,兼容性也不完整。 考虑以下客户端代码:

  //  static void Method() { var library = new Library(); HandleAction(library.Foo); } static void HandleAction(Action action) {} static void HandleAction(Action<int> action) {} 

在该库的第一个版本中,一切都很好。 调用HandleAction方法会将方法组转换为library.Foo HandleAction委托,因此将创建一个Action 。 在1.1版中,情况变得模棱两可:可以将一组方法转换为Action或Action。 也就是说,严格来说,这样的更改与源代码不兼容。

在这个阶段,很诱人的是放弃并向自己保证,绝对不要再添加任何重载。 或者我们可以说,这样的情况不太可能不怕这样的失败。 现在,我们将一组方法的转换称为超出范围。

不相关的参考类型

考虑另一种上下文,其中必须使用具有相同数量参数的重载。 可以假定对库的这种更改将是非破坏性的:

 //  1.0 public void Foo(string x) //  1.1 public void Foo(string x) public void Foo(FileStream x) 

乍一看,一切都是合乎逻辑的。 我们保留原始方法,因此不会破坏二进制兼容性。 打破它的最简单方法是编写一个在v1.0中有效但在v1.1中无效或在两个版本中均有效的调用,但调用方式不同。
这样的调用会在v1.0和v1.1之间造成什么不兼容? 我们必须有一个与stringFileStream都兼容的参数。 但是这些是彼此不相关的参考类型...

如果我们对stringFileStream进行用户定义的隐式转换,则可能会导致第一个失败:

 //  class OddlyConvertible { public static implicit operator string(OddlyConvertible c) => null; public static implicit operator FileStream(OddlyConvertible c) => null; } static void Method() { var library = new Library(); var convertible = new OddlyConvertible(); library.Foo(convertible); } 

我希望问题显而易见:以前可以明确使用string的代码现在变得模棱两可,因为OddlyConvertible类型可以隐式转换为stringFileStream (这两种重载均适用,两者都不比另一个好)。

也许在这种情况下,禁止用户定义的转换是合理的……但是此代码可以简化并且变得更容易:

 //  static void Method() { var library = new Library(); library.Foo(null); } 

我们可以将null文字隐式转换为任何引用类型或任何可为null的有效类型...因此,再次,版本1.1中的情况是模棱两可的。 让我们再试一次...

引用类型和不可为空的有效类型的参数

假设我们不在乎用户定义的转换,但是我们不喜欢有问题的空文字。 在这种情况下,如何使用不可为空的有效类型添加重载?

  //  1.0 public void Foo(string x) //  1.1 public void Foo(string x) public void Foo(int x) 

乍一看,这很好library.Foo(null)在v1.1中可以正常工作。 那他安全吗? 不,只是不在C#7.1中...

  //  static void Method() { var library = new Library(); library.Foo(default); } 

默认文字完全为null,但适用于任何类型。 这非常方便-当涉及到过载和兼容性时,这确实令人头疼:(

可选参数

可选参数是另一个问题。 假设我们有一个可选参数,并且我们想添加第二个参数。 我们有以下三个选项,分别为1.1a,1.1b和1.1c。

  //  1.0 public void Foo(string x = "") //  1.1a //   ,         public void Foo(string x = "") public void Foo(string x = "", string y = "") //  1.1b //          public void Foo(string x = "", string y = "") //  1.1c //   ,    ,   //  ,     . public void Foo(string x) public void Foo(string x = "", string y = "") 


但是如果客户打了两个电话怎么办:

 //  static void Method() { var library = new Library(); library.Foo(); library.Foo("xyz"); } 

库1.1a在二进制级别上保持兼容性,但在源代码级别上违反:现在, library.Foo()不明确的。 根据C#中的重载规则,首选不需要编译器“填充”所有可用可选参数的方法,但是,它不调节可以填充多少个可选参数。

库1.1b保持源级别的兼容性,但是违反了二进制兼容性。 现有的编译代码被设计为使用单个参数来调用方法-并且这种方法不再存在。

1.1c库保留了二进制兼容性,但是在源代码级别上充满了可能的意外。 现在,将library.Foo()调用解析为具有两个参数的方法,而将library.Foo("xyz")解析为具有一个参数的方法(从编译器的角度来看,它比具有两个参数的方法更可取,主要是因为没有可选参数无需填充)。 如果具有一个参数的版本仅委派具有两个参数的版本,并且在两种情况下都使用相同的默认值,则这可能是可以接受的。 但是,如果以前调用的方法仍然存在,则第一次调用的值将发生变化似乎很奇怪。

如果您不希望在末尾添加一个新参数,而是在中间添加一个新参数,则带有可选参数的情况变得更加令人困惑,例如,尝试遵守协议并在最后保留可选的CancellationToken参数。 我不会进入这个...

广义方法

在最好的时候得出类型的结论并非易事。 解决超负荷问题时,这项工作变成了一场噩梦。

假设在v1.0中只有一种非通用方法,而在v1.1中我们添加了另一种通用方法。

 //  1.0 public void Foo(object x) //  1.1 public void Foo(object x) public void Foo<T>(T x) 

乍一看,它并没有那么可怕...但是让我们看看客户端代码中会发生什么:

 //  static void Method() { var library = new Library(); library.Foo(new object()); library.Foo("xyz"); } 

在库v1.0中,两个调用都在Foo(object)中解决-唯一可用的方法。

v1.1库是向后兼容的:如果您获取为v1.1编译的可执行客户端文件,则两个调用仍将使用Foo(object) 。 但是,在重新编译的情况下,第二个调用(并且只有第二个调用)将切换为使用通用方法。 两种方法都适用于两个调用。

在第一次调用时,类型推断将表明T是一个object ,因此在两种情况下将参数转换为参数类型都将减少为object中的object 。 太好了 编译器将应用以下规则:非泛型方法始终比泛型方法更可取。

在第二次调用中,类型推断将显示T始终为string ,因此,在将参数转换为类型参数时,对于原始方法,我们将stringobject ,对于通用方法,将stringstring 。 第二种转换是“更好”,因此选择了第二种方法。

如果两种方法的工作方式相同,则可以。 如果没有,您将以一种非常明显的方式破坏兼容性。

继承和动态类型

抱歉,我已经气喘吁吁了。 解决重载时,继承和动态类型化都可以以“最酷”和最神秘的方式体现出来。
如果我们在继承层次结构的一个级别上添加了这样的方法,该方法将使基类的方法过载,则即使将参数转换为类型参数时基类的方法更准确,新方法也会被首先处理,并且将优先于基类的方法。 有足够的空间来混合所有内容。

动态键入(在客户端代码中)也是如此; 在某种程度上,情况变得不可预测。 您已经在编译过程中严重牺牲了安全性...因此,如果发生问题,请不要感到惊讶。

总结

我试图使本文中的示例足够简单。 当您具有许多可选参数时,一切都会变得非常复杂,并且很快。 版本控制是一件复杂的事情;我的头因此而膨胀。

Source: https://habr.com/ru/post/zh-CN414223/


All Articles