[深入解析C#] 第 8 章:极简属性和表达式主体成员
第 8 章——极简属性和表达式主体成员
❗️8.1 属性语法演进简史(C# 1 → C# 5)
-
核心概念:属性用于封装状态,区分“对外暴露什么”与“内部如何实现”,避免因直接暴露字段而丧失未来修改的灵活性。
-
关键点:
- 为什么不用公共字段:
- 无法在赋值时校验值(如禁止 NaN、无穷大)。
- 无法在读取时执行额外计算或转换。
- 一旦将字段改为属性,属于破坏性修改:破坏源码、二进制、反射兼容性。因此从一开始就应该使用属性。
- C# 1:必须手动声明私有字段,编写
get/set访问器,代码冗长。 - C# 2:唯一改进——
get和set可以有不同的访问修饰符(如public get; private set;)。 - C# 3:引入自动实现属性(
{ get; set; }),编译器生成匿名支持字段。但无法直接定义只读自动属性;若要只读,只能退回手动实现(声明readonly字段,仅提供get),或者使用private set;变通。 private set;的缺陷:能在类内部随意修改,不能真正表达“只读且只在构造时赋值”的意图。C# 5 仍停留在此困境。- 作者的编码哲学:字段几乎总是
private,属性是类型公共协议的一部分。
- 为什么不用公共字段:
-
代码示例(演变对比):
// 公共字段(不推荐):丧失封装性 public double X; // C# 1 手动属性 private double x; public double X { get { return x; } set { x = value; } } // C# 3 自动属性(读写) public double X { get; set; } // C# 3 模拟只读(不完美) public double X { get; private set; } // C# 3 真正只读(手动实现,冗余) private readonly double x; public double X { get { return x; } } -
与上下文关联:
- 本章为第 8 章开头,回顾属性历史是为了凸显 C# 6 即将带来的改进(下节 8.2 将介绍只读自动属性和表达式体成员),解决 C# 3/5 的“简洁与语义准确不可兼得”的问题。
- 前文第 7 章介绍 C# 5 特性,本章进入 C# 6 的“极简属性”。
-
给 Unity 岗位学生的建议:
- 重要性:⭐⭐⭐(核心基础,必须掌握)
- 原因:
- Unity 开发中属性能安全地控制
MonoBehaviour字段的访问,虽然 Unity 序列化通常要求字段为public或标记[SerializeField],但提供公共属性包装字段是常见模式。 - 理解自动属性与只读限制有助于写出清晰、可维护的代码,尤其在设计数据容器(如游戏配置、坐标、状态对象)时。
- 面试常问:“属性和公共字段的区别”,“如何实现只读属性”。要能回答封装性、破坏性修改风险,以及从 C# 3 自动属性到 C# 6 只读自动属性的演进。
- Unity 开发中属性能安全地控制
- 学习建议:务必牢记“公共字段改属性是破坏性修改”这个关键点,实战中应默认将数据暴露为属性。
8.2 自动实现属性的升级
❗️8.2.1 只读的自动实现属性(C# 6)
-
核心概念:
{ get; }语法声明真正的只读自动属性,背后由编译器生成readonly字段支持,只能在构造器或属性初始化器中赋值。 -
关键点:
- 彻底移除 setter:不再需要
private set;来模拟只读,语义精确传达“此属性在构造后不可变”。 - 编译器行为:任何对只读自动属性的赋值(除构造器/初始化器外)都会导致编译时错误。内部字段为
readonly,赋值被转换为直接字段赋值。 - 与手动只读字段等价:代码更少,但效果与手动声明
private readonly字段并提供 getter 完全一致。 - 初始化方式:可在构造器中直接赋值,或利用属性初始化器(下一节内容)。
- 不可变类型的福音:让创建不可变数据类(如 Point)变得极简,消除了过去必须在简洁与语义间取舍的痛点。
- 彻底移除 setter:不再需要
-
代码示例:
public sealed class Point { public double X { get; } // 只读自动属性 public double Y { get; } public Point(double x, double y) { X = x; // 只能在构造器中赋值 Y = y; } } -
面试准备建议:
- 重要性:⭐⭐⭐(必会,属性和不可变类型是高频考点)
- 面试回答要点:
- 只读自动属性用
{ get; }定义,无 setter,背后生成readonly字段。 - 只能在构造器中或通过属性初始化器赋值,之后不可变,编译期保证安全。
- 比较
public double X { get; private set; }和public double X { get; }的区别:前者类内部仍可修改,后者完全只读,语义更清晰。 - 结合场景:在需要不可变数据结构(如配置、坐标、事件参数)时使用,避免意外修改并简化线程安全。
- 只读自动属性用
8.2.2 自动实现属性的初始化(C# 6)
-
核心概念:可以在声明自动实现属性的同时直接初始化,无需依赖构造器完成初始赋值。
-
关键点:
- C# 3 的限制:自动属性不允许在声明时初始化,必须把初始化代码放入构造器中,导致代码变冗长。
- C# 6 的改进:允许在属性声明后直接使用
= value;进行初始化,既可用于读写属性,也可用于只读属性。 - 常用模式:只读自动属性搭配初始化器,对外暴露不可变的集合引用,但集合内容可修改(调用方不能替换集合对象本身,也不能设为 null)。
- 与只读属性的协同:只读属性 + 初始化器 = 真正的“一次赋值,永不改变引用”的效果,非常适合在对象创建时注入依赖或默认值。
- 编译器行为:初始化代码会被移入构造器执行,效果与手动在构造器中赋值相同。
-
代码示例:
// 读写自动属性初始化 public List<Person> Friends { get; set; } = new List<Person>(); // 只读自动属性初始化(推荐) public List<Person> Friends { get; } = new List<Person>(); -
面试准备建议:
- 重要性:⭐⭐(常用,需理解其作用)
- 面试回答要点:
- C# 6 允许直接在自动属性声明时初始化,简化构造器代码。
- 与只读自动属性组合,可以创建“引用不可变但内部状态可变”的属性,常见于集合或依赖对象。
- 若被问及“如何防止属性被外部赋值”,可答用
{ get; }加初始化器,编译期保证只能在此处或构造器赋值。 - 注意:初始化器在构造器之前执行(按声明顺序),但最终构造器中的赋值会覆盖初始化值。这是考察细节的点。
8.2.3 结构体中的自动实现属性(C# 6)
-
核心概念:C# 6 放宽了结构体构造器中对自动属性的使用限制,允许在结构体中使用只读自动属性并直接赋值,不再需要先调用默认构造器。
-
关键点:
- 旧版(C# 5 及之前)的痛点:
- 结构体中定义自动属性(如
public double X { get; private set; })无法实现真正的只读,必须带private set。 - 由于结构体的“确定赋值”规则,构造器内必须先链式调用
this()才能访问属性或方法,否则编译器认为字段未完全赋值,既不能使用属性,也不能直接返回。 - 这种写法繁琐且违背了自动属性简约的初衷。
- 结构体中定义自动属性(如
- C# 6 的新规则(在构造器内将自动属性视为字段):
- 允许在所有字段赋值之前为自动实现属性赋值。
- 为自动属性赋值等价于字段初始化,满足编译器的赋值追踪。
- 一旦自动属性赋值完成,即使其他字段尚未初始化,也可以读取该属性。
- 效果:结构体可以像类一样使用真正的只读自动属性(
{ get; }),并在构造器中直接赋值,代码与类的写法几乎一致。 - 作者提醒:是否使用结构体取决于需求(值类型语义、性能等),但至少 C# 6 提供了更好的语法支持。
- 旧版(C# 5 及之前)的痛点:
-
代码示例:
// C# 5 的繁琐写法 public struct Point { public double X { get; private set; } public double Y { get; private set; } public Point(double x, double y) : this() // 必须调用默认构造器 { X = x; Y = y; } } // C# 6 的简洁写法 public struct Point { public double X { get; } public double Y { get; } public Point(double x, double y) { X = x; // 直接赋值,编译器不再报错 Y = y; } } -
面试准备建议:
- 重要性:⭐⭐(中等,熟悉结构体特性会加分)
- 面试回答要点:
- 结构体在 C# 6 之前不能在构造器中直接为自动属性赋值,必须链式调用
this(),因为编译器要求所有字段在访问任何成员前已赋值。 - C# 6 改进了这一限制,允许在构造器内将自动属性赋值视同字段赋值,并且允许读取已赋值的自动属性,使只读自动属性可顺畅用于结构体。
- 可结合 Unity 常见结构体(如
Vector3)来说明:如果自定义一个只读的点结构体,使用{ get; }会更干净,且能保证初始化后不可变。 - 延伸思考:结构体的只读自动属性必须在构造器或属性初始化器中赋值,且不可变的值类型有助于避免一些常见的陷阱(如防御性拷贝)。
- 结构体在 C# 6 之前不能在构造器中直接为自动属性赋值,必须链式调用
8.3 表达式主体成员
8.3.1 表达式主体属性(C# 6)
-
核心概念:使用
=>语法简化只读属性的定义,消除大括号、get、return等冗余形式代码,让声明式编程更直观。 -
关键点:
- 适用场景:
- 计算属性:返回值依赖其他字段或属性的计算结果(如
DistanceFromOrigin)。 - 代理属性:直接转发另一个对象的属性值(如
Year => Date.Year),使代码极简且意图明确。 - 简单逻辑属性:如取余、格式化等单表达式操作。
- 计算属性:返回值依赖其他字段或属性的计算结果(如
- 不是 Lambda 表达式:虽然都使用
=>(宽箭头),但表达式主体属性不涉及委托或表达式树,只是指示编译器生成一个返回表达式结果的只读属性。 - 优势:移除
{ get { return ...; } }样板代码,保留核心访问修饰符、类型和表达式,可读性更高。 - 重要陷阱:
public int Foo => 0;是只读计算属性(每次调用都返回 0)。public int Foo = 0;是读写自动属性带初始化器(可被修改)。- 两者仅差一个字符(
>替换为=),极易误写且编译器不一定报错,代码审查时应特别留意。
- 适用场景:
-
代码示例:
// 计算属性:传统写法 public double DistanceFromOrigin { get { return Math.Sqrt(X * X + Y * Y); } } // 计算属性:表达式主体写法 public double DistanceFromOrigin => Math.Sqrt(X * X + Y * Y); // 代理属性(Noda Time 实例) public int Year => Date.Year; public int Month => Date.Month; public int Hour => TimeOfDay.Hour; // 简单逻辑属性 public int NanosecondOfSecond => (int)(NanosecondOfDay % NodaConstants.NanosecondsPerSecond); -
面试准备建议:
- 重要性:⭐⭐(实用技巧,体现代码简洁性)
- 面试回答要点:
- 表达式主体属性是 C# 6 引入的语法糖,用
=>直接后接表达式,适用于只读计算属性或代理属性。 - 与 lambda 表达式符号相同但语义不同:这里是指示编译器生成只读属性,不是创建委托。
- 写代理属性时尤其清晰,能快速暴露子对象的属性而不写冗余的 get 体。
- 加分陷阱回答:
public int Foo => 0;与public int Foo = 0;的区别——前者是每次调用都执行的只读属性,后者是可变的自动属性初始化器,书写易混但语义完全不同,code review 时应警惕。
- 表达式主体属性是 C# 6 引入的语法糖,用
8.3.2 表达式主体方法、索引器和运算符(C# 6)
-
核心概念:将
=>语法扩展到方法、只读索引器、运算符和自定义转换,凡是单表达式体均可省略大括号和return。 -
关键点:
- 适用范围:
- 方法(有返回值或
void):有返回值时隐含return;void方法则单纯省略大括号。 - 运算符(如
+、 等):重载运算符通常就是单个表达式,特别适合。 - 只读索引器:
T this[int index] => ...;与只读属性语法一致。 - 自定义转换:同样支持。
- 方法(有返回值或
- 代码风格建议:表达式较长时可将
=>放在声明行末尾,主体另起一行缩进,保持可读性。 - 典型应用:
- 简单代理方法(如
Add直接转发给运算符)。 - 包装类/视图类(如实现
IReadOnlyList<T>只读包装),大量单语句成员可用表达式体极简实现。 void方法同样适用,虽然收益不如有返回值的方法明显,但能减少一行大括号。
- 简单代理方法(如
- 再次强调:与 lambda 表达式符号相同,但语义不同——表达式主体成员不涉及委托。
- 适用范围:
-
代码示例:
// 表达式主体方法(有返回值) public static Point Add(Point left, Vector right) => left + right; // 表达式主体运算符 public static Point operator +(Point left, Vector right) => new Point(left.X + right.X, left.Y + right.Y); // 表达式主体 void 方法 public static void Log(string text) => Console.WriteLine("{0:o}: {1}", DateTime.UtcNow, text); // 表达式主体索引器(只读包装类) public sealed class ReadOnlyListView<T> : IReadOnlyList<T> { private readonly IList<T> list; public ReadOnlyListView(IList<T> list) => this.list = list; // 构造器尚未简化,见后续章节 public T this[int index] => list[index]; public int Count => list.Count; public IEnumerator<T> GetEnumerator() => list.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } -
面试准备建议:
- 重要性:⭐⭐(常用语法糖,提升代码简洁度)
- 面试回答要点:
- 表达式主体成员是 C# 6 的特性,可用
=>简化单表达式的方法、只读属性、索引器、运算符等。 - 有返回值时隐含
return;void方法仅省略大括号,使声明和方法体保持在同一行。 - 在实现包装类、代理类或大量单语句成员时特别有用,能大幅减少样板代码。
- 与 lambda 表达式符号相同但语义不同:表达式主体成员是编译时生成对应类成员,而非创建委托。
- 如需举例,可用只读集合包装类展示其效果,说明如何用极少代码完整实现一个接口。
- 表达式主体成员是 C# 6 的特性,可用
📦8.3.3 表达式主体成员的限制与 C# 7 扩展
-
核心概念:C# 6 的表达式主体成员仅限于方法、只读属性、只读索引器、运算符等,构造器、终结器、读写属性/索引器、事件等不支持;C# 7 消除了这些限制,所有类成员都可使用
=>语法。 -
关键点:
- C# 6 的限制(不支持表达式主体的成员):
- 静态构造器 / 实例构造器
- 终结器
- 读/写属性或只写属性
- 读/写索引器或只写索引器
- 事件(自定义访问器)
- C# 7 的全面支持:
- 构造器:
public Demo(string name) => this.name = name; - 静态构造器、终结器同理。
- 读/写属性、读/写索引器:
get和set各自独立选择是否使用表达式主体,互不影响。 - 事件访问器:
add => handler += value;/remove => handler -= value;
- 构造器:
- 最佳实践:
get通常简单,适合表达式主体;set经常需要校验,可保留块体写法。- 若
get逻辑复杂,应考虑是否该改为方法,以保持属性和方法语义的清晰界限。
- 设计意图:减少垂直空间,传达“这是简单成员”的意图,增强可读性。
- C# 6 的限制(不支持表达式主体的成员):
-
代码示例:
// C# 7 表达式主体构造器 public Demo(string name) => this.name = name; // 读写索引器:get 用表达式主体,set 保留块体进行校验 public int this[int index] { get => values[index]; set { if (value < 0) throw new ArgumentOutOfRangeException(); values[index] = value; } } // 事件自定义访问器 public event PropertyChangedEventHandler PropertyChanged { add => handler += value; remove => handler -= value; } -
面试准备建议:
- 重要性:⭐(了解演进即可,非核心考点)
- 面试回答要点:
- 表达式主体成员始于 C# 6,最初限于只读属性、方法、运算符等;C# 7 扩展到构造器、终结器、读写属性/索引器、事件。
get和set可以各自独立使用表达式主体,常见模式是get =>配合块体set(需要校验时)。- 这类语法糖的目标是让简单成员的声明更紧凑,明确表达“仅做简单赋值或返回”的意图。
- 如果被问及使用场景,可举例:构造器仅做字段赋值、只读属性做代理转发时,表达式主体让代码更干净。
📦8.3.4 表达式主体成员使用指南
-
核心概念:表达式主体成员是纯语法糖,应根据可读性而非炫技来决定是否使用。简单、单表达式的成员适合;包含条件检查或多步骤逻辑的不适合。
-
关键点:
- 推荐场景:
- 运算符、转换、比较、相等判断、
ToString等方法——通常仅一个表达式,简洁且意图清晰。 - 大量样板代码的成员(如代理属性)能显著减少冗余。
- 运算符、转换、比较、相等判断、
- 不推荐场景:
- 多条件检查:如多个参数校验。强行合并会让调用链复杂、难以阅读。最多允许一个校验。
- 需要解释性变量:若中间结果有明确含义且能提高可读性,应保留块体形式,便于为中间值命名,也便于调试时逐语句检查。
- 心理陷阱:过度使用可能产生“函数式编程的错觉”,但可读性才是首要目标。
- 自由度:语法糖可随时在两种写法间切换,团队可根据共识和代码审查调整风格。
- 推荐场景:
-
代码示例(反面案例):
// 块体写法清晰 public ZonedDateTime InZone(DateTimeZone zone, ZoneLocalMappingResolver resolver) { Preconditions.CheckNotNull(zone); Preconditions.CheckNotNull(resolver); return zone.ResolveLocal(this, resolver); } // 强制改为表达式主体——不易读 public ZonedDateTime InZone(DateTimeZone zone, ZoneLocalMappingResolver resolver) => Preconditions.CheckNotNull(zone) .ResolveLocal(this, Preconditions.CheckNotNull(resolver));// 有解释性变量,保持块体更好 public int Minute { get { int minuteOfDay = (int)NanosecondOfDay / NanosecondsPerMinute; return minuteOfDay % MinutesPerHour; } } -
面试准备建议:
- 重要性:⭐(编码规范层面,适当了解)
- 面试回答要点:
- 表达式主体是纯语法糖,提升简洁性但不应牺牲可读性。
- 适合单表达式成员(运算符、简单属性、代理方法);多步骤逻辑、多参数校验或需中间变量的情况应保留传统块体。
- 代码审查时关注“是否一眼看懂”,而非盲目缩短行数。表达清晰比减少字符更重要。

浙公网安备 33010602011771号