はじめに
パーソナルコンピューティングの世界に出現した最初のキラーアプリケーションは表計算ソフトでした。これは当然のことです。数値が詰まった表は、データを表示・分析するための最も簡単かつ有用な手法です。
数値の配列は、数値プログラミングの基礎です。多次元配列を専門的に扱う言語(たとえば、APLや、その後継言語であるJおよびK)がいくつも作り出されたのは、驚くにあたりません。これらの言語の推進者は、配列言語を使うことで、すっきりと理解しやすいコードが書ける、と言っています。
金融プログラミングを少しでも手がけた方なら、必ず数値の多次元配列を扱っているはずです。高次元の配列はデータ構造を図式化するのも難しく、一筋縄では扱えません。コード自体も読みにくいものになりがちです。三重のネストになった、優に3画面分にも及ぶfor
ループなどを扱った覚えのある人もいるのではないでしょうか。
しかし、多次元配列のメリットは否定できません。数値データ構造がたとえ扱いにくいほどの高次元配列だったとしても、すべての軸が均質であるというメリットは何物にも代えられません。つまり、配列を高次元空間で自在に回転させ、低次元のデータスライスを切り取って、それを操作し、表示できるのです。軸と軸の間に大きな違いはありませんから、どの軸の取り扱いにも同じルーチン群を使用できます。
本稿では、多次元配列用ライブラリを設計して実装し、それを使用してプログラミングを単純化する方法について考えていきます。
Genericsについてのメモ
本稿で説明するライブラリは、JDK 1.5のJava Generics機能を使用しています。むしろ、このライブラリのCube
クラスの実装は、JDK 1.5の新しいGenerics機能に多くを依存していると言ってもよいでしょう。Cube
クラスは、T
(Cube
の各セルの型)でパラメータ化されています。この方法の利点は、Cube
の次元数を前もって決めておく必要がないことです。何次元の配列であっても、コンストラクタはそれを受け入れ、そこから再帰的にCube
を作成します。実は、これがまさに狙いどおりに運んだことに、私自身が驚きました。ちょっと魔法のようだとも思いました。このコンストラクタのコードは、きっと読者の参考になるでしょう。
私がJavaのこの機能を使ったのは、今回が初めてです。コードのあちこちで型パラメータT
を指定することになるだろうと予想していましたが、その必要がほとんどないことに驚きました。コンパイラがT
の型をかなり正確に推測してくれるようです。
Cubeクラス
今回のコードの中心は、Cube
というクラスです。Cube
は多次元配列です。単に「Cube」(立方体)と言うと3次元を連想するので混乱を招くかもしれませんが、安心してください。Cube
クラスは1次元以上、何次元でも取り扱うことができます。
Cube
は多次元配列です。これは、Javaでは「ネストした配列」を意味します。つまり、「配列の配列」や「配列の配列の配列」といったものを表します。「Cube」という名前を選んだのは、ツリー(木)の概念が入り込むのを避けたかったからです。ツリーの場合は、個々のネスト部分で形状が異なることがありますが、Cube
はそうでなく、多次元の規則正しいグリッドを含んでいます。
それでは、まずCube
の使い方を見てみましょう。その後で、具体的な実装方法を見ていきます。
Cubeの作成
まずは簡単なデータで考えてみましょう。次に示すのは典型的な配列です。
Integer data[] = { 10, 20, 30 };
これを1次元のCube
に置き換えるのは簡単です。
Cube cube = new Cube( data );
図1は、このCube
の内部のデータがどう見えるかを示しています。
私がスロット0に10、スロット1に20、スロット2に30を入れたのは偶然です。データを一様にして、それに何が起こるかを見やすくしたかっただけですが、現実にはさまざまなデータが入るはずです。
データの変更
Cube
に対してまずできることは、1つ目の軸を中心にしてCube
を反転させることです。そのためには次のようにします。
Cube r = cube.reverse();
これにより、図2のような新しいCube
が返されます。
ここで「新しいCube
が返される」と言ったことに注意してください。このクラスは関数のスタイルで書かれているので、オブジェクトが自分自身を変化させるのではなく、「オブジェクトのメソッドが新しいオブジェクトを返す」ということになります。やや奇妙に聞こえるかもしれませんが、配列プログラミングでは非常に有用な方法です。他のプログラミング言語(特に、APLなどの配列言語)から借用したアイデアですが、Javaでも有効です。
マップ操作
マップとは、ある関数をリスト中のすべての要素に適用する操作を言います。マップの結果として得られるものは、毎回の関数コールから得られた出力のリストです。たとえば、Cube
オブジェクトに対してdoubler
(2倍化)という関数をマップした場合は、図3に示すようなCube
が得られます。
Cube d = cube.map( doubler );
ところで、このdoubler
とは何でしょうか。関数のような形をしていますが、Java内で受け渡しできないので、正確には関数ではありません。むしろ、1つの関数を含む単純なオブジェクトと言えます。
Fun doubler = new Fun<Integer>() { public Integer apply( Integer n ) { return n * 2; } };
doubler
クラスは、apply()
というメソッドを1つだけ含むFun
インターフェイスを実装しています。apply()
メソッドは、何らかのオブジェクトを受け取り、それを変更して、変更済みバージョンを返します。Fun
は値変換プロセスであり、この例では、数値を受け取り、それを2倍にして返します。このFun
をmap()
メソッドに渡すと、Cube
全体に変換が適用され、すべてのセルの値が2倍になります。
フォールド操作
フォールド(折り畳み)も、多次元配列に対して行える便利な操作です。マップがN次元配列を受け取ってN次元配列を返したのに対し、フォールドはN次元配列を受け取って(N-1)次元配列を返します。つまり、ある次元に沿ってCube
を折り畳みます。
例を示しましょう。最初の[10 20 30]
という配列に戻ります。次のようにして、この配列にフォールドを適用することができます。
Integer data[] = { 10, 20, 30 } Cube cube = new Cube( data ); int sum = cube.fold( plus, 0 );
フォールドの結果(10+20+30=60)が、整数型の変数sum
に格納されます。つまり、配列中のすべての要素が合計されたわけです。フォールドでは加算以外の処理を行うこともできますが、それはさておき、この例ではどのようにして合計が計算されたのでしょうか。
先ほどの例ではfold( plus, 0 )
というメソッドを呼び出しました。これは次のコードを意味しています。
int sum = 0; // 0 sum = plus( sum, 30 ); // 30 = 0+30 sum = plus( sum, 20 ); // 50 = 20+30 sum = plus( sum, 10 ); // 60 = 10+50
つまり、このコードはplus
を繰り返し呼び出して配列を圧縮した、ということです。もっと関数らしい形で表現すると、次のようになります。
int sum = plus( 10, plus( 20, plus( 30, 0 ) ) );
あるいは、+演算子を使えば、次のようになります。
int sum = 10 + (20 + (30 + 0));
フォールドの長所は、いろいろな関数を使い分け、いろいろな結果を得られることです。たとえば、和でなく積が欲しいときは、plus
と0の代わりにtimes
と1を使います。
Integer data[] = { 10, 20, 30 }
Cube cube = new Cube( data );
Integer product = cube.fold( times, 1 );
このコードからは、10 * (20 * (30 * 1))
の結果、すなわち6000が返されます。もちろん、plus
とtimes
はあらかじめ定義しておく必要があります。doubler
と同じ要領で定義できますが、引数が1つではなく、2つになる点が異なります。
Fun2 plus = new Fun2<Integer>() { public Integer apply( Integer a, Integer b ) { return a + b; } }; Fun2 times = new Fun2<Integer>() { public Integer apply( Integer a, Integer b ) { return a * b; } };
少々長ったらしい定義であることは否定できませんが、再使用可能であるのが最大のメリットです。こうしたマップやフォールドを行うプロセスをいくつも定義し、ライブラリを作っておけば、コード中で必要なものを名前で参照できます。
左フォールドと右フォールド
フォールドには、上記の方法しかないわけではありません。実は、左フォールドと右フォールドという2通りの方法があります。
先ほど説明したのは右フォールドでした(foldr
とも呼ばれます)。これが既定なので、上記のfold()
は、実はfoldr()
を意味しています。右フォールドでは、個々の要素を右から左へ結合していきます。たとえば、次の右フォールドは、
Integer data[] = { 10, 20, 30 } Cube cube = new Cube( data ); int sum = cube.foldr( plus, 0 );
次の操作と同じです。
int sum = 10 + (20 + (30 + 0)); // 60
一方、左フォールド(foldl
とも呼ばれます)では、数値を左から右へ合計していきます。
Integer data[] = { 10, 20, 30 } Cube cube = new Cube( data ); int sum = cube.foldl( plus, 0 );
これは、次のようにするのと同じことです。
int sum = ((0 + 10) + 20) + 30; // 60
もちろん、加算なら、左フォールドでも右フォールドでも結果は同じになります。加算は結合的演算であり、(a + b) + c
はa + (b + c)
と同じだからです。しかし、除算は結合的演算ではありません。さて、次のフォールドの結果はどうなるでしょうか。
Float data[] = { 10F, 20F, 30F, 40F }; Cube cube = new Cube( data ); float sum_right = (Float)cube.foldr( dividedby, 1F ); float sum_left = (Float)cube.foldl( dividedby, 1F );
このコードでは、sum_right
(右フォールドの結果)が0.375、sum_left
(左フォールドの結果)が2.6666667となります。
次元の追加
これまで扱ってきたのは1次元配列でした。簡単ですが、あまりおもしろくありません。そこで、もっとデータを追加してみることにします。2次元Cube
の作り方は1次元Cube
の作り方と同じですが、2次元分のデータが必要です。
Integer data[][] = { { 1010, 1020, 1030 },
{ 2010, 2020, 2030 },
{ 3010, 3020, 3030 } };
Cube cube = new Cube( data );
この2次元Cube
内のデータは、図4のようになります。
マップ操作は、2次元配列でも1次元配列と同じように実行できます。図4の2次元Cube
にマップ操作としてdoubler
を適用すると、図5のような結果が得られます。
フォールド操作も、2次元配列に対して支障なく実行できます。フォールドでは次元数が1つ減ることを思い出してください。1次元配列(10, 20, 30)
を折り畳むと1個の数値(60という和)になったように、2次元配列を折り畳むと、結果は1次元配列になります。
Cube f = cube.fold( 'plus', 0 );
このフォールドでは、図6に示すとおり1列の値が得られます。
軸の操作
高次元の配列では、軸を動かすと便利なことがあります。たとえば、上の2次元配列の軸を交換したくなったとします。つまり、図4の配列を図7の配列のようにしたいとします。できるでしょうか。
答えは簡単です。次のようにして、2つの軸を入れ替えるだけです。
int permutation[] = { 1, 0 };
Cube pcube = cube.permuteAxes( permutation );
{ 1, 0 }
という配列は、出力配列の組み立て方法を制御しています。この配列変更では、入力の軸#1を出力の軸#0にし、入力の軸#0を出力の軸#1にします。つまり、軸の交換を行っています。
マップとフォールドは強力な操作です。その力の源泉は、計算の実行方法を記述せず、データをたどっていく順序だけを記述するという点にあります。計算方法自体は、Fun
またはFun2
として引き渡されます。
データをたどる順序と計算方法とを分離することが、関数的プログラミングの基本です。これは、アスペクト指向プログラミングで言う「関心事の分離」という考え方にも通じます。本稿で述べたマップとフォールドは初歩中の初歩です。あとはご自分で新しい関数を作成し、データ構造を縦横にマップし、フォールドしてください。