methaneのブログ

このブログに乗せているサンプルコードはすべてNYSLです。

Python と Ruby と typing

この話題について補足しておきます。なお、僕はTAPL脱落組なのであまり正確性は期待しないでください。

背景

Ruby Kaigi で Matz が Ruby3 に向けて考え中の静的型について話されたようです。

少し前から、 Python でも Guido が Dropbox での大量のコードベースを改善していくために type hinting がほしいということで PEP 484 を始めました。

ここでは、 Java などが採用している一番よく知られた nominal subtyping, Go などが採用している structural subtyping, PythonRuby の duck typing, そして PEP 484 が採用した Gradual Typing について、違いをかんたんに紹介していきます。

Nominal Subtyping

まずは次のコードを見てください。

interface IFoo {
  void Foo(String s);
}

class Foo implements IFoo {
  public void Foo(String s) {}
}

class Main {
  public static void main(String[] args) {
    IFoo f = new Foo();
    f.Foo("hello");
  }
}

IFoo f = new Foo() では、 IFoo 型の変数 f を宣言し、 Foo 型のインスタンスで初期化しています。 静的に(実行しなくても)変数に型が付いているので、これは静的型付けです。

IFoo 型の変数に、 Foo 型のインスタンスを代入していますが、これは Foo が IFoo のサブタイプだからです。(ちなみにサブタイプの逆はスーパータイプです。)

サブタイプは、スーパータイプが定義しているAPIをすべて提供している必要があります。例えば、スーパータイプが Foo(string) というメソッドを定義しているなら、サブタイプもそのメソッドを提供しなければなりません。 スーパータイプに対して可能な操作がすべてサブタイプに対しても可能なので、スーパータイプの型の変数にサブタイプの値を代入してもスーパータイプと同じように操作できることが保証されます。つまり、 f.Foo("hello")コンパイル時に安全性が検証され、実行時に fFoo(String) というメソッドを持ってないというエラーは起こりません。

さて、ここまでの説明は静的型付けと subtyping についてでした。では nominal subtyping の nominal とは何でしょうか? それは、 Foo implements IFoo のように、サブタイプ関係を宣言によって定義するという意味です。そして、 nominal subtyping と対になるのが structural subtyping です。

Structural Subtyping (構造的部分型)

Nominal Subtyping が implements や extends といった宣言により部分型関係が成り立っていたのに対して、 Structural Subtyping では構造によって部分型関係が成り立ちます。次の例を見てください。

package main

import (
    "fmt"
    "io"
)

type Reader interface {
    Read(buf []byte) (int, error)
}

type StringReader struct {
    s string
}

func NewStringReader(s string) *StringReader {
    return &StringReader{s}
}

func (r *StringReader) Read(buf []byte) (int, error) {
    l := len(r.s)
    if len(buf) < l {
        copy(buf, r.s)
        r.s = r.s[len(buf):]
        return len(buf), nil
    }
    copy(buf[:l], r.s)
    r.s = ""
    return l, io.EOF
}

func ReadPacket(r Reader) ([]byte, error) {
    header := make([]byte, 1)
    if _, err := r.Read(header); err != nil {
        return nil, err
    }
    l := int(header[0])
    data := make([]byte, l)
    n, err := r.Read(data)
    return data[:n], err
}

func main() {
    r := NewStringReader("\x03foo")
    packet, _ := ReadPacket(r)
    fmt.Printf("%s\n", packet)
}

StringReader 型は Reader 型を明示的に継承していませんが、 ReadPacket(r Reader) に *StringReader 型の値を渡せています。

Go のコンパイラは、 *StringReaderReader インターフェースが宣言しているメソッドを全部定義しているので、サブタイプだと判断しています。これが構造による部分型です。

Structural Subtyping の Nominal Subtyping に対するメリットを考えてみましょう。

今回書きたかった関数 ReadPacket(r Reader) が、本当は os.File 型からパケットを読み込むための関数で、 *StringReader は単に ReadPacket をテストするためのモックだったとします。 もし os.File が何もインターフェイスを継承していなかったとしたら、 nominal typing では、 ReadPacket() の引数の型を、 os.File とモックの両方を直接受け取れるように定義することができません。自分で os.File をラップするクラスを作って Reader インターフェイスを継承しないと行けないので面倒です。 また、 os.FileRead(), Write(), Close(), Seek() を定義したインターフェイスを実装していた場合、 ReadPacket()Read() しか使っていないのに、 *StringReader は3つの使ってないメソッドのダミー実装をしないといけませんでした。

構造的部分型は、このように実際に利用しているメソッドだけを定義したスーパータイプを後付けできるのが魅力です。 ライブラリを提供する人も、実装している型が一つだけなのにユーザーがモックを定義しやすくするためにインターフェイスを作らなくてよくなるので、ライブラリのAPIの見通しがよくなるといったメリットもあります。

Duck Typing

duck typing は動的型付け言語による、「とりあえずやってみてエラー無く動いたらオッケー」という型システムです。(というかこれは型システムなのか?)

次のコードを見てください。

def read_packet(s):
    n = s.recv(1)[0]
    return s.recv(n)

このコードは、ソケットのようなオブジェクトを引数に取ることを期待しています。そのオブジェクトは .recv(int) というメソッドを実装していて、バイト列を返すことを期待しています。バイト列に対して [0] するとその整数値を取り、また s.recv(int) を呼び出して、その戻り値のバイト列を返す、という想定です。

