副作用のない関数

このレッスンで分かること

  • 同じ引数なら必ず同じ戻り値になる関数の書きかた
  • 受け取った配列やオブジェクトを書き換えずに新しいものを返す手順
  • 副作用のある処理を関数の外側に追い出す考えかた

副作用のない関数 とは

同じ引数で呼べば必ず同じ値を返し、外側の変数や受け取った引数を書き換えない関数のことです。純粋関数とも呼び、動きを確かめやすくなります。

2 回呼ぶと結果が変わる関数

作品の表示回数にボーナスを足す処理を考えます。まずは素直に書いた形です。

function addBonus(works, bonus) { works.forEach(function (work) { work.views = work.views + bonus; }); return works; } const works = [{ title: "プロフィールサイト", views: 120 }]; console.log(addBonus(works, 10)[0].views); // 130 console.log(addBonus(works, 10)[0].views); // 140

1 回目と 2 回目で答えが違います。関数が works の中身を書き換えているので、次に呼ぶときの入力がもう別物になっているからです。呼んだ回数が結果に混ざる関数は、テストが書けません。書けたとしても、実行の順番を変えた瞬間に落ちます。

「この関数は何を返すか」を答えるのに、それまでの呼び出し履歴が必要になったら、その関数は純粋ではありません。

新しい配列を作って返す

書き換えるかわりに、作り直します。map は元の配列を変えずに新しい配列を返すので、そのまま使えます。

function addBonus(works, bonus) { return works.map(function (work) { return { ...work, views: work.views + bonus }; }); } const works = [{ title: "プロフィールサイト", views: 120 }]; console.log(addBonus(works, 10)[0].views); // 130 console.log(addBonus(works, 10)[0].views); // 130 console.log(works[0].views); // 120

何度呼んでも 130 で、元の works は 120 のままです。中の work をそのまま返さず、スプレッドで写しを作っているところが要点です。写しを作らずに work を返すと、配列は新しくても中のオブジェクトは同じものを指したままになります。

元を書き換えるもの新しいものを返すもの
push, pop, spliceconcat, slice, スプレッド
sort, reversetoSorted, スプレッドしてから sort
代入 work.views = 1スプレッドで写しを作る

第2章で sort が元の配列を並び替えてしまう話をしました。原因は同じで、対処も同じです。まず写しを作り、写しを並び替えます。

純粋かどうかの見分けかた

判定は 2 つだけです。ひとつめは、引数以外から値を読んでいないか。ふたつめは、引数と外側の変数を書き換えていないか。

