読者です 読者をやめる 読者になる 読者になる

「変数に型がないということの利点について考える」の問題について考える

id:perlcodesample さんの 変数に型がないということの利点について考える - サンプルコードによるPerl入門 から。

ううむ。

けれども、型がないということは、本当に素晴らしいことです。
型がないことによって、たくさんの面倒から解放されるからです。

冒頭のこれが、「静的型付き言語にはメリットが(ほとんど)ない」と言っているように思えてしまいます。 コメントのやり取りを見ても、ある程度そう考えているように受け取れます。

勘違いなどが多く見られたので、補足というか、反論というか、そんな感じのことを書きます。

追記:
ごく一部、このエントリを「動的型付き言語と静的型付き言語を比べて、静的型付き言語の方が素晴らしい言語である」ということを言うためのものだと勘違いしている人を見かけました。 このエントリは、そこについては言及していません。 あくまで、元記事で「動的型付き言語のメリット」とされている部分について、「そうではないよ」と指摘するためのエントリです。

どのような型の値でも代入できる?

これは、「変数に型を明示する必要がない」ということですよね。 型推論によって、静的型付き言語であっても変数の型は明示不要です。 例えば、Perl

my $str = 'Hello';
my $num = 1;
my $nums = [1, 2, 3];
my $person = {age => 2, name => 'taro'};
my $ua = LWP::UserAgent->new;

という例を挙げていますが、静的型付き言語であるF#でも、

let str = "Hello"
let num = 1
let nums = [1; 2; 3]
(* 追記: Perlの方の$personをクラス的な何かと勘違いしてたので、連想配列に合わせました。
let person = { Age = 2; Name = "taro" }
*)
let person = Map.ofList [("age", box 2); ("name", box "taro")]
let ua = LWP.UserAgent()

と、ほとんど変わらない記述が可能です。

記述量がとても短くなる?

これは、上の例で説明したように、型推論機能さえあればほとんど問題になりません。 型推論が不完全のくだりは取り下げているので触れません。

また、コンパイル時間のことを挙げていますが、対話環境を持った処理系であれば、 一部を手軽に確認することが可能です。 そして、ScalaやF#など、対話環境を持った静的型付き言語はいろいろと存在します。

統合開発環境でのメソッドの自動補完機能を実装したことがないので、 その実装が難しくなるかどうかは判断がつかないです。

変数に型がないと変更に強い?

ここで言っているのは、おそらくこういうことでしょう。

ClientA ua = c.client();

こういうJavaのコードがあったとして、clientの返す型がClientBに変更された場合、

ClientB ua = c.client();

という変更が必要になる、と。 そうであれば、ここでも型推論により解決されます。

let ua = c.client()

それはともかくとして、このように局所的な例だけで変更に強い、としてしまうのには違和感が残ります。 「変更に強い場合がある」程度ならいいのですが、それだとメリットになりませんよね。

関数のオーバーロードが不要になる?

動的型付き言語ではオーバーロードが不要になるのではなく、(型による)オーバーロードが実現できないのでは・・・

というのは置いといて、

変数に型がないことによって、関数の重複を減らすことができるという大きなメリットがあります。

これはどういうことかよくわかりません。 関数のシグネチャのことを言っている・・・?

public T sum(A a) { ... } // このpublic T sumと、
public T sum(B b) { ... } // こっちのpublic T sumが重複している?

これを「大きなメリット」と呼ぶのはつらい気がします。

それと、オーバーロードを持たない静的型付き言語のことも、たまには思い出してあげてください。 ちなみに、F#ではメソッドでのみオーバーロードが可能で、関数でのオーバーロードはできません。 ではどうするか?判別共用体というものを使います。

(* sumに渡せる判別共用体を定義する *)
type SumType =
  | A of int list
  | B of float list

let sum = function
| A value -> (* Aの場合の処理 *)
| B value -> (* Bの場合の処理 *)

「sumに渡すための型を定義しなければならず面倒」と言われてしまうかもしれませんが、 型を定義したことによって大きなメリットが生まれます。 それは、考慮漏れや、一致しない条件をコンパイル時に発見してもらえるようになることです。

例えば、「Aの処理は汎用的でいいんだけど、(高速化のために)2要素以下の時は直接計算したい」という要望が上がったとします。

(* sumはSumTypeを受け取って、intを返す関数だとする *)
let sum = function
| A value -> (* Aの場合の処理 *)
| B value -> (* Bの場合の処理 *)
| A [x; y] -> x + y
| A [x] -> x
| A [ ] -> 0

こう書いてしまった場合、後ろ3行のケースにはどうやってもたどり着けません。 なぜならば、| A value -> ...のケースがそれらのケースでも当てはまってしまうからです。 これを、F#コンパイラは警告として知らせてくれます。

では修正しましょう。

let sum = function
| A [x; y] -> x + y
| A [x] -> x
| A [ ] -> 0
| A value -> (* Aの場合の処理 *)

今度は、修正を間違ってBのケースを消してしまいました。 この場合でも、F#コンパイラは「Bのケースが考慮されていない」という警告で知らせてくれます。

これに対しても「発見が早いか遅いかの違いだ」と言うことはできるでしょう。 しかし、たとえ「何かバグを埋め込んだとしても絶対にテストで検知できる」というありえない仮定をしても、 バグの発見は早ければ早いほどその修正は楽であることが多いのです。

条件の考慮漏れや、一致しない条件を書いてしまうことがあるのと同じくらい、 その条件分岐に対するテストを書き忘れるというミスも起こりえます。 こういうミスをはじくことができる言語があり、 そういう言語を使うことである種のバグはなくなるのです。

その中にはほかの言語では「値レベルの問題」である、NullPointerExceptionの問題も含まれます。 OptionやMaybeについて調べてみるといいでしょう。

複数の型を受け取りたいときに、インターフェースを実装する必要がない?

ここでは「Java」と限定しているので深くは踏み込みませんが、

変数に型がないことによって、クラスの実装が重複がなくとてもシンプルになります。

は気になります。 変数に型があるなしと、クラスの実装の重複にどのような関係があるのでしょうか?

C++のテンプレートのような機能も必要がない?

ここは、「何でもかんでも1つの関数に詰め込むことができる」と言っているように思えます。 これをメリットに含めるのは無理があります。

変数に型がないとどのような型の値が代入されているかわからないという批判に答える?

静的型付き言語のメリットの一つとして、「型がドキュメントとして使える」というものがあります *1。 型があれば「その関数にどんなものが渡せ、どんなものが返ってくるのか」程度の情報は得られます。

例えば、

val f: 'a list -> int

であれば、「リスト(入っている要素は何でもいい)を受け取って、intを返す」ことがわかります。 他にも、「(よほどマジカルなことをしていない限りは)この関数はリストの各要素には用はないのだな」ということもわかりますし、「引数は一つしかとらない」という確信を得ることもできます。

ここではfなんていう適当な名前を付けましたが、これも適切な名前にすればさらにドキュメント性は高まります。

この種の判断を、動的型付き言語ではドキュメントに頼るか、自分で関数を読み解く必要があります。 記事中では後者の方法を紹介していましたが、関数が複雑になればなるほど、その作業は難しくなり、 また間違いも犯しやすくなっていきます。

変数に型がないことのメリットは重複を少なくソースコードがかけること?

ここでの重複は何を指しているのでしょうか? それに、

静的言語はインターフェースやクラスをそのたびに実装しなければならないので、修正や変更が行いづらいです。その点では、保守性は低いといえます。

というのは色々と勘違いが含まれています。

インターフェイスやクラスを実装することは必須ではない

静的型付き言語であってもインターフェイスやクラスをそのたびに実装しなければいけないわけではありません。 例えば、Scalaには構造的部分型があるので、

def getName(x: { def name: String }) = x.name

のように、「nameを持つ型」という指定が可能です。 nameさえ持っていれば、どんなものでもこのメソッドに渡せます。 SML#には、多相レコードがあり、

fun getName x = #name x

のように、同じく「nameを持つ型」という指定が可能です。

静的型付き言語の方が修正が容易なものも多い

静的型付きの言語は、外部に影響を与えるような修正は動的型付き言語に比べて容易です。 例えば、

  • クラス名を変更する
  • メソッド名を変更する
  • メソッドの引数を変更する
  • メソッドの戻り値を変更する

のような修正は、静的型付き言語では影響の及ぶ範囲をコンパイラがチェックしてくれますので、 変更してエラーになった部分を潰していけば修正作業は終了です。 コンパイルのチェックはテストにも及ぶため、テスト側の修正漏れの恐れもありません。 IDEによっては、そもそもこの種の変更をIDEが自動でやってくれるものもあります。

それに対して、動的型付き言語の場合、どこかのテストに不備があった場合、バグを埋め込んでしまうことになります。

思うに、動的型付き言語の「修正の行いやすさ」は、メソッド内や、クラス内に閉じている場合のみに言えるのではないでしょうか。

その「変更の強さ」は「バグの埋め込みやすさ」に直結している

例えばあるインターフェイスにメソッドを追加した場合、その実装クラスすべてにメソッドを実装する必要があります*2。 そして、これを指して「静的型付き言語は変更に弱く、動的型付き言語は変更に強い」としているのであれば、「変更の強さ」についての考えが甘いでしょう。

確かに、動的型付き言語であれば実装クラスにメソッドを追加するだけで「そのまま動かす」ことは可能です。 が、それは「正しく」動き続けているわけではありません。 実装クラスにメソッドを追加したということは、それがどこかで使われる、ということです。 どこかでその追加したメソッドを呼び出すようにして、そのメソッドを呼び出すためのレシーバとして使っているインスタンスがほとんどの場合メソッドを追加した実装クラスだったとしても、たった1パターンでも違うクラスのインスタンスが入ってくるような場合、それをどうやって修正するのでしょうか。

静的型付き言語では、確かにインターフェイスにメソッドを追加しただけでは「そのまま動かす」ことは無理ですが、コンパイルエラーを潰せば「正しく」することは容易ですし、IDEの力によって「このメソッドを呼び出している個所を洗い出す」ことも容易です。

全体を通して

コメント欄含め、全体を通してみると、テストを過信しすぎです。 全数テストでもやらない限り、テストで「保証」を得ることはできません。

また、静的な型があればバグが減るのかどうかですが、最終的なバグの数(システムに残ってしまう見つからなかったor放置されたバグの数)は同じくらいになるかもしれません。 ですが、バグの絶対数(システムが死に絶えるまでに見つかったバグの数)は減るはずです。 なぜなら、動的型付き言語であれば埋め込んだであろうバグを、そもそも入れ込まなくなるから。 品質どうこうは置いておいたとしても、この点は非常に重要です。

例えば、例に挙げられていたsum関数のテストですが、sum関数自体のテストはそれほど手間は変わらないでしょう。 しかし、sum関数を呼び出している側がsum関数が対応している型を渡しているかどうか、という確認は、 直接的でないにしても行う必要があります。 この確認に一つでも漏れがあった場合、それは静的型付き言語では埋め込みえなかったバグです。

静的型付き言語では、sum関数呼び出し時の型に関するテストは不要ですし、漏れもないことが保証されます。 こういった言語を実際に使ってそれなりの大きさのシステムを構築してみれば、テストの絶対数が少なくなることを感じれるはずですよ。

*1:もちろん、型さえあればドキュメントなんて不要だ、と言いたいわけではないです

*2:Scalaのtraitや、Java8で導入予定のインターフェイスのデフォルト実装などで追加すればその限りではありません