Junio de 2017

Volumen 32, número 6

Essential .NET: iteradores personalizados con Yield

Por Mark Michaelis

Mark MichaelisEn mi última columna (msdn.com/magazine/mt797654), investigué los detalles del funcionamiento oculto de la instrucción foreach de C# , y expliqué cómo el compilador de C# implementa las capacidades de foreach en el Lenguaje intermedio común (CIL).  También traté brevemente la palabra clave yield con un ejemplo (consulte la Figura 1), pero prácticamente sin ninguna explicación.

Figura 1 Producción de algunas palabras clave de C# de manera secuencial

using System.Collections.Generic;
public class CSharpBuiltInTypes: IEnumerable<string>
{
  public IEnumerator<string> GetEnumerator()
  {
    yield return "object";
    yield return "byte";
    yield return "uint";
    yield return "ulong";
    yield return "float";
    yield return "char";
    yield return "bool";
    yield return "ushort";
    yield return "decimal";
    yield return "int";
    yield return "sbyte";
    yield return "short";
    yield return "long";
    yield return "void";
    yield return "double";
    yield return "string";
  }
    // The IEnumerable.GetEnumerator method is also required
    // because IEnumerable<T> derives from IEnumerable.
  System.Collections.IEnumerator
    System.Collections.IEnumerable.GetEnumerator()
  {
    // Invoke IEnumerator<string> GetEnumerator() above.
    return GetEnumerator();
  }
}
public class Program
{
  static void Main()
  {
    var keywords = new CSharpBuiltInTypes();
    foreach (string keyword in keywords)
    {
      Console.WriteLine(keyword);
    }
  }
}

Esta es una continuación de ese artículo, en el que proporciono más detalles sobre la palabra clave yield y su funcionamiento.

Iteradores y estado

Al colocar un punto de interrupción al principio del método GetEnumerator en la Figura 1, observará que se llama a GetEnumerator al principio de cada instrucción foreach.  En este punto, se crea un objeto iterador y su estado se inicializa en un estado "start" especial que representa el hecho de que ningún código se ejecutó en el iterador y, por lo tanto, no se produjo todavía ningún valor. A partir de entonces, el iterador mantiene su estado (ubicación), siempre que la instrucción foreach en el sitio de la llamada se siga ejecutando. Cada vez que el bucle solicita el siguiente valor, el control introduce el iterador y continúa donde lo dejó la última vez en torno al bucle; la información de estado almacenada en el objeto iterador se usa para determinar dónde debe reanudarse el control. Cuando finaliza la instrucción foreach en el sitio de la llamada, el estado del iterador ya no se guarda. La Figura 2 muestra el diagrama de una secuencia de alto nivel de lo que sucede. Recuerde que el método MoveNext aparece en la interfaz IEnumerator<T>.

En la Figura 2, la instrucción foreach en el sitio de la llamada inicia una llamada a GetEnumerator en la instancia de CSharpBuiltInTypes denominada keywords. Como puede ver, siempre es seguro volver a llamar a GetEnumerator; se crearán objetos enumeradores "fresh" si es necesario. Dada la instancia del iterador (a la que hace referencia el iterador), la instrucción foreach inicia cada una de las iteraciones con una llamada a MoveNext. En el iterador, debe volver a producir un valor en la instancia foreach en el sitio de la llamada. Después de la instrucción yield return, el método GetEnumerator se interrumpe aparentemente hasta la siguiente solicitud MoveNext. De vuelta al cuerpo del bucle, la instrucción foreach muestra el valor producido en la pantalla. A continuación, realiza un bucle invertido por todas partes y vuelve a llamar a MoveNext en el iterador. Observe que la segunda vez, el control se elige en la segunda instrucción yield return. Una vez más, la instrucción foreach se muestra en la pantalla que CSharpBuiltInTypes produjo y vuelve a iniciar el bucle. Este proceso continúa hasta que no existen más instrucciones yield return en el iterador. En este punto, el bucle foreach en el sitio de la llamada finaliza porque MoveNext devuelve false.

Diagrama de secuencia con yield return
Figura 2 Diagrama de secuencia con yield return

Otro ejemplo de iterador

Considere un ejemplo similar con la clase BinaryTree<T>, que presenté en el artículo anterior. Para implementar la clase BinaryTree<T>, primero debo usar un iterador para que Pair<T> admita la interfaz IEnumerable<T>. La Figura 3 es un ejemplo que produce todos los elementos en Pair<T>.

En la Figura 3, la iteración sobre el tipo de datos Pair<T> crea el bucle dos veces: primero a través de yield return First y, luego, a través de yield return Second. Cada vez que se encuentra la instrucción yield return en GetEnumerator, el estado se guarda y la ejecución parece "saltar" fuera del contexto del método GetEnumerator y dentro del cuerpo del bucle. Cuando se inicia la segunda iteración, GetEnumerator comienza a ejecutarse de nuevo con la instrucción yield return Second.

