IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Composition de programmes

Partie 2 : Construire des abstractions avec des données


précédentsommaire

7. Opérations génériques

Dans ce chapitre, nous avons présenté les valeurs de données composées, ainsi que la technique d'abstraction des données à l'aide de constructeurs et de sélecteurs. En utilisant le passage de message, nous avons doté directement nos types de données abstraits d'un comportement. En utilisant la métaphore de l'objet, nous avons regroupé la représentation des données et les méthodes utilisées pour manipuler ces données afin de modulariser les programmes pilotés par les données avec l'état local.

Cependant, nous devons encore montrer que notre système d'objets nous permet de combiner différents types d'objets de manière flexible dans un grand programme. Le passage de message via des expressions pointées n'est qu'une façon de créer des expressions combinées avec plusieurs objets. Dans cette section, nous explorons d'autres méthodes pour combiner et manipuler des objets de différents types.

7-1. Conversion de chaîne

Nous avons indiqué au début de ce tutoriel qu'une valeur d'objet devrait se comporter comme le type de données qu'elle est censée représenter, y compris produire une représentation de lui-même sous la forme d'une chaîne de caractères. Les représentations sous forme de chaîne de valeurs de données sont particulièrement importantes dans un langage interactif tel que Python, où la boucle read-eval-print nécessite que chaque valeur ait une sorte de représentation sous forme de chaîne.

Les valeurs de chaîne fournissent un moyen fondamental de communication de l'information entre les humains. Les séquences de caractères peuvent être affichées à l'écran, imprimées sur du papier, lues à haute voix, converties en braille ou transmises en morse. Les chaînes sont également fondamentales pour la programmation, car elles peuvent représenter des expressions Python. Pour un objet, nous souhaitons générer une chaîne qui, lorsqu'elle est interprétée comme une expression Python, est évaluée en un objet équivalent.

Python stipule que tous les objets doivent produire deux représentations distinctes des chaînes : une qui est un texte interprétable par un humain et une qui est une expression interprétable par Python. La fonction de construction des chaînes, str, renvoie une chaîne lisible par l'homme. Lorsque cela est possible, la fonction repr renvoie une expression Python qui évalue un objet égal. La docstring pour repr explique cette propriété :

 
Sélectionnez
1.
2.
3.
4.
repr(object) -> string

Return the canonical string representation of the object.
For most object types, eval(repr(object)) == object.

Le résultat de l'appel de repr sur la valeur d'une expression est ce que Python imprime dans une session interactive.

 
Sélectionnez
>>> 12e12
12000000000000.0
>>> print(repr(12e12))
12000000000000.0

Dans les cas où il n'existe aucune représentation qui évalue à la valeur d'origine, Python produit un proxy.

 
Sélectionnez
>>> repr(min) 
'<built-in function min>'

Le constructeur str coïncide souvent avec repr, mais fournit une représentation de texte plus interprétable dans certains cas. Par exemple, nous voyons une différence entre str et repr avec des dates :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
>>> from datetime import date
>>> today = date(2011, 9, 12)
>>> repr(today)
'datetime.date(2011, 9, 12)'
>>> str(today)
'2011-09-12'

Définir la fonction repr présente un nouveau défi : nous aimerions qu'elle s'applique correctement à tous les types de données, même ceux qui n'existaient pas lorsque repr a été implémentée. Nous aimerions que ce soit une fonction polymorphe, c'est-à-dire qui peut être appliquée à de nombreuses (poly) formes (morph) différentes de données.

Le passage de message fournit une solution élégante dans ce cas : la fonction repr invoque une méthode appelée __repr__ sur son argument.

 
Sélectionnez
>>> today.__repr__()
'datetime.date(2011, 9, 12)'

En implémentant cette même méthode dans des classes définies par l'utilisateur, nous pouvons étendre l'applicabilité de repr à n'importe quelle classe que nous créons à l'avenir. Cet exemple met en évidence un autre avantage du passage de messages en général, à savoir qu'il fournit un mécanisme pour étendre le domaine des fonctions existantes à de nouveaux types d'objets.

Le constructeur str est implémenté d'une manière analogue : il appelle une méthode appelée __str__ sur son argument.

 
Sélectionnez
>>> today.__str__()
'2011-09-12'

Ces fonctions polymorphes sont des exemples d'un principe plus général : certaines fonctions doivent s'appliquer à plusieurs types de données. L'approche de passage de message illustrée ici n'est qu'un exemple d'une famille de techniques pour mettre en œuvre des fonctions polymorphes. Le reste de cette section explore certaines alternatives.

7-2. Représentations multiples

L'abstraction de données, qu'elle utilise des objets ou des fonctions, est un outil puissant pour gérer la complexité. Les types de données abstraits nous permettent de construire une barrière d'abstraction entre la représentation sous-jacente des données et les fonctions ou messages utilisés pour la manipuler. Cependant, dans les grands programmes, parler de « la représentation sous-jacente » d'un type de données dans un programme n'a pas toujours de sens. D'une part, il peut y avoir plus d'une représentation utile pour un objet de données, et nous aimerions peut-être concevoir des systèmes capables de gérer plusieurs représentations.

Pour prendre un exemple simple, les nombres complexes peuvent être représentés de deux manières presque équivalentes : sous la forme algébrique (parties réelle et imaginaire) et sous la forme polaire (module et angle). Parfois, la forme algébrique est plus appropriée et c'est parfois la forme polaire qui est la plus appropriée. En effet, il est parfaitement plausible d'imaginer un système dans lequel les nombres complexes sont représentés des deux manières, et dans lequel les fonctions de manipulation des nombres complexes fonctionnent avec l'une ou l'autre représentation.

Il y a un point plus important encore. Les grands systèmes logiciels sont souvent conçus par de nombreuses personnes travaillant sur de longues périodes, soumises à des exigences qui changent au fil du temps. Dans un tel environnement, il n'est tout simplement pas possible que tout le monde convienne à l'avance des choix de représentation des données. En plus des barrières d'abstraction des données qui isolent la représentation de l'utilisation, nous avons besoin de barrières d'abstraction qui isolent les différents choix de conception les uns des autres et permettent à différents choix de coexister dans un même programme. En outre, étant donné que les grands programmes sont souvent créés en combinant des modules préexistants conçus isolément, nous avons besoin de conventions qui permettent aux programmeurs d'intégrer des modules dans des systèmes plus importants, sans avoir besoin de revoir ou de réécrire ces modules.

Nous commençons par l'exemple simple des nombres complexes. Nous verrons comment le passage de messages nous permet de concevoir des représentations algébriques et polaires distinctes pour des nombres complexes tout en maintenant la notion d'un objet abstrait de type « nombre complexe ». Nous allons y parvenir en définissant des fonctions arithmétiques pour les nombres complexes (add_complex, mul_complex) en termes de sélecteurs génériques qui accèdent à des parties d'un nombre complexe indépendamment de la façon dont le nombre est représenté. Le système de nombres complexes qui en résulte contient deux types différents de barrières d'abstraction. Elles isolent les opérations de niveau supérieur des représentations de niveau inférieur. En outre, il existe une barrière verticale qui nous permet de concevoir séparément des représentations alternatives.

Image non disponible

Notons au passage que nous développons un système qui effectue des opérations arithmétiques sur des nombres complexes comme un exemple simple, mais irréaliste d'un programme qui utilise des opérations génériques. Le type nombre complexe existe déjà en Python, mais pour cet exemple nous allons mettre en place le nôtre.

Comme les nombres rationnels, les nombres complexes sont naturellement représentés comme des paires. L'ensemble des nombres complexes peut être considéré comme un espace à deux dimensions avec deux axes orthogonaux, l'axe réel et l'axe imaginaire. De ce point de vue, le nombre complexe z = x + y * i (avec i*i = -1) peut être considéré comme le point dans le plan dont la coordonnée réelle est x et dont la coordonnée imaginaire est y. L'ajout de nombres complexes implique l'ajout de leurs coordonnées x et y respectives.

