22 October 2011

Traits в php 5.4. Разбираем детали реализации

PHP
Tutorial
Совсем недавно вышла первая beta php 5.4, а пока я писал топик подоспела и вторая. Одно из нововведений в 5.4 – это traits (типажи). Предлагаю разобраться во всех деталях в том, что же типажи из себя представляют в php.

Простой пример типажа, чтобы не заглядывать в Википедею:
//определение типажа
trait Pprint 
{
    public function whoAmI()
    {
        return get_class($this) . ': ' . (string) $this;
    }
}

class Human 
{
    use Pprint; //подключаем типаж, ключевое слово use

    protected $_name = 'unknown';
    
    public function __construct($name)
    {
        $this->_name = $name;
    }

    public function __toString()
    {
        return (string) $this->_name;
    }   
}

$a = new Human('Nikita');
echo $a->whoAmI(), PHP_EOL; //=> Human: Nikita

Как видно, к классу Human было добавлено поведение из типажа Pprint.

Но во всём есть свои детали.

Синтаксис


В общем и целом всё просто. Типажей можно подключить к классу неограниченное кол-во через одну или несколько конструкций use внутри определения класса. use может быть указан в любом месте класса.

Дополнительно в блоке ({...}) после use можно:
  • назначить alias'ы к методам типажа (Trait::method as myMethodmethod из Trait будет дополнительно доступен, как myMethod);
  • указать перекрытие метода одного типажа, методом другого, если у них совпали названия (TraitA::method insteadof TraitB – будет использован метод TraitA вместо одноимённого метода TraitB);
  • повысить или понизить доступ к методу из типажа, за исключение перевода метода в статический (Trait::publicMethod as protected), можно сразу с переименованием (Trait::publicMethod as protected _myProtectedMethod).

Сам типаж записывается, как trait и может включать другие типажи, через указание их в ключевом слове use. Cинтаксис и возможности аналогичны use в классе.

Более сложный пример:
trait Pprint 
{
    public function whoAmI()
    {
        return get_class($this) . ': ' . (string) $this;
    }
}

trait Namer 
{
    //использование одного типажа в другом
    use Pprint;
    
    public function getMyName()
    {
        return $this->whoAmI();
    }
    
    public function getMyLastName()
    {
        return 'Unknown =(';
    }
    
    public function getMyNickname()
    {
        return preg_replace('/[^a-z]+/i', '_', strtolower($this->getMyName()));
    }
}

trait SuperNamer
{
    public function getMyLastName()
    {
        return 'Ask me';
    }
}


class Human 
{
    use SuperNamer;
    use Namer
    {
    	SuperNamer::getMyLastName insteadof Namer;
    	Namer::getMyNickname as protected _getMyLogin;
    }

    protected $_name = 'unknown';
    
    public function __construct($name)
    {
        $this->_name = $name;
    }

    public function __toString()
    {
        return (string) $this->_name;
    }
    
    public function getLogin()
    {
        return $this->_getMyLogin();
    }
    
    
}

$a = new Human('Nikita');

echo join(', ', get_class_methods($a)), PHP_EOL;
//__construct, __toString, getLogin, getMyLastName, 
//getMyName, getMyNickname, whoAmI

echo $a->getMyName(), PHP_EOL; //Human: Nikita
echo $a->getMyLastName(), PHP_EOL; //Ask me
echo $a->getLogin(), PHP_EOL; //human_nikita
echo $a->getMyNickname(), PHP_EOL; //human_nikita

Тут важно обратить внимание на два момента. Во-первых, блок после use кажется связанным с типажом около которого он описан, но это не так. Правила в блоке глобальные и могут быть объявлены в любом месте.

Чтобы не возникало путаницы, хорошей практикой будет записать сначала все типажи через запятую, а затем на отдельной строке правила перекрытия и alias. Либо описывать все правила для типажа рядом с его подключением. Выбор за вами.
//так
use SuperNamer, Namer, Singleton, SomeOther
{
    SuperNamer::getMyLastName insteadof Namer;
    SomeOther::getSomething as private;
}

//либо так
use Namer;
use Singleton;
use SuperNamer
{
    SuperNamer::getMyLastName insteadof Namer;
}
use SomeOther
{
    SomeOther::getSomething as private;
}

Во-вторых, обратите внимание на список методов, в списке остался getMyNickname, а _getMyLogin просто его alias с пониженным доступом. Можно исключить исходный метод совсем, но об этом ниже в разделе магии.

Типажи инициализируются, как и классы, динамически. При большом желании можно писать так:
if ($isWin) {
    trait A { /* … */}
} else {
    trait A { /* … */}
}

Свойства в типажах


До этого, я оперировал методами, но типаж может включать в себя и свойства, которые будут добавлены в класс. В этом плане «типажи» в php – это скорее mixin.
trait WithId 
{
    protected $_id = null;
    
