logo le blog invivoo blanc

Prototype – Design Patterns

6 janvier 2022 | Python | 0 comments

I. Contexte

Pour gérer et analyser les portefeuilles des fonds qui contiennent de nombreux ordres chez les clients, on fait d’abord les requêtes via les objets d’accès aux données vers les différentes sources, par exemple, les bases de données sur les ordres, les clients, les comptes, les parts, etc. avant de créer une liste de records instanciés contenant les informations complètes sous les contrôles et vérifications pour chaque portefeuille. En fait, les recherches des sources et les manipulations des données pourraient être très coûteuses à chaque appel d’accès vers leurs sources originales. En effet, un objet d’ordre se composerait par au moins les instances de client, compte et part. On aurait souvent le recours aux caches pour stocker les résultats de ces instances en mémoire, pour que tous les appels lisent les mêmes données mises en cache. Néanmoins, si on veut retoucher localement un champ d’un objet d’accès pendant une simulation, il faut récupérer une copie superficielle ou profonde, afin de ne pas déranger les autres réutilisations de l’application qui demandent toujours les objets originaux. Par exemple, s’il ne fallait chiffrer les données clientèles dans les ordres pendant qu’une seule génération d’un résultat, on aurait dû avoir besoin d’une copie profonde des ordres qui comprennent les objets de client depuis la source, et à la fois les copies superficielles des clients à modifier. Les données de source sont donc protégées grâce au clonage.

En fait, le patron de conception de création : Prototype permet de cloner des objets complexes et coûteux, sans se coupler à leurs classes. Dans cet article, on va apprendre le patron de conception de prototype en guise d’une mise en place d’un portefeuille à dupliquer avec les ordres encapsulant les informations de client, compte et part. Les prototypes servent à préparer les diverses manières à cloner tous ces composants du portefeuille en fonction des besoins. L’héritage multiple et la composition sont introduits pour leurs mises en œuvre en Python 3, tandis que le programme respecte le principe SOLID.

II. Conception de prototype

Le diagramme ULM démontre la conception du Prototype. En général, toutes les classes prototype devraient dériver d’une interface commune IPrototype déclarant une méthode abstraite clone, même sans connaître leur classe concrète de client à copier. Les objets prototype sont capables de créer les copies complètes du client, à condition qu’ils puissent accéder aux attributs privés des autres objets dans la classe de client. Au lieu d’évoquer __new__ ou __init__ de la classe, on appelle directement la méthode clone pour la création. Le client peut exploiter ces prototypes par l’héritage multiple ou la composition.

Selon notre contexte, dans un portefeuille les ordres sont les instances complexes comprenant plusieurs objets élémentaires comme le client, le compte et la part. Leurs informations sont fréquemment manipulées et réutilisées pour les analyses, par exemple, dans différents modèles évaluant les performances du portefeuille. Du coup, si une modification a lieu éventuellement sur un élément, les clones en profondeur devront être demandés chez les ordres ainsi que le portefeuille, afin que leurs informations originales soient toutes intouchables en application. Toutefois, les duplicatas des objets compris dans un ordre n’auront besoin que les duplications superficielles, si l’on appelle leurs méthodes de clone

III. Développement de prototype

Dans notre exemple, les objets client, compte, part composent une instance ordre, lorsque qu’un portefeuille contient une liste d’ordres instanciés. On va vérifier si grâce au prototype, les données resteront inchangées chez tous les ordres du portefeuille à la source, à la suite d’un chiffrement sur leurs informations clientèles dans une copie.

Afin de simplifier la démonstration, on cherche simplement les attributs basiques dont les valeurs viennent des sources extérieures pour les instances. Du fait, à part les identifiants uniques pour chacun, on définit la date d’exécution, le montant d’exécution et la devise chez l’ordre ; le nom et le pays du client ; le nom et le type du compte ; le nom et l’état de la part. En plus, dans cet exemple le portefeuille ne contient que dix ordres exécutés hier chez un client dans le même compte sur les parts identiques. Les montants des ordres à la source sont générés de la façon aléatoire à la distribution uniforme entre -100000,0 et 100000,0, lorsque leurs devises sont toujours en euro.   

Dans un premier temps, on conçoit en Python 3 l’interface de prototype IPrototype avec la méthode de clone, qui est mise en œuvre dans les deux classes dérivées pour obtenir les copies superficielles ou profondes. Les classes client, compte, part, ordre et portefeuille héritent des deux prototypes concrets en fonction de leurs attributs internes. D’ailleurs, on introduit les propiétés et le __slots__ pour rendre les attributs concisément utilisables pour le prototype et optimaux en mémoire. Sinon, en Python 3 les méthodes copy et deepcopy permettraient de fournir aux prototypes une duplication superficielle ou profonde respectivement au gré du besoin. Tandis que l’on veut chiffrer les informations de client par Fernet chez les ordres, on cherche uniquement un duplicata profond de tous les ordres du portefeuille. Par conséquent, lors du prochain appel du clone du portefeuille, on pourra retrouver constamment les informations originales de client dans les ordres.

