مبادئ SOLID في البرمجة الشيئية: دليل شامل للمطورين
تُعد مبادئ SOLID حجر الزاوية في تصميم الفئات الموجهة للكائنات (Object-Oriented Class Design)، وتمثل مجموعة من القواعد وأفضل الممارسات التي يجب اتباعها عند بناء هيكل الفئات. تساعدنا هذه المبادئ الخمسة على فهم الحاجة إلى أنماط تصميم معينة وهندسة البرمجيات بشكل عام، مما يجعلها موضوعًا أساسيًا يجب على كل مطور إتقانه. سيقدم لك هذا المقال كل ما تحتاج لمعرفته لتطبيق مبادئ SOLID في مشاريعك.
سنبدأ باستكشاف تاريخ هذا المصطلح، ثم نتعمق في التفاصيل الجوهرية – الأسباب والكيفية وراء كل مبدأ – من خلال إنشاء تصميم فئة وتحسينه خطوة بخطوة. لذا، استعد لكوب من قهوتك أو شايك المفضل، ولنبدأ!
خلفية تاريخية لمبادئ SOLID
طُرحت مبادئ SOLID لأول مرة بواسطة عالم الحاسوب الشهير روبرت سي. مارتن (المعروف أيضًا باسم ‘العم بوب’) في ورقته البحثية عام 2000. لكن مصطلح SOLID كاختصار قُدم لاحقًا بواسطة مايكل فيذرز. العم بوب هو أيضًا مؤلف الكتب الأكثر مبيعًا مثل Clean Code و Clean Architecture، وهو أحد المشاركين في ‘تحالف أجايل’ (Agile Alliance). لذلك، ليس من المستغرب أن تكون جميع هذه المفاهيم المتعلقة بالكود النظيف، وهندسة الكائنات، وأنماط التصميم مترابطة ومتكاملة. جميعها تخدم نفس الغرض: ‘إنشاء كود مفهوم، قابل للقراءة، وقابل للاختبار، يمكن للعديد من المطورين العمل عليه بشكل تعاوني.’
دعنا نلقي نظرة على كل مبدأ على حدة. باتباع اختصار SOLID، هي كالتالي:
- مبدأ المسؤولية الواحدة (The Single Responsibility Principle)
- مبدأ الفتح/الإغلاق (The Open-Closed Principle)
- مبدأ استبدال ليسكوف (The Liskov Substitution Principle)
- مبدأ تجزئة الواجهة (The Interface Segregation Principle)
- مبدأ عكس التبعية (The Dependency Inversion Principle)
مبدأ المسؤولية الواحدة (SRP)
ينص مبدأ المسؤولية الواحدة على أن الفئة يجب أن تقوم بشيء واحد فقط، وبالتالي يجب أن يكون لديها سبب واحد فقط للتغيير. لبيان هذا المبدأ بشكل أكثر تقنية: يجب أن يكون تغيير محتمل واحد فقط (منطق قاعدة البيانات، منطق التسجيل، وما إلى ذلك) في مواصفات البرنامج قادرًا على التأثير على مواصفات الفئة.
هذا يعني أنه إذا كانت الفئة حاوية بيانات، مثل فئة Book أو فئة Student، وتحتوي على بعض الحقول المتعلقة بهذا الكيان، فيجب أن تتغير فقط عندما نغير نموذج البيانات.
يُعد اتباع مبدأ المسؤولية الواحدة أمرًا مهمًا لعدة أسباب. أولاً، نظرًا لأن العديد من الفرق المختلفة يمكن أن تعمل على نفس المشروع وتعديل نفس الفئة لأسباب مختلفة، فقد يؤدي ذلك إلى وحدات غير متوافقة. ثانيًا، يسهل التحكم في الإصدارات. على سبيل المثال، إذا كان لدينا فئة مسؤولية عن عمليات قاعدة البيانات، ورأينا تغييرًا في هذا الملف في سجلات GitHub، فباتباع مبدأ المسؤولية الواحدة، سنعرف أن التغيير يتعلق بالتخزين أو قاعدة البيانات. تُعد تعارضات الدمج (Merge Conflicts) مثالًا آخر؛ تظهر عندما تقوم فرق مختلفة بتغيير نفس الملف. ولكن إذا تم اتباع مبدأ المسؤولية الواحدة، فستظهر تعارضات أقل – ستكون للفئات سبب واحد للتغيير، وستكون التعارضات الموجودة أسهل في الحل.
المزالق الشائعة والأنماط المضادة
في هذا القسم، سنلقي نظرة على بعض الأخطاء الشائعة التي تنتهك مبدأ المسؤولية الواحدة، ثم سنتحدث عن بعض الطرق لإصلاحها. سننظر إلى كود لبرنامج فاتورة مكتبة بسيط كمثال. لنبدأ بتعريف فئة Book لاستخدامها في فاتورتنا.
class Book {
String name;
String authorName;
int year;
int price;
String isbn;
public Book (String name, String authorName, int year, int price, String isbn) {
this.name = name;
this.authorName = authorName;
this.year = year;
this.price = price;
this.isbn = isbn;
}
}
هذه فئة Book بسيطة تحتوي على بعض الحقول. لا شيء معقد. لم أجعل الحقول خاصة (private) حتى لا نضطر للتعامل مع دوال الجلب والتعيين (getters و setters) ونركز على المنطق بدلاً من ذلك.
الآن دعنا ننشئ فئة Invoice التي ستحتوي على منطق إنشاء الفاتورة وحساب السعر الإجمالي. في الوقت الحالي، افترض أن مكتبتنا تبيع الكتب فقط ولا شيء آخر.
public class Invoice {
private Book book;
private int quantity;
private double discountRate;
private double taxRate;
private double total;
public Invoice (Book book, int quantity, double discountRate, double taxRate) {
this.book = book;
this.quantity = quantity;
this.discountRate = discountRate;
this.taxRate = taxRate;
this.total = this.calculateTotal();
}
public double calculateTotal () {
double price = ((book.price - book.price * discountRate) * this.quantity);
double priceWithTaxes = price * (1 + taxRate);
return priceWithTaxes;
}
public void printInvoice () {
System.out.println(quantity + "x " + book.name + " " + book.price + "$");
System.out.println("Discount Rate: " + discountRate);
System.out.println("Tax Rate: " + taxRate);
System.out.println("Total: " + total);
}
public void saveToFile (String filename) {
// Creates a file with given name and writes the invoice
}
}
هذه هي فئة Invoice الخاصة بنا. تحتوي أيضًا على بعض الحقول المتعلقة بالفواتير و3 دوال (methods): دالة calculateTotal() التي تحسب السعر الإجمالي، دالة printInvoice() التي يجب أن تطبع الفاتورة إلى وحدة التحكم، ودالة saveToFile() المسؤولة عن كتابة الفاتورة إلى ملف.
يجب أن تمنح نفسك لحظة للتفكير فيما هو الخطأ في تصميم هذه الفئة قبل قراءة الفقرة التالية.
حسنًا، ما الذي يحدث هنا؟ تنتهك فئتنا مبدأ المسؤولية الواحدة بطرق متعددة. الانتهاك الأول هو دالة printInvoice()، التي تحتوي على منطق الطباعة الخاص بنا. ينص مبدأ المسؤولية الواحدة على أن فئتنا يجب أن يكون لديها سبب واحد فقط للتغيير، ويجب أن يكون هذا السبب تغييرًا في حساب الفاتورة لفئتنا. ولكن في هذه البنية، إذا أردنا تغيير تنسيق الطباعة، فسنحتاج إلى تغيير الفئة. لهذا السبب يجب ألا نخلط منطق الطباعة مع منطق العمل في نفس الفئة.
هناك دالة أخرى تنتهك مبدأ المسؤولية الواحدة في فئتنا: دالة saveToFile(). من الأخطاء الشائعة جدًا أيضًا خلط منطق الثبات (Persistence Logic) مع منطق العمل. لا تفكر فقط في الكتابة إلى ملف – فقد يكون حفظًا في قاعدة بيانات، أو إجراء استدعاء واجهة برمجة تطبيقات (API)، أو أشياء أخرى متعلقة بالثبات.
إذًا، كيف يمكننا إصلاح دالة الطباعة هذه، قد تتساءل؟ يمكننا إنشاء فئات جديدة لمنطق الطباعة والثبات الخاص بنا، وبالتالي لن نحتاج بعد الآن إلى تعديل فئة Invoice لهذه الأغراض. سنقوم بإنشاء فئتين، InvoicePrinter و InvoicePersistence، وننقل الدوال إليهما.
public class InvoicePrinter {
private Invoice invoice;
public InvoicePrinter (Invoice invoice) {
this.invoice = invoice;
}
public void print () {
System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $");
System.out.println("Discount Rate: " + invoice.discountRate);
System.out.println("Tax Rate: " + invoice.taxRate);
System.out.println("Total: " + invoice.total + " $");
}
}
public class InvoicePersistence {
Invoice invoice;
public InvoicePersistence (Invoice invoice) {
this.invoice = invoice;
}
public void saveToFile (String filename) {
// Creates a file with given name and writes the invoice
}
}
الآن، يطيع هيكل فئتنا مبدأ المسؤولية الواحدة، وكل فئة مسؤولة عن جانب واحد من تطبيقنا. ممتاز!
مبدأ الفتح/الإغلاق (OCP)
يتطلب مبدأ الفتح/الإغلاق أن تكون الفئات مفتوحة للتوسع ومغلقة للتعديل. التعديل يعني تغيير الكود الخاص بفئة موجودة، والتوسع يعني إضافة وظائف جديدة. لذا، ما يريد هذا المبدأ قوله هو: يجب أن نكون قادرين على إضافة وظائف جديدة دون لمس الكود الموجود للفئة.
هذا لأننا كلما قمنا بتعديل الكود الموجود، فإننا نخاطر بإنشاء أخطاء محتملة. لذلك يجب أن نتجنب لمس كود الإنتاج المختبر والموثوق (في الغالب) إن أمكن. ولكن كيف سنضيف وظائف جديدة دون لمس الفئة، قد تتساءل؟ يتم ذلك عادة بمساعدة الواجهات (interfaces) والفئات المجردة (abstract classes).
الآن بعد أن غطينا أساسيات المبدأ، دعنا نطبقه على تطبيق الفاتورة الخاص بنا. لنفترض أن مديرنا جاء إلينا وقال إنه يريد حفظ الفواتير في قاعدة بيانات حتى نتمكن من البحث عنها بسهولة. نفكر: حسنًا، هذا سهل جدًا يا سيدي، فقط أعطني ثانية! نقوم بإنشاء قاعدة البيانات، والاتصال بها، ونضيف دالة save() إلى فئة InvoicePersistence الخاصة بنا:
public class InvoicePersistence {
Invoice invoice;
public InvoicePersistence (Invoice invoice) {
this.invoice = invoice;
}
public void saveToFile (String filename) {
// Creates a file with given name and writes the invoice
}
public void saveToDatabase () {
// Saves the invoice to database
}
}
لسوء الحظ، نحن، كمطورين كسولين لمكتبة الكتب، لم نصمم الفئات لتكون قابلة للتوسع بسهولة في المستقبل. لذلك، من أجل إضافة هذه الميزة، قمنا بتعديل فئة InvoicePersistence. إذا كان تصميم فئتنا يطيع مبدأ الفتح/الإغلاق، فلن نحتاج إلى تغيير هذه الفئة.
لذلك، كمطورين كسولين ولكن أذكياء لمكتبة الكتب، نرى مشكلة التصميم ونقرر إعادة هيكلة الكود (refactor) لاتباع المبدأ.
interface InvoicePersistence {
public void save (Invoice invoice);
}
نغير نوع InvoicePersistence إلى واجهة (interface) ونضيف دالة save(). ستقوم كل فئة ثبات بتنفيذ دالة save() هذه.
public class DatabasePersistence implements InvoicePersistence {
@Override
public void save (Invoice invoice) {
// Save to DB
}
}
public class FilePersistence implements InvoicePersistence {
@Override
public void save (Invoice invoice) {
// Save to file
}
}
الآن يبدو هيكل فئتنا هكذا:

