アルファテックブログ

「副作用」と「スコープ」を意識して、バグの少ないコードを書くことを心がけよう

カバー

目次


はじめに

最初に私が関数型言語を学びたいと思ったきっかけは、今まで習得してきた言語でも関数は使用してきましたが、この言語の関数との違いは何なんだろうという疑問からでした。 気になった点を箇条書きにしてみました。

  • これまでプログラムをコーディングしてきてなじみのある関数について、命令型言語でいわれる関数と関数型言語でいわれる関数の違いは何であるのか。
  • なぜ関数型言語は今更副作用という言葉を取り上げて語るのだろうか。

上記のことを深掘りしていくうちにまたなじみのある言葉に出会いました。それはスコープでした。 「副作用」や「スコープ」は本当に古臭い用語だと思いますが、この概念はプログラム設計において重要だと再度理解できました。
以降の章で私が副作用とスコープについて調べた際に得た知見を述べたいと思います。 また関数型言語について純粋関数といわれる形式の関数が重要視されます。 この構成についても簡単に述べたいと思います。


日々の開発でこんな経験はありませんか?

  • 「あれ、この変数の値、どこで変わったのだろうか」
  • 「関数を呼び出したら、関係ないはずのデータが壊れた」
  • 「この変数は使えるはずであるのに、なぜか『未定義です』とエラーが出た」
  • 「パッと見たところ同じ名称の変数はないのに『重複しています』とエラーが出た」

これらの問題の多くは、「副作用(Side Effects)」と「スコープ(Scope)」という2つの重要な概念への理解が不足していることに起因します。
今回は「副作用」と「スコープ」という2つの概念について、これらは堅牢で予測可能、保守しやすいコードを書くための土台となる知識です。この2つの概念を深掘りしていきます。


スコープの簡単な解説

「スコープ」とは、変数や関数がどこで有効でアクセス可能かを定義する範囲のことです。

  • スコープを管理することで、予期しない名前の衝突やデータの不整合を防ぐ。これによりプログラムが安定し、その結果として可読性と保守性が間接的に向上する。
  • 変数のスコープを適切に設定することで、変数がどの範囲で利用されるかを明確にし、プログラムの動作がより想定通りになるようにする。

たとえば、JavaScriptやC、Javaなどの言語では、スコープは {}(中カッコ)で囲まれたブロックや関数の中で定義されます。JavaScriptにおいては、letconst を使って定義された変数はブロックスコープを持ちますが、var を使った変数は関数スコープを持ちます。その結果、var で定義した変数はホイスティングの影響を受け、意図しないスコープで使用されることがあります。一方、CやJavaなどの言語では、変数は初期からブロックスコープを持ち、スコープによって情報を論理的に管理し、副作用を抑えることができます。

ホイスティングは、varで定義した変数の定義位置が、定義した関数スコープの先頭に巻き上げられる現象を指します。

一方、Pythonのような言語では、インデントによってスコープを表現します。

スコープの存在は、以下のような利点をもたらします:

  • 1つの名前を利用できる範囲が明確になる:異なる関数やブロックで同じ変数名(例えば ix)を使用することが可能である。このように、スコープにより変数が定義されたブロック内でのみ有効となるため、異なる範囲での名前の使用が許容される。

スコープを適切に管理することは、プログラムの安定性を確保し、意図しない副作用を防ぐための非常に効果的な方法の一つです。


副作用の簡単な解説

プログラミングにおける「副作用」とは、関数やメソッドの実行が、その処理対象以外である外部の状態を変更したり、外部のデータに依存したりすることを指します。

具体的には、あるスコープ内の処理が他のスコープのデータを参照または変更し、その結果としてプログラム全体または他のスコープに影響を与えることです。こうした状況により、同じ入力に対しても異なる結果や状態が生じることがあり、プログラムの予測可能性が低下します。

完全に副作用を排除することは難しいため、副作用を局所化し、その影響を最小限に留めることが重要です。これにより、プログラムの保守性と拡張性が向上します。


コーディング例での説明

ここでは、副作用に関するコーディング例をJavaScriptを使って説明します。

副作用のない関数の例

まず、関数型言語を学ぶうえで頻出する用語に「純粋関数」というものがあります。「純粋関数」の設計原則は、同じ入力に対して常に同じ出力を返し、副作用を持たないことです。この設計原則を通常の関数にも取り入れることで、コードは予測可能でデバッグしやすくなり、並行処理やパイプライン処理において予期しない悪影響を及ぼすことが少なくなります。

// 引数 a と b を受け取り、その合計を返すだけの純粋関数
function add(a, b) {
return a + b;
}
const result = add(3, 5); // result は 8 になる

この関数は、どんなに繰り返し呼び出しても同じ引数なら同じ結果を返し、外部の状態に一切影響を与えません。そのため、非常に予測しやすく、テストや再利用が容易です。


副作用のある関数の例

次に、副作用を持つ関数の例を示します。ここでは、グローバル変数 counter を使用し、1秒ごとにカウントアップして表示する関数 startTimer() を定義します。さらに、外部から counter を変更することで、副作用が発生する様子を示します。 主な副作用の原因としてはグローバル変数をカウンターとして読み書きに使用している点とグローバル変数なので、どこからでも容易に変更できてしまう点です。

let counter = 0; // グローバル変数(副作用の原因)
function startTimer() { // この関数も処理内部でグローバル変数を変更しているので副作用を持ちます。
setInterval(() => {
counter++;
console.log(`Counter: ${counter}`);
}, 1000);
}
startTimer();
setTimeout(() => counter = 0, 5000); // 5秒後にカウンターをリセット(この処理も副作用の原因に当たる)

実行結果(例):

Counter: 1
Counter: 2
Counter: 3
Counter: 4
Counter: 5
Counter: 1
Counter: 2

5秒後にカウンターの値がリセットされる様子が見て取れます。

  • 副作用の例
    • グローバル変数 counter を使用することで、外部から予期しない変更が可能である。
    • startTimer() の動作が、外部の処理によって不意に変わる。

副作用を隔離して周辺処理との影響を管理上排除した例

上記の例を改善し、副作用を隔離して周辺処理との影響を管理上排除する方法を示します。ここでは、副作用を持つ変数や関数を createTimer() 関数のスコープ内に閉じ込め、外部から直接操作できないようにしています。

function createTimer() {
let counter = 0;
function startTimer() {
setInterval(() => {
counter++;
console.log(`Counter: ${counter}`);
}, 1000);
}
function resetCounter() {
counter = 0;
}
return { startTimer, resetCounter };
}
const timer = createTimer();
timer.startTimer();
setTimeout(timer.resetCounter, 5000); // 5秒後にカウンターをリセット
// この処理(カウンターのクリア)は 'createTimer()' 関数が提供した関数(resetCounter())で行われているため、
// 明確で安全な操作として扱われ、副作用とはみなされません。

実行結果その2(例):

Counter: 1
Counter: 2
Counter: 3
Counter: 4
Counter: 5
Counter: 1
Counter: 2
  • スコープによる副作用防止
    • countercreateTimer() の内部スコープに閉じ込められており、外部から直接変更できない。
    • リセット処理も resetCounter() 関数を通じて安全に行う。
    • 適切に設計された関数を使用することで、副作用を避けつつ、意図した処理を確実に実行できる。また、関数が処理の文脈を明確にするため、コードの理解が容易になり、予期しない動作のリスクが減少する。
  • カウンターのリセットを防ぎたい場合は、リセット関数を外部に公開しなければよい。

副作用に該当するもの

  • グローバル変数の書き込み
  • グローバル変数の読み込み
  • 引数として受け取ったオブジェクトや配列の直接的な変更
  • コンソールへの入出力やネットワークアクセス(I/O操作)
  • ファイルの読み書き
  • データベース操作
  • APIへのリクエスト

副作用の問題点

副作用自体が直接コードを複雑にするわけではありませんが、意図しない副作用を考慮せずにコーディングを行うと、コードが複雑になり、バグが潜在する可能性が高まります。これにより、予期せぬ動作が発生することもあります。

  1. 予測不能性
    関数の結果が外部の状態に依存するため、正確な挙動を予測するには入力だけでなく外部の状態も考慮する必要があります。

  2. デバッグの困難さ
    たとえば counter の値が想定通りでない場合、startTimer() 関数だけを見ても原因がわからず、プログラム全体を調査する必要があります。
    具体例はこちらです。

  3. 再利用性の低下
    環境変数などの外的要因に依存して動作を変える関数は、そのままでは異なるプロジェクトや環境での再利用が難しくなります。


副作用との付き合い方

副作用を完全にゼロにすることはできません。API通信やファイル操作など、実際のアプリケーションでは副作用が不可避です。

重要なのは、副作用を意識的にコントロールすることです。

1. 処理を分割する

副作用を伴う処理と、純粋関数として扱える処理を分離することで、コードの可読性とテストの容易さが向上します。そのための設計方針には、「モジュール化」や「構造化」、さらに「スコープ管理」があります。

2. 副作用を特定のスコープ内に留め、影響範囲を限定する

1.と関連しますが、変数や状態を関数などのスコープ内に封じ込め、スコープ外には出さないようにします。こうすることで、処理の移植性やテストの容易さが向上します。
具体例はこちらです。

3. 状態の不変性を保つ設計を優先する

メモリに余裕がある場合は、関数内部の変数以外は明示的に変更しないように扱うことで、副作用を防ぎやすくなります。

// NG: 元の配列を直接変更する(副作用あり)
function addElement(arr, element) {
arr.push(element);
return arr;
}
// OK: 新しい配列を返す(副作用なし)
function addElementImmutable(arr, element) {
return [...arr, element];
}
const originalArray = [1, 2, 3];
const newArray = addElementImmutable(originalArray, 4);
console.log(originalArray); // [1, 2, 3]
console.log(newArray); // [1, 2, 3, 4]

4. スコープ間でデータや状態の操作が必要な場合の対応

やむを得ずスコープ外の変数を変更する必要がある場合は、以下の方法で副作用を明示、制御します:

  • 差分を返却し、呼び出し元で更新するものとする。
  • 関数名やコメントで、副作用を伴う操作であることを明示する(例:mutateXxx() であること)。

副作用とスコープは、コードの品質を左右する非常に重要なテーマです。ここで述べた内容を意識することで、より安全で保守性の高いコードを書くことができます。


おわりに

  • 学ぶにつれて、関数型言語とCなどの命令型言語での関数には、以下の違いがあることが理解できた。
    • 「関数型言語」:その振る舞いが数学の関数に近く、副作用を許さないロジックが求められる。
    • 「命令型言語」:構造化された処理単位としての関数を指し、スコープで関連する命令を囲ったものである。
  • 「関数型言語」をキーにして、「副作用」について深掘りして得た知見は、これまでプログラミングに関するさまざまな技術を勉強してきた中で、それら全てに共通する考え方として情報やデータをどのように論理的に管理するかという観点があるということである。そのために「スコープ」という考え方があったのだと理解できた。

参考文献


TOP
アルファロゴ 株式会社アルファシステムズは、ITサービス事業を展開しています。このブログでは、技術的な取り組みを紹介しています。X(旧Twitter)で更新通知をしています。