let taxRate = 0.1; function withTax(price) { return Math.round(price * (1 + taxRate)); // 外の値を読んでいる }

taxRate が書き換わると同じ price でも答えが変わるので、これは純粋ではありません。引数で受け取る形にすれば純粋になります。

function withTax(price, taxRate) { return Math.round(price * (1 + taxRate)); }

Math.random や new Date も、呼ぶたびに答えが変わるので同じ問題を抱えます。これらは関数の中で呼ばず、呼び出し側で作った値を引数で受け取ります。

純粋にできない処理は必ず残ります。画面への描画、通信、保存がそれです。無くすのではなく、境目をはっきりさせて外側に集めます。

純粋にすると何が楽になるか

純粋な関数は、引数と戻り値だけを見ればよくなります。効いてくる場面は 3 つあります。

ひとつめは確かめやすさです。works を渡して戻り値を見るだけで正しさが分かるので、画面もサーバーも要りません。このコースの課題が戻り値だけで採点できているのは、書かせている関数が純粋だからです。

ふたつめは並べ替えの自由です。純粋な関数は呼ぶ順番を変えても結果が変わりません。前のレッスンで書いた合成が安全に成り立つのも、材料の関数が純粋である前提があるからです。

みっつめは元に戻せることです。元データが残っているので、絞り込みを解除したいときに再取得が要りません。

const allWorks = [ { id: 1, title: "プロフィールサイト", views: 120, published: true }, { id: 2, title: "自己紹介カード", views: 48, published: false } ]; function visibleWorks(works, showAll) { return showAll ? works : works.filter(function (work) { return work.published; }); } console.log(visibleWorks(allWorks, false).length); // 1 console.log(visibleWorks(allWorks, true).length); // 2

allWorks は常に全件のままなので、切り替えは何度でも行き来できます。もし filter のかわりに allWorks から要素を削っていたら、戻す手立てがありません。

副作用を外側に集める

ポートフォリオアプリで言えば、次のように分けます。計算する関数は純粋にして、書き換えるのは最後の 1 か所だけにします。

// 純粋。データからデータを作る function rankWorks(works) { return [...works].sort(function (a, b) { return b.views - a.views; }); } // 副作用あり。画面を書き換える function renderWorks(list) { const workList = document.querySelector(".work-list"); workList.textContent = ""; list.forEach(function (work) { workList.append(createWorkCard(work)); }); } renderWorks(rankWorks(works));

rankWorks は works を受け取って新しい配列を返すだけなので、画面が無くても確かめられます。今回の課題も、同じ引数を 2 回処理した結果と、処理後の元データを一緒に返して、この 2 点を確かめる形になっています。

よくある間違い

ひとつめは、配列だけ写して中身を写し忘れることです。

function addBonus(works, bonus) { const copy = [...works]; copy.forEach(function (work) { work.views = work.views + bonus; // 元のオブジェクトを触っている }); return copy; }

配列は新しくなりますが、中の work は元と同じものです。元の works の views も増えます。

ふたつめは、sort を写す前に呼ぶことです。

function rankWorks(works) { return works.sort(function (a, b) { return b.views - a.views; }); }

sort は元の配列を並び替えて、その同じ配列を返します。受け取った側からは新しい配列に見えるので気付きにくい形です。

みっつめは、集計しながら外の変数に足していくことです。

let total = 0; function sumViews(works) { works.forEach(function (work) { total = total + work.views; }); return total; }

2 回呼ぶと合計が 2 倍になります。合計は関数の中で let total = 0 から始めるか、reduce で作ります。

要件

  1. 関数名は applyBonus とし、引数は works (作品の配列) と bonus (数値) の 2 つにする
  2. views に bonus を足した新しい配列を作る処理を、works を書き換えずに行う。同じ works に対して 2 回行う
  3. 戻り値は first と second と original の 3 つのキーを持つオブジェクトにする。値はいずれも views の数値だけを並べた配列にする
  4. first は 1 回目の結果、second は 2 回目の結果、original は処理を終えた時点の works の views にする。works が空なら 3 つとも空の配列になる

入出力例

applyBonus([{"id":1,"title":"プロフィールサイト","views":120},{"id":2,"title":"自己紹介カード","views":48}], 10){"first":[130,58],"original":[120,48],"second":[130,58]} applyBonus([{"id":1,"title":"プロフィールサイト","views":120},{"id":2,"title":"自己紹介カード","views":48}], 0){"first":[120,48],"original":[120,48],"second":[120,48]} applyBonus([{"id":5,"title":"学習ログ","views":0}], 5){"first":[5],"original":[0],"second":[5]} applyBonus([], 10){"first":[],"original":[],"second":[]} applyBonus([{"id":1,"title":"プロフィールサイト","views":120},{"id":2,"title":"自己紹介カード","views":48},{"id":3,"title":"スキル一覧の表","views":32},{"id":4,"title":"作品ギャラリー","views":8},{"id":5,"title":"学習ログ","views":0}], 1){"first":[121,49,33,9,1],"original":[120,48,32,8,0],"second":[121,49,33,9,1]}

ヒント

生田 陸人
ゆめさくエンジニア / 現役ソフトウェアエンジニア
編集 LuaGate編集部
main.js
main.js
学習モード

メモ

副作用のない関数

⌘S で保存