Le présent post est très fortement inspiré par un exemple de cette vidéo sur super par mcoding.

La curiosité

Vous connaissez probablement super, qui permet de remplacer un appel explicite à une classe parente :

# sans super :
class BaseLogger:
    def log(self, msg: str):
        print(msg)


class TimestampLogger(BaseLogger):
    def log(self, msg: str):
        timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        timestamped_msg = f"{timestamp} {msg}"
        BaseLogger.log(self, timestamped_msg)  # <-- appel explicite à la classe parente


# avec super :
class BaseLogger:
    def log(self, msg: str):
        print(msg)


class TimestampLogger(BaseLogger):
    def log(self, msg: str):
        timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        timestamped_msg = f"{timestamp} {msg}"
        super().log(timestamped_msg)  # <-- pas d'appel explicite à la classe parente

Avec la hiérarchie de classes ci-dessus, grâce à super, TimestampLogger.log appellera la méthode de la classe parente, soit BaseLogger.log :

logger = TimestampLogger()
logger.log("Pouet")  # <-- grâce à super(), TimestampLogger.log appelle BaseLogger.log

Et neuf fois sur dix, "super appelle la méthode de la classe parente" est un raccourci amplement suffisant. Mais saviez-vous que ça n’était pas toujours vrai ?

Si je reprends — sans les modifier — exactement les deux mêmes classe BaseLogger et TimestampLogger, et que je leur adjoins deux autres classes UpperLogger et surtout TimestampUpperLogger qui mélange les deux précédentes :

class BaseLogger:
    def log(self, msg: str):
        print(msg)


class TimestampLogger(BaseLogger):
    def log(self, msg: str):
        timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        timestamped_msg = f"{timestamp} {msg}"
        super().log(timestamped_msg)


class UpperLogger(BaseLogger):
    def log(self, msg: str):
        uppercase_msg = msg.upper()
        super().log(uppercase_msg)


class TimestampUpperLogger(TimestampLogger, UpperLogger):
    def log(self, msg: str):
        super().log(msg)


logger = TimestampUpperLogger()
logger.log("Pouet")  # <-- que fait TimestampLogger.log ?

Avec cette nouvelle hiérarchie, on a un héritage en diamant :

             BaseLogger
            /          \
TimestampLogger      UpperLogger
            \          /
       TimestampUpperLogger

Croyez-le ou non, mais dans cette situation, alors même qu’on n’a pas modifié TimestampLogger et BaseLogger, TimestampLogger.log n’appelle plus la méthode parente BaseLogger.log…​ mais la méthode sœur UpperLogger.log !

L’explication

Cette curiosité n’en est pas une quand on sait que le comportement de super n’est PAS d’appeler la méthode de la classe parente, mais plutôt d’appeler la méthode de "la classe suivante dans le MRO".

MRO quésaco ? Le Method Resolution Order (glossaire python) indique l’ordre selon lequel python va rechercher un attribut d’un objet dans une hiérarchie de classes. Par exemple, si dans notre hiérarchie en diamant, on appelle logger.youpi()logger est une instance de TimestampUpperLogger :

  • l’interpréteur python va commencer par regarder si la classe TimestampUpperLogger a un membre youpi

  • si non, il regardera si TimestampLogger a ce membre youpi

  • idem avec UpperLogger

  • idem avec BaseLogger

  • si BaseLogger n’a pas de membre youpi…​ ce n’est pas encore tout à fait fini ! On regarde alors si object a youpi ; en effet, en python, toutes les classes sont filles de object)

  • ici, même object n’a pas de membre youpi, on raise alors une AttributeError

Je ne détaille pas les règles qui gouvernent le MRO, mais si le sujet vous intéresse, la vidéo qui inspire ce post en fait un résumé, et l’algo complet est décrit ici, ça remonte à la version 2.3 de python !

Avec notre héritage en diamant, le MRO de logger est quelque chose comme :

  • TimestampUpperLogger

  • TimestampLogger

  • UpperLogger

  • BaseLogger

  • object

Ce qu’on peut vérifier dynamiquement :

print(" ".join(klass.__name__ for klass in TimestampUpperLogger.__mro__))
# TimestampUpperLogger TimestampLogger UpperLogger BaseLogger object

Comme UpperLogger vient juste après TimestampLogger dans le MRO, et que super() appelle "la classe suivante dans le MRO", c’est bien UpperLogger.log (soit la méthode de la classe sœur, et non parente) qui sera utilisée.

On pourrait d’ailleurs le vérifier en récupérant manuellement avec super() ce qui vient après TimestampLogger pour l’objet logger :

super(TimestampLogger, logger)
# wrapper autour d'UpperLogger

super(TimestampLogger, logger).log("pouet")
# POUET

Conclusion

Vous savez maintenant que super est un raccourci pour "la classe suivante dans le MRO", et je voulais simplement partager cette curiosité où super() n’appelle pas la classe parente mais plutôt une classe sœur, qu’on retrouvera dans tous les héritages en diamant.

Je suis très très fan des curiosités dans ce genre qui permettent de mieux comprendre le fontionnement des choses sous le capot.

En pratique cependant, je n’aime pas faire des hiérarchies de classes complexes — et encore moins en python où il est facile de les éviter. Notamment, je ne recommande pas d’utiliser ici cet héritage en diamant : on peut simplement utiliser des fonctions pour modifier le message (e.g. str.upper ou add_timestamp_prefix), ou bien utiliser des décorateurs si le besoin se complexifie.

Un dernier mot : pourquoi utiliser super ? La doc de super suggère deux raisons :

  • le cas le plus courant = faire référence à la classe parente sans la nommer explicitement,

  • la curiosité du jour = permettre de résoudre de façon déterministe les attribute-lookups lors des héritages en diamant.