DRY Python

Introduction

Si vous avez en tête le Martini Dry, DRY Python ce n’est pas un nouveau cocktail ! C’est plutôt l’application d’un des bons principes de développement appliqués au Python. Dans cet article, nous allons explorer les principes DRY et OAOO, quand et comment les appliquer. Nous allons ensuite nous focaliser sur comment ces principes peuvent être utilisés dans le langage Python en exploitant toutes les fonctionnalités qui nous ont été mises à disposition.

 

DRY et OAOO

DRY et OAOO sont deux acronymes qui signifient respectivement « Don’t Repeat Yourself » et « Only And Only Once ». Ils sont fortement corrélés et ils passent un message clair : éviter la duplication de code à tout prix !

Le problème de la duplication du code a été examiné plusieurs fois et de façons plus exhaustive dans de nombreux ouvrages. Je vais juste rappeler certains points fondamentaux pour expliquer pourquoi éviter de dupliquer le code :

  • Si notre application ne contient pas de duplication de code, les modifications, les améliorations ou les bug fixes, vont se faire dans un endroit unique.
  • La duplication impacte la maintenabilité du code. Dupliquer le code a plusieurs conséquences négatives :
    • Elle rend notre code sujet aux erreurs : Oubli de modifier la même portion de code en cas de correction ou amélioration.
    • Elle est coûteuse : Le fait de devoir modifier plusieurs fois le même code dans notre application nous prend beaucoup plus de temps.
    • Elle n’est pas fiable : Liée au premier point, la vérité, logique du code, est répétée plusieurs fois.

Exemple

Ce code ne respecte pas le principe DRY. La logique de calcul est répétée à deux endroits différents plutôt qu’être mise dans une fonction.

def process_teams_ranking(teams):
    teams_ranking = sorted(
        teams, key=lambda team: team['victories'] * 3 + team['draws'] * 1, reverse=True)

    for team in teams_ranking:
        print('Name {0}, Score {1}'.format(
            team['name'], (team['victories'] * 3 + team['draws'] * 1)))


teams = [
    {
        'name': 'Team Blue',
        'victories': 5,
        'draws': 1,
        'losts': 3
    },
    {
        'name': 'Team Red',
        'victories': 4,
        'draws': 3,
        'losts': 2
    }
]

process_teams_ranking(teams)

La solution pour ce problème est évidente : bouger la logique de calcul du score dans une autre fonction et la passer comme argument pour le sort et pour l’affichage.

def calculate_points(team):
    return team['victories'] * 3 + team['draws'] * 1


def process_teams_ranking(teams):
    teams_ranking = sorted(
        teams, key=lambda team: calculate_points(team), reverse=True)

    for team in teams_ranking: 
        print('Name {0}, Score {1}'.format(
            team['name'], calculate_points(team)))


teams = [
    {
        'name': 'Team Blue',
        'victories': 5,
        'draws': 1,
        'losts': 3
    },
    {
        'name': 'Team Red',
        'victories': 4,
        'draws': 3,
        'losts': 2
    }
]

process_teams_ranking(teams)

 

KIS

Le principe DRY n’est pas l’unique dont nous devons tenir compte lors de l’écriture du code. Il y a pas mal de principes que je pourrais mentionner, mais j’ai décidé d’en choisir un autre qui me tient à cœur : KIS. L’acronyme signifie « Keep It Simple » et il repose sur le fait de garder le code le plus simple possible, en termes de lecture et de logique.

Le principe n’est pas en contradiction avec DRY, mais il met en garde tous les développeurs que pour éviter la duplication du code créant des architectures surdimensionnées (over-engineering). C’est souvent le cas dans la programmation Objets où il y a une classe mère où une interface qui est implémentée par une seule classe enfant. Nous sommes en train de rajouter de la complexité à notre système sans vraiment bénéficier d’un avantage. Dans ce cas, il existe une règle très simple : avant de créer un composant réutilisable il faut que, dans notre code, il y ait au moins à 3 endroits différents la nécessité de ce composant. Si ce n’est pas le cas, alors autant garder la logique à cet endroit, simplifier la lecture et garder notre architecture simple.

 

Application en Python

Dans cette section, on va décliner le principe DRY en Python et nous allons passer en revue différentes façons de factoriser le code pour éviter la duplication via des décorateurs, contexte managers et descripteurs.

Decorator

Les décorateurs existent en plusieurs langages et permettent d’extraire une partie de la logique dans un composant séparé et de rajouter cette logique à un autre composant sans modifier son implémentation. En Python, les décorateurs sont des fonctions et peuvent être appliquées à d’autres fonctions ou à des classes.

Des exemples typiques de décorateurs sont :

  • Logger
  • Calcul du temps d’exécution
  • Logique de retry
  • Caching (ex. fonction de mémorisation)

Ce qui est commun à tous les décorateurs sont les caractéristiques suivantes :

  • Encapsulation: séparation des responsabilités entre la logique du décorateur et la logique de l’objet qui est décoré
  • Orthogonalité: indépendance entre le décorateur et l’objet décoré
  • Réutilisable : le même décorateur peut être appliqué à différents types et classes

