PHP 任意数文字列または配列を引数として受け入れる問題

2021/09/10に公開

タイトル長いw

タイトル通り、今回は任意数の文字列または配列を引数として渡したいという問題に若干ハマっていました。例えば:

func('arg1','arg2','arg3',...) // OK
func(['arg1','arg2','arg3',...]) // もOK

ただ、配列と文字列のミックスは今回論外にします:

func(['arg1','arg2'],'arg3') // NG
func('arg1',['arg2','arg3']) // NG

splat演算子

調べてみると、phpにも...というsplat operatorがあるらしいです。任意の引数を渡すことが可能との意味であり、引数を集めて配列とする機能を持っています。ただこれだけでは目的達成できません。何が問題かというと、例えば:

function showArgs(string|array ...$args)
{
  if (is_array($args)) {
    var_dump($args);
  } else {
    echo 'not array';
  }
}

showArgs(['aa','bb']);      // 1
showArgs('aa','bb','cc');   // 2
showArgs('dd');             // 3
showArgs(['ee']);           // 4
showArgs(['aa','bb'],'cc'); // 5

これでやると、任意の数の文字列を渡すことが上手く処理できますが、配列を渡してしまうと、一つの引数として集められ、二重配列になってしまいます。

// 1
array(1) {
  [0]=>
  array(2) {
    [0]=>
    string(2) "aa"
    [1]=>
    string(2) "bb"
  }
}
// 2
array(3) {
  [0]=>
  string(2) "aa"
  [1]=>
  string(2) "bb"
  [2]=>
  string(2) "cc"
}
// 3
array(1) {
  [0]=>
  string(2) "dd"
}
// 4
array(1) {
  [0]=>
  array(1) {
    [0]=>
    string(2) "ee"
  }
}
// 5
'not array'

これだと配列を渡す時に、どういう風に配列をフラット化するかを考えましたが、マージするのが一番楽かなと:

function showArgs(string|array ...$args)
{
  $args = is_array($args[0]) ? array_merge(...$args) : $args;
  if (is_array($args)) {
    var_dump($args);
  } else {
    echo 'not array';
  }
}

出力は下記の通り:

// 1
array(2) {
  [0]=>
  string(2) "aa"
  [1]=>
  string(2) "bb"
}
// 2
array(3) {
  [0]=>
  string(2) "aa"
  [1]=>
  string(2) "bb"
  [2]=>
  string(2) "cc"
}
// 3
array(1) {
  [0]=>
  string(2) "dd"
}
// 4
array(1) {
  [0]=>
  string(2) "ee"
}
// 5
'not array'

ちなみに、この...を使って受け取った引数は、内部の関数にも同じく引き渡すことが可能です。

function showArgs(string|array ...$args)
{
  nested(...$args);
}

function nested(string|array ...$args)
{
  $args = is_array($args[0]) ? array_merge(...$args) : $args;
  if (is_array($args)) {
    var_dump($args);
  } else {
    echo 'not array';
  }
}

引数タイプチェック

ただ、多重配列かどうかを判断するには、is_array($args[0])だけを使っているので、5番のケースのように、配列と文字列が混在する場合は対処できません。

もちろん、こういう渡し方があまりないだろうと思われますが、やはり配列か文字列か、どれかに統一してほしいですね。なので、任意の数で渡された引数を対象に、データタイプが一色かどうかを判断して、全部同じタイプでない場合はエクセプションを出す、というふうに変更:

function showArgs(string|array ...$args)
{
  $arg_type = array_unique(array_map('gettype',$args));
  if (count($arg_type) > 1) {
    throw new Exception('Argument types not match');
  }
  $args = is_array($args[0]) ? array_merge(...$args) : $args;
  // ...
}

これで解決できたかと思いきや、一つ問題があります。任意の数の引数のため、0個でも実質OKとのことです。

参考として、compact関数は次のように定義されています。

 compact(array|string $var_name, array|string ...$var_names): array

つまり、一つ目の引数は必須となっていて、それ以降は任意となります。上記のコードだけでは、引数がなくても関数は実行できてしまいます。必須引数がある場合、引数を渡さないとArgumentCountError: Too few argumentsのエクセプションが出てきます。もちろん、実装の目的により、引数がなくても良い場合があるかもしれません。ただやはり、引数があることを前提に処理しているので、望ましい解決法とは言えません。

func_get_args関数

と悩んだところ、ふと思い出したのは、LaravelのRequestオブジェクト処理のhasメソッドです。こちらは次のように定義されています。

/**
 * Determine if the request contains a given input item key.
 *
 * @param  string|array  $key
 * @return bool
 */