Lorsque l'on multiplie des nombres complexes, il est plus naturel de penser en termes de représentation d'un nombre complexe sous la forme polaire, en tant que module (magnitude) et angle. Le produit de deux nombres complexes est le vecteur obtenu en étirant un nombre complexe d'un facteur de la longueur de l'autre, puis en le faisant pivoter de l'angle de l'autre.

Ainsi, il existe deux représentations différentes pour les nombres complexes, qui sont appropriées pour différentes opérations. Cependant, du point de vue de quelqu'un qui écrit un programme utilisant des nombres complexes, le principe de l'abstraction des données suggère que toutes les opérations de manipulation de nombres complexes devraient être disponibles, quelle que soit la représentation utilisée par l'ordinateur.

Interfaces. Le passage de message fournit non seulement une méthode pour coupler le comportement et les données, mais il permet également à différents types de données de répondre au même message de différentes manières. Un message partagé qui suscite un comportement similaire de différentes classes d'objets est une méthode puissante d'abstraction.

Comme nous l'avons vu, un type de données abstrait est défini par des constructeurs, des sélecteurs et des conditions de comportement supplémentaires. Un concept étroitement lié est celui d'une interface, qui est un ensemble de messages partagés, avec une spécification de ce qu'ils signifient. Les objets qui répondent aux méthodes spéciales __repr__ et __str__ implémentent tous une interface commune de types pouvant être représentés en tant que chaînes.

Dans le cas de nombres complexes, l'interface nécessaire à l'implémentation de l'arithmétique consiste en quatre messages : real, imag, magnitude et angle. Nous pouvons implémenter l'addition et la multiplication en termes de ces messages.

Nous pouvons avoir deux types de données abstraits différents pour des nombres complexes qui diffèrent dans leurs constructeurs.

  • ComplexRI construit un nombre complexe à partir de parties réelles et imaginaires.
  • ComplexMA construit un nombre complexe à partir d'un module et d'un angle.

Avec ces messages et constructeurs, nous pouvons implémenter l'arithmétique complexe.

 
Sélectionnez
1.
2.
3.
4.
5.
>>> def add_complex(z1, z2):
        return ComplexRI(z1.real + z2.real, z1.imag + z2.imag)

>>> def mul_complex(z1, z2):
        return ComplexMA(z1.magnitude * z2.magnitude, z1.angle + z2.angle)

La relation entre les termes « type de données abstrait » (abstract data type - ADT) et « interface » est subtile. Un ADT comprend des moyens de construire des types de données complexes, en les manipulant comme des unités et en sélectionnant leurs composants. Dans un système orienté objet, un ADT correspond à une classe, bien que nous ayons vu qu'un système objet n'est pas nécessaire pour implémenter un ADT. Une interface est un ensemble de messages qui ont des significations associées et qui peuvent ou non inclure des sélecteurs. Conceptuellement, un ADT décrit une abstraction représentationnelle complète de quelque chose, alors qu'une interface spécifie un ensemble de comportements qui peuvent être partagés entre plusieurs choses.

Propriétés. Nous aimerions utiliser les deux types de nombres complexes de façon interchangeable, mais il serait peu efficace de stocker des informations redondantes sur chaque nombre. Nous aimerions stocker soit la représentation réelle-imaginaire, soit la représentation module-angle.

Python a une fonction simple pour calculer les attributs à la volée à partir de fonctions à zéro argument. Le décorateur @property permet aux fonctions d'être appelées sans la syntaxe d'appel standard. Une implémentation de nombres complexes en termes de parties réelles et imaginaires illustre ce point.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
>>> from math import atan2
>>> class ComplexRI(object):
        def __init__(self, real, imag):
            self.real = real
            self.imag = imag
        @property
        def magnitude(self):
            return (self.real ** 2 + self.imag ** 2) ** 0.5
        @property
        def angle(self):
            return atan2(self.imag, self.real)
        def __repr__(self):
            return 'ComplexRI({0}, {1})'.format(self.real, self.imag)

