C# 指針之美


    將C#圖像庫的基礎部分開源了(https://github.com/xiaotie/GebImage)。這個庫比較簡單,且離成熟還有一段距離,但它是一種新的開發模式的探索:以指針和非托管內存為主的C#程序開發。
    我許多項目都是在這個庫基礎上的開發,實戰證明了它的有效。從今天起,將斷斷續續(太忙了)的寫一系列文章來介紹這種開發方法,介紹基於此的圖像編程。本文便是第一篇。
    以指針和非托管內存為主的C#程序開發,無論對.Net程序員來說,還是對傳統的C/C++程序員來說,均屬異類。然而這種方法在很多場景下是非常有效的,尤其是圖像編程,所謂談笑間,檣櫓灰飛煙滅,不外如是。
    既有C/C++的高性能,又能直接管理內存不給GC帶來壓力,同時又擁有.net開發的大部分優勢,可以快速迭代,何樂而不為呢?

一、簡潔優美的代碼

    本來初稿這節寫了好幾百字,將C#指針開發與C/C++開發,Java開發、D語言開發等進行對比,闡述理念。不過現在覺得,闡述一個新事物,沒有比用例子更直接的了。
    例子:打開一張圖像,先將它轉化為灰度圖像,再進行二值化(變成黑白圖像),然后進行染色,將白色的像素變成紅色。以上每一個過程都彈出窗體顯示出來。
    代碼截圖更有視覺沖擊力:

 
    像詩歌一樣簡潔和優美,這就是孤的代碼。具備C/C++的高性能和C#的行雲流水,同時又有IDE的強大生產力相助,說這些話已屬多余,看見這樣的代碼,更應該想到的是:妹紙,今天工作全部搞定,現在有空嗎,哥來接你。
    這才是工作,這才是生活。留下時間,看看書,看看漫畫,玩玩樂樂。最近在看《偷星九月天》,就拿滄殿來測試這段程序吧:

 


