본문 바로가기
JLPT

【PHP8.0】PHPに名前付き引数が実装される

by 엘리후 2021. 6. 27.

全ぺちぱーが待ち望んでいた機能がついに来まするよ。

変な関数

function hoge($foo = null, $bar= false, $baz=0, $qux = ''){}

こんな関数があったとして、4番目の引数だけ変更したい、他はデフォルトのままでいいという場合、現在のPHPではいちいちデフォルト値を調べて与えないといけません。

めんどう

hoge(null, false, 0, 'fuga');

この問題解決のために、これまで幾つものRFCが立ち上がっては却下されてきました。

しかし今回この問題に、ついに決定的な解決策が提供されました。

名前付き引数

hoge(qux: 'fuga');

C#とかPythonとかの名前付き引数とだいたい同じです。

以下は該当のRFC、Named Argumentsの日本語訳です。

Named Arguments


Introduction


名前付き引数では、引数の順番ではなく、引数の名前に基づいて関数に引数を渡すことができます。

これにより、引数の意味が自己文書化され、引数の順序に囚われないようになり、デフォルト値を任意にスキップすることができるようになります。

// これまで array_fill(0, 100, 50); // 名前付き引数 array_fill(start_index: 0, num: 100, value: 50);

名前付き引数に渡す引数の順番は任意です。

上の例では関数シグネチャと同じ順番でしたが、異なる順番で渡すことも可能です。

array_fill(value: 50, num: 100, start_index: 0);

名前付き引数と通常の引数を組み合わせることも可能であり、オプション引数の一部のみを順番に関係なく指定することができます。

htmlspecialchars($string, double_encode: false); // ↑同じ↓ htmlspecialchars($string, ENT_COMPAT | ENT_HTML401, 'UTF-8', false);

What are the benefits of named arguments?


Skipping defaults

名前付き引数の明白な利点のひとつは、上記htmlspecialcharsの例に見て取れます。

変更したい引数までの間にある引数すべてにデフォルト値を指定する必要がなくなります。

名前付き引数を使用すると、変更したい引数だけを直接指定することができます。

これはskipparamsのRFCと似ていますが、名前付き引数のほうが意図がより明確になります。

htmlspecialchars($string, default, default, false); // どっちがわかりやすい? htmlspecialchars($string, double_encode: false);

たまたまhtmlspecialcharsの引数を全て暗記でもしていないかぎり、最初の例では最後のfalseが何を表しているのかわかりませんが、後者の例であればその意味が見ればわかります。

Self-documenting code

引数を省略しない場合でも、コードを自己文書化するメリットはあります。

array_slice($array, $offset, $length, true); // 同じ array_slice($array, $offset, $length, preserve_keys: true);

2つめの例がなかったとしたら、array_sliceの4番目のtrueが何を意味しているのかきっとわからないでしょう。

Object Initialization

プロパティ宣言を簡潔にするオブジェクト初期化子のRFCからひとつ例を取ってみます。

// Part of PHP AST representation class ParamNode extends Node { public function __construct( public string $name, public ExprNode $default = null, public TypeNode $type = null, public bool $byRef = false, public bool $variadic = false, Location $startLoc = null, Location $endLoc = null, ) { parent::__construct($startLoc, $endLoc); } }

コンストラクタは特に、引数の数が通常よりも多く、その順番は特に重要ではなく、一般的にデフォルト値がついていることが多いです。

オブジェクト初期化子はクラスの宣言をよりシンプルにしますが、クラスのインスタンス化は全くシンプルにしません。

オブジェクトの構築をより容易なものにしようとする試みは、Object InitializerCOPAなど、これまで幾度も行われてきました。

しかし、コンストラクタやprivateプロパティとの相性が悪く、言語にうまく統合することができなかったため、これらは全て却下されてきました。

名前付き引数は、既存の言語セマンティクスとうまく統合され、副作用としてオブジェクトの初期化問題を解決します。

new ParamNode("test", null, null, false, true); // ↑は↓こうなる new ParamNode("test", variadic: true); new ParamNode($name, null, null, $isVariadic, $passByRef); // ↑↓どっちだったっけ? new ParamNode($name, null, null, $passByRef, $isVariadic); // どっちだったとしても大丈夫 new ParamNode($name, variadic: $isVariadic, byRef: $passByRef); // 順番なんてもはやどうでもいい new ParamNode($name, byRef: $passByRef, variadic: $isVariadic);

