連載:C# 3.0入門

第6回 LINQ基礎編

株式会社ピーデー 川俣 晶
2008/08/29
Page1 Page2 Page3

シンプルなソート

 foreach文を使った単なる列挙に対するLINQの長所の1つは、簡単にソートができることだろう。orderby句をクエリ式に追加するだけである。

 以下は、与えられた数値のマイナス符号を無視し、値の大きい順に並べ替える例である。

using System;
using System.Linq;

class Program
{
  static void Main(string[] args)
  {
    int[] array = { -2, -1, 0, 1, 2 };

    var query = from x in array
                    orderby Math.Abs(x) descending select x;

    foreach (int n in query) Console.WriteLine(n);
    // 出力:
    // -2
    // 2
    // -1
    // 1
    // 0
  }
}
リスト11 絶対値の逆順ソート

 ここで、「orderby Math.Abs(x) descending」という部分がソートの指定に当たる。「Math.Abs(x)」はソートの根拠になる値を計算する式となる。「descending」は逆順(降順)を指定するキーワードで、昇順を指定するキーワードはascendingだが、これは省略できる。

 さて、この実行結果を見ると、2よりも先に-2が来ている。Math.Abs(2)とMath.Abs(-2)はどちらも2になるので、どちらが先に来るかはそれだけでは思いどおりに制御できない(ただし、順序が保存された安定した並べ替えを行うので、結果は一定している)。

 しかし、orderby句はソート条件を複数指定できるので、カンマ区切りでもう1つの条件を付加すれば、思いどおりにコントロールできる。-2よりも2を先にするには、絶対値ではない値の降順(x descending)を追加すればよい。

var query = from x in array
            orderby Math.Abs(x) descending, x descending select x;
リスト12 複数のソート条件を指定したクエリ

 この場合の実行結果は以下のようになる。

2
-2
1
-1
0
リスト12の実行結果

クエリの接続

 以下のようなクエリ式があったとしよう。このクエリ式は、1から10までの整数を2乗した値が50を超えるもののみを選び出す。

using System;
using System.Linq;

class Program
{
  static void Main(string[] args)
  {
    var query = from n in Enumerable.Range(1, 10)
                        where n * n > 50 select n * n;

    foreach (int n in query) Console.WriteLine(n);
    // 出力:
    // 64
    // 81
    // 100
  }
}
リスト13 2乗すると50を超える数値のみを抽出

 しかし、この式には「n * n」という計算が2回出てきて冗長である。この計算を1つにするには、例えば次のように「1から10までの整数を2乗した値」のシーケンスを得るクエリ式と、値が50を超えるもののみを選び出すクエリ式を分離すればよい。

var query1 = from n in Enumerable.Range(1,10) select n * n;
var query2 = from m in query1 where m > 50 select m;

foreach (int n in query2) Console.WriteLine(n);
リスト14 2つのクエリ式に分離

 これで「n * n」は1つになった。

 だが、これはこれでクエリ式が2つあり、別の意味で冗長である。ここで、into句を使うと、1つのクエリの結果を別のクエリのソースとすることができる。つまり、2つのクエリ式をinto句で接続することで、これを1つのクエリ式にまとめることができる。

var query = from n in Enumerable.Range(1,10)
              select n * n into m where m > 50 select m;
リスト15 2つのクエリをinto句により接続

 この例で、「from n in ……」がソースからの入力を範囲変数mで受けるのと同様に、「…… into m」は手前のクエリ式の結果を範囲変数mで受けている。それにより、m > 50という条件判断が可能となっている。

クエリ結果のグループ化

 通常、クエリ式はselect句で終わり、これによって結果の内容を確定させる。しかし、このほかにgroup句を使い、結果を分類することができる。

 以下の例は、名前とCPUのペアとなる情報を、CPUによってグループ化して分類する。つまり、CPUが「6800」のものだけ集め、「Z-80」のものだけ集め……という処理をすべて行うわけである。

using System;
using System.Linq;

class Program
{
  class 商品情報
  {
    public string 名前;
    public string Cpu;
  }

  static void Main(string[] args)
  {
    商品情報[] 商品情報データ =
    {
      new 商品情報() { 名前="Altair 680b ", Cpu="6800" },
      new 商品情報() { 名前="FP-1100", Cpu="Z-80" },
      new 商品情報() { 名前="H68/TR", Cpu="6800" },
      new 商品情報() { 名前="LKIT-16", Cpu="MN1610" },
      new 商品情報() { 名前="MZ-80K", Cpu="Z-80" },
      new 商品情報() { 名前="TRS-80 Color Computer", Cpu="6809" },
    };

    var query = from n in 商品情報データ group n by n.Cpu;

    foreach (IGrouping<string, 商品情報> r in query)
    {
      Console.WriteLine("CPU={0}", r.Key);
      foreach (商品情報 p in r) Console.WriteLine("\t{0}", p.名前);
    }
    // 出力:
    // CPU=6800
    //    Altair 680b
    //   H68/TR
    // CPU=Z-80
    //   FP-1100
    //   MZ-80K
    // CPU=MN1610
    //   LKIT-16
    // CPU=6809
    //   TRS-80 Color Computer
  }
}
リスト16 group句によるグループ化

 group句は、「group 要素 by キー」という書式で使用される。キーの値ごとにグループ化された要素のコレクションが得られる。

 また、group句はselect句と異なり、クエリ結果の型を自由に指定できず、常に「IGrouping<TKey, TElement>」という型に固定される。これは結果が指定型のリストという形で提供されるためである。TKeyはキーの型、TElementは要素の型である。

 そして、IGrouping<TKey, TElement>型の値を列挙すれば、1つのグループに含まれる要素を得ることができる。また、Keyプロパティを参照することで、キーの値を得ることができる。「CPU=Z-80」のような出力は、このKeyプロパティの値を参照することで実現している。

