首页 > 编程笔记 > C#笔记 阅读:3

C#结构体用法详解(附带实例)

C# 中的结构体和类相似,不同之处在于:
结构体可以包含类能包含的所有成员,但终结器除外。由于结构体无法继承,因此我们无法将它的成员标记为 virtual、abstract 或者 protected 的。

在 C#10 之前,我们无法在结构体中定义字段初始化器与无参构造器。虽然 C#10 中放宽了这一规定,当然主要是为了支持 record struct,但是在使用这些功能前仍需要三思而行,否则将可能造成令人困惑的结果。

当表示一个值类型语义时,使用结构体更加理想。数值类型就是一个很好的例子。对于数值来说,在赋值时对值进行复制而不是对引用进行复制是很自然的。由于结构体是值类型,因此它的实例不需要在堆上实例化,创建一个类型的多个实例就更加高效了。例如,创建一个元素类型为值类型的数组只需要进行一次堆空间的分配。

结构体是值类型,因而它的实例不能为 null。结构体对象的默认值是一个空值实例,即其所有的字段均为空值(均为默认值)。

C#结构体的创建

结构体和类不同,它的每一个字段必须在构造器(或字段初始化器)中显式的赋值。例如:
struct Point
{
    int x, y;
    public Point(int X, int y) { this.x = x; this.y = y; } // OK
}

如果我们添加以下构造器,则会出现编译错误,因为我们并没有为其中的 y 字段赋值:
public Point(int x)
{
    this.x = x; // Not OK
}

结构体不论有没有定义构造器,均会包含隐式的无参构造器。无参构造器会将其中的每一个字段按位以零初始化(即每个字段的默认值):
Point p = new Point(); // p.x and p.y will be 0
struct Point { int x, y; }

即使定义了无参构造器,隐式的无参构造器也仍然存在,并且可以用 default 关键字“调用”它:
Point p1 = new Point(); // p1.x and p1.y will be 1
Point p2 = default; // p2.x and p2.y will be 0

struct Point
{
    int x = 1;
    int y;
    public Point() => y = 1;
}
在以上例子中,我们使用字段初始化器将结构体中的 x 初始化为 1,并在无参构造器中将 y 初始化为 1。如果使用 default 关键字,那我们仍然可以在创建 Point 实例时跳过所有的初始化逻辑。

当然,除了使用 default 关键字之外,我们还有其他“调用”默认构造器的办法:
var points = new Point[10]; // Each point in the array will be (0,0)
var test = new Test(); // test.p will be (0,0)

class Test { Point p; }

同时拥有“两个”无参构造器很容易造成错误。这也是我们避免在结构体中使用字段初始化器和显式定义无参构造器的理由。

因此,在结构体设计过程中,应当确保其 default 值是一个有效状态,而无须进行初始化。例如,与其使用初始化器:
struct WebOptions
{
    public string Protocol { get; set; } = "https";
}

不如采用以下形式:
struct WebOptions
{
    string protocol;
    public string Protocol
    {
        get => protocol ?? "https";
        set => protocol = value;
    }
}

C#只读结构体和只读函数

在结构体上应用 readonly 修饰符可用于确保其中所有的字段都是 readonly 的。这不但可以帮助我们表达只读的本意,还能够给予编译器更多的优化空间:
readonly struct Point
{
    public readonly int X, Y; // X and Y must be readonly
}

如果需要更细粒度的应用 readonly 的特性,可以将 readonly 修饰符应用在结构体的函数(function)中。这确保了如果该函数试图更改任何字段的值就会产生一个编译期错误:
struct Point
{
    public int X, Y;
    public readonly void Resetx() => X = 0; // Error!
}
如果 readonly 函数调用非 readonly 函数,则编译器会生成一个警告(并同时保护性地创建一个结构体对象的副本以避免潜在地发生更改字段的风险)。

C# ref结构体

ref 结构体是在 C# 7.2 中适时引进的功能。该功能的引入主要是为 Span<T> 和 ReadOnlySpan<T> 提供支持(除此之外,还有高度优化的 Utf8JsonReader)。这些结构体可以通过一些微小的优化来减少内存分配。

和引用类型不同(引用类型实例是分配在堆上的),值类型对象是存储在声明处的(即变量在哪里声明就存储在哪里)。如果值类型声明为参数或者局部变量,则该对象会存储在栈上:
void SomeMethod()
{
    Point p; // p will reside on the stack
}

struct Point { public int X, Y; }

但是如果值类型声明为类中的字段,则它将存储在堆上:
class MyClass
{
    Point p; // Lives on heap, because MyClass instances live on the heap
}
类似地,结构体数组是存储在堆上的,同样,装箱的结构体也会转存在堆上。

在结构体在声明上添加 ref 修饰符可以确保该结构体只可能存储在栈上。任何可能令 ref 结构体存储在堆上的做法都会产生编译期错误:
var points = new Point[100]; // Error: will not compile!

ref struct Point { public int X, Y; }

class MyClass { Point p; } // Error: will not compile!
ref 结构体的引入主要是为了支持 Span<T> 和 ReadOnlySpan<T> 这两个结构体。由于 Span<T> 和 ReadOnlySpan<T> 实例只能够存储在栈上,因此它们能够安全地包装栈上分配的内存。

ref 结构体无法和任何可能直接或者间接导致堆存储的功能结合使用,包括一些高级 C# 特性,例如 Lambda 表达式、迭代器、异步函数(因为异步函数会创建对外不可见的包含字段的类型)。此外,ref 结构体也不能出现在非 ref 的结构体中,并且它们也无法实现任何接口(因为这可能会导致装箱操作)。

相关文章