L’expression d’assignation vient d’être acceptée


Après des mois de débats sur python-idea (mailing list sur laquelle, je vous le rappelle, tout le monde a le droit de participer), Guido a validé la PEP 572. (foo := bar) sera donc un code valide en Python 3.8.

Je n’avais pas vu de feature plus controversée depuis les f-strings. Et je gage que, comme les f-strings, après un temps d’adaptation, la communauté va se demander comment on a vécu sans auparavant.

Un peu d’histoire

Il existe deux grandes catégories d’instructions dans les langages de programmation. Les déclarations et les expressions.

La déclaration, en anglais “statement”, est une action indépendante formulée sur une ligne. C’est une unité syntaxique, et une déclaration ne peut être contenue dans une autre déclaration sur la même ligne.

Ex:

import os

est une déclaration. Un import est tout seul sur sa ligne et ne se combine pas avec d’autres instructions.

L’expression, elle, est une combinaison de littéraux, variables, d’opérateurs et d’appels de fonctions qui retournent un résultat. Les expressions peuvent contenir d’autres expressions, et elles peuvent être contenues dans une déclaration.

Ex:

1 + 1

est une expression. On peut, en effet, assigner le résultat de ce code à une variable, le passer à une fonction en paramètre ou le tester dans une condition.

Les langages, comme le COBOL, qui privilégient le style impératif utilisent majoritairement des déclarations. Les langages, comme LISP, qui privilégient le style fonctionnel, utilisent majoritairement des expressions.

Très souvent néanmoins, les langages populaires font largement usage des deux. L’expression est plus flexible et plus puissante. La déclaration force une opinion sur la structure du programme et évite les divergences de style. Selon la philosophie que l’on souhaite donner à son bébé, un créateur de langage va donc s’orienter plus vers l’une ou l’autre.

Python est multiparadigme et soutient le style impératif, fonctionnel et orienté objet. Il possède donc non seulement des expressions et des déclarations, mais également souvent les deux versions pour une même instruction.

Ex, les déclarations:

squares = []
for x in numbers:
    squares.append(x * x)
 
def mean(x):
    return x * x / 2
 
if is_ok:
    print(welcome)
else:
    print(error)

ont des expressions équivalentes:

 
squares = [x * x for x in number]
 
mean = lambda x: x * x / 2
 
print(welcome if is_ok else error)

Parfois Python choisit d’orienter le style du programmeur, non pas évitant de fournir des expressions, mais par le biais de la grammaire imposée. Ainsi il oblige à indenter, et n’autorise pas les lambdas à contenir de déclaration. C’est une autre stratégie pour imposer une philosophie au langage. Pour Python, la philosophie est que la capacité à s’exprimer doit rester riche, mais pas au détriment de la capacité à comprendre le code.

Une particularité de Python, c’est que l’assignation, c’est-à-dire le fait d’associer une valeur à une variable, est une déclaration. Ex:

a = 1

Comme les déclarations ne peuvent contenir d’autres déclarations, cette syntaxe interdit:

if a = 1:

Ce n’est pas le cas dans de nombreux langages populaires, comme le C, PHP, le JS… Exemple en Ruby:

if (value = Settings.get('test_setting'))
  perform_action(value)
end

Ici, non seulement on assigne le résultat du get() à la variable value, mais en plus, on teste le résultat du get() avec le if.

Actuellement ceci est impossible en Python, et le même code serait:

value = Settings.get('test_setting')
if value:
  perform_action(value)

Ce n’est pas une erreur, c’est un choix de design. En effet une source de bug très courante en programmation est de vouloir faire une comparaison, mais de taper une assignation.

Ainsi, quelqu’un voudrait faire:

while reponse == "oui":
    ...
    reponse = input('Voulez-vous continuer ?')

Mais ferait:

while reponse = "oui": # erreur subtile et difficile à voir
    ...
    reponse = input('Voulez-vous continuer ?')