# prototype_shallow_deep_clones.py

from abc import ABC, abstractmethod
from copy import copy, deepcopy
from cryptography.fernet import Fernet
from datetime import datetime, timedelta
from pytest import raises
from random import uniform

key = Fernet.generate_key()


class IPrototype(ABC):
    __slots__ = ()
    
    @abstractmethod
    def clone(self):
        raise NotImplemented


class ShallowClone(IPrototype):
    __slots__ = () 
    
    def clone(self):
        return copy(self)
        
        
class DeepClone(IPrototype):
    __slots__ = () 
    
    def clone(self):
        return deepcopy(self)
        
        

class Client(ShallowClone):
    
    __slots__ = ('_client_id', '_client_name', '_client_country')
    
    def __init__(self):
        self._client_id = None
        self._client_name = None
        self._client_country = None
        
    @property
    def client_id(self):
        return self._client_id
        
    @property
    def client_name(self):
        return self._client_name
        
    @property
    def client_country(self):
        return self._client_country
        
    @client_id.setter
    def client_id(self, value):
        self._client_id = value
        
    @client_name.setter
    def client_name(self, value):
        self._client_name = value
        
    @client_country.setter
    def client_country(self, value):
        self._client_country = value 
        
        
class Account(ShallowClone):
    
    __slots__ = ('_account_id', '_account_name', '_account_type')
    
    def __init__(self):
        self._account_id = None
        self._account_name = None
        self._account_type = None
        
    @property
    def account_id(self):
        return self._account_id
        
    @property
    def account_name(self):
        return self._account_name
        
    @property
    def account_type(self):
        return self._account_type
        
    @account_id.setter
    def account_id(self, value):
        self._account_id = value
        
    @account_name.setter
    def account_name(self, value):
        self._account_name = value
        
    @account_type.setter
    def account_type(self, value):
        self._account_type = value 


class Share(ShallowClone):
    
    __slots__ = ('_share_id', '_share_name', '_share_state')
    
    def __init__(self):
        self._share_id = None
        self._share_name = None
        self._share_state = None
        
    @property
    def share_id(self):
        return self._share_id
        
    @property
    def share_name(self):
        return self._share_name
        
    @property
    def share_state(self):
        return self._share_state
        
    @share_id.setter
    def share_id(self, value):
        self._share_id = value
        
    @share_name.setter
    def share_name(self, value):
        self._share_name = value
        
    @share_state.setter
    def share_state(self, value):
        self._share_state = value 


class Order(DeepClone):
    
    __slots__ = ('_order_id', '_pricing_date', '_amount', '_currency',
                 '_client', '_account', '_share')
    
    def __init__(self):
        self._order_id = None
        self._client = None
        self._account = None
        self._share = None
        self._pricing_date = None
        self._amount = None 
        self._currency = None 
        
    @property
    def order_id(self):
        return self._order_id
        
    @property
    def pricing_date(self):
        return self._pricing_date
        
    @property
    def amount(self):
        return self._amount
        
    @property
    def currency(self):
        return self._currency
        
    @property
    def client(self):
        return self._client
        
    @property
    def account(self):
        return self._account
        
    @property
    def share(self):
        return self._share
        
    @order_id.setter
    def order_id(self, value):
        self._order_id = value
        
    @pricing_date.setter
    def pricing_date(self, value):
        self._pricing_date = value
        
    @amount.setter
    def amount(self, value):
        self._amount = value
        
    @currency.setter
    def currency(self, value):
        self._currency = value
        
    @client.setter
    def client(self, value):
        self._client = value
        
    @account.setter
    def account(self, value):
        self._account = value
        
    @share.setter
    def share(self, value):
        self._share = value 
        
class Portfolio(DeepClone):
    
    __slots__ = ('_portfolio_id', '_orders')
    
    def __init__(self):
        self._portfolio_id = None
        self._orders = None 
        
    @property
    def portfolio_id(self):
        return self._portfolio_id
        
    @property
    def orders(self):
        return self._orders

    @portfolio_id.setter
    def portfolio_id(self, value):
        self._portfolio_id = value
        
    @orders.setter
    def orders(self, value):
        self._orders = value