Une seconde implémentation utilisant le module et l'angle fournit la même interface, car elle répond au même ensemble de messages.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
>>> from math import sin, cos
>>> class ComplexMA(object):
        def __init__(self, magnitude, angle):
            self.magnitude = magnitude
            self.angle = angle
        @property
        def real(self):
            return self.magnitude * cos(self.angle)
        @property
        def imag(self):
            return self.magnitude * sin(self.angle)
        def __repr__(self):
            return 'ComplexMA({0}, {1})'.format(self.magnitude, self.angle)

En fait, nos implémentations de add_complex et mul_complex sont maintenant terminées ; l'une ou l'autre classe de nombres complexes peut être utilisée pour l'un ou l'autre argument dans l'une ou l'autre des fonctions arithmétiques complexes. Il convient de noter que le système objet ne relie pas explicitement les deux types complexes de quelque manière que ce soit (par exemple, par héritage). Nous avons implémenté l'abstraction des nombres complexes en partageant un ensemble commun de messages, une interface, entre les deux classes.

 
Sélectionnez
>>> from math import pi
>>> add_complex(ComplexRI(1, 2), ComplexMA(2, pi/2))
ComplexRI(1.0000000000000002, 4.0)
>>> mul_complex(ComplexRI(0, 1), ComplexRI(0, 1))
ComplexMA(1.0, 3.141592653589793)

L'approche d'interface pour coder des représentations multiples a des propriétés attrayantes. La classe pour chaque représentation peut être développée séparément ; ces classes doivent seulement s'entendre sur les noms des attributs qu'ils partagent. L'interface est également additive. Si un autre programmeur voulait ajouter une troisième représentation de nombres complexes au même programme, il n'aurait qu'à créer une autre classe avec les mêmes attributs.

Méthodes spéciales. Les opérateurs mathématiques intégrés peuvent être étendus de la même manière que repr ; il existe des noms de méthodes spéciaux correspondant aux opérateurs Python pour les opérations arithmétiques, logiques et séquentielles.

Pour rendre notre code plus lisible, nous aimerions peut-être utiliser les opérateurs + et * directement lors de l'addition et de la multiplication de nombres complexes. L'ajout des méthodes suivantes à nos deux classes de nombres complexes permettra d'utiliser ces opérateurs, ainsi que les fonctions add et mul dans le module operator :

 
Sélectionnez
>>> ComplexRI.__add__ = lambda self, other: add_complex(self, other)
>>> ComplexMA.__add__ = lambda self, other: add_complex(self, other)
>>> ComplexRI.__mul__ = lambda self, other: mul_complex(self, other)
>>> ComplexMA.__mul__ = lambda self, other: mul_complex(self, other)

Maintenant, nous pouvons utiliser la notation infixée avec nos classes définies par l'utilisateur :

 
Sélectionnez
>>> ComplexRI(1, 2) + ComplexMA(2, 0)
ComplexRI(3.0, 2.0)
>>> ComplexRI(0, 1) * ComplexRI(0, 1)
ComplexMA(1.0, 3.141592653589793)

Lectures complémentaires. Pour évaluer les expressions contenant l'opérateur +, Python recherche des méthodes spéciales sur les opérandes gauche et droite de l'expression. Tout d'abord, Python vérifie une méthode __add__ sur la valeur de l'opérande de gauche, puis recherche une méthode __radd__ sur la valeur de l'opérande de droite. Si l'un ou l'autre est trouvé, cette méthode est invoquée avec la valeur de l'autre opérande comme argument.

Des protocoles similaires existent pour évaluer les expressions qui contiennent n'importe quel type d'opérateur en Python, y compris les notations de tranche et les opérateurs booléens. La documentation Python liste l'ensemble exhaustif de noms de méthodes pour les opérateurs. Le livre Dive into Python 3 a un chapitre sur les noms de méthodes spéciales qui décrit de nombreux détails de leur utilisation dans l'interpréteur Python.

7-3. Fonctions génériques

