Wie gehen Sprachen mit Vielleicht-Typen anstelle von Nullen mit Randbedingungen um?


53

Eric Lippert hat in seiner Diskussion, warum C # nulleher einen als einen Maybe<T>Typ verwendet, einen sehr interessanten Punkt angesprochen :

Die Konsistenz des Typsystems ist wichtig; Können wir immer wissen, dass ein nicht nullwertfähiger Verweis unter keinen Umständen als ungültig angesehen wird? Was ist mit dem Konstruktor eines Objekts mit einem nicht nullwertfähigen Referenzfeldtyp? Was ist mit dem Finalizer eines solchen Objekts, bei dem das Objekt finalisiert wird, weil der Code, der die Referenz ausfüllen sollte, eine Ausnahme ausgelöst hat? Ein Typensystem, das Sie über seine Garantien belügt, ist gefährlich.

Das hat mir die Augen geöffnet. Die Konzepte interessieren mich, und ich habe ein bisschen mit Compilern und Typsystemen herumgespielt, aber ich habe nie über dieses Szenario nachgedacht. Wie behandeln Sprachen mit dem Typ "Vielleicht" anstelle eines Nullwerts Randfälle wie Initialisierung und Fehlerbehebung, bei denen ein angeblich garantierter Nicht-Nullwert-Verweis tatsächlich nicht gültig ist?


Ich denke, wenn das Vielleicht Teil der Sprache ist, könnte es sein, dass es intern über einen Nullzeiger implementiert ist und es nur syntaktischer Zucker ist. Aber ich glaube nicht, dass eine Sprache das wirklich so macht.
Panzi

1
@panzi: Ceylon verwendet die flussempfindliche Eingabe, um zwischen Type?(vielleicht) und Type(nicht null) zu unterscheiden
Lukas Eder

1
@RobertHarvey Gibt es nicht schon eine "nette Frage" -Schaltfläche in Stack Exchange?
user253751

2
@panzi Das ist eine nette und gültige Optimierung, aber es hilft nicht bei diesem Problem: Wenn etwas nicht ist Maybe T, muss es nicht sein Noneund daher können Sie seinen Speicher nicht auf den Nullzeiger initialisieren.

@immibis: Ich habe es schon geschoben. Wir bekommen hier einige gute Fragen. Ich dachte, das hätte einen Kommentar verdient.
Robert Harvey

Antworten:


45

Dieses Zitat weist auf ein Problem hin, das auftritt, wenn die Deklaration und Zuweisung von Bezeichnern (hier: Instanzmitglieder) voneinander getrennt sind. Als kurze Pseudocode-Skizze:

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

Das Szenario sieht jetzt so aus, dass während der Erstellung einer Instanz ein Fehler ausgegeben wird, sodass die Erstellung abgebrochen wird, bevor die Instanz vollständig erstellt wurde. Diese Sprache bietet eine Destruktormethode, die ausgeführt wird, bevor die Zuordnung des Speichers aufgehoben wird, um z. B. Nicht-Speicherressourcen manuell freizugeben. Es muss auch für teilweise erstellte Objekte ausgeführt werden, da manuell verwaltete Ressourcen möglicherweise bereits zugewiesen wurden, bevor die Erstellung abgebrochen wurde.

Mit Nullen konnte der Destruktor testen, ob eine Variable wie folgt zugewiesen wurde if (foo != null) foo.cleanup(). Ohne Nullen befindet sich das Objekt jetzt in einem undefinierten Zustand - wie hoch ist der Wert von bar?

Dieses Problem besteht jedoch aufgrund der Kombination von drei Aspekten:

  • Das Fehlen von Standardwerten wie nulloder die garantierte Initialisierung für die Mitgliedsvariablen.
  • Der Unterschied zwischen Deklaration und Zuordnung. Das Erzwingen der sofortigen Zuweisung von Variablen (z. B. mit einer letAnweisung, wie sie in funktionalen Sprachen angezeigt wird) ist eine einfache Aufgabe, die garantierte Initialisierung zu erzwingen - schränkt die Sprache jedoch auf andere Weise ein.
  • Die spezifische Variante von Destruktoren als Methode, die von der Laufzeitsprache aufgerufen wird.

