Hello World
Spiga

使用值类型LazyString分析字符串

2009-12-07 10:09 by 老赵, 6178 visits

.NET里提供了值类型与引用类型可谓是一个非常关键的特性,例如开发人员使用值类型时,可以充分控制内存的布局方式,这对于Interop等操作非常重要。不过,其实值类型最重要,最基本的特性还是内存分配上。现在基本上是个.NET开发人员就会叨念说“值类型”分配在栈上,“引用类型”分配在堆上。但是什么是栈什么堆?分配在栈上和堆上的区别是什么?如果说这两个问题太“理论”,那么来个实际的:您在平时什么情况下会使用,或者说,定义一个值类型呢?其实这才是重要的,否则背再多概念也没有用。只可惜从我面试的经验上来看,基本没有多少兄弟能把这些.NET基础完整说清楚。

其实值类型与性能的关系很大,因为它是直接分配在线程的调用栈上,随着方法的退出分配的内存就完全释放了,因此不会对GC产生压力——对一个托管程序来说,可以说是性能最为关键的因素之一。不过,值类型在作为参数传递或者变量赋值时,拷贝的不是一个字长,而是整个对象,因此我们一般不会创建拥有很多字段的值类型。如果一个类型的目的是保存数据,字段不多,但是会创建许多个,便可以考虑将其构造为值类型。例如,我昨天总结的PDC 09中的PLINQ内容中,第一条建议便是在合适的时候使用值类型,否则Concurrent GC可能成为性能瓶颈。

不过这让我想起了我常用的一个值类型,它不是为了保存数据用的,而是一个工具类:LazyString。它的目的是减少字符串解析过程中所生成的字符串的数量,这样便可以节省一定时间和空间上的开销。例如有这么一个需求,从一个使用“-”分割的字符串中,拆分出所有的整数(保证输入没有错误),那么最简单的做法可能便是使用Split:

public static List<int> ParseBySplit(string input)
{
    return input.Split('-').Select(s => Int32.Parse(s)).ToList();
}

不过假设这个问题比较复杂,没有办法使用Split,需要我们手动进行分析,那么最简单的方法可能就是这样的:

public static List<int> ParseByString(string input)
{
    var result = new List<int>();
    var s = input;

    while (true)
    {
        int index = s.IndexOf('-');

        if (index < 0)
        {
            result.Add(Int32.Parse(s));
            break;
        }

        result.Add(Int32.Parse(s.Substring(0, index)));
        s = s.Substring(index + 1);
    }

    return result;
}

在这段代码中,我们在一个循环中不断查找第一个“-”,然后将该字符前段作为整数放入结果集中,再将该字符的后段进行后续处理。在分析过程中,我们会将字符串不断地缩短、缩短……只可惜每次Substring都会生成一个新的字符串,这给GC带来的一定压力,大量的复制也会带来一些时间上的开销。当然,在这个例子中我们可以使用变量保存下标,不断地向后移动,慢慢的分割出一个一个地整数,这样可以避免出现更多字符串,但是这种做法从“思路”上来说,似乎没有直接修改字符串来的直接。为了在保持程序清晰度的同时减少开销,我构建了一个叫做LazyString的值类型组件:

public struct LazyString
{
    public LazyString(string s)
        : this(s, 0, s.Length) { }

    private string m_str;
    private int m_index;
    private int m_length;

    private LazyString(string s, int index, int length)
    {
        this.m_str = s;
        this.m_index = index;
        this.m_length = length;
    }

    public int Length { get { return this.m_length; } }

    public override string ToString()
    {
        return this.m_str.Substring(this.m_index, this.m_length);
    }
}

在这个值类型中,我们保存了三个字段:源字符串的引用,当前起始下标,以及长度。通过这三个字段,我们便可以得到这个LazyString对象所“表示”的字符串:如ToString所示,它可以由字符串的Substring方法来获得。不过,这个Substring操作也只有在ToString方法调用时才会执行,因此在平时的操作过程中是不会产生新字符串的。那么LazyString有哪些操作呢?其实它们都是些和字符串本身对应的操作,例如:

public struct LazyString
{
    ...

    public LazyString Substring(int index, int length)
    {
        if (index >= this.m_length)
        {
            throw new ArgumentOutOfRangeException();
        }

        if (index + length > this.m_length)
        {
            length = this.m_length - index;
        }

        return new LazyString(this.m_str, this.m_index + index, length);
    }

    public LazyString Substring(int index)
    {
        if (index >= this.m_length)
        {
            throw new ArgumentOutOfRangeException();
        }

        return new LazyString(this.m_str, this.m_index + index, this.m_length - index);
    }

    public int IndexOf(char c)
    {
        var index = this.m_str.IndexOf(c, this.m_index);
        return index < 0 ? index : index - this.m_index;
    }
}

Substring和IndexOf的语义和String类型中定义的方法完全一致,不过如Substring已经不是返回一个新字符串对象了,而是一个新的LazyString对象。这是个值类型对象,不会在堆上分配数据,因此不会给GC带来压力,而构造这么一个对象,也只是进行了一些简单的整数运算,这样它表示的字符串是改变了,但是性能非常高。而且,由于对应方法的语义相同,使用起来也和String没有太大区别:

public static List<int> ParseByLazy(string input)
{
    var result = new List<int>();
    var s = new LazyString(input);

    while (true)
    {
        int index = s.IndexOf('-');

        if (index < 0)
        {
            result.Add(Int32.Parse(s.ToString()));
            break;
        }

        result.Add(Int32.Parse(s.Substring(0, index).ToString()));
        s = s.Substring(index + 1);
    }

    return result;
}

可见,除了构造方式及一些必要的ToString方法,其它部分的逻辑与直接使用String对象没有任何区别。但是这么做的性能便可以有较大提高,测试一下:

var ints = Enumerable.Range(0, 1000);
var input = String.Join("-", ints.Select(i => i.ToString()).ToArray());

CodeTimer.Initialize();
int iteration = 1000;

CodeTimer.Time("Split", iteration, () => ParseBySplit(input));
CodeTimer.Time("String", iteration, () => ParseByString(input));
CodeTimer.Time("LazyString", iteration, () => ParseByLazy(input));

结果是:

Split
        Time Elapsed:   348ms
        CPU Cycles:     838,419,828
        Gen 0:          49
        Gen 1:          1
        Gen 2:          0

String
        Time Elapsed:   1,684ms
        CPU Cycles:     4,054,499,880
        Gen 0:          3837
        Gen 1:          0
        Gen 2:          0

LazyString
        Time Elapsed:   241ms
        CPU Cycles:     584,045,208
        Gen 0:          30
        Gen 1:          15
        Gen 2:          0

可见,LazyString是性能最高的做法,而String产生的GC数量也是最为可观的。不过也必须注意到,LazyString带来了15次Gen 1的GC操作,这虽然不会带来性能问题,但也给了我们一些“警示”:LazyString虽然不会生成新字符串,但是会把旧的字符串持有更长的时间。因为,LazyString即便是表示一小段字符串,也是需要引用一整个字符串。这意味着LazyString只能是一个“工具类型”而不能用它来完全代替String来表示字符串。当然,LazyString在进行Substring操作时,也可以进行一些判断,例如可以在m_length小于m_str.Length一半的情况下生成新的字符串,这样便可以在时间和空间两方面做出一个权衡。

不过我在使用LazyString时并没有那么复杂,因为我的场景保证了操作总是一瞬间就可以完成的,不会把一个较长的字符串捏住不放。

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

