首页 > 解决方案 > 如何初始化不可为空的泛型类型属性(或字段)(c#8 或 9)

问题描述

我正在将一些旧代码转换为 c# 8 和 9,并启用了 nullable。我在泛型方面遇到了一些麻烦。这是一个例子。旧类有一个不受约束的类型参数:

public class WeightedValue1<T>
{
    public double Weight { get; set; }
    public T Value { get; set; }
}

这给出了 CS8618:退出构造函数时不可为空的属性“值”必须包含非空值。

那太棒了; 它可以防止该功能旨在捕获的那种错误 - 用户取消引用 .Value 并获得 NullReferenceException。问题是很难正确初始化属性。在旧世界中,它将是:

public class WeightedValue2<T>
{
    public double Weight { get; set; }
    public T Value { get; set; } = default;
}

这为 CS8601 提供了可能的空引用分配。也应该!我还没有真正解决问题。即使 T 是不可为空的字符串,调用者也会在没有警告的情况下获得 NRE :

[TestClass]
public class NreTest2
{
    [TestMethod]
    public void NonNullableString_ThrowsNre()
    {
        Assert.ThrowsException<NullReferenceException>(() => new WeightedValue2<string>().Value.ToLower());
    }
}

为了完整起见,请注意指定 notnull 约束不会改变任何内容。

public class WeightedValue3<T>
    where T : notnull
{
    public double Weight { get; set; }
    public T Value { get; set; } = default;
}

[TestClass]
public class NreTest3
{
    [TestMethod]
    public void ConstrainedString_ThrowsNre()
    {
        Assert.ThrowsException<NullReferenceException>(() => new WeightedValue3<string>().Value.ToLower());
    }
}

当然,使用 null-forgiving 运算符 (!) 会隐藏警告,但对调用者毫无帮助。

public class WeightedValue4<T>
    where T : notnull
{
    public double Weight { get; set; }
    public T Value { get; set; } = default!;
}
[TestClass]
public class NreTest4
{
    [TestMethod]
    public void ForgivenString_ThrowsNre()
    {
        Assert.ThrowsException<NullReferenceException>(()=> new WeightedValue4<string>().Value.ToLower());
    } 
}

是的,所以问题实际上是字符串的默认值为空,无论我们是否希望它被允许。我们告诉编译器的任何事情都不会让它改变这一点。所以我们必须用一些东西来初始化它。由于它是一个通用的无约束 T,我们找不到任何有效值。我们必须将它推送给调用者。我所有现有的调用者都将使用对象初始值设定项语法,因此新的 c#9“init”属性看起来非常合适。

public class WeightedValue5<T>
{
    public double Weight { get; set; }
    public T Value { get; init; }
}

叹。不幸的是 init 没有做我想要的。这意味着您不能在创建对象后调用 setter,但它不会强制您实际初始化属性。这是双重令人沮丧的,因为编译器知道它需要初始化。

[TestClass]
public class NreTest5
{
    [TestMethod]
    public void InitOnlySetter_Compiles_AndThrowsNre()
    {
        Assert.ThrowsException<NullReferenceException>(() => new WeightedValue5<string>().Value.ToLower());
    }
}

这是一个至少可以警告调用者关于可空性的版本。但它需要更改每个现有的呼叫站点。当你开始有很多属性和多个构造函数来初始化时,它通常会让人烦恼。

public class WeightedValue6<T>
{
    public double Weight { get; set; }
    public T Value { get; }

    public WeightedValue6(T value)
    {
        Value = value;
    }
}

我还探索了属性注释,但我看不出它们有什么帮助。尽管新记录类型的目标是减少简单引用类型的样板文件,但 AFAIK 也存在同样的问题。

我疯了吗?我可以用绝对非空值初始化通用字段的唯一方法是让每个调用者手动指定它们,这真的是真的吗?唯一的方法是使用全脂构造函数?(当然,将特定的警告设置为错误。)

如果是这种情况,希望编译器用 init 重写我的版本是错误的吗?好像它有完整的构造函数?

标签: c#genericsc#-8.0nullable-reference-typesc#-9.0

解决方案


您提到记录无法解决问题,但我认为它们减少了相当多的样板文件。

public record WeightedValue<T>(double Weight, T Value);

如果将 null 传递给 value,编译器将抱怨可空引用类型。还有一些with语法确实会给您一些init感觉,但是您需要有一个要从中复制的值。

var weightedValue = new WeightedValue<string>(0, "Hello"); // fine
weightedValue = new WeightedValue<string>(0, null); // compiler warning
weightedValue = weightedValue with { Value = null }; // compiler warning

我也喜欢对象初始化,但命名参数看起来很不错。他们唯一不起作用的地方是表达式树(这很烦人)。

var weightedValue = new WeightedValue<string>(
    Weight: 0, 
    Value: "Hello"
);

使用该语法的另一个潜在选项with是创建一个默认值来初始化您的类型,然后调用它并更改您对对象感兴趣的参数。

public static WeightedValue<string> defaultWeightedString = new(0, "");

//...

var weightedValue = defaultWeightedString with { Weight = 10 }; // string Value initialized

我在这里看到了很多潜力。但是,如果我们有某种{ get; init required; }强制实例化的初始化,那就太好了。

更新看起来有一个建议。

https://github.com/dotnet/csharplang/issues/3630

https://github.com/dotnet/cshaplang/discussions/4209


推荐阅读