このサイトでの挿絵は、Gemini Proによる生成が大半です。 また、情報収集・まとめなどのサイトについては、検索結果をベースとしたものを使用しています。可能な限り出典を明示するよう努めますが完全ではありません。各自で調べる事もお忘れなく。

同じ式なのに答えが変わる——置換テストで見抜く副作用〈言語知新(7)前編〉

こんにちは!Agy無限会社のコンテンツ制作部です。

bump() + bump() という同じ式を2回書いたのに、1回目は 3、2回目は 7 になる——そんな経験、ありませんか。バグではありません。むしろこの「答えが変わってしまう」という体験こそが、今回のテーマである 参照透過性 を理解する最短ルートです。

今回の言語知新は「参照透過性とイミュータビリティの考古学」という2部構成の 前編 にあたります。後編では「参照透過性」という言葉がどこから来たのかを一次資料まで遡って追いかける予定ですが、歴史の話を先にすると必ず迷子になるので、この前編では手を動かして確かめられる道具を1つだけ持ち帰っていただきます。それが 置換テスト ——「その式を変数に括り出しても、答えが変わらないか」です。IDE の「変数に抽出(Extract Variable)」というリファクタリングが安全に押せるかどうか、と言い換えても構いません。

そして今回いちばんお伝えしたいのは、参照透過性は「言語の看板」ではなく「式ひとつひとつに宿る性質」だということです。Python にも参照透過な式はいくらでもありますし、Haskell にも参照不透明な式を書く手段はあります。出てくるコード例はすべて Python 3 と C(gcc)で実際に実行・コンパイル検証したものだけを使っています。



1. 変数に括り出すと壊れる

まず、実際に壊してみましょう。

1
2
3
4
5
6
7
8
9
counter = 0

def bump():
    global counter
    counter += 1
    return counter

print(bump() + bump())    # 実行結果: 3
print(bump() + bump())    # 実行結果: 7

実行結果(検証済み)

3
7

bump() は呼ばれるたびに 1, 2, 3, 4 と違う値を返す関数です。だから bump() + bump() は、最初が 1+2=3、次が 3+4=7 になります。ポイントは global counter でグローバル変数を書き換えているところで、この宣言の意味は Python のプログラミング FAQにまとまっています。「同じ式を書いたのに違う値になる」というのは、数学の関数ではありえないことです。

ここで置換テストをやってみます。bump() の呼び出しを変数に括り出してみるのです。

1
2
3
4
5
6
counter = 0
print(bump() + bump())    # 3

counter = 0
z = bump()
print(z + z)              # 2

実行結果(検証済み)

3
2

結果が変わりました。 bump()z という変数に括り出しただけで、答えが 3 から 2 になってしまいました。これが 参照不透明 ということです。

対照実験として、副作用のない関数で同じことをやってみます。

1
2
3
4
5
6
7
def double(n):
    return n * 2

print(double(5) + double(5))    # 20

y = double(5)
print(y + y)                     # 20

実行結果(検証済み)

20
20

こちらは変わりません。これが 参照透過 です。参照透過とは、突き詰めれば「変数に括り出しても答えが変わらないか」——この一問に尽きます。

参照透過性を壊すものを、まとめて 副作用(side effect) と呼びます。「主たる結果(返り値)以外に起きること」という意味です。典型的なものを挙げておきます。

種類
グローバル変数の変更global counter; counter += 1
引数の破壊的変更lst.append(x)*p = 5
入出力print()、ファイル読み書き、ネットワーク
外部状態の読み取りtime.time()random.random()、環境変数
例外の送出制御の流れを変える(広義の副作用とされることがある)

ここで注意したいのは、外部状態を 読み取るだけ の操作も副作用に数える点です。書き込んでいなくても、time.time() は時間が経てば違う値を返すので置換テストに落ちます。

1
2
import time
print(time.time() == time.time())   # 環境によって False(時計の分解能次第で True になることもある)

bump() はグローバル変数を書き換える典型例でしたが、副作用は「書き込み」だけとは限らない——この視点を持っておくと、次の節以降で出てくる罠の見え方が変わってきます。

2. 「代入」という言葉が2つの別物を指している

ここが最大の混乱ポイントです。プログラミングでよく使う = は、実は見た目が同じでもまったく別の2つの操作を指しています。

1つは数学の代入(substitution)です。これは「x という箱に 3 を入れる」のではなく「x と書いてある場所を 3 と読み替えてよい」という宣言であり、時間の経過という概念がありません。もう1つはプログラミングの代入(assignment)で、こちらは記憶域の破壊的更新です。「代入する前」と「代入した後」という時間の経過を含みます。英語では前者を substitution、後者を assignment と呼び分けますが、日本語ではどちらも「代入」になってしまうのが、この混乱の一因です。

C言語で確かめてみましょう。C では代入すら「式」です。

1
2
3
4
5
6
7
8
#include <stdio.h>

int main(void) {
    int x;
    int y = (x = 5);      /* x = 5 という「式」の値は 5 */
    printf("x=%d y=%d\n", x, y);
    return 0;
}

実行結果(gcc で検証済み)

x=5 y=5

C では x = 5 は文ではなく式であり、値(5)を持ちます。だから y = (x = 5) と書けますし、while ((c = getchar()) != EOF) のようなイディオムが成立します。しかし この式は参照透過ではありません。 x = 5 の値は 5 ですが、これを 5 に置き換えてしまうと x への代入という副作用が消えて、プログラムの意味が変わってしまいます。「値を持つ」ことと「参照透過である」ことは別なのです。

Python では事情が違います。Python では x = 5 であり、値を持ちません。だから y = (x = 5) と書くと構文エラーになります。この「代入を式として使いたい」というニーズに応えるために、Python 3.8 で追加されたのがウォルラス演算子 := です。PEP 572 – Assignment Expressionsが、この構文をわざわざ独立した提案として定義しています。

1
2
# y = (x = 5)          # SyntaxError
y = (x := 5)           # これは OK。x に 5 を入れつつ、式の値も 5

C は代入が最初から式であるのに対し、Python は代入を文のまま保ちつつ、必要な場面だけ := という別構文で式化した——という設計の違いがここに表れています。

3. 空リストが消えない罠

Python 経験者なら一度は踏む、有名な罠を見てみましょう。これも参照透過性の話そのものです。

1
2
3
4
5
6
7
def add_item(item, basket=[]):
    basket.append(item)
    return basket

print(add_item("りんご"))
print(add_item("ばなな"))
print(add_item("みかん"))

実行結果(検証済み)

['りんご']
['りんご', 'ばなな']
['りんご', 'ばなな', 'みかん']

期待した動作は、毎回新しい空のリストから始まって、それぞれ ['りんご']['ばなな']['みかん'] が返ってくることです。しかし実際には、前の呼び出しの結果が残ってしまいます。なぜかというと、デフォルト引数の []関数定義時に一度だけ 評価され、その同じリストオブジェクトが毎回使い回されるからです。そして basket.append(item) がそれを破壊的に書き換えています。Python 公式ドキュメントのプログラミング FAQにも、この「可変オブジェクトをデフォルト引数にすると問題になる」理由が明記されています。

これは参照不透明の実例です。add_item("ばなな") という同じ式の結果が、その前に何を呼んだかによって変わってしまっているのです。

修正版はこうなります。

1
2
3
4
def add_item(item, basket=None):
    if basket is None:
        basket = []
    return basket + [item]      # append ではなく、新しいリストを作って返す

basket.append(item) が既存のリストを書き換えるのに対し、basket + [item]新しいリストを作ります。 後者なら元の basket は無傷であり、何度呼んでも同じ結果になります。

この罠の背景にあるのが、 エイリアシング (名前が2つ、実体は1つ)という現象です。

1
2
3
4
5
6
a = [1, 2, 3]
b = a               # コピーではない。同じリストに2つ目の名前を付けただけ
b.append(4)

print(a)            # [1, 2, 3, 4]  ← a も変わっている!
print(a is b)       # True (同一オブジェクト)

実行結果(検証済み)

[1, 2, 3, 4]
True

b = a は代入ですが、コピーではありません。Python の変数は値ではなく、オブジェクトへの参照を持ちます。だから片方を書き換えると、もう片方からもその変化が見えてしまいます。エイリアシングがあると、「この関数はこの変数を変えないはずだ」という局所的な推論が効かなくなります。可変デフォルト引数の罠も、根っこにあるのはこの構造です。

4. コンパイラが1回で済ませる

参照透過な式が「なぜ嬉しいのか」を、理屈ではなく実測で確かめます。まずはコンパイラの最適化から。

GCC には、関数が純粋であることをプログラマが宣言する __attribute__((const)) という機能があります。GCC 公式マニュアル “Common Function Attributes”に定義されているものです。

1
2
3
4
5
int plain(int n);                                 /* 純粋かどうか不明 */
int pure_(int n) __attribute__((const));          /* 純粋だと宣言 */

int use_plain(int n) { return plain(n) + plain(n); }
int use_pure (int n) { return pure_(n) + pure_(n); }

これを gcc -O2 -S でアセンブリに落として、call 命令を数えてみます。以下は x86-64 Linux 環境での出力です。CPU アーキテクチャや OS が変わればニーモニックは変わりますが、「2回の呼び出しが1回にまとめられる」という結果自体は再現します。

実行結果(検証済み)

use_plain:
        call    plain@PLT
        call    plain@PLT     ← 2回呼んでいる
use_pure:
        call    pure_@PLT     ← 1回で済ませた

ソースコードは両方とも「2回呼ぶ」と書いてあります。にもかかわらず、純粋だと宣言したほうは1回にまとめられました。これが 共通部分式除去(Common Subexpression Elimination, CSE) という最適化です。参照透過性は、コンパイラにとって最適化の許可証なのです。

ただし注意が必要です。__attribute__((const)) は「純粋かどうかを検査する」機能ではありません。プログラマが「純粋です」と約束するだけで、その約束が事実と違っていても GCC は検出しません。約束が破られていれば、プログラムはそのまま壊れます。

同じ発想はキャッシュにも現れます。Python の functools.lru_cache は、関数の結果を覚えておいて2回目以降は計算しない仕組みで、Python 公式ドキュメントの functools.lru_cacheに説明があります。これは 純粋な関数にしか使ってはいけません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import functools

counter = 0

@functools.lru_cache(maxsize=None)
def cached_bump():
    global counter
    counter += 1
    return counter

print(cached_bump(), cached_bump(), cached_bump())
print("counter =", counter)

実行結果(検証済み)

1 1 1
counter = 1

本来なら 1, 2, 3 と返すはずの関数が、1, 1, 1 を返してしまいました。キャッシュが効いて、2回目以降は関数本体が実行されていないのです。counter も 1 のままです。メモ化は「同じ入力なら同じ出力」を前提にしています。その前提が崩れている関数にメモ化を掛けると、エラーも警告も出ないまま 静かに壊れます。

これは Python の lru_cache に限った話ではありません。React の useMemo / React.memo、Vue の computed、各種 ORM のクエリキャッシュ——これらは全部、同じ前提の上に成り立っています。「純粋にしろ」というフレームワークの要求は、思想の押しつけではなく、キャッシュを壊さないための技術的な必要条件なのです。

5. const は不変を意味しない

ここが、この前編でいちばん大事な区別かもしれません。参照透過性とイミュータビリティ(不変性)は、似た文脈で語られますが定義が違います。参照透過性は「式の性質」で、「値に置き換えてよいか」です。イミュータビリティは「値や束縛の性質」で、「後から書き換えられるか」です。関係は深いのですが、片方から他方は導けません。

C の const で確かめてみます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <stdio.h>

int main(void) {
    int v = 10;
    const int *p = &v;      /* p 経由では書けない */
    /* *p = 20; */          /* ← これはコンパイルエラーになる */
    v = 20;                 /* しかし v 自身は変えられる */
    printf("*p = %d\n", *p);
    return 0;
}

実行結果(検証済み)

*p = 20

pconst int * なのに、*p の値が 10 から 20 に変わってしまいました。C の const は「このハンドル経由では書き換えない」という約束であって、「指している先が不変である」という保証ではありません。JavaScript の const や Java の final も同様に、束縛だけを固定します。

1
2
3
const arr = [1, 2, 3];
arr.push(4);        // OK。arr の中身は変えられる
// arr = [];        // これはエラー。束縛の付け替えだけが禁止

もう1つ、浅い不変性と深い不変性の区別も押さえておきます。

1
2
3
t = (1, [2, 3])     # タプルは不変
t[1].append(4)      # だが中のリストは可変
print(t)

実行結果(検証済み)

(1, [2, 3, 4])

タプル自体は「どの要素を指すか」を変えられませんが、指している先のリストは変えられます。これを 浅い(shallow)不変性 と呼びます。到達可能なもの全部が不変なら 深い(deep)不変性 です。「イミュータブルだからスレッドセーフ」という主張が成り立つのは、深い不変性の場合に限られる点に注意してください。

この区別は、フレームワークの流儀を理解するうえでも役立ちます。たとえばReact 公式ドキュメント “Components and Hooks must be pure”は、コンポーネントに対して「厳密な純粋性」ではなく「冪等性(同じ入力なら同じ結果を返すこと)」を求めています。つまり React が要求しているのは値の物理的な不変性そのものではなく、参照透過性に近い「振る舞いの再現性」なのです。参照透過性とイミュータビリティが別概念だと分かっていると、こうしたフレームワークのルールも「なぜそう決められているか」まで理解できるようになります。

実務での使いどころ——純粋な核と、命令的な薄い殻

