Skip to main content

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



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

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是一个不可或缺的语言功能,优雅,简单。

本文为 赵劼 发表在 个人博客 的系列文章之一。