Heim  >  Artikel  >  Backend-Entwicklung  >  Summentypen in Python

Summentypen in Python

Mary-Kate Olsen
Mary-Kate OlsenOriginal
2024-10-19 06:18:02649Durchsuche

Sum Types in Python

Python ist eine schöne Sprache. Wenn ich jedoch in Python arbeite, vermisse ich häufig die integrierte Unterstützung für Summentypen. Sprachen wie Haskell und Rust machen so etwas so einfach:

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

Obwohl Python diese Art der Konstruktion nicht standardmäßig unterstützt, werden wir sehen, dass Typen wie Expr dennoch möglich (und einfach) auszudrücken sind. Darüber hinaus können wir einen Dekorateur erstellen, der die ganze fiese Standardarbeit für uns erledigt. Das Ergebnis unterscheidet sich nicht allzu sehr vom Haskell-Beispiel oben:

# 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

Darstellung von Summentypen

Wir stellen Summentypen mithilfe einer „getaggten Union“ dar. Das lässt sich leicht anhand eines Beispiels erkennen:

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

Jede Variante ist eine Instanz derselben Klasse (in diesem Fall Expr). Jede enthält ein „Tag“, das angibt, um welche Variante es sich handelt, zusammen mit den spezifischen Daten dafür.

Die einfachste Art, einen Ausdruck zu verwenden, ist mit einer if-else-Kette:

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)

Dies hat jedoch einige Nachteile:

  • Die gleiche if-else-Kette wird überall dort wiederholt, wo ein Ausdruck verwendet wird.
  • Das Ändern des Tag-Werts – beispielsweise von „lit“ in „literal“ – führt zu Problemen Vorhandener Code.
  • Um Summentypen zu konsumieren, müssen Sie die Implementierungsdetails kennen (d. h. das Tag und die Namen der Felder, die von jeder Variante verwendet werden).

Übereinstimmung umsetzen

Wir können all diese Probleme vermeiden, indem wir eine einzige öffentliche Match-Methode bereitstellen, die zum Konsumieren von Summentypen verwendet wird:

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

Aber zunächst müssen wir die verschiedenen Varianten noch etwas einheitlicher gestalten. Anstatt ihre Daten in verschiedenen Feldern zu speichern, speichert jede Variante sie jetzt in einem Tupel mit dem Namen 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

Dadurch können wir match:
implementieren

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}")

Auf einen Schlag haben wir alle oben genannten Probleme gelöst! Als weiteres Beispiel und zur Abwechslung ist hier der Option-Typ von Rust auf diese Weise transkribiert:

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)

Als kleinen Vorteil für die Lebensqualität können wir einen speziellen Wildcard- oder „Catchall“-Handler im Match unterstützen, der durch einen Unterstrich (_) gekennzeichnet ist:

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}")

Dies ermöglicht uns die Verwendung von Übereinstimmungen wie:

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

Enumeration implementieren

Wie die Option-Klasse zeigt, folgt ein Großteil des Codes, der zum Erstellen von Summentypen benötigt wird, demselben Muster:

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}")

Anstatt dies selbst zu schreiben, schreiben wir einen Dekorateur, der diese Methoden basierend auf einer Beschreibung der Varianten generiert.

def enum(**variants):
    pass

Was für eine Beschreibung? Am einfachsten wäre es, eine Liste mit Variantennamen bereitzustellen, aber wir können es noch etwas besser machen, indem wir auch die Arten von Argumenten angeben, die wir erwarten. Wir würden enum verwenden, um unsere Option-Klasse automatisch wie folgt zu erweitern:

# 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

Die Grundstruktur von enum sieht folgendermaßen aus:

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

Es ist eine Funktion, die eine andere Funktion zurückgibt, die mit der Klasse, die wir erweitern, als einziges Argument aufgerufen wird. Innerhalb von „Enhance“ fügen wir Methoden zum Erstellen jeder Variante sowie Match hinzu.

Erstmals zusammenpassen, denn es handelt sich nur um eine Kopie der Pasta:

# 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

Das Hinzufügen von Methoden zum Erstellen jeder Variante ist nur geringfügig aufwändiger. Wir durchlaufen das Variantenwörterbuch und definieren eine Methode für jeden Eintrag:

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

wobei make_constructor eine Konstruktorfunktion für eine Variante mit Tag (und Name)-Tag und „Typsignatur“-Sig erstellt:

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)

Hier ist die vollständige Definition von enum als Referenz.

Bonusfunktionen

Weitere Dunder-Methoden

Wir können unsere Summenklassen ganz einfach mit den Methoden __repr__ und __eq__ erweitern:

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

Mit der auf diese Weise verbesserten Verbesserung können wir Option mit minimalem Aufwand definieren:

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

Rekursive Definitionen

Leider ist enum der Aufgabe, Expr: zu definieren, (noch) nicht gewachsen

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}")

Wir verwenden die Klasse Expr bevor sie definiert wurde. Eine einfache Lösung besteht darin, nach der Definition der Klasse einfach den Dekorator aufzurufen:

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)

Aber wir können eine einfache Änderung vornehmen, um dies zu unterstützen: Erlauben Sie, dass eine „Signatur“ eine Funktion ist, die ein Tupel zurückgibt:

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}")

Alles was dazu erforderlich ist, ist eine kleine Änderung in make_constructor:

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

Abschluss

So nützlich er auch sein mag, unser schicker neuer Enum-Dekorator ist nicht ohne Mängel. Am offensichtlichsten ist die Unfähigkeit, irgendeine Art von „verschachteltem“ Mustervergleich durchzuführen. In Rust können wir Dinge wie diese tun:

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}")

Aber wir sind gezwungen, ein Doppelspiel durchzuführen, um das gleiche Ergebnis zu erzielen:

def enum(**variants):
    pass

Allerdings scheinen solche Fälle relativ selten zu sein.

Ein weiterer Nachteil besteht darin, dass für die Übereinstimmung viele Funktionen erstellt und aufgerufen werden müssen. Das bedeutet, dass sie wahrscheinlich viel langsamer ist als die entsprechende if-else-Kette. Hier gilt jedoch die übliche Faustregel: Verwenden Sie Enum, wenn Ihnen die ergonomischen Vorteile gefallen, und ersetzen Sie es durch den „generierten“ Code, wenn es zu langsam ist.

Das obige ist der detaillierte Inhalt vonSummentypen in Python. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!

Stellungnahme:
Der Inhalt dieses Artikels wird freiwillig von Internetnutzern beigesteuert und das Urheberrecht liegt beim ursprünglichen Autor. Diese Website übernimmt keine entsprechende rechtliche Verantwortung. Wenn Sie Inhalte finden, bei denen der Verdacht eines Plagiats oder einer Rechtsverletzung besteht, wenden Sie sich bitte an admin@php.cn