2009-05-23

Grupowanie w LINQ

Przykładowy kod dokonujący grupowania w LINQ:
class TestObj
{
    private static System.Random s_random = new System.Random();

    public int Value1 = s_random.Next(5);
    public int Value2 = s_random.Next(5);
    public int Value3 = s_random.Next(5);

    public void Operation()
    {
        System.Console.WriteLine("v1: {0}, v2: {1}, v3: {2}", Value1, Value2, Value3);
    }
}

public void Test()
{
    var obj_list = from el in Enumerable.Range(0, 10)
                   select new TestObj();

    var grouped = from obj in obj_list
                  group obj by obj.Value1;

    grouped.ForEach(g =>
    {
        System.Console.WriteLine("Grupa:");
        g.ForEach(obj => obj.Operation());
    });
}
Przykładowy wynik: Grupa: v1: 4, v2: 4, v3: 2 v1: 4, v2: 1, v3: 1 v1: 4, v2: 4, v3: 1 Grupa: v1: 2, v2: 0, v3: 3 v1: 2, v2: 4, v3: 0 v1: 2, v2: 3, v3: 0 Grupa: v1: 1, v2: 1, v3: 0 v1: 1, v2: 2, v3: 3 Grupa: v1: 0, v2: 1, v3: 3 v1: 0, v2: 3, v3: 1 Pytanie brzmi jak pogrupować najpierw po Value1, a później po Value2. Możemy to zrobić sposób bardziej skomplikowany:
public void Test()
{
    var obj_list = from el in Enumerable.Range(0, 10)
                   select new TestObj();

    var grouped = from gr1 in
                      (from obj1 in obj_list
                      group obj1 by obj1.Value1 into g1
                      select from obj2 in g1
                             group obj2 by obj2.Value2)
                  from gr2 in gr1
                  select gr2;

    grouped.ForEach(g =>
    {
        System.Console.WriteLine("Grupa:");
        g.ForEach(obj => obj.Operation());
    });
}
Albo prostszy:
public void Test()
{
    var obj_list = from el in Enumerable.Range(0, 10)
                   select new TestObj();

    var grouped = from obj in obj_list
                  group obj by new { obj.Value1, obj.Value2 };

    grouped.ForEach(g =>
    {
        System.Console.WriteLine("Grupa:");
        g.ForEach(o => o.Operation());
    });
}
Przykładowe wyniki: Grupa: v1: 1, v2: 2, v3: 3 Grupa: v1: 2, v2: 3, v3: 3 Grupa: v1: 3, v2: 1, v3: 3 Grupa: v1: 0, v2: 1, v3: 0 Grupa: v1: 1, v2: 3, v3: 4 v1: 1, v2: 3, v3: 1 v1: 1, v2: 3, v3: 4 Grupa: v1: 2, v2: 2, v3: 4 Grupa: v1: 4, v2: 4, v3: 1 Grupa: v1: 2, v2: 0, v3: 1 Takie coś też jest możliwe:
public void Test()public void Test()
{
   var obj_list = from el in Enumerable.Range(0, 10)
                  select new TestObj();

   var grouped = from obj in obj_list
                 group obj by new { obj.Value1, v = obj.Value2 + obj.Value3};

   grouped.ForEach(g =>
   {
       System.Console.WriteLine("Grupa:");
       g.ForEach(o => o.Operation());
   });
}
Przykładowe wyniki: Grupa: v1: 4, v2: 2, v3: 4 v1: 4, v2: 4, v3: 2 Grupa: v1: 2, v2: 0, v3: 3 Grupa: v1: 1, v2: 2, v3: 1 Grupa: v1: 4, v2: 0, v3: 1 Grupa: v1: 4, v2: 4, v3: 4 Grupa: v1: 2, v2: 1, v3: 1 Grupa: v1: 0, v2: 4, v3: 3 Grupa: v1: 2, v2: 2, v3: 3 Grupa: v1: 0, v2: 3, v3: 0 Wygląda na to, że w w części group możemy dodać co nam się żywnie podoba. Np. takie coś kompiluje się poprawnie, ale nie daje prawidłowych wyników grupowania:
class TestObj
{
   private static System.Random s_random = new System.Random();

   public int Value1 = s_random.Next(5);
   public int Value2 = s_random.Next(5);
   public int Value3 = s_random.Next(5);

   public void Operation()
   {
       System.Console.WriteLine("v1: {0}, v2: {1}, v3: {2}", Value1, Value2, Value3);
   }
}

public class T
{
   private static int counter = 0;

   public T()
   {
       counter++;
       System.Console.WriteLine(counter);
   }

   public int v1;
   public int v23;
}

