C# 9记录验证

49

使用C# 9的新记录类型,如何在构造对象时注入自定义参数验证/空值检查等,而无需重写整个构造函数?

类似于这样的操作:

record Person(Guid Id, string FirstName, string LastName, int Age)
{
    override void Validate()
    {
        if(FirstName == null)
            throw new ArgumentException("Argument cannot be null.", nameof(FirstName));
        if(LastName == null)
            throw new ArgumentException("Argument cannot be null.", nameof(LastName));
        if(Age < 0)
            throw new ArgumentException("Argument cannot be negative.", nameof(Age));
    }
}

5
如果使用可空类型,您就不需要进行 null 检查。如果要在构造函数中验证参数,请使用自定义构造函数而不是生成的构造函数。此外,验证工作与任何其他类相同,例如通过数据注释、验证器等方式。 - Panagiotis Kanavos
2
我不确定这与记录类型有什么关系? - Liam
6
@PanagiotisKanavos 在使用NRT时,你仍需要添加空值检查 -- 人们仍然可以传递null,例如来自不可空感知的代码或通过使用! - canton7
3
请注意,with { .. } 只适用于 init 属性,因此如果您定义自己的构造函数,您将失去使用 withers 的能力。 - canton7
6个回答

20

虽然我来晚了,但这可能仍然对某些人有所帮助...

实际上有一个简单的解决方案(在使用之前请先阅读下面的警告)。像这样定义一个基本的记录类型:

public abstract record RecordWithValidation
{
    protected RecordWithValidation()
    {
        Validate();
    }

    protected virtual void Validate()
    {
    }
}

让您的实际记录继承RecordWithValidation并覆盖Validate

record Person(Guid Id, string FirstName, string LastName, int Age) : RecordWithValidation
{
    protected override void Validate()
    {
        if (FirstName == null)
            throw new ArgumentException("Argument cannot be null.", nameof(FirstName));
        if (LastName == null)
            throw new ArgumentException("Argument cannot be null.", nameof(LastName));
        if (Age < 0)
            throw new ArgumentException("Argument cannot be negative.", nameof(Age));
    }
}

正如您所看到的,这几乎是原作者的代码。它很简单,而且有效。

但是,如果您使用此代码,请非常小心:它仅适用于使用“位置记录”语法(又称“主构造函数”)定义的属性。

原因是我在这里做了一些“不好”的事情:我从基类型的构造函数中调用了虚拟方法。通常不建议这样做,因为基类型的构造函数先于派生类型的构造函数运行,所以派生类型可能尚未完全初始化,因此重写的方法可能无法正确工作。

但是对于位置记录,事情并非按照那种顺序发生:位置属性首先被初始化,然后调用基类型的构造函数。因此,当调用Validate方法时,属性已经初始化,因此它可以按预期工作。

如果将Person记录更改为具有显式构造函数(或仅可初始化属性并且没有构造函数),则会在设置属性之前调用Validate,因此它将失败。

编辑:此方法的另一个令人讨厌的限制是它不适用于with(例如 person with { Age = 42 })。这使用不同的(生成的)构造函数,该构造函数不调用Validate...


这似乎是目前最有效的“解决方法” - 非常感谢,伙计! - Simon Mattes
2
这个问题与上面使用init和initializers的问题相同,因为在使用“with”语法创建新实例时不进行验证。但是...无论如何都没有办法做到这一点,除非自己完全编写属性。 - Lasse V. Karlsen
1
@LasseV.Karlsen 很好的观点,我没有想到这一点! - Thomas Levesque
“位置属性首先被初始化,然后调用基类型的构造函数。因此,当调用Validate方法时,属性已经被初始化,所以它按预期工作。”相反,我根本没有预料到这种不寻常的构造函数行为——在record类型之前(据我所知),派生类的构造函数参数不能在超类构造函数完成之前与this联系起来——我不知道CLR甚至让你这样做,所以这引起了我的兴趣... - Dai
@Dai 或许我表达不够清楚。当我说“像预期一样工作”时,我指的是“验证方法有效”。但是,确实有点令人惊讶... - Thomas Levesque
2
@ThomasLevesque 对不起,这是一个旧的线程,但是以防其他人碰巧遇到这个问题,我还是回答一下。如果你在RecordWithValidation中添加一个拷贝构造函数,并在其中调用Validate方法,那么基类将会被尊重,并且在使用'with'时会调用Validate方法。例如,添加以下构造函数:public RecordWithValidation(RecordWithValidation other) { Validate(); }我通过查看生成的低级别C#代码并复制记录的自动生成的拷贝构造函数的签名来发现了这一点。 - undefined

