属性

理解C#属性、访问器、自动属性、init与required的真实契约,并避开验证失效和伪不可变对象等常见错误。

难度 进阶 时长 标准深度约12分钟
版本 C# 14 / .NET 10
what

C#属性用字段式语法公开数据,却通过get、set或init访问器执行读取和赋值契约。

trap

init不是深度不可变,required也不是运行时验证;公开集合的getter仍可能泄露可变状态。

fix

简单存储使用自动属性,需要验证时实现访问器,并把状态变更、空值与对象所有权写成可测试的契约。

是什么,为什么存在

属性(property) 是类、结构体或接口的成员。调用方以order.Total或order.Status = value这样的字段式语法使用它,声明类型则通过访问器控制读取或赋值。属性本身不等于字段:它可以返回存储值、计算结果,也可以拒绝一次赋值。

属性解决的是公开数据与保留控制权之间的矛盾。公开字段一旦成为API,声明类型就无法在同一访问点增加验证、计算或更严格的写权限;成对的GetName()、SetName()方法又会让简单数据访问变得笨重。属性把这些控制点放在稳定的成员语法后面。

你会在领域对象、配置类型、DTO、序列化模型和UI绑定对象中遇到属性。调用代码看起来相同,不代表契约相同:自动属性通常只保存值,计算属性每次读取都求值,而自定义访问器可以运行任意代码。审查属性时,要从声明而不是调用语法推断行为。

属性适合表达对象在某一时刻具有的特征。一次网络请求、数据库查询、长时间计算或明显的状态转换通常更适合方法,因为方法调用能让成本与动作更可见。

工作原理

访问器定义读写能力

属性访问器(property accessor) 是属性中的get、set或init部分。读取属性会执行get;普通赋值会执行set;init只允许在对象构造阶段的合法位置赋值。set与init中的隐式参数value就是调用方提供的新值。

访问器组合直接构成公开契约。只有get的属性对调用方只读;get加set可以反复赋值;get加init允许构造阶段赋值,之后不再允许普通赋值。C#也允许仅写属性,但调用方无法读回状态,实际API中很少需要这种形状。

属性和访问器还可以使用不同的可访问性。public decimal Balance { get; private set; }允许所有调用方读取,却只允许声明类型修改。较严格的访问修饰符只能放在其中一个访问器上,而且属性必须同时具有读取与写入访问器。

声明形状外部读取外部普通赋值对象初始化器赋值
{ get; set; }可以可以可以
{ get; private set; }可以不可以不可以
{ get; init; }可以不可以可以
{ get; }可以不可以不可以
=> expression可以不可以不可以

存储属性与计算属性

自动属性省略访问器主体,例如public string Name { get; set; }。编译器为它创建隐藏的 幕后字段(backing field) ,并生成读取和写入该存储的访问器。属性初始值设定项会在构造过程中为这份存储赋值。

需要控制逻辑时,可以显式声明幕后字段,再在访问器中引用它。这样适合范围检查、规范化输入或维护简单不变量。访问器应先验证,再写入字段,否则抛出异常时对象可能已经进入部分更新状态。

计算属性不需要自己的存储。public decimal Total => UnitPrice * Quantity;每次读取都会根据当前状态求值,因此不会出现独立缓存过期的问题。计算很昂贵或需要I/O时,应改用名称明确的方法,或者设计带清晰失效规则的缓存。

C# 14的field上下文关键字提供第三种形状。访问器可以通过field使用编译器合成的幕后字段,同时保留自动实现的另一个访问器。这减少了只为一小段验证逻辑而声明字段的样板代码。

init与required回答不同问题

init访问器(init accessor) 限制属性可以在哪里赋值。调用方可以在对象初始化器中设置它,类型的构造过程也可以建立初始状态;构造阶段结束后,普通代码不能再次给它赋值。这个限制针对属性赋值,不会冻结属性引用的对象。

必需成员(required member) 要求创建表达式初始化某个字段或属性,除非所调用的构造函数声明自己已经满足全部必需成员。required不决定之后能否修改,因此既可配合set,也可配合init。它也不会自动验证字符串非空、数值范围或业务关系。

非可空引用类型、required和运行时验证各自处理一层问题。非可空注解参与编译器的空状态分析,required检查对象创建语法是否遗漏成员,访问器或构造函数则执行真正的运行时不变量。健壮的边界类型经常需要三者配合,而不是选择其中一个。

调用语法隐藏了方法调用

编译后,读取和写入属性对应特殊名称的方法,通常可在元数据中看到get_Name与set_Name。反射把属性作为属性元数据公开,同时也能取得相应访问器。调用方仍写customer.Name,编译器负责绑定到访问器调用。

这解释了为什么属性能够出现在接口中,也能声明为virtual或abstract。实现或重写提供的是访问行为,不是要求所有类型具有同名字段。它也解释了为什么调试器读取一个属性可能执行用户代码。

示例

从自动属性开始

第一个例子把简单状态交给自动属性,把派生值写成计算属性,并把库存变化放进方法。private set让调用方不能绕过TrySell直接替换库存。

ProductInventory.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;

var item = new InventoryItem(openingStock: 3)
{
    Name = "Keyboard",
    UnitPrice = 79.90m
};

Console.WriteLine($"{item.Name}: {item.Stock} units, {item.InventoryValue:F2}");
Console.WriteLine($"sold: {item.TrySell(2)}");
Console.WriteLine($"remaining: {item.Stock}");

public sealed class InventoryItem
{
    public InventoryItem(int openingStock)
    {
        if (openingStock < 0)
            throw new ArgumentOutOfRangeException(nameof(openingStock));
        Stock = openingStock;
    }

    public required string Name { get; init; }
    public decimal UnitPrice { get; init; }
    public int Stock { get; private set; }
    public decimal InventoryValue => UnitPrice * Stock;

    public bool TrySell(int quantity)
    {
        if (quantity <= 0 || quantity > Stock)
            return false;
        Stock -= quantity;
        return true;
    }
}
# not executed here: the .NET SDK and C# compilers are unavailable

Name必须出现在创建表达式中,之后也不能通过普通赋值修改。UnitPrice没有required,所以遗漏它仍能编译,并得到decimal的默认值0;真实领域若不接受零价,还需要构造或验证契约。

InventoryValue始终根据当前Stock计算。库存只有私有setter,但TrySell仍能在类型内部使用复合赋值;方法集中处理数量范围,并以返回值表达一次预期的拒绝。

用field验证赋值

第二个例子使用C# 14的字段支持属性。调用方仍然面对普通属性,init和私有set则分别规范化名称与保护积分变化入口。

ValidatedProfile.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;

var profile = new CustomerProfile
{
    DisplayName = "  Ada  "
};

profile.AddPoints(25);
Console.WriteLine($"{profile.DisplayName}: {profile.LoyaltyPoints}");

try
{
    profile.AddPoints(-1);
}
catch (ArgumentOutOfRangeException)
{
    Console.WriteLine("negative points rejected");
}

public sealed class CustomerProfile
{
    public required string DisplayName
    {
        get;
        init => field = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Display name is required")
            : value.Trim();
    }

    public int LoyaltyPoints
    {
        get;
        private set => field = value >= 0
            ? value
            : throw new ArgumentOutOfRangeException(nameof(value));
    }

    public void AddPoints(int points) => LoyaltyPoints += points;
}
# not executed here: the .NET SDK and C# compilers are unavailable

DisplayName的required检查创建代码没有遗漏成员,init主体才负责拒绝空白并保存裁剪后的值。这两个约束不能互相替代。调用方即使显式写入null,required条件也算满足,因此运行时验证仍然必要。

AddPoints把结果写回LoyaltyPoints,所以私有setter会检查相加后的最终值。这里的负数调用被拒绝,是因为结果小于零;若业务规则规定参数本身必须为正,还应在方法入口单独检查points。

保护集合所有权

第三个例子把列表保留在对象内部,只通过只读包装公开。属性只读并不等于对象深度不可变,因此类型仍用方法控制集合变化。

PurchaseOrder.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;

var order = new PurchaseOrder { Number = "PO-42" };
order.AddLine(new OrderLine("Keyboard", 2, 79.90m));
order.AddLine(new OrderLine("Cable", 1, 12.50m));

Console.WriteLine($"{order.Number}: {order.Lines.Count} lines");
Console.WriteLine($"total: {order.Total:F2}");

public sealed class PurchaseOrder
{
    private readonly List<OrderLine> _lines = [];

    public PurchaseOrder()
    {
        Lines = _lines.AsReadOnly();
    }

    public required string Number { get; init; }
    public ReadOnlyCollection<OrderLine> Lines { get; }
    public decimal Total => _lines.Sum(line => line.Quantity * line.UnitPrice);