    public function getId()
    {
        return $this->_id;
    }
    
    public function setId($id)
    {
        $this->_id = $id;
    }
}

Сразу предлагаю хорошую практику, чтобы однажды не оказалось, что свойство _id в типаже конфликтует с используемым в классе или его потомках, свойства типажей записывать с префиксами:
trait WithId 
{
    protected $_WithId_id = null;
    protected $_WithId_checked = false;
    //...
    
    public function getId()
    {
        return $this->_WithId_id;
    }
    
    public function setId($id)
    {
        $this->_WithId_id = $id;
    }
}

Область видимости


Важно понимать, как будут разрешаться различные вызовы внутри типажа. В этом поможет правило думать о подключении типажа, как о «copy-paste» кода в целевой класс. В самом первом примере, интерпретатор как бы сделал «copy-paste» метода whoAmI в класс Human, соответственно все вызовы к parent, self, $this будут работать также, как и вызов в методах класса. Исключение будут составлять некоторые магические константы, например внутри whoAmI __METHOD__ === 'Pprint::whoAmI'.

Внутри методов типажа доступны все свойства объекта для обращения напрямую, никаких дополнительных областей видимости не добавляется. Можно было бы получить просто $this->_name, вместо вызова __toString. Однако стоит несколько раз подумать, прежде чем делать это, так как на сложных реализациях это внесёт не мало путаницы. Я бы рекомендовал всегда использовать понятные методы, при необходимости даже описать их в интерфейсе и «заставлять» классы его имплементировать.

Статические методы и свойства


В типаже можно объявлять статические методы, но нельзя объявлять статические свойства. Внутри статических методов можно использовать, как статическое связывание (self::), так и динамическое (static::), всё будет работать так, как будто вызвано из метода класса («copy-paste»).

Ограничение на хранение статических свойств обойти можно, как именно покажу позже с обращением к магии.

Совпадение методов типажей между собой и с методами класса


Метод описанный в классе перекрывает метод из типажа. Но если какой-то метод описан в родительском классе, а в дочернем классе подключён типаж с таким же методом, он перекроет метод из родительского (снова вспоминаем «copy-paste»).

Если в нескольких, указанных у класса типажах, используются одинаковые методы, php выдаст ошибку на этапе инициализации класса:
trait A
{
    public function abc() {}
}

trait B
{
    public function abc() {}
}

class C 
{
    use A, B;
}
//Fatal error: Trait method abc has not been applied, 
//because there are collisions with other trait methods
//on C in %FILE% on line %line%
На помощь приходит insteadof, с помощью которого нужно будет разрешить все коллизии.

Хитрая ошибка может быть в случае, когда в классе тоже определён метод, вызвавший коллизию, в таком случае php пропустит эту проверку, т.к. он проверяет только «выжившие» методы типажа:
trait A
{
    public function abc() {}
}

trait B
{
    public function abc() {}
}

class C 
{
    use A, B;
    
    public function abc() {}
    
}
//OK
Когда-нибудь потом, перенеся метод abc в родительский класс, получим странную ошибку по коллизии методов типажей, которая может сбить с толку. Так что, коллизии лучше разрешить заранее. (С другой стороны, если в коде методы типажа и класса совпадают, возможно что-то уже не так.)

Совпадение свойств типажа со свойствами другого типажа и свойствами класса


В этом моменте нас поджидают неприятные проблемы. Сразу пример:
trait WithId 
{
    protected $_id = false;
    //protected $_var = 'a';
    
    public function getId()
    {
    	return $this->_id;
    }
    
	//...
}

trait WithId2 
{
    protected $_id = null;
    
    //protected $_var = null;
    //...
}

class A 
{
    use WithId, WithId2;
}

class B 
{
    use WithId2, WithId;
}

class C
{
	
	use WithId;
	
	protected $_id = '0';
}
//

$a = new A();
var_dump($a->getId()); //NULL

$b = new B();
var_dump($b->getId()); //false

$c = new C();
var_dump($c->getId()); //false (!)

//Если раскомментировать $_var
// WithId and WithId2 define the same property ($_var)
// in the composition of A. However, the definition differs 
// and is considered incompatible. Class was composed 
// in %FILE% on line %LINE%

Поясняю. В общем случае при пересечении свойств типажей между собой или свойств типажа и класса выдаётся ошибка. Но зачем-то для «совместимых» свойств делается исключение и они работают по принципу «кто последний, тот и прав». Поэтому в классе A в getId получилось NULL, а в классе B – false. При этом свойства класса считаются ниже, чем свойство типажа (с методами равно наоборот) и в C вместо ожидаемого '0' получим false.

Совместимыми считаются значения нестрогое сравнение которых даёт true, а так как в php при этом много неявных преобразований, могут быть неприятные ошибки при использовании строго сравнения возвращаемых значений.
var_dump(null == false); //true
var_dump('0' == false); //true
var_dump('a' == null); //false

Так что практика с префиксами, предложенная выше, будет полезна и в таких случаях. Я же надеюсь что эту часть реализации ещё пересмотрят к релизу.

Ошибки и исключения в типажах


Если следовать мнемоническому правилу trait == «copy-paste», с ошибками становится сразу всё понятно:
<?php trait Slug 
{
    public function error()
    {
        echo $this->a; //5
    }
    
    public function someMethod()
    {
    	$this->error();
    }
    
    public function testExc()
    {
    	throw new Exception('Test'); //16
    }
}

class Brain 
{
    use Slug;
    
    public function plurk()
    {
    	$this->testExc(); //25
    }
}

error_reporting(E_ALL);
$b = new Brain();
$b->someMethod();
//Notice: Undefined property: Brain::$a in %FILE% on line 5

try {
       $b->plurk(); //35
} catch(Exception $e) {
	echo $e;
}
// exception 'Exception' with message 'Test' in %FILE%:16
// Stack trace:
// #0 %FILE%(25): Brain->testExc()
// #1 %FILE%(35): Brain->plurk()
// #2 {main}

Объект уже не знает, откуда у него взялся метод в котором был Notice или Exception, но это можно узнать в stack trace по строкам кода, в которых были вызовы. Если хранить типажи в отдельных файлах определить будет ещё проще.

Немного белой чёрной магии


Покажу пару грязных приёмов с типажами, используйте их на свой страх и риск.

Удаление метода типажа


Чтобы удалить метод типажа, например, когда ему был задан alias, можно сделать так:
trait A
{
    public function a() {}
    
    public function b() {}
}

trait B
{
    public function d() 
    {
    	$this->e();
    }
    
    public function e() {}
}

class C 
{    
    use A
    {
    	//удаляем и переименовываем
    	A::b insteadof A;
    	A::b as c;
    }
    
    use B
    {
    	//удаляем метод совсем
    	B::e insteadof B;
    }
}

echo join(", ", get_class_methods('C')), PHP_EOL;
//a, c, d

Но в таком подходе таится большая опасность, т.к. одни методы типажа потенциально могут вызывать другие методы:
$c = new C();
$c->d();
//Fatal error: Call to undefined method C::e()

При переименовании типаж ничего не знает о том, что метод был переименован. Поэтому по-умолчанию при указании alias'а сохраняется оригинальный метод.

«Наследование» в типажах


С помощью похожего трюка можно реализовать «наследование» в типажах c возможностью вызова «родительских» методов.
trait Namer 
{
    public function getName()
    {
        return 'Name';
    }
}

trait Namer2
{
    public function getName()
    {
        return 'Name2';
    }
}

trait Supernamer 
{
    use Namer, Namer2
    {
        Namer::getName insteadof Namer;
        Namer::getName as protected _Namer_getName_;
        
        Namer2::getName insteadof Namer2;
        Namer2::getName as protected _Namer2_getName_;
    }
    
    public function getName()
    {
        return  $this->_Namer_getName_() . $this->_Namer2_getName_();
    }
    
}

Два способа реализовать Singleton с помощью типажей


Чтобы сгладить это магическое безобразие покажу один полезный пример. Часто в виде типажа приводят Singleton, хотя без возможности задания в типаже статической переменной сделать его будет не так просто, как кажется на первый взгляд. Можно воспользоваться двумя хитростями.

Первая – получить внутри вызываемого метода название класса, к которому он был вызван, а затем в качестве хранилища воспользоваться отдельным классом со статическим методом, примерно так:
trait Singleton {
    
    static public function getInstance()
    {
        $class = get_called_class(); //работает аналогично static::
        
        if (!Storage::hasInstance($class)) {
            $new = new static();
            Storage::setInstance($class, $new);
            
        }
        return Storage::getInstance($class);
    }
}

Вторая – воспользоваться толи фичей, толи багой php, которая связана с использованием ключевого слова static при объявлении переменной. Эти переменные должны сохранять своё значение при вызовах метода, но видимо структура для хранения этих переменных инициализируется в каждом месте использования метода. В итоге получается такая схема:
trait Singleton 
{
    static public function getInstance()
    {
        static $instance = null;
        if ($instance === null) {
            $instance = new static();
        }
        return $instance;
    }
}

class MyClass 
{
    use Singleton;
}

class MyExtClass extends MyClass {}

echo get_class(MyClass::getInstance()), PHP_EOL; //MyClass
echo get_class(MyExtClass::getInstance()), PHP_EOL; //MyExtClass


P.S.

Спасибо aveic за помощь и интересные идеи по работе с типажами.
Tags:php 5.4traitsin a nutshell
Hubs: PHP
+147
48k 416
Comments 102
Top of the last 24 hours