07 Ноя 2010

ООП Практикум PHP5: эмуляция примесей (mixin) в языке

Category: PHP,СтатьиFractalizeR @ 22:49

Как-то вечером для реализации моделей поведения в ORM в моем велосипеде фреймворке мне понадобилось что-то, ведущее себя как примесь (mixin) в Ruby или как метод расширения (extension method) в C# (или как трейт / графт в будущих версиях PHP) Я решил для интереса посмотреть, как у меня получится реализовать примеси на PHP. Если вы не знаете, что такое примесь, не беда, сейчас все расскажу.

Приглашаю последовать за мной в рассуждениях о реализации примесей на PHP и программировании небольшой библиотеки, позволяющей их реализовать. Статья ориентирована на PHP разработчиков начинающего и среднего уровня. В процессе я также сделаю небольшую ошибку, касающуюся тонкостей работы PHP 5.3 с классами, через какое-то время на нее укажу и предложу исправить. А также предоставлю свое решение для вашей критики. Приятного чтения.

Что такое примесь?

Примесь – это класс, просто предоставляющий свои методы и свойства другим классам. Можно считать, что подмешивание в класс других классов – просто вариант эмуляции множественного наследования, которое в PHP не реализовано. Приведу небольшой пример на псевдокоде, похожем по синтаксису на PHP для ясности:

<?php
mixin Timeable {
private $timeStarted;
private $timeStopped;
public function start() { $timeStarted = time(); }
public function stop() { $timeStopped = time(); }
public function getElapsed() { return $timeStopped - $timeStarted }
}
 
mixin Dumpable {
public function dump() { var_dump($this); }
}
 
class MyClass mixing Timeable, Dumpable {
public function hello() {}
}
 
$a = new MyClass();
$a->start();
sleep(250);
$a->stop();
echo $a->getElapsed();
$a->dump();
?>

Идея понятна? Примеси просто добавляют в класс свой функционал, как будто класс унаследован сразу от них всех. При этом они могут манипулировать членами класса, в который они подмешаны. Вот такой функционал в PHP мы и будем реализовывать.

