Bei der Konzeption und implenting eine objektorientierte Programmiersprache, muss irgendwann man eine Wahl trifft über grundlegende Arten der Umsetzung (wie int
, float
, double
oder Ä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?
quelle
Antworten:
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
Nein.
Das andere Problem ist, dass fundamentale Typen tendenziell von fundamentalen Operationen verwendet werden. Der Compiler muss wissen, dass dies
int + int
nicht zu einem Funktionsaufruf kompiliert wird, sondern zu einer elementaren CPU-Anweisung (oder einem äquivalenten Bytecode). An diesem Punkt, wenn Sie dasint
als 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,
D
wo Sie a könnenB
. 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,int
Teil 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.
quelle
int + int
kann 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 desint
Erbens vonobject
besteht nicht nur in der Möglichkeit, einen anderen Typ zu erbenint
, sondern auch in der Möglichkeit,int
sichobject
ohne 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 nurobject
durch (implizites, vom Compiler generiertes) Boxing entstehen können.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
int
Objekt eine bestimmte feste Größe hat oder dass keine Methoden überschrieben wurden, und es ist ein Sicherheitsproblem, da die Semantik vonint
s 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
int
sHashable
ist und Sie möchten eine Funktion deklarieren, die einenHashable
Parameter verwendet, der neben regulären Objekten auchint
s 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
double
auffloat
Dies 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.quelle
int
ist in allen Sprachen kaum unveränderlich.int
Wert ist unveränderlich, eineint
Variable jedoch nicht.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.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:
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
Object
Referenz 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.
quelle
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.
quelle
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.
quelle