    public void AddLine(OrderLine line)
    {
        ArgumentNullException.ThrowIfNull(line);
        if (line.Quantity <= 0 || line.UnitPrice < 0)
            throw new ArgumentOutOfRangeException(nameof(line));
        _lines.Add(line);
    }
}

public sealed record OrderLine(string Product, int Quantity, decimal UnitPrice);
# not executed here: the .NET SDK and C# compilers are unavailable

Lines没有setter,调用方不能替换包装对象;ReadOnlyCollection<T>也不公开添加和删除操作。内部列表改变时,包装视图会反映最新内容,这正是此API选择的实时视图语义。

元素使用不可变记录,避免调用方取得某一行后再偷偷修改数量。若元素本身可变,只读集合只能保护集合结构,不能保护元素状态;这种所有权决策必须在类型契约中明确。

陷阱

访问器递归调用自身

修复方法: 显式实现时写入幕后字段,或者在C# 14中使用field。搜索访问器主体中的属性自身名称,并为一次读写添加最小测试;不要把这种错误当成普通验证异常来捕获。

在getter中隐藏I/O或状态变化

修复方法: 有明显成本、失败模式或副作用的操作使用方法,例如LoadReportAsync()。计算属性保持可预测;必须缓存时,明确缓存的所有者、线程行为与失效条件。

把init或私有setter当成深度不可变

修复方法: 对外暴露不可变集合、只读包装或快照,并控制元素本身的可变性。审查属性类型形成的整个对象图,不要只看setter的可访问级别。

把required当成验证器

修复方法: 在可信边界内执行空值、格式和跨字段验证。把required当作调用方体验与编译时完整性工具,不要把它当作运行时数据验证或安全边界。

在验证前修改幕后状态

修复方法: 先在局部值上完成验证和规范化,再提交字段更新。涉及多个成员的原子状态转换应使用方法或工厂,把全部条件检查完后再一次性修改对象。

让可写属性破坏哈希或集合身份

修复方法: 用稳定且不可变的值定义键身份,或者在修改前移出、修改后重新加入。记录类型也要检查参与值相等性的属性是否会引用可变对象。

深入 属性在元数据中的形状

属性在元数据中的形状

C#源码把属性呈现为一个成员,但公共语言运行时通过访问器方法执行它。常规实例getter没有参数并返回属性类型,setter接收一个属性类型的参数并返回void。属性元数据把这些方法关联起来,让反射、序列化器和绑定框架能够把它们视为一个逻辑属性。

自动属性的幕后字段是实现细节。它的名称、特性和是否存在都不应成为应用契约;从自动属性改为显式幕后字段后,依赖编译器字段名称的反射代码很容易失效。需要持久化或传输时,应绑定公开属性或明确声明的契约,而不是扫描编译器生成字段。

属性调用也参与普通成员规则。静态属性没有实例接收者,虚属性根据运行时类型分派,接口属性要求实现提供兼容访问器。字段没有这些多态行为,因此把属性描述成“公开字段的语法糖”会漏掉重要语义。

复合赋值仍会读后再写

account.Balance += amount不是对某个公开存储位置的直接修改。概念上,它先调用getter取得旧值,计算新值,再调用setter写回。getter或setter中任一逻辑都可能运行并抛出。

这对带副作用的访问器尤其危险。一次看似单独的复合赋值会同时触发读与写,线程之间也可能在两步之间交错;属性访问本身不提供原子性。共享计数应使用锁、Interlocked或更高层同步契约,而不是依赖Count++的简短语法。

只写属性无法参加复合赋值,因为运算需要先读取旧值。只有getter的属性则不能接收最终写回。编译器在调用点检查这些访问器能力。

C# 14的字段支持属性

field是属性访问器中的上下文关键字,表示该属性由编译器合成的幕后字段。它允许一个访问器保持自动实现,另一个访问器增加验证。例如,getter可以只写分号,init则在写入field前规范化输入。

field不会让多个属性共享存储,每个字段支持属性都有自己的合成字段。它也不是可以传给其他API的公开名称,调用方仍只能通过属性访问。需要多个属性共同维护一个值或进行跨成员协调时,显式字段通常更清楚。

已有成员确实可能名为field。在属性访问器范围内需要引用该成员时,应写成@field,否则新关键字可能改变名称解析。升级到C# 14后,包含这种名称的生成代码值得单独检查。

字段支持属性减少样板代码,却没有改变访问器设计规则。验证仍应在修改前完成,getter仍不应隐藏昂贵工作,异常和线程行为仍属于API契约。语法变短不能代替行为审查。

