Herencia
En esta guía (15)
En esta lección vas a ver cómo una clase puede heredar de otra: reusar sus atributos y métodos, y especializar lo que haga falta. Vas a sobrescribir métodos y campos, preguntar de qué tipo es un objeto en tiempo de ejecución, usar miembros protegidos y clases abstractas, y entender en qué orden se construye un objeto. Al final vas a modelar los tres tipos de cuenta del sistema bancario como subclases de una cuenta base.
Extender una clase
Con extends, una clase nueva (la subclase) hereda todo lo de una clase existente (la superclase) y puede agregar o cambiar comportamiento:
class Figura {
def area: Double = 0.0
}
class Circulo(radio: Double) extends Figura {
override def area: Double = math.Pi * radio * radio
}
val circulo = new Circulo(5.0)
println(f"Área del círculo: ${circulo.area}%.2f") // Área del círculo: 78.54
En Scala una clase puede extender una sola clase. Para combinar comportamientos de varias fuentes están los traits, que vas a ver en la lección 13.
Sobrescribir métodos
Para redefinir un método heredado se usa la palabra override. Es obligatoria cuando el método ya tiene implementación en la superclase: si la olvidás, el compilador da error, y así no hay sobrescrituras accidentales.
class Persona {
def saludar(): Unit = println("Hola, soy una persona.")
}
class Estudiante extends Persona {
def estudiar(): Unit = println("Estoy estudiando.")
override def saludar(): Unit = println("Hola, soy un estudiante.")
}
val persona: Persona = new Estudiante
persona.saludar() // Hola, soy un estudiante.
// persona.estudiar() // no compila: value estudiar is not a member of Persona
val estudiante: Estudiante = new Estudiante
estudiante.saludar() // Hola, soy un estudiante.
estudiante.estudiar() // Estoy estudiando.
Este ejemplo muestra dos ideas clave:
- Polimorfismo.
personaestá declarada comoPersona, pero el objeto real es unEstudiante. Al llamar asaludar(), se ejecuta la versión deEstudiante: el método que corre lo decide el objeto real, en tiempo de ejecución. - El tipo declarado manda en compilación. Aunque el objeto sea un
Estudiante, a través de una variable de tipoPersonasolo podés llamar a lo quePersonadefine. Por esopersona.estudiar()no compila.
Desde la subclase podés llamar a la versión de la superclase con super:
class Persona {
def saludar(): Unit = println("Hola, soy una persona.")
}
class Docente extends Persona {
override def saludar(): Unit = {
super.saludar()
println("Y además doy clases.")
}
}
new Docente().saludar()
// Hola, soy una persona.
// Y además doy clases.
Si querés que un método no se pueda sobrescribir, marcalo como final. Y si querés que una clase no se pueda extender, final class.
Verificar y convertir tipos
A veces necesitás saber de qué tipo es realmente un objeto. La forma idiomática en Scala es match con un patrón de tipo:
val valor: Any = 42
valor match {
case x: Int => println(s"Es un entero: $x")
case s: String => println(s"Es un texto: $s")
case _ => println("Es otra cosa")
}
// Es un entero: 42
También existen los métodos que vienen de Java:
isInstanceOf[T]pregunta si el objeto es de tipoT(o de una subclase deT).asInstanceOf[T]convierte la referencia al tipoT(un cast). Si el objeto no es de ese tipo, lanza unaClassCastException.
class Animal
class Perro extends Animal {
def ladrar(): Unit = println("Guau")
}
val animal: Animal = new Perro
if (animal.isInstanceOf[Perro]) {
val perro = animal.asInstanceOf[Perro]
perro.ladrar() // Guau
}
Funciona, pero hace dos pasos separados y el segundo puede fallar si te equivocás en el primero. Con match, la verificación y la conversión van juntas y es imposible separarlas:
// continúa del bloque anterior
animal match {
case p: Perro => p.ladrar()
case _ => println("No es un perro.")
}
Preferí match; reservá isInstanceOf/asInstanceOf para cuando no haya otra opción.
Campos y métodos protegidos
Un miembro protected es accesible desde la clase y sus subclases, pero no desde afuera:
class Superclase {
protected val campoProtegido: Int = 42
protected def metodoProtegido(): Unit = println("Método protegido")
}
class Subclase extends Superclase {
def accederCampoProtegido(): Int = campoProtegido
def llamarMetodoProtegido(): Unit = metodoProtegido()
}
val s = new Subclase
println(s.accederCampoProtegido()) // 42
s.llamarMetodoProtegido() // Método protegido
// s.campoProtegido // no compila: es protected
Construcción de la superclase
Cuando creás una instancia de una subclase, primero se construye la parte de la superclase. Si la superclase recibe parámetros, la subclase se los pasa en el extends:
class Superclase(parametro: Int) {
def mostrarParametro(): Unit = println(s"Parámetro de la superclase: $parametro")
}
class Subclase(parametro: Int, otro: String) extends Superclase(parametro) {
def mostrarOtro(): Unit = println(s"Otro parámetro: $otro")
}
val instancia = new Subclase(42, "Hola")
instancia.mostrarParametro() // Parámetro de la superclase: 42
instancia.mostrarOtro() // Otro parámetro: Hola
Sobrescribir campos
También se puede sobrescribir un val de la superclase:
class Superclase {
val valor: Int = 42
}
class Subclase extends Superclase {
override val valor: Int = 24
}
val instancia: Superclase = new Subclase
println(instancia.valor) // 24
Igual que con los métodos, gana la versión del objeto real, aunque la variable sea de tipo Superclase.
Subclases anónimas
Podés crear una subclase "en el lugar", sin darle nombre, agregando un cuerpo después de new. Es útil para una implementación puntual que no vas a reusar:
abstract class Animal {
def hacerSonido(): Unit
}
val perro = new Animal {
def hacerSonido(): Unit = println("Guau")
}
val gato = new Animal {
def hacerSonido(): Unit = println("Miau")
}
perro.hacerSonido() // Guau
gato.hacerSonido() // Miau
Clases abstractas
Una clase abstracta (abstract class) funciona como plantilla: declara métodos sin implementarlos, y las subclases están obligadas a implementarlos. No se puede instanciar directamente.
abstract class Figura {
def area: Double // sin cuerpo: es abstracto
def descripcion: String = f"Figura de área $area%.2f" // concreto, usa el abstracto
}
class Circulo(radio: Double) extends Figura {
def area: Double = math.Pi * radio * radio
}
class Rectangulo(base: Double, altura: Double) extends Figura {
def area: Double = base * altura
}
val figuras: List[Figura] = List(new Circulo(1.0), new Rectangulo(2.0, 3.0))
figuras.foreach(f => println(f.descripcion))
// Figura de área 3.14
// Figura de área 6.00
// new Figura // no compila: Figura es abstracta
Para implementar un método abstracto no hace falta override (aunque se puede poner).
Campos abstractos
Una clase abstracta también puede declarar campos sin valor, que las subclases deben definir:
abstract class Figura {
def area: Double
val nombre: String
}
class Circulo(radio: Double) extends Figura {
def area: Double = math.Pi * radio * radio
val nombre: String = "Círculo"
}
println(new Circulo(2.0).nombre) // Círculo
Orden de construcción
Al crear un objeto, el cuerpo de cada clase se ejecuta de arriba hacia abajo, empezando por la superclase. Dentro de una clase, los campos se inicializan en el orden en que están escritos:
class MiClase {
val x: Int = 1
val y: Int = x + 1
}
println(new MiClase().y) // 2
Ese orden tiene una trampa cuando la superclase usa un campo que la subclase sobrescribe:
abstract class Cuenta {
val tipo: String
val etiqueta: String = s"Cuenta de tipo $tipo"
}
class CuentaAhorro extends Cuenta {
val tipo: String = "ahorro"
}
println(new CuentaAhorro().etiqueta) // Cuenta de tipo null
Cuando se ejecuta el cuerpo de Cuenta, la parte de CuentaAhorro todavía no se construyó, así que tipo vale null. Las soluciones habituales son declarar etiqueta como lazy val (se calcula recién cuando se usa) o como def (se calcula cada vez):
abstract class Cuenta {
val tipo: String
lazy val etiqueta: String = s"Cuenta de tipo $tipo"
}
class CuentaAhorro extends Cuenta {
val tipo: String = "ahorro"
}
println(new CuentaAhorro().etiqueta) // Cuenta de tipo ahorro
En Scala 2 existía otra salida, las definiciones tempranas (class CuentaAhorro extends { val tipo = "ahorro" } with Cuenta), que corren antes del constructor de la superclase. Scala 3 las eliminó: si las ves en código viejo, reemplazalas por lazy val o por parámetros del constructor.
La jerarquía de tipos de Scala
Todas las clases de Scala forman parte de una misma jerarquía:
Anyes la raíz: todo valor es unAny.AnyValagrupa los tipos de valor:Int,Double,Boolean,Chary compañía.AnyRefagrupa los tipos de referencia, es decir, todos los objetos (equivale ajava.lang.Object). Tus clases están acá.Nothingestá en la base: es subtipo de todo y no tiene valores. Es el tipo dethrow.Nulles el tipo denully es subtipo de losAnyRef.
val x: Any = 42
val y: AnyRef = "Hola"
val lista: List[Any] = List(1, "dos", 3.0, true)
Igualdad de objetos
En Scala, == compara contenido: llama al método equals. Para comparar si dos referencias apuntan al mismo objeto se usan eq y ne:
val a = "Hola"
val b = "Hola"
val c = new String("Hola")
println(a == b) // true: mismo contenido
println(a == c) // true: mismo contenido
println(a eq b) // true: la JVM reusa el mismo objeto para literales iguales
println(a eq c) // false: new String crea un objeto distinto
Atención con esto si venís de Java: ahí == compara referencias, y en Scala compara contenido.
Con tus propias clases, == compara referencias salvo que sobrescribas equals (y hashCode). Las case classes ya lo hacen por vos:
class PuntoClase(val x: Int, val y: Int)
case class PuntoCase(x: Int, y: Int)
println(new PuntoClase(1, 2) == new PuntoClase(1, 2)) // false: objetos distintos
println(PuntoCase(1, 2) == PuntoCase(1, 2)) // true: mismo contenido
Practicá
Creá una clase base Cuenta con numeroCuenta y saldo, métodos para depositar y retirar, y tres subclases:
CuentaAhorro: hereda deCuentay suma un atributotasaInteres(Double). Su constructor recibenumeroCuenta,saldoytasaInteres, y tiene un método para calcular el interés.CuentaCorriente: hereda deCuentay suma un atributosobregiro(Double), el monto que puede quedar en negativo. Su constructor recibenumeroCuenta,saldoysobregiro, y su forma de retirar tiene que respetar el sobregiro.CuentaInversion: hereda deCuentay suma un atributoplazo(Int, en meses). Su constructor recibenumeroCuenta,saldoyplazo, y tiene un métodocalcularInteresesque calcula los intereses según el plazo y el saldo actual (usá una tasa anual del 5%).
Verificá que los atributos heredados estén disponibles en cada subclase, que cada cuenta se muestre bien con println y escribí una función para transferir dinero entre dos cuentas cualesquiera.
Soluciones
class Cuenta(val numeroCuenta: String, saldoInicial: Double) {
protected var _saldo: Double = saldoInicial
def saldo: Double = _saldo
def depositar(monto: Double): Unit = {
require(monto > 0, "El monto tiene que ser positivo.")
_saldo += monto
}
def retirar(monto: Double): Unit = {
require(monto > 0, "El monto tiene que ser positivo.")
require(monto <= _saldo, s"Saldo insuficiente en $numeroCuenta")
_saldo -= monto
}
override def toString: String = s"Cuenta($numeroCuenta, saldo: $saldo)"
}
class CuentaAhorro(numero: String, saldoInicial: Double, val tasaInteres: Double)
extends Cuenta(numero, saldoInicial) {
def calcularInteres(): Double = _saldo * tasaInteres
override def toString: String = s"CuentaAhorro($numeroCuenta, saldo: $saldo, tasa: ${tasaInteres * 100}%)"
}
class CuentaCorriente(numero: String, saldoInicial: Double, val sobregiro: Double)
extends Cuenta(numero, saldoInicial) {
override def retirar(monto: Double): Unit = {
require(monto > 0, "El monto tiene que ser positivo.")
require(monto <= _saldo + sobregiro, s"No se puede retirar $monto: excede el sobregiro de $sobregiro")
_saldo -= monto
}
override def toString: String = s"CuentaCorriente($numeroCuenta, saldo: $saldo, sobregiro: $sobregiro)"
}
class CuentaInversion(numero: String, saldoInicial: Double, val plazo: Int)
extends Cuenta(numero, saldoInicial) {
private val tasaAnual = 0.05
def calcularIntereses(): Double = _saldo * (plazo / 12.0) * tasaAnual
override def toString: String = s"CuentaInversion($numeroCuenta, saldo: $saldo, plazo: $plazo meses)"
}
def transferir(origen: Cuenta, destino: Cuenta, monto: Double): Unit = {
origen.retirar(monto) // si falla, lanza una excepción y no se deposita nada
destino.depositar(monto)
}
val cuentaInversion = new CuentaInversion("CI2", 1000.0, 12)
println(cuentaInversion)
println(s"Intereses generados: ${cuentaInversion.calcularIntereses()}")
val cuentaCorriente = new CuentaCorriente("CC3", 0.0, 100.0)
cuentaCorriente.retirar(50.0)
println(cuentaCorriente)
val cuentaAhorro = new CuentaAhorro("CA3", 500.0, 0.03)
println(cuentaAhorro)
transferir(cuentaInversion, cuentaAhorro, 200.0)
println(cuentaInversion)
println(cuentaAhorro)
try {
cuentaCorriente.retirar(100.0)
} catch {
case e: IllegalArgumentException => println(s"Error: ${e.getMessage}")
}
CuentaInversion(CI2, saldo: 1000.0, plazo: 12 meses)
Intereses generados: 50.0
CuentaCorriente(CC3, saldo: -50.0, sobregiro: 100.0)
CuentaAhorro(CA3, saldo: 500.0, tasa: 3.0%)
CuentaInversion(CI2, saldo: 800.0, plazo: 12 meses)
CuentaAhorro(CA3, saldo: 700.0, tasa: 3.0%)
Error: requirement failed: No se puede retirar 100.0: excede el sobregiro de 100.0
Algunos puntos del diseño:
- Los parámetros de las subclases se llaman
numeroy nonumeroCuenta. Si se llamaran igual que elval numeroCuentaheredado y los usaras en el cuerpo de la subclase (por ejemplo, entoString), el parámetro taparía al atributo heredado y el compilador lo rechazaría. Con otro nombre, dentro de la subclasenumeroCuentaes siempre el atributo deCuenta. transferirrecibe dosCuenta: gracias al polimorfismo funciona con cualquier combinación de subclases, y cada una aplica su propia regla deretirar.requireagrega el prefijorequirement failed:al mensaje de la excepción.
Resumen
extendshereda de una sola clase;overridees obligatorio para redefinir algo que ya tiene implementación, ysuperllama a la versión de la superclase.- El método que se ejecuta lo decide el objeto real (polimorfismo), pero lo que se puede llamar lo decide el tipo declarado.
- Para verificar tipos, preferí
matchcon patrones de tipo sobreisInstanceOf/asInstanceOf. protecteddeja acceder a las subclases; la superclase se construye primero y recibe sus parámetros en elextends.- Las clases y campos abstractos definen una plantilla que las subclases completan; las subclases anónimas la completan en el lugar.
- Cuidado con usar en la superclase un campo que la subclase define: usá
lazy valodef. Las definiciones tempranas de Scala 2 no existen en Scala 3. ==compara contenido yeqcompara referencias; las case classes traen la igualdad por contenido resuelta.