Давайте поставим себе задачу.

  • Нам нужно реализовать возможность подмешивать в экземпляры заданных классов функционал из указанных классов примесей.
  • Классы-примеси не должны загружаться прежде класса, в который они подмешиваются. В примере вверху использован псевдо-синтаксис, который позволил нам определить классы-примеси прямо в объявлении класса. Но такой способ имеет свои недостатки. Что, если в процессе работы программы нам понадобится добавить плагины, которые будут выступать в качестве примесей к классам нашей системы? В этом случае, мы могли бы где-нибудь в скрипте инициализации объявить все примеси и нам важно, чтобы такое объявление не приводило к загрузке классов.
  • Если примесь подмешивается в какой-то класс, это означает, что ее функционал должен быть доступен и в классе-потомке этого класса. Все же мы используем объектно-ориентированный язык и это будет логично.
  • При реализации желательно учитывать, что использование членов классов примесей не должно быть очень уж тормозным, особенно, если в системе будет использовано много примесей.
  • Модификация существующих классов для использования примесей не должна требовать перепроектировки существующей системы. Как следствие, это означает, что должна иметься другая возможность, помимо наследования от абстрактного класса, для того, чтобы научить класс подмешивать в себя функционал из других классов.
  • Публичные свойства и методы примесей должны быть доступны через экземпляр класса-хозяина(далее я буду называть его «агрегатор», поскольку он может агрегировать в себе несколько примесей). А приватные и защищенные должны быть видны только самой примеси.
  • Примесь должна иметь возможность обращаться даже к скрытым и защищенным полям своего класса-агрегатора (при выставлении такого требования я ориентировался на Ruby, в котором нет скрытых и защищенных свойств в том смысле, в каком они есть в C++, PHP или C#. Там обращаться отовсюду можно к любым полям класса. Но, поскольку примесь может добавлять новое поведение, ей может потребоваться защищенная информация из класса-агрегатора).

Проектируем реестр.

Давайте поразмыслим. Мы можем захотеть добавить разные примеси к разным классам системы. То есть, где-то мы должны хранить информацию о том, к каким классам какие примеси подмешаны. Такая информация для проекта глобальна и должна быть доступна отовсюду. Поэтому для реализации такого хранилища я выбрал статический класс (В PHP нет статических классов в том виде, в каком они есть в C#. Под статическим классом я подразумеваю класс, создавать экземпляры которого нет необходимости. Весь его функционал будет реализован статическими методами, доступными через имя класса). В качестве небольшого задания предлагаю (если вам будет интересно; после того, как вы дочитаете статью до конца) перепроектировать реестр, чтобы использование синглтона не требовалось.

Из вышесказанного следует, что реестр должен уметь регистрировать примеси для классов-агрегаторов. И еще чуть выше мы сказали, что если мы регистрируем примесь для какого-то класса, то функционал этой примеси должен подмешиваться и во все классы-потомки. Получить список классов-предков сразу во время регистрации мы не можем (нам ведь надо избегать загрузки классов, а инспекция иерархии классов этого потребует). Из этого следует вывод, что список соответствий (класс => список примесей) мы построим позже, когда он реально потребуется. Кроме того, такой список нужно будет закэшировать, чтобы при создании новых экземпляров классов-агрегаторов не строить его заново.

<?php
class Registry {
	private static $registeredMixins = array();
 
	public static function register($className, $mixinClassName) {
		$mixinClassNames = func_get_args();
		unset($mixinClassNames[0]);
 
		foreach ($mixinClassNames as $mixinClassName) {
			self::$registeredMixins[$className][] = $mixinClassName;
 
		}
 
		self::$classNameToMixinCache = array();
	}
}

Функция регистрации получилась довольно простой. Мы передаем ей имя класса-агрегатора и список примесей для него. Список примесей для удобства можно будет указывать через запятую. Об этом позаботится func_get_args() (добавьте изящную поддержку указания списка примесей массивом, если интересно). Потом мы просто добавляем каждую примесь в список примесей для данного класса. А последний вызов в конце функции очищает кэш, поскольку регистрация примеси для данного класса добавит ее также и во все его потомки, что потребует перестройки кэша.

Теперь напишем функцию кэширования. Она должна проходить по списку классов и зарегистрированных для них примесей и добавлять в него все классы-потомки данного с тем же списком примесей. В результате получится кэш.

Для функции кэширования нам понадобится функция, получающая список предков данного класса:

<?php
private static $classNameToMixinCache = array();
 
	private static function getAncestors($className) {
		$classes = array($className);
		while (($className = get_parent_class($className)) !== false) {
			$classes[] = $className;
		}
		return $classes;
	}
 
	private static function precacheMixinListForClass($className) {
		if (isset(self::$classNameToMixinCache[$className])) {
			return;
		}
 
		$ancestors = self::getAncestors($className);
		$result = array();
		foreach ($ancestors as $ancestor) {
			if (isset(self::$registeredMixins[$ancestor])) {
				$result = array_merge($result, self::$registeredMixins[$ancestor]);
			}
		}
		self::$classNameToMixinCache[$className] = array_unique($result);
	}

Обратите внимание, что функция добавляет в кэш список примесей только для заданного класса. Мы не будем сразу строить весь кэш, поскольку большая часть его содержимого может никогда не понадобиться. Перед тем, как строить кэш, мы проверили, а не сделали ли мы это уже ранее.

Теперь, если нам понадобится получить список примесей для заданного класса, мы можем воспользоваться такой функцией:

<?php
	public static function getMixinsFor($className) {
		self::precacheMixinListForClass($className);
		return self::$classNameToMixinCache[$className];
	}

Переходим к следующему шагу. Представьте, что мы вызываем метод класса-агрегатора, который определен не в нем, а в какой-то из примесей. Что нам нужно сделать? Нам нужно получить список примесей этого класса, затем пройтись по ним и посмотреть, а не определен ли в какой-нибудь из них метод, который нам нужен.

Поскольку примесь – суть класс, делаем примерно так:

<?php
private static $methodLookupCache = array();
 
	public static function getMixinNameByMethodName($className, $methodName) {
		if (isset(self::$methodLookupCache[$className][$methodName])) {
			return self::$methodLookupCache[$className][$methodName];
		}
 
		self::precacheMixinListForClass($className);
 
		foreach (self::$classNameToMixinCache[$className] as $mixin) {
			if (method_exists($mixin, $methodName)) {
				self::$methodLookupCache[$className][$methodName] = $mixin;
				return $mixin;
			}
		}
		throw new MemberNotFoundException("$className has no mixed method $methodName()!");
	}

То есть, если в кэше уже есть запись для данного класса и имени метода, мы просто их возвращаем. Если нет – получаем список примесей для данного класса из нашего кэша,обходим их и проверяем, реализует ли какая-нибудь нужный нам метод. Если да – добавляем его в кэш и возвращаем имя примеси. Если ничего найти не удалось – кидаем исключение.

В точности аналогичный вариант получается и для свойств. Предлагаю написать его вам самостоятельно.

Вот и все. Реестр мы реализовали. Переходим к программированию класса примеси.

Программируем примесь.

Итак, примесь. А что примесь? Примесь – это обычный класс. Просто он умеет работать с полями другого класса. И экземпляр этого другого класса будет логично передать ему в конструкторе.

<?php
class Base {
	protected $_owningClassInstance;
 
	protected $_owningClassName;
 
	public function __construct($owningClassInstance) {
		$this->_owningClassInstance = $owningClassInstance;
		$this->_owningClassName = get_class($owningClassInstance);
	}
}

Я назвал базовый класс примесей Base просто потому, что в моем проекте он принадлежит пространству имен Mixins и называть его более конкретно не требуется. Но вы можете назвать его как вам удобно.

Работать с публичными полями и методами мы можем напрямую через переменную owningClassInstance. А вот со скрытыми и защищенными придется работать через отражение. Ничего сложного. Привожу все определения функций:

<?php
protected $_owningPropertyReflectionCache;
 
	protected $_owningMethodReflectionCache;
 
	protected function getProtected($name) {
		if (! isset($this->_owningPropertyReflectionCache[$name])) {
			$property = new \ReflectionProperty($this->_owningClassName, $name);
			$property->setAccessible(true);
			$this->_owningPropertyReflectionCache[$name] = $property;
		}
		return $this->_owningPropertyReflectionCache[$name]->getValue($this->_owningClassInstance);
	}
 
	protected function setProtected($name, $value) {
		if (! isset($this->_owningPropertyReflectionCache[$name])) {
			$property = new \ReflectionProperty($this->_owningClassName, $name);
			$property->setAccessible(true);
			$this->_owningPropertyReflectionCache[$name] = $property;
		}
		$this->_owningPropertyReflectionCache[$name]->setValue($this->_owningClassInstance, $value);
	}
 
	protected function invokeProtected($name, $parameters) {
		$method = new \ReflectionMethod($this->_owningClassName, $name);
		$method->setAccessible(true);
		$parameters = func_get_args();
		unset($parameters[0]);
		$method->invokeArgs($this->_owningClassInstance, $parameters);
	}

Обратите внимание на то, что здесь я снова задействовал кэширование, чтобы не создавать и не настраивать постоянно экземпляры системных классов для работы отражения. Для сокращения потребления памяти от кэширования можно отказаться, если необходимо.

Кто-то, возможно, уже заметил, что функции method_exists() и property_exists(), которые мы использовали в классе реестра проверяют у примеси наличие и скрытых и защищенных функций с данным именем, наряду с публичными. Это приводит к тому, что у класса-агрегатора получится «попытаться» вызвать и функцию с таким именем, если она определена как скрытая или защищенная. В результате мы все равно получим ошибку, но я предпочитаю сделать это явно:

<?php
public function __call($name, array $arguments) {
		throw new MemberNotFoundException(
				"Method $name is not defined or is not accessible in mixin \"" . get_class() . "\"");
	}
 
	public function __get($name) {
		throw new MemberNotFoundException(
				"Property $name is not defined or is not accessible in mixin \"" . get_class() . "\"");
	}
 
	public function __set($name, $value) {
		throw new MemberNotFoundException(
				"Property $name is not defined or is not accessible in mixin \"" . get_class() . "\"");
	}

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

Гм. Вот и все. Примесь готова к употреблению. Остался последний шаг – реализация платформы для подмешивания примесей — классы-агрегаторы. Этим мы сейчас и займемся.

Пишем класс-агрегатор.

Что у нас умеет класс-агрегатор? Он умеет хранить в себе экземпляры классов примесей и вызывать их методы. Ну и к свойствам обращаться. Реализовывать такое поведение мы будем с использованием «магических» методов PHP.

<?php
class Aggregator {
 
	protected $_mixins;
 
	protected $_className;
 
	public function __construct($aggregatorClassInstance = false) {
		$this->_className = $aggregatorClassInstance ? get_class($aggregatorClassInstance) : get_class($this);
		$mixinNames = Registry::getMixinsFor($this->_className);
		foreach ($mixinNames as $mixinName) {
			$this->_mixins[$mixinName] = new $mixinName($aggregatorClassInstance ? $aggregatorClassInstance : $this);
		}
	}
}

В коде конструктора мы просто получаем список примесей для класса, затем в цикле по ним проходимся и создаем их экземпляры.

Переменная $aggregatorClassInstance служит для того, чтобы нам было необязательно наследовать наш класс от класса Aggregator. Мы сможем включить класс Aggregator в другой класс и вызывать его конструктор с параметром $aggregatorClassInstance равным экземпляру этого другого класса. Соответственно, при этом мы получим список примесей для этого класса-владельца и передадим примесям соответствующий экземпляр класса-агрегатора.

Если объяснение выше показалось вам слишком сложным – не беда. Скользните чуть ниже, там есть примеры. Посмотрите, чем отличается пример Inheritance от примера Composition и как они работают.

Реализуем «магические методы».

<?php
public function __call($name, array $arguments) {
		return call_user_func_array(array($this->_mixins[Registry::getMixinNameByMethodName($this->_className, $name)], 	$name), $arguments);
	}
 
	public function __get($name) {
		return $this->_mixins[Registry::getMixinNameByPropertyName($this->_className, $name)]->$name;
	}
 
	public function __set($name, $value) {
		$this->_mixins[Registry::getMixinNameByPropertyName($this->_className, $name)]->$name = $value;
	}
 
	public function __isset($name) {
		return isset($this->_mixins[Registry::getMixinNameByPropertyName($this->_className, $name)]->$name);
	}

Каждый из магических методов обращается к реестру за информацией. Все просто.

Класс исключения, который мы использовали, выглядит так:

<?php
class MemberNotFoundException extends \Exception {}

Посмотрим на несколько примеров

Сначала на традиционную схему с наследованием:

<?php
class MixinAggregatorSample extends Mixins\Aggregator {
 
}
 
class MixinHello extends Mixins\Base {
 
	protected $inaccessible;
 
	public $text = "I am a text!\r\n";
 
	public function hello() {
		echo ("Hello from mixin!\r\n");
	}
}
 
Mixins\Registry::register("MixinAggregatorSample", "MixinHello");
 
$a = new MixinAggregatorSample();
$a->hello(); //Accesing mixed methid
echo ($a->text); //Accessing mixed property
$a->text = "I am also a text!\r\n"; //Setting mixed property
//$a->inaccessible = 'Error here'; //Throws exception
//$a->inaccessible2 = 'Error here'; //Throws yet another exception (Homework: explain, why)
echo ($a->text);
var_dump(isset($a->text));

А теперь взгляните на схему с включением:

<?php
class MixinAggregatorSample {
 
	protected $_aggregator;
 
	public function __construct() {
		$this->_aggregator = new Mixins\Aggregator($this);
	}
 
	public function __call($name, $arguments) {
		return $this->_aggregator->__call($name, $arguments);
	}
}
 
class MixinHello extends Mixins\Base {
 
	public function hello() {
		echo ("Hellp from mixin!");
	}
}
 
Mixins\Registry::register("MixinAggregatorSample", "MixinHello");
 
$a = new MixinAggregatorSample();
$a->hello();

Видите разницу? В случае с включением мы свободны унаследовать наш класс-агрегатор от любого другого без потери функционала. Разумеется, для его нормального использования придется реализовать все магические методы, а не только __call().

Быстродействие

Я произвел некоторые замеры быстродействия получившейся библиотеки. Замеры очень приблизительные, проведены на домашнем компе с открытой IDE, Winamp и всем, что полагается

Time native: 0.57831501960754
Time byname: 1.5227220058441
Time mixed: 7.5425450801849
Time reflection: 12.221807956696
  • Native – время прямого вызова метода класса в PHP
  • Byname —  время вызова метода класса через название $myClass->$methodName
  • Mixed – время вызова подмешенного метода
  • Reflection – время вызова подмешенного метода, изменяющего свойство класса через Reflection. Т.е. = mixed + reflection.
  • Время приведено в секундах для 800.000 вызовов.

Я думаю, приведенные цифры вполне приемлемы для того, чтобы подобный подход можно было использовать в большом проекте. Как правило, методы примесей не вызываются тысячи раз в скрипте и 10 микросекунд на вызов метода против 0,7 микросекунд для родных методов вполне приемлемый вариант. Особенно, если учитывать, что время, уходящее на htmlspecialchars(), например, на большом объеме текста или на выполнение запроса к БД гораздо выше.

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

Эпилог

С удовольствием выслушаю вашу критику в отношении данной статьи. Особенно меня интересует, удалось ли мне сделать материал понятным для всех читателей.

Разумеется, данная статья не претендует на полноту, точность или свободу от ошибок. Буду очень признателен, если вы меня поправите. Код получившейся библиотеки выкладываю сюда. Разумеется, проект учебный и перед его использованием в реальном проекте стоит хорошо подумать и все оттестировать. Некоторые проблемные моменты как то несколько примесей с одинаковыми именами публичных методов в одном классе наличествуют.

Если тема примесей в PHP вас заинтересовала, предлагаю также пройтись по гуглу.

<?php

mixin Timeable {

private $timeStarted;

private $timeStopped;

public function start() { $timeStarted = time(); }

public function stop() { $timeStopped = time(); }

public function getElapsed() { return $timeStopped — $timeStarted }

}

mixin Dumpable {

public function dump() { var_dump($this); }

}

class MyClass mixing Timeable, Dumpable {

public function hello() {}

}

$a = new MyClass();

$a->start();

sleep(250);

$a->stop();

echo $a->getElapsed();

$a->dump();

?>

Метки: , ,

5 комментариев “ООП Практикум PHP5: эмуляция примесей (mixin) в языке”

  1. OZ говорит:

    Здравствуйте.
    Объясните, пожалуйста, преимущества Вашей модели перед той, что описана здесь: http://schleicher.ru/blog/183.html Использование реестра я не считаю преимуществом. И, на мой взгляд, использование кэширования лишь затрудняет понимание кода, уменьшает его прозрачность и увеличивает количество возможных багов. Ваш код без кэширования был бы гораздо интереснее. Просто для понимания той модели примесей, которую Вы придумали.

  2. admin говорит:

    Спасибо за внимание, в самое ближайшее время я опубликую статью с анализом. Мне самому стало интересно 🙂

  3. FractalizeR’s WebSite » ООП практикум в PHP5: Анализ ошибок, преимуществ и недостатков двух реализаций примесей в PHP говорит:

    […] выхода моей статьи о примесях мы продолжали дискуссии и в комментариях к статье и в […]

  4. FractalizeR’s WebSite » Примеси VS делегирование: преимущества и недостатки говорит:

    […] в некотором роде является логическим продолжением этой статьи о реализации примесей в PHP. Если вам интересно […]

Ответить

Для отправки комментария вам нужно зарегистрироваться. Войти.