Hello World
Spiga

Why Java Sucks and C# Rocks(6):yield及其作用

2010-07-18 21:26 by 老赵, 10183 visits

C# 2.0新增了yield关键字,其初衷是简化迭代器的生成,这可以说是现代语言的标配。只可惜Java历经数次升级,从数量上来说也算增加了不少语言特性了,却还是将这个功能拒之门外,让人费解。除了用于生成迭代器之外,yield还可用于其它一些场景,颇为奇妙。这些场景都是在生产过程中常用的开发模式,只可惜对于使用Java语言的程序员来说都只能望而兴叹了。

迭代生成器

说起迭代器(Iterator)大家一定都不陌生,无论是是Java,C#或是Python等语言都有内置标准的迭代器结构,它们也都提供了内置的for或foreach关键字简化迭代器的“使用”。不过对于迭代器的“生成”,不同语言之间的就会有很大差距。例如,在C#和Python中都提供了yield来简化迭代器的“创建”,此时生成一个迭代器便再简单不过了。但对于Java程序员来说,即使到了Java 7还必须为在迭代器内部手动维护状态,非常痛苦。而更重要的一点是,利用yield我们可以轻松地创建一个“延迟”的,“无限”的序列。

例如,如果我们使用Java写一个无限的斐波那契数列,一般则需要这样:

// Java
public class Fibonacci implements Iterable<Integer> {

    @Override
    public Iterator<Integer> iterator() {
        return new Iterator<Integer>() {

            private int m_state = 0;
            private int m_current;
            private int m_last0;
            private int m_last1;
            
            public boolean hasNext() {
                return true;
            }
            
            public Integer next() {
                if (m_state == 0) { // first
                    this.m_current = 0;
                    this.m_state = 1;
                }
                else if (this.m_state == 1) {
                    this.m_current = 1;
                    this.m_last1 = 0;
                    this.m_state = 2;
                }
                else {
                    this.m_last0 = this.m_last1;
                    this.m_last1 = this.m_current;
                    this.m_current = this.m_last0 + this.m_last1;
                }
                
                return this.m_current;
            }

            public void remove() {
                throw new UnsupportedOperationException();
            }
        };
    }
}

在C# 1.0实现相同的功能(即IEnumerable<int>迭代器)也需要使用类似的做法,甚至比Java更麻烦一些,因为在C#中没有Java语言中的“匿名类型”特性。如下:

展开代码
隐藏代码 

// C#
public class Fibonacci : IEnumerable<int>
{
    public class Enumerator : IEnumerator<int>
    {
        private int m_state = 0;
        private int m_current;
        private int m_last0;
        private int m_last1;

        public bool MoveNext()
        {
            if (this.m_state == 0) // first
            {
                this.m_current = 0;
                this.m_state = 1;
            }
            else if (this.m_state == 1)
            {
                this.m_current = 1;
                this.m_last1 = 0;
                this.m_state = 2;
            }
            else
            {
                this.m_last0 = this.m_last1;
                this.m_last1 = this.m_current;
                this.m_current = this.m_last0 + this.m_last1;
            }

            return true;
        }

        public int Current { get { return this.m_current; } }
        object IEnumerator.Current { get { return this.Current; } }

        public void Reset() { }
        public void Dispose() { }
    }

    public IEnumerator<int> GetEnumerator()
    {
        return new Enumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return this.GetEnumerator();
    }
}

一个枚举器其实就是个状态机,在普通状态下我们往往需要手动维护其中的格式状态,编写起来可谓既费神又不直观。幸好C# 2.0提供了yield语法支持,一切就变得简单了:

// C#
public static IEnumerable<int> GenerateFibonacci()
{
    yield return 0;
    yield return 1;

    int last0 = 0, last1 = 1, current;

    while (true)
    {
        current = last0 + last1;
        yield return current;

        last0 = last1;
        last1 = current;
    }
}

yield return的作用是在执行到这行代码之后,将控制权立即交还给外部,此时外部代码可以通过Current对象访问到返回出去的值。而yield return之后的代码会在外部代码再次调用MoveNext时才会执行,直到下一个yield return——或是迭代结束。虽然上面的代码看似有个死循环,但事实上在循环内部我们始终会把控制权交还给外部,这就由外部来决定何时中止这次迭代。有了yield之后,我们便可以利用“死循环”,我们可以写出含义明确的“无限的”斐波那契数列。

就最终执行的代码来说,C# 2.0和Java或C# 1.0是差不多的,只不过C#的编译器帮助开发人员节省了许多工作。事实上,我们根据C#编译器最终的生成结果,可以根据一定规律反推出原始代码,只是在某些情况下会显得比较困难罢了。

如“无限斐波那契数列”那样,利用yield我们可以用最直观的方式实现一个迭代器,例如连接多个迭代器:

// C#
static IEnumerable<T> Concat<T>(params IEnumerable<T>[] iterators)
{
    foreach (var iter in iterators)
    {
        foreach (var item in iter)
            yield return item;
    }
}

或是一个二叉树的中序遍历:

// C#
static IEnumerable<T> Traverse<T>(TreeNode<T> node)
{
    if (node == null) yield break;

    foreach (var child in Traverse(node.Left))
        yield return child;

    yield return node.Value;
    
    foreach (var child in Traverse(node.Right))
        yield return child;
}

如果没有yield,那么这两段代码会是什么样子呢?如果您感兴趣的话,也不妨使用Java语言来实现一下,有比较便能看出差距。

简化异步操作

异步操作是强大的,它是许多高伸缩性架构的基石。但是,异步编程又是十分困难的,它让这让许多程序员敬而远之。因此,越来越多的编程语言都对异步编程提供了相当程度的支持,其中的典型代表便是F#中的异步工作流。不过,其实在C# 2.0出现了yield之后,许多情况下的异步编程已经变得十分简单了。那么,我们还是先来看一下异步编程困难的原因吧。

这里我准备了一个接口:

// C#
public class CompletedEventArgs : EventArgs
{
    public CompletedEventArgs(Exception ex)
    {
        this.Error = ex;
    }

    public Exception Error { get; private set; }
}

public class WebAsyncTransfer
{
    public void StartAsync(HttpContext context, string url)
    {
        ...
    }

    public event EventHandler<CompletedEventArgs> Completed;
}

在这里WebAsyncTransfer是一个“异步下载类”,它的StartAsync方法会发起一个针对远程url的请求,并将内容下载至context中(并设置ContentType等参数),下载完成后则通过Completed事件进行通知。写好了吗?那么也来看看我给的参考答案吧:

展开代码
隐藏代码 

// C#
public class WebAsyncTransfer
{
    private HttpContext m_context;
    private WebRequest m_request;
    private WebResponse m_response;
    private Stream m_streamIn;
    private Stream m_streamOut;

    public void StartAsync(HttpContext context, string url)
    {
        this.m_context = context;

        this.m_request = HttpWebRequest.Create(url);
        this.m_request.BeginGetResponse(this.EndGetResponse, null);
    }

    public event EventHandler<CompletedEventArgs> Completed;

    private void EndGetResponse(IAsyncResult ar)
    {
        try
        {
            this.m_response = this.m_request.EndGetResponse(ar);
            this.m_context.Response.ContentType = this.m_response.ContentType;

            var buffer = new byte[1024];
            this.m_streamIn = this.m_response.GetResponseStream();
            this.m_streamOut = this.m_context.Response.OutputStream;

            this.m_streamIn.BeginRead(
                buffer, 0, buffer.Length,
                this.EndReadInputStream, buffer);
        }
        catch (Exception ex)
        {
            this.OnCompleted(ex);
        }
        finally
        {
            this.m_request = null;
        }
    }

    private void EndReadInputStream(IAsyncResult ar)
    {
        var buffer = (byte[])ar.AsyncState;
        int lengthRead;

        try
        {
            lengthRead = this.m_streamIn.EndRead(ar);
        }
        catch (Exception ex)
        {
            this.OnCompleted(ex);
            return;
        }

        if (lengthRead <= 0)
        {
            this.OnCompleted(null);
        }
        else
        {
            try
            {
                this.m_streamOut.BeginWrite(
                    buffer, 0, lengthRead,
                    this.EndWriteOutputStream, buffer);
            }
            catch (Exception ex)
            {
                this.OnCompleted(ex);
            }
        }
    }

    private void EndWriteOutputStream(IAsyncResult ar)
    {
        try
        {
            this.m_streamOut.EndWrite(ar);

            var buffer = (byte[])ar.AsyncState;
            this.m_streamIn.BeginRead(
                buffer, 0, buffer.Length,
                this.EndReadInputStream, buffer);
        }
        catch (Exception ex)
        {
            this.OnCompleted(ex);
        }
    }

    private void OnCompleted(Exception ex)
    {
        if (this.m_response != null)
        {
            this.m_response.Close();
            this.m_response = null;
        }

        var handler = this.Completed;
        if (handler != null)
        {
            handler(this, new CompletedEventArgs(ex));
        }
    }
}

是不是很复杂?

