Entries for tag traits

Thu Jun 03 2010

PHP, Programming

php, traits

8 comments

Un point sur les Traits de PHP

Dernièrement, les Traits ont fait leur entrée dans le trunk de PHP. Je ne vais pas les présenter dans leur intégralité puisque la RFC correspondante dont les premières esquisses ont plus de deux ans, est suffisamment claire et détaillée.

Cependant, le document n'est pas très explicite en ce qui concerne l'utilisation d'interfaces et de traits au sein d'une même classe, ainsi que le statut des variables membres et leur accessibilité dans les méthodes des traits. Nous allons voir ce qu'il en est et les conséquences que cela implique.

Avant de commencer, il est vivement recommandé d'avoir lu la RFC. Juste la première partie qui concerne les traits. La seconde partie présente les Grafts que nous n'aborderons pas ici [1].

L'opération de flattening, aka le lissage

L'ordre dans lequel les traits sont manipulés par php est le suivant:

  • Un fichier source est chargé, et comme par hasard, celui-ci contient des traits.
  • Le parser rencontre une déclaration de trait. Ce dernier est pris en compte comme n'importe quelle classe [2]. C'est à dire qu'une structure zend_class_entry est crée et complétée. Seul un flag spécial précise qu'il s'agit d'un trait.
  • Le même parser rencontre une classe qui utilise un trait via le mot clé use ou un trait qui dépend d'un autre trait (on dit qu'il s'agit d'un trait composite). A la fin de la déclaration de la classe, que l'on appelle une client class ou host class, les opérations s'enchainent ainsi:
    • Les méthodes provenant des traits sont triées selon les directives as et insteadof. Une nouvelle table temporaire est construite accueillant ces méthodes candidates à la future intégration dans la classe. Certaines méthodes peuvent être ignorées s'il y a des conflits. On se chope alors un "Warning: Trait method blah has not been applied, because there are collisions with other trait methods".
    • L'opération de flattening (lissage) commence. Pour chaque méthode candidate qui n'est pas redéfinie (overridden) dans la classe client:
      • Vérification du prototype (la signature) par rapport aux éventuelles méthodes des classes parents. S'il y a des différences, et selon la configuration, c'est là qu'on reçoit un "Strict Standards: Declaration of Machin::truc() should be compatible with..".
      • La méthode est dupliquée (son zend_op_array est littéralement copié comme le fait l'extension runkit) et est ajoutée à la table des méthodes de la classe client. Certaines opérations irréversibles se produisent, notamment le fait que la méthode perd toute notion et tout lien avec son trait d'origine [3].
    • Vérification de la bonne conformance de la classe (maintenant dotée de nouvelles méthodes provenant des traits) par rapport aux méthodes abstraites ainsi qu'aux interfaces sensées être implémentées.
  • Le parser continue son job jusqu'à la fin du fichier.

OK, voila une bonne chose de faite.

Sachant que les méthodes des traits sont injectées pratiquement "les yeux fermés", tout se passe comme si celles-ci étaient définies naturellement dans la classe client. Les accès à $this se font exactement de la même manière, rien ne change.

On peut dès lors s'interroger sur certains idiomes qui ne devraient pas tarder à fleurir sur les coins de tables en cette période pré-estivale. En effet, la nature stateless des traits tels qu'implémentés à l'heure actuelle dans php n'est pas sans conséquence quant à l'organisation du code.

L'état des traits

En fait, ils n'en ont pas. On dit qu'ils sont stateless. C'est un choix délibéré et argumenté dans les documents académiques du Software Composition Group (SCG) sur lesquels l'implémentation de php est basée.

Les traits sont uniquement fonctionnels. Ils ne sont pas liés à un quelconque état qui leur serait propre, comme cela peut être le cas dans de l'héritage multiple. Par état, il faut comprendre variables membres. Seule la classe client peut en fournir.

Or, il y a fort à parier que des traits ne manipulant aucun état se feront rares.

Donc, que faire ?

Je vais mettre de coté l'utilisation de variables globales, statiques ou de Singletons. On veut juste faire en sorte que les traits puissent utiliser les variables membres de la classe client. Il n'y a pas 50 possibilités, il y en a juste 2:

  • Accès direct aux variables via $this->
  • Recours à des getters/setters fournis par la classe client

Quoique l'on fasse, ou que je sois, rien ne t'efface, je pense à toi, le trait est dépendant de sa classe client. Accéder directement à une variable avec $this->var peut (pourrait, pourra, pouvait, a pu?) sembler briser l'encapsulation, et nous reviendrons sur ce point. Utiliser des getters et setters est la méthode conseillée par le SCG en particulier parce que leur démonstration et implémentation est basée sur le langage Smalltalk et que de toute manière ils ne peuvent faire autrement.

OK, OK. Il est grand temps qu'on balance un code illustratif car toute cette prose commence à devenir lourdingue ;)

trait T1
{
  public function doSomething() {
    $this->var = 42;
    echo $this->var;
  }
}

trait T2
{
  public function doSomethingElseButDoItWell() {
    $this->setVar(43);
    echo $this->getVar();
  }
}

class C
{
  use T1, T2;
  
  private $var;
  
  private function getVar() {
    return $this->var;
  }
  private function setVar($value) {
    $this->var = $value;
  }
}

$c = new C;
$c->doSomething();
$c->doSomethingElseButDoItWell(); // please

var_dump($c);

Cela donne, comme attendu:

4243object(C)#1 (1) { ["var":"C":private]=> int(43) }

Dépendances et requirements

La dépendance classe -> trait est simplement exprimée dans le code via le mot clé use. Celle-ci est vérifiée dès la compilation. La dépendance trait -> classe n'est quant à elle pas exprimée du tout dans le code précédent, en tout cas pas avant l'exécution. T1 dépend de la présence de la propriété $var et T2 dépend des getter et setter getVar() et setVar().

La RFC préconise l'usage de méthodes abstraites au sein des traits pour exprimer et vérifier à la compilation cette dépendance trait -> classe. Le document parle de requirements, ce qui est la même chose.

Réécrivons T2 en le bardant de méthodes abstraites afin de se conformer aux bonnes pratiques au paragraphe du dessus:

trait T2
{
  public function doSomethingElseButDoItWell() {
    $this->setVar(43);
    echo $this->getVar();
  }
  
  /*private*/ abstract function getVar(); 
  /*private*/ abstract function setVar($value);
}

Ce frêle bout de code est en fait plus profond que ce que l'on pourrait imaginer à première vue, surtout quand on sait que ça compile et que ça fonctionne.

Premièrement, bien que les getters/setters de la classe client soient de visibilité private, cela ne pose pas de problème de les déclarer public dans le trait. La règle des méthodes abstract dit pourtant que la méthode concrète doit avoir une visibilité identique ou moins restrictive que la déclaration abstraite.

Cela fonctionne parce que l'opération de lissage décrite plus haut ne concerne que les méthodes des traits qui ne sont pas redéfinies dans la classe client. Cette redéfinition ne se fait que sur le nom de la méthode. La signature n'intervient pas du tout. La méthode qui porte le même nom dans la classe va complètement masquer celle du trait. Il est donc tout à fait possible de remplacer setVar($value) dans le trait par ceci:

/*private*/ abstract function setVar($value, array $bonus);

On pourrait s'attendre à un beau "Fatal error: Declaration of C::setVar() must be compatible with that of T2::setVar()" car les signatures sont clairement différentes. Et bien non. La phase de lissage rend possible ce comportement.

Exprimer les requirements d'un trait par l'intermédiaire de méthodes abstraites n'est donc que partiel. Seul le nom de la fonction compte. La signature et les éventuels type hintings sont ignorés.

Mais alors pourquoi ça fonctionne quand même? C'est simple, toutes les méthodes abstraites du trait qui ne sont pas masquées par celles de la classe client se retrouvent dans cette même classe toujours avec le statut abstract. Là php peut détecter la non implémentation de ces méthodes et ainsi s'opère la vérification partielle des dépendances trait -> class.

Deuxièmement (le premièrement est 4 ou 5 paragraphes plus haut;) pourquoi ai-je supprimé la visibilité private pour les méthodes abstraites du trait? Après tout, les getter et setter sont privés dans la classe C.

C'est une bien sage question.

La réponse est que l'on veut éviter cet effet indésirable: "Fatal error: Abstract function T2::getVar() cannot be declared private". C'est une conséquence du fait que les traits sont traités (c'est le cas de le dire) par php comme de simples classes lors de la compilation. Ce point fut brièvement abordé plus haut ici. Or, une méthode abstraite ne peut être déclarée private car php considère que celle-ci n'est pas accessible depuis des classes dérivées et qu'il est donc impossible d'y apporter une implémentation. Quelque part, ça a du sens...

De l'existentialisme

Jusqu'ici, nos getters et setters font leur job. On n'en demande pas plus. Pourtant, un sérieux problème se profile à l'horizon.

C::getVar() retourne une copie de la variable membre $var. Sa valeur n'est pas systématiquement dupliquée, php faisant du copy-on-write, mais nous restons dans une sémantique de copie. Toute modification opérée sur ce que retourne getVar() n'aura pas de répercussion sur la véritable variable membre. Cela peut être tout à fait justifié et suffisant.

Cependant, prenons un peu de recul. Nous avons affaire à un getter privé dont l'appel se fait d'une méthode pleinement intégrée à la classe et qui pourrait tout à fait être privée elle aussi. Nous évoluons dans un no man's land à la frontière de l'encapsulation, l'indirection et l'abstraction. Le fameux triptyque des mots en ion.

Le trait pourrait avoir besoins de manipuler autre chose qu'un simple scalaire, par exemple un array. Va-t-il falloir définir toujours plus de setters pour toujours plus d'opérations telles que l'ajout, l'insertion ou le retrait d'éléments? Faut-il gonfler la classe client en méthodes annexes à faible valeur ajoutée, annulant au passage les bénéfices en copy'n'paste sensés provenir de l'utilisation même du trait?

Une solution à mi-chemin entre rien du tout, c'est à dire accéder directement à la variable membre via $this->var, et une belle série de getters/setters serait de recourir à un simple accesseur retournant une référence sur la variable. Coté trait, l'affectation classique = ou par référence =& permet de controller la nature de la liaison suivant l'opération que l'on désire effectuer, ce qui laisse à php la possibilité de faire son job routinier de passage (retour) par copie.

Il ne reste plus qu'à dégainer les variables temporaires. Oui, car comme chacun sait, php est très frileux quand il s'agit de déréférencer automatiquement les retours de fonctions. Vous aurez beau bidouiller en ajoutant des parenthèses ou des accolades, seule la dernière ligne n'est pas une erreur dans les constructions suivantes:

$this->func()[]
$this->func()()
++$this->func()
$this->func() = 
$this->func()->chain()

C'est navrant, et c'est la syntaxe qui n'est pas bonne, le problème détecté étant un parse error dans la plupart des cas. Pour contourner ce problème, il faudra d'abord récupérer le retour de la fonction dans une variable puis travailler à partir de celle-ci.

Traits et Interfaces

Nous allons clore cet article par un exemple qui réunit tout ce qui a été vu: faire endosser à un trait l'implémentation d'une interface héritée de la classe client. Cette construction est à mon sens potentiellement utile pour les auteurs de libraires et frameworks.

Une telle classe client peut être utilisée dans un contexte polymorphique ou de type hinting sans reposer pour autant sur une hiérarchie parfois bancale de classes abstraites, ni sur la présence d'adaptateurs spécifiques et verbeux dans le cadre d'une composition ou aggrégation.

On choisit d'implémenter ArrayAccess qui est automatiquement disponible dans php car fournie par la SPL.

/*
interface ArrayAccess
{
  public function offsetGet($offset);
  public function offsetSet($offset, $value);
  public function offsetExists($offset);
  public function offsetUnset($offset);
} */

trait ArrayAccessImpl
{
  abstract function &data();
  
  public function offsetGet($offset) {
    $d = $this->data();
    return $d[$offset];
  }
  
  public function offsetSet($offset, $value) {
    $d = &$this->data();
    $offset === null ? $d[] = $value : $d[$offset] = $value;
  }
  
  public function offsetExists($offset) {
    return array_key_exists($offset, $this->data());
  }
  
  public function offsetUnset($offset) {
    $d = &$this->data();
    unset($d[$offset]);
  }
}

class MyClass implements ArrayAccess
                     //, Iterator // laissé en exercice
{
  private $data = array(
    'a' => 3.14,
    'b' => null,
    'c' => true,
  );
  
  use ArrayAccessImpl;
  
  private function &data() {
    return $this->data;
  }
}

$c = new MyClass;

var_dump(isset($c['b']));
$c[] = $c['a'];
unset($c['c']);
var_dump($c);

Note: l'utilité plus que douteuse de MyClass n'est pas fortuite, il s'agit juste d'un code d'exemple (ma spécialité;)

Conclusions

Comme d'hab, RAS?

Traits're in da trunk, mais cela ne veut pas dire pour autant qu'ils seront dans le futur php, ni sous leur forme actuelle. Cependant, vu l'avancement du projet, le contraire serait étonnant.

Si tout se passe comme prévu, alors j'ai hâte de voir comment ils seront employés à l'avenir. Il s'agit d'un réel nouveau paradigme pour composer du code OOP. Par exemple, pour valider la pertinence des traits, le SCG a réimplémenté toute la hiérarchie des Collections de Smalltalk.

Je salue mageekguy pour son billet Et si on tirait des traits ? qui est à l'origine de mon soudain regain d'intérêt.

 

[1] Le statut des grafts est bien plus hypothétique puisqu'aucune implémentation n'existe à l'heure actuelle.

[2] Un trait peut déclarer des variables membres ou dériver d'une classe. Cela n'est pas interdit (pour l'instant?). Il ne peut cependant pas implémenter une interface. Cela résulte en une fatal error.

[3] Les éventuelles variables membres déclarées dans le trait auquel la méthode appartenait sont purement et simplement ignorées.