副作用のないコードのメリットとは? - 副作用について解説 -

Scala 3.3.1
最終更新:2023年12月7日

[AD] scalapediaでは記事作成ボランティアを募集しています

この記事では副作用について解説します。

副作用とは主たる作用とは別に発生する作用である

「副作用」とはなんでしょうか?

副作用というと一般的に薬を服用した際に期待する効果とは別の症状が出ることを思い浮かべることでしょう。
例えば、風邪の治療薬の場合は風邪を治療することが主たる作用(期待する効果)ですが、それ以外に肌が荒れる症状が出た場合のそれは副作用(期待する効果とは別の症状)になります。

では、「プログラミングにおける副作用」とは何でしょうか?

プログラミングでは式、関数を評価し値を得る際の何らかの効果を「作用」と呼びます。
式、関数を評価し値を得ることを期待する効果が、「主たる作用」です。
それ以外の状態(グローバル変数など)を変化させる作用が「副作用」です。

以下は副作用のあるコードの例です。

def add(x: Int): Int = total = total + x total

この関数は関数外の変数totalに引数xの値を足して、totalを返しています。
この関数の主たる作用は「2つの値を足した値を返す」ですが、主たる作用以外に変数totalの値を更新しているので「副作用のある関数」です。

副作用があるとテストが難しい

副作用があるコードはテスト(単体テスト)を実行することが難しいです。
それはなぜでしょうか?

先程の関数addを例に考えてみます。

以下の戻り値resの値はいくつでしょうか?

val res = add(1)

これだけでは戻り値resの値はわかりませんね。

先程の関数addの戻り値は関数外の変数totalの値に依存しているので、戻り値は変数totalの値によって違ってきます。
そのため、戻り値の値は関数外の変数totalの値を考慮してチェックする必要があります。

関数addをテストするには、変数totalの値を初期化してから関数addを呼び出す必要があります。

total = 2 val res_2 = add(1) assert(res_2 == 3)

この例だとそれほど問題があるように思えないかもしれません。

次の例ではどうでしょうか?
この関数add2の主たる作用は、先程の関数addと同じで「2つの値を足した値を返す」です。

def add2(x: Int): Int = var total = Storage.get() total = total + x Storage.put(total) total

関数add2は何らかのストレージ(ファイルやデータベースなど)の値に引数xの値を足してその値を返しています。

この関数をテストするにはストレージが必要になります。
テストコードでは以下のようにストレージの値を初期化してから関数を呼び出す必要があります。
また、テスト後にはストレージの終了処理が必要になるかもしれません。

// ストレージの初期化処理 Storage.create() Storage.init(2) val res = add2(1) assert(res == 11) // ストレージの終了処理 Storage.delete() Storage.close()

この例では、副作用があることにより、事前条件を整えたり、ストレージの初期化処理、終了処理が必要になりました。
当然、実装にかかる時間や手間、毎回のテストにかかる計算量や時間も増大しています。

副作用があるとテストが難しくなることが理解できたでしょうか。

副作用があると予期せぬ動作となる場合がある

副作用があるとプログラムが「予期せぬ動作」となる場合があります。
予期せぬ動作とは何でしょうか?

再度、先程の関数addを見てみます。

def add(x: Int): Int = total = total + x total

以下のように関数を呼び出した結果、変数resの値はいくつでしょうか?

total = 2 val res_3 = add(1)

普通に考えると変数resの値は3になると思います。
ですが、3にならないケースがあります。

変数totalは値を書き換えているため、「可変」です。

関数addでは、以下のように2ステップのコードになっています。

  1. 変数totalに引数xを加えた値を保管
  2. 変数totalの値を返す

では、1と2の間に他(例えば別のスレッド)からtotalの値を10に書き換えた場合について考えてみましょう。
関数addの戻り値は10+1の11となり、当初期待していた3とは違っています。
このため、このタイミングで関数addを使用していたプログラムは正常に動作しないと考えられます。

副作用をコードから排除する

前述の説明から副作業のあるコードには問題があることがわかりました。
では、先程の関数addから「副作用を排除」してみます。

関数addの主たる作用は「2つの値を足した値を返す」でした。
よって、2つの値を引数で受け取り足した値を返すようにすれば、「副作用のない関数」になります。

def add(x: Int, y: Int): Int = x + y

このように主たる作用のみの関数を「純粋関数」と呼びます。
純粋関数は引数が同じ場合、常に同じ結果を返します。
そのため、テストも以下のように関数を呼び出した結果をチェックするだけになり簡単になります。

val res = add(1, 2) assert(res == 3)

完全に副作用がないプログラムは意味がない

副作用をコードから排除する方法はわかりました。
では、変数totalの更新やストレージの更新など「副作用のある処理」はどうすればよいでしょうか?

「副作用のあるコードは使う箇所を必要最小限にする」のが良いです。

先程のストレージの値を更新するケースを例に副作用のあるコードを局所化する例を見てみます。

副作用のあるストレージへアクセスする部分を関数にします。
この関数はストレージの値を引数の関数に適用して結果をストレージに保管します。
こうすることで値を操作する箇所とストレージに保管する箇所が分離され、副作用のあるコードを局所化することができます。

def withStorage(f: (Int) => Int): Unit = Storage.open() val res = f(Storage.get()) Storage.put(res) Storage.close()

関数addを使用する場合は以下のように呼び出します。

withStorage(add(1, _))

また、この関数withStorageの引数は「Int型の引数を受け取ってInt型の値を返す関数」であればよいので、以下のように「値を2倍する関数」を指定することもできます。

withStorage(x => x * 2)

副作用のあるコードはテストをするのが大変ですが、副作用のあるコードを最小限にすることでテストのコストを減らすことができます。

副作用のないプログラミングを心がけるとよい

副作用の無いプログラミングを心がけると、テストがしやすく予期せぬ動作がないコードを書くことができます。
結果、バグが少なくメンテナンスのしやすいコードになります。

ただし、副作用にはデメリットが多い一方で、実装次第では効率的なプログラムが書ける場合があります。
パフォーマンスが求められる場面においては、副作用のあるプログラミングも検討してみましょう。

サイト内検索