17
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【PHP8.6】PHPで関数の部分適用が使えるようになる

Posted at

PHP8.5でパイプライン演算子が導入されたときに「どうして毎回クロージャを書かないといけないんだ」的なことを言ったわけですが、あれは次世代への布石のためでした。

ということで部分適用のRFCが提出されています。
まだ途中ですが実装も存在しており、順調に進めばPHP8.6で導入されることになります。
コア開発者が多数参加していることもあり、導入が遅れることはあっても却下されることはまずないのではないかと思います。

部分適用が何かってざっくり書くとこんなの。

PHP8.6?
// 普通の関数
function substr5($str)
{
    return substr($str, 0, 5);
}
echo substr5('abcdefg'); // abcde

// 第一級callable
$f = substr5(...);
echo $f('abcdefg'); // abcde

// 無名関数
$f = function ($str) {
    return substr($str, 0, 5);
};
echo $f('abcdefg'); // abcde

// アロー関数
$f = fn ($str) => substr($str, 0, 5);
echo $f('abcdefg'); // abcde

// 部分適用
$f = substr(?, 0, 5);
echo $f('abcdefg'); // abcde

↑の結果は全部同じです。

以下は該当のRFC、Partial Function Application (v2)の紹介です。
RFCでは非常に細かなエッジケースも全て言及されているのですが、和訳は全部入れると見通しが悪くなるので(あと面倒)適当に端折っています。

Partial Function Application (v2)

関数の部分適用(PFA)とは、関数のパラメータのうち一部のみを指定し、残りのパラメータは後で実行するプロセスを表します。
論理的には以下の式と同一です。

function f(int $a, int $b, int $c) {}
 
$partialF = fn(int $b) => f(1, $b, 3);

とはいえ無名関数では全てのパラメータを写経する必要があり、たいへん面倒な作業です。
コールバックやパイプ演算子を組み合わせると、この厄介さは特に顕著になります。

$result = array_map(static fn(string $string): string => str_replace('hello', 'hi', $string), $arr);

$foo 
  |> static fn(array $arr) => array_map(static fn(string $string): string => str_replace('hello', 'hi', $string), $arr)

この例では型情報などのオプションもあえて全て記載していますが、実際にも型情報などを含めることは推奨されており、静的解析ツールもこれらを必須としてきます。
またオプションを省いたとしても、やはり冗長で煩雑な書式が必要となります。

本RFCで提案されている部分適用を導入することで、上記例を以下のように簡略化できます。

PHP8.6?
$result = array_map(str_replace('hello', 'hi', ?), $arr);
 
$foo 
  |> array_map(str_replace('hello', 'hi', ?), ?);

結果は同じですが、あらゆる関係者にとって人間工学的に優れた書式となります。

PFAは、多くの関数型言語にとってはコアとなる機能です。
PHPにおいては、Haskellのように言語と深く統合することは目指していませんが、PHPの関数型機能における重要な構成要素のひとつになるでしょう。

また見方を変えると、PFAはPHP8.1で導入されたFirst-class callable構文の自然な拡張であると言えます。
むしろFirst-class callableの最初の提案は、ほとんどPFAの文法になっていました。
ここしばらく、FCCはコード簡素化においてその価値を優位に示しており、それはPFAによってさらに強化されます。

このRFCでは、「関数」とはあらゆる呼び出し可能なオブジェクトのことを示します。
名前付き関数、メソッド、静的メソッド、無名関数、短縮無名関数、呼び出し可能オブジェクトなどです。
部分適用は、これら全ての関数に適用されます。

Proposal

Overview

部分適用は、一つ以上の引数を疑問符?もしくは省略記号...に置き換えた関数呼び出しで構成されます。
そのような引数があった場合、エンジンは関数を呼び出す代わりにクロージャを作成して返します。
クロージャのシグネチャは、元の関数から指定済の引数を抜いたシグネチャと一致します。
すなわち、クロージャはリフレクションを用いて検証可能であり、元となる関数の型情報を継続します。

例として、以下の関数は同等です。

// サンプル関数
function foo(int $a, int $b, int $c, int $d): int
{
    return $a + $b + $c + $d;
}

$f = foo(1, ?, 3, 4);
$f = static fn(int $b): int => foo(1, $b, 3, 4);
 
$f = foo(1, ?, 3, ?);
$f = static fn(int $b, int $d): int => foo(1, $b, 3, $d);
 
$f = foo(1, ...);
$f = static fn(int $b, int $c, int $d): int => foo(1, $b, $c, $d);
 
$f = foo(1, 2, ...);
$f = static fn(int $c, int $d): int => foo(1, 2, $c, $d);
 
$f = foo(1, ?, 3, ...);
$f = static fn(int $b, int $d): int => foo(1, $b, 3, $d);

部分適用にさらに部分適用を適用することも可能です。

Placeholder Semantics

プレースホルダーシンボルが2種類導入されます。
引数プレースホルダ?は、引数が1つであることを示します。
可変長引数プレースホルダ...は、0個以上の引数が指定可能であることを意味します。

PHP8.6
function foo(int $a = 5, int $b = 1, string ...$c) { }

// 同じ
$pfa = foo(?, ?, ?, ?);
$pfa = fn(int $a, int $b, string $c1, string $c2) => foo($a, $b, $c1, $c2);

残りの引数がない場合でも...を書くことができ、引数のないクロージャが生成されます。
これは実質的に関数呼び出しを遅延させる手段であり、このパターンはThunkと呼ばれています。

理論上は、次のような呼び出し方も有効です。

PHP8.6
function stuff(int $i, string $s, float $f, Point $p, int $m = 0) {}

// 同じ
$c = stuff(1, ?, ..., p: ?, f: 3.14);
$c = fn(string $s, Point $p, int $m = 0) => stuff(1, $s, 3.14, $p, $m);

現実的には、呼び出し方は以下の3スタイルのどれかになるでしょう。

PHP8.6
// ひとつだけ残して指定
$c = array_map(strtoupper(...), ?);
$c = array_filter(?, is_numeric(...));
 
// ひとつかふたつ程度を指定
$c = stuff(1, 'two', ...);
 
// 名前で指定
$c = stuff(..., f: 3.14, s: 'two');

Examples

例を挙げた方が簡単でしょう。
以下の例は、論理的に等価です。

普通の関数

// 関数の例
function stuff(int $i1, string $s2, float $f3, Point $p4, int $m5 = 0): string {}
 
// そのまま
$c = stuff(?, ?, ?, ?, ?);
$c = stuff(?, ?, ...);
$c = fn(int $i1, string $s2, float $f3, Point $p4, int $m5 = 0): string
  => stuff($i1, $s2, $f3, $p4, $m5);

// PHP8.1で使用可能
$c = stuff(...);
$c = fn(int $i1, string $s2, float $f3, Point $p4, int $m5 = 0): string
  => stuff($1i, $s2, $f3, $p4, $m5);

// 前二つを指定
$c = stuff(1, 'hi', ?, ?, ?);
$c = stuff(1, 'hi', ...);
$c = fn(float $f3, Point $p4, int $m5 = 0): string => stuff(1, 'hi', $f3, $p4, $m5);
 
// 前から順ではなくいくつかを指定
$c = stuff(1, ?, 3.5, ?, ?);
$c = stuff(1, ?, 3.5, ...);
$c = fn(string $s2, Point $p4, int $m5 = 0): string => stuff(1, $s2, 3.5, $p4, $m5);
 
// 最後の値だけ指定
$c = stuff(?, ?, ?, ?, 5);
$c = fn(int $i1, string $s2, float $f3, Point $p4): string 
  => stuff($i1, $s2, $f3, $4p, 5);
 
// オプション引数未指定の場合はデフォルト値が使われる
$c = stuff(?, ?, ?, ?); 
$c = fn(int $i1, string $s2, float $f3, Point $p4): string
  => stuff($i1, $s2, $f3, $p4);
 
// 名前付き引数
$c = stuff(?, ?, f3: 3.5, p4: $point);
$c = stuff(?, ?, p4: $point, f3: 3.5);
$c = fn(int $i1, string $s2): string => stuff($i1, $s2, 3.5, $point);
 
// ...は残り全て
$c = stuff(?, ?, ..., f3: 3.5, p4: $point);
$c = fn(int $i1, string $s2, int $m5 = 0): string => stuff($i1, $s2, 3.5, $point, $m5);
 
// 遅延呼び出し
$c = stuff(1, 'hi', 3.4, $point, 5, ...);
$c = fn(...$args): string => stuff(1, 'hi', 3.4, $point, 5, ...$args);
 
// 名前付き?は...の後
$c = stuff(?, p4: $point, f3: ?, s: ?, m5: 4);
$c = stuff(..., m5: 4, p4: $point, i: ?);
$c = fn(int $i1, string $s2, float $f3): string => stuff($i1, $s2, $f3, $point, 4);
可変長引数

// 関数の例
function things(int $i1, ?float $f3 = null, Point ...$points) { ... }
 
// PHP8.1で動く
$c = things(...);
$c = fn(int $i1, ?float $f3 = null, Point ...$points): string => things(...[$i1, $f3, ...$points]);
 
// いくつか指定し、可変長引数はそのまま
$c = things(1, 3.14, ...);
$c = fn(Point ...$points): string => things(...[1, 3.14, ...$points]);
 
// 4引数のうしろ2つが可変長引数に渡される
$c = things(?, ?, ?, ?);
$c = fn(int $i1, ?float $f3, Point $p1, Point $p2): string => things($i1, $f3, $p1, $p2);


// あんまり普通じゃない使用例
function four(int $a, int $b, int $c, int $d) {
    print "$a, $b, $c, $d\n";
}
// 全て"1, 2, 3, 4"
(four(...))(1, 2, 3, 4);
(four(1, 2, ...))(3, 4);
(four(1, 2, 3, ?))(4);
(four(1, ?, ?, 4))(2,3);
(four(1, 2, 3, 4, ...))();
(four(..., d: 4, a: 1))(2, 3);
(four(c: ?, d: 4, b: ?, a: 1))(2, 3);
その他の例

// 関数の例
class E {
    public function __construct(private int $x, private int $y) {}
    public static function make(int $x, int $y): self;
    public function foo(int $a, int $b, int $c): int {}
}

// シンタックスエラー
$e = new E(1, ?);

// 許される
$eMaker = E::make(1, ?);
$eMaker = fn(int y): E => E::make(1, $y);

// makeが呼び出される
$e = $eMaker(2);

// 同じ
$c = $e->foo(?, ?, 3, 4);
$c = fn(int $a, int $b): int => $e->foo($a, $b, 3, 4);

// 
$c2 = $c(1, ?);
$c2 = fn(int $b): int => $e->foo(1, $b, 3, 4);


// invoke
class RunMe {
    public function __invoke(int $a, int $b): string {}
}

$r = new RunMe();
 
$c = $r(?, 3);
$c = fn(int $a): string => $r($a, 3);

Error examples

以下はエラーになる例です。

エラー

// 関数の例
function stuff(int $i, string $s, float $f, Point $p, int $m = 0) {}
 
// 引数が足りないエラー
$c = stuff(?);
 
// 引数が多いエラー
$c = stuff(?, ?, ?, ?, ?, ?);
 
// 名前付き引数$iが重複
$c = stuff(?, ?, 3.5, $point, i: 5);

// 名前付き引数はプレースホルダの後にする必要がある
$c = stuff(i:1, ?, ?, ?, ?);
$c = stuff(?, ?, ?, p: $point, ?);

func_get_args() and friends

func_get_args()や類似の関数は、部分適用を考慮に入れません。
全ての引数が入った状態で一度だけ呼び出されます。