このコードは、次のようにして呼び出してもエラー無く実行することができます。

class S:
    def recv(self, x):
        return [x]

print(read_packet(S()))  # => [1]

戻り値の型は bytes を期待していたのに、配列が返ってきましたね。でも、エラーなく実行できたんだから、これでOKというのが duck typing です。 利用者のメンタルモデルとしては、 インターフェイスの定義を省略した structural subtyping (実際にインターフェイスの型の定義を省略できる structural typing の言語があります。後述) のような型がふんわりと頭の中にあると思いますが、実際の duck typing はもっとガバガバです。

この例だけで、一番のデメリットである脆弱性(型が実行時エラーから何も守ってくれない&実行時エラーにすらならず出力が壊れる危険もある)は十分伝わったと思います。

ですが、このデメリットは非実用的というほどは大きくありません。まず、こんなパズル的なコードを書かない限り、ちゃんと実行時エラーで丁寧なエラーメッセージとバックトレースを吐いて止まります。なので、気軽になんども実行できるようなスクリプトであれば、実行時エラーを見ながら修正するサイクルは、ビルドが遅い言語でコンパイルエラーを見ながら修正するサイクルより早いことだってありえます。

一方のメリットは、インターフェイスの定義が要らない分だけコードが簡潔になることです。 Javaインターフェイスを見る時間で、 PythonRuby なら実装を読めてしまうことだってあります。

とはいえ、プログラムが大規模化してくると、リファクタリングツールや静解析ツール等に十分なヒントを与えられないために改修コストがどんどん膨れ上がる危険性があります。 Python が type hinting を導入したのもそれが理由です。 Python 自体が型を使うのではなく(将来そういうモードが追加される可能性はありますが)、 mypy, Jedi, PyCharm などの静解析、コード補完、IDEが型情報を使うので、 typing ではなく type hinting と呼んでいます。

Python の Gradual Typing

Python の type hinting で使われているシステムが PEP 483 で紹介されている Gradual Typing です。 漸進的型付けとは何か も良い紹介記事なので参考にしてください。

ざっくりと言えば、 Gradual Typing とは、 通常の Subtyping に Any という特殊な型を追加したものです。

通常の部分型関係は順序関係になっていて、推移律が成り立ちます。つまり、 B が A のサブクラス、 C が B のサブクラスなら、 C は A のサブクラスでもあります。 (C > B > A) なので、 C 型の値を A 型にアップキャスト(サブタイプからスーパータイプへのキャスト)が可能です。

一方で Any 型は部分型関係の外にいて、すべての型から Any にキャストできますし、 Any からすべての型にキャストできます。ですが部分型関係の外にいるので推移律も当てはまらず、 A が Any にキャストできて、 Any が C にキャストできるからといって、直接 A から C へのキャストが許される訳ではありません。

この Any は type hint がまだついてないとか、任意の型を取りうるという意味になります。例えば JSON の object を dict にするとき、がんばらない型ヒントは Dict[string, Any] になります。(頑張ると Any ではなく Union という「幾つかの型のどれか」型になるのですが、その Union が取り得る型の1つが JSON object を表す dict 型になるので、再帰的な定義をすることになります。)

さて、type hinting をどんどん書いていくと、結局静的型付け言語と同じくらいコードが増えそうな気がします。

しかし実際にはそこまで増えないことが多そうです。というのは、上の例であったような「モックにすげ替えるためだけにインターフェイス型を用意する」必要がないからです。 モックオブジェクトを Any 型にしてしまえば、type hint に具象型を直接書いてる引数にでもモックを突っ込めますし、そもそも mypy などの静解析ツールの対象からテストディレクトリを外してしまうという手だってあります。

Python の type hinting はまだまだ開発途中で実用段階とは言い難いですが、だいたいこんな感じで、動的型の特徴を殺すこと無く静的型のメリットの一つである手厚いツールの支援を取り込む方向で進んでいます。

Ruby Kaigi で語られた構想では、 Python よりももっとプログラマーが書く型情報が減る方向で考えられているようなので、どうなっていくのか期待して見守りたいと思います。

追記: 静的な duck typing

上の例では structural subtyping と duck typing の比較として Go と Python を比べていましたが、実際には structural subtyping は単に部分型に関するルールでしかないので、直接比較できるようなものではありません。

実際に、引数の型をコード上に書かなくても静的な duck typing とよべるような型導出を行ってくれる言語 (OCaml が有名) もあります。つまり、

def read_packet(s):
    n = s.recv(1)[0]
    return s.recv(n)

このコードから、 "s の型は、 .recv(int) というメソッドを持っていて、そのメソッドの戻り地は添字アクセス (x[0]) できるような型で、..." という暗黙の型を実行せずに静解析で導出します。あとはこの関数を呼び出すときの実引数の型が、導出された暗黙の型のサブタイプかどうかを structural subtyping でテストします。

このコードから暗黙の型を導出するステップは静的な duck typing の用にみえますし、動的な duck typing でプログラムしているプログラマーの頭の中にふんわりと存在する型ともかなり一致すると思います。たぶん Matz の soft-typing 構想にも近くて、これが冒頭の Matz の発言につながったのだと思います。

動的な duck typing はもっとガバガバ(引数の型ではなく値によって戻り値の型が変わるなんてこともザラ)なので、この静的な duck typing は動的 duck typing に対する完全な静的型チェックにはならないですが、 Python の Gradual Typing が Any を導入したのと同じようになんらかのクッションを挟むことでうまく適用できる可能性があります。