مبادئ صلبة يجب على كل مطور معرفتها

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



المادة ، التي ننشر ترجمتها اليوم ، مخصصة لأساسيات SOLID ومخصصة للمطورين المبتدئين.

ما هو سوليد؟


إليك كيفية اختصار SOLID:

  • S: مبدأ المسؤولية الفردية.
  • O: مبدأ مفتوح مغلق.
  • L: مبدأ استبدال Liskov (مبدأ استبدال Barbara Liskov).
  • I: مبدأ فصل الواجهة.
  • D: مبدأ انعكاس التبعية.

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

مبدأ المسؤولية الوحيدة


”مهمة واحدة. شيء واحد فقط. " - لوكي يخبر Skurge في فيلم Thor: Ragnarok.
يجب على كل فصل حل مشكلة واحدة فقط.

يجب أن يكون الفصل مسؤولاً عن شيء واحد فقط. إذا كانت الطبقة مسؤولة عن حل العديد من المشكلات ، فإن أنظمةها الفرعية التي تنفذ حل هذه المشكلات تتحول إلى بعضها البعض. تؤدي التغييرات في أحد هذه الأنظمة الفرعية إلى تغييرات في نظام آخر.

لاحظ أن هذا المبدأ لا ينطبق فقط على الفئات ، ولكن أيضًا على مكونات البرامج بمعنى أوسع.

على سبيل المثال ، ضع في اعتبارك هذا الرمز:

class Animal {    constructor(name: string){ }    getAnimalName() { }    saveAnimal(a: Animal) { } } 

تصف فئة Animal المعروضة هنا نوعًا من الحيوانات. ينتهك هذا الفصل مبدأ المسؤولية وحدها. كيف ينتهك هذا المبدأ بالضبط؟

وفقًا لمبدأ المسؤولية الحصرية ، يجب على الفصل حل مهمة واحدة فقط. يقوم بحل الاثنين من خلال العمل مع مستودع البيانات في طريقة saveAnimal ومعالجة خصائص الكائن في المنشئ وفي طريقة getAnimalName .

كيف يمكن أن يؤدي مثل هذا الهيكل الطبقي إلى مشاكل؟

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

من أجل جعل الرمز أعلاه يتماشى مع مبدأ المسؤولية الوحيدة ، سننشئ فئة أخرى مهمتها الوحيدة هي العمل مع المستودع ، على وجه الخصوص ، تخزين الكائنات من فئة Animal فيه:

 class Animal {   constructor(name: string){ }   getAnimalName() { } } class AnimalDB {   getAnimal(a: Animal) { }   saveAnimal(a: Animal) { } } 

إليك ما يقوله ستيف فينتون عن هذا: "عند تصميم الفئات ، يجب أن نسعى جاهدين لدمج المكونات ذات الصلة ، أي تلك التي تحدث فيها التغييرات لنفس الأسباب. يجب ان نحاول فصل المكونات والتغييرات التى تسبب اسبابا مختلفة ".

يؤدي التطبيق الصحيح لمبدأ المسؤولية الفردية إلى درجة عالية من الاتصال بين العناصر داخل الوحدة ، أي إلى أن المهام التي تم حلها داخلها تتوافق بشكل جيد مع هدفها الرئيسي.

مبدأ مفتوح


يجب أن تكون كيانات البرامج (الفئات والوحدات والوظائف) مفتوحة للتوسيع ، ولكن ليس للتعديل.

نواصل العمل على فئة Animal .

 class Animal {   constructor(name: string){ }   getAnimalName() { } } 

نريد فرز قائمة الحيوانات ، والتي يمثل كل منها كائن من فئة Animal ، ومعرفة الأصوات التي تصدرها. تخيل أننا نحل هذه المشكلة باستخدام وظيفة AnimalSounds :

 //... const animals: Array<Animal> = [   new Animal('lion'),   new Animal('mouse') ]; function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(a[i].name == 'lion')           return 'roar';       if(a[i].name == 'mouse')           return 'squeak';   } } AnimalSound(animals); 

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

أضف عنصرًا جديدًا إلى الصفيف:

 //... const animals: Array<Animal> = [   new Animal('lion'),   new Animal('mouse'),   new Animal('snake') ] //... 

بعد ذلك ، يتعين علينا تغيير رمز وظيفة AnimalSound :

 //... function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(a[i].name == 'lion')           return 'roar';       if(a[i].name == 'mouse')           return 'squeak';       if(a[i].name == 'snake')           return 'hiss';   } } AnimalSound(animals); 

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

كيف تجعل وظيفة AnimalSound تتماشى مع مبدأ فتح مغلق؟ على سبيل المثال ، مثل هذا:

 class Animal {       makeSound();       //... } class Lion extends Animal {   makeSound() {       return 'roar';   } } class Squirrel extends Animal {   makeSound() {       return 'squeak';   } } class Snake extends Animal {   makeSound() {       return 'hiss';   } } //... function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       a[i].makeSound();   } } AnimalSound(animals); 

قد تلاحظ أن فئة Animal لديها الآن طريقة makeSound افتراضية. مع هذا النهج ، من الضروري أن تقوم الفئات المصممة لوصف حيوانات معينة بتوسيع فئة Animal وتنفيذ هذه الطريقة.

ونتيجة لذلك ، سيكون لكل فئة تصف حيوانًا طريقة makeSound الخاصة بها ، وعند التكرار عبر مصفوفة مع الحيوانات في وظيفة AnimalSound ، سيكون كافيًا استدعاء هذه الطريقة لكل عنصر من عناصر الصفيف.

إذا قمت الآن بإضافة كائن يصف الحيوان الجديد إلى الصفيف ، فلن تضطر إلى تغيير وظيفة AnimalSound . لقد جعلناه يتماشى مع مبدأ الانفتاح.

تأمل في مثال آخر.

افترض أن لدينا متجر. نعطي العملاء خصم 20٪ باستخدام هذه الفئة:

 class Discount {   giveDiscount() {       return this.price * 0.2   } } 

الآن تقرر تقسيم العملاء إلى مجموعتين. fav العملاء المفضلون ( fav ) على خصم 20 ٪ ، وعملاء VIP ( vip ) - ضعف الخصم ، أي 40 ٪. ولتطبيق هذا المنطق تقرر تعديل الصف على النحو التالي:

 class Discount {   giveDiscount() {       if(this.customer == 'fav') {           return this.price * 0.2;       }       if(this.customer == 'vip') {           return this.price * 0.4;       }   } } 

هذا النهج ينتهك مبدأ التقارب والانفتاح. كما ترى ، هنا ، إذا كنا بحاجة إلى منح مجموعة معينة من العملاء خصمًا خاصًا ، فيجب علينا إضافة رمز جديد إلى الفصل الدراسي.

من أجل معالجة هذا الرمز وفقًا لمبدأ القرب من الانفتاح ، نضيف فئة جديدة إلى المشروع الذي يوسع فئة Discount . في هذه الفئة الجديدة ، نقوم بتنفيذ آلية جديدة:

 class VIPDiscount: Discount {   getDiscount() {       return super.getDiscount() * 2;   } } 

إذا قررت منح خصم بنسبة 80٪ لعملاء "super-VIP" ، فيجب أن يبدو كما يلي:

 class SuperVIPDiscount: VIPDiscount {   getDiscount() {       return super.getDiscount() * 2;   } } 

كما ترى ، يتم استخدام تمكين الطبقات هنا ، وليس تعديلها.

مبدأ الاستبدال باربرا ليسكوف


من الضروري أن تعمل الفئات الفرعية كبديل للفئات الفائقة الخاصة بهم.

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

خذ بعين الاعتبار تطبيق هذا المبدأ ، والعودة إلى المثال مع فئة Animal . سنكتب وظيفة مصممة لإرجاع معلومات حول عدد أطراف الحيوان.

 //... function AnimalLegCount(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(typeof a[i] == Lion)           return LionLegCount(a[i]);       if(typeof a[i] == Mouse)           return MouseLegCount(a[i]);       if(typeof a[i] == Snake)           return SnakeLegCount(a[i]);   } } AnimalLegCount(animals); 

الوظيفة تنتهك مبدأ الاستبدال (ومبدأ إغلاق الانفتاح). يجب أن يعرف هذا الرمز عن أنواع جميع الكائنات التي يعالجها ، واعتمادًا على النوع ، استخدم الوظيفة المقابلة لحساب أطراف حيوان معين. ونتيجة لذلك ، عند إنشاء نوع جديد من الحيوانات ، يجب إعادة كتابة الوظيفة:

 //... class Pigeon extends Animal {      } const animals[]: Array<Animal> = [   //...,   new Pigeon(); ] function AnimalLegCount(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(typeof a[i] == Lion)           return LionLegCount(a[i]);       if(typeof a[i] == Mouse)           return MouseLegCount(a[i]);        if(typeof a[i] == Snake)           return SnakeLegCount(a[i]);       if(typeof a[i] == Pigeon)           return PigeonLegCount(a[i]);   } } AnimalLegCount(animals); 

لكي لا تنتهك هذه الوظيفة مبدأ الاستبدال ، نقوم بتحويلها باستخدام المتطلبات التي وضعها Steve Fenton. وهي تتكون في حقيقة أن الطرق التي تقبل أو ترجع القيم مع نوع بعض الفئات الفائقة ( Animal في حالتنا) يجب أن تقبل أيضًا وتعيد القيم التي أنواعها هي الفئات الفرعية ( Pigeon ).

مسلحًا بهذه الاعتبارات ، يمكننا إعادة وظيفة AnimalLegCount :

 function AnimalLegCount(a: Array<Animal>) {   for(let i = 0; i <= a.length; i++) {       a[i].LegCount();   } } AnimalLegCount(animals); 

الآن هذه الوظيفة ليست مهتمة بأنواع الكائنات التي تم تمريرها إليها. إنها ببساطة تدعو أساليب LegCount الخاصة بهم. كل ما تعرفه عن الأنواع هو أن الكائنات التي تعالجها يجب أن تنتمي إلى فئة Animal أو فئاتها الفرعية.

يجب أن تظهر طريقة LegCount الآن في فئة Animal :

 class Animal {   //...   LegCount(); } 

وتحتاج فئاته الفرعية إلى تطبيق هذه الطريقة:

 //... class Lion extends Animal{   //...   LegCount() {       //...   } } //... 

ونتيجة لذلك ، على سبيل المثال ، عند الوصول إلى طريقة LegCount لمثيل لفئة Lion ، يتم استدعاء الطريقة LegCount في هذه الفئة ويتم إرجاع ما يمكن توقعه تمامًا من استدعاء مثل هذه الطريقة.

الآن AnimalLegCount تحتاج دالة AnimalLegCount إلى معرفة أي كائن من فئة فرعية معينة من فئة Animal AnimalLegCount أجل معرفة معلومات حول عدد الأطراف في الحيوان التي يمثلها هذا الكائن. تستدعي الوظيفة ببساطة طريقة LegCount لفئة Animal ، حيث يجب أن تطبق الفئات الفرعية من هذه الفئة هذه الطريقة بحيث يمكن استخدامها بدلاً من ذلك ، دون انتهاك العملية الصحيحة للبرنامج.

مبدأ فصل الواجهة


إنشاء واجهات عالية التخصص مصممة لعميل معين. يجب ألا يعتمد العملاء على الواجهات التي لا يستخدمونها.

يهدف هذا المبدأ إلى معالجة أوجه القصور المرتبطة بتنفيذ واجهات كبيرة.

خذ بعين الاعتبار واجهة Shape :

 interface Shape {   drawCircle();   drawSquare();   drawRectangle(); } 

يصف طرق رسم الدوائر ( drawCircle ) والمربعات ( drawSquare ) والمستطيلات ( drawRectangle ). ونتيجة لذلك ، يجب أن تحتوي الفئات التي تطبق هذه الواجهة وتمثل أشكالًا هندسية فردية ، مثل الدائرة والمربع والمستطيل ، على تنفيذ جميع هذه الأساليب. يبدو هذا:

 class Circle implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } class Square implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } class Rectangle implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } 

