第 87 講:C# 2 之定長緩沖區(qū)字段
今天我們要完結(jié) C# 2 的語法。今天講的是 C# 2 里最后一個新語法特性,叫定長緩沖區(qū)字段(Fixed-Sized Buffer)。
本節(jié)內(nèi)容和 C 語言關(guān)系較大。如果你對 C 語言不熟悉,本節(jié)內(nèi)容可能需要你重新復(fù)習(xí)一下 C 語言里有關(guān)結(jié)構(gòu)體語法的內(nèi)容。當(dāng)然,也不必學(xué)得非常熟悉,這里大概直到結(jié)構(gòu)體在 C 語言里是啥語法,啥機(jī)制就夠了。下面對于一些 C 語言的細(xì)節(jié)會有闡述和描述,所以不必?fù)?dān)心細(xì)節(jié)記不住的問題。
Part 1 引例
C# 的靈活性不只是新的語法,它對 C/C++ 語言里的指針也有保留。不過,可能你看過本教程之前關(guān)于指針的相關(guān)語法,可以發(fā)現(xiàn)指針并不是那么容易使用,還必須配合 fixed
關(guān)鍵字,綁定一個數(shù)組和一個指針變量的方式,才能使用指針,因為還得考慮避免 GC(垃圾回收器)的回收機(jī)制。因此,指針仍舊不方便。C# 2 誕生了第一種擴(kuò)展指針語法的機(jī)制。
考慮一種情況,C/C++ 里允許在結(jié)構(gòu)體(C# 里叫結(jié)構(gòu))里存儲數(shù)組元素。比如學(xué)生類型:
C 語言里是這么書寫的。typedef
表示類型定義,這樣定義就不必每次你都要把 struct
關(guān)鍵字給帶上;而 _Bool
是 C 語言的語法,表示布爾類型。因為 C 語言原生語法里不支持布爾類型,都是用的整數(shù)來表示真假的,因此 C 語言為了兼容和擴(kuò)展語法,C99 標(biāo)準(zhǔn)里誕生了布爾類型,并定義布爾類型使用的關(guān)鍵字為 _Bool
。
順帶一說。
_Bool
也可以寫成bool
,不過不論是_Bool
也好還是bool
也好,你都需要導(dǎo)入stdbool.h
這個頭文件,即必須使用 C 語言的#include
指令導(dǎo)入該頭文件,才能使用bool
或者_Bool
。而要說_Bool
和bool
的區(qū)別,其實一個 C 語言的#define
就可以搞定:#define bool _Bool
。是的,我們直接定義bool
作為_Bool
的代替。所以,它們是這么個關(guān)系。
在 C# 里,我們無法做到。一來是因為 C# 有字符串語法,因此你可以這么寫:
string
就可以代替掉原本的 char
數(shù)組在結(jié)構(gòu)里的東西了。當(dāng)然,public
當(dāng)然,有了封裝機(jī)制所以寫起來比起 C 語言要復(fù)雜一些。
正是因為 C# 里有 string
來代替 C 語言的 char
類型的數(shù)組,因此 C# 完全不必需要類似 C 語言的結(jié)構(gòu)體里自帶的 char Name[10]
類似的語法。不過,因為 string
是引用類型,因此該數(shù)據(jù)類型在 Student
類型里是以地址數(shù)值存儲起來的。但 C 語言里,這樣的字段是“平鋪”的。所謂的“平鋪”,指的是這個 Name
在 C 語言結(jié)構(gòu)體里是整個數(shù)組元素全部都位于 Student
的內(nèi)存空間里的。換句話說,C 語言里字符是 1 字節(jié),所以 char Name[10];
語句等于是聲明了一個 10 字節(jié)的內(nèi)存空間,也就是說,可以存儲 10 個字符的內(nèi)存大小。而這 10 字節(jié),全部都是在 Student
的內(nèi)存空間里的,而不像是 C# 那樣,存儲的是地址數(shù)值。這是 C 語言和 C# 在底層上,這種聲明形式和過程的區(qū)別。
那么,一些時候我們?nèi)孕枰x類似 C 語言這樣的“平鋪”版的字段信息,可 C# 原生語法做不到。怎么辦呢?這就需要今天講解的新語法了:fixed
字段。
我們使用類似 C 語言里的這個語法來表達(dá)平鋪字段信息,而它在 C# 里,為和普通的字段作區(qū)分,需要對這樣的字段使用 fixed
關(guān)鍵字,那么,完全等價 C 語言里的 Student
結(jié)構(gòu)體類型的 C# 語法在 C# 2 里就可以寫出來了:
這個 Name
就是 C 語言等價的平鋪字段了。我們把 C# 里這個平鋪的字段叫做緩沖區(qū)字段(Buffer)。
這里需要你注意 unsafe
關(guān)鍵字。這是 C# 里對 C 語言這種字段唯一的一個需要注意的地方。而為什么它需要 unsafe
呢?下面我們就要說說,它的使用方式了。
Part 2 緩沖區(qū)字段的用法
怎么使用緩沖區(qū)字段呢?還記得 C 語言的語法嗎?C 語言我們使用 malloc
在堆內(nèi)存里開辟一個內(nèi)存空間,存儲 Student
類型的變量。然后往里面賦值。
student
malloc
函數(shù),因此它存儲到堆內(nèi)存里。而堆內(nèi)存的對象是需要手動內(nèi)存釋放的,因此你需要用 free
下面我們對于這個代碼實現(xiàn)說說細(xì)節(jié)吧。
2-1 第 12 行代碼:unsafe
修飾符
注意到 Main
方法的聲明上也有 unsafe
。這是因為 Student
的緩沖區(qū)字段 Name
在使用的時候是按指針來用的,因此你需要加 unsafe
啟用不安全代碼的使用。稍后第 16 行代碼的對應(yīng)解釋文字里會給出說明。
2-2 第 14 行代碼:Student
類型實例化
第 14 行代碼里,student
變量使用了 new
語句來實例化。.NET 實際上也有和 C 語言里和 malloc
函數(shù)等價的方法,位于 NativeMemory
靜態(tài)類型里,包含一個叫做 Alloc
的方法,是一樣的。不過它需要傳入一個全新的數(shù)據(jù)類型叫 nuint
,它是 C# 9 開始才能使用的數(shù)據(jù)類型,因此這里沒辦法做到完全等價,只能做到盡量等價。
malloc
函數(shù)是不初始化內(nèi)存塊的,也就是說里面是垃圾數(shù)值;而new
語句會初始化內(nèi)存塊,更像是 C 語言里的calloc
函數(shù)。如果你不想初始化內(nèi)存的話,你需要使用一個泛型方法:Unsafe.SkipInit
。代碼是這樣的:
是的,使用了
out
參數(shù)來完成帶出實例的方式。這個SkipInit
方法在底層啥事都沒有做,只是故意從out
參數(shù)上故意返回了一個未初始化的實例對象。而這里的SkipInit
方法就和malloc
2-3 第 15 行代碼:fixed
固定字符串
C# 的原生語法還不支持固定一個字符串。在 C# 里,字符串是特殊的數(shù)據(jù)類型,它和 C 語言的字符串在設(shè)計上并不相同。C 語言的字符串是由字符數(shù)組呈現(xiàn)和表達(dá)的,它一般記作 char *s
或 char s[]
。但是 C# 的字符串是一個全新的數(shù)據(jù)類型 string
(BCL 名稱為 System.String
)。
正是因為設(shè)計上不同,因此 C# 不支持直接對字符串使用 fixed
語句。要知道,fixed
語句用在數(shù)組的固定上,防止對象提前被 GC 回收導(dǎo)致使用指針變得危險。就是因為字符串此時已經(jīng)不再是字符數(shù)組,因此字符串不能直接固定。那么怎么辦呢?我們可以用到 ToCharArray
這個實例方法。我們在字符串實例后跟上 ToCharArray
實例方法調(diào)用,就可以將 string
轉(zhuǎn)為 char[]
數(shù)據(jù)類型。
可你會問我,為啥我非轉(zhuǎn)字符數(shù)組不可?下面就得說下一行代碼了。
2-4 第 16 行代碼:實現(xiàn)拷貝內(nèi)存塊的過程
C# 沒有像是 C 語言那樣靈活操作指針的操作,因此很多 C 語言有的函數(shù),C# 里不一定有。當(dāng)然,也有一部分是有的,只不過也因為用得很少而導(dǎo)致鮮有人知道它們。
這里我們要說一個方法,叫 Unsafe.CopyBlock
。它是靜態(tài)方法,位于 Unsafe
類型里(這不是廢話嗎)。這個 CopyBlock
方法的目的是按內(nèi)存塊拷貝數(shù)據(jù),它在邏輯上等價于你用 for
循環(huán)對數(shù)組挨個賦值差不多的效果。不過 CopyBlock
更加簡潔,而且效率更高,因為它用的是指針在操作。
Unsafe.CopyBlock
方法一共有兩個重載版本,一個是傳入帶 ref
修飾符的,一個則是傳入 void*
類型的。這里我們指針這個重載版本。這個重載版本下有三個參數(shù)。第一個參數(shù)是 void* destination
,看參數(shù)名就看得出來它表示的是目標(biāo)內(nèi)存塊的首地址。它表示你需要把數(shù)據(jù)拷貝到哪里去。第二個參數(shù)是 void* source
,它表示復(fù)制什么內(nèi)存塊的數(shù)據(jù)。這個方法的作用是把內(nèi)存塊 1 的數(shù)據(jù)原封不動復(fù)制到內(nèi)存塊 2 里,那么這里所謂的“內(nèi)存塊 1”就是這個 source
參數(shù)了,而“內(nèi)存塊 2”就是 destination
參數(shù)了。第三個參數(shù)需要指定拷貝多少字節(jié)的內(nèi)存空間。
現(xiàn)在,我們要把名字 "Sunnie"
字符串表達(dá)的字符數(shù)組拷貝到 student.Name
字段里去,而 Name
已經(jīng)是一個字符類型的指針了,因此按照調(diào)用 Unsafe.CopyBlock
重載的規(guī)則,第二個參數(shù)也不得不是一個指針類型的數(shù)值。所以,我們需要第 15 行的代碼,將字符數(shù)組 char[]
固定一下,得到 char*
類型的指針,才可操作起來。得到 p
之后,我們第一個參數(shù)和第二個參數(shù)的數(shù)值就有了:destination
參數(shù)上直接傳入 student.Name
字段,而 source
參數(shù)上則傳入 p
即可。那么第三個參數(shù)是多少呢?"Sunnie"
的長度,乘以每一個字符占據(jù)的內(nèi)存字節(jié)數(shù),就是拷貝的長度了。因此,第三個參數(shù)的表達(dá)式應(yīng)為 sizeof(char) * "Sunnie".Length
。當(dāng)然,這里我們知道 "Sunnie"
字符串長度為 6,因此直接寫 sizeof(char) * 6
甚至是 12
就可以。特別注意,sizeof(char)
表示一個字符占據(jù)多大的內(nèi)存空間,在 C# 里,這個結(jié)果為 2 而不是 C 語言的 1,特別需要你注意。所以 sizeof(char) * 6
等于是在計算 2 * 6
,因此是 12,而不是 6。
總之,就是因為這樣的原因,Unsafe.CopyBlock(student.Name, p, sizeof(char) * 6);
這個語句長這樣才是這個原因。這里就可以看出來為什么有 unsafe
修飾符了。student.Name
這個緩沖區(qū)字段,在 C# 里是按指針形式使用的,即使它寫成數(shù)組的寫法,但操作的時候是按指針操作的,因此包裹緩沖區(qū)字段的類型需要有 unsafe
修飾符,而使用期間也需要有 unsafe
修飾符提供不安全代碼操作的環(huán)境。
2-5 第 21 行代碼:new string(char*)
構(gòu)造器
在 C# 原生語法里,字符串有一個構(gòu)造器,傳入 char*
參數(shù)即可將 char*
表示起來的字符數(shù)組改造為字符串?dāng)?shù)據(jù)類型的實例。你無需關(guān)心它的實際長度,只要上下文里這個 char*
指向的實例是安全、正常使用的(比如上面這樣用就是正常安全地在使用),你就無需擔(dān)心什么默認(rèn)沒有 '\0'
啊之類的問題。C# 也確實根本不關(guān)心你字符數(shù)組末尾有無 '\0'
,字符串的處理機(jī)制非常特殊,它使得 C# 里即使你在字符串中間有 '\0'
也是 OK 的。
至此我們就給大家解釋了一下,真正意義上如何使用緩沖區(qū)字段的基本語法和使用規(guī)則??偠灾芎唵我痪湓挘?/span>緩沖區(qū)字段是把 T[]
當(dāng)成 T*
在使用??聪逻\行結(jié)果吧。

