Elm アドベントカレンダーの4日目です。

3日目は @arowM さんのSPAルーティングで特殊クリックをあつかうでした。

この手の記事無限に書いている気もしますが、、、 今回は普段オブジェクト指向の言語を触っていて関数型を学んでみたい、けど腰が重いと思ってる方や関数型を学び始めたが、どう書いていくかがわからない、そんな方に向けた記事になります。オブジェクト指向の言語だと広すぎるのでJavaのプログラムをサンプルとしてあげ純粋関数型言語Elmでは、どのように表現するのか、というのを意識して書いてみました。

Class

関数型言語では関数とデータ構造を組み合わせて行くことでプログラミングをおこなっていきます。Javaではクラスを定義していくことでプログラミングをおこなっていきます。この説明だけを見ると関数型は、C言語と何が違うのだろう? オブジェクト指向と関数型はまったく違う系統の言語ではないのかと疑問がフツフツと沸いてきますが、実際のところそうでもありません。まずはじめに簡単なクラスを用意してみます。フィールドとGetter, SetterObjectクラスで定義されているいくつかのメソッドをオーバーロードしたPersonクラスです。注意として「必ず」メソッドが値を返す形になっています。Setterは無理ではないか?と思うかもしれませんが、新しい値をセットした新たなオブジェクトを返すことでイミュータブルなオブジェクトとして設計されています。

Person.java

// new Person("Takeshi", 15)

public class Person {
    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public Person setName(String name) {
        return new Person(name, age);
    }

    public int getAge() {
        return age;
    }

    public Person setAge(int age) {
        return new Person(name, age);
    }

    public String introduce() {
        String hi = "Hi! ";
        String n = "My name is " + name + "! ";
        String a = "My age is " + age + ".";

        return hi + n + a;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Person person = (Person) o;

        if (age != person.age) return false;
        return name != null ? name.equals(person.name) : person.name == null;
    }

    @Override
    public int hashCode() {
        int result = name != null ? name.hashCode() : 0;
        result = 31 * result + age;
        return result;
    }

    @Override
    public String toString() {
        return "Person \"" + name + "\" " + age;
    }
}

それではPersonクラスをElmで再現してみましょう。type Person = Person Name Age僅か一行でデータ自体の定義は終わります。これはunion typeと言いフィールドの定義とコンストラクタ、そしてtoString, equals, hashCodeを言語(Elm)側で一気に定義してくれます。Person "Takeshi" 15このように書くことでインスタンス(とは言いませんが)を生成することができます。NameAgeは単なるStringIntの型aliasに過ぎませんが、aliasを張るだけでとても定義が見やすくなります(JavaにもDDDするときに欲しいですね)。

Person.elm

module Person exposing (Person(..), getName, setName, getAge, setAge)


type alias Name =
    String


type alias Age =
    Int


{-| Person "Takeshi" 15
(Person "Takeshi" 15 |> toString) == "Person "Takeshi" 15"
-}
type Person
    = Person Name Age

Elmでは関数でメソッドを表現します。メソッドとは引数とオブジェクトそのものも引数として受け取る関数であるとみなすことができます。引数の順序はどのような順番でも動作は変わりませんが、引数1 -> 引数2 -> 引数3 ... -> オブジェクト -> 戻り値の型という順序で定義することがElmでは推奨されます。その秘密はパイプ演算子(|>)にあります。パイプ演算子は関数の引数を左辺から右辺に流すような形で使うことができます。x |> f == f x パイプを使うとメソッドチェーンのようなコードが書きやすくなります。

-- setName : Name -> Person -> Person
setName "John" (Person "Takeshi" 15 ) == Person "John" 15

-- new Person("Takeshi", 15).setName("John") のように流れるように読める!
(Person "Takeshi" 15 |> setName "John") == Person "John" 15

それではPerson型のメソッド(関数群)をご覧ください。union typeで書かれた型は、パターンマッチと呼ばれる構文でフィールドごとに分解することができます。使わないフィールドは、_で無視することができます。let in構文は、let節でローカル変数を定義し、inで最終的な戻り値を返す式を書きます。

{-| (Person "Takeshi" 15 |> getName) == "Takeshi"
-}
getName : Person -> Name
getName (Person name _) =
    name



{-| (Person "Takeshi" 15 |> setName "John") == Person "John" 15
-}
setName : Name -> Person -> Person
setName name (Person _ age) =
    Person name age


{-| (Person "Takeshi" 15 |> getAge) == 15
-}
getAge : Person -> Age
getAge (Person _ age) =
    age


{-| (Person "Takeshi" 15 |> setAge 20) == Person "John" 20
-}
setAge : Age -> Person -> Person
setAge age (Person name _) =
    Person name age


{-| (Person "Takeshi" 15 |> introduce) == "Hi! My name is Takeshi! My age is 15."
-}
introduce : Person -> String
introduce (Person name age) =
    let
        hi =
            "Hi! "

        n =
            "My name is " ++ name ++ "! "

        a =
            "My age is " ++ toString age ++ "."
    in
        hi ++ n ++ a

構文は多少面食らうかもしれませんが、アレ?OOPもFPもやっていることは変わらないぞ?と言う気持ちになってきませんでしょうか。これが考え方の基本形になります。また、Personのようなデータ構造の場合、Getter, Setterがあらかじめ定義されているレコード構文を使うことでもっとシンプルに記述することができます。書き方はJsonに似た記述方法で、.fieldの形でアクセスできるので、よりオブジェクト指向の書き方に近い書き方になります。レコード用のパターンマッチ構文も用意されています。

type alias Person = { name : Name, age : Age }

-- レコードの生成
takeshi : Person
takeshi = { name = "Takeshi", age = "15" }

-- Getter
> takeshi.name
"Takeshi" : String
-- Setter
> { takeshi | age = 20 }
{ name = "Takeshi", age = 20 } : { name : String, age : number }

introduce : Person -> String
introduce { name, age } =
    let
        hi =
            "Hi! "

        n =
            "My name is " ++ name ++ "! "

        a =
            "My age is " ++ toString age ++ "."
    in
        hi ++ n ++ a

Composition

クラス定義が出来たら今度はCompositionを再現してみましょう。Javaでのサンプルコードはこちらです。しっかり書こうとすると中々ヘビーです(万人にわかりやすくLombok等のライブラリを使っていません。)。

Foo.java

package composition;

// new Foo(5, new Bar(6, 7));

public class Foo {
    private final int x;
    private final Bar bar;

    public Foo(int x, Bar bar) {
        this.x = x;
        this.bar = bar;
    }

    public int getX() {
        return x;
    }

    public Foo setX(int x) {
        return new Foo(x, bar);
    }

    public int getY() {
        return bar.getY();
    }

    public Foo setY(int y) {
        return new Foo(x, bar.setY(y));
    }

    public int getZ() {
        return bar.getZ();
    }

    public Foo setZ(int z) {
        return new Foo(x, bar.setZ(z));
    }


    public Bar getBar() {
        return bar;
    }

    public int calc() {
        return x + bar.getY() + bar.getZ();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Foo foo = (Foo) o;

        if (x != foo.x) return false;
        return bar != null ? bar.equals(foo.bar) : foo.bar == null;
    }

    @Override
    public int hashCode() {
        int result = x;
        result = 31 * result + (bar != null ? bar.hashCode() : 0);
        return result;
    }

    @Override
    public String toString() {
        return "Foo " + x + " (" + bar + ')';
    }
}

Bar.java

package composition;

public class Bar {
    private final int y;
    private final int z;

    public Bar(int y, int z) {
        this.y = y;
        this.z = z;
    }

    public int getY() {
        return y;
    }

    public Bar setY(int y) {
        return new Bar(y, z);
    }

    public int getZ() {
        return z;
    }

    public Bar setZ(int z) {
        return new Bar(y, z);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Bar bar = (Bar) o;

        if (y != bar.y) return false;
        return z == bar.z;
    }

    @Override
    public int hashCode() {
        int result = y;
        result = 31 * result + z;
        return result;
    }

    @Override
    public String toString() {
        return "Bar " + y + ' ' + z;
    }
}

