Control de acceso: seis niveles y dos fronteras
Qué expone exactamente `private`, `fileprivate`, `internal`, `package`, `public` y `open`, por qué hizo falta un nivel nuevo cuando el paquete dejó de ser un solo módulo, qué promete `open` que `public` calla, y cómo `@inlinable`, `@_spi` y los imports con nivel filtran o sellan el límite.
El control de acceso de Swift suele enseñarse como una escala de secretismo, del más escondido al más expuesto, y esa lectura es cómoda y engañosa. Ninguno de estos niveles protege nada: la reflexión, la depuración y @testable los atraviesan sin esfuerzo, y en el intérprete se pueden desactivar por completo con una bandera. Lo que hacen es otra cosa, mucho más valiosa: convierten una decisión de arquitectura —qué parte de este código me comprometo a mantener estable, y ante quién— en una restricción que el compilador comprueba en cada compilación. Cada palabra clave nombra un público, y el público de una declaración determina exactamente cuánta libertad conservas para cambiarla mañana. Por eso el nivel correcto casi nunca es el que hace que el código compile, sino el más estrecho que sigue haciéndolo.
- Enunciar con precisión el ámbito de visibilidad de los seis niveles y sus reglas de propagación.
- Justificar la existencia de
packagea partir de la separación entre unidad de compilación y unidad de distribución. - Distinguir qué añade
opensobrepublicy qué contrato adquiere quien lo escribe. - Detectar las filtraciones del límite:
@inlinable,@usableFromInline,@_spiy los imports con nivel.
Seis niveles y dos fronteras que sí existen
Los niveles forman una cadena, pero solo dos de sus escalones corresponden a fronteras reales del sistema de construcción: la del módulo y la del paquete. El resto son divisiones internas de un mismo módulo.
private // la declaracion que lo contiene, mas extensiones del mismo tipo en el mismo fichero
fileprivate // el fichero entero
internal // el modulo, es decir el target. Es el valor por defecto
package // todos los modulos del mismo paquete
public // cualquier modulo que importe, sin derecho a heredar ni sobrescribir
open // public, y ademas subclasificable y sobrescribible desde fuera
Sobre esa tabla operan tres reglas que explican casi todos los errores del compilador en esta materia. La primera: ninguna declaración puede definirse en términos de otra menos accesible. Una función public cuyo parámetro sea de un tipo internal no compila, y esa negativa es el mecanismo que impide que una superficie pública mienta sobre lo que necesita. La segunda: el nivel efectivo de un miembro es el mínimo entre el suyo propio y el del tipo que lo contiene, de modo que marcar public un método dentro de un tipo internal no lo saca de ninguna parte. La tercera: los miembros de un tipo public son internal por defecto, no public; el nivel no se hereda hacia abajo, se declara uno por uno.
De esa tercera regla sale la trampa más citada de todo el lenguaje:
public struct Coordenada {
public var latitud: Double
public var longitud: Double
// el inicializador por miembros existe, pero es internal
}
// desde otro modulo: Coordenada(latitud: 0, longitud: 0) no compila
No es un descuido del diseño, es exactamente lo que se quiere. El inicializador por miembros cambia de forma cada vez que añades una propiedad almacenada; si fuese público por defecto, cualquier campo nuevo rompería a todos tus clientes. Que sea internal significa que escribir un public init es un acto deliberado por el que aceptas congelar esa lista.
Empieza siempre sin anotar nada. internal por defecto es la elección del lenguaje y es la buena: obliga a que cada ampliación de público sea una decisión escrita. Cuando el compilador te exija subir un nivel, la pregunta útil no es cuál falta, sino si el diseño que te lleva ahí es el que querías.
El nivel que faltaba: package
Durante años, entre internal y public había un abismo y ningún puente. El motivo es histórico y tiene fecha: mientras un paquete fue un módulo, internal y «privado del paquete» coincidían. En cuanto los paquetes empezaron a tener diez o veinte targets —por tiempos de compilación, por capas, por reutilización interna—, apareció una necesidad que ningún nivel cubría: compartir entre módulos del mismo paquete sin prometerle nada al mundo.
El sustituto habitual fue marcar public y añadir un comentario que decía «uso interno, no lo toques». Eso no es una frontera; es una esperanza. El compilador no la comprueba, la documentación generada la ignora y el primer cliente que descubre el símbolo lo usa y ya nunca puedes quitarlo.
// en el modulo Nucleo
package struct EstadoInterno {
package var contador: Int
package init() { contador = 0 }
}
// en el modulo Tejido, mismo paquete: visible
// en cualquier modulo de otro paquete: no existe
La frontera la define el sistema de construcción, no el lenguaje: al compilar, SwiftPM pasa un nombre de paquete a cada módulo, y dos módulos pertenecen al mismo paquete si ese nombre coincide. Eso tiene una consecuencia que conviene tener presente: package no es una propiedad intrínseca del código, es una relación relativa al artefacto que lo agrupa. Mover un target a otro paquete cambia el significado de sus anotaciones sin tocar una línea.
Como los demás niveles, admite forma asimétrica: package private(set) da lectura a todo el paquete y escritura solo al fichero, que es el patrón exacto para un estado compartido con un único dueño.
flowchart TB
subgraph Otro paquete
X[Modulo cliente]
end
subgraph Mi paquete
subgraph Modulo Nucleo
subgraph Fichero
A[private] --> B[fileprivate]
end
B --> C[internal]
end
C --> D[package]
subgraph Modulo Tejido
E[ve lo package y lo public de Nucleo]
end
end
D --> E
D --> F[public y open]
F --> X
style D fill:#f9e2af,color:#11111b
style F fill:#a6e3a1,color:#11111b
style A fill:#89b4fa,color:#11111bLo que public no promete
Con las clases, public y open dividen una capacidad que en otros lenguajes viene siempre junta. Una clase public es visible y utilizable desde fuera, pero no heredable; sus métodos no son sobrescribibles. Solo open concede ese derecho, y solo se aplica a clases y a sus miembros.
La asimetría es intencionada y su fundamento es de diseño, no de permisos. Cuando alguien puede sobrescribir tu método, cada llamada que tu propia clase hace a ese método pasa a formar parte del contrato: si en la versión siguiente dejas de invocarlo internamente, o lo invocas dos veces, o cambias el orden, romperás subclases que jamás has visto. open es la promesa de que el patrón de llamadas internas se mantiene, y esa promesa es mucho más difícil de honrar que la de una firma.
Con protocolos, la propagación funciona al revés de lo que la intuición sugiere: los requisitos de un protocolo adoptan el nivel del protocolo y no admiten anotación propia. Un protocolo public tiene requisitos públicos, y cualquier tipo que lo adopte deberá satisfacerlos con miembros al menos igual de accesibles. De ahí que declarar público un protocolo sea siempre una decisión más grande de lo que parece: no expones una lista de métodos, expones una obligación que cualquiera podrá contraer.
Cuando el límite se filtra
Hay tres mecanismos que atraviesan la frontera del módulo a propósito, y los tres cobran.
@inlinable y @usableFromInline. Marcar una función pública como @inlinable copia su cuerpo al módulo cliente para que el optimizador lo vea. A cambio, todo lo que ese cuerpo mencione debe ser visible desde fuera, lo que obliga a marcar @usableFromInline los símbolos internos que use. El resultado es que la implementación deja de ser tuya: cambiarla ya no basta con recompilar tu módulo, porque hay clientes que llevan dentro la versión antigua. Es una herramienta de rendimiento con un coste de evolución que casi nadie contabiliza.
@usableFromInline internal func normalizar(_ x: Double) -> Double { x / 100 }
@inlinable public func porcentaje(_ x: Double) -> Double {
normalizar(x) // el cuerpo viaja al cliente, y este simbolo con el
}
@_spi. Permite marcar símbolos públicos con un grupo y hacerlos visibles solo a quien escriba @_spi(Nombre) import Modulo. El guion bajo advierte de lo que es: una característica no oficial, sin garantías de estabilidad. Para compartir dentro del propio paquete ha quedado desplazada por package; sigue teniendo sitio cuando la superficie debe cruzar paquetes distintos —una API para tests de integración externos, o para un consumidor concreto— sin figurar en la pública.
Los imports con nivel de acceso. Un import puede llevar su propio nivel: internal import Cimientos declara que esa dependencia no aparece en tu superficie pública, y el compilador rechaza cualquier firma pública que mencione uno de sus tipos. Es la herramienta que convierte en error comprobable lo que antes era una convención frágil, y bajo la característica que la hace predeterminada, la exposición de una dependencia pasa a ser un acto explícito.
internal import Cimientos // detalle de implementacion, no se filtra
public import Registro // parte declarada de mi contrato
Queda @testable import, que eleva lo internal a visible para los tests. No alcanza a private ni a fileprivate, exige que el módulo se haya compilado con soporte de comprobabilidad y desactiva ciertas optimizaciones. Para probar símbolos package desde un test del mismo paquete no hace ninguna falta: un import normal ya los ve.
Público, no secreto
Cada nivel nombra a quién le prometes estabilidad. No hay protección frente a nadie; hay compromiso frente a alguien.
Relativo al artefacto
package depende de cómo agrupes los targets. Mover un módulo de paquete cambia el significado de sus anotaciones sin tocar el código.
El cuerpo también se promete
Con @inlinable el cliente se lleva tu implementación. A partir de ahí, recompilar tu módulo ya no basta para cambiarla.
Parnas formuló en 1972 la idea que sigue siendo la única guía sólida en esta materia: un sistema no se descompone por pasos del proceso sino por decisiones que cada módulo oculta, y la interfaz debe revelar únicamente lo que es improbable que cambie. Leído así, un nivel de acceso no describe dónde está el código sino cuánta duda te reservas sobre él: internal dice que la decisión es tuya y reversible, public dice que has dejado de dudar y aceptas mantenerla mientras dure la versión mayor. Ese marco explica por qué la aparición de package era inevitable y por qué llegó tarde. Los niveles clásicos de Swift asumían una coincidencia que la práctica destruyó: que la unidad de compilación y la unidad de distribución fueran la misma cosa. Mientras un paquete equivalía a un módulo, internal significaba a la vez «privado de mi compilación» y «privado de mi artefacto», y nadie notaba que eran dos afirmaciones distintas. En cuanto un paquete pasó a tener veinte targets, el hueco se volvió estructural, y todo hueco estructural en un sistema de tipos se rellena con la aproximación más cercana disponible, que era public más disciplina humana; es decir, con una frontera que existe en la cabeza de quien la escribió y en ningún otro sitio. La lección generalizable va bastante más allá de Swift: cualquier desajuste entre la granularidad de tus niveles de acceso y la granularidad de tus artefactos se paga en públicos falsos, y los públicos falsos no son ruido, son deuda que devenga intereses, porque la ley de Hyrum garantiza que alguien acabará dependiendo de ellos y entonces ya no habrá diferencia práctica entre lo que prometiste y lo que solo dejaste asomar. Kotlin lo resolvió con internal sobre módulos Gradle, Rust con pub(crate), Java tardó hasta los módulos de la versión nueve y arrastró décadas de paquetes públicos por accidente; la coincidencia de las tres soluciones no es imitación, es que el problema es el mismo en cuanto un sistema separa compilar de publicar. Y de ahí sale el criterio práctico que sobrevive a cualquier lenguaje: antes de anotar una declaración, pregunta a quién estás dispuesto a deberle una migración cuando la cambies. Si la respuesta es «a nadie fuera de este repositorio», entonces public no es una elección técnica sino una renuncia silenciosa a tu propio margen de maniobra.
- Coge un módulo tuyo y baja todo lo
publicainternal; sube solo lo que el compilador exija y cuenta cuántos símbolos sobraban. - Parte ese módulo en dos targets del mismo paquete y observa qué símbolos necesitan
packagey cuáles pueden seguir siendointernal. - Declara una
public structsin inicializador propio y comprueba desde otro paquete el error exacto; añade después elpublic init. - Marca una función
@inlinable, comprueba qué símbolos internos te obliga a anotar y razona qué has dejado de poder cambiar. - Añade
internal importa una dependencia y localiza todas las firmas públicas que la estaban filtrando sin que nadie lo supiera.