2016年9月に次の記事を書きました。
タイトルからして引き続く記事を予告しているのですが、その予告を実行することができませんでした。タイトル中の「何か」とは「型クラス」のことです。上記の記事の最後の部分は:
関数型プログラミングにもオブジェクト指向にも関係があって、今後重要度を増すであろう「型クラス」ですが、今述べた(愚痴った)ような事情で(あと、C++のコンセプトは宙ぶらりんだし)、説明の方針も題材も定まりません。でも、いつか、何か書く、かも。
今回この記事で、予備知識をあまり仮定しないで型クラスの説明をします。言いたいことの1/3くらいは書きました。1/3でも長い記事なので、ぼちぼちと読んでもらえれば、と。書き残したことは最後に触れます。いつか、1年はたたないうちに(苦笑)、続きを書くつもりです。
内容:
- Haskellの型クラスは無視しよう
- CoqとStandard ML、それとこの記事での型クラスの書き方
- 壮絶な車輪の再発明と混乱を極める用語法
- 指標(型クラス)とはこんなもの
- 指標(型クラス)のインスタンスとはこんなもの
- 無名の指標、無名のインスタンス
- ユニット型と引数なし関数と定数値
- 型インスタンスとデータインスタンス
- 状態遷移と隠蔽型、メイヤー先生登場
- よりオブジェクト指向へ
- 関数と関数オブジェクト
- 言い残したことなど
Haskellの型クラスは無視しよう
型クラスを知りたい人は、Haskellの型クラスから勉強をはじめようとするかも知れません。それはやめましょう。
型クラスという言語機能を導入し華々しく応用した最初のプログラミング言語は、確かにHaskellです。しかし、今となってみれば、Haskellの型クラスは失敗作です。Haskellの型クラスが分かりにくいのは、分かりにくいようになっている、つまり設計が歪〈ゆが〉んでいるからです。
失敗であることを認識しなかったり、欠陥品になんか深い意味があると勘ぐったりするのは健全な態度ではありません。失敗の状況や原因を分析するか、あるいは、興味がないなら単に無視しましょう。この記事では、Haskellの型クラスは無視します。失敗の状況や原因に興味があるなら、次の記事を参照してください。
- 入門的ではない型クラスの話:Haskellの型クラスがぁ (´^`;)
- オーバーロードは何故にかくも難しいのか:Haskellの成功と失敗
- Haskellの型クラスに関わるワークアラウンド
- JavaScriptで説明するオーバーロード解決
Haskellの型クラスのダメな所は、“オーバーロード(名前・記号の多義性)機構”と“構造の記述”という2つの別々なことを、一緒に実現してしまった点です。当初は、「一粒で二度おいしい」と嬉しかったのですが、時間がたてば問題点が現れてきます*1。
この記事のタイトルにある「まともな型クラス」とは、オーバーロード機構は除外して、構造の記述だけを目的とした記法と概念のことです。オーバーロードは、構造の記述とは切り離して別に考えるべきなのです(この記事ではオーバーロードは扱いません)。この記事が「まともな『入門記事』」かどうか怪しいけど、「僕が思う『まともな型クラス』を解説する記事」です。
CoqとStandard ML、それとこの記事での型クラスの書き方
型クラスが何であるかの説明をまだしてませんが、とりあえず目を慣らす意味で、CoqとStandard MLの型クラスの書き方を眺めておきましょう、眺めるだけでいいです。Coqでは実際に「型クラス〈type class〉」と呼んでますが、Standard MLでは「指標〈signature〉」という呼び名です。
Class Collects := { e: Type; ce: Type; empty : ce; insert : e -> ce -> ce; member : e -> ce -> bool }.
signature Collects = sig type e type ce val empty : ce val insert : e -> ce -> ce val member : e -> ce -> bool end
よく似てますが、書き方は微妙に違います。その違いを列挙します。
Coq | Standard ML | |
---|---|---|
型クラスのキーワード | Class | signature |
束縛を作る記号 | := | = |
ブロック構造 | { と } | sig と end |
宣言の区切り記号 | セミコロン | 空白・改行 |
型の宣言 | 名前 : Type | type 名前 |
値の宣言 | 名前 : 型 | val 名前 : 型 |
型クラス定義の終端記号 | ピリオド | なし |
けっこう事細かに指摘しましたが、違いが重要なのではありません。重要なことは、
- そんな違いなんてどうもいい!
ということです。
プログラミング言語は、それぞれに違った構文を持つので、その違いにどうしても目が行きがちですが、概念を理解するときは、構文的な違いに拘るのは得策ではありません。「どうでもいい」と考えることがとても重要です。
さて、この記事では、CoqでもStandard MLでもなく、その中間くらいの記法を採用します。
signature Collects := { type e; type ce; value empty : ce; value insert : e -* ce -* ce; value member : e -* ce -* bool }
これは、概念としての型クラスを説明するための擬似言語です。Coq、Standard ML、擬似言語の構文を比較すると:
Coq | Standard ML | この記事では | |
---|---|---|---|
型クラスのキーワード | Class | signature | signature |
束縛を作る記号 | := | = | := |
ブロック構造 | { と } | sig と end | { と } |
宣言の区切り記号 | セミコロン | 空白か改行 | セミコロン |
型の宣言 | 名前 : Type | type 名前 | type 名前 |
値の宣言 | 名前 : 型 | val 名前 : 型 | value 名前 : 型 |
関数型構成子の記号 | -> | -> | -* |
型クラス定義の終端記号 | ピリオド | なし | なし |
一番違和感があるのは、矢印'->'の代わりの'-*'でしょう。これについては最後のほうで説明します。宣言を区切る記号はセミコロン';'ですが、空な宣言を認めるとして、次のように余分なセミコロンを書いてもいいとしましょう*2。
signature Collects := { ; type e; type ce; ; value empty : ce; value insert : e -* ce -* ce; value member : e -* ce -* bool; }
壮絶な車輪の再発明と混乱を極める用語法
オーバーロード機構を切り離した「構造を記述する記法と概念」としての型クラスは、多くのプログラミング言語の言語機能やプログラミング技法(デザインパターン)として既に実現されています。要するに、大して珍しいものではないのです。
しかし、それぞれの言語の文化圏において特有な形で型クラスが実現されているので、ほぼ同一の概念・機能がまったく別物のように見えてしまいます。型クラス関連のキーワードを、Standard ML、Haskell、Javaから拾って並べてみましょう。
Standard ML | Haskell | Java | |
---|---|---|---|
型クラス | signature | class | interface |
型クラスのインスタンス | structure | instance | class |
型クラスのあいだの変換 | functor | 特になし*3 | Adaptorパターン |
横に並んだ概念達(1行目なら signature, class, interface)は、まったく同じとは言いませんが、とても似ています。その類似性と多少の違いをこの記事でハッキリさせます。
オブジェクト指向でのクラス〈class〉を、Haskellではインスタンス〈instance〉、Standard MLではストラクチャ〈structure | 構造体〉と呼ぶあたりは、なかなかにややこしくて混乱を招く可能性が高いです。この記事内では、「型クラス」よりむしろ「指標」を使うのは、少しは混乱が減るかと思ってです。
「型」「クラス」「インスタンス」などと言う言葉は、何か特定のモノ・概念を指すのではなくて、ある文脈において、モノ・概念の役割を表す言葉に過ぎません。文脈が変われば異なる解釈になります。特定プログラミング言語でのキーワードや記号、特定コミュニティ内での用語法(所詮は隠語です)には拘らない、ここでもう一度叫びましょう。
- そんな違いなんてどうもいい!
しかしそれでも、この記事内でどんな言葉を使うかは決めないといけないので、次のようにします。
Standard ML | Haskell | この記事では | |
---|---|---|---|
型クラス | signature | class | signature |
型クラスのインスタンス | structure | instance | instance |
型クラスのあいだの変換 | functor | 特になし | transformer |
指標(型クラスのこと)のあいだの変換をStandard MLではfunctor〈関手〉と呼んでいます。この変換はとても重要です。圏論用語functorをそのまま使うのはどうかな? と思うのでtransformer〈トランスフォーマー〉とします。が、今回トランスフォーマーまで説明できません。いつか続きの記事で……
Standard MLで"functor"という言葉を使うのは、割と合理的な根拠があります。実際に、圏論的な関手を誘導するからです。しかし、この状況を説明するとき「functorはfunctorを誘導する」では意味不明なので、プログラミング言語機能の名前はfunctor以外にしておいたほうが無難だと思います。
ちなみに、Prologでは、関数呼び出しの形をした構造体の名前部分をfunctor〈関数子〉と呼んでます。C++では、関数オブジェクトをfunctorと呼ぶようです。言葉の選び方/使い方とはこういうもんです。いちいち文句を言っていたらきりがない、しょうがない。
[/補足]
指標(型クラス)とはこんなもの
この記事でtypeと書いた場合、それは単に集合の意味です。type X と書いたら、「名前'X'は集合を表すつもり」という意味です -- それ以上余計なことは考えないように。
次の指標定義を見てましょう。
// とある2つの型 signature TwoTypes := { type X; type Y }
これは、「集合を表す2つの名前'X', 'Y'がある」という意味しかありません。そのような2つの名前をまとめてTwoTypesと名付けているので、指標定義全体の意味は:
- 集合を表す2つの名前'X', 'Y'をまとめて、それをTwoTypesと呼ぼう。
もうひとつ指標定義をみてみましょう。
// とある1つの関数 signature OneFunction := { type X; type Y; function f: X -> Y }
この指標定義は、次の意味です。
- 集合を表す名前'X'がある。
- 集合を表す名前'Y'がある。
- 関数を表す名前'f'がある。
- 'f'が表す関数の引数の型(値の集合)は'X'で、戻り値の型(値の集合)は'Y'だとする。
- 'X', 'Y', 'f'をまとめてOneFunctionと呼ぼう。
指標とは、名前の集合と、それらの名前達が何を表すかの約束のことです。名前が表すものは二種類しかありません。型〈type〉か関数〈function〉です。二種類ですよ、二種類。もう一度言います。
- 指標内の名前が表すものは型〈type〉か関数〈function〉のどちらか。
指標定義に現れるキーワードは、typeとfunction、そしてsignatureです。基本はこの3つだけです。その他の記法はすべて、便利な略記法(構文マクロ)として導入されます。
指標(型クラス)のインスタンスとはこんなもの
指標は名前に関する約束に過ぎません。名前の実体はまったく決まってません。そこで、名前に実体を割り当てたものを考えて、それを当該指標のインスタンスと呼びます。
signature OneFunction := { type X; type Y; function f: X -> Y } // 1つの関数の具体例:二乗する関数 instance SquareFunction of OneFunction := { X := int; Y := int; f := (x:X):Y => x*x }
指標のインスタンスでは、指標で宣言されたすべての名前に実体を与えます。指標で宣される名前は型か関数を表すので、実際の型と実際の関数を名前に割り当てることになります。
上記インスタンス定義で、intは整数型を表します。intは単なる名前ではなくて、実体としての集合(整数値の集合)を表しています。intの他に、real(実数型)、bool(ブール型)、unit(ユニット型、後述)などは組み込みの型だとします。よく知られた算術演算や論理演算も、システム組み込みで最初から入っているとします。組み込みの型と関数は、いつでもどこでも使えるとします。
インスタンスにおける関数(ユーザー定義関数)の実体は、アロー関数記法で書きました。最近のJavaScriptではアロー関数記法が使えます。さらにTypeScriptでは型宣言*5も付けられます。簡潔で、Standard MLにも近い記法なので、これを使うことにします。アロー関数記法、おススメだよ。
Standard MLならば、上記の擬似コードをそのまま書けます(若干の構文の違いは翻訳して)。
signature OneFunction = sig type X type Y val f: X -> Y end structure SquareFunction : OneFunction = struct type X = int type Y = int val f = fn x => x*x end
TypeScriptで書いてみましょう。“そのまま”ってわけにはいかないです。
interface OneFunction<X, Y> { // type X; // type Y; f(x:X) : Y; } class SquareFunction implements OneFunction<number, number> { // type X = number; // type Y = number; f = (x:number):number => x*x; }
- インターフェイス内で型の名前だけを宣言できないので、型パラメータに変更。
- int型はないので、number型に変更。
- 意味的には、fは静的関数(static)だが、staticを付けるとエラーになるのでインスタンス関数としている。
指標のインスタンスは、TypeScriptのクラス〈class〉より、ネームスペース〈namespace〉(旧内部モジュール)に近いと思います。
namespace SquareFunction { type X = number; type Y = number; let f = (x:X):Y => x*x; }
しかし、ネームスペースをインターフェイスに対してチェックすることはできません。
ネームスペース内の名前に関する約束をインターフェイスでは書けませんが、TypeScriptの型定義構文によってある程度は書けます。
declare namespace SquareFunction { function f(x:number):number; // 関数fの引数型と戻り値型の宣言 }
でも型定義構文では、指標としての表現力が足りません。
[/補足]
無名の指標、無名のインスタンス
最近のプログラミング言語では無名の関数(so-called ラムタ式 or クロージャ)をサポートすることが多いですよね。関数名なんて要らないことがあるからです。signature TwoTypes := {type X; type Y} という書き方は、指標に'TwoTypes'という名前を付けています。しかし、いつでも名前が必要なわけではありません。無名の指標を次の形で書くことにします。
signature {type X; type Y}
先の名付け構文は、上記の無名指標と名前'TwoTypes'を':='を使って束縛(結びつけ)していますから、次でも同じです。
TwoTypes := signature {type X; type Y}
名前TwoTypesが指標の名前であることを最初に明示したいなら、
signature TwoTypes := signature {type X; type Y}
signatureが2つも要らないから、右辺側を落とせば:
signature TwoTypes := {type X; type Y}
あっ、最初に戻りました。結局のところ、書き方は次のどれでもいいのです。
TwoTypes := signature {type X; type Y} signature TwoTypes := signature {type X; type Y} signature TwoTypes := {type X; type Y}
- そんな違いなんてどうもいい!
無名のインスタンスも instance of TwoTypes {X := int; Y := int} のように書きましょう。TwoTypesは指標の名前なので、名前の代わりに指標そのものをインラインで書いてしまえば:
instance of signature {type X; type Y} { X := int; Y := int }
インスタンスに名前を付ける構文は、次のどれでもいいとします。
IntInt := instance of TwoTypes {X := int; Y := int} instance IntInt := instance of TwoTypes {X := int; Y := int} instance of TwoTypes IntInt := {X := int; Y := int} instance IntInt of TwoTypes := {X := int; Y := int}
- そんな違いなんてどうもいい!
しつこく繰り返しますが、書き方や呼び名の違いで、同一(またはほとんど同一)の概念を別物だと思いこんでしまうのは不幸なので、「どうもいい」のオマジナイで表面上の違いを吹き飛ばしましょう。
ユニット型と引数なし関数と定数値
C言語のような古い言語でもvoid型という型があります。「voidは値がないことを示す」というのはウソです。そうじゃなくて、voidはただ一つの値を持つ型です。その値が何であるかを知る必要がないだけです。(void型に関する詳しい分析は「void型とunit型とoneway呼び出し」を参照。)
関数型言語では、void型をユニット型と呼ぶことが多いです。そして、ユニット型のただ一つの値は空タプル()だとする習慣です。void型/ユニット型の唯一の値は「これだ」と決めれば何でもいいのです。値が何であるかはどうでもよくて(また「どうでもいい」だ)、値が決めてないのが困るのです。JavaScriptだと、特殊な値としてnullとundefinedがあるので、void型の値をどっちにするか悩むところです。TypeScriptの実装を見ると、voidの値はnullとundefinedの両方を許しているようです。唯一に決めてねーじゃん、それダメだよ*6。
さて、我々の擬似言語ではユニット型をunitと書くことにします。指標内に関数のプロファイル(引数の型と戻り値の型)を書くとき、f:unit -> X を f: -> X と書いていいことにします。「引数の型が書いてなかったらunit型とする」という約束です。
次の指標を考えましょう。
// oneという名前の関数 signature One := { function one : -> int }
指標Oneのインスタンス例*7を無名インスタンスとして挙げます。
instance of One { one := () => 1 }
oneは引数なしの関数なので、普通は one() のように呼び出します。プログラミング言語によっては one だけで呼び出せるかも知れません。引数なしの関数呼び出しと定数記号はどう違うのでしょうか? その答は、
- そんな違いなんてどうもいい!
です。引数なしの関数は定数と同じとみなしてもいいので、function one : -> int を、value one : int と略記することにします。指標Oneとそのインスタンスは次のように書き換えられます。
// oneという名前の値 signature One := { value one : int } instance of One { one := 1 }
valueは単なる略記で、functionの特別なケースであることに注意してください。
引数なしの関数と定数の違いはどうでもいいのですが、それでもウジウジと詮索したい場合は次の記事を参照してください。
型インスタンスとデータインスタンス
指標とは、名前に関する約束でした。名前の種類は、“型の名前”と“関数の名前”だけです*8。関数の特殊な形を値〈value〉と呼ぶのでした(前節)。
となると、次も立派な指標です。(realは実数型とする。)
// 2次元の点 signature Point2D := { value x : real; value y : real }
指標のインスタンスとは、指標に出現する名前に実体を割り当てるので、例えば次がインスタンスです。
instance of Point2D {x := 4.7; y := -1.0}
これって、単にデータ型とデータインスタンス(オブジェクトデータ)です。普通はデータインスタンスをこんなに丁寧に書かないで、Point2D {x := 4.7; y := -1.0} で済ませるでしょう。JSON風の構文なら Point2D {x : 4.7, y : -1.0} かな。裸のタプル (4.7, -1.0) で代用することさえできます。でも、書き方なんでどうでもいいです。指標(型クラス)とそのインスタンスの概念は、通常のデータ型(構造体型)とデータインスタンスの概念も含むのです。
次の例は、指標の内部に型名も含まれるものです。インスタンスでは、型の具体化(型名への実際の型の割り当て)も値の具体化も行います。
// 座標値がとある型である2次元の点 signature Point2D := { type T; value x : T; value y : T } instance of Point2D {T := int; x := 5; y := -1}
状態遷移と隠蔽型、メイヤー先生登場
副作用を伴う関数は純関数ではない、と言われます。まー、定義上そうなのでしょう(純関数=副作用がない関数)。しかし、副作用が状態を変化させることだとすれば、その状態遷移を純関数で表せないこともありません。次の例をみてみましょう。(boolはブール型とする。)
// ONとOFFの2状態を持つトグルスイッチ signature ToggleSwitch := { type State; function isOn : State -> bool; function isOff : State -> bool; function toggle : State -> State }
状態遷移を引き起こす関数toggleは、Stateの値を引数に受け取って、新しいStateの値を返すとします。毎回新しいState値を作るのか、それとも既存の値を上書きするのか、とかはメモリ管理や最適化の問題なので、ここでは「どうでもいい」とします。([追記]この節の最後に補足があります。[/追記])
状態遷移を関数で表せたのですが、やはり状態は特別扱いしたい気持ちもあるので、hiddenという修飾子を導入しましょう。状態を表す型の前にhiddenを付けます。(なんでhidden(隠れた)かは、すぐ後でわかります。)
signature ToggleSwitch := { // Stateは状態の型 hidden type State; function isOn : State -> bool; function isOff : State -> bool; function toggle : State -> State }
これだけでは何も嬉しいことがありません。次の約束を導入します。
- hiddenな型を“第1引数に持つ関数”の第1引数を省略してよい。ただし、functionの代わりにqueryと書く。
- hiddenな型を“第1引数と戻り値に持つ関数”の第1引数を省略し、戻り値型はunit(voidと同じ)にしてよい。ただし、functionの代わりにcommandと書く。
この約束にしたがって指標を書き換えると:
signature ToggleSwitch := { hidden type State; query isOn : -> bool; query isOff : -> bool; command toggle : -> unit }
おおおー、こっ、これは…。そうです、メイヤー先生ご推奨のCommand-Query分離されたインターフェイスです。
さらに次の約束をします。
- signatureの前にhiddenを付けると、自動的に決まった名前('This'とする)のhidden typeが宣言される。
hidden typeは省略され、表面に現れることはない(ゆえにhidden=隠れている)ので、明示的に宣言する必要はありません。上記の約束のもと、自動宣言に任せます。この形の指標を隠蔽指標〈hidden signature〉と呼びます。
hidden signature ToggleSwitch := { // hidden type の宣言は不要 query isOn : -> bool; query isOff : -> bool; command toggle : -> unit }
隠蔽指標ToggleSwitchの無名インスタンを書いてみましょう。Unitはunit型の唯一の値だとします。
instance of ToggleSwitch { This := bool; isOn := (this:This) => this == true; isOff := (this:This) => this == false; // タプルの第一成分が新しい状態、第二成分はコマンドの戻り値(常にUnit) toggle := (this:This) => ((this ? false : true), Unit) }
これはもはや、オブジェクト指向のインターフェイスとクラスなので、TypeScriptで書けます。
interface ToggleSwitch { isOn() : boolean; isOff() : boolean; toggle() : void; } class ToggleSwitchImpl implements ToggleSwitch { private _ : boolean; constructor() {this._ = false;} isOn = () => this._ == true; isOff = () => this._ == false; toggle = () => {this._ = (this._ ? false : true); undefined} }
微妙な構文的・意味的な調整が入ってますが、だいたい hidden signature → interface、instance of → class implements と置き換えただけです。
状態を、関数の引数と戻り値として受け渡すのは効率が心配で夜も眠れない人がいるかも知れません。
理想的な状況は、我々人間はホントに「状態か値かを気にしないで済む」ようになることです。大きなデータのコピー/新規作成はコストですが、賢いコンパイラが、共有/上書きして大丈夫かを判断して、コピー/新規作成を抑制してくれたら嬉しい。
しかし現実には、そこまで賢いコンパイラを作るのは難しいです。値(データ実体)そのものではなくて、値の場所を示す参照〈reference〉(安全性を増したポインター)を導入して、状態は参照型を使って受け渡す方法があります。データの操作も参照経由で上書きします。これなら効率の問題は解消されます。代償として、意図せぬデータ破壊のリスクは生じます。
ひとつのプロセスが、自分専用のデータを上書き更新し続けるような状況は、末尾再帰で処理できます。末尾再帰の最適化で、スタックフレームを新設せずに再利用します。スタックフレームまたはスタックフレームから参照される自分専用データなら、上書きしても他人に迷惑がかかりません。Erlangのサーバーは、そんな形で実装されます。
[/追記 補足]
よりオブジェクト指向へ
XとYが型(の名前)だとして、その直積型〈direct product type | ペア型 | タプル型〉を X*Y で表します。HaskellもStandard MLも'*'で直積型を表しています。数学の記法では X×Y です。
2引数(2変数)の関数のプロファイルは、f:X, Y -> Z のように書き、1つのペアを引数にする関数のプロファイルは f: X*Y -> Z と書くことにします。「2引数の関数」と「1つのペアを引数にする関数」はどう違うでしょう? 多くの場合は「そんな違いなんてどうもいい!」のですが、カリー化が絡むと注意が必要です。注意は後で述べます。今は、「2引数の関数」と「1つのペアを引数にする関数」は同じだと思ってください。
XとYが型(の名前)だとして、その直和型〈direct sum type | disjoint union type〉は X | Y とします。X | Y は、XまたはYの意味で、XとYに共通する(両方に入る)値はないとします。'*'と'|'は、型から型を作り出す演算子なんで、型構成子〈type constructor〉と呼ぶことがあります。
前節で、Command-Query分離されたインターフェイスとその実装(インスタンス)を示しました。Command-Query分離するのは望ましい習慣ですが、そうもいかないときもあります。Commandとは限らないメソッドの表現法を示しましょう。
スタックの例を使います。popが、状態遷移と同時に値も返すとします。errorはエラーを表す特別な値を含む集合(型)とします。
signature Stack := { hidden type State; type Elem; function top : State -> Elem | error; function push : State * Elem -> State; function pop : State -> State * (Elem | error); }
function top は query top に、function push は command push にできますが、popはqueryにもcommandにもなりません。引数側と戻り値側に出てくる隠蔽型(この場合はState)を消して、functionをmethodに変えます。次のように書けます。
signature Stack := { hidden type State; type Elem; query top : -> Elem | error; command push : Elem -> unit; method pop : -> Elem | error; }
commandをmethodと書いてもかまいません。hiddenをsignatureの前に移して、Stateを隠蔽しましょう。次のようになります。
hidden signature Stack := { type Elem; query top : -> Elem | error; method push : Elem -> unit; method pop : -> Elem | error; }
型Elemを型パラメータに置き換えれば、この隠蔽指標はTypeScriptインターフェイスで次のように書けます。
type error = null; interface Stack<Elem> { top() : Elem | error; push(e : Elem) : void; pop() : Elem | error; }
別な例として、先ほど例に出したPoint2Dを隠蔽指標に直して、メソッドを追加してみます。query x : -> real を field x : real と書く略記も使うことにします。「フィールド」か「プロパティ」か「データメンバ」か? んなことはどうでもいいでしょうが。
hidden signature Point2D := { // x座標とy座標 field x : real; field y : real: // 引数で指定された移動量(ベクトル)だけ移動する method moveBy : real, real -> unit }
これは、さまざまな略記が使われているので、略記無しで書けば次のようです。This * unit は事実上Thisと同じであることに注意してください。
signature Point2D := { hidden type This; function x : This -> real; function y : This -> real: function moveBy : This, real, real -> This * unit }
hiddenという修飾子は導入しましたが、最初に言ったとおり、指標で使うキーワードはtype, function, signatureの3つだけです。シンプルな概念に基づき、便利な略記を導入するくらいで、関数型からオブジェクト指向までカバーできます。守備範囲を広げるコツは、どうでもいい違いに惑わされないことです。
ここで、今までに導入した略記規則をまとめておきましょう。(注意:2引数と1タプル引数は区別しない、This * unit は This と同じ。)
略記 | 略記を展開すると |
---|---|
function 名前 : -> 型 | function 名前 : unit -> 型 |
value 名前 : 型 | function 名前 : unit -> 型 |
query 名前 : -> 型 | function 名前 : This -> 型 |
command 名前 : 型 -> unit | function 名前 : This, 型 -> This * unit |
method 名前 : 型1 -> 型2 | function 名前 : This, 型1 -> This * 型2 |
field 名前 : 型 | function 名前 : This -> 型 |
この表から、我々が、同じモノを色々な違った名前で呼びたがる傾向があることが読み取れるでしょう*9。
関数と関数オブジェクト
「どうでもいい違いに惑わされないようにしよう」と言ってきたのですが、皆さんがあまり区別してない違いでどうでもよくないものもあります。そのひとつに関数と関数オブジェクトの違いがあります。
手短な説明をするので、この節は分かりにくいかも知れません。分かりにくいときは飛ばして、ラムダ計算や関数型言語を学習した後でまた読んでください。
C言語やJavaなどの伝統的プログラミング言語で関数と呼ばれるもの、あれは確かに関数だとしましょう。副作用があるから関数じゃない、って? それはどうでもいいです。
型構成子(型=集合に関する演算)記号として、直積記号'*'と直和記号'|'を導入しました。もうひとつの型構成子記号として'-*'を導入します*10。数学的記法との対応は:
型構成子記法 | 数学的記法 |
---|---|
X * Y | X×Y |
X | Y | X + Y |
X -* Y | YX |
YXは、集合Xから集合Yへのすべての関数からなる集合です。数学的記法に掛け算、足し算、累乗の記号が使われているのが納得できないなら、次の記事を読んでみてください。
HaskellやStandard MLなどで使われる矢印記号'->'は、関数のプロファイル(引数型と戻り値型の組み合わせ)ではなくて、型構成子です。数学的なYXを X -> Y と書いているのです。これは事情を分かりにくくするので、型構成子記号'->'を'-*'に変更しました。我々の矢印'->'は、あくまで関数のプロファイルです。
Standard MLの val f: X -> Y を我々の記法で書くと value f : X -* Y です。valueは特別なfunctionの略記だったので、
- function f : -> (X -* Y)
さらにunitも明示的に書いてみれば:
- function f : unit -> (X -* Y)
HaskellやStandard MLで言っている関数とは、データオブジェクトとみなした関数 -- つまり関数オブジェクトです。関数オブジェクトの名前fとは、“集合(X -* Y)の値を取る引数なし関数”です。引数なしの関数とは値〈value〉と同じことなので、fは“関数オブジェクト値の定数”です。
次のStandard MLの宣言だとより事情がハッキリします。
- val g: X -> Y -> Z
これは、我々の書き方では次の意味です。
- value g: unit -> (X -* (Y -* Z))
gは、集合(X -* (Y -* Z))の要素を指し示す名前です。Xの要素aに対して、g a と書くと、今度は集合 (Y -* Z) の要素を指し示します。Yの要素bをとって g a b と書くと、集合Zの要素を指し示すことになり、あたかも2引数の関数を呼び出したように振る舞うのです。
g a b と書いたときの空白がくせ者で、空白が評価関数〈eval function | evaluator〉を呼び出しているのです。我々の書き方で、
- Eval_1 : (X -* (Y -* Z)), X -> (Y -* Z)
- Eval_2 : (Y -* Z), Y -> Z
とすると、g a b は次の式を意味します。
- Eval_2(Eval_1(g, a), b)
HaskellやStandard MLでは、このようなメカニズムを意識させないように巧みな構文糖衣〈シンタックスシュガー〉でくるんでいますが、かえって目眩〈めくら〉ましになっているかも知れません。HaskellやStandard MLでは、関数は現れず、関数オブジェクト(値としての関数)だけなんです。関数オブジェクトを関数として働かせているのは、評価演算子〈evaluator〉(または適用演算子)である“空白”です。
この節の説明は舌足らずでしたが、事情をシッカリ把握するにはラムダ計算のデカルト閉圏によるモデルを勉強するのがよいでしょう。
言い残したことなど
次のような話題がまだ残っています。
- トランスフォーマー
- 指標に対する様々な操作
- 相対指標、部分インスタンス、指標のパラメータ化
- プロファイル推論/指標推論
- データストアオブジェクト
- さらにオブジェクト指向
- データベーススキーマ
- メイヤー流の契約モデル
- 名前構造と名前管理
- 表明付きの指標
- 高次の指標
このなかで最も重要なのはトランスフォーマー(Standard MLのfunctor相当)です。これ、ホントゥに重要なんです。トランスフォーマーは、ある指標ともうひとつの指標を繋ぐ変換です。トランスフォーマーは、他の概念(2番目以降の項目)を統一的に説明する基礎となります。また、トランスフォーマーは、「良いプログラミングとは何か」に強い示唆を与えます。
指標を操作対象とする操作があります。例えば、指標に出現する名前のリネームとか、指標の一部分だけを取り出すとかです。2つの指標を一緒にする演算(マージ)も重要です。モジュールに対する演算や、オブジェクト指向の継承などは、指標に対する操作によりスッキリ説明できます。
最近のプログラミング言語は、“ジェネリクス”として型パラメータをサポートしますが、指標のパラメータ化は、通常の“ジェネリクス”よりさらに一般的な機構です。ただし、パラメータ化でもまだ機能不足や問題点があるので、相対指標と部分インスタンスという概念を導入して再定式化したほうがいいでしょう。
人間がいちいち型を書かなくても済む機能として型推論があります。同様に、人間がいちいちプロファイルや指標を書かなくてもいいようにするのがプロファイル推論/指標推論の機能です。現実には、ソフトウェアが推論してくれないと「やってられない」程度に面倒になると思います。
データストアオブジェクトという、特別な種類の指標とインスタンスを導入すると、状態遷移やオブジェクト指向のオブジェクトを説明しやすくなります。データストアオブジェクトは、オブジェクト指向に限らず至る所に出てくる概念です。
データベースのテーブルスキーマは特殊な指標です。テーブルが隠蔽型、カラムが関数です。データベースのライブラリやORマッパなどは、トランスフォーマーだと考えられます。指標とトランスフォーマーを使ったデータベース理論は、関手データモデルに近いものです。
Command-Query分離で名前を出したメイヤー先生は、契約による設計〈design by contract〉でも有名です。ソフトウェア仕様技術の根本を、権利と義務という比喩で極めて的確かつ鮮やかに描き出してくれました。指標はメイヤーの意味での契約書とみなせます。利用者の権利と実装者の義務を記述したものが指標=契約書です。メイヤーの契約モデルは単なる比喩にとどまらず、理論的分析にも大変に有効です。僕は、普通の証明の話でもメイヤー・モデルを使っています→「証明の“お膳立て”のやり方 2: 証明の顧客・業者モデル」
現状のプログラミング言語では(HaskellにしろCoqにしろ)、型クラス(指標)に関連する名前の扱いがうまくいってないようです。名前をどのように構造化/管理するかも考えるべき課題です。オーバーロード問題も名前の問題ですね。(「ハイコンテキストな定数・記号の解釈」も参照。)
今回扱った指標は、“名前に関する約束”ですが、それらの名前(型名または関数名)が満たすべき条件を表明〈アサーション〉として追加すると、正確な仕様記述、ソフトウェアの“テスト”/“正当性の証明”につながります。ソフトウェアの話に限らず、表明を普通の論理式とみなせば、証明支援系/証明検証系もまた型クラスをヘビーに使います(例えば「Coqで半環:アンバンドル方式の例として」参照)。
指標に出現する名前は、型名か関数名だと言いました。指標を図示すると、型名はノードのラベル、関数名は辺のラベルとなる有向グラフになります。有向グラフは1次元の図形ですね。これを2次元以上の図形にして、対応する表現を考えると高次の指標になります。表明付きの指標は、2次元の指標の特別なものです。
今回説明したように、型クラス=指標は、かなり単純な概念です。幾つかの名前を、その使い方を添えて集めただけのものですから。にも関わらず、関数型言語からオブジェクト指向言語まで、あるいは仕様記述言語などまでカバーする強力な概念装置なのです。現存する言語は、この概念装置のパワーを十分には利用しきれてないように思えます。もう少し型クラス=指標と深く付き合ってみてはどうでしょう -- と言ってこの記事を締めます。
一年数ヶ月もあいだが空かないで続きが書けるといいのですが…
*1:問題点を解決するのは無理でしょう。もとがダメだから。
*2:余分な区切り記号を認めない構文だと、最後の1行の削除で構文エラーが起きて辛い!
*3:僕が知らないだけで、Standard MLのfunctorのような仕掛けが何かあるかも知れません。
*4:By Pearson Scott Foresman - Archives of Pearson Scott Foresman, donated to the Wikimedia Foundation このファイルは以下の画像から切り出されたものです: PSF H-450005.png, パブリック・ドメイン, https://commons.wikimedia.org/w/index.php?curid=3237459
*5:TypeScriptでは、型宣言を型注釈と呼ぶようです。
*6: (void 0) === undefined は真ですが、(void 0) === null は偽です。よって、voidの値はundefinedにすべきだと思います。が、nullを、voidの値のように取り扱ってきた悪しき伝統があるので、致し方なくnullもvoid値に認めたのでしょう。
*7:"instance"が「例」だから、「インスタンス例」って日本語は変かも。
*8:指標の名前もありますが、指標内に出現する名前は(今回は)型名と関数名です。
*9:違った名前を使われると、同じモノであることを認識しにくくなるので困ったことです。が、これはどうにもならない気がします。違った名前を付けたがるのは、人間の本能的欲求みたいです。おそらく、集団を形成したいという本能の一部なのでしょう。
*10:これは圏論的指数演算の記号なのですが、どんな記号にするか、毎回頭を痛めます。「圏の指数のための中置演算子記号」参照。