function f($a = 0, $b = 0, $c = 3, $d = 4) {
    echo func_num_args() . PHP_EOL;
    var_dump($a, $b, $c, $d);
}
 
f(1, 2);
$f = f(?, ?);
$f(1, 2);

/* ↓になる
2
int(1)
int(2)
int(3)
int(4)
2
int(1)
int(2)
int(3)
int(4)
*/

Extraneous arguments

PHPのユーザ関数は余分な引数も受け入れたうえで、単に無視します。
多くの場合これは便利ですが、PFAでは問題になる場合があります。

そのため、可変長引数プレースホルダを使わないかぎり、PFAクロージャは余分な引数を受け入れず無視します。

$inter = intval(?);
$inter('5');      // $baseはデフォルト値
$inter('5', '6'); // 6は完全に無視される

$variInter = intval(...);
$variInter('5');      // $baseはデフォルト値
$variInter('5', '6'); // intvalの第二引数に'6'が渡される

このアプローチが、開発者にとって最も驚きが少ないものになると考えます。

Evaluation order

既存のラムダ構文と部分適用の違いは、引数式が事前に評価されることです。

function getArg() {
  print __FUNCTION__ . PHP_EOL;
  return 'hi';
}
 
function speak(string $who, string $msg) {
  printf("%s: %s\n", $who, $msg);
}

$arrow = fn($who) => speak($who, getArg());
print "Arnaud\n";
$arrow('Larry');

/* ラムダ
Arnaud
getArg
Larry: hi
*/
 
$partial = speak(?, getArg());
print "Arnaud\n";
$partial('Larry');
 
/* 部分適用
getArg
Arnaud
Larry: hi
*/

部分適用の場合、まず引数が全て評価され、その結果としてエンジンが引数にプレースホルダがあることを検知するからです。
いっぽうラムダの場合、関数呼び出しを含む式全体をクロージャで囲います。

Magic methods

マジックメソッド__call__callStaticがサポートされます。
具体的には、マジックメソッドを部分適用で呼び出すと、そのシグネチャは部分適用で指定された引数になります。
引数には型がなく、リフレクションでは$argsで表されます。

言い換えると、マジックメソッドは暗黙的なシグネチャ(...$args)を持ち、部分適用で呼ばれた場合はそのように振る舞います。

class Foo {
    public function __call($method, $args) {
        printf("%s::%s\n", __CLASS__, $method);
        print_r($args);
    }
}
 
$f = new Foo();
$m = $f->method(?, ?);
 
$m(1, 2);
 
/* ?で部分適用
Foo::method
Array
(
    [0] => 1
    [1] => 2
)
*/
 
$m(a: 1, b: 2);
 
/* 名前付き引数で部分適用
Foo::method
Array
(
    [a] => 1
    [b] => 2
)
*/
 
// Unknown named parameter $aのエラー
$m->method(?)(a: 1);
 
// こちらはOK
$m->method(...)(a: 1);

マジックメソッド__get__setは部分適用で呼び出す方法がないので対応しません。

Constructors

コンストラクタはエンジンによって間接的に呼び出されること、ユースケースが少ないことから、newの部分適用はサポートされません。
もちろん静的ファクトリメソッドについては部分適用が完全にサポートされます。

Implementation notes and optimizations

第一級callableと同じく、部分適用は基本的にはコンパイル時実行されません。
コンパイラは、どの関数が部分適用されているか、あるいは呼び出されているかを全てのケースで完全に把握しているわけではありません。
すなわち、少なくとも一部の処理は実行時まで遅延される必要があります。

部分適用は、第一級callableで導入されたメカニズムとopcodeを基盤に実装されています。
具体的には、部分適用はVMコールスタックに対象の情報を一時的に保存し、その後その情報からクロージャを作成します。
関数呼び出しに使用されるopcodeはこのコンテキストでも再利用され、コードの重複を最小限に抑え、動作の一貫性を保証します。

呼び出される関数が既にクロージャである場合は、クロージャをネストするのではなく、引数をマージした新たなクロージャを生成します。

このプロセスは、既存の関数ロジック・インラインキャッシュ・エラー処理などを共有します。
これによって多くのリソースを追加することなく、部分関数の処理を通常の関数と合わせることができるようになりました。

テストでは、クロージャの作成は、手動でアロー関数を使用するよりわずかに遅くなることがわかりました。
作成されたクロージャの呼び出しのパフォーマンスは手動クロージャとほぼ同じです。
とはいえ、全体としてはパフォーマンスの差は無視できる程度だと予想しています。

コンパイル時に最適化できるケースがひとつ存在します。
それはPFAがパイプの右側にあり、引数がひとつだけである場合です。
この場合部分適用は最適化によって削除され、直接呼び出しに変換されます。
具体的には、パイプ演算子と以下の形式が組み合わさっていた場合は最適化されます。

foo(?)
foo(1, ?)
foo(1, a: ?)
foo(1, ...)

逆に、以下は最適化されません。

foo(..., a: 1)
foo(?, ?)
foo(?, a: ?)
foo(?, ...)

Common use cases

幅広いユースケースが存在しますが、一般的によく使用されるユースケースは3種類ほどと予想されます。

Callable reference

...を使って呼び出し可能オブジェクトからクロージャを作成する第一級callableのサポート。
これはFirst Class Callablesによって既にサポートされており、一般的に使用されています。

Unary functions

1変数関数とは、引数がひとつの関数です。
多くのコールバック関数は1変数関数が必要となりますが、これを部分適用で簡単に作成できるようになります。
パイプ演算子の右側も1変数関数を必要としますが、部分適用を使用可能です。

例としては以下のようになります。

$result = array_map(in_array(?, $legal, strict: true), $input);

Delayed execution

部分適用は、全ての引数を渡したクロージャを返すことができます。
実行に必要な引数はすべて含まれていますが、まだ実行されていません。
そのため、後で実行することができます。

このような引数のない遅延実行は"thunk"と呼ばれています。

// なんか重い処理
function expensive(int $a, int $b, Point $c) { /* ... */ }

// まだ実行されない
$default = expensive(3, 4, $point, ...);

// 必要な時だけ実行する
if ($some_condition) {
  $result = $default();
}

Reflection

部分適用はクロージャを生成するので、リフレクションAPIに変更はありません。
普通のクロージャと同様にリフレクションで扱えます。
具体的にはReflectionFunctionを使用します。

ReflectionFunctionAbstractにメソッドがひとつ追加されます。

public function ReflectionFunctionAbstract::isPartial() : bool;

Backward Incompatible Changes

互換性のない変更はありません。

Proposed PHP Version(s)

PHP8.6

感想

RFCでも述べられているように、PHP8.1で導入された第一級callable構文をほぼそのまま拡張しただけといった見た目であり、引数がひとつだけなのか複数あるのかというだけの違いに見えますね。

この構文の導入により、パイプライン演算子だけでは少々もったりしていた複数引数の関数がすっきり書けるようになり、パイプラインとの大きなシナジーがあります。

// PHP8.5
$str = random_bytes(10)
    |> fn($x) => str_repeat($x, 2)
    |> fn($x) => str_replace($x, 'c', 'z');

// PHP8.6
$str = random_bytes(10)
    |> str_repeat(?, 2)(...)
    |> str_replace(?, 'c', 'z')(...);

他にも遅延実行など便利な使用方法が増えることで、関数型PHPの使い勝手が大きく上昇します。
ただでさえなんでもできるPHPですが、今後もますます進化していくことでしょう。

ところで部分適用にも第一級callableにもパイプラインにも...を書くことができるので、ぱっと見混乱してしまいそうですね。
というか既に引数アンパックやら可変長引数やらにも使われているのでそろそろ...の過労死が心配になる昨今。

17
14
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
17
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?