public void Test()
{
   var obj_list = from el in Enumerable.Range(0, 10)
                  select new TestObj();

   var grouped = from obj in obj_list
                 group obj by new T() { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 };

   grouped.ForEach(g =>
   {
       System.Console.WriteLine("Grupa \t\t\t\t\t v1={0}, v2={1}", g.Key.v1, g.Key.v23);
       g.ForEach(o => o.Operation());
   });
}
Przykładowy wynik: 1 2 3 4 5 6 7 8 9 10 Grupa v1=0, v2=5 v1: 0, v2: 2, v3: 3 Grupa v1=1, v2=3 v1: 1, v2: 2, v3: 1 Grupa v1=3, v2=2 v1: 3, v2: 1, v3: 1 Grupa v1=4, v2=4 v1: 4, v2: 1, v3: 3 Grupa v1=0, v2=6 v1: 0, v2: 2, v3: 4 Grupa v1=0, v2=4 v1: 0, v2: 4, v3: 0 Grupa v1=1, v2=2 v1: 1, v2: 0, v3: 2 Grupa v1=0, v2=1 v1: 0, v2: 0, v3: 1 Grupa v1=4, v2=5 v1: 4, v2: 2, v3: 3 Grupa v1=4, v2=4 v1: 4, v2: 2, v3: 2 Jak widać grupowanie nie działa. A przy okazji widać, że grupowanie dla każdego obiektu tworzy obiekt po którym grupuje. Jest on później dołączany do wyniku jako klucz. Pytanie brzmi dlaczego w tym przypadku grupowanie nie powiodło się. W tym celu musimy posłużyć się programem Reflector. Preparujemy sobie tego rodzaju kod:
class TestObj
{
    private static System.Random s_random = new System.Random();

    public int Value1 = s_random.Next(5);
    public int Value2 = s_random.Next(5);
    public int Value3 = s_random.Next(5);

    public void Operation()
    {
        System.Console.WriteLine("v1: {0}, v2: {1}, v3: {2}", Value1, Value2, Value3);
    }
}

public class KeyT
{
    public int v1;
    public int v23;
}

public void Test()
{
   List obj_list = (from el in Enumerable.Range(0, 10)
                            select new TestObj()).ToList();

   var grouped1 = from obj in obj_list
                  group obj by new KeyT() { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 };

   var grouped2 = from obj in obj_list
                  group obj by new { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 };

   grouped1.ForEach(g =>
   {
       System.Console.WriteLine("Grupa \t\t\t\t\t v1={0}, v2={1}", g.Key.v1, g.Key.v23);
       g.ForEach(o => o.Operation());
   });

   System.Console.WriteLine("-------------------------------------------");

   grouped2.ForEach(g =>
   {
       System.Console.WriteLine("Grupa \t\t\t\t\t v1={0}, v2={1}", g.Key.v1, g.Key.v23);
       g.ForEach(o => o.Operation());
   });
}
Kompilujemy przykład i otwieramy w Reflectorze. I... naprawdę ciężko się połapać. Po ustawieniu w opcjach platformy na .NET 2.0 i wybrania jako języka dekompilacji C# otrzymujemy dla metody Test():
public void Test()
{
    IEnumerable<TestObj> obj_list = Enumerable.Range(0, 10).Select<int, TestObj>(delegate (int el) {
        return new TestObj();
    });
    IEnumerable<IGrouping<KeyT, TestObj>> grouped1 = obj_list.GroupBy<TestObj, KeyT>(delegate (TestObj obj) {
        return new KeyT { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 };
    });
    var grouped2 = obj_list.GroupBy(delegate (TestObj obj) {
        return new { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 };
    });
    grouped1.ForEach<IGrouping<KeyT, TestObj>>(delegate (IGrouping<KeyT, TestObj> g) {
        Console.WriteLine("Grupa \t\t\t\t\t v1={0}, v2={1}", g.Key.v1, g.Key.v23);
        g.ForEach<TestObj>(delegate (TestObj o) {
            o.Operation();
        });
    });
    Console.WriteLine("-------------------------------------------");
    grouped2.ForEach(delegate (IGrouping<<>f__AnonymousType0<int, int>, TestObj> g) {
        Console.WriteLine("Grupa \t\t\t\t\t v1={0}, v2={1}", g.Key.v1, g.Key.v23);
        g.ForEach<TestObj>(delegate (TestObj o) {
            o.Operation();
        });
    });
}
Gdybyśmy wybrali platformę .NET 3.5 to niezależnie czy w źródłach używamy LINQ, czy zapisu takiego jak w dekompilacji Reflector zawsze pokaże nam zapis LINQ. Reflector nie tylko może zdekompilować nasze źródła, ale także te Microsoftowe. Czas dowiedzieć się co się tak naprawdę dzieje w metodzie GroupBy. Choć mamy dwa podejścia do grupowania, dowiedzenie się o co chodzi z Reflectora jest prawie nie możliwe. Tak naprawdę kod wykonujący oba grupowania jest taki sam. Różnią się tylko klucze grupowania. Po przeanalizowaniu źródeł w Reflectorze, pierwsze czego spróbowałem to dostarczenie własnego obiektu implementującego IEqualityComparer.
class TestObj
{
   private static System.Random s_random = new System.Random();

