Quali sono gli avvertimenti per l'implementazione di tipi fondamentali (come int) come classi?

27

Quando si progetta e si impianta un linguaggio di programmazione orientato agli oggetti, ad un certo punto si deve fare una scelta sull'implementazione di tipi fondamentali (come int , float , double o equivalenti) come classi o qualcos'altro. Chiaramente, le lingue nella famiglia C hanno la tendenza non a definirle come classi (Java ha tipi primitivi speciali, C # li implementa come strutture immutabili, ecc.)

Riesco a 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 Liskov appropriati del tipo root. Pertanto, evitiamo di complicare la lingua con boxing / unboxing (esplicito o implicito), tipi di wrapper, regole di varianza speciali, comportamento speciale, ecc.

Naturalmente, posso parzialmente capire perché i progettisti di linguaggio decidono il loro modo di agire: le istanze di classe tendono ad avere un overhead spaziale (perché le istanze possono contenere un vtable o altri metadati nel loro layout di memoria), quelle primitive / structs don ' t necessario avere (se la lingua non consente l'ereditarietà su quelli).

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

In genere ho pensato 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) è provata essere strettamente locale.

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

    
posta Theodoros Chatzigiannakis 01.01.2015 - 14:50
fonte

6 risposte

19

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

In primo luogo, non è solo un "overhead spaziale". Rendere i primitivi in box / heap-allocati ha anche dei costi di performance. C'è un'ulteriore pressione sul GC per allocare e raccogliere quegli oggetti. Questo vale doppiamente se gli "oggetti primitivi" sono immutabili, come dovrebbero essere. Poi ci sono più errori di cache (sia a causa della indiretta sia perché meno dati si inseriscono in una data 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 "caricare direttamente il valore".

In secondo luogo, l'analisi di fuga non è una polvere fatata più veloce. Si applica solo a valori che, beh, non sfuggono. È certamente utile ottimizzare i calcoli locali (come contatori di loop e risultati intermedi di calcoli) e fornirà vantaggi misurabili. Ma una maggioranza molto più ampia di valori vive nei campi degli oggetti e degli array. Certo, quelli possono essere soggetti all'analisi di fuga, ma dato che sono di solito dei tipi di riferimento mutabili, qualsiasi aliasing di essi presenta una sfida significativa all'analisi di fuga, che ora deve dimostrare che quegli alias (1) non sfuggono a nessuno dei due e (2) non fare la differenza allo scopo di eliminare le allocazioni.

Dato che chiamare qualsiasi metodo (inclusi i getter) o passare un oggetto come argomento a qualsiasi altro metodo può aiutare l'oggetto a scappare, avrai bisogno di analisi interprocedurale in tutti i casi tranne i 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'allontanamento delle risorse. Quando un oggetto che contiene un esc esegue il escape, l'analisi di escape cessa di essere applicata anche a int. Dì addio ai campi primitivi efficienti

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

Questo non vuol dire che tali lingue non possano essere valide. Chiaramente lo sono. Non dare per scontato che sarà linea per linea veloce come le lingue con primitive dedicate. In altre parole, non illuderti delle visioni di un compilatore sufficientemente intelligente .

    
risposta data 01.01.2015 - 15:27
fonte
27

Is spatial efficiency (and improved spatial locality, especially in large arrays) the only reason why fundamental types are often not classes?

No.

L'altro problema è che i tipi fondamentali tendono ad essere utilizzati dalle operazioni fondamentali. Il compilatore deve sapere che int + int non verrà compilato in una chiamata di funzione, ma a qualche istruzione CPU elementare (o codice byte equivalente). A quel punto, se si dispone di int come oggetto normale, si dovrà comunque rimuovere definitivamente l'oggetto.

Quel tipo di operazioni anche non funziona bene con i sottotitoli. Non è possibile inviare a un'istruzione della CPU. Non puoi inviare da un'istruzione della CPU. Voglio dire che l'intero punto della sottotipizzazione è così puoi usare un D dove puoi B . Le istruzioni della CPU non sono polimorfiche. Per fare in modo che i primitivi lo facciano, devi avvolgere le loro operazioni con la logica di spedizione che costa più volte la quantità di operazioni come semplice aggiunta (o qualsiasi altra cosa). Il vantaggio di avere int come parte della gerarchia di tipi diventa un po 'discutibile quando è sigillato / finale. E questo sta ignorando tutti i mal di testa con la logica di invio per gli operatori binari ...

Fondamentalmente, i tipi primitivi dovrebbero avere un sacco di regole speciali su come il compilatore li gestisce e cosa l'utente può fare con i loro tipi comunque , quindi è spesso più semplice trattali come completamente distinti.

    
risposta data 01.01.2015 - 15:13
fonte
4

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

  • Vuoi che i tipi definiti dall'utente siano in grado di ereditare da 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 un int avrà una dimensione fissa specifica o che nessun metodo è stato sovrascritto, ed è un problema di sicurezza perché la semantica di int s potrebbe essere sovvertita (si consideri un intero che è uguale a qualsiasi numero, o che cambia il suo valore anziché essere immutabile).

  • I tuoi tipi primitivi hanno supertipi e vuoi avere variabili con tipo di supertipo di tipo primitivo. Ad esempio, supponi che int s sia Hashable e vuoi dichiarare una funzione che accetta un parametro Hashable che potrebbe ricevere oggetti regolari ma anche int s.

    Questo può essere "risolto" rendendo illegali questi tipi: sbarazzarsi di sottotipi e decidere che le interfacce non sono tipi ma vincoli di tipo. Ovviamente questo riduce l'espressività del tuo sistema di tipi, e un tale sistema di tipi non sarebbe più chiamato object-oriented. Vedi Haskell per una lingua che usa questa strategia. Il C ++ è a metà strada perché i tipi primitivi non hanno supertipi.

    L'alternativa è il pugilato completo o parziale di tipi fondamentali. Il tipo di boxe non deve essere visibile all'utente. In sostanza, si definisce un tipo di box interno per ogni tipo fondamentale e conversioni implicite tra il tipo in scatola e quello fondamentale. Questo può diventare imbarazzante se i tipi in scatola hanno una semantica diversa. Java presenta due problemi: i tipi di box hanno un concetto di identità, mentre i primitivi hanno solo un concetto di equivalenza di valore, ei tipi di box 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 nullable tutti gli oggetti per impostazione predefinita.

  • Non hai funzionalità di digitazione statica. Una variabile può contenere qualsiasi valore, inclusi tipi o oggetti primitivi. Pertanto, tutti i tipi primitivi devono essere sempre inseriti in una casella per garantire una digitazione strong.

Le lingue con tipizzazione statica fanno bene a usare i tipi primitivi laddove possibile e ricadono sui tipi di box solo come ultima risorsa. Sebbene molti programmi non siano tremendamente sensibili alle prestazioni, ci sono casi in cui la dimensione e il trucco dei tipi primitivi sono estremamente rilevanti: si pensi al numero di crunch su larga scala in cui è necessario inserire miliardi di punti dati in memoria. Passare da double a float potrebbe essere una strategia di ottimizzazione dello spazio percorribile in C, ma non avrebbe alcun effetto se tutti i tipi numerici fossero sempre in scatola (e quindi sprecare almeno la metà della loro memoria per un puntatore del meccanismo di invio) . Quando i tipi primitivi in scatola sono usati localmente, è abbastanza semplice rimuovere la boxe attraverso l'uso di intrinseche del compilatore, ma sarebbe miope scommettere le prestazioni generali della tua lingua su un "compilatore sufficientemente avanzato".

    
risposta data 01.01.2015 - 17:09
fonte
2

La maggior parte delle implementazioni sono a conoscenza dell'imposizione di tre restrizioni su tali classi che consentono al compilatore di utilizzare in modo efficiente i tipi primitivi come rappresentazione sottostante per la maggior parte del tempo. Queste restrizioni sono:

  • Immutabilità
  • Finalità (impossibile da derivare)
  • Tipizzazione statica

Le situazioni in cui un compilatore ha bisogno di inscatolare una primitiva in un oggetto nella rappresentazione sottostante sono relativamente rare, come quando un riferimento Object lo sta puntando.

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

    
risposta data 01.01.2015 - 18:01
fonte
1

In Smalltalk tutti (int, float, ecc.) sono oggetti di prima classe. Il caso speciale solo è che gli SmallInteger sono codificati e trattati diversamente dalla Macchina Virtuale per motivi di efficienza, e quindi la classe SmallInteger non ammetterà le sottoclassi (che non è una limitazione pratica). Si noti che questo non richiede alcuna considerazione particolare da parte del programmatore in quanto la distinzione è circoscritta a routine automatiche come la generazione di codice o la garbage collection.

Sia Smalltalk Compiler (codice sorgente - > VM bytecodes) che il nativizzatore VM (bytecodes - > code machine) ottimizzano il codice generato (JIT) in modo da ridurre la penalità delle operazioni elementari con questi oggetti di base.

    
risposta data 01.01.2015 - 19:50
fonte
1

Stavo progettando un linguaggio OO langauge e runtime (ciò non ha funzionato per una serie di motivi completamente diversa).

Non c'è nulla di intrinsecamente sbagliato nel fare cose come le vere classi int; infatti questo rende il GC più facile da progettare in quanto ora ci sono solo 2 tipi di header di heap (class & array) piuttosto che 3 (class, array e primitive) [il fatto che possiamo unire class & array dopo questo non è rilevante].

Il vero caso importante in cui i tipi primitivi dovrebbero avere metodi per lo più definitivi / sigillati (+ conta davvero, ToString non così tanto). Ciò consente al compilatore di risolvere in modo statico quasi tutte le chiamate alle funzioni stesse e di incorporarle. Nella maggior parte dei casi questo non ha importanza come comportamento di copia (ho scelto di rendere l'embedding disponibile a livello di linguaggio [così come .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.

    
risposta data 02.01.2015 - 05:19
fonte