Upgrade to Pro — share decks privately, control downloads, hide ads and more …

PHPだって関数型したい 〜できること、できないこと〜 / fp-in-php

Avatar for jsoizo jsoizo
July 11, 2026

PHPだって関数型したい 〜できること、できないこと〜 / fp-in-php

Avatar for jsoizo

jsoizo

July 11, 2026

More Decks by jsoizo

Other Decks in Programming

Transcript

  1. 自己紹介 関根 純 せきね じゅん 経歴など いくつかの事業会社で開発に従事したのち、 現在は株式会社コドモンで働いています。 KotlinやScala, Apache

    Pekkoが好き。 PHPは12年ぶり2回目。 愛知県出身、都内在住。ディズニーとサッカーが趣味 X, GitHub: @jsoizo 2
  2. 失敗・null・状態のような曖昧さは、どのアプリケーションにもある。 しかし、 PHPでは古い標準APIやコーディングの慣習、array の万能さ等が 重なることで、曖昧さが型に現れないまま広がりやすい。 ⚠️ array : list 兼

    record 兼 dictionary のフリーダムな器。      古いコードは「とりあえずarrayに入れておく」としがち 歴史あるPHPコードで起きやすい曖昧さ 6
  3. DBの行は列の集まりなので、1レコードを array で受け取るのは自然。 ここは PHP の柔軟さが素直に活きていて、何も問題はない。 例: DBから1行を取ってくる // DB

    の1 行を array で受け取る $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?'); $stmt->execute([$id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); // 該当行があれば連想配列、無ければ fetch() は false (PDO の仕様) // $row = ['id'=>.., 'name'=>.., 'email'=>.., 'status'=>..] 7
  4. 古いコードでは、異常系をとりあえず false で返すことがある。 その array を業務ロジックで扱うと function findUser($pdo, $id): array|false

    { /* ... 前ページと同じように $row を fetch ... */ if (!$row) return false; // null も false if ($row['status'] === 'deleted') return false; // 削除済みも false return [ 'id' => $row['id'], 'name' => $row['name'], 'email' => $row['email'], // ただの string 'status' => $row['status'], // DB 由来の文字列がそのまま流れる ]; } 8
  5. 呼び出し側 $user = findUser($pdo, $id); if (!$user) { // 「null

    」も「削除済み」も、同じ false に落ちる // 違う意味なのに、どちらだったのかは呼ぶ側から分からない return; } $user['foo'] = 'bar'; // array なので自由に書き換えられる // $user のキーや型は、呼ぶ側からは見えない echo $user['name']; 9
  6. FPなら値や型で語れる情報が、PHPでは複数の慣習に分散し、シグネチャか ら消える。 本来語りたいこと 先の例 散らばりがちな表現 失敗した return false false /

    例外 / エラー配列 存在しない return false null / false / [] 取りうる状態 $row['status'] === 'deleted' マジック文字列 / 定数 データの形 return [...] array / mixed 曖昧さの正体は「意図がわからなくなること」 10
  7. readonly を付けたプロパティは、初期化後の再代入を禁止する。 PHP 8.1 からフィールドに対して、 PHP 8.2 からはクラス全体を readonly にできるようになった。

    → 不変な値オブジェクトを素直に表現できる 値を変えたいときは書き換えず、新しいインスタンスを作る というイミュータビリティを強制させる設計を持ち込める。 イミュータビリティ (PHP 8.1~) 13
  8. イミュータビリティ (PHP 8.1~) final readonly class User { public function

    __construct( public string $id, public string $name, ) {} } $user = new User('u1', 'Alice'); // array なら書き換えられたが、readonly では弾かれる $user->name = 'Bob'; // Error: Cannot modify readonly property User::$name 14
  9. 取りうる状態を enum で列挙すれば、単純な直和型に近い表現になる。 match 式は値を返し、厳密比較( === )で分岐するので、状態ごとの処理 を一つの式にまとめて書ける。 ただし、 直和型とパターンマッチング

    ≒ enum + match式 コンパイルする言語ではないので、実行前に網羅性をチェックして実装漏 れを検知することはできない • 各caseはデータを持てず、値を持つ代数的データ型は表現できない • Scalaでいう sealed + data class|object のようなこと ◦ 15
  10. 直和型とパターンマッチング ≒ enum + match式 enum UserStatus: string { case

    Active = 'active'; case Invited = 'invited'; case Deleted = 'deleted'; } // DB から来た文字列は、境界で enum 値に変換する $status = UserStatus::from('deleted'); $canLogin = match ($status) { UserStatus::Active => true, UserStatus::Invited => false, UserStatus::Deleted => false, }; 16
  11. クロージャ、アロー関数、first-class callable がそろっており、 小さな関数を渡して組み合わせられるので、後の map / flatMap による合 成の土台になる。 First

    class functionをもつ // アロー関数, $withTax はCallable 型の値となる $rate = 1.1; $withTax = fn(int $price): float => $price * $rate; // 関数を値として渡せる $prices = array_map($withTax, [100, 200]); // [110.0, 220.0] // first-class callable: 既存の関数・メソッドも値にできる $labels = array_map(strtoupper(...), ['xs', 'm', 'l']); 17
  12. パイプ演算子 |> は左の値を右の関数へ渡すことができる。 入れ子だった呼び出しを、左から右に読める形へ並べられる。 first-class callable と相性がよく、複数引数の関数はアロー関数で包む。 パイプライン (PHP 8.5~):

    左から右に読む合成 // これまで: 内側から外側へ読む $slug = str_replace(' ', '-', strtolower(trim($input))); // PHP 8.5: |> なら左から右に読める $slug = $input |> trim(...) // 先頭・末尾の空白を取り除いて |> strtolower(...) // 小文字に変換して |> (fn($s) => str_replace(' ', '-', $s)); // スペースをハイフンにする 18
  13. 複数引数の関数は、カリー化して「残りの引数を待つ関数」にする。 できた部品を |> で並べれば、処理の流れがそのまま新しい関数になる。 パイプ演算子とカリー化を組み合わせる $map = fn($f) => fn($xs)

    => array_map($f, $xs); $filter = fn($f) => fn($xs) => array_values(array_filter($xs, $f)); // カリー化した部品を組み合わせて「税込合計を求める関数」をつくる $totalOf = fn(array $orders) => $orders |> $filter(fn($o) => $o->paid) // 支払い済みだけ残して |> $map(fn($o) => $o->price * 1.1) // 税込み価格へ変換して |> array_sum(...); // 合計する $total = $totalOf($orders); 19
  14. Result型のようなFPの道具立てを型に乗せるには、 抽象度を上げて表現するためにジェネリクスが言語機能として求められる。 2026年には Bound-Erased Generic Types の RFC が提案されたが、投票で否決された。 reifiedにするというアプローチなども存在するが、

    型推論の難しさや既存コードとの互換性などの観点から、当面は言語機能と しては入らなさそう。 言語の外側 = PHPDoc と静的解析でなんとか頑張る のが現状。 ジェネリクスや型の表現は言語の外に託す 20
  15. レベル 求める厳格さ・チェックすること Level 0〜2 PHP の動的さに対して、まず明らかなミスを拾う Level 3〜5 戻り値・プロパティ・引数の型整合性を見る Level

    6 型注釈不足を問題にし始める。PHPDoc generics の出番 Level 7〜8 union / nullable の扱いが厳しくなり、type narrowing の価値が出る Level 9〜10 mixed を自由に扱えず、外部入力や array を具体的な型へ絞り込む設計が必要になる PHPStanの適用度合い 23
  16. PHPの特徴である array は list 兼 record 兼 dictionary のフリーダムな器。 array{...}

    で record を、 list<T> で同質なコレクションを表現する。 array の意味を絞る: array shape と list<T> // Before: 何が入っているか型からは分からない /** @param array $row */ function toUser(array $row): User { /* ... */ } // After: キーと型が出る / 「User だけのリスト」も表せる /** @param array{id: string, name: string} $row */ function toUser(array $row): User { /* ... */ } /** @return list<User> */ function activeUsers(): array { /* ... */ } 24
  17. PHPDocジェネリクスで Result<T, E> を作る final readonly class EmailAddress // データ型はreadonly

    でイミュータブルに { private function __construct(public string $value) {} // コンストラクタを隠匿 /** @return Result<EmailAddress, InvalidEmail> */ public static function parse(mixed $raw): Result // スマートコンストラクタ { return is_string($raw) && str_contains($raw, '@') ? Result::success(new self($raw)) : Result::failure(new InvalidEmail($raw)); } } // エラーも意味のある型で表す final readonly class InvalidEmail { public function __construct(public mixed $raw) {} 26
  18. map は成功値だけを変換し、 flatMap は失敗しうる処理をつなぐ。 どこかで失敗したら以降は進まず、最初の失敗が返る。 例外や null に散っていた失敗処理が一本の線になり認知負荷を下げられる。 途中で増えた失敗の型も、PHPStanがunionとして追跡してくれる。 map

    / flatMap で「成功時だけ次に進む」を表現する /** @return Result<User, InvalidEmail|VerifyError> */ function register(string $rawEmail): Result { return EmailAddress::parse($rawEmail) // 失敗しうる ->map(fn(EmailAddress $email) => new User($email)) // 成功時だけ変換 ->flatMap(fn(User $user) => $user->verify()); // 別の失敗が増える } 27
  19. 先の説明で match 式では「実行前に網羅性は確かめられない」としたが、 enum を渡せば、ケースの書き漏れを PHPStan が指摘してくれる。 enum + match

    の網羅性チェック // UserStatus はActive, Invited, Deleted をもつenum である function label(UserStatus $status): string { return match ($status) { UserStatus::Active => ' 利用中', UserStatus::Invited => ' 招待中', // Deleted を書き忘れると… // PHPStan: Match expression does not handle UserStatus::Deleted }; } 28
  20. PHPの enum の各 case はデータを持てないが、 @phpstan-sealed というアノテーションで値を持つ直和型を表現可能。 継承可能なクラスを限定することができる。が… 直和型の表現 :

    @phpstan-sealedアノテーション /** @phpstan-sealed EmailContact|PhoneContact */ interface Contact {} final readonly class EmailContact implements Contact { public function __construct(public EmailAddress $email) {} } final readonly class PhoneContact implements Contact { public function __construct(public string $number) {} } // Error: Type FaxContact is not allowed to be a subtype of Contact. final readonly class FaxContact implements Contact {} 29
  21. enum と違い、sealed では match 式での網羅性が保証されない。 全パターンを書いても「default挙動不足」と指摘されてしまう。 このあたりがどうしても片手落ち感があり残念なところ。 @phpstan-sealedの欠点: match では

    default / ignore が要る function notify(Contact $c): string { /** @phpstan-ignore match.unhandled (Contact is sealed: EmailContact|PhoneContact) return match (true) { $c instanceof EmailContact => "mail: {$c->email->value}", $c instanceof PhoneContact => "sms: {$c->number}", // 全パターンを書いても PHPStan は網羅と認めず default を要求 // ignore するか、絶対に通らないdefault 挙動を書くしかない }; } 30
  22. php-result は Success<T> / Failure<E> の sealed として実装した。 そのうえで「match式に登場したときに Success

    と Failure の両方が登 場しているか」をチェックするPHPStanのカスタムルールを提供している。 php-resultの実装でやっていること // Error: Match expression on Result type is not exhaustive. Missing: Failure. match (true) { $result instanceof Success => 'success', }; match (true) { // すべて網羅していればPHPStan が通過する $result instanceof Success => 'success', $result instanceof Failure => 'failure', }; 31
  23. flatMap の連鎖は、前の結果を後の処理でも使うときにネストが深くなる。 php-resultの binding はGeneratorを使って、 Scalaのfor内包表記のように平らに書けるようにしたもの。 binding: ネストする合成を平らに書く findOrder($orderId) ->flatMap(fn($order)

    => findCustomer($order->customerId) ->flatMap(fn($customer) => createInvoice($order, $customer))); // binding: yield で成功値を取り出し、Success<T> が返る限り次へ進む $result = Result::binding(function () use ($orderId) { $order = yield findOrder($orderId); $customer = yield findCustomer($order->customerId); $invoice = yield createInvoice($order, $customer); 32
  24. Generator の型の制約(次頁)から binding では yield の結果が mixed になってしまう。 ゆえに @var

    を手書きして型推論を補うしかない。 ただし、yield を通ると型が追えなくなる $result = Result::binding(function () use ($orderId) { /** @var Order $order */ // ← 書かないと $order は mixed として推論される $order = yield findOrder($orderId); /** @var Customer $customer */ // ← yield の結果に型がつかない $customer = yield findCustomer($order->customerId); /** @var Invoice $invoice */ // ← yield のたびに手書きが要る $invoice = yield createInvoice($order, $customer); return $invoice; }); 33
  25. yield で処理を一時停止して呼び出し側に値を渡し、 send() で外から値を受け取って続きから再開できる関数。 Generator全体でsendできるのは TSend なので、 複数の値をsendするとmixedに潰れてしまう… Generator<TKey, TValue,

    TSend, TReturn> function gen(): Generator { $x = yield 1; // 1 を渡して一時停止。再開時に送られた値が $x に入る echo $x + 1; // 11 } $g = gen(); $g->current(); // 1 $g->send(10); // 10 を送って再開 34
  26. 全体を1つの Result / Option にまとめる traverse が欲しいとして、 「型引数を取る型」を型引数にできれば、実装は1回で済むのだが… F<_> のように型パラメータを宣言することができない

    /** * @template F<_> ← こう書きたいが、書けない * @template TIn * @template TOut * @param list<TIn> $items * @param callable(TIn): F<TOut> $f * @return F<list<TOut>> */ function traverse(array $items, callable $f) {} traverse($ids, findUser(...)); // Result<list<User>, E> traverse($rows, parseEmail(...)); // Option<list<Email>> 37
  27. PHPStanの @template が受け取れるのは具体的な型だけ。 同じ形の traverse を、型ごとに書くことになる。 現実: 同じ traverse を型ごとに書く

    /** @return Result<list<TOut>, TErr> */ function traverseResult(array $items, callable $f): Result { /* ... */ } /** @return Option<list<TOut>> */ function traverseOption(array $items, callable $f): Option { /* ... */ } // 中身はほぼコピペ 38
  28. Type Classも同様に表現できない。 Scalaの given と using のような、 PHP言語として後付する機構がなく、PHPStanも静的解析に過ぎない。 Type Class:

    既存の型に振る舞いを後付けしたい /** * @template T of Comparable // Comparable は自作interface とする * @param non-empty-list<T> $items * @return T */ function maxOf(array $items): mixed { /* ... */ } // DateTimeImmutable にComparable な性質を後付できるとこのようにできたら理想的であるができない maxOf([new DateTimeImmutable('today'), new DateTimeImmutable('tomorrow')]); 39
  29. Comparable を要求する関数に、組み込みクラスへ直接実装させられない。 wrapした自前の型を作って、それを渡すことになる。 現実: wrapした自前の型で実装 /** @implements Comparable<Timestamp> */ final

    readonly class Timestamp implements Comparable { public function __construct(public DateTimeImmutable $value) {} // 引数は mixed だが、型は PHPStan が Comparable<Timestamp> として追う public function compareTo(mixed $other): int { return $this->value <=> $other->value; // 委譲するだけ } } maxOf([ new Timestamp(new DateTimeImmutable('today')), 40
  30. Result<T, E> やinterfaceの実装など、個別の型は作れる。 が、それらを統一的に扱う一段上の抽象は作れない。 作れるもの 表現できないもの 実務での割り切り HKT 個別の Result

    / Option 任意の F<_> という抽象 型ごとに書く Type Class interfaceの実装 既存型への後付け wrapして自前の型にする ※ Kotlinも同様の制約があり、Arrow-ktのようなライブラリは同じような割り切りをしている 型の抽象度をあげることができない 41
  31. 新しく書くクラスに readonly を付け、setterを書かない。 依存ゼロなので、めのまえの新規コードから始められる。 スマートコンストラクタを採用してもいい。 まず、言語機能だけで不変にする final readonly class User

    { public function __construct(public string $id, public string $name) {} // 変更は、書き換えずに新しいインスタンスで返す public function withName(string $name): self { return new self($this->id, $name); } } 44
  32. 外から来る array / mixed / 例外は、境界で型の付いた値に変換する。 たとえば、Functional Core / Imperative

    Shellパターン imperative shell — Controller / Eloquent / HTTP 境界 — EmailAddress::parse(...) / Result::catch(...) functional core — 判断・計算・分岐 Result・直和型・不変・合成 — 今日の道具が効く場所 境界で変換してしまえば、Functional Core 内では曖昧さがなくなる。 最後に、境界で曖昧さを閉じる 46
  33. 境界を越えるときに変換すれば、Core内は型の付いた値だけになる。 Result にするのは、ドメイン上の失敗と境界を越える失敗だけでよい。 すべての例外を Result にする必要はない。 境界でやることは「変換」だけ // 外から来た mixed

    を、境界で値オブジェクトに変換する $email = EmailAddress::parse($request->input('email')); // → Result<EmailAddress, InvalidEmail> // 外部I/O の例外を、境界で Result に変換する $user = Result::catch(fn() => $repo->find($id)); // → Result<User, Throwable> 47
  34. 全部やる必要はないく、曖昧さが実際に痛い場所から1つだけでも。 明日から取り入れるなら readonly な値オブジェクトを使う • enum + match で分岐を明示する •

    mixed を減らす • array を array shape や list<T> に寄せる • ドメイン上の失敗だけ Result にする • 境界で例外を Result に変換する • 48