Java OO & Functional/Mini-challenge: Sở thú — sealed + record + pattern matching
9/36
Bài 9 / 36~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 Kế thừa & Đa hình — 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 01 — Kế thừa & Đa hình. 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 4 animals
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 / implementsBài 03Record implements Animal
Override + dynamic dispatchBài 04name() override mỗi record
Interface + default methodBài 06description() default trong interface
Sealed classBài 07sealed interface Animal permits ...
Composition over inheritanceBài 08Zoo chứa list animal, không extends
Record + compact constructorBài 07 (sealed + record)Validate cân nặng, tuổi dương
Switch pattern matchingBài 07dailyFoodKg, 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 --release 21 ZooApp.java
java ZooApp

Switch pattern matching đã final từ Java 21 (JEP 441) — không cần flag --enable-preview.

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) {
        Objects.requireNonNull(name, "name must not be null");
        if (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 08). 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 — Kiểm thử tự động (Unit Tests):

Để tự động hóa việc xác minh tính chính xác của chương trình, bạn hãy tạo một lớp kiểm thử bằng JUnit 5 dưới đây trong dự án của mình. Trải nghiệm nhìn thấy toàn bộ các test case chuyển sang màu xanh (Green) sẽ củng cố niềm tin và tư duy thiết kế vững chắc:


import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Map;

// Record Lion/Elephant/Snake nested trong ZooApp — phai qualify ZooApp.Lion
// (hoac static import) de test compile duoc.
class ZooAppTest {
  @Test
  void testLionFoodCalculation() {
      ZooApp.Lion lion = new ZooApp.Lion("Simba", 100.0, 5, true);
      assertEquals(5.0, ZooApp.dailyFoodKg(lion), 0.001, "lion eats 5% of body weight");
  }

  @Test
  void testElephantFoodCalculation() {
      ZooApp.Elephant elephant = new ZooApp.Elephant("Dumbo", 1000.0, 10, 50.0);
      assertEquals(25.0, ZooApp.dailyFoodKg(elephant), 0.001, "elephant eats 2% of weight + 0.1 per tusk cm");
  }

  @Test
  void testVenomousSnakeFoodCalculation() {
      ZooApp.Snake venomous = new ZooApp.Snake("Slither", 10.0, 3, true);
      ZooApp.Snake harmless = new ZooApp.Snake("Sly", 10.0, 3, false);
      assertEquals(0.15, ZooApp.dailyFoodKg(venomous), 0.001, "venomous snake eats 1.5% of body weight");
      assertEquals(0.1, ZooApp.dailyFoodKg(harmless), 0.001, "harmless snake eats 1% of body weight");
  }

  @Test
  void testConstructorValidation() {
      assertThrows(IllegalArgumentException.class, () -> new ZooApp.Lion("Simba", -10.0, 5, true), "weight must be positive");
      assertThrows(IllegalArgumentException.class, () -> new ZooApp.Lion("Simba", 100.0, -1, true), "age must be non-negative");
      assertThrows(NullPointerException.class, () -> new ZooApp.Lion(null, 100.0, 5, true), "name must not be null");
      assertThrows(IllegalArgumentException.class, () -> new ZooApp.Lion("", 100.0, 5, true), "name must not be blank");
  }

  @Test
  void testZooOperations() {
      ZooApp.Zoo zoo = new ZooApp.Zoo();
      zoo.addAnimal(new ZooApp.Lion("Simba", 180.0, 5, true));
      zoo.addAnimal(new ZooApp.Elephant("Dumbo", 3500.0, 10, 80));
      
      assertEquals(2, zoo.totalAnimals());
      assertEquals(3680.0, zoo.totalWeight(), 0.001);
      
      Map<String, Long> countMap = zoo.countBySpecies();
      assertEquals(1L, countMap.get("Lion"));
      assertEquals(1L, countMap.get("Elephant"));
  }
}

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 01 — Kế thừa & Đa hình! Bạn đã có đầy đủ công cụ OOP + kế thừa + pattern matching để mô hình domain phức tạp. Module tiếp theo — Exception handling — sẽ bàn về 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