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.
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:
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:
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:
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:
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:
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.
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")
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:
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:
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:
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:
trait A {
println("Inicializando A")
}
trait B {
println("Inicializando B")
}
class MiClase extends A with B {
println("Inicializando MiClase")
}
val instancia = new MiClase
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:
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.
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:
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))
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á
-
Calculadora con traits. Creá un trait
OperacionesMatematicascon dos métodos abstractos,sumaryrestar. Implementá una claseCalculadoraque lo mezcle y pruebala con algunas operaciones. -
Gestión de cuentas. Creá un trait
GestionCuentascon: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
CuentaBancariaque mezcle el trait e implemente el retiro. -
Un banco que se guarda en archivos. Agregale al banco un trait
FileHandlercon dos métodos:readFromCSV, que lea los clientes desde un archivo con el formato declientes.txtde la lección 11 (id|nombre|direccion|cuentas),writeToCSV, que escriba los clientes actualizados en un archivo con el mismo formato.
Cada
Clientetiene sus cuentas en unArrayBuffer[String]con los tipos:"CA"(caja de ahorro),"CC"(cuenta corriente) y"CI"(cuenta de inversión). La claseBancomezclaFileHandler, carga los clientes del archivo al crearse, tiene métodos para agregar clientes y abrir cuentas, y un métodoguardarque usewriteToCSV.
Soluciones
// 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
// 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())
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().
// 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")
}
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:
FileHandlerno sabe nada delBanco: 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.resourceabre el archivo, ejecuta el bloque y lo cierra siempre, aunque haya un error. A diferencia deUsing(...), que viste en la lección 11, no envuelve el resultado en unTry: si algo falla, lanza la excepción.abrirCuentamodifica elArrayBufferdel cliente, que es mutable aunqueClientesea una case class. Funciona, pero mezcla las dos ideas: un diseño más funcional usaría unaListycopypara crear un cliente nuevo con la cuenta agregada.@maines la forma de Scala 3 de declarar el punto de entrada (lección 1). Ejecutalo consbt runo conscaladesde 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.