Perché i tradizionali linguaggi OOP statici forti impediscono l'ereditarietà delle primitive?

53

Perché questo è OK e per lo più previsto:

abstract type Shape
{
   abstract number Area();
}

concrete type Triangle : Shape
{
   concrete number Area()
   {
      //...
   }
}

... mentre questo non è OK e nessuno si lamenta:

concrete type Name : string
{
}

concrete type Index : int
{
}

concrete type Quantity : int
{
}

La mia motivazione è la massimizzazione dell'uso del sistema di tipi per la verifica della correttezza della compilazione.

PS: sì, ho letto questo e il wrapping è una soluzione hacky.

    
posta Den 10.08.2016 - 11:37
fonte

10 risposte

83

Suppongo che stiate pensando a linguaggi come Java e C #?

In quelle lingue le primitive (come int ) sono fondamentalmente un compromesso per le prestazioni. Non supportano tutte le funzionalità degli oggetti, ma sono più veloci e con meno sovraccarico.

Affinché gli oggetti supportino l'ereditarietà, ogni istanza deve "sapere" in fase di esecuzione di quale classe è un'istanza di. In caso contrario, i metodi sottoposti a override non possono essere risolti in fase di runtime. Per gli oggetti ciò significa che i dati di istanza sono archiviati in memoria insieme a un puntatore all'oggetto classe. Se tali informazioni dovessero essere memorizzate insieme ai valori primitivi, i requisiti di memoria si gonfieranno. Un valore intero a 16 bit richiederebbe i suoi 16 bit per il valore e inoltre 32 o 64 bit di memoria per un puntatore alla sua classe.

A parte l'overhead della memoria, ci si aspetterebbe anche di poter eseguire l'override delle operazioni comuni su primitive come gli operatori aritmetici. Senza sottotipizzazione, operatori come + possono essere compilati in una semplice istruzione di codice macchina. Se può essere sovrascritto, è necessario risolvere i metodi in fase di esecuzione, un'operazione molto più costosa. (È possibile sapere che C # supporta l'overloading dell'operatore, ma non è la stessa cosa: l'overloading dell'operatore viene risolto in fase di compilazione, quindi non esiste una penalità di runtime predefinita.)

Le stringhe non sono primitive ma sono ancora "speciali" nel modo in cui sono rappresentate in memoria. Ad esempio sono "internati", il che significa che due valori letterali di stringhe uguali possono essere ottimizzati allo stesso riferimento. Ciò non sarebbe possibile (o almeno molto meno efficace) se le istanze di stringa dovessero tenere traccia della classe.

Ciò che descriveresti sarebbe sicuramente utile, ma il suo supporto richiederebbe un overhead delle prestazioni per ogni utilizzo di primitive e stringhe, anche quando non sfruttano l'ereditarietà.

La lingua Smalltalk fa (credo) consente la sottoclasse degli interi. Ma quando Java è stato progettato, Smalltalk è stato considerato troppo lento, e il sovraccarico di avere tutto essere un oggetto è stato considerato uno dei motivi principali. Java ha sacrificato eleganza e purezza concettuale per ottenere prestazioni migliori.

    
risposta data 10.08.2016 - 13:24
fonte
20

Ciò che alcune lingue propongono non è una sottoclasse, ma sottotipizzazione . Ad esempio, Ada ti consente di creare tipi derivati o sottotipi . La sezione Ada Programming / Type System vale la pena di leggere per comprendere tutti i dettagli. Puoi limitare l'intervallo di valori, che è ciò che desideri la maggior parte del tempo:

 type Angle is range -10 .. 10;
 type Hours is range 0 .. 23; 

Puoi utilizzare entrambi i tipi come numeri interi se li converti in modo esplicito. Nota anche che non puoi usarne uno al posto di un altro, anche quando gli intervalli sono strutturalmente equivalenti (i tipi sono controllati dai nomi).

 type Reference is Integer;
 type Count is Integer;

I tipi sopra indicati sono incompatibili, anche se rappresentano lo stesso intervallo di valori.

(ma puoi usare Unchecked_Conversion; non dirlo alle persone che ti ho detto)

    
risposta data 10.08.2016 - 12:20
fonte
16

Penso che potrebbe essere una domanda X / Y. Punti salienti, dalla domanda ...

My motivation is maximising the use of type system for compile-time correctness verification.

... e da il tuo commento che elabora:

I don't want to be able to substitute one for another implicitly.

Scusami se mi manca qualcosa, ma ... Se questi sono i tuoi obiettivi, allora perché sulla Terra stai parlando dell'eredità? La sostituibilità implicita è ... come ... la sua intera cosa. Sai, il Principio di sostituzione di Liskov?

Quello che sembri voler, in realtà, è il concetto di un "typedef strong" - per cui qualcosa "è", ad es. un int in termini di intervallo e rappresentazione ma non possono essere sostituiti in contesti che prevedono un int e viceversa. Suggerirei di cercare informazioni su questo termine e qualunque sia la lingua scelta (s) potrebbe chiamarlo. Di nuovo, è praticamente il contrario dell'ereditarietà.

E per coloro che potrebbero non gradire una risposta X / Y, penso che il titolo potrebbe essere ancora applicabile con riferimento al LSP. I tipi primitivi sono primitivi perché fanno qualcosa di molto semplice, e questo è tutto ciò che fanno . Consentire loro di essere ereditati e quindi di rendere infiniti i loro possibili effetti porterebbe a una grande sorpresa nella migliore e fatale violazione di LSP nel peggiore dei casi. Se posso assumere ottimisticamente Thales Pereira non mi dispiacerà citare questo commento fenomenale:

There is the added problem that If someone was able to inherit from Int, you would have innocent code like "int x = y + 2" (where Y is the derived class) that now writes a log to the Database, opens a URL and somehow resurrect Elvis. Primitive types are supposed to be safe and with more or less guaranteed, well-defined behavior.

Se qualcuno vede un tipo primitivo, in un linguaggio sano, presume giustamente che farà sempre la sua piccola cosa, molto bene, senza sorprese. I tipi primitivi non hanno dichiarazioni di classe disponibili che segnalino se possono o meno essere ereditati e che i loro metodi sono sovrascritti. Se lo fossero, sarebbe davvero molto sorprendente (e interromperà totalmente la compatibilità all'indietro, ma sono consapevole che è una risposta all'indietro a "perché X non è stato progettato con Y").

... anche se, come Mooing Duck ha sottolineato in risposta, i linguaggi che consentono il sovraccarico dell'operatore consentono all'utente di confondersi in modo simile o uguale se lo desiderano, quindi è dubbio che questo ultimo argomento sia valido. E smetterò di riassumere i commenti degli altri ora, heh.

    
risposta data 10.08.2016 - 22:19
fonte
4

Per consentire l'ereditarietà con la distribuzione virtuale 8, che è spesso considerata piuttosto auspicabile nella progettazione dell'applicazione), è necessario disporre di informazioni sul tipo di runtime. Per ogni oggetto, è necessario memorizzare alcuni dati relativi al tipo di oggetto. Una primitiva, per definizione, manca di queste informazioni.

Esistono due linguaggi OOP mainstream (gestiti, eseguiti su una VM) con primitive: C # e Java. Molte altre lingue non hanno primitive in primo luogo, o usano un ragionamento simile per permetterle / usarle.

I primitivi sono un compromesso per le prestazioni. Per ciascun oggetto, è necessario spazio per l'intestazione dell'oggetto (in Java, in genere 2 * 8 byte su macchine virtuali a 64 bit), oltre ai relativi campi, oltre all'eventuale riempimento (in Hotspot, ogni oggetto occupa un numero di byte multiplo di 8). Quindi un int come oggetto richiederebbe almeno 24 byte di memoria da conservare, invece di solo 4 byte (in Java).

Quindi, sono stati aggiunti tipi primitivi per migliorare le prestazioni. Rendono un sacco di cose più facili. Cosa significa a + b se entrambi sono sottotipi di int ? È necessario aggiungere una sorta di dispathcing per scegliere l'aggiunta corretta. Questo significa invio virtuale. Avere la possibilità di utilizzare un opcode molto semplice per l'aggiunta è molto, molto più veloce e consente ottimizzazioni in fase di compilazione.

