C# 结构
1. 什么是结构
在C#中,结构(Structure)是一种用户定义的值类型(Value Type)。它类似于类(Class),但有一些重要的区别。
结构在C#中用struct
关键字定义,并且通常用于表示简单的数据结构,如坐标、日期、时间等。与类不同,结构是值类型,而不是引用类型。这意味着结构的实例在被赋值或传递给其他变量时会被复制,而不是传递引用。
结构的特点包括:
-
值类型:结构是值类型,它存储在栈上,并直接包含其数据。当结构被赋值给另一个变量或作为参数传递时,将进行值的复制。
-
默认构造函数:结构可以有自己的构造函数,但如果没有显式定义构造函数,编译器会为其提供一个默认的无参构造函数。
-
不支持继承:结构不支持类的继承,它们不能作为其他结构或类的基类。
-
值语义:结构具有值语义,即可以使用
==
和!=
运算符进行比较,按值进行相等性比较。 -
栈分配:结构的实例通常分配在栈上,这使得它们的创建和销毁速度更快。
-
适用于小型数据:由于结构被存储在栈上,所以通常适用于包含少量数据的情况,当数据较大时,使用类更为合适。
2. 创建和使用结构
前面介绍了类如何封装程序中的对象,也介绍了如何将它们存储在堆中,通过这种方式可以在数据的生存期上获得很大的灵活性,但性能会有一定损失。
因为托管堆的优化,这种性能损失较小。但是,有时仅需要一个小的数据结构。此时,类提供的功能多于我们需要的功能,由于性能原因,最好使用结构。
例如:
public class Dimensions
{
public Dimensions(double length, double width)
{
Length = length;
Width = width;
}
public double Length { get; }
public double Width { get; }
}
上面的代码定义了类 Dimensions,它只存储了某一项的长度和宽度。假定编写一个布置家具的程序,让人们试着在计算机上重新布置家具,并存储每件家具的尺寸。表面看来使字段变为公共字段会违背编程规则,但这里的关键是我们实际上并不需要类的全部功能。现在只有两个数字,把它们当成一对来处理,要比单个处理方便一些。既不需要很多方法,也不需要从类中继承,也不希望 .NET运行库在堆中遇到麻烦和性能问题,只需要存储两个 double 类型的数据即可。
此时,只需要修改代码,用关键字 struct 代替 class,定义一个结构而不是类:
public struct Dimensions
{
public Dimensions(double length, double width)
{
Length = length;
Width = width;
}
public double Length { get; }
public double Width { get; }
}
为结构定义函数与为类定义函数完全相同。
using System;
public struct Dimensions
{
public double Length { get; }
public double Width { get; }
public Dimensions(double length, double width)
{
Length = length;
Width = width;
}
public double Diagonal => Math.Sqrt(Length * Length + Width * Width); // 新增
}
结构是值类型,不是引用类型。它们存储在栈中或存储为内联(如果它们是存储在堆中的另一个对象的一部分),其生存期的限制与简单的数据类型一样。
-
结构不支持继承。
-
对于结构,构造函数的工作方式有一些区别。如果没有提供默认的构造函数,编译器会自动提供一个,把成员初始化为其默认值。
-
使用结构,可以指定字段如何在内存中布局。
因为结构实际上是把数据项组合在一起,所以有时大多数或者全部字段都声明为 public。严格来说,这与编写 .NET 代码的规则相反——根据 Microsoft,字段(除了 const 字段之外)应总是私有的,并由公有属性封装。但是,对于简单的结构,许多开发人员都认为公有字段是可接受的编程方式。
3. 类和结构之间的区别
3.1 结构是值类型
虽然结构是值类型,但在语法上常常可以把它们当作类来处理。例如,在上面的 Dimensions 类的定义中。可以编写下面的代码:
var point = new Demensions();
point.Length = 3;
point.Width = 6;
注意,因为结构是值类型,所以 new 运算符与类和其他引用类型的工作方式不同。new 运算符并不分配堆中的内存,而是只调用相应的构造函数,根据传送给它的参数,初始化所有字段。对于结构,可以编写下述完全合法的代码:
Demensions point;
point.Length = 3;
point.Width = 6;
如果 Demensions 是一个类,就会产生一个编译错误,因为 point 包含一个未初始化的引用——不指向任何地方的一个地址,所以不能给其字段设置值。
但对于结构,变量声明实际上是为整个结构在栈中分配空间,所以就可以为它赋值了。
注意:下面的代码会产生一个编译错误,编译器会抱怨用户使用了未初始化的变量
public class Test
{
public static void Main()
{
Dimensions point;
double d = point.Length; // error: Local variable 'point' might not be initialized before accessing
}
}
结构遵循其他数据类型都遵循的规则:在使用前所有元素都必须进行初始化。在结构上调用new
运算符,或者为所有的字段分别赋值,结构就完全初始化了。当然,如果结构定义为类的成员字段,在初始化包含的对象时,该结构会自动初始化为0。
结构是会影响性能的值类型。,但根据使用结构的方式,这种影响可能是正面的,也可能是负面的。
-
正面的影响是为结构分配内存时, 速度非常快,因为它们将内联或者保存在栈中。
-
负面影响是,只要把结构作为参数来传递或者把一个结构赋予另一个结构(如 A = B,其中 A 和 B 是结构),结构的所有内容就被复制,而对于类,则只复制引用。这样就会有性能损失,根据结构的大小,性能损失也不同。注意,结构主要用于小的数据结构。
但当把结构作为参数传递给方法时,应把它作为 ref 参数传递,以避免性能损失——此时只传递了结构在内存中的地址,这样传递速度就与在类中的传递速度一样快了。但如果这样做,就必须注意被调用的方法可以改变结构的值。
3.2 只读结构
从属性中返回一个值类型时,调用方会收到一个副本。设置此值类型的属性只更改副本,原始值不变。
这可能会让访问属性的开发人员感到困惑。这就是为什么结构的指导原则定义了值类型应该是不可变的。
当然,这个准则对于所有值类型都无效,因为 int、short、double ......不是不可变的,而且 ValueTuple 也不是不可变的。
然而,大多数结构类型都是不可变的。
使用 C# 7.2 时,readonly 修饰符可以应用于结构,因此编译器保证结构体的不变性。使用 C# 7.2 时,可以声明前面定义的类型 Dimensions 为 readonly,因为它只包含一个修改其成员的构造函数。属性只包含一个 get 访问器,因此不可能进行更改。
using System;
public readonly struct Dimensions
{
public double Length { get; }
public double Width { get; }
public Dimensions(double length, double width)
{
Length = length;
Width = width;
}
public double Diagonal => Math.Sqrt(Length * Length + Width * Width);
}
对于 readonly 修饰符,如果在创建对象后类型更改了字段或属性,编译器就会报错。使用这个修饰符,编译器可以生成优化的代码,使其在传递结构体时不会复制结构的内容,相反,编译器使用引用,因为它永远不会改变。
3.3 结构和继承
结构不是为继承设计的。这意味着:它不能从一个结构中继承。唯一的例外是对应的结构(和 C# 中的其他类型一样)最终派生于类 System.Object。因此,结构也可以访问 System.Object 的方法。在结构中,甚至可以重写 System.Object 中的方法——如重写 ToString() 方法。结构的继承链是:每个结构派生自 System.ValueType 类,System.ValueType 类又派生自 System.Object。ValueType 并没有给 Object 添加任何新成员,但提供了一些更适合结构的实现方式。注意,不能为结构提供其他基类:每个结构都派生自 ValueType。
-
注意:只有结构作为对象时,才从 System.ValueType 中继承。不能用作对象的结构是引用结构。这些类型自 C# 7.2 以来一直可用。
-
注意:要比较结构值,最好实现接口 IEquatable.
3.4 结构的构造函数
为结构定义构造函数的方式与为类定义构造函数的方式相同。
前面说过,默认构造函数把数值字段都初始化为0,且总是隐式地给出,即使提供了其他带参数的构造函数,也是如此。不能为结构创建定制的默认构造函数。
另外,可以像类那样为结构提供 Close() 或 Dispose() 方法。
3.5 ref 结构(ref struct)
在C# 7.2及更高版本中,引入了ref struct
,它是一种特殊的结构类型,具有更严格的语义和限制。
ref struct
是一个用于定义栈分配的、堆不可分配的结构类型。它具有以下特点:
-
栈分配:
ref struct
类型的实例在栈上分配内存,而不是在堆上。这使得它们的创建和销毁速度更快,但也限制了它们的使用范围。 -
堆不可分配:
ref struct
实例不能被分配到堆上,这意味着不能将其作为引用类型存储在堆上的对象中,也不能作为另一个对象的字段或元素。 -
严格的限制:
ref struct
类型有一些严格的限制,包括不能定义默认的无参构造函数、不能继承其他类或结构、不能被继承、不能实现接口等。
使用ref struct
时需要特别注意以下几点:
-
生命周期:由于
ref struct
实例是栈分配的,因此其生命周期受限于定义它的作用域。离开作用域后,实例将被自动释放。 -
内存安全:由于
ref struct
实例在栈上分配,所以需要特别小心,确保不会出现悬空引用或访问已释放的实例。 -
性能优化:
ref struct
类型适用于小型数据结构,并且可以用于性能敏感的场景。它们不会引入额外的堆分配和垃圾回收开销。
以下是一个示例,演示了如何定义和使用ref struct
:
ref struct Point
{
public int X;
public int Y;
public Point(int x, int y)
{
X = x;
Y = y;
}
}
class Program
{
static void Main()
{
Point point = new Point(2, 3);
Console.WriteLine("Point: X = " + point.X + ", Y = " + point.Y);
}
}
4. 按值和按引用传递参数
假设有一个类型 A,它有一个 int 类型的属性X。ChangeA 方法接收类型 A 的参数,把 X 的值改为2
public static void ChangeA(A a)
{
a.X = 2;
}
Main() 方法创建类型 A 的实例,把 X 初始化为 1,调用 ChangeA 方法:
static void Main()
{
A a1 = new A { X = 1 };
ChangeA(a1);
Print($"a1.X: {a1.X}");
}
输出是什么?1还是2?
答案视情况而定。需要知道 A 是一个类还是结构。下面先假定 A 是结构:
public struct A
{
public int X { get; set; }
public static void ChangeA(A a) { a.X = 2; }
}
结构按值传递,通过按值传递,ChangeA 方法中的变量 a 得到堆栈中变量 a1 的一个副本。在方法 ChangeA 的最后修改并销毁副本。a1 的内容从不改变,一直是1。
A 作为一个类时,是完全不同的:
public class A
{
public int X { get; set; }
public static void ChangeA(A a) { a.X = 2; }
}
类按引用传递。这样,a 变量把堆上的同一个对象引用为变量 a1。当 ChangeA 修改 a 的 X 属性值时,把它改为 a1.X,因为它是同一个对象。这里的结果是2。
注意:为了避免在更改成员时类和结构之间的不同行为上出现这种混淆,最好将结构设置为不可变的。如果一个结构体只有不允许改变状态的成员,就不会陷入如此混乱的境地。
4.1 ref 参数
也可以通过引用传递结构。如果 A 是结构类型,就添加 ref 修饰符,修改 ChangeA 方法的声明,通过引用传递变量:
public struct A
{
public int X { get; set; }
public static void ChangeA(ref A a)
{
a.X = 2;
}
}
// 从调用端也可以看出这一点,所以给方法参数应用了 ref 修饰符后,在调用方法时需要添加它:
public class Program
{
public static void Main()
{
A a1 = new A { X = 1 };
A.ChangeA(ref a1);
print($"a1.X: {a1.X}");
}
}
现在,与类类型一样,结构也按引用传递,所以结果是2。
类类型如何使用 ref 修饰符?下面修改 ChangeA 方法的实现:
public class A
{
public int X { get; set; }
public static void ChangeA(A a)
{
a.X = 2;
a = new A { X = 3 };
}
}
使用 A 类型的类,可以预期什么结果?当然,Main() 方法的结果不是1,因为按引用传递是通过类类型实现的。a.X 设置为2,就改变了原始对象 a1。然而,下一行 a = new A { X = 3 }
现在在堆上创建一个新对象,和一个对新对象的引用。Main() 方法中使用的变量 a1 仍然引用值为2的旧对象。ChangeA 方法结束后,没有引用堆上的新对象,可以回收它。所以这里的结果是2。
把 A 作为类类型,使用 ref 修饰符,传递对引用的引用(在 C++ 术语中,是一个指向指针的指针),它允许分配一个新对象,Main() 方法显示了结果3:
public class A
{
public int X { get; set; }
public static void ChangeA(ref A a)
{
a.X = 2;
a = new A { X = 3 };
}
}
最后,一定要理解,C# 对传递给方法的参数继续应用初始化要求。在任何变量传递给方法之前,必须初始化,无论是按值还是按引用传递。
注意:在 C# 7 中,还可以对局部变量和方法的返回类型使用 ref 关键字。
4.2 out 参数
如果方法返回一个值,该方法通常声明返回类型,并返回结果。如果方法返回多个值,可能类型还不同,该怎么办?
这有不同的选项。
-
声明类和结构,把应该返回的所有信息都定义为该类型的成员。
-
使用元组类型。
-
使用 out 关键字。
在C#中,out
参数用于在方法调用时将一个或多个值从方法内部传递回调用方。与普通参数(按值传递)不同,out
参数是按引用传递的,方法内部可以修改该参数的值,并且这些更改将反映到调用方。
使用out
参数时,有以下几点需要注意:
-
out
参数在方法内部必须被赋值。在方法结束之前,必须确保为out
参数分配了一个值。 -
在调用方法之前,无需为
out
参数分配初始值。 -
在调用方法时,使用
out
关键字声明参数。
下面是一个使用out
参数的示例:
class Program
{
static void Divide(int dividend, int divisor, out int quotient, out int remainder)
{
quotient = dividend / divisor;
remainder = dividend % divisor;
}
static void Main()
{
int dividend = 10;
int divisor = 3;
int result, remainder;
Divide(dividend, divisor, out result, out remainder);
Console.WriteLine("Quotient: " + result);
Console.WriteLine("Remainder: " + remainder);
}
}
在上述示例中,我们定义了一个Divide
方法,它接受两个整数参数dividend
和divisor
,并使用out
关键字声明了两个out
参数quotient
和remainder
。在方法内部,我们计算除法的商和余数,并将结果赋值给quotient
和remainder
。
在Main
方法中,我们声明了两个变量result
和remainder
。然后,我们调用Divide
方法,并使用out
关键字将这两个变量作为参数传递。在方法调用结束后,我们打印商和余数的值。
4.3 in 参数
在C#中,in
参数是一种传递参数的方式,用于将参数按只读方式传递给方法,以提高性能并避免不必要的拷贝。使用in
参数时,参数的值不可被修改,且方法不能对其进行分配。
以下是使用in
参数的几个重要点:
-
传递只读参数:
in
参数用于传递只读的参数值给方法。这意味着方法内部无法修改传递的参数值,只能对其进行读取操作。 -
避免拷贝:使用
in
参数可以避免在传递参数时进行不必要的拷贝。通常情况下,当传递结构体或值类型参数时,会发生参数拷贝。使用in
参数可以避免这种拷贝,提高性能。 -
不可分配:在方法内部,不能对
in
参数进行分配操作。这意味着不能将in
参数重新赋值给其他变量,也不能修改其内部的字段或属性。
以下是一个使用in
参数的示例:
class Program
{
static void PrintValues(in int x, in int y)
{
Console.WriteLine("x: " + x);
Console.WriteLine("y: " + y);
}
static void Main()
{
int a = 10;
int b = 20;
PrintValues(in a, in b);
}
}
在上述示例中,我们定义了一个PrintValues
方法,该方法接受两个in
参数x
和y
。在方法内部,我们只是打印了这两个参数的值,但不能对它们进行修改。
在Main
方法中,我们声明了两个变量a
和b
,然后将它们作为in
参数传递给PrintValues
方法。由于使用了in
参数,方法内部不会对传递的参数进行拷贝,从而提高了性能。
需要注意的是,in
参数适用于传递大型结构体或值类型参数时,以避免不必要的拷贝操作。但在其他情况下,使用in
参数可能对性能产生微弱或无明显的影响,因此在使用时需要根据具体情况进行权衡和评估。
注意:in 修饰符主要用于值类型。也可以对引用类型使用它。in 修饰符用于引用类型时,可以更改变量的内容,但不能更改变量本身。
#知识点##技术分享##学习笔记##学习##CSharp#C#知识库用于学习和复习C#知识~