Chaque décorateur doit faire une seule chose. C’est une mauvaise pratique de donner trop des responsabilités à une seule fonction. En deux mots, il doit respecter aussi le principe « single responsibility ». Comme nous allons voir dans le prochain exemple, plutôt que d’avoir un décorateur unique, nous allons séparer le décorateur de log de celui de timing. Les deux seront ensuite appliqués à la même fonction.

Exemple

Un exemple de code avec un décorateur de log et de mesure du temps d’exécution.

from functools import wraps
import logging
import time


def log_function(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        logging.info('Execution of function {}'.format(function.__name__))
        return function(*args, **kwargs)

    return wrapped


def time_function(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        start_time = time.time()
        result = function(*args, *kwargs)
        elapsed_time = time.time() - start_time
        logging.info('Execution of function {} took {} sec'.format(
            function.__name__, elapsed_time))
        return result

    return wrapped


@log_function
@time_function
def hello_fn(name='No one'):
    time.sleep(1)
    print('Hello "{}"'.format(name))


# Increase logging level
logging.getLogger().setLevel(logging.DEBUG)

# Execute the function
hello_fn('Ulysse')

Context managers

Context manager est une fonctionnalité distinctive de Python. Elle est utilisée chaque fois qu’une partie de code doit être exécutée avec des pre et post conditions, autrement dit quand nous allons exécuter du code avant et après l’exécution de la partie de code qui est intéressé par le contexte.

Des exemples typiques sont :

  • Connexion à une database
  • Lecture/Ecriture d’un fichier
  • Gestion des exceptions

Le context manager se matérialise dans le code avec le keyword with. Tous les détails sur comment implémenter un contexte manager sont en dehors de cet article.

Exemple

Un exemple de context manager qui pourrait être utilisé pour effectuer la connexion à une DB et lancer des commandes.

class DBHandlerConnection:
    def __init__(self, db_name):
        self.db = db_name

    def __enter__(self):
        print('Connection established with DB {}'.format(self.db))
        return self

    def __exit__(self, exc_type, ex_value, ex_traceback):
        print('Connection closed with DB {}'.format(self.db))


def send_backup_command(db_handler):
    print('Send backup command to DB {}'.format(db_handler.db))


with DBHandlerConnection('MySQL') as mysql_db_handler:
    send_backup_command(mysql_db_handler)

Descripteurs

Les descripteurs, comme les décorateurs et les context managers, permettent d’achever le problème de la duplication du code. Ils sont appliqués aux membres d’une classe pour y rajouter des fonctionnalités. Nous retrouvons dans les cas classiques d’implémentation des descripteurs pour la validation des membres ou le fait d’historiser les valeurs pour un membre dans le temps.

Pour obtenir un descripteur, il faut implémenter un protocole particulier et il faut définir typiquement quatre méthodes :

  • __get__
  • __set__
  • __delete__
  • __set_name__

Si nous implémentons uniquement la méthode __get__ nous allons parler de non-data-descriptor, autrement si nous implémentons __set__ et __delete__ alors nous parlons d’un data-descriptor.

Un exemple de descripteur que nous retrouvons très souvent dans le code est @property sur une méthode d’une classe. Celui-ci nous permet de faire une manipulation avant de renvoyer la valeur.

Exemple

Dans cet exemple nous allons voir comment utiliser le descripteur @property pour appliquer un taux de change à notre montant sans casser les consumer de notre classe.

class ClothesDistribution:

    def __init__(self):
        self.hat_price = 2
        self.tshrit_price = 10
        self.swimsuit_price = 5


italy_distribution = ClothesDistribution()
france_distribution = ClothesDistribution()

print('Price of "hat" in Italy is {} EUR.'.format(italy_distribution.hat_price))
print('Price of "hat" in France is {} EUR.'.format(france_distribution.hat_price))

Si notre consumer décide que sa device est USD, nous allons retourner un montant différent avec le taux de change appliqué.

EUR_2_USD = 1.13


class ClothesDistribution:

    def __init__(self, currency='EUR'):
        self.currency = currency
        self._hat_price = 2
        self._tshrit_price = 10
        self._swimsuit_price = 5

    def _conversion_factor(self):
        return EUR_2_USD if self.currency == 'USD' else 1

    @property
    def hat_price(self):
        return self._hat_price * self._conversion_factor()

    @property
    def tshrit_price(self):
        return self._tshrit_price * self._conversion_factor()

    @property
    def swimsuit_price(self):
        return self._swimsuit_price * self._conversion_factor()


italy_distribution = ClothesDistribution()
france_distribution = ClothesDistribution()
amer_distribution = ClothesDistribution('USD')

print('Price of "hat" in Italy is {} EUR.'.format(italy_distribution.hat_price))
print('Price of "hat" in France is {} EUR.'.format(france_distribution.hat_price))
print('Price of "hat" in US is {} USD.'.format(amer_distribution.hat_price))

 

Conclusion

Nous avons exploré différentes façons pour agréger notre code, le rendre indépendant et le réutiliser. Chaque technique a sa propre difficulté de mise en place et ses particularités dans l’utilisation, mais toutes ont un fil rouge : elles peuvent être appliquées sur n’importe quel objet, autrement dit, les classes que nous avons défini sont indépendantes des objets sur lesquels nous allons les appliquer.

Avant de commencer à écrire un composant qui peut être réutilisé, il faut vérifier si effectivement le coût d’over-engineering est justifié par la cardinalité de son utilisation.