Island Life

< GaucheでもLand of Lisp | システムの非平衡状態 >

2013/02/27

型付けと変更の時定数

静的型付けvs動的型付けの永遠の議論で良く出てくる論点に、 「変更に強いのはどちらか」という話がある。

  • 動的型付け陣営は、「後付けでシグネチャを変更したくなった時に、 既存のモジュールに手を入れずに拡張できるから動的型の方が変更に強い」と言う。
  • 静的型付け陣営は、「静的型付けだと、型を変更したら合わせて変更すべき箇所を コンパイラが漏らさず見つけてくれるので、静的型の方が変更に強い」と言う。

理由が反対を向いてるのに結論が同じになるのがおもしろいが、 これは、同じものについて議論してるんだけど、 見てる場所が違うせいだと思う。 双方の立場の一番の違いは、動的型付け言語がシステムの非平衡状態を重視するのに対し、 静的型付け言語は平衡状態を重視していることなんじゃなかろうか。

ある安定した系がある。その一部に変更が加わる。するとその影響が徐々に波及してゆき、 十分な時間が経つと(元の状態とは違うけれど)再び安定した平衡状態に落ち着く。

静的型付けは、平衡状態で明らかな矛盾が起きないことを保証する強力な道具だ。 変更が加わったら、それによって影響を受ける場所を速やかに見つけて、 なるべく短時間で再び平衡状態に持ってゆくべきだ、という前提がある。

動的型付けが変更に強い、と言っている人は逆に、 「変更途中だけど動かさないとならない」とか「動いているものを止めずに変更してゆく」 といった非平衡状態を頭に置いている。 理論上は最終的に平衡状態に収束するはずだけれど、そこまでの時定数が 現実に見えるほど大きいことを前提にしている。 そして、変更は次から次へとやってくるものだから、 システムは常に、平衡へと向かう動的な状態に置かれる。

ソースコードを全て個人あるいは1チームがコントロールできる環境なら、 システムの時定数は小さい。一ヶ所APIを変更したら、影響を受けるモジュールも 一緒に変更して同時にコミットすればいい。そういう場面では静的型言語の方が圧倒的に有利だ。

逆に時定数が大きくなるのはどんな場合だろうか。 自分のチームがあるライブラリXを作っていて、さらにXを使ったアプリケーションAを作っているんだけど、 他のチームがやはりXに依存するアプリケーションB, C,...を作っている、なんて場合がそうかもしれない。 Xは比較的新しい技術で、どういうAPIが正しいのかまだはっきりわかっていない。 現在のAPIは、今分かっている知見に基づく暫定版だ。 B, C,... のアプリケーションの中には、既に実運用に投入されているものもあれば、 まだ実験段階のものもある。

さて、自分のチームでAを作っているうちに、どうも今のXのAPI SolveX :: Foo -> Bar は解くべき問題を正しく抽象化していない、 ということがわかった。直すには、パラメータや返り値を増やすとか、 データ型FooBarの設計をやり直す必要がありそうだ。

問題なのは、じゃあ SolveX を変更して影響を受けるところを いっせーのせで変えましょう、というわけにはいかないことだ。

  • 既に実運用に入っているアプリケーションの変更には、工数がかかる。 単に型を合せればokではなく、その変更が既存のユースケースにちゃんと合うかどうか 検証しないとならない。今その工数を取っている余裕がない。
  • 一方、抽象化の設計が正しいかどうかというのは、実際の問題に適用して 使ってみなければわからないものだ。だから「変更してもいいよ」という 実験的なプロジェクトや自分のチームで作ってるプロジェクトのために、 新たなAPIをさっさと実装して使えるようにする必要がある。

これが時定数の大きな非平衡状態だ。 そこでまず新たなパラメータを導入したAPIをライブラリの開発版に足して、 それをアプリケーションAで使ってみる。 いけそうだったら外のチームに伝えて、実験段階のものから暫時新しいAPIに移行してもらう。 その変更が徐々に伝わって、最終的に全部が新たなAPIに移行するには 年単位の時間がかかるかもしれない。

醜いけど SolveX2 という新たなAPIを作って使う、という手がある (醜い、というのは単に名前が増えるいうだけではなく、新しい知見では 新APIの方がより完全にSolveXを体現しているので、 本来なら新APIにSolveXという名前を与えたいからだ)。 あるいは変更がまばらなら、ライブラリのバージョニングで対応するという手もある。

けれどこういう変更がひっきりなしに起きるとしたら? バージョニングは簡単に破綻する。一次元のバージョンをつけられないからだ。 ライブラリが解決するサブ問題SolveXaとSolveXbとSolveXcについてそれぞれこういう変更が起きて、 色々な開発ラインが各APIの特有の組み合わせに依存して、 しかもバグフィクスはAPIとは関係なしにロールアウトしないとならない、とか。

Common Lispの開発ではこの手の「以前のバージョンを壊さずに後付けで 新しいAPIも使えるようにする。徐々に新APIに移行して、全部移行し終わったら 以前のバージョンのサポートを落とす」っていうのをかなり頻繁にやる。

