字段是什麼
字段(Field)是型別用來保存狀態的成員。最常見的兩類是:
- 實例字段:每個物件各自持有一份。
- 靜態字段:整個型別共享一份。
public sealed class Demo
{
readonly int someReadonly = 2;
static int someStaticField = 3;
}
在 IL 裡,這兩種標記會對應成不同的 metadata:
// ildasm 中常見的樣子
someReadonly : private initonly int32
someStaticField: private static int32
字段本質上就是「值型別的實際資料」或「參考型別的引用」。實例字段跟著物件一起存在,靜態字段則跟著型別一起存在。
常見修飾詞
字段最常見的幾個修飾詞,可以先這樣理解:
| CLR / IL | C# | 作用 |
|---|---|---|
static | static | 屬於型別,而不是某個實例 |
instance | 預設 | 屬於物件實例 |
initonly | readonly | 正常情況下只能在建構階段寫入 |
volatile | volatile | 告訴編譯器與執行時不要隨意重排此字段的讀寫語意 |
其中 readonly 很容易被誤解。它保證的是「這個字段在建構完成後不能再被重新賦值」,不是保證整個物件一定不可變。
readonly 限制的是引用,不是物件內容
如果 readonly 字段保存的是參考型別,那麼不能改的是「它指向誰」,不是「被指向的物件一定完全不可變」。
public sealed class AType
{
public static readonly char[] CharArr = { 'A', 'B', 'C' };
}
public sealed class BType
{
public static void Modify()
{
AType.CharArr[0] = 'X';
AType.CharArr[1] = 'Y';
AType.CharArr[2] = 'Z';
// 編譯錯誤:不能重新指定 readonly 字段
// AType.CharArr = null;
}
}
所以 readonly 常常只能保證「引用穩定」,不能直接等同於「深層不可變」。
如果真的需要不可變資料,通常要搭配:
- 不可變型別設計
- 唯讀集合包裝
record/ immutable collection
public static readonly 與 const
這兩者看起來都像「不會變的值」,但在版本管理與程序集邊界上差很多。
const 是編譯期常量
const 的值會在使用端編譯時直接嵌入到呼叫方程式集中。這表示如果常量定義在 DLL 裡,EXE 編譯後就把那個字面值抄走了。之後你只重編 DLL、不重編 EXE,EXE 看到的仍可能是舊值。
public static readonly 是執行期取值
public static readonly 則是在執行時透過字段讀取,因此使用方不需要把值硬編進自己的 IL。只要重新部署被引用的 DLL,就能讀到更新後的字段內容。
// DLL
public sealed class SomeLibraryType
{
public static readonly string ReadOnlyString = "Orange";
}
// EXE
public sealed class Program
{
public static void Main()
{
Console.WriteLine("Readonly String: " +
SomeLibraryType.ReadOnlyString);
}
}
對應 IL 會是讀字段,而不是直接塞字面值:
IL_001e: ldstr "Readonly String: "
IL_0023: ldsfld string [Program]CLR_Ch7.SomeLibraryType::ReadOnlyString
IL_0028: call string [mscorlib]System.String::Concat(string, string)
也因此,如果 DLL 把 ReadOnlyString 從 "Orange" 改成 "Apple",只要重新部署 DLL,使用端就能看到新值。


實務上可以先這樣選:
- 真正的編譯期常量,用
const - 跨程序集公開的固定值,多半用
public static readonly
內聯初始化其實是語法糖
字段不只可以在建構函式裡初始化,也可以直接寫在宣告處:
public sealed class Program
{
public readonly int inlineVar = 100;
public readonly int constructorVar;
public Program()
{
constructorVar = 500;
}
}
這種 inline initializer 讀起來很方便,但編譯器最後仍然會把它轉成建構流程中的賦值動作。也就是說,它不是一種獨立的執行模型,只是更順手的語法。
欄位初始化會被複製到每個建構函式
如果一個型別有多個建構函式,而你又在字段宣告處寫了初始值,編譯器通常會把那段初始化邏輯複製到每個建構函式。
internal sealed class SomeType
{
int m_X = 5;
string m_S = "Test";
byte m_B;
public SomeType() { }
public SomeType(int x) { m_X = x; }
public SomeType(string s) { m_S = s; }
}
這代表每個 .ctor 都會先執行那一輪共同初始化,再執行自己特有的覆寫邏輯。建構函式越多,重複 IL 也越多。
用建構函式串接減少重複
如果你想避免每個建構函式都重複做同樣的初始化,可以把共用邏輯收斂到一個主建構函式,再讓其他建構函式透過 : this(...) 串接。
internal sealed class SomeTypeOptimized
{
int m_X;
string m_S;
byte m_B;
public SomeTypeOptimized()
{
m_X = 10;
m_S = "Test";
m_B = 0;
}
public SomeTypeOptimized(int x) : this()
{
m_X = x;
}
public SomeTypeOptimized(string s) : this()
{
m_S = s;
}
}
這樣做的好處不是什麼玄學優化,而是:
- 共用初始化邏輯只保留一份
- 行為集中,較不容易漏改
- 生成出的建構流程更容易推理
小結
字段本身不複雜,但有幾個很值得記住的點:
readonly保證的是字段引用或值在建構完成後不再改寫,不等於整個物件不可變。const與public static readonly最大差異在編譯期嵌入與執行期取值。- 內聯字段初始化只是語法糖,最後仍會落回建構流程。
- 當型別有多個建構函式時,善用
: this(...)可以避免重複初始化邏輯。
參考資料
- 《CLR via C#》