selectに匿名型を利用する

プログラミングC# 第6版 8.3.5


selectで取ってくるデータとして匿名型を利用する。匿名型は以前習いましたね

using System;
using System.Linq;

class Program {
    static void Main() {
        int[] nums = new int[] {
            1,2,3,4,5
        };
        
        // selectに匿名型を利用
        var ret = from num in nums
                  select new { id = num };
        
        foreach(var item in ret) {
            Console.WriteLine("id={0}",item.id);
        }
    }
}
$ main
id=1
id=2
id=3
id=4
id=5

上記例だとあまり匿名型を利用するメリットがなさそうに見えますが、いやはや匿名型だとその場で定義し利用できるのでクラスを用意する必要がないというのが便利ですね。

一行でスパッと書けるのもナイスです。

複数のデータソースを使用する

プログラミングC# 第6版 8.3.5.2


複数のデータソースを処理する。fromをつなげて処理する感じですね。

using System;
using System.Linq;

class Program {
    static void Main() {
        int[][] nums = new int[][] {
            new int[]{1,2,3},
            new int[]{10,20,30},
            new int[]{100,200,300},
        };
        
        var ret = from n1 in nums
                  from n2 in n1
                  select n2;
        
        foreach(var n in ret) {
            Console.WriteLine("{0}",n);
        }
    }
}
$ main
1
2
3
10
20
30
100
200
300

例としては微妙ですが、単にジャグ配列を全表示してるだけですね。

他の例として

using System;
using System.Linq;

class Program {
    static void Main() {
        int[] nums1 = new int[] {1,2,3,4,5};
        int[] nums2 = new int[] {5,6,7};
        
        // 二つの配列の加算
        var ret = from n1 in nums1
                  from n2 in nums2
                  select n1 + n2;
        
        foreach(var n in ret) {
            Console.WriteLine("{0}",n);
        }
    }
}
$ main
6
7
8
7
8
9
8
9
10
9
10
11
10
11
12

こんなかんじ。

メソッドを使う場合はSelectManyを利用する。

上記処理をSelectManyで書き直すと

        // 二つの配列の加算
        var ret = nums1.SelectMany( x => nums2, (x,y) => x + y );

こうなる。

Selectメソッドで添え字を得る

プログラミングC# 第6版 8.3.5.3


Selectメソッドを使い、反復処理中の添え字を得る。

以下実装例

using System;
using System.Linq;

class Program {
    static void Main() {
        string[] strs = new string[] {"foo","bar","baz"};
        
        // ラムダ式の第二引数に添え字が来る
        var ret = strs.Select( (str,i) => i+":"+str );
        
        foreach(var n in ret) {
            Console.WriteLine("{0}",n);
        }
    }
}
$ main
0:foo
1:bar
2:baz

これは非常に便利ですね。

Zipメソッドによるマージ処理

プログラミングC# 第6版 8.3.6


二つのデータを一つのデータにまとめます。

Concatのように結合するのではなく、同じ要素で一まとめにするといった感じです。まぁ文章より実装を見た方が早いですね。

using System;
using System.Linq;

class Program {
    static void Main() {
        // それぞれ3要素の配列を用意。
        int[] nums = new int[] {1,2,3};
        string[] strs = new string[] {"foo","bar","baz"};
        
        // numsとstrsをマージする
        var ret = nums.Zip(strs,(num,str) => new { id = num, name = str });
        
        foreach(var item in ret) {
            Console.WriteLine("id={0} name={1}",item.id,item.name);
        }
    }
}
$ main
id=1 name=foo
id=2 name=bar
id=3 name=baz

ここでも匿名型が活躍です。それぞれ3要素の値を持つ配列をZipを使って匿名型で一まとめにするといった感じですね。

注意点としては、マージする配列の要素数が違う場合、多いほうの要素が捨てられてしまいます。

なのでそもそも論ですが、マージする場合は要素が同じもの同士でやるべきという感じですかね。

