探索 local functions (1)

2020 新年快樂,一年的開始來討論一些 local functions (區域函式) 的眉眉角角。

使用環境
Visual Studio 2019 16.4.0
C# 8.0
.NET Core 3.1

 在 C# 7.0 時代,C# 引入了 local functions,今天來聊聊這個不太新的特性中的某些議題。

static or instance method?

首先要了解的一件事情是在編譯後的 IL code 是沒有區域函式這玩意的,所有在 C# 被稱之為區域函式的方法在 IL code 都會以獨立的方法存在。而區域函式在編譯後有兩種可能的存在,一個是編譯成靜態方法 (static method)、另一個則是編譯成執行個體方法 (instance method)。C# 8.0 中新增的靜態區域函式編譯後絕對會是 static method,這無庸置疑;但如果不是靜態區域函式呢?會依據怎麼樣的條件來決定最終型式?

根據測試的結果,編譯器是根據區域函式內部有無使用到所在型別的執行個體成員來決定是否要編譯為靜態函式,例如以下的幾個情況會被編譯成執行個體方法 (instance methods):

 class MyClass
 {
     public int _number = 0;

     public object Instance { get; set; } = new object();

     private void PrivateMethod()
     {
         Console.WriteLine($"private method , not local function");
     }

     public void Request()
     {
         LocalExecWithField();

         LocalExecWithProperty();

         LocalExecWithMethod();
        
         void LocalExecWithField()
         {
             Console.WriteLine($" {nameof(LocalExecWithField)} : {_number}");
         }

         void LocalExecWithProperty()
         {
             Console.WriteLine($" {nameof(LocalExecWithProperty)} : {Instance.ToString() }");
         }

         void LocalExecWithMethod()
         {
             Console.WriteLine($" {nameof(LocalExecWithMethod)} : {nameof(PrivateMethod) }");
         }
     }
 }

(1) LocalExecWithField 區域函式內部使用了 MyClass 的執行個體欄位 _number => LocalExecWithField 會被編譯為執行個體方法。
(2) LocalExecWithProperty 區域函式內部使用了 MyClass 的執行個體屬性 Instance => LocalExecWithProperty 會被編譯為執行個體方法。
(3) LocalExecWithMethod 區域函式內部使用了 MyClass 的執行個體方法 PrivateMethod => LocalExecWithMethod 會被編譯為執行個體方法。

也就是說以上的範例編譯後會類似下方的程式碼 -- 僅止於形式上的類似,其他部分像是 $ 字串插補與建構式等等沒有繼續展開:

 class MyClass
 {
     public int _number = 0;

     public object Instance { get; set; } = new object();

     private void PrivateMethod()
     {
         Console.WriteLine($"private method , not local function");
     }

     public void Request()
     {
         LocalExecWithField();

         LocalExecWithProperty();

         LocalExecWithMethod();         
     }

     private void LocalExecWithField()
     {
         Console.WriteLine($" {nameof(LocalExecWithField)} : {_number}");
     }

     private void LocalExecWithProperty()
     {
         Console.WriteLine($" {nameof(LocalExecWithProperty)} : {Instance.ToString() }");
     }

     private void LocalExecWithMethod()
     {
         Console.WriteLine($" {nameof(LocalExecWithMethod)} : {nameof(PrivateMethod) }");
     }
 }

只要區域函式內部沒有直接呼叫或使用任何所在型別的執行個體成員,該區域函式最終會被編譯成靜態方法 (static method)。

區域函式如何存取容器內定義的變數

所謂區域函式的容器,指的就是包住區域函式的成員,有可能是方法、建構式、屬性存取子等等,以前面的例子而言,Request method 就是 LocalExecWithField、LocalExecWithProperty 與 LocalExecWithMethod 的容器。在撰寫 C# 程式碼時區域函式可以存取容器中的區域變數與容器的方法參數,例如以下:

 class MyClass
 {

     public void Request()
     {
         int i = 0;

         Exec();

         void Exec()
         {
             Console.WriteLine($"{nameof(Request)} -- {nameof(Exec)} -- {i}");
         }
     }
 }

