Lezione 6 di 6 · 30 min di lettura

Riepilogo e sfida finale

Funzioni esterne, decoder, JSON, file e argomenti in una pagina, tre domande di controllo, e uno scaffale di libri da riga di comando che ricorda tutto tra un'esecuzione e l'altra.

Guarda quanta strada

In questo modulo i tuoi programmi sono usciti dal loro guscio. Adesso sai:

  • chiamare funzioni Erlang con @external, e ricordarti che il compilatore si fida della firma che scrivi;
  • trasformare dati di forma sconosciuta in valori Gleam con i decoder, e leggere gli errori che producono;
  • leggere e scrivere JSON con gleam_json, con un decoder e un encoder per ogni tipo;
  • salvare dati su file con simplifile, e ricevere comandi dalla riga di comando con argv;
  • dividere un programma in moduli sotto la cartella del progetto, documentarlo, e impacchettarlo con gleam export escript.

La pagina da tenere accanto

CosaCome si scrive
Funzione esterna@external(erlang, "math", "sqrt") + fn square_root(x: Float) -> Float (senza corpo)
Tipo esternopub type UniqueId (senza varianti)
Testo da Erlanggleam/erlang/charlist: charlist.to_string(c)
Applicare un decoderdecode.run(dato, decode.int) → Ok(v) o Error([DecodeError(..)])
Decoder di un recorduse x <- decode.field("x", decode.int) … decode.success(R(x:))
Campi facoltatividecode.optional_field("k", riserva, decoder), decode.optional(d) per null
Alternativedecode.one_of(d1, or: [d2]), decode.map(d, f)
Leggere JSONjson.parse(from: testo, using: decoder)
Scrivere JSONjson.object([#("k", json.int(1))]) \|> json.to_string
Filesimplifile.read(p), simplifile.write(to: p, contents: t), simplifile.append
Argomentiargv.load().arguments; gleam run -m m -- a b
Moduli del progettosrc/shelf/book.gleam → import shelf/book
Documentazionegleam docs build --open
Un eseguibilegleam export escript, poi ./shelf

Trovi tutto anche nel Codex (tasto K).

Quiz di controllo

Quiz

Hai scritto una funzione esterna con un tipo di ritorno sbagliato. Quando te ne accorgi?

Quiz

Un decoder per Person(name: String, age: Int) riceve un dato senza name e con age scritto "forty". Quanti errori trovi nella lista?

Quiz

Con gleam run, dove finisce un file scritto con simplifile.write(to: "data.json", ...)?

La sfida: lo scaffale

Completa il programma shelf della lezione precedente: uno scaffale di libri da riga di comando, che salva i libri in un file shelf.json e se li ricorda tra un’esecuzione e l’altra. Tre comandi:

  • add <titolo> <anno> aggiunge un libro, non ancora letto;
  • list elenca i libri numerati, con [x] davanti a quelli letti, oppure dice che lo scaffale è vuoto;
  • read <numero> segna come letto il libro con quel numero.

Ogni errore, un anno che non è un numero, un libro che non esiste, un file rovinato, va spiegato a parole, senza far schiantare il programma.

Esercizio · sul tuo computer

Lo scaffale

Nel progetto shelf, organizza il codice in tre moduli:

src/shelf/book.gleam: il tipo Book(title: String, year: Int, read: Bool), con new(title, year), mark_read, to_string (come nella lezione precedente), e in più to_json e decoder().

src/shelf/storage.gleam: load() -> Result(List(Book), StorageError) e save(books) -> Result(Nil, StorageError), sul file shelf.json. Il tipo StorageError distingue un file che non si legge, un file che non si scrive, e un file che non contiene uno scaffale valido. Se il file non esiste ancora, load restituisce uno scaffale vuoto: non è un errore. Aggiungi describe_error, per spiegare ogni errore a parole.

src/shelf.gleam: legge gli argomenti con argv, esegue il comando, e stampa il risultato o l’errore. Con argomenti sconosciuti stampa le istruzioni per l’uso. Un consiglio: fai restituire a ogni comando un Result(String, CommandError), con un tuo tipo di errore, e concatena i passi con use e result.try.

Poi crea l’eseguibile con gleam export escript, e prova:

terminale
./shelf add Dune 1965
./shelf add Emma 1815
./shelf add Ulysses nineteen
./shelf read 2
./shelf read 7
./shelf list
output
Added [ ] Dune (1965)
Added [ ] Emma (1815)
Error: the year must be a number
Marked as read: [x] Emma (1815)
Error: there is no book number 7
1. [ ] Dune (1965)
2. [x] Emma (1815)

Incolla qui l’output dell’ultimo comando, ./shelf list.

Mostra una soluzione (prima prova da solo!)
src/shelf/book.gleam
//// Books on the shelf, and how they are written to and read from JSON.

import gleam/dynamic/decode
import gleam/int
import gleam/json

/// A book, with the year it was published and whether it has been read.
pub type Book {
  Book(title: String, year: Int, read: Bool)
}

/// A new book, not read yet.
pub fn new(title: String, year: Int) -> Book {
  Book(title:, year:, read: False)
}

/// The same book, marked as read.
pub fn mark_read(book: Book) -> Book {
  Book(..book, read: True)
}

/// A one-line description, like `[x] Dune (1965)`.
pub fn to_string(book: Book) -> String {
  let mark = case book.read {
    True -> "[x] "
    False -> "[ ] "
  }
  mark <> book.title <> " (" <> int.to_string(book.year) <> ")"
}

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

pub fn decoder() -> decode.Decoder(Book) {
  use title <- decode.field("title", decode.string)
  use year <- decode.field("year", decode.int)
  use read <- decode.field("read", decode.bool)
  decode.success(Book(title:, year:, read:))
}
src/shelf/storage.gleam
//// Saving the shelf to a JSON file, and loading it back.

import gleam/dynamic/decode
import gleam/json
import gleam/result
import shelf/book.{type Book}
import simplifile

const path = "shelf.json"

pub type StorageError {
  CannotRead(simplifile.FileError)
  CannotWrite(simplifile.FileError)
  Corrupted
}

/// All the books on the shelf. A shelf that was never saved is empty.
pub fn load() -> Result(List(Book), StorageError) {
  case simplifile.read(path) {
    Ok(text) ->
      json.parse(text, decode.list(book.decoder()))
      |> result.replace_error(Corrupted)
    Error(simplifile.Enoent) -> Ok([])
    Error(error) -> Error(CannotRead(error))
  }
}

/// Replaces the saved shelf with these books.
pub fn save(books: List(Book)) -> Result(Nil, StorageError) {
  let text = json.array(books, book.to_json) |> json.to_string
  simplifile.write(to: path, contents: text)
  |> result.map_error(CannotWrite)
}

pub fn describe_error(error: StorageError) -> String {
  case error {
    CannotRead(error) ->
      "cannot read " <> path <> ": " <> simplifile.describe_error(error)
    CannotWrite(error) ->
      "cannot write " <> path <> ": " <> simplifile.describe_error(error)
    Corrupted -> path <> " is not a valid shelf"
  }
}
src/shelf.gleam
import argv
import gleam/bool
import gleam/int
import gleam/io
import gleam/list
import gleam/result
import gleam/string
import shelf/book
import shelf/storage.{type StorageError}

pub type CommandError {
  InvalidYear
  InvalidNumber
  NoSuchBook(number: Int)
  StorageFailed(StorageError)
}

const usage = "Usage:
  shelf add <title> <year>
  shelf list
  shelf read <number>"

pub fn main() -> Nil {
  let result = case argv.load().arguments {
    ["add", title, year] -> add(title, year)
    ["list"] -> list_books()
    ["read", number] -> mark_read(number)
    _ -> Ok(usage)
  }
  case result {
    Ok(message) -> io.println(message)
    Error(error) -> io.println("Error: " <> explain(error))
  }
}

fn add(title: String, year: String) -> Result(String, CommandError) {
  use year <- result.try(int.parse(year) |> result.replace_error(InvalidYear))
  use books <- result.try(load())
  let new_book = book.new(title, year)
  use Nil <- result.try(save(list.append(books, [new_book])))
  Ok("Added " <> book.to_string(new_book))
}

fn list_books() -> Result(String, CommandError) {
  use books <- result.try(load())
  case books {
    [] -> Ok("The shelf is empty")
    _ ->
      books
      |> list.index_map(fn(item, index) {
        int.to_string(index + 1) <> ". " <> book.to_string(item)
      })
      |> string.join(with: "\n")
      |> Ok
  }
}

fn mark_read(number: String) -> Result(String, CommandError) {
  use number <- result.try(
    int.parse(number) |> result.replace_error(InvalidNumber),
  )
  use <- bool.guard(when: number < 1, return: Error(NoSuchBook(number)))
  use books <- result.try(load())
  use target <- result.try(
    books
    |> list.drop(number - 1)
    |> list.first
    |> result.replace_error(NoSuchBook(number)),
  )
  let updated =
    list.index_map(books, fn(item, index) {
      case index + 1 == number {
        True -> book.mark_read(item)
        False -> item
      }
    })
  use Nil <- result.try(save(updated))
  Ok("Marked as read: " <> book.to_string(book.mark_read(target)))
}

fn load() -> Result(List(book.Book), CommandError) {
  storage.load() |> result.map_error(StorageFailed)
}

fn save(books: List(book.Book)) -> Result(Nil, CommandError) {
  storage.save(books) |> result.map_error(StorageFailed)
}

fn explain(error: CommandError) -> String {
  case error {
    InvalidYear -> "the year must be a number"
    InvalidNumber -> "the book number must be a number"
    NoSuchBook(number) -> "there is no book number " <> int.to_string(number)
    StorageFailed(error) -> storage.describe_error(error)
  }
}

Qualche dettaglio da notare:

  • storage sa dove e come si salvano i libri; shelf non sa nemmeno che esiste un file JSON. Se un giorno volessi salvare lo scaffale in un database, cambieresti solo storage.gleam.
  • load tratta Enoent come uno scaffale vuoto: la prima volta il file non c’è, ed è normale. Tutti gli altri errori del file restano errori.
  • Ogni comando è una catena di use ... <- result.try(...): ogni passo che può fallire interrompe la catena con il suo errore, come nel modulo 5. use Nil <- result.try(save(...)) è lo stesso schema per un passo che non produce un valore utile.
  • bool.guard scarta i numeri minori di 1 prima di cercare il libro: list.drop con un numero negativo non toglierebbe niente, e read 0 segnerebbe il primo libro.
  • I libri si aggiungono in fondo con list.append, perché l’ordine dello scaffale è quello di inserimento. Con qualche migliaio di libri va benissimo; con milioni, una lista non sarebbe la struttura giusta.

Cosa succede nel Modulo 9

Con questo modulo hai tutti gli strumenti per scrivere programmi che parlano con il mondo: file, dati, altri linguaggi, la riga di comando. Il passo successivo è il mondo più grande di tutti: il web.

Nel prossimo modulo costruiremo un piccolo server web in Gleam, con i pacchetti mist e wisp: rispondere alle richieste di un browser, restituire pagine e JSON, e vedere come i processi del modulo 7 permettono a un server di servire tanti utenti contemporaneamente.