Final de práctica — Programación Orientada a Objetos

Temas: todo el curso, con énfasis en las partes 3 y 4 (semanas 9 a 16) · Duración: 3 horas

Este es un examen de práctica con el formato del final. Resuélvanlo en papel, con el reloj corriendo, y recién después comprueben sus respuestas con java o jshell.

Indicaciones

  • En el código cuenta la idea, no los errores menores de sintaxis.
  • En las preguntas de “qué imprime”, justifiquen cada respuesta: una salida correcta sin explicación no basta.
  • Todo el código de los enunciados compila con javac -Xlint:all (Java 25), salvo las líneas que una pregunta presenta para decidir si compilan.
  • Los montos van en céntimos, como enteros, igual que en el curso.
Parte Preguntas
A. Leer código 1–6
B. Escribir código 7–9
C. Diseño 10–11
D. Preguntas cortas 12–15

Parte A — Leer código

Pregunta 1 — Excepciones que se propagan

public class F1Sales {

    static class InsufficientFundsException extends Exception {
        private static final long serialVersionUID = 1L;

        InsufficientFundsException(String message) {
            super(message);
        }
    }

    static class SaleException extends Exception {
        private static final long serialVersionUID = 1L;

        SaleException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    static long parseCents(String text) {
        IO.println("parse " + text);
        return Long.parseLong(text);
    }

    static void charge(long balance, long amount) throws InsufficientFundsException {
        if (amount > balance) {
            throw new InsufficientFundsException("saldo " + balance + ", pide " + amount);
        }
        IO.println("cobrado " + amount);
    }

    static void sell(long balance, String text) throws SaleException {
        try {
            charge(balance, parseCents(text));
            IO.println("venta ok");
        } catch (InsufficientFundsException e) {
            throw new SaleException("venta rechazada", e);
        } finally {
            IO.println("fin de venta " + text);
        }
    }

    public static void main(String[] args) {
        String[] inputs = { "300", "900", "abc", "200" };
        for (String in : inputs) {
            try {
                sell(500, in);
            } catch (SaleException e) {
                IO.println(e.getMessage() + ": " + e.getCause().getMessage());
            }
        }
        IO.println("fin");
    }
}

a) ¿Qué imprime el programa y cómo termina? ¿Se procesa la entrada "200"? ¿Se imprime "fin"? Justifiquen siguiendo cada excepción desde donde se lanza hasta donde se captura (o hasta que nadie la captura).

b) ¿Por qué sell tiene que declarar throws SaleException, y main compila sin capturar ni declarar la excepción que lanza Long.parseLong?

c) En la segunda venta, ¿por qué "fin de venta 900" sale antes que "venta rechazada: ..."? Si sell lanzara new SaleException("venta rechazada", null), ¿qué cambiaría en la salida?

Pregunta 2 — try-with-resources

public class F2Resources {

    static class Resource implements AutoCloseable {
        private final String name;
        private final boolean failOnClose;

        Resource(String name, boolean failOnOpen, boolean failOnClose) {
            if (failOnOpen) {
                throw new IllegalStateException("no se pudo abrir " + name);
            }
            this.name = name;
            this.failOnClose = failOnClose;
            IO.println("abre " + name);
        }

        void use() {
            IO.println("usa " + name);
        }

        @Override
        public void close() {
            IO.println("cierra " + name);
            if (failOnClose) {
                throw new IllegalStateException("falla al cerrar " + name);
            }
        }
    }

