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)

ref 结构类型(C# 参考)

在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方法,它接受两个整数参数dividenddivisor,并使用out关键字声明了两个out参数quotientremainder。在方法内部,我们计算除法的商和余数,并将结果赋值给quotientremainder

Main方法中,我们声明了两个变量resultremainder。然后,我们调用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参数xy。在方法内部,我们只是打印了这两个参数的值,但不能对它们进行修改。

Main方法中,我们声明了两个变量ab,然后将它们作为in参数传递给PrintValues方法。由于使用了in参数,方法内部不会对传递的参数进行拷贝,从而提高了性能。

需要注意的是,in参数适用于传递大型结构体或值类型参数时,以避免不必要的拷贝操作。但在其他情况下,使用in参数可能对性能产生微弱或无明显的影响,因此在使用时需要根据具体情况进行权衡和评估。

注意:in 修饰符主要用于值类型。也可以对引用类型使用它。in 修饰符用于引用类型时,可以更改变量的内容,但不能更改变量本身。

in 参数修饰符

#知识点##技术分享##学习笔记##学习##CSharp#
C# 知识库 文章被收录于专栏

C#知识库用于学习和复习C#知识~

全部评论

相关推荐

10-15 17:25
已编辑
武汉科技大学 Web前端
点赞 评论 收藏
分享
2 收藏 评论
分享
牛客网
牛客企业服务