Notre implémentation de nombres complexes a rendu deux types de données interchangeables en tant qu'arguments des fonctions add_complex et mul_complex. Nous allons maintenant voir comment utiliser cette même idée non seulement pour définir des opérations qui sont génériques sur différentes représentations, mais aussi pour définir des opérations qui sont génériques sur différents types d'arguments qui ne partagent pas une interface commune.

Les opérations que nous avons définies jusqu'à présent traitent les différents types de données comme étant complètement indépendants. Ainsi, il existe des paquets distincts pour ajouter, disons, deux nombres rationnels, ou deux nombres complexes. Ce que nous n'avons pas encore considéré est le fait qu'il est sensé de définir des opérations qui traversent les frontières de type, telles que l'addition d'un nombre complexe à un nombre rationnel. Nous nous sommes donné beaucoup de mal pour introduire des barrières entre les parties de nos programmes afin qu'ils puissent être développés et compris séparément.

Nous aimerions introduire les opérations de type croisé d'une manière soigneusement contrôlée, afin que nous puissions les supporter sans violer sérieusement nos barrières d'abstraction. Il y a une forme de contradiction entre les résultats que nous désirons : nous aimerions pouvoir ajouter un nombre complexe à un nombre rationnel, et nous aimerions le faire en utilisant une fonction add générique capable de faire ce qu'il faut avec tous les types numériques. Dans le même temps, nous aimerions séparer les problèmes de nombres complexes et des problèmes de nombres rationnels chaque fois que c'est possible, afin de maintenir un programme modulaire.

Reprenons notre implémentation des nombres rationnels pour utiliser le système d'objet intégré de Python. Comme précédemment, nous allons stocker un nombre rationnel comme un numérateur et un dénominateur sous forme de fraction simplifiée.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
>>> from fractions import gcd
>>> class Rational(object):
        def __init__(self, numer, denom):
            g = gcd(numer, denom)
            self.numer = numer // g
            self.denom = denom // g
        def __repr__(self):
            return 'Rational({0}, {1})'.format(self.numer, self.denom)

Ajouter et multiplier les nombres rationnels dans cette nouvelle implémentation se fait comme précédemment.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
>>> def add_rational(x, y):
        nx, dx = x.numer, x.denom
        ny, dy = y.numer, y.denom
        return Rational(nx * dy + ny * dx, dx * dy)

>>> def mul_rational(x, y):
        return Rational(x.numer * y.numer, x.denom * y.denom)

Résolution de type. Une façon de gérer les opérations avec des types croisés est de concevoir une fonction différente pour chaque combinaison possible de types pour lesquels l'opération est valide. Par exemple, nous pourrions étendre notre implémentation de nombres complexes afin qu'elle fournisse une fonction permettant d'ajouter des nombres complexes à des nombres rationnels. Nous pouvons fournir cette fonctionnalité de manière générique en utilisant une technique appelée résolution de type (dispatching on type).

L'idée consiste à écrire des fonctions qui inspectent d'abord le type d'argument reçu, puis exécuter le code approprié pour le type. En Python, le type d'un objet peut être inspecté avec la fonction type intégrée.

 
Sélectionnez
>>> def iscomplex(z):
        return type(z) in (ComplexRI, ComplexMA)

>>> def isrational(z):
        return type(z) == Rational

Dans ce cas, nous nous appuyons sur le fait que chaque objet connaît son type, et nous pouvons rechercher ce type en utilisant la fonction de type Python. Même si la fonction type n'était pas disponible, nous pourrions imaginer implémenter iscomplex et isrational en termes d'un attribut de classe partagé pour Rational, ComplexRI et ComplexMA.

Considérons maintenant l'implémentation suivante de la fonction add, qui vérifie explicitement le type des deux arguments. Nous n'utiliserons pas les méthodes spéciales de Python (à savoir __add__) dans cet exemple.

 
Sélectionnez
>>> def add_complex_and_rational(z, r):
            return ComplexRI(z.real + r.numer/r.denom, z.imag)