Figura 3 Uso de Yield para implementar BinaryTree<T>

public struct Pair<T>: IPair<T>,
  IEnumerable<T>
{
  public Pair(T first, T second) : this()
  {
    First = first;
    Second = second;
  }
  public T First { get; }  // C# 6.0 Getter-only Autoproperty
  public T Second { get; } // C# 6.0 Getter-only Autoproperty
  #region IEnumerable<T>
  public IEnumerator<T> GetEnumerator()
  {
    yield return First;
    yield return Second;
  }
#endregion IEnumerable<T>
  #region IEnumerable Members
  System.Collections.IEnumerator
    System.Collections.IEnumerable.GetEnumerator()
  {
    return GetEnumerator();
  }
  #endregion
}

Implementación de IEnumerable con IEnumerable<T>

System.Collections.Generic.IEnumerable<T> se hereda de System.Collections.IEnumerable. Por lo tanto, al implementar IEnumerable<T>, también es necesario implementar IEnumerable. En la Figura 3, esto se realiza de manera explícita y la implementación implica simplemente una llamada a la implementación IEnumerable<T> GetEnumerator. Esta llamada de IEnumerable.GetEnumerator a IEnumerable<T>.Get­Enumerator siempre funcionará debido a la compatibilidad de tipos (a través de la herencia) entre IEnumerable<T> e IEnumerable. Dado que las signaturas de ambos métodos GetEnumerator son idénticas (el tipo de valor devuelto no distingue ninguna signatura), una o ambas implementaciones deben ser explícitas. Dada la seguridad de tipos adicional que ofrece la versión de IEnumerable<T>, la implementación de IEnumerable debe ser explícita.

El código siguiente usa el método Pair<T>.GetEnumerator y muestra "Inigo" y "Montoya" en dos líneas consecutivas:

var fullname = new Pair<string>("Inigo", "Montoya");
foreach (string name in fullname)
{
  Console.WriteLine(name);
}

Colocación de una instrucción yield return en un bucle

No es necesario codificar de forma rígida cada instrucción yield return, como hice en CSharpPrimitiveTypes y Pair<T>. Con la instrucción yield return, puede devolver valores desde dentro de una construcción de bucle. En la Figura 4 se usa un bucle foreach. Cada vez que se ejecuta foreach en GetEnumerator, devuelve el siguiente valor.

Figura 4 Colocación de instrucciones yield return en un bucle

public class BinaryTree<T>: IEnumerable<T>
{
  // ...
  #region IEnumerable<T>
  public IEnumerator<T> GetEnumerator()
  {
    // Return the item at this node.
    yield return Value;
    // Iterate through each of the elements in the pair.
    foreach (BinaryTree<T> tree in SubItems)
    {
      if (tree != null)
      {
        // Because each element in the pair is a tree,
        // traverse the tree and yield each element.
        foreach (T item in tree)
        {
          yield return item;
        }
      }
    }
  }
  #endregion IEnumerable<T>
  #region IEnumerable Members
  System.Collections.IEnumerator
    System.Collections.IEnumerable.GetEnumerator()
  {
    return GetEnumerator();
  }
  #endregion
}

En la Figura 4, la primera iteración devuelve el elemento raíz en el árbol binario. Durante la segunda iteración, atraviesa el par de subelementos. Si el par de subelementos contiene un valor no nulo, atraviesa ese nodo secundario y produce sus elementos. Tenga en cuenta que foreach (elemento T del árbol) es una llamada recursiva a un nodo secundario.

Según lo observado con CSharpBuiltInTypes y Pair<T>, ahora puede iterar en BinaryTree<T> mediante un bucle foreach. En la Figura 5 se muestra este proceso.

Figura 5 Uso de foreach con BinaryTree<string>

// JFK
var jfkFamilyTree = new BinaryTree<string>(
  "John Fitzgerald Kennedy");
jfkFamilyTree.SubItems = new Pair<BinaryTree<string>>(
  new BinaryTree<string>("Joseph Patrick Kennedy"),
  new BinaryTree<string>("Rose Elizabeth Fitzgerald"));
// Grandparents (Father's side)
jfkFamilyTree.SubItems.First.SubItems =
  new Pair<BinaryTree<string>>(
  new BinaryTree<string>("Patrick Joseph Kennedy"),
  new BinaryTree<string>("Mary Augusta Hickey"));
// Grandparents (Mother's side)
jfkFamilyTree.SubItems.Second.SubItems =
  new Pair<BinaryTree<string>>(
  new BinaryTree<string>("John Francis Fitzgerald"),
  new BinaryTree<string>("Mary Josephine Hannon"));
