wandres.dev
NIVEL DIOS: SÍNTESIS · el lenguaje completo

Decisiones de diseño en código real: leer una API de la stdlib

Una firma de la biblioteca estándar no es una descripción de lo que hace una función: es un contrato sobre coste, propiedad, evolución y ABI, comprimido en una línea. Aprender a descomprimirla, con dos casos reales diseccionados hasta el hueso.

⏱ 24 min

Cuando lees func map<T>(_ transform: (Element) throws -> T) rethrows -> [T] probablemente entiendes qué hace. Eso es leer la firma como documentación. Un nivel más arriba, la misma línea dice otras cosas: que transform no escapa, que no se copia el array de entrada, que si el cierre lanza el error se propaga sin envolverse, y que el resultado reserva capacidad de antemano. Un nivel más arriba todavía, dice quién puede cambiarla en el futuro sin romper binarios compilados hace tres años. La biblioteca estándar de Swift es el corpus de diseño de API más denso al que tienes acceso libre, y está escrita por gente que tenía que responder ante un compilador, ante una ABI estable y ante millones de líneas de código ajeno. Esta lección enseña a leerla en esos tres niveles, con dos casos que parecen triviales y no lo son.

🎯 Al terminar esta lección sabrás
  • Aplicar un protocolo sistemático de lectura de firmas: nombre, genéricos, propiedad, escape, efectos, coste y atributos.
  • Diseccionar el subíndice con valor por defecto de Dictionary y explicar cada una de sus cuatro decisiones.
  • Explicar por qué String usa índices opacos y no es RandomAccessCollection, y qué se ganó a cambio.
  • Reconocer los atributos de resiliencia y rendimiento que condicionan el diseño de cualquier API pública estable.

Cómo se lee una firma

Una firma se lee en siete pasadas y en este orden. Primero el nombre, que en Swift codifica gramática: un verbo imperativo muta (sort), su participio devuelve (sorted); un sustantivo es una propiedad o una vista sin coste (indices, lazy); una preposición dentro de la etiqueta señala el papel del argumento (removeAll(where:) frente a remove(at:)). Segundo, los parámetros genéricos y sus restricciones, que dicen qué se abstrae y qué se exige; una restricción where Element: Equatable es una decisión sobre quién puede usar la API, no un detalle. Tercero, la propiedad de los parámetros: inout, borrowing, consuming, o nada, que en Swift significa préstamo garantizado para la mayoría de los casos.

Cuarto, el escape de los cierres: sin @escaping el cierre muere en la llamada y el compilador puede asignarlo en la pila. Quinto, los efectos: throws, rethrows, async, y su combinación; rethrows es una declaración de transparencia, la función no inventa errores. Sexto, el tipo de retorno, donde some frente a any frente a un tipo concreto es una decisión sobre si el llamante puede depender de la representación. Y séptimo, los atributos: @inlinable, @frozen, @discardableResult, @_disfavoredOverload, cada uno con consecuencias que sobreviven a la versión del compilador.

💡
La pregunta que desbloquea todo

Ante cada elemento de la firma, pregunta: qué habría pasado si hubieran elegido lo contrario. Si no encuentras una consecuencia concreta, todavía no has entendido la decisión. Casi siempre la hay, y casi siempre es de rendimiento o de evolución.

Caso uno: el subíndice con valor por defecto

La API es esta, y es un ejemplo casi perfecto de densidad de decisión.

extension Dictionary {
    @inlinable
    public subscript(
        key: Key,
        default defaultValue: @autoclosure () -> Value
    ) -> Value {
        @inline(__always) get { ... }
        _modify { ... }
    }
}

Se usa así, y el uso revela para qué existe.

var conteo: [String: Int] = [:]
for palabra in palabras {
    conteo[palabra, default: 0] += 1
}

Hay cuatro decisiones y todas se pueden defender por separado. La primera, @autoclosure: el valor por defecto se escribe como una expresión pero se recibe como una función, de modo que solo se evalúa si la clave falta. Si el valor por defecto fuese crearBufferDeDiezMegas(), la versión sin @autoclosure lo construiría en cada iteración aunque la clave existiera siempre. La segunda, que el subíndice devuelva Value y no Value?: elimina el desenvuelto en el punto de uso, que es todo el objetivo, y convierte el patrón de tres líneas en uno.

La tercera es la importante, y es _modify. Un subíndice mutable normal se compone de get y set, y conteo[palabra] += 1 significa literalmente leer el valor, sumarle uno y volver a escribirlo: dos búsquedas con hash, y si Value fuese un array, una copia completa del array intermedio. _modify es un accesor de corrutina: en lugar de devolver una copia, se detiene en medio y cede al llamante una referencia directa al almacenamiento dentro de la tabla, deja que la mutación ocurra en el sitio y luego retoma el control para restablecer invariantes. El resultado es una sola búsqueda y cero copias, y es la razón por la que el patrón de conteo con diccionarios en Swift es lineal en vez de cuadrático cuando los valores son colecciones.

La cuarta decisión es @inlinable, y pertenece a otro mundo: sin ella, el cuerpo vive en el binario de la biblioteca y no puede especializarse en el binario del cliente, lo que en una función de dos instrucciones significa pagar una llamada por cada elemento.

Compara ahora con la versión que casi todo el mundo escribe antes de conocer la API y verás las cuatro decisiones en negativo.

// Version ingenua: dos busquedas y, si Value es una coleccion, dos copias
if var lista = indice[clave] {
    lista.append(x)
    indice[clave] = lista
} else {
    indice[clave] = [x]
}

// Version de la stdlib: una busqueda, cero copias
indice[clave, default: []].append(x)

La segunda no es más corta: es asintóticamente distinta cuando los valores son colecciones grandes, porque la primera copia el array entero en cada inserción al sacarlo del diccionario. Ese es el patrón general de la biblioteca estándar: la forma legible y la forma rápida se hacen coincidir a propósito, para que el programador no tenga que elegir entre las dos.

sequenceDiagram
participant C as Codigo cliente
participant S as Subindice modify
participant T as Tabla hash
C->>S: conteo de palabra mas igual uno
S->>T: buscar o insertar una sola vez
T-->>S: direccion del almacenamiento
S-->>C: yield de la referencia
C->>C: suma en el sitio
C-->>S: fin del acceso
S->>T: restablecer invariantes

Caso dos: por qué los índices de String son opacos

String es Collection pero no RandomAccessCollection, y su Index es un tipo opaco que no puedes construir con un entero. Es la decisión más criticada de la biblioteca y también la más defendible.

let s = "café"
// s[2]                      // no compila: el indice no es Int
let i = s.index(s.startIndex, offsetBy: 2)
print(s[i])                  // "f"
print(s.count)               // 4, aunque en UTF-8 ocupa 5 bytes

El motivo es que el elemento de String es Character, y un Character es un grafema extendido de Unicode: una unidad percibida por el lector, que puede ocupar uno o veinte puntos de código. La letra con tilde puede venir precompuesta en un punto o descompuesta en dos; una bandera son dos indicadores regionales; una familia con tonos de piel puede ser una docena de escalares unidos por selectores de anchura cero. Si String ofreciera indexación por enteros en tiempo constante, tendría que elegir: o indexa por unidades de codificación, y entonces s[1] puede caer en mitad de un carácter y devolver basura, o mantiene un índice de grafemas precalculado, y entonces cada String paga memoria y tiempo de construcción proporcionales a su longitud.

La decisión fue no mentir. El índice opaco es un desplazamiento en bytes con metadatos de validación, count es una operación lineal y está documentada como tal, y el compilador te obliga a pedir un índice explícitamente para que el coste de recorrer sea visible en el código. La consecuencia práctica es un principio de diseño general: cuando una operación no puede ser barata, la API debe hacerla parecer cara. s.count dentro de un bucle es un error que se ve al leerlo; s[i] con i entero habría sido un error que no se ve nunca.

La segunda mitad de la decisión es igual de instructiva: en vez de imponer una interpretación, String ofrece vistas sobre los mismos bytes, cada una con su propio elemento y su propio índice.

let bandera = "🇪🇸"
bandera.count               // 1  caracteres percibidos
bandera.unicodeScalars.count // 2  indicadores regionales
bandera.utf16.count          // 4  unidades UTF-16
bandera.utf8.count           // 8  bytes

Cuatro respuestas correctas a cuatro preguntas distintas, y ninguna privilegiada por defecto salvo la que corresponde a lo que un lector humano llamaría un carácter. El diseño te obliga a nombrar qué preguntas, y ese acto de nombrar es precisamente el que evita la clase de bug que corta una cadena por la mitad de un emoji.

Caso tres: la jerarquía como tabla de costes

La tercera lectura conviene hacerla no sobre una función sino sobre una jerarquía completa, porque ahí se ve un uso del sistema de tipos que casi ninguna biblioteca explota: codificar complejidad algorítmica en los protocolos.

🔱

Sequence

Una sola pasada garantizada. Consumirla puede destruirla. No promete que iterar dos veces dé lo mismo, y por eso un flujo de red encaja aquí.

📐

Collection

Multipaso no destructivo, índices estables, count alcanzable. Avanzar es constante; saltar puede ser lineal.

↔️

BidirectionalCollection

Añade retroceder en tiempo constante. Es lo que hace posible last, reversed perezoso y suffix eficiente.

RandomAccessCollection

Añade saltar a cualquier distancia en tiempo constante. Es el requisito que convierte una búsqueda binaria en viable.

