Exceptions et assertions

Temps de lecture10 min

En bref

Résumé de l’article

Dans ce cours, nous introduisons les exceptions, qui indiquent des erreurs détectées pendant l’exécution du programme.

Nous détaillons également les assertions, qui permettent de faire des vérifications à un certain point du programme. Elles sont très pratiques pour le développement, car elles permettent de lever des erreurs quand quelque chose ne va pas.

Points clés à retenir

  • Les exceptions sont des erreurs qui surviennent pendant l’exécution du programme, comme des saisies utilisateur invalides ou des fichiers manquants.

  • Les exceptions peuvent être capturées et gérées par le programme.

  • Le bloc try-except est utilisé pour capturer et gérer les exceptions en Python.

  • La clause finally est utilisée pour définir des actions de nettoyage qui doivent être exécutées dans toutes les circonstances.

  • La clause raise permet de lever (i.e. générer une exception).

  • Les assertions sont faites pour valider et appliquer les conditions attendues dans le code pendant l’exécution.

  • Les assertions échouées fournissent un retour immédiat, aidant les développeurs à identifier et corriger rapidement les problèmes.

  • L’utilisation cohérente des assertions applique des contraintes, conduisant à un logiciel plus robuste et fiable.

  • Les assertions peuvent être facilement activées/désactivées pour la production.

Contenu de l’article

1 — Exceptions

1.1 — Qu’est-ce qu’une exception ?

Un code peut être syntaxiquement et sémantiquement correct mais voir son exécution abortée brutalement à cause d’une erreur dite d’exécution. Voici quelques exemples de tels scénarios :

  • Un utilisateur saisit des données invalides.
  • Un fichier est manquant ou inaccessible.
  • Une connexion réseau est perdue.

Les exceptions sont utilisées pour traiter ces erreurs.

Une exception est un événement qui se produit pendant l’exécution d’un programme, qui perturbe le flux normal des instructions. Mais les exceptions ne sont pas nécessairement fatales, elles peuvent être gérées par le programme.

Cependant, quand une exception n’est pas gérée par les programmes, cela résulte en un message d’erreur comme celui montré ci-dessous :

def euclidean_division (a: int, b: int) -> int:

    """
        This function returns the integer division of a by b.
        Beware of division by 0!
        In:
            * a: The numerator.
            * b: The divisor.
        Out:
            * The integer division of a by b.
    """

    # No check for division by 0, the program will crash if b is 0
    return a // b

Exécuter le code précédent avec b = 0 lèvera une ZeroDivisionError en Python.

Output
Traceback (most recent call last):
  File "functions.py", line 16, in <module>
    euclidean_division(10, 0) 
    ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "functions.py", line 14, in euclidean_division
    return a // b
           ~~^^~~
ZeroDivisionError: integer division or modulo by zero

La dernière ligne du message d’erreur indique ce qui s’est passé. Les exceptions viennent en différents types, et le type est affiché comme partie du message (ZeroDivisionError). La chaîne affichée comme type d’exception est le nom de l’exception intégrée qui s’est produite.

Information

Les programmes peuvent définir leurs propres types d’exception en créant une nouvelle classe d’exception (qui devrait être dérivée de la classe Exception), mais nous nous focaliserons sur les types existants dans cette session.

1.2 — Gestion des exceptions

1.2.1 — Le bloc try-except

Il est possible d’écrire des programmes qui gèrent des exceptions sélectionnées, c’est-à-dire, qui détectent quand une exception se produit, et la capturent pour faire quelque chose et empêcher le programme de planter. Pour ce faire, nous pouvons utiliser un bloc try-except pour capturer et gérer l’exception. La syntaxe du bloc try-except est la suivante :

# The code that has a chance to raise an exception should be written within the try block
try:
    <do something>

# The code to execute in case of an exception of specified type should be written in the except block
except <exception type>:
    <handle the error>

L’instruction try fonctionne comme suit :

  1. D’abord, la clause try (la ou les instruction(s) entre les mots-clés try et except) est exécutée.
  2. Voici ce qui peut arriver ensuite :
    • Si aucune exception ne se produit, la clause except est ignorée, et l’exécution de la clause try est terminée.
    • Si une exception se produit pendant l’exécution de la clause try (par exemple, à la ligne 42), le reste de la clause (lignes 43 et au-delà) est ignoré. Ensuite, si le type de l’exception levée correspond à l’exception nommée après le mot-clé except (<exception type> dans le code ci-dessus), la clause except est exécutée, et puis l’exécution continue après le bloc try-except.
    • Si une exception se produit qui ne correspond pas à l’exception nommée dans la clause except, elle est transmise aux instructions try externes (s’il y en a). Si aucun gestionnaire n’est trouvé, c’est une exception non gérée et l’exécution s’arrête avec un message d’erreur.

