Dela via


Iteratorer

Nästan alla program som du skriver kommer att behöva iterera över en samling. Du skriver kod som undersöker varje objekt i en samling.

Du skapar också iteratormetoder, som är metoder som skapar en iterator för elementen i den klassen. En iterator är ett objekt som passerar en container, särskilt listor. Iteratorer kan användas för:

  • Utför en åtgärd på varje objekt i en samling.
  • Räkna upp en anpassad samling.
  • Utöka LINQ eller andra bibliotek.
  • Skapa en datapipeline där data flödar effektivt via iteratormetoder.

C#-språket innehåller funktioner för både generering och användning av sekvenser. Dessa sekvenser kan skapas och användas synkront eller asynkront. Den här artikeln innehåller en översikt över dessa funktioner.

Iterera med foreach

Det är enkelt att räkna upp en samling: Nyckelordet foreach räknar upp en samling och kör den inbäddade instruktionen en gång för varje element i samlingen:

foreach (var item in collection)
{
    Console.WriteLine(item?.ToString());
}

Det är allt. För att iterera över allt innehåll i en samling är -instruktionen foreach allt du behöver. Men foreach uttalandet är inte magiskt. Det förlitar sig på två generiska gränssnitt som definierats i .NET Core-biblioteket för att generera den kod som krävs för att iterera en samling: IEnumerable<T> och IEnumerator<T>. Den här mekanismen beskrivs mer detaljerat nedan.

Båda dessa gränssnitt har även icke-generiska motsvarigheter: IEnumerable och IEnumerator. De generiska versionerna är att föredra för modern kod.

När en sekvens genereras asynkront kan du använda -instruktionen await foreach för att asynkront använda sekvensen:

await foreach (var item in asyncSequence)
{
Console.WriteLine(item?.ToString());
}

När en sekvens är en System.Collections.Generic.IEnumerable<T>använder foreachdu . När en sekvens är en System.Collections.Generic.IAsyncEnumerable<T>använder await foreachdu . I det senare fallet genereras sekvensen asynkront.

Uppräkningskällor med iteratormetoder

En annan bra funktion i C#-språket gör att du kan skapa metoder som skapar en källa för en uppräkning. Dessa metoder kallas iteratormetoder. En iteratormetod definierar hur du genererar objekten i en sekvens när det begärs. Du använder kontextuella yield return nyckelord för att definiera en iteratormetod.

Du kan skriva den här metoden för att skapa sekvensen med heltal från 0 till 9:

public IEnumerable<int> GetSingleDigitNumbers()
{
    yield return 0;
    yield return 1;
    yield return 2;
    yield return 3;
    yield return 4;
    yield return 5;
    yield return 6;
    yield return 7;
    yield return 8;
    yield return 9;
}

Koden ovan visar distinkta yield return instruktioner för att markera det faktum att du kan använda flera diskreta yield return instruktioner i en iteratormetod. Du kan (och ofta gör det) använda andra språkkonstruktioner för att förenkla koden för en iteratormetod. Metoddefinitionen nedan ger exakt samma sekvens med tal:

public IEnumerable<int> GetSingleDigitNumbersLoop()
{
    int index = 0;
    while (index < 10)
        yield return index++;
}

Du behöver inte bestämma det ena eller det andra. Du kan ha så många yield return instruktioner som behövs för att uppfylla behoven för din metod:

public IEnumerable<int> GetSetsOfNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    index = 100;
    while (index < 110)
        yield return index++;
}

Alla dessa föregående exempel skulle ha en asynkron motsvarighet. I varje fall ersätter du returtypen IEnumerable<T> med en IAsyncEnumerable<T>. Det föregående exemplet skulle till exempel ha följande asynkrona version:

public async IAsyncEnumerable<int> GetSetsOfNumbersAsync()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    await Task.Delay(500);

    yield return 50;

    await Task.Delay(500);

    index = 100;
    while (index < 110)
        yield return index++;
}

Det är syntaxen för både synkrona och asynkrona iteratorer. Låt oss överväga ett verkligt exempel. Anta att du är i ett IoT-projekt och att enhetssensorerna genererar en mycket stor dataström. För att få en känsla för data kan du skriva en metod som tar exempel på varje Nth-dataelement. Den här lilla iteratormetoden gör susen:

public static IEnumerable<T> Sample<T>(this IEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

Om läsning från IoT-enheten ger en asynkron sekvens ändrar du metoden enligt följande metod:

public static async IAsyncEnumerable<T> Sample<T>(this IAsyncEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    await foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

Det finns en viktig begränsning för iteratormetoder: du kan inte ha både en return -instruktion och en yield return -instruktion i samma metod. Följande kod kompileras inte:

public IEnumerable<int> GetSingleDigitNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    // generates a compile time error:
    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    return items;
}

Den här begränsningen är normalt inte ett problem. Du kan välja att antingen använda yield return i hela metoden eller att separera den ursprungliga metoden i flera metoder, vissa med hjälp av return, och vissa med hjälp av yield return.

Du kan ändra den sista metoden något så att den används yield return överallt:

public IEnumerable<int> GetFirstDecile()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    foreach (var item in items)
        yield return item;
}

Ibland är rätt svar att dela upp en iteratormetod i två olika metoder. En som använder return, och en sekund som använder yield return. Överväg en situation där du kanske vill returnera en tom samling, eller de första fem udda talen, baserat på ett booleskt argument. Du kan skriva det som dessa två metoder:

public IEnumerable<int> GetSingleDigitOddNumbers(bool getCollection)
{
    if (getCollection == false)
        return new int[0];
    else
        return IteratorMethod();
}

private IEnumerable<int> IteratorMethod()
{
    int index = 0;
    while (index < 10)
    {
        if (index % 2 == 1)
            yield return index;
        index++;
    }
}

Titta på metoderna ovan. Den första använder standard-instruktionen return för att returnera antingen en tom samling eller iteratorn som skapades av den andra metoden. Den andra metoden använder -instruktionen yield return för att skapa den begärda sekvensen.

Fördjupa dig i foreach

-instruktionen foreach expanderas till ett standard-idiom som använder gränssnitten IEnumerable<T> och IEnumerator<T> för att iterera över alla element i en samling. Det minimerar också fel som utvecklare gör genom att inte hantera resurser korrekt.

Kompilatorn översätter loopen foreach som visas i det första exemplet till något som liknar den här konstruktionen:

IEnumerator<int> enumerator = collection.GetEnumerator();
while (enumerator.MoveNext())
{
    var item = enumerator.Current;
    Console.WriteLine(item.ToString());
}

Den exakta koden som genereras av kompilatorn är mer komplicerad och hanterar situationer där objektet som returneras av GetEnumerator() implementerar IDisposable gränssnittet. Den fullständiga expansionen genererar kod mer så här:

{
    var enumerator = collection.GetEnumerator();
    try
    {
        while (enumerator.MoveNext())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of enumerator.
    }
}

Kompilatorn översätter det första asynkrona exemplet till något som liknar den här konstruktionen:

{
    var enumerator = collection.GetAsyncEnumerator();
    try
    {
        while (await enumerator.MoveNextAsync())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of async enumerator.
    }
}

Hur uppräknaren tas bort beror på egenskaperna för typen av enumerator. I det allmänna synkrona fallet finally utökas satsen till:

finally
{
   (enumerator as IDisposable)?.Dispose();
}

Det allmänna asynkrona fallet expanderas till:

finally
{
    if (enumerator is IAsyncDisposable asyncDisposable)
        await asyncDisposable.DisposeAsync();
}

Men om typen av enumerator är en förseglad typ och det inte finns någon implicit konvertering från typen av enumerator till IDisposable eller IAsyncDisposableexpanderas finally satsen till ett tomt block:

finally
{
}

Om det finns en implicit konvertering från typen till enumeratorIDisposable, och enumerator är en värdetyp som inte kan nulleras, finally expanderas satsen till:

finally
{
   ((IDisposable)enumerator).Dispose();
}

Tack och lov behöver du inte komma ihåg alla dessa detaljer. -instruktionen foreach hanterar alla dessa nyanser åt dig. Kompilatorn genererar rätt kod för någon av dessa konstruktioner.