Maison  >  Article  >  développement back-end  >  Types de somme en Python

Types de somme en Python

Mary-Kate Olsen
Mary-Kate Olsenoriginal
2024-10-19 06:18:02649parcourir

Sum Types in Python

Python est un langage charmant. Cependant, lorsque je travaille en Python, il me manque souvent la prise en charge intégrée des types de somme. Des langages comme Haskell et Rust rendent ce genre de chose si simple :

data Op = Add | Sub | Mul
  deriving (Show)

data Expr
  = Lit Integer
  | BinOp Op Expr Expr
  deriving (Show)

val :: Expr -> Integer
val (Lit val) = val
val (BinOp op lhs rhs) =
  let x = val lhs
      y = val rhs
   in apply op x y

apply :: Op -> Integer -> Integer -> Integer
apply Add x y = x + y
apply Sub x y = x - y
apply Mul x y = x * y

val (BinOp Add (BinOp Mul (Lit 2) (Lit 3)) (Lit 4))
-- => 10

Bien que Python ne supporte pas ce type de construction par défaut, nous verrons que des types comme Expr sont néanmoins possibles (et faciles) à exprimer. De plus, nous pouvons créer un décorateur qui gère tous les passe-partout désagréables pour nous. Le résultat n'est pas trop différent de l'exemple Haskell ci-dessus :

# The `enum` decorator adds methods for constructing and matching on the
# different variants:
@enum(add=(), sub=(), mul=())
class Op:
    def apply(self, x, y):
        return self.match(
            add=lambda: x + y,
            sub=lambda: x - y,
            mul=lambda: x * y,
        )


# Recursive sum types are also supported:
@enum(lit=(int,), bin_op=lambda: (Op, Expr, Expr))
class Expr:
    def val(self):
        return self.match(
            lit=lambda value: value,
            bin_op=lambda op, lhs, rhs: op.apply(lhs.val(), rhs.val()),
        )


Expr.bin_op(
    Op.add(),
    Expr.bin_op(Op.mul(), Expr.lit(2), Expr.lit(3)),
    Expr.lit(4)
).val()
# => 10

Représentation des types de somme

Nous représenterons les types de somme en utilisant une "union étiquetée". C'est facile à comprendre par exemple :

class Expr:
    def lit(value):
        e = Expr()
        e.tag = "lit"
        e.value = value
        return e

    def bin_op(op, lhs, rhs):
        e = Expr()
        e.tag = "bin_op"
        e.op = op
        e.lhs = lhs
        e.rhs = rhs
        return e

Chaque variante est une instance de la même classe (dans ce cas Expr). Chacun contient un "tag" indiquant de quelle variante il s'agit, ainsi que les données qui lui sont spécifiques.

La façon la plus simple d'utiliser une Expr est d'utiliser une chaîne if-else :

class Expr:
    # ...
    def val(self):
        if self.tag == "lit":
            return self.value
        elif self.tag == "bin_op":
            x = self.lhs.val()
            y = self.rhs.val()
            return self.op.apply(x, y)

Cependant, cela présente quelques inconvénients :

  • La même chaîne if-else est répétée partout où une Expr est utilisée.
  • Changer la valeur de la balise, par exemple de « allumé » à « littéral », interrompt code existant.
  • La consommation de types de somme nécessite de connaître les détails de mise en œuvre (c'est-à-dire la balise et les noms des champs utilisés par chaque variante).

Mise en œuvre du match

Nous pouvons éviter tous ces problèmes en exposant une seule méthode de correspondance publique utilisée pour consommer les types de somme :

class Expr:
    # ...
    def match(self, handlers):
        # ...

Mais nous devons d’abord rendre les différentes variantes un peu plus uniformes. Au lieu de stocker ses données dans différents champs, chaque variante les stockera désormais dans un tuple nommé data :

class Expr:
    def lit(value):
        e = Expr()
        e.tag = "lit"
        e.data = (value,)
        return e

    def bin_op(op, lhs, rhs):
        e = Expr()
        e.tag = "bin_op"
        e.data = (op, lhs, rhs)
        return e

Cela nous permet de mettre en œuvre match :

class Expr:
    # ...
    def match(self, **handlers):
        if self.tag in handlers:
            return handlers[self.tag](*self.data)
        else:
            raise RuntimeError(f"missing handler for {self.tag}")

D’un seul coup, nous avons résolu tous les problèmes mentionnés ci-dessus ! Comme autre exemple, et pour changer de décor, voici le type Option de Rust transcrit de cette façon :

class Option:
    def some(x):
        o = Option()
        o.tag = "some"
        o.data = (x,)
        return o

    def none():
        o = Option()
        o.tag = "none"
        o.data = ()
        return o

    def match(self, **handlers):
        if self.tag in handlers:
            return handlers[self.tag](*self.data)
        else:
            raise RuntimeError(f"missing handler for {self.tag}")

    def __repr__(self):
        return self.match(
            some=lambda x: f"Option.some({repr(x)})",
            none=lambda: "Option.none()",
        )

    def __eq__(self, other):
        if not isinstance(other, Option):
            return NotImplemented
        return self.tag == other.tag and self.data == other.data

    def map(self, fn):
        return self.match(
            some=lambda x: Option.some(fn(x)),
            none=lambda: Option.none()
        )

Option.some(2).map(lambda x: x**2)
# => Option.some(4)

Comme un petit avantage en termes de qualité de vie, nous pouvons prendre en charge un joker spécial ou un gestionnaire "catchall" en match, indiqué par un trait de soulignement (_) :

def match(self, **handlers):
    if self.tag in handlers:
        return handlers[self.tag](*self.data)
    elif "_" in handlers:
        return handlers["_"]()
    else:
        raise RuntimeError(f"missing handler for {self.tag}")

Cela nous permet d'utiliser des correspondances comme :

def map(self, fn):
    return self.match(
        some=lambda x: Option.some(fn(x)),
        _=lambda: Option.none(),
    )

Implémentation de l'énumération

Comme l'illustre la classe Option, une grande partie du code nécessaire pour créer des types de somme suit le même modèle :

class Foo:
    # For each variant:
    def my_variant(bar, quux):
        # Construct an instance of the class:
        f = Foo()
        # Give the instance a distinct tag:
        f.tag = "my_variant"
        # Save the values we received:
        f.data = (bar, quux)
        return f

    # This is always the same:
    def match(self, **handlers):
        if self.tag in handlers:
            return handlers[self.tag](*self.data)
        elif "_" in handlers:
            return handlers["_"]()
        else:
            raise RuntimeError(f"missing handler for {self.tag}")

Au lieu d'écrire ceci nous-mêmes, écrivons un décorateur pour générer ces méthodes basées sur une description des variantes.

def enum(**variants):
    pass

Quel genre de description ? Le plus simple serait de fournir une liste de noms de variantes, mais nous pouvons faire un peu mieux en fournissant également les types d'arguments que nous attendons. Nous utiliserions enum pour améliorer automatiquement notre classe Option comme ceci :

# Add two variants:
# - One named `some` that expects a single argument of any type.
# - One named `none` that expects no arguments.
@enum(some=(object,), none=())
class Option:
    pass

La structure de base de l'énumération ressemble à ceci :

data Op = Add | Sub | Mul
  deriving (Show)

data Expr
  = Lit Integer
  | BinOp Op Expr Expr
  deriving (Show)

val :: Expr -> Integer
val (Lit val) = val
val (BinOp op lhs rhs) =
  let x = val lhs
      y = val rhs
   in apply op x y

apply :: Op -> Integer -> Integer -> Integer
apply Add x y = x + y
apply Sub x y = x - y
apply Mul x y = x * y

val (BinOp Add (BinOp Mul (Lit 2) (Lit 3)) (Lit 4))
-- => 10

C'est une fonction qui renvoie une autre fonction, qui sera appelée avec la classe que nous améliorons comme seul argument. Dans l'amélioration, nous joindrons des méthodes pour construire chaque variante, ainsi que la correspondance.

Tout d'abord, faites correspondre, car ce ne sont que des copies de pâtes :

# The `enum` decorator adds methods for constructing and matching on the
# different variants:
@enum(add=(), sub=(), mul=())
class Op:
    def apply(self, x, y):
        return self.match(
            add=lambda: x + y,
            sub=lambda: x - y,
            mul=lambda: x * y,
        )


# Recursive sum types are also supported:
@enum(lit=(int,), bin_op=lambda: (Op, Expr, Expr))
class Expr:
    def val(self):
        return self.match(
            lit=lambda value: value,
            bin_op=lambda op, lhs, rhs: op.apply(lhs.val(), rhs.val()),
        )


Expr.bin_op(
    Op.add(),
    Expr.bin_op(Op.mul(), Expr.lit(2), Expr.lit(3)),
    Expr.lit(4)
).val()
# => 10

L'ajout de méthodes pour construire chaque variante n'est que légèrement plus complexe. Nous parcourons le dictionnaire des variantes, en définissant une méthode pour chaque entrée :

class Expr:
    def lit(value):
        e = Expr()
        e.tag = "lit"
        e.value = value
        return e

    def bin_op(op, lhs, rhs):
        e = Expr()
        e.tag = "bin_op"
        e.op = op
        e.lhs = lhs
        e.rhs = rhs
        return e

où make_constructor crée une fonction constructeur pour une variante avec une balise (et un nom) et une signature "type signature" :

class Expr:
    # ...
    def val(self):
        if self.tag == "lit":
            return self.value
        elif self.tag == "bin_op":
            x = self.lhs.val()
            y = self.rhs.val()
            return self.op.apply(x, y)

Voici la définition complète de l'énumération pour référence.

Fonctionnalités bonus

Plus de méthodes Dunder

Nous pouvons facilement améliorer nos classes de somme avec les méthodes __repr__ et __eq__ :

class Expr:
    # ...
    def match(self, handlers):
        # ...

Avec l'amélioration améliorée de cette façon, nous pouvons définir l'option avec un minimum de complexité :

class Expr:
    def lit(value):
        e = Expr()
        e.tag = "lit"
        e.data = (value,)
        return e

    def bin_op(op, lhs, rhs):
        e = Expr()
        e.tag = "bin_op"
        e.data = (op, lhs, rhs)
        return e

Définitions récursives

Malheureusement, enum n'est pas (encore) à la hauteur de la définition de Expr :

class Expr:
    # ...
    def match(self, **handlers):
        if self.tag in handlers:
            return handlers[self.tag](*self.data)
        else:
            raise RuntimeError(f"missing handler for {self.tag}")

Nous utilisons la classe Expr avant qu'elle soit définie. Une solution simple ici consiste simplement à appeler le décorateur après avoir défini la classe :

class Option:
    def some(x):
        o = Option()
        o.tag = "some"
        o.data = (x,)
        return o

    def none():
        o = Option()
        o.tag = "none"
        o.data = ()
        return o

    def match(self, **handlers):
        if self.tag in handlers:
            return handlers[self.tag](*self.data)
        else:
            raise RuntimeError(f"missing handler for {self.tag}")

    def __repr__(self):
        return self.match(
            some=lambda x: f"Option.some({repr(x)})",
            none=lambda: "Option.none()",
        )

    def __eq__(self, other):
        if not isinstance(other, Option):
            return NotImplemented
        return self.tag == other.tag and self.data == other.data

    def map(self, fn):
        return self.match(
            some=lambda x: Option.some(fn(x)),
            none=lambda: Option.none()
        )

Option.some(2).map(lambda x: x**2)
# => Option.some(4)

Mais il y a un changement simple que nous pouvons apporter pour prendre en charge cela : permettre à une "signature" d'être une fonction qui renvoie un tuple :

def match(self, **handlers):
    if self.tag in handlers:
        return handlers[self.tag](*self.data)
    elif "_" in handlers:
        return handlers["_"]()
    else:
        raise RuntimeError(f"missing handler for {self.tag}")

Tout cela nécessite un petit changement dans make_constructor :

def map(self, fn):
    return self.match(
        some=lambda x: Option.some(fn(x)),
        _=lambda: Option.none(),
    )

Conclusion

Aussi utile qu'il puisse être, notre nouveau décorateur d'énumérations sophistiqué n'est pas sans défauts. Le plus évident est l’incapacité d’effectuer tout type de correspondance de modèles « imbriqués ». Dans Rust, nous pouvons faire des choses comme ceci :

class Foo:
    # For each variant:
    def my_variant(bar, quux):
        # Construct an instance of the class:
        f = Foo()
        # Give the instance a distinct tag:
        f.tag = "my_variant"
        # Save the values we received:
        f.data = (bar, quux)
        return f

    # This is always the same:
    def match(self, **handlers):
        if self.tag in handlers:
            return handlers[self.tag](*self.data)
        elif "_" in handlers:
            return handlers["_"]()
        else:
            raise RuntimeError(f"missing handler for {self.tag}")

Mais on est obligé de réaliser un double match pour arriver au même résultat :

def enum(**variants):
    pass

Cela dit, ce genre de cas semble relativement rare.

Un autre inconvénient est que la correspondance nécessite de construire et d'appeler de nombreuses fonctions. Cela signifie qu'elle est probablement beaucoup plus lente que la chaîne if-else équivalente. Cependant, la règle générale habituelle s'applique ici : utilisez enum si vous aimez ses avantages ergonomiques, et remplacez-le par son code "généré" s'il est trop lent.

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn