Java — Từ Zero đến Senior/Kế thừa & Đa hình/Mini-challenge: Sở thú — sealed + record + pattern matching
7/7
~30 phútKế thừa & Đa hình

Mini-challenge: Sở thú — sealed + record + pattern matching

Bài thực hành khép lại Module 6 — mô hình sở thú với sealed interface Animal, record cho mỗi loài, switch pattern matching để tính khẩu phần ăn. Áp dụng ADT đầy đủ của Java 21.

Đây là mini-challenge khép lại Module 6. Bạn sẽ thiết kế một sở thú nhỏ với 4 loài động vật: Lion, Elephant, Penguin, Snake. Mỗi loài có dữ liệu riêng (cân nặng, tuổi, đặc trưng) và cách tính khẩu phần ăn khác nhau. Yêu cầu dùng sealed interface + record + switch pattern matching — công cụ mới của Java 21 để làm ADT (algebraic data type) đầy đủ.

Mục tiêu không chỉ "chạy được" — mà làm cho compiler giúp bạn: thêm loài mới → compiler tự bắt mọi switch chưa update; không bao giờ miss case.

🎯 Đề bài

Thiết kế domain sở thú với yêu cầu:

1. sealed interface Animal

  • permits Lion, Elephant, Penguin, Snake.
  • abstract method String name() — tên đặt cho cá thể.
  • default method String description() trả "Unknown animal" — mặc định, mỗi loài override khi cần.

2. 4 record implement Animal

  • record Lion(String name, double weightKg, int age, boolean isMale) — sư tử.
  • record Elephant(String name, double weightKg, int age, double tuskLengthCm) — voi với độ dài ngà.
  • record Penguin(String name, double weightKg, int age, int colonyId) — chim cánh cụt thuộc đàn ID.
  • record Snake(String name, double weightKg, int age, boolean isVenomous) — rắn có/không độc.

3. class Zoo quản lý animal

  • Field private final List<Animal> animals.
  • Constructor khởi tạo list rỗng.
  • void addAnimal(Animal a) — thêm vào list, throw nếu null.
  • int totalAnimals(), double totalWeight().
  • List<Animal> olderThan(int minAge) — trả snapshot immutable, filter theo tuổi.
  • Map<String, Long> countBySpecies() — đếm mỗi loài (key là tên loài: "Lion", "Elephant"...).

4. Utility static method

  • static double dailyFoodKg(Animal a) — khẩu phần ăn hàng ngày (kg):

    • Lion: weight * 0.05 (5% cân nặng).
    • Elephant: weight * 0.02 + tuskLength * 0.1 (2% + bonus cho ngà lớn).
    • Penguin: weight * 0.1 (10%).
    • Snake: weight * 0.01 nếu không độc, weight * 0.015 nếu độc.
  • Dùng switch pattern matching không có default — để compiler đảm bảo exhaustive.

  • static String describeAnimal(Animal a) — mô tả 1 dòng kèm thông tin riêng của loài, cũng dùng switch pattern matching + deconstruction.

Output mẫu:

Added Lion(Simba, 180.0kg, 5yo, male)
Added Elephant(Dumbo, 3500.0kg, 10yo, tusk=80cm)
Added Penguin(Pingu, 15.0kg, 3yo, colony=42)
Added Snake(Slither, 4.0kg, 2yo, venomous)

Zoo: 4 animals, total weight 3699.0kg
Daily food:
  Simba: 9.00 kg
  Dumbo: 78.00 kg
  Pingu: 1.50 kg
  Slither: 0.06 kg

Older than 4 years old:
  Lion Simba (5 yo)
  Elephant Dumbo (10 yo)

Species count: {Lion=1, Elephant=1, Penguin=1, Snake=1}

📦 Concept dùng trong bài

ConceptBàiDùng ở đây
extends / implementsModule 6, bài 1Record implements Animal
Override + dynamic dispatchModule 6, bài 2name() override mỗi record
Interface + default methodModule 6, bài 4description() default trong interface
Sealed classModule 6, bài 5sealed interface Animal permits ...
Composition over inheritanceModule 6, bài 6Zoo chứa list animal, không extends
Record + compact constructorModule 5, bài 6Validate cân nặng, tuổi dương
Switch pattern matchingModule 3, bài 2dailyFoodKg, describeAnimal
Stream API(preview)filter, collect, groupingBy

▶️ Starter code

import java.util.*;
import java.util.stream.Collectors;

public class ZooApp {

    // TODO: sealed interface Animal permits Lion, Elephant, Penguin, Snake
    //       voi abstract String name() va default String description()

    // TODO: 4 record implement Animal voi compact constructor validate
    //       weightKg > 0, age >= 0

