0. 文章目的
??本文面向有一定.NET C#基礎知識的學習者,介紹C#中結構體定義、使用以及特點,
1. 閱讀基礎
??了解C#基本語法
??了解.NET中的堆疊與托管堆
2. 值型別
2.1 .NET的兩大型別
??在.NET中,所有型別都是object型別的子類,而在object繁多的子類中,又可以將它們歸結為兩種型別:參考型別與值型別,兩者最大的區別在于值型別物件會在堆疊上分配,而參考型別物件則是在托管堆中分配,由于對堆疊上資料的操作通常遠遠快于對托管堆中資料的操作,因此對值型別訪問與操作通常會更高效,.NET中的值型別有一個最為明顯的特點,就是所有的值型別都繼承自類ValueType,
2.2 ValueType
??ValueType是一個繼承自object的特殊抽象類,它是所有值型別的基類,它的意義在于區分值型別與參考型別,然而ValueType本身的定義很簡單,只是重寫了object中的Equals、GetHashCode以及ToString方法,并隱藏了默認構造方法:
public abstract class ValueType
{
protected ValueType() { ... }
public override bool Equals(object obj) { ... }
public override int GetHashCode() { ... }
public override string? ToString() { ... }
}
??ValueType通過重寫Equals方法,讓其子類的比較行為為比較相等性(而非object默認的用于比較同一性),這也符合“值”的特點-兩個值應該比較是否相等而非是否同一:
????? Foo { }
Foo a = new Foo();
Foo b = new Foo();
Console.WriteLine(a.Equals(b));
??上述代碼中的兩個Foo物件a和b是兩個無關的變數,因此當Foo為參考型別時,上述代碼將會輸出False(Equals默認進行同一性比較),但是如果Foo型別為值型別且沒有重寫Equals方法,則會輸出True,所有的ValueType的子類的Equals方法默認都是進行值相等性比較,其比較依據就是比較各個欄位的值是否相等,即若兩個型別相同的值物件的所有欄位的值均相同,則這兩個值物件相同,
??另外,盡管ValueType表面上看起來只是一個普通的抽象類,但是這是用于編譯器的類,編譯器禁止程式員顯式從ValueType派生類,也就是說下面的代碼是不允許的:
class Foo : ValueType { ... } // 不允許從ValueType派生
??不過將其用作型別宣告是允許的,盡管大多數時候這一行為沒有什么意義:
ValueType val = 1; // val的型別是ValueType
ValueType Add(ValueType a, ValueType b) { ... } // 將ValueType用于引數型別與回傳型別
??總而言之,型別是否繼承自ValueType是.NET中參考型別與值型別的分界線,
3.3 C#中的值型別
(1)基元型別中的值型別
??C#中最基礎的值型別就是其基元型別中的值型別,如下:
| C#基元型別 | 對應的FCL型別 |
| sbyte | System.SByte |
| byte | System.Byte |
| short | System.Int16 |
| ushort | System.UInt16 |
| int | System.Int32 |
| uint | System.UInt32 |
| long | System.Int64 |
| ulong | System.UInt64 |
| char | System.Char |
| float | System.Single |
| double | System.Double |
| bool | System.Boolean |
| decimal | System.Decimal |
??這些型別都是常用且重要的型別,CLR也對上述型別提供了直接操作IL碼,擁有相比于其他型別來說更高的操作效率,
(2)列舉型別
??C#中的列舉型別也是值型別,因為所有列舉型別都繼承自Enum型別,而Enum型別則繼承自ValueType,列舉型別相比基元型別更復雜,需要一定的篇幅講解,但考慮到本文重點,這里不做過多介紹,
(3)自定義值型別:結構體
??C#也允許程式員通過使用struct來定義自己的值型別,這一型別被稱為結構體,定義一個結構體的定義和定義一個類在語法上沒有太大的區別,下面是一個簡單的結構體的示例:
struct Point
{
public float X;
public float Y;
}
??上述代碼定義了一個名為Point的結構體,該結構體中有兩個float型別的欄位,名為X與Y,你會發現其和定義一個類的區別似乎僅在于將class關鍵字替換為了struct,甚至在使用上也幾乎與普通的類無異:
Point p = new Point();
p.X = 1;
??然而這只是表面上看起來,由于使用了struct來定義Point,Point現在是一個值型別而非參考型別,意味著它遵守值型別堆疊上分配的規則,同時還有一些更深層次的東西,
3.4 與C++的不同
??如果你寫過C++,可能使用下面的方法來決定物件的分配方式:
Point p; // 分配到堆疊
Point* p = new Point(); // 分配到堆
??但在.NET中,你會意識到物件究竟會分配到堆疊還是分配到堆并不在分配時決定,而是在定義型別時就決定了(當然另一方面,C++中可以通過對解構式與new運算子的私有化來實作在定義時限定分配方式),
3. C#中的結構體
3.1 定義與使用結構體
3.1.1 定義
??一個結構體的定義非常簡單,從語法上來說只需要將定義一個類使用的class關鍵字替換為struct即可,如下:
public struct Point
{
public float X;
public float Y;
}
??同樣和定義類一樣,你可以定義為結構體添加訪問修飾符,例如上述定義中就為其添加了public訪問修飾符,同樣的,你也可以在結構體中定義屬性,方法,甚至事件,
3.1.2 使用
??結構體的使用也非常簡單,和類的使用基本一致:
Point p = new Point(); // 實體化一個Point
p.X = 1; // 將p的欄位X的值設為1
int x = p.X; // 獲取p的欄位X的值
??簡而言之,從語法層面來說,結構體的定義與使用和類的定義與使用沒有太大的差別,兩者大多數的操作基本可以通用,真正讓結構體與類區分開來的是它作為的值型別特點,
3.2 結構體的特點
3.2.1 堆疊上分配
??在前文對值型別的簡介中已經提到過,值型別在實體化時物件會被直接分配到堆疊上,而參考物件的變數只有在實體化的時候才會分配實際所需的記憶體:
FooClass foo; // 此處只是宣告了一個FooClass物件的參考,FooClass實體尚未創建
foo = new FooClass(); // 到這里才實際分配了所需記憶體并實體化了一個FooClass物件
??而值型別是堆疊上物件,意味著當你宣告值型別的變數后就會立馬分配記憶體:
FooStruct foo; // 立馬在堆疊上分配可以容納FooStruct大小的記憶體,并將所有欄位初始化為0
FooStruct foo = new FooStruct(); // 同上,但是語意更明確
??盡管上述代碼的第一種寫法無法通過編譯,但這只是編譯器的要求,第一行代碼和第二行代碼的作用在默認情況下完全相同(默認情況是指使用結構體的自動生成的構造方法),你可以認為,堆疊上物件的記憶體分配在你寫下的一瞬間就決定好了,你可以嘗試如下代碼來體會這一區別:
????? Foo {
public long A;
public long B;
public long C;
public long D;
public long E;
}
var foos = new Foo[100 * 100 * 100];
Console.WriteLine(GC.GetTotalAllocatedBytes());
??上述代碼運行時會輸出程式運行時分配過的記憶體大小,將開頭的‘?????’替換為class或struct,你會發現當其為struct時程式所占的記憶體明顯高于其為class時,原因在于當Foo為struct時陣列中的每一項所占的記憶體就是儲存一個Foo物件所需要的所有空間,而為class時則只會儲存一個參考所占的記憶體(通常為8位元組或4位元組,取決于平臺配置),就像下圖這樣:

??分配到堆疊是值型別的重要特征,理解這一點對于值型別的很多性質的理解以及正確使用值型別有巨大幫助,
3.2.2 無繼承
??結構體不支持繼承,所有的結構體都是隱式密封的,也就是說,下面的代碼是不可行的:
struct Foo { }
struct Foo2 : Foo { } // 結構體不允許繼承
??但是結構體可以實作介面:
struct Foo : IEquatable<Foo> { ... }
??關于為何結構體不可繼承,其中一個重要的原因是由于結構體所代表的值型別需要直接分配到堆疊上,在入堆疊出堆疊的時候必須能夠確定其資料大小,因此結構體需要提供明確固定的大小,如果允許繼承,下面這種情況是難以預測的:
struct Foo { }
struct Foo2 : Foo { }
Foo foo;
foo = new Foo();
foo = new Foo2();
??foo的尺寸應該以Foo為準還是以Foo2為準?答案是Foo,然而根據“基類參考可以指向派生類參考”這一規則,foo應該也可以指向Foo2的實體,然而值型別是堆疊上分配,其記憶體大小在宣告的一瞬間就已經確定,顯然如果Foo2中有額外的欄位,已分配給Foo物件的堆疊空間中將沒有額外的儲存空間容納這些欄位,另外從事實上來說,foo甚至不是一個參考,所以結構體不允許繼承是理所當然的,
3.3.3 副本式賦值
??對一個參考型別進行賦值的時候,獲得的是對指向物件的參考:
class Foo
{
public int Value;
}
Foo f1 = new Foo();
Foo f2 = f;
f2.Value = https://www.cnblogs.com/HiroMuraki/archive/2022/06/07/10;
Console.WriteLine(f1.Value);
??上述代碼將輸出10,盡管是對f2賦值,但是實際上是將f1所指向的物件的參考賦值給了f2,此時f1與f2指向的是同一個物件,因此f2修改Value的值時等同于修改f1指向的物件的Value的值,但對于值型別來說則不如此:
struct Foo
{
public int Value;
}
Foo f1 = new Foo();
Foo f2 = f;
f2.Value = https://www.cnblogs.com/HiroMuraki/archive/2022/06/07/10;
Console.WriteLine(f1.Value);
??上述代碼將輸出0,這是上述的賦值行為實際是將f的副本賦值給了f2,也就是說,不同于Foo為類時f1與f2指向的是同一個物件,f2在此時持有的是一個和f相同的副本,兩者互不相干,因此修改f2的Value不會影響f1的值,
??結構體的賦值方式如下:
- 實體化一個相同型別的結構體,作為副本
- 將當前結構體各個欄位的值逐一賦給創建的副本中相同的欄位
??不只于賦值操作,結構體作為方法引數、方法回傳值時也是按值傳遞:
????? Foo
{
public int Value;
}
void IncreaseValue(Foo foo)
{
foo.Value += 1;
}
Foo f1 = new Foo();
IncreaseValue(f1);
Console.WriteLine(f1.Value);
??若Foo定義為class,則上述代碼輸出的為1,若Foo為struct,上述代碼輸出為0,原因是定義為class時,將f1作為引數傳入后foo獲得的是f1對其參考物件的參考,因此foo此時指向的就是f1指向的物件;而定義為struct時,foo此時獲取的是f1的副本,這一行為有時候會帶來一些奇怪的表現,例如:
struct CatCard
{
public int ID;
}
class Cat
{
public CatCard Card { get; } = new CatCard();
}
Cat cat = new Cat();
cat.CatCard.ID = 10;
??上述代碼嘗試修改直接修改Cat中CatCard屬性的ID欄位,咋一看好像沒問題,但實際上上述代碼甚至無法通過編譯,不要忘記一個很重要的點:屬性的本質是方法,因此上面的賦值代碼的實質如下:
CatCard card = cat.get_Card();
card.ID = 10;
??你可能已經發現問題了:get_Card()回傳了一個CatCard物件,然而根據值型別按副本賦值的特點,get_Card()回傳的其實是Cat中Card屬性的副本,因此card此時并不是表示Cat中的Card,而是其副本,這意味著對card的修改將不會對Cat的Card屬性產生任何影響,為了防止潛在的編程錯誤,這一行為會被編譯器阻止,
??不過,就像C++中可以按參考傳遞堆疊上物件一樣,C#也支持通過參考傳值而直接修改原始資料,具體方法會在后文提到,
3.4. 特殊結構體
3.4.1 只讀結構體(readonly)
??基元型別中的值型別的實體是不可變的,例如,下面的代碼是無法通過編譯的:
1 = 2;
(字面值1可以視為Int32的一個實體)
??這一點很容易理解,將數字2賦值給數字1從數學上來說是及其令人困惑的,因此基元型別的值型別都是不可變型別,不可變型別可以帶來諸多好處,例如更安全的編程,可以基于不可變做出許多假設而進行優化等等,因此將型別定義為不可變型別是有意義的,你可以通過在結構體中只宣告只讀欄位來保證這一點:
struct Point
{
public readonly float X;
public readonly float Y;
}
??這樣當Point的實體創建后就無法修改其成員值了,當然這個結構體沒有什么意義,因為它的X和Y值將永遠是0,為此,你還需要提供構造方法來允許在實體化時指定欄位的值:
struct Point
{
public readonly float X;
public readonly float Y;
public Point(float x, float y)
{
X = x;
Y = y;
}
}
Point p = new Point(1, 2); // 使用示例
??盡管如此,有時在編碼時依然可能出現失誤而導致忘記將某個欄位設定為只讀欄位,并且只是在對欄位宣告只讀顯然缺乏更清晰的語意,因此C#還提供了一種語法來宣告‘只讀結構體’,只讀結構體的所有的欄位必須宣告為只讀欄位,否則會出現編譯錯誤:
readonly struct Point
{
public readonly float X;
public readonly float Y;
public float Z; // 編譯錯誤,欄位必須為只讀
}
??如上,在struct關鍵字前添加readonly關鍵字,即可將結構體宣告為只讀結構體,編譯器會為readonly結構體加上IsReadonlyAttribute,因此上述代碼會被翻譯為如下:
[System.Runtime.CompilerServices.IsReadOnly]
struct Point
{
public readonly float X;
public readonly float Y;
}
??(當然,IsReadonlyAttribute是一個internal的類,主要用于標記元資料,程式員不應該使用它)
??除了只讀欄位外,同樣還可以宣告只讀屬性:
struct Point
{
public float X { get; }
public float Y { get; }
}
??和類的只讀屬性一樣,結構體的只讀屬性同樣是依賴一個只讀欄位實作,同樣的,你可以設定init訪問器來允許在‘物件初始值設定項’中初始化欄位的值,避免定義過多的構造方法:
struct Point
{
public float X { get; init; }
public float Y { get; init; }
}
Point p = new Point()
{
X = 1,
Y = 2
};
??此外你還可以定義只讀方法:
struct Point
{
// ... 省略其他代碼
public readonly void Print()
{
// X = 1; // 不允許的操作
Console.WriteLine(X + Y);
}
}
??被readonly修飾的方法意味方法做出保證:不會修改實體狀態,也就是說readonly方法中不能對欄位進行賦值操作,只能訪問欄位,因此如果將上面Print方法的X = 1那一行取消注釋,編譯器將會報錯,因為它嘗試修改結構體的狀態,
3.4.2 僅堆疊分配結構體(ref)
??‘結構體分配到堆疊上’這一重點被反復強調,然而有時候可能并不是那么簡單:
struct Point
{
public float X;
public float Y;
}
class Square
{
public Point Position;
}
Square square = new Square();
??上述代碼中,Square的Position欄位并沒有分配到堆疊上,反而是和Square的實體一起被分配到了托管堆中,除此之外,裝箱也會導致結構體實體分配到托管堆:
Point point = new Point();
object obj = point; // 裝箱,結構體轉移到托管堆
??上述代碼從邏輯上來說是可行的,但有時候因為性能要求或者種種原因我們希望結構體只能分配到堆疊上,此時便可以使用僅堆疊分配結構體,即ref結構體:
ref struct Point
{
public float X;
public float Y;
}
??盡管ref這一關鍵字讓人疑惑,但是在struct關鍵字前添加ref確實是指將結構體宣告為只能在堆疊上分配的結構體,對于這種結構體,任何可能將其轉移到托管堆的行為都將被阻止(例如在參考型別中定義ref結構體欄位,或者進行裝箱操作):
class Square
{
public Point Position; // 錯誤,Position會隨著Square實體轉移到托管堆
}
Point point = new Point();
object obj = point; // 錯誤,point會被裝箱到托管堆
??ref結構體保證了結構體只能在堆疊上分配,但是也因此有了諸多限制,MSDN上指出了ref結構體的的使用限制:
- 不能是陣列的元素型別,
- 不能是類或非 ref 結構的欄位的宣告型別,
- 不能實作介面,
- 不能被裝箱為 System.ValueType 或 System.Object,
- 不能是型別引數,
- 不能由 lambda 運算式或本地函式捕獲,
- 不能在 async 方法中使用, 但是,可以在同步方法中使用 ref 結構變數,例如,在回傳 Task 或 Task<TResult>的方法中使用結構變數,
- 不能在迭代器中使用,
??需要說明的是,你可以宣告在其他ref結構體中宣告ref結構體欄位,因為ref結構體保證堆疊上分配:
ref struct AlsoPoint
{
public Point Point; // 允許,因為XPoint同樣保證了堆疊上分配
}
??另外,你可以宣告只讀ref結構體:
readonly ref struct Point
{
public Point Point; // 允許,因為XPoint同樣保證了堆疊上分配
}
(注意,readonly關鍵字必須位于ref之前)
??ref結構體可以讓程式員對結構體的分配做出預設,從而放心實作一些高性能的庫,例如Span<T>與ReadOnlySpan<T>就是對ref結構體的具體應用,
3.4.3 記錄結構體(record)
??record是一個新的概念,闡述它需要一定的篇幅,這不是本文的重點,因此這里不多做闡述,只是簡單說明以下可以將結構體也宣告為記錄:
record struct Point
{
public float X;
public float Y;
}
??從實質上來講,記錄結構體就是實作了IEquatable<>介面,重寫了ToString、GetHashCode與Equals方法,并多載了==與!=運算子的結構體,不過這些操作均由編譯器自動完成,另外,同樣可以用下面的語法宣告宣告記錄結構體:
record struct Point3(float X, float Y);
??上述代碼的對等代碼大概如下:
查看代碼
record struct Point
{
private float _x;
private float _y;
public float X
{
get => this._x;
set => this._x = value;
}
public float Y
{
get => this._y;
set => this._y = value;
}
public Point(float X, float Y)
{
this._x = X;
this._y = Y;
}
public void Deconstruct(out float X, out float Y)
{
X = this.X;
Y = this.Y;
}
}
(注意Point的X和Y是被定義為屬性而非欄位,并且這種宣告方式還實作了Deconstruct解構方法)
??同樣的,可以宣告只讀記錄結構體,
3.4.4 不安全結構體(unsafe)
??所謂不安全結構體就是允許出現不安全成員的結構體,例如:
unsafe struct Window
{
public void* Handle;
}
??Handle是一個void*指標,是不安全代碼,因此使用該欄位的結構體需要宣告為unsafe,unsafe結構體不是什么新東西,只是unsafe作用于結構體范圍的體現,不安全代碼也不是本文重點,故這里不多做闡述,
3.4.5 多特性結構體
??可以將readonly、record、ref、unsafe等修飾符組合,來創建諸如‘只讀ref結構體’、‘只讀記錄結構體’、‘只讀不安全結構體’等具有多種特性的結構體,鑒于這些結構體只是相應修飾符含義的組合,這里不過多闡述,
4. 對結構體的特殊操作
4.1 按參考傳遞值型別
??來看下面的一個例子:
struct Point
{
public int X;
public int Y;
}
void AddX(Point point)
{
point.X += 1;
point.Y += 1;
}
Point p = new Point();
AddX(p);
Console.WriteLine(point.X);
??上述代碼中將輸出0,請記住結構體默認是副本式復制,也就是說上述代碼中呼叫AddX方法,并將p作為引數傳入后,方法中的point只是p的副本而非p本身,因此對point的改變不會影響到p,但有時候確實需要通過方法直接修改p的值,此時可以使用按參考傳遞:
void AddX(ref Point point)
{
point.X += 1;
point.Y += 1;
}
Point p = new Point();
AddX(ref p);
Console.WriteLine(point.X);
??現在AddX方法的point現在是一個ref引數,傳遞引數p時,point此時直接指向p所在的資料地址,因此修改point的值等同于直接修改p,ref引數并不奇怪,你很可能已經用過了,但現在回過頭來看前文的一個例子:
struct CatCard
{
public int ID;
}
class Cat
{
public CatCard Card { get; } = new CatCard();
}
Cat cat = new Cat();
cat.CatCard.ID = 10; // 報錯
??上述代碼無法通過編譯,然而如果將上述代碼中Cat的Card屬性修改為欄位,則代碼可以正常運行:
class Cat
{
public CatCard Card = new CatCard(); // 修改為欄位
}
Cat cat = new Cat();
cat.CatCard.ID = 10; // 此時可以通過編譯
??這是由于屬性的本質是方法,因此當Card為屬性時,其等效代碼類似如下:
struct CatCard
{
public int ID;
}
class Cat
{
private readonly CatCard _card = new CatCard();
public CatCard get_Card()
{
return this._card;
};
}
Cat cat = new Cat();
cat.get_Card().ID = 10; // 報錯
??請思考一下為何編譯器不允許上述代碼:get_Card是方法,回傳一個CatCard型別的物件,而CatCard是一個結構體,這意味著該方法回傳的將是欄位_card的副本而非_card欄位本身,因此修改get_Card的回傳值不會對_card欄位本身造成任何影響,而僅僅是修改_card欄位的一個臨時副本的X,并在修改完成后就丟棄此副本,由于這一問題會導致人對代碼本身做的事產生誤解而撰寫出錯誤的代碼,因此C#編譯器禁止了上述行為,但就像按引數可以參考傳遞一樣,回傳值也可以按參考回傳,因此,你可以寫出如下代碼:
class Cat
{
// ... 省略其他代碼
public ref CatCard get_Card()
{
return ref this._card;
};
}
Cat cat = new Cat();
cat.get_Card().ID = 10; // 正確,get_Card()回傳的是欄位_card的參考
??注意cat.get_Card().ID = 10等效代碼如下:
ref CatCard card = ref cat.get_Card(); // 而不是CatCard card = cat.get_Card(),否則card依然只是副本
card.ID = 10
??回到屬性上,你可以宣告按參考回傳的值型別屬性:
class Cat
{
private readonly CatCard _card;
public ref CatCard Card
{
get
{
return ref this._card;
}
}
}
Cat cat = new Cat();
cat.CatCard.ID = 10; // 正確
??基于顯而易見的原因,這種屬性不能有set訪問器,
4.2 裝箱與拆箱
??既然結構體是值型別,那么結構體也存在裝箱與拆箱,將資料在堆疊與托管堆之間遷移:
struct Foo { }
Foo foo = new Foo();
object obj = foo; // 裝箱,移動到托管堆
Foo foo2 = (Foo)obj; // 拆箱,從托管堆中獲取資料并移動到堆疊上
??裝箱與拆箱的相關概念不是本文的重點,故此處不做過多介紹,另外,理所當然的,ref結構體(僅堆疊上結構體)不允許裝箱與拆箱,
4.3. 控制結構體的記憶體布局
??既然結構體是分配到堆疊上的,那結構體需要的記憶體大小必然是在編譯時與運行時都可以確定的,例如下述結構體:
struct Point
{
public int X;
public int Y;
}
??Point結構體有兩個int型別的欄位,C#中每個int直接映射到System.Int32型別,因此每個int欄位長度為4位元組,故儲存上述結構體所需要的記憶體大小就是4+4=8位元組,可以通過sizeof來查看結構體所需的記憶體大小:
unsafe
{
Console.WriteLine(sizeof(Point)); // 輸出8
}
??一般來說,結構體所需的記憶體大小就是各個欄位大小的總和,但有時候還需要考慮記憶體對齊的問題,例如下述結構體:
struct Point
{
public byte X;
public int Y;
}
??byte型別只占用一個位元組,所以你可能會認為上述代碼中Point的大小是1+4=5位元組,然而由于記憶體對齊,byte依然會需要占據4位元組大小,因此該結構體實際上依然需要8位元組來儲存,關于記憶體對齊是一個需要一定篇幅來闡述的問題,這個不是本文的重點,如有興趣可以參考C語言中結構體的記憶體對齊的相關文章進行了解,
??另一個重要的點是,你可以通過System.Runtime.InteropServices.StructLayoutAttribute來指定欄位布局方案,該Attribute主要接受一個System.Runtime.InteropServices.LayoutKind列舉來指定對齊模式,該列舉有三個列舉值:
(1)Sequential:順序布局,按欄位的宣告順序布局,是默認行為
(2)Auto:自動布局,自動排列欄位順序以用最小的空間來儲存欄位
??例如對于下面結構體:
struct Foo
{
public byte A;
public int B;
public byte C;
}
??默認情況下由于記憶體對齊,該結構體所需的記憶體大小為4+8+4=12,但你可以按下面的順序宣告欄位讓其只需要8個位元組:
struct Foo
{
public byte A;
public byte C;
public int B;
}
??也就是說欄位的宣告順序影響結構體的記憶體占用,而通過StructLayoutAttribute,你可以讓運行時對欄位順序自動調整以求最小記憶體浪費:
[StructLayout(LayoutKind.Auto)]
struct Foo
{
public byte A;
public int B;
public byte C;
}
??經過運行時的自動調整欄位儲存順序后,一個Foo物件在程式運行的時候的記憶體占用同樣是8,
(3)Explicit:顯式布局,手動指定欄位地址的偏移
??你可以手動指定欄位的偏移值,來達到一些特殊的效果:
[StructLayout(LayoutKind.Explicit)]
struct Foo
{
[FieldOffset(0)]
public short A;
[FieldOffset(4)]
public int B;
[FieldOffset(2)]
public short C;
}
??通常,按照Foo中欄位的宣告順序,Foo的記憶體布局應該如下圖:

??但這里將結構體的LayoutKind設定為了Explicit,并使用了FieldOffsetAttribute來顯式指定了各個欄位相對于結構體起始地址的偏移(以位元組為單位),因此Foo的實際記憶體布局如下圖:

??這一功能一個比較重要的用途是用于模擬C語言中的聯合體(union),例如對于下面C中的聯合體定義:
union Foo
{
int IntValue;
long LongValue;
double DoubleValue;
};
??可以使用下述的C#的結構體來模擬:
[StructLayout(LayoutKind.Explicit)]
struct Foo
{
[FieldOffset(0)]
public int IntValue;
[FieldOffset(0)]
public long LongValue;
[FieldOffset(0)]
public double DoubleValue;
}
??上述結構體的記憶體布局如下圖:

??同樣,顯式指定記憶體也受記憶體對齊的影響,
5. 結構體雜談
5.1. 欄位 or 屬性
??前文給出的結構體幾乎都是直接使用欄位而非屬性,但這只是為了避開屬性的復雜性,從而更方便更直觀地說明結構體的一些性質,在實際使用中,鑒于屬性的種種好處,一般情況下即便是結構體也應該優先使用屬性,
??不過有時候確實使用欄位更好,例如定義與非托管代碼互動的結構體的成員就最好使用欄位,或者性能瓶頸確實出現在使用屬性上因此不得不直接使用欄位(大多數情況這并不可能),
??簡而言之,除非確實真的有必要,否則對于結構體也應該優先使用屬性而非欄位,
5.2. 類 or 結構
??通常使用結構體是因為結構體的堆疊上分配特點而有使其具有更好的操作性能,因此結構體通常是為高性能需要服務的,然而,結構體默認使用副本式傳值,可能會導致程式運行時創建不必要的副本,例如:
struct MethodArgs { ... }
MethodArgs args = new MethodArgs();
Method(args); // 第一次副本
Method(args); // 第二次創建副本
Method(args); // ...創建副本
??上述代碼中每一次傳參都會有一次額外的創建副本的開銷,然而如果將MethodArgs定義為class,則只會傳遞對同一個實體參考,則避免了創建副本的開銷,當然,可以通過按參考傳遞引數來解決,但這仍然不是一個完美的解決方法,
??此外,結構體不允許繼承,這意味著結構體的代碼重用能力遠低于類,并且結構體不會有多型行為,
??最后,從概念上來講,結構體是值型別,應當將其認為是表現類似于int、long、double這類數值型別的型別,遵循用于“表達一個值”的用法,并根據輕量原則,結構體不應該定義的過于復雜,
??基于上述原因,《CLR via C#》一書中建議在下面的情況下使用結構體:
- 型別定義十分簡單,具有類似基元型別的表現,
- 不需要繼承,也不需要被繼承,
- 記憶體的實體較小(16位元組以下)
- 或者記憶體實體較大(16位元組以上),但是不會作為方法的引數與回傳值使用
??不過例外的是,與非托管代碼互動時,記憶體布局明確且不受托管堆影響的的結構體有不可替代的優勢,此時使用結構體基本是唯一的選擇,
??綜上,盡管結構體由于堆疊上分配擁有一定的性能優勢,然而現實中更多地依然是使用的類,因為通常來說真正需要用到結構體的地方基本都是在對性能有更高要求的地方,但真正需要這種代碼的情況可能并不多見,并且雖然結構體的堆疊上分配使其操作起來很快并且不會影響GC與托管堆,但是副本式的賦值方式也意味著一般情況下存在復制一個完整結構體的開銷,所以結構體也基本只用在那些需要簡單把各種基元型別簡單包裝一下的場合,例如:
- 把2個int包一下(Point)
- 把三個float包一下(Vector3)
- 把4個byte包一下(模擬Int型別)
??同時,結構體本身最好使用只讀屬性或者很少有狀態更改(就像基元型別那樣);類則相反,由于往往用于表示一個物件,因此往往會用到很多欄位或者會涉及到各種各樣的狀態更改,如果確實不確定到底應該用類還是結構體,那么優先使用類,
5.3. 實作一個更好的結構體
(1)重寫Equals與GetHashCode方法
??默認的結構體實作會使用ValueType中重寫的Equals方法,默認實作會考慮對普遍情況的可靠性,但這往往意味著它對特定實作來說是低效的,實際上ValueType對Equals的重寫方法遠比想象中的復雜,考慮到值型別往往需要在性能敏感的場合使用,因此有必要手動重寫其Equals方法,關于如何重寫Equals方法已經在另一篇文章中提出,這里不再贅述,同樣的,也需要重寫結構體的GetHashCode方法,
(2)使用只讀結構體
??由于結構體最好具有基元型別的表現,因此最佳的做法是盡可能地將結構體定義為只讀結構體,
(3)實作IEqualable<>介面
??作為值型別,那么結構體自然也應該可以進行相等性比較,實作IEquatable<>介面可以為結構體提供更好的比較方法,并提高結構體的泛用性,
(4)多載==與!=運算子
??理由同上,使用==與!=對值型別進行相等性比較是符合直覺的,
(5)僅包含欄位、屬性與只讀方法
??盡管可行,但是不應該在結構體中定義事件,此外,結構體中的方法應該盡可能定義為只讀方法,或者說方法不應該修改結構體的欄位狀態,
5.4. 語法糖
??C#中提供了一種名為with運算式的語法糖來獲得對結構體進行非破壞性修改的副本:
Point p1 = new Point();
Point p2 = p1 with { X = 1 };
??上述代碼實際做的事如下:
Point p1 = new Point();
Point temp = p1;
temp.X = 1;
Poitn p2 = temp;
??在一些場合,這一語法可以有效減少不必要的代碼量,
5.5. 奇技淫巧
??如果結構體不是只讀結構體,那么下述代碼是可行的:
struct Point
{
public float X;
public float Y;
public void Reset(Point p)
{
this = p; // 修改this
}
}
??這并不奇怪,結構體在宣告時就分配好了記憶體,并遵循按副本賦值,因此上述代碼中this = p實際就是將p逐欄位賦值給this表示的實體的相應欄位的值而已,不過如果沒有必要還是應該避免這種迷惑性的操作,
5.6. 其他注意事項
(1)結構體型別無法嵌套,也就是說下面的代碼是不可行的
struct Foo
{
public Foo Foo; // 嵌套一個位元組
}
??顯然,這會導致遞回定義,所以是不允許的,
(2)構造方法必須保證對每個欄位都賦值
struct Foo
{
public int X;
public int Y;
public Foo(int x)
{
X = x;
}
}
??盡管可以看出上述代碼中的構造方法是想僅設定X的值,讓Y的值保持其默認值0,然而該構造方法無法通過編譯,因為構造方法中還沒有完成對欄位Y賦值,需要將其修改為如下:
struct Foo
{
public int X;
public int Y;
public Foo(int x)
{
X = x;
Y = 0;
}
}
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/487829.html
標籤:.NET技术