複数ソースを関連付けるjoin句

 複数のソースの値を関連付けることは、複数のfrom句を使うことでも実現できる。例えば、以下のリスト17のようなクエリ式を書くことで、同じId番号を持つ商品情報オブジェクトと、商品販売価格オブジェクトを関連付けることができ、名前と価格を組み合わせたリストを出力することができる。

using System;
using System.Linq;

class Program
{
  class 商品情報
  {
    public int Id;
    public string 名前;
  }

  class 商品販売価格
  {
    public int Id;
    public int 価格;
  }

  static void Main(string[] args)
  {
    商品情報[] 商品情報データ =
    {
      new 商品情報() { Id = 1, 名前="PC-8001" },
      new 商品情報() { Id = 2, 名前="MZ-80K" },
      new 商品情報() { Id = 3, 名前="Basic Master Level-3" },
    };

    商品販売価格[] 商品販売価格データ =
    {
      new 商品販売価格() { Id = 1, 価格 = 148000 },
      new 商品販売価格() { Id = 2, 価格 = 178000 },
      new 商品販売価格() { Id = 3, 価格 = 229000 },
    };

    var query = from x in 商品情報データ
                from y in 商品販売価格データ
                where x.Id == y.Id
                select new { Name = x.名前, Price = y.価格 };

    foreach (var 商品 in query) {
      Console.WriteLine("{0} {1:C}", 商品.Name, 商品.Price);
    }
    // 出力:
    // PC-8001 \148,000
    // MZ-80K \178,000
    // Basic Master Level-3 \229,000
  }
}
リスト17 複数のソースを関連付ける

 しかし、このようなコードは効率が良くない。上の掛け算の九九表サンプルを見て分かるとおり、2つのソースについてすべて総当たりで調べることになるためだ。つまり、商品情報が3つ、商品販売価格が3つなら、3×3=9回の判定が実行される。

 これを改善するには、関連付けという役割に特化したjoin句を使用するとよい。具体的には、クエリ式を以下のように書き直す。

var query = from x in 商品情報データ
            join y in 商品販売価格データ on x.Id equals y.Id
            select new { Name = x.名前, Price = y.価格 };
リスト18 join句への書き換え

 これは、join句の使い方の1つである「内部結合」を使用したものである。この場合、join句は、以下のような書式で記述する。

join [型名、省略可] 識別子 in 式1 on 式2 equals 式3
join句の構文

 ここで「join …… in ……」の部分は、「from …… in ……」の構文と似ている。しかし、決定的に違うのは「on」以降の構文が存在する点である。これは、2つのソースを関連付ける条件を指定するために存在する。

 ここで注意すべき点は、from句の場合、関連付けるソースを選ぶ条件として、「where x.Id == y.Id」と記述したが、join句では「on x.Id equals y.Id」と記述していることである。より具体的にいえば、「==」という汎用の演算子を使っているか、「equals」というjoin句専用キーワードを使っているかの相違である。つまり、where句で条件を指定する場合は、あらゆる式を自由に記述できたが、join句では「等価」という判定しか行うことができない。

 この制約は、逆にいえば、join句を使用するには「等価」と判定できる値を2つのソースが含んでいなければならないことも示す。

from句とjoin句のパフォーマンス

 さて、join句は制約が大きいことから、できればfrom句を使いたいと思う読者もいるだろう。果たして、from句とjoin句の差はどれぐらいあるのだろうか。

 ここでは、上のリスト17を少し変更し、クエリ式からIdの値が何回参照されているかを調べてみよう。

using System;
using System.Linq;

class Program
{
  class 商品情報
  {
    private int id;
    public static int ReadCount = 0;

    public int Id
    {
      get { ReadCount++; return id; }
      set { id = value; }
    }
    public string 名前;
    public int 定価;
  }

  class 商品販売価格
  {
    private int id;
    public static int ReadCount = 0;

    public int Id
    {
      get { ReadCount++; return id; }
      set { id = value; }
    }
    public int 価格;
  }