>>> def add(z1, z2):
        """Add z1 and z2, which may be complex or rational."""
        if iscomplex(z1) and iscomplex(z2):
            return add_complex(z1, z2)
        elif iscomplex(z1) and isrational(z2):
            return add_complex_and_rational(z1, z2)
        elif isrational(z1) and iscomplex(z2):
            return add_complex_and_rational(z2, z1)
        else:
            return add_rational(z1, z2)

Cette approche simpliste de la résolution de type, qui utilise une grande instruction conditionnelle, n'est pas additive. Si un autre type numérique était inclus dans le programme, nous devions ré-implémenter add avec de nouvelles clauses.

Nous pouvons créer une implémentation plus flexible de la fonction add en implémentant la résolution de type via un dictionnaire. La première étape de l'extension de la flexibilité d'add sera de créer dans nos classes un ensemble d'étiquettes qui masque les deux implémentations distinctes des nombres complexes.

 
Sélectionnez
>>> def type_tag(x):
        return type_tag.tags[type(x)]

>>> type_tag.tags = {ComplexRI: 'com', ComplexMA: 'com', Rational: 'rat'}

Ensuite, nous utilisons ces étiquettes de type pour indexer un dictionnaire qui stocke les différentes façons d'ajouter des nombres. Les clés du dictionnaire sont des tuples d'étiquettes de type, et les valeurs sont des fonctions d'addition spécifiques au type.

 
Sélectionnez
>>> def add(z1, z2):
        types = (type_tag(z1), type_tag(z2))
        return add.implementations[types](z1, z2)

Cette définition d' add n'a aucune fonctionnalité en elle-même ; elle repose entièrement sur un dictionnaire appelé add.implementations pour implémenter l'addition. Nous pouvons alimenter ce dictionnaire comme suit.

 
Sélectionnez
>>> add.implementations = {}
>>> add.implementations[('com', 'com')] = add_complex
>>> add.implementations[('com', 'rat')] = add_complex_and_rational
>>> add.implementations[('rat', 'com')] = lambda x, y: add_complex_and_rational(y, x)
>>> add.implementations[('rat', 'rat')] = add_rational

Cette approche de la résolution de type basée sur un dictionnaire est additive, car add.implementations et type_tag.tags peuvent toujours être étendus. Tout nouveau type numérique peut « s'installer » dans le système existant en ajoutant de nouvelles entrées à ces dictionnaires.

Bien que nous ayons introduit une certaine complexité dans le système, nous avons maintenant une fonction add générique et extensible qui gère les types mixtes.

 
Sélectionnez
>>> add(ComplexRI(1.5, 0), Rational(3, 2))
ComplexRI(3.0, 0)
>>> add(Rational(5, 3), Rational(1, 2))
Rational(13, 6)

Programmation orientée données. Notre mise en œuvre d'add fondée sur un dictionnaire n'est pas du tout spécifique à l'addition ; elle ne contient directement aucune logique d'addition. Elle implémente l'addition seulement parce que nous avons alimenté son dictionnaire d'implementations avec des fonctions qui effectuent l'addition.

Une version plus générale de l'arithmétique générique appliquerait des opérateurs arbitraires à des types arbitraires et utiliserait un dictionnaire pour stocker les implémentations de diverses combinaisons. Cette approche entièrement générique de la mise en œuvre des méthodes est appelée programmation orientée données. Dans notre cas, nous pouvons implémenter à la fois l'addition générique et la multiplication sans logique redondante.

 
Sélectionnez
>>> def apply(operator_name, x, y):
        tags = (type_tag(x), type_tag(y))
        key = (operator_name, tags)
        return apply.implementations[key](x, y)

Dans cette fonction générique apply, une clé est construite à partir du nom de l'opérateur (par exemple, 'add') et un tuple d'étiquettes de type pour les arguments. Les implémentations sont également alimentées à l'aide de ces étiquettes. Cela nous permet de mettre en place la multiplication sur les nombres complexes et rationnels :

 
Sélectionnez
>>> def mul_complex_and_rational(z, r):
        return ComplexMA(z.magnitude * r.numer / r.denom, z.angle)

