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

業務で使う関数型言語 (番外編) 〜 タプルに対する誤解

業務で使う関数型言語の番外編第一回は、タプルに対する誤解についてです。
以下の項目のどれかに当てはまる人は是非読んでください。

  • タプルってリスト (もしくは配列) みたいなものでしょ?
  • タプルってリストより使いにくいから全然使ってないや
  • タプルって変更不可能なリストでしょ?
  • タプルって何・・・?

タプルとは

タプルは「フィールド名のない構造体 (もしくはクラス)」です。
フィールド名がないのにどうやってフィールドにアクセスするのさ・・・と思うかもしれませんが、とりあえず先に進めます。
タプルがどういうものか、どう使うのか、ということを、簡単な問題を通して見ていきましょう。

名前と年齢がカンマ区切りで記述されたファイルの中から、一番若い人の名前と年齢を取り出したい。

例えば、

hoge,20
piyo,25
foo,19
bar,24

こんな内容のファイルの場合、「foo」と「19」を取り出します。
これをまずは Java で書いてみます。
ファイルからの読み込みは本題ではないので、行ごとに分割された文字列リストを取る形にしています*1

/* 入れ物となるクラスを用意 */
class Person {
    final String name;
    final int age;
    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    // 行からPersonに変換するヘルパメソッド
    private static Person toPerson(String line) {
        String[] ss = line.split(',');
        return new Person(ss[0], Integer.parseInt(ss[1]));
    }
    // 若い方を返すヘルパメソッド
    private static Person younger(Person a, Person b) {
        return a.age < b.age ? a : b;
    }
    // 本体
    static Person youngestPerson(List<String> lines) {
        Person youngest = toPerson(lines.get(0));
        for (String line : lines.subList(1, lines.size())) {
            youngest = younger(youngest, toPerson(line));
        }
        return youngest;
    }
}
/* 使う側(linesに入力が入っている想定) */
Person youngest = Person.youngestPerson(lines);
System.out.printf("name: %s, age: %d%n", youngest.name, youngest.age);

Person を他でも使うのならこれでもいいでしょうが、今回はそこまで求めていませんので、面倒です。
なので、人によっては Person を作らず、文字列のまま扱うようなこともあるでしょう。


これを、タプルを使って F# で書いてみます。

(* 余分なクラスは不要 *)
let youngestPerson lines =
  // 文字列配列をタプル(これをPersonとみなす)にして返す関数内関数
  let toPerson line =
    let ss = (line: string).Split(',')
    (ss.[0], int ss.[1])

  // 全ての行に対してtoPersonする
  let ps = lines |> List.map toPerson
  // 年齢(タプルの2つ目。sndという関数で取り出せる)のもっとも若いものをこの関数の値とする
  ps |> List.minBy snd

(* 使う側(linesに入力が入っている想定) *)
let name, age = youngestPerson lines
printfn "name: %s, age: %d" name age

このように、タプルを使うと専用の入れ物を用意せずに扱うことができるようになります。
Java の例と F# の例を比べるとわかるように、タプルは「クラスにするまでもないようなデータの持ち運び」にその威力を発揮します。
これを例えばリストでやろうとした場合、どうなるでしょうか?

let youngestPerson lines =
  let toPerson line =
    let ss = (line: string).Split(',')
    [ box ss.[0]; box (int ss.[1]) ]

  lines
  |> List.map toPerson
  |> List.minBy (fun [_; age] -> age :?> int)

(* 使う側(linesに入力が入っている想定) *)
let [name; age] = youngestPerson lines
printfn "name: %O, age: %O" name age

リストを使うと、age :?> int のように、キャストが必要となってしまいます。
出力するだけなのでわかりにくいですが、youngestPerson の戻り値の型はタプル版だと string * int だったの対して、リスト版では obj list となります。
そのため、age を int として扱いたい場合はやはり使う側でもキャストが必要となってしまいます。
これは、せっかく静的型付けの言語を使っているのに、コンパイラでチェックできない部分ができてしまうということです。


この例では、string * int という型のタプルを使いましたが、例えば X 座標と Y 座標を保持するような場合はリストでは駄目なのでしょうか?
その場合だと、float list となってキャストが不要となるので問題ないように思えます。
しかし、リストはコンパイル時に長さが分からないため、単純に let でばらそうとしても警告が出てしまいます。
それに対してタプルには「長さ」と言う概念がありませんので、あるタプルがどんなフィールドを持っているかがコンパイル時にわかるということです。
そのためタプルは単純かつ安全に let でばらすことができます。

(* リスト版 *)
let f x y = [x + 1.0; y + 1.0]

let [x; y] = f 10 20  // リストの長さはコンパイル時にわからないので、
                      // このパターンマッチは警告が出る
                      // 例えば、fの実装が[x + 1.0]と間違っていたら実行時エラー

(* タプル版 *)
let g x y = (x + 1.0, y + 1.0)

let x, y = f 10 20    // gの戻り値の型はfloat * floatとわかるので、
                      // 安全にばらせるし警告も出ない
                      // 例えば、gの実装が(x + 1.0)と間違っていたらコンパイルエラー


このように、タプルとリストは全く用途が異なるものなのです。
リストが「同じ型の列を扱うためにある」のに対して、タプルは「型を組み合わせるために」使います。

タプルの長さ?

「タプルには長さと言う概念がありません」と書きましたが、一番最初の例でタプルの 2 番目の要素を取り出す snd 関数を使いました。
タプルの 2 番目の要素を取り出すことができるってことは、任意の要素を取り出せるんじゃないの?というかつまり長さもあるんじゃないの?
と思うかもしれません。


しかし、この snd 関数と言うのは、2 要素タプルに対してしか使えません。
つまり、

(* これはコンパイルエラー *)
let t3 = (1, 2, 3)
let e2 = snd t3

はできません。
snd 関数の定義は、例えば

let snd (x, y) = y

のようになっており、snd 関数に任意の要素数のタプルを渡すことはできないのです(型が違う)。

タプルの比較

先ほどの例に少し変更を加えます。
「使う側」で、すでに otherPerson というデータを持っていて、これと youngestPerson が返したものが同じかどうかを調べたい、とします。
Java の場合、そのままでは駄目なので equals メソッドをオーバーライドするか、外に比較用のメソッドを用意する必要があります。

// 比較用メソッドを用意
private boolean eq(Person a, Person b) {
    if (a == null) return b == null;
    if (b == null) return false;
    return a.age == b.age && a.name == b.name;
}

// 使う側
Person youngest = Person.youngestPerson(lines);
System.out.println(eq(youngest, otherPerson) ? "eq" : "ne");

equals メソッドをオーバーライドするのは面倒ですし、用意するなら hashCode も用意しないと警告が出たりしますので、ここでは外部にメソッドを実装する方式を取りました。
これがタプルになると、タプルは何も用意しなくても比較が可能ですので、

let youngest = youngestPerson lines
printfn (if youngest = otherPerson then "eq" else "ne")

これだけで OK です。
他にも、F# におけるタプルは %A という書式指定子を使うことによって、簡単に文字列化することも可能です。


このように、F# のタプルは (Java で言う) equals、hashCode、toString が実装されたフィールド名を持たないクラスのようなもの、と言えます。

リストとタプル

北海道で発表したときのスライドに、リストとタプルの違いを図にしたものがありました。

上で長々と説明してきましたが、要はこんな感じです。
次回はパターンマッチについて取り上げる予定です。多分その前に本編を書きます。

おまけ:Java で youngestPerson の実装を F# に近くする

F# で書いた youngestPerson は変数を取り除くととてもシンプルになります。

let youngestPerson lines =
  let toPerson line =
    let ss = (line: string).Split(',')
    (ss.[0], int ss[1])

  lines
  |> List.map toPerson
  |> List.minBy snd

Java でこれに近づけるためには、Collections クラスの力を借りることになります。

private Person toPerson(String line) {
    String[] ss = line.split(',');
    return new Person(ss[0], Integer.parseInt(ss[1]));
}
Person youngestPerson(final List<String> lines) {
    List<Person> ps = new ArrayList<Person>() {{ for (String l : lines) add(toPerson(l)); }};
    return Collections.min(ps, new Comparator<Person>() {
        public int compare(Person a, Person b) { return a.age < b.age ? a : b; }
    });
}

Comparator の部分がどうしても面倒ですね。
Java SE 8 では、Java にもとうとうラムダ式が入るらしいです。どうなるかよくわかってませんが、おそらく

private Person toPerson(String line) {
    String[] ss = line.split(',');
    return new Person(ss[0], Integer.parseInt(ss[1]));
}
Person youngestPerson(List<String> lines) {
    List<Person> ps = lines.map(l -> toPerson(l));
    return Collections.min(ps, (a, b) -> a.age < b.age ? a : b);
}

このように書けるようになるのではないでしょうか?

*1:他のあれこれ (要素数だとか、名前部分のカンマだとか) も察してください