Revenge of the Types: 型の復讐

  • 93
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

本稿は Python に型アノテーションを追加するという提案が行われたときに起こった Python コミュニティの議論の後、2014年8月24日 (日) に Armin Ronacher (@mitsuhiko) 氏によって書かれた記事の翻訳です。

Python 3.5 で導入を検討している型アノテーションについて興味がある方は以下を参考にしてください。

私自身、型システムや他言語に明るくないため、一部未訳の部分があったり、勘違いや誤訳もあると思います。そういった誤りを見つけたら編集リクエストを送ってもらえると助かります。

型の復讐

これは "私がみたい Python" についてのパート2です。最近の議論を踏まえ Python の型システムを少し探索します。本稿の一部は slot についての前の記事 を参照しています。前の記事と同様、本稿は Python という言語の未来の言語設計者へ向けての考察材料であり、CPython のインタープリターの世界に飛び込みます。

Python プログラマーの1人として、型は少し厄介なものです。型は確かに存在していて相互に異なる方法で作用しますが、型の存在に気付くほとんどのケースは、型が意図したように振る舞わないときに起こる例外や実行に失敗したときのみでしょう。

Python はその型付けの扱い方を誇らしく思っていました。私は何年も前にこの言語の FAQ を読んだことを覚えていて、そこにはダックタイピングがすごいものだと書かれていました。公平であるために実用面でもダックタイピングは優れた解決策です。それは基本的には型システムと戦わず、やりたいことを制限しないことから良い API を実装させてくれるからです。特に一般的によくやるようなことは Python で超簡単です。

私が Python 向けに設計したほとんどの API は他の言語では動きません。click の汎用インターフェイスのようなすごく単純なものであっても、やはり他の言語では動きません。その最大の理由は絶えず型と戦うということです。

最近、Python に静的型付けを追加することについての議論がなされました。私はその列車が駅を出発して遠くの方へ行ってしまい、決して戻ってくることはないと確信しています。興味深いので Python が明示的な型付けに適応しないことを願う理由について私の考察を紹介します。

型システムとは何なのか?

型システムとは型がどのように相互作用するかの規則です。コンピューターサイエンスにはまるまる型だけを取り扱う一分野さえあります。型はそれ自体がとても印象的なものです。しかし、理論上のコンピューターサイエンスにとりわけ興味がない場合でも型システムを無視することは難しいです。

私は次の2つの理由から型システムにのめり込みたくはありません。1つ目の理由は、型システムを私自身がほとんど理解していません。2つ目の理由は、型システムの論理的帰結を"実感する"ためには、理解することはそれほど重要ではないということです。私にとっては型がどう振る舞うかが重要であり、そこから API がどう設計されるかに影響を与えます。そのため、本稿を正しい型の入門というよりも私の妄想からのより良い API の基礎入門と捉えてください。

型システムは多くの特性を有しますが、型を区別する最も重要なことは、型を使って説明しようとしているときにその型が提供する情報量です。

例として Python を使ってみましょう。Python には型があります。42 という数値の型を尋ねると Python は整数型だと答えます。それは多くの意味をもち、整数型が他の整数型とどう相互作用するかの規則をインタープリターに定義させます。

しかし、Python にはないものが1つあります。それは複合型です。Python の型はすべて基本型 (primitive) です。つまり、基本的には一度に1つの型のみが作用することを意味します。基本型の反対は複合型 (composite) です。時々、別のコンテキストで Python の複合型を見かけるでしょう。

ほとんどのプログラミング言語が持っている最も簡単な複合型は構造体です。Python は直接的には構造体を持っていませんが、ラリブラリで独自の構造体を定義する必要があるといった状況はよくあります。例えば、Django や SQLAlchem​​y の ORM モデルは本質的に構造体です。各データベースのカラムは、Python のディスクリプターを通して表現され、それがそのまま構造体のフィールドに相当します。主キーを id と呼び IntegerField() だとするなら、それは複合型としてモデルを定義しています。

複合型は構造体に限定されるものではありません。例えば、複数の整数を使いたい場合、配列のようなコレクションを使います。Python にはリストが用意されていて、リストの個々の要素は任意の型になります。これは (整数型のリストのような) ある型を指定して定義したリストとは対照的です。

"整数型のリスト" はリストではないと言えます。そのリストを繰り返し処理することで型を把握できると反論できますが、空のリストで問題が起こります。Python で要素をもたないリストを使うときにその型は分かりません。

Python でまったく同じ問題が null 参照 (None) によって起こります。ある関数へユーザーオブジェクトを渡して、そのユーザーオブジェクトが None になる可能性がある場合、その引数がユーザーオブジェクトであるかどうかは全く分かりません。