public function has($key)
{
    $keys = is_array($key) ? $key : func_get_args();

    $input = $this->all();

    foreach ($keys as $value) {
        if (! Arr::has($input, $value)) {
            return false;
        }
    }

    return true;
}

ここのis_array($key) ? $key : func_get_args();が答えにかなり近いと思いました。配列でも良し、任意数の文字列でもよし。...にこだわる必要もありません。ただやはり、文字列と配列のミックスが渡されると少し困ります:

function showArgs(string|array $arg)
{
  $args = is_array($arg) ? $arg : func_get_args();

  if (is_array($args)) {
    var_dump($args);
    var_dump(func_get_args());
  } else {
    throw new Exception('invalid arguments');
  }
}

showArgs(['aa','bb'],'cc'); // 5
showArgs('aa',['bb','cc']); // 6

結果:

// 5 - $args
array(2) {
  [0]=>
  string(2) "aa"
  [1]=>
  string(2) "bb"
}
// 5 - func_get_args()
array(2) {
  [0]=>
  array(2) {
    [0]=>
    string(2) "aa"
    [1]=>
    string(2) "bb"
  }
  [1]=>
  string(2) "cc"
}
// 6 - $args
array(2) {
  [0]=>
  string(2) "aa"
  [1]=>
  array(2) {
    [0]=>
    string(2) "bb"
    [1]=>
    string(2) "cc"
  }
}
// 6 - func_get_args()
array(2) {
  [0]=>
  string(2) "aa"
  [1]=>
  array(2) {
    [0]=>
    string(2) "bb"
    [1]=>
    string(2) "cc"
  }
}

一番目の引数のタイプ(文字列か配列か)もよって決められるので、結果が安定しません。もちろん、ミックスを受け入れることが今回の目的ではないため、もしこれを引数のタイプチェックと合体すれば、最も目的に合致する答えになるのではないかと:


function showArgs(string|array $arg)
{
  $arg_type = array_unique(array_map('gettype',func_get_args()));
  if (count($arg_type) > 1) {
    throw new Exception('Argument types not match');
  }
  $args = is_array($arg) ? $arg : func_get_args();

  // ...
}

最終案

これでやっと解決かな、と思いましたが、また疑問ができました。それは、二つ以上の配列を渡すと、一つ目の配列しか$argsに渡すことができません。

それを解決するために、あまり美しいやり方とは言い難いですが、引数タイプが配列の場合、引数の数もチェックすればこのケースを対処できます:

function showArgs(string|array $arg) // もしくは($arg, ...$args)
{
  $all_args = func_get_args();
  $arg_type = array_unique(array_map('gettype', $all_args));
  if (count($arg_type) > 1) {
      throw new Exception("Argument types not match");
  }

  if ($arg_type[0] === 'array' && count($all_args) > 1) {
    $args = array_merge(...$all_args); // もしくはエクセプションを出す
  } else {
    $args = is_array($arg) ? $arg : $all_args;
  }
  // ...
}

ついでに、任意数の配列を受け入れるようにしました。元々は想定していませんでしたので、場合によってエクセプションに変えても良いでしょう。

また、結局func_get_args()で全ての引数を集めていますので、定義ではshowArgs($arg)のみか、もしくはshowArgs($arg, ...$args)にするか、どちらでも結果に影響はないと。

最終的に次の条件が満足されました:

  • 引数は少なくとも1つ必須
  • 任意数の配列を受け入れる
  • 任意数の文字列を受け入れる(もしくは一つのみ受け入れるように変更可能)
  • 配列と文字列の混在は受け入れない

なんやかんやで、一見簡単な問題をかなり複雑化してしまいました。配列と文字列の混在も処理する関数(compactとか)も存在しますが、今回はここで一旦引きます。またいずれそういうニーズがある時にダイブしてみようと思います。

以上です!

こっそりと補足

やっぱり、文字列と配列が混在しても対応したい!!

本当にどうしようもないですね。過去に書いた記事に関連する解決法がありますが、ここで自分を引用します:

function showArgs(string|array $arg) // もしくは($arg, ...$args)
{
    $args = array_values(flatten(func_get_args()));
    // ...
}

function flatten(array $arr, string $prefix = '')
{
    $flattened = [];
    foreach ($arr as $key => $value) {
        if (is_array($value)) {
            $flattened = array_merge($flattened, flatten($value,  "{$prefix}{$key}."));
        } else {
            $flattened[$prefix . $key] = $value;
        }
    }
    return $flattened;
}

結局なんのためにこの記事書いていたんだ(笑)??

Discussion