L’exemple suivant demande à l’utilisateur une saisie jusqu’à ce qu’un entier valide ait été saisi, mais permet à l’utilisateur d’interrompre le programme (en utilisant Control-C).

# Infinite loop
while True:

    # Start the block that may raise an exception
    try:

        # Here, int() can raise a ValueError if the provided string cannot be converted to an integer
        x = int(input("Please enter a number: "))

        # If we can reach this point, it means that no exception was raised
        # We abort the infinite loop
        break

    # In case of a ValueError, we make a print and loop again
    except ValueError:
        print("Oops!  That was no valid number.  Try again...")

Une instruction try peut avoir plus d’une clause except, pour spécifier des gestionnaires pour différentes exceptions. Au plus un gestionnaire sera exécuté. Une clause except peut nommer plusieurs exceptions comme un tuple entre parenthèses, par exemple :

except (RuntimeError, TypeError, NameError):
    pass

L’instruction pass est utilisée comme espace réservé pour du code futur. Quand l’instruction pass est exécutée, rien ne se passe (c’est une commande pour “ne rien faire”), mais vous évitez d’obtenir une erreur quand du code vide n’est pas autorisé. Le code vide n’est pas autorisé dans les boucles, les définitions de fonctions, les définitions de classes, ou dans les instructions if.

Information

Comme toutes les exceptions Python sont dérivées d’une classe de base appelée Exception, vous pouvez capturer toute erreur qui se produit pendant le bloc try en écrivant :

except Exception:
    pass

Ou, de manière équivalente :

except:
    pass

Enfin, si vous voulez plus d’informations sur l’exception que vous avez capturée, vous pouvez utiliser la bibliothèque traceback, et obtenir l’exception dans une variable (e dans le code ci-dessous) :

# Needed import
import traceback

# Raise and catch an exception
try:
    x = 1 / 0
except Exception as e:

    # Print a few info
    print(f"Type of the exception: {type(e).__name__}")
    print(f"Error message of the exception: {str(e)}")
    
    # Show complete traceback (which function was called, etc.)
    print("Traceback:")
    traceback.print_exc()
1.2.2 — Définir des actions de nettoyage

L’instruction try a une autre clause optionnelle qui est destinée à définir des actions de nettoyage qui doivent être exécutées dans toutes les circonstances. Si une clause finally est présente, la clause finally s’exécutera comme dernière tâche avant que l’instruction try ne se termine. Par exemple :

try:
    while True:
        print("Program is running")

except KeyboardInterrupt:
    print("Oh! you pressed CTRL + C.")
    print("Program interrupted.")

finally:
    print("This was an important code, ran at the end.")
Output
Program is running
Program is running
...
Program is running
Program is running
Prog^Cis running
Oh! you pressed CTRL + C.
Program interrupted.
This was an important code, ran at the end.
Information

La clause finally est exécutée qu’une exception soit levée ou non dans la clause try.

1.3 — Lever des exceptions

Les exceptions sont levées quand une erreur se produit. Mais il est aussi possible pour un programmeur de forcer une exception à se produire avec le mot-clé raise. L’argument de raise indique l’exception à lever. Cela doit être soit une instance d’exception soit une classe d’exception.

Par exemple :

def euclidean_division (a: int, b: int) -> int:

    """
        This function returns the integer division of a by b.
        Beware of division by 0!
        In:
            * a: The numerator.
            * b: The divisor.
        Out:
            * The integer division of a by b.
        Exceptions:
            * ZeroDivisionError: If b is 0.
    """

    # Raise an exception with a custom error message
    if b == 0:
        raise ZeroDivisionError("Division by zero. Please provide a non-zero divisor.")
    
    # Perform a division that cannot be by 0
    return a // b

Dans l’exemple ci-dessus, la fonction euclidean_division lève une ZeroDivisionError en Python si le diviseur b est 0. Ici, le même type d’erreur est levé mais le message diffère. Cela montre principalement comment lever une exception.

Nous pourrions aller un peu plus loin et définir notre propre classe étendant Exception et la lever, au lieu de juste personnaliser le message d’erreur. Avec cela, nous pouvons ajouter des propriétés à l’objet exception créé et les utiliser dans le bloc except.

class MyDivisionError(Exception):
    _message = "Division by zero is not allowed."
    
    """Custom exception for division errors."""
    def __init__(self, message=_message):
        self.message = message
        super().__init__(self.message)


def euclidean_division (a: int, b: int) -> int:

    """
        This function returns the integer division of a by b.
        Beware of division by 0!
        In:
            * a: The numerator.
            * b: The divisor.
        Out:
            * The integer division of a by b.
        Exceptions:
            * ZeroDivisionError: If b is 0.
    """

    # Raise an exception with a custom error message
    if b == 0:
        raise MyDivisionError()
    
    # Perform a division that cannot be by 0
    return a // b

