在第8章中,我们了解了C#支持的两种基本类型:值类型和引用类型。值类型的变量将值直接存储到栈上,而引用类型变量包含的则是引用(地址),引用本身存储在栈上,但该引用指向堆上的对象。创建引用类型的方法已经在第七章中讨论。在这一章中,我们将学习如何创建自己的值类型。
C#支持的值类型有两种:枚举和结构。接下来我们会逐一学习。
枚举类型
假设一个情景:你想要在程序中表示一年四季。固然你可以用整数 0,1,2,3
来分别表示春夏秋冬,这是可行的,但并不直观。而且,许多时候你在代码中已经使用过很多次整数值 0
,那么你将会搞不清楚一个特定的 0
到底代表裙裙还是一个 int
类型的值。此外,如果你使用一个 int
类型的变量存储季节,那么除了合法的 0,1,2,3
之外,诸如 114514
这样意义不明非法的数据也可以被赋值给它。因此,我们引入 enum
关键字用于创建一个枚举类型,枚举类型变量的取值限定于一组符号名称。
声明枚举
定义枚举要先写一个 enum
关键字,后跟一对 {}
,然后在打括号内添加一组符号名称,这些符号表示该枚举类型可以拥有的合法的值。下面的例子展示了如何声明 Season
枚举,其字面值限定于 Spring
,Summer
,Autumn
,Winter
这四个符号名称:
enum Season { Spring, Summer, Autumn, Winter }
使用枚举
声明好枚举之后,你可以像使用其他任何类型一样使用它。例如,对于上面的 Season
枚举,你可以声明一个 Season
类型的变量(字段、参数、局部变量…),就像这样:
Season colorful = Season.Autumn; Console.WriteLine(colorful); // 希望你还记得应该把这些代码放在哪里 :(
注意,你必须使用 Season.Autumn
而不是 Autumn
。每个枚举定义的字面值名称都只有这个枚举类型的作用域。这是个很有必要的设计,它让不同的枚举类型可以包含相同的字面值。
如果你执行了上面的代码,应该会发现,当你使用 Console.WriteLine()
方法显示枚举变量时,编译器会自动生成代码,输出和变量值匹配的字符串。如有必要,你可以调用所有枚举都有的 ToString()
方法,显式地将枚举变量转换成代表其当前值的字符串。
为什么所有枚举都定义了 ToString()
方法?
答案将在第12章中揭晓
很有趣的一点是,适用于 int
变量的许多标准操作符也适用于枚举变量。唯一例外的是按位和移位操作符(将在第16章讨论)。例如,你可以用 ==
比较同类型的两个枚举变量,甚至可以对它们进行算术运算(尽管多数都是意义不明的)。
选择枚举字面值
之所以枚举类型的变量具有一部分 int
变量的性质,是因为枚举内部的每个元素都对应着一个整数值,并且默认第一个元素对应整数 0
,以后的每个元素对应的整数值依次递增。事实上,你可以将枚举变量转型为基础类型,然后获取其基础整数值。在介绍拆箱时说过,将数据从一种类型转换为另一种类型,只要转换结果是有效的、有意义的,转型就会成功。例如,下面的代码会在控制台中输出数字 2
而不是单词 Autumn
:
Season colorful = Season.Autumn; Console.WriteLine((int)colorful); // 强制类型转换是什么时候介绍的来着...
选择枚举的基础类型
既然你已经知道枚举类型的变量会自动关联到 int
类型,那你是否会想到,能否将诸如 short
之类的其他类型关联到枚举类型呢?答案是肯定的,你可以让枚举类型关联到不同的基础整型。例如,你可以将 Season
的基础类型改为 short
:
enum Season : short { Spring, Summer, Autumn, Winter }
枚举可以基于8种整型(哪八种?)的任何一种。但需要注意,定义枚举时所有的字面值都不能超出所选基础类型的范围。假如你的枚举基于 byte
类型,那么你最多可以创建256个字面值。
下面是一个简单的例子,展示了枚举类型的各种特性:
using System; enum Season { Spring, Summer, Autumn, Winter } class Program { static void Main(string[] args) { Season season = Season.Autumn; Console.WriteLine($"The current season is {season}"); int seasonValue = (int)season; Console.WriteLine($"The value of {season} is {seasonValue}"); int nextSeasonValue = seasonValue + 1; Season nextSeason = (Season)nextSeasonValue; Console.WriteLine($"The next season is {nextSeason}"); if (season == Season.Autumn) { Console.WriteLine("It's time to enjoy the autumn foliage!"); } } } // 才不会告诉你这段代码是AI写的
其实还有一种称为位域枚举的特殊枚举,如果你感兴趣可以自己了解,它不在这篇教程的介绍范围内。
结构类型
在学习枚举后,我们来看另一种值类型——结构。在第8章中我们学到,类定义的是引用类型,总是在堆上创建。有时类只包含极少的数据,因为管理堆而造成的开销显得不合算。这时更好的做法是将类型定义成结构。结构是值类型,直接在栈上存储,能有效减少内存管理的开销(前提是该结构足够小)。
注意:不要滥用结构。它和类在行为上很相似,但许多时候它们之间的区别会给你造成很大的麻烦。
常用结构类型
你可能没有注意到,在先前的教程中,我们已经大量地使用了各种结构体。基元数据类型 int
,float
,long
分别是三个定义在 System
命名空间下的结构 Int32
,Single
,Int64
的别名。这些结构有自己的字段和方法,可以直接为这些类型的变量和字面值调用方法,例如,下面的代码尽管看起来怪,但是是合法的:
int i = 55; Console.WriteLine(i.ToString()); Console.WriteLine(55.ToString()); // 往往是合法但有病的写法.jpg
其实像这样调用 ToString()
方法的情况是很罕见的,因为 Console.WriteLine()
方法会在需要的时候自动调用它。更常见的情况是,我们需要将一个字符串 "123"
转换成 int
,这时我们会使用这些结构提供的静态方法。你是否还记得我们在之前的教程中经常用到的 xxx.Parse()
?它就是这些结构提供的静态方法,用于将字符串表示的值转换为对应类型的值。
string s = "42"; int i = int.Parse(s); // 完全等价于 Int32.Parse
除了诸如 Parse()
,TryParse()
这样的静态方法外,结构还包含一些有用的静态字段。例如,在 int
中定义了两个静态字段 int.MaxValue
和 int.MinValue
,分别表示 int
能容纳的最大和最小值。
在C#常见的基元数据类型中,除了 string
和 object
是引用类型外,其余的基本都是值类型(结构)。
声明结构
声明结构要以关键字 struct
开头,后跟类型名称,最后是大括号中的结构主体,和类的定义非常相似:
struct Time { public int hours, minutes, seconds; }
你也可以为结构声明构造器和方法,这一点上结构和类几乎没有区别:
struct Time { private int hours, minutes, seconds; public Time(int hh,int mm,int ss) { hours = hh % 24; minutes= mm % 60; seconds = ss % 60; } public int Hours() => hours; } // 这段代码看上去有些奇怪 // 不过原文大致如此
为什么说几乎呢?如果你手动敲了上面的代码,在编写构造器时,你可能发现VS有这样的报错提示:
我们暂且卖个关子,在下一节讨论结构和类的区别时再来说这个。
对于上面的例子,我们定义了一个看上去很奇怪的 Hours()
方法,仅仅是为了获得 hours
的值。然而,有许多C#教程都提到,应该尽量不使用 public
类型的字段,因为无法控制它们的值,换言之,你无法预料被赋给字段的值是否合法。因此,它们建议使用私有字段(或者后面会介绍的属性),并通过构造器和方法来初始化和处理它们。
不过根据群友的情况,似乎很少有modder遵循这一规则,因此你大概也可以无视罢(心虚)
此外,很多常用操作符都不能自动应用于自定义结构类型。例如,你无法对自己定义的结构类型使用 ==
和 !=
,但是结构都具有公开的方法 Equals()
方法来比较是否想到。你还可以为自己的结构类型显式声明并实现操作符,这种操作将在后面的章节中介绍。
理解结构和类的区别
如果一个概念的重点在于值而非功能,就用结构来实现。 ——《Visual C# 从入门到精通》
或许在上一节你就已经察觉到了结构和类的一些区别,在这一节中我们将进行详细的学习。
不能为结构声明默认构造器
(希望你还记得什么是默认构造器)
下面的两张图应该足以解释问题了
之所以不能为结构声明自己的默认构造器,是因为编译器始终都会自动生成一个。而在类中,仅当没有自己写构造器时,编译器才会自动生成默认构造器。编译器为结构生成的默认构造器总是将字段设为对应的默认值,如 int
设为数值 0
,引用类型设为 null
。
非默认构造器必须显式初始化所有字段
正如上面的例子所演示的那样,如果你在自己声明的构造器中没有对所有字段进行初始化,那么代码无法通过编译。然而,如果 Time
被定义为一个类,那么未赋值的字段将被悄悄地初始化为对应的默认值。
结构的字段不可在声明时初始化
如你所见,下面的代码是非法的:
同样地,如果 Time
是类,上面的写法是合法的。
结构类型的变量在复制时的行为
这是结构和类非常重要的一个区别,前面我们已经说过,结构和类在语法上是极其相似的,你可以像使用类那样使用结构,比如声明变量。接下来我们来看一段由GPT3.5生成的代码,它向你展示了结构和类在复制时的差异:
using System; public struct MyStruct { public int x; } public class MyClass { public int x; } class Program { static void Main(string[] args) { MyStruct struct1 = new MyStruct(); struct1.x = 1; MyStruct struct2 = struct1; struct2.x = 2; Console.WriteLine("struct1.x = " + struct1.x); // Output: struct1.x = 1 MyClass class1 = new MyClass(); class1.x = 1; MyClass class2 = class1; class2.x = 2; Console.WriteLine("class1.x = " + class1.x); // Output: class1.x = 2 } }
如果你运行代码,你会发现,在修改 struct2.x
的值后,struct1.x
的值仍然为 1。这是因为 struct1
和 struct2
是不同的对象,它们在内存中的位置不同,它们的值是相互独立的。而当我们修改 class2.x
的值时,class1.x
的值也被修改了。这是因为 class1
和 class2
引用同一个对象,它们的值是相同的,它们在内存中的位置相同。因此,当我们修改 class2.x
的值时,class1.x
的值也会发生变化。如果你不能理解上面的话,你可以重新阅读第8章的内容。本质上,这种差异正是值类型和引用类型的差异。
在复制值类型的变量时,=
操作符左侧结构变量的每个字段都直接从右侧结构变量的对应字段复制,而不是像类一样复制对象引用。因此,如果一个结构很大(包含过多的数据),在复制时可能导致更大的开销。
在什么时候使用结构
当你需要定义小型的、简单的、不可变的数据结构时,应该使用 struct
。
例如,为了表示二维平面上的一个点,你可以定义一个 Point
结构:
public struct Point { public int X { get; } public int Y { get; } public Point(int x, int y) { X = x; Y = y; } }
还有棱镜曾经用到过的一种二维向量的实现:
public struct Vector : IEquatable<Vector> { public float X; public float Y; public float PolarRadius { get => Length; set => Length = value; } public float Length { get { return (float)Math.Sqrt(LengthSquared); } set { double d = Angle; X = (float)(value * Math.Cos(d)); Y = (float)(value * Math.Sin(d)); } } public float LengthSquared { get { return X * X + Y * Y; } } public double Angle { get { return Math.Atan2(Y, X); } set { float len = Length; X = (float)(len * Math.Cos(value)); Y = (float)(len * Math.Sin(value)); } } public Vector(float x, float y) { X = x; Y = y; } public Vector(float xy) { X = Y = xy; } public static Vector FromPolar(double angle, float length) { return new Vector { X = (float)(Math.Cos(angle) * length), Y = (float)(Math.Sin(angle) * length) }; } public static Vector NewByPolar(double angle, float length) { return new Vector { X = (float)(Math.Cos(angle) * length), Y = (float)(Math.Sin(angle) * length) }; } public static Vector operator -(Vector me) { me.X = -me.X; me.Y = -me.Y; return me; } public static Vector operator -(Vector left, Vector right) { return (left.X - right.X, left.Y - right.Y); } public static Vector operator +(Vector left, Vector right) { return (left.X + right.X, left.Y + right.Y); } public static Vector operator *(Vector left, Vector right) { return (left.X * right.X, left.Y + right.Y); } public static Vector operator *(Vector vector, float scale) { vector.X *= scale; vector.Y *= scale; return vector; } public static Vector operator *(float scale, Vector vector) { vector.X *= scale; vector.Y *= scale; return vector; } public static bool operator !=(Vector left, Vector right) { return !(left == right); } public static bool operator ==(Vector left, Vector right) { return DistanceSquare(left, right) < 0.001f * 0.001f; } public static float Dot(Vector left, Vector right) { return left.X * right.X + left.Y + right.Y; } public Vector ToLenOf(float len) { Vector result = this; result.PolarRadius = len; return result; } public Vector ToAngleOf(double angle) { Vector result = this; result.Angle = angle; return result; } private Vector ToVertical() { return (-Y, X); } public Vector ToVertical(float len) { return FromPolar(Angle + Math.PI / 2, len); } public Vector AddVertical(float len) { return this + ToVertical(len); } public void Normalize() { float num = X * X + Y * Y; float num2 = 1f / (float)Math.Sqrt((double)num); X *= num2; Y *= num2; } public override string ToString() { return string.Format("({0},{1})", X, Y); } public static Vector Zero { get { return default; } } public static float Distance(Vector left, Vector right) { float X = left.X - right.X; float Y = left.Y - right.Y; return (float)Math.Sqrt(X * X + Y * Y); } public static float DistanceSquare(Vector left, Vector right) { float X = left.X - right.X; float Y = left.Y - right.Y; return X * X + Y * Y; } public static double AngleAbs(Vector left, Vector right) { return Math.Abs(left.Angle - right.Angle); } public static double AngleAbs(Vector left, Vector2 right) { return Math.Abs(left.Angle - right.Angle()); } public bool Equals(Vector value) { return this == value; } public override bool Equals(object obj) { if (obj is Vector) { return ((IEquatable<Vector>)obj).Equals(this); } return false; } }
或许你现在还无法理解其中的很多方法,但这些方法的本质都是在对存储的 X,Y
两个字段进行操作,是一种小型的数据结构,因此我们将它定义为结构而不是类。
反之,如果某个对象有大量的字段和方法,需要实现与对象的状态有关的操作,或者实现某些功能(比如访问网络资源、数据库查询等),那么你应该考虑将它定义为类,而不是结构。
结合这一章和第8章的内容,你现在应该已经对值类型和引用类型有了足够的认识。如果你已经将这部分知识理解透彻,那么你应当已经有一定能力解决自己在模组开发中遇到的问题,同时大幅降低了问弱智问题的概率。
在下一章中,我们将介绍C#中的数组。