API-DD

Guía práctica

El getter que permite modificar el módulo desde fuera

Un ejemplo en Go de un getter aparentemente inocente: devolver el slice interno permite cambiar el módulo sin usar su API y hace que resultados anteriores muten solos.

Por Lautaro Mei

Una colección ofrece dos mensajes sencillos:

go
type Collection struct {
    items []string
}

func (c *Collection) Add(item string) {
    c.items = append(c.items, item)
}

func (c *Collection) Items() []string {
    return c.items
}

Items parece un getter de solo lectura. El nombre no contiene Set, el consumidor recibe un valor y el campo items sigue siendo privado.

Sin embargo, un slice no contiene los elementos. Contiene una referencia a un array. Al devolver el slice interno, el módulo comparte su estado:

go
collection := &Collection{}
collection.Add("first")

items := collection.Items()
items[0] = "changed from outside"

fmt.Println(collection.Items()[0])
// changed from outside

El consumidor ha modificado Collection sin enviarle ningún mensaje. Add ya no es la única conversación que cambia el contenido. También lo cambia cualquier escritura sobre el resultado de Items.

La API visible dice una cosa; la propiedad de la memoria permite otra.

Dos propietarios para los mismos datos#

El problema no se limita a que un consumidor pueda comportarse mal. El proveedor también puede cambiar un resultado que el consumidor creía suyo.

go
collection := &Collection{}
collection.Add("first")
collection.Add("second")

snapshot := collection.Items()
collection.items[0] = "updated internally"

fmt.Println(snapshot[0])
// updated internally

La variable se llama snapshot, pero no es una fotografía. Es otra ventana al mismo array.

Ahora ninguna de las dos partes puede razonar localmente:

No es un detalle exclusivo de Go. El mismo fallo aparece al devolver mapas, punteros, listas mutables u objetos internos en otros lenguajes. Go lo hace especialmente fácil de pasar por alto porque el slice se copia al retornarlo, pero su array subyacente puede seguir compartido.

Decide qué significa el resultado#

Antes de elegir una solución hay que responder una pregunta de contrato:

¿El consumidor recibe un resultado independiente o acceso temporal al estado vivo del módulo?

Las dos conversaciones pueden ser válidas, pero no son intercambiables.

Si el consumidor necesita una observación estable que puede guardar, comparar o entregar a otra parte, necesita un resultado autónomo. Si realmente necesita observar cambios posteriores, la API debería expresarlo como una suscripción, un iterador con reglas de vida o una vista explícita. Un getter que devuelve []string no comunica ninguna de esas restricciones.

Para esta colección elegimos la primera garantía:

text
Items() devuelve los elementos presentes al comenzar la llamada.
Modificar el resultado no modifica Collection.
Los cambios posteriores de Collection no modifican el resultado entregado.

La implementación más pequeña es copiar:

go
func (c *Collection) Items() []string {
    return append([]string(nil), c.items...)
}

Ahora el consumidor posee el slice devuelto y Collection conserva el suyo.

El test debe comprobar las dos direcciones#

Un test que solo compara el contenido inicial no detecta el problema:

go
func TestItemsReturnsCurrentItems(t *testing.T) {
    collection := &Collection{}
    collection.Add("first")

    got := collection.Items()

    if !slices.Equal(got, []string{"first"}) {
        t.Fatalf("unexpected items: %v", got)
    }
}

Pasa tanto si el resultado es independiente como si comparte el array interno. El contraste aparece cuando una de las partes cambia después de la entrega:

go
func TestItemsReturnsAnIndependentResult(t *testing.T) {
    collection := &Collection{}
    collection.Add("first")

    got := collection.Items()
    got[0] = "changed outside"

    current := collection.Items()
    if current[0] != "first" {
        t.Fatalf("consumer changed collection: %v", current)
    }
}

Y en la otra dirección:

go
func TestPreviousResultDoesNotChangeWithCollection(t *testing.T) {
    collection := &Collection{}
    collection.Add("first")

    previous := collection.Items()
    collection.Rename(0, "renamed internally")

    if previous[0] != "first" {
        t.Fatalf("previous result changed: %v", previous)
    }
}

Los tests no exigen append, copy ni una representación determinada. Una colección persistente o un snapshot inmutable también podrían cumplir el contrato.

Una copia superficial puede seguir compartiendo estado#

Copiar el slice exterior no basta cuando sus elementos contienen referencias mutables:

go
type Item struct {
    Labels []string
}

func (c *Collection) Items() []Item {
    return append([]Item(nil), c.items...)
}

El resultado tiene un slice exterior nuevo, pero cada Item.Labels todavía puede apuntar al mismo array que conserva el módulo. Lo mismo ocurre con mapas y punteros dentro de structs copiados.

La copia debe llegar hasta el nivel en que exista mutabilidad compartida:

go
func cloneItem(item Item) Item {
    return Item{
        Labels: append([]string(nil), item.Labels...),
    }
}

func (c *Collection) Items() []Item {
    result := make([]Item, len(c.items))
    for i, item := range c.items {
        result[i] = cloneItem(item)
    }
    return result
}

No hace falta copiar todos los valores por sistema. Hace falta que la propiedad coincida con la promesa. Un valor inmutable puede compartirse. Una transferencia explícita puede evitar una copia. Un volumen grande puede recorrerse mediante una API propia. Lo peligroso es dejar la decisión implícita.

El fundamento de autonomía no pide aislar todos los valores. Pide que cada módulo y cada resultado conserven un límite que permita entenderlos sin coordinarse por accidente.

Un resultado es autónomo cuando recibirlo también significa saber quién puede cambiarlo.