C#

Variabili e cenni sui tipi

nb: in questo importante paragrafo, molto denso di materiale utile per tutto il corso, dovremo per forza parlare di alcuni concetti che saranno più chiari man mano che si procederà nel nostro cammino... caso mai tornate a guardarlo quando avrete immagazzinato ulteriori informazioni.

Qualunque sia il linguaggio di programmazione che usate, salvo gli esoterici e poco altro, avrete a che fare con il concetto delle variabili che rappresentano dei valori che vengono memorizzati, manipolati ed eventualmente modificati a seguito delle operazioni applicate su di essi. Vedremo in questo paragrafo di presentare come C# ci permette di utilizzare le variabili insieme ad un altro concetto ad esse relativo, molto importante, definito dalla parola inglese,  scope, che in italiano potremmo tradurre come "visibilità". Parleremo inoltre, in maniera introduttiva e generalizzata, dei tipi perchè la variabili rappresentano sempre qualche cosa di molto ben definito e determinato nei suoi comportamenti.

Nella forma più semplice le variabili in C# hanno, nell'ordine e un tipo, un nome ma possono, opzionalmente, avere anche un valore iniziale, mancando il quale si dicono "unassigned", ovvero non hanno un valore assegnato; tuttavia è necessario attribuirgliene uno prima o poi se devono essere usate. E' possibile attribuire loro anche un modificatore che garantisce alcuni vincoli al loro uso. La dichiarazione formale, che stabilisce l'ordine dei vari elementi, è la seguente:

[modificatore] tipo-variabile nome-variabile [= valore iniziale]

Gli elementi tra parentesi quadrata sono opzionali in questa fase. Quindi è possibile scrivere i seguenti esempi:

int x;
public string s;
int y = 4;
private int z = 8;


Sono tutte espressioni valide anche se le prime due variabili, x ed s dovranno essere inizializzate con un valore prima di poter essere utilizzate. Tralasciamo per il momento il ruolo dei modificatori (negli esempi public e private) di cui parleremo diffusamente nel prossimo capitolo. In caso presenza di variabili non inizializzate, ed ovviamente non utilizzate, il compilatore emette comunque un warning, se si cerca di utilizzarle non compila del tutto. Come avrete notato l'assegnazione avviene attraverso l'operatore = che si può tradurre con la parola "vale". quindi ad esempio l'espressione  int x = 1 vuol dire "l'intero x vale 1". Ovvero, questo operatore, attribuisce il valore che sta alla sua destra alla variabile che sta alla sua sinistra. Non confondetevi dunque, = non è l'operatore di confronto. Chi proviene da molti altri linguaggi peraltro sarà abituato a questo fatto.
C# permette anche di inserire dichiarazioni multiple:

int x, y, z;
int x = 0, y = 1;
int x = 0, y, z= 3;


Infine sono anche accettabili dichiarazioni come:

int x = 3 + 4 // ok! va bene una espressione
string s = metodo() // anche qui va bene, a s è attribuito il valore di ritorno di un metodo, valore che dovrà essere di tipo string


Va da sè, come accennato nell'esempio qui di sopra, che il valore che assegnate alla variabile deve essere di tipo compatibile con quello dichiarato per la variabile stessa; dei tipi parleremo più avanti ma è evidente che scrivere

int x = "ciao";

non va bene. La parola "ciao" non può mai essere un intero. In questo specifico caso ad esempio il compilatore è molto preciso:

error CS0029: Cannot implicitly convert type 'string' to 'int'

La dichiarazione segue regole piuttosto ferree, come vedremo ci sono anche alcuni vincoli legati ai singoli tipi, ma un po' di libertà ci è lasciata nella scelta dell'identificativo della variabile, ovvero il nome che intendiamo attribuire ad essa. Le regole da seguire sono esattamente quelle suggerite nello standard Unicode Annex 15. Unica eccezione, il fatto che viene ammesso il carattere _ (underscore) in posizione inizale per un identificatore. E' inoltre possibile trovare all'inizio il carattere @ che introduce un identificatore verbatim che permette anche ad una parola chiave di essere usata come identificatore, diversamente come è naturale, tali parole riservate sono di proprietà del linguaggio; questa caratteristica è utile per l'interazione con altri linguaggi aventi parole chiave differenti e nei quali invece le keyword di C# possano essere usate come identificatori normali. Di norma comunque è molto meglio non usare parole chiave precedute da @ come identificatore. Si è già detto, ed è importante ricordare, che C# è case sensitive per cui x = 0 è diverso da X = 0; e xY è diverso da xy;  un nome di variabile può essere usato solo una volta nel programma nell'ambito dello stesso scope, vedremo tra breve di cosa si stratta. Bisogna infine tenere presente che due identificatori sono considerati uguali se risultano identici una volta compiute le seguenti operazioni:

• vengono tolti i caratteri @ iniziali
• vengono sostituiti alle sequenze unicode i corrispondenti caratteri
• vengono eliminate eventuali formattazioni.

Quindi ad esempio @aa e aa sono considerati eguali e collidono se nello stesso scope. Vediamo ora alcuni esempi di nome di variabile; ovviamente vale la generica raccomandazione di usare nomi significativi e descrittivi nelle vostre applicazioni; vediamo ora alcuni esempi, potete poi sbizzarrirvi coi vostri in generale vanno bene quasi tutti i caratteri Unicode all'interno dei nomi:

x             è un nome accettabile
_xyz          è anche accettabile
va?r          non va bene il ? non è un carattere accettabile.
x01           va bene
nome_utente   è ok anche questo.
@array        è corretto.
_@aaa         non va bene, il carattere @ deve stare in prima posizione
@_aaa         invece è ok.
3w            inizia con un numero e non va bene
a33w          invece è corretto in quanto inizia con un carattere
a#01          contiene il carattere # che non è valido.
int /u005a    va bene ed equivale a int _a; questo conferma che anche le
              sequenze UNICODE sono accettabili usate nella forma /uNNNN

Una domanda che a volte viene posta è se esiste un limite alla lunghezza degli identificatori utilizzabili in C#. Per quanto ne so tale limite è di 511 caratteri, pur non essendo una richiesta del linguaggio by design. Se qualcuno vuole fare dei test, da parte mia l’unico tentativo fatto in merito, giusto per sfizio, mi ha confermato tale valore che mi sembra ampio a sufficienza per qualsiasi scopo.
Esistono delle regole da seguire o meglio delle convenzioni che sono dettagliatamente spiegate sul sito MSDN. Di seguito do alcune regole generali:
  • Scegliete nomi significativi. I programmi lunghi e complessi (non i miei in queste pagine) saranno così autodescrittivi, molto spesso.
  • I nomi delle variabili e i metodi iniziano con le minuscole
  • Namespace, classi, struct, interfacce, enumerativi e delegati iniziano con la maiuscola
  • In caso di nomi composti è buona norma usare le maiuscole per le varie parole componenti non i trattini o gli underscore. Cioè
    AnnoDiNascita va bene Anno_di_nascita un po' meno (anche se per il compilatore non fa differenza e qualcuno storce il naso)
Tali regole sono importanti, ancorchè si tratti di puri formalismi, per favorire la leggibilità dei programmi e per la loro manutenzione.

Ed ecco giunto il momento di parlare dell'importante concetto di scope o visibilità di una variabile. Con questo concetto si definisce quella zona, quella porzione di programma nel quale una data variabile è utilizzabile o, se preferite, è richiamabile semplicemente tramite il suo nome. Detto così può sembrare un po' complesso, in realtà è abbastanza semplice se pensate a come è conformato un programma suddiviso in namespace, classi o semplicemente blocchi racchiusi tra una coppia di parentesi graffe. E sarà ancora così quando parleremo di metodi, loop eccetera. Il concetto è importante, l'usabilità di una variabile è legata alla sua visibilità, ma nasconde alcune insidie che è bene conoscere. Possiamo partire con un esempio di base:

  Esempio 4.1
1
2
3
4
5
6
7
8
9
10
using System;
class test
{
  public static void Main()
  {
    int x = 5;
    x = x + 1;
    Console.WriteLine(x);
  }
}

L'output restituisce il numero 6 e ovviamente è tutto chiaro. Alla riga 6 definiamo la variabile x, alla 7 la incrementiamo di una unità, assegnandole il valore di se stessa più 1, ed alla 8 scriviamo sullo standard output il risultato. Facile no? Però, se provassimo a fare una apparentemente innocua modifica le cose cambiano:

  Esempio 4.2
1
2
3
4
5
6
7
8
9
10
11
12
using System;
class test
{
  public static void Main()
  {
    {
      int x = 5;
      x = x + 1;
    }
    Console.WriteLine(x);
  }
}

Qui il compilatore si arrabbia:

00009.cs(10,21): error CS0103: The name 'x' does not exist in the current context

Questo errore significa che la variabile alla riga 10 poiszione 21 del nostro programma, e precisamente la 'x', non è definita nel contesto dell'istruzione. Se invece scriviamo il nostro programma di esempio come segue:

  Esempio 4.3
1
2
3
4
5
6
7
8
9
10
11
12
using System;
class test
{
  public static void Main()
  {
    int x = 5;
    x = x + 1;
    {
      Console.WriteLine(x);
    }
  }
}

allora le cose tornano a funzionare. Il perchè di tutto questo è intuibile: ciò che è più di alto livello, più "esterno", è visibile da ciò che sta all'interno ma non viceversa. Quindi l'istruzione di scrittura alla riga 10 dell'esempio 4.2 non può vedere x definito alla riga 7 ma ad un livello più interno (qui ci può anche venire utile una corretta indentazione per capire visivamente ciò che accade) mentre quella alla riga 9  dell'esempio 4.3 riesce a vedre x definito alla 6 in quanto si trova ad un livello più interno. Quindi, tornando all'esempio 4.2, la variabile x esiste solo all'interno del blocco che inizia alla riga 6 e termina alla 9, mentre nel 4.3 x esiste dalla riga 5 alla 11. Ogni qual volta incontrerete un errore segnalato dal compilatore come quello visto in precedenza si tratterà probabilmente di errore di digitazione ma potrebbe anche essere un problema di visibilità. Se non si tratta del primo è molto probabile che sia il secondo. Quanto visto ci porta un po', come similitudine, a riconsiderare il significato della clausola "using": essa non fa altro che rendere disponbili in un programma, ponendosi ad un livello più esterno, classi, namespace e quant'altro, tutte cose che sarebbero fuori dalla visibilità del codice interno al programma, in quanto si tratterebbe di entità chiuse rispetto allo stesso.
Un'altra considerazione interessante riguardo a questo aspetto ci porta a fare le nostre scoperte nel campo delle possibili collisioni che possono avvenire. Prendiamo ad esempio il seguente frammento di codice:

public static void Main()
{
int x = 5;
string x = "ciao";
}

Questo, come intuibile non compila:

00011.cs(7,10): error CS0128: A local variable named 'x' is already defined in this scope
00011.cs(7,14): error CS0029: Cannot implicitly convert type 'string' to 'int'


Il primo errore segnalato è il più significativo perchè ci dice che esiste una collisione tra nomi. infatti int x e string x sono ad uno stesso livello di visibilità quindi collidono. Il secondo errore indica anche una incompatibilità tra i tipi ma è meno importante in questo caso. Vediamo ora come si comporta un altro programma di prova:

public static void Main()
{
  string x = "ciao";
  {
    int x = 5;
  }
}

Anche qui è evidente una collisione in quanto il primo x definito deve essere visibile anche all'interno, ad un livello più basso; quindi il secondo x collide con quello precedente. In altri linguaggi non è così e viene consentito un pericoloso shadowing che è origine di bugs piuttosto insidiosi. Per far funzionare le cose ci deve essere una netta distinzione a livello di blocchi che non devono essere in alcun modo collidenti. Ecco un esempio sul quale riflettere un po:

  Esempio 4.4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;
class program
{
  public static void Main()
  {
    {
      int a1 = 0;
      Console.WriteLine(a1 + 1);
      {
        Console.WriteLine(a1 + 2);
      }
    }
    {
      int a1 = 9;
      Console.WriteLine(a1);
    }
  }
}

Questo esempio funziona. La variabile a1 definita alla riga 7 è chiusa dentro il blocco che inizia alla 6 e si chiude alla 12; successivamente, alla 13 si apre un nuovo blocco, indipendente e scorrelato dal precedente, in cui possiamo definire un'altra variabile avente nome "a1" che non può conflittare con l'altra. Naturalmente vista l'ampia possibilità di scelta dei nomi è più semplice far in modo che casi del genere non accadano. Ma a parte questo credo che la logica sia chiara.

Meno intuitivo forse è il fatto che una variabile non può essere usata, anche nel suo blocco di appartenenza, fino al momento in cui viene esplicitamente definita. Questo significa che all'interno di un blocco non possiamo scrivere due righe come le seguenti:

x = x + 1;
int x = 0;


perchè il compilatore ci direbbe:

error CS0841: Cannot use local variable 'x' before it is declared

che spiega chiaramente qual è il problema. Prima si dichiara la variabile poi la si usa.
Quando parleremo delle classi incontreremo anche il concetto di variabile di classe e vedremo delle eccezioni a questo comportamento. In generale comunque vale sempre questo concetto di area delimitata entro il quale una variabile esiste, area che può anche essere una classe, un metodo o una struct o un enumerativo, tutte cose di cui riparleremo, non necessariamente quindi una anonima porzione di codice. Come detto lo scope di una variabile è direzionato verso i livelli più interni del codice nel quale viene definito mai verso l'esterno, questo è il punto cardine di tutto il discorso che vale la pena ribadire. Il concetto di scope è chiaramente molto importante e consiglio di fare qualche prova dal momento che qualche insidia la nasconde. Lo reincontreremo, anche se nulla di eclatante sarà aggiunto, nel capitolo ove parleremo dei cicli di controllo dell'esecuzione del flusso del programma.

A volte può essere utile, definendo una variabile, fornire ad essa una forma di protezione che la tuteli da futuri possibile modifiche del valore in essa memorizzato. Si può allora fare uso dell'operatore const. Come è intuibile dal nome stesso quindi una variabile di tipo const rimane costante per tutta la durata del programma e non può essere modificata. I vantaggi che offre l'uso delle costanti è dato dal fatto che, quasi paradossalmente, possono rendere più agevoli le modifiche complessive di un programma nonchè la sua gestione e inoltre, non essendo modificabili, ci possono aiutare a prevenire gli errori nel senso che un valore memorizzato in una const è praticamente in cassaforte. Dal momento che il compilatore sa tutto di una costante, la gestisce in maniera ottimale. Una variabile const è anche static implicitamente tant'è che non ci viene permesso di appiccicargli esplicitamente l'etichetta static perchè in pratica c'è già. L’uso delle costanti è molto semplice anche concettualmente ed è soggetto soltanto ad un paio di regole di base:

1) una costante deve sempre essere inizialiazzata al momento della sua dichiarazione
2) l'inizializzazione deve avvenire attraverso una espressione costante, quindi non tramite una variabile

sintattiticamente abbiamo:

[modificatore] const tipo-della costante nome-della-costante = valore

con il modificatore opzionale. Esempio:

  Esempio 4.5
1
2
3
4
5
6
7
8
9
10
11
using System;
class program
{
  public static void Main()
  {
    const int x = 0;
    //const string y;
    int t = 5;
    //const int z = t;
  }
}