foreach (string name in jfkFamilyTree)
{
  Console.WriteLine(name);
}

A continuación se muestran los resultados:

John Fitzgerald Kennedy
Joseph Patrick Kennedy
Patrick Joseph Kennedy
Mary Augusta Hickey
Rose Elizabeth Fitzgerald
John Francis Fitzgerald
Mary Josephine Hannon

Origen de los iteradores

En 1972, Barbara Liskov y un equipo de científicos del MIT empezaron a investigar la programación de metodologías centrada en abstracciones de datos definidos por el usuario. Para demostrar gran parte de su trabajo, crearon un lenguaje denominado CLU que presentaba un concepto denominado "clústeres" (CLU son las tres primeras letras de este término). Los clústeres fueron los predecesores de la abstracción de datos principal que los programadores usan actualmente: los objetos. Durante su investigación, el equipo se dio cuenta de que, aunque podía usar el lenguaje CLU para la abstracción de la representación de algunos datos de los usuarios finales de sus tipos, tenían que revelar constantemente la estructura interna de sus datos para que otros usuarios pudieran consumirlos de manera inteligente. El resultado de su consternación fue la creación de una construcción de lenguaje denominada iterador. (El lenguaje CLU ofrecía muchas perspectivas de lo que finalmente se popularizaría como "programación orientada a objetos").

Cancelación de la iteración adicional: Yield Break

En ocasiones, podría querer cancelar la iteración adicional. Para hacerlo, puede incluir una instrucción if, de modo que no se ejecuten más instrucciones en el código. No obstante, también puede usar yield break para que MoveNext devuelva false y controlar que se devuelva inmediatamente al llamador y se complete el bucle. A continuación, se ofrece un ejemplo de método como este:

public System.Collections.Generic.IEnumerable<T>
  GetNotNullEnumerator()
{
  if((First == null) || (Second == null))
  {
    yield break;
  }
  yield return Second;
  yield return First;
}

Este método cancela la iteración si alguno de los elementos de la clase Pair<T> es nulo.

Una instrucción yield break es similar a la colocación de una instrucción return en la parte superior de una función tras determinar que no hay trabajo que hacer. Existe una manera de salir de las iteraciones adicionales sin rodear todo el código restante con un bloque if. En consecuencia, permite varias salidas. Úselo con precaución, ya que una lectura eventual del código podría omitir la salida temprana.

Funcionamiento de los iteradores

Cuando el compilador de C# encuentra un iterador, expande el código en el CIL adecuado para el patrón de diseño del enumerador correspondiente. En el código generado, el compilador de C# crea primero una clase privada anidada para implementar la interfaz IEnumerator<T>, junto con su propiedad Current y un método MoveNext. La propiedad Current devuelve un tipo que se corresponde con el tipo de valor devuelto del iterador. Como pudo observar en la Figura 3, Pair<T> contiene un iterador que devuelve un tipo T. El compilador de C# examina el código contenido en el iterador y crea el código necesario dentro del método MoveNext y la propiedad Current para imitar su comportamiento. Para el iterador Pair<T>, el compilador de C# genera código prácticamente equivalente (consulte la Figura 6).

Figura 6 Equivalente de C# del código de C# generado por el compilador para los iteradores

using System;
using System.Collections.Generic;
public class Pair<T> : IPair<T>, IEnumerable<T>
{
  // ...
  // The iterator is expanded into the following
  // code by the compiler.
  public virtual IEnumerator<T> GetEnumerator()
  {
    __ListEnumerator result = new __ListEnumerator(0);
    result._Pair = this;
    return result;
  }
  public virtual System.Collections.IEnumerator
    System.Collections.IEnumerable.GetEnumerator()
  {
    return new GetEnumerator();
  }
  private sealed class __ListEnumerator<T> : IEnumerator<T>
  {
    public __ListEnumerator(int itemCount)
    {
      _ItemCount = itemCount;
    }
    Pair<T> _Pair;
    T _Current;
    int _ItemCount;
    public object Current
    {
      get
      {
        return _Current;
      }
    }
    public bool MoveNext()
    {
      switch (_ItemCount)
      {
        case 0:
          _Current = _Pair.First;
          _ItemCount++;
          return true;
        case 1:
          _Current = _Pair.Second;
          _ItemCount++;
          return true;
        default:
          return false;
      }
    }
  }
}

Dado que el compilador toma la instrucción yield return y genera clases que se corresponden con lo que probablemente habría escrito manualmente, los iteradores de C# exponen las mismas características de rendimiento que las clases que implementan el patrón de diseño del enumerador manualmente. Aunque no se produce ninguna mejora en el rendimiento, el aumento de la productividad de los programadores es considerable.

