08
Herencia e interfaces

Tipos sealed, pattern matching y examen parcial

Herencia, polimorfismo e interfaces

Semana 8 — Tipos sealed, pattern matching y examen parcial

Objetivos

  • Declarar una jerarquía cerrada con sealed y permits, y explicar qué gana el compilador al conocer todos los subtipos.
  • Usar pattern matching con instanceof y en switch, incluidos los patrones de record.
  • Comparar dos formas de agregar comportamiento a una jerarquía: el override de un método en cada subclase frente a un switch exhaustivo sobre un tipo sealed.
  • Repasar las partes 1 y 2 del curso para el examen parcial.

Apuntes de la semana: notes/week-08.md (incluye una sección de repaso con ejercicios tipo examen) · Código: code/week-08

Sesión A — Tipos sealed y pattern matching · Repaso

Lecturas (Eck cubre Java 17 y no trata switch con patrones):

Lecturas complementarias:

Temas:

  • sealed interface Shape permits Circle, Square, Triangle: la jerarquía queda cerrada (Triangle es el subtipo que se agrega al final, para ver qué marca el compilador). Cada subtipo permitido es final, sealed o non-sealed. Con records como subtipos: datos inmutables de la semana 3 más una jerarquía cerrada.
  • Pattern matching con instanceof: if (s instanceof Circle c) prueba el tipo y declara la variable en un paso (la forma que ya usamos en equals en la semana 5).
  • switch con patrones de tipo y de record: case Circle(double r) -> Math.PI * r * r;. Guards con when. case null.
  • Exhaustividad: si el switch cubre todos los subtipos permitidos, no hace falta default, y al agregar un subtipo nuevo el compilador marca cada switch incompleto. Error común: poner default “por seguridad” y perder ese aviso.
  • Ejemplo propio: un evaluador de expresiones aritméticas con sealed interface Expr y records Num, Add, Mul; evaluar e imprimir con switch.
  • Dos estilos: override del método en cada clase (fácil agregar tipos, difícil agregar operaciones) frente a switch sobre un tipo sealed (fácil agregar operaciones, el compilador avisa al agregar tipos). Contraste con la cadena de instanceof de la semana 6, donde nadie avisa.
  • Repaso: preguntas de las semanas 1 a 7 resueltas en clase.

Sesión B — Examen parcial

Temas: partes 1 y 2 del curso, semanas 1 a 8 (objetos y clases; herencia, polimorfismo e interfaces). Ver exams/README.md.

Qué entra:

  • Clases, constructores, this, static, overloading. Referencias, alias, null, identidad e igualdad. Diagramas de stack y heap.
  • Encapsulamiento, invariantes, inmutabilidad y records. Arreglos y ArrayList. Diagramas de clases UML y reparto de responsabilidades.
  • Herencia, super, override, protected, final. toString, equals, hashCode y sus contratos.
  • Polimorfismo y dynamic dispatch (trazas de qué método se ejecuta), clases abstractas, interfaces, composición frente a herencia, principio de Liskov.
  • Pruebas JUnit: leer una prueba y decir qué verifica; escribir pruebas cortas para una clase dada.
  • Tipos sealed y pattern matching: solo lectura de código (decir qué hace un switch con patrones o por qué no compila), dado lo cerca que está la sesión A.

Cómo estudiar:

  1. Las Preguntas de repaso de las semanas 1 a 8.
  2. Trazas a mano: escribir programas cortos con alias, overloading y override, predecir la salida y verificarla con java o jshell.
  3. Rehacer el diseño de un dominio pequeño: tarjetas CRC, diagrama de clases, invariantes y pruebas.
  4. Los cuestionarios (quizzes) de Eck de los capítulos 5 y 7, y los apuntes de cada semana.

Laboratorio — Repaso con ejercicios

Código en code/week-08/: PaymentFees.java y sus pruebas son el punto de partida; make errores muestra los errores de compilación de los ejemplos de broken/.

  1. Reescribir con un tipo sealed y switch una jerarquía pequeña que hoy usa una cadena de instanceof (PaymentFees, la comisión de cada forma de pago), sin que dejen de pasar sus pruebas; agregar un subtipo y ver qué marca el compilador.
  2. Ejercicios tipo examen en parejas (sección de repaso de los apuntes): trazas de dynamic dispatch, contrato de equals, diagramas de stack y heap, encontrar la violación de Liskov.
  3. Leer pruebas JUnit ajenas y decir qué comportamiento fijan y qué caso falta.

Entregas

  • P2 en curso (Catálogo). Entrega en semana 9.