L'esempio 4.5 compila regolarmente, anche se vi darà dei warning dovuto alle variabili dichiarate ma non usate. La riga 6, che è quella che ci interessa, rispecchia l'esatta definizione di una costante. Se togliamo al codice della riga 7 le i due slash che lo rendono un commento abbiamo il seguente errore:

error CS0145: A const field requires a value to be provided

mentre rendendo operativa la riga 9 ne ricaveremmo:

error CS0133: The expression being assigned to 'z' must be constant

direi in entrambi i casi abbastanza autoesplicativi.
Come detto il valore di una costante non può più essere cambiato nel corso dell'intero programma. Ad esempio se nel programma precedente sostituiamo la riga 8 con la seguente:

x = 10;

ovvero cercando di cambiare il valore della costante x da 0 a 10 otteniamo il seguente errore:

error CS0131: The left-hand side of an assignment must be a variable, property or indexer

che ci spiega che l'operazione di assegnazione va bene su variabili, proprietà o indicizzatori (gli ultimi due vedremo cosa sono più avanti) e quindi non sulle costanti.

Un altro interessante operatore, dall'uso più ampio come vedremo quando parleremo delle classi è readonly. Esso è in parte simile a const, nel senso che il valore ad esso assegnato è costante per tutta la durata del programma ma viene assegnato a runtime, quindi non deve essere obbligatoriamente assegnato contestualmente alla sua dichiarazione. Per ora tenete lì il concetto.

Novità interessante, non presente nelle prime versioni di C# e del framework in generale, è la possibilità di applicare su una variabile l'inferenza di tipo. Con questo meccanismo si affida al compilatore il compito di stabilire al compilatore qual è il tipo più adatto da attribuire ad una variabile basandosi solo sul valore iniziale ad essa assegnato. Il concetto in realtà è un po' più complesso, come potrete facilmente capire informandovi un po' di più, ma per i nostri scopi è più che sufficiente quanto detto. E' un meccanismo comune nei linguaggi dinamici ma ultimamente molti linguaggi si sono dotati di esso se non ne erano provvisti mentre quelli di più nuova concezione ce l'hanno, per così dire, di serie. C# è nato senza questa possibilità ma con l'avvento della versione 3.0 c'è stata questa importante aggiunta per la quale si è riservata la keyword var che, posta in una certa posizione come vedremo tra breve,  avvisa il compilatore che tocca a lui farsi carico di assegnare il tipo giusto alla variabile. Il nostro linguaggio si aggiunge quindi ad una grande famiglia che, in un modo o nell'altro ha abbracciato l'inferenza di tipo (o tipizzazione implicita, se volete) e che comprende ad esempio il linguaggio D, Delphi, Boo, Cobra, F#, Scala, il recente Rust, Python, Ruby ecc... E' importante sottolineare che anche in questo caso viene conservato il principio di tipizzazione statica, quindi una volta che il compilatore ha assegnato un tipo la variabile potrà accettare solo valori con quel tipo compatibili. E' sicuramente una possibilità interessante. Vediamo un esempio e avremo una piccola sorpresa relativamente proprio alla keyword var:

  Esempio 4.6
1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
class program
{
  public static void Main()
  {
    var i = 5;
    var s = "ciao";
    Console.WriteLine(i.GetType());
    Console.WriteLine(s.GetType());
    int var = 8;
    Console.WriteLine(var);
  }
}

Le righe 6 e 7 sono quelle che realizzano quando detto; le variabili i ed s sono create senza specificarne il tipo. Il compilatore provvede ad attribuire il tipo migliore per la variabile sulla base del valore assegnato. Le righe 8 e 9 attraverso il metodo GetType(), al quale accenneremo in questo stesso paragrafo, esplicitano il tipo assegnato alle due variabili che risultano essere rispettivamente:

System.Int32
System.String

ovvero un intero ed una stringa. Le cose saranno più chiare quando avremo parlato dei tipo ma credo che sia abbastanza intuitivo anche in questa fase.
Piuttosto, avete notato la riga 10? in essa la parola "var" viene usata come nome di variabile. Questo ci indica una cosa, cioè che var innesca l'inferenza di tipo solo quando si trova nella posizione in cui, normalmente, si troverebbe il tipo. In questo senso è una "mezza" parola riservata, si potrebbe dire. Questa scelta, forse, deriva dalla necessità di mantenere la compatibilità con programmi scritti antecedentemente quando var non era una keyword e quindi poteva essere liberamente usata. Ma è una supposizione mia, francamente non ho mai approfondito il discorso.

L'uso dell'inferenza è sottoposto a qualche limitazione:

•  Essa può essere applicata solo a variabili locali (quindi ad esempio non a parametri e nemmeno ai campi di una classe e nemmeno a valori di ritorno per un metodo a meno che questi non coincidano con la signature del metodo stesso, vedremo un esempio quando parleremo dell'argomento metodi) evidentemente sempre per non mandare in cofusione il compilatore.
•  Va sempre attribuito un valore in fase di dichiarazione, valore che comunque non potrà essere null, questo è ovvio se no il compilatore non sa che fare.

Quindi:

var x;                 // non va bene
var x = 0;             // ok
var x; x = 0;          // non va neanche così. Il valore va attribuito in fase di dichiarazione
int x = 0; var y = x;  // questo è ok
var y = null           // non va

Ribadiamo, perchè è importante, che il tipo assegnato ad una variabile tramite inferenza resta quello per tutta la durata del programma e quindi i valori successivamente attibuiti alla variabile "inferita" devo essere compatibili col suo tipo. Cioè:

var i = 0; // i è un intero
i = "ciao": // non va.


Una nota importante è che in pratica non esistono particolari limitazioni per il tipo su cui si puòapplicare l'inferenza, ovvero la variabile inferita potrà essere essere built-in, user defined, ecc... Per quanto raro può capitare inoltre che un tipo chiamato “var” sia presente a livello di scope quando noi definiamo la nostra variabile da inferire. Chiaramente sarà un tipo, una classe, definita dall'utente. In quel caso il compilatore fa riferimento al tipo definito e non farà uso del meccanismo di inferenza. Usate nomi di tipi sensati, quindi, siete avvistati.... Come vedremo la keyword var può essere usata anche con gli array, pur se la cosa diventa un attimo più complicata. A questo punto del nostro excursus questa feature non aggiunge poi molto (magari è utile per limitare in qualche modo la digitazione di tipi dalla descrizione lunga, pur se la leggibilità sarà comunque un po’ penalizzata) e sarà più utile, quando parleremo dei tipi anonimi. Per ora basta memorizzare l'esistenza di questa possibilità. Su Internet tra l'altro esistono anche dei dibattiti sui reali vantaggio nell'uso di var... a mio modesto avviso nelle condizioni giuste questi vantaggi ci sono sicuramente.

TIPI

Dal momento che le variabili sono sempre connesse al concetto di "tipo", del quale abbiamo accennato in ambito .Net nel corso del capitolo introduttivo, vediamo di iniziare il discorso su di esso nei prossimi paragrafi scenderemo accuratamente in dettaglio.
Il tipo che viene attribuito ad un dato serve a delimitarne e ad specificare i valori che questo può assumere. Quindi, molto semplicemente, definisce un perimetro all'interno del quale sarà possibile pescare i valori da assegnare alla variabile unitamente ad altre caratteristiche.
In ambito .Net, quindi parliamo non solo con riferimento a C#, sono ammesse due famiglie di tipi (in realtà ci sarebbero anche i puntatori e i generic type parameters ma per ora non ne parliamo):

value types (tipi valore)
reference types (tipi per riferimento)


Tra i primi, i tipi valore,  troviamo ad esempio gli interi (int) e i caratteri singoli (char) mentre tra i tipi di riferimento abbiamo le classe, disponibili nella BCL o create dall’utente, gli array, le stringhe e così via, come vedremo nel prosieguo del nostro cammino. Evidentemente ogni tipo si porta dietro una certa parte di informazioni che saranno usate dal compilatore per effettuare delle verifiche. Ad esempio i valori massimi e minimi ammissibili, lo spazio di memoria necessario per l’allocazione, le operazioni che sono permesse su quel tipo, in linea con l’impostazione generale del linguaggio e così via. Da un punto di vista pratico i tipi valore sono forse di comprensione più immediata (certamente un intero è concettualmente più semplice di una classe, anche se nella realtà vedremo che non è poi tanto vero in questo linguaggio) e rappresentano elementi immutabili (chiaramente come natura, non in termini di valore); d’altronde si chiamano value types quindi è comprensibile che, a logica, descrivano dei valori.
I value types si dividono in due categorie:

1. strutture (a loro volta sono distinguibili in interi, floating point, decimal, bool e user defined)
2. enumeratori

da qui si evince (si evincerà, forse è meglio) che anche gli interi o i caratteri sono strutture in C# e dichiarare una variabile di quel tipo significa istanziare una struttura. Gli enumeratori sono un’altra cosa ancora e vvoiamente ne parleremo più avanti.

I reference types si possono invece dividere in:

1. object types
2. interface types
3. pointer types (managed e unmanaged)
4. built-in reference types

non preoccupatevi ovviamente per ora di approfondire di cosa si tratta ogni cosa sarà ampiamente sviluppata nel corso dei prossimi paragrafi. I reference types sono indubbiamente un po’ più complicati e sfuggenti in prima battuta e complessivamente sono forse meno intuitivi per cui andranno studiati con calma. Analogamente non vale la pena di fissarsi troppo per il momento sulla differenziazione tra dati built-in e user-defined. Basti sapere che tutto sommato, fatta salva l’origine, lavorano allo stesso modo e non ci sono particolarità comportamentali eclatanti in genere.
Infine, schematicamente, possiamo cercare di tirare un po' le fila del discorso caratterizzando le due tipologie come segue:

•  Una variabile value type può essere visto come una cella di memoria che contiene un valore coerente con la sua natura.
•  Una variabile appartenente ai reference types invece è in pratica un puntatore ad una zona di memoria in cui sono contenuti (o iniziano) i suoi dati. Una secondo me interessante analogia li paragona agli URL, ai link nelle pagine Web che puntano ad altre locazioni sulla rete. In pratica quando si crea un reference type si crea in realtà una coppia consistente in puntatore + l'oggetto in memoria
•  I value types sono allocati nello stack qualora si tratti di variabili locali o parametri di metodi. Non è invece vero che i value types vivano in ogni caso nello stack. Controesempio classico sono i campi delle classi quando siano rappresentati da tipi valore; nonostante la loro natura, proprio per il fatto di appartenere ad una classe, pascolano beatamente nello heap.
•  I reference types sono invece sempre allocati nello heap, loro si hanno fissa dimora, e sono interessati dall’operato del garbage collector, meccanismo automatico del quale parleremo in seguito. Normalmente non ci importa in alcun modo sapere dove sono collocati nello heap i vari tipi.
•  Si vuole che value types siano più leggeri e prestazionalmente preferibili. In realtà le cose non stanno esattamente così  in realtà in parecchi casi è vero il contrario e per quanto concerne le performance, alcune operazioni portate a termine usando tipi di riferimento richiedono pochi bytes rispetto a trasferimenti dati più pesanti se realizzate tramite value types. Insomma si tratta di concezioni, a mio modesto avviso, un po’ da rivedere se considerate come dogmi assoluti, per quanto diffuse e radicate nella concezione comune, soprattutto da valutare in fase di design della vostra applicazione. E' però certamente vero in genere che i reference types hanno un qualche overhead iniziale.
•  I value types non possono avere eredi, nel senso che sono come le foglie di un albero, se mi passate l’immagine, mentre i reference-types possono avere dei discendenti. Questa è una differenza estremamente importante in quanto attraverso i reference types si possono creare intere catene o complesse strutture ad albero sfruttando i complessi meccanismi che si accompagnano alle classi. Da un punto di vista architetturale questo è l'elemento più importante, in questo sta il punto di forza di questi tipi. Questo è un concetto, ripeto, molto importante, assolutamente fondamentale nella teoria della programmazione a oggetti  e lo affronteremo accuratamente parlando delle classi.
•  I value-types possono discendere solo da System.ValueType mentre i reference type possono avere progenitori di diversa origine (anche se esiste una radice comune, come vedremo tra breve).
•  I reference types accettano il valore null (ossia il riferimento, che viene comunque creato, punta ad un oggetto nullo) i value types no tranne i nullable types, che in pratica sono una estensione dei tipi valore "normali".

La differenza più sottile e forse di primo acchito meno comprensibile però è come esse sono manipolate in memoria.

Prima però di andare più nello specifico su quest'ultimo argomento dobbiamo parlare del concetto di istanziazione, quello che di fatto unisce tipi e variabili essendo una parte comune ed irrinunciabile per entrambe le categorie. La realizzazione del processo di istanziazione è di fatto semplicissimo e lo abbiamo già visto in azione più volte; scrivendo ad esempio:

int x;

non solo abbiamo creato creato una variabile assegnandogli il tipo intero ma, leggendo in senso inverso, ciò che è più esatto, abbiamo creato un'istanza del tipo intero, ovvero un'entità manipolabile con tutte le caratteristiche del tipo intero. Questo vale per tutti i tipi, anche quelli creati da noi tramite le definizioni di classe o struct. Il tipo è in pratica il modello, il processo di istanziazione ne ricava una variabile che, come detto, ha tutte le caratteristiche definite dalla tipologia da cui deriva. Ad esempio se la classe intero, di default definita su 32 bit, prevede  un certo massimo e un certo minimo anche la variabile derivata non potrà andare oltre quelle soglie; se il tipo prevede determinate proprietà e metodi il tutto sarà disponibile per la variabile.
Detto questo vediamo appunto come lavorano in memoria le due categorie di tipi. Sia ben chiaro che del seguente programma potreste capire ben poco, se già non avete un qualche skill sulla programmazione a oggetti, per cui sarà bene che eventualmente torniate rivederlo tra qualche tempo; è però importante che comprendiate appieno la parte concettuale ed è su quella che insisterò. Nel prosieguo del nostro cammino avremo modo di icnotrare ancora queste informazioni.

  Esempio 4.7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using System;
class PuntoSulPiano
{
  public PuntoSulPiano(int x, int y)
  {
    this.x = x;
    this.y = y;
  }
  internal int x;
  internal int y;
}

struct AltroPuntoSulPiano
{
  public int x;
  public int y;
}

class program
{
  public static void Main()
  {
    PuntoSulPiano p1 = new PuntoSulPiano(2, 3);
    AltroPuntoSulPiano p2 = new AltroPuntoSulPiano();
    p2.x = 5;
    p2.y = 6;
    PuntoSulPiano p3 = p1;
    AltroPuntoSulPiano p4 = p2;
    Console.WriteLine("{0}, {1}, {2}, {3}", p1.x, p1.y, p3.x, p3.y);
    Console.WriteLine("{0}, {1}, {2}, {3}", p2.x, p2.y, p4.x, p4.y);
    p3.x = 9;
    p3.y = 8;
    p4.x = 1;
    p4.y = 0;
    Console.WriteLine("{0}, {1}, {2}, {3}", p1.x, p1.y, p3.x, p3.y);
    Console.WriteLine("{0}, {1}, {2}, {3}", p2.x, p2.y, p4.x, p4.y);
  }
}

che presenta il seguente output:

2, 3, 2, 3
5, 6, 5, 6
9, 8, 9, 8
5, 6, 1, 0

Andiamo con ordine.
---  Abbiamo creato una classe dalla riga 2 alla 11 (un reference type quindi) e una struct dalla riga 13 alla 13 (un value type).
---  Alle righe 23 e 24 ne abbiamo creato delle istanze, (p1 e p2) ossia delle variabili basate sui due tipi e alle righe 27 e 28 una copia (p3 e p4) per ciascuna istanza.
---  Le righe 29 e 30 mostrano, esponendo l’output relativo ai valori dei rispettivi campi, che le istanze principali e le loro copie sono perfettamente identiche in termini di detti valori.
---  Le righe dalla 31 e 34 procurano delle alterazioni nelle copie p2 e p4.
---  L’output alle righe 35 e 36 dimostrano che la variazione introdotta su p2 causa un cambiamento dei valori anche in p1 mentre i mutamenti in p4 non intaccano p2 che mantiene i valori 5 e 6 rispettivamente per x ed y.

Ripeto non guardate tanto a come sono fatte le struct e le classi o al processo di istanziazione che è un po' diverso, solo apparentemente, da quello che conosciamo. Chiediamoci invece perchè se cambio i valori su una copia anche l'originale viene cambiato se si tratta di un reference type mentre le copie sono indipendenti in caso di value type. La risposta la trovate in questa figura:


Il fatto è che creare la copia di un reference significa creare un copia di un puntatore e questa non può che condurci alla stessa locazione di memoria dell'originale. Una eventuale terza copia di p1 avrebbe puntato ancora alla medesima zona e così pure una quarta e così via. Parlando di tipi valore invece ogni istanza occupa una diversa cella di memoria e quindi ogni modifica su uno di essi non intacca i valori dell'altro; ogni elemento vive di vita propria. Questa è la grande differenza di comportamento e strada facendo impareremo conoscere queste peculiarità. In particolare parlando parametri verso i metodi vedremo come la diversa natura abbia importanti conseguenze.

A questo punto abbiamo imparato come dichiarare le variabile, assegnare un tipo e compreso le differenza basilare delle due più grandi famiglie di tipi che abitano il mondo .Net. Un accenno è doveroso farlo alle "case" di tipi valori e di tipi per riferimento ovvero rispettivamente per lo stack e per lo heap.
Il primo è un ben nota struttura a pila del tipo LIFO (Last In First Out) semplice concettualmente e tipicamente efficiente; in esso vengono allocate variabili, metodi e parametri delle funzioni che “appoggiano”queste entità una sopra l’altra; man mano che le funzioni terminano lo stack, che normalmente è di piccole dimensioni, si svuota, partendo, evidentemente, dall’alto. Quindi cresce e decresce dinamicamente a seconda che entrino o escano elementi. In pratica reca una traccia di cosa viene eseguito nel nostro codice. E’ una struttura compatta composta, in un certo modo di “strati” o di scatole se preferite l’immagine (tecnicamente ciascuno di essi si chiama frame) ognuno dei quali corrisponde ad un qualcosa che vi viene appoggiato sopra. E' una struttura altamente efficiente ma rigida.Non è m ia intenzione fare una lunga disquisizione su stack e heap, sul web troverete di tutto e di più. Comunque, giusto per far capire un po' il meccanismo a chi è interessato e ancora non ha ben chiaro come possa funzionare ecco la figura seguente:

Cosa significa:
1) Inizia il programma, i dati value types del metodo main, entry point come sappiamo, vengono caricati nello stack.
2) Nel main viene richiamato un certo metodo che impila i suoi dati nello stack sopra quelli di main
3) Quando il metodo giunge al termine i dati e il suo frame vengono rimossi (LIFO)
4) Termina il main, e quindi il programma, ed anche i suoi dati escono dallo stack.

Un po' semplicistico come esempio ma dovrebbe rendere l'idea.

Lo heap invece, che è responsabile della gestione degli oggetti, è abbastanza vicino al concetto di memoria libera e, contrariamente allo stack, può soffrire di problemi di frammentazione a causa della natura dinamica delle variabili in esso allocate e deallocate che lasciano dei “buchi” dopo la fase di deallocazione i quali possono anche creare dei problemi di saturazione. L'inserimento dei dati è più caoitico come potrete capire. Come vedremo il fatto che il nome di un reference type sia in pratica un puntatore ad una zona di memoria porta a qualche complicazione aggiuntiva. Nello stack insomma si può immaginare il contenuto ordinatamente impilato mentre lo heap può apparire più caotico e casuale per quanto riguarda la distribuzione interna degli oggetti con l'indubbio vantaggio di poterli gestire in maniera random e non gerarchizzata. Stack e Heap sono dimensionalmente limitati dalla memoria virtuale disponibile per il processo cui si riferiscono. I tipi valore che vengono caricati nello heap (come abbiamo accennato ad esempio i campi value type di una classe) sopravvivono in esso finchè non cessa il dominio dell’applicazione. In aiuto allo heap c’è il già citato garbage collector, che è un meccanismo che si occupa di liberare lo heap da oggetti non più referenziati, come abbiamo detto anche nel paragrafo dedicato al framwork .Net. E’ molto utile e solleva il programmatore da una buona parte delle problematiche gestione degli oggetti stessi, purchè si rimanga nell’ambito managed. Per quanto come detto si tratti di un automatismo è tuttavia possibile interagire con esso per via programmatica, pur se, almeno in base alle mie esperienze, capita raramente. Va da sè che sia lo heap che lo stack sono in realtà astrazioni costruite dal compilatore sulla base della memoria fisica del computer. Devo dire che solo da programmatori con molta esperienza ho visto fare un uso corretto e coerente di stack e heap. Ovviamente per chi è all'inizio si tratta di un argomento direi persino inutile, ma nel corso del tempo imparerete ad apprezzare anche certe finezze.

