Abril de 2017
Volume 32 - Número 4
Essential .NET - Como Entender os Iteradores Internos e Personalizados foreach de C# com yield
Este mês irei explorar as partes internas de uma construção principal de C# que todos nós programamos com frequência — a instrução foreach. Dada uma compreensão do comportamento interno de foreach, você poderá explorar como implementar as interfaces da coleção foreach usando a instrução yield, como explicarei.
Embora a instrução foreach seja fácil de codificar, fico surpreso com o fato de poucos desenvolvedores compreenderem como funciona internamente. Por exemplo, você sabia que foreach funciona de modo diferente para as matrizes e as coleções IEnumberable<T>? Qual sua familiaridade com a relação entre IEnumerable<T> e IEnumerator<T>? E se você entende as interfaces enumeráveis, sente-se confortável em implementá-las usando yield?
O Que Torna uma Classe uma Coleção
Por definição, uma coleção no .NET Framework da Microsoft é uma classe que, no mínimo, implementa IEnumerable<T> (ou o tipo não genérico IEnumerable). Essa interface é crítica porque implementar os métodos de IEnumerable<T> é o mínimo necessário para suportar a iteração em uma coleção.
A sintaxe da instrução foreach é simples e evita a complicação de precisar saber quantos elementos existem. Contudo, o tempo de execução não suporta diretamente a instrução foreach. Pelo contrário, o compilador C# transforma o código como descrito nas próximas seções.
foreach com Matrizes: A seguir, demonstramos um loop foreach simples iteragindo em uma matriz de inteiros, então, imprimindo cada inteiro no console:
int[] array = new int[]{1, 2, 3, 4, 5, 6};
foreach (int item in array)
{
Console.WriteLine(item);
}
Com este código, o compilador C# cria um equivalente CIL do loop for:
int[] tempArray;
int[] array = new int[]{1, 2, 3, 4, 5, 6};
tempArray = array;
for (int counter = 0; (counter < tempArray.Length); counter++)
{
int item = tempArray[counter];
Console.WriteLine(item);
}
Neste exemplo, observe que foreach conta com o suporte da propriedade Length e do operador de índice ([]). Com a propriedade Length, o compilador C# pode usar a instrução for para iterar cada elemento na matriz.
foreach com IEnumerable<T>: Embora o código anterior funcione bem nas matrizes nas quais o comprimento é fixo e o operador de índice é sempre suportado, nem todos os tipos de coleções têm um número conhecido de elementos. E mais, muitas das classes da coleção, inclusive Stack<T>, Queue<T> e Dictionary<TKey e TValue>, não suportam recuperar os elementos pelo índice. Portanto, é necessária uma abordagem mais geral da iteração nas coleções de elementos. O padrão do iterador fornece esta capacidade. Supondo que você pode determinar o primeiro, próximo e último elementos, é desnecessário saber a contagem e dar suporte à recuperação dos elementos pelo índice.
As interfaces System.Collections.Generic.IEnumerator<T> e System.Collections.IEnumerator não genéricas são para permitir que o padrão do iterador interaja nas coleções de elementos, ao invés do padrão de comprimento e índice mostrado anteriormente. Um diagrama de classe de suas relações aparece na Figura 1.
Figura 1 - Um Diagrama de Classe do IEnumerator<T> e das Interfaces IEnumerator
O IEnumerator, do qual IEnumerator<T> deriva, inclui três membros. O primeiro é a ferramenta MoveNext. Usando este método, você pode ir de um elemento na coleção para o próximo, enquanto, ao mesmo tempo, detecta quando enumerou cada item. O segundo membro, uma propriedade de somente leitura denominada Current, retorna o elemento atualmente no processo. Current é sobrecarregada em IEnumerator<T>, fornecendo uma implementação específica do tipo. Com esses dois membros na classe de coleção, é possível iterar a coleção simplesmente usando um loop while:
System.Collections.Generic.Stack<int> stack =
new System.Collections.Generic.Stack<int>();
int number;
// ...
// This code is conceptual, not the actual code.
while (stack.MoveNext())
{
number = stack.Current;
Console.WriteLine(number);
}
Neste código, o método MoveNext retorna false quando ele passa do final da coleção. Isso substitui a necessidade de contar os elementos durante o loop.
(O método Reset geralmente lança uma NotImplementedException, portanto, nunca deve ser chamado. Se você precisar reiniciar uma enumeração, basta criar um enumerador novo.)
O exemplo anterior mostrou a essência da saída do compilador C#, mas realmente não compila assim porque omite dois detalhes importantes em relação à implementação: intercalação e tratamento de erro.
O Estado É Compartilhado: O problema com uma implementação como essa no exemplo anterior é que se dois loops se intercalarem — um foreach dentro de outro, com ambos usando a mesma coleção — a coleção deverá manter um indicador de estado do elemento atual para que quando MoveNext for chamado, o próximo elemento possa ser determinado. Neste caso, um loop de intercalação poderá afetar o outro. (O mesmo ocorre para os loops executados por vários threads.)
Para resolver o problema, as classes de coleção não suportam o IEnumerator<T> e as interfaces IEnumerator diretamente. Pelo contrário, há uma segunda interface, denominada IEnumerable<T>, cujo único método é GetEnumerator. A finalidade desse método é retornar um objeto que suporta IEnumerator<T>. Ao invés da classe de coleção manter o estado, uma classe diferente — em geral uma classe aninhada para ter acesso às partes internas da coleção — suportará a interface IEnumerator<T> e manterá o estado do loop de interação. O enumerador é como um “cursor” ou um “indicador” na sequência. Você pode ter vários indicadores e mover qualquer um deles enumera a coleção, independentemente dos outros. Usando este padrão, o equivalente C# de um loop foreach será parecido com o código mostrado na Figura 2.
Figura 2 - Um Enumerador Separado Mantendo o Estado Durante uma Iteração
System.Collections.Generic.Stack<int> stack =
new System.Collections.Generic.Stack<int>();
int number;
System.Collections.Generic.Stack<int>.Enumerator
enumerator;
// ...
// If IEnumerable<T> is implemented explicitly,
// then a cast is required.
// ((IEnumerable<int>)stack).GetEnumerator();
enumerator = stack.GetEnumerator();
while (enumerator.MoveNext())
{
number = enumerator.Current;
Console.WriteLine(number);
}
Limpando a Seguinte Iteração: Dado que as classes que implementam a interface IEnumerator<T> mantêm o estado, às vezes, você precisará limpar o estado após sair do loop (porque todas as iterações terminaram ou foi lançada uma exceção). Para tanto, a interface IEnumerator<T> deriva de IDisposable. Os enumeradores que implementam IEnumerator não implementam necessariamente IDisposable, mas se implementarem, Dispose será chamado também. Isto permite chamar Dispose após o loop foreach sair. O equivalente C# do código CIL final, portanto, parece com a Figura 3.
Figura 3 - O Resultado Compilado de foreach nas Coleções
System.Collections.Generic.Stack<int> stack =
new System.Collections.Generic.Stack<int>();
System.Collections.Generic.Stack<int>.Enumerator
enumerator;
IDisposable disposable;
enumerator = stack.GetEnumerator();
try
{
int number;
while (enumerator.MoveNext())
{
number = enumerator.Current;
Console.WriteLine(number);
}
}
finally
{
// Explicit cast used for IEnumerator<T>.
disposable = (IDisposable) enumerator;
disposable.Dispose();
// IEnumerator will use the as operator unless IDisposable
// support is known at compile time.
// disposable = (enumerator as IDisposable);
// if (disposable != null)
// {
// disposable.Dispose();
// }
}
Observe que como a interface IDisposable é suportada por IEnumerator<T>, a instrução using pode simplificar o código na Figura 3 para o que é mostrado na Figura 4.
Figura 4 - Tratamento de Erro e Limpeza do Recurso com using
System.Collections.Generic.Stack<int> stack =
new System.Collections.Generic.Stack<int>();
int number;
using(
System.Collections.Generic.Stack<int>.Enumerator
enumerator = stack.GetEnumerator())
{
while (enumerator.MoveNext())
{
number = enumerator.Current;
Console.WriteLine(number);
}
}
Porém, lembre que CIL não suporta diretamente a palavra-chave using. Assim, o código na Figura 3 realmente é uma representação C# mais precisa do código CIL foreach.
foreach sem IEnumerable: C# não requer que IEnumerable/IEnumerable<T> seja implementado para iterar um tipo de dados usando foreach. Ao contrário, o compilador usa um conceito conhecido como tipagem pato. Ele procura um método GetEnumerator que retorna um tipo com uma propriedade Current e um método MoveNext. A tipagem pato envolve pesquisar pelo nome, ao invés de contar com uma interface ou chamada de método explícita para o método. (O nome “tipagem pato” vem da ideia estranha de que sendo tratado como um pato, o objeto deve apenas implementar um método Quack; ele não precisa de uma interface iPato.) Se a tipagem pato não conseguir encontrar uma implementação adequada do padrão enumerável, o compilador irá verificar se a coleção implementa as interfaces.
Apresentando os Iteradores
Agora que você entendeu as partes internas da implementação foreach, é hora de analisar como os iteradores são usados para criar implementações personalizadas de IEnumerator<T> e das interfaces IEnumerable<T> correspondentes e não genéricas para as coleções personalizadas. Os iteradores fornecem uma sintaxe clara para especificar como iterar os dados nas classes de coleção, especialmente usando o loop foreach, permitindo que os usuários finais de uma coleção naveguem sua estrutura interna sem conhecerem tal estrutura.
O problema com o padrão da enumeração é que ele pode ser complicado de implementar manualmente porque deve manter todo o estado necessário para descrever a posição atual na coleção. Esse estado interno deve ser simples para uma classe do tipo coleção de listas. O índice da posição atual é suficiente. Em oposição, para as estruturas de dados que requerem uma passagem recursiva, como as árvores binárias, o estado pode ser bem complicado. Para amenizar os desafios associados à implementação desse padrão, o C# 2.0 adicionou a palavra-chave contextual yield para facilitar que uma classe informe como o loop foreach iterage seu conteúdo.
Definir um Iterator:Iterators é um meio de implementar os métodos de uma classe e são atalhos sintáticos para um padrão de enumerador mais complexo. Quando o compilador C# encontra um iterador, ele expande seu conteúdo no código CIL que implementa o padrão do enumerador. Assim, não há dependências de tempo de execução para implementar os iteradores. Como o compilador C# lida com a implementação através da geração do código CIL, não há nenhuma vantagem real no desempenho da execução para usar os iteradores. Porém, há um substancial ganho de produtividade do programador ao escolher iteradores em comparação com a implementação manual do padrão do enumerador. Para entender essa melhoria, primeiro iremos considerar como um iterador é definido no código.
Sintaxe do Iterador: Um iterador fornece uma implementação de atalho das interfaces do iterador, a combinação de IEnumerable<T> e das interfaces IEnumerator<T>. A Figura 5 declara um iterador para o tipo BinaryTree<T> genérico criando um método GetEnumerator (ainda que sem nenhuma implementação).
Figura 5 - Padrão de Interfaces do Iterador
using System;
using System.Collections.Generic;
public class BinaryTree<T>:
IEnumerable<T>
{
public BinaryTree ( T value)
{
Value = value;
}
#region IEnumerable<T>
public IEnumerator<T> GetEnumerator()
{
// ...
}
#endregion IEnumerable<T>
public T Value { get; } // C# 6.0 Getter-only Autoproperty
public Pair<BinaryTree<T>> SubItems { get; set; }
}
public struct Pair<T>: IEnumerable<T>
{
public Pair(T first, T second) : this()
{
First = first;
Second = second;
}
public T First { get; }
public T Second { get; }
#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
// ...
}
Produzindo Valores a partir de um Iterador: As interfaces do iterador são como funções, mas ao invés de retornarem um único valor, produzem uma sequência de valores, um de cada vez. No caso de BinaryTree<T>, o iterador produz uma sequência de valores do tipo argumento fornecido para T. Se a versão não genérica de IEnumerator for usada, os valores produzidos serão do tipo objeto.
Para implementar corretamente o padrão do iterador, você precisa manter um estado interno para controlar onde você está enquanto enumera a coleção. No caso BinaryTree<T>, você controla quais elementos na árvore já foram enumerados e quais ainda estão por vir. Os iteradores são transformados pelo compilador em uma “máquina do estado” que controla a posição atual e sabe como “se mover” para a próxima posição.
A instrução yield return produz um valor sempre que um iterador a encontra e o controle retorna imediatamente para quem chamou e solicitou o item. Quando quem chama solicitar o próximo item, o código começará a ser executado imediatamente após a instrução yield return executada anteriormente. Na Figura 6, as palavras-chave do tipo de dados predefinidas em C# são retornadas em sequência.
Figura 6 - Produzindo Algumas Palavras-chave de C# em Sequência
using System;
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);
}
}
}
Os resultados da Figura 6 aparecem na Figura 7, que é uma listagem dos tipos predefinidos no C#.
Figura 7 - A Lista de Uma Saída de Palavras-chave do C# no Código da Figura 6
object
byte
uint
ulong
float
char
bool
ushort
decimal
int
sbyte
short
long
void
double
string
Certamente, é necessária mais explicação, mas não tenho espaço este mês, portanto, você ficará em suspense para ver outra coluna. É suficiente dizer que com os iteradores você pode criar coleções de forma mágica como propriedades, como mostrado na Figura 8 — neste caso, contando com as tuplas do C# 7.0 apenas por diversão. Para as pessoas que desejam antecipar as coisas, é possível verificar o código-fonte ou dar uma olhada no Capítulo 16 do meu livro “Essential C#”.
Figura 8 - Usando yield return para Implementar uma Propriedade IEnumerable<T>
IEnumerable<(string City, string Country)> CountryCapitals
{
get
{
yield return ("Abu Dhabi","United Arab Emirates");
yield return ("Abuja", "Nigeria");
yield return ("Accra", "Ghana");
yield return ("Adamstown", "Pitcairn");
yield return ("Addis Ababa", "Ethiopia");
yield return ("Algiers", "Algeria");
yield return ("Amman", "Jordan");
yield return ("Amsterdam", "Netherlands");
// ...
}
}
Conclusão
Nesta coluna, voltei para a funcionalidade que fazia parte do C# desde a versão 1.0 e ela não mudou muito desde a introdução dos genéricos no C# 2.0. Porém, apesar do uso frequente dessa funcionalidade, muitos não entendem os detalhes do que está acontecendo internamente. Então, mostrei superficialmente o padrão do iterador — aproveitando a construção yield return — e dei um exemplo.
Grande parte desta coluna foi retirada do meu livro “Essential C#” (IntelliTect.com/EssentialCSharp), que estou atualizando no momento para o “Essential C# 7.0”. Para obter mais informações, consulte os Capítulos 14 e 16.
Mark Michaelis é fundador da IntelliTect, onde atua como arquiteto técnico principal e instrutor. Há quase 20 anos trabalha como Microsoft MVP, e é Diretor Regional da Microsoft desde 2007. Michaelis atua em diversas equipes de análise de design de software da Microsoft, incluindo C#, Microsoft Azure, SharePoint e Visual Studio ALM. Ele dá palestras em conferências de desenvolvedores e escreveu diversos livros, incluindo o mais recente, “Essential C# 6.0 (5th Edition)” (itl.tc/EssentialCSharp). Você pode entrar em contato com ele pelo Facebook, em facebook.com/Mark.Michaelis, pelo seu blog IntelliTect.com/Mark, no Twitter: @markmichaelis ou pelo email mark@IntelliTect.com.
Agradecemos aos seguintes especialistas técnicos da IntelliTect pela revisão deste artigo: Kevin Bost, Grant Erickson, Chris Finlayson, Phil Spokas e Michael Stokesbary