Webエンジニア向けプログラミング解説動画をYouTubeで配信中!
▶ チャンネル登録はこちら

【ITニュース解説】Your Refactor Copies a List. The Caller Still Holds the Old One.

2026年10月28日に「Dev.to」が公開したITニュース「Your Refactor Copies a List. The Caller Still Holds the Old One.」について初心者にもわかりやすく解説しています。

作成日: 更新日:

ITニュース概要

関数が受け取ったリストなどを直接変更した場合、返り値のテストだけでは呼び出し元が古いデータを使うバグに気づきにくい。これを防ぐため、引数の変更を自動で記録・比較し、意図しない変更を検知する仕組みが重要だ。

ITニュース解説

プログラムの世界では、私たちが書いたコードが予期せぬ振る舞いをすることがあります。特に、ある関数が受け取ったデータをその場で変更する場合、その変更が思わぬ問題を引き起こすことがあります。この問題は通常のテストでは見過ごされがちで、プログラムの「静かな破壊」とも呼ばれます。

たとえば、ある関数がリスト(複数のデータを順序付けて並べたもの)を引数として受け取り、そのリストに要素を追加したり、内容を並び替えたりするとします。このとき、関数がそのリスト自体を直接変更する(「in-place mutation」、つまりその場でデータを変更すること)ことで、呼び出し元のプログラムも、その変更されたリストを参照するようになります。これは意図通りの動作かもしれません。しかし、もし後でコードを改善する「リファクタリング」を行う際に、その関数が「新しいリストを作成して返す」ように変更されたらどうなるでしょうか。関数は確かに新しいリストを返しますが、呼び出し元のプログラムが元々持っていた変数は、古いリストを指したままです。この古いリストは関数によって変更されませんから、呼び出し元は古い、そして変更されていないリストを読み込んでしまい、期待と異なる結果を生んでしまいます。厄介なことに、このような状況ではエラーが発生したり、ログに記録されたりすることはほとんどありません。テストも、関数の戻り値が正しいかどうかだけをチェックすることが多いため、「呼び出し元のリストが変わったかどうか」という点は見落とされがちです。

なぜこのような問題が起こるかというと、「エイリアシング」という現象が関係しています。これは、複数の変数が同じメモリ上のデータを指している状態を指し、関数が引数として受け取ったリストの実体と、呼び出し元の変数が指すリストの実体が同じであるために起こります。関数がその実体を変更すれば、呼び出し元からも変更が見えますが、関数が新しいリストを作成して返した場合、呼び出し元は古い実体を参照し続けるため、変更に気づきません。関数の「シグネチャ」、つまり引数と戻り値の型が変わらないため、この問題は発見しにくいのです。

この「静かな破壊」を防ぐためには、関数が引数として受け取ったデータをどのように変更したかを記録し、それをチェックする仕組みが必要です。この記録を「ミューテーションレジャー」(変更履歴)と呼ぶことができます。具体的な方法としては、まず、変更を監視したい関数を特別な仕組みでラップします。この仕組みは、関数が実行される直前に、その関数に渡される全ての引数の「ディープスナップショット」(完全に独立したコピー)を取ります。そして、関数が実行された後にもう一度引数のスナップショットを取り、最初のスナップショットと比較して、どのような変更があったかを検出するのです。

この比較では、引数の型が変わったか、リストの長さが変わったか、辞書に新しいキーが追加されたか、特定の値が変わったかなど、細かな違いをJSON形式で記録します。この記録は、どの関数が、どの引数の、どの部分を、どのように変更したかという詳細を含んでいます。例えば、「arg0(最初の引数)の[2]番目の要素の値が変更された」といった情報が残されます。ただし、このスナップショット作成にはメモリと処理時間がかかるため、非常に頻繁に実行されるような場所や、巨大なデータを扱う関数には注意が必要です。

