Lezione 6 di 7 · 20 min di lettura

Stampare e formattare

Costruire testo con +, String() e le t-string, arrotondare i decimali, allineare colonne, e scoprire perché in Mojo una stringa non ha una "lunghezza". Più le sequenze di escape e lo standard error.

Il problema

Alla fine della lezione scorsa il nostro programma stampava Energy: 0.23291666666666666 kWh. Corretto, ma nessuno vuole leggerlo. Stampare bene significa due cose: costruire il testo che vuoi, mettendo insieme parole e numeri, e impaginarlo, con i decimali giusti e le colonne allineate. Mojo ti dà diversi strumenti, ognuno con un suo scopo.

Tre modi per costruire una stringa

Il primo è la concatenazione con +, che però funziona solo tra stringhe. I numeri vanno convertiti con String(...):

concat.mojo
def main():
    var name = "Ada"
    var age = 36
    var line = "Name: " + name + ", age: " + String(age)
    print(line)
output
Name: Ada, age: 36

Dimenticare la conversione è un errore di compilazione (invalid call to '__add__', con cannot be converted from 'Int'). Il secondo modo è passare più argomenti a String(...): li converte e li unisce tutti, senza spazi in mezzo, ed è più efficiente di una catena di + perché costruisce il risultato in un colpo solo.

mojo
var line = String("Name: ", name, ", age: ", age)

Il terzo, il più comodo, sono le t-string (template string): una stringa preceduta da t, con le espressioni da inserire tra parentesi graffe.

tstring.mojo
def main():
    var name = "Ada"
    var age = 36
    print(t"{name} is {age} years old, next year {age + 1}")
    var width = 3.5
    var height = 2.0
    print(t"Area: {width * height} m2")
    print(t"Literal braces: {{like this}}")
output
Ada is 36 years old, next year 37
Area: 7.0 m2
Literal braces: {like this}

Tra le graffe va qualsiasi espressione, e ogni valore viene convertito in testo da solo. Per scrivere una graffa vera si raddoppia: {{ e }}.

Dettagli nerd Perché una t-string è più veloce di una stringa normale?

Una t-string non è una String: è un valore di tipo TString che ricorda il modello e i valori da inserire, senza costruire subito il testo. print la scrive direttamente sull’output pezzo per pezzo, senza mai allocare in memoria la stringa completa. Allocare memoria (chiedere al sistema un blocco libero, e poi restituirlo) è tra le operazioni più costose di un programma che fa tanti piccoli lavori: evitarla dove non serve è il genere di dettaglio su cui Mojo è costruito. Se la stringa ti serve davvero, per esempio per salvarla in una variabile e modificarla, la costruisci esplicitamente con String(t"...").

Arrotondare

Niente specificatori di formato, quindi i decimali si sistemano prima di stamparli. Lo strumento è round, built-in come print:

rounding.mojo
def main():
    var kwh = 0.23291666666666666
    print(round(kwh, 2), round(kwh, 3))
    var price = 2.4
    var qty = 3.0
    print(price * qty, round(price * qty, 2))
output
0.23 0.233
7.199999999999999 7.2

round(x, cifre) restituisce un altro Float64, arrotondato al numero di decimali richiesto, che print mostra nella forma più breve. Nella seconda riga vedi perché serve: 2,4 × 3 fa 7,199999999999999, perché 2,4 non è rappresentabile esattamente in binario.

C’è però un limite: round non aggiunge zeri. round(7.2, 2) resta 7.2, non 7.20: un Float64 non sa quante cifre vuoi vedere, conosce solo il suo valore.

Dettagli nerd Quanto fa round(2.5)?

Fa 2.0. E round(3.5) fa 4.0, round(0.5) fa 0.0, round(-2.5) fa -2.0. Senza cifre decimali, round usa l’arrotondamento al pari (round half to even, detto anche “arrotondamento del banchiere”): quando il valore è esattamente a metà, sceglie l’intero pari più vicino. Il motivo è statistico: arrotondare sempre per eccesso i casi a metà sposta la media verso l’alto, e su milioni di valori (pensa a una somma di importi, o a una media di misure) l’errore si accumula. Scegliendo il pari, metà delle volte si arrotonda per eccesso e metà per difetto. Python e lo standard IEEE 754 fanno lo stesso.

Soldi e decimali fissi

Quando i decimali devono essere esattamente due, come negli importi, la tecnica più robusta è non usare i decimali affatto: si lavora in centesimi, con gli Int, e si formatta solo alla fine. Con // e % separi euro e centesimi; per avere sempre due cifre (05, non 5) c’è ascii_rjust, che allinea a destra una stringa riempiendo a sinistra:

money.mojo
def main():
    var cents = 1205
    var euros = cents // 100
    var rest = String(cents % 100).ascii_rjust(2, "0")
    print(t"Total: {euros}.{rest} EUR")
output
Total: 12.05 EUR

