Semana 3 — Encapsulamiento, static y records
Objetivos
- Proteger el estado de un objeto con encapsulamiento (encapsulation): campos
private, una interfaz pública pequeña y un invariante (invariant) que se cumple siempre. - Distinguir lo que pertenece al objeto de lo que pertenece a la clase (
static) y organizar el código en paquetes (packages). - Diseñar clases inmutables (immutable) y reconocer cuándo un
recordbasta. - Escribir los primeros unit tests con JUnit y correrlas en el contenedor.
Apuntes de la semana: notes/week-03.md · Código: code/week-03
Sesión A — Encapsulamiento e invariantes
Lecturas:
- Eck, subsec. 5.1.3 Getters and Setters.
- Eck, subsec. 4.7.1 Preconditions and Postconditions.
- Eck, subsecs. 8.2.2 Preconditions and Postconditions y 8.2.3 Invariants (la parte de invariantes de clase).
- Eck, subsec. 4.3.7 Throwing Exceptions: solo
throw new IllegalArgumentException(...); las excepciones se ven completas en la semana 9.
Lecturas complementarias:
- dev.java, Controlling Access to Members of a Class (sección Controlling Access, dentro de More on Classes).
- Martin Fowler, Tell Don’t Ask.
Temas:
- Modificadores de acceso:
private,publicy el acceso de paquete (sin modificador).protectedqueda para la semana 5. - Invariante de clase: una condición sobre los campos que vale después del constructor y después de cada método público. Ejemplo propio:
Fractioncon denominador positivo y siempre reducida;BankAccountcuyo saldo nunca es negativo. - El constructor valida y rechaza datos inválidos lanzando
IllegalArgumentException: un objeto inválido nunca llega a existir. - Getters sí, setters con cuidado: un setter por cada campo rompe el encapsulamiento igual que un campo público. Mejor métodos con intención (
deposit,withdraw) quesetBalance. - Error común: devolver desde un getter la referencia a un arreglo interno; quien la recibe puede modificar el estado sin pasar por la clase (el alias de la semana 2). Solución: copia defensiva.
Sesión B — static, paquetes, inmutabilidad · record
Lecturas:
- Eck, subsec. 4.2.4 Member Variables y subsec. 4.8.3 Named Constants.
- Eck, sec. 4.6 APIs, Packages, Modules, and Javadoc, subsecciones 4.6.2 y 4.6.3; y subsec. 2.6.5 The Problem of Packages (compilar paquetes desde la línea de comandos).
- Eck, subsec. 5.3.1 Some Built-in Classes (
Stringes inmutable). - Eck, sec. 7.4 Records, subsecciones 7.4.1 y 7.4.2.
Lecturas complementarias:
- dev.java, Using Records to Model Immutable Data y Packages.
- JEP 395: Records.
Temas:
static: campos y métodos de la clase, no del objeto. Contadores compartidos, métodos de utilidad (Math.max), constantesstatic final. Por qué un métodostaticno puede usarthis.- Error común: volver
staticun campo “para que compile” desdemainy terminar con un estado compartido por todos los objetos. - Paquetes:
package,import, la correspondencia entre paquete y carpeta,javac -d buildyjava -cp build. Desde esta semana el código va en clases normales y en paquetes. - Inmutabilidad: campos
private final, sin métodos que modifiquen, operaciones que devuelven un objeto nuevo (comoString.toUpperCase). Ventajas: se puede compartir sin miedo al alias. record: una clase inmutable con componentes, constructor, métodos de acceso,equals,hashCodeytoStringgenerados. Constructor compacto para validar. Ejemplo:record Money(long cents, String currency). El contrato deequalsyhashCodeque el record cumple se estudia en la semana 5.- Cuándo un record y cuándo una clase: un record es un dato con valor; una clase con estado que cambia e invariantes que proteger (como
BankAccount) no es un record.
Laboratorio — Unit tests con JUnit
Código inicial en code/week-03/: Fraction.java (sin pruebas todavía), las clases de bank/ y grades/ con sus pruebas como modelo, y un Makefile que compila los paquetes. Guía de referencia: JUnit User Guide, Writing Tests, Annotations, Assertions y Console Launcher. Eck, subsec. 8.4.2 Annotations, para entender qué es @Test.
- Correr
make testen el contenedor y leer la salida del Console Launcher: pruebas que pasan, que fallan y por qué. - Escribir
FractionTest.javapara la clase encapsuladaFraction: una prueba por comportamiento, con nombres que digan qué se espera (reducesOnCreation,rejectsZeroDenominator). - Probar que el constructor rechaza datos inválidos con
assertThrows(IllegalArgumentException.class, () -> new Fraction(1, 0)). La flecha() ->es una lambda: por ahora la usamos como receta y la explicamos en la semana 12. - Romper el invariante a propósito (por ejemplo, quitar la validación) y ver qué prueba lo detecta.
- Pasar
Fractional paquetenumbersy ajustarpackage,importy elMakefile(los apuntes muestran el error que aparece al moverla).
Entregas
- P0 entrega.
- P1 publicado: Inventario (clases encapsuladas con invariantes,
ArrayListy pruebas JUnit). Entrega en semana 6. Verprojects/README.md.
Preguntas de repaso
- Enuncien el invariante de una clase
Fractionreducida y con denominador positivo. ¿Qué métodos tienen que preservarlo y cuál lo establece? - ¿Por qué un setter público para cada campo debilita el encapsulamiento? Den un ejemplo en que un método con intención proteja mejor el invariante.
- Un campo
static int countse incrementa en el constructor. Si se crean tres objetos, ¿cuánto valecounten cada uno? ¿Por qué? - ¿Qué genera el compilador para
record Money(long cents, String currency)? ¿Dónde pondrían la validación de quecentsno es negativo? - Un getter devuelve el arreglo interno de una clase. Muestren cómo un cliente rompe el invariante sin llamar a ningún método de la clase, y cómo lo evita una copia defensiva.
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (9)Pruebas (3)Compilación (1)
Ejemplos
Los programas que usan los apuntes y la clase.
/**
* Una fracción inmutable. Invariante: el denominador es positivo y la
* fracción está reducida (numerador y denominador sin factores comunes).
*/
public class Fraction {
private final long numerator;
private final long denominator;
/** Crea numerator/denominator, reducida. El denominador no puede ser cero. */
public Fraction(long numerator, long denominator) {
if (denominator == 0) {
throw new IllegalArgumentException("denominador cero");
}
if (denominator < 0) { // el signo va en el numerador
numerator = -numerator;
denominator = -denominator;
}
long g = gcd(Math.abs(numerator), denominator);
this.numerator = numerator / g;
this.denominator = denominator / g;
}
public long numerator() {
return numerator;
}
public long denominator() {
return denominator;
}
/** Devuelve this + other como una fracción nueva; this no cambia. */
public Fraction plus(Fraction other) {
return new Fraction(numerator * other.denominator + other.numerator * denominator,
denominator * other.denominator);
}
/** Devuelve this × other como una fracción nueva. */
public Fraction times(Fraction other) {
return new Fraction(numerator * other.numerator, denominator * other.denominator);
}
// Máximo común divisor (Euclides). Es static: no usa ningún campo.
private static long gcd(long a, long b) {
while (b != 0) {
long r = a % b;
a = b;
b = r;
}
return a == 0 ? 1 : a;
}
}// El constructor de Fraction establece el invariante; plus y times lo conservan.
public class FractionDemo {
public static void main(String[] args) {
Fraction half = new Fraction(2, 4);
Fraction third = new Fraction(1, -3);
IO.println("2/4 -> " + show(half));
IO.println("1/-3 -> " + show(third));
IO.println("suma: " + show(half.plus(third)));
IO.println("producto: " + show(half.times(third)));
IO.println("half sigue siendo " + show(half));
new Fraction(1, 0); // termina con una excepción
}
static String show(Fraction f) {
return f.numerator() + "/" + f.denominator();
}
}// Un campo que debía ser de cada objeto se declaró static "para que compile".
public class StaticPitfall {
static class Tally {
private static int count; // error: un solo contador para todos
void click() {
count++;
}
int count() {
return count;
}
}
public static void main(String[] args) {
Tally door = new Tally();
Tally window = new Tally();
door.click();
door.click();
window.click();
IO.println("door: " + door.count() + ", window: " + window.count());
}
}package bank;
/**
* Una cuenta bancaria. Invariante: el saldo nunca es negativo.
* Los montos van en céntimos, como enteros, para no perder precisión.
*/
public class BankAccount {
private static int accountsOpened = 0; // de la clase: uno solo para todas las cuentas
private final String id;
private final String owner;
private long balance; // céntimos; siempre >= 0
/** Abre una cuenta con saldo cero. El titular no puede estar vacío. */
public BankAccount(String owner) {
if (owner == null || owner.isBlank()) {
throw new IllegalArgumentException("el titular no puede estar vacío");
}
accountsOpened++;
this.id = String.format("CTA-%04d", accountsOpened);
this.owner = owner;
this.balance = 0;
}
/** Deposita un monto positivo. */
public void deposit(long amount) {
requirePositive(amount);
balance += amount;
}
/**
* Retira un monto positivo si alcanza el saldo. Devuelve si se pudo; si no
* alcanza, el saldo no cambia.
*/
public boolean withdraw(long amount) {
requirePositive(amount);
if (amount > balance) {
return false;
}
balance -= amount;
return true;
}
public String id() {
return id;
}
public String owner() {
return owner;
}
public long balance() {
return balance;
}
/** Cuántas cuentas se abrieron desde que arrancó el programa. */
public static int accountsOpened() {
return accountsOpened;
}
private static void requirePositive(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("el monto debe ser positivo: " + amount);
}
}
}package bank;
// Una cuenta protege su invariante; Money es un valor inmutable.
public class BankDemo {
public static void main(String[] args) {
BankAccount alice = new BankAccount("Alice");
BankAccount bob = new BankAccount("Bob");
alice.deposit(10_000);
boolean ok = alice.withdraw(2_500);
IO.println(alice.id() + " " + alice.owner() + ": retiro " + ok + ", saldo " + alice.balance());
ok = bob.withdraw(2_500);
IO.println(bob.id() + " " + bob.owner() + ": retiro " + ok + ", saldo " + bob.balance());
IO.println("cuentas abiertas: " + BankAccount.accountsOpened());
Money price = Money.soles(1_050);
Money total = price.plus(Money.soles(250));
IO.println(price + " + S/ 2.50 = " + total);
IO.println("iguales: " + total.equals(new Money(1_300, "PEN")));
}
}package bank;
/**
* Una cantidad de dinero en una moneda: un valor inmutable.
* El compilador genera el constructor, los métodos de acceso cents() y currency(),
* equals, hashCode y toString.
*/
public record Money(long cents, String currency) {
/** Constructor compacto: valida antes de asignar los campos. */
public Money {
if (cents < 0) {
throw new IllegalArgumentException("monto negativo: " + cents);
}
if (currency == null || currency.length() != 3) {
throw new IllegalArgumentException("código de moneda inválido: " + currency);
}
}
/** Atajo para soles: Money.soles(1050) es S/ 10.50. */
public static Money soles(long cents) {
return new Money(cents, "PEN");
}
/** Devuelve una suma nueva; this no cambia. */
public Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("monedas distintas: " + currency + " y " + other.currency);
}
return new Money(cents + other.cents, currency);
}
}package grades;
// Un getter que devuelve el arreglo interno deja romper el invariante desde afuera.
public class CopyDemo {
public static void main(String[] args) {
int[] input = {14, 17, 11};
LeakyScores leaky = new LeakyScores(input);
leaky.values()[0] = 99; // nadie llamó a un método que cambie notas
input[1] = -5;
IO.println("LeakyScores: máxima " + leaky.highest() + ", primera " + leaky.values()[0]
+ ", segunda " + leaky.values()[1]);
int[] again = {14, 17, 11};
Scores safe = new Scores(again);
safe.values()[0] = 99; // cambia una copia
again[1] = -5; // cambia el arreglo de quien llamó, no el de safe
IO.println("Scores: máxima " + safe.highest() + ", primera " + safe.values()[0]
+ ", segunda " + safe.values()[1]);
}
}package grades;
/** Igual que Scores, pero sin copias defensivas: el invariante tiene fugas. */
public class LeakyScores {
private final int[] values;
public LeakyScores(int[] values) {
for (int v : values) {
if (v < 0 || v > 20) {
throw new IllegalArgumentException("nota fuera de 0..20: " + v);
}
}
this.values = values; // guarda la referencia de quien llamó
}
public int[] values() {
return values; // entrega la referencia interna
}
public int highest() {
int best = 0;
for (int v : values) {
best = Math.max(best, v);
}
return best;
}
}package grades;
/**
* Las notas de una evaluación, en escala vigesimal.
* Invariante: cada nota está entre 0 y 20.
*/
public class Scores {
private final int[] values;
public Scores(int[] values) {
for (int v : values) {
if (v < 0 || v > 20) {
throw new IllegalArgumentException("nota fuera de 0..20: " + v);
}
}
this.values = values.clone(); // copia defensiva al entrar
}
/** Devuelve una copia: cambiarla no cambia estas notas. */
public int[] values() {
return values.clone(); // copia defensiva al salir
}
public int highest() {
int best = 0;
for (int v : values) {
best = Math.max(best, v);
}
return best;
}
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
package bank;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
// Cada prueba comprueba un comportamiento y su nombre dice qué se espera.
class BankAccountTest {
@Test
void newAccountStartsAtZero() {
BankAccount account = new BankAccount("Alice");
assertEquals(0, account.balance());
}
@Test
void depositIncreasesBalance() {
// Preparar
BankAccount account = new BankAccount("Alice");
// Actuar
account.deposit(10_000);
// Verificar
assertEquals(10_000, account.balance());
}
@Test
void withdrawWithEnoughBalance() {
BankAccount account = new BankAccount("Alice");
account.deposit(10_000);
boolean ok = account.withdraw(2_500);
assertTrue(ok);
assertEquals(7_500, account.balance());
}
@Test
void withdrawMoreThanBalanceChangesNothing() {
BankAccount account = new BankAccount("Alice");
account.deposit(1_000);
boolean ok = account.withdraw(2_500);
assertFalse(ok);
assertEquals(1_000, account.balance());
}
@Test
void rejectsNegativeDeposit() {
BankAccount account = new BankAccount("Alice");
assertThrows(IllegalArgumentException.class, () -> account.deposit(-500));
assertEquals(0, account.balance());
}
@Test
void rejectsZeroWithdrawal() {
BankAccount account = new BankAccount("Alice");
assertThrows(IllegalArgumentException.class, () -> account.withdraw(0));
}
@Test
void rejectsBlankOwner() {
assertThrows(IllegalArgumentException.class, () -> new BankAccount(" "));
}
@Test
void eachAccountGetsADifferentId() {
BankAccount a = new BankAccount("Alice");
BankAccount b = new BankAccount("Bob");
assertNotEquals(a.id(), b.id());
}
}package bank;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class MoneyTest {
@Test
void plusAddsCents() {
Money total = Money.soles(1_050).plus(Money.soles(250));
assertEquals(1_300, total.cents());
assertEquals("PEN", total.currency());
}
@Test
void plusDoesNotChangeTheOriginal() {
Money price = Money.soles(1_050);
price.plus(Money.soles(250));
assertEquals(1_050, price.cents());
}
@Test
void recordsWithSameComponentsAreEqual() {
// equals lo genera el record: compara componentes, no referencias.
assertEquals(new Money(1_300, "PEN"), Money.soles(1_300));
}
@Test
void rejectsNegativeCents() {
assertThrows(IllegalArgumentException.class, () -> new Money(-1, "PEN"));
}
@Test
void rejectsInvalidCurrencyCode() {
assertThrows(IllegalArgumentException.class, () -> new Money(100, "SOLES"));
}
@Test
void rejectsMixedCurrencies() {
Money soles = Money.soles(100);
Money dollars = new Money(100, "USD");
assertThrows(IllegalArgumentException.class, () -> soles.plus(dollars));
}
}package grades;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class ScoresTest {
@Test
void highestOfSeveral() {
Scores scores = new Scores(new int[] {14, 17, 11});
assertEquals(17, scores.highest());
}
@Test
void rejectsScoreAboveTwenty() {
assertThrows(IllegalArgumentException.class, () -> new Scores(new int[] {14, 21}));
}
@Test
void changingTheReturnedArrayDoesNotChangeTheScores() {
Scores scores = new Scores(new int[] {14, 17, 11});
scores.values()[0] = 99;
assertEquals(14, scores.values()[0]);
assertEquals(17, scores.highest());
}
@Test
void changingTheInputArrayDoesNotChangeTheScores() {
int[] input = {14, 17, 11};
Scores scores = new Scores(input);
input[1] = -5;
assertEquals(17, scores.values()[1]);
}
}Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 3: encapsulamiento, static, paquetes, records y pruebas con JUnit.
# make compila todo a build/ (build/bank, build/grades...); con -g, para que los
# mensajes de error y jdb nombren las variables locales
# make test corre las demostraciones y las pruebas JUnit (*Test.java)
# make clean borra build/
#
# Las fuentes se buscan en todas las subcarpetas: cada carpeta es un paquete
# (bank/BankAccount.java empieza con "package bank;"). Las demostraciones se
# ejecutan por su nombre completo, con el paquete: bank.BankDemo.
JAVAC = javac
JFLAGS = -Xlint:all -Werror -g
SRC = $(sort $(shell find . -name '*.java' -not -path './build/*'))
DEMOS = bank.BankDemo grades.CopyDemo StaticPitfall
all: build/.stamp
build/.stamp: $(SRC)
mkdir -p build
$(JAVAC) $(JFLAGS) -cp $(JUNIT_JAR) -d build $(SRC)
touch $@
test: all
@for d in $(DEMOS); do echo "== $$d"; java -cp build $$d || exit 1; done
@echo "== FractionDemo (termina con IllegalArgumentException a propósito)"; ! java -cp build FractionDemo
java -jar $(JUNIT_JAR) execute --class-path build --scan-class-path \
--disable-banner --disable-ansi-colors --details=tree
clean:
rm -rf build
.PHONY: all test clean