AIGC标识 [深入解析C#] 第 8 章:极简属性和表达式主体成员

第 8 章——极简属性和表达式主体成员

❗️8.1 属性语法演进简史(C# 1 → C# 5)

  • 核心概念:属性用于封装状态,区分“对外暴露什么”与“内部如何实现”,避免因直接暴露字段而丧失未来修改的灵活性。

  • 关键点

    • 为什么不用公共字段
      • 无法在赋值时校验值(如禁止 NaN、无穷大)。
      • 无法在读取时执行额外计算或转换。
      • 一旦将字段改为属性,属于破坏性修改:破坏源码、二进制、反射兼容性。因此从一开始就应该使用属性。
    • C# 1:必须手动声明私有字段,编写 get/set 访问器,代码冗长。
    • C# 2:唯一改进——getset 可以有不同的访问修饰符(如 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 只读自动属性的演进。
    • 学习建议:务必牢记“公共字段改属性是破坏性修改”这个关键点,实战中应默认将数据暴露为属性。

8.2 自动实现属性的升级

❗️8.2.1 只读的自动实现属性(C# 6)

  • 核心概念{ get; } 语法声明真正的只读自动属性,背后由编译器生成 readonly 字段支持,只能在构造器或属性初始化器中赋值。

  • 关键点

    • 彻底移除 setter:不再需要 private set; 来模拟只读,语义精确传达“此属性在构造后不可变”。
    • 编译器行为:任何对只读自动属性的赋值(除构造器/初始化器外)都会导致编译时错误。内部字段为 readonly,赋值被转换为直接字段赋值。
    • 与手动只读字段等价:代码更少,但效果与手动声明 private readonly 字段并提供 getter 完全一致。
    • 初始化方式:可在构造器中直接赋值,或利用属性初始化器(下一节内容)。
    • 不可变类型的福音:让创建不可变数据类(如 Point)变得极简,消除了过去必须在简洁与语义间取舍的痛点。
  • 代码示例

    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 的繁琐写法
    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; } 会更干净,且能保证初始化后不可变。
      • 延伸思考:结构体的只读自动属性必须在构造器或属性初始化器中赋值,且不可变的值类型有助于避免一些常见的陷阱(如防御性拷贝)。

8.3 表达式主体成员

8.3.1 表达式主体属性(C# 6)

  • 核心概念:使用 => 语法简化只读属性的定义,消除大括号、getreturn 等冗余形式代码,让声明式编程更直观。

  • 关键点

    • 适用场景
      1. 计算属性:返回值依赖其他字段或属性的计算结果(如 DistanceFromOrigin)。
      2. 代理属性:直接转发另一个对象的属性值(如 Year => Date.Year),使代码极简且意图明确。
      3. 简单逻辑属性:如取余、格式化等单表达式操作。
    • 不是 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 时应警惕。

8.3.2 表达式主体方法、索引器和运算符(C# 6)

  • 核心概念:将 => 语法扩展到方法、只读索引器、运算符和自定义转换,凡是单表达式体均可省略大括号和 return

  • 关键点

    • 适用范围
      • 方法(有返回值或 void):有返回值时隐含 returnvoid 方法则单纯省略大括号。
      • 运算符(如 +、 等):重载运算符通常就是单个表达式,特别适合。
      • 只读索引器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 的特性,可用 => 简化单表达式的方法、只读属性、索引器、运算符等。
      • 有返回值时隐含 returnvoid 方法仅省略大括号,使声明和方法体保持在同一行。
      • 在实现包装类、代理类或大量单语句成员时特别有用,能大幅减少样板代码。
      • 与 lambda 表达式符号相同但语义不同:表达式主体成员是编译时生成对应类成员,而非创建委托。
      • 如需举例,可用只读集合包装类展示其效果,说明如何用极少代码完整实现一个接口。

📦8.3.3 表达式主体成员的限制与 C# 7 扩展

  • 核心概念:C# 6 的表达式主体成员仅限于方法、只读属性、只读索引器、运算符等,构造器、终结器、读写属性/索引器、事件等不支持;C# 7 消除了这些限制,所有类成员都可使用 => 语法。

  • 关键点

    • C# 6 的限制(不支持表达式主体的成员):
      • 静态构造器 / 实例构造器
      • 终结器
      • 读/写属性或只写属性
      • 读/写索引器或只写索引器
      • 事件(自定义访问器)
    • C# 7 的全面支持
      • 构造器:public Demo(string name) => this.name = name;
      • 静态构造器、终结器同理。
      • 读/写属性、读/写索引器:getset 各自独立选择是否使用表达式主体,互不影响。
      • 事件访问器:add => handler += value; / remove => handler -= value;
    • 最佳实践
      • get 通常简单,适合表达式主体;set 经常需要校验,可保留块体写法。
      • get 逻辑复杂,应考虑是否该改为方法,以保持属性和方法语义的清晰界限。
    • 设计意图:减少垂直空间,传达“这是简单成员”的意图,增强可读性。
  • 代码示例

    // 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 扩展到构造器、终结器、读写属性/索引器、事件。
      • getset 可以各自独立使用表达式主体,常见模式是 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;
        }
    }
    
  • 面试准备建议

    • 重要性:⭐(编码规范层面,适当了解)
    • 面试回答要点
      • 表达式主体是纯语法糖,提升简洁性但不应牺牲可读性。
      • 适合单表达式成员(运算符、简单属性、代理方法);多步骤逻辑、多参数校验或需中间变量的情况应保留传统块体。
      • 代码审查时关注“是否一眼看懂”,而非盲目缩短行数。表达清晰比减少字符更重要。
posted @ 2026-07-28 19:37  绘星tsuki  阅读(1)  评论(0)    收藏  举报