Saltar al contenido
K
Kotlin: el lenguajePaso a paso40 min Básico

Herencia, interfaces y composición

Al terminar: Escribir clases con herencia, interfaces y clases abstractas, y elegir composición antes que herencia.

Al terminar esta lección las reglas de puntuación de Marcador dejarán de estar escritas a fuego dentro de una función. Habrá un reglamento que se puede cambiar por otro —«3 puntos por victoria» o «2 puntos por victoria, como antes»— sin tocar la tabla ni el torneo.

Para hacerlo se usan las piezas con las que Kotlin organiza el comportamiento. Temas: override y super en la herencia, interfaces, clases abstractas y herencia frente a composición. Cada uno lleva su ejemplo con otros datos (animales, descuentos de una tienda) antes de llegar a Marcador. En la lección siguiente llegan object, companion object, los objetos anónimos y vararg.

Herencia: una clase que parte de otra

La herencia ya la conoces de Propiedades y visibilidad: una clase padre con open y clases hijas que parten de ella con :. La hija recibe todo lo del padre y solo añade o cambia lo suyo, así que lo común se escribe una sola vez. Aquí falta lo que más se usa con ella: cambiar una función del padre.

Dos palabras de apoyo. Estado son los datos que guarda un objeto, es decir, sus propiedades con valor (el nombre de un animal). Y sobrescribir es cambiar en la hija una función que ya trae el padre. (Una instancia es, como en Clases y objetos, cada objeto creado desde una clase: Perro("Rex") es una instancia de Perro.)

Para sobrescribir, Kotlin pide dos cosas más, para que sea siempre una decisión voluntaria: la función del padre que una hija pueda cambiar también es open, y la hija escribe override al cambiarla. Dentro de la hija, super es la versión del padre.

ejemplo mínimo: herencia con Animal y Perro (con comentarios)
// Clase padre: lo común a todos los animales.
// `open` permite que otras clases hereden de ella.
open class Animal(val nombre: String) {
    // `open` también en cada función que una hija pueda cambiar
    open fun sonido(): String = "ruido"
}
 
// Perro hereda de Animal (como ya viste): los dos puntos y la llamada al constructor del padre.
// `override` reemplaza la función del padre por la suya.
class Perro(nombre: String) : Animal(nombre) {
    override fun sonido(): String = "Guau"
}
 
// `super` es la versión del padre: sirve para añadir algo a lo heredado
class Cachorro(nombre: String) : Animal(nombre) {
    override fun sonido(): String = super.sonido() + " agudo"
}
 
fun main() {
    val rex = Perro("Rex")
    println(rex.nombre)                    // `nombre` lo hereda de Animal: no se repite
    println(rex.sonido())                  // la versión de Perro
    println(Cachorro("Toby").sonido())     // la del padre, más algo propio
}

Al ejecutarlo, imprime:

Rex
Guau
ruido agudo

Interfaz: el contrato

La herencia tiene un límite: una clase solo puede tener un padre. Pero a veces hace falta que clases sin relación entre sí cumplan lo mismo. En Marcador, la tabla de posiciones tiene que calcular puntos con reglas distintas («3 por victoria», «2 por victoria») sin saber cuál le tocará. Lo único que necesita es algo que sepa decir «cuántos puntos vale este marcador».

Una interfaz es ese contrato: dice qué se puede hacer, sin decir cómo. Quien la implemente rellena lo que falta, y una clase puede implementar todas las que quiera. Una interfaz puede traer también funciones con cuerpo, que sirven de comportamiento por defecto, y propiedades con valor por defecto (con get(), que calcula el valor cada vez que se lee).

Un detalle importante: super también sirve con interfaces. super.aplicar(precio) llama a la versión por defecto de la interfaz, por si quieres reutilizarla y añadirle algo.

ejemplo mínimo: una interfaz con valores por defecto (con comentarios)
// Interfaz: un contrato. Dice QUÉ se puede hacer, no CÓMO.
interface Descuento {
    // Propiedad sin valor: quien implemente la interfaz tiene que darlo.
    // La interfaz pide que exista, pero no guarda su valor: lo pone quien la implementa.
    val porcentaje: Int
 
    // Propiedad con valor por defecto: se calcula con get() y no hace falta repetirla
    val etiqueta: String
        get() = "descuento"
 
    // Función con cuerpo: es el comportamiento por defecto, se hereda sin escribirlo
    fun aplicar(precio: Int): Int = precio - precio * porcentaje / 100
}
 
// Implementa la interfaz con los dos puntos y da el valor que faltaba.
// No cambia nada más: hereda `etiqueta` y `aplicar`.
class Rebajas : Descuento {
    override val porcentaje: Int = 20
}
 
class Liquidacion : Descuento {
    override val porcentaje: Int = 50
    override val etiqueta: String = "liquidación"     // cambia el valor por defecto
    // super.aplicar(...) es la versión de la interfaz; aquí se le resta 1 de más
    override fun aplicar(precio: Int): Int = super.aplicar(precio) - 1
}
 
fun main() {
    println("${Rebajas().etiqueta}: ${Rebajas().aplicar(200)}")
    println("${Liquidacion().etiqueta}: ${Liquidacion().aplicar(200)}")
}

Al ejecutarlo, imprime:

descuento: 160
liquidación: 99

Clase abstracta: una base que no se crea sola

Una clase abstracta es un punto intermedio: una base pensada solo para heredar de ella. No se puede crear con Clase(...), pero a diferencia de una interfaz sí guarda estado, y puede dejar miembros abstract, sin valor, que cada hija tiene que rellenar.

InterfazClase abstractaClase open
Guarda estado (datos)no: puede pedir que exista una propiedad, pero su valor lo pone quien la implementasísí
Se puede crear con Clase(...)nonosí
Cuántas puede tener una clasevariasuna solauna sola
Miembros por rellenarsísí (abstract)no

Herencia frente a composición

  • Herencia dice «es un»: Perro es un Animal. Es rígida: la hija queda atada al padre y solo se hereda de una clase.
  • Composición dice «tiene un»: el Torneo tiene un Reglamento. Se lo dan de fuera, y para cambiarlo se le da otro.

La regla práctica: si dudas, compón. Con herencia, para tener un torneo con otras reglas tendrías que crear una subclase de Torneo; con composición pasas un objeto distinto.

Las tres ideas juntas, con una tienda que aplica descuentos:

ejemplo mínimo: clase abstracta y composición (con comentarios)
interface Descuento {
    val porcentaje: Int
    fun aplicar(precio: Int): Int = precio - precio * porcentaje / 100
}
 
// Clase abstracta: una base que no se puede crear sola.
// `override val porcentaje` en el constructor es a la vez un parámetro del
// constructor y la propiedad que pide la interfaz: así guarda el dato.
abstract class DescuentoConNombre(override val porcentaje: Int) : Descuento {
    abstract val nombre: String            // sin valor: cada hija lo da
    // `println` convierte un objeto en texto llamando a su `toString`; todas las
    // clases tienen uno, y se sobrescribe para decidir cómo se muestra
    override fun toString(): String = "$nombre (-$porcentaje%)"
}
 
// Clases concretas: rellenan lo que falta (aquí, `nombre`)
class Rebajas : DescuentoConNombre(20) {
    override val nombre: String = "Rebajas"
}
 
class Outlet : DescuentoConNombre(40) {
    override val nombre: String = "Outlet"
}
 
// Composición: la Tienda TIENE un descuento y se lo dan de fuera.
// Para cambiar de descuento no hace falta otra clase de Tienda.
class Tienda(val descuento: Descuento) {
    fun precioFinal(precio: Int): Int = descuento.aplicar(precio)
}
 
fun main() {
    val rebajas = Rebajas()
    println(rebajas)                              // usa el toString de la clase abstracta
    println(Tienda(rebajas).precioFinal(200))     // 200 menos el 20 %
    println(Tienda(Outlet()).precioFinal(200))    // la misma Tienda, con otro descuento
}

Al ejecutarlo, imprime:

Rebajas (-20%)
160
120

Dos detalles. Rebajas no puede ser creada hasta que rellena nombre: el compilador se queja mientras falte un miembro abstract. Y los dos puntos : sirven tanto para heredar de una clase como para implementar una interfaz.

