Was sind die Einschränkungen beim Implementieren grundlegender Typen (wie int) als Klassen?

27

Bei der Konzeption und implenting eine objektorientierte Programmiersprache, muss irgendwann man eine Wahl trifft über grundlegende Arten der Umsetzung (wie int, float, doubleoder Äquivalente) als Klassen oder etwas anderes. Offensichtlich Sprachen in der C - Familie eine Tendenz haben , nicht sie als Klassen zu definieren (Java hat spezielle Urtyp, C # implementiert sie als unveränderliche Strukturen, etc).

Ich kann mir einen sehr wichtigen Vorteil vorstellen, wenn grundlegende Typen als Klassen implementiert werden (in einem Typsystem mit einer einheitlichen Hierarchie): Diese Typen können richtige Liskov-Subtypen des Stammtyps sein. Auf diese Weise vermeiden wir, dass die Sprache durch (explizites oder implizites) Ein- und Auspacken, Wrapper-Typen, spezielle Varianzregeln, spezielles Verhalten usw. kompliziert wird.

Natürlich kann ich teilweise nachvollziehen, warum Sprachentwickler entscheiden, wie sie vorgehen: Klasseninstanzen haben in der Regel einen gewissen räumlichen Aufwand (da die Instanzen möglicherweise eine vtable oder andere Metadaten in ihrem Speicherlayout enthalten), den Primitive / Strukturen nicht benötigen haben (wenn die Sprache keine Vererbung für diese zulässt).

Ist räumliche Effizienz (und verbesserte räumliche Lokalität, insbesondere in großen Arrays) der einzige Grund, warum fundamentale Typen oft keine Klassen sind?

Ich habe allgemein angenommen, dass die Antwort ja ist, aber Compiler haben Escape-Analyse-Algorithmen und können daher ableiten, ob sie den räumlichen Aufwand (selektiv) weglassen können, wenn sich eine Instanz (jede Instanz, nicht nur ein grundlegender Typ) als streng erwiesen hat lokal.

Ist das oben Gesagte falsch oder fehlt mir noch etwas?

Theodoros Chatzigiannakis
quelle

Antworten:

19

Ja, es kommt so ziemlich auf die Effizienz an. Aber Sie scheinen die Auswirkungen zu unterschätzen (oder zu überschätzen, wie gut verschiedene Optimierungen funktionieren).

Erstens ist es nicht nur "räumlicher Aufwand". Das Erstellen von boxed / heap-allokierten Primitiven hat auch Leistungskosten. Es gibt den zusätzlichen Druck auf den GC, diese Objekte zuzuweisen und zu sammeln. Das geht doppelt, wenn die "primitiven Objekte" unveränderlich sind, wie sie sein sollten. Dann gibt es mehr Cache-Fehler (sowohl wegen der Indirektion als auch weil weniger Daten in eine bestimmte Menge an Cache passen). Plus die bloße Tatsache, dass "die Adresse eines Objekts laden, dann den tatsächlichen Wert von dieser Adresse laden" mehr Anweisungen als "den Wert direkt laden".

Zweitens ist die Fluchtanalyse kein schneller Feenstaub. Dies gilt nur für Werte, die sich nicht entziehen. Es ist auf jeden Fall schön, lokale Berechnungen (wie Schleifenzähler und Zwischenergebnisse von Berechnungen) zu optimieren, und es wird messbare Vorteile bringen. Die weitaus größere Mehrheit der Werte lebt jedoch im Bereich von Objekten und Arrays. Zugegeben, diese können selbst einer Escape-Analyse unterzogen werden. Da es sich jedoch in der Regel um veränderbare Referenztypen handelt, stellt jedes Aliasing eine erhebliche Herausforderung für die Escape-Analyse dar, die nun beweisen muss, dass diese Aliase (1) ebenfalls nicht entkommen , und (2) machen keinen Unterschied, um Zuordnungen zu eliminieren.

Da das Aufrufen einer Methode (einschließlich Getters) oder das Übergeben eines Objekts als Argument an eine andere Methode dem Objekt dabei helfen kann, zu entkommen, müssen Sie die Interprozeduranalyse in allen bis auf die trivialsten Fälle durchführen. Dies ist weitaus teurer und komplizierter.

Und dann gibt es Fälle, in denen die Dinge wirklich entkommen und nicht vernünftigerweise wegoptimiert werden können. Ziemlich viele von ihnen, wenn man bedenkt, wie oft C-Programmierer Probleme mit der Heap-Zuweisung von Dingen haben. Wenn ein Objekt, das ein int enthält, ausgeblendet wird, gilt die Escape-Analyse nicht mehr auch für das int. Verabschieden Sie sich von effizienten primitiven Feldern .

Dies knüpft an einen anderen Punkt an: Die erforderlichen Analysen und Optimierungen sind sehr kompliziert und ein aktives Forschungsgebiet. Es ist fraglich, ob eine Sprachimplementierung jemals den von Ihnen vorgeschlagenen Optimierungsgrad erreicht hat, und selbst wenn dies der Fall ist, war dies eine seltene und herkulische Anstrengung. Auf den Schultern dieser Riesen zu stehen ist sicherlich einfacher als selbst ein Riese zu sein, aber es ist noch alles andere als trivial. Erwarten Sie in den ersten Jahren keine Wettbewerbsleistung, wenn überhaupt.

Das heißt nicht, dass solche Sprachen nicht lebensfähig sein können. Offensichtlich sind sie. Gehen Sie einfach nicht davon aus, dass es Zeile für Zeile so schnell ist wie Sprachen mit dedizierten Primitiven. Mit anderen Worten, täuschen Sie sich nicht mit Visionen eines hinreichend intelligenten Compilers .


quelle
Wenn es um Fluchtanalysen ging, meinte ich auch die Zuweisung von automatischem Speicher (er löst nicht alles, aber wie Sie sagen, löst er einige Dinge). Ich gebe auch zu, dass ich unterschätzt hatte, inwieweit Felder und Aliasing die Escape-Analyse häufiger zum Scheitern bringen können. Cache-Ausfälle waren das, worüber ich mich am meisten Gedanken gemacht habe, wenn es um räumliche Effizienz ging.
Theodoros Chatzigiannakis
@TheodorosChatzigiannakis Ich beziehe die Änderung der Zuweisungsstrategie in die Fluchtanalyse ein (denn ehrlich gesagt scheint dies das einzige zu sein, wofür sie jemals verwendet wurde).
Zu Ihrem zweiten Absatz: Objekte müssen nicht immer Heap-reserviert oder Referenztypen sein. Wenn dies nicht der Fall ist, werden die erforderlichen Optimierungen vergleichsweise einfach. Ein frühes Beispiel finden Sie in den Stack-zugewiesenen Objekten von C ++ und in Rusts Besitzersystem, um die Escape-Analyse direkt in die Sprache zu schreiben.
amon
@amon Ich weiß, und vielleicht hätte ich das klarer machen sollen, aber es scheint, dass OP nur an Java- und C # -ähnlichen Sprachen interessiert ist, bei denen die Heap-Zuweisung aufgrund von Referenzsemantik und verlustfreien Umwandlungen zwischen Subtypen fast obligatorisch (und implizit) ist. Ein guter Punkt, warum Rust eine Flucht vor der Analyse ist!
@delnan Es ist wahr, dass ich mich hauptsächlich für Sprachen interessiere, die die Speicherdetails abstrahieren. Sie können jedoch auch alles einbeziehen, was Sie für relevant halten, auch wenn es in diesen Sprachen nicht anwendbar ist.
Theodoros Chatzigiannakis
27

Ist räumliche Effizienz (und verbesserte räumliche Lokalität, insbesondere in großen Arrays) der einzige Grund, warum fundamentale Typen oft keine Klassen sind?

Nein.

Das andere Problem ist, dass fundamentale Typen tendenziell von fundamentalen Operationen verwendet werden. Der Compiler muss wissen, dass dies int + intnicht zu einem Funktionsaufruf kompiliert wird, sondern zu einer elementaren CPU-Anweisung (oder einem äquivalenten Bytecode). An diesem Punkt, wenn Sie das intals reguläres Objekt haben, müssen Sie das Ding sowieso effektiv entpacken.

Diese Art von Operationen spielen auch beim Subtyping keine große Rolle. Sie können nicht an eine CPU-Anweisung senden. Sie können nicht von einer CPU-Anweisung versenden . Ich meine, der ganze Punkt der Untertypisierung ist, dass Sie a verwenden können, Dwo Sie a können B. CPU-Anweisungen sind nicht polymorph. Damit Primitive dies tun können, müssen Sie ihre Operationen mit einer Dispatch-Logik umschließen, die das Mehrfache der Menge an Operationen als einfache Addition (oder was auch immer) kostet. Der Vorteil, intTeil der Typhierarchie zu sein, wird ein wenig umstritten, wenn es versiegelt / endgültig ist. Und das ignoriert all die Kopfschmerzen mit Dispatch-Logik für Binäroperatoren ...

Grundsätzlich müssten die primitiven Typen viele spezielle Regeln haben, wie der Compiler mit ihnen umgeht und was der Benutzer mit ihren Typen sowieso tun kann. Daher ist es oftmals einfacher, sie einfach als vollständig unterschiedlich zu behandeln.

Telastyn
quelle
4
Sehen Sie sich die Implementierung einer der dynamisch typisierten Sprachen an, die Ganzzahlen und z. B. Objekte behandelt. Der endgültige primitive CPU-Befehl kann sehr gut in einer Methode (Operatorüberladung) in der nur etwas privilegierten Klassenimplementierung in der Laufzeitbibliothek versteckt werden. Die Details würden bei einem statischen Typsystem und Compiler anders aussehen, aber es ist kein grundlegendes Problem. Im schlimmsten Fall wird es nur noch langsamer.
3
int + intkann ein regulärer Operator auf Sprachebene sein, der eine intrinsische Anweisung aufruft, die garantiert mit dem nativen CPU-Integer-Zusatz op kompiliert wird (oder sich so verhält). Der Vorteil des intErbens von objectbesteht nicht nur in der Möglichkeit, einen anderen Typ zu erben int, sondern auch in der Möglichkeit, intsich objectohne Boxen zu verhalten . Betrachten Sie C # -Generika: Sie können Kovarianz und Kontravarianz aufweisen, diese sind jedoch nur auf Klassentypen anwendbar - Strukturtypen werden automatisch ausgeschlossen, da sie nur objectdurch (implizites, vom Compiler generiertes) Boxing entstehen können.
Theodoros Chatzigiannakis
3
@delnan - nach meiner Erfahrung mit statisch typisierten Implementierungen hat der Overhead jedes Nicht-System-Aufrufs dramatische Auswirkungen auf die Leistung, was sich wiederum noch dramatischer auf die Implementierung auswirkt.
Telastyn
@TheodorosChatzigiannakis - großartig, so dass Sie Varianz und Kontravarianz bei Typen erhalten können, die keinen nützlichen Sub- / Super-Typ haben ... Und die Implementierung dieses speziellen Operators zum Aufrufen des CPU-Befehls macht ihn immer noch besonders. Ich bin nicht anderer Meinung mit der Idee - ich habe in meinen Spielzeugsprachen sehr ähnliche Dinge getan, aber ich habe festgestellt, dass es während der Implementierung praktische Fallstricke gibt, die solche Dinge nicht so sauber machen, wie man es erwarten würde.
Telastyn
1
@TheodorosChatzigiannakis Ein Inlining über Bibliotheksgrenzen hinweg ist sicherlich möglich, obwohl es sich um einen weiteren Punkt auf der Einkaufsliste "High-End-Optimierungen, die ich mir wünschen würde" handelt. Ich fühle mich jedoch verpflichtet, darauf hinzuweisen, dass es notorisch schwierig ist, alles richtig zu machen, ohne so konservativ wie nutzlos zu sein.
4

Es gibt nur sehr wenige Fälle, in denen Sie "grundlegende Typen" benötigen, um vollständige Objekte zu sein (hier sind ein Objekt Daten, die entweder einen Zeiger auf einen Versandmechanismus enthalten oder mit einem Typ versehen sind, der von einem Versandmechanismus verwendet werden kann):

  • Sie möchten, dass benutzerdefinierte Typen von grundlegenden Typen erben können. Dies ist normalerweise nicht erwünscht, da es zu Performance- und Sicherheitsproblemen führt. Dies ist ein Leistungsproblem, da beim Kompilieren nicht davon ausgegangen werden kann, dass ein intObjekt eine bestimmte feste Größe hat oder dass keine Methoden überschrieben wurden, und es ist ein Sicherheitsproblem, da die Semantik von ints unterlaufen werden kann (eine ganze Zahl, die einer beliebigen Zahl entspricht, ist zu berücksichtigen) das ändert seinen Wert, anstatt unveränderlich zu sein).

  • Ihre primitiven Typen haben Supertypen und Sie möchten Variablen mit dem Typ eines Supertyps eines primitiven Typs haben. Angenommen, Ihr ints Hashableist und Sie möchten eine Funktion deklarieren, die einen HashableParameter verwendet, der neben regulären Objekten auch ints empfangen kann .

    Dies kann „gelöst“ werden, indem solche Typen illegal gemacht werden: Entfernen Sie Subtyping und entscheiden Sie, dass Interfaces keine Typen, sondern Typbeschränkungen sind. Offensichtlich verringert dies die Ausdruckskraft Ihres Typsystems, und ein solches Typsystem würde nicht länger als objektorientiert bezeichnet. Siehe Haskell für eine Sprache, die diese Strategie verwendet. C ++ ist auf halbem Weg, da primitive Typen keine Supertypen haben.

    Die Alternative ist das vollständige oder teilweise Boxen grundlegender Arten. Der Boxtyp muss nicht für den Benutzer sichtbar sein. Im Wesentlichen definieren Sie einen internen Boxtyp für jeden Fundamentaltyp und implizite Konvertierungen zwischen Boxtyp und Fundamentaltyp. Dies kann umständlich werden, wenn die Boxtypen unterschiedliche Semantiken haben. Java weist zwei Probleme auf: Boxed Types haben ein Konzept der Identität, während Primitive nur ein Konzept der Wertäquivalenz haben und Boxed Types nullbar sind, während Primitive immer gültig sind. Diese Probleme können vollständig vermieden werden, indem kein Identitätskonzept für Werttypen angeboten wird, keine Operatorüberladung möglich ist und nicht alle Objekte standardmäßig auf null gesetzt werden.

  • Sie haben keine statische Typisierung. Eine Variable kann einen beliebigen Wert enthalten, einschließlich primitiver Typen oder Objekte. Aus diesem Grund müssen alle primitiven Typen immer mit einem Kästchen versehen werden, um eine starke Typisierung zu gewährleisten.

