字段是什麼

字段(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 / ILC#作用
staticstatic屬於型別,而不是某個實例
instance預設屬於物件實例
initonlyreadonly正常情況下只能在建構階段寫入
volatilevolatile告訴編譯器與執行時不要隨意重排此字段的讀寫語意

其中 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 readonlyconst

這兩者看起來都像「不會變的值」,但在版本管理與程序集邊界上差很多。

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 保證的是字段引用或值在建構完成後不再改寫,不等於整個物件不可變。
  • constpublic static readonly 最大差異在編譯期嵌入與執行期取值。
  • 內聯字段初始化只是語法糖,最後仍會落回建構流程。
  • 當型別有多個建構函式時,善用 : this(...) 可以避免重複初始化邏輯。

參考資料

  • 《CLR via C#》