それは Duck Typing ぢゃない(らしい)

no extension

今回は Go と Rust との比較をちょっとポエミーに語ってみる(笑)

そもそも duck typing は Ruby のような動的型付け言語における型推論の手法(のひとつ)である。 その由来は duck test から来ていて

If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.

というフレーズに集約されている。

静的型付け言語である Go や Rust における抽象型を使った型決定を duck typing と呼ぶのは厳密には正しくない,らしい。 Go や Rust における interface や trait といった抽象型を用いた型決定は「部分型付け(subtyping)」と呼ばれる。 ただし Go と Rust では全く異なる戦略をとる。

Cat コマンドもどき(Go 版)

ここで簡単なプログラムを書いてみよう。 UNIX 系のプラットフォームではおなじみの cat コマンドの「もどき」を書いてみる。

本来の cat コマンドは複数の入力を結合(concatenate)して出力するものだが,真面目な実装をし始めるとキリがないので,今回は以下の2つの機能のみ実装する。

  1. コマンドライン引数で指定したファイルを1つのみ標準出力に出力する
  2. ファイルの指定がない場合は標準入力をそのまま標準出力に出力する

ぶっちゃけ,ただの「土管」である(笑) これを Go で書いたのが以下のコードだ。

package main

import (
    "fmt"
    "io"
    "os"
)

func concatenate(w io.Writer, r io.Reader) error {
    _, err := io.Copy(w, r)
    return err
}

func main() {
    if len(os.Args) > 1 {
        file, err := os.Open(os.Args[1])
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            return
        }
        defer file.Close()

        if err := concatenate(os.Stdout, file); err != nil {
            fmt.Fprintln(os.Stderr, err)
            return
        }
    } else {
        if err := concatenate(os.Stdout, os.Stdin); err != nil {
            fmt.Fprintln(os.Stderr, err)
            return
        }
    }
}

concatenate() 関数がメインのロジックで,引数の io.Writer, io.Reader および返り値の error は全て interface 型である。 まぁ concatenate() 関数を括り出す必然性は全くないのだが,後述の Rust のコードと比較しやすいよう敢えて分けている。

concatenate() 関数の呼び出しで,最初の

if err := concatenate(os.Stdout, file); err != nil {
    fmt.Fprintln(os.Stderr, err)
    return
}

と次の

if err := concatenate(os.Stdout, os.Stdin); err != nil {
    fmt.Fprintln(os.Stderr, err)
    return
}

は(当然ながら)同じ関数で,引数や返り値にどのようなインスタンスが入るかは実行時に決まる。 コンパイル時に決まるのは注入するインスタンスの構造が受け入れる interface 型の構造と合致していることだけだ(合致しなければコンパイル・エラー)。

すンごい簡単に書かれているけど,これは「依存の注入(depencency injection)」の典型例である。

では,これをリファレンスとして,今度は Rust を使って書いてみる。

Cat コマンドもどき(Rust 版,総称型編)

とりあえず,えいやっで書いたコードがこちら。

fn concatenate<W, R>(w: &mut W, r: &mut R) -> Result<(), std::io::Error>
where
    W: std::io::Write,
    R: std::io::Read,
{
    let mut buf = Vec::new();
    r.read_to_end(&mut buf)?;
    w.write_all(&buf)?;
    Ok(())
}

fn main() -> Result<(), std::io::Error> {
    let args = std::env::args();
    if args.len() > 1 {
        for s in args.skip(1).take(1) {
            concatenate(
                &mut std::io::stdout(),
                &mut std::io::BufReader::new(std::fs::File::open(s)?),
            )?;
        }
    } else {
        concatenate(&mut std::io::stdout(), &mut std::io::stdin())?;
    }
    Ok(())
}

std::io::Write と std::io::Read が trait 型なのだが,各 trait は総称型 W, R の制約条件として書かれているだけで実行時に機能するわけではない。 つまり最初の

concatenate(
    &mut std::io::stdout(),
    &mut std::io::BufReader::new(std::fs::File::open(s)?),
)?;

と次の

concatenate(&mut std::io::stdout(), &mut std::io::stdin())?;

はコンパイル時に別の関数として展開される1。 これを(多態化(polymorphization)に対する)単態化(monomorphization)と呼ぶ。

じゃあ Rust では依存の注入は書けないのかというと,勿論そんなことはない。

Cat コマンドもどき(Rust 版,依存注入編)

依存の注入ができるように書き換えたバージョンがこれ。

fn concatenate(
    w: &mut Box<dyn std::io::Write>,
    r: &mut Box<dyn std::io::Read>,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut buf = Vec::new();
    r.read_to_end(&mut buf)?;
    w.write_all(&buf)?;
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = std::env::args();
    let mut r: Box<dyn std::io::Read> = if args.len() > 1 {
        let fnam = match args.skip(1).next() {
            Some(s) => s,
            _ => "".to_string(),
        };
        Box::new(std::io::BufReader::new(std::fs::File::open(fnam)?))
    } else {
        Box::new(std::io::stdin())
    };
    let mut w: Box<dyn std::io::Write> = Box::new(std::io::stdout());
    concatenate(&mut w, &mut r)?;
    Ok(())
}

