如何解决“T 的问题?”/类型参数的可为空约束?

Geo*_*ung 5 c# generics nullable c#-8.0 nullable-reference-types

我正在 C# 8.0 中设计一个nullable启用的接口,目标是 .Net Standard 2.0(使用Nullable 包)和 2.1。我现在面临这个问题T?

在我的示例中,我正在为缓存构建一个接口,该接口存储Streamstring键标识的 s/字节数据,即文件系统可以通过一个简单的实现。每个条目还由一个版本标识,该版本应该是通用的。例如,此版本可以是另一个string键(如 etag)、 anint或 a date

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);

    // ...
}
Run Code Online (Sandbox Code Playgroud)

虽然我明白问题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
    
    Run Code Online (Sandbox Code Playgroud)

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

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

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

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

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

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

走哪条路?我错过了另一种方式吗?有没有我遗漏的相关方面?

我认为如果微软在博客文章中建议了一个可能的解决方法,那就太好了。

Geo*_*ung 0

从 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);
    // ...
}
Run Code Online (Sandbox Code Playgroud)

但它没有实现我试图实现的目标。

的实现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)
    {
        // ...
    }

    // ...
}
Run Code Online (Sandbox Code Playgroud)

但的实现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)
    {
        // ...
    }

    // ...
}
Run Code Online (Sandbox Code Playgroud)

@rikki-gibson 提到了Nullable 引用类型:如何指定“T?” 在注释中键入而不限制类或结构,谢谢。它是相关的并进一步有助于理解问题,但并不能解决问题(见上文)。

恕我直言,我能想到的最干净的解决方案在Returning nullable and null in single C# generic method?中进行了描述。

这样声明接口:

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);
    // ...
}
Run Code Online (Sandbox Code Playgroud)

另请注意该[NotNullWhen(true)]属性并查看相应的文档。感谢 C# 9,可以有一个可为空的输出参数out TVersionIdentifier? result。如果TVersionIdentifierint,则不会intint?如上所述)。鉴于Try*方法在 .Net 中的典型使用方式,该函数的意图和语义很容易理解。使用该NotNullWhen属性,我们也可以进行正确的编译器空检查。因此,这个解决方案满足了我的设计目标。