初始化契约的边界

init的限制由编译器在赋值位置执行。它提供浅层的赋值限制:属性所引用的列表、字典或普通类仍可通过自己的API修改。若需要真正不可变的对象图,成员类型本身也必须不可变,或者对象必须复制传入数据并只公开不可变视图。

required同样主要是编译时协议。创建类型实例的C#表达式必须设置所有可见必需成员,但显式赋默认值仍算“已设置”。非可空分析可能另外警告null,两套诊断表达的是不同问题。

标有SetsRequiredMembers的构造函数告诉编译器:它已经初始化全部必需成员。编译器不会检查这个承诺是否真实,所以该特性是一个需要审计的逃生口。生成代码机械添加它时,遗漏的成员会从调用点诊断中消失。

反射和某些对象构造基础设施不一定通过普通C#创建表达式,因此不能把required当成反序列化保证。应针对实际使用的序列化器和配置运行端到端测试,并在数据进入可信模型时验证不变量。框架支持会随版本和选项变化,类型设计不能只靠一个关键词猜测绑定行为。

继承会扩大必需成员集合

派生类型会继承基类型的必需成员,并可以增加自己的必需成员。调用方创建派生实例时,必须满足最终类型的完整集合。重写一个必需属性不能移除它的必需状态。

这会影响工厂和带new()约束的泛型代码。具有必需成员的类型不能直接用作依赖无参new()约束来创建实例的类型实参,因为泛型创建点无法提供对象初始化器。工厂需要接收初始化数据或由类型提供能明确建立契约的构造路径。

API演进时新增required也会给重新编译的调用方增加工作。它也许不会改变现有二进制中的访问器签名,却会改变新的源代码创建要求。公共库应把这种变化当成调用方契约变更来评估。

属性与对象不变量

单属性验证只能看到一次赋值。如果Start必须早于End,分别公开两个setter会让赋值顺序决定中间状态,甚至让任何顺序都先失败一次。构造函数、工厂或ChangeWindow(start, end)方法可以先验证整组候选值,再一起提交。

规范化也属于契约。setter若自动裁剪名称或调整大小写,读取结果可能不同于调用方写入的文本;这可能合理,但应被测试和记录。对密码、令牌或签名输入进行静默规范化通常会破坏数据,应由领域要求决定。

属性抛出异常后,对象应尽量保持原状态。先计算、验证并保存到局部变量,再更新幕后字段,可以让单属性修改具有清晰的提交点。需要更新多个字段时,同样先建立完整的新状态,再执行不会失败的赋值部分。

可观察不变量还包括相等性、哈希和排序。若这些行为依赖可写属性,修改会影响集合位置、缓存键或去重结果。标识属性通常应在构造后稳定,状态属性则不应偷偷参与标识。

可访问性与重写保持契约

访问器的可访问性决定调用方能执行什么,不只是文档提示。公开属性上的private set不会出现在外部赋值的候选操作中,因此调用方代码直接编译失败。它比“请勿修改”的注释更可靠,但声明类型内部仍要负责所有写入路径。

虚属性的重写必须保留基类契约允许的访问器。调用方若通过基类引用读取属性,运行时会分派到派生实现;派生getter因而不应突然加入破坏基类预期的I/O、副作用或更弱不变量。多态属性需要像多态方法一样审查替换行为。

隐藏继承属性与重写不同。派生类型使用new声明同名属性后,通过基类静态类型访问时仍选择基类属性,通过派生静态类型访问时则选择新属性。同一个对象因引用类型不同而呈现两套状态,通常比明确的重写或改名更难维护。

接口只规定属性访问器形状,不规定幕后字段。一个实现可以存储值,另一个实现可以计算值,但两者都应满足接口对成本、失败和可变性的语义约定。若这些约定对调用方重要,应写进接口文档并用契约测试覆盖。

特性可以指向不同产物

属性声明上的特性默认作用于属性元数据,不会自动作用于编译器生成的幕后字段。需要标注自动属性的存储时,可以使用field:特性目标;需要标注访问器返回值或方法时,则应选择相应目标。

这个区别会影响序列化、验证与反射工具,因为不同工具读取的元数据位置并不相同。添加特性前先确认使用方检查属性、字段还是访问器,不能只根据源码中紧邻的位置推断。

集成测试可以通过实际使用方读取一份最小模型,确认特性落在预期元数据目标上。

延伸阅读

检查点

4个问题 · 1道输出预测题 · 2道找错题

复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?