頑なに再代入しない阿部です。
YAPC::Fukuoka 2025で頑なに再代入しない!というタイトルでトークします。
スライドも公開しますが、発表用のスライドのため十分な説明が記載されていないので、解説テキストを追加したブログ記事も残します。
はじめに
トークの説明から
コードを読んでいると、この変数はどこで定義され、どこで値が設定されたのか?を確認することがしばしばあります。 再代入が多いとどのタイミングで値が変更されるのか?を確認するコストが発生するので、私はめんどうだと思ってしまいます。 また、再代入がないほうがメンテナンス性が高いと信じています。なぜならば、すべてが再代入なし(= すべてが定数)の方がバグが生まれにくいはずだからです。 (いわゆる関数型プログラミング、というやつです。)
と、いうことで私は基本的に再代入をしないコードを書くように心がけています。 (もちろん、それによるデメリットがあるのも承知でそのようにしています。)
私が始めたOSSではありませんが、現在は9割方私が書いたコードになっているnode-lambda ( https://github.com/motdotla/node-lambda ) (JavaScriptです)を例に、頑なに再代入をしない(関数型プログラミング)を実践した例を紹介します。 (これまではデメリットについて、厳密に検証したことがなかったのですが、改めて検証してまとめてデメリットについても発表します。)
大規模開発の参考になること間違いなし!
という文面で応募しました。私の「頑なに再代入しない!」思いをぶつけるための発表です。
イメージしやすいように「関数型プログラミング」という単語を出しましたが、「関数型プログラミング」についての話ではないことをご理解の上お読みください。
内容
- 頑なに再代入しない!ワケ
- 再代入しない!コード例
- 再代入しない!デメリット検証
- 再代入しない!実践してみて
頑なに再代入しない!ワケ
再代入のコード例
頑なに再代入しない!ワケの説明の前に再代入しているコードがどういうものか例で示します。
let price = 100
if (キャンペーンA期間中) {
price = price * 0.8
}
// ...
if (キャンペーンF期間中) {
// キャンペーンF期間中のみ税込価格から2割引
price = (price * 1.1) * 0.8
} else {
// ...
}
// ...
// `price` はいくつ?
キャンペーンの有無によって商品価格の割引をするコード例です。
再代入によりpriceの値が変わって最終的なpriceの値がわかりにくいのではないでしょうか。
だいぶ極端な例でしたが再代入したときのコード例を踏まえると、再代入してわかりにくくなったということは、逆に再代入しなければわかりやすくなるということではないでしょうか!?
わかりやすくなることが正しいとすると、再代入しないことで次のメリットが得られそうです。
- 読みやすさ・メンテナンス性の向上
- 変数の値が途中で変わることがないため、「この時点で何の値か」を一目で把握できる
- 値の追跡が容易になり、コードの全体像や処理の流れが理解しやすい
- バグの発生リスクを低減
- 意図しない再代入によるバグや予期せぬ挙動が発生しにくく、信頼性が高まる
- 複数箇所で値を変更する必要がなくなるため、バグを防ぎやすい
- 保守性・変更への強さ
- 変数の状態が関数内で常に不変なら、変更やリファクタ時の影響調査が容易になる
- 意図しない影響範囲が狭くなり、スコープも明確になる
実際に再代入しないコードを紹介して、本当にこれらのメリットが得られそうか見ていきます。
再代入しない!コード例
よくありそうな次のコード例で見ていきます。
- フラグの設定
- 参考: 配列から抽出
- 参考: 配列の加工
例: 再代入あり: フラグ設定
最初に紹介したコードに近い例です。
tが2か7で割り切れたら、flagにtrueを設定するコードです。
(この処理自体に特に意味はありません。)
let flag = false
if (t % 2 === 0) {
flag = true
} else if (t % 7 === 0) {
flag = true
}
// ...
この部分だけを読んでわかりにくい、と感じる人はあまりいないと思います。
ただflagはletで宣言されており、// ... で省略している部分のコードが非常に長く、最後の最後でflagを使う処理があったらどうでしょうか。
// ...のどこかでflagの値が変わっている可能性を気にしなければならず、保守するのが大変そうです。
補足: JavaScriptではletで変数を宣言すると、再代入できる変数になります。
例: 再代入なし: フラグ設定
ということで、先ほどのコードを再代入しないように書き換えます。 例えば次のように書き換えられます。
const flag = (() => {
if (t % 2 === 0) {
return true
}
if (t % 7 === 0) {
return true
}
return false
})()
// ...
今度はflagがconstで宣言されているので、この宣言以降はflagに再代入できません。
つまり// ...の途中で値が変わる可能性考えなくて良くなります。
これは楽です!
補足: JavaScriptではconstで変数を宣言すると、再代入できない変数になります。
参考: 素朴に配列から抽出
次のコード例では再代入はしていないのですが、このように書くよりはfilterを使ったほうが再代入をしない感が出るのとシンプルなので紹介です。
dataから2で割り切れる値だけを取り出しています。
const data = Array.from({ length: 1000 }, (_, i) => i)
const length = data.length
const result = []
for (let i = 0; i < length; i++) {
if (data[i] % 2 === 0) {
result.push(data[i])
}
}
push()で値を追加しているので、後続の処理でもpush()しそう感のあるコードです。
補足: Array.prototype.push()
filterを紹介する前に、先ほどのコードに馴染みのない方だと違和感があるかもしれないので補足です。
const data = []
data.push(1)
constで宣言したdataにpush()ができています。
constなのだから、push()もできないのでは?という疑問が生じそうです。
疑問の回答としてはconstは変数への再代入ができなくなるだけ、です。
dataにpush()はしていますが、data自体に再代入はしていないのでエラーにならないのです。
このようにconstで宣言したArrayやObjectの要素は普通に変更などができます。
上述のpush()はエラーになりませんが、次の通り再代入するとエラーになります。
const data = []
data = [1]
// data = [1]
// ^
//
// TypeError: Assignment to constant variable.
Object.freeze()を使うとpush()でエラーになるようにすることもできます。
const data = Object.freeze([])
data.push(1)
// data.push(1)
// ^
//
// TypeError: Cannot add property 0, object is not extensible
参考: 配列から抽出(filter)
本題のfilterに戻ります。filterを使うと次のように書けます。
const data = Array.from({ length: 1000 }, (_, i) => i)
const result = data.filter((v) => v % 2 === 0)
filterを使うとだいぶシンプルです。
そして、前述のforでpush()するコードよりはresultへ再代入しない感はあるのではないでしょうか。
再代入しない感はありますが、resultにはこの後の継続処理でpush()とかで値の変更が可能ではあります…。
真面目に再代入を防ぎたいならfreeze()すると良いです。
参考: 素朴に配列の加工
filterとほぼ同じ例です。
次のコード例では再代入はしていないのですが、このように書くよりはmapを使ったほうが再代入をしない感が出るのとシンプルなので紹介です。
dataの要素をすべて2倍した新しい配列を作っています。
const data = Array.from({ length: 1000 }, (_, i) => i)
const length = data.length
const result = []
for (let i = 0; i < length; i++) {
result.push(data[i] * 2)
}
参考: 配列の加工(map)
mapを使うと次のとおりです。
const data = Array.from({ length: 1000 }, (_, i) => i)
const result = data.map((v) => v * 2)
シンプルでわかりやすいのと、resultへ再代入しない感があります。
ここまでまとめ
- 再代入がないほうがわかりやすい!
- 途中で値を追加したりしないほうがわかりやすい!
再代入しないデメリット検証: 実行速度
ここまでは、再代入をしないほうがわかりやすくなりそう(保守しやすそう)、という内容を紹介してきました。 わかりやすいならどんどん活用しよう!となるところですが、「例: 再代入なし: フラグ設定」の例だと、関数の定義&実行が追加される分、遅くなりそうな雰囲気があります。
遅いんだろうな、と思いつつ、どのくらい遅いのか検証したことがなかったので、この機会にざっくり検証してみました。
検証方法
- Node.js 24で検証
- Dockerイメージ
node:24を活用
- Dockerイメージ
console.time()で測定- 時間が短いほうが速い
- 5回実行して中央値
検証に使ったコードはこちら: https://github.com/abetomo/node-benchmark-functional
速度検証: フラグ
次の3パターンで測定しました。
- IIFE(即時実行関数式)
- 関数をつくる
- 再代入でフラグ設定
先に検証に使ったコードを紹介して結果を提示します。
IIFE検証
即時実行関数式というやつです。 完全に好みの話で、私はこの書き方が好きでよく使います。 その場で関数を作って実行するのでだいぶ遅いと思われますが実際はどうでしょうか。
const n = 100_000
console.time('no reassignment IIFE')
for (let t = 0; t < n; t++) {
const flag = (() => {
if (t % 2 === 0) {
return true
}
if (t % 7 === 0) {
return true
}
return false
})()
}
console.timeEnd('no reassignment IIFE')
関数実行で検証
即時実行関数式はその都度関数をつくるので遅いと思われます。 事前に関数をつくると違いは出るのか確認します。
console.time('no reassignment func call')
const checkValue = (num) => {
if (num % 2 === 0) {
return true
}
if (num % 7 === 0) {
return true
}
return false
}
for (let t = 0; t < n; t++) {
const flag = checkValue(t)
}
console.timeEnd('no reassignment func call')
再代入検証
シンプルに処理の中で再代入してフラグを設定する場合のコード例です。
console.time('reassignment')
for (let t = 0; t < n; t++) {
let flag = false
if (t % 2 === 0) {
flag = true
} else if (t % 7 === 0) {
flag = true
}
}
console.timeEnd('reassignment')
速度検証: フラグの結果
| 時間 | |
|---|---|
| IIFE | 11.51ms |
| 関数実行 | 1.99ms |
| 再代入 | 1.929ms |
やっぱりIIFEは遅いですね…。 関数実行だと再代入しているのと大差ないので、関数化すると良さそうです。
参考: 速度検証: filter
検証コードは省略しますが、素朴に抽出とfilterについても検証しました。
| 時間 | |
|---|---|
| 素朴に抽出 | 19.696ms |
| filter | 34.01ms |
filterだとちょっと遅いですね…。
参考: 速度検証: map
同様にmapも確認。mapだと速い!
| 時間 | |
|---|---|
| 素朴に加工 | 32.12ms |
| map | 21.378ms |
ここまでまとめ
実行速度について、ざっくり検証しました。 書き方によって変わりますが、頑なに再代入しない!コードでも十分速い(と言えるときがある)のではないでしょうか!?
他に再代入しないデメリット
オプジェクトのコピーなどの操作が増えるので、メモリの使用量など気になるところです。
process.memoryUsage()を使うとよさそう、というところまでは調べたのですが、いい感じに検証できなかったので今回は割愛します。
オススメの検証方法があったら教えてください!
頑なに再代入しない!を実践してみて
ここまでは頑なに再代入にしない!方法と実行速度について説明しました。 ここからは頑なに再代入にしない!を実践してみて、どうだったのかを説明します。
客観的な観測が難しかったので、ほぼ私の主観になりますが参考になれば幸いです。
実践したソフトウェア
node-lambda: https://github.com/motdotla/node-lambda
AWS Lambdaにコードをdeployするソフトウェアで、Lambdaが始まった頃から開発されていて、私自身も使っていました。 (その後はSAMに乗り換えましたが…。)
再代入しない実践してみて: よかったこと
やっぱり値が不変なので安心です。コードレビューもしやすいです。
IIFEの例からもわかる通り、再代入しないようにすると自然と関数をつくることに意識が向きやすいです。 関数化するとユニットテストがしやすくなるというメリットもあります。
再代入しない実践してみて: バグが少ない?
バグの発生リスクを低減、というメリットをあげたので、リスクが低減できたのか確認します。
厳密に集計するのが大変なので、私が作成した全PR中にバグを修正してそうなPRがどのくらいあるのかざっくり集計してみます。
- 私の全PRは296件
- PRに
bugが含まれるのは7件 - タイトルに
fixが含まれるPRは54件- バグだけだと漏れてるかもしれないので、参考までにタイトルに
fixが含まれるPRの件数でも集計- 私はバグ修正を含め何かしら不具合修正のときはタイトルに
fixを付けがちなので
- 私はバグ修正を含め何かしら不具合修正のときはタイトルに
- 54 / 296 = 18%
- https://github.com/motdotla/node-lambda/issues?q=is%3Apr%20author%3Aabetomo%20fix%20in%3Atitle
- バグだけだと漏れてるかもしれないので、参考までにタイトルに
そこそこバグが混在してそうな結果でしょうか…。 私が開発に参加する以前から混在していたバグの修正もしているのを加味すると少ないと言える、かも…?
再代入しない実践してみて: よくないかも?なこと
私が好きだったので、関数即時実行を多用しましたが、慣れないと読みにくいかもしれないです。
また今回実践例として取り上げたnode-lambdaはツールの特性上、実行速度にシビアになる必要がなかったこともあり、頑なに再代入をしないようにできましたが、実行速度が重要な場面では性能検証はしっかりした方が良いです。 ユニットテストで時間のかかったテストを教えてくれたりするので、それを活用すると良いと思います。
最後に実際のコード紹介
最後にnode-lambdaではどのように頑なに再代入をしていないのか実際のコードを紹介します。
実際の例: ファイル名の取得
const filename = (() => {
for (const extension of ['.js', '.mjs']) {
if (fs.existsSync(splitHandler[0] + extension)) {
return splitHandler[0] + extension
}
}
})()
関数即時実行で同じディレクトリにあるファイル名を取得しています。
実際の例: map
const paramsList = scheduleList.map((schedule) =>
Object.assign(schedule, { FunctionArn: functionArn }))
mapでパラメーターを生成しています。
実際の例: isXXX
_isUseS3 (program) {
if (typeof program.deployUseS3 === 'boolean') {
return program.deployUseS3
}
return program.deployUseS3 === 'true'
}
_useECR (program) {
return program.imageUri != null && program.imageUri.length > 0
}
関数化に意識が向くの例で、よく見かけるisXXXといった関数もいくつかあります。
まとめ
私の頑なに再代入をしない思いのたけを書きました。
保守しやすくなったりして良い点もありますし、書き方によっては遅くなったりしますし、適切な場面で適切に活用すると良いと思います!
おまけ: 再代入しない?
実践例として紹介したnode-lambdaでは再代入が一切ないのか確認してみます。
let はない?
letがなければ再代入はしていない、と言えそうです。確認してみます。
$ grep -r 'let ' node-lambda/{bin,lib} | wc -l
5
letがありますね…。
どういう場面で使っているかは割愛するので、興味があればご確認ください!
再代入?
本文中でも簡単に触れましたが、Arrayへの要素の追加やObjectの各keyの追加などはconstで宣言してもできます。
node-lambdaでも普通に設定しています。
_lambdaFunctionConfiguration (params) {
const lambdaFunctionConfiguration = {
Events: params.Events,
LambdaFunctionArn: params.FunctionArn
}
if (params.Filter != null) {
// 再代入?
lambdaFunctionConfiguration.Filter = params.Filter
}
return lambdaFunctionConfiguration
}
この辺も厳密に再代入しないようにもできますが、再代入しないことで逆にコードが複雑になったりするので、よしなに判断して実装しています。