ここまで「壊れる例」を中心に見てきましたが、参照透過性は実務の設計にも直結します。次の2つの関数を比べてみてください。

1
2
3
4
5
# 純粋: 入力と出力だけ見ればテストできる
def tax_included(price, rate=0.1):
    return int(price * (1 + rate))

assert tax_included(1000) == 1100      # これで終わり
1
2
3
4
5
6
7
8
# 不純: テストするには周辺の準備が要る
import datetime

def is_business_hours():
    h = datetime.datetime.now().hour   # 外部状態を読んでいる
    return 9 <= h < 18

# テストするには「今の時刻」を差し替える必要がある(モック)

上の関数は引数だけで結果が決まるので、テストが一行で済みます。下の関数は現在時刻という外部状態に依存するので、テストするには時計を偽装しなければなりません。

ここで現実的な設計指針が見えてきます。「時刻を読む」部分と「時刻から判定する」部分を分ければ、後者は純粋にできます。

1
2
3
4
5
def is_business_hours_at(hour):        # 純粋。テストしやすい
    return 9 <= hour < 18

def is_business_hours():               # 不純。だが薄い
    return is_business_hours_at(datetime.datetime.now().hour)

これが functional core / imperative shell (純粋な核と、命令的な薄い殻)と呼ばれる設計パターンです。副作用そのものをなくすのではなく、副作用を持つコードをできるだけ薄い層に押し込め、ロジックの本体は純粋に保つ——参照透過性を実務に持ち込む、いちばん現実的な形だと思います。

まとめ

用語を整理しておきます。

用語何の性質か定義
参照透過性式をその値に置き換えても意味が変わらない
純粋性関数同じ入力に同じ出力を返し、副作用がない
不変性(束縛)名前名前が指す先を後から変えられない
不変性(値)オブジェクトオブジェクトの中身を後から変えられない
深い不変性オブジェクトのグラフ到達可能なもの全部が不変
副作用操作返り値以外に起きること

ここまで確かめてきたことを並べ直すと、次のようになります。

  • 参照透過性とは「式を値に置き換えてよいか」であり、置換テストで確かめられる(1節)
  • それを壊すのが副作用。グローバル変数の書き換え、破壊的更新、入出力、外部状態の読み取りが含まれる(1節・3節)
  • 「値を持つ式」と「参照透過な式」は別物。C の x = 5 がその例(2節)
  • 参照透過性は、コンパイラの最適化やメモ化が成り立つための前提になっている(4節)
  • 参照透過性とイミュータビリティは別概念であり、const は不変を意味しない(5節)

そのうえで、いちばん誤解されやすい点を書いておきます。 参照透過性を追求しても、副作用は消えません。 画面に何も出さず、ファイルも読まず、ネットワークにも触れないプログラムは、何の役にも立たないからです。現実の言語がやっているのは、副作用を「なくす」ことではなく、「どこに置くかを決める」ことです。Haskell なら IO 型の中、React ならイベントハンドラと useEffect の中、というように。つまり参照透過性とは「副作用ゼロ」ではなく「副作用の在り処が分かっている」という状態のことだと理解するのが、実務的にはいちばん役に立ちます。

そして、「参照透過な言語」という言い方には少し注意が必要です。正確には「通常の書き方をすれば式が参照透過になる言語」です。参照透過性は言語の看板ではなく、式ひとつひとつに宿る性質なのですから。

よく言われることと実際のずれも、表にしておきます。手を動かして確かめた内容と照らし合わせてみてください。

言われること実際は
const にしたから不変束縛だけ固定。中身は変わりうる(5節)
イミュータブルだからスレッドセーフ深い不変性でなければ保証されない(5節)
不変にすると速くなる速くなるのは「それによって可能になる最適化」の話であり、素朴な非破壊更新それ自体は普通に遅い
メモ化すれば速くなる純粋でない関数に掛けると静かに壊れる(4節)
値を持つ式は参照透過C の x = 5 は値を持つが参照透過ではない(2節)
タプルは不変だから安全中に可変オブジェクトを入れられる(5節)
React はイミュータブルだから関数型React 公式は「厳密な純粋性は求めない、冪等性が重要」と明記している(5節)

次回予告: 後編では、「参照透過性」という言葉そのものがどこから来たのか、一次資料まで遡って追いかけます。この用語は元々どの分野の言葉だったのか、プログラミング言語論に持ち込まれたのはいつなのか——今回整理した用語表を手元に置きながら、歴史探訪編でその答えを確かめていきます。後編は近日公開予定です。

参考リンク

Hugo で構築されています。
テーマ StackJimmy によって設計されています。