如何解决类型参数的 "The issue with T?"/nullable 约束?

How to solve "The issue with T?"/nullable constraint on type parameter?

我正在使用启用了 nullable 的 C# 8.0 设计接口,目标是 .Net Standard 2.0(使用 Nullable package) and 2.1. I am now facing The issue with T?.

在我的示例中,我正在为存储 Streams/byte 数据的缓存构建一个接口,由 string 键标识,即文件系统可以通过简单的实现.每个条目都另外由一个版本标识,该版本应该是通用的。例如,此版本可以是另一个 string 密钥(如 etag)、intdate.

public interface ICache<TVersionIdentifier> where TVersionIdentifier : notnull
{
    // this method should return a nullable version of TVersionIdentifier, but this is not expressable due to 
    // "The issue with T?" https://devblogs.microsoft.com/dotnet/try-out-nullable-reference-types/
    Task<TVersionIdentifier> GetVersionAsync(string file, CancellationToken cancellationToken = default);

    // TVersionIdentifier should be not nullable here, which is what we get with the given code
    Task<Stream> GetAsync(string file, TVersionIdentifier version, CancellationToken cancellationToken = default);

    // ...
}

虽然我知道 T? 的问题是什么以及为什么它对编译器来说是一个不小的问题,但我不知道如何处理这种情况。

我想到了一些选择,但似乎都不是最佳选择:

  1. 为界面禁用nullable,手动标记TVersionIdentifier:

    的不可为空的事件
    #nullable disable
    public interface ICache<TVersionIdentifier>
    {
        Task<TVersionIdentifier> GetVersionAsync(string file, CancellationToken cancellationToken = default);
        // notice the DisallowNullAttribute
        Task<Stream> GetAsync(string file, [DisallowNull] TVersionIdentifier version, CancellationToken cancellationToken = default);
        // ..
    }
    #nullable restore
    

    这似乎没有帮助。在可空上下文中实现 ICache<string> 时,Task<string?> GetVersionAsync 会生成警告,因为签名不匹配。编译器很可能知道为 TVersionIdentifier 指定的类型是不可空的并强制执行它的规则,即使 ICache 不知道它。对于像 IList<T> 这样的流行界面,这是有道理的。

    这会导致警告,因此这似乎不是一个真正的选择。

  2. 为成员的实现禁用nullable。虽然在任何一种情况下都会产生警告,但似乎随后为接口禁用了 nullable(这真的有意义吗?)。

    #nullable disable
        public Task<string> GetVersionAsync(string file, CancellationToken cancellationToken = default)
        {
            return Task.FromResult((string)null);
        }
    #nullable restore
    
  3. 与 (2) 类似,但对整个实现 class(以及接口)禁用可为空。也许这是最重要的,因为它清楚地表达了 可空引用的概念 types/generics/... 不适用于此 class 并且调用者必须处理这种情况就像他们之前不得不做的那样(C# 8.0 之前)。

    #nullable disable
    class FileSystemCache : ICache<string>
    {
        // ...
    }
    #nullable restore
    
  4. 选项 (2) 或 (3) 但抑制编译器警告而不是禁用可为空。也许编译器之后得出了错误的结论,所以这是个坏主意?

  5. 与 (1) 类似,但对实现者有约定:为接口禁用 nullable,但手动使用 [DisallowNull][NotNull] 进行注释(参见 ( 1)).在所有实现中手动使用可空类型作为 TVersionIdentifier(我们不能强制执行此操作)。这可能使我们尽可能接近正确注释的程序集。我们实施的消费者在不应该使用 null 时会收到警告,并且他们会得到正确注释的 return 值。虽然这种方式不是很自我记录。任何可能的实施者都需要阅读我们的文档以充分理解我们的意图。因此,我们的接口对于可能的实现来说不是一个好的模型,因为它遗漏了一些方面。人们可能没有想到这一点。

走哪条路?还有另一种我错过的方法吗?我是否遗漏了任何相关方面?

我认为,如果 Microsoft 在博客中提出可能的解决方法,那就太好了 post。

从 C# 9 开始,接口可以这样声明:

public interface ICache<TVersionIdentifier> where TVersionIdentifier : notnull
{
    Task<Stream> GetAsync(string file, TVersionIdentifier version, CancellationToken cancellationToken = default);
    Task<TVersionIdentifier?> GetVersionAsync(string file, CancellationToken cancellationToken = default);
    // ...
}

它并没有达到我想要达到的目的。

ICache<string> 的实现将正确地具有成员:

public class StringVersionedCache : ICache<string>
{
    public Task<Stream> GetAsync(string file, string version, CancellationToken cancellationToken = default)
    {
        // ...
    }

    public Task<string?> GetVersionAsync(string file, CancellationToken cancellationToken = default)
    {
        // ...
    }

    // ...
}

ICache<int> 的实现将 错误地 包含这些成员:

public class IntVersionedCache : ICache<int>
{
    public Task<Stream> GetAsync(string file, int version, CancellationToken cancellationToken = default)
    {
        // ...
    }

    // WRONG: needs to be Task<int?>
    public Task<int> GetVersionAsync(string file, CancellationToken cancellationToken = default)
    {
        // ...
    }

    // ...
}

@rikki-gibson在评论中提到了Nullable reference types: How to specify "T?" type without constraining to class or struct,谢谢。它是真实的,并进一步帮助理解问题,但并没有解决问题(见上文)。

恕我直言,我想出的最干净的解决方案在 中有所描述。

这样声明接口:

public interface ICache<TVersionIdentifier> where TVersionIdentifier : notnull
{
    Task<Stream> GetAsync(string file, [DisallowNull] TVersionIdentifier version, CancellationToken cancellationToken = default);
    Task<bool> TryGetVersionAsync(string file, [NotNullWhen(true)] out TVersionIdentifier? result, CancellationToken cancellationToken = default);
    // ...
}

还要注意 [NotNullWhen(true)] 属性和 see the corresponding docs。感谢 C# 9,可以有一个可为 null 的输出参数 out TVersionIdentifier? result。如果 TVersionIdentifierint,这将是 int 而不是 int?(如上所述)。考虑到 Try* 方法在 .Net 中的典型使用方式,该函数的意图和语义很容易理解。使用 NotNullWhen 属性,我们也可以进行正确的编译器空检查。因此,这个解决方案符合我的设计目标。