Contre le typage statique dans Python

Après l'asynchrone, le typage statique est la nouvelle tendance dans les langages de programmations : TypeScript, Ruby 3, etc. Python n'y échappe pas et voilà pourquoi ça m'agace.

Grenade dégoupillé

Ce que j'aime dans Python

J'ai commencé à développer en Python en 2006. C'était avant les décorateurs, les générateurs, les gestionnaires de contextes, etc. Avant unittest et mock aussi. Le Python de l'époque peut paraître rudimentaire aujourd'hui. J'avais le sentiment d'écrire du code aussi simple qu'en C ou PHP, mais avec une syntaxe plus élégante, plus expressive. On pouvait facilement remplacer du C, PHP, Perl ou Java avec du Python avec moins de code et moins de bibliothèques tierces.

Les versions 2.5 et 2.6 étaient très modernes et ont vus émerger les frameworks web comme Django, la programmation réseau avec Twisted. J'ai trouvé ces évolutions très organiques, cohérentes avec le langage et son esprit pythonique.

Ce que j'aime dans le typage

Avant de critiquer le typage statique dans Python, je tiens à en reconnaître les avantages.

  • Le premier qui me vient en tête est le complètement du code. Les méthodes et les attributs d'un objet ne peuvent être suggéré par l'éditeur que sur la base d'annotation. C'est un confort intéressant.

  • Sur ce qui touche les types str et bytes, l'annotation est vraiment pertinente pour éviter de la confusion.

  • Une certaine catégorie d'erreur est détectée statiquement par mypy et ses compères, notamment des TypeError, certains AttributeError.

  • J'aime aussi l'explicitation d'API implicites qui apparaissent souvent dans les projets Python.

Dans les langages compilés, le typage statique permet des optimisations importantes. Cela n'est pas valable en Python.

Je suis favorable à une annotation partielle et simple pour le confort et pour certains cas nécessitant de la rigueur. Malheureusement, mypy est un cancer®. Quand on commence à annoter, le sens de l'outil est de pousser à l'annotation totale. Plusieurs points m'agacent, je vais illustrer un premier et évoquer les autres.

Steve Ballmer

Un cas d'école : httpx.get

Un beau jour d'automne, je cherche la signature de la fonction httpx.get - bibliothèque que je recommande chaudement au passage. Par paresse ou simplement pour faire simple et local, je lance un bon vieux help(). Grave erreur !

>>> import httpx
>>> help(httpx.get)

Help on function get in module httpx:

get(url:Union[_ForwardRef('URL'), str], *, params:Union[_ForwardRef('QueryParams'), 
Mapping[str, Union[str, int, float, NoneType, Sequence[Union[str, int, float, 
NoneType]]]], List[Tuple[str, Union[str, int, float, NoneType]]], Tuple[Tuple[str,
Union[str, int, float, NoneType]], ...], str, bytes, NoneType]=None, headers:Union[
_ForwardRef('Headers'), Dict[str, str], Dict[bytes, bytes], Sequence[Tuple[str, str]], 
Sequence[Tuple[bytes, bytes]]]=None, cookies:Union[_ForwardRef('Cookies'), 
http.cookiejar.CookieJar, Dict[str, str], List[Tuple[str, str]]]=None, auth:Union[
Tuple[Union[str, bytes], Union[str, bytes]], Callable[[_ForwardRef('Request')],
_ForwardRef('Request')], _ForwardRef('Auth'), NoneType]=None, proxies:Union[
_ForwardRef('URL'), str, _ForwardRef('Proxy'), Dict[Union[_ForwardRef('URL'),
str], Union[NoneType, _ForwardRef('URL'), str, _ForwardRef('Proxy')]]]=None,
follow_redirects:bool=False, cert:Union[str, Tuple[str, Union[str, NoneType]],
Tuple[str, Union[str, NoneType], Union[str, NoneType]]]=None, verify:Union[str,
bool, ssl.SSLContext]=True, timeout:Union[float, NoneType, Tuple[Union[float,
NoneType], Union[float, NoneType], Union[float, NoneType], Union[float, NoneType]],
_ForwardRef('Timeout')]=Timeout(timeout=5.0), trust_env:bool=True) -> httpx.Response
    Sends a `GET` request.

    **Parameters**: See `httpx.request`.

    Note that the `data`, `files`, and `json` parameters are not available on
    this function, as `GET` requests should not include a request body.

>>>

C'est totalement illisible. Et ce exclusivement par les annotations. Je me suis demandé comment un formatage ad-hoc pourrait améliorer la lisibilité de cette doc. Préparez votre molette de souris.

get(
    url:Union[_ForwardRef('URL'), str],
    *,
    params:Union[
        _ForwardRef('QueryParams'),
        Mapping[str, Union[str, int, float, NoneType, Sequence[Union[str, int, float, NoneType]]]],
        List[Tuple[str, Union[str, int, float, NoneType]]],
        Tuple[Tuple[str, Union[str, int, float, NoneType]], ...],
        str,
        bytes,
        NoneType
    ]=None,
    headers:Union[
        _ForwardRef('Headers'),
        Dict[str, str],
        Dict[bytes, bytes],
        Sequence[Tuple[str, str]],
        Sequence[Tuple[bytes, bytes]]
    ]=None,
    cookies:Union[
        _ForwardRef('Cookies'),
        http.cookiejar.CookieJar,
        Dict[str, str],
        List[Tuple[str, str]]
    ]=None,
    auth:Union[
        Tuple[Union[str, bytes], Union[str, bytes]],
        Callable[[_ForwardRef('Request')], _ForwardRef('Request')],
        _ForwardRef('Auth'),
        NoneType
    ]=None,
    proxies:Union[
        _ForwardRef('URL'),
        str,
        _ForwardRef('Proxy'),
        Dict[Union[_ForwardRef('URL'), str], Union[NoneType, _ForwardRef('URL'), str, _ForwardRef('Proxy')]]
    ]=None,
    follow_redirects:bool=False,
    cert:Union[
        str,
        Tuple[str, Union[str, NoneType]],
        Tuple[str, Union[str, NoneType], Union[str, NoneType]]
    ]=None,
    verify:Union[
        str,
        bool,
        ssl.SSLContext
    ]=True,
    timeout:Union[
        float,
        NoneType,
        Tuple[Union[float, NoneType], Union[float, NoneType], Union[float, NoneType], Union[float, NoneType]],
        _ForwardRef('Timeout')
    ]=Timeout(timeout=5.0),
    trust_env:bool=True) -> httpx.Response

Tout ça pour 11 paramètres. Autant dire que ce n'est pas bien plus lisible. Cela m'a rappelé un principe cher au pythonistes :

Readability counts.

Cette citation du Zen of Python semble totalement violée par le typage statique et sa bordée d'annotations.

Les annotations n'avaient-elles pas la promesse d'un code plus lisible ? auto-documenté ? Quel gain en terme de complètement du code ces annotations vont-elle fournir ? Des doctest seraient bien plus adaptés pour documenter et valider cette fonction, modulo quelques mocks. Le seul gain est que mypy est content.

Force est de constater que la lisibilité d'une API Pythonique est incompatible avec les annotations. Cela se vérifie également pour des API plus simples. Le travail pour rendre ces annotations lisibles est énorme, et le résultat sera toujours décevant.

Je vous épargne les exemples dans la doc de Flask. J'y ait vu traîner des Union[Any, Any] qui m'ont laissé songeur.

développeur réfléchissant devant son écran

Ce que je déteste dans le typage statique avec Python

Outre la lisibilité, il y a d'autre soucis. Je pense par exemple aux imports. Les annotations requièrent leurs propres imports. Souvent, cela rallonge notablement l'en-tête du fichier et sa maintenance. Mais un autre point compte sur les imports. Parfois, on se retrouve à faire des imports croisés uniquement pour l'annotation. Pour couper ça, il faut faire une interface. Bref, retour au bon vieux .h. C'est pas vraiment ce qui me plait dans Python.

Autre point, il y a quelque chose de fondamentalement pété dans les outils d'analyse statique des annotations : leur implémentation singe la logique de l'interpréteur Python. Cela génère une tétra-chiée de problèmes de cas valides que mypy refuse parce qu'il ne comprends pas. Pas besoin de grosses subtilités pour ça. Je vous laisser éplucher les quelques 2k tickets ouverts sur le GitHub de mypy pour illustrer ce propos. On finit par se battre avec l'outil pour qu'il accepte votre code. Avez-vous aimé vous battre avec flake8 pour une ligne un peu trop longue ? Vous allez adorer mypy !

Quid des novices ? Le combat contre mypy se révèle décourageant pour les débutants. Seuls les gourous habitués se montrent capable de comprendre pourquoi la nouvelle version de mypy refuse le nouveau code et comment le contourner. mypy n'est pas inclusif. (-:

Enfin, le typage statique correspond à un état-d'esprit où la qualité est un principe formel, démontrable. Je pense que c'est faux et qu'il faut prendre garde à cet état d'esprit. Si c'était vrai, alors il n'y aurait pas de NullPointerException et autres bugs dans les langages compilés. Le temps investis dans l'annotation, le contournement du comportement de mypy, la compréhension et la maintenance d'annotations complexes apporte peu en retour sur investissement.

J'ai partagé mon avis sur la stratégie de test. Une batterie de tests d'intégration, exécutant le logiciel tel que l'utilisateur le fera, est irremplaçable. Même les sacro-saints tests unitaires ne suffisent pas. En l'occurrence, je trouve que le retour sur investissements des tests unitaires en qualité comme en compréhension du code est bien meilleure que le typage statique.

Pour conclure. Merci d'avoir lu jusqu'ici ! Face à la quasi unanimité pour le typage statique, je voulais apporter une pierre de contradiction. La trouvez-vous constructive ? Qu'en pensez-vous ? Quelle est votre expérience sur le sujet ?

Quelques références pour finir

Merci de partager d'autres références !