何をやりたいか
PHPのarrayってめっちゃ便利ですよね。
ただし、タイトルどおりではあるのですが、arrayは使いたいが、PHPでせっかく型の指定ができるようになったのに、なにもかもarray型で指定するって気持ち悪くない? というエントリーです。
最近のPHPは、関数の引数だけでなく、戻り値に対しても型の指定ができるようになったのですが、データのやり取りに利用するデータ型はarray型が非常に扱いやすいにも関わらず、array型はその中身がどうなっているのかを何も保証しないという問題があり、全部arrayで型を指定してしまうのは気持ち悪いなというのが発端。
arrayを(ほぼ)arrayのまま簡単に別の型名をつけて利用できるようになれば嬉しいなぁというお話。
array型をそのまま使うと気持ち悪い例
適当なCSVファイルに含まれたユーザー別のスコアデータの中から1番成績の良いデータを抽出する例。
sqliteに打ち込むべきと言われたらその通りですが、、、。
<?php
/**
指定されたファイルを開いて、1行目をタイトル行として連想配列に読み込んで返却する
*/
function readCsvData(string $filename): array
{
// 実装は省略
return [
[
"id" => 1,
"name" => "Taru8",
"score" => 50,
],
[
"id" => 2,
"name" => "Taru9",
"score" => 100,
],
];
}
/**
scoreのmax()を出して、一致したレコードを一つ返却するものとする
input : $score_array: readCsvData()の戻り値を想定する
*/
function getHighScorePlayer(array $score_array): array
{
// 実装は省略
return [
"id" => 2,
"name" => "Taru9",
"score" => 100,
];
}
$score_array = readCsvData("./score_file.csv");
$high_score_player = getHighScorePlayer($score_array); // この関数の引数はreadCsvData()の戻り値である必要があるが、関数の定義上はこれを担保していない。
echo "ハイスコアは".$high_score_player["name"]."さんで".$high_score_player["score"]."点でした。\n";
どこが気持ち悪いかの解説
getHighScorePlayer()の引数が、readCsvData()の戻り値ではない場合の動作を担保していない。
function readCsvData(string $filename): array
にて、この関数の戻り値の型がarrayであることは担保されている。
function getHighScorePlayer(array $score_array): array
にて、この関数の引数がarrayであることは担保されている。
しかし、コード上、getHighScorePlayer()の引数は、必ずreadCsvData()を利用して作成されたarrayでなければならないことが担保されていない為、別のarrayを渡してもなんとなく動いてしまったり、予期しないエラーを発生させる可能性がある。
readCsvData()
の戻り値の型とgetHighScorePlayer()
の引数の型が一致していることを担保したい。
今回敢えて忘れておくこと。
ただし、よく訓練されたPHPerにとっては、関数の引数の型が想定と異なる可能性があることなど当たり前であるため、
「俺は気持ち悪いと思わないもんね。めんどくさいし、そんなのコードレビューで担保しろよ。型でなんとかする問題じゃない。」
という返事が帰ってくる。(過去の樽八にアンケート。)
$score_array = readCsvData("./score_file.csv");
$high_score_player = getHighScorePlayer($score_array);
この部分のコードをシンプルに書けばその間に改変がなかったことなど自明じゃん。とか。
$high_score_player = getHighScorePlayer(readCsvData("./score_file.csv"));
こう書けばエラーは紛れ込まない。
とか。
今回はこれらのつっこみを忘れておくこととする。
もっと複雑になるケースはいくらでもあるからね。
【ほぼこれでOK】お手軽に、arrayをほぼそのまま使って型名をつけてみる。
arrayをArrayObjectを継承したクラスに突っ込むことで、新たな型を定義してみる
まずは、この改善をするだけでもコードの見通しは良くなる(と思う。)
ここで作成されたクラスは、全てarrayとしてそのまま扱うことが可能なのが嬉しい。
擬似コードはこちら。
<?php
// ScoreArrayというほぼarray型のクラスを定義する
class ScoreArray extends ArrayObject {}
/**
指定されたファイルを開いて、1行目をタイトル行として連想配列に読み込んで返却する
*/
function readCsvData(string $filename): ScoreArray
{
// 実装は省略するが、 今までarrayのまま返答してたのを、 new ScoreArray()の中に入れた部分だけ変わっている
return new ScoreArray(
[
[
"id" => 1,
"name" => "Taru8",
"score" => 50,
],
[
"id" => 2,
"name" => "Taru9",
"score" => 100,
],
]
);
}
/**
scoreのmax()を出して、一致したレコードを一つ返却するものとする
input : $score_array: readCsvData()の戻り値(ScoreArray型)を想定する
*/
function getHighScorePlayer(ScoreArray $score_array): array
{
// 実装は省略
return [省略]:
}
$score_array = readCsvData("./score_file.csv");
$high_score_player = getHighScorePlayer($score_array); // 引数が型で指定されているので、間違いなくreadCsvData()の戻り値である
echo "ハイスコアは".$high_score_player["name"]."さんで".$high_score_player["score"]."点でした。\n";
解説
変更点は、
class ScoreArray extends ArrayObject {}
の追加と、readCsvDataによるデータのやり取りをScoreArray型に変更しただけ。
たったこれだけの変更で、
function readCsvData(string $filename): ScoreArray
にて、この関数の戻り値の型がScoreArrayであることを担保されている。
function getHighScorePlayer(ScoreArray $score_array): array
にて、この関数の引数がScoreArrayであることは担保されている。
ScoreArrayは、内部では型のフォーマットのチェックを一切実施していないが、ArrayObjectを継承しており、arrayをそのままオブジェクトに変換することが可能。
さらに、arrayとして振る舞うことができるインタフェイスをすべて実装しているので、通常のarray操作をすべて受け付ける。
※(Object)$array
でも同様な型の変換はできるが、この場合はObject型になってしまい、任意の型名をつけることができない。
これで、型の指定に関して、雑なarray型の指定を消すことができる。
また、
function getHighScorePlayer(score_array $score_array): array{...}
に関しても同様に以下のように修正することも可能
class HighScorePlayer extends ArrayObject {}
function getHighScorePlayer(score_array $score_array): HighScorePlayer{...}
【おまけ1】もう少しちゃんと中身のフォーマットを担保したい場合
上記、class ScoreArray extends ArrayObject {}
の追加による型名の付与で(私の)やりたいことの90%は担保された。
コード上、このクラスは、readCsvData()の戻り値として生成されていることが見える。
しかし、以下のようなコードを書くことも可能である。
$score_array = new ScoreArray(["hoge" => "fuga"]);
このScoreArrayオブジェクトは、getHighScorePlayer()
で処理をすることができない。
この場合は、以下のようにScoreArrayクラスのコンストラクタで渡された引数のvalidationを行うよう改修しても良いかもしれない。
<?php
class ScoreArray extends ArrayObject {
public function __construct(array $array = [], int $flags = 0, string $iteratorClass = ArrayIterator::class){
self::__checkFormat($array);
parent::__construct($array, $flags, $iteratorClass);
}
// ここでは配列の各要素のindexがid, name, scoreで構成されている事をチェックしている
private function __checkFormat(array $array){
$expected_keys = ["id","name", "score"];
sort($expected_keys);
foreach($array as $player){
$keys = array_keys($player);
sort($keys);
if ($keys !== $expected_keys) {
throw new exception("Type mismatch. Each rows must have id, name, and score.");
}
}
}
ここまで書いておいた場合には、
$score_array = new ScoreArray(["hoge" => "fuga"]);
と宣言しようとしたときに__construct()
が呼ばれるので、その中で__checkFormat()
が実行されるため、例外がthrowされるのでわかる。
【おまけ2】new したタイミングではなく、あとから変更されても型の中身のチェックを実施したい場合
$score_array = new ScoreArray(["hoge" => "fuga"]);
のタイミング以外で以下のように変更された場合はどうするか?
$score_array = readCsvData("./score_file.csv"); // ここでは正しいScoreArrayオブジェクトが返答される
unset($score_array[0]["name"]); // 各要素に必ずnameを含めたいので、エラーにしたい。
$score_array[0]["age"] = 20; // ScoreArrayにageはいらないので、エラーにしたい。
この場合、それぞれに対応するメソッドが呼ばれる度に再度フォーマットチェックを実施するようにフックするか、そもそもイミュータブルにしてしまうという手もある。
ArrayObjectを継承したオブジェクトをイミュータブルにするサンプルがこちら
<?php
// ArrayObject の更新系の各メソッドを上書きすることで更新を禁止する。
trait immutableArrayObject{
// append()禁止
public function append($value){
throw new exception("This object is immutable");
}
// $arr[0]["name"] = "Taru10"; といった直接の上書きを禁止
public function offsetSet($index, $newval){
throw new exception("This object is immutable");
}
// unset($arr[0]["name"])などの操作を禁止
public function offsetUnset($key){
throw new exception("This object is immutable");
}
// 中身を全部差し替えるメソッドの禁止
public function exchangeArray($array){
throw new exception("This object is immutable");
}
};
class ScoreArray extends ArrayObject {
use immutableArrayObject;
public function __construct(array $array = [], int $flags = 0, string $iteratorClass = ArrayIterator::class){
self::__checkFormat($array);
parent::__construct($array, $flags, $iteratorClass);
}
// ここでは配列のindexがid, name, scoreで構成されている事をチェックしている
private function __checkFormat(array $array){
// 省略
}
}
結論
array型でデータをやり取りする際には、ArrayObjectを継承した別のオブジェクトに突っ込んでおくと名前を付けることができるので型チェックの恩恵をうけられて嬉しい。
class ScoreArray extends ArrayObject {}
最後に
頑張って自己解決したのを備忘録として記載したのですが、もっといいスマートなやり方があったら教えてください。