String(5).ascii_rjust(2, "0") fa "05": la stringa viene portata a 2 caratteri aggiungendo "0" a sinistra. Se è già lunga abbastanza, resta com’è.

Colonne allineate

Oltre a ascii_rjust esistono ascii_ljust (allinea a sinistra, riempiendo a destra) e ascii_center. Con queste si costruiscono tabelle leggibili:

table.mojo
def main():
    print("Item".ascii_ljust(10), "Qty".ascii_rjust(5))
    print("Tea".ascii_ljust(10, "."), String(2).ascii_rjust(5))
    print("Biscuits".ascii_ljust(10, "."), String(12).ascii_rjust(5))
    print("caffè".ascii_ljust(10, "."), String(3).ascii_rjust(5))
output
Item         Qty
Tea.......     2
Biscuits..    12
caffè....     3

Guarda l’ultima riga: caffè ha un punto in meno e la colonna si è spostata. Non è un bug: il prefisso ascii_ è un avvertimento. Queste funzioni contano i byte, non i caratteri, e in UTF-8 la è occupa due byte. Con testo solo ASCII (lettere inglesi, cifre, punteggiatura) byte e caratteri coincidono; con gli accenti, no.

Una stringa non ha una lunghezza

Da qui nasce una delle regole più sorprendenti di Mojo. Prova a chiedere la lunghezza di una stringa come in Python:

length.mojo
def main():
    var word = "caffè"
    print(len(word))
output
/home/ada/learn-mojo/length.mojo:3:11: error: `len(String/StringSlice)` is not supported because Mojo strings are UTF-8 encoded, so a single length is ambiguous: it could mean the number of UTF-8 bytes, the number of Unicode code points, or the number of user-visible characters (grapheme clusters). Use `s.byte_length()` or `len(s.bytes())` for the number of UTF-8 bytes, `len(s.codepoints())` for Unicode code points, or `len(s.graphemes())` for grapheme clusters.
    print(len(word))
          ^~~
<unknown>:0: note: 'len' declared here
/home/ada/learn-mojo/.venv/bin/mojo: error: failed to parse the provided Mojo source module

Il compilatore rifiuta la domanda perché è ambigua, e ti spiega le tre risposte possibili. Proviamole tutte, su due parole che a schermo sembrano quasi uguali:

length.mojo
def main():
    var w = "caffè"  # è is one code point, U+00E8
    var v = "caffe\u0301"  # e followed by a combining acute accent
    print(w, w.byte_length(), w.count_codepoints(), w.count_graphemes())
    print(v, v.byte_length(), v.count_codepoints(), v.count_graphemes())
output
caffè 6 5 5
caffé 7 6 5
  • byte_length(): quanti byte occupa in memoria. È quello che conta per le funzioni ascii_, per i file e per la rete.
  • count_codepoints(): quanti code point Unicode contiene.
  • count_graphemes(): quanti caratteri percepiti da un essere umano (grapheme cluster).

La seconda parola (che a schermo appare come caffé) è scritta con una e seguita dal code point U+0301, l’accento acuto “combinante”, che si fonde con la lettera precedente: 7 byte, 6 code point, ma sempre 5 caratteri visibili. Per lo stesso motivo non puoi scrivere word[0]: devi dire se intendi il byte (word[byte=0]), il code point (word[codepoint=0]) o il carattere (word[grapheme=0]).

Dettagli nerd Byte, code point, grafemi: cos'è UTF-8?

Unicode assegna un numero, il code point, a ogni simbolo di ogni sistema di scrittura: A è U+0041, è è U+00E8, l’accento acuto combinante è U+0301. UTF-8 è il modo più diffuso di scrivere questi numeri in byte: i code point fino a U+007F (l’ASCII) occupano 1 byte, quelli fino a U+07FF 2 byte, poi 3 e fino a 4. Per questo è pesa 2 byte e il testo inglese non paga niente in più.

Un grafema è ciò che una persona chiamerebbe “un carattere”: a volte è un solo code point, a volte una sequenza (una lettera più accenti combinanti, una bandiera fatta di due code point, certi simboli composti anche da sette). Contare i grafemi richiede di applicare le regole dello standard Unicode (UAX #29), quindi è l’operazione più lenta delle tre; contare i byte è istantaneo. Mojo ti obbliga a scegliere perché la scelta ha un costo diverso, e perché la risposta “giusta” dipende da cosa devi fare.

Quiz

Hai var cents = 1205. Quale espressione produce esattamente il testo 12.05?

Caratteri speciali

Alcuni caratteri non si possono scrivere direttamente dentro le virgolette. Si usano le sequenze di escape, che iniziano con la barra rovesciata:

SequenzaSignificato
\na capo
\ttabulazione
\" e \'virgolette dentro una stringa
\\una barra rovesciata
\u00E8il code point U+00E8 (è), con 4 cifre esadecimali
\U0001F525un code point oltre U+FFFF, con 8 cifre
escapes.mojo
def main():
    print("Line one\nLine two")
    print("Name\tLanguage")
    print('She said "hi"')
    print('It\'s "fine"')
    print("C:\\mojo\\learn")
    print("Caff\u00e8 ready")
    print(r"Raw: \n stays \n")
    var note = """Triple quotes
keep the line breaks"""
    print(note)
output
Line one
Line two
Name	Language
She said "hi"
It's "fine"
C:\mojo\learn
Caffè ready
Raw: \n stays \n
Triple quotes
keep the line breaks

Nota le virgolette: una stringa che contiene virgolette doppie si scrive più comodamente tra apici singoli, e viceversa. Quando servono entrambe, una delle due va protetta con la barra rovesciata. È anche la scelta che fa mojo format: tra "..." e '...' usa quella che richiede meno escape.

Il prefisso r crea una stringa grezza (raw), in cui le barre rovesciate restano barre rovesciate: comoda per percorsi Windows ed espressioni regolari. Le tre virgolette permettono una stringa su più righe, ed è la stessa sintassi delle docstring. E due letterali scritti uno accanto all’altro vengono uniti dal compilatore: "Hello, " "World" è "Hello, World".

Due uscite: stdout e stderr

print scrive sullo standard output, ma accetta un argomento file per scrivere altrove. Lo standard error si importa dal modulo sys:

streams.mojo
from std.sys import stderr


def main():
    print("Result: 42")
    print("Warning: using default settings", file=stderr)
output
Result: 42
Warning: using default settings

Nel terminale le due righe appaiono insieme, ma sono canali diversi, e la shell sa separarli. Prova:

terminale
mojo streams.mojo > result.txt

Il terminale mostra solo l’avviso: il risultato è finito nel file result.txt, perché > redirige solo lo standard output. È per questo che i messaggi diagnostici vanno su stderr: un altro programma può leggere il tuo output pulito, senza avvisi mescolati in mezzo. Il compilatore stesso scrive i suoi errori su stderr.

Esercizio · sul tuo computer

Lo scontrino

Crea receipt.mojo che stampi esattamente questo scontrino:

output
Coffee x2 ......... 2.40
Croissant x3 ...... 4.50
Water x1 .......... 0.90
Total ............. 7.80

Regole:

  • i prezzi unitari sono in centesimi: caffè 120, cornetto 150, acqua 90;
  • il totale va calcolato, non scritto a mano;
  • la parte sinistra di ogni riga ("Coffee x2 ", con lo spazio finale) va allineata a 19 caratteri con ascii_ljust(19, "."), seguita da uno spazio e dall’importo;
  • gli importi si formattano con //, % e ascii_rjust(2, "0").

Senza funzioni (arrivano nel prossimo modulo) dovrai ripetere un po’ di codice: va bene così, ed è proprio il motivo per cui le funzioni esistono.

Mostra una soluzione (prima prova da solo!)
receipt.mojo
comptime COFFEE = 120
comptime CROISSANT = 150
comptime WATER = 90


def main():
    var coffee = COFFEE * 2
    var croissant = CROISSANT * 3
    var water = WATER * 1
    var total = coffee + croissant + water

    var left = "Coffee x2 ".ascii_ljust(19, ".")
    var cents = String(coffee % 100).ascii_rjust(2, "0")
    print(t"{left} {coffee // 100}.{cents}")

    left = "Croissant x3 ".ascii_ljust(19, ".")
    cents = String(croissant % 100).ascii_rjust(2, "0")
    print(t"{left} {croissant // 100}.{cents}")

    left = "Water x1 ".ascii_ljust(19, ".")
    cents = String(water % 100).ascii_rjust(2, "0")
    print(t"{left} {water // 100}.{cents}")

    left = "Total ".ascii_ljust(19, ".")
    cents = String(total % 100).ascii_rjust(2, "0")
    print(t"{left} {total // 100}.{cents}")

Ricapitolando

  • + concatena solo stringhe: i numeri si convertono con String(n). String(a, b, c) unisce più valori in una volta.
  • Le t-string t"... {espressione} ..." inseriscono valori senza allocare; {{ e }} per le graffe; niente specificatori di formato.
  • round(x, n) arrotonda (al pari quando è esattamente a metà) ma non aggiunge zeri; per gli importi si lavora in centesimi con //, % e ascii_rjust(2, "0").
  • ascii_ljust, ascii_rjust, ascii_center allineano, contando byte.
  • len() su una stringa non compila: byte_length(), count_codepoints(), count_graphemes(), e l’indicizzazione s[byte=i], s[codepoint=i], s[grapheme=i].
  • Escape: \n, \t, \", \\, \u00E8; stringhe grezze r"..." e su più righe """...""".
  • print(..., file=stderr) con from std.sys import stderr scrive sullo standard error.

Nella prossima lezione tiriamo le somme del modulo con una sfida che mette insieme tutto: costanti, conversioni, operatori e impaginazione.