全体から一部を取り出す

プログラミングC# 第6版 8.3.7


全部は必要なくて例えば先頭から3件だけ処理したいとかいう場合にTakeメソッドを使います。

using System;
using System.Linq;

class Program {
    static void Main() {
        int[] nums = new int[] {1,2,3,4,5,6,7};
        
        // 先頭から3件まで取得
        var ret = nums.Take(3);
        
        foreach(var n in ret) {
            Console.WriteLine("{0}",n);
        }
    }
}
$ main
1
2
3

先頭から1件だけ欲しい場合はFirstメソッドが使えます。FirstはTakeと違って配列ではなく要素そのものが返ってきます。

class Program {
    static void Main() {
        string[] strs = new string[] {"foo","bar","baz"};
        
        // 先頭ひとつ
        var ret = strs.First();
        
        // 配列じゃなく、要素が返ってくるのでそのまま出力する
        Console.WriteLine("{0}",ret);
    }
}
$ main
foo

ただしFirstは要素が無い場合に使用すると例外をスローします。

$ main

ハンドルされていない例外: System.InvalidOperationException: シーケンスに要素が含まれていません
   場所 System.Linq.Enumerable.First[TSource](IEnumerable`1 source)
   場所 Program.Main()

要素が無い場合に例外を発生させるのではなく、null等のデフォルト値を返したい場合はFirstOrDefaultメソッドが使えます。

class Program {
    static void Main() {
        string[] strs = new string[] {};
        
        // 要素が無い場合にデフォルト値が入る
        var ret = strs.FirstOrDefault();
        
        if ( ret != null ) {
            Console.WriteLine("{0}",ret);
        }
    }
}

また、Firstに似たSingleというメソッドもあります。こちらは要素が1つ以外の場合に例外をスローするので、要素が必ず1つであることを保障します。

using System;
using System.Linq;

class Program {
    static void Main() {
        // 要素二つ
        string[] strs = new string[] {"foo","bar"};
        
        // 要素が1つのはず。それ以外は例外
        var ret = strs.Single();
        
        Console.WriteLine("{0}",ret);
    }
}
$ main

ハンドルされていない例外: System.InvalidOperationException: シーケンスに複数の要素が含まれています
   場所 System.Linq.Enumerable.Single[TSource](IEnumerable`1 source)
   場所 Program.Main()

SingleOrDefaultメソッドもあってそっちは要素が0か1つであることを保障します。

FirstとSingleの使い分けですが、あるクエリ式の結果が必ず一つのはずってときにSingleを使って堅牢にしておくって感じですかね。

AnyやAllメソッドによる要素のチェック

プログラミングC# 第6版 8.3.8


Anyメソッドを使うことで、ある条件に合う要素が一つでも存在しているかどうかのチェックを行うことができます。

using System;
using System.Linq;

class Program {
    static void Main() {
        int[] nums1 = new int[] {1,3,5,7}; // 偶数なし
        int[] nums2 = new int[] {1,3,5,8}; // 偶数あり
        
        // 各配列に偶数が含まれているかどうかをチェックする
        Console.WriteLine("{0}", nums1.Any(i => i % 2 == 0) );
        Console.WriteLine("{0}", nums2.Any(i => i % 2 == 0) );
    }
}
$ main
False
True

また、Anyメソッドを引数なしで呼ぶと、一つでも要素があるかどうかをチェックすることができます。

class Program {
    static void Main() {
        int[] nums1 = new int[] {};  // 要素なし
        int[] nums2 = new int[] {1}; // 要素あり
        
        // 各配列に一つでも要素が存在してるかどうかチェックする
        Console.WriteLine("{0}", nums1.Any() );
        Console.WriteLine("{0}", nums2.Any() );
    }
}
$ main
False
True

次にAllメソッドは、全ての要素がある条件に合うかどうかをチェックすることができます。

