1. L'embedding espande la API pubblica
All'inizio embedding sembra una piccola vittoria sul numero di caratteri: meno .Inner., meno "wrap", meno codice. Ma questo risparmio ha un prezzo: il tipo esterno all'improvviso inizia a sembrare come se sapesse molto piu di quanto davvero dovrebbe. Il codice diventa piu difficile da leggere e piu facile da fraintendere, e tutto cio che si legge male di solito diventa una casa accogliente per i bug.
Idea chiave della lezione: embedding non riguarda solo il "modo piu comodo di accedere". Embedding cambia la superficie del tipo: cio che e disponibile all'utente tramite .. E la superficie del tipo e il vostro mini‑contratto: "cosa si puo fare con questo tipo".
Fissiamo il concetto con un piccolo frammento della nostra applicazione didattica "task tracker" (elenco di attivita). Partiamo da un modello semplice:
package main
type Task struct {
ID int
Title string
Done bool
}
Per ora tutto e noioso e sicuro. Il pericolo inizia quando decidiamo: "inseriamo qualcosa di utile".
2. I principali problemi dell'embedding
Fuga di una dipendenza interna
Uno scenario molto comune: scrivete una struttura "servizio/applicazione" e al suo interno vi serve un helper — un generatore di ID, un logger, un validatore, un contatore. A un certo punto viene voglia di non scrivere s.idGen.Next() e fate embedding. Ed ecco che inizia la magia, che in produzione di solito si chiama: "perche questo si puo chiamare?!"
Creiamo un piccolo generatore di ID:
package main
type IDGen struct {
next int
}
func (g *IDGen) Next() int {
g.next++
return g.next
}
Ora facciamo il "progetto" (contenitore di attivita) e inseriamo IDGen per evitare di scrivere roba inutile:
package main
type Project struct {
Name string
IDGen // embedded
}
L'uso sembra piacevole:
package main
import "fmt"
func main() {
p := Project{Name: "Home"}
fmt.Println(p.Next()) // 1
fmt.Println(p.Next()) // 2
}
E ora la domanda che dovrebbe farvi un po' drizzare le orecchie: perche un utente Project dovrebbe avere accesso a Next()? Questa e la meccanica interna di assegnazione degli ID, non una "funzione del progetto".
Con embedding state in pratica dicendo: "Il progetto sa fare Next". E chi legge il codice inizia a pensare che sia un'operazione normale del dominio. Ma questo e un problema di design, non di sintassi.
Espansione della superficie del tipo
L'espansione della superficie del tipo avviene quando il tipo esterno ottiene decine di metodi/campi che non corrispondono alla sua responsabilita. E particolarmente sgradevole quando non state incorporando un piccolo "value object", ma qualcosa di "pesante" con un grande set di metodi.
Mostriamolo sul nostro tipo di elenco di attivita. Creiamo un wrapper su uno slice (sappiamo gia come definire metodi sui "slice type"):
package main
type TaskList []Task
func (tl *TaskList) Add(t Task) {
*tl = append(*tl, t)
}
func (tl TaskList) DoneCount() int {
count := 0
for _, t := range tl {
if t.Done {
count++
}
}
return count
}
Ora il "progetto" contiene le attivita. E possiamo fare embedding:
package main
type Project struct {
Name string
TaskList // embedded
}
Sembra carino: p.Add(...), p.DoneCount(). Ma embedding ha un effetto collaterale: all'esterno "emerge" non solo il comportamento che volevate, ma anche il fatto che conservate le attivita proprio cosi (tramite TaskList), e in piu compare l'accesso diretto al campo p.TaskList.
Da fuori ora si puo fare cosi:
package main
func main() {
p := Project{Name: "Home"}
// Il codice esterno puo sostituire direttamente l'intero elenco.
p.TaskList = TaskList{}
}
A volte va bene. Ma spesso questo rompe i piani futuri: per esempio potreste voler memorizzare le attivita non in uno slice, ma in una struttura con indice per ID, oppure mantenere invarianti ("ID unici", "Title vuoto vietato"). E il codice esterno si e gia abituato a poter manipolare a mano TaskList.
E questo e un esempio classico del costo nascosto di embedding: avete accidentalmente fissato la struttura interna del tipo nel suo comportamento pubblico.
Origine non ovvia di campi e metodi
Quando il codice viene letto da principianti (e non solo da principianti), succede una cosa semplice: la persona vede p.Next() e cerca di capire dove sia definito. Se il metodo e "esterno", tutto semplice: cerchiamo func (Project) Next. Se e "promoted" (promoted) bisogna ricordarsi dell'embedding, trovare il tipo incorporato, poi trovare li il metodo.
Non e la fine del mondo, ma il carico cognitivo cresce. E piu il carico cresce, piu e facile sbagliare. E non e un'estetica teorica: il codice poco leggibile e davvero piu facile da mantenere male.
Dal punto di vista della manutenzione, i metodi promoted spesso si trasformano in "magia invisibile": il codice compila, ma non si spiega da solo.
Per questo una regola semplice per i principianti (e anche per gli sviluppatori adulti stanchi): se l'origine del metodo e importante per capire, meglio rendere l'accesso esplicito.
Confrontate:
p.TaskList.DoneCount()
con:
p.DoneCount()
La seconda e piu corta, ma la prima risponde subito alla domanda "questo riguarda le attivita".
L'embedding nasconde parametri importanti e mescola le responsabilita
A volte embedding viene usato come "contenitore per tutto": "metto dentro la struttura e la lascio vivere li". Ma se dentro si nasconde qualcosa che dovrebbe essere controllato dal codice chiamante, ottenete una API in cui e impossibile gestire il comportamento in modo corretto.
Nella comunita Go esiste un'idea nota: non nascondete cio che semanticamente dovrebbe essere un parametro di chiamata dentro una struttura. Questo rende il comportamento confuso e mescola tempi di vita (lifetime) e aree di responsabilita diverse.
Non entreremo nel dettaglio di quel meccanismo specifico (lo vedremo piu avanti nel corso), ma la lezione di design ci e gia utile: se una dipendenza influenza "come funziona il metodo", molto spesso e meglio passarla esplicitamente (come parametro o tramite un campo controllato), invece di "promuoverla" all'esterno tramite embedding in modo che non sia chiaro da dove arrivi e chi ne sia responsabile.
In termini di un semplice task tracker, questo significa: se il "processo di creazione delle attivita" dipende da un generatore di ID e dalla validazione, questi sono dettagli interni. L'utente dovrebbe vedere il metodo AddTask(title) — non avere la possibilita di girare direttamente Next() sul generatore.
3. Refactoring pratico senza embedding
Ora facciamo la versione "corretta" di Project. Rimuoveremo embedding, nasconderemo le dipendenze e esporremo all'esterno solo cio che appartiene alla responsabilita del progetto.
Iniziamo con una nuova struttura:
package main
type Project struct {
Name string
tasks TaskList // not embedded and not exported
idGen IDGen // internal too
}
Due cose sono diventate subito migliori.
Primo, il codice esterno non vede piu che dentro Project le attivita sono conservate proprio come TaskList. Secondo, il codice esterno non puo "giocare" con il generatore di ID.
Ora aggiungiamo un metodo di dominio "normale": aggiungere un'attivita tramite il titolo.
package main
func (p *Project) AddTask(title string) Task {
t := Task{
ID: p.idGen.Next(),
Title: title,
}
p.tasks.Add(t)
return t
}
L'uso diventa cosi:
package main
import "fmt"
func main() {
var p Project
p.Name = "Home"
t := p.AddTask("Buy milk")
fmt.Println(t.ID, t.Title) // 1 Buy milk
}
Notate l'effetto: l'utente ora non sa e non deve sapere come assegnate gli ID. Sa solo: "aggiungi un'attivita". Questo e un buon API.
4. Come restituire le attivita in modo sicuro
Quando chiudete le parti interne, sorge subito la domanda: "Ok, ma come mostro le attivita?" E qui e molto facile finire di nuovo nella stessa trappola, solo con scarpe diverse.
Se restituite lo slice interno cosi com'e, l'utente potra modificarne gli elementi (e questo e di nuovo una fuga della proprieta). Sappiamo gia dell'aliasing degli slice e del perche "esporre il proprio buffer" puo essere pericoloso. Quindi facciamo un metodo pulito che restituisce una copia.
package main
func (p Project) Tasks() []Task {
out := make([]Task, len(p.tasks))
copy(out, p.tasks)
return out
}
Uso:
package main
import "fmt"
func main() {
p := Project{Name: "Home"}
p.AddTask("Buy milk")
p.AddTask("Clean room")
for _, t := range p.Tasks() {
fmt.Println(t.ID, t.Title)
// 1 Buy milk
// 2 Clean room
}
}
Ora il codice esterno ha ricevuto i dati, ma non il diritto di cambiare direttamente lo stato interno del progetto. Questa e la "superficie del tipo controllata": esponete solo cio che serve e mantenete gli invarianti al vostro interno.
5. Cheat sheet: quando l'embedding fa danni
A volte si desidera una cheat sheet semplice, ma senza trasformare la lezione in una lista lunga tre pagine. Quindi facciamo una tabella compatta, utile da tenere in testa quando la mano tende a incorporare il prossimo tipo.
| Situazione | Cosa di solito succede | Perche e male | Cosa fare invece |
|---|---|---|---|
| Incorporiamo un tipo "di servizio" (generatore, logger, validatore) | I metodi della dipendenza diventano "come se fossero metodi del dominio" | Il significato si nasconde, le responsabilita si mescolano | Campo nominato + metodi wrapper |
| Incorporiamo una struttura con una grande API | Il tipo esterno "sa fare tutto" cio che sa fare la dipendenza | Superficie del tipo gonfia, piu difficile da mantenere | Estrarre solo i metodi stretti che servono davvero |
| Incorporiamo un tipo che vogliamo cambiare in futuro | Gli utenti iniziano a dipendere dai dettagli di implementazione | Le parti interne sono difficili da cambiare senza rompere tutto | Nascondere il campo, esporre una API stabile |
| Embedding rende poco chiara l'origine di un metodo | Il lettore non capisce "da dove arriva" | Il codice e piu difficile da leggere e piu facile da usare male | Accesso esplicito x.inner.M() o metodi espliciti sul tipo esterno |
Questa tabella non e una "legge", ma e un buon filtro: se riconoscete il vostro caso, vale la pena fermarsi e pensare.
6. Mini schema di espansione del tipo
A volte e piu facile vedere una volta con gli occhi che leggere dieci volte. Immaginiamo il nostro Project in due varianti come superficie dei metodi.
flowchart TD
A["Project (con embedding)"] --> B["Metodi Project"]
A --> C["Metodi promoted TaskList"]
A --> D["Metodi promoted IDGen"]
E["Project (senza embedding)"] --> F["Metodi Project (AddTask, Tasks, ...)"]
E --> G["TaskList e IDGen nascosti all'interno"]
La variante con embedding quasi sempre offre piu "punti di ingresso". E piu punti di ingresso significano piu modi per usare il tipo in modo sbagliato, anche senza cattive intenzioni.
7. Errori tipici
Errore №1: l'embedding viene fatto "perche cosi e piu corto".
Quando scegliete embedding solo per ridurre x.inner.M() → x.M(), state pagando in leggibilita e controllabilita della API. Tra un mese (o tra sei mesi, quando il progetto iniziara a vivere di vita propria) scoprirete che all'esterno spuntano un sacco di metodi di cui il vostro tipo non dovrebbe proprio occuparsi.
Errore №2: attraverso l'embedding "fuoriesce" una dipendenza che volevate mantenere interna.
Un esempio frequente e un generatore di ID incorporato o un helper "tecnico". Alla fine l'utente del tipo ottiene accesso a metodi come Next(), che non hanno senso nel modello di dominio. Questo trasforma una meccanica interna in una promessa pubblica, e le promesse di questo tipo poi sono dolorose da cambiare.
Errore №3: embedding usato in un tipo esportato senza controllare "cosa diventa disponibile tramite il punto".
Se il tipo e pubblico, la sua superficie e di fatto il vostro contratto con il codice esterno. Embedding puo gonfiare questo contratto per caso. E i contratti casuali, di solito, non sopravvivono alla prima seria revisione dell'architettura.
Errore №4: restituzione dello stato interno all'esterno travestita da comodita.
Quando incorporate TaskList e permettete di accedere direttamente a p.TaskList, semplificate la vita all'utente solo fino a quando non vi serviranno invarianti, validazione o una diversa struttura di archiviazione. Se dovete restituire i dati all'esterno, meglio restituire una copia (tramite make + copy) che condividere il buffer interno.
Errore №5: i metodi promoted peggiorano la leggibilita, ma questo viene ignorato.
Il codice in cui non e chiaro da dove arrivi un metodo si legge peggio e si mantiene con piu rischio di errori — soprattutto nei team e soprattutto per i principianti. In questi casi e meglio scrivere una qualificazione esplicita (p.tasks.DoneCount()) oppure creare un metodo wrapper pulito sul tipo esterno.
GO TO FULL VERSION