异步操作的难点之一,便是破坏了“代码局部性(Code Locality)”,这可能也是异步操作中最为常见的阻碍。程序员早已习惯了“线性”地表达逻辑,但即便是多个顺序执行的异步操作,也会因为大量的回调函数而将算法拆得支离破碎,更何况还会出现各种循环及条件判断。同时,在线性的代码中,我们可以使用“局部变量”保存状态,而在编写异步代码时则需要手动地在多个函数中传递状态。此外,由于逻辑被拆分至多个方法,因此我们也无法使用传统的try/catch进行统一异常处理。

反映在上面这段实现中,就在于我们无法使用普通循环来实现异步读取写入,也必须在每个异步操作时使用try…catch来捕获可能会抛出的异常。此外,我们还必须手动地保持状态,更重要的是手动地清理一些资源。例如在EndGetResponse方法中,我们需要手动地将m_request设为null,这样使得该对象可以早于WebAsyncTransfer得到回收。总之,编写异步代码就是这么麻烦。

那么yield又是怎么样帮到我们的呢?且看如下代码:

private static IEnumerator<int> GenerateTransferTask(
    AsyncEnumerator ae, HttpContext context, string url)
{
    WebRequest request = WebRequest.Create(url);
    request.BeginGetResponse(ae.End(), null);
    yield return 1;

    using (WebResponse response = request.EndGetResponse(ae.DequeueAsyncResult()))
    {
        Stream streamIn = response.GetResponseStream();
        Stream streamOut = context.Response.OutputStream;
        byte[] buffer = new byte[1024];

        while (true)
        {
            streamIn.BeginRead(buffer, 0, buffer.Length, ae.End(), null);
            yield return 1;
            int lengthRead = streamIn.EndRead(ae.DequeueAsyncResult());

            if (lengthRead <= 0) break;

            streamOut.BeginWrite(buffer, 0, lengthRead, ae.End(), null);
            yield return 1;
            streamOut.EndWrite(ae.DequeueAsyncResult());
        }
    }
}

这段代码利用了Jeffrey Ricther提供的AsyncEnumerator组件。在每次发起一个异步操作之后,我们使用yield将操作控制权交给外部——实际上就是AsyncEnumerator组件,然后在异步操作结束之后,AsyncEnumerator又会调用迭代器的MoveNext方法,这样便可以于yield之后的代码继续执行了。在这里我们可以继续使用while,if,break等常见的控制语句来表述“线性”的逻辑,而编译器会为我们生成那些“支离破碎”的代码。至于异常控制,我们只需要在一处进行即可:

public class YieldWebAsyncTransfer
{
    private static IEnumerator<int> GenerateTransferTask(
        AsyncEnumerator ae, HttpContext context, string url)
    {
        ...
    }

    private AsyncEnumerator m_asyncEnumerator;

    public void StartAsync(HttpContext context, string url)
    {
        this.m_asyncEnumerator = new AsyncEnumerator();
        var asyncTask = GenerateTransferTask(this.m_asyncEnumerator, context, url);
        this.m_asyncEnumerator.BeginExecute(asyncTask, this.EndExecuteCallback);
    }

    private void EndExecuteCallback(IAsyncResult ar)
    {
        Exception error = null;
        try
        {
            this.m_asyncEnumerator.EndExecute(ar);
        }
        catch (Exception ex)
        {
            error = ex;
        }

        var handler = this.Completed;
        if (handler != null)
        {
            handler(this, new CompletedEventArgs(error));
        }
    }

    public event EventHandler<CompletedEventArgs> Completed;
}

这就是yield的威力。yield本身只是个基础语言特性,但是有了这个特性,开发人员就能写出如AsyncEnumerator这样简化异步编程的类库,甚至在一定程度上模拟F#中异步工作流的功能。同样的功能,有的语言只能写出编写困难理解不易的代码,而有的语言却让开发人员轻松地完成工作,而最终的成果也十分利于后期的维护。这个情况下,您还会说语言是不重要的吗?

轻量级任务

如果您有过VB(不是VB.NET)编程的经验,可能还记得当时是如何在进行长时间计算的情况下保持界面响应能力的。没错,就是使用DoEvents语句。DoEvents的作用是暂时将计算挂起,把控制权交还给UI,看看有没有什么事件需要响应,然后再继续DoEvents后的计算。其实yield从某些角度上看也有这样的效果,例如MSDN上写道

yield关键字用于指定返回的值。到达yield return语句时,会保存当前位置。下次调用迭代器时将从此位置重新开始执行。