Bei Sprachen mit statischer Typisierung empfiehlt es sich, primitive Typen zu verwenden, wo immer dies möglich ist, und nur als letzte Möglichkeit auf geschachtelte Typen zurückzugreifen. Während viele Programme nicht überaus leistungsempfindlich sind, gibt es Fälle, in denen die Größe und der Aufbau von primitiven Typen äußerst relevant sind: Stellen Sie sich vor, Sie müssen in großem Maßstab Datenpunkte in den Speicher einfügen, um Milliarden von Datenpunkten zu speichern. Umschalten von doubleauffloatDies ist möglicherweise eine praktikable Strategie zur Speicherplatzoptimierung in C, hat jedoch praktisch keine Auswirkung, wenn alle numerischen Typen immer in einem Kästchen angeordnet sind (und daher mindestens die Hälfte ihres Speichers für einen Dispatch-Mechanismus-Zeiger verschwenden). Wenn primitive Boxed-Typen lokal verwendet werden, ist es recht einfach, das Boxing mithilfe von Compiler-Eigenheiten zu entfernen, aber es wäre kurzsichtig, die Gesamtleistung Ihrer Sprache auf einen „ausreichend fortgeschrittenen Compiler“ zu setzen.

amon
quelle
Eine intist in allen Sprachen kaum unveränderlich.
Scott Whitlock
6
@ScottWhitlock Ich verstehe, warum Sie das vielleicht denken, aber im Allgemeinen sind primitive Typen unveränderliche Werttypen. In keiner vernünftigen Sprache können Sie den Wert der Zahl sieben ändern. In vielen Sprachen können Sie jedoch eine Variable, die einen Wert eines primitiven Typs enthält, einem anderen Wert zuweisen. In C-ähnlichen Sprachen ist eine Variable ein benannter Speicherort und verhält sich wie ein Zeiger. Eine Variable entspricht nicht dem Wert, auf den sie zeigt. Ein intWert ist unveränderlich, eine intVariable jedoch nicht.
amon
1
@amon: Keine vernünftige Sprache; Nur Java: thedailywtf.com/articles/Disgruntled-Bomb-Java-Edition
Mason Wheeler
get rid of subtyping and decide that interfaces aren't types but type constraints.... such a type system wouldn't be called object-oriented any longer aber das klingt nach prototypbasierter Programmierung, was definitiv OOP ist.
Michael
1
@ScottWhitlock die Frage ist, ob Sie, wenn Sie dann int b = a haben, etwas gegen b tun können, das den Wert von a ändert. Es gab einige Sprachimplementierungen, bei denen dies möglich war, aber dies wird im Allgemeinen als pathologisch und unerwünscht angesehen, im Gegensatz dazu, dasselbe für ein Array zu tun.
Random832,
2

