🔰

【初心者向け】なぜ副作用がある関数を書いてはいけないか? ~~ 引数の変更を例に ~~

に公開
2

この記事を書くことになった背景

~~ 先日の出来事 ~~

新卒2ヶ月目の後輩くん「コード書けたのでレビューお願いします!」

私「OK!後で見るよ!」

後輩くん「お願いします!」

~~ しばらくして ~~

私「前の仕事片付いたから後輩くんのレビューするか〜」

const removeAttributes = (obj, removeTargetKeys) => {
    const keys = Object.keys(obj);
    for (const k of keys) {
        const value = obj[k];
        if (removeTargetKeys.includes(k)) {
            delete obj[k];
        }
    }
    return obj;
}

const main = (obj) => {
    // ~~ 既存の処理 ~~

    const newObj = removeAttributes(obj, ["a"]);
    save(newObj); // 既存のobjを保存する処理

    // ~~ 既存の処理 ~~
};

私「これはよくないな。。。」

~~ 後輩くんのところに移動 ~~

私「後輩くんこのコード良くないんだけど何が悪いかわかる?副作用って言葉知ってる?」

後輩くん「わからないです。。。」

私「ほな、いい感じの記事探すか〜 なんやいい記事が全くないやんけ。。。」

ということがありました。
グローバル変数を例としてテスト安定性や再利用性の話が書かれた記事は出てくるものの、もう一歩進んだ参照渡し言語での引数変更がダメという話をしている記事が意外と見つかりませんでした。

JS、TS初心者は割とやりがちだと思うので、このコードの問題点を解説していきます。

副作用とは?

一般論

始めに副作用とは何かから説明します。
wikiを参照すると

式の評価による作用には、主たる作用とそれ以外の副作用(side effect)とがある。 式は、評価値を得ること(※関数では「引数を受け取り値を返す」と表現する)が主たる作用とされ、それ以外のコンピュータの論理的状態(ローカル環境以外の状態変数の値)を変化させる作用を副作用という。

とあります。
ざっくり書くと、その関数を使う側から見たときに引数を受け取って、値を返す以外に行う作用すべてのことを指しています。

他記事によく登場するグローバル変数だと下記の例ような関数が副作用のある関数になります。

let total = 0;
function addToTotal(x) {
  total += x;    // 副作用:外部変数 total の変更
  return total;
}

totalの状態を書き換えている処理が副作用に当たります。

JS,TSにおける引数にオブジェクトを渡した場合の挙動

先日の例を考えるには、もう一歩踏み込んだ知識として引数にオブジェクトを渡した場合の挙動の理解が必要なので、まずは簡単に説明します。
ご存知の方は次章まで読み飛ばしてください!

簡単に言うとオブジェクトを全体コピーして渡すのではなく、その値や構造を維持したまま渡すということが行われます。
例えば下記のようなコードでは引数に渡されたオブジェクトのプロパティの値が更新されます。

function addProperty(obj) {
  obj.a = 42;   // obj の参照先を書き換え → 呼び出し元も変化
}

const original = { a: 1 };
addProperty(original);
console.log(original); // { a: 42 }

詳しい話は下記の記事が参考になるのでご一読ください!
https://typescriptbook.jp/reference/functions/pass-by-value

先日の例における副作用

さて、元の話に戻ってきて、先日の例での問題点を話していきましょう。
改めて先日後輩くんが書いたコードを出しつつ、何が問題かを見ていきます。

const removeAttributes = (obj, removeTargetKeys) => {
    const keys = Object.keys(obj);
    for (const k of keys) {
        const value = obj[k];
        if (removeTargetKeys.includes(k)) {
            delete obj[k];
        }
    }
    return obj;
}

const main = (obj) => {
    // ~~ 既存の処理 ~~

    const newObj = removeAttributes(obj, ["a"]);
    save(newObj); // 既存のobjを保存する処理

    // ~~ 既存の処理 ~~
};

後輩くんはこのコードにおいて既存の処理を維持まま、removeAttributesにてobjからプロパティaが削除されたオブジェクトnewObjを作成したかったと思われます。

ここで、後輩くんが想定していなかった挙動とは何でしょうか?

もうおわかりかと思いますが、objへの影響ですね!
本来やりたいのはプロパティaがないnewObjを作りたいことのはずだが、objのプロパティaが削除されてしまっています。

例えば、obj={a: "a", b: "b"}であったときにremoveAttributesの実行後はnewObjobjもともに{b: "b"}となります。

const main = (obj) => {
    // ~~ 既存の処理 ~~

    const newObj = removeAttributes(obj, ["a"]);
    save(newObj); // 既存のobjを保存する処理

    // newObjもobjもともに`{b: "b"}`となる
    console.log(obj);
    console.log(newObj);

    // ~~ 既存の処理 ~~
};

main({a: "a", b: "b"})

なぜ副作用がダメか?

では、なぜ副作用がダメかを見ていきたいと思います。

今回の例において、副作用による問題点は下記の2点になります。

  • 予期しないエラー
  • コードの可読性

それぞれ見ていきましょう。

予期しない挙動

上記の例においてはaは文字列として記載しましたが、obj.aがオブジェクトだったとしましょう。
更に不運なことに上記の例のremoveAttributesの実行後の既存の処理と省略していたものの中にobj.a.a2を参照する処理が存在したとします。

const main = (obj) => {
    // ~~ 既存の処理 ~~

    const newObj = removeAttributes(obj, ["a"]);
    save(newObj); // 既存のobjを保存する処理

    // ~~ 既存の処理 ~~
    console.log(obj.a.a2) // `obj.a.a2`を参照する既存の処理
};

main({a: {a2: "a2"}, b: "b"})

このとき、コード実行時に何が起こるかわかりますか?
実際に実行した結果がこちら
コンソールの実行結果画面