def get_original_portfolio(): 
    original_orders = []
    for i in range(10):
        original_client = Client()
        original_client.client_id = 100
        original_client.client_name = 'Clement' 
        original_client.client_country = 'France'
        
        original_account = Account()
        original_account.account_id = 'A00'
        original_account.account_name = 'BKSG'
        original_account.account_type = 'omnibus'
        
        original_share = Share()
        original_share.share_id = 'FR123'
        original_share.share_name = 'ETF201'
        original_share.share_state = 'open'
        
        original_order = Order()
        original_order.order_id = i
        original_order.pricing_date = datetime.today() - timedelta(days=1)
        original_order.amount = uniform(-100000.0, 100000.0)
        original_order.currency = 'EUR'
        original_order.client = original_client
        original_order.account = original_account
        original_order.share = original_share
        original_orders.append(original_order)
    
    original_portfolio = Portfolio()
    original_portfolio.orders = original_orders
    return original_portfolio
    

def encrypt(message):
    fernet = Fernet(key) 
    return fernet.encrypt(str(message).encode('utf'))
    
    
def decrypt(encode_message): 
    fernet = Fernet(key)
    return fernet.decrypt(encode_message).decode('utf')
    
        
if __name__ == '__main__':
    original_portfolio = get_original_portfolio()
    
    copy_portfolio_1 = original_portfolio.clone()
    # profound copies
    assert copy_portfolio_1.orders !=  original_portfolio.orders
    assert len(copy_portfolio_1.orders) ==  len(original_portfolio.orders)
    assert copy_portfolio_1.portfolio_id ==  original_portfolio.portfolio_id
    
    for (o_1, o_2) in zip(copy_portfolio_1.orders, original_portfolio.orders):
        # profound copies
        assert o_1.order_id == o_2.order_id
        assert o_1.pricing_date == o_2.pricing_date
        assert o_1.amount == o_2.amount
        assert o_1.currency == o_2.currency
        assert o_1.client != o_2.client
        assert o_1.account != o_2.account
        assert o_1.share != o_2.share 
        
        # shallow copies
        assert o_1.client.client_id == o_2.client.client_id
        assert o_1.client.client_name == o_2.client.client_name
        assert o_1.client.client_country == o_2.client.client_country
        assert o_1.account.account_id == o_2.account.account_id
        assert o_1.account.account_name == o_2.account.account_name
        assert o_1.account.account_type == o_2.account.account_type
        assert o_1.share.share_id == o_2.share.share_id
        assert o_1.share.share_name == o_2.share.share_name
        assert o_1.share.share_state == o_2.share.share_state
    
    for o in copy_portfolio_1.orders:
        copy_client = o.client.clone()
        copy_client.client_name = encrypt(copy_client.client_name)
        copy_client.client_country = encrypt(copy_client.client_country)
        o.client = copy_client
    
    for o in copy_portfolio_1.orders:
        assert o.client.client_name != 'Clement' 
        assert decrypt(o.client.client_name) == 'Clement'
        assert o.client.client_country != 'France'
        assert decrypt(o.client.client_country) == 'France'
    
    copy_portfolio_2 = original_portfolio.clone()
    for o in copy_portfolio_2.orders:
        assert o.client.client_name == 'Clement'
        assert o.client.client_country == 'France'

Les tests unitaires sont bien passés. En effet, on crée un portefeuille par la méthode « couteuse » get_original_portfolio, qui ne devra jamais être changé à la suite. La 1e duplication du portefeuille confirme d’abord que la copie profonde s’applique sur lui-même et ses ordres, et les autres objets sont clonés en guise de la copie superficielle. Ensuite on y chiffre les informations clientèles confidentielles, ce qui ne modifie que le duplicata chez les ordres, lorsque toutes les données dans le portefeuille original restent intactes et disponibles pour une utilisation suivante.

Par ailleurs, la méthode clone pourrait être réalisée d’une manière plus complexe dans le prototype, si l’on ne veut copier que certains attributs d’un objet lors de son appel. Par exemple, au lieu de chiffrer le nom et le pays chez un client, on pourrait enlever ces attributs secrets lors du clonage.

Le code suivant aborde ce sujet. La mise en œuvre de la classe PartialClone dérivant de l’interface IPrototype prend un ensemble de noms d’attribut en propriété (hidden_property_set) à enlever. Grace à l’héritage multiple en Python 3, la classe Client peut dériver de cette classe de prototype et d’une classe OriginalClient sans clone. Avant chaque clonage, on doit définir les propriétés à supprimer dans son hidden_property_set, lorsque l’on remet ce dernier à un ensemble vide, pour que la duplication superficielle redevienne totale.

