Lezione 4 di 6 · 25 min di lettura

File e riga di comando

Un programma che ricorda le cose tra un'esecuzione e l'altra, e che riceve istruzioni quando lo lanci. Leggere e scrivere file con simplifile, salvare JSON su disco, e gli argomenti della riga di comando con argv.

Un programma che dimentica tutto

Finora ogni programma è ripartito da zero a ogni gleam run: i dati erano scritti nel codice, e quello che il programma calcolava spariva quando finiva. Anche gli attori del modulo 7 dimenticano tutto quando il programma si spegne.

Per ricordare qualcosa tra un’esecuzione e l’altra, un programma deve scriverlo fuori da sé: in un file. E per fare cose diverse a ogni esecuzione, deve poter ricevere istruzioni da chi lo lancia: gli argomenti della riga di comando. Per entrambe le cose ci sono due pacchetti piccoli e molto usati:

terminale
gleam add simplifile argv

Leggere e scrivere file

simplifile ha una funzione per ogni operazione sui file. Le tre fondamentali:

FunzioneCosa fa
simplifile.read(from: percorso)Legge tutto il file come testo: Result(String, FileError).
simplifile.write(to: percorso, contents: testo)Scrive il testo nel file, creandolo o sostituendo quello che c’era.
simplifile.append(to: percorso, contents: testo)Aggiunge il testo in fondo al file, creandolo se non c’è.

Tutte restituiscono un Result, perché con i file può sempre andare storto qualcosa: il file non esiste, la cartella non esiste, non hai i permessi, il disco è pieno.

src/notes.gleam
import gleam/io
import gleam/string
import simplifile

pub fn main() -> Nil {
  let assert Ok(Nil) = simplifile.write(to: "notes.txt", contents: "buy milk\n")
  let assert Ok(Nil) =
    simplifile.append(to: "notes.txt", contents: "call Joe\n")
  io.println(string.inspect(simplifile.read("notes.txt")))
  io.println(string.inspect(simplifile.read("nowhere/notes.txt")))
}
output
Ok("buy milk\ncall Joe\n")
Error(Enoent)

Dopo averlo lanciato, nella cartella exercises trovi un file notes.txt con due righe. Il \n alla fine di ogni riga è l’a capo (lezione 1.6): i file di testo sono una lunga stringa, e le righe sono separate da quel carattere.

Il secondo read fallisce: la cartella nowhere non esiste. L’errore Enoent ha un nome strano perché viene dal sistema operativo: è l’abbreviazione di Error: NO ENTry, “nessuna voce con questo nome”. FileError ha una variante per ogni errore del sistema, Eacces (permesso negato), Enospc (disco pieno) e così via, e simplifile.describe_error le traduce in una frase: No such file or directory.

Dettagli nerd Cos'è un percorso? (e la cartella di lavoro)

Un percorso (path) dice dove si trova un file, cartella dopo cartella: /home/ada/learn-gleam/exercises/notes.txt. Un percorso che comincia con / è assoluto: parte dalla radice del disco, e vale da qualsiasi posto (su Windows comincia con una lettera, come C:\).

Un percorso come notes.txt o data/books.json è relativo: parte dalla cartella di lavoro, quella in cui ti trovi quando lanci il programma. Con gleam run è la cartella del progetto, dove sta gleam.toml; per questo notes.txt è finito lì, e non dentro src/. Se lanciassi lo stesso programma da un’altra cartella, cercherebbe e creerebbe i file lì.

Salvare JSON su disco

File e JSON insieme fanno la cosa più utile di tutte: salvare i propri dati in modo strutturato, e rileggerli la volta dopo. Con due fonti di errore diverse, però: il file potrebbe non leggersi (simplifile.FileError), o potrebbe non contenere JSON valido (json.DecodeError). Come nel modulo 5, le riuniamo in un tipo nostro, con result.map_error:

src/bookshelf.gleam
import gleam/dynamic/decode
import gleam/int
import gleam/io
import gleam/json
import gleam/list
import gleam/result
import simplifile

pub type Book {
  Book(title: String, year: Int)
}

pub type ShelfError {
  FileError(simplifile.FileError)
  JsonError(json.DecodeError)
}

const path = "books.json"

pub fn main() -> Nil {
  let books = [Book("Dune", 1965), Book("Emma", 1815)]
  let assert Ok(Nil) = save(books)
  case load() {
    Ok(books) ->
      list.each(books, fn(book) {
        io.println(book.title <> " (" <> int.to_string(book.year) <> ")")
      })
    Error(FileError(error)) ->
      io.println("Cannot read the file: " <> simplifile.describe_error(error))
    Error(JsonError(_)) -> io.println("The file is not a valid bookshelf")
  }
}

fn save(books: List(Book)) -> Result(Nil, ShelfError) {
  let text = json.array(books, book_to_json) |> json.to_string
  simplifile.write(to: path, contents: text)
  |> result.map_error(FileError)
}

fn load() -> Result(List(Book), ShelfError) {
  use text <- result.try(simplifile.read(path) |> result.map_error(FileError))
  json.parse(text, decode.list(book_decoder()))
  |> result.map_error(JsonError)
}

fn book_to_json(book: Book) -> json.Json {
  json.object([
    #("title", json.string(book.title)),
    #("year", json.int(book.year)),
  ])
}

fn book_decoder() -> decode.Decoder(Book) {
  use title <- decode.field("title", decode.string)
  use year <- decode.field("year", decode.int)
  decode.success(Book(title:, year:))
}
output
Dune (1965)
Emma (1815)

