trubidata
Lección 9 de 14·20 min

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:

scala
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.

scala
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. persona está declarada como Persona, pero el objeto real es un Estudiante. Al llamar a saludar(), se ejecuta la versión de Estudiante: 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 tipo Persona solo podés llamar a lo que Persona define. Por eso persona.estudiar() no compila.

Desde la subclase podés llamar a la versión de la superclase con super:

scala
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:

scala
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 tipo T (o de una subclase de T).
  • asInstanceOf[T] convierte la referencia al tipo T (un cast). Si el objeto no es de ese tipo, lanza una ClassCastException.
scala
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:

scala
// 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:

scala
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:

scala
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:

scala
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:

scala
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.

scala
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:

scala
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:

scala
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:

scala
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):

scala
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:

  • Any es la raíz: todo valor es un Any.
  • AnyVal agrupa los tipos de valor: Int, Double, Boolean, Char y compañía.
  • AnyRef agrupa los tipos de referencia, es decir, todos los objetos (equivale a java.lang.Object). Tus clases están acá.
  • Nothing está en la base: es subtipo de todo y no tiene valores. Es el tipo de throw.
  • Null es el tipo de null y es subtipo de los AnyRef.
scala
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:

scala
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:

scala
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:

  1. CuentaAhorro: hereda de Cuenta y suma un atributo tasaInteres (Double). Su constructor recibe numeroCuenta, saldo y tasaInteres, y tiene un método para calcular el interés.
  2. CuentaCorriente: hereda de Cuenta y suma un atributo sobregiro (Double), el monto que puede quedar en negativo. Su constructor recibe numeroCuenta, saldo y sobregiro, y su forma de retirar tiene que respetar el sobregiro.
  3. CuentaInversion: hereda de Cuenta y suma un atributo plazo (Int, en meses). Su constructor recibe numeroCuenta, saldo y plazo, y tiene un método calcularIntereses que 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

scala
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}")
}
text
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 numero y no numeroCuenta. Si se llamaran igual que el val numeroCuenta heredado y los usaras en el cuerpo de la subclase (por ejemplo, en toString), el parámetro taparía al atributo heredado y el compilador lo rechazaría. Con otro nombre, dentro de la subclase numeroCuenta es siempre el atributo de Cuenta.
  • transferir recibe dos Cuenta: gracias al polimorfismo funciona con cualquier combinación de subclases, y cada una aplica su propia regla de retirar.
  • require agrega el prefijo requirement failed: al mensaje de la excepción.

Resumen

  • extends hereda de una sola clase; override es obligatorio para redefinir algo que ya tiene implementación, y super llama 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í match con patrones de tipo sobre isInstanceOf/asInstanceOf.
  • protected deja acceder a las subclases; la superclase se construye primero y recibe sus parámetros en el extends.
  • 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 val o def. Las definiciones tempranas de Scala 2 no existen en Scala 3.
  • == compara contenido y eq compara referencias; las case classes traen la igualdad por contenido resuelta.

← Volver al temario