LoginSignup
3

More than 1 year has passed since last update.

【PHP8.1】静的変数を継承したときの挙動が変更になる

Posted at

細かすぎて伝わらない変更点。

PHPには静的変数という機能があります。

class A{
    public static int $hoge = 1; // 普通のクラス変数

    public function foo(){
        static $foo = 1; // ← これ
        return $foo++;
    }
}

$a = new A();
$a->foo(); // 1
$a->foo(); // 2
$a->foo(); // 3

関数・メソッド内でstaticを付けて変数定義すると、その変数値はプログラムが終了するまでずっと保持されます。
イメージとしては、関数ローカルのクラス変数みたいなかんじでしょうか。

そしてこの静的変数、クラス変数と並べてみると動作がほんのり微妙に異なっているんですよね。

class A
{
    public static int $hoge = 1;

    public function foo()
    {
        static $foo = 1;
        return $foo++;
    }
}

class B extends A {}

echo A::$hoge++; // 1
echo B::$hoge++; // 2
echo A::$hoge++; // 3
echo B::$hoge++; // 4

$a1 = new A();
$a2 = new A();
echo $a1->foo(); // 1
echo $a2->foo(); // 2
echo $a1->foo(); // 3
echo $a2->foo(); // 4

$a = new A();
$b = new B();
echo $a->foo(); // 5 ← $a1/$a2と同じ
echo $b->foo(); // 1 ← Bだけ違う
echo $a->foo(); // 6
echo $b->foo(); // 2

$b2 = new B();
echo $b2->foo(); // 3 ←B同士であれば同じ
echo $b->foo();  // 4

クラス変数ではA::$hogeB::$hogeが指している先は必ず同じです。
静的変数の場合、同じクラスであれば同じですが、異なるクラスであれば別の変数になる、というよくわからない状態になっています。

これはこれで使い慣れたら使い道がありそうですが、わかりにくいし事故の元なので修正しようというRFCが提出されました。
既に受理されており、PHP8.1から動作が変更になります。

PHP RFC: Static variables in inherited methods

Introduction

現状、静的変数を使っているメソッドを継承すると、継承先では別の静的変数を使います。
このRFCでは、ひとつのメソッドにたいして静的変数はひとつだけにすることを提案します。

以下は、現在の静的変数の例です。

class A {
    public static function counter() {
        static $i = 0;
        return ++$i;
    }
}
class B extends A {}

var_dump(A::counter()); // int(1)
var_dump(A::counter()); // int(2)
var_dump(B::counter()); // int(1)
var_dump(B::counter()); // int(2)

A::counter()を継承した場合、継承先のクラスBでの静的変数は異なる値が使用され、A::counter()B::counter()は異なる静的変数を管理することになります。

このRFCでは、継承にかかわらず、ひとつの静的変数はひとつの値を使うことを提案します。

class A {
    public static function counter() {
        static $i = 0;
        return ++$i;
    }
}
class B extends A {}

var_dump(A::counter()); // int(1)
var_dump(A::counter()); // int(2)
var_dump(B::counter()); // int(3)
var_dump(B::counter()); // int(4)

静的変数に関するバグレポートを参照する限りでは、これが直感的に期待される動作のようです。
また、これはクラス変数の動作とも一致します。

class A {
    private static $i = 0;
    public static function counter() {
        return ++static::$i;
    }
}
class B extends A {}

var_dump(A::counter()); // int(1)
var_dump(A::counter()); // int(2)
var_dump(B::counter()); // int(3)
var_dump(B::counter()); // int(4)

また、他の言語の動作とも一致します。

C++の例
#include <iostream>

class A {
public:
    static int counter() {
        static int i = 0;
        return ++i;
    }
};
class B : public A {
};

int main() {
    std::cout << A::counter() << std::endl; // 1
    std::cout << A::counter() << std::endl; // 2
    std::cout << B::counter() << std::endl; // 3
    std::cout << B::counter() << std::endl; // 4
    return 0;
}

現在の実装の問題点のひとつは、メソッドを子クラスでオーバーライドしてparent::を呼び出すと動作が変わるという点です。

class A {
    public static function counter() {
        static $i = 0;
        return ++$i;
    }
}
class B extends A {
    public static function counter() {
        return parent::counter();
    }
}

var_dump(A::counter()); // int(1)
var_dump(A::counter()); // int(2)
var_dump(B::counter()); // int(3)
var_dump(B::counter()); // int(4)

一般的には、直接親メソッドを呼び出すのと、オーバーライドした子クラスからparent::を呼び出すのでは、プログラムの動作は変わらないはずです。
現在は、実装をまるごと子クラスにコピーしない限り、オーバーライドしつつ元の動作を再現する方法はありません。

最後に、静的変数には明らかなバグがあります。
コンストラクタ(および他のマジックメソッド)で静的変数を使った場合、それは同じ値になります。

class A {
    public function __construct() {
        static $i = 0;
        var_dump(++$i);
    }
}
class B extends A {}

new A; // int(1)
new A; // int(2)
new B; // int(3)
new B; // int(4)

このRFCが受理された場合、この動作は正しいものになります。
このRFCが却下された場合、この動作はバグなので修正されなければなりません。

Proposal

静的変数を使用しているメソッドを継承しても、静的変数の指す値はひとつになります。

class A {
    public static function counter() {
        static $i = 0;
        return ++$i;
    }
}
class B extends A {}

var_dump(A::counter()); // int(1)
var_dump(A::counter()); // int(2)
var_dump(B::counter()); // int(3)
var_dump(B::counter()); // int(4)

ただし、トレイトの静的変数は値を共用しません。

trait T {
    public static function counter() {
        static $i = 0;
        return ++$i;
    }
}
class A {
    use T;
}
class B {
    use T;
}
var_dump(A::counter()); // int(1)
var_dump(A::counter()); // int(2)
var_dump(B::counter()); // int(1)
var_dump(B::counter()); // int(2)

トレイトの一般的なセマンティクスは『コードのコピペ』であり、トレイトのコードは単にそれぞれのクラスにコピペされたものとして扱われます。

Backward Incompatible Changes

メソッド内の静的変数の挙動が変わります。
現在の静的変数の挙動はドキュメント化されていませんが、私はこの挙動はバグであると考えています。
静的変数の一般的な利用法であるメモ化については、よほど特殊なことをしていないかぎり影響はありません。

現在の仕様を意図的に使っているコードは、static::classでインデックス化することで互換することができます。

class A {
    public function counter() {
        // 静的メソッドでも通常のメソッドでも動く
        static $counters = [];
        $counters[static::class] ??= 0;
        return ++$counters[static::class];
    }
}
class B extends A {
}

var_dump((new A)->counter()); // int(1)
var_dump((new A)->counter()); // int(2)
var_dump((new B)->counter()); // int(1)
var_dump((new B)->counter()); // int(2)

このコードは仕様変更前後で同じ動作をします。

Vote

投票は2021/04/14から2021/04/28に行われ、賛成32反対0の全員賛成で受理されました。

感想

わかりにくい!

まあ、普通は静的変数自体をあんまり使わないですよね。
素直にクラス変数・インスタンス変数を使えって話ですよ。
いや、これこれこういう理由があって私は静的変数をばりばり使ってるんだ、という使い道があったら教えてください。

あと、RFCの例は親子関係だけだからわかりやすかったけど、複数の継承があった場合に「なんかわからんけど値がおかしくなった!」ってなるかもしれません。

class A
{
    public function foo() {
        static $foo = 1;
        return $foo++;
    }
}

class B extends A{}
class C extends A{}

$b = new B();
$c = new C();
echo $b->foo(), $c->foo(), $b->foo(), $c->foo();
// PHP8.0まで1122、PHP8.1から1234

こんな使い方する方が悪いと言われればそのとおりですが。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
3