# prototype_partial_clone_by_inheritance.py

from abc import ABC, abstractmethod
from copy import copy
from pytest import raises


class IPrototype(ABC):
    __slots__ = ()
    
    @abstractmethod
    def clone(self):
        raise NotImplemented

        
class PartialClone(IPrototype):
    __slots__ = ()
    
    @property
    def hidden_property_set(self):
        return self._hidden_property_set
    
    @hidden_property_set.setter
    def hidden_property_set(self, v):
        self._hidden_property_set = v
        
    def reset_hidden_property_set(self):
        self.hidden_property_set = set()
        
    def clone(self):
        result = self.__class__.__new__(self.__class__)
        for base in self.__class__.__mro__:
            for s in getattr(base, '__slots__', []):
                if s not in self.hidden_property_set:
                    setattr(result, s, getattr(self, s))
        return result
        

class OriginalClient(object):
    __slots__ = ('_client_id', '_client_name', '_client_country')
    
    def __init__(self):
        self._client_id = None
        self._client_name = None
        self._client_country = None
        
    @property
    def client_id(self):
        return self._client_id
        
    @property
    def client_name(self):
        return self._client_name
        
    @property
    def client_country(self):
        return self._client_country
        
    @client_id.setter
    def client_id(self, value):
        self._client_id = value
        
    @client_name.setter
    def client_name(self, value):
        self._client_name = value
        
    @client_country.setter
    def client_country(self, value):
        self._client_country = value
        
        
class Client(OriginalClient, PartialClone):
    __slots__ = ('_hidden_property_set', )


def create_orignal_client():
    original_client = Client()
    original_client.client_id = 100
    original_client.client_name = 'Clement'
    original_client.client_country = 'France'
    return original_client


if __name__ == '__main__':
    original_client = create_orignal_client()
    
    original_client.reset_hidden_property_set()
    copy_client_1 = original_client.clone()
    assert copy_client_1.client_id == 100
    assert copy_client_1.client_name == 'Clement'
    assert copy_client_1.client_country == 'France' 
    copy_client_1.client_id = 10
    assert original_client.client_id == 100
    assert original_client.client_name == 'Clement'
    assert original_client.client_country == 'France' 
    
    original_client.hidden_property_set = {'_client_name', '_client_country'}
    copy_client_2 =  original_client.clone()
    original_client.reset_hidden_property_set() # should reset the clone 
    assert copy_client_2.client_id == 100
    with raises(AttributeError):
        copy_client_2.client_name
    with raises(AttributeError):
        copy_client_2.client_country
    
    copy_client_2.client_id = 10
    assert original_client.client_id == 100
    assert original_client.client_name == 'Clement'
    assert original_client.client_country == 'France'

Les tests unitaires sont bien passés. Le client dérive des deux classes qui s’occupent de fabriquer et cloner l’objet de client respectivement. Quand l’on appelle sa méthode reset_hidden_property_set, la copie superficielle totale est remise pour son clonage. En revanche, dès que hidden_property_set est affecté par un ensemble de noms d’attribut définis dans __slots__, un filtre chez la méthode clone empêche les duplications des attributs concernés. Du coup, les attributs d’un objet sont bien enlevés en copie, lorsque les données originales sont intouchées.

En outre, on aimerait parfois demander le clonage qui se varie dynamiquement au gré du besoin durant l’exécution. A part le héritage multiple en Python 3, on peut s’appuyer sur la composition pour mettre en place le clone partiel dans le code suivant, où le client dérive directement de l’interface IPrototype et il encapsule un objet de clonage (par défaut superficiel total) qui s’occupe de copier son instance. Du coup, justement avant l’appel de la méthode clone chez le client, on pourrait changer cet objet de clonage comme on veut pendant l’exécution, lorsque l’on le remet au clonage initial après l’appel, ce qui rend notre utilisation de clone flexible.

# prototype_partial_clone_by_composition.py

from abc import ABC, abstractmethod
from copy import copy
from pytest import raises


class IPrototype(ABC):
    __slots__ = ()
    
    @abstractmethod
    def clone(self):
        raise NotImplemented


class ShallowClone(IPrototype):
    __slots__ = ('_instance', )
        
    @property
    def instance(self):
        return self._instance
    
    @instance.setter
    def instance(self, v):
        self._instance = v
    
    def clone(self):
        return copy(self._instance)
        
        
