44 votes

Héritage dynamique en Python : Comment choisir la classe de base lors de la création d'une instance ?

Introduction

J'ai rencontré un cas intéressant dans mon travail de programmation qui me demande d'implémenter un mécanisme d'héritage dynamique de classes en python. Ce que j'entends par "héritage dynamique" est une classe qui n'hérite pas d'une classe de base en particulier, mais qui choisit plutôt d'hériter d'une ou plusieurs classes de base à l'instanciation, en fonction d'un paramètre.

Ma question est donc la suivante : dans le cas que je vais présenter, quelle serait la meilleure façon, la plus standard et la plus "pythonique" d'implémenter la fonctionnalité supplémentaire nécessaire via l'héritage dynamique.

Pour résumer le cas de figure de manière simple, je vais donner un exemple en utilisant deux classes qui représentent deux formats d'image différents : 'jpg' y 'png' des images. J'essaierai ensuite d'ajouter la possibilité de prendre en charge un troisième format : le 'gz' image. Je me rends compte que ma question n'est pas si simple, mais j'espère que vous êtes prêts à me supporter pendant quelques lignes supplémentaires.

Le cas d'exemple des deux images

Ce script contient deux classes : ImageJPG y ImagePNG tous deux héritant de de la Image classe de base. Pour créer une instance d'un objet image, on demande à l'utilisateur d'appeler la fonction image_factory avec un chemin de fichier comme seul paramètre.

Cette fonction devine ensuite le format du fichier ( jpg o png ) du chemin et renvoie une instance de la classe correspondante.

Les deux classes d'images concrètes ( ImageJPG y ImagePNG ) sont capables de décoder via leur data propriété. Les deux le font d'une manière différente. Cependant, les deux demandent au Image pour un objet fichier afin de faire cela.

UML diagram 1

import os

#------------------------------------------------------------------------------#
def image_factory(path):
    '''Guesses the file format from the file extension
       and returns a corresponding image instance.'''
    format = os.path.splitext(path)[1][1:]
    if format == 'jpg': return ImageJPG(path)
    if format == 'png': return ImagePNG(path)
    else: raise Exception('The format "' + format + '" is not supported.')

#------------------------------------------------------------------------------#
class Image(object):
    '''Fake 1D image object consisting of twelve pixels.'''
    def __init__(self, path):
        self.path = path

    def get_pixel(self, x):
        assert x < 12
        return self.data[x]

    @property
    def file_obj(self): return open(self.path, 'r')

#------------------------------------------------------------------------------#
class ImageJPG(Image):
    '''Fake JPG image class that parses a file in a given way.'''

    @property
    def format(self): return 'Joint Photographic Experts Group'

    @property
    def data(self):
        with self.file_obj as f:
            f.seek(-50)
            return f.read(12)

#------------------------------------------------------------------------------#
class ImagePNG(Image):
    '''Fake PNG image class that parses a file in a different way.'''

    @property
    def format(self): return 'Portable Network Graphics'

    @property
    def data(self):
        with self.file_obj as f:
            f.seek(10)
            return f.read(12)

################################################################################
i = image_factory('images/lena.png')
print i.format
print i.get_pixel(5)

Le cas de l'exemple d'image compressée

En se basant sur l'exemple de la première image, on voudrait ajouter la fonctionnalité suivante :

Un format de fichier supplémentaire doit être pris en charge, le gz format. Au lieu de un nouveau format de fichier image, il s'agit simplement d'une couche de compression qui, une fois décompressée, révèle soit un jpg ou une png image.

El image_factory conserve son mécanisme de fonctionnement et simplement essayer de créer une instance de la classe d'image concrète ImageZIP lorsqu'il reçoit un gz fichier. Exactement de la même manière qu'il créer une instance de ImageJPG lorsqu'on lui donne un jpg fichier.

El ImageZIP veut simplement redéfinir la classe file_obj propriété. En aucun cas, il ne veut redéfinir la propriété data propriété. Le nœud du problème du problème est que, en fonction du format de fichier qui se cache dans l'archive zip, la propriété ImageZIP doit hériter de soit de ImageJPG ou de ImagePNG de façon dynamique. La classe dont il faut hériter ne peut être déterminée qu'au moment de la création de la classe, lorsque l'élément path est analysé.

Ainsi, voici le même script avec le supplément ImageZIP classe et une seule ligne ajoutée à la image_factory fonction.

De toute évidence, le ImageZIP est non fonctionnelle dans cet exemple. Ce code nécessite Python 2.7.

UML diagram 2

import os, gzip