这里的关键就在于“保存当前位置”并交出控制权,这时候我们便有办法根据需要进行下一步的处理。例如我们知道,操作系统进行任务调度的最小单元是“线程(Thread)”,此外Windows里有“纤程(Fiber)”,可用于在线程的基础上手动实现更小粒度的任务调度,还有一些如“协程(coroutine)”之类的概念也有相似之处。利用yield我们也可以在C#中实现更小粒度的任务概念,这只需要任务本身在合适的时候使用yield将控制权交还给外部即可。外部的任务调度逻辑可以在得到控制权的时候,判断是否继续当前任务还是切换到下一个任务。如此,我们便可以自己定义调度实现了。

事实上,之前的异步编程在一定程度上也是基于这里的“轻量级任务”,只不过这个应用过于典型,因此单独拿出来强调一下。

总结

有人说,yield不该加入到语言之中,它破坏了语言的紧凑性。但我认为,yield本身是个再简单不过的语言特性,你几乎不会察觉到它的存在。更何况,yield本身的确大大降低了创建迭代器的难度,而迭代器本身可以说是系统中最常见的功能之一,因此我认为在语言中为其加入foreach和yield关键字的支持丝毫不为过。更何况我们也看到,yield本身也有超脱于迭代器之外作用,它们都源于我日常工作中的使用模式。因此在我看来,yield是一个不可或缺的语言功能,优雅,简单。

相关文章

Creative Commons License

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

Add your comment

