En faveur de la RFC "Scalar Type Hints"

9 février 2015php, internals@, rfc, type-hint

This post is also available in English.


La RFC Scalar Type Hints pour PHP 7 a été initialisée en décembre 2014. Elle est passée en version 0.2 mi-janvier 2015, après une refonte de plusieurs idées fondatrices et est maintenant en version 0.3 qui intègre les types de retours, puisque la RFC Return Type Declarations a été acceptée il y a quelques jours.

Avertissement : cet article fait référence à une fonctionnalité en cours de discussion (début février 2015) pour PHP 7. Comme pour toute proposition en cours de discussion, il est possible que cette fonctionnalité soit acceptée, qu’une version alternative le soit, ou même qu’elle soit entièrement refusée — que ce soit pour PHP 7.0 ou pour une version ultérieure. La vérité à propos de cette fonctionnalité — ou de son absence — se trouvera dans le manuel de PHP et dans les guides de migration.

Je suis cette RFC (et celles qui l’ont précédées) avec un certain intérêt et puisque j’ai pris le temps de jouer un peu avec la semaine dernière, en construisant PHP depuis les sources de la branche Git correspondant, je vais essayer de résumer ici pourquoi je pense qu’elle est intéressante. Notez qu’il s’agit de mon avis personnel.

Pourquoi des type-hints (scalaires) ?

PHP supporte les type-hints1 sur les types complexes depuis déjà un bon moment : PHP 5.0 pour les objets, 5.1 pour array et 5.4 pour callable. La question d’étendre ce support aux types scalaires (entier, flottant, booléen et chaîne de caractères) est revenue plusieurs fois ces dernière années, à chaque fois avec un peu plus de soutien.

À mes yeux, les type-hints, scalaires ou non, peuvent et/ou doivent répondre à plusieurs objectifs :

  1. Rendre le code explicite : dès la lecture du prototype d’une fonction2, qui est souvent affiché par mon IDE lorsque j’écris un appel de fonction, sans avoir à aller lire ni son code-source ni sa documentation, les type-hints permettent de savoir quels types de données elle attend.
  2. Assurer que, au sein de la fonction, on ait une donnée du type attendu — sans avoir à coder soi-même les vérifications correspondantes.
  3. Être compatibles avec la philosophie et l’historique souple de PHP.
  4. Ne pas casser la compatibilité des librairies spécifiant des type-hints avec le code les appelant, code qui ne travaille pas nécessairement avec du typage strict.

Avec la proposition introduite par cette RFC, si elle passe, une fonction attendant un entier pourrait être déclarée comme ceci :

function ma_fonction_01(int $entier)
{
    var_dump($entier);
}

Si cette fonction est appelée en lui passant en paramètre autre chose qu’un entier, l’appel échouera et une erreur rattrapable sera levée — comme pour les autres type-hints actuellement disponibles :

Catchable fatal error: Argument 1 passed to ma_fonction_01() must be of the type integer, string given

Vu comme ça, ce type-hinting pour les scalaires ne semble guère difficile à manipuler.

Du typage souple…

Une des grandes forces de PHP, un des éléments clef à la base de son accessibilité, est son principe de typage souple, avec des conversions de types effectuées lorsque nécessaire.

Par exemple, pour une URL comme http://example.org/index.php?id=123, l’entrée $_GET['id'] contiendra la chaîne de caractères "123" — qui pourra être manipulée avec la quasi-totalité des fonctions de PHP comme s’il s’agissait d’un entier, sans que nous n’ayons à nous soucier de son type réel : c’est la donnée et la valeur contenues et le sens qu’on lui donne qui sont importants ! Cet exemple s’applique d’ailleurs aussi assez bien à la majeure partie des API de requêtage de bases de données, où les résultats sont souvent renvoyés sous forme de chaînes de caractères.

Cette RFC a ce qui est pour moi l’immense avantage de partir par défaut sur des type-hints avec du typage souple. Ainsi, avec la fonction définie un peu plus haut, les appels suivants seraient tous deux valides :

ma_fonction_01(42);
ma_fonction_01('42');

Dans les deux cas, la variable $entier reçue par la fonction sera un entier : à partir du type-hint spécifié, PHP effectuera une conversion automatique de la valeur passée, en suivant les règles déjà en place partout ailleurs dans le langage !

La sortie obtenue sera donc la suivante :

int(42)
int(42)

En revanche, passer une valeur qui ne puisse être convertie en entier mènera à l’erreur reproduite plus haut. Par exemple, avec :

ma_fonction_01('plop');

La sortie obtenue sera, je me répète :

Catchable fatal error: Argument 1 passed to ma_fonction_01() must be of the type integer, string given

Cette RFC propose donc un mécanisme de type-hinting souple, qui répond à la fois aux besoins :

  • du développeur de la fonction, qui souhaite recevoir une donnée du type qu’il a spécifié,
  • et du développeur utilisant cette fonction, qui souhaite continuer à bénéficier du typage souple de PHP.

Ce fonctionnement en mode souple par défaut a également l’avantage de bien s’intégrer à l’approche traditionnelle de PHP et de ne pas casser la compatibilité du code existant.

Et du typage strict !

Toutefois, malgré l’historique et la tradition souple de PHP, nombre développeurs ont tendance depuis quelques années à travailler avec un typage plus strict, en utilisant des variables de type entier lorsqu’il s’agit de contenir des valeurs entières, des variables de type flottant lorsqu’il s’agit de stocker des valeurs décimales et ainsi de suite.

À une certaine échelle, cette tendance se manifeste par l’emploi devenu quasi-systématique des opérateurs de comparaison stricts comme === ou !==. À un autre niveau, elle se retrouve également dans l’engouement pour la fonctionnalité d’annotations de types du langage Hack.

Et bien, cette RFC répond également à ce besoin de typage strict !

Pour basculer en mode de type-hinting scalaire strict, une nouvelle directive declare()3 est ajoutée : strict_types.

Au sein d’un bloc declare(strict_types=1), tous les appels de fonctions avec des type-hints scalaires seront effectués en mode de typage strict. Par exemple :

declare(strict_types=1) {
    ma_fonction_01('42');
}

La fonction ma_fonction_01() est définie comme attendant un entier en paramètre. Puisque nous avons passé une chaîne de caractères après avoir activé le mode de typage strict, l’appel reproduit ci-dessus entraînera la levée d’une erreur :

Catchable fatal error: Argument 1 passed to ma_fonction_01() must be of the type integer, string given, 
  called in .../test-01.php on line 9 
  and defined in .../test-01.php on line 17

Au lieu d’encapsuler des portions de code dans des blocs declare(strict_types=1), il est également possible d’employer cette directive au début d’un fichier — auquel cas tous les appels de fonctions effectués au sein de ce fichier seront en mode de typage strict :

<?php
declare(strict_types=1);
// Tous les appels de fonctions/méthodes effectués depuis
// ce fichier seront en typage strict

// Valide => int(42) 
ma_fonction_01(42);

// Invalide
ma_fonction_01('42');
?>

Avec cette directive, cette RFC répond aux besoins et souhaits de ceux qui préfèrent travailler avec des types stricts, quitte à rompre avec les usages traditionnels de souplesse de PHP. Au sein de chaque fonction, comme précédemment, les types des données reçues en paramètres sont ceux définis et attendus par l’auteur de la fonction.

Un exemple un peu plus conséquent ?

Pour visualiser un peu mieux les choses, considérons la classe suivante, faisant partie d’une bibliothèque :

// On pourrait ou non mettre un declare(strict_types=1) ici, pour jouer
// sur les appels de fonctions effectués depuis ce fichier.

class MaLib
{
    public function expectInt(int $entier) {
        printf("%s( %s )\n\n", __METHOD__, str_replace("\n", " ", var_export($entier, true)));
    }

    public function expectFloat(float $flottant) {
        printf("%s( %s )\n\n", __METHOD__, str_replace("\n", " ", var_export($flottant, true)));
    }

    public function expectString(string $chaine) {
        printf("%s( %s )\n\n", __METHOD__, str_replace("\n", " ", var_export($chaine, true)));
    }
}

Mettons également en place un gestionnaire d’erreurs, chargé d’intercepter (entre autres) les erreurs rattrapables qui seront levées si les type-hints ne sont pas respectés :

set_error_handler(function (int $errno , string $errstr, string $errfile, int $errline, array $errcontext) {
    printf("Erreur :\n");
    printf("  * errno = %s\n", var_export($errno, true));
    printf("  * errstr = %s\n", var_export($errstr, true));
    printf("  * errfile = %s\n", var_export($errfile, true));
    printf("  * errline = %s\n", var_export($errline, true));
    printf("  * errcontext = %s\n", str_replace("\n", " ", var_export($errcontext, true)));

    return true;    // on continue le traitement
});

Utilisons maintenant cette classe depuis un fichier en mode de typage souple (donc, sans utiliser la directive declare() vue plus haut) :

// Pas de declare() en haut du fichier => mode souple

$obj = new MaLib();

// Appels qui fonctionnent
printf("Appels qui fonctionnent :\n\n");
$obj->expectInt(123456);
$obj->expectFloat(3.1415);
$obj->expectString('Hello, World!');
$obj->expectString('123456789');

// Appels qui fonctionnent aussi, puisque nous ne sommes pas en mode strict_types
// => Les transtypages habituels sont effectués (typage "souple")
printf("Appels qui fonctionnent aussi :\n\n");
$obj->expectInt('123456');          // MaLib::expectInt( 123456 )
$obj->expectFloat('3.1415');        // MaLib::expectFloat( 3.1415000000000002 )
$obj->expectString(123456789);      // MaLib::expectString( '123456789' )

// Appels qui ne fonctionnent pas, puisque les transtypages habituels ne sont pas possibles
printf("Appels qui ne fonctionnent pas :\n\n");
$obj->expectInt('abcdef');
$obj->expectFloat([123, 'hello']);
$obj->expectString($obj);

La sortie obtenue (un peu reformatée pour faciliter la lecture) en exécutant cet exemple sera la suivante :

Appels qui fonctionnent :

    MaLib::expectInt( 123456 )
    MaLib::expectFloat( 3.1415000000000002 )
    MaLib::expectString( 'Hello, World!' )
    MaLib::expectString( '123456789' )

Appels qui fonctionnent aussi :
    MaLib::expectInt( 123456 )
    MaLib::expectFloat( 3.1415000000000002 )
    MaLib::expectString( '123456789' )

Appels qui ne fonctionnent pas :

    Erreur :
      * errno = 4096
      * errstr = 'Argument 1 passed to MaLib::expectInt() must be of the type integer, string given, called in .../test-02-nostrict.php on line 34 and defined'
      * errfile = '.../lib.php'
      * errline = 6
      * errcontext = array ( )
    MaLib::expectInt( 'abcdef' )

    Erreur :
      * errno = 4096
      * errstr = 'Argument 1 passed to MaLib::expectFloat() must be of the type float, array given, called in .../test-02-nostrict.php on line 35 and defined'
      * errfile = '.../lib.php'
      * errline = 10
      * errcontext = array ( )
    MaLib::expectFloat( array (   0 => 123,   1 => 'hello', ) )

    Erreur :
      * errno = 4096
      * errstr = 'Argument 1 passed to MaLib::expectString() must be of the type string, object given, called in .../test-02-nostrict.php on line 36 and defined'
      * errfile = '.../lib.php'
      * errline = 14
      * errcontext = array ( )
    MaLib::expectString( MaLib::__set_state(array( )) )

Ceci met clairement en évidence le fait qu’en mode de typage souple, dans les cas où des conversions de types automatiques étaient possibles, PHP les a jouées. Par contre, dans les cas inverses, des erreurs ont été levées, empêchant (si mon gestionnaire d’erreur n’avait pas bêtement retourné true) l’exécution des fonctions appelées avec des paramètres incorrects.

Passons maintenant à des appels depuis un fichier en mode de typage strict :

declare(strict_types=1);  // Mode strict !

$obj = new MaLib();

