将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 == ) *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 = ;
int* p = &age;
*p = ;
MessageBox.Show(p->ToString());

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

IntPtr handle = System.Runtime.InteropServices.Marshal.AllocHGlobal();
Int32* p = (Int32*)handle;
*p = ;
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())
{
int* p = (int*)memory.Handle;
*p = ;
MessageBox.Show(p->ToString());
}

2、使用 stackalloc 在栈中分配内存
    C# 提供了stackalloc 关键字可以直接在栈中分配内存,一般情况下,使用栈内存会比使用堆内存速度快,且栈内存不用担心内存泄漏。下面是例子:

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

3、模拟C中的union(联合体)类型
     使用 StructLayout 可以模拟C中的union:

[StructLayout(LayoutKind.Explicit)]
public struct Argb32
{
[FieldOffset()]
public Byte Blue;
[FieldOffset()]
public Byte Green;
[FieldOffset()]
public Byte Red;
[FieldOffset()]
public Byte Alpha; [FieldOffset()]
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(,)
{
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 ;
}
}

产生迭代器的代码:

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 = ;
}

测试性能和直接循环性能几乎一样。有人可能要问,你这样有什么优势?和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 = ; });

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

 原文链接:C# 指针之美

[转]C# 指针之美的更多相关文章

  1. C#通过指针读取文件

    // readfile.cs // 编译时使用:/unsafe // 参数:readfile.txt // C#通过指针读取文件.使用该程序读并显示文本文件. using System; using ...

  2. C/C++中的函数指针

    C/C++中的函数指针 一.引子 今天无聊刷了leetcode上的一道题,如下: Median is the middle value in an ordered integer list. If t ...

  3. C语言中的作用域、链接属性与存储属性

    C语言中的作用域.链接属性与存储属性 一.作用域(scope) 代码块作用域 表示{}之间的区域,下例所示,a可以在不同的代码块里面定义. #include<stdio.h> int ma ...

  4. Qt之美(一):d指针/p指针详解

    Translated  by  mznewfacer   2011.11.16 首先,看了Xizhi Zhu 的这篇Qt之美(一):D指针/私有实现,对于很多批评不美的同路人,暂且不去评论,只是想支持 ...

  5. Qt之美(一):d指针/p指针详解(解释二进制兼容,以及没有D指针就会崩溃的例子。有了D指针,所使用的对象大小永远不会改变,它就是该指针的大小。这个指针就被称作D指针)good

    Translated  by  mznewfacer   2011.11.16 首先,看了Xizhi Zhu 的这篇Qt之美(一):D指针/私有实现,对于很多批评不美的同路人,暂且不去评论,只是想支持 ...

  6. Qt之美(一):d指针/p指针详解(二进制兼容,不能改变它们的对象布局)

    Translated  by  mznewfacer   2011.11.16 首先,看了Xizhi Zhu 的这篇Qt之美(一):D指针/私有实现,对于很多批评不美的同路人,暂且不去评论,只是想支持 ...

  7. Qt之美(一):D指针/私有实现

    The English version is available at: http://xizhizhu.blogspot.com/2010/11/beauty-of-qt-1-d-pointer-p ...

  8. iOS开发系列--打造自己的“美图秀秀”

    --绘图与滤镜全面解析 概述 在iOS中可以很容易的开发出绚丽的界面效果,一方面得益于成功系统的设计,另一方面得益于它强大的开发框架.今天我们将围绕iOS中两大图形.图像绘图框架进行介绍:Quartz ...

  9. 尚德,国美 interview summary

    尚德 Q:SDWebimage源代码,cell重用.先请求出来小头像,再请求出大头像?怎么处理? SDWebImageDownloader 直接给cell设置图片会怎样 A:图片URL相同,比较nsd ...

随机推荐

  1. Ruby 踩坑 “Failed to build gem native extension”

    ruby新手,总是会出现这样那样的问题,这里先记录下,希望能解决你得问题. 首先是安装ruby 环境,楼主愚钝,在公司和自己的电脑上来来回回整了好几天,每次安装 gem 包的时候总是报错,错误信息大致 ...

  2. 解决maven项目中,缺少 maven dependencies

    因为项目需要将普通项目转换为maven项目,在右键 ’项目‘configure 后,添加maven后,发现缺少 maven dependencies,于是从网上找了一些处理措施,大体上是在.class ...

  3. MyBatis入门篇

    一.什么是MyBatis MyBatis 本是apache的一个开源项目iBatis, 2010年这个项目由apache software foundation 迁移到了google code,并且改 ...

  4. lintcode 题目记录3

    Expression Expand  Word Break II Partition Equal Subset Sum  Expression Expand  字符串展开问题,按照[]前的数字展开字符 ...

  5. webstorm添加自定义代码块

    widnow下使用alt+ctrl+s 调出setting面板 mac下使用command+,(逗号)调出Preferences面板 搜索live template选中js,在javascrpt 模板 ...

  6. 《火星救援》NASA惊现lisp

    duang-跳出个界面上面一个lisp程序.

  7. [翻译] 单例(Singleton)

    英文原文: https://sourcemaking.com/design_patterns/singleton 意图 确保一个类只有一个实例,并提供一个访问其实例的全局点: 封装 “即时初始化” ( ...

  8. android之键盘转载

    显示键盘: EditText editText.setFocusable(true); editText.setFocusableInTouchMode(true); editText.request ...

  9. java模拟post提交

    package javapost; import java.io.BufferedReader; import java.io.DataOutputStream; import java.io.IOE ...

  10. java、C语言实现数组模拟栈

    java: public class ArrayStack { private int[] data; private int top; private int size; public ArrayS ...