| Dec | Hex | Char | Dec | Hex | Char | Dec | Hex | Char | Dec | Hex | Char |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0x00 | NUL | 32 | 0x20 | SP | 64 | 0x40 | @ | 96 | 0x60 | ` |
| 1 | 0x01 | SOH | 33 | 0x21 | ! | 65 | 0x41 | A | 97 | 0x61 | a |
| 2 | 0x02 | STX | 34 | 0x22 | " | 66 | 0x42 | B | 98 | 0x62 | b |
| 3 | 0x03 | ETX | 35 | 0x23 | # | 67 | 0x43 | C | 99 | 0x63 | c |
| 4 | 0x04 | EOT | 36 | 0x24 | $ | 68 | 0x44 | D | 100 | 0x64 | d |
| 5 | 0x05 | ENQ | 37 | 0x25 | % | 69 | 0x45 | E | 101 | 0x65 | e |
| 6 | 0x06 | ACK | 38 | 0x26 | & | 70 | 0x46 | F | 102 | 0x66 | f |
| 7 | 0x07 | BEL | 39 | 0x27 | ' | 71 | 0x47 | G | 103 | 0x67 | g |
| 8 | 0x08 | BS | 40 | 0x28 | ( | 72 | 0x48 | H | 104 | 0x68 | h |
| 9 | 0x09 | HT | 41 | 0x29 | ) | 73 | 0x49 | I | 105 | 0x69 | i |
| 10 | 0x0a | LF | 42 | 0x2a | * | 74 | 0x4a | J | 106 | 0x6a | j |
| 11 | 0x0b | VT | 43 | 0x2b | + | 75 | 0x4b | K | 107 | 0x6b | k |
| 12 | 0x0c | FF | 44 | 0x2c | , | 76 | 0x4c | L | 108 | 0x6c | l |
| 13 | 0x0d | CR | 45 | 0x2d | - | 77 | 0x4d | M | 109 | 0x6d | m |
| 14 | 0x0e | SO | 46 | 0x2e | . | 78 | 0x4e | N | 110 | 0x6e | n |
| 15 | 0x0f | SI | 47 | 0x2f | / | 79 | 0x4f | O | 111 | 0x6f | o |
| 16 | 0x10 | DLE | 48 | 0x30 | 0 | 80 | 0x50 | P | 112 | 0x70 | p |
| 17 | 0x11 | DC1 | 49 | 0x31 | 1 | 81 | 0x51 | Q | 113 | 0x71 | q |
| 18 | 0x12 | DC2 | 50 | 0x32 | 2 | 82 | 0x52 | R | 114 | 0x72 | r |
| 19 | 0x13 | DC3 | 51 | 0x33 | 3 | 83 | 0x53 | S | 115 | 0x73 | s |
| 20 | 0x14 | DC4 | 52 | 0x34 | 4 | 84 | 0x54 | T | 116 | 0x74 | t |
| 21 | 0x15 | NAK | 53 | 0x35 | 5 | 85 | 0x55 | U | 117 | 0x75 | u |
| 22 | 0x16 | SYN | 54 | 0x36 | 6 | 86 | 0x56 | V | 118 | 0x76 | v |
| 23 | 0x17 | ETB | 55 | 0x37 | 7 | 87 | 0x57 | W | 119 | 0x77 | w |
| 24 | 0x18 | CAN | 56 | 0x38 | 8 | 88 | 0x58 | X | 120 | 0x78 | x |
| 25 | 0x19 | EM | 57 | 0x39 | 9 | 89 | 0x59 | Y | 121 | 0x79 | y |
| 26 | 0x1a | SUB | 58 | 0x3a | : | 90 | 0x5a | Z | 122 | 0x7a | z |
| 27 | 0x1b | ESC | 59 | 0x3b | ; | 91 | 0x5b | [ | 123 | 0x7b | { |
| 28 | 0x1c | FS | 60 | 0x3c | < | 92 | 0x5c | \ | 124 | 0x7c | | |
| 29 | 0x1d | GS | 61 | 0x3d | = | 93 | 0x5d | ] | 125 | 0x7d | } |
| 30 | 0x1e | RS | 62 | 0x3e | > | 94 | 0x5e | ^ | 126 | 0x7e | ~ |
| 31 | 0x1f | US | 63 | 0x3f | ? | 95 | 0x5f | _ | 127 | 0x7f | DEL |
10 Input e output
In tutti i programmi che abbiamo esaminato fino a questo momento abbiamo messo insieme dati in ingresso e codice di analisi. Nel nostro primo programma di fit, ad esempio, le coordinate dei punti da fittare e le incertezze associate erano fisicamente scritte nel file python. Questo va bene per cose molto semplici, ma questo paradigma banalmente si rompe oltre un certo livello di complessità—dovrete imparare molto presto a separare i dati (che andranno in un file a se stante di qualche tipo) dal codice che li processa. In molti casi i dati vi saranno forniti all’origine nella forma di un file, magari scritto da un sistema di acquisizione dedicato.
Tutto questo ci mette immediatamente davanti ad una classe di problemi che non abbiamo ancora affrontato: come possiamo usare Python per scrivere dati su disco e, soprattutto, leggerli indietro?
Se non sapete rispondere a questa domanda, siete nel posto giusto, perché questo capitolo è dedicato proprio a questo. Cercheremo di rispondere affrontando non solo gli aspetti logistici (come faccio in pratica a leggere un file di testo con dati in colonna e fare un grafico in Python?), ma anche il problema più profondo di come diversi tipi di oggetti sono rappresentati all’interno di un calcolatore digitale. Sappiamo già più o meno che tutto è una sequenza di \(0\) e \(1\)—ma quale sequenza rappresenta a? E il numero intero 7 e 7.0?
La cosa ha implicazioni profonde perché, anche se il modello di dati di Python è largamente non standard, per cui una variabile di tipo int all’interno dell’interprete è un oggetto significativamente più complesso che non un numero intero su disco, il modo in cui rappresentiamo le cose determina in parte le operazioni che possiamo fare sulle cose stesse. Lo vedremo in qualche dettaglio quando parleremo dell’aritmetica in virgola mobile, nel Capitolo 11.
10.1 Rappresentazione del testo
Avrete sicuramente tutte presenti la scena del film “The Martian” in cui l’astronauta Mark Watney (al secolo Matt Damon), davanti al problema non banale di stabilire un canale di comunicazione con il controllo missione a Terra, rispolvera un vecchio manuale degli anni ’60 recuperato da uno scatolone.
La trascrizione della scena è estremamente istruttiva, per cui la riportiamo verbatim qui sotto.
So, here’s the rep. Somehow we have to have complex astrophysical engineering conversations using nothing but a still frame camera from 1996. Luckily, the camera does spin, so I can make an alphabet. I can’t be our alphabet: 26 characters plus a question card and the 360 gives us 13 degrees of arc, that’s way too narrow, you’d never know what the camera was pointing at…
[Pausa di suspence per costruire il climax ascendente.]
Hexadecimals! Hexadecimals!
I figured one of you guys kept an ASCII table lying around. And I was right!
Wow. Se questo non è ragionare come una fisica, non so cosa potrebbe esserlo. Vediamo un attimo.
- Mark è capace di calcolare approssimativamente \(360 / 27\) in testa—il processo mentale potrebbe essere: \(360 / 30 = 12\); \(27\) è circa il \(10\%\) più piccolo di \(30\); per cui il risultato sarà il \(10\%\) più grande di 12; ovvero circa \(13\).
- Mark ha ben chiaro il concetto di unità di misura, e capisce immediatamente che una risoluzione angolare di \(13^\circ\) è al limite per il sistema che sta per mettere in piedi.
- Mark conosce la rappresentazione esadecimale dei numeri—un momento: non è quella che abbiamo visto nella Sezione 7.3?
- Ma soprattutto: Mark conosce la tabella ASCII, ed è proprio questo che ci porta all’argomento della prima sezione di questo capitolo. (Ci arriviamo tra un secondo.)
In un certo senso potremmo dire che dopo il corso di laboratorio del primo anno avreste qualche chance di sopravvivere da sole su Marte, se per caso vi capitasse di essere abbandonate là. (La coltivazione delle patate, però, dovete impararla come attività extra-curricolare.)
Perdonerete la lunghezza di questo siparietto introduttivo, ma ci sono due cose che devo dirvi. “Discussioni complicate di ingegneria astrofisica” non vuol dire assolutamente niente; se dopo la laurea doveste essere ingaggiate come consulenti per un film di fantascienza, resistete, per favore, alla tentazione di mettere assurdità dei dialoghi solo perché alcune parole sono accattivanti di altre.
Un’altra cosa: se guardate la scena su YouTube (un attimo! ho appena nominato la piattaforma di sorveglianza di Google?) noterete che (sigh!) “ASCII table” è sottotitolato come “asking table”. Vi rendete conto? Il concetto fondamentale dell’intera scena letteralmente annientato in sei caratteri…
10.1.1 La tabella ASCII
Se ci pensate un attimo, l’idea di fondo nella scena di “The Martian” è tutto sommato semplice. Se vogliamo rappresentare in modo efficiente un insieme arbitrario di caratteri, possiamo assegnare univocamente un numero intero a ciascuno di essi ed usare questo numero al posto il carattere. La tabella di conversione che ne risulta è quello che si chiama generalmente un encoding, o codifica. (Ed il processo inverso—quello, cioè, che fa passare da numero a carattere, si chiama decoding, o decodifica.)
Cosa ci guadagno, vi chiedete? Beh, nella situazione di Mark Watney, questo permette di rappresentare qualsiasi carattere con 16 cifre, che si traducono in un settore circolare sotteso da un arco di \(22.5^\circ\). Ma tornando al nostro problema, i numeri interi hanno la bella proprietà di poter essere scritti nella loro rappresentazione binaria—cioè proprio come una sequenza di \(0\) e \(1\)!
Nei giorni eroici dei primi calcolatori digitali la codifica dei dati era implementata in modo custom ed incompatibile da sistema a sistema. Lo standard ASCII (American Standard Code for Information Interchange), inizialmente pubblicata nel 1963, è stato il primo tentativo organico di mettere ordine nella questione. In linea di principio la decisione di come rappresentare i dati è totalmente arbitraria, ma uno standard comune è fondamentale per lo scambio di informazioni. (Con le dovute differenze, una cosa simile succede anche noi tutti i giorni: la Fisica è indipendente dal nome che diamo alle variabili, ma vi è un vantaggio indubbio nell’usare, ove possibile, convenzioni comuni, e.g., indicare l’energia con \(E\) e la massa con \(m\).)
Nel progettare uno standard di codifica, la prima cosa che dobbiamo chiederci è: quanti, e quali caratteri vogliamo rappresentare? La codifica ASCII originale prevedeva \(128\) caratteri (corrispondenti quindi a \(7\) bit), cui hanno fatto seguito un certo numero di proposte di estensione, parzialmente incompatibili tra loro, a \(8\) bit, i.e., \(256\) caratteri. (La scelta iniziale non è irragionevole, perché \(128\) è paragonabile al numero di tasti di una tastiera tipica da terminale.) Questi \(128\) caratteri includono le \(26\) lettere (maiuscole e minuscole) dell’alfabeto inglese, le cifre da \(0\) a \(9\), segni di punteggiatura, parentesi ed operatori, più un certo numero di cose più esotiche che non abbiamo tempo di analizzare in dettaglio.
La codifica ASCII, dunque, è essenzialmente una tabella di \(128\) righe nella quale, a ciascuno dei \(128\) caratteri scelti, corrisponde un numero intero da \(0\) a \(127\), che prende il nome di code point. I primi \(32\) code point sono riservati a caratteri di controllo (non stampabili) che, senza andare troppo a fondo nella questione, includono tra le altre cose lo spazio e l’andata a capo. Ogni carattere è rappresentato in memoria (e, eventualmente, in un file) come il suo code point, ed ogni code point può essere convertito nel carattere corrispondente una volta che sappiamo l’encoding. Tanto per fare un esempio, il code point ASCII del carattere a (lettera a minuscola) è 97 in decimale, i.e., 0x61 in esadecimale.
print(ord("a"), hex(ord("a")))
print(chr(97))97 0x61
a
Se supponiamo, ad esempio, di avere un file contenente la stringa “Ciao!” (più un’andata a capo alla fine della prima linea), ed assumiamo di avere un qualche tipo di utility per visualizzato il contenuto su disco del file (ad esempio xxd su GNU-Linux), il file avrebbe una dimensione di esattamente \(6\) byte ed apparirebbe come \[
\overbrace{\texttt{01000011}}^{67~\text{(C)}}\;\;
\overbrace{\texttt{01101001}}^{105~\text{(i)}}\;\;
\overbrace{\texttt{01100001}}^{97~\text{(a)}}\;\;
\overbrace{\texttt{01101111}}^{111~\text{(o)}}\;\;
\overbrace{\texttt{00100001}}^{33~\text{(!)}}\;\;
\overbrace{\texttt{00001010}}^{10~\text{(Line feed)}}
\]
10.1.2 Lo standard unicode
Le più attente tra voi avranno notato che la tabella ASCII non comprende, tra le altre cose, le lettere accentate—che pure sono, per ovvi motivi, rilevanti per chi parla (e scrive in) italiano. Se questo vi preoccupa, mettetevi per un attimo nei panni di una programmatrice che vive in un paese in cui non si usa l’alfabeto latino—diciamo in Giappone. Come dovrebbe sentirsi lei?
Inutile dirlo, si tratta di un problema che è difficile da ignorare. Anche accettando l’inglese come la lingua universale dell’informatica, immaginate tutte le interfacce grafiche e le pagine web prodotte nelle circa \(8000\) lingue che sono correntemente usate sul pianeta. Non ci soffermiamo troppo sulla questione, ma per riassumere una storia lunga in una frase possiamo dire: la confusione è stata pressoché totale fino all’avvento dello standard unicode. Chiunque abbia più di 40 anni ricorda perfettamente quanto comune fosse negli anni ’90 imbattersi una pagina web o in un’email in cui tutti o un sottoinsieme dei caratteri (e.g., le lettere accentate) erano rappresentati con strani segni incomprensibili.
La cosa curiosa è che il problema che stiamo cercando di risolvere è semplice: dobbiamo mappare un insieme di caratteri in un insieme di numeri interi. La cosa difficile è mettersi d’accordo su quali caratteri e su come stabilire la corrispondenza. E per molti decenni organizzazioni e compagnie diverse hanno semplicemente deciso di procedere in ordine sparso ed in modo non mutuamente compatibile—al di là, ovviamente, della tabella ASCII, su cui l’accordo è stato trovato più o meno subito.
Ma veniamo a unicode. Senza entrare nel dettaglio si tratta di uno standard che definisce un certo numero di encoding, ovvero di corrispondenze che associano un numero intero (o code point) ad ogni carattere in un insieme predefinito. I due encoding principali si chiamano UTF-8 e UTF-16. (Senza entrare nell’annosa questione su quale sia superiore, UTF-8 ha la buona proprietà di essere retro-compatibile con la tabella ASCII.) Per darvi un’idea della varietà di caratteri supportata da unicode, basti pensare che lo standard copre, tra le molte altre cose, il cuneiforme sumero-accadico, e che ci sono proposte per includere il Klingon. Va da sé, non potevano mancare gli emoji—che, anzi, storicamente sono stati uno dei fattori fondamentali per l’adozione dello standard.
In Python potete usare emoji, lo sapevate?
print("\U0001F600")😀
Ok, abbiamo divagato abbastanza. Cosa dovete ricordare di tutto questo? Semplicemente che un encoding è un modo per associare un numero intero ad un carattere; quando scriviamo un carattere su disco la sequenza di \(0\) ed \(1\) che usiamo è semplicemente il code point del carattere, e quando leggiamo da disco facciamo l’operazione inversa, passando dal code point al carattere. Se volete rileggere esattamente quello che avete scritto dovete fare attenzione ad usare lo stesso encoding. Se avete dubbi, usate UTF-8.
10.1.3 Scrivere e leggere file di testo
In Python i file di testo si leggono e si scrivono utilizzando il builtin open(). Date un’occhiata veloce alla documentazione, perché è garantito che, prima o poi, dobbiate usarlo. Per un motivo tecnico in cui non abbiamo tempo di addentrarci, è una buona pratica mettere la funzione open() all’interno di uno statement with—è un pattern che vedrete spessissimo, per cui vale la pena fare le cose nel modo più Pythonico possibile sin dall’inizio. In sintesi:
# Write text to file...
with open("path/to/the/file.txt", "w", encoding="utf-8") as output_file:
output_file.write("Hello world!\n")
# Note the file is automatically closed when you exit from the with block.
# Read text from file, all at once...
with open("path/to/the/file.txt", "r", econding="utf-8") as input_file:
text = input_file.read()
print(text)
# Read text from file, line by line...
with open("path/to/the/file.txt", "r", econding="utf-8") as input_file:
for line in input_file:
print(line)Le regole sono semplici: sapete cosa è il percorso ad un file, perché lo abbiamo visto nel Capitolo 9, e, oltre al percorso, alla funzione open() dovete passare due cose:
- la modalità di apertura—
w(write) per scrivere er(read) per leggere; - l’encoding da utilizzare—e ormai siamo ferrate sull’argomento.
Una cosa fondamentale da mettere a fuoco—che può confondere all’inizio chi, come noi, lavora prevalentemente con numeri—è il fatto che i file di testo si chiamano di testo per una ragione. Se scriviamo 2 in un file di testo, intendiamo il carattere con code point \(50\), o \(0x32\) della tabella ASCII, e non il numero intero \(2\). Quando lo rileggiamo indietro in Python otteniamo un oggetto di tipo <str>, non <int>. Se volete un numero, dovete fare la conversione esplicitamente:
text = "2"
num = int(text)
print(text, type(text), num, type(num))
text = "2."
num = float(text)
print(text, type(text), num, type(num))2 <class 'str'> 2 <class 'int'>
2. <class 'str'> 2.0 <class 'float'>
(Ma aspettate di leggere la prossima sezione prima di disperare.)
La cosa è rilevante non solo dal punto di vista logistico (dobbiamo stare attente a convertire stringhe in numeri ove necessario), ma anche da un punto di vista più profondo: forse il testo non è il modo più efficiente di scrivere numeri in un file? Ci torniamo tra un attimo, nella Sezione 10.2.
10.1.4 Leggere dati tabulari
Per il caso d’uso piuttosto comune di un file che contiene dati in forma tabulare, ovverosia righe consecutive, ciascuna delle quali contiene un numero fissato di valori, separati da un carattere opportuno (uno spazio, un TAB, o una virgola), di solito non è necessario riscrivere ogni volta un programma dedicato per la lettura: numpy ci viene in soccorso con la funzione loadtxt().
Come sempre, la documentazione è il riferimento ultimo che descrive gli argomenti della funzione, ma assumendo di avere un file di dati con tre colonne (spoiler alert: non sono prese a caso)
# Time [s], Period [s], Transit time [s]
2.629304, 2.167903, 0.008696
3.713143, 2.167476, 0.008726
4.796780, 2.167056, 0.008756
5.880199, 2.166631, 0.008786
...
possiamo leggerlo, spacchettando le colonne in array di numpy che possono essere immediatamente utilizzate, con una linea di codice:
import numpy as np
t, period, transit = np.loadtxt("path/to/file.txt", delimiter=",", unpack=True)10.2 Rappresentazione dei numeri
Riprendiamo il filo della discussione, e torniamo alla rappresentazione dei dati. Dato che lavoriamo principalmente con i numeri, questi ci interessano in modo particolare, e possiamo chiederci se non ci sia un modo più intelligente di scriverli su disco (e rappresentarli in memoria) che non come sequenza di caratteri.
Partiamo dall’inizio. Quando scriviamo un file in formato testo ogni carattere della prima pagina della tabella ASCII costa esattamente un byte, il che vuol dire che quando scriviamo un numero di \(n\) cifre ci servono esattamente \(n\) byte, più eventualmente un byte extra per il punto decimale . se il numero è in virgola mobile. Non molto efficiente: con un byte in formato testo posso scrivere una cifra (i.e., i numeri interi da \(1\) a \(9\)), mentre in linea di principio sappiamo già bene che \(8\) bit corrispondono a \(2^8 = 256\) possibilità. In un certo senso potremmo dire che stiamo usando \(10 / 256\), ovverosia meno del 4% della capacità del nostro byte. Non molto impressionante.
In questa sezione ci occupiamo proprio della rappresentazione standard dei numeri interi ed in virgola mobile.
Per rimanere aderenti al titolo di questo capitolo, che è “input ed output”, questo è il momento di notare che la rappresentazione di numeri che discutiamo di seguito è quella utilizzata quando chiamiamo la funzione open() in modalità binaria, i.e. rb o wb anziché testo. Con ogni probabilità all’inizio non dovrete scrivere o leggere molti dati in formato binario, ma siete avvertite.
10.2.1 Numeri interi
Per noi che abbiamo una buona familiarità con i bit e la numerazione in base \(2\), la rappresentazione dei numeri interi risulta tutto sommato naturale—sicuramente più naturale e meno ambigua della rappresentazione di una stringa. Sappiamo già che con \(n\) bit possiamo rappresentare \(2^n\) configurazioni diverse, ad esempio tutti i numeri interi positivi tra \(0\) e \(2^n - 1\), ed in effetti se ci limitiamo agli interi positivi (senza segno, o unsigned) questo è proprio ciò che accade. Allora con \(8\) bit possiamo rappresentare gli interi tra \(0\) e \(2^8 - 1 = 255\), con \(16\) bit quelli da \(0\) a \(2^{16} - 1 = 65536\), e così via; il valore di un intero senza segno è semplicemente il valore numerico del pattern binario che lo rappresenta in memoria.
Il segno complica leggermente le cose. La generalizzazione più semplice di quanto abbiamo appena detto è quella che generalmente va sotto il nome di sign-magnitude representation: utilizziamo il bit più significativo per codificare il segno (con lo \(0\) che rappresenta numeri positivi e l’\(1\) che rappresenta numeri negativi), e gli altri \(n - 1\) per rappresentare il valore numerico. Questa rappresentazione non è utilizzata in pratica perché ha due limitazioni importanti:
- ci sono due rappresentazioni possibili dello zero (se volete, \(+0\) e \(-0\)), il che è suscettibile a confusione; e, cosa pi`u importante,
- interi positivi ed interi negativi debbono essere processati indipendentemente nelle operazioni aritmetiche.
La strategia che invece si utilizza nei calcolatori è la cosiddetta 2’s complement representation in cui il bit più significativo rappresenta ancora il segno (con la solita convenzione), mentre i restanti \(n - 1\) bit rappresentano il valore numerico con le seguenti regole:
- per gli interi positivi (ovverosia quando il bit più significativo è \(0\)) il valore assoluto del numero è uguale al valore numerico del pattern binario degli \(n - 1\) bit meno significativi;
- per gli interi negativi si prende il complemento (ovvero la negazione bit a bit) degli \(n - 1\) bit meno significativi, aumentata di \(1\). Non `e difficile convincersi che in questo modo si possono rappresentare tutti gli interi tra \(-2^{n - 1}\) e \(2^{n - 1} - 1\), con un’unica rappresentazione (e quindi non ambigua) dello zero. Ad esempio con \(8\) bit si possono rappresentare tutti i numeri da un minimo di \(-128\) \[ \texttt{10000000} %\underbrace{1}_\text{segno}\!\!\!\!\!\!\overbrace{0000000}^\text{valore} \rightarrow -(0b1111111 + 0b1) = -0b10000000 = -0x80 = -128 \] ad un massimo di \(127\) \[ \texttt{01111111} %\underbrace{0}_\text{segno}\!\!\!\!\!\!\overbrace{1111111}^\text{valore} \rightarrow +0b1111111 = +0x7F = +127, \] passando per lo zero, che è ovviamente rappresentato dal pattern \(\texttt{000000000}\).
La maggior parte dei linguaggi di programmazioni mette a disposizione dell’utente tipi distinti per interi unsigned e signed, con diverse profondità—tipicamente \(8\), \(16\), \(32\) o \(64\) bit, ovverosia \(1\), \(2\), \(3\) o \(4\) byte. I corrispondenti intervalli utili sono indicato nella Tabella 10.2. Python fa eccezione: come abbiamo visto gli interi hanno precisione arbitraria, e la memoria utilizzata per la rappresentazione è allocata automaticamente a seconda del valore che vogliamo rappresentare. Questo non vuol dire che quando scriviamo su disco possiamo farlo senza sapere quanto è grande quello che scriviamo.
| Tipo | Bit | Valore minimo | Valore massimo |
|---|---|---|---|
| signed | 8 | \(-2^7 = -128\) | \(2^7-1 = 127\) |
| unsigned | 8 | \(0\) | \(2^8-1 = 255\) |
| signed | 16 | \(-2^{15} = -32\,768\) | \(2^{15}-1 = 32\,767\) |
| unsigned | 16 | \(0\) | \(2^{16}-1 = 65\,535\) |
| signed | 32 | \(-2^{31} \approx - 2.15 \times 10^9\) | \(2^{31}-1 \approx 2.15 \times 10^9\) |
| unsigned | 32 | \(0\) | \(2^{32}-1 \approx 4.29 \times 10^9\) |
| signed | 64 | \(-2^{63} \approx - 9.22 \times 10^{18}\) | \(2^{63}-1 \approx 9.22 \times 10^{18}\) |
| unsigned | 64 | \(0\) | \(2^{64}-1 \approx 1.85 \times 10^{19}\) |
10.2.2 Digressione: numpy e tipi di intero
Arrivate a questo punto non possiamo fare a meno di addentrarci per un attimo in una piccola stranezza di Python che, se non compresa appieno, potrebbe prima o poi venirvi a cercare.
Come abbiamo detto nel capitolo Capitolo 4, il tipo intero nativo di Python è questa sorta di miracolo della natura che offre precisione infinita (fino ad esaurimento della memoria), tenendo automaticamente traccia di quanti bit servono allo scopo. Contrariamente alla rappresentazione standard degli interi che abbiamo appena descritto, non ci sono limiti a quanto grande può essere un intero.
Le cose sono radicalmente diverse quando usiamo array di numpy. Fino ad ora non abbiamo avuto occasione di notarlo, ma quando creiamo un array di numpy, questo array nasce e muore con un tipo ben preciso attaccato.
import numpy as np
a = np.array([1, 2, 3])
print(a, a.dtype)[1 2 3] int64
Oh oh: int64 assomiglia pericolosamente ad un intero con segno a \(64\) bit come quello nella Tabella 10.2. In effetti possiamo anche controllare il tipo nel momento in cui creiamo l’array—possiamo decidere di usare, e.g., interi senza segno a \(8\) bit (quelli che si estendono da \(0\) a \(255\)):
import numpy as np
a = np.array([1, 2, 3], dtype="uint8")
print(a, a.dtype)[1 2 3] uint8
C’è un’ottima ragione per questo. Gli array di numpy sono allocati in modo continuo nella memoria, ed il fatto che ogni elemento occupi uno spazio noto a priori è cruciale per ragioni di performance al momento dell’accesso.
Questo porta a volte a situazioni che possono apparire paradossali. Guardate per un attimo questo breve programma:
import numpy as np
a = np.arange(17, dtype="uint8")
print(a)
print(a**2)[ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16]
[ 0 1 4 9 16 25 36 49 64 81 100 121 144 169 196 225 0]
Vedete niente di strano? L’ultimo elemento dell’array dei quadrati è zero! Come è possibile? Non dovrebbe essere \(256\)?
Mi fermo un attimo per lasciarvi il tempo di pensare.
Si: dovrebbe essere \(256\), ma \(256\) non si può rappresentare in \(8\) bit, per cui quello che succede è un rollover a zero.
Non vi lasciate spaventare più dello stretto necessario: nella vita reale non è una cosa che succede tutti i giorni. Quello che dovete tenere in mente, però, è che l’aritmetica intera a precisione infinita offerta da Python è un lusso, e non una cosa che dovete aspettarvi in generale.
10.2.3 Numeri in virgola mobile
La rappresentazione dei numeri in virgola mobile nei moderni calcolatori digitali è ormai universalmente conforme allo standard IEEE 754 (Cowlishaw 2008), nelle due specifiche di formato a 32 e 64 bit. Più precisamente, ogni volta che scriviamo un numero in virgola mobile, dobbiamo pensare che, all’interno del calcolatore, esso sia rappresentato nella forma \[ x = (-1)^s \times m \times 2^{e - b} \quad \text{dove} \quad 1 \leq m < 2, \] in cui, spostandosi da sinistra a destra nella rappresentazione—ovverosia dal bit più significativo a quello meno significativo:
- \(s\) rappresenta il del numero, e corrisponde al bit più significativo (\(0\) se il numero è positivo, \(1\) se è negativo);
- \(e\) è l’esponente, che ha a disposizione 8 bit in precisione singola e 11 bit in precisione doppia; \(b\), che prende il nome di bias, è una costante additiva (positiva) che permette di rappresentare numeri \(< 1\) senza bisogno di aggiungere un segno all’esponente, e vale \(127\) in precisione singola e \(1023\) in precisione doppia;
- \(m\) prende il nome di mantissa e rappresenta (utilizzando 23 bit in precisione singola e 52 bit in precisione doppia) la sequenza di cifre dopo la virgola, assumendo che la parte intera sia \(1\) (cioè la mantissa è convenzionalmente compresa tra \(1\) e \(2\) ed il bit più significativo, che è garantito essere \(1\), può essere omesso).
Proviamo a scrivere un numero semplice, \(x = 2.25\), seguendo queste regole, i.e., come il prodotto di una mantissa compresa tra \(1\) e \(2\) per una potenza di \(2\): \[ x = 2.25 = 1.125 \times 2 \] Nel formato in doppia precisione avremo dunque \[\begin{align*} s & = 0\\ e & - b = 1 \implies e = 1024 = 0b10000000000\\ m & = 1.125 = 0b(1).0010000000000000000000000000000000000000000000000000 \end{align*}\] (notate che abbiamo indicato tutti gli 11 bit per l’esponente ed i 52 per la mantissa, con il bit più significativo di normalizzazione tra parentesi) e, in definitiva \[ x \rightarrow 0b0\overbrace{0010000000}^{e-b} \overbrace{0010000000000000000000000000000000000000000000000000}^{m~\text{(bit più significativo omesso)}} \]
Wow! Abbiamo scritto il nostro primo numero in virgola mobile nella forma in cui lo troveremmo nel disco rigido del nostro calcolatore!
10.3 Stringhe, numeri e modalità di IO
Arrivate a questo punto, se avete letto con attenzione, non potrete fare a meno di chiedervi: se io scrivo su disco un singolo byte, ad esempio 10101001, che cosa ho fatto realmente? Dato che \[
0b10101001 = 0xa9 = 169
\] abbiamo almento tre risposte potenzialmente corrette a disposizione! Il \(169\) è al di fuori della tabella ASCII ristretta, ma se lo interpretiamo come code point di una tipica estensione ad \(8\) bit della codifica ASCII, abbiamo scritto un bel simbolo di copyright. Se assumiamo che il nostro byte di storage rappresenti un intero unsigned, allora abbiamo scritto \(169\). Se invece assumiamo che sia un intero con segno, siccome il bit più significativo è \(1\), si tratta di un numero negativo, e per la precisione \(-87\). Complicato?
La risposta corretta è, come spesso accade: dipende. Quando rileggiamo dal disco il nostro byte di informazione possiamo intepretarlo come vogliamo. Ed il corollario è che sarà bene assicurarci prima dell’intento con cui il file è stato scritto per evitare di prendere cantonate. Se il file in questione è un file di testo, allora dovremo leggerlo come tale, e con l’encoding corretto (quello con cui è stato scritto). Se invece il file è un file binario, allora dovremo leggerlo con la modalità opportuna, e dobbiamo sapere esattamente qual è il layout dei dati all’interno per poterli spacchettare nel modo opportuno.
Abbiamo imparato una lezione fondamentale, ovvero la distinzione tra il contenuto di una porzione di memoria (sia essa volatile o su disco) ed il significato che noi diamo a quella particolare sequenza di \(0\) ed \(1\) all’interno del nostro programma. Ed abbiamo capito che lo stesso bit pattern in memoria può essere interpretato in più modi, che dobbiamo fare attenzione ad essere consistenti nelle fasi di lettura e di scrittura.
10.3.1 Formato testo o binario?
Nel caso d’uso tipico per il corso di laboratorio—quello in cui scriviamo, manualmente o programmaticamente, un numero (limitato) di valori in un file di testo, per poi rileggerli con numpy—salvare i dati in formato testo va benissimo. Il footprint in memoria è irrilevante in entrambi i casi, ed abbiamo il vantaggio che un file di testo si può aprire con un editor e leggere direttamente.
Ma se doveste un giorno trovarvi a realizzare un sistema di acquisizione professionalmente preparatevi a dominare i file binari, perché una delle cose che dovrete fare sarà cercare di comprimere il volume di dati fino all’ultimo bit!