C# 指针之美
  M9aMEIE19lAW 2023年11月02日 51 0


C# 指针之美


    将C#图像库的基础部分开源了(https://github.com/xiaotie/GebImage)。这个库比较简单,且离成熟还有一段距离,但它是一种新的开发模式的探索:以指针和非托管内存为主的C#程序开发。
    我许多项目都是在这个库基础上的开发,实战证明了它的有效。从今天起,将断断续续(太忙了)的写一系列文章来介绍这种开发方法,介绍基于此的图像编程。本文便是第一篇。
    以指针和非托管内存为主的C#程序开发,无论对.Net程序员来说,还是对传统的C/C++程序员来说,均属异类。然而这种方法在很多场景下是非常有效的,尤其是图像编程,所谓谈笑间,樯橹灰飞烟灭,不外如是。
    既有C/C++的高性能,又能直接管理内存不给GC带来压力,同时又拥有.net开发的大部分优势,可以快速迭代,何乐而不为呢?

一、简洁优美的代码

    本来初稿这节写了好几百字,将C#指针开发与C/C++开发,Java开发、D语言开发等进行对比,阐述理念。不过现在觉得,阐述一个新事物,没有比用例子更直接的了。
    例子:打开一张图像,先将它转化为灰度图像,再进行二值化(变成黑白图像),然后进行染色,将白色的像素变成红色。以上每一个过程都弹出窗体显示出来。
    代码截图更有视觉冲击力:




C# 指针之美_System


 
    像诗歌一样简洁和优美,这就是孤的代码。具备C/C++的高性能和C#的行云流水,同时又有IDE的强大生产力相助,说这些话已属多余,看见这样的代码,更应该想到的是:妹纸,今天工作全部搞定,现在有空吗,哥来接你。
    这才是工作,这才是生活。留下时间,看看书,看看漫画,玩玩乐乐。最近在看《偷星九月天》,就拿沧殿来测试这段程序吧:

 


C# 指针之美_System_02


改编    程序员A:那帮孙子又新提了几百条需求,老大,你要带我们突围吗?

    程序员B:不是突围,是杀光它们!

 
     下面,请跟随我,来一段短程探险吧。
     (本文中的代码可在 https://github.com/xiaotie/GebImage/tree/develop 处下载。打包下载地址见 集异璧图像与视觉分析库 )

二、C# 指针基础

    在C#中使用指针,需要在项目属性中选中“Allow unsafe code”:


C# 指针之美_System_03

 

    接着,还需要在使用指针的代码的上下文中使用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
   
 
    也可在class或struct 上加上unsafe 关键字,如:
   
     public      partial      unsafe      class


    指针配合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());

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

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 {       get;       private  
     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 关键字让我们可以将一个类的代码分在多个文件,那么可以这样分:第一个文件是我们自己写的代码,第二个文件用来描述模板,第三个文件,用来根据模板自动生成代码。
    三个文件这样取名字的:


C# 指针之美_Image_04


 

    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# 指针之美_Image_05

  

    如此实装,我们就有模板用了!一切自动化,就好像内置的一样。强类型、有编译器进行类型约束,减少出错的可能。调试也很容易,就和调试普通的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库里,大量使用了模板:



C# 指针之美_非托管_06

  

    有了模板,只用维护公共代码。

六、迭代器

    下面来实现迭代器。这里,要放弃使用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区域),只对该区域进行处理(标注为红色区域)。


C# 指针之美_System_07


 

    对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


    对于图像处理,我定义了许多扩展方法,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吧,一行代码搞定:


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应用的核心算法!够另类吧!


作者: xiaotie

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2023年11月08日 0

暂无评论

推荐阅读
M9aMEIE19lAW