基本的にはClassで説明した内容と同じです。フィールドにBar型を持ってあげるだけです。パターンマッチはunion typeがネストした場合でも問題無く構造を分解することができます。

module Foo exposing (..)

import Bar exposing (..)


{-| Foo 5 (Bar 6 7)
-}
type Foo
    = Foo Int Bar


getX : Foo -> Int
getX (Foo x _) =
    x


setX : Foo -> Int -> Foo
setX (Foo _ bar) x =
    Foo x bar


getY : Foo -> Int
getY (Foo _ (Bar y _)) =
    y


setY : Foo -> Int -> Foo
setY (Foo x (Bar _ z)) y =
    Foo x (Bar y z)


getZ : Foo -> Int
getZ (Foo _ (Bar _ z)) =
    z


setZ : Foo -> Int -> Foo
setZ (Foo x (Bar y _)) z =
    Foo x (Bar y z)


{-| (Foo 5 (Bar 6 7) |> calc) == 18
-}
calc : Foo -> Int
calc (Foo x (Bar y z)) =
    x + y + z

パターンマッチを利用することで構造が透過的に扱うことができ、Compositionされる側のBar型は一切の関数を持たなくなっています。もちろん構造や使われ方が複雑な場合はJavaの例と同様にモジュールごとの関数の責任はそれぞれ分担した方が良いと思います。また、カプセル化を意識してコーディングすることも可能です。先ほどのClassの説明のようにレコードをネストしても構いません(少し更新が複雑になりますが)。この辺りの書き方の自由度はJavaとまったく同じです(知ったときはあまりにも同じ感覚で出来るのでびっくりしました)。

module Bar exposing (..)


type Bar
    = Bar Int Int

Polymophism

Elm流のクラスの作り方、Compositionを通してカプセル化やモジュールの利用が把握できたと思います。もちろんこれだけでは満足しないと思います。インターフェースを利用したPolymorphismをElmで再現してみましょう。簡単な計算をするTreeとVisitorパターンをサンプルコードとして使ってみましょう。例を簡単にするために計算式は、数字と足し算のみをサポートします。

NodeTest.java

package polymorphism;

public class NodeTest {
    public static void main(String[] args) {
        Node tree = new AddNode(
                new AddNode(
                        new NumNode(2),
                        new NumNode(3)),
                new NumNode(4)
        );
        // ((2 + 3) + 4) = 9
        System.out.println(tree.accept(new Calculator()));
    }
}

Node.java

package polymorphism;

public interface Node {
    int accept(Visitor visitor);
}

NumNode.java


package polymorphism;

public class NumNode implements Node {
    private final int value;

    public NumNode(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    @Override
    public int accept(Visitor visitor) {
        return visitor.visit(this);
    }
}

AddNode.java

package polymorphism;

public class AddNode implements Node {
    private final Node left;
    private final Node right;

    public Node getLeft() {
        return left;
    }

    public Node getRight() {
        return right;
    }

    public AddNode(Node left, Node right) {
        this.left = left;
        this.right = right;
    }

    @Override
    public int accept(Visitor visitor) {
        return visitor.visit(this);
    }
}

Visitor.java

package polymorphism;

public interface Visitor {
    int visit(NumNode node);

    int visit(AddNode node);
}

Calculator.java

package polymorphism;

public class Calculator implements Visitor {
    @Override
    public int visit(NumNode node) {
        return node.getValue();
    }

    @Override
    public int visit(AddNode node) {
        int left = node.getLeft().accept(this);
        int right = node.getRight().accept(this);

        return left + right;
    }
}

Visitorパターンなどのポリモーフィズムをフル活用しているようなコードは実はElmは大得意です。今まで利用していたunion typeですが型の直和を利用することで本領を発揮します。type Node = Num Int | Add Node Node Node型は、NumAddの2つの型から構成され、さらにAddは、自身のNode型を2つ持つ再帰的な構造になっています。形自体はJavaのNode型と一緒ですが、使われ方が異なります。calc関数を見てみましょう。case node ofという新しい構文が見えます。これはパターンマッチの一種で引数ではなく式として使われるときの書き方です。union typeの直和型を場合分けするときに使われます。見方を変えれば、Num型とAdd型にcalcメソッドを持ったインターフェースを実装していると見ることができます。またAdd型の場合はcalcを再帰的に呼び出し右辺と左辺の値を求めていることがわかります。ツリーはunion typeにより直感的に表現することができます。

Node.elm

module Node exposing (..)

import Html exposing (..)


type Node
    = Num Int
    | Add Node Node


calc : Node -> Int
calc node =
    case node of
        Num n ->
            n

