Quali sono le avvertenze sull'implementazione dei tipi fondamentali (come int) come classi?


27

Nella progettazione e implenting un linguaggio di programmazione orientato agli oggetti, ad un certo punto si deve fare una scelta circa l'attuazione di tipi fondamentali (come int, float, doubleo equivalenti) come classi o qualcos'altro. Chiaramente, i linguaggi della famiglia C hanno la tendenza a non definirli come classi (Java ha tipi primitivi speciali, C # li implementa come strutture immutabili, ecc.).

Posso pensare a un vantaggio molto importante quando i tipi fondamentali sono implementati come classi (in un sistema di tipi con una gerarchia unificata): questi tipi possono essere sottotipi di Liskov del tipo root. Pertanto, evitiamo di complicare la lingua con boxe / unboxing (esplicito o implicito), tipi di wrapper, regole speciali di varianza, comportamento speciale, ecc.

Naturalmente, posso parzialmente capire perché i progettisti del linguaggio decidono il loro modo di fare: le istanze di classe tendono ad avere un sovraccarico spaziale (perché le istanze possono contenere una vtable o altri metadati nel loro layout di memoria), che le primitive / le strutture non devono avere (se la lingua non consente l'ereditarietà su quelli).

L'efficienza spaziale (e una migliore localizzazione spaziale, specialmente in array di grandi dimensioni) è l'unica ragione per cui i tipi fondamentali non sono spesso classi?

In genere ho ipotizzato che la risposta fosse sì, ma i compilatori hanno algoritmi di analisi di escape e quindi possono dedurre se possono (selettivamente) omettere l'overhead spaziale quando un'istanza (qualsiasi istanza, non solo un tipo fondamentale) si dimostra rigorosamente Locale.

Quanto sopra è sbagliato o c'è qualcos'altro che mi manca?


Risposte:


19

Sì, si riduce praticamente all'efficienza. Ma sembra sottovalutare l'impatto (o sopravvalutare il modo in cui funzionano varie ottimizzazioni).

Innanzitutto, non si tratta solo di "spese generali spaziali". Realizzare le primitive inscatolate / allocate in heap comporta anche costi di prestazione. C'è la pressione aggiuntiva sul GC per allocare e raccogliere quegli oggetti. Ciò vale doppiamente se gli "oggetti primitivi" sono immutabili, come dovrebbero essere. Quindi ci sono più errori nella cache (sia a causa della direzione indiretta sia perché meno dati si inseriscono in una determinata quantità di cache). Inoltre il semplice fatto che "carica l'indirizzo di un oggetto, quindi carica il valore effettivo da quell'indirizzo" richiede più istruzioni di "carica direttamente il valore".

In secondo luogo, l'analisi dell'evasione non è polvere fatata più veloce. Si applica solo ai valori che, beh, non sfuggono. È certamente bello ottimizzare i calcoli locali (come contatori di loop e risultati intermedi dei calcoli) e offrirà benefici misurabili. Ma una maggioranza molto più ampia di valori vive nei campi di oggetti e matrici. Certo, quelli possono essere soggetti all'analisi di escape da soli, ma poiché di solito sono tipi di riferimento mutabili, qualsiasi aliasing di essi presenta una sfida significativa all'analisi di escape, che ora deve dimostrare che quegli alias (1) non scappano neanche e (2) non fanno differenza allo scopo di eliminare le allocazioni.

Dato che chiamare qualsiasi metodo (compresi i getter) o passare un oggetto come argomento a qualsiasi altro metodo può aiutare l'oggetto a fuggire, avrai bisogno di analisi interprocedurali in tutti tranne i casi più banali. Questo è molto più costoso e complicato.

E poi ci sono casi in cui le cose sfuggono davvero e non possono essere ragionevolmente ottimizzate. Molti di loro, in realtà, se si considera la frequenza con cui i programmatori C affrontano il problema dell'allocazione delle cose. Quando un oggetto contenente un int fuoriesce, l'analisi di escape cessa di applicarsi anche all'int. Dì addio a campi primitivi efficienti .

Questo si collega a un altro punto: le analisi e le ottimizzazioni richieste sono seriamente complicate e un'area attiva di ricerca. È discutibile se qualsiasi implementazione del linguaggio abbia mai raggiunto il grado di ottimizzazione che suggerisci e, anche se così, è stato uno sforzo raro ed erculeo. Sicuramente stare sulle spalle di questi giganti è più facile che essere un gigante, ma è ancora tutt'altro che banale. Non aspettarti prestazioni competitive in qualsiasi momento nei primi anni, se mai.

Ciò non significa che tali lingue non possano essere praticabili. Chiaramente lo sono. Non dare per scontato che sarà line-to-line veloce come le lingue con primitive dedicate. In altre parole, non illuderti con le visioni di un compilatore sufficientemente intelligente .


Quando si parla di analisi di escape, intendevo anche l'allocazione allo storage automatico (non risolve tutto, ma come dici tu, risolve alcune cose). Ammetto anche di aver sottovalutato la misura in cui i campi e l'aliasing potrebbero far fallire più spesso l'analisi di escape. I cache miss sono la cosa di cui mi preoccupavo di più quando parlavo di efficienza spaziale, quindi grazie per averlo affrontato.
Theodoros Chatzigiannakis,

@TheodorosChatzigiannakis Includo la modifica della strategia di allocazione nell'analisi di escape (perché onestamente sembra essere l'unica cosa per cui sia mai stato usato).

Per quanto riguarda il tuo secondo paragrafo: gli oggetti non devono sempre essere allocati in heap o essere tipi di riferimento. In effetti, quando non lo sono, questo rende le ottimizzazioni necessarie relativamente facili. Vedi gli oggetti allocati nello stack di C ++ per un primo esempio e il sistema di gestione proprietario di Rust per un modo per eseguire analisi di escape direttamente nel linguaggio.
amon

@amon Lo so, e forse avrei dovuto renderlo più chiaro, ma sembra che OP sia interessato solo a linguaggi simili a Java e C # in cui l'allocazione dell'heap è quasi obbligatoria (e implicita) a causa della semantica di riferimento e cast senza perdita tra sottotipi. Un buon punto su Rust usando ciò che equivale a sfuggire all'analisi!

@delnan È vero che sono principalmente interessato alle lingue che astraggono i dettagli di archiviazione, ma sentiti libero di includere tutto ciò che ritieni pertinente, anche se non applicabile in quelle lingue.
Theodoros Chatzigiannakis,

27

L'efficienza spaziale (e una migliore localizzazione spaziale, specialmente in array di grandi dimensioni) è l'unica ragione per cui i tipi fondamentali non sono spesso classi?

No.

L'altro problema è che i tipi fondamentali tendono ad essere utilizzati da operazioni fondamentali. Il compilatore deve sapere che int + intnon verrà compilato per una chiamata di funzione, ma per alcune istruzioni CPU elementari (o codice byte equivalente). A quel punto, se hai intcome oggetto normale, dovrai comunque decomprimere efficacemente la cosa.

Anche questo tipo di operazioni non funziona davvero bene con il sottotipo. Non è possibile inviare un'istruzione CPU. Non è possibile inviare da un'istruzione CPU. Voglio dire, l'intero punto del sottotipo è che puoi usare un punto Ddove puoi B. Le istruzioni della CPU non sono polimorfiche. Per fare in modo che le primitive lo facciano, devi concludere le loro operazioni con la logica di invio che costa più volte la quantità di operazioni come semplice aggiunta (o qualsiasi altra cosa). Il vantaggio di intfar parte della gerarchia dei tipi diventa un po 'controverso quando è sigillato / finale. E questo ignora tutti i mal di testa con la logica di invio per gli operatori binari ...

In sostanza, i tipi primitivi avrebbero bisogno di avere un sacco di regole speciali in giro come il compilatore maniglie loro, e ciò che l'utente può fare con i loro tipi in ogni caso , per cui è spesso più semplice per trattarli proprio come completamente distinti.


4
Controlla l'implementazione di qualsiasi linguaggio tipizzato in modo dinamico che tratti numeri interi e oggetti. L'istruzione CPU primitiva finale può benissimo essere nascosta in un metodo (sovraccarico dell'operatore) nell'implementazione della classe un po 'privilegiata nella libreria di runtime. I dettagli sarebbero diversi con un sistema di tipo statico e un compilatore, ma non è un problema fondamentale. Nel peggiore dei casi rende le cose ancora più lente.

3
int + intpuò essere un normale operatore a livello di linguaggio che invoca un'istruzione intrinseca che è garantita per compilare (o comportarsi come) l'aggiunta dell'intero CPU nativo op. Il vantaggio di intereditare objectnon è solo la possibilità di ereditare un altro tipo da int, ma anche la possibilità di intcomportarsi come un objectsenza boxe. Prendi in considerazione i generici C #: puoi avere covarianza e contraddizione, ma sono applicabili solo ai tipi di classe: i tipi di struttura sono automaticamente esclusi, perché possono diventare solo objectattraverso la boxe (implicita, generata dal compilatore).
Theodoros Chatzigiannakis,

3
@delnan - certo, sebbene nella mia esperienza con implementazioni tipicamente statiche, poiché ogni chiamata non di sistema si riduce alle operazioni primitive, avere un sovraccarico ha un impatto drammatico sulle prestazioni - che a sua volta ha un effetto ancora più drammatico sull'adozione.
Telastyn,

@TheodorosChatzigiannakis - fantastico, quindi potresti ottenere varianza e contraddizione su tipi che non hanno un sottotipo / super-tipo utile ... E l'implementazione di quell'operatore speciale per chiamare l'istruzione CPU lo rende ancora speciale. Non sono in disaccordo con l'idea: ho fatto cose molto simili nei miei linguaggi giocattolo, ma ho scoperto che ci sono dei pratici problemi durante l'implementazione che non rendono le cose pulite come ti aspetteresti.
Telastyn,

1
@TheodorosChatzigiannakis Allinearsi oltre i confini della biblioteca è certamente possibile, anche se è ancora un altro elemento della lista della spesa "ottimizzazioni di fascia alta che vorrei avere". Mi sento in dovere di sottolineare, tuttavia, che è notoriamente complicato ottenere completamente nel modo giusto senza essere così conservatore da essere inutile.

4

Ci sono solo pochissimi casi in cui è necessario che i "tipi fondamentali" siano oggetti completi (qui, un oggetto sono dati che contengono un puntatore a un meccanismo di invio o sono etichettati con un tipo che può essere utilizzato da un meccanismo di invio):

  • Desideri che i tipi definiti dall'utente siano in grado di ereditare dai tipi fondamentali. Questo di solito non è voluto in quanto introduce mal di testa legati alle prestazioni e alla sicurezza. È un problema di prestazioni perché la compilazione non può presumere che intavrà una dimensione fissa specifica o che non sia stato ignorato alcun metodo ed è un problema di sicurezza perché la semantica di ints potrebbe essere sovvertita (considerare un numero intero uguale a qualsiasi numero, oppure che cambia il suo valore anziché essere immutabile).

  • I tuoi tipi primitivi hanno supertipi e vuoi avere variabili con tipo di un supertipo di tipo primitivo. Ad esempio, supponiamo intche lo siano Hashablee che si desidera dichiarare una funzione che accetta un Hashableparametro che potrebbe ricevere oggetti regolari ma anche ints.

    Questo può essere "risolto" rendendo illegali questi tipi: sbarazzarsi del sottotipo e decidere che le interfacce non sono tipi ma vincoli di tipo. Ovviamente ciò riduce l'espressività del sistema di tipi e tale sistema di tipi non verrebbe più definito orientato agli oggetti. Vedi Haskell per un linguaggio che utilizza questa strategia. Il C ++ è a metà strada perché i tipi primitivi non hanno supertipi.

    L'alternativa è il pugilato completo o parziale di tipi fondamentali. Non è necessario che il tipo di boxe sia visibile all'utente. In sostanza, si definisce un tipo boxed interno per ogni tipo fondamentale e conversioni implicite tra il tipo boxed e fondamentale. Questo può diventare imbarazzante se i tipi inscatolati hanno una semantica diversa. Java presenta due problemi: i tipi boxed hanno un concetto di identità mentre i primitivi hanno solo un concetto di equivalenza di valore, ei tipi boxed sono nullable mentre i primitivi sono sempre validi. Questi problemi sono completamente evitabili non offrendo un concetto di identità per i tipi di valore, offrendo un sovraccarico dell'operatore e non rendendo tutti gli oggetti nulli per impostazione predefinita.

  • Non hai la digitazione statica. Una variabile può contenere qualsiasi valore, inclusi tipi o oggetti primitivi. Pertanto, tutti i tipi primitivi devono essere sempre inscatolati per garantire una digitazione forte.

Le lingue che hanno la tipizzazione statica fanno bene a usare i tipi primitivi ove possibile e ricorrono ai tipi boxati come ultima risorsa. Mentre molti programmi non sono incredibilmente sensibili alle prestazioni, ci sono casi in cui le dimensioni e la composizione dei tipi primitivi sono estremamente rilevanti: pensate allo scricchiolio dei numeri su larga scala in cui è necessario adattare miliardi di punti dati nella memoria. Passare da doubleafloatpotrebbe essere una strategia di ottimizzazione dello spazio praticabile in C, ma non avrebbe praticamente alcun effetto se tutti i tipi numerici fossero sempre inscatolati (e quindi sprecasse almeno metà della loro memoria per un puntatore del meccanismo di invio). Quando i tipi primitivi inscatolati sono usati localmente, è abbastanza semplice rimuovere la boxe attraverso l'uso di intrinseci del compilatore, ma sarebbe miope scommettere le prestazioni complessive della tua lingua su un "compilatore sufficientemente avanzato".


Non intè praticamente immutabile in tutte le lingue.
Scott Whitlock,

6
@ScottWhitlock Vedo perché potresti pensarlo, ma in generale i tipi primitivi sono tipi di valore immutabili. Nessuna lingua sana ti consente di modificare il valore del numero sette. Tuttavia, molte lingue consentono di riassegnare una variabile che contiene un valore di tipo primitivo a un valore diverso. Nei linguaggi simili a C, una variabile è una posizione di memoria denominata e si comporta come un puntatore. Una variabile non è uguale al valore a cui punta. Un intvalore è immutabile, ma una intvariabile no.
amon

1

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 ma questo suona come una programmazione basata su prototipi, che è decisamente OOP.
Michael

1
@ScottWhitlock la domanda è se se hai int b = a, puoi fare qualcosa in b che cambierà il valore di a. Ci sono state alcune implementazioni linguistiche in cui ciò è possibile, ma è generalmente considerato patologico e indesiderato, a differenza del fare lo stesso per un array.
Casuale 832,

2

La maggior parte delle implementazioni di cui sono a conoscenza impone tre restrizioni a tali classi che consentono al compilatore di utilizzare in modo efficiente i tipi primitivi come rappresentazione sottostante la maggior parte delle volte. Queste restrizioni sono:

  • Immutabilità
  • Finalità (impossibile derivarne)
  • Digitazione statica

Le situazioni in cui un compilatore deve inserire una primitiva in un oggetto nella rappresentazione sottostante sono relativamente rare, come quando un Objectriferimento lo punta.

Questo aggiunge un bel po 'di gestione dei casi speciali nel compilatore, ma non si limita solo a un mitico compilatore super-avanzato. Tale ottimizzazione è nei compilatori di produzione reali nelle principali lingue. Scala consente persino di definire le proprie classi di valore.


1

In Smalltalk tutti (int, float, ecc.) Sono oggetti di prima classe. L' unico caso speciale è che SmallInteger è codificato e trattato in modo diverso dalla Macchina Virtuale per motivi di efficienza, e quindi la classe SmallInteger non ammetterà le sottoclassi (che non è una limitazione pratica). Nota che questo non richiede alcuna considerazione speciale da parte del programmatore poiché la distinzione è circoscritta a routine automatiche come la generazione di codice o la garbage collection.

Sia il compilatore Smalltalk (codice sorgente -> bytecode VM) che il nativizer VM (bytecodes -> codice macchina) ottimizzano il codice generato (JIT) in modo da ridurre la penalità delle operazioni elementari con questi oggetti di base.


1

Stavo progettando una lingua OO e un runtime (questo non è riuscito per una serie completamente diversa di motivi).

Non c'è nulla di intrinsecamente sbagliato nel creare cose come le classi vere int; in effetti ciò semplifica la progettazione del GC in quanto ora esistono solo 2 tipi di intestazioni di heap (classe e matrice) anziché 3 (classe, matrice e primitiva) [il fatto che possiamo unire classe e matrice dopo ciò non è rilevante ].

Il vero caso importante che i tipi primitivi dovrebbero avere per lo più metodi finali / sigillati (+ conta davvero, ToString non tanto). Ciò consente al compilatore di risolvere staticamente quasi tutte le chiamate alle funzioni stesse e incorporarle. Nella maggior parte dei casi questo non ha importanza come comportamento di copia (ho scelto di rendere disponibile l'incorporamento a livello di lingua [come ha fatto .NET]), ma in alcuni casi se i metodi non sono sigillati il ​​compilatore sarà costretto a generare la chiamata a la funzione utilizzata per implementare int + int.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.