30 Stimmen

Statische Typen, Polymorphismus und Spezialisierung.

Als ich zum ersten Mal Haskell gelernt habe, habe ich sehr schnell die parametrische Polymorphie lieben gelernt. Es ist eine herrlich einfache Idee, die erstaunlich gut funktioniert. Das Ganze mit "wenn es kompiliert, funktioniert es in der Regel richtig" liegt größtenteils an der parametrischen Polymorphie, meiner Meinung nach.

Aber neulich ist mir etwas aufgefallen. Ich kann foo als polymorphe Funktion schreiben. Aber wenn bar foo aufruft, erfolgt dies mit einem bestimmten Satz von Argumenttypen. Oder wenn bar selbst polymorph ist, dann wird sein Aufrufer bestimmte Typen zuweisen. Durch Induktion scheint es, dass wenn man eines beliebigen gültigen Haskell-Programms analysiert und den gesamten Codebestand betrachtet, man den Typ jeder einzelnen Sache im gesamten Programm statisch bestimmen kann.

Dies ist in gewisser Weise ein bisschen wie C++ Templates. Es gibt keine Laufzeit-Polymorphie, nur Kompilierungszeit-Polymorphie. Ein Haskell-Compiler könnte sich dafür entscheiden, für jeden Typ, mit dem jede polymorphe Funktion aufgerufen wird, separaten Maschinencode zu generieren. Die meisten Haskell-Compiler machen das nicht, aber man könnte einen implementieren, wenn man wollte.

Nur wenn man Haskell-Erweiterungen hinzufügt (ExistentialQuantification ist die offensichtliche) beginnt man echte Laufzeit-Polymorphie zu bekommen, bei der man Werte hat, deren Typ nicht statisch berechnet werden kann.

Ach ja, meine Frage?

  1. Sind die obigen Aussagen tatsächlich korrekt?

  2. Gibt es eine weit verbreitete Bezeichnung für diese Eigenschaft?

31voto

Heatsink Punkte 7621

Haskell (ohne Erweiterungen) erlaubt polymorphe Rekursion, und allein diese Funktion macht es unmöglich, ein Programm vollständig monomorph zu spezialisieren. Hier ist ein Programm, das eine N-Tief geschachtelte Liste ausdruckt, wobei N ein Befehlszeilenparameter ist:

import System

foo :: Show a => Int -> a -> IO ()
foo 0 x = print x
foo n x = foo (n-1) [x]

main = do [num_lists] <- getArgs
          foo (read num_lists) 0

Im ersten Aufruf von foo hat a den Typ Int. Im nächsten rekursiven Aufruf hat es den Typ [Int], dann [[Int]] und so weiter.

Wenn polymorphe Rekursion verboten ist, dann glaube ich, dass es möglich ist, ein Programm statisch zu spezialisieren.

14voto

glaebhoerl Punkte 7573

Ja, darüber habe ich auch nachgedacht. Grundsätzlich ist die Idee, dass es so aussieht, als könnte man Haskell 98 implementieren, aber nicht einige der Spracherweiterungen dazu, indem man Polymorphismus durch Multiinstanziierung anstelle von Polymorphismus durch Boxen verwendet.

Man kann Einblicke darin gewinnen, indem man versucht, einige Haskell-Funktionen als C++-Bibliotheken zu implementieren (wie Sie bemerken, verwendet C++ Polymorphismus durch Multiinstanziierung). Was man feststellt, ist, dass man alles tun kann, was Haskell kann, außer dass es unmöglich ist, polymorphe Werte zu haben, was auch Verweise auf polymorphe Funktionen einschließt.

Wie das aussieht, ist dass, wenn Sie haben

template
void f(T);           // f :: a -> IO ()

können Sie die Adresse einer bestimmten Instanziierung nehmen, um sie als Funktionszeiger zur Laufzeit zu übergeben:

&f

aber Sie können nicht die Adresse eines Templates (&f) nehmen. Das macht Sinn: Templates sind ein rein kompilierzeitliches Konstrukt. Es macht auch Sinn, dass wenn Sie Polymorphismus durch Multiinstanziierung machen, Sie einen Zeiger auf eine bestimmte Instanziierung haben können, aber keinen Zeiger auf die polymorphe Funktion selbst, weil auf Maschinencodeebene keine existiert.

Wo verwendet Haskell also polymorphe Werte? Auf den ersten Blick scheint eine gute Faustregel "überall, wo Sie ein explizites forall schreiben müssen". Also PolymorphicComponents, Rank2Types, RankNTypes und ImpredicativeTypes sind offensichtliche No-Gos. Dies kann nicht in C++ übersetzt werden:

data MkList = MkList (forall a. a -> [a])
singleton = MkList (\x -> [x])

Andererseits ist ExistentialQuantification in zumindest einigen Fällen durchführbar: Es bedeutet, eine nicht-template Klasse mit einem Vorlagenkonstruktor zu haben (oder allgemeiner ausgedrückt, eine Klasse, deren Konstruktor auf mehr Dinge als die Klasse selbst vortemplate ist).

Wenn Sie also in Haskell haben:

