Article
· Avr 19 6m de lecture

Conception par contrat (DbC)

Cette fois, je ne veux pas parler d’une fonctionnalité géniale d’IRIS (qui en possède de nombreuses), mais plutôt d’une fonctionnalité qui manque cruellement.
Aujourd’hui, parler de POO n’est pas sexy. Bien que presque tous les langages de programmation modernes implémentent une sorte de POO, les discussions sur les problèmes fondamentaux du développement logiciel ne sont pas très courantes entre les développeurs de technologies telles que les développeurs. En fait, l’informatique dans son ensemble n’est pas un sujet courant parmi les développeurs, ce qui devrait l’être à mon avis.
Dans cette communauté de développeurs InterSystems, la plupart des messages portent sur des questions (Comment puis-je… ?) sur la façon d'utiliser la technologie ou sur des solutions pratiques à un problème (Comment puis-je… ?).
Mais peu d’entre eux portent sur les concepts fondamentaux de l’informatique et sur la manière dont ils sont implémentés (ou non) dans notre plateforme de données préférée.
 
Comment peut-on utiliser un concept fondamental tel que la POO, sans en discuter sur la manière dont il est implémenté sur votre plateforme préférée ?
C'est la même chose pour tout le monde qui utilise (et a des opinions sur) les API REST sans même savoir ce que signifie REST ou avoir lu la thèse de Roy Fielding.
Ou en utilisant le concept d'ESB et en ne sachant rien du livre de Dave Chappel dans lequel il a introduit pour la première fois le terme Enterprise Service Bus.
(Mal)Utiliser le terme « agile » sans jamais avoir lu le Manifeste Agile.

L'un de mes livres préférés de tous les temps sur l'informatique et la POO est le livre de Bertrand Meyer Object-Oriented Software Construction (2e édition) (OOSC), un classique sur la qualité, la robustesse, l'extensibilité et la réutilisabilité des logiciels, et pourquoi la POO est une solution pour bon nombre de ces problèmes.
Tout développeur utilisant des classes, des objets, des méthodes et des héritages devrait lire ce livre.

 
Sur la base du travail de DataTree, InterSystems a réalisé une excellente implémentation POO avec les extensions Caché et Caché Object Script pour Mumps. Excellent héritage multiple (sur lequel j'écrirai probablement un autre article) et polymorphisme, non seulement dans le langage de programmation mais aussi dans la base de données. À mon avis, la seule (mais unique) combinaison qui implémente véritablement la POO : le code et les données en un seul endroit.
 
Mais ce qui manque à IRIS et qui est décrit en détail dans OOSC, c'est une fonctionnalité qui pose une couche de robustesse concise sur la POO appelée Design by Contract.
 
Ce que nous voyons souvent dans le code (méthodes) est :
méthode getMail(id) → Mail
S'il n'y a pas d'id
, la méthode renvoie « L'identifiant est requis »
 
C'est ce que nous appelons la « programmation défensive », la méthode appelée s'arme contre les invocations « illégales ».
Mais le « contrat » de la méthode est évident : si vous me donnez un identifiant (valide) je vous renverrai l’e-mail associé, ce code, exécuté à chaque invocation, ne devrait pas être nécessaire. De cette façon, la responsabilité est transférée de l’appelant à la méthode appelée.
 
DbC est tout cela, le « contrat » d'une classe ou d'une méthode ou d'une instance d'objet de cette classe indique précisément ce qui est attendu en entrée et ce qui est promis en sortie tant que l'appelant adhère au contrat de la méthode (ou la classe dans son ensemble).
 
DbC est une programmation défensive sous stéroïdes, sans la pénalité d'exécution consistant à tout vérifier : vous décrivez toutes les contraintes au moment de la conception.
DbC dispose de deux modes d'exécution : développement et production.
En mode développement, toutes les contraintes et vérifications sont exécutées et rapportées.
En mode production, ils ne seront exécutés qu’en cas d’exception.
 
Comment DbC fait-il cela ?
 
Au niveau de la classe, vous définissez des « invariants » : des conditions qui doivent être remplies à tout moment (saisie des méthodes de constructeur exclues).
Au niveau de la méthode, vous définissez des pré-conditions et des post-conditions.
Avant même d’exécuter une seule ligne de code, les conditions préalables doivent être remplies.
Après avoir exécuté le code de la méthode, les post-conditions doivent être remplies, pour que pour chaque propriété il y ait une « ancienne » valeur, la valeur qu'avait la propriété avant l'exécution de la méthode.
Et tant pour entrer que pour sortir de la méthode, les invariants doivent toujours être valides.

En mode développement, ces conditions seront toutes exécutées. À ce niveau, vous pouvez choisir comment gérer les conditions non remplies : générer une erreur ou simplement signaler l'événement.

 
En mode production, les conditions ne seront pas exécutées, mais en cas d'exception dans la méthode, les invariants et les pré-conditions seront vérifiés et feront partie des informations sur l'exception, après quoi une erreur sera générée à tout moment.
Vous pouvez également définir une méthode de « sauvetage », un code qui est exécuté lorsqu'une exception se produit, depuis la définition des valeurs de la base de données, la fermeture des fichiers et des connexions, l'exécution du code de réparation, etc. Vous pouvez même définir un indicateur « réessayer ».
Bien entendu, vous pouvez également obtenir ce dernier résultat avec les mécanismes $ZTrap ou Try-Catch standard d'IRIS.
 
Pourquoi devrions-nous vouloir DbC dans IRIS Objectscript ?
Je pense que le code sera beaucoup plus robuste et, n'ayant pas à se soucier des pénalités de performances au moment de l'exécution, les conditions peuvent (devraient) être assez élaborées.
Après avoir construit le bon ensemble de conditions, les tests unitaires ne seraient guère nécessaires, les « tests » seront exécutés à chaque invocation d’une méthode. Et, contrairement aux frameworks de tests unitaires séparés, les conditions sont directement dans le code, c'est aussi dans la documentation.
 
Si DbC est si génial, pourquoi n’est-il pas largement appliqué ?
Revenons à mon introduction : l'ignorance.
Comme les développeurs, en général, ne « font » pas d’informatique, ils ne seront jamais en mesure de saisir l’essence des concepts fondamentaux de l’ingénierie informatique. Mais, pire encore, les enseignants, les programmes de formation professionnelle et les « experts » connaissent à peine le concept DbC.
Les tests (unitaires) sont un concept beaucoup plus facile à comprendre que la rédaction d'un « contrat » pour des classes ou du code.
Je m'attends également à ce qu'il soit assez difficile à intégrer dans un langage ou un compilateur existant.
Il existe quelques langages intégrant DbC, le plus important étant Eiffel, un langage, ce qui n'est pas surprenant, conçu par Bertrand Meyer.
 
Chez InterSystems, la tendance semble s'éloigner lentement d'ObjectScript, en particulier avec la fonctionnalité Embedded Python, ce qui est une brillante initiative.
Mais du fait qu'ObjectScript pourrait intégrer DbC, le langage bénéficierait d’un formidable élan pour la conception d’applications robustes.
Il serait intéressant de savoir ce que les principaux développeurs d'ObjectScript ont à dire à ce sujet.
Discussion (4)1
Connectez-vous ou inscrivez-vous pour continuer

Exemple en ObjectScript

Include %occErrors

Class MyApp.Account Extends %Persistent
{

Property Balance As %Numeric;

/// Deposit money into the account
Method Deposit(amount As %Numeric) As %Status
{
    // Preconditions
    If amount < 0 {
        Return $$$ERROR($$$GeneralError, "Deposit amount must be non-negative")
    }

    // Store the old balance
    Set oldBalance = ..Balance

    // Update balance
    Set ..Balance = oldBalance + amount

    // Postconditions
    If (..Balance '= $$$NULLOREF) && (..Balance '= (oldBalance + amount)) {
        Return $$$ERROR($$$GeneralError, "Postcondition failed: Balance calculation error")
    }

    Quit $$$OK
}

/// Withdraw money from the account
Method Withdraw(amount As %Numeric) As %Status
{
    // Preconditions
    If amount < 0 {
        Return $$$ERROR($$$GeneralError, "Withdrawal amount must be non-negative")
    }
    If (..Balance = $$$NULLOREF) || (..Balance < amount) {
        Return $$$ERROR($$$GeneralError, "Insufficient funds")
    }

    // Store the old balance
    Set oldBalance = ..Balance

    // Update balance
    Set ..Balance = oldBalance - amount

    // Postconditions
    If (..Balance '= $$$NULLOREF) && (..Balance '= (oldBalance - amount)) {
        Return $$$ERROR($$$GeneralError, "Postcondition failed: Balance calculation error")
    }

    Quit $$$OK
}

/// Invariant: Balance should always be non-negative
Method CheckBalanceInvariant() As %Status
{
        Set tSC = $$$OK
        If ..Balance < 0 {
            Set tSC = $$$ERROR($$$GeneralError, "Balance invariant violated: Balance is negative")
        }
        Quit tSC
}

/// Class method to test the Account class
ClassMethod TestAccount() As %Status
{
    // Create a new instance of Account
    Set account = ##class(MyApp.Account).%New()
    
    // Initialize the balance
    Set account.Balance = 0
    
    // Test depositing a positive amount
    Set tSC = account.Deposit(100)
    If $$$ISERR(tSC) {
        Write "Deposit failed: ", $system.Status.GetErrorText(tSC), !
        Quit tSC
    }
    Write "Deposit succeeded: Balance after deposit: ", account.Balance, !
    
    // Test depositing a negative amount (should fail)
    Set tSC = account.Deposit(-50)
    If $$$ISERR(tSC) {
        Write "Deposit of negative amount failed as expected: ", $system.Status.GetErrorText(tSC), !
    } Else {
        Write "Deposit of negative amount unexpectedly succeeded", !
        Quit $$$ERROR($$$GeneralError, "Deposit of negative amount unexpectedly succeeded")
    }
    
    // Test withdrawing a valid amount
    Set tSC = account.Withdraw(50)
    If $$$ISERR(tSC) {
        Write "Withdrawal failed: ", $system.Status.GetErrorText(tSC), !
        Quit tSC
    }
    Write "Withdrawal succeeded: Balance after withdrawal: ", account.Balance, !
    
    // Test withdrawing more than the available balance (should fail)
    Set tSC = account.Withdraw(200)
    If $$$ISERR(tSC) {
        Write "Withdrawal of more than available balance failed as expected: ", $system.Status.GetErrorText(tSC), !
    } Else {
        Write "Withdrawal of more than available balance unexpectedly succeeded", !
        Quit $$$ERROR($$$GeneralError, "Withdrawal of more than available balance unexpectedly succeeded")
    }
    
    // Check balance invariant (should succeed)
    Set tSC = account.CheckBalanceInvariant()
    If $$$ISERR(tSC) {
        Write "Balance invariant violated: ", $system.Status.GetErrorText(tSC), !
        Quit tSC
    }
    Write "Balance invariant holds true", !
    
    // Intentionally set balance to negative value to trigger balance invariant failure
    Set account.Balance = -10
    
    // Check balance invariant (should fail)
    Set tSC = account.CheckBalanceInvariant()
    If $$$ISERR(tSC) {
        Write "Balance invariant violated as expected: ", $system.Status.GetErrorText(tSC), !
    } Else {
        Write "Balance invariant unexpectedly held true", !
        Quit $$$ERROR($$$GeneralError, "Balance invariant unexpectedly held true")
    }
    
    Write "Account operations completed successfully", !
    Quit $$$OK
}

Storage Default
{
<Data name="AccountDefaultData">
<Value name="1">
<Value>%%CLASSNAME</Value>
</Value>
<Value name="2">
<Value>Balance</Value>
</Value>
</Data>
<DataLocation>^MyApp.AccountD</DataLocation>
<DefaultData>AccountDefaultData</DefaultData>
<IdLocation>^MyApp.AccountD</IdLocation>
<IndexLocation>^MyApp.AccountI</IndexLocation>
<StreamLocation>^MyApp.AccountS</StreamLocation>
<Type>%Storage.Persistent</Type>
}

}

Avec le résultat suivant :

write ##class(MyApp.Account).TestAccount()
Deposit succeeded: Balance after deposit: 100
Deposit of negative amount failed as expected: ERROR #5001: Deposit amount must be non-negative
Withdrawal succeeded: Balance after withdrawal: 50
Withdrawal of more than available balance failed as expected: ERROR #5001: Insufficient funds
Balance invariant holds true
Balance invariant violated as expected: ERROR #5001: Balance invariant violated: Balance is negative
Account operations completed successfully
1

Exemple en Python :

class Account:
    def __init__(self):
        self.balance = 0

    def deposit(self, amount):
        if amount < 0:
            raise ValueError("Deposit amount must be non-negative")
        
        old_balance = self.balance
        self.balance += amount
        
        # Postconditions
        if self.balance != old_balance + amount:
            raise ValueError("Postcondition failed: Balance calculation error")

    def withdraw(self, amount):
        if amount < 0:
            raise ValueError("Withdrawal amount must be non-negative")
        if self.balance < amount:
            raise ValueError("Insufficient funds")

        old_balance = self.balance
        self.balance -= amount
        
        # Postconditions
        if self.balance != old_balance - amount:
            raise ValueError("Postcondition failed: Balance calculation error")

    def check_balance_invariant(self):
        if self.balance < 0:
            raise ValueError("Balance invariant violated: Balance is negative")

    @classmethod
    def test_account(cls):
        account = cls()
        
        try:
            # Test depositing a positive amount
            account.deposit(100)
            print("Deposit succeeded: Balance after deposit:", account.balance)
            
            # Test depositing a negative amount (should fail)
            account.deposit(-50)
        except ValueError as e:
            print("Deposit of negative amount failed as expected:", e)
        else:
            raise ValueError("Deposit of negative amount unexpectedly succeeded")
        
        try:
            # Test withdrawing a valid amount
            account.withdraw(50)
            print("Withdrawal succeeded: Balance after withdrawal:", account.balance)
            
            # Test withdrawing more than the available balance (should fail)
            account.withdraw(200)
        except ValueError as e:
            print("Withdrawal of more than available balance failed as expected:", e)
        else:
            raise ValueError("Withdrawal of more than available balance unexpectedly succeeded")
        
        try:
            # Check balance invariant (should succeed)
            account.check_balance_invariant()
            print("Balance invariant holds true")
        except ValueError as e:
            print("Balance invariant violated:", e)
        
        # Intentionally set balance to negative value to trigger balance invariant failure
        account.balance = -10
        
        try:
            # Check balance invariant (should fail)
            account.check_balance_invariant()
        except ValueError as e:
            print("Balance invariant violated as expected:", e)
        else:
            raise ValueError("Balance invariant unexpectedly held true")
        
        print("Account operations completed successfully")

# Run the test
Account.test_account()

Avec le résultat suivant :

python account.py 
Deposit succeeded: Balance after deposit: 100
Deposit of negative amount failed as expected: Deposit amount must be non-negative
Withdrawal succeeded: Balance after withdrawal: 50
Withdrawal of more than available balance failed as expected: Insufficient funds
Balance invariant holds true
Balance invariant violated as expected: Balance invariant violated: Balance is negative
Account operations completed successfully