Es ist einfach, ein anderes Design zu wählen, das diese Probleme nicht aufweist, indem beispielsweise immer eine Deklaration mit einer Zuweisung kombiniert wird und die Sprache mehrere Finalizer-Blöcke anstelle einer einzelnen Finalisierungsmethode bietet:

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

Es gibt also kein Problem mit dem Fehlen von Null, sondern mit der Kombination einer Reihe anderer Merkmale mit dem Fehlen von Null.

Die interessante Frage ist nun, warum C # ein Design gewählt hat, das andere jedoch nicht. Hier werden im Kontext des Zitats viele weitere Argumente für eine Null in der C # -Sprache aufgeführt, die meistens als „Vertrautheit und Kompatibilität“ zusammengefasst werden können - und das sind gute Gründe.


Es gibt noch einen weiteren Grund, warum sich der Finalizer mit nulls befassen muss : Die Reihenfolge der Finalisierung ist aufgrund der Möglichkeit von Referenzzyklen nicht garantiert. Aber ich denke, Ihr FINALIZEDesign löst auch das Problem: Wenn fooes bereits fertiggestellt wurde, wird sein FINALIZEAbschnitt einfach nicht ausgeführt.
SVICK

14

Auf die gleiche Weise garantieren Sie, dass alle anderen Daten in einem gültigen Zustand sind.

Man kann die Semantik strukturieren und den Ablauf so steuern, dass man keine Variablen / Felder haben kann, ohne einen Wert dafür zu erstellen. Anstatt ein Objekt zu erstellen und es einem Konstruktor zu ermöglichen, seinen Feldern "Anfangswerte" zuzuweisen, können Sie ein Objekt nur erstellen, indem Sie Werte für alle Felder gleichzeitig angeben. Anstatt eine Variable zu deklarieren und anschließend einen Anfangswert zuzuweisen, können Sie eine Variable nur mit einer Initialisierung einführen.

In Rust erstellen Sie beispielsweise ein Objekt vom Typ struct über, Point { x: 1, y: 2 }anstatt einen Konstruktor zu schreiben, der dies tut self.x = 1; self.y = 2;. Dies kann natürlich mit dem von Ihnen gewünschten Sprachstil in Konflikt geraten.

Ein weiterer, komplementärer Ansatz besteht darin, die Verfügbarkeitsanalyse zu verwenden, um den Zugriff auf den Speicher vor seiner Initialisierung zu verhindern. Auf diese Weise können Sie eine Variable deklarieren, ohne sie sofort zu initialisieren, sofern dies vor dem ersten Lesevorgang nachweislich der Fall ist. Es können auch einige störungsbedingte Fälle wie abgefangen werden

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

Technisch gesehen können Sie auch eine beliebige Standardinitialisierung für Objekte definieren, z. B. alle numerischen Felder auf Null setzen, leere Arrays für Arrayfelder erstellen usw. Dies ist jedoch eher willkürlich, weniger effizient als andere Optionen und kann Fehler maskieren.


7

Das macht Haskell folgendermaßen: (Dies ist kein Widerspruch zu Lipperts Aussagen, da Haskell keine objektorientierte Sprache ist.)

WARNUNG: Langatmige Antwort von einem ernsthaften Haskell-Fan.

TL; DR

