トレイトの課題
PHP5.4から追加されたトレイト。
様々なアプリケーションやフレームワークで利用されているのはご存知でしょう。
ただし、PHPのトレイトは依存関係が複雑になることが多々あります。
それはどういう時でしょうか。
Laravelの例
自分がよく使うフレームワークを例にしましょう。
Laravelは複数のトレイトを組み合わせて構成されているものがいくつかあります。
例えば、Illuminate\Foundation\Auth\SendsPasswordResetEmailsトレイトは、
パスワードリセットに関する振る舞いが記述されています。
一見正しい様に見えて、実はこのトレイトには複雑な依存関係があります。
下記のメソッド内部でコールされているvalidateメソッドは、
このトレイトにはないメソッドで、他のクラス・トレイトのメソッドに依存しています。
(Illuminate\Foundation\Validation\ValidatesRequestsトレイトのメソッド)
protected function validateEmail(Request $request)
{
$this->validate($request, ['email' => 'required|email']);
}
依存関係を表すことができないため、
PHPのトレイトの性質上、
処理の内容を知らなければ利用できない複雑さを簡単に作ることができてしまいます。
これ以外にも特定のプロパティが存在するかどうかを確認して、
処理を実行する様なものも生まれてしまいます。
例えば下記の様なパターンです。
/**
* Get the maximum number of attempts to allow.
*
* @return int
*/
public function maxAttempts()
{
return property_exists($this, 'maxAttempts') ? $this->maxAttempts : 5;
}
/**
* Get the number of minutes to throttle for.
*
* @return int
*/
public function decayMinutes()
{
return property_exists($this, 'decayMinutes') ? $this->decayMinutes : 1;
}
トレイト同士の依存や、プロパティの有無がトレイト自体に入り込んでしまうと
それらのトレイトを活用するのは難しくなります。
またこれらが発展すると、グローバルアクセ・シングルトンによる安易なインスタンス生成が
入り込んでしまいます。
例えば下記の例です。
/**
* Fire an event when a lockout occurs.
*
* @param \Illuminate\Http\Request $request
* @return void
*/
protected function fireLockoutEvent(Request $request)
{
event(new Lockout($request));
}
/**
* Get the rate limiter instance.
*
* @return \Illuminate\Cache\RateLimiter
*/
protected function limiter()
{
return app(RateLimiter::class);
}
利用側にとっては非常に便利ですが、
依存関係はさらに複雑で、Laravelの app
ヘルパー関数は
サービスコンテナ(DIコンテナ)にアクセスができるもので、
つまりトレイトとの依存関係がある何かの処理の中で、
インスタンス生成についての保証ができないため、
DIコンテナにグローバルアクセスを行いインスタンスを取得する、
というサービスロケータ依存をも見えにくくしています。
Hackの場合
Hackではこうした実装は元から生まれにくくなっていますが、
複雑になりがちなトレイトに対して制約をかけることができます。
これはPHPには実装されていない機能の一つになります。
Hackのトレイトには、下記の構文が記述できます。
require extends <class name>;
require implements <interface name>;
これは、トレイトを利用するクラスの範囲を指定することができるものです。
- あるクラスを継承したもののみに許可をする
- あるインターフェースを実装したもののみに利用を許可する
というものです。
これらと <<__Sealed>>
を組み合わせることで的確な制約を設けることができる様になります。
require
下記のクラス、インターフェースを例にしましょう。
<?hh // strict
namespace Acme;
class Request {
}
<?hh // strict
namespace Acme;
interface RequestInterface {
}
ものすごく単純な例です。
下記のトレイトの場合は、RequestInterfaceを実装していなければ利用することができなくなります。
<?hh // strict
namespace Acme;
trait MessageTrait {
require implements RequestInterface;
private dict<string, vec<string>> $headers = dict[];
}
Requestクラスにそのまま記述するとTypecheckerで下記エラーとなります。
src/Request.php:6:7,18: Failure to satisfy requirement: Acme\RequestInterface (Typing[4111])
src/MessageTrait.php:6:22,37: Required here
インターフェース自体にはトレイトを記述することができませんので、
RequestInterfaceを実装するしか方法がない、ということになります。
<?hh // strict
namespace Acme;
class Request implements RequestInterface {
use MessageTrait;
}
PHPの例にもあった様にトレイトを利用するクラスにメソッドを記述し、
そのメソッドをトレイトで利用する場合を例にします。
<?hh // strict
namespace Acme;
class Request implements RequestInterface {
use MessageTrait;
protected function getVersion(): string {
return '1.1';
}
}
トレイトにgetVersionに記述してみましょう。
trait MessageTrait {
require implements RequestInterface;
private dict<string, vec<string>> $headers = dict[];
public function castIntVersion(): int {
return (int) $this->getVersion();
}
}
この場合は残念ながらTypecheckerで以下のエラーとなりますので、実行することができなくなります。
src/MessageTrait.php:10:25,34: Could not find method getVersion in an object of type Acme\MessageTrait (Typing[4053])
src/MessageTrait.php:10:18,22: This is why I think it is an object of type Acme\MessageTrait
src/MessageTrait.php:5:7,18: Declaration of Acme\MessageTrait is here
Typecheckerが理解できる様に、method_existsを記述すれば回避できるのでしょうか?
public function castIntVersion(): int {
if(\method_exists($this, 'getVersion')) {
return (int) $this->getVersion();
}
return 1;
}
残念ながらこの様に記述しても回避することはできません。
method_existsは継承元のものか、または同一のトレイト内のみ以外ではTypececkerでエラーとして扱われます。
Typechckerを無視するように記述するしかなく、
このままではLaravelの例の様な実装をすることができません。
この様な場合はTypecheckerを回避するのではなく、
クラス設計などを見直した方が良いでしょう。
次の様な、特定のインスタンスを利用したい場合を例にしてみましょう。
<?hh // strict
namespace Acme;
trait MessageTrait {
require implements RequestInterface;
private dict<string, vec<string>> $headers = dict[];
protected \stdClass $class;
public function getStdClass(): \stdClass {
return $this->class;
}
}
インスタンス生成方法がトレイトにはなく、
RequestInterfaceを実装したクラスにある例です。
<?hh // strict
namespace Acme;
class Request implements RequestInterface {
use MessageTrait;
protected function getClass(): string {
return $this->class;
}
}
Requestクラス内で $this->class
をこのまま参照することはできません。
RequestInterface自体はTypecheckerで認識されますが、
インスタンス生成方法が不明なため、
nullableではないことを保証する様なコードに書き換えるしかありません。
メソッドを下記の様に書き換えることになります。
protected function getClass(): string {
$class = $this->class;
if($class is \stdClass) {
return \strval($class);
}
return '';
}
もしくは下記となります。
<?hh // strict
namespace Acme;
class Request implements RequestInterface {
use MessageTrait;
public function __construct(
protected \stdClass $class
) {}
protected function getClass(): string {
return \strval($this->class);
}
}
依存関係を明らかにする様に記述するか、
もしくはインスタンスがなくても正しく動作するように実装するしかありません。
またトレイト自体がユーティリティクラスの様な用途担っている場合、
特定のクラスで利用するが他のクラスで利用しない様な実装の場合、
下記のようにクラス自体に実装はなく、
MessageTraitに protected \stdClass $class;
がそのままある状態でもエラーとなります。
<?hh // strict
namespace Acme;
class Request implements RequestInterface {
use MessageTrait;
}
src/Request.php:5:7,13: Class Acme\Request does not initialize all of its members; class is not always initialized.
Make sure you systematically set $this->class when the method __construct is called.
Alternatively, you can define the member as optional (?...)
(NastCheck[3015])
不確実な指定になるため、MessageTraitのclassプロパティをnullableにするしかなくなります。
Sealed併用
利用していたインターフェースに下記の記述が追加された場合、
トレイトを利用していいたRequestクラスでの利用が不可となり、
Sealedで指定したクラスのみ利用可となりますので、
型について意識した設計を行うことができる様になります。
<?hh // strict
namespace Acme;
<<__Sealed(HogeRequest::class)>>
interface RequestInterface {
}
Hackにのトレイトに関して簡単に紹介しました。
これらの仕組みは役に立つことが非常に多いですので、
有効に使ってアプリケーケーション開発に役立ててみてください!