深入理解C#指針之美
一、簡潔優美的代碼
本來初稿這節寫瞭好幾百字,將C#指針開發與C/C++開發,Java開發、D語言開發等進行對比,闡述理念。不過現在覺得,闡述一個新事物,沒有比用例子更直接的瞭。
例子:打開一張圖像,先將它轉化為灰度圖像,再進行二值化(變成黑白圖像),然後進行染色,將白色的像素變成紅色。以上每一個過程都彈出窗體顯示出來。
代碼截圖更有視覺沖擊力:
二、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模式的簡單例子:
public unsafe class UnmanagedMemory : IDisposable { public int Count { get; private set; } private byte* Handle; private bool _disposed = false; public UnmanagedMemory(int bytes) { Handle = (byte*) System.Runtime.InteropServices.Marshal.AllocHGlobal(bytes); Count = bytes; } public void Dispose() { Dispose(true); GC.SuppressFinalize(true); } protected virtual void Dispose( bool isDisposing ) { if (_disposed) return; if (isDisposing) { if (Handle != null) { System.Runtime.InteropServices.Marshal.FreeHGlobal((IntPtr)Handle); } } _disposed = true; } ~UnmanagedMemory() { Dispose( false ); } }
使用:
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應用的核心算法!夠另類吧!
總結
本篇文章就到這裡瞭,希望可以幫助到你,也希望你能夠多多關註WalkonNet的更對內容!
推薦閱讀:
- C#異常執行重試的實現方法
- C# InitializeComponent()方法案例詳解
- C#使用OpenCV剪切圖像中的圓形和矩形的示例代碼
- C# 控件屬性和InitializeComponent()關系案例詳解
- c# Bitmap轉bitmapImage高效方法