trubidata
Lección 13 de 14·24 min

Traits

En esta guía (16)

En esta lección vas a ver los traits (rasgos), la herramienta de Scala para compartir comportamiento entre clases. Vas a usarlos como interfaces, con métodos y campos implementados, combinados en capas, y vas a ver en qué orden se construyen, cómo un trait puede depender de otro con self types y en qué se convierten cuando se compilan. Al final vas a agregarle al banco la capacidad de leer y guardar sus clientes en un archivo, con un trait.

¿Por qué no hay herencia múltiple?

En Scala, como en Java, una clase puede extender una sola clase. La herencia múltiple de clases trae problemas conocidos: si dos superclases definen el mismo método, ¿cuál hereda la subclase? En lugar de eso, Scala usa traits para reusar código y componer comportamientos.

Un trait define un conjunto de miembros (métodos y campos) que se pueden mezclar (mix-in) en cualquier clase. Podés pensarlo como una plantilla de métodos y campos que varias clases comparten. Una clase extiende una sola clase, pero puede mezclar todos los traits que quiera.

Una diferencia histórica con las clases abstractas: en Scala 2 un trait no podía recibir parámetros de constructor. En Scala 3 sí puede (lo vas a ver más abajo), así que la diferencia principal que queda es que una clase extiende una sola clase abstracta pero mezcla muchos traits.

Traits como interfaces

En su forma más simple, un trait es como una interfaz de Java: declara métodos abstractos que la clase que lo mezcla tiene que implementar.

scala
trait Saludable {
  def saludar(): Unit
}

class Persona(nombre: String) extends Saludable {
  def saludar(): Unit = println(s"Hola, soy $nombre.")
}

val p = new Persona("Lucas")
p.saludar() // Hola, soy Lucas.

Para el primer trait (o la clase) se usa extends; para los siguientes, with.

Traits con implementaciones concretas

A diferencia de las interfaces clásicas de Java, un trait puede traer métodos implementados. Las clases que lo mezclan los heredan, y pueden sobrescribirlos:

scala
trait Saludable {
  def saludar(): Unit = println("¡Hola!")
}

class Robot extends Saludable

class Persona(nombre: String) extends Saludable {
  override def saludar(): Unit = println(s"Hola, soy $nombre.")
}

new Robot().saludar()            // ¡Hola!
new Persona("Lucas").saludar()   // Hola, soy Lucas.

Desde Java 8, las interfaces de Java también pueden tener métodos con implementación (los default methods), y los traits se parecen mucho a eso. La diferencia es que los traits pueden tener además estado (campos), combinarse en capas y declarar dependencias, como vas a ver.

Objetos con traits

Un object también puede mezclar traits:

scala
trait Saludable {
  def saludar(): Unit = println("¡Hola!")
}

object MiApp extends Saludable {
  def main(args: Array[String]): Unit = {
    saludar()
  }
}

Y podés mezclar un trait en una instancia puntual, al crearla, sin definir una clase nueva:

scala
trait Auditable {
  def auditar(evento: String): Unit = println(s"[AUDITORÍA] $evento")
}

class Cuenta(val numero: String)

val cuentaAuditada = new Cuenta("123") with Auditable
cuentaAuditada.auditar(s"Consulta de la cuenta ${cuentaAuditada.numero}")
// [AUDITORÍA] Consulta de la cuenta 123

Varios traits en una clase

Podés mezclar varios traits en una misma clase, sumando funcionalidad de cada uno:

scala
trait A {
  def mensajeA: String = "Mensaje de A"
}

trait B {
  def mensajeB: String = "Mensaje de B"
}

class C extends A with B {
  def mensajeC: String = "Mensaje de C"
}

val c = new C
println(List(c.mensajeA, c.mensajeB, c.mensajeC).mkString(" | "))
// Mensaje de A | Mensaje de B | Mensaje de C

Un trait también puede declarar varios métodos abstractos y la clase implementarlos todos, armando una interfaz más rica:

scala
trait Saludable {
  def saludar(): Unit
}

trait Hablante {
  def hablar(): Unit
}

class Persona(nombre: String) extends Saludable with Hablante {
  def saludar(): Unit = println(s"Hola, soy $nombre.")
  def hablar(): Unit = println("Estoy hablando.")
}

Traits en capas

La característica más original de los traits es que se pueden apilar: cada trait modifica el comportamiento del anterior llamando a super. El orden en que los mezclás define el orden en que se aplican.

scala
trait Logger {
  def log(mensaje: String): Unit = println(mensaje)
}

trait ConFecha extends Logger {
  override def log(mensaje: String): Unit = super.log(s"2023-10-16 | $mensaje")
}

trait Recortado extends Logger {
  override def log(mensaje: String): Unit =
    super.log(if (mensaje.length > 25) mensaje.take(22) + "..." else mensaje)
}

class Servicio1 extends Logger with ConFecha with Recortado
class Servicio2 extends Logger with Recortado with ConFecha

new Servicio1().log("Transferencia aprobada para la cuenta 12345")
new Servicio2().log("Transferencia aprobada para la cuenta 12345")
text
2023-10-16 | Transferencia aprobada...
2023-10-16 | Transfere...

Los traits se aplican de derecha a izquierda. En Servicio1, primero actúa Recortado (el último), que recorta el mensaje original y le pasa el resultado a ConFecha, que agrega la fecha. En Servicio2 es al revés: primero se agrega la fecha y después se recorta todo junto, por eso se pierde más texto del mensaje. Cada super.log llama al trait que está a su izquierda, no a la superclase fija.

Esto permite armar comportamientos combinando piezas chicas: agregar fecha, filtrar, recortar, encriptar, en el orden que necesites.

Métodos abstractos en traits que heredan

Un trait puede extender a otro y completar lo que el primero dejó abstracto, a su vez en términos de algo nuevo que deja abstracto:

scala
trait Saludable {
  def saludar(): Unit
}

trait Mensajero extends Saludable {
  def mensaje: String                     // nuevo método abstracto
  def saludar(): Unit = println(mensaje)  // implementa el de Saludable
}

class Bienvenida extends Mensajero {
  def mensaje: String = "Bienvenido al banco"
}

new Bienvenida().saludar() // Bienvenido al banco

La clase final solo tiene que completar lo que queda abstracto en la cadena (mensaje).

Campos en traits

Un trait puede tener campos concretos, con valor, que pasan a formar parte de cada clase que lo mezcla:

scala
trait EnPesos {
  val moneda: String = "ARS"
  def formatear(monto: Double): String = f"$moneda $monto%.2f"
}

class Cuenta(val saldo: Double) extends EnPesos

println(new Cuenta(1500).formatear(1500)) // ARS 1500.00

Y también campos abstractos, que la clase tiene que definir:

scala
trait Identificable {
  val id: Int
}

class Producto(val id: Int, val nombre: String) extends Identificable

println(new Producto(7, "Caja de ahorro").id) // 7

En Producto, el parámetro val id del constructor implementa el campo abstracto del trait.

Orden de construcción

Cuando una clase mezcla varios traits, se construyen de izquierda a derecha, y la clase al final:

scala
trait A {
  println("Inicializando A")
}

trait B {
  println("Inicializando B")
}

class MiClase extends A with B {
  println("Inicializando MiClase")
}

val instancia = new MiClase
text
Inicializando A
Inicializando B
Inicializando MiClase

Fijate que es el orden inverso al de las llamadas a super en los traits en capas: se construyen de izquierda a derecha, pero los métodos sobrescritos se resuelven de derecha a izquierda. Y vale la misma trampa que viste en la lección 9: si un trait usa en su cuerpo un campo abstracto que define la clase, al construirse el trait ese campo todavía no tiene valor. La solución es la misma: lazy val o def.

Inicializar campos de un trait con parámetros

Para que cada clase le dé su propio valor a un campo del trait, hay dos formas. La clásica, que funciona en Scala 2 y 3, es declararlo abstracto y que la clase lo defina, como Identificable más arriba. La otra, solo en Scala 3, es darle parámetros al trait, como si fuera una clase:

scala
trait Identificable(val id: Int) {
  def etiqueta: String = s"ID-$id"
}

class Cliente(numero: Int, val nombre: String) extends Identificable(numero)

val cliente = new Cliente(42, "Ana")
println(cliente.id)       // 42
println(cliente.etiqueta) // ID-42

Con parámetros no hay riesgo de que el campo todavía no tenga valor al construirse el trait, porque llega como argumento antes de ejecutar su cuerpo.

Traits que extienden clases

Un trait puede extender una clase. Eso restringe dónde se puede mezclar: la clase que lo mezcle pasa a ser, sí o sí, una subclase de esa clase. Si ya extiende otra clase que no tiene nada que ver, no compila.

scala
class Animal

trait Mascota extends Animal {
  def acariciar(): Unit
}

class Perro extends Animal with Mascota {
  def acariciar(): Unit = println("Acariciando al perro")
}

new Perro().acariciar() // Acariciando al perro

class Vehiculo
// class Auto extends Vehiculo with Mascota { ... } // no compila: Auto no es un Animal

Self types

Un self type declara que un trait necesita que la clase que lo mezcle también mezcle otro trait, sin heredar de él. Se escribe this: OtroTrait => al principio del cuerpo:

scala
trait Logger {
  def log(mensaje: String): Unit
}

trait ServicioUsuarios {
  this: Logger => // quien mezcle ServicioUsuarios tiene que ser también un Logger
  // para varios: this: TraitA with TraitB =>