35 条回复

  1. Ivony...
    *.*.*.*
    链接

    Ivony... 2009-12-07 10:34:00

    介个。。。。。Indexof和Substring都有两个参数的版本么。

  2. FF123小鸡爆[未注册用户]
    *.*.*.*
    链接

    FF123小鸡爆[未注册用户] 2009-12-07 10:38:00

    为什么我看你的照片很不顺眼

  3. 老赵
    admin
    链接

    老赵 2009-12-07 10:38:00

    @Ivony...
    啥子?

  4. 刚开始编程[未注册用户]
    *.*.*.*
    链接

    刚开始编程[未注册用户] 2009-12-07 10:39:00

    膜拜下楼主的,近期似乎对字符串很有热情
    顺便问个问题(其实是主要的 :()
    silverlight里方法都只能异步调用
    有没有一些优雅的处理框架实现同步效果

  5. 老赵
    admin
    链接

    老赵 2009-12-07 10:40:00

    @FF123小鸡爆
    经鉴定,您的眼光是很正常的。

  6. 老赵
    admin
    链接

    老赵 2009-12-07 10:41:00

    @刚开始编程
    Reactive Framework,或者你可以了解一下AsyncEnumerator。

  7. 极品拖拉机
    *.*.*.*
    链接

    极品拖拉机 2009-12-07 10:48:00

    为什么是struct而不是class
    lazyString.
    仅仅是因为struct是值类型?

  8. 老赵
    admin
    链接

    老赵 2009-12-07 10:50:00

    @极品拖拉机
    struct等价于值类型,你说“因为struct是值类型”好比在说“因为1等于1,2等2”。
    应该这么说:“因为struct有XX,YY,ZZ等特性”。

  9. 刚开始编程[未注册用户]
    *.*.*.*
    链接

    刚开始编程[未注册用户] 2009-12-07 11:00:00

    Jeffrey Zhao:
    @刚开始编程
    Reactive Framework,或者你可以了解一下AsyncEnumerator。


    谢谢楼主

  10. 无名
    *.*.*.*
    链接

    无名 2009-12-07 11:17:00

    good!

  11. vczh[未注册用户]
    *.*.*.*
    链接

    vczh[未注册用户] 2009-12-07 11:37:00

    值类型不是分配在调用栈的,考虑一下有值类型作为函数局部变量的一个函数,在返回值那里返回了一个匿名函数。

    Func<int,int> Foo()
    {
    int a=0;//值类型
    return b=>a+b;
    }

  12. vczh[未注册用户]
    *.*.*.*
    链接

    vczh[未注册用户] 2009-12-07 11:40:00

    根据以前做编译器的经验,凡是支持匿名函数的语言一般有以下几个特征(不一定是feature)才算得上是安全的

    1:垃圾收集
    2:没有对指针类型的数值运算
    3:每次调用函数都会产生一个object来存放所有的局部变量和函数参数(如果变量参数不为空,并且没有制造匿名函数的话,估计可以不用)
    4:因为2所以内置了很多容器

  13. 老赵
    admin
    链接

    老赵 2009-12-07 11:42:00

    @vczh
    如果作为一个类型的字段,那么就做为那个对象主体的一部分了……好吧,我漏了个前提。

  14. 幸存者
    *.*.*.*
    链接

    幸存者 2009-12-07 12:15:00

    vczh:
    根据以前做编译器的经验,凡是支持匿名函数的语言一般有以下几个特征(不一定是feature)才算得上是安全的

    1:垃圾收集
    2:没有对指针类型的数值运算
    3:每次调用函数都会产生一个object来存放所有的局部变量和函数参数(如果变量参数不为空,并且没有制造匿名函数的话,估计可以不用)
    4:因为2所以内置了很多容器


    第2条的理由是什么?
    另外第3条,需要分情况讨论,如果匿名函数没有引用外部变量,完全可以只生成一个静态方法,如果仅引用this变量,则可以生成一个成员方法,只有在使用了外部变量的情况下才需要生成一个对象,事实上C#就是这么干的。

  15. 幸存者
    *.*.*.*
    链接

    幸存者 2009-12-07 12:27:00

    其实值类型在C#中有非常多的陷阱,如果不能熟练掌握的话非出现很多意想不到的情况,猜猜以下两段代码会输出什么:

    struct MyStruct {
        public int value;
        public void SetValue(int value) {
            this.value = value;
        }
    }
    
    class Program {
        static void Main() {
            var ms = new MyStruct();
            Action<int> action = ms.SetValue;
            action(1);
            Console.WriteLine(ms.value);
        }
    }
    

    struct MyStruct {
        public int value;
        public int Increment() {
            return ++value;
        }
    }
    
    class Program {
        static readonly MyStruct ms;
    
        static void Main() {
            Console.WriteLine(ms.Increment());
            Console.WriteLine(ms.Increment());
        }
    }
    

  16. 老赵
    admin
    链接

    老赵 2009-12-07 12:29:00

    @幸存者
    是的,这种代码就是玩人的。
    因此在实际项目中,我强烈反对出现mutable的struct类型。

  17. Ivony...
    *.*.*.*
    链接

    Ivony... 2009-12-07 12:50:00

    值类型本身就应该是一个值,任何状态可更改的值类型都是危险的。

  18. RednaxelaFX
    *.*.*.*
    链接

    RednaxelaFX 2009-12-07 13:50:00

    @Jeffrey Zhao
    "这都会给这使GC的压力增大" << InfoQ上的typo...

    话说CLR的JIT对值类型的优化一直比较差。很长一段时间内,如果参数列表里有struct的话,CLR的JIT都不会对它做内联。到3.5SP1,x86版CLR JIT才开始能够对参数列表有值类型的方法做内联。(这段讨论中整型、浮点数等CLR有直接支持的原始类型除外)。

    而且CLR的实现中,值类型作为参数按值传递的实现方法也很有趣,不是把值从caller的栈帧复制到callee的栈帧,而是直接在caller的栈帧里复制一份,然后把指针传到callee;返回值类型的方法也很有趣,是在caller的栈帧里先预留一块空间,把指针传到callee去,然后callee要写返回值的时候实际上是通过指针写到了caller的栈帧里。
    更有趣的是现在的CLR可以在一定条件下对值类型做scalar replacement。有兴趣的话试试这段代码:

    using System;
    using System.Runtime.CompilerServices;
    
    namespace TestCLR2JIT_ScalarReplacement {
        public struct Point {
            public int X { get; private set; }
            public int Y { get; private set; }
    
            public Point( int x, int y )
                : this( ) {
                X = x;
                Y = y;
            }
        }
    
        static class Program {
            [MethodImpl( MethodImplOptions.NoInlining )]
            public static void Foo( ) {
                var p = new Point( 1, 2 );
                Console.WriteLine( "({0}, {1})", p.X.ToString( ), p.Y.ToString( ) );
            }
    
            static void Main( string[ ] args ) {
                Foo( );
            }
        }
    }

    会看到Foo()被JIT编译为native code之后,变量p完全消失了,而原本Point中的x与y倒是留了下来。
    Normal JIT generated code
    TestCLR2JIT_ScalarReplacement.Program.Foo()
    Begin 00e70090, size 64
    00E70090 push        ebp
    00E70091 mov         ebp,esp
    00E70093 push        edi
    00E70094 push        esi
    00E70095 sub         esp,8
    00E70098 xor         eax,eax
    00E7009A mov         dword ptr [ebp-0Ch],eax
    00E7009D mov         dword ptr [ebp-10h],eax
    00E700A0 mov         dword ptr [ebp-0Ch],1
    00E700A7 mov         esi,dword ptr [ebp-0Ch]
    00E700AA call        792E0CA0 (System.Globalization.NumberFormatInfo.get_CurrentInfo(), mdToken: 06002747)
    00E700AF push        eax
    00E700B0 mov         ecx,esi
    00E700B2 xor         edx,edx
    00E700B4 call        79E8D11A (System.Number.FormatInt32(Int32, System.String, System.Globalization.NumberFormatInfo), mdToken: 06000c28)
    00E700B9 mov         esi,eax
    00E700BB mov         dword ptr [ebp-10h],2
    00E700C2 mov         edi,dword ptr [ebp-10h]
    00E700C5 call        792E0CA0 (System.Globalization.NumberFormatInfo.get_CurrentInfo(), mdToken: 06002747)
    00E700CA push        eax
    00E700CB ecx,edi
    00E700CD xor         edx,edx
    00E700CF call        79E8D11A (System.Number.FormatInt32(Int32, System.String, System.Globalization.NumberFormatInfo), mdToken: 06000c28)
    00E700D4 mov         edi,eax
    00E700D6 call        792ED2F0 (System.Console.get_Out(), mdToken: 06000772)
    00E700DB push        esi
    00E700DC push        edi
    00E700DD mov         ecx,eax
    00E700DF mov         edx,dword ptr ds:[02342030h] ("({0}, {1})")
    00E700E5 mov         eax,dword ptr [ecx]
    00E700E7 call        dword ptr [eax+000000E4h]
    00E700ED lea         esp,[ebp-8]
    00E700F0 pop         esi
    00E700F1 pop         edi
    00E700F2 pop         ebp
    00E700F3 ret

    把聚合量拆散成标量的这种优化就叫做scalar replacement。
    (为了代码好看,这里顺便调用了Int32.ToString(),避免x与y被装箱)
    不过这段代码也可以看到当前版本(3.5SP1)CLR的一个不足:冗余的初始化。x位于[ebp-0Ch],y位于[ebp-10h],它们都得到了确定性赋值,但在方法开头处仍然有对它们做默认初始化的代码。当前实现是MSIL里方法的.locals后指定了init的话CLR就会生成出默认初始化代码,即便它是冗余的……

    幸存者:
    其实值类型在C#中有非常多的陷阱,如果不能熟练掌握的话非出现很多意想不到的情况,猜猜以下两段代码会输出什么:


    嘿嘿,想起以前我测过的一个东西:http://rednaxelafx.javaeye.com/blog/146484

    vczh:
    值类型不是分配在调用栈的,考虑一下有值类型作为函数局部变量的一个函数,在返回值那里返回了一个匿名函数。

    Func<int,int> Foo()
    {
    int a=0;//值类型
    return b=>a+b;
    }


    这个例子被C#编译器编译过之后就变成这样了:
    class DisplayClass1 {
      public int a;
      public int Method(int b) { return a + b; }
    }
    
    Func<int, int> Foo() {
      DisplayClass1 closure1 = new DisplayClass1();
      closure1.a = 0;
      return new Func<int, int>(closure1.Method);
    }

    C#编译器会做简单的逃逸分析(escape analysis)……如果它能断定某个参数/局部变量没有被闭包捕获,就照旧生成代码。所以没捕获this的匿名方法会被编译为静态方法,只捕获了this的匿名方法会被编译为实例方法,捕获了参数或者局部变量的方法会连同被捕获的内容一起被提升到一个生产的闭包类里。没被闭包捕获的值类型参数或者局部变量还是在调用栈上分配空间的(原始类型例外,可能在寄存器上)。

  19. 韦恩卑鄙 alias:v-zhewg
    *.*.*.*
    链接

    韦恩卑鄙 alias:v-zhewg 2009-12-07 14:29:00

    lazystring 这个想法 我一直想用在tcp的接收缓冲区上。
    我这边接收到的数据片段byte[]大都保存在一个单向链表组成的队列中

    可能一般会有多个片断byte[]组成一个包 那么包能不能做成lazy 的模式把多个片断放进去 并且标记每个片段的开始偏移和有效长度呢?

    但是聂,由于这些数据经常要全部取出来作hash算签名和解密 这个想法就无疾而终了。

  20. 老赵
    admin
    链接

    老赵 2009-12-07 15:02:00

    话说我现在一大快事便是听RFX讲那CLR的故事。

  21. 韦恩卑鄙 alias:v-zhewg
    *.*.*.*
    链接

    韦恩卑鄙 alias:v-zhewg 2009-12-07 15:09:00

    @Jeffrey Zhao
    我很惊奇RFX把各个版本的特性差异记得那么清楚 几乎有个数据立方体在他面前 掰开了揉碎了随便看-0-

    我对我自己去年前年的两个状态差异都糊里糊涂的。。。更别说clr了。。。

  22. Zhenway
    *.*.*.*
    链接

    Zhenway 2009-12-07 15:47:00

    撇开原来的那个string不谈

    在ParseByString中使用:
    int index = s.IndexOf('-');
    //...
    result.Add(Int32.Parse(s.Substring(0, index)));//一次分配string实例
    s = s.Substring(index + 1);//又一次分配string实例

    而在LazyString中使用:
    m_str.Substring(this.m_index, this.m_length);//仅分配一次string实例

    ParseByString创建了2N个string实例而ParseByLazy和ParseBySplit都只创建了N个string实例,感觉LazyString是改良了算法,减少了创建实例,和值类型的关系应该不大,换句话说,就是把LazyString声明为class,其性能也应该差不太多

  23. 老赵
    admin
    链接

    老赵 2009-12-07 15:58:00

    @Zhenway
    没有改良算法,算法不完全一样的么,只是因为Lazy了,操作特性变了而已。
    如果把LazyString设为class,就有一堆小对象了,经测试价值不大。

  24. Ivony...
    *.*.*.*
    链接

    Ivony... 2009-12-07 16:12:00

    那啥,这个LazyString其实就是一个int和一个string外加几个方法,典型的data + function结构。当然算法的优化有存在,最核心的就是使用两个参数版本的Substring和IndexOf。

    所以我看了个开头说为啥不用两个参数版本的。。。。

    也就是说,如果把ParseByString方法用两个参数的版本重写,也能得到一样的效果。。。。。

  25. 老赵
    admin
    链接

    老赵 2009-12-07 16:17:00

    @Ivony...
    是啊,我文章里也写了,只要保持一个下标不断后移也一样的,但是逻辑就要变了。我就是为了保持逻辑不变,然后性能提高。
    这个问题很简单所以看不出了,有些解析工作如果靠维护一些下标等等就麻烦了……

  26. 韦恩卑鄙 alias:v-zhewg
    *.*.*.*
    链接

    韦恩卑鄙 alias:v-zhewg 2009-12-07 17:11:00

    我想纯水一下 又怕被删除

    隐隐约约 迷迷糊糊 觉得 似乎把lazystring 被放在一个 类似StringBuilder一样的容器里 让容器进行 replace/tostring/splite 等操作,取代lazyString的ToString() 可以连N个对象也不必创建, 和 Conact一样直接new一个string出来然后array.copy
    -0-

  27. 老赵
    admin
    链接

    老赵 2009-12-07 17:21:00

    @韦恩卑鄙 alias:v-zhewg
    StringBuilder的Replace操作也会产生许多字符复制的……
    不过,其实我这里如果要不是需要Int32.Parse,其实ToString也是不需要俄……

  28. 韦恩卑鄙 alias:v-zhewg
    *.*.*.*
    链接

    韦恩卑鄙 alias:v-zhewg 2009-12-07 17:24:00

    Jeffrey Zhao:
    @韦恩卑鄙 alias:v-zhewg
    StringBuilder的Replace操作也会产生许多字符复制的……
    不过,其实我这里如果要不是需要Int32.Parse,其实ToString也是不需要俄……


    许多字符复制 咱们还是继续复制 Lazy String oh yeah

  29. -==NoWay.==-
    *.*.*.*
    链接

    -==NoWay.==- 2009-12-08 16:02:00

    这种手段不是更好吗?

            public static List<int> ParseByLastIndex(string input)
            {
                var result = new List<int>();
                int lastLastIndex = input.Length; 
                    int lastIndex = input.Length;
                while (true)
                {
                    lastIndex = input.LastIndexOf('-', lastIndex-1, lastIndex);
                    if (lastIndex < 0)
                    {
                        result.Add(Int32.Parse(input.Substring(0, lastLastIndex)));
                        break;
                    }
                    result.Add(Int32.Parse(input.Substring(lastIndex + 1, lastLastIndex - lastIndex - 1)));
                    lastLastIndex = lastIndex;
                }
                return result;
            }
    
    


    Split
    Time Elapsed: 303ms
    CPU Cycles: 627,878,367
    Gen 0: 30
    Gen 1: 0
    Gen 2: 0

    String
    Time Elapsed: 2,272ms
    CPU Cycles: 4,564,586,805
    Gen 0: 2555
    Gen 1: 0
    Gen 2: 0

    LazyString
    Time Elapsed: 425ms
    CPU Cycles: 849,947,850
    Gen 0: 17
    Gen 1: 0
    Gen 2: 0

    LastIndex
    Time Elapsed: 256ms
    CPU Cycles: 512,278,169
    Gen 0: 17
    Gen 1: 0
    Gen 2: 0

  30. 老赵
    admin
    链接

    老赵 2009-12-08 22:12:00

    @-==NoWay.==-
    好像许多兄弟都直接看代码看结果,但是没看清文章啊,呵呵。

  31. Kevin Dai
    *.*.*.*
    链接

    Kevin Dai 2009-12-11 11:45:00

    看完了,我感觉这里面就是在做抽象,通过一些改动“屏蔽”掉原来方法的弊端。

  32. 老赵
    admin
    链接

    老赵 2009-12-11 12:02:00

    @Kevin Dai
    可以这样理解。

  33. 陈飞
    *.*.*.*
    链接

    陈飞 2009-12-21 16:04:00

    我在值类型与引用类型在内存上的分配比较模糊,不知道老赵有没有推荐的文章或书籍推荐?

  34. 老赵
    admin
    链接

    老赵 2009-12-21 17:01:00

    @陈飞
    CLR via C#说的很清楚

  35. 蛙蛙王子
    *.*.*.*
    链接

    蛙蛙王子 2010-01-29 22:50:00

    RednaxelaFX的评论还是看不懂,
    韦恩卑鄙 alias:v-zhewg:做的环形缓冲区做好了吗
    -==NoWay.==-的算法写的挺好的呀。

    这周末得把老赵最近写的帖子都看看,拉了太多了,呵呵。

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我