Visibilidad: public, private, protected e internal
Los cuatro modificadores de Kotlin, la diferencia de protected frente a Java, que es exactamente un modulo para internal, y como el compilador representa internal en el bytecode mediante mangling de nombres porque la JVM no tiene ese concepto.
La visibilidad es el único mecanismo del lenguaje que sirve para decir no. Todo lo demás que escribes en una clase añade capacidades; un modificador de visibilidad las quita, y por eso es la herramienta más subestimada del diseño de tipos. Kotlin ofrece cuatro, y solo tres de ellos existen también en Java. El cuarto, internal, no tiene equivalente en la JVM, lo que obliga al compilador a inventarse una representación que conviene conocer, porque explica comportamientos raros en tests, en interoperabilidad con Java y en librerías publicadas.
- Dominar el significado exacto de
public,private,protectedeinternalen cada contexto. - Reconocer las dos diferencias de
protectedyprivaterespecto a Java. - Definir qué es un módulo y por qué esa unidad es la correcta para separar API de implementación.
- Explicar cómo se codifica
internalen el bytecode y qué límites tiene esa codificación.
Los cuatro modificadores
El valor por defecto es public, y esa es ya una decisión: Kotlin no tiene visibilidad de paquete, así que lo que no marcas es API. Los cuatro modificadores significan cosas distintas según dónde los pongas.
En el nivel superior de un archivo —funciones, propiedades, clases, object— solo hay tres opciones. public lo ve todo el mundo; internal lo ve el módulo entero; private lo ve solo ese archivo, no el paquete. protected no está permitido ahí, porque sin clase no hay herencia de la que hablar.
// Fichero: red/Cliente.kt
private const val TIEMPO_MAXIMO = 30 // visible solo en este fichero
internal class Reintentos // visible en todo el modulo
class Cliente // public: API hacia fuera
Dentro de una clase, los cuatro se aplican a miembros. private limita al interior de la clase que lo declara; protected amplía a las subclases; internal deja ver el miembro a cualquiera del módulo que además vea la clase; public no restringe nada.
open class Base {
private val secreto = 1 // solo dentro de Base
protected val heredable = 2 // Base y sus subclases
internal val delModulo = 3 // todo el modulo
val publico = 4 // todos
}
class Derivada : Base() {
fun leer() = heredable // legal
// fun otro() = secreto // error: private no se hereda
}
Dos diferencias con Java importan. La primera: protected en Kotlin no implica visibilidad de paquete. En Java, un miembro protected lo ve cualquier clase del mismo paquete aunque no herede; en Kotlin, solo las subclases. La segunda: no existe el modificador implícito package-private de Java, y su hueco lo ocupa internal, que es una unidad más grande y, curiosamente, más útil.
public
El valor por defecto. Es API: lo ve cualquiera que vea el tipo, y comprometerse con ello significa sostenerlo mientras exista la librería.
internal
Visible en todo el módulo y en sus tests. Es la frontera correcta para una implementación repartida en muchos archivos y paquetes.
protected
Visible en la clase y en sus subclases, nunca en el paquete. Solo tiene sentido junto a open, y hereda todos sus compromisos.
private
En un miembro, solo la clase que lo declara. En el nivel superior, solo el archivo. Es la única visibilidad que no genera deuda.
Hay una regla más que conviene fijar: al sobrescribir, un miembro no puede reducir la visibilidad que heredó, porque eso rompería el contrato del tipo padre. Ampliarla sí está permitido, y a veces es justo lo que quieres para exponer en una implementación concreta algo que la base dejaba solo para descendientes.
open class Base {
protected open fun paso() {}
}
class Publica : Base() {
public override fun paso() {} // ampliar de protected a public: legal
}
class Rota : Base() {
// private override fun paso() {} // error: no se puede reducir la visibilidad
}
Para restringir la construcción hay que escribir la palabra constructor de forma explícita: class Sesion private constructor(val id: String). Con el constructor privado, la única forma de obtener instancias es la que tú publiques —una función en el companion object, una fábrica, un object—, y ahí es donde puedes validar, cachear o devolver un tipo de resultado en lugar de lanzar.
internal: el módulo como frontera real
Un módulo en Kotlin es el conjunto de archivos que se compilan juntos en una misma invocación del compilador. En la práctica: un source set de Gradle, un módulo de IntelliJ, un proyecto de Maven, un objetivo de Bazel. Ojo con el detalle de Gradle: main y test son conjuntos distintos, pero el de test se compila con friend paths hacia el de producción, y por eso las pruebas sí ven las declaraciones internal del módulo que prueban.
flowchart TD A[Modulo app] --> B[Publico: lo ve cualquier consumidor] A --> C[Internal: lo ve solo este modulo y sus tests] A --> D[Private: lo ve solo el fichero o la clase] E[Otro modulo del mismo proyecto] -->|solo alcanza| B F[Tests del modulo app] -->|friend path| C style B fill:#a6e3a1,color:#11111b style C fill:#89b4fa,color:#11111b style D fill:#f9e2af,color:#11111b
Esa granularidad es la que faltaba en Java. Con visibilidad de paquete, dividir la implementación en varios paquetes obligaba a hacerla pública; con internal, puedes repartir una implementación en veinte archivos y diez paquetes sin que ni una sola de esas piezas salga del módulo. La superficie pública deja de estar dictada por la organización de carpetas y pasa a ser una decisión independiente.
De ahí sale la regla operativa para librerías: el módulo es la unidad de encapsulación, no la clase. Marca internal todo lo que no quieras sostener durante años, y deja public únicamente lo que estés dispuesto a mantener compatible. Si además activas el modo explicit API con explicitApi() en Gradle, el compilador exige visibilidad y tipo de retorno explícitos en cada declaración pública, lo que convierte los olvidos en errores.
El compilador vigila además la coherencia: una declaración pública no puede filtrar un tipo menos visible en su firma. Si una función pública devuelve una clase internal, el consumidor externo recibiría un valor de un tipo que no puede ni nombrar, así que el error llega en compilación y no en el momento de usar la librería.
internal class DetalleInterno
class Fachada {
// fun crear(): DetalleInterno = DetalleInterno() // error: expone un tipo internal
internal fun crear(): DetalleInterno = DetalleInterno() // coherente
}
Esa comprobación es más valiosa de lo que parece, porque hace imposible el escenario clásico de una API que parece pequeña pero arrastra medio proyecto en sus tipos de retorno. Si al marcar algo como internal el compilador empieza a protestar en cadena, esa cadena era exactamente tu superficie pública real, y merecía verse.
internal en el bytecode: el mangling de nombres
La JVM no conoce internal. Sus modificadores son public, protected, private y package-private, y ninguno describe “visible en esta unidad de compilación”. El compilador de Kotlin resuelve el desajuste con dos decisiones combinadas.
Primera: una declaración internal se emite como public en el bytecode. No hay otra opción, porque el módulo puede abarcar muchos paquetes y package-private no llegaría. Segunda: para que esa apertura no se convierta en una API accidental, el compilador decora el nombre de funciones y accesores internal añadiendo el nombre del módulo:
// Kotlin, en el modulo llamado app
internal fun calcular(x: Int) = x * 2
// Visto desde Java, en el mismo proyecto
// El metodo NO se llama calcular, sino algo como:
Utilidades.calcular$app(21);
El mangling persigue dos objetivos concretos. Uno, evitar que una clase Java sobrescriba por accidente un miembro internal de una clase Kotlin, ya que las firmas no coinciden. Dos, señalar el uso: si en Java escribes un nombre con $ dentro, sabes perfectamente que estás cruzando una frontera que alguien marcó como interna.
Conviene fijar los límites de la técnica. Las clases internal no se decoran: aparecen como clases públicas normales, así que desde Java se pueden instanciar sin trucos. Y el mangling no es seguridad: la reflexión, un cargador de clases o simplemente escribir el nombre decorado a mano rompen la barrera. internal es una herramienta para el compilador de Kotlin y para tus compañeros, no un control de acceso en tiempo de ejecución.
Una función public inline copia su cuerpo en el sitio de llamada, que puede estar en otro módulo. Por eso el compilador prohíbe que acceda a declaraciones internal o private: el código insertado se rompería al compilar fuera. La salida oficial es @PublishedApi internal, que mantiene la declaración marcada como interna para el compilador de Kotlin pero la deja realmente pública en el bytecode. Es una promesa a medias, y hay que usarla sabiendo que ese símbolo ya forma parte de tu compatibilidad binaria.
Es tentador leer los modificadores como un sistema de seguridad, y es una lectura equivocada que lleva a decisiones equivocadas. private no impide que nadie lea tu campo: la reflexión lo hace en tres líneas, y el mangling de internal se deshace escribiendo el nombre decorado. Lo que la visibilidad protege no es el dato, es tu capacidad futura de cambiar de opinión. Cada declaración pública es un contrato que alguien puede empezar a usar mañana, y una vez usado, cambiarlo se convierte en romper a otros; cada declaración interna o privada es una decisión que sigue siendo tuya para siempre, revisable un martes cualquiera sin avisar a nadie. Por eso la pregunta correcta al marcar visibilidad nunca es quién debería poder ver esto, sino qué estoy dispuesto a sostener durante los próximos cinco años. Una API pequeña y deliberada no es una API pobre: es una API donde queda margen para mejorar la implementación, corregir errores de diseño y reescribir por dentro sin publicar una versión mayor. Una API amplia por descuido —porque public era lo que salía por defecto y nadie lo pensó— es deuda contraída sin haberla negociado, y se cobra el día que descubres que la estructura de datos elegida en la primera semana ya no sirve y no puedes tocarla porque tres equipos dependen de ella. Ahí es donde internal se gana su sitio: te deja repartir una implementación por todos los archivos y paquetes que haga falta, con nombres claros y responsabilidades separadas, sin que ni un gramo de esa organización se filtre al mundo. La consecuencia práctica es que el diseño interno puede ser tan rico como quieras sin que el contrato externo crezca ni un símbolo, y esa independencia entre ambas cosas es exactamente lo que hace mantenible un proyecto grande.
- Declara una función
privateen el nivel superior de un archivo y comprueba que otro archivo del mismo paquete no la ve. - Escribe una clase con un miembro
protectede intenta accederlo desde otra clase del mismo paquete que no herede. Compara con lo que haría Java. - Marca una clase como
internaly llámala desde una clase Java del mismo proyecto: comprueba que la clase sí es accesible y que una funcióninternalaparece con el nombre decorado. - Convierte un constructor en
privatey ofrece una función de fábrica en elcompanion objectque valide y devuelva un tipo de resultado en lugar de lanzar. - Activa
explicitApi()en un módulo tuyo, cuenta cuántos errores aparecen y decide para cada uno si esa declaración debía ser pública de verdad.