PHPの関数内static変数は誰のもの? 継承で共有される落とし穴
関数内のstatic変数は、関数を抜けても値が保持され、次の呼び出しに引き継がれる変数です。スコープは関数の中だけですが、寿命は関数ではなくプロセスに紐づきます。
この「スコープは狭いのに寿命だけ長い」というねじれが、実務でじわじわ効いてきます。書いた本人は「ちょっとしたキャッシュ」のつもりでも、あとから読む人にとっては見えないグローバル変数と変わりません。
しかもPHP 8.1で挙動が静かに変わっています。継承まわりで昔のコードと結果が違う、という話を今日は中心に書きます。
static変数はどこに保存されているのか
まず前提の確認から。static変数は「関数ごとに1セット」持たれます。オブジェクトごとでも、呼び出しごとでもありません。
<?php
class Reporter
{
public function count(): int
{
static $n = 0;
return ++$n;
}
}
$x = new Reporter();
$y = new Reporter();
echo $x->count(); // 1
echo $y->count(); // 2 ← 別インスタンスなのに続きから
インスタンスを分けても、カウンタは1つです。ここを「プロパティみたいなもの」と誤解すると、テストで前のケースの値を引きずって落ちます。
なぜPHP 8.1で継承の挙動が変わったのか
ここが本題です。static変数を持つメソッドを子クラスが継承しただけ(オーバーライドしていない)場合、PHP 8.0以前は親と子で別々のstatic変数を持っていました。PHP 8.1以降は、これが共有されます。
<?php
class A
{
public function counter(): int
{
static $n = 0;
return ++$n;
}
}
class B extends A {}
$a = new A();
$b = new B();
echo $a->counter(); // 1
echo $b->counter(); // PHP 8.0以前は 1 / PHP 8.1以降は 2
「メソッドは1つしかないのだから、static変数も1セットであるべき」という整理で、RFC「Static variables in inherited methods」として入りました。理屈としては自然だと思います。
ただ、これを暗黙の前提にしたコードがあると壊れます。親クラスに「初回だけ初期化する」処理を置いて、子クラスごとに1回ずつ走ることを期待していた、というパターンが典型ですね。8.1に上げたあと、なぜか2つ目のクラスで初期化がスキップされる、という形で表に出ます。
なお、子クラスがメソッドをオーバーライドした場合は別のメソッドなので、static変数も別になります。共有されるのは「継承しただけ」のときだけです。
トレイト経由なら使うクラスごとに別
紛らわしいのがトレイトです。トレイトのメソッドは使う側のクラスにコピーされる扱いなので、static変数もクラスごとに独立します。
<?php
trait Counter
{
public function inc(): int
{
static $c = 0;
return ++$c;
}
}
class C1 { use Counter; }
class C2 { use Counter; }
echo (new C1())->inc(); // 1
echo (new C2())->inc(); // 1 ← クラスごとに別のカウンタ
継承だと共有、トレイトだと独立。同じ「コードの再利用」でも結果が逆になるので、ここは覚えておくと事故が減ります。
クロージャの中のstaticはいつリセットされるか
クロージャに書いた場合は、function () { ... } という式が評価されるたびに新しいstatic変数のセットが作られます。つまりクロージャオブジェクト1つにつき1セットです。
<?php
$make = function (): callable {
return function (): int {
static $n = 0;
return ++$n;
};
};
$f = $make();
$g = $make();
$h = $f; // 同じクロージャオブジェクトを指すだけ
echo $f(); // 1
echo $h(); // 2 ← $f と同一オブジェクトなので共有
echo $g(); // 1 ← 別オブジェクトなので独立
変数に代入し直しただけでは新しくならない、というのはオブジェクトの代入と同じ感覚ですね。逆に言えば、ファクトリ関数からクロージャを返す形にすれば「使い捨てできるstatic」が作れます。テストしやすさで言えば、こちらのほうが素直だと思います。
PHP 8.3で初期化式に何が書けるようになったか
PHP 8.2以前は、static変数の初期値は定数式しか書けませんでした。関数呼び出しも引数も使えないので、「nullで初期化してから中で詰め直す」という書き方が定番でしたね。
<?php
// PHP 8.2以前の定番。初期値に式が書けないので手で分岐する
function config(): array
{
static $config = null;
if ($config === null) {
$config = json_decode(file_get_contents(__DIR__ . '/config.json'), true);
}
return $config;
}
PHP 8.3からは初期化式に任意の式が書けるようになりました。初期化式は最初にその行へ到達したとき一度だけ評価されるので、分岐を自分で書かなくて済みます。
<?php
// PHP 8.3以降。初回到達時に一度だけ評価される
function config(): array
{
static $config = (array) json_decode(
file_get_contents(__DIR__ . '/config.json'),
true
);
return $config;
}
ついでに、同じ関数内で同じstatic変数を二重に宣言するのはPHP 8.3以降コンパイルエラーになりました。
<?php
function f(): void
{
static $i = 1;
static $i = 2; // PHP 8.3以降: Fatal error: Duplicate declaration of static variable $i
}
以前は書けてしまって、しかも挙動が直感に反していた部分です。素直にエラーにしてくれるのはありがたい変更でした。
常駐プロセスではリクエストをまたいで生き残る
mod_phpやPHP-FPMで動かしているうちは、static変数はリクエストの終わりに消えます。そのつもりで「実質リクエストスコープのキャッシュ」として使っているコードは多いはずです。
ところがSwooleやRoadRunner、FrankenPHPのワーカーモードのような常駐プロセスに載せ替えると、前提が崩れます。static変数はワーカーが生きている限り残るので、前のリクエストのユーザー情報がそのまま次のリクエストに出てしまう、という事故につながります。
引数をキーにしたメモ化も同じで、常駐環境ではそのまま上限なしのメモリリークになります。static変数を使うなら「このプロセスは何分生きるのか」を一度考えておくと安全です。
まとめ
関数内のstatic変数は手軽ですが、実体は「そのメソッドに紐づく見えないグローバル変数」です。インスタンスをまたいで共有され、PHP 8.1以降は継承した子クラスとも共有され、トレイト経由なら独立する。この差を把握していないと、バージョンを上げたときや実行環境を変えたときに理由のわからない挙動に化けます。
個人的には、状態を持ちたいならオブジェクトのプロパティに持たせて、外から差し替えられるようにしておくほうが結局は楽だと思っています。static変数は「この関数の外に一切漏れない、純粋な計算のメモ化」くらいに範囲を絞っておくと平和ですね。
よくある質問
Q. static変数はインスタンスごとに別々になりますか?
A. なりません。static変数はメソッド単位で1セット持たれるため、同じクラスのインスタンスをいくつ作っても同じ変数を共有します。インスタンスごとに持たせたい場合はプロパティを使ってください。
Q. 初期化式に関数呼び出しを書けますか?
A. PHP 8.3以降なら書けます。8.2以前は定数式しか許されていないため、nullで初期化してから関数内で代入する書き方が必要です。
Q. 親クラスと子クラスでカウンタを分けたい場合はどうしますか?
A. PHP 8.1以降、継承しただけのメソッドはstatic変数を共有します。分けたいなら子クラスでメソッドをオーバーライドするか、そもそもstatic変数をやめて静的プロパティやインスタンスの状態として持つのが確実です。