// Appels qui fonctionnent
printf("Appels qui fonctionnent :\n\n");
$obj->expectInt(123456);
$obj->expectFloat(3.1415);
$obj->expectString('Hello, World!');
$obj->expectString('123456789');

// Appels qui ne fonctionnent pas, puisque nous sommes ici en mode strict_types
printf("Appels qui ne fonctionnent pas :\n\n");
$obj->expectInt('abcdef');
$obj->expectFloat([123, 'hello']);
$obj->expectString(123456789);

La sortie obtenue (également retouchée pour faciliter la lecture), cette fois, sera la suivante :

Appels qui fonctionnent :

    MaLib::expectInt( 123456 )
    MaLib::expectFloat( 3.1415000000000002 )
    MaLib::expectString( 'Hello, World!' )
    MaLib::expectString( '123456789' )

Appels qui fonctionnent aussi :

    MaLib::expectInt( 123456 )
    MaLib::expectFloat( 3.1415000000000002 )
    MaLib::expectString( '123456789' )

Appels qui ne fonctionnent pas :

    Erreur :
      * errno = 4096
      * errstr = 'Argument 1 passed to MaLib::expectInt() must be of the type integer, string given, called in .../test-02-nostrict.php on line 34 and defined'
      * errfile = '.../lib.php'
      * errline = 6
      * errcontext = array ( )
    MaLib::expectInt( 'abcdef' )

    Erreur :
      * errno = 4096
      * errstr = 'Argument 1 passed to MaLib::expectFloat() must be of the type float, array given, called in .../test-02-nostrict.php on line 35 and defined'
      * errfile = '.../lib.php'
      * errline = 10
      * errcontext = array ( )
    MaLib::expectFloat( array (   0 => 123,   1 => 'hello', ) )

    Erreur :
      * errno = 4096
      * errstr = 'Argument 1 passed to MaLib::expectString() must be of the type string, integer given, called in .../test-02-nostrict.php on line 36 and defined'
      * errfile = '.../lib.php'
      * errline = 14
      * errcontext = array ( )
    MaLib::expectString( 123456789 )

Cette fois, les seuls appels qui ont fonctionné sont ceux où les types des données, et plus uniquement leurs valeurs, respectaient les type-hints positionnés lors de la déclaration des méthodes : aucune conversion de type n’a été automatiquement effectuée !

Typage strict… par fichier appelant ? Ou par fonction appelée ?

Une idée est revenue plusieurs fois pendant les débats : ce serait à chaque fonction de déterminer si, lors de ses appels, les types doivent être vérifiés en mode souple (si des conversions automatiques peuvent être appliquées lorsqu’elles sont possibles) ou en mode strict (si une erreur doit être levée dès qu’un type est incorrect, même si la valeur serait acceptable).

Avec un tel fonctionnement, c’est l’auteur d’une bibliothèque qui déciderait si celle-ci peut être utilisée dans une application développée avec une logique de types souples ou si elle ne doit être utilisable que par des applications écrites avec une approche du typage stricte.

Autrement dit, si demain l’auteur d’une des nombreuses bibliothèques que j’utilise dans mes projets décidait d’utiliser des type-hints, il pourrait décider que mon application, où j’ai parfois des valeurs entières dans des variables de type chaîne de caractères, n’est pas digne d’utiliser sa bibliothèque — et que des erreurs devraient être levées un peu partout !

Je ne comprends pas en quoi cette approche pourrait être une bonne idée — ni même une idée viable en quelque façon que ce soit !

Les type-hints dans une déclaration de fonction sont là pour :

  • indiquer à l’appelant quels types de valeurs elle attend,
  • et pour garantir que les données vues par la fonction sont des types définis.

Qu’il y ait ou non une conversion automatique n’a pas à être visible au niveau de la fonction appelée : seul l’appelant est à même de décider quel comportement doit être adopté : c’est lui qui sait si son application travaille sur une logique de typage souple ou strict !