では解決策はあるのでしょうか?null 参照をもたない明示的な型付き配列 (typed array) を持つことです。Haskell は言うまでもなくこれを持っていると誰もが知っている言語ですが、他にも不適切とはみえないものがあります。例えば、Rust は 多くの部分が C++ のようだとよく知られている言語ですが、そのテーブルにとても強力な型システムをもたらします。

では、null 参照がない場合は "ユーザーが存在しない" というのをどう表現するのでしょうか?例えば、Rust における答えは option 型です。Option<User>Some(user)None のどちらかであることを意味します。前者はある値 (特定ユーザー) をラップするタグ付きの enum です。いまその変数が値をもつか存在しないかのどちらかになるため、明示的に None のケースを処理しないとその変数を扱う全てのコードがコンパイルされません。

未来はどちらとも言えない

過去、世界は明確に動的型付けなインタープリター型言語と、事前に静的型付けを行うコンパイル型言語に分かれていました。これは新しい傾向の出現により変わってきています。

その未踏の領域へ向かい始めた最初の兆候は C# でした。静的なコンパイル型言語であり、当初はその言語の扱い方は Java によく似たものでした。言語が改良されるごとに、多くの新たな型システムに関連する機能が追加されていきました。最も重要なことは、コンパイラが提供する以外のコレクション、リストやディクショナリなどに強い型付けを提供するジェネリクスの導入です。その後、C#は静的型付けとは逆方向にも進み、個々の変数ごとに静的型付けをやめて動的型付けにすることもできるようになりました。これは途方もなく便利です。特に Web サービス (JSON, XML など) が提供するデータを扱うコンテキストにおいてはそうです。動的型付けを使えば、型安全ではないかもしれない処理でもひとまず実行してみて、不正な入力データにより型エラーが発生したら捕捉してユーザーに表示するということができます。

今日の C# の型システムは、共変性と反変性という仕様に対応するジェネリクスがとても強力です。それだけではなく、null 許容型 (nullable type) を扱うための多くの言語レベルサポートも発達しました。例えば、null 合体演算子 (??) は null として表現されたオブジェクトのデフォルト値を提供するために導入されました。C# は言語から null を取り除くには手遅れなところまで来てしまいましたが、 null が引き起こす害悪を制御できるようにはなっています。

同時に、伝統的に静的型付けで事前コンパイルされる他の言語も新たな分野を探求しています。C++ は必ず静的に型付けされますが、多くのレベルで型推論できるよう検討が続けられています。MyType <X, Y><::const_iterator iter の日々は過ぎ去りました。今日では、ほとんどの状況において、型を単に auto に置き換えれば、コンパイラが代わりに型を埋め込むでしょう。

言語としての Rust もまた、完全に任意の型定義をせずに静的型付けプログラムを書く優れた型推論に対応しています。

Rust
    use std::collections::HashMap;

    fn main() {
        let mut m = HashMap::new();
        m.insert("foo", vec!["some", "tags", "here"]);
        m.insert("bar", vec!["more", "here"]);

        for (key, values) in m.iter() {
            println!("{} = {}", key, values.connect("; "));
        }
    }

我々は強力な型システムとともに未来へ向かっていると私は考えています。このことが動的型付けの終焉になると私は考えてはいませんが、ローカルな型推論 (local type inference) つきの強力な静的型付けを受け入れる顕著な傾向があるようにみえます。

Python と明示的な型付け

そのため、静的型付けは素晴らしく言語機能としてあるべきだと、少し前までは誰かがカンファレンスで周りの人たちを説得していました。その議論がどうであったか正確には把握していませんが、最終結果は mypy の型モジュールと Python 3 の型アノテーション構文の組み合わせが Python における型付けの標準になると宣言されました。

もしまだその提案を見たことがないなら、このようなものが提唱されています:

Python3
    from typing import List

    def print_all_usernames(users: List[User]) -> None:
        for user in users:
            print(user.username)

正直なところ、多くの理由でこれはまったく良い決断ではないと私は考えています。その最大の理由は Python は優れた型システムをまさに持っていないことに苦しんでいるからです。この言語の意味論は見方によって変わってしまうものなのです。

静的型付けが意味をなすには、型システムが良いものである必要があります。2つの型を与えられたとき、型が互いにどう関係しているか分かるような型システムがそれです。Python はそうなっていません。

Python の型意味論

以前に書いた slot システムに関する記事を読んでいたら、Python の型が C 言語側もしくは Python 側で実装されるかに依存して異なる意味論をもつことを覚えているでしょう。これはこの言語のかなり珍しい機能であり、通常、多くの他の言語では見られません。多くの言語がブートストラップ目的でインタープリターレベルで実装された型をもつのは真実ですが、それらはふつう基本型とされ、特別扱いされます。

