Top ≫ 総合 目次 ≫ C# によるプログラミング入門
C# 3.0 の新機能の多くは、一言でいってしまえば「データ処理」のための機能です。 そこでまず、データ処理に関する話をしてきます。
近年、ほとんどのアプリケーションは、何らかのデータに対する操作と表示が主な仕事となっています。 すなわち、データ処理(読み出しや更新)と表示用の GUI 構築がプログラムの行う処理です。
このうち、GUI の構築に関しては、オブジェクト指向とイベント駆動という考え方に基づいてプログラムが作られます。 C# は 1.0 の頃から、オブジェクト指向とイベント駆動に関する機能は充実していました。 (参考: 「オブジェクト指向」 、 「イベント」 )
一方、データ処理に関しては、C# 3.0 で追加された LINQ と呼ばれる機能によって、ようやく簡単に処理が行えるようになりました。 LINQ に関する文法や実装上の説明は 「LINQ」 などで行いますが、 ここでは、データ処理を行う上で押さえておくべきプログラミングモデルについて説明します。
LINQ にはいくつかの目的があります。 オブジェクト指向的な考え方と、リレーショナルデータベース(以下、RDB と略す)的な考え方の間のギャップを埋めるというのもその1つです。
この辺りに関して、細かい部分はこのページで説明したいことの範囲を超えるので、 もう少し詳細に知りたい場合は 「[雑記] O/R インピーダンスミスマッチ」 辺りを参照してください。 ここで説明しておきたいのは、RDB において、 データの読み出しをどうやって行っているかです。
ほとんどの場合、データベースに格納されたデータすべてを読む必要性はなくて、 特定の条件を満たしたものだけを読み出します。 そのために、RDB では、以下のような感じの「問い合わせ言語」を使って、条件指定しながらデータを読み出します。
select xxx from テーブル1 join テーブル2 on 結合条件 where 選択条件
意味合いとしては以下のような感じです。
ここでは例示していませんが、他にも、以下のようなものもあります。
C# 3.0 の LINQ では、これと似たような書き方でデータの読み出しができるようになっています。 ただし、データ上から順に処理いていくために、select 文を末尾に移動しています。
from x in テーブル1 join y in テーブル2 on x.Id equals y.Id where 選択条件 select x;
上から順に、
y.CategoryId == 1 などと言った条件を指定。
new { x.Id, x.Value } などという書き方をする。
何らかのデータ列に対して、1行ずつ読み出していって、上から順に流れるように処理していくというところがポイントです。
別に RDB に限らず、 データ処理の多くはストリーム的でパイプライン的です。
データは、前から順に、1行ずつ処理されていきます。 RDB のテーブルなどに格納されたデータを一気に全部読み出す必要はありません。 このような処理の仕方をストリーム(stream: 流れ、小川)処理と呼びます。
後述する、「選択」や「射影」(前述の where や select)など、多くの処理はストリーム処理になっています。 ただし、データの整列(order by)など、どうしても一度すべてのデータを読み出してからでないと行えない処理も存在します。
ここでは、ストリーム的に処理できるデータ列を「データストリーム」と呼びましょう。
データストリームの処理結果は、やはりデータストリームになります。 例えば、あるデータストリームに対して「特定の条件を満たす要素だけ残す」という処理をかけたものはやはりデータストリームとして扱えます。
そこで、データストリームに対してパイプライン的に(多段に)処理を掛けていくということも可能になります。 例えば、元データ → [条件選択] → [データの加工] → 出力データ というような感じです。 データストリームに対する処理なので、1段目の条件選択が全要素に対して終わる前に、平行して2段目のデータ加工を進めていくことができます。
データストリームに対する処理として、主要なものを挙げるなら、選択、加工、集計の3つかと思います。 Google の言う map-reduce 処理はこの類に入ります。
SQL でいうなら where、map-reduce でいうなら map の一部分です。 条件を指定して、一部の要素だけを選択します。
この例は C# で書くなら inputs.Where(x => (x % 2) == 1); になります。
LINQ では、以下のような種類があります。
「要素 x から x の二乗を求める」とか「オブジェクト x のうち、x.Value プロパティと x.Name プロパティだけを取り出す」とかの、 要素の加工を射影と呼びます。 SQL でいうなら select に相当します。
選択と射影を併せたものが map-reduce でいうところの map になります。 科学計算の分野で写(map)や斜(morphism)と呼ばれる概念と同じ考え方です。
この例は C# で書くなら inputs.Select(x => x * x); となります。
選択・加工したデータは、そのまま一覧で見る場合もありますが、 集計して合計や平均などを求める場合も多いです。
map-reduce でいうところの reduce に当たります。科学計算分野ではこの手の処理を縮約(reduction)と呼んだりします。
上の例は C# で書くなら inputs.Sum();、
Aggregate で明示的に書くなら、inputs.Aggregate((x, y) => x + y); となります。
LINQ では、以下のような種類があります。
前述のとおり、データストリームに対する処理はパインプライン的につなぐことができます。 例えば、上記の例を3つ繋いでみましょう(+ もう1条件追加)。
C# で書くと、
inputs.Where(x => (x % 2) == 1).Select(x => x * x).Where(x => x > 20).Aggregate((x, y) => x + y);
となります。
C# 2.0 で追加されたイテレーターや C# 3.0 のLINQは、 データ列をストリーム的・パイプライン的に処理するための機能です。 (もちろん、これらの機能がなくても、頑張ればストリーム的・パイプライン的に処理を書けますが、結構大変です。)
C# 3.0 以降でデータ処理を書く際には、以下の2点を意識しましょう。
List や配列を作るのではなく、 IEnumerable インターフェイスとイテレーターブロック( 「イテレータブロック」 参照)を使いましょう。
例えば、C# 2.0 までで書きがちだった(IEnumrable の実装が面倒だったため)のは以下のようなコードです。 (データ列に対して、全ての要素を二乗したデータ列を作る。)
static List<int> Square(int[] source) { var results = new List<int>(); foreach (var x in source) { results.Add(x * x); } return results; }
C# 3.0 以降では以下のように書きます。
static IEnumerable<int> Square(IEnumerable<int> source) { foreach (var x in source) { yield return x * x; } }
前者のコードでは、データ列中のすべての要素を一気に読み出して、同じサイズの List を作ってしまっています。 一方で、後者のイテレーターブロックで書いたものは、必要な分だけ読み出して、必要な分だけ加工して返します。 (この辺りの挙動、詳しくは 「[雑記] LINQ と遅延評価」 を参照してください。)
もちろん、実際にはさらに、x * x の部分を外に出してしまって、以下のように書きます。
static IEnumerable<int> Select(IEnumerable<int> source, Func<int, int> filter) { foreach (var x in source) { yield return filter(x); } }
A → B → C という順でデータ処理を掛けたいなら、C(B(A(data))); ではなく、
data.A().B().C(); と書ける方が自然です。
C# 3.0 以降では、拡張メソッドを使うことで、この順で処理を書くことができます。
例えば、前節( 「データ処理の基礎的な例」 )の最後の例を、 拡張メソッドを使わずに書くと、以下のようになります。
Enumerable.Aggregate( Enumerable.Where( Enumerable.Select( Enumerable.Where( data, x => (x % 2) == 1 ), x => x * x ), x => x > 20 ), (x, y) => x + y );
Enumerable と書かなきゃいけなくなった分うっとおしいというのもありますが、そこはまだ許容するとして、
むしろ問題は、述語(Aggregate や Select などの動詞(メソッド))と補語((x, y) => x + y など)の位置が離れてしまっていることでしょう。
上記のコードでは、述語と補語のペアを同じ色で塗り分けしていますが、
階層が深くなるほど述語と補語の距離が離れて行っています。
一方で、拡張メソッドを使えば、以下のように書き換えることができます。
data.Where(x => (x % 2) == 1)
.Select(x => x * x)
.Where(x => x > 20)
.Aggregate((x, y) => x + y);
前から順に、データストリームをパイプライン的に処理している感が出ていると思います。 述語と補語のペアも、常に近い位置にかけて、対応関係を見失うこともありません。
某掲示板で出ていた例ですが、以下のような処理を考えてみましょう。
C# なら、イテレーターを使って以下のような感じで書いていきます。
まず始めに、連続した同じ値を1つにまとめる処理:
/// <summary> /// 隣り合ってる同じ値を1つにまとめてしまう。 /// </summary> /// <typeparam name="T">要素の型</typeparam> /// <param name="seq">元データ列。</param> /// <returns>隣り合った重複を削除したデータ列。</returns> public static IEnumerable<T> DistinctAdjacently<T>(this IEnumerable<T> seq) where T : struct { T? prev = null; foreach (var x in seq) { if (prev == null || !prev.Equals(x)) { yield return x; } prev = x; } }
次に、連番になっている部分をグループ化する処理は、2段階に分けて考えましょう。 まずは、階差を求めます。
/// <summary> /// 整数列の階差を作る。 /// </summary> /// <param name="seq">整数列。</param> /// <returns>値/階差のペアのデータ列。</returns> public static IEnumerable<ValueDifferencePair> Differences(this IEnumerable<int> seq) { int prev = seq.First(); int diff; foreach (var x in seq.Skip(1)) { diff = x - prev; yield return new ValueDifferencePair(prev, diff); prev = x; } yield return new ValueDifferencePair(prev, 0); }
そして、「特定の条件を満たす場所でデータ列を切る」という処理を考えます。
/// <summary> /// 特定の条件を満たすところでデータ列を分割する。 /// (条件を満たした箇所がサブ データ列の末尾になる。) /// /// 例えば、{ 1, 1, 0, 1, 0, 1 } というデータ列を渡して、 /// 「要素が 0」という条件で分割すると、結果は /// { { 1, 1, 0 }, { 1, 0 }, { 1 } } /// となる。 /// </summary> /// <typeparam name="T">要素の型</typeparam> /// <param name="seq">元データ列。</param> /// <param name="splitCondition">分割条件。</param> /// <returns>分割したサブ データ列群。</returns> public static IEnumerable<IEnumerable<T>> Split<T>(this IEnumerable<T> seq, Predicate<T> splitCondition) { var sub = new List<T>(); foreach (var x in seq) { sub.Add(x); if (splitCondition(x)) { yield return sub; sub = new List<T>(); } } if (sub.Count != 0) { yield return sub; } }
目的の連番のグループ化は、要するに、「階差が1でない場所で切る」ということになります。
(data.Differences().Split(x => x.Difference != 1) で求まる。)
これらを繋いで、結局、所望の処理は、以下のようになります。(まさに、データストリームに対するパイプライン処理になっています。)
/// <summary> /// 整数列から、連番になっている部分を、{ 初項, 項数 } のペアで抜き出す。 /// </summary> /// <param name="seq">元整数列。</param> /// <returns>{ 初項, 項数 } のペアのデータ列。</returns> public static IEnumerable<ContinuousSequence> GetContinuousSequence(this IEnumerable<int> seq) { return seq .DistinctAdjacently() .Differences() .Split(x => x.Difference != 1) .Select(x => new ContinuousSequence(x.First().Value, x.Count())); }
英語なせいでよく分からないかもしれませんが、 じゃあ、日本語になっていたらどうでしょうか。 (C# では、変数名やメソッド名に日本語を利用できます。)
seq.連続した同じ値を1つにまとめる() .階差を求める() .分割(x => x.Difference != 1) .加工(x => new 連番(x.初項(), x.項数()));
割かし、意図の伝わるソースコードになっているんじゃないでしょうか。
順序に関して、2点ほど留意点があります。
データストリームとして流れてくるデータの順序に意味があるかどうかによって、並列化のしやすさが変わります。
一般に、並列処理を行うと出力として得られるデータの順序が変わってしまいます。 なので、順序に意味がある場合には並列化しづらくなります。 (順序を記憶しておいて、並列処理後に整列しなおすとかの処理が必要。)
Aggregate で集計を掛ける場合、集計関数(Aggregate の引数として渡す関数)が満たす条件によって並列化のしやすさが変わります。
f(f(x, y), z) == f(x, f(y, z))
f(x, y) == f(y, x)
f(f(x, y), z) == f(x, f(y, z)) ←こちらだけ満たす。
データ処理には関数型プログラミングモデルがいいと言われる由縁の1つは、データの不変性(immutability)の保証。 関連して、以下のような言葉があります。
map-reduce 型のデータ処理の場合、データは不変な(immutable: 状態を変化させない)方がいいです。 その方が、プログラマーの人的ミスも減るし、スレッド間のデータ書き込み競合が起きない分、並列化しやすいので。
そんなモデルにしちゃって、データの更新はどうするの? → データをストリーム的に(1次元的に、前から1要素ずつ)読めれば十分で、蓄積しっぱなしでいい(更新不要な)種類のデータも存在する。 この手のデータに対する処理に向いたモデルかも。
フェーズを分けて考えます。
の3つに分けて考える。 要は、記録しっぱなしでどんどんデータ増やす。 それでも、更新が入って一貫性を気にするよりは、 並列化しやすい分性能よくなるかもという。
データの分析系なら使いやすいかも。 例えば:
こういうモデルなら、immutable でいい(データの新規追加はあっても、更新は別にない)はず。