イミュータブルが大事な理由、そしてImmerで簡単実現!
始め
JS,特にReactを勉強してるとよくimmutabilityという言葉を聞きます。最近immerを使ってみて不意にimmutabilityは何で重要だったっけ?と思ったので、投稿します。
1. immutabilityとは
immutabilityは不変性、つまり変わらない性質という英単語です。
プログラミングでのimmutabilityは「state
を変更しないこと」とも言えます。(韓国では漢字の方の「不変性」を採用していますが、日本語あまり分からないので今回は英単語そのまま書きます。)
ここで、「state
を変更する」ということは正確に何を意味するのでしょうか?一番簡単な例を見てみましょう。
let greeting = "おはよう"
greeting = "さよなら"
このように再代入したら変数の値が変わるので、立派な「state
を変更する」行為です。だったら再代入しなければimmutability守れるんだ!と思うかもしれませんが、違います。
次の例です。
const string = "hello";
const makeUpperCase = (string) => string.toUpperCase();
console.log(makeUpperCase(string)); //HELLO
console.log(string); //hello
を引数でもらったstring
を変更して返す関数です。特に問題はなさそうです
const array = ["おはよう", "hello"];
const addArray = (array) => {
array.unshift("안녕");
return array;
};
console.log(addArray(array)); // ["안녕", "おはよう", "hello"]
console.log(array); // ["안녕", "おはよう", "hello"]
問題はこれです。引数でもらった配列に新しい要素を追加して返す関数ですが、どこでも再代入はしてないのにarray
の値が変更されてしまいました。その理由について説明します。
関数に引数として変数を渡す時は、その変数のコピー本を渡します。
ここで、その変数の中身がプリミティブタイプなら値を新しいメモリ保存して渡します。ですので、コピー本を変更しても原本には影響がありません。
しかし、変数の中身がオブジェクトタイプならその変数のメモリアドレスを渡します。原本とコピー本が同じメモリアドレスを共有するので、コピー本を変更したら原本にも影響がでるのです。
(Javascriptのコピーメカニズムについては以前書いた「シャローコピー・ディープコピーとは」でも説明してますので、ご参考ください。)
このようにstate
を変更する行為は再代入だけではありません。ですので、「state
を変更する」ということはメモリに保存されている値を変更するすべての行為と定義したほうが良いと思います。
2. immutabilityが大事な理由
2-1. 予想できないstateの更新を塞げられる
state
はアプリケーションの現状を示してくれるものです。しかし、あちこちでstate
を参照したり変更しまくった場合はアプリケーションの現状がわからなくなる危険があります。
すごく極端的な例を上げてみます。
const hi = { greeting: 'おはよう' };
// コード1万行
console.log(`${hi.greeting}, みんちゃん`);
ここでconsole窓見たらおはよう, みんちゃん
が出ると思います。が、誰かコード1万行の中でhi
オブジェクトを変更してしまったら急にさよなら, みんちゃん
とかが出る可能性も全然あります。
この状況で誰がhi
オブジェクトを更新したのか探すことは難しいです。(しかもバグじゃないからエラーも出ない)
immutabilityを守るということはメモリに保存されているデータを変更しないことなので、こういう予想できないstateの更新を塞げられます。
2-2. state変更が追跡できる
上のサンプルコードですが、実はコード1万行のなかにこういう関数があるとします。
const hi = { greeting: "おはよう" };
function changeName(object) {
object.greeting = "さよなら";
return object;
}
const bye = changeName(hi);
console.log(`${hi.greeting}, みんちゃん`); // さよなら, みんちゃん
console.log(`${bye.greeting}, みんちゃん`); // さよなら, みんちゃん
ここで原本オブジェクトのプロパティまで変更されるのも問題ですが、もう一つの問題はこういうstate
の変更を追跡できないということです。この例のhi
オブジェクトとbye
オブジェクトは変数名が違うだけで、実際は同じメモリに保存されてるデータです。
ですので、Javascriptは2つを同じものだと判断します。
console.log(hi === bye) // true
しかしオブジェクトと配列などを全く変更しないことも不可能でしょう。この問題は新しいオブジェクトや配列を生成することで解決できます。
const hi = { greeting: "おはよう" };
function changeName(object) {
const newObject = { ...object }; // オブジェクトコピー
newObject.greeting = "さよなら";
return newObject;
}
const bye = changeName(hi);
console.log(`${hi.greeting}, みんちゃん`); // おはよう, みんちゃん
console.log(`${bye.greeting}, みんちゃん`); // さよなら, みんちゃん
このようにオブジェクトをコピーしてそれをいじったら大丈夫でした。この書き方がめんどうくさいなら以下のような書き方でもいけます。
function changeName(object) {
return {
...object,
greeting: "さよなら"
};
}
changeName
が返したオブジェクトはhi
と全然違うオブジェクトです。予想できないオブジェクトの変更も塞げられますし、state
の変更も追跡できるようになりました。
console.log(hi === bye) // false
オブジェクトを更新させるとき「更新されたオブジェクト」を新しく生成すると、更新前のオブジェクトと更新後のオブジェクトを比べた時false
がでます。この事実を利用してオブジェクトが更新されたとわかります。
これがReactがimmutabilityを大事にする理由です。Reactはstate
の変更を探知し、変更前と変更後のオブジェクトを比較して違うと判断したらコンポーネントを再レンダリングします。この比較するときにメモリアドレスが違ってたらfalse
が出るのですぐ判断できます。
もしimmutabilityを守らなかったならすべての要素を一つ一つ比較することになるので、もっとコストがかかるでしょう。
3. immutabilityはめんどうくさい
しかし残念ながらimmutabilityを守ることはめんどうくさいです。
これは私が勉強で書いてたコードです。SNSを実装してて、コメントを追加する機能をreduxのreducerに書いてました。
const reducer = (state, action) => {
switch (action.type) {
// 色々ある
case ADD_COMMENT_SUCCESS: {
const postIndex = state.mainPosts.findIndex((mainPost) => mainPost.id === action.data.postId);
const commentAddedPost = { ...state.mainPosts[postIndex] };
commentAddedPost.Comments = [generateComment(action.data.content), ...mainPost.Comments];
const mainPosts = [...state.mainPosts];
mainPosts[postIndex] = commentAddedPost;
return {
...state,
mainPosts,
addCommentLoading: false,
addCommentDone: true
};
}
// 色々ある
default:
return state;
}
};
配列とオブジェクトをずっとコピーしてるから...
も多いし、長いし、読みづらいしとりあえずめんどうくさいです。これ書いてマジかと思いました。
ですので、immerというライブラリを紹介します。
4. Immer
4-1. Immerとは
immerはimmutabilityを守るコードを楽に書けるようにしてくれるライブラリです。
使い方は公式サイトのQuick Exampleを見るとすぐわかります。
import produce from "immer"
const baseState = [
{
todo: "Learn typescript",
done: true
},
{
todo: "Try immer",
done: false
}
]
const nextState = produce(baseState, draftState => {
draftState.push({todo: "Tweet about it"})
draftState[1].done = true
})
immerが提供するproduce
関数さえ使えば簡単にimmutabilityを守れます。
produce
関数は2つの引数が必要で、第1引数は更新したいオブジェクトもしくは配列、第2引数は第1引数をどう更新するかを定義する関数です。
4-2. Immerを使うと
3の例をimmerを使ってリファクタリングするとこうなります。
const reducer = (state, action) =>
produce(state, (draft) => {
switch (action.type) {
// 色々ある
case ADD_COMMENT_SUCCESS: {
const commentAddedPost = draft.mainPosts.find((mainPost) => mainPost.id === action.data.postId);
commentAddedPost.Comments.unshift(generateComment(action.data.content));
draft.addCommentLoading = false;
draft.addCommentDone = true;
break;
}
// 色々ある
default:
break;
}
});
produce
の第2引数である関数の中にロジックを書きます。書く時、immutabilityなんて思いっきり破壊すればよいです。そうしたらimmerが勝手にそのコードをimmutabilityを守るコードに変えてくれるので、めちゃくちゃ便利です。ちょっと感動しました。
終わり
調べたら前はimmutable.jsというライブラリがよく使われてたようですが、npmtrendsで確認したら最近はimmerが勝ってました。immerのほうが使いやすいからだと思います。
Discussion