在 C#9 中,仅初始化属性与只读属性有何不同?

In C#9, how do init-only properties differ from read-only properties?

我一直在阅读 C#9 中的仅初始化属性,但我认为我们已经有了只能在构造函数中设置的只读属性。在那之后,它是不可变的。

例如,在class这里,NameDescription都可以在构造函数中赋值,但只能在那里赋值,这正是init-only属性的方式描述。

示例Class


class Thingy {
    
    public Thingy(string name, string description){
        Name        = name;
        Description = description;
    }
    
    public string Name        { get; }
    public string Description { get; }
    
    public override string ToString()
        => $"{Name}: {Description}";
}

测试程序

using System;

class Program {

    public static void Main (string[] args) {
        
        var thingy = new Thingy("Test", "This is a test object");
        Console.WriteLine(thingy);
        // thingy.Name = “Illegal”; <— Won’t compile this line
    }
}

这将输出以下内容:

Test: This is a test object

此外,如果我试图在构造函数运行后修改 NameDescription,它将无法编译。

那我错过了什么?

不同之处在于 init 属性可以从对象初始值设定项以及构造函数中设置:

public class C
{
     public int Foo { get; init; }   
}

// Legal
var c = new C()
{
    Foo = 3,  
};

// Illegal
c.Foo = 4;

See SharpLab.

如果您使用 init 属性声明记录,编译器还允许您使用 with 表达式设置它们:

public record C
{
    public int Foo { get; init; }
}

var c = new C() { Foo = 3 };
var d = c with { Foo = 4 };

See SharpLab.

它们在使用反射时也显示为可写。这是一个深思熟虑的设计决定,允许 reflection-based 序列化程序反序列化为具有 init-only 属性的对象,而无需修改。

public class C
{
    public int GetterOnly { get; }
    public int InitOnly { get; init; }
}

typeof(C).GetProperty("GetterOnly").CanWrite); // False
typeof(C).GetProperty("InitOnly").CanWrite); // True

See SharpLab.

你可以写一个初始化body。就像一组body。除了它只会在初始化期间工作。

也可以从 object initializers 或构造函数中设置 init-only 属性。

初始化示例body:

    public string LastName
    {
        get => _lastName;
        init => _lastName = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Shouldn't be null or whitespace",
                nameof(LastName))
            : value;
    }

下面第一个 link 的示例。

另请参阅:

假设您有一个无参数的构造函数:

class Thingy {
    
    public Thingy(){
    }
    
    public string Name        { get; }
    public string Description { get; }
    
    public override string ToString()
        => $"{Name}: {Description}";
}

那你不能这样做:

var test = new Thingy
 {
 Name = "Test",
 Description "Test"
 };

如果您使用 init 关键字编写 class:

class Thingy {
    
    public Thingy(){
    }
    
    public string Name        { get; init; }
    public string Description { get; init; }
    
    public override string ToString()
        => $"{Name}: {Description}";
}

那么上面的代码就合法了

我想这个问题的答案可以在这里找到: Official doc

“自 1.0 以来,在 C# 中构建不可变数据的底层机制没有改变。它们仍然是: 将字段声明为 read-only。 声明仅包含 get 访问器的属性。

这些机制在允许构建不可变数据方面很有效,但它们通过增加类型样板代码的成本并从对象和集合初始化程序等功能中选择此类类型来实现。这意味着开发人员必须在易用性和不变性之间做出选择。"

文档详细解释了差异。

init 访问器 与几乎所有领域的 set 访问器 相同,只是它以某种方式标记,这使得编译器不允许在一些特定上下文之外使用它。

相同我的意思是完全相同。创建的隐藏方法的名称是 set_PropertyName,就像 set 访问器一样,并且使用反射你甚至无法区分它们,它们看起来是相同的(参见我关于下面这个)。

不同之处在于,编译器使用此标志(下面详细介绍)将只允许您在 C# 中为 属性 设置一个值(下面还有详细介绍)上下文。

  • 来自类型的构造函数或派生类型
  • 来自对象初始值设定项,即。 new SomeType { Property = value }
  • 从结构中使用新的 with 关键字,即。 var copy = original with { Property = newValue }
  • 从另一个 属性 的 init 访问器中(因此一个 init 访问器可以写入其他 init 访问器属性)
  • 来自属性说明符,因此您仍然可以编写 [AttributeName(InitProperty = value)]

除此之外,基本上相当于正常的 属性 赋值,编译器将阻止您写入 属性 并出现如下编译器错误:

CS8852 Init-only property or indexer 'Type.Property' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.

所以给出这个类型:

public class Test
{
    public int Value { get; init; }
}

您可以通过以下所有方式使用它:

var test = new Test { Value = 42 };
var copy = test with { Value = 17 };

...

public class Derived : Test
{
    public Derived() { Value = 42; }
}

public class ViaOtherInit : Test
{
    public int OtherValue
    {
        get => Value;
        init => Value = value + 5;
    }
}

但你不能这样做:

var test = new Test();
test.Value = 42; // Gives compiler error

所以 出于所有意图和目的 这种类型是不可变的,但它现在允许您更轻松地构造该类型的实例,而不会陷入这种不可变性问题。


我在上面说过反射并没有真正看到这一点,请注意我今天才了解到实际的机制,所以也许有一种方法可以找到一些可以真正区分差异的反射代码。重要的是编译器可以看到差异,这就是。

鉴于类型声明为:

public class Test
{
    public int Value1 { get; set; }
    public int Value2 { get; init; }
}

那么为这两个属性生成的 IL 将如下所示:

.property instance int32 Value1()
{
    .get instance int32 UserQuery/Test::get_Value1()
    .set instance void UserQuery/Test::set_Value1(int32)
}
.property instance int32 Value2()
{
    .get instance int32 UserQuery/Test::get_Value2()
    .set instance void modreq(System.Runtime.CompilerServices.IsExternalInit) UserQuery/Test::set_Value2(int32)
}

你可以看到 Value2 属性 setter (init 方法)已经 tagged/flagged (不确定这些是否正确,我确实说过我今天学到了这个) modreq(System.Runtime.CompilerServices.IsExternalInit) 类型告诉编译器这个方法不是你叔叔的设置访问器。

这就是编译器如何知道以不同于普通 set 访问器的方式处理此访问器方法。

考虑到 @canton7 对问题的评论,这个 modreq 构造还意味着如果您尝试在旧的 C# 编译器中使用使用新的 C# 9 编译器编译的库,它不会考虑这个方法。这也意味着您将无法在对象初始值设定项中设置 属性,但这当然只能在 C# 9 和更新的编译器中使用。


那么设置值的反射呢?好吧,事实证明反射将能够很好地调用 init 访问器,这很好,因为这意味着反序列化(您可以说它是一种对象初始化)仍然会像您期望的那样工作。

观察以下LINQPad程序:

void Main()
{
    var test = new Test();
    // test.Value = 42; // Gives compiler error
    typeof(Test).GetProperty("Value").SetValue(test, 42);
    test.Dump();
}

public class Test
{
    public int Value { get; init; }
}

产生此输出:

这是一个 Json.net 示例:

void Main()
{
    var json = "{ \"Value\": 42 }";
    var test = JsonConvert.DeserializeObject<Test>(json);
    test.Dump();
}

给出与上面完全相同的输出。