>>> mul_rational_and_complex = lambda r, z: mul_complex_and_rational(z, r)
>>> apply.implementations = {('mul', ('com', 'com')): mul_complex,
                             ('mul', ('com', 'rat')): mul_complex_and_rational,
                             ('mul', ('rat', 'com')): mul_rational_and_complex,
                             ('mul', ('rat', 'rat')): mul_rational}

Nous pouvons également ajouter à apply les implémentations complémentaires d'add, en utilisant la méthode update des dictionnaires :

 
Sélectionnez
>>> adders = add.implementations.items()
>>> apply.implementations.update({('add', tags):fn for (tags, fn) in adders})

Maintenant que la fonction apply prend en charge huit implémentations différentes dans une seule table, nous pouvons l'utiliser pour manipuler des nombres rationnels et complexes de manière assez générique.

 
Sélectionnez
>>> apply('add', ComplexRI(1.5, 0), Rational(3, 2))
ComplexRI(3.0, 0)
>>> apply('mul', Rational(1, 2), ComplexMA(10, 1))
ComplexMA(5.0, 1)

Cette approche orientée données gère la complexité des opérateurs de type croisé, mais elle est lourde. Avec un tel système, le coût de l'introduction d'un nouveau type n'est pas seulement celui de l'écriture des méthodes pour ce type, mais aussi la construction et l'installation des fonctions qui mettent en œuvre les opérations de type croisé. Cette charge peut facilement exiger beaucoup plus de code que ce qui est nécessaire pour définir les opérations sur le type lui-même.

Les techniques de résolution de type et de programmation orientée données créent des implémentations additives de fonctions génériques, mais elles ne séparent pas efficacement les problèmes de mise en œuvre ; les développeurs des types numériques individuels doivent prendre en compte d'autres types lors de l'écriture d'opérations de type croisé. La combinaison de nombres rationnels et de nombres complexes n'est pas strictement le domaine de l'un ou l'autre type. La formulation de politiques cohérentes sur la répartition des responsabilités entre les types peut être une tâche écrasante dans la conception de systèmes ayant de nombreux types et opérations de type croisé.

Coercition. Dans la situation générale d'opérations complètement indépendantes agissant sur des types complètement indépendants, la mise en œuvre d'opérations croisées explicites, aussi lourde soit-elle, est le mieux que l'on puisse espérer. Heureusement, nous pouvons parfois faire mieux en profitant de la structure supplémentaire qui peut être latente dans notre système de types. Souvent, les différents types de données ne sont pas complètement indépendants, et il peut y avoir des moyens par lesquels les objets d'un type peuvent être considérés comme étant d'un autre type. Ce processus s'appelle la coercition. Par exemple, si on nous demande de combiner arithmétiquement un nombre rationnel avec un nombre complexe, nous pouvons considérer le nombre rationnel comme un nombre complexe dont la partie imaginaire est nulle. Ce faisant, nous transformons le problème en celui de combiner deux nombres complexes, qui peut être traité de la manière ordinaire par add_complex et mul_complex.

En général, nous pouvons implémenter cette idée en concevant des fonctions de coercition qui transforment un objet d'un type en un objet équivalent d'un autre type. Voici une fonction de coercition qui transforme un nombre rationnel en un nombre complexe avec une partie imaginaire nulle :

 
Sélectionnez
>>> def rational_to_complex(x):
        return ComplexRI(x.numer/x.denom, 0)

Maintenant, nous pouvons définir un dictionnaire des fonctions de coercition. Ce dictionnaire pourrait être étendu à mesure que d'autres types numériques sont introduits.

 
Sélectionnez
>>> coercions = {('rat', 'com'): rational_to_complex}

Il n'est généralement pas possible de convertir un objet quelconque de chaque type dans chacun des autres types. Par exemple, il n'y a aucun moyen de convertir un nombre complexe quelconque en un nombre rationnel ; donc ce type de conversion ne figurera pas dans le dictionnaire coercions.

