3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

引数としてクロージャをより良く使う方法

Last updated at Posted at 2025-09-29

はじめに

PHPでClosureを関数の引数として受け取る際、Closure型ヒントを使用します。関数内部に渡された関数を使おうとしたら、引数としてどんな値を渡し、戻り値としてどの型を返すのかを知っておく必要があります。しかし、Closure型の引数だけでは、関数の仕様に関する情報が十分ではありません。

本記事では、PHPのリフレクション(reflection)を使ったランタイム(runtime)検査を利用して、関数の引数として渡されたClosureのパラメータ型や戻り値の型を確認する方法を提案します。これにより、クロージャを扱う際の不便さを軽減する方法、さらに、クロージャを使うときに注意すべき点や、場合によっては代替手段を検討したほうがよい理由も紹介します。

本論

PHP Closureの特徴

この部分は本稿の目的を理解する上で必須ではありません。そのため、必要に応じて読み飛ばし、Closureの問題点に進んでいただいて構いません。

シノプシス

次は、PHP公式マニュアルにあるClosureのシノプシス(synopsis: 概要)です。

final class Closure {
    /* Methods */
    private __construct()
    public static bind(Closure $closure, ?object $newThis, object|string|null $newScope = "static"): ?Closure
    public bindTo(?object $newThis, object|string|null $newScope = "static"): ?Closure
    public call(object $newThis, mixed ...$args): mixed
    public static fromCallable(callable $callback): Closure
}

上記のシグネチャの特性を理解すると、なぜクロージャの引数や戻り値の型を指定する方法を考えにくいのかが分かります。まずは、Closureの特性について見ていきます。

継承不可

final classであるため、継承はできません。既存のメソッドをオーバーライド(overriding)して変更したり、新しいメソッドを追加したりすることもできません。与えられたクラスの仕様をそのまま使用するしかないです。

オブジェクトの生成不可

private __constructは、オブジェクトの外部からnewキーワードを使ったオブジェクト生成を防ぎます。通常、newによって新しいオブジェクトを作る際にはpublicなコンストラクタが呼び出されますが、コンストラクタがprivateであるためアクセスが遮断され、オブジェクトを生成できなくなっています。

privateであるため、オブジェクト内部ではコンストラクタにアクセスでき、new Selfのようなコードでオブジェクトを生成するメソッドを作ることは可能です。しかし、finalクラスであり継承できないため、任意のメソッドを追加することはできません。

そのため、Closure::method のような方法ではオブジェクトを生成できず、無名関数を生成する構文(function ($param) { /* code */ } や fn($param) => $return;)を使うことのみクロージャオブジェクトを生成できます。

バインディングとは?

無名関数は、Closureクラスの呼び出し可能なメソッドを定義することと同じです。そのため、無名関数の関数ブロック内での$thisClosureを生成するスコープの$thisを指します。このとき、オブジェクトをバインドすることで、無名関数ブロック内の$thisに他のオブジェクトを指定してアクセスできます。

無名関数をコピーする

public static bind(Closure $closure, ?object $newThis, object|string|null $newScope = "static"): ?Closure

$newFn = Closure::bind($fn, $obj)のようにして、無名関数$fnをコピーしつつ、必要に応じてバインドするオブジェクトを指定し、コピーされた無名関数にバインドすることができます。

オブジェクトをバインドすることで$thisを通じてバインドされたオブジェクトにアクセスできますが、クラス内部のコードとして定義されていない場合、クラスがインスタンス化された時点でアクセスできるのはパブリックメンバーに限られます。コピーされた無名関数は、すでに生成済みのオブジェクトにバインドされるため、バインド対象のプライベートメンバーやプロテクテッドメンバーにはアクセスできず、パブリックメンバーのみが参照可能となります。プライベートメンバーにアクセスするには、インスタンス化されていない、まだアクセス可能な鋳型(クラス)情報が必要です。

リフレクションを使ってオブジェクトのクラス情報を取得し、プライベートメンバーにアクセスする方法もありますが、鋳型(クラス)情報を直接使用することで、リフレクションにリソースを消費させずに済みます。

$newScopeはコピーされる無名関数にプロトタイプ情報をバインドします。デフォルトでは'static'という文字列になっており、クロージャメンバー内でstatic::のコードとしてアクセスできるようになります。もしクラスAを文字列として伝えてバインドすると、無名関数ブロック内でA::のコードを使うことができ、self::のselfはAを指すことになります。

無名関数にバインド対象を渡して実行する

public call(object $newThis, mixed ...$args): mixed

メソッド宣言は、バインドするオブジェクトを受け取り、クロージャを実行する際に必要な引数を受け取る形になっています。

このメソッドが静的メソッドでない理由は、$fn->call()のように、無名関数をオブジェクトとして扱い、直接->call()で実行するためです。

callable関数をクロージャに変換する

public static fromCallable(callable $callback): Closure

この部分は、callable型の呼び出し可能なものを受け取り、Closure型の関数を生成できることを示しています。

なお、callable型ヒントにClosure型を渡すことができます。しかし、Closurecallableのサブタイプではないと言われています。1

Closureの問題点

引数および戻り値の型を定義できない

引数として関数を受け取る場合、特定の型の引数を持つ関数の型を定義することができません。

PHPには関数を引数として渡せる高階関数の特性がありますが、それにもかかわらずPHPが関数型プログラミングに適さないとされる理由のひとつは、引数として受け取る関数の型を指定できない点にあります。

PHPは静的解析が行いやすい方向へと進化しています。しかし、クロージャの引数および戻り値の型を指定できないため、静的解析の対象とすることができません。そのため、本来であれば関数型の特性を活用できる場面であっても、クロージャの利用を積極的に推奨するのは難しいと言えます。

Closureクラスの継承不可

PHPにおけるClosureは言語に組み込まれたクラスです。このクラスを継承して新しいクラスを作成できれば、関数のパラメータとして関数を受け取る際に、Closureを継承して再定義したサブクラスを受け取り、特定のシグネチャを持つClosure型を扱うという考え方が可能です。

しかし、PHPでClosureクラスはfinalクラスであるため、継承して別のクラスを作ることができません。そのため、別のシグネチャを定義することもできず、Closureパラメータがどの型の引数を受け取り、どの型の戻り値を返すかを、特別なコメントや文書なしに把握することはできないという問題があります。

クロージャの型を確認できないことで生じる問題

declare(strict_types=1);

function calculate(Closure $operation, $transArg1, $transArg2): int {
    /*************
     * some code *
     *************/
    echo 'debug';
    /*************
     * some code *
     *************/
    return $operation($transArg1, $transArg2); // Fatal error: Uncaught TypeError: {closure}(): Argument #2 ($y) must be of type int, string given
}

$result = calculate(fn(int $x, int $y) => $x + $y, 3, '5');
echo $result; // 8

$operation関数は、2つの引数$x$yを受け取り、計算結果を返す関数です。しかし、calculate関数に渡す際、$operation関数に直接引数が渡されるのではなく、calculateにすべての引数が渡された後に$operation($transArg1, $transArg2)が実行されるときになってから$operation関数に引数が渡されます。echo 'debug';が正しく表示されることは、ここまではエラーが発生していないことを意味します。その後、$operation関数を実行するコードでは、1つ目の引数にint型、2つ目の引数にstring型の値が渡されます。しかし、2つ目の引数にはint型が渡されるべきところにstring型が渡されるため、エラーが発生します。

問題は、calculate関数のパラメータであるClosure $operationに引数が渡される際に、型チェックが行われない点です。渡されたクロージャは、実行されるタイミングで型エラーが発生するため、クロージャに誤った型が渡されたかどうかをすぐに確認できません。関数のロジックが長くなると、クロージャの型の問題かどうか分からず、他の原因を探すために時間を浪費することもあります。また、クロージャの仕様を知らないため、渡されたクロージャの仕様を確認するためにコードを追跡する必要があり、手間がかかります。