このミューテーションレジャーを作る際には、テストコードの中だけで行うのではなく、実際にプログラムが動いているのと同じような状況でデータを記録することが大切です。なぜなら、テストで使うデータは、実際の運用で使われるデータのごく一部に過ぎないからです。まず、コードベースの中からリストの追加やソートなど、引数を変更しそうな関数を特定します。次に、それらの関数を先ほどの記録用の仕組みでラップし、実際の運用データを模したテストデータや、記録済みのデータを使って関数を実行します。この実行によって生成されたミューテーションレジャーは「ベースライン」として保存され、今後の変更と比較する際の基準となります。このベースラインが、実行するたびに同じ内容であることを確認することも重要です。もし毎回異なる内容が記録されるようでは、それは信頼できる基準とは言えません。

このベースラインができたら、次はその記録を「ゲート」として利用します。ゲートとは、コードの変更が許可されるかどうかを判断する仕組みのことです。リファクタリング後に再度ミューテーションレジャーを作成し、ベースラインと比較します。比較結果は主に二つの情報を示します。「LOST」(以前は発生していたミューテーションが、新しいコードでは発生しなくなった)と「NEW」(新しいコードで、以前はなかったミューテーションが発生した)です。

「LOST」のラインは非常に危険な信号です。これは、呼び出し元のプログラムが依存していたであろうデータ変更の振る舞いが失われたことを意味します。もしLOSTが出た場合、多くの場合、行ったリファクタリングを元に戻し、元の振る舞いを維持する必要があります。これはリファクタリングの失敗ではなく、ゲートが正常に機能し、潜在的なバグからプログラムを守ってくれた証拠です。「NEW」のラインは、意図せず新しいミューテーションを導入してしまった可能性を示します。もしこのミューテーションが意図したものでなければ、それも修正が必要です。

安全なリファクタリングを進めるためには、変更は一度に一つの関数に限定することが重要です。一つの関数をリファクタリングしたら、すぐにミューテーションレジャーを再生成し、ゲートでチェックします。このプロセスを繰り返すことで、一つ一つの変更がプログラム全体に与える影響を確実に把握できます。

どのようなミューテーションを許容するかは、個々のケースで判断が必要です。例えば、呼び出し元のプログラムが「引数として渡したリストに要素が追加される」という振る舞いに依存していることが確実であれば、そのミューテーションは維持し、コードのコメントなどでその契約を明確に記述するべきです。しかし、設定情報を保持する辞書を引数として受け取り、関数内でその辞書の内容を変更している場合で、呼び出し元がその変更に依存していないのであれば、その変更を新しい辞書のコピーを返す形にリファクタリングすることも可能です。ただし、スレッド間で共有されるデータに対するミューテーションなど、並行処理が絡むケースは、この仕組みでは完全に検知できないため、より慎重な検討が必要です。

このミューテーション記録のアプローチにはいくつかの限界があります。ディープスナップショットの作成はコストがかかるため、処理速度が求められる場所には不向きです。リストの要素の追加・削除はインデックスをずらし、「arg0[2]」のようなパスでの変更追跡を困難にすることがあります。また、データベース接続のような「不透明なオブジェクト」はディープコピーが難しく、ミューテーションを見逃す可能性も。さらに重要なのは、この仕組みが単一の関数の、単一スレッドにおける実行しか監視できない点です。複数のスレッドやプロセス間でのデータのやり取り、引数以外のファイル書き込みやネットワーク通信といった「副作用」は、このアプローチでは検知できません。

したがって、この手法は、既にコードがデータを不変として扱っている場合や、オブジェクトグラフが非常に大きくディープコピーが非現実的な場合、あるいは懸念されるミューテーションが別のプロセスで発生する場合などには適用すべきではありません。

しかし、関数の戻り値だけをテストするのでは、プログラムが持つ契約(期待される振る舞い)の半分しか証明できません。関数が引数に対して何をしたのかをしっかりと把握し、記録することで、プログラムに潜む「静かな破壊」から私たちを守ることができるのです。

関連コンテンツ

関連IT用語

関連ITニュース