静的型付け言語だと…型によるオーバロードあたりで何とかするのかな。 ただ。オーバロードは人間が覚える名前を少なくする効果はあるけど、 実行時に名前から関数引っ張ってきて呼び出すなんて操作をしてると 別名をつけてるのとあんまり変わらないんだよね。

それにカリー化と高階関数と型推論を多用する静的型付け言語だと、オプショナルな引数や キーワード引数で変更に対応するという手法と相性が悪い (できなくはないみたいだけど、 無理してる感じがする。)

で、結局どっちが良いかって話だけど、私としてはどちらのメリットも捨てがたいんで、 開発形態によって選べるといいなと思うんだけどね。

一つのシステムであっても、初期のいろいろなものが流動的で変更の時定数が大きい状態が、 だんだん枯れてきて平衡状態に落ち着くなんて経緯をたどるものがあるんで、 できれば言語そのものは変えないで両方のいいとこ取りをしたいんだけど、 でも例えばHaskell書くときとGauche書くときでは発想段階から違う気がするから、 都合良く両方を融合するってことは出来ない気がする。

動的陣営としては、原則動的で、徐々にアノテーションを入れて必要な部分だけ 静的検証が別途できるようにしてゆく、って方向が現実的かなあ。

(追記2013/03/01 03:03:22 UTC): 「大規模開発なら動的なのか」って感想を見たけど、規模よりはコードのコントロールの問題だと思う。大規模でも関係する全部のコードを一気に修正できるなら静的がいいし、小規模でも自分の制御の及ばないところでいろんなふうに使われちゃってたら一気に修正するわけにはいかないだろう。

Tag: Programming

Past comment(s)

keigoi (2013/02/28 22:33:12):

こんにちは。記事の本筋から外れますが、 >それにカリー化と高階関数と型推論を多用する静的型付け言語だと、オプショナルな引数や キーワード引数で変更に対応するという手法と相性が悪い (できなくはないみたいだけど、 無理してる感じがする。) ここですが、OCamlのラベル付き引数やオプション引数を念頭に置いていたりしますでしょうか? 型推論やカリー化、さらには無名関数とも相性は悪くないように思います(オプション引数の位置について一定の制限はあります)。もしご存じないようでしたらぜひご覧になってください。 http://ocaml.jp/refman/ch04s01.html

shiro (2013/03/01 01:17:13):

はい、OCamlのを何となく頭に置いていました。今マニュアルを読んでみましたが、その「一定の制限」のところがまさしく私がひっかかっているところのように思います。

「あるバージョンでは x:int -> int -> int としてた関数だけど、やっぱりラベルは y の方が良い気がする。いずれ全面的に y:int -> int -> int にするとして、移行期は両方サポートしたい」なんて発想をするのが動的脳で、Gaucheだと (define (foo i :key x y) ...) でxもy受け付けるようにしといて関数の中で良きにはからう(x,y両方指定されないとか両方指定された場合とかの処理も含めて)ってことになると思います。これだと呼び出し側は変える必要がない。

x:int -> int -> inty:int -> int -> intを期待してるところに ?x:int -> ?y:int -> int -> int を渡せないのは、オプショナル引数だと必須引数の後に置けないからかな、と思うんですが、let foo (f : x:int -> int -> int) a = f ~x:a とかなってたら式全体を見れば曖昧性は無いんだから、f に ?x:int -> int -> int を許してくれても良さそうなのに、とは思います。

多分、OCaml的には「両方サポートする関数」なんて発想はせずに、シグネチャの違う二つの関数を、名前を変えるなりモジュールを分けるなりして提供するのが当然でしょ、ってことになると思うんですが、それをしたくない/できない場合があるよねってのがここでの趣旨です (同名で x:int -> int -> inty:int -> int -> intのオーバーロードみたいなことできましたっけ?)

山本和彦 (2013/03/01 02:06:02):

Common Lisp のところですが、具体的にはオプショナルな引数を増やして、利用するアプリがなくなった時点で、オプショナルを必須に変えるといことでしょうか?

shiro (2013/03/01 02:41:57):

引数を増やしたい場合はそうですね。他にもキーワード引数の仕様を変えるとか、必須引数の意味を変えちゃうとか…あと今日の追加エントリに挙げたようにデータ型の定義を変えたいとか。

例えばGaucheのgauche.uvectorに、s8vector-copy! のような破壊的代入の関数が用意されてます。これは当初 (s8vector-copy! dst-s8vector src-s8vector) だったんですが、srfi-43のベクタライブラリで (vector-copy! dst-vector dst-index src-vector :optional src-start-index src-end-index) というのが定義されたので、Gaucheの方もそれに合わせて (s8vector-copy! dst-s8vector dst-index src-s8vector :optional src-start-index src-end-index) に変更しました。でも(use gauche.uvector) して旧来のインタフェースを使ってるコードは壊したくないので、今は両方サポートしてます。1.0くらいで古い方のサポートを止めようかと思ってます。

このケースは型によるオーバロードができればサポートできますが。 新しい方も古い方もオプショナル引数を取るんだけどそのオプショナル引数の型だけが違う、って場合は静的型でサポートするの難しそうですね。オーバロード解決の優先順位が指定できれば何とかなるかな。

山本和彦 (2013/03/01 03:04:28):

あら? 長い質問を書いたのですが、それをポストしたらなくなってしまいました。サーバは受け取っていますか? あと、間違って同じコメントを投稿してしまいました。すいません。

shiro (2013/03/01 03:22:25):

む、コメント欄表示してからある程度時間が経ってたり、逆にPOSTまで短すぎるとはじくようにしてるんですが、サーバのログにはそれではじいたってのは出てませんね。なぜだろう。重複コメントは消しときます。

山本和彦 (2013/03/01 03:22:54):

すいません。書き直します。

山本和彦 (2013/03/01 03:33:27):

回答、ありがといございました。よくわかりました。以下、本当に聞きたい質問です。

記事にあるようにライブラリXを使っているアプリAとアプリBがあるとします。ライブラリXのAPIを説明して頂いた方法で、互換性を保ちながら変更しました。アプリAは新しいAPIを使ってくれましたが、アプリBは運用中なので古いAPIを使い続けます。この時点で、アプリBはライブラリXの新しいバージョンをリンクする必要はありませんでした。

ライブラリXにはセキュリティ上の問題があって、それが修正されたとします。アプリBにもリンクしなければならなくなりました。ライブラリXの品質は、単体テストである程度保証されています。おそらくアプリBを変更せずにアプリBへリンクしても問題なく動くでしょう。

ここで、「今その工数を取っている余裕がない」という理由で、結合テストより上のテストは省略していいのでしょうか?

省略してもいいという場合は、工数がかからないことには同意できますが、個人的には恐くてその方法は採用できません。

省略してはならないという場合は、結局工数がかかると思います。ここで前提が変わりますが、もし静的型付き関数型言語を使っていれば、変更すべき点はすべて分かるので、その変更の手間は、テストに比べれば無視できるというのが僕の経験からくる感覚です。

記事は全体的に素晴らしいのですが、この辺りの感覚が分からなかったので、少しもやもやしています。

garriguejej (2013/03/01 06:02:11):

OCamlのオプション引数に関して、制限は型ではなく、コンパイル方法に由来しています。特に、オプション引数がオプション型になるので、ラベル付引数と区別する必要があります。 型があるお陰でほとんどオーバーヘッドなしに実装できていると言えます。

garriguejej (2013/03/01 06:14:37):

因みに、schemeのキーワード引数の表現力に近いものが欲しければ、多相バリアントのリストを使った方がいいかもしれません。

shiro (2013/03/01 09:00:22):

なるほど>山本さん。確かにそこの比重は状況によってかなり変わりそうです。

私が「非平衡状態」を一番強く経験したのはCGプロダクションのアセット管理でして、そこだとあらゆる点で完全になるようにテストするよりも、「今、工程を止めずに動いて、目的の画さえ出来れば問題ない」という優先順位になるので、最小限の変更+ターゲットを絞ったテストでgoできる環境でした。

ここしばらくやってたCLの仕事は外部に顧客がたくさんいる環境だったのでテストの比重がかなり大きかったですが、「現在のAPIがちゃんと動いていること」を検証する巨大なテストは揃っていて、新しいのをリリースする時はどっちにせよそれを流します。で、そこまでがこちらの任務で、顧客の方は顧客の方で「現在のシステムの動作を保証する」ためのリグレッションテストを大量に抱えてるので、ベータ段階でそれでもって検証してもらうという形でした。後者のテストはプロプライエタリなのでこっちでは流せず、「新APIに移行して、テストも変えて流して下さい」とお願いするのはなかなか難しく。

もちろん、状況によってテストと変更の工数の比重は簡単に逆転すると思います。私の経験では、自チーム以外に「API変わったのですぐcaller側変更してね」ってお願いするのは結構敷居が高いなあという感覚です。

shiro (2013/03/01 09:05:34):

garriguejejさん、OCamlのその事情は何となく察しがつきます。他に実現方法があるのもわかるのですが、ここでの話題は「既にあって公開しちゃってるAPI」にまつわるものなので、既にあるAPIがラベル付き引数使ってたらそれをもとに互換性を考えるしかないですよね。

とはいえ、どんな言語にも弱い点はあって、だいたい設計時からそういうところになるべくはまらないような道を作ると思うので、現実にOCamlで回ってるプロジェクトならここで書いたようなことがそんなに問題にはならないんじゃないかな、とは想像します。逆もまた然りですが。

山本和彦 (2013/03/01 10:02:44):

すべてのソースが自分の手中にあるわけではないという状況での話ならよく分かります。記事中では、すべて手中にある話のように理解したので、テストを含めるとコード変更は誤差かなぁと思ったのでした。

Post a comment

Name: