C# 可空类型:使空检查依赖于另一个属性/变量。

11

我刚在我的 .net core 3.1 项目中启用了空值检查。

问题是我有一个响应类

public class DecryptResponse
{
    public DecryptStatus Status { get; set; }

    //This is the attribute in question
    [NotNullWhen(Status==DecryptStatus.Ok)]
    public Stream? Stream { get; set; }

    public string? ErrorMessage { get; set; }
}

public enum DecryptStatus
{
    Ok,
    InvalidData,
    KeyChecksumFailure,
    NoData,
    UnhandledError
}

Verify方法不允许空值的情况下,以上内容被用于此场景。

但我知道因为DecryptStatus==Ok,所以该流不为空。

if (decryptResponse.Status != DecryptStatus.Ok)
    return (decryptResponse, null);

var verifyResponse = Verify(customerId, decryptResponse.Stream);
return (decryptResponse, verifyResponse);

是否有任何标记可以实现这个逻辑,还是需要重写代码?


NotNullWhen仅适用于方法参数,对于属性,您可以使用NotNull属性。或将其转换为方法并使用NotNullIfNotNull。您可以在此文章中找到一些想法尝试可空引用类型 - Pavel Anikhouski
2个回答

20
对于.NET 5及更高版本:请使用新的MemberNotNullWhen属性。
  • {{link1:.NET 5.0和C# 9.0引入了MemberNotNullWhenAttribute类型。

    • (C# 9.0还引入了init属性,对于不需要额外构造函数参数的不可变类型中的可选属性非常有用)。
  • MemberNotNullWhen应用于任何Boolean/bool属性,其中true/false值断言当该属性为truefalse时,某些字段和属性将是"notnull"(尽管您目前无法直接断言基于属性的属性将是null

  • 当单个bool属性指示多个属性为nullnotnull时,您可以应用多个[MemberNotNullWhen]属性 - 或者您可以使用params String[]属性构造函数。

  • 您可能会注意到您的Status属性不是bool - 这意味着您需要添加一个新属性,将Status属性适配为bool值,以便与[MemberNotNullWhen]一起使用。

...就像这样:

public class DecryptResponse
{
    public DecryptStatus Status { get; init; }

    [MemberNotNullWhen( returnValue: true , nameof(DecryptResponse.Stream))]
    [MemberNotNullWhen( returnValue: false, nameof(DecryptResponse.ErrorMessage))]
    private Boolean StatusIsOK => this.Status == DecryptStatus.Ok;

    public Stream? Stream { get; init; }

    public string? ErrorMessage { get; init; }
}

当然,这种方法存在一个巨大的漏洞:编译器无法验证StatusStreamErrorMessage是否被正确设置。你的程序可以在不设置任何属性的情况下执行return new DecryptResponse();。这意味着对象处于无效状态。
你可能认为这不是个问题,但是如果你需要不断地向一个类添加或删除属性,最终你会粗心大意地忘记设置一个必需的属性,然后你的程序就会崩溃。
一个更好的DecryptResponse实现应该使用两个独立的构造函数来表示两个互斥的有效状态,如下所示:
public class DecryptResponse
{
    public DecryptResponse( Stream stream )
    {
        this.Status = DecryptStatus.OK;
        this.Stream = stream ?? throw new ArgumentNullException(nameof(stream));
        this.ErrorMessage = null;
    }

    public DecryptResponse( DecryptStatus error, String errorMessage )
    {
        if( error == DecryptStatus.OK ) throw new ArgumentException( paramName: nameof(error), message: "Value cannot be 'OK'." );
        
        this.Status       = error;
        this.Stream       = null;
        this.ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage ));
    }

    public DecryptStatus Status { get; }

    [MemberNotNullWhen( returnValue: true , nameof(DecryptResponse.Stream))]
    [MemberNotNullWhen( returnValue: false, nameof(DecryptResponse.ErrorMessage))]
    private Boolean StatusIsOK => this.Status == DecryptStatus.Ok;

    public Stream? Stream { get; }

    public String? ErrorMessage { get; }
}

然后像这样使用:
DecryptResponse response = Decrypt( ... );
if( response.StatusIsOK )
{
    DoSomethingWithStream( response.Stream ); // OK! The compiler "knows" that `response.Stream` is not `null` here.
}
else
{
     ShowErrorMessage( response.ErrorMessage ); // ditto
}

长答案(为了更好地编写类):

更新到.NET 5 + C# 9并避免上述描述的“无效状态”问题的替代方法是使用更好的类设计,使得“无效状态不可表示”。

我不喜欢可变的“结果对象”(也称为“进程内DTO”)- 即每个属性都有get; set;的对象,因为没有“主构造函数”,无法确保对象实例被正确初始化。

(这与Web服务DTO不同,特别是JSON DTO,其中每个属性可变可能有充分的理由,但这是另一个讨论)

如果我要为旧的.NET平台编写代码,其中没有MemberNotNullWhen可用,那么我会像下面这样设计DecryptResponse

public abstract class DecryptResponse
{
    public static implicit operator DecryptResponse( Stream okStream )
    {
        return new DecryptResponse.OK( okStream );
    }

    public static implicit operator DecryptResponse( ( DecryptStatus status, String errorMessage ) pair )
    {
        return new DecryptResponse.Failed( pair.status, pair.errorMessage );
    }

    private DecryptResponse( DecryptStatus status )
    {
        this.Status = status;
    }

    public DecryptStatus Status { get; }

    public sealed class OK : DecryptResponse
    {
        public OK( Stream stream )
            : base( DecryptStatus.OK )
        {
            this.Stream = stream ?? throw new ArgumentNullException(nameof(stream));
        }

        public Stream Stream { get; }
    }

    public sealed class Failed : DecryptResponse
    {
        public Failed ( DecryptStatus status, String errorMessage )
            : base( status )
        {
            if( status == DecryptStatus.OK ) throw new ArgumentException( message: "Value cannot be " + nameof(DecryptStatus.OK) + "." );
            this.ErrorMessage = errorMessage ?? throw new ArgumentNullException(nameof(errorMessage));
        }

        public String ErrorMessage { get; }
    }
}

(从计算机科学理论的角度来看,上述类是一个联合类型)。
这种设计的优点有很多:
类设计清楚地表明了结果数据只有两种可能的“形状”:“OK”或“Failed”,而每个子类都拥有其特定上下文的数据成员(分别是“Stream”和“ErrorMessage”)。
类型层次结构是封闭的(基本的“抽象”类型有一个私有构造函数),它的两个子类型都是“sealed”,这使得除了“OK”或“Failed”之外的结果是不可能的。
这基本上与“枚举(类)类型”相同,就像Java的“enum”类一样。而C#的“enum”更像是一个命名常量,编译器和语言不保证C#的“enum”值在运行时是有效的(例如,即使“123”不是一个定义的值,你仍然可以执行“MyEnum v = (MyEnum)123”)。
“OK”和“Failed”构造函数中的验证逻辑保证了“DecryptStatus.OK”始终意味着结果类型是“DecryptResponse.OK”,并且具有非“null”的“Stream”属性。同样,如果“Status != DecryptStatus.OK”,则得到的是一个“DecryptResponse.Failed”对象。
“implicit”操作符的定义意味着返回“DecryptResponse”的方法可以直接返回“Stream”或“ValueTuple”,C#编译器会自动执行转换。
返回的结果类型如下所示:
public DecryptResponse DecryptSomething()
{
    Stream someStream = ... // do stuff
    if( itWorked )
    {
        return someStream; // Returning a `Stream` invokes the DecryptResponse conversion operator method.
    }
    else
    {
        DecryptStatus errorStatus = ...
        return ( errorStatus, "someErrorMessage" ); // ditto for `ValueTuple<DecryptStatus,String>`
    }
}

或者如果你想更明确一些:
public DecryptResponse DecryptSomething()
{
    Stream someStream = ... // do stuff
    if( itWorked )
    {
        return new DecryptResponse.OK( someStream );
    }
    else
    {
        DecryptStatus errorStatus = ...
        return new DecryptResponse.Failed( errorStatus, "someErrorMessage" );
    }
}

并且像这样消耗:

DecryptResponse response = DecryptSomething();
if( response is DecryptResponse.OK ok )
{
    using( ok.Stream )
    {
        // do stuff
    }
}
else if( response is DecryptResponse.Failed fail )
{
    Console.WriteLine( fail.ErrorMessage );
}
else throw new InvalidOperationException("This will never happen.");

(不幸的是,C#编译器目前还不够智能,无法识别封闭类型的层次结构,因此需要使用else throw new...语句,但希望最终不再需要这样做。)
(如果您需要使用JSON.net进行序列化支持,那么您不需要做任何事情,因为JSON.NET完全支持这些类型的序列化 - 但如果您需要反序列化它们,那么您将需要一个自定义的合同解析器,不幸的是 - 但编写一个通用的合同解析器来处理封闭类型是很简单的,一旦您编写了一个,您就不需要再编写另一个。)

你的 StatusIsOK 是私有的,如何在类外访问它? - huang
@huang 通过不使用那个版本,而是使用下面的DecryptResponse.OK类。 - Dai
仅依赖于枚举是否可能?无需额外的布尔检查。 - huang
@huang 不可以,因为C#的可为空性分析(以及NotNullWhen / MaybeNullWhen属性类型)仅支持对const bool值进行流分析,而不是枚举类型:这是我们无法控制的限制。我同意这是一个令人烦恼的限制。 - Dai

1

NotNullWhenAttribute仅适用于参数。它告诉编译器当方法返回指定值(true或false)时,(out)参数不为null。例如:

public bool TryParse(string s, [NotNullWhen(true)] out Person person);

这意味着当方法返回 true 时,person 不会为空。
但是这个属性不适用于你想要实现的内容:
- NotNullWhen 不能应用于类属性,只能用于方法参数。 - NotNullWhen 不提供对某些外部值(如类属性)的依赖,它只能使用方法参数的返回值。而且更重要的是,该返回值只能是布尔类型。
但你可以尝试使用方法。
public bool TryDecrypt(Foo bar,
    [NotNullWhen(false) out DecryptError error, // wraps error status & message
    [NotNullWhen(true)] out Stream stream)

或者使用空值允许运算符。
if (decryptResponse.Status == DecryptStatus.Ok)
{
    // decryptResponse.Stream!
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接