data SomeShow = forall a. Show a => SomeShow a
instance Show SomeShow where show (SomeShow a) = show a

kann man dies in C++ wie folgt implementieren:

// eine Funktion, die einen void* nimmt, diesen auf den gegebenen Typ castet und
// die entsprechende show() Funktion aufruft (statisch ausgewählt aufgrund
// der Überladungsauflösungsregeln)
template
String showVoid(void *x)
{
    show(*(T*)x);
}

class SomeShow
{
    private:
        void *m_data;
        String (*m_show)(void*); // m_show :: Any -> String

    public:
        template
        SomeShow(T x)
            : m_data(new T(x)) // Speicherprobleme, die aber nichts damit zu tun haben
            , m_show(&showVoid)
        {
        }

        String show()
        {
            // alternativ könnten wir die Top-Level-show() als friend deklarieren und
            // dies dort platzieren
            return m_show(m_data);
        }
};

// C++ hat keine Typklassen im eigentlichen Sinne, aber es hat Überladung, was bedeutet,
// dass Schnittstellen implizit sind: Wo in Haskell man eine Klasse und
// Instanzen schreiben würde, schreibt man in C++ einfach eine Funktion mit dem gleichen Namen für jeden Typ
String show(SomeShow x)
{
    return x.show();
}

In beiden Sprachen hat man also einen nicht-polymorphen Typ mit einem polymorphen Konstruktor.

Wir haben also gezeigt, dass es einige Spracherweiterungen gibt, die Sie implementieren können und einige, die Sie nicht können, aber was ist mit der anderen Seite der Medaille: Gibt es etwas in Haskell 98, das Sie nicht implementieren können? Wenn Sie bedenken, dass Sie eine Spracherweiterung (ExplicitForAll) benötigen, um überhaupt ein forall zu schreiben, würden Sie denken, dass die Antwort nein ist. Und Sie wären fast richtig, aber es gibt zwei Haken: Typklassen und polymorphe Rekursion. Typklassen werden typischerweise mit dem Durchreichen von Dictionaries implementiert: Jede Instanzerklärung führt zu einem Satz von Funktionen, die implizit überall dort übergeben werden, wo sie benötigt werden.

Also für Monad hätten Sie beispielsweise:

data MonadDict m = MonadDict { 
    return :: forall a. a -> m a,
    (>>=)  :: forall a b. m a -> (a -> m b) -> m b
}

Nun, schauen Sie sich diese Foralls an! Sie können sie nicht explizit schreiben, aber in Implementierungen mit Dictionary-Passing, selbst in Haskell 98, führen Klassen mit polymorphen Methoden zu Records, die polymorphe Funktionen enthalten. Was, wenn Sie versuchen, das Ganze mit Multiinstanziierung zu implementieren, offensichtlich ein Problem darstellt. Sie könnt fast ohne Dictionary-Passing auskommen, weil, wenn Sie sich an Haskell 98 halten, Instanzen fast immer global und statisch bekannt sind. Jede Instanz führt zu einigen polymorphen Funktionen, aber weil zur Compile-Zeit fast immer bekannt ist, welche Funktion aufgerufen werden soll, müssen Sie fast nie Referenzen darauf zur Laufzeit übergeben (was gut ist, denn Sie können es nicht). Der Kompromiss besteht darin, dass Sie eine gesamte Programm-Kompilierung durchführen müssen, da Instanzen sonst nicht mehr statisch bekannt sind: Sie könnten in einem anderen Modul sein. Und die Ausnahme ist polymorphe Rekursion, die Sie praktisch dazu zwingt, ein Dictionary zur Laufzeit aufzubauen. Sehen Sie sich die andere Antwort für weitere Details dazu an. Polymorphe Rekursion macht den Multi.instanziierung Ansatz auch ohne Typklassen zunichte: Siehe den Kommentar zu BTrees. (Auch ExistentialQuantification *plus* Klassen mit polymorphen Methoden sind nicht mehr durchführbar, weil Sie wieder anfangen müssten, Verweise auf polymorphe Funktionen zu speichern.)

8voto

Don Stewart Punkte 136046

Ganze Programmcompiler nutzen den globalen Zugriff auf Typinformationen, um sehr aggressive Optimierungen vorzunehmen, wie Sie oben beschreiben. Beispiele dafür sind JHC und MLton. GHC mit Inlining ist ebenfalls teilweise "Ganzprogramm", aus ähnlichen Gründen. Andere Techniken, die von globalen Informationen profitieren, umfassen Superoptimierung.

Beachten Sie, dass Sie durch die Spezialisierung polymorpher Funktionen auf alle Typen, bei denen sie verwendet werden, die Codegröße massiv erhöhen können - dies erfordert dann starkes Inlining, um den Code auf normale Werte zu reduzieren. Dies zu verwalten, ist eine Herausforderung.

CodeJaeger.com

CodeJaeger ist eine Gemeinschaft für Programmierer, die täglich Hilfe erhalten..
Wir haben viele Inhalte, und Sie können auch Ihre eigenen Fragen stellen oder die Fragen anderer Leute lösen.

Powered by:

X