Paramètres nommés en PHP 5 et 7

Divulgâchage : Contrairement à de nombreux languages, le PHP (avant la version 8) ne permet pas le passage de paramètres nommés. Voici un adapteur générique pour ajouter cette possibilité au langage. On va définir un adaptateur qui fera la traduction entre le code appelant avec un seul tableau et vos méthodes et leurs (nombreux) 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"]) ;
jplenio @ pixabay

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 suit :

class SomeClass
{

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

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

}

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

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 :

C’est pour les passer en les nommant, comme en python. Je préfèrerais écrire method(foo: "bar") mais php le permet pas.

Tintin (prénom d’emprunt)

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

Alors sans plus attendre, voici une méthode pour ajouter cette possibilité à PHP sans toucher aux fonctions appelées !

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 cœur du sujet : gérer le tableau fourni pour appeler la méthode qu’on veut.

Pour ça, on va utiliser l’API de réflexion 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. Vous récupérez la ReflectionMethod correspondante puis faites votre appel.

$refClass  = new ReflectionClass(get_class($obj) ;
$refMethod = $refClass->getMethod("method") ;
$res       = invokeWithArray($refMethod, $obj, ["foo" => "bar"]) ;

Mais ça n’est pas encore ça. Si le but était de simplifier, c’est un peut raté… 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 :

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 adaptateur 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") ;
    }
}

C’est déjà mieux mais si on doit créer une nouvelle classe à chaque fois, on va jamais s’en sortir. C’est là où le PHP dépasse beaucoup de langages, on peut automatiser tout ça.

Métaprogrammation

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

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

eval() Souvent considérée comme (très) dangereuse pour la sécurité, on peut quand même l’utiliser avec parcimonie pour ajouter de la magie à nos codes…

L’idée est donc de définir un espace de nom dédié aux adaptateurs (e.g. \\Adapted\\ dans notre cas) dans lequel les classes seront construites automatiquement lorsqu’on en a besoin.

Encore une fois, ce code est simplifié. En vrai, je fais une vérification que la classe à adapter existe et plutôt que « éval()uer » une chaîne, j’écris le template dans un fichier à part que j’inclus tout en récupérant le résultat via une temporisation de sortie. Mais restons simples pour l’instant.

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) ;

    // Here be dragons
    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 pour vous :).

use Adapted\Sample ;

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}

Et après ?

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 indiqué 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 aucun nettoyage du code n’est possible.