C#基础7- 面向对象中级应用7 | 看了不亏系列

2 阅读6分钟
  1. 结构体 struct:与类的区别、值类型语义

知识点

  1. 内存语义与核心价值

struct 是值类型,默认分配在线程栈上,具有副本语义(赋值、传参时复制完整数据);

class 是引用类型,分配在托管堆,传递时仅复制引用。

企业开发中,结构体是高吞吐、低 GC 场景的核心优化手段,可大幅减少垃圾回收压力。

  1. 与类的选型级差异
维度struct 结构体class 类
继承不支持继承,隐式继承自System.ValueType支持单继承、多接口实现
构造函数C# 10 前无自定义无参构造,编译器始终生成默认零值构造;C# 10 + 可自定义,但无法覆盖default(T)零值行为可自由定义构造函数,无默认构造限制
析构函数不能定义终结器,栈内存随方法作用域自动释放可定义终结器,由 GC 回收
相等性默认逐字段值比较(反射实现,性能低)默认引用地址比较
GC 影响栈上分配无 GC;装箱 / 作为类字段时进入堆全程受 GC 管理
  1. 企业级进阶特性

      1.   readonly struct :只读结构体,强制所有成员不可变,消除装箱时的防御性副本,是高性能开发的标准写法。
      1.   ref struct :栈专属结构体,无法被装箱,配合 Span<T> 实现内存安全的高性能缓冲区操作,用于网络报文、文件 IO 处理。
      1.   StructLayout 特性:精确控制内存布局,用于 P/Invoke 互操作、C++ 动态库调用。
  2. 企业场景:高吞吐日志事件、批量计算中间结果、网络协议报文、轻量级数据快照、互操作数据载体。

示例 高吞吐业务日志系统的只读日志事件结构体

模拟百万级 QPS 的业务日志处理,使用readonly struct承载日志事件,配合Span<T>栈内存批量处理,全程零 GC 分配,满足互联网级性能要求。

/// <summary>
/// 只读日志事件结构体:高吞吐场景下的无GC数据载体
/// readonly struct:只读结构体,所有字段自动不可修改,分配在栈上,减少堆内存GC压力
/// IEquatable<LogEvent>:实现值类型专用相等比较接口,避免装箱,提升比较性能
/// </summary>
public readonly struct LogEvent : IEquatable<LogEvent>
{
    // 只读属性,readonly struct内属性不能在构造之外修改
    public int EventId { get; }                // 日志事件编号
    public LogLevel Level { get; }             // 日志级别
    public DateTimeOffset Timestamp { get; }   // 日志时间戳(推荐DateTimeOffset,带时区,日志系统常用)
    public Guid TraceId { get; }               // 链路追踪Id,用于分布式追踪,定位一次请求全链路日志
    public string MessageTemplate { get; }     // 日志消息模板(Serilog风格模板字符串)

    /// <summary>
    /// 构造函数,初始化只读结构体的全部属性
    /// </summary>
    /// <param name="eventId">事件编号</param>
    /// <param name="level">日志级别</param>
    /// <param name="timestamp">日志时间</param>
    /// <param name="traceId">追踪ID</param>
    /// <param name="messageTemplate">日志模板,不允许null</param>
    public LogEvent(int eventId, LogLevel level, DateTimeOffset timestamp, Guid traceId, string messageTemplate)
    {
        EventId = eventId;
        Level = level;
        Timestamp = timestamp;
        TraceId = traceId;
        // 参数校验:模板字符串不能为null,抛出参数空异常
        MessageTemplate = messageTemplate ?? throw new ArgumentNullException(nameof(messageTemplate));
    }

    /// <summary>
    /// 自定义值比较,替代默认反射实现,提升性能
    /// IEquatable<T>接口方法,专门用于LogEvent之间的相等判断,**不会发生装箱**
    /// 默认结构体Equals会用反射遍历字段,性能差;手动实现直接逐个字段对比
    /// </summary>
    /// <param name="other">待比较的另一个LogEvent</param>
    /// <returns>true代表两个日志事件所有字段完全一致</returns>
    public bool Equals(LogEvent other)
    {
        return EventId == other.EventId
               && Level == other.Level
               && Timestamp.Equals(other.Timestamp)
               && TraceId.Equals(other.TraceId)
               // Ordinal:原生二进制字符串对比,不做文化处理,速度最快,日志场景首选
               && string.Equals(MessageTemplate, other.MessageTemplate, StringComparison.Ordinal);
    }

    /// <summary>
    /// 重写object.Equals,处理object类型比较(会装箱,尽量少用)
    /// 模式匹配:判断obj是不是LogEvent,如果是则调用上面高性能Equals
    /// </summary>
    public override bool Equals(object? obj) => obj is LogEvent other && Equals(other);

    /// <summary>
    /// 重写哈希码,用于HashSet/Dictionary等集合。
    /// HashCode.Combine:把多个字段合并生成哈希值,C#内置高性能组合哈希
    /// 自定义Equals必须配套重写GetHashCode,否则放入字典会出现匹配异常
    /// </summary>
    public override int GetHashCode() => HashCode.Combine(EventId, Level, Timestamp, TraceId, MessageTemplate);

    /// <summary>
    /// ==运算符重载,直接调用Equals,方便用 `a == b` 语法比较两个结构体
    /// </summary>
    public static bool operator ==(LogEvent left, LogEvent right) => left.Equals(right);
    /// <summary>
    /// !=运算符重载
    /// </summary>
    public static bool operator !=(LogEvent left, LogEvent right) => !left.Equals(right);
}