オブジェクト初期化の際の名前付き引数の利点は、一見他の関数と同じですが、実際にはより利点が明確になるでしょう。

Type-safe and documented options

名前付き引数がなかったころの一般的な対策のひとつは、オプションを配列にすることです。

さきほどの例は、オプション配列を使うことで以下のように書き換えることができます。

class ParamNode extends Node { public string $name; public ExprNode $default; public TypeNode $type; public bool $byRef; public bool $variadic; public function __construct(string $name, array $options = []) { $this->name = $name; $this->default = $options['default'] ?? null; $this->type = $options['type'] ?? null; $this->byRef = $options['byRef'] ?? false; $this->variadic = $options['variadic'] ?? false; parent::__construct( $options['startLoc'] ?? null, $options['endLoc'] ?? null ); } } // 使い方 new ParamNode($name, ['variadic' => true]); new ParamNode($name, ['variadic' => $isVariadic, 'byRef' => $passByRef]);

これも動作しますし、現在すでに使用可能ですが、デメリットも多く存在します。

・せっかくのオブジェクト初期化子が利用できない。

・オプション配列は関数シグネチャに記載されないため、何が使えるかは実装やPhpdocを見ないとわからない。またPhpdocにはオプション配列を表記する統一方法がない。

・オプション配列の値の型は型宣言で検証することができない。

・よほど厳格にチェックしないかぎり、未知のオプションを渡してもそのまま動いてしまう。

・最初からオプション配列を導入していないかぎり、後からオプション配列の引数に切り替えることは難しい。

名前付き引数であれば、オプション配列と同程度のメリットを、デメリットなく受容することができます。

Attributes

Phpdocアノテーションにおいては名前付き引数の使用は既に広まっています。

アトリビュートのRFCではPhpdocアノテーションをファーストクラス言語機能に取り入れていますが、名前付き引数には対応していません。

すなわちアノテーションをアトリビュートに移行するためには、構造を変更しなければならないことを意味します。

たとえばSymfonyのRouteアノテーションは多くのオプショナル引数を受け取ります。

現在、アノテーションのアトリビュートへの移行は以下のようになります。

/** * @Route("/api/posts/{id}", methods={"GET","HEAD"}) */ public function show(int $id) { ... } // ↑は↓こうならざるを得ない <<Route("/api/posts/{id}", ["methods" => ["GET", "HEAD"]])>> public function show(int $id) { ... }

名前付き引数を導入することで、移行前と全く同じ構造でアトリビュートを指定することが可能になります。

<<Route("/api/posts/{id}", methods: ["GET", "HEAD"])>> public function show(int $id) { ... }

ネストされたアノテーションのサポートが不十分なため完全に同じとまではいきませんが、移行をよりスムーズにすることはできます。

Proposal


Syntax

名前付き引数は、引数名にコロンを続けて値を渡します。

callAFunction(paramName: $value);

引数名として予約済キーワードも使用可能です。

array_foobar(array: $value);

引数名は固定値でなければならず、動的に指定することはできません。

// だめ function_name($variableStoringParamName: $value);

定数もサポートされません。

なぜならfunction_name(FOO: $value)の変数名はそのままFOOなのか、それとも定数FOOの値なのかが区別できないからです。

引数名を動的に指定する別の方法については後述します。

技術的に実現可能な代替構文が幾つか存在します。

function_name(paramName: $value); // (1) RFCの提案 function_name(paramName => $value); // (2) function_name(paramName = $value); // (3) function_name(paramName=$value); // (3) 3のバリエーション function_name($paramName: $value); // (4) function_name($paramName => $value); // (5)

以下の構文は、現在既に正しいコードであるため使用できません。

function_name($paramName = $value);

このRFCの旧バージョンでは、バリアント2の構文を使用していました。

しかし、実際に導入してみると問題が多く、人間に優しくないことがわかりました。

より良いかもしれない追加の構文については、Future Scopeセクションを参照してください。

Constraints

ひとつの関数で位置引数と名前付き引数の両方を指定することができますが、名前付き引数は位置引数の後ろに来なければなりません。

// OK test($foo, param: $bar); // コンパイルエラー test(param: $bar, $foo);

同じ引数を複数回渡すと例外が発生します。

function test($param) { ... } // Error: Named parameter $param overwrites previous argument test(param: 1, param: 2); // Error: Named parameter $param overwrites previous argument test(1, param: 2);

ひとつめは同じ名前を2回指定している単純なミスです。

ふたつめは、位置引数と名前付き引数が両方とも同じ引数を指しているため、これもエラーです。

後述する可変変数を除いて、存在しない引数を指定すると例外になります。

function test($param) { ... } // Error: Unknown named parameter $parma test(parma: "Oops, a typo");

Variadic functions and argument unpacking

可変長引数...$argsで定義された関数では、名前付き引数も$argsに集約されます。

名前付き引数は常に位置引数の後ろになり、順番は引数に渡された順です。

function test(...$args) { var_dump($args); } test(1, 2, 3, a: 'a', b: 'b'); // [1, 2, 3, "a" => "a", "b" => "b"]

引数アンパックは名前付き引数にも対応しています。

$params = ['start_index' => 0, 'num' => 100, 'value' => 50]; array_fill(...$params);

文字列キーの値は名前付き引数として扱われます。

整数キーは通常の位置引数として扱われ、整数値は無視されます。

整数でも文字列でもないキー(イテレータでのみ現れることがある)はTypeErrorになります。

引数アンパックも、名前付き引数は位置引数の後ろになければならないという制限に従います。

以下の呼び出しはどちらも例外になります。

// どちらもエラー array_fill(...['start_index' => 0, 100, 50]); array_fill(start_index: 0, ...[100, 50]);

さらに引数アンパックは、...の後ろに引数を追加することができないという、元からあった制限も受け継ぎます。

test(...$values, $value); // Compile-time error (as before) test(...$values, paramName: $value); // Compile-time error

可変長引数と引数アンパックのよくある使い方のひとつは、引数の転送です。

function passthru(callable $c, ...$args) { return $c(...$args); }

可変長引数も引数アンパックも既に名前付き引数をサポートしているので、このパターンは名前付き引数が導入されても動作は変わりません。

func_get_args() and friends

func_*()系の関数では、全ての引数が位置引数として渡されたかのように扱われ、未指定の引数はデフォルト値になります。

たとえば以下のようになります。

function test($a = 0, $b = 1, $c = 2) { var_dump(func_get_args()); } test(c: 5); // array(3) { [0] => 0, [1] => 1, [2] => 5 } // ↓と全く同じ test(0, 1, 5);

func_num_args()とfunc_get_arg()も、func_get_args()と一致する動作となっています。

3つの関数は全て未知の名前付き引数を無視します。

func_get_arg()は値を返さず、func_num_args()はカウントしません。

未知の名前付き引数は直接値にアクセスした場合にのみアクセス可能です。

call_user_func() and friends

call_user_func()やcall_user_func_array()などの呼び出し転送を行う関数も、名前付き引数をサポートします。

$func = function($a = '', $b = '', $c = '') { echo "a: $a, b: $b, c: $c\n"; } // 全部同じ $func('x', c: 'y'); call_user_func($func, 'x', c: 'y'); call_user_func_array($func, ['x', 'c' => 'y']);

これらの呼び出しも他と同様の制限を受けます。

たとえば、名前付き引数の後ろに位置引数を書くことはできません。

call_user_func_array()は微妙に後方互換性がありません。

以前は、配列のキーは完全に無視されていましたが、今後は文字列キーが引数名として解釈されるようになります。

call_user_funcを基本形として、ReflectionClass::newInstance()やReflectionClass::newInstanceArgs()など類似の関数にも同じ対応が適用されます。

__call()

__invoke()と異なり、__call()と__callStatic()マジックメソッドは名前付き引数の有無で動作を区別しません。

最大限の互換性を保つため、__call()は可変長引数と同様に引数の配列として渡します。

