28.

PHP の抽象クラスと抽象メソッド|interface との違いと使い分け

編集
この記事の要点
  • abstract classインスタンス化できないクラス。継承して使う前提の型
  • abstract function中身を書かないメソッド。子で必ず実装させられる
  • 実装しないまま new すると Fatal error。実装漏れを実行前に発見できる
  • 共通の実装を持たせたいなら抽象クラス、型だけ決めたいならインターフェース
  • 抽象クラスは 1 つしか継承できない。迷ったらインターフェースを先に考える

基本

abstract class Shape
{
    public function __construct(protected string $label) {}

    abstract public function area(): float;      // 中身は書かない(; で終わる)

    public function describe(): string           // 通常のメソッドも持てる
    {
        return sprintf('%s の面積は %.2f', $this->label, $this->area());
    }
}

class Circle extends Shape
{
    public function __construct(private float $r)
    {
        parent::__construct('円');
    }

    public function area(): float                // 実装が必須
    {
        return M_PI * $this->r ** 2;
    }
}

echo (new Circle(2))->describe();   // 円 の面積は 12.57

// new Shape('図形');
// Fatal error: Cannot instantiate abstract class Shape

実装しないとエラーになる

class Broken extends Shape
{
    // area() を実装していない
}

// Fatal error: Class Broken contains 1 abstract method and must therefore be
// declared abstract or implement the remaining methods (Shape::area)

このエラーはクラスを読み込んだ時点で出ます。実行してそのメソッドに到達するまで気づけない、ということがありません。これが抽象メソッドの最大の利点です。

// 中間クラスとして「まだ実装しない」ことは宣言できる
abstract class Polygon extends Shape
{
    abstract public function sides(): int;    // 抽象メソッドを追加してもよい
}

class Square extends Polygon
{
    public function __construct(private float $side)
    {
        parent::__construct('正方形');
    }

    public function area(): float { return $this->side ** 2; }
    public function sides(): int  { return 4; }
}

書き方のルール

できることできないこと
抽象メソッドと通常メソッドを混ぜる抽象メソッドに中身(波かっこ)を書く
プロパティ・定数を持つprivate abstract にする(子から実装できない)
コンストラクタを持つfinal abstract にする(矛盾している)
抽象クラスを継承した抽象クラスを作る抽象メソッドが 1 つでもあれば new する
static な抽象メソッドを宣言する
abstract class Sample
{
    abstract public function a(): void;       // OK
    abstract protected function b(): void;    // OK

    // abstract private function c(): void;   // Fatal error
    // abstract public function d(): void {}  // Fatal error: 中身を書けない
}

テンプレートメソッドパターン

抽象クラスの代表的な使い方です。処理の流れは親が決め、変わる部分だけ子に書かせます。

abstract class Importer
{
    final public function run(string $path): int      // 流れは固定(final)
    {
        $rows = $this->read($path);
        $valid = array_filter($rows, fn($r) => $this->isValid($r));
        foreach ($valid as $row) {
            $this->save($row);
        }
        return count($valid);
    }

    abstract protected function read(string $path): array;   // 形式ごとに違う
    abstract protected function save(array $row): void;      // 保存先ごとに違う

    protected function isValid(array $row): bool             // 既定の実装あり
    {
        return $row !== [];
    }
}

class CsvImporter extends Importer
{
    protected function read(string $path): array
    {
        $rows = [];
        $fp = fopen($path, 'r');
        while (($cols = fgetcsv($fp)) !== false) { $rows[] = $cols; }
        fclose($fp);
        return $rows;
    }

    protected function save(array $row): void
    {
        // DB に入れる
    }
}

echo (new CsvImporter())->run('data.csv');

run()final を付けると、順番を勝手に変えられません。共通の手順を守らせたいときに有効です。

インターフェースとの違い

抽象クラスインターフェース
実装を持てる持てる持てない
プロパティを持てる持てる持てない(定数のみ)
コンストラクタ持てる宣言だけ可能
1 クラスがいくつ使えるか1 つだけいくつでも
メソッドの可視性public / protectedpublic のみ
表すもの「〜の一種である」「〜ができる」
interface Exportable
{
    public function export(): string;
}

abstract class Report implements Exportable    // 併用できる
{
    public function __construct(protected array $rows) {}

    protected function header(): string        // 共通の実装は抽象クラスに
    {
        return '=== ' . static::class . ' ===';
    }
}

class CsvReport extends Report
{
    public function export(): string
    {
        return $this->header() . "\n"
            . implode("\n", array_map(fn($r) => implode(',', $r), $this->rows));
    }
}

function save(Exportable $e, string $path): void   // 型はインターフェースで受ける
{
    file_put_contents($path, $e->export());
}

受け取る側の型はインターフェースにしておくと、抽象クラスを継承していない実装も渡せるようになります。

トレイトとの使い分け

trait Loggable
{
    protected function log(string $m): void { error_log($m); }
}

abstract class Job
{
    use Loggable;                       // 実装を取り込む

    abstract public function handle(): void;
}
やりたいこと選ぶもの
共通の手順を強制し、一部だけ差し替えさせたい抽象クラス
「この操作ができる」ことを型で保証したいインターフェース
継承関係に関係なく同じ実装を配りたいトレイト
実装が 1 種類しかない普通のクラス(抽象化しない)

実装が 1 つしかないうちは抽象クラスにしないでください。2 つ目が出てきて共通部分が見えた時点で切り出すほうが、正しい抽象になります。

実例: 支払い方法を差し替える

abstract class PaymentMethod
{
    abstract public function name(): string;
    abstract protected function charge(int $amount): string;   // 決済 ID を返す

    public function pay(int $amount): string
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException('金額が不正です');
        }
        $id = $this->charge($amount);
        error_log("{$this->name()} で {$amount} 円を決済: {$id}");
        return $id;
    }
}

class CreditCard extends PaymentMethod
{
    public function name(): string { return 'クレジットカード'; }

    protected function charge(int $amount): string
    {
        return 'cc_' . bin2hex(random_bytes(4));
    }
}

class BankTransfer extends PaymentMethod
{
    public function name(): string { return '銀行振込'; }

    protected function charge(int $amount): string
    {
        return 'bt_' . bin2hex(random_bytes(4));
    }
}

function checkout(PaymentMethod $m, int $amount): void
{
    echo $m->pay($amount);
}

checkout(new CreditCard(), 1000);
checkout(new BankTransfer(), 1000);

金額の検証とログ出力はどの支払い方法でも同じなので親に置き、実際の課金処理だけを子に書かせています。新しい支払い方法を足すときは PaymentMethod を継承するだけで、checkout() は変更不要です。

定数とインターフェース定数

abstract class Job
{
    public const MAX_RETRY = 3;         // 子からも self::MAX_RETRY で使える

    abstract public function handle(): void;
}

interface HasStatus
{
    public const OPEN = 'open';         // インターフェースにも定数を置ける
    public const DONE = 'done';
}

class Task extends Job implements HasStatus
{
    public function handle(): void
    {
        echo self::MAX_RETRY;    // 3
        echo self::OPEN;         // open
    }
}

状態を表す文字列の集合は、PHP 8.1 以降なら enum のほうが型安全です。

enum Status: string
{
    case Open = 'open';
    case Done = 'done';

    public function label(): string
    {
        return match ($this) {
            Status::Open => '未着手',
            Status::Done => '完了',
        };
    }
}

echo Status::from('open')->label();      // 未着手
var_dump(Status::tryFrom('x'));          // null(不正値でも例外にしない)

抽象クラスにしないほうがよい場合

  • 実装が 1 つしかない — 2 つ目が出るまで普通のクラスでよい
  • 共通部分が「たまたま同じ」だけ — 別々の理由で変わるものを 1 つにまとめると、片方の変更が他方を壊す
  • 継承の階層が 3 段以上になる — どこに何が書かれているか追えなくなる。委譲かトレイトを検討する
  • 子ごとに親のメソッドを空実装している — インターフェースを分けるべき合図

関連

編集
Post Share
子ページ

子ページはありません

同階層のページ
  1. 基本事項
  2. HTMLへの埋め込み
  3. 変数
  4. 可変変数
  5. 定数
  6. データ型
  7. キャスト
  8. エスケープ文字
  9. 配列
  10. 演算子
  11. 代入の際の注意点
  12. 条件分岐
  13. 繰り返し処理
  14. クラスとインスタンス
  15. コンストラクタ
  16. 関数
  17. スーパーグローバル変数
  18. スコープ
  19. staticについて
  20. yieldについて
  21. ファイルのアップロード方法
  22. DB接続方法
  23. SQL実行方法
  24. カプセル化の具体例
  25. 継承の構文
  26. オーバーライド
  27. ポリモーフィズム(多様性)の具体例
  28. 抽象クラス・メソッドの構文と具体例
  29. GET通信
  30. try catchで全てのエラーを拾う方法

最近更新/作成されたページ