Python には本当の "基本 (fundamental)" 型がありません。しかし、C 言語側で実装された型がかなりたくさんあります。これらは全くプリミティブや基本型 (fundamental type) に限られたものではなく、何のロジックもなくどこにでも現れます。例えば、collections.OrderedDict は Python 側で実装された型であるのに対して、同じモジュールにある collections.defaultdict は C 言語側で実装されている型です。

このことは実際に PyPy で相当数の問題を引き起こしています。これらの違いが目立たない類似の API を実現するため、できるだけオリジナルの型を模倣する必要があるからです。C 言語レベルのインタープリターのコードと言語の残りの部分との間にある、この雑多な違いが意味することを理解するのはとても重要です。

例として、Python 2.7 までの re モジュールについて指摘しておきましょう。(この振る舞いは、最終的には re モジュールで変更されていますが、言語とは違うところで作用するインタープリターの雑多な問題はまだ存在しています。)

re モジュールは、正規表現を正規表現パターンにコンパイルする機能 (compile) を提供します。それは文字列を受け取りパターンオブジェクトを返します。だいたい次のようになります。

Python2.7
    >>> re.compile('foobar')
    <_sre.SRE_Pattern object at 0x1089926b8>

ご覧の通り、このパターンオブジェクトは _sre モジュールの内部にありますが、汎用的に利用できます:

Python2.7
    >>> type(re.compile('foobar'))
    <type '_sre.SRE_Pattern'>

残念ながらそれはちょっと嘘でした。_sre モジュールは実際にその型を含みません。

Python2.7
    >>> import _sre
    >>> _sre.SRE_Pattern
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    AttributeError: 'module' object has no attribute 'SRE_Pattern'