    public static void main(String[] args) {
        IO.println("-- caso 1");
        try (Resource log = new Resource("log", false, false);
             Resource printer = new Resource("impresora", false, true)) {
            log.use();
            printer.use();
            IO.println("venta guardada");
        } catch (IllegalStateException e) {
            IO.println("capturada: " + e.getMessage()
                    + " (suprimidas: " + e.getSuppressed().length + ")");
        }

        IO.println("-- caso 2");
        try (Resource log = new Resource("log", false, false);
             Resource net = new Resource("red", true, false)) {
            log.use();
            net.use();
        } catch (IllegalStateException e) {
            IO.println("capturada: " + e.getMessage());
        } finally {
            IO.println("finally");
        }

        IO.println("-- caso 3");
        try (Resource log = new Resource("log", false, true)) {
            log.use();
            throw new IllegalArgumentException("línea 3 mal formada");
        } catch (RuntimeException e) {
            IO.println("capturada: " + e.getClass().getSimpleName() + ": " + e.getMessage());
            for (Throwable s : e.getSuppressed()) {
                IO.println("suprimida: " + s.getMessage());
            }
        }
    }
}

a) ¿Qué imprime cada uno de los tres casos?

b) En el caso 2, ¿por qué no aparece "cierra red"? En el caso 3, ¿por qué la excepción que llega al catch es la del bloque y no la de close(), y dónde queda esta última?

Pregunta 3 — Claves, equals y hashCode

import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

public class F3Keys {

    record Song(String title, String artist, int seconds) { }

    /** Un código de producto con equals... y sin hashCode. */
    @SuppressWarnings("overrides")
    static final class Sku {
        private final String code;

        Sku(String code) {
            this.code = code;
        }

        @Override
        public boolean equals(Object o) {
            return o instanceof Sku other && code.equals(other.code);
        }
    }

    public static void main(String[] args) {
        Map<Song, Integer> plays = new HashMap<>();
        Song garua = new Song("Garúa", "Costa Verde", 220);
        plays.put(garua, 1);
        Song again = new Song("Garúa", "Costa Verde", 220);
        plays.put(again, plays.getOrDefault(again, 0) + 1);
        IO.println("1. " + plays.size() + " " + plays.get(new Song("Garúa", "Costa Verde", 220)));

        Map<Sku, Integer> stock = new HashMap<>();
        stock.put(new Sku("A-1"), 10);
        stock.put(new Sku("A-1"), 20);
        IO.println("2. " + stock.size() + " " + stock.get(new Sku("A-1"))
                + " " + new Sku("A-1").equals(new Sku("A-1")));

        List<Song> songs = List.of(
                new Song("Malecón", "Costa Verde", 190),
                new Song("Cumbre", "Los Andinos", 190),
                new Song("Arena", "Duna", 185));
        Set<Song> byLength = new TreeSet<>(Comparator.comparingInt(Song::seconds));
        byLength.addAll(songs);
        Set<Song> hashed = new HashSet<>(songs);
        IO.println("3. " + byLength.size() + " " + hashed.size());
        IO.println("4. " + byLength.contains(new Song("Otra", "Nadie", 185)));
    }
}

a) ¿Qué imprime cada una de las cuatro líneas? Justifiquen.

b) En la línea 2, equals dice que las dos claves son iguales. ¿Por qué el mapa tiene dos entradas y get devuelve null? ¿Qué advierte javac -Xlint:all si se borra el @SuppressWarnings? Corrijan Sku.

c) En las líneas 3 y 4, ¿por qué el TreeSet y el HashSet tienen tamaños distintos con los mismos elementos? ¿Qué propiedad le falta al comparador? Corríjanlo para que dos canciones distintas no se pierdan.

Pregunta 4 — Un stream paso a paso

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class F4Streams {

    record Song(String title, String artist, int seconds) { }

    public static void main(String[] args) {
        List<Song> songs = List.of(
                new Song("Garúa", "Costa Verde", 220),
                new Song("Puente de piedra", "Los Andinos", 245),
                new Song("Arena", "Duna", 185),
                new Song("Brisa", "Duna", 210),
                new Song("Cumbre", "Los Andinos", 190));

        List<String> shortest = songs.stream()
                .filter(s -> {
                    IO.println("filter " + s.title());
                    return s.seconds() < 230;
                })
                .sorted(Comparator.comparingInt(Song::seconds))
                .map(s -> {
                    IO.println("map " + s.title());
                    return s.title();
                })
                .limit(2)
                .toList();
        IO.println(shortest);

        Map<String, Long> perArtist = songs.stream()
                .collect(Collectors.groupingBy(Song::artist, TreeMap::new, Collectors.counting()));
        IO.println(perArtist);
    }
}

a) ¿Qué imprime el programa? Expliquen por qué todos los filter salen antes del primer map, y por qué hay solo dos map.

b) ¿Qué cambia en la última línea si se quita TreeMap::new del groupingBy?

Pregunta 5 — Genéricos y wildcards

Con estos dos métodos y estas variables:

static double sum(List<? extends Number> xs) { ... }
static void addZeros(List<? super Integer> xs, int n) { ... }

List<Integer> ints = new ArrayList<>(List.of(1, 2));
List<Number> nums = new ArrayList<>();
List<Object> objs = new ArrayList<>();
List<? extends Number> view = ints;
Object o = ints;

a) Digan cuáles de estas líneas compilan, cada una por separado. Para las que no, expliquen por qué.

sum(ints);                                  // (a)
addZeros(nums, 2);                          // (b)
addZeros(objs, 2);                          // (c)
sum(objs);                                  // (d)
view.add(3);                                // (e)
Number first = view.get(0);                 // (f)
boolean b = o instanceof List<Integer>;     // (g)

b) ¿Por qué no compila una clase con estos dos métodos?

static void print(List<String> names) { }
static void print(List<Integer> numbers) { }

Pregunta 6 — Eventos y estados

La caja de la bodega reacciona a eventos de texto según su estado:

import java.util.List;

public class F6Register {

    enum State { CLOSED, OPEN, SELLING }

    static class Register {
        private State state = State.CLOSED;
        private int sales = 0;

        String handle(String event) {
            return switch (state) {
                case CLOSED -> event.equals("abrir") ? go(State.OPEN, "caja abierta") : "caja cerrada";
                case OPEN -> switch (event) {
                    case "vender" -> go(State.SELLING, "nueva venta");
                    case "cerrar" -> go(State.CLOSED, "cierre, ventas: " + sales);
                    default -> "no aplica";
                };
                case SELLING -> switch (event) {
                    case "confirmar" -> confirm();
                    case "cancelar" -> go(State.OPEN, "venta cancelada");
                    default -> "termine la venta primero";
                };
            };
        }

        private String confirm() {
            sales++;
            return go(State.OPEN, "venta " + sales + " registrada");
        }

        private String go(State next, String message) {
            state = next;
            return message;
        }
    }

    public static void main(String[] args) {
        Register r = new Register();
        for (String e : List.of("vender", "abrir", "vender", "cerrar", "confirmar",
                "vender", "cancelar", "abrir", "cerrar")) {
            IO.println(e + " -> " + r.handle(e));
        }
    }
}

a) ¿Qué imprime el programa?

b) Se agrega SUSPENDED al enum State. ¿Qué pasa al compilar? ¿Pasaría lo mismo si handle usara una sentencia switch (con case ...: y return en cada rama) en lugar de una expresión?

Parte B — Escribir código

Pregunta 7 — Una clase genérica

Escriban Tally<T>, que cuenta cuántas veces aparece cada elemento:

  • void add(T item): cuenta una aparición. Rechaza null con NullPointerException.
  • void addAll(Collection<...> items): cuenta todos. Tiene que aceptar una colección de T o de cualquier subtipo de T; por ejemplo, un Tally<Number> acepta una List<Integer>.
  • int count(T item): cuántas veces apareció (0 si nunca).
  • int distinct(): cuántos elementos distintos hay.
  • Optional<T> mostCommon(): el más frecuente; si empatan, el que apareció primero; vacío si no se agregó nada.

a) Escriban la clase. Elijan la colección interna y justifiquen la elección en una línea.

b) Escriban cuatro pruebas JUnit: repeticiones y un elemento que no está; addAll con una lista de un subtipo; mostCommon vacío; y el empate.

Pregunta 8 — Consultas con streams

Los viajes de una app de taxis:

/** Un viaje de una app de taxis: tarifa en céntimos. */
public record Trip(String driver, String district, int minutes, long fareCents) {
    public Trip {
        if (driver == null || driver.isBlank() || district == null || district.isBlank()) {
            throw new IllegalArgumentException("conductor y distrito son obligatorios");
        }
        if (minutes <= 0 || fareCents < 0) {
            throw new IllegalArgumentException("minutos o tarifa inválidos");
        }
    }
}

Datos de ejemplo:

List<Trip> trips = List.of(
        new Trip("rosa", "Miraflores", 25, 1850),
        new Trip("luis", "Surco", 40, 2600),
        new Trip("rosa", "Surco", 15, 1100),
        new Trip("ana", "Miraflores", 40, 2900),
        new Trip("luis", "Lince", 10, 800));

Escriban con streams, en una clase TripReports:

a) long totalFare(List<Trip> trips). Con los datos: 9250.

b) Map<String, Long> fareByDriver(List<Trip> trips), ordenado por conductor. Con los datos: {ana=2900, luis=3400, rosa=2950}.

c) Optional<String> busiestDistrict(List<Trip> trips): el distrito con más viajes; si empatan, el primero en orden alfabético; vacío si no hay viajes. Con los datos: Miraflores.

d) List<Trip> longestFirst(List<Trip> trips): de mayor a menor duración y, a igual duración, por conductor. Con los datos, los conductores quedan en el orden ana, luis, rosa, rosa, luis.

e) Un compañero ordena por tarifa con (x, y) -> (int) (x.fareCents() - y.fareCents()). ¿Qué puede salir mal? Escriban la versión correcta.

Pregunta 9 — Leer un CSV

El archivo tiene un encabezado y una línea por viaje:

conductor,distrito,minutos,tarifa_centimos
rosa,Miraflores,25,1850
luis,Surco,40,2600

Ya existe esta excepción del dominio:

/** Una línea del CSV que no se puede convertir en un viaje. */
public class MalformedLineException extends RuntimeException {
    private static final long serialVersionUID = 1L;
    private final int lineNumber;

    public MalformedLineException(int lineNumber, String message, Throwable cause) {
        super("línea " + lineNumber + ": " + message, cause);
        this.lineNumber = lineNumber;
    }

    public int lineNumber() {
        return lineNumber;
    }
}

a) Escriban, en una clase TripCsv, static Trip parse(String line, int lineNumber), que lance MalformedLineException si la línea no tiene cuatro campos o si un campo numérico no es válido (sin perder la excepción original), y static List<Trip> load(Path path) throws IOException, que salte el encabezado y cierre el archivo aunque haya un error.

b) ¿Por qué load declara throws IOException en lugar de capturarla? ¿Dónde la capturarían?

Parte C — Diseño

Pregunta 10 — Principios y refactoring

import java.util.List;

public class LowStockReport {

    public void send(List<Product> products, String email) {
        StringBuilder sb = new StringBuilder();
        for (Product p : products) {
            if (p.stock() < 5) {
                switch (p.kind()) {
                    case "unidad" -> sb.append(p.name()).append(": ").append(p.stock()).append(" u\n");
                    case "peso" -> sb.append(p.name()).append(": ").append(p.stock()).append(" kg\n");
                    default -> sb.append(p.name()).append(": ?\n");
                }
            }
        }
        if (!sb.isEmpty()) {
            new SmtpMailer("smtp.bodega.pe").send(email, "Stock bajo", sb.toString());
        }
    }
}

Product es un record (String name, String kind, int stock); SmtpMailer envía correos de verdad a través de un servidor.

a) Nombren tres problemas de diseño de esta clase. Para cada uno, digan qué principio SOLID (o qué code smell) corresponde y qué cambio concreto de requisitos lo haría doler.