在 Exec() 這個區域函式的內部可以直接存取到 Request Method 中定義的 i 變數,這是如何辦到的?基本上編譯器會創造出一個結構型別並封入與區域變數相同型別的欄位,而區域函式則會被改成具有一個該結構型別參數的方法,參數的傳遞形式為 by reference,像上方的程式碼編譯後會近似於以下的程式碼:

 struct DisplayClass
 {
     public int i;
 }
 class MyClass
 {

     public void Request()
     {
         DisplayClass class_;
         class_.i = 0;
         Exec(ref class_);       
     }

     static void Exec(ref DisplayClass class_Ref1)
     {
         Console.WriteLine($"{nameof(Request)} -- {nameof(Exec)} -- {class_Ref1.i}");
     }
 }

(1) 先創造出一個結構,並且封入一個 int i 的欄位,DisplayClass 是我給予的一個類似編譯後的名稱,真實編譯出來的名字比較複雜,但後面是 Class 而非 Struct 這點還滿有趣的。
(2) 在 Exec method 加入一個 by reference 參數 ref DisplayClass ,名稱為 class_Ref1。並且改寫 Exec method 內部引用的 i 為 class_Ref1。
(3) 在 Request method 的開頭宣告 DisplayClass 型別的變數名稱為 class_ ,這一類自動產生的變數永遠會放在所有你在 C# 程式碼寫的變數之前。
(4) 在 Request method 中對於 i 賦值的程式碼由 int i = 0 修改為 class_.i  = 0。
(5) 在 Request method 中對於 Exec 的呼叫由無參數改為 Exec(ref class_)。

註:以上的命名都只是類似,事實上在編譯之後,連 Exec 方法的名稱都可能被改變。

如果我們將上述 Request method  程式碼的 int = 0 和 Exec() 對調則會得到編譯失敗的結果,這又是為什麼?也就是如下的寫法是會編譯失敗:

  class MyClass
  {
      public void Request()
      {
          Exec();
          int i = 0;           
          void Exec()
          {
              Console.WriteLine($"{nameof(Request)} -- {nameof(Exec)} -- {i}");
          }
      }
  }

這會得到一個編譯錯誤的訊息 -- CS0165 使用未指派的區域變數 i,既然是編譯失敗,我們當然沒辦法說編譯後的結果是什麼;但是我們可以依照前面的說明模擬大概的情形會類似撰寫以下的程式碼:

  class MyClass
  {     
      public void Request()
      {
          DisplayClass class_;           
          Exec(ref class_);
          class_.i = 0;
      }

      static void Exec(ref DisplayClass class_Ref1)
      {

          Console.WriteLine($"{nameof(Request)} -- {nameof(Exec)} -- {class_Ref1.i}");
      }
  }

編譯錯誤的訊息會出現在 Exec(ref class_) 這一行  --  CS0165 使用未指派的區域變數 class_,如果就這樣解釋就可以說得通了。不過這個行為其實是由 C# 編譯器主導的,在 IL code 應該是沒有這個限制,但如果連編譯成 IL code 都沒機會就甚麼都不用提了,但以下的事情卻是一個特例。

再改變一件事情,如果我們在 Exec() local function 中對 i 賦值又將會甚麼情形?修改上述程式碼,在 Exec() method 中加入一行 i = 99。

  class MyClass
  {

      public void Request()
      {
          Exec();
          int i = 0;
          void Exec()
          {
              i = 99;
              Console.WriteLine($"{nameof(Request)} -- {nameof(Exec)} -- {i}");
          }
      }
  }

這是可以編譯成功而且正確執行的,開發團隊在這邊做了一個有趣的決定 -- 在這個狀況下忽略 CS0165 的錯誤 (我猜想以後可能有機會改為編譯失敗,僅止於猜測)。詳情可以參考 https://github.com/dotnet/roslyn/pull/15584。

所以展開後是類似以下的程式碼,原本在 Exec(ref class_) 這一行應該出現的 CS0165 錯誤就微妙地被忽略了,但這只容許編譯器自己長程式碼的時候,你如果照著以下的程式碼寫是一樣不會過,這事情還滿奇妙的。

  struct DisplayClass
  {
      public int i;
  }
  class MyClass
  {
      public void Request()
      {
          DisplayClass class_;
          Exec(ref class_);
          class_.i = 0;
      }

      static void Exec(ref DisplayClass class_Ref1)
      {
          class_Ref1.i = 99; 
          Console.WriteLine($"{nameof(Request)} -- {nameof(Exec)} -- {class_Ref1.i}");
      }
  }

其他的議題我們下篇再聊。