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. ***