   public int Value1 = s_random.Next(5);
   public int Value2 = s_random.Next(5);
   public int Value3 = s_random.Next(5);

   public void Operation()
   {
       System.Console.WriteLine("v1: {0}, v2: {1}, v3: {2}", Value1, Value2, Value3);
   }
}

public class KeyT
{
   public int v1;
   public int v23;
}

public class KeyComparer : IEqualityComparer<KeyT>
{
   public bool Equals(KeyT x, KeyT y)
   {
       if (x == null)
           return false;
       if (y == null)
           return false;
       return (x.v1 == y.v1) && (x.v23 == y.v23);
   }

   public int GetHashCode(KeyT obj)
   {
       return obj.v1.GetHashCode() + obj.v23.GetHashCode();
   }
}

public void Test()
{
   List obj_list = (from el in Enumerable.Range(0, 10)
                             select new TestObj()).ToList();

   //var grouped1 = from obj in obj_list
   //               group obj by new T() { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 };

   var grouped1 = obj_list.GroupBy(obj => new KeyT() { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 }, new KeyComparer());

   var grouped2 = from obj in obj_list
                  group obj by new { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 };

   grouped1.ForEach(g =>
   {
       System.Console.WriteLine("Grupa \t\t\t\t\t v1={0}, v2={1}", g.Key.v1, g.Key.v23);
       g.ForEach(o => o.Operation());
   });

   System.Console.WriteLine("-------------------------------------------");

   grouped2.ForEach(g =>
   {
       System.Console.WriteLine("Grupa \t\t\t\t\t v1={0}, v2={1}", g.Key.v1, g.Key.v23);
       g.ForEach(o => o.Operation());
   });
}
Oba podejścia zaczęły zwracać takie same wyniki. Różnica pomiędzy nimi rozstrzyga się tutaj:
private Lookup(IEqualityComparer<TKey> comparer)
{
   if (comparer == null)
   {
       comparer = EqualityComparer<TKey>.Default;
   }
   this.comparer = comparer;
   this.groupings = new Grouping<TKey, TElement>[7];
}
Dostarczając do GroupBy komparator stworzony jak wyżej dostaniemy także błędy w grupowaniu. Nie dostarczając komparatora, ale definiując obiekt klucza jako:
public class KeyT
{
    public int v1;
    public int v23;

    public virtual int GetHashCode()
    {
        return v1.GetHashCode() + v23.GetHashCode();
    }

    public virtual bool Equals(object obj)
    {
        if (obj == this)
            return true;

        if (obj == null)
            return false;

        KeyT t = obj as KeyT;

        if (t == null)
            return false;

        return (t.v1 == v1) && (t.v23 == v23);
    }
}
Także wygenerujemy błędne wyniki. Wygląda na to, że jeśli klucz nie jest obiektem anonimowym to komparator musi być dostarczony. Zgodnie z działaniem tworzonego w metodzie Lookup komparatora wywołuje on właśnie metody GetHashCode i Equals klasy KeyT. Mimo to ustawiając na nich breakpointy, okazuje się, że nie są one wywoływane. Oto ostateczny przykład:
class TestObj
{
    private static System.Random s_random = new System.Random();

    public int Value1 = s_random.Next(2);
    public int Value2 = s_random.Next(2);
    public int Value3 = s_random.Next(2);

    public void Operation()
    {
        System.Console.WriteLine("v1: {0}, v2: {1}, v3: {2}", Value1, Value2, Value3);
    }
}

public class KeyT
{
    public int v1;
    public int v23;

    public virtual int GetHashCode()
    {
        return v1.GetHashCode() + v23.GetHashCode();
    }

    public virtual bool Equals(object obj)
    {
        if (obj == this)
            return true;

        if (obj == null)
            return false;

        KeyT t = obj as KeyT;

        if (t == null)
            return false;

        return (t.v1 == v1) && (t.v23 == v23);
    }
}