Den meisten Implementierungen sind drei Einschränkungen für solche Klassen bekannt, die es dem Compiler ermöglichen, die primitiven Typen die meiste Zeit effizient als zugrunde liegende Darstellung zu verwenden. Diese Einschränkungen sind:

  • Unveränderlichkeit
  • Finalität (nicht ableitbar)
  • Statische Eingabe

Die Situationen, in denen ein Compiler ein Grundelement in ein Objekt in der zugrunde liegenden Darstellung packen muss , sind relativ selten, z. B. wenn eine ObjectReferenz darauf verweist.

Dies fügt dem Compiler einiges an Sonderfallbehandlung hinzu, beschränkt sich jedoch nicht nur auf einige mythische, hochentwickelte Compiler. Diese Optimierung ist in realen Produktionscompilern in den Hauptsprachen. In Scala können Sie sogar Ihre eigenen Wertklassen definieren.

Karl Bielefeldt
quelle
1

In Smalltalk sind alle (int, float usw.) erstklassige Objekte. Der einzige Sonderfall besteht darin, dass SmallInteger-Werte aus Gründen der Effizienz von der virtuellen Maschine unterschiedlich codiert und behandelt werden. Daher lässt die SmallInteger-Klasse keine Unterklassen zu (was keine praktische Einschränkung darstellt.) Beachten Sie, dass hierfür keine besonderen Überlegungen erforderlich sind Seitens des Programmierers wird die Unterscheidung zu automatischen Routinen wie Codegenerierung oder Garbage Collection umschrieben.

Sowohl der Smalltalk-Compiler (Quellcode -> VM-Bytecodes) als auch der VM-Nativizer (Bytecodes -> Maschinencode) optimieren den generierten Code (JIT), um den Aufwand für elementare Operationen mit diesen Basisobjekten zu verringern.

Leandro Caniglia
quelle
1

Ich habe eine OO-Sprache und -Runtime entworfen (dies ist aus einem ganz anderen Grund fehlgeschlagen).

Es ist von Natur aus nichts Falsches daran, Dinge wie int true classes zu machen. Tatsächlich erleichtert dies das Entwerfen des GC, da es jetzt nur noch zwei Arten von Heap-Headern (Klasse und Array) statt drei (Klasse, Array und Grundelement) gibt [die Tatsache, dass wir danach Klasse und Array zusammenführen können, ist nicht relevant ].

Der wirklich wichtige Fall, in dem die primitiven Typen hauptsächlich endgültige / versiegelte Methoden haben sollten (+ ist wirklich wichtig, ToString nicht so sehr). Auf diese Weise kann der Compiler fast alle Aufrufe der Funktionen selbst statisch auflösen und in die Funktionen einbinden. In den meisten Fällen spielt dies keine Rolle als Kopierverhalten (ich habe die Einbettung auf Sprachebene verfügbar gemacht [ebenso wie .NET]), aber in einigen Fällen wird der Compiler gezwungen, den Aufruf von zu generieren, wenn die Methoden nicht versiegelt sind Die Funktion zur Implementierung von int + int.

Joshua
quelle