M19. INTRODUZIONE AL C#
Introduzione
Per parlare di C# cominciamo a dire che gestire la memoria è un problema, perché bisogna rilasciarla e accedere a essa solo quando è “valida”. Per questo motivo hanno avuto
grande successo i linguaggi gestiti, fatti in modo tale che la memoria non sia sotto il diretto controllo del programmatore, ma di un qualche sistema – come il garbage collector
– che si occupa di rilasciarla nel momento opportuno. Quindi Microsoft ha provato a fare
una sua implementazione di Java, ma Sun si è un po’ incazzata. Perciò ha accantonato la
cosa creando C#, che ricalca solo l’idea di Java.
C# supporta astrazioni a livello più elevato rispetto a quelle di C/C++. L’oggetto non è
caratterizzato solo in termini di metodi e campi, ma ha anche altre cose:
• proprietà, sembrano variabili istanza ma sono dei metodi
• eventi, un misto tra una variabile che contiene un dato e un puntatore a funzione
• attributi, che in realtà sono metadati e assomigliano alle annotazioni in Java
A differenza di quello che succede negli altri linguaggi dove ciò che si compila diventa
un blocco .obj o un .class, qui nasce un eseguibile che prende il nome di assembly.
Quest’ultimo è un pacchetto entro-contenuto ed autodescrittivo che non richiede l’uso di
linguaggi ad hoc per la descrizione delle interfacce e contiene dentro di sé sia il programma che un insieme di metadati (versione, copyright, certificati associati, documentazione...). Perché ciò sia possibile, l’esecuzione non avviene direttamente sul processore specifico ma su una virtual machine (“compile once, run everywhere”).
Dato che C# è arrivato dopo ha risolto tutta una rogne che c’erano in Java, dove i tipi
elementari non sono oggetti: questo implica l’aggiunta di una serie di complicazioni nella gestione di liste, tra cui l’introduzione di classi wrapper. Questi ultimi però non sono
mutabili.
In C# le differenze sono quindi meno nette, perché:
• la conversione da valore elementare a oggetto è trasparente (boxing)
• la conversione inversa è esplicita
• esistono classi wrapper (Int32, Byte, …) modificabili
L’allocazione di memoria è gestita automaticamente e il compilatore verifica la corretta
inizializzazione delle variabili. Il concetto di eccezione è cablato nel linguaggio, ma a differenza di Java, in cui le eccezioni devono essere catchate o al più inviate al livello superiore, qui non c’è l’obbligo. Ogni modulo binario ha esplicitamente una versione ed è
compito del programmatore gestire eventuali conflitti.
Contemporaneamente a C# sono stati ripensati una serie di altri linguaggi: da Visual Basic ad esempio è nato .NET. Il tutto è stato ridisegnato al fine di garantire un alto livello
di interoperabilità (es.: programmi con interfaccia in C#, business logic in C++ e accesso al database in VB) e con altri standard come XML.
Architettura .NET
Com’è che funziona tutta la baracca? Alla base di tutto c’è
Windows, con i suoi meccanismi. Sopra Windows c’è una
macchina virtuale che prende il nome di CLR: Common
Language Runtime, la quale conosce i modelli elementari
del linguaggio (tipi, eccezioni, debug, compilazione JIT).
Al di sopra di questo strato c’è il framework che è formato da un insieme di classi strutturate gerarchicamente e facenti capo alla classe System.Object. Queste classi servono
a gestire I/O, stringhe, strutture dati complesse, ecc.
Su questo strato elementare che consente di creare applicazioni general purpose, c’è
uno strato che consente la gestione dei dati in basi dati. C’è dunque tutto un insieme di
package che ci permettono di esportare da e verso diversi tipi di basi dati.
In cima a tutto c’è lo strato applicativo, molto differenziato a seconda di quello che vogliamo usare (interfacce vecchie, nuove o per applicazioni web)
Alla base di C#: Common Language Runtime
Il CLR è lo strato software che media tra l’esecuzione del linguaggio generato dai compilatori e la macchina sottostante. Questo perché i compilatori generano moduli espressi
in un linguaggio intermedio comune, CIL: eseguire direttamente questo codice porterebbe a prestazioni non buonissime, in quanto è basato su un modello a stack infinito. Il
Common Intermediate Language serve quindi a togliere tutte le dipendenze dalla singola CPU ed è molto potente. Contiene le istruzioni base per l’esecuzione (JMP, CALL, IF...)
e quelle necessarie al caricamento e all’inizializzazione delle classi, nonché per l’esecuzione di metodi. Indipendentemente da come viene generato, il codice CIL può essere
mischiato con codice CIL proveniente da altri linguaggi ed è compilato just-in-time.
Il common type system vede tutta una serie di cose
(tipi elementari e complessi). La conoscenza di questo
sistema comune dei tipi permette la costruzione dei
compilatori del linguaggio intermedio. C’è una fase di
supporto all’esecuzione (creazione e gestione di thread...) e una gestione di garbage collection che blocca
tutto. Come in Java è presente un class loader che si occupa del mapping delle classi del progetto tenendo conto di eventuali vincoli di sicurezza.
Managed code
Il codice è detto managed perché l’esecuzione avviene nella macchina virtuale e gli oggetti hanno un ciclo di vita gestito automaticamente (siamo noi a decidere quando nascono, ma è il sistema a decidere quando muoiono). Nel sistema è inglobata la gestione
delle eccezioni.
Il linguaggio C#
C# è un linguaggio ad oggetti: la cosa più piccola creabile è una classe, quindi per scrivere un main() dobbiamo fare come in Java. E’ fortemente tipato, quindi non si può dire
“sì, questa è una stringa ma usala come intero”. Ha dei tipi più deboli (come i puntatori)
da utilizzare a nostro rischio e pericolo.
Come Java è ad ereditarietà semplice, quindi non è lecito derivare da più classi. Tutte le
classi derivano da System.Object. I metodi per default sono polimorfici: se nella classe
Base è definito un metodo m che viene ridefinito nella classe Derivata, è sempre usata la
definizione contenuta in quest’ultima anche se l’utilizzatore pensa che l’oggetto appartenga alla classe Base. Ogni oggetto contiene un puntatore alla classe a cui appartiene,
quindi è possibile sapere la classe da cui deriva secondo il meccanismo della reflection:
è inoltre possibile sintetizzare via codice nuove classi, secondo il meccanismo dell’emit.
Così come Java è costruito sui pattern di programmazione, anche C# fa la stessa cosa:
troviamo ad esempio la gestione di file con gli stream, i meccanismi di iterazione, la gestione delle risorse, ecc.
using System;
/* Non è necessario dichiarare degli args. Main va con la maiuscola */
class Hello {
public static void Main() {
Console.WriteLine("Hello World!");
}
}
Compilando questo Main con csc Hello.cs esce un .exe.
Questo è possibile perché nel portable executable è segnato un entry point a una JMP indirizzo. L’indirizzo fa
riferimento a una DLL mappata dal sistema (ci ha pensato il loader, mappando le DLL nello spazio di indirizzamento), ovvero mscoree.dll.
Quest’ultima ha un blocchetto che determina su che
macchina (client o server) si è in esecuzione. In funzione
del tipo, mscoree.dll decide se caricare nello spazio di indirizzamento la libreria mscorwrks.dll (client, cerco di ottimizzare l’interattività) o mscorsvr.dll (server, cerco di ottimizzare l’uso dei processori).
In ciascuna di queste due DLL è presente una funzione _CorExeMain, che inizializza
l’ambiente d’esecuzione, compila JIT il metodo Main (scritto in CIL dentro Hello.exe) in
un buffer e salta a questo buffer.
Vale la pena spendere due parole sulle differenze tra Win32 e .NET per quanto riguarda
l’unità di esecuzione. In Win32 l’unità di esecuzione è il processo che contiene uno spazio di indirizzamento, in .NET è un AppDomain: un singolo processo che contiene una
macchina virtuale può contenere molte applicazioni separate. I thread sono divisi in
gruppi e ogni gruppo non può inficiare gli altri.
Sintassi del linguaggio
Commenti XML
Facendo precedere ogni riga da un triplo slash (///) siamo in grado di aggiungere documentazione al linguaggio in dialetto XML.
class Element {
/// <summary>
///
Returns the attribute with the given name and
///
namespace</summary>
/// <param name="name">
///
The name of the attribute</param>
/// <param name="ns">
///
The namespace of the attribute, or null if
///
the attribute has no namespace</param>
/// <return>
///
The attribute value, or null if the attribute
///
does not exist</return>
/// <seealso cref="GetAttr(string)"/>
///
public string GetAttr(string name, string ns) {
...
}
}
Istruzioni ed espressioni
Circa le stesse che si hanno in C/C++/Java. Ci sono alcune lievi differenze:
• lo switch in quasi tutti gli altri linguaggi ha un comportamento fall-through (se
non scrivo break si continua a fare tutto il resto), in C# no
• non è possibile fare goto da un blocco a un altro blocco
• esiste l’istruzione foreach (Variabile in Contenitore) che automatizza l’analisi
di tutti gli elementi in un contenitore che implementa l’interfaccia System.Ienumeration
IList customers =...;
foreach (Customer c in customers.OrderBy("name")) {
if (c.Orders.Count != 0)
...
}
•
le istruzioni matematiche possono scatenare overflow e possono scatenare
un’eccezione se debitamente annotate
Tipi di dato
Ogni dato ha ovviamente un tipo. In C# anche ogni variabile ha un tipo (in Javascript ad
esempio non è così) predeterminato: il compilatore si fa carico di garantire che a quella
variabile siano assegnati dati coerenti col tipo statico noto alla variabile.
I tipi sono organizzati in una gerarchia di ereditarietà semplice con radice in System.Object. Vengono distinti due rami:
• tipi valore, che contengono direttamente il dato, pensati per essere ospitati (anche) dentro lo stack; non possono valere NULL e quando vengono copiati si effettua una copia del valore
• tipi reference, che contengono un puntatore al valore situato nell’heap gestito
(quello soggetto a garbage collection); possono valere NULL e quando vengono
copiati si effettua una copia del puntatore
Nell’esempio, int i causa la predisposizione di un
blocco nello stack in cui è contenuto direttamente il
valore. L’istruzione string s causa invece l’allocazione di un blocco nell’heap gestito: la variabile s conosce solo il puntatore all’oggetto.
In particolare, l’oggetto ha dentro di sé tutta una serie di cose implicite, tra cui un riferimento alla classe
e un flag di raggiungibilità utilizzabile dal garbage
collector per sapere se eliminare oggetti (e poi effettuare la compattazione). I puntatori
NON sono dunque garantiti costanti, perché dopo una garbage collection s può puntare
a “Hello World” spostata a un diverso indirizzo.
Organizzazione dei tipi
Tutto deriva da System.Object, che di per sé è un tipo riferimento (la cosa è una forzatura, ma dev’essere fatta perché altrimenti non se ne esce).
I tipi valore hanno un corrispondente tipo riferimento e il
compilatore automaticamente mappa dall’uno all’altro. La
procedura per passare da tipo valore a tipo riferimento si
chiama boxing, l’inverso si chiama unboxing e richiede un
cast. Nell’esempio è importante notare che se i viene mutato, o non cambia (e viceversa).
Tipi predefiniti
Numerici interi
Con segno: sbyte, short, int, long
Senza segno: byte, ushort, uint, ulong
Numerici reali
float, double, decimal
Non numerici
char
Riferimento
object
string
(formato Unicode), bool
base di tutti i tipi (System.Object)
sequenza immutabile di caratteri Unicode (System.String)
Strutture
E’ possibile la costruzione di tipi valore come le struct che assomigliano sintatticamente
alle classi (possono avere campi, metodi e costruttori), ma non supportano l’ereditarietà. Intasano un po’ di più lo stack, ma non richiedendo l’uso dello heap non generano
problemi di garbage collection.
Classi
Le classi sono organizzate in una gerarchia di
ereditarietà semplice e possono implementare
molte interfacce. Ogni classe ha un nome e un
package (che qui è chiamato namespace).
All’interno della classe si possono dichiarare
elementi public, protected, private, internal.
Dentro a una classe ci possono essere diverse
cose, tra cui costanti (campi preceduti dalla parola chiave const), campi (gli attributi in Java, le
variabili istanza in C++), metodi (funzioni che
contengono un riferimento implicito all’istanza corrente), costruttori, distruttori (anche
se non servono quasi mai perché le risorse vengono gestite automaticamente), proprietà, indicizzatori, eventi, operatori. I singoli elementi delle classi possono essere relativi
alle singole istanze o all’intera classe, mediante l’uso della parola chiave static.
Le proprietà sono elementi presenti in una classe che sintatticamente vengono usati
come campi, ma che in realtà consistono in una coppia di metodi getter e setter. Da un
punto di vista pratico una proprietà si appoggia su una variabile privata dello stesso
tipo. In definitiva sono metodi che permettono di accedere a una variabile privata come
se fosse pubblica.
public class Button: Control {
private string _caption;
public string Caption {
get {return _caption;}
set {
_caption = value;
Refresh();
}
}
}
public class Test {
public string Caption {
get; set;
}
}
Scrivere b.Caption = "OK" equivale quindi a invocare un ipotetico setter di _caption. E’
lecito evitare di implementare i metodi get e set esplicitamente (perché magari ho bisogno di comportamenti espliciti) usando la sintassi di destra: il vantaggio di seguire questa strada è dato dal fatto che ponendo il set come private potrei leggere, ma non scrivere.
Dal C++ viene ripreso il concetto di operator overloading, che permette di utilizzare gli
operatori nei modi più disparati. L’indicizzatore effettua l’overloading di operator[]: in
questo modo si può far apparire un oggetto come un array, senza che questo lo sia.
public class ListBox : Control {
private string[] items;
public string this[int index] {
get {return items[index];}
set {
items[index] = value;
Repaint();
}
}
}
ListBox lb = new ListBox();
lb[0] = "hello";
Console.WriteLine(lb[0]);
Nell’esempio, l’oggetto ListBox lb appare come un array ed è quindi utilizzabile come
un array. Si noti che si è scelto di fare l’overloading di operator[] passando un indice intero, ma è assolutamente lecito usare altri tipi di indici.
Interfacce
Un’interfaccia è una classe completamente astratta. Una singola classe può implementare molte interfacce.
interface IDataBound {
void Bind(IDataBinder binder);
}
class EditBox: Control, IDataBound {
void IDataBound.Bind(IDataBinder binder) {...}
}
Nelle interfacce è lecito inserire, oltre ai metodi, anche proprietà, indicizzatori ed eventi.
Callback, delegati ed eventi
L’esecuzione di un algoritmo può richiedere che un metodo chiamato richiami un metodo del chiamante: perché questo avvenga in modo parametrico, occorre che il chiamato
conosca sia l’identità del chiamante che il metodo da invocare (con i relativi parametri).
L’implementazione del pattern “callback” richiede una certa quantità di codice sia in
C++ (usando liste di puntatori a funzione) che in Java (usando liste e interfacce listener). Quando si creano le interfacce grafiche servono un sacco di callback, quindi bisogna che il giochino funzioni bene e sia facile.
Per semplificare la vita, in C# è stato introdotto il tipo delegato che serve a implementare il meccanismo della callback. Internamente mantiene delle liste di coppie (this, metodo da chiamare) e il tipo del delegato mi specifica la signature del metodo da inserire
dentro la lista. Su un oggetto di tipo delegato mi posso registrare, indicando la mia identità e il metodo da chiamare desiderato.
Il tipo delegato è eseguibile! Quando eseguo un tipo delegato vado da tutti quelli che
sono dentro chiamandoli, facendo sì che tutti facciano qualcosa.
namespace PDSLezM37 {
class Program {
delegate void TipoDelegato(int i);
static void ProvaLui(int n) {
Console.WriteLine("Prova (" + n + ")");
}
/* 1) Dichiarazione simil-prototipo */
static void Main(string[] args) {
TipoDelegato d1;
/* 2) Dichiarazione di variabile col tipo del delegato */
d1 = Console.WriteLine; /* 3a) Cancella tutta la lista e aggiungi un interessato */
d1 += Program.ProvaLui; /* 3b) Aggiungo un secondo interessato */
d1(10);
/* 4) Invoco il delegato con i suoi parametri */
d1 -= Console.WriteLine; /* Rimuovo la console dai registrati */
d1(20);
/* Dico a ProvaLui (unico registrato) di stampare 20 */
}
}
}
Un meccanismo di questo genere è importantissimo, perché consente di fare cose molto
utili senza sapere a chi siano dovute. Un classico esempio è quello del bottone premuto
su un’interfaccia grafica: se qualcuno vuol sapere se quel bottone è stato premuto, si registrerà sul delegato del bottone. Nell’esempio, i metodi sono stati aggiunti usando una
sintassi compatta. Quella completa sarebbe d1 = new Handler(Console.WriteLine).
La dichiarazione del tipo delegato è più o meno come un prototipo di funzione, con un
tipo di ritorno, un nome e una lista di parametri formali. Normalmente il tipo di ritorno è
void.
I metodi sono chiamati in ordine. Se uno degli n chiamati scatena un’eccezione si blocca
tutto e l’eccezione viene mandata al pezzo di codice che invoca il delegato (surroundabile con try/catch).
Nell’esempio, al punto 3a è evidenziato che l’istruzione d1 = Console.WriteLine cancella eventuali soggetti già registrati. C# permette di utilizzare la parola chiave event che
restringe l’utilizzo agli operatori += e -=. Può essere usata solo davanti a campi. I delegati associati ad un evento hanno tipicamente due parametri e ritornano void: il primo parametro, di classe System.Object, rappresenta il mittente dell’evento, il secondo parametro, di classe System.EventArgs, rappresenta gli eventuali dettagli associati all’evento.
public delegate void Handler(object sender, EventArgs e);
public class Button
{
public event Handler Click;
protected void OnClick(...) {
/* Prima di invocare un delegato, bisogna testare che ci sia almeno un iscritto */
if (Click != null) Click(this, new MouseEventArgs(...));
}
}
public class Test
{
public static void MyHandler(object sender, EventArgs e) {
/* Reagisci all'evento */
}
public static void Main() {
Button b = new Button();
b.Click += new Handler(MyHandler);
}
}
Esattamente come in C++ è lecito costruire delle funzioni lambda assegnabili a istanze
di delegati o di eventi. La sintassi ( () => {}) è simile a quella del C++. Gli elementi tra
parentesi tonde vengono catturati per riferimento e il ciclo di vita è prolungato perché le
variabili sono promosse sullo heap.
delegate bool D1();
delegate bool D2(int i);
class Test {
D1 del1; D2 del2;
public void method(int input) {
int j=0; /* j è una variabile locale inizializzata */
del1 = () => { j=10; return j >input; }
del2 = (x) => { return x == j; }
bool result = del1(); /* true, j diventa 10 */
}
}
public static void Main() {
Test test=new Test();
test.method(5);
bool result = test.del2(10); /* true, j vale 10 */
}
Attributi
Gli attributi permettono l’aggiunta di metadati al codice sorgente, che vengono scritti
tra parentesi quadre vicino al nome dei metodi.
public class OrderProcessor {
[WebMethod] /* Genera in automatico tutto il codice per pubblicare l'endpoint */
public void SubmitOrder(PurchaseOrder order) {...}
}
/* Gestione della serializzazione XML */
[XmlRoot("Order", Namespace="urn:acme.b2b-schema.v1")]
public class PurchaseOrder {
[XmlElement("shipTo")] public Address ShipTo;
[XmlElement("billTo")] public Address BillTo;
[XmlElement("comment")] public string Comment;
[XmlElement("items")]
public Item[] Items;
[XmlAttribute("date")] public DateTime OrderDate;
}
public class Address {...}
public class Item {...}
La libreria del framework
Enorme. Digeribile se si sa che i 7000 e più componenti
sono organizzati in circa 100 namespace organizzati
gerarchicamente. Ci sono tutta una serie di aree funzionali: I/O legato a file e flussi ( System.IO e parenti),
gestione di collezione di oggetti (System.Util), espressioni regolari (System.Regex), socket e interazioni con la
rete (System.Network), accesso ai dati, reflection e generazione dinamica del codice, consumo e generazione di servizi web, interfacce grafiche...
Input/Output
Molto più ganzo rispetto a quello del C. La classe astratta Stream costituisce la principale
astrazione e rappresenta una sequenza di byte che si possono leggere o scrivere. Si lavora sempre con una qualche classe concreta
BufferedStream:
FileStream:
blocco di byte gestito tutti insieme
modella l’accesso a un file su disco
MemoryStream:
modella l’accesso a blocco di byte
NetworkStream:
CryptoStream:
modella l’accesso a un socket
flusso per gestire dati crittografati
Per l’uso di flussi di testo si possono usare le classi TextReader e TextWriter che permettono rispettivamente di leggere e scrivere un flusso di caratteri, uno alla volta. Da queste derivano classi concrete per leggere da stream o stringhe.
Interfacce grafiche
Cambiano radicalmente il modo in cui si utilizza il programma, perché l’utente non segue più il flusso dettato dal calcolatore. Di conseguenza, la comunicazione da e verso
l’applicazione avviene servendosi dei controlli disegnati sullo schermo: ciascun elemento presente nell’interfaccia offre un numero limitato di funzionalità (nel bottone non posso scriverci, la casella di testo non è cliccabile), in modo che i singoli oggetti siano altamente specializzati. In compenso il tutto si complica, proprio perché non posso prevedere l’ordine dei comandi dettato dall’utente.
E’ dunque il programma a reagire agli eventi esterni cooperando col sistema operativo.
L’applicazione deve prima di tutto comporre le videate e in seguito preparare delle routine da eseguire, invocate automaticamente dal sistema
quando si verifica qualcosa di particolare.
Per interagire con lo schermo, un’applicazione crea
una o più finestre. Ogni finestra ha una HANDLE mantenuta dal sistema operativo corrente e a cui è associata una coda di messaggi per far sapere cosa è capitato. Il GUI Manager è incaricato di leggere fisicamente
mouse e tastiera e dare alle diverse code i messaggi relativi.
Siccome non è prevedibile quale azione compia l’utente in quale momento bisogna seguire un pattern di programmazione reattiva. Il nostro programma deve dunque dire in
anticipo cosa deve succedere (e in ciò gli eventi diventano essenziali). In relazione a un
evento si esegue un po’ di codice e recarsi eventualmente dal GUI Manager per ridisegnare il tutto.
Per interagire con il GUI Manager un’applicazione deve:
• iniziare una sessione di lavoro, creando la propria coda di messaggi
• richiedere la creazione di risorse grafiche (finestre, bottoni, campi di testo, …)
• predisporre, per ogni widget ed evento atteso, un’opportuna routine di callback
• iterare sulla coda dei messaggi, inoltrando le richieste ricevute alla relativa callback
Le modalità con cui queste operazioni vengono effettuate, dipendono dal sistema operativo e/o dagli eventuali strati software intermedi adottati.
I componenti grafici derivano dalla classe System.Windows.Forms.Control. Su questi
componenti si possono fare un mucchio di cose (controllo dimensioni, posizioni, aspetto,
organizzazione logica, interattività, visibilità). Tipicamente la radice di tutto è un oggetto
Form.
***
Scarica

m19. introduzione al c