object, que ya conoces

En el paso de Marcador hay un reglamento que se declara con object en lugar de class: object ReglamentoLiga. Ya lo viste en Sealed: estados con datos: un object es un tipo con un único ejemplar, que se usa por su nombre, sin paréntesis: ReglamentoLiga.puntosPor(2, 1). Aquí solo lo usas; la lección siguiente lo ve a fondo.

Reto · Ejercicio: puntuación de baloncesto

En el Kotlin Playground, fuera de tu proyecto, escribe:

  1. Un contrato de puntuación con un valor base por acierto y un total que, por defecto, multiplica los aciertos por la base.
  2. Una clase de triples (base 3) que da 1 punto extra a partir de 5 aciertos, reutilizando el cálculo por defecto.
  3. Otra clase de dobles (base 2) que no cambia nada más.
  4. Una jugada que tiene una puntuación (no hereda de ella) y calcula el valor de unos aciertos.

Con 5 aciertos, imprime el valor de una jugada de triples y el de una de dobles. La salida tiene que ser:

16
10
Ver solución
solución: puntuación por composición
interface Puntuacion {
    val base: Int
    fun total(aciertos: Int): Int = aciertos * base
}
 
class Triples : Puntuacion {
    override val base = 3
    // `override` es obligatorio para cambiar la versión por defecto.
    override fun total(aciertos: Int): Int =
        super.total(aciertos) + if (aciertos >= 5) 1 else 0
}
 
class Dobles : Puntuacion {
    override val base = 2
}
 
class Jugada(val puntuacion: Puntuacion) {
    fun valor(aciertos: Int): Int = puntuacion.total(aciertos)
}
 
fun main() {
    println(Jugada(Triples()).valor(5))   // 16
    println(Jugada(Dobles()).valor(5))    // 10
}

Jugada no hereda de nada: tiene una Puntuacion que se le da de fuera, y por eso funciona igual con Triples que con Dobles. Para cambiar el comportamiento por defecto de total hace falta override: sin esa palabra, el compilador rechaza la función porque coincide con la de la interfaz. Y super.total(aciertos) llama a la versión de la interfaz para reutilizarla y sumarle el bonus.

Paso · El reglamento como interfaz

Objetivo. Crear Reglamento y ReglamentoLiga, y hacer que Torneo y la tabla lo reciban de fuera, y que puntosPor (de Condiciones: if y when) se apoye en él. Al terminar, la tabla sigue dando los mismos puntos de siempre, pero la regla vive en un solo sitio y se puede cambiar.

torneo → kotlin+java
com.sebastiannarvaez.marcador.torneo
Reglamento.ktse crea
Puntos.ktse modifica
Tabla.ktse modifica
Torneo.ktse modifica
Main.ktse modifica
Un fichero nuevo y cuatro modificados, en la vista Android.

En la vista Android, torneo → kotlin+java tiene dos paquetes con el mismo nombre: com.sebastiannarvaez.marcador.torneo guarda el código y com.sebastiannarvaez.marcador.torneo (test) guarda los tests. Reglamento.kt se crea con clic derecho sobre el primero → New → Kotlin Class/File → File, escribe Reglamento y pulsa Intro. Aunque el fichero solo tenga interfaz, clase abstracta y object, se guarda todo junto porque los tres forman una unidad. Los otros cuatro ya existen: ábrelos con doble clic.

puntosPor sigue existiendo como atajo, y su test de Bucles y tu primer test no cambia. Lo único que cambia es que ya no repite los números 3, 1 y 0: los pregunta a ReglamentoLiga. Así hay una sola fuente de la regla.

Dónde va cada cosa:

  1. Reglamento.kt es un fichero nuevo entero.
  2. En Puntos.kt, el bloque nuevo sustituye a la función puntosPor que tenías (comentario incluido); el resto del fichero no cambia.
  3. En Tabla.kt cambian dos líneas sueltas de tablaDePosiciones, y se muestran como «antes / después»; el resto del fichero no cambia.
  4. Torneo.kt cambia en dos sitios (la cabecera y la tabla), así que se muestra entero y sustituye al tuyo.
  5. En Main.kt, pega el bloque nuevo en lugar de la última } de main (la que cierra la función, al final del fichero); el resto del fichero no cambia.

