やってみる

アウトプットすべく己を導くためのブログ。その試行錯誤すらたれ流す。

イミュータブルについて(副作用)

 今までのプログラミング言語には無かったので。

使いどころは?

 イミュータブルとミュータブルと定数って、どうやって使い分けるの? さっぱりわからん。

 というわけで、イミュータブルについて調べてみた。内容は保証できない。

イミュータブル=不変

 イミュータブルとは変数の値が変わらないもの。

なぜイミュータブルを使う?

 イミュータブルって何が嬉しいの? なぜ今まで可変(変数)だったところを不変(定数)でプログラミングしようとするの?

 イミュータブルによって状態(変数)を排除すると、副作用が生じなくなる。副作用(意図しない操作)が起こる余地をなくすことで、予期せぬ不具合を排する狙い。また、債務を明確に分離することでコードのメンテナンス性を向上させて不具合を作り込みにくくする。

 私はそう読み取った。

 以下参考。

副作用とは?

 副作用とは、意図的でない結果のこと。

 プログラミング言語における副作用とは、他の関数の結果に影響を与えてしまうこと。副作用が生じるのは共有変数の値を変えたときである。たとえば、ある関数内において他の関数が使用する変数(グローバル変数など)の値を変えてしまうことによって副作用が生じる。

 副作用を抑制するには、以下を満たす必要がある。

  1. 同じ条件を与えれば必ず同じ結果が得られる(参照透過性
  2. 他のいかなる機能の結果にも影響を与えない

副作用がある・ない判断

副作用がある

  • グローバル変数の読書
  • スタティック変数を持つ
  • 物理(フィジカル、ハード)の読書
    • 標準入出力を使う
    • ファイル入出力
    • メモリ
    • ネットワーク(IEEE
    • ヒューマンインタフェース入出力

副作用がない

  • 定数に依存する
  • ポインタを介して値を読書、関数呼出する
  • 副作用があるコードを呼び出していない

副作用の例

 副作用って、具体的にどういうもの? どんなコード書いたらどうなることをいうの?

 「副作用 コード例」でググった。

 これはいい記事。責務分離を追求するとユニットテストできないコードが減る。

 それは以下でも言われている。

なぜ副作用がいけないか

単体テストができないからです。

 以下、副作用コードの例。

Greet.java

public class Greet {
    public void greet() { System.out.println(getFirstMessage()); }
    private String getMessage() {
        int hour = LocalDateTime.now().getHour();
        if (6 <= hour && hour < 12) { return "おはよう ";}
        else if (12 <= hour && hour < 18) { return "こんにちは "; }
        else { return "おやすみ "; }
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        new Greet().greet();
    }
}

 greet, getMessage関数は副作用がある。入力(引数)が同じでも結果(戻り値)が異なりうるから。システム時刻という隠された値によって結果が変化してしまう。

 getMessage関数内から副作用をなくすには、以下のように修正する。

Greet.java

public class Greet {
    LocalDateTime now;
    public void greet() { System.out.println(getFirstMessage()); }
    private String getMessage() {
        int hour = this.now().getHour();
        if (6 <= hour && hour < 12) { return "おはよう ";}
        else if (12 <= hour && hour < 18) { return "こんにちは "; }
        else { return "おやすみ "; }
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        new Greet(LocalDateTime.of(1999, 12, 31, 23, 59, 59)).greet();
    }
}

 だが、Greetクラス内ではnow変数がある。これを別のクラス内メソッドで使えば副作用となる。そこでクラス内メンバ変数も排除することで副作用をなくす。

Greet.java

public class Greet {
    public static void greet(LocalDateTime now) { System.out.println(getFirstMessage(now)); }
    private static String getMessage(LocalDateTime now) {
        int hour = this.now().getHour();
        if (6 <= hour && hour < 12) { return "おはよう ";}
        else if (12 <= hour && hour < 18) { return "こんにちは "; }
        else { return "おやすみ "; }
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        Greet.greet(LocalDateTime.of(1999, 12, 31, 23, 59, 59));
    }
}

 標準出力も副作用なのでクラスから排除する。

Greet.java

public class Greet {
    public static String get(LocalDateTime now) {
        int hour = this.now().getHour();
        if (6 <= hour && hour < 12) { return "おはよう ";}
        else if (12 <= hour && hour < 18) { return "こんにちは "; }
        else { return "おやすみ "; }
    }
}

Main.java

public class Main {
    public static void main(String[] args) {
        string msg = Greet.get(LocalDateTime.of(1999, 12, 31, 23, 59, 59));
        System.out.println(msg);
    }
}

 これにてGreetクラスの債務は「(受け取った日時に応じた)挨拶を返す」だけになった。日時や標準出力に関しては呼出元が責任を負う。単一責任原則に従ったコードが書けた、と思う。

気づいたこと1: オブジェクト指向は副作用を含む

 そもそも上記のコードはオブジェクト指向である必要性がない。

 「オブジェクト=状態+操作」である。状態=変数、操作=関数。オブジェクトとは、型から同じメモリサイズとその区分を複製したもの。だが、そもそも今回の要件は型からコピーする必要性がない。同じ型のものを複数生成する必要がない。ひとつあればいい。

 また、今回のコードは副作用の原因である状態を排除すべく書いたのだから、オブジェクトになるはずがない。「状態」が排され、「操作」だけになる。

 逆に言えば、オブジェクト指向は必ず副作用を含むことを意味する。「オブジェクト=状態+操作」であり、オブジェクトは状態をもつ。状態は少なくとも同一クラス内の2つ以上のメソッドで利用される。さもなくばメンバ変数にする意味が薄いから。よってオブジェクト指向は副作用を含むことになる。

 もし副作用を排したコードを書くなら、オブジェクトから状態が排され、結果的にStaticメソッドだけを持つクラスだけになる。それはもはやオブジェクト指向ではなく関数指向だろう。つまり、副作用をなくそうとすれば関数指向になる。ということか?

気づいたこと2: 副作用を産むものは他にもありそう

 変数をイミュータブルにしさえすれば副作用がなくなるわけではない。たとえば日時を参照することにより結果が変わる。

 日時が副作用をもたらすというなら、乱数を使ったときも副作用と言えそう。

 他にもネットワーク、メモリ、補助記憶装置など結果が不定のものはすべて副作用あり? たとえばメモリ不足ならエラーが発生する。ファイルシステムでノード数が上限のときにファイル生成しようとするとエラーになる。これも副作用といえる? それとも例外処理? 例外処理は副作用の一種?

 あらためて副作用を避ける条件を確認する。

  1. 同じ条件を与えれば必ず同じ結果が得られる(参照透過性
  2. 他のいかなる機能の結果にも影響を与えない

 というか、物理的なものはすべて副作用をなくすことが不可能なのでは? 例外はどうしても生じうるから。論理的な部分なら可能だろうけど。

 逆に言えば物理と論理を分離し、論理的(ロジック)な部分は状態を排することで副作用をなくせる。物理的な部分は副作用を含んだコードである。ハードウェア制御とかがそれ。

 ソフトウェア全体で副作用をなくすのではなく、副作用がある部分とない部分を明確に分離することで問題箇所を特定しやすくする。それが大事なのだろう。さらにいえば、できるだけ副作用がない領域を増やし、ユニットテストを通すことでバグを解消しやすくなり品質向上する。

気づいたこと3: 副作用という言葉では意味がわかりにくい

 不定性とかいうほうがそれっぽい。でもきっと頭いい人たちが考えた末なのだろうから、何か理由があるのだろう。

 イミュータブル(不変の)の逆だから、そのままか? いやでもイミュータブルと副作用の関係性はその言葉だけではわからない。

まとめ

 副作用は不定性によって生じうる。プログラミングにおける不定性は、共有変数の値が変わることで起こりうる。イミュータブルにすることで変数の値を変えたコードが一意に特定できる。

 さらに関数内の処理から可変性を排除することで不定性がなくなる。これにより問題箇所が一意に特定できる。ユニットテストができるようになるため、問題を発見しやすい。

言語 [可不]変性 カプセル化の単位
オブジェクト指向 ミュータブル寄り クラス
関数指向 イミュータブル寄り ステップ(行、命令)

結論

イミュータブルを使う観点

 実装するときは大まかに以下の2つに分離するよう心がける。

  • ロジック部分: 変数はイミュータブルにする。関数内の処理から副作用を排除することでユニットテストできるようにする(債務分離)。
  • フィジカル部分: 可変性があり例外(実行時エラー)が起こりうる。ユニットテストできない。結合テスト以降で確かめる。

 ロジック部分はフィジカル部分からできるかぎり分離する。

対象環境

$ uname -a
Linux raspberrypi 4.14.98-v7+ #1200 SMP Tue Feb 12 20:27:48 GMT 2019 armv7l GNU/Linux

前回まで