Comment marche réellement la fonction super () de Python

La méthode super () de Python a inspiré un bon nombre d'explications. Toutefois, il apparaît qu'aussi sophistiquées qu'elles soient, elles sont toutes désespérément superficielles.
Je ne parle pas de celles qui le sont d'évidence, comme cette vidéo. L'auteur est bien gentil, mais sa présentation de super() se limite à assimiler la fonction à un moyen de déléguer un appel à la méthode __init__ () (par ailleurs, tout à fait improprement assimilée à un constructeur, comme si __new__ () n'existait pas...) surchargée dans une classe C à sa version dans une classe parent A. Autrement dit, à appeler A.__init__ () depuis C.__init__ (). Mais dans l'exemple, C hérite non seulement de A, mais aussi de B. Dès lors, les questions des commentateurs fusent : pourquoi B.__init__ () n'est-elle pas appelée ? Sincèrement, avant de publier une explication sur un sujet technique, son auteur devrait s'assurer qu'il maîtrise son sujet. Comme il le dit, "The super keyword is a little bit confusing in Python. It's been confusing for decades now and it's going to continue to be confusing as we move into the future. It's just something that's confusing". Tu m'étonnes! surtout après avoir entendu sa prétendue explication...
Non. Je parle d'explications d'auteurs qui ont pignon sur rue, comme Python's super() considered super! de Raymond Hettinger, à laquelle renvoie la documentation de super(), ou encore celle d'Alex Martelli, Anna Ravenscroft et Steve Holden dans leur Python in a Nutshell. Le moins qu'on puisse dire, c'est qu'on ne ressort pas illuminé de ces lectures. Sans doute, on comprend que super() permet de jouer avec le Method Resolution Order (MRO), mais quant à savoir exactement comment, c'est une autre affaire. Tout le monde se contente de terminer en recommandant d'utiliser systématiquement super() à tous les niveaux la hiérarchie des appels, et pour le reste renvoie sur l'algorithme C3, présenté comme abscons.
Bref, tout comme peu de personnes semblent avoir compris que JavaScript est un langage orienté objet à base de prototypes (et non de classes !), y'en a-t-il seulement qui ont compris comment la fonction super() de Python fonctionne vraiment ? Sérieusement, il y a de quoi nourrir de sérieuses inquiétudes quant au niveau d'exigence des développeurs désormais. Plus personne ne se donne la peine d'aller au fond des choses, ou quoi ?
On va donc arrêter de plaisanter, et expliquer sérieusement...

Le problème de l'héritage multiple

En Python, l'héritage peut être simple ou multiple. C'est ce dernier cas qui nous intéresse. Par exemple :
class LivingThing:
	def __init__ (self, name):
		self.name = name

class MovingThing:
	def __init__ (self, position):
		self.position = position

class Bird (LivingThing, MovingThing):
	def __init__ (self, name, position):
		self.name = name
		self.position = position

b = Bird ('Eagle', (100, 100))
L'appel à une méthode d'une classe parent surchargée depuis la méthode de la classe enfant, c'est ce qu'on appelle de la délégation (la classe enfant délègue une partie du traitement de l'appel à une classe parent).
Contrairement à ce qui se passe dans certains langages orientés objet, la délégation n'est pas implicite en Python. Il faut y procéder explicitement :
class Bird (LivingThing, MovingThing):
	def __init__ (self, name, position):
		LivingThing.__init__ (self, name)		# Appel à une méthode d'une classe parent (délégation)
		MovingThing.__init__ (self, position)	# Idem
Or la délégation pose problème quand plusieurs classes parents MovingThing et LivingThing d'une classe Bird héritent d'une même classe Thing, configuration dite en diamant :
Configuration en diamant lors d'un héritage multiple
En effet, une méthode surchargée à tous les étages est alors appelée plusieurs fois :
class Thing:
	def __init__ (self):
		print ('Thing.__init__ ()')

class LivingThing (Thing):
	def __init__ (self):
		print ('LivingThing.__init__ ()')
		Thing.__init__ (self)

class MovingThing (Thing):
	def __init__ (self):
		print ('MovingThing.__init__ ()')
		Thing.__init__ (self)

class Bird (LivingThing, MovingThing):
	def __init__ (self):
		LivingThing.__init__ (self)
		MovingThing.__init__ (self)

b = Bird ()
LivingThing.__init__ ()
Thing.__init__ ()		# Appelée depuis LivingThing.__init__ ()
MovingThing.__init__ ()
Thing.__init__ ()		# Appelée depuis MovingThing.__init__ ()
Recourir à super () permet de résoudre ce problème.

super () à la rescousse

Pour éviter les multiples appels à la même méthode surchargée, il est possible d'utiliser super () ainsi :
class Thing:
	def __init__ (self):
		print ('Thing.__init__ ()')

class LivingThing (Thing):
	def __init__ (self):
		print ('LivingThing.__init__ ()')
		super.__init__ ()

class MovingThing (Thing):
	def __init__ (self):
		print ('MovingThing.__init__ ()')
		super ().__init__ ()

class Bird (LivingThing, MovingThing):
	def __init__ (self):
		super ().__init__ ()

b = Bird ()
LivingThing.__init__ ()
MovingThing.__init__ ()
Thing.__init__ ()
En fait, super () prend deux arguments : une classe C et une instance o. Toutefois, dans le contexte d'une classe, l'écriture d'un appel à super () peut être simplifiée : le compilateur devine que la classe courante et l'instance courante doivent constituer les arguments. C'est du sucre syntaxique (syntactic sugar).
Le sucre syntaxique, cette drogue...
Comme toutes les technologies, Python évolue. Il n'est pas dit que ce soit une bonne chose.
De fait, avec des années de recul en matière de développement, force est de constater que des technologies qui connaissent un grand succès parce qu'avant toute chose, elles sont simples, deviennent au fil du temps aussi complexes - si ce n'est plus, tant cette complexité est mal assumée, s'agissant d'un reniement de la promesse marketing initiale - que celles qu'elles avaient vocation à remplacer.
Parmi les cas bien célèbres, on peut citer PHP qui s'est doté de classes, ou JavaScript qui a fait de même. Notons que dans ce dernier cas, ce serait Steve Jobs prétendant qu'il a inventé la micro-informatique. C'est que le reniement tient du révisionnisme : le sucre syntaxique class est un poison qui fait accroire au débutant comme au confirmé que JavaScript est un langage orienté objet à base de classes, alors qu'il est à base de prototypes. Le sucre syntaxique, c'est l'opium du peuple.
A quoi donc revient un appel de type super (C, o) ?
Lorsqu'il rencontre une instruction telle que o.f ()o est une instance d'une classe C, Python appelle o.__getattribute__ () pour récupérer un pointeur sur la fonction à appeler.
Or un appel de type s = super (C, o) retourne un objet s qui dispose de sa propre implémentation de __getattribute__ (). Autrement dit, super () crée un objet wrapper qui permet de court-circuiter l'appel à __getattribute__ () de l'objet wrappé afin d'introduire une nouvelle manière de résoudre l'appel à une méthode de cet objet.
Pour illustrer le propos, un objet wrapper, ce serait quelque chose comme :
def createMessage (message):
	return (Wrapper (message))

class Wrapper:
	def __init__ (self, message):
		self.o = Message (message)

	def echo (self):
		print ('This is a message: ')
		self.o.f ()

class Message:
	def __init__ (self, message):
		self.message = message

	def echo (self):
		print (self.message)

m = createMessage ('Hello, world!')		# Retourne un objet Wrapper et non Message, qui intercepte l'appel à Message.echo () pour en altérer le fonctionnement
m.echo ()
This is a message
Hello, world!
s.__getattribute__ () s'appuie sur o.__mro__ pour cette résolution. Cette variable est un tuple dont le contenu peut être affiché par un simple print () :
print (Thing.__mro__)		# (<class '__main__.Thing'>, <class 'object'>)
print (MovingThing.__mro__)	# (<class '__main__.MovingThing'>, <class '__main__.Thing'>, <class 'object'>)
print (LivingThing.__mro__)	# (<class '__main__.LivingThing'>, <class '__main__.Thing'>, <class 'object'>)
print (Bird.__mro__)		# (<class '__main__.Bird'>, <class '__main__.LivingThing'>, <class '__main__.MovingThing'>, <class '__main__.Thing'>, <class 'object'>)
Ainsi, __mro__ contient la liste des classes où la méthode appelée doit être recherchée. La manière dont cette liste est constituée dépend de l'algorithme de Method Resolution Order (MRO) utilisé.
Dans Python 3, l'algorithme est l'algorithme C3. Auparavant, l'algorithme était une forme de recherche depth-first et left-to-right, signifiant par-là que si une classe Bird héritait dans l'ordre des classes MovingThing et LivingThing, la résolution d'une référence à Bird.f () consistait à remonter toute la hiérarchie de MovingThing avant de remonter toute celle de LivingThing. Cela posait des problèmes dans des configurations comme celle en diamant montrée plus tôt, ou plusieurs classes parents héritent d'une même classe (on l'a vu : Thing.__init__ () est appelée deux fois, alors qu'il faudrait qu'elle ne le soit qu'une fois). D'où un changement d'algorithme à l'occasion d'une nouvelle version de Python. Cette histoire a été documentée ici en 2010 par l'auteur de Python, sans toutefois rentrer beaucoup dans les détails.
Le rôle des arguments dans l'appel super (C, o) peut maintenant être éclairé :
  • C est la classe après laquelle entamer la recherche dans les classes listées dans o.__class__.__mro__ ;
  • o est une référence à l'instance dont la hiérarchie doit être ainsi explorée.
Mais pourquoi appeler super () à tous les étages de la hiérarchie, c'est-à-dire non seulement dans Bird, mais aussi dans MovingThing et LivingThing ?

Le déroulé d'un appel à super ()

En fait, quel que soit l'étage d'une hiérarchie donnée, l'appel super () retourne le même objet, et utilise __mro__ de la classe de la même instance. Dans l'exemple, cela signifie que les appels à super () dans les fonctions __init__ () de Bird, MovingThing et LivingThing reviennent toujours à faire travailler le même wrapper sur b.__class__.__mro__, qui n'est autre que Bird.__mro__ :
(<class '__main__.Bird'>, <class '__main__.LivingThing'>, <class '__main__.MovingThing'>, <class '__main__.Thing'>, <class 'object'>)
Toutefois, à chaque étage, la classe à partir de laquelle rechercher __init__ () dans Bird.__mro__ évolue. Cela devient clair une fois les appels à super () déroulés :
class Thing:
	def __init__ (self):
		print ('Thing.__init__ ()')

class LivingThing (Thing):
	def __init__ (self):
		print ('LivingThing.__init__ ()')
		super.__init__ ()		# Revient à super (LivingThing, self).__init__ ()

class MovingThing (Thing):
	def __init__ (self):
		print ('MovingThing.__init__ ()')
		super ().__init__ ()	# Revient à super (MovingThing, self).__init__ ()

class Bird (LivingThing, MovingThing):
	def __init__ (self):
		super ().__init__ ()	# Revient à super (Bird, self).__init__ ()

b = Bird ()
La création de l'instance par b = Bird () débouche donc sur le scénario suivant :
Exécuter b = Bird ()
→ Appeler Bird.__init__ ()
Exécuter super (Bird, self).__init__ ()
→ Rechercher __init__ () dans self.__class__.__mro__ (donc Bird.__mro__) après la classe Bird, donc dans LivingThing
→ Appeler LivingThing.__init__ ()
Exécuter super (LivingThing, self).__init__ ()
→ Rechercher __init__ () dans self.__class__.__mro__ (donc Bird.__mro__) après la classe LivingThing, donc dans MovingThing
→ Appeler MovingThing.__init__ ()
Exécuter super (MovingThing, self).__init__ ()
→ Rechercher __init__ () dans self.__class__.__mro__ (donc Bird.__mro__) après la classe MovingThing, donc dans Thing
→ Appeler Thing.__init__ ()
Au passage, qui en doute peut demander l'affichage de __self__.__class__.__name__ dans toutes les méthodes __init__ () de la hiérarchie pour s'assurer que self est bien toujours considéré comme une instance de la classe Bird. C'est que __init__ () est appelée après la création de l'instance. Ne pas confondre __init__ () et __new__ () !

Un fonctionnement super mal documenté

Ce fonctionnement de super () est super mal documenté, au point qu'on se demande si quelqu'un y a jamais compris quelque chose.
Généralement, les auteurs se contentent de recommander d'utiliser super () pour déléguer, quelle que soit la méthode considérée, et ce à tous les étages de la hiérarchie de classes. La seule chose qu'ils parviennent à expliquer clairement, c'est un fonctionnement de super () qui semble relever de l'effet de bord : en utilisant super ().<methode> () plutôt que <class>.<methode> (), on peut éviter de nommer une classe parent dans le contexte d'une classe enfant et permettre donc de plus facilement changer cette dernière. Par exemple, une première version... :
class LivingThing (Thing):
	def __init__ ():
		super ().__init__ ()
...et une seconde version après modification de la hiérarchie :
class LivingThing (SomeThing):
	def __init__ ():
		super ().__init__ ()	# Comme Thing n'était pas citée, inutile de mentionner que SomeThing la remplace
Soit, mais dans un contexte où tout EDI digne de ce nom est désormais doté de fonctionnalités de refactoring, on ne voit guère l'intérêt. Par exemple, dans PyCharm 2017, il suffit de presser Maj + F6 pour réusiner, et c'est fini. Bref, pas de quoi se taper le cul par terre, et à plus forte raison en faire l'objet d'un article ou d'une vidéo.
Ce que les auteurs omettent aussi souvent de rappeler, mais comme la documentation de Python concernant super () le précise bien ici, c'est qu'il est parfaitement possible d'appeler super () en dehors du contexte d'une classe. Par exemple :
class Thing:
	def __init__ (self, name):
		self.name = name

class LivingThing (Thing):
	def __init__ (self, name):
		super.__init__ (name)

class MovingThing (Thing):
	def __init__ (self, name):
		super.__init__ (name)

class Bird (LivingThing, MovingThing):
	def __init__ (self, name):
		super.__init__ (name)

b = Bird ('The Great Eagle')
print (b.name)
super (MovingThing, b).__init__ ('The Crow')
print (b.name)
The Great Eagle
The Crow
Au passage, il convient de remarquer les limitations imposées par super (), pour le coup bien expliquées ici par Raymond Hettinger. La fonction s'utilise pour mettre en place un héritage multiple coopératif, ce qui signifie qu'en ce qui concerne la méthode surchargée (dans les exemples donnés jusqu'à présent, __init__ ()) :
  • toutes les classes doivent adopter la même signature pour la méthode ;
  • toutes les implémentations de la méthode doivent appeler super ()...
  • ...sauf son implémentation dans une classe racine.
L'auteur présente diverses techniques pour contourner la première limitation, notamment utiliser un dictionnaire où chaque implémentation de la méthode va chercher les arguments qu'elle attend.
Mentionner l'usage de super () en dehors du contexte d'une classe permet de bien rappeler que super () n'est pas la méthode d'une classe, mais une fonction. Incidemment , cela permet de rappeler que super () ne sert donc pas qu'à déléguer. Son potentiel est bien plus grand.
Quant à savoir dans quelles circonstances ce potentiel pourrait être exploité, c'est une autre histoire... Dans la réalité, il y a fort à parier que l'immense majorité des développeurs se limitent à utiliser super () pour déléguer sans difficulté dans le cas où une hiérarchie de classes où plusieurs classes parents peuvent hériter d'une même classe. Une configuration qui, quand on (y) pense bien, n'est certainement pas si fréquente... Mais peut-être qu'ayant mieux compris comment fonctionne super (), cela donnera des idées à certains ?
Pour finir, c'est étrangement en lisant ici une partie de la documentation de Python consacrée au descripteurs qu'il est possible de vraiment prendre conscience de tout ce potentiel. En effet, ce qui est expliqué, c'est qu'il existe quatre types de bindings, et que super est tout simplement l'un d'eux :
  • Direct call : x.__get__ (a)
  • Class binding : A.xA.__dict__['x'].__get__ (None, A).
  • Instance binding : a.xtype (a).__dict__['x'].__get__ (a, type (a))
  • Super binding : super (B, obj).m () → rechercher dans obj.__class__.__mro__ la classe de base A qui précède B et invoquer A.__dict__['m'].__get__ (obj, obj.__class__)
Comprendre comment super () fonctionne permet donc de s'assurer quelle implémentation d'une méthode doit être utilisée à chaque étage d'une hiérarchie où elle est surchargée.

Pourquoi appeler super () ?

Oui, c'est presque le même titre qu'au début, mais la conclusion d'un article ne doit-elle pas avant tout démontrer qu'une réponse a été apportée à la question formulée en introduction, et ouvrir la réflexion pour la suite ? C'est donc du "pourquoi" et non plus du "pour quoi" dont il sera question pour finir.
Because the MRO is complex and a well-defined algorithm is used, Pyhton can't leave it to you to get the MRO right. Instead, Python gives you the super() function, which handles all of this for you in the places that you need the altering type of action as I did in Child.altered. With super() you don't have to worry about getting this right, and Python will find the right function for you.
C'est vraiment le genre de propos qui m'exaspère. Sans doute, pour conduire une voiture, nul besoin de comprendre comment marche ce qu'elle a sous le capot. De fait, si tous ceux qui souhaitent conduire devaient l'apprendre, bien peu conduiraient, et il n'est même pas dit qu'ils se révèleraient les meilleurs conducteurs. Pour renverser la métaphore du singe savant, on ne peut contester que lorsque l'animal accomplit son tour, il peut faire mieux que son dresseur.
Toutefois, si celui qui utilise un outil peut ignorer comment l'outil fonctionne, ce n'est jamais que dans une certaine mesure. Au minimum, il est bon qu'il élabore sur ce fonctionnement des hypothèses raisonnables, sans quoi il pourrait se lancer dans des usages qui, in fine, se révèleraient impraticables. Même s'il n'est pas exposé à ce risque car en matière d'usages, on ne lui demanderait pas d'accomplir beaucoup, en apprendre de ce fonctionnement peut enrichir sa culture générale, ce machin qui permet juste à la pensée "de s’exercer avec ordre, de discerner dans les choses l’essentiel de l’accessoire, d’apercevoir les prolongements et les interférences, bref de s’élever à ce degré où les ensembles apparaissent sans préjudice des nuances", comme l'a si bien dit Mongénéral.
Et surtout, super () n'est pas sous le capot. La pratique du modèle objet de Python impose au développeur d'écrire sans cesse des appels à la fonction. Or peut-on légitimement exiger de quelqu'un qu'il passe son temps à énoncer des mots dont il ne comprend que vaguement, voire pas du tout, le sens ? Comme déjà dit, faire un usage rituel de super (), ce qui, en plus d'être absurde, est aussi dégradant et, quand on se met à colporter son ignorance, aussi nuisible ?
Comme souvent, l'analyse du problème ne trouve finalement pas mieux à s'exprimer que dans la formulation initiale du problème, cette dernière permettant désormais d'en saisir toute la profondeur. En l'espèce, ce que l'analyse du déficit de documentation de super () révèle, c'est effectivement un déficit de documentation. Mais il ne faut donc pas se tromper de coupable. Les auteurs de Python sont les premiers responsables de la production de didacticiels tels que ceux mis à l'index dans l'introduction de cet article. S'ils attendent des développeurs qu'ils utilisent une fonctionnalité, ils doivent la documenter de telle sorte que chacun puisse comprendre ce qu'il fait en la mobilisant. Céder à la tendance qui conduit à pervertir une technologie en la complexifiant, c'est déjà commettre une faute matérielle. Ne pas assumer cette complexification en laissant les utilisateurs auxquels on l'impose seuls face à cette dernière, c'est la doubler d'une faute morale.

Pour aller plus loin...

Pour creuser le sujet sur super (), il faut visiblement se reporter à des articles comme Can you customize method resolution order?.
Il est aussi intéressant de lire des critiques sur la fonction, comme The Sadness of Python's super() ou Python's Super is nifty, but you can't use it (anciennement Python's Super Considered Harmful).
Comment marche réellement la fonction super () de Python