改編    程序員A:那幫孫子又新提了幾百條需求,老大,你要帶我們突圍嗎?
    程序員B:不是突圍,是殺光它們!

 
     下面,請跟隨我,來一段短程探險吧。
     (本文中的代碼可在 https://github.com/xiaotie/GebImage/tree/develop 處下載。打包下載地址見 集異璧圖像與視覺分析庫 )

二、C# 指針基礎

    在C#中使用指針,需要在項目屬性中選中“Allow unsafe code”:

 

    接着,還需要在使用指針的代碼的上下文中使用unsafe關鍵字,表明這是一段unsafe代碼。
    可以用unsafe {  } 將代碼圍住,如:

                      unsafe
                     {
                          new ImageArgb32(path).ShowDialog( " 原始圖像 ")
                             .ToGrayscaleImage().ShowDialog( " 灰度圖像 ")
                             .ApplyOtsuThreshold().ShowDialog( " 二值化圖像 ")
                             .ToImageArgb32()
                             .ForEach((Argb32* p) => {  if (p->Red ==  255) *p = Argb32.RED; })
                             .ShowDialog( " 染色 ");
                     }


    也可在方法或屬性上加入unsafe關鍵字,如:

      private  unsafe  void btnSubmit_Click( object sender, EventArgs e)

 

    也可在class或struct 上加上unsafe 關鍵字,如:

     public  partial  unsafe  class FrmDemo1 : Form

    指針配合fixed關鍵字可以操作托管堆上的值類型,如:

     public  unsafe  class Person
    {
         public  int Age;

         public  void SetAge( int age)
        {
             fixed ( int* p = &Age)
            {
                *p = age;
            }
        }
    }

    指針可以操作棧上的值類型,如:

              int age =  0;
              int* p = &age;
             *p =  20;
             MessageBox.Show(p->ToString());

 
    指針也可以操作非托管堆上的內存,如:

             IntPtr handle = System.Runtime.InteropServices.Marshal.AllocHGlobal( 4);
             Int32* p = (Int32*)handle;
             *p =  20;
             MessageBox.Show(p->ToString());
             System.Runtime.InteropServices.Marshal.FreeHGlobal(handle);


    System.Runtime.InteropServices.Marshal.AllocHGlobal 用來從非托管堆上分配內存。System.Runtime.InteropServices.Marshal.FreeHGlobal(handle)用來釋放從非托管對上分配的內存。這樣我們就可以避開GC,自己管理內存了。

三、幾種常用用法

    1、使用Dispose模式管理非托管內存

    如果使用非托管內存,建議用Dispose模式來管理內存,這樣做有以下好處: 可以手動dispose來釋放內存;可以使用using 關鍵字開管理內存;即使不釋放,當Dispose對象被GC回收時,也會收回內存。

    下面是Dispose模式的簡單例子:

View Code
 1          public  unsafe  class UnmanagedMemory : IDisposable
 2         {
 3              public  int Count {  getprivate  set; }
 4 
 5              private  byte* Handle;
 6              private  bool _disposed =  false;
 7 
 8              public UnmanagedMemory( int bytes)
 9             {
10                 Handle = ( byte*) System.Runtime.InteropServices.Marshal.AllocHGlobal(bytes);
11                 Count = bytes;
12             }
13 
14              public  void Dispose()
15             {
16                 Dispose( true);
17                 GC.SuppressFinalize( true);
18             }
19 
20              protected  virtual  void Dispose(  bool isDisposing )
21             {
22                  if (_disposed)  return;
23                  if (isDisposing)
24                 {
25                      if (Handle !=  null)
26                     {
27                         System.Runtime.InteropServices.Marshal.FreeHGlobal((IntPtr)Handle);
28                     }
29                 }
30                 _disposed =  true;
31             }
32 
33             ~UnmanagedMemory()
34            {
35               Dispose(  false );
36            }
37         }

    使用:

             using (UnmanagedMemory memory =  new UnmanagedMemory( 10))
            {
                 int* p = ( int*)memory.Handle;
                *p =  20;
                MessageBox.Show(p->ToString());
            }


    2、使用 stackalloc 在棧中分配內存
    C# 提供了stackalloc 關鍵字可以直接在棧中分配內存,一般情況下,使用棧內存會比使用堆內存速度快,且棧內存不用擔心內存泄漏。下面是例子:

              int* p =  stackalloc  int[ 10];
              for ( int i =  0; i <  10; i++)
             {
                 p[i] =  2 * i +  2;
             }
             MessageBox.Show(p[ 9].ToString());

 

    3、模擬C中的union(聯合體)類型
     使用 StructLayout 可以模擬C中的union:

         [StructLayout(LayoutKind.Explicit)]
         public  struct Argb32
        {
            [FieldOffset( 0)]
             public Byte Blue;
            [FieldOffset( 1)]
             public Byte Green;
            [FieldOffset( 2)]
             public Byte Red;
            [FieldOffset( 3)]
             public Byte Alpha;

            [FieldOffset( 0)]
             public Int32 IntVal;
        }

    這個和指針無關,非unsafe環境下也可使用,有很多用途,比如,序列化和反序列化,求hash值 ……

四、C# 指針操作的幾個缺點

    C# 指針操作的缺點也不少。下面一一道來。
    缺點1:只能用來操作值類型
    .Net中,引用類型的內存管理全部是由GC代勞,無法取得其地址,因此,無法用指針來操作引用類型。所以,C#中指針操作受到值類型的限制,其中,最主要的一點就是:值類型無法繼承。
    這一點看起來是致命的,其實不然。首先,需要用到指針來提高性能的地方,其類型是很少變動的。其次,在OO編程中有個名言:組合優於繼承。使用組合,我們可以解決很多需要繼承的地方。第三,最后,我們還可以使用引用類型來對值類型打包,進行繼承,權衡兩者的比重來完成任務。
    缺點2:泛型不支持指針類型
    C# 中泛型不支持指針類型。這是個很大的限制,在后面的篇幅中,我會引入模板機制來克服這個問題。同理,迭代器也不支持指針,因此,我們需要自己實現迭代機制。
    缺點3:沒有函數指針
    幸運的是,C# 中有delegate,delegate 支持支持指針類型,lambda 表達式也支持指針。后面會詳細講解。

五、引入模板機制

    沒有泛型,但是我們可以模擬出一套類似C++的模板機制出來,進行代碼復用。這里大量的用到了C#的語法糖和IDE的支持。
    先介紹原理:
    partial 關鍵字讓我們可以將一個類的代碼分在多個文件,那么可以這樣分:第一個文件是我們自己寫的代碼,第二個文件用來描述模板,第三個文件,用來根據模板自動生成代碼。
    三個文件這樣取名字的:

 

    XXXClassHelper 是模板定義文件,XXXClassHelper_Csmacro.cs 是自動生成的模板實現代碼。

    ClassHelper文件的例子:

namespace Geb.Image
{
     using TPixel = Argb32;
     using TCache = System.Int32;
     using TKernel = System.Int32;
     using TImage = Geb.Image.ImageArgb32;
     using TChannel = System.Byte;

     public  static  partial  class ImageArgb32ClassHelper
    {
         #region include "ImageClassHelper_Template.cs"
         #endregion
    }

     public  partial  class ImageArgb32
    {
         #region include "Image_Template.cs"
         #endregion

         #region include "Image_Paramid_Argb_Templete.cs"
         #endregion
    }

     public  partial  struct Argb32
    {
         #region include "TPixel_Template.cs"
         #endregion
    }
}

    這里用到了using 語法糖。using 關鍵字,可以為一個類型取別名。使用 VS 的 #region 來定義所使用的模板文件的位置。上面這個文件中,引用了4個模板文件:ImageClassHelper_Template.cs,Image_Template.cs,Image_Paramid_Argb_Templete.cs 和 TPixel_Template.cs。
    只看其中的一個模板文件  Image_Template.cs:

  using TPixel = System.Byte;
  using TCache = System.Int32;
  using TKernel = System.Int32;
 
  using System;
  using System.Collections.Generic;
  using System.Text;
 
  namespace Geb.Image.Hidden
 {
      public  abstract  class Image_Template : UnmanagedImage<TPixel>
     {
          private Image_Template()
             :  base( 1, 1)
         {
              throw  new NotImplementedException();
         }
 
          #region mixin
 
          public  unsafe TPixel* Start {  get {  return (TPixel*) this.StartIntPtr; } }
 
          public  unsafe TPixel  this[ int index]
         {
              get
             {
                  return Start[index];
             }
              set
             {
                 Start[index] = value;
             }
         }
 
   
   ……
 
 
          #endregion
     }
 }

    這個模板文件是編譯通過的。也使用了 using 關鍵字來對使用的類型取別名,同時,在代碼中,有一段用 #region mixin 和 #endregion 環繞的代碼。只需要寫一個工具,將模板文件中 #region mixin 和 #endregion 環繞的代碼提取出來,替換到模板定義中 #region include "Image_Template.cs" 和 #endregion 之間,生成第三個文件 ClassHelper_Csmacro.cs 即可實現模板機制。由於都使用了 using 關鍵字對類型取別名,因此,ClassHelper_Csmacro.cs 文件也是可以編譯通過的。在不同的模板定義中,令同樣的符號來代表不同的類型,實現了模板代碼的公用。
    上面機制可以全部自動化。Csmacro 是我寫的一個工具,可以完成上面的過程。將它放在系統路徑下,然后在項目的build event中添加pre-build 指令即可。Csmacro 程序在代碼包的lib的目錄下。

  

    如此實裝,我們就有模板用了!一切自動化,就好像內置的一樣。強類型、有編譯器進行類型約束,減少出錯的可能。調試也很容易,就和調試普通的C#代碼一樣,不存在C++中的模板的難調試問題。缺點嘛,就是沒有C++中模板的語法優美,但是,也看的過去,至少比C中的宏好看多了是吧。
    參照上面對模板的實現,完全可以定義出一套C#的宏出來。沒這樣做,是因為沒這個需求。

    下面是一個完整的例子,為 Person 類和 Cat 類添加模板擴展方法(非擴展方法也可類似添加),由於這個方法有指針,無法用泛型實現:

  void SetAge( this T item,   int* age)

    首先,建一個可編譯通過的模板類 Template.cs:

  namespace Introduce.Hide
 {
      using T = Person;
 
      public  static  class Template
     {
          #region mixin
 
          public  static  unsafe  void SetAge( this T item,   int* age)
         {
             item.Age = *age;
         }
 
          #endregion
     }
 }

 
    我在命名空間中加入了 Hide,只要不引用這個命名空間,這個擴展方法不會出現對程序產生干擾。
    接着,建立 PersonClassHelper.cs 文件:

  namespace Introduce
 {
      using T = Person;
 
      public  static  partial  class PersonClassHelper
     {
          #region include "Template.cs"
          #endregion 
     }
 }


    建立 CatClassHelper.cs 文件:

  namespace Introduce
 {
      using T = Cat;
 
      public  static  partial  class CatClassHelper
     {
          #region include "Template.cs"
          #endregion
     }
 }

 
    為了節省篇幅,我省略了命名空間的引用,實際代碼中是有命名空間的引用的。下載包里包含了全部的代碼。
    接下來,編譯一下,哈哈,編譯通過。
    且慢,怎么看不到編譯生成的兩個 Csmacro.cs 文件呢?

    這兩個文件已經生成了,需要手動將它們添加到項目中,只用添加一次即可。添加進來,再編譯一下,哈哈,通過。
    這個例子雖小,可不要小看模板啊,在Geb.Image庫里,大量使用了模板:


  

    有了模板,只用維護公共代碼。

六、迭代器

    下面來實現迭代器。這里,要放棄使用foreach,返回古老的迭代器模式,來訪問圖像的每一個像素:

     public  unsafe  struct ItArgb32Old
    {
         public  unsafe Argb32* Current;
         public  unsafe Argb32* End;

         public  unsafe Argb32* Next()
        {
             if (Current < End)  return Current ++;
             else  return  null;
        }
    }

     public  static  class ImageArgb32Helper
    {
         public  unsafe  static ItArgb32Old CreateItorOld( this ImageArgb32 img)
        {
            ItArgb32Old itor =  new ItArgb32Old();
            itor.Current = img.Start;
            itor.End = img.Start + img.Length;
             return itor;
        }
    }

    不幸的是,測試性能,這個迭代器比單純的while循環慢很多。對一個100萬像素的圖像,將其每一個像素值的Red分量設為200,循環100遍,使用迭代器在我的電腦上耗時242 ms,直接使用循環耗時 72 ms。我測試了很多種方案,均未得到和直接循環性能近似的迭代器實現方案。

    沒有辦法,只好對迭代器來打折了,只進行部分抽象(這已經不能算迭代器了,但這里仍沿用這個名稱):

      public  unsafe  struct ItArgb32
     {
          public  unsafe Argb32* Start;
          public  unsafe Argb32* End;
 
          public  int Step(Argb32* ptr)
         {
              return  1;
         }
     }

 
    產生迭代器的代碼:

      public  unsafe  static ItArgb32 CreateItor( this ImageArgb32 img)
     {
         ItArgb32 itor =  new ItArgb32();
         itor.Start = img.Start;
         itor.End = img.Start + img.Length;
          return itor;
     }

     使用:

     ItArgb32 itor = img.CreateItor();
      for (Argb32* p = itor.Start; p < itor.End; p+= itor.Step(p))
     {
         p->Red =  200;
     }

 
    測試性能和直接循環性能幾乎一樣。有人可能要問,你這樣有什么優勢?和for循環有什么區別?
    這個例子中當然看不出優勢,換個例子就可以看出來了。
    在圖像編程中,有 ROI(Region of Interest,感興趣區域)的概念。比如,在下面這張女王出場的畫面中,假設我們只對她的頭部感興趣(ROI區域),只對該區域進行處理(標注為紅色區域)。

 

    對ROI區域創建一個迭代器,用來迭代ROI中的每一行:

     public  unsafe  struct ItRoiArgb32
    {
         public  unsafe Argb32* Start;
         public  unsafe Argb32* End;
         public  int Width;
         public  int RoiWidth;

         public  int Step(Argb32* ptr)
        {
             return Width;
        }

         public ItArgb32 Itor(Argb32* p)
        {
            ItArgb32 it =  new ItArgb32();
            it.Start = p;
            it.End = p + RoiWidth;
             return it;
        }
    }

 
    這個ROI迭代器又可以產生一個ItArgb32迭代器,來迭代該行中的像素。

    產生ROI迭代器的代碼如下,為了簡化代碼,我這里沒有進行ROI的驗證:

         public  unsafe  static ItRoiArgb32 CreateRoiItor( this ImageArgb32 img,
             int x,  int y,  int roiWidth,  int roiHeight)
        {
            ItRoiArgb32 itor =  new ItRoiArgb32();
            itor.Width = img.Width;
            itor.RoiWidth = roiWidth;
            itor.Start = img.Start + img.Width * y + x;
            itor.End = itor.Start + img.Width * roiHeight;
             return itor;
        }

    性能測試表明,使用ROI迭代器進行迭代和直接進行循環,性能一致。
    為一副圖像添加ROI字段,設置ROI值來控制不同的處理區域,然后用ROI迭代器進行迭代,比直接使用循環要方便得多。

七、風情萬種的Lambda表達式

    接下來,來看看C#指針最有風情的一面——Lambda表達式。
    C# 里 delegate 支持指針,下面這種寫法是沒有問題的:

  void ActionOnPixel(TPixel* p);

    對於圖像處理,我定義了許多擴展方法,ForEach是其中的一種,下面是它的模板定義:

         public  unsafe  static UnmanagedImage<TPixel> ForEach( this UnmanagedImage<TPixel> src, ActionOnPixel handler)
        {
            TPixel* start = (TPixel*)src.StartIntPtr;
            TPixel* end = start + src.Length;
             while (start != end)
            {
                handler(start);
                ++start;
            }
             return src;
        }

    讓我們用lambda表達式對圖像迭代,將每像素的Red分量設為200吧,一行代碼搞定:

img.ForEach((Argb32* p) => { p->Red =  200; });

    用ForEach測試,對100萬像素的圖像設置Red通道值為200,循環100次,我的測試結果是 400 ms,約是直接循環的 4-5 倍。可見這是個性能不高的操作(其實也夠高了,100萬象素,循環100遍,耗時400ms),可以在對性能要求不是特別高時使用。

八、與C/C++的比較

    我測試了很多場景,C# 下指針性能約是 C/C++ 的 70-80%,性能差距,可以忽略。

    相對於C/C++來說,C#無法直接操作硬件是其遺憾,這種情況,可以使用C/C++寫段小程序來彌補,不過,我還沒遇到這種場景。很多情況都可以P/Invoke解決。
    做圖像的話,很多時候需要使用顯卡加速,如使用CUDA或OpenCL,幸運的是,C#也可以直接寫CUDA或OpenCL代碼,但是功能可能會受到所用的庫的限制。也可以用傳統方式寫CUDA或OpenCL代碼,再P/Invoke調用。如果用傳統的C/C++開發的話,也需要做同樣的工作。

和C比較:

    這套方案比C的抽象程度高,我們有模板,有lambda表達式,還有一大票的語法糖。在類庫上,比C的類庫完善的多。我們還有反射,有命名空間等等一大票的東西。

和C++比較:
    這套方案的抽象程度比C++要低一些。畢竟,值類型無法繼承,模板機制比C++ 差一點。但是在生產力上比C++要高很多。拋開C++那一大票陷阱不說,以秒計算的編譯速度就夠讓C++程序員流口水的。當我們在咖啡館里約會喝咖啡時,C++程序員還正端着一杯咖啡坐在電腦前等待程序編譯結束。

九、接下來的工作

    接下來的工作主要有兩個:
    內聯工具:C# 的內聯還不夠強大。需要一個內聯工具,對想要內聯的方法使用特性標記一下,在編譯結束后,在IL代碼層面內聯。
    翻譯工具:移動開發是個痛。如何將C#的代碼翻譯成C/C++的代碼,在缺乏.Net的運行時下運行?

    這兩個工作都不緊要。C#內聯效果不好的地方(這種情況很少),可以手動內聯。至於移動開發嘛,在哥的一雲三端大計中,C# 的定位是雲圖像開發(C#+CUDA),三端中,桌面運用是用C#和Flash開發,Web和移動應用使用Flash開發,沒有C#的事情。

    C/C++ 呢?更沒有它們的位置啦!不對,還是有的。用它們來開發Flash應用的核心算法!夠另類吧!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM