当前位置: 首页 > news >正文

C#结构体(Struct)深度解析:轻量数据容器与游戏开发应用 (Day 20)

Langchain系列文章目录

01-玩转LangChain:从模型调用到Prompt模板与输出解析的完整指南
02-玩转 LangChain Memory 模块:四种记忆类型详解及应用场景全覆盖
03-全面掌握 LangChain:从核心链条构建到动态任务分配的实战指南
04-玩转 LangChain:从文档加载到高效问答系统构建的全程实战
05-玩转 LangChain:深度评估问答系统的三种高效方法(示例生成、手动评估与LLM辅助评估)
06-从 0 到 1 掌握 LangChain Agents:自定义工具 + LLM 打造智能工作流!
07-【深度解析】从GPT-1到GPT-4:ChatGPT背后的核心原理全揭秘

PyTorch系列文章目录

Python系列文章目录

C#系列文章目录

01-C#与游戏开发的初次见面:从零开始的Unity之旅
02-C#入门:从变量与数据类型开始你的游戏开发之旅
03-C#运算符与表达式:从入门到游戏伤害计算实践
04-从零开始学C#:用if-else和switch打造智能游戏逻辑
05-掌握C#循环:for、while、break与continue详解及游戏案例
06-玩转C#函数:参数、返回值与游戏中的攻击逻辑封装
07-Unity游戏开发入门:用C#控制游戏对象移动
08-C#面向对象编程基础:类的定义、属性与字段详解
09-C#封装与访问修饰符:保护数据安全的利器
10-如何用C#继承提升游戏开发效率?Enemy与Boss案例解析
11-C#多态性入门:从零到游戏开发实战
12-C#接口王者之路:从入门到Unity游戏开发实战 (IAttackable案例详解)
13-C#静态成员揭秘:共享数据与方法的利器
14-Unity 面向对象实战:掌握组件化设计与脚本通信,构建玩家敌人交互
15-C#入门 Day15:彻底搞懂数组!从基础到游戏子弹管理实战
16-C# List 从入门到实战:掌握动态数组,轻松管理游戏敌人列表 (含代码示例)
17-C# 字典 (Dictionary) 完全指南:从入门到游戏属性表实战 (Day 17)
18-C#游戏开发【第18天】 | 深入理解队列(Queue)与栈(Stack):从基础到任务队列实战
19-【C# 进阶】深入理解枚举 Flags 属性:游戏开发中多状态组合的利器
20-C#结构体(Struct)深度解析:轻量数据容器与游戏开发应用 (Day 20)


文章目录

  • Langchain系列文章目录
  • PyTorch系列文章目录
  • Python系列文章目录
  • C#系列文章目录
  • 前言
  • 一、揭开结构体 (Struct) 的面纱
    • 1.1 结构体的定义与基本语法
      • 1.1.1 什么是结构体?
      • 1.1.2 基本语法
    • 1.2 结构体的核心特性:值类型语义
      • 1.2.1 内存分配
      • 1.2.2 赋值即复制
      • 1.2.3 继承限制
  • 二、结构体 (Struct) vs 类 (Class):深度辨析
    • 2.1 核心差异:值类型 vs 引用类型
      • 2.1.1 内存分配详解 (Memory Allocation Explained)
      • 2.1.2 赋值行为的实例对比
    • 2.2 性能考量 (Performance Considerations)
      • 2.2.1 结构体的潜在优势
      • 2.2.2 结构体的潜在劣势
  • 三、结构体的适用场景
    • 3.1 何时应该优先考虑结构体?
      • 3.1.1 逻辑上代表单个值
      • 3.1.2 实例尺寸较小
      • 3.1.3 不可变性 (Immutability)
      • 3.1.4 实例数量巨大且生命周期短
    • 3.2 应该避免使用结构体的场景
  • 四、实战应用:游戏开发中的坐标存储
    • 4.1 定义坐标结构体:`Vector2D`
    • 4.2 在游戏逻辑中使用
    • 4.3 Unity 中的 Vector2 / Vector3
  • 五、常见问题与注意事项
    • 5.1 装箱与拆箱的性能陷阱 (Boxing and Unboxing Performance Trap)
    • 5.2 可变结构体的修改困境 (Mutable Struct Modification Issues)
    • 5.3 `readonly` 结构体与防御性复制 (Readonly Structs and Defensive Copies)
  • 六、总结


前言

