C#基础(八):结构体(Struct)

结构体是 C# 中用户自定义的值类型,用于封装轻量的相关数据组。与作为引用类型的 class 不同,struct 直接存储数据本身,通常分配在线程栈上,无 GC 开销,但也因此有诸多限制与特殊行为。它是 C# 高性能编程的核心基础之一。

一、核心本质与内存特性

核心定义

结构体使用 struct 关键字声明,属于值类型,隐式继承自 System.ValueType(该类又继承自 object),并且天生是密封的(无法被继承)。

其最核心的特征是:变量直接存储数据本身,赋值时会创建完整的数据副本,两个变量相互独立。

内存分配

结构体的内存位置并非绝对在栈上,取决于它的声明场景:

  • 局部变量:分配在线程栈上,随方法执行结束自动释放,无 GC 压力
  • 类的字段:随所属类对象一起分配在托管堆上
  • 数组元素:直接存储在数组的连续内存中,而非单独的堆对象
  • 装箱后:被包装到托管堆上的对象中

日常开发中所说 “结构体在栈上”,指的是其作为局部变量时的典型场景,这也是结构体性能优势的主要来源。

赋值行为:值拷贝

这是结构体与类最本质的区别:赋值 = 完整复制所有字段数据,两个变量完全独立。

public struct Point
{
    public int X;
    public int Y;

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
}

// 演示值拷贝
Point p1 = new Point(3, 4);
Point p2 = p1; // 创建完整副本,p1 和 p2 是两个独立的数据
p2.X = 100;

Console.WriteLine(p1.X); // 输出 3,p1 不受 p2 修改影响
Console.WriteLine(p2.X); // 输出 100

对比引用类型的类:赋值仅复制内存地址,多个变量指向同一个堆对象,修改会相互影响。

默认值

结构体变量的默认值是「所有字段均为对应类型的零值」,无需手动初始化即可使用。

Point p; // 声明即可用,X 和 Y 默认都是 0
Console.WriteLine(p.X); // 输出 0,不会出现“未赋值变量”编译错误

二、结构体的成员与语法规则

支持的成员

结构体可以包含绝大多数类型成员,核心包括:

  • 字段、常量、只读字段
  • 属性、索引器
  • 方法、运算符重载、静态构造函数
  • 事件
  • 可以实现一个或多个接口