Lo importante no es la lista sino qué significa una restricción genérica escrita contra ella. Cuando una función declara where C: RandomAccessCollection no está pidiendo una capacidad: está declarando que su propio coste depende de esa garantía, y el compilador rechaza el uso con un tipo que no la tiene. Es una prueba de complejidad delegada al sistema de tipos.

// El algoritmo es correcto para cualquier Collection, pero solo es
// logaritmico si el salto es constante. La restriccion lo hace explicito.
func busquedaBinaria<C: RandomAccessCollection>(
    _ c: C, _ objetivo: C.Element
) -> C.Index? where C.Element: Comparable {
    var bajo = c.startIndex, alto = c.endIndex
    while bajo < alto {
        let medio = c.index(bajo, offsetBy: c.distance(from: bajo, to: alto) / 2)
        if c[medio] == objetivo { return medio }
        if c[medio] < objetivo { bajo = c.index(after: medio) } else { alto = medio }
    }
    return nil
}

Aquí String vuelve a encajar en su sitio: no es RandomAccessCollection precisamente porque index(_:offsetBy:) no puede ser constante sobre grafemas, y por tanto esta función lo rechaza. El sistema de tipos ha impedido escribir un algoritmo que sería silenciosamente cuadrático. Ese es el techo de lo que una firma puede hacer por ti, y la stdlib lo alcanza de forma rutinaria.

Lo que una firma publica promete de verdad: resiliencia, ABI y el precio de acertar a la primera

Hay una capa de la stdlib que no se explica en ningun tutorial y que cambia por completo como se leen sus decisiones: desde Swift 5 la biblioteca estandar de Apple tiene ABI estable, lo que significa que vive en el sistema operativo y que un binario compilado hoy debe seguir funcionando contra la stdlib de dentro de cinco anos. Eso convierte cada declaracion publica en un compromiso casi irreversible, y da sentido a un vocabulario de atributos que de otro modo parece ruido. @frozen sobre un struct o un enum promete que su disposicion en memoria y su lista de casos no volveran a cambiar; a cambio, el cliente puede asignarlo en la pila, hacer switch exhaustivo sin caso default y evitar toda indireccion. Optional esta congelado, y por eso es gratis; muchos tipos de Foundation no lo estan, y por eso pasan por una tabla de metadatos y un patron de acceso indirecto que el compilador genera sin que lo veas. @inlinable publica el cuerpo de una funcion, no solo su firma, y es una promesa mas fuerte todavia: ese cuerpo queda copiado en los binarios de los clientes y ya no puede corregirse con una actualizacion del sistema, de modo que un bug en una funcion @inlinable es un bug permanente en todo lo compilado hasta la fecha; su companero @usableFromInline existe para que ese cuerpo pueda referirse a cosas internas sin hacerlas publicas. El guion bajo inicial de _modify y _read senala justo lo contrario: son accesores de corrutina que funcionan, que la stdlib usa masivamente y que el equipo deliberadamente no ha estabilizado, porque su forma final sigue en diseno y publicarlos congelaria una decision prematura. De ahi sale la leccion transferible, y es dura: en una biblioteca con clientes que no controlas, cada firma tiene dos costes, el de usarla mal y el de no poder cambiarla nunca. Por eso el equipo de Swift discute durante meses el nombre de un argumento, por eso la revision de evolucion exige justificar cada etiqueta, y por eso conviene que tu propio codigo distinga con claridad entre lo que es API y lo que es implementacion: la diferencia no es de estilo, es de cuantos anos vas a cargar con ello.

📝
Lo esencial

Lee toda firma en siete pasadas y pregunta siempre qué habría pasado con la decisión contraria. @autoclosure difiere coste, _modify elimina copias, @inlinable cruza la frontera del binario y @frozen congela la representación. Y cuando una operación no puede ser barata, la API debe hacerla parecer cara.

⚔️ Diseccionar tres APIs hasta el hueso
  1. Aplica el protocolo de siete pasadas a Sequence.reduce(into:_:) y explica por qué existe además de reduce(_:_:). La respuesta está en la palabra inout.
  2. Busca en la stdlib tres declaraciones marcadas @frozen y tres que no lo estén. Formula la hipótesis de por qué en cada grupo y contrástala con los comentarios del código.
  3. Escribe un banco de pruebas que compare conteo[k, default: []] .append(x) frente a la versión con desenvuelto manual sobre cien mil elementos. Explica la diferencia con _modify.
  4. Diseña la firma de una API tuya que hoy devuelve [String] y hazla some Collection en su lugar. Enumera qué pierde el llamante y qué ganas tú.
  5. Elige una función pública de tu proyecto y decide si merece ser @inlinable. Escribe el argumento a favor y el argumento en contra antes de decidir.