En utilisant le dictionnaire coercions, nous pouvons écrire une fonction appelée coerce_apply qui tente de convertir des arguments dans des valeurs du même type, et applique ensuite un opérateur. Le dictionnaire d'implémentations de coerce_apply n'inclut aucune implémentation d'opérateur de type croisé.

 
Sélectionnez
>>> def coerce_apply(operator_name, x, y):
        tx, ty = type_tag(x), type_tag(y)
        if tx != ty:
            if (tx, ty) in coercions:
                tx, x = ty, coercions[(tx, ty)](x)
            elif (ty, tx) in coercions:
                ty, y = tx, coercions[(ty, tx)](y)
            else:
                return 'No coercion possible.'
        key = (operator_name, tx)
        return coerce_apply.implementations[key](x, y)

Les implementations de coerce_apply ne nécessitent qu'une seule étiquette de type, car elles supposent que les deux valeurs partagent la même étiquette de type. Par conséquent, nous n'avons besoin que de quatre implémentations pour prendre en charge l'arithmétique générique sur les nombres complexes et rationnels.

 
Sélectionnez
>>> coerce_apply.implementations = {('mul', 'com'): mul_complex,
                                    ('mul', 'rat'): mul_rational,
                                    ('add', 'com'): add_complex,
                                    ('add', 'rat'): add_rational}

Avec ces implémentations en place, coerce_apply peut remplacer apply.

 
Sélectionnez
>>> coerce_apply('add', ComplexRI(1.5, 0), Rational(3, 2))
ComplexRI(3.0, 0)
>>> coerce_apply('mul', Rational(1, 2), ComplexMA(10, 1))
ComplexMA(5.0, 1.0)

Ce schéma de coercition présente certains avantages par rapport à la définition explicite des opérations de type croisé. Bien que nous ayons encore besoin d'écrire des fonctions de coercition pour relier les types, nous devons écrire une seule fonction pour chaque paire de types plutôt qu'une fonction différente pour chaque collection de types et chaque opération générique. Nous comptons ici sur le fait que la transformation appropriée entre les types dépend uniquement des types eux-mêmes, et non de l'opération particulière à appliquer.

D'autres avantages viennent de l'extension de la coercition. Certains schémas de coercition plus sophistiqués n'essaient pas seulement de convertir un type en un autre, mais plutôt d'essayer de convertir deux types différents en un troisième type commun. Considérons un losange et un rectangle : aucun des deux n'est un cas particulier de l'autre, mais les deux peuvent être considérés comme des quadrilatères. Une autre extension de la coercition est la coercition itérative, dans laquelle un type de données est forcé dans un autre via des types intermédiaires. Considérons qu'un entier peut être converti en un nombre réel en le convertissant d'abord en un nombre rationnel, puis en convertissant ce nombre rationnel en un nombre réel. L'enchaînement de contraintes de cette manière peut réduire le nombre total de fonctions de coercition requises par un programme.

Malgré ses avantages, la coercition présente des inconvénients potentiels. D'une part, les fonctions de coercition peuvent faire perdre des informations lorsqu'elles sont appliquées. Dans notre exemple, les nombres rationnels sont des représentations exactes, mais deviennent des approximations lorsqu'ils sont convertis en nombres complexes.

Certains langages de programmation ont des systèmes de coercition automatique intégrés. D'ailleurs, les premières versions de Python avaient une méthode spéciale __coerce__ sur les objets. En fin de compte, la complexité du système de coercition intégré ne justifiait pas son utilisation, et cette méthode spéciale a donc été supprimée. Au lieu de cela, les opérateurs particuliers appliquent la coercition à leurs arguments si nécessaire. Les opérateurs sont implémentés comme des appels de méthode sur les types définis par l'utilisateur en utilisant des méthodes spéciales comme __add__ et __mul__. C'est à vous, l'utilisateur, de décider si vous souhaitez utiliser la résolution de type, la programmation orientée données, le passage de message ou la coercition pour implémenter des fonctions génériques dans vos programmes.


précédentsommaire

Licence Creative Commons
Le contenu de cet article est rédigé par John DeNero et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Pas d'Utilisation Commerciale - Partage dans les Mêmes Conditions 3.0 non transposé.
Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright © 2018 Developpez.com.