الأنواع العامّة

مشروع: مستودع جنيريك

15 دقيقة الدرس 10 من 13

مشروع: مستودع جنيريك

على مدار هذه الدورة درستَ الـ Generics قطعةً قطعة: الكلاسات الجنيرية، معاملات الأنواع المحدودة، الـ Wildcards، محو النوع، وقواعد الوراثة مع الـ Generics. في هذا الدرس الختامي تطبّق كل شيء دفعةً واحدة ببناء مستودع جنيريك — مخزن في الذاكرة قابل لإعادة الاستخدام، آمن من حيث الأنواع، يمكنه إدارة أي كيان تمرّره دون تكرار سطر واحد من منطق الحفظ.

هذا النمط يعكس ما تفعله أُطر العمل الحقيقية (Spring Data، Hibernate) خلف الكواليس. فهمه بيدك يجعل تلك الأُطر أقل سحرًا بكثير.

المشكلة بدون Generics

تخيّل أنك تحتاج مخزنًا في الذاكرة لكيان User وآخر لكيان Product. بدون Generics ستكتب كلاسَين متطابقَين تقريبًا، أو ستلجأ إلى قوائم Object الخام وتخسر كل الأمان في وقت الترجمة. الـ Generics تتيح لك كتابة المنطق مرة واحدة وجعل المُحرِّج يُجبر الأنواع الصحيحة في كل مكان استخدام.

الخطوة الأولى — تعريف عقد الكيان

كل كيان يديره المستودع يجب أن يمتلك معرِّفًا فريدًا. نعبّر عن هذا العقد بواجهة محدودة:

public interface Identifiable<ID extends Comparable<ID>> { ID getId(); }

القيد ID extends Comparable<ID> يعني أن المستودع يستطيع لاحقًا ترتيب الكيانات أو البحث عنها بحسب معرِّفاتها — دون أن يعرف ما إذا كانت المعرِّفات من نوع Long أو String أو غيرهما.

الخطوة الثانية — كلاسات الكيانات الملموسة

public record User(Long id, String name, String email) implements Identifiable<Long> { @Override public Long getId() { return id; } } public record Product(String sku, String title, double price) implements Identifiable<String> { @Override public String getId() { return sku; } }

الـ Records في Java 17 مثالية هنا: مضغوطة وثابتة (immutable) وخالية من الكود الزائد. لاحظ أن User يستخدم معرِّفًا من نوع Long بينما Product يستخدم رمزًا من نوع String — المستودع سيتعامل مع كليهما.

الخطوة الثالثة — واجهة المستودع الجنيرية

import java.util.List; import java.util.Optional; public interface Repository<T extends Identifiable<ID>, ID extends Comparable<ID>> { void save(T entity); Optional<T> findById(ID id); List<T> findAll(); void delete(ID id); int count(); }

معاملا نوع: T — نوع الكيان، مقيَّد بـ Identifiable<ID> — وID — نوع المفتاح، مقيَّد بـ Comparable<ID>. كل دالة مكتوبة بالأنواع الكاملة؛ لا تحويل (casting) ولا Object.

لماذا معاملا نوع؟ لو اقتصرت على T extends Identifiable (نوع خام) لفقدتَ نوع المعرِّف وأُجبرتَ على التحويل عند استدعاء getId(). المعامل الثاني ID يمرّر نوع المفتاح عبر كل توقيعات الدوال ويبقي كل شيء آمنًا.

الخطوة الرابعة — التنفيذ في الذاكرة

import java.util.*; public class InMemoryRepository<T extends Identifiable<ID>, ID extends Comparable<ID>> implements Repository<T, ID> { private final Map<ID, T> store = new LinkedHashMap<>(); @Override public void save(T entity) { store.put(entity.getId(), entity); } @Override public Optional<T> findById(ID id) { return Optional.ofNullable(store.get(id)); } @Override public List<T> findAll() { return Collections.unmodifiableList(new ArrayList<>(store.values())); } @Override public void delete(ID id) { store.remove(id); } @Override public int count() { return store.size(); } }

التنفيذ يعتمد على LinkedHashMap (يحفظ ترتيب الإدراج). إعادة Collections.unmodifiableList هي نمط النسخة الدفاعية: يحصل المستدعون على لقطة، لا على بوابة خلفية للمخزن الداخلي.