27 条回复

  1. tangang
    60.176.101.*
    链接

    tangang 2010-07-18 22:16:02

    沙发,嘿嘿,先占了再看。。。

  2. Duron800
    125.39.132.*
    链接

    Duron800 2010-07-18 22:21:55

    看完沙发没了。

  3. lee51076008
    124.117.87.*
    链接

    lee51076008 2010-07-18 22:49:02

    DoEvents的作用是暂时将计算挂起,把控制权交还给UI,看看有没有什么事件需要【相应】,然后再继续DoEvents后的计算。

    应该是“响应”吧?

  4. 老赵
    admin
    链接

    老赵 2010-07-18 23:02:34

    @lee51076008

    谢谢

  5. infinte
    60.166.109.*
    链接

    infinte 2010-07-19 10:15:53

    你说我在lofn中要不要做这个功能呢?

    类似这样:

    generator(args){
        ......
        yield return x
        ......
    }
    
  6. You Xu
    71.81.146.*
    链接

    You Xu 2010-07-19 10:21:21

    用函数是编程的角度来说, yield 简单来说就是在语言里做出了 continuation; coroutine, generator, 和 async call 都是引申用法。用命令编程的角度来说,yield 则是做出了 coroutine, 其他是引申用法。 其实以 java 的 threading 的能力,随便写一个 coroutine 库乃是砍瓜切菜,随便一搜就很多。 比如搜 Java+Yield 或者 Java+coroutine. 当然的确没有 yield 这样一个关键字好用。

  7. 老赵
    admin
    链接

    老赵 2010-07-19 10:23:17

    @You Xu

    任何有threading能力的语言框架之类的都容易做的,但有些东西比如这里的的确需要一个yield来支持,否则实在就是不好用……关于这点还有个例子就是.NET 4.0的并行库了。

  8. 老赵
    admin
    链接

    老赵 2010-07-19 10:26:29

    @infinte

    我觉得你在加之前需要先想出几种这个特性的应用场景吧,话说这是Anders说它考虑语言特性的原则之一。

  9. 躺着读书
    116.77.210.*
    链接

    躺着读书 2010-07-19 11:01:59

    轻量级任务 不就是 这个嘛 PlatformUI.getWorkbench().getDisplay().asyncExec(new Runnable(){})

  10. 链接

    miloyip 2010-07-19 11:04:27

    Fiber (computer science)

    Fibers describe essentially the same concept as coroutines. The distinction, if there is any, is that coroutines are a language-level construct, a form of control flow, while fibers are a systems-level construct, viewed as threads that happen not to run concurrently. Priority is contentious; fibers may be viewed as an implementation of coroutines, or as a substrate on which to implement coroutines.

  11. 老赵
    admin
    链接

    老赵 2010-07-19 11:29:40

    @躺着读书

    这里的关键不是“给个计算任务”,而是要“能调度”的计算任务。一个Runnable要么不执行,要么从头执行到底,和coroutines还是不一样的……

  12. 链接

    twty1988 2010-07-19 14:04:24

    @老赵

    Fibonacci 这个JAVA程序是JDK1.7编译的吗?1.6编译不了。

  13. 链接

    twty1988 2010-07-19 14:35:26

    好像是少了一对括号。

  14. 老赵
    admin
    链接

    老赵 2010-07-19 15:03:12

    @twty1988: 少了一对括号。

    嗯,是,谢谢。

  15. 躺着读书
    116.77.210.*
    链接

    躺着读书 2010-07-20 11:23:22

    @老赵

    public void function(){
        dosomething();
        PlatformUI.getWorkbench().getDisplay().asyncExec(new Runnable(){}) ;
        dorest();
    }
    

    我说的这个就是你提的,Eclipse会保证dorest()执行完以后才会执行Runnable内的内容。这里已经是调度了。Runnable只是接口啊,这里包装接口的不是 new Thread(new Runnable(){...});

    java的任务调度有很成熟的框架的org.eclipse.core.runtime.jobs就是。

    yield还是有用的,不过有了闭包后,意义就没有原来那么大了。

  16. 老赵
    admin
    链接

    老赵 2010-07-20 13:41:29

    @躺着读书

    我提的不是你这个。你说的这个还是刚才那个问题:这个Runnable要么不执行,要么就从头执行到尾。如果你认为这个也是调度的话,那么我提的应该是“更灵活的调度”吧。简单地说,就是要求一个任务可以在执行过程中交出控制权,供调度逻辑决定什么时候继续执行,就像VB里的执行长任务时的DoEvents语句的作用一样。

  17. richlxx
    203.86.42.*
    链接

    richlxx 2010-07-20 17:17:04

    老赵啊,唐骏以前也是盛大的,你也报点内幕呗?

  18. 老赵
    admin
    链接

    老赵 2010-07-20 17:29:45

    @richlxx

    我去盛大时他都离开N久了……

  19. Chris
    202.120.34.*
    链接

    Chris 2010-07-21 20:22:17

    好文,顶起! 不过不知道通过yield方法进行遍历的效率如何?

  20. 神仙
    58.246.74.*
    链接

    神仙 2010-07-29 18:25:43

    呵呵 从 python 里引入的这个特性确实很实用。

  21. 链接

    tsorgy 2010-08-10 23:42:30

    @Chris

    正如LZ说的那样,yield创建出来的迭代器在编译器编译之后形成的也是那些“支离破碎”的代码。所以就效率来说和不用yield实现的迭代器应该一样。

    @老赵

    我不知道VB6的DoEvents编译之后是一种什么样的形式、用什么途径实现的,不过DoEvents插入到一个For循环里会严重影响整个循环的速度,和Win32 API实现多线程来处理完全是天差地别。 或许DoEvents使控制权交给UI后要等到界面渲染完毕才会继续执行DoEvents下面的语句吧,也就是说实际上还是单线程的处理。

  22. 老赵
    admin
    链接

    老赵 2010-08-11 00:37:28

    @tsorgy

    DoEvents其实就是让UI线程上的逻辑中断一下,处理掉界面消息。的确是单线程的,因为UI线程终归只有一个么,呵呵。

  23. woxf
    221.12.174.*
    链接

    woxf 2011-04-17 20:50:13

    一口气把这系列看完了,发现自己眼界太窄了。 说了这么多,能否给学习.NET的菜鸟们指点下学习曲线、学习路径呢?不要总是提自学,就算自学也得高手点拨下啊 让学习.NET的人真正的多起来

  24. 链接

    ClarenceAu 2012-03-01 13:10:14

    老赵啊老赵啊!我本来从大一开始就学习Java,但是现在看了你越来越多的文章,而且自己对Java的理解也越来越深之后,我也搞得很想学习一下.net平台上的语言。能不能给我个建议,学习C#看哪本书比较好?我在书店看到很多名字很大的书,实在不敢买。我就看到一般CLR via c#,感到比较靠谱,但是翻看目录后又感觉那书还太过偏重CLR原理的讲解,暂时还没懂CLR上的任何语言的情况下好像不太适合。所以能否推荐一本比较初学者的书籍。

  25. 老赵
    admin
    链接

    老赵 2012-03-01 13:57:16

    @ClarenceAu

    一般在C#和.NET方面我就推荐两本书,一本是《CLR via C#》,一本是《C# in Depth》。

  26. Nevermore
    58.254.92.*
    链接

    Nevermore 2014-08-25 16:20:00

    写得很好!这个系列还会继续更新吗?期待中

  27. ahdung
    222.172.161.*
    链接

    ahdung 2015-09-06 17:49:41

    来自几年后的低端码农的回复:虽然以博主的层次已经不需要我说什么,但我还是想说,博主的代码功力深厚,质量高到违反广告法,是那种可以写核心基础类库、API的人,仰视。

发表回复

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

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

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

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

使用Live Messenger联系我