b) La clase no tiene pruebas. Describan, en orden, los primeros pasos pequeños de un refactoring seguro. ¿Qué hacen antes de mover nada?

c) Escriban la versión final de LowStockReport, en la que el envío entra por el constructor como una interfaz Mailer, y una prueba JUnit que verifique el correo enviado sin usar ningún servidor.

Pregunta 11 — Patrones: cuándo sí y cuándo no

Para cada situación, digan qué patrón usarían (Strategy, Observer, Factory, Composite o Decorator) o si no hace falta ninguno, y justifiquen en una o dos líneas.

  1. Cuando el stock de un producto baja de un umbral, la bodega quiere avisar al celular de la dueña y anotarlo en un registro. Mañana podría agregarse un correo.
  2. El precio de una venta se calcula con una promoción que elige el cajero: ninguna, 10 % los martes o “lleve 3 pague 2”.
  3. Una “canasta familiar” contiene productos y otras canastas, y su precio es la suma de lo que contiene menos un descuento propio.
  4. Al leer el CSV, la columna tipo decide si se crea un producto por unidad o por peso. Hoy esa decisión está copiada en el importador y en el menú.
  5. Se quiere medir cuánto demora cada llamada a save de un repositorio, sin tocar la clase del repositorio.
  6. El IGV es 18 % y se calcula en un solo lugar del programa. Alguien propone una interfaz TaxStrategy “por si cambia”.

Parte D — Preguntas cortas

Respondan cada una en tres o cuatro líneas.

Pregunta 12

¿Qué pierde el programa con el type erasure? Den un ejemplo de algo que no se puede escribir dentro de una clase genérica Box<T> por esa razón.

Pregunta 13

max de un stream devuelve Optional<T> en lugar de T o null. ¿Qué gana quien lo llama? ¿Por qué llamar a get() sin verificar no mejora nada?

Pregunta 14

¿Cuántos campos devuelve line.split(",") si line es "Arroz Costeño, 5 kg",1850,10 (con las comillas dentro del texto)? Propongan una regla para el formato CSV de la bodega que evite el problema.

Pregunta 15

Un ReadOnlySongRepository implements SongRepository (la interfaz de la semana 15, con load y save) lanza UnsupportedOperationException en save. ¿Qué principios SOLID se violan? ¿Cómo cambiarían las interfaces?

Lista de verificación: resultados de aprendizaje

Después de resolver el examen y comparar sus respuestas, marquen lo que ya pueden hacer. Cada fila es un resultado de aprendizaje del programa del curso, con las semanas donde se trabaja y las preguntas de este examen que lo ejercitan. Si una fila no la pueden marcar todavía, vuelvan a esas semanas antes del final.

Puedo… Semanas Preguntas
… modelar un problema con clases y objetos: identificar responsabilidades, estado e invariantes, y protegerlos con encapsulamiento. 1–4 7a, 10c
… explicar cómo se representan los objetos en memoria (referencias, heap, garbage collector) y distinguir identidad de igualdad. 2, 5, 11 3
… usar herencia, clases abstractas e interfaces, explicar el dynamic dispatch y elegir entre herencia y composición con el principio de sustitución de Liskov. 5–8 10c, 11, 15
… escribir código robusto con excepciones: lanzarlas, propagarlas, liberar recursos y elegir entre checked y unchecked, también al leer archivos. 9, 15 1, 2, 9, 14
… escribir código reutilizable con genéricos y el Collections Framework. 10, 11 3, 5, 7a, 12
… escribir consultas con lambdas y streams, y usar Optional. 12 4, 8, 13
… aplicar principios de diseño (SOLID, bajo acoplamiento, alta cohesión) y patrones de diseño clásicos, y reconocer cuándo no hacen falta. 13–16 6, 10a, 11, 15
… escribir unit tests con JUnit y refactorizar con ellos como red de seguridad. 3, 7, 13 7b, 10b, 10c