تحولت كود غريب. على سبيل المثال ، تطبق فئة Rectangle تمثل مستطيلًا أساليب ( drawCircle و drawSquare ) لا تحتاجها على الإطلاق. يمكن رؤية الشيء نفسه عند تحليل رمز فئتين أخريين.

افترض أننا قررنا إضافة طريقة أخرى إلى واجهة Shape ، drawTriangle ، مصممة لرسم المثلثات:

 interface Shape {   drawCircle();   drawSquare();   drawRectangle();   drawTriangle(); } 

سينتج عن ذلك فئات تمثل أشكالًا هندسية محددة يجب عليها تنفيذ طريقة drawTriangle أيضًا. خلاف ذلك ، سيحدث خطأ.

كما ترون ، من المستحيل باستخدام هذا النهج إنشاء فئة تنفذ طريقة لإخراج دائرة ، ولكنها لا تنفذ طرقًا لاشتقاق مربع ومستطيل ومثلث. يمكن تنفيذ هذه الأساليب بحيث عندما يتم إخراجها ، يتم طرح خطأ يشير إلى أنه لا يمكن تنفيذ هذه العملية.

يحذرنا مبدأ فصل الواجهة من إنشاء واجهات مثل Shape من مثالنا. لا يجب على العملاء (لدينا فئات Circle Rectangle ) تنفيذ الأساليب التي لا يحتاجون إلى استخدامها. بالإضافة إلى ذلك ، يشير هذا المبدأ إلى أن الواجهة يجب أن تحل مهمة واحدة فقط (في هذا الأمر تشبه مبدأ المسؤولية الوحيدة) ، وبالتالي يجب نقل كل ما يتجاوز نطاق هذه المهمة إلى واجهة أو واجهات أخرى.

في حالتنا ، تحل واجهة Shape مشاكل لحلها من الضروري إنشاء واجهات منفصلة. باتباع هذه الفكرة ، نقوم بإعادة صياغة الكود عن طريق إنشاء واجهات منفصلة لحل العديد من المهام المتخصصة للغاية:

 interface Shape {   draw(); } interface ICircle {   drawCircle(); } interface ISquare {   drawSquare(); } interface IRectangle {   drawRectangle(); } interface ITriangle {   drawTriangle(); } class Circle implements ICircle {   drawCircle() {       //...   } } class Square implements ISquare {   drawSquare() {       //...   } } class Rectangle implements IRectangle {   drawRectangle() {       //...   } } class Triangle implements ITriangle {   drawTriangle() {       //...   } } class CustomShape implements Shape {  draw(){     //...  } } 

الآن ICircle استخدام واجهة ICircle فقط لرسم الدوائر ، بالإضافة إلى واجهات متخصصة أخرى لرسم أشكال أخرى. يمكن استخدام واجهة Shape كواجهة عالمية.

مبدأ انعكاس التبعية


يجب أن يكون هدف التبعية مجردة ، وليس شيئًا محددًا.

  1. لا يجب أن تعتمد وحدات المستوى الأعلى على وحدات المستوى الأدنى. يجب أن يعتمد كلا النوعين من الوحدات على التجريد.
  2. لا يجب أن تعتمد التجريد على التفاصيل. يجب أن تعتمد التفاصيل على التجريد.

في عملية تطوير البرنامج ، هناك لحظة تتوقف فيها وظيفة التطبيق عن ملاءمتها داخل نفس الوحدة النمطية. عندما يحدث هذا ، علينا حل مشكلة تبعيات الوحدة. نتيجة لذلك ، على سبيل المثال ، قد يتبين أن المكونات عالية المستوى تعتمد على المكونات ذات المستوى المنخفض.

 class XMLHttpService extends XMLHttpRequestService {} class Http {   constructor(private xmlhttpService: XMLHttpService) { }   get(url: string , options: any) {       this.xmlhttpService.request(url,'GET');   }   post() {       this.xmlhttpService.request(url,'POST');   }   //... } 

هنا ، تعد فئة Http مكونًا عالي المستوى ، XMLHttpService ذا مستوى منخفض. تنتهك هذه البنية الفقرة (أ) من مبدأ انعكاس التبعية: "لا يجب أن تعتمد وحدات المستويات الأعلى على وحدات المستويات الأدنى. يجب أن يعتمد كلا النوعين من الوحدات على التجريد ".

يتم فرض فئة Http للاعتماد على فئة XMLHttpService . إذا قررنا تغيير الآلية المستخدمة من قبل فئة Http للتفاعل مع الشبكة ، فلنفترض أنها ستكون خدمة Node.js أو ، على سبيل المثال ، خدمة كعب تستخدم لأغراض الاختبار ، سيتعين علينا تحرير جميع مثيلات فئة Http عن طريق تغيير الرمز المطابق. وهذا ينتهك مبدأ التقارب والانفتاح.

لا يجب أن تعرف فئة Http ما يتم استخدامه بالضبط لإنشاء اتصال شبكة. لذلك ، Connection واجهة Connection :

 interface Connection {   request(url: string, opts:any); } 

تحتوي واجهة Connection على وصف لطريقة request ونقوم بتمرير وسيطة نوع Connection إلى فئة Http :

 class Http {   constructor(private httpConnection: Connection) { }   get(url: string , options: any) {       this.httpConnection.request(url,'GET');   }   post() {       this.httpConnection.request(url,'POST');   }   //... } 

الآن ، بغض النظر عن ما يتم استخدامه لتنظيم التفاعل مع الشبكة ، يمكن لفئة Http استخدام ما تم تمريره إليها ، دون القلق بشأن ما هو مخفي وراء واجهة Connection .

نعيد كتابة فئة XMLHttpService بحيث تنفذ هذه الواجهة:

 class XMLHttpService implements Connection {   const xhr = new XMLHttpRequest();   //...   request(url: string, opts:any) {       xhr.open();       xhr.send();   } } 

ونتيجة لذلك ، يمكننا إنشاء العديد من الفئات التي تطبق واجهة Connection وهي مناسبة للاستخدام في فئة Http لتنظيم تبادل البيانات عبر الشبكة:

 class NodeHttpService implements Connection {   request(url: string, opts:any) {       //...   } } class MockHttpService implements Connection {   request(url: string, opts:any) {       //...   } } 

كما ترى ، هنا تعتمد الوحدات عالية المستوى ومنخفضة المستوى على التجريد. تعتمد فئة Http (الوحدة النمطية عالية المستوى) على واجهة Connection (التجريد). تعتمد XMLHttpService NodeHttpService و MockHttpService و MockHttpService (الوحدات ذات المستوى المنخفض) أيضًا على واجهة Connection .

بالإضافة إلى ذلك ، تجدر الإشارة إلى أنه وفقًا لمبدأ انعكاس التبعية ، فإننا نلاحظ مبدأ الاستبدال Barbara Liskov. أي أنه يتبين أن أنواع XMLHttpService و NodeHttpService و MockHttpService يمكن أن تعمل كبديل لنوع Connection الأساسي.

الملخص


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

أعزائي القراء! هل تستخدم مبادئ SOLID في مشاريعك؟

Source: https://habr.com/ru/post/ar426413/


All Articles