首页 > 解决方案 > 在 C# 8.0 中反序列化对象时处理临时空引用类型?

问题描述

我正在尝试 C# 8.0,并且我想为整个项目启用空引用检查。我希望我可以改进我的代码设计,并且不会在任何代码范围内禁用可空性上下文。

我在反序列化对象图时遇到了问题。对象之间存在引用,但对于最终用户视图,对象图中的所有引用都必须有一个值。

换句话说,在反序列化过程中,引用可能null,但是在所有对象完成加载后,最终过程会将所有对象链接在一起,从而解决这些null引用。

解决方法

我已经能够使用几种不同的技术来解决这个问题,并且它们都按预期工作。然而,它们还通过引入大量额外的脚手架大大扩展了代码。

选项#1:影子类

例如,我尝试为每种对象编写一个配对类,在反序列化期间将它们用作中间对象。在这些配对类中,所有引用都允许为null. 反序列化完成后,我从这些类中复制所有字段并将它们转换为真实对象。当然,使用这种方法,我需要编写很多额外的代码。

选项#2:影子成员

或者,我尝试放置一个可为空的字段和一个不可为空的属性。这类似于以前的方法,但我使用的是配对成员而不是配对类。然后我为每个字段添加一个内部设置器。这种方法的代码比第一种方法少,但它仍然大大增加了我的代码库。

选项#3:反思

传统上,如果不考虑性能,我会使用反射来管理反序列化,这样每个类几乎没有额外的代码。但是编写自己的解析代码有一些好处——例如,我可以输出更多有用的错误消息,包括调用者如何解决问题的提示。

但是当我引入可空字段时,我的解析代码会大大增加——而且唯一的目的是满足代码分析。

示例代码

为了演示,我尽量简化了代码;我的实际课程显然不止于此。

class Person
{
    private IReadOnlyList<Person>? friends;

    internal Person(string name)
    {
        this.Name = name;
    }

    public string Name { get; }
    public IReadOnlyList<Person> Friends => this.friends!;

    internal SetFriends(IReadOnlyList<Person> friends)
    {
        this.friends = friends;
    }
}

class PersonForSerialize
{
    public string? Name { get; set; }
    public IReadOnlyList<string> Friends { get; set; }
}

IReadOnlyList<Person> LoadPeople(string path)
{
    PersonForSerialize[] peopleTemp = LoadFromFile(path);
    Person[] people = new Person[peopleTemp.Count];
    for (int i = 0; i < peopleTemp.Count; ++i)
    {
        people[i] = new Person(peopleTemp[i].Name);
    }

    for (int i = 0; i < peopleTemp.Count; ++i)
    {
        Person[] friends = new Person[peopleTemp[i].Friends.Count];
        for (int j = 0; j < friends.Count; ++j)
        {
            string friendName = peopleTemp[i].Friends[j];
            friends[j] = FindPerson(people, friendName);
        }

        people[i].SetFriends(friends);
    }
}

问题

有没有一种方法可以满足 C# 8.0 中对反序列化期间仅临时为空的属性的空引用检查,而无需为每个类引入大量额外代码?

标签: c#.net-corec#-8.0nullable-reference-types

解决方案


您担心虽然您的对象不打算null成员,但这些成员将不可避免地存在null于您的对象图的构建过程中。

归根结底,这是一个非常普遍的问题。是的,它影响反序列化,但也影响对象的创建,例如,在映射或数据绑定期间,例如数据传输对象或视图模型。通常,这些成员在构造对象和设置其属性之间的很短的时间内为空。其他时候,它们可能会在更长的时间内处于不确定状态,因为您的代码例如完全填充依赖关系数据集,正如您的互连对象图所要求的那样。

幸运的是,微软已经解决了这个确切的场景,为我们提供了两种不同的方法。

选项 #1:Null-Forgiving 运算符

第一种方法,正如@andrew-hanlon 在他的回答中指出的那样,是使用null-forgiving operator。然而,可能不是立即显而易见的是,您可以直接在不可为空的成员上使用它,从而完全消除您的中间类(例如,PersonForSerialize在您的示例中)。事实上,根据您的确切业务需求,您可能可以将您的Person课程简化为以下简单的内容:

class Person
{

    internal Person() {}

    public string Name { get; internal set; } = null!; 

    public IReadOnlyList<Person> Friends { get; internal set; } = null!;

}

选项 #2:可为空的属性

更新:从 2021 年 3 月 9 日发布的 .NET 5.0.4 (SDK 5.0.201) 开始,以下方法现在会产生CS8616警告。鉴于此,您最好使用上面概述的 null-forgiving 运算符。

第二种方法为您提供了相同的确切结果,但这样做是通过可空属性为 Roslyn 的静态流分析提供提示。这些需要比 null-forgiving 运算符更多的注释,但也更明确地说明正在发生的事情。事实上,我实际上更喜欢这种方法,因为它对于不习惯语法的开发人员来说更加明显和直观。

class Person
{

    internal Person() {}

    [NotNull, DisallowNull]
    public string? Name { get; internal set; }; 

    [NotNull, DisallowNull]
    public IReadOnlyList<Person>? Friends { get; internal set; };

}

在这种情况下,您可以通过将可null空性指示符 ( ?) 添加到返回类型(例如,IReadOnlyList<Person>?)来明确承认成员。但是您随后使用 nullable 属性告诉消费者,即使成员被标记为 nullable

  • [NotNull]:可以为空的返回值永远不会为空。
  • [DisallowNull]:输入参数永远不应为空。

分析

无论您使用哪种方法,最终结果都是相同的。如果在不可为空的属性上没有容错运算符,您的成员将收到以下警告:

CS8618:不可为空的属性“名称”未初始化。考虑将属性声明为可为空。

或者,如果不对[NotNull]可空属性使用该属性,则在尝试将其值分配给不可为空的变量时会收到以下警告:

CS8600: 将 null 文字或可能的 null 值转换为不可为 null 的类型。

或者,类似地,在尝试调用值的成员时:

CS8602:取消引用可能为空的引用。

然而,使用这两种方法中的一种,您可以使用默认 ( null) 值构造对象,同时仍然让下游消费者相信这些值实际上不会是null- 并且因此允许他们使用这些值而无需警卫条款或其他防御性代码。

相反,当使用这些方法中的任何一种时,在尝试为这些成员分配值时仍然会收到以下警告:null

CS8625: 无法将 null 文字转换为不可为 null 的引用类型。

没错:您甚至会在分配给string?属性时得到它,因为这[DisallowNull]是指示编译器执行的操作。

结论

采用哪种方法取决于您。因为它们都产生相同的结果,所以这纯粹是一种风格偏好。无论哪种方式,您都可以null在构造过程中保留成员,同时仍然可以实现 C# 的不可为空类型的好处。


推荐阅读