F2R Articles

All articles wrote by F2R: PHP, methodology, web, architecture, development, programming, and so.

Arrêtez d'utiliser les pseudo-types

(Publié le 20 jan 2025 - English version)

En 2011, pour la sortie de PHP 5.4, le pseudo type callable a été introduit par la RFC: Callable, et en 2016, un autre pseudo type, iterable, a été introduit en PHP 7.1.0 par la RFC: Iterable.

Les pseudo-types ne sont pas des vrais types comme string ou array. Ce sont des ensembles de types avec une logique de validation particulière. Voyons le pseudo-type callable pour mieux comprendre.

Le pseudo-type callable

En PHP 4, la fonction call_user_func() a été introduite pour permettre d'appeler une fonction à partir de son nom sous forme d'une chaîne de caractères. Il y avait également une fonction call_user_method(), mais elle a été rapidement dépréciée en PHP 4.1 et supprimée en PHP 7.0, car call_user_func() acceptait également un tableau où le premier élément prenait un objet et le second le nom de la méthode.

<?php
$format = [new Datetime('2025-01-30'), 'format'];
echo call_user_func($format, 'd/m/Y');
// Résultat : 30/01/2025

Try this code

La version PHP 4.0.6 a introduit la fonction is_callable(), permettant de vérifier si un paramètre est appelable." En effet, une chaîne ou un tableau peuvent être appelables, mais seulement dans certaines conditions.

<?php
$create = 'Datetime::createFromFormat';
$invoke = [new stdClass, '__invoke'];

var_dump(is_callable($create), is_callable($invoke));
// Résultat:
// bool(true)
// bool(false)

Try this code

Je vous suggère la lecture de l'article de mon ami Damien Seguy : How to call a method in PHP.

Pour qu'une chaîne soit appelable, il faut qu'elle contienne le nom d'une fonction ou d'une méthode existante. Ainsi, trim est appelable, mais unknown ne le sera pas si vous n'avez pas de fonction portant ce nom.

Attention, certains noms ne seront pas appelables, tels que isset, empty, echo ou include. exit et die n'étaient pas appelable avant PHP 8.4, mais elles le sont désormais : PHP RFC: Transform exit() from a language construct into a standard function

Petite lecture intéressante : A Look At PHP’s isset().

Bref, au moment de l'exécution, on ne peut pas savoir si une chaîne est appelable ou non.

C'est en mars 2012, avec la sortie de PHP 5.4, que la logique de la fonction is_callable() a été déplacée pour introduire le pseudo-type callable. C'est pour cela que l'on parle de pseudo-type : callable n'est pas un type, c'est une union des types Closure, string et array avec une logique pour vérifier, lors de l'exécution, qu'un paramètre est bien appelable. À la compilation, à moins que ce soit une Closure, on ne peut pas savoir si une fonction ou une classe existera lors de sa validation. C'est d'ailleurs pour cette raison qu'il n'est pas possible d'utiliser callable pour typer les propriétés d'une classe : PHP RFC: Typed Properties 2.0, Supported Types.

Le pseudo-type (ou pas) iterable

En PHP 7.1, le pseudo-type iterable a été introduit. Il permettait de vérifier que l'on pouvait itérer dessus en validant qu'il implémentait le type array ou l'interface Traversable. C'est avec la sortie de PHP 8.2 que iterable a perdu son statut de pseudo-type pour devenir une union de types Traversable|array.

Pour mieux comprendre cette différence, regardez le comportement de ce code en PHP 8.1 et en 8.2 :

<?php

function foo(): iterable {
    return [];
}

function bar(): iterable|bool {
    return [];
}

echo (new ReflectionFunction('foo'))->getReturnType()->__toString();
echo ", ";
echo (new ReflectionFunction('bar'))->getReturnType()->__toString();

Try this code

Ce changement interne a été introduit par la RFC: Make the iterator_*() family accept all iterables.

Seulement, l'interface Traversable est un peu particulière et pourrait presque s'assimiler à un pseudo-type également, car c'est une interface qui n'est pas implémentable mais qui est étendue par les interfaces Iterator et IteratorAggregate. En fait, le type iterable aurait dû être une union de types : Iterator|IteratorAggregate|array

Typage de données avec iterable

Le type iterable ne devrait pas être utilisé pour typer un retour de fonction ou de méthode, car cela peut générer de la confusion si la donnée est mal utilisée. Il n'est pas rare de voir des développeurs et des développeuses peu rigoureux et rigoureuses qui ne se limitent pas à itérer sur le résultat d'une fonction, mais qui veulent manipuler celui-ci. En constatant, par exemple, que la donnée retournée est un tableau, illes peuvent se laisser tenter par l'utilisation d'un count afin de savoir s'il y a des valeurs pour afficher un texte alternatif. Seulement, ce type pourrait évoluer et retourner, plus tard, un itérateur. Exemple:

<?php
class A {
    public function get(): iterable {
        return [17];
    }
}

class B extends A {
    public function get(): iterable {
        yield 42;
    }
}

echo (new A)->get()[0]; // 17
echo (new B)->get()[0]; // Fatal error

Try this code

Alors, oui, je vous l'accorde, c'est une erreur grossière, et l'usage d'un bon analyseur statique comme PHPStan ou Exakat ne doit pas permettre ce genre de chose. Seulement, dites-vous que les développeurs et les développeuses font des erreurs, surtout quand illes débutent.

Pour gérer correctement cette variation de résultat, on doit donc tester si la donnée retournée est un tableau (is_array) ou un itérateur (instanceof Iterator), ou alors utiliser une fonction comme iterator_to_array(), comme ceci:

<?php
// ...
echo iterator_to_array((new B)->get())[0];

Try this code

Cette solution fonctionne, mais peut avoir des conséquences sur la performance de votre code, surtout si l'usage d'un Generator a été justement prévu pour pallier l'occupation mémoire d'un résultat de grande taille.

Une bonne approche Defensive programming consiste donc à limiter les possibilités d'utilisation, en proposant soit un tableau, soit un itérateur. Personnellement, j'ai tendance à privilégier les itérateurs par l'intermédiaire des générateurs.

Quant au typage de paramètres dans une fonction ou une méthode, utiliser iterable indiquera que votre implémentation va seulement itérer sur votre donnée, et vous pouvez donc passer un tableau ou un itérateur indifféremment. Cela va donc améliorer la Developer eXperience (DX).

Typage de données avec callable

Autant l'usage de iterable est discutable, autant callable l'est moins. Déjà, parce qu'il n'est pas possible de l'utiliser pour typer une propriété. Ensuite, parce qu'il autorise les développeuses et les développeurs à définir l'appel sous la forme d'une chaîne, et cette forme pose énormément de problèmes quand on fait de l'analyse statique ou juste quand on fait une recherche sur l'usage d'une méthode, par exemple.

Même si les IDE, aujourd'hui, font beaucoup de progrès, il peut être difficile pour eux de trouver les usages d'une fonction quand celle-ci est définie dans une variable sous la forme d'une chaîne de caractères.

De plus, avec les first class callables, vous n'avez plus d'excuse pour définir vos fonctions de rappel sous cette forme :

<?php
$data = array_map(trim(...), [' x', 'z  ']);

Try this code

Il est donc préférable d'utiliser la classe Closure quand vous souhaitez définir des fonctions de rappel. Si vous avez une version antérieure à PHP 8.1, et que vous ne pouvez donc pas utiliser les first class callables, je vous suggère d'utiliser Closure::fromCallable():

<?php
$data = array_map(
    Closure::fromCallable('trim'),
    [' x', 'z  ']
);

Try this code

Typage des propriétés avec des Closures

Un point de détail quand on utilise des propriétés du type Closure, c'est qu'on ne peut pas appeler la closure de cette façon :

<?php

class A {
    public function __construct(
        private Closure $callback
    ) {}
    
    public function show(): void {
        $this->callback();
    }
}

Try this code

Cela génère une erreur Fatal error: Uncaught Error: Call to undefined method A::callback(). Ce qui est plutôt logique, car il devient difficile de faire la différence entre un appel de méthode et une propriété invocable.

Il faut donc appeler la propriété de cette façon : ($this->callback)(); (Try this code).

Vous pouvez aussi faire comme ceci : call_user_func($this->callback); (Try this code).

Mais il y a une autre méthode intéressante : la classe Closure dispose d'une méthode __invoke. Vous pouvez donc écrire : $this->callback->__invoke(); (Try this code). Je trouve cette écriture séduisante car elle est très explicite : "On invoque la closure".

Conclusion

Les pseudo-types comme callable et iterable peuvent sembler pratiques à première vue, mais ils introduisent des ambiguïtés et rendent le code plus difficile à analyser.

Privilégier l'usage de types clairs et explicites permet non seulement d'améliorer la lisibilité du code, mais aussi de réduire les risques d'erreurs. Par exemple, remplacer callable par Closure facilite la recherche de méthodes dans un projet et optimise les performances du compilateur. De même, utiliser des générateurs au lieu d'un simple type iterable permet de mieux gérer les grands ensembles de données tout en garantissant une meilleure Developer eXperience.

De plus, si vous utilisez des analyseurs statiques comme PHPStan (vous devriez si ce n'est pas le cas), completez vos types Closure avec un maximum d'information : callable typehint.

En résumé, évitez les pseudo-types lorsque vous le pouvez. Utiliser des types précis renforce la robustesse du code et permet d'exploiter pleinement les outils modernes d'analyse statique comme PHPStan. Cela vous aidera à produire du code plus maintenable et performant — un objectif que tout développeur devrait viser.


Suivez-moi sur Bluesky: @bouchery.fr

Flux Atom en français