Clases y objetos
En esta guía (12)
En esta lección empezás con la programación orientada a objetos en Scala. Vas a definir clases con atributos y métodos, controlar quién puede ver y modificar cada atributo, encapsular datos detrás de métodos, organizar importaciones y crear objetos singleton y companion objects, que en Scala reemplazan a los miembros static de Java. También vas a ver las case classes, que ya usaste en las lecciones anteriores. Al final vas a diseñar las clases de un sistema bancario: clientes, cuentas, transacciones y el banco.
Clases simples
Una clase es el molde para crear objetos: define qué datos tienen (atributos) y qué pueden hacer (métodos). En Scala, los parámetros del constructor se escriben directamente al lado del nombre de la clase:
class Persona(nombre: String, edad: Int) {
def saludar(): Unit = {
println(s"Hola, mi nombre es $nombre y tengo $edad años.")
}
def saludar2: Unit = println(s"Hola, soy $nombre.")
}
val persona = new Persona("Juan", 30)
persona.saludar() // Hola, mi nombre es Juan y tengo 30 años.
persona.saludar2 // Hola, soy Juan.
class Persona(nombre: String, edad: Int)define la clase y su constructor primario en una sola línea. No hace falta escribir un constructor aparte como en Java.- Todo lo que está en el cuerpo de la clase (entre las llaves) se ejecuta al crear el objeto.
saludar()se declara con paréntesis ysaludar2sin ellos. La convención: con paréntesis si el método produce un efecto (imprime, modifica algo); sin paréntesis si solo devuelve un valor, como un getter.new Persona("Juan", 30)crea una instancia. En Scala 3,newes opcional para cualquier clase:Persona("Juan", 30)también funciona. En Scala 2, solo se podía omitir con case classes. En el curso lo escribimos para que quede claro cuándo se crea un objeto.
Modificadores de acceso y mutabilidad
Lo que antepongas a cada parámetro del constructor define dos cosas: si se puede ver desde afuera de la clase y si se puede modificar.
| Declaración | ¿Se ve desde afuera? | ¿Se puede modificar? |
|---|---|---|
class C(nombre: String) | No: solo dentro de la clase | No |
class C(val nombre: String) | Sí, es público | No |
class C(var nombre: String) | Sí, es público | Sí |
class C(private val nombre: String) | No: solo la clase y su companion object | No |
class C(private var nombre: String) | No: solo la clase y su companion object | Sí |
class C(protected val nombre: String) | Solo la clase y sus subclases | No |
class Cliente(val nombre: String, var direccion: String, dni: String)
val cliente = new Cliente("Ana", "Calle 1", "30111222")
println(cliente.nombre) // Ana
cliente.direccion = "Calle 2" // se puede: es var
// cliente.nombre = "Otra" // no compila: es val
// println(cliente.dni) // no compila: dni no es un atributo visible
Dos aclaraciones importantes, porque suelen confundirse:
- En Scala, lo público es el valor por defecto para los miembros de una clase. No existe la palabra
public: unval,varodefsin modificador se puede usar desde cualquier lado. - Un parámetro del constructor sin
valnivarno es un atributo público: solo se puede usar dentro de la clase. Por eso, en la lección 2, la claseNumeroComplejonecesitabavalpara leerotro.real.
La regla práctica: empezá con val (o sin nada, si solo lo usás adentro) y pasá a var solo si realmente necesitás modificarlo.
Propiedades con getters y setters
Si un atributo tiene que poder cambiar pero no de cualquier manera, la técnica es guardarlo en un var privado y exponer métodos que controlen el acceso:
class Cuenta {
private var _saldo: Double = 0.0
def saldo: Double = _saldo // getter: se lee como si fuera un atributo
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, "Saldo insuficiente.")
_saldo -= monto
}
}
val cuenta = new Cuenta
cuenta.depositar(100.0)
println(s"Saldo actual: ${cuenta.saldo}") // Saldo actual: 100.0
// cuenta._saldo = 1000000 // no compila: es privado
Desde afuera, cuenta.saldo se lee como un atributo, pero la única forma de cambiarlo es a través de depositar y retirar, que validan cada operación. Eso es encapsulamiento: el objeto protege la consistencia de sus propios datos.
Las importaciones pueden ir en cualquier lugar
En Scala podés importar en cualquier parte del código, no solo al principio del archivo. El import vale únicamente dentro del bloque donde está:
object MiApp {
def main(args: Array[String]): Unit = {
import java.time.LocalDate
val hoy = LocalDate.now()
println(hoy)
}
}
Renombrar y ocultar miembros
Al importar podés renombrar un miembro con =>, por ejemplo para evitar un choque de nombres, u ocultarlo con => _:
// Renombrar
import java.util.{Date => Fecha}
val fecha = new Fecha
println(fecha)
// Ocultar Date e importar todo lo demás del paquete
import java.util.{Date => _, _}
val lista = new ArrayList[String]
println(lista) // []
// val otra = new Date // no compila: Date quedó oculta
En Scala 3, la forma nueva de escribir lo mismo es con as y *: import java.util.{Date as Fecha} e import java.util.{Date as _, *}. La sintaxis con => y _ sigue funcionando en Scala 3.3, y es la que vas a ver en la mayoría del código existente.
Conversiones implícitas
Scala permite definir conversiones implícitas: funciones que el compilador aplica solas cuando un valor de un tipo se usa donde se espera otro. Es una forma de agregar funcionalidad a tipos que ya existen.
import scala.language.implicitConversions
object Conversiones {
implicit def intToString(numero: Int): String = numero.toString
}
import Conversiones._
val numero: Int = 42
val texto: String = numero // el compilador aplica intToString(numero)
println(texto.length) // 2
El import scala.language.implicitConversions es obligatorio: Scala te pide que lo actives a propósito, porque las conversiones implícitas pueden hacer que el código sea difícil de seguir. Usalas con mucha moderación.
En Scala 3, la forma recomendada de escribir una conversión es con given:
import scala.language.implicitConversions
given Conversion[Int, String] = (numero: Int) => numero.toString
val texto: String = 42
println(texto) // 42
implicit def sigue funcionando en Scala 3.3, así que lo vas a encontrar en mucho código. Para agregar métodos a un tipo existente (por ejemplo, 1500.0.enPesos), Scala 3 tiene una herramienta más clara, los extension methods:
extension (monto: Double) {
def enPesos: String = f"$$ $monto%.2f"
}
println(1500.0.enPesos) // $ 1500.00
Objetos singleton
Un objeto singleton se define con object en lugar de class. Hay una única instancia, que Scala crea automáticamente la primera vez que se usa; no se puede crear con new. Sirve para agrupar métodos y valores relacionados que no dependen de una instancia en particular, como los miembros static de Java.
object MiObjetoSingleton {
private var contador: Int = 0
def incrementarContador(): Unit = {
contador += 1
}
def obtenerContador(): Int = contador
}
MiObjetoSingleton.incrementarContador()
MiObjetoSingleton.incrementarContador()
println(MiObjetoSingleton.obtenerContador()) // 2
Se accede a sus miembros con el nombre del objeto, sin crear nada. El object con main que usaste desde la lección 1 es justamente un singleton.
Companion objects
Cuando un object tiene el mismo nombre que una clase y está en el mismo archivo, se llama companion object (objeto acompañante). La clase y su companion pueden acceder a los miembros privados del otro. Es el lugar natural para lo que en Java sería static: contadores, constantes y formas de crear instancias.
Un caso de uso típico: asignar un ID incremental a cada cuenta.
class Cuenta(val titular: String, saldoInicial: Double) {
val id: Int = Cuenta.siguienteID() // usa un método privado del companion
private var saldo: Double = saldoInicial
def depositar(monto: Double): Unit = {
saldo += monto
}
def retirar(monto: Double): Unit = {
if (monto <= saldo) saldo -= monto
else println("Saldo insuficiente.")
}
def consultarSaldo(): Double = saldo
}
object Cuenta {
private var contadorID: Int = 0
private def siguienteID(): Int = {
contadorID += 1
contadorID
}
}
val cuenta1 = new Cuenta("Titular1", 1000.0)
val cuenta2 = new Cuenta("Titular2", 1500.0)
println(s"Cuenta 1 - ID: ${cuenta1.id}, Saldo: ${cuenta1.consultarSaldo()}")
println(s"Cuenta 2 - ID: ${cuenta2.id}, Saldo: ${cuenta2.consultarSaldo()}")
Cuenta 1 - ID: 1, Saldo: 1000.0
Cuenta 2 - ID: 2, Saldo: 1500.0
siguienteID es privado del companion, pero la clase Cuenta lo puede usar igual. Desde afuera, nadie puede llamar a Cuenta.siguienteID() y desordenar los IDs.
Case classes
Una case class es una clase pensada para representar datos. Con solo agregar case, Scala genera muchas cosas por vos:
case class Transaccion(origen: String, destino: String, monto: Double)
val t1 = Transaccion("A", "B", 100.0) // sin new: el companion trae un apply
val t2 = Transaccion("A", "B", 100.0)
println(t1) // Transaccion(A,B,100.0): un toString legible
println(t1.monto) // 100.0: los parámetros son val públicos
println(t1 == t2) // true: compara por contenido, no por referencia
val t3 = t1.copy(monto = 250.0) // una copia con un campo cambiado
println(t3) // Transaccion(A,B,250.0)
t1 match { // se puede desarmar con pattern matching
case Transaccion(o, d, m) if m > 50 => println(s"Transferencia grande de $o a $d")
case _ => println("Transferencia chica")
}
Usá case classes para datos (una transacción, un registro de un archivo, un mensaje) y clases comunes para objetos con comportamiento y estado interno (una cuenta que valida sus operaciones, un banco que administra clientes).
Practicá
-
Clases para un sistema bancario. Diseñá las clases principales de un sistema bancario que permita abrir cuentas, depositar, retirar, transferir y consultar saldos. Enfocate en las clases, sus atributos, sus métodos y la relación entre ellas; no hace falta implementar toda la lógica en detalle.
Cliente, conid(Int),nombre(String),direccion(String) y la lista de sus cuentas. Métodos para abrir una cuenta y para consultar el saldo total de todas sus cuentas.Cuenta, una clase abstracta que sirve de base para los distintos tipos de cuenta, connumeroCuenta(String) ysaldo(Double), y métodos comunes para depositar, retirar y transferir.- Al menos tres tipos de cuenta que hereden de
Cuenta:CuentaAhorro(con tasa de interés),CuentaCorriente(con sobregiro) yCuentaInversion(con plazo), cada una con lo que la distingue. Transaccion, conorigen,destino,montoyfecha.Banco, con la lista de clientes y de transacciones, un método para crear clientes y otro para exportar las transacciones para auditoría.
Las clases abstractas y la herencia (
abstract class,extends,override) las vas a ver en detalle en la lección 9. Para este ejercicio alcanza con saber queclass CuentaAhorro(...) extends Cuenta(...)crea un tipo de cuenta que tiene todo lo deCuentamás lo propio, y queoverrideredefine un método heredado.
Soluciones
import java.time.LocalDate
// Clase base abstracta: no se pueden crear "cuentas genéricas"
abstract class Cuenta(val numeroCuenta: String, saldoInicial: Double) {
protected var _saldo: Double = saldoInicial
def saldo: Double = _saldo
def puedeRetirar(monto: Double): Boolean = monto <= _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.")
if (puedeRetirar(monto)) _saldo -= monto
else println(s"Saldo insuficiente en $numeroCuenta.")
}
def transferir(monto: Double, destino: Cuenta): Unit = {
require(monto > 0, "El monto tiene que ser positivo.")
if (puedeRetirar(monto)) {
_saldo -= monto
destino.depositar(monto)
} else {
println(s"Saldo insuficiente en $numeroCuenta para transferir.")
}
}
}
class CuentaAhorro(numeroCuenta: String, saldoInicial: Double, val tasaInteres: Double)
extends Cuenta(numeroCuenta, saldoInicial) {
def calcularInteres(): Double = saldo * tasaInteres
}
class CuentaCorriente(numeroCuenta: String, saldoInicial: Double, val sobregiro: Double)
extends Cuenta(numeroCuenta, saldoInicial) {
// Puede quedar en negativo hasta el límite del sobregiro
override def puedeRetirar(monto: Double): Boolean = monto <= _saldo + sobregiro
}
class CuentaInversion(numeroCuenta: String, saldoInicial: Double, val plazoMeses: Int)
extends Cuenta(numeroCuenta, saldoInicial) {
def liquidar(): Double = {
val total = _saldo
_saldo = 0
total
}
}
class Cliente(val id: Int, val nombre: String, val direccion: String) {
private var _cuentas: List[Cuenta] = List()
def cuentas: List[Cuenta] = _cuentas
def abrirCuenta(cuenta: Cuenta): Unit = {
_cuentas = _cuentas :+ cuenta
}
def consultarSaldoTotal(): Double = _cuentas.map(_.saldo).sum
}
case class Transaccion(origen: Cuenta, destino: Cuenta, monto: Double, fecha: LocalDate)
class Banco {
private var clientes: List[Cliente] = List()
private var transacciones: List[Transaccion] = List()
def crearCliente(id: Int, nombre: String, direccion: String): Cliente = {
val nuevo = new Cliente(id, nombre, direccion)
clientes = clientes :+ nuevo
nuevo
}
def registrarTransaccion(t: Transaccion): Unit = {
transacciones = transacciones :+ t
}
def exportarTransacciones(): List[String] =
transacciones.map(t => s"${t.origen.numeroCuenta},${t.destino.numeroCuenta},${t.monto},${t.fecha}")
}
// Uso
val banco = new Banco
val cliente1 = banco.crearCliente(1, "Juan Perez", "Calle Principal 123")
val cuentaAhorro = new CuentaAhorro("123456789", 1000.0, 0.02)
val cuentaCorriente = new CuentaCorriente("987654321", 2000.0, 500.0)
val cuentaInversion = new CuentaInversion("456789012", 5000.0, 12)
cliente1.abrirCuenta(cuentaAhorro)
cliente1.abrirCuenta(cuentaCorriente)
cliente1.abrirCuenta(cuentaInversion)
cuentaAhorro.depositar(500.0)
cuentaCorriente.retirar(1000.0)
cuentaAhorro.transferir(200.0, cuentaCorriente)
banco.registrarTransaccion(Transaccion(cuentaAhorro, cuentaCorriente, 200.0, LocalDate.of(2023, 11, 30)))
println(s"Saldo total de ${cliente1.nombre}: ${cliente1.consultarSaldoTotal()}")
println(s"Interés de la caja de ahorro: ${cuentaAhorro.calcularInteres()}")
banco.exportarTransacciones().foreach(println)
Saldo total de Juan Perez: 7500.0
Interés de la caja de ahorro: 26.0
123456789,987654321,200.0,2023-11-30
Las cuentas terminan así: ahorro 1000 + 500 - 200 = 1300; corriente 2000 - 1000 + 200 = 1200; inversión 5000. Total, 7500.
Algunas decisiones del diseño que vale la pena mirar:
- El saldo vive en un
protected var _saldoy se expone con el gettersaldo. Desde afuera nadie puede asignarlo; las subclases sí lo pueden modificar, porque esprotected. CuentaCorrienteno reescriberetirarnitransferir: solo redefine la reglapuedeRetirar. Como los dos métodos de la clase base la usan, el sobregiro funciona en ambos.- Los parámetros de las subclases (
numeroCuenta,saldoInicial) se pasan tal cual aCuenta(...). Un error común es declarar en la clase basevar saldocomo parámetro del constructor y en la subclase un parámetro también llamadosaldo: dentro de la subclase,saldopasa a referirse al parámetro (que no se puede modificar) y no al atributo heredado. Transacciones una case class porque es un dato;Cuenta,ClienteyBancoson clases comunes porque tienen estado que cambia y reglas propias.
Resumen
- Una clase define atributos y métodos; el constructor primario se escribe al lado del nombre.
valhace un atributo público e inmutable,varpúblico y mutable, y sin nada queda visible solo adentro.privateyprotectedrestringen el acceso. Lo público es el valor por defecto: no existepublic.- Un
private varcon un getter y métodos que validan es la forma de encapsular estado. - Los imports pueden ir en cualquier lugar, renombrar (
{Date => Fecha}) u ocultar ({Date => _, _}) miembros. - Las conversiones implícitas existen pero conviene usarlas poco; en Scala 3 se escriben con
given Conversion, y para agregar métodos se usan extension methods. - Un
objectes un singleton; con el mismo nombre que una clase, es su companion object y comparte el acceso a los miembros privados. - Las case classes son ideales para datos:
toString, igualdad por contenido,copy, pattern matching y creación sinnew.