[Serializable]
public class ObjectEqualityComparer_<T> : EqualityComparer<T>
{
    // Methods
    public override bool Equals(object obj)
    {
        ObjectEqualityComparer_<T> comparer = obj as ObjectEqualityComparer_<T>;
        return (comparer != null);
    }

    public override bool Equals(T x, T y)
    {
        if (x != null)
        {
            return ((y != null) && x.Equals(y));
        }
        if (y != null)
        {
            return false;
        }
        return true;
    }

    public override int GetHashCode()
    {
        return base.GetType().Name.GetHashCode();
    }

    public override int GetHashCode(T obj)
    {
        if (obj == null)
        {
            return 0;
        }
        return obj.GetHashCode();
    }
}

[Serializable]
public class ObjectEqualityComparer__ : EqualityComparer<KeyT>
{
    public override bool Equals(object obj)
    {
        ObjectEqualityComparer__ comparer = obj as ObjectEqualityComparer__;
        return (comparer != null);
    }

    public override bool Equals(KeyT x, KeyT y)
    {
        if (x != null)
        {
            return ((y != null) && x.Equals(y));
        }
        if (y != null)
        {
            return false;
        }
        return true;
    }

    public override int GetHashCode()
    {
        return base.GetType().Name.GetHashCode();
    }

    public override int GetHashCode(KeyT obj)
    {
        if (obj == null)
        {
            return 0;
        }
        return obj.GetHashCode();
    }
}

public void Test()
{
    List<TestObj> obj_list = (from el in Enumerable.Range(0, 10)
                              select new TestObj()).ToList();

    var grouped1 = obj_list.GroupBy(obj => new KeyT() { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 });
    var grouped2 = obj_list.GroupBy(obj => new KeyT() { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 }, new ObjectEqualityComparer_<KeyT>());
    var grouped3 = obj_list.GroupBy(obj => new KeyT() { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 }, new ObjectEqualityComparer__());
    var grouped4 = obj_list.GroupBy(obj => new { v1 = obj.Value1, v23 = obj.Value2 + obj.Value3 });

    System.Console.WriteLine("grouped1: {0}", grouped1.Count());
    System.Console.WriteLine("grouped2: {0}", grouped2.Count());
    System.Console.WriteLine("grouped3: {0}", grouped3.Count());
    System.Console.WriteLine("grouped4: {0}", grouped4.Count());
}
I teraz pytanie jakie będą wyniki. ObjectEqualityComparer_ i ObjectEqualityComparer_ zostały utworzone na podstawie źródeł orginalnego ObjectEqualityComparer. Przykładowy wyniki działania: grouped1: 10 grouped2: 10 grouped3: 6 grouped4: 6 Jak więc widać użycie generycznego ObjectEqualityComparer_ generuje błędne wyniki. Dlatego nie dostarczenie własnego komparatora dla GroupBy dawało błędne wyniki. Wystarczy popatrzeć jak w Lookup ten komparator jest tworzony. Czemu tak się dzieje nie wiem. Według mnie to jest jakiś bug. Generalnie używając jako klucza nie-anonimowo stworzonego obiektu, trzeba dla niego dostarczyć własny nie-generyczny komparator.

ForEach w stylu LINQ

Pisząc przy użyciu LINQ chciałoby była to pierwsza rzecz jakiej mi brakowało. Bardzo często zdarzało mi się tworzyć zbiór obiektów poprzez LINQ, a następnie chciałem na każdym z nim wykonać jakąś operacje. Normalnie musiało by to wyglądać tak:
class TestObj
{
    public TestObj(int value)
    {
        Value = value;
    }

    public int Value;

    public void Operation()
    {
        System.Console.WriteLine(Value);
    }
}

public void Test()
{
    var obj_list = new[] { new TestObj(2), new TestObj(3), new TestObj(4), 
                           new TestObj(5), new TestObj(6) };

    var x = from obj in obj_list
            where obj.Value < 5
            select obj;
    foreach (var obj in x)
        obj.Operation();
}
Aby móc zapisać linie 20-24 w takiej postaci:
(from obj in obj_list
 where obj.Value < 5
 select obj).ForEach(obj => obj.Operation());
Potrzebujemy następującej metod rozszerzających:
public static class LINQExtensions
{
    [System.Diagnostics.DebuggerStepThrough]
    public static void ForEach(this IEnumerable enumerable, Action handler)
    {
        foreach (T item in enumerable)
            handler(item);
    }
}
Atrybut [System.Diagnostics.DebuggerStepThrough] zapewnia nam, że debagując nie wskoczymy w tą metodę. W przypadku, gdy za działanie metody jesteśmy pewni, zastosowanie tego atrybuty znacznie ułatwia debagowanie, sprawiając, że nie skaczemy jak głupi po kodzie.