  static void Main(string[] args)
  {
    (商品情報データと商品販売価格データの定義は
                                  リスト17と同じなので省略)


#if false // from句を試す場合はtrueに書き換える

    var query = from x in 商品情報データ
                from y in 商品販売価格データ
                where x.Id == y.Id
                select new { Name = x.名前, Price = y.価格 };

#else

    var query = from x in 商品情報データ
                join y in 商品販売価格データ on x.Id equals y.Id
                select new { Name = x.名前, Price = y.価格 };

#endif

    foreach (var 商品 in query) {
      Console.WriteLine("{0} {1:C}", 商品.Name, 商品.Price);
    }

    Console.WriteLine(
      "商品情報.ReadCount={0},商品販売価格.ReadCount={1}",
      商品情報.ReadCount, 商品販売価格.ReadCount);
  }
}
リスト19 Idの参照回数を調べる

……中略……
商品情報.ReadCount=9,商品販売価格.ReadCount=9
リスト19の実行結果(from句の場合)

……中略……
商品情報.ReadCount=3,商品販売価格.ReadCount=3
リスト19の実行結果(join句の場合)

 この結果を見て分かるとおり、from句を2つ使った場合、クエリ対象の情報は、

ソース1の個数(3)×ソース2の個数(3)×2=18回

の参照だが、join句を使用した場合は、

ソース1の個数(3)+ソース2の個数(3)=6回

しか参照されていない。これは、クエリ時のデータ参照の回数がfrom句では掛け算で増えるのに対して、join句では足し算で増えることを意味する。つまり、データの個数が増えれば増えるほど回数の差は劇的に増えていく。

 もちろん、データを参照する回数だけでパフォーマンスが決まるわけではない。では、具体的な時間差はどの程度あるのだろうか。以下はそれを調べるために作成したソース・コードである。

using System;
using System.Linq;

class Program
{
  (商品情報と商品販売価格の定義はリスト17と同じなので省略)

  static void Main(string[] args)
  {
    (商品情報データと商品販売価格データの定義は
                                  リスト17と同じなので省略)


    var query1 = from x in 商品情報データ
                 from y in 商品販売価格データ
                 where x.Id == y.Id
                 select new { Name = x.名前, Price = y.価格 };

    var query2 = from x in 商品情報データ
                 join y in 商品販売価格データ on x.Id equals y.Id
                 select new { Name = x.名前, Price = y.価格 };

    const int COUNT = 1000000;

    DateTime start1 = DateTime.Now;
    for (int i = 0; i < COUNT; i++) foreach (var 商品 in query1) { }
    Console.WriteLine("from句: {0}", DateTime.Now - start1);

    DateTime start2 = DateTime.Now;
    for (int i = 0; i < COUNT; i++) foreach (var 商品 in query2) { }
    Console.WriteLine("join句: {0}", DateTime.Now - start2);
  }
}
リスト20 from句とjoin句の時間差を調べる

from句: 00:00:03.5363536
join句: 00:00:01.9231923
リスト20の実行結果(デバッグ・ビルド時。筆者のPCでの結果)

 このように、処理時間としても、歴然とした差が見て取れる。また、データの個数が増えれば、差はさらに拡大するだろう。両者には歴然とした効率の差があると考えてよいだろう。join句が使用できるときは、join句の利用がお勧めである。

次回予告

 join句の主な使い方にはここで紹介した「内部結合」のほかに、「グループ化結合」と「左外部結合」もある。また、クエリ式にはメソッド構文という別の書き方もある。クエリのインスタンス化やlet句なども解説が残っている。

 LINQの解説はまだまだ入り口にすぎない。筆者も、LINQの深層に触れてワクワクしている。次回に続く。End of Article

 

 INDEX
  C# 3.0入門
  第6回 LINQ基礎編
    1.LINQの面白さ/LINQとは何か?/「値の集まり」に対する演算/なぜLINQなのか
    2.最も基本的なLINQ/本質は列挙/結果の加工/複数ソース/絞り込み
  3.ソート/クエリの接続/グループ化/join句/from句とjoin句のパフォーマンス
 
インデックス・ページヘ  「C# 3.0入門」


Insider.NET フォーラム 新着記事
  • 第2回 簡潔なコーディングのために (2017/7/26)
     ラムダ式で記述できるメンバの増加、throw式、out変数、タプルなど、C# 7には以前よりもコードを簡潔に記述できるような機能が導入されている
  • 第1回 Visual Studio Codeデバッグの基礎知識 (2017/7/21)
     Node.jsプログラムをデバッグしながら、Visual Studio Codeに統合されているデバッグ機能の基本の「キ」をマスターしよう
  • 第1回 明瞭なコーディングのために (2017/7/19)
     C# 7で追加された新機能の中から、「数値リテラル構文の改善」と「ローカル関数」を紹介する。これらは分かりやすいコードを記述するのに使える
  • Presentation Translator (2017/7/18)
     Presentation TranslatorはPowerPoint用のアドイン。プレゼンテーション時の字幕の付加や、多言語での質疑応答、スライドの翻訳を行える
@ITメールマガジン 新着情報やスタッフのコラムがメールで届きます(無料)

注目のテーマ

Insider.NET 記事ランキング

本日 月間