class Proxy { public function __construct( private object $object, ) {} public function __call(string $name, array $args) { // $name == "someMethod" // $args == [1, "paramName" => 2]; $this->object->$name(...$args); } } $proxy = new Proxy(new FooBar); $proxy->someMethod(1, paramName: 2);

Attributes

アトリビュートも名前付き引数をサポートします。

<<MyAttribute('A', b: 'B')>> class Test {}

通常の呼び出しと同様、名前付き引数の後に位置引数を渡すとコンパイルエラーになり、同じ名前の引数を複数回渡すとコンパイルエラーになります。

ReflectionAttribute::getArguments()は、位置引数と名前付き引数を可変長引数と同じ形式で返します。

var_dump($attr->getArguments()); // array(2) { // [0]=> // string(1) "A" // ["b"]=> // string(1) "B" // }

ReflectionAttribute::newInstance()メソッドも、通常の呼び出しと同じ規則で名前付き引数をコンストラクタに渡します。

Parameter name changes during inheritance

現在のところ、引数名は継承の対象ではありません。

これは位置引数だけを使用する場合は合理的でした。

引数名は呼び出し側には無関係だからです。

名前付き引数ではここが変更になります。

継承したクラスが引数名を変更すると、名前付き引数の呼び出しが失敗する可能性があり、リスコフの置換原則に違反します。

interface I { public function test($foo, $bar); } class C implements I { public function test($a, $b) {} } $obj = new C; // インターフェイスに従ったのにエラー $obj->test(foo: "foo", bar: "bar"); // ERROR!

他の言語ではこの問題をどのように処理しているかを、ここで詳細に分析しています。

結果をまとめると、

・PythonとRubyは引数名の変更を許可し、呼び出し時にエラー

・C#とSwiftはオーバーロード。オーバーライドするとエラー。PHPはオーバーロードできないため真似できない。

・Kotlinは引数名変更時に警告を出し、呼び出すとエラー。

多くの言語が名前付き引数を後付けで実装しているため、既存のコードに影響を出さないように、引数名の違いを無条件で警告することは賢明ではないと考えられます。

このRFCでは、PythonやRubyのモデルに従うことを提案しています。

すなわち、PHPは継承時の引数名変更を許容しますが、その結果、引数名を変更したメソッドを呼び出すと実行時例外が発生する可能性があります。

静的解析ツールやIDEは、引数名の不一致に警告を出し、その警告を抑制可能にすることが推奨されます。

これは、名前付き引数が多くのメソッドにはあまり関係なく、名前の変更された引数を使用しても問題にしないことを認める、実用面から見たアプローチです。

offsetGet()のようなメソッドを名前付き引数で呼び出すことは考えにくいですし、継承時に同じ引数名を強制するメリットもありません。

前述のように、幾つかの他言語でもこのアプローチが採用されており、特にPythonは名前付き引数の使用が多い言語です。

このようなアプローチが実際にそれなりにうまく機能しているという実証になるでしょう。

このRFCには含まれていませんが、今後必要性が高くなった場合の代替案がAlternativesセクションにて開設されています。

Internal functions

歴史的に、内部関数には引数のデフォルト値という統一された概念がありませんでした。

どの引数がオプションであるかは外からわかりますが、デフォルト値は実装が仕様であり中を見ないことにはわかりません。

PHP8.0以降、内部関数には外部から取得できるデフォルト値を定義することが可能になりました。

そしてPHPにバンドルされている内部関数では既に対応されています。

このRFCは、そのデフォルト値の情報に基づいています。

スキップされた引数は、関数を呼び出す前にデフォルト値に置き換えられます。

しかし、全ての引数に対してデフォルト値という概念を適用することはできません。

例えば以下のようなものです。

function array_keys(array $arg, $search_value = UNKNOWN, bool $strict = false): array {}

array_keys()関数は$search_valueが存在するか否かによって、根本的に異なる動作をします。

$search_valueに渡すことのできるデフォルト値というものはなく、この場合は引数を渡さないのと同じ動作になります。

このような引数はUNKNOWNと表し、引数をスキップすると例外が発生します。

// こっちはOK array_keys($array, search_value: 42, strict: true); // Error: Argument #2 ($search_value) must be passed explicitly, because the default value is not known array_keys($array, strict: true);

$search_valueを指定せずに$strictを指定しても意味がないので、これは想定された動作だといえます。

このアプローチの欠点は、動作させるためにデフォルト値の情報を提供しなければならないことです。

まだこの情報を提供していないサードパーティの拡張モジュールは、名前付き引数は動作しますが、引数のスキップはサポートしません。

このRFCの以前のバージョンで行おうとしていた代替案は、未定義値をスタック上に残し、内部パラメータ解析機構(ZPP)によって適切に解釈させることでした。

これは概ね動作しますが、一部のケース、特に明示的にZEND_NUM_ARGS()を使って引数カウントしている場合は、動作を誤ったりクラッシュする可能性もあります。

Documentation / Implementation mismatches

現在、ドキュメントの引数名と実装上の引数名は、常に一致しているとはかぎりません。

このRFCが受理された場合は、この両者の引数を同期させます。

これには、引数名の命名ガイドラインを作成することも含まれます。

Internal APIs

上記のとおり、名前付き引数は内部関数においてはほぼ透過的です。

内部関数は、呼び出しが名前付き引数で行われたかどうかにかかわらず、通常の位置引数で実行されます。

そのため、ほとんどの場合はコードの修正は必要ありません。

特殊なケースとして可変長引数があります。

これは未知の名前付き引数をextra_named_paramsに保存し、ZEND_CALL_HAS_EXTRA_NAMED_PARAMSを立て、execute_dataをコールします。

ほとんどの内部関数は、この情報を使用して有用なことを行うことはできないため、Z_PARAM_VARIADICやZ_PARAM_VARIADIC_EXマクロを使う関数は、未知の名前付き引数に遭遇すると自動的にArgumentCountErrorをスローします。

array_merge([1, 2], a: [3, 4]); // ArgumentCountError: array_merge() does not accept unknown named parameters

未知の名前付き引数を受け入れたい関数は、これらのかわりにZ_PARAM_VARIADIC_WITH_NAMEDマクロを使用する必要があります。

zval *args; uint32_t num_args, HashTable *extra_named; ZEND_PARSE_PARAMETERS_START(0, -1) Z_PARAM_VARIADIC_WITH_NAMED(args, num_args, extra_named) ZEND_PARSE_PARAMETERS_END();

zend_call_function()は、zend_fcall_info構造体に新しいフィールドを追加することで、名前付き引数をサポートするよう拡張されました。

typedef struct _zend_fcall_info { /* ... */ HashTable *named_params; } zend_fcall_info;

zend_fcall_info構造体をサポートしている初期化関数を使わず手動で初期化する場合は、このフィールドをNULLで初期化する必要があります。

Backwards incompatible changes


狭義には、このRFCにはひとつだけ後方互換性のない変更があります。

call_user_func_array()の引数の文字列キーは、これまでは無視されていましたが、名前付き引数として解釈されるようになります。

それに加え、名前付き引数を想定していないコードで名前付き引数を使用した場合に発生する可能性のある潜在的な問題が2点あります。

ひとつめは、引数名が重要なものとなったので、継承で変更すべきではありません。

そのような変更を行う既存のコードは、実質的に名前付き引数と互換性がないかもしれません。

第二に、可変変数による未知の名前付き引数をうまく扱えないかもしれません。

ほとんどの場合は単に無視されるだけなので無害です。

Alternatives


代替案。

このRFCには含まれません。

To named arguments

私の知っている名前付き引数の実装には2種類の代替案があり、以下に簡単に説明します。

ひとつめは、名前付き引数をオプトインにすることです。

このRFCでは全ての関数・メソッドを名前付き引数で呼び出すことができますが、明示的なオプトインを必要とすることで引数名の変更の問題などを回避することができます。

このアプローチの大きな欠点は、もちろん、名前付き引数が既存のコードで使えないことです。

これはこの機能にとって大きな損失であり、もはや導入する価値がないと考えています。

名前付き引数を望まない機能のために<<NoNamedArgs>>のようなオプトアウトメカニズムを用意する方が、まだ建設的だと思います。

例としてArrayAccessインターフェイスなどがあり、これは直接呼び出されることはなく、実装ごとに引数名が異なるのが普通です。

To parameter name changes during inheritance

このRFCでは、継承時の引数名の変更を黙認します。

これは実用的ですが、引数名を変更されている子オブジェクト上でメソッドを呼び出した際に、エラーが発生する可能性があります。

代替として、親メソッドでの引数の使用も許可する案があります。

interface I { public function test($foo, $bar); } class C implements I { public function test($a, $b) {} } $obj = new C; // C::test()と合ってる $obj->test(a: "foo", b: "bar"); // 動く // I::test()と合ってる $obj->test(foo: "foo", bar: "bar"); // こっちもOKにしてしまう

親クラスには引数名fooとbarに対応したメソッドがあるため、実際にはaとbとして解釈します。

これにより、このメソッドは疑似的にLSPを満たすようになります。

親メソッドの引数はエイリアスとして登録されますが、特定のシグネチャには縛られません。

従って、異なるシグネチャを混ぜて使うことも可能です。

もちろん推奨はされません。

$obj->test(a: "foo", bar: "bar"); // これも動く

設計的には、この呼び出しは禁止した方がよいでしょうが、技術的にも性能的にも対応するコストを書ける意味はなさそうです。

この方式にはひとつ問題があります。

以下はどう解釈すればよいでしょうか。

interface I { public function test($foo, $bar); } class C implements I { public function test($bar, $foo) {} }

この場合、LSPの継承チェックは単に致命的エラーを出します。

この制限は、実は引数名の変更を禁止するよりも影響が少ないと思われます。

Composerライブラリのトップ2000を調査した結果はhttps://gist.github.com/nikic/6cc9891381a83b8dca5ebdaef1068f4dで見ることができます。

Future Scope


この項目は今後の展望であり、このRFCには含まれていません。

Shorthand syntax for matching parameter and variable name

特にコンストラクタでは、同じ名前のプロパティに引数を代入することが一般的です。

new ParamNode( name: $name, type: $type, default: $default, variadic: $variadic, byRef: $byRef );

言語によっては、同じ名前を2回繰り返すことを避けるために特別な構文を提供しているものもあります。

名前付き引数の代替案に応じて、各省略構文がどのようになるかを見てみましょう。

new ParamNode(:$name, :$type, :$default, :$variadic, :$byRef); new ParamNode(=$name, =$type, =$default, =$variadic, =$byRef); new ParamNode(=> $name, => $type, => $default, => $variadic, => $byRef);

Positional-only and named-only parameters

このRFCの有用な拡張として考えられるものは、位置引数としてしか使用できない引数、または名前付き引数としてしか使用できない引数を定義することです。

これは主にAPI設計者にとって、より自由度が高くなります。

位置引数のみの引数は名前を自由に変更することができ、名前付き引数のみの引数は順番を自由に入れ替えることができます。

Vote


投票は2020/07/24まで、投票の2/3の賛成で受理されます。

2020/07/13時点では賛成35、反対14で賛成が多めです。

というか投票開始直後は賛成9反対8とかで却下圏内だったんですよね。

驚きのあまり普段滅多にしない応援ツイートなんてやってしまいましたよ。

その後は応援ツイートのおかげで持ち直し1ましたが、それでもまだ簡単にひっくり返る程度の票差です。

投票権を持ってる人はYESに投票しよう!

感想


先日Nikitaがプルリクを出してからずっと注目してたRFCなんですよね。

正直満場一致かそれに近い票数になると思ってたので、これだけ反対票が多いのはかなり意外でした。

やはり細かいとはいえ互換性がなかったり動作が微妙だったりするところが気になったりしたのでしょうか。

call_user_func_array()なんて使うんじゃねえ、と言えればいいのでしょうがそうにも行きませんしね。

ということで、おそらくPHP8.0からは名前付き引数が使用可能になるはずです。

何が便利って、呼び出し側で引数の意味がすぐにわかるのが大きいです。

なにしろPHPには引数の順番が異なる関数なんかがたくさんあったりしますからね。

strposは全文字列が1番目、検索対象文字列が2番目なのにpreg_matchは全文字列が2番目で検索対象文字列が1番目だったりとか。

array_*系なんてぐちゃぐちゃですしね。

これが$pos = strpos(heystack:$heystack, needle:$needle); preg_match(subject:$subject, pattern:$pattern, matches:$matches);のようにわかりやすく書けるようになるのは素晴らしいことです。

可変長引数や引数アンパックと組み合わせるとややこしいので、それは単純に避けたほうがよいでしょう。

댓글