الآن أصبح منطق الثبات الخاص بنا قابلاً للتوسع بسهولة. إذا طلب منا مديرنا إضافة قاعدة بيانات أخرى ووجود نوعين مختلفين من قواعد البيانات مثل MySQL و MongoDB، يمكننا القيام بذلك بسهولة.
قد تعتقد أنه يمكننا فقط إنشاء فئات متعددة بدون واجهة وإضافة دالة save() إلى جميعها. ولكن لنفترض أننا نوسع تطبيقنا ولدينا فئات ثبات متعددة مثل InvoicePersistence و BookPersistence وننشئ فئة PersistenceManager التي تدير جميع فئات الثبات:
public class PersistenceManager {
InvoicePersistence invoicePersistence;
BookPersistence bookPersistence;
public PersistenceManager (InvoicePersistence invoicePersistence, BookPersistence bookPersistence) {
this.invoicePersistence = invoicePersistence;
this.bookPersistence = bookPersistence;
}
}
يمكننا الآن تمرير أي فئة تنفذ واجهة InvoicePersistence إلى هذه الفئة بمساعدة تعدد الأشكال (polymorphism). هذه هي المرونة التي توفرها الواجهات.
مبدأ استبدال ليسكوف (LSP)
ينص مبدأ استبدال ليسكوف على أن الفئات الفرعية يجب أن تكون قابلة للاستبدال بفئاتها الأساسية. هذا يعني أنه، بافتراض أن الفئة B هي فئة فرعية من الفئة A، يجب أن نكون قادرين على تمرير كائن من الفئة B إلى أي دالة تتوقع كائنًا من الفئة A، ويجب ألا تعطي الدالة أي مخرجات غريبة في هذه الحالة.
هذا هو السلوك المتوقع، لأنه عندما نستخدم الوراثة، نفترض أن الفئة الابنة ترث كل ما تملكه الفئة الأم. الفئة الابنة توسع السلوك ولكنها لا تضيقه أبدًا. لذلك، عندما لا تلتزم الفئة بهذا المبدأ، فإن ذلك يؤدي إلى بعض الأخطاء السيئة التي يصعب اكتشافها. مبدأ ليسكوف سهل الفهم ولكنه صعب الاكتشاف في الكود. لذا دعنا نلقي نظرة على مثال.
class Rectangle {
protected int width, height;
public Rectangle () {
}
public Rectangle ( int width, int height) {
this.width = width;
this.height = height;
}
public int getWidth () {
return width;
}
public void setWidth ( int width) {
this.width = width;
}
public int getHeight () {
return height;
}
public void setHeight ( int height) {
this.height = height;
}
public int getArea () {
return width * height;
}
}
لدينا فئة Rectangle بسيطة، ودالة getArea() التي تعيد مساحة المستطيل. الآن قررنا إنشاء فئة أخرى للمربعات (Squares). كما تعلم، المربع هو مجرد نوع خاص من المستطيلات حيث يكون العرض مساويًا للارتفاع.
class Square extends Rectangle {
public Square () {
}
public Square ( int size) {
width = height = size;
}
@Override
public void setWidth ( int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight ( int height) {
super.setHeight(height);
super.setWidth(height);
}
}
فئة Square الخاصة بنا ترث من فئة Rectangle. لقد قمنا بتعيين الارتفاع والعرض لنفس القيمة في المُنشئ (constructor)، لكننا لا نريد أن يقوم أي عميل (شخص يستخدم فئتنا في كوده) بتغيير الارتفاع أو العرض بطريقة قد تنتهك خاصية المربع. لذلك، قمنا بتجاوز دوال التعيين (setters) لتعيين كلا الخاصيتين كلما تم تغيير إحداهما. ولكن بفعل ذلك، انتهكنا للتو مبدأ استبدال ليسكوف.
دعنا ننشئ فئة رئيسية (main class) لإجراء اختبارات على دالة getArea().
class Test {
static void getAreaTest (Rectangle r) {
int width = r.getWidth();
r.setHeight(10);
System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea());
}
public static void main (String[] args) {
Rectangle rc = new Rectangle(2, 3);
getAreaTest(rc);
Rectangle sq = new Square();
sq.setWidth(5);
getAreaTest(sq);
}
}
جاء مختبر فريقك بدالة الاختبار getAreaTest() ويخبرك أن دالة getArea() تفشل في اجتياز الاختبار لكائنات المربع. في الاختبار الأول، ننشئ مستطيلًا حيث العرض 2 والارتفاع 3 ونستدعي getAreaTest(). الناتج هو 20 كما هو متوقع، لكن الأمور تسوء عندما نمرر المربع. هذا لأن استدعاء دالة setHeight() في الاختبار يقوم بتعيين العرض أيضًا وينتج عنه مخرج غير متوقع.
مبدأ تجزئة الواجهة (ISP)
تجزئة (Segregation) تعني إبقاء الأشياء منفصلة، ومبدأ تجزئة الواجهة يدور حول فصل الواجهات. ينص المبدأ على أن الواجهات المتعددة الخاصة بالعميل أفضل من واجهة واحدة ذات أغراض عامة. يجب ألا يُجبر العملاء على تنفيذ دالة لا يحتاجونها. هذا مبدأ بسيط للفهم والتطبيق، لذا دعنا نرى مثالًا.
public interface ParkingLot {
void parkCar (); // Decrease empty spot count by 1
void unparkCar (); // Increase empty spots by 1
void getCapacity (); // Returns car capacity
double calculateFee (Car car); // Returns the price based on number of hours
void doPayment (Car car);
}
class Car {
}
لقد قمنا بنمذجة موقف سيارات مبسط للغاية. إنه نوع موقف السيارات الذي تدفع فيه رسومًا بالساعة. الآن لنفترض أننا نريد تطبيق موقف سيارات مجاني.
public class FreeParking implements ParkingLot {
@Override
public void parkCar () {
}
@Override
public void unparkCar () {
}
@Override
public void getCapacity () {
}
@Override
public double calculateFee (Car car) {
return 0;
}
@Override
public void doPayment (Car car) {
throw new Exception("Parking lot is free");
}
}
كانت واجهة ParkingLot الخاصة بنا تتألف من شيئين: منطق متعلق بركن السيارات (parkCar(), unparkCar(), getCapacity()) ومنطق متعلق بالدفع (calculateFee(), doPayment()). لكنها كانت محددة جدًا. وبسبب ذلك، اضطرت فئة FreeParking الخاصة بنا إلى تنفيذ دوال متعلقة بالدفع لا صلة لها بها.
دعنا نفصل أو نجزئ الواجهات.