Los bloques llevan como título la ruta del fichero en tu proyecto.

Intenta escribirlo tú. El orden que funciona:

  1. Reglamento, el contrato: cuántos puntos da una victoria y cuántos un empate (cada reglamento lo decide), cuántos da una derrota (0, salvo que alguien diga otra cosa) y puntosPor(golesAFavor, golesEnContra), que ya sabe calcular los puntos con esos tres números.
  2. ReglamentoPorPuntos, una base que no se puede crear sola. Guarda los puntos de victoria y de empate. Declara un nombre sin valor, que cada reglamento concreto tiene que dar. Y al imprimirla sale «Reglamento Liga (3/1/0)».
  3. ReglamentoLiga, el único reglamento de liga, declarado con object: se llama «Liga» y da 3 puntos por victoria y 1 por empate.
  4. Los cambios. puntosPor de Puntos.kt deja de escribir los números y pregunta a ReglamentoLiga. tablaDePosiciones y Torneo reciben un reglamento, que por defecto es el de la liga, y la tabla lo usa para los puntos.
  5. main. Imprime el reglamento de la liga.

Tras las líneas de las lecciones anteriores, la salida debe acabar así:

Partidos en el torneo: 3
Reglamento Liga (3/1/0)

Cuando termines, abre el bloque y compara con tu código.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Reglamento.kt
package com.sebastiannarvaez.marcador.torneo
 
// Una interfaz dice QUÉ se puede hacer, sin decir cómo. Quien la implemente
// rellena lo que falta.
interface Reglamento {
    val puntosPorVictoria: Int
    val puntosPorEmpate: Int
 
    // Propiedad con implementación por defecto: no hace falta sobrescribirla.
    val puntosPorDerrota: Int
        get() = 0
 
    // Método con implementación por defecto, escrito sobre las propiedades.
    fun puntosPor(golesAFavor: Int, golesEnContra: Int): Int = when {
        golesAFavor > golesEnContra -> puntosPorVictoria
        golesAFavor == golesEnContra -> puntosPorEmpate
        else -> puntosPorDerrota
    }
}
 
// Clase abstracta: no se puede instanciar, y sí lleva estado (los puntos) y un
// miembro por rellenar (`nombre`). Una clase solo hereda de UNA clase, pero
// puede implementar muchas interfaces.
abstract class ReglamentoPorPuntos(
    override val puntosPorVictoria: Int,
    override val puntosPorEmpate: Int,
) : Reglamento {
    abstract val nombre: String
 
    override fun toString(): String = "Reglamento $nombre ($puntosPorVictoria/$puntosPorEmpate/$puntosPorDerrota)"
}
 
// `object`: una clase con UNA sola instancia, creada al primer uso (singleton).
// Se usa por su nombre, sin paréntesis: ReglamentoLiga.puntosPor(2, 1)
object ReglamentoLiga : ReglamentoPorPuntos(puntosPorVictoria = 3, puntosPorEmpate = 1) {
    override val nombre: String = "Liga"
}

Lee el reparto de tareas: la interfaz define el contrato y la lógica de puntosPor; la clase abstracta añade el estado (los dos números del constructor) y deja nombre por rellenar; el object lo rellena y fija los valores 3 y 1. puntosPorDerrota vale 0 en todas partes salvo que alguien lo sobrescriba.

Este es el nuevo puntosPor de Puntos.kt, que ahora delega en el reglamento de la liga: el when vive ahora en Reglamento. Sustituye también su comentario.

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Puntos.kt
// Función suelta: escrita directamente en el fichero.
// La regla vive ahora en el reglamento de la liga; esta función se queda
// como atajo y ya no repite los números.
fun puntosPor(golesAFavor: Int, golesEnContra: Int): Int =
    ReglamentoLiga.puntosPor(golesAFavor, golesEnContra)

Los dos cambios de Tabla.kt. Primero, la cabecera de tablaDePosiciones, que gana un parámetro reglamento. Antes:

Tabla.kt, antes (esto es lo que tienes)
fun tablaDePosiciones(partidos: List<Partido>, equipos: List<Equipo> = emptyList()): List<FilaDePosicion> {

Después (la línea de arriba se sustituye por estas cuatro):

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Tabla.kt
fun tablaDePosiciones(
    partidos: List<Partido>,
    equipos: List<Equipo> = emptyList(),
    reglamento: Reglamento = ReglamentoLiga,
): List<FilaDePosicion> {

Segundo, la línea de los puntos, dentro del FilaDePosicion(...). Antes:

Tabla.kt, antes (esto es lo que tienes)
                puntos = marcadores.sumOf { (aFavor, enContra) -> puntosPor(aFavor, enContra) },

Después:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Tabla.kt
                puntos = marcadores.sumOf { (aFavor, enContra) -> reglamento.puntosPor(aFavor, enContra) },

Torneo.kt cambia en dos sitios (la cabecera y la tabla), así que va entero:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Torneo.kt
package com.sebastiannarvaez.marcador.torneo
 
// El torneo es lo único mutable del dominio: va acumulando partidos.
// Por eso cuida qué enseña hacia fuera.
// Por composición: el torneo TIENE un reglamento (se lo dan de fuera) en vez de
// ser un tipo de reglamento. Cambiar de reglas no obliga a cambiar de clase.
class Torneo(
    val nombre: String,
    equipos: List<Equipo>,
    val reglamento: Reglamento = ReglamentoLiga,
) {
 
    // Copia defensiva de lo que entra.
    val equipos: List<Equipo> = equipos.toList()
 
    // `val` NO significa inmutable: la referencia no cambia, pero la lista sí
    // puede cambiar por dentro. Es `private` para que nadie de fuera la toque.
    private val partidosJugados = mutableListOf<Partido>()
 
    // Lo que sale es una COPIA de solo lectura. Si devolviéramos
    // `partidosJugados` directamente, el tipo `List` engañaría: por dentro
    // sigue siendo una MutableList y un `as MutableList` la vaciaría.
    val partidos: List<Partido>
        get() = partidosJugados.toList()
 
    fun agregar(partido: Partido) {
        require(partido.local in equipos && partido.visitante in equipos) {
            "Los dos equipos deben estar inscritos en $nombre"
        }
        partidosJugados.add(partido)
    }
 
    fun tablaDePosiciones(): List<FilaDePosicion> = tablaDePosiciones(partidosJugados, equipos, reglamento)
 
    fun goleadores(): List<Pair<Jugador, Int>> = tablaDeGoleadores(partidosJugados)
}

Y la línea nueva de Main.kt, en lugar de la última } de main:

torneo/src/main/java/com/sebastiannarvaez/marcador/torneo/Main.kt
    // Interfaces y objetos: el reglamento se puede cambiar por otro.
    println(ReglamentoLiga)
}

Punto de control

  • En la Terminal de Android Studio, ./gradlew :torneo:run termina en BUILD SUCCESSFUL y las últimas líneas son:
Partidos en el torneo: 3
Reglamento Liga (3/1/0)
  • La tabla de posiciones de antes no cambia: con ReglamentoLiga por defecto, Rayo FC sigue con 6 puntos.
  • ./gradlew :torneo:test pasa: siguen siendo 48 tests en total, y el test de puntosPor de Bucles y tu primer test no ha cambiado.
  • Puntos.kt ya no contiene los números 3, 1 y 0.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.¿Qué diferencia principal hay entre una interfaz y una clase abstracta en Kotlin?

  2. 2.El Torneo recibe un Reglamento por su constructor en lugar de heredar de una clase de reglamento. ¿Cómo se llama esa decisión y qué ventaja da?

  3. 3.Escribes class Reglamento2 : ReglamentoPorPuntos(2, 1) y ReglamentoPorPuntos es abstract. ¿Qué te pide el compilador antes de poder crear objetos de Reglamento2?

  4. 4.Una interfaz Descuento trae fun aplicar(precio: Int) con cuerpo. En una clase que la implementa, quieres reutilizar ese cálculo y restarle 1. ¿Cómo lo haces?

Faltan 4 de 4