はい、その通りでした。型がそこにはないと嘘をつくのは初めてのことではないでしょうし、いずれにしてもこれは内部的な型ということです。なので次に進みます。我々はこのパターンオブジェクトの型は _sre.SRE_Pattern 型だと知っています。それ自体は object のサブクラスです:`

Python2.7
    >>> isinstance(re.compile(''), object)
    True

我々が知っているように、全てのオブジェクトはいくつか共通メソッドを実装しています。例えば、全てのオブジェクトは __repr__ を実装します。

Python2.7
    >>> re.compile('').__repr__()
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    AttributeError: __repr__

あぁ。一体何が起こったのでしょう?さて、答えはかなり奇妙です。理由は私には分からないですが、内部的に SRE パターンオブジェクトは、Python 2.7 まで、カスタムの tp_getattr スロットを持っていました。このスロットには、カスタムメソッドと属性へのアクセスを提供するカスタム属性の探索処理がありました。実際に dir() でオブジェクトを調べると、多くの機能が欠落しているのに気付きます。

Python2.7
    >>> dir(re.compile(''))
    ['__copy__', '__deepcopy__', 'findall', 'finditer', 'match',
     'scanner', 'search', 'split', 'sub', 'subn']

その上、この型が実際にどう機能するかは本当に奇妙な冒険へ誘います。ここで何が起こっているかを紹介します。

type 型はそれが object のサブクラスだと主張します。これは CPython のインタープリターの世界では本当ですが、Python という言語では違います。これが同じではないというのは残念なことですが、一般的なケースです。型は Python レイヤー上の object のインターフェイスに対応していません。インタープリターを経由する呼び出しは動作しますが、Python 言語を経由する呼び出しは失敗します。つまり、type(x) は成功するのに対して x.__class__ は失敗します。

サブクラスとは何なのか

上述した例では、Python は基底クラスの振る舞いと一致しない、別のサブクラスを持つことができると示しています。これは静的型付けについて話そうというときにことさら問題となります。Python 3 では、例えば、C 言語側でその型を書かない限り dict 型のインターフェイスを実装できません。理由は簡単に実装できない view オブジェクトの特定の振る舞いを型が保証するからです。なんてことでしょうか。

そのため、文字列のキーと整数のオブジェクトのディクショナリを受け取る関数を静的にアノテートするとき、それが dict もしくは dict のようなオブジェクトの場合か、ディクショナリのサブクラスを許容する場合かは全く明確ではありません。

未定義の振る舞い

先のパターンオブジェクトの奇妙な振る舞いは Python 2.7 で変更されましたが、根本的な問題は依然として残っています。先に述べた dict のインスタンスの振る舞いのように、言語はそのコードがどう書かれたか次第で異なる振る舞いをします。そして、型システムの厳格な意味論を完全に理解するのは不可能です。

こういったインタープリター内部のすごく奇妙なケースは、例えば Python 2 だと型比較です。この特定のケースは Python 3 ではインターフェースが変更されたので存在しませんが、基本的な問題は様々なレベルで見つけられます。

それでは、例として set 型のソートをみてみましょう。Python の set 型は便利ですが、比較操作がかなり奇妙です。Python 2 では cmp() という関数があり、与えられた2つの型からどちらが大きいかを示す数値を返します。0より小さい値は第一引数が第二引数より小さい、0はそれらが等しい、正の数は第二引数が第一引数より大きいことを意味します。

set を比較したときに何が起こるかを紹介します:

Python2.7
    >>> cmp(set(), set())
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    TypeError: cannot compare sets using cmp()

どうしてでしょう?正確なことは正直分かりません。おそらくは、比較演算子が実際にサブセットを設定していて cmp() でそれが動作しないからです。しかし、例えば frozenset 型は動きます。

Python2.7
    >>> cmp(frozenset(), frozenset())
    0

この frozenset の一方が空ではないときは失敗します。なぜでしょう?これに対する答えは、それが言語機能ではなく CPython のインタープリターの最適化だからです。frozenset は共通の値を intern します。空の frozenset は常に同じ値 (不変であり、追加できない) なので、空の frozenset は同じオブジェクトになります。2つのオブジェクトが同じポインタのアドレスを持っている場合、cmp は普通に 0 を返します。Python 2 における複雑な比較ロジックのため、私はどうしてこうなるかをすぐには理解できませんが、比較ルーチンにこの結果を引き起こすかもしれない複数のコードパスがあります。

要点はこれがバグだというよりむしろ Python は実際に型がどのように相互作用するかについての適切な意味論を持っていないということです。本当に長い間、型システムの振る舞いは "CPython のなすがまま" でした。

PyPy には CPython の振る舞いを再構築しようとした無数のチェンジセットが見つかります。PyPy が Python で書かれていることを考えると、言語にとって非常に興味深い問題になります。Python という言語が、この言語の Python 部分のような振る舞いを完全に定義していたなら、PyPy で起こる問題はずっと少なかったでしょう。

インスタンスレベルの振る舞い

ここでは前述した全ての問題を修正した仮想的な Python があると仮定してみましょう。それでも静的型付けは Python にうまく適合するものではありません。大きな理由は Python の言語レベルで、オブジェクトがどのように相互作用するかに関して、伝統的に型がほとんど意味を持たないからです。

例えば、datetime オブジェクトは一般的に他のオブジェクトと比較することができますが、他の datetime オブジェクトと比較するときはタイムゾーン設定に互換性がなければなりません。同様に多くの操作結果は、手にとってオブジェクトを調べるまで明らかではありません。2つの文字列を Python 2 で結合すると unicode か bytestring オブジェクトのどちらかが作られます。コー​​デックシステムのエンコードやデコードの API は任意のオブジェクトを返します。

言語としての Python は、アノテーションとうまくやるにはあまりに動的過ぎます。言語にとってジェネレーターがどのぐらい重要かを考えてみてください。けれども、ジェネレーターは1つ1つの繰り返し処理で異なる型変換を行うことができます。

型アノテーションは部分的には良いものだろうけれど、API 設計にネガティブな影響を与えるかもしれません。実行時に型アノテーションを削除しない限り、少なくとも遅くなるでしょう。 Python を Python でないものに作り変えてしまわない限り、効率良く静的にコンパイルする言語を実装することは決してできないでしょう。

得られたものと意味論

Python から個人的に得られたと思うことは、言語というのは途方もなく複雑だということです。Python は、言語仕様を持たずにこういった異なる型同士の複雑な相互作用に苦しんでいる言語です。それは決して1つにまとまらないと思われます。あまりに多くの不可思議な挙動やちょっとした奇妙な振る舞いがあるため、言語仕様を作ってみたところで、結局は CPython インタープリターを文章に書き起こしたものにしかならないでしょう。

この土台の上に型アノテーションを置くことはほとんど筋が通らないことだと私は思います。

もし将来誰かが別の動的型付き言語を開発するなら、型がどう作用するかを明確に定義するためにより一層の努力をすべきです。JavaScript はその点においてかなりうまくやっています。奇妙ではあっても組み込み型の全ての意味論が明確に定義されています。これは一般的には良いことだと私は思います。その意味論がどう作用するかを明確に定義したなら、最適化したり、後から選択的静的型付け (optional static typing) を行う余地があります。

言語を無駄のないよく定義された状態で維持することには、困難に見合うだけの大きな価値があります。未来の言語設計者は PHP、Python や Ruby が犯した過ちを絶対に行うべきではありません。そこでは言語の振る舞いが "インタープリターのなすがまま" という結論で終わります。

私が Python に対して思うことは、現時点ではどうも変えられそうにありません。言語とインタープリターをきれいにするための時間と労力は、得られる価値を上回るからです。

© Copyright 2014 by Armin Ronacher.
Content licensed under the Creative Commons attribution-noncommercial-sharealike License.