Java OO & Functional/Mini-challenge: Sở thú — sealed + record + pattern matching
8/33
Bài 8 / 33~30 phútKế thừa & Đa hìnhMiễn phí lượt xem

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.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên