Da non crederci, ma oggi ho lanciato l'installazione
Windows 98 (dopo almeno 5 anni di polvere nel mobile).
Ovviamente non sono stato preso da un raptus di
follia, nè di amore per l'antiquariato (sì, lo so che ce ne sono ancora molti in
giro), ma mi è stato necessario per effettuare alcuni test di un'applicazione
che sto sviluppando in questi giorni.
Lanciato il setup sotto Virtual Pc, ho continuato a
farmi gli affari miei, salvo notare due piccoli particolari che mi hanno fatto
pensare a come sono cambiate le cose (che non vuol dire migliorate).
Primo. Durante la richiesta del numero di serie, la
maschera indicava esplicitamente qualcosa del tipo "Inserire il numero di serie
scritto sul retro del CD senza includere i
trattini".
Secondo.Durante la fase finale (registrazione e
salvataggio delle impostazioni) la didascalia a sinistra indicava esplicitamente
qualcosa del tipo "Durante questo procedimento il computer potrebbe riavviarsi.
Se il computer rimane bloccato a lungo, spegnere e
riaccendere.
Onestamente non so se ridere, o pensare che
riboostrappare un computer bloccato ed inserire numeri di serie è ormai
diventato un'abitudine che non ha bisogno di ulteriori,
esplicite spiegazioni ...
Tra ieri e
oggi sono usciti due post (Joe Duffy e Somasegar) a proposito
di un cambiamento nella gestione dei Nullable Types.
Le
modifiche, che dovrebbero essere presenti già nelle prossime CTP (si parla di Agosto),
coinvolgono principalmente le situazioni di boxing/unboxing.
Riassumendo,
dovrebbe accadere quanto segue (tutti gli esempi riguardano un Nullable<Int32>, ma il tutto si applica
ad ogni Nullable<T> where T :
ValueType
):
- Un Nullable<Int32>
x sottoposto ad operazione di boxing
genera:
- null se x == null
(x.HasValue == false)
- un
boxed Int32 in caso contrario
- E'
possibile effettuare unboxing da un boxed int ad un Nullable<Int32>
, ottenendo un tipo nullabile con valore interno corrispondente al
valore del boxed int.
A corollario di quanto
detto, il codice seguente :
Nullable<Int32> i =
null;
Object o = i; // boxing here
Console.WriteLine(o == null);
scriverà true a
console (con la Beta2 che ho installato il risultato è false).
Analogamente, il codice che segue:
Int32 i = 78;
Object o = i; // boxing here
Nullable<Int32> j =
(Nullable<Int32>) o; // unboxing here.
sarà
perfettamente valido, producendo un Nullable<Int32> , al contrario di quanto
accade oggi con la Beta2, che genera una InvalidCastException
alla terza riga.
Citando
direttamente dalla fonte, infatti (sottolineatura mia):
The outcome is that the
Nullable type is now a new basic runtime intrinsic
. It is still declared as a generic
value-type, yet the runtime treats it special
. One of the foremost changes is that
boxing now honors the null state. A
Nullabe int now boxes to become not a boxed Nullable int but a boxed int (or a
null reference as the null state may indicate.) Likewise, it is now possible to unbox
any kind of boxed value-type into its Nullable type
equivalent.
Il tutto risulta, a mio parere, estremamente più intuitivo.
Comunque, va sottolineato come evidentemente il feedback ricevuto da
Microsoft sia stato molto influente e abbastanza pressante, tanto da
indurre ad un cambiamento decisamente tardivo, se consideriamo che l'uscita
della RTM è ormai prossima e che le modifiche effettuate (leggete i link) non
siano state proprio minimali.
Ho letto il post di Paolo relativo al (non)funzionamento di alcuni programmi sotto utente non
amministratore, e non posso che concordare con
lui.
Ormai sono alcuni mesi che ho
adottato l'abitudine di utilizzare un utente User sui miei
computer.
Dovendo tirare le somme, adesso,
della mia esperienza, devo dire che è stata meno traumatica di come me l'ero
prefigurata e che indubbiamente i vantaggi superano di molto lo sforzo iniziale,
dovuto magari proprio al malfunzionamento di alcuni programmi.
Il discorso cambia quando si parla di diffondere
questa pratica.
Per la mia esperienza (lo sottolineo due
volte, perchè magari la realtà è differente) ci sono in giro diverse situazioni
di informatizzazione forzata: intendo dire posti in cui l'utilizzo
dello strumento informatico è in qualche modo subìto in modo passivo, e
manca un'adeguata preparazione del personale che lo utilizza.
Manca, in quei casi, la competenza necessaria per
capire l'importanza di questa abitudine: talvolta il concetto di
privilegi di un utente non è assolutamente noto, la password è
quella cosa che mi permette di accendere il pc , e via
dicendo.
Però, ammesso e non concesso di porre rimedio a
questa disinformazione, il problema è risolto ?
Giusto qualche giorno fa mi è capitato di
installare un'applicazione che ho scritto presso un ufficio.
Tra una parola e l'altra, ho proposto di adottare la
pratica di utilizzare utenti a bassi privilegi.
Domanda: Perchè ?
Risposta:
bla bla bla.
Domanda: Ma poi funziona tutto come prima
?
Risposta:
bla bla bla ... Dipende ... Facciamo una prova
?
Prova: il programma di contabilità che stanno usando
(pagato diverse migliaia di euro) si pianta inesorabilmente.
Risultato: ovviamente come non detto, si lascia
tutto così come sta, non è un problema mio (semmai di chi ha prodotto il
software).
Sono due le cose da evidenziare, secondo
me.
Primo. Se l'utente non è informato, difficilmente adotterà
una pratica "fuori default". E quindi difficilmente i difetti del software che
compra verranno a galla.
Secondo. In alcuni casi non c'è quasi la
possibilità di scelta sull'acquisto del software. Non stiamo parlando di
programmi "general purpose", ma di applicazioni molto, molto specifiche, spesso
sviluppate ad hoc, talvolta scelte obbligate addirittura per motivi
pseudo-legali. Quello che hai, te lo tieni, perchè mancano le
alternative.
Se sei
costretto ad utilizzare un programma, per forza di cose non hai troppa
libertà nel modificare il suo ambiente di esecuzione. In pratica sei costretto a
fare in modo che il programma funzioni. Non la software house che l'ha
prodotto (che ha una sorta di monopolio), ma tu che lo usi.
Sigh.
Non so come mi è ritornata in mente questa storia,
ma tant'è ... la racconto lo stesso.
Da piccolo (credo più o meno 8 o 9 anni) la mia
esperienza informatica era con un vecchio 8088 che mio padre aveva comprato, per
motivi di lavoro immagino, ed aveva portato a casa.
Al 90% mi davo ai giochi che avevo a disposizione
(rigorosamente su floppy da 5 pollici e 1 quarto).
Il 10% rimanente smanettavo col DOS: crea una
directory di qua, copia i file di là, le prime prove con autoexec e poco
altro.
Finchè non decisi che accendere il computer e
trovarsi di fronte uno scarno prompt (C:\>) non mi soddisfaceva.
Così creai il mio menu personale.
Il file principale era menu.bat: una serie di echo
uno dopo l'altro, che mostravano a video le varie voci previste, precedute da un
numero progressivo (1, 2 ... n). E basta.
Poi c'era una serie di file batch denominati 1.bat,
2.bat ... n.bat, ciascuno corrispondente ad una voce del menu.
All'avvio del computer, lanciavo menu.bat che mi
riempiva lo schermo di caratteri di formattazione (80 righe x 25 colonne tutte più o meno piene). E ritornavo al prompt del DOS.
Quando volevo eseguire un comando dal menu, beh,
semplicissimo: bastava scrivere 1 + <ENTER>.
Il file 1.bat poteva essere qualcosa del
tipo:
@echo off
cd pctools
pctools
e, manco a dirlo, lanciava il PcTools della
Norton.
Insomma ... un piccolo programmatore in erba
!!!
L'ambiente di
esecuzione virtuale del Common Language Runtime effettua le chiamate
di metodo attraverso la MethodTable. Questo sottintende che non solo i metodi
virtuali, ma anche quelli non virtuali (statici compresi) hanno una loro
identità precisa, rappresentata (anche) da un'entry nella tabella dei
metodi.
Al runtime spetta la scelta di quale MethodTable utilizzare. Se, per un metodo
non virtuale, questa può essere effettuata in maniera "statica" in fase di
compilazione, i metodi virtuali richiedono, per la loro stessa natura, un
meccanismo di scelta rimandato alla fase di esecuzione.
L'Intermediate Language consente di selezionare il comportamento richiesto
tramite le due istruzioni call e callvirt.
A farla breve, si potrebbe quindi dire che
i compilatori generano l'istruzione call per chiamate a metodi non virtuali (e
statici) e l'istruzione callvirt per chiamate virtuali.
Non si impiega molto a verificare la
validità di questa affermazione: buttiamo giù due righe di codice !
class Esempio {
public static void MetodoStatico() {}
public void MetodoNonVirtuale() {}
public virtual void MetodoVirtuale() {}
}
class Chiamante {
static EsempioDammiUnEsempio() { ... }
static void Main() {
Esempio esempio = DammiUnEsempio();
Esempio.MetodoStatico();
esempio.MetodoNonVirtuale();
esempio.MetodoVirtuale();
}
}
Ildasm alla mano, andiamo ad analizzare il codice IL del
chiamante (Chiamante::Main()):
IL_0006: call void Esempio::MetodoStatico()
IL_000b: ldloc.0
IL_000c: callvirt instance void Esempio::MetodoNonVirtuale()
IL_0011: ldloc.0
IL_0012: callvirt instance void Esempio::MetodoVirtuale()
Mmmhhh ... Non ci siamo !
Abbiamo (IL_000c) una chiamata non virtuale effettuata
tramite l'istruzione callvirt. Il che smentisce quanto assunto in precedenza.
Dove sta l'errore ?
Un piccolo (!!) prologo è d'obbligo.
Virtual
Dispatch.
Quando un metodo non è virtuale (questo vale, quindi, anche per i
metodi statici) il compilatore ha la garanzia (per definizione) che la sua implementazione
è fornita dal tipo del riferimento sul quale il metodo stesso è
invocato.
Se
scrivo:
Esempio esempio =
DammiUnEsempio();
esempio.MetodoNonVirtuale();
il compilatore è
in grado di risolvere direttamente l'indirizzo del metodo
invocato.
Questo vale per un
compilatore C++ che genera codice x86 da sorgenti C++, tanto quanto per il
compilatore JIT del CLR che genera codice x86 da sorgenti
IL.
Un'istruzione IL
come:
IL_000c:
call
void Esempio::MetodoNonVirtuale()
verrà compilata
just in time dando origine a codice macchina simile al seguente (a meno dei
valori reali degli offset, ovviamente):
mov ecx,
esi
call dword ptr ds:[02BF0D88h]
Innanzitutto il
riferimento all’oggetto viene copiato
nel registro ecx. La modalità di passaggio dei parametri dipende dalla
convenzione di chiamata che il Jitter utilizza: il Jitter del CLR, almeno
nelle versioni distribuite sino ad ora, utilizza la convenzione fastcall, che prevede,
se possibile, il passaggio dei primi due parametri in
ecx ed edx.
Successivamente, l'indirizzo
del metodo (o del thunk di precompilazione, in ogni caso l'indirizzo target
dell'istruzione x86 call) viene inserito direttamente all'interno dello stream
di codice generato. Il compilatore lo conosce a priori.
Punto.
Se un metodo è
virtuale, le cose cambiano.
Dichiarare un
metodo virtuale significa, più o meno, affermare: "Questo metodo può essere
ridefinito (sovrascritto) da un tipo derivato".
E, quindi,
significa che un’istruzione di chiamata come:
Esempio esempio =
DammiUnEsempioOUnSuoDerivato();
esempio.MetodoVirtuale();
non è risolvibile
in fase di compilazione in maniera diretta, come nel caso
precedente.
Questo perchè non
basta il tipo del riferimento (Esempio) per
determinare quale sia l'implementazione corretta da invocare. La variabile esempio,
per quanto acceduta tramite un riferimento alla classe base,
potrebbe essere di tipo EsempioDerivato (class EsempioDerivato : Esempio { ...
}), e se la classe EsempioDerivato ridefinisce il metodo
MetodoVirtuale, allora sarà quello a dover essere
utilizzato.
L'implementazione
più comune del meccanismo di dispatch virtuale prevede l'utilizzo
di
una tabella
di indirizzi di metodi specifica per ciascun tipo (classicamente chiamata v-table) che sia
accessibile a partire dal riferimento ad
un'istanza.
In tal modo, benchè
non sia noto a
priori l'indirizzo del metodo da chiamare (poichè non è noto il tipo reale dell'oggetto), il
compilatore conosce l'offset del metodo in tale tabella. Il "trucco",
ovviamente, è assegnare ad un metodo virtuale lo stesso offset in tutte
le v-table di tutti i tipi che lo definiscono: in esecuzione, il riferimento all'istanza
chiamata consentirà di risalire alla tabella corretta, e l'offset
(staticamente inserito dal compilatore) consentirà di invocare il metodo
appropriato.
Come abbiamo
visto, il sistema dei tipi del CLR non sfugge a questo disegno, anzi lo estende
mantenendo informazioni a livello dei tipi che vanno ben al di là di un elenco
di puntatori a funzione.
Ecco spiegato come mai
la chiamata ad un metodo virtuale si trasforma nel seguente codice
x86:
mov ecx, esi
mov eax, dword ptr [ecx]
call dword ptr [eax + 38h]
Nel registro ecx è
memorizzato l'indirizzo in memoria dell'istanza in questione (di tipo Esempio o
derivato).
La seconda
istruzione mov
copia nel registro eax il contenuto di tale indirizzo di memoria. In pratica
dereferenzia il puntatore all’oggetto.
Infine il metodo
viene invocato indirizzando in modo indiretto la tabella dei metodi tramite un
offset predeterminato in compilazione.
Il risultato è che
c'è un livello di indirezione ulteriore, nella chiamata ai metodi virtuali, che
risulta necessario proprio per la definizione stessa di "metodo
virtuale".
Effetti Collaterali.
Ritorniamo al caso
di una chiamata ad un metodo di istanza non
virtuale.
Non abbiamo detto
che il compilatore è in grado di determinare a priori l'indirizzo in memoria del
metodo ed emettere la corrispondente istruzione call
?
Sicuramente sì,
tanto è vero che l'assembler generato è:
mov ecx, esi
call dword ptr ds:[02BF0D88h]
Ma, ciò nonostante, chi dà origine a questa istruzione è l'opcode IL
callvirt associato ad un token dei metadati che indica un metodo di istanza non
virtuale.
Come mai
?
La
spiegazione va ricercata in un side-effect del dispatch virtuale: la
dereferenziazione
del riferimento all'oggetto, c
he risulta necessaria per accedere alla
v-table.
E' un aspetto
importante, poichè consente di verificare la validità del puntatore al nostro
oggetto di tipo Esempio.
Se il puntatore
è invalido (per esempio nullo), la chiamata non avviene poichè prima che
questa sia possibile il sistema genera un'eccezione di Access Violation e il
programma va in crash.
Nel caso di una
chiamata ad un metodo statico, non c'è nessun riferimento da derefereniziare
(niente this, per intenderci). E il compilatore C# è felicissimo di utilizzare
l'istruzione IL call, che il Jitter saprà interpretare in modo
appropriato.
Ma nel caso di una
chiamata ad un metodo di istanza non virtuale, la funzione chiamata deve ricevere
come (primo) parametro il riferimento all’oggetto sul quale è
invocata.
Semplicemente che
in questo caso la validità del riferimento (cioè il fatto che sia correttamente
dereferenziabile) non si può verificare tramite il meccanismo di indirezione del
dispatch virtuale: il metodo, in fondo, virtuale non lo è affatto, e il
meccanismo di dispatch virtuale non può (non deve)
intervenire.
Il Jitter, allora,
incontrando un'istruzione callvirt su un metodo non virtuale, lo invoca in modo
diretto ma fa precedere l'invocazione da un test sulla validità del riferimento
all'oggetto. Lo stesso meccanismo che era implicito per i metodi virtuali
diventa ora esplicito:
mov ecx, esi
cmp dword ptr [ecx], ecx
call dword ptr ds:[02BF0D88h]
L’istruzione x86
cmp, per farla breve, effettua il confronto per sottrazione di due valori ed
imposta in modo conseguente il registro di flag (EFLAGS); nella maggior parte
degli utilizzi la si trova in congiunzione con qualche istruzione di salto
condizionato.
Nel nostro caso,
in realtà, il suo comportamento è del tutto inutile (o, per meglio dire,
inutilizzato): non c’è alcun interesse a verificare la differenza fra
l’indirizzo di memoria in ecx (puntatore all’oggetto) con il valore dword in
esso contenuto (l’oggetto stesso).
A parte, appunto,
quello di fallire se il riferimento in ecx è
invalido.
Qualsiasi istruzione
che avesse effettuato una dereferenziazione sarebbe stata adatta allo
scopo.
La scelta di cmp
credo sia dovuta principalmente ad un paio di
fattori.
Primo: cmp non
fa altro che settare flag. Non modifica registri, non modifica locazioni
di memoria, tutti “effetti collaterali” sicuramente indesiderati in un caso come
questo.
Secondo:
prestazioni. Su
un Pentium la versione di cmp che confronta un registro con un
indirizzo di memoria porta via solamente due cicli di
clock.
Ecco, alla fine, la
spiegazione: in definitiva, l'utilizzo di callvirt su metodi non virtuali non
modifica la modalità di chiamata, che è e resta non virtuale, ma serve
esclusivamente ad indicare al Jitter di aggiungere un controllo ulteriore prima
della chiamata stessa.
La prova
del 9.
In altre
parole, cosa succederebbe se il Jitter ignorasse la necessità di questo controllo
ed emettesse solamente l’istruzione di chiamata ?
Quali problemi
porta con sé un codice come il seguente (che è, nè
più nè meno, quello generato dall'istruzione IL call) ?
mov ecx, esi
; Questo non viene eseguito:
; cmp dword ptr [ecx], ecx
call dword ptr
ds:[02BF0D88h]
Come detto, se il
riferimento all’oggetto è valido l’istruzione cmp è del tutto superflua, e il
codice scritto sopra è perfettamente legale e
corretto.
Ma poniamo il caso
che, per un errore o semplicemente per costruzione, il metodo DammiUnEsempio()
sia:
static Esempio DammiUnEsempio()
{
return null;
}
Il chiamante si ritrova con un riferimento nullo, che viene salvato nel
registro ecx come primo parametro per il MetodoNonVirtuale. Senza
istruzione cmp, questa situazione non provoca errori e il controllo viene passato al metodo
stesso.
Eccoci così
con un metodo di istanza pronto ad essere eseguito su ... nessuna istanza
!
Il metodo inizierà
la propria esecuzione con la garanzia (implicita nel suo essere “metodo di
istanza”) di avere a disposizione un puntatore this valido in ecx. Alla prima
occasione in cui tale puntatore andrà dereferenziato (per esempio al primo
accesso ad un campo di istanza) il programma genererà la famigerata eccezione di
violazione di accesso alla memoria (il codice di stato dell’eccezione in SEH me
lo ricordo a memoria, 0xC0000005, chissà come mai ... ).
E giusto che venga
generata una NullReferenceException/Access Violation ?
Sicuramente sì,
perchè tentiamo di dereferenziare un riferimento
invalido.
E’ giusto che
questa sia generata dal MetodoNonVirtuale() ?
Sicuramente no !
Non è compito del metodo di istanza verificare la validità dell’istanza, è
compito del suo chiamante !
In questo caso,
eliminando l’istruzione cmp, abbiamo sollevato il chiamante da una sua precisa
responsabilità, e l’effetto che se ne ottiene è che abbiamo un’eccezione
originata nel posto sbagliato.
Addirittura, la
cosa si fa curiosa se, ad esempio, il metodo di istanza
è:
void
MetodoNonVirtuale() {
Console.WriteLine("Ciao");
}
Se il metodo
non “tocca” il riferimento this, non viene generata alcuna eccezione ed il
codice è (o meglio, sembra) esente da errori.
Siamo riusciti a chiamare un metodo di istanza su un’istanza nulla, e
l’esecuzione procede senza intoppi.
In definitiva:
l’istruzione cmp, che funge da “dereferenziatore” è necessaria per garantire che
un metodo non virtuale riceva un riferimento all’istanza valido, e che
l’esecuzione vada in exception nel punto di chiamata se questa condizione, al
contrario, non si verifica.
Considerazioni.
Come sempre,
andando ad indagare il funzionamento del CLR, ci sono diversi attori in gioco, e
diversi livelli dai quali si possono osservare i fenomeni: linguaggio macchina,
Linguaggio Intermedio, linguaggio ad alto livello (C# ecc.).
Il tutto diventa
interessante quando si deve scegliere in quale ambito implementare una
funzionalità.
E' una
caratteristica del linguaggio ? Del motore di esecuzione ? Chi deve garantire
cosa ?
Nella situazione discussa sinora, la funzionalità da realizzare è: "garantire che un metodo di
istanza sia invocato su un'istanza non nulla, e fallire in caso
contrario".
IL, in questa
occasione, mostra il suo
lato di linguaggio a basso livello e lascia ai linguaggi "applicativi" l'onere del
controllo: se le specifiche di un linguaggio lo consentono, il compilatore
relativo è libero di violare questa invarianza ed effettuare chiamate su riferimenti
nulli (bontà sua ...) emettendo un opcode call.
Le specifiche di C#,
però, indicano con chiarezza che una chiamata su un
riferimento nullo deve generare una NullReferenceException. L'utilizzo di callvirt su
metodi non virtuali può sembrare una
forzatura, ma il compilatore C# deve ... materializzare le sue specifiche, sfruttando
le funzionalità ed i meccanismi che il runtime mette a
disposizione.