Qu’est-ce que le duck typing et à quoi ça sert ?


Le duck typing, qu’on pourrait traduire par “typage canard” mais on ne le fera pas parce que c’est très moche, est une manière de créer des APIs basée sur la philosophie que l’aspect pratique est plus important que la pureté du code.

L’idée est de créer des signatures de callable qui acceptent des paramètres en fonction de leur comportement, pas leur type :

Si ça marche comme un canard et que ça fait le bruit d’un canard, alors ça ressemble assez à un canard pour le traiter comme un canard

Imaginez que vous ayez un objet avec un interface ICanard:

class ICanar:
    def coin():
        pass

Une fonction qui est programmée selon le duck typing acceptera comme argument un objet qui possède la méthode coin, peut importe si il implémente cette interface ou non.

En gros, si un paramètre possède une interface suffisante pour nous, ou peut être casté en un objet avec une interface suffisante pour nous, on l’accepte. Cela rend un callable plus générique.

Ok, trève de bavardage, qu’est-ce que ça implique, dans la vraie vie vivante ?

Si je fais une fonction qui retourne le premier élément d’une liste ou un élément par défaut :

def getfirst(lst, default=None):
    try:
        return lst[0]
    except IndexError:
        return default

Pratique, et ça marche sur d’autres itérables :

>>> getfirst([1, 2, 3])
1
>>> getfirst('abcde')
'a'

On a une forme de duck typing : si on peut récupérer le premier élément, alors ça suffit pour nous. Peut importe qu’il s’agit d’une liste ou d’un tuple.

On peut néanmoins améliorer la généricité de cette fonction:

def getfirst(iterable, default=None):
    for x in iterable:
        return x
    return default

Ici, le comportement recherché est qu’on puisse faire une une boucle for dessus, pas qu’on puisse récupérer un élément par son index.

Cela rend la fonction encore plus flexible, ainsi elle marche sur les générateurs, les flux, les fichiers:

>>> getfirst(open('/etc/fstab'))
'# /etc/fstab: static file system information.\n'

Un autre exemple ? La fonction Python sum par exemple, accepte tout types de nombres :

>>> sum((1, 2, 3)) # integers
6
>>> sum((1.3, 2.4, 3.5)) # floats
7.2
>>> sum((1j, 2j, 3j)) # complexes
6j

Sympas, mais l’addition en Python supporte bien plus que les nombres :

>>> [1] + [2]
[1, 2]
>>> (1, 2) + (2, 4)
(1, 2, 2, 4)
>>> "a" + "b"
'ab'

Mais sum ne les accepte pas :

>>> sum("a", "b")
Traceback (most recent call last):
  File "<ipython-input-24-1e5baeda1183>", line 1, in <module>
    sum("a", "b")
TypeError: sum() can't sum strings [use ''.join(seq) instead]

Il est possible de faire un sum plus générique :

def sumum(*iterable, start=None, default=None):
    # On donne à l'utilisateur la possibilité
    # de passer un premier élément
    if start is None:
        # on récupère le premier élément
        try:
            start, *iterable = iterable
        except ValueError:
            # Il n'y a aucun élément dans l'itérable
            # donc on retourne la valeur par default
            return default
    # on additionne
    for x in iterable:
        start += x
    return start

Le duck typing, à son maximum :

>>> sumum('a', 'b', 'c')
'abc'
>>> sumum([1, 2], [3, 4])
[1, 2, 3, 4]

Le duck typing implique aussi une prise de décision. Qu’est-ce qui serait le plus pratique ? De pouvoir additionner tous les types additionnables ? Ou de pouvoir additionner n’importe quoi qui ressemble à un nombre ?

Imaginons que la plupart de nos libs, plutôt que de fournir la possibilité d’additionner, propose la possibilité de caster vers un float :

class Temperature:
    def __init__(self, value, unit='C'):
        self.value = float(value)
        self.unit = unit
    def __float__(self):
        if self.unit == 'C':
            return self.value
        if self.unit == 'K':
            return self.value - 273.15
        if self.unit == 'F':
            return (self.value - 32) * 5/9
    def __repr__(self):
        return '%s %s' % (self.value, (self.unit != 'K')*'°'+self.unit)
 
t1 = Temperature(5)
t2 = Temperature(3, 'K')
t3 = Temperature(30, 'F')
t1, t2, t3
## (5.0 °C, 3.0 K, 30.0 °F)

Dans ce cas notre fonction pourrait convertir tous les éléments d’un itérable avant addition :

def sumcast(*iterable, start=None, default=None):
    # On donne à l'utilisateur la possibilité
    # de passer un premier élément
    if start is None:
        # on récupère le premier élément
        try:
            start, *iterable = iterable
        except ValueError:
            # Il n'y a aucun élément dans l'itérable
            # donc on retourne la valeur par default
            return default
    # on additionne en convertissant tout en float
    start = float(start)
    for x in iterable:
        start += float(x)
    return start
 
>>> sumcast(1, "3", t1, t2, t3)
-262.26111111111106

Dans tous les cas, on se fiche complètement que nos objets soient d’un type précis ou qu’ils implémentent une interface précise à partir du moment où leur API est suffisamment proche du ce type ou de l’interface dont on a besoin.

Le duck typing a beau être une pratique vouée à simplifier la vie au prix du formalisme, il ne dispense pas de documenter votre code à propos de cette subtilité afin que l’utilisateur final n’ait pas de mauvaise surprise.

Il convient de ne pas abuser du duck typing, qui est là pour rendre service uniquement. Si vous ajoutez des cas farfelus dans votre code pour supporter des situations rares, vous le rendez plus compliqué et moins robuste. Visez la généricité pour les situations les plus courantes, pas toutes les situations possibles.

Et souvenez-vous que plus on est dynamique sur les types, plus on perd en performance. Il faut savoir quelle part de compromis on est prêt à faire.

5 thoughts on “Qu’est-ce que le duck typing et à quoi ça sert ?

  • francoisb

    Merci beaucoup pour ce post.

    Quelques typos:

    Une fonction qui est programmée selon le duck typing acceptera en comme argument un objet -> en OU comme

    dans def sumum/except ValueError:/return empty -> return default

    idem dans sumcast

    Dans tous les cas, on se fichr -> fiche

    Le duck typing à beau être -> a beau être

    afin que l’utilisateur final n’ai pas de mauvaise surprise ->n’ait pas

    vous le rendez plus compliqué et moins robustes -> robuste

    Pour faire mon chieur: on dit °C, °F (car ce sont des échelles à 2 points fixes) mais K (et non °K) car c’est une échelle à un point fixe (le point triple de l’eau). Mais l’erreur est extrêmement fréquente. [En plus ça alourdirait le code de class Temperature]

  • Sam Post author

    Merci beaucoup pour ces corrections. Comme d’habitude, ça apporte une vraie valeur ajoutée aux articles, et c’est très bienvenue.

    Pour les kelvins, on devrait pouvoir s’arranger :)

  • nel50n

    Maarci bôcou pour se paust !

    Cependant, pour continuer à l’améliorer :

    dans la phrase « …acceptera en comme argument un objet qui possède la méthode quack, … »

    le “en” me semble de trop et le “quack” n’est pas cohérent avec l’exemple qui le précède (“coin”).

    Sinon, pour faire encore plus mon chieur que francoisb : le °C est également une échelle à un point fixe (dérivée par décalage du kelvin). C’est l’échelle centigrade qui est à deux points fixes. [/chieur]

  • Sam Post author

    Je vous aime, mais là le °, il bougera plus. Il va rester à sa place, tranquile, fumer sa clope, mater un porno, et osef.

Comments are closed.

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