Dieses Beispiel zeigt genau, wie unterschiedlich sich Haskell von C # unterscheidet. Anstatt die Logistik des Tragwerksbaus an einen Konstrukteur zu delegieren, muss diese im umgebenden Code abgewickelt werden. Es gibt keine Möglichkeit, dass ein Nullwert (oder Nothingin Haskell) auftritt, wenn ein Nicht-Nullwert erwartet wird, da Nullwerte nur in speziellen aufgerufenen Wrapper-Typen auftreten können, Maybedie nicht mit / direkt in reguläre, nicht austauschbare Werte konvertierbar sind. nullbare Typen. Um einen Wert zu verwenden, der durch Umschließen mit einem nullwertfähig gemacht wurde Maybe, müssen wir zuerst den Wert mithilfe des Mustervergleichs extrahieren. Dadurch werden wir gezwungen, den Kontrollfluss in einen Zweig umzuleiten, von dem wir sicher wissen, dass er nicht null ist.

Deshalb:

Können wir immer wissen, dass ein nicht nullwertfähiger Verweis unter keinen Umständen als ungültig angesehen wird?

Ja. Intund Maybe Intsind zwei völlig getrennte Typen. Das Finden Nothingin einer Ebene Intwäre vergleichbar mit dem Finden der Zeichenfolge "Fisch" in einer Ebene Int32.

Was ist mit dem Konstruktor eines Objekts mit einem nicht nullwertfähigen Referenzfeldtyp?

Kein Problem: Wertekonstruktoren in Haskell können nichts anderes tun, als die angegebenen Werte zu nehmen und sie zusammenzufügen. Die gesamte Initialisierungslogik findet statt, bevor der Konstruktor aufgerufen wird.

Was ist mit dem Finalizer eines solchen Objekts, bei dem das Objekt finalisiert wird, weil der Code, der die Referenz ausfüllen sollte, eine Ausnahme ausgelöst hat?

Es gibt keine Finalizer in Haskell, daher kann ich das nicht wirklich ansprechen. Meine erste Antwort steht jedoch noch.

Vollständige Antwort :

Haskell hat keine Null und verwendet den MaybeDatentyp, um Nullables darzustellen. Vielleicht ist ein algabraischer Datentyp wie folgt definiert:

data Maybe a = Just a | Nothing

Wenn Sie mit Haskell nicht vertraut sind, lesen Sie dies als "A Maybeist entweder ein Nothingoder ein Just a". Speziell:

  • Maybeist der Typkonstruktor : Er kann (fälschlicherweise) als generische Klasse betrachtet werden (wobei adie Typvariable ist). Die C # Analogie ist class Maybe<a>{}.
  • Justist ein Wertekonstruktor : Es ist eine Funktion, die ein Argument vom Typ aannimmt und einen Wert vom Typ zurückgibt Maybe a, der den Wert enthält. Der Code x = Just 17ist also analog zu int? x = 17;.
  • Nothingist ein anderer Wertekonstruktor, der jedoch keine Argumente akzeptiert und für den Maybekein anderer Wert als "Nothing" zurückgegeben wird. x = Nothingist analog zu int? x = null;(unter der Annahme, dass wir unser ain Haskell beschränkt haben Int, was durch Schreiben geschehen kann x = Nothing :: Maybe Int).

MaybeWie vermeidet Haskell die in der Frage des OP erörterten Probleme, nachdem die Grundlagen des Typs nicht mehr vorhanden sind?

Nun, Haskell unterscheidet sich wirklich von den meisten bisher diskutierten Sprachen. Ich beginne mit der Erläuterung einiger grundlegender Sprachprinzipien.

Zunächst einmal ist in Haskell alles unveränderlich . Alles. Namen beziehen sich auf Werte, nicht auf Speicherorte, an denen Werte gespeichert werden können (dies allein ist eine enorme Quelle für die Beseitigung von Fehlern). Anders als in C #, wobei variable Deklaration und Zuordnung sind zwei getrennte Vorgänge, in Haskell Werte durch Definition deren Wert erzeugt werden ( zum Beispiel x = 15, y = "quux", z = Nothing), die sich nie ändern kann. Daher Code wie:

ReferenceType x;

Ist in Haskell nicht möglich. Es gibt keine Probleme beim Initialisieren von Werten für, nullda alles explizit auf einen Wert initialisiert werden muss, damit es existiert.