  def obtenerUsuario(id: Int): String = {
    log(s"Obteniendo información del usuario $id") // puede usar log
    s"Información del usuario $id"
  }
}

// class MiApp extends ServicioUsuarios // no compila: falta Logger

class MiApp extends ServicioUsuarios with Logger {
  def log(mensaje: String): Unit = println(s"Registro: $mensaje")
}

val app = new MiApp
println(app.obtenerUsuario(123))
text
Registro: Obteniendo información del usuario 123
Información del usuario 123

La diferencia con trait ServicioUsuarios extends Logger es sutil pero útil: con el self type, ServicioUsuarios usa un Logger pero no es un Logger. La dependencia queda declarada y el compilador la verifica, sin mezclar las dos jerarquías.

Qué pasa debajo del capó

La JVM no sabe qué es un trait: el compilador de Scala traduce cada trait a una interfaz de Java. Los métodos con implementación se convierten en default methods de esa interfaz, y los campos se agregan a cada clase que mezcla el trait.

Ventajas:

  • Interoperabilidad con Java. Un trait que solo tiene métodos se ve desde Java como una interfaz común, así que podés usarlo desde código Java existente.
  • Diseño modular. Entender cómo se compilan los traits ayuda a diseñar sistemas reusables sabiendo qué costo tiene cada decisión.

Desventajas:

  • Jerarquías complejas. En proyectos grandes, muchos traits apilados generan muchas interfaces en el bytecode y una jerarquía difícil de seguir. Como con la herencia, conviene no abusar.

Practicá

  1. Calculadora con traits. Creá un trait OperacionesMatematicas con dos métodos abstractos, sumar y restar. Implementá una clase Calculadora que lo mezcle y pruebala con algunas operaciones.

  2. Gestión de cuentas. Creá un trait GestionCuentas con:

    • consultarSaldo(): Double, que devuelve el saldo,
    • realizarDeposito(monto: Double): Unit, que deposita,
    • realizarRetiro(monto: Double): Unit, que retira. Dejalo declarado pero sin implementar.

    El trait implementa los dos primeros usando un campo de saldo. Después creá una clase CuentaBancaria que mezcle el trait e implemente el retiro.

  3. Un banco que se guarda en archivos. Agregale al banco un trait FileHandler con dos métodos:

    • readFromCSV, que lea los clientes desde un archivo con el formato de clientes.txt de la lección 11 (id|nombre|direccion|cuentas),
    • writeToCSV, que escriba los clientes actualizados en un archivo con el mismo formato.

    Cada Cliente tiene sus cuentas en un ArrayBuffer[String] con los tipos: "CA" (caja de ahorro), "CC" (cuenta corriente) y "CI" (cuenta de inversión). La clase Banco mezcla FileHandler, carga los clientes del archivo al crearse, tiene métodos para agregar clientes y abrir cuentas, y un método guardar que use writeToCSV.

Soluciones

scala
// Ejercicio 1
trait OperacionesMatematicas {
  def sumar(a: Int, b: Int): Int
  def restar(a: Int, b: Int): Int
}

class Calculadora extends OperacionesMatematicas {
  def sumar(a: Int, b: Int): Int = a + b
  def restar(a: Int, b: Int): Int = a - b
}

val calc = new Calculadora()
println(calc.sumar(5, 3))   // 8
println(calc.restar(10, 2)) // 8
scala
// Ejercicio 2
trait GestionCuentas {
  protected var saldo: Double = 0.0

  def consultarSaldo(): Double = saldo

  def realizarDeposito(monto: Double): Unit = {
    if (monto > 0) {
      saldo += monto
      println(s"Depósito de $monto realizado. Nuevo saldo: $saldo")
    } else {
      println("El monto del depósito debe ser mayor que cero.")
    }
  }

  def realizarRetiro(monto: Double): Unit // declarado, sin implementar
}

class CuentaBancaria extends GestionCuentas {
  def realizarRetiro(monto: Double): Unit = {
    if (monto <= 0) {
      println("El monto del retiro debe ser mayor que cero.")
    } else if (monto > saldo) {
      println("Fondos insuficientes para realizar el retiro.")
    } else {
      saldo -= monto
      println(s"Retiro de $monto realizado. Nuevo saldo: $saldo")
    }
  }
}

val cuenta = new CuentaBancaria()
cuenta.realizarDeposito(1000.0)
cuenta.realizarRetiro(500.0)
cuenta.realizarDeposito(200.0)
cuenta.realizarRetiro(1500.0)
println(cuenta.consultarSaldo())
text
Depósito de 1000.0 realizado. Nuevo saldo: 1000.0
Retiro de 500.0 realizado. Nuevo saldo: 500.0
Depósito de 200.0 realizado. Nuevo saldo: 700.0
Fondos insuficientes para realizar el retiro.
700.0

El saldo es protected: la clase que mezcla el trait lo puede modificar, pero desde afuera solo se consulta con consultarSaldo().

scala
// Ejercicio 3: Banco.scala
import java.io.PrintWriter
import scala.collection.mutable.ArrayBuffer
import scala.io.Source
import scala.util.Using

case class Cliente(id: Int, nombre: String, direccion: String, cuentas: ArrayBuffer[String] = ArrayBuffer())

trait FileHandler {
  def readFromCSV(ruta: String): List[Cliente] =
    Using.resource(Source.fromFile(ruta, "UTF-8")) { archivo =>
      archivo.getLines().drop(1).filter(_.trim.nonEmpty).map { linea =>
        val Array(id, nombre, direccion, cuentasStr) = linea.split('|')
        Cliente(id.toInt, nombre, direccion, ArrayBuffer.from(cuentasStr.split(',')))
      }.toList
    }

  def writeToCSV(ruta: String, clientes: Seq[Cliente]): Unit =
    Using.resource(new PrintWriter(ruta, "UTF-8")) { writer =>
      writer.println("id|nombre|direccion|cuentas")
      clientes.foreach { c =>
        writer.println(s"${c.id}|${c.nombre}|${c.direccion}|${c.cuentas.mkString(",")}")
      }
    }
}

class Banco(rutaClientes: String) extends FileHandler {
  private val tiposValidos = Set("CA", "CC", "CI")

  val clientes: ArrayBuffer[Cliente] = ArrayBuffer.from(readFromCSV(rutaClientes))

  def agregarCliente(cliente: Cliente): Unit = {
    require(!clientes.exists(_.id == cliente.id), s"Ya existe un cliente con id ${cliente.id}")
    clientes += cliente
  }

  def abrirCuenta(cliente: Cliente, tipoCuenta: String): Unit = {
    require(tiposValidos.contains(tipoCuenta), s"Tipo de cuenta inválido: $tipoCuenta")
    cliente.cuentas += tipoCuenta
  }

  def guardar(ruta: String): Unit = writeToCSV(ruta, clientes.toSeq)
}

@main def ejemploBanco(): Unit = {
  val banco = new Banco("clientes.txt")
  println("Clientes cargados desde el archivo:")
  banco.clientes.foreach(println)

  val nuevoCliente = Cliente(6, "Juan", "Otra Calle")
  banco.agregarCliente(nuevoCliente)
  banco.abrirCuenta(nuevoCliente, "CA")
  banco.abrirCuenta(nuevoCliente, "CI")

  banco.guardar("clientes_actualizados.txt")
  println("\nClientes guardados en clientes_actualizados.txt")
}
text
Clientes cargados desde el archivo:
Cliente(1,Alice,Calle Principal,ArrayBuffer(CA, CC))
Cliente(2,Bob,Avenida Secundaria,ArrayBuffer(CC))
Cliente(3,Charlie,Calle Terciaria,ArrayBuffer(CA))
Cliente(4,Dave,Avenida Cuaternaria,ArrayBuffer(CC))
Cliente(5,Eve,Calle Quintenaria,ArrayBuffer(CA, CC))

Clientes guardados en clientes_actualizados.txt

Y clientes_actualizados.txt termina con la línea 6|Juan|Otra Calle|CA,CI.

Algunas decisiones del diseño:

  • FileHandler no sabe nada del Banco: recibe una ruta y devuelve clientes, o recibe clientes y los escribe. Así el mismo trait sirve para cualquier clase que necesite leer o guardar clientes.
  • Using.resource abre el archivo, ejecuta el bloque y lo cierra siempre, aunque haya un error. A diferencia de Using(...), que viste en la lección 11, no envuelve el resultado en un Try: si algo falla, lanza la excepción.
  • abrirCuenta modifica el ArrayBuffer del cliente, que es mutable aunque Cliente sea una case class. Funciona, pero mezcla las dos ideas: un diseño más funcional usaría una List y copy para crear un cliente nuevo con la cuenta agregada.
  • @main es la forma de Scala 3 de declarar el punto de entrada (lección 1). Ejecutalo con sbt run o con scala desde la carpeta donde está clientes.txt.

Resumen

  • Una clase extiende una sola clase pero puede mezclar muchos traits (extends A with B with C).
  • Los traits pueden ser interfaces puras, traer métodos implementados y tener campos concretos o abstractos.
  • Se pueden mezclar en clases, en objetos y en instancias puntuales (new Cuenta("1") with Auditable).
  • Apilados con super, se aplican de derecha a izquierda; se construyen de izquierda a derecha.
  • En Scala 3, los traits pueden recibir parámetros: trait Identificable(val id: Int).
  • Un trait que extiende una clase solo se mezcla en subclases de esa clase; un self type (this: Logger =>) declara una dependencia sin heredar.
  • Por debajo, un trait es una interfaz de Java con default methods.

← Volver al temario