استخدم Optional بدلًا من إعادة null. تعيد findById قيمة Optional<T> فيُجبَر المستدعون على معالجة حالة "غير موجود" بشكل صريح، مما يقضي على فئة كاملة من أخطاء NullPointerException.

الخطوة الخامسة — توسيع المستودع بدالة جنيرية

المستودعات الحقيقية غالبًا تحتاج قدرات بحث. يمكنك إضافة دالة جنيرية تقبل شرط تصفية (predicate)، فيدعم الكلاس نفسه عمليات بحث عشوائية دون أن يعرف شيئًا عن شكل الكيان:

import java.util.function.Predicate; import java.util.stream.Collectors; public List<T> findWhere(Predicate<? super T> predicate) { return store.values().stream() .filter(predicate) .collect(Collectors.toList()); }

الـ Wildcard في Predicate<? super T> يتّبع قاعدة PECS (المُنتِج يستخدم extends، المُستهلِك يستخدم super): الـ predicate يستهلك كيانًا ليُنتج قيمة منطقية، لذا super هو الصحيح. هذا يتيح للمستدعين تمرير Predicate<Object> أو Predicate<User> — كلاهما يعمل.

الخطوة السادسة — ربط كل شيء معًا

public class Main { public static void main(String[] args) { // مكتوب بشكل محدد لـ User عند التصريح، لا تحويل في أي مكان InMemoryRepository<User, Long> userRepo = new InMemoryRepository<>(); userRepo.save(new User(1L, "Alice", "alice@example.com")); userRepo.save(new User(2L, "Bob", "bob@example.com")); userRepo.save(new User(3L, "Carol", "carol@example.com")); userRepo.findById(2L).ifPresent(u -> System.out.println("Found: " + u.name())); List<User> gmailUsers = userRepo.findWhere(u -> u.email().endsWith("@example.com")); System.out.println("Count: " + gmailUsers.size()); // 3 userRepo.delete(1L); System.out.println("After delete: " + userRepo.count()); // 2 // مستودع Product — نوع مستقل تمامًا، صفر تكرار في الكود InMemoryRepository<Product, String> productRepo = new InMemoryRepository<>(); productRepo.save(new Product("SKU-001", "Laptop", 999.99)); productRepo.findById("SKU-001").ifPresent(p -> System.out.println("Product: " + p.title())); } }
محو النوع في الواقع: في وقت التشغيل كلا الـ userRepo وproductRepo هما نفس الكلاس InMemoryRepository — معاملات النوع تُمحى. لهذا لا يمكنك كتابة if (userRepo instanceof InMemoryRepository<User, Long>)؛ JVM لا يرى سوى InMemoryRepository. الأمان في الأنواع موجود في وقت الترجمة فقط.

دروس التصميم المستخلصة

  • تنفيذ واحد، أنواع كيانات غير محدودة. يعمل InMemoryRepository مع أي كلاس يُنفِّذ Identifiable دون أي تعديل.
  • الأمان في وقت الترجمة في كل مكان. المُحرِّج يرفض userRepo.save(new Product(...)) — لا مفاجآت في وقت التشغيل.
  • واجهة مقروءة عبر Optional والـ Streams. يكتب المستدعون Java اصطلاحيًا دون تحويل أو فحوصات null.
  • مفتوح للتوسّع. يمكن للكلاسات الفرعية إضافة مُوجِّدات خاصة بالمجال (مثل UserRepository extends InMemoryRepository<User, Long> مع دالة findByEmail) دون لمس الكلاس الأساسي.

الخلاصة

بنيتَ مستودعًا جنيريكًا آمنًا من حيث الأنواع من الصفر: عقد واجهة محدود، كيانَي Record ملموسَين، واجهة جنيرية بمعاملَي نوع، تنفيذ InMemoryRepository، ودالة تصفية جنيرية تستخدم قاعدة PECS. كل مفهوم من هذه الدورة — الكلاسات الجنيرية، المعاملات المحدودة، الـ Wildcards، ومحو النوع — أسهم في التصميم النهائي. هذه هي القوة الحقيقية للـ Generics: اكتب المنطق مرة واحدة، دع المُحرِّج يتحقق من الصحة في كل موقع استخدام، ولا تكتب تحويلًا بعد الآن.