En cela, la directive declare(), à positionner en haut de chaque fichier (ou autour de chaque bloc) où les appels peuvent/doivent être effectués en mode strict, répond à mes yeux parfaitement au besoin : elle offre des possibilités, sans pour autant imposer de fonctionnement plus contraignant que nous ne sommes en mesure d’accepter.

Et pour les types de retour ?

Puisque la RFC Return Type Declarations a été acceptée alors que celle-ci était en cours de discussion, une v0.3 a été publiée, ajoutant les type-hints scalaires aux types de retour.

Le principe global est le même que pour les types de paramètres : en mode souple, des conversions sont automatiquement effectuées si elles sont nécessaires et possibles, alors qu’en mode strict, une erreur est levée si une fonction essaye de retourner une valeur qui n’est pas du bon type.

La différence majeure réside dans le fait que le mode souple ou strict n’est pas décidé au moment de l’appel de la fonction, mais lors de sa déclaration. En effet, la personne la mieux placée pour déterminer si sa fonction ou sa bibliothèque retourne les bons types ou a besoin de conversions est bien l’auteur de celle-ci !

Par exemple, considérons les deux déclarations de fonctions suivantes, toutes deux écrites alors que nous sommes en mode souple :

// Pas de declare() spécifique => mode de typage souple

// Fonction qui retourne un entier, comme prévu
// => Tout va bien !
function ma_fonction_01(int $a, int $b) : int
{
    return $a + $b;
}

// Fonction qui essaye de retourner une chaine au lieu d'un entier
// Elle est déclarée alors qu'on est en mode souple => conversion
function ma_fonction_02(int $a, int $b) : int
{
    return strval($a + $b);
}

On peut appeler ces deux fonctions, que l’on soit en mode souple ou en mode strict :

var_dump( ma_fonction_01(2, 5) );
var_dump( ma_fonction_02(2, 5) );

Et la sortie obtenue sera la même dans les deux cas :

int(7)
int(7)

La seconde fonction a essayé de retourner une chaîne de caractères au lieu d’un entier comme spécifié, mais puisqu’elle a été déclarée en mode souple et que le chaîne pouvait être convertie en entier, cette conversion a eu lieu.

À l’inverse, si nous avions défini ces deux même fonctions alors que nous étions en mode strict, la sortie obtenue par nos deux appels serait la suivante, que ces appels soit réalisés depuis une portion de code en mode souple ou en mode strict :

int(7)

Catchable fatal error:  Return value of ma_fonction_02() must be of the type integer, string returned 
  in .../return-02.php on line 15

Vous l’aurez compris, le second appel a échoué, puisque la fonction devait retourner un entier, en mode strict, et qu’elle a essayé de retourner une chaîne de caractères.

Directives declare(), use strict, …

Au cours des échanges que j’ai lu et/ou auxquels j’ai participé, plusieurs fois est revenue la remarque que la directive declare(strict_types=1) serait moche, qu’elle ne serait pas facile à mémoriser ou à taper, voire même qu’un use strict serait préférable.

Avec un brin de mauvaise foi, si je n’avais qu’une chose à répondre, ce serait :

Le séparateur d’espaces de noms \ introduit avec PHP 5.3, alors même que PHP 5.3 était déjà en phase de versions alpha et que :: avait été évoqué pendant un très long moment, lui aussi, tout le monde a dit qu’il était moche et pas pratique à taper…

Et pourtant, quelques années après, on s’y est tous fait et on ne peut pas dire que les espaces de noms ne sont pas un réel succès ;-)

En argumentant un peu plus :

  • use strict risquerait de porter à confusion, puisque use est déjà utilisé pour les espaces de noms, aujourd’hui — il est d’ailleurs possible de déclarer un espace de noms nommé strict, même si essayer de l’importer sans l’aliaser entraîne une Fatal error: You seem to be trying to use a different language... ;-)
  • Utiliser une option nommée strict et non strict_types serait mentir : il y a plein de choses que l’on pourrait vouloir rendre plus strictes en PHP et il s’agit ici d’activer un mode strict uniquement pour une d’entre elles.
  • Après avoir tapé quelques fois cette directive, elle nous tombera naturellement sous les doigts4 — et une fois PHP 7 sortie, je suis certain que nos IDE sauront l’auto-compléter ;-)

