「関数の副作用の有無」よりも大事なもの

プログラミングをやっていると、「関数に副作用がある」とか「副作用がない」あるいは「純粋である」という話をちょいちょい耳にする。そして、「外界の状態を読み取るけど変更はしない関数」、例えば

function getTime() {
    return Date.now();
}

のような関数に副作用があるか?みたいな議論が始まったりする。

くだらない議論だ。

何か概念を定義するときは、それが「役に立つ」場面を提示できる必要がある。「関数の副作用」を定義するときは、「関数の副作用」がわかったときに何をしたいのかをはっきりさせる必要がある。「関数のどういう側面に注目したいか」を決めずに「副作用の有無」を論じるのはナンセンスだ。

ここでは、言語処理系(コンパイラー)を実装する者の立場で、関数の副作用について論じてみたい。

一般に、「副作用がない」関数の呼び出しは、「副作用がある」関数の呼び出しに対するものよりも多くの最適化を適用できる。不要コード削除、実行順の変更、共通部分式削除がその例だ。

そして重要なのは、関数の副作用は「ある」「ない」の2択ではなく、その中間、「あの最適化は適用できるけどこの最適化は適用できない」というような状態があるということだ。

いくつかの最適化について、それを適用できる例とできない例を挙げてみよう。

不要コード削除の対象にできること

副作用がない式は、結果を使わない場合は計算自体を消去できるはずだ(不要コード削除)。例えば、次のコードにおいて、add には副作用がないので、add(5, 3) の呼び出しは消去できる。

function add(x: number, y: number) {
    return x + y;
}
add(5, 3); // 結果を使わない→消去できる

可変な状態を読み取る関数や、新しくオブジェクトを生成する処理は、Haskellで言うと IO 等のモナドを使う処理ではある。しかし、コンパイラーからすると、そういう処理でも結果を使わないなら消去して良いだろう。

function readSomeData() {
    return someArray[123];
}
function makeSomeObject() {
    return new Object();
}
readSomeData(); // 結果を使わない→消去できる
makeSomeObject(); // 結果を使わない→消去できる

一方で、世界の状態を変更する関数などは、結果の使用の有無に関わらず消去できない。

console.log("Hello world"); // 最適化で消去できない

実行順を変えて良いこと

副作用がない関数なら、どのタイミングで呼び出しても値は変わらないはずだ。なので、

let x = f(42);
let y = g(37);

というコードを

let y = g(37);
let x = f(42);

に書き換えて良いということになる。この例は人工的なつまらない例だが、もっと実践的な例で言うと、

for (let i = 0; i < Math.floor(n / 4); ++i) {
    let x0 = a[4 * i];
    let y0 = Math.sqrt(x0 + 1);
    b[4 * i] = y0;
    let x1 = a[4 * i + 1];
    let y1 = Math.sqrt(x1 + 1);
    b[4 * i + 1] = y1;
    let x2 = a[4 * i + 2];
    let y2 = Math.sqrt(x2 + 1);
    b[4 * i + 2] = y2;
    let x3 = a[4 * i + 3];
    let y3 = Math.sqrt(x3 + 1);
    b[4 * i + 3] = y3;
}

というコードを並び替えて

for (let i = 0; i < Math.floor(n / 4); ++i) {
    let x0 = a[4 * i];
    let x1 = a[4 * i + 1];
    let x2 = a[4 * i + 2];
    let x3 = a[4 * i + 3];
    let y0 = Math.sqrt(x0 + 1);
    let y1 = Math.sqrt(x1 + 1);
    let y2 = Math.sqrt(x2 + 1);
    let y3 = Math.sqrt(x3 + 1);
    b[4 * i] = y0;
    b[4 * i + 1] = y1;
    b[4 * i + 2] = y2;
    b[4 * i + 3] = y3;
}

にできるなら、SIMDの利用のような最適化に一歩近づくことになる。

可変な状態を読み取る関数や、世界の状態を変更する関数に対しては、一般にはこの最適化は適用できない。一方、新しくオブジェクトを生成する関数に対してはこの最適化を適用しても問題ないだろう。

「実行順を変えて良いこと」は、どちらかというと複数の「副作用」の間の関係、と言うべきかもしれない。例えば、配列の読み取りと書き込みは一般には順番を入れ替えられないが、配列の読み取り同士であれば順番を入れ替えられる。

複数の呼び出しを1回にまとめられること(共通部分式削除)

例えば、f に副作用がなければ

let a = f(42);
let b = f(42);

というコードを

let a = f(42);
let b = a;

に書き換えることができるはずだ。このような最適化を共通部分式削除 (common subexpression elimination) と呼ぶ。

新しいオブジェクト(アイデンティティーのある値)を作るような処理は、共通部分式削除するとまずい。例えば、JavaScriptで

let a = Symbol("foo");
let b = Symbol("foo");

という処理があったときに let b = a; に変換すると意味が変わってしまう。

1回の呼び出しを複数に増やせること

状況によっては、関数呼び出しの回数を増やせると都合が良いかもしれない。例えば、次の関数においては、y の計算は十分コストが低いと考えられるので、無名関数にキャプチャーさせるよりも、無名関数を () => x * (x + 1) に変換した方が良いかもしれない。

function f(x: number) {
    const y = x + 1;
    return () => x * y;
}

副作用がない関数なら、こういう最適化をしても問題ないだろう。副作用がある関数の場合はこういうことをすると意味が変わってしまうかもしれない。

例外を投げないこと

例外を投げる可能性のある処理に対しては、不要コード削除や順番を入れ替えたりする最適化は適用できない。あるいは、f が例外を投げないとわかっていれば

try {
    f();
} catch {
}

というコードのtry-catch節を削除できるかもしれない。

「例外を投げる」の変種として「継続を操作する」というのもある。

Haskellの場合

Haskellが純粋関数型言語と呼ばれるのは、原則としてすべての関数 a -> b が純粋、つまり上記の最適化を適用しても問題ないという紳士協定があるからだ。まあ、「1回の呼び出しを増やす」のは計算がよっぽどチープじゃないとやらないとは思うが。

厳密に言うと、ボトム ⊥ みたいなやつがあったり、unsafePerformIO みたいなやつはあるわけだが、そういうのは例外ということで……。こういう記事を書いたこともあった:unsafePerformIOではじめる愉快なHaskellプログラミング

Haskellにはボトム ⊥ があるが、純粋なコードではボトム ⊥ をキャッチできないというのは重要なポイントだと思っている。

C言語の場合

C言語の関数はどんな副作用も起こせる、従ってコンパイラーは勝手に上記の最適化をできない、というのが伝統だったが、C23では関数の副作用を制限する属性 [[reproducible]][[unsequenced]] が追加された。説明のためにいくつかの用語を定義する(解釈に自信なし)。

関数によるストア操作が関数呼び出しと同期的であって、外から見えるストア操作はポインター引数経由であるようなものをeffectlessであるという(N3096 6.7.12.7 段落7)。

「連続する複数の呼び出しをまとめられる/1回の呼び出しを複数にできる」関数はidempotentと呼ばれる(N3096 6.7.12.7 段落8)。

effectlessかつidempotentである関数はreproducibleであるという(N3096 6.7.12.7 段落9)。

関数fおよびfから呼び出される関数で定義される静的またはスレッドローカルな変数がすべてconstかつ非volatileである場合、その関数fはstatelessであるという(N3096 6.7.12.7 段落5)。

関数fが引数以外の方法でアクセスする変数(状態)Xについて、プログラムの実行中におけるfによるXへのアクセスで得られる値がすべて同じであること。引数経由でXを観測する場合はXへのアクセスは一意的なポインター引数に基づくものであること。これらを満たす場合に、fはindependentであるという(N3096 6.7.12.7 段落6)。

statelessかつeffectlessかつidempotentかつindependentな関数はunsequencedであるという(N3096 6.7.12.7 段落9)。

ざっくりまとめると、reproducibleな関数呼び出しは呼び出し回数を変えても良さそう、unsequencedな関数呼び出しはそれに加えて呼び出し位置を自由に変更できる、ということになるだろうか。N3096 6.7.12.7.2 段落3(NOTE 1)には、unsequencedな関数は共通部分式削除、メモ化、遅延評価等の最適化の対象にできる、とある。ただし、並行な実行はできるとは限らないようだ(同NOTE 3)。

正直これらの属性の解説は私の手に余るので、他の解説ページも参照してほしい:

GHCの場合

この記事はコンパイラーの立場の記事なので、実際のコンパイラーが副作用をどうやって扱っているかも見てみよう。

GHCの場合、プリミティブ命令の「副作用」(GHCコード上は「effect」)の種類は以下の4つに分類される(compiler/GHC/Builtin/PrimOps.hsを参照):

  • ReadWriteEffect
    • グローバルな状態、あるいは可変な状態を読み書きする。
  • ThrowsException
    • ReadWriteEffectではないが、未定義動作ではない使い方で例外を投げる可能性がある。
  • CanFail
    • ReadWriteEffectでもThrowsExceptionでもないが、不適切な使い方をするとunchecked exceptionを投げる可能性がある。
    • 実用上は、「引数の範囲チェックの前に投機的に実行してはいけない」となるだろう。
  • NoEffect
    • それ以外。

これらの副作用と、可能な最適化の関係は、Note [Transformations affected by primop effects]

                    NoEffect    CanFail    ThrowsException    ReadWriteEffect
Discard                YES        YES            NO                 NO
Defer (float in)       YES        YES           SAFE               SAFE
Speculate (float out)  YES        NO             NO                 NO
Duplicate              YES        YES            YES                NO

という形でまとまっている。

まとめ

副作用が「ある」か「ない」という分類はコンパイラーにとっては粗すぎる。よっぽど文脈がはっきりしているのでない限りは、単に「副作用がある/ない」という言い方をするのは避けて、どういう側面に注目したいのかはっきりさせてはどうか。

【2025年6月22日 追記】これはコンパイラー屋だからこの記事に書いたような着眼点になるが、例えばHTTPの文脈、メモ化の文脈ではまた別の分類になるかもしれない。要は「副作用」というラベルを貼る目的がはっきりしていることが大事である。【追記終わり】

Spread the love