数値計算業界に突如彗星の如く現れたJulia。
Rubyの動的さとC言語の速度を両立させた、東大理三卒の弁護士みたいな、そんなのアリかよって感じの言語なのだけれど、この言語、オブジェクト指向ではないのだ。
「Juliaはなぜオブジェクト指向ではないのですか?」そんな問いをよく聞かれる。
本当はよく聞かれるというのは真っ赤な偽り、私自身は一度も聞かれたことはないのだが、オブジェクト指向的に書きたいという声はtwitter等の電脳空間でちらほら目にするのだ。
私はそんな時、このように答えるようにしている。正確には、聞かれたらこのように答えたいと思っている。「Juliaはとりたててオブジェクト指向ではないんですけど、割とオブジェクト指向的なプログラミングもできるんですよ。と言うか、ある意味普通のオブジェクト指向言語を超えているんでるよ」と。なんともキレのない答えだ。
そんな回答をされても困ってしまうだろうし、私もこの回答で何かを伝えられるとはとても思わない。しかし、Juliaがオブジェクト指向的な文法を採用していないからと言って、オブジェクト指向が否定されていると誤解されたらそれはとても悲しい。私はオブジェクト指向が好きだ。一時期は熱狂していると言っていいくらいだった。オブジェクト指向検定というものが存在するとしたら3級くらいはとる自信があるのだ。逆に、Juliaがオブジェクト指向的な文法を採用していないことで、Juliaの評価が下がってしまうとしたら、それも悲しいことだ。Juliaは凄いやつなのだ。そのようなわけで、オブジェクト指向言語とは何だったかを駆け足で振り返りながら、Juliaでいかにオブジェクト指向的プログラミングが可能であるか、さらにはどのあたりが通常のオブジェクト指向言語を超えているか、ということを話そうと思う。
オブジェクト指向とは何か
「オブジェクト指向とは何か」
いきなり大それた題目である。オブジェクト指向というのはとんでもないパラダイムなのだ。オブジェクト指向という言葉について万人が合意する定義というのは思いの外少ない。[1]この辺りの議論に興味のある方は次のリンクを参照してほしい。http://practical-scheme.net/trans/reesoo-j.html
そのような中で、私は次のような立場をとる。オブジェクト指向とは、ソフトウエアを部品の集合体として構成するための手段である、と。正確には、「ソフトウェアを部品の集合体として構築する」という大目標があり、それを実現する一つの手段として「オブジェクト指向プログラミング」と呼ばれるパラダイムがあるのだ。
ソフトウェアは部品の集合体として構成されるべき、という点に関しては、ほぼ万人が合意してくれると考えてもいいだろう。もちろん程度問題はある。書き捨ての小さなプログラムをわざわざ部品の集合体として仕上げる価値はないかもしれない。それだけのコストをかけられないという費用対効果の問題かもしれないし、単に小さなプログラムで理解するのが非常に簡単なのかもしれない。しかし一定規模以上の複雑さのソフトウェアに関しては、部品の集合体として構築することは、おそらく必須である。
となると問題は、いかにすれば部品の集合体として構成できるのか、というところに行き着く。その一つの帰結がSOLID原則と呼ばれるオブジェクト指向の原則である。これはオブジェクト指向の金字塔である。これを満たせばオブジェクト指向設計をしたと胸を張って主張して良い。
抽象化
部品として取り扱えるとはどのようなことだろうか?部品の利用者は、部品の振る舞いのルールは知っておく必要はあるが、部品がなぜそのように振る舞うかの詳細について知る必要がないことをいう。これを「抽象化」と呼ぶ。
オブジェクト指向の細かい話に入る前に、2つの抽象化について語ろう。手続きの抽象化とデータの抽象化である。手続きとデータ、最も素朴なレベルでは、ソフトウェアはこの二種類の構成要素から成る。[2] … Continue reading
手続きの抽象化というのはお馴染みの関数のことである。言語によってはメソッドとかサブルーチンとかいう風に呼ばれたりもする。関数とはひとまとまりの手続きを固まりとして取り出したもののことだ。通常、ひとまとまりに呼び出した関数はブラックボックスとして扱うことができる。例えば、引数を二乗して結果を返す関数は、その内部で何を行っているか、利用者側は基本的には知る必要はない。内部で自分自身を掛け算しているかもしれないし、何かのライブラリを使って指数計算をしているかもしれないし、もしかしたら足し算を何回も繰り返しているかもしれない。(ひどい実装だ!)
しかし、そう言った内部での具体的な手続きについてを利用者は知る必要はなく、ただ関数を呼び出せば良い。内部処理をどう変えようが、入力に対する出力さえ変えなければ何をしても良い。これが手続きの抽象化の意味である。
データの抽象化には、いくつかのパターンがあるが、代表的なのは複合データ型と呼ばれるものだ。これは、複数の型を組み合わせて新しい型を作るというものだ。例えば、分数を表すデータをRationalという複合データ型で表し、Rationalは分子と分母を持ちますよ、というようなものだ。そして、このとき、分子と分母を内部的にどう保持しているかは外部からは知る必要がない。内部で二つの変数に保持しているかもしれないし、配列の1番目と2番目に入れているかもしれないし、もしかしたらハッシュテーブルに格納しているかもしれない。
さて、大事なことは複合データ型は定義しただけでは、データの抽象化として不完全だということだ。なぜなら、言語がもともと用意しているオペレータは複合データを取り扱う術を知らないからだ。言語はintとintを足し算する術は知っているが、RationalとRationalを足し算する術は知らない。当たり前だ。Rationalはプログラマが好き勝手に決めたものなので、それにどんな振る舞いを決められるのはプログラマだけだからだ。RationalはRationalと足し算するにはどうすればいいかは、プログラマが用意してあげる必要がある。データの抽象化は、それをサポートする手続きの存在が不可避なのである。そのため、抽象データを導入した時点で、手続きは概念的には少なくとも2層に別れることになる。抽象データを外部から取り扱う手続きと、抽象データと外部を仲介する手続きだ。
- データ
- データと外部を仲介する手続き
- データを外部から取り扱う手続き
データにしろ手続きにしろ、通常具体的なものである。これらに対してどのような機構を導入すれば抽象的に取り扱うことができるのか、というところがポイントになる。概念のレベルでの機構がSOLID原則で、各オブジェクト指向言語が提供している実装のレベルでの機構が、オブジェクト指向三要素(カプセル化、継承、多態)である。
求めるな、命じよ
「求めるな、命じよ。」オブジェクト指向の本質を一言で表すとしたらこれである。上の3つの例で言うと、「データ」自身しか知らない情報に関する処理は「データを仲介する手続き」が実行して必要な情報を提供すべきであり、「データを外部から取り扱う手続き」は、データの中身を知るべきではなく、ただ何かを命じて結果を取得するに留めておくべきと言うことである。
オブジェクト指向三要素
SOLID原則の前に、オブジェクト指向三要素について簡単に述べておこう。ここからしばらくは、静的なクラス指向の言語について語る。C++とか、Javaとか、C#とか、TypeScriptとかだ。その方が当面説明しやすいのだ。動的な言語についてはその後補足する。
カプセル化
カプセル化の特徴は二つである。まず第一に、データの詳細が隠蔽されている。第二に、データが手続きを所有している。この二つの要素は不可分である。
先程から話したように、データがその詳細を完璧に隠蔽すると、他の手続きは手も足も出なくなってしまう。そのため、データが気を許した一部の手続きだけには、自分の詳細にアクセスすることを許可する。その他の手続きとは、その手続きを経由してのみやりとりを行う。外部と疎通するための手続きを持つ、カプセル化されたデータのことをオブジェクトと呼ぶ。カプセルというよりも、触手がウニョウニョでている生き物のようなイメージかもしれない。
そう、データが手続きを持つこと自体は、データの詳細が隠蔽されているがための結果であり、それ自体が目的ではないのだ。
しかし、ここからが少し面白いところだが、データを隠蔽するために、言わば副次的要素として導入された手続きは、結果的に外部から見るとそのオブジェクトの性質を代表するものになっている。オブジェクトの内部でデータがどのような形で保持されていてもいいし、極端な話、オブジェクトの内部にデータはなくても構わない。外部に公開された手続きがそれらしく振る舞うのであれば、それでいい。[3] … Continue reading
オブジェクトとは、データと手続きを一体化させたものだとよく言われるが、もっと言うと、データを「振る舞い」に置き換えたものなのだ。
カプセル化を実現するために、通常アクセス指定子(public, privateなど)が定義されている。
継承
継承とは、あるオブジェクトの性質を引き継いだ別のオブジェクトをつくることだ。継承先のクラス(派生クラス)は継承元のクラス(基底クラス)の「特別なケース」として扱われる。なので、基底クラスの変数に、派生クラスのオブジェクトを代入することができる。このことを指して、継承先は継承元と「is a 関係」にあるべきだとも言われる。
カプセル化の部分で語ったように、カプセル化されたデータはもはやデータとして捉えるべきではない。振る舞いの集合体として捉えるべきだ。なので、継承で引き継ぐべきなのも、内部のデータや手続きではなくて外部から見た振る舞いだ。振る舞いを引き継ぐことを意識すると良い継承になることが多い。データと手続きを引き継ぐことを意識すると悪い継承になることが多い。(振る舞いを引き継ぐことを意識した結果、継承元オブジェクトのデータや手続きを使うのは構わない。)
多態
多態は英語で言うとポリモーフィズム。日本語にしろ英語にしろあまり耳慣れない単語だ。(えっ、お前の語学力の問題だって!?)
しかし、多態こそがオブジェクト指向の真髄であるとも言われる。多態とは、同じように見えるオブジェクトを同じように取り扱っても、オブジェクト自身の持つ性質により振る舞いが変化することを言うのだ。この性質は部品化のために大変重要だ。部品を利用する側は、部品の詳細を知らずに済む。部品自身が振る舞いを決めてくれるからだ。
多態を実現するために継承が使われる。クラスを利用する側のコードは、基底クラスの型の変数を宣言する。変数の中には、基底クラスのオブジェクトが渡されたり、派生クラスのオブジェクトが渡されたりする。クラスを利用する側のコードは、具体的に基底クラスのオブジェクトが入っているのか、派生クラスのオブジェクトが入っているかは気にせず、変数に対して何らかの命令を実行する。オブジェクトは自身の属するクラスに従い、適切な振る舞いをする。
オブジェクト指向三要素まとめ
データの抽象化をするためにカプセル化を行い、さらに継承の仕組みを使うことで、多態が実現されるという構図である。カプセル化と多態は性質について言及していて、継承は仕組みについて言及している。
SOLID原則
さあようやくSOLID原則だ。SOLID原則とは英語の頭文字を集めたものである。
- 単一責任の原則(Single responsibility principle)
- 開放閉鎖の原則(Open–closed principle)
- リスコフの置換原則(Liskov substitution principle)
- インターフェイス分離の原則(Interface segregation principle)
- 依存性逆転の原則(Dependency inversion principle)
カッコいい言葉が並んでいる。大事な順番に解説していこう。
開放閉鎖の原則(Open–closed principle)
どれが一番大事と言われたらこれである。他の4つに比べてこれは別格である。本当はこれだけ唱えていればいいのであるが、困ったことにこの原則は、それだけ言われても何のことかよくわからないのである。どんな文言なのだろうか。
「ソフトウェアは、拡張に対して開いていて、修正に対して閉じていなければならない」
日本語の勉強からやり直した方がいいのかと自分を責める必要はない。この日本語は少しばかりわかりづらい。もっと説明が必要なのだ。
拡張に対して開いているというのは、ソフトウェアに新たなパターンの振る舞いをさせるときに、既存のコードを解剖して新たな振る舞いを上手いこと組み込む必要がないということである。コードにはすでに振る舞いを拡張するための何らかの機構が用意されていて、振る舞いのパターンを追加したいときには、それを利用できるということだ。
修正に対して閉じているというのは、ソフトウェアの既存のあるパターンの振る舞いを修正するときに、他のパターンの振る舞いに影響を与えることがないということである。
魔法のような構造である。そのような構造はどうやって実現できるのか?それを実現するための手段が残りの原則で述べられている。
依存性逆転の原則(Dependency inversion principle)
非常に重要な原則なのだが、名前があまり良くないと思う。しばしば使われるこちらの言葉の方が的確だ。「抽象に依存せよ。」
ここで言う「抽象」とは、コードを外部から見たときの振る舞いのことである。普通のコードは「詳細」に依存している。詳細とは何か。具体的なコードである。
例えば、与えられた範囲の整数の逆数の和を計算するコードを考えよう。こう言った計算するときのポイントの一つに、絶対値の小さい方から足すというものがある。これは非常に大きな数字と非常に小さな数字を足したときに小さな方の数値が無視されてしまう、情報落ち誤差と呼ばれる現象を回避するテクニックだ。絶対値の小さな方から足していくと、だんだん大きくなっていって、大きな数と足す時にも無視されることがなくなる。
しかし、ループで数値をクルクル足していくプログラムを書いていると、このテクニックを使うのは意外と難しい。実際にどのような値になるかをコードを書くときに推定するのは難しいからだ。単調増加や単調減少であればわかりやすいが、そうでないときにどうするか。その問題を回避してくれるクラスを1つ作ろう。SeriesAccumulator(級数累積器)クラスである。このクラスは与えられた数値の列を内部で保持しておき、号令がかかると絶対値が小さい順に合計してくれるというクレバーなクラスだ。例はTypeScriptで書いたが、TypeScript自体に詳しくなくても支障はないと思う。
class SeriesAccumulator{
//このクラスは一連の数字を受け取り、その和を返す。
//和を計算する際に情報落ち誤差を考慮して、絶対値の小さなものから足していく。
public push(x: number): void{
this.nums.push(x);
}
public sum(): number{
this.nums.sort(this.compareFunc)//小さい順に並べる
var s = 0;
for (var i = 0; i < this.nums.length; i++){
s += this.nums[i];
}
return s;
}
private compareFunc(a: number, b: number): number{
return Math.abs(a) - Math.abs(b)
}
private nums: number[] = [];
}
function sumReciprocalSeries(a: number, b: number): number {
//逆数の和を求める。開始a, 終了b。bを含む。
var s = new SeriesAccumulator();
for (var n = a; n <= b; n++){
s.push(1/n);
}
return s.sum();
}
console.log(sumReciprocalSeries(1, 10));
さて、この例で「詳細」に依存してしまっているのが下記の部分である。
function sumReciprocalSeries(a: number, b: number): number {
var s = new SeriesAccumulator(); //ここ!!
//...
}
何が悪いかというと、逆数の和を計算するという重大な使命を持った上位メソッドが、情報落ち誤差の面倒を見る下位の部品に直接依存してしまっていることだ。もしもSeriesAccumulatorが反旗を翻し、「自分はもうpushメソッドで数字を1つずつチマチマ受け取ったりはしません。配列にまとめて渡してください。」と一方的に通達してきたとしよう。
そうすると哀れ逆数の和を計算する処理は、せっせと配列に数字を詰めてやり、SeriesAccumulatorに差し出す必要がある。やれやれ。しかも来月にもsumメソッドの返り値を文字列にすると言い出している。そのうち、JSONやXMLに詰めて返すと言い出すかもしれない。私がパースしなければならないのだろうか?
これが通常のコードでの依存関係である。上位のメソッドは下位のメソッドを使役している一方、実際には下位のメソッドに振り回される立場でもあるなのだ。どことなく中間管理職の悲哀を思わせる。
依存性逆転の原則はこの関係を良しとしない。
「あなたは部下の自由にやらせすぎている。」銀縁メガネをキラリと光らせて彼は喋る。彼は一流のコンサルタントなのだ。「あなたは上司なのだから、ただ宣言すれば良いのです。あなたのやり方に従うものだけを部下として認めるのだ、と。」
素晴らしいアドバイスを受けたあなたは厳かに宣言をする。
interface SeriesAccumulatorInterface{
push(x: number): void;
sum(): number;
}
function sumReciprocalSeries(a: number, b: number, seriesAccumulator: SeriesAccumulatorInterface): number {
//...
var s = seriesAccumulator;
//...
}
console.log(sumReciprocalSeries(1, 10, new SeriesAccumulator()));
そう、あなたはもはや部下の仕事のやり方を明確に規定した。pushの引数は数値を取ること、sumの結果は数値を返すこと、そう決めたのだ。この流儀に従わない者はもはや部下ではない。そんな奴らはただの馬の骨であり、彼らはあなたの神聖な職場に足を踏み入れることすらできない。厳格なコンパイラ警備員が阻止してくれるのだ。
これが依存性逆転の原則である。もともと上位メソッドは下位メソッドに依存していた。しかし、依存性逆転を行った結果、上位メソッドも下位メソッドも、共に同じ約束事に依存するようになった。クラス図で書くと、依存関係の矢印の向きが変わるので依存性逆転の原則と呼ばれる。しかし、逆転させるのが本質ではないように思う。共に共通の約束事(Interface)に従うようになったことが大事なのである。
部品化の重要なポイントに、差し替え可能であることが挙げられる。差し替え可能かどうかを判定する根拠が、Interfaceである。Interfaceを満たしていれば差し替え可能だ。満たしていなければ差し替え不可能だ。
そして、もう一つポイントがある。もともとはクラスの内部で作られていたSeriesAccumulatorが、引数となっていることである。この点も重要だ。いくらInterfaceに従っていても、外部から差し替えるにはそう言った手立てが必要だからだ。引数である必要はないが、何らかの形で外部から与えられる必要がる。
依存性逆転の法則が部品化にとって重要であるというのはこういったわけである。
「抽象に依存せよ。」毎晩寝る前に100回唱えておくと良い。
リスコフの置換原則(Liskov substitution principle)
この原則は難しい。言っていることはそんなに難しくないのだが、この原則との向き合い方が難しいのだ。
リスコフの置換原則とはこうだ。「基底クラスのオブジェクトは常に派生クラスで置き換え可能でなければならない。」言い換えれば、「派生クラスは基底クラスに期待される振る舞いを裏切ってはならない。」
なるほどなるほど。その通りだ。基底クラスの変数を用意し、派生クラスのオブジェクトを差し替え可能な部品として扱うからには、そうでなくてはならない。しかし、何をすればそれが満たせるのだろうか?
しばしば、継承という機能を使っていいかどうかの判定基準は、「is a関係」であると言われている。では、is a 関係を満たしていればそれでいいのだろうか?
例え話をしよう。あなたはとある企業で図形に関するアプリケーションを開発している。ユーザーが何かの図形を定義すると、その図形の面積を高精度に計算することができるのだ。ユーザーは半径5cmの円を定義したり、一辺8cmの正方形を定義したりして、その正確な面積を計算することを楽しんでいる。高精度を売りにしていながらもオプションで円周率を3.14にすることもできるので、小学校の教師からも評判が良い。(競合他社の製品はそのような自由度はないので、小学校のテストの検算には使いづらいのだ。)
さて、あなたのアプリケーションでは、基底クラスとして図形クラスが定義されている。円や正方形は図形の一部である。なので、図形クラスを継承して円や正方形のクラスを作るのは自然な考えだ。円 is a 図形、正方形 is a 図形。いいよね、継承しちゃおうよ。
さて、次なるバージョンアップでの目玉は、図形の周の長さの計算機能の追加だ。マーケティング部門の試算では、これで市場シェアを1.4倍に広げることができるのだ。来月のリリースに向け既に開発は完了しており、最終フェーズのテストが残るのみである。どうやら今回のリリースは平穏に終わりそうだ。
ところが、新製品リリースの噂を聞きつけた天文学者のグループが、惑星の公転軌道は楕円形なので、図形の選択肢にぜひ楕円を加えて欲しいと要望してきたことで状況は一変する。彼らの要望を満たせばまさに天文学的な利益が見込みめると考えた上層部は、チーフ開発者であるあなたの意見を聞くことなく、楕円の追加を約束してしまった。楕円だって図形の一部だし、大丈夫だろう。楕円 is a 図形。いいよね、継承しちゃおうよ。
さて、事情を知ったあなたは大いに困ってしまう。楕円が図形の1つであることに異論はないが、実装上の問題がある。これまで取り扱っている対象の図形は円、正方形、長方形、正三角形だったので、面積と周の長さを計算することは可能だった。楕円であっても面積の計算はそう難しくはない。ところが、楕円の周の長さの計算はとても難しい。上層部の誰かに楕円積分についての知識が少しでもあれば、このような事態には陥らなかったのだが、今更言ってもしょうがないことだ。以後二度とこのようなことが起こらないためにも、あなたが上層部まで上り詰めるしかない。となるとこの難局はむしろチャンスとなるだろう。
とはいえ、高精度の楕円積分の実装を、リリースまでのわずかな時間で完成まで持っていけるかは微妙なところである。テストだって必要だ。思い悩んだあなたは、楕円の周の長さは計算不可能だとして、-1を返すのはどうだろうかと同僚に相談する。幸い天文学者たちは楕円の面積には熱心だが、周の長さには興味がないという。
一人目の同僚はいいんじゃないかと言った。彼の言い分はこうだ。
「周の長さを計算するメソッドの返り値を見たまえ。number型になっているだろう。-1を返したってそれは満たしているわけで、何も悪い事はないじゃないか。」
class Shape{
calcArea(): number;//面積
calcCircumferenceLength(): number;//周長
}
もう一人の同僚は激怒した。彼の言い分はこうだ。
「返り値の型など糞食らえだ。誰が周の長さを計算するメソッドを呼び出して返り値をが負の数になることを期待するんだ。」
どちらの意見が正しいだろうか?それは利用者側のコードが決める。もともとShapeクラス共通の決め事として、計算時にうまくいかないことが出てきたら-1を返すものだったとしよう。そうすると利用者側のコードは-1が返ってきたときの対処が記述されているはずなので、楕円の周の長さとして-1を返しても問題なく成立するはずだ。この場合、リスコフの置換原則を満たしていると言える。
一方、これまではそんな動作をさせていないのであれば、-1が返った時の動きは通常は想定されておらず、Shapeクラスを楕円クラスで置き換えてしまっては問題が出るだろう。この場合、リスコフの置換原則を満たしているとは言えない。
つまり、派生クラスの設計者は基底クラスだけを見ていても派生クラスの正当性は判断できず、利用者側のコード、あるいは、利用者に公式に公開しているドキュメント、そのようなもので判断する必要がある。
しかし、我々クラス設計者はクラス利用者の期待をどこまで想定しなければならないのだろうか?極端な話、クラス利用者側の好きにさせていては、完璧にこの要求に応えることは無理だ。だって、振る舞いを変えたいから派生させるのだ。振る舞いの変更がどこまでも正当なものであったとしても、変更前の振る舞いをピンポイントで期待されると、その期待は間違いなく裏切られることになる。
逆に言えば、クラスの利用者とクラスの設計者が期待する範囲について合意しておけば、その範囲内で派生クラスの動きを変更して良い。しばしば、事前条件、事後条件のような言葉で語られる。派生クラスは基底クラスよりも事前条件が狭くてはいけないし、事後条件が広くてはいけない。[4] … Continue reading
このような考え方を契約プログラミングと言って、言語やライブラリでサポートされることがある。
クラスの利用者側が基底クラスの動きをピンポイントで期待すると言うのは、事後条件が極めて狭いと言うことであり、派生クラスはその狭苦しい事後条件に縛られることになる。世間一般に公開するAPIだと、この制約は厳しくのしかかって来るかもしれないが、自分のアプリケーションの内部での部品化の話であれば、そこまでガチガチに考える必要はないだろう。とはいえ、どういった点に気をつけるべきかについては知っておくべきだ。
単一責任の原則(Single responsibility principle)
クラスは単一の仕事のみの責任を負うべきであり、複数の仕事をしていてはならないと言うことだ。変更の理由が複数あってはいけないとも言われている。しかし、どちらもちょっと基準としてわかりづらいと思う。意地悪なケースを考えたらどんなに小さなクラスだって仕様変更の理由の2つや3つ考えつくだろう。
私は次の判定基準が分かり易いと思っている。
- クラスが管理している変数が1つであれば、まず間違いなく単一責任の原則を満たしている。
- クラスが複数の変数を管理していても、全てのメソッドが全ての変数に関連していれば、この場合もおそらく単一責任の原則を満たしている。
- クラスが複数の変数を管理していて、この時変数Aのみに関連するメソッドA群と変数Bのみに関連するメソッドB群に分かれたら、単一責任の原則に反している可能性が高い。
実際、多くのケースでは、このようにスッパリと分かれず、変数Aのみに関連するメソッドA群があり、変数Bのみに関連するメソッドB群もあり、変数AとB両方に関連するメソッドAB群もあると言うケースが一番多いと思うが、どのパターンに近いかを知っておけば、クラスを分割する際の目安にはなるだろう。
この原則は、部品化しようと言う発想があれば自然と実現されるだろう。クラスの変更理由など、考えれば考えるほどわからなくなってくるものだ。心構えくらいに思ってもいいかもしれない。
インターフェイス分離の原則(Interface segregation principle)
Interfaceを巨大にしてはならない、小さな複数のInterfaceに分離(分割)しなさい、と言う原則だ。これは正直おまけ感が強い。単一責任の原則を守ると自然と実現されるはずだが、一応言っておいた、くらいのものだ。心構えくらいに思って良い。
SOLID原則まとめ
SOLID原則とあったが、S(単一責任の原則)とI(インターフェイス分離の原則)は心構えくらいに思っておけば良い。重要なのはO(開放閉鎖の原則)とD(依存性逆転の原則)とL(リスコフの置換原則)だった。OLD原則と言っても良いくらいだ。さらにO(開放閉鎖の原則)も目指すべき場所を示しているだけで、実際の方策はD(依存性逆転の原則)とL(リスコフの置換原則)なのであった。D(依存性逆転の原則)を実現するにはInterfaceを定義して差し替え可能な形にすれば良いのだった。L(リスコフの置換原則)を実現するには基底クラスと派生クラスの事前条件と事後条件を明確にすれば良いのだった。つまり、これらと同等のことをJuliaで実現するにはどうすれば良いか、と言うことがわかれば、Juliaでもオブジェクト指向プログラミングが可能になる。次のセクションからはその方策を検討していく。それが結果としてO(開放閉鎖の原則)を満たしていることがわかれば、この記事の目的は達成できたことになる。
動的言語でのSOLID原則
一足飛びにJuliaに入る前に、オブジェクト指向を採用している動的言語について考えよう。例えばPythonである。ここでのSOLID原則はどのような形になるだろうか?ここでは依存性逆転の原則、リスコフの置換原則に焦点を絞ろう。
動的言語での依存性逆転の原則
静的言語では依存性逆転の原則に対して、Interfaceが決定的に重要な役割を果たした。なぜだろうか?
オブジェクト指向的には、クラスの利用者側は自分が用意した変数に具体的にどんなオブジェクトが差し込まれているかは知る必要が必要なく、ただオブジェクトに命じるだけで良いと言うのが理想である。なので、コードを書くときにはできれば呼び出すメソッドだけを指定したい。しかし、静的言語では、原則としてメソッドを呼び出すコードを書いたら、そのメソッドがきちんと型が定義したメソッドであるかどうかを厳しくチェックされる。間違った呼び出しはコンパイルすらできない。それが静的言語の良さであるが、どうしても型に縛られてしまうと言う側面はある。そしてコンパイラが許してくれる最大限に抽象的な型、それがInterfaceである。Interfaceというのは静的言語の世界では極限の抽象化ではあるが、それは静的な型チェックの制約が入った世界の中での極限である。
動的言語でははるかに自由な世界である。何と言ってもコーディング時の型チェックが存在しないのだ。そのため、PythonやRubyはオブジェクト指向言語でありながら、言語自身はInterfaceという機構を用意していない。しかし、だからと言って動的言語では依存性逆転の原則が適用できないわけではない。思い出そう。「抽象に依存せよ」だ。「Interfaceに依存せよ」ではない。
先ほどの逆数の和を求めるケースのPython版は次のようなコードになるだろう。
class SeriesAccumulator:
"""このクラスは一連の数字を受け取り、その和を返す。
和を計算する際に情報落ち誤差を考慮して、絶対値の小さなものから足していく。
"""
def __init__(self):
self.__nums = []
def push(self, x):
self.__nums.append(x)
def sum(self):
sorted_nums = sorted(self.__nums, key=abs)#小さい順に並べる
s = 0
for num in sorted_nums:
s += num
return s
def sum_reciprocal_series(a, b, series_accumulator):
"""逆数の和を求める。開始a, 終了b。bを含む。"""
s = series_accumulator
for n in range(a, b+1):
s.push(1/n)
return s.sum()
print(sum_reciprocal_series(1, 10, SeriesAccumulator()))
TypeScript版との目立った違いは、Interfaceという定義がないことくらいである。実際にはInterfaceに相当するものは存在する。ただ明示的には記述されていないだけだ。それはsum_reciprocal_seriesの中に存在する。そう、series_accumulator変数は、push(x)、sum()と呼べる必要があるということだ。この変数にその制約に違反するオブジェクトを渡してはいけない。渡すと実行時にエラーになる。Interfaceは利用者側のコードに内在している。
静的言語では、依存性逆転の原則を適用する(というよりも抽象に依存する)ためには2つのプロセスが必要だった。Interfaceの定義と、外部からの注入である。動的言語ではInterfaceの定義は必要ない。外部から注入すればそれで良い。外部から渡されたものが何なのかは知らないが、とにかく命令すれば良いのである。そうして、オブジェクトは自身に定義されたメソッドに従い、適切に振る舞うのだ。
動的言語でのリスコフの置換原則
動的言語では、コーディング時に変数に型を指定する必要がないおかげで、継承という機構に頼ることなく多態を実現することができる。オブジェクトに指定されたメソッドが定義されていたらそれで良い。これをダックタイピングと呼んだりする。「もしもそれがアヒルのように歩き、アヒルのように鳴くのなら、それはアヒルである。」
そのため、動的言語においては、継承というものが果たす役割は静的言語と比べてずっと小さい。継承の説明で述べたように、振る舞いを引き継ぐことを意識すると良い継承になることが多く、データと手続きを引き継ぐことを意識すると悪い継承になることが多い。動的言語では前者を実現するために継承を使う必要はないので、使うとしたら後者になる。別にうまく使えば後者の目的で使っても悪い事はないのだが、データや手続きを共有するだけであれば委譲という別の仕組みもあり、そちらの方が好まれる傾向にある。
ただし、リスコフの置換原則は継承という文脈で語られてはいたものの、本質的にはクラスの利用者側の期待とクラスの振る舞いに関する原則である。そのため、動的言語でも依然としてリスコフの置換原則に相当する概念は存在する。ただし、クラスの利用者側のコードに変数の型宣言のようなものはないので、必然的に言い回しが変わることになる。
リスコフの置換原則の静的言語版は「派生クラスは基底クラスに期待される振る舞いを裏切ってはならない。」であった。これを動的言語版に置き換えると、「利用されるオブジェクトは、利用者側のコードが期待する振る舞いを裏切ってはならない。」というごく当たり前の文言になる。取り立てて原則と言うほどのこともない。
継承の役割が縮小してしまった以上、リスコフの置換原則の重要度も下がってしまうのは仕方のない事だ。
動的言語でのSOLID原則まとめ
静的言語で考えていた時と比べると、動的言語ではSOLID原則の見え方が随分と変わって来る。
- 単一責任の原則、インターフェイス分離の原則
- これらは部品化を念頭におけば自然とこの方向に向かうというものである。
- リスコフの置換原則
- 取り立てて言うほどのものでもなくなった。
そうなると、もはや開放閉鎖の原則を実現するために必要なのは、依存性逆転の原則だけということになる。
- 依存性逆転の原則
- 差し替えたいオブジェクトは内部で作るのではなく外部から渡すべし。(でなければどうやって差し替えるのか?)
この依存性逆転の原則だけで、開放閉鎖の原則を満たせるのだろうか?
- 開放閉鎖の原則
- 拡張に開いており、修正に閉じているべし
すなわち、外部から渡す口を作ってあげておけば、新たな振る舞いをするオブジェクトを渡す事は簡単であり(拡張に開いている)、それぞれのオブジェクトはそれぞれのクラスで閉じた変数とメソッドを持っているので、あるクラスのメソッドの修正が他のクラスのメソッドの修正に影響を与える事はない(修正に閉じている)。
何と、動的言語でのSOLID原則と言うものは、結局のところ、部品化されたオブジェクトを自由に差し替えられるようにすべし、と言うところに帰着してしまったのである。
しかし、考えてみれば当たり前かもしれない。プログラムを部品化して、振る舞いは部品に任せると言う事は、振る舞いが決まるのはコーディング時ではなく実行時になると言う事である。そうなるとコーディング時に色々決めなければならない静的言語では継承やInterfaceなどの工夫が必要だった。実行時に全てが決まる動的言語はそのような仕組みが不要なので、結果気にすることが少なくなり、シンプルな原則になっていくのは当然のことかもしれない。
Juliaでのオブジェクト指向
いよいよJuliaだ。ここまで長々と語ってきたように、オブジェクト指向と動的言語はとても相性が良い。と言う事は、動的言語であるJuliaはきっとオブジェクト指向と相性が良いはずなのだ。
まず、オブジェクト指向三要素について話そう。Juliaにはオブジェクト指向三要素はあるのだろうか?答えから言うと、カプセル化はない、継承もない、多態はある。
カプセル化がないと言うのは致命的に思える。この記事の出発点が、データの抽象化から始まっている。カプセル化がなくて大丈夫だろうか?大丈夫だ。なぜならカプセル化に関しては、必殺「見ないふり」ができるからだ。公開されていたって良いじゃないか。必要のない時は見なければ良いだけの話だ。
Juliaが用意しているデータ抽象の仕組みは構造体だ。構造体にはprivateやpublicのようなアクセス指定子はない。言ってしまえば全部publicだ。しかし、データ抽象は依然として有用なアイデアなので、公開したくない要素を見分けるために、名前の先頭にアンダースコアをつけることにする。そして、アンダースコアの付いている要素にアクセスできるのは一部の関数に制限する。自分で規律を守って制限するのだ。
継承がないのは問題にならない。動的言語で継承があまり重要でないのは見た通りだ。
多態に関しては、多重ディスパッチという非常に強力な仕組みが提供されている。多重ディスパッチについては後ほど説明するが、この機能を採用したことにより通常のクラスベースのオブジェクト指向を一部超えているのだ。
Juliaのカプセル化
簡単な例を見よう。今回の例では多態はまだ登場しない。カプセル化のイメージだ。2次元の点を表す構造体Point2Dを考える。普通に考えたら内部にx, yと言う変数を保持するところだが、どうやらこの構造体は配列で保持しているようだ。配列の1番目がx, 2番目がyである。何か実装上の観点からそのような保持をした方がいいと言う判断になったのだろう。
しかし、2次元の点と言うものを考えたときに、内部で配列を持っていると言うことはなかなか予期できない。こういった内部情報に依存したコードが至る所に散らばっていたら、内部でのデータの保持のやり方を変えるときに多くの処理が影響を受けてしまう。そこで、内部情報にアクセスできる関数は一部に制限し、その他の関数は全てその関数を経由してデータを取得するようにしよう。
struct Point2D
_arr #公開したくない変数。配列の1番目にxを、配列の2番目にyを持っている。
end
#get_x, get_yは詳細を覆い隠している
function get_x(pt)
return pt._arr[1]
end
function get_y(pt)
return pt._arr[2]
end
function to_string(pt)
#仮に内部データの持ち方を変えてもこの関数は影響を受けない
x = string(get_x(pt))
y = string(get_y(pt))
return "(" * x * ", " * y * ")"
end
pt2D = Point2D([1, 2])
println(to_string(pt2D)) # (1, 2) と表示される。
こうすることで、Point2Dの内部変数を配列から辞書に変えたとしても、影響を受けるのはPoint2Dを作る部分と、get_x, get_y だけであり、to_stringは影響を受けなくなる。
Juliaの多態
次はJuliaの多態の仕組みを見ていこう。ちなみに、まだ多重ディスパッチは登場しない。
先程の例に下記のようなコードを追加しよう。2次元の点を表すPoint2Dの豪華版、DeluxPoint2Dだ。どのあたりがDeluxなのかと言うと、to_stringしたときに「耳あて」がつくのだ。
struct DeluxPoint2D
_arr
end
function to_string(pt::DeluxPoint2D)
x = string(get_x(pt))
y = string(get_y(pt))
return "*(" * x * ", " * y * ")*"
end
deluxPt2D = DeluxPoint2D([1, 2])
println(to_string(deluxPt2D)) # *(1, 2)* と表示される。おしゃれな耳あてがついている。
まず目を引くのは、get_x, get_yを定義していないことだ。これはダックタイピングの一例だ。Point2Dのget_x, get_yでは、型指定をしていなかった。そのため、DeluxPoint2Dではget_x, get_yを定義していないのだが、Point2Dのget_x, get_yが適用され、たまたま_arrという変数を内部に持っていたのでうまくいったのである。
次に目を引くのが、to_stringである。このケースではDeluxPoint2D用に特化された関数として定義されている。このため、Point2Dにはない特典をDeluxPoint2Dにはつけることができているのである。このように同等の関数呼び出しでもパラメータの型に最も特化された関数が選択されると言う仕組みで、多態が実現されている。
なお、余談になるが、例では上のようにしたが、私は普通このようには書かない。Point2Dの内部構成を変更したときに、DeluxPoint2Dも同様に変えなければならないことを忘れるからである。このようなケースでは委譲を使う。[5]何となく、マクロを使ったらもっとエレガントに解決できる気もするが、深入りしない。
struct DeluxPoint2D
_pt2D::Point2D #内部でPoint2Dを保持する
end
function DeluxPoint2D(arr)
DeluxPoint2D(Point2D(arr))
end
#Point2Dと同じでいい仕事は全部Point2Dに丸投げ
function get_x(pt::DeluxPoint2D)
return get_x(pt._pt2D)
end
function get_y(pt::DeluxPoint2D)
return get_y(pt._pt2D)
end
#変えたい動作だけ定義する。
function to_string(pt::DeluxPoint2D)
x = string(get_x(pt))
y = string(get_y(pt))
return "*(" * x * ", " * y * ")*"
end
deluxPt2D = DeluxPoint2D([1, 2])
println(to_string(deluxPt2D)) # *(1, 2)* と表示される。
Juliaの多重ディスパッチ
いよいよJuliaの真骨頂、多重ディスパッチを見ていこう。ディスパッチとは処理を振り分けると言う意味だ。多重ディスパッチとは、複数の引数オブジェクトの型に従って使われるメソッドが決定されることだ。どういうことだろうか?
いわゆるオブジェクト指向の書き方「object.method」形式だと、使用されるメソッドはオブジェクトで決まる。object.method(…)という形式の記述は、実質的にはmethod(object, …)と言う関数呼び出しと等価である。そのため、第一引数という単一の引数オブジェクトのみに依存してメソッドが挿しかわるといることで、単一ディスパッチと呼ぶのだ。
多重ディスパッチがどのように働くかを見るのはコードを見てもらうのが早いだろう。先程Point2DとDeluxPoint2Dを考えたが、これに点を移動させるメソッドmoveを作るようにしよう。さて、Point2Dはmoveで受け取った引数の通りにそのまま動くが、Delux版は何とmoveで受け取った引数の1.1倍の距離を動くのだ。とんだじゃじゃ馬だ。さすがデラックスの名に恥じない。
これではまだ単一ディスパッチだ。多重ディスパッチにするにはもう1つ引数がいる。moverというものに登場してもらうようにしよう。moverは移動距離dx, dyを持つ。moverにも普通のmoverとデラックス版moverがある。普通のmoverは特に何の作用も及ぼさないが、デラックス版のmoverは、普通のPoint2Dと組み合わさると移動距離を1.2倍にしてくれて(とんでもないじゃじゃ馬だ!)、さらにデラックス版のPoint2Dと組み合わさるとその力はさらに増幅され、移動距離はなんと729倍にもなるのだ。(すごいぞ!)
このバグとしか思えない仕様を実装してみよう。
struct Point2D
_arr
end
struct DeluxPoint2D
_arr
end
struct Mover
dx
dy
end
struct DeluxMover
dx
dy
end
#get_x, get_y, to_stringは先ほどと同じなので省略
function move(pt, mover)
new_x = get_x(pt) + mover.dx
new_y = get_y(pt) + mover.dy
return Point2D([new_x, new_y])
end
function move(pt::DeluxPoint2D, mover::Mover)
new_x = get_x(pt) + mover.dx * 1.1
new_y = get_y(pt) + mover.dy * 1.1
return DeluxPoint2D([new_x, new_y])
end
function move(pt::Point2D, mover::DeluxMover)
new_x = get_x(pt) + mover.dx * 1.2
new_y = get_y(pt) + mover.dy * 1.2
return Point2D([new_x, new_y])
end
function move(pt::DeluxPoint2D, mover::DeluxMover)
new_x = get_x(pt) + mover.dx * 729
new_y = get_y(pt) + mover.dy * 729
return DeluxPoint2D([new_x, new_y])
end
pt2D = Point2D([1, 2])
pt2D = move(pt2D, Mover(1, 1))
println(to_string(pt2D)) # (2, 3)
deluxPt2D = DeluxPoint2D([1, 2])
deluxPt2D = move(deluxPt2D, Mover(1, 1)) #1.1倍
println(to_string(deluxPt2D)) # *(2.1, 3.1)*
pt2D = Point2D([1, 2])
pt2D = move(pt2D, DeluxMover(1, 1)) #1.2倍
println(to_string(pt2D)) # (2.2, 3.2)
deluxPt2D = DeluxPoint2D([1, 2])
deluxPt2D = move(deluxPt2D, DeluxMover(1, 1)) #729倍
println(to_string(deluxPt2D)) # *(730, 731)*
moveという関数が同じ数の引数でいくつも定義されている。それぞれ特定のデータ型と結びついており、どのデータ型に紐づくメソッドが選択されるかは、実行時の型情報に従いJuliaが決める。
静的言語でいうところのオーバーロードに似た印象を持つかもしれない。しかし、静的言語の場合は通常は変数の型に依存してメソッドが決定される。変数に入っている値の型ではないのだ。なので、いくら基底クラスと派生クラスでオーバーロードが定義されていても、基底クラスの変数を渡している限り、派生クラスのオブジェクトが代入されていても、派生クラスのメソッドは呼ばれない。[6] … Continue reading
一方、動的言語では通常オーバーロードというものは存在しない。引数に型を指定することがないからだ。
そのため、静的言語にしろ動的言語にしろ、引数の型に応じて動作を変えようと思うと、引数の実行時の型を読み取ってメソッド内部で条件分岐をするしかない。これは機能を拡張したいときに(派生型を増やしたいときに)既存のメソッドの中身をいじることになり、開放閉鎖の原則に反する。
多重ディスパッチは動的言語でありながら必要に応じて型指定できるJuliaならではの機能だと言える。(Julia以外にこの機能を実装している代表的な言語がCommon LispのオブジェクトシステムCLOSで、これもまた動的な言語だ。)
なお、クラス指向の静的なオブジェクト指向言語でも、二つのクラスの実行時の型に応じて呼び出しメソッドが決まる、二重ディスパッチは可能である。これはVisitorパターンと呼ばれるやり方で、良く確立されたやり方なのだが、欠点がある。
1つ目は、実装がややこしいという点である。二重ディスパッチはそれでも頑張ればついていけないことはないが、三重以上のディスパッチは煩雑で手に負えないと思う。(実際にはやったことがないので何とも言えない。原理的には可能だとは思うが。)
2つ目は、クラスの拡張の方向性が非対称であるという欠点がある。こちらはより深刻な問題で、2つのクラスの一方しか拡張に開いていないのだ。
多重ディスパッチはどちらの問題も存在しない。メソッドは必要な型の組み合わせ分だけ、ただ並列に並べていけばよいのだ。
Juliaでのオブジェクト指向まとめ
Juliaはクラス指向のオブジェクト指向文法を採用していない。抽象データが手続きを所有しているという機構は確かに強力で直感的なのだが、制約も大きく複雑になる。そのため、あえてデータは公開状態にしておき、手続きとデータは引数を通じてゆるく関連させておくにとどめておく。そうした上で、動的言語の利点を生かし、適切なメソッドが選択される多重ディスパッチの機構を提供しておく。結果としてJuliaが提供している機構は驚くほどシンプルだ。
その上で私は、データをある程度は隠蔽しておくことを推奨する。構造体の変数が、その構造体の公開インターフェスとしてふさわしいとは限らないからだ。相応しくないと判断した場合は、先頭にアンダースコアをつけるなどの命名規則で隠蔽する意図があることを明確にしておき、限られたメソッドを通じてしかアクセスさせないように意識する。そのメソッドが構造体の変数の代わりに公開インターフェースとして構造体の振る舞いを宣言する働きを担う。
さらに、SOLID原則を実現するべく、次の点に気をつける。メソッドの内部で直接構造体をつくっている箇所があったら、差し替えの必要がないか検討すること。もしも差し替える必要が今後出てきそうであれば、引数などの形で外部から与えること。TypeScript, Pythonで例示した、逆数の和を計算する処理のJulia版を書いておく。Python版とは特によく似ている。object.method形式になっているかどうか、というくらいの違いである。
struct SeriesAccumulator
_nums
end
function SeriesAccumulator()
return SeriesAccumulator([])
end
function push!(accum::SeriesAccumulator, val)
Base.push!(accum._nums, val)
end
function sum(accum::SeriesAccumulator)
sort!(accum._nums, by= x->abs(x))
s = 0
for num in accum._nums
s += num
end
return s
end
function sum_reciprocal_series(a, b, seriesAccumulator)
"""逆数の和を求める。開始a, 終了b。bを含む。"""
s = seriesAccumulator
for n in a:b
push!(s, 1/n)
end
return sum(s)
end
print(sum_reciprocal_series(1, 10, SeriesAccumulator()))
以上が、私の話したかった内容である。Juliaは決してオブジェクト指向を採用していないわけではない。それどころか、現行存在する最も強力なオブジェクト指向言語の1つと言ってもいい。その実現のために採用した文法が、一見オブジェクト指向らしくは見えないというだけの話なのだ。Juliaでのめくるめくオブジェクト指向ライフをぜひ楽しんでいただきたいと思う。
おまけ
どうしても、どうしてもobject.method形式で呼びたいのだという人のために、このセクションを設けた。先ほど言ったように、object.method形式はJuliaの武器である多重ディスパッチが発揮できない。そのため、あまりおすすめはしない。しかし、object.method形式での呼び出しが確かに自然に思えるケースというのもある。そのため、Juliaでobject.method形式で呼ぶやり方も紹介しておこう。
Point2Dを再度例に出そう。今回は先程の仕様は忘れる。Point2Dはmoveという処理で素直に指定された距離だけ移動する。1.1倍などしない。
さて、Point2Dは内部の変数としてx, yを持つとしよう。さらに、get_x, get_y でx, yの値を取得でき、to_stringで(1, 2)のような形式で文字列を返し、moveメソッドで移動する。一気に実装してしまおう。
struct Point2D
get_x
get_y
to_string
move
end
function Point2D(x, y)
function get_x()
return x
end
function get_y()
return y
end
function to_string()
str_x = string(x)
str_y = string(y)
return "(" * str_x * ", " * str_y * ")"
end
function move(dx, dy)
return Point2D(x + dx, y + dy)
end
return Point2D(get_x, get_y, to_string, move)
end
pt = Point2D(1, 2)
println(pt.get_x()) # 1
println(pt.get_y()) # 2
pt = pt.move(1, 1)
println(pt.to_string()) # (2, 3)
これは一体何をやっているのか?
これを理解するにはクロージャという機構を理解する必要がある。
クロージャ
クロージャというのは、分かってしまえば大したことはないのだが、分かるまでは全くわからないという稀有な概念だ。なので、この文章を読んでわからなくても自信を喪失する必要はない。なるべくわかりやすく説明しようとは思うが、自分自身がわからなかった頃の気持ちを忘れている気がするので、うまく説明できるかはわからない。
クロージャというのは、一言で言うと「状態を持った関数」のことだ。わからないね?わかったと言ってくれた人は元から知っている人だろう。ここでいきなりクロージャのサンプルを見せたりはしない。順を追って説明していこう。
さて、Juliaでは関数は第一級のオブジェクトだと言われる。これはどういう意味かというと、関数を値と同じように、変数に代入したり、引数に渡したりできるということだ。一部の言語では関数は二級市民だ。彼らはただ定義されるだけであり、数値や文字列ように変数に代入することはできない。しかし、Juliaでは可能なのだ。
次の例では、関数squareを変数fに代入している。fに引数を与えると、squareの関数が実行されたのと同じことになる。
function square(x)
return x * x
end
f = square
println(f(3)) #9と表示される
次の例は、関数を引数として渡している。与えられた関数を、引数に2回適用する。
function double_apply(f, x)
return f(f(x)) #fをxに2回適用している
end
println(double_apply(square, 3)) #squareが2回適用されて81と表示される。
あまり役に立たない例を出したが、関数を変数に代入したり引数に渡すことのイメージは掴んでもらえたと思う。関数を引数として渡せることは非常に強力な抽象化となる。例えば、積分の計算を行うときに、積分計算のアルゴリズムと被積分関数を分けることができるのだ。
では次に、関数を返す関数というものを考える。関数は変数に代入したり引数にできたのだ。当然返り値にもできる。次の例は、与えられた引数が0以上だったら「引数を2乗する関数」を、負だったら「引数を2倍する関数」を返す関数だ。何の役に立つかはよくわからない。
function make_func(val)
function square(x)
return x * x
end
function double(x)
return 2 * x
end
if (val >= 0)
return square
else
return double
end
end
f = make_func(1)
println(f(3)) #fにはsquareが入っているので9
g = make_func(-1)
println(g(3)) #gにはdoubleが入っているので6
さて、次のステップでいよいよクロージャの登場だ。まず、クロージャは関数を返す関数によって作られる。そして、返される関数は変数を保持できるのだ。次の例を見て欲しい。
これがクロージャの定義のコードだ。make_counterは関数だ。内部にローカル変数countと、関数count_upを持っている。make_counterは関数count_upを返す。
主役は返される関数count_upだ。これがクロージャである。count_upがただの関数ではなくクロージャと呼ばれる所以は、count_upは、外側の変数countを参照していることにある。ひとまず定義側の説明はここまでだ。
function make_counter()
count = 0
function count_up()
count += 1
return count
end
return count_up
end
次にこれがクロージャの利用者側のコードだ。変数counterにクロージャが入っている。counter何度も呼び出すと、数値がカウントアップすることがわかる。
counter = make_counter()
println(counter()) # 1
println(counter()) # 2
println(counter()) # 3
明らかにcounter変数に入っている内部関数count_upは、make_counterのローカル変数countの値を保持している。通常ローカル変数は、関数を抜けると破棄される。しかし、countは内部関数count_upにより参照されている。この場合、関数を抜けても破棄されないようになっているのだ。このように、自身の周囲の環境を保持している関数のことをクロージャと呼ぶ。
私はなかなかクロージャが理解できなかったが、むしろ理解することを拒否していたのかもしれない。ローカル変数というのは見ていて安心する変数だ。それは関数を抜けたらきれいさっぱり忘れて良いからだ。その概念を根底から覆すのがクロージャなのだ。そのようなわけで私は最初は不安に思っていたが、慣れたらただの関数なのかクロージャを返す関数なのかはすぐにわかるようになるので、あまり気にならなくなる。
大して代わり映えのしない例だが、もう1つクロージャの例を載せておこう。先ほどの例との違いは、クロージャを作る関数とクロージャ自身がそれぞれ引数をとっていること、returnを明示していないことくらいである。Juliaの文法で、returnを明示していない場合には最後の式が返り値となるので、この場合、関数adderが返り値となる。
function make_adder(init_value)
sum = init_value
println("adderが生成されました。初期値は" * string(init_value) * "です。")
function adder(val)
sum += val
println(string(val) * "が加算されました。合計は" * string(sum) * "です。")
end
end
a = make_adder(100) #adderが生成されました。初期値は100です。
a(1) #1が加算されました。合計は101です。
a(2) #2が加算されました。合計は103です。
a(3) #3が加算されました。合計は106です。
さて、クロージャは何かに似ていないだろうか?そう、クラスの定義にとてもよく似ているのだ。Pythonで似たようなクラスを書くとするとこうなるだろう。
class Adder:
def __init__(self, init_value):
self.__sum = init_value
print("adderが生成されました。初期値は" + str(init_value) + "です。")
def add(self, val):
self.__sum += val
print(str(val) + "が加算されました。合計は" + str(self.__sum) + "です。")
a = Adder(100)
a.add(1)
a.add(2)
a.add(3)
そっくりだ!生き別れの兄弟と言ってもいいだろう。クラスではメンバ変数をメンバメソッドが共有する。クロージャでは内部変数を内部メソッドが共有する。このクロージャの仕組みを使ってクラスもどきを作ろうというのである。
もう一度、最初の例に戻ろう。まず最初に構造体を定義している。ここで普通の構造体と違うのは、ここに定義されているのは構造体の内部データではなく、構造体の振る舞いを定義するメソッド名だということである。データはクロージャで管理するので構造体には入ってこない。通常は構造体のデータには「構造体名.変数名」でアクセスするが、構造体の変数に入っているのがデータではなくクロージャになるのだ。
struct Point2D
get_x
get_y
to_string
move
end
これがPoint2Dのコンストラクタである。コンストラクタの内部で4つのクロージャを定義している。このクロージャはローカル変数を持たず引数の情報のみを保持している。構造体が作られた時に与えられた引数の値を保持しておき、利用するのである。
function Point2D(x, y)
function get_x()
return x
end
function get_y()
return y
end
function to_string()
str_x = string(x)
str_y = string(y)
return "(" * str_x * ", " * str_y * ")"
end
function move(dx, dy)
return Point2D(x + dx, y + dy) #moveメソッドはオブジェクト自身の情報は変更しない。新しいオブジェクトを作る。
end
return Point2D(get_x, get_y, to_string, move)
end
そしてこれがPoint2Dの利用者側のコードである。あたかもPoint2Dというクラスを定義したかの如くpt.get_x()などとメソッド呼び出しができている。
pt = Point2D(1, 2)
println(pt.get_x()) # 1
println(pt.get_y()) # 2
pt = pt.move(1, 1)
println(pt.to_string()) # (2, 3)
moveについて注文がつくかもしれない。今回作ったクロージャはJuliaの構造体のデフォルトに倣い、不変にしている。そのため、moveを呼び出してもオブジェクト自身の情報は何も変わらず、新しいオブジェクトを作って返却しているのだ。
しかし、通常のオブジェクト指向では、オブジェクトが管理する変数の情報は可変であるため、pt = pt.moveのように書く必要はない。ただ、pt.moveのように呼び出せば良いのだ。どうせ似せるのであればそこまでした方がいいかもしれない。破壊的メソッドであることを明示するためメソッド名はmove!としておく。
function Point2D(x, y)
_x = x
_y = y
function get_x()
return _x
end
function get_y()
return _y
end
function to_string()
str_x = string(_x)
str_y = string(_y)
return "(" * str_x * ", " * str_y * ")"
end
function move!(dx, dy)
_x += dx
_y += dy
end
return Point2D(get_x, get_y, to_string, move)
end
pt = Point2D(1, 2)
println(pt.get_x()) # 1
println(pt.get_y()) # 2
pt.move!(1, 1)
println(pt.to_string()) # (2, 3)
もはや呼び出し側のコードは普通のオブジェクト指向言語としか思えないだろう。Juliaは何だって許容してくれるのだ。最高だ。Juliaで思い思いのオブジェクト指向ライフを楽しんで欲しい。
References
↑1 | この辺りの議論に興味のある方は次のリンクを参照してほしい。http://practical-scheme.net/trans/reesoo-j.html |
---|---|
↑2 | Lisperはコードはデータだと口を挟んでくるかもしれないが、その話は以前やったのと、今回はそういう話をしたいわけではないので無視しておこう。 |
↑3 | 内部にデータを持たずにオブジェクトらしく振る舞うことができるかどうか、という議論に興味がある方は、「チャーチ数」という言葉で検索してみてほしい。 |
↑4 | 楕円の例で言うと、Shapeクラスがこれまで計算値として0以上の数を返すと言う事後条件だったところに対して、楕円クラスは負の数も返すと言うより広い事後条件にしてしまったのだ。 |
↑5 | 何となく、マクロを使ったらもっとエレガントに解決できる気もするが、深入りしない。 |
↑6 | これが静的言語の本質的な制約なのか、単に仕様をそう決定しているだけの問題なのか、私にはわからない。ただ、クラスに属するメソッド呼び出しを動的に切り替えるには、C言語でいうところの関数ポインタのような機構で実現できそうだと想像できるが、関数の引数の部分まで動的に選択されるような機構をコンパイル時に作るのは想像できない。 |