C# 元组元素名称和方法签名相关

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()
    {
        // ...
    }
}

在通过 SomeClassSomeInterface 分别调用 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)
    {
        // ...
    }
}
分享到 评论