不支持的成员(核心限制)

  1. 不能继承其他类 / 结构体:天生密封,不能指定基类
  2. 不能被其他类 / 结构体继承:无法作为父类
  3. 不能声明 protected 成员:因为没有继承体系
  4. 不能声明实例析构函数:值类型由栈自动释放,不需要 GC 终结
  5. 不能声明虚方法 / 抽象方法:不支持多态(可以重写 object 的虚方法如 ToString()
  6. C# 10 之前不能自定义无参构造函数
// 一个标准的二维向量结构体
public struct Vector2
{
    // 字段
    public float X;
    public float Y;

    // 常量
    public const float Epsilon = 0.00001f;

    // 有参构造函数
    public Vector2(float x, float y)
    {
        X = x;
        Y = y;
    }

    // 只读计算属性
    public float Length => MathF.Sqrt(X * X + Y * Y);

    // 实例方法
    public void Normalize()
    {
        float len = Length;
        if (len > Epsilon)
        {
            X /= len;
            Y /= len;
        }
    }

    // 运算符重载
    public static Vector2 operator +(Vector2 a, Vector2 b)
    {
        return new Vector2(a.X + b.X, a.Y + b.Y);
    }

    // 重写 object 的方法
    public override string ToString()
    {
        return $"({X}, {Y})";
    }
}

三、构造函数详解

结构体的构造函数规则是最容易混淆的知识点,随着 C# 版本升级发生了重要变化。

1. 默认无参构造函数

  • C# 9 及以前:编译器始终生成默认无参构造函数,将所有字段初始化为零值,开发者不能自定义无参构造函数
  • C# 10 及以后:允许开发者自定义无参构造函数,但编译器仍会在你未定义时自动生成默认无参构造。
public struct Size
{
    public int Width;
    public int Height;

    // C# 10+ 支持自定义无参构造函数
    public Size()
    {
        Width = 100;
        Height = 100;
    }
}

// 注意:new 调用自定义无参构造
Size s1 = new Size(); // Width=100, Height=100
// default 关键字永远返回零值,不会调用构造函数
Size s2 = default; // Width=0, Height=0

高频坑点:default(Struct) 永远返回全零值,与构造函数无关。

2. 有参构造函数的强制规则

结构体的有参构造函数必须为所有实例字段赋值,不允许字段保持未初始化状态。

public struct Point
{
    public int X;
    public int Y;

    // 编译报错:构造函数退出前,字段 Y 未被赋值
    // public Point(int x)
    // {
    //     X = x;
    // }
}

3. 构造函数链:this 调用

和类一样,结构体也可以通过 this() 调用自身的其他构造函数。

public struct Point
{
    public int X;
    public int Y;

    public Point() : this(0, 0) { }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
}

4. 主构造函数(C# 12+)

结构体支持主构造函数语法,简化初始化逻辑。

// 主构造函数参数直接作为结构体的状态
public struct Point(int x, int y)
{
    public int X { get; set; } = x;
    public int Y { get; set; } = y;
}

四、只读结构体与只读成员(性能优化核心)

问题背景:防御性拷贝

当结构体作为 readonly 字段、in 参数使用时,如果调用它的普通方法,编译器无法确认方法是否会修改结构体状态,因此会创建一个临时副本再调用方法,避免原数据被修改,这就是「防御性拷贝」。对于大结构体,这会带来显著的性能损耗。

只读结构体(readonly struct,C# 7.2+)

使用 readonly 修饰结构体,强制所有实例字段都是只读的,所有实例方法都不能修改状态。编译器可以确认不会修改数据,从而跳过防御性拷贝,提升性能。

// 只读结构体:所有实例成员都不能修改状态
public readonly struct ReadonlyPoint
{
    // 字段必须是只读的
    public readonly int X;
    public readonly int Y;

    public ReadonlyPoint(int x, int y)
    {
        X = x;
        Y = y;
    }

    // 计算属性,不能修改值
    public double Length => Math.Sqrt(X * X + Y * Y);

    // 方法隐式只读,不能修改字段
    public override string ToString()
    {
        return $"({X}, {Y})";
    }
}

最佳实践:如果结构体是不可变的数据载体,务必加上 readonly 修饰,这是结构体性能优化的最重要手段之一。

只读实例方法(readonly 方法,C# 8.0+)

如果不想把整个结构体设为只读,可以单独给不修改状态的方法加上 readonly 修饰,调用该方法时不会产生防御性拷贝。

public struct Point
{
    public int X;
    public int Y;

    // 标记该方法不会修改结构体状态
    public readonly double GetLength()
    {
        // X = 10; // 编译报错,readonly 方法内不能修改字段
        return Math.Sqrt(X * X + Y * Y);
    }
}

五、ref 结构体(栈 - only 类型,C# 7.2+)

核心本质

ref struct 是一种特殊的值类型,只能分配在线程栈上,绝对不能逃逸到托管堆。它从语言层面保证了类型的栈 - only 约束,是高性能内存操作的基础。

.NET 内置的 Span<T>ReadOnlySpan<T> 就是典型的 ref 结构体。

强制限制

为了保证不逃逸到堆,编译器施加了严格限制:

  1. 不能被装箱(不能转成 objectValueType、接口类型)
  2. 不能作为普通类 / 普通结构体的字段
  3. 不能在 async 方法、迭代器方法中使用
  4. 不能作为泛型类型参数
  5. 不能被 Lambda 表达式捕获
// ref 结构体:只能在栈上使用
public ref struct BufferWrapper
{
    public Span<byte> Data;
    public int Length => Data.Length;

    public BufferWrapper(Span<byte> data)
    {
        Data = data;
    }
}

// 使用场景:栈上分配内存并包装
Span<byte> buffer = stackalloc byte[1024];
var wrapper = new BufferWrapper(buffer);

适用场景:高性能底层开发、临时内存包装、避免 GC 分配的热路径代码。普通业务开发极少需要自定义 ref struct。


六、record struct 记录结构体(C# 10+)

C# 9 引入了引用类型的 record,C# 10 补充了值类型版本的 record struct,专门用于不可变的值数据模型。

核心特性

  1. 值相等性:默认按字段值比较两个实例是否相等
  2. 非破坏性修改:支持 with 表达式,基于现有对象创建修改了部分属性的新对象
  3. 默认不可变:主构造参数默认生成 { get; init; } 属性
  4. 值类型:分配在栈上,无 GC 开销
// 记录结构体:位置语法定义主构造函数
public record struct Student(string Name, int Age);

// 使用
var s1 = new Student("Alice", 20);
var s2 = new Student("Alice", 20);

// 值比较:内容相同即相等
Console.WriteLine(s1 == s2); // True

// with 表达式:非破坏性修改
var s3 = s1 with { Age = 21 };
Console.WriteLine(s3.Name); // Alice
Console.WriteLine(s3.Age);  // 21

record class vs record struct

特性record classrecord struct
类型分类 引用类型 值类型
内存分配 托管堆 栈(局部变量)
相等性 重写为值比较 默认值比较
默认可变性 不可变(init) 不可变(init)
适用场景 复杂数据模型、需要继承 轻量数据、值语义

七、结构体与接口

结构体不能继承类,但可以实现接口。但需要注意:将结构体转换为接口类型时,会发生装箱,将值类型包装到堆上的对象中,带来性能损耗。

public interface IComparable<T>
{
    int CompareTo(T other);
}

public struct Point : IComparable<Point>
{
    public int X;
    public int Y;

    public int CompareTo(Point other)
    {
        return X.CompareTo(other.X);
    }
}

// 装箱:结构体转接口
Point p = new Point(1, 2);
IComparable<Point> iface = p; // 发生装箱,分配到堆上

避免装箱的技巧

使用泛型 + 接口约束,编译器会直接调用结构体的方法,不会发生装箱。

// 泛型方法:无装箱调用
int Compare<T>(T a, T b) where T : IComparable<T>
{
    return a.CompareTo(b);
}

Compare(p, new Point(3, 4)); // 无装箱,性能更高

八、经典坑点与避坑指南

1. 可变结构体 + 集合索引器:修改无效

这是新手最高频踩坑点:List<T> 的索引器是属性,get 访问器会返回结构体的副本,直接修改副本不会影响集合内的原始数据,编译器会直接报错。

List<Point> points = new List<Point>();
points.Add(new Point(1, 2));

// ❌ 编译报错:无法修改返回值,因为它不是变量
// points[0].X = 100;

// ✅ 正确做法:取出副本修改后重新赋值
Point temp = points[0];
temp.X = 100;
points[0] = temp;

// ✅ 数组索引可以直接修改,因为数组元素是直接存储的连续内存
Point[] arr = { new Point(1, 2) };
arr[0].X = 100; // 正常生效

2. 方法 / 属性返回结构体,修改不生效

方法返回的结构体是副本,直接修改副本没有意义。

Point GetPoint() => new Point(1, 2);

// ❌ 无意义,修改的是临时副本
GetPoint().X = 100;

3. 大结构体拷贝开销大

结构体赋值、传参都会完整拷贝所有字段,如果结构体超过 32 字节,拷贝开销会显著超过引用类型。

经验法则:结构体大小建议控制在 16 字节以内,超过 64 字节且频繁传递时,优先考虑用类或者 readonly ref struct

4. 不要在结构体中写引用类型字段

结构体本身是值语义,如果内部包含引用类型字段,赋值时只拷贝引用地址,会出现 “值类型里套引用类型” 的混合语义,极易产生 bug。

5. 作为方法参数时,默认是值传递

结构体作为参数默认是值传递,方法内修改不会影响原对象;需要修改原对象时要加 refin(只读传引用)。

// 传引用,避免大结构体拷贝,同时可修改原对象
void Transform(ref Point p)
{
    p.X *= 2;
}

// in 修饰:只读传引用,不拷贝也不允许修改
void Print(in Point p)
{
    Console.WriteLine(p.X);
}

九、选型原则:struct vs class

优先使用 struct 的场景

  1. 类型的核心职责是存储数据,行为很少或没有
  2. 实例体积小(通常 ≤ 16 字节)、生命周期短
  3. 具有明确的值语义,相等判断基于内容而非引用
  4. 不需要继承、不需要多态
  5. 高性能热路径,希望避免 GC 压力

优先使用 class 的场景

  1. 类型有复杂的行为和状态变化
  2. 实例体积较大,频繁传递
  3. 需要继承、多态、面向抽象编程
  4. 需要标识同一性(同一个对象的引用)
  5. 生命周期较长,需要在多个地方共享同一个实例

总结:struct 是「数据的载体」,class 是「对象的模板」。不要为了 “性能” 盲目使用 struct,不合适的结构体反而会因为拷贝、装箱带来更多问题。

posted @ 2026-07-23 10:17  挺秃然的i  阅读(2)  评论(0)    收藏  举报