Clases abiertas y cerradas para herencia
⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.
r8vnhill/object-oriented-programming-kt
Puedes ejecutar el siguiente comando para crear el módulo
./gradlew setupOpenClosedModule
Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.
import tasks.ModuleSetupTask
tasks.register<ModuleSetupTask>("setupOpenClosedModule") {
group = "setup"
description = "Creates the necessary files for the lesson on open/closed classes."
module = "open-closed"
doLast {
createFiles(
"database",
main to "DatabaseConnection.kt",
main to "EncryptedDatabaseConnection.kt",
)
}
}
Preocúpate de que el plugin open-closed
esté aplicado en el archivo build.gradle.kts
de tu proyecto.
./gradlew setupOpenClosedModule
Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts
.
Cuando diseñamos una biblioteca de software, debemos tomar decisiones conscientes sobre qué partes de nuestro código deberían poder extenderse y cuáles no. Permitir herencia sin restricciones puede facilitar la reutilización, pero también puede volver nuestro código más frágil y difícil de mantener, especialmente si otros proyectos dependen de nuestras clases base.
Kotlin aborda este problema con una decisión de diseño simple pero poderosa: las clases y métodos son cerrados por defecto. Esto significa que no pueden ser heredados ni sobrescritos a menos que lo autoricemos explícitamente usando el modificador open
. Este enfoque ayuda a prevenir errores comunes asociados con la herencia no controlada, promoviendo un diseño más robusto y seguro.
En esta lección, veremos cómo aplicar esta idea en la práctica para proteger la estabilidad de nuestras bibliotecas sin sacrificar la extensibilidad cuando sea necesaria. Exploraremos el problema de la base frágil, aprenderemos a usar open
de forma controlada, y discutiremos los beneficios y limitaciones de esta estrategia en el contexto del diseño de APIs.
🧨 Problema de la base frágil
Cuando desarrollamos una biblioteca de software, es crucial minimizar el riesgo de introducir cambios que afecten negativamente a quienes consumen la API. El problema de la base frágil ocurre cuando una clase base es modificada sin considerar las dependencias de sus subclases, lo que puede provocar errores o romper funcionalidades inesperadamente. Este riesgo es especialmente alto en bibliotecas utilizadas por múltiples proyectos, donde cada actualización debe ser cuidadosamente planificada y documentada.
Joshua Bloch, en su libro Effective Java, ofrece una recomendación clave para el diseño de bibliotecas:
"Diseña y documenta pensando en la herencia, o de lo contrario, prohíbela."
Esta idea sugiere que, al desarrollar una biblioteca, deberías diseñar y documentar explícitamente aquellas clases y métodos que se espera que sean heredados o sobrescritos por lxs usuarixs. Si no puedes garantizar un comportamiento predecible al extenderlos, lo más seguro es prohibir su herencia.
Este enfoque está directamente alineado con el principio Open/Closed, que establece que una clase debe estar abierta para extensión pero cerrada para modificación. Es decir, deberíamos permitir que el comportamiento pueda extenderse sin tener que alterar el código fuente original. Para lograr esto de forma segura, es necesario definir con claridad qué partes de una clase pueden extenderse y cuáles deben permanecer estables.
Al aplicar este principio, reducimos el riesgo de romper contratos implícitos con lxs usuarixs de nuestra biblioteca y favorecemos un diseño más robusto, mantenible y predecible.
💥 Ejemplo de base frágil
Supongamos que una biblioteca escrita en Scala define la siguiente clase:
class Document:
def render(): String = "Rendering base document"
Unx usuarix de la biblioteca decide extenderla:
class Invoice extends Document:
override def render(): String = "Rendering invoice"
Más adelante, lxs autores de la biblioteca actualizan Document
para agregar lógica interna importante:
class Document:
def render(): String =
val header = renderHeader()
val body = renderBody()
s"$header\n$body"
protected def renderHeader(): String = "Header"
protected def renderBody(): String = "Body"
La clase Invoice
continúa sobrescribiendo render()
sin enterarse de los cambios, omitiendo por completo la nueva lógica introducida por la biblioteca. Esto rompe expectativas, puede producir errores sutiles y hace que el comportamiento del sistema sea menos predecible.
🧷 Clases abiertas y cerradas en Kotlin
En Kotlin, las clases son cerradas por defecto, lo que representa una gran ventaja al diseñar bibliotecas. Esto significa que, a menos que marques explícitamente una clase o método como open
, no podrán ser heredados ni sobrescritos. Así, se evita que lxs usuarixs modifiquen su comportamiento sin tu consentimiento, lo cual ayuda a proteger la integridad del código y prevenir el problema de la base frágil.
📘 Ejemplo de uso
Supongamos que estás desarrollando una biblioteca para manejar conexiones a bases de datos. Tienes una clase base DatabaseConnection
que define cómo abrir y cerrar una conexión. Quieres permitir que otras clases especializadas puedan personalizar ciertos aspectos del comportamiento, pero sin comprometer la lógica crítica.
Clase DatabaseConnection
(abierta para herencia controlada)
package com.github.username.database
open class DatabaseConnection(protected val url: String) {
fun startConnection() = println("Connecting to $url")
open fun closeConnection() = println("Closing connection to $url")
}
- Clase abierta: La clase
DatabaseConnection
está marcada comoopen
, lo que permite que otras clases la hereden. - Control explícito sobre qué se puede sobrescribir: Solo el método
closeConnection()
esopen
, permitiendo que las subclases cambien su implementación. El métodostartConnection()
está cerrado, lo que protege la lógica de apertura de conexiones contra modificaciones accidentales o inseguras. - Diseño seguro: Esta estrategia de herencia controlada permite extender funcionalidades sin poner en riesgo el comportamiento central de la clase base, una práctica esencial al diseñar bibliotecas robustas.
Subclase especializada
Ahora, lxs usuarixs de tu biblioteca pueden extender la clase y sobrescribir únicamente el método closeConnection
si necesitan cerrar la conexión de una forma personalizada, mientras se garantiza que el método startConnection
no puede ser modificado:
package com.github.username.database
class EncryptedDatabaseConnection(url: String) : DatabaseConnection(url) {
override fun closeConnection() = println("Closing encrypted connection to $url")
}
- La clase
EncryptedDatabaseConnection
hereda deDatabaseConnection
e invoca el constructor de la clase base pasando el parámetrourl
. - El método
closeConnection
se sobrescribe utilizando la palabra claveoverride
, que en Kotlin es obligatoria para indicar que se está redefiniendo un método de una superclase. - El método
startConnection
, al no estar marcado comoopen
, no puede ser sobrescrito, lo que protege su implementación original. - Este enfoque permite a lxs usuarixs personalizar el comportamiento deseado sin arriesgar la estabilidad de la clase base.
Esto permite que la biblioteca sea flexible sin comprometer su integridad, ya que el comportamiento sensible se mantiene bajo control.
En Kotlin, las clases y funciones abstractas son abiertas por naturaleza, lo que significa que no es necesario marcarlas como open
para que puedan ser heredadas o sobrescritas. Esto tiene sentido, ya que su propósito es justamente servir de base para implementaciones concretas.
Por ejemplo:
abstract class Animal {
abstract fun speak()
}
Aquí, speak()
es automáticamente sobrescribible, y Animal
se puede extender sin necesidad de agregar open
.
🧭 ¿Qué abrir y qué cerrar?
Uno de los desafíos al diseñar bibliotecas es decidir qué partes del código deben estar abiertas a la extensión y cuáles deben mantenerse cerradas para proteger la lógica interna. Kotlin facilita esta tarea al exigir que la herencia sea explícita mediante el modificador open
, pero la decisión sigue siendo responsabilidad de quien diseña la API.
🔐 Mantener cerrado si...
- La lógica es crítica para la seguridad o consistencia interna del sistema.
- El método no tiene un contrato claro de extensión o su modificación puede romper invariantes.
- Se espera que el comportamiento sea estable y predecible en todas las implementaciones.
- El método ya es parte de una secuencia de pasos controlada, como en un patrón Template.
🔓 Abrir si...
- Quieres permitir que lxs usuarixs de la biblioteca personalicen parte del comportamiento sin duplicar código.
- El método es una extensión natural o prevista del flujo lógico del sistema.
- La documentación puede establecer con claridad qué se espera del comportamiento sobrescrito.
- Existen casos de uso múltiples o variables que tu biblioteca no puede cubrir directamente.
Si no puedes documentar fácilmente qué hace una subclase al sobrescribir un método, probablemente ese método debería mantenerse cerrado.
Esta distinción te permite aplicar el principio de mínimo privilegio también en el diseño de clases: expón solo lo necesario, protege lo demás.
✅ Beneficios / ❌ Limitaciones
Beneficios
- Control explícito de la herencia: Las clases cerradas por defecto en Kotlin protegen el comportamiento de la clase base, evitando que cambios internos afecten de forma inesperada a subclases. Esto es especialmente útil en bibliotecas públicas, donde es fundamental mantener la compatibilidad.
- Diseño consciente y predecible: Al requerir el uso explícito de
open
, se obliga a lxs desarrolladorxs a pensar en cómo se espera que una clase sea utilizada y extendida. Esto fomenta la documentación y la previsión, reduciendo ambigüedades en el uso de la API. - Menor riesgo de errores sutiles: Limitar la herencia reduce la posibilidad de que pequeñas modificaciones en una clase base generen errores complejos o difíciles de rastrear en subclases.
- Facilita el mantenimiento: Un diseño con herencia controlada es más fácil de mantener y refactorizar, ya que hay menos puntos de extensión que puedan romper la lógica interna del sistema.
Limitaciones
- Menor flexibilidad en sistemas abiertos: En arquitecturas que requieren una extensibilidad dinámica, como plugins o frameworks altamente configurables, el enfoque cerrado puede entorpecer la personalización.
- Sobrecarga inicial para usuarixs avanzadxs: Quienes deseen extender el comportamiento de la biblioteca podrían encontrar limitaciones que los obliguen a copiar código o proponer cambios en la API.
- Posible necesidad de rediseño: En algunos casos, bloquear la herencia puede llevar a soluciones alternativas más complejas o a una reestructuración para lograr la extensibilidad deseada sin romper el encapsulamiento.
🎯 Conclusiones
Diseñar bibliotecas implica tomar decisiones cuidadosas sobre qué aspectos deben ser extensibles y cuáles deben permanecer protegidos. El problema de la base frágil nos recuerda que permitir herencia sin control puede generar errores sutiles, romper contratos con lxs usuarixs y dificultar el mantenimiento a largo plazo.
Kotlin ofrece una estrategia clara para evitar estos problemas: cerrar las clases por defecto. Este enfoque obliga a que la extensibilidad sea una decisión deliberada, usando el modificador open
solo cuando hay una necesidad real y justificada. Esta práctica no solo protege la integridad del código, sino que también facilita la comprensión, el uso y la evolución de la API.
En esta lección aprendimos a aplicar el principio Open/Closed de forma segura en Kotlin: mantener cerradas las clases por defecto, y abrir únicamente lo que tenga sentido extender. Esta estrategia equilibra la flexibilidad con la estabilidad, lo que es clave al construir bibliotecas reutilizables.
🔑 Puntos clave
- Kotlin cierra clases y métodos por defecto, lo que evita herencias accidentales.
- La herencia debe planearse y documentarse, sobre todo en bibliotecas públicas.
- El modificador
open
permite controlar de forma precisa qué puede extenderse. - El principio Open/Closed nos ayuda a extender el comportamiento sin modificar el código existente.
- La herencia controlada mejora la mantenibilidad y la seguridad de nuestras APIs.
🧰 ¿Qué nos llevamos?
La herencia es una herramienta poderosa, pero también una fuente común de errores si no se usa con cuidado. En esta lección vimos cómo Kotlin promueve un enfoque más seguro al cerrar las clases por defecto, forzándonos a pensar antes de permitir la extensión.
Nos llevamos una estrategia clara:
Cerrar por defecto, abrir con intención.
Este principio no solo protege nuestras bibliotecas de modificaciones inesperadas, sino que también da señales claras a quienes las usan sobre qué partes pueden extenderse y cómo hacerlo de forma segura.
Diseñar para la extensión controlada no es una limitación, sino una invitación a pensar en contratos estables, comportamientos previsibles y APIs que crecen sin romperse. Es una forma de escribir código que cuida tanto a quienes lo consumen como a quienes lo mantienen.
📖 Referencias
🔥 Recomendadas
- 📚 Classes and Interfaces. (2018). En Joshua Bloch, Effective Java (Third edition, pp. 73–116). Addison-Wesley.