Part 3 使用限制
fixed
字段里去呢?
布爾類型:
bool
;字符類型:
char
;整數(shù)類型:
byte
、sbyte
、ushort
、short
、uint
、int
、ulong
、long
;浮點數(shù)類型:
float
、double
。
是的,僅限內(nèi)置的值類型。string
和 object
作為內(nèi)置引用類型,也是無法使用 fixed
的,因為它們不定長;而這些數(shù)據(jù)類型都是定長的,而且是永遠(yuǎn)都可以立馬得到內(nèi)存占的字節(jié)數(shù)。不過請你注意,在內(nèi)置值類型里,decimal
沒有在這個列表里出現(xiàn),因為 decimal
占據(jù) 16 個字節(jié)不說,它的處理也相對很復(fù)雜:它是通過基本數(shù)據(jù)類型搭建起來的二次數(shù)據(jù)類型(所謂的二次數(shù)據(jù)類型就是,將基本數(shù)據(jù)類型通過基本操作封裝起來的新的數(shù)據(jù)類型,比如我們自定義的結(jié)構(gòu)和類之類的),因此它也不支持 fixed
修飾。
雖然種種跡象表明,decimal
非常特殊——它有自己的常量和字面量語法,但 decimal
是用三個字段(分別是 int
類型、uint
類型和 ulong
類型)搭建起來的數(shù)據(jù)類型。如果你想知道,decimal
類型到底是如何實現(xiàn)的,你可以通過查看它的源代碼,了解它的實現(xiàn)細(xì)節(jié)。
Part 4 緩沖區(qū)字段的底層實現(xiàn)
終于到了說明定長緩沖區(qū)字段的底層實現(xiàn)的環(huán)節(jié)了。既然定長緩沖區(qū)字段是直接 fixed
關(guān)鍵字來搞定的,那么它的底層是如何做到的呢?
我們?nèi)耘f使用前文介紹的示例代碼來介紹:
而它會被 C# 編譯器改寫成這樣:
4-1 FixedBuffer
嵌套結(jié)構(gòu)
C# 將 fixed
改造為了一個實際的數(shù)據(jù)類型,這個數(shù)據(jù)類型就是這里我們看到的 FixedBuffer
類型。這個類型相當(dāng)神奇:它只有一個字段,只占據(jù)兩個字節(jié)的內(nèi)存空間,因為它只包含一個 char
類型的字段。但是我想告訴你的是,這個 FixedBuffer
類型實際上占據(jù) 20 個字節(jié)的內(nèi)存空間。為啥是 20?因為 sizeof(char) * 10
是 20,而這個 10 就是我們原先在 fixed
字段里設(shè)定的這個 10,表示有 10 個 char
的實例的平鋪內(nèi)存空間。
可是,一個字段怎么會改變整個數(shù)據(jù)類型的所占字節(jié)數(shù)呢?這怎么可能?答案其實是它頭上的 [StructLayout]
特性標(biāo)記。這個特性標(biāo)記我們以前從未講過,但在 C# 原生語法下就已經(jīng)有這個特性了。
因為整篇文章的結(jié)構(gòu)和體系講解的安排,我把這個特性的講解內(nèi)容放在了稍后的下一個部分(Part 5)里。如果你想要立刻完整了解它的具體實現(xiàn)和用法,你可以暫時跳轉(zhuǎn)到下一個部分去看這些內(nèi)容,然后待了解完后回到這里繼續(xù)。
接著是 [CompilerGenerated]
特性標(biāo)記。這個已經(jīng)出現(xiàn)很多次,我都不必多說了吧。它表示和用來暗示,這段代碼是編譯器自動生成的,我們作為用戶不建議使用這個特性,而編譯器會使用這個特性給它生成的代碼的部分上打上這個標(biāo)記,用來區(qū)分和辨別哪些代碼是編譯器生成,哪些代碼是用戶自己寫的代碼。
最后,[UnsafeValueType]
特性標(biāo)記表示,這個類型是有明顯溢出使用的現(xiàn)象的數(shù)據(jù)類型??梢钥吹?,一個 char
字段只占據(jù) 2 個字節(jié)的內(nèi)存空間,而整個數(shù)據(jù)類型占 20 個字節(jié),顯然有 18 個字節(jié)在我們這里聲明的地方是“看不到”的。我們把這樣的類型叫做存在潛在溢出(Potential Overflow)的類型。按照基本的使用規(guī)則,只要類型會出現(xiàn)這樣的現(xiàn)象,我們就應(yīng)該標(biāo)記整個特性到結(jié)構(gòu)上方,可以引導(dǎo) CLR(公共語言運行時,也就是真正意義上的、運行 C# 程序的環(huán)境和框架)知道和了解這個數(shù)據(jù)類型的真實使用目的。而這個特性是 C# 編譯器加上去的,而實際上這個特性一般也只有編譯器才會用,我們作為用戶一般是接觸不到它的。因此,這里了解一下即可。
4-2 Student
結(jié)構(gòu)的 Name
字段
既然都生成了類型了,那么自然字段也會跟著變。當(dāng)然,變化也不多。這里的 fixed char Name[10]
直接翻譯成 FixedBuffer Name
就可以了。
你想想,一個 FixedBuffer
占 20 個字節(jié),我只需要給出基本的字段 char
,我直接取字段的地址,就相當(dāng)于得到的是整個 char Name[10]
的類似字符數(shù)組概念下的首地址。那么這么聲明完全是有效且可行的。所以,字段是這么改變的。
另外,這個字段是特殊的,因為它是編譯器改變過的字段,因此總得有什么辦法區(qū)分一下。這里用到了 [FixedBuffer]
特性來完成。這個特性標(biāo)記,標(biāo)記到字段上,表示的是這個字段的基本數(shù)據(jù)信息。比如原先寫的是 fixed char Name[10]
,那么 char
類型和 10 都會在特性上有所體現(xiàn)。仔細(xì)看看這個類型就可以發(fā)現(xiàn),它帶有兩個參數(shù)來實例化特性的實例。第一個參數(shù)是 Type
類型,表示和指示這個 fixed
字段是啥類型的,而第二個參數(shù)就是指代多少個元素了。
那么,這個被編譯器改良過的 Student
類型我們就說完了。下面我們來說說 Main
方法里的代碼。
4-3 Main
方法里的代碼
下面我們來說說,Main
方法里到底都寫了一些啥。
首先 Student
類型實例化了一個實例,變量為 student
。接著,我們通過了 Unsafe.CopyBlock
來拷貝復(fù)制了整個“字符數(shù)組”的元素。這次我們需要兩個地址,第二個參數(shù)(source
參數(shù))的地址數(shù)值仍然不變,原來是 p
現(xiàn)在還是 p
。但是第一個參數(shù)需要改造一下了。因為現(xiàn)在我們使用的 Name
類型改為了我們嵌套的結(jié)構(gòu) FixedBuffer
類型,因此我們需要通過該類型唯一的字段 FixedElementField
來訪問和獲取整個 20 字節(jié)的內(nèi)存空間。那么,既然類似字符數(shù)組的存儲機(jī)制,那么我們只需獲取該字段的地址,是不是就等于是在說,獲取了整個“字符數(shù)組”的首地址?。恳虼?,翻譯的代碼里是用 &student.Name.FixedElementField
來做到的。這個寫法非常有意思,我們從未使用過對字段取地址的過程,而我們通過之前的 C# 教程僅會通過取地址符 &
去取臨時變量的地址。實際上,獲取字段的地址在 C# 語法里也是支持的,下面給出一個最精簡的、使用字段地址的代碼:
&new S().Field
。因為 new S()
是一個表達(dá)式,它產(chǎn)生的實例你無法獲取地址,畢竟你是直接對字段取地址的。從另一個角度而言,new S()
是表達(dá)式,它是一個結(jié)果,它不是一個變量。我們能獲取信息,只能通過變量或者參數(shù)來獲取,而 new S()
不是參數(shù)也不是變量。
拆行就可以了:因為 s
此時是一個臨時變量,由我們自身定義和控制。而 S
的特殊性,它只包含一個 int
字段,該字段是長度可以預(yù)知的。既然 Field
字段能預(yù)知其內(nèi)存占據(jù)的字節(jié)數(shù),那么自然而然地,S
類型也就能得到了:因為它自身只包含這一個可預(yù)知長度的類型作為字段出現(xiàn),別的啥都沒有,你自然就可以猜想到,S
總長度應(yīng)該只需要有存儲 Field
字段的內(nèi)存空間就夠了,因此是 4 個字節(jié)。所以,&s.Field
合法的原因就在于,s
能夠安全地獲取地址,那么也就說明了 Field
實例字段的地址也能安全地獲取了。
回到剛才的例子。表達(dá)式 &student.Name.FixedElementField
合法的原因在于,&student
是合法的表達(dá)式,因此它的 Name
屬性的地址能獲?。欢?&student.Name
的地址可以正確獲取,因此將其當(dāng)作變量來看,&student.Name.FixedElementField
就是可以獲取的,因為 FixedElementField
它也是一個字段,和這里所說的 Field
字段是完全相同的思路。
順帶一說。啥樣的地址不能正常獲取呢?引用類型。引用類型是經(jīng)常受 GC 控制的。因為 GC 不定期或定期回收垃圾內(nèi)存的時候,會做一次緊湊操作,此時會改變所有移動過位置的對象的地址。而值類型也不一定不受 GC 控制,按照最簡單的定義規(guī)則來看,只有包含 .NET 內(nèi)置值類型(這里由于
decimal
的復(fù)雜性,我們暫時把它除開不看)的字段的數(shù)據(jù)類型,才可以不受 GC 控制。但是,如果這樣的數(shù)據(jù)類型作為了引用類型的一個字段的話,這又會受到 GC 控制了。能獲取地址的對象,只能是參數(shù)和臨時變量,而且這個參數(shù)和臨時變量的類型,得是內(nèi)置值類型,或只包含這些內(nèi)置值類型封裝起來的結(jié)構(gòu)。只有這些類型的對象,才能獲取地址;當(dāng)然,這些類型不一定要獲取對象本身的地址,如果它們包含字段,也可以獲取它們自身的字段的地址。
稍微注意一下第三個參數(shù) 12U
。千萬別忘了它實際上是 uint
類型的字面量。這里第三個參數(shù)確實需要我們傳入的是 uint
類型的數(shù)值,而因為 sizeof(char)
和 6 都是常量,因此它們的乘積也是常量。而常量編譯器會自動按類型上去推斷和處理,所以我們無需在這里寫成 (uint)(sizeof(char) & 6)
。但是如果 6 我們寫的是 "Sunnie".Length
的話,因為它不再是常量,因此這種情況下需要 uint
類型的強(qiáng)制轉(zhuǎn)換:(uint)(sizeof(char) * "Sunnie".Length)
。
剩下的代碼都是可以自己看懂的部分了,就不作解釋了。
代碼的底層就說完了??偠灾覀兛梢愿爬ㄆ饋恚?/span>fixed
字段被翻譯為了一個嵌套結(jié)構(gòu)類型,并直接把 fixed
字段的實際數(shù)據(jù)類型改成了這個嵌套結(jié)構(gòu)類型;在使用緩沖區(qū)字段的時候,會被自動改成獲取這個緩沖字段下的 FixedElementField
字段的地址。進(jìn)而來計算和獲取整個緩沖區(qū)內(nèi)存下的數(shù)據(jù)。
下面我們來說說,之前從未接觸到的 StructLayoutAttribute
。
Part 5 StructLayoutAttribute
控制結(jié)構(gòu)的內(nèi)存布局規(guī)則
其實,這個特性早在原生語法里就有。但是因為語法特殊性,它用到了的是特性的語法是屬于反射的內(nèi)容,而它操作的不安全的代碼又是指針的內(nèi)容,而改變布局行為的具體操作則是面向?qū)ο蟮膬?nèi)容??偟膩碚f,它是一個綜合性很強(qiáng)的數(shù)據(jù)類型,因此我們無法在原生語法里提及它有關(guān)的任何東西。那么,既然有了今天內(nèi)容的鋪墊,我們也學(xué)習(xí)了特性的知識點了,那么這樣的特性就可以學(xué)習(xí)一下了。對于一些大量使用指針運算和處理機(jī)制的時候(比如 Socket 編程之類),這個特性就顯得尤為重要。
5-1 StructLayoutAttribute
是什么?它的目的是什么?
[StructLayout]
特性標(biāo)記一般用于一個結(jié)構(gòu)(或者是一個類)上面,表示你想要自己定義和排版類型數(shù)據(jù)成員的布局規(guī)則。舉個例子,給你一個收納箱,你需要把東西放進(jìn)去。東西的擺放方法有很多種,C# 會選擇一種執(zhí)行代碼效率適中、并且占據(jù)內(nèi)存大小適中的模式來作為布局的默認(rèn)情況。在這種情況下,類型可能會為了提升運算執(zhí)行性能,會補(bǔ)充和插入一些空白內(nèi)存空間補(bǔ)充和占位,它們稱為填充比特或者填充位(Padding Bits),而這種現(xiàn)象則稱為位填充(Bit-Padding)。
注意,雖然這個特性的名字叫 struct layout attribute,但它不只是用于 struct(結(jié)構(gòu))。它確實可以用于類上面。只是,用在結(jié)構(gòu)上是最正常普遍的做法。稍后我們會說明,為什么它可以用于類上作為特性標(biāo)記。
考慮一種情況。假設(shè)我們把一個內(nèi)存空間看成收納箱,字段則作為補(bǔ)充和放入的內(nèi)存空間的占據(jù)實際內(nèi)存大小的數(shù)據(jù)。那么,假設(shè)一個數(shù)據(jù)類型是這樣的:
假設(shè)我給出這 6 個數(shù)據(jù)成員,它們都會存儲數(shù)據(jù)進(jìn)去,因此實例化的時候,會針對它們產(chǎn)生合適的內(nèi)存空間大小,存儲它們。那么一個合適的存儲方式可以是這樣的:

首先 Name
是指針存儲,string
是引用類型,是以指針(作為地址數(shù)值)存儲的。如果你的電腦是 64 位的話,這個指針數(shù)值大小就恰好是 8 字節(jié)的內(nèi)存空間。因此我這里展示的就是 64 位的情況。接著,Age
是 int
類型,占 4 個字節(jié)的內(nèi)存空間;Chinese
、Math
和 English
均為 float
類型,都也分別占據(jù) 4 字節(jié)內(nèi)存空間,因此我們可以先排好它們。最后 IsBoy
是 bool
類型的,它只占 1 個字節(jié),因此我們拿出 1 字節(jié)放進(jìn)去后,發(fā)現(xiàn)有 3 個字節(jié)無法放數(shù)據(jù);即使我們把 Chinese
的前三個字節(jié)放在圖中第 13 到 16 字節(jié)里,那么也會多出 1 個字節(jié)。在 .NET 系統(tǒng)里,如果有單獨的 1 字節(jié)的數(shù)據(jù)的話,放進(jìn)去總會多出 3 個字節(jié)。那么這個時候,IsBoy
后會自動補(bǔ)充上 3 個字節(jié)為填充位。填充位的數(shù)據(jù)沒有任何意義,只是為了保證每一個字段的開始是從 4 的倍數(shù)的位置開始的。為啥非要這么干呢?因為索引和找地址方便快捷啊。我 4 個字節(jié) 4 個字節(jié)地移動指針,和我移動若干次 4 字節(jié)指針然后改成移動 1 字節(jié)指針了,是不是會覺得別扭?
于是,.NET 聰明地處理了這一點。填充位就是用來干這個的。當(dāng)然,經(jīng)過填充位的處理后,整個數(shù)據(jù)類型占據(jù)的字節(jié)數(shù)就會比原本只存儲數(shù)據(jù)成員總共占據(jù)的字節(jié)數(shù)要大一些。比如原本是 8 + 4 * 4 + 1 = 25 字節(jié),現(xiàn)在可能就變?yōu)?8 + 4 * 4 + 4 = 28 字節(jié)了。
當(dāng)然,也可以這樣:

這種情況下,填充位就有 7 字節(jié)了,這樣的話,總大小就變?yōu)榱?32 字節(jié)了。
如果你想要故意改動布局規(guī)則(比如我想故意先把 Chinese
、Math
和 English
三個字段放在開始,放在 Name
的前面),那么我們就可以使用 [StructLayout]
來干這個事情。
5-2 構(gòu)造器參數(shù):LayoutKind
枚舉
下面我們來從構(gòu)造器參數(shù)下手,講解如何變更布局模式。
這個特性在標(biāo)記的時候會用到一個參數(shù),叫做布局的模式,它的數(shù)據(jù)類型是一個叫做 LayoutKind
的類型。LayoutKind
類型一共有三種可能數(shù)值:
LayoutKind.Sequential
(特征數(shù)值為 0):表示對象的數(shù)據(jù)成員將按照聲明的格式自動挨個進(jìn)行排版,并帶有填充位處理;LayoutKind.Explicit
(特征數(shù)值為 2):表示對象的數(shù)據(jù)成員將按照用戶自己指定的方式來排版,不帶填充位處理;LayoutKind.Auto
(特征數(shù)值為 3):表示對象的數(shù)據(jù)成員將自動按照 CLR 提供的算法自動處理,并自動補(bǔ)充填充位。
可以從介紹文字看出,只有 Explicit
這種模式下,不會有填充位的概念,因為這種布局規(guī)則是由用戶自己規(guī)定和定義的,它全權(quán)交給我們了;但別的兩種則都會按照固有的操作補(bǔ)充填充位。比如說,在 C# 處理的時候,我們這里看到的 Sequential
模式就對應(yīng)的是之前的那個圖片上的布局。
用法也很簡單,直接在類型的頭上標(biāo)記這個特性即可:
一般正常的情況是默認(rèn) Auto
模式,所以如果你要標(biāo)記特性又不想改變模式的話,就寫 Auto
即可。這種模式和不寫特性是一樣的行為。所有結(jié)構(gòu)的默認(rèn)布局規(guī)則都是 Auto
模式。
5-3 FieldOffsetAttribute
特性
為了能夠靈活變動和布局各個字段的位置,我們需要一個叫做 [FieldOffset]
的特性標(biāo)記。這個特性用于字段,它用來控制字段的偏移量(Offset)。所謂的偏移量,指的是這個字段的布局的位置相對于最開始的地址數(shù)值差了幾個字節(jié)單位。