Zweitens ist Haskell keine objektorientierte Sprache : Es ist eine rein funktionale Sprache, es gibt also keine Objekte im eigentlichen Sinne des Wortes. Stattdessen gibt es einfach Funktionen (Wertekonstruktoren), die ihre Argumente verwenden und eine verschmolzene Struktur zurückgeben.

Als nächstes gibt es absolut keinen zwingenden Stilcode. Damit meine ich, dass die meisten Sprachen einem ähnlichen Muster folgen:

do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
    do thing 6
return thing 7

Das Programmverhalten wird als eine Reihe von Anweisungen ausgedrückt. In objektorientierten Sprachen spielen auch Klassen- und Funktionsdeklarationen eine große Rolle im Programmfluss, aber das "Fleisch" der Programmausführung besteht im Wesentlichen aus einer Reihe auszuführender Anweisungen.

In Haskell ist dies nicht möglich. Stattdessen wird der Programmfluss ausschließlich durch Verkettungsfunktionen bestimmt. Sogar die imperativ aussehende doAnmerkung ist nur syntaktischer Zucker, um anonyme Funktionen an den >>=Bediener weiterzugeben . Alle Funktionen haben folgende Form:

<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression

Wo body-expressionkann alles sein, was zu einem Wert ausgewertet wird. Offensichtlich stehen mehr Syntaxfunktionen zur Verfügung, aber der Hauptaspekt ist das völlige Fehlen von Anweisungsfolgen.

Schließlich und wahrscheinlich am wichtigsten ist Haskells Typensystem unglaublich streng. Wenn ich die zentrale Designphilosophie von Haskells Typensystem zusammenfassen müsste, würde ich sagen: "Machen Sie zur Kompilierungszeit so viele Fehler wie möglich, damit zur Laufzeit so wenig wie möglich schief geht." Es gibt keinerlei implizite Konvertierungen (Sie möchten eine Intin eine Double? Verwenden Sie die fromIntegralFunktion). Das einzige, was möglicherweise zur Laufzeit zu einem ungültigen Wert führt, ist die Verwendung Prelude.undefined(die anscheinend nur vorhanden sein muss und unmöglich zu entfernen ist ).

Schauen wir uns in diesem Sinne das "kaputte" Beispiel von amon an und versuchen, diesen Code in Haskell erneut auszudrücken. Zuerst die Datendeklaration (unter Verwendung der Datensatzsyntax für benannte Felder):

data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar } 

( foound barsind hier wirklich Accessorfunktionen für anonyme Felder anstelle von tatsächlichen Feldern, aber wir können dieses Detail ignorieren).

Der NotSoBrokenWertekonstruktor ist nicht in der Lage, andere Aktionen als a Foound a Bar(die nicht nullwertfähig sind) NotSoBrokenauszuführen und daraus ein zu machen. Es gibt keinen Platz, um Imperativcode einzufügen oder die Felder auch nur manuell zuzuweisen. Die gesamte Initialisierungslogik muss an einer anderen Stelle stattfinden, höchstwahrscheinlich in einer dedizierten Factory-Funktion.

Im Beispiel Brokenschlägt die Konstruktion von immer fehl. Es gibt keine Möglichkeit, den NotSoBrokenWertekonstruktor auf ähnliche Weise zu unterbrechen (es gibt einfach keinen Ort, an dem der Code geschrieben werden kann), aber wir können eine Factory-Funktion erstellen, die ähnlich fehlerhaft ist.

makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing

(Die erste Zeile ist eine Typensignaturdeklaration: makeNotSoBrokenNimmt ein Foound ein Barals Argument und erzeugt ein Maybe NotSoBroken).

Der Rückgabetyp muss Maybe NotSoBrokenund nicht einfach sein, NotSoBrokenweil wir ihm gesagt haben, dass er ausgewertet werden Nothingsoll. Dies ist ein Wertekonstruktor für Maybe. Die Typen würden einfach nicht in einer Reihe stehen, wenn wir etwas anderes schreiben würden.

Abgesehen davon, dass diese Funktion absolut sinnlos ist, erfüllt sie nicht einmal ihren eigentlichen Zweck, wie wir sehen werden, wenn wir versuchen, sie zu verwenden. Erstellen wir eine Funktion, useNotSoBrokendie a NotSoBrokenals Argument erwartet :

useNotSoBroken :: NotSoBroken -> Whatever

( useNotSoBrokenakzeptiert a NotSoBrokenals Argument und erzeugt a Whatever).

Und benutze es so:

useNotSoBroken (makeNotSoBroken)

In den meisten Sprachen kann dieses Verhalten eine Nullzeigerausnahme verursachen. In Haskell stimmen die Typen nicht überein: makeNotSoBrokenGibt a zurück Maybe NotSoBroken, useNotSoBrokenerwartet jedoch a NotSoBroken. Diese Typen sind nicht austauschbar und der Code kann nicht kompiliert werden.

Um dies zu umgehen, können wir eine caseAnweisung verwenden, um basierend auf der Struktur des MaybeWerts zu verzweigen (mithilfe eines Features namens Mustervergleich ):

case makeNotSoBroken of
    Nothing  -> --handle situation here
    (Just x) -> useNotSoBroken x

Natürlich muss dieses Snippet in einen bestimmten Kontext gestellt werden, um tatsächlich kompiliert zu werden, aber es demonstriert die Grundlagen, wie Haskell mit NULL-Werten umgeht. Hier finden Sie eine schrittweise Erklärung des obigen Codes:

  • Zunächst makeNotSoBrokenwird ausgewertet, was garantiert einen Wert vom Typ ergibt Maybe NotSoBroken.
  • Die caseAnweisung überprüft die Struktur dieses Werts.
  • Wenn der Wert ist Nothing, wird der Code "Situation hier behandeln" ausgewertet.
  • Wenn der Wert stattdessen mit einem JustWert übereinstimmt, wird der andere Zweig ausgeführt. Beachten Sie, wie die Übereinstimmungsklausel den Wert gleichzeitig als JustKonstruktion identifiziert und sein internes NotSoBrokenFeld an einen Namen bindet (in diesem Fall x). xkann dann wie der normale NotSoBrokenWert verwendet werden.

Die Mustererkennung bietet daher eine leistungsstarke Möglichkeit zur Durchsetzung der Typensicherheit, da die Struktur des Objekts untrennbar mit der Verzweigung der Steuerung verbunden ist.

Ich hoffe das war eine nachvollziehbare Erklärung. Wenn es keinen Sinn ergibt, springen Sie zu Learn You A Haskell For Great Good! , eines der besten Online-Tutorials, die ich je gelesen habe. Hoffentlich sehen Sie in dieser Sprache die gleiche Schönheit wie ich.


TL; DR sollte oben sein :)
andrew.fox

@ andrew.fox Guter Punkt. Ich werde bearbeiten.
ApproachingDarknessFish

0

Ich denke, Ihr Zitat ist ein Strohmann-Argument.

Die heutigen modernen Sprachen (einschließlich C #) garantieren, dass der Konstruktor entweder vollständig ausgeführt wird oder nicht.

Wenn es im Konstruktor eine Ausnahme gibt und das Objekt teilweise nicht initialisiert wird, hat der Status nulloder Maybe::nonefür den nicht initialisierten Status keinen wirklichen Unterschied im Destruktorcode.

Sie müssen nur so oder so damit umgehen. Wenn externe Ressourcen zu verwalten sind, müssen Sie diese auf irgendeine Weise explizit verwalten. Sprachen und Bibliotheken können helfen, aber Sie müssen sich Gedanken darüber machen.

Btw: In C # ist der nullWert so ziemlich gleichbedeutend mit Maybe::none. Sie können nullnur Variablen und Objektelementen zuweisen , die auf Typebene als nullable deklariert sind :

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

Dies ist in keiner Weise anders als das folgende Snippet:

Maybe<String> optionalString = getOptionalString();

Zusammenfassend sehe ich also nicht, wie Nullwertigkeit in irgendeiner Weise den MaybeTypen entgegengesetzt ist. Ich würde sogar vorschlagen, dass C # sich in seinen eigenen MaybeTyp eingeschlichen und ihn genannt hat Nullable<T>.

Mit Erweiterungsmethoden ist es sogar einfach, die Bereinigung der Nullable nach dem monadischen Muster durchzuführen :

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );

2
Was bedeutet es, "Konstruktor wird entweder vollständig ausgeführt oder nicht"? In Java zum Beispiel ist die Initialisierung des (nicht endgültigen) Felds im Konstruktor nicht vor Datenrassen geschützt - gilt dies als vollständig abgeschlossen oder nicht?
gnat

@gnat: Was meinst du mit "In Java ist beispielsweise die Initialisierung des (nicht endgültigen) Feldes im Konstruktor nicht vor Datenrassen geschützt"? Wenn Sie nicht etwas spektakulär Komplexes mit mehreren Threads tun, sind die Chancen für Race-Bedingungen in einem Konstruktor nahezu ausgeschlossen (oder sollten es auch sein). Sie können nur innerhalb des Objektkonstruktors auf ein Feld eines nicht konstruierten Objekts zugreifen. Und wenn die Konstruktion fehlschlägt, haben Sie keinen Verweis auf das Objekt.
Roland Tepp

Der große Unterschied zwischen nullals implizites Mitglied von jedem Typ und Maybe<T>mit dem Willen Maybe<T>, kann man auch nur haben T, der keinen Standardwert hat.
SVICK

Beim Erstellen von Arrays ist es häufig nicht möglich, nützliche Werte für alle Elemente zu bestimmen, ohne dass einige gelesen werden müssen, und es kann statisch überprüft werden, dass kein Element gelesen wird, ohne dass ein nützlicher Wert dafür berechnet wurde. Am besten initialisieren Sie Array-Elemente so, dass sie als unbrauchbar erkannt werden.
Supercat

@svick: In C # (die vom OP in Frage gestellte Sprache) nullist nicht jeder Typ ein implizites Mitglied. Um nullein Lebal-Wert zu sein, müssen Sie den Typ so definieren, dass er explizit nullbar ist, was eine T?(Syntax sugar for Nullable<T>) im Wesentlichen äquivalent zu macht Maybe<T>.
Roland Tepp

-3

C ++ greift dazu auf den Initialisierer zu, der vor dem Konstruktortext auftritt. C # führt den Standardinitialisierer vor dem Konstruktortext aus, ordnet grob alles 0 zu, floatswird 0.0, boolswird false, Verweise werden null usw. In C ++ können Sie einen anderen Initialisierer ausführen, um sicherzustellen, dass ein Verweistyp ungleich null niemals null ist .

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}

2
die frage ging um sprachen mit maybe types
gnat

3
" Referenzen werden zu Null " - die ganze Prämisse der Frage ist, dass wir keine haben null, und die einzige Möglichkeit, das Fehlen eines Werts anzuzeigen, besteht darin, einen MaybeTyp (auch bekannt als Option) zu verwenden, den AFAIK C ++ in der nicht hat Standardbibliothek. Das Fehlen von Nullen garantiert, dass ein Feld immer als Eigenschaft des Typsystems gültig ist . Dies ist eine stärkere Garantie, als manuell sicherzustellen, dass kein Codepfad vorhanden ist, in dem sich möglicherweise noch eine Variable befindet null.
Amon

Während c ++ nicht explizit Vielleicht-Typen hat, sind Dinge wie std :: shared_ptr <T> nah genug, dass es meiner Meinung nach immer noch relevant ist, dass c ++ den Fall behandelt, in dem die Initialisierung von Variablen "außerhalb des Gültigkeitsbereichs" des Konstruktors erfolgen kann, und ist in der Tat für Referenztypen (&) erforderlich, da sie nicht null sein können.
FryGuy
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.