String è un altro caso. Sia in Java che in C #, String è un oggetto. Ma in C # è sigillato, e in Java è definitivo. Questo perché entrambe le librerie standard Java e C # richiedono che String s sia immutabile e la loro sottoclasse interromperà questa immutabilità.

In caso di Java, la VM può (e lo fa) stringhe interne e "raggrupparle", consentendo prestazioni migliori. Funziona solo quando le stringhe sono veramente immutabili.

Inoltre, uno raramente deve sottoclasse i tipi primitivi. Finché i primitivi non possono essere sottoclassi, ci sono un sacco di cose belle che la matematica ci dice su di loro. Ad esempio, possiamo essere sicuri che l'aggiunta sia commutativa e associativa. Questo è qualcosa che ci dice la definizione matematica degli interi. Inoltre, in molti casi, possiamo indurre facilmente gli invarianti sui loop tramite l'induzione. Se consentiamo la sottoclasse di int , perdiamo quegli strumenti che ci fornisce la matematica, perché non possiamo più garantire che alcune proprietà siano valide. Quindi, direi che l'abilità non di essere in grado di sottoclasse i tipi primitivi è in realtà una buona cosa. Meno cose che qualcuno può rompere, più un compilatore può spesso dimostrare di essere autorizzato a fare certe ottimizzazioni.

    
risposta data 10.08.2016 - 23:03
fonte
4

Nei linguaggi OOP statici forti, la sottotipizzazione è vista principalmente come un modo per estendere un tipo e per sovrascrivere i metodi correnti del tipo.

Per fare ciò, gli 'oggetti' contengono un puntatore al loro tipo. Questo è un sovraccarico: il codice in un metodo che utilizza un'istanza Shape deve prima accedere alle informazioni sul tipo di quell'istanza, prima che conosca il metodo corretto Area() da chiamare.

Una primitiva tende a consentire solo le operazioni su di essa che possono tradursi in istruzioni di linguaggio macchina singola e non portano con sé alcuna informazione di tipo. Rendere più lento un numero intero in modo che qualcuno possa creare una sottoclasse non è abbastanza attraente per bloccare qualsiasi lingua che lo ha fatto diventando mainstream.

Quindi la risposta a:

Why do mainstream strong static OOP languages prevent inheriting primitives?