比如這個圖片的布局模式下,Age
的偏移量為 8,因為它是相對于數(shù)據(jù)類型開頭一共 8 字節(jié);換句話說,它是從第 8 個字節(jié)開始布局 Age
字段。那么 IsBoy
、Chinese
、Math
和 English
字段的偏移量都分別為 12、16、20 和 24。
如果我們要自己規(guī)定布局規(guī)則,那么偏移量這個概念就必不可少。我們使用 [FieldOffset]
特性標(biāo)記放在每一個字段上,標(biāo)識這個字段的偏移量。這樣就可以達(dá)到自定義布局的規(guī)則了。
比如上面這個圖里給的這個布局模式,我們需要這么改一下代碼:
我們需要改變第一行代碼里傳入的 LayoutKind
枚舉類型的數(shù)值。這種布局規(guī)則需要我們是顯式指定的,所以這里設(shè)置的模式應(yīng)為 Explicit
模式。接著,我們需要給每一個數(shù)據(jù)成員明確偏移量。注意,C# 規(guī)定你必須在布局的時候給出所有數(shù)據(jù)成員的偏移量。少一個都不行。
這樣的話就是合適的、正確的使用方式。當(dāng)然,這里要注意一下的是,這種顯式給出偏移量的情況一般只用于固定長度的數(shù)據(jù)類型作為數(shù)據(jù)成員的類型。這個話有點繞,你看這個例子里有一個 string
,它的長度就是不固定的。我們顯式給出偏移量就會導(dǎo)致一些潛在的 bug。比如電腦以后出現(xiàn)有 128 位的情況的時候,指針可能就是 16 字節(jié)了,此時你還是偏移量 8 的話,數(shù)據(jù)就不正常了。
5-4 命名參數(shù):CharSet
字段
CharSet
字段是第一個我們需要學(xué)習(xí)的 [StructLayout]
的命名參數(shù)。它一般也只用在布局規(guī)則里帶有 char
和 string
數(shù)據(jù)類型的時候。
要知道,C# 有時候會和 C/C++ 交互代碼使用。在這種時候,字符和字符串大小并不相同就會導(dǎo)致交互失敗或者出現(xiàn)異常情況。C# 的 char
默認(rèn)都是雙字節(jié)(兩個字節(jié))的,但 C/C++ 里(尤其是 C 語言),char
是單字節(jié)(1 個字節(jié))的。
這交互起來不出毛病么?當(dāng)然會出毛病。所以,C# 提供了這個命名參數(shù)來確定和表達(dá)我字符到底需不需要在布局的時候?qū)ψ址妥址膬?nèi)容進(jìn)行特殊處理和調(diào)整。這個命名參數(shù)包含 4 種取值:
CharSet.None
(特征數(shù)值為 1):這個情況已經(jīng)不用了。它目前等價于CharSet.Ansi
;CharSet.Ansi
(特征數(shù)值為 2):表示將其按 ANSI 字符串(也就是 C 語言那種單字節(jié)字符)進(jìn)行轉(zhuǎn)換;CharSet.Unicode
(特征數(shù)值為 3):表示將其按 Unicode 字符串(也就是 C# 現(xiàn)在用的這種雙字節(jié)字符)進(jìn)行轉(zhuǎn)換;CharSet.Auto
(特征數(shù)值為 4):表示處理的時候?qū)凑者\行的操作系統(tǒng)環(huán)境來臨時決定如何轉(zhuǎn)換。
因為這里沒有任何交互的功能,因此我們對此不作任何深層次的涉及。一般用 Auto
即可。
5-5 命名參數(shù):Pack
字段
Pack
字段可以控制和設(shè)置填充位補(bǔ)充是按多少字節(jié)為倍數(shù)計算。一般來說,Pack
字段的默認(rèn)數(shù)值是 0,也就是你不設(shè)置這個命名參數(shù)的時候的數(shù)值。它表示“視操作系統(tǒng)運行環(huán)境而定”。
一般我們會設(shè)置 4 或者 8,當(dāng)然你也可以設(shè)置別的情況,但只能在如下的數(shù)值里選擇:0、1、2、4、8、16、32、64、128。是的,必須是 2 的次冪,或者 0(默認(rèn)情況)。舉個例子吧。

Pack

這種情況,Pack
為 8。
5-6 命名參數(shù):Size
字段
Size
是最后一個命名參數(shù)。Size
表示整個數(shù)據(jù)類型占據(jù)多大的內(nèi)存空間。直接填入一個整數(shù)即可,它表示這個結(jié)構(gòu)的總內(nèi)存占據(jù)的字節(jié)數(shù)。這是一種強(qiáng)制規(guī)定,設(shè)置是 20,就一定是 20 字節(jié)。
可以注意到,緩沖區(qū)字段在翻譯代碼的時候,用到的就是這個 Size
命名參數(shù),設(shè)置的就是 20。
5-7 用 [StructLayout]
模擬 C 語言的共用體
因為 [StructLayout]
可以顯式給出每一個數(shù)據(jù)成員的偏移量,因此我們可以給所有的字段設(shè)置相同數(shù)值的方式達(dá)到 C 語言里共用體的效果。
考慮一個情況。假設(shè)提供了一個空間存儲一些常用數(shù)據(jù)類型的信息,但又不想浪費內(nèi)存空間,我就可以這樣:
我們故意對兩個緩沖區(qū)字段設(shè)置相同的偏移量,這樣我們就可以存儲到一個地方上去了。這樣就模擬出了 C 語言的共用體了。這種情況的 C 語言寫法是這樣的:
這樣可以節(jié)省空間。
5-8 [StructLayout]
不只用于結(jié)構(gòu)
是的,[StructLayout]
還可以用于類。為什么一個 [StructLayout]
用于和底層交互的特性可以用于 C# 的類呢?不是結(jié)構(gòu)更合理嗎?實際上,類和結(jié)構(gòu)的布局規(guī)則是不受數(shù)據(jù)類型的類別的影響的。它不管是類還是結(jié)構(gòu),在布局上,都是受到 CLR 的自身控制,只要字段合理,填充位合理,就能構(gòu)造出一個固定的算法。
只是說,類會受到 GC 內(nèi)存控制,因為它只能放在堆內(nèi)存里;而結(jié)構(gòu)可以放在棧內(nèi)存也可以放在堆內(nèi)存里,不一定會有 GC 的參與。但這些東西都跟內(nèi)存布局算法本身沒有關(guān)系。所以,你完全可以設(shè)置這個特性標(biāo)記到一個類上面。
那么,看完了這些,你再回頭看看那個特性設(shè)置,是不是就感覺會稍微簡單了一些呢?
至此,我們就完成了對 C# 2 所有語法的學(xué)習(xí)。下一節(jié)我們將進(jìn)入 C# 3 的新語法特性的學(xué)習(xí)。準(zhǔn)備好了嗎?