Concludiamo questo importante e forse un po' noioso paragrafo con una piccola sorpresa: ovvero la rivelazione che tipi valore e reference types hanno una radice comune; si tratta di System.Object ovvero la classe archetipa, vero progenitore e supporto di tutto quanto esiste in .Net indipendentemente dal linguaggio usato. L'esistenza di un oggetto che stia al di sopra di tutti e dal quale tutti gli altri discendono è comune in altri linguaggi a oggi, ad esempio anche Java ha un padre per tutte le sue classi che si chiama anche in questo caso Object, mentre nel linguaggio Scala si chiama Any e nel recente linguaggio Ceylon il nome attribuito alla classe root è Anything. Nel nostro framework l'immagine seguente raffigura la situazione:


Molto semplice da capire. Ma l'aspetto interessante è che dal momento che esiste una base comune significa che dovrebbe esistere una base comune anche dal punto di vista comportamentale dei due rami. Ebbene, questa base siste, sia pur molto ridotta sotto forma di un pugno di metodi comuni, i seguenti:

Metodo Modificatore Uso
Equals() Pubblico Supporta il confronto accettando uno o due (statico) parametri object
Finalize() Protetto Usato per eliminare l’oggetto dalla memoria, richiamato dal garbage collector
GetHashCode() Pubblico Genera un valore per il tipo nell’ambito di una hash table
GetType() Pubblico Restituisce il tipo dell’oggetto. Lo abbiamo incontrato nell’esempio 4.6
MemberwiseClone() Protetto Crea una shallow copy dell'oggetto (importante, ne riparleremo)
ToString() Pubblico Restituisce una stringa che rappresenta l'oggetto corrente
ReferenceEquals() Pubblico Statico - determina l'identità tra istanze

Qualunque tipo di dato usiate nei vostri programmi su di esso saranno applicabili questi metodi. Un esempio?

  Esempio 4.8
1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
class program
{
  public static void Main()
  {
    Console.WriteLine(1.GetType());
    Console.WriteLine((1 + 1).Equals(2));
    string s1 = 22.ToString();
    Console.WriteLine(s1);
    string s2 = "22";
    Console.WriteLine(Object.ReferenceEquals(s1, s2));
  }
}

Non fatevi venire troppi mal di testa per il momento per capire proprio tutto di questo banale esempio.
Object, proprio a causa del fatto che costituisce il punto di incontro tra tipi di natura molto diversa, è utile come elemento generico di memorizzazione dei dati, ne parleremo ad esempio nel capitolo degli array, ed è anche alla base del potente ed importante meccanismo di boxing e unboxing che spiegheremo più avanti e che costituisce un ponte virtuale tra value e reference types.

Un argomento abbastanza importante, introdotto nella versione 2.0 del linguaggio, se ricordo bene, è quello dei nullable types. Si tratta di un concetto un po' complicato, sulle prime, ma diventerà più chiaro col passar del tempo. Un tipo T può divenire un'istanza della struct System.Nullable. In pratica l'effetto che si ha su quel tipo, che viene in un certo senso "inscatolato" in questa struct, è quello di avere tra i suoi valori ammissibili anche null, pur se non previsto per il tipo originale. Ad esempio il tipo Int32 se trasformato in nullable, ha come valori accettabili ovviamente quelli di default previsti per il tipo (da -2147483648 a 2147483647) e anche null, in aggiunta. La sintassi per definire un tipo Nullable, ovvero per wrappare un tipo in System.Nullable è

T? ove T rappresenta il tipo. Ad esempio

int x = 0;        // ok
int? y = 0;       // ok
int z = null;     // errore - int non ammette il valore null
int? t = null;    // ok


Il terzo caso dà errore perchè gli interi non accettano null tra i loro valori. Al contrario il quarto va bene. Approfondiremo più avanti questo discorso, se non è chiaro consiglio di tornarci sopra quanto avrete preso un po' la mano col linguaggio.

Siamo giunti alla fine di questo paragrafo di pura presentazione; per il momento basta ricordare le poche nozioni fondamentali qui espresse, che sono però vitali per una piena comprensione degli argomenti che seguiranno. Per fare qualche utile test è almeno necessario tenere presenti:

•  La necessità di dichiarare correttamente in termini grammaticali e, preferibilmente anche dal punto di vista delle convenzioni, ed inizializzare le variabili che vogliamo tenendo d’occhio il discorso relativo alla loro visibilità (o scope)
•  La scelta e l’assegnazione del tipo corretto (o la creazione della classe adatta) in base ai contenuti che nella variabile vogliamo memorizzare.

Sembra poco ma è la base senza la quale non si fa nulla.

Il valori sui tipi non si ferma certamente qua, parleremo di conversioni, del meccanismo di boxing e unboxing, insomma c'è molta carne al fuoco. Nei prossimi paragrafi impareremo quindi quali sono nel dettaglio i tipi valore che abbiamo a disposizione in C#.