Autrement dit, même si cette directive semble un peu étrange au premier abord, je n’ai aucun doute sur le fait que nous nous y habituerons sans mal, surtout considérant les avantages qu’elle apporte !

Et pour la suite ?

Pour commencer, cette RFC est entrée en phase de votes il y a quelques jours. Ceux-ci se termineront le 19 février.

Si cette RFC passe, il deviendra temps de réfléchir à aller un peu plus loin, notamment en repensant à la notion de types nullables — idée qui pourrait éventuellement arriver pour une version mineure ultérieure, comme PHP 7.1 ou 7.2.

Pour l’instant, les votes en sont à 41 pour et 27 contre. Considérant qu’il faudrait 2/3 de votes positifs pour qu’elle soit validée, les choses ne sont pas encore décidées et la balance pourrait aussi bien pencher d’un côté que de l’autre !

En parallèle, alors même que la RFC est en phase de votes, des idées continuent à être lancées, comme, pas plus tard qu’hier soir :

  • La possibilité d’un type-hint numeric pour les nombres, qu’ils soient entiers ou flottants. Je n’ai pas vraiment d’opinion sur ce point (pour l’instant).
  • Une éventuelle bascule vers un marqueur au niveau de la balise PHP ouvrante, comme <?php strict. Pourquoi pas, mais peut-être plus avec quelque chose du style <?php strict_types pour ne pas fermer de porte à d’autres points visant à rendre PHP plus strict ?

Mon avis en quelques mots

Cela fait un moment que je suis avec intérêt les différents débats qui ont eu lieu autour de l’idée de type-hinting pour les scalaires et je dois avouer que, cette fois, je suis conquis, principalement grâce aux points suivants :

  • La fonctionnalité n’est pas activée par défaut ; donc pas de cassure de l’existant.
  • Les type-hints garantissent que les données reçues par des fonctions en spécifiant lors de leur déclaration seront bien des types attendus, qu’elles soient appelées en mode souple ou strict.
  • L’utilisation d’un typage souple par défaut correspond bien à l’esprit de PHP, ne sera pas déstabilisant pour les débutants et permettra d’adapter graduellement les bibliothèques et/puis les applications.
  • C’est l’appelant qui détermine lorsqu’il est prêt à passer à un typage strict lors des appels qu’il effectue, une fois que son code travaille avec des types stricts.
  • Et c’est l’auteur de chaque librairie qui détermine — et c’est lui le mieux placé pour cela — si il estime que son code fonctionnera ou non avec des types de retour stricts.

J’ajouterais qu’Andrea, auteur de cette RFC, a fait un super boulot, tant sur la RFC qui est bien détaillée que sur les échanges autour de sa proposition, ainsi qu’au niveau de la prise en compte des très nombreux retours qui lui ont été adressés !

Bref, j’espère de tout cœur que cette RFC passera !


  1. En réalité, les type-hints existant de PHP ne sont pas des hints, mais des vérifications fortes, puisque des erreurs sont levées s’ils ne sont pas respectés. 

  2. Dans cet article, je parlerai de fonctions ou de méthodes, selon les exemples présentés. Le principe est systématiquement le même dans les deux cas, une méthode n’étant à peu de choses près qu’une fonction placée au sein d’un objet. 

  3. L’instruction declare() en elle-même n’est pas nouvelle : elle existe depuis un certain temps et vise à altérer le comportement du moteur de PHP. Elle n’est que peu connue, cela dit, parce que les directives existant actuellement ne sont que relativement peu utiles et utilisées. 

  4. J’arrive déjà à taper declare(strict_types=1) en n’ayant quasiment plus à réfléchir… Alors que je n’ai passé que quelques demi-heures à jouer avec ! 

Vous avez apprécié cet article ? Faites-le savoir !

Ce blog a récemment été migré vers un générateur de sites statiques et je n'ai pas encore eu le temps de remettre un mécanisme de commentaires en place.

Avec un peu de chance, je parviendrai à m'en occuper d'ici quelques semaines ;-)