16

在初始化期间,您可以验证属性:


record Person(Guid Id, string FirstName, string LastName, int Age)
{
    public string FirstName {get;} = FirstName ?? throw new ArgumentException("Argument cannot be null.", nameof(FirstName));
    public string LastName{get;} = LastName ?? throw new ArgumentException("Argument cannot be null.", nameof(LastName));
    public int Age{get;} = Age >= 0 ? Age : throw new ArgumentException("Argument cannot be negative.", nameof(Age));
}

https://sharplab.io/#gist:5bfbe07fd5382dc2fb38ad7f407a3836


太棒了,这个想法非常有效! - Simon Mattes
6
不幸的是,这仍需要重复记录类型成员的名称和类型(作为参数和属性),这有点违背了首次使用“记录”类型的主要目的:简洁性。 - Dai
2
@Dai,使用类,您需要编写更多的构造函数行来设置所有属性。然而,使用这种方法,您只需要为要检查 null 的属性编写额外的行。顺便说一句,您可以为此创建一个代码片段。 - Artemious
4
这不是正确的回答,因为当使用新的with表达式时,验证过程不会发生。请参阅以下测试:https://gist.github.com/C0DK/1df531dc5d8bbde6faffe2dc887cd4ef - Casper Bang
2
@CasperBang 嗯,无法覆盖 with 表达式,因为它会发出简单的赋值语句。这就是为什么该示例未为属性定义 setter,并且它有效地禁用了仅具有 getter 的属性的 with 语句。 - Aik

7
以下方法也能实现,而且更短(我认为也更清晰):
record Person (string FirstName, string LastName, int Age, Guid Id)
{
    private bool _dummy = Check.StringArg(FirstName)
        && Check.StringArg(LastName) && Check.IntArg(Age);

    internal static class Check
    {
        static internal bool StringArg(string s) {
            if (s == "" || s == null) 
                throw new ArgumentException("Argument cannot be null or empty");
            else return true;
        }

        static internal bool IntArg(int a) {
            if (a < 0)
                throw new ArgumentException("Argument cannot be negative");
            else return true;
        }
    }
}

如果有方法可以摆脱虚拟变量就好了。


8
请参阅String.IsNullOrEmpty函数。这个函数用于检查字符串是否为空或null。如果该字符串为空或null,则返回true,否则返回false。 - Etienne Charland
2
你还应该将参数名称传递给异常。对于C# 10,请考虑使用CallerArgumentExpressionAttribute - Richard Deeming
1
这真的很聪明,避免了添加所有显式属性的需要。谢谢。 - Avrohom Yisroel
1
请不要将其视为抱怨,但是在没有进一步思考实际用例的情况下模仿Kotlin功能会使C#变得越来越糟糕。这就像发明了try而没有catch一样。 - Michael P
3
@AvrohomYisroel 继承不应该被滥用作为混合的替代品。相反,应该使用 using static 导入。 - Dai
显示剩余2条评论

5
如果您可以不使用位置构造函数,您可以在每个需要验证的属性的init部分完成验证:
record Person
{
    private readonly string _firstName;
    private readonly string _lastName;
    private readonly int _age;
    
    public Guid Id { get; init; }
    
    public string FirstName
    {
        get => _firstName;
        init => _firstName = (value ?? throw new ArgumentException("Argument cannot be null.", nameof(value)));
    }
    
    public string LastName
    {
        get => _lastName;
        init => _lastName = (value ?? throw new ArgumentException("Argument cannot be null.", nameof(value)));
    }
    
    public int Age
    {
        get => _age;
        init =>
        {
            if (value < 0)
            {
                throw new ArgumentException("Argument cannot be negative.", nameof(value));
            }
            _age = value;
        }
    }
}

否则,您需要创建一个自定义构造函数,如上面的评论中提到的。
(顺便说一下,考虑使用ArgumentNullExceptionArgumentOutOfRangeException代替ArgumentException。它们继承自ArgumentException,但更具体地指明了发生的错误类型。) (源代码)

2
如果您可以不使用位置构造函数,那么也可以不使用析构函数。 - Pang
@Pang 当然,您还可以添加构造函数和析构函数,这样您仍然可以获得“with”语法的附加优势。 - Spoc
2
这并不替换构造函数:如果没有设置,构造对象中的 FirstName 可以为 null,而构造函数则排除了这种可能性。是否有一种方法可以强制记录运行验证器(逻辑上)(例如,通过将 default 显式分配给调用代码未分配的任何属性)? - minnmass

5
这里的其他答案都非常棒,但据我所知,没有涵盖with操作符。我需要确保在我们的领域模型中不能进入无效状态,并且我们希望在适用的情况下使用记录。我在查找最佳解决方案时偶然发现了这个问题。我为不同情况和解决方案创建了一堆测试,但大多数测试都与with表达式失败。我尝试的变体可以在此处找到:https://gist.github.com/C0DK/d9e8b99deca92a3a07b3a82ba4a6c4f8 我最终选择的解决方案是:
public record Foo(int Value)
{
    private readonly int _value = GetValidatedValue(Value);

    public int Value
    {
        get => _value;
        init => _value = GetValidatedValue(value);
    }

    private static int GetValidatedValue(int value)
    {
        if (value < 0) throw new Exception();
        return value;
    }
}

很遗憾,目前您需要同时处理更新和创建记录的方式。


2
哇,这跟使用“结构体”类型一样痛苦(其中“this == default(T)”可能随时发生,因此所有非空引用类型字段仍然可能为“null”),只不过我们被告知“record”类型是未来...啊呀。 - Dai
1
我看了你写的测试(并在Linqpad中使其工作使用快速修复),并且认为with运算符的行为感觉像一个bug - 你考虑过在C#语言GitHub存储库中提交问题吗? - Dai
2
@Dai,据我所知,记录仍处于早期阶段,他们正在下一个版本中开发init运算符。记录似乎实现得很差,这很遗憾。这个想法非常棒。我建议尽可能切换到F#。在那里,您将获得大多数出色的功能:)(另外,我很难过我的答案没有更高,因为据我所知,它“更正确”) - Casper Bang
2
是啊,每一个让人感到兴奋的新的C#特性最终都会变成一个巨大的失望。在这种语言中,需要使用很多hack才能使某些东西正常工作。 - Konrad

0
record Person([Required] Guid Id, [Required] string FirstName, [Required] string LastName, int Age);

6
只有在非常特定的情况下(例如ASP.NET Core模型绑定)才会使用[Required]属性。 .NET中没有任何保证以这种方式实例化的记录将验证参数/属性。如果您的记录是用于ASP.NET Core操作的绑定模型,那么这是可以接受的,但对于大多数其他情况可能不太适合。 - Jeremy Caney
@JeremyCaney 只需要一行代码就可以在对象上运行验证,而且不需要使用asp.net。这似乎是最好的方法。 - Kelly Elton
2
@KellyElton:这绝对是我使用过的一种方法,我当然不会反对它。但同样重要的是,任何考虑使用它的人都应该知道,在大多数情况下,验证并不是强制执行或隐含的。 - Jeremy Caney

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