Preguntas de repaso

  1. ¿Qué pasa al compilar si se agrega Triangle a permits y un switch sobre Shape sin default no lo trata? ¿Y si el switch tiene default?
  2. Escriban un switch que calcule el área de cada Shape usando patrones de record. ¿Por qué no necesita casting?
  3. ¿Cuándo prefieren un método abstracto con un override en cada subclase y cuándo un switch exhaustivo sobre un tipo sealed?
  4. ¿Por qué cada subtipo permitido de una interfaz sealed tiene que ser final, sealed o non-sealed? ¿Por qué un record cumple esa regla sin escribir nada?

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (18)Pruebas (3)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

Add.javaDescargar Add.java
/** La suma de dos expresiones. */
public record Add(Expr left, Expr right) implements Expr {
}
Calc.javaDescargar Calc.java
/** Operaciones sobre expresiones, cada una en un solo switch exhaustivo. */
public final class Calc {
    private Calc() {
    }

    public static int eval(Expr e) {
        return switch (e) {
            case Num(int v) -> v;
            case Add(Expr l, Expr r) -> eval(l) + eval(r);
            case Mul(Expr l, Expr r) -> eval(l) * eval(r);
        };
    }

    /** Texto de la expresión; las sumas van entre paréntesis. */
    public static String show(Expr e) {
        return switch (e) {
            case Num(int v) -> Integer.toString(v);
            case Add(Expr l, Expr r) -> "(" + show(l) + " + " + show(r) + ")";
            case Mul(Expr l, Expr r) -> show(l) + " * " + show(r);
        };
    }

    /** Quita los 1 que multiplican y los 0 que suman. Patrones anidados con guards. */
    public static Expr simplify(Expr e) {
        return switch (e) {
            case Mul(Num(int v), Expr r) when v == 1 -> simplify(r);
            case Mul(Expr l, Num(int v)) when v == 1 -> simplify(l);
            case Add(Num(int v), Expr r) when v == 0 -> simplify(r);
            case Add(Expr l, Num(int v)) when v == 0 -> simplify(l);
            case Add(Expr l, Expr r) -> new Add(simplify(l), simplify(r));
            case Mul(Expr l, Expr r) -> new Mul(simplify(l), simplify(r));
            case Num n -> n;
        };
    }
}
Circle.javaDescargar Circle.java
/** Un círculo dado por su radio. Un record es final: cumple la regla de sealed. */
public record Circle(double radius) implements Shape {
    public Circle {
        if (radius <= 0) {
            throw new IllegalArgumentException("radio no positivo: " + radius);
        }
    }
}
Expr.javaDescargar Expr.java
/** Una expresión aritmética: un número, una suma o un producto. */
public sealed interface Expr permits Num, Add, Mul {
}
ExprDemo.javaDescargar ExprDemo.java
/** Evalúa, imprime y simplifica 2 + 3 * 4 y (1 * x) + 0. */
public class ExprDemo {
    public static void main(String[] args) {
        Expr e = new Add(new Num(2), new Mul(new Num(3), new Num(4)));
        IO.println(e);
        IO.println(Calc.show(e) + " = " + Calc.eval(e));

        Expr f = new Mul(new Add(new Num(2), new Num(3)), new Num(4));
        IO.println(Calc.show(f) + " = " + Calc.eval(f));

        Expr g = new Add(new Mul(new Num(1), new Num(7)), new Num(0));
        IO.println(Calc.show(g) + "  se simplifica a  " + Calc.show(Calc.simplify(g)));
    }
}
ExprMethods.javaDescargar ExprMethods.java
/**
 * El mismo evaluador en el otro estilo: cada tipo hace override de eval y show.
 * Agregar un tipo es fácil (una clase nueva); agregar una operación obliga a
 * tocar todas las clases.
 */
public class ExprMethods {
    interface Expr {
        int eval();

        String show();
    }

    record Num(int value) implements Expr {
        public int eval() {
            return value;
        }

        public String show() {
            return Integer.toString(value);
        }
    }

    record Add(Expr left, Expr right) implements Expr {
        public int eval() {
            return left.eval() + right.eval();
        }

        public String show() {
            return "(" + left.show() + " + " + right.show() + ")";
        }
    }

    record Mul(Expr left, Expr right) implements Expr {
        public int eval() {
            return left.eval() * right.eval();
        }

        public String show() {
            return left.show() + " * " + right.show();
        }
    }

    public static void main(String[] args) {
        Expr e = new Add(new Num(2), new Mul(new Num(3), new Num(4)));
        IO.println(e.show() + " = " + e.eval());
    }
}
Mul.javaDescargar Mul.java
/** El producto de dos expresiones. */
public record Mul(Expr left, Expr right) implements Expr {
}
Num.javaDescargar Num.java
/** Un número entero: la hoja del árbol de una expresión. */
public record Num(int value) implements Expr {
}
PaymentFees.javaDescargar PaymentFees.java
/**
 * Código inicial del laboratorio: la comisión de cada forma de pago, calculada
 * con una cadena de instanceof. Reescríbanlo con un tipo sealed y un switch.
 */
public final class PaymentFees {
    private PaymentFees() {
    }

    public interface Payment {
        long cents();
    }

    /** Pago en efectivo: sin comisión. */
    public record Cash(long cents) implements Payment {
    }

    /** Pago con tarjeta: 3 % si es de crédito, 1 % si es de débito. */
    public record Card(long cents, boolean credit) implements Payment {
    }

    /** Transferencia bancaria: S/ 1.50 fijos. */
    public record Transfer(long cents, String bank) implements Payment {
    }

    /** Comisión en céntimos. */
    public static long fee(Payment p) {
        if (p instanceof Cash) {
            return 0;
        } else if (p instanceof Card c) {
            return c.credit() ? c.cents() * 3 / 100 : c.cents() / 100;
        } else if (p instanceof Transfer) {
            return 150;
        }
        throw new IllegalArgumentException("forma de pago desconocida: " + p);
    }
}
Shape.javaDescargar Shape.java
/**
 * Una figura del plano. La jerarquía está cerrada: solo estos tres records
 * pueden implementar Shape, y el compilador lo sabe.
 */
public sealed interface Shape permits Circle, Square, Triangle {
}
ShapeDemo.javaDescargar ShapeDemo.java
import java.util.List;

/** Recorre una lista de figuras y usa las operaciones de Shapes. */
public class ShapeDemo {
    public static void main(String[] args) {
        List<Shape> shapes = List.of(new Circle(1), new Square(2), new Triangle(3, 4), new Circle(10));
        for (Shape s : shapes) {
            IO.println(s + " -> área " + "%.2f".formatted(Shapes.area(s)) + ", " + Shapes.describe(s));
        }
        IO.println(Shapes.describe(null));

        // La versión con instanceof olvidó Triangle y nadie avisó.
        try {
            Shapes.areaWithInstanceof(new Triangle(3, 4));
        } catch (IllegalArgumentException e) {
            IO.println("instanceof: " + e.getMessage());
        }
    }
}
Shapes.javaDescargar Shapes.java
/** Operaciones sobre figuras, escritas con switch sobre el tipo sealed Shape. */
public final class Shapes {
    private Shapes() {
    }

    /** Área de la figura. Sin default: si falta un subtipo, no compila. */
    public static double area(Shape s) {
        return switch (s) {
            case Circle(double r) -> Math.PI * r * r;
            case Square(double side) -> side * side;
            case Triangle(double b, double h) -> b * h / 2;
        };
    }

    /** Una descripción corta. Muestra guards (when) y case null. */
    public static String describe(Shape s) {
        return switch (s) {
            case null -> "sin figura";
            case Circle c when c.radius() >= 10 -> "círculo grande";
            case Circle c -> "círculo de radio " + c.radius();
            case Square(double side) when side == 1 -> "cuadrado unitario";
            case Square sq -> "cuadrado de lado " + sq.side();
            case Triangle t -> "triángulo de base " + t.base();
        };
    }

    /** Lo mismo que area, con instanceof: compila aunque falte un caso. */
    public static double areaWithInstanceof(Shape s) {
        if (s instanceof Circle c) {
            return Math.PI * c.radius() * c.radius();
        } else if (s instanceof Square sq) {
            return sq.side() * sq.side();
        }
        throw new IllegalArgumentException("figura desconocida: " + s);
    }
}
Square.javaDescargar Square.java
/** Un cuadrado dado por su lado. */
public record Square(double side) implements Shape {
    public Square {
        if (side <= 0) {
            throw new IllegalArgumentException("lado no positivo: " + side);
        }
    }
}
Triangle.javaDescargar Triangle.java
/** Un triángulo dado por su base y su altura. Es el subtipo que se agregó al final. */
public record Triangle(double base, double height) implements Shape {
    public Triangle {
        if (base <= 0 || height <= 0) {
            throw new IllegalArgumentException("base o altura no positiva");
        }
    }
}
broken/AreaMissingCase.javaDescargar broken/AreaMissingCase.java
// No compila: Shape ya permite Triangle y este switch no lo trata.
public class AreaMissingCase {
    static double area(Shape s) {
        return switch (s) {
            case Circle(double r) -> Math.PI * r * r;
            case Square(double side) -> side * side;
        };
    }
}
broken/ChainNoWarning.javaDescargar broken/ChainNoWarning.java
// Sí compila, y ese es el problema: con default, el compilador no avisa que falta Triangle.
public class ChainNoWarning {
    static double area(Shape s) {
        return switch (s) {
            case Circle(double r) -> Math.PI * r * r;
            case Square(double side) -> side * side;
            default -> 0;
        };
    }
}
broken/Dominated.javaDescargar broken/Dominated.java
// No compila: el primer caso ya captura todos los Shape; el segundo nunca se alcanzaría.
public class Dominated {
    static String name(Shape s) {
        return switch (s) {
            case Shape any -> "una figura";
            case Circle c -> "un círculo";
        };
    }
}
broken/Hexagon.javaDescargar broken/Hexagon.java
// No compila: Hexagon no está en la lista permits de Shape.
public record Hexagon(double side) implements Shape {
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

CalcTest.javaDescargar CalcTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

class CalcTest {
    private final Expr twoPlusThreeTimesFour = new Add(new Num(2), new Mul(new Num(3), new Num(4)));

    @Test
    void evalRespectsTheTree() {
        assertEquals(14, Calc.eval(twoPlusThreeTimesFour));
        assertEquals(20, Calc.eval(new Mul(new Add(new Num(2), new Num(3)), new Num(4))));
    }

    @Test
    void showParenthesizesSums() {
        assertEquals("(2 + 3 * 4)", Calc.show(twoPlusThreeTimesFour));
    }

    @Test
    void simplifyRemovesNeutralElements() {
        Expr e = new Add(new Mul(new Num(1), new Num(7)), new Num(0));
        assertEquals(new Num(7), Calc.simplify(e));
    }

    @Test
    void bothStylesAgree() {
        ExprMethods.Expr e = new ExprMethods.Add(new ExprMethods.Num(2),
                new ExprMethods.Mul(new ExprMethods.Num(3), new ExprMethods.Num(4)));
        assertEquals(Calc.eval(twoPlusThreeTimesFour), e.eval());
        assertEquals(Calc.show(twoPlusThreeTimesFour), e.show());
    }
}
PaymentFeesTest.javaDescargar PaymentFeesTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

// Estas pruebas tienen que seguir pasando después de reescribir PaymentFees.
class PaymentFeesTest {

    @Test
    void cashHasNoFee() {
        assertEquals(0, PaymentFees.fee(new PaymentFees.Cash(5000)));
    }

    @Test
    void creditCardPaysThreePercent() {
        assertEquals(150, PaymentFees.fee(new PaymentFees.Card(5000, true)));
    }

    @Test
    void debitCardPaysOnePercent() {
        assertEquals(50, PaymentFees.fee(new PaymentFees.Card(5000, false)));
    }

    @Test
    void transferHasAFixedFee() {
        assertEquals(150, PaymentFees.fee(new PaymentFees.Transfer(5000, "BN")));
    }
}
ShapesTest.javaDescargar ShapesTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;

class ShapesTest {

    @Test
    void areaOfEachShape() {
        assertEquals(Math.PI, Shapes.area(new Circle(1)), 1e-9);
        assertEquals(4.0, Shapes.area(new Square(2)), 1e-9);
        assertEquals(6.0, Shapes.area(new Triangle(3, 4)), 1e-9);
    }

    @Test
    void guardsChooseTheFirstMatchingCase() {
        assertEquals("círculo grande", Shapes.describe(new Circle(10)));
        assertEquals("círculo de radio 2.0", Shapes.describe(new Circle(2)));
        assertEquals("cuadrado unitario", Shapes.describe(new Square(1)));
        assertEquals("cuadrado de lado 3.0", Shapes.describe(new Square(3)));
    }

    @Test
    void nullHasItsOwnCase() {
        assertEquals("sin figura", Shapes.describe(null));
    }

    @Test
    void recordsRejectInvalidSizes() {
        assertThrows(IllegalArgumentException.class, () -> new Circle(0));
        assertThrows(IllegalArgumentException.class, () -> new Triangle(3, -1));
    }
}

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 8: tipos sealed y pattern matching.
#   make          compila todo a build/
#   make test     corre las demostraciones y las pruebas JUnit
#   make errores  compila cada ejemplo de broken/ y muestra qué dice javac
#   make clean    borra build/
JAVAC  = javac
JFLAGS = -Xlint:all -Werror
SRC    = $(wildcard *.java)
DEMOS  = ShapeDemo ExprDemo ExprMethods
SHAPES = Shape.java Circle.java Square.java Triangle.java

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
	java -jar $(JUNIT_JAR) execute --class-path build --scan-class-path \
	  --disable-banner --disable-ansi-colors --details=tree

# Los ejemplos de broken/ muestran errores de compilación a propósito (salvo
# ChainNoWarning, que compila y calla); por eso no están en SRC.
errores:
	@mkdir -p build/broken
	@for f in broken/*.java; do \
	  echo "== $$f"; \
	  if $(JAVAC) $(JFLAGS) -d build/broken $(SHAPES) $$f; then echo "(compila sin errores)"; fi; \
	done

clean:
	rm -rf build

.PHONY: all test errores clean