cat swift/laboratorio-refactor-swift-idiomatico.md

Lezione 21 75 min

Laboratorio: refactor Swift idiomatico

Trasformare codice funzionante ma fragile in Swift idiomatico: tipi, enum, API leggibili, collection pipeline e testabilità.

Questo laboratorio chiude il modulo su Swift idiomatico. Partiamo da codice che funziona, ma è brutto: stringhe magiche, parametri primitivi, nomi generici, logica procedurale e fallimenti poveri.

L’obiettivo è trasformarlo in codice che sembri Swift: chiaro, tipizzato, vicino al dominio.

Codice di partenza

func applyCoupon(type: String, value: Int, total: Int, categories: [String], isFirst: Bool) -> Int {
  if type == "percent" {
    if isFirst == true {
      return total - (total * value / 100)
    }
    return total
  }

  if type == "fixed" {
    if categories.contains("books") {
      return total - value
    }
    return total
  }

  return total
}

Questo codice ha molti problemi:

  • type è una stringa magica;
  • value cambia significato;
  • total è un Int senza dominio;
  • categories usa stringhe;
  • isFirst è poco chiaro;
  • non sappiamo perché un coupon non viene applicato;
  • il risultato può diventare negativo;
  • la funzione fa troppe cose.

Passo 1: nominare il dominio

enum CategoriaProdotto: String, Codable {
  case libri
  case elettronica
  case accessori
}

struct Prezzo: Equatable, Comparable {
  let centesimi: Int

  init?(_ centesimi: Int) {
    guard centesimi >= 0 else {
      return nil
    }

    self.centesimi = centesimi
  }

  static func < (lhs: Prezzo, rhs: Prezzo) -> Bool {
    lhs.centesimi < rhs.centesimi
  }
}

Ora prezzo e categoria non sono più valori primitivi anonimi.

Passo 2: modellare lo sconto

enum Sconto: Equatable {
  case percentuale(Int)
  case importoFisso(Prezzo)
}

Il tipo dello sconto ora determina il significato del valore.

Passo 3: contesto esplicito

struct ContestoCoupon: Equatable {
  let totale: Prezzo
  let categorie: Set<CategoriaProdotto>
  let utenteHaGiaOrdinato: Bool
}

Il contesto raccoglie ciò che serve alla validazione. La funzione non riceve più cinque parametri scollegati.

Passo 4: regole come enum

enum RegolaCoupon: Equatable {
  case primoOrdineSolo
  case categoriaRichiesta(CategoriaProdotto)
}

struct Coupon: Equatable {
  let codice: String
  let sconto: Sconto
  let regole: [RegolaCoupon]
}

Il coupon è ora un valore di dominio.

Passo 5: errori espliciti

enum ErroreCoupon: Error, Equatable {
  case nonPrimoOrdine
  case categoriaMancante(CategoriaProdotto)
}

Non basta restituire il totale invariato: vogliamo sapere perché.

Passo 6: motore leggibile

struct CouponEngine {
  func applica(_ coupon: Coupon, nel contesto: ContestoCoupon) throws -> Prezzo {
    try valida(coupon, nel: contesto)
    return totaleApplicando(coupon.sconto, a: contesto.totale)
  }
}

La chiamata ora legge bene:

let totale = try engine.applica(coupon, nel: contesto)

Passo 7: validazione con pattern matching

extension CouponEngine {
  func valida(_ coupon: Coupon, nel contesto: ContestoCoupon) throws {
    for regola in coupon.regole {
      try valida(regola, nel: contesto)
    }
  }

  func valida(_ regola: RegolaCoupon, nel contesto: ContestoCoupon) throws {
    switch regola {
    case .primoOrdineSolo:
      guard contesto.utenteHaGiaOrdinato == false else {
        throw ErroreCoupon.nonPrimoOrdine
      }

    case .categoriaRichiesta(let categoria):
      guard contesto.categorie.contains(categoria) else {
        throw ErroreCoupon.categoriaMancante(categoria)
      }
    }
  }
}

Lo switch parla il linguaggio del dominio.

Passo 8: calcolo dello sconto

private extension CouponEngine {
  func totaleApplicando(_ sconto: Sconto, a totale: Prezzo) -> Prezzo {
    switch sconto {
    case .percentuale(let valore):
      let riduzione = totale.centesimi * valore / 100
      return Prezzo(max(0, totale.centesimi - riduzione))!

    case .importoFisso(let importo):
      return Prezzo(max(0, totale.centesimi - importo.centesimi))!
    }
  }
}

Il force unwrap è tollerabile qui solo perché max(0, ...) garantisce un valore non negativo. In un refactor ulteriore potremmo aggiungere un initializer interno non fallibile per valori già validati.

Passo 9: usare collection pipeline con giudizio

Se hai più coupon e vuoi trovare quelli applicabili:

let couponApplicabili = couponDisponibili.filter { coupon in
  do {
    try engine.valida(coupon, nel: contesto)
    return true
  } catch {
    return false
  }
}

Qui filter è leggibile. Ma se vuoi conservare gli errori, non usare filter: perderesti informazione.

let risultati = couponDisponibili.map { coupon in
  Result {
    try engine.applica(coupon, nel: contesto)
  }
}

La scelta dipende dall’obiettivo.

Passo 10: confronto finale

Prima:

applyCoupon(type: "percent", value: 10, total: 6000, categories: ["books"], isFirst: true)

Dopo:

let coupon = Coupon(
  codice: "WELCOME10",
  sconto: .percentuale(10),
  regole: [.primoOrdineSolo, .categoriaRichiesta(.libri)]
)

let contesto = ContestoCoupon(
  totale: Prezzo(6000)!,
  categorie: [.libri],
  utenteHaGiaOrdinato: false
)

let totale = try engine.applica(coupon, nel: contesto)

La seconda versione è più lunga, ma molto più chiara e sicura.

Checklist del refactor

Verifica di aver migliorato:

  • stringhe magiche sostituite da enum;
  • primitive sostituite da tipi di dominio dove serve;
  • nomi leggibili;
  • fallimenti espliciti;
  • switch esaustivi;
  • funzioni piccole;
  • comportamento vicino ai tipi;
  • nessun protocollo inutile;
  • pipeline usate solo dove chiariscono.

Esercizio finale

Estendi il refactor:

  • aggiungi .spedizioneGratuita;
  • aggiungi regola .minimoCarrello(Prezzo);
  • crea CodiceCoupon validato;
  • raccogli tutti gli errori invece di fermarti al primo;
  • crea una funzione che ordina i coupon dal più conveniente al meno conveniente.

Poi scrivi una breve retrospettiva: quale parte del codice è diventata più lunga, e perché vale la pena?