/// <summary>
/// 日志级别枚举,和微软标准ILogger日志级别语义对齐
/// Trace: 详细跟踪 | Debug:调试 | Information:普通信息 | Warning:警告 | Error:错误 | Critical:致命
/// </summary>
public enum LogLevel
{
    Trace,
    Debug,
    Information,
    Warning,
    Error,
    Critical
}

/// <summary>
/// 批量日志处理器:基于Span栈内存零GC处理
/// static class:静态类,不能实例化,里面全部是静态工具方法
/// Span<T>:内存视图类型,可以指向栈内存、堆内存,**不需要装箱**,高性能处理连续内存
/// </summary>
public static class LogEventProcessor
{
    /// <summary>
    /// 遍历日志集合,统计Error及以上级别的日志数量,收集对应的TraceId
    /// 返回元组 (errorCount, List<Guid>):多返回值语法
    /// </summary>
    /// <param name="events">日志事件Span视图,可以指向stackalloc栈数组</param>
    /// <returns>(错误日志总数,错误日志对应的TraceId列表)</returns>
    public static (int errorCount, List<Guid> errorTraceIds) ProcessErrorLogs(Span<LogEvent> events)
    {
        int errorCount = 0;
        var traceIds = new List<Guid>();

        // ref readonly var evt:只读引用遍历Span,**不复制结构体副本**,性能极高
        // 普通foreach会把struct拷贝一份;ref readonly直接引用原内存,避免拷贝开销
        foreach (ref readonly var evt in events)
        {
            // >= Error:Error、Critical都会被统计
            if (evt.Level >= LogLevel.Error)
{
errorCount++;
traceIds.Add(evt.TraceId);
}
        }
        return (errorCount, traceIds);
    }
}