concatenate() 関数が同一のものであることを強調するために呼び出しをひとつに纏めているので少しまだるこしい書き方になっているが,ご容赦。 このように Rust では trait 型を Box<dyn Trait> の形式に落とし込むことで実行時の動的ディスパッチを可能にしている。

Accept Interfaces, Return Structs

Go の設計指針で有名な言葉に accept interfaces, return structs というのがある。 私自身は必ずしもこれに賛同しないが(システム内部のコンテキスト境界は interface にすべき),この指針は Go の特徴をよく表している。

たとえば io.Reader と os.File は Read() 関数という「同じ振る舞いを持つ」点で関連しているけど,両者の間に明示された記述は存在しない2。 それでも,その関係を以って io.Reader に os.File インスタンスを注入可能であり「Go では duck typing ができる」とか言われる所以である。 Go プログラマは息をするように依存を注入するのだ。

このような関係を構造型の部分型付け(structural subtyping)と呼ぶそうな。

構造型と公称型

Go の interface 型が構造型の部分型付けであるのに対し Rust の trait 型は公称型の部分型付け(nominal subtyping)に分類されるだろう。 たとえば std::io::Read と std::fs::File との間にはコード上で明示された関連がある。 その「明示された関連」がなければ,たとえ同じ構造を持っていたとしても,両者の間に関係があるとは見なされないのだ。

Rust の言語仕様がこのような制約を構成しているのには,勿論ちゃんとした理由がある。

Go においてはメモリ管理や並列処理3 といった面倒事をランタイム・モジュールに「丸投げ」している。 なので,プログラマは富豪的な記述に専念できるが,バイナリは肥大化してしまうしコンパイル時の最適化にも限度がある4。

Rust はリソース管理等についてプログラマ側でかなり面倒を見なければならないが(それでも C/C++ などに比べれば全然楽だし安全),言い換えればコード上でのコントロールがし易くコンパイル時の最適化についても期待できる。 上述の cat コマンドもどきでも,コンパイル時の単態化を避けるコードをわざわざ書く理由はないだろう。

これはプログラム設計時の重要なトレードオフとなる。 まぁ「Go か Rust か」みたいな究極の選択をする状況はないと思うが,複数のプログラミング言語からどれかを選ぶ際にはこういったことも考慮していくべきだ(選ぶ余裕もない事案のほうが多いだろうけどw)。

前にも書いたが,「それができる」ことと「そのように作られている」ことには天と地ほどの違いがある。 どうせ「書く」なら無茶せず楽しく書きたいものである。

ブックマーク

参考図書

photo
プログラミング言語Rust 公式ガイド
Steve Klabnik (著), Carol Nichols (著), 尾崎 亮太 (翻訳)
KADOKAWA 2019-06-28 (Release 2019-06-28)
単行本
4048930702 (ASIN), 9784048930703 (EAN), 4048930702 (ISBN)
評価     

[Comment] 公式ドキュメントの日本語版。索引がちゃんとしているので,紙の本を買っておいて手元に置いておくのが吉。

reviewed by Spiegel on 2020-02-24 (powered by PA-APIv5)

photo
プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)
Alan A.A. Donovan (著), Brian W. Kernighan (著), 柴田 芳樹 (翻訳)
丸善出版 2016-06-20
単行本(ソフトカバー)
4621300253 (ASIN), 9784621300251 (EAN), 4621300253 (ISBN)
評価     

[Comment] 著者のひとりは(あの「バイブル」とも呼ばれる)通称 “K&R” の K のほうである。この本は Go 言語の教科書と言ってもいいだろう。と思ったら絶版状態らしい(2025-01 現在)。復刊を望む!

reviewed by Spiegel on 2016-07-13 (powered by PA-APIv5)

photo
Go言語による並行処理
Katherine Cox-Buday (著), 山口 能迪 (翻訳)
オライリージャパン 2018-10-26
単行本(ソフトカバー)
4873118468 (ASIN), 9784873118468 (EAN), 4873118468 (ISBN)
評価     

[Comment] Eブック版もある。感想はこちら。 Go 言語で並行処理を書くならこの本は必読書になるだろう。

reviewed by Spiegel on 2020-01-13 (powered by PA-APIv5)


  1. 余談だが Rust では「ファイルを閉じる」操作は変数の生存期間満了時に暗黙的に行われるようだ。明示的に閉じるには drop 関数を使う。 ↩︎

  2. たとえば var _ io.Reader = (*os.File)(nil) のような記述で関連を明示することは可能。これはパッケージを書く際によく用いられる手法で,実行バイナリには反映されないが,事実上のコンパイラ・ヒントとして機能する。 ↩︎

  3. Go における並行処理と並列処理の違いについては『Go言語による並行処理』を読むことを強くおすすめする。 ↩︎

  4. 近年,特に組込み用途で注目されている TinyGo は LLVM 上で動作することを前提としていて,本家 Go に比べてかなり小さい実行バイナリを吐けるらしい。 ↩︎