Skip to content

refactor!: filter をカリー化して ShapeOp を導入する - #91

Open
Javakky wants to merge 11 commits into
masterfrom
javakky/curry-op
Open

refactor!: filter をカリー化して ShapeOp を導入する#91
Javakky wants to merge 11 commits into
masterfrom
javakky/curry-op

Conversation

@Javakky

@Javakky Javakky commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

概要

issue #76 (PHP 8.5 のパイプ演算子対応) の第 1 弾。filter 1 関数だけをカリー化し、型表現の器 ShapeOp を導入します。

$users |> filter(fn (User $u) => $u->isActive()) |> map(fn (User $u) => $u->name);

1 関数に絞ったのは型表現を実物でレビューしていただきたいからです。承認後に残り 27 関数へ展開します。packagist 未公開 (タグ 0 本) なので BC を破棄できる段階です。

破壊的変更

filter($input, $callback, $mode)   →   filter($callback, $mode)($input)

$mode の意味は変わりません。引き続き $callback の直後に指定しますが、引数番号は第 3 引数から第 2 引数に変わります

CHANGELOG.md は置いていません。packagist 未公開でタグが 0 本なので移行対象の利用者が存在せず、破壊的変更の内容は refactor!: コミットの本文とこの PR に書けば足ります。

動作確認

何を 結果
vendor/bin/phpunit tests (PHP 8.4) Tests: 111, Assertions: 119, Skipped: 1 (skip はパイプテストのみ)
vendor/bin/phpstan analyse (level 10) [OK] No errors (@phpstan-ignore は負のテスト 1 箇所のみ)
vendor/bin/php-cs-fixer fix Fixed 0 of 34 files
パイプの実動作 (docker php:8.5-cli) [1,2,3,4] |> filter(fn ($v) => $v % 2 === 0)[2, 4]

型は tests/types/filter.php\PHPStan\Testing\assertType() により固定しています。

filter($cb)($list)                     → list<int>
filter($cb)($assoc)                    → array<string, int>
filter($cb, Mode::MODE_LIST)($assoc)   → list<int>
filter($cb, Mode::MODE_ASSOC)($list)   → array<int<0, max>, int>
filter(fn (object $v) => true)($users) → list<User>          値型が広がらない

このテストが検出力を持つことを確認済みです。 28 行目の期待値を array<string, int>list<int> に壊すと Expected type list<int>, actual: array<string, int> で落ち、戻すと [OK] に復帰します。

設計

なぜ Closure でなく invokable オブジェクトなのか

@return Closure(...) は 9 パターン試して level 10 を通せませんでした。Closure の変性は戻りが共変なので Closure(array): arrayClosure(list<V>): list<V> を満たせず (#11707 仕様)、戻り型を絞るための Closure リテラルの phpdoc は読まれず (#3770 open)、外側の @template も返り値 Closure 内で使えません (#3075 open)。

__invoke はメソッドなので phpdoc が効き、@param で union を保ちつつ @phpstan-return で条件分岐でき、メソッドテンプレートで $input から型を推論できます。

利用者からは隠れます — native 戻り型は callable、プレーンな @returncallable(...) で、@phpstan-return だけが ShapeOp を知り、クラスには @internal が付いています。

クラスは戻り値の「代数形」ごとに 5 個

操作の意味 (値を保存する / 変換する) で分けてはいけません。 最初はそう分けて 6 クラスになりましたが、filtermap は代数形で見ると両方 Shape<E> で同じ器です。Shape = 入力と同じ器 (Mode と入力に応じて listarray<K, ·>) と置くと 5 形に尽きます。

代数形 クラス 関数 (28)
list<E> | array<I, E> ShapeOp (この PR) filter map map_keys collect unique take drop slice tail init sort_by take_while drop_while flat_map associate count_by (16)
array<I, Shape<E>> KeyedOp group_by group_map (2)
list<Shape<E>> ListOp chunk windowed chunk_by (3)
array{Shape<E>, Shape<E>} PairOp partition partition_map (2)
R ValueOp reduce any all none find_option (5)

全 28 関数ぶんの型を実測して確定しました。 表を書き直す原因になったのは 3 つです。

  • flat_mapキーを保存せず array<R, E> を返す (Mode 分岐あり) → ShapeOpI で賄う
  • chunk / windowed は内側が non-empty-list
  • reduce は既に (R is null ? (?V) : R) の番兵を持ち、find_option?Vany / all / nonebool も同じ式から出る

KeyedOp (array<I, ·>) と ListOp (list<·>) は外側の器しか違いませんが統合できません。器を型パラメータで受けるには higher-kinded types が必要で、器の形は関数を呼ぶ時点で決まるのに中身 (TK / TV) は $input を渡す時点で決まるため「あとで埋める枠」を渡せません。

Preserve マーカーで「入力の型をそのまま返す」を表す

filter    → ShapeOp<K, V, Preserve, Preserve, TMode>    キーも値も保存
map       → ShapeOp<K, V, Preserve, E,        TMode>    値が変わる
map_keys  → ShapeOp<K, V, I,        Preserve, Mode::MODE_ASSOC>
associate → ShapeOp<K, V, I,        E,        Mode::MODE_ASSOC>

Preserve は実行時に現れない phantom type で、(E is Preserve ? TV : E) の分岐スイッチです。「入力の値型」は TV (メソッドテンプレート) でしか書けず E (クラステンプレート) に渡せないため、値ではなく「保存せよ」という指示を型で渡します。

null を番兵にできないのは、E が値型なので map(fn () => null)collect?Enull が正当に来るためです。マーカー型の慣習としては C++ の tag type (std::in_place_t / std::nullopt_t — 意味をそのまま名詞化した空の型) に倣っています。

実測で確定した PHPStan の境界

試したこと 結果
条件型をの位置にネスト list<(E is Preserve ? TV : E)> 通る。ただし括弧が必須で、無いとパースエラー
条件型をキーの位置にネスト array<(I is Preserve ? TK : I), X> 通らない。エラーも出ず phpdoc が丸ごと無視され array に落ちる
型エイリアスにテンプレート引数 @phpstan-type ShapeAlias<TK2, TV2> = … パースエラー (Unexpected token "<")。条件型の共通部分を切り出せない
@template TK of K の bound は @param の照合で効くか 効かないarray<TK, TV> と書いても K = string の callback に list を渡せてしまう。array<TK&K, TV> と交差させると K 側の適合も検査される

キーの位置に置けないため ShapeOp の条件型は 8 枝あります (I is Preserve を最上位に出して TMode の 3 枝を 2 回書く)。値側は (E is Preserve ? TV : E) で畳めているので、重複しているのは外側だけです。

レビュー指摘への対応

3 巡目 (High 1 / Medium 3 / Low 6)

🔴 High: PHP 8.5 の CI job がない → #92 で対応しました (別ブランチなのでこの PR の差分には現れません)。lint / checks (8.4) / checks (8.5) の 3 job 構成で、パイプのテストが 8.5 で実際に走ります。

🟡 Medium: $apply の契約を ShapeOp に集約する提案 → 採りませんでした。

array_values() を一律で通すか foreach で $result[] = するかは関数ごとに最適が違う (array_slice を使う関数は前者、foreach で組む関数は後者が速い) ため、実装者の判断に委ねます。ご指摘の「型は嘘をつくのに CI は緑」という問題はテスト側で担保します — Mode を取る関数は 4 経路 (AUTO×list / AUTO×assoc / LIST×assoc / ASSOC×list) すべてを assertSame で固定することを共通規約にしました。assertSame はキーの順序・型まで見るので、MODE_LIST の期待値を list リテラルで書けばキーが残った時点で落ちます。

なお assertTrue(array_is_list($result)) を足す案は実測で捨てました。PHPStan が宣言された型から array{2, 3} と narrowing して function.alreadyNarrowedType で level 10 が落ちます。型が嘘をついていれば narrowing も嘘になるので、静的解析下では検出器になりません。

🟡 Medium: \Closure を満たさない → 実害はほぼありませんでした。

「回避策はどちらも条件型を落として array に退化する」というご指摘は実測では否定されました。

filter($cb)(...)                          → Closure<TK of K, TV of V>(array<TK, TV>): ($input is list<TV> ? list<TV> : array<TK, TV>)
(filter($cb)(...))($list)                 → list<int>       ← 退化しない
Closure::fromCallable(filter($cb))($list) → list<int>       ← 退化しない

private \Closure $op に入れたい場合は filter($cb)(...) と書くだけで、型精度の代償なく解決します。private callable $op が書けないのは PHP の仕様 (Property cannot have type callable) で ShapeOp とは無関係です。README への追記は次のコミットで入れます。

🟢 Low (5 件対応、1 件は本文で回答)

指摘 対応
new Preserve() が通る private function __construct() で塞ぎました
$apply の第 2 引数を Mode::MODE_LIST|MODE_ASSOC に絞れる 対応しました。filterelse が「ASSOC 確定」と PHPStan に読めるようになりました
array_filter → foreach はカリー化と無関係 array_filter に戻しました。 挙動の等価性は 4 経路 + 空配列で実測済み
テストの穴 (MODE_LIST × assoc × キーを見る callback) 1 ケース追加しました
tests/types/ の置き場 tests/Types/ にリネームしました (case-insensitive FS では git mv を 2 段階でやる必要がありました)
設計表に head_option / last_option / intersect がない 意図的です。 この 3 つと diff / zip / zip_with はオーナー判断でカリー化対象外です — head_option / last_option は引数が $input だけなので head_option(...) で既にパイプに置けます。intersect / diff / zip / zip_with は配列を 2 つ取り、どちらを残すかが非自明なため現行の形を維持します。表は「カリー化する 28 関数」の内訳なので 3 関数が現れないのが正しい状態です。tail() / init() / unique() の空括弧呼び出し ($x |> tail()) は意図した形です (追加引数が Mode だけの関数もカリー化する、というオーナー決定)

「8 枝は許容範囲」への同意に加えていただいた提案 — 「キー位置の phpdoc は失敗しても沈黙で array に退化するので、ShapeOp に載せる関数はすべて tests/types/<fn>.phpassertType を必須にする」は、展開 PR の完了条件として共通規約に入れました。期待値をわざと壊して落ちることの確認まで含めています。

2 巡目 (3 件すべて対応)

指摘 対応
[High] list<TV>K の制約を迂回している 本物でした。 ただし原因はご提案とは別で、K$callback から正しく推論されています (dumpTypeShapeOp<string, int, Preserve, Preserve, MODE_ASSOC>)。@template TK of K の bound が @param の照合で効いていないのが原因で、@param array<TK, TV> に変えても拒否されませんでした (実測)。array<TK&K, TV> と交差させて K 側の適合も検査させることで Parameter #1 $input expects array<string, int>, list<int> given. が出るようになりました。filter() のプレーンな @returncallable(array<K, V>) に揃えました。list を渡す経路は塞いでいませんarray<K, V> は list を含むので、Kint を許容する限り list リテラル (filter($cb)([1, 2, 3])list<1|2|3>) も空配列 (filter($cb)([])list<*NEVER*>) も通ります (実測)。拒否されるのは callback が string キーしか受け取れない場合だけです。空配列を渡す経路は non-empty 系の型を入れると壊れやすいので実行時テストで固定しました
[Medium] README の assert() が壊れている assert($result === [2, 4]) に修正。既存の例も同じ形だったので併せて直しました
[Low] $mode の位置の記述が不正確 「引数番号は第 3 引数から第 2 引数に変わる」と明記しました

負のテストを @phpstan-ignore で固定しました。 拒否できなくなると未使用 ignore として level 10 が落ちるので、これが退行の検出器になります。&K を外すと

45  No error with identifier argument.type is reported on line 45.
45  No error with identifier argument.templateType is reported on line 45.
    [ERROR] Found 2 errors

で落ち、戻すと [OK] に復帰することを確認しました。

1 巡目 (6 件すべて対応)

うち 4 件は実測で本物のバグと確認しました。

指摘 対応
I is K でキー保存を表すのは危険 本物でした。 is は同一性判定でなく subsumption なので K = array-keyI = string を渡すと string is array-key が真になり、dumpTypearray<int<0, max>, int> と言うのに実行時は文字列キーが返っていました。専用マーカー Preserve に置換
値型が widening する 本物でした。 filter(fn (object $v) => true)($users)list<object> に広がっていました。(E is Preserve ? TV : E) で解決
型テストに実行できない呼び出しがある 本物でした。 filter(fn (int $v, string $k), MODE_ASSOC)([1,2,3]) は静的には通りますが実行すると TypeError。削除しました
inline @vararray<I, E> を落としている 本物でした。 array<I&array-key, mixed> を追加。callable(mixed, K)TK と繋がらないので array-key に修正
「キーを受け取らない callback でも」が過剰主張 is_int(...) のような内部関数の first-class callable は ArgumentCountError になるため、「引数を 1 つしか宣言しないクロージャ」に限定して明記
移行例がない README にカリー化後の使い方とパイプの例を追加。CHANGELOG.md はリリース前で移行対象の利用者がいないため置かない

composer.json は触っていません (ShapeOp / Preserve は PSR-4 で解決されます)。

見てほしい点

  1. README の設計思想との整合 — 「戻り値はすべて array または null」は __invoke の戻りが array なので守られていますが、filter() 自身の戻りは callable になります。「オブジェクトを生成してチェーンしていくスタイルは採らない」は ->map()->filter() を指していて $x |> map($f) |> filter($g) は演算子による接続なので別物だと解釈していますが、この解釈が妥当かの判断をお願いします。思想を優先して見送る選択もあります (その場合パイプは $x |> (fn ($a) => filter($a, $cb)) と書けます — RFC 自身がこの形を例示しています)
  2. 8 枝の条件型が許容範囲か — キー位置に条件型を置けない制約の帰結です。I を捨てて map_keys / flat_map / associate / count_by 用に別クラスを立てれば 3 枝になりますが、クラスが 1 つ増えます
  3. $apply の契約が型で守れていない$apply に「キーを変えてしまう実装」を渡しても型エラーになりません。Closure の中身が宣言型と照合されない (#3770 と同根) ためで、契約はテストで担保しています
  4. テストメソッド名の混在tests/FilterTest.php の既存分は snake_case のままです。「無関係なリネームをしない」を優先しましたが、揃えるなら別コミットで切れます

マージ順のお願い

カリー化は全関数のシグネチャに触るので、open な PR すべてと衝突します。この順序だと手戻りがありません。

  1. refactor: autoload の関数登録を src/_functions.php に集約する #89 (autoload 集約) — 以降 composer.json の衝突が消えます
  2. 関数追加 11 本 (feat: sort_by を追加する #77feat: partition と partition_map を追加する #87) + fix: slice / tail / init が MODE_LIST でも文字列キーを残すのを直す #88 + feat: chunk_by に Mode を追加して assoc 対応にする #90
  3. このカリー化 → 残り 27 関数へ展開
  4. build: packagist にアップロードする #26 (packagist 公開)

3 と 4 は逆にできません。 公開後のシグネチャ変更は BC break になります。

Refs #76

🤖 Generated with Claude Code

@claude

This comment was marked as resolved.

@claude

This comment was marked as resolved.

@claude

This comment was marked as resolved.

@claude

This comment was marked as resolved.

@claude

This comment was marked as resolved.

@claude

This comment was marked as resolved.

@Javakky
Javakky requested a review from oyashiro846 July 27, 2026 13:02
@Javakky
Javakky force-pushed the javakky/curry-op branch 3 times, most recently from f6cab67 to be95fbb Compare July 27, 2026 13:30
Javakky added 3 commits July 27, 2026 23:26
適用対象の配列だけを保留した操作を表す invokable クラス。 $input を受け取る callable なので
そのまま呼び出すか PHP 8.5 のパイプ演算子の右辺に置ける。 issue #76 の土台。

Closure を返す形は PHPStan level 10 を通せない。 原因は 3 つとも PHPStan 側の制限で、 Closure の
変性 (引数=反変・戻り=共変) により Closure(array): array は Closure(list<V>): list<V> を満たさず
(#11707)、 戻り型を絞るには Closure リテラルに phpdoc を書くしかないがそれは読まれず (#3770)、
外側の @template を返り値 Closure 内で使えない (#3075)。 __invoke はメソッドなので phpdoc が効き、
@PARAM で union を保ちつつ @phpstan-return で条件分岐でき、 メソッドテンプレート
(@template TK of K / @template TV of V) で $input から型を推論できる。

番兵に入力の型パラメータを使う設計 (I is K で「入力キーを保存する」を表す) は誤りだった。
条件型の is は subsumption (部分型判定) であって同一性判定ではないため、 出力キーが入力のキーを
保存するかどうかを見分けられず型が嘘をつく。 PR #91 のレビュー指摘のうち 4 件がこれを原因とする
実際のバグだった。 専用マーカー Preserve を導入し I is Preserve / E is Preserve で判定する形へ
置き換えた。 Preserve は他の array-key に subsume されないので同じ壊れ方をしない。

条件型はキーの位置に置けない (array<(I is Preserve ? TK : I), X> は黙って array に落ちる) ため、
I is Preserve を条件型の最上位に出して TMode の 3 枝を 2 回書いている。 値の位置にはネストできる
(括弧が必須で、 無いとパースエラー) ので値側は (E is Preserve ? TV : E) に畳めており、 重複して
いるのは外側だけ。 @phpstan-type はテンプレート引数を取れないため、 外側の重複を型エイリアスへ
切り出すことはできない。

クラス名は戻り値の代数形を表す。 ShapeOp は list<E> | array<I, E> を返す 16 関数 (filter map
map_keys collect unique take drop slice tail init sort_by take_while drop_while flat_map
associate count_by) を収容する。 器の形が違う操作は別クラスにする: array<I, Shape<E>> は KeyedOp、
list<Shape<E>> は ListOp、 array{Shape<E>, Shape<E>} は PairOp、 スカラーは ValueOp (いずれも
後続 PR)。 器の形は関数を呼ぶ時点で決まるのに中身は $input を渡す時点で決まるため、 器を型
パラメータで受け取れない (higher-kinded types が無い)。 この分け方は Scala (HKT を持ちながら
groupBy / partition は具体型) と Kotlin (全操作で具体型) と一致する。

$apply の @var 再アサーションは消せない。 返すべきなのは $input から推論した TK を使った型だが、
TK は __invoke のメソッドテンプレートなのでコンストラクタの @PARAM には書けず、 PHPStan は
array<TK, TV> と array<K, V> を (TK of K でも) 繋げない。 理由をコード内コメントに残した。

前回の履歴では CollectionOp として追加していたが、 origin/master には出ていないので履歴を作り
直してこのクラス 1 つに畳んだ。

Refs #76
filter($input, $cb, $mode) を filter($cb, $mode)($input) に変える。 戻り値は $input を受け取る
callable なので PHP 8.5 のパイプ演算子の右辺に直接置ける。

破壊的変更だが packagist 未公開 (タグ 0 本) なので BC を破棄できる段階。 移行は引数の順序を
入れ替えて $input を戻り値の呼び出しへ移すだけで済む。

  // 変更前
  $adults = filter($users, fn (User $u): bool => $u->age >= 20);

  // 変更後
  $adults = filter(fn (User $u): bool => $u->age >= 20)($users);

  // PHP 8.5 のパイプ演算子と組み合わせる場合
  $adults = $users |> filter(fn (User $u): bool => $u->age >= 20);

$mode の位置と意味は変わらない。

native 戻り型は callable にして ShapeOp を phpdoc の奥に隠す。 利用者から実装クラスは見えない。
@phpstan-return ShapeOp<K, V, Preserve, Preserve, TMode> で入力のキーと値の両方を保存することを
表す。

MODE_ASSOC で array_filter への委譲をやめ foreach に統一した。 $cb の型で PHPStan が落ちるため。
挙動は同一で、 既存 5 件の期待値を無変更のまま通ることで担保している。

Closure 内の $cb の @var は callable(mixed, array-key): bool にした。 $value は $input 由来の TV、
キーも TK だが PHPStan は callable の引数を不変扱いするため証明できない。 キー側を K のままに
すると __invoke 側の TK と繋がらず、 宣言が実態と食い違う。

テストは既存 5 件を curried 呼び出しに書き換え、 5 件追加した。

  - 戻り値が callable であること (call_user_func 経由で実呼び出し)
  - 同じ操作を list と assoc に再利用できること (MODE_AUTO の判定が呼び出しごとに効いている証明)
  - パイプ演算子での動作 (#[RequiresPhp('>= 8.5')] + eval。 8.4 ではファイル全体のパースで
    構文エラーになるため eval で包む。 8.4 では skip される)
  - MODE_LIST に assoc を渡すと 0 始まり連番になること
  - MODE_ASSOC に list を渡すと元の整数添字を保持し詰めないこと

後ろの 2 件は型の証拠として挙げている組合せなのに実行経路のテストが無かったもの。

composer.json の require.php は >=8.4 のまま。 Closure を返すだけなら 8.4 で動き、 パイプを使うかは
利用者の PHP バージョン次第。

issue #76 の第 1 弾 (残り 27 関数は形の承認後に展開)。

Refs #76
型の正しさの根拠が dumpType の目視だけだったので、 PHPStan に検査させるファイルを置く。
PHPStan 公式の \PHPStan\Testing\assertType() で期待値を文字列で明示する。

検出力があることを確認した。 期待値を壊すと落ちる:

  assertType('list<int>', filter($cb)($assoc));
  -> Expected type list<int>, actual: array<string, int> at tests/types/filter.php:28

受け取り側の関数に厳しい型を宣言してそこへ渡す形も検討したが、 それでは「型が広がった」ときだけ
しか落ちない。 assertType は狭まった場合も検出するのでこちらを採った。

カバーする組合せは list×AUTO / assoc×AUTO / assoc×MODE_LIST / list×MODE_ASSOC / 引数を 1 つしか
宣言しないクロージャの list と assoc の 6 本。 CollectionOp から ShapeOp へ載せ替えても推論結果は
変わらない。

callback が K を string に固定した状態で list を渡すケースは置かない。 静的型は通るが実行すると
TypeError: Argument #2 ($k) must be of type string, int given で落ちる。 実行できないコードを型
テストに置かない。 同じ理由で「キーを受け取らない callback でも値型が保たれる」というコメントも
過剰主張なので、 引数を 1 つしか宣言しないクロージャに限る旨へ直した。 内部関数の first-class
callable (is_int(...) 等) は引数の数が厳密で ArgumentCountError になり渡せない。

phpstan.neon の paths は既に tests を含むので設定変更は不要。 PHPUnit は既定 suffix Test.php で
収集するため tests/types/filter.php は対象外。

Refs #76
@Javakky
Javakky force-pushed the javakky/curry-op branch from be95fbb to 0549691 Compare July 27, 2026 14:33
@Javakky Javakky changed the title refactor!: filter をカリー化して Op クラスを導入する refactor!: filter をカリー化して ShapeOp を導入する Jul 27, 2026
callback を先に受け取る形と、 PHP 8.5 のパイプ演算子の右辺にそのまま置ける ことを使い方の節に足した。

CHANGELOG は置かない。 packagist 未公開でタグが 0 本なので移行対象の 利用者が存在せず、 破壊的変更の内容は refactor! コミットの本文と PR に 書けば足りる。

Refs #76

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Javakky
Javakky force-pushed the javakky/curry-op branch from 0549691 to 7f44bda Compare July 27, 2026 14:40
Javakky and others added 2 commits July 27, 2026 23:48
@template TK of K の bound は @PARAM の照合では効かないため、 K に string を要求する callback に list を渡す呼び出しが静的に通り、 実行時に TypeError になっていた。

filter(fn (int $v, string $k), MODE_ASSOC)([1, 2, 3]) → Argument #2 ($key) must be of type string, int given

キー型を array<TK&K, TV> と交差させて K 側の適合も検査させる。 拒否されることを @PHPStan-Ignore で固定したので、 退行すると未使用 ignore として level 10 が落ちる (&K を外すと No error with identifier argument.type is reported で落ちることを確認済み)。

list を渡す経路は塞いでいない。 array<K, V> は list を含む (list は array<int, V> の特殊形) ので、 K が int を許容する限り list リテラルも空配列も渡せる。 拒否されるのは callback が string キーしか受け取れない場合だけ。 空配列を渡す経路は non-empty 系の型を入れると壊れやすいので実行時テストで固定した。

filter のプレーンな @return も list を union に並べる形をやめ array<K, V> に揃えた。

Refs #76

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
assert([2, 4], $result) は第 2 引数が説明文 (Throwable|string|null) なので、 assertion が有効な環境では配列を渡した時点で TypeError になる。 既存の例も同じ形だったので併せて直した。

Refs #76

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Javakky
Javakky force-pushed the javakky/curry-op branch from 1f6a090 to d28833e Compare July 27, 2026 14:49
@Javakky

Javakky commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

@claude [review]

@claude

This comment was marked as resolved.

Javakky and others added 5 commits July 28, 2026 00:57
phantom type として型パラメータの位置に置くためだけのクラスなのに new Preserve() が通っていた。 27 関数へ展開すると型引数の位置に大量に 現れるので、 誤用の芽を private constructor で塞ぐ。

Refs #76

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docblock は「解決済み mode」と説明しているのに型が Mode で MODE_AUTO を 許していた。 Mode::check_mode() の戻り型は MODE_LIST|MODE_ASSOC なので そのまま書ける。

絞ると filter の else が「MODE_ASSOC 確定」だと PHPStan にも読めるようになり、 MODE_AUTO が漏れた場合に暗黙で assoc 扱いされる形より契約が明確になる。

Refs #76

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
カリー化のついでに array_filter($input, $cb, ARRAY_FILTER_USE_BOTH) を 手書き foreach に置き換えていたが、 カリー化と無関係な変更だった。 C レベルのループを PHP レベルに落とすため assoc の大きい配列で遅くなる。

非カリー化版と同じ「MODE_ASSOC は array_filter で早期 return」の構造に戻した。 挙動の等価性は実測で確認 (list/assoc x AUTO/LIST/ASSOC の 4 経路 + 空配列)。

Refs #76

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
キーを捨てるのは出力側だけという契約が未固定だった。 既存の MODE_LIST x assoc テストは値のみの callback、 キーを見るテストは list 入力で、 この組み合わせが抜けていた。

Refs #76

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
autoload-dev の PSR-4 は Oyashiro846\Phollection\ -> tests/ なので、 名前空間 ...\Types に対応するディレクトリは tests/Types (大文字 T)。 小文字のままだと将来ここにクラスを置いたとき case-sensitive な FS で オートロードが壊れる。

Refs #76

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown

Claude finished @Javakky's task in 5m 27s —— View job


レビュー結果(3 巡目)

  • 差分の読み込み(前回レビュー以降の 5 コミット)
  • ShapeOp / Preserve / filter の再確認
  • 前回指摘の対応状況の突き合わせ
  • 静的解析・テストの実行確認 → 実行できませんでした(下記)
  • レビュー結果の投稿

⚠️ 検証の限界: この環境では php / composer の実行が許可されておらず(vendor/ なし)、phpstan / phpunit / php-cs-fixer を回せていません。以下はコードと設定ファイルの静的読解に基づく指摘で、PR 本文の実測結果は追試していない前提です。

前回指摘のうち 5 件が対応済みであることを確認しました。特に src/filter.php の実装本体が git show origin/master:src/filter.php構造的に同一(assoc → array_filter、else → foreach)に戻っており、差分がカリー化そのものだけになったのは良い修正です。Preserve の private constructor、$apply 第 2 引数の Mode::MODE_LIST|Mode::MODE_ASSOC への絞り込み、testFilterAssocWithListModeStillPassesOriginalKeysToCallback の追加、tests/Types/ へのリネーム、いずれも意図どおりです(array_filter 無修飾はこのリポジトリの native_function_invocation: @compiler_optimized 設定と整合しています — \count / \in_array だけが修飾される慣習に合っています)。

以下、新規に見つけたものを重要度順に挙げます。


🔴 High: TV of V の bound も @param で効いていないはずで、値型に同じ抜け道が残っている

前回対応いただいた「@template TK of K の bound が @param の照合で効かない」という実測結果は、キーだけの性質ではありません。 同じ機構が値側にもそのまま当てはまります。

src/ShapeOp.php:35 は現在こうなっています。

 * @template TK of K
 * @template TV of V
 *
 * @param array<TK&K, TV> $input

キー側は TK&K と交差させて K の適合を検査させましたが、値側は TV 単独のままです。bound が照合で効かないなら、V に何が来ていても TV は自由に決まります。

// V は callback から User と推論される
$userOnly = static fn (User $u): bool => true;

// TV = int。bound (of User) は検査されないので静的に通ってしまう(想定)
$rejected = filter($userOnly)([1, 2, 3]);
// 実行すると callback 内で TypeError

キー側の抜け道は「string キーしか受け取れない callback に list を渡せる」でしたが、値側は filter の主目的そのもの(callback の値型と要素型の適合)が無検査という、より広い穴になります。しかも tests/Types/filter.php の負のテストはキー側 1 本だけなので、通ってしまっていても CI は緑です。

候補の修正は対称に array<TK&K, TV&V> ですが、K of array-key と違って V は無制約テンプレートなので、PHPStan が非オブジェクトのテンプレート同士の交差をどう扱うかは実測が要ります。通らない場合でも、いま「拒否できているのか / できていないのか」が誰にも分からない状態が問題なので、まず tests/Types/filter.php に上記の負のテストを 1 本足して、@phpstan-ignore が必要か(=拒否できているか)を確定させてください。キー側と同じ形で書けば、そのまま退行検出器になります。

ShapeOp は 16 関数に展開される器なので、ここを filter のうちに詰めておく価値が一番大きいと思います。

Fix this →


🟡 Medium: 入力形状のガードが黙って外れている

master の filter() には @phpstan-param に入力側の条件型がありました。

 * @phpstan-param ($mode is Mode::MODE_LIST ? list<V> :
 *   ($mode is Mode::MODE_ASSOC ? array<K, V> :
 *     list<V>|array<K, V>
 * )) $input

カリー化後の ShapeOp::__invokearray<TK&K, TV> を無条件に受けるので、この制約は消えています。 結果として filter($cb, Mode::MODE_LIST)($assoc) が静的に通るようになり、実際 tests/Types/filter.php:29testFilterAssocWithListModeRenumbersKeys はそれを期待値として固定しています。

私はこの緩和自体は正しいと思います — MODE_LIST で assoc のキーを詰め直すのは実行時に意味のある操作で、master のガードはそれを塞いでいました(だから master にはこの 2 ケースのテストが無く、この PR で新規追加されています)。

ただし、

  • PR 本文の「破壊的変更」の節に書かれていません。$mode の引数番号の変更だけが挙がっていますが、型レベルの受け入れ範囲も広がっています
  • 27 関数への展開時に、各関数の既存 @phpstan-param 条件型を同じ判断で外すのか個別に決めるのかが未定です。map / unique など既存関数も同型のガードを持っているので(src/map.php:11, src/unique.php:12)、方針を先に決めておかないと関数ごとにバラつきます

の 2 点だけ、本文に 1 行足すか展開 PR の方針として明記しておくことを勧めます。


🟡 Medium: README の 3 点が未対応のまま(前回 High/Medium 相当)

assert() は直っていますが、残り 3 点はそのままです。

  1. README.md:24Arrays::map($nums, ...) は動きません。 grep -rn Arrays のヒットは README のこの 1 行だけで、Arrays クラスはリポジトリに存在しません。同じコードブロックの assert() を今回直しているので、ここも一緒に直すのが自然です
  2. カリー化済みが filter だけであることが書かれていません。 README.md:32 は「filter のように callback を先に受け取る関数は」と複数形で書かれており、24 行目の map($nums, $cb) 形式と 39 行目の $nums |> filter($cb) 形式が並ぶため、読者は map(fn ...) も動くと解釈します
  3. README.md:47「戻り値はすべて PHP の array または null で表現し」は、この PR で字面として偽になりました。 filter() の戻りは ShapeOp インスタンスです

3 番は「見てほしい点 1」への私の回答(前回コメント)と対になっています。解釈(|> はメソッドチェーンではない)は妥当だと考えていますが、README は契約なので「操作を表す値だけは callable として返す。コレクション自体は常に array / null」の 1 段を明示的に足す必要があります。 ここを曖昧にしたまま 28 関数へ広げるのが一番まずいパターンだと思います。

Fix this →


🟡 Medium: PHP 8.5 の CI ジョブが未追加のまま

.github/workflows/check.yml:24php-version: '8.4' 固定のままで、tests/FilterTest.php#[RequiresPhp('>= 8.5')] は恒久的にスキップされます。この PR の目的そのもの(パイプ演算子の右辺に置ける)を CI が一切検証していない状態です。

前回もお伝えしたとおり、私は GitHub App の権限で .github/workflows/ を変更できないため、この修正は手元でお願いします。マトリクスを足すだけです。

jobs:
  checks:
    strategy:
      fail-fast: false
      matrix:
        php: ['8.4', '8.5']
    steps:
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}

なお testFilterWorksWithPipeOperatoreval() で包まれているのは 8.4 でのパースエラーを避けるためで、これは正しい書き方です。ただし eval() は 8.4 では実行されず 8.5 でしか意味を持たないので、**8.5 ジョブが無い限りこのテストは「書いてあるだけ」**になります。


🟢 Low: \Closure でないことの利用者影響が未文書(前回から継続)

filter($cb)ShapeOp インスタンスなので callable は満たしますが \Closure は満たしません。PHP では callable をプロパティ型に書けないため、利用者が操作を保持するときの自然な選択 private \Closure $op が通りません。28 関数すべてに波及するので、README に「戻り値は callable として扱ってください」の 1 行が欲しいです。

🟢 Low: $resolved の分岐が網羅的でない

src/filter.php:36-38if ($resolved === Mode::MODE_ASSOC) { ... } の後が暗黙の list パスです。$apply の phpdoc で 2 値に絞られたので PHPStan 的には安全ですが、クロージャの native シグネチャは Mode $resolved のままなので、実行時に MODE_AUTO が渡れば黙って list 扱いになります(master では逆に assoc 扱いでした)。match ($resolved) { Mode::MODE_ASSOC => ..., Mode::MODE_LIST => ... } にすると UnhandledMatchError で気づけます。到達不能ではあるので任意です。

🟢 Low: 設計表に既存 3 関数が入っていない(前回から継続・未回答)

5 形の表(計 28)に head_option / last_option / intersect が現れません。特に intersect($input, $other, $mode)引数が配列 2 つで、どちらをパイプの主語にするか(intersect($other)($input) か)で API が決まります。あわせて tail() / init() / unique() のような callback を持たない関数が空括弧呼び出し$x |> tail())になる点も、意図した形か確認しておきたいです。展開時に迷う箇所なので承認前に埋めておくと手戻りが減ります。


まとめ

マージ前に対応を勧めるもの

  • 🔴 値型の負のテスト追加(TV&V の要否確定)— ShapeOp が 16 関数の器になる前に潰しておきたい
  • 🟡 README の 3 点(Arrays::map / カリー化済みは filter のみ / 設計思想の追記)
  • 🟡 PHP 8.5 の CI マトリクス(ワークフロー変更は私の権限外です)

展開前に方針を決めたいもの

  • 入力形状ガード(@phpstan-param 条件型)を全関数で外すのか
  • \Closure でないことの明記
  • 設計表の 3 関数(head_option / last_option / intersect)と空括弧呼び出し

前回指摘の対応は丁寧で、特に array_filter を戻して差分をカリー化だけに絞った判断と、Preserve を構造的にインスタンス化不能にした対応は良いと思います。残る TV の抜け道は、まさに前回ご自身が実測で確定させた機構の裏返しなので、同じ手法で確認すれば決着すると思います。

コードの変更は行っていません(レビュー依頼のため)。
| ブランチ: javakky/curry-op

@oyashiro846

Copy link
Copy Markdown
Owner

PHPStanのbound照合が効いていないことについては、Keyと同じくValue側も交差型で強制照合して、テストで拒否できるかどうかを確認しましょう。

@phpstan-paramの条件緩和は、カリー化のために不可避だと思うので「緩和」の方針でいいと思います。コールバックに型ヒントを明記することを推奨としましょう。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants