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

14

我一直在了解C#9中的init-only属性,但是我认为我们已经可以通过只读属性实现这个功能了,它们只能在构造函数中被设置,并且在那之后就变成了不可变的。

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

示例类


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,它将无法编译。
那么我缺少什么呢?

2
由于我只有观察性知识,所以我会避免发布答案,肯定会有人发布一个好的答案。但是这里有一个答案。init 访问器与 set 访问器相同,除了编译器将阻止您在“允许的上下文”之外使用它,这些上下文包括构造函数、对象初始化程序或新的 with 关键字。这也意味着反射将能够使用它设置值,因此例如反序列化将工作。 - Lasse V. Karlsen
@LasseV.Karlsen 哦,你说得对 - canton7
这可能会有所帮助 Init-Only (对象初始化器)。 - Rans
@canton7 我从没见过这个 modreq 修改器,今天学到了新东西,这也是我觉得自己没有资格发表实际答案的原因之一(尽管我现在看到这里的答案忽略了技术细节),就是我看不出一个 set 访问器和一个 init 访问器之间实际上区别在哪里,我使用反射检查了属性定义和方法定义,没有任何迹象表明有这种 init 差异,但现在我知道在 IL 级别下有一些不用常规反射即可获取的内容。 - Lasse V. Karlsen
@LasseV.Karlsen 是的,你可以在 SharpLab 上看到 setter 上的 modreq。 (https://sharplab.io/#v2:EYLgtghgzgLgpgJwDQxASwDZICYgNQA+AAgEwCMAsAFDVEDMABKQwMLUDe1D3TjaAdjAYAxAPaiG7BgHM4MANwMBaBQwC+1DTSoB6HQwCS0/qIRwGAWgsN+cONnsMAZqYYBlABYQEABwAyEMAMABTQSjBQDABuiFBoovwMok4MMB7mCACugmhg5hgQ0pHAcB4C2KnpDADGomA+mIjRsfH8AJTU/BB5UD4Q1eZEZAB0AErZMLlwwyx1DRiIbohRaANQHFw89ExkAGxMJIZQAKIAHvAIXRgG/CqS6ppAA=)。 - canton7
显示剩余3条评论
5个回答

19

init访问器在几乎所有方面与set访问器的实现完全相同,唯一的区别是它以一种特定的方式标记,使得编译器只允许在少数特定上下文中使用它。

我真正意思上的“相同”是指完全相同。创建的隐藏方法的名称为set_PropertyName,与set访问器一样,在使用反射时,你甚至无法区分它们,它们会被视为相同(请参见下面关于此的注释)。

不同之处在于编译器使用此标志(更多内容请参见下文)仅允许您在C#中的少数特定上下文中设置属性的值(有关此的更多信息也请参见下文)。

  • 来自类型或派生类型的构造函数
  • 来自对象初始化程序,即new SomeType { Property = value }
  • 从带有新with关键字的构造中,即var copy = original with { Property = newValue }
  • 从另一个属性的init访问器中(因此一个init访问器可以写入其他init访问器属性)
  • 从属性规范符中,因此您仍然可以编写[AttributeName(InitProperty = value)]

除此之外,也就是正常的属性赋值,编译器将阻止您使用类似于以下的编译器错误将属性写入:

CS8852:只读属性或索引器'Type.Property'只能在对象初始值设定项中分配,在实例构造函数或'init'访问器中的'this'或'base'上分配。

因此,考虑到该类型:

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

那么这两个属性所生成的中间语言代码将如下所示:

.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方法)已被标记为modreq(System.Runtime.CompilerServices.IsExternalInit)类型,告诉编译器此方法不是普通的set访问器。

这是编译器知道如何将此访问器方法与普通的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();
}

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


啊,抱歉关于LINQPad截图的4K复制:P - Lasse V. Karlsen
1
modreq - 顾名思义 - 是必需的。它是方法签名的一部分,并由运行时用于重载解析。如果您尝试调用带有modreq的方法并且在调用中未指定它,则不会解析。例如,modred用于输出参数。我认为应该是“TargetInvocationException”。编辑:请参阅CLI的ECMA标准。 - Theraot
很高兴知道,直到今天我才知道modreq,所以我想知道编译器如何区分set属性和init属性。LINQPad有这个不错的Diff功能,例如Util.Dif(p1, p2),其中p1p2是两种类型属性的PropertyInfo,它们只在元数据标记和属性名称上有所不同,但现在我看到这比那更深入。 - Lasse V. Karlsen
1
感谢您深入了解细节! - canton7

5
区别在于init属性可以从对象初始化器以及构造函数中设置:
public class C
{
     public int Foo { get; init; }   
}

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

// Illegal
c.Foo = 4;

如果您使用init属性声明记录,编译器还可以让您使用with表达式设置它们。请参见See SharpLab
public record C
{
    public int Foo { get; init; }
}

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

查看SharpLab

当通过反射使用时,它们也可以作为可写的属性出现。这是一个有意的设计决策,以允许基于反射的序列化器反序列化为具有仅初始化属性的对象,而无需进行修改。

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

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

查看 SharpLab


我对你的答案和提到Set方法的答案感到犹豫,我甚至没有考虑过这一点。这使得它非常强大。不过问题是,这如何适用于结构体,因为据我所知,结构体必须支持一个无参数构造函数?(我知道记录解决了很多这方面的问题。) - Mark A. Donohoe
@MarkA.Donohoe 它们对结构体的应用与类相同。就像只有 getter 的属性一样,只读初始化属性可以从构造函数中设置,并且它们也将被初始化为它们的默认值。您不必强制设置只读初始化属性。如果一个结构体具有只读初始化属性,并且您调用其无参数构造函数然后不明确设置它,则只读初始化属性将保持其默认值。 - canton7

4

你可以编写一个init body。就像set body一样。不过它只能在初始化期间工作。

另外,只读属性(init-only properties)可以从对象初始化器或构造函数中设置。

init body的示例:

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

以下内容摘自于下面链接中的范例。

另外还可以参考:


“init”属性可以从对象初始化器以及构造函数中进行设置。对于没有 “set” 的属性,它们只能从初始化器中分配?还是只能从构造函数中分配?(我尝试了 SharpLab 的链接,但由于某种原因,第一个链接会生成错误,并且我不清楚它应该如何工作,无法说明原因。) - Konrad Viltersten
@KonradViltersten 我认为你试图评论 canton7 的答案。无论如何,据我所知,SharpLab中的主分支(2022年3月18日)出了点问题,请尝试将其更改为默认值。不管怎样,第一个应该会导致错误(请参见 //Illegal 注释)。是的,你是对的,init 属性可以从对象初始化程序或构造函数中设置,我会更新我的答案(编辑:完成)。没有 set 的属性不能从对象初始化程序中设置,只能从构造函数中设置。 - Theraot
您在上面的评论中所说的所有陈述都是完全正确的。我不知道我是如何发布到另一个答案而不是我打算发布的那个答案的。大概是脑子抽了吧... :) - Konrad Viltersten

2

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

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 Thingy {
    
    public Thingy(){
    }
    
    public string Name        { get; init; }
    public string Description { get; init; }
    
    public override string ToString()
        => $"{Name}: {Description}";
}

那么上述代码就是合法的。

0

我认为这个问题的答案可以在这里找到:官方文档

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

这些机制有效地允许构建不可变数据,但是通过向类型的样板代码添加成本,并使这些类型退出对象和集合初始化程序等功能来实现。这意味着开发人员必须在易用性和不可变性之间做出选择。”

文档详细解释了其中的差异。


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