Creación de varios iteradores como una sola clase

Los ejemplos de iterador anteriores implementaron el método IEnumerable<T>.Get­Enumerator, que es el que foreach busca de manera implícita. En ocasiones, quizás quiera secuencias de iteración diferentes, como la iteración inversa, el filtrado de resultados o la iteración sobre la proyección de un objeto, distintas de las predeterminadas. Puede declarar iteradores adicionales en la clase mediante su encapsulación en las propiedades o los métodos que devuelven IEnumerable<T> o IEnumerable. Si quiere iterar sobre los elementos de Pair<T> de forma inversa, por ejemplo, podría proporcionar un método GetReverseEnumerator, como se muestra en la Figura 7.

Figura 7 Uso de yield return en un método que devuelve IEnumerable<T>

public struct Pair<T>: IEnumerable<T>
{
  ...
  public IEnumerable<T> GetReverseEnumerator()
  {
    yield return Second;
    yield return First;
  }
  ...
}
public void Main()
{
  var game = new Pair<string>("Redskins", "Eagles");
  foreach (string name in game.GetReverseEnumerator())
  {
    Console.WriteLine(name);
  }
}

Observe que devuelve IEnumerable<T>, no IEnumerator<T>. Es diferente de IEnumerable<T>.GetEnumerator, que devuelve IEnumerator<T>. El código de Main muestra cómo llamar a GetReverseEnumerator mediante un bucle foreach.

Requisitos de la instrucción yield

Puede usar la instrucción yield return solo en los miembros que devuelven un tipo IEnumerator<T> o IEnumerable<T>, o sus equivalentes no genéricos. Es posible que los miembros cuyos cuerpos incluyen una instrucción yield return no tengan una instrucción return simple. Si el miembro usa la instrucción yield return, el compilador de C# genera el código necesario para mantener el estado del iterador. Al contrario, si el miembro usa la instrucción return en lugar de yield return, el programador es responsable de mantener su propia máquina de estados y de devolver una instancia de una de las interfaces de iteradores. Además, igual que todas las rutas de acceso de código de un método con un tipo de valor devuelto deben contener una instrucción return acompañada por un valor (suponiendo que no generen una excepción), todas las rutas de acceso de código de un iterador deben contener una instrucción yield return si van a devolver algún dato.

Las siguientes restricciones adicionales de la instrucción yield generan errores del compilador si se infringen:

  • La instrucción yield puede aparecer solo dentro de un método, un operador definido por el usuario o el descriptor de acceso get de un indizador o una propiedad. El miembro no debe tomar ningún parámetro ref ni out.
  • La instrucción yield no puede aparecer en ningún lugar dentro de un método anónimo o una expresión lambda.
  • La instrucción yield no puede aparecer dentro de las cláusulas catch y finally de la instrucción try. Además, una instrucción yield puede aparecer en un bloque try solo si no existe un bloque catch.

Resumen

De manera abrumadora, los genéricos fueron la característica destacada que se lanzó en C# 2.0, aunque no fue la única característica relacionada con las colecciones que se presentó entonces. Otra adición significativa fue el iterador. Como resumí en este artículo, los iteradores implican una palabra clave contextual, yield, que C# usa para generar el código CIL subyacente que implementa el patrón de iterador usado por el bucle foreach.  Además, detallé la sintaxis de yield y expliqué cómo cumple la implementación GetEnumerator de IEnumerable<T>, permite la interrupción de un bucle con yield break e incluso admite un método de C# que devuelve un objeto IEnumerable<T>.

Gran parte de esta columna proviene de mi libro "Essential C#" (IntelliTect.com/EssentialCSharp), que actualmente estoy actualizando a "Essential C# 7.0".  Para obtener más información sobre este tema, consulte el capítulo 16.


Mark Michaelis es el fundador de IntelliTect y trabaja de arquitecto técnico como jefe y formador. Durante casi dos décadas, ha sido MVP de Microsoft y director regional de Microsoft desde 2007. Michaelis trabaja con varios equipos de revisión de diseño de software de Microsoft, como C#, Microsoft Azure, SharePoint y Visual Studio ALM. Hace presentaciones en conferencias de desarrolladores y escribió varios libros, siendo el más reciente “Essential C# 6.0 (5th Edition)” (itl.tc/EssentialCSharp). Póngase en contacto con él en Facebook en facebook.com/Mark.Michaelis, en su blog IntelliTect.com/Mark, en Twitter @markmichaelis o a través de la dirección de correo electrónico mark@IntelliTect.com.

Gracias a los siguientes expertos técnicos de IntelliTect por revisar este artículo: Kevin Bost