if __name__ == "__main__":
    # Test cases
    try:
        print(euclidean_division(10, 2))  # Should print 5
        print(euclidean_division(10, 0))  # Should raise MyDivisionError
    except MyDivisionError as e:
        print(e)

2 — Assertions

Les assertions sont un outil crucial en développement logiciel utilisé pour valider les hypothèses faites dans le code. Elles sont des types spécifiques d’exceptions, faites à des fins de développement. Les assertions vérifient si des conditions spécifiques sont vraies pendant l’exécution, aidant à détecter les bugs et erreurs logiques au plus tôt.

Quand une assertion échoue, elle fournit un retour immédiat, aidant au débogage rapide. En appliquant des contraintes et des comportements attendus, les assertions contribuent à créer du code plus fiable et maintenable.

2.1 — Qu’est-ce qu’une assertion ?

Les assertions nous donnent un moyen de rendre nos hypothèses explicites dans notre code. Elles sont utilisées pour vérifier que l’état du programme est comme attendu à un point donné dans le code. Si l’assertion échoue, une exception est levée, et le programme s’arrête. Donc, le mécanisme d’assertion est utilisé pour vérifier les conditions pendant l’exécution du code.

La plupart du temps, cela ne nécessite aucune dépendance supplémentaire. En Python, une assertion est ajoutée en utilisant le mot réservé assert, suivi par la condition à vérifier, et un message optionnel à afficher si l’assertion échoue : assert condition, message.

Considérons la euclidean_division vue plus tôt. Dans ce code, nous avons une hypothèse que b != 0. Par conséquent, une assertion peut être plus adaptée qu’une exception :

def euclidean_division (a: int, b: int) -> int:

    """
        This function returns the integer division of a by b.
        Beware of division by 0!
        In:
            * a: The numerator.
            * b: The divisor.
        Out:
            * The integer division of a by b.
    """

    # This assertion will fail when a division by 0 is attempted
    assert b != 0, "Division by zero"

    # If we pass all assertions, we are fine
    return a // b

Les assertions peuvent être utilisées pour enrichir le code en vérifiant les invariants de code et ainsi être vues comme une forme de documentation : elles peuvent décrire l’état que le code s’attend à trouver avant de s’exécuter.

Cependant, les assertions sont seulement utilisées dans la phase de développement et sont rendues silencieuses en production. Par conséquent, en production, la vérification que b != 0 ci-dessus n’est pas faite. Il est ainsi supposé que le programme ne devrait jamais appeler la fonction avec ce cas problématique.

Information

Ceci est fait pour accélérer le code et éviter les vérifications inutiles.

Cependant, si vous sentez que l’erreur peut encore arriver pendant la production, une exception peut encore être plus adaptée. C’est particulièrement le cas quand vous interagissez avec un utilisateur qui pourrait définir b = 0.

Comme les assertions peuvent être désactivées, vous devriez faire attention aux points suivants :

  • Les assertions ne devraient pas avoir d’effets de bord (elles ne devraient pas modifier l’état du programme, mais seulement le vérifier) – Par exemple, vous ne devriez pas utiliser d’assertion pour ouvrir un fichier, mettre à jour une structure de données, etc.

  • Les assertions ne devraient pas être utilisées pour vérifier des conditions qui peuvent être causées par des facteurs externes (par exemple, saisie utilisateur, réseau, etc.) – Par exemple, vous ne devriez pas utiliser d’assertions pour vérifier si un fichier existe.

  • Les assertions ne sont pas un substitut à la gestion d’erreurs, la validation d’entrée, les tests, la documentation, etc.

  • Quand vous utilisez des assertions qui nécessitent un temps d’exécution assez élevé, assurez-vous que les calculs sont faits dans la condition assert. En effet, si vous calculez le résultat avant, le stockez dans une variable, et puis vérifiez la variable dans l’assert, le calcul sera encore fait lors de la désactivation des assertions. Pour de telles vérifications complexes, il peut ainsi être une bonne idée de définir des fonctions, et les appeler dans l’assert. Par exemple, dans le code suivant, la fonction is_sorted est appelée dans l’assert :

def binary_search (data: list[int], search_value: int) -> int :

    """ 
        This function searches for a value in a sorted list using the binary search algorithm.
        In:
            * data: The list to search in.
            * search_value: The value to search for.
        Out:
            * The index of the value in the list if it is found, -1 otherwise.
    """

    assert len(data) > 0, "The list is empty"
    assert is_sorted(data), "The list is not sorted"    

Pour aller plus loin

Vous pouvez consulter ces deux ressources pour distinguer les propriétés attendues d’un code en mode développement et production :

Pour aller au-delà

Il semble que cette section soit vide !

Y a-t-il quelque chose que vous auriez aimé voir ici ? Faites-le nous savoir sur le serveur Discord ! Peut-être que nous pouvons l’ajouter rapidement. Sinon, cela nous aidera à améliorer le cours pour l’année prochaine !