C# 可为 null:使 nullchecking 依赖于另一个属性/变量

C# Nullable: Make nullchecking dependent on another property/variable

提问人:mabeto5p 提问时间:5/28/2020 最后编辑:mabeto5p 更新时间:11/15/2023 访问量:3970

问:

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

问题是我有一个响应类

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
}

上述方法用于方法不允许 null 的情况。Verify

但我知道流不是空的,因为DecryptStatus==Ok

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

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

是否有任何标签允许这种逻辑,或者是否需要对代码进行重大重写?

C# 属性 可为 null

评论

0赞 Pavel Anikhouski 5/28/2020
NotNullWhen仅适用于方法参数,对于属性,您可以使用 Atirbute。或者将其转换为方法并使用某些东西。您可以在本文中找到一些想法 尝试可为 null 的引用类型NotNullNotNullIfNotNull

答:

1赞 Sergey Berezovskiy 5/28/2020 #1

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

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

这意味着当方法返回时不会为空。persontrue

但是这个属性不适合你想要实现的目标:

  • NotNullWhen 不能应用于类属性 - 它只能与方法参数一起使用。
  • NotNullWhen 不提供对某些外部值(如类属性)的依赖性——它只能使用方法参数所属的返回值。更重要的是,这个返回值只能是布尔值。

但是你可以尝试使用方法代替

public bool TryDecrypt(Foo bar,
    [NotNullWhen(false) out DecryptError error, // wraps error status & message
    [NotNullWhen(true)] out Stream stream)

或使用 null-forgiving 运算符

if (decryptResponse.Status == DecryptStatus.Ok)
{
    // decryptResponse.Stream!
}
20赞 Dai 6/13/2021 #2

对于 .NET 5 及更高版本:使用 new 属性。MemberNotNullWhen

  • MemberNotNullWhenAttribute 类型是在 .NET 5.0 和 C# 9.0 中引入的。

    • (C# 9.0 还引入了 init 属性,这些属性对于不可变类型中的可选属性非常有用,而无需无关的构造函数参数)。
  • 通过将其应用于任何 / 属性来使用,其中其 / 值断言某些字段和属性在该属性为 or 时将为 “”(尽管您还不能根据属性直接断言属性将为 nullMemberNotNullWhenBooleanbooltruefalsenotnulltruefalse

  • 当单个属性指示多个属性时,您可以应用多个属性 - 也可以使用属性 ctor。boolnullnotnull[MemberNotNullWhen]params String[]

  • 您可能会注意到您的属性不是 - 这意味着您需要添加一个新属性,该属性将该属性调整为与 一起使用的值。StatusboolStatusbool[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; }
}

当然,这种方法存在一个巨大的漏洞:编译器无法验证 、 和 是否设置正确。在不设置任何属性的情况下,没有什么可以阻止程序执行操作。这意味着对象处于无效状态。StatusStreamErrorMessagereturn new DecryptResponse();

你可能认为这不是问题,但如果你需要继续向类添加或删除新属性,最终你会变得粗心大意,忘记设置你需要的必需属性,然后你的程序就会爆炸。

更好的实现是为 2 个互斥的有效状态使用两个单独的构造函数,如下所示: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 平台编写,那么我会像下面这样设计:MemberNotNullWhenDecryptResponse

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

(从 CS 理论的角度来看,上面的类是联合类型)。

这种设计的优点很多:

  • 类设计清楚地表明,结果数据只有 2 种可能的“形状”:“OK”或“Failed”——每个子类都拥有其特定于上下文的数据成员(和)。StreamErrorMessage
  • 类型层次结构是封闭的(基类型有一个构造函数),并且它的两个子类型都是 ,因此不可能有除 或 之外的结果。abstractprivatesealedOKFailed
    • 这与“枚举(类)类型”基本相同,例如 Java 的 -classes。而 C# a 更像是一个命名常量,编译器和语言不能保证 C# 值在运行时有效(例如,即使不是定义的值,您也始终可以这样做)。enumenumenumMyEnum v = (MyEnum)123123
  • and 构造函数中的验证逻辑提供了保证,该保证始终意味着结果类型具有非属性。同样,如果你有一个对象。OKFailedDecryptStatus.OKDecryptResponse.OKnullStreamStatus != DecryptStatus.OKDecryptResponse.Failed
  • 运算符定义意味着返回 a 的方法可以直接返回 or,并且 C# 编译器将自动为您执行转换。implicitDecryptResponseStreamValueTuple<DecryptStatus,String>

这样的结果类型是这样返回的:

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 很好地支持这些类型的序列化 - 但如果你需要反序列化它们,那么你需要一个自定义的合约解析器,不幸的是 - 但是为封闭类型编写一个通用的合约解析器很简单,一旦你编写了一个,你就不需要再写另一个了。

评论

0赞 huang 2/15/2023
你的是私人的,你如何在课外访问它?StatusIsOK
0赞 Dai 2/16/2023
@huang 不使用该版本,而是使用其下面的类。DecryptResponse.OK
0赞 huang 2/16/2023
有没有可能只取决于枚举?无需额外的布尔值检查。
0赞 Dai 2/16/2023
@huang 否,因为 C# 的可为 null 分析(和 / 属性类型)仅支持对值的流分析,而不支持枚举类型:这是我们无法控制的限制。不过,我同意这是一个令人讨厌的限制。NotNullWhenMaybeNullWhenconst bool