欢迎来到我们 C# 学习系列的第 20 天!在前几周,我们探索了 C# 的基础语法、面向对象的核心概念(类、继承、多态、接口)以及常用的数据结构(数组、List、Dictionary 等)。今天,我们将聚焦于一个既基础又关键的概念——结构体 (Struct)

结构体在 C# 中扮演着“轻量级数据容器”的角色,它与我们熟悉的类 (Class) 既有相似之处,也存在本质的区别。理解结构体的特性、适用场景,以及它与类的不同,对于编写高效、健壮的 C# 代码至关重要,尤其在对性能敏感的游戏开发领域。

本文将带你深入理解:

  • 什么是结构体及其基本语法?
  • 结构体与类的核心差异在哪里?(内存、赋值、继承等)
  • 何时应该选择使用结构体?
  • 结构体在游戏开发中的实际应用(以存储 2D 坐标为例)。
  • 使用结构体时需要注意的常见问题和陷阱。

无论你是 C# 新手,还是希望深化理解的进阶开发者,相信本文都能为你带来清晰的认识和实用的指导。让我们开始今天的学习吧!

一、揭开结构体 (Struct) 的面纱

在我们深入探讨结构体与类的区别之前,首先需要明确结构体到底是什么,以及它的基本用法。

1.1 结构体的定义与基本语法

1.1.1 什么是结构体?

在 C# 中,结构体 (Struct) 是一种值类型 (Value Type) 的数据结构。你可以把它想象成一个轻量级的“盒子”,专门用来封装一组相关的数据字段和方法。与类(引用类型)不同,结构体主要用于表示那些“像值一样”的数据,比如坐标、颜色、复数等。

核心特点:作为值类型,结构体变量直接持有其数据。当你将一个结构体变量赋值给另一个,或者将其作为参数传递给方法时,系统会创建该结构体的一个完整副本

1.1.2 基本语法

定义一个结构体非常简单,使用 struct 关键字即可:

// 定义一个表示二维坐标点的结构体
public struct Point2D
{
    // 公共字段 (也可以使用属性)
    public int X;
    public int Y;

    // 构造函数 (用于初始化)
    public Point2D(int x, int y)
    {
        X = x;
        Y = y;
    }

    // 可以包含方法
    public void Display()
    {
        Console.WriteLine($"Point Coordinates: ({X}, {Y})");
    }

    // 甚至可以重写方法 (例如 ToString)
    public override string ToString()
    {
        return $"({X}, {Y})";
    }
}

代码解释:

  • public struct Point2D: 使用 struct 关键字声明了一个名为 Point2D 的公共结构体。
  • public int X; public int Y;: 定义了两个公共整型字段 XY,用于存储坐标值。在实际应用中,更推荐使用属性({ get; set; })来封装数据,以提供更好的控制。
  • public Point2D(int x, int y): 定义了一个构造函数,方便在创建结构体实例时进行初始化。注意:在 C# 10 之前,结构体不能显式声明无参构造函数(系统会自动提供一个将所有字段初始化为默认值的无参构造函数)。C# 10 及以后版本允许显式定义无参构造函数。
  • public void Display(): 结构体可以包含方法,就像类一样。
  • public override string ToString(): 重写了 ToString 方法,方便调试和输出。

实例化与使用:

// 使用 new 关键字和构造函数创建实例
Point2D p1 = new Point2D(10, 20);

// 也可以不使用 new(仅当没有自定义构造函数或想使用默认值时)
// 所有字段会被初始化为其默认值 (int 默认为 0)
Point2D p2;
p2.X = 5; // 需要在使用前确保所有字段都被赋值
p2.Y = 15; // 如果不赋值就直接使用 p2.X 或 p2.Y 会编译错误

// 调用方法
p1.Display(); // 输出: Point Coordinates: (10, 20)
Console.WriteLine(p2.ToString()); // 输出: (5, 15)

1.2 结构体的核心特性:值类型语义

理解结构体最关键的一点是它的值类型 (Value Type) 行为。这直接影响了它在内存中的存储方式和赋值行为。

1.2.1 内存分配

  • 通常在栈上分配 (Stack Allocation): 当结构体变量在方法内部声明时,其实例通常直接在栈 (Stack) 上分配内存。栈是一种后进先出 (LIFO) 的内存区域,分配和回收速度非常快。
  • 作为类成员时在堆上: 如果结构体是某个类 (引用类型) 的成员变量,那么它会随着该类实例一起存储在堆 (Heap) 上。

1.2.2 赋值即复制

这是值类型最核心的行为。当你把一个结构体变量赋值给另一个时,会创建该结构体实例的一个全新副本

Point2D originalPoint = new Point2D(100, 200);
Point2D copiedPoint = originalPoint; // 这里发生了复制!

// 修改副本的值
copiedPoint.X = 150;

// 检查原始点的值,它没有被改变
Console.WriteLine($"Original Point: {originalPoint}"); // 输出: Original Point: (100, 200)
Console.WriteLine($"Copied Point: {copiedPoint}");   // 输出: Copied Point: (150, 200)

这个特性与类(引用类型)完全不同。如果是类,赋值操作只会复制引用(内存地址),两个变量将指向堆上的同一个对象。

1.2.3 继承限制

结构体不能从其他类或结构体继承(它们隐式地继承自 System.ValueType,而 System.ValueType 又继承自 System.Object)。但是,结构体可以实现接口。

public interface IMovable
{
    void Move(int dx, int dy);
}

public struct MovablePoint : IMovable // 结构体可以实现接口
{
    public int X;
    public int Y;

    public void Move(int dx, int dy)
    {
        X += dx;
        Y += dy;
    }
}

// public struct SpecialPoint : Point2D {} // 编译错误!结构体不能继承其他结构体或类

二、结构体 (Struct) vs 类 (Class):深度辨析

现在我们已经了解了结构体的基本概念和特性,是时候将它与我们更熟悉的类 (Class) 进行详细对比了。理解它们的差异对于做出正确的技术选型至关重要。

2.1 核心差异:值类型 vs 引用类型

这是结构体和类最根本的区别,由此衍生出其他所有差异。

特性结构体 (Struct)类 (Class)
类型种类值类型 (Value Type)引用类型 (Reference Type)
继承只能实现接口,不能被继承可以继承其他类,可以被继承
内存分配通常在栈上(局部变量)总是在堆上
赋值操作复制实例内容复制引用(地址)
默认值各成员为其类型的默认值 (e.g., 0, false)null
new操作符分配内存并调用构造函数分配内存、调用构造函数并返回引用
可空性需要使用 Nullable<T>T?可以直接为 null

2.1.1 内存分配详解 (Memory Allocation Explained)

  • 栈 (Stack): 速度快,生命周期通常与方法调用绑定。适合存储小型、生命周期短的数据。结构体作为局部变量时优先考虑栈。
  • 堆 (Heap): 速度相对较慢,需要垃圾回收器 (GC) 管理内存。适合存储大型、生命周期较长的数据。类的实例总是在堆上。

类比:

  • 结构体 (值类型): 像一张写着具体数值(比如 100 元)的现金。你把它给别人,就是把这张现金给了别人,你手里就没了(或者说给了别人一张一模一样的复印件)。
  • 类 (引用类型): 像一张写着保险箱地址的纸条。你把纸条给别人,你们俩现在都知道同一个保险箱在哪,都可以去操作里面的东西。

我们可以用 Mermaid 语法来形象地展示一下:

graph TD
    subgraph Stack (栈 - 快速, LIFO)
        A[方法 M 调用] --> B(int i = 10);
        A --> C(Point2D p1 = new Point2D(5, 5));
        C --包含X=5, Y=5--> C_Data(实际数据);
        A --> D(MyClass objRef);
    end

    subgraph Heap (堆 - 需GC管理)
        E(MyClass Instance);
        E --包含成员数据--> E_Data(对象数据);
    end

    D --指向--> E;

    style Stack fill:#f9f,stroke:#333,stroke-width:2px
    style Heap fill:#ccf,stroke:#333,stroke-width:2px

(注意:此图简化了概念,实际内存布局可能更复杂,尤其涉及闭包、迭代器等情况)

2.1.2 赋值行为的实例对比

让我们通过代码更直观地看看赋值行为的区别:

// --- 结构体 (值类型) ---
Point2D structP1 = new Point2D(1, 1);
Point2D structP2 = structP1; // 复制内容

structP2.X = 10; // 修改副本

Console.WriteLine($"Struct P1: {structP1}"); // 输出: Struct P1: (1, 1) - 未受影响
Console.WriteLine($"Struct P2: {structP2}"); // 输出: Struct P2: (10, 1)

// --- 类 (引用类型) ---
public class PointClass
{
    public int X;
    public int Y;
    public PointClass(int x, int y) { X = x; Y = y; }
    public override string ToString() => $"({X}, {Y})";
}

PointClass classP1 = new PointClass(1, 1);
PointClass classP2 = classP1; // 复制引用 (地址)

classP2.X = 10; // 修改同一个对象

Console.WriteLine($"Class P1: {classP1}"); // 输出: Class P1: (10, 1) - 受到影响
Console.WriteLine($"Class P2: {classP2}"); // 输出: Class P2: (10, 1)

这个例子清晰地展示了修改 structP2 不会影响 structP1,而修改 classP2 会同时影响 classP1,因为它们指向堆上的同一个对象。

2.2 性能考量 (Performance Considerations)

选择结构体还是类,性能是一个重要的考量因素,但这并非绝对:

2.2.1 结构体的潜在优势

  • 减少垃圾回收 (GC) 压力: 由于栈分配的结构体生命周期通常较短且管理简单,可以减少堆内存分配,从而减轻 GC 的负担。对于大量创建和销毁的小对象,这可能带来显著的性能提升。
  • 更好的数据局部性 (Data Locality): 如果结构体数组或包含结构体的类实例在内存中是连续存储的,CPU 缓存更容易命中,访问速度可能更快。

2.2.2 结构体的潜在劣势

  • 复制开销: 当结构体实例较大时,或者在方法调用、赋值操作中频繁复制时,复制整个实例内容的开销可能会超过引用类型复制引用的开销。
  • 装箱与拆箱 (Boxing/Unboxing): 当需要将结构体(值类型)当作引用类型(如 object 或接口类型)使用时,会发生装箱操作(在堆上创建一个包装对象并复制结构体数据)。反之,从引用类型转换回值类型则需要拆箱。频繁的装箱/拆箱会带来性能损耗。
Point2D p = new Point2D(3, 4);
object obj = p; // 装箱: 在堆上创建对象,并将p的值复制进去
Point2D p_unboxed = (Point2D)obj; // 拆箱: 从堆对象中取回值

性能总结: 没有绝对的“哪个更快”。需要根据具体场景评估:实例大小、生命周期、使用方式(是否频繁复制、是否需要装箱)等。

三、结构体的适用场景

理解了结构体与类的区别后,我们就能更好地判断何时应该使用结构体。

3.1 何时应该优先考虑结构体?

遵循以下原则可以帮助你做出决策:

3.1.1 逻辑上代表单个值

当你的类型在概念上更像一个单一的值时,结构体通常是合适的。比如:

  • 数字(虽然基础类型已经是结构体,但自定义的如复数 ComplexNumber
  • 几何坐标(Point, Vector2, Vector3
  • 颜色(RGBColor
  • 日期和时间(DateTime 本身就是结构体)
  • 键值对(KeyValuePair<TKey, TValue>

这些类型通常没有复杂的行为,主要用于存储和传递数据。

3.1.2 实例尺寸较小

虽然没有硬性规定,但微软官方文档曾建议,如果实例大小小于 16 字节,可以考虑使用结构体。这只是一个经验法则,更重要的是考虑复制开销。如果一个结构体很大,频繁复制它可能会导致性能下降。

如何估算大小? 大致等于其所有字段大小的总和(考虑内存对齐)。例如 struct Point { int X; int Y; } 大约是 8 字节(假设 int 是 4 字节)。

3.1.3 不可变性 (Immutability)

结构体非常适合表示不可变 (Immutable) 数据。不可变意味着一旦创建,其状态(字段值)就不能被修改。由于结构体赋值是复制操作,修改副本不会影响原始值,这天然地支持了不可变性。

// 设计为不可变的结构体
public readonly struct ImmutablePoint
{
    public int X { get; } // 使用 readonly 字段或 get-only 属性
    public int Y { get; }

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

    // 如果需要“修改”,则返回一个新的实例
    public ImmutablePoint Move(int dx, int dy)
    {
        return new ImmutablePoint(X + dx, Y + dy);
    }
}

使用 readonly struct 可以强制整个结构体不可变。

3.1.4 实例数量巨大且生命周期短

在某些高性能场景下,如果需要创建大量生命周期很短的对象(例如,某个计算过程中的临时坐标点),使用结构体可以避免大量的堆分配和 GC 开销。

3.2 应该避免使用结构体的场景

  • 实例尺寸大: 复制开销可能过高。
  • 需要继承: 结构体不支持类继承。
  • 需要引用语义: 如果你希望多个变量指向同一个对象实例,并且修改一个变量会影响其他变量,那么应该使用类。
  • 频繁装箱/拆箱: 如果你的代码需要经常将该类型实例转换为 object 或接口类型,使用类可能更高效。
  • 逻辑复杂性高: 如果类型需要包含大量逻辑、复杂的状态转换或与其他对象的复杂交互,类通常是更好的选择,因为它提供了更丰富的面向对象特性。

四、实战应用:游戏开发中的坐标存储

游戏开发是对性能要求较高的领域之一,结构体在这里找到了重要的应用场景,最典型的就是表示位置和方向。

4.1 定义坐标结构体:Vector2D

让我们模拟一下在 2D 游戏中表示坐标或向量的需求。虽然像 Unity 这样的引擎已经内置了优化的 Vector2Vector3 结构体,但我们自己实现一个简单的 Vector2D 可以帮助理解其原理。

using System; // 需要引入 System 命名空间以使用 Math.Sqrt

/// <summary>
/// 表示二维空间中的点或向量的结构体。
/// </summary>
public struct Vector2D
{
    // 使用公共属性封装数据,更符合 C# 规范
    public float X { get; set; }
    public float Y { get; set; }

    // 提供一个方便的构造函数
    public Vector2D(float x, float y)
    {
        X = x;
        Y = y;
    }

    // 提供一个静态只读属性表示零向量 (0, 0)
    public static readonly Vector2D Zero = new Vector2D(0f, 0f);

    /// <summary>
    /// 计算当前点到另一个点的距离。
    /// </summary>
    /// <param name="other">另一个点。</param>
    /// <returns>两点之间的距离。</returns>
    public float DistanceTo(Vector2D other)
    {
        float dx = this.X - other.X; // 使用 this 明确表示当前实例的成员
        float dy = this.Y - other.Y;
        // 平方根计算,需要引入 System 命名空间
        // 使用 $$ 公式 $$ 包裹公式
        // 距离公式:$$d = \sqrt{(x_2-x_1)^2 + (y_2-y_1)^2}$$
        return (float)Math.Sqrt(dx * dx + dy * dy);
    }

    /// <summary>
    /// 返回向量的长度(模)。
    /// </summary>
    public float Magnitude()
    {
        // 长度公式:$$||\vec{v}|| = \sqrt{x^2 + y^2}$$
        return (float)Math.Sqrt(X * X + Y * Y);
    }

    /// <summary>
    /// 返回一个新的规范化向量(单位向量)。
    /// 如果原向量是零向量,则返回零向量。
    /// </summary>
    public Vector2D Normalized()
    {
        float mag = Magnitude();
        if (mag > 1E-6f) // 避免除以零或非常小的数
        {
            return new Vector2D(X / mag, Y / mag);
        }
        else
        {
            return Zero; // 返回零向量
        }
    }

    // 重载运算符,使向量运算更直观
    public static Vector2D operator +(Vector2D a, Vector2D b)
    {
        return new Vector2D(a.X + b.X, a.Y + b.Y);
    }

    public static Vector2D operator -(Vector2D a, Vector2D b)
    {
        return new Vector2D(a.X - b.X, a.Y - b.Y);
    }

    public static Vector2D operator *(Vector2D vec, float scalar)
    {
        return new Vector2D(vec.X * scalar, vec.Y * scalar);
    }

    public static Vector2D operator *(float scalar, Vector2D vec)
    {
        return new Vector2D(vec.X * scalar, vec.Y * scalar); // 支持 scalar * vector
    }

    // 重写 ToString() 方法,方便调试输出
    public override string ToString()
    {
        return $"({X:F2}, {Y:F2})"; // 格式化输出,保留两位小数
    }

    // 结构体也应该重写 Equals 和 GetHashCode
    public override bool Equals(object obj)
    {
        return obj is Vector2D other && Equals(other);
    }

    public bool Equals(Vector2D other)
    {
        // 使用浮点数比较,考虑精度问题
        return Math.Abs(X - other.X) < 1E-6f && Math.Abs(Y - other.Y) < 1E-6f;
    }

    public override int GetHashCode()
    {
        // 一个简单的哈希码计算方式
        return HashCode.Combine(X, Y); // 使用 System.HashCode 结构 ( .NET Core / .NET 5+)
        // 或者手动计算: return X.GetHashCode() ^ (Y.GetHashCode() << 2);
    }
}

代码解释:

  • 我们使用了 float 类型,因为游戏坐标通常是浮点数。
  • 添加了常用的向量操作,如计算距离 DistanceTo、获取模长 Magnitude、规范化 Normalized
  • 通过运算符重载 (Operator Overloading),我们可以直接使用 +, -, *Vector2D 进行运算,使代码更简洁易读。
  • 提供了静态只读属性 Zero 代表零向量。
  • 重写了 ToString(), Equals(), 和 GetHashCode(),这对于所有自定义类型(尤其是结构体)都是良好实践。EqualsGetHashCode 对于在集合(如 Dictionary)中使用结构体作为键或进行比较非常重要。

4.2 在游戏逻辑中使用

现在我们可以在模拟的游戏逻辑中使用这个 Vector2D 结构体:

// 模拟玩家和敌人的位置
Vector2D playerPosition = new Vector2D(10.5f, 20.1f);
Vector2D enemyPosition = Vector2D.Zero; // 初始化为 (0, 0)

// 移动敌人
enemyPosition = enemyPosition + new Vector2D(5.0f, 3.0f); // 使用重载的 '+' 运算符

// 计算玩家与敌人的距离
float distance = playerPosition.DistanceTo(enemyPosition);
Console.WriteLine($"Distance between player and enemy: {distance:F2}");

// 计算玩家朝向敌人的方向向量
Vector2D directionToEnemy = enemyPosition - playerPosition; // 使用重载的 '-' 运算符
Console.WriteLine($"Direction to enemy (raw): {directionToEnemy}");

// 获取单位方向向量 (Normalized)
Vector2D normalizedDirection = directionToEnemy.Normalized();
Console.WriteLine($"Normalized direction to enemy: {normalizedDirection}");

// 模拟子弹移动:当前位置 + 方向 * 速度 * 时间
float bulletSpeed = 50.0f;
float deltaTime = 0.1f; // 假设一帧的时间
Vector2D bulletPosition = playerPosition; // 子弹初始位置在玩家处 (复制值)
Vector2D bulletVelocity = normalizedDirection * bulletSpeed; // 子弹速度向量

bulletPosition = bulletPosition + bulletVelocity * deltaTime; // 更新子弹位置
Console.WriteLine($"Bullet position after {deltaTime}s: {bulletPosition}");

// 再次强调值类型复制
Vector2D pos1 = new Vector2D(1, 1);
Vector2D pos2 = pos1;
pos2.X = 5;
Console.WriteLine($"pos1: {pos1}, pos2: {pos2}"); // 输出: pos1: (1.00, 1.00), pos2: (5.00, 1.00)

为什么游戏引擎(如 Unity)选择结构体来表示 Vector?

  1. 性能: 坐标和向量运算在游戏中极其频繁。使用结构体可以减少堆分配和 GC 开销。
  2. 值语义: 逻辑上,一个点或一个向量就是一个值。复制一个位置或方向向量通常是期望的行为,而不是让多个对象共享同一个可变的位置引用。
  3. 小尺寸: Vector2 (2个 float) 和 Vector3 (3个 float) 的尺寸相对较小,复制开销可控。

4.3 Unity 中的 Vector2 / Vector3

如果你使用 Unity 引擎进行开发,你会发现它已经内置了 UnityEngine.Vector2UnityEngine.Vector3 结构体,它们功能完善且经过高度优化。原理与我们上面实现的 Vector2D 类似,但包含了更多针对游戏开发的功能(如点积、叉积、插值等)。

// 在 Unity 脚本中:
using UnityEngine; // 需要引入 UnityEngine 命名空间

public class PlayerMovement : MonoBehaviour
{
    public float speed = 5.0f;

    void Update()
    {
        // 获取输入
        float horizontal = Input.GetAxis("Horizontal"); // -1 to 1
        float vertical = Input.GetAxis("Vertical");   // -1 to 1

        // 创建移动向量 (使用 Unity 的 Vector3)
        // Vector3 是结构体!
        Vector3 movement = new Vector3(horizontal, 0f, vertical); // X, Y (up), Z

        // 标准化向量,使得斜向移动速度和直线移动一致
        movement = movement.normalized;

        // 计算位移
        // Time.deltaTime 是完成上一帧所花的时间 (秒)
        // transform.position 是 GameObject 的位置 (也是 Vector3 结构体)
        Vector3 displacement = movement * speed * Time.deltaTime;

        // 应用位移 (这里发生了结构体赋值)
        transform.position = transform.position + displacement;

        // Unity 的 Vector3 也是值类型
        Vector3 posA = transform.position;
        Vector3 posB = posA;
        posB.x += 1.0f; // 修改 posB 不会影响 posA 或 transform.position
    }
}

这个 Unity 示例再次印证了结构体在游戏开发核心组件(如位置、向量)中的普遍应用。

五、常见问题与注意事项

在使用结构体时,有一些常见的“坑”和需要注意的地方。

5.1 装箱与拆箱的性能陷阱 (Boxing and Unboxing Performance Trap)

如前所述,将结构体实例存入需要引用类型的地方(如 object 类型的变量、旧式的 ArrayList 集合、或者作为接口类型参数传递)会触发装箱 (Boxing)。这会在堆上分配内存并复制结构体数据,带来性能开销。

using System.Collections; // for ArrayList

ArrayList list = new ArrayList();
Point2D p = new Point2D(1, 1);

// 将结构体添加到 ArrayList 会导致装箱
list.Add(p); // <-- Boxing happens here!

// 从 ArrayList 取出时需要拆箱
Point2D pFromList = (Point2D)list[0]; // <-- Unboxing happens here!

// 解决方法:使用泛型集合!
List<Point2D> genericList = new List<Point2D>();
genericList.Add(p); // NO Boxing!

Point2D pFromGenericList = genericList[0]; // NO Unboxing!

最佳实践: 优先使用泛型集合(如 List<T>, Dictionary<TKey, TValue>)来存储结构体,避免不必要的装箱/拆箱。

5.2 可变结构体的修改困境 (Mutable Struct Modification Issues)

如果结构体是可变的(其字段或属性可以被修改),在某些情况下修改它可能会遇到问题,因为你可能在不知不觉中修改了一个副本

常见陷阱 1:修改集合中的结构体

List<Point2D> points = new List<Point2D>();
points.Add(new Point2D(5, 5));

// 错误尝试:直接修改 List 返回的结构体副本
// points[0].X = 10; // 编译错误! 因为 points[0] 返回的是 Point2D 的一个副本

// 正确做法:取出副本,修改副本,然后将修改后的副本存回去
Point2D temp = points[0];
temp.X = 10;
points[0] = temp; // 将修改后的副本替换原来的元素

Console.WriteLine(points[0]); // 输出: (10, 5)

常见陷阱 2:修改属性返回的结构体

public class GameObject
{
    // Position 属性返回一个 Vector2D 结构体
    public Vector2D Position { get; set; }
}

GameObject player = new GameObject();
player.Position = new Vector2D(0, 0);

// 错误尝试:直接修改返回的结构体副本的成员
// player.Position.X = 10; // 编译错误! (在旧版 C# 中可能编译通过但无效)
                         // 因为 player.Position 返回的是 Position 结构体的副本

// 正确做法:
Vector2D currentPos = player.Position; // 获取副本
currentPos.X = 10;                     // 修改副本
player.Position = currentPos;          // 将修改后的副本设置回去

// 或者让 Vector2D 提供修改方法
// public Vector2D SetX(float newX) { return new Vector2D(newX, Y); }
// player.Position = player.Position.SetX(10);

最佳实践:

  1. 优先考虑设计不可变结构体 (readonly struct)。 如果需要修改,让修改操作返回一个新的结构体实例。
  2. 如果必须使用可变结构体,要意识到在集合、属性访问等场景下可能操作的是副本,需要显式地取回、修改、再存回。

5.3 readonly 结构体与防御性复制 (Readonly Structs and Defensive Copies)

使用 readonly struct 声明的结构体,编译器会保证其字段在构造后不被修改。然而,当调用 readonly 结构体的非 readonly 成员(比如继承自 objectToString(),或者实现的接口方法没有标记为 readonly)时,编译器为了保证不修改原始 readonly 结构体,可能会创建一个防御性副本 (defensive copy),并在副本上调用该方法。这可能导致意外的性能开销。

public readonly struct ReadonlyPoint
{
    public int X { get; }
    public int Y { get; }

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

    // 如果 ToString 没有标记为 readonly (在 C# 8.0 之前总是这样)
    // 调用它可能会创建防御性副本
    public override string ToString()
    {
        // Console.WriteLine("ToString called - potentially on a copy"); // 用于调试
        return $"({X}, {Y})";
    }

    // C# 8.0+ 可以将成员标记为 readonly
    public readonly string ToReadOnlyString()
    {
        return $"Readonly ({X}, {Y})";
    }
}

// ...
ReadonlyPoint rp = new ReadonlyPoint(1, 1);
string s = rp.ToString(); // 可能触发防御性复制
string rs = rp.ToReadOnlyString(); // 不会触发复制

最佳实践: 在 C# 8.0 及以上版本中,为 readonly struct 的所有实例成员(方法、属性 getter)尽可能添加 readonly 修饰符,以避免不必要的防御性复制。

六、总结

经过今天的学习,我们深入了解了 C# 中的结构体 (Struct)。现在,让我们回顾一下核心要点:

  1. 结构体是值类型 (Value Type): 变量直接包含数据,赋值和传参时执行内容复制。通常在栈上分配(局部变量),生命周期管理高效,可减少 GC 压力。
  2. 与类的核心区别: 类是引用类型 (Reference Type),变量存储对象的引用(地址),赋值和传参时复制引用,实例总是在堆上分配,支持继承。
  3. 选择结构体的场景:
    • 逻辑上代表单个值(坐标、颜色、键值对等)。
    • 实例尺寸较小(经验法则 < 16 字节,需结合复制开销考虑)。
    • 倾向于不可变性 (Immutability)
    • 需要创建大量短生命周期实例的性能敏感场景。
  4. 游戏开发应用: 结构体常用于表示游戏中的坐标 (Vector2/Vector3)、旋转 (Quaternion)、颜色 (Color) 等,因其性能优势和值语义特性。Unity 引擎广泛使用结构体。
  5. 注意事项:
    • 警惕装箱/拆箱带来的性能损耗,优先使用泛型集合。
    • 理解可变结构体在修改时可能操作的是副本,推荐设计为不可变结构体 (readonly struct)。
    • readonly struct 中,注意非 readonly 成员可能导致的防御性复制,尽量将成员标记为 readonly (C# 8.0+)。

掌握结构体及其与类的区别,是编写高质量、高性能 C# 代码的关键一步,尤其对于资源敏感的游戏开发更是如此。希望今天的讲解能帮助你更自信地在项目中选择和使用结构体!


http://www.dtcms.com/a/113641.html

相关文章:

  • pinia-plugin-persist、vuex
  • Spring Boot项目连接MySQL数据库及CRUD操作示例
  • Java Timer:老派但好用的“定时任务小闹钟“
  • 【Linux】进程间通信、匿名管道、进程池
  • 将OpenFOAM中的lduMatrix数据转换为CSC稀疏矩阵格式
  • 混合编程的架构
  • Java EE期末总结(第三章)
  • Leedcode刷题 | 回溯算法小总结01
  • kali——masscan
  • Matlab轴承故障信号仿真与故障分析
  • spring-cloud-alibaba-nacos-config使用说明
  • 《K230 从熟悉到...》无线网络
  • LINUX 4 tar -zcvf -jcvf -Jcvf -tf -uf
  • Transformer+BO-SVM多变量时间序列预测(Matlab)
  • 力扣刷题——508.出现次数最多的子树和
  • Docker存储策略深度解析:临时文件 vs 持久化存储选型指南
  • 每日算法-250405
  • 4. 面向对象程序设计
  • 分布式事务解决方案全解析:从经典模式到现代实践
  • 每天五分钟深度学习框架pytorch:搭建LSTM完成手写字体识别任务?
  • 深入探索 Linux Top 命令:15 个实用示例
  • python中的sort使用
  • 在 macOS 上安装和配置 Aria2 的详细步骤
  • 【数学建模】(时间序列模型)ARIMA时间序列模型
  • tomcat的web三大组件Sciidea搭建web/maven的tomcat项目
  • grep命令: 过滤
  • 基于STM32与应变片的协作机械臂力反馈控制系统设计与实现----2.2 机械臂控制系统硬件架构设计
  • 自托管本地图像压缩器Mazanoke
  • (三)链式工作流构建——打造智能对话的强大引擎
  • 5天速成ai agent智能体camel-ai之第1天:camel-ai安装和智能体交流消息讲解(附源码,零基础可学习运行)