class Program {
    static void Main() {
        int[] nums1 = new int[] {1,3,5,8}; // 一つだけ偶数
        int[] nums2 = new int[] {2,4,6,8}; // 全て偶数
        
        // 各配列が全て偶数かどうかをチェックする
        Console.WriteLine("{0}", nums1.All(i => i % 2 == 0) );
        Console.WriteLine("{0}", nums2.All(i => i % 2 == 0) );
    }
}
$ main
False
True

またAllメソッドは要素が空の時にはTrueを返します。これは条件に合わない場合にFalseを返すという実装になっているためらしいです。

ただまぁそもそも要素が空の配列に対してAllメソッド使うこと自体おかしいので気にする必要はなさそうですね。

集約用Aggregate演算子

プログラミングC# 第6版 8.3.9


要素を全て使用して単一の値を返すための演算子です。例えば要素数を返すCount演算子や、全ての要素の合計値を計算するSumなどがそれにあたります。

using System;
using System.Linq;

class Program {
    static void Main() {
        int[] nums = new int[] {2,4,6,8};
        
        Console.WriteLine("要素数:{0}", nums.Count() );
        Console.WriteLine("合計値:{0}", nums.Sum() );
    }
}
$ main
要素数:4
合計値:20

お次はタイトルにもあるようにAggregate演算子についてです。

これは汎用の演算子になっていてこの演算子を使えば全ての集約用演算子の処理を書くことが出来ます。

例として先程のCountやSumをAggrageteに置き換えてみましょう。

class Program {
    static void Main() {
        int[] nums = new int[] {2,4,6,8};
        
        Console.WriteLine("要素数:{0}", nums.Aggregate(0,(c,i) => c + 1) );
        Console.WriteLine("合計値:{0}", nums.Aggregate(0,(t,i) => t + i) );
    }
}

Aggregateの第一引数は初期値です。第二引数のラムダ式の第一引数(説明ややこしいw)にはラムダ式でreturnした値が渡って来ます。そして第二引数には要素の値が来ます。

なのでCountでは単に「c + 1」としていくだけで要素数を数えることができます。Sumは今まで加算されたtに要素iを足しこんでいくだけという感じです。

0除算の結果定数

プログラミングC# 第6版


飛ばしていた例外の章について見ていきます。

double型の0で0除算をすると以下のような出力が得られます。

using System;

class Program {
    static void Main() {
        double i = 0.0;
        
        Console.WriteLine(0.0/i);
        Console.WriteLine(1.0/i);
        Console.WriteLine(-1.0/i);
    }
}
$ main
NaN (非数値)
+∞
-∞

なにやらNaNやら∞やら見慣れないものが表示されました。

これらはDoubleに定義されている定数ということです。それぞれ

Double.NaN
Double.PositiveInfinity
Double.NegativeInfinity

となっています。

またこれらを比較するための関数も存在しています。

using System;


class Program {
    static void Main() {
        Check(0.0);
        Check(1.0);
        Check(-1.0);
    }
    
    static void Check(double d) {
        double i = 0.0;
        
        Console.WriteLine("{0}/{1}",d,i);
        Console.WriteLine("Double.IsNaN              = {0}",Double.IsNaN(d/i));
        Console.WriteLine("Double.IsPositiveInfinity = {0}",Double.IsPositiveInfinity(d/i));
        Console.WriteLine("Double.IsNegativeInfinity = {0}",Double.IsNegativeInfinity(d/i));
        Console.WriteLine("Double.IsInfinity         = {0}",Double.IsInfinity(d/i));
    }
}
$ main
0/0
Double.IsNaN              = True
Double.IsPositiveInfinity = False
Double.IsNegativeInfinity = False
Double.IsInfinity         = False
1/0
Double.IsNaN              = False
Double.IsPositiveInfinity = True
Double.IsNegativeInfinity = False
Double.IsInfinity         = True
-1/0
Double.IsNaN              = False
Double.IsPositiveInfinity = False
Double.IsNegativeInfinity = True
Double.IsInfinity         = True

うまく比較できていますね。

ちなみにint型で0除算すると例外が発生します。

using System;

class Program {
    static void Main() {
        int i = 0;
        int j = 0 / i;
    }
}
$ main

ハンドルされていない例外: System.DivideByZeroException: 0 で除算しようとしました。
   場所 Program.Main()

doubleとintで挙動が違うので注意が必要ですね。

例外処理

プログラミングC# 第6版 6.3.1


C#における例外処理のお話。C++にもありましたね。

まずは基本の実装例です。

using System;

class Program {
    static void Main() {
        try {
            Foo();
        }
        catch(Exception e) {
            Console.WriteLine("catch実行:"+e);
        }
        finally {
            Console.WriteLine("finally実行");
        }
    }
    
    static void Foo () {
        throw new Exception("error in Foo");
    }
}
$ main
catch実行:System.Exception: error in Foo
   場所 Program.Main()
finally実行

try内でスロー(発生)した例外をcatchでキャッチします。finallyは例外が発生したかしないかに限らずに常に呼ばれます。

キャッチした例外を再スローすることもできます。

using System;

class Program {
    static void Main() {
        try {
            Foo();
        }
        catch(Exception e) {
            Console.WriteLine("catch2実行:"+e);
        }
        finally {
            Console.WriteLine("finally2実行");
        }
    }
    
    static void Foo () {
        try {
            throw new Exception("error in Foo");
        }
        catch(Exception e) {
            Console.WriteLine("catch1実行:"+e);
            // 再スローする
            throw;
        }
        finally {
            Console.WriteLine("finally1実行");
        }
    }
}
$ main
catch1実行:System.Exception: error in Foo
   場所 Program.Foo()
finally1実行
catch2実行:System.Exception: error in Foo
   場所 Program.Foo()
   場所 Program.Main()
finally2実行

引数なしのthrow呼び出しで再スローとなります。

また新しい例外をスローする際に、既存の例外を引数に渡すことでInnerExceptionとして登録することができます。

そしてcatch先で登録されているInnerExceptionを取り出すことができます。

using System;

class Program {
    static void Main() {
        try {
            Foo();
        }
        catch(Exception e) {
            Console.WriteLine("catch2実行:"+e);
            
            // InnerExceptionとして登録されている例外を全て表示する
            Exception current = e;
            while( current != null ) {
                
                Console.WriteLine("InnerException:"+current);
                current = current.InnerException;
            }
        }
        finally {
            Console.WriteLine("finally2実行");
        }
    }
    
    static void Foo () {
        try {
            throw new Exception("error in Foo");
        }
        catch(Exception e) {
            Console.WriteLine("catch1実行:"+e);
            
            // eをInnerExceptionとして登録して再スローする
            throw new Exception("再スロー",e);
        }
        finally {
            Console.WriteLine("finally1実行");
        }
    }
}
$ main
catch1実行:System.Exception: error in Foo
   場所 Program.Foo()
finally1実行
catch2実行:System.Exception: 再スロー ---> System.Exception: error in Foo
   場所 Program.Foo()
   --- 内部例外スタック トレースの終わり ---
   場所 Program.Foo()
   場所 Program.Main()
InnerException:System.Exception: 再スロー ---> System.Exception: error in Foo
   場所 Program.Foo()
   --- 内部例外スタック トレースの終わり ---
   場所 Program.Foo()
   場所 Program.Main()
InnerException:System.Exception: error in Foo
   場所 Program.Foo()
finally2実行

反復処理の実装とyield文

プログラミングC# 第6版 7.3.1.1


反復処理を独自に実装することが可能とのこと。

これは説明が難しいので実装を見た方がわかりやすいかと思います。

using System;
using System.Collections.Generic;

class Program {
    static void Main() {
        foreach(int i in Foo()){
            Console.WriteLine(i);
        }
    }
    
