C# 中带元组的方法,在涉及继承、接口实现等等情况时遵循特殊的规则。可惜这部分并没有一篇文档记载,于是我就先记下来,以后如果这个规则正式成文了(或没成文),本文都可以起到参考作用。
这是我在写一段代码时偶然发现的。示例代码如下:
using System; using System.Collections; using System.Collections.Generic;
class Program { static void Main() { var e = new SomeEnumerable();
foreach (var x in e) { Console.WriteLine("({0}, {1})", x.First, x.Second); } } }
class SomeEnumerable : IEnumerable<(int First, int Second)> {
public IEnumerator<(int First, int Second)> GetEnumerator() { throw new NotImplementedException(); }
IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); }
}
|
以上的代码编译通过,没有问题。然后我就想,这个元组((int First, int Second)
)写起来太麻烦了,几个类里都有相似的使用的地方,语义都是一样的。而且根据 C# 的语法,元组是可以判断结构等同的,字段的名字只不过是个有特性支持的语法糖而已。那么,我就想将实现的接口相关类型的字段名称抹除,只在返回值提示字段意义。也就是说,改成下面这个样子:
class SomeEnumerable : IEnumerable<(int, int)> {
public IEnumerator<(int First, int Second)> GetEnumerator() { throw new NotImplementedException(); }
IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); }
}
|
你猜怎么着?编译失败了。但是如果我使用的时候不加名字呢?
foreach (var (f0, f1) in e) { Console.WriteLine("({0}, {1})", f0, f1); }
|
在保持 SomeEnumerable
为原状的情况下,上面这一段代码也可以正常编译。
怎么这么奇怪,有时候是结构等同的(只需要看字段顺序,不看字段名称),有时候不是结构等同的(字段顺序和名称都要看)?那岂不是我每个地方都得写上冗长的“(int First, int Second)
”了吗?这是语言设计的缺漏还是其他什么问题?
于是我去找了这部分的规范和文档。可惜,标准目前只到 5.0,Microsoft Docs 上只到 6.0 的草稿。而元组是在 C# 7.0 引入的,相对正式的文档也只有一个参考。另外,不久之后,C# 9.0 就要出来了。很遗憾,没有任何文档记录了我上面演示的编译器行为。
接着,我根据编译器输出的错误信息(CS8141
),找到了 dotnet/orleans#3421。在其回复中提到了 Roslyn 的修复 PR dotnet/roslyn#20838。这个 PR 的内容是放宽对元组名称匹配的限制,使得元组能在不支持元组字段命名、但是能使用元组类型本身的时候(例如 C# 6.0,见 dotnet/roslyn#20528),允许元组字段名称的部分或者全部不匹配。
也就是说,以下这段代码(dotnet/orleans#3421 中的例子)在这个 PR 之前会报告编译错误,但是之后是通过的:
using System.Threading.Tasks;
public interface ISomeGrain { Task<(bool someBool, string someString)> Func(); }
class Impl : ISomeGrain { public Task<(bool, string)> Func() { return Task.FromResult((true, "")); } }
|
但是这仍然不能解决我的问题,因为我的代码不是这种情况,并不能编译通过。根据 dotnet/orleans#3421 的描述,在 .NET Core 2.1 SDK 及之后应该是得到修复的。可是我在 .NET Framework 4.7.2 和 .NET Core 3.1 SDK 上编译,仍然无法通过。难道它是一个回归错误(regression)?
我没有找到类似的解决元组相关的 PR,所以就去看上面那个 PR 的内容。比较感兴趣的是加入的测试代码。现时点的快照是这样的。阅读了这些测试用例后才明白,原来这些编译错误就是防呆设计。理由也不难推测:元组的类型其实都是 System.ValueTuple<...>
,而且可以任意修改元组字段名称;如果类型隐式转换通过的话,按照结构相等性,编译就会通过;但是修改字段名称就意味着修改代码约定,会引入难以排查的错误。考虑以下代码:
interface SomeInterface { (string Name, string Address) GetContactInfo(); }
class SomeClass : SomeInterface { public (string Address, string Name) GetContactInfo() { } }
|
在通过 SomeClass
和 SomeInterface
分别调用 GetContactInfo()
的时候就会产生令人困惑的结果。因此最好的方式就是禁止元组字段名称发生变化。
回到出发点的问题。所以很不幸地,如果要在代码中保留语义的话,我必须在每个地方都写上“(int First, int Second)
”,而没法在一部分地方简写成“(int, int)
”。
在 CodeGenTupleTest.cs
的单元测试中出现了若干元组相关错误码。在阅读了上面的探索过程后,你应该理解了为什么它们这么设计。但是错误信息还是有点让人搞不清在说什么(GCC:我比你强),所以在这里简单做个整理。对于每一种错误,可能有多种触发方式,这里只举出简单直接的例子。
CS8139
:在类继承时,被覆盖的方法签名中含有元组时检查。如果元组各字段名称在基类和子类中不同,则输出这个错误。
class Base { public virtual (int, int) TupleAsReturn() { }
public virtual void TupleAsParam((int A, int B) value) { } }
class Derived : Base { public override (int A, int B) TupleAsReturn() { }
public override void TupleAsParam((int, int B) value) { } }
|
CS8140
:某个泛型接口的泛型的实例化类型为元组时检查。如果实现了相同的泛型接口,只有元组字段名称不同时,输出这个错误。
interface IGeneric<T> { }
class Class1 : IGeneric<(int A, int B)>, IGeneric<(int A, int B)> { }
class Class2 : IGeneric<(int A, int B)>, IGeneric<(int C, int D)> { }
|
CS8141
:某个泛型接口的泛型的实例化类型为元组时检查。如果实现时元组字段名称不同则输出这个错误。
interface IGeneric<T> { T Get(); void Set(T t); }
class Class1 : IGeneric<(int A, int B)> { public virtual (int, int) Get() { }
public virtual void Set((int, int) tuple) { } }
class Class2 : Class1, IGeneric<(int C, int D)> { public override (int C, int D) Get() { }
public override void Set((int C, int D) tuple) { } }
|
CS8142
:分部方法签名中含有元组时检查。如果定义和实现中的元组字段名称不同则输出这个错误。
partial class Class { partial void Method((int A, int B) tuple); }
partial class Class { partial void Method((int, int) tuple) { } }
|