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
{
// ↓ CS8139:覆盖时不可以更改元组元素名称
public override (int A, int B) TupleAsReturn()
{
// ...
}
// ↓ CS8139:覆盖时不可以更改元组元素名称
public override void TupleAsParam((int, int B) value)
{
// ...
}
}
CS8140
:某个泛型接口的泛型的实例化类型为元组时检查。如果实现了相同的泛型接口,只有元组字段名称不同时,输出这个错误。
interface IGeneric<T>
{
}
// ↓ CS0528:IGeneric<(int A, int B)> 已经在实现列表中
class Class1 : IGeneric<(int A, int B)>, IGeneric<(int A, int B)>
{
}
// ↓ CS8140: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)>
{
// ↓ CS8141:元组元素名称必须和接口方法匹配
public virtual (int, int) Get()
{
// ...
}
// ↓ CS8141:元组元素名称必须和接口方法匹配
public virtual void Set((int, int) tuple)
{
// ...
}
}
class Class2 : Class1, IGeneric<(int C, int D)>
{
// ↓ CS8141:元组元素名称必须和接口方法匹配
public override (int C, int D) Get()
{
// ...
}
// ↓ CS8141:元组元素名称必须和接口方法匹配
public override void Set((int C, int D) tuple)
{
// ...
}
}
CS8142
:分部方法签名中含有元组时检查。如果定义和实现中的元组字段名称不同则输出这个错误。
partial class Class
{
partial void Method((int A, int B) tuple);
}
partial class Class
{
// ↓ CS8142:分部方法的各个部分必须使用相同的元组元素名称
partial void Method((int, int) tuple)
{
// ...
}
}