    static IEnumerable<int> Foo () {
        yield return 1;
        yield return 3;
        yield return 6;
    }
}
$ main
1
3
6

Foo関数の戻り値がIEnumerable型というものになっています。

これでforeachで評価することができるようになります。

そして通常のreturnではなく、yield returnというものになっています。

Fooが呼ばれるたびにyieldの位置まで処理が進み、次のループに入るときに、前回進んだyieldの位置から処理が再開されるという処理になっています。

またFooの戻り値を一時変数に保持し、whileループで処理させることもできます。

using System;
using System.Collections.Generic;

class Program {
    static void Main() {
        IEnumerable<int> foo = Foo(); 
        IEnumerator<int> iter = foo.GetEnumerator();
        
        while ( iter.MoveNext() ) {
            Console.WriteLine(iter.Current);
        }
    }
    
    static IEnumerable<int> Foo () {
        yield return 1;
        yield return 3;
        yield return 6;
    }
}

色々と活用できそうですね。今後使えるケースがあれば使ってみたいと思います。

初めてのLINQ

プログラミングC# 第6版 8.1


LINQです。クエリ式とも言うみたいです。一見すると何がなんだか良くわからない感じに見えますが、今までのC#の知識で十分読み解けそうです。

ということでまずは一番シンプルなクエリ式の構文を見てみましょう。

using System;
using System.Linq;

class Program {
    static void Main() {
        int[] nums = new int[] {
            1,2,3,4,5,6,7,8,9,10
        };
        
        // 偶数だけを得る処理 これがLINQ!
        var ret = from i in nums where i % 2 == 0 select i;
        
        foreach(int i in ret) {
            Console.WriteLine(i);
        }
    }
}
$ main
2
4
6
8
10

クエリ式は必ずfromから始まり、fromの後はforeachに指定するのと同じ様な構文を指定し、whereでif条件を指定し、selectでreturnするものを指定すると言った感じです。

LINQを使わず上記処理を書くならば以下のようになります。

using System;
using System.Collections.Generic;

class Program {
    static void Main() {
        int[] nums = new int[] {
            1,2,3,4,5,6,7,8,9,10
        };
        
        // 偶数だけを得る処理
        var ret = new List<int>();
        foreach(var i in nums) {
            if ( i % 2 == 0 ) {
                ret.Add(i);
            }
        }
        
        foreach(int i in ret) {
            Console.WriteLine(i);
        }
    }
}

こうみると、LINQを使うことでかなり処理がスッキリしますね。

ちなみにLINQの構文はC#の通常の言語仕様の処理にも置き換えが可能とのことです。具体的には以下のような処理になります。

using System;
using System.Linq;

class Program {
    static void Main() {
        int[] nums = new int[] {
            1,2,3,4,5,6,7,8,9,10
        };
        
        // 偶数だけを得る処理
        var ret = nums.Where(i => i % 2 == 0).Select(i => i);
        
        foreach(int i in ret) {
            Console.WriteLine(i);
        }
    }
}

int[]に対してWhereメソッドを呼んでますが、これは以前習った拡張メソッドです。

そしてWhereメソッドに渡している引数はラムダ式ですね。

何もややこしくないですね。初めて見たときは複雑怪奇で意味がわかりませんでしたが、上記仕様を理解していれば簡単に読めますね。

LINQ演算子の独自実装

プログラミングC# 第6版 8.1.2


WhereやSelectを拡張メソッドに頼らずに独自に実装することでクエリ式として扱うことができます。

using System;
using System.Linq;

class Foo {
    public int id;
    
    public Foo Where (Func<Foo, bool> predicate) {
        return this;
    }
    
    public TResult Select<TResult>(Func<Foo, TResult> selector) {
        return selector(this);
    }
}

class Program {
    static void Main() {
        var foo = new Foo() { id = 1 };
        
        var ret = from f in foo where f.id == 1 select f.id;
        
        Console.WriteLine(ret);
    }
}
$ main
1

WhereとSelectを独自実装しています。ただし独自実装できるよっていうのを確認しただけの処理なので本来こういった処理は書くことはないようです。

Whereのpredicateも使わずに無視してますしね。

let句

プログラミングC# 第6版 8.1.3


クエリ式の中で使用できる一時変数を定義するためのものです。

まずはlet句を使わなかった場合の処理を見てみましょう。

using System;
using System.Linq;

// 数値チェッククラス
class CheckNum {
    private int _num;
    public int num {
        get { return _num; }
    }
    
    public CheckNum(int i) {
        _num = i;
    }
    
    // 偶数かどうか
    public bool isEven () {
        return num % 2 == 0;
    }
}

class Program {
    static void Main() {
        int[] nums = new int[] {
            1,2,3,4,5,6,7,8,9,10
        };
        
        var ret = from i in nums
                  where  new CheckNum(i).isEven()
                  select new CheckNum(i).num;
        
        foreach(int i in ret) {
            Console.WriteLine(i);
        }
    }
}
$ main
2
4
6
8
10

処理自体にあまり意味はありませが、whereとselectでCheckNumを無駄に二回newしているのがわかります。

これをlet句を使うことで一時変数として保持し、newを一回にすることが可能になります。

class Program {
    static void Main() {
        int[] nums = new int[] {
            1,2,3,4,5,6,7,8,9,10
        };
        
        var ret = from i in nums
                  let    checkNum = new CheckNum(i)
                  where  checkNum.isEven()
                  select checkNum.num;
        
        foreach(int i in ret) {
            Console.WriteLine(i);
        }
    }
}

LINQは遅延実行

プログラミングC# 第6版 8.2.3


クエリ式で得た値は、実際にforeach等でループするまではまだ実行されてない状態とのこと。

というわけで動作確認。

using System;
using System.Linq;
using System.Collections.Generic;

class Program {
    static IEnumerable<int> Nums () {
        for(int i=1; i<11;i++ ) {
            Console.WriteLine(i);
            yield return i;
        }
    }
    
    static void Main() {
        Console.WriteLine("LINQ start");
        var ret = from i in Nums() where i % 2 == 0 select i;
        Console.WriteLine("LINQ end");
        
        foreach(int i in ret) {
        }
    }
}
$ main
LINQ start
LINQ end
1
2
3
4
5
6
7
8
9
10

実行結果を見てわかるとおり、クエリ式終了時点ではまだ反復処理が実行されていない。実際にretをループさせたときに処理が実行されている。

OfTypeフィルタ演算子

プログラミングC# 第6版 8.3.1


OfTypeは指定した型のオブジェクトだけを抽出するフィルタです。

以下実行例。

using System;
using System.Linq;

class Foo { public int fooId; }
class Bar { public int barId; }

class Program {
    static void Main() {
        object[] objs = new object[] {
            new Foo() { fooId = 1 },
            new Foo() { fooId = 2 },
            new Bar() { barId = 10 },
            new Bar() { barId = 20 },
        };
        
        // Bar型のオブジェクトだけを抽出する
        var ret = from obj in objs.OfType<Bar>() select obj;
        
        foreach(var item in ret) {
            Console.WriteLine(item.barId);
        }
    }
}
$ main
10
20

Bar型だけがちゃんと表示されていますね。

orderby句

プログラミングC# 第6版 8.3.2


ソートされたデータを返します。

以下実行例

using System;
using System.Linq;

class Foo {
    public int id;
    public string name;
}

class Program {
    static void Main() {
        Foo[] fooList = new Foo[] {
            new Foo() { id = 5 , name = "hanako" },
            new Foo() { id = 4 , name = "tarou" },
            new Foo() { id = 9 , name = "aiko" },
            new Foo() { id = 1 , name = "tarou" },
        };
        
        // idの昇順でソート
        var ret = from foo in fooList
                  orderby foo.id ascending
                  select foo;
        
        foreach(var item in ret) {
            Console.WriteLine("id={0} name={1}",item.id,item.name);
        }
    }
}
$ main
id=1 name=tarou
id=4 name=tarou
id=5 name=hanako
id=9 name=aiko

