PHPオブジェクト指向入門:クラス・interface・enumと8.5新機能

readonly、trait の衝突、static と self、backed enum、プロパティフック、8.5 のパイプ演算子を PHP 8.5.6 で実行。実際のエラー文とオブジェクト対配列のメモリ実測付き。

シリーズ:PHP講座 3/6(中級)。 関数とフォーム処理は書けるようになり、次はコードをクラスで整理したい人向けです。読み終えると、class・interface・abstract class・trait・enum のどれを選ぶべきか、選び間違えたときに PHP 8.5 がどんなエラーを出すかが分かります。

受託開発や社内システムで引き継いだ PHP のコードを開くと、コンストラクタで引数5つをプロパティ5つに1行ずつ代入し、ステータスは '1'(受付)'2'(対応中)のような文字列や数字の定数で比較し、全フィールドに getXxx() が付いている、という書き方をよく見かけます。今でも動きますが、最近の PHP なら最初の1行で止めてくれるバグをそのまま通してしまいます。この記事はそうしたコードを保守する人・新しく書く人のために、同じ処理を PHP 8.x の文法で書き直し、すべて PHP 8.5.6 CLI(Apple Silicon Mac、M1 Pro、特記なければ OPcache 無効)で実行して、ミスごとに出るエラーをそのまま載せます。

Java や C# を先に学んだ人なら、概念の大半はそのまま通じます。比較は Java のクラス・record・interface 講座を参照してください。PHP 特有の落とし穴は trait、static、そして JSON に出せない enum です。

クラス、コンストラクタのプロモーション、readonly

コンストラクタプロモーション(constructor property promotion、PHP 8.0)は、コンストラクタの引数の位置でプロパティの宣言と代入を同時に行います。readonly(8.1)は、クラスの内側から一度だけ代入できるプロパティです。

<?php
declare(strict_types=1);

final class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $email,
        private array $roles = [],
    ) {}

    public function withEmail(string $email): static
    {
        return new static($this->id, $email, $this->roles);
    }
}

$u = new User(1, 'a@example.com');
$u2 = $u->withEmail('b@example.com');
echo $u2->email, ' / original: ', $u->email, PHP_EOL;

try {
    $u->email = 'hack@example.com';
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), PHP_EOL;
}
b@example.com / original: a@example.com
Error: Cannot modify readonly property User::$email

例外クラスに注目してください。Exception ではなく Error です。catch (Exception $e) では捕まらず、スクリプトはそのまま止まります。Error(または Throwable)を捕まえるのは、本当にその意図があるときだけにしましょう。

readonly class(8.2)は全プロパティを readonly にし、動的プロパティも禁止します。

readonly class Point
{
    public function __construct(public int $x, public int $y) {}
}
$p = new Point(1, 2);
$p->x = 10;   // Error: Cannot modify readonly property Point::$x
$p->z = 3;    // Error: Cannot create dynamic property Point::$z

実際に動かして確認した、意外と知られていない readonly のルールが3つあります。

class A { public readonly $x; }        -> Fatal error: Readonly property A::$x must have type
class A { public readonly int $x = 5; } -> Fatal error: Readonly property A::$x cannot have default value
$b->items[] = 3;  (readonly な配列)     -> Error: Cannot indirectly modify readonly property Bag::$items

ただし readonly は「浅い」です。readonly プロパティに ArrayObject を入れておくと中身は追加でき、要素数は1から2に増えました。readonly が守るのは参照であって、その先のオブジェクトではありません。

PHP 8.5 の clone-with と、実際にハマった点

PHP 8.5 から clone に変更するプロパティの配列を渡せるようになり、withX() 系のメソッドが短く書けます。最初はクラスの外から呼んでみました。

$p2 = clone($p, ['x' => 99]);
Fatal error: Uncaught Error: Cannot modify protected(set) readonly property Point::$x from global scope in ~/demo/a1_readonly.php:54

メッセージがルールを説明しています。readonly プロパティは暗黙的に protected(set) なので、clone-with 経由でもクラスの内側(または子クラス)からしか書き込めません。メソッドの中に移すと動きます。