لقد قمنا الآن بفصل منطق موقف السيارات. باستخدام هذا النموذج الجديد، يمكننا حتى المضي قدمًا وتقسيم PaidParkingLot لدعم أنواع مختلفة من الدفع. الآن أصبح نموذجنا أكثر مرونة وقابلية للتوسع، ولا يحتاج العملاء إلى تنفيذ أي منطق غير ذي صلة لأننا نوفر فقط الوظائف المتعلقة بركن السيارات في واجهة موقف السيارات.
مبدأ عكس التبعية (DIP)
ينص مبدأ عكس التبعية على أن فئاتنا يجب أن تعتمد على الواجهات أو الفئات المجردة بدلاً من الفئات والدوال الملموسة (Concrete Classes and Functions). في مقالته (2000)، يلخص العم بوب هذا المبدأ على النحو التالي: ‘إذا كان مبدأ الفتح/الإغلاق (OCP) يحدد هدف هندسة الكائنات، فإن مبدأ عكس التبعية (DIP) يحدد الآلية الأساسية’.
هذان المبدآن مرتبطان بالفعل، وقد طبقنا هذا النمط من قبل عندما كنا نناقش مبدأ الفتح/الإغلاق. نريد أن تكون فئاتنا مفتوحة للتوسع، لذلك قمنا بإعادة تنظيم تبعياتنا لتعتمد على الواجهات بدلاً من الفئات الملموسة. فئة PersistenceManager الخاصة بنا تعتمد على InvoicePersistence (الواجهة) بدلاً من الفئات التي تنفذ تلك الواجهة (مثل DatabasePersistence أو FilePersistence).
الخلاصة التقنية
في هذا المقال، بدأنا بتاريخ مبادئ SOLID، ثم حاولنا اكتساب فهم واضح للأسباب والكيفية وراء كل مبدأ. لقد قمنا حتى بإعادة هيكلة تطبيق فاتورة بسيط لاتباع مبادئ SOLID. إن تطبيق هذه المبادئ ليس مجرد ممارسة أكاديمية، بل هو استثمار مباشر في جودة الكود على المدى الطويل. يضمن الالتزام بـ SOLID بناء أنظمة برمجية أكثر مرونة، وأسهل في الصيانة، وأقل عرضة للأخطاء عند التوسع أو التغيير. هذه المبادئ تمكن المطورين من كتابة كود يمكن فهمه، اختباره، وتعديله بثقة، مما يعزز التعاون ويسرع عملية التطوير. تذكر دائمًا أن الكود النظيف هو أساس البرمجيات الناجحة.
أشكرك على تخصيص وقت لقراءة المقال بالكامل، وآمل أن تكون المفاهيم المذكورة أعلاه واضحة. أقترح الاحتفاظ بهذه المبادئ في ذهنك أثناء تصميم وكتابة وإعادة هيكلة الكود الخاص بك، حتى يصبح الكود الخاص بك أنظف وأكثر قابلية للتوسع والاختبار. إذا كنت مهتمًا بقراءة المزيد من المقالات المشابهة، يمكنك الاشتراك في القائمة البريدية لمدونتي لتلقي إشعارات عند نشر مقال جديد.