// 调用演示
public static void StructDemo()
{
    // stackalloc:在【栈内存】分配数组,**不进入托管堆,不产生GC**,适合短生命周期、小容量数组
    // ⚠️栈空间有限,stackalloc不能分配很大数组,一般几百以内
    Span<LogEvent> logEvents = stackalloc LogEvent[100];
    var baseTime = DateTimeOffset.Now;

    // 填充模拟日志数据
    for (int i = 0; i < logEvents.Length; i++)
    {
        logEvents[i] = new LogEvent(
            eventId: 1000 + i,
// i%10 ==0 模拟每10条产生一条Error日志
level: i % 10 == 0 ? LogLevel.Error : LogLevel.Information,
timestamp: baseTime.AddMilliseconds(i * 10),
traceId: Guid.NewGuid(),
messageTemplate: "订单处理事件 - 订单ID:{OrderId}");
    }

    // 值语义验证:结构体赋值是完整拷贝,副本独立
    var original = logEvents[0];
var copy = original;
    // copy.EventId = 9999; // 编译报错:readonly struct,所有属性只读,实例创建后不能修改

    // 调用处理器,传入Span(直接指向栈上数组,无拷贝)
    var (errorCount, errorTraces) = LogEventProcessor.ProcessErrorLogs(logEvents);
    Console.WriteLine($"错误日志数量:{errorCount}");
}

语法注意事项

  1. 装箱拆箱开销:结构体转换为 object、接口时会发生装箱,产生堆分配与 GC 压力;高频场景优先使用泛型约束 where T : struct 替代接口参数 。
  2. 默认加 readonly:业务开发中定义结构体默认加 readonly,避免成员方法隐式修改this导致的防御性副本,同时提升编译器优化空间。
  3. 栈分配限制:stackalloc分配的内存不可逃逸出方法作用域;单块内存建议不超过 1KB,避免栈溢出。
  4. 零值合法性:结构体始终存在默认零值,即使自定义无参构造也无法覆盖default(T),业务中需显式校验零值是否合法。
  5. 作为字典键必须重写哈希:默认值比较通过反射实现,性能极差,高并发场景会成为瓶颈,必须手动实现 IEquatable<T> 。
  6. 体积控制:结构体过大时,复制开销会超过引用类型;64 位系统下建议单结构体不超过 16 字节,否则考虑改用类。
  1. 记录类型 record:声明与相等性(C# 9+)

知识点

  1. 核心定位record(全称record class)是引用类型的不可变数据载体,

编译器自动生成值相等性逻辑、解构方法、 ToString 方法, 核心解决 “数据对象按值比较” 的痛点,是 DDD 领域驱动设计中值对象、API 层 DTO 的首选实现。

  1. 企业级核心特性

      1.   值相等性:两个实例只要所有属性值完全相同,就判定为相等,区别于 class 的引用相等,无需手动重写 Equals / GetHashCode 。
      1.   不可变性:默认属性为init-only,对象创建后不可修改,保证分布式传输、缓存场景下的数据一致性。
      1.   非破坏性修改:通过with表达式基于现有实例创建修改后的新实例,保留不可变特性的同时简化数据变更。
      1.   自动解构:编译器生成解构方法,支持按属性快速拆解变量。
  2. 编译器自动生成成员:EqualityContract属性、强类型Equals 、 GetHashCode 、相等运算符、 ToString 、全属性构造函数、解构器。

  3. 典型企业场景:REST API 请求 / 响应 DTO、事件溯源领域事件、配置项对象、业务数据快照、分布式消息载体。

示例 电商采购系统的不可变订单 DTO 与地址值对象

模拟微服务间的采购订单数据传输,用record 实现不可变的订单、地址、明细,演示值相等性、嵌套非破坏性修改、作为缓存键的企业级用法。

/// <summary>
/// 地址值对象:典型DDD值对象
/// record特性:自动实现值相等,实例不可变;适合描述没有唯一标识、靠字段内容判断相等的业务概念
/// DDD值对象核心特征:不可变;只关心内容是否相同,不关心是不是同一个对象
/// </summary>
public record Address
{
    // init访问器:只能在构造函数 / 对象初始化器赋值,初始化完成后,外部不能修改,保证不可变
public string Province { get; init; }
public string City { get; init; }
public string Detail { get; init; }
public string PostalCode { get; init; }

    /// <summary>
    /// 构造函数:创建地址时校验业务规则,保证值对象永远合法(DDD:不允许存在非法的值对象)
    /// </summary>
    public Address(string province, string city, string detail, string postalCode)
    {
        // 业务校验:省份、城市不能为空空白字符串
        if (string.IsNullOrWhiteSpace(province)) throw new ArgumentException("省份不能为空");
        if (string.IsNullOrWhiteSpace(city)) throw new ArgumentException("城市不能为空");
        Province = province;
City = city;
Detail = detail;
PostalCode = postalCode;
    }
}

/// <summary>
/// 订单明细记录:record记录,不可变,存放单行商品信息
/// </summary>
public record OrderLineItem
{
    public Guid ProductId { get; init; }
    public string ProductName { get; init; }
    public int Quantity { get; init; }
    public decimal UnitPrice { get; init; }

    /// <summary>
    /// 表达式主体成员,只读计算属性,不需要存储字段
    /// 不需要set,读取时实时计算:总金额 = 数量 * 单价
    /// </summary>
    public decimal TotalPrice => Quantity * UnitPrice;
}

/// <summary>
/// 采购订单主记录:嵌套不可变数据,DDD聚合根示例
/// record会自动生成Equals、==、GetHashCode,基于全部属性的值做比较,而不是引用地址
/// </summary>
public record PurchaseOrder
{
    public Guid OrderId { get; init; }
    public string SupplierCode { get; init; }
    public Address ShippingAddress { get; init; }
    // IReadOnlyList:只读集合,外部无法Add/Remove,防止外部修改订单明细
public IReadOnlyList<OrderLineItem> Items { get; init; }

    /// <summary>
    /// 订单总金额,实时计算,不持久化存储
    /// </summary>
    public decimal TotalAmount => Items.Sum(i => i.TotalPrice);
    public DateTimeOffset CreatedAt { get; init; }

  /// <summary>
/// 订单构造,强制创建订单时传入必要数据,自动赋值创建时间
/// </summary>
public PurchaseOrder(Guid orderId, string supplierCode, Address shippingAddress, IReadOnlyList<OrderLineItem> items)
{
OrderId = orderId;
SupplierCode = supplierCode;
ShippingAddress = shippingAddress;
// 非空校验:订单明细不能为空
Items = items ?? throw new ArgumentNullException(nameof(items));
CreatedAt = DateTimeOffset.Now;
}
}

// 业务演示
public static void RecordDemo()
{
    // 构造地址值对象
    var address = new Address("上海市", "浦东新区", "张江高科技园区", "201203");

    // 构建订单明细List
    var items = new List<OrderLineItem>
    {
        // Record对象初始化简写:new() 省略类型名
new() { ProductId = Guid.NewGuid(), ProductName = "企业服务器", Quantity = 2, UnitPrice = 15000m },
new() { ProductId = Guid.NewGuid(), ProductName = "27寸显示器", Quantity = 5, UnitPrice = 1800m }
    };

    // AsReadOnly:包装List为只读视图,外部不能修改内部集合
    var order1 = new PurchaseOrder(Guid.NewGuid(), "SUP001", address, items.AsReadOnly());

    // ===================== 语法1:with表达式 【非破坏性修改】=====================
    // with:基于原有record复制一份新实例,只修改指定属性;原有对象保持不变(不可变对象核心能力)
    var newAddress = address with { Detail = "张江集电港B区" };
    var order2 = order1 with { ShippingAddress = newAddress };

    // ===================== 语法2:with { } 浅拷贝 =====================
    // 不写任何属性,代表复制全部字段,生成一个全新副本
var order3 = order1 with { };

    // Record自动实现值相等,对比所有属性的值,不是对比内存引用
Console.WriteLine($"order1 == order3:{order1 == order3}"); // True,所有字段完全一样
Console.WriteLine($"order1 == order2:{order1 == order2}"); // False,配送地址Detail不一样

    // ===================== 语法3:解构 Deconstruct =====================
    // Record自动生成Deconstruct解构方法,一次性把属性拆到多个变量
    var (orderId, supplierCode, _, _, _) = order1;
// _ 是弃元,不需要接收该属性,直接丢弃
Console.WriteLine($"订单ID:{orderId},供应商:{supplierCode}");

    // ===================== 特性4:可直接作为Dictionary的Key =====================
    // 因为record自动重写Equals与GetHashCode,基于值比较,适合做缓存Key
var orderCache = new Dictionary<PurchaseOrder, string> { [order1] = "已提交审核" };
// order3是order1拷贝出来的,值完全相同,可以命中缓存
Console.WriteLine($"order3缓存状态:{orderCache[order3]}");
}

语法注意事项

  1. 引用类型本质:

record默认是引用类型,仍然分配在堆上,会产生 GC;不可变是语法约束,不是性能优化手段。

  1. 浅拷贝限制:

with表达式是浅拷贝,引用类型成员只复制引用;包含集合时建议使用不可变集合(IReadOnlyList、ImmutableList),避免共享修改。

  1. 值相等的嵌套坑:

如果 record 中包含自定义引用类型成员,该成员仍按引用相等比较;要保证完整值相等,所有嵌套成员都必须实现值相等(如都用 record)。

  1. 不可变性是编译级:

init属性仅在编译期限制修改,通过反射仍可修改,不能作为安全边界,仅用于业务约束。

  1. 敏感信息风险:

编译器自动生成的ToString会输出所有属性值,包含密码、手机号的 record 必须重写ToString,防止日志泄露。

  1. 继承相等性:

子类 record 与父类 record 比较时,会先校验类型,类型不同直接返回 false;避免跨继承层级的相等判断。

  1. 业务边界:

record 定位是贫血数据载体,不建议编写复杂业务逻辑,业务逻辑应放在服务类中。

  1. struct 与 record class 简析(C# 9+、10+)

知识点

  1. 类型定位对比

      1.   record struct(C# 10+):值类型记录,结合了 struct 的值语义与 record 的语法糖(值相等、with、解构),是轻量级高性能数据载体。
      1.   record class:即 C# 9 的record,引用类型记录,面向数据传输与领域建模。
  2. 企业选型核心差异

维度record structrecord class
类型本质值类型,默认栈分配引用类型,堆分配
GC 影响栈上无 GC,装箱时产生 GC全程受 GC 管理
默认可变性属性默认可读写(值类型惯例)属性默认 init-only(不可变)
继承不支持继承,仅可实现接口支持单继承
副本行为赋值时全量值拷贝赋值时仅拷贝引用
适用体积小数据(建议≤32 字节)任意大小,大数据更优
  1. 进阶最优实践:readonly record struct 只读值类型记录,兼具不可变性与高性能,可完全替代手动实现IEquatable的普通 struct,是高频计算场景的首选。

  2. 典型企业场景

      1.   record struct:高频计算中间结果、表格单元格数据、方法多返回值结构化包装、批量数据处理元素。
      1.   record class:API DTO、领域值对象、事件消息、配置对象、长生命周期数据。

示例 金融行情系统汇率快照(record struct) + 客户信息 DTO(record class)

分别演示两种 record 的典型企业场景:高频行情处理用值类型降本增效,客户信息用引用类型支持继承扩展。

// ========== 场景1:高频汇率行情 - readonly record struct ==========
/// <summary>
/// 汇率快照:百万级每秒的行情数据,零GC开销
/// </summary>
public readonly record struct ExchangeRateSnapshot
{
    public string CurrencyPair { get; init; } // 货币对,如USD/CNY
    public decimal BidPrice { get; init; }    // 买入价
    public decimal AskPrice { get; init; }    // 卖出价
    public long TimestampUnixMs { get; init; } // 毫秒级时间戳
    public decimal MidPrice => (BidPrice + AskPrice) / 2m; // 计算中间价
}

/// <summary>
/// 行情计算器:批量处理汇率快照
/// </summary>
public static class ExchangeRateCalculator
{
    /// <summary>
    /// 计算指定货币对的小时均价
    /// </summary>
    public static decimal CalculateHourlyAverage(Span<ExchangeRateSnapshot> snapshots, string currencyPair)
    {
        decimal sum = 0m;
        int count = 0;

        foreach (ref readonly var snap in snapshots)
        {
            if (string.Equals(snap.CurrencyPair, currencyPair, StringComparison.Ordinal))
            {
                sum += snap.MidPrice;
                count++;
            }
        }
        return count == 0 ? 0m : sum / count;
    }
}

// ========== 场景2:客户信息DTO - record class ==========
/// <summary>
/// 基础审计DTO:供所有业务DTO继承
/// </summary>
public abstract record BaseDto
{
    public Guid Id { get; init; }
    public DateTimeOffset CreatedAt { get; init; }
    public string? CreatedBy { get; init; }
    public DateTimeOffset? UpdatedAt { get; init; }
}

/// <summary>
/// 客户信息DTO:继承基础审计字段
/// </summary>
public record CustomerDto : BaseDto
{
    public string CustomerName { get; init; }
    public string ContactPhone { get; init; }
    public string Email { get; init; }
    public int CustomerLevel { get; init; }

    /// <summary>
    /// 工厂方法:创建新客户DTO
    /// </summary>
    public static CustomerDto Create(string name, string phone, string email, string operatorName)
    {
        return new CustomerDto
        {
            Id = Guid.NewGuid(),
            CreatedAt = DateTimeOffset.Now,
            CreatedBy = operatorName,
            CustomerName = name,
            ContactPhone = phone,
            Email = email,
            CustomerLevel = 1
        };
    }
}

// 调用演示
public static void RecordStructDemo()
{
    // --- record struct 演示 ---
    Span<ExchangeRateSnapshot> rates = stackalloc ExchangeRateSnapshot[50];
    var baseTime = DateTimeOffset.Now.ToUnixTimeMilliseconds();
    for (int i = 0; i < rates.Length; i++)
    {
        rates[i] = new ExchangeRateSnapshot
        {
            CurrencyPair = i % 2 == 0 ? "USD/CNY" : "EUR/CNY",
            BidPrice = 7.25m + i * 0.001m,
            AskPrice = 7.26m + i * 0.001m,
            TimestampUnixMs = baseTime + i * 100
        };
    }

    // with表达式生成新快照
    var updatedRate = rates[0] with { BidPrice = 7.245m, AskPrice = 7.255m };
    var avgUsd = ExchangeRateCalculator.CalculateHourlyAverage(rates, "USD/CNY");
    Console.WriteLine($"USD/CNY小时均价:{avgUsd:F4}");

    // --- record class 演示 ---
    var customer1 = CustomerDto.Create("张三", "13800138000", "zhangsan@example.com", "admin");
    // 非破坏性修改:升级客户等级
    var customer2 = customer1 with { CustomerLevel = 2, UpdatedAt = DateTimeOffset.Now };
    Console.WriteLine($"客户等级变更前:{customer1.CustomerLevel},变更后:{customer2.CustomerLevel}");
}

语法注意事项

  1. 默认可变性差异:record struct的属性默认可读写,要不可变必须加readonly关键字,或手动声明init属性。
  2. 拷贝开销阈值:单实例超过 32 字节时,值拷贝性能会低于record class,需评估选型。
  3. 装箱行为:record struct赋值给object、接口时会发生装箱,和普通 struct 一致;高频场景避免装箱。
  4. 继承限制:record struct不能继承也不能被继承;需要继承的场景只能用record class。
  5. with 本质区别:record struct的with是创建新的值副本,全字段复制;record class的with是创建新的堆对象,浅拷贝引用。
  6. 选型原则:纯数据、小体积、高频创建、追求零 GC → 优先readonly record struct;需要继承、数据量大、生命周期长 → 优先record class。