Ce qui ne compare pas DU TOUT la variable. Et en plus change son contenu. Dans ce cas précis, le résultat est une boucle infinie, mais parfaitement valide et qui ne provoquera pas d’erreur.

Pour éviter ce genre de bug que même les programmeurs aguerris font un jour de fatigue, la syntaxe a tout simplement été interdite en stipulant que l’assignation était toujours une déclaration.

La PEP 572

L’absence de cette fonctionnalité a eu d’excellents bénéfices. Je le vois régulièrement dans mes salles de classe, ce bug. La SyntaxError qui résulte de cette faute en Python permet de l’attraper avant qu’il ne fasse le moindre dégât.

Car c’est tout le problème de cette erreur: si la syntaxe est valide, le bug est silencieux, le code tourne, il ne fait juste pas du tout ce qu’on lui demande. C’est la plus pernicieuse des situations, avec des conséquences qui peuvent ne se déclarer que bien plus loin dans le code et une séance de débuggage des plus irritantes.

A mon sens, c’était une excellente décision de design.

Pourtant la PEP 572 revient dessus, en proposant, comme pour lambda, la boucle for ou la condition, un équivalent sous forme d’expression.

Pourquoi ?

Et bien Python doit aussi satisfaire les programmeurs chevronnés, qui en ont marre de se retrouver avec des situations comme :

match1 = pattern1.match(data)
if match1:
    print(match1.group(1))
else:
    match2 = pattern2.match(data)
    if match2:
        print(match2.group(2))

Certes, ce code est clair et facile à comprendre, mais il est très verbeux. On doit faire avec une indentation artificiellement induite par la limite de la syntaxe, et non par la logique du raisonnement qui est ici parfaitement linéaire.

Comment donc réconcilier le désir d’éviter le fameux bug tout en satisfaisant les besoins d’expressivité, légitimes une fois qu’on quitte le nid des débutants ?

La solution proposée est triple :

  • Ajouter un nouvel opérateur dédié, permettant l’assignation sous forme d’expression.
  • Forcer l’usage des parenthèses pour encadrer cette expression.
  • Rendre les deux opérateurs d’assignation mutuellement exclusifs.

Le nouvel opérateur choisi est := et il ne peut exister qu’entre parenthèses. Il peut être utilisé là où un simple = ne serait pas autorisé. Mais, il ne peut PAS être utilisé là où on peut utiliser =. Le but est de ne jamais mettre ces deux opérateurs en concurrence: une situation permet l’un ou l’autre, jamais les deux.

= ne change pas. La seule différence, c’est qu’à partir de Python 3.8, vous aurez le droit de faire:

if (match1 := pattern1.match(data)):
    print(match1.group(1))
elif (match2 := pattern2.match(data)):
    print(match2.group(2))

L’opérateur := permet donc bien, dans des situations très précises, d’obtenir un code plus cours et élégant, sans introduire pourtant d’ambiguïté et donc de bug potentiel. Il autorise une nouvelle forme d’expressivité, mais sa syntaxe est très marquée: impossible de le confondre avec son frère, et les parenthèses l’isolent du reste du code.

On ne se pose pas non plus la question de quand choisir = et := puisque:

a = 1

est valide, mais pas:

a := 1

Bien que:

(a := 1)

soit valide, personne n’aura envie d’utiliser vainement cette forme plus lourde.

L’usage de := est donc marginal, et cantonné à des cas particuliers.

Une opinion, c’est comme un trou du cul…

Personnellement j’étais mitigé sur l’idée. De plus, j’aurais préféré l’usage du mot clé as, puisqu’on l’utilisait déjà dans les imports, les context managers et la gestion des exceptions.

Chaque ajout d’expression rajoute également la possibilité d’abus. Si vous avez déjà vu ces horreurs à base de lambdas imbriquées ou d’intensions sans fin, vous savez de quoi je parle.

Avec :=, on peut vraiment se lancer dans du grand n’importe quoi:

Pour reprendre l’exemple de la PEP:

while True:
    old = total
    total += term
    if old == total:
        return total
    term *= mx2 / (i*(i+1))
    i += 2

est très clair.

En revanche:

while total != (total := total + term):
    term *= mx2 / (i*(i+1))
    i += 2
return total

Est une abomination qu’il faut purger par le feu.

Mais par expérience, j’ai rarement vu en 15 ans de Python beaucoup d’abus de ses fonctionnalités avancées. Les bénéfices ont toujours dépassé le coût d’une large marge. Pourtant entre les décorateurs, les dunder methods et les meta classes, il y a matière à messe noire.

Par ailleurs j’avoue que je suis ravi de pouvoir enfin faire:

while (data := io.get(x)):

Et:

[bar(x) for z in stuff if (x := foo(z))]

La PEP mentionne aussi un exemple que je n’avais pas prévu, et qui souffle le chaud et le froid:

diff = x - x_base
if diff:
    g = gcd(diff, n)
    if g > 1:
        return g

Peut devenir:

if (diff := x - x_base) and (g := gcd(diff, n)) > 1:
    return g

Comme l’auteur, j’approuve le fait que la première version est inutilement verbeuse, mais contrairement à lui, je trouve que la seconde est bien trop complexe pour être scannée d’un coup d’œil.

En revanche:

diff = x - x_base
if diff and (g := gcd(diff, n)) > 1:
    return g

est tout à fait à mon goût.

Ceci démontre bien qu’il va falloir un temps d’adaptation avant que la communauté trouve l’équilibre entre Perl et BASIC. Quoi qu’il en soit, on n’aura pas à s’en soucier avant l’année prochaine, et même d’ici là, peu de code pourra en faire usage avant que la 3.8 soit largement installée.

De mon côté je m’attends à ce qu’on ignore majoritairement cette fonctionnalité, jusqu’au moment où elle apparaîtra dans un coin de l’esprit le temps d’un besoin ponctuel et local, pour être oubliée à nouveau jusqu’à l’occasion suivante. Comme Dieu Guido l’aura voulu. D’ailleurs, côté enseignement, je ne compte pas introduire l’opérateur dans mes cours, ou alors dans une section bonus, à moins qu’un participant ne pose la question.

Aller, vous pouvez râler en commentaire maintenant :)

