PHPで列挙型を作る
最近、列挙型についていろいろ思うところがあったので、ブログにまとめてみます。
列挙型とは
列挙型とは名前付きの定数の集合を持つデータ型です。
例えば、トランプゲームを実装したいとして、カードのスートを表すときに、CやC++なら enum
キーワードを使って列挙型を定義することができます。
ゲームの中では、スート同士を比較して同じかどうかを判定するメソッドなり関数なりが必要になるはずです。スートをint
で表すように書くとこんな感じになると思います。
const int SPADE = 1;
const int HEART = 2;
const int DIA = 3;
const int CLOBER = 4;
int isSameSuit(int suit, int otherSuit) {
return suit == otherSuit;
}
isSameSuit(SPADE, SPADE); /* 1 */
isSameSuit(SPADE, HEART); /* 0 */
このコードは、intがスートを表すことを知っていないと意味が分かりづらいです。
ここでスートを表す列挙型を定義してみます。
typedef enum {
SPADE,
HEART,
DIA,
CLOBER
} Suit;
この型を使って、あるスートがスペードかどうかを判定する関数を実装してみます。
int isSameSuit(Suit suit, Suit otherSuit) {
return suit == otherSuit;
}
/* 使い方は前の例と同じ */
isSameSuit(SPADE, SPADE); /* 1 */
isSameSuit(SPADE, HEART); /* 0 */
型宣言に列挙型の名前が入って、可読性が上がったのではないでしょうか。
また、Javaなら列挙型のメンバはオブジェクトになるのでメソッドを定義することもできます。
enum Suit
{
SPADE {
public boolean isSpade() { return true; }
},
HEART,
DIA,
CLOBER;
public boolean isSpade() { return false; }
}
同値判定のequalsTo()
は勝手に定義されるので、スペードかどうかを判定するメソッドを生やしてみました。使用例はこんな感じです。
Suit.SPADE.isSpade(); // true
Suit.HEART.isSpade(); // false
Cの列挙型とJavaの列挙型には大きな違いがあります。
Cの列挙型は実際はint
です。つまり、列挙型を期待しているところにただのint
が入ってもエラーにはなりません。
Suit suit = SPADE;
int xxx = 42;
isSameSuit(suit, xxx); /* コンパイルが通る */
一方で、Javaの列挙型の値はオブジェクトなので、このコードはエラーになります。
int xxx = 42;
Suit.SPADE.equalsTo(x); // コンパイルが通らない
PHPでの列挙型実装
ここで我らがPHPですが、もちろん言語レベルで列挙型をサポートしていません。
なので「列挙型のようなもの」を作ったり使ったりするのですが、実装にはいくつかのパターンがあると思います。
コンストラクタ方式
列挙型のオブジェクトを作成するときに、クラスとして定義した「列挙型のようなもの」のコンストラクタに定数を渡す方式です。Javaの例と同じく、equalsTo
とisSpade
も定義してみます。
class Suit {
public const SPADE = 1;
public const HEART = 2;
public const DIA = 3;
public const CLOBER = 4;
private $value;
public function __construct(int $value)
{
$this->value = $value;
}
public functoin getValue()
{
return $this->value;
}
public function equalsTo(Suit $other)
{
return $this->value === $other->getValue();
}
public function isSpade(): bool
{
return $this->value === self::SPADE;
}
}
$spade = new Suit(Suit::SPADE);
$heart = new Suit(Suit::HEART);
$spade->equalsTo($spade); // true
$spade->equalsTo($heart); // false
$spade->isSpade(); // true
$heart->isSpade(); // false
よく見ますが、クラス内で定義した定数値以外の値がコンストラクタに渡される可能性があります。
new Suit(9999);
定義値以外はコンストラクタでエラーにすれば良いとかいうと話が終わってしまいますので(またはコンストラクタが例外安全じゃないのはイヤとか別の話が始まってしまいますので)、もう一つよく見る方式を見てみます。
リフレクション+マジックメソッド方式
今度は定数値をprivateにして、値オブジェクトの生成にメソッドを使うことにしてみます。つまり以下のように定数ごとに、その値に対応するオブジェクトを返すメソッドを定義します。
/* 略 */
class Suit {
/* 定数をprivateに */
private const SPADE = 1;
private const HEART = 2;
private const DIA = 3;
private const CLOBER = 4;
/* コンストラクタもprivateに */
private function __construct(int $value)
{
$this->value = $value;
}
public static SPADE(): Suit
{
return new static(static::SPADE);
}
public static HEART(): Suit
{
return new static(static::HEART);
}
/* 以下定数の分だけ定義が続く */
}
// 使用例
$spade = Suit::SPADE();
$heart = Suit::HEART();
定数とコンストラクタをprivateにして先程の問題を解決していますが、定数ひとつひとつにメソッドを作るのは面倒なので、マジックメソッドとリフレクションを使って「楽」をしてみます。これも多くの列挙型ライブラリで用いられている方式です。
class Suit {
/* 定数をprivateに */
private const SPADE = 1;
private const HEART = 2;
private const DIA = 3;
private const CLOBER = 4;
/* コンストラクタもprivateに */
private function __construct(int $value)
{
$this->value = $value;
}
/* 略 */
public static function __callStatic($name, $arguments)
{
$reflection = new \ReflectionClass(static::class);
$constants = $reflection->getConstants();
if (array_key_exists($name, $constants)) {
return new static($constants[$name]);
}
throw new \InvalidArgumentException('invalid value for suits');
}
}
しかし、今度は値を取るのにマジックメソッドを使っているので、IDEでの補完や静的解析が効かないという問題が発生します(2019年5月現在)。
なので、メソッドの分だけPHP Docを書く必要があります。
/**
* @method static Suit SPADE()
* @method static Suit HEART()
* @method static Suit DIA()
* @method static Suit CLOBER()
*/
class Suit
{
private const SPADE = 1;
private const HEART = 2;
private const DIA = 3;
private const CLOBER = 4;
/* 略 */
}
なんとなく、二度手間のような気がします…
新しいパターンの列挙型
つまり、解決したい問題は2つです。
- 型安全性: 正しい列挙型のインスタンスのみを生成する
- IDEフレンドリー: 補完や静的解析が効きやすい
今、型安全性が保証しづらいのは列挙型のコンストラクタをユーザが呼ばなければいけないからで、IDEフレンドリーにしづらいのは列挙型の値を返すメソッドを書くのが面倒くさいからです。
列挙型クラスをと型オブジェクトと値オブジェクトを分ける
ここで、こういう問題が起こるのは「列挙型の定数の定義と、値オブジェクトのメソッドが同じクラスに入っているからではないか」と考えてみました。
まず、値オブジェクトを取り出してみます。
class Suit
{
private $value;
private $type;
public function __construct(string $value, SuitType $type)
{
$this->value = $value;
$this->type = $type;
}
public function getValue()
{
return $this->value;
}
public function equalsTo(Suit $other)
{
return $this->value === $other->getValue();
}
public function isSpade()
{
return $this->equalsTo($this->type->spade());
}
}
$value
の型がしれっとstring
に変わっていたり、新しくSuitTypeというもの
がコンストラクタに渡っていたりしますが、これは後で解説します。
次に列挙型の定義として、型オブジェクトというものを考えます。型オブジェクトとは値オブジェクトのファクトリのようなものです。
/**
* @method Suit spade()
* @method Suit heart()
* @method Suit dia()
* @method Suit clover()
*/
class SuitType
{
private $definedValues = [];
/**
* コンストラクタ
*
* この型に定義された値メソッドのドキュメントから、
* Enum値として有効なメソッドの配列を生成する
*/
private function __construct()
{
$reflectionClass = new \ReflectionClass(static::class);
$docComment = $reflectionClass->getDocComment();
$lines = preg_split('/\n|\r/', $docComment);
foreach ($lines as $line) {
$matches = [];
preg_match('/@method\s+\S+\s+(\w+)\s*\(\)/', $line, $matches);
if (isset($matches[1])) {
$this->definedValues[] = $matches[1];
}
}
}
/**
* Enum値を返すマジックメソッド
* @param $name
* @param $arguments
* @return mixed
*/
public function __call($name, $arguments)
{
if (!in_array($name, $this->definedValues)) {
throw new \LogicException('disallowed value: '.$name);
}
$valueClass = $this->valueClass;
$valueObject = new $valueClass($name, $this);
return $valueObject;
}
}
PHP Docをパースしてマジックメソッドからメソッド定義を読むことで、二度手間問題を回避しています。また、SuitTypeオブジェクトをSuitオブジェクトから参照させることで、各Suitは他のSuitを参照して自分自身と比較したり合成することができます。
使い方はこんな感じになります。
$suitType = new SuitType();
$spade = $suitType->spade();
$heart = $suitType->heart();
$spade->equalsTo($spade); // true
$spade->equalsTo($heart); // false
$spade->isSpade(); // true
$heart->isSpade(); // false
キモっ
でも補完が効きますし、値型のコンストラクタがpublicなのに目をつむれば不正な値も入ってきません。やった。
問題点
入りそうなツッコミを自分で入れておきます。
リフレクションによるパフォーマンスへの影響
PHP Docのパースにリフレクションを使用していますが、リフレクションは一般的に重いです。インスタンスをキャッシュすればリフレクションを使うのも一回で済むのですが、型オブジェクトに状態を持たせないように注意が必要です。
メソッド名がそのまま列挙型オブジェクトの内部値になってしまう
これが一番痛いと思います。例えば内部値をDBなどに保存していた場合、うっかりメソッド名を変えると面倒くさいことになります。
内部値を指定する書き方を考えれば良いような気がして、こんな方法も考えてみましたが…
/**
* メソッド名のイコールの後ろに内部値を書く
* @method Suit spade() = 1
* @method Suit heart() = 2
* @method Suit dia() = 3
* @method Suit clover() = 4
*/
- え、これって内部値の型はintになるの、stringでいいの?
- ていうか内部値の型を指定する方法も考えなきゃ…
- あとふつうにドキュメント書きたいときはどうすれば?
ここまでくるとPHPで列挙型がうまくいかない理由がしみじみとわかったような気がしたので、考えるのをやめました。
終わりに
今回作ったEnumを、ライブラリっぽくまとめてgistに置きました。よろしければ遊んでみてください。