شارك هذا المقال:
إن الكود الخالي من الأخطاء أمر ضروري لبناء برامج مرنة ومستقرة، ولكن فعلياً هذا لا يكفي فنحن نحتاج لمراعاة مبادئ التصميم وتعد "SOLID" هي أكثر مبادئ التصميم شهرةً، يمكن أن تساعدك في تجنب الثغرات الشائعة والتفكير في بنية تطبيقاتك بمستوى أعلى.
قبل البدء , نود توضيح بعض المصطلحات المستخدمة في هذا المقال , وهم اثنان:
صف وتعني المطصلح البرمجي كلاس Class
تابع وتعني المصطلح البرمجي دالة function
مبادئ تصميم SOLID هي خمسة مبادئ تصميم برامج تُمكنك من كتابة تعليمات برمجية فعالة.
من المهم معرفة مبادئ OOP مثل الوراثة والتغليف والاستخراج وتعدد الأشكال ، ولكن كيف يمكنك استخدامها في عملك اليومي؟ أصبحت مبادئ تصميم SOLID شائعة للغاية في السنوات الأخيرة لأنها تجيب على هذا السؤال بطريقة مباشرة.
اسم SOLID هو اختصار مساعد للذاكرة حيث يمثل كل حرف مبدأ لتصميم البرامج، كما يلي:
(Single responsibility principle)S أي يمتلك الصف مسؤولية إفرداية فقط
O(Open-closed principle) يجب أن تكون البنى البرمجية مفتوحة إلى التمديد ومغلقة بالنسبة للتعديل.
L(Liskov substitution principle) يجب على الكائنات الموجودة أثناء عمل البرنامج أن تكون قابلة للاستبدال بكائنات أخرى وارثة لها دون التأثير على صحة البرنامج.
(Interface segregation principle)I إن وجود عدد من الواجهات الخاصة بالزبون أفضل كثيراً من وجود واجهة وحيدة عمومية الأهداف.
(Dependency inversion principle)D "يجب الاعتماد على التجريدات وليس التحقيقات"، عكس التبعية هي طريقة تساعد على اتباع هذا المفهوم.
تتداخل المبادئ الخمسة هنا وهناك، ويستخدمها المبرمجون على نطاق واسع.
تؤدي مبادئ SOLID إلى بنية برمجية أكثر مرونة واستقراراً مما يُسهل صيانتها وتوسيعها، وتصبح أقل عرضة للانهيار.
مبدأ المسؤولية الفردية هو أول مبدأ تصميم ل SOLID، ويمثله الحرف "S" ويحدده (Robert C Martin)،
ينص على أنه في أي تطبيق مُصمم، يجب أن يرتبط كل صف بمهمة خاصة به. واستخدام هذه المهمة يتم عند وجود سبب لاستخدام هذا الكود.
عندما يتعامل الصف(class) مع أكثر من مهمة واحدة، فإن أي تغييرات يتم إجراؤها على الوظائف قد تؤثر على الصفوف (class) الأخرى.
يعد هذا أمراً سيئاً جداً بحيث يُمكن أن يسبب مشكلة عند العمل على مستوى مشاريع برمجية معقدة عالية.
مثال على مبدأ المهام الفردية
دعونا نرى مثالاً باستخدام لغة Java مع العلم أنّه يمكنك تطبيق مبادئ تصميم SOLID على أي لغة OOP أيضاً.
سنُنشئ تطبيق باستخدام Java لمتجر كتب، نقوم بإنشاء صف كتاب يتيح للمستخدمين الحصول على عناوين كل مؤلفين لكل كتاب وتعيينهم، والبحث عن الكتاب ضمن سجلات البحث.
class Book {
String title;
String author;
String getTitle() {
return title;
}
void setTitle(String title) {
this.title = title;
}
String getAuthor() {
return author;
}
void setAuthor(String author) {
this.author = author;
}
void searchBook() {...}
}
يتعارض الكود أعلاه مع مبدأ المسؤولية الفردية ، حيث يتحمل صف الكتاب مسؤوليتين:
أولاً، تعيين البيانات المتعلقة بالكتب (العنوان والمؤلف).
ثانياً، البحث عن الكتاب في سجلات البحث.
تقوم أساليب setter بتغيير كائن Book، مما قد يسبب مشاكل عندما نريد البحث عن نفس الكتاب في سجلات البحث.
لتطبيق مبدأ المسؤولية الفردية، نحتاج إلى فصل المهمتين.
عند إدراج الكود سيكون صف الكتاب مسؤولاً فقط عن الحصول على بيانات كائن الكتاب وإعدادها.
من خلال التأكد من أن كل وحدة تحتوي على مهمة واحدة فقط، يمكنك توفير الكثير من وقت للاختبار وإنشاء بنية أكثر قابلية للصيانة.
class Book {
String title;
String author;
String getTitle() {
return title;
}
void setTitle(String title) {
this.title = title;
}
String getAuthor() {
return author;
}
void setAuthor(String author) {
this.author = author;
}
}
بعد ذلك، نقوم بإنشاء صف آخر يسمى InventoryView يكون مسؤول عن التحقق ضمن سجلات البحث.
ثمَّ ننقُل دالة searchBook () ونُرجِع صف الكتاب المُنشَأ.
class InventoryView {
Book book;
InventoryView(Book book) {
this.book = book;
}
void searchBook() {...}
}
هذا المبدأ هو "O" لمبادئ SOLID الخمسة لتصميم البرامج.
كان Bertrand Meyer هو الذي صاغ المصطلح في كتابه "بناء برمجيات كائنية التوجه". وينص هذا المبدأ على أن الصفوف ووحدات الأكواد الأخرى يجب أن تكون مفتوحة للتوسع ولكن يجب إغلاقها للتعديل.
لذلك، يجب أن تكون قادراً على توسيع الشفرة الحالية باستخدام ميزات OOP مثل الوراثة عبر الفئات الفرعية والواجهات.
ومع ذلك، يجب ألا تقوم أبداً بتعديل الفئات والواجهات ووحدات الكود الأخرى الموجودة بالفعل، حيث يمكن أن تؤدي إلى سلوك غير متوقع.
إذا قمت بإضافة ميزة جديدة عن طريق توسيع التعليمات البرمجية الخاصة بك بدلاً من تعديلها، فإنك تقلل من خطر الفشل قدر الإمكان. بالإضافة إلى ذلك، ليس عليك اختبار الوظائف الحالية.
مثال عن المبدأ Open-closed
دعونا نستمر في مثال متجر الكتب.
الآن، يريد المتجر تسليم كتب الطبخ بسعر مخفض قبل حدث معين لكسب المزيد من المبيعات . نحن نتبع مبدأ المهام الفردية، لذلك نقوم بإنشاء صفين منفصلين: CookbookDiscount للاحتفاظ بتفاصيل الخصم وDiscountManager لتطبيق الخصم على السعر.
class CookbookDiscount {
String getCookbookDiscount() {
String discount = "30% between Dec 1 and 24.";
return discount;
}
}
class DiscountManager {
void processCookbookDiscount(CookbookDiscount discount) {...}
}
يعمل هذا الكود بشكل جيد إلى أن تُبلغنا إدارة المتجر أن مبيعاتها من تخفيضات كتاب الطبخ كانت ناجحة جداً لدرجة أنها تريد توسيعها. الآن، يريدون توزيع كل تفصيل مع خصم 50 ٪ . لإضافة الميزة الجديدة، نقوم بإنشاء صف BiographyDiscount جديد:
class BiographyDiscount {
String getBiographyDiscount() {
String discount = "50% on the subject's birthday.";
return discount;
}
}
لمعالجة النوع الجديد من الخصم، نحتاج إلى إضافة الوظيفة الجديدة إلى صف DiscountManager، أيضاً:
class DiscountManager {
void processCookbookDiscount(CookbookDiscount discount) {...}
void processBiographyDiscount(BiographyDiscount discount) {...}
}
ومع ذلك، نظراً لأننا قمنا بتغيير الوظائف الحالية، فقد انتهكنا مبدأ Open-closed.
على الرغم من أن الكود أعلاه يعمل بشكل صحيح، إلا أنها قد تضيف نقاط ضعف جديدة إلى التطبيق.
لا نعرف كيف تتفاعل الإضافة الجديدة مع الأجزاء الأخرى من الكود الذي يعتمد على صف DiscountManager ، في التطبيق الحقيقي، قد يعني هذا أننا نحتاج إلى اختبار تطبيقنا بالكامل ونشره مرة أخرى.
ولكن، يمكننا أيضاً اختيار إعادة هيكلية لكودنا من خلال إضافة طبقة إضافية تمثل جميع أنواع الخصومات.
لذلك، دعونا ننشئ واجهة جديدة تسمى BookDiscount والتي سيتم تنفيذها في صفي CookbookDiscount وBiographyDiscount.
public interface BookDiscount {
String getBookDiscount();
}
class CookbookDiscount implements BookDiscount {
@Override
public String getBookDiscount() {
String discount = "30% between Dec 1 and 24.";
return discount;
}
}
class BiographyDiscount implements BookDiscount {
@Override
public String getBookDiscount() {
String discount = "50% on the subject's birthday.";
return discount;
}
}
الآن، يمكن لـ DiscountManager الرجوع إلى واجهة BookDiscount بدلاً من الفئات المحددة.
عندما يتم استدعاء التابع processBookDiscount ()، يمكننا تمرير كل من CookbookDiscount وBiographyDiscount كوسيط، حيث أن كلاهما تطبيق لواجهة BookDiscount.
class DiscountManager {
void processBookDiscount(BookDiscount discount) {...}
}
تَتبَع التعليمات البرمجية المُعاد هيكلتها لمبدأ Open-closed ، حيث يمكننا إضافة صف CookbookDiscount الجديد دون تعديل قاعدة الشفرة الحالية.
هذا يعني أيضاً أنه في المستقبل، يمكننا توسيع تطبيقنا مع أنواع الخصم الأخرى على سبيل المثال، معCrimebookDiscount)).
مبدأ استبدال Liskov هو المبدأ الثالث لـ SOLID، ويمثله الحرف " L"
كانت Barbara Liskov هي مَن قَدَم المبدأ في عام 1987 في حديثها الرئيسي في مؤتمرها بعنوان "تجريد البيانات".
الصياغة الأصلية لمبدأ استبدال Liskov معقدة بعض الشيء، وتقول:
"في برامج الكمبيوتر، إذا كانت S هي نوع فرعي من T، فيمكن استبدال كائنات من النوع T بكائنات من النوع S (أي، الكائنات من النوع S قد تحل محل كائنات من النوع T) بدون تغيير أي من الخصائص المرغوبة لذلك البرنامج (الدقة، والمهمة المنجزة، وغير ذلك). "
في شروط الشخص العادي، فإنه ينص على أن كائن الطبقة العليا يجب أن يكون قابلاً للاستبدال بواسطة كائنات الصفوف الفرعية الخاصة به دون التسبب بمشاكل في التطبيق.
لذلك، يجب ألا يغير الصف الفرعي خصائص الصف الأصل.
يمكنك تنفيذ مبدأ استبدال Liskov من خلال الانتباه إلى التسلسل الهرمي الصحيح للوراثة.
مثال على مبدأ استبدال Liskov
الآن، يطلب منا متجر الكتب إضافة وظيفة تسليم جديدة إلى التطبيق. لذلك، نقوم بإنشاء صف BookDelivery لإعلام العملاء حول عدد المواقع التي يمكنهم فيها جمع طلباتهم:
class BookDelivery {
String titles;
int userID;
void getDeliveryLocations() {...}
}
ومع ذلك، يقوم المتجر أيضاً ببيع أغلفة الكتب الفاخرة للذين يرغبون فقط مع توصيلها إلى متاجرهم.
لذلك، نقوم بإنشاء صف فرعي جديد من HardcoverDelivery يعمل على توسيع BookDelivery ويتجاوز طريقة getDeliveryLocations () بالإضافة لوظيفته الخاصة:
class HardcoverDelivery extends BookDelivery {
@Override
void getDeliveryLocations() {...}
}
لاحقاً، يطلب منا المتجر إنشاء وظائف توصيل للكتاب الصوتي أيضاً.
الآن، نقوم بتوسيع صف BookDelivery الحالي مع صف فرعي من AudiobookDelivery.
ولكن عندما نريد تجاوز طريقة getDeliveryLocations ()، فإننا ندرك أنه لا يمكن تسليم الكتب الصوتية إلى المواقع المادية.
class AudiobookDelivery extends BookDelivery {
@Override
void getDeliveryLocations() {/* can't be implemented */}
}
يمكننا تغيير بعض خصائص التابع getDeliveryLocations ()، ومع ذلك، قد ينتهك مبدأ استبدال Liskov ، بعد التعديل، لم نتمكن من استبدال صنف BookDelivery الطبقة العليا بصنف AudiobookDelivery الفرعي بدون توقف في التطبيق.
class BookDelivery {
String title;
int userID;
}
class OfflineDelivery extends BookDelivery {
void getDeliveryLocations() {...}
}
class OnlineDelivery extends BookDelivery {
void getSoftwareOptions() {...}
}
في التعليمات البرمجية المُعاد هيكلتها، ستكون HardcoverDelivery هي الصف الفرعي من OfflineDelivery وستتجاوز تابع getDeliveryLocations () بوظائفه الخاصة به.
ستكون AudiobookDelivery هي الصف الفرعي من OnlineDelivery وهذا شيء جيد، حيث إنه ليس من الضروري الآن التعامل مع التابع getDeliveryLocations ().
بدلاً من ذلك، يمكن أن يتجاوز تابع getSoftwareOptions () الخاصة بالأعلى منه من خلال تطبيقه الخاص (على سبيل المثال ، من خلال إدراج مشغلات الصوت المتوفرة ودمجها).
class HardcoverDelivery extends OfflineDelivery {
@Override
void getDeliveryLocations() {...}
}
class AudiobookDelivery extends OnlineDelivery {
@Override
void getSoftwareOptions() {...}
}
بعد إعادة الهيكلة، يمكننا استخدام أي صف فرعي بدلاً من الصف الأعلى بدون توقف ضمن التطبيق.
مبدأ فصل الواجهة هو مبدأ تصميم SOLID الرابع الذي يمثله الحرف "I" في الاختصار.
كان Robert C Martin هو الذي حدد المبدأ في البداية بقوله "لا ينبغي إجبار العملاء على الاعتماد على الأساليب التي لا يستخدمونها"، ويعني بالعملاء، الطبقات التي تنفذ واجهات، بمعنى آخر، يجب ألا تتضمن الواجهات وظائف كثيرة جداً.
في التطبيق المصمم جيداً، يجب تجنب العبث بالواجهة (وتسمى أيضاً بالواجهات الضخمة).
الحل هو إنشاء واجهات أصغر يمكنك تنفيذها بشكل أكثر مرونة.
مثال على مبدأ فصل الواجهة
دعنا نضيف بعض إجراءات المستخدم إلى محل بيع الكتب عبر الإنترنت بحيث يتمكن العملاء من التفاعل مع المحتوى قبل الشراء. للقيام بذلك، نقوم بإنشاء واجهة تسمى BookAction من خلال ثلاث توابع: seeReviews () وsearchSecondHand () وlistenSample ().
public interface BookAction {
void seeReviews();
void searchSecondhand();
void listenSample();
}
بعد ذلك، نقوم بإنشاء صفين: HardcoverUI وAudiobookUI ينفذان واجهة BookAction مع وظائفها الخاصة:
class HardcoverUI implements BookAction {
@Override
public void seeReviews() {...}
@Override
public void searchSecondhand() {...}
@Override
public void listenSample() {...}
}
class AudiobookUI implements BookAction {
@Override
public void seeReviews() {...}
@Override
public void searchSecondhand() {...}
@Override
public void listenSample() {...}
}
يحتوي كِلا الصفين على توابع لا يستخدمونها، لذلك فقد اخترقنا مبدأ فصل الواجهة.
لا يمكن الاستماع إلى كتب Hardcover، لذلك فإن صف HardcoverUI لا يحتاج إلى تابع listenSample (). وبالمثل، ليس لدى الكتب المسموعة نسخاً مستعملة، لذلك لا يحتاجها صف AudiobookUI .
ومع ذلك، نظراً لأن واجهة BookAction تتضمن هذه التوابع، فإن جميع الصفوف التابعة لها يجب أن تنفذها.
بمعنى آخر، BookAction هي واجهة غير مستقرة ونحتاج إلى فصلها. دعنا نوسعها باستخدام واجهتين فرعيتين إضافيتين: HardcoverAction وAudioAction.
public interface BookAction {
void seeReviews();
}
public interface HardcoverAction extends BookAction {
void searchSecondhand();
}
public interface AudioAction extends BookAction {
void listenSample();
}
الآن، يمكن لصف HardcoverUI تطبيق واجهة HardcoverAction ويمكن لصف AudiobookUI تطبيق واجهة AudioAction .
وبهذه الطريقة، يمكن لكِلا الصفين تطبيق تابع seeReviews () لواجهة BookAction الطبقة العليا.
ومع ذلك، لا يتعين على HardcoverUI تنفيذ تابع listenSample () الذي لاعلاقة له به، ولا يتعين على AudioUI تطبيق searchSecondhand() أيضاً.
class HardcoverUI implements HardcoverAction {
@Override
public void seeReviews() {...}
@Override
public void searchSecondhand() {...}
}
class AudiobookUI implements AudioAction {
@Override
public void seeReviews() {...}
@Override
public void listenSample() {...}
}
يتبع الكود المعاد هيكلته مبدأ فصل الواجهة، حيث لا يعتمد أي صف على التوابع التي لا تستخدمها.
مبدأ عكس التبعية هو المبدأ الخامس والأخير لتصميم SOLID الذي يمثله " “D والذي قدمه Robert C Martin، الهدف من مبدأ انعكاس التبعية هو تفادي الكود المرتبط بشدة بكود آخر، لأنه يكسر التطبيق بسهولة.
ينص المبدأ على ما يلي:
"يجب ألا تعتمد النماذج عالية المستوى على النماذج منخفضة المستوى. يجب أن يعتمد كلاهما على مبدأ التجريد ".
بمعنى آخر، تحتاج إلى فصل النماذج عالية المستوى عن المستوى المنخفض.
عادةً ما تقوم الصفوف العالية بتغليف منطقي معقد بينما تتضمن الصفوف ذات المستوى المنخفض بيانات أو أدوات مساعدة.
عادةً ما يرغب معظم الناس في جعل الصفوف عالية المستوى تعتمد على صفوف منخفضة المستوى.
ومع ذلك، وفقاً لمبدأ انعكاس التبعية، فأنت بحاجة إلى عكس التبعية. وإلا، عند استبدال الصف منخفض المستوى، سيتأثر الصف عالي المستوى أيضاً.
كحل لذلك، تحتاج إلى إنشاء طبقة مجردة للصفوف منخفضة المستوى، بحيث يمكن للصفوف عالية المستوى أن تعتمد على التجريد بدلاً من عمليات التنفيذ المحددة.
يذكر Robert C Martin أيضاً أن مبدأ انعكاس التبعية هو مزيج محدد من مبدأي Open / Closed وLiskov.
مثال على مبدأ انعكاس التبعية
الآن، طُلب من متجر الكتب بناء ميزة جديدة تمكّن العملاء من وضع كتبهم المفضلة على الرف. لتنفيذ الوظيفة الجديدة، نقوم بإنشاء صف كتاب بمستوى منخفض وصف رف Shelf بمستوى عالٍ.
سيتيح فصل الكتاب للمستخدمين لرؤية المراجعات وقراءة عينة من كل كتاب يخزنونه على الرفوف الخاصة بهم.
ستتيح لهم صف الرف Shelf إضافة كتاب إلى رفهم وتخصيص الرف.
class Book {
void seeReviews() {...}
void readSample() {...}
}
class Shelf {
Book book;
void addBook(Book book) {...}
void customizeShelf() {...}
}
يبدو كل شيء جيداً حتى الآن، ولكن نظراً لأن صف الرف Shelf عالي المستوى تعتمد على صف الكتاب ذي المستوى المنخفض، فإن التعليمات البرمجية أعلاه تنتهك مبدأ انعكاس التبعية.
يصبح هذا واضحاً عندما يطلب منا المتجر تمكين العملاء من إضافة أقراص DVD إلى أرففهم أيضاً.
لتلبية الطلب، نقوم بإنشاء صف DVD جديد:
class DVD {
void seeReviews() {...}
void watchSample() {...}
}
الآن، يجب علينا تعديل صف الرف Shelf بحيث يمكنه قبول أقراص DVD أيضاً.
ومع ذلك، فإن هذا من شأنه كسر مبدأ open/closed بوضوح. الحل هو إنشاء طبقة تجريدية لصفوف المستوى الأدنى (Book وDVD).
سنفعل ذلك من خلال تقديم واجهة المنتج التي سيعتمدها كِلا الصفين.
public interface Product {
void seeReviews();
void getSample();
}
class Book implements Product {
@Override
public void seeReviews() {...}
@Override
public void getSample() {...}
}
class DVD implements Product {
@Override
public void seeReviews() {...}
@Override
public void getSample() {...}
}
الآن، يمكن لصف الرف Shelf الرجوع إلى واجهة المنتج بدلاً من تنفيذ تعليماتها (Book وDVD)
كما يسمح لنا الكود المعاد هيكلته لاحقاً بتقديم أنواع منتجات جديدة (على سبيل المثال، مجلة) يمكن للعملاء وضعها على رفوفهم أيضاً.
class Shelf {
Product product;
void addProduct(Product product) {...}
void customizeShelf() {...}
}
يتبع الكود أعلاه أيضاً مبدأ Liskov Substitution، حيث يمكن استبدال نوع المنتج بكلا الصفين الفرعيين (Bookو DVD) بدون توقف في البرنامج.
في الوقت نفسه، طبقنا أيضاً مبدأ انعكاس التبعية، كما في التعليمات البرمجية المُعاد هيكلتها، لا تعتمد الصفوف عالية المستوى على الصفوف منخفضة المستوى أيضاً.
يؤدي تطبيق مبادئ تصميم SOLID إلى زيادة التعقيد الكلي للكود، ولكنه يؤدي إلى تصميم أكثر مرونة.
إلى جانب التطبيقات المتجانسة، يمكنك أيضاً تطبيق مبادئ تصميم SOLID على الخدمات المصغّرة حيث يمكنك التعامل مع كل خدمة كوحدة نمطية قائمة بذاتها (مثل صف في الأمثلة المذكورة أعلاه).
عندما تخرق مبدأ تصميم SOLID، فإن لغة Java واللغات الأخرى المترجمة قد تحتمل استثناءات، لكن هذا لا يحدث دائماً.
يصعب اكتشاف مشاكل هندسة البرمجيات، لكن برامج التشخيص المتقدمة مثل أدوات APM يمكن أن توفر لك العديد من الملاحظات المفيدة.
يعمد المبرمجون الى تجاهل الحقائق العلمية البرمجية و التركيز على الكود , ولا يخفى على أي مبرمج محنك أهمية تطبيق هذه القواعد خاصة عند الكلام عن تطبيقات كبيرة , فالمرونة و امكانية التوسيع هي من أهم ما قد يحتاجه اي برنامج ليتحول الى مشروع ضخم , أخبرونا , ما رأيكم في مبدأ تصميم SOLID بشكل عام ؟ وهل تقومون باتباعه في برامجكم ؟