        Add l r ->
            let
                lValue =
                    calc l

                rValue =
                    calc r
            in
                lValue + rValue


main : Html msg
main =
    let
        tree =
            Add
                (Add
                    (Num 2)
                    (Num 3)
                )
                (Num 4)
    in
        text <| toString <| calc tree

少し慣れるのに時間が掛かるかもしれませんが、基本的にunion type + パターンマッチを用いた関数でオブジェクト指向でやっていたことが簡単に再現することができます。非常に簡潔に書けるケースが多いので是非マスターしましょう。

Collection

せっかくなのでjshellとJava9を使ってCollectionの比較をしてみましょう。実行結果を併記しますので違いを感じとってみてください。

List

List.java

jshell> IntStream.rangeClosed(1, 10).mapToObj(n -> n * 2).collect(Collectors.toList())
$1 ==> [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
jshell> IntStream.rangeClosed(1, 10).filter(n -> n % 2 == 0).boxed().collect(Collectors.toList())
$2 ==> [2, 4, 6, 8, 10]
jshell> List<Integer> list = IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toList())
$3 ==> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
jshell> Collections.reverse($3)
jshell> $3
$3 ==> [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
jshell> IntStream.rangeClosed(1, 10).limit(5).boxed().collect(Collectors.toList())
$4 ==> [1, 2, 3, 4, 5]
jshell> IntStream.rangeClosed(1, 10).skip(5).boxed().collect(Collectors.toList()
$5 ==> [6, 7, 8, 9, 10]
jshell> Stream.generate(() -> "a").limit(10).collect(Collectors.toList())
$6 ==> [a, a, a, a, a, a, a, a, a, a]
Stream.concat(Stream.concat(Stream.of(1, 2), Stream.of(3)), Stream.of(4, 5)).collect(Collectors.toList())
$7 ==> [1, 2, 3, 4, 5]
jshell> IntStream.rangeClosed(1, 5).flatMap(i -> IntStream.of(0, i)).boxed().skip(1).collect(Collectors.toList())
$8 ==> [1, 0, 2, 0, 3, 0, 4, 0, 5]
jshell> IntStream.rangeClosed(1, 10).boxed().collect(Collectors.partitioningBy(n -> n % 2 == 0))
$9 ==> {false=[1, 3, 5, 7, 9], true=[2, 4, 6, 8, 10]}
jshell> String[] alphas = {"a", "b", "c", "d", "e"}
alphas ==> String[5] { "a", "b", "c", "d", "e" }
jshell> IntStream.range(0, 5).boxed().collect(Collectors.toMap(i -> i, i -> alphas[i])).entrySet()
$10 ==> [0=a, 1=b, 2=c, 3=d, 4=e]

List.elm

> List.range 1 10 |> List.map (\n -> n * 2)
[2,4,6,8,10,12,14,16,18,20] : List Int
> List.range 1 10 |> List.filter (\n -> n % 2 == 0)
[2,4,6,8,10] : List Int
> List.range 1 10 |> List.reverse
[10,9,8,7,6,5,4,3,2,1] : List Int
> List.range 1 10 |> List.take 5
[1,2,3,4,5] : List Int
> List.range 1 10 |> List.drop 5
[6,7,8,9,10] : List Int
> List.repeat 10 "a"
["a","a","a","a","a","a","a","a","a","a"] : List String
> List.concat [[1,2],[3],[4,5]]
[1,2,3,4,5] : List number
> List.range 1 5 |> List.intersperse 0
[1,0,2,0,3,0,4,0,5] : List Int
> List.range 1 10 |> List.partition (\n -> n % 2 == 0)
([2,4,6,8,10],[1,3,5,7,9]) : ( List Int, List Int )
> List.indexedMap (,) ["a","b","c","d","e"]
[(0,"a"),(1,"b"),(2,"c"),(3,"d"),(4,"e")] : List ( Int, String )

Map

Map.java

jshell> Map.of("a", 1, "b", 2, "c", 3)
$1 ==> {b=2, c=3, a=1}
jshell> Map.of("a", 1, "b", 2, "c", 3).keySet()
$2 ==> [c, b, a]
jshell> Map.of("a", 1, "b", 2, "c", 3).values()
$3 ==> [3, 2, 1]
jshell> Map.of("a", 1, "b", 2, "c", 3).containsKey("a")
$4 ==> true
jshell> Map.of("a", 1, "b", 2, "c", 3).get("a")
$5 ==> 1
jshell> Map.of("a", 1, "b", 2, "c", 3).get("d")
$6 ==> null
jshell> Map.of("a", 1, "b", 2, "c", 3).getOrDefault("d", -1)
$7 ==> -1
jshell> HashMap<String, Integer> hash = new HashMap<>(Map.of("a", 1, "b", 2, "c", 3)))
hash ==> {a=1, b=2, c=3}
jshell> hash.put("d", 4)
$8 ==> null
jshell> hash.get("d")
$9 ==> 4
jshell> HashMap<String, Integer> hash = new HashMap<>(Map.of("a", 1, "b", 2, "c", 3))
hash ==> {a=1, b=2, c=3}
jshell> hash.replace("a", hash.get("a") + 10)
$10 ==> 1
jshell> hash
hash ==> {a=11, b=2, c=3}
jshell> HashMap<String, Integer> hash = new HashMap<>(Map.of("a", 1, "b", 2, "c", 3))
hash ==> {a=1, b=2, c=3}
jshell> hash.remove("a")
$35 ==> 1
jshell> hash
hash ==> {b=2, c=3}
// こっから先はツラすぎて断念

Map.elm

> Dict.fromList [("a",1),("b",2),("c",3)]
Dict.fromList [("a",1),("b",2),("c",3)] : Dict.Dict String number
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.keys
["a","b","c"] : List String
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.values
[1,2,3] : List number
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.member "a"
True : Bool
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.get "a"
Just 1 : Maybe.Maybe number
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.get "d"
  Nothing : Maybe.Maybe number
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.get "d" |> Maybe.withDefault -1
-1 : number
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.insert "d" 4 |> Dict.get "d"
Just 4 : Maybe.Maybe number
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.update "a" (\m -> Maybe.map (\v -> v + 10) m)
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.update "a" (Maybe.map (\v -> v + 10))
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.update "a" (Maybe.map ((+) 10))
Dict.fromList [("a",11),("b",2),("c",3)] : Dict.Dict String number
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.remove "a"
Dict.fromList [("b",2),("c",3)] : Dict.Dict String number
-- ここからElmのみ
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.map (\k v -> k ++ toString v)
Dict.fromList [("a","a1"),("b","b2"),("c","c3")] : Dict.Dict String String
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.foldl (\k v z -> v + z) 0
6 : number
> Dict.fromList [("a",1),("b",2),("c",3)] |> Dict.filter (\k v -> v >= 2)
Dict.fromList [("b",2),("c",3)] : Dict.Dict String number
> Dict.union (Dict.fromList [("a",1),("b",2),("c",3)]) (Dict.fromList [("d",4)])
Dict.fromList [("a",1),("b",2),("c",3),("d",4)] : Dict.Dict String number
> Dict.diff (Dict.fromList [("a",1),("b",2),("c",3)]) (Dict.fromList [("a",1)])
Dict.fromList [("b",2),("c",3)] : Dict.Dict String number

まとめ

関数型言語への入門にためらいがある人や、オブジェクト指向言語とのギャップに苦しみを感じている人に、やりたいことに差は無いよ!ということに気づいて欲しく記事にまとめました。また関数型言語の特徴として豊富なコレクションとコレクション操作のための関数を是非触れて欲しくて、ざっと実行例を並べてみました。楽しみながら関数型に入門していただけるとうれしいです!