public function withX(int $x): static
{
    return clone($this, ['x' => $x]);
}
// 1,2 -> 99,2

interface、抽象クラス、trait

interface は契約で、メソッドの形だけを持ち状態は持ちません。抽象クラスは状態や完成したメソッドを持てますが、直接 new できません。trait はコンパイラが手伝うコピー&ペーストで、メソッドが使う側のクラスにそのまま貼り込まれます。

interface Notifier
{
    public function send(string $to, string $msg): bool;
}

abstract class BaseNotifier implements Notifier
{
    private array $log = [];
    public function send(string $to, string $msg): bool
    {
        $ok = $this->deliver($to, trim($msg));
        $this->log[] = sprintf('%s %s %s', static::class, $to, $ok ? 'OK' : 'FAIL');
        return $ok;
    }
    abstract protected function deliver(string $to, string $msg): bool;
    public function log(): array { return $this->log; }
}

trait Retries
{
    public function hello(): string { return 'Retries::hello'; }
    protected function attempt(callable $fn, int $times = 3): bool
    {
        for ($i = 1; $i <= $times; $i++) {
            if ($fn($i)) return true;
        }
        return false;
    }
}
trait Greets
{
    public function hello(): string { return 'Greets::hello'; }
}

final class MailNotifier extends BaseNotifier
{
    use Retries, Greets {
        Greets::hello insteadof Retries;
        Retries::hello as protected retriesHello;
    }
    protected function deliver(string $to, string $msg): bool
    {
        return $this->attempt(fn(int $i) => $i >= 3);   // 3回目で成功
    }
    public function both(): string { return $this->hello() . ' + ' . $this->retriesHello(); }
}

$m = new MailNotifier();
var_dump($m->send('user@example.com', '  hi  '));
echo $m->log()[0], PHP_EOL, $m->both(), PHP_EOL;
bool(true)
MailNotifier user@example.com OK
Greets::hello + Retries::hello

2つの trait が同じ名前のメソッドを持つとき、insteadof で勝つ方を選び、as で負けた方を別名で残します(ここでは可視性も変えています)。このブロックがないと、PHP はクラスをコンパイルしません。

Fatal error: Trait method B::hello has not been applied as C::hello, because of collision with A::hello in ~/demo/a3b.php on line 4

入門して最初の1週間で出会うエラーがあと2つあります。

Fatal error: Class Sq contains 1 abstract method and must therefore be declared abstract or implement the remaining method (Shape::area) in ~/demo/a3c.php on line 3
Fatal error: Uncaught Error: Cannot instantiate abstract class Base in ~/demo/a3d.php:3

1つ目はコンパイル時のエラー(ファイルは1行も実行されない)、2つ目は実行時の Error で catch できます。PHP 8.5 ではコンパイルエラーでも末尾に Stack trace: が付きますが、これは 8.5 の新機能(Fatal error のバックトレース)で、別の何かが壊れたわけではありません。

判断図。まず、値が決まった有限の集合のどれかか。はいなら enum。いいえなら、複数のクラスが同じメソッドの形を持ち中身だけ違うか。はいなら interface。いいえなら、状態とコードも共有するか。はいなら abstract class。いいえなら、無関係なクラスで使い回す小さなメソッド群か。はいなら trait、それ以外は普通の final class(データを持つなら readonly)。
どの仕組みを使うかの判断順。左上の質問から矢印をたどり、最初に「はい」になったところで止まる。

static と self:遅延静的束縛

self は「この行が書かれているクラス」、static は「メソッドが呼ばれたクラス」です。違いは継承したときだけ現れますが、ORM やモデルの基底クラスはまさにその場所です。

class Model
{
    public static function createSelf(): self   { return new self(); }
    public static function createStatic(): static { return new static(); }
    public static function table(): string     { return strtolower(static::class) . 's'; }
    public static function tableSelf(): string { return strtolower(self::class) . 's'; }
}
class Post extends Model {}

echo get_class(Post::createSelf()), PHP_EOL;
echo get_class(Post::createStatic()), PHP_EOL;
echo Post::table(), PHP_EOL;
echo Post::tableSelf(), PHP_EOL;
Model
Post
posts
models

static プロパティにも罠があります。子クラスが再宣言しない限り、親の static プロパティを共有します。

class Counter
{
    protected static int $count = 0;
    public static function inc(): int { return ++static::$count; }
}
class A extends Counter {}
class B extends Counter { protected static int $count = 100; }
A::inc(); A::inc();
echo 'A: ', A::inc(), ' Counter: ', Counter::inc(), ' B: ', B::inc(), PHP_EOL;
A: 3 Counter: 4 B: 101

A と Counter は同じ変数を数えていました。子クラスごとにカウンタを持たせたいなら全ての子で再宣言が必要ですが、それよりインスタンスプロパティにして static を使わない方が安全です。

enum:backed 値、from と tryFrom

enum(8.1)は、型チェッカーが理解できる閉じた値の集合です。backed enum は DB や JSON 用に string か int の値も持ちます。画面に出す日本語ラベルは enum の中のメソッドにまとめると、ステータスを追加したときの修正箇所が1つで済みます。

enum Status: string
{
    case Draft = 'draft';
    case Published = 'published';
    case Archived = 'archived';

    public function label(): string
    {
        return match ($this) {
            self::Draft => '下書き',
            self::Published => '公開中',
            self::Archived => 'アーカイブ',
        };
    }
    public static function fromInput(?string $s): self
    {
        return self::tryFrom((string) $s) ?? self::Draft;
    }
}

$s = Status::from('published');
echo $s->name, ' ', $s->value, ' ', $s->label(), PHP_EOL;
var_dump(Status::tryFrom('deleted'));
echo Status::fromInput(null)->name, PHP_EOL;
echo json_encode(['status' => Status::Archived]), PHP_EOL;
Status::from('deleted');
Published published 公開中
NULL
Draft
{"status":"archived"}
ValueError: "deleted" is not a valid backing value for enum Status

from() は信頼できるデータ(自分の DB から読んだ値。おかしな値ならバグなので例外でよい)に、tryFrom() はユーザー入力(null なら無効、代わりの値は自分で決める)に使います。

declare(strict_types=1) のもとでは、int の backed enum は $_GET から来た文字列 '3' を受け付けません。TypeError: Priority::from(): Argument #1 ($value) must be of type int, string given になります。strict types を外すと同じ呼び出しが Priority::High を返しました。入力の境界で (int) に変換してください。

よくあるミス → 実際に起きること → 直し方

ミス1:enum を文字列と比較する。 Suit::Hearts == 'Hearts' は bool(false) です。エラーも警告もなく、if の中が永遠に実行されません。直し方: case 同士を比較(=== Suit::Hearts)するか、backed enum なら ->value を比較します。

ミス2:pure(非 backed)enum を json_encode する。 false が返り、echo は何も出力しませんでした。json_last_error_msg() は Non-backed enums have no default serialization、JSON_THROW_ON_ERROR を付けると同じメッセージの JsonException になります。直し方: プロセスの外に出る可能性がある enum は backed にします。

ミス3:case を追加して match を直し忘れる。 case Live を追加し、Draft しか扱わない match を呼びました。

Fatal error: Uncaught UnhandledMatchError: Unhandled match case Status::Live in ~/demo/a5d.php:4

PHP はコンパイル時に網羅性をチェックしません(Swift や Dart とは違います。各講座を参照)。直し方: match は enum 内のメソッドに置き、case を足す場所とラベルを直す場所を同じにします。抜けは PHPStan などの静的解析で検出します。

ミス4:普通のクラスでプロパティ名をタイプミス。 $u->nmae = 'x' は Deprecated: Creation of dynamic property U::$nmae is deprecated を出して、そのまま処理を続けました。直し方: プロパティは宣言し、データクラスは readonly に(その場合は Error になる)、開発環境では Deprecated も失敗として扱います。

first-class callable、プロパティフック、8.5 のパイプ演算子

first-class callable 構文(8.1)は、名前(...) で関数やメソッドを Closure にします。昔ながらの [$obj, 'method'] 配列も動きますが、タイプミスは実行されるまで分かりませんでした。

$make = $slug->make(...);     // $slug に束縛された Closure
print_r(array_map($make, ['Hello World!', '  Enums & Traits  ']));
array_map([$slug, 'mkae'], $titles);
Array
(
    [0] => hello-world
    [1] => enums-traits
)
TypeError: array_map(): Argument #1 ($callback) must be a valid callback or null, class Slug does not have a method "mkae"

$slug->mkae(...) と書けば、その行で Error: Call to undefined method Slug::mkae() として失敗するので追跡しやすくなります。

プロパティフックと非対称可視性(8.4)は、getter・setter のほとんどを置き換えます。

final class Account
{
    public string $email {
        set(string $value) {
            $value = strtolower(trim($value));
            if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException("invalid email: $value");
            }
            $this->email = $value;
        }
    }
    public string $domain {                       // 仮想プロパティ:保存領域なし
        get => substr($this->email, strpos($this->email, '@') + 1);
    }
    public private(set) int $loginCount = 0;      // 読むのはどこでも、書くのは内側だけ

    public function __construct(string $email) { $this->email = $email; }
    public function login(): void { $this->loginCount++; }
}
$a = new Account('  Alice@Example.COM ');
echo $a->email, ' | ', $a->domain, PHP_EOL;     // alice@example.com | example.com

外から書き込もうとすると、3通りの結果になりました。

$a->loginCount = 99        -> Error: Cannot modify private(set) property Account::$loginCount from global scope
$a->domain = 'x.com'       -> Error: Property Account::$domain is read-only
$a->email = 'not-an-email' -> InvalidArgumentException: invalid email: not-an-email

仮想プロパティ $domain は保存領域がないので、var_dump($a) には表示されません。

PHP 8.5 のパイプ演算子 |> は、左の値を右の callable の唯一の引数として渡します。

$slug = '  Hello PHP 8.5 World  '
    |> trim(...)
    |> strtolower(...)
    |> (fn(string $s) => preg_replace('/[^a-z0-9]+/', '-', $s))
    |> (fn(string $s) => trim($s, '-'));
echo $slug, PHP_EOL;   // hello-php-8-5-world

日本語サイトでこのままスラッグ生成に使うと困ります。同じパイプに日本語のタイトルを流した実際の結果です。

'  Hello PHP 8.5 World  '  -> 'hello-php-8-5-world'
'PHP 8.5 の新機能まとめ'     -> 'php-8-5'
' 全角スペース入り '        -> ''

[^a-z0-9] が日本語をすべて区切り文字として扱うため、タイトルが英数字を含まなければ空文字列になり、URL が重複します。さらに trim(' 全角 ') は全角スペースを除去せず string(12) のままで、8.4 で追加された mb_trim() を使うと string(6) "全角" になりました。日本語タイトルのスラッグは ID ベースにするか、空になった場合のフォールバックを必ず用意してください。

8.5.6 で確認したこと:

コード 8.5.6 での結果
括弧なしの |> fn($s) => $s . '!' Fatal error: Arrow functions on the right hand side of |> must be parenthesized
|> str_replace('a', 'x', ...)(部分適用) Parse error: syntax error, unexpected token ")" — 8.5 には未導入
|> intdiv(...)(引数2つが必要) ArgumentCountError: intdiv() expects exactly 2 arguments, 1 given
参照渡しの引数を持つ関数 Error: addOne(): Argument #1 ($a) could not be passed by reference
array_first($xs)、array_last($xs)(8.5 新規) 2、3。array_first([]) は NULL
#[\NoDiscard] の戻り値を無視 Warning: The return value of function withTax() should either be used or intentionally ignored by casting it as (void), …

Composer を使わないオートロード

Composer のプロジェクトも、内部では spl_autoload_register を使っています。PSR-4 のローダーを10行ほどで手書きすると、何が起きているかが見えます。

// main.php — App\  =>  src/
spl_autoload_register(function (string $class): void {
    $prefix = 'App\\';
    if (!str_starts_with($class, $prefix)) {
        return;                       // 自分の担当外なら他のローダーに任せる
    }
    $file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
    echo "[autoload] $class\n";
    if (is_file($file)) {
        require $file;
    }
});

$repo = new App\Repo\UserRepo();      // src/Repo/UserRepo.php、内部で App\Model\User を使う
foreach ($repo->all() as $u) echo $u->id, ' ', $u->name, PHP_EOL;
var_dump(class_exists('App\Model\Missing'));
new App\Model\Missing();
[autoload] App\Repo\UserRepo
[autoload] App\Model\User
1 alice
2 bob
[autoload] App\Model\Missing
bool(false)
[autoload] App\Model\Missing

Fatal error: Uncaught Error: Class "App\Model\Missing" not found in ~/demo/autoload/main.php:25

分かることが2つあります。use App\Model\User; だけでは何も読み込まれず、ファイルはクラスを最初に使ったときに読み込まれます。そして失敗した検索はキャッシュされず、同じクラスに対してローダーがもう一度走りました。ローダーがディレクトリ走査のような重い処理をしていると、そのコストが毎回かかります。また、大文字小文字を区別する Linux サーバーでは App\model\User は Model/User.php を見つけられません。標準設定の macOS のディスクでは見つかってしまうので、「ローカルでは動くのに本番で Class not found」の定番原因です。

実測:10万件、オブジェクト vs 配列

「配列の方がオブジェクトより軽い」とよく言われるので、実際に測りました。1行は {id, name, age, active}、10万件を作る前後の memory_get_usage() の差、PHP 8.5.6 CLI、Apple Silicon Mac(M1 Pro)、3回実行です。

$rows[] = match ($mode) {
    'assoc'    => ['id' => $i, 'name' => $name, 'age' => 20 + $i % 50, 'active' => $i % 2 === 0],
    'list'     => [$i, $name, 20 + $i % 50, $i % 2 === 0],
    'object'   => new UserObj($i, $name, 20 + $i % 50, $i % 2 === 0),   // 型付きプロモーション4つ
    'stdclass' => (object) ['id' => $i, 'name' => $name, /* … */],
};
形 メモリ 1行あたり 生成時間(3回の中央値)
型付きクラス 17.5 MB 183 バイト 15.6 ms
リスト配列 26.4 MB 277 バイト 9.3 ms
連想配列 41.7 MB 437 バイト 12.2 ms
stdClass 46.5 MB 487 バイト 13.2 ms
棒グラフ。10万件で、型付きプロパティ4つのクラスは17.5MB、数値キーのリスト配列は26.4MB、連想配列は41.7MB、stdClass は46.5MB。
{id, name, age, active} の10万件が使うメモリ。PHP 8.5.6 CLI、Apple Silicon Mac(M1 Pro)で memory_get_usage() により計測。3回とも同じ値。

メモリは3回とも同じ値でした。プロパティを宣言したオブジェクトは4つの値を固定スロットに持ち、連想配列は1行ごとにキー入りのハッシュテーブルを持ちます。1行あたり約61バイトは name 文字列で、どの形でも共通です。オブジェクトは1行ごとにコンストラクタを呼ぶので作る時間は少し長く、OPcache を有効にしてもメモリの数値は変わりませんでした。stdClass は配列と同じ動的ハッシュテーブルにオブジェクトのオーバーヘッドが乗るため、最も大きくなりました。CSV の取り込みや帳票の集計のように大量のレコードをメモリに持つなら、小さな readonly クラスの方が安全で、しかも小さく済みます。

判断表

必要なもの 使うもの 理由
決まった値の集合(ステータス、権限) backed enum 不正な値は ValueError、JSON にも出せる
1つの契約に複数の実装 interface テスト用の差し替え、ドライバの交換
共通の状態と決まった流れ abstract class テンプレートメソッド、子は1ステップだけ実装
無関係なクラスに共通のヘルパー trait 共通の親が不要、衝突は明示的に解決
値オブジェクト / DTO final readonly class 不変・型付き、今回の計測で連想配列の約1/2.4
代入時の検証 プロパティフック set(8.4+) getter・setter の定型コードが不要
外からは読むだけ、内側では書く public private(set)(8.4+) カウンタ、ID
基底クラスのファクトリ new static() 子クラスを返す

正直な限界とバージョンメモ

  • すべて PHP 8.5.6 CLI でのみ実行しました。バージョン情報は古い PHP で動かしたものではなく、PHP の変更履歴に基づきます。プロモーション 8.0、enum・readonly プロパティ・first-class callable 8.1、readonly クラス・動的プロパティの非推奨 8.2、プロパティフック・非対称可視性・mb_trim() 8.4、パイプ演算子・clone-with・array_first/last・#[\NoDiscard]・Fatal error のバックトレース 8.5 です。レンタルサーバーの PHP が 8.3 ならフックと |> は構文エラーになるので、使う前にサーバーで php -v を確認してください。
  • メモリの数値はこのレコードの形と 64 ビット版 PHP 8.5 のものです。フィールドが多い、文字列が長いといった条件で比率は変わります。生成時間は1桁ミリ秒でブレが大きいので、「オブジェクトの生成は少し遅かった」以上の読み方はしないでください。
  • 頻繁に呼ばれる処理でのプロパティフックのオーバーヘッドは測っていません。

演習

int の backed enum Role(Admin = 1、Editor = 2、Viewer = 3)と、$_GET['role'] をロールに変換する static メソッド fromQuery(?string $raw): self を書いてください。値がない・数字でない・未知の値なら Viewer にフォールバックし、strict_types=1 で動くこと。

解答

<?php
declare(strict_types=1);

enum Role: int
{
    case Admin = 1;
    case Editor = 2;
    case Viewer = 3;

    public static function fromQuery(?string $raw): self
    {
        if ($raw === null || !ctype_digit($raw)) {
            return self::Viewer;
        }
        return self::tryFrom((int) $raw) ?? self::Viewer;
    }
}

foreach (['1', '2', '9', 'abc', null, '-1'] as $q) {
    echo var_export($q, true), ' => ', Role::fromQuery($q)->name, PHP_EOL;
}
'1' => Admin
'2' => Editor
'9' => Viewer
'abc' => Viewer
NULL => Viewer
'-1' => Viewer

(int) の変換を外すと、strict types のもとで tryFrom('1') が TypeError: Role::tryFrom(): Argument #1 ($value) must be of type int, string given を投げます。tryFrom が寛容なのは値に対してであって、型に対してではありません。


前回:PHP講座 2 — 関数、フォーム、ビルトインサーバー · 次回:PHP講座 4 — PDO、SQLite、SQL インジェクション

よくある質問

PHP のオブジェクトは配列よりメモリを使いますか?

プロパティを宣言したクラスなら、むしろ少なくなりました。PHP 8.5.6 で4フィールド×10万件を作ると、型付きクラス17.5MB、リスト配列26.4MB、連想配列41.7MB、stdClass 46.5MB でした。生成時間はオブジェクトの方が少し遅く、中央値で約15.6ms(連想配列は12.2ms)です。

self と static は何が違いますか?

self はコードが書かれているクラス、static は実際に呼び出されたクラスです(遅延静的束縛)。Post::createSelf() は Model を、Post::createStatic() は Post を返しました。

パイプ演算子 |> やプロパティフックはもう使えますか?

プロパティフックと非対称可視性は PHP 8.4、パイプ演算子は 8.5 からです。どれも 8.5.6 で動きました。|> の右側にアロー関数を置くときは括弧で囲む必要があり、囲まないとコンパイル時に Fatal error になります。

  • #php
  • #中級
  • #オブジェクト指向
  • #enum
  • #readonly
  • #trait
  • #php85

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