というわけで下記のエラーが出ました。
a2を参照しようとしたがないと言われています。

VM55:18 Uncaught TypeError: Cannot read properties of undefined (reading 'a2')
    at main (<anonymous>:18:23)
    at <anonymous>:21:1

なぜこんなことが起きたかというと、console.log(obj.a.a2)が実行される前にremoveAttributes(obj, ["a"]);によりobjからプロパティaが削除されたからですね。

フロントエンドでこれが起きるとホワイトアウトが起こり、バックエンドだと500になってしまいます。
非常に危ない!

コードの可読性

この記事を通してかなり丁寧にコード例を載せながら解説してきたので麻痺していると思いますが、我々が普段の業務で立ち向かっているのはコードの海、何なら大海に生きている人もいることでしょう。

あなたの船はobj.a.a2を用いた機能追加するという航海をしています。
そこであなたは大海原に一つの樽が浮いているのを見つけます。
あなたはその樽を気にせずにリリース、保守運用と航路を進めた結果。。。
「ドン!」
いきなり爆発が起きて、船は損傷。
3日3晩、バグ修正という船に空いた穴を防ぐ作業に追われました。

樽の中身は何だったのでしょうか?そう、removeAttributesですね。

小噺はさておき、実際に機能追加では起こり得る話です。
前節と同じコードではありますが、今回はobj.a.a2の参照する処理を機能追加として行うケースを考えます。

皆さんにはremoveAttributesの実装を一旦忘れて見てほしいです。

const main = (obj) => {
    // ~~ 既存の処理 ~~

    const newObj = removeAttributes(obj, ["a"]);
    save(newObj); // 既存のobjを保存する処理

    // ~~ 既存の処理 ~~
    console.log(obj.a.a2) // `obj.a.a2`を参照する追加機能
};

main({a: {a2: "a2"}, b: "b"})

const newObj = removeAttributes(obj, ["a"]);を見たときに果たして、removeAttributesobjのプロパティaを丸ごと削除するように見えるでしょうか?
僕には見えないです。

可読性と言われるとコーディング規約の話などが先行しますが、本質はその実装を他のエンジニアが読めるかにあります。

このコードはremoveAttributesの実装を見なければobjに対する副作用があることを見抜くことができません。
もちろん、実行をすればエラーになるので実装段階で問題に気づけはしますが、大海原に浮いている樽を探す作業やりたくはないですよね。。。

この例だとまだ1つの関数に渡されているだけでいいのですが、僕は過去に5回以上繰り返し関数に渡された引数がその最深部でプロパティの追加されているコードを見て絶望したことがあります()

副作用はその関数の振る舞いを関数の実装を見に行かないとわからないという点で可読性を引き下げます。

解決方法:純粋関数化する

じゃあどうすればええねん!と思っている方もいると思うので、最後に解決方法を提示して終わろうと思います。

くどくどと書いてきましたが、上記のような副作用が存在しない関数のことを純粋関数と呼びます。
(厳密は副作用がないかつ決定的であることなのですがこの記事では説明を割愛します)

DBへの保存など機能的の一部として純粋関数とできないものは存在しますが、それらを除くと関数は常に純粋関数であるべきです。

今回出てきたremoveAttributes関数を手っ取り早く純粋関数にしたければ下記のようにすればOKです!

import { cloneDeep } from 'lodash';

const removeAttributes = (obj, removeTargetKeys) => {

    const newObj = cloneDeep(obj); // objをdeep copyする

    const keys = Object.keys(newObj);
    for (const k of keys) {
        const value = newObj[k];
        if (removeTargetKeys.includes(k)) {
            delete newObj[k]
        }
    }
    return newObj;
}

const main = (obj) => {
    // ~~ 既存の処理 ~~

    const newObj = removeAttributes(obj, ["a"]);
    save(newObj); // 既存のobjを保存する処理

    // newObjのみプロパティaが削除される
    console.log(obj); // {a: "a", b: "b"}
    console.log(newObj); // {b: "b"}

    // ~~ 既存の処理 ~~
};

main({a: "a", b: "b"})

渡されたobjをdeep copyしてから同様の処理を行って、処理後の値を返しているだけですね。
deep copyについては説明を割愛しますが、オブジェクト全体をコピーすると考えていただければよいです。

無事テストも通り後輩くんはバグなくリリースできましたとさ。
おしまい

あとがき

初学者向けにかなりポップなテイストにしてみましたが、いかがでしたでしょうか笑
たぶん、私自身も今後何度も同じ説明をすることになると思うので、後輩が入ってくるたびに記事を渡していこうと思っています。

皆様の日々の業務の一助になれば嬉しいです!
ここまで読んでくださりありがとうございました!

エアークローゼットテックブログ

Discussion

junerjuner

引数の参照渡しとは?

それは副作用はありますが、参照渡しにはなっていないのではないでしょうか?
(参照渡しではなくて 値渡し(の一種である共有渡し/参照の値渡し)であるし、単にオブジェクトの破壊的操作では……?

参照渡しであるならば 仮引数と実引数がエイリアスである必要があります。(参照渡しの参照は渡しているオブジェクトという値ではなく、渡している変数のことなので

参照渡しについては評価戦略に詳しいと思います

https://ja.wikipedia.org/wiki/評価戦略#参照呼び

Itsuki OnishiItsuki Onishi

ご指摘ありがとうございます!

値渡しと言われると値自体をコピーして渡すかのような印象を受けるので避けた結果参照渡しと書きましたが、確かにポインタを渡しているわけではないので、CSとしては不適切な表現でした。
添付頂いた記事も読みつつ改めて整理しましたが、参照渡しという単語を使わない形で記事を修正しました。

この後ともご指導よろしくお願いします!

1