University Logo

Università
di Padova

Diploma in Ingegneria Informatica
Corso di Fondamenti di Informatica 2
2 Moduli

A.A. 2001/2002

Versione 1.0 27/09/2001

FAQ & IAQ


  1. Introduzione a Java
  2. Strutture lineari
  3. Ordinamento
  4. Interfaccia grafica
  5. C++

Pagina principale Torna alla pagina principale


  1. Introduzione a Java
  2. Domanda 1.1

    Se un'applicazione definisce una nuova eccezione, questa deve essere sempre di tipo controllato?

    Risposta 1.1

    Non necessariamente. È pur vero che, da un certo punto di vista, è opportuno definire solo eccezioni controllate cosicché il compilatore può eseguire alcuni controlli di consistenza tra metodi che possono sollevare eccezione e i potenziali loro utilizzatori, che ne dovrebbero essere consci; contestualmente, grazie alle clausole throws obbligatorie, di questi fatti è reso consapevole anche un lettore diverso dal programmatore. È però anche vero che l'uso di eccezioni controllate rende piuttosto pesante la programmazione e ciò risulta, in molti casi, non giustificato a causa della effettiva eccezionalità dell'evento e del fatto che non è possibile far di meglio che interrompere, con una segnalazione, il programma. In questi casi può essere ragionevole utilizzare una eccezione NON controllata che quindi sarà definita come classe discendente da Error (errori che non dovrebbero mai verificarsi) oppure da RunTimeException (usuali errori della JVM che possono essere non controllati) al contrario di quelle controllate che sono discendenti da Exception.

    Domanda 1.2

    Come mai si fa un uso frequente di casting in alcune parti del codice, rendendolo talvolta di difficile lettura? Non si possono definire prototipi con parametri più specializzati ed evitare così il casting?

    Risposta 1.2

    L'opinione che il casting renda di difficile lettura il codice è certamente condivisibile. Però occorre ricordare il motivo per cui conviene definire metodi con parametri di tipo generale e non specializzato. La possibilità di definire interfacce generiche, classi astratte da cui ricavare specializzazioni, queste istanziabili, più in generale un uso ampio del polimorfismo, come accade ad esempio nella costruzione di contenitori per oggetti generici, richiede che talvolta si debbano usare i cast dal tipo generale a quello più specifico. Un caso molto comune è quello degli 'iteratori': l'interfaccia ObjectIterator prevede che il metodo nextObject() ritorni genericamente un oggetto. Ma quando si utilizza una classe che ritorna da qualche metodo un ObjectIterator, si sa però che l'iteratore scorre oggetti di un certo tipo che, una volta restituiti come Object da nextObject(), è necessario riportare al tipo specifico per poter essere proficuamente manipolati. Di converso, nel caso di PositionIterator che è un iteratore specializzato, i metodi ereditati da ObjectIterator hanno l'interfaccia generica ma il metodo aggiuntivo specializzato NextPosition può restituire direttamente un Position ed evitare così l'uso del casting.

    Parlando di polimorfismo, il fatto di aver definito un metodo in una interfaccia presenta alcuni altri importanti vantaggi. Una classe implementazione dell'interfaccia può fornire un'implementazione di base del metodo, eventualmente utilizzata con quella semantica da altri metodi della stessa classe. Se una classe derivata da quella ridefinisce il metodo, a causa del polimorfismo i metodi della classe base che chiamano quel metodo si troveranno automaticamente a chiamare la sua versione più specializzata, che condivide con quella base lo stesso prototipo. In più, variazioni di realizzazione del metodo o delle sue varie specializzazioni, a patto di mantenerne la semantica, non comportano alcuna variazione sui metodi chiamanti poiché il prototipo rimane quello comune, definito nell'interfaccia.

    Concludendo, i vantaggi che la programmazione object-oriented introduce, soprattutto a livello applicativo, compensano largamente un'apparente difficoltà nel realizzare i metodi di base. Si tenga inoltre presente che nel linguaggio C++ si riesce ad ottenere una maggiore flessibilità mediante l'uso della parametrizzazione multipla dei costrutto template che ovvia al problema del casting. Quindi anche il linguaggio scelto ha la sua parte di responsabilità. In ogni caso il progetto di una libreria di classi completa ed efficace richiede la perizia di un programmatore con esperienza.

    Domanda 1.3

    Se si vuole definire una specializzazione di una classe, quando conviene adottare l'approccio della derivazione e quando quello dell'adattatore?

    Risposta 1.3

    La derivazione è opportuna nei casi in cui la sottoclasse mantiene sostanzialmente le operazioni ammesse dalla superclasse, aggiornandone qualcuna ed aggiungendone di nuove. In particolare deve essere accettabile trattare un oggetto di sottoclasse come oggetto di superclasse secondo il principio del polimorfismo.

    L'approccio di adattatore è consigliabile nei casi in cui, pur rimanendo utili molte operazioni della classe adattata, la nuova classe voglia dare una visibilità esterna diversa, mascherando alcune operazioni, modificandone altre e quindi rendendo non giustificata l'applicazione del polimorfismo. Un caso particolare di questa situazione è quando si vuole limitare la visibilità di un metodo, per esempio passando da public a protected, cosa che è esplicitamente vietata se si usa il messanismo di overriding del metodo.

    Domanda 1.4

    Se un metodo ha un parametro Object, è possibile passare un oggetto mediante un riferimento che ha il tipo di una interfaccia?

    Risposta 1.4

    La risposta è sì. Infatti, pur utilizzando un riferimento definito come interface, l'oggetto ad esso collegato è di classe che implementa l'interfaccia stessa e, come tutti gli oggetti, deriva direttamente o indirettamente (se estende un'altra classe) dalla classe Object. Pertanto può essere passato come un Object. Di converso, è possibile applicare un cast con un tipo di interfaccia al valore di ritorno di un metodo se tale valore è di tipo Object.

    Ad esempio:

    Position p = new GenericPosition(); // GenericPosition deriva da Object
    Sequence s = new ArraySequence();
    s.insertAtRank(0, p);  // secondo parametro Object
    Position q = (Position) s.removeRank(0);  // ritorna un Object
    

    Domanda 1.5

    Come mai si suggerisce sempre come unico metodo di collaudo il metodo statico main della classe? Non sarebbe più opportuno, più chiaro e soprattutto più flessibile dichiarare una o più classi di collaudo collegate alla classe da provare?

    Risposta 1.5

    Certamente. Ciò tra l'altro consentirebbe di creare più agevolmente tipologie di collaudo diverse per la stessa classe e anche collaudi che coinvolgono più classi. Il motivo per cui si ricorre al metodo main è solo per maggiore semplicità: questo metodo è direttamente parte integrante della classe, ha sempre lo stesso prototipo ed evita, se non si pretende maggiore flessibilità, la proliferazione di classi (che può creare un po' di disagio a livello didattico). La presenza del metodo main non esclude per altro la possibilità di definire, se lo si desidera, altre classi di collaudo che possono tranquillamente convivere. Si tenga infine presente che il metodo main, pur essendo normalmente chiamato dall'ambiente esecutivo all'attivazione del programma, può essere chiamato anche da altri metodi (potrebbe addirittura essere un metodo ricorsivo!) e quindi è anche possibile attivare nello stesso programma metodi main relativi a classi diverse.

    Domanda 1.6

    Se una classe B estende A, che relazione c'è tra le classi d'array rispettive A[] e B[]? Funziona anche tra queste il casting?

    Risposta 1.6

    La classe B[] è automaticamente discendente da A[], quindi una variabile di tipo A[] può riferire un array di oggetti di tipo B. Per il casting invece, occorre tener conto che un array di oggetti di tipo A, una volta creato, rimane sempre un array di oggetti di tipo A. Il seguente codice, ove Boh2 discende da Boh, ha la prima istruzione legittima ma dà un errore run-time ArrayStoreException nella seconda istruzione:
            Boh[] v2 = new Boh2[2];
            v2[0] = new Boh();
    
    Questo fatto rende ad esempio problematica la costruzione di un metodo generico del tipo:
    static Object[] arrayGrow(Object[] a) { ... }
    con l'intento di ottenere una versione di ampiezza raddoppiata dell'array originario, passato come parametro in modo generico, perché il metodo non potrebbe che allocare un array di Object che non potrebbe essere riportato ad un array di oggetti del tipo originario. Per risolvere il problema occorre ricorrere alla tecnica della riflessione (vedi [Hor] pag. 199).

    IndiceTorna all'indice


  3. Strutture lineari
  4. Domanda 1.1

    Che differenza c'è tra posizionatore, localizzatore, iteratore ed enumeratore?

    Risposta 1.1

    Il posizionatore può essere visto come un iteratore 'fisso'. Partendo da un posizionatore è possibile accedere all'elemento associato, inserito nel contenitore, e alle posizioni immediatamente contigue. Il posizionatore non è condizionato dall'inserimento o estrazione di elementi diversi da quello associato. Lo è invece nel caso di operazioni che interessano l'elemento associato: in caso di replace o di swap, il posizionatore mantiene la sua collocazione rispetto al contenitore ma viene associato ad un elemento diverso; in caso invece di rimozione dell'elemento, il posizionatore va invalidato. Conviene far coincidere il posizionatore con un nodo della struttura di dati contenitore: in questo modo si è sicuri che sia unico l'oggetto che funge da posizionatore per un determinato elemento nel contenitore, rendendo agevole l'operazione di invalidazione.

    Il localizzatore (locator) è un posizionatore più strettamente legato all'elemento di dati e tale che ne segue le vicissitudini, come ad esempio lo spostamento dovuto ad un swap. Da un localizzatore valido si arriva quindi sempre al medesimo elemento inizialmente associato. Un localizzatore va invalidato quando l'elemento viene rimosso dal contenitore (quindi anche in occasione di un replace).

    Un iteratore può essere corrispondentemente visto come un posizionatore 'mobile', in grado di scorrere la struttura di dati a cui è collegato, restituendo di volta in volta i vari elementi contenuti. Un iteratore dispone delle primitive per spostarsi all'interno del contenitore secondo cammini prefissati, e per questo deve poter conoscere la struttura interna del contenitore. A causa di questa natura mobile, diversamente dal posizionatore, un iteratore deve essere un oggetto distinto dai nodi della struttura di dati, e questo apre la possibilità, in generale, di creare più istanze di un iteratore per lo stesso contenitore. Lo stretto legame con il contenitore porta a definire la classe dell'iteratore come classe interna a quella del contenitore. Questi aspetti rendono delicata l'operazione di invalidazione di tutti gli iteratori associati quando sul contenitore vengono operate azioni di trasformazione, in particolare rimozioni di elementi: gli iteratori comuni allo stesso contenitore devono rimanere fra loro collegati per poter effettuare questa operazione in modo esaustivo. Il problema della validità di un iteratore può essere risolto distinguendo gli iteratori visti ora (detti non conservativi), che si muovono direttamente sul contenitore originario, da quelli (detti conservativi) che vengono associati ad una copia appositamente creata, sottoforma di sequenza, degli elementi nel contenitore. Questo secondo tipo di iteratori prevedono in sostanza che venga effettuata una 'fotografia' del contenitore in un certo istante e che lo 'spazzolamento' avvenga in modo consistente su questa fotografia.

    L'enumeratore è un termine applicabile alla forma semplificata di iteratore data dalla classe java.util.Enumeration che consente solo due operazioni, hasMoreElements() per stabilire se gli elementi sono stati tutti visitati e nextElement(), esattamente come la classe ObjectIterator usata in [Good] seconda edizione. Per semplicità si parlerà di iteratori anche nel caso semplificato di quest'ultima classe.

    Domanda 1.2

    Il metodo newContainer definito nell'interfaccia FI2.Set.Container non poteva essere con più ragione definito statico?

    Risposta 1.2

    La risposta è no, ma la domanda è interessante per più ragioni. Intanto, se si decidesse di dichiarare statico il metodo, non potrebbe comparire in un'interfaccia poiché è vietato dichiarare prototipi statici in un'interfaccia. Il motivo di questo, che è anche la vera ragione per cui la risposta è negativa, è che per i metodi statici non vale la regola del polimorfismo che si può applicare ai metodi non statici. Per illustrare l'affermazione si considerino due classi A e B, la seconda derivata dalla prima, che definiscono entrambe un metodo statico str nel modo seguente:

    public class A
    {
    ...
        public static String str()
        {  return "class A"; }
    ...
    }
    
    public class B extends A
    {
    ...
        public static String str()
        {  return "class B"; }
    ...
    }
    
    Se si eseguono le seguenti due istruzioni:
        A a = new B();
        System.out.println(a.str() + " " + ((B)a).str());
    
    viene prodotto l'output:
    class A class B
    a conferma del fatto che, per i metodi statici, il compilatore prende come riferimento quello della classe della variabile o del cast esplicito, ma non quello della classe dell'oggetto run-time come nel caso dei metodi non statici. Pertanto non ha senso dichiarare un prototipo statico in un'interfaccia, poiché l'uso delle interfacce fa diretto riferimento alla regola del polimorfismo.

    Per completare la risposta, si noti che nel caso dei metodi non statici il compilatore è costretto a generare codice che effettui al tempo di esecuzione la risoluzione dell'effettivo metodo da attivare, che a causa del polimorfismo è, come detto, legato all'oggetto che viene in quel momento trattato; nel caso dei metodi statici può invece generare direttamente il codice di attivazione del metodo che è quello della classe della variabile (o un suo cast). Il codice in questo secondo caso è anche più efficiente e questo può far talvolta preferire la realizzazione di talune funzioni come metodi di classe (cioè statici) piuttosto che come metodi d'istanza.

    IndiceTorna all'indice


  5. Ordinamento
  6. Domanda 3.1

    Perché le metriche forniscono tempi di esecuzione piuttosto imprecisi e spesso, se gli elementi sono pochi, addirittura una durata nulla?

    Risposta 3.1

    Il calcolo della durata di esecuzione di un segmento di codice è viziato sia dalla relativa bassa risoluzione dell'orologio interno (unità o decine di ms, nella migliore delle ipotesi, contro tempi di esecuzione misurati usualmente in µs) sia dal fatto che, in un sistema multitasking, la macchina può non essere dedicata al solo svolgimento del segmento in oggetto, ma può eseguire concorrentemente altre attività che introducono ritardi aggiuntivi, e perciò la misura del tempo trascorso non coincide con quello che la CPU dedica all'esecuzione delle istruzioni del segmento.

    Il primo problema può essere risolto eseguendo più volte il segmento (ad esempio, per durate dell'ordine di qualche decina-centinaia di ms, si può ripetere l'esecuzione 500-1000 volte) e successivamente dividere il tempo trascorso per il fattore di ripetizione. Si potrebbe, a tal fine, arricchire la definizione della classe FI2.Util.Metrics aggiugendo un campo che memorizza il fattore di ripetizione e adattare i metodi di FI2.Sorting.ArraySort in modo che il segmento misurato venga ripetuto quel numero di volte su un array ogni volta riportato alle condizioni di ordinamento iniziali, ed effettuare quindi il calcolo suggerito.

    Per il secondo problema, occorrerebbe disporre nella macchina di prova di un contatore di cicli di clock relativo ai singoli processi in esecuzione o di altri meccanismi simili per il profiling delle applicazioni. A tal proposito si veda la tabella di confronto.

    IndiceTorna all'indice


  7. Interfaccia grafica
  8. Domanda 4.1

    Una volta programmato un applet è agevole trasformarlo in un'applicazione? E il viceversa?

    Risposta 4.1

    Un applet richiede che un browser (o lo appleviewer del JDK) fornisca il contesto grafico e la finestra ove eseguire l'output grafico. L'applet apparirà come un rettangolo della finestra del browser, di dimensioni precisate nel file HTML che incorpora il relativo TAG <APPLET>. Perché possa operare come applicazione autonoma è sufficiente che da parte di un oggetto ausiliario venga allocata un'istanza dell'applet e che tale oggetto fornisca il contesto grafico in sostituzione del browser, ovvero la finestra ove l'applet agirà e l'attivazione dei metodi base dell'applet (init(), start(), ecc.). Il codice dell'applet rimane sostanzialmente invariato. Si vedano gli esempi di applicazioni grafiche forniti.

    L'operazione opposta è invece più difficoltosa poiché una applicazione normalmente apre una propria finestra grafica. Detta App la classe che genera tale finestra e supponendo che un'istanza di questa venga creata da un applet accessorio, ciò produrrebbe l'apertura di una finestra distinta da quella del browser, finestra in cui avverrebbe tutto l'output grafico. L'esecuzione nell'applicazione originaria di un System.exit produrrebbe un'eccezione nell'applet. Volendo invece ottenere l'output nella finestra del browser, si è costretti a modificare il codice in modo da riportarlo ad una struttura tipica di un applet.

    IndiceTorna all'indice


  9. C++
  10. Domanda 5.1

    Che ruolo hanno compilatore, linker, caricatore nella produzione di software in C++? Quali corrispondenze ci sono nei riguardi di Java?

    Risposta 5.1

    Un programma (applicazione) in C++ richiede in generale di fondere uno o più moduli oggetto, generati dalla compilazione di moduli sorgente C++ editati dall'utente, tra loro e con uno o più moduli tratti da librerie. Le librerie da cui vengono estratti questi moduli 'di servizio' sono quelle standard dell'ambiente di compilazione, che contengono ad esempio la realizzazione delle funzioni di supporto quali quelle di interazione con i file (printf, getc, open, fopen, ecc.), quelle di gestione delle stringhe (strcpy, strcmp, ecc.) ecc., e altre librerie che possono essere precisate in fase di collegamento dall'utente. Il collegamento è a carico del linker. Obiettivo finale è generare un modulo eseguibile (di estensione .exe in ambiente DOS/WINDOWS) che può essere successivamente caricato in memoria (dal caricatore incluso nello shell di comando, COMMAND.COM del caso DOS) e posto in esecuzione. Il modulo eseguibile contiene il codice macchina delle istruzioni che corrispondono alle istruzioni ad alto livello scritte nei moduli sorgente.

    Ogni modulo (file) sorgente costituisce quindi in C/C++ l'unità di compilazione. È un grande vantaggio poter realizzare l'applicazione in più moduli compilabili separatamente perché è in questo caso più semplice dominare la possibile complessità dell'applicazione, ad esempio dovendo ri-compilare solo il/i modulo/i interessato/i in caso di qualche modifica, senza dover ri-compilare tutto il sorgente. Nell'ambiente RHIDE compilatore e linker vengono attivati al suo interno in successione mediante l'apposito comando. L'insieme dei moduli che devono essere collegati a formare l'eseguibile finale viene precisato nel project.

    In JAVA l'unità di compilazione è egualmente il modulo (file) sorgente. Generalmente contiene una solo classe pubblica ed eventualmente altre classi al primo livello non pubbliche. La compilazione produce, come equivalente dei moduli oggetto, i file .class: ciascuno di essi contiene il bytecode della corrispondente classe. Non c'è una vera e propria fase di collegamento, nel senso che la fusione dei bytecode coinvolti in una applicazione è a carico del caricatore (ClassLoader) che viene attivato dall'interprete o dal browser. Ogni classe conosce le classi con cui in qualche modo interagisce e quindi al caricatore viene demandato di porre in memoria una dopo l'altra tutte le classi che servono per l'applicazione. Anche in JAVA esiste il concetto di libreria nel senso che l'ambiente prevede un'archivio di classi già compilate che include tutte le classi di sistema e da cui il caricatore può trarre le classi che servono.

    La portabilità in C++ è in larga misura garantita a livello sorgente nel senso che lo stesso set di moduli, se utilizza librerie standard, può essere, previa ricompilazione, reso applicazione eseguibile su architetture diverse con effetti analoghi o eguali. Solo raramente in certi ambienti UNIX si riesce ad ottenere una portabilità a livello di eseguibile (compatibilità binaria) pur in presenza di differenze sostanziali di macchina. La portabilità di JAVA è invece garantita (almeno nelle intenzioni) a livello di bytecode e quindi atteso che sia disponibile la macchina virtuale JAVA (JVM), il bytecode può essere eseguito con effetti simili su macchine anche molto diverse, senza richiedere una ri-compilazione (da cui l'interesse in ambito Internet).

    IndiceTorna all'indice


Pagina principale Torna alla pagina principale