🧐
PHP 8.1 において名前付き引数で NULL と引数省略を区別する方法
問題
以下のようなユーザ情報を格納するテーブルを考える。
CREATE TABLE users(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL
);
このテーブルに関して,
「指定された ID のユーザの, 指定されたフィールドのみを 差分更新したい」
という要求があり,それに合わせて以下のように UserRepository
クラスを実装した。
class UserRepository
{
public function update(
int $id,
?string $name = null,
?string $description = null,
): void {
// 処理内容はダミー
$updated = [];
if ($name !== null) {
$updated['name'] = $name;
}
if ($description !== null) {
$updated['description'] = $description;
}
var_dump([
"User(ID:$id)'s updated fields" => $updated,
]);
}
}
$repository = new UserRepository();
$repository->update(1, 'Bob', "I'm Bob.");
$repository->update(2, name: 'Alice');
$repository->update(3, description: "I'm Tom.");
/*
array(1) {
["User(ID:1)'s updated fields"]=>
array(2) {
["name"]=>
string(3) "Bob"
["description"]=>
string(8) "I'm Bob."
}
}
array(1) {
["User(ID:2)'s updated fields"]=>
array(1) {
["name"]=>
string(5) "Alice"
}
}
array(1) {
["User(ID:3)'s updated fields"]=>
array(1) {
["description"]=>
string(8) "I'm Tom."
}
}
*/
テーブルのフィールドが全て NOT NULL
である場合はこれで問題無かった。
では,以下のように一部のフィールドが NULL
を取り得る場合,
「NULL
と省略したことを区別したい」
という要望が発生するが,どうすれば良いだろうか?
CREATE TABLE users(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT -- ここが NULL 許容になった!
);
enum
でデフォルト引数識別用の型を作ってデフォルト値に充てる
最適解: enum
の本来の使い方としては,マジックナンバー的な使い方をされる int
や string
に型安全性をもたらすような用途が挙げられるが,なんとこんな使い方もできる。
enum Arg
{
case Identity;
}
class UserRepository
{
public function update(
int $id,
string|Arg $name = Arg::Identity,
null|string|Arg $description = Arg::Identity,
): void {
$updated = [];
if ($name !== Arg::Identity) {
$updated['name'] = $name;
}
if ($description !== Arg::Identity) {
$updated['description'] = $description;
}
var_dump([
"User(ID:$id)'s updated fields" => $updated,
]);
}
}
長所
- 型レベルで完全に識別可能な値を使えている。
-
===
!==
で素直に比較できる。 - 意図が伝わりやすい。
短所
無し
class
でデフォルト引数識別用の型を作ってデフォルト値に new
で充てる
別解: final class Identity
{
}
class UserRepository
{
public function update(
int $id,
string|Identity $name = new Identity(),
null|string|Identity $description = new Identity(),
): void {
$updated = [];
if (!$name instanceof Identity) {
$updated['name'] = $name;
}
if (!$description instanceof Identity) {
$updated['description'] = $description;
}
var_dump([
"User(ID:$id)'s updated fields" => $updated,
]);
}
}
長所
- 型レベルで完全に識別可能な値を使えている。
短所
- インスタンスが1個1個異なるので,
instanceof
を使わないと比較できない。 -
new
がノイズになって,意図が認識しにくい。 - PHP 8.1 縛りが何れにせよかかるので,
enum
案に対してのメリットが無い。
func_get_args()
と名前付き引数の関係
おまけ: enum Arg
{
case Identity;
}
class UserRepository
{
public function update(
int $id,
string|Arg $name = Arg::Identity,
null|string|Arg $description = Arg::Identity,
): void {
var_dump(func_get_args());
}
}
$repository = new UserRepository();
$repository->update(1, 'Bob', "I'm Bob.");
$repository->update(2, name: 'Alice');
$repository->update(3, description: "I'm Tom.");
/*
array(3) {
[0]=>
int(1)
[1]=>
string(3) "Bob" <-- わかる
[2]=>
string(8) "I'm Bob." <-- わかる
}
array(2) {
[0]=>
int(2)
[1]=>
string(5) "Alice" <-- わかる
}
array(3) {
[0]=>
int(3)
[1]=>
enum(Arg::Identity) <-- えっ,君なんでいるの?
[2]=>
string(8) "I'm Tom." <-- わかる
}
*/
初見だとちょっと意外な結果に見えるかもしれないが,これもマニュアルでしっかり説明されている。
ここまでは PHP 7.4 までの常識。引数が指定された部分までが入ってきて,指定しなかった部分以降は入ってこない。問題は以下の部分。
「名前付き引数が無かった時代だったらこう書く」 というコードに置き換えた上で従来の動きを再現する,という動きになっているようだ。互換性を維持するためには最良の選択だったのだろうか…
おまけ: PHP 7.4 までの機能で何とかして頑張る
絶対に被らない(被らないとは言ってない)値を用意する
final class Identity
{
// 文字列専用。 int 型は?とか言われても知りません
public const STR = '_________IDENTITY__________';
}
class UserRepository
{
public function update(
int $id,
string $name = Identity::STR,
?string $description = Identity::STR
): void {
$updated = [];
if ($name !== Identity::STR) {
$updated['name'] = $name;
}
if ($description !== Identity::STR) {
$updated['description'] = $description;
}
var_dump([
"User(ID:$id)'s updated fields" => $updated,
]);
}
}
$repository = new UserRepository();
$repository->update(1, 'Bob', "I'm Bob.");
$repository->update(2, 'Alice');
$repository->update(3, Identity::STR, "I'm Tom.");
つらい。やめようね。
連想配列 + ArrayShape 記法に逃げる
多分 PHP 7.4 までなら一番マシな方法…
PHPStan や PhpStorm で ArrayShape 記法 対応が入っていればどうにかなる。
class UserRepository
{
/**
* @param array{name?: string, description?: ?string} $params
*/
public function update(
int $id,
array $params
): void {
$updated = [];
if (array_key_exists('name', $params)) {
$updated['name'] = $params['name'];
}
if (array_key_exists('description', $params)) {
$updated['name'] = $params['description'];
}
var_dump([
"User(ID:$id)'s updated fields" => $updated,
]);
}
}
$repository = new UserRepository();
$repository->update(1, ['name' => 'Bob'], ['description' => "I'm Bob."]);
$repository->update(2, ['name' => 'Alice']);
$repository->update(3, ['description' => "I'm Tom."]);
Discussion
Python界隈でも結構この問題は取り沙汰されていて(デフォルト引数のNoneと明示的に渡すNoneが区別できない: 手前味噌ですが→記事)、sentinel object (オブジェクトのidが一意に定まり、シングルトンとして常に比較できるもの)を使うのがデファクトスタンダードとなっています。
標準ライブラリでも割と出てくるidiomなのですが、適当にobject()などを使うとデバッグ時の情報が雑になったり、かと言って拙著のように表示や使用感を凝ってしまうと冗長に書かざるを得ない問題があるので、標準入りも検討されています。(検討されているリストの中にmpywさんがこの記事で使っているenumを使う方法も載っています)
結局今のところ下の議論で落ち着いている案はjavascriptのSymbolとほぼ同じでは、、?っていう形になっている気がしますが、そこは一旦置いておいて、これらの議論も参考にしてもらえるといいなと思いました!
PHPに用意されている選択肢の中では、enumが一番良さそうに思います!
一方で注意点として、一意な値の識別に列挙するための型を使っているため、型チェックの部分が安全ではない方向に開いていることがありますね。(Argで受けてArg::Identityと比較しているため、Argのcaseが増えると型安全ではなくなります)
Argは拡張されないべき型なのでコメントに残しとくとかなのかなーと思いますが、静的な型検査でenumのリテラル値をチェックできるようになったら導入したほうが良いでしょう。(Argで受けるのではなくArg::Identityで受ける)