E in books.json c’è il JSON compatto della lezione precedente: [{"title":"Dune","year":1965},{"title":"Emma","year":1815}]. Prova ad aprirlo con un editor e a rovinarlo, togliendo una parentesi; poi commenta la riga let assert Ok(Nil) = save(books) e rilancia: il programma risponde The file is not a valid bookshelf, invece di schiantarsi. Nota che FileError e JsonError sono varianti con dati, usate come funzioni da result.map_error (lezione 4.3).

Quiz

Il programma chiama simplifile.write(to: "log.txt", contents: "B") su un file che contiene A. Cosa contiene il file dopo?

Gli argomenti della riga di comando

Quando lanci un programma dal terminale, puoi scrivere delle parole dopo il suo nome: sono gli argomenti. Con gleam run, gli argomenti per il tuo programma vanno dopo un doppio trattino, --, che separa quelli per gleam da quelli per il programma:

terminale
gleam run -m greet -- Ada

Il pacchetto argv li legge: argv.load().arguments è una List(String), con una stringa per ogni argomento. E una lista si smonta con il pattern matching, come nel modulo 3:

src/greet.gleam
import argv
import gleam/io

pub fn main() -> Nil {
  case argv.load().arguments {
    [] -> io.println("Hello, stranger!")
    [name] -> io.println("Hello, " <> name <> "!")
    _ -> io.println("Usage: gleam run -m greet -- [name]")
  }
}
output
Hello, stranger!

Questa è l’uscita senza argomenti. Con un argomento:

terminale
gleam run -m greet -- Ada
output
Hello, Ada!

e con due, gleam run -m greet -- Ada Joe, il programma risponde con le istruzioni per l’uso. Gli argomenti sono separati dagli spazi; per passare una frase intera come un solo argomento, la metti tra virgolette: gleam run -m greet -- "Ada Lovelace".

Il -- si può omettere se nessun argomento comincia con un trattino. Ma se scrivi gleam run -m greet --verbose, gleam pensa che --verbose sia per lui, e risponde unexpected argument '--verbose' found, suggerendo proprio di usare -- --verbose. Mettere sempre il -- evita la sorpresa.

Dettagli nerd Da dove arrivano gli argomenti?

Quando il terminale avvia un programma, il sistema operativo gli consegna la lista delle parole scritte dopo il nome: è un meccanismo antico quanto Unix, e in C quella lista si chiama argv (argument vector), da cui il nome del pacchetto. Il terminale la prepara prima di lanciare il programma: divide la riga negli spazi, e toglie le virgolette, che servono solo a lui per capire dove finisce un argomento. Per questo il tuo programma riceve Ada Lovelace senza virgolette.

Esercizio · sul tuo computer

Il diario

Crea src/diary.gleam, un diario da riga di comando che salva le voci in diary.txt, una per riga:

  • gleam run -m diary -- add "testo" aggiunge una voce in fondo al file e stampa Saved;
  • gleam run -m diary -- read stampa le voci numerate (1. ..., 2. ...), oppure The diary is empty se il file non esiste ancora;
  • con argomenti diversi, stampa le istruzioni per l’uso.

Usa una costante per il nome del file. Prova la sequenza:

terminale
gleam run -m diary -- add "Learned about files"
gleam run -m diary -- add "Wrote JSON"
gleam run -m diary -- read

e incolla qui l’output dell’ultimo comando.

Mostra una soluzione (prima prova da solo!)
src/diary.gleam
import argv
import gleam/int
import gleam/io
import gleam/list
import gleam/string
import simplifile

const diary = "diary.txt"

pub fn main() -> Nil {
  case argv.load().arguments {
    ["add", entry] -> add(entry)
    ["read"] -> read()
    _ -> io.println("Usage: gleam run -m diary -- add <entry> | read")
  }
}

fn add(entry: String) -> Nil {
  case simplifile.append(to: diary, contents: entry <> "\n") {
    Ok(Nil) -> io.println("Saved")
    Error(error) -> io.println("Error: " <> simplifile.describe_error(error))
  }
}

fn read() -> Nil {
  case simplifile.read(diary) {
    Ok(text) ->
      text
      |> string.trim_end
      |> string.split("\n")
      |> list.index_map(fn(line, index) {
        int.to_string(index + 1) <> ". " <> line
      })
      |> list.each(io.println)
    Error(simplifile.Enoent) -> io.println("The diary is empty")
    Error(error) -> io.println("Error: " <> simplifile.describe_error(error))
  }
}

string.trim_end toglie l’ultimo a capo prima di dividere il testo in righe: senza, string.split produrrebbe una riga vuota in fondo, e il diario stamperebbe un 3. senza niente. Il pattern Error(simplifile.Enoent) distingue il caso “file non ancora creato”, che non è un vero errore, da tutti gli altri.

Ricapitolando

  • simplifile (gleam add simplifile): read, write (sostituisce), append (aggiunge in fondo); tutte restituiscono un Result con un FileError.
  • Enoent vuol dire “non esiste”; simplifile.describe_error spiega ogni errore a parole.
  • I percorsi relativi partono dalla cartella di lavoro: con gleam run, la cartella del progetto.
  • File e JSON insieme salvano dati strutturati; result.map_error riunisce errori di tipo diverso in un tipo tuo.
  • argv (gleam add argv): argv.load().arguments è la lista degli argomenti, da smontare con case.
  • Gli argomenti per il programma vanno dopo --: gleam run -m greet -- Ada.

Nella prossima lezione, prima della sfida finale: come si organizza un programma che non sta più in un file solo.