class PartialClone(IPrototype):
    __slots__ = ('_hidden_property_set', '_instance')
    
    @property
    def hidden_property_set(self):
        return self._hidden_property_set
        
    @property
    def instance(self):
        return self._instance
    
    @hidden_property_set.setter
    def hidden_property_set(self, v):
        self._hidden_property_set = v
    
    @instance.setter
    def instance(self, v):
        self._instance = v
    
    def clone(self):
        result = self._instance.__class__.__new__(self._instance.__class__)
        for base in self._instance.__class__.__mro__:
            for s in getattr(base, '__slots__', []):
                if s not in self._hidden_property_set:
                    setattr(result, s, getattr(self._instance, s))
        return result
        

class Client(IPrototype):
    
    __slots__ = ('_client_id', '_client_name', '_client_country',
                 '_clone_obj')
    
    def __init__(self):
        self._client_id = None
        self._client_name = None
        self._client_country = None
        self._clone_obj = ShallowClone()
        
    @property
    def client_id(self):
        return self._client_id
        
    @property
    def client_name(self):
        return self._client_name
        
    @property
    def client_country(self):
        return self._client_country
        
    @property
    def clone_obj(self):
        return self._clone_obj
        
    @client_id.setter
    def client_id(self, value):
        self._client_id = value
        
    @client_name.setter
    def client_name(self, value):
        self._client_name = value
        
    @client_country.setter
    def client_country(self, value):
        self._client_country = value
        
    @clone_obj.setter
    def clone_obj(self, value):
        self._clone_obj = value
        
    def clone(self):
        c = self._clone_obj
        c.instance = self
        return c.clone()
        

def create_orignal_client():
    original_client = Client()
    original_client.client_id = 100
    original_client.client_name = 'Clement'
    original_client.client_country = 'France'
    return original_client
    
    
if __name__ == '__main__':
    original_client = create_orignal_client()
    
    copy_client_1 = original_client.clone()
    assert copy_client_1.client_id == 100
    assert copy_client_1.client_name == 'Clement'
    assert copy_client_1.client_country == 'France'
    copy_client_1.client_id = 10
    assert original_client.client_id == 100
    assert original_client.client_name == 'Clement'
    assert original_client.client_country == 'France'
    
    partial_clone = PartialClone()
    partial_clone.hidden_property_set = {'_client_name', '_client_country'}
    initial_clone = original_client.clone_obj
    original_client.clone_obj = partial_clone
    assert original_client.client_id == 100
    assert original_client.client_name == 'Clement'
    assert original_client.client_country  == 'France' 
    
    copy_client_2 =  original_client.clone()
    original_client.clone_obj = initial_clone  # should reset to the original
    assert copy_client_2.client_id == 100
    with raises(AttributeError):
        copy_client_2.client_name
    with raises(AttributeError):
        copy_client_2.client_country
    
    copy_client_2.client_id = 10
    assert original_client.client_id == 100
    assert original_client.client_name == 'Clement'
    assert original_client.client_country == 'France'

Les tests unitaires sont bien passés. Le client dérivant de l’interface IPrototype met au point la méthode clone en déléguant son instance à un objet de clonage, qui est responsable de la duplication et fourni pendant l’exécution du programme avant l’appel de clone. L’objet de clonage superficiel par défaut chez le client va copier entièrement l’instance du client. Or, si un objet de clonage partiel est affecté au client, la duplication superficielle aura lieu partiellement. Car cet objet prend en compte l’ensemble de noms d’attribut à enlever. Sinon, les données originales sont toujours inchangées.

Quoi que ce soit implémenté par l’héritage ou la composition en Python 3, plusieurs prototypes sont désormais disponibles pour fabriquer les duplicatas des différents objets chez un portefeuille complexe selon les besoins. D’ailleurs, par l’héritage, il faudrait choisir en avance une meilleure méthode clone qui s’adapterait aux attributs de l’instance à la phase du développement, lorsque la composition permettrait d’instancier dynamiquement un clonage qui fait partie de l’objet à copier justement avant la demande du clone.

Du fait, le patron de conception Prototype découple la génération d’un objet et son clonage. D’ores et déjà, on ne pourrait construire qu’une seule fois les objets originaux complexes dont on profiterait pour diverses copies nécessaires à la suite.

IV. Conclusion

Dans cet article, on met en évidence la nécessité du patron de conception Prototype, qui répond à nos besoins de la réutilisation des instances coûteuses durant leur première fabrication de la source. Il respecte le principe de responsabilité unique en découplant la génération et le clonage. Les implémentations s’appuient sur l’héritage multiple ou la composition en Python 3, ce qui permet de mettre les divers types de duplication dehors les objets à cloner. Le patron de conception prototype est mis en place et testé pour les copies superficielles, profondes et partielles, ce qui pourrait facilement s’étendre aux autres façons du clonage.