    // TODO: class Zoo voi field private final List<Animal> animals

    public static void main(String[] args) {
        Zoo zoo = new Zoo();

        Lion simba = new Lion("Simba", 180.0, 5, true);
        Elephant dumbo = new Elephant("Dumbo", 3500.0, 10, 80);
        Penguin pingu = new Penguin("Pingu", 15.0, 3, 42);
        Snake slither = new Snake("Slither", 4.0, 2, true);

        zoo.addAnimal(simba);
        zoo.addAnimal(dumbo);
        zoo.addAnimal(pingu);
        zoo.addAnimal(slither);
        System.out.println("Added 4 animals");

        System.out.printf("Zoo: %d animals, total weight %.1fkg%n",
            zoo.totalAnimals(), zoo.totalWeight());

        System.out.println("Daily food:");
        zoo.animals().forEach(a ->
            System.out.printf("  %s: %.2f kg%n", a.name(), dailyFoodKg(a)));

        System.out.println("Older than 4 years old:");
        zoo.olderThan(4).forEach(a -> System.out.println("  " + describeAnimal(a)));

        System.out.println("Species count: " + zoo.countBySpecies());
    }

    // TODO: static double dailyFoodKg(Animal a) — switch pattern matching

    // TODO: static String describeAnimal(Animal a) — switch pattern matching with deconstruction
}
javac --enable-preview --release 21 ZooApp.java
java --enable-preview ZooApp

Java 21+ không cần --enable-preview cho switch pattern matching — đã final.

Dành 25–30 phút tự làm. Gợi ý phía dưới.


💡 Gợi ý

💡 💡 Gợi ý — đọc khi bị kẹt

Sealed interface với default method:

public sealed interface Animal permits Lion, Elephant, Penguin, Snake {
    String name();

    default String description() {
        return "Unknown animal";
    }
}

Record với compact constructor validate:

public record Lion(String name, double weightKg, int age, boolean isMale) implements Animal {
    public Lion {
        if (name == null || name.isBlank())
            throw new IllegalArgumentException("name must not be blank");
        if (weightKg <= 0)
            throw new IllegalArgumentException("weightKg must be positive");
        if (age < 0)
            throw new IllegalArgumentException("age must be non-negative");
    }
}
// lap lai tuong tu cho Elephant, Penguin, Snake

Zoo class:

public class Zoo {
    private final List<Animal> animals = new ArrayList<>();

    public void addAnimal(Animal a) {
        Objects.requireNonNull(a);
        animals.add(a);
    }

    public int totalAnimals() { return animals.size(); }

    public double totalWeight() {
        return animals.stream()
            .mapToDouble(a -> switch (a) {
                case Lion l -> l.weightKg();
                case Elephant e -> e.weightKg();
                case Penguin p -> p.weightKg();
                case Snake s -> s.weightKg();
            })
            .sum();
    }

    public List<Animal> animals() {
        return List.copyOf(animals);   // immutable snapshot
    }

    public List<Animal> olderThan(int minAge) {
        return animals.stream()
            .filter(a -> switch (a) {
                case Lion l -> l.age() > minAge;
                case Elephant e -> e.age() > minAge;
                case Penguin p -> p.age() > minAge;
                case Snake s -> s.age() > minAge;
            })
            .toList();
    }

    public Map<String, Long> countBySpecies() {
        return animals.stream()
            .collect(Collectors.groupingBy(
                a -> a.getClass().getSimpleName(),
                Collectors.counting()
            ));
    }
}

dailyFoodKg — exhaustive switch:

static double dailyFoodKg(Animal a) {
    return switch (a) {
        case Lion l -> l.weightKg() * 0.05;
        case Elephant e -> e.weightKg() * 0.02 + e.tuskLengthCm() * 0.1;
        case Penguin p -> p.weightKg() * 0.1;
        case Snake s -> s.isVenomous() ? s.weightKg() * 0.015 : s.weightKg() * 0.01;
    };
}

describeAnimal với deconstruction:

static String describeAnimal(Animal a) {
    return switch (a) {
        case Lion l -> "Lion " + l.name() + " (" + l.age() + " yo)";
        case Elephant e -> "Elephant " + e.name() + " (" + e.age() + " yo, tusk " + e.tuskLengthCm() + "cm)";
        case Penguin p -> "Penguin " + p.name() + " from colony " + p.colonyId();
        case Snake s -> "Snake " + s.name() + (s.isVenomous() ? " (venomous)" : " (harmless)");
    };
}

✅ Lời giải

ℹ️ ✅ Lời giải — xem sau khi đã thử

import java.util.*;
import java.util.stream.Collectors;

public class ZooApp {

    public sealed interface Animal permits Lion, Elephant, Penguin, Snake {
        String name();

        default String description() {
            return "Unknown animal";
        }
    }

    public record Lion(String name, double weightKg, int age, boolean isMale) implements Animal {
        public Lion {
            validateCommon(name, weightKg, age);
        }
    }

    public record Elephant(String name, double weightKg, int age, double tuskLengthCm) implements Animal {
        public Elephant {
            validateCommon(name, weightKg, age);
            if (tuskLengthCm < 0)
                throw new IllegalArgumentException("tuskLengthCm must be non-negative");
        }
    }

    public record Penguin(String name, double weightKg, int age, int colonyId) implements Animal {
        public Penguin {
            validateCommon(name, weightKg, age);
            if (colonyId < 0)
                throw new IllegalArgumentException("colonyId must be non-negative");
        }
    }

    public record Snake(String name, double weightKg, int age, boolean isVenomous) implements Animal {
        public Snake {
            validateCommon(name, weightKg, age);
        }
    }

    private static void validateCommon(String name, double weightKg, int age) {
        if (name == null || name.isBlank())
            throw new IllegalArgumentException("name must not be blank");
        if (weightKg <= 0)
            throw new IllegalArgumentException("weightKg must be positive, got " + weightKg);
        if (age < 0)
            throw new IllegalArgumentException("age must be non-negative, got " + age);
    }

    public static class Zoo {
        private final List<Animal> animals = new ArrayList<>();

        public void addAnimal(Animal a) {
            Objects.requireNonNull(a, "animal must not be null");
            animals.add(a);
        }

        public int totalAnimals() {
            return animals.size();
        }

        public double totalWeight() {
            return animals.stream()
                .mapToDouble(a -> switch (a) {
                    case Lion l -> l.weightKg();
                    case Elephant e -> e.weightKg();
                    case Penguin p -> p.weightKg();
                    case Snake s -> s.weightKg();
                })
                .sum();
        }

        public List<Animal> animals() {
            return List.copyOf(animals);
        }

        public List<Animal> olderThan(int minAge) {
            return animals.stream()
                .filter(a -> switch (a) {
                    case Lion l -> l.age() > minAge;
                    case Elephant e -> e.age() > minAge;
                    case Penguin p -> p.age() > minAge;
                    case Snake s -> s.age() > minAge;
                })
                .toList();
        }

        public Map<String, Long> countBySpecies() {
            return animals.stream()
                .collect(Collectors.groupingBy(
                    a -> a.getClass().getSimpleName(),
                    Collectors.counting()
                ));
        }
    }

    public static double dailyFoodKg(Animal a) {
        return switch (a) {
            case Lion l -> l.weightKg() * 0.05;
            case Elephant e -> e.weightKg() * 0.02 + e.tuskLengthCm() * 0.1;
            case Penguin p -> p.weightKg() * 0.1;
            case Snake s -> s.isVenomous() ? s.weightKg() * 0.015 : s.weightKg() * 0.01;
        };
    }

    public static String describeAnimal(Animal a) {
        return switch (a) {
            case Lion l -> String.format("Lion %s (%d yo)", l.name(), l.age());
            case Elephant e -> String.format("Elephant %s (%d yo, tusk %.0fcm)",
                e.name(), e.age(), e.tuskLengthCm());
            case Penguin p -> String.format("Penguin %s from colony %d", p.name(), p.colonyId());
            case Snake s -> String.format("Snake %s (%s)",
                s.name(), s.isVenomous() ? "venomous" : "harmless");
        };
    }

    public static void main(String[] args) {
        Zoo zoo = new Zoo();

        Lion simba = new Lion("Simba", 180.0, 5, true);
        Elephant dumbo = new Elephant("Dumbo", 3500.0, 10, 80);
        Penguin pingu = new Penguin("Pingu", 15.0, 3, 42);
        Snake slither = new Snake("Slither", 4.0, 2, true);

        zoo.addAnimal(simba);
        zoo.addAnimal(dumbo);
        zoo.addAnimal(pingu);
        zoo.addAnimal(slither);
        System.out.println("Added 4 animals");

        System.out.printf("Zoo: %d animals, total weight %.1fkg%n",
            zoo.totalAnimals(), zoo.totalWeight());

        System.out.println("Daily food:");
        zoo.animals().forEach(a ->
            System.out.printf("  %s: %.2f kg%n", a.name(), dailyFoodKg(a)));

        System.out.println("Older than 4 years old:");
        zoo.olderThan(4).forEach(a -> System.out.println("  " + describeAnimal(a)));

        System.out.println("Species count: " + zoo.countBySpecies());
    }
}

Giải thích từng phần:

  • sealed interface Animal permits Lion, Elephant, Penguin, Snake — 4 loài đóng kín. Thêm loài mới phải update permits → compiler bắt mọi switch chưa phủ case mới. Đây là ưu điểm lớn nhất của sealed.

  • Record cho mỗi loài — immutable, tự sinh constructor/accessor/equals/hashCode. Validate trong compact constructor: tên không blank, cân nặng dương, tuổi không âm. validateCommon là helper static để không lặp code.

  • class Zoo — composition với List<Animal>, KHÔNG extends ArrayList (theo bài 6). Method animals() trả List.copyOf — snapshot immutable, bảo vệ encapsulation.

  • Switch pattern matching trong 4 chỗ (totalWeight, olderThan, dailyFoodKg, describeAnimal) — không có default vì compiler biết 4 case phủ đủ nhờ sealed. Thêm Parrot vào permits → 4 switch compile error ngay lập tức, IDE chỉ vị trí cần update.

  • dailyFoodKgdescribeAnimal — logic khác nhau theo loài, giữ ngoài record. Lựa chọn thiết kế: có thể đặt là method trong Animal interface (mỗi record override) — cả 2 cách đều hợp lý. Đặt ngoài vì:

    • Giữ record thuần data holder.
    • Business logic (công thức food) có thể thay đổi, không muốn entity chịu trách nhiệm.
  • countBySpecies — dùng Collectors.groupingBy với getClass().getSimpleName() làm key và Collectors.counting() làm value. Output {Lion=1, Elephant=1, ...}.


🎓 Mở rộng

Mức 1 — Thêm loài mới (Parrot):

Thêm Parrot(String name, double weightKg, int age, boolean canTalk):

  1. Thêm vào permits Lion, Elephant, Penguin, Snake, Parrot.
  2. Tạo record.
  3. Compiler ngay lập tức báo lỗi 4 switch — update từng chỗ. Đây là điểm mạnh sealed.

Mức 2 — Chuyển logic vào record với polymorphism:

public sealed interface Animal permits Lion, Elephant, Penguin, Snake {
    String name();
    double weightKg();
    int age();
    double dailyFoodKg();           // thay vi static function
}

public record Lion(...) implements Animal {
    @Override public double dailyFoodKg() { return weightKg() * 0.05; }
}

Trade-off: nếu logic là domain behaviour, đặt trong record OK. Nếu là application logic (tính phí, report), tách ra ngoài.

Mức 3 — Unit test:

@Test
void lionFoodIsFivePercent() {
    assertEquals(5.0, ZooApp.dailyFoodKg(new Lion("X", 100, 5, true)), 0.001);
}

@Test
void venomousSnakeEatsMore() {
    double v = ZooApp.dailyFoodKg(new Snake("V", 10, 5, true));
    double h = ZooApp.dailyFoodKg(new Snake("H", 10, 5, false));
    assertTrue(v > h);
}

@Test
void negativeWeightRejects() {
    assertThrows(IllegalArgumentException.class,
        () -> new Lion("X", -1, 5, true));
}

Mức 4 — Thêm abstract method vào sealed (ràng buộc polymorphism):

public sealed interface Animal permits ... {
    String name();
    int age();

    default String species() {
        return getClass().getSimpleName();
    }
}

Mỗi record tự có species() qua reflection — tiện cho log/report.

Mức 5 — Visitor pattern không cần:

Với sealed + pattern matching, visitor pattern cổ điển không còn cần. Mỗi "visitor" là một static method với switch exhaustive — ngắn hơn, type-safe hơn. Đây là lý do pattern matching đổi cách code OOP.

✨ Điều bạn vừa làm được

Hoàn thành mini-challenge này, bạn đã:

  • Thiết kế ADT đầy đủ trong Java 21: sealed interface + record + switch pattern matching. Tập subtype đóng kín, compiler kiểm tra exhaustive.
  • Áp dụng composition over inheritance cho Zoo — class chứa list animal, không extends ArrayList. Encapsulation qua List.copyOf snapshot.
  • Validate input trong compact constructor record, throw IllegalArgumentException cho input sai.
  • Dùng switch pattern matching không có default — compiler buộc phủ mọi case. Thêm loài mới → mọi switch chưa update compile error ngay.
  • Tách domain data (record) khỏi business logic (static function) — record immutable, logic có thể thay đổi độc lập.
  • Stream API cơ bản: filter, mapToDouble, sum, groupingBy, counting, toList() immutable.

Chúc mừng — bạn đã hoàn thành Module 6! Bạn đã có đầy đủ công cụ OOP + kế thừa + pattern matching để mô hình domain phức tạp. Module 7 sẽ bước sang Exception handling — try/catch/finally, checked vs unchecked, try-with-resources, và vì sao "nuốt exception" luôn là anti-pattern.