Unity中的结构体(C#)
发表于2018-07-18
结构体和类很像,但又完全不同如果大家对结构体不甚了解,但又想用结构体,也许你不知道传引用和传值的差别,那么本篇文章对结构体的介绍和使用一定能帮到你。
Unity中的结构体
既然这个系列是为了Unity而学习C#的,那先来了解一下,那些已经使用了结构体的地方吧。
- Vector2, Vector3 和 Vector4
- Rect
- Color和Color32
- Bounds
- Touch
尤其,各种形式的Vector(2-4)使用的非常广泛。你会发现它们被用于存储各种信息,从变换的位置、旋转、大小,到刚体的速度,或者触摸、点击的屏幕位置。
什么是结构体
结构体是一种复合数据类型。它和类很像,你可以用相同的方式定义域和方法。下面的例子定义了一个结构体和一个类,它们几乎是一样的
public struct PointA { public int x; public int y; }
public class PointB { public int x; public int y; }
在这个例子中,最显著区别就是关键字——“struct”而不是“class”。其他区别包括:
- 结构体不能从基类继承,但类可以
- 结构体不能有无参构造函数
- 在构造函数结束之前,所有的结构体域都必须被赋值
- 结构体是传值,而类的实例是传引用
最后一点,对我来说也是最重要一点。“值”类型和“引用”类型之间有很显著的差别,它会影响到应该何时及如何使用它们。
引用类型
当说到类的实例是传引用时,实际过程是,先获取一个指针,它指向对象在内存中的地址,然后传递这个指针。这很重要,因为一个类的实例,实际上可能很大,包含了很多域甚至其他对象。在这种情况下,赋值和传递整个实例可能非常影响性能,这就为什么要用传地址来替代。
引用类型在“堆”上分配,在调用“垃圾回收”时被清理。垃圾回收是一个自动的过程,但是它很慢,通常会降游戏的帧率。基于这个原因,最好不要频繁创建对象并让它们超出作用域。下面的例子就是一个大忌:
//最好别这样做 void Update () { //在Update循环中创建局部作用域的类实例(每帧调用) List<GameObject> objects = new List<GameObject>(); //假设对这个对象列表执行了一些操作(可能是填充、迭代等) for (int i = 0; i < objects.Count; ++i) { } //当方法结束时,对象列表超出作用域,有时有这种需求 //执行垃圾回收 }
值类型
说到传值时,实际过程是,对这个变量进行全克隆/拷贝,然后传递这个副本,原始值不变。结构体就是值类型,它是传值的。这意味着,结构体是理想的小型数据结构。
值类型在“栈”的分配,这意味着它们的内存很容易被回收,它们不受“垃圾回收”的影响。和Update循环例子中的引用类型不同,创建值类型是完全合理的,它们超出作用域也不必担心帧率下降或内存问题。下面的例子就是完全合理的:
//这样是可以的 void Update () { //创建一个值类型的局部变量——结构体 Vector3 offset = new Vector3 (UnityEngine.Random.Range (-1, 1), 0, 0); //对它执行操作 Vector3 pos = transform.localPosition; pos += offset * Time.deltaTime; transform.localPosition = pos; //当超出作用域,你的结构体内存很容易被回收 }
陷阱
人们很容易像使用类的实例一样使用结构体,但是因为它是值传递,可能会经常遇见一些陷阱。看看下面的例子:
using UnityEngine; using System.Collections; public class Demo : MonoBehaviour { public Vector3 v1; public Vector3 v2 { get; private set; } void Start () { v1.Set(1,2,3); v1.x = 4; v2.Set(1,2,3); // ** (Note 2) v2.x = 4; // * (Note 1) Debug.Log(v1.ToString()); Debug.Log(v2.ToString()); } }
* (Note 1)这一行会导致程序无法编译。你会看到错误提示“错误CS1612:不能修改’Demo.v2’返回的值类型。考虑将该值存储到临时变量中”。编译器保护你远离一个逻辑错误(这个我稍后会解释),并建议你先创建一个新的结构体,修改新的结构体,然后将它赋值给你原本想要修改的那个。
** (Note 2)更为危险,因为它会编译通过并运行,但实际上它并未生效。
如果代码编译通过并运行,应该会看到如下输出结果:
(4.0, 2.0, 3.0)
(0.0, 0.0, 0.0)
这可能并不是你预期的。所以,发生了什么?C#为‘v2’自动创建了一个隐藏的backer属性。当你使用getter时(通过简单地引用‘v2’),C#提供了一个backer的副本,而不是真正的backer——记住这是因为结构体是传值而不是传引用。在Note2这一行,实际是,你获得了一个backer的副本,在这里修改了副本,之后这些信息立即丢失了,因为它们并没有被赋值回去。
下面的例子也一样——它说明了引用类型和值类型的概念,通常是如何被忽视并导致问题的。这里我们持有一个列表的引用,它持有一个Vector3的引用。
usingUnityEngine; usingSystem.Collections; usingSystem.Collections.Generic; public class Demo : MonoBehaviour { voidStart () { List<Vector3> coords = new List<Vector3>(); coords.Add( new Vector3(0, 0, 0) ); coords[0].Set(1, 2, 3); coords[0].x = 4; //错误CS1612(参考上例,注释掉本行编译) Debug.Log(coords[0].ToString()); //输出(0.0, 0.0, 0.0),并非预期值! } }
相比之下,下面的例子将会按照预期运行(或者至少有了上一个例子作为恐吓或混淆你应该有所预期)
usingUnityEngine; usingSystem.Collections; public class Foo { public Vector3 pos; } public class Demo : MonoBehaviour { voidStart () { Foo myFoo = new Foo(); myFoo.pos.Set(1, 2, 3); myFoo.pos.x = 4; //没有编译错误 Debug.Log(myFoo.pos.ToString()); //输出(4.0, 2.0, 3.0),和预期一致 } }
为什么这个例子正常而另一个不是呢?答案就是,因为我们使用的是‘myFoo’的引用——而不是对象域的引用。这个对象直接持有了结构体的值(作为一个域),并直接修改它,并不会产生错误。
是否应该让Vector3作为Foo的一个属性,而不是一个域(即使是一个指定了backing的域)?这是个问题——看看下面的例子:
usingUnityEngine; usingSystem.Collections; public class Foo { public Vector3 pos { get{ return _pos; } set{ _pos = value; } } private Vector3 _pos; } public class Demo : MonoBehaviour { void Start () { Foo myFoo = new Foo(); myFoo.pos.Set(1, 2, 3); myFoo.pos.x = 4; //错误CS1612(参考上例,注释掉本行编译) Debug.Log(myFoo.pos.ToString()); //输出(0.0, 0.0, 0.0),并非预期值! } }
这些问题很多是可以缓解的,如果你能够将结构体视为“不可变”的(这意味着绝不改变任何域的值),或将它们定义为不可变的(如果它只是你的结构体)。
总结
本课介绍了结构体,并比较了何时、何处及为何要使用它而不是类。还展示了一些结构体的限制和陷阱,但也有它们的好处。正确地使用结构体,它是非常重要高效的工具,把它加入到你的编程中吧。
原文链接:https://theliquidfire.wordpress.com/2015/03/23/structs/
原文作者:Jonathan Parham