副作用
関数が戻り値以外に外部の状態を変更する操作で、テストやデバッグを困難にする要因
副作用とは
副作用 (Side Effect) は、関数が戻り値を返す以外に、関数の外から観測できる状態を変えてしまう操作である。DB への書き込み、ファイルの変更、API 呼び出し、グローバル変数の変更、コンソール出力、DOM の書き換えが該当する。副作用を持たない関数が純粋関数で、その条件や参照透過性はそちらで扱う。
判定の軸は「外から観測できるか」である。関数の中で作った配列を破壊的に並べ替えても、その配列が外へ出ないなら呼び出し側は違いを検出できないため副作用として扱わない。逆に引数で受け取ったオブジェクトを書き換えると、呼び出し側の変数の中身が変わるので副作用になる。同じ sort() が場所によって副作用になるかならないかが変わるのは、この観測範囲の違いによる。
もう一つ、書き込みではなく読み取り側の問題も区別しておきたい。Date.now() や乱数、環境変数の参照は外部を変更しないが、呼び出しごとに結果が変わるため関数の出力が引数だけで決まらなくなる。厳密な用語としては副作用は書き込み側を指すが、純粋関数であるための条件はこの暗黙の読み取りも禁じる。実務では両方まとめて「切り離す対象」として扱うのが扱いやすい。
観測できるかどうかで分かれる
同じ配列操作でも、書き換えた対象が関数の外に見えているかどうかで扱いが変わる。
// ✅ 副作用なし: 変更したのは関数内で作った配列だけ
function topThree(scores: number[]): number[] {
const copy = [...scores];
copy.sort((a, b) => b - a); // 破壊的だが copy は外へ出ていない
return copy.slice(0, 3);
}
// ❌ 副作用あり: 引数のオブジェクトを書き換え、呼び出し側に見える
function applyDiscount(order: Order, rate: number): void {
order.total = order.total * (1 - rate); // 呼び出し側の order が変わる
}
// ✅ 戻り値で返せば、変更を受け入れるかを呼び出し側が決められる
function withDiscount(order: Order, rate: number): Order {
return { ...order, total: order.total * (1 - rate) };
}
applyDiscount の形が厄介なのは、呼び出し側が「渡しただけ」と思っている点である。同じオブジェクトを別の処理でも使っていれば、そちらの計算結果まで静かに変わる。戻り値で返す形にしておけば、変更の適用範囲が代入先に限定される (イミュータブル)。
影響が届く範囲で分類する
副作用は「どこまで影響が届くか」で並べると、取り消しの難しさとテストの手間が同時に見える。上に行くほど痕跡が閉じており、下に行くほど取り消せない。
| 範囲 | 例 | 取り消しやすさ |
|---|---|---|
| 関数の内側だけ | ローカル変数・関数内で作った配列の書き換え | 影響が漏れないため副作用として扱わない |
| 引数経由で呼び出し側へ | 受け取ったオブジェクト・配列の破壊的変更 | 呼び出し側がコピーを渡せば防げる |
| プロセス内で共有 | モジュールレベル変数・キャッシュ・window への代入 | 同一プロセス内で再現するためテストで初期化が必要 |
| プロセスの外へ | DB 書き込み・API 呼び出し・ファイル出力・メール送信 | 取り消しには補償処理が必要 |
| 外部からの読み取り | Date.now()・乱数・環境変数 | 変更はしないが結果が固定できない |
console.log() と DOM 操作はプロセスの外に近い扱いになる。特に DOM は他のコードが同じ要素を触るため、書き換えの順序で結果が変わりうる点で DB と同じ問題を抱える。なお document.getElementById() のような読み取りだけの呼び出しは状態を変えないが、外部の状態に結果が依存するという意味で最下段に入る。
副作用は再実行で重複する
副作用が外に出るということは、同じ関数を 2 回呼べば外の世界にも 2 回痕跡が残るということである。タイムアウトからのリトライやキュー再配信で処理が二重に走ると、純粋な計算部分は何度実行しても同じ結果なのに、送信やカウント更新だけが重複する。副作用を持つ処理には冪等性の担保 (処理済みキーの記録など) が別途必要になる。
だからこそ、計算と副作用を同じ関数に混ぜないことが実利につながる。混在していると、リトライしたいときに計算までやり直すか副作用まで繰り返すかの二択になる。分離の型と参照透過性の詳しい議論は純粋関数に置いた。ここでは境界を引く形だけ示す。
// ❌ 再配信されるとメールが 2 通届く
async function notifyShipped(orderId: string) {
const order = await db.get(orderId);
await sendEmail(order.userId, buildShippedMail(order)); // 取り消せない副作用
}
// ✅ 副作用の直前に一意キーを押さえ、2 回目は何もしない
async function notifyShipped(orderId: string, eventId: string) {
// claimOnce = 同じキーが未登録のときだけ登録して true を返すヘルパー
if (!(await claimOnce(`notify#${eventId}`))) return; // 送信済みなら素通し
const order = await db.get(orderId);
await sendEmail(order.userId, buildShippedMail(order));
}
// 本文の組み立ては副作用なし: 何度呼んでも安全でテストは戻り値だけ
function buildShippedMail(order: Order): Mail {
return { subject: `ご注文 ${order.id} を発送しました`, body: renderItems(order.items) };
}
境界の引き方は「副作用は関数の端に 1 箇所だけ」に尽きる。組み立てや判定を副作用なしの関数に寄せておけば、リトライで安全でない部分が claimOnce と sendEmail の 2 行に絞られ、そこだけを重点的に守れる。
React の useEffect
React は「レンダリングは純粋・副作用は useEffect」という境界をフレームワーク側で引いている。この境界を守るときの要点は後始末である。useEffect は依存配列の値が変わるたびに前回の後始末を実行してから再実行され、Strict Mode を有効にした開発時にはさらに最初の実行前に開発専用の実行と後始末が 1 回追加される (React 公式ドキュメント)。後始末を書いていない副作用は、この繰り返しで二重購読や古いレスポンスの上書きとして表面化する。
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(() => { /* 中断は無視 */ });
return () => controller.abort(); // 後始末で古い取得を打ち切る
}, [userId]); // userId が変わるたび後始末 → 再実行
// レンダリングは純粋: props と state から画面を決めるだけ
return user ? <div>{user.name}</div> : <div>Loading...</div>;
}
後始末を省くと、userId を素早く切り替えたときに先に投げた取得が後から返り、新しいユーザーの表示を上書きすることがある。開発時の追加実行は、この抜けを早期に見つけるための仕掛けでもある。
テストへの影響
副作用の位置がテストの書き方をそのまま決める。モックが必要になったら、それは副作用が計算の内側に入り込んでいる合図と読める。
| 関数の型 | テストに必要なもの |
|---|---|
| 副作用なし | 入力と期待値だけ |
| 引数を書き換える | 呼び出し後の引数の中身も検証が必要 |
| プロセス内の状態を触る | テストごとの初期化と実行順序の管理 |
| プロセス外へ出す | モックまたはテスト用の代替実装 |
| 時刻・乱数に依存 | 値を固定する仕組み (擬似時刻や種の固定) |
時刻や乱数への依存は、引数として受け取る形に変えるだけで検証可能になる場合が多い。Date.now() を関数の中で呼ぶ代わりに now: number を引数に取れば、テストは境界の時刻を素直に渡せる。
実務での活用方法は関連書籍にも詳しい。
この記事は役に立ちましたか?