Introduzione al corso di Programmazione Sabrina Mantaci A.A.2004-2005 1 Il Computer Supponiamo di volere risolvere un’equazione di secondo grado: ax2 + bx + c = 0 Per trovare la soluzione dobbamo svolgere diversi passi: 1. Determinare il ∆ = b2 − 4ac; 2. Studiare il segno del ∆: (a) Se √ ∆ > 0, allora determinare le soluzioni x1 = (−b + ∆)/2a; √ ∆)/2a e x2 = (−b − (b) Se ∆ < 0 non ci sono soluzioni reali; (c) Se ∆ = 0 le due soluzioni coincidono e sono x1 = x2 = −b/2a. Se è dato questo metodo per la risoluzione delle equazioni, sono dati i parametri del problema (nel nostro caso i coefficienti a, b, c), se abbiamo la possibilità di memorizzare i dati parziali su un supporto fisico (come, per esempio della carta da minuta), e se abbiamo una calcolatrice per effettuare i calcoli, siamo in grado di determinare facilmente le soluzioni. In pratica, una volta che qualcuno fornisce il metodo di risoluzione e i dati del problema, il lavoro dell’esecutore è talmente semplice da poter essere svolto da un dispositivo automatico in grado di leggere i dati e di eseguire le istruzioni del procedimento descritto. Indipendentemente dalle modalità con cui realizziamo questi dispositivi nella pratica, dal punto di vista concettuale, un calcolatore deve essere costituito dai seguenti componenti di base: 1. Memoria: permette di memorizzare la procedura, i dati iniziali, i risultati intermedi, i risultati finali; 1 2. Funzione aritmetica: permette di effettuare operazioni aritmetiche sui dati; 3. Ingresso/uscita: dispositivi atti a ricevere dati dall’esterno e a comunicare i risultati all’esterno; 4. Controllo: serve ad eseguire i passi della procedura, coordinando il flusso dei dati tra i vari componenti. Più in dettaglio, i calcolatori elettronici sono formati dalle seguenti componenti: • La memoria del calcolatore è costituita da due parti principali. Una parte è detta unità RAM (Random Access Memory - Memoria ad accesso casuale) ed è costituita da una successione ordinata di celle o registri, ciascuno dei quali è capace di memorizzare una stringa di bit (binary digit - cifra binaria, ossia 0 o 1) di lunghezza fissata dipendente dal tipo di calcolatore (in genere 8 o multipli di 8) detta parolamacchina. Le celle sono numerate da 0 a k − 1: si dirà allora che la memoria ha dimensione k. Ciascuno dei numeri da 0 a k − 1 denota una locazione o indirizzo di memoria. In particolare la locazione di memoria 0 prende il nome di accumulatore ed è la cella dove viene conservato il risultato dell’ultima operazione effettuata dal calcolatore. Un’altra parte della memoria, detta unità ROM (Read Only Memory - Memoria a sola lettura), contiene dati e programmi inalterabili, come, ad esempio, i codici di caratteri e il sistema operativo. • Il processore o CPU (Central Processing Unit) è il vero e proprio “cervello” del computer. L’attività principale della CPU consiste nel recuperare le istruzioni da eseguire, decodificarle ed eseguirle. La CPU è costituita da due parti principali, l’ALU e la CU. L’ ALU (Arithmetic Logic Unit) è la parte attiva del sistema dove vengono eseguite operazioni aritmetiche (+,-,*,/) e le elaborazioni logiche (confronti e operazioni logiche). I dati vengono letti dalla memoria e scritti nella memoria facendo in modo che il processore contenga solo i dati di volta in volta necessari. La CU (Control Unit) si occupa di dirigere il flusso dei dati all’interno del processore, andando a cercare nella memoria i dati che gli sono necessari e depositando nella memoria i dati elaborati. • Unità di INPUT è un qualunque dispositivo che permette di immettere i dati dall’esterno (ad esempio la tastiera o il mouse). • Unità di OUTPUT è invece un qualunque dispositivo che permette di visualizzare all’esterno i risultati del calcolo (ad esempio la stampante o lo schermo). 2 2 La risoluzione dei problemi In questo corso siamo interessati ad imparare delle tecniche che ci permettano di scrivere dei programmi informatici per la soluzione di determinati problemi, che possono essere formalizzati in termini matematici. Per questo vogliamo stabilire quali sono i passi fondamentali che ci permettono di arrivare ad una soluzione di un dato problema. La soluzione di un problema nel nostro ambito consiste infatti di diversi passaggi, ognuno dei quali deve essere preso in considerazione nella progettazione di un programma: • definizione del problema: si deve definire e formalizzare il problema che ci poniamo; • caratterizzazione della soluzione: bisogna specificare qual’è l’output che vogliamo che il nostro programma ci fornisca; • sviluppo di un algoritmo: questa è la fase più importante del progetto, ossia quella di stabilire quale metodo che intendiamo utilizzare per fornire una soluzione al problema; • codifica dell’algoritmo. Questa è fase di programmazione propriamente detta, ossia la traduzione dell’algoritmo in un particolare linguaggio di programmazione; • verifica del programma: una volta che il programma è stato scritto, si deve verificare che da una risposta corretta al problema; • documentazione della soluzione: consiste nell’integrare il programma con dei commenti che assolvono al compito di rendere il programma leggibile, e, di conseguenza, più facile da correggere, da modificare, adattare e da condividere con altri programmatori; • manutenzione: consiste nel tenere il programma aggiornato con modifiche che rispondono alle nuove esigenze delle specifiche del problema. 3 Gli Algoritmi Ci concentriamo qui sulla fase più importante della progettazione di un programma. Supporremo che sia già stato formalizzato il problema e stabilito quali sono le risposte che vogliamo dal programma. La definizione di Informatica data dall’ACM (Association for Computing Machinery) è la seguente: l’informatica è lo studio sistematico degli algoritmi che descrivono e trasformano l’informazione: la loro teoria, analisi, progetto, efficienza, realizzazione ed applicazione. In questa definizione viene utilizzata la nozione di “algoritmo” che può informalmente essere definito come un “metodo” per la risoluzione di un problema specifico. 3 Per avere un’intuizione su cosa sia un algoritmo, possiamo pensare per esempio a una ricetta di cucina: vengono definiti tutti gli ingredienti e le dosi, e infine vengono date le istruzioni per l’esecuzione, in cui si descrivono ordinatamente tutti i passi che si devono svolgere per ottenere il risultato finale. Oppure pensiamo alle istruzioni per l’uso del videoregistratore, che descrivono passo dopo passo tutto quello che si deve fare per farlo funzionare (collegare il videoregistratore al televisore; inserire la spina; accendere il videoregistratore; inserire la cassetta; premere il tasto “play”). Diamo qui una definizione più formale: Definizione 3.1 Un algoritmo è un insieme finito di regole che dà una sequenza delle operazioni da svolgere al fine di risolvere un problema specifico. Un algoritmo deve soddisfare le seguenti proprietà fondamentali: 1. Finitezza: un algoritmo deve sempre terminare dopo un numero finito di passi. Ogni volta che si scrive un algoritmo bisogna sempre verificare che a un certo punto termini. 2. Non ambiguità: in un algoritmo ogni passo deve essere ben specificato, in modo che un esecutore (umano o automatico) sappia esattamente e in maniera univoca (o deterministica) cosa deve fare ad ogni passo. L’uso dei linguaggi di programmazione permette di eliminare certe ambiguità che sono presenti nei linguaggi naturali. 3. Input: un algoritmo ha zero o più input, che sono i dati che vengono forniti prima che il calcolo abbia inizio, e sui quali vengono svolte le computazioni; 4. Output: un algoritmo ha uno o più output, che rappresentano i risultati del problema, quantità che hanno una specifica relazione con gli inputs; 5. Realizzabile praticamente: tutte le operazioni che sono descritte nell’algoritmo devono essere sufficientemente semplici da poter essere, in linea di principio, eseguite in tempo finito da un uomo con carta e penna. Inoltre ad un algoritmo si richiede che sia corretto ed efficiente. Un algoritmo è corretto se perviene alla soluzione del compito a cui è preposto. Un algoritmo è efficiente se perviene alla soluzione del problema nel modo più veloce possibile e usando la più piccola quantità possibile di risorse fisiche (memoria). Diamo ora alcuni esempi di algoritmi, partendo da alcuni problemi più semplici, fino ad arrivare alla formalizzazione algoritmica un problema matematico classico. Esempio: Supponiamo di avere un pallottoliere con tre file di palline. Vogliamo effettuare la somma di due numeri utilizzando il pallottoliere. Supponiamo che la prima riga rappresenti il primo addendo e la seconda riga il secondo addendo, mentre la terza riga conterrà il risultato della somma. Nella prima e nella seconda riga disponiamo sul lato 4 sinistro tante palline quanto è il valore del primo e del secondo addendo, rispettivamente. Nella terza riga tutte le palline si trovano sul lato destro. Ci muoviamo come segue: 1. nella prima riga, si sposta una pallina da sinistra a destra e, contestualmente, nella terza riga se ne sposta una da destra a sinistra. 2. si ripete l’operazione precedente finché non si è svuotata la prima riga. 3. non appena la prima riga è vuota, nella seconda riga si sposta una pallina dalla sinistra alla destra e, contestualmente, nella terza riga se ne sposta una da destra a sinistra. 4. si ripete l’operazione precedente finché non si è svuotata la seconda riga. 5. il numero di palline che si trova nel lato sinistro della terza riga al termine delle operazioni è il risultato cercato. Esempio: Supponiamo di volere cercare il titolo di un libro in uno schedario di una biblioteca. Se non abbiamo alcuna informazione su come è organizzato lo schedario, l’algoritmo che possiamo utilizzare è il seguente: 1. si esamina la prima scheda dello schedario; 2. se il titolo coincide con quello cercato allora terminiamo la ricerca. Altrimenti si passa alla scheda successiva. 3. si continua cosı̀ fino a quando si trova la scheda cercata oppure si arriva all’ultima scheda. Nel primo caso estraiamo la scheda cercata, mentre nel secondo caso possiamo affermare che la ricerca è fallita, cioè il libro non è presente nella biblioteca. È facile notare che questo algoritmo può essere molto “costoso” in termini di tempo. Infatti nel caso peggiore occorrerà consultare tutte le schede, che sono tante quante i libri presenti nella biblioteca. È per questo che molte volte è utile organizzare i titoli in ordine lessicografico. In tal caso la ricerca può essere resa molto più rapida, seguendo l’algoritmo descritto qui di seguito. 1. si prende la scheda centrale dello schedario; 2. se la scheda è quella cercata, la ricerca si interrompe; 3. in caso contrario, se il titolo del libro precede in ordine lessicografico quello della scheda appena estratta, si ripete il procedimento nella metà dello schedario che precede la scheda estratta. In caso contrario si procederà alla ricerca nel gruppo di schede che seguono la scheda estratta. 5 Si intuisce che il secondo algoritmo proposto giunge alla soluzione più velocemente del precedente, in quanto in nessun caso esamina tutte le schede (ad ogni passo ne esclude la metà). In particolare, come vedremo da un’analisi più precisa, esaminerà un numero di schede proporzionale al logaritmo in base 2 del numero di libri nella biblioteca, riducendo drasticamente il tempo della ricerca. Questa osservazione mette in evidenza come oltre al problema di determinare un algoritmo per la soluzione di un problema, ci interesseremo a trovare l’algoritmo più efficiente possibile. Oltre a questo, è importante osservare che un altra fase fondamentale nella progettazione di un algoritmo è quella di strutturare i dati in maniera tale da rendere più efficiente l’applicazione dell’algoritmo stesso. Per esempio, ci siamo resi conto che nella gestione di un archivio, è conveniente mantenere le schede ordinate, in maniera tale da facilitare la fase di ricerca. Questo viene realizzato mediante strutture dati adatte alla gestione di operazioni di dizionario (inserimento, cancellazione e ricerca). Esempio: Dati due interi a e b, vogliamo calcolare il massimo comun divisore (M CD) di a e b. Ricordiamo che: Definizione 3.2 Il massimo comun divisore (MCD) tra due interi a e b è un intero q tale che q divide a e b, e se p è un altro divisore di a e b, allora p divide q. Esistono diversi algoritmi per determinare il M CD fra due numeri, ma il più efficiente è il cosiddetto Algoritmo di Euclide. Tale algoritmo è basato sul seguente teorema: Teorema 3.1 (algoritmo della divisione) Sia a un intero e d un intero positivo. Allora esiste un’unica coppia di interi q ed r con 0 ≤ r ≤ d tale che a = dq + r. Lemma 3.1 Sia a = bq + r dove a, b, q, r sono interi. Allora M CD(a, b) = M CD(b, r). Dimostrazione: Se d divide a e d divide b allora d divide r = a − bq. Viceversa, se d divide b e d divide r, allora d divide a = bq + r. 2 Il lemma appena enunciato ci dà la possibilità di trovare un “metodo” per stabilire qual’è il M CD tra due numeri. Siano a e b due interi positivi con a ≥ b. Sia r0 = a, r1 = b. Allora possiamo applicare ripetutamente l’algoritmo della divisione e ottenere: r0 = r1 q1 + r2 r1 = r2 q2 + r3 ··· rn−2 = rn−1 qn−1 + rn rn−1 = rn qn 0 ≤ r2 < r1 0 ≤ r3 < r2 0 ≤ rn < rn−1 Per il lemma si ha: M CD(a, b) = M CD(r0 , r1 ) = M CD(r1 , r2 ) = · · · = M CD(rn−1 , rn ) = M CD(rn , 0) = rn 6 Dunque il massimo comun divisore è l’ultimo resto non nullo nella sequenza delle divisioni. L’algoritmo di Euclide si può quindi esprimere nella seguente forma: Algoritmo di Euclide 1. x ← a, y ← b; 2. dividi x per y e sia r il resto della divisione; 3. se r = 0 l’algoritmo termina, e y sarà il MCD; 4. altrimenti poni x ← y, y ← r e torna al passo 2. Questo algoritmo termina perchè i valori di x e y sono non negativi decrescenti. Inoltre questo algoritmo calcola correttamente il M CD per quanto dimostrato dal lemma. 4 Complessità degli Algoritmi Lo studio della complessità di un algoritmo consiste nel determinare la quantità di risorse impiegate durante esecuzione dell’algoritmo su un input di taglia generica n. Fra queste terremo in particolare considerazione due grandezze fondamentali: il tempo di calcolo e lo spazio di memoria utilizzata. Nel primo caso conteremo il numero di operazioni elementari svolte dall’algoritmo su un input di taglia n. Si parla in questo caso di complessità di tempo. Nel secondo caso calcoleremo la quantità di locazioni di memoria che devono essere utilizzate nello svolgimento dell’algoritmo. In questo caso parleremo di complessità di spazio. In questo corso prenderemo principalmente il tempo come parametro per calcolare l’efficienza di un algoritmo. Quindi, dati due programmi che risolvono lo stesso problema, diremo che il più efficiente è quello che impiega il minor tempo nella sua esecuzione. Se considerassimo un’unità di misura standard per il tempo, come i secondi, un possibile metodo di confronto potrebbe consistere nel mandare in esecuzione i due programmi e vedere quale dei due finisce prima i suoi calcoli. Ma questo metodo di valutazione non è attendibile se non consideriamo le condizioni in cui si effettuano le prove. Bisogna infatti tener conto: • dell’elaboratore su cui il programma viene eseguito. Il confronto fra due programmi sarà valido solo se li mandiamo in esecuzione sullo stesso elaboratore; • del particolare compilatore. Infatti compilatori diversi possono generare programmi in linguaggio macchina con caratteristiche diverse; • dei dati di ingresso. Per confrontare l’efficienza di due programmi essi devono essere eseguiti sullo stesso insieme di dati di ingresso. 7 • della significatività dei dati di ingresso, perché per confrontare l’efficienza di due programmi, essi devono essere eseguiti più volte su dati differenti. Anche ammesso che il test venga fatto sugli stessi dati, può succedere che uno dei due programmi si comporti meglio su un certo input e peggio su un altro. Qual’è quindi un criterio oggettivo di valutazione dell’efficienza di un programma? Un’analisi oggettiva del tempo di computazione deve ignorare tutti i fattori che dipendono dalla macchina, dal compilatore, dai dati di input utilizzati e i dettagli implementativi legati al linguaggio di programmazione utilizzato. Cercheremo quindi di associare ad ogni algoritmo una funzione della dimensione dei dati di input, che ci permetta di definire in maniera oggettiva la complessità di tempo dell’algoritmo. A tal fine dobbiamo fare delle semplificazioni. Considereremo che: • il costo di esecuzione di ogni istruzione semplice (assegnazione, lettura, scrittura, operazioni aritmetiche e logiche) è 1; • il costo di esecuzione di un’istruzione composta è pari alla somma delle istruzioni semplici che la compongono; • il costo di un ciclo è dato dal costo delle istruzioni del ciclo più il costo del test di fine ciclo, moltiplicato per il numero di volte che il ciclo viene eseguito; • il costo di un’istruzione condizionale è dato dal costo del test più il costo delle istruzioni che vengono eseguite se la condizione è vera; • il costo di attivazione di un sottoprogramma è pari al costo di esecuzione delle istruzioni che compongono il sottoprogramma. Si può pensare che assegnare a tutte le istruzioni semplici valore 1 sia una semplificazione troppo forte. Infatti questo equivarrebbe a dire che, per esempio, le istruzioni a:=a+1 e a:=b+(c*b)/(a+c)+2 valgono entrambe 1, malgrado la seconda appaia più complessa della prima. Ma si può osservare che se t1 è il tempo che occorre a calcolare la prima istruzione e t2 il tempo necessario a calcolare la seconda, allora possiamo sempre trovare una costante c tale che t2 < c · t1 . Quindi possiamo dire che le due istruzioni hanno lo stesso costo, a meno di un fattore costante. Le stesse considerazioni si possono fare per la valutazione delle altre istruzioni. Possiamo concludere che la nostra valutazione del costo di un programma è approssimata per un fattore moltiplicativo c e che è indipendente dal tipo di calcolatore, programma o compilatore usato. Fatte queste considerazioni, come determiniamo qual’è la funzione che ci dà una valutazione della complessità di un algoritmo? Osserviamo che il tempo di esecuzione di un programma dipende dai suoi dati di ingresso. Infatti prendiamo ad esempio il problema della ricerca di una scheda in un archivio. In questo caso, indipendentemente dal 8 programma utilizzato, tanto più grande è l’archivio, tanto più tempo impiegherà il programma a trovare la scheda cercata. Una valutazione dell’efficienza di un algoritmo sarà quindi una funzione della dimensione dell’input. Per dimensione dell’input si intende la quantità di memoria necessaria per memorizzare i dati di input del problema. Infatti il numero di istruzioni semplici eseguite da un programma dipende molto da come è fatto l’input. Quindi, quali input dobbiamo considerare di volta in volta? Consideriamo ancora una volta il problema della ricerca in un archivio. Supponiamo che l’archivio non sia ordinato e dobbiamo quindi fare una ricerca esaustiva. Se l’elemento da cercare è il primo dello schedario, siamo fortunati e dobbiamo fare una sola operazione di confronto. Ma se l’elemento cercato è l’ultimo dello schedario, o addirittura non è contenuto nell’archivio, faremo un numero di confronti uguale al numero delle schede presenti. È chiaro che, per avere una valutazione oggettiva sul costo di esecuzione di un algoritmo, la valutazione non deve dipendere dal particolare dato di ingresso. Proprio per questo il costo del programma verrà valutato in funzione delle dimensioni dell’input con riferimento al caso peggiore, cioè quello in cui l’esecuzione impiega più tempo. Dovremo quindi di volta in volta individuare qual’è il caso peggiore per quell’algoritmo, e valutare come si comporta l’algoritmo su questo input. Nel caso dell’esempio considerato, il caso peggiore è quello in cui l’elemento cercato non è contenuto nell’archivio, per cui l’algoritmo di ricerca esaustiva effettua n confronti, e diremo quindi che l’algoritmo ha un costo proporzionale ad n. Vogliamo formalizzare quanto detto sopra. Diamo la seguente definizione. Definizione 4.1 Un algoritmo ha complessità O(f (n)) (e diremo che l’algoritmo ha complessità O grande di f (n)) se esistono due costanti positive c ed n0 , indipendenti da n, tali che il numero di istruzioni t(n) che devono essere eseguite nel caso peggiore con un input di dimensione n verifica la seguente disuguaglianza t(n) < c · f (n) per ogni n > n0 . Esempio: Se un algoritmo svolge un numero di istruzioni t(n) = 4n + 3, l’algoritmo è O(f (n)) dove f (n) = n2 . Infatti basta scegliere c = 4 e n0 = 2. Si può anche osservare che t(n) è anche O(n). Infatti basta prendere in questo caso c = 5 e n0 = 3. Definizione 4.2 Diremo che un problema ha una delimitazione superiore (upper bound) O(f (n)) alla sua complessità se esiste un algoritmo per la sua soluzione che ha complessità O(f (n)). Definizione 4.3 Diremo che un problema ha una delimitazione inferiore (lower bound) O(f (n)) alla sua complessità se nessun algoritmo che risolve il problema può effettuare su un generico input di taglia n meno di O(f (n)) operazioni elementari. 9 In generale questa funzione calcola quella che viene detta complessità asintotica di un algoritmo, che ci dice come cresce il tempo di calcolo dell’algoritmo quando l’input cresce di dimensione. Visto che si tratta di un’analisi più qualitativa che quantitativa, qualora la funzione fosse una somma di diversi termini, considereremo solo quello “dominante”, ossia quello che al crescere di n cresce più velocemente. Questo perché è sempre possibile, con opportuni calcoli, fare assimilare i termini non dominanti dalla costante c. Il seguente teorema afferma che se una funzione è un polinomio, allora la funzione è O grande del termine di grado massimo, che è, infatti, il termine dominante. Teorema 4.1 Sia A(n) = am nm + am−1 nm−1 + · · · + a1 n + a0 un polinomio di grado n. Allora A(n) = O(nm ). Dimostrazione: |A(n)| ≤ |am |nm + |am−1 |nm−1 + · · · + |a1 |n + |a0 | ≤ ≤ (|am | + |am−1 |/n + · · · + |a0 |/nm ) · nm ≤ (|am | + |am−1 | + · · · + |a0 |) · nm = cnm . 2 Questo teorema permette di dire che se un’algoritmo ha una complessità che è un polinomio di grado m, allora ha complessità O(nm ). Cioè si può non tenere conto dei termini di grado inferiore, poiché sono termini che crescono con n meno velocemente di quello “dominante”. Per lo stesso motivo si può non tenere conto delle costanti moltiplicative, che vengono “assorbite” dalla costante c. Abbiamo detto che questo criterio per la valutazione degli algoritmi non tiene conto né delle costanti moltiplicative né dei termini di ordine inferiore. Si può però osservare che se un programma fa 2n operazioni elementari è sicuramente diverso da uno che ne fa 1000n. D’altra parte si può ossevare che per quanto le costanti moltiplicative siano grandi, alla fine l’andamento qualitativo è determinato dalla funzione di n. Se la funzione di complessità di un algoritmo è O(nm ), per qualche intero m, si dice che ha complessità polinomiale. In particolare se m = 1, cioè l’algoritmo ha complessità O(n) si dice che l’algoritmo ha complessità lineare. Se m = 2 si dice che l’algoritmo ha complessità quadratica. Se la funzione di complessità di un algoritmo è O(log n), diremo che l’algoritmo ha complessità logaritmica. Se la funzione di complessità di un algoritmo è O(k n ), per qualche costante k > 1, diremo che ha complessità esponenziale. Un problema per cui il migliore algoritmo possibile ha complessità esponenziale si dice intrattabile. Gli algoritmi esponenziali richiedono un tale tempo di calcolo che nessun miglioramento futuro della velocità dei calcolatori sequenziali produrrà mai un range più grande dell’insieme dei problemi risolubili nella pratica. 10 log n 0 1 2 3 4 5 n 1 2 4 8 16 32 nlogn 0 2 8 24 64 160 n2 1 4 16 64 256 1024 n3 1 8 64 512 4096 32.768 2n 2 4 16 256 65.563 4.294.967.296 Figura 1: La tabella rappresenta i primi 5 valori delle funzioni indicate. Si noti come passando da una funzione a quella superiore, il tasso di crescita diventa sempre maggiore 5 I linguaggi di programmazione Un programma è un algoritmo, espresso in un determinato linguaggio formale, detto linguaggio di programmazione. I linguaggi formali si distinguono da quelli naturali in quanto sono completamente definiti mediante regole esplicite, per cui è sempre possibile determinare la correttezza (grammaticale) di una proposizione; inoltre il significato di ogni frase è sempre privo di ambiguità. Per contro i linguaggi naturali, che hanno delle ambiguità intrinseche, hanno un potere espressivo più ampio dei linguaggi formali. I linguaggi di programmazione sono un sottoinsieme di quelli formali. Essi possono essere definiti come lo strumento che ci permette di comunicare al computer la sequenza di operazione da effettuare per raggiungere un obiettivo prefissato. Un’importante attività degli informatici è stata nel corso degli anni la definizione di linguaggi per la codifica degli algoritmi, cioè i linguaggi che consentono di scrivere gli algoritmi sotto forma di programmi che possono essere interpretati dal calcolatore, che ne è l’esecutore. Una prima distinzione possiamo farla tra i linguaggi a basso e quelli ad alto livello. Ogni processore ha un proprio linguaggio che ad ogni stringa di bit fa corrispondere una operazione elementare come il caricamento di un registro interno al processore o la somma tra una cella di memoria e un registro. Questo tipo di linguaggio, detto linguaggio macchina, essendo molto vicino alla logica del processore, risulta essere molto lontano dal modo di ragionare dell’uomo, per cui utilizzarlo per la codifica di algoritmi comporta un lavoro molto lungo e difficile. Agli albori dell’informatica, con l’avvento dei primi calcolatori, la programmazione consisteva nella traduzione minuziosa in linguaggio macchina delle istruzioni, e la successione delle cifre binarie che codificava le istruzioni forniva il programma, già pronto per essere eseguito dal calcolatore. Questo lavoro di traduzione era chiamato codifica. La programmazione in linguaggio macchina portava comunque una serie di problemi: • questo metodo comportava un enorme lavoro da parte del programmatore, che era 11 generalmente un tecnico super specializzato, e inoltre la complessità del linguaggio stesso impediva anche ai programmatori più esperti di scrivere programmi molto complessi. • Questo tipo di linguaggio portava i programmatori ad utilizzare certi tipi di “astuzie” che erano spesso comprensibili solo da colui che aveva realizzato il programma, e quindi difficilmente comprensibili ad un altro programmatore; • per le stesse ragioni era difficilissimo in tali programmi, riconoscere e correggere gli errori; • questi linguaggi, essendo molto vicini alla logica del calcolatore, erano molto difficilmente trasportabili ad un altro tipo di calcolatore; • infine il linguaggio macchina era molto lontano dal modo di ragionare dell’uomo, nonché dai suoi mezzi espressivi (cioè il suo linguaggio naturale). Tutte queste ragioni portarono gli studiosi del tempo a tentare di semplificare il modo di programmare, cercando di esprimere i programmi in un linguaggio più facilmente comprensibile all’uomo. Un primo tentativo di semplificare il linguaggio di programmazione fu la creazione del linguaggio Assembler, in cui alcune istruzioni, che in linguaggio macchina venivano espresse mediante delle sequenze di cifre binarie, venivano codificate mediante delle parole chiave. Tuttavia la logica di base per la scrittura dei programmi restava legata alla logica del calcolatore. L’Assembler quindi, pur permettendo una semplificazione del lavoro, costringeva a ragionare ancora in un modo strettamente legato a quello del processore. Per cui in seguito si cercò di distaccarsi sempre piú dalla logica dei processori arrivando cos ai cosiddetti linguaggi ad alto livello orientati non piú alla macchina ma alla soluzione di problemi. I linguaggi a basso livello furono accantonati quando, con l’invenzione del linguaggio FORTRAN (FORmula TRANslation), venne per la prima volta introdotto un linguaggio ad alto livello, ossia più vicino alla logica dell’uomo e più lontano dalla logica del calcolatore. I linguaggi ad alto livello si rivelarono subito più adatti a codificare gli algoritmi oltre che molto più comprensibile dall’uomo. Inoltre il compito di tradurre il programma nel linguaggio macchina è affidato alla macchina stessa mediante l’uso di un particolare software, detto compilatore, che ha il compito da un lato di verificare la correttezza sintattica del programma, e dall’altro di tradurre il programma nel linguaggio macchina. L’introduzione dei linguaggi di programmazione a basso livello ha portato dei grandissimi vantaggi: • Molte più persone sono oggi in grado di programmare in questi linguaggi, dunque si sono potuti realizzare programmi per risolvere problemi sempre più complessi rispetto a quelli che si riuscivano ad affrontare con i linguaggi a basso livello; 12 • Questo sistema risulta portabile In quanto il programma risulta virtualmente indipendente dal processore o dalla macchina particolare in cui si sviluppa. Quindi un cambiamento della macchina non stravolge drammaticamente il lavoro fatto; • Il programma risulta più leggibile, nel senso che sia il programmatore originario che un altro programmatore sono in grado di leggere, modificare o correggere senza grosse difficoltà un programma già esistente. Una volta verificata la maggiore efficacia dei linguaggi di programmazione ad alto livello rispetto ai linguaggi a basso livello, tutto un filone della ricerca Informatica si occupò di progettare vari tipi linguaggi di programmazione ad alto livello. In base al modo di approcciarsi alla soluzione del problema, si parla di diversi paradigmi (dal greco paradeigma=modello) di programmazione. Esistono due grosse classi di paradigmi di programmazione: il paradigma dichiarativo e il paradigma procedurale o imperativo. 5.1 Il paradigma dichiarativo Nel paradigma dichiarativo si elimina la fase di ricerca della strategia per la risoluzione del problema (algoritmo) facendo in modo che la strategia risolutiva sia trovata dal programma stesso. Ci si concentra più sul “cosa” si deve risolvere piuttosto che sul “come”: ecco perché i tempi di elaborazione possono essere piuttosto lunghi. Esempio: Un programma di tipo dichiarativo per il Massimo Comun Divisore (MCD) specificherà: • La teoria dei numeri naturali e le definizioni di addizione, moltiplicazione etc. • “cosa” vuole il problema, cioè la definizione di MCD. Per esempio “Il Massimo Comun Divisore tra x e y è il massimo numero z tale che sia x che y divisi per z danno come resto 0”. • Il programma verificherà per ogni numero più piccolo di x e y se soddisfa la definizione. I programmi dichiarativi sono in genere brevi, leggibili, verificabili e facilmente riutilizzabili. Sono molto vicini al metodo matematico di deduzione di predicati a partire da certe premesse o assiomi. Tuttavia la programmazione dichiarativa ha un grande costo in termini di efficienza. Per esempio nel caso del MCD l’interprete applicherà la definizione sostituendo a una variabile z tutti i valori 1, 2, 3 . . . fino a trovare il valore di z che soddisfa la definizione di Massimo Comun Divisore. Nell’ambito dei paradigmi dichiarativi ritroviamo il paradigma logico, ossia quello utilizzato nel linguaggio di programmazione PROLOG (PROgrammazione LOGica): Il programma è considerato come la dimostrazione della verità di un‘asserzione. 13 • Si definisce una serie di assiomi o fatti elementari assunti come veri; • I risultati vengono prodotti da un processo di calcolo deduttivo; • Per definire un modello logico occorre definire: oggetti (domains), relazioni fra gli oggetti (predicates), fatti e regole (clauses), obiettivi (goals); I linguaggi logici risultano particolarmente adatti a risolvere problemi che riguardano le relazioni fra varie entità. Il Prolog prende ispirazione dalla teoria matematica del calcolo dei predicati del primo ordine. Sempre nell’ambito del paradigma dichiarativo, la Programmazione Funzionale trasforma invece il problema in una funzione, quindi l’OUTPUT sarà dato dal valore che la funzione assume sul valore dell’INPUT. Nel linguaggio funzionale puro manca del tutto l’assegnazione. Qui riveste particolare importanza la ricorsione e, come struttura dati, la lista. Il linguaggio funzionale più utilizzato nella pratica è il LISP (LISt Processing) che prende ispirazione dal lambda calcolo di Church, ed è stato utilizzato per esempio per realizzare certi text editor come l’emacs. 5.2 Il Paradigma Imperativo e la Programmazione Strutturata Il paradigma imperativo è caratterizzato dal fatto che, nella progettazione del programma, la fase fondamentale consiste nella ricerca di una strategia risolutiva per un problema specifico, espressa mediante un algoritmo. Nel paradigma imperativo il programma costituito da una sequenza di istruzioni il cui effetto quello di modificare il contenuto della memoria dell’elaboratore o di determinare le modalit di esecuzione di altre istruzioni; in questo modello assume un ruolo fondamentale l’istruzione di assegnazione. Sono imperativi la maggior parte dei linguaggi piú diffusi (Pascal, Basic, Fortran, C, Cobol, ecc.). La Programmazione strutturata, alle cui regole si ispira il linguaggio Pascal, ma anche la programmazione ad oggetti, è basata su dei principi fondamentali: 1. Scomposizione del problema in blocchi, ossia in problemi più semplici detti procedure. 2. Ogni blocco ha un solo punto di entrata e un solo punto di uscita. 3. Utilizzazione di solo 3 costrutti di controllo (sequenza, selezione, iterazione). Ci si chiede se i principi della programmazione strutturata non siano troppo restrittivi. Per esempio ci si chiede se l’assenza del costrutto di salto incondizionato (GOTO), utilizzato ampiamente in alcuni linguaggi di programmazione, che permette di passare direttamente a una certa istruzione del programma, non limiti l’efficacia del linguaggio. Il seguente importante teorema prova che tutto ciò che si può calcolare utilizzando paradigmi di programmazione più flessibili, può essere calcolato utilizzando la programmazione strutturata. 14 Teorema 5.1 (di Böem - Jacopini) Un qualunque programma non strutturato può essere realizzato utilizzando i soli costrutti di sequenza, selezione e iterazione. 5.3 Costrutto di sequenza Il costrutto di sequenza consiste nell’elencare in maniera sequenziale le istruzioni che devono essere eseguite. La sintassi di questo costrutto in Pascal è la seguente: BEGIN <istruzione 1>; <istruzione 2>; ··· ··· <istruzione n>; END; che equivale a dire: “Esegui la seguente sequenza di istruzioni in quest’ordine”. 5.4 Costrutto di selezione Nel costrutto di selezione un’istruzione o una serie di istruzioni viene eseguita in dipendenza di una certa condizione. Il principale costrutto di selezione utilizzato in Pascal è il seguente: if < condizione > then BEGIN <sequenza di istruzioni 1> END else BEGIN <sequenza di istruzioni 2>; END; Questo costrutto significa: “se la <condizione> è vera allora esegui <sequenza di istruzioni 1> altrimenti esegui <sequenza di istruzioni 2>. In Pascal possiamo avere anche il seguente costrutto: if <condizione> then BEGIN <sequenza di istruzioni>; END; che significa: “se la condizione è vera allora esegui istruzioni altrimenti passa all’istruzione successiva”. 15 In Pascal esiste anche un costrutto di selezione che verifica allo stesso tempo diverse condizioni: case <espressione di controllo> of V 1: BEGIN <sequenza di istruzioni END; V 2: BEGIN <sequenza di istruzioni END; ... V k: BEGIN <sequenza di istruzioni END; else BEGIN <sequenza di istruzioni> {per END; 1> 2> k> ogni valore non codificato} I valori V k sono detti valori-chiave. Questo costrutto significa: “Se il risultato dell’<espressione di controllo> è uno dei valori chiave V i allora esegui le istruzioni corrispondenti <sequenza di istruzioni i>, altrimenti esegui la <sequenza di istruzioni>”. L’espressione di controllo deve essere una variabile intera o un carattere: non può assumere valori reali. Tale costrutto non può essere utilizzato nei casi in cui la scelta è fondata sulla valutazione di espressioni o il valore chiave è un certo intervallo: le grandezze devono essere ben definite. 5.5 Costrutto di Iterazione Nel costrutto di iterazione un’istruzione o una serie di istruzioni vengono ripetute tante volte in relazione al verificarsi o meno ad una certa condizione che viene controllata all’inizio o alla fine di ogni ciclo. Affinché un costrutto di iterazione funzioni correttamente, le condizioni devono essere sempre inizializzate e devono modificare il proprio valore durante l’esecuzione del ciclo. Nei cicli condizionali (while e repeat) la condizione può essere o una variabile booleana o un espressione valutabile in forma booleana (TRUE o FALSE). In Pascal uno dei cicli condizionali è il ciclo while: while <condizione> do BEGIN <sequenza di istruzioni>; END; 16 che significa “continua ad eseguire la <sequenza di istruzioni> fintantoché la <condizione> continua ad essere vera”. In Pascal possiamo anche realizzare il ciclo condizionale mediante il costrutto repeat-until, che ha la seguente sintassi: repeat <sequenza di istruzioni> until <condizione>; Che significa: “ripeti la <sequenza di istruzioni> fino a quando la <condizione> diventa vera”. Anche se le istruzioni sono più di una, i comandi BEGIN e END si possono omettere, in quanto repeat e until svolgono loro il compito di parentesi. Un altro costrutto di iterazione disponibile in Pascal è il ciclo for, che si può applicare quando conosciamo a priori il numero di volte in cui il ciclo deve essere eseguito. In Pascal ha la seguente sintassi: for <variabile:= k to h> do BEGIN <sequenza di istruzioni>; END; che significa: “esegui h − k volte le istruzioni”. Se h ≤ k allora il programma salta l’intera <sequenza di istruzioni>. La variabile deve essere un intero o un carattere (tra apici) o una variabile di tipo enumerativo. Nelle dichiarazioni deve essere specificato l’intervallo in cui varia la variabile contatore. i:=n..t; se al posto di to si scrive downto esegue il conto alla rovescia da k ad h. 5.6 Cicli equivalenti I cicli while e repeat possono, con alcuni accorgimenti, essere utilizzati in maniera equivalente. Cosı̀ come sono definiti, essi differiscono dal fatto che mentre nel ciclo repeat-until le istruzioni vengono eseguite almeno una volta, visto che la condizione viene verificata alla fine, nel ciclo while la condizione viene testata all’inizio del ciclo, per cui se essa non è vera la prima volta, il programma non eseguirà nemmeno una volta le istruzioni del ciclo. Si noti inoltre che mentre nel ciclo while la condizione deve essere vera perchè le istruzioni vengano eseguite, nel ciclo repeat-until l’istruzione deve risultare falsa per riprendere ad eseguire da capo le istruzioni. Infine il ciclo for viene utilizzato quando conosciano esattamente il numero di volte in cui le istruzioni devono essere eseguite. Esso può essere simulato mediante i cicli while e repeat-until, ma non è vero il viceversa, cioè i cicli while e repeat-until non sempre possono essere simulati mediante un 17 ciclo FOR. Diamo ora uno schema di come si possono simulare alcune istruzioni di ciclo condizionale mediante altre: Il ciclo: for i:=1 to n do BEGIN <sequenza di istruzioni>; END; è equivalente a: i:=1; {inizializzazione del contatore i a 1} while i<=n do BEGIN <sequenza di istruzioni>; i:=i+1; END; oppure alle istruzioni: i:=0; {inizializzazione del contatore i a 0} repeat <sequenza di istruzioni>; i:=i+1; until i=n; Anche i cicli while e repeat-until possono essere resi equivalenti con particolari accorgimenti. Per esempio: while <condizioni> DO BEGIN <sequenza di istruzioni>; END; è equivalente a: if <condizioni> then {se la condizione è vera} repeat <sequenza di istruzioni>; until not <condizioni>; {condizione falsa} Il ciclo: 18 repeat <sequenza di istruzioni>; until <condizioni> è equivalente a: <sequenza di istruzioni>; while not <condizioni> DO BEGIN <sequenza di istruzioni>; END 6 Il Sistema Binario Tutti i dati elaborati da un calcolatore vengono immagazzinati in memoria sotto forma di parole binarie. La rappresentazione dell’informazione (o codifica) è l’assegnazione di una stringa di simboli a ciascuno degli oggetti che vogliamo rappresentare. Abbiamo quindi bisogno di un insieme finito di simboli, detto alfabeto. Generalmente considereremo un alfabeto di due simboli (0 e 1) detto alfabeto binario. Se fissiamo un intero k, le possibili sequenze distinte di lunghezza k costituite da due simboli sono in numero 2k . Infatti per ciascuna posizione, il simbolo può essere scelto in due modi distinti, e otteniamo quindi 2k possibili combinazioni. Normalmente le parole binarie contenute nelle celle di memoria sono di lunghezza 16 o 32 bits, quindi possiamo rappresentare fino a 216 o 232 diversi elementi. Osserviamo che 28 = 216 216 = 65.536 232 = 4.294.967.296 Teorema 6.1 Sia b ∈ Z+ un intero non negativo. Ogni intero n > 1 può essere espresso in maniera unica nella forma n = am bm + am−1 bm−1 + · · · + a1 b + a0 dove m è un intero non negativo, 0 ≤ ai < b per i 6= m e 0 < am < b. Questo teorema ci permette di rappresentare gli interi non negativi in qualunque base b. Se infatti b è un intero non negativo, il numero n potrà essere rappresentato dalla sequenza am am−1 · · · a1 a0 dove tutti gli ai sono minori di b. La sequenza am am−1 · · · a1 a0 sarà la rappresentazione numero n in base b. In particolare se b = 2, allora otteniamo la rappresentazione binaria di n. Nel sistema binario quindi, ogni numero sarà espresso 19 con le sole cifre 0 e 1 (< b = 2). Il motivo per cui il sistema binario viene utilizzato nei calcolatori è che un segnale binario si può facilmente ottenere mediante apertura e chiusura dei circuiti. Se quindi ci viene dato un numero in forma binaria, per determinare qual’è il suo valore in forma decimale, basta applicare la formula del teorema. Ci interessa descrivere un algoritmo che ci permette di effettuare l’operazione contraria, ossia dato un numero espresso in forma decimale, rappresentarlo in forma binaria. 6.1 Metodo delle Divisioni Successive Tale metodo permette di esprimere un qualsiasi numero intero decimale n in forma binaria. Il nostro obiettivo è quello di scrivere n come somma di potenze di 2. Il metodo generale è quello di trovare la più grande potenza di 2 che si approssima per difetto ad n, sottrarla al numero e iterare il procedimento sulla differenza fino ad arrivare al valore 1. Si scrive quindi 1 in corrispondenza di ogni potenza di 2 presente e 0 per quelle che non figurano. Esempio: N = 149 149 − 128 = 21 21 − 16 = 5 5−4=1 27 = 128 24 = 16 22 = 4 20 = 1 quindi possiamo scrivere n = 1 ∗ 27 + 0 ∗ 26 + 0 ∗ 25 + 1 ∗ 24 + 0 ∗ 23 + 1 ∗ 22 + 1 ∗ 20 . Questo può essere espresso dicendo che (n)2 = 10010101. Il metodo descritto è tuttavia algoritmicamente poco efficiente, poiché dobbiamo trovare, provandole una per una, qual’è la potenza di 2 che più si approssima per difetto al numero dato. Il metodo più frequentemente usato per determinare la rappresentazone binaria di un numero è quello delle divisioni successive. Consideriamo n = am bm +am−1 bm−1 +· · ·+a1 b+a0 che è un numero generico scritto in base b. Se dividiamo il numero per b, utilizzando la distributività della divisione rispetto alla somma, otteniamo: a0 n = am bm−1 + am−1 bm−2 + · · · + a1 + b b Questo equivale a dire che am bm−1 + am−1 bm−2 + · · · + a1 è il quoziente e a0 è il resto della divisione del numero per b. Questo significa che per ottenere il termine meno significativo della rappresentazione in base b basta dividere il nostro numero (in base decimale) per b e prendere il resto. Per ottenere il secondo termine meno significativo basta iterare il procedimento sul quoziente. Il resto della divisione per b darà tale termine. Tale procedura viene iterata finché non si arriva ad avere quoziente uguale a 1. Il numero in forma binaria si ottiene leggendo l’ultimo quoziente (1) e tutti i resti letti dall’ultimo al primo. 20 Esempio: Sia n = 155 155 : 2 = 77 + 1 77 : 2 = 38 + 1 38 : 2 = 19 + 0 19 : 2 = 9 + 1 9:2=4+1 4:2=2+0 2:2=1+0 1 (155)2 = 10011011 6.2 Addizione fra due numeri binari Per sommare due numeri espressi in forma binaria, si ricorre alla seguente tavola di addizione per le cifre binarie 0 + 0 = 0, 0 + 1 = 1 + 0 = 1, 1 + 1 = 10 Per effettuare la somma di due numeri si agisce mediante il metodo di addizione noto per i numeri decimali. Esempio: 11001101 + 10101110 = 101111011 Si osservi che per sommare due numeri di taglia n si compiono circa 2n operazioni di accesso alla tavola di addizione (n per le somme delle cifre più eventualmente altre n somme per i riporti). Diremo quindi che quest’algoritmo svolge O(n) operazioni elementari. 6.3 Moltiplicazione fra due numeri binari Per moltiplicare 2 numeri di taglia n abbiamo bisogno di definire una tavola di moltiplicazione. Abbiamo che: 0 ∗ 0 = 0 ∗ 1 = 1 ∗ 0 = 0, 1 ∗ 1 = 1 Per quanto riguarda la moltiplicazione a più cifre ricorriamo al classico algoritmo della moltiplicazione, per cui ogni cifra del secondo numero viene moltiplicata per il primo, e infine i risultati, shiftati ciascuno di una posizione verso sinistra rispetto al precedente, vengono sommati fra loro. Quindi per moltiplicare due numeri di taglia n, si ricorre n2 volte alla tavola di moltiplicazione, si compiono circa n SHIFT (spostamenti) e infine si operano O(n2 ) somme. In totale otteniamo quindi che questo algoritmo svolge O(n2 ) operazioni elementari. 21 Osservazione: Esiste un algoritmo più sofisticato per la moltiplicazione di due numeri binari che ha una complessità uguale a O(nlog 3 ). Esercizi 1. Tradurre i seguenti numeri espressi in binario nei corrispondenti numeri decimali: 1001101, 1100110, 1010110, 11111, 111010, 1000001, 1101111, 1010000. 2. Tradurre i seguenti numeri espressi nel sistema decimale nei corrispondenti numeri in sistema binario: 342, 118, 201, 74, 174, 131, 200, 100. 3. Effettuare le seguenti somme di numeri binari: 100100101 + 1001010, 11111111 + 1110101, 101001000 + 111100001, 1100110010 + 1010000100. 4. Effettuare i seguenti prodotti fra numeri binari: 10010×101, 10111×111, 1101×110, 11111 × 100. 6.4 Rappresentazione binaria degli interi relativi Nei calcolatori è necessario dare una rappresentazione, oltre che degli interi positivi, anche degli interi negativi. Esistono due possibili codifiche degli interi relativi (cioè sia positivi che negativi). La prima è la rappresentazione con modulo e segno, che utilizza il primo bit disponibile per la rappresentazione del segno (0 positivo e 1 negativo) e tutti gli altri per la rappresentazione del modulo del numero. In tal modo, se abbiamo a disposizione 4 bit per la rappresentazione del numero, avremo che, per esempio, 3 sarà rappresentato da 0011, mentre -3 sarà rappresentato da 1011. Osserviamo che però questa rappresentazione crea ambiguità rispetto allo zero. Infatti 1000 e 0000 rappresentano entrambi lo zero. Questo può complicare il controllo delle operazioni aritmetiche. Per questo motivo spesso si adotta una rappresentazione diversa, che è la rappresentazione in complemento a due. In questa rappresentazione, se abbiamo a disposizione k bit per rappresentare il nostro intero x, consideriamo la rappresentazione binaria di 2k + x, ed eliminiamo la cifra più significativa (ossia quella più a sinistra). Per esempio, se abbiamo a disposizione 4 bit e vogliamo rappresentare il numero 3, consideriamo la rappresentazione binaria di 16(= 24 ) + 3 = 19 che è uguale a 10011, ed eliminiamo la prima cifra, ottenendo cosı̀ 0011 per la rappresentazione di 3 (che poi è esattamente corrispondente alla rappresentazione binaria di 3). Stessa cosa se dobbiamo fare una rappresentazione di −3: calcoliamo 16 − 3 = 13, calcoliamo la sua rappresentazione binaria 01101, ed eliminando la prima cifra, otteniamo 1101. Si noti che tutti i numeri positivi inizieranno per 0 mentre tutti i numeri negativi inizieranno per 1. Inoltre per ottenere l’opposto di un numero occorre invertire tutti i bits del numero. Con questa codifica lo zero avrà un’unica rappresentazione 0000 e potremo rappresentare i numeri interi da −2k−1 fino a 2k−1 − 1. 22 Vedremo in seguito come vengono rappresentati i numeri razionali e i numeri reali all’interno del calcolatore. 6.5 Rappresentazione di dati non numerici Oltre che i numeri, il calcolatore deve anche registrare dei dati non numerici, ossia le lettere dell’alfabeto e tutti gli altri caratteri (punteggiatura, parentesi, simboli speciali, etc.). Per fare questo si sono stabiliti dei codici, che sono degli standard universali, per cui i caratteri alfanumerici vengono codificati con particolari sequenze nell’alfabeto {0, 1}. Per quanto riguarda i linguaggi occidentali, come l’italiano e l’inglese, il codice più frequentemente usato è il Codice ASCII (American Standard Code for Information Interchange) che codifica i simboli utilizzando 7 bit (cioè vengono codificati 27 = 128 caratteri). Tuttavia questo codice non riesce, per esempio a rappresentare le lettere accentate, quindi esistono delle versioni estese di codice ASCII (oggi più frequentemente usata) che utilizza 8 bit, con un’estensione a 256 caratteri. Simile a questa versione estesa del codice ASCII è il codice EBCDIC (Extended Binary-Coded Decimal Interchange Code) sviluppato da IBM e che fa uso, anch’esso di 8 bit. Tuttavia 8 bit non sono sufficienti a rappresentare i caratteri di varie lingue del mondo (i caratteri greci, i caratteri cirillici, i caratteri arabi, etc). Per questo è stato inventato un tipo di codice, chiamato UNICODE, basato su successioni di 16 bit, in grado quindi di codificare 216 = 65535 diversi caratteri. Per facilitare il passaggio da ASCII a UNICODE, i primi 128 caratteri del codice UNICODE sono gli stessi del codice ASCII. 23