الوراثة وتعدّد الأشكال

التركيب مقابل الوراثة

15 دقيقة الدرس 9 من 14

التركيب مقابل الوراثة

تعرّفت الآن على كيفية بناء تسلسلات كلاسات باستخدام extends. لكن الوراثة ليست الأداة الصحيحة دائمًا. في هذا الدرس ستتعلّم التركيب — وهو أسلوب مختلف لإعادة استخدام الكود — وستفهم متى يكون كل أسلوب مناسبًا.

علاقة "هو نوع من" مقابل "يحتوي على"

السؤال الأكثر فائدة عند الاختيار بين الوراثة والتركيب هو:

  • هل هي علاقة "هو نوع من"؟ استخدم الوراثة. Dog هو نوع من Animal. SavingsAccount هو نوع من BankAccount.
  • هل هي علاقة "يحتوي على"؟ استخدم التركيب. Car يحتوي على Engine. Person يحتوي على Address.

حين تكون العلاقة هرمية حقيقية ويصحّ الاستبدال — أي في أي مكان يُتوقّع BankAccount يصلح SavingsAccount — فالوراثة مناسبة. أما حين تمتلك إحدى الكلاسات كلاسًا آخر أو تستخدمه فحسب، فالتركيب هو الخيار الأفضل.

مثال سريع: الطريقة الخاطئة

لنفترض أنّك نمذجت سيارة بالوراثة من Engine:

// خاطئ — السيارة ليست محرّكًا class Engine { public void start() { System.out.println("Engine started"); } } class Car extends Engine { // خطأ: هل السيارة نوع من المحرّك؟ private String model; Car(String model) { this.model = model; } }

يُترجَم هذا الكود، لكنّه مفاهيميًا خاطئ. Car ليست نوعًا من Engine؛ بل تمتلك محرّكًا. والأسوأ أن كل دالة عامة في Engine أصبحت مكشوفة على Car — يمكن للمُستدعين استدعاء car.start() ظنًا أنّهم يُشغّلون السيارة، لكنّهم في الواقع يصلون إلى تفصيلة تنفيذ داخلية.

الطريقة الصحيحة: التركيب

بالتركيب، تحتفظ Car بمرجع لكائن من نوع Engine كحقل:

class Engine { private final String type; Engine(String type) { this.type = type; } public void start() { System.out.println(type + " engine started"); } } class Car { private final String model; private final Engine engine; // يحتوي على Car(String model, Engine engine) { this.model = model; this.engine = engine; } public void drive() { engine.start(); System.out.println(model + " is moving"); } } public class Main { public static void main(String[] args) { Engine v8 = new Engine("V8"); Car car = new Car("Mustang", v8); car.drive(); // الإخراج: // V8 engine started // Mustang is moving } }

تُفوِّض Car سلوك الإقلاع إلى Engine عبر استدعاء دالة، لا عبر الوراثة. تبقى تفاصيل Engine الداخلية مخفية تمامًا عن مستدعي Car.

التفويض هو النمط الأساسي. التركيب يعني أن تحتفظ كلاس بكائن من كلاس آخر وتستدعي دوالّه. تتحكّم الكلاس الخارجية في أي الدوال تكشفها وأيّها تُخفيها — ممّا يمنحك تحكّمًا دقيقًا في الواجهة البرمجية العامة.

مشكلة الكلاس الأساسية الهشّة

من أهم الأسباب للتفضيل التركيب هي مشكلة الكلاس الأساسية الهشّة: حين تُعدّل كلاسًا أصليًا، قد تكسر جميع الكلاسات المشتقّة بصمت — حتى تلك التي لم تلمسها قط.

إليك مثالًا كلاسيكيًا. تخيّل قائمة مخصّصة تحصي عدد العناصر التي أُضيفت على الإطلاق:

import java.util.ArrayList; class CountingList<E> extends ArrayList<E> { private int addCount = 0; @Override public boolean add(E element) { addCount++; return super.add(element); } @Override public boolean addAll(java.util.Collection<? extends E> c) { addCount += c.size(); return super.addAll(c); } public int getAddCount() { return addCount; } } public class FragileDemo { public static void main(String[] args) { CountingList<String> list = new CountingList<>(); list.addAll(java.util.List.of("a", "b", "c")); System.out.println(list.getAddCount()); // يطبع 6 وليس 3! } }

لماذا 6 بدلًا من 3؟ لأن ArrayList.addAll تستدعي add داخليًا لكل عنصر. لذلك تزيد CountingList.addAll العدّاد بـ 3، ثم تستدعي super.addAll بدورها CountingList.add ثلاث مرات إضافية، فيزيد العدّاد مجدّدًا. انكسرت الكلاس المشتقّة لأنها اعتمدت على تفصيلة تنفيذية داخلية للكلاس الأصلية — وهي تفصيلة ليست جزءًا من العقد العام وقد تتغيّر في أي إصدار قادم من JDK.

لا ترث من كلاس فقط لإعادة استخدام تنفيذها إلا إذا كنت تنمذج علاقة "هو نوع من" حقيقية وصُمِّمت الكلاس الأصلية للتمديد صراحةً. إن توسيع كلاسات مكتبية ملموسة كـ ArrayList أو HashMap أو Stack يُفضي دائمًا تقريبًا إلى هذا النوع من الأخطاء الخفية.

الإصلاح بالتركيب

لفّ القائمة بدلًا من التوسيع منها:

import java.util.*; class CountingList<E> { private final List<E> list = new ArrayList<>(); private int addCount = 0; public boolean add(E element) { addCount++; return list.add(element); } public boolean addAll(Collection<? extends E> c) { addCount += c.size(); return list.addAll(c); } public int size() { return list.size(); } public E get(int i) { return list.get(i); } public int getAddCount() { return addCount; } } public class FixedDemo { public static void main(String[] args) { CountingList<String> list = new CountingList<>(); list.addAll(List.of("a", "b", "c")); System.out.println(list.getAddCount()); // يطبع 3 بشكل صحيح } }

الآن تمتلك CountingList منطق العدّ بالكامل. لا يمكن للتغييرات في تنفيذ ArrayList أن تؤثّر عليها.

تفضيل التركيب: المبدأ التوجيهي

عبارة "افضل التركيب على الوراثة" مأخوذة من كتاب Design Patterns الكلاسيكي ("عصابة الأربعة"). لا تعني أبدًا استخدام الوراثة — بل تعني استخدامها فقط حين تجتاز علاقة "هو نوع من" الاختبار باقتناع وصُمِّمت الكلاس الأصلية للتمديد. في كل الحالات الأخرى، استخدم التركيب.

  • التركيب أكثر مرونة. يمكنك استبدال الكائن المُركَّب في وقت التشغيل (مثل حقن Engine مختلف)، وهو أساس أنماط تصميم عديدة كـ Strategy وDecorator.
  • التركيب تغليف أحكم. لا تتسرّب التفاصيل الداخلية للكلاس المساعدة إلى واجهة الكلاس الخارجية.
  • الوراثة لا تزال صحيحة أحيانًا. نمذجة تسلسل أنواع حقيقي — Shape / Circle / Rectangle — هو بالضبط ما وُجدت الوراثة من أجله. المفتاح هو الصدق في تحديد طبيعة العلاقة.
قائمة تحقّق سريعة: هل يمكنك القول "[الكلاس المشتقّة] هي نوع من [الكلاس الأصلية]" بشكل مقنع؟ هل تحترم الكلاس المشتقّة عقد الأصلية في كل الحالات؟ هل صُمِّمت الكلاس الأصلية للتمديد؟ إن كانت الإجابات الثلاث بنعم فالوراثة مقبولة. وإلا فضِّل التركيب.

الخلاصة

الوراثة تنمذج علاقات "هو نوع من"، والتركيب ينمذج علاقات "يحتوي على". تُظهر مشكلة الكلاس الأساسية الهشّة أن توسيع الكلاسات الملموسة لإعادة استخدام كودها قد يكسر بصمت عند تغيير الكلاس الأصلية. التركيب — الاحتفاظ بمرجع لكائن مساعد والتفويض إليه — يتجنّب هذا الترابط الضيّق، ويمنحك واجهة برمجية أنظف، ويجعل كودك أسهل في الاستبدال والاختبار. في الدرس الختامي من هذه السلسلة ستطبّق كل ما تعلّمته ببناء تسلسل كامل لأشكال هندسية.