Hello World
Spiga

浅谈尾递归的优化方式

2009-04-01 01:00 by 老赵, 33116 visits

在上文《尾递归与Continuation》里,我们谈到了尾递归的概念和示例,不过有些朋友对于尾递归的功效依然有所怀疑。因此现在,我再简单讲解一下尾递归的优化原理,希望能给大家以一定理性认识。

尾递归的循环优化

尾递归,即是递归调用放在方法末尾的递归方式,如经典的阶乘:

int FactorialTailRecursion(int n, int acc)
{
    if (n == 0) return acc;
    return FactorialTailRecursion(n - 1, acc * n);
}

由于递归在方法的末尾,因此方法中的局部变量已经毫无用处,编译器完全可以将其“复用”,并把尾递归优化为“循环”方式:

int FactorialLoopOptimized(int n, int acc)
{
    while (true)
    {
        if (n == 0) return acc;

        acc *= n;
        n--;
    }
}

不过,上文还提到了尾递归中的常用技巧Continuation。那么对于如下形式的Continuation,编译器又该如何优化呢?

int FactorialContinuation(int n, Func<int, int> continuation)
{
    if (n == 0) return continuation(1);
    return FactorialContinuation(n - 1, r => continuation(n * r));
}

我们先用“人脑”来思考一下,这段代码的执行方式是怎么样的。我们每次使用n和contn调用FactorialContinuation时,都会构造一个新的contn - 1,并同n - 1传入下一次FactorialContinuation调用中去。以此类推,直到n等于0时,就直接调用cont0并返回。至于每个Continuation的定义,我们可以归纳出如下结果:

Func<int, int> contn = r => r * n

因此:

Factorial(n) 
    = contn(contn - 1(...(cont2(cont1(cont0(1)))...))
    = n * ((n – 1) * (...(2 * (1 * 1))...)) = 
    = n * (n - 1) * ... * 2 * 1
    = n!

于是,我们可以根据这个“意图”,将FactorialContinuation方法“优化”为如下形式:

int FactorialLoopOptimized2(int n, Func<int, int> continuation)
{
    LinkedList<Func<int, int>> contList = new LinkedList<Func<int, int>>();

    while (true)
    {
        if (n == 0) break;

        int tempN = n;
        Func<int, int> newCont = r => tempN * r;
        contList.AddFirst(newCont);

        n--;
        continuation = newCont;
    }

    return contList.Aggregate(1, (acc, cont) => cont(acc));
}

我们构造了一个Continuation函数链表,随着n递减,每次都会把新的Continuation函数插入到链表头,最后Aggregate方法会将第一个参数(累加器)依次运用到每个函数中去,得到最后结果并返回。只可惜,这个优化完全是我们“一厢情愿”而已,这么做的前提是“理解”了函数的意义,把方法的迭代调用“拆开”,而编译器是无法(还是很难)帮我们优化到如斯地步的。那么编译器对于此类问题又该如何解决呢?

之前,我们使用C#中的匿名方法特性来构造每个Continuation方法。如果我们使用自定义的封装类,再将递归“优化”成循环,FactorialContinuation又会成为什么样呢?如下:

private class Continuation
{
    public Continuation(Func<int, int> cont, int n)
    {
        this.cont = cont;
        this.n = n;
    }

    private Func<int, int> cont;
    private int n;

    public int Invoke(int r)
    {
        return this.cont(this.n * r);
    }
}

public static int FactorialLoopOptimized3(int n, Func<int, int> continuation)
{
    while (true)
    {
        if (n == 0) break;
        continuation = new Continuation(continuation, n).Invoke;
        n--;
    }

    return continuation(1);
}

其实这才是FactorialContinuation的“直译”,也是编译器能够进行优化。不过朋友们应该也能够看出,这只是一个Continuation对象套着另一个Continuation对象。如果形成了数万个Continuation对象的嵌套,在最终调用最外层的Continuation时,每个内部的Continuation也会在调用时往同一个堆栈中不断累加,最终还是会造成堆栈溢出。因此,如果使用了Continuation,还是无法简单把递归优化成循环来避免堆栈溢出的。编译器还必须进行其他方面的优化。

方法尾调用的优化

上一篇文章曾经谈到:“与普通递归相比,由于尾递归的调用处于方法的最后,因此方法之前所积累下的各种状态对于递归调用结果已经没有任何意义,因此完全可以把本次方法中留在堆栈中的数据完全清除,把空间让给最后的递归调用。这样的优化便使得递归不会在调用堆栈上产生堆积,意味着即时是“无限”递归也不会让堆栈溢出”。这其实才是尾递归的“正统”优化方式,那么我们先暂时忘记之前的“循环优化”,从最简单的示例中查看这样的优化是如何进行的。还是最简单的“尾递归”阶乘:

static int FactorialTailRecursion(int n, int acc)
{
    if (n == 0) return acc;
    return FactorialTailRecursion(n - 1, acc * n);
}

它的IL代码是:

.method private hidebysig static int32 FactorialTailRecursion(int32 n, int32 acc) cil managed
{
    .maxstack 8
    L_0000: ldarg.0            // 加载第1个参数,即n
    L_0001: brtrue.s L_0005    // 如果第一个参数不为0,则跳转到L_0005
    L_0003: ldarg.1            // 运行到此,说明第1个参数为0,则加载第2个参数,即acc 
    L_0004: ret                // 返回(刚加载的第2个参数)
    L_0005: ldarg.0            // 加载第1个参数,即n
    L_0006: ldc.i4.1           // 加载数值1
    L_0007: sub                // 将两者相减,即n - 1
    L_0008: ldarg.1            // 加载第2个参数,即acc
    L_0009: ldarg.0            // 加载第1个参数,即n
    L_000a: mul                // 将两者相乘,即acc * n
  // 把n - 1和acc * n作为参数递归调用
    L_000b: call int32 TailRecursion.Recursion::FactorialTailRecursion(int32, int32)
    L_0010: ret                // 返回递归调用结果
}

在这个问题上,我们还需要观察它的汇编代码(为了不干扰文章内容,我会把获取汇编代码的做法单独写一篇文章,稍后发布),如下:

00ad00d0    push    ebp
00ad00d1    mov     ebp,esp
00ad00d3    push    esi
00ad00d4    mov     eax,edx
00ad00d6    test    ecx,ecx
00ad00d8    jne     00ad00dd
00ad00da    pop     esi
00ad00db    pop     ebp
00ad00dc    ret
00ad00dd    lea     edx,[ecx-1]
00ad00e0    imul    ecx,eax
00ad00e3    mov     esi,ecx
00ad00e5    test    edx,edx
00ad00e7    jne     00ad00ed
00ad00e9    mov     eax,esi
00ad00eb    jmp     00ad00f9
00ad00ed    lea     ecx,[edx-1]
00ad00f0    imul    edx,esi
00ad00f3    call    dword ptr ds:[703068h] (地址703068h的值即为00ad00d0)
00ad00f9    pop     esi
00ad00fa    pop     ebp
00ad00fb    ret

上面的汇编代码非常简单,从中可以看出,每次递归调用都使用了最简单的call指令,没有经过任何有效的优化或调整。因此在不断地递归调用之后,终究会出现堆栈溢出。这就是普通递归的缺陷。而对于尾递归来说,MSIL提供了额外的tail指令表示“尾调用”1,它只需简单补充在IL指令call, callvirt, calli之前便可。因此我们使用ildasm.exe将IL代码dump出来,并在call之前加上tail指令:

.method private hidebysig static int32 FactorialTailRecursion(int32 n, int32 acc) cil managed
{
    .maxstack 8
    L_0000: ldarg.0
    L_0001: brtrue.s L_0005
    L_0003: ldarg.1
    L_0004: ret
    L_0005: ldarg.0
    L_0006: ldc.i4.1
    L_0007: sub
    L_0008: ldarg.1
    L_0009: ldarg.0
    L_000a: mul
    L_000b: tail.
    L_000c: call int32 TailRecursion.Recursion::FactorialTailRecursion(int32, int32)
    L_0010: ret
}

使用ilasm.exe重新编译之后运行,再重新察看FactorialTailRecursion的汇编代码:

00a600d0    push    ebp
00a600d1    mov     ebp,esp
00a600d3    push    edi
00a600d4    push    esi
00a600d5    push    ebx
00a600d6    mov     eax,ecx
00a600d8    mov     esi,edx
00a600da    test    eax,eax
00a600dc    jne     00a600e5
00a600de    mov     eax,esi
00a600e0    pop     ebx
00a600e1    pop     esi
00a600e2    pop     edi
00a600e3    pop     ebp
00a600e4    ret
00a600e5    lea     ecx,[eax-1]
00a600e8    imul    eax,esi
00a600eb    mov     edx,eax
00a600ed    mov     eax,dword ptr ds:[813068h]
00a600f3    push    0
00a600f5    push    0
00a600f7    push    1
00a600f9    push    eax
00a600fa    cmp     dword ptr [mscorwks!g_TrapReturningThreads (7204339c)],0
00a60101    je      00a6010c
00a60103    push    ecx
00a60104    push    edx
00a60105    call    mscorwks!JIT_PollGC (71d5c9d3)
00a6010a    pop     edx
00a6010b    pop     ecx
00a6010c    call    mscorwks!JIT_TailCall (71b02890)
00a60111    int     3

在这里我实在无法完整讲述上述汇编代码的含义,不过从中可以看出它的确对于尾递归进行了特别的处理,而并非使用简单的call指令进行调用。对此互联网上的资源也不多,我只找到了Shri Borde的一篇文章,其中简单描述了Whidbey V2(真早)中CLR对于这方面的处理,以及一些相关的考虑,从中似乎能够看出一些苗头来。

让我们再回想之前的问题:Continuation无法通过简单优化为循环来解决递归问题。但是通过观察可以看出,Continuation.Invoke方法中的cont委托调用是最后一条命令,这说明它是一个“尾调用”——虽然不是“尾递归”,不过这已经满足tail指令的要求了:只需和所在方法返回值相同(或兼容)即可。因此,对于Continuation来说,我们也需要进行尾递归的优化。您可以进行尝试,现在无论递归多“深”,都不会使堆栈溢出了。

相关文章

 

注1:与tail类似,IL指令jmp也能够用于方法调用。这两条指令都不会在调用栈上堆积数据。tail与jmp的不同之处在于,前者只需要返回值与所在方法相同或兼容即可,而后者需要签名完全相同。您可以想象得到,对于“直接递归”来说,可以使用jmp进行调用,而普通的“尾调用”,则只能使用tail了。

Creative Commons License

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

Add your comment

44 条回复

  1. 包建强
    *.*.*.*
    链接

    包建强 2009-03-31 21:26:00

    进来看看,不感兴趣,hoho。
    小赵看看这幅漫画。

  2. 老赵
    admin
    链接

    老赵 2009-03-31 21:27:00

    --引用--------------------------------------------------
    包建强: 进来看看,不感兴趣,hoho。
    小赵看看这幅漫画。
    --------------------------------------------------------
    什么漫画?

  3. 亚历山大同志
    *.*.*.*
    链接

    亚历山大同志 2009-03-31 21:43:00

    什么漫画?

  4. 徐少侠
    *.*.*.*
    链接

    徐少侠 2009-03-31 21:44:00

    老赵的东西是需要先沐浴后

    安静下来看的。

    呵呵

  5. Hr
    *.*.*.*
    链接

    Hr 2009-03-31 21:47:00

    --引用--------------------------------------------------
    包建强: 进来看看,不感兴趣,hoho。
    小赵看看这幅漫画。
    --------------------------------------------------------
    感不感兴趣是一回事,老赵能把问题再和大家分析到实质还是很让人佩服的,愿意分享知识的人我是很尊敬的.
    如果反汇编出的代码是简单的call必然会有堆栈溢出,我原先以为会优化成非call的指令以jmp和堆栈优化的方式来做优化的,不过现在看来还没那么简单,我没学过.net,对.net的机制一点都不了解,不然下面2句不知道它具体是做什么用的,和老赵学习一下
    00a60103 push ecx
    00a60104 push edx
    00a60105 call mscorwks!JIT_PollGC (71d5c9d3)
    00a6010a pop edx
    00a6010b pop ecx
    00a6010c call mscorwks!JIT_TailCall (71b02890)

    JIT_PollGC, JIT_TailCall 分别干嘛的?

  6. 包建强
    *.*.*.*
    链接

    包建强 2009-03-31 22:01:00

    --引用--------------------------------------------------
    Hr: --引用--------------------------------------------------
    包建强: 进来看看,不感兴趣,hoho。
    小赵看看这幅漫画。
    --------------------------------------------------------
    感不感兴趣是一回事,老赵能把问题再和大家分析到实质还是很让人佩服的,愿意分享知识的人我是很尊敬的.
    如果反汇编出的代码是简单的call必然会有堆栈溢出,我原先以为会优化成非call的指令以jmp和堆栈优化的方式来做优化的,不过现在看来还没那么简单,我没学过.net,对.net的机制一点都不了解,不然下面2句不知道它具体是做什么用的,和老赵学习一下
    00a60103 push ecx
    00a60104 push edx
    00a60105 call mscorwks!JIT_PollGC (71d5c9d3)
    00a6010a pop edx
    00a6010b pop ecx
    00a6010c call mscorwks!JIT_TailCall (71b02890)

    JIT_PollGC, JIT_TailCall 分别干嘛的?


    --------------------------------------------------------

    @Hr
    是啊,我第三次仔细看了一遍最后这段代码,这段代码应该是从UltraEdit32中看到的,所以里面很多word不是IL中的关键字。所以你不应该care这个地方,而是应该从ILAsm中分析。至于JIT_PollGC, JIT_TailCall,肯定是方法啦,但具体怎么来的,估计没人知道。

  7. Hr
    *.*.*.*
    链接

    Hr 2009-03-31 22:37:00

    @包建强
    我不了解.net怎么会分析ilasm呢 :)

  8. 老赵
    admin
    链接

    老赵 2009-03-31 22:58:00

    --引用--------------------------------------------------
    Hr: JIT_PollGC, JIT_TailCall 分别干嘛的?
    --------------------------------------------------------
    这就是我不知道的,不过我给的那个链接里有点苗头,定义在mscorwks里,继续!u进去肯定可以看到汇编代码。
    等我什么时候闲得蛋疼了再研究一下。

  9. 老赵
    admin
    链接

    老赵 2009-03-31 23:00:00

    --引用--------------------------------------------------
    包建强: 是啊,我第三次仔细看了一遍最后这段代码,这段代码应该是从UltraEdit32中看到的,所以里面很多word不是IL中的关键字。所以你不应该care这个地方,而是应该从ILAsm中分析。至于JIT_PollGC, JIT_TailCall,肯定是方法啦,但具体怎么来的,估计没人知道。
    --------------------------------------------------------
    这怎么可能直接看到,肯定要等JIT后才能得到ASM,每一条指令都是机器码,没有一条是MSIL。
    我是用windbg跟踪的,不知道你说的Ultraedit32是什么办法?dump出来用Ultraedit看?

  10. Hr
    *.*.*.*
    链接

    Hr 2009-03-31 23:24:00

    @Jeffrey Zhao
    "等我什么时候闲得蛋疼了再研究一下", 太经典了......囧.

  11. 飞林沙
    *.*.*.*
    链接

    飞林沙 2009-03-31 23:37:00

  12. 飞林沙
    *.*.*.*
    链接

    飞林沙 2009-03-31 23:39:00

    我晕 终于调好了 好怪的图片属性
    包包说的就是这个图片

  13. 老赵
    admin
    链接

    老赵 2009-03-31 23:40:00

    --引用--------------------------------------------------
    飞林沙:
    --------------------------------------------------------
    这算什么……

  14. 老赵
    admin
    链接

    老赵 2009-03-31 23:41:00

    --引用--------------------------------------------------
    Hr: @Jeffrey Zhao
    “等我什么时候闲得蛋疼了再研究一下”, 太经典了......囧.
    --------------------------------------------------------
    今天你疼了没有?

  15. 飞林沙
    *.*.*.*
    链接

    飞林沙 2009-03-31 23:42:00

    @Jeffrey Zhao

    就是包包说的漫画

  16. 飞林沙
    *.*.*.*
    链接

    飞林沙 2009-03-31 23:49:00

    老赵,麻烦问个题外话,我想在亚马逊上买书。
    我在北京大概多久能到,邮费怎么算,还有我要怎么付账呢?dollar?

  17. Hr
    *.*.*.*
    链接

    Hr 2009-03-31 23:53:00

    @Jeffrey Zhao
    没发现你猥琐的一面,待我好好开发你的潜能...

  18. 老赵
    admin
    链接

    老赵 2009-03-31 23:57:00

    @飞林沙
    可以在淘宝上找人代购,或麻烦某些大型外企的人。
    前者一般5美刀一本,后者主要消费在平时搞好关系上。一般都要3个星期到1个月。
    不建议直接international shipping,没有尝试过。
    最好就是谁要回国,寄到他那里去,呵呵。

  19. 老赵
    admin
    链接

    老赵 2009-03-31 23:58:00

    --引用--------------------------------------------------
    Hr: @Jeffrey Zhao
    没发现你猥琐的一面,待我好好开发你的潜能...
    --------------------------------------------------------
    偶尔一次,忙了几天,终于有时间看书了。

  20. 飞林沙
    *.*.*.*
    链接

    飞林沙 2009-04-01 00:41:00

    淘宝代购?
    麻烦给个您常用的好么?
    我搜了半天都没找到.......

  21. 老赵
    admin
    链接

    老赵 2009-04-01 00:53:00

    @飞林沙
    不是吧,你怎么搜的阿?“亚马逊原版代购”?
    // 我现在找不到,白天再说……

  22. Tony  Qu
    *.*.*.*
    链接

    Tony Qu 2009-04-01 01:20:00

    最近流行研究IL了?看来我落伍了~

  23. 飞林沙
    *.*.*.*
    链接

    飞林沙 2009-04-01 01:25:00

    @Jeffrey Zhao

    老赵,真不像你风格,这么早就bed去了.....

  24. 老赵
    admin
    链接

    老赵 2009-04-01 08:59:00

    --引用--------------------------------------------------
    Tony Qu: 最近流行研究IL了?看来我落伍了~
    --------------------------------------------------------
    流行?关流行什么事,我是碰到什么写什么,不刻意研究,嗯嗯。你看现在Silverlight多流行,我一点都不会……

  25. 老赵
    admin
    链接

    老赵 2009-04-01 09:54:00

    @overred
    和好使不好使无关阿,原本就是两个功能,*认作是通配符,就不用写完整Module名了。比如:
    !name2ee modulename typename
    !name2ee *!typename
    后者会遍历所有已加载的模块。

  26. 老赵
    admin
    链接

    老赵 2009-04-01 10:51:00

    @overred
    不过这个应该交给编译器可以优化吧,我是用高级代码表示逻辑而已,呵呵。

  27. overred
    *.*.*.*
    链接

    overred 2009-04-01 11:02:00

    @Jeffrey Zhao
    看老赵的文章,我一般都不会抽象。。


    附:“资本家害怕没有利润或利润太小,就像自然界害怕真空一样。一旦有适当的利润,资本家就大胆起来。如果有百分之十的利润,他们就保证到处被使用;有百分之二十的利润,他们就活跃起来;有百分之五十的利润,他就铤而走险,为了百分之一百的利润,他就敢践踏一切人间法律,有百分之三百的利润,他就敢犯任何罪行,甚至冒绞刑的危险”

    ——卡尔·马克思

  28. 老赵
    admin
    链接

    老赵 2009-04-01 11:53:00

    @overred
    什么什么和什么……

  29. 韦恩卑鄙
    *.*.*.*
    链接

    韦恩卑鄙 2009-04-01 13:01:00

    其实到 IL tail那时候就说明问题了

    毕竟il到每个平台的汇编都有可能有不同实现

  30. 装配脑袋
    *.*.*.*
    链接

    装配脑袋 2009-04-01 13:21:00

    老赵试验一下64位.NET下,AnyCPU和x64平台栈空间不一样大的问题?

  31. 老赵
    admin
    链接

    老赵 2009-04-01 13:24:00

    @装配脑袋
    没有这个条件……

  32. 老赵
    admin
    链接

    老赵 2009-04-01 13:24:00

    --引用--------------------------------------------------
    韦恩卑鄙: 其实到 IL tail那时候就说明问题了
    毕竟il到每个平台的汇编都有可能有不同实现
    --------------------------------------------------------
    嗯嗯,我同意。我放到汇编,只是表示了“汇编”会有不同——其实写文章一开始是想说明白的……

  33. frank.d.zu
    *.*.*.*
    链接

    frank.d.zu 2009-04-01 21:26:00

    markup

  34. 未登录的怪怪[未注册用户]
    *.*.*.*
    链接

    未登录的怪怪[未注册用户] 2009-04-03 08:24:00

    对他们现在怎么具体实现不是太蛋疼所以没细看,但就我的笨脑子想(我的意思是让我来实现的话),估摸着相当于修改个返回地址啥的,然后把没用的处理了完事,比如删掉一个帧之类:关键是让尾部被调用者直接返回到当前方法的调用者。

    不过如果跟我想得差不多,我倒是觉得编译器不优化这里也不是没有道理,毕竟如果情况复杂一些,这么直接暴力的做法是不是能照顾所有情况是个问题。

    Update:一较真就忍不住去看了看老赵给的那个链接。他说的似乎是:.NET不优化的原因是,GC的实现搞的一些猫腻是从当前方法的返回地址入手的,在这里进行尾递归的优化,会导致泄漏;如果是两个方法连续相互尾递归甚至是无限递归,因为GC找不着机会下手,就只好去发呆了。

    估计上面的东东就是处理GC的,而且从老赵给的结果来看,这个问题就没好好解决,所以编译器就不生成tail.call的IL了。嗯嗯真讨厌,又被勾起了馋虫,沦落到细节的陷阱里去了 -___-

  35. edfg5233[未注册用户]
    *.*.*.*
    链接

    edfg5233[未注册用户] 2009-04-06 07:55:00

    路过,看不懂,需要长时间进修了

  36. 多米诺
    *.*.*.*
    链接

    多米诺 2009-10-18 14:47:00

    在 Debug/Release 编译并运行下述函数
    public static void Go(int n)
    {
    Console.Write(n);
    Go(n + 1);
    }
    结果都是堆栈溢出,估计.net 下没有真正的“尾递归”,也许其实现还在纸面上吧。。。

  37. 老赵
    admin
    链接

    老赵 2009-10-18 14:58:00

    @多米诺
    尾递归是要编译器出力的,其实和平台没有太大关系。
    而且,.NET有tail指令,只是C#不用而已,F#用了,64位C#也用了。
    // 其实这些都在文章里写了啊……

  38. elite_lcf
    *.*.*.*
    链接

    elite_lcf 2009-11-03 10:12:00

    受教

  39. 晶晶
    218.24.179.*
    链接

    晶晶 2011-10-03 19:15:30

    您好,我在看快速排序算法的改进的时候看到空间优化的方案就是尾递归优化,我已经打印出来,好好研究研究,我还有一个叫做“替代分区策略”不是很懂,而且我也没有找到相关的资料,也不知道“alternative partition strategy”翻译成“替代分区策略”,是不是我翻译错了???如果您对这个有了解的话希望能给我讲解一下,谢谢了!!!

  40. 老赵
    admin
    链接

    老赵 2011-10-05 00:14:58

    @晶晶

    其实就是“另一种”分区策略,“可选”的分区策略的意思吧。

  41. charlie
    203.117.130.*
    链接

    charlie 2011-11-16 10:55:11

    循环可以做到的事情,我们用递归来做,还要写成尾递归的方式,这样编译器可以帮助我们优化成循环。

  42. 老赵
    admin
    链接

    老赵 2011-11-16 11:01:50

    @charlie

    文章已经写的很清楚了吧?首先,不是所有语言都支持循环。其次不是所有问题都容易用循环来做。再次,不是所有尾递归都能够被优化为循环。

  43. 链接

    satan.student 2014-08-19 19:03:39

    感觉这个还是编译器的问题,其效果完全取决于编译器。不少函数式编程语言都可以把递归转化成尾递归(也许是把所有的调用都弄成尾递归了,见 Compiling with Continuations),然后最终就没有调用栈多大事了,所以也不会出现在栈上累计数据以至于栈溢出这样的囧事了。

  44. 链接

    satan.student 2014-08-19 19:05:21

    老赵你还有个坑没填呢,尾递归对时间与空间复杂度的影响(下)这篇文章什么时候出啊,嘿嘿。

发表回复

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

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

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

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

使用Live Messenger联系我