è:

  • C'era poca richiesta
  • E avrebbe reso la lingua troppo lenta
  • Sottotipo è stato visto principalmente come un modo per estendere un tipo, piuttosto che un modo per migliorare il controllo statico del tipo (definito dall'utente).

Tuttavia, stiamo iniziando a ottenere linguaggi che consentono il controllo statico basato su proprietà di variabili diverse da "digitare", ad esempio F # ha "dimensione" e "unità" in modo che non sia possibile, ad esempio, aggiungere una lunghezza in un'area.

Esistono anche linguaggi che consentono "tipi definiti dall'utente" che non cambiano (o cambiano) il tipo di un tipo, ma aiutano solo con il controllo statico dei tipi; vedi la risposta di coredump.

    
risposta data 10.08.2016 - 13:39
fonte
3

Non sono sicuro che trascuri qualcosa qui, ma la risposta è piuttosto semplice:

  1. La definizione di primitive è: i valori primitivi non sono oggetti, i tipi primitivi non sono tipi di oggetto, le primitive non fanno parte del sistema di oggetti.
  2. L'ereditarietà è una funzionalità del sistema di oggetti.
  3. Ergo, le primitive non possono prendere parte all'ereditarietà.

Si noti che ci sono solo due solidi linguaggi OOP statici che anche hanno primitive, AFAIK: Java e C ++. (In realtà, non sono nemmeno sicuro di quest'ultimo, non so molto del C ++ e quello che ho trovato durante la ricerca è stato confuso.)

In C ++, le primitive sono fondamentalmente un ereditario ereditato (pun intended) da C. Quindi, non prendono parte al sistema di oggetti (e quindi all'ereditarietà) perché C non ha né un sistema di oggetti né un'ereditarietà.

In Java, le primitive sono il risultato di un tentativo errato di migliorare le prestazioni. I primitivi sono anche gli unici tipi di valore nel sistema, è infatti impossibile scrivere i tipi di valore in Java ed è impossibile che gli oggetti siano tipi di valore. Quindi, a parte il fatto che le primitive non prendono parte al sistema degli oggetti e quindi l'idea di "ereditarietà" non ha nemmeno senso, anche se se potresti ereditare da loro, non lo faresti essere in grado di mantenere il "valore". Questo è diverso da ad es. C♯ che ha ha tipi di valore ( struct s), che tuttavia sono oggetti.

Un'altra cosa è che non essere in grado di ereditare non è in realtà univoco per i primitivi. In C♯, struct s eredita implicitamente da System.Object e può implementare interface s, ma non possono né ereditarli né ereditati da class es o struct s. Inoltre, sealed class es non può essere ereditato da. In Java, final class es non può essere ereditato da.

tl; dr :

Why do mainstream strong static OOP languages prevent inheriting primitives?

    I primitivi
  1. non fanno parte del sistema oggetto (per definizione, se lo fossero, non sarebbero primitivi), l'idea di ereditarietà è legata al sistema degli oggetti, ergo l'eredità primitiva è una contraddizione in termini
  2. Le primitive
  3. non sono univoche, molti altri tipi non possono essere ereditati ( final o sealed in Java o C♯, struct s in C♯, case class es in Scala)
risposta data 10.08.2016 - 22:07
fonte
2

Joshua Bloch in "Java efficace" raccomanda di progettare esplicitamente per ereditarietà o di proibirlo. Le classi primitive non sono progettate per l'ereditarietà perché sono progettate per essere immutabili e consentire l'ereditarietà potrebbe cambiarle in sottoclassi, quindi interrompere il principio Liskov e sarebbe una fonte di molti bug.

Comunque, perché questo un hacky soluzione alternativa? Dovresti davvero preferire la composizione all'ereditarietà. Se il motivo è la performance rispetto a un punto e la risposta alla tua domanda è che non è possibile inserire tutte le funzionalità in Java perché richiede tempo per analizzare tutti i diversi aspetti dell'aggiunta di una funzione. Ad esempio, Java non aveva Generics prima di 1.5.

Se hai molta pazienza, allora sei fortunato perché c'è un piano per aggiungere classi di valore in Java che ti permetterà di creare classi di valore che ti aiuteranno ad aumentare le prestazioni e nello stesso tempo ti daranno più flessibilità.

    
risposta data 10.08.2016 - 17:42
fonte
2

A livello astratto, puoi includere tutto ciò che vuoi in una lingua che stai progettando.

A livello di implementazione, è inevitabile che alcune di queste cose siano più semplici da implementare, alcune saranno complicate, alcune possono essere velocizzate, altre sono più lente e così via. Per tener conto di ciò, i progettisti devono spesso prendere decisioni difficili e compromessi.

A livello di implementazione, uno dei modi più veloci che abbiamo trovato per accedere a una variabile è scoprire il suo indirizzo e caricare il contenuto di quell'indirizzo. Ci sono istruzioni specifiche nella maggior parte delle CPU per caricare i dati dagli indirizzi e quelle istruzioni di solito devono sapere quanti byte devono caricare (uno, due, quattro, otto, ecc.) E dove mettere i dati che caricano (registro singolo, registro coppia, registro esteso, altra memoria, ecc.). Conoscendo la dimensione di una variabile, il compilatore può sapere esattamente quale istruzione emettere per gli usi di quella variabile. Non conoscendo la dimensione di una variabile, il compilatore dovrebbe ricorrere a qualcosa di più complicato e probabilmente più lento.

A livello astratto, il punto di sottotipizzazione deve essere in grado di utilizzare istanze di un tipo in cui è previsto un tipo uguale o più generale. In altre parole, il codice può essere scritto che si aspetta un oggetto di un tipo particolare o qualcosa di più derivato, senza sapere in anticipo quale sarebbe esattamente questo. E chiaramente, poiché più tipi derivati possono aggiungere più membri dati, un tipo derivato non ha necessariamente gli stessi requisiti di memoria dei suoi tipi base.

A livello di implementazione, non esiste un modo semplice per una variabile di una dimensione predeterminata di contenere un'istanza di dimensioni sconosciute e di accedere a un sistema che normalmente si chiama efficiente. Ma c'è un modo per spostare le cose un po 'e usare una variabile per non memorizzare l'oggetto, ma per identificare l'oggetto e lasciare che l'oggetto venga memorizzato da qualche altra parte. In questo modo è un riferimento (ad esempio un indirizzo di memoria) - un livello aggiuntivo di riferimento indiretto che assicura che una variabile abbia solo bisogno di contenere qualche tipo di informazione a dimensione fissa, purché possiamo trovare l'oggetto attraverso tali informazioni. Per riuscirci, abbiamo solo bisogno di caricare l'indirizzo (dimensione fissa) e quindi possiamo lavorare come al solito usando gli offset dell'oggetto che sappiamo essere validi, anche se quell'oggetto ha più dati sugli offset che non conosciamo. Possiamo farlo perché non ci occupiamo più dei suoi requisiti di archiviazione quando accediamo ad esso.

A livello astratto, questo metodo consente di memorizzare un (riferimento a a) string in una variabile object senza perdere le informazioni che lo rendono un string . Va bene che tutti i tipi funzionino così e potresti anche dire che è elegante sotto molti aspetti.

Tuttavia, a livello di implementazione, il livello extra di indirizzamento indiretto richiede più istruzioni e sulla maggior parte delle architetture rende ogni accesso all'oggetto un po 'più lento. Puoi consentire al compilatore di spremere più prestazioni da un programma se includi nella tua lingua alcuni tipi comunemente usati che non hanno quel livello aggiuntivo di riferimento indiretto (il riferimento). Ma rimuovendo quel livello di riferimento indiretto, il compilatore non può più permettervi di sottotitolare in un modo sicuro per la memoria. Questo perché se aggiungi altri membri di dati al tuo tipo e assegni a un tipo più generale, tutti i membri di dati aggiuntivi che non rientrano nello spazio allocato per la variabile di destinazione verranno tagliati via.

    
risposta data 11.08.2016 - 18:43
fonte
1

In generale

Se una classe è astratta (metafora: una scatola con buche), è OK (anche richiesto di avere qualcosa di utile!) per "riempire la / e buca / e", ecco perché sottoclassiamo le classi astratte.

Se una classe è concreta (metafora: una scatola piena), non è corretto modificare l'esistente perché se è pieno, è pieno. Non abbiamo spazio per aggiungere qualcosa di più all'interno della scatola, ecco perché non dovremmo sottoclasse le lezioni concrete.

Con i primitivi

I primitivi sono classi concrete di progettazione. Rappresentano qualcosa che è ben noto, completamente definito (non ho mai visto un tipo primitivo con qualcosa di astratto, altrimenti non è più un primitivo) e ampiamente utilizzato attraverso il sistema. Consentire una sottoclasse di un tipo primitivo e fornire la propria implementazione ad altri che si basano sul comportamento progettato delle primitive può causare molti effetti collaterali e ingenti danni!

    
risposta data 10.08.2016 - 13:53
fonte
1

Di solito l'ereditarietà non è la semantica che vuoi, perché non puoi sostituire il tuo tipo speciale ovunque sia previsto un primitivo. Per prendere a prestito dal tuo esempio, un Quantity + Index non ha senso semanticamente, quindi una relazione di ereditarietà è la relazione sbagliata.

Tuttavia, diverse lingue hanno il concetto di un tipo di valore che esprime il tipo di relazione che stai descrivendo. Scala è un esempio. Un tipo di valore utilizza una primitiva come rappresentazione sottostante, ma ha un'identità di classe diversa e operazioni all'esterno. Questo ha l'effetto di estendere un tipo primitivo, ma è più una composizione che una relazione di ereditarietà.

    
risposta data 12.08.2016 - 18:10
fonte

Leggi altre domande sui tag