うまくソートされていますね。ascendingの部分をdescendingに変更すれば降順でソートされます。また省略時は昇順としてソートされます。

次に、複数の条件を指定してソートする場合は以下のようになります。

        // nameの昇順でソートした後、idの降順でソートする
        var ret = from foo in fooList
                  orderby foo.name ascending,foo.id descending
                  select foo;
$ main
id=9 name=aiko
id=5 name=hanako
id=4 name=tarou
id=1 name=tarou

さて最後に同様の処理をorderby句を使わずにOrderByメソッドを使った場合の実装例も試しておきましょう。

        // nameの昇順でソートした後、idの降順でソートする
        var ret = fooList.OrderBy(foo => foo.name)
                         .ThenByDescending(foo => foo.id)
                         .Select(foo => foo);

OrderByが昇順ソード、OrderByDescendingが降順ソート用のメソッドになります。

ThenByというのは複数条件のソートを行う場合に使用するメソッドです。

OrderByDescendingと同様に、降順の場合はThenByDescendingメソッドを使用することになります。

Concat演算子による連結

プログラミングC# 第6版 8.3.3


データ同士を連結します。

以下実行例

using System;
using System.Linq;

class Program {
    static void Main() {
        int[] nums1 = new int[] { 1,2,3 };
        int[] nums2 = new int[] { 5,6,7 };
        
        var ret = nums1.Concat(nums2);
        
        foreach(var i in ret) {
            Console.WriteLine(i);
        }
    }
}
$ main
1
2
3
5
6
7

簡単ですね。

group by句によるグループ化

プログラミングC# 第6版 8.3.4


データをグループごとにまとめます。

以下実行例

using System;
using System.Linq;

class Foo {
    public int id;
    public string name;
}

class Program {
    static void Main() {
        Foo[] fooList = new Foo[] {
            new Foo() { id = 2 , name = "foo1" },
            new Foo() { id = 2 , name = "foo2" },
            new Foo() { id = 2 , name = "foo3" },
            new Foo() { id = 3 , name = "foo4" },
            new Foo() { id = 3 , name = "foo5" },
        };
        
        // idをキーにしてグループ化する
        var ret = from foo in fooList
                  group foo by foo.id;
        
        // まずはid毎のループ
        foreach(var item in ret) {
            Console.WriteLine("key(id)={0}",item.Key);
            
            // id毎にまとめられたFooのデータでループ
            foreach(var foo in item) {
                Console.WriteLine("id={0} name={1}",foo.id,foo.name);
            }
        }
    }
}
$ main
key(id)=2
id=2 name=foo1
id=2 name=foo2
id=2 name=foo3
key(id)=3
id=3 name=foo4
id=3 name=foo5

特定のキーでグループ化するという感じですね。

さらにここからgroup〜intoを利用して、グループ化されたデータを対象に処理を続けることもできます。

class Program {
    static void Main() {
        Foo[] fooList = new Foo[] {
            new Foo() { id = 2 , name = "foo1" },
            new Foo() { id = 2 , name = "foo2" },
            new Foo() { id = 2 , name = "foo3" },
            new Foo() { id = 3 , name = "foo4" },
            new Foo() { id = 3 , name = "foo5" },
        };
        
        // idをキーにしてグループ化し、グループ化されたtmpに対して処理をする
        var ret = from foo in fooList
                  group foo by foo.id into tmp
                  select tmp.Key; // グループ化したキー(id)のみ返す
        
        // グループ化したidを表示
        foreach(var id in ret) {
            Console.WriteLine("id={0}",id);
        }
    }
}
$ main
id=2
id=3

処理自体に意味はありません。intoグループ化された値をtmpに一時変数として格納し、selectでtmpに対して処理させています。