Paramètres nommés en PHP

tbowan

15 Avril 2018

Contrairement à de nombreux languages, le PHP ne permet pas le passage de paramètres nommés. Dans cet article, je vous présente un adapteur générique pour ajouter cette possibilité au language.

Un tableau pour passer les paramètres

Il y a quelques temps, je relisais un morceau de code et je suis tombé sur une façon originale de passer les paramètres, en utilisant un tableau. Quelque chose de ce genre :

$res = $obj->method(["foo" => "bar"]) ;

Comme c'est la première fois que je rencontre cet idiome dans ce code, je vais voir la fameuse méthode que je trouve facilement et qui, en gros, fait comme suit1 :

class SomeClass
{

    public function method($params)
    {
        // Get Parameters
        $foo = $params["foo"] ?: "default" ;
        $bar = $params["bar"] ?: "default" ;

        // Compute result
        return $foo . $bar ;
    }

}

Je m'étonne que cette fonction ne déclare pas simplement deux paramètres avec leurs type et leur valeurs par défaut et j'imagine déjà tous les risques en matière de maintenance qu'elle contient...

Du coup, parce que je suppose que l'auteur de ces lignes avait de bonnes raisons pour les écrire comme il l'a fait, je vais à la chasse aux informations et fini par trouver la raison de cet idiome...

<tbowan> C'est quoi l'idée derrière le tableau pour tous les paramètres ?

<tintin>2 C'est pour les passer en les nommant, comme en python.

<tintin> Je préfèrerais écrire method(foo: "bar") mais php le permet pas.

Et oui, contrairement à d'autres langages comme python, C# et d'autres, le PHP ne permet pas de passer les paramètres en les nommant, ce qui peut manquer quelques fois3.

Alors sans plus attendre, voici une méthode pour ajouter cette possibilité à PHP sans toucher aux fonctions appelées ! Autant vous spoiler la fin tout de suite, on va définir un adapter qui fera la traduction entre le code appelant avec un seul tableau et vos méthodes et leurs paramètres.

Exemple de classe à adapter

Avant de commencer, voici un exemple de classe PHP que nous allons adapter. Le seul but de cette classe étant de servir d'exemple, ses méthodes ne font rien d'utile.

class Sample
{

    public static function logMsg($foo = "foo", $bar = "bar", $method = null)
    {
        echo ($method ?: "logMsg") ;
        echo " - foo => $foo" ;
        echo " - bar => $bar\n" ;
    }
    
    public $foo ;
    public $bar ;
    
    public function __construct($foo = "foo", $bar = "bar")
    {
        $this->foo = $foo ;
        $this->bar = $bar ;
        self::logMsg($foo, $bar, "__construct") ;
    }

    public function doStuff($foo = "foo", $bar = "bar")
    {
        self::logMsg($foo, $bar, "doStuff") ;
    }

    public function __toString()
    {
        return "{foo : {$this->foo}, bar : {$this->bar}}" ;
    }

}

Adapter les méthodes

Commençons donc par la méthode commune à tous les cas, et qui se charge du coeur du sujet, gérer le tableau fournis pour appeler la méthode qu'on veut.

Pour ça, on va utiliser l'API de réflection de PHP et en particulier, la classe ReflectionMethod qui permet de manipuler les méthodes comme si c'étaient des objets.

function invokeWithArray($method, $instance, $parameters)
{
    $args = [] ;
    
    foreach ($method->getParameters() as $param) {
        $name = $param->getName() ;
        if (isset($parameters[$name])) {
            $args[$name] = $parameters[$name] ;
            unset($parameters[$name]) ;
        } else if ($param->isDefaultValueAvailable()) {
            $args[$name] = $param->getDefaultValue() ;
        } else {
            throw new \Exception("parameter missing") ;
        }
    }
    
    if (count($parameters) > 0) {
        throw new \Exception("Too many arguments") ;
    }
    
    // Appel de la méthode avec les paramètres
    return $method->invokeArgs($instance, $args) ;
}

Bien sûr, on pourrait imaginer améliorer cette fonction en lui ajoutant la gestion des types (via quelque chose du genre instance of $params->getType()) mais ça alourdi l'exemple et sera de toutes façons fait lors de l'appel à invokeArgs().

Avec ça, vous pouvez déjà appeler n'importe quelle méthode mais ce n'est pas très pratique car vous devrez récupérer la ReflectionMethod correspondante puis faire votre appel. On va donc améliorer le système.

Adapter les classes

Pour adapter facilement n'importe quelle classe, on va définir une classe abstraite qui servira de base à toutes les autres. Et pour éviter de devoir définir toutes les méthodes possibles, nous utilisons les méthodes magiques du PHP, à savoir :

__call()
qui est appelée lorsque la méthode correspondante n'est pas définie pour l'objet. Le premier paramètre est nom de la méthode, le deuxième est le tableau contenant les paramètres.
__callStatic()
qui fait la même chose mais pour les méthodes statiques.
__invoke()
qui permet d'utiliser l'objet comme étant une fonction. Je m'en sert pour déréférencer l'adapteur et obtenir une référence sur l'objet adaptés.
abstract class ArrayAdapter
{

    abstract public static function getReflectionClass() ;

    private $target ;

    public function __construct($args)
    {
        $class = static::getReflectionClass() ;
        $this->target = $class->newInstanceWithoutConstructor() ;

        $ctor = $class->getConstructor() ;
        invokeWithArray($ctor, $this->target, $args) ;
    }

    public function __call($name, $args)
    {
        if (count($args) != 1) {
            throw new \Exception("Wrong number of arguments") ;
        }
        $class = static::getReflectionClass() ;
        $method = $class->getMethod($name) ;
        return invokeWithArray($method, $this->target, $args[0]) ;
    }

    public static function __callStatic($name, $args)
    {
        if (count($args) != 1) {
            throw new \Exception("Wrong number of arguments") ;
        }
        $class = static::getReflectionClass() ;
        $method = $class->getMethod($name) ;
        return invokeWithArray($method, null, $args[0]) ;
    }
    
    public function __invoke()
    {
        return $this->target ;
    }

}

Pour construire un adapteur pour une classe (i.e. celle d'exemple), il suffit donc de créer une classe qui étende le ArrayAdapter et de définir la méthode getReflectionClass(). Quelque chose dans ce goût là :

class AdaptedSample extends \ArrayAdapter
{
    public static function getReflectionClass() {
        return new \ReflectionClass("Sample") ;
    }
}

Mais là où le PHP dépasse beaucoup de langages4, c'est qu'on peut même automatiser tout ça.

Meta-programmation

Pour adapter n'importe quelle classe sans devoir écrire de code, nous allons utiliser trois fonctionnalités très pratique du PHP :

  1. Les espaces de nom,
  2. Le chargement automatique de classes,
  3. L'exécution de code arbitraire5.

L'idée est donc de définir un espace de nom dédié aux adapteurs (Adapted dans notre cas) dans lequel les classes seront construites automatiquement lorsqu'on les appelera6.

spl_autoload_register(function ($name) {

    if (strpos($name, "Adapted") !== 0) {
        return ;
    }

    $parts = explode("\\", $name) ;
    $classname = array_pop($parts) ;
    $namespace = implode("\\", $parts) ;
    $targetClass = str_replace("Adapted\\", "", $name) ;

    // Meta programming here
    eval("
        namespace $namespace ;
        class $classname extends \ArrayAdapter
        {
            public static function getReflectionClass() {
                return new \ReflectionClass('$targetClass') ;
            }
        }
        ") ;

}) ;

Avec ça, il n'est même plus nécessaire d'écrire du code pour adapter vos méthodes, l'autoloader s'en charge pourvous :).

use Adapted\Sample ;

$a = new Sample(["bar" => "new"]) ; // __construct - foo => foo - bar => new
$a->doStuff(["bar" => "new"]) ;     // doStuff     - foo => foo - bar => new
Sample::logMsg(["bar" => "new"]) ;  // logMsg      - foo => foo - bar => new
echo $a() . "\n";                   // {foo : foo, bar : new}

Conclusion

L'intérêt des langages interprêtés sur les langages compilés est, entre autre, leur facilité d'introspection. Comme on le voit ici, il est possible d'écrire du code qui va s'adapter automatiquement à n'importe quel code.

Dans le cas qui nous occupe, ça nous permet d'ajouter une fonctionnalité manquante au langage, le passage de paramètres par leurs noms. On peut alors les passer dans l'ordre qu'on veut et le code appelant est plus lisible puisque le nom du paramètre est lisible directement.

Mais cette possibilité des paramètres nommés est pour moi dangereuse car elle masque un problème plus profond de maintenabilité en facilitant la définition de méthodes ayant un grand nombre de paramètres. Ce type de programmation ne devrait donc être utilisée que si aucune refactorisation du code n'est possible.


  1. La vraie méthode faisait en réalité 500 lignes, faisait des if/else plutôt que d'utiliser l'opérateur ?: et utilisait une disaine de paramètres, pas très photogénique. Là, je vous fait une version plus light...

  2. Pour garder son anonyma, le pseudonyme a été changé.

  3. Des RFC ont été proposées pour PHP 5.6 et PHP 7.x mais n'ont pour l'instant pas abouti.

  4. Des fois que vous doutiez de sa supériorité naturelle ;-).

  5. Souvent considérée comme (très) dangereuse pour la sécurité, on peut quand même l'utiliser pour ajouter de la magie à nos codes...

  6. Encore une fois, le code d'exemple est simplifié. En vrai, je fais une vérification que la classe à adapter existe et j'écrit le template dans un fichier à part que j'inclu via une temporisation de sortie.