wandres.dev
CLASES Y PROPIEDADES · estado con contrato

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.

⏱ 17 min

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.

🎯 Al terminar esta lección sabrás
  • Dominar el significado exacto de public, private, protected e internal en cada contexto.
  • Reconocer las dos diferencias de protected y private respecto 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 internal en 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
}
📝
Un constructor tambien lleva 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 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.

⚠️
Public inline no puede ver lo internal

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.

La visibilidad no protege el codigo: protege tu libertad de cambiarlo

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.

⚔️ Reduce tu superficie publica
  1. Declara una función private en el nivel superior de un archivo y comprueba que otro archivo del mismo paquete no la ve.
  2. Escribe una clase con un miembro protected e intenta accederlo desde otra clase del mismo paquete que no herede. Compara con lo que haría Java.
  3. Marca una clase como internal y llámala desde una clase Java del mismo proyecto: comprueba que la clase sí es accesible y que una función internal aparece con el nombre decorado.
  4. Convierte un constructor en private y ofrece una función de fábrica en el companion object que valide y devuelva un tipo de resultado en lugar de lanzar.
  5. 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.