ここで、calculate関数にクロージャを渡す場合、$operationクロージャは2つの引数を受け取り、1つ目と2つ目の引数はint型であること、そしてcalculateの2番目と3番目の引数が$operation関数に渡される値であることを仕様として把握しておく必要があります。しかし、クロージャのパラメータ情報がないという問題があります。

このような問題を解決するためには、クロージャを使用する場合、そのシグネチャを明示する必要があります。しかし、PHPが提供する文法ではクロージャの仕様を明示することができないため、phpdocを使った表記や、ランタイムリフレクションを利用した型チェッカーを使用する必要があります。

関数名からシグネチャを推測することの限界

上記のようなcalculate関数は、関数型プログラミングではapplyという名前の関数として実装されます。しかし、関数型プログラミングで使用される関数名を活用して関数を定義できる人も、関数型プログラミングでよく使われる関数名から関数の役割や使い方を覚え出す人も少ないです。そのため、関数のパラメータの仕様をできるだけ明示することが望ましいです。

declare(strict_types=1);

function apply(Closure $operation, mixed ...$transArgs): int {
    return $operation(...$transArgs);
}

$result = apply(fn(int $x, int $y) => $x + $y, 3, 5);
echo $result; // 8

↑ apply 関数の例です。

静的解析による型確認

PHPの文法だけでは型推論ができないClosureの仕様のため、docblockを使って呼び出し可能な関数の型を指定することができます。

/**
 * @param Closure(int, int): int $operation
 */
function calculate(Closure $operation, int $transArg1, int $transArg2): int {
    return $operation($transArg1, $transArg2);
}

$result = calculate(fn($x, $y) => $x + $y, 3, 5);
echo $result; // 8

このようにして、パラメータとして渡される関数の型を指定することで、静的解析によって誤った型の指定を検出できます。強力な静的解析ツールでは型の不一致が指摘されますが、IDE上ではバグまたは導入されない機能のため指摘されない場合もあります。さらに、ランタイムで動作中に誤りが分からない問題もあるため、ランタイム時に渡された関数のパラメータの型や引数の数が意図した形の関数であるかどうかを確認するコードを追加することが望ましいです。

ランタイムでの型確認

コンパイルを行う静的型言語と異なり、動的型言語であるPHPでは、静的解析だけでコードの完全性を保証することは難しいです。PHPStanなどの静的解析ツールを最高レベルに設定して開発することも可能ですが、この開発方法では特別なPHPコーディングスタイルが要求されるため、初期設定の段階から厳格な型付けのコードを書ける環境を整える必要があります。そうでない場合、静的解析レベルを上げるために多くのコードスタイル変更やリファクタリングが求められます。また、厳密な静的解析を行うためにはphpdocの記述が必要で、多くのボイラープレートが発生します。場合によっては、一部のdocblock構文がIDEのコードチェックで反映されないこともあります。一般的なコーディングスタイルで、静的解析とランタイムでの型確認を適切に組み合わせる方法でコードを作成し、IDEの静的解析によるミスを減らし、ランタイム実行時にもう一度コードをチェックするプロセスを踏むことが望ましいです。

もしdocblockで関数のパラメータとして渡される関数の型情報を記述できる場合、さらにPHPの不完全な型検査の限界を補うために、ランタイムで型を検証する段階を追加します。渡されたクロージャがどの型を受け取るかをランタイム時点で確認するためには、リフレクションを使用する必要があります。PHPではクロージャの情報を取得できるリフレクションクラスが提供されており、これを通じてクロージャが持つ引数の型情報や戻り値の型情報を確認することができます。

function check0Target1String2IntParamStringReturn(Closure $fn): bool {
    $ref = new ReflectionFunction($fn);
    $params = $ref->getParameters();
    if (count($params) !== 3) return false;
    $param0TypeName = $params[0]->getType()->getName();
    if ($param0TypeName !== Target::class && !is_subclass_of($param0TypeName, Target::class)) return false;
    if ($params[1]->getType()->getName() !== 'string') return false;
    if ($params[2]->getType()->getName() !== 'int') return false;
    if ($ref->getReturnType()->getName() !== 'string') return false; 
    return true;
}

$repeatPropertyValue = fn(Target $target, string $property, int $iterationNumber):  string => str_repeat((string)$target->{$property}, $iterationNumber);

assert(check0Target1String2IntParamStringReturn($repeatPropertyValue));

class Target
{
    public readonly string $value;
}

$ref = new ReflectionFunction($fn);;:無名関数$fnのリフレクション情報を取得します。

$params = $ref->getParameters();:それぞれのパラメータ情報をインデックス付き配列として返します。

if (count($params) !== 3) return false;:パラメータが3つであるかを確認します。$ref->getNumberOfParameters()を使用することも可能です。

リフレクションクラスのインスタンスでgetType()を使用すると、ReflectionTypeクラスのオブジェクトが返されます。getNameメソッドで型名を確認することができます。

if ($param0TypeName !== Target::class && !is_subclass_of($param0TypeName, Target::class)) return false;:最初のパラメータがTargetクラスかどうかを確認します。型チェックではサブタイプも許容する型として考慮する必要があるため、渡された対象がサブタイプかどうかも確認します。

if ($params[1]->getType()->getName() !== 'string') return false;:2番目のパラメータが文字列型かどうかを確認します。

if ($params[2]->getType()->getName() !== 'int') return false;:3番目のパラメータが整数型かどうかを確認します。

参考:サブタイプのチェック方法

class ParentClass {}
class ChildClass extends ParentClass {}
var_dump(is_subclass_of(ChildClass::class, ParentClass::class));

assert(checkStringIntParamStringReturn($repeat));のように、ランタイムでの型チェックに活用します。

再利用可能なコードにする

パラメータを1つずつ検証できる機能を作ることで、再利用可能なコードにすることができます。

function checkClosureParam(Closure $fn, string|int $keyNameOrIdx, string ...$types): bool {
    $ref = new ReflectionFunction($fn);
    $params = $ref->getParameters();
    if (is_numeric($keyNameOrIdx)) {
    	$targetParamKey = $keyNameOrIdx;
    } else {
        $targetParamKey = array_search($keyNameOrIdx, array_map(fn($e) => $e->getName(), $params));
    }
    $targetParam = $params[$targetParamKey] ?? null;
    if (is_null($targetParam)) return false;
    $paramTypeName = $targetParam->getType()?->getName() ?? '';
    return in_array($paramTypeName, $types, true)
        || (array_reduce($types, fn($acc, $type) => $acc || is_subclass_of($paramTypeName, $type), false));
}

function checkClosureReturn(Closure $fn, string ...$types): bool {
    $ref = new ReflectionFunction($fn);
    $returnTypeObj = $ref->getReturnType();
    if ($returnTypeObj === null && (in_array('', $types, true) || in_array('null', $types, true))) return true;
    $returnTypeName = $returnTypeObj->getName() ?? '';
    return in_array($returnTypeName, $types, true) || (array_reduce($types, fn($acc, $type) => $acc || is_subclass_of($returnTypeName, $type), false));
}

$repeatPropertyValue = fn(ChildClass $target, string $property, int $iterationNumber): string => str_repeat((string)$target->{$property}, $iterationNumber);

assert(checkClosureParam($repeatPropertyValue, 'target', ParentClass::class));
assert(checkClosureParam($repeatPropertyValue, 0, ParentClass::class));
assert(checkClosureParam($repeatPropertyValue, 'property', 'string'));
assert(checkClosureParam($repeatPropertyValue, 1, 'string'));
assert(checkClosureParam($repeatPropertyValue, 'iterationNumber', 'int'));
assert(checkClosureParam($repeatPropertyValue, 2, 'int'));
assert(checkClosureParam($repeatPropertyValue, 'option', ''));
assert(checkClosureParam($repeatPropertyValue, 3, ''));
assert(checkClosureReturn($repeatPropertyValue, 'string'));

class ParentClass {}
class ChildClass extends ParentClass {}

ランタイム型チェックのために、パラメータの型を検証する機能としてcheckClosureParam、戻り値の型を検証する機能としてcheckClosureReturnを作成しました。ユニオン型をチェックできるよう、可変パラメータで複数の型を指定できるようにしています。また、型ヒントがない場合も許容するかどうかを指定できるよう、空文字列('')で型ヒントがない場合に通過できるようにしました。さらに、リスコフの置換原則に対応するため、is_subclass_ofを用いて型チェックを行っています。その後、一度作成した関数は複数のファイルで再利用できるよう、use function checkClosureParamuse function checkClosureReturnで呼び出し、クロージャの型を確認します。(use functionキーワードを使用するためには、composer.jsonのautoload設定を行う必要があります。)

ただし、この方法は型を1つずつチェックする方式であるため、ジェネリクス(型パラメータ)を利用する方法は使えないという制限があります。ジェネリクスに関する情報は、この方法ではなく、docblockのジェネリクスやユニオン型、型アサーションなどを用いて補完する必要があります。

上記コードの注意点としては、この方法では実際のランタイムで渡されるクロージャのパラメータや返却型がユニオン型の場合のロジックは実装していません。クロージャのパラメータ型と返却型が単一型の場合のみを仮定しています。ここでユニオン型を考慮したのは、パラメータ型と返却型が単一型である複数種類のシグネチャを持つクロージャを受け入れられるようにするためです。もしユニオン型のパラメータ型や返却型を持つクロージャに対する型チェックロジックを追加したい場合は、『渡されるクロージャのユニオン型それぞれが、ランタイム検査によって確認したいユニオン型の中で少なくとも一つの型と一致するか、またはサブタイプである』という条件を満たすロジックを作成することでできます。

また、実務ではほとんど使われない「intersection type」は考慮していません。ここで提示したコードは、個人の業務で使用するための最低限のロジックを作成し掲載したものであり、不要なオーバーエンジニアリングは避ける方針のため、この程度の範囲の機能のみを実装しました。

ランタイムでの確認のためのリフレクションはリソースを消費する作業なので、プロダクション環境では動作させず、assert関数を使ってローカルやテスト環境での実行確認に使用するのが適切です。

クロージャの代替案

クロージャの問題点

Laravelの各種メソッドでは、無名関数をパラメータとして使うコードが多く見られます。表面上はコードを優雅に見せることができますが、渡すべき関数の仕様をPHP言語レベルで確認できないため、公式ドキュメントなどの補助資料が必須です。十分に定義されたドキュメントやコメントがない場合、PHPの関数パラメータがどの型の引数を受け取り、どの型の戻り値を返すか分からず、再利用に適さないコードになってしまいます。

クロージャを活用するには

一般的または直感的な関数名、あるいはドキュメント

クロージャを適切に活用するためには、プログラミングで一般的に使われる関数名を通じて、どの仕様のクロージャを受け取るか分かる形式にすることが重要です。例えば、mapのようなコレクションメソッドを使用したり、追加の仕様書やコメントで、どのクロージャを渡すべきかを明示することが大切です。

Laravelのコレクションでは、配列やコレクションの各要素に適用される引数で伝えられる無名関数を使用します。この場合、無名関数はコレクションの要素の型を受け取るため、基本的に特定の型に縛られません。無名関数は、特定の型に依存する機能を持つのではなく、型に依存しないように作ることが望ましいです。たとえば、A型を関数の引数として渡すとA型を受け取り、B型を返すとチェーンされたコレクションメソッドにB型が渡される、といった直感的に理解できる設計のメソッドがあります。直感的に理解できない場合でも、各メソッドの説明がLaravelドキュメントに記載されているため参考すれば問題ないです。

引数としてクロージャが本当に必要か?

無名関数は設計したコードを柔軟に変更するために使われることが多いです。しかし、自由すぎるロジックを許すと設計意図が曖昧になる可能性があります。オブジェクトや関数に事前に設計された具体的なロジックがあり、提供される値の範囲が決まっている場合、無名関数を使う必要はなく、むしろ無名関数によるロジックの自由度によって、オブジェクトや関数の動作が意図以上に多様化し、コードの本来の設計目的やドメイン制約を超えたり無視する動作が生まれる可能性があります。

ドメインで使用される範囲に機能を制限することで、そのコードの意味や目的を明確にできます。しかし無名関数が乱用されると、作成された目的や意味が損なわれるコードが生まれることがあります。無名関数を使う際、関数内に受け取った無名関数のロジックがある程度制限されていることが望ましいです。無名関数を引数として受け取る場合でも、自由度を制限できる範囲で使用する方が適切です。

関数のパラメータとして関数を受け取るのは通常、関数型プログラミングで使われます。純粋関数であれば関数を渡しても動作の予測は容易ですが、純粋関数でなく、オブジェクト内で渡された関数に外部変数やオブジェクトをバインドする場合、オブジェクトや変数の状態を変更できるため、動作の予測が難しくなります。また、渡された関数をオブジェクトでどのように利用するかを明確にするため、オブジェクトの仕様(インターフェース、パブリックメソッド)を示す必要がありますが、示すのが難しい場合もあるため、関数をパラメータとして渡すコードを乱用するのは好ましくありません。

クロージャの代替案

メソッドを使う

クラス内部でパラメーターとしてクロージャを使い、引数として関数を渡すよりも、関数ブロック内でメソッドを呼び出すコードを記述する方が望ましいです。メソッドの場合、パラメーターの型や返り値の型をシグネチャで明示できるため、静的解析が可能なコードになります。

無名関数をパラメーターとして使用するのが適しているのは、関数のシグネチャを示さなくても使い方がすぐに分かるコードや、シンプルなコードの場合、または関数外部の変数をキャプチャして利用できる場合、特定のコンテキストだけで使うコードを記述する場合です。これらの利点が得られない場合には、メソッドを作成して静的解析が可能なコードにする方が良いでしょう。

インスタンスメソッドは$thisを多用することになり、オブジェクトの状態管理が複雑になることがあります。そのため、静的メソッドを使用し、特別な理由がない限り、静的メソッドは静的メンバーの状態共有がない純粋関数として定義すると、関数の動作を予測しやすくなります。

デザインパターンを活用する

デザインパターンを使うことで、事前に作成された特定仕様のオブジェクトに置き換えたり、設定可能なオプションを用意して必要に応じて注入できるようにコードを作る方法を学びます。関数パラメータを代替できるOOPのデザインパターンには、戦略パターン、状態パターン、テンプレートメソッドパターンなどがあります(詳細な説明はデザインパターン関連の資料に譲ります)。

  • 戦略パターン:ランタイムで差し替え可能な複数のアルゴリズム(戦略)をカプセル化し、インターフェースで抽象化して選択的に利用できるようにするパターン

  • 状態パターン:オブジェクトの状態を別クラスとして分離し、状態変更に応じてオブジェクトの動作を変更するパターン

  • テンプレートメソッドパターン:上位クラスでアルゴリズムの基本フローを定義し、一部の詳細実装を下位クラスで定義するパターン

クラスに代替して使う

クラスをオブジェクト化し、__invokeを使用してクラス自体を関数のように呼び出せるようにすれば、パラメータ型と戻り値型を定義可能です。クロージャの代わりに、特定の型のオブジェクトを渡す方法もあります。

また、ValueObjectを定義してクロージャの代わりに使う方法も有効です。ValueObjectは、ドメイン制約を持つ値を保持するだけでなく、ドメイン制約を表現するロジックを含むクラスを使う方式でクロージャを代替します。

さいごに

クロージャをパラメーターとして使用する前に、本当に使う必要があるかどうかを考えてみましょう。代替できる方法がないか検討し、どうしても必要な場合は、ランタイムで検証する方法を考えると役立ちます。

  1. https://externals.io/message/125943

3
0
0

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
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?