Ingegneria del software. Fondamenti e principi
 88-7192-204-2, 9788871922041 [PDF]

  • 0 0 0
  • Gefällt Ihnen dieses papier und der download? Sie können Ihre eigene PDF-Datei in wenigen Minuten kostenlos online veröffentlichen! Anmelden
Datei wird geladen, bitte warten...
Zitiervorschau

Carlo Ghezzi Mehdi Jazayeri Dino Mandrioli

Ingegneria del software Fondamenti e principi 21' edizione

PEARSON

Prefazione alla seconda edizione

xi

Il ruolo dell'orientamento agli oggetti Lo scopo dei casi di studio Risorse per i docenti

xii xiii xiii

Prefazione alla prima edizione

xv

A chi è rivolto Prerequisiti Organizzazione e contenuti Esercizi Casi di studio Laboratorio Percorsi di lettura Ringraziamenti

Ingegneria del software: visione d'insieme

1

II ruolo dell'ingegneria del software nel progetto di un sistema Breve storia dell'ingegneria del software II ruolo dell'ingegnere del software II ciclo di vita del software Rapporto tra l'ingegneria del software e altri campi dell'informatica

2 3 6 6 9

Capitolo 1 1.1 1.2 1.3 1.4 1.5

xvi xvi xvi xvii xviii xviii xviii xix

1.5.1 1.5.2 1.5.3 1.5.4 1.5.5

Linguaggi di p r o g r a m m a z i o n e Sistemi operativi Basi di dati Intelligenza artificiale Modelli teorici

1.6 Relazioni tra l'ingegneria del software e altre discipline 1.6.1 1.6.2

Scienze organizzative Ingegneria dei sistemi

1.7 Osservazioni conclusive Note bibliografiche

Capitolo 2

II software: natura e qualità

2.1 Classificazione delle qualità del software 2.1.1 2.1.2

Qualità interne ed esterne Qualità del processo e qualità del p r o d o t t o

9 10 11 12 12

13 13 14

14 15

17 18 18 19

2.2 Principali qualità del software 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.2.7 2.2.8 2.2.9 2.2.10 2.2.11 2.2.12

Correttezza, affidabilità e robustezza Prestazioni Usabilità Verificabilità Manutenibilità Riusabilità Portabilità Comprensibiltà Interoperabilità Produttività Tempestività Visibilità

2.3 Requisiti di qualità in diverse aree applicative 2.3.1 2.3.2 2.3.3 2.3.4

Sistemi Sistemi Sistemi Sistemi

informativi in t e m p o reale distribuiti embedded

2.4 Misura della qualità 2.5 Osservazioni conclusive Note bibliografiche

Capitolo 3 3.1 3.2 3.3 3.4 3.5 3.6 3.7 3.8

Principi dell'ingegneria del software

Rigore e formalità Separazione degli interessi Modularità Astrazione Anticipazione del cambiamento Generalità Incrementalità Illustrazione dei principi dell'ingegneria del software attraverso due casi di studio 3.8.1 3.8.2

Caso di studio nella costruzione di u n compilatore Caso di studio nell'ingegneria dei sistemi

3.9 Osservazioni conclusive Note bibliografiche

Capitolo 4

Progettazione e architetture software

4.1 Attività di progettazione del software e suoi obiettivi 4.1.1 4.1.2

Progettazione in vista del c a m b i a m e n t o Famiglie di prodotti

4.2 Tecniche di modularizzazione 4.2.1 4.2.2 4.2.3 4.2.4 4.2.5

La struttura m o d u l a r e e la sua rappresentazione Interfaccia, implementazione e i n f o r m a t i o n h i d i n g Notazioni per la progettazione Categorie di m o d u l i Tecniche specifiche per la progettazione in vista del c a m b i a m e n t o

19 19 13 24 25 26 29 31 31 32 33 34 36

37 37 38 40 41

42 42 43

45 47 49 51 55 56 58 59 60 61 66

71 73

75 78 80 85

87 87 95 103 1 11 119

4.2.6 4.2.7

R a f f i n a m e n t o per passi successivi Progettazione t o p - d o w n e b o t t o m - u p

122 129

4.3 Gestione delle anomalie 4.4 Un esempio di progettazione 4.5 Software concorrente 4.5.1 4.5.2 4.5.3

130 134 137

I dati condivisi Software real-time Software distribuito

138 146 148

4.6 Progettazione orientata agli oggetti 4.6.1 4.6.2 4.6.3 4.6.4

154

Generalizzazione e specializzazione Associazioni Aggregazione Ulteriori nozioni sui d i a g r a m m i delle classi U M L

4.7 Architettura e componenti 4.7.1 4.7.2 4.7.3 4.7.4

161

Architetture standard C o m p o n e n t i software L'architettura c o m e p i a t t a f o r m a per l'integrazione dei c o m p o n e n t i Architetture per sistemi distribuiti

4.8 Osservazioni conclusive Note bibliografiche

Capitolo 5 5.1 5.2 5.3 5.4 5.5

5-5-2 5.5.3 5.5.4

Specifica

177 178 181 184 187 188

D i a g r a m m i di flusso di dati: la specifica delle f u n z i o n i dei sistemi informativi D i a g r a m m i U M L per c o m p o r t a m e n t i specifici M a c c h i n e a stati finiti: descrizione del flusso di controllo Le reti di Petri: specifica di sistemi asincroni

5.6 Specifiche descrittive 5.6.1 5.6.2 5.6.3

Requisiti per le notazioni di specifica Costruzione di specifiche modulari Specifiche per l'utente

230 233 251

259

finale

5.8 Osservazioni conclusive Note bibliografiche

Capitolo 6

Verifica

6.1 Obiettivi e requisiti della verifica 6.1.1 6.1.2

188 194 196 204

230

D i a g r a m m i entità-relazione Specifiche logiche Specifiche algebriche

5.7 Stesura e uso delle specifiche nella pratica 5.7.1 5.7.2 5.7.3

162 165 167 169

170 175

I possibili usi delle specifiche Qualità delle specifiche Classificazione degli stili di specifica Verifica delle specifiche Specifiche operazionali 5.5.1

155 158 160 160

Verificare t u t t o I risultati della verifica possono n o n essere binari

259 263 282

283 291

295 296 296 297

6.2 6.3

6.4

6.5

6.6 6.7 6.8 6.9

6.10

6.1.3 La verifica può essere oggettiva o soggettiva 6.1.4 Verificare anche le qualità implicite Approcci alla verifica Test 6.3.1 Obiettivi del test 6.3.2 Fondamenti teorici del test 6.3.3 Principi empirici di test 6.3.4 Test in piccolo 6.3.5 Test in grande 6.3.6 Separazione degli interessi nell'attività di test 6.3.7 Test di software concorrente e real-time Analisi 6.4.1 Tecniche di analisi informale 6.4.2 Prove di correttezza Esecuzione simbolica 6.5.1 Concetti fondamentali dell'esecuzione simbolica 6.5.2 Programmi con array 6.5.3 Uso dell'esecuzione simbolica nel test Model checking Integrazione delle tecniche di verifica Debugging Verifica di altre proprietà del software 6.9.1 Verifica delle prestazioni 6.9.2 Verifica dell'affidabilità 6.9.3 Verifica di qualità soggettive Osservazioni conclusive Note bibliografiche

Capitolo 7

Processo di produzione del software

7.1 Cos'è un modello di un processo di produzione del software? 7.2 Perché i modelli di processo di sviluppo del software sono importanti? 7.3 Attività principali della produzione del software 7.3.1 Studio di fattibilità 7.3.2 Acquisizione, analisi e specifica dei requisiti 7.3.3 Definizione e progettazione dettagliata dell'architettura software 7.3.4 Produzione di codice e test dei moduli 7.3.5 Integrazione e test del sistema 7.3.6 Rilascio, installazione e manutenzione 7.4 Visione d'insieme dei modelli di processo del software 7.4.1 Modello a cascata 7.4.2 Modelli evolutivi 7.4.3 Modello trasformazionale 7.4.4 Modello a spirale 7.4.5 Studio dei modelli di processo 7.5 Gestione del software esistente 7.6 Casi di studio 7.6.1 Caso di studio: un sistema di commutazione telefonica 7.6.2 Caso di studio: un sistema per il controllo del budget

298 299 300 300 302 304 306 309 330 340 342 345 346 349 368 370 373 376 378 381 382 387 387 388 392 403 413

417 419 421 423 424 424 432 432 433 433 435 435 443 446 449 450 453 454 454 459

7.7

7.8 7.9 7.10

7.6.3 Caso di studio: il processo "synchronize and stabilize" di Microsoft 7.6.4 Caso di studio: l'approccio open-source 465 Organizzazione del processo 7.7.1 Structured Analysis/Structured Design 7.7.2 Metodologia di Jackson 7.7.3 Processo unificato di sviluppo del software (UP) Gestione delle configurazioni Standard per il software Osservazioni conclusive Note bibliografiche

Capitolo 8

Gestione dell'ingegneria del software

8.1 Funzioni del management 8.2 Pianificazione di progetto 8.2.1 Produttività del software 8.2.2 Persone e produttività 8.2.3 Stima dei costi 8.3 Controllo di progetto 8.3.1 Schemi di scomposizione delle attività 8.3.2 Diagrammi di Gantt 8.3.3 Diagrammi PERT 8.3.4 Gestione delle deviazioni rispetto al piano 8.4 Organizzazione 8.4.1 Organizzazione centralizzata 8.4.2 Organizzazioni decentralizzate 8.4.3 Organizzazioni miste 8.4.4 Valutazione delle organizzazioni di gruppo 8.5 Gestione dei rischi 8.5.1 Tipici rischi di management nell'ingegneria del software 8.6 II modello CMM 8.7 Osservazioni conclusive Note bibliografiche

Capitolo 9

Strumenti e ambienti dell'ingegneria del software

9.1 Evoluzione storica degli strumenti e degli ambienti 9.2 Parametri di confronto degli strumenti 9.3 Strumenti rappresentativi 9.3.1 Editor 9.3.2 Linker 9.3.3 Interpreti 9.3.4 Generatori di codice 9.3.5 Debugger 9.3.6 Strumenti utilizzati nel test 9.3.7 Analizzatori statici 9.3.8 Strumenti per le interfacce grafiche 9.3.9 Strumenti di gestione delle configurazioni 9.3.10 Sistemi per il tracciamento

464 466 467 472 477 481 484 485 488

491 493 494 496 503 504 512 512 513 515 518 519 522 524 525 526 527 528 530 532 536

539 540 541 545 546 547 547 548 549 550 552 554 556 560

9.3.11 Strumenti di reverse engineering e reingegnerizzazione 9.3.12 Strumenti di supporto al processo 9.3.13 Strumenti di management 9.4 Integrazione di strumenti 9.5 Fattori di influenza dell'evoluzione degli strumenti 9.6 Osservazioni conclusive Note bibliografiche

Capitolo 10 Epilog o 10.1 10.2 10.3 10.4

Appendice

II futuro Responsabilità etiche e sociali Codice etico e deontologia dell'ingegneria del software Commenti conclusivi Note bibliografiche

Casi di studio

Caso di studio A: automazione di un ufficio legale A. 1 Pianificazione economica e finanziaria A.2 Pianificazione tecnica e gestione A.3 Monitoraggio del progetto A.4 Rilascio iniziale A. 5 Un parziale recupero Caso di studio B: costruzione di una famiglia di compilatori B.l Pianificazione iniziale del prodotto B.2 Pianificazione economica e finanziaria B.3 Pianificazione e gestione tecnica B.4 Prime fasi dello sviluppo B.5 Monitoraggio del progetto B.6 Riesame del progetto, ristrutturazione e precisazione degli obiettivi B.7 Assegnamento di responsabilità B.8 Progresso continuo e rilascio del prodotto Distribuzione del prodotto B.9 B.10 Commenti Caso di studio C: sviluppo incrementale Caso di studio D: applicazione di metodi formali nell'industria D.l Formazione D.2 Specifica dei requisiti D.3 Convalida dei requisiti e pianificazione della verifica D.4 Progettazione, implementazione e verifica D.5 Valutazione complessiva D.6 Impatto del progetto sulla strategia aziendale Considerazioni finali Note bibliografiche

561 562 563 564 565 566 568 571 571 574 575 576 576 579 579 581 581 583 583 584 584 584 585 586 586 587 587 589 590 590 590 593 594 596 596 598 599 600 602 603 604

Bibliografìa

605

Indice analitico

637

PREFAZIONE

ALLA

SECONDA

EDIZIONE

La prima edizione di questo libro è stata pubblicata nel 1991. Da quella data, numerosi sono stati i progressi registrati nell'informatica e nel campo dell'ingegneria del software. Certamente, la diffusione di Internet ha esercitato una profonda influenza sulla società: dalla formazione alla ricerca, dall'economia all'industria, al commercio. Abbiamo deciso di scrivere la seconda edizione per aggiornare il libro rispetto ai progressi nell'ingegneria del software nell'ultimo decennio. In tutti questi anni abbiamo avuto conferma della validità dell'approccio su cui il libro è basato, e cioè la durabilità e l'importanza dei principi, che hanno retto il passaggio del tempo: nonostante la tecnologia sia migliorata, i principi dell'ingegneria del software sono rimasti invariati. Abbiamo, quindi, potuto aggiornare tutti i capitoli senza modificare la struttura originaria del libro, che rimane la seguente: •

Introduzione: Capitoli 1-3;



Il prodotto: Capitoli 4-6;



Processo e gestione: Capitoli 7-8;



Strumenti e ambienti: Capitolo 9.

I Capitoli relativi al prodotto seguono questo ordine: progetto (Capitolo 4), specifica (Capitolo 5) e verifica (Capitolo 6). Ciò differisce dall'approccio tenuto da altri libri, che trattano la specifica prima del progetto. La nostra scelta deriva invece dall'approccio basato sui principi seguiti dal libro. Le attività di progetto, specifica e verifica permeano l'intero ciclo di vita del software. Ad esempio, l'attività di progetto non riguarda solo la definizione dell'architettura del software, ma anche la produzione delle specifiche. L'approccio basato sulla progettazione modulare ci aiuta a strutturare non solo il software, ma anche i documenti di specifica. Altri libri trattano la specifica prima del progetto siccome, secondo i processi tradizionali di software, prima si specifica un software e poi lo si progetta. Al contrario, noi crediamo che apprendere prima gli approcci e l'attività di progettazione crei la motivazione necessaria per lo studio della specifica e fornisca le tecniche e le capacità per poter strutturare tali specifiche. Rispetto alla pubblicazione della prima edizione di questo libro, tutte le aree del software si sono comunque evolute, ma l'area che ha subito il cambiamento più marcato

è quella degli strumenti e degli ambienti di sviluppo. Il Capitolo 9, quindi, è stato notevolmente rivisto, anche se il nostro approccio consiste nel presentare i principi più che gli specifici strumenti. Abbiamo visto durante gli anni che gli strumenti cambiamo di pari passo con l'evoluzione tecnologica, e la scelta di quali particolari strumenti studiare dipende sia dal problema che dall'ambiente di lavoro. Ci concentreremo pertanto su un quadro di riferimento per lo studio e la valutazione degli strumenti software, senza trattarne dettagliatamente nessuno in particolare. Oltre ad aggiunte e cambiamenti minori, sono state apportate le seguenti modifiche. Nel Capitolo 3 sono stati aggiunti due nuovi casi di studio, uno riguardante un semplice compilatore e l'altro riguardante il sistema di ascensori, che viene poi utilizzato ampiamente nel libro. I due casi di studio sono complementari, in quanto trattano di aree applicative diverse e pongono problemi progettuali diversi. Vengono presentati in questo capitolo in modo semplice e intuitivo per far riflettere lo studente riguardo ai problemi dei sistemi complessi e per illustrare l'uso dei principi generali con esempi concreti. Nel Capitolo 4 è stata estesa la trattazione riguardante l'orientamento agli oggetti, l'architettura software, i componenti e i sistemi distribuiti. Nel Capitolo 5 è stata aggiunta la trattazione riguardante Z e UML. Vi è, inoltre, un nuovo paragrafo che affronta in maniera più sistematica l'ingegneria dei requisiti. Nel Capitolo 6 sono stati aggiunti il model checking e GQM come tecniche di valutazione e verifica. Nel Capitolo 7 sono state incluse trattazioni del processo software in connessione con UML, del processo open-source e del processo synchronize-and-stabilize di Microsoft. E stato inoltre aggiunto un nuovo caso di studio sull'ingegneria dei requisiti. Nel Capitolo 8 sono stati aggiunti il capability maturìty model e la descrizione delle fabbriche del software di Nokia. Nel Capitolo 9 è stata aggiunta una trattazione di CVS. Nel Capitolo 10 è stata aggiunta una trattazione dei problemi di tipo etico nell'ambito dell'ingegneria del software. Nell'Appendice, è stato aggiunto un nuovo caso di studio riguardante l'uso dei metodi formali nell'industria.

Il ruolo dell'orientamento agli oggetti Il libro tratta i principi del software orientato agli oggetti in modo equilibrato, piuttosto che considerarlo l'unico metodo con cui produrre software. L'analisi, la progettazione e la programmazione orientata agli oggetti si sono certamente evolute, diventando un approccio dominante nell'ambito dell'ingegneria del software. Crediamo però che i principi di base dell'ingegneria del software siano più profondi. Ciò che gli studenti dovrebbero imparare sono i principi e i metodi che possono essere usati in diversi approcci. Lo studente dovrebbe imparare come scegliere tra i vari approcci, ed essere in grado di ricorrere all'uso di un approccio orientato agli oggetti quando questo si riveli la scelta migliore. Ad esempio, lo studente dovrebbe venire a conoscenza e imparare il principio di information hiding prima di approfondire tecniche orientate agli oggetti quale l'ereditarietà.

Lo scopo dei casi di studio I casi di studio presentati nel libro e nell'appendice hanno due scopi: presentare gli argomenti trattati in un contesto più ampio, per poter dare allo studente una visione più ampia riguardo all'importanza dei principi e delle tecniche e fornire agli studenti che non hanno mai visto progetti reali una "visione della realtà pratica". I casi di studio, pur essendo semplificati per focalizzare l'attenzione sugli argomenti più importanti, sono utili soprattutto agli studenti meno esperti. Lo studio dell'ingegneria del software in un ambiente universitario pone dei problemi, in quanto lo studente medio non è mai venuto in contatto con i problemi che gli ingegneri del software trattano tutti i giorni; i casi di studio cercano pertanto di ovviare a questo problema.

Risorse per i docenti E disponibile per i docenti un CD-ROM d'accompagnamento che comprende soluzioni agli esercizi e un programma del corso d'esempio. È anche disponibile, sia per gli studenti che per i docenti, un sito Web complementare fornito dall'editore, attraverso il quale è possibile scaricare le slide (in inglese) e contattare gli autori: www.prenhall.com/ghezzi. Commenti, suggerimenti e feedback sono sempre ben accetti.

Carlo Ghezzi Milano, Italia Mehdi Jazayeri Palo Alto, California Dino Mandrioli Lugano, Svizzera

Alle nostre famiglie, per il loro sostegno e la pazienza.

PREFAZIONE

ALLA

PRIMA

EDIZIONE

Questo è un libro di testo dedicato all'ingegneria del software. Il tema conduttore è l'importanza del rigore nella pratica di questa disciplina. I libri di testo tradizionali sono basati sul modello di ciclo di vita dello sviluppo del software e su una illustrazione delle fasi che lo compongono: requisiti, specifica, progetto, codifica, manutenzione. Al contrario, la nostra presentazione si basa sui principi fondamentali che possono essere adottati indipendentemente dal modello di ciclo di vita, e in tutte le sue fasi. Ci focalizzeremo, pertanto, sull'identificazione e l'applicazione di principi fondamentali applicabili per tutto il ciclo di vita. Vediamo ora le caratteristiche principali del libro. •

Affronta i problemi dell'ingegneria del software, non quelli della programmazione. Ad esempio, sono state omesse tutte le discussioni riguardanti i costrutti dei linguaggi di programmazione, come goto, cicli, etc. Riteniamo che lo studente di ingegneria del software debba già avere familiarità con tali argomenti, che sono trattati nei libri di testo sui linguaggi di programmazione. Non viene neppure affrontato il problema di tradurre la descrizione di un progetto software in determinati linguaggi di programmazione. II libro assume come prerequisito l'abilità di realizzare i singoli moduli che compongono un programma complesso e si concentra sui problemi di composizione di un progetto modulare.



Sottolinea i principi e le tecniche piuttosto che gli strumenti specifici. Oggi molte aziende stanno sviluppando strumenti e ambienti d'ingegneria del software, ed è auspicabile che ne verranno inventati migliori e più sofisticati al crescere delle conoscenze sulla disciplina. Una volta che lo studente abbia compreso i principi e le tecniche sulle quali lo strumento si basa, gli sarà facile padroneggiarlo. I principi e le tecniche sono applicabili a vari strumenti mentre l'acquisizione della padronanza nell'uso di un particolare strumento non prepara lo studente all'uso di altri strumenti. Inoltre, è rischioso usare strumenti senza comprenderne i principi sottostanti.



Presenta principi ingegneristici; non è un manuale d'uso. I principi sono generali ed è molto probabile che rimangano applicabili per molti anni, mentre le tecniche cambieranno con la scoperta di nuove tecnologie e con l'acquisizione di nuove conoscenze. Mentre un manuale d'uso può essere consultato per capire come applicare una par-

ticolare tecnica seguendo un insieme di indicazioni pratiche, questo libro vuole metter in grado il lettore di comprendere perché debba, o non debba, essere usata una particolare tecnica. Nonostante nella trattazione venga anche mostrato come certe tecniche possono essere usate per implementare un principio specifico, lo scopo principale è la comprensione del perché. In questo senso, libro incarna la nostre convinzioni sull'uso dei principi fondamentali e l'importanza delle teoria nella pratica dell'ingegneria, ed è stato usato sia in corsi universitari che professionali sui diversi aspetti dell'ingegneria del software.

A chi è rivolto Il volume è stato pensato per essere usato sia come libro di testo per studenti che frequentano corsi di ingegneria del software che per autodidatti. Ingegneri, professionisti e manager potranno trovare materiale che li convinca dell'utilità delle moderne pratiche dell'ingegneria del software e della necessità di adottarle. Può essere usato da quanti siano disposti a una riflessione approfondita, mentre non è del tutto appropriato per una rapida consultazione. In particolare, ove necessario, abbiamo sacrificato l'ampiezza dei temi trattati per la profondità di analisi. Per i professionisti, le note sui possibili approfondimenti possono essere estremamente utili. E inoltre disponibile un manuale per docenti1, con idee per l'organizzazione dei corsi e le soluzioni di alcuni esercizi.

Prerequisiti Il libro è pensato per studenti di informatica che conoscano la programmazione, gli algoritmi e le strutture dati e che siano esperti in almeno un linguaggio di programmazione. Il ragionamento analitico, anche se non strettamente necessario, agevolerà notevolmente la capacità del lettore di comprendere i concetti più profondi del libro. Queste capacità sono sviluppate tramite corsi di analisi matematica, matematica discreta e, ovviamente, informatica teorica. Riteniamo infatti necessario che lo studente, indipendentemente dalla disciplina ingegneristica seguita, sia "matematicamente maturo".

Organizzazione e contenuti L'ingegneria del software è una disciplina vasta e multi-dimensionale. Organizzare quindi un libro di testo su questo argomento presenta numerose difficoltà, in quanto dovrebbe presentare il materiale in maniera sequenziale, mentre i numerosi aspetti dell'ingegneria del software sono talmente correlati tra di loro da rendere impossibile una sequenza ottimale di argomenti. Il libro è stato organizzato in base a queste considerazioni:

1

Su C D - R O M , in lingua inglese (N.d.E.).



costruzione di un prodotto: il software;



utilizzo di un processo per costruire tale prodotto;



utilizzo degli strumenti per supportare tale processo.

Il libro è suddiviso in tre blocchi, che trattano a turno il prodotto software (dal Capitolo 4 al 6), il processo di ingegneria del software e la sua gestione (Capitoli 7 e 8), gli strumenti (Capitolo 9). I Capitoli 1, 2 e 3 forniscono un'introduzione generale alla materia e costituiscono un prologo per i capitoli successivi. Nel Capitolo 2 vengono discussi i numerosi aspetti e le caratteristiche desiderabili del software. Queste caratteristiche impongono dei limiti al costruttore di software e al processo da utilizzare. Nel Capitolo 3 sono presentati i principi per costruire software di alta qualità. Studiando i principi, invece degli strumenti specifici, lo studente acquisisce una conoscenza indipendente da una particolare tecnologia e dall'ambiente applicativo. Siccome la tecnologia cambia e gli ambienti evolvono, lo studente dovrebbe così disporre di fondamenti e tecniche che possono essere utilizzate in diverse aree applicative. Dal Capitolo 4 al Capitolo 8 vengono affrontate le tecniche per applicare i principi del Capitolo 3 al progetto, alla specifica, alla verifica, al processo ingegneristico e alla sua gestione. Nel Capitolo 9, viene discusso l'uso dei computer come supporto alla creazione del software. Le discussioni sui vari strumenti specifici accennati nel libro sono rinviate a questo capitolo. Mentre il materiale dei primi due blocchi dovrebbe in linea di massima reggere il passaggio del tempo, è molto probabile che il materiale della terza sezione diventi superato a causa dell'auspicabile sviluppo di nuovi e migliori strumenti. Siccome i linguaggi di programmazione sono uno strumento fondamentale dell'ingegnere del software, il Capitolo 9 serve da unione tra le problematiche della progettazione e il mondo dei linguaggi di programmazione.

Esercìzi Il libro contiene tre tipi di esercizi. •

Esercizi brevi, con lo scopo di ampliare la conoscenza acquisita attraverso il libro o di applicare tale conoscenza in modo più specifico; questi esercizi sono distribuiti all'interno dei diversi capitoli.



Esercizi più lunghi, alla fine del capitolo, che richiedono l'integrazione del materiale presente nel capitolo.



Progetti che richiedono lo sviluppo di software da parte di una piccola squadra.

Le soluzioni di alcuni esercizi sono fornite alla fine di ciascun capitolo. Altre soluzioni vengono invece fornite nel manuale per i docenti.

Casi di studio Nel testo vengono utilizzati numerosi casi di studio per dimostrare l'integrazione di concetti differenti e per confrontare diversi approcci in situazioni realistiche. Inoltre, sono proposti e analizzati tre casi di studio di progetti reali. Questi casi di studio possono essere letti ed analizzati in momenti diversi e con diversi scopi. Da questi casi di studio, lo studente con scarsa esperienza può acquisire un'idea sintetica dei problemi che si incontrano nello sviluppo industriale. Mentre quello che disponga già di competenze pratiche potrà riconoscere certi aspetti e trarre arricchimento dall'altrui esperienza. Questi casi di studio possono anche essere analizzati durante la lettura del libro. Numerosi esercizi del libro si riferiscono a questi casi di studio.

Laboratorio Molti corsi di ingegneria del software combinano lezioni tradizionali a progetti di laboratorio. E alquanto difficile svolgere tutte queste attività in un singolo semestre. Il docente si troverebbe a discutere problemi organizzativi mentre gli studenti sono concentrati sui problemi giornalieri del debug. Crediamo che l'ingegneria del software debba essere insegnata, come tutte le altre discipline ingegneristiche, fornendo prima allo studente una solida base teorica. Solo dopo che questa è stata raggiunta, l'attività di laboratorio aumenterà e potenzierà le conoscenze dello studente. Ciò implica che un progetto di laboratorio debba partire più o meno a metà del semestre, invece che all'inizio. Dal nostro punto di vista, un approccio ancora migliore è quello di dedicare un semestre alla teoria e un altro al laboratorio. II manuale per i docenti propone numerose idee per organizzare un corso di laboratorio basato su questo libro.

Percorsi di lettura Il libro può essere letto seguendo sequenze diverse e a vari livelli. I Capitoli dal 4 al 7 contengono materiale che può essere omesso durante una prima lettura o nei corsi di studio meno dettagliati. I Capitoli 1-3 sono necessari per un corretto inquadramento dei capitoli seguenti. Il grafo illustra la dipendenza tra i capitoli e i vari percorsi di lettura del libro. La notazione «P indica la lettura parziale del Capitolo n; nC ne indica invece la lettura completa. Il manuale per i docenti tratta diverse modalità di organizzazione di corsi basati sul libro. Un convenzionale corso di ingegneria del software di un semestre potrebbe seguire la sequenza: 1, 2, 3, 7P, 5P, 4P, 6P, 8, 9, 10. Noi preferiamo la sequenza 1, 2, 3, 4P, 5P, 6P, 7P, 8, 9, 10. In ogni caso, sarebbe preferibile che gli studenti iniziassero il progetto dopo il 5P.

Ringraziamenti Ringraziamo Reda A. Ammar, University of Connecticut; Larry C. Christensen, Brigham University; William R Decker, University of Iowa; David A. Gustafson, Kansas State University; Richard A. Kemmerer, University of California at Santa Barbara; John C. Knight, University of Virginia; Seymour V. Pollack, Washington University e K. C. Tai, North Carolina State University, per le revisioni delle bozze iniziali. Ringraziamo anche le seguenti presone che hanno fornito importanti feedback su varie bozze del libro: Vincenzo Ambriola, Paola Bertaina, David Jacobson e Milon Mackey. Gli Hewlett-Packard Laboratories, Alfredo Scarfone, HP Italia e il Politecnico di Milano hanno reso possibile la nascita di questo libro supportando un corso tenuto da Mehdi Jazayeri al Politecnico di Milano nella primavera del 1988. Vorremmo anche ringraziare il supporto degli Hewlett-Packard Laboratories, in particolare John Wilkes, Dick Lampman, Bob Ritchie, Frank Carrubba a Palo Alto e Peter Porzer a Pisa. Ringraziamo anche Bart Sears per il suo aiuto su vari sistemi e John Wilkes per l'utilizzo del suo database per la gestione dei riferimenti bibliografici. Ringraziamo infine per i contributi ricevuti il Consiglio Nazionale delle Ricerche.

Carlo Ghezzi Milano, Italia Mehdi Jazayeri Palo Alto, California Dino Mandrioli Pisa, Italia

C A P I T O L O

1

Ingegneria del software: visione d'insieme

L'ingegneria del software è il settore dell'informatica che si occupa della creazione di sistemi software talmente grandi o complessi da dover essere realizzati da una o più squadre di ingegneri. Di solito questi sistemi esistono in varie versioni e rimangono in servizio per parecchi anni. Durante la loro vita subiscono numerose modifiche: per eliminare difetti, per potenziare caratteristiche già esistenti, per implementarne nuove, per eliminare quelle obsolete, o per essere adattati a funzionare in un nuovo ambiente. Possiamo definire l'ingegneria del software come "l'applicazione dell'ingegneria al software". Più precisamente, l'IEEE Standard 610.12-1990 Glossario standard della terminologia dell'ingegneria del software (ANSI) definisce l'ingegneria del software come l'applicazione di un approccio sistematico, disciplinato e quantificabile nello sviluppo, funzionamento e manutenzione del software. Parnas [1978] ha definito l'ingegneria del software come la "creazione di software multiversione da parte di più operatori". Questa definizione mette in risalto l'essenza dell'ingegneria del software e sottolinea le differenze tra programmazione e ingegneria del software. Un programmatore scrive un programma completo, mentre un ingegnere del software scrive un componente software che sarà poi combinato con componenti scritti da altri ingegneri del software per creare un sistema. Il componente scritto da un ingegnere del software può essere modificato da altri ingegneri del software e potrà essere usato da altri per creare versioni diverse del sistema, anche se il suo creatore ha abbandonato il progetto da tempo. La programmazione è un'attività individuale mentre l'ingegneria del software è essenzialmente un'attività di gruppo. Di fatto, la locuzione "ingegneria del software" fu coniata verso la fine degli anni Sessanta quando ci si rese conto che tutto ciò che si era appreso riguardo alle corrette tecniche di programmazione non aiutava a costruire sistemi software migliori. Mentre il campo della programmazione aveva fatto progressi straordinari - attraverso lo studio sistematico di algoritmi e strutture dati e l'invenzione della "programmazione strutturata"- esistevano ancora notevoli difficoltà nella creazione di sistemi software complessi. Le tecniche utilizzate da un fisico per scrivere un programma di calcolo per la soluzione di un'equazione differenziale, necessaria per un esperimento, non erano adeguate per un programmatore che, all'interno di un gruppo, cercava di creare un sistema operativo o un sistema di gestione degli inventari. In questi casi complessi era necessario un classico approccio ingegneristico: definire chiaramente il problema e quindi sviluppare e usare strumenti e tecniche per risolverlo.

Indubbiamente, l'ingegneria del software ha fatto progressi dagli anni Sessanta a oggi. Sono state create tecniche standard e, più che essere praticata come un qualcosa di artigianale, l'ingegneria del software ha acquisito una maggiore disciplina, cosa che è tradizionalmente associata con l'ingegneria. Nonostante ciò, le differenze con l'ingegneria tradizionale permangono. Nel disegnare un sistema elettrico, come ad esempio un amplificatore, l'ingegnere elettronico può descrivere il sistema in modo preciso. Tutti i parametri e i livelli di tolleranza sono stabiliti chiaramente e sono compresi facilmente e in modo chiaro sia dall'ingegnere sia dal cliente. Nei sistemi software tali parametri rimangono ancora sconosciuti. Non sappiamo ancora quali parametri specificare e come specificarli. Nelle discipline ingegneristiche classiche, l'ingegnere ha strumenti e competenze matematiche per descrivere le caratteristiche del prodotto, separatamente da quelle del progetto. Per esempio, un ingegnere elettronico può fare affidamento sulle equazioni matematiche per verificare che un progetto non violerà requisiti di alimentazione. Nell'ingegneria del software, tali strumenti matematici non sono ancora ben sviluppati e si sta tuttora discutendo sulla loro reale applicabilità. L'ingegnere del software si appoggia più sull'esperienza e sul suo giudizio personale che su tecniche matematiche. Viceversa, mentre esperienza e giudizio sono necessari, anche strumenti di analisi formale sono essenziali nella pratica dell'ingegneria. Questo libro presenta l'ingegneria del software come una disciplina ingegneristica, evidenziando determinati principi che riteniamo essere essenziali alla "creazione di software multiversione da parte di più persone". Questi principi sono molto più importanti di qualsiasi particolare notazione o metodologia per costruire software e permettono all'ingegnere del software di valutare differenti metodologie e usarle al momento opportuno. Il Capitolo 3 analizza i principi dell'ingegneria del software; quelli successivi mostrano invece la loro applicazione nei vari ambiti della disciplina. In questo capitolo passiamo in rassegna l'evoluzione dell'ingegneria del software e le sue relazioni con altre discipline. Scopo di questo capitolo è offrire uno scorcio sul settore dell'ingegneria del software.

1.1

II ruolo dell'ingegneria del software nel progetto di un sistema

Un sistema sofware è spesso un componente di un sistema più vasto. L'ingegneria del software è quindi parte di un'attività di progettazione di sistema molto più vasta in cui i requisiti del software interagiscono con i requisiti di altre parti del sistema durante la progettazione. Ad esempio, un sistema telefonico è composto da computer, telefoni, linee telefoniche e cavi, altri componenti hardware come i satelliti e infine da software che controlla i vari componenti. E la combinazione di tutti questi componenti che deve soddisfare i requisiti del sistema. Requisiti come "il sistema non deve essere inattivo per più di un secondo in venti anni" o "quando una cornetta telefonica viene alzata, il segnale di linea libera viene attivato entro mezzo secondo" possono essere soddisfatti con una combinazione di hardware, software e dispositivi speciali. La decisione su come meglio soddisfare i requisiti implica numerosi compromessi. I sistemi delle centrali elettriche o di monitoraggio del traffico, sistemi bancari o di amministrazione ospedaliera sono altri esempi, che mostrano la necessità di vede-

re il software come un componente di un più ampio sistema. Il software diviene sempre di più la parte interna intelligente di vari sistemi, dalle televisioni agli aeroplani. Si parla, in tal caso, di software embedded. Avendo a che fare con tali sistemi, l'ingegnere del software deve rivolgere uno sguardo più ampio al problema più generale dell'ingegneria di sistema. Ciò richiede che l'ingegnere del software partecipi allo sviluppo dei requisiti di tutto il sistema e che questi comprenda l'area applicativa prima di iniziare a pensare quali interfacce astratte debbano essere soddisfatte dal software. Ad esempio, se il dispositivo hardware che svolge funzione di interfaccia con l'utente ha capacità limitate per l'input di dati, nel sistema non risulterà necessario un word processor sofisticato. Considerare l'ingegneria del software come parte dell'ingegneria dei sistemi ci fa riconoscere l'importanza del compromesso, che è la caratteristica di ogni disciplina ingegneristica. Una classico compromesso concerne la scelta di ciò che deve essere trasformato in software e ciò che deve essere trasformato in hardware. L'implementazione in software offre maggiore flessibilità, mentre l'implementazione in hardware offre migliori prestazioni. Ad esempio, nel Capitolo 2 vedremo un esempio di macchina operante a gettoni che può essere costruita sia con varie fessure, ciascuna per un tipo diverso di gettone, sia con una sola fessura, lasciando al software il compito di riconoscere i diversi gettoni. Un compromesso ancora più di base implica la decisione su cosa debba essere automatizzato e cosa debba essere eseguito manualmente.

1.2

Breve storia dell'ingegneria del software

La nascita e l'evoluzione dell'ingegneria del software come disciplina nel campo dell'informatica risale alla maturazione dell'attività di programmazione. Agli albori dell'informatica, il problema principale della programmazione era come mettere insieme una sequenza di istruzioni per fare in modo che il computer producesse qualcosa di utile. I problemi che venivano programmati erano ben compresi e conosciuti: ad esempio, come risolvere un'equazione differenziale. Il programma era scritto, ad esempio, da un fisico per risolvere un'equazione di proprio interesse e il problema era circoscritto tra il computer e l'utente-programmatore; nessun'altra persona era coinvolta. Con la diminuzione dei prezzi dei computer e la loro diffusione, un numero sempre maggiore di persone iniziarono a utilizzarlo. I linguaggi di alto livello furono inventati nei tardi anni Cinquanta per rendere più facile comunicare con le macchine, ma nonostante ciò, l'attività di far eseguire al computer qualcosa di utile rimaneva sempre essenzialmente il compito di una sola persona che doveva scrivere un opportuno programma per un ben determinato compito. In questo periodo, "programmare" divenne una professione: una persona poteva chiedere al programmatore di scrivere un programma, invece di realizzarlo per conto proprio. Questo accordo introdusse una separazione tra utente e computer: l'utente specificava cosa voleva ottenere da una data applicazione utilizzando un linguaggio diverso dalla notazione di programmazione. Il programmatore leggeva quindi tale specifica e la traduceva in un insieme ben preciso di istruzioni macchina. Questo, ovviamente, portò a volte a un'interpretazione erronea delle intenzioni dell'utente da parte del programmatore, anche per problemi relativamente semplici.

Nei primi anni Sessanta i progetti di software complessi furono veramente pochi e quei pochi furono intrapresi da pionieri del campo dell'ingegneria, che erano molto esperti. Ad esempio, il sistema operativo CTSS sviluppato al MIT fu un progetto vasto, sviluppato da individui estremamente motivati e competenti. Nella seconda parte degli anni Sessanta, vi furono tentativi di creare grandi sistemi di software commerciali. Di questi progetti, quello meglio documentato fu il sistema operativo OS 360 per la famiglia di computer IBM 360. Le persone che lavoravano in questi ambiti presto si resero conto che costruire vasti sistemi di software era decisamente diverso rispetto a quelli più piccoli. Vi erano delle difficoltà profonde nel cercare di adattare le tecniche di sviluppo di piccoli programmi allo sviluppo di grossi software. L'espressione "ingegneria del software" fu coniata in questo specifico periodo e vennero tenute conferenze per discutere sui problemi che tali progetti incontravano nello sviluppare il prodotto promesso. I progetti di grossi software, generalmente, sforavano il budget prefissato ed erano in ritardo rispetto al termine previsto. Un'altra locuzione coniata in quel periodo fu "crisi del software". Si comprese che i problemi riscontrati nella creazione di grandi sistemi software non riguardavano la pura e semplice capacità di combinare tra loro le istruzioni del computer. Piuttosto, i problemi che dovevano essere risolti non erano ben compresi, almeno non da tutte le persone coinvolte nel progetto. Coloro che lavoravano sul progetto spendevano molto più tempo per comunicare tra di loro che per scrivere codice. Addirittura a volte alcune persone abbandonavano il progetto, influenzando così non solo il lavoro che avevano fatto, ma anche il lavoro di quanti facevano affidamento su di loro. Sostituire un individuo richiedeva il trasferimento orale dei requisiti del progetto e del modello del sistema. Ci si rese conto che ogni cambiamento dei requisiti del sistema originale influenzava numerose parti del progetto, ritardando inoltre la consegna del sistema. Questo tipo di problemi non esisteva ai tempi della "programmazione" e richiedeva quindi un nuovo tipo di approccio. Vennero proposte e provate numerose soluzioni. Alcuni suggerirono un miglioramento delle tecniche di organizzazione e gestione dei progetti; altri proposero una diversa organizzazione in squadre. Altri ancora sostennero che fossero necessari linguaggi di programmazione e strumenti migliori; molti auspicarono l'adozione di standardizzazioni, come ad esempio convenzioni di codifica uniformi. Alcuni invocarono l'uso di un approccio formale e matematico. Di certo non mancavano le idee. Alla fine si raggiunse un accordo sul fatto che il problema della costruzione di un software dovesse essere affrontato nello stesso modo adottato dagli ingegneri per costruire sistemi grandi e complessi come ponti, raffinerie, fabbriche, navi e aeroplani. Lo scopo era vedere il sistema software finale come un prodotto complesso e la sua creazione come un lavoro ingegneristico. L'approccio ingegneristico richiedeva management, organizzazione, strumenti, teorie, metodologie e tecniche. Nacque così l'ingegneria del software. In un articolo ormai classico del 1987, Brooks, parafrasando Aristotele, sostenne che vi erano solo due tipi di sfide nello sviluppo del software: l'essenziale e l'accidentale. Le difficoltà accidentali sono quelle che riguardano gli strumenti e le tecnologie correnti: ad esempio, i problemi sintattici che sorgono dal linguaggio di programmazione utilizzato. Si possono superare tali difficoltà con strumenti e tecnologie migliori. Le difficoltà essenziali, invece, non sono generalmente superate con l'uso di nuovi strumenti. Problemi di progettazione complessa — ad esempio, la creazione di un modello utile per le previsioni atmosferi-

che o per l'economia - richiedono sforzo intellettuale, creatività e tempo. Brooks sostenne che non vi era alcuna soluzione magica, nessun "proiettile d'argento"1 per risolvere i problemi essenziali incontrati dagli ingegneri del software. Il ragionamento di Brooks mostra le false supposizioni dietro alla locuzione "crisi del software", che venne ideata in quanto i progetti di software erano continuamente in ritardo e oltre il budget prestabilito. La conclusione fu che il problema era temporaneo e avrebbe potuto essere risolto con strumenti e tecniche di management migliori. In realtà i progetti erano in ritardo perché l'applicazione era complessa e scarsamente compresa sia dai clienti che dagli sviluppatori e nessuno aveva alcuna idea di come stimare la difficoltà del lavoro e di quanto tempo ci sarebbe voluto per risolverlo. Nonostante l'espressione "crisi del software" sia talvolta ancora usata, è opinione generale che le difficoltà inerenti lo sviluppo di software non siano di breve periodo. Applicazioni nuove e complesse sono ardue da affrontare e non sono di rapida soluzione. La storia mostra la crescita dell'ingegneria del software partendo dalla programmazione. Alcune evoluzioni tecnologiche hanno giocato un ruolo importante nello sviluppo della disciplina. L'influenza maggiore è stata il cambio di equilibrio tra i costi dell'hardware e del software. Mentre un tempo il costo di un sistema computerizzato era determinato soprattutto dal costo dell'hardware, e il software era un fattore ininfluente, ora la componente software è di gran lunga quella dominante nel costo di un sistema. La diminuzione del costo dell'hardware e la crescita di quello del software ha sfavorito quest'ultimo, accentuando l'importanza economica dell'ingegneria del software. Un altro orientamento evolutivo si è creato anche all'interno del campo stesso. Vi è stata una crescente enfatizzazione a vedere l'ingegneria del software come una disciplina che va ben oltre la pura attività di codifica. Al contrario, il software viene considerato un prodotto che ha un intero ciclo di vita, partendo della sua concezione, continuando attraverso la progettazione, lo sviluppo, la messa in funzione, la manutenzione e l'evoluzione. Lo spostamento dell'enfasi dalla codifica all'intero ciclo di vita del software ha favorito lo sviluppo di metodologie e di sofisticati strumenti a sostegno delle squadre coinvolte. Per parecchie ragioni, possiamo quindi pensare che l'importanza dell'ingegneria del software continuerà ad aumentare. Una prima ragione è di tipo economico: le spese mondiali per il software sono passate da 140 miliardi di dollari nel 1985 a 800 miliardi di dollari nel 2000. Solo questo basterebbe a dimostrare quanto l'ingegneria del software crescerà come disciplina. In secondo luogo, il software ormai permea la nostra società: sempre di più viene usato software per controllare funzioni critiche di varie macchine, come aeroplani e dispositivi medici, e per supportare funzioni di importanza critica per il mondo intero, come ad esempio il commercio elettronico. Questo fatto garantisce una crescente attenzione della società verso software affidabile, al punto da promulgare legislazioni su standard specifici, requisiti e procedure di certificazione. Senza dubbio, imparare a creare software migliore in modo migliore continuerà a essere fondamentale.

Il proiettile d'argento è quello che nelle leggende uccide il lupo mannaro (N.d.T.).

1.3

II ruolo dell'ingegnere del software

L'evoluzione dell'ingegneria del software ha portato a definire il ruolo professionale dell'ingegnere del software, l'esperienza e la formazione richiesta. L'ingegnere del software deve, ovviamente, essere un buon programmatore, molto versato in strutture dati e algoritmi ed esperto in uno o più linguaggi di programmazione. Questi sono dei requisiti per la cosiddetta "programmazione in piccolo", definita approssimativamente come la creazione di programmi che possono essere scritti interamente da un unico individuo. Ma un ingegnere del software è coinvolto anche nella "programmazione in grande", che richiede molte più abilità. L'ingegnere del software deve aver familiarità con più approcci di progetto, deve essere capace di tradurre richieste e desideri vaghi in precise specifiche, e anche di interagire con l'utente di un sistema nei termini dell'applicazione più che nel gergo tecnico degli informatici. Queste capacità richiedono a loro volta flessibilità e apertura mentale per comprendere e familiarizzare con i fondamenti delle differenti aree applicative. L'ingegnere del software deve essere capace di muoversi attraverso diversi livelli di astrazione nei diversi stadi del progetto, dalle procedure applicative e dai requisiti di una specifica applicazione, alle astrazioni per il sistema software, a uno specifico progetto del sistema fino al livello dettagliato della codifica in un linguaggio di programmazione. Come in molti altri campi dell'ingegneria, l'ingegnere del software deve sviluppare capacità che gli permettano di costruire una vasta varietà di modelli e di ragionare su questi per poter operare scelte riguardo ai vari compromessi (trade-off) che si incontrano nel processo di sviluppo del software. I modelli usati nella fase di definizione dei requisiti sono diversi da quelli usati nella progettazione dell'architettura software, i quali a loro volta sono diversi da quelli usati nella fase di implementazione. In alcuni momenti, il modello può essere usato per rispondere a domande sia sul comportamento del sistema che sulle sue prestazioni. L'ingegnere del software è anche un membro di un gruppo di lavoro e necessita quindi di capacità comunicative e interpersonali. Deve inoltre essere in grado di coordinare il lavoro, sia il proprio sia quello di altri. Come già detto in precedenza, un ingegnere del software ha molteplici responsabilità. Sovente, molte organizzazioni dividono le responsabilità tra vari specialisti con qualifiche differenti. Ad esempio, un analista di sistema ha la responsabilità di ricavare i requisiti, di interagire con il cliente e di comprendere l'area applicativa, mentre un analista di prestazioni ha la responsabilità di analizzare le prestazioni del sistema. A volte lo stesso ingegnere svolge ruoli differenti in momenti differenti del progetto o in progetti differenti.

1.4

II ciclo di vita del software

Il software subisce uno sviluppo e un'evoluzione, dall'idea iniziale di un possibile prodotto o sistema software fino a quando viene implementato e consegnato al cliente (e anche in seguito) . Si dice che il software ha un ciclo di vita composto da varie fasi. A ciascuna di queste è associato lo sviluppo di una parte del sistema o di qualche elemento a questo legato come, ad esempio, un manuale per l'utente o un piano di test. Nel modello tradizionale del ciclo di vita, chiamato "modello a cascata", ogni fase ha un inizio e una fine ben definiti,

dei risultati parziali ("artefatti") che vengono trasferiti a una ben identificata fase sueva. Raramente però nella realtà le cose sono così semplici. Un modello a cascata è como dalle seguenti fasi. Analisi e specifica dei requisiti. L'analisi dei requisiti è di solito la prima fase di un progetto per lo sviluppo di un software di grandi dimensioni. Viene intrapresa dopo aver compiuto uno studio di fattibilità per definire in modo preciso i costi e i benefici di un sistema software con lo scopo di identificare e documentare i requisiti del sistema. Questo studio può essere compiuto dal cliente, dallo sviluppatore, da specialisti nell'analisi di mercato o da una qualsiasi combinazione di questi. Nei casi in cui i requisiti non siano chiari (per esempio, un nuovo sistema che non è mai stato precedentemente realizzato), è necessario che vi sia ampia interazione tra il cliente e lo sviluppatore. In questa fase i requisiti dovrebbero essere scritti impiegando una terminologia comprensibile dall'utente finale, ma molte volte non è cosi. Numerose metodologie di ingegneria del software ritengono che questa fase debba anche portare alla creazione di manuali per gli utenti e alla progettazione dei test di sistema che saranno effettuati alla fine, prima della consegna del sistema. Progettazione di sistema e sua specifica. Una volta che i requisiti del sistema sono stati documentati, gli ingegneri del software progettano un sistema software che li soddisfi. Questa fase è a volte divisa in due sottofasi: il progetto architetturale, o di alto livello, e il progetto dettagliato. Il progetto architetturale affronta l'organizzazione globale del sistema in termini di componenti di alto livello e delle loro interazioni. Man mano che ci si addentra in livelli di progettazione sempre più dettagliati, i vari componenti vengono scomposti in moduli di basso livello, con interfacce definite in modo accurato. Tutti i livelli di progettazione vengono indicati in un documento di specifica che fornisce informazioni sulle decisioni di progettazione prese. La separazione della fase di analisi dei requisiti da quella di progettazione è un perfetto esempio della fondamentale dicotomia tra "che cosa" e "come" che spesso incontriamo nell'informatica. Il principio generale consiste nel fare una chiara distinzione tra che cosa è il problema e come si risolve tale problema. In questo caso, la fase di analisi dei requisiti cerca di specificare qual è esattamente il problema. E per questo che diciamo che i requisiti dovrebbero essere specificati in base alle esigenze dell'utente finale. Di solito vi sono numerosi modi di soddisfare i requisiti, a volte ricorrendo anche a soluzioni manuali che non richiedono l'uso del computer. Lo scopo della fase di progettazione è quella di giungere alla specifica di un particolare sistema software che soddisfi i requisiti dichiarati. Anche in questo caso, vi sono numerosi modi per creare il sistema specificato. Nella fase di codifica, che segue quella di progettazione, un preciso sistema viene codificato per soddisfare le specifiche di sistema. Nel prosieguo del libro vedremo altri esempi della dicotomia del che "cosa/come". Codifica e test di modulo. In questa fase, l'ingegnere produce il codice che sarà consegnato al cliente sotto forma di un sistema funzionante. Anche altre fasi del ciclo di vita del software potranno portare allo sviluppo di codice, ad esempio per effettuare test, per sviluppare prototipi e test driver, ma in tutti questi casi il codice sviluppato sarà impiegato solamente dagli sviluppatori. Bisogna notare che i singoli moduli creati nella fase di codifica vengono testati prima di passare a quella successiva.

Analisi e specifica dei requisiti Progettazione di sistema e sua specifica

Codifica e test di modulo Integrazione e test di sistema

Consegna e manutenzione

Figura 1.1

Modello del ciclo di vita a cascata del software.



Integrazione e test di sistema. Tutti i moduli sviluppati e testati singolarmente nella fase precedente vengono in questa assemblati, integrati, e testati come un unico sistema.



Consegna e manutenzione. Una volta che il sistema supera tutti i test, viene consegnato al cliente ed entra nella fase di manutenzione in cui vengono incorporate tutte le modifiche apportate al sistema dopo la prima consegna.

La Figura 1.1 offre una visione grafica del ciclo di vita dello sviluppo di un software e offre un chiarimento grafico del termine "cascata". Ciascuna fase crea risultati che "fluiscono" in quella seguente e idealmente il processo procede in modo ordinato e lineare. Per come è stato qui presentato, questo modello offre una visione parziale e semplificata del convenzionale ciclo di vita a cascata del software. Il processo può essere scomposto in un diverso insieme di fasi, con nomi diversi, diversi fini e una diversa granularità. Potrebbero anche essere proposti schemi di cicli di vita totalmente differenti, non basati strettamente su fasi sviluppate in cascata. Ad esempio, è ovvio che se un test dovesse scoprire i difetti del sistema, occorrerebbe tornare indietro almeno fino alla fase di codifica e forse anche a quella di progettazione per correggere gli errori. In generale, ogni fase potrebbe portare alla luce problemi nelle fasi antecedenti, e quando ciò dovesse accadere sarebbe necessario tornare indietro e rifare parte del lavoro già svolto. Ad esempio, se la fase di progettazione del sistema scopre inconsistenze o ambiguità nei requisiti del sistema, si dovrà riesaminare la fase di analisi dei requisiti per determinare quali erano quelli che realmente si intendeva specificare e quali quelli errati. Un'altra semplificazione fatta con la precedente rappresentazione del ciclo di vita a cascata riguarda il fatto che si suppone che ciascuna fase sia completata prima che la successiva abbia inizio. Nella pratica invece, è spesso vantaggioso iniziare una fase prima che la precedente sia finita. Questo può succedere, ad esempio, se dei dati necessari al completamento della fase di specifica dei requisiti non sono disponibili per un po' di tempo. Oppure può

essere necessario perché le persone pronte a iniziare la fase successiva sono disponibili e non hanno altri lavori da fare. Un'altra ragione per sovrapporre le varie fasi può anche essere la riduzione del tempo di lancio del prodotto. Il termine usato per definire tale moderno processo organizzativo, che cerca di abbreviare il tempo di consegna dei prodotti introducendo del parallelismo nelle fasi di sviluppo di processi precedentemente sequenziali è ingegneria concorrente. Posporremo questa e altre questioni legate al ciclo di vita del software fino al Capitolo 7. Molti libri sull'ingegneria del software sono organizzati seguendo il modello tradizionale del ciclo di vita del software, con paragrafi o capitoli incentrati solamente su una fase. Invece, questo libro è stato organizzato facendo riferimento a una serie di principi. Una volta che tali principi vengono compresi e conosciuti a fondo, possono essere usati dall'ingegnere del software in tutte le fasi di sviluppo del software, anche in modelli di ciclo di vita diversi da quelli basati su uno sviluppo in fasi, come si è detto prima. Infatti l'esperienza maturata e alcune ricerche hanno mostrato che vi è un'ampia varietà di modelli del ciclo di vita del software e che nessuno di essi è adatto a tutti i sistemi software. Nel Capitolo 7 esamineremo diversi modelli di ciclo di vita.

1.5

Rapporto tra l'ingegneria del software e altri campi dell'informatica

L'ingegneria del software, che è ormai diventata una disciplina a sé stante, è un settore importante dell'informatica. Occorre notare che esistono numerosi rapporti sinergici tra questa e altri numerosi ambiti dell'informatica che influenzano e sono a loro volta influenzati dall'ingegneria del software. Affrontiamo ora alcuni casi.

1.5.1

Linguaggi di programmazione

L'influenza dell'ingegneria del software sui linguaggi di programmazione è evidente. I linguaggi di programmazione sono tra gli strumenti principali impiegati nello sviluppo di software e di conseguenza hanno un profondo impatto sul corretto raggiungimento dei traguardi dell'ingegneria del software. Uno dei più chiari esempi di questa influenza è l'inclusione di caratteristiche modulari, come la compilazione separata e indipendente e la separazione tra specifiche e implementazione, in modo da favorire la suddivisione in squadre del lavoro di sviluppo di software di grandi dimensioni. Linguaggi di programmazione come Ada 95 e Java, ad esempio, supportano lo sviluppo di "pacchetti" - che permettono la separazione della loro interfaccia dall'implementazione - e di librerie di pacchetti che possono essere utilizzati come componenti nello sviluppo di sistemi software indipendenti. Questo offre la possibilità di creare software scegliendo da un catalogo di componenti preesistenti già disponibili e combinandoli tra di loro, come si fa per l'hardware. Un altro esempio è l'introduzione di costrutti che gestiscono eccezioni nei linguaggi di programmazione, per permettere di scoprire e rispondere ad eventuali malfunzionamenti che possono verificarsi mentre il software è in esecuzione. Tali costrutti consentono all'ingegnere del software di costruire applicazioni più affidabili. Viceversa, anche i linguaggi di programmazione hanno influenzato l'ingegneria del software.

Un esempio è che i requisiti e il progetto debbano essere descritti in modo preciso, usando possibilmente un linguaggio rigoroso ed elaborabile da una macchina come un linguaggio di programmazione. Un altro esempio è il fatto di trattare l'input in un sistema software come un programma codificato in una sorta di linguaggio di programmazione. I comandi che un utente può immettere in un sistema non sono una collezione casuale di caratteri, bensì formano un linguaggio usato per comunicare con il sistema. Progettare un linguaggio di input appropriato fa parte della progettazione dell'interfaccia del sistema. I vecchi sistemi operativi, come ad esempio OS 360, avevano un'interfaccia complicata e criptica - chiamata linguaggio di controllo di lavoro (JCL,job control language) - che era usata per dare ordini al sistema operativo. I successivi sistemi operativi — in particolare UNIX - introdussero i linguaggi a linea di comando, specificatamente ideati per programmare il sistema operativo. L'approccio attraverso i linguaggi rese l'interfaccia molto più facile da comprendere e utilizzare. Una conseguenza del considerare l'interfaccia di sistemi software come un linguaggio di programmazione è che gli strumenti di sviluppo dei compilatori — che sono molto avanzati - possono essere usati per lo sviluppo di software generico. Ad esempio, possiamo impiegare grammatiche per specificare la sintassi dell'interfaccia e generatori di analizzatori sintattici per individuare inconsistenze e ambiguità nell'interfaccia, e quindi generare automaticamente la "facciata" del sistema. Le interfacce per utenti sono un caso interessante, perché in esse possiamo vedere un'influenza nel senso opposto: le problematiche di ingegneria del software riguardo alle interfacce utente grafiche hanno favorito lo studio di linguaggi di programmazione visuali. Questi linguaggi cercano di riprodurre la semantica delle finestre e dei paradigmi di interazione offerti dai dispositivi di visualizzazione grafica. Un'altra influenza del settore dei linguaggi di programmazione sull'ingegneria del software riguarda le tecniche implementative, sviluppate per anni per l'elaborazione dei linguaggi. Un approccio generativo all'ingegneria del software si basa sulla lezione appresa con i linguaggi di programmazione, che la formalizzazione porta all'automazione: creare una grammatica formale per un linguaggio permette di produrre automaticamente un analizzatore sintattico. Questa tecnica è ampiamente sfruttata in molte aree dell'ingegneria del software per la creazione di specifiche formali e la generazione automatica di software.

1.5.2

Sistemi operativi

L'influenza dei sistemi operativi sull'ingegneria del software è notevole, soprattutto perché questi furono i primi sistemi di software di ragguardevoli dimensioni a essere realizzati, e quindi costituirono il primo esempio di software che richiedeva un lavoro di ingegnerizzazione. Molte originarie idee di progettazione di software nacquero dai primi tentativi di realizzazione dei sistemi operativi. Concetti quali le macchine virtuali, i livelli di astrazione e la separazione delle politiche dai meccanismi nascono nel campo dei sistemi operativi, ma possono essere applicati a qualsiasi sistema software di grosse dimensioni. Ad esempio, l'idea di separare la politica che un sistema operativo vuole imporre, come quella di assicurare che tutti i processi vengano attivati in modo da garantire l'avanzamento della loro esecuzione (task scheduling), dal meccanismo usato per raggiungere tale parallelismo, ad esempio la divisione del tempo (time sharing), costituisce un modello di separazione del "che cosa" dal "come" o della

specifica dall'implementazione. L'idea dei livelli di astrazione è un altro modo di modularizzare il progetto di un sistema. Esempi dell'influenza delle tecniche di ingegneria del software sulla struttura dei sistemi operativi sono i sistemi operativi portabili e i sistemi operativi che sono strutturati in modo da contenere un piccolo micro-kernel "protetto", che provvede a fornire solo una minima parte di funzionalità per interfacciarsi con l'hardware, mentre la maggior parte delle funzionalità usualmente associate ai sistemi operativi vengono fornite da una parte "non protetta". Ad esempio, la parte non protetta potrebbe permettere all'utente di controllare lo schema di gestione delle pagine di memoria, che è sempre stato tradizionalmente visto come parte integrante del sistema operativo. Allo stesso modo, nei primi sistemi operativi, l'interprete di comandi era parte integrante di essi. Oggi, invece, è considerato come qualsiasi altro programma {utility), il che permette a ogni utente di avere una versione personalizzata dell'interprete. In molti sistemi UNIX ci sono almeno tre di questi interpreti.

1.5.3

Basi di dati

Le basi di dati rappresentano un'altra classe di sistemi software di grandi dimensioni il cui sviluppo ha influenzato l'ingegneria del software attraverso la scoperta di nuove tecniche di progettazione. Probabilmente, l'influenza più importante è rappresentata dalla nozione di "indipendenza dei dati", che è ancora un ennesimo esempio di separazione della specifica dall'implementazione. Una base di dati permette di scrivere applicazioni che usano tali dati senza preoccuparsi della loro rappresentazione sottostante. Questa indipendenza fa in modo che la base di dati possa essere in qualche modo modificata — ad esempio, per aumentare le prestazioni del sistema - senza che sia necessario cambiare anche le applicazioni che interagiscono con essa. Questo è un esempio dei benefici dell'astrazione e della separazione degli interessi (separation of concerti), due principi fondamentali dell'ingegneria del software come vedremo nel Capitolo 3. Un altro interessante influsso della tecnologia delle basi di dati sull'ingegneria del software è la possibilità di usare sistemi di basi di dati come componenti di sistemi software di grosse dimensioni. Siccome le basi di dati hanno risolto molti dei problemi legati alla gestione degli accessi concorrenti da parte di più utenti a grandi quantità di informazioni, non c'è alcun bisogno di re-inventare quelle soluzioni quando stiamo creando un sistema software; possiamo semplicemente usare un sistema esistente per la gestione di basi di dati come un componente. Un'interessante influenza dell'ingegneria del software sulla tecnologia delle basi di dati ha le sue origini nei primi tentativi di usare le basi di dati per supportare gli ambienti di sviluppo del software. Questa esperienza mostrò che la tecnologia tradizionale delle basi di dati era incapace di trattare i problemi posti dai processi di ingegneria del software. Ad esempio, un database tradizionale non è in grado di gestire in maniera ottimale le seguenti richieste: memorizzare grossi oggetti non strutturati come programmi sorgente, manuali per utente o codice eseguibile; mantenere diverse versioni dello stesso oggetto; memorizzare oggetti, come un prodotto, con molti ampi campi, strutturati e non, come codice sorgente, codice oggetto e un manuale utente. Un'altra difficoltà è legata alla lunghezza delle transazioni. Le basi di dati tradizionali supportano transazioni corte, come un deposito o un prelievo su un conto bancario. Gli ingegneri

del software invece necessitano di transazioni molto lunghe: un ingegnere può richiedere un lavoro prolungato per ricostruire un sistema costituito da molti moduli o potrebbe prelevare dal database (check out) un programma e lavorarci per settimane prima di riconsegnarlo (check in). Il problema per la base di dati è come trattare il blocco di codice durante quelle settimane. Se l'ingegnere del software volesse lavorare solo su una piccola parte del programma? Durante tale periodo è proibito l'accesso a tale programma a tutti i programmatori? Queste richieste hanno stimolato progressi nell'area delle basi di dati che hanno portato a nuovi modelli di database e di transazioni o all'adattamento dei modelli preesistenti.

1.5.4

Intelligenza artificiale

L'intelligenza artificiale è un altro campo che ha esercitato un'influenza sull'ingegneria del software. Molti sistemi software creati nella comunità di ricerca dell'intelligenza artificiale sono sistemi complessi e di grosse dimensioni, ma sono sempre stati significativamente diversi dagli altri. Molti di essi furono costruiti avendo solo una vaga idea di come il sistema avrebbe funzionato. Il termine "sviluppo esplorativo" è stato impiegato per il processo utilizzato per costruire questi sistemi. Lo sviluppo esplorativo è l'opposto dell'ingegneria del software tradizionale, ove il progettista svolge determinati passi per cercare di produrre un progetto completo prima di procedere con la codifica. Con l'intelligenza artificiale sono nate nuove tecniche per gestire le specifiche, la verifica e l'analisi quando vi è un dato grado di incertezza. Altre tecniche portate dall'intelligenza artificiale includono l'uso della logica sia nella specifica di un software che nei linguaggi di programmazione. Tecniche di ingegneria del software sono state usate nei sistemi di intelligenza artificiale chiamati sistemi esperti. Questi sistemi sono modularizzati, con una chiara divisione tra i "fatti" conosciuti dal sistema e le "regole" usate dal sistema per elaborare tali fatti, ad esempio, una regola per decidere il corso di un'azione. Questa separazione ha permesso la creazione e la commercializzazione di shell (letteralmente "gusci") di sistemi esperti che includono solo regole. Un utente può applicare quindi la shell a un'applicazione di suo interesse fornendole fatti specifici dell'applicazione. L'idea è che la conoscenza riguardo l'applicazione sia fornita dall'utente, mentre i principi generali su come applicare tale conoscenza a un qualunque problema sia fornita dalla shell. Alcuni ricercatori hanno provato a usare tecniche di intelligenza artificiale per perfezionare i compiti dell'ingegneria del software. Ad esempio, sono stati sviluppati "assistenti di programmazione" che svolgano funzione di consulenti per il programmatore, controllando frasi di programmazione idiomatiche o i requisiti del sistema. Il problema di fornire interfacce agli utenti inesperti - ad esempio, attraverso l'uso del linguaggio naturale - fu affrontato inizialmente dall'intelligenza artificiale. Per tracciare il profilo dell'utente furono utilizzati modelli cognitivi. Questi studi hanno influenzato l'area dell'ingegneria del software che si occupa dell'interfaccia utente.

1.5.5

Modelli teorici

L'informatica teorica ha sviluppato vari modelli che sono diventati poi strumenti utili nell'ingegneria del software. Ad esempio, le macchine a stati finiti sono servite sia come basi per le tecniche di specifica del software sia come modelli per la progettazione e la struttura

del software. Protocolli di comunicazione e analizzatori di linguaggio utilizzano macchine a stati finiti come modelli di processo. Sono stati impiegati anche gli automi a pila (pushdoum automato), ad esempio, per specifiche operazionali di sistemi e per costruire processori per tali specifiche. E interessante rimarcare il fatto che gli automi a pila sono essi stessi scaturiti da tentativi pratici di creazione di analizzatori sintattici e compilatori per linguaggi di programmazione. Le reti di Petri, descritte nel Capitolo 5, sono un altro contributo dell'informatica teorica all'ingegneria del software: vennero inizialmente usate per creare modelli di sistemi hardware, ma furono sempre più utilizzate anche per modellare il software. Infine, un ulteriore importante esempio è fornito dalla logica matematica, che è stata la base di molti linguaggi di specifica. Allo stesso tempo, l'ingegneria del software ha influenzato l'informatica teorica. Specifiche algebriche e tipi di dati astratti sono nati per motivi di ingegneria del software. Inoltre nell'area delle specifiche, l'ingegneria del software ha focalizzato maggiore attenzione su teorie logiche non del primo ordine, come ad esempio la logica temporale. I logici matematici hanno sempre prestato molta più attenzione alle teorie di primo ordine che a quelle di ordini più elevati, perché le due sono equivalenti in potenza, ma le teorie di primo livello sono, da un punto di vista matematico, più essenziali. Esse però non sono così espressive come le teorie di ordine più elevato. Un ingegnere del software, a dispetto di un informatico teorico, è interessato sia alla potenza sia all'espressività di una teoria. Per esempio, la logica temporale assicura un stile più naturale e compatto per la specifica dei requisiti di un sistema concorrente rispetto alle teorie di primo ordine. Le necessità dell'ingegneria del software hanno quindi ispirato nuovi studi dei teorici verso tali teorie di ordine superiore.

1.6

Relazioni tra l'ingegneria del software e altre discipline

Nelle sezioni precedenti abbiamo analizzato i rapporti tra ingegneria del software e gli altri campi dell'informatica. In questo paragrafo vedremo come l'ingegneria del software ha rapporti con altri campi al di fuori dell'informatica. L'ingegneria del software non può essere praticata su una torre d'avorio. Vi sono molti problemi non specifici dell'ingegneria del software che sono stati risolti da altri campi. Tali soluzioni possono essere adattate all'ingegneria del software. In questo modo non c'è bisogno di reinventare ogni singola soluzione. Ad esempio, le scienze cognitive possono aiutarci a sviluppare interfacce utenti migliori e le teorie economiche ci supportano nella scelta tra diversi modelli dei processi di sviluppo.

1.6.1

Scienze organizzative

Gran parte dell'ingegneria del software coinvolge ambiti organizzativi e gestionali. Come in ogni grande progetto, cui partecipano molte persone, occorre effettuare stime, pianificare, gestire le risorse umane, scomporre e assegnare compiti e monitorare il processo. Inoltre è necessario assumere persone, motivarle e assegnare a ciascuno il compito più adatto.

Le scienze organizzative studiano esattamente questi problemi. Sono stati sviluppati molti modelli gestionali che possono essere applicati all'ingegneria del software e, facendo ricorso alle scienze organizzative, è possibile sfruttare i risultati di decenni di studi ed esperienze. Allo stesso tempo l'ingegneria del software ha fornito alle scienze organizzative un nuovo dominio nel quale testare teorie e modelli organizzativi e gestionali. I modelli tradizionali adatti alla produzione per linea non sono applicabili ad attività incentrate sul lavoro umano quale l'ingegneria del software.

1.6.2

Ingegneria dei sistemi

L'ingegneria dei sistemi si occupa dello studio dei sistemi complessi. L'ipotesi sottostante è che certe leggi governino il comportamento di qualunque sistema complesso costituito da molti componenti con complicate interrelazioni. L'ingegneria dei sistemi è utile quando l'interesse dello studio è incentrato sul sistema piuttosto che sui suoi componenti. L'ingegneria dei sistemi tenta di scoprire approcci comuni che si applicano a sistemi diversi come impianti chimici, costruzioni di edifici e di ponti. Il software è spesso un componente di un sistema molto più grande. Per esempio il software di gestione di una fabbrica o il software di controllo del volo di un aereo sono componenti di sistemi più complessi. Le tecniche dell'ingegneria dei sistemi possono essere applicate allo studio di questi sistemi. Possiamo anche considerare un sistema software composto da migliaia di moduli come un sistema candidato per essere studiato con le leggi dell'ingegneria dei sistemi. Allo stesso tempo l'ingegneria dei sistemi si è arricchita di un insieme crescente di modelli analitici, che tradizionalmente erano basati su metodi matematici classici, fino a includere modelli discreti che sono di uso comune nell'ingegneria del software.

1.7

Osservazioni conclusive

L'ingegneria del software è una disciplina ingegneristica in evoluzione che studia gli approcci sistematici per costruire software di grosse dimensioni attraverso il lavoro di gruppo. Abbiamo illustrato la storia della sua evoluzione, presentato le relazioni con altre materie e abbiamo elencato le qualifiche che un ingegnere del software deve possedere. In questo libro vedremo i principi essenziali per la creazione di software mediante un approccio ingegneristico.

Note bibliografiche La definizione di ingegneria del software citata all'inizio del capitolo è tratta da Parnas [1978]. La distinzione tra "programmare in piccolo" e "programmare in grande" e la constatazione che l'ingegneria del software concerne la programmazione in grande è di D e R e m e r e Kron [1976]. Il termine "ingegneria del software" fu usato per la prima volta in una conferenza della N A T O , tenutasi a Garmisch, Germania, nel 1968. Un rapporto su tale conferenza si trova in un libro p u b blicato da N a u r et al. [1976]. Per la terminologia standard del campo, il lettore può far riferimento alla collezione degli standard pubblicata dalla IEEE [1999]. Boehm [1976] portò all'attenzione di tutti l'ingegneria del software e le sue sfide. Le difficoltà pratiche incontrate nello sviluppo di prodotti software industriali sono trattate nel libro classico di Brooks, The MythicalMan-Month [1975, 1995]. Di Brooks [1987] è anche l'ormai classico articolo sul "proiettile d'argento". Boehm [1981, 1995] fornisce le f o n d a m e n t a per modellare e valutare i costi di un software. Gli articoli di Parnas [1985] e Brooks [1987] contengono acute discussioni sulla natura del software e le difficoltà a esso inerenti. Un'opinione provocatoria è contenuta nel dibattito riportato in D e n n i n g [1989], che contiene un discorso tenuto da Dijkstra [1989] e confutazioni da parte di molti dei più importanti informatici. Per un dibattito sulla relazione tra ingegneria del software e linguaggi di programmazione, si consulti Ghezzi e Jazayeri [1998], che fornisce anche una visione globale dei linguaggi di programmazione, dei loro concetti e della loro evoluzione. Molti lavori sui sistemi operativi h a n n o influenzato la progettazione del software; citiamo, in particolare, i lavori iniziali di Dijkstra [1968a e b, e 1971], Hoare [1972, 1974, 1985] e Brinch Hansen [1977]. L'interazione tra sistemi operativi e ingegneria del software è discussa da Browne [1980], Le basi di dati sono studiate da Ullman e W i d o m [1997] e Date [2000], Gli specifici requisiti delle basi di dati richiesti dall'ingegneria del software sono analizzati da Dittirch et al. [2000]. I rapporti tra ingegneria del software e intelligenza artificiale sono analizzati in vari articoli, e le opinioni sono spesso controverse. Ad esempio, Simon [1986] e T i c h y [1987] sostengono che l'ingegneria del software dovrebbe adottare i metodi e gli strumenti dell'intelligenza artificiale, mentre Parnas [1988] sostiene il contrario e fornisce una visione critica dell'intelligenza artificiale. Alcuni approcci al software basato sulla conoscenza sono descritti da Kant e Barstow [1981], dall'edizione speciale dell'IEEE Transactions o n Software Engineering curato da Mostow (TSE [1985]), Goldberg [1986], e Rich e Waters [1988], Una discussione dei rapporti tra informatica teorica e sviluppo di software p u ò essere trovata in Mandrioli e Ghezzi [1987] Boehm [2000] sottolinea l'importanza dei rapporti tra ingegneria del software e ingegneria dei sistemi. Spector e Gifford [1986] discutono delle relazioni tra ingegneria del software e un altro campo dell'ingegneria, il progetto di ponti. N e u m a n n [1995] fornisce u n allarmante elenco di rischi per il pubblico, dovuti a software difettosi, e solleva il fondamentale problema della responsabilità sociale di u n ingegnere del software. Bohem e Sullivan [2000] discute dell'importanza dell'economia nell'ingegneria del software.

CAPITOLO

2

Il software: natura e qualità

Obiettivo di ogni attività ingegneristica è costruire qualcosa, un artefatto' o un prodotto; così l'ingegnere civile costruisce un ponte, l'ingegnere aerospaziale un aereo, l'ingegnere elettronico un circuito. Il prodotto dell'ingegneria del software è un sistema software: questo non è tangibile alla pari degli altri, ma è comunque un manufatto in grado di rispondere a una specifica funzione d'uso. La caratteristica che forse distingue maggiormente il software dagli altri prodotti è il fatto che sia "duttile": ossia, è possibile modificare il prodotto anziché modificare il progetto in maniera molto facile e questo lo rende sostanzialmente diverso dagli altri prodotti, come le automobili o i forni. Questa peculiarità del software è spesso male utilizzata. Quantunque sia possibile modificare un ponte o un aereo per soddisfare nuove necessità (per esempio, adeguare il ponte al crescente flusso di traffico o consentire a un aereo di trasportare più merci) questa modifica non viene mai intrapresa a cuor leggero, e certamente non viene tentata senza prima rielaborare il progetto e verificare l'impatto del cambiamento in maniera approfondita. Al contrario, agli ingegneri del software viene spesso richiesto di effettuare modifiche sostanziali. La duttilità del software porta a pensare che apportare modifiche sia banale, ma in pratica non è così. È facile cambiare il codice attraverso un editore di testi, ma non è facile fare in modo che il software soddisfi i fabbisogni per i quali era richiesto un cambiamento. È bene dunque che il software sia trattato alla pari degli altri prodotti ingegneristici: una modifica nel software deve essere vista come un cambiamento nel progetto piuttosto che nel codice. Una proprietà come la duttilità può essere vantaggiosamente sfruttata, ma occorre farlo con disciplina. Un'altra caratteristica del software è che la sua creazione è human intensive, ossia richiede un'elevata intensità di lavoro; richiede essenzialmente un'attività di "ingegneria" piuttosto che di "fabbricazione". Nella maggior parte delle altre discipline, il processo di fab-

1 Come precisato in seguito (Paragrafo 2.1.2), utilizziamo il termine artefatto come traduzione dell'inglese artifact per indicare la specifica accezione di "elaborato intermedio", distinguendolo così da "elaborato", considerato come prodotto finale, consegnato al committente (N.d.T.).

bricazione determina il costo finale del prodotto; inoltre il processo di produzione deve essere gestito in maniera accurata, in modo che non vengano introdotti difetti indesiderati nel prodotto. Le stesse considerazioni si applicano ai prodotti hardware, mentre invece per il software la fabbricazione si riduce a un banale processo di duplicazione. Il processo di produzione del software consiste essenzialmente nel progetto e nell'implementazione, e non nella fabbricazione. Questo processo deve soddisfare opportuni criteri che assicurino la produzione di software di elevata qualità. E auspicabile che un prodotto soddisfi determinate necessità e rispetti standard di accettazione che prescrivono le qualità che deve possedere. Ad esempio, la funzionalità di un ponte è quella di rendere facile il collegamento da un posto a un altro; una delle qualità attese è che questo non crolli in presenza di vento molto forte o quando è attraversato da una fila di camion. Nelle discipline dell'ingegneria tradizionale, l'ingegnere dispone di strumenti per descrivere le qualità del prodotto in maniera distinta rispetto al progetto del prodotto stesso. Nell'ingegneria del software questa distinzione non è cosi chiara: le qualità di un prodotto software sono molte volte mescolate nelle specifiche, insieme alle qualità intrinseche del progetto. In questo capitolo esamineremo le qualità rilevanti nei prodotti software e nei processi di produzione del software. Queste qualità diventeranno i nostri obiettivi da perseguire nella pratica dell'ingegneria del software. Nel capitolo successivo presenteremo i principi dell'ingegneria del software applicabili per raggiungere questi obiettivi. E inoltre importante verificare e misurare la presenza di qualità: questi argomenti sono introdotti nel Paragrafo 2.4 e approfonditi nel Capitolo 6.

2.1

Classificazione delle qualità del software

Esistono molte qualità desiderabili per il software; alcune si applicano sia al prodotto che al processo utilizzato per il suo sviluppo. L'utente richiede che il prodotto software sia affidabile, efficiente e facile da usare. Il produttore del software desidera che sia verificabile, manutenibile, portabile ed estendibile. Il manager di un progetto software desidera che il processo di sviluppo sia produttivo, prevedibile e facile da controllare. In questo paragrafo analizzeremo due diverse classificazioni delle qualità relative al software: qualità interne e qualità esterne da un lato, e qualità di prodotto e qualità del processo dall'altro.

2.1.1

Qualità interne ed esterne

Possiamo dividere le qualità del software in due categorie: interne ed esterne. Le qualità esterne sono visibili agli utenti del sistema mentre le qualità interne riguardano gli sviluppatori. In generale gli utenti del software hanno interesse soltanto nelle qualità esterne, ma sono le qualità interne, che in larga misura hanno a che fare con la struttura del software, ad aiutare gli sviluppatori a raggiungere le qualità esterne. Per esempio la qualità interna di verificabilità è necessaria per ottenere la qualità esterna di affidabilità. In molti casi tuttavia le qualità sono tra di loro strettamente correlate e la distinzione tra qualità interne ed esterne non è così marcata.

2.1.2

Qualità del processo e qualità del prodotto

Per realizzare un prodotto software si utilizza un processo. E possibile attribuire alcune qualità a un processo, anche se le qualità del processo sono spesso correlate con quelle del prodotto. Per esempio, l'affidabilità di un prodotto aumenta se il processo relativo richiede un'accurata pianificazione dei dati di test prima che sia effettuata un'attività di progettazione e di sviluppo del sistema. Nell'affrontare le qualità del software è bene comunque cercare di distinguere tra qualità del processo e qualità del prodotto. II termine prodotto di solito si riferisce a quanto viene alla fine consegnato al committente. Quantunque questa sia una definizione accettabile dal punto di vista del committente, non è corretta per lo sviluppatore, in quanto questi, nel corso del processo di sviluppo, produce una serie di prodotti intermedi. Il prodotto effettivamente visibile al committente consiste nel codice eseguibile e nei manuali utente, ma lo sviluppatore produce un numero elevato di altri artifatti, quali i documenti di specifica dei requisiti e di progetto, i dati di test e così via. Noi useremo il termine artefatto per denotare questi prodotti intermedi e per distinguerli dal prodotto finale consegnato al committente. Questi prodotti intermedi sono spesso soggetti agli stessi requisiti di qualità del prodotto finale. Poiché esistono molti prodotti intermedi, è possibile che diversi sottoinsiemi di questi vengano poi resi disponibili a diversi committenti. Per esempio, un costruttore di computer potrebbe vendere a un'azienda produttrice di sistemi di controllo di processo il codice oggetto che deve essere installato nell'hardware specializzato di un'applicazione embedded; questa potrebbe poi distribuire il codice oggetto e i manuali utente ai rivenditori di software. Infine, potrebbe cedere il progetto e il codice sorgente ai produttori di software, i quali potrebbero modificarli per costruire altri prodotti. L'attività di gestione delle configurazioni (configuration management) costituisce quella parte del processo di produzione del software che affronta il problema di mantenere e controllare le relazioni tra tutti i diversi prodotti intermedi delle varie versioni di un prodotto. Gli strumenti di gestione delle configurazioni supportano la manutenzione di famiglie di prodotti e dei loro componenti e aiutano a controllare e a gestire i cambiamenti ai prodotti intermedi. Discuteremo l'attività di gestione della configurazione nel Capitolo 7.

2.2

Principali qualità del software

In questa sezione presentiamo le più importanti qualità dei prodotti e dei processi software; ove appropriato, analizziamo una qualità con riferimento alle classificazioni che abbiamo descritto nel paragrafo precedente.

2.2.1

Correttezza, affidabilità e robustezza

I termini correttezza, affidabilità e robustezza sono strettamente correlati e insieme caratterizzano una qualità del software secondo la quale l'applicazione realizza la sua funzionalità attesa. Vediamo ora in dettaglio questi tre termini, analizzandone le loro mutue relazioni.

2.2.1.1

Correttezza

Un programma deve soddisfare la specifica dei suoi requisiti funzionali (functional requirements specification)-, esistono tuttavia altri requisiti, quelli di prestazioni e di scalabilità, i quali non fanno riferimento alle funzionalità del sistema; questi sono chiamati requisiti non funzionali (nonfunctional requirements). Un programma è funzionalmente corretto se si comporta secondo quanto stabilito dalle specifiche funzionali. Spesso si usa il termine "corretto" invece di "funzionalmente corretto", e analogamente, in questo contesto si usa il termine "specifiche" invece di "specifiche dei requisiti funzionali". Noi seguiremo questa convenzione quando il contesto è chiaro. La definizione di correttezza assume che le specifiche del sistema siano disponibili e che sia possibile determinare in maniera non ambigua se un programma soddisfi le specifiche. Tali specifiche raramente sono disponibili per la maggior parte dei sistemi software esistenti. Se una specifica esiste, questa è normalmente scritta in linguaggio non formale usando il linguaggio naturale; pertanto è probabile che contenga ambiguità. Tuttavia la definizione di correttezza è utile perché cattura un obiettivo desiderabile dei sistemi software. La correttezza è un proprietà matematica che stabilisce l'equivalenza tra il software e la sua specifica. Ovviamente, possiamo essere tanto più sistematici e precisi nel valutare la correttezza quanto più rigorosi siamo stati nello specificare i requisiti funzionali. Come vedremo nel Capitolo 6, si può valutare la correttezza di un programma mediante vari metodi, alcuni basati su un approccio sperimentale (ad esempio il testing), altri basati su un approccio analitico, come le ispezioni del codice o la verifica formale della sua correttezza. La correttezza può essere migliorata usando strumenti adeguati quali linguaggi di alto livello, in particolare quelli che supportano un'analisi statica approfondita dei programmi. Analogamente, la correttezza può essere migliorata usando ben noti algoritmi standard o librerie di moduli standard, piuttosto che inventarne ogni volta di nuovi. Infine, la correttezza può essere migliorata utilizzando metodologie e processi di provata efficacia. 2.2.1.2

Affidabilità

Informalmente, il software è considerato affidabile nella misura in cui l'utente può fare affidamento sulle sue funzionalità 2 . La letteratura specializzata definisce l'affidabilità in termini statistici, ovvero come la probabilità che un software operi come atteso in un intervallo di tempo determinato. Approfondiremo questo approccio più avanti, per ora ci accontenteremo invece di una definizione informale. La correttezza è una qualità assoluta: una qualunque deviazione rispetto a quanto stabilito rende un sistema non corretto, indipendentemente dalla serietà delle conseguenze di tale deviazione. La nozione di affidabilità è invece relativa. Se la conseguenza di un errore software non è grave, un software non corretto può continuare a essere considerato affidabile. È comune attendersi che i prodotti dell'ingegneria siano affidabili: quelli non affidabili di solito scompaiono velocemente dal mercato. Sfortunatamente i prodotti software non hanno raggiunto questo invidiabile stato, e vengono spesso rilasciati insieme a una lista di malfunzionamenti noti; gli utenti del software accettano come ineluttabile il fatto che la ver-

2

II termine che viene spesso usato come sinonimo di affidabile (reliable) è dependable.

Figura 2.1

Relazione tra correttezza e affidabilità nel caso ideale.

sione 1 di un prodotto contenga errori (un prodotto del genere viene definito "bacato", in inglese buggy). Questo è uno dei sintomi più evidenti dell'immaturità dell'ingegneria del software rispetto agli altri campi dell'ingegneria3. Nelle discipline classiche, un prodotto non viene rilasciato se può generare malfunzionamenti. Nessuno, infatti, accetterebbe di acquistare un automobile insieme a una lista di inconvenienti noti, o attraverserebbe un ponte che presenta un cartello di avvertimento sui pericoli del suo utilizzo. Gli errori di progettazione sono rari e qualora si manifestino vengono pubblicati sui giornali come fatti di cronaca. Il crollo di un ponte comporta inevitabilmente la denuncia dei progettisti. Al contrario, gli errori di progettazione del software vengono spesso trattati come inevitabili e, quando troviamo errori in un'applicazione, invece di essere sorpresi, in un certo senso li accettiamo con una sorta di rassegnazione. Mentre i manufatti convenzionali sono accompagnati da una garanzia che ne certifica l'assoluta affidabilità, di solito i prodotti software sono accompagnati da un avvertimento, nel quale il produttore afferma che non si ritiene responsabile degli errori presenti nel prodotto e dagli eventuali danni che possono provocare. L'ingegneria del software potrà davvero essere considerata una disciplina ingegneristica solo nel momento in cui si riuscirà a raggiungere con il software un'affidabilità paragonabile a quella raggiunta dagli altri prodotti dell'ingegneria. Nella Figura 2.1 è illustrata la relazione tra affidabilità e correttezza, nell'ipotesi che la specifica dei requisiti funzionali colga esattamente tutte le proprietà desiderabili di un'applicazione e non contenga invece, erroneamente, proprietà indesiderabili. La figura dimostra che l'insieme di tutti i programmi affidabili include l'insieme dei programmi corretti, ma non viceversa. Sfortunatamente, in pratica, la situazione è diversa: infatti la specifica è un modello di ciò che l'utente desidera, ma il modello non è necessariamente una descrizione accurata dei requisiti effettivi dell'utente. Ciò che il software può fare è soddisfare i requisiti specificati dal modello, non può invece assicurare l'accuratezza del modello. La Figura 2.1 rappresenta una situazione idealizzata nella quale si assume che i requisiti siano corretti, e cioè che siano una rappresentazione fedele di ciò che l'implemen-

3

Dijkstra [1989] afferma che l'utilizzo del termine gergale bug (letteralmente "cimice"; in italiano, nel contesto informatico, "baco"), spesso usato dagli ingegneri del software, è sintomo di mancanza di professionalità.

razione deve assicurare al fine di soddisfare i fabbisogni degli utenti previsti. Come tratteremo in modo approfondito nel Capitolo 7, si pongono frequentemente ostacoli insormontabili al raggiungimento di questo obiettivo; la conseguenza è che talvolta vi sono applicazioni corrette sviluppate sulla base di requisiti non corretti, sicché la correttezza del software non è sufficiente a garantire all'utente che il software si comporti come effettivamente desiderato. 2.2.1.3

Robustezza

Un programma è robusto se si comporta in modo accettabile anche in circostanze non previste nella specifica dei requisiti, per esempio quando vengono inseriti dati di input non corretti o in presenza di malfunzionamenti hardware (come la rottura di un disco). Un programma non è robusto se assume dati di ingresso perfetti e genera un errore non recuperabile durante l'esecuzione, qualora l'utente prema inavvertitamente un tasto sbagliato. Esso tuttavia può essere corretto, se la specifica dei requisiti non indica che cosa il programma dovrebbe fare in corrispondenza di un input scorretto. Naturalmente la robustezza è una qualità difficile da descrivere: se potessimo definire esattamente che cosa occorrerebbe fare per rendere un'applicazione robusta, saremmo sempre capaci di specificare, in maniera completa, il comportamento che ci attendiamo da un programma, e pertanto la robustezza diventerebbe equivalente alla correttezza o all'affidabilità nel senso della Figura 2.1. Ancora una volta, l'analogia con i ponti risulta istruttiva. Due ponti sullo stesso fiume sono entrambi corretti se soddisfano i requisiti di partenza; se invece durante un improvviso e inatteso terremoto solo uno dei due crolla, possiamo definire quello rimasto intatto più robusto dell'altro. Si noti che la lezione che si può trarre dal crollo di un ponte porterà a requisiti più completi per il futuro, stabilendo la resistenza ai terremoti come un requisito di correttezza. In altre parole, non appena il fenomeno studiato diventa meglio conosciuto, è possibile approssimare meglio il caso ideale mostrato nella Figura 2.1, dove le specifiche colgono i requisiti attesi in maniera esatta. La quantità di codice che affronta i requisiti di robustezza dipende dall'area applicativa; ad esempio, un sistema scritto per utenti inesperti di computer deve avere una maggiore predisposizione a reagire a input formattati in maniera scorretta, rispetto a un sistema embedded, che riceve i dati da sensori. Questo naturalmente non significa che i sistemi embedded non debbano essere robusti; al contrario, siccome spesso controllano dispositivi critici, richiedono di conseguenza un'elevata robustezza. In conclusione, possiamo dire che la robustezza e la correttezza sono caratteristiche strettamente correlate, senza che esista un linea divisoria netta tra le due. Se un requisito diventa parte della specifica, il suo soddisfacimento diventa un problema di correttezza; se invece lasciamo un requisito fuori dalla specifica, allora esso può diventare un problema di robustezza. La linea di demarcazione tra le due qualità è la specifica del sistema. Inoltre l'affidabilità ha un ruolo importante, in quanto non tutti i comportamenti scorretti implicano malfunzionamenti di eguale severità; vale a dire, alcuni comportamenti scorretti possono in pratica essere tollerati. Possiamo inoltre usare i termini correttezza, robustezza e affidabilità anche in relazior : al processo di produzione del software. Un processo è robusto, per esempio, se può far fronte a cambiamenti inattesi relativi all'ambiente, come il rilascio di un nuovo sistema operativo o l'improvviso trasferimento di metà dei dipendenti dell'azienda a un'altra sede. Un

processo è affidabile se porta comunque alla realizzazione di prodotti di elevata qualità. In molte discipline ingegneristiche una considerevole attività di ricerca è stata sviluppata per individuare processi affidabili.

2.2.2

Prestazioni

E comune attendersi che un prodotto dell'ingegneria offra buone prestazioni. Diversamente da altre discipline, nell'ingegneria del software molte volte confondiamo le prestazioni con l'efficienza, malgrado questi termini non denotino esattamente le stesse caratteristiche. L'efficienza è una caratteristica interna che si riferisce al "peso" che un software ha sulle risorse del computer. La prestazione invece è una qualità esterna basata sui requisiti dell'utente. Ad esempio, potrebbe essere richiesto a un sistema telefonico di gestire 10.000 chiamate all'ora. L'efficienza influenza e, frequentemente, determina le prestazioni di un sistema. Le prestazioni sono importanti in quanto influiscono sull'utilizzabilità di un sistema; infatti, se un sistema software è troppo lento, può ridurre la produttività dell'utente, magari fino al punto da non soddisfare più le sue esigenze. Se un sistema software utilizza troppo spazio su disco, potrebbe anche essere molto costoso; se usa troppa memoria potrebbe influire sulle applicazioni che sono in esecuzione nello stesso sistema, oppure potrebbe rallentare la propria esecuzione mentre il sistema operativo cerca di bilanciare l'uso della memoria da parte delle varie applicazioni in esecuzione in quel momento. Proprio per questi motivi è diffìcile definire esattamente il concetto di efficienza, perché questo si evolve e trasforma con l'evolversi della tecnologia. Anche il concetto di "troppo costoso" è in costante cambiamento a causa dei continui progressi tecnologici che ridefiniscono continuamente i limiti di ciò che la tecnologia è in grado di assicurare. I computer di oggi costano molto meno rispetto a quelli di qualche anno fa, e allo stesso tempo sono molto più potenti. Le prestazioni sono inoltre importanti in quanto influenzano la capacità di crescita [scalability) di un sistema software. Un algoritmo di complessità quadratica potrà funzionare per input di piccole dimensioni, ma di sicuro non funzionerà per input di grandi dimensioni. Ad esempio, un compilatore che usa un algoritmo di allocazione di registri il cui tempo di esecuzione dipende dal quadrato del numero di variabili del programma sarà sempre più lento man mano che aumenteranno le dimensioni del programma da compilare. Ci sono vari modi per valutare le prestazioni di un sistema, ad esempio analizzando la complessità degli algoritmi usati. Vi è una teoria che classifica il funzionamento di un algoritmo nella situazione peggiore (worst case) e in quella media (average), in termini di tempo e spazio o, addirittura, in termini del numero di messaggi scambiati nei casi di sistemi distribuiti. L'analisi di complessità degli algoritmi fornisce informazioni solo sui casi medio e peggiore, ma non fornisce alcuna informazione specifica su di una determinata implementazione. Per avere informazioni più specifiche e dettagliate, possiamo usare le tecniche di valutazione delle prestazioni. I tre metodi principali per valutare le prestazioni di un sistema sono: misura (measurement), analisi (analysis) e simulazione {simulatiori). Possiamo misurare le prestazioni reali di un sistema grazie a sistemi hardware e software di monitoraggio, che collezionano dati mentre il sistema è in esecuzione e quindi ci permettono di scoprire eventuali colli di bottiglia nel sistema. In questo caso è estremamente importante impostare dati di input che portino a esecuzioni che siano rappresentative dell'uso tipico del sistema. Il se-

condo approccio si basa sulla costruzione di un modello del prodotto e al suo successivo studio analitico. Il terzo approccio invece si basa sulla costruzione di un modello che simula il prodotto. I modelli analitici, usati nel secondo approccio, solitamente basati sulla teoria delle code, sono facili da costruire ma poco accurati, mentre i modelli di simulazione sono più costosi ma anche più accurati. A volte queste due tecniche possono essere combinate tra loto: all'inizio di un progetto di grandi dimensioni, un modello analitico può essere utile per avere una visione generale delle aree critiche nelle prestazioni del sistema e quindi individuare le aree in cui sono necessari ulteriori studi; successivamente si può passare alla costruzione dei modelli simulativi per tali aree. In molti progetti di sviluppo di software, le prestazioni vengono prese in considerazione solo dopo l'implementazione della versione iniziale del prodotto. E molto difficile a volte è proprio impossibile - arrivare a significativi miglioramenti nelle prestazioni senza dover riprogettare il software. Anche un modello molto semplice, tuttavia, può essere utile per prevedere le prestazioni di un sistema e indirizzare le scelte di progettazione in modo da minimizzare la riprogettazione. In alcuni progetti complessi, per i quali potrebbe non essere ovvia la raggiungibilità di determinati requisiti di prestazioni, ci si impegna a creare modelli per l'analisi delle prestazioni. Tali progetti iniziano con un modello, utilizzato inizialmente per rispondere a domande e dubbi inerenti la fattibilità e successivamente per prendere decisioni. Questi modelli possono, ad esempio, aiutare a decidere se una determinata funzione dovrà essere fornita dal software o da un dispositivo hardware specialpurpose. Le precedenti osservazioni sono utili a livello globale, quando cioè viene concepita la struttura generale del software. Spesso, invece, non si possono applicare "in piccolo", quando i singoli programmi prima vengono progettati prestando attenzione alla correttezza e poi vengono modificati singolarmente per aumentarne l'efficienza. Ad esempio, i cicli interni sono degli ovvi candidati a modifiche volte a migliorare l'efficienza. Occorre pertanto distinguere le diverse modalità per operare in the large e in the small. Il concetto di prestazioni si può anche applicare al processo di sviluppo; in tal caso prende il nome di produttività, argomento che, per la sua importanza, sarà trattato autonomamente nel Paragrafo 2.2.10.

2.2.3

Usabilità

Un software è usabile (usable) o, più comunemente, user friendly (letteralmente "amichevole con l'utente"), se i suoi utenti lo reputano facile da utilizzare. Questa definizione riflette la natura soggettiva dell'usabilità. Le qualità che rendono un'applicazione user friendly ai principianti sono diverse da quelle desiderabili per gli utenti esperti. Ad esempio, un principiante potrebbe gradire messaggi prolissi, mentre un utente esperto probabilmente li ignorerebbe. Allo stesso modo, mentre un programmatore potrebbe trovarsi più a suo agio con comandi testuali, un utente meno smaliziato potrebbe preferire l'uso di menu. L'interfaccia utente influisce molto sull'amichevolezza4 di un'applicazione. Un sistema

4

Termine con il quale indicheremo sinteticamente il concetto di intuitiva, immediata fruibilità - a prima vista - del software (look andfeel) (N.d.T.).

software che fornisce a un principiante un'interfaccia a finestre e l'impiego del mouse è molto più amichevole di un sistema che richiede di digitare una serie di comandi. Allo stesso tempo, però, un utente esperto potrebbe preferire una scelta di comandi da tastiera, piuttosto che navigare attraverso un'interfaccia eccessivamente elaborata. Comunque, esistono altri componenti, oltre all'interfaccia utente, che influiscono sul grado di usabilità di un'applicazione. Ad esempio, un sistema software embedded non ha alcuna interfaccia utente ma interagisce con componenti hardware o con altri software. In questo caso l'usabilità si basa sul grado di facilità di configurazione del software e di adattamento all'ambiente hardware circostante. In generale, l'usabilità di un sistema dipende dalla coerenza e dalla prevedibilità della sua interfaccia nei confronti dell'utente o dell'operatore. Chiaramente, però, altre qualità già precedentemente citate, come la correttezza e le prestazioni, influiscono sul livello di facilità d'uso. Un sistema che elabora risposte sbagliate non è amichevole, indipendentemente da quanto sofisticata sia la sua interfaccia utente. Analogamente, un sistema software che elabora le risposte più lentamente di quanto sia tollerabile non è amichevole, anche se tali risposte sono mostrate con colori accattivanti. L'usabilità è studiata anche dalla disciplina scientifica che si occupa del "fattore umano" (human factor) e i risultati sono estremamente importanti in molti ambiti dell'ingegneria. Ad esempio, i progettisti di automobili dedicano molto tempo per definire il posizionamento ergonomico dei vari pulsanti di controllo sul cruscotto. Anche i costruttori di elettrodomestici, quali televisori e forni a microonde, cercano di realizzare prodotti di facile utilizzo. In questi campi dell'ingegneria classica, le decisioni riguardo l'interfaccia utente vengono prese dopo lunghi studi delle necessità e attitudini dell'utente, coinvolgendo specialisti di varie discipline, tra le quali disegno industriale e psicologia. La facilità d'uso in molte discipline ingegneristiche viene raggiunta con la standardizzazione dell'interfaccia uomo-macchina. Questa consente all'utente, una volta che ha imparato a far funzionare il televisore di una certa marca, di poter facilmente utilizzare anche quelli di altre case produttrici. Negli ultimi anni si può notare una marcata tendenza nel software ad adottare interfacce utente standard, come nel caso dei browser per il Web. Esercizio 2.1

2.2.4

Discutete come l'interfaccia utente possa influire sull'affidabilità.

Verificabilità

E molto importante poter facilmente procedere alla verifica della correttezza e delle prestazioni di un sistema software: questa caratteristica è detta, appunto, verificabilità. Come vedremo nel Capitolo 6, la verifica potrà essere svolta attraverso metodi di analisi formali o informali, oppure attraverso test. Una tecnica comune per aumentare la verificabilità si basa sull'uso di software monitor (codice inserito nel software per mantenere sotto controllo determinati aspetti della qualità, come le prestazioni o la correttezza). Al fine di migliorare, fin dall'inizio, la verificabilità, si possono adottare metodi di progettazione modulare, norme sistematiche di codifica e l'uso di linguaggi di programmazione appropriati alla scrittura di codice ben strutturato.

La verificabilità è di solito una qualità interna, nonostante talvolta possa diventare anche esterna. Ad esempio, per molte applicazioni in cui la sicurezza svolge un ruolo critico, l'utente richiede la verificabilità di determinate caratteristiche. Il livello più alto negli standard di sicurezza per un computer arriva a richiedere la verificabilità del nucleo del sistema operativo.

2.2.5

Manutenibilità

Il termine "manutenzione del software" viene comunemente usato per indicare le modifiche effettuate su un software dopo il suo rilascio iniziale. Inizialmente, si riteneva che la manutenzione riguardasse esclusivamente l'eliminazione degli errori; tuttavia è stato dimostrato che la maggior parte del tempo per la manutenzione viene di fatto impiegato per migliorare il prodotto implementando caratteristiche che inizialmente non erano presenti nelle specifiche originali, o per correggere specifiche che erano state introdotte in maniera scorretta. In effetti, il termine "manutenzione" viene impiegato in modo improprio. Innanzitutto, per come viene oggi usato, denota un ampio spettro di attività che hanno tutte a che fare con la modifica di un frammento esistente di software al fine di migliorarlo. Il termine che probabilmente meglio esprime l'essenza di questo concetto è "evoluzione del software". Inoltre, in altri prodotti ingegneristici quali l'hardware dei computer, le automobili o gli elettrodomestici, il termine manutenzione si riferisce alle azioni svolte al fine di continuare a garantire il buon funzionamento del prodotto, malgrado il graduale deterioramento delle parti dovuto all'uso continuativo. Ad esempio, i sistemi di trasmissione devono essere lubrificati e i filtri dell'aria vanno periodicamente sostituiti a causa delle polveri che vi si depositano. L'uso del termine manutenzione nel caso del software fornisce un'impressione sbagliata, dal momento che il software non si deteriora. Purtroppo, però, il termine è usato in maniera così diffusa che ormai siamo obbligati a continuare a utilizzarlo. I fatti dimostrano che i costi della manutenzione superano il 60 per cento dei costi totali del software. Per analizzare i fattori che influenzano tali costi è comune classificare la manutenzione del software in tre categorie: correttiva, adattativa e perfettiva. La manutenzione correttiva riguarda la rimozione di errori residui presenti nel prodotto al momento del rilascio, come pure l'eliminazione di errori introdotti nel software durante l'attività di manutenzione stessa. Alla manutenzione correttiva può essere attribuito circa il 20 per cento dei costi totali di manutenzione. Le attività di manutenzione adattativa e perfettiva derivano dalle richieste di cambiamenti nel software e richiedono, da parte dell'applicazione, una capacità di evolvere come qualità fondamentale. Richiedono anche la capacità di anticipare i cambiamenti (definita nel Capitolo 3), quale principio generale che dovrebbe guidare l'ingegnere del software nello svolgimento delle attività progettuali. Alla manutenzione adattativa può essere attribuito circa il 20 per cento dei costi di manutenzione, mentre la manutenzione perfettiva assorbe dunque più del 50 per cento dei costi. La manutenzione adattativa riguarda le modifiche dell'applicazione in risposta a cambiamenti nell'ambiente come, per esempio, una nuova versione dell'hardware, del sistema operativo o del DBMS con il quale il software interagisce. In altri termini, nella manutenzione adattativa la necessità dei cambiamenti del software non è tanto da attribuire a problemi del software stesso, quali ad esempio errori residui o incapacità di fornire alcune fun-

zioni richieste dall'utente, quanto a cambiamenti che avvengono nell'ambiente nel quale il software è inserito. Infine, la manutenzione perfettiva riguarda i cambiamenti nel software per migliorarne alcune qualità. In questo caso i cambiamenti sono dovuti alla necessità di modificare le funzioni offerte dall'applicazione, aggiungerne di nuove, migliorare le prestazioni, renderne più facile l'utilizzo, etc. Le richieste di manutenzione perfettiva possono provenire direttamente dall'ingegnere del software, con l'obiettivo di migliorarne la presenza sul mercato, oppure dal committente, al fine di rispondere a nuovi requisiti. Il termine legacy software (software ereditato) si riferisce al software che esiste in un'organizzazione da lungo tempo e che pertanto incorpora una parte notevole delle conoscenze dei processi dell'organizzazione. Pertanto tale software possiede per l'organizzazione un valore strategico, rappresenta investimenti passati e non può essere sostituito facilmente. D'altra parte, a causa della sua età, in genere questo software è scritto in linguaggi di programmazione ormai desueti e utilizza tecnologia obsoleta. Il legacy software è pertanto diffìcile da modificare e mantenere. Il legacy software rappresenta una sfida all'evoluzione del software. Le tecniche e le tecnologie di reverse engingeering (letteralmente, "ingegneria inversa") e reenginereering ("re-ingegnerizzazione") hanno l'obiettivo di aiutare a scoprire la struttura del legacy software per ristrutturarlo o, almeno, aiutare a migliorarlo. La manutenibiltà può essere vista come un insieme di due diverse qualità: la riparabilità e l'evolvibiltà. Il software è riparabile se consente facilmente di eliminare i difetti; è evolvibile se facilita i cambiamenti che gli permettono di soddisfare nuovi requisiti. La distinzione tra evolvibilità e riparabilità non è sempre chiara; ad esempio, se le specifiche dei requisiti sono vaghe, può non essere evidente se un'attività di modifica sia volta all'eliminazione di un difetto o al soddisfacimento di un nuovo requisito. 2.2.5.1

Riparabilità

Un sistema software è riparabile se i suoi difetti possono essere corretti con una quantità ragionevole di lavoro. In molti prodotti ingegneristici la riparabilità è un obiettivo progettuale fondamentale. Per esempio, un'automobile è costruita in modo che le parti maggiormente soggette a guasti o a usura siano agevolmente accessibili. Nell'ingegneria dell'hardware dei computer esiste una particolare specializzazione chiamata RAS (dalle iniziali di repairability, availability e serviceability) che affronta queste problematiche. In altri campi dell'ingegneria, quando il costo di un prodotto diminuisce e il prodotto diventa di uso quotidiano, assumendo lo stato di commodity, la necessità di riparabilità decresce: di fatto diventa più economico sostituire l'intero prodotto o gran parte di esso piuttosto che ripararlo. Ad esempio, nei primi apparecchi televisivi capitava di dover sostituire una singola valvola: oggi invece viene sostituita l'intera parte circuitale. Una tecnica comune per ottenere la riparabilità dei prodotti consiste nell'utilizzo di parti standard, facilmente sostituibili. A titolo di esempio, i personal computer inizialmente erano costruiti con componenti ad hoc e mediante meccanismi di interconnessione di tipo proprietario. Oggi, invece, sono costruiti con componenti standard e con sistemi di interconnessione {bus) standard. Questa standardizzazione ha portato a\ proliferare di produttori, ciascuno specializzato in singole parti. Attraverso la specializzazione, questi produttori hanno potuto dedicarsi al miglioramento dell'affidabilità di ciascuna parte, riducendone anche i costi. Di conseguenza, la produzione di un computer è diventata più ve-

loce e meno costosa, e un guasto può essere riparato sostituendo la parte difettosa. Nel caso del software, le parti che costituiscono un'applicazione non si deteriorano. Pertanto, mentre l'uso di parti standard può ridurre il tempo e il costo di produzione, il concetto di parti riparabili non sembra applicarsi in maniera analoga al software. Il fatto di costruire un software a partire da componenti riduce solo il tempo di progettazione, in quanto il progettista si concentra sul combinare insieme componenti ben noti, piuttosto di dover partire da zero nel progetto. La riparabilità è influenzata anche dal numero di parti in un prodotto. Per esempio, è più difficile riparare un difetto nella carrozzeria di un'automobile costituita da un unico pezzo, di quanto non succeda per una carrozzeria costituita da numerosi componenti con forma regolare. In questo secondo caso è possibile sostituire un singolo pezzo molto più facilmente rispetto alla sostituzione dell'intera carrozzeria. Naturalmente, se la carrozzeria fosse costituita da un numero eccessivo di parti, richiederebbe molte connessioni tra questi, il che implicherebbe a sua volta una probabilità non trascurabile che le connessioni stesse debbano essere riparate. Una situazione analoga si applica al software: un prodotto software che consiste di moduli ben progettati è più facile da analizzare e riparare di un sistema monolitico. Il semplice aumento del numero dei moduli però non rende di per sé più riparabile un prodotto. E necessario scegliere la corretta struttura dei moduli, con le giuste interfacce che evitino l'insorgere di interconnessioni e interazioni troppo complesse tra i moduli. La giusta modularizzazione promuove la riparabilità, consentendo all'ingegnere di localizzare più facilmente gli errori. Nel Capitolo 4 esamineremo diverse tecniche di modularizzazione, tra le quali l'information hiding (incapsulamento di informazioni) e i tipi di dati astratti. La riparabilità può essere migliorata anche attraverso l'uso di strumenti adeguati; per esempio, l'utilizzo di un linguaggio di alto livello piuttosto del linguaggio assembler conduce a una migliore riparabilità. Strumenti come i debugger possono aiutare a isolare e riparare gli errori. Infine la riparabilità di un prodotto ne influenza l'affidabilità, e la necessità di riparabilità diminuisce con l'aumentare dell'affidabilità. 2.2.5.2

Evolvibili

Come altri prodotti dell'ingegneria, i prodotti software sono modificati in continuazione al fine di fornire nuove funzioni o modificare quelle già esistenti. Il fatto che il software sia intrinsecamente duttile rende la modifiche molto facili da effettuare direttamente sull'implementazione. Esiste tuttavia una sostanziale differenza tra le modifiche nel campo del software e le modifiche negli altri prodotti ingegneristici. In quest'ultimo caso le modifiche iniziano al livello del progetto e solo successivamente si procede verso modifiche all'implementazione del prodotto. Ad esempio, se si decide di aggiungere un piano a un edificio, innanzitutto occorre eseguire uno studio di fattibilità per valutare se questa aggiunta possa effettivamente essere apportata in maniera sicura. Successivamente il progetto deve essere approvato, dopo aver verificato che non violi alcuno dei vincoli esistenti. Solo a questo punto può essere firmato un contratto per la costruzione del nuovo piano. Questo approccio sistematico di solito non è applicabile per il software. Anche nel caso di cambiamenti radicali a un'applicazione, spesso la fasi di studio di fattibilità e di analisi del progetto vengono saltate e si procede immediatamente a effettuare le modifiche sul-

l'implementazione. Peggio ancora, una volta che i cambiamenti sono stati effettuati, questi non vengono documentati neppure a posteriori; vale a dire, le specifiche non vengono aggiornate in modo da riflettere i cambiamenti effettuati, e questo rende eventuali futuri cambiamenti ancora più difficili da apportare. Allo stesso tempo, occorre osservare che prodotti software di successo hanno una lunga vita. Il loro rilascio iniziale è il primo di una lunga serie di rilasci, ciascuno dei quali costituisce un passo nell'evoluzione del sistema. Se il software viene progettato avendo in mente la sua evoluzione e se ogni modifica viene progettata e poi messa in pratica attentamente, allora il software può effettivamente evolvere in maniera ordinata e controllata. Con il crescere del costo della produzione del software e della complessità delle applicazioni, l'evolvibilità assume ancora maggiore importanza. Uno dei motivi è la necessità di far fruttare gli investimenti fatti nel software, a fronte dei continui cambiamenti della tecnologia hardware. Ancora oggi alcuni dei primi grandi sistemi sviluppati negli anni Sessanta continuano a evolversi per sfruttare i vantaggi dei nuovi dispositivi hardware e delle tecnologie di rete. Per esempio, il sistema di prenotazione SABRE dell'American Airlines, sviluppato inizialmente negli anni Sessanta, è andato evolvendosi per decenni al fine di includere un insieme sempre più ricco di funzionalità. Nelle fasi iniziali, la maggior parte dei sistemi software è in grado di evolvere con facilità, ma purtroppo, dopo continui cambiamenti, si raggiunge uno stadio in cui ogni ulteriore modifica rischia di introdurre malfunzionamenti nelle caratteristiche esistenti. Inizialmente, l'evolvibilità viene raggiunta attraverso un'adeguata modularizzazione del sistema, ma i cambiamenti progressivi tendono a ridurre la modularità del sistema originario, tanto più quando le modifiche vengono effettuate senza un attento studio del progetto originario e senza che venga stilata una descrizione precisa dei cambiamenti, sia nei documenti di progetto che in quelli di specifica dei requisiti. E stato dimostrato da studi empirici sull'evoluzione di grandi sistemi software che la capacità di evolvere tende a diminuire con i rilasci successivi del prodotto. Ogni rilascio complica la struttura del software e rende le modifiche future sempre più difficili da effettuare. Al fine di superare questo problema occorre fare ogni sforzo perché sia il progetto iniziale che i successivi cambiamenti vengano effettuati prestando estrema attenzione al problema dell'evolvibilità. Questa è un'importante qualità del software per il suo impatto economico; molti dei principi presentati nel prossimo capitolo aiutano a ottenere una migliore evolvibilità. Nel Capitolo 4 illustreremo concetti quali le famiglie di programmi, le famiglie di prodotti e le architetture software, che hanno lo scopo di consentire una più facile evoluzione dei prodotti software, L'approccio delle famiglie di prodotti, spesso chiamate "linee di prodotti" {produci line), ha proprio l'obiettivo di trovare un approccio sistematico all'evolvibilità.

2.2.6

Riusabilità

La riusabilità è per molti aspetti simile all'evolvibilità. Nell'evoluzione di un prodotto, questo viene modificato per dar vita a una nuova versione; nel caso del riuso, questo viene utilizzato - magari con un numero inferiore di modifiche - per dar vita a un prodotto diverso. La riusabilità può essere applicata a vari livelli di granularità — dall'intera applicazione a specifiche routine - tuttavia il concetto sembra applicarsi meglio ai componenti software piuttosto che ai prodotti completi.

Un buon esempio di prodotto riusabile è lo shell di UNIX, cioè l'interprete del linguaggio di comandi, che accetta ed esegue i comandi dell'utente. Lo shell è stato progettato per essere utilizzato sia in modo interattivo che in batch. La possibilità di far eseguire un nuovo shell con un file che contiene una lista di comandi shell permette di scrivere programmi, detti script, nel linguaggio di comandi dello shell. Possiamo dunque vedere il programma come un nuovo prodotto che usa lo shell come componente. Per incoraggiare l'uso di interfacce standard, l'ambiente UNIX di fatto supporta il riuso di uno qualunque dei suoi comandi nel fornire funzionalità più complesse. Le librerie numeriche sono state i primi esempi di componenti riusabili. Parecchie librerie software, inizialmente scritte in FORTRAN e ora riscritte in C, C++ e altri linguaggi, esistono ormai da molti anni. Gli utenti possono acquistare queste librerie e utilizzarle nello sviluppo dei loro prodotti senza dover reinventare o ricodifìcare algoritmi ben noti. Vi sono ormai vari produttori di software che si sono specializzati nella produzione di queste librerie e oggi esistono librerie riusabili per aree diverse, quali le interfacce grafiche, la simulazione, etc. Uno degli obiettivi dei ricercatori è quello di aumentare la granularità dei componenti che possono essere riusati. Una delle finalità della programmazione object-oriented è esattamente quella di raggiungere sia una migliore riusabilità che una migliore evolvibilità. Oltre ai componenti software, il concetto è applicabile in maniera più ampia e, a diversi livelli, può riguardare sia il prodotto sia il processo. In generale, qualunque artefatto del processo software, quale ad esempio la specifica dei requisiti, può essere reimpiegato. Pertanto, la possibilità di riusare questi artefatti, per intero o solo in alcune parti, dipenderà da quanto sia modulare il progetto. Per esempio, un documento di specifica dei requisiti riusabile consente di riutilizzare in futuro parti del risultato di analisi e di comprensione del problema. La riusabilità si applica anche al processo: le varie metodologie di sviluppo del software possono essere infatti viste come tentativi di riutilizzare lo stesso processo per costruire prodotti diversi. Anche i modelli di ciclo di vita possono essere visti come tentativi di riuso, ad alto livello, dei processi software. Discuteremo tutto ciò nel Capitolo 7. La riusabilità di parti standard caratterizza la maturità di un settore industriale. Si può infatti osservare un forte riutilizzo in aree mature come l'industria automobilistica o l'elettronica di consumo. Per esempio, un'autovettura viene costruita assemblando molti componenti fortemente standardizzati e che vengono riutilizzati per molti modelli dello stesso produttore, così come avviene per i progetti e per gli stessi processi di produzione. Il livello di riuso è in forte aumento anche nell'ambito dell'ingegneria del software, ma siamo ancora ben lontani da quanto avviene nei settori più tradizionali dell'ingegneria. Esercizi 2.2

Discutete q u a n t o la riusabilità possa influenzare l'affidabilità dei prodotti.

2.3

II riuso di u n c o m p o n e n t e può richiedere qualche attività di adattamento. Discutete come l'ereditarietà fornita da un linguaggio object-oriented, quale Java o C++, consenta l'adattabilità di un c o m p o n e n t e .

2.2.7

Portabilità

Il software è portabile se può essere eseguito in ambienti diversi. Il termine ambiente può riferirsi alla piattaforma hardware o all'ambiente software, come il particolare sistema operativo nel quale l'applicazione viene eseguita. La portabilità è economicamente importante, in quanto consente di ammortizzare gli investimenti in un sistema software su diversi ambienti e diverse generazioni dello stesso ambiente. Al giorno d'oggi molte applicazioni sono indipendenti dalla piattaforma hardware, in quanto il sistema operativo fornisce portabilità tra le diverse piattaforme; sono invece spesso dipendenti dal sistema operativo e dagli altri sistemi software di base, quali i DBMS o il sistema di supporto delle interfacce utente. La portabilità può essere ottenuta modularizzando il software, in modo tale che le dipendenze dall'ambiente vengano isolate in pochi moduli, modificabili in caso di trasporto del software in un ambiente diverso. A causa della proliferazione dei sistemi distribuiti in rete, la portabilità ha assunto ulteriore importanza, in quanto gli ambienti di esecuzione, che consistono di diversi tipi di computer e di sistemi operativi, sono per loro natura eterogenei. Infine, in molti casi, possono essere diversi i dispostivi sui quali il software può essere eseguito. Ad esempio i browser devono poter essere eseguiti non solo su workstation (stazioni di lavoro) o personal computer ma anche su palmari o telefoni cellulari. Taluni sistemi software sono specifici della macchina. Ad esempio un sistema operativo viene scritto per controllare un ben preciso computer e un compilatore produce codice per un determinato hardware. Tuttavia anche in questi casi è possibile raggiungere un certo livello di portabilità. UNIX e la sua variante, Linux, sono esempi di sistemi operativi che sono stati portati su molte e diverse piattaforme hardware. Naturalmente lo sforzo di trasporto può richiedere mesi di lavoro; tuttavia possiamo definire il software come portabile quando scriverlo da zero nel nuovo ambiente richiederebbe uno sforzo maggiore di quanto non lo richieda l'adattamento. Esercizi 2.4

Analizzate la portabilità quale caso speciale di riusabilità.

2.5

Possiamo applicare il concetto di portabilità alle pagine Web. Discutete che cosa significa dire che una pagina web è portabile.

2.2.8

Comprensibiltà

Alcuni sistemi software sono più facili da capire di altri. La comprensibilità di un sistema software dipende in parte da come il software è progettato, ma in larga misura dipende dal problema affrontato dal software: alcuni problemi sono per loro natura più complessi di altri. Per esempio, un sistema che effettua le previsioni del tempo, anche se ben progettato e implementato, sarà più difficile da capire rispetto a un software che si limita a stampare un indirizzario. Dati due compiti di difficoltà simile, è possibile seguire alcune linee guida che consentono di sviluppare progetti e scrivere programmi più facilmente comprensibili. Per esempio, i concetti di astrazione e modularità migliorano la comprensibilità di un sistema. Durante l'attività di manutenzione di un software è fondamentale la comprensione dei programmi. Gli ingegneri che effettuano manutenzione passano gran parte del loro tempo

a cercare di scoprire la logica dell'applicazione e solo una piccola porzione del loro tempo viene impiegata per effettuare i cambiamenti necessari. La comprensibilità è una qualità interna del prodotto che aiuta a raggiungere molte delle altre qualità, quali l'evolvibilità e la verificabilità. Da un punto di vista esterno, l'utente considera un sistema comprensibile se ha un comportamento prevedibile. La comprensibilità esterna è un fattore importante che contribuisce all'usabilità del prodotto.

2.2.9

Interoperabilità

L'interoperabilità si riferisce alla capacità di un sistema di coesistere e cooperare con altri sistemi come, ad esempio, la capacità di un sistema di elaborazione testi di incorporare un diagramma prodotto da un pacchetto grafico e la capacità del pacchetto grafico di rappresentare dati prodotti da un foglio elettronico, oppure la capacità del foglio elettronico di elaborare un'immagine acquisita da uno scanner. L'interoperabilità è molto diffusa negli altri prodotti ingegneristici. Per esempio, un impianto audio può essere costituito da elementi di diversi produttori ed essere connesso a una televisione e a un videoregistratore; inoltre, sistemi audio datati (anche di decenni) possono facilmente inserirsi in un impianto nel quale esistono lettori di compact disc. Al contrario, i primi sistemi operativi dovettero essere modificati, talvolta in maniera molto significativa, prima che potessero essere utilizzati con nuovi tipi di dispositivi. La generazione dei sistemi operativi cosiddetti plug-and-play (letteralmente "collega e usa") tenta di risolvere questo problema individuando e interfacciandosi automaticamente con i nuovi dispositivi. L'ambiente UNIX, con le sue interfacce standard, offre un primo esempio di interoperabilità all'interno dello stesso ambiente. UNIX incoraggia la progettazione di applicazioni con interfacce molto semplici e standard, che consentono all'output di un'applicazione di essere usato come input di un'altra. Purtroppo, però, l'interfaccia standard di UNIX è primitiva, basata sui caratteri, e risulta poco conveniente quando è invece necessario usare dati strutturati, ad esempio un foglio elettronico o un'immagine, prodotti da altre applicazioni. Mediante l'interoperabilità, un produttore può sviluppare diversi prodotti e far sì che l'utente, se necessario, possa combinarli liberamente, scegliendo, di volta in volta, quali funzioni acquistare. L'interoperabilità può essere raggiunta attraverso la standardizzazione delle interfacce. Un esempio è fornito dai web browser, che supportano diversi tipi di plug-in consentendo così, per esempio, di aggiungere un nuovo programma per la lettura di file audio fornito da un venditore al browser fornito da un altro. Un concetto correlato con l'interoperabilità è quello di sistema aperto, vale a dire una collezione estendibile di applicazioni scritte in modo indipendente tra di loro, ma che funzionano come un sistema intergrato. Un sistema aperto consente l'aggiunta di nuove funzionalità, sviluppate da organizzazioni indipendenti, dopo che il sistema è stato consegnato. Questo viene ottenuto, per esempio, rilasciando il sistema insieme alla specifica delle sue interfacce "aperte". Ogni sviluppatore di applicazioni può avvantaggiarsi dalla conoscenza di queste interfacce, che possono essere usate per far comunicare tra di loro parti diverse. I sistemi aperti consentono di fare interoperare diverse applicazioni, scritte da diverse organizzazioni. Un interessante requisito dei sistemi aperti è che possono essere aggiunte nuove funzionalità senza dover necessariamente fermare l'esecuzione del sistema (proprietà tipica del plug-and-play). Un sistema aperto è dunque analogo a un'organizzazione sociale che cresce

ed evolve nel tempo, adattandosi ai cambiamenti nell'ambiente. L'importanza dell'interoperabilià ha dato luogo al crescente interesse verso i sistemi aperti, e ciò a sua volta ha generato sforzi di standardizzazione, quali CORBA, che definisce le interfacce che supportano lo sviluppo di componenti da utilizzare in sistemi distribuiti aperti. CORBA verrà trattato nel Capitolo 4. Esercizio 2.6

Discutete le relazioni tra evolvibiltà e sistemi aperti.

2.2.10

Produttività

La produttività è una qualità del processo di produzione del software: ne indica l'efficienza e le prestazioni. Un processo efficiente dà luogo a una consegna più rapida del prodotto finale. Ciascun ingegnere produce software alla propria velocità, anche se ci sono molte variazioni tra individui con diverse capacità di programmazione. Quando gli individui sono parte di un gruppo, la produttività del gruppo è una funzione della produttività degli individui. Spesso la produttività combinata è molto inferiore alla produttività che risulterebbe dalla somma delle parti, a causa del tempo necessario per la comunicazione e il coordinamento all'interno del gruppo. Il management del progetto cerca di organizzare i gruppi di lavoro e di adottare processi di sviluppo in modo tale da sfruttare al massimo la produttività individuale di ciascun membro. La produttività del personale tecnico influenza la scelta del processo, e viceversa. Per esempio, un processo che richiede specializzazione dei membri del gruppo di lavoro può dar luogo a un'elevata produttività nello sviluppo di un certo prodotto, ma non altrettanto per un'ampia gamma di prodotti. Il riuso del software è una tecnica che migliora la produttività complessiva di un'organizzazione per un insieme di prodotti, ma il costo di sviluppo dei moduli riutilizzabili può essere ammortizzato solo attraverso lo sviluppo di più prodotti. La produttività del software è una caratteristica difficile da misurare, anche se riveste un grande interesse pratico, a causa dei costi crescenti dello sviluppo. Se vogliamo poter confrontare tra di loro diversi processi in funzione della loro produttività, dobbiamo ovviamente possedere una metrica per misurarla. Le metriche individuate in un primo tempo, quali le linee di codice prodotte, hanno numerosi inconvenienti. Nel Capitolo 8 tratteremo le metriche per quantificare la produttività e come organizzare gruppi di lavoro per ottimizzarla. Come per ogni altra disciplina ingegneristica, constateremo che l'efficienza del processo è fortemente influenzata dall'automazione. I moderni ambienti e strumenti dell'ingegneria del software sono finalizzati al miglioramento della produttività, e saranno trattati nel Capitolo 9. Esercizio 2.7

Valutate criticamente il n u m e r o di linee di codice come misura della produttività. (Questo problema verrà analizzato a p p r o f o n d i t a m e n t e nel Capitolo 8.)

2.2.11

Tempestività

La tempestività è una qualità del processo che indica la capacità di rendere disponibile un prodotto al momento giusto. Storicamente, questa qualità è stata carente nei processi di sviluppo del software e ciò ha condotto alla cosiddetta "crisi del software" che, a sua volta, è stata responsabile della nascita dell'ingegneria del software come disciplina nell'ambito dell'informatica. Al giorno d'oggi, soprattutto a causa dei mercati sempre più competitivi, i progetti software affrontano sfide ancora più stringenti in termini di tempo di sviluppo dei prodotti. Il seguente esempio illustra come venivano gestite le difficoltà di consegna verso la fine degli anni Ottanta. Una società di software aveva promesso di rilasciare una prima versione del proprio compilatore per il linguaggio Ada entro una certa data. Alla scadenza prevista, i committenti che avevano effettuato l'ordine, anziché ricevere il prodotto, si videro recapitare una lettera, nella quale si annunciava la decisione di ritardare la consegna in quanto il prodotto era instabile e conteneva ancora molti difetti; la consegna sarebbe stata prorogata di tre mesi. Dopo quattro mesi il prodotto fu consegnato insieme a una lettera, nella quale si dichiarava che molti difetti erano stati corretti, ma molti altri erano ancora presenti. Tuttavia, il produttore aveva deciso che era meglio che i clienti ricevessero il compilatore Ada, nonostante contenesse ancora numerosi gravi difetti, in modo tale che potessero iniziare a sviluppare le applicazioni che utilizzavano il linguaggio Ada. Si era infatti valutato che i rischi connessi all'utilizzo di un prodotto prematuro fossero controbilanciati dal poter utilizzare il prodotto per lo sviluppo delle applicazioni nelle quali il committente era coinvolto. Il risultato fu comunque che il compilatore fu consegnato in ritardo e con difetti. Di per sé la tempestività non è sempre una qualità utile, anche se talvolta arrivare in ritardo alla consegna di un prodotto può precludere interessanti opportunità di mercato. Non ha molto senso consegnare alla data prevista un prodotto privo di altre qualità, quali l'affidabilità e le prestazioni. Tuttavia alcuni sostengono che la consegna tempestiva di una versione preliminare di un prodotto, ancorché instabile, favorisca la successiva accettazione della versione finale. Internet ha facilitato questo approccio, in quanto i produttori di software possono esporre versioni iniziali di prodotto attraverso i propri siti, e rilasciarne l'utilizzo agli utenti interessati, i quali possono fornire utili suggerimenti e critiche. La tempestività richiede un'attenta pianificazione temporale del processo, un'accurata stima delle attività e una specifica chiara degli obiettivi intermedi (milestone). Tutte le discipline ingegneristiche usano tecniche di gestione del progetto che favoriscono la tempestività. Esistono addirittura molti strumenti di gestione dei progetti, supportati dal computer, che facilitano queste attività. Le tecniche standard di gestione dei progetti sono talvolta difficili da applicare nell'ambito dell'ingegneria del software, a causa delle difficoltà intrinseche nella definizione dei requisiti e a causa della natura astratta del bene da produrre. Queste difficoltà, a loro volta, danno luogo a problemi nel misurare la quantità di lavoro necessaria per produrre un componente software, a problemi nel quantificare la produttività dei progettisti — o addirittura a individuare metriche affidabili per definire la produttività — e a problemi nella definizione di milestone precise e verificabili. Un'altra causa di difficoltà nel raggiungimento della tempestività nel processo software è costituita dalla continua evoluzione dei requisiti dell'utente. La Figura 2.2 illustra graficamente l'andamento delle modifiche nei requisiti dell'utente rispetto a quanto effettivamen-

/ Capacità attuali del sistema

'o

h

h h

'4

Tempo

Figura 2.2

Mancanza di tempestività del software. (Da Davis et al. 11998] ©1998 IEEE, riprodotto con il permesso dell'IEEE.)

te offerto dal sistema e indica come mai gran parte dei progetti di sviluppo del software falliscono. Il tempo t0 indica il momento in cui ci si rende conto della necessità di sviluppare un sistema software. A questo istante lo sviluppo inizia con una conoscenza incompleta degli effettivi requisiti dell'utente e pertanto, il prodotto iniziale consegnato al tempo t\ non solo non soddisfa i requisiti iniziali al tempo t0, ma neppure quelli che si sono andati evolvendo dall'istante t0all'istante t{. Tra gli istanti £,e ^sul prodotto viene eseguita della manutenzione, al fine di approssimare meglio le richieste dell'utente. All'istante t2 possiamo immaginare che termini lo sviluppo di una nuova versione del sistema, che soddisfa i requisiti che l'utente aveva inizialmente, all'istante tv Per la ragione che abbiamo visto nel Paragrafo 2.2.5, al tempo t} il costo di manutenzione è diventato così alto che lo sviluppatore del software decide di effettuare una riprogettazione radicale del sistema. La nuova versione diventa pertanto disponibile al tempo t4, ma a questo punto la distanza del sistema rispetto alle necessità dell'utente è diventata ancora più grande di quanto lo fosse precedentemente. Al di fuori dell'ingegneria del software, un classico esempio delle difficoltà che si incontrano nella comprensione di requisiti complessi viene offerta dai sistemi avanzati di difesa. In parecchi casi descritti in letteratura, i sistemi diventano obsoleti prima ancora di essere consegnati, oppure non soddisfano i requisiti iniziali. Ovviamente, dopo dieci anni di sviluppo o più, è diffìcile decidere che cosa fare di un prodotto che non soddisfa i requisiti di dieci anni prima. Il problema chiave in questi casi è la formulazione precisa dei requisiti, in quanto questi dovrebbero riuscire a prefigurare il sistema più avanzato possibile, ovvero nel momento in cui sarà consegnato e non nel momento in cui i requisiti sono formulati. Una tecnica per ottenere la tempestività è attraverso la consegna incrementale del prodotto {incrementai delivery). Questa tecnica è illustrata attraverso questo ulteriore esempio di sviluppo di un compilatore Ada, eseguito da una società di software diversa da quella citata precedentemente. Questa società fu in grado di consegnare molto presto un compilatore che supportava un sottoinsieme estremamente ridotto del linguaggio Ada; sostanzial-

mente, si trattava di un sottoinsieme equivalente al Pascal, con in più il costrutto package. Il compilatore non supportava alcuna delle caratteristiche innovative del linguaggio, quali la programmazione concorrente e la gestione delle eccezioni. Il risultato fu però la consegna molto rapida di un prodotto affidabile. Gli utenti iniziarono a sperimentare con il nuovo linguaggio e la società che aveva il compito di sviluppare il compilatore riuscì gradualmente a comprendere e imparare a trattare le sottigliezze e le difficoltà delle nuove caratteristiche del linguaggio. Il compilatore per il linguaggio completo fu consegnato attraverso una serie di rilasci intermedi, che avvennero in un periodo di circa due anni. I rilasci incrementali consentono al prodotto di essere reso disponibile in tempi brevi e l'uso del prodotto contribuisce a raffinare i requisiti del prodotto stesso, in modo incrementale. Ovviamente, la possibilità di una consegna incrementale dipende dalla capacità di spezzare l'insieme delle funzionalità richieste in sottoinsiemi che possono essere sviluppati successivamente. Se non è possibile identificare questi sottoinsiemi, è impensabile che si possa adottare un processo che renda disponibile il prodotto in modo incrementale. Tuttavia, un processo non incrementale impedisce la produzione di sottoinsiemi, anche se questi sottoinsiemi sono identificabili. Pertanto, è la combinazione di un prodotto che può essere spezzato in sottoinsiemi e di un processo incrementale che ci consente uno sviluppo tempestivo. E ovvio che la consegna incrementale di sottoinsiemi di scarsa utilità, non ha alcun valore. La tempestività deve essere combinata con le altre qualità del software. Nel Capitolo 4 discuteremo altre tecniche che consentono di sviluppare sottoinsiemi di un prodotto e nel Capitolo 7 vedremo le tecniche per ottenere processi incrementali.

2.2.12

Visibilità

Un processo di sviluppo del software è visibile se tutti i passi successivi (step) e lo stato attuale sono documentati in modo chiaro. Un altro termine utilizzato per caratterizzare questa proprietà è trasparenza. L'idea è che i passi e lo stato del processo siano disponibili e facilmente accessibili per un esame esterno. In molti progetti software, gli ingegneri e i manager non hanno una completa consapevolezza di quale sia, in ogni istante, lo stato del progetto. Alcuni ingegneri potrebbero essere nella fase di progetto, altri nella fase di codifica e altri ancora nella fase di testing. Ciò di per sé non genera inconvenienti; tuttavia se un ingegnere inizia la riprogettazione di una parte del codice immediatamente prima che venga da altri iniziato il test di integrazione, i rischi che possano sorgere problemi seri e che ciò possa causare forti ritardi sono ovviamente molto alti. La visibilità consente agli ingegneri di soppesare l'impatto sul progetto complessivo delle proprie azioni e pertanto di guidarli nelle loro decisioni. Essa consente a tutti i membri di un gruppo di lavorare nella stessa direzione, piuttosto che, come spesso accade, in direzioni contrastanti. Un tipico esempio di questa situazione si ha nel caso in cui un gruppo, durante l'integrazione, ha testato la versione del software assumendo che la versione successiva ne eliminasse i difetti, mentre invece il gruppo di ingegnerizzazione decide di effettuare una riprogettazione radicale, al fine di aggiungere funzionalità. Questa tensione, tra un gruppo che cerca di stabilizzare il prodotto e un altro che lo destabilizza, è una situazione piuttosto comune. Il processo di sviluppo adottato deve invece incoraggiare il fatto che esista una visione coerente dello stato del sistema e che vi siano obiettivi comuni a tutti i partecipanti.

La visibilità non è solo una qualità interna, ma anche esterna. Nello sviluppo di un progetto di lunga durata sorgono spesso richieste relative allo stato corrente del progetto. Talvolta queste richieste esigono che venga stilata una presentazione formale di tale stato, in altri casi invece si tratta di richieste del tutto informali. Talvolta le richieste giungono dal management interno all'organizzazione, al fine di un'ulteriore pianificazione del progetto; altre volte invece provengono dall'esterno, ad esempio dal committente. Se il processo di sviluppo software ha una bassa visibilità, questi rapporti sullo stato di avanzamento non possono essere accurati, oppure richiedono un notevole sforzo per la loro preparazione. Una delle maggiori difficoltà che si incontrano nella gestione di grossi progetti deriva dal ricambio di personale (turnoverj. In molti progetti software alcune informazioni critiche, relative ai requisiti del software o alle attività di progettazione, sono tramandate oralmente e sono note soltanto alle persone che hanno lavorato nel progetto fin dall'inizio, o comunque per un tempo sufficientemente lungo. In queste situazioni, la perdita di un ingegnere che svolge una funzione chiave all'interno dell'organizzazione, o l'aggiunta di nuovi ingegneri al progetto, può generare grosse difficoltà. Infatti l'inserimento di nuove persone spesso riduce la produttività dell'intero progetto, dal momento che l'informazione tramandata oralmente (intrinsecamente poco strutturata) viene trasferita lentamente alle nuove persone inserite nel gruppo di lavoro. Quanto detto mostra come la visibilità del processo richieda non solo che i passi del processo stesso siano ben documentati, ma anche che lo stato corrente dei prodotti intermedi (e cioè il prodotto stesso) abbia un'elevata visibilità. Intuitivamente, un prodotto è visibile se è chiaramente strutturato come una collezione di componenti, con funzioni ben comprensibili e con un'accurata documentazione disponibile.

2.3

Requisiti di qualità in diverse aree applicative

Le qualità descritte nei paragrafi precedenti sono generiche, nel senso che si applicano a un qualunque sistema software. Il software viene però costruito al fine di automatizzare una specifica applicazione e quindi può essere caratterizzato sulla base dei requisiti dell'area applicativa. In questo paragrafo identificheremo quattro principali aree applicative ed esamineremo i requisiti specifici che queste impongono ai sistemi software. Mostreremo anche come queste aree sollecitino prioritariamente alcune delle qualità generali di cui abbiamo discusso in precedenza.

2.3.1

Sistemi informativi

I sistemi informativi costituiscono una delle aree applicative più importanti dell'informatica; sono così chiamati in quanto il loro scopo primario è quello di gestire informazioni. Esempi di sistemi informativi sono i sistemi bancari, quelli bibliotecari, quelli per la gestione del personale. Il cuore di tali sistemi è costituito da una base di dati, utilizzata da transazioni che creano, ricercano, modificano o cancellano dati. Molti di questi sistemi forniscono un'interfaccia web per operare sulle informazioni memorizzate. I sistemi informativi hanno acquisito una grande importanza, in quanto l'informazione è diventata una risorsa sempre più preziosa. I dati gestiti da questi sistemi costituiscono

le risorse più significative di un'organizzazione. I dati riguardano sia i processi e le risorse interne all'impresa (impianti, beni materiali, personale, etc.) sia informazioni sul mondo esterno (concorrenti, fornitori, clienti, etc.) I sistemi informativi sono applicazioni orientate alla gestione dei dati e possono essere caratterizzati in base al modo in cui i dati vengono elaborati. Alcune delle qualità che caratterizzano i sistemi informativi sono: spesso



Integrità dei dati. In quali circostanze i dati possono essere corrotti a causa di malfunzionamenti del sistema?



Sicurezza. Quale livello di protezione fornisce il sistema per negare accessi non autorizzati ai dati?



Disponibilità dei dati. In quali condizioni i dati possono essere non disponibili? E per quanto tempo?



Prestazioni delle transazioni. Qual è il numero di transazioni eseguibili per unità di tempo dal sistema?

Un'altra caratteristica importante dei sistemi informativi è la necessità di supportare l'interazione con gli utenti finali, che hanno spesso scarsa predisposizione all'utilizzo di strumenti tecnologici (ad esempio addetti alle vendite, personale amministrativo e manager). Pertanto, i requisiti di interazione uomo-macchina, quale l'amichevolezza delle interfacce, hanno un'importanza primaria. Queste interazioni dovrebbero essere mediate da interfacce grafiche basate su menu, che a loro volta dovrebbero essere progettati in maniera uniforme e con una facile navigabilità attraverso le diverse funzionalità. Gli utenti non dovrebbero avere mai la sensazione di sentirsi persi durante l'uso del sistema, e dovrebbero invece avere sempre il controllo dell'interazione con l'applicazione; anche l'applicazione dovrebbe vigilare affinché gli utenti non usino il sistema in maniera scorretta. I moderni sistemi informativi vanno in questa direzione: non solo supportano un facile accesso da parte degli utenti, ma addirittura ne incoraggiano il coinvolgimento nella creazione di alcune funzioni applicative. Oltre a fornire un insieme fisso di funzionalità, molti sistemi offrono semplici caratteristiche di personalizzazione. In questo modo gli utenti possono, ad esempio, definire nuovi tipi di rapporti che l'applicazione può generare, o nuovi formati per tali rapporti. Questa caratteristica viene spesso chiamata end-user computing.

2.3.2

Sistemi in tempo reale

I sistemi in tempo reale (real-time system) costituiscono un'altra importante categoria di sistemi software. La loro caratteristica principale è che debbono rispondere a determinati eventi entro un periodo di tempo predefinito e spesso molto limitato. Ad esempio, in un sistema di monitoraggio di un impianto, il software deve rispondere a cambiamenti improvvisi di temperatura, attivando certi dispositivi o inviando segnali di allarme. Analogamente, il software di controllo del volo di un aereo deve monitorare le condizioni ambientali e la posizione corrente dell'aereo, e controllare la traiettoria di volo in funzione di queste. I sistemi in tempo reale in genere si associano ai sistemi di automazione di fabbrica, ai sistemi di sorveglianza, etc. Tuttavia, requisiti di risposta in tempo reale possono essere tro-

vati anche in molte altre situazioni tradizionali. Un interessante esempio è dato dal software di gestione del mouse che si trova in ogni computer, il quale deve rispondere ai segnali di pressione del mouse (clic) entro un certo periodo di tempo. In molti sistemi un singolo clic è un comando per selezionare un oggetto (per esempio, un'icona che rappresenta un file), mentre un doppio clic — purché le due pressioni siano sufficientemente ravvicinate nel tempo - è un comando che implica l'apertura dell'oggetto (per esempio, il file rappresentato dall'icona). Questo tipo di interfaccia fa dunque nascere un requisito di tempo reale per il software, che deve elaborare il primo clic con una velocità tale da far sì che possa accettare il secondo clic, determinando se l'utente abbia effettivamente inteso inviare un "doppio clic", o invece due singoli clic, che hanno un significato diverso. Spesso, erroneamente, si dice che un sistema in tempo reale è un sistema che richiede tempi di risposta veloci. Ciò non è né vero né sufficientemente preciso. La velocità è infatti una proprietà qualitativa di un'applicazione; ciò che è necessario affinché un sistema sia definibile "in tempo reale" è che esista una nozione di tempo di risposta specificabile e quantificabile in maniera precisa. In alcuni sistemi in tempo reale, una risposta che viene fornita troppo presto può essere altrettanto scorretta quanto una risposta elaborata troppo tardi. Nel precedente esempio, se il primo clic è elaborato troppo velocemente, il "doppio clic" potrebbe non essere identificato correttamente. I sistemi in tempo reale sono stati studiati approfonditamente. Mentre possiamo definire "orientati ai dati" i sistemi informativi, i sistemi in tempo reale si possono definire "orientati al controllo". La parte fondamentale di un sistema in tempo reale è un pianificatore (scheduler) il quale ordina o "schedula" la azioni del sistema. Vi sono due tipi fondamentali di algoritmi di schedulazione: quelli basati su priorità e quelli basati su deadline. Nella schedulazione basata su priorità, a ogni azione è associata una priorità. In ogni istante viene eseguita l'azione che ha priorità più elevata. Invece, in uno schedulatore basato su deadline, ogni azione ha un tempo associato entro il quale essa deve essere iniziata o completata. È responsabilità dello schedulatore assicurare che le azioni siano ordinate in modo tale da rispettare i requisiti specificati. Un'altra classificazione dei sistemi in tempo reale è quella che distingue tra i sistemi basati su eventi e quelli basati sul tempo. Nei sistemi basati su eventi, gli eventi causano l'attivazione dei vari componenti, che vengono eseguiti in risposta a essi. In un sistema basato sul tempo, i componenti eseguono le loro azioni a determinati istanti di tempo predeterminati; la sincronizzazione temporale assicura che tutti i componenti osservino Io stesso tempo (siano sincronizzati). Oltre a soddisfare le generiche qualità del software, i sistemi in tempo reale devono soddisfare i requisiti in termini di tempi di risposta. Mentre in altri sistemi la risposta temporale è spesso una pura questione di efficienza del sistema, in questi sistemi il tempo di risposta diventa un criterio di correttezza. Inoltre i sistemi in tempo reale sono spesso usati per operazioni critiche (quali il monitoraggio di pazienti, i sistemi di difesa e i sistemi di controllo di processo) che hanno requisiti molto stringenti in termini di affidabilità5.

5

Spesso questi sistemi vengono caratterizzati mediante il termine inglese mission criticai, per indicare la criticità della missione loro affidata.

Nel caso di sistemi critici si utilizza spesso il termine safety (traducibile con "innocuità") per denotare l'assenza di comportamenti indesiderabili che possono causare situazioni di pericolo. La safety richiede che il sistema venga eseguito senza generare rischi inaccettabili. Diversamente dai requisiti funzionali, che descrivono il comportamento corretto di un sistema in termini delle relazioni ingresso-uscita, i requisiti di safety descrivono ciò che non dovrebbe mai capitare durante l'esecuzione del sistema. In un certo senso sono dei requisiti negativi: specificano lo stato in cui un sistema non dovrebbe mai entrare. Ad esempio, in un sistema di raggi X per applicazioni medicali, la proprietà di safety da rispettare impone che la radiazione applicata sia sempre al di sotto di un certo limite. Esistono infine altre qualità importanti nel caso dei sistemi in tempo reale. Come per i sistemi informativi, gli aspetti di relazione uomo macchina sono molto importanti. Ad esempio, l'interfaccia esterna di un sistema di controllo di un impianto industriale critico deve essere progettata in maniera tale che l'operatore comprenda perfettamente lo stato del sistema che sta controllando, per poter sempre operare sull'impianto senza generare inavvertitamente situazioni pericolose.

2.3.3

Sistemi distribuiti

I progressi nell'ambito dell'hardware e delle tecnologie di rete hanno reso possibile costruire sistemi distribuiti, che consistono di macchine indipendenti o semi-indipendenti collegate da una rete di telecomunicazioni. Le reti a banda larga rendono possibile lo sviluppo di applicazioni distribuite in cui i diversi componenti sono eseguiti da computer diversi. Oltre alle qualità generiche del software descritte in precedenza, il software distribuito ne possiede altre peculiari. Ad esempio, l'ambiente di sviluppo deve supportare lo sviluppo dell'applicazione su una molteplicità di computer, sui quali gli utenti devono essere in grado di compilare, collegare e testare il codice. La possibilità di caricare ed eseguire componenti su macchine diverse ha favorito lo sviluppo di nuovi linguaggi quali Java e C#. Per esempio, Java definisce un linguaggio intermedio (bytecode) che può essere interpretato in maniera efficiente su ogni computer del sistema distribuito. Ciò consente ai componenti di essere caricati dalla rete in maniera dinamica quando ciò risulta necessario. Tra le caratteristiche importanti di un sistema distribuito abbiamo: 1. il livello di distribuzione supportato (per esempio: sono distribuiti i dati, l'elaborazione o entrambi?), 2. la possibilità di tollerare il partizionamento (ad esempio, quando la mancanza di un collegamento di rete rende impossibile a due sottoinsiemi di computer di comunicare), 3. la possibilità di tollerare che uno o più computer non siano funzionanti. Uno degli aspetti interessanti dei sistemi distribuiti è che offrono nuove opportunità per il raggiungimento di alcune delle qualità analizzate in precedenza. Per esempio, è possibile aumentare l'affidabilità di un sistema mediante la replicazione di dati su più computer e migliorarne le prestazioni e l'affidabilità distribuendo i dati su più computer. Naturalmente, la replica o la distribuzione dei dati non è così semplice come si potrebbe pensare, e richiede una significativa attività di progettazione; esistono molte tecniche consolidate che permettono di affrontare questi problemi.

Un'altra possibilità interessante consiste nello sfruttamento della tecnologia di mobilità del codice, vale a dire la possibilità che il codice migri sulla rete durante l'esecuzione. Il codice mobile ha il vantaggio, rispetto ai tradizionali sistemi client-server, che le connessioni di rete non sono necessarie in modo permanente per supportare le interazioni tra il client e il server. Ci possono inoltre essere vantaggi dal punto di vista delle prestazioni, se si trasferisce il codice al nodo che memorizza i dati sui quali il codice deve operare. Le applet Java sono un semplice esempio di codice mobile.

2.3.4

Sistemi embedded

I sistemi embedded sono sistemi nei quali il software è solo uno dei componenti e spesso non ha interfacce rivolte all'utente finale, ma verso altri componenti del sistema che esso controlla. Il software embedded viene usato in aerei, robot, elettrodomestici, automobili, telefoni cellulari, etc. Ciò che distingue un sistema embedded rispetto agli altri tipi di software è proprio la mancanza di dispositivi di interfaccia rivolti a operatori umani, combinata con la presenza di interfacce verso altri tipi di dispositivi. Per esempio, invece di mostrare i dati mediante un grafico sullo schermo, il software invia dati di controllo ai motori di un robot. Questo porta a una sensibile riduzione dei requisiti di interfaccia e permette quindi di raggiungere vari compromessi nella decisione delle interfacce di cui dotare il sistema. Ad esempio, è spesso possibile modificare l'interfaccia software verso un dispositivo, complicando il codice pur di semplificare lo schema di un dispositivo hardware. Si consideri, ad esempio, una macchina a gettoni distributrice di bibite che accetta monete di dimensioni diverse. È possibile costruire un dispositivo hardware per determinare il valore di ciascuna moneta, ad esempio attraverso differenti fessure per inserire i vari tipi di monete, oppure fare in modo che l'hardware calcoli il peso e la dimensione di ciascuna moneta e fornisca questi valori al software. Nel secondo caso il software è responsabile della determinazione di ciascuna moneta e pertanto della decisione se siano state inserite un numero sufficiente di monete per poter effettuare l'acquisto. Il fatto di assegnare al software l'attività di decisione in merito alle monete, permette di sviluppare un sistema più flessibile riguardo, ad esempio, alla possibilità che la macchina venga adattata a nuovi tipi di monete, all'aumento dei prezzi degli articoli che contiene o all'uso della macchina stessa in un'altro Paese, senza che sia necessaria l'aggiunta di ulteriori componenti hardware. Infatti, con un'appropriata progettazione del software, questi cambiamenti richiedono soltanto la modifica di alcune tabelle o di alcuni dati interni. Anche se la nostra precedente trattazione ha assunto che le quattro aree applicative siano distinte, in pratica, molti sistemi hanno caratteristiche comuni a diverse aree. È facile, infatti, immaginare un sistema informativo che abbia requisiti tipici di un sistema in tempo reale. Tale sistema può anche essere distribuito o essere una parte embedded all'interno di un complesso sistema di monitoraggio. Come ulteriore esempio, segnaliamo che i sistemi embedded, in genere, sono sistemi in tempo reale. Un sistema di monitoraggio dei pazienti di un ospedale è un buon esempio di sistema che può avere diverse caratteristiche: deve mantenere una base di dati delle anamnesi dei pazienti; può essere distribuito al fine di supportare l'interazione con la stazioni di lavoro delle infermiere o diversi laboratori; può avere caratteristiche in tempo reale, per esempio il monitoraggio di dispositivi nelle sale di rianimazione; infine vi potrebbero essere requisiti di si-

stemi embedded, derivanti dall'interazione con i dispositivi di laboratorio per aggiornare i dati clinici dei pazienti in maniera automatica.

2.4

Misura della qualità

Una volta stabilite le qualità che si vogliono raggiungere come obiettivo dell'ingegneria del software, è necessario disporre di principi e di tecniche che consentano di raggiungerle. Occorre, inoltre, saper misurare con precisione le singole qualità. Nelle organizzazioni che sviluppano software, questa attività viene chiamata assicurazione della qualità (quality assurance). Se si ritiene che una qualità sia importante, è fondamentale poter misurare quanto prossimi si sia al suo raggiungimento. Ciò a sua volta richiede che ciascuna qualità sia definita in maniera sufficientemente precisa in modo da poter essere misurata. Senza strumenti di misurazione non abbiamo alcuna legittimazione a parlare di miglioramenti della qualità, e senza definire in maniera precisa una qualità non c'è speranza che questa possa essere quantitativamente misurata. Le discipline ingegneristiche consolidate dispongono di tecniche standard per la misura della qualità. Ad esempio, l'affidabilità di un amplificatore può essere misurata determinando l'intervallo di valori entro i quali opera correttamente. L'affidabilità di un ponte può essere misurata, tra le altre cose, in base al peso che può sostenere. Questi livelli di tolleranza vengono spesso rilasciati insieme al prodotto e fanno parte della sua specifica. Per quanto riguarda il software, alcune caratteristiche, quali le prestazioni, possono essere misurate in maniera precisa, mentre per gran parte delle altre qualità non esistono metriche universalmente accettate. Ad esempio, il fatto che un sistema possa evolvere più facilmente di un altro viene di solito valutato in termini soggettivi. Ciononostante, le metriche sono necessarie e in effetti, ancora oggi, molti gruppi di ricerca cercano di affrontare il tema della definizione di metriche oggettive per il software. Di questo parleremo nel Capitolo 6.

2.5

Osservazioni conclusive

L'ingegneria del software riguarda l'applicazione dei principi ingegneristici alla costruzione dei prodotti software. Questo testo si pone l'obiettivo di identificare un insieme di principi che si applicano nello sviluppo di un'ampia casistica di prodotti software. Per ottenere questo, è necessario identificare , come primo passo, l'insieme di qualità che caratterizzano i prodotti. Ciò è quanto abbiamo fatto in questo capitolo. Nel seguito vedremo come progettare software in modo da assicurare il raggiungimento dei livelli di qualità richiesti o desiderati. Ulteriori esercizi 2.8

In questo capitolo a b b i a m o trattato le qualità del software che consideriamo più importanti. Altre qualità sono la testabilità, l'integrità, la facilità d'uso, la facilità di operazione, l'apprendibilità. D e f i n i t e ciascuna di queste ed, eventualmente, altre qualità; fornite alcuni esempi e analizzate le relazioni tra queste qualità e quelle che sono state trattate in questo capitolo.

2.9

Classificate ciascuna delle qualità discusse nel capitolo come interne, esterne, di prodotto, o di processo f o r n e n d o n e esempi. Tali classi n o n sono m u t u a m e n t e esclusive.

2.10

Mostrate graficamente l'interdipendenza delle qualità discusse in questo capitolo. Disegnate un grafo in cui ciascun n o d o rappresenti una qualità software e u n collegamento orientato dal nodo A al n o d o B indichi che la qualità A contribuisce a raggiungere la qualità B. C h e cosa ci mostra questo grafo, relativamente all'importanza relativa delle qualità software? Vi sono cicli? C h e cosa implica u n ciclo?

2.11

Talvolta i manager riutilizzano molte delle tecniche già utilizzate in un precedente progetto. Usando questo come esempio, discutete il concetto di riusabilità applicato al processo software.

2.12

Se avete familiarità con i protocolli T C P / I P (per esempio ftp e telnet), analizzate il loro ruolo nell'interoperabilità.

2.13

Abbiamo discusso l'interoperabilità come una qualità del prodotto. Possiamo anche parlare di interoperabilità del processo. Ad esempio, il processo adottato da un'organizzazione per l'assicurazione della qualità, deve essere compatibile con quello adottato dall'organizzazione che effettua lo sviluppo all'interno della stessa compagnia. Un altro esempio viene offerto da una compagnia che stipula contratti con un'organizzazione indipendente per produrre la documentazione di un prodotto. Utilizzate questo e altri esempi per analizzare l'interoperabilità quando viene applicata a un processo.

Suggerimenti e tracce di soluzioni 2.1

In alcuni casi, le decisioni relative all'interfaccia utente possono influenzare l'affidabilità di un sistema. Per esempio, si dovrebbe assicurare che due pulsanti, che inviano comandi con effetti opposti, n o n siano adiacenti, al fine di evitare errori nella selezione.

2.2

Poiché i componenti sono sempre più riusabili, essi diventeranno sempre più affidabili, in quanto gli errori residui saranno progressivamente eliminati.

Note bibliografiche Per una discussione sul software, la sua natura e la sue caratteristiche, si faccia riferimento a Boehm [1976], Wegner [1984], Parnas [1985], Freeman [1987] e Brooks [1987]. Weinberg [1971] e W e i n b e r g e Schulman [1974] affrontano il tema degli aspetti u m a n i nel processo di sviluppo. Boehm [1981], Agresti [1986], Curtis et al. [1988], H u m p h r e y [1989] e Cugola e Ghezzi [1999] forniscono diverse viste del processo di sviluppo del software. Boehm [1976] presenta una classificazione delle qualità del software; Boehm et al. [1978] le discute in dettaglio. Per ciascuna qualità esiste a sua volta una bibliografia specializzata. La conferenza internazionale sul reliable software dell'ACM [1975] ha avuto u n ruolo fondamentale nello stimolare ricerca in quest'area. Musa et al. [1987] fornisce una vista approfondita dell'affidabilità del software, che deriva da un approccio statistico. La correttezza dei programmi viene studiata da M a n n a [1974] e Mandrioli e Ghezzi [1987]. Ritorneremo su questi temi nel Capitolo 6. Il concetto di safety è stato studiato da Leveson [1986], la security da McLean [1990] e i c o m p u t e r a elevata sicurezza {trusted computer?) da Arnes et al. [1983]. Alcuni testi generali sulle prestazioni sono Ferrari [1978] e Smith [1989]; un testo classico sul-

la complessità computazionale è A h o et al. [1974], Bentley [2000, 1988] illustra approcci per la scrittura di programmi efficienti. L'amichevolezza e il tema dell'interazione uomo-macchina sono trattati da Rubinstein e Hersch [1984], Schneiderman [1998] e N o r m a n e Draper [1986]. N o r m a n [1998] presenta un saggio divertente e facile da leggere su c o m e fare in m o d o che l'interfaccia di un c o m p u t e r sia così naturale da n o n essere notata. La manutenzione e l'evoluzione del software sono studiate intensivamente da Lientz e Swanson [1980], Belady e Lehman [1979] e Lehman e Belady [1985]. La distinzione tra manutenzione correttiva, adattativa e perfettiva è dovuta a Lientz e Swanson, i quali h a n n o fornito anche alcuni dati generali che abbiamo fornito in questo capitolo. C u s u m a n o e Yoffie [1999b] discutono l'approccio di Netscape per la progettazione "cross-piattaforme" e la portabilità e le prime delusioni della società produttrice e dei problemi con Java. Freeman [1987b] e Biggerstaff e Perlis [1989] discutono il tema della riusabilità. Kernighan e Pike [1984] illustra q u a n t o l'ambiente U N I X influenzi l'interoperabilità. La produttività viene discussa a p p r o f o n d i t a m e n t e da Boehm [1981] e Capers Jones [1986]. C u s u m a n o e Yoffie [1999a] discute l'impatto dei requisiti di veloce commercializzazione {internet timé) nello sviluppo del software. W i r t h [1977] e Stankovic [1988] h a n n o offerto una caratterizzazione dei sistemi in tempo reale. Kopetz [1997] affronta la progettazione dei sistemi in t e m p o reale. Leveson [1995] discute i rischi relativi alla safety nel software e alcuni modi per evitarli. Le prime rassegne sullo stato dell'arte riguardo alle metriche per le qualità del software sono dovute a Basili [1980] e C o n t e et al. [1986]. Alcuni avanzamenti sono illustrati nell'edizione speciale di IEEE Software [1990b]. Fenton e Pleeger [1998] offre u n o studio completo delle metriche di qualità del software.

C A P I T O L O

3

Principi dell'ingegneria del software

Questo capitolo illustra alcuni principi generali che sono fondamentali per lo sviluppo del software: principi che si riferiscono sia al processo che al prodotto finale. Un processo adeguato aiuta a produrre un prodotto adeguato e a sua volta il prodotto finale influenza la scelta del processo da adottare. Prodotto e processo sono due aspetti fondamentali dell'ingegneria del software, non ha senso enfatizzare uno a scapito dell'altro: entrambi sono importanti. I principi che svilupperemo in questo capitolo sono sufficientemente generali da essere applicabili lungo l'intero processo di costruzione e gestione del software. I principi, tuttavia, non sono sufficienti a guidare lo sviluppo del software. Questi, infatti, descrivono proprietà desiderabili del processo e dei prodotti in termini generali e astratti. Per poterli applicare, l'ingegnere del software deve disporre di metodi appropriati e di tecniche specifiche che lo aiutino a incorporare le proprietà desiderate nei processi e nei prodotti. Si noti che abbiamo distinto tra i termini "metodi" e "tecniche". I metodi sono delle linee guida generali che governano l'esecuzione di un'attività e definiscono un approccio rigoroso, sistematico e disciplinato. Le tecniche sono di carattere più meccanico rispetto ai metodi e spesso hanno un'applicabilità più limitata. Tuttavia, in generale, la differenza tra i due termini non è decisamente delineata, e pertanto spesso li useremo in modo interscambiabile. Talvolta, i metodi e le tecniche sono confezionati insieme a formare una metodologia. Scopo di una metodologia è promuovere un certo approccio alla soluzione di un problema attraverso l'individuazione dei metodi e delle tecniche che devono essere usati. Inoltre possono essere forniti strumenti per aiutare l'applicazione delle tecniche, dei metodi e delle metodologie. La Figura 3.1 mostra la relazione logica tra principi, metodi, tecniche, metodologie e strumenti. Ciascun livello della figura è basato sul livello o sui livelli sottostanti ed è maggiormente suscettibile di cambiamenti con il passare del tempo. La figura mostra chiaramente che i principi sono alla base di metodi, tecniche, metodologie e strumenti e potrebbe anche essere usata per sintetizzare la struttura di questo libro. In questo capitolo mostriamo i principi fondamentali dell'ingegneria del software. Nei Capitoli 4, 5 e 6 esamineremo i metodi e le tecniche basati sui principi esposti in questo capitolo. Il Capitolo 7 presenta alcune metodologie e il Capitolo 9 illustra gli strumenti e gli ambienti di supporto. Nella nostra trattazione dei principi, cercheremo di essere sufficientemente generali da coprire qualunque tipo di applicazione. Questo vale anche per i metodi e le tecniche che svilupperemo nei capitoli successivi. I principi, i metodi e le tecniche su cui concentreremo

Figura 3.1

Relazione tra principi, metodi e tecniche, metodologie e strumenti.

la nostra attenzione costituiscono invece una scelta ben precisa. Tra le qualità che abbiamo discusso nel capitolo precedente, porremo particolare enfasi sull'affidabilità del software e sulla sua capacità di evolvere. Questa scelta influenza, a sua volta, l'enfasi che porremo nella discussione dei principi, dei metodi e delle tecniche. Come affermato nel Capitolo 1, considereremo il caso in cui il software che deve essere sviluppato non è un'applicazione sperimentatale, ossia che verrà utilizzata poche volte dal suo sviluppatore, bensì un'applicazione i cui potenziali utenti potrebbero avere scarse competenze nell'udlizzo del computer e del software. Un altro caso che considereremo è quello del software di supporto ad applicazioni critiche, nelle quali gli effetti degli errori possono essere molto seri o addirittura disastrosi. Per queste e altre ragioni, l'applicazione deve essere estremamente affidabile. Considereremo anche il caso di applicazioni così complesse da richiedere di essere scomposte in parti per poterne gestire lo sviluppo. Ciò vale per progetti di gruppo, ma vale anche nel caso in cui lo sviluppo dell'applicazione sia in carico a un singolo progettista. In entrambi i casi è necessario un approccio allo sviluppo del software che consenta di dominarne la complessità. In tutti questi casi, che rappresentano situazioni tipiche dello sviluppo del software, l'affidabilità e la capacità di evolvere giocano un ruolo fondamentale. È evidente che, se il software non dovesse avere requisiti di affidabilità e capacità di evolvere, non sarebbe necessario adottare particolari principi e tecniche dell'ingegneria del software. In generale, possiamo infatti affermare che la scelta dei principi e delle tecniche deriva dagli obiettivi di qualità del software. In questo capitolo discuteremo sette fondamentali principi applicabili lungo l'intero processo di sviluppo del software: formalità e rigore, separazione degli interessi (separation ofconcerns), modularità, astrazione, anticipazione del cambiamento, generalità e incrementalità. La lista, per sua natura, non può essere esaustiva, ma copre tuttavia gli aspetti fondamentali dell'ingegneria del software. Questi principi sono tra di loro strettamente correlati, ma per chiarezza verranno in seguito descritti separatamente. Rivisiteremo questi principi alla fine del capitolo, attraverso due casi di studio riassuntivi. Questi verranno poi ripresi in maniera più concreta e dettagliata nei capitoli seguenti. In particolare, il principio di modularità verrà presentato nel Capitolo 4, come colonna portante della progettazione del software.

3.1

Rigore e formalità

Lo sviluppo del software è un'attività creativa. In tutte le attività creative esiste una naturale tendenza ad essere poco precisi e accurati e a seguire, piuttosto, l'ispirazione del momento in una maniera scarsamente strutturata. Il rigore, d'altra parte, è un necessario complemento alla creatività in ogni attività ingegneristica. Soltanto attraverso un approccio rigoroso possiamo infatti realizzare prodotti affidabili, controllarne il costo e aumentare la nostra fiducia nel loro corretto funzionamento. Il rigore non limita necessariamente la creatività, anzi può essere visto come uno strumento concettuale che la amplifica. L'ingegnere riesce ad avere maggiore fiducia nei propri processi creativi se è in grado di effettuare una rigorosa verifica dei risultati. Paradossalmente, il rigore è un concetto intuitivo che non può essere definito in maniera rigorosa. Inoltre, esistono diversi livelli di rigore. Il livello più alto può essere chiamato formalità. La formalità è un requisito più forte del rigore, in quanto richiede che il processo di sviluppo del software sia guidato e valutato mediante leggi matematiche. Ovviamente la formalità implica il rigore, ma non viceversa: si può infatti essere rigorosi e precisi anche in una situazione di informalità. In ogni campo dell'ingegneria, la progettazione procede come un sequenza di passi ben definiti e specificati in maniera precisa. A ogni passo l'ingegnere segue alcuni metodi e applica tecniche specifiche, che possono essere basate su una combinazione di aspetti teorici che derivano da qualche modello teorico della realtà, adattamenti empirici che tengono conto di fenomeni che non sono trattati dal modello e regole pratiche che incorporano i risultati di esperienze passate. Il "mix" di questi fattori dà luogo a un approccio rigoroso e sistematico, la metodologia, che può essere applicata in maniera continua e sistematica. Non è sempre necessario essere formali nell'attività di progettazione, ma l'ingegnere deve essere in grado di capire dove e quando è opportuno esserlo. Ad esempio, può limitarsi al ricorso all'esperienza passata e a regole empiriche nel caso del progetto di un ponte di piccole dimensioni, che deve essere usato per collegare temporaneamente due sponde di un fiume. Ma se il ponte dovesse essere di grandi dimensioni e di utilizzo permanente, l'ingegnere ricorrerebbe a modelli matematici che consentano di verificare la sicurezza del progetto. Qualora il ponte dovesse essere di lunghezza eccezionale o dovesse essere costruito in un'area soggetta a movimenti sismici, i modelli matematici utilizzati sarebbero ancora più sofisticati e terrebbero conto di fattori che potrebbero essere ignorati nel caso precedente. Anche nel caso della matematica, possiamo osservare il ruolo del rigore e della formalità. I libri di testo sull'analisi funzionale sono rigorosi ma raramente sono formali: le dimostrazioni dei teoremi sono sviluppate in modo molto accurato, attraverso sequenze di deduzioni intermedie, che conducono all'affermazione conclusiva. Ciascun passo deduttivo si basa su una giustificazione intuitiva, che ha l'obiettivo di convincere il lettore della sua validità. Raramente, invece, la derivazione viene espressa mediante regole di derivazione formale attraverso la logica matematica. Ciò significa che, normalmente, il matematico ritiene soddisfacente una descrizione rigorosa del processo di derivazione di una dimostrazione e non necessita di una sua completa formalizzazione. In alcuni casi critici, tuttavia, quando la convalida di alcune deduzioni intermedie non è del tutto ovvia, il matematico cerca di formalizzare il ragionamento informale, con l'obiettivo di convalidarne la sua validità.

Questi esempi mostrano che l'ingegnere (e il matematico) devono essere capaci di scegliere il livello di rigore e di formalità da raggiungere, in funzione della difficoltà concettuale e della criticità del compito che stanno affrontando. Questo livello può differire a seconda delle diverse parti di uno stesso sistema. Ad esempio, alcune parti critiche, come nel caso dello schedulatore di processi del kernel di un sistema operativo real-time o del componente che controlla la sicurezza in un sistema di commercio elettronico, possono richiedere una descrizione formale del funzionamento richiesto e una dimostrazione formale che questo funzionamento sia effettivamente ottenuto. Invece, parti più standard e meglio comprese dell'applicazione non necessitano di approcci così formali. Questa situazione si applica in tutte le aree dell'ingegneria del software. Nel Capitolo 5 affronteremo il tema in dettaglio nel contesto delle specifiche del software mostrando, per esempio, che la descrizione di ciò che un programma svolge può essere fornita in maniera rigorosa, usando il linguaggio naturale, ma può anche essere espressa in modo formale, attraverso un linguaggio basato sulla logica. Il vantaggio della formalità rispetto al solo rigore informale è che la formalità può essere alla base di processi automatizzati. Per esempio, è possibile che la descrizione formale di un programma possa essere utilizzata per generare automaticamente il programma stesso, se questo non esiste, o per dimostrare che il programma corrisponde alla sua descrizione formale, nel caso in cui il programma e la descrizione formale esistano. La fase in cui tradizionalmente si utilizza un approccio formale è la fase di programmazione, in quanto i programmi sono oggetti formali, scritti in un linguaggio la cui sintassi e semantica sono perfettamente definite. I programmi sono descrizioni formali che possono essere manipolate automaticamente dai compilatori: verificate nella loro correttezza formale, trasformate in una rappresentazione equivalente in un altro linguaggio (assembler o linguaggio macchina), stampate secondo regole di incolonnamento particolari per migliorarne l'aspetto, etc. Queste operazioni meccaniche, rese possibili dall'uso della formalità nella programmazione, aiutano a migliorare la verificabilità e l'affidabilità nei prodotti software. Rigore e formalità non sono però limitati alla programmazione, ma possono essere applicati lungo l'intero processo di produzione del software. In seguito mostreremo come possano essere applicati nel caso della progettazione (Capitolo 4), nel caso della specifica del software (Capitolo 5) e nel caso della sua verifica (Capitolo 6). La nostra discussione ha finora incentrato la sua attenzione sulla relazione tra rigore e formalità, da un lato, e affidabilità e verificabilità del software, dall'altro. Il rigore e la formalità hanno anche effetti benefìci sulla manutenibilità, la riusabilità, la portabilità, la comprensibilità e l'interoperabilità. Ad esempio, una rigorosa documentazione del software, anche se non formale, può avere effetti benefici su tutte queste qualità rispetto a una documentazione informale, sovente anche ambigua, inconsistente e incompleta. Il rigore e la formalità si applicano anche al processo di sviluppo del software. Una documentazione rigorosa del processo aiuta a riusare tale processo in altri progetti. Sulla base di tale documentazione i manager posso prevedere i passi attraverso i quali il nuovo progetto dovrà evolvere, assegnare risorse appropriate al processo, etc. Analogamente, una documentazione rigorosa aiuta nel processo di manutenzione di un prodotto esistente; se i vari passi, attraverso i quali il progetto è andato evolvendo, sono ben documentati, è possibile modificare un prodotto esistente partendo da un livello intermedio della sua derivazione, e non dal prodotto finale. Infine, se il processo software è specificato in modo rigoroso, i ma-

nager possono monitorarlo in maniera accurata, valutando il rispetto dei tempi previsti e migliorando la produttività.

3.2

Separazione degli interessi

Il principio di separazione degli interessi ci consente di affrontare differenti aspetti del problema, concentrando la nostra attenzione su ciascuno di essi in maniera separata. Questo principio è una pratica di uso comune nella vita quotidiana, che ci consente di superare molte delle difficoltà che incontriamo, e può anche essere applicato nello sviluppo del software per dominarne la complessità. Numerose sono le decisioni che occorre prendere durante lo sviluppo di un prodotto software. Alcune di queste riguardano le caratteristiche che il prodotto dovrà avere: le funzionalità che dovrà offrire, l'affidabilità richiesta, l'efficienza in termini di quantità di memoria occupata e tempo di esecuzione, le relazioni tra il prodotto e l'ambiente nel quale dovrà essere utilizzato (ad esempio, l'hardware o le risorse software richieste), le interfacce utente e così via. Altri aspetti riguardano invece il processo di sviluppo: quale debba essere l'ambiente di sviluppo, la struttura organizzativa del gruppo di lavoro, la tempistica di sviluppo, le procedure di controllo, le strategie di progettazione e i meccanismi di gestione di eventuali malfunzionamenti. Altre ancora riguardano aspetti di tipo economico e finanziario. Queste differenti decisioni possono essere scollegate le une dalle altre e in tal caso è ovvio che debbano essere trattate separatamente. Spesso tuttavia, le decisioni sono correlate tra di loro. Per esempio, decisioni di progetto, quale il trasferimento di dati dalla memoria centrale al disco, possono dipendere dalla dimensione della memoria del computer utilizzato (e pertanto dal costo della macchina) e, a loro volta, queste decisioni possono influenzare le scelte adottate per le politiche di gestione dei malfunzionamenti. Quando diverse decisioni sono strettamente correlate tra di loro, sarebbe utile che lo stesso gruppo di progettisti potesse, allo stesso tempo, analizzare tutti gli aspetti; cosa che, purtroppo, nella pratica non sempre può avvenire. Il solo modo per dominare la complessità di un progetto è quello di concentrarsi separatamente sui diversi aspetti. Innanzitutto, isolando quelli scarsamente correlati tra loro e, successivamente, considerando ciascun aspetto separatamente e approfondendo i dettagli importanti per il suo trattamento. Gli aspetti possono essere separati in differenti modi. Innanzitutto esiste una separazione in diversi periodi di tempo. Facendo riferimento a un esempio di vita quotidiana, un professore potrebbe applicare questo principio pianificando le attività didattiche, quali le lezioni, i seminari, le ore di ricevimento e gli incontri all'interno del dipartimento dalle 9 del mattino alle 14 di ciascun giorno della settimana, da lunedì a giovedì, riservando il venerdì alle attività di consulenza e dedicando alla ricerca il resto del proprio tempo. Questa separazione delle diverse attività in diversi periodi temporali consente una pianificazione precisa delle attività ed elimina gli inconvenienti che sorgono quando si deve passare continuamente da un'attività all'altra. Come abbiamo visto nel Capitolo 1, e come vedremo in maggiore dettaglio nel Capitolo 7, questa separazione degli interessi in termini del tempo è la motivazione principale che sta alla base dei modelli di ciclo di vita del software, i quali definiscono le sequenze di attività che devono essere seguite nel processo di produzione del software.

Un altro tipo di separazione degli interessi riguarda le qualità che devono essere considerate separatamente. Ad esempio, nel caso del software, potremmo voler trattare separatamente l'efficienza e la correttezza di un programma. Si potrebbe inizialmente decidere di progettare il software in modo che la sua correttezza sia assicurata, a priori, dall'adozione di un approccio molto strutturato alla progettazione e, successivamente, ristrutturare in maniera parziale il programma iniziale, al fine di migliorarne l'efficienza. Similmente, durante la fase di verifica si potrebbe, innanzitutto, verificare la correttezza funzionale del programma e, separatamente, la sua efficienza. Entrambe le attività possono essere fatte in maniera rigorosa, applicando procedure sistematiche o addirittura formali, ad esempio usando prove di correttezza formale e di analisi di complessità. Un alto importante tipo di separazione degli interessi riguarda l'analisi di viste diverse di un'applicazione software. Ad esempio, quando analizziamo i requisiti di un'applicazione, può essere utile concentrarsi separatamente sul flusso di dati da un'attività all'altra all'interno del sistema e sul flusso di controllo che governa il modo con il quale le diverse attività sono sincronizzate. Entrambe le viste aiutano a capire il sistema, anche se nessuna di esse ne fornisce una visione completa. Ancora un altro tipo di separazione di interessi riguarda la capacità di affrontare le diverse parti del sistema separatamente. In tal caso, la separazione delle parti riguarda la dimensione del sistema. Questo è un concetto fondamentale che dobbiamo imparare a governare per controllare la complessità di produzione del software ed è così importante che ne discuteremo specificamente nel paragrafo successivo, dedicato alla modularità. Esiste, ovviamente, un inconveniente intrinseco alla separazione degli interessi: separando due o più aspetti è possibile perdere la possibilità di effettuare alcune ottimizzazioni globali, che sarebbero possibili se gli aspetti venissero affrontati contemporaneamente. Anche se ciò è vero in linea di principio, la nostra capacità di prendere decisioni ottimizzanti nel caso di sistemi complessi è in pratica molto limitata. Quando si affrontano troppi aspetti contemporaneamente, è molto probabile che si finisca con l'essere sopraffatti dalla quantità di dettagli e dalla complessità che si deve affrontare. Una decisione importante, che i progettisti software devono essere in grado di prendere, riguarda proprio quali aspetti debbano essere trattati separatamente e quali debbano essere necessariamente trattati insieme. Nel caso in cui due aspetti di un problema siano difficilmente separabili, è sovente possibile prendere alcune decisioni globali di progetto in una prima fase e quindi separare tra di loro i diversi aspetti solo in una seconda fase. Ad esempio, si consideri un sistema in cui transazioni on-line debbano accedere concorrentemente a una base di dati. In una prima implementazione del sistema, si potrebbe introdurre un semplice schema di blocco (locking) in modo che ciascuna transazione blocchi l'accesso all'intera base di dati all'inizio della transazione e lo sblocchi alla fine. Si supponga ora che una preliminare analisi di prestazioni evidenzi come una transazione — ad esempio t i ; il cui scopo potrebbe essere quello di stampare un rapporto complesso estratto da molti dati della base di dati - impieghi un tempo talmente lungo da non consentire un accesso accettabile ad altre transazioni. In questo caso il problema è quello di rivedere l'implementazione, al fine di migliorare le prestazioni, mantenendo, tuttavia, la correttezza complessiva del sistema. E chiaro che i due aspetti - correttezza funzionale e prestazioni - sono strettamente interconnessi e pertanto la prima decisione deve riguardare entrambi: ti non viene più implementata come una transazione atomica, ma viene spezzata in diverse transazioni elementari tiU ti2, ..., t i n , ciascuna delle qua-

li è atomica. La nuova implementazione può influenzare la correttezza complessiva del sistema, in quanto l'esecuzione di due sequenze di transazioni elementari può alternarsi in maniera arbitraria. Si noti tuttavia che, avendo separato i due aspetti di verifica della correttezza funzionale del sistema e di analisi delle sue prestazioni, possiamo svolgere le due analisi indipendentemente e separatamente, eventualmente facendo anche ricorso a progettisti diversi con differenti livelli di competenza specifica. Una delle più importanti applicazioni del principio di separazione degli interessi riguarda la separazione tra aspetti relativi al dominio del problema da quelli relativi al dominio dell'implementazione. Le proprietà specifiche del dominio del problema valgono indipendentemente dagli aspetti implementativi. Ad esempio, nella progettazione di un sistema di gestione del personale dobbiamo trattare separatamente i problemi che riguardano il personale in generale da quelli che sono conseguenza delle nostre scelte implementative, relative alla struttura dati che viene adottata per rappresentare i dipendenti. Nel dominio del problema noi parliamo di relazioni tra dipendenti quali: "l'impiegato A dipende gerarchicamente dal dirigente B" mentre nel dominio dell'implementazione noi potremmo parlare di un oggetto che contiene un puntatore a un altro oggetto. Purtroppo, nella pratica si tende a mescolare tra di loro gli aspetti relativi all'implementazione con quelli relativi al problema. Da ultimo, si noti che il principio di separazione degli interessi può dar luogo a una distinzione di responsabilità nel trattare i diversi interessi. Pertanto questo principio diventa la base per suddividere il lavoro necessario per un problema complesso in assegnamenti specifici di lavoro a diverse persone, eventualmente con diverse capacità e responsabilità. Ad esempio, è possibile separare le responsabilità manageriali da quelle tecniche all'interno di un processo di sviluppo del software; oppure, avendo separato gli aspetti di analisi e specifica dei requisiti dalle altre attività all'interno di ciclo di vita del software, è possibile assumere analisti specializzati con competenze nel dominio dell'applicazione, invece di fare affidamento su risorse interne. Gli analisti a loro volta possono concentrarsi separatamente sui requisiti funzionali e non funzionali del sistema. Esercizi 3.1

Mostrate, in un semplice p r o g r a m m a (o in un f r a m m e n t o di programma) a vostra scelta, come è possibile concentrarsi separatamente sugli obiettivi di correttezza e di efficienza.

3.2

Leggete alcuni articoli relativi al tema dell'aspect-oriented p r o g r a m m i n g e valutateli in relazione al tema della separazione degli interessi. C o m e viene affrontata la separazione degli interessi dall'aspect-oriented programming?

3.3

Modularità

Un sistema complesso può essere suddiviso in parti più piccole chiamate moduli. Un sistema composto da moduli è detto modulare. Il vantaggio principiale della modularità è quello di consentire di applicare il principio di separazione degli interessi in due fasi. In una prima fase si possono trattare i dettagli di un singolo modulo separatamente, ignorando i dettagli degli altri e, in una seconda fase, si possono esaminare le caratteristiche complessive di

tutti i moduli e le loro relazioni, in modo da integrarli in un sistema coerente. Quando le due fasi vengono eseguite concentrandosi inizialmente sui moduli e quindi sulla loro composizione, diciamo che il sistema viene progettato bottom-up. Al contrario, quando scomponiamo innanzitutto un problema in moduli e ci concentriamo successivamente sulla progettazione di ciascuno di questi, il processo viene chiamato progettazione top-down. La modularità è una proprietà importante di quasi tutti i processi e prodotti ingegneristici. Ad esempio, nell'industria automobilistica, la costruzione delle auto procede assemblando blocchi costitutivi che sono progettati e costruiti separatamente. Inoltre, le parti sono spesso riusate da un modello all'altro, dopo aver apportato minimi cambiamenti. Anche i processi industriali sono modulari, costituiti da moduli di lavorazione che sono combinati in maniera semplice (in sequenza o in parallelo) al fine di raggiungere i risultati desiderati. Esercizio 3.3

Descrivete i moduli lavorativi necessari per la costruzione di una casa e indicate come questi possano essere organizzati in maniera sequenziale e parallela.

Il prossimo capitolo tratta la modularità nel contesto della progettazione del software. La modularità comunque non è solo un principio di progettazione, ma permea l'intero processo di sviluppo del software. In particolare, la modularità dà luogo a quattro fondamentali tipi di benefici: •

la capacità di scomporre un sistema complesso in parti più semplici



la capacità di comporre un sistema complesso a partire dai moduli esistenti



la capacità di capire un sistema in funzione delle sue parti



la capacità di modificare un sistema modificando soltanto un piccolo insieme delle sue parti.

La capacità di scomporre un problema in parti consente di suddividere il problema originario top-down in sottoproblemi e di applicare la scomposizione successivamente e ricorsivamente a ciascun sottoproblema. Questo modo di procedere riflette il motto latino divide et impera, il quale ben descrive la filosofia degli antichi romani nel dominare le altre nazioni: dividere e isolare innanzitutto, e quindi conquistare singolarmente ciascun paese. La capacità di comporre un sistema è basata invece su un procedimento bottom-up, a partire da componenti elementari combinati successivamente fino a raggiungere il sistema completo. Ad esempio, un sistema di automazione di ufficio può essere progettato aggregando componenti hardware esistenti, quali personal computer, una rete locale, periferiche e componenti software quali il sistema operativo, strumenti di produttività personale (software per la gestione di documenti, di basi di dati, di fogli elettronici). L'automobile è un altro ovvio esempio di sistema costruito a partire da componenti: la carrozzeria, il sistema di alimentazione elettrica, il sistema di trasmissione, il motore. Ciascuno di questi elementi, a sua volta, viene costruito a partire da componenti standard. Ad esempio: batteria, fusibili, cavi, etc., formano il sistema elettrico. Quando si genera un malfunzionamento, si sostituiscono i componenti difettosi con quelli nuovi.

Idealmente, nel processo di produzione del software vorremmo poter essere in grado di costruire nuove applicazioni attraverso l'interconnessione di componenti (moduli) prelevati da una libreria e combinati in maniera tale da fornire le funzionalità richieste. I moduli dovrebbero essere progettati con l'obiettivo di costruire componenti riutilizzabili. Attraverso l'uso di componenti riutilizzabili è possibile aumentare la velocità di costruzione del sistema e della sua messa a punto. Ad esempio, diventa possibile sostituire un componente con un altro che fornisce la stessa funzionalità, ma che differisce in termini di utilizzo di risorse computazionali. La capacità di comprendere la struttura interna di un sistema e quella di modificarlo sono strettamente connesse tra di loro, in quanto la comprensione di un sistema è spesso il primo passo nell'effettuare modifiche. Abbiamo enfatizzato la capacità di evolvere come obiettivo di qualità, in quanto gli ingegneri del software devono spesso ritornare su un'applicazione precedentemente sviluppata per modificarla. In un sistema che può essere compreso soltanto nella sua interezza, le modifiche sono diffìcili da effettuare e il risultato di una modifica produce probabilmente un software inaffidabile. Quando è necessario riparare un difetto o migliorare una funzionalità, la modularità aiuta a confinare la ricerca di un difetto o del punto in cui intervenire per il miglioramento, limitandola a un singolo componente. Pertanto la modularità diventa fondamentale ai fini della capacità del software di evolvere. Per ottenere la capacità di comporre, scomporre, comprendere e modificare il software in maniera modulare, l'ingegnere del software deve progettare i moduli in modo che abbiano alta coesione e basso accoppiamento. Un modulo ha un'alta coesione se tutti i suoi elementi sono strettamente connessi. Gli elementi di un modulo (vale a dire, le istruzioni, le procedure e le dichiarazioni) sono raggruppati per un motivo logico, non in maniera puramente casuale, e cooperano tra di loro in modo tale da raggiungere un obiettivo comune: la realizzazione della funzione richiesta per il modulo. Mentre la coesione è una proprietà interna al modulo, l'accoppiamento caratterizza la relazione del modulo con altri moduli. L'accoppiamento misura l'interdipendenza di due moduli; ad esempio, un modulo A chiama una funzione definita nel modulo B o accede a una variabile dichiarata dal modulo B. Due moduli hanno un elevato accoppiamento se dipendono strettamente l'uno dall'altro. È desiderabile avere moduli con un basso livello di accoppiamento, in quanto ciò rende possibile analizzare, capire, modificare, testare o riusare ciascun modulo separatamente. La Figura 3.2 alla pagina seguente fornisce una visione grafica della coesione e dell'accoppiamento. Un buon esempio di sistema con elevata coesione e basso accoppiamento è il sistema elettrico all'interno di una casa. Il sistema ha un basso livello di accoppiamento in quanto è costituito da un insieme di dispositivi e apparecchiature che forniscono funzioni ben identificabili e che sono connessi attraverso semplici cavi elettrici di collegamento. Il sistema ha un'elevata coesione in quanto ciascuna apparecchiatura e ciascun dispositivo è costituito internamente da componenti elementari che si trovano all'interno del dispositivo o del componente, proprio per assicurare la funzione che esso deve fornire. Strutture modulari con alta coesione e basso accoppiamento ci consentono di vedere i moduli come delle black box (scatole nere) quando si vuole descrivere la struttura complessiva del sistema, e vedere invece ciascuno di essi nei suoi dettagli quando è necessario de-

Figura 3.2

Visione grafica della coesione e dell'accoppiamento, (a) Una struttura fortemente accoppiata, (b) Una struttura con alta coesione e basso accoppiamento.

scrivere e analizzare la struttura interna del modulo. In altre parole, la modularità supporta l'applicazione del principio di separazione degli interessi. Esercizi . — :

;

— .

^

3.4

S u p p o n i a m o di voler modularizzare la descrizione di un'auto considerandola come composta da parti di 5 cm di lato. Discutete questa modularizzazione in termini di coesione e di accoppiamento. Si proponga u n eventuale sistema migliore di modularizzare la descrizione e si traggano alcune conclusioni generali circa il m o d o con il quale dovrebbe essere modularizzato un sistema complesso di questo tipo.

3.5

Fornite alcuni esempi di cause e di eventuali rimedi per la bassa coesione di un m o d u l o software.

3.6

Esponete alcune delle cause e dei possibili rimedi per lo stretto accoppiamento tra due moduli software.

3.4

Astrazione

L'astrazione è uno strumento fondamentale per capire e analizzare problemi complessi, poiché ci consente di identificare gli aspetti fondamentali di un fenomeno e di ignorare i suoi dettagli. Pertanto, l'astrazione è un caso particolare della separazione degli interessi che ci permette di separare gli aspetti importanti da quelli che contengono dettagli secondari. Ciò che consideriamo un dettaglio dal quale noi vogliamo astrarre, dipende dallo scopo dell'astrazione. Ad esempio, consideriamo un orologio digitale. Un'astrazione utile per il suo possessore è la descrizione degli effetti della pressione di vari pulsanti, che consentono all'orologio di passare attraverso diversi modi di funzionamento e di reagire in maniera dif-

ferenziata alle sequenze di comandi. Un'astrazione utile per chi deve riparare l'orologio, è quella di una scatola che può essere aperta per poter sostituire la batteria. Sono necessarie ancora diverse astrazioni del dispositivo al fine di poter intervenire sull'orologio per ripararlo, o addirittura per riprogettarlo in parte. Possono quindi esistere molteplici astrazioni della stessa realtà, ciascuna delle quali fornisce un differente punto di vista della realtà e serve a uno specifico scopo. Esercizio 3.7

Diverse persone che interagiscono con un'applicazione software possono richiedere differenti astrazioni. C o m m e n t a t e brevemente quali tipi di astrazioni siano utili per l'utente finale, il progettista e il m a n u t e n t o r e dell'applicazione.

L'astrazione è un tecnica di progettazione utilizzata dagli ingegneri in ogni settore per dominare la complessità. Ad esempio, la rappresentazione di un circuito elettrico in termini di resistenze, capacità, etc., ciascuna delle quali è caratterizzata da un insieme di equazioni, definisce un'astrazione ideale del dispositivo. Le equazioni sono modelli semplificati che approssimano il comportamento dei componenti fisici. Molte volte, le equazioni ignorano i dettagli, quali l'inesistenza di connessioni "pure" tra componenti, che richiederebbero di essere modellate in termini di resistenze, capacità, etc. Il progettista ignora questi fatti in quanto gli effetti che essi inducono sono trascurabili al fine di descrivere i risultati osservati. Questo esempio illustra un'idea generale molto importante: i modelli che costruiamo per i fenomeni, ad esempio le equazioni che descrivono i dispositivi, sono un'astrazione della realtà che ignora alcuni aspetti e si concentra su altri ritenuti più importanti. Lo stesso vale per i modelli costruiti e analizzati dagli ingegneri del software. Ad esempio, quando si analizzano e si specificano i requisiti di una nuova applicazione, gli ingegneri del software costruiscono un modello della potenziale applicazione. Come vedremo nel Capitolo 5, questo modello può essere espresso in vari modi, che dipendono dal livello desiderato di rigore e formalità. Qualunque sia il linguaggio usato per esprimere i requisiti, sia esso il linguaggio naturale o un linguaggio formale di formule matematiche, ciò che viene fornito è un modello che astrae da numerosi dettagli che i progettisti decidono di poter ignorare senza conseguenze. L'astrazione è un concetto chiave della programmazione. I linguaggi di programmazione che noi impieghiamo sono astrazioni costruite sull'hardware: essi ci forniscono utili ed efficaci costrutti che possiamo utilizzare per scrivere programmi, ignorando dettagli quali il numero di bit usati per rappresentare i numeri o i particolari meccanismi di indirizzamento adottati dal computer. Questo aiuta il progettista a concentrarsi sulla soluzione del problema che deve risolvere, piuttosto che sulla modalità di istruire la macchina sulla sua risoluzione. I programmi sono a loro volta astrazioni. Per esempio, una procedura di pagamento degli stipendi è un'astrazione della procedura manuale che essa sostituisce: realizza l'essenza del comportamento della procedura manuale, ma non ne riproduce esattamente i dettagli. L'astrazione è un principio importante che si applica sia ai prodotti software che ai processi. Ad esempio, i commenti che noi spesso usiamo nell'intestazione di una procedura so-

no un'astrazione che descrive l'effetto della procedura stessa. Quando si analizza la documentazione di un programma, questi commenti dovrebbero fornire tutte le informazioni necessarie a comprendere l'utilizzo della procedura da parte di altri componenti del programma. Come esempio di utilizzo dell'astrazione nei processi software, si consideri il caso della stima dei costi per una nuova applicazione. Un modo possibile per effettuare una stima consiste nell'identificare alcuni fattori chiave del nuovo sistema, ad esempio, il numero di progettisti che dovranno essere coinvolti nel processo e la dimensione attesa del prodotto finale, ed estrapolare il risultato dal profilo di costi di sistemi precedentemente realizzati che realizzano funzionalità analoghe. I fattori chiave usati per effettuare l'analisi sono astrazioni del sistema che vengono fatte allo scopo di consentire la stima dei costi. Esercizi 3.8

Le variabili fornite da un linguaggio di programmazione possono essere viste come astrazioni delle celle di memoria. D a quali dettagli astrae il concetto di variabile di un linguaggio di programmazione? Quali sono i vantaggi di usare questa astrazione? Quali gli svantaggi?

3.9

Le variabili in un programma sono usate c o m e astrazioni di concetti che appaiono nel dominio del problema. Spiegate in che senso una variabile chiamata "dipendente" sia un'astrazione del concetto di dipendente, che appare nel d o m i n i o del problema.

3.10

Un modello di ciclo di vita del software, quale il modello a cascata tratteggiato nel Capitolo 1, è un'astrazione di u n processo software. In che senso?

3.5 Anticipazione del cambiamento Il software viene modificato in continuazione. Come abbiamo visto nel Capitolo 2, i cambiamenti sono dovuti sia alla capacità di riparare il software, eliminando gli errori che non erano stati scoperti prima del rilascio dell'applicazione, sia alla necessità di supportare l'evoluzione dell'applicazione, a seguito dell'insorgere di nuovi requisiti o di cambiamenti in quelli vecchi. Per questo motivo abbiamo identificato la manutenibilità come caratteristica fondamentale di qualità del software. La capacità del software di evolvere non nasce dal nulla, ma deve essere pianificata e anticipata con estrema cura. I progettisti possono cercare di identificare quali siano i futuri cambiamenti e possono intervenire con particolare cura nel progetto al fine di rendere agevole la loro applicazione. Vedremo in dettaglio questo importante aspetto nel Capitolo 4, parlando di progettazione e mostrando come il software possa essere progettato in maniera tale da poter facilmente incorporare sia i probabili cambiamenti, anticipati durante la fase di analisi dei requisiti, sia i cambiamenti pianificati, come parte della strategia di progetto. In sostanza, i probabili cambiamenti dovrebbero essere attribuibili a specifiche porzioni del software e il loro effetto dovrebbe essere ristretto a tali porzioni. In altre parole, l'anticipazione del cambiamento dovrebbe essere alla base della strategia di modularizzazione. L'anticipazione del cambiamento è forse uno dei principi che maggiormente distingue il software dagli altri tipi di prodotti industriali. In molti casi, un'applicazione software viene sviluppata quando i suoi requisiti non sono completamente noti. Successivamente, una

volta che viene rilasciata, sulla base delle reazioni degli utenti, l'applicazione può evolvere in funzione dei nuovi requisiti che vengono scoperti o dei vecchi requisiti che vengono parzialmente modificati. Per di più, le applicazioni sono spesso immerse in un ambiente, quale ad esempio una struttura organizzativa. L'ambiente risulta influenzato dall'introduzione dell'applicazione, e ciò genera nuovi requisiti che non potevano essere evidenziati all'inizio. Pertanto l'anticipazione del cambiamento è un principio che può essere utilizzato per ottenere la capacità di evolvere dell'applicazione. La riusabilità è un'altra caratteristica che risulta fortemente influenzata dall'anticipazione del cambiamento. Come abbiamo visto, un componente è riusabile se può essere direttamente riusato per produrre un nuovo prodotto o, più realisticamente, se deve essere sottoposto solo a limitati cambiamenti per poter essere riusato. Pertanto la riusabilità può essere vista come l'evolvibilità a livello dei singoli componenti. Se potessimo anticipare i cambiamenti del contesto nel quale un componente può essere inserito, potremmo progettare il componente in modo che questi cambiamenti possano essere ottenuti facilmente. L'anticipazione dei cambiamenti richiede adeguati strumenti per la gestione delle diverse versioni e delle revisioni del software. Deve essere possibile immagazzinare e ritrovare la documentazione, i moduli sorgente, i moduli oggetto, etc., su una base di dati che funga da deposito centralizzato dei componenti riusabili. L'accesso alla base di dati deve essere controllato e deve sempre essere disponibile una visione consistente del sistema, anche a seguito dei cambiamenti apportati ai suoi componenti. Come abbiamo già citato in precedenza e vedremo in maggiore dettaglio nei Capitoli 7, 8 e 9, la disciplina che studia questa classe di problemi è chiamata gestione delle configurazioni (configuration management). Nella trattazione del principio di anticipazione del cambiamento abbiamo finora focalizzato l'attenzione sui prodotti software invece che sui processi. Il principio però si applica anche alla gestione dei processi software. Per esempio, i manager dovrebbero prevedere gli effetti del ricambio di personale; inoltre quando viene progettato il ciclo di vita di un'applicazione, è necessario prevedere un'articolazione della fase di manutenzione. I manager dovrebbero essere in grado di stimare i costi e progettare la struttura organizzativa che favorisce l'evoluzione del software, una volta che si è deciso quali cambiamenti anticipare. Infine, i manager dovrebbero decidere se e quando è importante investire tempo e sforzo nella produzione di componenti riusabili, sia come sottoprodotto di uno specifico progetto di sviluppo di software, sia come sforzo specifico investito per la produzione di componenti riusabili. Esercizio 3.11

Considerate un qualunque classico p r o g r a m m a di o r d i n a m e n t o e discutetelo dal p u n t o di vista della riusabilità. L'algoritmo scelto fa assunzioni sul tipo di elementi da ordinare? Sarebbe possibile riusare l'algoritmo per tipi di elementi diversi? C h e cosa succede se la sequenza di valori da ordinare è lunga e pertanto deve essere immagazzinata su memoria di massa? C o m e potrebbe essere modificato il p r o g r a m m a al fine di migliorarne la riusabilità in funzione di queste caratteristiche? Producete una lista generale di suggerimenti che favoriscano i cambiamenti in un programma, sulla base di questa esperienza.

3.6

Generalità

Il principio di generalità può essere così descritto. O g n i volta che si deve risolvere un problema, si cerca di scoprire qual è il problema più generale che si nasconde dietro lo specifico problema da risolvere. Talvolta, p u ò capitare che il problema generale sia più complesso del problema originario, ma sovente accade, invece, che sia più semplice. Inoltre, è probabile che la soluzione al problema generalizzato abbia un maggior potenziale di riusabilità. Può anche accadere che la soluzione sia già fornita da un c o m p o n e n te commerciale. Infine, può capitare che attraverso la generalizzazione del problema si giunga a progettare u n c o m p o n e n t e che p u ò essere utilizzato in diversi punti dell'applicazione, invece di dover utilizzare ogni volta una soluzione specifica.

Una soluzione generalizzata non ha solo vantaggi, ma può essere più costosa in termini di velocità d'esecuzione, occupazione di memoria o tempo di sviluppo, rispetto a una soluzione specializzata. In generale è pertanto necessario valutare i prò e i contro della generalità esaminandone i costi e l'efficienza al fine di decidere se sia utile risolvere il problema generalizzato anziché concentrarsi sul problema specifico al quale si è posti di fronte. Ad esempio, si supponga di dover effettuare la fusione di due file ordinati in un singolo file, anch'esso ordinato. Sulla base dei requisiti, sappiamo che i due file originali non contengono elementi duplicati, con lo stesso valore di chiave. Generalizzando la soluzione potremmo risolvere il problema per file che contengono elementi duplicati con lo stesso valore di chiave. In tal caso costruiremmo un programma che ha un grado di riusabilità più elevato. Potrebbe anche succedere che, fatta la generalizzazione, si scopra l'esistenza di un programma di libreria che risolve esattamente il problema generalizzato di fusione dei file. Come ulteriore esempio, si supponga di dover progettare un'applicazione che gestisce una piccola libreria di ricette di cucina, e si supponga anche che le ricette abbiano un'intestazione, che contiene informazioni circa il nome, la lista degli ingredienti e le informazioni sulla modalità di cottura, e una parte testuale che descrive come eseguire la ricetta. Oltre a immagazzinare le ricette nella libreria, deve anche essere possibile effettuare ricerche sofisticate in funzione degli ingredienti disponibili, della quantità massima di calorie, etc. Invece di progettare un nuovo insieme di programmi, queste ricerche potrebbero essere viste come un caso speciale di funzionalità di manipolazione di testi, quali le funzionalità fornite dal programma AWK disponibile nei sistemi UNIX, o il linguaggio PERL. Prima di iniziare la progettazione delle routine di ricerca specializzate, il progettista dovrebbe considerare se può essere utile l'adozione di uno strumento generalizzato di manipolazione di testi. Lo strumento generalizzato è sicuramente più affidabile del programma specializzato che verrebbe progettato e consentirebbe di incorporare più facilmente futuri cambiamenti nei requisiti o l'insorgere di nuovi requisiti. In termini negativi, invece, va considerato il costo dell'acquisizione e l'inefficienza indotta dall'uso dello strumento generalizzato. La generalità è un principio fondamentale che ci consente di sviluppare strumenti generali o componenti da rendere disponibili sul mercato. Il successo di strumenti quali i fogli elettronici, i sistemi di gestione di basi di dati, i word processor, deriva dal fatto che sono sufficientemente generali da coprire gran parte delle richieste di utenti che desiderano gestire le proprie applicazioni personali attraverso un computer. Invece di produrre soluzioni

ad hoc per ciascun uso personale, è sicuramente più economico utilizzare prodotti già disponibili sul mercato. L'esistenza di prodotti di uso generale (ojf-the-shelf) rappresenta una linea di tendenza fondamentale nel software. Sono infatti sempre più disponibili pacchetti generali e componenti che forniscono soluzioni standard a problemi di uso comune per gran parte delle aree applicative. Tutte le volte che affrontiamo un problema che può essere riformulato come un particolare caso di un problema risolto da un componente presente sul mercato, può risultare più conveniente adottare questo componente invece di una soluzione specializzata. Questa linea di tendenza è simile a quanto accade in altri settori dell'industria. Ad esempio, agli albori dell'industria automobilistica era possibile realizzare automobili personalizzate, che rispondessero ai requisiti specifici dell'acquirente. Con la maturazione e l'industrializzazione del settore, gli acquirenti possono solo scegliere i modelli da un catalogo fornito dal costruttore, in cui ogni modello corrisponde a una soluzione preconfezionata. Oggi è praticamente impossibile acquistare un'automobile il cui progetto sia personalizzato sulle esigenze dell'acquirente. Il passo successivo di questa linea di sviluppo dell'industria del software è costituito dai server applicativi {application server) che forniscono le funzionalità generalizzate su macchine {server) remote. In questo modo gli utenti non devono neppure installare l'applicazione sulle loro macchine, ma possono invece utilizzarne le funzionalità, che sono rese disponibili in modo remoto. Ad esempio, sono già fornite, mediante soluzioni di mercato, applicazioni che consentono di gestire la posta elettronica o le agende con gli impegni delle persone.

3.7

Incrementalità

Parliamo di incrementalità nel caso di un processo che procede attraverso passi, detti incrementi. In questa maniera cerchiamo di raggiungere l'obiettivo desiderato attraverso una serie di approssimazioni successive. Ogni approssimazione è un incremento rispetto alla precedente. L'incrementalità si applica a molte attività ingegneristiche. Nel caso del software, intendiamo che l'applicazione desiderata viene prodotta come il risultato di un processo evolutivo. Un modo di applicare un principio di incrementalità consiste nell'identifìcare da subito dei sottoinsiemi di un'applicazione che possano essere sviluppati e consegnati ai committenti, in modo da ottenere un immediato feedback. Ciò consente all'applicazione di evolvere in modo controllato nei casi in cui i requisiti iniziali non sono stabili o pienamente compresi. La motivazione che porta all'uso dell'incrementalità è che in gran parte dei casi non c'è modo di ottenere tutti i requisiti in maniera completa ed esatta prima che l'applicazione venga sviluppata. Invece, i requisiti emergono contemporaneamente alla disponibilità dell'applicazione o di sue parti. Di conseguenza, prima riusciamo a ricevere un feedback dai committenti riguardo l'utilizzo dell'applicazione, più facile sarà incorporare i cambiamenti desiderati all'interno del prodotto. Pertanto l'incrementalità è strettamente correlata con l'anticipazione dei cambiamenti ed è uno dei principi su cui si fonda la capacità di evolvere di un sistema. L'incrementalità si applica a gran parte delle qualità del software che abbiamo discusso nel Capitolo 2. E possibile aggiungere progressivamente funzioni all'applicazione sviluppata, partendo da un insieme minimale iniziale che è comunque in grado di rendere il si-

stema utilizzabile, anche se incompleto. Ad esempio, nei sistemi di automazione di attività commerciali, alcune funzioni potrebbero essere temporaneamente svolte manualmente, mentre altre verrebbero svolte in maniera automatica dall'applicazione parzialmente sviluppata. E possibile anche migliorare le prestazioni in maniera incrementale. Vale a dire: la versione iniziale dell'applicazione può porre maggior attenzione all'interfaccia utente e all'affidabilità, piuttosto che alle prestazioni, mentre i rilasci successivi potrebbero migliorarne l'efficienza in spazio e tempo. Tutte le volte che un'applicazione viene sviluppata in maniera incrementale, i passi intermedi costituiscono, in un certo senso, dei prototipi del prodotto finale; essi sono soltanto una sua approssimazione. L'idea della prototipazione rapida è spesso proposta come modo per sviluppare in maniera progressiva un'applicazione, man mano che i requisiti vengono meglio compresi. Un ciclo di vita basato sulla prototipazione è ovviamente piuttosto diverso dal modello a cascata che abbiamo descritto in precedenza, il quale prevede che venga svolta una completa e specifica analisi dei requisiti prima di intraprendere lo sviluppo dell'applicazione. Al contrario, la prototipazione è basata su un modello di sviluppo più flessibile e iterativo. Questa differenza modifica non solo gli aspetti tecnici di un processo, ma anche quelli organizzativi e gestionali. Come abbiamo già visto nel caso dell'anticipazione del cambiamento, un processo di tipo evolutivo richiede un'attenzione particolare nella gestione dei documenti, dei programmi, dei dati di test, etc., che vengono sviluppati per le varie versioni del software. Ogni passo incrementale deve essere accuratamente catalogato e documentato; la documentazione deve essere facilmente rintracciabile e i cambiamenti devono essere effettuati in modo controllato. Se non si opera con estrema cura, un processo evolutivo può rapidamente trasformarsi in uno sviluppo di software indisciplinato e si perderebbero tutti i potenziali vantaggi dell'evolvibilità. Esercizio 3.12

3.8

Discutete il concetto di p r o t o t i p o software qui illustrato, c o n f r o n t a n d o l o con il concetto di p r o t o t i p o usato in altri settori dell'ingegneria (ad esempio, il p r o t o t i p o di un p o n t e o di un'auto).

Illustrazione dei principi dell'ingegneria del software attraverso due casi di studio

In questo paragrafo verranno presentati due casi di studio che ci aiuteranno a capire meglio i principi che abbiamo illustrato nel capitolo. Il primo fa riferimento a un prodotto software abbastanza tipico: un compilatore; il secondo esamina un sistema non puramente software: un ascensore. Entrambi i casi mostrano come i principi illustrati nel capitolo siano principi ingegneristici generali. Entrambi servono a illustrare meglio gli aspetti comuni e le differenze tra l'ingegneria tradizionale e l'ingegneria del software. Il secondo caso di studio aiuta an-

che a capire che, in molte situazioni, il software è solo un componente di un sistema complesso che integra componenti di diverso tipo.

3.8.1

Caso di studio nella costruzione di un compilatore

Esaminiamo come i principi illustrati in questo capitolo possano essere applicati nello sviluppo di un compilatore. 3.8.1.1

Rigore e formalità

Esistono molti motivi per cui i progettisti dei compilatori dovrebbero essere rigorosi e, se possibile, formali. Innanzitutto, un compilatore è un prodotto critico, in quanto la generazione di codice erroneo porterebbe il computer a eseguire istruzioni scorrette. Un compilatore scorretto genererebbe dunque applicazioni scorrette, indipendentemente da altre eventuali qualità possedute. Inoltre, poiché un compilatore potrebbe venire usato per generare codice per software del quale viene fatto un uso di massa, l'effetto di un errore nel compilatore si moltiplicherebbe su larga scala. Pertanto, in generale, è importante che lo sviluppo di un compilatore venga affrontato in modo rigoroso, per produrre uno strumento di alta qualità. La costruzione di compilatori è uno dei campi dell'informatica nei quali la formalità è stata ben sfruttata da lungo tempo. Infatti, la teoria degli automi e dei linguaggi formali è stata motivata in larga misura dalla necessità di rendere la costruzione di compilatori sistematica e affidabile. Al giorno d'oggi la sintassi dei linguaggi di programmazione viene definita in modo formale, attraverso formalismi quali la forma di Backus-Naur (BNF). Non a caso, molte volte i problemi associati con la correttezza dei compilatori derivano dagli aspetti semantici del linguaggio, definiti sovente in maniera informale, e non da quelli sintattici, che sono ben definiti dalla BNF. La formalità che è possibile raggiungere attraverso la BNF e l'applicazione della teoria degli automi forniscono inoltre benefici in termini di generalità, come vedremo nel Paragrafo 3.8.1.6. 3.8.1.2

Separazione degli interessi

La costruzione di compilatori riguarda numerosi aspetti. La correttezza è l'obiettivo primario di chi sviluppa un compilatore: è infatti necessario produrre codice oggetto che sia consistente con il codice sorgente e produrre appropriati messaggi d'errore nel caso di programma sorgente scorretto. Altri aspetti importanti sono l'efficienza e l'amichevolezza dell'interfaccia. L'efficienza può riguardare sia gli aspetti relativi al tempo di compilazione, il che significa velocità nell'analisi del codice sorgente e della traduzione e utilizzo limitato della memoria, ma riguarda anche il tempo di esecuzione del programma, nel qual caso ciò significa produrre un codice oggetto che sia esso stesso efficiente. L'amichevolezza dell'interfaccia ha diversi aspetti, che vanno dalla precisione e completezza dei messaggi diagnostici, alla facilità di interazione attraverso un'interfaccia utente, per esempio un sistema di finestre ben progettato e altri supporti di tipo grafico. Questi aspetti del compilatore dovrebbero essere analizzati separatamente, per quanto possibile. Ad esempio, non c'è ragione di preoccuparsi per i messaggi diagnostici mentre si sta progettando un algoritmo sofisticato per l'ottimizzazione dell'allocazione dei registri. Ciò

non significa che i diversi aspetti non si influenzino l'un l'altro. Tipicamente, infatti, nel cercare di generare codice oggetto il più efficiente possibile, potremmo incorrere nel sovraccarico d'uso di alcuni registri. Nel tentativo di produrre una buona diagnostica durante l'esecuzione del programma, ad esempio verificando che gli indici di un array restino nel proprio campo di variabilità, si potrebbero produrre delle inefficienze a run-time. La diagnostica a run-time e l'efficienza sono tipici casi nei quali la separazione degli interessi può e deve essere applicata tenendo ben presente però che esistono mutue dipendenze tra i diversi aspetti. Nell'esempio specifico, i progettisti tengono di solito i due aspetti ben separati, tant'è che spesso è possibile che l'utente abiliti o disabiliti le verifiche a runtime. Durante le fasi di sviluppo e verifica di un programma, nelle quali la preoccupazione maggiore di un progettista software è quella di produrre programmi corretti, le verifiche a run-time dovrebbero essere abilitate in modo tale da rendere possibile la diagnostica del programma. Una volta che il programma è stato verificato a fondo, l'efficienza diventa invece la preoccupazione maggiore e pertanto i progettisti potrebbero disabilitare la generazione delle verifiche a run-time effettuate del compilatore. 3.8.1.3

Modularità

Un compilatore può essere modularizzato in vari modi. Qui di seguito proponiamo una modularizzazione piuttosto elementare e tradizionale, basata su diversi passi effettuati dal compilatore sul codice sorgente. Questa struttura modulare è accettabile come soluzione iniziale. Nel Capitolo 4 vedremo alcune critiche a questo schema e mostreremo soluzioni alternative, che possono produrre risultati migliori dal punto di vista degli altri principi, quali la generalità e la progettazione per il cambiamento. La letteratura esistente sulla costruzione dei compilatori suggerisce che la compilazione proceda attraverso una serie di fasi o passi, ciascuno dei quali effettua una traduzione parziale da una rappresentazione intermedia a un'altra, in modo tale che l'ultimo passo trasformi il suo input nel codice oggetto finale, pronto per essere eseguito.

Figura 3.3a

Struttura modulare di un compilatore. Le scatole rappresentano i moduli e le frecce gli input e gli output.

Le fasi principali di un compilatore tradizionale sono: •

Analisi lessicale, con la quale vengono analizzati gli identificatori che appaiono nel programma, sostituendoli con una rappresentazione interna e costruendo una tabella dei simboli, che contiene una loro descrizione. Questa fase produce anche una prima serie di messaggi diagnostici se il codice sorgente contiene errori lessicali, ad esempio, identificatori malformati.



Analisi sintattica o parsing, che prende il risultato prodotto dall'analisi lessicale e costruisce una struttura dati chiamata albero sintattico, la quale descrive la struttura sintattica del codice originario. Questa fase produce anche un secondo insieme di messaggi diagnostici riferiti alla struttura sintattica del programma, ad esempio la mancanza di parentesi.



Generazione di codice, che produce il codice oggetto. Quest'ultima fase è essa stessa svolta attraverso più passi. Ad esempio, molte volte viene prima prodotto un codice intermedio indipendente dalla macchina e successivamente questo viene tradotto in codice oggetto specifico per la macchina. Ognuna di queste traduzioni parziali può includere una fase di ottimizzazione che ristruttura il codice al fine di renderlo più efficiente.

La descrizione che abbiamo appena dato suggerisce una descrizione modulare della struttura del compilatore, descritta graficamente nella Figura 3.3a. Malgrado le semplificazioni presenti nella figura possiamo comunque derivare alcune caratteristiche distintive di un progetto modulare: •

I moduli di un sistema possono essere disegnati in maniera del tutto naturale come scatole di una qualche forma, nel nostro caso sono rettangolari.



Le interfacce dei moduli possono essere disegnate come delle linee orientate che collegano le scatole che rappresentano i moduli. Un'interfaccia è un'informazione che collega, in qualche modo, diversi moduli e rappresenta qualunque cosa sia condivisa da

Figura 3.3b

Ulteriore modularizzazione del modulo di generazione del codice.

essi. Si noti che la metafora grafica suggerisce che tutto ciò che si trova all'interno della scatola sia nascosto dall'esterno. Il resto del sistema può comunicare con un modulo esclusivamente attraverso la sua interfaccia. Nella figura è conveniente rappresentare le interfacce con delle frecce, per enfatizzare il fatto che l'informazione descritta è l'output di un modulo e l'input di un altro. Vedremo casi in cui la nozione di interfaccia può essere resa più simmetrica, ad esempio, una struttura dati condivisa. In questi casi è più conveniente rappresentare la relazione con una linea non orientata. Si noti anche che le linee che rappresentano il codice sorgente, i messaggi diagnostici e il codice oggetto sono input o output dell'intero "sistema"; pertanto sono disegnate senza un collegamento specifico a una sorgente e a una destinazione. •

La struttura modulare della Figura 3.3a favorisce essa stessa una naturale iterazione del processo di scomposizione. Ad esempio, in accordo con quanto detto nella descrizione della fase di generazione di codice, la scatola che rappresenta questo passo può essere raffinata come rappresentato nella Figura 3.3b.

I diagrammi che abbiamo qui utilizzato vengono chiamati informalmente "diagrammi a scatole e linee" (box-and-line diagram), e vengono usati comunemente per mostrare in modo informale la struttura complessiva — l'architettura - dei sistemi software. Esistono molte varianti dei diagrammi a scatole e linee che sono state proposte per rendere la notazione più formale. Vedremo esempi di tali notazioni nei Capitoli 4 e 5. 3.8.1.4

Astrazione

L'astrazione può essere applicata in diverse direzioni nell'ambito del progetto del compilatore. Dal punto di vista sintattico è tipico distinguere tra sintassi concreta e sintassi astratta. La sintassi astratta ha l'obiettivo di focalizzare l'attenzione sulle caratteristiche fondamentali dei costrutti del linguaggio, ignorando dettagli che non hanno effetto sulla struttura del programma. Ad esempio, un'istruzione condizionale consiste di una condizione insieme a un'istruzione, da eseguirsi nel caso in cui la condizione sia vera, ed eventualmente un'istruzione da eseguirsi nel caso la condizione risulti falsa. Questa descrizione è valida sia che si includa la parola chiave t h e n prima dell'istruzione da eseguire nel caso positivo, come nel caso del Pascal, sia nel caso in cui ciò non avvenga, come capita in C. Analogamente, in C si usano le coppie di parentesi graffe, mentre nei linguaggi Algol-like si usano le coppie di identificatori b e g i n - e n d . Nel caso di generazione del codice si applica un'altra tipica astrazione. Come abbiamo visto nella sezione precedente, la prima fase della generazione di codice produce un codice intermedio, che può essere visto come il codice di una macchina astratta, la seconda fase trasforma successivamente il codice di questa macchina astratta nel codice della macchina concreta finale. In questa maniera gran parte della costruzione del compilatore astrae dalle specificità del particolare processore che deve eseguire il codice oggetto. Il linguaggio Java definisce una macchina virtuale Java (JVM, Java Virtual Machine) il cui codice (Java bytecode) può essere eseguito interpretandolo su diverse macchine concrete. In entrambi gli esempi di astrazione che abbiamo fornito in questo paragrafo, l'astrazione si combina in modo naturale con il principio di generalità. Ad esempio, la produzione di codice intermedio per una macchina astratta, invece della produzione del codice oggetto finale per una macchina concreta permette di costruire un compilatore generale che

può essere adattato, con modifiche di minor complessità, alla produzione di codice per macchine diverse, migliorando anche la riusabilità. 3.8.1.5 Anticipazione del cambiamento Durante la vita di un compilatore possono intervenire diversi motivi di cambiamento. •

E possibile che avvengano nuovi rilasci del processore, con nuove e più potenti istruzioni.



E possibile che siano introdotti nuovi dispositivi di input-output (I/O) che richiedono nuovi tipi di istruzioni.



I comitati di standardizzazione potrebbero definire cambiamenti ed estensioni al linguaggio sorgente.

Il progetto di un compilatore dovrebbe anticipare questi cambiamenti. Ad esempio, il linguaggio Pascal tentò di "congelare" le istruzioni di I/O attraverso una definizione rigida. Questa decisione entrò in conflitto con dipendenze tipiche dalla macchina e il risultato fu la nascita di numerosi dialetti del linguaggio, i quali differivano essenzialmente per gli aspetti di I/O. Successivamente fu riconosciuto che il tentativo di "congelare" il linguaggio per le istruzioni di I/O era risultato inefficace. Pertanto, linguaggi come C e Ada incapsularono le istruzioni di I/O all'interno di librerie standard, riducendo così la quantità di lavoro necessario, in corrispondenza di cambiamenti nell'I/O. Per quanto riguarda, infine, la capacità del compilatore di adattarsi a diverse architetture hardware, si è dimostrata particolarmente efficace la separazione della fase di generazione di codice nelle due sottofasi discusse in precedenza. 3.8.1.6

Generalità

Come l'astrazione, la generalità può essere perseguita in vari modi durante la costruzione del compilatore, in funzione degli obiettivi complessivi del progetto. Ad esempio, potrebbe suggerire la realizzazione di un'ampia famiglia di prodotti rispetto alla produzione di un compilatore fortemente specializzato per una singola macchina. Abbiamo discusso in precedenza la necessità di essere parametrici rispetto all'architettura finale. Il caso del bytecode di Java è un esempio convincente dei benefici che si possono raggiungere con un progetto che ha come obiettivo la generalità. Infatti il bytecode è indipendente dal linguaggio sorgente, e pertanto può essere usato esso stesso come linguaggio oggetto per altri linguaggi, oltre che Java. Talvolta un compilatore può essere parametrico anche rispetto al linguaggio sorgente. Un esempio che radicalizza questo concetto di generalità viene fornito dai cosiddetti "compilatori di compilatori" (compiler compilerà). Si tratta di software che prende in ingresso la definizione del linguaggio sorgente ed eventualmente del linguaggio oggetto e automaticamente produce un compilatore che traduce il linguaggio sorgente nel linguaggio oggetto. L'esempio di compilatore di compilatori più noto e di maggior successo è quello illustrato dai programmi lex e yacc all'interno del sistema UNIX, che vengono utilizzati per ottenere in maniera automatica le componenti di analisi lessicale e sintattica di un compilatore. Questa generalità può essere ottenuta grazie alla formalizzazione della sintassi del linguaggio; pertanto il principio di generalità viene sfruttato congiuntamente con quello di for-

malità. Esiste un'altra relazione tra i principi di generalità e di anticipazione del cambiamento. L'essere parametrici, generali, rispetto alle caratteristiche che più probabilmente sono soggette a cambiamento favorisce la capacità del software di evolvere. 3.8.1.7

Incrementalità

Anche l'incrementalità può essere ottenuta in modi diversi. Ad esempio, possiamo dapprima consegnare una versione parziale del compilatore, la quale riconosce soltanto un sottoinsieme del linguaggio sorgente e quindi, attraverso consegne successive, fornire sottoinsiemi sempre più estesi del linguaggio. In alternativa, la versione iniziale rilasciata del sistema potrebbe offrire soltanto i componenti essenziali del compilatore, che consentono la traduzione nel linguaggio oggetto, con solo un minimo di capacità diagnostica. Successivamente si possono aggiungere maggiori caratteristiche diagnostiche e operazioni di ottimizzazione migliori. L'uso sistematico di librerie fornisce un altro modo naturale per sfruttare l'incrementalità. È abbastanza comune che la versione rilasciata inizialmente includa solo librerie di carattere minimale (ad esempio, librerie di I/O e per la gestione della memoria) e che vengano rilasciate solo in una fase successiva librerie più complete e ricche, per esempio librerie grafiche e matematiche.

3.8.2

Caso di studio nell'ingegneria dei sistemi

Si supponga di voler progettare un sistema di ascensori che debba essere incluso come parte di uno o più edifìci. Si noti che stiamo parlando di progetto e non di un singolo esemplare fisico di ascensore. La domanda preliminare che ci poniamo è la seguente: che cosa ha a che fare il progetto di un sistema di ascensori con l'ingegneria del software? Questo è un tipico esempio della forte relazione che l'ingegneria del software ha con l'ingegneria di sistemi. Come abbiamo detto in precedenza nel Paragrafo 1.6.2, i prodotti software sono spesso parte di un sistema più complesso, quale un impianto manifatturiero, un edificio o un'automobile. Pertanto, l'ingegnere del software in alcune situazioni deve operare come un ingegnere dei sistemi. Gran parte dell'analisi e della progettazione iniziale di un sistema deve essere fatta a livello del sistema e pertanto deve coinvolgere specialisti di diversi settori dell'ingegneria. Solo in fasi successive i progettisti possono concentrarsi esclusivamente sugli aspetti software, come la codifica dei programmi che controllano i dispositivi informatici, i quali a loro volta controllano l'intero sistema. Gran parte dei principi che abbiamo esaminato nei Capitoli 2 e 3 si applicano inoltre a qualunque attività ingegneristica e non solo alla costruzione di software. Verifichiamo, dunque, come i principi esaminati in questo capitolo si possano applicare alla progettazione di un sistema di ascensori. Useremo questo stesso esempio in altre parti del libro per illustrare altre tecniche. 3.8.2.1

Rigore e formalità

Esistono naturali motivazioni perché il progettista del nostro ipotetico sistema di ascensori debba operare in modo rigoroso, attraverso tutte le fasi del progetto. Innanzitutto, il sistema ha caratteristiche di criticità (safety criticai), in quanto eventuali malfunzionamenti possono provocare danni gravi e persino perdite di vite umane. Pertanto si deve innanzitutto definire in modo rigoroso quali siano i requisiti di safety per il nostro sistema. Per esempio:



un ascensore deve essere in grado di trasportare fino a 400 kg senza generare malfunzionamenti;



qualora i cavi di sostegno si stacchino, i freni di emergenza devono essere in grado di fermare l'ascensore entro un metro oppure entro due secondi dal distacco, in qualunque circostanza ciò avvenga;



deve essere generato un segnale sonoro nel caso in cui l'ascensore sia sovraccarico e, in tal caso, deve risultare impossibile l'utilizzo dell'ascensore.

Una volta progettato l'ascensore, occorrerà verificare che questi requisiti siano effettivamente rispettati dal progetto. Secondariamente, occorre essere rigorosi e precisi al fine di evitare dispute di natura contrattuale. Ad esempio, se la specifica iniziale che viene usata come base del contratto tra il fornitore del sistema e il committente non definisce vincoli sulla velocità dell'ascensore, com'è possibile poi lamentarsi per la lentezza dell'ascensore dopo la sua installazione? In terzo luogo, si supponga che durante il test di sistema si verifichino il corretto funzionamento e la validità delle prestazioni attraverso vari comandi che comportano la pressione dei pulsanti interni ed esterni. Ad esempio, si verifica che la pressione del pulsante interno con il numero 4 fa in modo che l'ascensore raggiunga il quarto piano entro il tempo limite specificato. Successivamente, durante l'effettivo impiego dell'ascensore, può capitare che una combinazione strana di pressione di pulsanti, esterni e interni, provochi un sovraccarico della memoria del microprocessore che controlla il sistema. A sua volta ciò può causare un comportamento indesiderato, quale il salto di un piano da parte dell'ascensore. Questo comportamento anomalo avrebbe dovuto essere escluso preventivamente attraverso un'analisi rigorosa di tutti gli eventi possibili che possono capitare durante il funzionamento del sistema. Infine si supponga che, sotto pressione dei committenti, venga firmato un contratto che contiene i seguenti requisiti. •

Date alcune condizioni probabilistiche circa la richieste degli utenti e la velocità dell'ascensore, il funzionamento deve minimizzare il tempo medio di attesa degli utenti.



Ogni richiesta deve essere, prima o poi, soddisfatta.

Può succedere che, adottando una politica che ottimizza le prestazioni da un punto di vista statistico, non si possa garantire l'obiettivo di fairness (imparzialità), cioè che ogni richiesta sia prima o poi soddisfatta. Pertanto, un'analisi rigorosa dei requisiti potrebbe scoprire questa proprietà ed evitare di fornire specifiche tra di loro conflittuali. Come abbiamo visto nel Paragrafo 3.1, l'applicazione di opportune tecniche formali può aiutare a essere rigorosi nella specifica e nella verifica di requisiti simili a quelli di cui abbiamo appena discusso. 3.8.2.2

Separazione degli interessi

Un sistema di ascensori pone il progettista di fronte a diversi aspetti che sono abbastanza tipici di molti progetti ingegneristici quali: •

sicurezza



prestazioni



usabilità (in termini di spazio, accessibilità, illuminazione dei pulsanti e così via)



costo

Naturalmente, gran parte di questi aspetti sono in larga misura intercorrelati e, pertanto, una decisione progettuale relativa a uno può influenzarne un altro. Ad esempio, se noi riduciamo i costi utilizzando materiale economico, potremmo mettere in pericolo la sicurezza. Ciononostante, la separazione degli interessi resta un principio di progetto importante. Ad esempio, possiamo effettuare l'analisi dei costi e l'analisi della sicurezza separatamente, tenendo comunque presente che entrambe la proprietà devono essere prima o poi verificate. Analogamente possiamo concentrare l'attenzione sull'usabilità in un momento successivo, verificando che le scelte adottate non portino a superare i limiti di costo accettabili. 3.8.2.3

Modularità

Una sintetica e grossolana rappresentazione del sistema di ascensori è mostrata nella Figura 3.4a. Anche qui possiamo commentare alcune caratteristiche distintive di un progetto modulare, a partire da questo diagramma. •

Come nel caso della struttura modulare della Figura 3.3a, usiamo scatole per denotare moduli e linee per denotare interfacce. In questo caso, tuttavia, usiamo linee non orientate, in quanto le informazioni di interfaccia, tipicamente segnali elettrici, fluiscono in entrambe le direzioni. A un livello di progetto successivo e più dettagliato potremmo poi utilizzare collegamenti direzionali. Ad esempio, potremmo rappresentare il comando fornito da un apparato di controllo al motore dell'ascensore mediante una freccia orientata. Analogamente potremmo rappresentare l'informazione relativa alla po-

Figura 3.4a

Descrizione modulare di un semplice sistema di ascensori.

sizione corrente dell'ascensore mediante una freccia orientata dalla scatola dell'ascensore alla scatola che rappresenta il modulo di controllo. •

L'esempio mostra anche che conviene modularizzare un sistema descrivendolo come una collezione di oggetti. Ciò accade spesso per molti sistemi, nei quali gli oggetti possono essere visti come le naturali unità di modularizzazione. La nozione di oggetto deve essere tuttavia vista in un'accezione più generale di quella di oggetto fisico. Tipicamente una porzione di software, quale una tabella di nomi o una coda di richieste da soddisfare, può essere vista come un oggetto. Si consideri la differenza tra l'ascensore, visto come insieme di oggetti che cooperano tra di loro, e il compilatore, visto come una collezione di moduli associati a diversi funzioni o passi di compilazione. Nel Capitolo 4 discuteremo i problemi della progettazione orientata agli oggetti rispetto a una progettazione orientata alle funzioni.



Anche in questo caso, la struttura modulare della Figura 3.4a può essere ulteriormente scomposta in modo del tutto naturale. Ad esempio, la scatola che rappresenta l'ascensore può essere raffinata come suggerito nella Figura 3.4b.

L'apparato di controllo può essere descritto come l'insieme di un microprocessore (la parte hardware) e del software che implementa le politiche di controllo, ad esempio gestendo la coda di richieste, inviando comandi al motore o ai freni e governando l'illuminazione dei pulsanti dell'ascensore. Anche in questo caso si vede chiaramente che la nozione di oggetto trascende chiaramente quella di oggetto fisico. I pulsanti, a loro volta, sia quelli ai piani che quelli all'interno dell'ascensore, possono essere definiti con maggior dettaglio, mostrando ciascun pulsante a ogni piano e ciascun pulsante all'interno dell'ascensore.

Freni

Figura 3.4b

Pulsanti interni

/

/

Ulteriore scomposizione del sistema di ascensori.

3.8.2.4

Astrazione

Il principio di astrazione può essere applicato al progetto del sistema di ascensori in molti modi. Innanzitutto notando che le parti a e b della Figura 3.4 sono a loro volta astrazioni dell'intero sistema, le quali si focalizzano sulla struttura modulare, trascurando altri aspetti quali il comportamento meccanico ed elettrico dell'ascensore e del suo motore. Una diversa vista astratta potrebbe invece concentrarsi su questi fattori, che sono trascurati nelle due figure, al fine di decidere quale sia la potenza di alimentazione per il motore e per i freni. Ancora un'altra astrazione potrebbe riguardare l'astrazione dei pulsanti, usando una variabile booleana per rappresentare il fatto che un pulsante sia illuminato e, pertanto, astraendo dalla potenza di alimentazione della lampadina che si trova nel pulsante fisico. Un'astrazione ancora diversa potrebbe descrivere la disposizione esterna dei pulsanti rispetto all'aspetto di usabilità; occorrerebbe decidere la loro dimensione, la potenza della loro illuminazione, l'altezza a cui si devono trovare rispetto al fondo dell'ascensore, etc. 3.8.2.5

Anticipazione del cambiamento, generalità e incrementalità

I principi di anticipazione del cambiamento, generalità e incrementalità mettono in luce le differenze fondamentali tra l'ingegneria del software e l'ingegneria dei sistemi più tradizionale, differenza dovuta alla malleabilità del software. Ad esempio, mentre è del tutto naturale costruire e consegnare un sottoinsieme del compilatore e successivamente aggiungere funzionalità attraverso nuove librerie, è del tutto improbabile che si possa consegnare un ascensore senza porte e consegnare le porte e altri accessori successivamente. Questi principi, tuttavia, hanno una loro rilevanza anche nell'ambito dell'ingegneria di sistemi, anche se la loro applicazione è prevalentemente ristretta alla fase di progettazione, la quale a sua volta è ancor più nettamente separata rispetto alle fasi di costruzione, installazione e manutenzione del prodotto. A titolo di esempio, si consideri il progetto di un sistema di ascensori che possa applicarsi a diversi edifici simili, ma non identici. In questo caso possiamo decidere di rendere il progetto parametrico rispetto ad alcune caratteristiche distintive, che cambiano da edificio a edificio, ma il cui campo di variabilità può essere formulato in maniera precisa a priori. E possibile progettare un sistema adatto a grattacieli con un numero di piani da 30 a 80, con un numero di ascensori da 4 a 10, con velocità e potenza variabili, etc. Successivamente, quando è necessario costruire un nuovo grattacielo, le cui caratteristiche rientrano all'interno di questi ambiti di variabilità, possiamo limitarci a specificare i parametri che abbiamo lasciato liberi e con ciò evitiamo di intraprendere il progetto da zero. La stessa notazione di progetto può essere adattata a enfatizzare questo tipo di parametricità del progetto. Ad esempio, la Figura 3.5 illustra il sistema della Figura 3.4a, ma modifica la scatola corrispondente all'ascensore e quella corrispondente ai pulsanti, in modo tale da denotare un numero parametrico di esemplari di oggetti dello stesso tipo. In questo caso, la generalità può andare oltre alla pura fase di progettazione. Infatti possiamo costruire diversi componenti, quali le cabine, i motori, gli apparati di controllo che sono utilizzabili in diversi edifìci della stessa categoria. Successivamente possiamo costruire l'ascensore che deve funzionare in un grattacielo specifico, semplicemente assemblando i componenti in accordo con il progetto, che a sua volta consiste nel definire alcuni parametri.

Apparalo di controllo

Ascensori

\ Pulsanti di piano

Figura 3.5

3.9

Struttura parametrica di un sistema di ascensori

Osservazioni conclusive

In questo capitolo abbiamo discusso sette importanti principi dell'ingegneria del software, che sono applicabili lungo tutto il processo di sviluppo e di evoluzione del software. Abbiamo sottolineato innanzitutto che questi sono principi ingegneristici e che il fatto di analizzare somiglianze e differenze nel modo in cui si applicano nei diversi campi dell'ingegneria può aiutare a comprenderli più a fondo. A causa della loro applicabilità generale, i principi sono stati presentati separatamente, come pietre miliari dell'ingegneria del software, piuttosto che nel contesto di fasi specifiche del ciclo di vita del software. Abbiamo anche deciso di presentare questi principi separatamente, al fine di offrire una terminologia uniforme, che verrà utilizzata nel resto del libro. I principi dell'ingegneria del software, così come sono stati qui descritti, potrebbero sembrare troppo astratti. Noi li renderemo ben più concreti con ulteriori dettagli nel resto del libro, nel contesto delle attività di progettazione, specifica, verifica e gestione del software. Faremo ciò sia in maniera esplicita, sottolineando dove i principi si applicano, sia in maniera implicita, lasciando al lettore lo stimolo di riconoscerli quando essi si manifestano. Abbiamo enfatizzato il ruolo dei principi prima di presentare metodi, tecniche e strumenti specifici. La motivazione di ciò è che l'ingegneria del software, come ogni altro ambito dell'ingegneria, deve basarsi su un insieme di solidi principi. I principi, a loro volta, sono la base dei metodi, delle tecniche e degli strumenti che vengono usati nella vita di ogni giorno. Con l'evoluzione della tecnologia, anche l'ingegneria del software evolve. Con l'aumento di conoscenze che si accumulano nell'ambito dell'ingegneria del software, anche i metodi e le tecniche evolvono, anche se meno rapidamente degli strumenti. I principi, invece, rimangono stabili; essi costituiscono i fondamenti sui quali tutto il resto può essere costruito, essi formano la base dei concetti che verranno discussi nel resto di questo libro.

Ulteriori esercizi 3.13

S u p p o n i a m o di voler scrivere u n p r o g r a m m a che manipola file, il quale offre (tra gli altri) comandi per ordinare i file in ordine ascendente e discendente. Alcuni dei file gestiti dal sistema sono memorizzati automaticamente in maniera ordinata, pertanto si potrebbe sfruttare il fatto che, se u n file è già ordinato, n o n occorre effettuare alcuna azione mentre, qualora il file sia ordinato in ordine opposto, si possa semplicemente realizzare l'ordinamento invertendo il contenuto dei file. Esponete prò e contro dell'impiego di queste soluzioni specializzate, invece di eseguire l'algoritmo di ordinamento in risposta al c o m a n d o di sort.

3.14

Indicate le relazioni tra principi di generalità e anticipazione del cambiamento.

3.15

Discutete brevemente le relazioni tra principi di generalità e astrazione.

3.16

Affrontate, in m o d o sintetico, le relazioni tra principi di incrementalità e tempestività.

3.17

Definite le relazioni tra principi di formalità e anticipazione del cambiamento.

3.18

Completate il caso di studio del Paragrafo 3.8 lungo le seguenti direzioni: si tenga conto di ulteriori requisiti; si vada più a f o n d o nell'identificazione della struttura modulare del sistema; si studino meglio le interfacce del sistema con l'ambiente; si studino altri casi in cui si possa applicare il principio di incrementalità.

3.19

Considerate un incrocio, in cui più binari intersecano una strada. L'incrocio è protetto da una sbarra che deve automaticamente impedire alle automobili di attraversare q u a n d o sta passando u n treno.

3.20

Descrivete la struttura del sistema di controllo dell'incrocio, m o s t r a n d o i componenti e le relative interfacce.

3.21

Definite chiaramente e in maniera precisa i requisiti che devono essere soddisfatti dal sistema al fine di operare in m o d o sicuro ma consentendo il passaggio dei veicoli. Ad esempio, la decisione di tenere sempre chiusa la sbarra avrebbe il vantaggio della sicurezza, ma sarebbe inutile, in q u a n t o nessuna macchina potrebbe mai attraversare l'incrocio.

3.22

Quali caratteristiche del sistema potrebbero cambiare in diversi contesti?

Suggerimenti e tracce di soluzione 3.5

La divisione di un p r o g r a m m a molto lungo in f r a m m e n t i contigui n o n dà luogo normalmente a una buona struttura con alta coesione. I f r a m m e n t i di istruzioni che realizzano le stesse f u n z i o n i da u n p u n t o di vista concettuale d a n n o invece luogo a una più alta coesione. Quest'approccio corrisponde alla tradizionale scomposizione di un programma in sottoprogrammi. E ancor meglio riuscire a raggruppare insieme i dati e i sottoprogrammi che acced o n o ai dati in quanto, così facendo, si migliora la leggibilità e la modificabilità del sistema.

3.6

I moduli che n o n interagiscono tra di loro h a n n o il m i n i m o livello di accoppiamento, ma ciò significa anche che questi moduli n o n cooperano tra di loro in alcun m o d o . Il fatto che un m o d u l o possa accedere alle variabili locali di un altro modulo, vale a dire che esso possa modificare lo stato dell'altro modulo, produce u n accoppiamento più alto di q u a n t o n o n accada nel caso di un m o d u l o che chiama un sottoprogramma definito in un altro.

3.7

L'utente finale è interessato soltanto alla descrizione astratta di come poter operare con l'applicazione; i dettagli che riguardano il m o d o in cui l'applicazione è stata implementata e progettata dovrebbero essere invece un dettaglio ignorato dall'astrazione. Il progettista dovrebbe

sapere quali sono i requisiti e, q u a n d o progetta una parte del sistema, dovrebbe poter disporre di una visione astratta del resto del sistema che gli consenta di avere un quadro chiaro di come la parte che sta realizzando interagisce con il resto del sistema, senza dover prendere in considerazione alcun dettaglio di esso. In fase di manutenzione di un sistema occorre poter riferirsi a una visione astratta del razionale del progetto e cioè delle motivazioni che h a n n o portato a certe decisioni e del motivo per il quale altre sono state scartate. In questo m o d o il sistema è più facilmente modificabile: le modifiche possono essere apportate senza danneggiare la struttura complessiva del sistema. 3.10

C o m e vedremo nel Capitolo 7, il ciclo di vita a cascata è una visione ideale del processo di sviluppo di software. Ad esempio, il modello della Figura 1.1 ignora il fatto che alcuni passi debbano essere ripetuti q u a n d o una fase rivela inconsistenze o errori nella fase precedente.

3.12

II prototipo software qui illustrato è un prototipo evolutivo, mentre in altri casi vediamo esempi di prototipi "usa e getta". Questi termini verranno discussi nel Capitolo 7.

3.16

Consegnando un'applicazione in m o d o incrementale, è possibile consegnare presto u n sottoinsieme utile dell'applicazione. C o n ciò è possibile presentare tempestivamente al mercato un prodotto anche se è incompleto.

Note bibliografiche Numerosi libri presentano approcci formali alla programmazione; tra questi citiamo Alagic e Arbib [1978], Gries [1981] e Mills et al. [1987a], Contributi fondamentali a questo settore sono forniti dai libri di W i r t h [1971], Dahl et al. [1972] e Dijkstra [1976], Liskov e G u t t a g [1986] fornisce fondamenti rigorosi alla programmazione di sistemi di grosse dimensioni; l'approccio che essi propongono è fondato sul concetto di astrazione dei dati. Liskov e G u t t a g [2001] applica questi principi a Java. I lavori di Parnas sui metodi di progettazione e di specifica stanno alla base dei concetti di separazione degli interessi, astrazione, modularità e anticipazione del cambiamento. Tutti i lavori di Parnas che vengono citati in bibliografìa illuminano aspetti fondamentali di queste problematiche. In particolare, Parnas [1978] illustra i più importanti principi dell'ingegneria del software. H o f f m a n e Weiss [2001] h a n n o raccolto trenta tra i più importanti articoli scritti da Parnas insieme ad alcuni aggiornamenti e a commenti critici. La programmazione orientata agli aspetti {aspect-orientedprogramming) è u n tipo di programmazione che si pone l'obiettivo di supportare il principio di separazione degli interessi nella progettazione del software. Questa tecnica aiuta il programmatore, n o n solo a concentrarsi su u n aspetto indipendentemente dall'altro, ma anche a esprimere aspetti che attraversano diversi insiemi di moduli. Ad esempio, il programmatore potrebbe desiderare di esprimere gli aspetti che riguardano la gestione della memoria, la sincronizzazione e la funzionalità di u n buffer di dimensioni limitate in diverse parti del p r o g r a m m a . Q u e s t a idea è stata proposta inizialmente da Kiczales et al. [1997]. Numerosi articoli h a n n o in seguito approfondito questa idea e diversi sistemi prototipali l'hanno implementata nel contesto di Java e di altri linguaggi. I concetti di coesione e accoppiamento sono trattati da Yourdon e Constantine [1979] e Myers [1978], i quali cercano di fornire misure obiettive per valutare la bontà di un progetto. I principi di anticipazione del cambiamento, generalità e incrementalità sono a m p i a m e n t e giustificaci dai lavori di Belady e Lehman [1979], Lehman e Belady [1985] e Lientz e Swanson [1980], i quali h a n n o approfondito il tema dell'evoluzione del software. Bennett e Rajlich [2000] afferma che considerare la manutenzione, che ha dimostrato assorbire un'enorme porzione dei costi del software,

semplicemente come l'ultima fase del processo di sviluppo è un errore. Gli autori propongono un ciclo di vita del software intrinsecamente basato sul concetto di evoluzione. La gestione delle configurazioni viene discussa da Babich [1986], Tichy [1989], Estublier [2000] e, successivamente in questo libro, nei Capitoli 7 e 9. Il linguaggio AWK è stato presentato da A h o et al. [1988], Boehm et al. [1984] illustra il principio di prototipazione rapida; il n u m e r o speciale della rivista IEEE Computer curato da Tanik e Yeh ( C o m p u t e r [1989]) contiene svariati articoli su questo argomento. Sia il sistema di ascensori descritto nel Paragrafo 3.8 che il sistema di controllo dell'incrocio, proposto nell'Esercizio 3.19, sono stati a m p i a m e n t e sviluppati in letteratura come casi di riferimento per dimostrare la validità di certe tecniche dell'ingegneria del software nella soluzione di problemi realistici. Il sistema di ascensori è stato inizialmente proposto in I W S S D [ 1 9 8 7 ] ; il sistema di controllo dell'incrocio viene presentato e studiato a f o n d o in Heitmeyer e Mandrioli [1996].

CAPITOLO

4

Progettazione e architetture software

La progettazione è un'attività umana fondamentale che, in senso generale, provvede a fornire una struttura a un qualsiasi artefatto complesso. Scompone un sistema in parti, assegna responsabilità a ciascuna e assicura che queste, nel loro insieme, raggiungano gli obiettivi globali. Ciò risulta vero in ogni campo, non solo in quello del software. Così, ad esempio, gli architetti possono progettare centri commerciali (struttura degli immobili, distribuzione degli spazi, parcheggi, impianti di condizionamento e di riscaldamento, rifornimento di elettricità, etc.) e gli autori concepire romanzi (personaggi, trama nel suo complesso e sua suddivisione in capitoli, etc.). Alcuni principi di progettazione — come scomporre un sistema in parti, individuare quali proprietà debbano avere, etc. - sono di natura generale; altri sono specifici del dominio applicativo. Nel caso del software, i concetti di progettazione sono applicabili in due contesti diversi ma strettamente correlati. Da una parte, la progettazione è l'attività che fa da ponte tra i requisiti e l'implementazione del software. Una volta determinata la necessità di avere un sistema software e decise le sue qualità desiderabili, inclusa l'interfaccia per l'interazione con il mondo esterno, si deve procedere alla sua progettazione. Il primo risultato dell'attività di progettazione sarà il progetto dell'architettura che mette in luce le parti principali del sistema e come queste si combinano e cooperano. L'architettura mostra a grandi linee la struttura del sistema. Dall'altra parte, ogni artefatto complesso va progettato. In questo contesto, la progettazione è l'attività che fornisce una struttura all'artefatto. Quindi, per esempio, il documento di specifica dei requisiti deve essere "progettato"; ovvero, deve possedere una struttura che lo renda facile da capire e da sviluppare ulteriormente. Questo capitolo affronterà il tema della progettazione sviluppando entrambe le accezioni, nei due contesti. Esiste una mutua dipendenza tra questo capitolo e quello successivo, che parlerà di specifica. Da una parte, secondo il primo contesto di progettazione cui abbiamo accennato, all'interno di un tipico ciclo di vita del software, la specifica dei requisiti avviene prima della progettazione dell'architettura. Ciò spingerebbe ad affrontare la specifica prima della progettazione. Dall'altra parte, in base al secondo contesto di progettazione, i principi di strutturazione di artefatti complessi si possono applicare ugualmente bene alla strutturazione della specifica dei requisiti. Inoltre, il risultato della progettazione dell'architettura deve a sua volta essere specificato. Per questo, abbiamo deciso di affrontare la progettazione prima della specifica. Questo capitolo inizia affrontando l'attività di progettazione e i suoi obiettivi fondamentali. Successivamente, mostrerà come possiamo ottenere le qualità illustrate nel Capito-

lo 2; in particolare, enfatizzerà la necessità di metodi di progettazione in grado di produrre sistemi affidabili e facilmente modificabili. Il principio di rigore e formalità ci porterà ad adottare notazioni appropriate per la descrizione dei progetti. Verranno applicati i principi di separazione degli interessi, modularità e astrazione, per semplificare l'attività di progettazione, produrre progetti caratterizzati da un alto grado di comprensibilità e accrescere la nostra fiducia nella correttezza delle nostre soluzioni. Infine, l'anticipazione del cambiamento e l'incrementalità ci permetteranno di progettare sistemi che siano in grado di evolvere facilmente in seguito al cambiamento dei requisiti, o che possono essere arricchiti progressivamente nelle loro funzioni, partendo da una versione iniziale, con funzionalità limitate. Progettare in vista del cambiamento è il motto che adotteremo da Parnas per sottolineare i principi di anticipazione del cambiamento e di incrementalità nel contesto della progettazione. Affronteremo anche il problema della progettazione di famiglie di applicazioni. Molte volte, le applicazioni non sono prodotti individuali, ma fanno parte di una famiglia di prodotti che possono differire tra loro per le funzionalità offerte, per le configurazioni hardware su cui vengano eseguite, per l'insieme dei servizi predisposti, etc. Nonostante le differenze, hanno però molti elementi in comune che possono essere analizzati e progettati in un'unica sede, per l'intera famiglia. I principi di generalità e anticipazione del cambiamento forniscono un supporto alla progettazione di famiglie di prodotti. Infatti, vari membri della famiglia possono essere progettati sulla base di una singola architettura. Un'architettura di famiglia attentamente progettata supporta lo sviluppo di progetti diversi per i singoli membri della famiglia. Per ottenere un alto livello qualitativo della progettazione, l'ingegnere informatico deve affrontare due questioni cruciali, strettamente correlate. Innanzitutto, deve fornire un'attenta definizione della struttura modulare del sistema, specificando i moduli e le relazioni che intercorrono tra loro. Dovrà, inoltre, scegliere un criterio appropriato per la scomposizione del sistema in moduli. Il criterio principale che introdurremo nel Paragrafo 4.2.2 e che approfondiremo nel seguito è quello dell'information hiding-. un modulo è caratterizzato dalle informazioni che nasconde agli altri moduli, detti suoi client (clienti). Le informazioni nascoste rimangono un segreto per i moduli client. Nel paragrafo successivo, affronteremo quindi una notazione di progettazione (TDN/GDN) che può essere utilizzata per documentare i risultati dell'attività di progettazione. Ne mostreremo una specializzazione, che si rende necessaria per poter trattare moduli che incapsulano dati, e che portano al fondamentale concetto di tipo di dato astratto. Introdurremo, infine, una tecnica chiamata stepwise refinement (progettazione per raffinamenti successivi). Questa tecnica produce progetti software con un approccio top-down mentre l'information hiding predilige principalmente un approccio di tipo bottom-up. Ciò ci condurrà a confrontare, da diversi punti di vista, queste due strategie di progettazione. Per realizzare l'obiettivo di affidabilità, approfondiremo il problema della progettazione di software che sappia reagire a malfunzionamenti o a situazioni anomale comportandosi in una maniera ritenuta accettabile. Un'attenta attività di progettazione deve occuparsi del requisito di robustezza, estremamente importante nelle applicazioni dove la sicurezza rappresenta un elemento critico. I principi di una buona progettazione non possono essere insegnati come un insieme prefissato di regole da applicare secondo una ricetta rigida. Se vengono formulati in termi-

ni astratti, non forniscono ai progettisti discernimenti profondi o suggerimenti convincenti. La loro efficacia viene meglio illustrata mediante esempi. Sfortunatamente, per ragioni di spazio, non è possibile illustrare la progettazione completa di applicazioni reali in un libro di testo. Illustreremo, dunque, i vari concetti di progettazione prevalentemente attraverso piccoli esempi. Nel Paragrafo 4.5 accenneremo a come gli aspetti di programmazione concorrente e distribuita e quelli di real-time possano influire sulla progettazione. Non approfondiremo l'argomento in questa fase, per diverse ragioni. Innanzitutto, si tratta di argomenti specializzati che meritano un trattamento separato: tradizionalmente vengono affrontati all'interno di corsi e testi riguardanti i sistemi operativi, i sistemi distribuiti, il software real-time, etc. In secondo luogo, le problematiche di progettazione dipendono fortemente dalle funzionalità offerte dal sistema operativo o dal linguaggio di programmazione utilizzati per l'implementazione. Di conseguenza, nella nostra discussione, ci atterremo a concetti generali e faremo riferimento a schemi selezionati di concorrenza senza puntare alla completezza. Nell'illustrare i principi di progettazione, faremo riferimento inizialmente ai sistemi tradizionali. Mostreremo poi come il concetto di information hiding e quello di tipi di dati astratti abbiano trovato un'applicazione coerente nella progettazione orientata agli oggetti. Discuteremo i concetti specifici introdotti da questa tecnica e mostreremo il modo in cui supporta l'evoluzione e il riuso di software. Inoltre, introdurremo la notazione di progettazione standard UML (UnifiedModeling Language). Nel caso di sviluppo di software, i progetti vengono prima o poi mappati sui programmi; ovvero, le strutture e i componenti definiti durante l'attività di progettazione sono rappresentati in termini di costrutti del linguaggio di programmazione che utilizziamo per l'implementazione. Questa mappatura dei progetti su programmi può essere realizzata più facilmente con alcuni linguaggi piuttosto che con altri; in particolare, esistono linguaggi per i quali le tecniche di progettazione che illustreremo possono portare ai programmi in maniera quasi diretta. Per esempio, l'information hiding e le strutture di progettazione illustrate in questo capitolo possono essere facilmente mappati sui linguaggi di programmazione modulari convenzionali come Ada. Linguaggi orientati agli oggetti, quali C++ o Java, sono i naturali candidati per l'implementazione di progetti orientati agli oggetti. Sempre di più, il software non viene implementato partendo "da zero", ma piuttosto integrando componenti che possono essere acquisiti sul mercato. L'obiettivo a lungo auspicato di riutilizzo attraverso la "componentizzazione", sta diventando realtà, sia perché i nuovi linguaggi permettono la progettazione di componenti riutilizzabili, sia perché sta diventando disponibile un supporto generalizzato per lo sviluppo di differenti componenti integrabili in un'architettura coerente. Ne sono esempi la libreria STL di C++, Java e JavaBeans, COM e CORBA. Inoltre, il problema della specifica di architetture software a un livello più alto rispetto a T D N / G D N o addirittura a UML sta assumendo sempre maggiore rilevanza, e rappresenta un argomento di ricerca attiva. Altri problemi centrali sono l'identificazione di pattern (modelli ricorrenti) di progettazione, che possono essere collezionati e classificati per un uso successivo, e la definizione di architetture adattabili, che possano definire intelaiature generalizzate per applicativi. Questi argomenti, riguardanti lo sviluppo basato su componenti, verranno affrontati nel Paragrafo 4.7. La progettazione è un'attività difficile e critica. E anche altamente creativa: in ogni nuovo progetto, l'ingegnere inventa qualcosa. Numerose sono le decisioni e i compromes-

si che vanno affrontati strada facendo. Questo capitolo cerca di identificare i metodi che possiamo utilizzare per superare queste difficoltà e per guidare e disciplinare il processo creativo. I sistemi possono risultare molto complessi, i requisiti possono trovarsi in contraddizione e i metodi generali da applicare lontani dall'essere norme precise. Sfortunatamente (o fortunatamente?) nella progettazione di software non esistono ricette generali, di facile utilizzo, che possono essere adottate una volta per sempre e seguite fedelmente in ogni circostanza. Norme specifiche sono applicabili solo all'interno di domini ristretti. Il progettista deve però disporre di principi e metodi generali, che potranno essere applicati in maniera pratica a seconda del caso specifico, e di requisiti quali la qualità desiderata del prodotto, la composizione del team di sviluppo e i vincoli temporali. È importante che il progettista applichi coerentemente i principi e i metodi che presenteremo, affinché diventino una prassi abituale. Per facilitare il loro impiego, a volte i metodi e i principi vengono "impacchetatti" in modo da formare metodologie standardizzate. Esiste una forte richiesta di queste metodologie da parte dell'industria, in quanto tendono a standardizzare lo sviluppo di software, così da uniformare l'applicazione dei metodi all'interno della stessa azienda. La standardizzazione, a sua volta, rende più facile affrontare questioni manageriali quali il turnover del personale all'interno di gruppi di sviluppo di software. Nella pratica, alcune di queste metodologie sono state adottate in maniera estesa, anche se spesso non sono giustificate da principi dimostrati, generali e rigorosi. Nel Capitolo 7, che si occuperà dell'organizzazione del ciclo di vita del software, forniremo un breve resoconto di alcune tra le più importanti fra queste metodologie. In questa parte, ci concentreremo invece sui principi generali di progettazione, indipendenti dall'applicazione che si vuole creare.

4.1

Attività di progettazione del software e suoi obiettivi

L'attività di progettazione rappresenta una fase fondamentale del processo di sviluppo del software, che trasforma progressivamente i requisiti di sistema, attraverso una serie di stadi intermedi, in un prodotto finito. Il risultato dell'attività di progettazione è un progetto software. Definiamo progetto software la scomposizione in moduli di un sistema e la descrizione di quali siano le funzioni di ciascun singolo modulo e delle relazioni che intercorrono tra loro. Spesso, prima del progetto software, viene prodotta un'architettura software che guida lo sviluppo del progetto. L'architettura mette in luce, a grandi linee, la struttura e l'organizzazione del sistema da definire. La descrizione di un'architettura software definisce i principali componenti di un sistema e come questi si relazionino gli uni con gli altri, le effettive ragioni per cui si procede alla suddivisione in componenti e i vincoli che devono essere rispettati nella loro progettazione. L'obiettivo dell'attività di progettazione architetturale è definire l'architettura del sistema; l'obiettivo dell'attività di progettazione del software è la definizione del progetto software in accordo con le linee guida stabilite nell'architettura del sistema. Dato che i principi utilizzati nello sviluppo dell'architettura e nello sviluppo del progetto sono simili, in questo capitolo faremo indistintamente riferimento al progetto e all'architettura.

Possiamo intendere la progettazione come un processo nel quale le diverse viste del sistema sono descritte attraverso successivi passaggi, definiti con sempre maggiore dettaglio. Partendo dai requisiti del sistema, viene sviluppata una prima architettura generale dell'applicazione, successivamente raffinata in un progetto ancora di alto livello, che viene quindi dettagliato in un progetto di livello più basso, e così via. Ogni nuovo passaggio realizza i requisiti specificati in quello precedente. L'ultimo passaggio rappresenta l'implementazione, che completa la trasformazione dell'architettura software in programmi. Il principio di modularità è di somma importanza nella progettazione del software, ed è il motivo per cui i componenti di un sistema, identificati durante l'attività di progettazione, vengono chiamati, semplicemente, moduli. In letteratura, comunque, il concetto di modulo è piuttosto ambiguo. A volte il termine viene utilizzato per indicare un elemento grafico, simile a una scatola, inteso a rappresentare un progetto. Altre volte, denota una parte di un programma chiaramente identificata, come una collezione di routine. In altri casi ancora, specifica gli incarichi di lavoro individuali, all'interno di un sistema complesso. Chiariremo la nostra idea di modulo più avanti; per ora, ci affideremo alla nozione intuitiva che racchiude tutte le possibilità sopra citate. La scomposizione di un sistema in moduli può essere realizzata in svariati modi e attraverso differenti passaggi. Per esempio, si potrebbe prima suddividere il sistema in componenti di alto livello, definendone le relazioni e specificandone i singoli comportamenti. Ciascun componente verrà quindi analizzato separatamente, iterando la procedura fino a raggiungere un livello di complessità sufficientemente ridotto da consentire a una sola persona di poter agevolmente implementare il singolo componente. Quando un modulo M viene scomposto in altri moduli, diremo che questi implementano M. In base a questo approccio, l'implementazione viene realizzata secondo la scomposizione ricorsiva dei moduli in (sotto)moduli, fino a quando non si raggiunge il punto in cui l'implementazione può essere realizzata in maniera semplice, in un linguaggio di programmazione. Oltre alla modularità, il lettore può qui riconoscere diversi principi e concetti presentati nel Capitolo 3. Rigore e formalità sono utili nella descrizione delle architetture software: più è precisa la descrizione, più risulta agevole suddividere lo sviluppo del software in compiti separati che possano essere svolti in parallelo, con un basso rischio di inconsistenze. Inoltre, la precisione semplificherebbe la comprensione del sistema, qualora dovesse sorgere la necessità di modificarlo. Infine, l'efficacia del processo di progettazione dipende da quanto le tecniche di modularizzazione selezionate ci aiutano ad affrontare ogni modulo separatamente, in accordo con il principio di "separazione degli interessi". Usando due concetti già introdotti nel Capitolo 3, possiamo affermare che i moduli dovrebbero avere un alto grado di coesione e un basso grado di accoppiamento. In accordo con la definizione fornita nel Capitolo 3, il processo di scomposizione modulare appena descritto può essere detto di tipo top-down. Ma è anche possibile procedere in una maniera bottom-up. Per esempio, un modulo può essere progettato per fornire un modo semplice e astratto per accedere a una periferica, nascondendo le primitive di basso livello fornite dal dispositivo. Il modulo agisce come uno strato che applica un'astrazione al dispositivo, facendolo apparire con un aspetto migliore e più semplice da trattare. In questo caso, il processo è intrinsecamente bottom-up: iniziamo da un oggetto esistente, intricato, e vi costruiamo attorno un'astrazione.

Secondo una strategia bottom-up, il processo di progettazione consiste nel definire moduli che possono essere combinati iterativamente fino a formare componenti di un livello più alto. E il tipico modo di procedere impiegato quando si riutilizzano i moduli presi da una libreria per costruire un nuovo sistema, invece di costruire il sistema da zero. L'intero sistema viene costruito assemblando in modo iterativo componenti di livello più basso. L'argomento del confronto tra la progettazione bottom-up e quella top-down verrà ripreso più tardi, mostrando come sia possibile, e spesso conveniente, combinare i due approcci, per diverse parti del sistema o in differenti momenti dell'attività di progettazione. Prima di discutere uno dei criteri che possono essere seguiti per la modularizzazione del sistema, esamineremo due importanti obiettivi che guidano la progettazione di un'architettura software: la progettazione in vista del cambiamento e le famiglie di prodotti. Il primo concetto definisce un modo per progettare software facilmente modificabile, qualora dovessero sorgere cambiamenti nei requisiti. Analogamente, il secondo concetto consente di interpretare una serie di prodotti come membri di una famiglia, che condividono, in diversi contesti, una singola architettura riutilizzata, specializzata e modificata per dare origine a differenti prodotti. Entrambi i concetti ricadono all'interno delle problematiche generali di riusabilità e supporto all'evoluzione del software.

4.1.1

Progettazione in vista del cambiamento

Nel Capitolo 3, abbiamo presentato l'anticipazione del cambiamento come un principio generale per supportare la natura evolutiva del software. Applicare questo principio nel contesto dell'attività di progettazione significa anticipare i cambiamenti cui potrà essere sottoposto durante il suo ciclo di vita e, di conseguenza, produrre un progetto software facilmente adattabile a quei cambiamenti. Seguendo Parnas, ci riferiremo alle tecniche utilizzate per raggiungere questo obiettivo come progettazione in vista del cambiamento (design for changè). La progettazione in vista del cambiamento promuove una progettazione sufficientemente flessibile da facilitare i cambiamenti. Tale obiettivo, però, non può essere raggiunto, in generale, per ogni tipo di cambiamento. È indispensabile una cura speciale nelle fasi iniziali, quando vengono forniti i requisiti software, per anticipare i probabili cambiamenti. In questa fase iniziale, non dovremmo concentrarci esclusivamente su cosa è necessario fornire, in quel dato momento, in termini di funzionalità o, più in generale, di qualità da raggiungere. Dovremmo anche considerare l'evoluzione prevista o possibile del sistema. Molte volte infatti, l'applicazione che stiamo progettando è il primo di una sequenza conosciuta e pianificata di passi che porteranno al prodotto finale. In questi casi, dobbiamo assicurarci che il progetto iniziale possa facilmente adattarsi all'anticipata evoluzione del prodotto. Più spesso, comunque, i cambiamenti richiesti non sono conosciuti a priori, ma appaiono, quasi inevitabilmente, a posteriori. In questi casi, le esperienze precedenti dell'ingegnere del software e la profonda conoscenza del dominio del problema da parte dell'utente finale possono giocare un ruolo fondamentale nell'identificare potenziali aree di cambiamento e la futura evoluzione del sistema. Dopo che sono stati individuati i requisiti per il cambiamento, bisognerebbe assicurarsi che il progetto possa, in futuro, facilitare quei cambiamenti. I progettisti del software devono capire l'importanza della progettazione in vista del cambiamento. Un errore comune consiste nel progettare i sistemi per soddisfare i requisiti di oggi, prestando poca (o nessuna) attenzione ai probabili cambiamenti. La conseguenza di un tale approccio è che anche un progetto meraviglioso può risultare estremamente diffìcile e

costoso da adattare, e dovrà essere quasi interamente rifatto in modo da incorporare cambiamenti anche apparentemente "minori". Un'altra conseguenza negativa è che il progettista, nel tentativo di facilitare cambiamenti, potrebbe dover compromettere progressivamente la struttura logica iniziale, rendendo l'applicazione sempre più difficile da mantenere e peggiorandone l'affidabilità. 4.1.1.1

Quali cambiamenti? La natura dell'evolvibilità

Quali tipologie di cambiamento deve tentare di anticipare un progetto? Per capire a fondo la questione dobbiamo riprendere i problemi discussi nel Capitolo 2 e ricordare che secondo i dati che appaiono in letteratura, il 60 per cento dei costi del software deve essere attribuito ai costi di manutenzione. Uno dei motivi per cui i costi sono così elevati è che gli ingegneri del software tendono a sorvolare sulla questione della manutenibilità, o addirittura a non considerarla, durante lo sviluppo del software. Si ricordi, dal Capitolo 2, che la manutenibilità può essere classificata in tre categorie: perfettiva, adattiva e correttiva. La manutenzione adattiva e perfettiva sono le reali cause di cambiamento nel software; hanno motivato l'introduzione dell'evolvibilità come una qualità fondamentale del software e l'anticipazione del cambiamento come un principio generale che dovrebbe sempre guidare l'ingegnere del software. In questo paragrafo, affronteremo i cambiamenti che potrebbero ricadere nelle categorie di manutenzione perfettiva o adattiva. Questi cambiamenti non esauriscono la casistica, ma sono tra i più comuni. Altri casi di cambiamenti importanti, che sono maggiormente sotto il controllo dell'ingegnere del software, capitano nel caso di una strategia di sviluppo basata sulla prototipazione iterativa. In una strategia di questo tipo, a un certo punto, alcune parti vengono progettate e implementate in una forma preliminare, per poi essere trasformate, a uno stadio successivo, in una versione definitiva. Cambiamenti riguardanti gli algoritmi. Questo tipo di cambiamento è, tra quelli che possiamo applicare al software, forse quello dalla motivazione più chiara: può consentire di migliorare l'efficienza di una sua parte, di poter affrontare casistiche più generali, etc. Consideriamo, ad esempio, gli algoritmi di ordinamento. Per scegliere tra i molteplici algoritmi esistenti, dovremmo conoscere la dimensione della lista da ordinare, la probabile distribuzione dei dati all'interno della lista, e così via. Di conseguenza, la scelta dell'algoritmo che meglio si adatta all'applicazione potrebbe dipendere dai dati sperimentali disponibili dopo che il sistema è diventato operativo. Potremmo cominciare con un algoritmo semplice come scelta iniziale e poi sostituirlo con una soluzione migliore, una volta disponibili più dati sperimentali. Se l'algoritmo è confinato in un modulo ben identificato (ad esempio, una routine del linguaggio di programmazione), la soluzione potrà essere agevolmente applicata in quanto la porzione di programma che richiede il cambiamento è facilmente identificabile, poiché delimitata dai suoi punti univoci di ingresso e di uscita. Esercizio 4.1

Fornite un esempio di due algoritmi di o r d i n a m e n t o i cui profili di esecuzione dipendano fortemente dalla distribuzione dei dati all'interno dell'array da ordinare. Esponete come la distribuzione dei dati condizioni i profili di esecuzione.

Cambiamenti riguardanti la rappresentazione dei dati. L'efficienza di un programma può cambiare fortemente se si cambia la struttura utilizzata per la rappresentazione dei suoi dati. Ad esempio, cambiare la struttura utilizzata per rappresentare una tabella da un array sequenziale a una lista concatenata o a una hash table può cambiare l'efficienza delle operazioni che accedono a quella tabella. Tipicamente, inserire un elemento in un array risulta costoso se gli elementi sono da tenere ordinati, ad esempio, per valori crescenti. Infatti, l'inserimento di un elemento nella posizione i deve essere preceduto da un'operazione che sposti tutti gli elementi nelle posizioni comprese tra la i-esima e la n-esima, essendo n il numero degli elementi in quel momento contenuti nell'array, in modo da creare spazio al nuovo elemento. L'operazione, detta di traslazione, il cui tempo medio di esecuzione risulta proporzionale a n, non sarebbe stata necessaria nel caso in cui avessimo scelto di utilizzare una lista concatenata come implementazione della tabella. Un altro esempio potrebbe essere quello di una struttura dati ad albero, implementata mediante puntatori, in cui ciascun nodo dell'albero possiede un puntatore al nodo fratello di destra e un puntatore al suo diretto discendente, qualora esistano, come illustrato nella Figura 4.1 (a). Si potrebbe successivamente pensare di aggiungere un ulteriore puntatore al fine di rendere più efficiente un'operazione che deve percorrere i nodi dell'albero, risalendo lungo la struttura dati da un nodo foglia verso la radice dell'albero. Il puntatore che si andrebbe ad aggiungere - vedi Figura 4.1 (b) - connetterebbe un nodo qualsiasi al suo nodo padre. Come ulteriore esempio di cambiamento nella rappresentazione dei dati, non dettato da motivazioni riguardanti l'efficienza, si potrebbe voler aggiungere (o togliere) campi da un record man mano che diventi necessario salvare più (o meno) informazioni all'interno di un file. È stato scoperto che i cambiamenti nelle strutture dati hanno una profonda influenza sui costi di manutenzione: ad essi è attribuibile circa il 17 per cento del totale dei costi di manutenzione 1 . Il cambiamento degli algoritmi e il cambiamento delle strutture dati, che abbiamo trattato separatamente, spesso sono strettamente correlati. Per esempio, potremmo applicare un cambiamento in una struttura dati in modo da fornire un algoritmo migliore o viceversa, un cambiamento in un algoritmo potrebbe richiedere un cambiamento nella struttura dati. Esercizio 4.2

Discutete alcune possibili motivazioni per il cambiamento della struttura dati ad albero presentata nella Figura 4.1. Verificate se, e perché, il c a m b i a m e n t o della struttura dati potrebbe richiedere un c a m b i a m e n t o di algoritmi.

Cambiamenti riguardanti la macchina astratta sottostante. I programmi che scriviamo vengono eseguiti da una qualche macchina astratta (o virtuale). La macchina coincide con l'hardware nel caso (fortunatamente improbabile) in cui non esista alcun linguaggio di più alto livello disponibile per la programmazione. Più frequentemente, la macchina astratta che utilizziamo corrisponde al linguaggio di alto livello in cui scriviamo i programmi, il sistema

1

Vedi Lientz e Swanson [1980].

(a)

(b)

Figura 4.1

Due esempi di strutture dati rappresentanti un albero.

operativo cui inoltriamo chiamate di sistema, il DBMS (database management system) che usiamo per immagazzinare e estrarre dati persistenti, etc. Una tale macchina astratta cela i dettagli della macchina fisica sottostante. Molte volte, però, dobbiamo modificare le applicazioni in modo da poterle eseguire sotto una nuova versione del sistema operativo, per sfruttare pienamente i nuovi servizi offerti. In maniera simile, potrebbe essere disponibile la nuova versione del compilatore che stiamo utilizzando, grazie alla quale potrebbero essere svolte ulteriori ottimizzazioni in modo da fornire codice oggetto più veloce e snello. O potrebbe essere disponibile la nuova versione del DBMS, che risparmia spazio su disco e offre funzioni migliorate in termini di protezione da accessi indesiderati e di recupero da guasti. O ancora, potrebbe diventare disponibile una versione più nuova, efficiente e affidabile di una determinata libreria usata dal-

l'applicazione. Ciò significa che la macchina astratta sottostante cambia, e i cambiamenti potrebbero avere delle conseguenze sul nostro applicativo. Purtroppo, i benefìci richiedono una contropartita. Per esempio, se il nuovo DBMS è in grado di immagazzinare i dati in metà dello spazio originale, dovremmo riformattare i nostri database. Potremmo dover cambiare anche i nostri programmi di accesso ai dati per sfruttare il risparmio di spazio su disco. Anche se le funzionalità offerte dal nostro software rimangono completamente inalterate, il cambiamento nella macchina astratta sottostante si riflette comunque nel software. Esercizio 4.3

Avete avuto personali esperienze riguardanti modifiche apportate al software in seguito a cambiamenti avvenuti nella macchina astratta sottostante? Presentate sinteticamente la vostra esperienza ed esponete le ragioni per cui avete incontrato difficoltà ad aggiornare il programma.

Cambiamenti dovuti a periferiche. Il cambiamento di una periferica ha strettamente a che fare con un cambiamento nella macchina astratta sottostante. Possiamo vederlo come una specializzazione di quel tipo di cambiamento nei casi di applicativi per sistemi embedded, sistemi avionici, sistemi di controllo del processo o in tutti i casi in cui un software di controllo deve interagire con molte periferiche diverse tra loro e specializzate. Tali dispositivi potrebbero essere soggetti a cambiamento; in particolare, stanno diventando sempre più "intelligenti", ovvero le funzioni per l'elaborazione dei dati vengono sempre più decentralizzate per essere eseguite localmente, senza disturbare l'applicazione principale in esecuzione sulla macchina principale. Idealmente, vorremmo poter affrontare tali cambiamenti senza cambiare o riprogettare l'intero programma. Cambiamenti dovuti all'ambiente sociale. Un cambiamento nell'ambiente sociale è simile ai due tipi di cambiamento precedenti. Non è motivato da un bisogno di modifica che nasce nel software stesso, ma piuttosto è l'ambiente in cui l'applicativo è inserito che richiede al software di cambiare. Per esempio, in un'applicazione per calcolare le tasse, possiamo ipotizzare che un cambio nella legislazione richieda una lieve modifica nelle regole di detrazione delle spese sostenute. Il concetto di un onere deducibile rimane, ma l'elenco dei possibili oggetti cambia. Il software deve cambiare di conseguenza, in modo da rendere l'applicazione valida anche per le nuove leggi riguardanti le tasse. Come ulteriore esempio, possiamo fare riferimento all'introduzione dell'euro. Questo cambiamento si è ripercosso sul software esistente. Pensiamo a una qualsiasi applicazione bancaria o a un qualsiasi sistema finanziario che debba gestire euro piuttosto che lire italiane o scellini austriaci. Tutto il software che si occupava di transazioni monetarie nelle vecchie unità di valuta ha dovuto essere modificato a causa di un cambiamento normativo. Esercizio 4.4

Fornite un esempio, per un'applicazione esistente o ipotetica, di un cambiamento software che potrebbe risultare necessario alla luce di u n m u t a m e n t o nell'ambiente sociale.

Cambiamenti dovuti al processo di sviluppo. Seguendo la motivazione discussa nel Capitolo 3, il software viene spesso sviluppato in modo incrementale. L'incrementalità è una sorgente di cambiamento che richiede particolare attenzione. Per esempio, si potrebbe cercare di isolare porzioni dell'applicazione, rilasciarle incrementalmente in modo che il cliente possa cominciare presto a utilizzare il sistema e fornire feed-back basato sulle sue esperienze. Più tardi, quando nuove parti vengono aggiunte al sistema, diventa importante concentrarsi sui nuovi sviluppi e lasciare inalterati i precedenti sottoinsiemi. Per rendere l'approccio fattibile, man mano che vengono rilasciate, le parti nuove devono potersi facilmente integrare con quelle vecchie e con il sistema funzionante ma ancora incompleto, senza richiedere complessi cambiamenti al software.

4.1.2

Famiglie di prodotti

In molte situazioni pratiche, i cambiamenti consistono nella costruzione di nuove versioni dello stesso software; ogni versione costituisce un prodotto individuale, ma l'insieme delle versioni costituisce una famiglia. In genere, si suppone che la nuova versione dovrebbe sostituire quella precedente, in quanto ne elimina gli errori e presenta caratteristiche migliorate. In altri casi, una nuova versione è semplicemente un nuovo prodotto che coesiste con quello precedente; potrebbe funzionare su un hardware differente, avere requisiti speciali di occupazione di memoria, o fornire funzionalità diverse per alcune parti del sistema. Il motivo per cui consideriamo le diverse versioni di un software una famiglia, piuttosto che un insieme di prodotti diversi, è che i membri di una famiglia hanno molto in comune e sono solo parzialmente diversi. Molte volte,condividono la stessa architettura. Progettando un'architettura comune per tutti i membri della famiglia, invece di sviluppare progetti separati per ciascun membro, risparmieremo sui costi che insorgerebbero nel progettare separatamente le parti comuni. Tipico esempio di una famiglia di prodotti è rappresentato dai telefoni cellulari le cui funzionalità base rimangono invariate indipendentemente dal Paese in cui sono commercializzati (effettuare e ricevere chiamate, mantenere una lista di numeri di telefono, etc.), mentre variano gli standard per la rete, le lingue per l'interazione con l'utente, i requisiti di sicurezza, e così via. Il software di base che controlla il telefono è sempre lo stesso, cambia l'interfaccia con l'ambiente in relazione alla località geografica. Un altro esempio potrebbe essere quello di un DBMS che debba operare su macchine differenti, possibilmente su diversi sistemi operativi e con varie configurazioni. In entrambi i casi, dovremmo innanzitutto identificare le parti comuni alle diverse versioni del software, e posticipare il più possibile il punto in cui incominciano a differenziarsi. Più poniamo l'accento sulle parti comuni, meno lavoro viene realizzato per ogni nuova versione. Ciò riduce le possibilità di inconsistenze e lo sforzo di manutenzione speso su tutti i prodotti. I primi approcci allo sviluppo del software non prestavano particolare attenzione alla progettazione di famiglie di prodotti, piuttosto procedevano da una versione all'altra in maniera sequenziale. Un errore comune è illustrato nei diagrammi informali, ma intuitivi, mostrati nella Figura 4.2. Iniziando dai requisiti, viene sviluppata la Versione 1 dell'applicazione - corrispondente al nodo 3 nella Figura 4.2(a) - attraverso una sequenza di passi di progettazione (rappresentati dalle frecce). I nodi rappresentati da cerchi rappresentano descrizioni intermedie di progetto; i nodi rappresentati da quadrati rappresentano una versione

completa ed eseguibile del software. La Figura 4.2(a) illustra il caso ipotetico in cui i requisiti vengono dapprima trasformati nello stadio intermedio di progettazione 1, poi nello stadio 2 e, infine, nella Versione 1 del prodotto. A questo punto, se nasce il bisogno di una seconda versione, si può cominciare a modificare la Versione 1. Inizialmente, l'applicazione verrebbe messa nello stadio intermedio di progettazione rappresentato dal nodo 4, Figura 4.2(b), cancellando alcune parti del codice della Versione 1 ; poi verrebbe trasformata in una versione completa e funzionante, rappresentata dal nodo 5, che a sua volta potrebbe diventare il punto di inizio per la derivazione di ulteriori versioni non illustrate nella figura. Un ramo rappresentante una versione diversa potrebbe anche iniziare dal nodo 3, come illustrato nella Figura 4.2(c). Questo tipo di approccio per la derivazione di membri di una famiglia di prodotti non è soddisfacente. Infatti, la famiglia illustrata nella Figura 4.2 risulta condizionata dalle scelte progettuali fatte durante lo sviluppo della Versione 1, visto che le versioni 2 e 3 sono modifiche della Versione 1. Non è stato fatto alcuno sforzo per isolare le parti comuni a tutte le versioni e, iterativamente, a sotto-insiemi sempre più piccoli della famiglia. Di conseguenza, la derivazione di un nuovo membro della famiglia diventa particolarmente difficile se il nuovo membro differisce in maniera sostanziale dai membri precedenti. Le nuove versioni del software vengono derivate modificando il codice delle versioni precedenti in quanto, spesso, i passi di progettazioni intermedie (rappresentati nella figura

Requisiti

Requisiti

(b) Figura 4.2

Progettazione sequenziale di una famiglia di prodotti.

Requisiti

(c)

con i cerchi) non sono documentati. I programmi stessi sono le uniche descrizioni disponibili, di cui ci si può fidare, che possono essere utilizzate come punti di inizio per le modifiche. I programmi (anche quelli ben scritti e ben documentati), però, possono risultare difficili da capire in termini sufficientemente precisi da consentire di effettuare modifiche che risultino affidabili. Non possiamo mai essere certi che una modifica a una parte del sistema non vada a interferire in maniera imprevista con altre parti. Inoltre, potremmo inavvertitamente ripetere scelte progettuali già scartate in precedenza ma mai documentate. Un approccio sistematico alla progettazione di famiglie di prodotti che risolve questi problemi verrà presentato in seguito. Questo approccio è basato sul principio generale di progettazione in vista del cambiamento, dove i cambiamenti sono quelli che caratterizzano i diversi membri della famiglia. Alla fine degli anni Novanta, furono sviluppate diverse tecniche per affrontare lo sviluppo sistematico di famiglie di prodotti. Queste tecniche sfruttano al meglio le tecniche di analisi, le architetture software e la modularizzazione.

4.2

Tecniche di modularizzazione

In questo paragrafo discuteremo le tecniche che possono essere utilizzate durante la progettazione, per raggiungere gli obiettivi identificati in precedenza. In particolare, distingueremo due aspetti complementari della progettazione: la definizione della struttura generale dell'architettura in termini di relazioni tra i moduli e la progettazione di ogni singolo modulo, per la quale applichiamo il principio dell'information hiding. Questi due aspetti vengono spesso chiamati progettazione architetturale (o di alto livello) e progettazione dettagliata. Anche se diverse metodologie di progettazione suggeriscono che vengano eseguiti come due passi sequenziali, noi non li percepiamo come momenti distinti, dove il secondo segue necessariamente il primo. Piuttosto, percepiamo la progettazione come un processo continuo in cui l'interazione di queste due attività avviene in maniera flessibile. Per poter documentare e analizzare i nostri progetti, abbiamo bisogno di una notazione. Introdurremo una notazione di progetto molto semplice nel Paragrafo 4.2.3. Lo scopo di questa notazione, sia testuale che grafica, è di tipo puramente pedagogico. La notazione non è intesa per l'uso nello sviluppo di software industriale. Piuttosto, la notazione servirà a illustrare quali siano le caratteristiche necessarie di una notazione. Più avanti, quando discuteremo la progettazione orientata agli oggetti, faremo riferimento a una notazione standard (UML).

4.2.1

La struttura modulare e la sua rappresentazione

Un modulo è un componente ben definito di un sistema software. Talvolta si tende a sovrapporre i concetti di modulo e routine, ma un modulo è un frammento software che corrisponde a qualcosa di più di una semplice routine. Potrebbe essere una collezione di routine, una collezione di dati, una collezione di definizioni di tipi, o un insieme di tutti questi. In generale, potremmo vedere un modulo come un fornitore di risorse computazionali o di servizi. Quando scomponiamo un sistema in moduli, dobbiamo essere in grado di descrivere, in modo preciso, la struttura modulare generale e le relazioni che esistono tra i singoli moduli.

È possibile definire molte relazioni tra moduli. Per esempio, possiamo definire una relazione che indica che un modulo deve essere implementato prima di un altro o che è più importante di un altro. La prima relazione potrebbe essere utilizzata da un manager per monitorare lo sviluppo del sistema; la seconda come linea guida per assegnare il lavoro ai programmatori in base alle loro capacità ed esperienze. In questo caso, siamo interessati alle relazioni tra moduli che sappiano definire un'architettura software e che ci aiutino a capire com'è organizzato un sistema complesso. Come vedremo tra breve, abbiamo due relazioni tra moduli, molto utili, che possono essere impiegate per definire la nostra architettura di sistema. Affronteremo tre problematiche. •

Qual è la struttura del software in termini dei moduli che la costituiscono?



Come possiamo definire questa struttura in maniera precisa?



Quali sono le proprietà desiderabili per tale struttura?

Innanzitutto, da un punto di vista astratto, la struttura modulare di un sistema può essere descritta in termini di relazioni matematiche. Sia S un sistema software composto dai moduli M1; M2, . . ., Mn; cioè, S

=

{M J ,

MJ(

. . . ,

M„}

Una relazione r su S è un sottoinsieme di S x S. Se due moduli Mi e Mj sono in S, rappresentiamo il fatto che la coppia è in r usando la notazione infissa Mjr Mj. Siccome siamo interessati alla descrizione delle mutue relazioni tra i diversi moduli, assumeremo sempre, implicitamente, che le relazioni di interesse in questo testo non siano riflessive. Ciò significa che Mi r Mi non può sussistere per alcun modulo Mi in S. La chiusura transitiva della relazione r su S è nuovamente una relazione su S, e viene indicata con r + . Presi due elementi qualsiasi di S, Mi e Mj, r + può essere definito ricorsivamente: Mi r* Mj se e solo se Mi r Mj o se esiste un elemento Mk in S tale che Mi r Mk e Mk r + Mj. Una relazione è gerarchica se e solo se non esistono due elementi Mi, Mj tale che Mi r + Mj e Mj r + Mi. La chiusura transitiva di una relazione cattura la nozione intuitiva di relazioni dirette e indirette. Per esempio, presi due moduli A e B, A CHIAMA* B implica che o A CHIAMA B direttamente oppure che A chiama B indirettamente attraverso una catena di relazioni CHIAMA. Solitamente, i concetti matematici possono essere intesi intuitivamente con maggiore efficacia se ne riusciamo a dare una rappresentazione grafica. Le relazioni sono buoni esempi di questo principio generale. Una relazione può essere rappresentata in forma grafica mediante l'uso di grafi orientati, i cui nodi sono etichettati come elementi di S, ed esiste un arco orientato tra il nodo etichettato Mi e il nodo etichettato Mj se e solo se Mi r M,. Una relazione è gerarchica se e solo se non esistono cicli nel grafo della relazione; questo tipo di grafo è detto DAG {Directed Acyclic Graph, grafo orientato aciclico). Un grafo generico è illustrato nella Figura 4.3(a); la Figura 4.3(b) rappresenta una gerarchia (un DAG). Nel seguito discuteremo due tipi di relazioni tra moduli che sono molto utili per strutturare progetti software: USES e IS COMPONENT OF.

Figura 4.3

4.2.1.1

Rappresentazione mediante grafi delle relazioni tra moduli, (a) Grafo generico, (b) Grafo orientato aciclico (DAG).

La relazione USES

Una relazione utile per la descrizione della struttura modulare di un sistema software è la cosiddetta relazione USES. Per due moduli distinti qualsiasi Mi e Mj; diciamo che Mi USES Mj se Mi richiede la presenza di M,, in quanto Mj possiede una risorsa di cui ha bisogno Mi per portare a termine il suo compito. Se M± USES M3, diremo anche che Mi è un client di Mj, visto che Mi richiede un servizio fornito da Mj. Al contrario, Mj verrà detto server. Più concretamente, una relazione USES viene stabilita se il modulo M accede a una risorsa fornita dal modulo Mj. Per esempio, Mi USES Mj se M¿ contiene una chiamata a una procedura contenuta nel modulo Mj o se Mi fa uso di un tipo definito in Mj. Un vincolo che può convenire imporre alla relazione USES è che sia una gerarchia. I sistemi gerarchici sono più facili da capire rispetto a quelli non gerarchici: una volta che sono chiare le astrazioni fornite dai moduli server utilizzati, i moduli client possono essere capiti senza dover guardare alla realizzazione dei server. In altre parole, la separazione degli interessi può essere applicata semplicemente attraversando la struttura USES, incominciando dai nodi del DAG che non fanno uso di altri nodi, fino ai nodi che non sono utilizzati da alcun altro nodo. Ogni volta che incontriamo un nodo, esso può essere capito in funzione delle astrazioni fornite dai moduli usati, precedentemente incontrati e capiti. Citando Parnas, si può osservare che se la struttura non è gerarchica, si incorre facilmente in un sistema "in cui niente funziona fino a quando tutto funziona" 2 . Infatti, la presenza di un ciclo nella relazione USES implica un forte legame tra tutti i moduli del ciclo: significa che nessun sotto-insieme di moduli nel ciclo può essere utilizzato o testato isolatamente. Ad esempio, se A USES B e B USES A devo necessariamente disporre sia di A che di B per poter eseguire A o B.

2

Parnas [1979].

La restrizione a una gerarchia ha anche un'implicazione metodologica: la struttura risultante definisce il sistema attraverso livelli di astrazione. Questo termine, spesso usato (e abusato), può essere illustrato facendo riferimento alla Figura 4.3(b). Al livello più astratto, l'intero sistema può essere visto come una serie di servizi definiti e forniti dal modulo Mj. Per implementare tale servizi, il modulo Mj usa M i ^ M ^ j e M l i 3 . A sua volta, i servizi astratti forniti, ad esempio, da MI _ 2 sono implementati usando i moduli di livello più basso M 1 | 2 1 e M1I2,2.

Definiamo il livello di un modulo all'interno di una gerarchia r nel seguente modo. 1. Il livello di un modulo Mi è 0 se non esiste alcun modulo

tale che Mi r

2. Per ogni modulo Mi, se k è il livello massimo di tutti i moduli Mj tali che Mi r Mj, allora il livello di Mi è k + l . Un sistema descritto da una relazione U S E S gerarchica può essere inteso in termini di successivi livelli di astrazione.Un modulo al livello i U S E S solo moduli di livello j tale che i> j . Per esempio, nella Figura 4.3(b), i livelli di M l i 3 , M2 e M4 sono rispettivamente 3 , 1 e 0; M 1 3

USES

M2 e M 1 3

USES

M4.

Dati due moduli qualsiasi Mi eMj, i cui livelli siano rispettivamente i e j , diciamo che Mi è di un livello più alto rispetto a Mj se e solo se i> j . Un altro termine comunemente utilizzato in connessione alla relazione gerarchica U S E S , specialmente nel caso della struttura di sistemi operativi, è macchina astratta (virtuale). Il suo significato è che l'insieme dei servizi forniti dai moduli di un dato livello corrisponde a una macchina astratta (o virtuale). Tali servizi sono utilizzati da un insieme di moduli di livello più alto per implementare i servizi che sono tenuti a fornire. Nella loro implementazione, i servizi forniti al livello i vengono utilizzati come se fossero forniti da una macchina virtuale. Le macchine virtuali vengono progressivamente dettagliate facendo affidamento su altre macchine virtuali, fino a quando non rimangono altri livelli. Ad esempio, si consideri il caso di un modulo M„ che fornisce input/output di record (struci). Immaginiamo che M„ faccia uso di un altro modulo M b che fornisce input/output di un singolo byte alla volta. Quando M r viene utilizzato per realizzare l'output di record, il suo compito consiste nel trasformare il record in una sequenza di byte e isolare un byte alla volta perché ne venga fatto l'output mediante l'operazione di output fornita da MB. I client percepiranno M r come una macchina virtuale che implementa operazioni di input/output sui record. Ma per poter portare a termine il servizio pubblicato, M„ utilizza un modulo di livello più basso Mb che corrisponde a una macchina virtuale più semplice. La relazione U S E S è definita in maniera statica tra i moduli; cioè, l'identificazione di tutte le coppie < M i f M 3 > appartenenti alla relazione U S E S è indipendente dall'esecuzione del software. Infatti, lo scopo della progettazione è proprio quello di definire la relazione una volta per tutte. Per chiarire l'argomento, si consideri un modulo M che fa uso dei moduli Mx e M2 chiamando una delle loro procedure. Se il modulo client M contiene la struttura di codice if cond

tben

proci

else

proc2

dove p r o c i è una procedura del modulo MI e p r o c 2 è una procedura del modulo M2, allora M U S E S MJ e M U S E S M 2 , anche se durante un'esecuzione particolare potrebbe accadere che venga invocato o MI o M2, ma non entrambi.

Come ulteriore esempio, si consideri la riconfigurazione dinamica di un sistema distribuito: durante l'esecuzione, M^ potrebbe utilizzare il modulo Mj fino a quando la riconfigurazione dinamica non faccia sì che Mi usi il modulo Mk. Mj e Mk forniscono esattamente le stesse funzionalità, ma risiedono su diversi nodi del sistema distribuito. Un guasto del nodo dove risiede Mj, fa sì che le richieste di funzionalità che erano offerte da Mj siano ridirette verso Mk. Dunque, in termini della relazione USES, abbiamo sia Mi USES M-jsiaMi USES Mk, anche se nei casi normali viene utilizzato solo M-,. La rappresentazione grafica della relazione USES fornisce una descrizione intuitiva, anche se parziale delle associazioni tra moduli. Se ogni nodo del grafo è connesso a ogni altro nodo del grafo (cioè il grafo è completo: esiste una coppia all'interno della relazione USES per ogni Mi, M-j in s), allora la struttura modulare risulta estremamente intricata e non fornisce un partizionamento gestibile dell'intero sistema. In verità, questi commenti risultano veri per la maggior parte delle altre relazioni tra moduli. Se il grafo di una relazione r è tale che ogni modulo è associato a ogni altro modulo allora nessuna parte risulta essere indipendente. In un caso simile, la cardinalità di r risulta essere n ( n - 1 ), dove n è la cardinalità di S. Dall'altra parte, se r è vuoto allora la relazione descrive una struttura modulare in cui non esistono due moduli associati. Dunque, il sistema può essere diviso in parti assolutamente incorrelate, che sono progettabili e comprensibili isolatamente. Una relazione r vuota non è significativa nella pratica, ma ci mostra che dovremmo cercare di ottenere strutture modulari dove la cardinalità di r è molto più piccola di n 2 . La relazione USES ci fornisce la possibilità di ragionare su un modo ben preciso di associare moduli. Facendo riferimento al grafo della relazione USES, possiamo distinguere tra il numero di archi uscenti da un modulo (detto il fan-out del modulo) e il numero di archi entranti in un modulo (detto il fan-in del modulo). E stato suggerito che una buona struttura progettuale dovrebbe tenere il fan-out basso e il fan-in alto. Un fan-in alto è un'indicazione di una buona progettazione in quanto un modulo con un alto fan-in rappresenta un'astrazione generale, molto utilizzata dagli altri moduli. Per poter valutare la qualità di un progetto, comunque, non è sufficiente la valutazione della struttura della relazione USES. È importante anche la natura dell'interazione tra i moduli. Ecco alcuni esempi di come i moduli possono utilizzarsi a vicenda. 1. Un'interazione poco desiderabile si ha quando un modulo modifica i dati - o addirittura le istruzioni - locali a un altro modulo. Questo può succedere nel caso di linguaggi di tipo assembler. 2. Un modulo potrebbe utilizzare un altro modulo comunicando attraverso un'area dati comune, come una variabile statica in C o un blocco COMMON in FORTRAN. 3. I dati scambiati tra i due moduli potrebbero essere dati "puri" o informazioni di controllo (cosiddetti flag). Lo scambio di informazioni di controllo spesso risulta in un tipo di interazione che impoverisce la leggibilità dei programmi. 4. Un sottoprogramma potrebbe comunicare con un altro invocandolo e trasferendo parametri. Questo è un modo tradizionale e disciplinato di interazione tra due moduli. 5. In un ambiente concorrente, un modulo client potrebbe comunicare con un server attraverso una chiamata a procedura remota (o invocazione di metodo remoto in Java).

Oppure, nel linguaggio di programmazione Ada, un modulo M che racchiude un compito CM potrebbe utilizzare un modulo M' che racchiude un compito CM. facendo una chiamata all'ingresso del compito C M .. Questi sono metodi disciplinati per la comunicazione tra due moduli concorrenti. Esercizi 4.5

Considerate il caso in cui la relazione U S E S sia definita da un albero. Cosa comporta che la struttura sia un albero e non un DAG? In generale, è preferibile un progetto in cui la relazione USES è un albero o un progetto in cui è un DAG?

4.6

Si supponga che la relazione USES corrisponda alle chiamate a procedure. Moduli mutuamente ricorsivi non formano una gerarchia. La ricorsione diretta all'interno di un modulo, però, è permessa in una gerarchia. Sono corrette queste affermazioni? Se lo sono, fornite una giustificazione.

4.7

Si può definire il concetto di livello per un grafo generico invece di un DAG? Perché? Perché no? Che cosa implica per la struttura di progetto?

4.8

Supponiamo di impiegare un linguaggio che supporti l'uso di procedure come parametri. Per esempio, il modulo MI può chiamare una procedura P del modulo MJ, passando come parametro la procedura Q del modulo MK. C o m e potreste definire la relazione USES per MJ; considerando i moduli di cui fa uso chiamando la sua procedura come parametro formale?

4.2.1.2

Relazione IS_COMPONENT_OF

I S_COMPONENT_OF è un'altra relazione tra moduli che risulta utile per la descrizione di progetti. Questa relazione permette ai progettisti di descrivere un'architettura in termini di un modulo che è composto da altri moduli, che a loro volta possono essere composti da altri moduli, e cosi via. Consideriamo un insieme di moduli S. Per qualsiasi Mi e M j ; in S, Mi IS_C0MP0NENT_0F Mj significa che M, è realizzato aggregando diversi moduli, uno dei quali è Mi. E anche possibile definire COMPRISES come la relazione inversa di IS_C0MP0NENT_0F; ovvero per due elementi Mi e Mj in s , diciamo che Mi c o m p r i s e s Mj se e solo seMj is_CC>mponent_of Mi. ms _ i è un sotto-insieme di s così definito: MSii

=

{M k M k

is

in

S and

Mk

I S_COMPONENT_OF

Mi)

Possiamo dire che M± I S C O M P O S E D O F M S i i e, viceversa, cheM E i i IMPLEMENTS Mi. Se un modulo Mi è composto da un insieme di moduli M S i i , allora i moduli dell'insieme M S ; i forniscono, a tutti gli effetti, tutti i servizi che dovrebbero essere forniti da Mi: sono il risultato della scomposizione in componenti di Mi, e dunque implementano Mi. Nella progettazione, una volta che Mi viene scomposto nell'insieme dei costituenti MSi viene sostituito da loro; ovvero, Mi diventa un'astrazione implementata in termini di astrazioni più semplici. L'unico motivo per mantenere Mi nella descrizione modulare di un sistema è quello di potervi fare riferimento, rendendo il progetto più chiaro e comprensibile. Alla fine del processo di scomposizione, comunque, solo i moduli non composti da altri moduli possono essere visti come componenti reali del sistema. Gli altri vengono mantenuti solo per motivi descrittivi.

Anche la relazione LS_COMPONENT_OF può essere descritta da un grafo orientato, come illustrato nella Figura 4.4(a). La relazione non è riflessiva e costituisce una gerarchia. Dunque, anche in questa relazione, possiamo definire un modulo che sta a un livello più alto di un altro modulo, come abbiamo fatto con la relazione USES. In pratica, è più utile introdurre il concetto di livello in riferimento nella relazione COMPRISES. La Figura 4.4(b) descrive il sistema di Figura 4.4(a) in termini di questa relazione. Il concetto di livello definito da IS_COMPOSED_OF è tale che se MI IS_COMPOSED_OF { M i , w m ì,2» • • • / Mi, „> allora Mi risulta essere di livello più alto di qualsiasi dei moduli MI;1, MI,2 / • • • / MI Notiamo che il concetto di livello di astrazione può risultare ambiguo quando utilizzato nelle descrizioni di progetto, a meno che non venga specificato esplicitamente se è inteso come il livello rispetto alla relazione USES o alla relazione COMPRISES. Nel caso di USES, tutti i moduli M ^ , M1I2, .. ., MIIN usati da un determinato modulo Mi risultano essere di un livello più basso rispetto a MÌ; dunque, Mi fornisce i servizi che esporta ai propri client usando i servizi forniti dai moduli di livello più basso M^,, M^ 2, . . ., M I N . Nel caso di COMPRI SES, tutti i moduli che implementano un dato modulo Mi sono di livello più basso rispetto a M^ prendono il suo posto (cioè, Mi è raffinato mediante la sostituzione con MI;1, MI 2, . . ., MI N. La rappresentazione grafica di IS_COMPONENT_OF descrive anche IS_C0MP0SED_0F, IMPLEMENTS e COMPRISES. Per esempio, nella Figura 4.4, M2, M3 e M4 sono componenti di MÌ; M! I S _ C O M P O S E D _ O F {M 2 , M 3 , M 4 } ; { M 2 , M 3 J

M4} IMPLEMENTS MI;EM1 COM-

PRISES MI, per 2 s i < 4. L'intero sistema software è in definitiva composto dai moduli M4,M5,M6,M7,ME e M,. Gli altri moduli che appaiono nel grafo non esistono fisicamente; il loro unico scopo è quello di aiutare a descrivere la struttura in una maniera gerarchica. Per esempio, supponiamo che la Figura 4.4 descriva la struttura modulare di un'applicazione in cui M2 è il modulo che fornisce servizi di input, M3 è il cuore del sistema ed esegue tutte le istruzioni e M4 è il modulo che fornisce servizi di output. A sua volta M2 è composto da vari moduli (M7, ME e M9), i quali forniscono determinati servizi di input: ad esempio, input attraverso il riempimento dei campi di una tabella, attraverso una sequenza di comandi interattivi, etc. Il modulo M3 è scomposto in M5 e M6. Il sistema finale contiene solo moduli fisici che corrispondono agli elementi della relazione LS_COMPOSED_OF che non sono scomposti in ulteriori moduli, per esempio, M4, M5, M6, M7, ME e M9.

(a) Figura 4.4

(b)

Esempio della relazione I S_COMPONENT_OF (a) e la corrispondente relazione COMPRISES

(b).

Finora, durante la discussione riguardante IS_C0MP0NENT_0F, abbiamo assunto che un modulo possa essere componente al massimo di un modulo. Anche se ciò rappresenta il caso più tipico, non imponiamo questa restrizione nella definizione della relazione I S _ C 0 M POSED_OF. Quindi, per esempio, è possibile completare il grafo della Figura 4.4 con un arco orientato dal nodo M6 al nodo M„ per indicare che m6 è componente sia di M3 sia di M„. Quando un modulo Mi è componente di entrambi i moduli Mj e Mk, si può dare una descrizione alternativa, che risulta ovvia, secondo la quale Mi è componente solo di M3 e una copia di Mi è componente di Mk. Un'altra soluzione adottata da alcuni linguaggi consiste nella definizione di una macro o di un modulo generico (template) per la generazione successiva di istanze da utilizzare nei diversi contesti. Approfondiremo l'argomento più avanti. Le due relazioni USES e i s _ c o m p o n e n t _ o f possono essere, e spesso lo sono, utilizzate insieme. Per esempio, potremmo cominciare una descrizione di più alto livello di un'architettura di sistema dicendo che s i s t e m a IS_C0MP0SED_0F i moduli M l5 M2 e m3, dove Mj USES sia M2 che M3. Più tardi, potremmo specificare che Mi IS_COMPOSED_OF M, e m5, e così via. Anche se abbiamo discusso le relazioni U S E S e l S _ C O M P O N E N T _ O F nel contesto della progettazione di architetture software, i concetti che queste relazioni esprimono possono essere ugualmente applicati a qualsiasi altro tipo di progetto. Nel contesto della specifica dei requisiti, ad esempio, dovremmo creare moduli di specifica e relazioni che descrivono le loro dipendenze. Un modulo di specifica potrebbe usare un altro modulo se fa riferimento a un concetto espresso da quest'ultimo. Un modulo di specifica potrebbe anche essere componente di un altro modulo qualora specificasse una parte del sistema illustrata da quest'ultimo. Esercizi 4.9 4.10

IS

COMPOSED OF e IMPLEMENTS s o n o relazioni su S?

S u p p o n i a m o di avere deciso di seguire la seguente politica: u n m o d u l o Mt p u ò essere implem e n t a t o p r i m a di M 2 se M, n o n h a c o m p o n e n t i e n o n usa M 2 o qualsiasi altro m o d u l o che c o m p r e n d e M2. Descrivete questa politica in m o d o formale, c o m e u n a relazione tra m o d u l i .

4.2.1.3

Rivisitazione delle famiglie di prodotti

Possiamo utilizzare le relazioni U S E S e l S _ C O M P O N E N T _ O F per riformulare alcuni punti riguardanti le famiglie di prodotti. Supponiamo di progettare un sistema S e di comporlo in un insieme di moduli M,,M 2 , . . ., Mi, mediante la relazione USES. Supponiamo poi di affrontare la progettazione di uno qualsiasi di questi moduli, ad esempio Mk, 1 s k s i . A questo punto, ci accorgeremmo che qualsiasi decisione di progetto che possiamo prendere separerà un sottoinsieme di membri della famiglia dagli altri; per esempio, Mk è un modulo di output e la sua progettazione potrebbe avere bisogno di discriminare tra output testuale e output grafico, da affrontare mediante due membri differenti della famiglia. Supponiamo, inoltre, di prendere la decisione di seguire una delle opzioni di progettazione (nell'esempio, l'output grafico), che ci porta a scomporre Mk in M k > 1 , Mk 2 , . . . , M k > i k , con la definizione di alcune relazioni USES all'interno di questo sottoinsieme.

Bisognerebbe sempre annotare queste decisioni di progettazione in maniera accurata, in modo da poter effettuare cambiamenti futuri in maniera affidabile. Supponiamo di dover progettare un diverso membro della famiglia in un momento successivo (per esempio, il sistema che fornisce il supporto per l'output testuale). Non si dovrebbe mai modificare l'implementazione finale, cambiando il codice, in modo da soddisfare i nuovi requisiti. Piuttosto, la documentazione della struttura dei moduli dovrebbe costringerci a riprendere la progettazione dalla scomposizione del modulo Mk, in modo da fornire una diversa implementazione in termini di moduli di più basso livello. Notiamo, comunque, che il resto del sistema non risulterebbe alterato; ovvero i moduli M1; . . ., Mk_i e Mk+1, . . . , Mi non subirebbero ripercussioni per via della progettazione del nuovo membro della famiglia.

4.2.2

Interfaccia, implementazione e information hiding

Le relazioni U S E S e l S _ C O M P O N E N T _ O F forniscono solo una descrizione parziale di un'architettura software. Per esempio, rimangono ancora molte cose da dire riguardo all'interazione tra due moduli che partecipano alla relazione U S E S e riguardo ai dettagli di l S _ C O M P O N E N T _ O F . Ovvero, quando un modulo Mi che U S E S un modulo Mj viene raffinato nei suoi componenti M i r l , M i i 2 , . . . , M i > n , diventa necessario dichiarare esattamente i significati della relazione U S E S tra i moduli del sotto-insieme {M^i, M i i 2 , • • •» M i i n } e M-,.

Intuitivamente, ci piacerebbe dividere il software in componenti tali che ognuno possa essere progettato indipendentemente dagli altri. Se ogni componente diventa un compito di un programmatore differente di una squadra, allora ciascun programmatore dovrebbe poter lavorare sul proprio componente con meno conoscenza possibile riguardo a come gli altri membri della squadra stanno implementando i propri. Ancora una volta, questa è l'essenza dei principi di separazione degli interessi e di modularità, discussi in termini generali nel Capitolo 3. Per essere più precisi, occorre definire come l'interazione tra moduli avviene veramente, ovvero, quale sia la natura esatta della relazione USES tra due moduli qualsiasi. L'insieme dei servizi offerti da ogni modulo ai suoi client viene detto interfaccia. Si dice che i servizi corrispondenti sono esportati dal modulo e importati dai client. Il modo in cui questi servizi vengono portati a termine dal modulo è un segreto del modulo ed è insito nella sua implementazione. La distinzione chiara tra l'interfaccia di un modulo e la sua implementazione è un aspetto chiave in una buona progettazione, in quanto supporta il principio della separazione degli interessi. L'interfaccia di un modulo M descrive esattamente ciò che i moduli client devono conoscere per usufruire dei servizi offerti da M. L'interfaccia è un'astrazione del modulo, che descrive come questo viene visto dai suoi client. Il progettista a capo di M, al momento della sua progettazione, deve conoscere solo le interfacce degli altri moduli usati da M, e può ignorare la loro implementazione. L'interfaccia di M viene vista dal progettista come la descrizione del suo compito: il suo obiettivo è quello di fornire quei servizi attraverso un'implementazione adeguata. L'implementazione di un modulo è la sua scomposizione in termini di componenti, descritti dalla relazione l S _ C O M P O N E N T _ O F ; oppure, se il modulo è sufficientemente piccolo, la sua codifica in un determinato linguaggio di programmazione, che potrebbe anche utilizzare servizi forniti da altri moduli di livello più basso.

L'interfaccia di un modulo M può essere vista come un contratto tra M e i suoi client; ovvero, l'interfaccia registra solo le operazioni che il progettista a capo di M concorda di fornire agli altri progettisti. I client possono fare affidamento solo su quanto specificato nell'interfaccia. Di conseguenza, fino a quando l'interfaccia rimane la stessa, M può cambiare senza causare ripercussioni sui propri client. Nella maggior parte dei casi pratici, le interfacce descrivono le risorse computazionali, come ad esempio una variabile che appartiene a un modulo e viene resa disponibile ad altri moduli per fornire una forma di interazione o funzioni che devono essere chiamate per l'esecuzione di una certa operazione. Le interfacce, comunque, non sono limitate a questi tipi di risorse. Per esempio, le informazioni riguardanti il tempo di risposta di una routine esportata potrebbero essere parte di una descrizione non funzionale dell'interfaccia nel caso di un'applicazione real-time. E un tipo di informazione che il client deve conoscere per poter decidere se e come utilizzare il modulo. Possiamo approfondire la distinzione tra interfaccia e implementazione introducendo il concetto di information hiding. I client di un modulo conoscono i suoi servizi solamente attraverso l'interfaccia; l'implementazione risulta essere così nascosta. Ciò significa che l'implementazione potrebbe cambiare senza conseguenze per i client del modulo, a patto che l'interfaccia rimanga immutata. Dunque, un aspetto critico della progettazione consiste nella scelta precisa di che cosa presentare nell'interfaccia — e dunque rendere visibile ai potenziali client - e di che cosa celare nell'implementazione, e dunque rendere modificabile in qualsiasi momento senza conseguenze per i client. La disponibilità di costrutti linguistici per la definizione di interfacce negli attuali linguaggi di programmazione fornisce un ottimo supporto all'attività di progettazione del software. Esercizi 4.11

Per il programmatore Ada: considerate la parte di specifica dei package Ada come la descrizione dell'interfaccia di un modulo. Q u a l è la differenza tra esportare u n tipo ed esportare un tipo privato? Descrivete la differenza in termini di funzionalità esportate.

4.12

Per il programmatore Java: il costrutto di interfaccia in Java permette a u n o sviluppatore di specificare l'interfaccia di un m o d u l o i n d i p e n d e n t e m e n t e dalla sua implementazione. Quali entità sono esportabili in Java? C o m e fa u n programmatore a fornire un'implementazione per un'interfaccia? È possibile avere due implementazioni della stessa interfaccia?

4.13

C o n f r o n t a t e il supporto alla definizione di interfacce presente in diversi linguaggi, quali Eiffel, Ada 95, C++ e Java.

4.2.2.1

Come progettare interfacce per i moduli

Un'analogia spesso utilizzata per descrivere i concetti di interfaccia, implementazione e information hiding è illustrata nella Figura 4.5. Un modulo è come un iceberg: l'interfaccia è la sua punta, la parte visibile, e l'implementazione è ciò che rimane celato sotto la superfìcie dell'acqua. La punta è solo una piccola parte dell'intero elemento. In quest'analogia, comunque, esistono alcuni limiti. Ad esempio, la punta dell'iceberg non fornisce un'astrazione soddisfacente dal punto di vista di una nave. Fare affidamento

Figura 4.5

L'interfaccia come punta dell'iceberg.

sulla forma della parte emersa non è infatti sufficiente per evitare di cozzare con quella sommersa. Contrariamente alla punta dell'iceberg, l'interfaccia di un modulo descrive tutto ciò che si deve conoscere per poter utilizzare il modulo in maniera corretta. Nonostante ciò, l'analogia dell'iceberg ci può illuminare su un punto molto importante: cosa dovrebbe essere presentato nella descrizione dell'interfaccia e cosa invece celato all'interno dell'implementazione? Chiaramente, l'interfaccia di un modulo dovrebbe rivelare meno informazioni possibili, ma sufficienti affinché gli altri moduli possano utilizzare i servizi forniti. Rivelare informazioni non necessarie rende l'interfaccia inutilmente complessa e riduce la comprensibilità del progetto del sistema. Inoltre, rivelando informazioni riguardanti dettagli interni, non necessari, diventa più probabile che un cambiamento al modulo produca conseguenze non solo sulla sua implementazione ma anche sulla sua interfaccia. Peggio ancora, altri moduli potrebbero approfittare dell'informazione resa pubblica, operandovi in maniera indesiderata. Dall'altra parte, non esportare i servizi che devono essere importati dai client diminuirebbe l'usabilità del modulo. Esercizio 4.14

Esponete che cosa rappresenta un telecomando, in termini di interfaccia, per u n utente che vuole guardare la TV. L'interfaccia risulta adeguata se l'utente vuole connettere l'apparecchio ad altre periferiche (per esempio, lo stereo, il videoregistratore o la videocamera) attraverso i suoi canali di input e output?

L'Esercizio 4.14 affronta un concetto importante: l'interfaccia che progettiamo dipende dai servizi che vogliamo offrire ai clienti e, per contro, da ciò che decidiamo di nascondere all'interno del modulo. Possiamo celare determinate funzioni se riteniamo che gli utenti non ne facciano uso. L'arte del progettare interfacce per moduli consiste nel bilanciare attentamente cosa vogliamo nascondere e cosa dobbiamo fornire. Se viene celato tutto, i moduli non comunicheranno e non coopereranno: saranno sottosistemi autonomi. Se tutto viene reso visibile, la struttura del sistema risulterà intricata e caratterizzata da eccessive interazioni.

ESEMPIO 4 . 1

Supponiamo di progettare un interprete per un linguaggio di programmazione molto semplice, M I N I , che opera su interi e array di interi. Forniamo un modulo che rappresenti la classica tabella dei simboli di un interprete o compilatore, che registra informazioni riguardanti le variabili di un programma. Supponiamo che la tabella dei simboli esporti una procedura GET che accetta in ingresso il nome simbolico di una variabile ed eventualmente il valore di un indice (nel caso di un array) e restituisce il valore della variabile. In maniera del tutto simile, supponiamo che una procedura PUT renda possibile l'inserimento di un nuovo valore per una data variabile. Infine, supponiamo che, quando viene incontrata la definizione di una nuova variabile, venga creata una nuova voce nella tabella dei simboli mediante la chiamata alla procedura CREATE, passando come parametro di ingresso il nome della variabile e la sua dimensione (il numero delle voci di tipo intero che rappresenta). Lo scopo dell'interfaccia che stiamo progettando è quello di nascondere ai clienti del modulo la struttura fisica della tabella. Per avvertire i clienti quando viene tentata la lettura o scrittura del valore di una variabile che non esiste, o viene tentato l'accesso a un array con un valore di indice non valido, le procedure GET e PUT restituiscono un parametro aggiuntivo, POS. Il valore restituito di POS è un puntatore alla variabile registrata nella tabella, se esiste, oppure un puntatore n u l i se la variabile non esiste. Questo progetto può essere criticato per la ridondanza dell'interfaccia. Se il nostro scopo è solo quello di fornire operazioni per la registrazione e il recupero di dati (e segnalare il caso in cui l'accesso ai dati risulta non corretto), allora stiamo fornendo informazioni in eccesso (e cioè, la posizione all'interno della tabella dove sono immagazzinati i dati). Tale ridondanza può avere effetti negativi sulla facilità di effettuare cambiamenti al progetto, come vedremo tra breve. Inoltre viola il principio dell'information hiding. • Come conviene procedere nella progettazione dei moduli, con information hiding, in modo da migliorare la coesione e ridurre le interazioni, sia in termini di numero di interconnessioni presenti nel grafo della relazione USES sia in termini del tipo e della quantità di informazioni esportate dall'interfaccia? Per rispondere a questa domanda, dovremmo prima definire quale sia realmente il principale obiettivo del nostro progetto. Come in precedenza, assumiamo che la semplicità nell'effettuare cambiamenti sia un obiettivo primario: vogliamo che il nostro progetto sia in grado di evolvere facilmente e in modo affidabile, in accordo con i cambiamenti previsti e con gli eventuali altri cambiamenti che si potrebbero prospettare. Il prossimo paragrafo fornirà alcune linee guida su come progettare moduli che sappiano assimilare i cambiamenti futuri. Esercizio 4.15

C o n s i d e r a t e u n c a m b i a m e n t o all'interfaccia del m o d u l o dell'Esempio 4.1 che preveda l'esistenza di un'altra p r o c e d u r a per p o t e r chiedere se u n a variabile esiste (e q u i n d i se p u ò essere letta o scritta in m a n i e r a sicura). D i s c u t e t e il c a m b i a m e n t o in termini di qualità della struttura m o d u l a r e , efficienza del sistema, etc.

4.2.2.2

Segreti dei moduli e progettazione per il cambiamento

Per massimizzare la facilità di modifica dell'implementazione di un modulo, le sue interfacce dovrebbero esporre meno dettagli possibili. Un altro obiettivo è quello di nascondere i dettagli di basso livello e fornire un'interfaccia astratta in modo da rendere il progetto più comprensibile. Una volta che i cambiamenti per agevolare ciò sono stati identificati, si deve strutturare il sistema in modo tale da nascondere nella parte implementativa le decisioni modificabili, così che le interfacce rappresentino informazioni stabili (e cioè, le informazioni non coinvolte nei cambiamenti). Diremo che le informazioni modificabili e nascoste diventano il segreto del modulo; inoltre, in accordo con un gergo largamente accettato, diremo che tali informazioni sono incapsulate all'interno dell'implementazione del modulo. ESEMPIO 4 . 2

Il segreto del modulo della tabella dei simboli dell'Esempio 4.1 risiede nella struttura dati scelta per la rappresentazione interna. Potremmo scegliere di usare un'array lineare, una hash table, una lista concatenata, un albero binario, o addirittura strutture più sofisticate. L'importante obiettivo che vogliamo raggiungere risiede nella possibilità di modificare la struttura dati senza provocare conseguenze ai moduli client. Inoltre, intendiamo progettare e implementare il sistema velocemente, concentrandoci prima sulla struttura complessiva, senza impiegare troppo tempo progettando e raffinando le strutture dati interne al modulo. Desideriamo posticipare la decisione riguardante la natura delle parti interne di ciascun modulo, come ad esempio il "miglior" tipo di struttura dati, a un secondo momento, in cui avremo terminato l'intera progettazione dell'interprete e possibilmente collezionato alcuni profili di esecuzione. Se esaminiamo l'interfaccia attentamente, però, possiamo osservare come questa riveli più del necessario; in particolare, espone una parte del suo (supposto) segreto. Conoscendo l'indirizzo di una variabile (rivelato da GET o PUT), è consentito accedervi direttamente, senza necessariamente passare dalle procedure definite nell'interfaccia. Per esempio, se un client accede ripetutamente alla stessa variabile semplice - diciamo X - potrebbe essere tentato di ottenere il valore di POS e poi usare direttamente il puntatore per accedere alla variabile. Questo metodo funzionerebbe correttamente solo se la posizione in cui viene registrato il valore non cambiasse nel tempo, come nel caso di un'implementazione semplice della tabella dei simboli in cui per ogni nuova dichiarazione viene semplicemente allocato un elemento in coda a una struttura dati sequenziale. Qualora, in una versione successiva della tabella dei simboli, la struttura dati sequenziale venisse sostituita da una struttura che tenesse ordinate le sue voci, l'applicativo diventerebbe evidentemente scorretto. Infatti, ogni volta che si incontrano nuove dichiarazioni, queste verrebbero registrate nelle posizioni appropriate della struttura dati, implicando lo spostamento di alcune voci già inserite, e rendendo così non validi i valori precedentemente restituiti da POS. • Come anticipato nel Paragrafo 4.1.1, i dettagli riguardanti la macchina astratta sottostante il software sono esempi di informazioni che dovrebbero rimanere nascoste. Questi includono alcuni dettagli riguardanti le chiamate al sistema operativo o le complessità delle interazioni ri-

chieste con dispositivi periferici speciali. La principale ragione per cui occorre nascondere questi dettagli è proteggere l'applicazione dai cambiamenti nella macchina astratta sottostante. Tali cambiamenti potrebbero derivare dalla prevista evoluzione dell'hardware o dalla volontà di rendere portabile l'applicazione. Un'altra importante motivazione per incapsulare in moduli ad hoc gli aspetti dipendenti dalla macchina astratta è data dal principio di separazione degli interessi: mescolare dettagli di basso livello dipendenti dalla macchina astratta con caratteristiche di alto livello dipendenti dall'applicazione ostacolerebbe la comprensibilità del software. ESEMPIO 4 . 3

Supponiamo che venga utilizzato un computer per il controllo remoto di un impianto. Il computer deve acquisire input per raccogliere misurazioni da alcuni dispositivi fisici dislocati in diversi punti dell'impianto. Ad esempio, il computer riceve i valori di temperatura nei punti P^ P2 e P5, i valori di pressione nei punti Pj, P3 e P4, etc. In funzione dei dati di input, devono essere rimandati segnali di controllo all'impianto, e si devono memorizzare dati storici (history log) in modo da facilitare la manutenzione dell'impianto. In una prima versione, i valori di input vengono ricevuti come sequenze di byte che devono essere decodificate dall'applicazione di controllo. Si prevede, però, che il sistema evolverà in una nuova configurazione distribuita, in cui gli input fisici verranno processati remotamente da dispositivi specializzati e spediti al computer di controllo sotto forma di dati strutturati. Un buon progetto dovrebbe definire un modulo per l'acquisizione dell'input, il cui segreto sarebbe il modo fisico in cui i dati di input vengono acquisiti. Tale modulo fornirebbe ai moduli client un'operazione di query da invocare per ottenere il successivo dato di input (che tipo di misurazione è stata eseguita, dove è stata misurata, etc.) e il valore della misurazione (un intero o il valore reale, a seconda del tipo di misurazione). • In conclusione, lo scopo dell'information hiding è quello di progettare moduli che proteggano le decisioni modificabili rendendole segrete e fornire un'astrazione significativa attraverso interfacce stabili. L'identificazione di probabili cambiamenti è cruciale per questo approccio. Un insieme di probabili cambiamenti dovrebbe, infatti, trovarsi all'interno del documento dei requisiti che stabilisce gli obiettivi per l'applicazione. Come già accennato, quando si determinano i requisiti di un nuovo sistema, si dovrebbe porre particolare attenzione non solo a ciò che è necessario in quel momento ma anche a cosa sarà, con buona probabilità, necessario in futuro. L'Esempio 4.3 illustra questo concetto. Altri concetti saranno illustrati nell'esempio successivo. ESEMPIO 4 . 4

Supponiamo che i requisiti per l'applicazione di controllo dell'Esempio 4.3 contengano una descrizione di come debbano essere processati i dati storici. Supponiamo che descrivano anche un insieme predefinito di query che possono essere utilizzate per estrarre dati durante la fase di manutenzione del software. Sono previste migliorie future per il sistema che consentiranno query mediante l'uso di linguaggio naturale. Una buona modularizzazione, in questo caso, incapsulerà in un modulo la struttura fìsica dei file usati per immagazzinare i dati storici; ovvero, il modulo fornirà procedure per

l'accesso alle varie voci registrate nella struttura dati. Un altro modulo fornirà le query astratte; ovvero, incapsulerà come le query verranno effettivamente formulate dall'utente — mediante l'uso di linguaggio naturale o query dal formato prestabilito - e come dovranno essere analizzate in modo da estrarre il significato esatto della richiesta dell'utente. • Come abbiamo visto nel Capitolo 3, una classe importante di probabili cambiamenti ha a che fare con la strategia seguita per produrre l'applicazione. La strategia dello sviluppo incrementale cerca di identificare sotto-insiemi utili dell'applicazione che potrebbero essere sviluppati e consegnati prima di altri. Anche se alcune parti del sistema non vengono affrontate subito ma rimandate, è necessaria una cura attenta nello stadio di progettazione, per definire con precisione le interfacce relative alle parti che vengono destinate allo sviluppo successivo. Ciò consentirà di integrare successivamente nel sistema i moduli mancanti senza che questi disturbino le funzionalità già rilasciate. In altri casi, nello stadio iniziale, alcune parti del sistema possono essere deliberatamente sviluppate in modo semplificato per poi essere riprogettate e reimplementate in uno stadio successivo. La tabella dei simboli dell'Esempio 4.1 ne è un esempio, come quello qui di seguito. ESEMPIO 4 . 5

Supponiamo di dover sviluppare un nuovo tipo di DBMS che speriamo diventi un prodotto rivoluzionario all'interno del mercato. L'aspetto innovativo del sistema risiederebbe nel linguaggio utilizzato per le query, che consentirebbe un sofisticato impiego di interazioni basate sia sul linguaggio naturale che su un linguaggio visuale. Prima di cominciare lo sviluppo del nuovo sistema, vorremmo essere in grado di valutare la validità dell'approccio per quanto riguarda gli aspetti innovativi dell'interazione uomo-macchina. Decidiamo, dunque, di implementare l'interfaccia utente, tralasciando per il momento l'implementazione del vero e proprio DBMS (e cioè, la definizione delle strutture dei file fisici, dei vari algoritmi di memorizzazione e ricerca, delle procedure di recupero, del controllo della concorrenza, etc.). Quello che decidiamo di implementare è un prototipo dell'applicazione, in grado di gestire solo una quantità limitata di informazioni, visto che tutti i dati verrebbero conservati nella memoria centrale usando array. Verrebbe, poi, chiesto ai potenziali utenti di "giocare" con il sistema, per fornire feed-back ai progettisti riguardo alla sua usabilità. Ovviamente gli utenti verrebbero avvertiti che le prestazioni, la robustezza, l'affidabilità, etc., del prototipo non hanno niente a che vedere con quelle del futuro sistema: dovranno quindi porre attenzione al modo in cui le query vengono sottoposte mediante l'uso congiunto di linguaggio naturale e immagini. Se l'interfaccia tra il modulo che fornisce le funzionalità interattive e il modulo che fornisce l'accesso al database è stata progettata con attenzione, allora i due moduli potranno evolvere indipendentemente. Per esempio, potremmo prima concentrarci sull'implementazione di una versione robusta del modulo di interazione e poi trasformare il prototipo del modulo dei DBMS in una versione realistica, senza influire sulla correttezza complessiva del sistema. In altre parole, se progettiamo interfacce stabili tra i vari moduli, questi potranno evolvere in maniera indipendente, dalla versione prototipale a quella finale. •

4.2.2.3

Ancora sui probabili cambiamenti

Gli esempi forniti nel paragrafo precedente rappresentano solo una piccola parte delle richieste di cambiamento di software che si possono incontrare nella pratica. I cambiamenti prevedibili possono essere suddivisi in poche classi. I moduli che implementano l'information hiding dovrebbero essere progettati per consentire queste classi di cambiamenti in maniera affidabile ed efficiente. Nel Paragrafo 4.1.1, abbiamo discusso un elenco di probabili cambiamenti: negli algoritmi, nella rappresentazione dei dati, nella macchina astratta sottostante, nell'ambiente sociale. L'incapsulamento all'interno di moduli attraverso XInformation hiding supporta questi cambiamenti. Per esempio, se dovessimo usare una procedura per incapsulare un algoritmo, modificare o addirittura sostituire l'algoritmo richiederebbe un cambiamento al corpo della procedura, e ciò può essere fatto senza intaccare i client della procedura. Analogamente, celando una struttura dati e fornendo un interfaccia astratta per accedervi e modificarla, possiamo proteggere gli utenti della struttura dati da cambiamenti nella sua rappresentazione interna. Le politiche sono un altro tipo di decisione di progetto che è bene incapsulare all'interno di moduli che implementano l'information hiding. Molte volte, hanno a che vedere con l'ordine in cui vengono eseguite certe operazioni. Ad esempio, supponiamo di progettare un modulo che fornisca ai clienti una lista ordinata di voci. Assumiamo anche le operazioni INSERT, per inserire una voce all'interno della lista, DELETE, per eliminare una voce della lista, PRINT, per stampare la lista dei nomi delle voci in ordine alfabetico. INSERT, DELETE e PRINT costituiranno l'interfaccia del modulo. Un tale modulo può nascondere diversi tipi di politiche; per esempio, in una certa politica la lista potrebbe essere mantenuta ordinata man mano che viene inserita o cancellata una voce; oppure in una politica incrementale, la lista potrebbe essere ordinata appena prima di essere stampata; oppure ancora, la lista non verrebbe mai mantenuta ordinata e sarebbe l'operazione di PRINT a stampare le voci nell'ordine corretto. Si può notare come un cambiamento di politica lasci indifferenti i clienti, visto che la politica è un segreto del modulo. La politica scelta avrebbe, comunque, ripercussioni sui tempi di esecuzione di ogni operazione. Ovvero, la prima politica renderebbe possibile supportare un'operazione di PRINT rapida, a costo di operazioni INSERT e DELETE più lente, visto che devono tenere la lista ordinata. Come ulteriore esempio di questo concetto, consideriamo il caso di un'applicazione concorrente, dove è indispensabile distinguere tra meccanismi e politiche. In questo tipo di applicazione, abbiamo bisogno di meccanismi che sospendano i processi nel caso debbano accedere a una risorsa condivisa (per esempio, una stampante o un buffer). Lo schedulatore sottostante dovrebbe poi utilizzare una determinata politica per risvegliare un processo sospeso; ad esempio, potrebbe risvegliare i processi sulla base di una politica puramente firstin, first-out, o potrebbe usare politiche più complesse basate, ad esempio, su concetti di priorità o tempi di esecuzione. Può essere implementata una qualsiasi di queste politiche, a patto di fornire un modulo che esporti meccanismi di sospensione e risveglio dei processi: •

suspend

(P) verrebbe invocata per sospendere il processo P



r é s u m é (P) verrebbe invocata per risvegliare il processo successivo; fornirebbe anche l'identificatore del processo risvegliato nel parametro di uscita.

Il modulo nasconderebbe la politica utilizzata per selezionare il successivo processo da risvegliare. Cambiamenti futuri nelle politiche si integreranno senza problemi con il resto del sistema: verrebbero alterate solo le prestazioni, non la correttezza.

Ciò che può essere celato dipende anche dal tipo di applicazione. Ad esempio, in molte applicazioni real-time, le politiche di schedulazione non possono essere nascoste ai moduli client, ma devono essere parte dell'interfaccia. Non sono nascoste nell'implementazione perché non possono essere cambiate indipendentemente dal volere dei clienti. Il fatto che certi eventi vengano trattati secondo una politica piuttosto che un'altra (per esempio FIFO piuttosto che eventi con priorità) potrebbe avere conseguenze negative sulla capacità di un modulo di reagire a uno stimolo entro determinati limiti di tempo, e ciò potrebbe causare effetti seri, pericolosi o addirittura catastrofici all'interno di un sistema real-time3. Esercizio 4.16

Discutete l'esempio della lista ordinata di voci nel caso di un'applicazione real-time. Un cambiamento nella politica p u ò avere effetti per i clienti? Perché? Perché no?

4.2.2.4

Prime conclusioni

Qualsiasi sia il metodo che scegliamo di seguire per la modularizzazione di un'applicazione, le interfacce dei moduli dovrebbero rappresentare solo le informazioni che i moduli client devono conoscere per poter utilizzare i servizi offerti. I progettisti degli altri moduli dovranno essere in grado di decidere se trarranno benefici dall'uso di quel modulo semplicemente esaminando l'interfaccia. Ovviamente, diviene necessario avere una notazione per descrivere le interfacce dei moduli in maniera precisa, in modo che nessuna ambiguità possa sorgere nell'interpretazione dei servizi esportati. Esamineremo la questione nel prossimo paragrafo (e parzialmente nel Capitolo 5). Prima di affrontare questo problema, comunque, si rendono necessari due commenti riassuntivi. Innanzitutto, una distinzione chiara tra l'interfaccia e l'implementazione e una definizione precisa di interfaccia sono necessarie per la (ri)usabilità di un modulo. Un modulo può essere (ri)usato in qualsiasi contesto, a patto che i servizi elencati nella sua interfaccia soddisfino le aspettative dei clienti, indipendentemente dall'implementazione. In secondo luogo, l'interfaccia deve contenere tutte le informazioni necessarie a caratterizzare il comportamento del modulo, come esso viene osservato dai clienti. Come abbiamo indicato, nella maggior parte dei casi, le interfacce forniscono descrizioni di funzioni che devono essere invocate dai moduli client. Possono anche fornire una descrizione di dati condivisi. Inoltre, nelle applicazioni real-time, il tempo di risposta di un'operazione esportata è parte dell'interfaccia.

4.2.3

Notazioni per la progettazione

Abbiamo finora discusso le problematiche di progettazione del software in modo informale: le architetture sono state descritte usando uno stile colloquiale. Ma la prosa italiana, o qualsiasi altra forma di descrizione basata sul linguaggio naturale, non è un mezzo

3

In realtà, spesso non è necessario - o utile - rendere esplicita la politica nell'interfaccia. Ad esempio, si potrebbe fornire una vista più astratta indicando limiti per i tempi di risposta di determinate operazioni.

adeguato per descrivere artefatti come i progetti software. Per una descrizione non ambigua sono necessari maggiore precisione, rigore e formalità. Gli ingegneri del software, dunque, hanno bisogno di notazioni speciali per la specifica dei loro progetti. In realtà ciò risulta vero per ogni campo dell'ingegneria. Per esempio, gli ingegneri elettrici producono progetti in cui apparecchiature complesse vengono descritte in termini di simboli grafici interconnessi che rappresentano dispositivi elementari come le resistenze, le capacità e i transistor. Questi dispositivi elementari possono essere visti come componenti standard assemblabili per produrre un nuovo sistema. Notazioni appropriate descrivono i tipi di dispositivi standard da assemblare, ad esempio, il voltaggio da fornire tra due punti dati o il valore, in ohm, delle resistenze. L'impostazione di tali progetti è standardizzata, e non sorgono ambiguità quando vengono interpretate le descrizioni durante la fase di costruzione del circuito. Il progetto può essere analizzato per scoprire inconsistenze o errori prima che cominci la fase di implementazione. Considerazioni simili possono essere fatte per l'ingegneria civile o meccanica: in tutti questi casi, i progetti vengono espressi mediante l'uso di una notazione grafica standard. Non è ancora emersa alcuna notazione standard per esprimere i progetti software anche se diverse proposte sono state prese in considerazione e alcune anche adottate nella pratica. UML ( Unified Modeling Language, linguaggio unificato di modellazione) è una combinazione di diverse notazioni primitive ed j; stato promosso a standard universale per la progettazione orientata agli oggetti. Nel seguito illustreremo due notazioni, una basata su una sintassi testuale simile a un linguaggio di programmazione (chiamata TDN) e l'altra basata su una sintassi grafica (chiamata GDN). Queste notazioni hanno molti aspetti in comune con le notazioni usate nella pratica. La ragione per cui abbiamo scelto di usare una nostra notazione è che non vogliamo essere distratti da dettagli di sintassi che non aggiungono molto all'espressività della notazione. Più tardi, quando affronteremo la progettazione orientata agli oggetti, faremo invece riferimento alla notazione standard UML. Le notazioni che introdurremo descrivono l'architettura software mediante la specifica di moduli e delle relazioni che intercorrono tra questi. La notazione è formale per quanto riguarda la sintassi delle interfacce. Ad esempio, dice, in una forma sintatticamente corretta, come formulare una richiesta per un servizio esportato da un modulo. Non specifica formalmente la semantica dei servizi esportati (e cioè, cosa realizza effettivamente un servizio, insieme a possibili limitazioni o proprietà che i clienti devono conoscere). La semantica è descritta solo informalmente, mediante l'uso di commenti. La specifica formale della semantica dei moduli verrà esaminata nel Capitolo 5. 4.2.3.1

TDN: notazione testuale per la progettazione

In questo paragrafo, illustreremo T D N , la nostra notazione testuale per la progettazione. Essa si ispira, in parte, alla sintassi dei linguaggi di programmazione tradizionali quali Ada o Modula-2, ma il suo obiettivo è quello di focalizzarsi su problematiche di modularizzazione. Di conseguenza, sono state aggiunte delle caratteristiche, e molti dettagli tipici dei linguaggi di programmazione sono deliberatamente ignorati. Inoltre, alcuni aspetti del linguaggio sono stati volutamente lasciati informali e possono essere completati dal progettista, in base alle preferenze, in accordo con il tipo di applicazione che si sta progettando, con il linguaggio di programmazione che verrà utilizzato per l'implementazione, etc. Soprattutto — a patto che il lettore conosca un linguaggio di programmazione modula-

re quale C++, Modula-2, Ada o Java - , la notazione non dovrebbe aver bisogno di molte spiegazioni. Assumiamo che un modulo possa esportare qualsiasi tipo di risorsa: una variabile, un tipo, una procedura, una funzione o qualsiasi altra entità definita dal linguaggio. Come abbiamo accennato, vengono utilizzati commenti per fornire informazioni di natura semantica circa i servizi esportati. In particolare, i commenti sono utilizzati per specificare il protocollo che il cliente deve seguire affinché i servizi esportati vengano forniti in maniera corretta. Ad esempio, il protocollo potrebbe richiedere che una data operazione, che esegue l'i— nizializzazione del modulo, sia chiamata prima di qualsiasi altra operazione. Oppure, il protocollo potrebbe richiedere che i clienti di un modulo per la gestione di una tabella non inseriscano voci se questa risulta essere già piena. In generale, se un modulo necessita che venga seguito un protocollo speciale per poter richiedere i servizi esportati, allora questo requisito dovrebbe essere specificato sotto forma di un commento associato alla descrizione sintattica del servizio esportato nell'interfaccia del modulo. Anche se riportato informalmente, il protocollo è una parte essenziale del contratto tra i clienti del modulo e l'implementatore del modulo, e dovrebbe essere concordato tra i progettisti e gli utenti di tali moduli. I commenti sono utilizzati anche per descrivere la natura esatta della risorsa esportata, una volta che il protocollo richiesto è stato soddisfatto dai clienti. Infine, i commenti vengono utilizzati per specificare aspetti dell'interfaccia che non corrispondono a risorse computazionali, quali le funzioni e le variabili, ma a tempi di risposta o altri aspetti. Le limitazioni di tempo e qualsiasi altro tipo di limitazioni aggiuntive o di proprietà delle entità esportate possono essere specificate mediante commenti in linguaggio naturale. Le parti della descrizione di un modulo fin qui discusse definiscono l'interfaccia, ovvero ciò che è visibile ai moduli client. T D N , in aggiunta, supporta la descrizione di altri aspetti dell'architettura che possono risultare necessari per la sua documentazione. In particolare, una parte u s e s specifica i nomi dei moduli utilizzati (se esistono), e una parte i m p l e m e n t a t i o n fornisce una descrizione di alto livello dell'implementazione, il che potrebbe risultare utile per capire il fondamento logico del modulo. Tipicamente, la parte i m p l e m e n t a t i o n fornisce un elenco dei componenti interni, in accordo a I S C O M P O S E D _ O F . Usando commenti informali possiamo descrivere anche quali segreti sono incapsulati all'interno del modulo e perché. Questa parte potrebbe rappresentare una linea guida per l'implementazione, oppure potrebbe documentare, dopo lo sviluppo, le importanti scelte implementative operate. In ogni caso, non riguarda i clienti. La Figura 4.6 fornisce un esempio di descrizione di un modulo mediante l'uso di TDN. Il lettore è invitato a leggerlo attentamente prima di procedere, osservando che la descrizione T D N non specifica un modulo preso singolarmente, ma piuttosto un modulo che è parte di un'architettura. Ciò che caratterizza un modulo dal punto di vista dei clienti (e cioè la sua interfaccia) è esattamente ciò che appare nella sezione e x p o r t s . Il resto della descrizione non ha a che vedere con l'interfaccia, ma serve a documentare l'architettura in modo preciso. Dunque, un cambiamento nella clausola e x p o r t s avrà delle conseguenze sulla correttezza funzionale dei clienti, mentre eventuali cambiamenti nelle altre sezioni non ne avranno. I benefici dell'utilizzare una notazione di progettazione come TDN, invece di una descrizione non strutturata e colloquiale, non risiedono solo nel rigore e nella precisione di ta-

module uses

X

Y, Z

exports type

var

A:

integer;

B: a r r a y

procedure

C

( 1 . . 1 0 ) of

(D:

in out

real ;

B; E:

in

integer;

F:

in

real);

Esempio di descrizione facoltativa, in linguaggio naturale, di cosa sono realmente A, B e C, insieme a possibili limitazioni o proprietà che i clienti devono conoscere; ad esempio, potremmo specificare che gli oggetti di tipo B spediti alla procedura C debbano essere inizializzati dal cliente e non contenere mai solo zero. implementation Se servono, seguono commenti generali della modu1arizzazione, suggerimenti is c o m p o s e d

of

R,

circa il fondamento sull'implementazione,

logico etc.

T

end X

Figura 4.6

Esempio di d e s c r i z i o n e di un m o d u l o mediante T D N .

le notazione, ma anche nel fatto che la descrizione del progetto diventa controllabile in termini di correttezza e completezza. Il controllo può essere svolto manualmente, esaminando attentamente la descrizione testuale, o meccanicamente nel caso venisse fornito un tool specifico in grado di eseguirlo. Nell'esempio della Figura 4.6, i moduli R e T dovranno prima o poi essere definiti; qualora ciò non avvenisse avremmo un caso di manifesta incompletezza. Dato che R e T sostituiscono a tutti gli effetti X, uno dei due o entrambi devono utilizzare Y o Z o entrambi, altrimenti la clausola u s e s sarebbe errata. Oltre a importare da Y o Z, R e T possono importare l'uno dall'altro. Inoltre, ciò che X esporta dovrebbe essere un sottoinsieme dell'unione degli insiemi di risorse esportate da R e T4. Tutti questi obblighi dovrebbero essere controllati per accertare la coerenza e la completezza della descrizione. Una descrizione corretta dei moduli R e T è fornita nella Figura 4.7. La clausola u s e s descrive, all'interno della specifica di un modulo, esattamente la relazione U S E S introdotta nel Paragrafo 4 . 2 . 1 . 1 . La clausola indica semplicemente che un modulo può accedere a qualsiasi risorsa esportata da un altro modulo. Potrebbe essere utile raffinare la clausola u s e s indicando esattamente quali risorse vengono importate dal modulo. Se ciò dovesse essere richiesto, useremmo la notazione uses

imports

( ) ;

Se nessuna clausola i m p o r t s viene specificata, tutte le risorse esportate possono essere importate dal modulo. Un esempio di un modulo W che usa i moduli X e XX è fornito nella Figura 4.8. L'esempio dimostra come W importi risorse specifiche da X (selettive import, import selettivo), e tutte le risorse esportate da XX.

Per semplificare a s s u m i a m o c h e gli insiemi esportati da R e T siano disgiunti.

module uses

R

Y

exports

var type

K: r e c o r d B: a r r a y

procedure

C

...

end;

( 1 . . 1 0 ) of

(D:

in out

real;

B; E:

in

integer;

F:

in

real);

i m p l e m e n t a t ion

end R

module uses

T

Y,

Z, R

exports

var A:

integer;

implementation

end

T

Figura 4 . 7

Esempio di c o m p o n e n t i del m o d u l o x nella Figura 4 . 6 .

Quando facciamo riferimento a un'entità E esportata da un modulo M, possiamo usare la cosiddetta dot notation M. E o, se non sorge alcuna ambiguità, semplicemente E. Potremmo continuare ad aggiungere nuove caratteristiche a T D N e a definire tutti i dettagli sintattici e semantici. Ad esempio, se un modulo facesse uso di diversi altri moduli e da loro importasse risorse aventi lo stesso nome (all'interno dei corrispettivi moduli esportatori), il linguaggio potrebbe fornire un modo per risolvere i conflitti di nome rinominando le risorse importate. Non seguiremo questa direzione, in modo da mantenere la notazione T D N il più possibile concisa e generale. Aggiungendo caratteristiche

module UB6S

W

X imports

(B,

C),

XX exports

...

implementation

end W

Figura 4.8

Esempio di un m o d u l o c o n import selettivo.

nuove a T D N , la renderemmo più simile ai linguaggi di programmazione, e questo limiterebbe il suo carattere generale. Lasciamo la possibilità di aggiungere nuove caratteristiche al progettista, qualora dovesse risultare utile. Se T D N dovesse essere utilizzata solo con un determinato linguaggio di programmazione, sarebbe possibile estenderlo con alcune caratteristiche specifiche a questo linguaggio. Ma ciò deve essere fatto con attenzione, visto che una notazione di progettazione utile dovrebbe rimanere lontana dai dettagli di basso livello di un linguaggio di programmazione. ESEMPIO 4 . 6

Gli Esempi 4.1 e 4.2 hanno introdotto il problema di scrivere un interprete per il linguaggio di programmazione MINI. Ora affronteremo il problema della definizione di un compilatore per MINI. Una possibile architettura potrebbe essere la seguente: module

COMPILER

exports

procedure

MINI

(PR0G:

in

file

of

char;

C O D E : out file of c h a r ) ; MINI è chiamato a compilare il programma contenuto produrre il codice oggetto nel file COD implementation

in PROG

e a

Un'implementazione convenzionale di compilatore. ANALYZER esegue un'analisi lessicale e sintattica e produce un albero astratto e una serie di voci nella tabella dei simboli; CODE_GENERATOR genera codice a partire dall'albero astratto e dalle informazioni immagazzinate nella tabella dei simboli. Il modulo MAIN svolge il compito di supervisione (job coordinator). is c o m p o s e d of A N A L Y Z E R , S Y M B O L T A B L E , ABSTRACT_TREE_HANDLER, end

CODE_GENERATOR,

MAIN

COMPILER

I moduli MAIN, ANALYZER e CODE GENERATOR sono specificati nel seguente modo: module

MAIN

uses ANALYZER, exports

C0DE_GENERAT0R

procedure

MINI

(PROG: CODE:

end

file

of

char;

file

of

char);

MAIN

module uses

in out

ANALYZER

SYMBOL_TABLE,

exports

procedure

ABSTRACTTREEHANDLER ANALYZE

(SOURCE:

in

file

of

Viene analizzato il codice sorgente (SOURCE). albero astratto usando i servizi forniti dal e vengono riconosciute le entità, con i loro immagazzinati nella tabella dei simboli.

char); Viene prodotto un gestore dell'albero attributi

end

ANALYZER

module

CODE_GENERATOR

uses SYMBOL_TABLE, exports procedure

ABSTRACT_TREE_HANDLER CODE

(OBJECT: out

file of

char);

L'albero astratto viene attraversato usando le operazioni esportate dal modulo ABSTRACT_TREE_HANDLER e accedendo alle informazioni contenute nella tabella dei simboli in modo da generare il codice nel file di output.

end

C0DE_GENERAT0R

Il lettore è invitato a completare la descrizione dei moduli rimanenti come esercizio. In particolare, per il modulo della tabella dei simboli, suggeriamo un ripasso degli Esempi 4.1 e 4.2. • Esercizio 4.17

La s t r u t t u r a del m o d u l o descritto nella Figura 4 . 6 rappresenta u n a gerarchia? Se n o n la rappresenta, c o m e si p o t r e b b e convertirla in u n a gerarchia? Se invece la rappresenta, c o m e si p o trebbe convertirla in u n a s t r u t t u r a n o n gerarchica?

4.2.3.2

GDN: notazione grafica per la progettazione

La ragione per cui gli ingegneri adottano frequentemente notazioni grafiche per i loro progetti è che sono più intuitive e più facili da capire rispetto alle descrizioni testuali, secondo il detto che un'immagine vale mille parole. In questo paragrafo, forniremo una notazione grafica per la progettazione (GDN) che rifletta la descrizione testuale (TDN) definita nel Paragrafo 4.2.3 e che assomigli alle notazioni grafiche cui abbiamo fatto riferimento in precedenza per descrivere le relazioni tra moduli. Un modulo è rappresentato da una scatola, le cui frecce in ingresso rappresentano la sua interfaccia (ad esempio, le risorse esportate). La ragione per cui le risorse esportate vengono indicate con frecce entranti è che le risorse esportate sono accessibili da fuori; ovvero, rappresentano un percorso di accesso verso l'interno del modulo. La Figura 4.9 fornisce una descrizione grafica del modulo x descritto testualmente nella Figura 4.6. Il fatto che X usi i moduli Y e Z viene indicato con frecce in neretto che connettono X con Y e Z. I dettagli delle risorse esportate — come il numero di parametri per le procedure, i loro tipi e i tipi delle variabili - vengono omessi per comodità, ma possono essere aggiunti come notazioni sulle frecce entranti. Una scatola è vuota se il modulo è elementare, ovvero se non è composto da sottocomponenti. Questo non è il caso del modulo X, che è composto da R e T. Siccome i mo-

Figura 4.9

Rappresentazione grafica del modulo x della Figura 4.6.

duli R e T sono componenti di x, possiamo espandere la loro definizione dentro x, secondo la Figura 4.7; il risultato è la descrizione rappresentata nella Figura 4.10. La Figura 4.10 mostra esplicitamente quali moduli interni diano origine effettivamente alle risorse esportate dal modulo X: B e C sono forniti da R, e A è fornito da T. Questa rela-

Figura 4.10

II modulo X è composto dai moduli R e i .

Modulo L

Modulo N

Modulo M

Modulo R

Modulo S

Modulo M Modulo H Modulo G

Figura 4.11

II modulo M è membro sia di L che di N.

zione è illustrata graficamente usando linee tratteggiate per unire le frecce di esportazione del modulo X con le corrispondenti frecce di esportazione dei moduli R e T. In maniera simile, linee tratteggiate in neretto vengono impiegate per specificare chi, tra R e T, faccia effettivamente uso dei moduli usati da X. Se un modulo M è componente di entrambi i moduli L e N allora disegniamo una scatola intitolata M all'interno sia di L che di N. Se M dovesse essere composto da altri moduli, la struttura I S _ C O M P O S E D _ O F verrebbe descritta separatamente. Ciò è illustrato nella Figura 4.11 per il caso in cui M sia composto da G e H. Esercizi 4.18

Fornite una descrizione T D N del m o d u l o T nella Figura 4.10.

4.19

Descrivete la struttura del m o d u l o nella Figura 4.6, utilizzando G D N .

4.20

Usando T D N , descrivete la struttura del m o d u l o nella Figura 4.6.

4.2.4

Categorie di moduli

I moduli possono essere progettati per esportare qualsiasi combinazione di risorse (variabili, tipi, procedure, eventi, eccezioni, etc.). Ovviamente la natura delle risorse esportate dipende anche da cosa è effettivamente supportato dal linguaggio di programmazione utilizzato per implementare il modulo. In generale, i moduli possono essere classificati in categorie standard. Una tale categorizzazione è utile in quanto fornisce uno schema di classificazione per la documentazione ed, eventualmente, per il ritrovamento all'interno di una libreria di moduli. Inoltre, usare un insieme limitato di categorie di moduli rende un progetto più uniforme e standard. Come abbiamo visto nel Capitolo 2, parti standard sono l'indicazione di una disciplina inge-

gneristica matura, e quindi la categorizzazione dei moduli rappresenta un passo verso lo sviluppo di componenti standard per l'ingegneria del software. In questo paragrafo illustreremo tre categorie standard: le astrazioni procedurali, le librerie e i pool di dati comuni. Due categorie più generali e di più alto livello saranno illustrate nei successivi paragrafi: oggetti astratti e tipi di dati astratti. Un tipo di modulo che viene comunemente usato è quello che fornisce semplicemente una procedura o una funzione che implementa alcune operazioni astratte. In altre parole, tali moduli forniscono uri astrazione procedurale che viene utilizzata per incapsulare un algoritmo. Tipici esempi sono i moduli di ordinamento, i moduli per la trasformata di Fourier e i moduli che effettuano la traduzione da un linguaggio a un altro. L'utilità delle astrazioni procedurali è stata riconosciuta fin dalle origini dell'informatica e i linguaggi di programmazione hanno fornito un supporto speciale attraverso l'uso delle funzioni. Un modulo può anche contenere un gruppo di astrazioni procedurali correlate tra loro. Un esempio tipico è rappresentato dalle librerie di funzioni matematiche, che forniscono soluzioni ai problemi matematici più comuni, come quelli riguardanti integrali e derivate. Un altro esempio può essere quello di una libreria che fornisca funzioni per le operazioni algebriche sulle matrici. Ancora un altro esempio potrebbe essere quello di una libreria per la manipolazione grafica di oggetti. Moduli di questi tipi vengono usati per riunire in un unico pacchetto un insieme di funzioni. Usiamo il termine libreria per indicare questa classe di moduli. Un altro tipo comune di modulo fornisce un pool comune di dati. Una volta riconosciuta la necessità di condividere dati tra diversi moduli, possiamo raggruppare questi dati in un pool comune che viene importato da tutti i moduli client, i quali possono dunque manipolare i dati direttamente, in accordo con la struttura utilizzata per rappresentarli, che diventa a loro visibile. Un uso interessante di un modulo di pool comune di dati è quello per cui si raggruppano in un unico luogo le costanti di configurazione di un sistema. Ad esempio, supponiamo che il supervisore di un sistema di controllo sia parametrizzato rispetto al numero di linee e alla lunghezza del buffer in cui vengono immagazzinati temporaneamente gli input. Ogni installazione del sistema di controllo richiede che vengano assegnati valori costanti a questi parametri, cui accedono i diversi moduli che compongono il sistema di controllo. Una soluzione tipica potrebbe consistere nel raggruppare tutte le costanti di configurazione in un pool comune di dati cui accedere con facilità durante la fase di configurazione. In generale, comunque, un pool comune di dati rappresenta un modulo di livello piuttosto basso. Tale modulo non fornisce alcuna forma di astrazione, tutti i dettagli dei dati sono visibili e manipolabili dai clienti. La possibilità di raggruppare dati condivisi in un blocco comune fornisce semplicemente un aiuto limitato in termini di leggibilità e modificabilità. Stabilire pool comuni di dati viene implementato facilmente nei linguaggi di programmazione convenzionali. Per esempio, può essere realizzato in FORTRAN mediante il costrutto C O M M O N , o in C e Java con l'uso delle variabili statiche. La maggior parte degli esempi forniti nei Paragrafi 4.2.2 e 4.2.3, tuttavia, necessitano di moduli più astratti che possano nascondere strutture dati particolari, rendendole segreti dei moduli. Ad esempio, il modulo della tabella dei simboli usato nell'interprete (Esempio 4.1 ed Esempio 4.2) e nel compilatore (Esempio 4.6) del linguaggio MINI nasconde la struttura dati specifica utilizzata per rappresentare la tabella ed esporta le operazioni necessarie per acce-

dervi. Questo modulo è un esempio di un importante tipo di moduli, che uniscono in un solo pacchetto sia dati sia funzioni (che verrà affrontato in seguito). 4.2.4.1

Oggetti astratti

Abbiamo già accennato al fatto che il 17 per cento dei costi della manutenzione del software derivi dai cambiamenti nelle rappresentazioni dei dati. Dunque, un tipo di incapsulamento molto importante è quello che nasconde i dettagli delle rappresentazioni dei dati e protegge i clienti dai cambiamenti che potrebbero essere effettuati nei confronti di queste rappresentazioni. Una tabella dei simboli è un tipico modulo che nasconde la struttura dati (come fosse un segreto) ed esporta funzioni che possono essere utilizzate come operazioni per accedere ai dati nascosti e modificare i valori immagazzinati in essi. Dovesse cambiare la struttura dati, tutto ciò che dovremmo cambiare sarebbero gli algoritmi che implementano le funzioni di accesso, mentre i moduli client non dovrebbero essere cambiati, visto che continueranno a usare le stesse chiamate per eseguire gli accessi richiesti. Dal punto di vista delle interfacce, questi tipi di moduli assomigliano a librerie. Possiedono, però, una proprietà speciale che le librerie matematiche non hanno: dispongono di una struttura dati permanente, nascosta e incapsulata nella loro parte implementativa, visibile alle funzioni interne al modulo ma nascosta dai moduli client. Nell'esempio della tabella simbolica, la struttura dati viene utilizzata per immagazzinare le voci, man mano che sono inserite nella tabella. La struttura dati nascosta fornisce a questi moduli uno stato. Infatti, come conseguenza delle chiamate alle funzioni esportate dal modulo, i valori immagazzinati nella struttura potrebbero cambiare da una chiamata all'altra; dunque, i risultati ottenuti da due chiamate con gli stessi parametri possono differire. Questo comportamento è diverso da quello di un pool di funzioni che costituiscono una libreria, in quanto la libreria non possiede uno stato: due chiamate successive alla stessa funzione con gli stessi parametri restituiranno sempre lo stesso risultato. La differenza tra un modulo con stato e moduli librerie non è visibile attraverso la sintassi dell'interfaccia. In entrambi i casi il modulo esporta semplicemente un insieme di funzioni. Distingueremo, comunque, tra queste due tipologie di moduli nel nostro schema di classificazione. Moduli che esibiscono uno stato verranno chiamati oggetti astratti. Useremo un commento per indicare che un modulo è un oggetto astratto. ESEMPIO 4 . 7

Le espressioni aritmetiche possono essere scritte senza l'uso di parentesi impiegando la cosiddetta notazione postfissa polacca, dove gli operatori seguono i loro operandi. Un esempio di espressione scritta in forma postfissa polacca è a b c + * ,

che corrisponde all'espressione infissa a *

(b+c).

Restringiamo la nostra attenzione alle espressioni aritmetiche con sole operazioni binarie e operandi interi. Inoltre, assumiamo che la stringa di input sia un'espressione postfissa dalla sintassi corretta.

Un modo per valutare le espressioni postfisse è quello di utilizzare una struttura last-in, first-out, ovvero una pila (stack). L'espressione viene letta da sinistra verso destra e i valori degli operandi sono depositati sulla pila man mano che vengono incontrati. Quando il simbolo letto è un operatore, i due valori in cima alla pila sono prelevati, l'operatore è applicato ai due operandi e il risultato inserito in cima alla pila. Come esempio, il lettore è invitato a simulare manualmente la valutazione dell'espressione a b c + *,dove a=2 , b=3 e c = 5 . Le pile possono essere implementate in diversi modi, come può illustrare un qualsiasi libro di testo sulle strutture di dati. Se la loro dimensione è limitata possiamo utilizzare un array, altrimenti potremmo utilizzare una lista concatenata. Se volessimo incapsulare una pila all'interno di un modulo potremmo definire la seguente interfaccia: exports procedure

PUSH

procedure

P0P_2

(VAL:

in

(VALI,

integer); V A L 2 : out

integer);

La procedura PUSH verrebbe utilizzata per inserire un nuovo operando in cima alla pila; la procedura POP_2 verrebbe utilizzata per estrarre la coppia di operandi che si trova in cima alla pila. La parte nascosta del modulo potrebbe allora scegliere una struttura dati qualsiasi per rappresentare la pila; la struttura dati risulterebbe un segreto del modulo. • La progettazione dell'oggetto astratto descritto nell'Esempio 4.7 potrebbe essere criticata in quanto fornisce un'operazione specializzata per rimuovere contemporaneamente due elementi dalla pila. Questo approccio è utile quando abbiamo solo operatori binari, ma fallisce nel momento in cui estendiamo il valutatore di espressioni a casi più generali dove possiamo avere anche operatori unari. Per poter trattare sia operatori binari che unari, si potrebbe fornire un'operazione di POP che estrae un elemento alla volta per poi lasciare che il modulo client esegua l'operazione due volte quando necessario. Un altro difetto dell'Esempio 4.7 è che il progetto si basa sull'assunzione che l'espressione da valutare sia corretta. Se ciò non dovesse essere vero, verrebbe generato un errore a run-time qualora tentassimo, ad esempio, di estrarre un elemento da una pila vuota. Un progetto più affidabile definirebbe un'altra funzione da esportare nell'interfaccia, chiamata per esempio EMPTY, la quale restituirebbe un risultato booleano per indicare se la pila fosse vuota. Ovviamente, questo progetto non previene l'errore a run-time, ma fornisce al cliente un modo per evitarlo. Notate come una soluzione del genere richieda più lavoro da parte del cliente, ma è il prezzo da pagare per rendere il nostro progetto maggiormente riutilizzabile e affidabile. Esercizi 4.21

Riprogettate l'interfaccia del m o d u l o pila affinché prenda in considerazione i commenti appena fatti. Discutete, inoltre, l'uso di una struttura dati di dimensioni fisse per l'implementazione della pila. In base a quali premesse risulterebbe corretta l'interfaccia? Il modulo risulterebbe veramente riusabile? Se così non fosse, come p o t r e m m o migliorare la sua riusabilità?

4.22

U n m o d u l o di o u t p u t è utilizzato per la s t a m p a di caratteri singoli. Il cliente vede che l'outp u t avviene un carattere alla volta. Il m o d u l o di o u t p u t , però, n a s c o n d e il m o d o esatto in cui viene eseguito l ' o u t p u t . C i ò p e r m e t t e la progettazione di u n a famiglia di p r o d o t t i dove i m e m bri differiscono per il tipo di periferica cui rivolgono il loro o u t p u t . Alcune periferiche eseg u i r a n n o l ' o u t p u t carattere per carattere, m e n t r e altre r i u n i r a n n o p i ù caratteri in sequenze più l u n g h e e a g g i u n g e r a n n o caratteri di controllo. Classifichereste questo m o d u l o di o u t p u t com e un'astrazione procedurale o u n o g g e t t o astratto? I m p o s t a t e le descrizioni T D N e G D N del m o d u l o di o u t p u t nel caso in cui l ' o u t p u t fisico sia eseguito da u n m o d u l o (hardware) c o n un b u f f e r lungo 16 caratteri.

4.2.4.2 Tipi di dati astratti In questo paragrafo introdurremo i moduli per la descrizione di tipi di dati astratti, un'ulteriore categoria di moduli che ci possono aiutare nella strutturazione uniforme e standard dei nostri progetti. Useremo l'Esempio 4.7 per motivare l'introduzione di questa nuova categoria. L'esempio faceva uso di un oggetto astratto rappresentante una pila. Ma cosa succederebbe nel caso in cui un'applicazione dovesse richiedere più di una pila? In questa situazione avremmo bisogno di definire un tipo per poi generarne diversi esemplari. Avremmo bisogno anche di un sistema (a) per associare un insieme di funzioni con un tipo, in modo da poter manipolare gli esemplari di quel tipo, e (b) per incapsulare i dettagli del tipo all'interno del modulo, in modo che possa essere modificato senza conseguenze per l'interfaccia. La Figura 4.12 illustra questo tipo di modulo mediante l'uso della nostra notazione di progettazione testuale. Un nuovo espediente notazionale è introdotto nella figura: il simbolo "?", che viene utilizzato per esportare la definizione di un tipo, celando i dettagli della struttura dati corrispondente, posta nella parte implementativa del modulo. Il fatto che venga esportato un tipo permette ai moduli client di dichiarare variabili di quel tipo; il fatto che rimanga nascosta la definizione del tipo implica, però, che le variabili di quel tipo possono essere manipolate solamente da procedure o da funzioni esportate dal modulo, visto che sono le sole

module

STACKHANDLER

exports type STACK = ? ; Questo è un modulo di tipo di dato è un segreto nascosto nella parte procedure PUSH procedure POP function

EMPTY

(S: in out

STACK

(S: in out STACK (S: in STACK)

astratto ; la struttura implementativa.

; VAL: in

integer);

; VAL: out

integer);

: BOOLEAN;

end STACK

HANDLER

Figura 4.12

Modulo di tipo di dato astratto in T D N .

dati

a "conoscerne" i segreti. I moduli client dovranno passare le variabili del tipo in questione alle funzioni esportate perché possano essere manipolate. Un modulo tipo di dati astratto è un modulo che esporta un tipo, insieme alle operazioni necessarie ad accedere e manipolare oggetti di quel tipo; inoltre, cela la rappresentazione del tipo e gli algoritmi utilizzati all'interno delle operazioni. Tale modulo può essere realizzato direttamente in Ada esportando un tipo privato (limitato), in Modula-2 esportando un tipo opaco, e in Java e C++ mediante l'uso di classi. Gli esemplari di un tipo di dato astratto sono oggetti astratti che si comportano esattamente come quelli precedentemente discussi. In particolare, possono essere manipolati solo dalle funzioni implementate ed esportate dal modulo tipo di dato astratto5. Tali funzioni possono includere quelle necessarie per assegnare un oggetto astratto a una variabile e quelle necessarie per confrontare due oggetti astratti e verificare se sono uguali. Per semplificare la notazione, invece di elencare questi operatori insieme alle funzioni esportate, useremo per loro gli operatori convenzionali ": = " e "=" ed elencheremo gli operatori dopo il "?" nella clausola di tipo. Dunque, scrivere type

A_TYPE:

?

(:=,

=);

all'interno dell'interfaccia di un modulo significa che i clienti possono assegnare un oggetto del tipo A T Y P E a una variabile del medesimo tipo e confrontare due oggetti del tipo A T Y P E per vedere se sono uguali. Se il simbolo ": = " o "=" manca nella dichiarazione del tipo, la corrispondente operazione non è consentita ai client. ESEMPIO 4 . 8

Supponiamo di voler progettare un sistema per la simulazione di un'area di servizio. L'obiettivo del sistema sarà quello di scoprire la "dimensione ottimale" (in termini di numero di linee di servizio, lunghezza delle linee, etc.) dell'area di servizio, conoscendo la frequenza di arrivo delle auto e l'entità delle richieste di servizio. Ogni richiesta di servizio sarà caratterizzata da una certa durata. Rappresentiamo ogni linea di servizio (benzina, auto-lavaggio, etc.) con un oggetto astratto che rappresenti le auto in attesa di essere servite. Ci sarà un'operazione per aggiungere un'auto alla linea di servizio, una per estrarre un'auto dalla linea, una per vedere se la linea è vuota e una per unire due linee associate allo stesso tipo di risorsa, qualora dovesse esaurirsi la risorsa fornita da una di esse. La politica sarà rigorosamente first-in, first-out (FIFO) per tutte le linee di servizio. Introduciamo un modulo tipo di dato astratto chiamato F I F O C A R S che descrive la coda FIFO delle auto. Assumiamo che le auto siano descritte da un altro modulo di tipo di dato astratto C A R S , il quale esporti il tipo CAR, utilizzato da F I F O C A R S per eseguire le operazioni sulle auto estratte dalle code. Ciò che segue è una bozza del modulo FIFO

CARS:

5 L'unica differenza sintattica nel caso di un esemplare di un tipo di dato astratto è che l'oggetto a cui deve essere applicata un'operazione è un parametro dell'operazione.

nodale uses

FIFOCARS

CARS

exports type

QUEUE

procedure

: ?; ENQUEUE

procedure DEQUEUE function IS_EMPTY function LENGTH procedure MERGE Q u e s t o è un modulo

(Q:

in out

QUEUE;

C:

in

(Q: in out Q U E U E ; C: out (Q: in Q U E U E ) : B O O L E A N ;

CARS); CARS);

(Q: in Q U E U E ) : N A T U R A L ; (Ql, Q 2 : in Q U E U E ; Q: out Q U E U E ) ; tipo di dato astratto che rappresenta

una

coda

di automobili, gestita rigorosamente secondo una politica FIFO; le code non p o s s o n o essere assegnate o confrontate per vedere se s o n o uguali, visto che e "=" non vengono esportati.

end

FIFOCARS

Questo modulo permette ad altri moduli di dichiarare esemplari del tipo esempio gasolinel, carwash:

gasoline_2,

gasoline_3:

QUEUE,

come ad

QUEUE;

QUEUE;

e operare su di loro usando le operazioni esportate. Per esempio, potremmo scrivere ENQUEUE MERGE

(car_wash,

(gasolinel,

thatcar); gasoline_2,

gasoline_3);

Esiste un motivo importante per distinguere tra moduli oggetti astratti e moduli tipi di dati astratti, anche se un oggetto astratto può indubbiamente essere ottenuto generando un esemplare del tipo di dato astratto incapsulato nel modulo. La ragione è che, intrinsecamente, un modulo tipo di dato astratto può generare un qualsiasi numero di istanze, mentre sappiamo a priori che oggetti astratti esistono solo in un singolo esemplare. Inoltre, un modulo oggetto astratto possiede uno stato, mentre un modulo tipo di dato astratto non lo possiede. Nei linguaggi orientati agli oggetti, i due concetti sono congiunti, in quanto i tipi di dati astratti vengono implementati da classi e gli oggetti astratti esistono solo al momento dell'esecuzione, come esemplari di un tipo di dato astratto. • Esercizio 4.23

U n m o d u l o p e r la g e s t i o n e d i c h i a v i f o r n i s c e u n a c h i a v e o g n i v o l t a c h e n e v i e n e r i c h i e s t a u n a d a u n c l i e n t e . P u ò i n o l t r e c o n f r o n t a r e d u e c h i a v i p e r v e d e r e se s o n o u g u a l i e d e t e r m i n a r e q u a le delle d u e sia p i ù p i c c o l a . P r o g e t t a t e il m o d u l o d i g e s t i o n e d e l l e c h i a v i u s a n d o T D N .

4.2.4.3

Moduli generici

In questo paragrafo introdurremo un'estensione di T D N in grado di aiutare nella produzione di componenti riusabili. L'estensione, chiamata moduli generici, può essere motivata tornando all'Esempio 4.7. In quell'esempio, le scelte di come immagazzinare i valori e di

come gestire la struttura LIFO venivano nascoste ai clienti mediante un'interfaccia che elencava le operazioni appropriate da invocare. Se volessimo valutare espressioni di altri tipi, ad esempio con valori reali o booleani, dovremmo fornire moduli nuovi, specializzati. Tutti questi moduli si comporterebbero, comunque, in maniera simile, differenziandosi solo per i tipi di valori immagazzinati nei loro stack. Sarebbe utile poter fornire una singola descrizione (astratta) per tutti i moduli che implementano un oggetto astratto, fattorizzando tutte le variazioni in un singolo modulo, invece di duplicarle in una serie di moduli quasi identici. Fornendo una sola descrizione per tutti i moduli, eliminiamo la possibilità (e il rischio) di avere inconsistenze tra i diversi moduli; inoltre, confiniamo le possibili modifiche a una sola unità. Otteniamo, così, un singolo componente più facilmente riusabile. Una soluzione a questo problema viene fornita estendendo T D N in modo che possa supportare moduli generici. Un modulo generico è un modulo che viene parametrizzato rispetto a un tipo. Nel nostro caso scriveremmo generic uses

module

GENERIC_STACK_2

(T)

. . .

exports

end

procedure

PUSH

procedure

P0P_2

(VAL

: in

(VALI,

T);

VAL 2

: out

T);

GENERIC_STACK_2

In questo esempio, il modulo G E N E R I C _ S T A C K _ 2 risulta essere generico rispetto al tipo T: le funzioni PUSH e P 0 P _ 2 , inoltre, richiedono parametri di quel tipo. Un modulo generico non è direttamente utilizzabile dai clienti. Infatti, in senso stretto, non è neppure un modulo, ma piuttosto un template di modulo. Per poterlo utilizzare, deve prima essere ¡stanziato fornendo parametri reali. Ad esempio, per ¡stanziare un modulo di stack per interi, scriveremmo module

INTEGER_STACK_2

is

GENERIC_STACK_2

(INTEGER)

Se dovessero esistere limitazioni sui possibili tipi utilizzabili come parametri al momento di istanziazione del modulo, queste dovrebbero essere specificate nell'interfaccia del modulo generico mediante l'uso di commenti. Se il modulo generico dovesse richiedere il supporto a una data operazione da parte del tipo di parametro, ciò dovrebbe essere specificato nell'intestazione del modulo. Ad esempio, generic uses

end

module

M(T)

with

OP(T)

. . .

M

indicherebbe che l'operazione OP deve essere supportata da un qualsiasi parametro passato al modulo M al momento dell'istanziazione. Sempre al momento dell'istanziazione, deve essere passata una procedura come parametro insieme al tipo, come nella dichiarazione:

modulo

M_A_TYPE

is M ( A _ T Y P E )

PROC

(M A

TYPE)

Come hanno dimostrato i precedenti esempi, i moduli generici consentono ai progettisti software di fattorizzare diversi algoritmi all'interno di una singola rappresentazione, astratta e generica, che deve essere ¡stanziata prima dell'uso. Un esempio tipico sarebbe un modulo generico di ordinamento parametrico rispetto al tipo di elementi da ordinare. Quindi un modulo generico è, intrinsecamente, un componente riusabile in quanto fattorizza diversi moduli in un'unica astrazione algoritmica, facile da riutilizzare in contesti diversi. Situazioni simili possono nascere nel caso di tipi di dati astratti, i quali spesso possono essere scritti come moduli generici, e poi ¿stanziati in vari moduli specializzati. Così, nell'Esempio 4.8, abbiamo introdotto un modulo per la rappresentazione di code FIFO di automobili. Supponiamo ora di voler modellizzare le code in banca, dove i clienti si mettono in fila, in attesa di essere serviti. In entrambi i casi dobbiamo descrivere che cosa sia una coda; l'unica differenza risiede nel tipo di oggetti che inseriamo in coda. Possiamo dunque, ancora una volta, risolvere il problema definendo un modulo tipo di dato astratto generico (chiamandolo GENERIC FIFO QUEUE) per poi generare le istanze di modulo necessarie.

L'uso di moduli generici può essere visto come un'applicazione del principio di generalità. Ad esempio, invece di risolvere un problema specifico per gli interi, risolviamo un problema più generico per una classe di tipi. Soluzioni specializzate possono essere successivamente derivate dalla soluzione generale. Visti così, i moduli generici possono risultare utili per lo sviluppo di famiglie di programmi. Esercizi 4.24

Definite precisamente il m o d u l o G E N E R i c _ F i F O _ Q U E U E e ¡stanziate un m o d u l o che rappresenti il tipo di dato astratto "coda di valori interi". Dimostrate, quindi, come si possa successivamente generare un esemplare di oggetto astratto.

4.25

Abbiamo descritto un m o d u l o generico come parametrizzato da tipi. Indicate altre possibilità per la parametrizzazione di moduli.

4.26

Fornite un esempio di m o d u l o che consenta l'ordinamento di un array di elementi di tipo qualsiasi. È necessario che sia possibile confrontare gli elementi del tipo scelto per vedere quale sia il maggiore.

4.2.5

Tecniche specifiche per la progettazione in vista del cambiamento

In questo capitolo abbiamo finora presentato un insieme di metodi generali che possono essere utilizzati per la progettazione di software ben strutturato, che possa essere facilmente compreso e modificato. Questi metodi sono anche utili per il raggiungimento di due obiettivi significativi: la produzione di famiglie di programmi e la generazione di componenti riusabili. La modularizzazione mediante information hiding può essere utilizzata per incapsulare le differenze esistenti tra i diversi membri di una famiglia, in modo che queste differenze siano invisibili al di fuori del modulo generico. In modo del tutto simile, la definizione di interfacce semplici, non ridondanti e chiare, può favorire il riutilizzo di moduli: per capire se un modulo è riusabile, occorre fare riferimento alla sua interfaccia. Come abbiamo accennato prima, la riusabilità viene ulteriormente aumentata dalla generalità.

Come complemento al principio generale dell'information hiding e ai metodi fin qui discussi, i paragrafi successivi illustreranno alcune tecniche specifiche di implementazione di moduli che facilitano il cambiamento. 4.2.5.1

Costanti di configurazione

La modifica di software risulta diffìcile quando informazioni specifiche, che devono essere cambiate, sono inserite e sparpagliate nel codice del programma. Come semplice esempio, consideriamo la dimensione di una tabella di interi che viene inizialmente impostata a 10, ma che si richiede diventi 50. Il sistema iniziale potrebbe contenere dichiarazioni come a: array

(1..10) of

integer;

nel caso volessimo realizzare una copia in locale della tabella. Per controllare che un intero k, utilizzato come indice nella tabella, non ecceda i suoi confini, il programma potrebbe contenere un'affermazione del genere if k > 1 and k s esegui

10

then

indicizzazione

else esegui

altre

operazioni

end 1 f ; Chiaramente, modificare il limite superiore dell'array a 50 richiederebbe un cambiamento sia nelle dichiarazioni che nelle righe di codice come quelle appena mostrate. Tuttavia, nei casi in cui i cambiamenti richiesti possono essere raggruppati in una serie di costanti (chiamate costanti di configurazione), il problema può essere risolto cambiando i valori di quelle costanti e ricompilando il programma. Molti linguaggi, come C, Ada, Java e C++, forniscono le costanti simboliche come soluzione al problema di rendere i programmi facilmente adattabili al cambiamento delle costanti di configurazione. Essendo costanti, i dati di configurazione non possono essere alterati inavvertitamente dal programma; essendo simboliche, possono possedere nomi in grado di suggerire il loro significato, migliorando così la leggibilità e la modificabilità. Come abbiamo accennato in precedenza, le costanti di configurazione possono essere raggruppate in un unico modulo per fornire un pool comune di dati. Questo modulo, poi, verrebbe utilizzato da tutti i clienti che devono accedere ai dati di configurazione. Un altro esempio dell'uso di costanti simboliche di configurazione potrebbe essere il caso di un gestore di un dispositivo in cui le lunghezze dei buffer possono cambiare di configurazione in configurazione. Ogni configurazione potrebbe essere vista come un diverso membro della stessa famiglia, e i diversi membri di questa famiglia potrebbero essere generati ricompilando l'applicazione con valori diversi delle costanti di configurazione. Esercizio 4.27

C a m b i a r e il valore di u n a costante di c o n f i g u r a z i o n e richiede la ricompilazione. E sempre necessario eseguire u n a ricompilazione completa (ad esempio, u n a compilazione di tutti i m o duli)? Discutete il p r o b l e m a f o r n e n d o esempi in C , Pascal, M o d u l a - 2 , Java, C++ o Ada.

4.2.5.2

Compilazione condizionale

Le costanti di configurazione supportano un modo molto semplice per rappresentare il software multi-versione. Possono essere forniti schemi più generali e flessibili con la compilazione condizionale. Con questo approccio, tutte le versioni di una famiglia sono rappresentate da una singola copia di programma sorgente, mentre le differenze tra le varie versioni vengono prese in considerazione dalla compilazione condizionale. Il codice sorgente rilevante solo per alcune versioni viene racchiuso da macro comandi riconosciuti dal compilatore. Quando viene invocato il compilatore, è necessario che siano stati specificati alcuni parametri per la descrizione di quale versione del codice oggetto debba essere prodotta; il compilatore ignorerà automaticamente le righe di codice che non siano parte della versione corretta. ESEMPIO 4 . 9

Supponiamo ci venga richiesto di scrivere un programma in cui alcune parti (ad esempio, i driver per alcune periferiche) debbano essere adattate a un certo hardware. Durante la progettazione, cercheremo di tener separate queste parti da quelle che non dipendono dallo specifico hardware. Nel caso in cui il programma finale debba essere scritto nel linguaggio di programmazione C, potremo poi utilizzare il preprocessore C per specificare quali parti debbano essere utilizzate per l'architettura hardware scelta. Ecco uno schema di come potrebbe essere il programma C: ...frammento #

ifdef

#

endif

#

ifdef

#

endif

di

codice

sorgente

comune

a tutte

le

versioni...

hardware-1 ...frammento

per

hardware

1...

per

hardware

2...

hardware-2 ...frammento

Se dovessimo specificare, al momento della compilazione, lo switch D = solo il codice associato a h a r d w a r e - 1 verrebbe compilato.

hardware-1, •

Esercizi 4.28

Discutete l'efficacia della compilazione condizionale nel caso in cui le differenze tra le varie versioni dovessero diventare via via sempre più complesse.

4.29

C o m e si p o t r e b b e r o utilizzare i costrutti generici di A d a per raggiungere gli stessi risultati dell'Esempio 4.9, senza affidarsi alla compilazione condizionale?

4.2.5.3

La generazione del software

Le costanti simboliche e la compilazione condizionale consentono a un software di evolvere e di essere, allo stesso tempo, sufficientemente generalizzato da poter coprire alcuni cam-

biamenti previsti e in grado di essere specializzato al momento della compilazione. Un'ulteriore strategia interessante è quella di generare automaticamente una nuova soluzione per ogni cambiamento richiesto. I generatori sono stati utilizzati con successo in alcuni domini applicativi ristretti. Un tipico esempio è un generatore di compilatori, come yacc nell'ambiente UNIX, il quale è in grado di generare (parte di) un compilatore, data la definizione formale del linguaggio che deve essere tradotto. Se decidessimo di cambiare il linguaggio sorgente per il quale abbiamo sviluppato un compilatore, non dovremmo modificare direttamente il compilatore; piuttosto, rieseguiremmo yacc applicandolo alla definizione del nuovo linguaggio. Questo approccio risulta particolarmente utile quando il linguaggio sorgente non è ancora "congelato", ma può essere soggetto a modifiche. Un ulteriore esempio è un sistema per la generazione di interfacce utenti; tale sistema può essere facilmente trovato all'interno della maggior parte dei DBMS su personal computer. In questi sistemi, la disposizione dei pannelli utilizzati per interagire con l'utente viene "disegnata" sul monitor del computer. La descrizione dichiarativa è poi automaticamente trasformata in azioni run-time che supportano l'interazione tra l'utente e l'applicazione. Si può quindi modificare la disposizione del monitor secondo le preferenze dell'utente, senza aggiunta di codice, mediante la semplice rigenerazione. Verranno forniti ulteriori esempi di generatori di software nel Capitolo 5, dove illustreremo come alcuni linguaggi di specifica possano risultare eseguibili. In alcuni casi è possibile tradurre la specifica in un'implementazione, generando, dunque, l'applicazione direttamente dalla descrizione astratta. Anche se, attualmente, questo approccio non rappresenta una pratica comune, viene utilizzato in domini ristretti all'interno di molti ambienti di produzione software. Affronteremo nuovamente l'argomento nel Capitolo 9.

4.2.6

Raffinamento per passi successivi

I corsi introduttivi di programmazione focalizzano, molte volte, l'attenzione degli studenti su approcci sistematici alla progettazione e convalida dei programmi. L'approccio più popolare che viene seguito è detto progettazione per raffinamenti o per passi successivi. Si tratta di una strategia di progettazione facile da descrivere e da capire. Come indica chiaramente il suo nome, il raffinamento per passi successivi è un processo iterativo. A ogni passo, il problema da risolvere viene scomposto in sotto-problemi da risolvere separatamente. Le sotto-soluzioni che costituiscono la soluzione del problema originale vengono poi riunite mediante l'uso di strutture di controllo semplici. Possono essere eseguite in sequenza, selezionate alternativamente o iterate ciclicamente. Dunque, dato P, dichiarazione originale del problema, P^ P2, . . ., P„, dichiarazioni dei sotto-problemi, e C un'espressione booleana che rappresenti una condizione, P potrebbe essere scomposto e risolto seguendo uno dei seguenti modelli: (1)

P,;

(2)

if

P2; C

then

P„

end (3)

i£;

whi1e

C

loop P,;

end

loop;

Molte volte, abbiamo bisogno di esprimere una selezione multi-ramifìcata. Invece di utilizzare dichiarazioni i f profondamente annidate, che potrebbero incidere sulla leggibilità di un programma, possiamo impiegare una dichiarazione c a s e generica: (2')

case C,: P,; C 2 : P2; • • •

J

C„: P„; otherwise end

P0;

case;

È richiesto che tutte le C¿, ciascuna delle quali rappresenta un'espressione booleana, siano mutuamente disgiunte. La dichiarazione del problema, a ogni passo della scomposizione, viene solitamente fornita mediante descrizioni in linguaggi simili al linguaggio naturale. Ogni passo di raffinamento è rappresentato riscrivendo la descrizione in termini di dichiarazioni di sotto-problemi, collegate tra loro per mezzo delle strutture di controllo precedentemente illustrate. Le dichiarazioni dei sotto-problemi, a loro volta, vengono rese più dettagliate al successivo passo del raffinamento. Il processo di progettazione incomincia, dunque, con una descrizione globale del problema da risolvere (la funzione "originale"), applica ricorsivamente una scomposizione funzionale e termina una volta che abbiamo raggiunto il punto in cui ogni sotto-problema risulta facile da esprimere in termini di poche righe di codice nel linguaggio di programmazione prescelto. ESEMPIO 4 . 1 0

Descriviamo la derivazione di un algoritmo di ordinamento (selettive sort) mediante il raffinamento per passi successivi. Si tratta di un piccolo esempio di programmazione, non di un esercizio di progettazione. L'esempio, però, illustra chiaramente come funzioni il raffinamento per passi successivi. Successivamente, vedremo anche un esempio in cui il raffinamento per passi successivi verrà utilizzato a livello di progettazione. Ecco l'algoritmo di ordinamento. Passo 1 sia i

n la

:=

lunghezza

while

i < n

a da

ordinare;

loop

trova il minimo pos izione i; i end

dell'array

1 ; tra

a¡ ...

a„ e scambialo

con

1'elemento

in

:= i + 1 ;

loop;

(segue a p. suce.)

(segue da p. prec.) Passo 2 sia i

n la

:=

lunghezza

wbile

i < n j

a da

ordinare;

loop

== n;

while

j > i loop if

a(i) > a(j)

scambia end j end i end

dell'array

1 ;

tra

then

loro

gli

elementi

in posizioni

j e i;

if ;

:=

j -

1;

loop;

:= i + 1 ;

loop;

Passo 3 sia n la l u n g h e z z a i := 1 ; while

end

i < n

dell'array

a da

ordinare;

loop

j := n; w b i l e j > i loop i f a(i) > a(j)then x :=a(i); a(i) end i f; j := j - 1; end l o o p ; i := i + 1 ; loop;

:= a ( j ) ;

a(j)

:=

x;

I

La progettazione per raffinamento per passi successivi può essere rappresentata graficamente mediante l'uso di un D T (decomposition tree, albero di scomposizione), un albero in cui la radice viene etichettata con il nome del problema iniziale, in cui ogni altro nodo viene etichettato con il nome di un sotto-problema e i nodi figli di un qualsiasi nodo, con i nomi dei sotto-problemi che lo dettagliano in un passo di raffinamento. L'ordine da sinistra a destra dei nodi figlio di un dato nodo rappresenta l'ordine in cui i sotto-problemi dovranno essere risolti durante l'esecuzione del programma. Nodi rappresentanti sotto-problemi alternativi vengono identificati da una linea tratteggiata che raggruppa gli archi che connettono i nodi al loro nodo padre; gli archi vengono etichettati anche con la condizione sotto la quale devono essere scelti i sotto-problemi. L'iterazione viene rappresentata da una linea solida, alla quale viene aggiunta, come etichetta, la condizione che governa la struttura w h i l e . Per esempio, la Figura 4.13 rappresenta il D T che corrisponde al seguente raffinamento per passi: Passo 1 P;

P e il problema

Pi; P2; P3;

P

da

risolvere.

Passo 2 viene

scomposto

nella

sequenza

Figura 4.13

Rappresentazione grafica del raffinamento per passi successivi. (Legenda: gli archi con linea continua rappresentano iterazioni; quelli tratteggiati rappresentano selezioni).

Passo 3 Putritile C loop

Pj.I! end P3;

P2 viene

scomposto

in

un'iterazione.

loop;

Passo 4 Pw while C

loop P2,¡ viene

if C, then

scomposto

in una

selezione.

Pi,.,.; else

P2.1.2; end P,;

end i £ ; loop;

Potremmo porci alcune domande in merito alle relazioni che intercorrono tra un D T e il grafo della relazione IS C O M P O S E D OF o, equivalentemente, tra un progetto top-down ottenuto mediante la scomposizione di un modulo in componenti e il raffinamento per passi successivi. In effetti sono concetti simili che presentano, però, anche alcune differenze. Ad esempio, supponiamo di voler descrivere il raffinamento per passi successivi illustrato nella Figura 4.13 in termini di relazioni I S _ C 0 M P 0 N E N T _ 0 F o, più convenientemente, in termini di L S _ C O M P O S E D _ O F . Siano M, M 1 ; M 2 e M 3 i moduli che rappresentano rispettivamente P, Pi, P2 e P3. Notiamo che non è possibile formulare la relazione M IS_COMPOSED_OF

{Mlr

M2,

M3)

in quanto non c'è alcun componente del sistema responsabile per l'organizzazione del flusso sequenziale da a M2 e infine a M3, implicito nella Figura 4.13. Dobbiamo, dunque, introdurre un modulo aggiuntivo di controllo M4 che faccia da collante e imponga il flus-

so sequenziale da H, a H2 e infine a M3 Ciò ci permetterebbe di affermare la seguente relazione: M ISCOMPOSEDOF

{M,,

M2,

MJ,

M,>

A sua volta M2 verrebbe scomposto in M2 _ ! (associato a P2, J e M2 _ 2 , il quale farebbe da modulo di controllo utilizzato per imporre l'uso iterativo di M 2 1 : M2

ISCOMPOSEDOF

{M21,

M2,2}

Infine M 2 ; t verrebbe scomposto in M 2 I 1 I 1 , M ! i 1 i 2 (associati rispettivamente a P 2 i l i l e P 2 , i , 2 ) e M2 _ ! _ 3 il quale farebbe da modulo di controllo per la selezione tra M2 , I, I e M2 _ 2 _ 2 secondo il valore di C ! : M2,I

IS

COMPOSED

OF

{M2/11,

M

2 1 2

,

M2,13>

Questo esempio ci dimostra che un progetto prodotto dal raffinamento per passi successivi può essere descritto anche in modo top-down con l'uso della relazione I S _ C 0 M P 0 S E D _ 0 F . Infatti, il metodo che abbiamo usato può essere applicato, in generale, per effettuare una trasformazione da una descrizione all'altra. La descrizione in termini di I S _ C O M P O S E D _ O F che ne risulta non corrisponde, però, a una modularizzazione significativa. A tutti gli effetti, il raffinamento per passi successivi dovrebbe essere considerato un metodo per la descrizione della struttura logica di un dato algoritmo, implementato da un singolo metodo, piuttosto che un metodo per la descrizione della scomposizione in moduli di un sistema. La descrizione fornita per il programma di ordinamento illustra le virtù di questo metodo quando viene applicato nel piccolo. Un sistema grande e complesso non può essere progettato e descritto mediante il raffinamento per passi successivi; piuttosto, la sua progettazione necessita della scomposizione in moduli, dello sviluppo separato di ogni modulo e dell'applicazione dell'information hiding. Esercìzi 4.30

Descrivete l'uso della relazione USES tra i m o d u l i che a b b i a m o i n t r o d o t t o per rappresentare il r a f f i n a m e n t o per passi successivi illustrato nella Figura 4 . 1 3 , e m o s t r a t e n e la s t r u t t u r a m o dulare, u s a n d o G D N .

4.31

Descrivete il r a f f i n a m e n t o per passi successivi dell'esempio di o r d i n a m e n t o di u n a selezione discusso nell'Esempio 4 . 1 0 in t e r m i n i del c o r r i s p o n d e n t e albero di scomposizione.

4.2.6.1

Una valutazione del raffinamento per passi successivi

Una convinzione errata a riguardo del raffinamento per passi successivi è che possa fornire una strategia per trovare la soluzione a un problema suggerendo una scomposizione quasi meccanica del problema in sotto-problemi. Questa convinzione nasce dagli esempi di derivazione che possono essere trovati in alcuni libri di testo introduttivi, dove sembra che il programma nasca in maniera naturale dal raffinamento per passi successivi. Il processo di derivazione di un programma, contrariamente alla apparenze, spesso richiede creatività e potrebbe anche richiedere che vengano esplorate diverse alternative prima di trovare la soluzione appropriata. Prendiamo un problema ben conosciuto come l'ordinamento; non è certamente seguendo pedissequamente il raffinamento per passi successivi che possiamo scoprire una buona soluzione come, ad esempio, il quicksort, rispetto a so-

luzioni più banali come il bubblesort o il selection sort! Il raffinamento per passi successivi è indubbiamente un modo efficace di descrivere una soluzione dopo che è stata trovata. E un modo per descrivere — a posteriori — le motivazioni che stanno dietro a un algoritmo, postulando un processo ideale e razionale da cui deriva l'algoritmo. Di conseguenza il raffinamento per passi successivi può essere una buona tecnica per la documentazione di programmi. Inoltre, se un codice viene scritto seguendo la tecnica del raffinamento per passi successivi il risultato sarà facile da leggere e capire. Il raffinamento è una tecnica efficace per la descrizione di programmi piccoli. Fallisce, però, nello scalare verso sistemi dalla complessità anche moderata. Il raffinamento per passi successivi è dunque un metodo che funziona nel piccolo, ma fallisce nel grande. In particolare, non permette di ottenere gli obiettivi che l'information hiding tenta di raggiungere e non aiuta i progettisti nel riuso di componenti provenienti da applicazioni precedenti 0 nella progettazione di componenti riusabili per programmi più grandi. Ecco alcuni motivi, responsabili di questi difetti. 1 sotto-problemi tendono ad essere analizzati isolatamente. Il raffinamento per passi successivi non pone alcuna enfasi sul tentativo di generalizzare i sottoproblemi in modo da renderli riusabili in diversi momenti della derivazione del sistema, anche in diversi progetti. Quando un problema deve essere maggiormente dettagliato, viene studiato nel contesto dell'albero di scomposizione in cui appare. Invece, quando si sta scomponendo un problema in sotto-problemi potrebbe risultare utile verificare se un'adeguata generalizzazione possa assimilarlo a un problema che si sta risolvendo da un'altra parte, così da unificarli e poter progettare un singolo modulo per entrambi. Non viene prestata alcuna attenzione all'information hiding. Il raffinamento per passi successivi non porta l'attenzione del progettista sulla necessità di incapsulare le informazioni modificabili all'interno di moduli. Infatti, i moduli che derivano dall'applicazione del raffinamento per passi successivi sono pure astrazioni procedurali. Un problema rappresentato da una funzione astratta viene scomposto ricorsivamente in sotto-problemi, i quali sono tutti rappresentati da funzioni astratte. Nel raffinamento per passi successivi la strategia non enfatizza mai il bisogno di raggruppare insieme funzioni per definire un oggetto astratto o un tipo di dato; non insiste neppure sulla derivazione di moduli che forniscano esportazioni selettive di collezioni di risorse. L'unico principio che guida la scomposizione funzionale nel raffinamento per passi successivi è la volontà di avere una soluzione finale leggibile. Non viene prestata alcuna attenzione ai dati. Questo è un corollario del punto precedente. Il raffinamento per passi successivi non stressa l'uso dell'information hiding, e cioè non focalizza l'attenzione del progettista sulla derivazione di moduli che nascondono una struttura dati ed esportano le operazioni astratte per accedervi. La funzione originale potrebbe non esistere. Il metodo inizia esprimendo il problema originale che viene ricorsivamente reso più dettagliato in termini di sotto-problemi. Una questione minore, ma fastidiosa, è che il problema originale potrebbe risultare "innaturale" da esprimere. Si ricordi che il problema originale dovrebbe descrivere il problema come una funzione di alto livello che trasforma i dati in ingresso nei risultati attesi. Una tale funzione, però, non sempre esiste.

Ad esempio, quale potrebbe essere la funzione svolta da un elaboratore di testi? Chiaramente, un elaboratore di testi è un sistema che reagisce a comandi in ingresso forniti dagli utenti: comandi che creano testo, inseriscono nuovi caratteri in un file e realizzano manipolazioni complesse sul testo. Ovviamente, si potrebbe sempre descrivere il problema originale come "risponde a tutti i comandi degli utenti". Ma ciò risulterebbe di scarso aiuto nei successivi passi di scomposizione. Viene fatta una scelta prematura del flusso di controllo tra moduli. Lo studio del caso di traduzione del raffinamento per passi successivi della Figura 4.13 in una gerarchia I S _ C 0 M P O S E D _ O F illustra chiaramente questo punto. Quando P 2 viene scomposto nell'iterazione di P2;1, vengono introdotti concettualmente due moduli: M2;1 e M2 2. M2,i corrisponde a P2,i; M2 _ 2 è semplicemente un modulo di controllo che contiene la seguente istruzione: while

C loop

P 2jl end

loop;

per costringere l'esecuzione ripetuta di M2;1. Una situazione simile nasce anche nella scomposizione di M2, IESEMPIO 4 . 1 1

Supponiamo di progettare un applicativo per il controllo della sintassi di un programma scritto in un dato linguaggio di programmazione. In accordo con il raffinamento per passi successivi potremmo scrivere: Passo 1 Riconosci

un p r o g r a m m a

immagazzinato

in un

dato

file

f;

Passo 2 correct:= analizza

true; f secondo

if c o r r e c t

la d e f i n i z i o n e

del

linguaggio;

then

stampa

messaggio " p r o g r a m m a

corretto";

stampa

messaggio " p r o g r a m m a

non

else end

corretto";

i f;

Passo 3 correct:= esegui

if

true;

l'analisi

lessicale:

immagazzina il programma in una sequenza di elementi lessicali (token) nel file ft e la tabella dei simboli nel file fs e imposta la variabile booleana error_in_lexical_phase a seconda del risultato dell'analisi del lessico; e r r o r _ i n _ l e x i c a l _ p h a s e then correct:= false;

else esegui l'analisi sintattica sul booleana error_in_syntactic_phase dell 'analisi ; if e r r o r _ i n _ s y n t a c t i c _ p h a s e correct:=

false;

then

file fe e imposta a seconda del

la variabile risultato

end i f ; end i f; if correct

then

stampa

messaggio

"programma

corretto";

stampa

messaggio

"programma

non

else corretto";

end i f;

Senza procedere ulteriormente con l'esempio, possiamo vedere che sono stati presi impegni pesanti per quanto riguarda il controllo del flusso fin dai primissimi stadi dello sviluppo. Ad esempio, abbiamo deciso che l'analisi lessicale deve essere eseguita prima, che dovrebbe operare sull'intero programma in ingresso e che dovrebbe produrre la corrispondente sequenza di elementi lessicali in un file intermedio, perché venga utilizzata nella fase successiva di analisi sintattica. Supponiamo ora di decidere di voler cambiare strategia; per esempio, decidiamo di non voler più eseguire l'analisi in due fasi ma di voler lasciare che sia l'analizzatore sintattico a guidare il processo. In questo caso, l'analizzatore sintattico attiverà ripetutamente l'analizzatore lessicale durante il processo, chiedendo il successivo elemento lessicale. Questo cambiamento avrebbe un profondo impatto sulla struttura del programma, in base alla descrizione fornita dal raffinamento per passi successivi: occorre quindi procedere a un sostanziale rifacimento, a partire dal Passo 3. L'impatto di questo cambiamento non sarebbe risultato così drammatico nel caso in cui avessimo seguito un approccio basato sull'information hiding, definendo i seguenti moduli di esempio: •



nasconde la rappresentazione fisica del file in ingresso ed esporta operazioni per accedere al file sorgente un carattere alla volta;

CHAR_HOLDER:

SCANNER: n a s c o n d e i dettagli della struttura lessicale del l i n g u a g g i o dal resto del sis t e m a e d e s p o r t a u n ' o p e r a z i o n e per fornire il s u c c e s s i v o e l e m e n t o lessicale della sequenza;



P A R S E R : nasconde la struttura dati utilizzata per eseguire l'analisi sintattica (parse tree, albero sintattico), la quale potrebbe essere incapsulata in un modulo di oggetto astratto (PARSER).



Esercizio 4.32

4.2.7

C o m p l e t a t e la progettazione del riconoscitore di linguaggi. Utilizzate s i a T D N che G D N per illustrare il progetto. Descrivete i c a m b i a m e n t i necessari per trasformare la soluzione in d u e passi in u n a soluzione a u n singolo passo.

Progettazione top-down e bottom-up

Quale strategia dovremmo seguire quando progettiamo un sistema? Dovremmo procedere in maniera top-down, applicando la scomposizione ricorsivamente secondo la relazione I S_COMPOSED_OF, fino a quando il sistema è stato scomposto in componenti gestibili? O

dovremmo procedere in maniera bottom-up, cominciando da ciò che vogliamo incapsulare in un modulo, definendo ricorsivamente interfacce astratte per poi raggruppare diversi moduli in un nuovo modulo di livello più alto che li comprende? Il raffinamento per passi successivi è un metodo intrinsecamente top-down. Alcune delle critiche che abbiamo sollevato a riguardo del metodo hanno a che vedere con le sue caratteristiche specifiche. In particolare, l'impegno prematuro verso le strutture di controllo e il suo orientamento verso la progettazione in piccolo sono dovuti allo stile, basato sui linguaggi di programmazione, utilizzato per descrivere i raffinamenti. Altre critiche importanti possono essere rivolte, in generale, alle strategie top-down. Tra queste, il fatto che i sotto-problemi tendono ad essere affrontati in isolamento, che nessuna enfasi viene posta sull'identificazione degli elementi in comune con altri o sulla riusabilità dei componenti, e che poca attenzione viene prestata ai dati e, più in generale, all'information hiding. L'information hiding è fondamentalmente una conseguenza dalle strategie bottom-up. Suggerisce che dovremmo prima riconoscere che cosa vogliamo incapsulare all'interno di un modulo per poi fornire un'interfaccia per la definizione dei confini del modulo. Notiamo, comunque, che la decisione di che cosa celare all'interno del modulo (per esempio, certe politiche) potrebbe dipendere dal risultato di una qualche attività di natura top-down. Dato che l'information hiding risulta essere altamente efficace nel supporto alla progettazione in vista del cambiamento, alle famiglie di prodotti e ai componenti riusabili, la sua filosofia bottom-up dovrebbe essere seguita in modo sistematico. Essendo, comunque, la progettazione un'attività umana altamente critica e creativa, i bravi progettisti non si limitano esclusivamente a seguire o la procedura top-down o quella bottom-up. Così, ad esempio, anche se dovessero decidere di procedere seguendo una logica prevalentemente top-down, presterebbero ugualmente attenzione all'identificazione di parti comuni e di possibili componenti riusabili, sostanzialmente combinando la strategia top-down con un approccio bottom-up. Una tipica strategia di progettazione potrebbe procedere parzialmente in modalità topdown e in parte in bottom-up, a seconda della fase del progetto e della natura dell'applicazione, in un modo che potrebbe essere denominato progettazione a yo-yo. Come esempio, potremmo cominciare a scomporre un sistema, in maniera top-down, in sotto-sistemi e, in un secondo momento, sintetizzare i sotto-sistemi in una gerarchia di moduli che implementino l'information hiding. L'approccio top-down, comunque, è molte volte utile per documentare un progetto. Anche se l'attività di progettazione non dovrebbe essere costretta a seguire un modello prestabilito e rigido, ma essere una fusione di passi top-down e bottom-up, è consigliabile che la descrizione del progetto risultante sia fornita in una maniera top-down. Tali descrizioni rendono la comprensione del sistema più semplice in quanto forniscono un quadro generale prima di mostrare i dettagli.

4.3

Gestione delle anomalie

Un approccio sistematico alla progettazione seguito da un'implementazione rigorosa e disciplinata è il modo migliore per dominare la complessità dello sviluppo di software e realizzare prodotti affidabili. Sfortunatamente, i prodotti software possono essere molto complessi, e soggetti all'errore umano. Anche quando lo sviluppo è attuato con notevole atten-

zione non è possibile fidarsi incondizionatamente del prodotto sviluppato. Questa mancanza di fiducia può essere frustrante per il programmatore coscienzioso, ma deriva dalla constatazione del carattere critico di molte applicazioni, per le quali un malfunzionamento potrebbe portare a conseguenze disastrose. Qualsiasi prodotto ingegneristico, dai ponti agli aerei al software, può fallire. Il progettista deve anticipare i rischi di fallimento e decidere se evitarli o tollerarli. Ovvero, deve ricorrere a una progettazione difensiva. Per questo cercherà di proteggere l'applicazione da malfunzionamenti che potrebbero insorgere durante lo sviluppo o per via di circostanze avverse al momento dell'esecuzione. Costruirà, inoltre, sistemi robusti, in grado di comportarsi in modo ragionevole anche in circostanze non previste. Diciamo che un modulo è anomalo se fallisce nel fornire un servizio secondo le modalità previste e come specificato dalla sua interfaccia. Fino a questo punto, le nostre descrizioni di progetto, testuali e grafiche, sono state principalmente di natura sintattica e non hanno supportato una descrizione precisa della semantica dei servizi esportati dal modulo. Un arricchimento semantico della notazione può essere dato in modo formale, come vedremo nel Capitolo 5. Per semplicità, assumeremo qui che esso sia espresso nell'interfaccia, per mezzo di commenti. Estenderemo, inoltre, la nostra notazione di progettazione affinché associ un insieme di eccezioni a ogni servizio esportato dal modulo. Le eccezioni associate a un servizio descrivono le anomalie che possono presentarsi durante l'esecuzione del servizio. Per semplicità, assumeremo che il servizio esportato dal modulo corrisponda a una funzione da invocare. Un modulo o esegue correttamente il proprio compito, nel qual caso effettua il servizio richiesto e ritorna al cliente in maniera normale, oppure entra in uno stato anomalo. La progettazione difensiva richiede che, nel secondo caso, il modulo segnali l'anomalia al cliente sollevando un'eccezione. In altre parole, distinguiamo tra il comportamento corretto e il comportamento anomalo del modulo. Se qualcosa va storto e il modulo non riesce a completare correttamente il servizio richiesto, dovrebbe restituire un'indicazione della situazione anomala sollevando un'eccezione, che può essere vista come un evento che viene segnalato al cliente. Il modulo server termina l'esecuzione e il client, informato del fatto che è stata sollevata un'eccezione, risponde gestendola in maniera appropriata. Perché un modulo M dovrebbe fallire nel fornire il suo servizio secondo le specifiche date? Ciò potrebbe accadere perché il client di M non soddisfa il protocollo richiesto per l'invocazione di uno dei servizi di M. Ad esempio, l'operazione op esportata da M potrebbe richiedere un parametro positivo, mentre il client potrebbe invocare op con un valore negativo del parametro. Un fallimento potrebbe anche presentarsi nel caso in cui M non soddisfi il protocollo richiesto per l'utilizzo di un servizio esportato da un altro modulo, diciamo N. In questo caso, il fallimento di N viene segnalato a M, e il gestore delle eccezioni di M viene attivato di conseguenza. Il gestore potrebbe tentare di recuperare dall'anomalia oppure potrebbe semplicemente fare un po' di pulizia dello stato del modulo e lasciare che la funzione fallisca, segnalando un'eccezione al modulo chiamante. Se il recupero dovesse avere successo, M non fallirebbe; altrimenti, un po' di pulizia dello stato potrebbe essere necessaria per assicurare che l'uso di M, da parte di clienti successivi, non trovi il modulo in uno stato incoerente. Notiamo, comunque, che i gestori d'anomalie sono celati nel corpo del modulo; ovvero, come un modulo gestisce un'eccezione fa parte dei segreti del modulo. Per questo motivo non approfondiremo la questione ora e non esamineremo come vengono legate le eccezioni segnalate ai rispettivi gestori o cosa succede

module

M

exports procedure

P

raises

(X:

INTEGGR;

...)

X_NON_NEGATIVE_EXPECTED,

INTEGER_OVERFLOW; X deve essere positivo ; se non lo è viene sollevata l'eccezione X_NON_NEGATIVE_EXPECTED; 1'eccezione INTEGER_OVERFLOW viene sollevata se la computaz ione interna di P genera un over flow

end

M

Figura 4.14

Interfaccia p a r z i a l e per un m o d u l o c h e i n c l u d e le e c c e z i o n i .

se un modulo client non possiede un gestore per l'eccezione che gli viene segnalata. Queste questioni dipendono fortemente dal linguaggio di programmazione che potremmo scegliere per l'implementazione. Da un punto di vista della progettazione il punto importante è che i client possano determinare dalla sua interfaccia quali eccezioni possono essere sollevate da un modulo. In un sistema robusto i client anticiperanno e gestiranno tutte le eccezioni che possono essere sollevate dai moduli server di cui fanno uso. A parte questi tipi di fallimenti, un modulo potrebbe fallire nel fornire il suo servizio per via di una condizione non prevista, come ad esempio un overflow o un indice che esce dai limiti di un array durante l'esecuzione. In questo caso assumiamo che la macchina astratta sottostante sia in grado di catturare la condizione anomala e di passarla al software per una gestione adeguata. Molti linguaggi di programmazione sono anche in grado di scoprire e segnalare una violazione delle asserzioni di correttezza logica durante l'esecuzione. Queste violazioni possono essere trattate come i tipi di eccezioni precedenti. Inoltre, è possibile specificare che certe condizioni dovrebbero essere considerate come eccezioni che necessitano di un trattamento particolare da parte del client dopo che sono state scoperte del modulo server. Nella discussione che segue estenderemo le descrizioni di interfacce con T D N in modo che ai servizi esportati possa essere associato un elenco di nomi di eccezioni. Questi saranno i nomi delle eccezioni che potranno essere sollevate dal servizio per segnalare il proprio completamento anomalo. Supponiamo che aJ momento di definire le interfacce i progettisti concordino certe restrizioni che si applicano ai parametri di una procedura P racchiusa nel modulo M. Ad esempio, potrebbero concordare che P debba ricevere un valore non negativo per il parametro x. Questa decisione verrebbe registrata nell'interfaccia di M in qualità di commento (Figura 4.14). Ovviamente, in un mondo perfetto, non ci sarebbe ragione di sospettare che i moduli client possano non rispettare questi requisiti. La progettazione difensiva, invece, richiede che non si confidi che i client si comportino in maniera appropriata e che si protegga M rimandando un'eccezione nel caso in cui P fosse chiamata con un valore negativo di X. Come ulteriore esempio, consideriamo la Figura 4.15, in cui il modulo L usa il mo-

module L UB6S

M Imports P (X:

exports

INTEGER;..)

... ; procedure

R

raises

(...) INTEGER_OVERFLOW;

implementation Se viene sollevata 1'eccezione INTEGE OVERFLOW quando viene invocata P, l'eccezione viene propagata

end L Figura 4.15

dulo

Frammento di progettazione con eccezione che viene propagata.

della Figura 4.14. Nel caso in cui dovesse essere sollevata l'eccezione I N T E G E R _ quando la procedura R di L chiama la procedura P, potremmo decidere che il gestore di R faccia un po' di pulizia, per poi sollevare un'eccezione appropriata (per esempio, nuovamente I N T E G E R _ 0 V E R F L 0 W ) da far gestire al cliente di M. La stessa politica potrebbe essere seguita anche dal cliente, e così via. In effetti, questa potrebbe essere una maniera per eseguire una terminazione graduale del sistema come conseguenza di un errore irrecuperabile. Dal frammento della Figura 4.15 osserviamo che L non solleva un'eccezione corrispondente alla condizione X_NON_NEGATIVE_EXPECTED, la quale potrebbe essere sollevata da P. Questo significa che L garantisce che l'eccezione non verrà mai sollevata oppure che L saprà recuperare nel caso dovesse essere sollevata. M

OVERFLOW

Esercizi 4.33

D e f i n i t e l'interfaccia di u n m o d u l o che i m p l e m e n t a il tipo di d a t o astratto S T A C K , dove l'operazione p o p solleva un'eccezione se c h i a m a t a a operare su u n o stack v u o t o .

4.34

S u p p o n i a m o che ci venga chiesto di costruire u n a tabella di riferimenti per le variabili che app a i o n o in u n p r o g r a m m a . U n a tabella di riferimenti è u n a i u t o per ricostruire d o c u m e n t a zione p a r t e n d o d a altri p r o g r a m m i che si a s s u m o n o corretti. In accordo c o n le specifiche, n o n dovrebbe mai accadere che u n a variabile sia utilizzata senza, o p r i m a di, essere dichiarata. Per semplicità, a s s u m i a m o che il linguaggio n o n fornisca regole per la specifica dei limiti e n t r o i quali u n a variabile sia visibile: tutti i n o m i delle variabili h a n n o visibilità globale. P r o g e t t i a m o u n a tabella di r i f e r i m e n t o CRT, u n oggetto astratto, che esporta d u e operazioni: (1) la p r o c e d u r a NOTIFY viene c h i a m a t a per inserire il n o m e di u n a variabile nella tabella, insieme al n u m e r o della riga in cui è stata dichiarata la variabile. (2) la p r o c e d u r a OCCUR viene c h i a m a t a per registrare la creazione di u n a variabile in u n a riga di codice, specificando il n o m e della variabile e il n u m e r o della riga di codice in questione.

C o m e parte del c o n t r a t t o c o n gli altri m o d u l i client, specificheremo nell'interfaccia che la proc e d u r a NOTIFY n o n p u ò essere c h i a m a t a se u n a variabile con lo stesso n o m e risulta già inserita nella tabella. La p r o c e d u r a OCCUR, invece, p o t r à essere c h i a m a t a solo se la variabile che stiamo t r a s m e t t e n d o c o m e p a r a m e t r o è già stata dichiarata (e q u i n d i già presente nella tabella). Q u e s t i protocolli s o n o coerenti c o n l'assunzione che il p r o g r a m m a sorgente sia corretto. Progettate u n m o d u l o CRT r o b u s t o e fornite la sua descrizione T D N . I m p l e m e n t a t e il progetto in u n linguaggio di p r o g r a m m a z i o n e di vostra scelta, a s s u m e n d o che ci siano m o d u l i adeguati per l'uso di CRT. E s p o n e t e vantaggi e svantaggi del linguaggio per q u a n t o riguarda la gestione delle eccezioni. 4.35

C o n f r o n t a t e le capacità di gestione delle eccezioni di C++ e Java. È possibile, in u n o di questi linguaggi, che u n m o d u l o client n o n abbia u n gestore di un'eccezione che p o t r e b b e incontrare? Q u a l e di questi d u e linguaggi rafforza la progettazione difensiva?

4.4

Un esempio di progettazione

In questo paragrafo illustreremo i concetti precedentemente presentati nel contesto di un esempio di progettazione. Il nostro obiettivo non è quello di fornire una ricetta generale su "cosa rende un progetto un buon progetto". La progettazione è un'attività creativa che non può essere svolta in maniera meccanica; richiede intuito ed esperienza. Di conseguenza, esamineremo un processo di progettazione ipotetico, mostrando alcuni tra i problemi che possono sorgere nella pratica e discutendo esempi di cosa rende un progetto un buon progetto. Consideriamo un piccolo gruppo di progettisti che sviluppa un compilatore di un ulteriore linguaggio di programmazione: MIDI è considerevolmente più complesso del linguaggio MINI presentato negli Esempi 4.1 e 4.6 ed è un linguaggio strutturato a blocchi (ALGOL-like). La progettazione complessiva dell'Esempio 4.6 risulta essere valida anche qui e, dunque, non verrà più discussa. Concentreremo la nostra attenzione sulla progettazione

module

SYMB0L_TABLE

Supporta uses

. . imports

exports

fino

a MAX_DEPTH

(IDENTIFIER,

procedure

procedure

INSERT

annidati

(ID:

in

IDENTIFIER;

DESCR:

in

DESCRIPTOR);

RETRIEVE

(ID:

DESCR:

end

blocchi

DESCRIPTOR)

(ID:

in

out

procedure

LEVEL

procedure

ENTER_SCOPE;

in

procedure

EX I T _ S C O P E ;

procedure

INIT

IDENTIFIER; DESCRIPTOR);

IDENTIFIER;

( M A X _ D E P T H:

in

L: out

INTEGER);

INTEGER);

SYMBOL_TABLE

Figura 4.16

Frammento di T D N che rappresenta la versione iniziale dell'interfaccia della tabella dei simboli.

del modulo SYMBOL_TABLE. I progettisti, innanzi tutto, si accordano sulle direttive di progettazione, che avranno un impatto sulle interfacce dei moduli: in primo luogo, le operazioni di S Y M B O L _ T A B L E possono essere invocate solo se il programma è sintatticamente e semanticamente corretto. In particolare, possono essere invocate solo nel caso in cui i blocchi siano correttamente delimitati da coppie di b e g i n ed e n d , in cui non esistano due identificatori con lo stesso nome all'interno dello stesso blocco e in cui ogni variabile sia dichiarata prima di essere utilizzata. In secondo luogo, per ragioni di stile, è predefinita la profondità massima di blocchi annidati, per cui programmi con un livello di annidamento più elevato del valore predefinito saranno considerati erronei. S Y M B O L _ T A B L E è un oggetto astratto che nasconde la struttura dati fìsica usata per rappresentare la tabella. La sua interfaccia è rappresentata dal frammento di T D N della Figura 4.16, secondo la quale i moduli client possono inserire un identificatore, con i propri attributi, all'interno della tabella mediante la procedura INSERT. I clienti possono anche estrarre gli attributi degli identificatori precedentemente inseriti mediante la procedura R E T R I E V E . Gli attributi dovrebbero essere immagazzinati all'interno di descrittori. Vengono fornite operazioni in grado di segnalare quando si entra in un nuovo blocco (mediante la procedura E N T E R S C O P E ) e quando si esce da un blocco (mediante la procedura EXIT_SCOPE). Un'operazione (LEVEL) viene resa disponibile per il calcolo del livello di annidamento lessicale di un identificatore. Il livello è zero se l'identificatore è dichiarato localmente, ovvero nel blocco in cui si è attualmente entrati e dal quale non si è ancora usciti; vale uno se l'identificatore non è locale ma dichiarato nel blocco in cui si era entrati immediatamente prima; e così via. I progettisti del compilatore MIDI si rendono quindi conto che l'attuale versione dell'interfaccia di SYMBOL TABLE non è soddisfacente. Le assunzioni che il programma sia sintatticamente e semanticamente corretto e che la profondità massima di blocchi annidati non debba essere superata potrebbero essere violate da un comportamento scorretto da parte dei moduli client. Conseguentemente, per migliorare la robustezza del compilatore, viene deciso che le invocazioni illegali sollevino eccezioni. Questo miglioramento nella progettazione di S Y M B O L _ T A B L E presenta un'ulteriore vantaggio: rende il modulo riusabile in altri contesti, in cui le assunzioni di correttezza sintattica e semantica non risultano vere. In conclusione, vengono adottate le seguenti decisioni di progettazione. 1. L'operazione INSERT solleva un'eccezione se l'inserimento non può essere portato a termine in quanto esiste già nel blocco corrente un identificatore con lo stesso nome; 2. Le operazioni R E T R I E V E e L E V E L sollevano un'eccezione se non è in quel momento visibile un identificatore con il nome specificato; 3. L'operazione E N T E R _ S C O P E solleva un'eccezione se viene superato il livello massimo di profondità di blocchi annidati; 4. L'operazione E X I T _ S C 0 P E solleva un'eccezione se non esiste un blocco corrispondente da cui uscire. Basandosi su questi punti, i progettisti producono una revisione dell'interfaccia di SYMBOL_TABLE (Figura 4.17). Studiamo il lavoro del progettista di SYMBOL_TABLE. La struttura a blocchi del programma richiede che le informazioni riguardanti i blocchi vengano allocate e deallocate se-

module uses

SYMBOLTABLE

...imports

(IDENTIFIER,

DESCRIPTOR)

exports Supporta fino a MAX_DEPTH INIT deve essere chiamata altra operazione procedure

INSERT

(ID:

in in

raises

MULTIPLE_DEF,

(ID:

in

IDENTIFIER;

out

DESCRIPTOR)

raises

NOTVISIBLE;

(ID:

in

raises procedure ENTER_SCOPE procedure E X I T S C O P E procedure

INIT

DESCRIPTOR)

DESCR:

L: out

end

procedura annidati; la invocare qualsiasi

IDENTIFIER;

DESCR: procedure RETRIEVE

procedure LEVEL

blocchi prima di

IDENTIFIER; INTEGER) N0T_VISIBLE;

raises raises

(MAX_DEPTH:

EXTRA_LEVELS; EXTRA_END;

in

INTEGER);

SYMBOLTABLE

Figura 4.17

Frammento di T D N c h e rappresenta una versione revisionata dell'interfaccia della tabella dei s i m b o l i .

condo una politica LIFO. Di conseguenza, quando si entra in un nuovo blocco, viene allocato un nuovo insieme di descrittori, mentre questi vengono deallocati nel momento in cui si esce da un blocco. Possiamo, dunque, utilizzare uno stack per immagazzinare i descrittori. Grazie alle informazioni riguardanti la profondità massima consentita di blocchi annidati, il progettista decide di implementare lo stack come un array (di dimensioni M A X _ D E P T H ) di liste, ognuna delle quali rappresenta le dichiarazioni che avvengono in un blocco in termini di coppie c i d e n t i f i c a t o r e , d e s c r i t t o r e » . Definire una lista non è un problema nuovo per il nostro progettista. Ha affrontato lo stesso problema molte volte, ridefinendo una lista ex novo ogni volta che ne aveva bisogno, il che risulta piuttosto frustrante! Il progettista decide, dunque, di definire un modulo generico per la gestione delle liste che possa essere riusabile in futuro. L I S T viene progettata come un tipo di dato astratto. Essendo generico, può essere ¡stanziato in un modulo che gestisca una lista di elementi di un qualsiasi tipo specificato. Essendo un tipo di dato astratto, permette l'istanziazione di diversi oggetti lista. Un'ipotetica versione dell'interfaccia del modulo viene mostrata nel frammento T D N della Figura 4.18. LIST esporta una procedura SEARCH che cerca all'interno della lista un elemento che "soddisfi" un determinato parametro di ricerca. Nell'esempio S Y M B O L _ T A B L E , dato che T è una coppia c i d e n t i f i e r , d e s c r i p t o r > , due elementi di tipo T si equivalgono se i loro identificatori sono gli stessi. In generale, cosa significhi "equivalere" dovrebbe essere specificato da una procedura associata al parametro formale T e spedito come parametro al momento dell'istanziazione del modulo. Inoltre, il modulo fornisce un procedura INSERT per im-

generic module LIST(T) with MATCH

(EL_1, EL_2: in T)

exports type LINKED_LIST: ? ; procedure Dice

IS EMPTY

se la lista

procedure Svuota

una

è

SETEMPTY

(L: in LINKED_LI ST) : BOOLEAN; vuota. (L: in out LINKED_LI ST) ;

lista.

procedure

INSERT

Inserisce

l'elemento

(L: in out LINKED_LIST;

procedure

SEARCH

nella

EL: in T) ;

lista

(L: in LINKED_L1ST;

EL_1: in T;

EL_2: out T; FOUND: out

boolean);

Cerca in L un elemento EL 2 uguale a EL 1 e restituisce il risultato in FOUND. end

LIST(T)

Figura 4.18

Frammento di T D N che rappresenta la versione iniziale dell'interfaccia di un modulo per la gestione di una lista.

magazzinare un elemento di tipo T. L'interfaccia non specifica dove verrà effettivamente immagazzinato l'elemento: potrebbe essere all'inizio di una lista, alla fine o in qualsiasi punto intermedio (magari secondo un dato ordinamento): la decisione viene lasciata all'implementazione. Esercizi 4.36

C o n s i d e r a t e la progettazione del m o d u l o SYMBOL TABLE m o s t r a t a nella Figura 4 . 1 6 e c o n siderate u n p r o g r a m m a MIDI in cui il n u m e r o di simboli b e g i n sia maggiore del n u m e r o di simboli e n d . O v v i a m e n t e , il p r o g r a m m a n o n è sintatticamente corretto. C o m e si comporterà il m o d u l o nella Figura 4 . 1 6 in questo caso? C o m e si p u ò migliorare il progetto, in m o d o che il m o d u l o sia in grado di a f f r o n t a r e u n a tale situazione?

4.37

II m o d u l o SYMBOL TABLE, m o s t r a t o nella Figura 4 . 1 6 , richiede che i m o d u l i client seguan o u n preciso protocollo nell'invocazione dei servizi esportati (INIT deve essere c h i a m a t a prim a di qualsiasi altra operazione). C o m e si p o t r e b b e i m p o r r e questa politica nell'interfaccia di SYMBOL TABLE m e d i a n t e l'uso di eccezioni?

4.5

Software concorrente

Fino a questo punto, abbiamo implicitamente assunto che l'applicazione da progettare avesse un solo flusso di esecuzione (detto anche control thread) ovvero che fosse un sistema puramente sequenziale. Con la proliferazione delle reti di computer e dei sistemi distribuiti, molte applicazioni devono poter gestire molteplici flussi di esecuzione, con conseguente, maggiore complessità sia del progetto che dell'analisi. Tali classi di applicazioni sono sempre più

importanti e meritano un trattamento speciale. Solitamente, vengono studiati come argomento separato nell'ambito di corsi e di testi sui sistemi operativi, sui sistemi distribuiti o sui sistemi real-time. Esamineremo qui le caratteristiche fondamentali di queste applicazioni in relazione agli altri tipi di software e mostreremo come le tecniche di progettazione, precedentemente illustrate, ne risultino influenzate. Uno dei problemi principali nella progettazione di software concorrente è quello di assicurare la coerenza dei dati condivisi tra i moduli eseguiti in concorrenza. Discuteremo il problema e alcune soluzioni nel Paragrafo 4.5.1 e prenderemo poi in considerazione due classi particolari di software concorrente: il software real-time (Paragrafo 4.5.2.), e il software distribuito (Paragrafo 4.5.3).

4.5.1

I dati condivisi

Possiamo generalizzare i concetti di modularità che abbiamo finora studiato per avere un oggetto astratto cui può accedere più di un'attività sequenziale (o processo)6 alla volta. Ad esempio, supponiamo di avere un oggetto astratto BUFFER di tipo QUEUE di caratteri. Questo oggetto potrebbe essere un'istanza del tipo generico della Figura 4.19, ovvero module

QUEUE_OF_CHAR

is G E N E R I C _ F I F O _ Q U E U E

(CHAR)

e ciò che segue l'istanziazione di una variabile BUFFER:

QUEUE_OF_CHAR.QUEUE;

assumendo che Q U E U E sia il nome del tipo esportato da G E N E R I C Q U E U E e utilizzando la dot notation per specificare la selezione di una risorsa esportata da un'istanza specifica. Assumiamo che siano disponibili le seguenti operazioni sugli oggetti di tipo Q U E U E di caratteri: •

PUT: inserisce un carattere in QUEUE



GET: estrae u n carattere da QUEUE



NOT FULL



NOT_EMPTY : restituisce t r u e se il parametro QUEUE n o n è v u o t o .

: restituisce

true

se il parametro

QUEUE

non

è

pieno7

I processi client ( P R O D U C E R _ 1 , P R O D U C E R _ 2 , etc.) accedono all'oggetto B U F F E R concorrentemente, producendo caratteri e chiamando l'operazione PUT per inserire un nuovo carattere nel buffer. Anche i processi client (C0NSUMER_1, C O N S U M E R _ 2 , etc.) accedono concorrentemente all'oggetto BUFFER, per rimuovere caratteri chiamando la procedura GET, la quale estrae un carattere alla volta. Assumiamo che l'operazione PUT possa essere chiamata solo se il buffer non è pieno e che l'operazione GET possa essere chiamata solo se il buffer non è vuoto.

6

In generale si distingue tra thread e processi (attività sequenziali all'interno di un singolo spazio di nomi o in diversi spazi di nomi). Per i nostri scopi, possiamo ignorare questa distinzione. 7 Assumiamo che le code abbiano una capacità finita. Le code non limitate sono simili (e più semplici da gestire), e non richiedono questa operazione.

Per usare il modulo BUFFER in maniera corretta potremmo tentare di inserire le chiamate GET e PUT eseguite dai client nelle seguenti strutture: (i)

if

QUEUE_OF_CHAR.NOT_FULL

(BUFFER)

QUEUE_OF_CHAR.PUT end

i f;

(ii)

if

QUEUE_OF_CHAR.NOTEMPTY

(BUFFER)

QUEUE_OF_CHAR.GET end

then

(X,BUFFER);

then

(X,BUFFER);

i f;

Sfortunatamente, questo approccio non è sufficiente per assicurare un accesso corretto al buffer in quanto potrebbe accadere che C O N S U M E R _ 1 controlli il buffer e non lo trovi vuoto. Sceglierebbe, di conseguenza, di entrare nel ramo t h e n e si preparerebbe a eseguire una GET. Prima di eseguire effettivamente la GET, C 0 N S U M E R _ 2 potrebbe controllare il buffer e trovarlo non vuoto; anch'esso dunque, entrerebbe nel ramo t h e n e si preparerebbe a eseguire una GET. Se B U F F E R , all'inizio, conteneva solo un carattere, raggiungiamo uno stato non valido in cui sono state date due autorizzazioni a eseguire GET. Ciò porterà certamente a un errore durante l'esecuzione. Esercizio 4.38

S u p p o n i a m o che il codice che implementa l'operazione PUT contenga l'istruzione TOT : = TOT + 1, con TOT uguale al numero totale di caratteri nel buffer, e che l'operazione GET contenga l'istruzione TOT : = TOT 1. S u p p o n i a m o , inoltre, che P R O D U C E R I e C0NSUMER 2 stiano eseguendo concorrentemente un'operazione di PUT e una di GET sul buffer. Mostrate c o m e sia possibile che il sistema entri in u n o stato invalido.

L'esempio BUFFER illustra il bisogno di sincronizzare le attività concorrenti. Due attività concorrenti procedono in parallelo fino a quando le loro azioni non interferiscono le une con le altre. Ma se devono cooperare o competere per l'accesso a una risorsa condivisa, come il BUFFER dell'esempio, non possono procedere indipendentemente ma devono sincronizzare le loro azioni. Ci sono diversi modi per sincronizzare i processi. Uno è quello di assicurarsi che qualsiasi risorsa condivisa, cui i processi devono accedere, venga utilizzata in mutua esclusione. Questo significa che, quando un processo sta effettuando una PUT (o una GET), nessun altro processo deve poter accedere a B U F F E R ; altrimenti potrebbe insorgere un errore, come nell'Esercizio 4.38. Inoltre, quando un consumatore esegue l'operazione (ii) dovrebbe accedere all'oggetto in mutua esclusione; ovvero, nessun altro processo dovrebbe poter eseguire alcun'altra operazione sul buffer condiviso. Lo stesso vale per il caso in cui un produttore esegue l'operazione (i). Più generalmente, le operazioni che hanno conseguenze sullo stato interno di un oggetto condiviso dovrebbero sempre essere eseguite in mutua esclusione, in modo da lasciare l'oggetto in uno stato coerente. Lo stesso vale per le sequenze di operazioni che testano il valore di un oggetto e, a seconda del risultato del test, ne modificano il valore. Il problema dell'accesso a dati condivisi in un ambiente concorrente è in realtà una ge-

neralizzazione dello stesso problema in un ambiente sequenziale. Anche le variabili condivise da diversi moduli in un ambiente sequenziale devono essere sottoposte a cure particolari, in quanto due chiamate successive al modulo M potrebbero osservare diversi valori di una variabile per via di una chiamata a M da parte di un altro modulo. Questa situazione potrebbe essere intenzionale (nel caso di un oggetto astratto) oppure potrebbe essere un errore. In un ambiente sequenziale, tali interazioni tra moduli sono esplicite nella progettazione dell'applicazione. In un ambiente concorrente, invece, le interazioni dipendono non solo dal progetto dell'applicazione, ma anche dalla particolare implementazione della concorrenza nel sistema di esecuzione. Questa difficoltà aggiuntiva è dovuta al fatto che l'ordine di esecuzione delle operazioni (per esempio, l'accesso ai dati condivisi) non può in generale essere determinato nel momento in cui viene scritto il programma, ma dipende dalla velocità di esecuzione dei diversi flussi concorrenti. Infatti, questi potrebbero essere eseguiti su processori diversi, ed esibire differenti velocità durante varie esecuzioni dell'applicazione. I potenziali problemi che abbiamo osservato nel caso di produttori e consumatori che accedono allo stesso buffer in maniera concorrente sono dovuti a sequenze di azioni particolarmente sfortunate. Potrebbe accadere che il sistema funzioni correttamente nella maggior parte delle esecuzioni, ma fallisca quando le azioni accadono in una determinata sequenza. Diverse sequenze di azioni di accesso al buffer potrebbero corrispondere a differenti velocità di esecuzione dei processi. Esistono svariati modi per influire sulla velocità di esecuzione dei processi. Innanzitutto, i processi potrebbero condividere lo stesso processore e lo schedulatore potrebbe assegnare una porzione prestabilita di tempo di processo a intervalli ciclici. O, in alternativa, alcuni processi potrebbero avere una priorità più alta rispetto ad altri. Oppure, ancora, ogni processo potrebbe eseguire su un processore fisico separato e dedicato. In questi tre casi la velocità di esecuzione dei processi è soggetta a variazioni. Vorremmo progettare il nostro software in modo da assicurare un comportamento corretto, indipendentemente dalla velocità di esecuzione dei processi. Il sistema dovrebbe avere lo stesso comportamento, sia che venga eseguito su una macchina monoprocessore sia che venga eseguito da una macchina multiprocessore, sia che il processore condiviso, nel caso della macchina monoprocessore, usi porzioni prestabilite di tempo di processo o priorità, e così via. Questo renderebbe la nostra soluzione più generale, permettendone il funzionamento su una famiglia di implementazioni della macchina astratta sottostante. Cambiare la macchina astratta sottostante, dunque, influirebbe solo sulle prestazioni del software e non sulla sua correttezza. Inoltre, ragionare sulla correttezza del progetto sarebbe più semplice, visto che il progetto potrebbe essere valutato senza prendere in considerazione la velocità dei processi. Per fare questo, estenderemo i concetti e la notazione degli oggetti astratti e dei tipi di dati astratti al caso del software concorrente. In particolare, seguiremo due paradigmi comuni della progettazione di software concorrente. Questi paradigmi, a loro volta, saranno riflessi nei costrutti forniti da alcuni linguaggi di programmazione esistenti. II primo approccio, ispirato dal linguaggio di programmazione Concurrent Pascal e ora reso popolare da Java, porta alla nozione di monitor, che rappresenta oggetti cui si vuole accedere concorrentemente come entità passive protette. Questo approccio è detto basato sui monitor. Il secondo approccio, ispirato dal linguaggio di programmazione Ada, porta al con-

certo di guardiano di risorsa, usato per rappresentare un oggetto concorrente attivo. Questo meccanismo per la sincronizzazione viene chiamato rendezvous\ per questo motivo l'approccio verrà detto basato su rendezvous. Anche se l'approccio scelto per descrivere il progetto software dovrebbe essere indipendente dal linguaggio di implementazione, la traduzione di un progetto su un programma risulta maggiormente diretta se entrambi sono basati sulla stessa filosofia. Certe strutture di progettazione (ad esempio, un progetto basato su rendezvous) sono più semplici da mappare su determinati linguaggi (per esempio, Ada). Inoltre, sarà necessario uno sforzo sostanzialmente maggiore nel caso in cui il linguaggio scelto sia sequenziale e dunque la concorrenza sia da ottenere tramite chiamate al sistema operativo sottostante. 4.5.1.1

Monitor

Un monitor è un oggetto astratto cui si può accedere in un ambiente concorrente. Il monitor garantisce ai propri clienti che le operazioni che esporta vengano eseguite in mutua esclusione. Se un processo P richiede l'esecuzione di un'operazione in un monitor già impegnato da un altro processo, il monitor sospende l'esecuzione di P. L'esecuzione verrà ripresa solo nel momento in cui P potrà accedere in modo esclusivo alle operazioni del monitor. Dal punto di vista del cliente, la mutua esclusione viene garantita dal monitor mediante la sua interfaccia; in realtà, il modo in cui viene fornita dipenderà dall'implementazione del monitor. Se dovessimo implementare il nostro sistema in un linguaggio come Concurrent Pascal o Java, la mutua esclusione potrebbe essere garantita direttamente dal linguaggio. Se il linguaggio non dovesse fornire alcun sistema automatico per assicurare la mutua esclusione, allora dovremmo garantirlo noi all'interno dell'implementazione della nostra applicazione. Ovviamente, la mutua esclusione, nell'esecuzione di operazioni individuali, non è sufficiente per garantire la correttezza nell'accesso agli oggetti condivisi. Come abbiamo visto prima, due consumatori potrebbero invocare l'operazione NOT_EMPTY per controllare che il buffer non sia vuoto, ed entrambi potrebbero ottenere l'autorizzazione ad eseguire la rimozione di un carattere. Nel caso in cui il buffer, originariamente, contenesse solo un carattere, il secondo tentativo di rimuovere un carattere genererebbe uno stato erroneo. Per risolvere problemi di questo tipo, estenderemo la nostra notazione di progettazione testuale, facendo sì che le operazioni esportate vengano associate a clausole opzionali r e q u i r e s . Dal punto di vista dei clienti questa clausola verrà controllata automaticamente all'atto di chiamare l'operazione. Se il risultato dovesse essere t r u e , allora l'operazione verrebbe eseguita normalmente, ma in mutua esclusione. Se il risultato dovesse essere f a l s e , allora il processo chiamante verrebbe sospeso in attesa che la condizione diventi t r u e . La sospensione dei processi rilascia la mutua esclusione precedentemente acquisita, in modo che altri processi possano ottenere il permesso di entrare nel monitor. A un certo punto, un processo che esegue un'operazione del monitor potrebbe far sì che la condizione su cui altri processi sono in attesa diventi t r u e . Tali processi potrebbero, allora, essere presi in considerazione per una ripresa dell'esecuzione. Una volta ripreso, il processo eseguirebbe l'operazione in mutua esclusione, come se avesse richiesto l'operazione solo in quel momento. In questo modo, il test sulla clausola r e q u i r e s e l'esecuzione dell'operazione associata risulterebbero in un'azione atomica.

concurrent Questo

module

CHARBUFFER

è un monitor

in u n ambiente u s e s ... exports

(ad

un

esempio,

modulo

di

oggetto

astratto

concorrente).

p r o c e d u r e P U T (C: in C H A R ) r e q u i r e s N O T F U L L ; p r o c e d u r e G E T (C: out C H A R ) r e q u i r e s N O T _ E M P T Y ; NOT_EMPTY e NOT_FULL sono funzioni booleane nascoste che, rispettivamente, restituiscono TRUE se il buffer non è vuoto o se non è pieno. Non vengono esportate come operazioni perché il loro scopo è solo quello di ritardare le chiamate a PUT e GET se queste vengono effettuate quando il buffer è in uno stato per non le può accettare.

end

CHAR

cui

BUFFER

Figura 4.19

Esempio di monitor in T D N .

Se, ad esempio, dovessimo scegliere Java come linguaggio di programmazione, tutte le sospensioni e le riattivazioni necessarie per l'adeguata gestione della clausola r e q u i r e s sarebbero fornite automaticamente dall'implementazione del monitor. Se dovessimo impiegare un linguaggio di programmazione sequenziale, la mutua esclusione e la clausola r e q u i r e s potrebbero essere implementate da appropriate chiamate al sistema operativo. La Figura 4.19 è un esempio di monitor che rappresenta un buffer di caratteri.

generic

concurrent

module

GENERIC_FIFO_QUEUE

Questo è un tipo di monitor generico astratto cui si accede in u n ambiente uses . . . exports type QUEUE: procedure

GET

requires

end

GENERIC

Figura 4.20

(Ql:in

out

NOT_FULL (Q2:in

QUEUE;

(Ql:

out

FIFO_QUEUE

in

EL)

QUEUE);

QUEUE;

NOT_EMPTY(Q2:

El: E2:

QUEUE);

(EL)

Esempio di tipo di monitor in T D N .

out

(EL)

esempio, un concorrente).

?;

PUT

requires procedure

(ad

EL)

tipo

di

dato

Aggiungiamo semplicemente la parola chiave c o n c u r r e n t per specificare la semantica del monitor per il modulo. È anche possibile definire monitor generici. Un esempio di monitor generico, che rappresenti code FIFO di componenti di qualsiasi tipo, è illustrato nella Figura 4.20. Le operazioni esportate da un monitor possono sollevare eccezioni, la cui sintassi rimane invariata. Ad esempio, nel caso del monitor C H A R B U F F E R , supponiamo che l'interfaccia specifichi che il carattere, parametro in ingresso di PUT debba soddisfare alcuni requisiti. La specifica di PUT verrebbe modificata e diventerebbe procedure raises

PUT

(C: in CHAR) requires

NOT_FULL

PAR_ERROR;

dove PAR ERROR è u n ' e c c e z i o n e sollevata d a PUT q u a n d o il p a r a m e t r o n o n s o d d i s f a i requisiti specificati nell'interfaccia.

Concludiamo a questo punto la nostra breve discussione sui monitor e i tipi di monitor, senza aggiungere ulteriori dettagli alla nostra notazione per la progettazione. Entrare nei dettagli solleverebbe diversi aspetti critici che renderebbero la nostra notazione più complessa ed eccessivamente orientata ai linguaggi di programmazione. Esercizio 4.39

Estendete G D N , f o r n e n d o u n a n o t a z i o n e grafica per i m o n i t o r e i tipi di m o n i t o r .

4.5.1.2

Guardiani e rendezvous

L'approccio alla progettazione di software concorrente basato sui monitor vede il sistema software composto da due tipi di entità: entità attive (ad esempio, processi) che hanno flussi di controllo indipendenti, e oggetti passivi. Gli oggetti passivi possono essere istanze di tipi astratti o di oggetti astratti. Gli oggetti passivi possono essere condivisi tra processi, o possono essere usati da un processo come risorsa privata. Un oggetto condiviso deve essere o un monitor o un'istanza di un tipo di monitor; altrimenti non avremmo la garanzia che gli accessi all'oggetto conservino uno stato consistente. Come abbiamo anticipato, esistono altri paradigmi per la progettazione di sistemi concorrenti. Un esempio di questi paradigmi è costituito dall'approccio scelto dal linguaggio di programmazione Ada. Secondo questo approccio gli oggetti privati sono le uniche entità passive di un sistema. Gli oggetti attivi (chiamati task in Ada) sono invece di due tipi: processi veri e propri e guardiani di risorse condivise. I guardiani sono loro stessi task, il cui unico scopo è quello di garantire un accesso ordinato al loro "segreto nascosto", che rappresenta una risorsa incapsulata, spesso una struttura dati. I guardiani sono task che non terminano, che ciclicamente aspettano richieste di esecuzione di una determinata operazione. Un guardiano potrebbe accettare o rifiutare una richiesta, secondo una determinata condizione basata sullo stato interno della risorsa controllata, accettandone, comunque, sempre una sola alla volta. Un task che inoltra una richiesta al guardiano viene sospeso fino a quando il guardiano non accetta la richiesta e completa l'esecuzione dell'azione associata; secondo la terminologia di Ada questo tipo di interazione tra task e guardiano viene detto rendezvous.

La stessa notazione sintattica che abbiamo fornito nel caso dell'approccio basato su monitor può essere utilizzata per descrivere un approccio alla progettazione basato su rendezvous. Ciò che cambia è ovviamente la semantica. Come esempio, si prenda il modulo concorrente della Figura 4.19. Se dovessimo interpretare la notazione di progettazione nel contesto dell'approccio basato su rendezvous, C H A R B U F F E R sarebbe un task che accetta richieste di operazioni sul proprio stato protetto mediante l'esecuzione o di GET O di PUT. Una richiesta di GET viene accettata solo se il buffer non è vuoto; una richiesta di PUT solo se il buffer non è pieno. Un task che fa di queste richieste (mediante chiamate appropriate) è sospeso fino a quando la richiesta non viene eseguita dal guardiano, ovvero fino a quando il guardiano non vede che la clausola w h e n diventa true e decide di rispondere alla richiesta ed eseguirne il contenuto. Il guardiano accetta richieste valide ciclicamente, senza mai fermarsi. Per chiarire queste questioni, si potrebbe assumere che, in un linguaggio basato su rendezvous, le parti interne del modulo assomiglino alla bozza di programma della Figura 4.21. Il programma, scritto in un linguaggio autoesplicativo simile ad Ada, descrive la struttura di un guardiano che implementa il modulo concorrente della Figura 4.20. L'esempio mostra come il guardiano controlli ripetutamente che non ci siano richieste da parte dei clienti. Sia l'approccio basato su monitor sia quello basato su rendezvous forniscono soluzioni non-deterministiche ai problemi di concorrenza. Il guardiano di CHAR BUFFER viene specificato come un server che accetta richieste di accesso al buffer, per aggiungervi un carattere o per estrarne uno. Le richieste di aggiunta di un carattere al buffer sono accettate se il buffer non è pieno; analogamente, le richieste di estrazione di un carattere dal buffer sono onorate se il buffer non è vuoto. Dal punto di vista del cliente, quando il buffer non è né pieno né vuoto, le richieste pendenti (se esistono) vengono gestite in maniera non-deterministica, come viene suggerito dal costrutto s e l e c t . . . o r . . . e n d s e l e c t della Figu-

loop select when

NOT_FULL

accept

PUT

(C:

in

Q u e s t o è il corpo come se fosse una

CHAR); di

PUT; il cliente procedura norma 1 e

lo

chiama

lo

ehiama

end ; or when

NOT_EMPTY

accept

GET

(C: out

Questo è il corpo come se fosse una

CHAR); di

GET; il cliente procedura normal e

end ; end end

select;

loop;

Figura 4.21

Struttura interna tipica di un task guardiano.

ra 4.21. Si noti che non abbiamo specificato cosa succede nel caso in cui diverse richieste dello stesso tipo (ad esempio GET) vengano fatte allo stesso guardiano. Anche in questo caso, possiamo assumere che la scelta di quale richiesta debba essere eseguita sia fatta nondeterministicamente 8 . Similmente, nell'approccio basato su monitor, diversi processi potrebbero essere in attesa che la condizione di mutua esclusione venga rilasciata. Quale tra questi viene realmente risvegliato quando il monitor si libera? E, infine, se diversi processi sono sospesi su una clausola r e q u i r e s e la condizione dovesse diventare vera, quale verrebbe scelto? In tutti questi casi, il comportamento del modulo, dal punto di vista del cliente, risulta non-deterministico. Ovvero, il modulo non rivela come prende realmente le sue decisioni. Il non-determinismo è una proprietà importante a livello di specifica, in quanto indipendente dalle particolari implementazioni di concorrenza. Il nostro progetto, dunque, non risulta sensibile al modo in cui verrà risolto il non-determinismo. Il linguaggio di programmazione che usiamo per implementare il sistema potrebbe operare delle scelte precise per i punti in cui abbiamo lasciato il non-determinismo, mentre altre scelte potrebbero venire prese dalla macchina astratta che supporta l'esecuzione del linguaggio di programmazione. Qualunque siano le scelte fatte durante l'implementazione, il sistema sarà corretto e varieranno solo le sue prestazioni. Evitare il non-determinismo a livello di specifica, costringe talvolta i progettisti a sovra-specificare inutilmente il comportamento di un sistema. Nella progettazione di sistemi concorrenti, è necessario essere particolarmente attenti, in modo da prevenire situazioni anomale durante l'esecuzione, che potrebbero causare il blocco, per un tempo indefinito, dell'intero sistema (o di un sotto-sistema). Questa situazione anomala viene chiamata deadlock. Ad esempio, consideriamo il caso in cui un processo A venga sospeso su una clausola r e q u i r e s X di un monitor. Supponiamo che l'unico modo affinché X diventi true è che un altro processo B esegua una determinata parte di codice. Ma ipotizziamo che il processo B sia a sua volta bloccato su una clausola r e q u i r e s Y di un monitor, e che l'unico modo per cui Y diventi true è che il processo A termini la sua chiamata al monitor ed esegua un certo pezzo di codice successivo. I processi A e B sono bloccati indefinitamente. Ciascuno aspetta che l'altro proceda nelle sue computazioni. I Capitoli 5 e 6 spiegheranno come sia possibile individuare situazioni anomale di questo tipo. L'individuazione può essere fatta fornendo un modello formale per l'architettura software e applicando poi metodi di analisi adeguati al modello formale. Ad esempio, illustreremo le reti di Petti, una notazione formale con cui è possibile modellizzare architetture concorrenti di questo tipo, e vedremo come, mediante l'analisi del modello delle reti di Petri, sia possibile individuare potenziali deadlock. Esercizio 4.40

Considerate un ambiente di programmazione c o m p o s t o da un linguaggio di programmazione sequenziale (ad esempio C) e un sistema operativo (ad esempio Unix). Fornite linee guida per la sua progettazione basata su monitor e su rendezvous.

In realtà Ada dice che queste richieste debbano essere gestite in una maniera first in, first out.

4.5.2

Software real-time

Nel paragrafo precedente abbiamo affrontato il problema dell'accesso concorrente a dati condivisi assumendo che si potesse sospendere l'esecuzione dei processi in competizione per un determinato periodo di tempo. Per esempio, nella progettazione basata su monitor, i produttori potevano essere sospesi qualora il buffer cui accedevano fosse pieno. Sfortunatamente, non è sempre possibile sospendere i processi. Per esempio, un'operazione invocata su un oggetto astratto potrebbe appartenere al flusso di esecuzione di un processo che non può essere sospeso, in quanto il processo è un'attività fisica, la cui evoluzione temporale non è sotto il controllo del sistema. Supponiamo, ad esempio, che in un impianto chimico un produttore sia un sensore che raccoglie dati da spedire a un controllore (un computer). In un caso come questo, potrebbe non esistere un modo di rallentare o sospendere l'impianto. Un dato inviato dall'impianto e non accettato in tempo utile dal controllore andrebbe irrimediabilmente perso. È compito del controllore provvedere a soddisfare i requisiti di velocità dell'impianto in modo che i dati inviati sulla linea vengano accettati senza perdite. Problemi di questo tipo caratterizzano i sistemi real-time, che possono essere definiti come sistemi per i quali la correttezza del funzionamento dipende dalla velocità di esecuzione dei processi che compongono il sistema stesso. Quando progetteremo sistemi di questo tipo dovremo soddisfare i requisiti che specificano i limiti di tempo entro i

concurrent module REACTIVE_ CHAR_BUFFER Questo è un oggetto simile a un monitor real-time. uses . . .

che

lavora

in

un

ambiente

exports reactive

p r o c é d u r e PUT

(C:

in

CHAR);

PUT viene utilizzato da processi esterni e due richieste consecutive a PUT devono arrivare distanziate da più di 5 millisecondi; altrimenti, alcuni caratteri potrebbero andare persi procedure

end

GET

(C:out

CHAR);

REACTIVE_CHAR_BUFFER (a)

Modulo PUT

REACTIVE_CHAR_BUFFER

GET

(b)

Figura 4.22

Rappresentazione (a) testuale e (b) grafica della notazione per la d e s c r i z i o n e di eventi.

quali devono essere eseguite determinate operazioni. Se alcune operazioni non dovessero essere eseguite entro i limiti (ad esempio se dovessero essere completate o troppo presto o troppo tardi) il sistema risulterebbe non corretto. I vincoli di rispetto dei limiti di tempo mostrano la differenza fondamentale tra un sistema puramente concorrente e un sistema concorrente real-time. Un sistema concorrente viene progettato ignorando la velocità dei processi. Applicando adeguati principi di progettazione, si può assicurare che il sistema sia corretto indipendentemente dalla velocità dei processi che lo compongono. I processi possono essere esplicitamente sospesi (oppure possono essere rallentati) in modo da assicurare la validità di determinate proprietà logiche. Per esempio, nel caso delle soluzioni basate su monitor discusse nel paragrafo precedente, siamo in grado di dire: "Nel momento in cui il produttore avrà il permesso di eseguire un'operazione di PUT il buffer avrà lo spazio libero necessario per immagazzinare il valore fornito dal cliente". Tutto ciò viene affermato dalla clausola r e q u i r e s . Tali affermazioni non hanno senso nel caso di un sistema real-time. Se i segnali in ingresso dovessero arrivare con una frequenza, supponiamo, di 5 millisecondi e, per ragioni di sicurezza, nessun segnale in ingresso deve esser perso, sapere che "prima o poi i segnali saranno inseriti in un buffer" non risolve il problema: il segnale deve essere inserito nel buffer entro 5 millisecondi (ovvero prima che arrivi il segnale successivo); altrimenti il segnale andrebbe perso. Per affrontare le questioni real-time non proponiamo alcun costrutto speciale nella nostra notazione di progetto, ma piuttosto suggeriamo di utilizzare commenti per aggiungere i requisiti necessari. Per esempio, si potrebbe usare un commento per dire che il tempo di esecuzione di una funzione esportata possiede limiti inferiori e superiori. I sistemi real-time molte volte interagiscono con un ambiente esterno che produce autonomamente stimoli, in momenti non prevedibili. Tali sistemi possono essere visti, dunque, come sistemi reattivi, che rispondono agli stimoli in ingresso forniti dal mondo esterno9. E quindi utile disporre di un modo per specificare che una data funzione rappresenta la risposta a una richiesta proveniente dall'ambiente esterno. In T D N specificheremo questo fatto con la parola chiave r e a c t i v e ; in GDN lo indichiamo mediante una freccia a zig zag (vedi Figura 4.22). Se un'operazione dovesse essere classificata come r e a c t i v e , vuol dire che la sua esecuzione non potrà essere ritardata, ad esempio, sospendendo il richiedente per riattivarlo più tardi, in un momento più conveniente. In pratica, le operazioni reattive vengono specificate indicando limiti per i loro tempi di esecuzione (ad esempio: "L'operazione può essere eseguita ogni x millisecondi, con 5 s x s 15"). E responsabilità del progettista assicurare che, quando sopraggiunge un richiesta per tale operazione, non vi siano altre operazioni del modulo in esecuzione. Altrimenti il risultato sarebbe imprevedibile. L'esperienza pratica ha mostrato che le questioni riguardanti il tempo sono estremamente critiche, e sono ciò che rende i sistemi real-time difficili da progettare e verificare. La complessità della progettazione e della verifica cresce sempre più, man mano che ci spostiamo da sistemi puramente sequenziali a sistemi concorrenti, e da sistemi concorrenti a sistemi realtime; ciò che fa la differenza è il tempo. Nel caso dei sistemi sequenziali, il tempo riguarda

9

II fatto che l'ambiente attivi un'operazione a intervalli n o n determinabili è tipico, anche se non esclusivo, dei sistemi real-time.

solo le prestazioni del sistema. Nel caso dei sistemi concorrenti è possibile organizzare il sistema in modo che un'adeguata sincronizzazione assicuri la correttezza, indipendentemente dal tempo. Ancora una volta, dunque, il tempo incide solo sulle prestazioni del sistema. Nel caso dei sistemi real-time, invece, il tempo incide sulla correttezza. Viene dunque introdotta un'ulteriore dimensione (la dimensione temporale) che occorre prendere in considerazione al momento della progettazione, dell'implementazione e della verifica dei sistemi. Oltre a essere intrinsecamente complessi, i sistemi real-time offrono spesso funzioni critiche, dove errori possono avere conseguenze disastrose, causando non solo gravi perdite finanziarie, ma addirittura di vite umane. Una delle qualità richieste in molti sistemi real-time, dunque, è l'affidabilità. La progettazione e la verifica di sistemi real-time affidabili sono attualmente oggetto di un'attiva ricerca.

4.5.3

Software distribuito

Una classe importante di sistemi concorrenti è costituita dai sistemi distribuiti, nei quali attività concorrenti vengono eseguite su computer differenti connessi da una rete di comunicazione. Ad esempio, i computer di un'azienda sono spesso connessi per mezzo di una LAN {locai area network) che consente agli utenti dei diversi computer di comunicare (ad esempio, tramite posta elettronica) e di condividere risorse (ad esempio, stampanti e file): in altre parole, di cooperare. Un insieme di LAN geograficamente distribuite possono essere connesse mediante una WAN {wide area network). Un sistema risultante dal collegamento delle diverse reti è detto inter-rete {internet). Nel caso in cui un'inter-rete appartenga o sia sotto il controllo di una singola organizzazione viene detta intranet. Una intranet può supportare diverse applicazioni distribuite, incluso il servizio interno di posta o il servizio interno basato sul web per distribuire informazioni ai dipendenti. In questo paragrafo forniremo una visione d'insieme delle questioni che hanno a che fare con la gestione di software distribuito. Commenti aggiuntivi verranno forniti dopo che, nel prossimo paragrafo, avremo introdotto la progettazione orientata agli oggetti. Inizieremo osservando come la distribuzione imponga ulteriori requisiti ai concetti di moduli e di relazioni tra moduli studiati fino a ora. I moduli guardiani di risorse (Paragrafo 4.5.1) sono direttamente utilizzabili in un'applicazione software distribuita e potrebbero rappresentare un'unità di distribuzione. Dobbiamo, tuttavia, imporre determinate restrizioni alla relazione U S E S tra due moduli che risiedono su due differenti macchine. In particolare, dato che i moduli su macchine diverse hanno spazi di indirizzi indipendenti, non possiamo consentire a un modulo di accedere direttamente alle variabili definite in altri moduli. Permetteremo, comunque, l'accesso indiretto a tali variabili attraverso procedure di accesso esportate dal modulo, descritte nel Paragrafo 4.6.3.3. Con il software distribuito dovremo considerare, al momento della progettazione, tre nuove questioni. •

I vincoli modulo-macchina. A volte è richiesto che un modulo sia eseguito su una particolare macchina. Ad esempio, se lo scopo del modulo è fornire un servizio di stampa, dovrà essere eseguito su un computer cui è collegata una stampante. In altri casi, il modulo potrebbe essere eseguito su un'ampia classe di macchine (ad esempio, i moduli che hanno una connessione a un gateway, in modo che possano raggiungere le reti esterne all'azienda).



La comunicazione tra moduli. Se due moduli risiedono su macchine differenti, come possono comunicare? Abbiamo visto che tutti i moduli che risiedono sulla stessa macchina possono comunicare mediante l'uso di un'area globale condivisa: un modulo vi registra le informazioni mentre un altro le legge. Questo approccio, che funziona sia per programmi sequenziali che per programmi concorrenti, non può essere esteso direttamente a un sistema distribuito visto che i moduli si trovano su macchine diverse. Un altro approccio alla comunicazione tra moduli in ambiente sequenziale consiste nel passaggio di parametri nella chiamata a una procedura e nei valori che la stessa procedura restituisce. Il meccanismo di chiamata alle procedure è stato esteso ai sistemi distribuiti attraverso il meccanismo di remote procedure cali (RPC, chiamata a procedura remota) in cui non viene richiesto che il chiamante e il chiamato siano sulla stessa macchina. Il linguaggio Java ha introdotto la nozione di remote method invocation (RMI, invocazione di metodo remoto), la quale permette che un oggetto chiami una procedura in un oggetto che risiede su un'altra macchina 10 . Un ulteriore approccio per affrontare la comunicazione intermodulare in un ambiente distribuito consiste nell'uso di messaggi. Esistono diverse librerie e diversi servizi forniti dai sistemi operativi per il supporto allo sviluppo di applicazioni che facciano uso di RPC o messaggi.



L'accesso efficiente agli oggetti astratti. Abbiamo identificato gli oggetti astratti come tipi di moduli che si propongono in maniera del tutto naturale durante la progettazione di un sistema. In un sistema centralizzato, non incontriamo costi elevati per incapsulare i dati necessari al modulo MT in un oggetto astratto M2. In un sistema distribuito, se i due moduli si trovano su macchine diverse, l'accesso ai dati contenuti in M2 da parte di M! richiederà molto più tempo (a causa della chiamata a procedura remota) rispetto all'accesso a dati contenuti in Mj. I tempi d'accesso non locali possono essere diversi ordini di grandezza più alti per dati remoti. Due approcci possibili per rendere più efficienti gli oggetti astratti in un ambiente distribuito sono la replicazione e la distribuzione.

Esamineremo queste questioni in maggiore dettaglio più avanti. Ma prima discuteremo brevemente un modello particolare per la strutturazione di un sistema distribuito: il modello client-server. 4.5.3.1

II modello client-server

Abbiamo detto che il ruolo dei moduli è quello di fornire servizi agli altri moduli, detti suoi client. Questo modello è direttamente applicabile alle architetture distribuite. L'architettura più popolare per le applicazioni distribuite consiste in moduli client e moduli server che risiedono su macchine diverse. Per esempio, consideriamo un servizio di stampa fornito a un'intera rete di computer. In questa rete, alcuni computer saranno connessi con stampanti, altri no. Possiamo progettare il servizio di stampa in modo che consista di moduli client e di moduli server. Il server riceve un file e lo stampa su una stampante. Il client accetta un no-

10

I termini oggetto e metodo sono definiti nel Paragrafo 4.6, che si occuperà della progettazione orientata agli oggetti.

me di file dall'utente e spedisce i contenuti del file a un modulo server, insieme alle informazioni riguardanti l'utente che ha richiesto l'operazione di stampa. Alcuni dei moduli che abbiamo incontrato possono essere considerati, in modo del tutto naturale, moduli server in un'architettura di tipo client-server. Per esempio, un modulo simile all'esempio BUFFER del Paragrafo 4.5.1 potrà essere utilizzato dai moduli client del servizio di stampa per depositarvi i file da stampare. I moduli client sono i produttori, mentre il modulo server è il consumatore. Anche i moduli guardiani di risorse del Paragrafo 4.5.1.2 possono modellare i server in un'applicazione distribuita. Esercizio 4.41

Lo stesso m o d u l o può essere client in un contesto e server in un altro. Per esempio, consideriamo un servizio di stampa che consiste di un numero di moduli client eseguiti su macchine senza stampanti, un certo numero di moduli BUFFER eseguiti su macchine qualsiasi e un certo numero di moduli server eseguiti su macchine che invece possiedono stampanti. Verificate se il BUFFER è client o server.

4.5.3.2

II vincolo modulo-macchina

Come abbiamo detto, una questione che occorre affrontare nelle architetture software distribuite è quella del vincolo tra moduli e macchine. A volte, come nell'esempio del server di stampa, il vincolo è imposto dall'ambiente fisico o dall'infrastruttura sottostante. In altri casi, esiste la possibilità di scelta e questa scelta potrà essere guidata da diverse considerazioni. Per esempio, per poter ridurre il costo della comunicazione potremmo volere vincolare i moduli server ad alcune macchine vicine ai propri client, eventualmente sulla stessa macchina, qualora fosse possibile. Un'altra questione è se il vincolo debba essere statico o dinamico. Un vincolo statico è più semplice, ma la possibilità di scegliere il luogo di esecuzione di un modulo in maniera dinamica ci permette, ad esempio, di scegliere un computer con un basso carico di lavoro, al fine di migliorare le prestazioni dell'applicazione. Questa possibilità è essenziale anche per il supporto di sistemi altamente affidabili, visto che il malfunzionamento di una macchina potrà essere tollerato spostando i moduli ivi allocati su un'altra macchina. Questo spostamento dinamico di processi viene detto migrazione. Per un approfondimento dei dettagli su questo argomento rimandiamo alla letteratura specializzata. Un modulo può essere, o meno, ¡stanziabile (cioè, può essere creato) dinamicamente. Alcuni sistemi supportano la creazione di processi a run-time, mentre altri non lo fanno. Nel caso in cui i processi possano essere creati dinamicamente l'applicazione potrà determinare a run-time quanti esemplari del processo debbano essere eseguiti. E anche possibile, in alcuni casi, definire su quale macchina il processo creato debba andare in esecuzione. Diversi linguaggi e librerie che supportano il software distribuito offrono una grande varietà di opzioni per il progettista. Esercizi 4.42

Spiegate per quale motivo la creazione dinamica di processi potrebbe non essere desiderabile in un sistema real-time.

4.43

Considerate un'applicazione che dovrebbe essere accessibile da qualsiasi macchina di una rete. Ci sono migliaia di macchine sulla rete ma ci aspettiamo che l'applicazione n o n sia eseguita da più di 10 utenti alla volta. Progettate una soluzione a questo problema. Poter vincolare dinamicamente moduli alle macchine risulta utile per questo esempio? C o m e si potrebbe utilizzare la migrazione dei processi in quest'applicazione? Quale sarebbe la vostra soluzione nel caso venisse richiesto l'uso di vincoli statici tra i moduli e le macchine?

4.5.3.3

Comunicazione tra moduli

Nelle applicazioni distribuite vengono utilizzati due modelli di comunicazione: la chiamata a procedure remote e la spedizione di messaggi. Il meccanismo della chiamata a procedure remote è un'estensione della chiamata a procedura tradizionale, che consente ai moduli chiamanti e chiamati di risiedere su macchine differenti. Sono disponibili diversi pacchetti commerciali che supportano questo tipo di interazione sotto diversi sistemi operativi. Questi pacchetti offrono un IDL (interface definition language, linguaggio di definizione di interfacce) e un compilatore. Usando IDL il progettista definisce un'interfaccia per qualsiasi procedura che possa essere chiamata da client remoti. Il compilatore elabora le definizioni e genera (o include) file header (di intestazione) che i client e i server includeranno al momento della loro compilazione; questi file forniranno l'accesso alle procedure stub che supportano la comunicazione intermodulare. Per via della similitudine tra le chiamate a procedure remote e le chiamate a procedure tradizionali è possibile progettare le applicazioni senza distinguere tra servizi richiesti locali e remoti: qualsiasi interfaccia di modulo scritta in termini di chiamate a procedure potrà essere supportata in un'applicazione distribuita. In pratica però ci sono molti dettagli che ostacolano questo approccio. La prima differenza tra chiamate a procedure locali e remote risiede nelle prestazioni. Dato che i parametri della chiamata devono essere trasmessi sulla rete, l'overhead di RPC è un ordine di grandezza maggiore di una chiamata locale. Cambiare da una chiamata locale a una remota potrà avere un impatto significativo sulle prestazioni di un'applicazione, considerando, inoltre, che un compilatore potrebbe a volte generare codice in-line per le chiamate locali in modo da migliorare le prestazioni, mentre la cosa non è possibile per le chiamate remote. Un'altra differenza sostanziale tra le chiamate a procedure locali e remote risiede nelle modalità del passaggio dei parametri. Anche se i concetti di chiamata e risposta di una procedura possono essere implementati in maniera semplice, non tutte le modalità di passaggio dei parametri possono essere supportate in remoto. Per esempio, quando i moduli chiamanti e i chiamati risiedono all'interno di due spazi di indirizzamento separati, due moduli non possono comunicare in termini di puntatori. Questo significa che il passaggio di parametri per riferimento, o il passaggio di strutture dati concatenate, potrebbe risultare molto problematico, sempre che risulti possibile. Di conseguenza, i sistemi commerciali di chiamate a procedure remote generalmente non supportano il passaggio di strutture a puntatori. Il paradigma della spedizione di messaggi per l'interazione intermodulare può essere pensato in termini di cassette postali. Si può considerare che ogni modulo abbia una cassetta della posta in cui riceve messaggi dagli altri moduli. I moduli client possono spedire messaggi alla cassetta della posta di un modulo server. Un server raccoglierà una richiesta dalla propria cassetta della posta, agirà di conseguenza e, se necessario, spedirà una risposta

alla cassetta della posta del client. Le considerazioni più importanti da fare nell'invio di messaggi riguardano le dimensioni delle cassette della posta (quanti messaggi possono essere inseriti nel buffer), se la spedizione di messaggi debba essere sincrona o asincrona e se un modulo possa scegliere una cassetta della posta piuttosto che un'altra in modo dinamico, o se la scelta debba rimanere statica. Anche se i due paradigmi di chiamata a procedure remote e di spedizione di messaggi hanno la stessa potenza (possono simularsi a vicenda), rappresentano soluzioni appropriate per architetture software differenti. La differenza più consistente risiede nel fatto che la chiamata a procedure remote è di per se stessa una modalità di interazione sincrona, mentre la spedizione di messaggi è asincrona. Ciò significa che un modulo che fa una richiesta a una procedura remota deve necessariamente aspettare la risposta, mentre un client che spedisce un messaggio può continuare con il suo thread di controllo. L'esistenza di diversi thread di controllo significa che il progettista dovrà affrontare i thread concorrenti in maniera esplicita. Esercizi 4.44

Considerate un'applicazione in cui u n m o d u l o sensore legge una serie di valori su una linea di ingresso e li spedisce a un m o d u l o di registrazione, per ulteriori elaborazioni. Se questi due moduli dovessero essere distribuiti su due differenti macchine, quale forma di comunicazione inter-modulare scegliereste di utilizzare? Perché?

4.45

Per l'esercizio precedente, abbozzate ciascun m o d u l o in un'estensione adeguata di T D N , usand o una volta la chiamata a procedura remota e una volta la spedizione di messaggi.

4.5.3.4

Replicazione e distribuzione

La considerazione finale inerente la progettazione di software per un ambiente distribuito riguarda la necessità di rendere efficiente l'accesso ai dati. In particolare, abbiamo enfatizzato come l'oggetto astratto sia un tipo di modulo utile, che fornisce ai moduli client un accesso a una struttura dati incapsulata al proprio interno. Ciò significa che un modulo client potrà inviare una richiesta, solitamente attraverso una chiamata a una procedura, per i dati cui deve accedere. Il costo dell'accesso ad alcuni dati attraverso una chiamata a procedura invece che direttamente nella memoria (come succede nel caso di dati locali) è considerato eccessivo addirittura in alcune applicazioni centralizzate. Il costo cresce considerevolmente nel caso in cui l'oggetto astratto si trovi su una macchina remota. Il costo dell'accesso remoto sulle reti più veloci è circa quattro volte il costo di un accesso locale e può incrementare fino a un ordine di grandezza più alto. Abbiamo, dunque, necessità di un modo per rendere efficiente l'accesso a oggetti astratti, se vogliamo utilizzarli all'interno di applicazioni distribuite. Esistono due metodi generali per riuscirci. Il primo approccio è quello di replicare l'oggetto distribuito su diverse macchine, addirittura su ogni macchina, se necessario. Nel secondo caso, ciascun cliente dovrebbe avere accesso all'oggetto astratto localmente. Il problema ora diventa che se un cliente modifica una copia dell'oggetto, tutte le copie dell'oggetto devono essere mantenute coerenti, in modo che i diversi clienti continuino a osservare lo stesso oggetto piuttosto che tanti oggetti diversi. Sono state sviluppate numerose tecniche per risolvere il problema della coerenza dei dati, sia nell'area dei sistemi operativi che in quella dei database.

Un'altra soluzione per velocizzare l'accesso ai dati remoti è quella di distribuire l'oggetto astratto su macchine differenti. Ovvero, anche se l'oggetto, dal punto di vista logico, rimane uno solo, possiamo partizionarlo fisicamente per poi vincolare diverse partizioni a diverse macchine, mettendo ogni partizione vicino ai clienti che, con maggiore probabilità, avranno bisogno di accedervi. Per ogni particolare oggetto astratto che deve essere utilizzato in un'applicazione distribuita dobbiamo considerare se ha senso replicarlo, partizionarlo, realizzare entrambe le cose o nessuna delle due. Esercizi 4.46

Estendete sia T D N che G D N perché possano gestire problematiche di allocazione dinamica, comunicazione intermodulare, replicazione e distribuzione.

4.47

Considerate un servizio di stampa. U n m o d u l o BUFFER immagazzina le richieste di stampa. D o v r e m m o partizionare o replicare BUFFER? Perché? Perché no?

4.48

Considerate un'applicazione per la gestione di conti bancari. U n oggetto astratto rappresenta tutti i clienti della banca, che ha molte sedi distribuite sul territorio nazionale. Ciascuna sede dispone di un computer per l'accesso all'oggetto dei conti degli utenti. Replichereste o partizionereste l'oggetto? Perché?

4.49

Abbozzate la progettazione di un'applicazione per la prenotazione di stanze per conferenze. Centinaia di stanze devono poter essere prenotate per un qualsiasi arco di tempo. Migliaia di macchine sulla rete dovranno poter accedere all'applicazione.

4.50

Considerate un'applicazione che si aspetta di ricevere in ingresso i dati riguardanti i titoli di borsa su un servizio di linea per renderli disponibili a tutti i computer di una rete, mediante diverse tipologie di query. Decidiamo di usare u n oggetto astratto per rappresentare i dati dei titoli di borsa. Partizionereste o replichereste i dati? Perché?

4.5.3.5

Middleware

La proliferazione di reti, inter-reti e intranet ha causato lo sviluppo di numerose applicazioni software distribuite, che si affidano a molti servizi comuni per eseguire i propri compiti. Per esempio, tutti devono poter trovare i servizi sulla rete, come quello di stampa, e devono potere trovare diversi processi e comunicare con loro. Il riconoscimento di tali servizi comuni ha portato a un nuovo strato di software detto middleware. Lo strato di middleware risiede tra lo strato di rete del sistema operativo e lo strato dell'applicativo. Proprio come i sistemi operativi forniscono ai programmi servizi, ad esempio, per la gestione di file e direttori, il middleware fornisce alle applicazioni distribuite servizi di distribuzione. Tipicamente, il middleware fornisce le seguenti due operazioni: •

servizi basati sui nomi: per trovare processi e risorse sulla rete;



servizi per la comunicazione: varie forme di comunicazione tra processi, come la spedizione di messaggi o le chiamate a procedure remote.

Questi servizi sono utilizzati da quasi tutte le applicazioni distribuite. I servizi di comunicazione forniscono importanti operazioni per l'impacchettamento dei parametri e il trasporto attraverso macchine eterogenee. Senza tali servizi, lo sviluppatore dell'applicazione

dovrebbe occuparsi della conversione tra tipi di dati quando comunicano processi allocati su due diversi computer. Attualmente, i sistemi di middleware forniscono servizi per costruire applicazioni distribuite su reti locali e se ne sta studiando l'estensione per sistemi distribuiti su Internet. Le sfide principali di questi sistemi di middleware sono la scalabilità e l'affidabilità. Le applicazioni su Internet devono essere in grado di gestire milioni di clienti e di affrontare parziali malfunzionamenti della rete. Grazie al middleware, chi progetta un sistema distribuito non deve cominciare da zero, ma può fare affidamento e, quindi, riusare componenti preesistenti. I sistemi di middleware forniscono molti altri servizi oltre a quelli associati ai nomi e alla comunicazione. Servizi comuni potranno essere la creazione di log, la gestione delle transazioni, la notifica di eventi, la sicurezza e così via. Nel Paragrafo 4.7 illustreremo CORBA, uno specifico standard per il middleware.

4.6

Progettazione orientata agli oggetti

La progettazione orientata agli oggetti (OO, object-oriented) estremizza l'approccio alla progettazione basato sui tipi di dati astratti, e si è sempre più diffusa man mano che i linguaggi O O (Smalltalk, C++, Java e altri) hanno incontrato un ampio impiego nella pratica. Nella progettazione O O esiste un solo tipo di modulo: il modulo di tipo di dato astratto. Usando la terminologia propria della progettazione O O chiameremo tali moduli classi. Una classe esporta le operazioni che possono essere utilizzate per manipolare i suoi esemplari (istanze). Tali operazioni vengono definite da funzioni, solitamente chiamate metodi nella terminologia OO. Le classi possono anche rivelare parte dei loro segreti interni, attraverso l'esportazione di attributi". Gli oggetti sono istanze di classi e le variabili sono riferimenti agli oggetti12. Modificheremo T D N in modo che esprima il fatto che tutti i moduli implementano tipi di dati astratti. Invece di usare la notazione " t y p e X = ?" nell'interfaccia di un modulo X per introdurre il nome del tipo, lasceremo che i moduli clienti usino direttamente il nome della classe. Quindi, invece di dichiarare un riferimento a un oggetto del tipo di dato astratto XX esportato dal modulo X come "a : XX . x", scriveremo "a : x" per esprimere che a è un riferimento all'istanza di un oggetto del tipo di dato astratto implementato dalla classe x. Un altro cambiamento sostanziale verrà apportato per quanto riguarda la sintassi delle operazioni che vengono invocate sugli oggetti istanziati. Nel caso del modulo di tipo di dato astratto X che esporta XX, l'invocazione dell'operazione op che manipola l'oggetto riferito da a veniva scritta come op(a,

altri_parametri )

Nel caso della progettazione O O scriveremo a.op(altri_parametri)

11 12

Un attributo di sola lettura è come una funzione esportata con il fine di fornire il valore dell'attributo. Ci riferiremo implicitamente al modello di dati supportato da Java.

per indicare l'invocazione dell'operazione op fornita dall'istanza a della classe X. Tutte le operazioni esportate da un modulo OO, dunque, opereranno su un oggetto istanziato. La progettazione O O insiste nell'identificare le classi e le relazioni tra classi. Le relazioni vengono usate da O O in maniera molto ampia e astratta. Discuteremo tra breve le varie tipologie di relazioni e introdurremo una notazione grafica che specializza e rimpiazza il nostro GDN nel caso della progettazione OO. Questa notazione, chiamata UML, viene comunemente usata per la descrizione di progetti OO. UML verrà ulteriormente trattato nel Paragrafo 4.6.4.

4.6.1

Generalizzazione e specializzazione

La progettazione O O permette l'organizzazione di tipi di dati astratti in maniera gerarchica mediante le relazioni di generalizzazione e specializzazione. Tale gerarchia definisce uno schema per la classificazione per i tipi di dati astratti. Se la classe B specializza la classe A (e pertanto, A generalizza B) allora il tipo di dato astratto implementato da B definisce oggetti che si comportano come le istanze di A, ma che possono offrire più metodi e più attributi. Di conseguenza tutti i metodi e gli attributi definiti per A possono essere utilizzati per manipolare gli oggetti di B (i quali possono essere manipolati anche mediante l'uso dei metodi e degli attributi definiti specificatamente per B). B viene detto sottoclasse di A, mentre A è superclasse di B. La relazione di generalizzazione-specializzazione può essere implementata attraverso l'uso del meccanismo di ereditarietà fornito dai linguaggi di programmazione. È per questo che molte volte diciamo che "B eredita da A" per indicare che "B specializza A". Possiamo anche dire che B è erede della classe A e che A è la classe padre di B. Come esempio, si consideri la classe E M P L O Y E E definita in T D N della Figura 4.23. La classe EMPLOYEE definisce le proprietà comuni a qualsiasi tipo di impiegato. Tutti gli esemplari di EMPLOYEE (che rappresentano singole persone) sono caratterizzate dalle operazioni fornite dalla classe per la manipolazione delle istanze. Ad esempio, un impiegato può essere assunto con uno stipendio iniziale, ricevendo di conseguenza un identificatore univoco; potrebbe essere licenziato, e quindi perderebbe l'identificatore univoco; potrebbe essere assegnato a una filiale dell'azienda; potrebbe ricevere una promozione; potrebbe essere ricercato in funzione del suo nome, della sua età, del suo stipendio, del suo identificatore univoco, etc. Alcuni impiegati sono membri di uno staff tecnico, altri sono membri dello staff amministrativo mentre altri ancora non appartengono a nessuna delle due categorie. Per questo motivo definiamo nella Figura 4.24 due sottoclassi: T E C H N I C A L _ S T A F F e A D M I N I S T R A TIVE_STAFF. Un membro dello staff amministrativo gode di tutte le proprietà degli impiegati: è a tutti gli effetti un impiegato. Da un punto di vista del tipo di dato astratto, ciò significa che gli oggetti corrispondenti possono essere manipolati da tutte le operazioni definite dalla classe padre E M P L O Y E E e da tutte le altre che caratterizzano il modulo erede. Secondo la terminologia O O diremo che A D M I N I S T R A T I V E _ S T A F F eredita automaticamente tutti i metodi e gli attributi definiti da EMPLOYEE. Ovvero, i membri dello staff amministrativo possono essere assunti, licenziati, etc. In aggiunta, potrebbero ottenere del lavoro da svolgere mediante il passaggio di una pratica. Quest'ultima operazione è specifica della classe erede; non viene ereditata da alcuna classe padre. In maniera simile, i membri dello staff tecnico, a parte i metodi e gli attributi ereditati da EMPLOYEE, sono caratterizzati da metodi aggiuntivi che rendono possibile la definizione e la richiesta della loro

class

EMPLOYEE

exports function

FIRST_NAME():

function

LAST_NAME():

string_of_char; string_of_char;

function

AGE():

function

WHERE( ) : SITE;

function

SALARY:

procedure

HIRE

naturai; MONEY;

(FIRSTN: LAS T _ N :

string

of

char ;

string_of_char;

INIT_SALARY: MONEY); Questa operazione inizializza un n u o v o un nuovo identificatore univoco. procedure FIRE(); procedure

ASSIGN

(S:

EMPLOYEE,

assegnandogli

SITE);

Non è possibile assegnare un impiegato a un sito se vi sia già stato assegnato in precedenza (ad esempio, WHERE deve essere diverso da S). È responsabilità del cliente assicurare che questa proprietà risulti vera. L'effetto è quello di cancellare l'impiegato da quelli in WHERE, aggiungere 1 ' impiegato a quelli in S, generare una nuova scheda identificativa per l_impiegato con il codice di sicurezza per l'accesso al sito durante le ore notturne e aggiornare WHERE. end

EMPLOYEE

Figura 4.23

D e f i n i z i o n e d e l l a c l a s s e EMPLOYEE i n T D N .

principale abilità. Infine gli individui che non sono né membri dello staff tecnico né membri dello staff amministrativo vengono rappresentati da istanze della classe EMPLOYEE e non appartengono ad alcuna delle sue sottoclassi. Dal punto di vista della progettazione del software, la generalizzazione-specializzazione può essere utilizzata per fattorizzare le parti comuni di diversi componenti all'interno della classe padre per poi evidenziare le variazioni all'interno delle classi eredi. Questo approccio ha il potenziale necessario per migliorare la riusabilità. Infatti, possiamo tentare di raggruppare in un modulo tutte le caratteristiche abbastanza generali da essere riutilizzabili. Le caratteristiche aggiuntive necessarie in determinate applicazioni potranno essere aggiunte successivamente per mezzo di moduli eredi. Possiamo anche vedere l'ereditarietà come un modo per costruire il software in maniera incrementale. L'ereditarietà facilita, dunque, l'evoluzione dei sistemi man mano che emergono nuovi requisiti e può rendere la manutenzione più facile da eseguire. Infatti, ogni volta che sorge la necessità di modificare un modulo esistente M[ per ottenere un nuovo modulo M2, invece di modificare Mj possiamo estenderlo e applicare i cambiamenti necessari a trasformarlo in M2. I tipi di cambiamenti che abbiamo fin qui analizzato consistono esclusivamente nell'aggiunta di nuove operazioni ai tipi di dati astratti. Esamineremo altri tipi di cambiamenti a breve. Abbiamo utilizzato l'incrementalità per definire due eredi di EMPLOYEE. I due moduli eredi sono stati definiti semplicemente elencando le differenze rispetto al modulo padre. Per

class

ADMINISTRATIVESTAFF

inherits

EMPLOYEE

exports procedure

end

DOTHIS

(F:

FOLDER);

ß u e s t a è un'operazione specifica altre operazioni possono essere ADMINISTRATIVE_STAFF

class

TECHNICAL_STAFF

inherits

degli amministratori; aggiunte.

EMPLOYEE

exports function procedure

end

GET_SKILL(): DEFSKILL

SKILL;

(SK:

SKILL);

Queste sono operazioni aggiuntive altre operazioni possono essere TECHNICAL STAFF

Figura 4.24

specifiche aggiunte.

dei

tecnici ;

D e f i n i z i o n e di s o t t o c l a s s i in T D N .

essere più precisi, il modulo erede è ottenuto dal modulo padre come una copia della sua implementazione con l'aggiunta di alcune nuove caratteristiche. Un altro modo per concepire la gerarchia di generalizzazione-specializzazione è quello di vedere una classe erede come l'implementazione di un sottotipo del tipo definito dalla superclasse. Un elemento di un sottotipo dovrebbe poter apparire in qualsiasi contesto in cui possa apparire un'istanza del tipo padre. Questo viene sovente detto principio di sostituibilità. Siccome tutte le istanze di una sottoclasse ereditano gli attributi e i metodi della sua classe padre, il principio di sostituibilità è soddisfatto in maniera banale. La progettazione O O aggiunge, però, ulteriori caratteristiche alla relazione di generalizzazione-specializzazione. Una sottoclasse non solo può aggiungere nuovi attributi e metodi ma può anche ridefinire i metodi definiti nella propria classe padre. Ad esempio, supponiamo che E M P L O Y E E fornisca un metodo per la promozione di ruolo, che aumenta lo stipendio di un impiegato. Le classi T E C H N I C A L _ S T A F F e A D M I N I S T R A T I V E _ S T A F F potrebbero ridefinire il metodo in modo che gli aumenti siano diversi. Supponiamo di considerare un programma che manipola un oggetto X del tipo E M P L O Y E E . Secondo il principio di sostituibilità, tale programma dovrebbe funzionare altrettanto bene nel caso gli venisse fornito un sottotipo di E M P L O Y E E (ad esempio, T E C H N I C A L _ S T A F F ) . Se dovesse essere invocato su X il metodo per la promozione, qualora X fosse legato a un'istanza della classe TECHNICAL_STAFF, verrebbe chiamato il metodo per la promozione ridefinito nella classe T E C H N I C A L _ S T A F F . I concetti fondamentali collegati a questo approccio sono il polimorfismo e il binding (legame) dinamico. Dato che x è un oggetto del tipo EMPLOYEE, possiamo legarlo a oggetti di uno qualsiasi dei suoi sottotipi (polimorfismo) e i metodi che verranno invocati dipenderanno dal tipo dell'oggetto legato a x al momento dell'esecuzione. Concludiamo la discussione in merito a generalizzazione-specializzazione fornendone la descrizione attraverso una notazione grafica. Come precedentemente accennato, in questo paragrafo introduciamo gradualmente gli elementi della notazione UML, in cui le clas-

Figura 4.25

Rappresentazione UML di una generalizzazione.

si sono rappresentate da scatole divise in tre parti, corrispondenti al nome della classe, agli attributi e ai metodi, mentre la relazione per la generalizzazione-specializzazione viene rappresentata da un connettore triangolare tra classi. Nella Figura 4.25 viene mostrata una descrizione della rappresentazione testuale presentata nella Figura 4.23 e nella Figura 4.24. Si osservi che la relazione USES tra classi non viene indicata esplicitamente. Piuttosto, è implicita nel fatto che i tipi di certi attributi o parametri per i metodi non sono elementari, ma sono definiti da altre classi (che, pertanto, sono utilizzate).

4.6.2

Associazioni

Le associazioni rappresentano relazioni tra istanze di classi che si richiede siano supportate dall'implementazione. Per esempio, i membri dello staff tecnico potrebbero essere associati al progetto cui stanno lavorando. (Ciascun tecnico lavorerebbe esattamente su un progetto, ma diversi tecnici potrebbero lavorare sullo stesso progetto.) La Figura 4.26 mostra come le associazioni possano essere rappresentate dal frammento di un diagramma delle classi UML. Il frammento introduce un'altra sottoclasse di TECHN I C A L _ S T A F F , chiamato M A N A G E R , e un'ulteriore associazione tra manager e progetti. Il diagramma mostra come i manager siano un tipo particolare di staff tecnico (ovvero, nel mondo particolare di cui stiamo parlando, un membro dello staff amministrativo non può essere un manager) e come un manager possa essere associato a uno o più progetti. Per semplicità, la Figura 4.26 non fornisce i dettagli dell'interfaccia della classe. (Per le classi EMPLOYEE e T E C H N I C A L _ S T A F F il lettore può fare riferimento alla Figura 4.24.) Le associazioni in UML vengono rappresentate da collegamenti, etichettati con il nome dell'associazione, tra le scatole che rappresentano le classi. Le associazioni possono coinvolgere più classi. Nella maggior parte dei casi, tuttavia, sono relazioni binarie (ovvero coinvolgono due classi); nel prosieguo, pertanto, assumeremo implicitamente che le associazioni siano binarie. Inoltre, le associazioni possono essere descritte specificando vincoli di molteplicità, i quali indicano quanti oggetti possono partecipare alla relazione. Per esempio, la Figura 4.26 mostra come un numero qualsiasi di tecnici possano essere coinvolti in un progetto (indicato dal vincolo di molteplicità "*" alla fine del collegamento dalla parte di T E C H N I C A L STAFF), mentre un tecnico può essere coinvolto in un solo progetto (indicato dal vincolo di molteplicità "L" sul collegamento dalla parte di PROJECT). In generale, i

vincoli di molteplicità vengono dati specificando " l i m i t e i n f e r i o r e . . l i m i t e s u p e r i o r e " . L'abbreviazione "*" in realtà significa "0. . i n f i n i t o ) , mentre " l " significa " l . . l". Ad esempio, se dovesse essere richiesto che almeno un tecnico sia coinvolto in un progetto, il limite di molteplicità "*" verrebbe sostituito da " l . . *". I vincoli di molteplicità assegnati all'associazione tra MANAGER e PROJECT mostrano come un manager possa gestire uno o più progetti. La specifica che i manager non gestiscano più di tre progetti, richiederebbe la sostituzione del vincolo di molteplicità " l . . *" con " l . . 3". La specifica delle associazioni in un diagramma delle classi, come quello mostrato nella Figura 4.26, non fornisce informazioni sufficienti per derivarne un'implementazione. Ad esempio, specifica che i manager sono associati ai progetti che gestiscono e che i progetti sono associati ai tecnici che vi prendono parte. Ma l'implementazione che ne deriverà dovrà supportare la navigazione sia dai progetti ai loro manager che dai manager ai progetti che gestiscono? Per navigazione dai manager ai progetti intendiamo che, dato un manager, siamo in grado di determinare tutti i progetti che gestisce. Questioni simili possono essere poste per l'associazione tra TECHNICAL_STAFF e PROJECT. Per rispondere a tali domande UML permette al progettista di decorare le associazioni con una freccia di navigabilità. Per esempio, il frammento di progetto illustrato nella Figura 4.26 indica che l'associazione tra MANAGER e PROJECT è tale che è sufficiente poter navigare da un manager ai progetti di cui è responsabile. Invece, se nessuna freccia di navigabilità viene fornita per guidare l'implementazione, dovremmo assumere che la navigazione debba essere possibile in entrambe le direzioni. Quindi, per esempio, l'implementazione dovrebbe supportare la navigazione da un tecnico ai progetti che gli sono stati assegnati e da un progetto ai tecnici che vi partecipano. La discussione precedente dimostra che le associazioni che introduciamo al momento della progettazione pongono obblighi all'implementazione per quanto riguarda la navigabilità tra le classi. Per esempio, una possibile implementazione dell'associazione tra MANAGER e PROJECT potrebbe consistere nell'avere, in ogni istanza di MANAGER, una variabile di tipo array di riferimenti a oggetti della classe PROJECT. Inoltre, la discussione illustra come un'associazione tra classi definisca implicitamente una relazione USES; ad esempio, nel caso di Figura 4.26, MANAGER USES PROJECT. Osserviamo infine che, durante la progettazione, la distinzione tra attributi (o metodi) e associazioni non sempre risulta ovvia. Per esempio, nella Figura 4.26, potremmo decidere che il metodo DO_THIS venga utilizzato per assegnare una cartella a un membro dei-

Figura 4.26

Rappresentazione di associazioni in UML.

lo staff amministrativo. In alternativa, potremmo definire una pratica FOLDER per rappresentare la pratica e un'associazione tra A D M I N I S T R A T I V E _ S T A F F e F O L D E R per descrivere il vincolo tra un membro dell'amministrazione e la pratica cui sta lavorando. Ovviamente, la differenza consisterebbe nel fatto che l'associazione esplicita implicherebbe un supporto alla navigazione dalle cartelle ai membri dell'amministrazione.

4.6.3

Aggregazione

Nel descrivere una classe, potrebbe essere utile definire gli oggetti di tale classe come composizione di componenti più semplici. Tale operazione avviene attraverso la relazione P A R T _ O F . Ad esempio, possiamo definire una classe TRI A N G L E e la sua relazione con la classe POI NT come un'aggregazione (Figura 4.27). Per semplicità, la figura non fornisce i dettagli (metodi e attributi) delle interfacce delle classi, ma illustra i limiti di cardinalità per la relazione di aggregazione: tre punti costituiscono un triangolo. Si noti come la relazione P A R T OF differisca dalla relazione IS C O M P O S E D OF, introdotta nel Paragrafo 4.2.1.2. Infatti, il componente che comprende le parti ha diverse proprietà che non sono direttamente fornite dalle parti. Piuttosto, il componente usa le sue parti per fornire i suoi comportamenti (ad esempio, attributi e metodi). Un'implementazione, ad esempio in Java, del frammento di progetto mostrato nella Figura 4.27 richiederebbe che la classe T R I A N G L E fornisse i metodi e gli attributi necessari per manipolare triangoli, rappresentati da tre riferimenti a oggetti della classe POINT. Implicitamente, dunque, questo indica che T R I A N G L E U S E S P O I N T .

Figura 4.27

4.6.4

Esempio di aggregazione.

Ulteriori nozioni sui diagrammi delle classi UML

I diagrammi delle classi UML possono essere visti come un'evoluzione della semplice notazione GDN precedentemente introdotta per la documentazione dei progetti. La relazione USES introdotta per il GDN viene rimpiazzata da una serie di relazioni: generalizzazionespecializzazione, associazioni di vario tipo e aggregazione. La generalizzazione-specializzazione viene implementata attraverso l'ereditarietà dei linguaggi OO. Gli altri tipi di relazioni possono essere implementati direttamente includendo, all'interno di un oggetto, i riferimenti agli oggetti con cui è in relazione. La relazione USES risulta, in un certo senso, implicita. Se una classe B eredita dalla classe A, B USES A. Se esiste un'associazione tra le classi A e B,

package_name Classe 1 Classe 3 Classe 2

Figura 4.28

Package U M L per la relazione IS_COMPONENT_OF.

con un vincolo di navigabilità che indica la direzione da B ad A, allora B USES A13. Se è descritta come un'aggregazione di A allora, ancora una volta, B U S E S A. In un certo senso, possiamo concludere che UML introduce una serie di relazioni più specifiche rispetto alla relazione USES utilizzata per le notazioni T D N e GDN. Queste relazioni sono più specifiche in quanto descrivono semanticamente concetti relazionali più ricchi di quelli eventualmente descrivibili nei termini della relazione U S E S . UML fornisce anche una notazione per la descrizione della relazione I S C O M P O NENT OF: il package. Il package raggruppa diverse classi o ulteriori package (Figura 4.28). E anche possibile disegnare link di dipendenza tra package per indicare che le entità racchiuse in un package dipendono in qualche modo dalle entità definite in un altro package. A parte fornire notazioni per la descrizione delle strutture statiche di un'architettura, UML fornisce notazioni che possono essere utilizzate per complementare i diagrammi delle classi mediante la descrizione degli aspetti dinamici di un'architettura: diagrammi degli stati e diagrammi delle attività. I diagrammi degli stati descrivono tutti i possibili stati che oggetti di una data classe possono assumere e come lo stato di un oggetto possa cambiare come risultato di operazioni eseguite sull'oggetto. I diagrammi delle attività descrivono flussi di lavoro che operano attraverso le esecuzioni di metodi di diversi oggetti; tali flussi possono procedere in parallelo. I diagrammi degli stati e i diagrammi delle attività saranno illustrati nel Paragrafo 5.7.

4.7 Architettura e componenti L'architettura di un sistema descrive l'organizzazione generale e la struttura del sistema nei termini dei suoi costituenti principali e delle loro interazioni. Per esempio, per un sistema di amministrazione di un moderno ospedale, la descrizione architetturale potrebbe illustrare come il sistema consista di molti sotto-sistemi: le apparecchiature per il monitoraggio dei pazienti, le stazioni di lavoro per le infermiere, i dispositivi mobili per l'inserimento di dati da parte dei medici, il database dei pazienti, e così via. L'architettura è un primo progetto di alto livello. Per trovare l'architettura corretta il progettista considera molte opzioni, tiene conto dei vincoli e deve trovare diversi compromessi. I compromessi determinano mol-

13 Si noti che se la navigabilità è permessa in entrambe le direzioni, la relazione USES che ne risulta non potrà essere una gerarchia.

te delle proprietà generali di un sistema, come le sue prestazioni, la sua affidabilità e la sua sicurezza. L'architettura, dunque, fornisce il mezzo per analizzare le proprietà globali del sistema, visto che queste sono determinate non dai componenti individuali ma dall'interazione dell'intero insieme di componenti. Nel progettare l'architettura, il progettista deve considerare molti requisiti funzionali oltre ai requisiti non funzionali, come il costo e l'affidabilità. Esistono alcuni principi strutturali che governano la progettazione dell'architettura e, a seconda dei requisiti del sistema, alcune modalità di scomposizione del sistema in componenti e alcune tipologie di interazione tra questi componenti possono risultare particolarmente appropriate. Abbiamo già visto un esempio di struttura particolarmente adatta ai sistemi distribuiti: l'architettura client-server, che fornisce una linea guida a tutti i progettisti di sistemi distribuiti, consistente nell'identificare insiemi di fornitori di servizi e insiemi di clienti che ricercano tali servizi. Numerosi sono i benefìci che derivano dallo sviluppo e dallo studio di tali architetture. In primo luogo, la conoscenza di architetture già testate in sistemi precedenti consente al progettista di intraprendere il progetto in modo rapido e con maggiore fiducia. Un'architettura di questo tipo rappresenta le esperienze maturate da progettisti precedenti e ad esse sono associate le decisioni di progettazione che devono essere intraprese. In secondo luogo, dato che un'architettura stabilisce le modalità di comunicazione tra i componenti del sistema, definisce un'interfaccia generica, punto di incontro dei vari componenti. L'esistenza delle specifiche di una tale interfaccia favorisce lo sviluppo di componenti standard, che possono essere utilizzati in sistemi che utilizzano quella architettura. Infine, un'architettura serve come piattaforma di integrazione tra i diversi sottosistemi. Alcuni di questi sottosistemi possono essere sviluppati per il particolare sistema che si sta progettando, oppure possono essere sistemi software già esistenti, come un database. Nei paragrafi che seguono affronteremo questi aspetti in dettaglio.

4.7.1

Architetture standard

Studiando i sistemi esistenti, i progettisti e i ricercatori hanno trovato come alcune architetture si ripetano con maggiore frequenza. Forniremo, quindi, di seguito un elenco di quelle di maggiore rilievo. Inoltre, nel Paragrafo 4.7.4, esamineremo le architetture per i sistemi distribuiti. Architetture "pipeline". A volte, i sottosistemi possono essere organizzati in modo da formare una pipeline (letteralmente, conduttura) di elementi di elaborazione. Ogni sottosistema riceverà in ingresso un input proveniente dal sottosistema precedente, processerà l'input e passerà il proprio output al sottosistema successivo. Il primo sottosistema legge l'input del sistema, mentre l'ultimo sottosistema produce l'output del sistema. Una tale architettura può risultare utile, ad esempio, per la parte di un sistema di monitoraggio di un impianto in cui i sensori leggono i dati ambientali per poi passarli a un altro sottosistema per ulteriori trattamenti. Un'architettura a pipeline viene chiamata anche architettura pipe-and-fìlter (conduttura e filtro) in quanto ogni sottosistema può essere visto come filtraggio dei dati che riceve, mentre i dati fluiscono all'interno della conduttura da filtro a filtro. Cominciando da un'architettura a pipeline, il progettista può concentrarsi immediatamente su questioni quali i requisiti per le prestazioni del flusso dei dati lungo le pipe, i requisiti di sincronizzazio-

(c)

Figura 4.29

Relazioni tra componenti in varie architetture: (a) a pipeline; (b) a lavagna; (c) basata su eventi.

ne tra filtri adiacenti, i possibili colli di bottiglia che si possono presentare nel flusso, e così via. Un'interpretazione grafica dell'architettura è illustrata nella Figura 4.29a. Architetture "blackboard". In un'architettura a pipeline la comunicazione tra due filtri avviene localmente. A volte diventa necessario per un sottosistema essere in grado di comunicare con altri sottosistemi che non siano loro vicini. Se molti sottosistemi hanno bisogno di comunicare tra di loro, allora potrebbe risultare appropriata un'architettura a blackboard (lavagna). In una tale architettura uno dei sottosistemi viene eletto a "lavagna" e serve come mezzo di comunicazione tra gli altri sottosistemi. Essenzialmente, la lavagna è un'interfaccia per scrivere informazioni e per ricevere delle richieste di lettura. Un sistema per mercati azionari o per aste, ad esempio, può essere facilmente strutturato con questa architettura, con richieste e offerte rese disponibili su una lavagna, cui i clienti possono rivolgere delle richieste. Un'interpretazione grafica di questa architettura è illustrata nella Figura 4.29b. Architettura basata su eventi. Nelle architetture tradizionali, i componenti comunicano e invocano operazioni mediante le chiamate a procedure. In un'architettura basata su eventi, invece, i componenti risponderanno a determinati eventi. Un evento potrebbe essere la ricezione di un segnale da parte di un sensore o l'arrivo di un messaggio. I componenti vengono progettati per creare eventi o per iniziare le loro operazioni alla ricezione di un evento. Architetture di questo tipo sono appropriate quando i componenti devono rimanere in attesa di un input dall'ambiente, oppure quando non sono definibili chiaramente relazioni di tipo client-server. Per esempio, le interfacce utenti sono in genere strutturate in modo da utilizzare clic o trascinamenti del mouse come eventi. Concettualmente possiamo immaginare un bus su cui vengono annunciati e propagati gli eventi. Diversi modelli di sistemi basati su eventi supportano operazioni per i componenti come la pubblicazione di eventi o la sottoscrizione a eventi. I tipi di eventi dipendono dall'applicazione. Le architetture basate su eventi soddisfano un paradigma o un modello publish-subscribe (pubblicazione-sottoscrizione). I componenti pubblicano eventi che vengono consegnati ai componenti che si erano, precedenza, ad essi iscritti. Fondamentale, per questa ar-

chitettura, è il distributore di eventi, ovvero il responsabile della distribuzione a run-time degli eventi, da chi li pubblica a chi è iscritto. Il distributore di eventi può essere fornito come parte del middleware. Un'interpretazione grafica di questa architettura è illustrata nella Figura 4.29c. Architetture specifiche al dominio. Le architetture a pipeline, a lavagna e quelle basate su eventi, codificano un certo insieme di componenti, unitamente alle loro relazioni e ai modelli di comunicazione. In pratica stanno emergendo molte architetture di questo tipo. Queste architetture vogliono astrarre le proprietà strutturali comuni alle classi dei sistemi, senza rivolgere particolare attenzione al dominio in cui verranno utilizzati tali sistemi. Esiste un'altra classe standard di architetture, che prova a sfruttare le proprietà comuni di un dato dominio applicativo. Queste architetture vengono dette architetture specifiche al dominio. Per esempio, sono state sviluppate architetture specifiche per il dominio dei sistemi realtime e per il dominio dei sistemi di interfacce utente. Le architetture specifiche al dominio prendono in considerazione molte assunzioni circa il dominio, come il modo in cui i componenti comunicano, la velocità con cui devono comunicare e l'esistenza di meccanismi di time-out per la comunicazione dei messaggi. Le architetture specifiche al dominio velocizzano lo sviluppo dei sistemi in determinati domini applicativi. Inoltre, incoraggiano e supportano lo sviluppo di componenti riusabili in molti sistemi all'interno del dominio. Infine, permettono lo sviluppo di strumenti quali editor, generatori e analizzatori, specifici per il supporto del dominio. Per esempio, generatori di interfacce utente possono essere basati su un'architettura standard di interfacce utente. ESEMPIO 4 . 1 2

Un'architettura specifica al dominio molto conosciuta per il software che ha interazioni significative con l'utente è l'architettura "model-view-controller" (modello-visualizzazione-controllo). L'architettura è composta da tre componenti separati: il modello, che vuole essere un modello del mondo reale, la visualizzazione, che mostra il modello all'utente, e il controllore, che comunica con l'utente e controlla gli altri due componenti. Come esempio di utilizzo dell'architettura "model-view-controller" si consideri un editor di file, che immagazzina i dati dell'utente per poterli mostrare successivamente in diversi formati, testuali o grafici. Il modello gestirebbe archiviazione dei dati, la visualizzazione richiederebbe i dati al modello per poi mostrarli, e il controllore interagirebbe con l'utente per decodificarne i comandi e aggiornare i dati nel modello. Fornendo diversi componenti per la visualizzazione, il sistema potrà supporta-

Figura 4.30

Architettura

"model-view-control".

re diverse opzioni di visualizzazione in maniera modulare. Per esempio, un componente di visualizzazione potrebbe fornire prima un profilo e poi i dati completi su un'altra pagina. Ogni componente specializzerebbe la propria semantica o nasconderebbe le proprie informazioni di formattazione dei dati. La Figura 4.30 illustra la struttura dell'architettura modello-visualizzazione-controllo. Le frecce nella figura rappresentano le richieste di servizi. Molte librerie per lo sviluppo delle interfacce utenti, come le librerie Java Swing, implementano l'architettura "model-view-controller". •

4.7.2

Componenti software

Nelle discipline ingegneristiche, i prodotti sono quasi sempre costruiti a partire da parti, o componenti. La progettazione basata su componenti è stata un obiettivo dell'ingegneria del software fin dagli albori della sua storia. Molta ricerca è stata fatta per rendere possibile l'utilizzo di componenti standard nello sviluppo di prodotti software. Di conseguenza la tecnologia del software è evoluta in modo che i linguaggi e le metodologie supportino lo sviluppo e l'uso di componenti. La domanda fondamentale circa i componenti software riguarda la forma che questi dovrebbero assumere. Ovvero, quale deve essere l'unità di impacchettamento da usare come entità indipendente per un componente software? Fino ai primi anni Novanta, le sole unità di impacchettamento a incontrare un certo successo nell'ingegneria del software sono state le funzioni e le librerie di funzioni. Ad esempio, sono diffusamente commercializzate le librerie di funzioni scientifiche per la manipolazione delle matrici. Tre le svariate motivazioni che hanno sovrinteso al successo di queste librerie, tre possono essere indicate come le più significative. •

Interfaccia chiara. La specifica del componente è definita in modo preciso nelle sue proprietà matematiche. Inoltre, le proprietà sono puramente funzionali, il che rende il componente più facile da descrivere, capire e integrare con altri componenti.



Servizio utile e separabile. Il servizio fornito da un componente è chiaramente identificabile come utile da molti clienti ed è separabile dalle funzionalità del cliente stesso.



Dominio di applicabilità evidente. I programmatori e gli ingegneri che scrivono programmi scientifici sono profondi conoscitori del dominio matematico interessato e dei confini dell'applicabilità di tali componenti all'interno di quel dominio.

Date queste proprietà, è facile per gli ingegneri individuare le circostanze in cui potrebbero aver bisogno di un componente, esaminarne e capirne l'interfaccia e, quindi, utilizzarlo nei loro applicativi. Negli anni Novanta, con i passi in avanti delle tecnologie dei linguaggi di programmazione, sono diventati possibili ulteriori meccanismi di impacchettamento di funzioni. Tra questi meccanismi ci sono (1) i costrutti generici in linguaggi come Ada e C++ e (2) gli oggetti e i framework nei linguaggi di programmazione a oggetti. Esamineremo un esempio per ciascuno di questi meccanismi. STL. La libreria dei templare standard è una collezione di componenti software progettati per C++, che è stata incorporata all'interno della libreria standard C++. La libreria consiste di strutture dati comuni, come liste e stack, e algoritmi frequentemente utilizzati, come

quelli per l'ordinamento o per la ricerca. Definendo un'interfaccia uniforme, sia per gli algoritmi sia per le strutture dati, STL ottiene uno stile di progettazione che consente l'applicazione della maggior parte degli algoritmi alla maggior parte delle strutture dati. Per esempio, un singolo algoritmo f i n d per la ricerca di un elemento in una collezione può essere applicato agli array, alle liste concatenate semplici e alle liste concatenate doppie. Algoritmi e strutture dati sono impacchettati come template C++ all'interno di STL. Ciò significa che il codice sorgente nella libreria STL deve essere disponibile ai programmatori in modo che possa essere compilato insieme al programma C++. STL adotta un principio uniforme per la struttura delle interfacce dei componenti. La maggior parte degli algoritmi che operano su collezioni di oggetti prende come input i riferimenti al primo e all'ultimo elemento della collezione. Per esempio, l'interfaccia per l'algoritmo f i n d , una funzione che cerca un elemento particolare all'interno di una sequenza di elementi, è template

< class

Inputlterator const

T£i

Inpu111erator , class

find(InputIterator

T >

first,

InputIterator

last,

value);

L'algoritmo generico è definito per una sequenza qualsiasi di elementi del tipo T. Le strutture dati (collezioni), sono in grado di restituire riferimenti al loro primo e ultimo elemento. Questa struttura uniforme per le interfacce stabilisce linee guida per i progettisti di nuovi algoritmi e strutture dati. Se un algoritmo ricalca queste linee guida, potrà essere combinato con una qualsiasi delle strutture dati di STL. Allo stesso modo, se una struttura dati ricalca le linee guida, allora potrà essere combinata con uno qualsiasi degli algoritmi contenuti in STL. Ciò significa che con m strutture dati ed n algoritmi abbiamo m x n combinazioni tra componenti possibili. Caratteristica unica di STL è il suo utilizzo dei template C++. I template permettono l'espressione di algoritmi e strutture dati generici, preservando la possibilità da parte del compilatore di effettuare controlli di tipo. STL include più di 100 componenti. Un tipico componente è piuttosto piccolo in termini di numero di righe di codice. I componenti derivano il proprio potere dal loro carattere generale e dal fatto che possono essere combinati con altri componenti. JavaBeans. I linguaggi di programmazione a oggetti promuovono componenti costituiti come classi e oggetti. Il linguaggio Java offre anche pacchetti e file di archiviazione (JAR, Java archive files, file di archiviazione Java) per aumentare le funzionalità a disposizione per lo sviluppo basato su componenti. Il framework dei componenti JavaBean promuove l'approccio visivo allo sviluppo del software in un ambiente in cui i componenti sono rappresentati da icone che possono essere trascinate e posizionate sullo schermo e connesse ad altre icone. Un framework è una collezione di classi relazionate che vengono progettate per essere utilizzate insieme nello sviluppo di applicazioni all'interno di un determinato dominio. Il framework dei JavaBean definisce un insieme di metodi che devono essere supportati da ciascun componente. Questi metodi assicurano che i componenti possano essere composti visivamente. Il framework definisce la semantica che ogni metodo deve fornire affinché sia assicurato che le connessioni tra componenti funzionino correttamente.

Swing. Java ha una serie di librerie per il supporto allo sviluppo di software per diverse applicazioni. Per esempio, ci sono librerie per la rete e per la sicurezza. La libreria Swing supporta lo sviluppo di interfacce utenti grafiche. Swing è un insieme di librerie di componenti specifici al dominio, progettati per interfacce utenti grafiche. Dato che la rappresentazione di tali interfacce è alquanto standardizzata (un'interfaccia è composta tipicamente da finestre, bottoni, menu e così via) le librerie come Swing forniscono funzionalità per la costruzione di interfacce utenti mediante la combinazione di componenti elementari detti widget. Il paradigma orientato agli oggetti è perfettamente appropriato per questo dominio. Ogni widget è rappresentato come un oggetto che supporta metodi per la specifica di cosa deve fare quando viene puntato dal mouse, quando viene trascinato, cliccato e così via. Come abbiamo detto prima, Swing segue lo stile architetturale "model-view-controller". Oggetto di ricerca nello sviluppo di componenti software è la granularità che dovrebbero avere i componenti. I componenti STL sono componenti di granularità fine, mentre i componenti Swing e JavaBean sono di granularità media. È anche possibile usare componenti di granularità grande come i DBMS. Infatti, visto che molte applicazioni impiegano database, poter utilizzare i DBMS come componenti in un'applicazione presenta numerosi benefici. Per poter utilizzare gli attuali DBMS come componenti, lo standard ODBC (Open Data Base Connectivity, connettività a database aperta) definisce un insieme di interfacce per un database relazionale. Queste interfacce sono state mappate su quasi tutti i sistemi per la gestione di database relazionali esistenti. La disponibilità di queste corrispondenze, significa che un progettista può assumere l'esistenza di un database relazionale come componente dell'architettura di un sistema. Ai livelli di progettazione e implementazione, si potrà selezionare un DBMS sulla base del costo, delle prestazioni, della compatibilità con altri prodotti e così via.

4.7.3

L'architettura come piattaforma per l'integrazione dei componenti

Lo sviluppo di software basato su componenti presuppone un processo in due passi. Innanzitutto viene sviluppata un'architettura o un progetto di alto livello del sistema, identificando i componenti che devono essere combinati per realizzare il sistema. Successivamente, viene operato un tentativo finalizzato a reperire i componenti necessari, che si trovano, già pronti, sul mercato. Ovviamente, questi due passi si influenzano reciprocamente. Da una parte, i progettisti sono motivati a optare per architetture che traggano vantaggio da componenti già noti e disponibili. Dall'altra parte, la conoscenza delle architetture concepite dai progettisti motiva gli sviluppatori a realizzare componenti il cui impiego possa risultare utile per le architetture più diffuse. Man mano che maturano determinati domini, si rendono disponibili componenti per quei domini. In questi casi, progettare un'architettura per un'applicazione si riduce all'assemblaggio di insiemi di componenti esistenti, in modo da raggiungere gli scopi prefissati. Possiamo, dunque, interpretare un'architettura come un quadro finalizzato a integrare un insieme di componenti. L'architettura specifica il modo in cui i componenti dovrebbero essere organizzati e connessi, così da soddisfare i requisiti dell'applicazione. CORBA (Common Object Request Broker Architecture) è un esempio di un'architettura standard che può essere interpretata come una piattaforma di integrazione. CORBA

assume un paradigma client-server in un ambiente distribuito. Assume che i client e i server risiedano su una rete e che stabiliscano reciproche connessioni mediante l'uso di un intermediario, chiamato ORB (Object Request Broker). I server informano ORB della loro disponibilità, mentre i client interrogano ORB per conoscere la disponibilità dei server. Una volta che un cliente trova un server mediante ORB, può comunicare direttamente con il server. Gli ORB che si trovano su reti diverse possono comunicare tra di loro (attraverso un protocollo Inter-ORB) in modo da fornire accesso ai server attraverso differenti reti. ORB, dunque, svolge la funzione di server dei nomi all'interno di un'architettura CORBA. Nel Paragrafo 4.5.3 abbiamo accennato al fatto che uno dei compiti necessari nel software distribuito è vincolare un modulo a una macchina. ORB rende possibile cercare questo collegamento a run-time. Una volta che è stato definito lo standard CORBA sono diventati disponibili una serie di ORB commerciali, oggigiorno largamente utilizzati. Lo standard CORBA definisce essenzialmente l'architettura di un generico sistema distribuito basata su client e server. La Figura 4.31 illustra l'architettura CORBA. Compito del progettista di un particolare sistema è rendere disponibili client e server adatti a questo framework, ovvero in grado di comunicare con gli ORB. Uno dei contributi più importanti dello standard CORBA è il suo IDL (Interface Definition Language, linguaggio di definizione delle interfacce). Il progettista di un server impiega questo linguaggio per definire le interfacce fornite dal server. I client usano queste interfacce per compilare e collegare i propri programmi. Il linguaggio fornisce un insieme di tipi di dati che possono essere utilizzati nelle definizioni delle procedure. Le interfacce possono ereditare da altre interfacce. Da un punto di vista dell'ingegneria del software l'esistenza di IDL è piuttosto significativa. Le specifiche IDL separano chiaramente le responsabilità dei progettisti da quelle dei programmatori dei client e dei server. La specifica IDL serve da specifica modulare dei server. CORBA fornisce una piattaforma per la costruzione di applicazioni distribuite. Dovendo costruire un nuovo sistema distribuito, è possibile produrre nuovi componenti e integrarli nella piattaforma CORBA. In molti casi, tuttavia, potremmo voler integrare componenti già esistenti. Per esempio, potremmo avere un sistema per il personale o un altro "software legacy"

Oggetti applicativi

Interfacce di dominio

Servizi C O R B A

Figura 4.31

Architettura CORBA.

Funzionalità di C O R B A

che vorremmo rendere disponibile sulla rete distribuita. Un modo per integrare sistemi di questo tipo in una piattaforma CORBA è quello di scrivere specifiche IDL per i loro servizi, per poi scrivere programmi che traducano l'interfaccia in interazioni con il software legacy. Un tale software di traduzione viene detto software wrapper (software che avvolge), in quanto avvolge il software legacy creando un pacchetto che possa essere utilizzato nel nuovo ambiente. Questa operazione, però, non sempre è possibile. Ad esempio, un software legacy interattivo non può essere facilmente "avvolto", in modo che operi in un ambiente client-server. Nel mondo Microsoft, DCOM (Distributed Component Object Model) è stato progettato appositamente per l'integrazione di software legacy e, in particolare, per l'integrazione di codice binario. DCOM è, sotto molti aspetti, simile a CORBA, ma è una piattaforma software proprietaria.

4.7.4 Architetture per sistemi distribuiti Uno dei principali vantaggi del modellare un sistema a livello architetturale è che diventa possibile capire la struttura generale del sistema e analizzare le sue proprietà globali. Possiamo addirittura scoprire modelli strutturali ricorrenti che risultino più utili del sistema che si sta in quel momento progettando. Per esempio, abbiamo visto che il paradigma client-server è stato codificato nello standard CORBA come un'architettura standard per sistemi distribuiti. Con la proliferazione delle reti e la disponibilità crescente di ambienti distribuiti è cresciuta l'importanza delle architetture standard per applicazioni distribuite. In questo paragrafo prenderemo in visione due architetture per i sistemi distribuiti: l'architettura a tre livelli (three tier) e gli application server. L'architettura a tre livelli è un'estensione dell'architettura client-server, la quale è a tutti gli effetti a due livelli. In un'architettura client-server esistono infatti due livelli di componenti: il livello client e il livello server. Il livello client fa affidamento sui servizi del livello server. Per esempio, l'architettura del W W W (World Wide Web) ha una struttura di questo tipo: il browser web risiede sul client e comunica con il server web che risiede su un altro computer. Il browser invia al server richieste di pagine che poi visualizza sullo schermo quando le riceve. Si veda nella Figura 4.32a l'illustrazione di questa architettura. In molte applicazioni distribuite, invece, possiamo distinguere un terzo strato di funzionalità. Supponiamo che la richiesta del browser non sia per una pagina semplice ma per un servizio più elaborato, come l'accesso a un database. In questo caso il server web dovrà spedire la richiesta alla macchina che ospita il database (la quale potrebbe essere, ovviamente, la stessa macchina su cui risiede il browser), ricevere i risultati e rispedirli al cliente. Possiamo dire che le applicazioni di questo tipo siano a tre livelli, un livello client, il quale esegue l'interfaccia, uno strato di logica di business, il quale interpreta le richieste dell'utente e determina cosa debba essere fatto, e un livello applicativo, il quale esegue il servizio richiesto. L'applicativo è spesso, ma non sempre, un server di database. Un'architettura a tre livelli è illustrata nella Figura 4.32b. Con la diffusione sempre più ampia di architetture a tre livelli diventa possibile identificare applicazioni specifiche, frequentemente richieste al livello applicativo. Per questo è possibile costruire application server che forniscano una specifica applicazione. Per esempio, un'intera applicazione come un server di posta potrebbe essere fornita da un'application server di posta. L'approccio basato su application server promuove la creazione di server altamente specializzati e ottimizzati per la soluzione di problemi specifici. Tali server possono

Browser web (client)

Richieste di servizio

Server web (server)

(a)

Figura 4.32

Architetture (a) a due e (b) a tre livelli.

essere visti come componenti a granularità alta da inserire all'interno di architetture distribuite. Il progettista deve solo scegliere, tra i diversi application server disponibili, quello che meglio si integra con il sistema che sta costruendo.

4.8

Osservazioni conclusive

In questo capitolo abbiamo esaminato i vari aspetti della progettazione di software e delle architetture. Molti dei principi generali per il software che abbiamo presentato nel Capitolo 3 sono stati qui esaminati in maggiore dettaglio, nel contesto della progettazione del software. In particolare abbiamo enfatizzato la modularità, vera essenza e tema portante dell'intero capitolo. Anche i principi di separazione degli interessi, astrazione, generalità e incrementalità sono stati discussi in maniera estesa. In particolare, sono stati visti come benefici derivanti da tecniche appropriate di modularizzazione. Infine, è stato mostrato come il rigore e la formalità siano obiettivi essenziali della nostra documentazione di progettazione, e come abbiano ispirato la definizione di una notazione di progettazione presentata sia in forma testuale (TDN) che in forma grafica (GDN). Tale notazione fornisce un modo chiaro per documentare la progettazione del software, per facilitare la comunicazione tra progettisti software e futuri mantenitori del sistema. La progettazione è un'attività critica e creativa. Può essere ispirata da principi e linee guida generali, ma non può essere resa un'operazione meccanica mediante regole fisse o teoremi. Abbiamo mostrato come la progettazione consista di (1) la definizione di un'architettura in termini di un insieme di relazioni tra moduli e (2) la definizione di interfacce tra moduli. Queste attività possono essere guidate dai principi generali secondo i quali l'architettura dovrebbe avere un basso livello di accoppiamento e un alto grado di coesione, e secondo i quali le interfacce dovrebbero imporre l'information hiding. Mettere in pratica tali principi, comunque, richiede perspicacia, maturità ed esperienza da parte del progettista. I principi non sono ricette!

Si è assunto che l'information hiding fosse la pietra angolare su cui basare un progetto solido. Abbiamo fatto molta attenzione, quindi, alla questione di come progettare interfacce che rispettassero l'information hiding. In particolare abbiamo identificato diverse categorie di moduli che possono essere utilizzate come linee guida durante la progettazione. I più importanti sono gli oggetti astratti e i tipi di dati astratti. Tra le qualità del progetto software abbiamo incentrato l'attenzione sull'evolvibilità e l'affidabilità. L'evolvibilità viene raggiunta mediante la progettazione per il cambiamento. L'affidabilità deriva, come risultato di un approccio disciplinato alla progettazione, dall'applicazione di metodi che aiutano a superare la complessità della progettazione e riducono la probabilità della presenza di errori nel progetto. Ma abbiamo anche affrontato la questione della progettazione difensiva, mostrando come possibili anomalie dovrebbero essere prese attentamente in considerazione durante la fase di progettazione e descritte nella documentazione. Abbiamo affrontato i problemi specifici della progettazione dei sistemi concorrenti, realtime e distribuiti, estendendo ad essi i principi e le notazioni di tipo generale. Il nostro obiettivo era di mostrare come i principi e gli approcci presentati per la progettazione di software sequenziale potessero essere estesi per affrontare la concorrenza, il real-time e la distribuzione. L'information hiding, gli oggetti astratti e i tipi di dati astratti hanno portato al concetto di progettazione orientata agli oggetti, un approccio alla progettazione di software che è diventato dominante nell'ultimo decennio, grazie all'avvento di linguaggi di programmazione, come C++ e Java, che lo supportano mediante caratteristiche linguistiche che mancavano nei linguaggi tradizionali. La progettazione orientata agli oggetti, insieme ai suoi linguaggi, porta l'idea dell'information hiding alla sua logica conclusione, aiutando i progettisti di software ad avvicinarsi di più agli obiettivi di progettazione per il cambiamento, progettazione di famiglie di prodotti, sviluppo incrementale, produzione di componenti riusabili e facilità di manutenzione. La notazione di progettazione UML è stata introdotta per la progettazione orientata agli oggetti. UML viene adottata sempre di più come notazione standard per la documentazione di architetture di applicazioni orientate agli oggetti. Infine abbiamo esaminato un numero di questioni inerenti allo sviluppo di software basato su componenti. Queste questioni riguardano architetture di riferimento e interfacce che permettono lo sviluppo di componenti standard e la loro integrazione in un'applicazione. Abbiamo concluso il capitolo con una discussione dell'evoluzione delle architetture a due e a tre livelli, per sistemi distribuiti. Ulteriori esercizi 4.51

Indicate alcune utili relazioni tra m o d u l i , diverse d a quelle presentate in questo capitolo.

4.52

Classificate i c a m b i a m e n t i discussi nel Paragrafo 4 . 1 . 1 . 1 , s u d d i v i d e n d o l i in interventi di m a n u t e n z i o n e perfettiva, adattiva o correttiva.

4.53

C o n s i d e r a t e u n p r o g r a m m a scritto in linguaggio Ada. (L'esercizio p u ò c o m u n q u e essere adattato p i u t t o s t o facilmente a n c h e ad altri linguaggi quali M o d u l a - 2 , C o Pascal.) Si considerin o le unità della libreria Ada c o m e m o d u l i , s e c o n d o la descrizione fornita in questo capitolo. Inoltre, definite u n a relazione CHIAMA tra d u e m o d u l i qualsiasi c o m e MI CHIAMA Ms s e e solo se u n a c h i a m a t a a u n a p r o c e d u r a o f u n z i o n e di Mj proviene da Mi. a.

C o n t r a r i a m e n t e all'assunzione fatta nel Paragrafo 4.2.1, avrebbe senso definire C H I A M A c o m e u n a relazione riflessiva?

b. Cosa consegue dal richiedere che CHIAMA sia una gerarchia? c.

Si disegni il grafo per la relazione CHIAMA e si controlli se risulta essere un D A G oppure no.

4.54

Dimostrate che l'inverso di una gerarchia è un'altra gerarchia.

4.55

Facendo riferimento alla Figura 4.4, possiamo dire che {MJ}

INCAPSULA

{M,,

M5,

M6,

{M2}

INCAPSULA

{M7,

M„,

M9>

{M3>

INCAPSULA

{MS,

M6}

M,,

M„,

M9>

La relazione INCAPSULA, d u n q u e , mette in relazione ciascun m o d u l o con l'insieme di moduli elementari da cui è composto. Definite formalmente INCAPSULA. 4.56

Spiegate come mai un progetto con u n livello basso di accoppiamento aiuta la manutenibilità.

4.57

Studiate il costrutto COMMON del F O R T R A N e discutete la differenza tra COMMON con etichetta e COMMON senza etichetta, e il loro uso. Inoltre, discutete i meccanismi messi a disposizione da F O R T R A N per inizializzare le aree COMMON.

4.58

Descrivete come utilizzare C per definire un area c o m u n e per l'immagazzinamento dei dati. Fate lo stesso per Pascal.

4.59

Abbiamo criticato l'interfaccia di P O P _ 2 nello stack dell'Esempio 4.7 per il fatto che risultava troppo specializzata. A b b i a m o detto che la sua generalizzazione di P O P fornisce ai clienti una maggiore flessibilità nel suo impiego. Tuttavia la specializzazione risulta a volte necessaria per motivi di efficienza. Per esempio, si consideri uno stack che è implementato su un nodo di u n sistema distribuito. C o n f r o n t a t e P O P e P O P _ 2 in termini di efficienza dell'interfaccia per i clienti.

4.60

Secondo la definizione del linguaggio di programmazione Ada, qual è la differenza tra i tipi private elimited private esportati da un package, per quanto riguarda i moduli clienti

4.61

L'Esempio 4.8 fa uso di oggetti del tipo FIFO_CARS per rappresentare una coda qualsiasi alla stazione di servizio. Vorremmo, però, poter trattare diversamente una coda di auto che aspettano il rifornimento di benzina rispetto a una coda di auto in attesa, ad esempio, di un controllo meccanico. In particolare, se dovesse finire la benzina, n o n v o r r e m m o che le diverse code venissero accorpate. Nell'Esempio 4.8, la gestione corretta delle code viene lasciata come compito ai moduli client. I clienti scelgono nomi appropriati per gli oggetti come g a s o l i n e _ l o c a r _ w a s h in m o d o da evitare di unire code eterogenee. Suggerite una soluzione migliore usando i moduli generici.

4.62

Nel Paragrafo 4.2.4.3 abbiamo descritto i moduli generici parametrizzati per tipo. Studiate la specifica del linguaggio Java. Java consente l'uso di moduli parametrizzati? E C++?

4.63

N o n abbiamo fornito un arricchimento di G D N per la descrizione di eccezioni e della loro gestione. Proponete una notazione per descrivere il fatto che un'eccezione possa essere sollevata da un m o d u l o durante l'esecuzione di un servizio offerto. La notazione dovrebbe fornire anche un m o d o per mostrare c o m e un'eccezione possa essere propagata d o p o che è stata segnalata a u n utente.

4.64

Supponete di voler modellare u n sistema per u n ascensore di un palazzo a più piani. Il sistema è composto da m piani e n ascensori. O g n i ascensore dispone di una pulsantiera dove ciascun tasto corrisponde a un piano. Q u a n d o viene premuto, il tasto si illumina, e provoca lo spostamento dell'ascensore al piano corrispettivo. Una volta raggiunto il piano la luce si spe-

gne. Inoltre, ad ogni piano (eccetto il piano terra e l'ultimo piano) sono collocati due tasti: uno per la richiesta di un ascensore in salita e uno per u n ascensore in discesa. Il tasto si spegne nel m o m e n t o in cui giunge u n ascensore che viaggia nelle giusta direzione o che risulta libero da richieste. Infine, ogni ascensore dispone di u n tasto di emergenza che, se premuto, invia un messaggio di allarme alla sede del gestore, e segnala che l'ascensore è "fuori servizio". Segnalazione che può, a sua volta, essere disattivata tramite un apposito meccanismo. a.

Modellate il sistema secondo u n o stile orientato agli oggetti.

b. Supponete che gli ascensori siano divisi in due insiemi, il primo che fornisce il servizio ai piani compresi tra il piano t e r r a e il piano ni!, il secondo che fornisce il servizio ai piani compresi tra ni! e m. Cosa cambiereste nel progetto per tener c o n t o di questa nuova caratteristica? 4.65

D o p o il primo passo di progettazione descritto all'inizio del Paragrafo 4.4, il progettista del m o d u l o SYMBOL TABLE anticipa che immagazzinerà le informazioni c o n t e n u t e nei vari blocchi in locazioni contigue. Gli algoritmi RETRIEVE e LEVEL saranno quasi identici: per cercare il valore di una variabile, innanzitutto si cercherebbe nell'ultimo blocco inserito, e poi, se n o n venisse trovata la variabile, si cercherebbe nel blocco precedentemente inserito, e cosi via. Il progettista p r o p o n e ai suoi colleghi di approfittare dell'estrema somiglianza tra gli algoritmi e di modificare l'interfaccia. Pertanto, invece di avere le procedure RETRIEVE e LEVEL, fornirà una procedura (RETRIEVE

L E V E L ) c h e r i u n i s c e le d u e . L ' i n t e r f a c c i a p r o -

posta è: procedure

RETRIEVE_LEVEL

DESCR:

out

(ID:

DESCRIPTOR;

L:

in

IDENTIFIER;

out

integer);

D o p o una discussione, i colleghi del progettista lo convincono che riunire le due procedure in una sola n o n è una b u o n a idea. Sei d'accordo con questa decisione? Perché? Perché no? 4.66

Facendo riferimento al m o d u l o QUEUE_OF_CARS discusso nel Paragrafo 4.5, ci possiamo aspettare che due esecuzioni concorrenti di NOT EMPT Y e N O T F U L L richiedano l'esecuzione in m u t u a esclusione? Perché? Perché no? E d u e esecuzioni concorrenti di NOT_EMPTY e GET?

4.67

È corretto dire che la clausola r e q u i r e s definita nel Paragrafo 4.5.1.1 è parte dell'interfaccia di un m o d u l o concorrente? Perché? Perché no?

4.68

C o n s i d e r a t e il m o d u l o della Figura 4 . 1 9 e a s s u m e t e che le operazioni NOT FULL e NOT_EMPTY siano esportate dal m o d u l o . Le operazioni esportate nella Figura 4 . 1 9 fornirebbero un'astrazione utile al m o m e n t o di essere chiamate dai m o d u l i client? Perché? Perché no?

4.69

Quali difficoltà si potrebbero incontrare se venisse data la possibilità a un'operazione di monitor di usare un'altra operazione di monitor (magari esportata dallo stesso monitor)?

4.70

Studiate le caratteristiche della concorrenza presenti in Ada e fornite una descrizione dettagliata del caso in cui un n u m e r o qualsiasi di consumatori (definiti da un tipo di compito) e un produttore possano accedere a un dato buffer per inserirvi e rimuovere caratteri.

4.71

Al passaggio del secolo, si diede particolare rilievo al cosiddetto problema Y2K ("year 2000"). Molti software, progettati per gestire date impiegando due cifre per identificare l'anno, avrebbero p o t u t o causare problemi. Ad esempio, "00" avrebbe p o t u t o essere interpretato come 1900. Discutete questo aspetto come un problema di evoluzione del software. In particolare, affrontate le seguenti questioni. Quale era la sorgente del problema? Il problema poteva essere previsto? Perché n o n fu previsto? C o m e si possono trovare gli errori all'interno di u n programma e come si potrebbe risolverli?

4.72

Esaminate il caso di studio nell'Appendice A. Mostrate come l'azienda avrebbe potuto gestire i diversi fabbisogni dei diversi clienti s f r u t t a n d o alcune delle tecniche illustrate in questo capitolo.

4.73

Modificate i diagrammi delle classi nelle Figure 4.25 e 4 . 2 6 specificando un'associazione che descriva la squadra che lavora al progetto. La squadra è composta da un manager, un amministratore e u n g r u p p o di tecnici.

Suggerimenti e tracce di soluzioni 4.9

I S COMPOSED OF e IMPLEMENTS n o n possono essere definite come relazioni matematiche su S visto che m e t t o n o in relazione u n elemento di S con u n sotto-insieme di elementi di S.

4.38

Osservate che le due affermazioni vengono, a tutti gli effetti, realizzate come una sequenza di più azioni elementari. Potrebbe accadere, d u n q u e , che entrambi i processi leggano TOT, che PRODUCER_1 aumenti il valore letto e lo salvi n u o v a m e n t e in TOT e che poi CONSUMER_2 decrementi il valore e lo salvi ancora in TOT!

4.51

U n a relazione di equivalenza semantica tra moduli è utile per decidere se un m o d u l o possa rimpiazzarne u n altro durante l'evoluzione del software. Per q u a n t o riguarda la compilazione, ci possono essere relazioni di ordine tra moduli, del tipo "M, deve essere compilato prima di M 2 ". Nei sistemi distribuiti potrebbe esistere una relazione che definisce che due moduli debbano essere allocati sulla stessa macchina.

4.65

Un'implementazione differente potrebbe mettere in cache i descrittori di tutti gli identificatori all'ingresso nel blocco, senza mettere in cache il livello. Ciò renderebbe n o n valida l'interfaccia proposta

4.67

U n m o d u l o concorrente può essere utilizzato correttamente anche senza conoscere la clausola requires. Questa clausola, però, fornisce informazioni utili per una migliore comprensione degli effetti delle operazioni associate. H a u n impatto anche sull'analisi delle prestazioni, in q u a n t o aiuta a trovare i ritardi dovuti alla m u t u a esclusione.

4.68

I clienti n o n possono fare affidamento sui risultati restituiti in q u a n t o altri processi potrebbero averli cambiati nel frattempo.

4.69

Una tale caratteristica potrebbe far crescere il rischio di deadlock.

4.72

Usando una progettazione orientata agli oggetti, l'azienda avrebbe p o t u t o prima raggruppare tutte le informazioni e le operazioni necessarie a tutti gli uffici legali. Più tardi, gli uffici legali avrebbero p o t u t o essere specializzati applicando l'ereditarietà. Q u e s t o approccio avrebbe anche supportato una costruzione e una consegna incrementale del sistema. Inoltre, seguend o l'approccio suggerito nell'Esempio 4.5, l'azienda avrebbe p o t u t o concentrare gli sforzi sia di progettazione che di promozione sulle caratteristiche innovative del sistema.

Note bibliografiche I lavori di D.L. Parnas sono la sorgente principale che ha ispirato la visione della progettazione del software presentata in questo libro. Dijkstra [1968a, 1968b e 1971] fu il p r i m o a insegnare a usare i principi di "separazione degli interessi" e i livelli di astrazione per poter affrontare la complessità della progettazione. Parnas fu pioniere di t u t t o il successivo lavoro fatto sulla progettazione del software. Parnas [1972b] introdusse il concetto di information hiding, la pietra angolare della "buona" progettazione del software, mentre Parnas [ 1972a] introdusse la nozione di specifica dei moduli, che affronteremo nel prossimo capitolo. I lavori successivi a p p r o f o n d i r o n o le questioni riguardanti le famiglie di prodotti (Parnas [1976]) e la modifica dei programmi (Parnas [1979]). Parnas [1974] discute il concetto di sistema gerarchico. Britton et al. [1981] discute le interfacce astratte per i moduli di interfaccia per dispositivi. Hester et al. [1981] illustra l'uso di una b u o n a documentazione di progetto. Gli articoli originali di Parnas sono stati collezionati in H o f f m a n e Weiss [2001]. H o f f m a n [1989] discute la questione della progettazione di interfacce e specifiche in una maniera pratica, ma rigorosa. H o f f m a n [1990] discute i criteri per la progettazione di interfacce per moduli. Il lavoro sui linguaggi di interconnessione di m o d u l i affronta la questione dei meccanismi linguistici per descrivere le interconnessioni di m o d u l i software; Prieto-Diaz e Neighbors [1986] ne presenta una rassegna. La nozione di tipo di dato astratto ha le sue radici nel lavoro di Dahl et al. [1972] e Liskov e Zilles [1974]. Liskov e G u t t a g [1986] illustra u n approccio metodologico alla costruzione di software basato sul riconoscimento di astrazioni. Lientz e Swanson [1980] discute le cause del cambiamento nel software e riportano dati che mostrano l'influenza dei diversi fattori (per esempio, il c a m b i a m e n t o delle strutture dati). La notazione T D N usata per illustrare i progetti è basata sui linguaggi di programmazione Ada (AJPO [1983]) e Modula-2 (Wirth [1983]). La rappresentazione grafica che abbiamo usato ricorda la notazione H O O D , definita dall'Agenzia Spaziale Europea ( H O O D [1989]). Wasserman et al. [1990] descrive un'altra proposta; Buhr [1984] fornisce una notazione grafica per descrivere i progetti Ada. La questione dei generatori di applicazioni è discussa in Software [1990c]. Per la compilazione condizionale, vedi Babich [1986], che illustra il concetto nel contesto del configuration management. La progettazione orientata agli oggetti è illustrata da Booch [1986, 1987a e 1987b], nel contesto del linguaggio di p r o g r a m m a z i o n e Ada, e da Meyer [2000], nel contesto del linguaggio di programmazione Eiffel. Wegner [1987] presenta una spiegazione e una classificazione delle questioni riguardanti la progettazione orientata agli oggetti. Szyperski [1998] fornisce u n o studio approfondito delle varie tecnologie e soluzioni offerte per supportare la progettazione per c o m p o n e n t i . La progettazione di software concorrente fu affrontata da Brinch Hansen [1977], che definì il linguaggio di programmazione C o n c u r r e n t Pascal. Meccanismi basati su rendezvous f u r o n o ispirati da C S P (CommunicatingSequentialProcessesi, definito da Hoare [1974], e trovarono la loro applicazione sistematica nel linguaggio di programmazione Ada. Weihl [1989] discute l'uso di tipi di dati astratti in un ambiente concorrente. I problemi specifici dei sistemi real-time sono discussi da Wirth [1977] e Stankovic [1988], Kopetz [1977] presenta un trattamento ampio dei problemi dei sistemi real-time, con particolare attenzione al rispetto dei vincoli temporali. La progettazione di sistemi distribuiti è discussa da Shatz e W a n g [1989]. II concetto di guardiano nel contesto dei sistemi distribuiti è dovuto a Liskov e rappresenta la base per la progettazione del sistema A R G U S (Liskov [1988]). Un aspetto della progettazione n o n affrontato specificatamente in questo libro riguarda le interfacce utente. Il lettore interessato potrà fare riferimento a Schneiderman [1988] e al n u m e r o speciale di IEEE Software [1989a],

Per u n q u a d r o sui linguaggi di programmazione e sul loro s u p p o r t o alla progettazione del software, si faccia riferimento a Ghezzi e Jazayeri [1998]. Altri approcci alla progettazione sono descritti in vari libri sulla "progettazione strutturata" come Yourdon e Costantine [1979] e Myers [1978]. Questi approcci sono basati sulla nozione di scomposizione di u n sistema in moduli funzionali. Questi metodi sono parte di una metodologia più ampia, chiamata SA/SD (Structured Analysis/Structured Design)-, affronteremo questa metodologia nel Capitolo 7. Per una discussione dettagliata dei concetti di coesione e accoppiamento si faccia riferim e n t o a Yourdon e Costantine [1979] e a Myers [1978], U n a rassegna di tecniche di progettazione è presentata da Bergland [1981] e Yau eTsai [1986], Ci sono molti libri e articoli sulla progettazione orientata agli oggetti. D u e idee che h a n n o ricevuto molta attenzione nel contesto della progettazione orientata agli oggetti sono i design pattern e le piattaforme. I design pattern sono strutture ricorrenti che consistono di diversi componenti che appaiono nella progettazione di molti sistemi. G a m m a et al. [1994] è la sorgente originale per quest'idea e contiene 2 3 pattern. I framework sono un insieme correlato di classi che costituiscono l'ossatura per una particolare area applicativa o dominio. Abbiamo usato la parola "framework" in questo capitolo in senso generico. Nel m o n d o O O ha una definizione rigorosa. A proposito di framework si veda il n u m e r o speciale di Communications of the ACM curato da Fayad e Schmidt [1977]. Anche se il termine "architettura" veniva utilizzato da Parnas e Brinch Hansen nei primi anni O t t a n t a , lo studio sistematico dell'architettura software come oggetto indipendente di ricerca attrasse l'attenzione solo negli anni Novanta. Perry e Wolf [1992] argomenta in favore dello studio sistematico del soggetto. Shaw e Garlan [1996] rappresenta il primo studio sistematico del settore ed esplora la nozione di c o m p o n e n t i e connettori come le astrazioni strutturali di base per le architetture software. Rechtin [1991] è un'eccellente sorgente di idee pratiche per la strutturazione dei sistemi, di cui enfatizza l'importanza della semplicità come principio architetturale. Numerosi libri riportano esperienze diverse con le architetture software. Tra questi ricordiamo Bass et al. [1999], Hofmeister et al. [1999] e Jazayeri et al. [2000], che si concentrano su architetture per famiglie di prodotti. Kruchten [1995] è un articolo influente, che introduce l'importanza dei diversi punti di vista dell'architettura software. Garlan [2000] recensisce le questioni più rilevanti nell'ambito delle architetture software e "predice" sviluppi futuri. Nuovi paradigmi architetturali sono stati esplorati in molte pubblicazioni. Wolf e Rosenblum [1997] discute le architetture basate su eventi, Hauswirth e Jazayeri [1999] fornisce una rassegna dei sistemi basati su "push" e li confronta con i sistemi basati su eventi. S T L viene descritto in dettaglio da Musser e Saini [1996] e viene analizzato da un p u n t o di vista dell'ingegneria del software da Jazayeri [1995]. Fowler e Scott [1998] presenta una sintetica introduzione a U M L . Booch et al. [1999] è il testo di base sull'argomento. Ci sono molti libri su C O R B A , D C O M e altri middleware. Per esempio, si veda Emmerich [2000] e Orfali et al. [1997]..

CAPITOLO

5

Specifica

Ogni sistema ingegneristico non banale deve essere specificato. Per esempio, si potrebbe stabilire che un ponte debba sostenere un peso fino a 1000 tonnellate, che debba essere largo almeno 30 metri, etc. In questo senso, la specifica è un'affermazione precisa sui requisiti che il sistema, in questo caso il ponte, deve soddisfare. Ovviamente, potremmo specificare, oltre ai requisiti del sistema finale, anche quelli dei sottosistemi e dei componenti che verranno utilizzati. Il progettista del ponte, quindi, specificherà i requisiti dei pilastri, dei cavi, delle viti, e così via. Ovviamente, andranno specificati anche il progetto e l'architettura. Nelle discipline ingegneristiche tradizionali, la parola specifica ha un significato preciso. Nell'ingegneria del software, invece, il termine viene utilizzato in diversi contesti con significati diversi. Noi stessi lo abbiamo utilizzato informalmente diverse volte nei capitoli precedenti. In generale, possiamo interpretare una specifica come un'affermazione riguardante l'accordo raggiunto tra il produttore e il consumatore di un servizio o tra un implementatore e un utente. A seconda del contesto, l'implementatore e l'utente saranno diversi, proprio come lo è la natura delle specifiche. La specifica dei requisiti è un accordo tra l'utente finale e lo sviluppatore del sistema. La specifica di un progetto, per esempio in termini di una gerarchia U S E S , un diagramma UML o di un'interfaccia IDL, è un accordo tra l'architetto (o progettista) del sistema e gli implementatori. La specifica di un modulo è un accordo tra i programmatori che dovranno usare il modulo e il programmatore che lo implementa. Per esempio, la clausola e x p o r t s dei moduli in una descrizione T D N può essere interpretata come una specifica (sintattica) di quei moduli. In maniera simile, i diagrammi delle classi possono essere arricchiti elencando la segnatura di tutte le operazioni esportate. Come dimostrano questi esempi, il termine viene usato in diversi stadi dello sviluppo di un sistema. Inoltre, una specifica a un determinato livello definisce i requisiti per l'implementazione dei livelli sottostanti. Siccome la specifica è un accordo tra l'utente e l'implementatore, possiamo interpretarla come una definizione di ciò che l'implementazione deve riuscire a ottenere. Questa relazione tra la specifica e l'implementazione viene molte volte spiegata in termini della dicotomia "what versus how' (ovvero, "che cosa" rispetto a "come") descritta nel Capitolo 1. La specifica afferma che cosa deve fare un sistema; l'implementatore decide come deve farlo. In pratica, però, la distinzione non è poi così evidente. Per esempio, la decisione se distribuire un sistema bancario a tutte le filiali della banca o se mantenerlo centra-

lizzato e utilizzare terminali remoti può essere considerata una questione di progettazione (ovvero un come). Si potrebbe allora affermare che la distribuzione fisica del sistema non sia un requisito, ma una questione riguardante l'implementazione. In altri casi l'utente potrebbe richiedere esplicitamente un'architettura distribuita, che diventerebbe così parte della specifica del sistema. Un'implementazione che realizzasse tutte le funzionalità richieste, mediante l'uso di una singola macchina, sarebbe inaccettabile. Inoltre, a volte, un modo semplice per descrivere che cosa si vuole è quello di fornire un esempio di come potrebbe essere realizzato. Questo non implica che debba essere realizzato esattamente in quel modo, ma che si dovrà comportare come se fosse stato implementato così. Per esempio, si potrebbe affermare che l'esecuzione di diverse transazioni concorrenti in un sistema informativo debba essere eseguita come se ogni transazione avvenisse in un modo non interrompibile. Ciò non significa che l'implementatore debba necessariamente lasciare che ogni transazione termini prima di eseguirne un'altra, il che sarebbe altamente inefficiente nel caso di transazioni lunghe; piuttosto, l'implementatore sarà libero di eseguire le transazioni contemporaneamente, a patto che l'utente le percepisca come eseguite senza interruzioni. In teoria, bisognerebbe specificare tutte le qualità desiderate, mentre l'implementazione dovrebbe assicurare che tutte le qualità descritte vengano effettivamente ottenute (ad esempio in termini di funzionalità, usabilità, prestazioni, portabilità, etc.). In questo capitolo, tuttavia, ci concentreremo principalmente sulla specifica di funzionalità software (ovvero sulle specifiche funzionali). Affronteremo brevemente la specifica dei requisiti non funzionali nel Paragrafo 5.2. L'attività di specifica è una parte critica dell'intero processo di progettazione. Le specifiche sono il risultato di un'attività di progettazione complessa e creativa; sono soggette a errori, proprio come i risultati di altre attività (ad esempio, come la programmazione). Di conseguenza, tutti i principi discussi nel Capitolo 3 dovrebbero essere applicati anche al processo di specifica. In questo capitolo analizzeremo innanzitutto i possibili usi della specifica. Poi, illustreremo le qualità principali delle specifiche, che dovrebbero essere tenute presenti durante la stesura delle specifiche stesse. Poi analizzeremo alcune tecniche rilevanti per la stesura di specifiche, classificandole secondo diversi stili di specifica. Discuteremo l'applicabilità di ogni classe a diversi ambiti applicativi. Il ruolo della specifica lungo il processo di sviluppo verrà trattato nel Capitolo 6. Infine, discuteremo i problemi - e le possibili soluzioni — della gestione delle specifiche di sistemi reali, i quali tendono a essere estremamente complessi.

5.1

I possibili usi delle specifiche

Le specifiche possono essere create e utilizzate per diversi motivi. Descrizione dei requisiti degli utenti. Lo scopo primario di un prodotto è quello di soddisfare i requisiti dell'utente ma questi, a volte, non vengono compresi a fondo dallo sviluppatore. Per evitare ciò, diventa necessaria un'attenta analisi, accompagnata da frequenti interazioni con l'utente, finalizzata a chiarire e documentare i requisiti, in modo da individuare ed evitare possibili fraintendimenti. A volte, all'inizio di un progetto, l'utente non sa

chiaramente quale sia il prodotto desiderato. Per esempio, un utente con poca esperienza di prodotti software potrebbe non essere in grado di capire esattamente il livello al quale spingere l'automazione. È probabile che la descrizione iniziale fornita da un utente con poca esperienza non contenga informazioni precise sui requisiti funzionali e prestazionali. In altri casi, i requisiti potrebbero essere invece molto chiari e la loro specifica semplice. Nell'ingegneria tradizionale, infatti, esistono specifiche standard per i comuni oggetti utilizzati nelle costruzioni, quali chiodi, viti e mattoni. Tali standard permettono a differenti produttori di realizzare lo "stesso" prodotto. Nell'ingegneria del software un linguaggio di programmazione (e l'architettura del processore) possono essere interpretati come una specifica standard per un compilatore per quel linguaggio, che generi codice per quell'architettura. Ciò permette a diversi produttori di creare prodotti basati sulle stesse specifiche. In genere, i progetti falliscono a causa di incomprensioni tra il produttore e il consumatore. E più probabile che tali fraintendimenti accadano quando la cultura e il "linguaggio" dei due sono molto diversi. Il caso di studio A in Appendice è un esempio di un problema di questo tipo. Quando si verificano fraintendimenti, sfortunatamente, ritornare al documento di specifica dei requisiti solitamente rivela un'ambiguità che supporta sia l'interpretazione dell'utente che quella del produttore. Questa situazione dimostra la necessità di poter verificare le specifiche, ad esempio, poter controllare se definiscono in modo adeguato che cosa debba essere il prodotto prima di implementarlo. Per esempio, sottoporre un documento di specifica all'utente finale potrebbe aiutare a individuare fraintendimenti circa le vere necessità dell'utente, evitando così di produrre definizioni improprie. Descrizione dell'interfaccia tra la macchina e l'ambiente controllato. I computer interagiscono con l'ambiente esterno ricevendo input (ad esempio, i comandi di un utente o i segnali dai sensori di una centrale controllata) e fornendo output (ad esempio, dati di controllo per gli attuattori o risposte ai comandi degli utenti). Una specifica errata o un fraintendimento tra gli ingegneri e gli esperti del dominio applicativo (i quali conoscono tutti i vari fenomeni che potrebbero influenzare la funzione di controllo da implementare), potrebbe avere conseguenze indesiderate, e richiedere una riprogettazione o reimplementazione di una grossa parte dell'applicazione, facendo così crescere i costi di sviluppo. Nel caso di un sistema critico, se questi problemi non venissero catturati e si dovessero propagare all'implementazione, potrebbero causare disastri o situazioni impossibili da recuperare. È quindi necessario specificare l'interfaccia tra la macchina e l'ambiente controllato, descrivendo in maniera precisa gli input, gli output e le relazioni previste, inclusa la specifica dei limiti di tempo che il controllore dovrà soddisfare. Tutti gli esempi illustrati condividono un'essenza comune. L'obiettivo della specifica è una descrizione precisa del confine tra la macchina e il mondo esterno con il quale la macchina interagisce. Nel caso di sistemi che devono interagire con gli utenti, il mondo esterno può essere descritto e capito in termini di che cosa si aspetta l'utente finale. Nel caso di sistemi embedded, l'ambiente esterno sarà l'insieme di periferiche controllate dalla macchina. Descrizione dei requisiti implementativi. Le specifiche possono essere usate come punto di riferimento durante lo sviluppo del prodotto. Infatti, lo scopo ultimo dell'implementazione è quello di costruire un prodotto che soddisfi le specifiche. Pertanto, l'implementato-

re usa le specifiche durante la fase di progettazione per prendere decisioni e durante l'attività di verifica per controllare che l'implementazione soddisfi i requisiti. Abbiamo già sottolineato come l'intero processo di progettazione consista in una catena di definizione-implementazione-verifica. E quindi probabile che, in un dato momento, esistano diversi documenti di specifica. Una specifica che definisca il comportamento esterno di un sistema viene detta specifica dei requisiti, mentre una specifica dell'architettura software, possibilmente a diversi livelli di astrazione, viene detta specifica di progetto. In generale, i diversi usi della specifica sottolineano le qualità richieste in modi diversi, a volte anche contrastanti. Per esempio, se le specifiche dovranno essere utilizzate come parte di un contratto, allora tutte le parti dovranno essere in grado di capire le specifiche. Ciò potrebbe porre restrizioni al linguaggio utilizzato per scriverle, visto che la terminologia e la notazione tecnica potrebbero non essere accettabili per molti utenti. D'altra parte, la specifica di un'interfaccia di un modulo risulterà più utile, ai fini dell'implementazione, se scritta in una notazione formale (vedi Capitolo 4). Descrizione di riferimento durante la manutenzione di un prodotto. Nei Capitoli 2 e 4 abbiamo visto le diverse tipologie di manutenzione che possono nascere durante il ciclo di vita di un prodotto. Tutte hanno a che vedere con le specifiche. Nel caso della manutenzione correttiva, solitamente viene cambiata solo l'implementazione. Le specifiche sono, quindi, necessarie per controllare se la nuova implementazione risolve o meno gli errori presenti nella versione precedente del prodotto. Un'eccezione potrebbe essere il caso in cui l'errore si trovi nella specifica stessa, ma generalmente questo non viene scoperto fino a quando non viene utilizzato il prodotto. In questo caso, si dovrà prima correggere la specifica, per poi modificare di conseguenza l'implementazione. La manutenzione adattativa si verifica a causa di cambiamenti nei requisiti. Tra questi cambiamenti ci sono le modifiche alle funzionalità del prodotto, ad esempio per rispettare un cambiamento di legge riguardante l'ambito applicativo del prodotto, o dovute a modifiche all'ambiente operativo, ad esempio un cambiamento nei meccanismi degli sportelli Bancomat delle banche. In questi casi, le specifiche originali dovranno essere adattate ai nuovi requisiti. Di conseguenza si dovrà controllare se la nuova implementazione soddisfa in maniera corretta i requisiti. A volte, gli ingegneri tentano di ridurre i tempi di sviluppo cambiando l'implementazione, senza prima modificare le specifiche. Questo modo di procedere, però, crea inconsistenze tra le specifiche e l'implementazione, e può portare a problemi ancora più grandi in futuro. Uno degli esempi peggiori di tale approccio è quando diverse modifiche (in gergo, patch) vengono applicate al codice oggetto, come nel caso di studio A. Queste patch producono incoerenze addirittura tra il codice sorgente e il codice oggetto. Nella manutenzione perfettiva, a volte, i requisiti funzionali non cambiano. Per esempio, si potrebbe ristrutturare il progetto di un prodotto nel tentativo di ottenere un miglioramento nelle prestazioni. In altri casi, invece, come l'inclusione di una nuova funzione o la modifica di una funzione esistente, cambieranno anche i requisiti funzionali. Ancora una volta, l'importante è che le specifiche vengano utilizzate per capire in maniera chiara l'impatto di un cambiamento e in modo da poter portare a termine il cambiamento in modo affidabile. Considerazioni simili possono essere applicate ai cambiamenti che devono essere apportati per i moduli che compongono il prodotto. Se è disponibile una specifica precisa

di un modulo, sarà possibile capire se un cambiamento avrà ripercussioni solo sulla sua implementazione (nel qual caso i moduli client risulteranno inalterati) o se avrà ripercussioni anche sull'interfaccia. Nel primo caso l'onere del cambiamento sarà interamente dello sviluppatore del modulo; nel secondo, invece, saranno coinvolti tutti i moduli utenti del modulo.

5.2

Qualità delle specifiche

È ovviamente possibile scrivere specifiche buone o specifiche cattive. La maggior parte delle qualità elencate nel Capitolo 2 contribuisce a produrre specifiche di buon livello. Per esempio, l'usabilità è una caratteristica rilevante per le specifiche, così come lo è per un prodotto software. Il concetto di usabilità, applicato alle specifiche, può avere diverse accezioni a seconda di chi sia effettivamente l'utente delle specifiche (ad esempio, l'utente finale o l'implementato re). Un'altra qualità desiderabile, nel caso delle specifiche, è la manutenibilità, in quanto le specifiche tendono a cambiare durante il ciclo di vita di un prodotto, di pari passo con i cambiamenti cui va incontro il prodotto stesso. In questo paragrafo discuteremo tre insiemi di qualità particolarmente rilevanti per le specifiche. Il primo insieme di qualità richieste per le specifiche è che esse siano chiare, non ambigue e facilmente comprensibili. Questa affermazione potrebbe sembrare ovvia ma non è mai troppo enfatizzata. In particolare, è probabile che le specifiche informali, scritte in un linguaggio naturale, contengano sottili ambiguità. Si consideri l'esempio di un programma per la videoscrittura che fornisca il comando s e l e c t , specificato nel seguente modo1: La selezione è il processo di designazione di aree del d o c u m e n t o su cui si vuole lavorare. La maggior parte delle azioni di modifica e di formattazione richiede due passaggi: è necessario prima selezionare ciò su cui si vuole lavorare, ad esempio un testo o un'immagine; poi si può iniziare l'azione appropriata.

Questa definizione non specifica esattamente che cosa si intende con il termine "area". Nella maggior parte dei tool, la definizione assume implicitamente che un'area sia una "sequenza contigua di caratteri". Un utente che non sia a conoscenza di questa assunzione potrebbe interpretare il termine "area" come una collezione di frammenti di testo non necessariamente contigui, tali per cui un utente può selezionare diverse parole in punti diversi all'interno di un testo e poi formattare i caratteri in corsivo con un comando solo. Ciò non risulta possibile in un programma di videoscrittura tradizionale, anche se la specifica originale non è chiara su questo fatto. Un altro esempio potrebbe essere il seguente frammento di specifica, estratto dalla documentazione di un progetto reale per un sistema critico: Il messaggio dovrà essere triplicato. Le tre copie dovranno essere spedite attraverso tre canali fisici diversi. II ricevitore dovrà accettare il messaggio sulla base di una politica di votazione "due su tre".

La specifica proviene dal manuale di Microsoft Word 4.0.

Intuitivamente, la specifica afferma che, per motivi di affidabilità, i messaggi vengono triplicati. Al momento di ricevere le tre copie del messaggio, il ricevitore determinerà i contenuti confrontando le copie: se due di esse dovessero essere uguali il contenuto verrà assunto come corretto. Non è chiaro, però, se il messaggio possa, o debba, essere considerato ricevuto non appena ne sono state ricevute due copie identiche, senza aspettare la terza, o se il ricevitore debba aspettare tutte e tre le copie prima di eseguire il confronto dei loro contenuti. Siccome stiamo parlando di un sistema real-time, la questione può fare la differenza quando cambiano i tempi di risposta per il trattamento dei messaggi2. L'applicazione dei principi di rigore e di formalità può aiutare significativamente nel raggiungere questa e molte altre qualità nelle specifiche. Per esempio, l'ambiguità nella politica di votazione appena discussa fu scoperta quando si decise di formalizzare la specifica informale. Più avanti, in questo capitolo, vedremo anche come formalizzare la politica di votazione, in modo da rimuovere tutte le ambiguità. La seconda qualità fondamentale richiesta per le specifiche è la coerenza. Per esempio, sempre nel caso di un programma di videoscrittura, si potrebbe affermare che: •

l'intero testo deve essere mantenuto su linee di uguale lunghezza, specificata dall'utente;



a meno che l'utente non inserisca esplicitamente nel testo un trattino, il comando per andare a capo dovrebbe essere possibile solo alla fine di una parola.

Queste due affermazioni sono contraddittorie nel caso in cui una particolare parola sia più lunga della lunghezza specificata per una linea. In tale caso, la specifica è incoerente, e nessuna implementazione la potrà mai soddisfare. La probabilità di introdurre inavvertitamente un'incoerenza all'interno di una specifica cresce con la lunghezza e la complessità dei documenti di specifica, cosa comune nei progetti reali. La terza qualità richiesta per le specifiche è che queste siano complete. Esistono due aspetti di completezza. In primo luogo, si richiede che la specifica sia completa internamente. Ciò significa che la specifica deve definire tutti i nuovi concetti o i nuovi termini di cui fa uso. Solitamente, per questo motivo, è utile servirsi di un glossario. Per esempio, se la specifica di un ascensore dovesse affermare che "in assenza di richieste l'ascensore entra in uno stato di attesa di richiesta", la specifica dovrà anche definire il significato dello stato di "attesa di richiesta". Il secondo aspetto (completezza esterna) si riferisce alla completezza dei requisiti: la specifica dovrebbe documentare tutti i requisiti richiesti. Nell'esempio dell'ascensore, se dovesse essere richiesto che un ascensore libero (senza richieste pendenti) vada al primo piano e apra le porte, questo requisito dovrebbe essere reso esplicitamente, e non lasciato alla discrezione del progettista. La completezza esterna si riferisce implicitamente ai requisiti funzionali. Anche se in genere i requisiti funzionali sono fondamentali, non sono gli unici requisiti importanti. Sono importanti anche i requisiti non funzionali o qualitativi (usabilità, affidabilità, prestazioni, etc.). Per esempio, le prestazioni in termini di tempi di risposta non

2

Esaminando la documentazione di progetto abbiamo scoperto che in realtà nessuna delle due possibilità era stata scelta dall'implementatore. Infatti, il ricevitore controllava periodicamente i tre canali in modo da definire un time-out implicito: se le tre copie venivano ricevute entro un periodo di tempo determinato, venivano confrontate tutte; se, invece, dopo un determinato periodo, erano state ricevute solo due copie e i loro contenuti erano identici, allora il messaggio veniva accettato senza attendere la terza trasmissione.

vengono molte volte specificate per sistemi non real-time (come ad esempio un programma di videoscrittura). Usando il prodotto, tuttavia, l'utente potrebbe lamentarsi del fatto che questo sia troppo lento. In genere, anche i cosiddetti "casi eccezionali" - che possono essere molto rilevanti - vengono frequentemente ignorati: si pensi per esempio agli inconvenienti che possono sorgere se un salto di tensione nell'impianto elettrico causasse la perdita di tutti i file aperti in quel momento. Spesso è irrealistico esigere specifiche complete in senso assoluto. Molti requisiti potrebbero essere identificati chiaramente solo dopo che si acquisisce una certa esperienza con il sistema, e inoltre dovrebbero essere specificati troppi dettagli. Più realisticamente, alcuni requisiti possono essere considerati comuni a ogni sistema e quindi assunti implicitamente per ogni specifica. Questa assunzione potrebbe quindi portarci ad accettare un certo livello di imprecisione e ambiguità. Per esempio, capiterà più volte di accontentarsi di frasi del tipo "i tempi di risposta dovrebbero essere di circa due secondi" o "il sistema dovrebbe essere robusto rispetto ai guasti del sistema di alimentazione". Lo scopo, comunque, è quello di mantenere tali imprecisioni entro i limiti necessari per evitare il rischio di ambiguità pericolose. E responsabilità sia dell'utente che del produttore decidere quando una determinata imprecisione possa essere accettata sulla base del buon senso e quando invece vada evitata. Si potrebbe argomentare che in molti casi pratici, inclusi gli esempi che abbiamo discusso in questo paragrafo, le specifiche vengano dichiarate appositamente in modo informale, in quanto la scelta di quale interpretazione dare per rimuovere le ambiguità è ritenuta una decisione implementativa: dal punto di vista dell'utente è ugualmente accettabile qualsiasi modo di togliere le ambiguità. Secondo questo punto di vista, fornire una specifica precisa sovraspecificherebbe il sistema e porrebbe limitazioni all'implementatore. Il punto debole di questa posizione è che solitamente non si sa se la mancanza di precisione in una specifica sia dovuta a una scelta precisa o a una distrazione. Inoltre, l'uso di un linguaggio informale non aiuta a sottolineare i punti in cui si nascondono le ambiguità: le domande nascono solo quando si tenta di descrivere i requisiti in modo formale. A quel punto, si può operare una scelta consapevole ed esplicita: o la specifica esprime in modo preciso che cosa va fatto, o la risoluzione dell'ambiguità viene rimandata all'implementazione. L'uso del principio di incrementalità è estremamente importante nel derivare le specifiche, proprio a causa delle difficoltà nell'ottenere specifiche complete, precise e non ambigue. Si potrebbe, quindi, cominciare abbozzando un documento di specifica per poi espanderlo attraverso diverse iterazioni, magari dopo aver accumulato una certa esperienza con alcuni prototipi. Vedremo diversi esempi di questa strategia più avanti in questo capitolo. Per motivi analoghi, anche il principio di modularità risulta essere importante per le specifiche. Esercizi 5.1

Ripassate le qualità del software elencate nel Capitolo 2 ed evidenziate quali siano rilevanti per le specifiche e quali no.

5.2

Fornite una specifica precisa per la funzione "giustifica a margine" per un p r o g r a m m a di videoscrittura.

5.3

Classificazione degli stili di specifica

Possiamo classificare i vari stili di specifica secondo due criteri indipendenti. Le specifiche possono essere poste in maniera formale o informale. Le specifiche informali sono scritte in un linguaggio naturale e possono utilizzare anche figure, tabelle e altre notazioni per fornire una struttura migliore e agevolare la comprensione. Possono anche essere strutturate in maniera standard. Una notazione dalla sintassi e dal significato precisamente definiti viene chiamata formalismo. I formalismi sono usati per creare specifiche formali. E utile parlare anche di specifiche semiformali, in quanto, nella pratica, capita frequentemente di impiegare una notazione senza insistere su una semantica precisa. Le notazioni T D N e GDN introdotte nel Paragrafo 4.2.3.1 sono un esempio di notazione semiformale, visto che uniscono una descrizione sintattica formale per le interfacce dei moduli alle descrizioni informali del loro significato. Vedremo come i commenti informali delle nostre specifiche di moduli con T D N possano essere resi precisi formalizzando la semantica delle operazioni. Anche i diagrammi delle classi UML possono essere usati come una notazione semiformale per descrivere un progetto, ma è anche disponibile un linguaggio formale chiamato OCL, utile per fornire alle classi una semantica precisa. La seconda principale distinzione tra i diversi stili di specifica è quella tra specifiche operazionali e descrittive. Le specifiche operazionali descrivono il sistema desiderato specificando il suo comportamento desiderato, solitamente fornendo un modello del sistema (cioè una "macchina astratta" in grado di simulare il comportamento desiderato). Le specifiche descrittive, invece, esprimono le proprietà desiderate del sistema in modo puramente dichiarativo. Per esempio, si supponga di fornire la seguente specifica della figura geometrica E: la figura geometrica E può essere disegnata nel seguente modo: 1. Selezionare due punti P, eP 2 su un piano. 2. Prendere una cordicella e legare i due capi rispettivamente a Pj e P 2 . 3. Posizionare una matita come illustrato nella Figura 5.1. 4. Muovere la matita in senso orario, tenendo la cordicella tesa, fino a quando non viene raggiunto il punto di partenza del disegno. Quella che abbiamo fornito è una definizione operazionale della curva nota in geometria con il nome di ellisse, con fuochi in Pj e P2. Una definizione alternativa della stessa curva poteva essere data sotto forma della sua equazione, ax 2 + by 2 + c = 0, dove a, b e c sono costanti. L'esempio mostra che, mediante una definizione operazionale, è possibile controllare se la specifica descrive il tipo di curva che avevamo in mente al momento di fornirne la specifica. Infatti, è molto semplice disegnare la curva con una matita su un foglio, seguendo la specifica, e poi controllare se la curva soddisfa i requisiti che avevamo in mente. Certo, l'implementazione della curva potrebbe risultare completamente diversa da quanto descritto nella specifica; per esempio, potrebbe essere una curva mostrata su un terminale. Ciononostante, la sperimentazione ci aiuta a capire se la specifica data è corretta. Se volessimo controllare se un dato punto P nella posizione ( x ^ y j giace sulla curva, potremmo farlo con più facilità riferendoci all'equazione. La curva potrebbe essere la traiettoria di un

5.3

Figura 5.1

Classificazione degli stili di specifica

185

Costruzione di un'ellisse.

robot, il punto P potrebbe rappresentare il punto in cui lavora una persona, e per motivi di sicurezza potrebbe essere necessario richiedere che il robot non passi mai per il punto P. Come esempio relativo al software, si consideri la seguente specifica operazionale informale dell'ordinamento di un array: Sia a un array di n elementi. Il risultato dell'ordinamento di a è un array b che p u ò essere costruito nel m o d o seguente: 1. Trovare l'elemento più piccolo c o n t e n u t o in a e assegnarlo come primo elemento di b . 2. Rimuovere l'elemento trovato nel passo 1 da a e trovare l'elemento più piccolo tra quelli rimanenti. Assegnare questo elemento come secondo elemento dell'array b . 3. Ripetere i passi 1 e 2 fino a q u a n d o tutti gli elementi n o n sono stati rimossi da a .

Questa specifica suggerisce un modo semplice e naturale (anche se non molto efficiente) per implementare l'ordinamento di un array. Il suggerimento, tuttavia, non implica che un algoritmo di ordinamento debba ordinare a in quel modo: dovrà solo produrre lo stesso risultato. Quicksort, per esempio, è un'implementazione perfettamente adeguata della specifica. Il problema con questo tipo di specifica è che risulta difficile per il lettore determinare esattamente che cosa sia prescritto, che cosa debba essere implementato e che cosa no. Una possibile specifica descrittiva dell'ordinamento di a potrebbe essere la seguente: Il risultato dell'ordinamento di a è un'array b ordinato che è anche una permutazione di a .

Se i concetti di permutazione e di ordinamento non dovessero essere considerati abbastanza chiari, potrebbero essere specificati ulteriormente. Se, d'altra parte, dovessero essere ritenuti concetti primitivi, la specifica si potrebbe considerare completa. Esistono diversi compromessi nello scegliere una specifica descrittiva piuttosto che una specifica operazionale. In genere si ritiene che le specifiche descrittive abbiano un maggiore livelb di astrazione rispetto a quelle operazionali, visto che non indirizzano il lettore verso alcuna implementazione particolare, ma aiutano a focalizzare l'attenzione sulle proprietà essenziali del sistema, senza modellare il comportamento di alcuna implementazione. Anche se si tratta di un'affermazione largamente condivisibile, dobbiamo riconoscere che in ogni

specifica è presente uno schema implementativo nascosto. Per esempio, la specifica descrittiva dell'ordinamento di un'array suggerisce la seguente implementazione banale: "enumerare tutte le permutazioni dell'array originale. La prima permutazione ordinata rappresenta un output adeguato per l'algoritmo di ordinamento". Si potrebbero scrivere anche specifiche che in qualche modo rappresentino una via di mezzo tra l'operazionale e il descrittivo. Per esempio, si potrebbe definire una nuova operazione da applicare a un array a nel seguente modo: •

Innanzitutto, a deve essere ordinato, dove la definizione di "ordinato" viene fornita in modo descrittivo.



Poi, tutti gli elementi presenti in copia multipla devono essere rimossi dall'array.

Questa specifica è operazionale nel senso che definisce una sequenza di due operazioni da eseguire in modo da ottenere il risultato desiderato. Ma una parte di questa, ovvero il significato di "ordinato", viene fornita in modo descrittivo. Per concludere, la distinzione tra specifiche operazionali e descrittive non può essere sempre precisa e, a volte, risulta soggettiva. È però una distinzione sostanzialmente adeguata per la categorizzazione di diversi stili di specifiche. Nelle prossime pagine approfondiremo i diversi stili di specifica, valutando criticamente alcuni esempi di notazioni. Anche se queste sono state scelte in quanto costituiscono esempi importanti di diversi stili di specifiche, non sarà la scelta delle notazioni l'argomento principale. Dato che non esiste lo stile perfetto per tutte le circostanze, il nostro obiettivo sarà aiutare il lettore a sviluppare la capacità di analizzare una notazione di specifica in maniera critica, per poi sceglierla o rifiutarla a seconda della situazione. Lo stile o notazione appropriata può aiutare un progettista a esprimere con chiarezza gli aspetti del progetto. Una notazione inadeguata, invece, rende tutto più difficile. Alcune notazioni, comunque, stanno guadagnando una larga diffusione, anche nella pratica. Di conseguenza, sono diventate oggetto di standardizzazione. La notazione UML, un esempio di notazione standardizzata introdotta nel Capitolo 4, verrà qui ulteriormente approfondita. Nessuno stile o notazione, formale o informale che sia, può comunque garantire che il progettista riesca a concepire un buon progetto. Una buona specifica e progettazione richiedono capacità adeguate, esperienza, giudizio e anche creatività. Poiché l'attività di progetto e sviluppo dipende fortemente da criteri soggettivi, risulta importante sviluppare spirito critico e intuizione in quanto aiuteranno ad adattare stile, tecnica e notazione di specifica al problema in esame. Esercizi 5.3

Fornite una specifica descrittiva completa dell'esempio precedente, che faceva uso di un insieme di stili descrittivi e operazionali per descrivere la costruzione di un array ordinato senza elementi duplicati.

5.4

Considerate le specifiche operazionali e descrittive dell'operazione di o r d i n a m e n t o fornite in questo paragrafo. Vi sono delle ambiguità? C o m e d o v r a n n o essere trattati gli elementi duplicati?

5.4 Verifica delle specifiche Un utilizzo importante delle specifiche è quello di costituire il punto di riferimento in base al quale verificare un'implementazione. Tuttavia, anche le specifiche necessitano di una verifica. Nel Capitolo 2 abbiamo visto che la correttezza di un'applicazione non garantisce che le funzioni messe a disposizioni coincidano necessariamente con quelle richieste dal cliente. La stessa cosa può dirsi per tutte le qualità del software. Anche se l'implementazione dovesse eventualmente soddisfare la specifica, potremmo sempre avere tra le mani un prodotto che non soddisfi il cliente. È quindi importante che le specifiche siano verificate prima di cominciare l'implementazione, in modo da controllare che siano corrette. Ci sono due modi generali per verificare una specifica. Uno consiste nell'osservare il comportamento dinamico del sistema specificato in modo da controllare se sia conforme all'idea intuitiva, che ci eravamo fatti, del comportamento del sistema. L'altro consiste nell'analizzare le proprietà del sistema specificato che possono essere dedotte a partire dalla specifica. E possibile infatti confrontare le proprietà dedotte con le proprietà attese del sistema. L'efficacia di entrambe le tecniche cresce in funzione del grado di formalità della specifica. Infatti, in un quadro interamente formale, un modo per osservare il comportamento dinamico del sistema specificato potrebbe essere quello di fornire un'interpretazione del linguaggio formale usato per le specifiche per poi eseguire le specifiche formali con un input di dati di esempio. In maniera simile, è possibile dedurre automaticamente le nuove proprietà da quelle indicate come parte della specifica (descrittiva) in un quadro di logica formale. Nell'esempio di un'ellisse, mostrato nella Figura 5.1, l'osservazione del comportamento dinamico del sistema può anche essere detta simulazione. La simulazione è ottenuta eseguendo la specifica formale, e quindi fornendo un prototipo del sistema prima ancora di cominciare l'implementazione. In un quadro meno formale, l'esecuzione potrebbe essere simulata a mente, piuttosto che meccanicamente. Analogamente, l'analisi delle proprietà può essere eseguita mediante ispezione umana, soprattutto se le specifiche non sono completamente formali. Potrebbe essere utile confrontare l'ingegneria del software con i settori ingegneristici più tradizionali. In questi, le specifiche descrittive sono frequentemente fornite in termini di equazioni matematiche che modellano il sistema. Si pensi a un ponte di una data forma che unisce due sponde di un fiume: il modello matematico fornito dall'ingegnere supporterà un'analisi delle proprietà, in termini di possibilità del ponte di supportare una data distribuzione di forze statiche e dinamiche. Un modello operazionale del sistema, invece, viene in genere fornito come prototipo, e non è visto tanto come una specifica ma piuttosto come un aiuto nella verifica della specifica. Sia nella costruzione di ponti che nella costruzione di software, una specifica inadeguata può portare al fallimento del sistema. E noto che alcuni ponti sono crollati durante tempeste perché, al momento della specifica, non era stata presa in considerazione la possibilità di venti anomali. Fino ad ora, abbiamo preso in considerazione solo la verifica di specifiche funzionali. È importante verificare anche la completezza e la coerenza delle specifiche. Ancora una volta, con le specifiche formali, una parte di queste verifiche può essere eseguita in maniera automatica (ad esempio verificando che tutti i termini utilizzati nella specifica siano stati definiti); una parte della specifica potrebbe invece necessitare di prove più sofisticate. Le spe-

tifiche informali sono più diffìcili da verificare automaticamente, ma anche per loro esistono, e dovrebbero essere usati, alcuni controlli meccanici. Affronteremo la questione della verifica dei requisiti più avanti, sia in questo capitolo che nei Capitoli 6, 7 e 9.

5.5

Specifiche operazionali

In questo paragrafo descriveremo alcune notazioni molto usate e applicate per le specifiche operazionali. Cominceremo con alcune notazioni semiformali il cui utilizzo è estremamente diffuso nella pratica per la descrizione di sistemi informativi, e passeremo poi ad illustrare notazioni formali adatte alla descrizione di aspetti di controllo nella modellazione di sistemi.

5.5.1

Diagrammi di flusso di dati: la specifica delle funzioni dei sistemi informativi

I DFD (data flow diagram, diagramma di flussi di dati) sono una notazione molto conosciuta e usata per la specifica di sistemi informativi e per la descrizione di come i dati fluiscono di funzione in funzione. Descrivono i sistemi come collezioni di funzioni che manipolano dati. I dati possono essere organizzati in diversi modi: possono essere immagazzinati in archivi di dati, possono fluire lungo flussi di dati e possono essere trasferiti da o verso un ambiente esterno. Uno dei motivi del successo dei DFD è che possono essere espressi mediante notazioni grafiche che li rendono facili da usare. Gli elementi di base dei DFD sono3: •

Le funzioni, rappresentate da bolle.



I flussi di dati, rappresentati da frecce. Le frecce entranti in una bolla rappresentano valori di input facenti parte del dominio della funzione rappresentata dalla bolla. Le frecce uscenti invece rappresentano i risultati della funzione, ovvero, valori appartenenti al codominio della funzione.



Gli archivi di dati, rappresentati da scatole aperte. Frecce entranti (o uscenti) nelle scatole aperte rappresentano dati che vengono inseriti (o estratti) nell'archivio di dati.



Gli input/output, rappresentati da tipi speciali di scatole I/O che descrivono acquisizioni e generazioni di dati durante l'interazione con gli utenti.

La Figura 5.2 fornisce alcuni esempi di questi simboli grafici. La Figura 5.3 illustra invece come i simboli possano essere combinati per formare un DFD. Il DFD descrive la valutazione dell'espressione aritmetica ( a + b )

3

* (c + a * d)

La notazione D F D non è uno standard. In letteratura esistono diverse definizioni leggermente diverse.

o

Simbolo per le funzioni

Simbolo per l'inpul rl.l periferica

Simbolo per il flusso di d.ili

Simbolo per gli archivi di dati

Figura 5.2

Simbolo per l'outpul verso periferiche

Simboli grafici di base usati per costruire i diagrammi di flussi di dati.

assumendo che i dati a, b, c e d vengano letti da un terminale e che il risultato venga stampato. La figura mostra come le frecce possano essere "separate" per rappresentare il fatto che gli stessi dati vengono usati in posti diversi. ESEMPIO 5.1

La Figura 5.4 descrive un sistema informativo semplificato per una biblioteca pubblica. I dati e le funzioni presenti non sono necessariamente parte di un sistema computerizzato. Il DFD può descrivere oggetti fisici, come libri e ripiani, oltre ad archivi di dati che, con buona probabilità, saranno realizzati come file di computer. Prendere un libro da un ripiano può essere fatto sia automaticamente (da un robot) sia manualmente. In entrambi i casi questa azione può essere rappresentata da una funzione raffigurata da una bolla. La figura, che potrebbe anche rappresentare una biblioteca senza alcuna procedura computerizzata, descrive anche il fatto che, per poter prendere un libro, occorrono: •

una richiesta esplicita da parte dell'utente, che consiste del titolo, il nome dell'autore del libro e il nome dell'utente;



un accesso al ripiano su cui sta il libro;

b +

Figura 5.3

d *

a

c +

Diagramma di flusso di dati per la specifica del calcolo di un'espressione aritmetica.

Figura 5.4

D F D per la descrizione di un sistema informativo semplificato per biblioteche.



un elenco di autori;



un elenco di titoli;

per catturare le informazioni necessarie per trovare il libro. La modalità precisa con cui viene ottenuto il libro, tuttavia, non è espressa interamente nella figura. Senza ricorrere alle nostre esperienze passate riguardanti il prelievo di un libro in biblioteca, non ci sarebbe alcun modo per dedurre queste informazioni dalla figura. Dovremmo, quindi, considerare questo DFD come una prima approssimazione della descrizione di un sistema informativo per biblioteche. Una descrizione più precisa, data nella Figura 5.5, può essere vista come un raffinamento della Figura 5.4. La Figura 5.5 risulta tuttavia ancora un po' imprecisa, in quanto non specifica se siano necessari sia il titolo che il nome dell'autore per identificare un libro o se basti uno dei due. Sappiamo che, in generale, uno dei due è sufficiente, anche se occasionalmente sono necessari entrambi. Questa distinzione però non è spiegata nella figura. • Nell'Esempio 5.1, le Figure 5.4 e 5.5 forniscono una descrizione intuitiva del sistema, ma difettano di precisione. Ciò risulta essere vero in generale per tutti i DFD, i quali possono avere un significato ambiguo, fondamentalmente per le seguenti ragioni:

Libro

Figura 5.5

R a f f i n a m e n t o p a r z i a l e d e l l a f u n z i o n e " C o n s e g n a un libro" d e l l a Figura 5 . 4 .

1. La semantica dei simboli utilizzati è specificata solo dai nomi scelti dall'utente. A volte un nome può definire un concetto in modo sufficientemente preciso; per esempio, il simbolo "+" denota chiaramente la funzione "somma", senza che siano necessarie ulteriori spiegazioni. Altre volte, invece, può non bastare. Per esempio, la funzione "Trova la posizione del libro" (Figura 5.5) possiede un significato intuibile ma non specifica che cosa succede se manca qualche informazione necessaria. Una definizione realistica (anche se ancora informale) potrebbe essere la seguente: if

viene

elseif

f o r n i t o sia il n o m e d e l l ' a u t o r e (o d e g l i a u t o r i ) e il t i t o l o d e l l i b r o t b e n t r o v a r e la p o s i z i o n e d e l l i b r o (se il l i b r o n o n e s i s t e r e s t i t u i r e un m e s s a g g i o a d e g u a t o ) viene fornito solo l'autore tben f o r n i r e un e l e n c o da q u e l l ' a u t o r e e una scelta

elseif

end

viene

fornito

solo

di t u t t i chiedere il

titolo

i libri scritti a l l ' u t e n t e di o p e r a r e then

. . .

if

2. Gli aspetti di controllo non sono definiti dal modello. Per esempio, la Figura 5.6 mostra un semplice DFD in cui gli output di tre bolle A, B e C sono input di D, mentre i due output di D sono input delle bolle E ed F. Preso singolarmente, questo diagramma non specifica chiaramente il modo in cui sono utilizzati gli input e come vengono prodotti

Figura 5.6

D F D ambiguo nel suo modo di usare gli input e gli output.

gli output dalla funzione D. In particolare potrebbero esserci numerose, differenti alternative, tutte ugualmente plausibili, sia per l'input che per l'output. Per gli input, •

D potrebbe avere b i s o g n o di A, B e C; ovvero D p o t r e b b e n o n essere in grado di eseguire, a m e n o che n o n siano presenti tutti e tre i valori.



D p o t r e b b e avere b i s o g n o s o l o di u n o tra A, B e C per eseguire; ovvero, la trasform a z i o n e dei dati p o t r e b b e avvenire a n c h e in presenza di u n o s o l o dei tre.

Per gli output, •

D p o t r e b b e produrre u n o u t p u t per una sola delle bolle tra E ed F, in maniera n o n deterministica m a esclusiva.



D potrebbe produrre lo stesso o u t p u t sia per E c h e per F.



D p o t r e b b e produrre o u t p u t diversi per E e per F.

Anche altre interpretazioni degli input e degli output di D potrebbero essere compatibili con la Figura 5.6. Un altro caso in cui i DFD non specificano interamente la sincronizzazione tra componenti di un sistema è mostrato nella Figura 5.7, dove due bolle A e B sono connesse da un singolo flusso di dati. L'output di A viene spedito a B come nuovo input. Ci sono almeno due interpretazioni possibili per questo DFD: • A produce un dato e poi aspetta che B lo consumi (questo potrebbe essere il caso in cui A e B denotano operazioni aritmetiche su dati semplici). • A e B sono attività autonome che hanno velocità diverse, ma esiste un meccanismo di buffer tra loro (come ad esempio una coda limitata o una "pipe" illimitata) che assicura che non si verifichino perdite o duplicazioni di dati (per esempio, A potrebbe occuparsi del calcolo del tempo impiegato dai dipendenti nel loro lavoro, e B del calcolo degli stipendi).

Figura 5.7

D F D che non specifica la sincronizzazione tra moduli.

Concludendo, i DFD sono una notazione grafica adatta a una descrizione immediata e intuitiva del flusso dei dati e delle operazioni coinvolte in un sistema informativo. Purtroppo, i DFD non possiedono una semantica precisa. Anche se la notazione potrebbe essere interpretata come operazionale, il comportamento della macchina astratta corrispondente non è interamente specificato, ma sono possibili diverse interpretazioni per il regime di controllo associato a un DFD. Questi inconvenienti implicano conseguenze negative. In primo luogo, una descrizione sommaria del sistema modellato non è sufficiente: abbiamo bisogno di definizioni precise e dettagliate che i DFD non sono in grado di fornirci. In secondo luogo, immaginiamo di voler costruire una macchina che simuli il sistema modellato in modo da poter controllare che le specifiche riflettano il volere degli utenti. Tale macchina non può essere derivata direttamente dal DFD visto che nessuna esecuzione automatica è possibile senza una semantica precisa per la notazione. Un lettore umano potrebbe essere in grado di riempire i vuoti concettuali grazie ai significati intuitivi degli identificatori, ma la macchina, che manca di intuito, non sarà mai in grado di interpretare una notazione come quella fornita nella Figura 5.7. Per questi motivi, diciamo che i DFD tradizionali sono una notazione semiformale. La loro sintassi, il modo in cui si compongono bolle, frecce e scatole, è definita in modo preciso, ma la loro semantica non lo è. Sono stati progettati diversi metodi per ovviare a queste difficoltà, che possono essere sommariamente classificati nel seguente modo. •

Usare una notazione complementare per descrivere quegli aspetti del sistema non definiti in maniera adeguata dai DFD. La specifica intera del sistema consisterà nell'integrazione di diverse descrizioni fornite con differenti notazioni. Vedremo esempi di questa tecnica più avanti.



Migliorare il modello DFD in modo da poter affrontare aspetti non definiti nella sua versione tradizionale. Per esempio, potremmo gestire aspetti di controllo introducendo frecce di controllo delflusso. Una freccia per il controllo del flusso entrante in una bolla significherebbe che la computazione della funzione associata alla bolla è possibile solo nel momento in cui appare un segnale sulla freccia. La Figura 5.8 mostra la notazione modificata e un esempio di uso delle frecce di controllo del flusso.

Figura 5.8

D F D parziale con l'aggiunta di frecce per il controllo del flusso. Il trigger è una freccia di controllo del flusso tratteggiata. La funzione "somma" associata con la bolla è applicata a tutti i dati esistenti nelle scatole non appena si presenta il trigger.



Revisionare la definizione tradizionale di DFD in modo da renderlo interamente formale. Per esempio, si potrebbe definire un modello D F D per rendere possibile l'espressione di tutte le interpretazioni desiderabili dei D F D originali in maniera non ambigua. Si potrebbero utilizzare differenti notazioni per distinguere il caso in cui una freccia tra due bolle rappresenta il flusso di un dato singolo dal caso in cui rappresenta una pipe. Oppure si potrebbe annotare il diagramma in modo da indicare la necessità di avere tutti i dati di flusso in input o uno solo. Infine, si potrebbe fornire una notazione per la specifica formale della funzione eseguita da una bolla.

Esercizi 5.5

Fornite una descrizione più dettagliata di u n sistema i n f o r m a t i v o per biblioteche, includ e n d o ulteriori operazioni c o m e la restituzione, la p r e n o t a z i o n e e la ricerca di un libro. Raffinate le operazioni fino a u n livello in cui sono t u t t e spiegate in m o d o sufficientemente dettagliato.

5.6

Fornite una specifica dettagliata di una funzione realistica che permetta di trovare la posizione di u n libro. Riflettete su quanti dettagli sono coinvolti nella specifica completa di una parte cosi piccola e apparentemente semplice di u n sistema informativo.

5.7

Fornite una descrizione D F D su c o m e richiedere ed effettuare l'iscrizione all'università.

5.5.2 Diagrammi UML per comportamenti specifici UML è una collezione di linguaggi che forniscono notazioni per la specifica, l'analisi, la visualizzazione e la documentazione di sistemi software. Queste notazioni vengono utilizzate dai progettisti per produrre progetti standard composti da diversi diagrammi, ognuno dei quali è finalizzato ad esprimere un diverso aspetto del sistema software. Illustreremo, in questo paragrafo, gli use case diagram, i sequence diagram e i collaboration diagram; ognuno di questi può essere utilizzato per modellare gli aspetti dinamici di un sistema. Gli use case diagram forniscono un'astrazione globale degli "attori" coinvolti in un sistema e delle azioni eseguite dal sistema che, a loro volta, forniscono un risultato visibile di interesse per gli attori. Gli use case diagram descrivono il contesto globale di un sistema partizionando le funzionalità offerte in transazioni utili agli attori e mostrando come gli attori possono interagire con esse. Gli attori sono un'astrazione di entità esterne che interagiscono col sistema, quali persone e processi, e sono legati agli use case da associazioni che rappresentano il percorso comunicativo tra un attore e il caso d'uso al quale partecipa. Per esempio, si consideri la descrizione di una biblioteca fornita nella Figura 5.9. Il sistema permette agli utenti di prendere in prestito e restituire libri. Queste azioni coinvolgono sia gli utenti che i dipendenti della biblioteca. I bibliotecari possono aggiornare la biblioteca inserendo copie di libri nuovi e eliminando copie di libri obsoleti. I sequence diagram e i collaboration diagram sono due notazioni equivalenti che possono essere utilizzate per descrivere le interazioni tra oggetti mediante la spedizione di messaggi. Forniscono una visione dinamica di un sistema, mostrando graficamente gli scenari che possono esistere a run-time quando gli oggetti interagiscono per portare a termine un determinato compito.

Figura 5.9

Use case diagram.

Il sequence diagram della Figura 5.10 illustra un frammento della specifica di un sistema bibliotecario; lo fa descrivendo uno tra gli scenari che si possono configurare qualora un cliente prenda un libro in prestito. In questo scenario, il cliente inizialmente esibisce la propria tessera e il bibliotecario provvede a controllarne la validità. Se è valida, controlla il catalogo per verificare se il libro richiesto è disponibile. In caso affermativo, il cliente può prenderlo in prestito. Il sequence diagram indica, visivamente, la progressione del tempo lungo l'asse verticale, in modo da mettere in evidenza la sequenza temporale dei messaggi scambiati tra oggetti (e cioè, il cliente, il bibliotecario e il catalogo). La Figura 5.11 descrive lo stesso scenario mediante un collaboration diagram. Questo indica quali siano gli oggetti coinvolti nell'interazione e descrive la sequenza temporale degli eventi mediante la numerazione degli archi che legano gli oggetti che collaborano. I sequence diagram e i collaboration diagram sono semanticamente equivalenti, ma diversi sintatticamente. La scelta di quale dei due usare è una questione di gusto personale. I collabo-

Clienle

Catalogo

Bibliolecario

Tessera bibliotecaria + richiesta libro

Tessera valida

Richiesta libro Libro disponibile Libro preslalo

Figura 5.10

Sequence diagram.

Tempo

1 : Tessera bibliotecaria + richiesta libro

»-

Cliente

5: Libro prestato

Figura 5.11

2 : Tessera valida 3: Richiesta libro Bibliotecario

Catalogo 4 : Libro disponibile

Collaboration diagram.

ration diagram evidenziano meglio le proprietà strutturali di una collaborazione, mentre i sequence diagram evidenziano meglio l'evoluzione temporale dello scenario. Le specifiche degli use case e di possibili scenari di comportamento del sistema sono molto utili nella fase di specifica dei requisiti, quando il progettista deve interagire con i clienti per capire quali siano le loro aspettative. I diagrammi descrivono alcuni casi rappresentativi del comportamento del sistema in maniera molto intuitiva. Analizzando questi diagrammi, l'utente può confermare se le specifiche catturano con successo i comportamenti attesi.

5.5.3 Macchine a stati finiti: descrizione del flusso di controllo Nella descrizione dei sistemi informativi, l'enfasi è sull'organizzazione di funzioni e flussi di dati. Per rendere le specifiche più precise è però necessario prestare attenzione anche agli aspetti di controllo. Per esempio, prima o poi, si potrebbe voler specificare in un DFD se l'esecuzione di una funzione debba attendere tutti gli input o se possa iniziare non appena ne arrivano alcuni. Proprio per questo motivo, anche i linguaggi di programmazione possiedono costrutti per la descrizione di dati organizzati e del flusso di controllo. Nella specifica di sistemi diversi, l'equilibrio tra la necessità di descrivere il flusso di dati e il flusso di controllo può essere diverso. Per esempio, in un sistema per la comunicazione potrebbe essere preferibile specificare i requisiti nel seguente modo:

Figura 5.12

Macchina a stati finiti.

Premi tasto

Premi tasto

Figura 5.13

Descrizione di una lampada mediante l'uso di una macchina a stati finiti.



non si deve poter scrivere in un buffer pieno o leggere da un buffer vuoto;



non si deve poter accedere a un buffer mentre un altro processo lo sta utilizzando in scrittura;



la lettura da un buffer deve avere priorità più alta rispetto alla scrittura;



ogni messaggio deve essere rispedito attraverso un canale entro 2 millisecondi dal momento del suo arrivo.

Si potrebbero affrontare anche aspetti funzionali: •

per ogni messaggio ricevuto deve essere controllata la parità;



dopo aver ricevuto 10 messaggi, deve essere sintetizzato un nuovo messaggio concatenando i 10 messaggi ricevuti e deve essere aggiunta un'intestazione contenente l'indirizzo della stazione che ha ricevuto i 10 messaggi originali.

Perciò, sia i sistemi informativi sia quelli di controllo - così come ogni altro tipo di sistema - presentano aspetti funzionali di trattamento dei dati unitamente ad aspetti di controllo. Tuttavia, i modelli utilizzati possono, a seconda della natura dei sistemi, porre in rilievo i due aspetti in modo differente. Le FSM (finite state machine, macchina a stati finiti) sono un'importante notazione formale, semplice e molto conosciuta, per la descrizione di aspetti di controllo. Una FSM consiste di4: 1. un insieme finito di stati Q; 2. un insieme finito di input I; 3. una funzione di transizione 5: Q x I — 8 può essere una funzione parziale; ovvero, può non essere definita per alcuni valori del dominio. Una FSM può essere illustrata mediante un grafo i cui nodi rappresentano gli stati; un arco etichettato i porta da q j in q 2 se o solo se 8 ( q j , i ) = q 2 . La Figura 5.12 fornisce un semplice esempio di FSM.

Più precisamente, questa è la definizione di una macchina a stati finiti deterministica.

Come suggerisce il termine stesso, le FSM sono molto adatte alla descrizione di sistemi che possono trovarsi in un insieme finito di stati e che sono in grado di spostarsi di stato in stato in seguito a determinati eventi, modellati come valori di input. Per esempio, una lampada può essere spenta o accesa e può cambiare il suo stato in seguito a un'azione esterna, che può consistere nella pressione di un tasto. Una seconda pressione del tasto può causare la transizione opposta. Questo sistema molto semplice è descritto dalla FSM nella Figura 5.13. ESEMPIO 5 . 2

Si consideri il controllo di una parte di un impianto chimico. Per motivi di sicurezza, i livelli di temperatura e pressione devono essere monitorati costantemente, ed esistono sensori installati per segnalare il superamento di determinati livelli. Ecco una politica banale per la gestione dell'impianto: quando viene generato un segnale da parte di uno dei sensori, il sistema di controllo spegne l'impianto e attiva un segnale di allarme; il sistema viene reinizializzato manualmente quando il malfunzionamento è stato eliminato. Tutto ciò è descritto nella FSM della Figura 5.14. Questa semplice politica è ovviamente inadeguata. Un modo migliore per gestire l'impianto è il seguente: quando viene generato uno dei due segnali provenienti dai sensori di controllo della temperatura e della pressione, il sistema viene portato in uno stato in cui si tenta un'azione di recupero. Se l'azione di recupero ha successo, il sistema viene riportato automaticamente allo stato "normale" e viene inviato un appropriato messaggio all'ambiente esterno. Altrimenti, si attiva il segnale di allarme e l'impianto viene spento. Il sistema viene spento anche nel caso in cui, trovandosi nello stato di recupero, venga generato un secondo segnale. Si assume che i due segnali non possano essere generati contemporaneamente. Questa seconda politica è illustrata nella Figura 5.15. • Le FSM sono frequentemente utilizzate per specificare insiemi di sequenze accettabili di segnali di input (ad esempio, linguaggi formali). In questo caso, vengono arricchite definendo uno stato iniziale q0 e Q e un sottoinsieme F di Q, chiamato insieme degli stati finali, i quali vengono specificati mediante l'uso di un nodo doppiamente cerchiato. L'insieme I è

Allarme "pressione troppo elevata"

Allarme "temperatura troppo elevata"

Riavvia

Figura 5.14

Descrizione di un impianto chimico mediante l'uso di una FSM.

Suonale pressione

Attività di recupero ha successo

Attività di recupero fallimentare

Attività di recupero ha successo

Attività di recupero tallimentare

Segnale temperatura

Figura 5.15

Segnale temperatura

Segnale pressione

Politica riveduta per il controllo di un impianto chimico descritta mediante l'uso di F S M .

un insieme di caratteri usati per comporre le stringhe di input. Una stringa di input è accettata dalla FSM se e solo se esiste un percorso nella sua rappresentazione grafica che porti da q 0 in uno qualsiasi degli stati finali; il concatenamento delle etichette sugli archi del percorso deve corrispondere alla stringa di input. Per esempio, la macchina nella Figura 5.16 accetta le parole b e g i n ed e n d , quella nella Figura 5.17 accetta gli identificatori validi di un linguaggio di programmazione. A volte, le FSM vengono arricchite mediante la possibilità di produrre segnali di output. In questo caso, la transizione 8 diventa: 8:

q x i ->q x o,

dove 0 è l'insieme finito di simboli di output. Graficamente, viene associata l'etichetta < i / o > a un arco che porta da q j in q 2 se e solo se 5 ( q ! , i ) = < q 2 , o > . Le FSM sono un modello semplice e ampiamente utilizzato. Le sue applicazioni variano dalla specifica di sistemi di controllo alla compilazione, dal riconoscimento di sequenze alla progettazione di protocolli e di hardware e addirittura ad applicazioni che non riguardano il mondo informatico. La semplicità del modello, tuttavia, può diventare una pec-

Figura 5 . 1 6

FSM che accetta le parole chiavi b e g i n ed e n d .

e

Legenda: Abbreviazione di un insieme di frecce etichettate rispettivamente a,b,...,z, A , . . . , Z

Figura 5.17

Abbreviazione di un insieme di frecce etichettate rispettivamente 0 , 1 , . . . , 9

FSM che accetta identificatori di un linguaggio di programmazione.

ca in alcuni casi più intricati. Discuteremo le pecche più rilevanti dal punto di vista della specifica di sistemi, facendo riferimento principalmente ai sistemi di controllo, uno dei più importanti campi applicativi delle FSM. Innanzitutto, le FSM hanno una memoria finita, il che rende il loro potere espressivo molto limitato. Così, nell'Esempio 5.2, si supponga che, in risposta a una temperatura anomala, si voglia tentare un'azione raffreddante proporzionale a Dif fTemp, la differenza tra la temperatura e un valore di riferimento. Questo tentativo non può essere modellato da una macchina a stati finiti in quanto gli stati di raffreddamento risultano essere infiniti (uno per ogni valore possibile di Dif fTemp). Anche quando l'insieme di valori possibili è finito, come in molti casi di interesse pratico, la descrizione delle risposte può diventare molto complicata. Una descrizione in linguaggio naturale risulterebbe notevolmente migliore rispetto alla FSM, che fa uso di un arco per ogni temperatura distinta in un insieme di 50 valori. Allo stesso modo, la memoria di un computer, anche se è pur sempre finita, consiste di un numero ingestibile di stati: descrivere un registro a 8 bit mediante un FSM richiederebbe 28 stati diversi! Quando si presentano situazioni del genere, possiamo affrontarle in diversi modi. •

Possiamo rinunciare alla descrizione di tutti i dettagli del sistema e accontentarci di un'approssimazione che ignori i requisiti del tipo appena discusso. Dopo tutto, la Figura 5.15 fornisce comunque informazioni utili nonostante non specifichi in maniera precisa l'entità del tentativo di raffreddamento.



Possiamo completare il diagramma con commenti informali in linguaggio naturale.



Possiamo cambiare il modello. In realtà sono stati proposti molti modelli per superare le difficoltà delle FSM. Alcuni sono modifiche delle FSM originali, altri invece sono modelli completamente diversi.



Possiamo arricchire il modello aggiungendo nuove caratteristiche alla descrizione per poter affrontare il nuovo requisito.

Così, nel sistema dell'Esempio 5.2, possiamo affermare, formalmente o informalmente, che la transizione dallo stato "normale" allo stato di "attività di recupero del livello normale di temperatura" debba essere accompagnata da un'azione descritta nel modo seguente: C o o 1 i n g _ e f f o r t : = k"

(present_temperature-standard_value)

Inoltre, si potrebbero aggiungere predicati alle transizioni. Un predicato dovrà essere o vero o falso in modo che la transizione vada a buon fine. Per esempio, si potrebbe aggiungere alla macchina a stati finiti della Figura 5.15 una transizione dallo stato "normale" allo stato "impianto spento" con predicato: temp

>

verydangerousvalue

Inoltre, il seguente predicato potrebbe essere aggiunto alla transizione dallo stato "normale" allo stato di "attività di recupero del livello normale di temperatura": temp


u {T x P} è la relazione del flusso 5. W: F —>N — {0} è la funzione peso, che associa un valore naturale non nullo a ogni elemento di F. Se non viene esplicitamente associato alcun peso a un elemento di flusso, è assunto il valore di default "1". Una PN (Petri Net, rete di Petri) può essere descritta usando una rappresentazione grafica, che rende la specifica comprensibile intuitivamente. I posti sono rappresentati come cerchi, le transizioni come sbarre e gli elementi di flusso come frecce. Quando necessario, è possibile unire un posto p e una transizione t con una freccia bidirezionale, andando così a sostituire le due frecce monodirezionali equivalenti, una da p in t e una da t in p. La Figura 5.20 illustra un esempio di rete di Petri. A una PN viene associato uno stato marcando i suoi posti. Formalmente, una marcatura è una funzione M che associa ai posti un numero naturale: M: P

-> N

Una marcatura è rappresentata graficamente inserendo un numero x di gettoni in ogni posto della rete, in modo che x = M(p). La Figura 5.21 (a) mostra una marcatura della PN illustrata nella Figura 5.20. L'evoluzione di una PN è regolata nel modo seguente. Una transizione può avere uno o più posti di input o di output. Se una freccia porta da un posto a una transizione, il posto viene detto uno dei posti di input della transizione; se una freccia porta da una transizione in un posto, questo viene detto uno dei posti di output della transizione. Un posto può essere sia di input che di output per una transizione. Una transizione

Figura 5.20

Esempio di rete di Petri.

Pi

Pi

Figura 5.21

P2

Pi

P2

Pi

P2

Evoluzione di una rete di Petri. (a) Marcatura iniziale, (b) Esecuzione di t t a partire dalla marcatura iniziale, (c) Esecuzione di t 2 a partire dalla marcatura iniziale, (d) Esecuzione di t t e t 2 a partire dalla marcatura iniziale.

che possiede in ogni posto di input un numero di gettoni maggiore o uguale al peso dell'elemento di flusso che li unisce si dice abilitata. Di default il peso dell'elemento di flusso è "1", quindi ogni posto di input dovrà avere almeno un gettone. Una transizione senza posti di input viene ritenuta sempre attivata. Una transizione abilitata si dice che può scattare. Lo scatto di una transizione t rimuove da ogni posto di input Pi un numero di gettoni pari al peso dell'elemento di flusso che unisce Pi con t e inserisce in ogni posto di output qi un numero di gettoni pari al peso dell'elemento di flusso che unisce t con qi. Nella Figura 5.21(a), sia t i che t 2 sono abilitate; nessun'altra transizione è attivata. In questo caso, la marcatura della PN può evolvere in almeno due modi: effettuando la transizione t i o effettuando la transizione t 2 . Il modello è, dunque, non deterministico, nel senso che, data una marcatura iniziale, sono possibili diverse evoluzioni della PN. Nel caso della Figura 5.21 (a) la scelta della transizione t i produce la marcatura illustrata al punto (b), mentre la scelta della transizione t 2 produce la marcatura illustrata al punto (c). Si osservi che dopo la scelta della transizione t „ t 2 rimane abi-

litata e pertanto può scattare. Anche t j rimane abilitata nel caso in cui t 2 scatti per prima. Le transizioni t , e t 2 possono anche scattare in parallelo. In ogni caso, è possibile raggiungere la marcatura illustrata nella Figura 5.21 (d). A questo punto sia t 3 che t , sono abilitate; qualsiasi delle due transizioni può scattare, in maniera non deterministica. Questa volta, però, quando scatta una delle due, l'altra si disabilita. Per esempio, se scatta t 3 , t 4 non può più scattare. Una sequenza di scatti di una PN viene indicata con una stringa di etichette di transizione < t u t 2 , . . . , t n >, secondo la quale t j è la prima transizione, t 2 la transizione che scatta dalla marcatura raggiunta grazie a t x e così via. Prima di addentrarci nell'analisi dei comportamenti delle PN, studieremo l'uso possibile per la descrizione di sistemi concorrenti. In una PN, una transizione rappresenta generalmente un evento o un'azione, e la sua esecuzione il fatto che l'evento capiti o che l'azione venga eseguita. Una transizione viene abilitata se sono soddisfatte le condizioni necessarie perché capiti l'evento o venga eseguita l'azione. La presenza di un gettone in un posto denota l'esistenza di una determinata condizione o stato. Per esempio, un posto può modellare una risorsa, mentre la presenza di uno o più gettoni nel posto rappresenta la disponibilità di una o più istanze di quella risorsa. Osservando la PN illustrata nella Figura 5.21 (a) possiamo notare come sia composta da due parti, una contenente le transizioni t „ t 3 , t 5 e l'altra le transizioni t 2 , t 4 e t 6 . Le due parti possono essere considerate due attività indipendenti che evolvono per effetto degli eventi modellati dalle transizioni. Le due attività però condividono una risorsa comune, modellata dal posto p 3 . Potrebbe trattarsi di due programmi che usano la stessa CPU, due studenti che condividono uno stesso libro, etc. Inizialmente le due attività possono procedere in maniera indipendente e asincrona. Infatti, t [ e t 2 sono entrambe abilitate e l'esecuzione di una delle due non impedisce all'altra di essere eseguita successivamente. In questo caso diciamo che le due transizioni sono concorrenti. Riconducendosi a casi di questo tipo, è possibile modellare la modifica indipendente di due programmi a due terminali diversi o la lettura di appunti da parte di due studenti. Dopo che sono state eseguite entrambe le transizioni6, invece, tutte e due le attività possono continuare la loro esecuzione in mutua esclusione. Ciò viene mostrato al punto (d) della Figura 5.21. La risorsa modellata da p3 è disponibile, ma solo per una delle due attività, la cui scelta è non deterministica. In questo caso, diciamo che le due transizioni sono in conflitto. Si supponga che la risorsa venga data all'attività di sinistra. La rete può procedere attraverso t 3 e t 5 , lasciando l'altra attività temporaneamente bloccata. Lo scatto di t 5 , però, libera nuovamente la risorsa, che diventa così disponibile. A questo punto può scattare t 4 . È anche possibile, però, che scatti ancora una volta t j e che la scelta tra t 3 e t , venga risolta a favore di t 3 . Questa sequenza di eventi, in pratica, può ripetersi all'infinito. Il modello non impone alcuna politica per risolvere conflitti di questo genere. Nella terminologia propria dei sistemi concorrenti, la politica di schedulazione precedente viene detta non fair (non giusta). Un processo che non ha mai la possibilità di accedere a una risorsa

6

Si noti che non necessariamente avviene quanto descritto. Per esempio, dopo lo scatto di t, potrebbe scattare t 3 . Ciò disabiliterebbe t 2 fino allo scatto di t 5 .

p

0

Figura 5.22

R

ó

Rete di Petri che può entrare in uno stato di deadlock.

necessaria si dice soggetto a starvation (letteralmente "destinato a morire di fame"). Una sequenza di scatti contenente solo t M t 3 e t 5 porta l'attività destra della PN a "morire di fame". Si assuma che la marcatura iniziale della PN abbia due gettoni in p 3 invece di uno solo. Ciò significa che sono disponibili due risorse indistinguibili. Di conseguenza, t 3 e t 4 non sono più in conflitto ma concorrenti. Se le due attività dovessero rappresentare processi, e dovessero essere disponibili due CPU, i due processi potrebbero essere eseguiti in parallelo. La Figura 5.22 è una modifica della Figura 5.21 (a) che modella il caso in cui le due attività necessitano di due copie identiche di una risorsa per proseguire. Queste copie sono modellate come due gettoni presenti nel posto R. Dopo che un'attività, ad esempio quella di sinistra, comincia l'esecuzione di t „ può ottenere una qualsiasi delle risorse disponibili eseguendo 1 P u ò tentare successivamente di ottenere anche l'altra risorsa mediante l'esecuzione di 1 3 '. Una volta che l'attività ha ottenuto entrambe le risorse, l'esecuzione può procedere, liberando entrambe le risorse mediante l'esecuzione di t 5 . Si consideri, però, la sequenza di esecuzione < t 1 ( t i , t 2 , t j > . Questa sequenza porta a una marcatura dove non è possibile alcuna ulteriore transizione. Ciascuna delle due attività ha ottenuto una risorsa ma necessita dell'altra per proseguire. Questa è una tipica situazione di deadlock (punto morto), situazione modellabile molto bene con le PN. Formalmente, una PN con una data marcatura è detta in deadlock se e solo se nessuna transizione risulta abilitata in quello stato. Una PN in cui una situazione di deadlock non può mai presentarsi data una marcatura iniziale è detta live (viva).

I deadlock portano aJ "congelamento" del sistema. I progettisti cercano ovviamente di evitare i deadlock ma individuarli è spesso molto difficile. I formalismi di modellazione come le reti di Petri, però, rendono possibile l'analisi del sistema. In questo esempio, siamo stati in grado di derivare la proprietà di deadlock di un sistema manualmente, analizzando la PN che modella il sistema.

Figura 5.24

Rete di Petri soggetta a starvation parziale.

Esercizi 5.12

D i m o s t r a t e che la modifica della P N della Figura 5.22 fornita nella Figura 5 . 2 3 è live. C o m e si p u ò interpretare la modifica introdotta?

5.13

Considerate la P N della Figura 5.24. La P N è c h i a r a m e n t e live. Le attività modellate dalle transizioni t ¡ e t , , però, possono andare in starvation. Infatti, la rete p u ò raggiungere u n a marcatura dalla quale le d u e transizioni n o n p o t r a n n o mai essere abilitate. C o m m e n t a t e brev e m e n t e la differenza tra questo tipo di starvation e quello illustrato p r e c e d e n t e m e n t e .

ESEMPIO 5 . 4

Torniamo al sistema produttore-consumatore modellato mediante una FSM nell'Esempio 5.3. Possiamo usare le PN della Figura 5.25 per descrivere i tre componenti separati del sistema. Graficamente, la composizione dei tre sottosistemi in una PN è illustrata nella Figura 5.26. La figura mostra che gli svantaggi della rappresentazione mediante FSM (Figura 5.19) possono essere ora risolti in maniera soddisfacente. Innanzitutto la complessità della figura non è causata dalla moltiplicazione del numero degli stati dei componenti, ma è semplicemente additiva. Infatti, nella Figura 5.19 il numero dei nodi coincide con il nume-

Consume

Write

- 0

Produce

Read

Read

0



Write

Figura 5.25

Tre reti di Petri separate per la descrizione di un sistema produttore-consumatore.

Figura 5.26

Rete di Petri integrata per la descrizione di un sistema produttore-consumatore.

ro di stati. Nella Figura 5.26 il numero degli stati viene fornito dal numero di possibili marcature. Il lettore è invitato a confrontare una PN che descrive due produttori e tre consumatori usando un buffer a quattro posizioni con la corrispondente rappresentazione mediante FSM. Inoltre, nella Figura 5.26 la concorrenza di attività indipendenti è descritta in maniera corretta. Infatti se il sistema si trova nello stato (ovvero, un gettone è presente in ognuno di quei posti), entrambe le transizioni p r o d u c e e consume sono abilitate. Ovvero, le due transizioni sono concorrenti. Possono essere eseguite in parallelo senza che lo scatto dell'una impedisca lo scatto dell'altra. La sequenza di scatti «produce,

write,

produce,

read,

consume,

write,

read,

consume>

mostra immediatamente quali azioni possono essere eseguite concorrentemente e quali devono essere serializzate in quanto la fine di una è necessaria per l'inizio di un'altra. •

Esercizi 5.14

Fornite esempi di sequenze di esecuzione per la rete della Figura 5.21(a).

5.15

Descrivete, mediante l'uso di PN, alcuni dei sistemi precedentemente presentati con le FSM e confrontate le diverse specifiche.

5.5.4.1

Limitazioni ed estensioni delle reti di Petri

Nonostante le PN modellino piuttosto bene certi aspetti dei sistemi, il loro uso ha rivelato alcune debolezze nell'utilizzo per la specifica di software. Innanzitutto, come le FSM, sono un modello orientato al controllo. I gettoni rappresentano in genere il flusso di controllo dell'esecuzione di diverse azioni. Essi, però, sono anonimi. Per esempio, la presenza di un gettone in un posto può indicare solo la presenza di un messaggio in un buffer, non il suo contenuto. Questa semplicità può tuttavia rivelarsi utile. Spesso (ad esempio quando siamo interessati ad analizzare il flusso di messaggi all'interno di una rete) la questione importante è se un messaggio, prodotto da qualche parte, giunga o meno a destinazione. In questi casi, il contenuto del messaggio può essere considerato irrilevante. Ma non è sempre così. Si supponga, ad esempio, di voler specificare un sistema in cui un messaggio debba essere spedito lungo uno tra due differenti canali: viene scelto c h a n n e l , se il messaggio è ben formato, mentre viene scelto c h a n n e l 2 (il canale di errore) se il messaggio è scorretto. Il messaggio è definito ben formato se contiene un numero pari di 1 (ovvero, se la sua parità è corretta). Nella Figura 5.27 viene illustrata una PN che specifica un sistema di questo tipo. Questa rete, tuttavia, fa sì che quando un messaggio è pronto per essere spedito (ovvero, quando un gettone si trova nel posto P), la scelta tra i due canali sia non deterministica. Pertanto, la figura non è una descrizione adeguata del sistema che abbiamo descritto a parole, visto che la scelta tra i due canali dovrebbe dipendere dal contenuto del messaggio. Nell'esempio, lo scatto di una transizione dovrebbe dipendere dal valore del messaggio, ma siccome il messaggio è rappresentato da un gettone "anonimo", ciò è chiaramente impossibile. Il gettone può infatti indicare solo la presenza di un messaggio. Dovremmo essere in grado di associare ai gettoni i valori dei messaggi, e dovremmo essere in grado di calcolare il valore di ciascun gettone. La stazione ricevente dovrebbe poi modificare il contenuto del messaggio prima di spedirlo (ad esempio, aggiungendo un nuovo indirizzo). Un'altra debolezza delle PN è dovuta al fatto che, in generale, non è possibile specificare una politica di scelta tra diverse transizioni abilitate. Per esempio, tornando alla Figura 5.21 (a),

channel 1

Figura 5.27

channel 2

Porzione di una rete di Petri che descrive la spedizione di messaggi su diversi canali.

le d u e s e q u e n z e di e s e c u z i o n e .

abbiamo visto come sia possibile che la sequenza di esecuzione < t 1 ( 1 3 , 1 5 > venga attivata continuamente, mandando in starvation la sequenza < t 2 , t 4 , t 6 > . Per evitare la starvation potremmo imporre una politica per alternare le due sequenze, modificando leggermente la rete secondo quanto mostrato nella Figura 5.28. Questa rete impedisce l'esecuzione ripetuta di t 3 , senza che venga eseguita prima t„. In generale si può dimostrare matematicamente che le PN non possiedono l'abilità di descrivere una politica del tipo seguente: if

transizione esegui

t è abilitata

then

t;

else

end

e s e g u i la p r i m a una d e t e r m i n a t a i f;

transizione p o l i t i c a di

abilitata secondo ordinamento

La temporizzazione è un altro aspetto critico di alcuni sistemi. Come abbiamo visto nei Capitoli 2 e 4, per alcuni sistemi real-time, non riuscire a calcolare una risposta entro un dato periodo temporale può avere gli stessi effetti negativi di non calcolarla affatto o calcolarla scorrettamente. Inoltre, il risultato di un calcolo può dipendere anche dalla velocità di esecuzione di determinate azioni.

Per esempio, si supponga che una linea esterna invii messaggi a un computer a una determinata velocità. Ogni messaggio ricevuto viene inserito in un buffer e poi elaborato. Un messaggio, se non viene tolto dal buffer prima che arrivi il messaggio successivo, viene perso. I risultati dell'esecuzione possono quindi dipendere dalla velocità di arrivo dei messaggi. Sfortunatamente, la maggior parte dei modelli di sistemi, incluse le PN, non prende esplicitamente in considerazione il tempo. Per esempio, si consideri nuovamente la Figura 5.21 (a). Si assuma che le azioni siano eseguite non appena diventano attive. Si assuma anche che lo scatto di una transizione accada quando terminano le azioni corrispondenti, modellate dalle transizioni. Se le azioni modellate da t i , t3 e t5 necessitano di 1 secondo per essere completate e l'azione modellata da t2 necessita di 5 secondi, la sequenza di transizioni non è ovviamente possibile, contrariamente a quanto suggerito dalla rete. Infatti, si supponga che al momento 0 sia ti che t2 vengano eseguite. Al momento 1 può essere eseguita t3, ma t2 non è ancora finita. Quindi, l'esecuzione di t2 non può avvenire prima dell'esecuzione di t3. Fortunatamente, la flessibilità del modello delle reti di Petri ha consentito la sua estensione in diverse direzioni, mantenendone le caratteristiche originali. Affronteremo qui alcune modifiche piuttosto comuni delle PN, che si sono dimostrate utili in diverse circostanze. Per semplicità, assumeremo il caso di default, in cui il peso di tutti gli elementi della relazione di flusso sia 1. Assegnare valori ai gettoni. I gettoni possono essere modificati per avere un valore di un tipo appropriato: un intero, un array di byte o addirittura un intero ambiente, costituito da diverse variabili con i relativi valori. Anche le transizioni possono essere modificate in modo da essere associate a predicati e funzioni. La regola di scatto di una transizione sarà, di conseguenza, basata sui valori delle variabili, oltre che sulla presenza o meno di gettoni. Una transizione con k posti di input e h posti di output verrà abilitata se sono presenti k gettoni, uno per ogni posto di ingresso, tali per cui il predicato associato nella transizione sia soddisfatto dai valori dei gettoni. L'insieme di questi gettoni viene detto tupla pronta. Si osservi che il predicato è valutato su un gettone per ciascun posto di input. Di conseguenza, potrebbe esistere più di una tupla pronta per una transizione; ovvero, lo stesso gettone potrebbe appartenere a diverse tuple pronte. Una transizione abilitata che scatta comporta alcune conseguenze: •

la cancellazione di tutti i gettoni che appartengono a una tupla pronta dai posti di ingresso (se esiste più di una tupla pronta la scelta è non deterministica);



la valutazione di h nuovi gettoni sulla base dei valori della tupla pronta applicando la funzione associata alla transizione (la funzione avrà un dominio di tuple di dimensione k e un codominio di tuple di dimensione h);



la produzione di un gettone per ciascun posto di output, il cui valore è calcolato dalla funzione associata alla transizione in questione.

Per esempio, si consideri la PN nella Figura 5.29, in cui si assume che i gettoni abbiano valori interi. La notazione è autoesplicativa: il nome di un posto in un predicato o in una funzione rappresenta un gettone in quel posto. Le transizioni t , e t ; sono abilitate. La transizione ti ha due tuple pronte, e < 3 , 4 > , visto che entrambe le tuple soddisfano il

Figura 5.29

Rete di Petri i cui gettoni portano valori. Il predicato p 2 > P ! e la funzione p , : = p 2 + P i sono associati alla transizione t , ; il predicato p 3 = p 2 e le funzioni p 4 : =p 3 — p 2 e p 5 : = p 2 + p 3 sono associati alla transizione t 2

predicato P2>Pi- La transizione t2 ha una tupla pronta, , la quale soddisfa il predicato P 3 =P 4 . Il gettone con valore 1 in p2 non appartiene ad alcuna tupla pronta. Lo scatto di t! mediante l'uso della tupla produrrebbe un gettone con valore 7 in p, e disabiliterebbe t2, visto che i gettoni 3 e 4 scomparirebbero da Pj e P2. Invece, lo scatto mediante l'uso di produrrebbe un valore 10 in P4, lasciando t2 ancora abilitate. Nel caso scattasse successivamente t2, verrebbe prodotto il valore 0 in P4 e 8 in p5. Questo primo arricchimento del modello PN fornisce una soluzione semplice al problema illustrato nella Figura 5.27. Infatti, è sufficiente considerare i gettoni come portatori di un valore del tipo "messaggio", ovvero una sequenza di bit. Il predicato "p ha un numero pari (risp. dispari) di l " può essere associato alla transizione c h a n n e l t (risp. c h a n n e l 2 ) . Inoltre, il fatto che un messaggio debba essere modificato prima di essere spedito su uno dei canali può essere indicato mediante l'aggiunta alle transizioni di apposite funzioni. Esercizio 5.16

Usando l'estensione alle P N illustrata in questo paragrafo, descrivete un m o d u l o addetto alla spedizione di messaggi. Il m o d u l o riceve messaggi su due canali diversi e controlla la parità di ciascun messaggio. Se la parità è scorretta, spedisce un "nack" (negative acknowledgment, riconoscimento errato) su un canale di risposta (esiste u n canale di risposta per ogni canale in ingresso); se la parità è corretta sposta il messaggio ricevuto in un buffer. Il buffer può contenere fino a 10 messaggi. Q u a n d o il buffer è pieno, il m o d u l o spedisce tutti i contenuti del buffer a un'unità di calcolo su un altro canale. N o n si possono inserire messaggi in un buffer pieno.

Specifica di politiche di schedulazione. Quando il non determinismo delle PN non è adeguato, è necessario affrontare il problema della specifica di una politica per la selezione di una transizione da eseguire tra tutte quelle abilitate in quell'istante. Un modo semplice per raggiungere questo scopo è quello di associare priorità alle transizioni. Formalmente, ciò può essere specificato mediante una funzione p r i che associa un numero naturale a ogni transizione: pri : T

—> N

È poi possibile modificare la regola di scatto di una transizione nel seguente modo: se diverse transizioni sono abilitate, possono scattare solo quelle con priorità massima. Secondo questa definizione, le priorità sono statiche. Se però i gettoni hanno un valore, potremmo pensare di definire priorità dinamiche, i cui valori dipendano dai valori dei gettoni dei posti di input delle transizioni. Esercizio 5.17

Aggiungete priorità alla P N costruita per la risoluzione dell'Esercizio 5.16. Se il m o d u l o per la spedizione dei messaggi è in una condizione per cui p u ò (a) ricevere un messaggio da un canale di input, (b) spedire una risposta "nack" o p p u r e (c) spedire i contenuti del messaggio al buffer, allora deve ordinare le priorità nel seguente m o d o : prima ricevere il messaggio, poi spedire il "nack" e infine spedire i contenuti del messaggio al buffer.

Reti di Petri temporizzate. Quando si tenta di introdurre il tempo nei modelli computazionali formali, sorgono problemi teorici delicati, che vanno al di là degli scopi di questo libro. Qui ci limiteremo a illustrare il modo più semplice e naturale di introdurre il tempo nelle PN. Le PN temporizzate sono PN in cui due costanti < t m i n , t m a x > vengono associate a ogni transizione. In modelli più sofisticati è possibile definire i limiti temporali in modo che siano calcolati come funzioni dei valori dei gettoni nei posti di input. Una marcatura iniziale viene fornita all'istante t = 0. Una volta che una transizione è abilitata, deve aspettare almeno t m i n prima di poter scattare. Inoltre deve scattare entro t,„ ax , a meno che non venga disabilitata dall'esecuzione di un'altra transizione. Una PN normale può essere rappresentata da una PN temporizzata dove, per ogni transizione, t m i n = 0 e t m a x = Le estensioni di natura temporale possono essere applicate in combinazione anche con altre estensioni. Se applichiamo sia le estensioni temporali che l'aggiunta di priorità alle transizioni, occorre porre molta attenzione nella determinazione di quale transizione possa o debba scattare in un dato momento. Una regola naturale è che se possono scattare diverse transizioni, data la disposizione di gettoni e considerato l'intervallo [ t m i n , t m a x ] , solo quelle con priorità massima possono effettivamente scattare, entro un periodo di tempo minore o uguale al proprio t m a x . Per esempio, si consideri la rete nella Figura 5.30, con la sua marcatura iniziale al tempo zero. Potrebbe accadere che t [ scatti entro 2 unità di tempo. Se non dovesse scattare in quell'intervallo, non potrà più scattare, visto che a t = 2, anche t 2 può scattare e ha una priorità più alta di t ^ Se al momento t = 1 viene prodotto un gettone in p«, allora nell'intervallo l < t < 2, sia t 3 che t j potranno scattare, ma t^ non potrà scattare prima di t 3 visto che ha una priorità più bassa. Torniamo al problema di attribuire un preciso significato alla specifica informale: Il messaggio deve essere triplicato. Le tre copie devono essere spedite su tre canali diversi. Il ricevitore accetta il messaggio in base a una politica di tipo due su tre

che abbiamo trattato nel Paragrafo 5.2. Le PN temporizzate, con gettoni portatori di valori riguardanti i messaggi, possono fornire in maniera facile una descrizione precisa delle possibili interpretazioni di questa specifica informale.

priority = 1 Figura 5.30

priority = 3

priority = 2

Rete di Petri temporizzata.

Original message

Message

triplication

Message copies

Message copies transmission

vot ing3

for all three transitions

Forwarded

Figura 5.31

Message

Possibile formalizzazione per la replicazione e selezione di messaggi mediante l'uso di reti di Petri modificate. Il predicato PC, = PC 2 è associato a t v o t i n 9 l . Il predicato PC, = PC 3 è associato a t v o t i n g 2 . Il predicato PC 2 = PC 3 è associato a tvotinga- H predicato true è associato a tutte le altre transizioni. La funzione d'identità è associata a tutte le transizioni, c ^ k j c ^ k ; ) sono i limiti inferiori (superiori) della durata dell'operazione Message triplication (Message copies

t r a n s m i s s ion).

Figura 5.32

Formalizzazione alternativa della replicazione e selezione di messaggi. Il predicato PC 3 = PC 2 or PC 2 = PC 3 or PC, = PC 3 è associato con t v o t i n g l . La funzione " i f PC, = PC 2 theo PC, elsif PC 2 = PC 3 then PC 2 elsif PC! = PC 3 then PC ; else " E R R 0 R " endif" è associata a t v o t i n g . Il predicato true è associato a tutte le altre transizioni così come la funzione d'identità.

La prima interpretazione suggerita nel Paragrafo 5.2 è quella secondo la quale il messaggio debba essere considerato ricevuto non appena sono state ricevute due copie identiche. Questa interpretazione può essere formalizzata dalla PN illustrata nella Figura 5.31. Con questa formulazione, non appena due gettoni dai valori identici sono presenti in Pj, P2 o P3, scatta la transizione corrispondente. Un'interpretazione diversa dei requisiti informali, basata sulla decisione di aspettare fino a quando tutte e tre le copie sono state ricevute prima di effettuare il confronto, è formalizzata dalla rete della Figura 5.32. Questo esempio dimostra che l'uso di un modello formale ci permette di fornire un significato preciso alla specifica del sistema. Inoltre, il modello formale può rappresentare la base per un'analisi rigorosa. Per esempio, se siamo interessati a determinare il tempo massi-

mo necessario a rispedire un messaggio ricevuto, possiamo notare che risulta le, + k 2 in entrambi i casi. Se assumiamo, però, che la trasmissione delle copie attraverso i tre canali necessiti di un tempo compreso tra c 2 e k 2 , possiamo notare che la probabilità di ricevere un messaggio nel posto F o r w a r d e d M e s s a g e entro l'istante t (con + c 2 £ t s ki + k 2 ) è più alta nel caso illustrato nella Figura 5.31 rispetto al caso della Figura 5.32. Si assuma ora che ogni canale di transizione possa avere un malfunzionamento. Questo caso può essere modellato aggiungendo altre tre trasmissione, ognuna connessa ai posti in input "message copies", il cui scatto distruggerebbe i gettoni che rappresentano i messaggi. Il modello della Figura 5.31 avrebbe una probabilità inferiore di malfunzionamento globale (il mancato inoltro del messaggio al posto F o r w a r d e d M e s s a g e ) rispetto al modello della Figura 5.32. Siccome la triplicazione di un messaggio viene fatta solo per rendere la PN più tollerante ai guasti, è chiaro che un'analisi della differenza tra le due interpretazioni della specifica informale è utile. L'analisi precedente poteva essere resa più precisa arricchendo ulteriormente il modello PN con caratteristiche stocastiche, come la distribuzione probabilistica di tempi di esecuzione o la distribuzione probabilistica dell'esecuzione di transizioni attive. Il lettore interessato potrà trovare modelli di questo genere nella letteratura suggerita nelle note bibliografiche. Esercizi 5.18 Fornite una formalizzazione in termini di PN estese della terza interpretazione vista per il Paragrafo 5.2, in cui il ricevitore controlla periodicamente i tre canali. Se vengono ricevute tre copie in un dato arco di tempo, vengono confrontate tutte e tre. Se ne vengono ricevute solo due, e sono identiche, il messaggio è accettato. 5.19 La formalizzazione della Figura 5.31 ha un piccolo difetto che può diventare rilevante se la PN diventa parte di un sistema ciclico. Si individui e si elimini l'errore (ad esempio, modificando la PN in modo tale che si comporti correttamente anche se ripetuta ciclicamente). 5.5.4.2

Un esempio di uso delle reti di Petri

Applicheremo ora il modello delle reti di Petri, e alcune delle sue variazioni, al caso della descrizione di un sistema realistico e complesso, come quello di un ascensore. Si consideri la seguente specifica informale, proposta in letteratura come banco di prova dell'applicabilità di tecniche di specifica. U n sistema composto da n ascensori deve essere installato in un palazzo a m piani. I costruttori forniscono gli ascensori e i meccanismi di controllo. Il problema concerne la logica necessaria per spostare gli ascensori tra i piani secondo le seguenti regole: 1. O g n i ascensore possiede un insieme di pulsanti, uno per piano. Q u a n d o premuti, i pulsanti si illuminano e causano lo spostamento dell'ascensore al corrispondente piano. Le luci si spengono q u a n d o il piano desiderato è raggiunto. 2. Ciascun piano, a parte il p r i m o e l'ultimo, possiede due tasti, u n o per richiedere un ascensore che sale e u n altro per richiedere u n ascensore che scende. Anche questi tasti si illuminano alla pressione. Le luci si spengono q u a n d o arriva un ascensore che sta viaggiando nella direzione desiderata o p p u r e che è libero. Nel secondo caso, se entrambi i tasti di richiesta

sono stati premuti viene annullata una sola delle richieste. L'algoritmo per decidere quale delle richieste soddisfare per prima mira a minimizzare i tempi di attesa di entrambe le richieste. 3. Q u a n d o u n ascensore n o n è richiesto da nessuno rimane fermo al piano che rappresenta l'ultima destinazione richiesta, con le porte chiuse e in attesa di ulteriori richieste. 4. Tutte le richieste di ascensori provenienti dai piani devono essere soddisfatte, prima o poi, e tutti i piani h a n n o la stessa priorità. 5. Tutte le richieste interne agli ascensori diretti ai piani devono essere soddisfatte, prima o poi, servendo i piani sequenzialmente nell'ordine di percorrenza dell'ascensore. 6. O g n i ascensore possiede un tasto di emergenza che, alla pressione, causa l'invio al supervisore di un segnale di allarme. L'ascensore viene successivamente segnalato come "fuori servizio". O g n i ascensore possiede u n meccanismo per cancellare lo stato di "fuori servizio".

Prima di tradurre queste specifiche in un modello formale, esaminiamole attentamente. Nonostante questo sistema sia di uso comune, vale la pena di ponderare le specifiche con una certa attenzione. Potrebbe anche essere interessante per il lettore posporre la lettura dei commenti che seguono a un'analisi e a un eventuale formalizzazione personale delle specifiche medesime. Focalizziamo la nostra attenzione sul punto 2. La descrizione informale prescrive che ciascun piano, eccetto il primo e l'ultimo, possegga due pulsanti. Di per sé non esiste alcuna implicazione secondo la quale il primo piano non possa possedere nove pulsanti e l'ultimo quattro. Questa osservazione può però essere vista come eccessivamente critica: esiste un'interpretazione "ovviamente corretta", ovvero che il primo piano possegga un solo pulsante per richiedere un ascensore per salire e che l'ultimo ne abbia uno solo per richiedere un ascensore in discesa. Possiamo scegliere questa interpretazione in quanto fa parte della nostra conoscenza degli ascensori e possiamo integrare questa conoscenza nel nostro progetto del sistema, specificando questi requisiti in maniera esplicita. Infatti, possiamo anche usare questo caso come esempio a favore dell'informalità delle specifiche: una descrizione formale richiederebbe una specifica pienamente dettagliata anche nell'eventualità di aspetti perfettamente owii, causando così uno spreco di energie. In generale, il formalismo è uno strumento per essere precisi quando serve. La precisione assoluta può essere inutile e addirittura noiosa se il lettore finale è una persona e non una macchina. È responsabilità di chi scrive le specifiche scegliere un livello di formalità adeguato. A volte, la semplicità, l'immediatezza e la generalità del linguaggio naturale possono essere preferibili al rigore semantico di un formalismo matematico. In altri casi, invece, una notazione semiformale, magari grafica, può dare un'idea veloce e sufficientemente chiara del sistema desiderato. In altri ancora, soprattutto se il sistema è complesso o critico, può valer la pena compiere lo sforzo di una formalizzazione completa. In generale, la formalità è richiesta quando non possiamo permetterci di essere fraintesi. Affronteremo ora un'analisi più approfondita del punto 2. La regola afferma che: le luci si spengono q u a n d o arriva u n ascensore che sta viaggiando nella direzione desiderata oppure che è libero.

Questa frase può essere interpretata in almeno due modi. Si consideri un ascensore che sta salendo, il caso di un ascensore che scende è simmetrico. La regola potrebbe essere interpretata in qualsiasi delle seguenti due maniere:



La luce viene spenta non appena un ascensore che arriva dal basso giunge al piano dove è stata effettuata la richiesta (questa interpretazione ammette un'eccezione per il primo piano).



La luce viene spenta dopo che l'ascensore giunge al piano e comincia la sua salita verso i piani superiori (questa interpretazione ammette un'eccezione per l'ultimo piano).

Esaminando diversi ascensori reali possiamo vedere come entrambe le interpretazioni siano state implementate in passato. Come abbiamo visto prima, si potrebbe discutere che l'ambiguità sia stata lasciata appositamente in modo da consentire all'implementatore la possibilità di scegliere la soluzione migliore senza imporre ulteriori vincoli. Questa affermazione può essere accettabile nel caso in cui una soluzione o l'altra non faccia differenza. In generale, comunque, l'ambiguità non viene colta fino a quando non viene costruita una versione formale della specifica. Si noti, infine, l'imprecisione presente nel requisito: l'algoritmo per decidere quale delle richieste soddisfare per prima mira a minimizzare i tempi di attesa di entrambe le richieste.

Cosa significa "minimizzare i tempi di attesa di entrambe le richieste"? Queste sono due interpretazioni possibili: •

Non deve essere possibile servire alcuna delle due richieste in un tempo minore. Questa interpretazione potrebbe non essere praticabile: minimizzare il tempo di attesa per una richiesta potrebbe richiedere un tempo di attesa più lungo per l'altra;



la somma dei due tempi di attesa dovrebbe essere minimizzata. Ma perché la somma?

Ancora peggio, il tempo di attesa previsto al momento di effettuare la richiesta potrebbe essere alterato da una richiesta fatta durante il servizio. Per esempio, si immagini che l'ascensore viaggi dal secondo piano per rispondere a una richiesta fatta al sessantesimo piano. Mentre sale, l'ascensore si ferma al quarantesimo piano per rispondere a una richiesta. Questa chiamata potrebbe non essere stata presa in considerazione per la "minimizzazione" del tempo di attesa al momento della scelta dell'ascensore per rispondere alla richiesta originale. Esercizio 5.20

Proseguite l'analisi delle specifiche precedenti, cercando di scoprirne i punti ambigui.

Vediamo ora di fornire una specifica del sistema di ascensori mediante l'uso di reti di Petri. La Figura 5.33 fornisce una bozza iniziale del sistema. Questa descrizione intuitiva è utile in quanto fornisce un'illustrazione grafica della posizione dell'ascensore e degli eventi che determinano il movimento degli ascensori. Sottolinea il fatto che, perché l'ascensore possa spostarsi da un piano a quello adiacente, un pulsante debba essere illuminato e che lo spostamento, a sua volta, risulta essere la conseguenza della pressione del tasto in questione.

A

o

Figura 5.33

Pressione del pulsante interno corrispondenic «il piano j + 1

Prima bozza della descrizione mediante reti di Petri dell'alternarsi dello stato di un pulsante dell'ascensore.

La descrizione della Figura 5.33, tuttavia, è ben lontana dall'essere soddisfacente. Ecco alcuni dei suoi difetti: 1. La descrizione è terribilmente incompleta: molte altre questioni devono essere prese in considerazione. Ci sono tasti interni, ma anche esterni. Lo spostamento dell'ascensore può essere causato dalla pressione di uno qualsiasi dei pulsanti: anche un ascensore fermo al primo piano può spostarsi quando, al quarantesimo piano, viene premuto il tasto per richiedere un ascensore per scendere. La rete inoltre non spiega come vengono spente le luci che illuminano i pulsanti. Inoltre, cosa succede se viene premuto il tasto esterno di richiesta di salita al piano 20 nel momento preciso in cui un ascensore sta passando a quel piano sul suo tragitto dal piano 4 al piano 27? Qual è l'ultimo istante accettabile di chiamata in questi casi? 2. La descrizione mostra immediatamente che la formalizzazione completa del sistema sarà enorme e difficilmente gestibile. Si pensi ad un sistema composto da 100 piani e da sette ascensori! 3. La descrizione è ovviamente errata in molti dei suoi dettagli. Per esempio, la figura suggerisce che un pulsante si illumina quando viene premuto; ciò viene modellato dalla presenza di un gettone nel posto "illuminazione del pulsante". Se il pulsante viene premuto due volte avremo due gettoni in quel posto. Quando la richiesta sarà soddisfatta, verrà consumato solo uno dei gettoni; l'altro gettone rimarrà, indicando erroneamente che il pulsante è ancora illuminato. Esercizio 5.21

Individuate ulteriori inadeguatezze e punti problematici della formalizzazione iniziale.

Nonostante le sue manchevolezze, la Figura 5.33 può essere assunta come punto di partenza per ottenere una specifica completa e corretta del sistema mediante l'uso di reti di Petri. Innanzitutto, affrontiamo il problema della gestione della complessità del sistema. Ricordiamo che la specifica è un'attività di progettazione e che un documento di specifica completa è, in generale, il risultato di molti tentativi e correzioni. Non è un documento da elaborare da zero senza mai modificarlo. Dobbiamo, quindi, applicare anche alla specifica tutti i principi di progettazione visti nei Capitoli 3 e 4. In particolare, è molto utile in questo caso definire moduli di specifica, intesi come componenti della PN finale. Ciascun modulo descrive un componente del sistema e la descrizione completa è il risultato dell'integrazione di tutti i moduli. In questo caso, risulta naturale usare diversi moduli per rappresentare, da una parte, le posizioni degli ascensori e, dall'altra, gli stati dei pulsanti interni ed esterni agli ascensori. Inoltre possiamo osservare che, con l'eccezione del primo e dell'ultimo piano, la descrizione di cosa succede al piano j è identica alla descrizione di cosa succede al piano k. Visto che lo stesso vale sia per gli ascensori che per i pulsanti, possiamo pensare di utilizzare una specifica parametrizzata che faccia riferimento al piano generico j , all'ascensore generico m, al pulsante generico h, etc. Otterremo così la seguente struttura della specifica. Descrizione del sistema. La specifica completa è scomposta in moduli. Ci sono n moduli di specifica del tipo E L E V A T O R (ascensore) e M moduli di specifica del tipo F L O O R (piano). Ogni modulo è descritto da una PN estesa, con interconnessioni appropriate. Ogni modulo di tipo E L E V A T O R è scomposto in due sottomoduli, uno di tipo E L E VATOR_POSITION, che rappresenta la posizione di un ascensore, e l'altro di tipo ELEVATOR_BUTTONS, che rappresenta lo stato dei pulsanti interni all'ascensore. Più precisamente, il secondo può essere scomposto in m moduli di tipo B U T T O N , ciascuno dei quali rappresenta uno degli m pulsanti interni a ogni ascensore. Ciascun modulo del tipo FLOOR, a sua volta, è scomposto in due moduli di tipo B U T TON, i quali rappresentano i pulsanti per richiedere un ascensore per salire o per scendere. I moduli che rappresentano il primo e l'ultimo piano sono un'eccezione, visto che sono composti da un solo modulo di tipo B U T T O N , il quale permette l'invio di una richiesta per salire o scendere, rispettivamente. Affronteremo ora le problematiche di descrizione delle diverse parti, una alla volta. Cominceremo con le regole per l'illuminazione dei pulsanti, facendo uso di PN temporizzate con priorità. Descrizione dei pulsanti. I moduli di tipo B U T T O N possono essere descritti come nella Figura 5.34. La pressione di un pulsante viene rappresentata dall'esecuzione della transizione Push, sempre attiva per rappresentare che un pulsante può essere premuto in qualsiasi momento. Se il pulsante è spento (ovvero, un gettone è presente in Off ) e viene eseguita Push, allora viene immediatamente eseguita anche Set (ovvero, t„, in (Set) = t n a x ( Set ) = 0) e il pulsante viene acceso (ovvero, un gettone viene inserito nel posto On). Per impedire l'inutile accumularsi di gettoni in P si può impostare t m i n ( P u s h ) =

Figura 5.34

Accensione dei pulsanti.

0,1 e t m i n ( c ) = t m a x ( c ) = 0 , 0005 (la transizione c funge da consumatore di gettoni) 7 . In questo modo, un pulsante acceso può essere premuto molte volte (con un tempo minimo di ritardo pari a 0 , 1 ) senza provocare conseguenze rilevanti. L'esecuzione della transizione Reset rappresenta la reinizializzazione del pulsante. Più avanti verrà illustrato come altri moduli reinizializzano i pulsanti. Ciò significa che altre frecce, qui non indicate, verranno connesse a Reset per altri moduli di specifica. Esercizio 5.22

Fornite una specifica alternativa per l'illuminazione di un pulsante usando le P N estese con il concetto di priorità, invece che con il concetto di tempo. Discutete le differenze tra le due rappresentazioni.

Descrizione della posizione di un ascensore e del suo spostamento. In prima approssimazione, ogni modulo del tipo E L E V A T O R _ P O S I T I O N può essere rappresentato come nella Figura 5.35. Intuitivamente, la figura descrive come un ascensore può spostarsi da un piano all'altro in entrambe le direzioni. Un gettone nel posto F i , 1 < i < m, rappresenta un ascensore fermo al piano i. Un gettone nel posto DF ^ ( UFA ) , 2 < i < m-1, rappresenta un ascensore che sta passando per il piano i , lungo il suo tragitto di discesa (salita). Si dovrebbero associare tempi adeguati alle transizioni in modo da prendere in considerazione le velocità degli ascensori. Insistiamo sull'importanza di descrivere questioni complesse in maniera incrementale. La Figura 5.35 fornisce un'idea di spostamento degli ascensori, distinguendo tra ascensori

7

II tempo viene indicato di default in secondi. Si noti che un qualsiasi valore arbitrario inferiore a 0,1 può sostituire 0,005.

Figura 5.35

Prima descrizione dello spostamento dell'ascensore.

fermi e ascensori in movimento. In una descrizione più dettagliata, le condizioni che spingono un ascensore a spostarsi possono essere espresse da un frammento di rete come quello illustrato nella Figura 5.36, il quale fa riferimento a un ascensore fermo al piano Fi. (Le condizioni che determinano il movimento dell'ascensore verso il basso possono essere modellate alla stessa maniera). Sia h un numero intero maggiore di j + 1 e minore o uguale a m. Un ascensore fermo al piano j può spostarsi verso l'alto se viene fatta una richiesta interna per andare al piano j + 1 o un qualsiasi piano h o se viene fatta una richiesta esterna di servizio al piano j +1 o a un qualsiasi piano h. Tali richieste sono modellate dalla presenza di un gettone nel posto On nelle reti di tipo BUTTON che rappresentano pulsanti interni ed esterni. Nella figura, I L B j + 1 e l L B h sono reti di tipo BUTTON che rappresentano i pulsanti interni per le fermate ai piani j + 1 e h, rispettivamente. UP j + 1 , DOWNJ + 1 , UP h e DOWNh rappresentano pulsanti per chiamate esterne dai piani j + 1 e h , per salire o scendere, come indicato dal loro nome8.

Il primo e l'ultimo piano sono un'eccezione, visto che hanno rispettivamente solo i pulsanti UP, e DOWN„.

Figura 5.36

Descrizione più precisa dello spostamento dell'ascensore.

La specifica della Figura 5.36 usa due posti intermedi, F • e F tra F.j e la coppia Fj+1 L'esecuzione delle transizioni comprese tra t x e 1 6 rappresenta la risposta dell'ascensore a una richiesta di salita; di conseguenza, un gettone viene collocato nel posto F •. L'esecuzione della transizione t tra F J e F " rappresenta il tempo necessario per spostarsi dal piano j al piano j + l ; infatti, definiamo t m i n ( t ) =t m a x ( t ) =AT il tempo necessario per spostarsi da un piano a quello adiacente. Per semplificare le cose ignoriamo il fatto che tale tempo non sia costante a causa dell'accelerazione. Per ogni altra transizione presente nella Figura 5.36 impostiamo t m l n =t m a i I =0. Assumiamo anche che il tempo necessario per prendere la decisione se fermarsi o meno a un determinato piano possa essere ignorato. L'assunzione è un'astrazione della situazione reale, in cui il meccanismo che governa il sistema (ad esempio, un microprocessore) possiede tempi di reazione trascurabili rispetto ai tempi richiesti dal sistema meccanico. Le transizioni comprese tra t 7 e t 1 2 rappresentano la scelta (non deterministica) tra le richieste di servizio. In questo modo, modelliamo il fatto che l'ascensore possa servire richieste in arrivo dal piano j +1, a patto che accadano durante il tempo previsto per salire dal piano j al piano j + l . La descrizione di un ascensore in transito dal piano j + l (rappresentato da un gettone presente nel posto UFj + 1) può essere simile (ma non identica) a quella appena fornita. Si osservi che un gettone appare nel posto UFj + 1 solo se ci sono richieste di servizio eUF j t l .

non soddisfatte (interne o esterne) provenienti da un piano j + 1 che si trova più in alto del piano. Nella nostra descrizione, fino ad ora, non abbiamo fatto assunzioni riguardo alla politica decisionale per la scelta di quale richiesta servire, nel caso in cui ve ne siano più di una in attesa. Il modello è pertanto non deterministico. Per esempio, il modello non richiede che l'ascensore si fermi al piano j + 1 se una richiesta interna viene fatta prima di passare da quel piano. La nostra scelta è quella di concentrare tutte le politiche decisionali in un modulo S C H E D U L E R che discuteremo tra breve. Prima di procedere in questo senso finiremo di inquadrare gli aspetti più salienti del sistema di ascensori. Spegnimento dei pulsanti. La Figura 5.37 modella lo spegnimento di un pulsante interno iLBj nel momento in cui l'ascensore giunge al piano j . Abbiamo disegnato una scatola tratteggiata intorno ai componenti di I L B j in modo da evidenziarne i confini. La transizione R e s e t ha t m i „=t m a x =0 e la priorità più alta; abbiamo, dunque, la certezza che la luce venga spenta non appena l'ascensore giunge al piano corrispondente. Ancora una volta, si noti come la specifica sia fatta di parti che si aggregano (incrementalmente o in maniera modulare). Nella figura, infatti, facciamo riferimento al posto Fj senza ripetere tutte le connessioni che vi giungono (e che abbiamo già visto). La Figura 5.38 descrive come i pulsanti UPj (1 < j < m-1) vengono spenti. La transizione 11 è la duplicazione di una qualsiasi transizione t i (1 < i < 6) della Figura 5.36 (sono trattate tutte allo stesso modo). La transizioni t 1 hanno anch'esse t„,in=tmax=0, ma hanno priorità più alta rispetto a ti; in questo modo viene scelto lo scatto di t [ rispetto a ti; se il pulsante deve essere spento. In altre parole entrambe le transizioni tA e t [ modellano la "decisione" dell'ascensore di salire. In più, però, la seconda transizione modella lo spegnimento del pulsante. La transizione Reset invece viene eseguita per spegnere un tasto quando non ci sono richieste pendenti. Definiamo t m i n ( R e s e t ) =tmaiI ( R e s e t ) =dp, dove dp è il tempo necessario per modellare una persona che entra in un ascensore e preme un pulsante. I tasti DOWNi (2 < i < m) sono modellati allo stesso modo. Di con-

Figura 5.37

Spegnimento dei pulsanti interni. Il riquadro tratteggiato indica i confini del m o d u l o I L B J .

Figura 5.38

Spegnimento dei pulsanti esterni del piano.

seguenza, entrambi i tasti esterni vengono spenti se nessuna richiesta interna viene fatta nel tempo previsto. Si osservi che in questo caso stiamo alterando leggermente le specifiche informali. Si osservi, però, che la Figura 5.38 toglie ogni ambiguità dall'affermazione informale riguardante lo spegnimento delle luci dei pulsanti, scegliendo la seconda delle interpretazioni ipotizzate quando abbiamo analizzato le carenze dei requisiti informali. Esercizi 5.23

Formalizzate la prima delle due interpretazioni della regola per lo spegnimento della luce del pulsante, discusse nella nostra analisi delle specifiche informali.

5.24

Formalizzate la regola originale espressa nelle specifiche informali, ovvero "nel secondo caso, se entrambi i tasti di richiesta sono stati premuti viene annullata una sola delle richieste", invece della scelta attuale che spegne le luci di entrambi i pulsanti.

Politiche decisionali. Il modello fino ad ora descritto è altamente non deterministico. In molti casi, il non determinismo può essere una proprietà importante della specifica, per esempio, quando si sceglie di specificare un insieme di comportamenti accettabili senza restringere il modello a un comportamento specifico, lasciando che questo venga scelto successivamente, in base a considerazioni di diversa natura. A volte, però, il non determinismo può causare comportamenti indesiderati, che vorremmo poter escludere al momento di effettuare la specifica. Nel nostro esempio, fino ad ora, abbiamo affrontato solo la specifica dei meccanismi che governano gli spostamenti degli ascensori, senza considerare le politiche coinvolte. Queste politiche devono, comunque, essere specificate se vogliamo fornire le prove che tutte le richieste, prima o poi, verranno soddisfatte dagli ascensori. Si veda il requisito informale 4. Abbiamo deciso di incapsulare tutte le politiche decisionali in un unico modulo chiamato SCHEDULER. Si tratta di un'applicazione dei principi di information hiding

alle specifiche: incapsulare le politiche decisionali ci consente di cambiarle senza causare ripercussioni sui meccanismi descritti dal resto della rete; per esempio, saremmo in grado di raffinare le prestazioni del sistema a livello di specifica dei requisiti simulando diverse politiche. Tratteggeremo qui solo una specifica molto semplice del modulo SCHEDULER. La politica scelta differisce dai requisiti ambigui delle specifiche informali: garantisce correttezza nei confronti delle richieste di servizio; ovvero, garantisce che ogni richiesta sarà servita prima o poi (ma non prova a "minimizzare i tempi di attesa", secondo una qualunque interpretazione). Nessuna richiesta soffrirà mai di starvation. Assegniamo a ciascun ascensore un "direction state" (stato di direzione), che può essere o U (up) o D {down). (Figura 5.39.) Un gettone in u ( D ) significa che la direzione di spostamento dell'ascensore è verso l'alto (verso il basso). La politica scelta consiste nel tenere la direzione di spostamento il più a lungo possibile uguale, fino a quando non vengono soddisfatte tutte le richieste pendenti di spostamento in quella direzione. Altrimenti, si eseguono le transizioni U_D e D_u per invertire la direzione di spostamento. uk denota una qualsiasi transizione delle rete che indica uno spostamento verso l'alto (ad esempio, t , , . . . , t 1 2 ); mentre, DK denota una qualsiasi transizione della rete che indica uno spostamento verso il basso. Si ricordi che queste transizioni hanno t m i n =t m a x =0 ; ovvero, vengono eseguite non appena sono rese attive. Le transizioni U_D e D_U, invece, rappresentano operazioni che hanno un tempo di esecuzione non nullo; ad esempio, hanno t m i n =t m a x =x msec, con x diverso da zero. Questa durata rappresenta il tempo necessario allo S C H E D U L E R per controllare se esistono richieste pendenti di salita o discesa. Inoltre, nella porzione di rete illustrata nella Figura 5.36, e in reti simili, viene data priorità più alta alle transizioni t 7 , t 8 e t 9 rispetto a t j o . t n e t i j . I n questo modo, un ascensore è costretto a fermarsi a ogni piano per cui c'è una richiesta di servizio interna o esterna. Come si può notare, un ascensore continua a spostarsi verso l'alto fino a quando non ci sono richieste pendenti di salita. Se non vi sono più richieste di servizio in quella direzione, dopo un certo periodo di tempo l'ascensore si porta in uno stato di discesa, se vi sono richieste pendenti di discesa. Se non ci sono richieste di questo tipo, l'ascensore continua a controllare fino a quando non diventano disponibili. Un modo più generale per modellare le politiche di schedulazione consiste nell'introdurre un posto, S C H E D U L E R , che contiene un gettone particolare che conosce lo stato globale del sistema. Si possono poi predisporre predicati adeguati da associare alle transizioni in modo che vengano rese attive le transizioni che possono essere eseguite secondo la politica di schedulazione. Ne viene fornito un esempio nella Figura 5.40. Una volta terminata la formalizzazione del sistema di ascensori in termini di PN, possiamo analizzarlo per verificare se definisce il comportamento desiderato in maniera correr-

SCHEDULER

Figura 5.40

Modo più generale per la rappresentazione di politiche di schedulazione. Ogni transizione ha un predicato del tipo 0K ( S c h e d u l e r ) (insieme ad altre possibili condizioni). Il gettone in SCHEDULER registra tutte le informazioni circa lo stato del sistema utili per la selezione di quale transizione eseguire. Il gettone è "permanente", in quanto viene sempre riprodotto dopo l'esecuzione di una qualsiasi delle transizioni e dopo il suo aggiornamento.

ta. Come anticipato nel Paragrafo 5.4, un modo per verificare l'adeguatezza di una specifica è quello di simularla. In questo caso, la simulazione delle PN risulta piuttosto naturale: è sufficiente applicare le regole di transizione del modello, partendo da uno stato iniziale e osservandone il comportamento. Per esempio, potremmo considerare una marcatura iniziale secondo la quale un ascensore si trova al primo piano (ovvero, un gettone è presente nel posto F J e tutti i pulsanti interni ed esterni sono spenti. Assumiamo ora che qualcuno entri nell'ascensore e prema il pulsante 2. Ciò corrisponde all'esecuzione della transizione P u s h nella porzione della rete che descrive il pulsante (Figura 5.34). Poi viene eseguita immediatamente la transizione S e t , che corrisponde all'accensione della luce del pulsante. Viene, quindi, resa attiva la transizione t 4 , presente nella rete del tipo rappresentato nella Figura 5.36, la quale viene eseguita immediatamente. Dopo un determinato tempo At sarà presente un gettone nel posto F", abilitando t 7 . Questa transizione verrà eseguita immediatamente. Subito dopo, viene eseguita la transizione Reset per il pulsante interno all'ascensore corrispondente al secondo piano (Figura 5.37). Questa simulazione ci permette di concludere che, se un ascensore si trova al primo piano e nessun pulsante è illuminato quando viene premuto il pulsante interno corrispondente al secondo piano, allora, dopo At secondi, l'ascensore giungerà al secondo piano e il pulsante verrà resettato. In maniera del tutto simile, si potrebbero simulare le chiamate esterne usando le regole formalizzate nelle Figure 5.34 e 5.38. Ciò renderebbe evidente la scena interpretativa del requisito informale. L'illuminazione è cancellata quando l'ascensore visita il piano e/o si muove nella direzione desiderata. Se i risultati della simulazione dovessero non corrispondere all'interpretazione del cliente, la specifica deve essere modificata (si veda l'Esercizio 5.23). Abbiamo constatato una motivazione essenziale dell'uso di modelli formali per le specifiche: le specifiche formali possono essere simulate automaticamente con l'aiuto di un interprete del modello. I benefici di questo approccio dovrebbero risultare evidenti. L'utilità di una simulazione dipende, tuttavia, dal modello. Interpretare una FSM, anche una piuttosto complessa, è facile ed efficiente. Interpretare una PN è concettualmente semplice, ma la sua efficienza è inferiore per via della natura intrinsecamente non deterministica del modello, il che potrebbe costringere all'uso di tecniche laboriose di backtracking. Infatti, si supponga di eseguire una PN per vedere se, da una determinata marcatura m possa essere rag-

giunta una marcatura diversa m '. Durante l'interpretazione vengono fatte delle scelte non deterministiche quando sono abilitate più transizioni. Nel caso queste scelte non portassero al risultato desiderato (ovvero, la marcatura m '), diventa necessario tornare indietro per tentare altre scelte. Commenteremo ulteriormente l'uso di modelli per la verifica delle specifiche nei Paragrafi 5.6.2.4 e 5.7.3. Esercizio 5.25

5.6

Fornite un'interpretazione ragionevole della frase "minimizzare i tempi di attesa", presente nei requisiti informali del sistema di ascensori. Si formalizzi l'interpretazione data in termini di funzioni di selezione da aggiungere a u n o schema P N del tipo rappresentato nella Figura 5.40. Si controlli se la politica fornita continua a garantire che tutte le richieste vengano prima o poi soddisfatte.

Specifiche descrittive

Come affermato nel Paragrafo 5.3, le specifiche descrittive cercano di esprimere le proprietà desiderate di un sistema piuttosto che il suo comportamento. Inizieremo ora un breve studio di una notazione di specifica descrittiva semiformale molto diffusa, e affronteremo successivamente le notazioni completamente formali. Illustreremo diverse notazioni che possono essere utilizzate per specificare sistemi lungo diversi stadi del processo di sviluppo e vedremo come alcune notazioni siano utili al livello dei requisiti, altre per la specifica della semantica delle interfacce dei moduli, mentre altre ancora per la specifica di frammenti di programma. Tutte le notazioni, comunque, possiederanno la stessa caratteristica comune: lo stile descrittivo. Un modo naturale di fornire specifiche descrittive precise è quello di usare formule matematiche. Contrariamente al linguaggio naturale, le formule matematiche possiedono una sintassi e una semantica precisa. Inoltre, possono essere gestite da strumenti automatici oltre che da modelli operazionali formali. Sono stati proposti molti formalismi matematici per la descrizione delle proprietà dei sistemi; in questo paragrafo, affronteremo lo studio di due approcci, uno basato sull'uso della logica matematica e l'altro sull'uso dell'algebra.

5.6.1 Diagrammi entità-relazione Abbiamo visto che i DFD costituiscono una notazione utile per la descrizione di operazioni usate per accedere e manipolare i dati di un sistema, in generale un sistema informativo. Tuttavia, non sono sufficienti per specificare tutte le caratteristiche interessanti del sistema: è necessaria anche una descrizione concettuale della struttura dei dati e delle loro relazioni. In realtà non è chiaro quale delle due descrizioni debba essere fornita prima: se quella delle operazioni o quella delle strutture dati. Da un lato, capire le operazioni da fornire aiuta a capire la struttura logica dei dati; dall'altro, la struttura logica dei dati risulta stabile, indipendentemente dalle operazioni eseguibili su di essa. Si potrebbe addirittura affermare che rappresenti la nostra conoscenza dell'area applicativa, che è più stabile delle operazioni fornite dall'applicativo.

I due punti di vista sono complementari ed entrambi utili. Cominceremo il nostro studio delle specifiche descrittive affrontando il modello ER (entity-relationship, entità-relazione), una notazione molto usata e adottata per la descrizione delle relazioni esistenti tra i dati di un sistema informativo. II modello ER nasce dall'esigenza di disporre di un modello concettuale dei dati che possa specificare i requisiti logici in un sistema informativo. Il modello è basato su tre concetti primitivi: le entità, le relazioni e gli attributi. Il modello possiede anche un linguaggio grafico particolarmente facile da capire; quando le descrizioni sono fornite nel linguaggio grafico vengono dette diagrammi ER. La Figura 5.41 mostra un esempio molto semplice di diagramma ER che descrive le entità S T U D E N T (studente) e C L A S S (classe), con la relazione E N R O L L E D _ I N (è iscritto a) che può sussistere tra uno S T U D E N T e una CLASS. Un'entità, rappresentata da una scatola, indica una collezione di oggetti che condividono proprietà comuni; il concetto è simile a quello di tipo in un linguaggio di programmazione. Le proprietà di un'entità sono i suoi attributi e le relazioni cui partecipa. Gli attributi vengono elencati vicini all'entità, mentre le relazioni vengono rappresentate da scatole a forma di rombo. Nel nostro esempio, S T U D E N T è una collezione di individui; N A M E , AGE e SEX sono attributi di S T U D E N T : ogni S T U D E N T è caratterizzato da tre valori rappresentanti il suo nome, la sua età e il suo sesso. Una relazione tra due entità, come S T U D E N T e CLASS, è un insieme di coppie , dove a è un elemento di S T U D E N T e b un elemento di CLASS. La relazione mostrata nella Figura 5.41 rappresenta il fatto che uno studente a è iscritto alla classe b. I diagrammi ER non sono stati standardizzati in maniera rigorosa. Ciò significa che non esiste alcuna versione universalmente riconosciuta come standard; in pratica, ne esistono molte varianti. Alcuni linguaggi ER permettono relazioni n-arie (ovvero, che possono mettere in relazione n entità); altri supportano solo relazioni binarie. Inoltre, alcuni permettono alle relazioni di avere attributi, mentre altri no. Nel caso fossero permessi gli attributi sulle relazioni, potremmo definire l'attributo P R O F I C I E N C Y (competenza) da associare alla relazione E N R O L L E D _ I N . L'attributo associato a una coppia < a , b > della relazione E N R O L L E D _ I N rappresenterebbe la competenza di uno studente a iscritto alla classe b. Infine, alcuni linguaggi ER supportano qualcosa di simile all'ereditarietà tra entità, spesso chiamata, nel gergo ER, relazione IS_A (ovvero, "è un"). Per esempio, si potrebbero definire UNDERG R A D U A T E e G R A D U A T E come due sottoentità di S T U D E N T , i quali ereditano le proprietà di S T U D E N T e ne aggiungono eventualmente di nuove in termini di attributi e partecipazioni in relazioni ( U N D E R G R A D U A T E IS A STUDENT). Molti linguaggi ER permettono relazioni parziali; ovvero, non tutti gli elementi delle entità coinvolte devono partecipare alla relazione. Inoltre, spesso viene data la possibilità di specificare la relazione come one to one (uno a uno), one to many (uno a molti), many to one (molti a uno) o many to many (molti a molti). Se una relazione R tra A e B è uno a uno, allora per qualsiasi in R non esiste alcun a ' in A tale che sia in R con b 1 * b. Se R è molti a uno, si richiede che, per ogni in R, non esista alcun b 1 in B tale che anche la sequenza che P scrive sul file. Una proprietà, o requisito, di P viene specificata come una formula del tipo { Pr e ( i j, i 2 ,

. . . , i„) }

P {Post(olr

o2,

dovePre(i1,

...,

om,

i1(

i2,

...,

in)>

i n ) indica una formula FOT avente le variabili libere ij, i2, o m , i l f i 2 , ..., i n ) indica una formula FOT avente le variabili libere o x , o 2 i . . . , o m e i l f i2, ..., in. Pr e viene detta precondizione di P, e Post postcondizione. La formula precedente significa che se Pre è vera per i valori di input, prima dell'esecuzione di P, allora, dopo la sua esecuzione, dovrà essere vera Post per i valori di output e input. Forniremo ora alcuni esempi di specifiche di programmi dati in termini di pre e postcondizioni: ...,

i2,

in, e P o s t ( o l f

1. { e x i s t s

z(ij

=

o2,

z*i 2 )>

P

(«1 =

Ìl/Ì2>

Questo esempio indica che se il valore di input è multiplo del valore di input i 2 , allora l'output dovrà essere il risultato della divisione i i / i 2 - Un requisito più forte per un programma di divisione può essere il seguente: 2. {i, > p {i[ =

i2> i 2 *Oi + o 2

and

o2

ì 0 and

o2




0 and

i2 >

0}

z2

(i, =

P {(exists and

z1(

o

* z, and

i2 = o

*

z2)

not

(exists

h

(exists

z,,

z2

(i, =

h

*

z, a n d

i2 =

h

* z 2 ) and

h >

o))>

richiede c h e P calcoli il m a s s i m o c o m u n e divisore di i j e i 2 .

Assumendo che n sia un valore positivo che indica la lunghezza della sequenza di input, la specifica 5.

0}

P

{° •.?. 4 richiede che P calcoli la somma della sequenza. Infine, la specifica 6.

{n > 0} P {for

ali

i

(1

s

i 5 n)

implies

(Oi =

i„.itl) }

richiede c h e P produca la sequenza inversa rispetto alla sequenza di input, a s s u m e n d o che la sequenza di i n p u t n o n sia vuota.

Esercizi 5.30

Fornite una specifica logica per un p r o g r a m m a che legge una sequenza di n + 1 valori e controlla se il p r i m o valore appare nuovamente nei seguenti n valori.

5.31

Fornite una specifica logica per u n p r o g r a m m a che legge d u e parole (ovvero, d u e sequenze di caratteri alfabetici, separati da u n o spazio e terminati da un carattere speciale '#'). La sec o n d a parola p u ò essere nulla; la p r i m a no. Il p r o g r a m m a legge poi una sequenza di altre parole, separate da spazi e t e r m i n a n t i con '#', e riscrive la sequenza, sostituendo tutte le occorrenze della p r i m a parola con la seconda. Al m o m e n t o fornite solo una bozza di soluzione senza a p p r o f o n d i r e tutti i dettagli. Riaffrontate poi il p r o b l e m a d o p o aver finito di leggere il seguito di questo paragrafo.

Gli esempi e gli esercizi precedenti hanno dimostrato che le formule necessarie per specificare anche problemi semplici possono richiedere molti dettagli e possono essere diffìcili da

capire. Abbiamo già affrontato questo inconveniente nei nostri esempi di specifiche operazionali. Nel Paragrafo 5.7 considereremo il problema della gestione di specifiche complesse in generale. Anticiperemo ora, comunque, alcuni suggerimenti per migliorare la leggibilità delle specifiche logiche, come abbiamo fatto quando abbiamo affrontato specifiche non banali con le PN e le FSM. Si consideri, ad esempio, il problema fornito nell'Esercizio 5.31. L'origine dei problemi è che anche concetti semplici e intuitivi, come "parola", non hanno un significato preciso nella sintassi FOT. Quindi, se vogliamo formalizzare una frase del tipo "due parole sono uguali" o "una parola è sostituita da un'altra", dobbiamo affrontare molti dettagli. Il problema può essere risolto una volta per tutte usando definizioni adeguate. Per esempio, possiamo introdurre il predicato i n p u t _ w o r d ( m, n ) per affermare che la sequenza di caratteri nello stream di input, compresa tra la m-esima e l'n-esima posizione, rappresenta una parola. Questa notazione è formalizzata dalla formula inputword

(m,n) =

(for

ali

i

(m < i < n )

implies

alphabetic(cL))

dove Ci rappresenta l'i-esimo carattere mentre a l p h a b e t i c ( c ) significa che c è un carattere alfabetico. (La formalizzazione di questo predicato è un esercizio banale). Ora possiamo usare il predicato i n p u t _ w o r d come abbreviazione compatta e chiara ogni volta che risulti necessario. In particolare, possiamo definire il predicato i n p u t _ t e x t ( m, n ) in modo che affermi che la sequenza di elementi del file di input dalla m-esima all'n-esima posizione è un frammento di testo, ovvero una sequenza di parole separate da spazi e racchiuse da una coppia di simboli '#'. Precisamente, input

text(m,n ) = (i„= ' #'

and

(exists

i„='#' k

(for

(exists

and ali

j

(1

s

j

< k)

hj, irij ( i n p u t _ w o r d mi = m

+

(1




0)

procedure {for

all

reverse i

(1

(a:

< i s n)

in

out

integer

implies

(a(i)

a r r a y ; n:

in

= old_a(n

- i +

integer); 1))}

In questa specifica è stato necessario indicare una relazione tra i valori delle variabili del programma prima e dopo l'esecuzione della procedura. Quindi, è stata usata la variabile ausiliaria o l d _ a per indicare il valore di a prima dell'esecuzione della procedura. La seguente è, invece, la specifica di una procedura di ordinamento: 9. {n

>

0)

procedure

sort

(a:

in

out

integer

a r r a y ; n:

in

integer);

{sorted(a,n ) > ,

Come abbiamo fatto prima, definiamo il nuovo predicato sorted(a,n)

=

(for

all

i(l

s i < n)

implies

a(i)

Ì

a(i+l))

Esercizio 5.33

La s p e c i f i c a 9 è a d e g u a t a p e r u n a p r o c e d u r a d i o r d i n a m e n t o ? P e r c h é ? Se n o n lo è, f o r n i t e u n a specifica adeguata.

5.6.2.3 Specifica di classi La specifica di proprietà dello stato di esecuzione di un programma, piuttosto che solo delle relazioni I/O, diventa ancora più importante nel caso di linguaggi orientati agli oggetti, quando vogliamo specificare una classe. Più precisamente, quando vogliamo specificare il comportamento degli oggetti di una data classe durante la loro vita, dalla loro creazione all'esecuzione di operazioni che ne alterano gli stati, alle operazioni chiamate per conoscere i loro stati. In questo caso, infatti, non esiste la nozione di una computazione, con un punto di inizio e uno di fine. Piuttosto, per capire l'effetto di un'operazione applicata a un ogget-

9

A s s u m i a m o i m p l i c i t a m e n t e c h e il l i m i t e i n f e r i o r e degli array sia 1.

to, dobbiamo poterne caratterizzare lo stato, che rappresenta il risultato delle sequenze di operazioni ad esso applicate in precedenza. Un modo per caratterizzare gli stati degli oggetti è quello di usare un predicato invariante. L'invariante definisce una proprietà che caratterizza l'oggetto dal momento della sua creazione, durante tutta la sua vita. L'invariante deve essere preservato dalle operazioni. Per esempio, se si utilizza un array I M P L di dimensione l e n g t h per implementare il tipo di dato astratto SET, l'invariante potrebbe affermare che non devono essere presenti in IMPL due elementi uguali: for

ali

i, j

(1 ^ i ^ l e n g t h

and

1
y a n d y >

z implies x > z

per il predicato V nell'aritmetica. Questo è un buon esempio dei diversi usi che si possono fare delle specifiche. Quando specifichiamo proprietà per gli oggetti di sistema, come ascensori e pulsanti, stiamo specificando requisiti di sistema; quando specifichiamo proprietà per le variabili di programma, stiamo specificando la progettazione e Y implementazione dei programmi. Per convenienza sintattica, useremo stringhe che cominciano con una lettera maiuscola per indicare variabili e stringhe che cominciano con lettere minuscole per indicare predicati. Si noti che considereremo il tempo una variabile di sistema. Questo ci permetterà di scrivere predicati riguardanti il tempo che intercorre tra diversi eventi (ad esempio, la richiesta di servizio da parte di un ascensore e il suo arrivo al piano corrispondente). Per formalizzare il sistema di ascensori seguiamo un approccio sistematico, che distingue l'insieme di predicati elementari, con i quali intendiamo descrivere l'evoluzione del sistema, in stati elementari ed eventi. Gli stati descrivono una condizione che ha una durata non nulla nel tempo. La nozione globale di stato di un sistema, in un dato istante, è l'insieme di tutti gli stati elementari verificati in quell'istante. Per brevità, in seguito faremo riferimento agli stati elementari semplicemente come stati. Il termine stato globale identificherà lo stato dell'intero sistema. Gli eventi descrivono le condizioni che si possono verificare solo in un determinato istante. Per esempio, l'evento arrived

(E, F, T)

s i g n i f i c a c h e l'ascensore E è g i u n t o al p i a n o F nell'istante T, m e n t r e lo stato standing

(E, F, T w

T2)

s i g n i f i c a c h e l'ascensore E è r i m a s t o f e r m o al p i a n o F dall'istante Tj all'istante T 2 .

In questa descrizione il tempo è rappresentato esplicitamente da un singolo valore per gli eventi, che sono per definizione istantanei, e da una coppia di valori per gli stati, dove la coppia indica l'intervallo durante il quale è rimasto invariato Io stato. Il comportamento del sistema viene descritto usando formule di implicazione o regole, le quali sono un insieme di premesse, seguito dalla parola chiave i m p l i e s , seguita da una conclusione, che deve discendere logicamente dalle premesse. Le regole sono implicitamente quantificate universalmente per simboli che appaiono alla sinistra di i m p l i e s e quantificate esistenzialmente per i simboli presenti solo alla sua destra. Esse permettono di dedurre le occorrenze di eventi o il cambiamento di uno stato in un determinato istante come conseguenza di un insieme di eventi o stati che si presentono in quell'istante o precedentemente, eventualmente soggetti a determinate condizioni. Dato che uno stato cambia

in seguito a un evento, un altro insieme di regole estende il periodo di tempo durante il quale rimane invariato uno stato, fino a quando non accade un evento che lo modifica. Per non complicare eccessivamente lo studio faremo alcune semplici assunzioni. Innanzitutto, assumeremo tempi decisionali nulli, come nel Paragrafo 5.5.4.2. Inoltre, escludiamo eventi simultanei generati dall'ambiente esterno. Queste decisioni non incidono in maniera sostanziale sulla specifica, ma permettono una semplificazione di determinate regole. Se dovessero risultare non realistiche, le assunzioni possono sempre essere rimosse in maniera semplice, al prezzo di incrementare leggermente il numero di formule necessarie. Forniamo ora un campione degli eventi, stati e regole che costituiscono la specifica del sistema. Gli esercizi proposti guideranno il lettore nel completamento della specifica e nella modifica di alcune sue parti. Si ricordi che ci sono m piani e n ascensori. Il sistema viene acceso all'istante t 0 . Eventi •

arrivai(E, E

in

(t 0

F,

[1..n],

is

the

T) F

in

[l..m],

initial

T

2 t0

time)

rappresenta l'arrivo dell'ascensore al piano F nell'istante T. Questo evento non indica se, una volta arrivato, l'ascensore si ferma a quel piano o lo lascia immediatamente per un altro; né implica alcunché circa la direzione di spostamento dell'ascensore o il piano lasciato dall'ascensore. •

departure(E, E

in

F,

[l..n],

D, F in

T) [l..m],

D in

(up,

down),

T 2 t0

Descrive la partenza di un ascensore dal piano F nella direzione D all'istante T. Vedremo nelle regole che questo evento pone l'ascensore in uno stato di moto dal piano F nella direzione D. •

stop(E, E

in

F,

T)

[l..n],

F in

[1..m],

T 2 t0

Rappresenta l'arrivo e la fermata dell'ascensore E al piano F all'istante T. S t o p serve a soddisfare richieste di servizio interne ed esterne. Vedremo nelle regole che questo evento pone l'ascensore E in uno stato di fermata al piano F. •

new_list(E, E

in

L,

[l..n],

T) L

in

(l..m)*,

T ì t0

In ogni istante T, a ciascun ascensore è associata una lista L di interi10 compresi tra l e m . La lista rappresenta l'insieme dei piani cui l'ascensore si fermerà, secondo le decisioni di schedulazione effettuate fino a quel momento dal componente di controllo del sistema. Una lista viene associata a ciascun ascensore affinché la strategia di schedulazione per le richieste interne ed esterne possa essere rappresentata in maniera semplice da regole per la gestione delle liste dei diversi ascensori. In ogni istante, ciascun ascensore "decide" che cosa fare se-

10

L'asterisco indica l ' o p e r a z i o n e d i c h i u s u r a transitiva; a * i n d i c a u n a s e q u e n z a di zero o p i ù a .

condo i contenuti della propria lista. N e w _ l i s t (E, L, T) significa che la lista associata all'ascensore E diventa L nell'istante T; quindi, il predicato rappresenta l'evento che trasforma lo stato di controllo dell'ascensore E impostando la sua lista di "prenotazioni" L. • •

cali(F,

D,

T)

request(E,

F,

E

in

[l..n],

T) F

in

[l..m],

D

in

{up,

down),

T

2 t„

Questi due predicati indicano gli eventi generati al di fuori del sistema: le chiamate esterne dal piano F nella direzione D all'istante T e la "prenotazione" del piano F dall'interno dell'ascensore E all'istante T, rispettivamente. Entrambi gli eventi sono associati alla pressione del corrispondente pulsante da parte di qualcuno che desidera inoltrare una richiesta. Se F = 1 o F = m, esiste un'ulteriore condizione sui valori dei parametri: D deve essere rispettivamente uguale a up o down. Stati Siccome gli stati sono proprietà degli oggetti che hanno una durata, tutti i predicati che fanno riferimento agli stati hanno due parametri temporali che rappresentano i limiti dell'intervallo temporale durante il quale la proprietà risulta vera. Useremo la convenzione che gli intervalli di tempo associati ai predicati di stato siano chiusi a sinistra e aperti a destra; ovvero, il limite inferiore dell'intervallo di tempo sarà incluso nell'intervallo, mentre quello superiore no (la notazione adottata per esprimere intervalli di tempo di questo tipo è [T l f T2 [). La motivazione di questa convenzione è che gli effetti degli eventi devono essere intesi istantanei, in modo che un nuovo stato possa risultare vero dall'istante (incluso) in cui accade l'evento che lo genera fino al momento (escluso) in cui accade l'evento che causa il successivo istantaneo cambiamento di stato. Dalla definizione di stato risulta chiaro che se una proprietà che caratterizza uno stato risulta vera durante un determinato intervallo di tempo, sarà vera durante qualsiasi intervallo di tempo contenuto nel primo. Inoltre, dato un intervallo durante il quale risulta vero lo stato, lo stesso stato può risultare vero anche in un intervallo più ampio. Per esempio, se affermiamo che moving(E,

(ovvero, lo

F,

D,

ali

T2)

stato di spostamento di u n ascensore E dal p i a n o F in direzione D) risulta vero

nell'intervallo [ T , , for

T,,

T 2 [ deve essere possibile dedurre

T 3 , T 4 ( T , Ì TJ < T 4 S T 2 ) m o v i n g ( E ,

F,

D,

T31

T,)

Niente impedisce comunque che moving(E,

F,

D,

T3,

T„)

con T3 < Ti < T 2 < T 4 sia anch'essa vera. Forniremo più avanti alcune regole specifiche per definire queste assunzioni circa la semantica degli stati. Forniamo ora un elenco di stati per gli ascensori: •

standing(E, E

in

[l..n[,

F, F

T1( in

T2 ) [l..m[,

t0

s T,


F

implies departure(E,

F,

up,

Ta).

Una regola simile è applicabile al caso in cui l'ascensore lascia un piano nella direzione opposta. R 2 L'ascensore E, arrivando al piano F, si ferma se il piano è in attesa di servizio, ovvero se il numero del piano appare come primo elemento della lista associata all'ascensore. arrival(E,

F,

Ta)

and

1i s t ( E ,

T,

Ta)

and

first(L)

L,

= F

implies S top(E,

F,

Ta) .

R 3 L'ascensore E arriva al piano F con una lista vuota, e si ferma lì. Assumendo che tutti gli ascensori siano in movimento per soddisfare le richieste di servizio dei piani che sono presenti nelle loro rispettive liste, dovrebbero partire da e arrivare ai piani con liste non vuote. Questa regola diventa significativa se la politica di schedulazione permette la cancellazione degli elementi dalla l i s t di un ascensore in movimento (si vedano le regole di controllo che verranno fornite tra poco). arrival(E, 1i s t ( E ,

F,

Ta)

empty,

and

T,

Ta)

implies s t o p ( E , F,

Ta).

R4 Assumiamo che gli ascensori abbiano un tempo di servizio costante e ben conosciuto At 3 . Se la lista dell'ascensore non è vuota alla fine di questo intervallo, l'ascensore lascia il piano immediatamente. stop(E,

F,

Ta )

1i s t ( E ,

L,

T,

first(L)

>

and Ta

+

Atg)

and

F

imp1ie s departure(E,

F,

up,

Ta

+ Ats | .

Una regola simile è applicabile per le partenze dell'ascensore nella direzione opposta. R 5 Alla fine del periodo di servizio se non ci sono piani da servire (ovvero, se la lista dell'ascensore è vuota) l'ascensore lascerà il piano solo quando la lista cesserà di essere vuota. stop(E,

F,

list(E,

empty,

Tp

+ At„

>

T„

Ta)

1ist(E , L , Tp, first(L)

>

and Ta

+

Ata,Tp)

and

and T)

and

F

implies departure(E,

F,

up,

Tp).

Come sempre, si può fornire una regola simile anche per il senso di marcia opposto. R i Come abbiamo fatto con le PN, assumiamo che il tempo At necessario perché un ascensore si sposti da un piano al successivo, in una qualsiasi delle due direzioni, sia costante e conosciuto. L'arrivo a un piano avviene nell'istante At dopo l'istante di partenza dal piano precedente. departure

(E,

F,

up,

T)

i m p l i es arrivai

(E,

F

+

1,

T

+

At).

Una regola simile può essere espressa anche per il caso in cui il senso di marcia sia verso il basso.

R

L ' e v e n t o d i f e r m a t a al p i a n o F n e l l ' i s t a n t e T a v v i a u n o s t a t o d i p e r m a n e n z a al p i a n o

7

che dura a l m e n o l'intervallo di t e m p o [ T , stop(E,

F,

T

+

Ats[.

T)

implie s standing(E,

F,

T,

T

+

At3).

R 8 Alla fine di una permanenza della durata di A t s , se non ci sono altri piani da servire, l'ascensore rimarrà fermo al piano fino a quando la lista rimane vuota. stop(E,

F,

1i s t ( E ,

empty,

Ts)

and Ts

+ Ataf

T)

F,

T).

implies standing(E,

T„,

R 9 L'evento di partenza di un ascensore E dal piano F nella direzione D nell'istante T avvia uno stato di spostamento che dura un intervallo di tempo [ T, T + At [. departure(E,

F,

D,

T)

moving(E,

F,

D,

implie s T,

T

+

At).

R 1 0 Se uno stato rimane costante per l'intervallo di tempo [ T j , T2 [, allora rimarrà costante anche per l'intervallo di tempo [ T 3 , T4 [, compreso in [ T i , T 2 [. standing(E,

F,

Tt

Tj < T„ and




T2.

Se riuscissimo a dedurre la validità di questa formula dalle specifiche precedenti del sistema, potremmo assumere che sia vera per qualsiasi implementazione valida del sistema. La dimostrazione di proprietà di una specifica logica consiste, quindi, in un'attività del tutto analoga alla deduzione della dinamica del sistema. Possiamo notare un'importante differenza tra gli stili di specifica operazionale e di specifica descrittiva. Nelle specifiche operazionali, la descrizione dello stato di un sistema è ben diversa dalla descrizione delle proprietà del sistema. Questo fatto ha un impatto enorme sul modo in cui possiamo utilizzare un interprete (possibilmente automatico) per verificare le specifiche. Per esempio, potremmo usare un interprete di PN per verificare se, dopo avere inizializzato un sistema di ascensori con 30 piani, quattro ascensori, una determinata velocità e una data sequenza di chiamate, tutte le richieste saranno soddisfatte entro un periodo di 30 secondi. Tuttavia, l'interprete non può fornire prove per cui qualsiasi sistema con n piani, in ascensori e velocità z, con qualsiasi sequenza con meno di y chiamate al minuto, il tempo massimo di attesa tra una richiesta e il servizio sia w, espresso come una funzione di n, m, z e y. In altre parole, un interprete di PN potrebbe essere utilizzato per simulare determinate specifiche ma non per fornire prove circa la validità di determinate proprietà. Questi commenti si applicano in generale a tutti gli stili operazionali e descrittivi e a tutte le proprietà. Per esempio, si considerino le FSM. Se vogliamo conoscere quale sarà lo stato del sistema, iniziando da un determinato stato e applicando una data sequenza di transizioni, dobbiamo semplicemente "mettere in moto l'automa". Se invece vogliamo sapere se il sistema si comporterà in maniera ciclica, dobbiamo analizzare il grafo che rappresenta la FSM alla ricerca di percorsi ciclici. Come ulteriore esempio, si consideri la proprietà di un sistema di assenza di deadlock. Le PN hanno una definizione formale chiara di questa proprietà, come abbiamo visto nel Paragrafo 5.5.4. Un interprete di PN, però, non può essere utilizzato per decidere l'assenza di deadlock; può solo, nella migliore delle ipotesi, "eseguire" il modello in diversi casi per vedere se appare un deadlock. Pertanto, se vogliamo sapere se un sistema modellato da una PN corre il rischio di incappare in un deadlock, dobbiamo analizzare il modello in un modo diverso da come lo eseguiamo". Invece, si supponga di aver fornito una definizione formale dello stesso sistema usando le formule FOT. La proprietà desiderata è, ancora una volta, una formula FOT, quale for

all

and

reachable

S,,

exists

S, ( ( s t a t e (S 2 ,

(S,)

S, ) )

S] ( s t a t e

and

state

(S 2 )

implies (S 3 )

and

S 3 * S, and

reachable

(S 3 ,

S2))

Ovvero, per qualsiasi stato s ^ e per qualsiasi stato S2 raggiungibile da s 1 ; il sistema può evolvere verso uno stato s 3 , raggiungibile da s 2 . Di conseguenza, possiamo usare lo stesso interprete delle formule FOT per simulare un sistema o per decidere le proprietà del sistema. Ovviamente, una proprietà indecidibile non diviene decidibile solo perché è stato utilizza-

' 1 L'assenza di deadlock in una "PN pura" (ovvero una PN che rispetta la definizione originale) è decidibile, anche se rappresenta, nel caso generale, un problema di complessità intrattabile.

to uno stile descrittivo per esprimerlo: semplicemente abbiamo un linguaggio omogeneo per la descrizione del sistema e delle sue proprietà. D'altra parte, eseguire le specifiche logiche (ovvero costruire interpreti adeguati) potrebbe non essere semplice come per le specifiche operazionali. Per esempio, "interpretare" un insieme di regole FOT richiede l'applicazione di regole di deduzione che, in generale, sono caratterizzate da non determinismo: spesso, da un insieme di premesse, possono essere derivate diverse conclusioni parziali, di cui molte possono anche non portare alla conclusione finale desiderata. È utile ricordare che il problema di dimostrare teoremi nelle FOT è indecidibile; in altre parole non possiamo decidere automaticamente se una data proprietà (una formula FOT) è implicata da una data specifica (un'altra formula FOT). Linguaggi logici eseguibili come il PROLOG, tuttavia, riescono ad approssimare piuttosto bene il potere deduttivo delle FOT attraverso tecniche di interpretazione efficaci. Per questa ragione, possono essere utilizzati come linguaggi di proto tipizzazione. In conclusione, i formalismi operazionali sembrano più orientati verso la simulazione dei sistemi, mentre i formalismi descrittivi sono applicabili in maniera più naturale all'analisi delle proprietà. Esercizio 5.39

Definite (non dimostrate!) una proprietà del sistema di ascensori che afferma come il t e m p o di attesa per ciascuna richiesta possieda u n limite superiore. Formalizzate questa proprietà usand o una formula FOT.

5.6.3 Specifiche algebriche Un altro stile di specifica descrittiva si basa sull'uso àc\Y algebra, piuttosto che della logica, come formalismo matematico sottostante. Essenzialmente, le specifiche algebriche definiscono un sistema come uri algebra eterogenea, ovvero, come una collezione di diversi insiemi su cui sono definite diverse operazioni. Le algebre tradizionali sono omogenee. Un'algebra omogenea consiste in un unico insieme e diverse operazioni. Per esempio, gli interi, con le operazioni di addizione, sottrazione, moltiplicazione e divisione, sono un'algebra omogenea. Invece, le stringhe alfabetiche (ovvero, le sequenze di caratteri), con le operazioni di concatenazione e calcolo della lunghezza non sono un'algebra omogenea, visto che il codominio dell'operazione di calcolo della lunghezza è l'insieme dei numeri interi, non delle stringhe. Quindi, quest'algebra consiste di due insiemi, stringhe e interi, su cui sono definite le operazioni di concatenamento e di lunghezza delle stringhe. Molti sistemi software possono essere definiti in maniera naturale come algebre eterogenee. Dopo tutto, la definizione "collezione di insiemi e operazioni" è molto vicina alla nozione di tipo di dato astratto introdotta nel Paragrafo 4.2.4.2. Nel Paragrafo 5.7.2.1 sottolineeremo alcune importanti connessioni esistenti tra le algebre eterogenee e i tipi di dati astratti. Vedremo ora le caratteristiche essenziali delle specifiche algebriche mediante alcuni esempi. Come punto d'inizio, si consideri il semplice caso delle stringhe.

ESEMPIO 5 . 6

Supponiamo di voler specificare un sistema per la gestione delle stringhe. Il primo insieme di fatti da registrare sono le operazioni necessarie e gli insiemi coinvolti da queste operazioni. In questo caso, assumiamo di voler gestire le stringhe mediante le seguenti operazioni: •

Creazione di nuove stringhe vuote (operazione new);



Concatenamento di stringhe (operazione append);



Aggiunta di un nuovo carattere alla fine di una stringa (operazione add);



Controllo della lunghezza di una data stringa (operazione l e n g t h ) ;



Controllo se una stringa è vuota (operazione



Controllo se due stringhe sono uguali (operazione e q u a l ) .

isEmpty);

U n a breve i s p e z i o n e della lista indica c h e gli i n s i e m i c o i n v o l t i , oltre all'insieme delle stringhe, chiamato S t r i n g , sono:

• Char: l'insieme dei caratteri alfabetici; • Nat: l'insieme dei numeri naturali; •

B o o l : l ' i n s i e m e dei valori logici,

{true,

false}.

La collezione di insiemi che formano l'algebra eterogenea è detta segnatura. Ogni insieme, a sua volta, è detto un sort (letteralmente "tipo") dell'algebra. Per definire un'algebra eterogenea è, quindi, necessario specificare la sua segnatura, le operazioni coinvolte, e i loro domimi e codominii. Questa definizione viene chiamata la sintassi dell'algebra e può essere espressa mediante diverse notazioni. Adotteremo una notazione basata sul linguaggio di specifica Larch. La notazione è piuttosto intuitiva e vicina ad altri linguaggi algebrici. Segue la sintassi dell'algebra per le stringhe in una notazione tipo Larch. algebra

StringSpec;

introduces sorts

String,

Char,

Nat,

Bool;

operations new:

()

—»

append: add:

String;

String,

String,

length:

String

isEmpty: equal:

String

Char

String

String,

—>

—»

String;

String;

—> N a t ; —»

Bool;

String

—»

Bool.

Il tipo s t r i n g è solo uno degli insiemi coinvolti. II fatto che si sia interessati alla sua definizione e che, invece, i tipi Char, Nat e B o o l siano "tipi ausiliari" non è esplicito nella notazione; è solo evidenziato dal nome dato all'algebra stessa nella prima linea. Si noti che l'operazione new non ha argomenti (ovvero, non ha alcun dominio). Questo è un modo convenzionale per indicare valori costanti. Infatti, una funzione senza argomenti deve avere un solo valore possibile. In questo caso è la "stringa vuota".

I significati delle singole operazioni specificate sopra sono molto chiari in questo caso. Ciò nonostante, il significato deve essere specificato in maniera precisa e lo si fa mediante la semantica dell'algebra, usando equazioni che sono intese a definire le proprietà essenziali che devono verificarsi quando vengono eseguite le operazioni. Per questo motivo, tali equazioni vengono dette anche assiomi dell'algebra. Seguono alcune proprietà ovvie delle operazioni dell'algebra S t r i n g : (1) la stringa creata dall'operazione new è una stringa empty (vuota). In questo caso si tratta più di una definizione che di una proprietà. (2) Il risultato del concatenamento di una stringa vuota a un'altra stringa corrisponde alla stessa stringa. (3) Il risultato dell'aggiunta di un carattere a una qualsiasi stringa non può mai corrispondere alla stringa vuota. La formalizzazione di queste e di altre proprietà nella nostra notazione è la seguente: constrains for

ali

new,

append,

add,

length,

isEmpty(new() ) =

equal

so

tbat

true;

isEmpty(add(s , c )) = length(new())

=

false;

0;

length(add(s,c)) append(s,new())

= =

length(s)+l; s;

append(s,,add(s2,c)) equa 1 ( n e w ( ) , n e w ( ) ) =

=

add(append(slfs2),c); true;

equa 1 ( n e w ( ) , a d d ( s , c ) ) =

false;

equa 1 ( a d d ( s , c ) , n e w ( ) ) =

false;

equa 1 ( a d d ( s w c ) , a d d ( s 2 , c) end

isEmpty,

[ s , s [, s 2 : S t r i n g ; c : C h a r ]

= e q u a 1(s^, s 2 ) ;

StringSpec.

Esaminiamo le equazioni fornite. Per cominciare, si osservi che è stato fatto uso di cinque simboli non dichiarati e non definiti, ovvero, 0, 1, +, t r u e e f a l s e . Gli altri simboli, incluse le parentesi e '=' (da non confondere con l'operazione "equal"), sono o simboli della notazione o sono stati definiti nella sintassi. Se avessimo voluto essere del tutto rigorosi, avremmo dovuto dichiarare '+' come un'operazione dell'algebra Nat e 0,1, t r u e e f a l s e come costanti (ovvero, funzioni con zero argomenti) dei rispettivi tipi. Inoltre, avremmo dovuto fornire assiomi per specificare le proprietà di quelle algebre; in particolare, secondo l'assiomatizzazione classica dell'aritmetica di Peano, avremmo dovuto affermare che ' l ' è il risultato dell'operazione s u c c e s s o r (successivo) applicato al valore costante 'o'. Per evitare un numero eccessivo di equazioni molto ovvie, le abbiamo assunte come implicite. Procediamo con l'analisi della specifica. Non c'è dubbio che le equazioni di questa specifica descrivono "verità" riguardanti le stringhe. Quello cui siamo interessati, però, è la possibilità di derivare ulteriori proprietà a partire da queste, come abbiamo fatto con le specifiche logiche. E per questo motivo che le equazioni vengono dette axioms (assiomi). Per esempio, la seguente proprietà dovrebbe risultare vera per ogni carattere c: a p p e n d ( n e w ( ) , a d d ( n e w ( ),c) ) =

add(new(),c)

Questa proprietà può essere derivata dagli assiomi nella seguente maniera: se s „ s 2 = new( ) nell'assioma append(s,,

add(s2,c))

=

add(append(st,

s2),c)

otteniamo append(new(),add(new(), c )) =

add ( a p p e n d ( n e w ( ) , n e w ( ) ) , c )

(i)

Poi, sostituendo s = new( ) nell'assioma a p p e n d ( s , n e w ( ) ) = s , otteniamo a p p e n d ( new( ) , new( ) ) = new( ). Sostituendo questo risultato in ( i ) , otteniamo la proprietà desiderata. In maniera simile, sia < c t , . . . , c„> un'abbreviazione per add(add. . .(add(new( ), c,),...),

c„)

È pertanto facile dimostrare che append

( , ) =

per ogni c 1 (

c2,

c3i

c4.

Si considerino le proprietà append(new(),

s)

=

s

(ii)

e append

(s1(

a p p e n d ( s2, s3 ) ) =

append)append(s,,

) ,s,)

(iii)

Queste due proprietà possono essere dimostrate per induzione. Mostriamo come, nel caso di (ii). Questa è verificata per s = new( ) applicando l'assioma append(s, new( ) ) = s con s = new( ). Si assuma ora che ( i i ) sia verificata per una stringa data Si, e sia s add ( s !, c ). Di conseguenza a p p e n d ( n e w ( ) , s ) = a p p e n d ( n e w ( ) , add ( s !, c ) ) = add ( a p p e n d ( n e w ( ) , s 1 ) , c ) = a d d ( s 1 , c ) = s . Quindi, ( i i ) è verificata anche per s. Il precedente ragionamento intuitivo, però, assume che ogni stringa s, diversa da new( ), sia del tipo add( s x , c ) per qualche s x , c. Le operazioni new e add vengono di conseguenza chiamate generator (generatori) dell'algebra S t r i n g S p e c . Nonostante questo fatto sembri ovvio, non è esplicitamente espresso nella formalizzazione di S t r i n g S p e c . Per specificare ciò, modifichiamo l'intestazione della precedente definizione semantica nel seguente modo: constrains

new,

append,

StringSpec for

all

add,

generated

length, by

[new,

[s , s t,s 2 : S t r i n g ;

c:

isEmpty,

equal

so

that

add] Char]

Questo costrutto esprime esplicitamente che tutti gli elementi del sort S t r i n g possono essere ottenuti come combinazione adeguata di operazioni new e add. In maniera simile, tutti i numeri naturali possono essere generati a partire dalla costante 'o' e l'operazione successor. Ora, assumiamo che la precedente algebra venga arricchita introducendo caratteri costanti, come 'a', ' b ' , ... (dove le costanti sono racchiuse tra virgolette singole). Formalmente, queste costanti sono introdotte definendole come funzioni senza argomenti, come abbiamo fatto per l'operazione new; per esempio, a : ( ) Char. ' a ' verrà utilizzata come abbreviazione di a ( ). Si consideri, poi, la proprietà esprimibile con la formula equal|add|s,'a'),add(s,'b'))

=

false

Intuitivamente, questa formula sembra essere vera. Non esiste, però, un modo per dimostrare che sia vera12 all'interno del sistema di equazioni. L'impossibilità di dimostrare la formula in questione dimostra che gli assiomi forniti per questo sistema sono incompleti, ovvero, non ci permettono di dedurre tutte le "verità" desiderate della nostra algebra. Fortunatamente, in questo caso, le specifiche formalizzate dalle equazioni precedenti possono essere completate facilmente (non sempre, però, è semplice ottenere la completezza!) arricchendo la sintassi del linguaggio nel seguente modo: •

Aggiungendo una nuova operazione e q u a l c , la quale definisce Vuguaglianza tra caratteri. Questa operazione dovrebbe essere definita da equazioni del tipo equalC(

1

a' , 'a' ) =

equalC('a','b')



=

true; false;

Sostituendo l'ultima equazione della semantica precedente con e q u a 1 ( a d d ( s t , c , ) , a d d ( s 21 c 2 ) ) = e q u a l ( s , , s , )

and

equalC(c,,c2)

(j)

Chiaramente, durante la stesura delle specifiche algebriche, come con altri stili, l'incompletezza non è l'unico rischio. Un altro pericolo è quello di sovraspecificare (ovvero, fornire limitazioni eccessive) un sistema, come accadrebbe, per esempio, nel caso in cui aggiungessimo l'assioma e q u a l ( add ( slf e, ), add ( s 2 , c 2 ) ) = e q u a l ( s w c 2 ) equalC(c,,c2)

and

not

and

equalC(clf'a')

(jj)

invece di ( j ). Infatti, ( j j ) afferma impropriamente che due stringhe possono essere uguali solo se non contengono il carattere ' a ' ! Potremmo anche scrivere specifiche contraddittorie o incoerenti, come nel caso aggiungessimo questo assioma e q u a l ( a d d ( s , c ), s ) =

true

In generale, un insieme di equazioni algebriche è contraddittorio, o incoerente, se consente di dimostrare che t r u e = f a l s e . Potremmo scrivere specifiche ridondanti, per esempio, aggiungendo all'insieme di assiomi append(new(),s)

= s

12 II lettore dovrebbe prestare attenzione a certe complicazioni delle formule matematiche. In questo caso vogliamo dimostrare una formula che afferma la falsità di un'altra formula. Dovremmo ricordarci che, in generale, il fatto che non sussistano prove di una formula non significa che questa sia falsa. N o n esiste una dimostrazione per e q u a l ( a d d ( s , ' a ' ) , a d d ( s , ' b ' ) ) = t r u e . N o n esiste n e m m e n o la dimostrazione per e q u a l ( a d d ( s , ' a ' ) , a d d ( s , ' b ' ) ) = f a l s e , ma "intuiamo" che questa formula dovrebbe essere vera e vorremmo poterla dimostrare. Ancora una volta, la nostra esposizione di questi argomenti teorici si affida all'intuito del lettore e lascia un approfondimento del trattamento matematico alla letteratura.

visto che questa formula può già essere derivata come conseguenza degli altri assiomi. Nella pratica, tuttavia, la ridondanza nelle specifiche è molto meno problematica dell'incoerenza o dell'incompletezza e può essere spesso ignorata. In conclusione, una specifica algebrica definisce un sistema come una collezione di insiemi e operazioni, espresse nella segnatura della specifica, i cui elementi soddisfano le equazioni della parte semantica e, quindi, anche le equazioni derivabili. • Acquisiremo una conoscenza più approfondita delle specifiche algebriche attraverso un esempio più elaborato. ESEMPIO 5 . 7

Supponiamo di voler specificare un editor di testi. La specifica deve indicare i tipi di dati su cui deve poter operare l'editor (ovvero, i tipi, le operazioni disponibili e i loro significati). Inizialmente, consideriamo un editor di testi molto semplificato, adatto alla gestione di file di testo molto semplici, dotato del seguente insieme di operazioni dove il suffisso F viene usato per indicare che l'operazione agisce sui file: : crea

un nuovo

file



NewF

vuoto;



isEmptyF: indica se un file è vuoto;



A d d F : a g g i u n g e u n a s t r i n g a d i c a r a t t e r i alla fine d e l



I n s e r t F : i n s e r i s c e u n a s t r i n g a i n u n a d a t a p o s i z i o n e d e l file. Il r e s t o d e l file v i e n e s p o -

file;

s t a t o alla p o s i z i o n e i m m e d i a t a m e n t e s u c c e s s i v a alla s t r i n g a i n s e r i t a ;

due file;



A p p e n d F : concatena



Altre operazioni che verranno discusse più avanti o che possono essere immaginate dal lettore come esercizio.

Grazie alle similitudini con l'esempio precedente, la seguente specifica algebrica per l'editor di testi dovrebbe risultare chiara: algebra

TextEditor;

introduces sorts

Text,

String,

Char,

Bool,

Nat;

operations newF:

( ) —»

isEmptyF: addF:

Text;

Text

Text,

—> B o o l ;

String

—>

Text;

insertF:

Text,

Nat,

String

appendF:

Text,

Text

—>

deleteF:

Text

lengthF : Text equalF: addFC:

Text, Text,

—>

—>

Text;

Text;

Text;

—> N a t ; Text Char

—> —>

Bool; Text;

{Questa è u n ' o p e r a z i o n e ausiliaria che sarà necessaria p e r d e f i n i r e a d d F e a l t r e o p e r a z i o n i sui f i l e . I n o l t r e ,

constrains TextEditor for

ali

[f,

si a s s u m e c h e t u t t e le o p e r a z i o n i p r e c e d e n t e m e n t e i n t r o d o t t e in S t r i n g S p e c s i a n o a n c o r a d i s p o n i b i l i . Per c h i a r i r e la d i s t i n z i o n e tra i d u e t i p i di o p e r a z i o n i , le s e c o n d e p o s s i e d o n o il s u f f i s s o 'S'. Si a s s u m e c h e la s i n t a s s i e la s e m a n t i c a di q u e s t e a p p a r t e n g a n o anche a TextEditor, senza copiarle e s p l i c i t a m e n t e ) n e w F , i s E m p t y F , a d d F , a p p e n d F , i n s e r t F , d e l e t e F so that g e n e r a t e d by [ n e w F , a d d F C ] fi,f ; :

Text;

isEmptyF(newF())=

s:

String;

isEmptyF(addFC(f,

c))=

a d d F ( f , news( ) )=

f;

addF(f , addS(s,

c))=

lengthF(newF())=

0;

lengthF(addFC(f,

c))=

appendF(f, appendF(f,,

c:

Char;

cursor:

Nat]

true;

newF())=

false;

a d d F C ( a d d F ( f , s),

lengthF(f)

+

c);

1;

f;

addFC(f2,c))=

a d d F C ( a p p e n d F ( f , , f ¡) , c ) ;

equalF(newF(),newF())=

true;

equalF(newF(),addFC(f,

c))=

equalF(addFC(f , c),new())=

false; false;

equalF(addFC(f1,c1),addFC(f2,c2)= e q u a l F ( f 1 ( f 2 ) and insertF(f,cursor , newS())=

equalC

(c,,c¡| ;

f;

((equa 1 F ( f , a p p e n d F ( f , , f 2 ) ) and

( l e n g t h F | £,)=

cursor

-

1))

imp1ie s equalF(insertF end

(f,cursor,s),

appendF(addF(f1,s),f2)))=

true;

TextEditor.

L'ultima equazione sembra piuttosto complicata. Per rendere le equazioni più chiare, potremmo scriverle come if

( e q u a l F ( f , a p p e n d F ( f ! , f 2 ) ) and (insertF(f,cursor,s)

(lengthF(f,)=cursor

-

1))

then

= a p p e n d F ( a d d F ( f ¡ , s), f 2 ) )

Le equazioni di questo tipo vengono chiamate equazioni condizionali. Esaminiamo il reale significato delle operazioni definite dalle equazioni precedenti. Le equazioni algebriche esprimono relazioni tra gli elementi degli insiemi coinvolti, non relazioni riguardanti variabili che immagazzinano valori. Sarebbe quindi plausibile, ma non necessario, dedurre dalle equazioni precedenti che il risultato dell'applicazione di i n s e r t F ( f , c u r s o r , s) sia una modifica dello stato del file f che consiste nell'inserimento della stringa s tra le porzioni f t e f 2 del file. Per sottolineare la differenza con i linguaggi di programmazione convenzionali, la sintassi per le specifiche algebriche usa la parola chiave o p e r a t i o n s invece delle parole chiave tradizionali p r o c e d u r e e f u n c t i o n . Entrambe le seguenti implementazioni dell'operazione di inserimento sarebbero adeguate rispetto alla specifica fornita: •

Implementazione 1 : l'operazione modifica f nella maniera specificata;



Implementazione 2: l'operazione crea un nuovo file il cui valore è calcolato nel modo specificato.

In generale, ovviamente, i file system ad accesso diretto tendono a lavorare secondo l'implementazione 1 (in assenza di comandi che impongono il contrario), mentre file system sequenziali lavorano necessariamente secondo l'implementazione 2. Anche se la specifica contiene un numero considerevole di equazioni (il lettore non deve dimenticare che ne sono state lasciate molte implicite), è ancora lontana dall'essere una specifica per un editor di testi realistico. Il procedimento per completarla dovrebbe però ora risultare chiaro. • Le specifiche algebriche sono una notazione utile per la specifica della semantica di moduli come i tipi di dati astratti. Possono, quindi, complementare le notazioni di progetto come il T D N / G D N o i diagrammi delle classi UML con lo scopo di aggiungere informazioni semantiche ai moduli. Le specifiche algebriche si differenziano dalle pre/post condizioni e dagli invarianti in quanto forniscono specifiche più astratte della semantica dei tipi di dati astracci. Le pre/post condizioni e gli invarianti sono infatti espresse in termini di implementazioni di tipi di dati astratti (ad esempio, in termini di variabili e parametri di classi). Esercizi 5.40

Dimostrate che le seguenti proprietà sono vere per l'algebra S t r i n g S p e c introdotta in questo paragrafo: equal( —>

Bool; Table;

(segue a p. succ.)

(segue da p. prec.) constrains for

ali

last, [d,

rest,

dwd2:

equalT,

Data;

last(insert(t, rest(new())

=

rest(insert(t,

t, d))

new

=

d))

=

d),

=

d))

false;

e q u a 1 D 1 7 ( 1 as t

(t,) ,

(d,,d2)

(t2))

rest

and

(t2));

delete

(t,

d);

then

d e l e t e ( i n s e r t ( i n s e r t ( t , d, ), d 2 ) = end

last

(ti)),

new();

delete(insert(t,d),d)= equalD

that

false;

=

equalT(rest

not

so

true; n e w( ) ) =

equalT

if

insert

t; =

insert(t,

de 1 e t e ( n e w ( ),d)=

new,

d;

equalT(new(), |t„t,)

isEmpty,

Table]

();

equalT(new(),new()) equalT(insert(t,

delete,

t„t;i

i n s e r t ( d e l e t e ( t , d 2 ), d, ) ;

TableAlg.

Come per imports, la clausola assumes fornisce, all'algebra che "assume", l'accesso alla sintassi e alla semantica dell'algebra che viene "assunta". Inoltre, sia imports sia assumes sono transitive nel senso che permettono l'accesso alle operazioni di altre algebre che sono a loro volta importate o assunte dall'algebra che si assume o si importa. Non ci soffermiamo qui su eventuali conflitti di nomi per quanto importato o assunto; aggiriamo il problema aggiungendo semplici suffissi alle operazioni comuni, come new ed e q u a l 1 8 . La differenza tra imports e assumes è che assumes permette la modifica della semantica delle operazioni assunte, mentre imports non lo permette. Nel nostro esempio, le equazioni di T a b l e A l g impongono nuove limitazioni alle operazioni new e insert. La clausola assumes, esprime quindi un tipica relazione di ereditarietà tra algebre, secondo il significato che questa relazione possiede nella terminologia della progettazione orientata agli oggetti. Un altro tipo di contenitore di dati è una coda FIFO, che possiede le operazioni last e equal, come le tabelle. L'operazione d e l e t e della coda FIFO, però, è sia sintatticamente che semanticamente diversa dalla d e l e t e per le tabelle. Inoltre, le code FIFO hanno un'operazione first, la quale restituisce il primo elemento che è stato inserito nella coda e che non è stato ancora rimosso. Viene fornita di seguito la definizione di Q u e u e A l g : algebra

QueueAlg;

assumes

Container;

introduces sort

17

Queue ;

Assumiamo che l'algebra DataType sia fornita di un'operazione di uguaglianza. Un'altra questione importante che non tratteremo è quella delle condizioni di errore. Per esempio, si supponga che l'operazione d e 1 e t e sia applicabile solo se l'elemento da cancellare esiste nella tabella. Dovremmo specificare che delete(t,d) produce un errore in questo caso. Inoltre, anche las t ( new ( ) ) dovrebbe risultare in un errore. Il lettore è invitato a migliorare questo e gli altri insiemi con appropriate condizioni di errore introducendo la parola chiave error. 18

operations last:

Queue

first:

all

Queue,

delete:

Queue

last,

[d:

Data;

first, q,

Data;

—>

equalQ:

constrains for

—>

Queue

Data; Queue

—»

equalQ,

qi,q 2 :

last(insert(q,

d))

fir st ( i n s e r t ( q ,

delete,

=

isEmpty,

new,

insert

so

that

d; d)=

d

d))

=

if

new())

=

true;

equalQ(insert(q, equalQ(new(),

Bool;

Queue]

first(insert(new(),

equa1Q(new(),

—»

Queue;

d),

not

new())

insert(q,

d))

isEmpty

=

false;

=

false;

e q u a l Q ( i n s e r t ( q ! , dj ) , i n s e r t ( q 2 , d 2 ) ) =

(q)

then

first

(q);

e q u a 1 D ( d , , d 2 ) and equalQ(q1(q2) ;

de l e t e ( n e w ( ) ) =

new();

de l e t e ( i n s e r t ( n e w () , d ) ) = if

not

equalQ(q,

new())

new();

then

de l e t e ( i n s e r t ( q , d ) ) = end

insert(de 1ete(q ) , d);

QueueAlg.

M

La Figura 5.46(a) riassume le algebre finora definite e le loro reciproche relazioni. Esercizio 5.47

Costruite l'algebra illustrata nella Figura 5.46(b), che completa la Figura 5.46(a). Si noti che l'uguaglianza possiede diversi significati in diverse algebre. Per esempio, i multi-insiemi sono insiemi che consentono di avere elementi ripetuti, in cui l'ordine n o n ha importanza. Q u i n d i , { a , b , a , c } = { b , a , c , a } * { a , b , b , c } . Gli insiemi sono considerati una specializzazione dei multi-insiemi, in q u a n t o possono essere ottenuti aggiungendo ulteriori equazioni alle equazioni di uguaglianza tra multi-insiemi.

I meccanismi introdotti per costruire gerarchie di algebre sfruttano il principio di incrementalità, in quanto permettono la costruzione di nuove algebre complesse modificando quelle già esistenti. L'incrementalità è favorita dai linguaggi di specifica algebrica anche in altri modi: in generale, potrebbe essere difficile ottenere un insieme di equazioni completo e coerente esprimendo le proprietà di un'algebra al primo tentativo, anche facendo uso di meccanismi adeguati di modularizzazione; un tale insieme di equazioni, tuttavia, può essere ottenuto in modo incrementale, partendo da versioni della specifica inizialmente incomplete. Per esempio, consideriamo l'operazione di uguaglianza tra code dell'Esempio 5.8. Inizialmente, si potrebbe scrivere l'equazione e q u a 1 Q ( i n s e r t ( q ! , dj ) , i n s e r t ( q 2 , d 2 ) ) = e q u a 1 D ( d ; , d 2 ) and

e q u a l Q ( q 3 , q2 )

la quale sembra contenere gli aspetti essenziali dell'operazione desiderata. Successivamente, ulteriori equazioni possono essere aggiunte per la gestione di "condizioni estreme". È anche

3t ) ^Trèej Queue i algebra j

Table i algebra j

Queue \algebra} Table algebra J

lContainer\ algebra Array \ algebra/

Bool i algebra J

Legenda :

/Container( lalgebra J

I Datatype » 1 algebra j

ljnport

relation

Bool algebra J

/Data typel lalgebra j

Nat algebra J

relation (b)

Figura 5.46

Gerarchie di algebre, (a) Una gerarchia semplice, (b) Una gerarchia più ricca: SortedDataType eredita da DataType e aggiunge un ordinamento totale (operazione ' < ' ). sortedTable contiene tabelle ordinate, senza elementi duplicati.

possibile procedere in modo opposto: specificare prima i casi speciali per affrontare successivamente il contesto generale. Con entrambe le tecniche, l'insieme delle equazioni viene costruito in maniera incrementale, focalizzando l'attenzione sui diversi aspetti in differenti momenti. In un certo senso, potremmo chiamare le due modalità espresse per sfruttare l'incrementalità incrementality in the large (incrementalità in grande) e incrementality in the small (incrementalità in piccolo). La prima si occupa della costruzione di famiglie di algebre, la seconda della costruzione di una singola algebra. Esercizi 5.48

Perché non aggiungere l'equazione s i z e specifica dell'algebra C o n t a i n e r ?

(insert(c,

d))

=

size(c)

+

1 nella

5.49

Discutete le differenze tra assumes e una o più forme di I N H E R I T S Capitolo 4.

5.50

Q u e u e A l g e T a b l e A l g , entrambe fornite nell'Esempio 5.8, hanno in realtà numerosi aspetti in comune, non solo C o n t a i n e r . Definite "un'algebra intermedia" che contenga tutto quanto le due hanno in comune.

FROM discusse nel

Dalla specifica all'implementazione. L'intero processo di progettazione può essere visto come una catena di passaggi di definizione-implementazione, che spesso risultano in una gerarchia di moduli. La relazione I S _ C O M P O S E D _ O F è un esempio di tale gerarchia. Durante il processo di progettazione, si possono impiegare diversi "linguaggi", a seconda delle necessità espressive; per esempio, si potrebbero scrivere le specifiche iniziali in un linguaggio algebrico, progettare in seguito l'architettura software usando la specifica UML e infine produrre il codice in Java. La transizione tra i diversi livelli della gerarchia definizione-implementazione può procedere più o meno naturalmente, anche a seconda dei linguaggi utilizzati. Per esempio, implementare la gerarchia di moduli presenti nella Figura 5.46(b) in termini di T D N / G D N risulta una transizione abbastanza semplice. Nel caso di Larch, sono stati definiti diversi linguaggi d'interfaccia, con lo scopo di aiutare a compiere una transizione naturale dalle specifiche, scritte nel linguaggio di base studiato in precedenza (chiamato lo shared language di Larch, ovvero il linguaggio condiviso di Larch, in quanto comune a qualsiasi progetto, indipendentemente dal linguaggio di programmazione utilizzato), all'implementazione, che può essere affrontata usando linguaggi diversi. Esiste, quindi, un linguaggio Larch/C++ e un linguaggio Larch/Pascal, e altri ancora possono esserne definiti. Esaminiamo brevemente le caratteristiche essenziali del linguaggio Larch/Pascal. Come primo passo, è piuttosto naturale vedere un'algebra come un tipo di dato astratto. Il modo più ovvio per implementare un tipo di dato astratto in Pascal è quello di usare la dichiarazione di un tipo unita alla dichiarazione di funzioni o procedure. È necessario prendere in considerazione quali siano le differenze più rilevanti tra lo shared Larch e Pascal. Il primo è puramente funzionale-, ovvero, le sue operazioni non modificano alcuno stato di esecuzione. Il secondo è operazionale; ovvero, in esso le procedure e le funzioni possiedono effetti collaterali. Tradurre una specifica scritta in Larch in una notazione simile a Pascal consiste essenzialmente nel definire quali operazioni devono diventare funzioni, quali diventano procedure e quali variabili possono essere modificate dalle procedure. Chiariamo la questione tramite un esempio. Consideriamo l'algebra S t r i n g S p e c definita nell'Esempio 5.6. La sua segnatura, una volta trasformata in Larch/Pascal, è la seguente: type

String {Si

exports

noti

avremmo ci i in based

on

function

che

add,

stata

tolta

potuto

usare

la

avrebbe

costretto

puntatori, grado

di

Boolean, isEmpty

modifies procedure modifies

isEmpty, è

o

at

[]

(var

most

{non ha a l c u n di m o d i f i c a r e

la

le

limitazione di

new;

tipo

infatti,

new,

la

stringhe di

non

stringa

a

function

length at

most

però

essere run-time}

{non s

Boolean ha

alcun

: String;

c

effetto :

collaterale}

char)

[s]

e f f e t t o s u l l o s t a t o di e s e c u z i o n e il p a r a m e t r o s t r i n g a s } ;

modifies

quale usando

character

String):

most

...

Pascal

implementare

variabili

integer,

add

notazione

accettare

creare (s:

at

a

append,

l'operazione

(s: [J;

String)

:

oltre

a

quello

integer (segue a p. succ.)

(segue da p. prec.) procedure modifies

append at

most

(var

s1,s2,s3:

string)

[s3]

{ a n c h e se è s t a t o d e c i s o , p e r c o n v e n i e n z a implementativa, che tutti i parametri delle procedure siano passati p e r r i f e r i m e n t o , in r e a l t à v i e n e m o d i f i c a t a s o l o s 3 ) ;

end

StringSpec.

Questa traduzione dalla segnatura Larch a una notazione più simile a Pascal può essere ottenuta come risultato di una traduzione interattiva, guidata dalla sintassi. Si potrebbero generare automaticamente porzioni di codice interagendo con il progettista, il quale decide se un'operazione debba essere implementata come una procedura o come una funzione e quali elementi, non necessariamente parametri, debbano essere modificabili. Il linguaggio di interfaccia Larch/Pascal è un esempio di tecnica di traduzione che aiuta nel complesso processo che porta dalle specifiche all'implementazione e che potrebbe essere applicata anche ad altri linguaggi di specifica e di implementazione. 5.7.2.2

Modularizzazione di macchine a stati finiti: il caso Statecharts

Il linguaggio Statecharts è la notazione grafica più conosciuta che supporta la specifica basata su descrizioni modulari di macchine a stati finiti. Faremo riferimento qui a due dei meccanismi forniti dagli Statecharts: i superstati e la scomposizione degli stati. Un superstato è uno stato complesso che è ulteriormente raffinato mediante la scomposizione in una macchina a stati finiti. La Figura 5.47 mostra u n o Statechart che esprime la politica di controllo dell'impianto c h i m i c o della Figura 5.15. Si entra nel superstato R e c o v e r y dallo stato N o r m a l n o n appena viene percepita un'anomalia. In questo superstato, si cerca di effettuare una politica di recovery. E possibile definire transizioni da e verso superstati. Qualsiasi transizione, c o m e A n o m a l y D e t e c t i o n , c h e porta a un superstato entra, implicitamente, nello stato iniziale di default del superstato. I n maniera simile, una transizione che esce da un superstato indica una transizione che p u ò uscire da u n o qualsiasi dei suoi stati interni. Nell'esempio, la transizione A n o m a l y D e t e c t i o n porta allo stato R e c o v e r y l d e n t i f i c a t i o n . Inoltre, una transizione chiamata R e c o v e r y S u c c e s s p u ò uscire da u n o qualsiasi degli stati interni (sottostati) per andare nello stato N o r m a l . U n a transizione R e c o v e r y F a i l u r e , inoltre, p u ò uscire da u n o qualsiasi dei sottostati dello stato per andare nello stato finale. U n o stato iniziale viene evidenziato c o n l'uso di una freccia c o n u n cerchio nero p i e n o mentre u n o stato finale viene evidenziato da u n cerchio nero pieno circondato da un cerchio n o n pieno.

Le transizioni degli Statecharts possono essere etichettate. Le etichette, in generale, sono tuple di tre valori: < e v e n t , guard, a c t i o n > , dove e v e n t è ciò che rende attivabile una transizione se l'evento è ricevuto dall'oggetto in un determinato stato, guard è un predicato che deve essere verificato perché possa essere attivata la transizione, e a c t i o n è un'azione atomica eseguibile che viene lanciata durante la transizione. Nel nostro esempio, le transizioni sono etichettate solo con gli eventi. La Figura 5.47 mostra la scomposizione sequenziale di un superstato. Negli Statecharts è anche possibile scomporre un superstato in sottostati concorrenti. La Figura 5.48 illustra

Figura 5.47

Statechart per la descrizione della politica di controllo dell'impianto chimico della Figura 5.1 5.

u n superstato, c h i a m a t o C o n c u r r e n t W o r k , c h e è stato s c o m p o s t o in tre sottostati c o n correnti: u n P r o d u c e r , u n C o n s u m e r e u n B u f f e r , c o m e discussi p r e c e d e n t e m e n t e n e l l ' E s e m p i o 5 . 3 . Si tratta di u n a s c o m p o s i z i o n e in AND, visto c h e in qualsiasi istante cias c u n o dei tre c o m p o n e n t i si trova in u n proprio stato ed evolve in maniera concorrente. L o spazio degli stati del superstato è il p r o d o t t o Cartesiano degli stati dei suoi c o m p o n e n t i , anche se tale spazio degli stati n o n v i e n e espresso esplicitamente. Per rendere le c o s e p i ù chiare, si è a g g i u n t o u n o stato rispetto alla descrizione originale della Figura 5.18, per rappresentare lo stato iniziale da cui v i e n e generato il c o m p o r t a m e n t o concorrente.

Idle Start ,,

Stop

Concurrent Work

Figura 5.48

Statechart per la descrizione di un sistema producer-buffer-consumer in termini di sottostati concorrenti.



Empty

Push(item) Poplstack contains 1 item

Push(item)

Poplstack contains more than 1 itemi

Figura 5.49

Diagramma delle transizioni per un oggetto di stack.

Gli Statecharts sono stati incorporati in UML per descrivere l'evoluzione dinamica degli oggetti di una determinata classe descrivendo come lo stato di un oggetto cambia man mano che gli vengono applicate le operazioni. Per esempio, il diagramma delle transizioni degli stati mostrato nella Figura 5.49 descrive il comportamento degli oggetti della classe STACK. Le etichette delle transizioni descrivono l'operazione invocata come un evento e una serie di condizioni che devono essere verificate perché la transizione sia attivabile. Nell'esempio, non c'è alcuna azione esplicita associata alla transizione. Gli oggetti della classe STACK possono essere manipolati mediante l'inserimento (push) di un oggetto in cima allo stack, mediante il prelievo {pop) dell'ultimo oggetto introdotto e chiedendo informazioni circa l'oggetto immagazzinato in cima allo stack {top). La capacità di scomporre gli Statecharts consente la specifica del comportamento dinamico di oggetti caratterizzati da spazi degli stati molto grandi e complessi. Solitamente, specifiche di questo tipo vengono affrontate scomponendo superstati di livello più alto in sottostati sequenziali. A volte gli oggetti possono essere specificati introducendo sottostati concorrenti. Tornando alla discussione iniziata nel Paragrafo 5.7.1.1, UML è un linguaggio di specifica composto da diversi sottolinguaggi, ognuno indirizzato alla descrizione di determinati aspetti del sistema. Fino ad ora, abbiamo visto alcuni di questi sottolinguaggi: i class diagram, mediante i quali specificare le proprietà statiche di un'architettura orientata agli oggetti, gli Statecharts, mediante i quali aggiungere gli aspetti dinamici dell'evoluzione degli oggetti, e gli activity diagram, mediante i quali fornire una specifica di determinati insiemi concorrenti di attività e delle loro sincronizzazioni. I sottolinguaggi mirano a fornire viste complementari del sistema. Nel Capitolo 6 vedremo come UML fornisca un insieme ancora più ricco di sottolinguaggi e discuteremo come queste notazioni possono essere utilizzate durante il processo di sviluppo. Esercizi 5.51

Descrivete il c o m p o r t a m e n t o dinamico degli oggetti della classe QUEUE. Le operazioni disponibili su tali oggetti sono: ENQUEUE per inserire un elemento nella coda; DE QUEUE per estrarre un elemento da una coda n o n vuota; I S EMPTY per interrogare la coda per scoprire se è vuota; e MERGE per eseguire la fusione di due code.

5.52

Gli Statecharts i n t r o d u c o n o l'utile nozione di history state (letteralmente, "stato della storia"). Di solito, q u a n d o una transizione entra in u n o stato composto sequenziale, entra nel sottostato iniziale (a m e n o che la transizione n o n indichi esplicitamente un altro stato iniziale). Tuttavia, in alcuni casi, può essere utile modellare il fatto che lo stato composto "ricorda" il sottostato che era attivo prima di lasciare lo stato; in questo m o d o , rientrando nello stato composto si potrebbe ricominciare da dove era stato lasciato. Per esempio, nel modellare gli interrupt di un computer vorremo poter specificare che l'azione interrotta dall'attivazione dell'interrupt verrà ripresa d o p o che il sistema ha risposto all'interrupt. U n history state viene rappresentato da un cerchio contenente il simbolo H . Se volessimo che una transizione attivasse il sottostato più recente, indicheremmo una transizione uscente dal superstato che p u n ta direttamente all'history state. Ci sarebbe anche una transizione dallo history state a u n sottostato sequenziale, per indicare qual è il sottostato che viene attivato la prima volta che si entra nel superstato (ovvero, prima che lo stato composto abbia una storia). Descrivete la generalizzazione dell'interruttore della lampada presentato nella Figura 5.13, usando gli stati composti e gli history state. La generalizzazione riguarda un interruttore generale che fornisce corrente al palazzo in cui è situata la lampada. L'interruttore deve essere nella posizione "on" perché la lampada possa operare; q u a n d o l'interruttore generale viene portato nella posizione "on" la lampada p u ò essere accesa o spenta, a seconda dello stato in cui si trovava l'ultima volta che l'interruttore era stato spento.

5.7.2.3

Modularizzazione delle specifiche logiche: il caso di Z

Z è un linguaggio di specifica formale basato sui concetti matematici di insiemi, funzioni e predicati della logica del primo ordine. Il linguaggio è tipizzato, ovvero, i tipi vengono introdotti per definire le entità necessarie per modellare il sistema di interesse. Un sistema è specificato descrivendo il suo spazio degli stati (la collezione di variabili tipizzate che caratterizzano il sistema). Le proprietà dello spazio degli stati sono descritte in termini di predicati invarianti che devono essere sempre verificati man mano che il sistema si sposta di stato in stato. Le transizioni degli stati sono descritte fornendo le relazioni esistenti tra gli input e gli output delle operazioni e i predicati che specificano i cambiamenti risultanti. Gli schemi Z sono i costrutti di modularizzazione impiegati per definire gli stati e come questi vengono alterati dalle operazioni. Nella rappresentazione grafica di uno schema una linea orizzontale separa le dichiarazioni delle variabili degli stati dai predicati. I predicati sono espressi come formule FOT. Il linguaggio Z definisce anche l'effetto delle combinazioni di vari schemi che specificano parti diverse del sistema. Introdurremo Z mediante un esempio che illustra lo stile di specifica imposto dal linguaggio e le caratteristiche più rilevanti della notazione. Faremo riferimento al caso di studio degli ascensori, assumendo per semplicità che esista un solo ascensore nel sistema. Inoltre, sempre per semplificare l'esempio, ignoreremo per il momento le questioni riguardanti il tempo. Cominceremo introducendo i tipi che sono utilizzati nella specifica. Z possiede un tipo che è presente di default, ovvero Z, il quale rappresenta l'insieme dei numeri interi. Sono disponibili anche altri tipi, come N (il quale rappresenta i numeri naturali). E possibile introdurre nuovi tipi usando diversi costrutti linguistici. Ad esempio, la definizione di un tipo libero permette la definizione di un nuovo tipo enumerando i valori che esso comprende. Le prime due linee della Figura 5.50 mostrano due tipi enumerati (SWITCH e MOVE) che possono essere definiti per descrivere, rispettivamente, lo stato di illuminazione dei pulsanti e lo spostamento dell'ascensore.

SWITCH ::= on \ off MOVE ::= up \ down FLOORS: N FLOORS > 0 r— IntButtons IntReq: 1 . . FLOORS

-»SWITCH

— FloorButtons ExlReq : 1 . . FLOORS

-^PMOVE

down & ExtReq( 1) up £ ExtReq(FLOORS) i— Scheduler | NextFloor ToServe : 0 . . FLOORS — Elevator CurFloor : 1 . . FLOORS CurDirection : MOVE Figura 5.50

Frammento di specifica Z per l'esempio dell'ascensore.

Z fornisce un costrutto (chiamato definizione assiomatica) per introdurre nuovi oggetti: questo costrutto definisce le limitazioni che si assume siano sempre verificate quando viene utilizzato l'oggetto all'interno della specifica. La terza linea della Figura 5.50 mostra l'esempio di una costante globale (FLOORS), introdotta per indicare il numero di piani serviti dall'ascensore. Un modo alternativo per fornire la stessa definizione potrebbe essere quello di scrivere FLOORS:

NI

che usa un altro tipo predefinito di Z, ovvero Ni (l'insieme dei numeri naturali positivi). La quarta linea della Figura 5.50 definisce lo schema I n t B u t t o n s che specifica i pulsanti interni all'ascensore. Questi vengono rappresentati come una funzione i n t R e q che ha come dominio i piani e definisce l'illuminazione di un pulsante. La funzione è specificata fornendo il tipo del dominio e il tipo del codominio; il simbolo —» indica un funzione totale, il simbolo indica una funzione parziale. Nell'esempio, il dominio della funzione è definito dal tipo 1. . . FLOORS. Questo è un altro modo per definire un nuovo tipo in Z, ovvero come un sottoinsieme finito di valori interi. Il codominio della funzione è definito dal tipo SWITCH. F l o o r B u t t o n s è u n altro e s e m p i o di s c h e m a Z . I n questo caso, il tipo del c o d o m i n i o della f u n z i o n e E x t R e q è d e f i n i t o c o m e i n s i e m e delle parti (powerset, P) del tipo MOVE. C i ò

— System Elevator IntButtons FloorButtons Scheduler NextFloorToServe * 0 => IntReqiNextFloorToServe) = on V ExtReq(NextFloorToServe) + 0

Figura 5.51

Specifica Z dell'intero spazio degli stati: primo tentativo.

significa che una richiesta esterna associa a un piano un i n s i e m e di spostamenti. L'insieme è v u o t o se n o n esiste alcuna richiesta p e n d e n t e proveniente da u n qualche piano. Se n o n è v u o t o , la "prenotazione" p u ò essere per u n o s p o s t a m e n t o verso l'alto o per u n o s p o s t a m e n t o verso il basso, o entrambe le cose. In contrasto c o n lo schema I n t B u t t o n s , F l o o r B u t t o n s possiede anche una sezione, separata da una linea orizzontale, in cui s o n o espressi d u e predicati. Si richiede che questi predicati siano sempre veri: la loro c o n g i u n z i o n e rappresenta una proprietà invariante per le variabili dello schema. In questo e s e m p i o , l'invariante esprim e s e m p l i c e m e n t e che u n o s p o s t a m e n t o verso il basso a partire dal p r i m o piano è impossibile, così c o m e u n o s p o s t a m e n t o verso l'alto a partire dall'ultimo piano.

I successivi due schemi specificano altri due componenti dello stato del sistema. Il componente S c h e d u l e r è responsabile dell'indicazione, in ogni istante, di quale sia il successivo piano da servire. Useremo il piano fittizio 0 per rappresentare il fatto che non deve essere visitato alcun piano, ovvero che non ci sono richieste pendenti. Lo schema E l e v a t o r modella lo stato dell'ascensore mediante una coppia: il piano corrente e la direzione corrente di spostamento. Vedremo che anche un ascensore fermo possiede una direzione corrente: la direzione in cui si stava spostando quando è arrivato al piano. La Figura 5.51 mostra c o m e gli s c h e m i che d e f i n i s c o n o i vari c o m p o n e n t i del sistema possano essere c o m b i n a t i per definire lo stato dell'intero sistema. Includendo le dichiarazioni E l e v a t o r , I n t B u t t o n s , E x t B u t t o n s e S c h e d u l e r , lo s c h e m a S y s t e m implicitam e n t e include sopra la linea orizzontale tutte le dichiarazioni fatte in quegli s c h e m i e sotto la linea orizzontale tutti i loro invarianti. Inoltre, è fornito u n invariante aggiuntivo c o m e p r i m o tentativo di vincolare in una specifica le diverse variabili degli stati dei c o m p o n e n t i per ottenere le proprietà desiderate dello stato del sistema globale. N e l l ' e s e m p i o , viene definito un invariante m o l t o semplice che vincola lo scheduler specificando che se un p i a n o è il prossimo da servire, allora deve essere p e n d e n t e una richiesta che riguarda quel piano: potrebbe trattarsi di una richiesta interna, fatta per fermare l'ascensore a quel piano, o di una richiesta esterna, fatta a quel piano per andare verso l'alto o verso il basso. È facile accorgersi che questo invariante è m o l t o debole. N o n garantisce affatto che venga fornito u n valore diverso da zero, da parte dello scheduler, per indicare il prossimo p i a n o da servire, q u a n d o ci s o n o richieste pendenti. I n f a t t i , l'invariante è soddisfatto a n c h e se N e x t F l o o r T o S e r v e è 0 q u a n d o esiste una richiesta p e n d e n t e . Per rimuovere questo difetto, nella Figura 5.52 viene fornita una n u o v a versione dello s c h e m a S y s t e m . Il n u o v o invariante fornito è una c o n g i u n z i o n e tra quello precedente e u n o n u o v o , il quale assicura che se N e x t F l o o r T o S e r v e è 0 allora tutti i piani f s o d d i s f a n o la c o n d i z i o n e per cui l n t R e q ( f ) è o f f e l'insieme di E x t R e q ( f ) è vuoto.

— System Elevator IntButtons FloorButtons Scheduler NextFloorToServe ^ 0 => IntReqiNextFloorToServe) = on\y ExtReq(NextFloorToServe) * 0 NextFloorToServe = 0 => (V f: 1 . . FLOORS • (IntReq(f) = off A ExtReq(f) = 0))

Figura 5.52

Specifica Z dell'intero spazio degli stati: secondo tentativo.

La nuova versione dell'invariante nella Figura 5.52 è una lista di due predicati che riguardano i due seguenti casi: (a) esiste un successivo piano da servire e (b) non ci sono piani da servire. In Z, i predicati in una lista sono implicitamente combinati in una congiunzione. Il secondo predicato dice che se N e x t F l o o r T o S e r v e è 0 (e cioè, non esiste un successivo piano da servire), allora non ci sono richieste interne o esterne pendenti. Si noti che il simbolo • funge soltanto da delimitatore nei quantificatori universali ed esistenziali. L'invariante fornito nella Figura 5.52 è ancora troppo debole. Secondo questa specifica, qualsiasi piano per cui esiste una richiesta pendente può essere scelto come il successivo piano da servire. La specifica non garantisce invece l'ovvio requisito che tutte le richieste fatte dagli utenti vengano prima o poi soddisfatte. Per soddisfare questo requisito, nella Figura 5.53 viene fornita una nuova specifica della politica di schedulazione. L'invariante (piuttosto complesso) è un predicato esistenziale che gestisce separatamente il caso dell'ascensore in salita e quello dell'ascensore in discesa. I due casi sono, comunque, gestiti in modo simile. L'idea dietro questa nuova specifica è che, nel selezionare il successivo piano da servire, l'ascensore debba procedere nella direzione di spostamento corrente, fino a quando non ci sono più richieste da soddisfare procedendo in quella direzione. Si consideri, per esempio, il caso di un ascensore che si sta spostando verso l'alto. L'insieme Pril indica tutti i piani superiori a quello attuale, per i quali esistono richieste interne o esterne per un "passaggio" nella direzione "verso l'alto". Se tale insieme non è vuoto, il successivo piano che l'ascensore visiterà sarà quello più in basso tra questi. Altrimenti, se l'insieme è vuoto, l'ascensore sceglierà tra le richieste di fermata ai piani nel percorso in discesa e tra le prenotazioni interne di fermata ai piani inferiori rispetto a quello corrente. Questi piani costituiscono l'insieme Pri2. Tra i piani compresi in Pri2, il primo da servire sarà quello che si trova più in alto. Se anche Pri2 è vuoto, allora lo schedulatore considererà l'insieme Pri3 di piani che si trovano a un livello inferiore rispetto a quello corrente, per cui esistono richieste pendenti per "passaggi" verso l'alto. Tra tutti i piani compresi in Pri3, lo schedulatore selezionerà, come successivo piano da visitare, quello più basso. Se è vuoto anche Pri3 allora non esiste un successivo piano da visitare; l'ascensore rimarrà quindi fermo al piano corrente. Questo esempio sottolinea un punto importante: se vogliamo cambiare la politica adottata dall'ascensore per scegliere come servire richieste pendenti, tutto quello che dobbiamo fare è cambiare il predicato invariante incapsulato nello schema S Y S T E M . Gli altri schemi non cambieranno. Ciò rappresenta uno degli effetti benefìci della modularità delle specifiche Z.

I— System Elevator IntButtons Floor Buttons Scheduler 3 Pr/1, Pr/2, Pr/3 : PM, • CurDirection =up => (Pr/1 = (f: 1 . FLOORS \ f > CurFloorA (IntReqif) = on\J up & (ExtReq(f))} A Pr/2 = (f: 1 . ,FLOORS\down G ExtReq(f) \J(f< CurFloor A IntReqlf) = on)} A Pr/3 = ( f: 1. . FLOORS \ f< CurFloorA up G ExtReq(f)} A ((Pr/1 ^ 0 A NextFloorToServe = min(Prn)) V (Pr/1 = 0 A Pr/2 + 0 A NextFloorToServe = max(Pri2)) V (Pr/1 = 0 A Pr/2 = 0 A Pr/3 0 A NextFloorToServe = min(Pri3)) V (Pr/1 = 0 A Pr/2 = 0 A Pr/3 = 0 A NextFloorToServe = 0))) A CurDirection - down => (Pr/1 = 1 . . FLOORS | CurFloorA (IntReqif) = on V down G ExtReq(f))} A Pr/2 = (f: 1 . . FLOORS \ up Curfloor A IntReqd) = on)) A Pr/3 = (f: 1 . . FLOORS \ f > CurFloor A down G ExtReq(f)} A ((Pr/1 ^ 0 A NextFloorToServe = max(Pri1)) V (Pr/1 = 0 A Pr/2 * 0 A NextFloorToServe = min(Pri2)) V (Pri 1 = 0 A Pr/2 = 0 A Pr/3 0 A NextFloorToServe = max(Pri3)) V (Pr/1 = 0 A Pr/2 = 0 A Pri3 = 0 A NextFloorToServe = 0))) Figura 5.53

Specifica Z dell'intero spazio degli stati: versione finale.

Per procedere con il nostro esercizio di specifica, è necessario descrivere l'effetto delle operazioni sullo stato del sistema. Introdurremo le seguenti operazioni, specificate formalmente nella Figura 5.54 (parti a e b): •

MoveToNextFloor,

descrive l'effetto dello s p o s t a m e n t o da un piano a quello

successivo; •

i n t e r n a l P u s h , descrive l ' e f f e t t o della p r e s s i o n e di u n p u l s a n t e i n t e r n o ;



E x t e r n a l P u s h , descrive l ' e f f e t t o della p r e s s i o n e di u n p u l s a n t e esterno;



S e r v e l n t R e q , descrive l ' e f f e t t o di "servire u n a richiesta" di f e r m a t a a u n p i a n o fatta p r e m e n d o un pulsante interno;



S e r v e E x t R e q S a m e D i r , descrive l ' e f f e t t o di "servire u n a richiesta" di f e r m a t a a u n p i a n o e f f e t t u a t a p r e m e n d o u n p u l s a n t e e s t e r n o , e r i c h i e d e n d o c h e l'ascensore c o n t i n u i a spostarsi n e l l a stessa d i r e z i o n e ;



S e r v e E x t R e q O t h e r D i r , descrive l ' e f f e t t o d i "servire u n a richiesta" d i f e r m a t a a u n p i a n o e f f e t t u a t a p r e m e n d o u n p u l s a n t e e s t e r n o , e r i c h i e d e n d o c h e l'ascensore c a m b i d i r e z i o n e di s p o s t a m e n t o .

Tutte

queste operazioni p o s s o n o alterare l o stato del sistema, fatto c h e v i e n e espresso scriven-

d o "A s c h e m a _ n a m e " nella parte di d i c h i a r a z i o n e della specifica. Per e s e m p i o , l'operazione M o v e T o N e x t F l o o r p u ò m o d i f i c a r e S y s t e m . L a parte predicativa dell'operazione c o n t i e n e u n a lista di predicati c h e ( c o m e a b b i a m o visto) s o n o i m p l i c i t a m e n t e c o m b i n a t i m e d i a n t e u n

and

logico.

Alcuni

di questi predicati f a n n o u s o di n o m i di variabili c o n u n apice c o m e suf-

— MoveToNextFloor A System NextFloorToServe 0 CurFloor NextFloorToServe CurFloor > NextFloorToServe => CurFloor1 = CurFloor - 1 A CurDirection' CurFloor < NextFloorToServe => CurFloor' = CurFloor + 1 A CurDirection' BlntButtons' = BlntButtons BFIoorButtons' = BFIoorButtons

=

down

= up

— InternalPush ASystem f? : 1 . . FLOORS IntReq' = IntReq © {f? >->• on) 8Elevator'= BEIevator BFIoorButtons'= BFIoorButtons — ExternalPush ASystem fV. 1 . . FLOORS diri : MOVE ExtReq' = ExtReq e 1 (fi -» (ExtReq BEIevator'= BEIevator BlntButtons'= BlntButtons

(fi) U (dir?)))}

— ServelntRequest ASystem NextFloorToServe = CurFloor IntReqfCurFloor) = on IntReq' = IntReq® {(CurFloor^ ExtReq' = ExtReq CurFloor1 = CurFloor CurDirection' = CurDirection

Figura 5.54 a

off)}

Specifica Z delle operazioni dell'ascensore.

fìsso (come nel caso di C u r F l o o r nello schema M o v e T o N e x t F l o o r ) . Per convenzione, l'apice denota il valore di una variabile dopo che è stata portata a termine l'operazione. Un'altra convenzione usata in Z è l'aggiunta di "?" c o m e suffisso al n o m e di una variabile, c o m e f nel caso dello schema I n t e r n a l P u s h . C i ò indica il parametro in ingresso dell'operazione. In maniera simile (anche se n o n viene fornito alcun esempio a proposito), un "!" p u ò essere usato c o m e suffisso per indicare un parametro in uscita dell'operazione. U n predicato che n o n fa uso né di una variabile c o n l'apice né di una variabile con il suffisso "?" è detto precondizione, visto che indica una proprietà dello stato prima di eseguire l'operazione in questione. U n predicato che contiene variabili c o n l'apice o variabili con il suffisso "!" viene detto postcondizione, in quanto indica vincoli sui valori che d e v o n o essere verificati d o p o che l'operazione in questione è stata portata a termine. Si osservi che le operazioni s o n o o eventi o operazioni esterne. Un evento è un'operazione che avviene in maniera spontanea q u a n d o le sue precondizioni d i v e n t a n o vere (nell'esempio, M o v e T o N e x t F l o o r , S e r v e l n t R e q , S e r v e E x t R e q S a m e D i r e S e r v e E x t R e q O t h e r D i r ) . \}rìoperazione esterna è un'operazione che deve essere esplicitamente attivata dall'ambiente esterno (nell'esempio, I n t e r n a l P u s h ed ExternalPush).

— ServeExtRequestSameDir ASystem NextFloorToServe = CurFloor IntReqiCurFloor) = off CurDirection E ExtReqiCurFloor) IntReq' = IntReq ExtReq' = ExtReq © ((CurFloor-> (ExtReqiCurFlooi1 CurFloor" = CurFloor CurDirection' = CurDirection

\

ICurDirection])))

— ServeExtRequestOtherDir A System NextFloorToServe = CurFloor IntReqiCurFloor) = off CurDirection i ExtReqiCurFloor) IntReq' = IntReq ExtReq' = ExtReq © [(CurFloor •-»0)} CurFloor' = CurFloor CurDirection' = CurDirection — Systeminit System' Vi: 1 . . FLOORS • IntReq'(i) NextFloorToServe' = 0 CurFloor1 = 1 CurDirection' = up

Figura 5.54 b

= off A ExtReq'(ì)

= 0

Specifica Z delle operazioni dell'ascensore.

La notazione OX' = OX indica che nessun c o m p o n e n t e dello schema X cambia per via dell'operazione. Il simbolo © usato dalle operazioni i n t e r n a l P u s h ed E x t e r n a l P u s h è un operatore di modifica (override) applicato alle funzioni. U n a f u n z i o n e è vista c o m e una coppia di elementi, il primo proveniente dal d o m i n i o , il s e c o n d o dal c o d o m i n i o . L'operatore © fornisce una funzione che contiene le stesse coppie dell'operando sinistro, solo che le coppie dell'operando destro sostituiscono quelle dell'operando sinistro nel caso abbiano lo stesso prim o elemento. Per esempio, d o p o l'esecuzione di un pressione interna per il piano f , il n u o v o valore in I n t R e q è uguale al valore prima dell'esecuzione dell'azione, eccetto che la luce del pulsante del piano f è illuminata. La notazione { f ? —» o n } indica un insieme contenente una semplice coppia. L'insieme indica una funzione che m a p p a il valore di input f ? sul valore "on". U n altro simbolo di Z che richiede spiegazioni è l'operatore "\" che appare nel primo degli schemi della Figura 5.54 (parte b), il quale indica la differenza tra insiemi. Si osservi che gli schemi S e r v e l n t R e q , S e r v e E x t R e q S a m e D i r e S e r v e E x t R e q O t h e r D i r descrivono le azioni che s p e n g o n o le luci dei pulsanti q u a n d o l'operazione richiesta è stata portata a termine. Per esempio, l'ultimo schema descrive il soddisfacimento di una richiesta da un piano che richiede un c a m b i a m e n t o nella direzione di spostamento dell'ascensore. Secondo l'invariante del sistema questa azione p u ò avvenire solo se n o n esiste alcuna altra richiesta pendente che consente all'ascensore di continuare a spostarsi nella direzione corrente. Inoltre, si noti che un c a m b i a m e n t o nella direzione di spostamento dell'ascensore avviene solo c o m e conseguenza dell'operazione M o v e T o N e x t F l o o r .

Una volta definiti lo stato del sistema e le operazioni che lo possono modificare, il passo finale è quello di specificare l'inizializzazione del sistema. L'inizializzazione è descritta dal-

lo schema S y s t e m l n i t , il quale può essere visto come risultato di un'operazione che non necessita di un uno stato del sistema prima di essere eseguita, ma che fornisce semplicemente un nuovo stato. La specifica dell'ascensore è ora completa e consiste degli schemi che appaiono nelle Figure 5.50, 5.53 e 5.54. Una volta fornita una specifica, diventa necessario verificarla. Nel caso della specifica Z, il processo di analisi può consistere nella verifica che la specifica sia sintatticamente corretta e non contenga errori di tipo. Gli altri controlli sono di natura semantica. Per esempio, si può anche controllare che lo stato iniziale non sia vuoto, ovvero che sia composto da valori delle variabili di stato tali da soddisfare sia il predicato dello schema I n i t che l'invariante. Tutti questi tipi di analisi possono essere effettuati manualmente, ma possono essere anche supportati da strumenti che assistono colui che compone la specifica durante la stesura e nel controllo delle specifiche formali. Esercizi 5.53

Modificate la specifica discussa in questo paragrafo introducendo la porta dell'ascensore e le operazioni per la sua apertura e chiusura.

5.54

Modificate la specifica dell'ascensore, assumendo che l'ascensore si sposti in continuazione dal primo all'ultimo piano e viceversa, invece di rispondere alle richieste interne ed esterne degli utenti.

5.7.3

Specifiche per l'utente finale

Le specifiche possono essere utilizzate come punto di riferimento comune sia per il produttore sia per l'utente di un'applicazione. Per questo scopo, una specifica scritta in un formalismo matematico non risulta particolarmente utile in quanto non tutti sono in grado di capire tali formalismi. Il più delle volte, l'unico linguaggio comune tra produttori e utenti è il linguaggio naturale. L'affermazione precedente non vuole scoraggiare l'uso del linguaggi formali. Piuttosto, le descrizioni formali di sistemi possono essere utilizzate per aiutare l'interazione tra produttori e utenti in due modi. Innanzitutto, una volta che è stato utilizzato il modello formale per individuare e togliere ambiguità da specifiche informali e imprecise, è facile ritradurre la formalizzazione in una descrizione in linguaggio naturale. Il vantaggio di questo approccio è che la descrizione che ne risulta non soffre dei problemi di ambiguità o incompletezza che sovente attanagliano le descrizioni informali iniziali. Per esempio, nel Paragrafo 5.5.4.2 è stato evidenziato come le specifiche informali del sistema di ascensori non esprimessero chiaramente quando le luci dei pulsanti dovessero essere spente. Una volta ottenuta una definizione precisa mediante la formalizzazione, non è risultato difficile riformularla tramite una frase in linguaggio naturale che indicasse in maniera precisa quanto desiderato. In secondo luogo, ci si potrebbe spingere oltre e costruire un prototipo del sistema fornendo un interprete del linguaggio di specifica. Il prototipo potrebbe essere fornito all'utente in modo da consentirgli di controllare se il produttore abbia recepito i requisiti in maniera corretta. Chiaramente, in alcuni casi il prototipo può essere utilizzato direttamente dall'utente, in altri no. Un'interfaccia utente adeguata può rendere un prototipo gestibile direttamente dall'utente.

Un'ulteriore considerazione che può essere tratta dall'esempio dell'ascensore è che la specifica PN potrebbe non essere compresa dall'utente finale. Potremmo, allora, utilizzare la descrizione in PN come forma interna e fornire all'utente una vista astratta in termini di simboli intuitivi. L'utente finale, per esempio, potrebbe vedere un simbolo rappresentante l'ascensore che sale e scende sul monitor. Questo potrebbe essere il risultato della traduzione del flusso di gettoni nella PN in termini di spostamento di un simbolo grafico che rappresenta l'ascensore. Inoltre, l'utente potrebbe ritrovare sul monitor una serie di pulsanti da cliccare con il mouse, i quali corrisponderebbero alla pressione di pulsanti veri nel sistema reale, etc. Una tale attività viene spesso detta animazione del sistema. I modelli formali ed eseguibili facilitano la produzione di animazioni dei requisiti.

5.8

Osservazioni conclusive

Questo capitolo è stato dedicato alla specifica. La specifica è una descrizione precisa, che può aiutare a documentare molti aspetti in un progetto di ingegneria del software, come: •

ciò di cui hanno bisogno gli utenti di un sistema (specifica dei requisiti);



il progetto di un sistema software (specifica progettuale e architetturale);



le funzionalità offerte da un sistema (specifica funzionale);



le caratteristiche prestazionali di un sistema (specifica prestazionale);



il comportamento esterno di un modulo (specifica dell'interfaccia di un modulo);



la struttura interna di un modulo (specifica della struttura interna).

A seconda della propria natura, la specifica è rivolta a una ben definita categoria di utenti: il progettista del sistema, l'implementatore del sistema, l'utente finale, il manager del sistema, etc. Le tecniche di specifica di cui si presenta la necessità per questi differenti casi possono fare riferimento a principi simili, anche se non identici. Dopo aver discusso le qualità più importanti delle specifiche, è stata fornita una classificazione delle tecniche di specifica in termini di specifiche formali o informali e operazionali o descrittive. Sono state presentate le più importanti tecniche operazionali come i diagrammi di flusso dei dati, le macchine a stati finiti, gli Statecharts e le reti di Petri, e le più importanti tecniche descrittive, come i diagrammi entità-relazione, i diagrammi delle classi, le specifiche logiche e quelle algebriche. I diagrammi di flusso dei dati e i diagrammi entità-relazione sono esempi di approcci semiformali; le specifiche logiche sono un esempio di approccio formale. Sono stati confrontati gli approcci operazionali e quelli descrittivi, usando come caso di studio un sistema di ascensori. Le specifiche possono essere documenti di grande dimensione e di notevole complessità, che evolvono durante la loro vita. Esiste pertanto la necessità di poter disporre di principi e tecniche per la gestione dello sviluppo delle specifiche. Abbiamo visto, quindi, come i principi presentati nei Capitoli 3 e 4 possano essere applicati, oltre che al software, anche alla stesura e alla gestione delle specifiche. Sono stati approfonditi i requisiti di gestione della stesura di specifiche complesse ed è stato analizzato il loro impatto sui diversi stili di spe-

cifica. È stato sottolineato il ruolo chiave della modularità nella gestione di specifiche complesse, e abbiamo mostrato come diversi stili di specifica possano essere utilizzati per lo sviluppo di specifiche modulari. Sono stati illustrati i linguaggi di specifica UML, Larch e Z che supportano queste caratteristiche e, in particolare, abbiamo discusso come il linguaggio di specifica Z fornisca un modo per modularizzare specifiche E stato evidenziato come non esista uno stile o un linguaggio di specifica ideale. Considerata la varietà dei dettagli che devono essere specificati in un sistema complesso, il miglior approccio è quello di utilizzare stili e linguaggi differenti, per raggiungere obiettivi diversi, a seconda dei vari stadi del processo di sviluppo, per le differenti parti del sistema, per differenti possibili punti di vista o addirittura per i differenti utenti cui può essere destinata la specifica. Un approccio così eclettico può comprendere un insieme di specifiche sia formali che informali, descrittive o operazionali. UML ne è un esempio. Ovviamente, i benefici che si possono ottenere in termini di espressività si pagano in termini di difficoltà nell'ottenere un'integrazione semplice delle diverse notazioni. Ciò spiega anche il motivo per cui risulta così difficile formulare una semantica rigorosa e unificata per i vari diagrammi UML che specificano un sistema. Infine, abbiamo discusso l'impiego delle specifiche per migliorare l'interazione tra l'utente finale e lo sviluppatore, incentrando l'attenzione sull'animazione del sistema e la prototipizzazione rapida. La letteratura riguardante la specifica del software contiene una dicotomia tra la specifica e la prototipazione. Alcuni sostengono che sia necessario procedere da una specifica (non eseguibile) al codice, mentre altri propongono la costruzione di un prototipo che rappresenti una prima versione del sistema, che verrà in seguito ulteriormente sviluppato fino a costruire il sistema finale. Abbiamo invece sottolineato che non esiste alcuna contraddizione tra i due approcci: si può, ad esempio, cominciare con una specifica eseguibile che rappresenta il prototipo. Le questioni organizzative che riguardano questo modo di operare verranno affrontate nel Capitolo 7. Nel Capitolo 6, invece, esamineremo uno tra i più importanti utilizzi delle specifiche: la verifica. Ulteriori esercizi 5.55

Considerate la seguente specifica dell'istruzione c h a n g e di u n editor di testi: change for

any

(P/q)

is

defined

sequence if

p

of

= s

end

s

in

text

then

replace end

by:

characters p

by

q;

if ;

change ;

La specifica è formale o informale? È operazionale o descrittiva? È ambigua? 5.56

Costruite una F S M che accetti l'insieme di stringhe contenenti un n u m e r o pari di 0 e un numero pari di 1, e che cominciano con 1 e finiscono con 0.

5.57

Fornite la descrizione mediante FSM di un sistema che consiste di diversi programmi indipendenti che condividono la stessa C P U , la stessa memoria e le stesse periferiche I / O . La FSM deve permettere l'accesso alle risorse in m u t u a esclusione.

5.58

Modificate l'esercizio precedente considerando u n sistema che possiede diverse istanze delle risorse fisiche ( C P U , dischi, etc.) in cui i programmi possono condividere file. Il modello usato nella soluzione dell'esercizio precedente è ancora valido? Perché?

Figura 5.55

Porzione di una rete di Petri.

5.59

Dimostrate che una qualsiasi FSM p u ò essere "tradotta" in una P N equivalente, ovvero una P N il cui insieme di possibili sequenze di scatti è uguale alle sequenze di transizione della FSM. Inoltre, dimostrate che l'operazione inversa n o n è sempre possibile. C o m e si p u ò analizzare la proprietà di assenza di deadlock mediante FSM?

5.60

II tempo necessario per elaborare alcuni dati spesso dipende dalle proprietà dei dati stessi. Per esempio, ordinare una sequenza di elementi dipende dalla lunghezza della sequenza. Immaginate che una transizione di una P N modelli u n processo di questo tipo. Fornite una modifica del modello P N temporizzate in grado di gestire casi di questo tipo.

5.61

Considerate la porzione di una P N fornita nella Figura 5.55. Supponete di voler modellare un requisito del tipo: "Un gettone prodotto nel posto P 3 può essere consumato o attraverso la transizione t j o attraverso la transizione t 2 , entro u n intervallo di tempo t „ „ . Se n o n scatta nessuna delle due alternative, il gettone non può più essere consumato." Dimostrate che le P N temporizzate, come definite in questo capitolo, non possono descrivere un requisito di questo tipo. Impostate un m o d o alternativo di definire le P N temporizzate che possa gestire casi di questo tipo.

5.62

Si completi la specifica del sistema di ascensori fornita nel Paragrafo 5.5.4.2 con la descrizione di u n pulsante di emergenza e dei suoi effetti.

5.63

Completate la specifica di u n sistema di ascensori fornita nel Paragrafo 5.5.4.2 descrivendo l'apertura e la chiusura delle porte.

5.64

Modificate la specifica del sistema di ascensori fornita nel Paragrafo 5.5.4.2 p r e n d e n d o in considerazione la dipendenza di A t , il tempo necessario per spostarsi da un piano a quello successivo, dal fatto che l'ascensore si fermi o m e n o a u n o dei due piani. Considerate anche il caso in cui l'ascensore riparte da u n piano dove si era fermato.

5.65

Fornite una sintetica rappresentazione del sistema di ascensori descritto nel Paragrafo 5.5.4.2 in termini di FSM. N o n è necessario occuparsi di tutti i dettagli. Indicate, piuttosto, cosa sono in grado di descrivere le FSM e dove, invece, n o n riescono a esprimere i requisiti desiderati.

5.66

Utilizzate le P N per modellare l'organizzazione e l'automazione di un ufficio. Descrivete tutte le attività rilevanti svolte al suo interno (produzione di documenti, calcolo di ricevute e stipendi, etc.), i dati coinvolti, i modi in cui le attività operano sui dati e le relazioni di precedenza esistenti tra le diverse attività (ad esempio, n o n è possibile spedire una lettera che non è ancora stata scritta). N o n è necessario produrre una lunga lista di attività, ma ponete piuttosto la vostra attenzione su poche attività e analizzatele dettagliatamente. Fate riferimento al Paragrafo 5.7.1 per trarre indicazioni riguardanti questo progetto.

5.67

Migliorate il diagramma E R della Figura 5.41 in m o d o che possano essere specificati i prerequisiti per ciascuna classe (per esempio, quali classi devono essere seguite prima di affrontare quella in questione).

5.68

Fornite u n diagramma ER che completi il D F D della Figura 5.5, in m o d o da descrivere le entità coinvolte nell'applicazione e le relazioni esistenti tra loro.

5.69

Fornite una specifica logica per un p r o g r a m m a che calcola il n u m e r o intero radice quadrata di u n n u m e r o intero n o n negativo.

5.70

Migliorate la specifica dell'Esercizio 5.69 i m p o n e n d o il requisito che, se il valore in ingresso è negativo, viene stampato un appropriato messaggio di errore.

5.71

Ritornate alla specifica della procedura s e a r c h ( n u m e r o 7) fornita nel Paragrafo 5.6.2.2. La procedura risulterebbe ancora valida nel caso la variabile t a b i e fosse una variabile globale invece di u n parametro in ingresso?

5.72

Modificate la specifica logica del sistema di ascensori p r e n d e n d o in considerazione l'accelerazione degli ascensori.

5.73

Riprendete la P N presente nella Figura 5.33. È incompleta? È inconsistente? Perché?

5.74

Descrivete, sia con u n o stile operazionale sia con u n o stile descrittivo, il seguente problema (non la sua soluzione!). Un contadino deve trasportare una capra, un cavolo e un lupo da una sponda all'altra di un fiume. Possiede una barca che, però, può trasportare contemporaneamente, oltre al contadino, solo due elementi tra la capra, il cavolo o il lupo. Ovviamente, qualora fossero lasciati incustoditi sulla stessa sponda o nella barca, la capra mangerà il cavolo. Lo stesso accadrebbe tra il lupo e la capra.

C o n f r o n t a t e le due specifiche da diversi p u n t i di vista. 5.75

Illustrate, sia con u n o stile operazionale che con u n o stile descrittivo, il fatto che un determinato n u m e r o di processi possano accedere a u n determinato n u m e r o di risorse in m u t u a esclusione. C o n f r o n t a t e le due specifiche.

5.76

Scrivete u n d o c u m e n t o che confronta, da differenti punti di vista, la specifica del Paragrafo 5.5.4.2 con la specifica del Paragrafo 5.6.2.5.

5.77

Considerate il seguente esempio di una specifica real-time che n o n coinvolge sistemi critici. C o n molte interfacce a finestre u n utente p u ò fare u n clic o u n d o p p i o clic con il mouse. D u e clic effettuati entro u n determinato arco di t e m p o A t , h a n n o un significato differente rispetto a quelli separati da u n arco di t e m p o più lungo. Descrivete la semantica del clic e del doppio clic sia con u n o stile operazionale (ad esempio mediante reti di Petri temporizzate) che con u n o stile descrittivo. C o n f r o n t a t e le due specifiche.

5.78

Costruite u n interprete di P N in grado di lavorare sia in modalità interattiva che in modalità batch. Nel primo caso, il n o n determinismo del modello p u ò essere risolto dall'utente attraverso u n dialogo. Nel secondo caso, le scelte n o n deterministiche dovrebbero essere prese in maniera casuale permettendo, tuttavia, all'utente di specificare, prima dell'esecuzione del com a n d o in questione, alcune opzioni di schedulazione sulla base di esperienze precedenti. Per esempio, potrebbe essere specificata "l'esecuzione completa". In questo caso, l'interprete dovrebbe fornire tutte le sequenze di scatti, fino a un determinato punto. In alternativa, potrebbe essere specificato che occorre iniziare da una sequenza di scatti già esistente e richiedere alcuni cambiamenti riguardanti le scelte di scatti per ottenere una nuova sequenza, evoluzione della precedente.

5.79

C o m e si potrebbe analizzare la proprietà di equivalenza tra due diversi sistemi descritti in termini di FSM? Si potrebbe fare uso di u n interprete di FSM?

5.80

Fornite una descrizione completa del sistema bibliotecario illustrato nelle Figure 5.4 e 5.5, integrando eventualmente l'uso dei D F D con altre notazioni.

5.81

Fornite una specifica algebrica del m o d u l o F I F O C A R S introdotto nell'Esempio 4.9

5.82

Discutete un arricchimento del linguaggio di specifica di Larch descritto nel Paragrafo 5.7.2.1, che prevede l'uso di costrutti generici.

5.83

Discutete le differenze tra la specifica del c o m p o r t a m e n t o di determinati oggetti di una data classe mediante l'uso di (1) Statecharts, (2) di un linguaggio algebrico c o m e Larch, e (3) di pre/post condizioni e invarianti.

5.84

Definite u n linguaggio di specifica-progettazione per equazioni algebriche basato su Larch e sulla notazione di progettazione discussa nel Paragrafo 4.2.3.1. Fornite esempi di utilizzo del linguaggio e create una bozza della sua traduzione in un linguaggio di programmazione. Lo scopo è quello di spostarsi dalla definizione di un'algebra alla definizione di u n tipo di dato astratto specificando quali operazioni diventano procedure e quali funzioni.

5.85

Fornite esempi di traduzioni di D F D in "scheletri" di codice di u n determinato linguaggio di programmazione (ad esempio, la parte dichiarativa di u n pacchetto Ada). N o n è necessario che definiate algoritmi per la traduzione.

5.86

Discutete come definire e costruire P N modulari.

5.87

Fornite regole per la costruzione di D F D complessi usando il raffinamento per passi successivi di D F D di livello più alto.

5.88

Modificate la politica dell'ascensore nella specifica Z presente nel Paragrafo 5.7.2.3 supponendo che il piano più alto sia destinato ai VIP. Qualsiasi richiesta esterna proveniente dal piano V I P deve essere servita con una priorità più alta rispetto alle altre richieste.

Suggerimenti e tracce di soluzione 5.19

È necessario aggiungere transizioni che rimuovano i gettoni che corrispondono a copie dei messaggi, che n o n vengono rimossi dallo scatto di una transizione di "voto".

5.20

a. Nel p u n t o 4, cosa significa che tutti i piani h a n n o lo stesso livello di priorità? •

C h e tutti i piani devono essere serviti in maniera n o n deterministica, a patto che vengano serviti tutti prima o poi?



C h e tutti i piani devono essere serviti seconda una politica di tipo FIFO?



Considerate il caso in cui sono presenti due ascensori al piano 2, ed esistono due richieste: una per il sessantesimo piano e l'altra per il terzo piano. Entrambi gli ascensori vanno prima al sessantesimo piano e poi al terzo. Q u e s t o caso è compatibile con la specifica?

b. Perché i p u n t i 4 e 5 esprimono requisiti diversi per le richieste interne ed esterne? (Il p u n to 5 è maggiormente preciso del p u n t o 4.) c. Dal p u n t o di vista deli'organizzazione delle specifiche, si dovrebbe notare che esiste u n insieme di specifiche per la definizione del c o m p o r t a m e n t o del sistema (ad esempio per l'accensione e lo spegnimento delle luci dei pulsanti), di specifiche per la definizione dei requisiti delle proprietà del sistema (ad esempio, tutte le richieste devono essere servite) e di specifiche per la definizione delle decisioni di schedulazione ("l'algoritmo dovrebbe minimizzare ...").

5.25

N e l caso generale, il g e t t o n e nel posto S C H E D U L E R d o v r e b b e c o n t e n e r e i n f o r m a z i o n i circa l'intero stato del sistema (e q u i n d i , le posizioni degli ascensori e l'illuminazione dei pulsanti), visto che i t e m p i di attesa d i p e n d o n o da queste i n f o r m a z i o n i . U n a volta che le informazioni s o n o immagazzinate in S C H E D U L E R , u n a f u n z i o n e di decisione p o t r e b b e essere associata a u n a transizione collegata solo a S C H E D U L E R . Tale f u n z i o n e c o m p r e n d e r e b b e l'algoritmo scelto per minimizzare i t e m p i di attesa; è necessario che venga calcolata in t e m p i trascurabili rispetto alle d i n a m i c h e del sistema. In altre parole, il t e m p o di attesa m a s s i m o associato alla transizione deve essere e s t r e m a m e n t e piccolo rispetto al t e m p o A t richiesto a f f i n c h é l'ascensore si sposti da u n p i a n o al successivo.

5.26

I n t r o d u c e t e u n a terza entità c h i a m a t a P R O F I C I E N C Y e definite u n a relazione ternaria.

5.32

La specifica i n f o r m a l e n o n ha indicato q u a n t i spazi vuoti p o s s o n o esistere tra d u e parole consecutive. Inoltre n o n è stato specificato se il carattere t e r m i n a t o r e ' # ' d e b b a seguire i m m e d i a t a m e n t e l ' u l t i m o carattere dell'ultima parola o se sia necessario a n t e p o r r e u n carattere vuoto. La specifica formale chiarisce la questione. È m o l t o semplice cambiare l'opzione scelta nel caso lo si desideri.

5.33

La p r o c e d u r a procedure

sort

(a:

in

out

i n t e g e r _ a r r a y ) is

begin for

i

in

a'first.

a end end

( i ) : =

a'iast

loop

i ;

loop;

sort;

soddisferebbe il requisito. U n a specifica a p p r o p r i a t a per u n a p r o c e d u r a di o r d i n a m e n t o è {n

>

0}

P {perm(a,old_a)

and

sorted(a,n)>

dove p e r m ( x , y ) indica che x è u n a p e r m u t a z i o n e di y . Q u a n d o si fornisce u n a definizione c o m p l e t a di p e r m , però, si d o v r e b b e fare a t t e n z i o n e al caso in cui un e l e m e n t o è presente p i ù volte nell'array. 5.41

Viene definita la nuova operazione name : Text g o n o m o d i f i c a t e di conseguenza; per esempio newF: name

Iden,tifier

—» Ident i f ier ; le altre operazioni ven-

—» T e x t

n e w F ( i d ) )=id ;

if

n a m e ( f ! ) =n a m e ( f 2 ) t h e n

if

(equalF(f,

appendF(f1(

£¡ = £2;

f2))

and

(lengthF(f1)=cursor )

and

name(f)=id)then

5.42

(insertF(id,

cursor,

name(insertF

(id,

3)=appendF(addF(f1(

cursor,

s),

f2)

and

s)=id)

La soluzione per l'operazione f i n d p u ò essere fornita nel seguente m o d o : •

I n n a n z i t u t t o , il c o d o m i n i o dell'operazione è il p r o d o t t o C a r t e s i a n o B o o l le ovvie restrizioni.

x Nat, con



Secondo, se s è la stringa vuota, è presente in £ in qualsiasi posizione.



Terzo, che cosa succede se s è presente diverse volte in f ? Cosa succede se alcune delle occorrenze s o n o intersecate? A l c u n i di queste osservazioni p o s s o n o essere fatte anche per il comando c hange.

Figura 5.56

5.49

Composizione sequenziale di un superstato.

Nel Capitolo 4, abbiamo visto che la relazione INHERITS_FROM è ancora piuttosto controversa; è possibile trovarne diverse definizioni in diversi linguaggi. Q u a n d o è permessa la ridefinizione incondizionata di operazioni ereditate, l'ereditarietà risulta diversa da assumes, visto che assumes può solo aggiungere nuove proprietà alle operazioni ereditate e non può cancellare equazioni precedenti. Alcuni linguaggi di programmazione orientati agli oggetti, invece, cercano di fornire una semantica della relazione di ereditarietà che coincida con assumes.

5.52

La Figura 5-56 fornisce una possibile soluzione.

5.61

Una soluzione semplice consiste nell'associare coppie < t m i n r t m a „> ai posti invece che alle transizioni. Quindi, se un gettone è prodotto nel posto P, questo non può essere consumato prima che sia passato t „ i n . Inoltre, se nell'istante t m a K viene abilitata una transizione di output, allora deve scattare all'istante t„ a „; se viene attivata più di una transizione, la scelta è, come al solito, non deterministica. Se è trascorso t m a x , e il gettone non è stato consumato, non potrà più essere consumato da alcuna transizione: il gettone viene perduto. Una soluzione più generale in grado di gestire svariate situazioni temporali, può essere impostata nel seguente modo: •

A ciascun gettone è associato un valore temporale, che rappresenta l'istante in cui è stato prodotto il gettone. I predicati associati alle transizioni sono definiti in base ai valori temporali dei gettoni presenti nei posti in input. Una definizione della semantica di P N di questo tipo può essere trovata in Ghezzi et al. [ 1989a].

5.71

No, perché la procedura potrebbe modificare table durante l'esecuzione. In questo caso, l'asserzione di output richiederebbe che la variabile f o u n d fosse vera se e solo se il valore di e 1 e m e n t esiste in t a b l e dopo l'esecuzione e non prima. La postcondizione dovrebbe quindi essere cambiata per fare uso di o l d t a b l e invece di table.

5.76

La simulazione delle P N è più semplice e risulta più naturale rispetto alla simulazione di specifiche logiche mediante deduzioni, sia che venga eseguita m a n u a l m e n t e sia in maniera automatica. Dall'altra parte, l'analisi delle proprietà risulta più semplice nelle specifiche logiche. La gestione (e il cambiamento) delle politiche implementative è più facile in uno stile logico. Infatti, per le P N , potrebbe essere necessario apportare modifiche radicali al modello. Comporre diversi componenti delle specifiche è forse più complesso con le PN. Infatti, la rappresentazione grafica può diventare ingestibile. La difficoltà deriva dalla necessità di controllare le possibili incoerenze tra i diversi componenti. In un insieme di regole logiche abbiamo lo stesso problema, ma seguire il flusso delle deduzioni logiche per controllare le possibili incoerenze risulta più sistematico.

Input employee record

Input key

Employee Table

Employee Times

X

Printout of salaries

Figura 5 . 5 7

Salaries

DFD per la d e s c r i z i o n e di u n a parte del c a l c o l o degli stipendi degli impiegati.

5.82

Applicate gli esempi e le tecniche usate nel C a p i t o l o 4.

5.85

II D F D della Figura 5 . 5 7 p u ò essere t r a d o t t o nell'interfaccia di package Ada riportato qui di seguito: package

Employees

ia

Empl_Table:

...

Empl_Record: Empl_Time:

... — c o n t a i n s

an

Empl_Key

Salaries :

...

procedure

Insert

(Empl_Record:

procedure

Delete

(Empl_Key:

procedure

Comp_Sal

in...;

in...;

(Empl_Times:

Empl_Table:

Empl_Table:

in

in

out...);

out...);

in...;

E m p l _ T a b 1 e: end

declaration—

...

in...;

Salaries:

out . . . ) ;

Employees.

5.86

Le P N p o s s o n o essere descritte in diversi m o d i s t r u t t u r a t i . In particolare, si p o t r e b b e vedere u n a transizione c o m e la descrizione di un'attività complessa che p u ò essere resa più dettagliata m e d i a n t e sottoreti a p p r o p r i a t e . La Figura 5 . 5 8 fornisce u n s u g g e r i m e n t o su c o m e p r o d u r re raffinamenti di questo tipo. In alternativa, sarebbe possibile definire diversi c o m p o n e n t i di u n a P N c o m e sottoreti i n d i p e n d e n t i che d e v o n o essere aggregate s e g u e n d o regole di composizione a p p r o p r i a t e . Le Figure 5 . 2 5 e 5 . 2 6 p o s s o n o rappresentare u n a sorgente di ispirazione per la definizione di queste regole.

5.87

A p p l i c a t e la scomposizione funzionale alle o p e r a z i o n i espresse m e d i a n t e bolle. Per esempio, u n a f u n z i o n e p e r J'elaborazione di s t i p e n d i p u ò essere s c o m p o s t a in "calcolo del n u m e r o di o r e di lavoro", "calcolo dello s t i p e n d i o lordo", "calcolo delle tasse" e "calcolo dello s t i p e n d i o netto".

Note bibliografiche Per la terminologia di base della specifica si faccia riferimento al documento degli standard IEEE [1999]. Davis [1990] e Davis [1993] sono due libri sui requisiti e sulle specifiche software. Jackson [1995] è un glossario illuminante sui requisiti e sulle specifiche. In particolare, p r o p o n e un modello sistematico per la gestione dell'acquisizione e della specifica dei requisiti che un'applicazione deve soddisfare per poter interagire in maniera corretta con l'ambiente circostante. Van Lamsweerde [2000a] fornisce una prospettiva di ricerca sull'ingegnerizzazione dei requisiti. Nuseibeh e Easterbrook [2000] presenta un riassunto delle problematiche dell'ingegnerizzazione dei requisiti. Boehm [1984b] distingue tra la verifica (il fatto che u n software segua i requisiti specificati) e la validazione (il fatto che u n sistema raggiunga lo scopo per il quale è stato progettato). Parnas [1972a] fornisce un contributo fondamentale che sottolinea una chiara distinzione tra la specifica e l'implementazione. Parnas [1977] fornisce un'introduzione lucida al tema della specifica. I diagrammi di flusso dei dati sono una delle notazioni più popolari per la specifica del software. Sono presentati, in molte versioni leggermente diverse tra loro, in numerosi articoli e libri, come DeMarco [1978]. Ward e Mellor [1985] estende i D F D con frecce per il controllo del flusso e altre opzioni in m o d o da renderli appropriati all'analisi e alla specifica di sistemi real-time. Le specifiche real-time sono descritte anche in Hatley e Pirbhai [1987]. U n a formalizzazione completa dei D F D è proposta in Fuggetta et al. [1983]. Le FSM sono probabilmente il modello più conosciuto, più semplice e più usato nell'informatica. Sono descritte molto dettagliatamente in molti libri di testo, come Mandrioli e Ghezzi [1987]. Le reti di Petri sono dovute a C.A. Petri [1962], il quale le definì originariamente per descrivere le interazioni tra macchine a stati finiti. Sono descritte in Peterson [1981] e Reisig [1985]. Tra le molte varianti ed estensioni proposte per il modello di base, abbiamo parlato di reti dotate di predicati associati a transizioni, introdotte da Genrich [1987], nelle quali ai gettoni sono associati vaio-

ri da cui d i p e n d o n o i predicati associati alle transizioni. Il tempo è stato aggiunto alle reti di Petri in diversi modi; quello presentato in questo libro è dovuto a Merlin e Faber [1976], Ghezzi et al. [1989a] p r o p o n e u n m o d o generale per introdurre il t e m p o nelle reti di Petri. A j m o n e M a r s a n et al. [1984] p r o p o n e un'estensione stocastica rendendo probabilistici gli scatti delle transizioni. Il caso di studio del sistema di ascensori è stato usato in letteratura come benchmark per i metodi e i linguaggi di specifica. La definizione informale qui presentata è stata presa dal califorpapers del 4° IEEE International W o r k s h o p o n Software Specifìcation and Design [IWSSD, 1987], dove è stata proposta esplicitamente come esercizio. La formalizzazione della definizione in reti di Petri è di Ghezzi e Mandrioli [1987]. Il modello entità-relazione è stato introdotto da C h e n [1976]. L'applicazione della logica matematica all'informatica è stata i n t r o d o t t a da M c C a r t h y [1962], Floyd [1967] e H o a r e [1969]. Il testo di M a n n a e Waldinger [1985] è u n ' o t t i m a introduzione alla logica matematica; esso p o n e particolare enfasi sull'applicazione all'informatica. U n ' i n t r o d u z i o n e più breve è fornita in u n testo di informatica teorica di M a n d r i o l i e Ghezzi [1987]. L'applicazione della logica alla specifica di sistemi concorrenti e real-time si fonda sul lavoro iniziale di Hoare [1972]. Particolare interesse è attualmente rivolto all'uso della logica temporale. U n b u o n tutorial sull'estensione della logica classica e sulle sue applicazioni nell'informatica è fornita da Kroger [1987]. Ostroff [1989] discute l'applicazione della logica temporale ai sistemi real-time. La formalizzazione del sistema di ascensori in termini di logica è stata presa da Garzotto et al. [1987]. L'uso di formalismi algebrici per la specifica del software è stato proposto da molti ricercatori. Gli sforzi iniziali sono dovuti a G u t t a g [1977] e G o g u e n et al. [1978]. Il linguaggio Larch discusso in questo capitolo e dovuto a G u t t a g e H o r n i n g [1983, 1986a e 1986b] e G u t t a g et al. [1985a e 1985b], Jalote [1989] discute c o m e verificare la completezza delle specifiche fornite secondo uno stile algebrico. C C S (Calculus of Communicating Systems, calcolo per sistemi comunicanti), dovuto a Milner [1980] è un approccio algebrico alla specifica di sistemi concorrenti ben conosciuto e molto studiato. Il linguaggio di specifica Z è stato definito da Spivey [1989]. Woodcock e Davies [1996] è un libro di testo sulle specifiche in Z. Spivey [1990] e Wordsworth [1990] descrivono casi di studio pratici in Z. Gli Statecharts sono stati definiti in Harel [1987], Harel et al. [1990] e Harrel e N a a m a d [1996], Per U M L , il lettore p u ò fare riferimento a Booch et al. [1999] e Fowler e Scott [1998], In questo capitolo abbiamo presentato solo alcuni esempi di linguaggi di specifica. N e sono stati sviluppati altri sulla base dei modelli presentati in questo capitolo: molti di questi h a n n o trovato u n uso pratico. A molti sono associati strumenti che ne facilitano l'uso. Ciò che segue è un elenco parziale di questi linguaggi: •

RSL (Requirement Statement Language) è basato sulle cosi dette reti-R, un'estensione semiformale delle FSM. Fa parte di S R E M (Software Requirements Engineeing Methodology), sviluppato da T R W per il c o m a n d o di difesa strategica degli Stati Uniti ed è stato presentato da Alfred [1977].



PSL (Problem Statement Language) è u n linguaggio operazionale sviluppato da Teichrow e Hershey [1977]. PSL è una tecnica automatica per la produzione di d o c u m e n t i strutturati e per l'analisi di sistemi per l'elaborazione di informazioni.



S A D T è stato sviluppato da Ross [1977] come parte di una metodologia completa.



V D M ( Vienna Defìnition Method) è un linguaggio formale basato su\V approccio denotazionale alla semantica formale, un m e t o d o descrittivo che consiste nella definizione del significato di una notazione come la soluzione di u n appropriato insieme di equazioni in un d o m i n i o ap-

propriato. Il linguaggio è stato presentato da Bjorner e Jones [1982], Bjorner e Prehn [1983] e Jones [1986a e b], •

ASLAN, sviluppato da Auernheimer e Kemmerer [1985] è anch'esso basato sul calcolo dei predicati di primo ordine. Il linguaggio è stato esteso da Auernheimer e Kemmerer [1986] per poter gestire i sistemi real-time. A S T R A L (Coen-Porisini et al. [1997]) è un altro linguaggio che eredita da ASLAN e T R I O (che verrà analizzato nel seguito).



O B J 2 è un linguaggio di specifica che deriva dai primi lavori sulle specifiche algebriche di Goguen. Il linguaggio è descritto da Futatsugi et al. [1985]; Nakagawa et al. [1988] descrive un'applicazione pratica del linguaggio, il quale è evoluto nel linguaggio di specifica CafeOBJ (Nakajima e Futatsugi [1997]).



L O T O S è un linguaggio di specifica per applicazioni per la comunicazione, basato sul C C S di Milner. È descritto da Bolognesi e Brinksma [1987].



T R I O è un linguaggio di specifica per sistemi critici real-time. (Ghezzi et al. [1990]). Basato su logica temporale, T R I O è supportato da u n insieme esteso di strumenti ed è stato validato in diversi casi di studio di ragguardevoli dimensioni, come riportato in Ciapessoni et al. [1999].



Il metodo per requisiti S C R (Software Cost Reduction) fu introdotto negli ultimi anni Settanta (Heninger [1980], Bharadwaj e Heitmeyer [1999]) per specificare i requisiti software di sistemi embedded real-time. È stato impiegato in maniera diffusa e recenti studi h a n n o rafforzato la sua natura formale (Heitmeyer et al. [1996]).

Heitmeyer e Mandrioli [1996] è una collezione di articoli sulla specifica formale di sistemi real-time che fornisce un ampio panorama dello stato dell'arte. Scrivere le specifiche è difficile e a rischio di errori. È per questo motivo che, secondo Boehm [1984b], è necessario verificarle (validarle). La questione riguardante l'esecuzione delle specifiche è stata affrontata da Kemmerer [1985]. La prototipazione è discussa in Luqi e Ketabchi [1988] e Luqi et al. [1988]. Il n u m e r o speciale di IEEE Computer curato da Tanik e Yeh ( C o m p u t e r [1989a]) può essere consultato per avere esempi di diverse tecniche e applicazioni della prototipazione rapida. Boehm et al. [1984] confronta la progettazione del software basata su una specifica con u n approccio basato sulla prototipazione. Moriconi e Hare [1986] e Harel [1988] affrontano l'uso di linguaggi visuali per migliorare la comprensibilità delle specifiche. R o m a n e Cox [1989] illustra l'uso dell'animazione per la visualizzazione di processi concorrenti. Un approccio eclettico alla stesura di specifiche è stato proposto da Ghezzi e Mandrioli [1987] e Sanden [1989b], La questione dei punti di vista nelle specifiche e il bisogno di stabilire una forma di correlazione tra di loro è stata analizzata da Nuseibeh et al. [1994], Van Lamsweerde [2000b] fornisce una roadmap per il campo delle specifiche formali. Engels e Groenwegen [2000] offre una roadmap della modellazione orientata agli oggetti, e Jackson e Rinard [2000] presenta una roadmap dell'analisi delle specifiche.

CAPITOLO

6

Verifica

Nei tre capitoli precedenti abbiamo affrontato lo sviluppo sistematico del software, in modo che il prodotto soddisfi le richieste dei suoi potenziali utenti. In un mondo perfetto potremmo fermarci a questo punto. Tuttavia, in quanto umani, e di conseguenza fallibili, anche se adottassimo le tecniche di progettazione più sofisticate e affidabili, non potremmo evitare a priori il verificarsi di risultati erronei. Pertanto, il prodotto di una qualunque attività ingegneristica, sia questo un ponte, un'automobile, un televisore o un word processor, deve essere verificato rispetto ai propri requisiti lungo tutto il processo di sviluppo. Ad esempio, nel caso di un ponte, occorre controllare sistematicamente il progetto e la costruzione in tutti gli stadi intermedi: i primi bozzetti vengono verificati usando opportune equazioni, per valutare se il ponte sarà in grado di sopportare il carico previsto, e si potrebbe anche costruirne un modello fisico, al fine di migliorare l'affidabilità della previsione. I materiali utilizzati nella costruzione vengono ispezionati per testarne l'affidabilità. Il processo di costruzione è anch'esso esaminato, per controllare il rispetto degli standard previsti. Infine, in alcuni casi, una volta ultimata la costruzione, si procede a un ulteriore accertamento caricando il ponte con un determinato peso, prima di aprirlo al pubblico. Come abbiamo detto, però, non sono solo i prodotti del progetto e della produzione del ponte ad essere verificati, ma anche il processo stesso. Ad esempio, prima di iniziare la costruzione del ponte, occorre appurare che il luogo in cui avverrà la costruzione sia opportunamente preparato; ad esempio, l'infrastruttura di supporto potrebbe includere le scuole per i figli dei lavoratori, qualora il ponte dovesse essere costruito in un area sottosviluppata. In alcuni casi esistono agenzie governative che devono approvare il progetto prima di avviare i lavori e la località prescelta deve essere approntata dal costruttore ed approvata dall'agenzia. Anche il software deve essere verificato in maniera del tutto analoga, e in questo capitolo illustreremo come questo processo sia molto più difficile rispetto a quello di molti altri prodotti dell'ingegneria; chiariremo tra breve il motivo. Prima di affrontare i dettagli tecnici, riteniamo utile illustrare alcuni aspetti relativi alla terminologia. Il campo della verifica del software è progredito molto rapidamente, e così anche la terminologia. I termini verifica e convalida, in particolare, sono usati con diversi significati, talvolta in modo incoerente. In questo capitolo eviteremo l'uso del termine con-

valida e useremo soltanto verifica. Con questo termine intendiamo indicare tutte le attività svolte per accertarsi che il software raggiunga i propri obiettivi. Come vedremo, queste attività ricoprono un ampio spettro che comprende testing, dimostrazioni matematiche e ragionamenti del tutto informali. All'inizio del capitolo useremo i termini errore e difetto in maniera informale, invece del termine molto diffuso, ma poco tecnico di baco (in inglese bug). Definiremo in maniera più precisa questi ed altri termini più avanti.

6.1

Obiettivi e requisiti della verifica

Cosi come occorre sottoporre a verifica un ponte o qualsiasi altro prodotto dell'ingegneria, ugualmente occorre verificare un prodotto software e il suo processo di sviluppo. Con programmi di piccola dimensione, o scritti per uso personale oppure che non devono rispettare requisiti stringenti di qualità, la verifica spesso consiste nell'effettuare alcune prove di funzionamento e verificare che i risultati prodotti dal codice in esecuzione soddisfino le aspettative. Quantunque ciò sia poco professionale e inadeguato (anche per semplici programmi), le eventuali conseguenze negative possono, in questi casi, essere sostanzialmente tollerate. Sfortunatamente, i progettisti di software spesso sono tentati di applicare questo approccio anche alla verifica di prodotti software. Limitarsi ad eseguire alcune semplici verifiche non è sufficiente per accertare l'affidabilità del software. Innanzitutto, come vedremo più avanti, non potremo mai raggiungere una fiducia assoluta riguardo alla correttezza del software. Inoltre, anche se così fosse, saremmo ancora ben lontani dall'aver certificato la completa accettabilità del software, che potrebbe ancora offrire prestazioni scadenti, o presentare scarsa documentazione: inconvenienti in grado di porre serie limitazioni all'utilizzo efficace del prodotto o al controllo della sua evoluzione. Verificare il software soltanto dopo che il codice è stato prodotto, rende difficili le azioni necessarie all'eliminazione dei difetti individuati. I dati sperimentali ottenuti dall'osservazione di progetti industriali dimostrano che il costo della rimozione di un errore dopo che il software è stato completamente implementato è molto più elevato rispetto all'eliminazione degli errori nelle fasi precedenti. Riassumendo, l'attività di verifica, come ogni altra attività di progettazione, deve procedere secondo principi rigorosi e tecniche adeguate; non può essere lasciata esclusivamente all'inventiva, all'esperienza né, tanto meno, alla buona sorte. Esaminiamo ora, in modo sistematico, i requisiti della verifica.

6.1.1

Verificare tutto

In linea di principio, occorre verificare tutte le attività di progetto e tutti i relativi prodotti. Abbiamo già discusso il problema della verifica delle specifiche nel Capitolo 5, illustrando due modalità che si possono adottare: la simulazione e l'analisi di proprietà. In un certo senso, anche la verifica stessa deve essere verificata. Infatti, una volta testato un sistema, per dimostrare il suo corretto funzionamento dovremmo anche verificare se i nostri test sono stati eseguiti correttamente. La verifica della validità degli esperimenti è una pratica standard nelle discipline scientifiche tradizionali. Ritorniamo al solito esempio

del ponte: dopo averne accertata la robustezza in vari casi sperimentali, è anche necessario verificare che tutti gli esperimenti siano stati svolti in modo corretto e determinare e se occorra procedere a ulteriori controlli. Allo stesso modo, se siamo stati in grado di dimostrare alcune proprietà di un sistema software sul quale si sta operando, occorre analizzare criticamente se la dimostrazione delle proprietà è, a sua volta, corretta. Ovviamente, questa applicazione ricorsiva dell'attività di verifica deve, prima o poi, terminare. E necessario che siano verificate tutte le qualità del software. Non basta verificare se il software implementato si comporta in maniera corretta rispetto ai documenti di specifica, ma è necessario certificare anche proprietà quali la portabilità, le prestazioni, la modificabilità, etc. Queste osservazioni suggeriscono che la verifica possa essere effettuata in diversi momenti, da persone diverse, con diversi obiettivi e applicando tecniche differenti. Ad esempio, in alcuni casi può essere utile che un prodotto sia verificato da persone diverse rispetto a quelle che lo hanno implementato; in altri casi la verifica potrebbe essere effettuata attraverso l'applicazione di specifici algoritmi, e così via. Questi problemi organizzativi verranno sviluppati nei Capitoli 7 e 8. In questo capitolo trattiamo principalmente i principi e le tecniche che vanno applicate durante l'attività di verifica.

6.1.2

I risultati della verifica possono non essere binari

Si tende a pensare che la verifica sia un'attività il cui risultato sia "sì" o "no"; ovvero, dopo aver effettuato molti test e aver analizzato a fondo il programma, il risultato finale è l'accettazione del prodotto o il suo rifiuto. Anche se, in ultima analisi, il produttore dovrà decidere se rilasciare il prodotto oppure no (e l'utente finale decidere se accettarlo oppure no), esistono molte attività di verifica i cui risultati non si possono ridurre a una ri« v r> u » sposta si o no . Come abbiamo osservato nel Capitolo 2, la correttezza stessa, vale a dire la corrispondenza del prodotto alle specifiche funzionali, non è una proprietà "binaria". Non è infatti possibile stabilire se un frammento di software sia assolutamente esente da errori, ma spesso si considera adeguata un'approssimazione del concetto ideale di correttezza. Talvolta si sentono frasi del tipo: "la nuova versione di questo prodotto corregge numerosi errori" (vale a dire che la nuova versione è "più corretta" della precedente). Questa affermazione implica che: •

la presenza di difetti in un sistema software complesso e di grandi dimensioni in pratica non possa essere eliminata completamente;



talvolta alcuni difetti possono essere tollerati (si ricordi, dal Capitolo 2, che il concetto di correttezza non è equivalente a quello di affidabilità o robustezza);



in pratica, il concetto di correttezza è relativo (il che non significa certo che sia facile da misurare).

L'efficienza è un tipico esempio di qualità del software che può essere valutata a diversi livelli. Questa caratteristica di qualità viene spesso richiesta nei documenti di specifica dei requisiti e può essere misurata in diversi modi. Ad esempio, l'efficienza potrebbe essere espressa in termini di complessità attraverso una formula del tipo "il tempo richiesto per eseguire una computazione è dato dalla funzione f ( n ) della lunghezza n dei dati d'ingresso"; op-

pure potrebbe essere possibile costruire in modo sperimentale tabelle che definiscano il numero e il tipo di risorse necessarie per eseguire il sistema in casi pratici. Come vedremo alla fine del capitolo, è possibile applicare criteri statistici per definire, misurare e, in ultima analisi, verificare l'affidabilità.

6.1.3

La verifica può essere oggettiva o soggettiva

In alcuni casi la verifica può essere il risultato di un'attività oggettiva, come nel caso in cui il sistema sia testato sollecitandolo con dati d'ingresso e verificando i risultati, oppure misurando il tempo di risposta a stimoli d'ingresso in un'applicazione interattiva. Tuttavia, non tutte le qualità possono essere quantificate in modo oggettivo. Ad esempio, la portabilità e la manutenibilità possono essere rese in maniera precisa soltanto specificando un particolare ambiente: portare un sistema software da una specifica architettura a un'altra oppure modificare il software esistente in modo tale che soddisfi nuovi e ben precisi requisiti. In molti casi si vorrebbe poter definire un livello generico di portabilità, anche se non si è in grado di specificare esattamente quali siano le caratteristiche tecniche delle possibili nuove architetture. Allo stesso modo si vorrebbe poter stimare la riusabilità anche quando non si riesce a specificare esattamente in quali nuovi contesti il software dovrà essere riutilizzato. In tutti questi casi, le misure di tipo oggettivo devono essere sostituite o integrate con stime soggettive. Ad esempio, abbiamo affermato nel Capitolo 4 che l'uso di tecniche objectoriented può migliorare la riusabilità. Abbiamo giustificato questa affermazione fornendo esempi che mostrano come alcune parti essenziali di codice esistente possano essere adattate in maniera naturale ad operare in condizioni diverse dalle condizioni originarie. Questa affermazione tuttavia non ha potuto essere giustificata in maniera oggettiva mostrando, ad esempio, il numero di linee di codice rimaste inalterate. Anche questa rozza quantificazione della riusabilità potrebbe essere fuorviante in quanto implicherebbe che la complessità del riuso del software debba essere misurata attraverso il numero di linee di codice e questa ipotesi, anche se spesso utilizzata, risulta in realtà opinabile. Ciononostante, è necessario poter stimare il livello di modificabilità anche in assenza di criteri oggettivi che consentano di misurarlo. ESEMPIO 6.1

Si consideri la seguente situazione reale che dimostra come, in pratica, sia possibile valutare in maniera empirica la riusabilità. Il produttore A desidera acquistare un editor di testi con interfaccia grafica per commercializzarlo con la sua nuova linea di computer. Il venditore di software B sostiene di avere sviluppato un editor che può essere adattato a differenti ambienti. L'editor funziona già su una della macchine che il produttore sta utilizzando. Ciò cui il produttore A è particolarmente interessato è se l'editor possa essere adattato per supportare diversi tipi di terminali video. B afferma che questo può essere effettuato facilmente e che l'editor, già di per sé, supporta molti tipi di terminali video. In assenza di una qualsiasi misura obiettiva per la modificabilità del software, A e B concordano di effettuare un esperimento: A cercherà di arricchire il proprio prodotto con la capacità di supportare il nuovo terminale, e se ci riuscirà in meno di una settimana, allora A acquisterà il software. Pertanto, mentre l'obiettivo di "modificabilità" era soggettivo per B durante lo sviluppo del

proprio editor, è diventato il fattore decisivo per la vendita, basato su un preciso (benché soggettivo) esperimento. • Altri aspetti dei prodotti software sono ancor più intrinsecamente soggettivi. Ciò non deve sorprendere in quanto, dopo tutto, anche i ponti ed altri prodotti possono essere, oltre che funzionali, più o meno gradevoli esteticamente. Certamente, nel caso del software, proprietà quali la comprensibilità e l'usabilità sono per definizione soggette ad una valutazione da parte delle persone e il giudizio può avere un esito variabile da persona a persona. Tutto ciò suggerisce che la valutazione soggettiva debba costituire una parte importante del processo complessivo di verifica, anche se i risultati di questa saranno meno affidabili rispetto a quelli di una valutazione oggettiva. Malgrado la sua intrinseca limitatezza, la valutazione soggettiva viene impiegata in tutti i settori dell'ingegneria, prendendo le opportune precauzioni al fine di ridurne i rischi. Ad esempio, si costituiscono comitati di valutazione e comitati di livello superiore per avere diverse opinioni e confrontarle tra di loro.

6.1.4

Verificare anche le qualità implicite

Le proprietà desiderabili di un prodotto software devono essere espresse in maniera esplicita nel documento di specifica dei requisiti. Alcuni requisiti tuttavia possono essere tralasciati o perché sono impliciti o semplicemente perché vengono dimenticati. Nel Capitolo 2 abbiamo usato il termine robustezza per caratterizzare la situazione di un software il cui comportamento rimane accettabile anche in situazioni inaspettate. Consideriamo il caso dei requisiti impliciti. I requisiti che vengono forniti per sistemi di elaborazione tradizionali spesso coprono soltanto gli aspetti funzionali e oltretutto risultano molte volte incompleti. Questo non significa che questi sistemi non abbiano, ad esempio, requisiti prestazionali. Semplicemente, questi sono stati omessi in quanto non considerati critici e pertanto lasciati alla competenza professionale del progettista e al suo buonsenso. Ad esempio, si sa che una transazione a uno sportello automatico normalmente non dovrebbe superare un minuto (attenzione, il termine normalmente dovrebbe essere quantificato, ad esempio, specificando che esso deve valere nel 95 per cento dei casi). La manutenibilità è un tipico esempio di qualità del software raramente espressa in maniera esplicita, anche se risulta altamente desiderabile. Oltretutto, abbiamo anche visto che essa è difficilmente quantificabile. Ciononostante, un buon ingegnere del software dovrebbe cercare non solo di progettare software facilmente modificabile, ma anche di verificare la modificabilità del prodotto durante ogni passo del processo di progettazione. Ad esempio, una volta specificati i requisiti di un'interfaccia utente, ci si dovrebbe chiedere come, quando e perché questi requisiti potranno cambiare. Analogamente, si supponga di aver deciso di acquistare un componente software che deve essere integrato in un sistema che si sta costruendo. Se non si è del tutto sicuri che quel componente software sia in grado di soddisfare tutte le necessità, presenti e future, si dovrebbero analizzare le implicazioni di eventuali cambiamenti di decisioni future che potrebbero comportare un completo rifacimento della stessa porzione di software. L'Esempio 6.1 illustra questo aspetto: il software della società B era ritenuto adeguato per le necessità della società A, ma la società A ha realizzato un esperimento per assicurare che la decisione di comperare dalla società B non impedisse l'utilizzo successivo di diversi terminali grafici.

Esercizio 6.1

Ritornate alle qualità del software trattate nel Capitolo 2 e classificatele in base alle seguenti caratteristiche di verificabilità: a. sono oggettive o soggettive? b. sono binarie o p p u r e no? c. la loro rilevanza è dipendente dai diversi tipi di applicazioni e di ambienti?

6.2

Approcci alla verifica

Esistono due approcci fondamentali alla verifica: sperimentale e analitico. Il primo consiste nello sperimentare il comportamento di un prodotto per analizzare se questo si comporti secondo le aspettative (test del prodotto). L'altro consiste nell'analizzare il prodotto, e tutta la documentazione di progetto ad esso relativa, per ricavare indicazioni circa la correttezza del suo operare come risultato delle decisioni progettuali effettuate. Le due categorie di tecniche di verifica vengono anche classificate come dinamiche e statiche, in quanto, per definizione, la prima richiede l'esecuzione del sistema sotto verifica, mentre l'altra è basata sull'analisi di modelli statici del prodotto. Come ci si può aspettare, le due tecniche risultano, in pratica, complementari. Abbiamo già visto un esempio di differenza tra metodi dinamici e statici nel Capitolo 5, quando abbiamo trattato il problema della verifica delle specifiche. In quel frangente abbiamo distinto tra la simulazione delle specifiche e l'analisi delle proprietà che da essa si possono dedurre. In questo capitolo ci focalizzeremo sulla verifica della correttezza del software. Tuttavia, molte delle tecniche proposte possono essere applicate anche alla verifica di altre qualità. Forniremo alcuni suggerimenti su questo argomento più avanti nel capitolo, in particolare discutendo la valutazione di qualità soggettive quali la comprensibilità e la modificabilità; presentando alcune tecniche, metteremo però in guardia il lettore circa le difficoltà di questo approccio.

6.3

Test

Il metodo più naturale e tradizionale per verificare un prodotto è quello di provarlo in un certo numero di situazioni rappresentative, accertando che si comporti come previsto. In generale, è impossibile testare un software in tutte le sue possibili condizioni operative e risulta pertanto necessario trovare alcuni casi di test che forniscano una sufficiente evidenza che il prodotto avrà un comportamento accettabile anche nelle situazioni in cui non è stato testato. Obiettivo, ovviamente, difficile da ottenere (se non impossibile). Inoltre, nel caso di test del software, le analogie che spesso si traggono con i settori tradizionali dell'ingegneria non ci forniscono suggerimenti utili per affrontare il problema del test. Si consideri il nostro classico esempio di costruzione di un ponte. Si supponga che si voglia testare la capacità di un ponte di reggere un certo peso. Se il ponte è stato verificato

dimostrando che può sostenere 1000 tonnellate, allora possiamo essere sicuri che reggerà un qualunque carico inferiore o uguale a 1000 tonnellate (ovviamente nelle stesse condizioni ambientali). Un criterio analogo, purtroppo, non può essere applicato al software, come mostra questo semplice esempio. ESEMPIO 6 . 2

Si consideri la seguente procedura di ricerca binaria: procedure

binary-search

table:

in e l e m e n t

(key:

in

Table;

element;

found:

out

Boolean)

is

begin bottom ubile

:= t a b l e ' f i r s t ; bottom if

< top

(bottom

top

:=

table'last;

loop 2 * 0

then

middle

+ t o p ) rem :=

(bottom

+ top

- 1) / 2;

middle

:=

(bottom

+ top)

else end

/ 2;

i f;

if key

i table

top

:=

(middle)

then

middle;

else bottom end end

+

1;

loop;

found end

:= m i d d l e

if ;

:= key

= table

(top);

binary-search;

Che cosa succede se ci dimentichiamo la clausola e l s e nel primo statement (istruzione) i f ? La procedura funziona correttamente per tutte le tabelle la cui dimensione è tale per cui la prima condizione i f darà sempre risultato vero (e cioè la dimensione della tabella è una potenza di due), ma ciò non ci garantisce il corretto funzionamento in altri casi. • Questo esempio mostra che piccoli cambiamenti nell'input al programma possono dar luogo a comportamenti sensibilmente diversi. Al contrario, la gran parte dei manufatti ingegneristici godono di una proprietà di continuità così che piccole differenze nelle condizioni operative - per esempio, tra il funzionamento durante il testing e sul campo - non producano comportamenti fortemente diversi. Questa proprietà di continuità è del tutto assente nel software, come illustrato nell'Esempio 6.2. Il test è un'attività critica nell'ingegneria del software e dovrebbe essere eseguita nel modo più sistematico possibile, definendo in maniera chiara i risultati che si attendono e il modo in cui si vuole ottenerli. Nella pratica, invece, il test viene eseguito in maniera non sistematica e senza applicare criteri predeterminati. Nei paragrafi che seguono definiremo gli obiettivi della verifica mediante testing, illustreremo una terminologia precisa e dimostreremo in modo esplicito le intrinseche limitazioni dell'attività di test; infine spiegheremo le strategie di test e il modo in cui questo può essere organizzato.

6.3.1

Obiettivi del test

Il test dei programmi può essere usato per dimostrare la presenza di malfunzionamenti, non per dimostrare la loro assenza. Questa famosa affermazione dovuta a Dijkstra (in Dahl et al. [1972]) è la sintesi perfetta di quali debbano essere gli obiettivi del test: se i risultati calcolati da un'applicazione sono diversi rispetto a quelli attesi, anche in un solo caso, significa inequivocabilmente che l'applicazione è scorretta. Invece, il funzionamento corretto dell'applicazione in un numero finito di casi non ci garantisce la correttezza in generale. Ad esempio, potremmo aver costruito un programma che funziona correttamente per numeri interi pari, ma non per numeri interi dispari. Chiaramente, un numero qualunque di test che utilizzi valori di ingresso pari non sarebbe in grado di evidenziare l'errore nel programma. Questo naturalmente non significa che il test sia inutile nella verifica del software. Dobbiamo semplicemente ricordarci che dall'attività di test non si possono trarre certezze assolute circa la correttezza di un programma. D'altra parte, una certezza assoluta è diffìcile da ottenere per molte attività umane. Anche le dimostrazioni matematiche, che sono un mezzo più sicuro del ragionamento informale per dimostrare proprietà, possono contenere errori. Si è scoperto che molte dimostrazioni matematiche contenevano errori addirittura dopo anni di utilizzo. Pertanto il test dovrebbe essere considerato soltanto come uno dei possibili mezzi per analizzare il comportamento di un sistema e dovrebbe essere integrato con altre tecniche di verifica in modo tale da migliorare la fiducia dei progettisti circa le qualità del prodotto che hanno realizzato. Il test dovrebbe essere basato su tecniche affidabili e sistematiche, in modo tale che, una volta eseguito, si abbia una maggior consapevolezza dell'affidabilità del prodotto. Ad esempio, un approccio apparentemente naturale, quale l'utilizzo di casi di test generati in maniera casuale, ha dimostrato di essere inadeguato in molte situazioni pratiche. Si consideri infatti il seguente frammento di programma: read if

(x);

x = y

read

(y) ;

then

z

: = 2 ;

z

:= 0 ;

else

end

i f;

write

(z ) ;

Si supponga che il programmatore abbia per errore scritto z : = 2 invece di z : = 2 2. Se noi eseguiamo il programma con dati di test che prevedano valori uguali per x e y, l'errore verrebbe scoperto immediatamente. Tuttavia, se x e y sono, ad esempio, interi e usiamo il generatore di numeri casuali per fornire i loro valori al programma, allora è estremamente improbabile che la condizione x = y venga effettivamente esercitata. Il test dovrebbe aiutare a localizzare gli errori e non solo a rilevarne la presenza. Il risultato del test non può essere visto come una risposta puramente booleana: i test dovrebbero essere organizzati in modo da aiutare a isolare gli errori. Questa informazione può essere utilizzata nella cosiddetta attività di debugging. Il test dovrebbe essere ripetibile-, vale a dire che i test dovrebbero essere costruiti in maniera tale che la ripetizione dello stesso esperimento che fornisca gli stessi dati in ingresso

allo stesso frammento di programma produca gli stessi risultati. Questa osservazione sembrerebbe quasi ovvia; tuttavia può succedere che alcuni esperimenti risultino difficilmente replicabili e ciò è causa di notevoli difficoltà nell'attività di debugging. Una delle ragioni della mancanza di ripetibilità nel test del software è dovuta all'influenza dell'ambiente di esecuzione sulla semantica dei programmi. Un tipico esempio è dato del caso delle variabili non inizializzate. Ad esempio, si consideri un frammento di programma che contiene l'istruzione if

x = 0

tben

write

("abnorma1 " ) ;

write

("normal");

else

end

if ;

e si supponga che la variabile x non sia inizializzata prima dell'esecuzione di questa istruzione. Pertanto, se l'implementazione del linguaggio non fornisce una verifica della mancata inizializzazione delle variabili, il che potrebbe accadere per motivi di efficienza dell'esecuzione, quando l'istruzione verrà eseguita, verrà letto il valore contenuto nella cella fisica di memoria corrispondente alla variabile x. Il contenuto di questa cella risulterà allora non predicibile e pertanto in molte possibili esecuzioni del programma verrà prodotto l'output normal ma, in modo assolutamente non prevedibile, alcune esecuzioni dello stesso programma, con gli stessi dati in ingresso, potrebbero fornire il risultato opposto. La questione della ripetibilità diventa ancora più critica nel caso di software concorrente, come vedremo nel Paragrafo 6.3.7. Infine il test dovrebbe essere accurato, qualità che ne aumenta l'affidabilità. Si osservi che il concetto di accuratezza dell'attività di test è strettamente dipendente dal livello di precisione, o addirittura di formalità, delle specifiche del software. Supponiamo, ad esempio, che in un sistema real-time la specifica stabilisca che, a causa di uno stimolo x di ingresso, il sistema debba produrre l'uscita y entro At millisecondi. Ciò suggerisce che l'input x venga usato come test per verificare se il corrispondente output y viene effettivamente prodotto entro At millisecondi. Ma se il requisito fosse "il sistema deve produrre l'output y entro At millisecondi come conseguenza della ricezione dello stimolo di ingresso x, indipendentemente da quali eventi si verifichino nel sistema durante questo lasso di tempo", dovremmo testare il sistema fornendo x in diversi contesti, in modo tale da verificare che il tempo di risposta sia accettabile anche nel caso di sistema con elevato carico di lavoro. Infine, se si utilizza una formula per esprimere una proprietà del software in modo matematico, risulterà più facile verificare se l'output prodotto dal programma soddisfa tale proprietà. Ad esempio, si consideri questa formula {for

ali

i

(1 < i < n

) implies

(a(i)

ì a ( i + 1 ) )>

che specifica che la variabile array a è ordinata. Accertando se a soddisfa questa formula dopo l'esecuzione del programma, si ottiene un modo efficace per verificare se il programma abbia prodotto un valore valido di a. Poiché può essere poco chiaro se i risultati di un'esecuzione siano effettivamente corretti (discuteremo questo problema nel Paragrafo 6.3.4.4), la verifica rispetto a una specifica formale può migliorare l'affidabilità dell'attività di test.

6.3.2

Fondamenti teorici del test

In questo paragrafo introdurremo una terminologia precisa per il test e ne mostreremo le limitazioni da un punto di vista matematico. La terminologia non è standardizzata e pertanto gli stessi termini possono essere usati nella letteratura con significati diversi. Sia P un programma e D ed R denotino rispettivamente i suoi domini di ingresso e uscita. Cioè D è l'insieme di tutti i dati che possono essere forniti in ingresso a P e i risultati dell'esecuzione di P, se esistono, sono elementi di R. Ovviamente, sia D che R possono contenere sequenze di dati anche illimitate se P contiene diverse istruzioni di I/O all'interno di cicli. Per semplicità, assumiamo che P calcoli una funzione, eventualmente parziale1, con dominio D e codominio R. Ciò accade frequentemente, in particolare per programmi sequenziali. Non è difficile tuttavia estendere le definizioni e le proprietà al caso più generale in cui P definisca una relazione2 tra D e R. Pertanto indicheremo i risultati dell'esecuzione di P su un dato del dominio D come P ( d ). Sia OR una descrizione (formale o informale) dei requisiti sui valori prodotti dal programma P. Pertanto, dato d e D, si dice che P è corretto per d se P ( d ) soddisfa OR. Quindi P è corretto se e solo se esso è corretto per ogni d in D. La presenza di un errore o difetto viene dimostrata mostrando che P(d) è un risultato scorretto per qualche D e cioè P ( d ) non soddisfa i requisiti di uscita. Definiamo questa situazione un fallimento e cioè un sintomo manifesto della presenza di un errore. Noi sappiamo, tuttavia, che la presenza di un errore non causa necessariamente un fallimento, pertanto il test cerca di aumentare la possibilità che un errore nel programma generi un suo fallimento attraverso opportuni casi di test. Un malfunzionamento {fault) è uno stato intermedio scorretto che può essere assunto dal programma durante la sua esecuzione. Ad esempio, potrebbe trattarsi del caso in cui a una variabile viene assegnato un valore diverso da quello che dovrebbe avere. Naturalmente, un fallimento si verifica soltanto se durante l'esecuzione sorge un malfunzionamento e tale malfunzionamento si genera soltanto se il programma contiene un errore, ma le due affermazioni opposte, in generale, non sono vere. Un caso di test è un elemento d di D, un insieme di test T è un insieme finito di casi di test e cioè, un sottoinsieme finito di D. P è corretto per T se risulta corretto per tutti gli elementi di T. In tal caso diciamo anche che T ha avuto successo3 per p. Un insieme di test T si dice ideale se, tutte le volte che P è scorretto, esiste un d e T tale che P risulta scorretto per d. Pertanto, un test ideale mostra sempre l'esistenza di un errore in un programma, se tale errore esiste. Naturalmente, per un programma corretto qualunque insieme di test risulta ideale. Se T è un insieme di test ideale e T ha successo per P, allora P è corretto.

1

La funzione può essere parziale in quanto, per alcuni dati di ingresso, il risultato potrebbe essere non definito (ad esempio potrebbe accadere un errore a run-time). 2 Ciò può accadere quando P viene codificato in un linguaggio che possiede costrutti concorrenti o non deterministici. 3 Alcuni autori definiscono il successo di un caso di test in maniera opposta: un test ha successo se questo causa il malfunzionamento di un programma. (Si vedano le note bibliografiche per ulteriori spiegazioni relative a questa definizione).

Un criterio di selezione di teste è un sottoinsieme di 2°, dove 2° indica l'insieme di tutti i sottoinsiemi finiti di D. In altre parole, C specifica una condizione che deve essere soddisfatta da un insieme di test. Ad esempio, per un programma il cui dominio D sia l'insieme degli interi, C potrebbe richiedere che gli insiemi di test contengano almeno tre elementi: uno negativo, uno positivo e uno zero. Un insieme di test T soddisfa C se esso appartiene a C. In generale, C può essere descritto mediante un'opportuna formula che deve essere soddisfatta da tutti gli elementi di uno qualunque dei sottoinsiemi di D che appartenga a C. Ad esempio, il requisito che abbiamo menzionato sopra per C viene descritto dalla formula C = {|n

ì 3 and

exists

i,

j, k

(Xj


0)}

Un criterio di selezione C è coerente se, per ogni coppia di insiemi di test Tj e T2, che soddisfano c, i1! ha successo se e solo se anche T2 ha successo. Pertanto, se disponessimo di un criterio coerente non ci sarebbe motivo teorico per scegliere un particolare insieme di test tra tutti quelli che soddisfano c. Vedremo tra breve, tuttavia, che esistono ragioni pratiche per preferire un insieme di test rispetto a un altro. Un criterio C è completo se, tutte le volte che P è scorretto, esiste un insieme di test che soddisfa C e non ha successo. Pertanto, se disponessimo di un criterio C coerente e completo, qualunque insieme di test T che soddisfi C potrebbe essere usato per decidere la correttezza di P. Per esempio, si supponga che P sia un programma per ordinare sequenze di interi e che funzioni correttamente solo se la lunghezza della sequenza che deve essere ordinata è una potenza di due. Un criterio completo e coerente per P dovrebbe essere tale per cui qualunque insieme di test contenga almeno una sequenza che non è potenza di due. Si consideri invece il seguente criterio: tutte le sequenze di T hanno una lunghezza che è potenza di due oppure nessuna di esse soddisfa questa proprietà. Questo criterio risulterebbe completo ma non coerente. Infine diciamo che un criterio di test Cj è più fine di C2 se, per ogni programma P, per ogni insieme di test Ti che soddisfa c „ esiste un sottoinsieme T2 di T^ che soddisfa c 2 . Ad esempio, si supponga che c 2 = { | ( xio ) }

e che C ] = { | n s 3 and e x i s t s i, j, k, m, p ( X j < 0 ,

Xj=0, x k > 0 ,

x„ e v e n

and

xpodd)}

Un insieme di test che soddisfa C1 è {—3, 0, 6}; questo insieme soddisfa anche C2. Un altro insieme di test che soddisfa C! è {—3, 0, 5, 8>, dal quale possiamo estrarre {—3, 0, 5} che soddisfa C2. Sfortunatamente, nessuna delle precedenti definizioni è effettiva: non si può derivare un algoritmo che stabilisca se un elemento (un programma, un insieme di test o un criterio) soddisfi la proprietà desiderata. Si consideri la correttezza di un programma P. Si supponga di poter specificare i requisiti funzionali attraverso una formula del primo ordine FR ( d, u ), dove d ed u sono variabili libere, e sia P ( d ) la funzione associata al programma. La correttezza di P potrebbe essere formalmente espressa come for

ali

d in D, u in R

(u = P ( d )

implies

FR

(d, u ) )

Questa espressione, tuttavia, non può essere decisa attraverso un algoritmo, poiché se ciò fosse possibile significherebbe poter decidere la verità di una qualunque formula del primo ordine e ciò è ben noto essere un problema indecidibile. Inoltre, in alcuni casi, potrebbe essere addirittura impossibile decidere se un valore d appartiene a un insieme di test T. Questa proprietà dipende da come T viene definito e infatti questo è un altro ben noto problema indecidibile. Risulta pertanto impossibile decidere se un insieme di test è ideale, se un criterio è coerente, e così via. Esiste una lunga lista di importanti proprietà dei programmi, quali la correttezza, la terminazione e l'equivalenza che risultano indecidibili. Inoltre, come vedremo tra breve, molti dei criteri che vengono usati in pratica sono essi stessi indecidibili; cioè non è decidibile se un dato insieme di test li soddisfi o anche se esista un insieme di test che li soddisfi. Come sempre accade per i problemi indecidibili, ciò significa che non è possibile una piena automazione e che l'approccio alla verifica deve essere basato sull'applicazione di buon senso e inventiva. In pratica, il supporto di strumenti automatici può fornire un importante aiuto, ma richiede sempre l'intervento umano in alcuni punti critici. Esercizio 6.2

6.3.3

Si dice che c l è più affidabile di c 2 se, tutte le volte che u n p r o g r a m m a P è scorretto, n o n p u ò accadere che u n insieme di test T 2 che soddisfa c 2 causi il fallimento di P, m e n t r e T, che soddisfa Ci n o n lo causa. D i m o s t r a t e che n o n è vero che, se Ci è più fine di c 2 , allora C! è più affidabile di C 2 . Fornite u n a condizione che garantisca questa implicazione.

Prìncipi empirici di test

Abbiamo osservato che, in generale, il test di un sistema può fornire risultati assoluti riguardanti la correttezza di un sistema soltanto se questo test è esaustivo, e cioè il sistema viene osservato in tutte le possibili situazioni. Sfortunatamente un test esaustivo non sarà quasi mai praticabile. È pertanto necessario definire strategie di test e criteri per selezionare casi di test significativi. La nozione di significatività di un caso di test, tuttavia, non può essere formalizzata, ma può essere soltanto una nozione intuitiva, in quanto costituisce un'approssimazione empirica del concetto di insieme di test ideale. Un caso di test significativo è un caso di test che ha un elevato potenziale di scoperta di presenza di errori. Pertanto, l'esecuzione con successo di un caso di test significativo aumenta la nostra fiducia nella correttezza del programma. Intuitivamente, invece di eseguire un elevato numero di casi di test, il nostro obiettivo dovrebbe essere quello di eseguire un numero sufficiente di casi di test significativi. Se un insieme di test T[ significativo è un sovrainsieme di un altro insieme significativo di test T2, possiamo certamente considerare più attendibile TI di T2. D'altra parte, siccome il test è costoso, è anche necessario limitare il numero di possibili esperimenti. ESEMPIO 6 . 3

Questo semplice esempio mostra che il numero di casi di test di un insieme di test non contribuisce necessariamente alla significatività dell'insieme. Si supponga di aver scritto il seguente frammento errato di programma per calcolare il massimo di due numeri:

if

x > y max

then :=

x;

else

max end

: = x;

i f;

In questo caso, l'insieme di test {x = 3, y = 2 ; x = 2, y = 3 } è i n grado di identificare l'errore, mentre {x = 3, y = 2; x = 4, y = 3; x = 5, y = 1} non lo è, anche se contiene più casi di test. • Malgrado le limitazioni teoriche di cui abbiamo parlato nel paragrafo precedente, è necessario in pratica disporre di criteri di test per definire insiemi di test significativi. Un criterio di test cerca di raggruppare gli elementi del dominio di ingresso in classi tali per cui ci si aspetta che gli elementi di una classe si comportino nello stesso modo. Possiamo pertanto scegliere un singolo caso di test come rappresentativo dell'intera classe. Se le classi Di sono tali per cui UD; = D, possiamo dire che il criterio di test soddisfa il principio di completa copertura. Vedremo che molti ben noti criteri di test pratici soddisfano questo principio. ESEMPIO 6 . 4

Si supponga di dover costruire un programma per calcolare il fattoriale di un qualunque numero. La specifica è la seguente. Se il valore d i n è < 0, il p r o g r a m m a deve stampare u n messaggio d'errore. Se 0 ì n < 2 0 , il p r o g r a m m a deve scampare il valore di n ! . Se 2 0 s n £ 2 0 0 , il p r o g r a m m a deve stampare un valore approssimato di n ! c o m e n u m e r o in virgola mobile (usando qualche m e t o d o di approssimazione del calcolo numerico). L'errore ammissibile è lo 0 , 1 % del valore esatto. Infine, se n > 2 0 0, il valore in ingresso può essere rifiutato e in tal caso deve essere s t a m p a t o u n appropriato messaggio diagnostico.

In questo caso risulta naturale dividere il dominio di ingresso nelle classi {n < 0}, {0 < n < 2 0}, {20 < n < 2 0 0 } e { n > 2 0 0 } e usare insiemi di test che contengano un elemento di test per ciascuna classe. Si supponga che i risultati siano corretti per i dati che appartengono a un insieme di test (ad esempio { - 1 0 , 5, 175, 290}). L'affermazione che il programma funzioni correttamente per un qualunque altro valore, naturalmente, è soltanto una speranza, ma non la verità. • Se dividiamo il dominio di ingresso in classi disgiunte DI tali per cui D; N DJ = 0 per i * j (e cioè, se le classi costituiscono una partizione di D) allora non esiste una particolare ragione per scegliere un elemento rispetto a un altro come rappresentativo di una classe. Le classi dell'Esempio 6.4 illustrano una partizione. Invece, se le classi non sono disgiunte, abbiamo la possibilità di scegliere rappresentanti che minimizzino il numero di casi di test. Ad esempio, se D i f i D j * 0, un caso di test appartenente a Di n D-, soddisfa sia DI che D A . Ad esempio, si consideri il seguente criterio di test: il programma deve essere testato con classi DI D2 e D3 di valori di x, dove

Di =

{di|di

D2

=

{di|di y } e { x s y}. In realtà esistono eccezioni a quanto detto, e invitiamo il lettore ad individuarle. Una scelta accurata dei casi di test, con l'obiettivo di soddisfare il criterio di completa copertura, può quindi approssimare criteri coerenti e completi. Possiamo migliorare l'affidabilità di tali criteri scegliendo più di un elemento rappresentativo da ciascuna classe. Ad esempio, se sospettiamo che una particolare partizione presenti eccezioni alla coerenza e completezza, potremmo migliorare il metodo generando ulteriori casi di test, eventualmente in maniera casuale. Il principio che abbiamo qui enunciato può essere applicato in molti modi diversi tra di loro e spesso complementari. Vedremo in seguito come l'attività di test possa essere organizzata in pratica e mostreremo vari approcci empirici per la determinazione di insiemi di test significativi, alla luce del principio di completa copertura. Faremo ciò distinguendo tra test in piccolo e test in grande. Il test in piccolo (Paragrafo 6.3.4) affronta il problema del test per singoli componenti software; il test in grande (Paragrafo 6.3.5) invece affronta il problema di scomporre e organizzare l'attività di test in funzione della struttura modulare di programmi complessi.

6.3.4

Test in piccolo

Il test in piccolo affronta il test per singoli moduli. Esistono due approcci principali: il test white-box (a scatola bianca o trasparente) e il test black-box (a scatola nera). Testare del software come una scatola nera significa operare sul software senza avere alcuna conoscenza del modo in cui il software è stato progettato e codificato. Si utilizza la specifica per sviluppare i casi di test e per valutarne i risultati. Testare il software come una scatola bianca (o trasparente), invece, significa usare informazioni circa la struttura interna del software, anche ignorando la sua specifica. La scelta di partizionare casi di test, per il programma dell'Esempio 6.3, nelle classi {x > y } e { x s y } è u n esempio di test white-box, mentre l'Esempio 6.4 è un esempio di test black-box. Intuitivamente, entrambe le strategie sono utili e in un certo senso complementari: il test white-box testa ciò che il programma fa, mentre il test black-box testa ciò che il programma dovrebbe fare. Per questa ragione ci si può riferire al test black-box come al test basato sulla specifica e a quello white-box come test basato sull'implementazione. Entrambi i tipi di test aumentano la nostra fiducia nell'affidabilità di un componente. 6.3.4.1

Test white-box

Il test white-box viene anche chiamato test strutturale poiché utilizza la struttura interna del programma per ricavare i dati di test. L'Esempio 6.5 illustra questo concetto. La prima e più semplice strategia di test white-box suggerisce di scegliere i casi di test in modo da eseguire tutte le istruzioni del programma. Questo criterio viene chiamato criterio della copertura delle istruzioni. ESEMPIO 6 . 5

Si consideri il seguente programma che codifica il ben noto algoritmo di Euclide: begin read

(x);

while

read

x * y

if

x > y

(y) ;

loop then

x

: = x - y;

y

: = y - x;

else

end

if;

end

loop;

gcd

:= x;

end ;

Il programma contiene una sequenza di istruzioni. Si vuole fare in modo che tutte le istruzioni del programma vengano eseguite almeno una volta. Pertanto dobbiamo assicurare che il ciclo w h i l e venga eseguito almeno una volta e all'interno del ciclo dobbiamo fare in modo che entrambe le istruzioni x : = x - y ; e y : = y - x ; siano eseguite. Un possibile insieme di test che forza l'esecuzione di tutte le istruzioni del programma è { , , } . •

Copertura delle istruzioni. Intuitivamente, il criterio di copertura delle istruzioni si basa sull'osservazione che non è possibile individuare un errore se la parte del programma che contiene l'errore e che genera il fallimento non viene eseguita. E pertanto necessario sforzarsi di coprire completamente l'insieme delle istruzioni. Nei linguaggi strutturati a blocchi, nei quali le istruzioni possono essere parte di istruzioni più complesse, dobbiamo essere precisi riguardo a ciò che intendiamo per istruzione. Un'assunzione naturale è quella di far riferimento alla definizione sintattica della BNF del linguaggio di programmazione e assumere come istruzione un qualunque elemento del linguaggio, che può essere derivato dal costrutto < s t a t e m e n t > del linguaggio senza produrre una generazione ricorsiva dello stesso costrutto. Pertanto, in un tradizionale linguaggio strutturato a blocchi, le istruzioni elementari risultano le assegnazioni, le operazioni di I/O e le chiamate di funzione. Con queste ipotesi il criterio può essere così formulato. CRITERIO DI COPERTURA DELLE ISTRUZIONI. Scegliere un insieme di test T tale che, eseguendo P con ogni elemento d di T, ciascuna istruzione elementare di P venga eseguita almeno una volta.

In generale, lo stesso dato di ingresso causa l'esecuzione di molte istruzioni, pertanto abbiamo il problema di cercare di minimizzare il numero di casi di test assicurando tuttavia l'esecuzione di tutte le istruzioni. Si consideri, ad esempio, il seguente frammento di programma: read if

(x ) ; read

x > 0

(y ) ;

then

write

( " 1" ) ;

write

( " 2" ) ;

else end if

if; y > 0

then

wr i te ( " 3 " ) ; else write end

( " 4" ) ;

i f ;

Si denotino con l 1 ; i 2 , Wj, w2> w3 e w4 rispettivamente la prima e seconda istruzione e le istruzioni w r i t e ( " 1 " ) , w r i t e ( " 2 " ) , w r i t e ( " 3 " ) e w r i t e (" 4 " ) . Inoltre, sia Di la classe dei valori d'ingresso che causano l'esecuzione di Wi, per i = 1, ..., 4 . Ovviamente D! = {x > 0 } , D 2

= {x < 0 > , D 3

= {y > 0 } e D 4

= {y < 0 } . Pertanto, se si sce-

glie un dato rappresentativo per ciascuna di queste classi, è garantito che tutte le istruzioni Wi.Wj.W3 e w4 vengano eseguite almeno una volta. Potremmo scegliere il seguente insieme di test: {,

,

,

>

Questo insieme di test però non è minimo rispetto al criterio, in quanto ogni dato di ingresso appartiene a due classi. Si può ridurre il numero dei casi di test a due, ad esempio, scegliendo questi casi rappresentativi: { ,

>

Il seguente frammento di programma mostra una debolezza del criterio di copertura delle istruzioni: if

x < 0 x

end z

then

: = -x;

if ;

:= x ;

La scelta di un insieme di test tale per cui, all'inizio dell'esecuzione del frammento, x è negativa, darebbe luogo all'esecuzione di tutte le istruzioni del frammento di programma. Tuttavia, la mancata esecuzione del caso in cui x > 0, dimostra una mancanza di completezza. Infatti, questo frammento potrebbe essere visto come un'abbreviazione della seguente descrizione if

x < 0 x

then

: = -x;

else nuli ; end z

if;

: = x;

In base a questa formulazione, il criterio di copertura delle istruzioni avrebbe richiesto l'esecuzione anche dell'istruzione n u l i , e questo esperimento avrebbe mostrato la presenza di un errore. Differenti convenzioni sintattiche ci avrebbero di conseguenza portato a diverse modalità di applicazione dello stesso criterio. Torneremo più avanti su questo problema. Copertura degli archi (edge coverage). Il principio di completa copertura può essere applicato ai criteri basati sulla struttura del programma, descrivendo quest'ultima attraverso una rappresentazione grafica del flusso di controllo. Anche qui consideriamo il caso di un semplice linguaggio strutturato a blocchi. Per ogni frammento di programma P, il suo grafo di controllo viene costruito in maniera induttiva nel modo seguente: 1. Per ogni istruzione di I/O, assegnamento o chiamata di funzione, viene costruito un grafo del tipo illustrato nella Figura 6.1 (a). Il grafo ha un arco che rappresenta l'istruzione4. L'arco collega due nodi che rappresentano l'ingresso e l'uscita dall'istruzione. Se necessario, è possibile usare un'unica etichetta sia per l'istruzione che per l'arco del grafo, evidenziando in modo esplicito la corrispondenza tra archi e istruzioni. 2. Siano S! e S2 due istruzioni eGj eG2 i corrispondenti grafi. Di conseguenza: • Il grafo della Figura 6.1 (b) è associato con l'istruzione if

cond

then

Scelse end

4

if;

Più comunemente, la letteratura definisce i grafi di controllo rappresentando le istruzioni come nodi e non come archi.

o

ò (a) (b)

(c)

A A

w G

2

(e)

Figura 6.1

Costruzione del grafo di controllo di un programma. (a)

Grafo di un'istruzione di I/O, di un'assegnazione o di una chiamata di funzione.

(b)

Grafo dell'istruzione i f - t h e n - e l s e .

(c)

Grafo dell'istruzione i f - t h e n .

(d)

Grafo di un ciclo w h i l e .

(e)

Grafo di due istruzioni in sequenza.

Il grafo della Figura 6.1 (c) è associato con l'istruzione if cond

then

Si; end i f;

Il grafo della Figura 6.1 (d) è associato con l'istruzione while cond end

loop

Si; loop;

Il grafo della Figura 6.1 (e) è associato con l'istruzione S,;

s2 ;

n

l

n

n

2

n

3

k-l

n

k

o — o — - o ••• o — « o

I n

Figura 6.2

l

o—-o

n

k

Porzioni equivalenti di un grafo di controllo.

Poiché siamo interessati al flusso di controllo in un programma, è possibile considerare una sequenza di archi del tipo illustrato nella Figura 6.2, che non ha archi che entrano o che escono da uno qualunque dei nodi n 2 , . . . , n k _i, come equivalente a un singolo arco che collega nj a n k . Questa costruzione può essere utilizzata per semplificare un grafo di controllo. La Figura 6.3 mostra il grafo di controllo dell'algoritmo di Euclide codificato nell'Esempio 6.5. Avendo associato un grafo di controllo a un frammento di programma, possiamo enunciare il seguente criterio: CRITERIO DI COPERTURA DEGLI ARCHI.

Scegliere un insieme di test T tale che, eseguendo P per ogni d in T, ogni arco del grafo del flusso di controllo di P risulta percorso almeno una volta.

Si può vedere che i casi di test prodotti dal criterio di copertura degli archi esercitano tutte le condizioni che governano il flusso di controllo del programma, nel senso che il criterio richiede casi di test che generino il valore vero e falso di ogni condizione. Invitiamo il lettore a dimostrare che, in generale, il criterio di copertura degli archi produce risultati diversi ed è più fine del criterio di copertura delle istruzioni. Come esercizio, il lettore potrebbe anche verificare che i test prodotti per l'algoritmo di Euclide dell'Esempio 6.5 soddisfino il criterio di copertura degli archi. Copertura delle condizioni (condition coverage). Il criterio di copertura degli archi può essere rafforzato al fine di aumentare la possibilità di esposizione degli errori di un programma. Si consideri il seguente frammento che effettua la ricerca di un elemento in una tabella, implementata come array di elementi: found

:= f a l s e ;

while

(not

if

table

if

:=

:=

1 ;

counter

< numberof

= desired_e1ement

items

loop

then

true;

if ;

counter end

and

(counter)

found end

counter

found)

:= c o u n t e r

+

1;

loop; found

then

write

("the

desired

element

exists

("the

desired

element

does

in

the

table");

else write end

not

exist

in

the

table");

i f;

Il codice contiene un errore banale ma comune: viene utilizzato l'operatore " può essere rappresentato mediante una tupla di dieci elementi booleani (chiamati cause). Ad esempio, < t r u e , false, false, false, false, false, false, false, false> r a p p r e s e n t a il c o m a n d o B e rappresenta E = B ed E. La stessa tecnica,

ovviamente, vale per le uscite, chiamate effetti. Nel nostro esempio, un'uscita è un elemen-

to in {b, i , p} e pertanto, < f a l s e , f a l s e , t r u e > indica un'uscita in formato normale. Una volta riformulati i domini di ingresso e uscita in termini di valori booleani che rappresentano cause ed effetti, anche la funzione può essere riformulata come funzione booleana, e quindi come combinazione degli operatori n o t , and e or e rappresentata graficamente usando una qualunque tecnica di rappresentazione quale un grafo and-or. La Figura 6.4 fornisce una rappresentazione grafica di come si possa ottenere un'uscita in grassetto; il lettore potrebbe completare il grafo delle altre situazioni come esercizio. Si supponga ora che i requisiti informali specifichino anche i seguenti vincoli: Sia B che I escludono P (e cioè non è possibile richiedere, per la stessa porzione di testo, sia testo normale che corsivo). E e S E sono mutuamente esclusive.

Poiché B e P sono mutuamente esclusivi, la tupla < t r u e , f a l s e , t r u e , ...> non descrive un comando di ingresso valido, in quanto specificherebbe una richiesta di ottenere sia grassetto che testo normale. È possibile usare la seguente notazione per specificare vincoli: •

Un collegamento punteggiato etichettato e, come nella Figura 6.5, che collega le variabili logiche a , b e c specifica che al più uno tra a , b e c può essere vero.



Un collegamento punteggiato etichettato i indica che almeno uno degli argomenti tra a , b e c deve essere vero.

Figura 6.4

Grafo and-or parziale per un word processor.

Figura 6.5

Rappresentazione grafica dei vincoli tra variabili logiche.



Un collegamento punteggiato etichettato o indica che uno e solo uno tra a , b e c deve essere vero.



Un collegamento punteggiato orientato da a a b etichettato r , specifica che a b (e cioè che a i m p l i e s b).



Un collegamento punteggiato orientato da a a b etichettato m, indica che a b (e cioè che a i m p l i e s n o t b).

richiede

maschera

Vincoli simili possono essere imposti anche sulle variabili di uscita. Se questi vincoli sono specificati sia sui valori d'ingresso che su quelli di uscita, risulta necessario verificarne la compatibilità. La Figura 6.6 è un arricchimento della Figura 6.4 che mostra tutti i vincoli sulle variabili d'ingresso e d'uscita. • Il grafo che si ottiene seguendo la procedura descritta nell'Esempio 6.9 viene chiamato grafo causa-effetto delle specifiche di un programma. Il grafo causa-effetto può essere usato per guidare la verifica di un programma attraverso il principio di completa copertura in maniera abbastanza ovvia, e cioè generando tutte le possibili combinazioni d'ingresso e verificando se le uscite corrispondono alle specifiche. In realtà, il grafo può anche essere usato per veri-

Figura 6.6

Frammento della Figura 6.4, con l'aggiunta di un diagramma che mostra i vincoli tra le variabili.

ficare la specifica stessa. Infatti può mostrare un'incoerenza se, ad esempio, l'uscita corrispondente a un ingresso inammissibile viola i vincoli di compatibilità del grafo. Inoltre, il grafo può mostrare l'incompletezza se non si trova un valore d'ingresso che generi un'uscita ammissibile. Se la dimensione del test risulta eccessiva, è possibile cercare di ridurla applicando questo concetto: per ogni combinazione ammissibile di valori di uscita, si trovino alcune combinazioni dei valori d'ingresso che causano quella combinazione dei valori d'uscita e si effettui poi una propagazione all'indietro nel grafo7. Le combinazioni dei valori d'ingresso sono selezionate fra tutte quelle possibili secondo i seguenti metodi euristici8. 1. Nel procedere all'indietro attraverso un nodo or il cui valore d'uscita deve essere vero, usiamo soltanto valori delle combinazioni d'ingresso che hanno soltanto un valore vero. In questo modo si esercita ciascuna causa in maniera indipendente rispetto alle altre, assumendo che la combinazione di due cause non alteri gli effetti separati di ciascuna di esse. Ad esempio, se l'uscita di un nodo or deve essere t r u e , e l 1 ; I 2 sono gli ingressi, consideriamo soltanto { , } . 2. Analogamente, nel procedere all'indietro attraverso un nodo and il cui risultato in uscita è falso, usiamo solo combinazioni d'ingresso che hanno un solo valore falso.

7

Per semplicità, ignoriamo tutte le azioni che vengono eseguite se durante questo procedimento si scoprono incoerenze o incompletezze. 8 Queste regole sono una versione semplificata di quelle date da Myers [1979].

Esercizi 6.11

Prendete in considerazione u n grafo causa-effetto nel quale si rappresentano anche i vincoli (ad esempio, si consideri il grafo della Figura 6.6). C o m ' è possibile rappresentare vincoli attraverso una tabella di decisione?

6.12

Fornite la decisione matematica di come la specifica di I / O di un p r o g r a m m a p u ò essere trasformata in una funzione booleana, come richiesto dalla tecnica dei grafi causa-effetto. Spiegate q u a n d o questo approccio è inefficace e q u a n d o non è assolutamente applicabile.

6.3.4.3

Test delle condizioni di confine

Abbiamo finora basato i criteri di test white-box e black-box sul partizionamento dei domini d'ingresso del programma in classi, assumendo che il comportamento del programma fosse "simile" per tutti gli elementi di ogni classe. Alcuni tipici errori di programmazione, tuttavia, sorgono ai confini tra due diverse classi. Ad esempio, spesso i programmatori usano erroneamente ' 0

read(x);

and

input2

read(y);

while

x

* y

loop

if

x

>

y

tben

x

s =

y

:=

x

- y;

else

end end

y

-

x;

i f;

loop;

wr i t e ( x ) ; {GCD

(inputH

input2,

output)}

Nel secondo f r a m m e n t o , il predicato GCD ( x , y , w ) va letto come "w è il massimo com u n divisore tra x e y " , che p u ò essere formulato nel m o d o seguente: exists and

6.4.2.2

zir

not

z2

(x = w* z] a n d

exists

h

(exists

y = w * z2 )

zy,

z2

(x = h * z ] and

y = h*z2)

and

h >

w)

Programmi con array

Le regole di prova finora usate consentono di dimostrare la correttezza di programmi costituiti da istruzioni molto semplici di un linguaggio di programmazione, mentre invece i linguaggi di programmazione reali offrono un insieme più ricco di istruzioni, che richiedono regole di prova più complicate. Non forniremo qui una trattazione dettagliata e completa di tutti i costrutti di un linguaggio di programmazione, in quanto può essere facilmente trovato nella letteratura specializzata. Tuttavia, desideriamo affrontare un ulteriore aspetto di rilevanza concettuale. Si consideri la seguente istruzione di assegnamento che riguarda una variabile indicizzata a(i)

:=

4;

Se applichiamo le regole di prova viste per le istruzioni di assegnamento, si ottiene un'asserzione del tipo { a(3)

= 2}

a ( i )

: =

{ a(3)

=

4 ; 2

and

a(i )

=

4 >

che è falsa se i = 3 quando viene eseguita l'istruzione. Chiaramente, il problema nasce come effetto della dipendenza dell'istruzione dal valore dell'indice. Per tener conto di questo problema, tutte le volte che a sinistra di un'istruzione di assegnazione appare una variabile indicizzata, occorre generalizzare la regola di sostituzione all'indietro. Vediamo come arrivare a questa generalizzazione. Per ogni asserzione Post, e per ogni istruzione del tipo a( i ) := e x p r e s s i o n , sia Pre l'asserzione ottenuta da Post sostituendo ogni occorrenza di una variabile indicizzata a ( j ) con il termine if

j =

i then

expression

else

a( j) ;

Possiamo allora usare la regola {Pre}

a ( i ) := e x p r e s s i o n ;

{Post} 14

per la nostra analisi di correttezza . Applicando la nuova regola al precedente esempio, si ottiene {(if

3 = i then

4 else

a(3))

= 2 and

(if

i = i then

4 else

a(i))

=

4}

a( i ) := 4 ; {a(3)

= 2 and

a(i)

= 4 }

che viene semplificato in {i * 3 and

a(3)

= 2 }

a( i ) := 4 ; {a(3)

= 2 and

a(i)

=

4}

Come prima applicazione della nuova regola, consideriamo un frammento di programma che inserisce un intero x in una tabella di n elementi. La tabella è implementata come un array di nmax elementi di tipo intero. Se prima dell'esecuzione del codice, n è minore di nmax, il programma deve garantire che x venga inserito all'interno della tabella. La dimostrazione formale di questi requisiti comporta la verifica della seguente formula: {n < if

nmax}

n < nmax n

then

: = n +

table(n) end

1 ; := x ;

if;

{n ^ n m a x

and

(exists

i(l

i i i n and

table(i)

=

x))}

Applicando la regola della sostituzione all'indietro modificata, si ottiene il seguente predicato: {n +

1 s nmax (exists

and i ( l s i s n + l (if

i = n +

and 1 then

x else

table

(i))

=

x))

)

}

Abbiamo omesso alcune sottigliezze matematiche per semplificare la notazione. Tuttavia, la regola semplificata può essere usata correttamente nella maggioranza delle situazioni pratiche.

Ora, n < nmax implica n + 1 < nmax. D'altra parte, (exists

i

(1 < i s n + 1 and

(if

i = n + 1 then

x else

table

(i)) = x ) )

è ovviamente soddisfatto da i = n + 1. Pertanto, la precondizione che abbiamo espresso garantisce la postcondizione desiderata. Come ulteriore esempio, supponiamo di voler dimostrare la seguente formula: {n > 1} i

:=

1;

j :=

found

:=

while

i < n

1;

false; loop

if t a b l e

(i) = x

found i

:=

then

true;

:= i + 1

else table i end end n

(j ) : = t a b l e

: = i + l ;

j

(i ) ;

: = j + l ;

if;

loop;

:=

{oot found

j -

1;

exists

m

(1 < m ^ n and

= exists

m

table(m)

(1 s m £ old_n

and

= x)

and

oldtable(m)

=

x)}

In questa formula o l d t a b l e e oldn sono costanti che denotano rispettivamente i valori di t a b l e e di n prima dell'esecuzione del frammento (Ricordiamo dal Paragrafo 5.6.2 che, talvolta, in una specifica formale è necessario riferirsi ai valori delle variabili in punti precedenti del flusso d'esecuzione). Intuitivamente, la specifica stabilisce che il programma dovrebbe cancellare dalla tabella tutte le occorrenze del valore x, se ne esistono, e dovrebbe assegnare alla variabile f o u n d un valore booleano che indica se x apparteneva alla tabella prima dell'esecuzione. Non è richiesto esplicitamente, tuttavia, che tutti gli altri elementi della tabella vengano conservati. Dimostriamo la correttezza del precedente frammento di programma per mezzo del seguente invariante di ciclo: I:

{(j i i) and

(i i oldn

and

(not

exists

and

(n =

old_n)

and

found

+

1) m

(1 i m < j and

= exists

m

table

(1 < m < i and

(m) =

x))

old_table(m)

= x) }

Innanzitutto, è facile mostrare che I and i > n implica il risultato della sostituzione all'indietro della postcondizione attraverso n : = j — 1. Successivamente, dimostriamo l'invarianza di I all'interno del ciclo. La sostituzione all'indietro del ramo t h e n fornisce {(j s i +

1) and

(i + 1 s old_n

and

(not

and

(n = old_n)

and

exists

+

1)

in ( 1 i m < j and and

oldtable(m)

exists = x)}

m

table(m)

=

(1 £ m < i + 1

x))

il quale è implicato da I and i £ n and t a b l e ( i ) Il ramo e l s e produce il seguente predicato { ( j + l i i + l )

and

and

(i + 1 < old_n

(not

exists and

+

= x.

1)

m ( l £ m < j + l

(i£ m = j t h e n t a b l e ( i ) t a b l e (m)) = x ) )

and

(n =

and

found = e x i s t s m ( l £ m < i + l o l d _ t a b l e ( m ) = x)>

else

oldn) and

Il fatto che I and i s n and t a b l e ( i ) * x implichi le prime due clausole è semplice da dimostrare. Dimostriamo che essi implicano anche (not e x i s t s m ( l ^ m < j + l

and

(if m = j t h e n t a b l e ( i ) e l s e table(in)) = x ) )

Per fare ciò, osserviamo che, per ogni m, con 1 s m < j , il predicato è implicato dall'invariante; quando m = j , esso è implicato dalla condizione dello statement i f . Allo stesso modo, possiamo dimostrare found

= exists

m

(1 i m < i + 1 and

old_table(m)

= x)

Infine, è chiaro che la precondizione n > 1 implica il risultato della sostituzione all'indietro di I attraverso i := l ; j := 1; f o u n d := f a l s e . Ciò completa la dimostrazione di correttezza. La tecnica che abbiamo mostrato qui per operare con gli array ci consente anche di operare in modo formale con le istruzioni di input-output. Infatti, possiamo trattare i file i n p u t e o u t p u t come degli array illimitati che hanno associati rispettivamente indici Ci e c 0 , inizializzati a uno e incrementati automaticamente dopo ogni operazione di input-output. Più precisamente, l'istruzione r e a d ( x ) può essere vista come un'abbreviazione di x

Ci

:=

input

(Ci);

: = Ci + 1 ;

Analogamente, w r i t e ( x ) può essere considerata come l'abbreviazione di output c0

;—

(c 0 ) CQ

+

:=

x;

1;

Esercizio 6.26

Arricchite la precedente specifica dell'operazione venga cancellato alcun e l e m e n t o di o.Zd_table elemento. Si specifichi inoltre che il n u m e r o di D i m o s t r a t e la correttezza del p r o g r a m m a rispetto

6.4.2.3

di cancellazione c o n la richiesta che n o n oltre a x e che n o n venga a g g i u n t o alcun e l e m e n t i n o n d e b b a mai superare nmax. alla n u o v a asserzione.

Prove di correttezza in grande

La distinzione tra tecniche "in piccolo" e "in grande" si può applicare alle prove di correttezza cosi come sono state applicate al test, alla specifica e al progetto. Non è infatti possi-

bile applicare a sistemi reali complessi e di grandi dimensioni le stesse tecniche che valgono per sistemi semplici ed elementari. Come di consueto, la modularizzazione fornisce l'approccio fondamentale per passare dal piccolo al grande. Non dovrebbe sorprendere pertanto che si cerchino di costruire e gestire prove di correttezza per sistemi di grande dimensione modularizzandole, così come abbiamo fatto in altri casi. In questo paragrafo suggeriamo brevemente come si possono costruire prove di correttezza modulari. Come per il test, anche in questo caso non ci si può limitare ad esaminare solamente il software ma, in molti casi, occorre tener conto dell'intero sistema nella prova di correttezza. ESEMPIO 6.14

Si supponga di avere definito e implementato un tipo di dato astratto TABLE: module

TABLE;

exports type

Table_Type

(max_size:

NATURAL):

?;

non più di max-size elementi possono essere memorizzati in una tabella; i moduli utenti devono garantirlo p r o c e d u r e I n s e r t ( T a b l e : in out T a b l e T y p e ; E L E M E N T : in ElementType); procedure Delete ElementType);

end

function

Size

fornisce

la

(Table:

(Table:

dimensione

in out

TableType;

in T a b l e T y p e ) corrente

di

return

una

ELEMENT:

in

NATURAL;

tabella

TABLE

Ogni operazione astratta può essere specificata formalmente mediante pre e postcondizioni, quali: {true) Delete(Table, {Element

£

{Size(Table) Insert(Table, {Element

e

Element);

Table};


e cioè, non implica il risultato della sostituzione all'indietro attraverso il corpo del ciclo. In realtà, come ben sappiamo, il frammento di programma è scorretto; dopo averlo corretto potremmo essere in grado di dimostrare l'asserzione. L'asserzione, tuttavia, non è una specifica completa del frammento, ma contiene soltanto i fatti che si considerano critici. In pratica, se si volesse fornire la specifica completa e la relativa dimostrazione di correttezza, il procedimento sarebbe alquanto complicato (si veda l'Esercizio 6.71). Ma l'esempio dimostra che non c'è bisogno di procedere attraverso una specifica completa e la sua dimostrazione di correttezza per valutare il programma. Se esistono fatti critici che si vogliono valutare (nell'esempio, l'indicizzazione degli array), è possibile specificare tali fatti critici e dimostrarli in modo formale. Questo è un esempio dei principi di separazione degli interessi e di astrazione con i quali si estraggono dal programma soltanto gli aspetti critici. Ciò risulta particolarmente utile quando il programma è vasto e complesso. Sottolineiamo anche che le asserzioni di un programma - pre e post condizioni e asserzioni intermedie - possono essere usate come modo formale per esprimere commenti all'interno di un programma, e quindi sia per guidare le prove di correttezza che per eliminare gli errori di un programma. In altri termini, la formulazione di proprietà formali degli stati di esecuzione in alcuni punti critici del programma può servire a localizzare ed eliminare errori (debugging) e dimostrare la loro assenza (prova di correttezza). Ritorneremo al tema del debugging di un programma mediante asserzioni nel Paragrafo 6.8. L'utilità delle tecniche di analisi formale può essere ulteriormente aumentata dall'uso di strumenti (semi) automatici. Infatti, nella prova formale di correttezza esistono alcuni punti nei quali occorre capacità inventiva (generalmente, nell'individuare invarianti di ciclo e nel dimostrare le implicazioni logiche), ma esistono anche molti passi che potrebbero essere meccanizzati (le sostituzioni all'indietro e alcune semplificazioni algebriche). E naturale lasciare questi ultimi aspetti al computer per consentire all'utente di concentrarsi solo sugli aspetti critici. La disponibilità, negli anni recenti, di strumenti ben ingegnerizzati per dimostrare la correttezza dei programmi ha favorito l'adozione della tecnica in alcuni importanti progetti industriali. Infine, riteniamo che una buona conoscenza delle tecniche di analisi formale possa contribuire a migliorare il modo in cui si sviluppa l'analisi informale da parte dei programma-

Cori. Infatti, il rigore e la formalità migliorano sempre l'affidabilità del ragionamento informale. Riassumendo, è vero che le prove formali di correttezza hanno tuttora un'applicabilità industriale molto limitata ma, se ben comprese e utilizzate, hanno un elevato potenziale di miglioramento della qualità dei sistemi software. Infatti, negli anni recenti, si sono avute testimonianze di successo nella loro applicazione a progetti reali. Questi fatti verranno ulteriormente discussi nel contesto dell'adozione dei cosiddetti metodi formali nell'ambito industriale, di cui parleremo nel caso di studio D dell'Appendice. Esercizi 6.27

Si può dimostrare q u a n t o segue rispetto alle specifiche fornite per il m o d u l o TABLE in questo paragrafo? Perché? Perché no? {max_si z e > 1 } De lete(Table, Element); Insert(Table, Element); {Element e Table}

6.28

Modificate le specifiche fornite nel Paragrafo 6.4.2.3 per le operazioni Insert e Delete, in m o d o tale che, sia {Size(Table) < max_size} Insert(Table, Element); Delete{Table, Element) ; {Table = Old_Table - Element)

sia {Size(Table)> 1 } De let e(Tab le, Element); Insert(Table, Element); {Element e Table}

diventino dimostrabili. Fornite q u i n d i u n ' i m p l e m e n t a z i o n e e dimostratene la correttezza rispetto alle specifiche. Se n o n dovesse risultare corretta, modificatela.

6.5

Esecuzione simbolica

L'esecuzione simbolica è una tecnica di verifica che può essere classificata come intermedia tra il test e l'analisi, in quanto costituisce una sintesi tra approcci sperimentali e analitici alla verifica del software. Si consideri il seguente programma: read(a);

x

read(b);

:= a + b;

write

(x ) ;

Il computer dovrebbe leggere dei valori in ingresso per effettuare la computazione e produrre opportuni valori d'uscita. Invece, un'analisi informale (ad esempio, durante un walk-through) potrebbe procedere nel modo seguente: Siano A e B i primi due valori del file d'ingresso. Essi vengono assegnati rispettivamente alle variabili a e b . Successivamente, l'effetto dell'assegnamento x = a + b è che x assume il valore A + B , che viene poi stampato.

Il vantaggio di questo tipo di ragionamento, rispetto al calcolo basato su valori numerici, risiede nella sua generalità: A e B rappresentano qualunque valore delle variabili d'ingresso. Pertanto, possiamo concludere che il risultato del programma è la loro somma, indipendentemente da quale sia il loro valore numerico. Invece, se noi semplicemente testassimo il programma fornendo 3 e 4 come valori d'ingresso, il fatto che il risultato sia il valore 7 fornisce un'evidenza limitata che la funzione del programma sia effettivamente il calcolo della somma. La differenza principale dell'esecuzione da parte di un computer e la simulazione manuale del programma è che, in un'esecuzione da parte del computer, le variabili assumono valori numerici attuali16, mentre in una simulazione manuale diamo loro valori simbolici. E per questo che abbiamo detto che il valore di a era A e che, dopo l'esecuzione, x ha assunto il valore A + B. Senza entrare nella teoria formale dell'esecuzione simbolica, per la quale il lettore può far riferimento alla letteratura specializzata riportata nelle note bibliografiche, possiamo dire che la tecnica si basa sul fatto che il dominio dei possibili valori delle variabili di un programma è l'insieme delle espressioni costruite mediante valori simbolici e simboli di operazione. Anche senza molte definizioni matematiche, il lettore può vedere che eseguire simbolicamente il seguente programma dà come risultato le espressioni tra parentesi quadre: read(a);

read(b);

x : = a + l ; y : = x *

b;

wr i te ( y ) ;

[ (A + 1)

* B]

read(a);

read(b);

x

: = a +

1; x

: = x + b +

2 ;

write(x);

[A + B + 3]

Dal secondo esempio notiamo che nel calcolo dei valori simbolici abbiamo applicato alcune semplificazioni di formule. L'esecuzione simbolica è una tecnica che si pone come candidata per l'analisi dei programmi. Si consideri, ad esempio, il seguente frammento con associate asserzioni: {true} read(a ) ; x

: = a * a;

x

: = x +

1 ;

write(x); {output

>

0}

La valutazione simbolica del frammento produce il risultato o u t p u t = A2 + 1. Pertanto, una semplice deduzione aritmetica ci consente di concludere che la specifica è eflfettiva-

16 In questo paragrafo parliamo di computazione numerica e valori numerici, rispetto alle computazioni simboliche e ai valori simbolici. Il termine "numerico" si applica non solo a valori effettivamente numerici, ma anche a valori che in senso stretto non lo sarebbero (come i caratteri e i booleani).

mente rispettata. Il metodo che seguiamo è simile a quello che abbiamo sviluppato per le prove di correttezza. Inoltre, molte delle trasformazioni simboliche necessarie per condurre questa analisi possono essere effettuate in maniera automatica, esattamente come la sostituzione all'indietro nel caso delle prove di correttezza. La situazione si complica, tuttavia, non appena l'esecuzione simbolica viene applicata a programmi non banali. Ad esempio, nel seguente frammento read(a); if

a >

0

then

DO_CASE_l; else D0CASE2; end

if;

l'esecuzione simbolica non è in grado di procedere quando incontra il condizionale, in quanto il valore simbolico di a non possiede sufficienti informazioni per consentire la scelta tra l'esecuzione di DO_CASE_l o DO_CASE_2. Un modo per affrontare questo problema è quello di scegliere un particolare cammino e di procedere lungo questo percorso. Mostreremo nel seguito come questo problema possa essere trattato in modo generale, descrivendo i concetti fondamentali che consentono l'esecuzione simbolica per un semplice linguaggio di programmazione convenzionale. Affronteremo più avanti, invece, alcuni dettagli che consentono di eseguire simbolicamente programmi con array e programmi concorrenti. Infine, discuteremo l'utilizzo dell'esecuzione simbolica come supporto al test dei programmi.

6.5.1

Concetti fondamentali dell'esecuzione simbolica

Si consideri il seguente frammento di programma e si assuma che all'inizio della sua esecuzione i valori simbolici delle variabili siano x = X,a = A e y = Y: x

: = y + 2 ;

if

x > a

then

a

: = a + 2 ;

y

:= x +

else

end x

3;

i f;

: = x + a + y;

Dopo l'esecuzione della prima istruzione, si ha x = Y + 2, con tutte le altre variabili inalterate. Raggiunto il condizionale, poiché il confronto Y + 2 > A potrebbe dare sia il risultato vero che falso, scegliamo arbitrariamente di eseguire il ramo e l s e . Ciò porta allo stato finale {a = A, y = Y + 5, x = 2 * Y + A + 7}. Dobbiamo però ricordarci del fatto che si è omessa l'esecuzione del ramo e l s e dello statement i f . Ciò può essere fatto tenendo traccia dei cammini eseguiti, ad esempio, con riferimento al grafo del flusso di controllo del programma e memorizzando la condizione che deve essere verificata dai valori simbolici, al fine di garantire l'attraversamento del cammino scelto. In questo caso, la condizione è Y + 2 £ A. Come risultato, possiamo affermare che l'esecuzione simbolica sopra descritta descrive la tripla

,

,

Y + 2 i A>

dove indica il cammino di esecuzione con riferimento al grafo del flusso di controllo mostrato nella Figura 6.10. La condizione che garantisce l'esecuzione di un certo cammino viene chiamata path condition (condizione sul cammino). Data una path condition, il corrispondente cammino di esecuzione può essere derivato immediatamente dal programma e viceversa. Pertanto, una sola delle due informazioni è necessaria per descrivere l'esecuzione simbolica. Per il momento, tuttavia, terremo conto di entrambe. Vedremo più avanti che la memorizzazione del cammino di esecuzione è necessaria nel caso di programmi concorrenti. In generale, sia P un frammento di programma e Gp denoti il grafo del flusso di controllo di P. Il risultato di eseguire simbolicamente P su un cammino di Gp è lo stato simbolico di P, definito come la tripla < s y m b o l i c _ v a r i a b l e _ v a l u e s , e x e c u t i o n _ p a t h , p a t h _ c o n d i t i o n > . L'insieme s y m b o l i c _ v a r i a b l e _ v a l u e s descrive i legami delle variabili con i loro valori simbolici mediante equazioni del tipo variable_identifier

=

symbo1ic_expression

dove s y m b o l i c e x p r e s s i o n è composta nel modo usuale mediante identificatori che denotano i valori simbolici delle variabili (per semplicità usiamo le lettere minuscole per indicare identificatori di variabili e lettere maiuscole per i valori simbolici). Il cammino di esecuzione è una sequenza di archi contigui in Gp. La path condition è un'espressione logica che denota la condizione sui valori simbolici delle variabili che garantisce l'attraversamento del cammino d'esecuzione.

Figura 6.10 Grafo del flusso di controllo con annotazioni. Il nodo di diramazione è contrassegnato dalla condizione corrispondente del frammento di programma, gli archi sono invece contrassegnati dagli statement corrispondenti.

Prima che inizi l'esecuzione simbolica, lo stato simbolico dell'interprete viene inizializzato in modo da mostrare i valori simbolici delle variabili come non definiti, il cammino d'esecuzione nullo e la path condition vera. Quando si incontra una nuova istruzione, l'interprete simbolico aggiorna lo stato simbolico nel modo seguente. 1. L'esecuzione di un'istruzione di input r e a d ( x ) rimuove ogni legame esistente per x nello stato e aggiunge il legame x = X, dove X è un valore simbolico generato ex novo, che non appare in qualunque altro stato precedente. 2. L'esecuzione di un'istruzione di assegnamento x := e x p r e s s i o n genera una nuova espressione simbolica, SV, costruita mediante i valori simbolici delle variabili che appaiono nell'espressione (i dettagli di questa costruzione dovrebbero risultare ovvi dagli esempi precedenti). Viene poi stabilito il legame x = SV che viene memorizzato nello stato, in sostituzione di un eventuale legame preesistente per x. 3. L'esecuzione di un'istruzione di output w r i t e ( e x p r e s s i o n ) causa la valutazione simbolica dell'espressione, come nel caso precedente, e il legame o u t p u t (n) = c o m p u t e d _ s y m b o l i c _ v a l u e , dove n indica un contatore associato con il file di output. Il contatore n è inizializzato a uno e automaticamente incrementato dopo ogni istruzione di output. 4. Dopo l'esecuzione dell'ultima istruzione di una sequenza corrispondente a un arco del grafo Gp, il nome dell'arco viene aggiunto alla stringa che rappresenta il cammino di esecuzione. 5. L'esecuzione di un'istruzione condizionale del tipo i f cond t h e n S ^ e l s e S 2 ; e n d i f , oppure w h i l e cond l o o p . . . e n d l o o p provoca l'esecuzione della seguente sequenza di passi: a.

si valuta la c o n d i z i o n e s o s t i t u e n d o i valori simbolici attuali alle variabili che a p p a i o n o nella condizione. D e n o t i a m o c o n e v a l ( c o n d ) il risultato simbolico;

b. se possiamo d e d u r r e che e v a l ( c o n d ) sia vera o falsa, i n d i p e n d e n t e m e n t e dai valori simbolici 1 7 che la c o m p o n g o n o , l'esecuzione c o n t i n u a seguendo la conseguente diramazione del flusso di controllo, altrimenti: c.

si effettua u n a scelta non-deterministica del valore vero o falso. Nel p r i m o caso, e v a l ( c o n d ) viene c o m p o s t a in a n d con la path c o n d i t i o n corrente. Nel secondo, not ( e v a l ( c o n d ) ) viene c o n g i u n t a in and c o n la p a t h c o n d i t i o n . In e n t r a m b i i casi, l'esecuzione procede poi l u n g o il c o r r i s p o n d e n t e arco di G p .

Applicando questo procedimento di esecuzione simbolica al frammento riportato all'inizio di questo paragrafo, si ottengono due diverse triple come risultati. Pertanto, oltre a quella sopra riportata, si ottiene la seguente tripla mediante la selezione del ramo t h e n del condizionale. A>

Ovviamente, in molti casi, l'insieme delle triple che costituiscono il risultato dell'esecuzione simbolica di un frammento di programma può essere infinito, in quanto infinito può essere il numero di cammini del grafo di controllo.

17 Si ricordi che, in generale, valutare le implicazioni logiche di espressioni simboliche è indecidibile. N o n è quindi possibile farlo in m o d o totalmente meccanico e potrebbe essere necessario un intervento umano.

L'esecuzione simbolica di costrutti di programmazione tradizionale come i f - t h e n e w h i l e stabilisce una corrispondenza uno ad uno tra un cammino di esecuzione e la path condition. Vale a dire, una path condition determina univocamente un cammino di esecuzione e viceversa. Ciò è dovuto alla natura deterministica dei costrutti, in quanto, per ogni stato d'esecuzione, quando le variabili hanno un valore numerico, lo stato successivo da eseguirsi viene determinato in maniera univoca. In tal caso è possibile eliminare dallo stato simbolico la componente che denota il cammino di esecuzione. Ciò non è possibile se il linguaggio contiene strutture di controllo non deterministiche, come avviene nel caso di programmi concorrenti. else

Esercizi 6.29 6.30

6.5.2

Fornite regole per l'esecuzione simbolica di costrutti di p r o g r a m m a z i o n e classici c o m e c a s e

o repeat-until.

Calcolate le triple che derivano dall'esecuzione simbolica a u m e n t a n d o la lunghezza dei c a m m i n i d'esecuzione dei p r o g r a m m i dell'Esercizio 6.25.

Programmi con array

L'esecuzione simbolica diventa più complessa quando bisogna tenere conto degli array, per le stesse ragioni che abbiamo visto nel caso della dimostrazione di correttezza. Illustreremo brevemente qui di seguito due modi possibili per risolvere questo problema. Il primo consiste nel considerare ogni accesso a un array, il cui valore dell'indice non sia noto numericamente, come se fosse una diramazione del flusso di controllo, con un ramo per ogni possibile valore dell'indice. In altri termini, sia a un array di 10 elementi. L'espressione a ( i ) : = exp può essere vista come un'abbreviazione per case i of when

1 => a(l)

:= exp;

when 2 => a(2)

:= exp;

Questo approccio è semplice ma genera una proliferazione di cammini d'esecuzione, e può essere usato in pratica solo per l'esecuzione interattiva o con array di piccole dimensioni. Un approccio più generale è quello di considerare che l'assegnamento a un elemento di array produca un nuovo valore simbolico per l'intero array e memorizzare opportune relazioni tra il vecchio valore e il nuovo. Ad esempio, sia Aj il valore simbolico dell'array a in un certo punto del programma dove viene eseguita l'istruzione a ( i ) = e x p . Dopo l'esecuzione di questa espressione, a riceve il nuovo valore simbolico A2, che si può denotare come A2 = A! < i , exp>, che rappresenta un'abbreviazione per for ali k if k = i then A 2 ( k ) = exp else A 2 (k) = A ^ k )

Si può vedere qui una similitudine tra questa formula e la regola di sostituzione all'indietro che abbiamo fornito per le prove di correttezza. Illustriamo ora il secondo metodo con l'aiuto di un esempio.

ESEMPIO 6 . 1 5

Si consideri il seguente frammento di programma, in cui x denota un array di interi di lunghezza 5: 1.

read(i);

2.

y

3.

x(3)

4 .

read ( i ) ;

5.

x(i)

6 .

y

7.

read ( i ) ;

8.

: = x( i ) ; :=

9;

:=

3 + y;

: = x( 2) ;

x(i):=x(i)-l;

9.

y

:= y +

x(i);

Supponiamo che l'esecuzione parta da uno stato iniziale in cui, alla variabile x sia legato il valore simbolico X t . L'esecuzione delle istruzioni da 1 a 9 produce i seguenti legami. 1.

read(i);

i =

2.

y

y = X, (I,)

:= x ( i ) ;

3.

x(3)

4.

read(i);

:=

5.

x(i)

6.

y

7.

read ( i ) ;

:=

9;

x = X2 w h e r e i = I

3 + y;

B.

x(i) y

X2 = X3 < 3 ,

9>

2

x = X 3 w h e r e X 3 = X 2 < I 2 , 3 + X! ( I ^ c i o è , Xj(I 2 ) = 3 + X ^ I , ) 1 8

:= x ( 2 ) ;

9.

I[

y =

X,(2)

i = 13

:= x ( i ) -

1;

: = y + x( i) ;

x = X, w h e r e

X4 = X2 < I 3 ,

X3(I3)

-

1>

y = X 3 ( 2 ) + X, ( I 3 )

Si noti che lo stato simbolico può essere espresso come una funzione dei valori simbolici letti e del valore iniziale Xi di x, usando sostituzioni che applicano le relazioni memorizzate nello stato. Eseguendo queste operazioni, otteniamo il seguente valore simbolico per la variabile y dopo l'esecuzione simbolica del frammento: y

= X i< 3 , 9 > < 1 2 , 3 + X l ( I , ) > ( 2 ) Xt< 1 2 , 3 + Xx ( I ! ) >< 1 3 , X j< 3 , 9 >< 1 2 , 3 +

X1(I1)>(I3)

-

1>(I3)



Esercizio 6.31

Calcolate diversi c a m m i n i d'esecuzione per il p r o g r a m m a dell'Esempio 6.12 (si consideri il caso in cui il ciclo viene eseguito una volta sola e si osservi come il trattamento degli array diventi più semplice q u a n d o gli indici h a n n o u n valore numerico anche durante l'esecuzione simbolica).

18 Si potrebbero eseguire alcune ottimizzazioni. Infatti, dato che x non è stato usato dopo il suo precedente assegnamento, si può semplicemente modificare il suo valore corrente mediante l'espressione

x = X2 w h e r e

X2

=

X! < 3 ,

9>.

6.5.2.1

Esecuzione simbolica di programmi concorrenti

In questo paragrafo esamineremo l'uso dell'esecuzione simbolica nell'analisi del software concorrente usando, in particolare, le reti di Petri. I commenti forniti, tuttavia, hanno un uso generale e non dipendono dal particolare formalismo usato per la descrizione dei sistemi concorrenti. Prendiamo in considerazione reti di Petri arricchite, nelle quali i token hanno dei valori e ogni transizione ha associata un'azione e un predicato che determina se la transizione può scattare a seconda dei valori dei token in ingresso. L'esecuzione simbolica ci consente di usare valori simbolici per i token e di valutare i predicati associati alle transizioni in maniera simbolica. In un programma sequenziale e deterministico, lo stato dell'interprete simbolico, in ogni punto, è dato da una tripla < s y m b o l i c _ v a r i a b l e _ v a l u e s , e x e c u t i o n p a t h , p a t h _ c o n d i t i o n > , dove p a t h _ c o n d i t i o n determina univocamente il cammino d'esecuzione. In un programma concorrente, il cammino d'esecuzione è una sequenza di passi atomici di esecuzione. Nel caso specifico di una rete di Petri, un passo atomico viene modellato dallo scatto di una transizione, che consuma una tupla di token dai suoi posti d'ingresso. Se assumiamo per semplicità che in ogni posto non sia mai presente più di un token, una sequenza di passi atomici può essere modellata da una sequenza di scatti, che risolve il non-determinismo dovuto al fatto che più transizioni possono essere abilitate allo stesso istante. Pertanto, la tripla < s y m b o l i c _ v a r i a b l e _ v a l u e s , e x e c u t i o n _ p a t h , p a t h _ c o n d i t i o n > può essere usata per modellare lo stato simbolico dell'interprete assumendo che e x e c u t i o n _ p a t h denoti la sequenza di scatti. Consideriamo la specifica di un sistema concorrente fornita nella Figura 6.11. La rete di Petri rappresenta i messaggi che possono essere ricevuti e distribuiti su uno fra tre canali. L'azione che genera una messaggio in una mail-box viene modellata mediante l'istruzione m : = f ( ). La funzione f senza argomenti indica che i messaggi sono inviati al sistema dall'ambiente esterno, senza alcuna relazione con lo stato interno del sistema. Pertanto f viene utilizzata come un generatore di numeri casuali.

Figura 6.11 Rete di Petri che descrive un canale di trasmissione.

Quando si esegue simbolicamente una rete di Petri, si parte con un valore iniziale vero della path condition. Una transizione può scattare se il predicato ad essa associato risulta implicato dalla path condition. In tal caso decidiamo di far scattare una transizione, occorre aggiornare il cammino d'esecuzione, e cioè la sequenza di scatti (firing sequence). Una transizione può scattare anche se la path condition non implica né la verità né la falsità del suo predicato. In questo caso, se decidiamo di far scattare una transizione, risulta necessario sia modificare il cammino d'esecuzione sia la path condition, mediante la congiunzione in and del predicato valutato. Si consideri la path condition M!.ok_parity

and

not

M2.ok_parity

and

M3.ok_parity

in cui Mi, M2 e M3 sono i valori simbolici dei messaggi ricevuti (e cioè, i token generati dalla transizione m e s s a g e _ r e c e p t i o n ) e M ± . o k _ p a r i t y ( c o n i = 1, 2 , 3 ) indica se il bit di parità di Mi è corretto. Si può osservare che la path condition rende possibile la seguente sequenza di scatti:

send_nack,

Nell'esempio, quando una path condition contiene Mi. o k _ p a r i t y , possono scattare due transizioni, in quanto il predicato loro associato è vero. La sequenza di scatti risolve la scelta tra essi. Pertanto, per caratterizzare in maniera univoca un cammino d'esecuzione, sono necessarie sia la path condition che la sequenza di scatti. Si può usare la c o p p i a < f i r i n g s e q u e n c e , p a t h c o n d i t i o n > per guidare l'attività di test. Per esempio, se la rete nella Figura 6.11 fosse la specifica di un sistema di comunicazione, si potrebbe usare la path condition per ricavare vincoli sui messaggi d'ingresso forniti al sistema sotto test e per verificare che il sistema esegua le proprie azioni nella stessa sequenza specificata dalla coppia. Sfortunatamente, a causa del non determinismo del sistema, sarebbero valide anche altre sequenze di azioni e dovremmo verificare se la sequenza osservata sia effettivamente valida, in base alla descrizione della rete della figura. Se volessimo riprodurre esattamente la stessa sequenza di azioni specificata nella coppia, potremmo incorrere in alcuni problemi, in quanto dovremmo essere capaci di risolvere il non determinismo esattamente come esso risulta risolto nella sequenza di scatti specificata dalla coppia. Ciò può essere diffìcile da ottenere se alcune azioni sono eseguite dall'ambiente esterno in maniera autonoma e non controllabile come potrebbe essere nel caso di messaggi ricevuti in una mail box. Su questo tema specifico, il lettore interessato può fare riferimento alla letteratura specializzata riportata nelle note bibliografiche.

6.5.3

Uso dell'esecuzione simbolica nel test

L'esecuzione simbolica è uno strumento concettuale potente, che può essere visto come una via di mezzo tra la generalità e il rigore delle prove di correttezza e la semplicità concettuale del test. Infatti, il risultato dell'esecuzione simbolica è costituito da formule che ci consentono di dedurre proprietà del programma. La tecnica è tuttavia ancora in evoluzione, e la sua applicabilità pratica a programmi complessi e di grandi dimensioni è discutibile.

L'esecuzione simbolica può essere usata, tuttavia, in maniera indiretta, come aiuto nella fase di test. Più precisamente, può aiutare nella selezione dei dati di test per uno specifico cammino d'esecuzione. Infatti, una volta che si sia scelto un cammino nel grafo di controllo di un programma (per esempio, con l'obiettivo di raggiungere un'istruzione nel caso del criterio di copertura delle istruzioni), è possibile costruire in maniera algoritmica una path condition che garantisca l'attraversamento del cammino. Il problema della garanzia di attraversamento del cammino si riduce quindi alla dimostrazione di soddisfacibilità di una formula matematica. Ad esempio, si consideri il seguente frammento di programma, già discusso nel Paragrafo 6.3.4.1, il cui flusso di controllo è illustrato nella Figura 6.12: found

:=

while

(not if

false;

found

:=

:=

1 ;

counter

< numberof

= desired_e1ement

items

loop

then

true;

if;

counter

if

and

table(counter)

end

end

counter

found)

:= c o u n t e r

+

1 ;

loop; found

then

write

("the

desired

element

exists

write

("the

desired

element

does

in

the

table");

else

end

not

exist

in

the

table");

if;

Se eseguiamo simbolicamente il cammino < l , 2 , 3 , 5 , 6 , 7 , 9 > , s i ottiene la path condition table(l)

=

desired_element

o 1

^ ^ ^ ^

c o u n t e r : = counter + 1

6 write "the desired element does not exist in the table"

write "the desired element exists in the table

Figura 6.12 G r a f o del flusso di c o n t r o l l o per il f r a m m e n t o di p r o g r a m m a del Paragrafo 6 . 3 . 4 . 1 . I rami s o n o etichettati m e d i a n t e le relative istruzioni di a s s e g n a m e n t o .

Se invece cerchiamo di eseguire il cammino path condition parziale, 1
,si ottiene, come

number_of_items

and

table(l)

and

not

(true)

=

desired_e1ement

and

2
; { R l f N r } c o n < P 4 / P 2 , P 3 > ; { N l f Rr> c o n < P [ , {Nx,

P5,

Cr>con;{C1, P7>;{Clf

Nr} con . {Rlf

Rr}con

< c.contents(m))

and

x

(x = c. c o n t e n t s

exists

(p))

m

(x = b . c o n t e n t s ( m ) ) )

) and

implies exists

m

(x =

b.contents(m))

) )

L'esecuzione della procedura con dati d'ingresso a =

{1,3,5,7}

, b =

{2,3,8}

produce un risultato erroneo c = { l , 2 , 3 , 3 , 5 , 7 , 8 } , i l quale viola il requisito di output {(1

£ 1 < m £ c.size)

implies

c.contents(1)


j - 1. La prima clausola di I formalizza la nozione intuitiva che, a ogni iterazione del ciclo, x memorizzi la ( j - 1 ) -esima potenza di z, dove j è il contatore del ciclo. La seconda clausola viene usata per assicurare y = j — 1 all'uscita dal ciclo.

Parte b. Invariante suggerito: il massimo c o m u n divisore di x e y è uguale al massimo c o m u n divisore di i n p u t ! e i n p u t 2 (si veda l'esercizio per la definizione di massimo c o m u n divisore).

Attenzione:

Usate identificatori diversi per le variabili quantificate (cioè le variabili che sono soggette a e x i s t s e f o r ali) e per le variabili di programma.

6.33

Sia p la probabilità che, durante il restart, accada un fallimento di inizializzazione. Sia { P i } l'insieme delle probabilità di tutti i fallimenti che possono dar luogo a una re-inizializzazione del sistema (incluso lo spegnimento del sistema da parte dell'operatore). La probabilità del fallimento di inizializzazione è uguale a

6.34

Consideriamo un riferimento a un elemento di array come una chiamata di funzione (ovvero dovrebbe essere considerato come u n operatore applicato a una lista di operandi). La coppia " ( , ) " è anch'essa un operatore e anche la virgola stessa. In istruzioni del tipo a : = b, dove a e b sono array, a e b dovrebbero essere considerati operandi

6.51

Inizialmente forniamo sequenze di stimoli 11 senza stimoli di tipo i 2 , e successivamente facciamo l'opposto. Infine cerchiamo di alternare entrambi i tipi di stimoli mediante sequenze più complesse.

6.15

Si può usare una grammatica "negativa" per generare il c o m p l e m e n t o di un linguaggio. Nel fare ciò, è possibile integrare un approccio sistematico al problema (i linguaggi n o n contestuali deterministici sono chiusi rispetto al complemento) con b u o n senso e sistematicità. Il compilatore n o n dovrebbe soltanto individuare gli errori, ma dovrebbe fornire un aiuto nel correggerli. Questo è u n tipico requisito che è praticamente impossibile da formalizzare. Tutti i programmatori h a n n o p o t u t o sperimentare che u n b u o n compilatore si comporta in maniera prevedibile su programmi corretti o contenenti errori semplici, m a si comporta in m o d o sempre m e n o prevedibile q u a n d o incontra programmi che contengono numerosi errori.

6.58

Sia c una condizione del tipo e , or c 2 . . . or c „ . Invece di tentare tutte le possibili combinazioni dei valori di input che r e n d o n o Ci vero e falso, cercate soltanto il caso in cui esse sono tutte false e gli n casi in cui solo una è vera. Operate analogamente con le condizioni and.

P " ^ÌPÌ • P = P • ^iPi

6.59

U n a conoscenza elementare della geometria cartesiana suggerisce la seguente scelta: a.

i tre p u n t i n o n appartengano a una linea retta.

b. i tre p u n t i appartengano alla stessa linea. Nessuna delle tecniche sistematiche discusse in questa sezione avrebbe portato a questa scelta. 6.60

Sappiamo che n o n succederà mai che le operazioni di unlock precedano operazioni di lock. Inoltre, anche nel caso in cui l'utente faccia ciò per errore, n o n dovrebbero sorgere problemi. Pertanto possiamo limitare il test del sistema per operazioni di unlock corrispondenti allo scatto delle transizioni U a , U b , con i token prelevati dal posto f o addirittura eliminare del tutto tali operazioni. Osservate però che si dovrebbe testare un unlock di b q u a n d o a ha il lock.

6.61

C o m e esempio di una strategia mista di test, si potrebbe operare come segue. Si scelga una tecnica che porti alle definizioni delle classi {Di} del d o m i n i o di input D. Si cerchi manualmente un elemento per ciascuna classe. Inoltre, si cerchi u n elemento per ciascuno dei confini tra le diverse classi, quindi si cerchi di raggiungere una maggior completezza generando casualmente un certo n u m e r o di elementi per le diverse classi (il n u m e r o di elementi da generare potrebbe variare da classe a classe in funzione della rilevanza della classe). Scrivere un generatore casuale per ciascuna classe può, in alcuni casi, essere difficile. Pertanto, per i casi nei quali n o n si è in grado di generare casualmente elementi per una data classe, si può procedere nel m o d o seguente: si generino casualmente elementi all'interno di D e quindi si decida se l'elemento generato appartiene alla classe D ¿considerata. Il fallimento nel generare un elemento di Di dopo un certo tempo può suggerire un'analisi più accurata delle proprietà di Di indipendentemente dal fatto che si sia stati in grado di trovare alcuni suoi elementi a mano. Questa procedura dovrebbe essere applicata ad almeno una tecnica white-box e a una black-box.

6.62

Lo stesso codice di debugging potrebbe contenere errori e dovrebbe essere verificato. Ad esempio, potrebbe essere stampata una variabile n o n inizializzata. Il codice nuovo potrebbe anche cambiare il c o m p o r t a m e n t o del programma, ad esempio, allocando variabili a diversi indirizzi.

6.73

C o n riferimento alla rete di Petri illustrata nella Figura 6.11, la path conditon Mj.ok_pari.ty

and

not

M2.ok_parity

and

M3.ok_parity

abilita sia la sequenza di scatti

che la sequenza di scatti

La sequenza di scatti

è anche abilitata dalla path condition (not

6.78

M!.ok_parity

and

M2.ok_parity

and

M3.ok_parity).

L'analisi di caso pessimo per verificare le qualità n o n può essere fatta in m o d o puramente sperimentale. In linea di principio, il solo m o d o per ottenere risultati che siano assolutamente certi è attraverso test di tipo esaustivo. Tuttavia, un'analisi preliminare p u ò guidare la selezione di casi di test che abbiano una buona probabilità di produrre prestazioni di caso pessimo. Ad esempio, una semplice analisi dell'algoritmo di o r d i n a m e n t o dell'Esercizio 6.47 suggerisce che le sequenze ordinate inversamente possano essere usate come casi di test per ottenere prestazioni nel caso pessimo. Ancora una volta, abbiamo integrato una tecnica d'ispezione con una valutazione sperimentale.

6.80

II numero di diverse stringhe di lunghezza k[ + k 2 che consistono nell'alternare i simboli che appartengono ad A x di dimensioni k ^ A 2 di dimensioni k 2 , è K

6.81 6.85

K ,

K

K ,

1 2 E sempre vero che un volume più piccolo significa maggiore astrazione? In generale, coprire più diramazioni in un insieme di test rispetto a un altro test non significa maggiore affidabilità, assumendo, naturalmente, che i test non generino fallimenti. Tuttavia, se l'insieme di test di dimensione maggiore è un super insieme di quello di dimensione inferiore, otteniamo almeno la stessa affidabilità che nell'altro caso, dal m o m e n t o che esercitiamo il sistema in tutti i casi dell'insieme di dimensioni inferiori, oltre ad altri casi.

Note bibliografiche I numeri speciali delle Communications of the ACM sul test del software ( C A C M [1988]) e di IEEE Software sulla verifica e convalida del software (Software [1989b]) forniscono una visione generale sulla pratica della verifica. Per la terminologia base, si può fare riferimento ad Adrion et al. [1975] e allo Standard IEEE [1999]. Il problema della verifica in relazione alla convalida viene trattato da Boehm [1984b]. Kemmerer [1985] contiene un'eccellente discussione sul perché occorra testare anche le specifiche; mostra anche come farlo nel contesto delle specifiche formali basate sulla logica. Il test e le sue applicazioni sono presentati da Myers [1979] (che introduce i grafi di causa-effetto e tratta dell'ispezione del codice e dei walkthrough), Beizer [1990], Hetzel [1984] e Howden [1987]. Il black-box testing viene trattato da Beizer [1995], e anche Chandrasekaran e Radicchi (a cura di) [1981], W h i t e [1987], DeMillo et al. [1987], e il lavoro fondamentale di Goodenough e Gerhart [1975] forniscono una visione complessiva del test. Tuttavia, abbiamo notato che non sempre la terminologia di test viene usata in m o d o uniforme. Ad esempio, Goodenough e Gerhart [1975] definiscono un test di successo se il programma in questione supera il test positivamente. Myers [1979] e altri lo definiscono in m o d o diametralmente opposto: un test ha successo se provoca il fallimento del programma. La differenza dei due punti di vista dipende dal fatto di associare il termine "successo" alla corretta esecuzione del programma o al raggiungimento dello scopo del test nello scovare un errore. Inoltre, Hetzel [ 1984] e altri definiscono il caso di test come un insieme di valori appartenenti al dominio d'ingresso, mentre altri lo definiscono come un unico valore. La famosa frase di Dijkstra sul fatto che il test sia utile per provare la presenza, e non l'assenza, degli errori è tratta da Dahl et al. [1972], Vi è stata molta attività di ricerca teorica e sperimentale riguardo alla valutazione, al confronto e all'integrazione di diversi criteri di test. Nella ricca letteratura riguardante questo argomento, menzioniamo Basili e Selby [1987] (che include anche le tecniche di ispezione del codice e il loro confronto), Duran e Ntafos [1984] (che suggerisce che il test casuale abbia i suoi vantaggi), Ntafos [1988] (che confronta tecniche di test white-box) e Clarke et al. [1989] (che suggerisce un approccio sistematico alla selezione dei cammini nel grafo del flusso di controllo di un programma). Zeil [1989] introduce il perturbation testing, un metodo che si concentra sugli errori nelle espressioni aritmetiche, e descrive un metodo di generazione di dati di test. Un'importante tecnica è la mutation analysis (analisi mutazionale), che può essere usata per valutare l'efficacia di un insieme di test T fornito a un dato programma P. L'idea base è che, se T non è in grado di rilevare alcuna differenza tra P e una sua appropriata variazione P ' , chiamata mutante, allora è difficile che T possa provare la correttezza di P. Budd in Chandrasekaran e Radicchi [1981] fornisce una trattazione dell'analisi mutazionale. Jalote [1989] descrive un metodo che può essere usato sia per verificare la completezza delle

specifiche dei tipi di dati astratti (specifiche di test) fornite in un linguaggio algebrico sia per derivare automaticamente casi di test da specifiche algebriche. Celentano et al. [1980] fornisce una tecnica guidata dalla sintassi per generare automaticamente casi di test per un compilatore. C h o p p y e Kaplan [1990] propone una tecnica per testare sistemi modulari che integra l'esecuzione sia di moduli implementati che parzialmente specificati, come suggerito nell'Esempio 6.10 Il problema del test dei sistemi concorrenti e real-time non è ancora stato studiato a fondo. Il problema viene introdotto da Brinch Hansen [ 1978]. Approcci interessanti sono suggeriti da Tai [ 1986], soprattutto in merito al problema di testare la ripetibilità. Mandrioli, Morasca e Morzenti [1995] presenta una tecnica black-box per derivare casi di test per sistemi real-time basata su specifiche codificate nel linguaggio logico T R I O . Descrivono anche uno strumento per supportare la tecnica e la sua applicazione a un caso industriale. San Pietro et al. [2000] tratta del problema del test nei sistemi real-time di vaste dimensioni. Le tecniche di analisi informale vengono descritte da Fagan [1976, 1986]; Basili e Selby [1987] valuta queste tecniche sperimentalmente. Una recente e approfondita valutazione delle tecniche di ispezione del codice è fornita da Porter et al. [1997]. Inoltre, Porter e Johnson [1997] sostiene che le riunioni non sempre influiscono sul costo delle ispezioni del codice. I metodi formali di verifica richiedono ovviamente le specifiche formali. N o n sorprende quindi che scritti pionieristici di McCarthy [1963], Floyd [1967] e Hoare [1969], che posero le fondamenta della definizione formale della semantica, trattino anche del problema dell'analisi di correttezza formale. Manna [1974] e Mandrioli e Ghezzi [1987], tra gli altri, introducono l'analisi della correttezza con metodi formali. L'estensione dei metodi di verifica della correttezza ai sistemi concorrenti è stata trattata per la prima volta da Hoare [1972]; Owicky e Gries [1976], Lamport [1979 e 1989], e Pnueli [1981] (che fa uso della logica temporale), tra gli altri, hanno poi continuato su tale strada. Il problema della correttezza del software real-time fu trattata inizialmente da Haase [1981], Fuggetta et al. [1989], e Liu e Shyamasundar [1990], Heitmeyer e Mandrioli [1996] contiene una raccolta di contributi recenti al settore. Le prove di correttezza modulare sono propugnate , tra gli altri, da Coen-Porisini, Kemmerer e Mandrioli [1994]. Il B-method (si veda, ad esempio, Abrial [1996]) utilizza la modularizzazione e il raffinamento nell'analisi di correttezza formale. L'approccio "clean-room" (stanza sterile) allo sviluppo del software è stato proposto da Mills et al. [1987b]. L'approccio è così chiamato da un'analogia con la produzione di semiconduttori in cui, per evitare difetti nei prodotti, la produzione avviene in un ambiente sterile. L'approccio a stanza sterile si basa sulla prevenzione dell'errore, più che sulla sua correzione. In particolare, i moduli sono specificati formalmente e la loro correttezza è verificata mediante metodi matematici; il test dei moduli è abolito. Il test viene eseguito soltanto al m o m e n t o dell'integrazione fornendo dati di test che riflettono il modello di utilizzo e usando un modello di affidabilità per stabilire se il sistema sia stato testato adeguatamente. Le prime esperienze pratiche con questo approccio nell'IBM furono incoraggianti, come si sostiene in Selby et al. [1987]. Nonostante i metodi formali siano lungi dall'avere una ampia applicabilità nell'ambiente industriale, esempi significativi della loro applicazione ai casi reali sono riportati da Good (a cura di) [1977] e Walker et al. [1980], Crispin [1987] tratta dell'applicazione di V D M ( Vienna Definition Method) a progetti industriali. Il lettore può farsi un idea sulla "controversia dei metodi formali" leggendo articoli come Hossein et al. [1996], Gerhart, Craigen e Ralston [1993 e 1994]. Young e Taylor [1988 e 1989] propongono una tassonomia per valutare e possibilmente integrare diverse tecniche di verifica, tra le quali il test, l'analisi statica dei programmi, le prove di correttezza e l'interpretazione simbolica. Clarke e Richardson in Chandrasekaran e Radicchi [1981] forniscono un'introduzione all'esecuzione simbolica e alle sue applicazioni alla verifica del software. Ghezzi et al. [ 1989] mostra come

l'esecuzione simbolica possa essere estesa alla verifica di sistemi concorrenti attraverso un'opportuna estensione delle reti di Petri. Coen Porisini et al. [2001] discute di come l'esecuzione simbolica possa essere applicata a software safety-critical. I primi articoli sul model checking sono Clarke e Emerson [1981], Clarke, Emerson e Sistla [1986] e Queille e Sifakis [1982], Sfruttano tutti la logica temporale come linguaggio di asserzione per esprimere le proprietà delle FSM. Un altro approccio consiste nel mostrare le equivalenze (o il contenimento) dei comportamenti di macchine differenti, una di esse usata per specificare i requisiti del sistema, l'altra per descrivere la sua implementazione. Tale approccio è stato perseguito, tra gli altri, da Har'El e Kurshan [1990], D o p o il primo articolo, è stata svolta molta ricerca per arricchire il model-checking, sia aumentando la dimensione gestibile degli spazi di stato sia arricchendo il modello base (ad esempio, per trattare i sistemi real-time). Menzioniamo, tra gli altri, Campos, Clarke e Minea [1996], Alur, Courcourbetis, e Dill [1990], Bharadwaj e Heitmeyer [1999]. II concetto di scaffolding di un programma per supportare il debugging è trattato da Bentley [1985]. Brindle e Taylor [1989] trattano il problema del debugging di programmi concorrenti scritti in Ada. McDowell e Helmbold [1989] esaminano le tecniche di debugging di programmi concorrenti. C A C M [1997] fornisce una visione dei problemi del debugging. L'analisi della complessità computazionale viene trattata a fondo in molti testi sul progetto di algoritmi. Un classico è Aho et al. [1974], Per una discussione sulla valutazione delle prestazioni, il lettore può fare riferimento a Ferrari [1978] e Smith [1989]. Musa et al. [1987] presenta una trattazione completa dell'affidabilità basata sull'applicazione di metodi statistici. Numerosi sono anche i contributi contenuti in Bittanti [1988]. In particolare, l'articolo di Bittanti et al. fornisce una visione dei fondamenti teorici per l'applicazione di modelli statistici, sui quali abbiamo basato la nostra discussione. Un approccio che supporta la ricalibrazione del modello scelto è descritto in Brocklehurst et al. [1990] . Knuth [1974] fornisce una valutazione degli stili di programmazione con e senza gli statement goto e conclude che, in generale, l'assenza di statement goto non determina in m o d o univoco la qualità del software. Una visione generale su vari tipi di metriche del software, le loro relazioni e la loro applicazione può essere trovata in Basili [1980], Conte et al. [1986] e Frewin et al. [1985]. Il numero speciale di IEEE Transactions on Software Engineering curato da Iyer (TSE [1990]) contiene diversi articoli riguardo al problema della convalida sperimentale delle varie qualità soggettive del software. Fenton e Pfleeger [1998] fornisce una trattazione completa delle metriche e della misurazione del software in generale. La scienza del software è definita da Halstead [1977]. McCabe [1976, 1983 e 1989] introduce un metodo di misura della complessità e la sua applicazione allo sviluppo di strategie di test. Tra i molti articoli che forniscono valutazioni critiche, validazioni sperimentali a varianti di diverse metriche del software, menzioniamo Hamer e Frewin [1982] e Shen et al. (che forniscono una valutazione critica della teoria di Halstead), Albrecht e Gaffney [1983], Basili e Hutchens [1983], Basili et al. [1983], Coulter [1983], Curtis et al. [1979], Henry e Kafura [1981] e Kearney et al. [1986], Il numero speciale di IEEE Software [1990b] fornisce una visione sullo stato dell'arte a tale data. Il ruolo delle misurazioni nell'adattare l'ambiente di supporto dello sviluppo è discusso da Basili et al. [1986] e Basili e Rombach [1988]; Basili e Caldiera [1988] suggerisce come le metriche del software possano essere usate per potenziare e misurare la riusabilità del software. Inoltre, in Harrison et al. [1982], Kafura e Reddy [1987] e Gibson e Senn [1989] le metriche vengono applicate alla raanutenibilità del software. La metodologia G Q M viene trattata in van Solingen e Berghout [1999]. L'esperimento descritto nel Caso di studio 6.1 è adattato da Briand e Morasca [1999].

CAPITOLO

7

Processo di produzione del software

Nella produzione di un sistema software sono coinvolte molte attività. Sono già state affrontate la progettazione del software (Capitolo 4), la specifica (Capitolo 5) e la verifica (Capitolo 6): ma come devono essere organizzate queste attività? L'ordine in cui le affrontiamo definisce il ciclo di vita del prodotto software. In generale, il processo di produzione del software è il processo che seguiamo per costruire, consegnare ai clienti e far evolvere il prodotto software, dalla nascita dell'idea fino alla consegna e al ritiro del prodotto quando questo è diventato obsoleto. I processi di produzione e confezione vengono studiati approfonditamente in ogni disciplina che ha l'obiettivo di dar vita a un prodotto. Il loro scopo è quello di soddisfare le aspettative dei clienti fornendo prodotti di qualità nei tempi e nel budget previsto, rendendo i prodotti remunerativi e i processi affidabili, predicibili ed efficienti. Un processo di produzione ben definito, come quello usato ad esempio nel settore automobilistico, presenta numerosi vantaggi, tra cui la possibilità di essere automatizzato, con l'impiego di componenti e processi standard. Con la definizione di un modello per il processo di produzione del software, è possibile usufruire dei benefìci che derivano dall'adozione di processi standard. È necessario però tener sempre presenti due caratteristiche: innanzitutto, la produzione del software è un'attività prevalentemente intellettuale, quindi non facilmente automatizzabile; in secondo luogo il software è caratterizzato da un alto grado di instabilità: i requisiti cambiano in continuazione e, di conseguenza, i prodotti devono essere evolvibili. Come potremmo, quindi, organizzare un processo di produzione del software che ci consenta di realizzare applicativi di qualità in maniera affidabile, predicibile ed efficiente? In questo capitolo esamineremo diversi modelli che cercano di catturare l'essenza di questo processo che viene anche chiamato "ciclo di vita del software". Questi modelli si basano sull'assunto che il software, come qualsiasi altro prodotto industriale, abbia un ciclo di vita che va dalla sua concezione al ritiro, e che il ciclo di vita debba essere anticipato e controllato in modo da ottenere le qualità desiderate. In questo capitolo si vedrà anche come questi processi possono essere più o meno automatizzati, o almeno resi predicibili usando standard e metodologie. Nell'analisi proposta vedremo che non esiste una "metodologia migliore" in assoluto, ma che ciascuna metodologia può avere un'utilità nell'ambito del processo di sviluppo. Mentre i capitoli precedenti si erano concentrati sul prodotto software, questo capitolo tratterà il processo di sviluppo software. Rimanderemo tutti gli argomenti riguardan-

ti la g e s t i o n e al p r o s s i m o c a p i t o l o , il q u a l e a f f r o n t e r à a n c h e gli a s p e t t i e c o n o m i c i della p r o d u z i o n e di software. P r i m a di a d d e n t r a r c i nello s t u d i o dei m o d e l l i dei processi di p r o d u z i o n e , d o b b i a m o o s s e r v a r e c h e in m o l t i casi p r a t i c i l ' a p p l i c a t i v o s o f t w a r e in via d i s v i l u p p o r a p p r e s e n t a u n c o m p o n e n t e d i u n s i s t e m a p i ù g r a n d e . Il ciclo d i v i t a del s o f t w a r e è q u i n d i p a r t e del ciclo d i v i t a d i u n s i s t e m a p i ù g e n e r a l e . A d e s e m p i o , la p r o g e t t a z i o n e e lo s v i l u p p o d i u n sis t e m a d i s t r i b u i t o p e r l ' a u t o m a z i o n e d i u n i m p i a n t o h a a c h e v e d e r e sia c o n c o m p o n e n t i h a r d w a r e ( p e r i f e r i c h e c o m p u t a z i o n a l i , r e t e , p e r i f e r i c h e s p e c i a l i z z a t e ) sia c o n c o m p o n e n t i s o f t w a r e . A l l o stesso m o d o , lo s v i l u p p o d i u n s i s t e m a d i a u t o m a z i o n e p e r u f f i c i n e c e s s i t a d e l l a p r o g e t t a z i o n e d i flussi d i l a v o r o ( w o r k f l o w ) b a s a t i s u p r o c e d u r e sia m a n u a l i c h e a u t o m a t i c h e . L e p r o c e d u r e m a n u a l i v e n g o n o e s e g u i t e d a p e r s o n e c h e i n t e r a g i s c o n o c o n le a p p l i c a z i o n i s o f t w a r e , le q u a l i f o r n i s c o n o u n s u p p o r t o a u t o m a t i z z a t o p e r c o m p i t i p i ù standard. Q u e s t o c a p i t o l o è p r i n c i p a l m e n t e d e d i c a t o ai m o d e l l i d i s v i l u p p o d e l s o f t w a r e . C o m i n c e r e m o d i s c u t e n d o c h e c o s a sia u n m o d e l l o d i u n p r o c e s s o d i s v i l u p p o del s o f t w a r e e p e r c h é sia i m p o r t a n t e d i s p o r r e d i u n m o d e l l o ( P a r a g r a f i 7 . 1 e 7 . 2 ) . S i c c o m e u n p r o c e s so o r g a n i z z a il flusso d e l l e a t t i v i t à , il P a r a g r a f o 7 . 3 p a s s e r à in r a s s e g n a le p r i n c i p a l i a t t i v i t à d i s v i l u p p o c o i n v o l t e n e l l a p r o d u z i o n e d i s o f t w a r e ; nel P a r a g r a f o 7 . 4 i n v e c e v e r r a n n o a n a l i z z a t i m o d e l l i d i p r o d u z i o n e s p e c i f i c i . D o p o aver e s a m i n a t o il t r a d i z i o n a l e m o d e l lo a c a s c a t a , v e r r a n n o p r e s e n t a t i altri m o d e l l i c h e c e r c a n o d i r i s o l v e r e i p u n t i p r o b l e m a t i ci d i q u e l l o t r a d i z i o n a l e . I n p a r t i c o l a r e , si v e d r à c h e il m o d e l l o a c a s c a t a è a d e g u a t o q u a n d o i r e q u i s i t i s o n o p e r f e t t a m e n t e n o t i e si p r e v e d e c h e n o n s u b i r a n n o s i g n i f i c a t i v i c a m b i a m e n t i ; se q u e s t e c o n d i z i o n i n o n s o n o s o d d i s f a t t e , il m o d e l l o r i s u l t e r à r i g i d o e m a n c h e r à di

flessibilità. L a m a g g i o r p a r t e d e i p r o c e s s i s o f t w a r e s o n o t e n t a t i v i d i c o n t r o l l a r e le c o m p l e s s i t à del

p r o c e s s o d i s v i l u p p o . N e l l a p r a t i c a , p e r ò , la m a g g i o r p a r t e d e g l i s f o r z i è d e d i c a t a al m a n t e n i m e n t o e al m i g l i o r a m e n t o d i s i s t e m i l e g a c y p i u t t o s t o c h e a l l o s v i l u p p o d i n u o v i s o f t w a r e . Il P a r a g r a f o 7 . 5 p r e s e n t e r à e d i s c u t e r à q u e s t e s i t u a z i o n i m o l t o c o m u n i . Il P a r a g r a f o 7 . 6 è i n t e r a m e n t e d e d i c a t o ai casi d i s t u d i o , p r e s e n t a n d o a l l ' i n i z i o d u e e s e m p i d i p r o g e t t i s o f t w a r e reali e s o t t o l i n e a n d o c o m e le l o r o d i v e r s e n a t u r e a b b i a n o richiesto l ' a d o z i o n e di modelli di processo differenti. V e r r a n n o a f f r o n t a t i poi d u e esempi di processi i n d u s t r i a l i m o d e r n i dagli a p p r o c c i c o n t r a s t a n t i : la s t r a t e g i a di s v i l u p p o del s o f t w a r e

synchronize andstabilize ( s i n c r o n i z z a e r e n d i s t a b i l e ) , a d o t t a t a d a M i c r o s o f t , e l ' a p p r o c c i o o p e n - s o u r c e , r e s o p o p o l a r e d a l l o s v i l u p p o del s i s t e m a o p e r a t i v o L i n u x . Il P a r a g r a f o 7 . 7 si o c c u p e r à d e l l ' o r g a n i z z a z i o n e e della g u i d a dei processi d i p r o d u z i o n e del s o f t w a r e : v e r r a n n o e s a m i n a t e a l c u n e i m p o r t a n t i m e t o d o l o g i e l a r g a m e n t e u s a t e n e l l ' i n d u s t r i a del s o f t w a r e .

structured analysis/ structured design (analisi s t r u t t u r a t a / p r o g e t t o s t r u t t u r a t o ) e J S D , Jackson's system development. V e r r à p o i i n t r o d o t t o lo unifieddevelopmentprocess ( p r o c e s s o d i s v i l u p p o u n i f i c a t o ) , V e r r a n n o descritti s i n t e t i c a m e n t e altri d u e a p p r o c c i tradizionali: lo

c h e f o r n i s c e u n a p p r o c c i o d i s c i p l i n a t o nel c o n t e s t o d e l l o s v i l u p p o o r i e n t a t o a o g g e t t i c o n U M L . Il P a r a g r a f o 7 . 8 a f f r o n t e r à l ' i m p o r t a n t e q u e s t i o n e o r g a n i z z a t i v a c h e r i g u a r d a la ges t i o n e d i t u t t i gli a r t e f a t t i p r o d o t t i n e l p r o c e s s o s o f t w a r e , q u e s t i o n e n o t a c o n il n o m e di

configuration management. I n f i n e , nel P a r a g r a f o 7 . 9 , v e r r à t r a t t a t a la n e c e s s i t à d i d e f i n i r e e a d o t t a r e s t a n d a r d p e r il s o f t w a r e .

7.1

7.1

Cos'è un modello di un processo di produzione del software?

419

Cos'è un modello di un processo di produzione del software?

Agli inizi d e l l ' i n f o r m a t i c a , lo s v i l u p p o del s o f t w a r e era p r i n c i p a l m e n t e u n lavoro i n d i v i d u a l e