Raggruppamento dei dati (C#)

Il raggruppamento consiste nell'inserire i dati in gruppi in modo che gli elementi in ogni gruppo condividano un attributo comune. Nella figura seguente vengono illustrati i risultati del raggruppamento di una sequenza di caratteri. La chiave di ogni gruppo è il carattere.

Diagramma che illustra un'operazione di raggruppamento LINQ

I metodi dell'operatore query standard che raggruppano gli elementi dati sono elencati nella tabella seguente.

Nome metodo Descrizione Sintassi di espressione della query C# Ulteriori informazioni
GroupBy Raggruppa gli elementi che condividono un attributo comune. Un oggetto IGrouping<TKey,TElement> rappresenta ciascun gruppo. group … by

oppure

group … by … into …
Enumerable.GroupBy

Queryable.GroupBy
ToLookup Inserisce gli elementi in un oggetto Lookup<TKey,TElement>, un dizionario uno-a-molti, sulla base di una funzione del selettore di chiavi. Non applicabile. Enumerable.ToLookup

Il seguente esempio di codice usa la clausola group by per raggruppare i numeri interi in un elenco a seconda che siano pari o dispari.

List<int> numbers = [35, 44, 200, 84, 3987, 4, 199, 329, 446, 208];

IEnumerable<IGrouping<int, int>> query = from number in numbers
                                         group number by number % 2;

foreach (var group in query)
{
    Console.WriteLine(group.Key == 0 ? "\nEven numbers:" : "\nOdd numbers:");
    foreach (int i in group)
    {
        Console.WriteLine(i);
    }
}

La query equivalente che usa la sintassi del metodo è mostrata nel codice seguente:

List<int> numbers = [35, 44, 200, 84, 3987, 4, 199, 329, 446, 208];

IEnumerable<IGrouping<int, int>> query = numbers
    .GroupBy(number => number % 2);

foreach (var group in query)
{
    Console.WriteLine(group.Key == 0 ? "\nEven numbers:" : "\nOdd numbers:");
    foreach (int i in group)
    {
        Console.WriteLine(i);
    }
}

Gli esempi che seguono in questo articolo usano le origini dati comuni per questa area:

public enum GradeLevel
{
    FirstYear = 1,
    SecondYear,
    ThirdYear,
    FourthYear
};

public class Student
{
    public required string FirstName { get; init; }
    public required string LastName { get; init; }
    public required int ID { get; init; }

    public required GradeLevel Year { get; init; }
    public required List<int> Scores { get; init; }

    public required int DepartmentID { get; init; }
}

public class Teacher
{
    public required string First { get; init; }
    public required string Last { get; init; }
    public required int ID { get; init; }
    public required string City { get; init; }
}
public class Department
{
    public required string Name { get; init; }
    public int ID { get; init; }

    public required int TeacherID { get; init; }
}

Ogni Student ha un livello di classe, un reparto primario e una serie di punteggi. Un Teacher ha anche una proprietà City che identifica il campus in cui l'insegnante tiene le lezioni. Un Department ha un nome e un riferimento a un Teacher che funge da responsabile del reparto.

Raggruppare i risultati di una query

Il raggruppamento è una delle funzionalità più efficace di LINQ. Negli esempi seguenti viene illustrato come raggruppare i dati in vari modi:

  • In base a una singola proprietà.
  • In base alla prima lettera di una proprietà stringa.
  • In base a un intervallo numerico calcolato.
  • In base a un predicato booleano o un'altra espressione.
  • In base a una chiave composta.

Inoltre, le ultime due query proiettano i risultati in un nuovo tipo anonimo che contiene solo il nome e il cognome dello studente. Per altre informazioni, vedere Clausola group.

Esempio di raggruppamento per singola proprietà

Nell'esempio seguente viene illustrato come raggruppare gli elementi di origine usando una sola proprietà dell'elemento come chiave di gruppo. La chiave è un enum, l'anno scolastico dello studente. L'operazione di raggruppamento usa l'operatore di confronto di uguaglianza predefinito per il tipo.

var groupByYearQuery =
    from student in students
    group student by student.Year into newGroup
    orderby newGroup.Key
    select newGroup;

foreach (var yearGroup in groupByYearQuery)
{
    Console.WriteLine($"Key: {yearGroup.Key}");
    foreach (var student in yearGroup)
    {
        Console.WriteLine($"\t{student.LastName}, {student.FirstName}");
    }
}

Il codice equivalente che usa la sintassi del metodo è mostrato nell'esempio seguente:

// Variable groupByLastNamesQuery is an IEnumerable<IGrouping<string,
// DataClass.Student>>.
var groupByYearQuery = students
    .GroupBy(student => student.Year)
    .OrderBy(newGroup => newGroup.Key);

foreach (var yearGroup in groupByYearQuery)
{
    Console.WriteLine($"Key: {yearGroup.Key}");
    foreach (var student in yearGroup)
    {
        Console.WriteLine($"\t{student.LastName}, {student.FirstName}");
    }
}

Esempio di raggruppamento per valore

Nell'esempio seguente viene illustrato come raggruppare gli elementi di origine usando un elemento diverso da una proprietà dell'oggetto per la chiave di gruppo. In questo esempio la chiave è la prima lettera del cognome dello studente.

var groupByFirstLetterQuery =
    from student in students
    let firstLetter = student.LastName[0]
    group student by firstLetter;

foreach (var studentGroup in groupByFirstLetterQuery)
{
    Console.WriteLine($"Key: {studentGroup.Key}");
    foreach (var student in studentGroup)
    {
        Console.WriteLine($"\t{student.LastName}, {student.FirstName}");
    }
}

Il foreach annidato è necessario per accedere agli elementi del gruppo.

Il codice equivalente che usa la sintassi del metodo è mostrato nell'esempio seguente:

var groupByFirstLetterQuery = students
    .GroupBy(student => student.LastName[0]);

foreach (var studentGroup in groupByFirstLetterQuery)
{
    Console.WriteLine($"Key: {studentGroup.Key}");
    foreach (var student in studentGroup)
    {
        Console.WriteLine($"\t{student.LastName}, {student.FirstName}");
    }
}

Esempio di raggruppamento per un intervallo

Nell'esempio seguente viene illustrato come raggruppare gli elementi di origine usando un intervallo numerico come chiave di gruppo. La query proietta quindi i risultati in un tipo anonimo che contiene solo il nome e il cognome e l'intervallo percentile a cui appartiene lo studente. Viene usato un tipo anonimo poiché non è necessario usare l'oggetto Student completo per visualizzare i risultati. GetPercentile è una funzione di supporto che consente di calcolare un percentile in base al voto medio dello studente. Il metodo restituisce un numero intero compreso tra 0 e 10.

static int GetPercentile(Student s)
{
    double avg = s.Scores.Average();
    return avg > 0 ? (int)avg / 10 : 0;
}

var groupByPercentileQuery =
    from student in students
    let percentile = GetPercentile(student)
    group new
    {
        student.FirstName,
        student.LastName
    } by percentile into percentGroup
    orderby percentGroup.Key
    select percentGroup;

foreach (var studentGroup in groupByPercentileQuery)
{
    Console.WriteLine($"Key: {studentGroup.Key * 10}");
    foreach (var item in studentGroup)
    {
        Console.WriteLine($"\t{item.LastName}, {item.FirstName}");
    }
}

Il foreach annidato è necessario per eseguire l'iterazione su gruppi ed elementi del gruppo. Il codice equivalente che usa la sintassi del metodo è mostrato nell'esempio seguente:

static int GetPercentile(Student s)
{
    double avg = s.Scores.Average();
    return avg > 0 ? (int)avg / 10 : 0;
}

var groupByPercentileQuery = students
    .Select(student => new { student, percentile = GetPercentile(student) })
    .GroupBy(student => student.percentile)
    .Select(percentGroup => new
    {
        percentGroup.Key,
        Students = percentGroup.Select(s => new { s.student.FirstName, s.student.LastName })
    })
    .OrderBy(percentGroup => percentGroup.Key);

foreach (var studentGroup in groupByPercentileQuery)
{
    Console.WriteLine($"Key: {studentGroup.Key * 10}");
    foreach (var item in studentGroup.Students)
    {
        Console.WriteLine($"\t{item.LastName}, {item.FirstName}");
    }
}

Esempio di raggruppamento per confronto

Nell'esempio seguente viene illustrato come raggruppare elementi di origine usando un'espressione di confronto booleana. In questo esempio l'espressione booleana consente di verificare se il voto medio degli esami di uno studente è maggiore di 75. Come negli esempi precedenti, i risultati vengono proiettati in un tipo anonimo poiché l'elemento di origine completo non è necessario. Le proprietà nel tipo anonimo diventano proprietà sul membro Key.

var groupByHighAverageQuery =
    from student in students
    group new
    {
        student.FirstName,
        student.LastName
    } by student.Scores.Average() > 75 into studentGroup
    select studentGroup;

foreach (var studentGroup in groupByHighAverageQuery)
{
    Console.WriteLine($"Key: {studentGroup.Key}");
    foreach (var student in studentGroup)
    {
        Console.WriteLine($"\t{student.FirstName} {student.LastName}");
    }
}

La query equivalente che usa la sintassi del metodo è mostrata nel codice seguente:

var groupByHighAverageQuery = students
    .GroupBy(student => student.Scores.Average() > 75)
    .Select(group => new
    {
        group.Key,
        Students = group.AsEnumerable().Select(s => new { s.FirstName, s.LastName })
    });

foreach (var studentGroup in groupByHighAverageQuery)
{
    Console.WriteLine($"Key: {studentGroup.Key}");
    foreach (var student in studentGroup.Students)
    {
        Console.WriteLine($"\t{student.FirstName} {student.LastName}");
    }
}

Raggruppare per tipo anonimo

Nell'esempio seguente viene illustrato come usare un tipo anonimo per incapsulare una chiave che contiene più valori. In questo esempio il primo valore della chiave è la prima lettera del cognome dello studente. Il secondo valore della chiave è un valore booleano che specifica se lo studente ha ottenuto un voto superiore a 85 nel primo esame. È possibile ordinare i gruppi in base a qualsiasi proprietà nella chiave.

var groupByCompoundKey =
    from student in students
    group student by new
    {
        FirstLetterOfLastName = student.LastName[0],
        IsScoreOver85 = student.Scores[0] > 85
    } into studentGroup
    orderby studentGroup.Key.FirstLetterOfLastName
    select studentGroup;

foreach (var scoreGroup in groupByCompoundKey)
{
    var s = scoreGroup.Key.IsScoreOver85 ? "more than 85" : "less than 85";
    Console.WriteLine($"Name starts with {scoreGroup.Key.FirstLetterOfLastName} who scored {s}");
    foreach (var item in scoreGroup)
    {
        Console.WriteLine($"\t{item.FirstName} {item.LastName}");
    }
}

La query equivalente che usa la sintassi del metodo è mostrata nel codice seguente:

var groupByCompoundKey = students
    .GroupBy(student => new
    {
        FirstLetterOfLastName = student.LastName[0],
        IsScoreOver85 = student.Scores[0] > 85
    })
    .OrderBy(studentGroup => studentGroup.Key.FirstLetterOfLastName);

foreach (var scoreGroup in groupByCompoundKey)
{
    var s = scoreGroup.Key.IsScoreOver85 ? "more than 85" : "less than 85";
    Console.WriteLine($"Name starts with {scoreGroup.Key.FirstLetterOfLastName} who scored {s}");
    foreach (var item in scoreGroup)
    {
        Console.WriteLine($"\t{item.FirstName} {item.LastName}");
    }
}

Creare un gruppo annidato

Nell'esempio seguente viene illustrato come creare gruppi annidati in un'espressione di query LINQ. Ogni gruppo creato in base all'anno o al livello degli studenti viene ulteriormente suddiviso in gruppi in base ai nomi delle persone.

var nestedGroupsQuery =
    from student in students
    group student by student.Year into newGroup1
    from newGroup2 in
    from student in newGroup1
    group student by student.LastName
    group newGroup2 by newGroup1.Key;

foreach (var outerGroup in nestedGroupsQuery)
{
    Console.WriteLine($"DataClass.Student Level = {outerGroup.Key}");
    foreach (var innerGroup in outerGroup)
    {
        Console.WriteLine($"\tNames that begin with: {innerGroup.Key}");
        foreach (var innerGroupElement in innerGroup)
        {
            Console.WriteLine($"\t\t{innerGroupElement.LastName} {innerGroupElement.FirstName}");
        }
    }
}

Sono necessari tre cicli annidati foreach per iterare sugli elementi interni di un gruppo annidato.
(Passare il cursore del mouse sulle variabili di iterazione outerGroup, innerGroup e innerGroupElement per vedere il loro tipo effettivo.)

La query equivalente che usa la sintassi del metodo è mostrata nel codice seguente:

var nestedGroupsQuery =
    students
    .GroupBy(student => student.Year)
    .Select(newGroup1 => new
    {
        newGroup1.Key,
        NestedGroup = newGroup1
            .GroupBy(student => student.LastName)
    });

foreach (var outerGroup in nestedGroupsQuery)
{
    Console.WriteLine($"DataClass.Student Level = {outerGroup.Key}");
    foreach (var innerGroup in outerGroup.NestedGroup)
    {
        Console.WriteLine($"\tNames that begin with: {innerGroup.Key}");
        foreach (var innerGroupElement in innerGroup)
        {
            Console.WriteLine($"\t\t{innerGroupElement.LastName} {innerGroupElement.FirstName}");
        }
    }
}

Eseguire una sottoquery su un'operazione di raggruppamento

Questo articolo descrive due diversi modi per creare una query che ordina i dati di origine in gruppi e quindi esegue una sottoquery separatamente su ogni gruppo. La tecnica di base in ogni esempio consiste nel raggruppare gli elementi di origine usando una continuazione denominata newGroup e quindi generando una nuova sottoquery su newGroup. Questa sottoquery viene eseguita su ogni nuovo gruppo creato dalla query esterna. In questo particolare esempio l'output finale non è un gruppo, ma una sequenza piatta di tipi anonimi.

Per altre informazioni sul raggruppamento, vedere Clausola group. Per altre informazioni sulle continuazioni, vedere into. L'esempio seguente usa una struttura dati in memoria come origine dati, ma gli stessi principi si applicano a qualsiasi tipo di origine dati LINQ.

var queryGroupMax =
    from student in students
    group student by student.Year into studentGroup
    select new
    {
        Level = studentGroup.Key,
        HighestScore = (
            from student2 in studentGroup
            select student2.Scores.Average()
        ).Max()
    };

var count = queryGroupMax.Count();
Console.WriteLine($"Number of groups = {count}");

foreach (var item in queryGroupMax)
{
    Console.WriteLine($"  {item.Level} Highest Score={item.HighestScore}");
}

La query nel frammento precedente può essere scritta anche usando la sintassi del metodo. Il frammento di codice seguente è una query semanticamente equivalente scritta usando la sintassi del metodo.

var queryGroupMax =
    students
        .GroupBy(student => student.Year)
        .Select(studentGroup => new
        {
            Level = studentGroup.Key,
            HighestScore = studentGroup.Max(student2 => student2.Scores.Average())
        });

var count = queryGroupMax.Count();
Console.WriteLine($"Number of groups = {count}");

foreach (var item in queryGroupMax)
{
    Console.WriteLine($"  {item.Level} Highest Score={item.HighestScore}");
}

Vedi anche