20 thoughts on “L’expression d’assignation vient d’être acceptée

  • David CHANIAL

    Merci pour cet article.

    Update qu’il me tarde d’utiliser.

    Par contre : while bytes := io.get(x): devrait plutôt être while (bytes := io.get(x)): dans ton article ? non ?

  • Martin

    D’un côté, pratiquant le Rust en parallèle, j’avoue que j’adore utiliser la pattern suivante:

    while let Some(x) = something() {
    // do stuff...
    }

    ou bien:

    if let Ok(x) = something() {
    // do stuff ...
    }

    Mais après, en Python, j’avoue qu’il va falloir un temps d’adaptation, et je suis d’accord qu’on aurait peut être pu s’en sortir un peu plus élégamment avec un as plutôt que d’introduire un opérateur dégueulasse !

    Et franchement,

    results = [(x, y, x/y) for x in input_data if (y := f(x)) > 0]

    je trouve que c’est une horreur à lire, même si c’est pratique; c’est exactement comme tu dis Sam avec Perl vs BASIC ;)

  • Fabio

    Merci pour l’article, c’est clair et bien argumenté !

    A sa lecture, je reste quand même sceptique sur la réelle utilité de l’opérateur : elle reste cantonnée à des cas marginaux, et c’est justement ce qui fait qu’il n’était pas nécessaire (en tout cas de mon point de vue). Je n’arrive pas à voir autre chose que le fait qu’il va plutôt perturber la lecture.

    Sinon, je trouve que le choix de la syntaxe est plutôt très bien pensé.

  • OPi

    Il manque une parenthèse dans [bar(x) for z in stuff if (x := foo(z)].

    Le dernier example

    diff = x - x_base
     
    if (g := gcd(diff, n)) > 1:
     
        return g

    est certes plus lisible que le précédent, mais il ne teste plus si diff est non nul, et n’est donc pas équivalent.

    @Martin, pour la lisibilité de results = [(x, y, x/y) for x in input_data if (y := f(x)) > 0] rien n’empêche de l’écrire proprement :

    [
        results = [(x, y, x/y)
        for x in input_data
        if (y := f(x)) > 0
    ]
  • Sam Post author

    @Matthieu : :=. Même syntaxe que tu utilisais un type hint, mais sans le type hint.

    @OPi: merci c’est corrigé.

  • =°.°=

    C’est juste du sucre. Ayant pratiqué le PHP (j’avoue), c’est une feature assez confortable. A utiliser avec précaution, comme le reste.

    Comme beaucoup de monde, j’aurais préféré as ou <-

    := c’est un peu dégueu. Je vois un castor en smiley

  • Victor Stinner

    Python est le langage de Guido van Rossum, parfois il nous laisse l’utiliser. Du coup, Guido fait ce qu’il veut avec son langage ;-)

  • Victor Stinner

    “Guido a validé la PEP 572” Moui, enfin, Guido a annoncé qu’il va approuver la PEP d’ici 1 semaine (s’il n’y a pas de grosse résistance d’ici là).

  • Julien Palard

    Dans l’exemple

    (carottes : Union[Legume, SexToy] = buy())
    

    Je pense que tu voulais dire :

    (carottes : Union[Legume, SexToy] := buy())
    

    ?

  • Sam Post author

    @Julien Palard: les deux formes sont possibles et la PEP n’en parle pas. La branche dédiée ne compile pas. Donc aucune idée.

    EDIT: les annotations de types ne sont juste pas supportées par la PEP actuelle. Je retire ma connerie.

  • NicolasB

    Simple question qui n’a qu’un demi lien avec l’article, avec :

    while (bytes := io.get(x)):
        pass

    Pourquoi ne pas faire quelque chose dans le style :

    for bytes in iter(io.get, b''):
        pass
  • Sam Post author

    Tu veux dire:

    for bytes in iter(lambda: io.get(x), b''):
        pass

    iter() ne passe en effet pas de paramètres.

    Du coup, comparé à:

    while (bytes := io.get(x)):
        pass

    Y a pas photo en terme de lisibilité.

    De plus, j’ai utilisé iter() et son sentinel plusieurs fois. A chaque fois, les gens qui lisent mon code me demandent ce que ça fait. C’est pas du tout évident.

    C’est aussi limité aux conditions d’égalité très basiques.

    Bref, si j’ai := qui fait plus court, plus simple, plus clair et plus flexible, autant l’utiliser.

    Sinon, oui.

  • panda.dragon
    while reponse = "oui": # erreur subtile et difficile à voir

    Oui et c’est pourquoi dans les consignes de dev C/C++ il est recommandé d’inverser les tests d’égalité.
    Par exemple au lieu d’écrire

    if(i == 0)

    plutôt écrire

    if(0 == i)

    dans le cas d’un oubli de = on se retrouve alors avec une affectation à une constante et le compilateur prévient immédiatement du problème…

    Cela demande un peu de gymnastique les premiers jours (de passer de “cette variable est égale à cette valeur” à “cette valeur est contenue dans cette variable”) mais très vite on s’y fait, motivé par le fait de se débarrasser une fois pour toute de ce problème.

  • Miaou

    Dans les exemples d’équivalence déclaration/expression, ce ne serait pas plutôt

    mean = lambda x: x * x / 2

    pour

    def mean(x):
        return x * x / 2

    ?

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.