trubidata
Lección 8 de 14·24 min

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:

scala
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 y saludar2 sin 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, new es 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 claseNo
class C(val nombre: String)Sí, es públicoNo
class C(var nombre: String)Sí, es públicoSí
class C(private val nombre: String)No: solo la clase y su companion objectNo
class C(private var nombre: String)No: solo la clase y su companion objectSí
class C(protected val nombre: String)Solo la clase y sus subclasesNo
scala
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: un val, var o def sin modificador se puede usar desde cualquier lado.
  • Un parámetro del constructor sin val ni var no es un atributo público: solo se puede usar dentro de la clase. Por eso, en la lección 2, la clase NumeroComplejo necesitaba val para leer otro.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:

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

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

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

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

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

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

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

scala
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()}")
text
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:

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

  1. 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, con id (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, con numeroCuenta (String) y saldo (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) y CuentaInversion (con plazo), cada una con lo que la distingue.
    • Transaccion, con origen, destino, monto y fecha.
    • 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 que class CuentaAhorro(...) extends Cuenta(...) crea un tipo de cuenta que tiene todo lo de Cuenta más lo propio, y que override redefine un método heredado.

Soluciones

scala
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)
text
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 _saldo y se expone con el getter saldo. Desde afuera nadie puede asignarlo; las subclases sí lo pueden modificar, porque es protected.
  • CuentaCorriente no reescribe retirar ni transferir: solo redefine la regla puedeRetirar. 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 a Cuenta(...). Un error común es declarar en la clase base var saldo como parámetro del constructor y en la subclase un parámetro también llamado saldo: dentro de la subclase, saldo pasa a referirse al parámetro (que no se puede modificar) y no al atributo heredado.
  • Transaccion es una case class porque es un dato; Cuenta, Cliente y Banco son 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.
  • val hace un atributo público e inmutable, var público y mutable, y sin nada queda visible solo adentro. private y protected restringen el acceso. Lo público es el valor por defecto: no existe public.
  • Un private var con 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 object es 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 sin new.

← Volver al temario