#------------------------------------------------------------------------------#
def image_factory(path):
    '''Guesses the file format from the file extension
       and returns a corresponding image instance.'''
    format = os.path.splitext(path)[1][1:]
    if format == 'jpg': return ImageJPG(path)
    if format == 'png': return ImagePNG(path)
    if format == 'gz':  return ImageZIP(path)
    else: raise Exception('The format "' + format + '" is not supported.')

#------------------------------------------------------------------------------#
class Image(object):
    '''Fake 1D image object consisting of twelve pixels.'''
    def __init__(self, path):
        self.path = path

    def get_pixel(self, x):
        assert x < 12
        return self.data[x]

    @property
    def file_obj(self): return open(self.path, 'r')

#------------------------------------------------------------------------------#
class ImageJPG(Image):
    '''Fake JPG image class that parses a file in a given way.'''

    @property
    def format(self): return 'Joint Photographic Experts Group'

    @property
    def data(self):
        with self.file_obj as f:
            f.seek(-50)
            return f.read(12)

#------------------------------------------------------------------------------#
class ImagePNG(Image):
    '''Fake PNG image class that parses a file in a different way.'''

    @property
    def format(self): return 'Portable Network Graphics'

    @property
    def data(self):
        with self.file_obj as f:
            f.seek(10)
            return f.read(12)

#------------------------------------------------------------------------------#
class ImageZIP(### ImageJPG OR ImagePNG ? ###):
    '''Class representing a compressed file. Sometimes inherits from
       ImageJPG and at other times inherits from ImagePNG'''

    @property
    def format(self): return 'Compressed ' + super(ImageZIP, self).format

    @property
    def file_obj(self): return gzip.open(self.path, 'r')

################################################################################
i = image_factory('images/lena.png.gz')
print i.format
print i.get_pixel(5)

Une solution possible

J'ai trouvé un moyen d'obtenir le comportement désiré en interceptant l'appel à l'aide. __new__ dans l'appel ImageZIP et en utilisant la classe type fonction. Mais cela semble maladroit et je soupçonne qu'il pourrait y avoir une meilleure façon de procéder en utilisant des techniques Python ou des modèles de conception que je ne connais pas encore.

import re

class ImageZIP(object):
    '''Class representing a compressed file. Sometimes inherits from
       ImageJPG and at other times inherits from ImagePNG'''

    def __new__(cls, path):
        if cls is ImageZIP:
            format = re.findall('(...)\.gz', path)[-1]
            if format == 'jpg': return type("CompressedJPG", (ImageZIP,ImageJPG), {})(path)
            if format == 'png': return type("CompressedPNG", (ImageZIP,ImagePNG), {})(path)
        else:
            return object.__new__(cls)

    @property
    def format(self): return 'Compressed ' + super(ImageZIP, self).format

    @property
    def file_obj(self): return gzip.open(self.path, 'r')

Conclusion

Gardez à l'esprit, si vous souhaitez proposer une solution, que l'objectif n'est pas de modifier le comportement de l'utilisateur. image_factory fonction. Cette fonction doit rester intacte. L'objectif, dans l'idéal, est de construire une fonction dynamique ImageZIP classe.

Je ne sais pas vraiment quelle est la meilleure façon de le faire. Mais c'est une occasion parfaite pour moi d'en apprendre davantage sur la "magie noire" de Python. Peut-être que ma réponse se trouve dans des stratégies comme la modification de la fonction self.__cls__ après la création ou peut-être en utilisant l'attribut __metaclass__ attribut de classe ? Ou peut-être quelque chose à voir avec l'attribut spécial abc Les classes de base abstraites pourraient aider ici ? Ou d'autres territoires Python inexplorés ?

6 votes

Je pense que vous imposez une contrainte artificielle selon laquelle il doit s'agir d'une classe héritant de vos types existants. Je pense qu'une fonction de fabrique ou une classe encapsulant un de vos types est plus pythonique. D'ailleurs, je pense qu'il serait encore mieux d'avoir une fonction générique Image avec des fonctions ou des méthodes de classe pour le chargement à partir de différents formats.

2 votes

Tout ce que @Thomas dit est vrai. Si vous avez besoin de ça, vous avez mal structuré votre héritage. Appelez le Image avec un argument "datatype" est le moyen le plus évident ; il en existe d'autres. Gardez également à l'esprit qu'au lieu de type(), vous pouvez simplement appeler la fonction __new__ des classes de base appropriées dans le bon ordre.

2 votes

Je ne comprends pas le problème non plus, vous pouvez faire votre exemple assez facilement avec ImagePNG, ImageJPG, CompressedFile classes et les coller ensemble avec l'héritage multiple ie class CompressedPNG(ImagePNG, CompressedFile) et écrire un simple image_from_path fonction.

20voto

Mike Steder Points 4309

Je préférerais ici la composition à l'héritage. Je pense que votre hiérarchie d'héritage actuelle semble erronée. Certaines choses, comme l'ouverture du fichier avec ou gzip, ont peu à voir avec le format d'image réel et peuvent être facilement gérées à un seul endroit, alors que vous voulez séparer les détails du travail avec un format spécifique de classes propres. Je pense qu'en utilisant la composition, vous pouvez déléguer les détails spécifiques à l'implémentation et avoir une simple classe d'image commune sans avoir besoin de métaclasses ou d'héritage multiple.

import gzip
import struct

class ImageFormat(object):
    def __init__(self, fileobj):
        self._fileobj = fileobj

    @property
    def name(self):
        raise NotImplementedError

    @property
    def magic_bytes(self):
        raise NotImplementedError

    @property
    def magic_bytes_format(self):
        raise NotImplementedError

    def check_format(self):
        peek = self._fileobj.read(len(self.magic_bytes_format))
        self._fileobj.seek(0)
        bytes = struct.unpack_from(self.magic_bytes_format, peek)
        if (bytes == self.magic_bytes):
            return True
        return False

    def get_pixel(self, n):
        # ...
        pass

class JpegFormat(ImageFormat):
    name = "JPEG"
    magic_bytes = (255, 216, 255, 224, 0, 16, 'J', 'F', 'I', 'F')
    magic_bytes_format = "BBBBBBcccc"

class PngFormat(ImageFormat):
    name = "PNG"
    magic_bytes = (137, 80, 78, 71, 13, 10, 26, 10)
    magic_bytes_format = "BBBBBBBB"

class Image(object):
    supported_formats = (JpegFormat, PngFormat)

    def __init__(self, path):
        self.path = path
        self._file = self._open()
        self._format = self._identify_format()

    @property
    def format(self):
        return self._format.name

    def get_pixel(self, n):
        return self._format.get_pixel(n)

    def _open(self):
        opener = open
        if self.path.endswith(".gz"):
            opener = gzip.open
        return opener(self.path, "rb")

    def _identify_format(self):
        for format in self.supported_formats:
            f = format(self._file)
            if f.check_format():
                return f
        else:
            raise ValueError("Unsupported file format!")

if __name__=="__main__":
    jpeg = Image("images/a.jpg")
    png = Image("images/b.png.gz")

Je n'ai testé cette méthode que sur quelques fichiers png et jpeg locaux, mais j'espère qu'elle illustre une autre façon d'aborder ce problème.

0 votes

+1 ! Peut-être que je ne suis pas assez intelligent pour gérer des schémas d'héritage "avancés", mais dans des cas comme celui-ci, je trouve toujours la composition plus facile à penser et à étendre/déboguer.

0 votes

Le schéma d'héritage est pour ainsi dire gravé dans le marbre, je ne peux donc plus composer de cette manière : l'idée d'ajouter le support de la compression n'est qu'une réflexion après coup. De plus, maintenant, pour chaque méthode f(x) que vous voulez ajouter à votre classe Image, vous devez rediriger f(x) : self._format.f(x)

1 votes

La surcharge des appels de fonction a un coût, mais selon la fréquence des appels au format sous-jacent, il existe des moyens d'atténuer ce coût. Je suis désolé d'apprendre que votre schéma d'héritage ne peut pas être modifié.

14voto

Niklas R Points 2269

Qu'en est-il de la définition de la ImageZIP au niveau des fonctions ?
Cela permettra à votre dynamic inheritance .

def image_factory(path):
    # ...

    if format == ".gz":
        image = unpack_gz(path)
        format = os.path.splitext(image)[1][1:]
        if format == "jpg":
            return MakeImageZip(ImageJPG, image)
        elif format == "png":
            return MakeImageZip(ImagePNG, image)
        else: raise Exception('The format "' + format + '" is not supported.')

def MakeImageZIP(base, path):
    '''`base` either ImageJPG or ImagePNG.'''

    class ImageZIP(base):

        # ...

    return  ImageZIP(path)

Modifier : Sans besoin de changer image_factory

def ImageZIP(path):

    path = unpack_gz(path)
    format = os.path.splitext(image)[1][1:]

    if format == "jpg": base = ImageJPG
    elif format == "png": base = ImagePNG
    else: raise_unsupported_format_error()

    class ImageZIP(base): # would it be better to use   ImageZip_.__name__ = "ImageZIP" ?
        # ...

    return ImageZIP(path)

0 votes

Je n'avais pas pensé à définir une classe à l'intérieur d'une fonction. C'est une bonne idée. Mais pour que cela fonctionne sans modifier la fonction image_factory, il faudrait que la nouvelle fonction s'appelle "ImageZIP".

0 votes

@xApple J'ai édité la réponse. Cela devrait être ce que vous recherchez.

0 votes

Oui, cela m'a aidé aujourd'hui. merci beaucoup.

5voto

Petr Viktorin Points 13687

Si jamais vous avez besoin de "magie noire", essayez d'abord de réfléchir à une solution qui n'en a pas besoin. Vous trouverez probablement quelque chose qui fonctionne mieux et qui donne un code plus clair.

Il serait peut-être préférable que les constructeurs de la classe d'image prennent un fichier fichier déjà ouvert au lieu d'un chemin. Ainsi, vous n'êtes pas limité aux fichiers sur le disque, mais vous pouvez utiliser des objets de type fichier à partir de urllib, gzip, et autres.

De plus, étant donné que vous pouvez distinguer le JPG du PNG en regardant le contenu du fichier, et que pour les fichiers gzip vous avez besoin de cette détection de toute façon, je recommande de ne pas regarder du tout l'extension du fichier.

class Image(object):
    def __init__(self, fileobj):
        self.fileobj = fileobj

def image_factory(path):
    return(image_from_file(open(path, 'rb')))

def image_from_file(fileobj):
    if looks_like_png(fileobj):
        return ImagePNG(fileobj)
    elif looks_like_jpg(fileobj):
        return ImageJPG(fileobj)
    elif looks_like_gzip(fileobj):
        return image_from_file(gzip.GzipFile(fileobj=fileobj))
    else:
        raise Exception('The format "' + format + '" is not supported.')

def looks_like_png(fileobj):
    fileobj.seek(0)
    return fileobj.read(4) == '\x89PNG' # or, better, use a library

# etc.

Pour la magie noire, allez sur Qu'est-ce qu'une métaclasse en Python ? mais réfléchissez-y à deux fois avant de l'utiliser, surtout au travail.

0 votes

Encore une fois, je pourrais changer tout le schéma d'héritage du projet. Mais à ce stade, c'est difficile. Le format et l'estimation de l'image ont été pris comme un bon exemple car ils nécessiteraient une fonction "image_factory". Je ne m'occupe pas vraiment des images. Je cherche simplement un moyen d'obtenir un héritage dynamique pour résoudre mon problème sans trop remanier ce qui est déjà en place.

1 votes

Vous avez décrit ce type de solution dans la partie "Une solution possible" de votre question. Comme vous l'avez dit, cette solution est maladroite et il existe une meilleure solution : la meilleure solution consiste à remanier le code pour qu'il soit plus logique. Si vous recherchez spécifiquement des classes dynamiques, vous ne pouvez pas obtenir beaucoup mieux que des appels de type() (sauf peut-être class à l'intérieur d'une fonction, mais ensuite, si vous voulez un nom de classe significatif, vous devez mettre __name__ après, donc ce n'est pas beaucoup plus agréable). Désolé, mais vous avez une solution qui fonctionne ; je ne peux pas vous aider davantage. Faites juste attention que vous ne pouvez pas sous-classer votre ImageZIP de manière significative.

3voto

Jordão Points 29221

Vous devez utiliser la composition dans ce cas, et non l'héritage. Jetez un coup d'œil à la modèle de décorateur . Le site ImageZIP doit décorer d'autres classes d'images avec la fonctionnalité souhaitée.

Avec les décorateurs, vous obtenez un comportement très dynamique en fonction de la composition que vous créez :

ImageZIP(ImageJPG(path))

C'est aussi plus flexible, vous pouvez avoir d'autres décorateurs :

ImageDecrypt(password, ImageZIP(ImageJPG(path)))

Chaque décorateur encapsule simplement la fonctionnalité qu'il ajoute et délègue à la classe composée si nécessaire.

0 votes

J'ai examiné les modèles de conception des décorateurs. Ils ne semblent pas s'appliquer ici car un décorateur doit avoir, comme un de ses attributs, une instance de la classe dont il hérite. De quoi mon décorateur hériterait-il ici ?

0 votes

Votre décorateur décore des images, il hérite donc de la base Image classe.

0 votes

Mais alors, si mon décorateur hérite d'Image au lieu d'ImageJPEG (ou d'ImagePNG d'ailleurs), la fonctionnalité spécifique au format est manquante, et "i.data" devient une propriété indéfinie ?

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X