مروری بر پنج اصلِ شیءگرایی با عنوان SOLID
یکی از مواردی که در مباحث شیءگرایی مهم هستن با عنوان اصول SOLID شناخته میشه که شاید خیلیها شنیده باشند.
واژهٔ SOLID برگرفته شده از پنج اصلِ زیر است:
- S - Single-responsiblity Principle
- O - Open-closed Principle
- L - Liskov Substitution Principle
- I - Interface Segregation Principle
- D - Dependency Inversion Principle
برای پیادهسازی SOLID در C++20، میتوانید از ویژگیهای زبان استفاده کنید. برای مثال:
- برای پیادهسازی SRP، میتوانید از کلاسهای ساختاری (Structs) و کلاسها و متدها که فقط یک وظیفه دارند، استفاده کنید.
- برای پیادهسازی OCP، میتوانید از الگوی Visitor و Strategy استفاده کنید. این باعث میشود که کلاسهای شما قابلیت بستهبودن (Close) و در عین حال قابلیت گسترش (Open) را داشته باشند.
- برای پیادهسازی LSP، میتوانید از وراثت، کلاسهای پایه (Base classes) و کلاسهای مشتق (Derived classes) استفاده کنید و مطمئن شوید که اصول وراثت رعایت شده است.
- برای پیادهسازی ISP، میتوانید از الگوی Interface استفاده کنید که به شما امکان میدهد که کلاسها فقط به آن قسمتهایی از یک Interface نیاز دارند که به آنها لازم است و از بقیه صرف نظر کنند.
- برای پیادهسازی DIP، میتوانید از Dependency Injection استفاده کنید که به شما این امکان را میدهد که از تمام وابستگیهای بین کلاسها جدا شده و بهصورت جداگانه به هم پیوندید، بهطوریکه تغییر در یک کلاس اثرات اصلی بر سایر کلاسها نداشته باشد.
اصلِ اول مربوط به اصل Single-Responsibility Principle یا همان SRP است. این اصل مشخص میکند که کلاسهای شما باید هر کدامشان فقط و فقط باید یک وظیفهٔ مشخص داشته باشند و نه بیشتر!
برای پیادهسازی SRP
بنابراین، اصلِ SRP در شیءگرایی به معنی این است که هر کلاس و متد باید فقط یک مسئولیت یا وظیفه را برعهده داشته باشد. این یعنی که هر کلاس فقط باید یک مورد از نرمافزار را انجام دهد و تغییر در آن، صرفا برای اعمال تغییرات در آن مورد خاص یا در راستای بهبود مسئولیتش باشد.
با رعایت اصل SRP، کدها به راحتی قابل نگهداری، توسعه و آزمایش خواهند بود؛ زیرا هر قطعه از کد فقط برای انجام کار خاص خود طراحی شده است و هیچ گونه وظیفهای به کلاس اضافه نشده است. این نه تنها باعث بهبود خوانایی و قابلیتِ پیشبینیِ کد میشود، بلکه باعث کاهش پیچیدگی و احتمال خطا نیز میشود.
برای پیادهسازی SRP در سیپلاسپلاس مثالی خواهم زد؛ ابتدا باید کلاس را به شکلی طراحی کنید که فقط یک مسئولیت را در بر داشته باشد. به عنوان مثال، فرض کنید یک کلاس برای محاسبهٔ مساحت یک شکل هندسی طراحی میکنید. با توجه به SRP، این کلاس فقط باید مسئول محاسبهٔ مساحت باشد و هیچ وظیفهای دیگر را نباید برعهده بگیرد.
class Shape {
public:
virtual double area() const = 0;
};
class Rectangle : public Shape {
public:
Rectangle(double h, double w) : height(h), width(w) {}
double area() const override {
return height * width;
}
private:
double height;
double width;
};
class Circle : public Shape {
public:
Circle(double r) : radius(r) {}
double area() const override {
return 3.1415 * radius * radius;
}
private:
double radius;
};
در این مثال، کلاس Shape یک مسئولیت واحد دارد که محاسبهٔ مساحت شکل هندسی است. همینطور کلاسهای Rectangle و Circle نیز فقط مسئول محاسبهٔ مساحت هر یک از شکلهای هندسی خود هستند.
به این ترتیب، هر یک از این کلاسها فقط یک مسئولیت دارند و تغییراتی که در آینده رخ میدهد، مربوط به تابعی است که این مسئولیت را پوشش میدهد و هرگونه تغییرات دیگری نباید شامل کلاس شود.
برای پیادهسازی OCP
اصلِ Open/Closed Principle (اصل OCP) در شیءگرایی به معنی ایجاد کلاسها به گونهای است که برای اضافه کردن ویژگی جدید به یک برنامه، نیاز به تغییر کد قبلی نباشد. به عبارت دیگر، کلاسها باید برای توسعه باز باشند (Open)، اما برای تغییر نباشند (Closed). برای پیادهسازی OCP، میتوانید از ارثبری، پلیمورفیسم، استفاده از ابستراکت کلاسها (کلاسهای انتزاعی)، الگوی تزریق وابستگی و ... استفاده کنید. این اصل بهبود پذیری (extensibility) و بهرهوری کد را بهبود میبخشد. همچنین برای پیادهسازی OCP، میتوانید از الگوی Visitor و Strategy استفاده کنید. این باعث میشود که کلاسهای شما قابلیت بستهبودن (Close) و در عین حال قابلیت گسترش (Open) را داشته باشند.
به عنوان مثال، فرض کنید یک برنامه داریم که میتواند اشکال هندسی مختلف را رسم کند. برای پیادهسازی OCP، میتوانید کلاس اشکال هندسی را به گونهای طراحی کنید که بتوانید به راحتی از آنها ارثبری کنید و اشکال جدیدی را به سیستم اضافه کنید بدون ایجاد تغییرات بیشتر در کد قبلی. به عنوان مثال، اینجا یک کد ساده برای این کار نوشته شده است:
#include <iostream>
#include <vector>
class Shape {
public:
virtual void draw() = 0;
};
class Rectangle : public Shape {
public:
void draw() override {
std::cout << "Drawing a rectangle\n";
}
};
class Circle : public Shape {
public:
void draw() override {
std::cout << "Drawing a circle\n";
}
};
class GraphicEditor {
public:
void drawAllShapes(std::vector<Shape*> shapes) {
for (auto shape : shapes) {
shape->draw();
}
}
};
int main() {
GraphicEditor editor;
std::vector<Shape*> shapes = { new Rectangle(), new Circle() };
editor.drawAllShapes(shapes);
for (auto shape : shapes) {
delete shape;
}
return 0;
}
در این مثال، کلاس Shape در واقع یک واسط برای هر یک از شکلهای هندسی است. کلاس Rectangle و کلاس Circle از کلاس Shape ارثبری کردهاند و تابع draw آن را پیادهسازی کردهاند. به این ترتیب، میتوانید در آینده اشکال هندسی جدیدی را به کد اضافه کنید بدون نیاز به تغییر کد اصلی.
برای پیادهسازی LSP
اصل LSP به معنی اصل جایگزینی قابل توجه لیسکوف (Liskov Substitution Principle) در شیءگرایی به این مفهوم است که باید بتوان اشیاء موروثی از یک کلاس را با اشیاء کلاس والد جایگزین کرد. به عبارت دیگر، هر جایگزین یا زیرکلاس باید بتواند عملکرد و ویژگیهای کلاس والد را حفظ کند و بدون هیچ تغییری، به جای کلاس والد استفاده شود. در واقع، هدف این اصل این است که به جای ایجاد جایگزینهایی که منجر به عملکرد غیرقابل پیشبینی میشوند، جایگزینهایی باشند که با انجام کارهایی مانند جایگزینی هنوز هم به نحو شایسته عمل کنند.
فرض کنید که گربه و سگ، از یک کلاس حیوان وارثی هستند. برای همهٔ حیوانات منطقی است که بتوانند صدای حیوان را تولید کنند. در اینجا با توجه به اصل LSP، باید بتوانیم به جای یک شیء گربه، یک شیء سگ یا شیء کلاس والدِ حیوان را به عنوان جایگزین استفاده کنیم و همچنین از آنها بخواهیم که قابلیت تولید صدا را داشته باشند. به عبارت دیگر، صدای گربه و صدای سگ برای ما دقیقاً در یک ردهبندی هستند و در همان حیطه معنایی قرار دارند، بنابراین باید بتوانیم هر یک از آنها را به عنوان جایگزین برای دیگری استفاده کنیم.
یک مثال ساده از اصل LSP در C++20، میتواند مربوط به کلاسهای Shape و Rectangle باشد. فرض کنید کلاس Rectangle از کلاس Shape و از آن ارثبری کرده باشد. برای رعایت اصل LSP، کلاس Rectangle باید تمام روشهای کلاس Shape را پیادهسازی کند، به گونهای که در هر جایی که در کد از کلاس Shape استفاده میشود، میتوانیم جایگزین آن را با کلاس Rectangle استفاده کنیم.
کلاس Shape میتواند به صورت زیر باشد:
class Shape {
public:
virtual double area() = 0;
virtual double perimeter() = 0;
};
و کلاس Rectangle از شکل یک مستطیل با طول و عرض دلخواه پیادهسازی شده است:
class Rectangle : public Shape {
private:
double length;
double width;
public:
Rectangle(double l, double w) : length(l), width(w) {}
double area() override {
return length * width;
}
double perimeter() override {
return 2 * (length + width);
}
};
حال، با استفاده از این کلاسها، میتوانیم مستطیلی با طول و عرض دلخواه ایجاد کنیم و روشهای کلاس Shape را روی آن صدا بزنیم:
Shape* shapePtr = new Rectangle(4, 6);
double area = shapePtr->area();
double perimeter = shapePtr->perimeter();
در اینجا، کلاس Rectangle با کلاس Shape جایگزین شده و اصل LSP رعایت شده است، به این معنی که هر جایی که از کلاس Shape استفاده شده، میتوانیم جایگزین آن را با کلاس Rectangle استفاده کنیم، بدون هیچ تغییری در کد قبلی.
برای پیادهسازی ISP
اصل ISP به معنی اصل جداسازی رابط (Interface Segregation Principle) در شیءگرایی به این مفهوم است که باید رابطها را به گونهای طراحی کرد که اشیاء فقط به آنچه برایشان لازم است دسترسی داشته باشند و به سایر اجزای رابط دسترسی نداشته باشند. به عبارت دیگر، باید یک رابط یکتا و بزرگ به چندین رابط کوچک و مجزا تفکیک شود تا برای هر کلاس، فقط تعداد کم و لازمی از ویژگیها و روشها در دسترس باشد.
برای پیادهسازی ISP، میتوانید از الگوهای طراحی مانند واسطها (Interfaces) و کلاسهای واسط (Abstract Classes) استفاده کنید. با استفاده از این الگوها، میتوانید پیچیدگیهای شناور در برنامه خود را کاهش داده و تغییرات را در کد خود به راحتی انجام دهید.
در واقع، هدف این اصل این است که کلاینتها باید بتوانند با استفاده از رابطهای ساده تر و متعارف، با سیستم تعامل داشته باشند. این روش منجر به افزایش قابلیت توسعه و خودکارسازی کد، بهرهوری بالا و حتی صرفهجویی در زمان و هزینه خواهد شد. برای مثال نمونهٔ زیر به عنوان بخشی از پردازش تصویر میباشد:
ابتدا واسط ImageTransformer را تعریف میکنیم که حاوی متدهای تبدیل تصویر به سیاه و سفید و برعکس آن است:
class ImageTransformer {
public:
virtual void transformToBlackAndWhite() = 0;
virtual void transformToColor() = 0;
};
سپس دو کلاس ImageToBlackAndWhiteTransformer و ImageToColorTransformer را برای پیادهسازی واسط ImageTransformer تعریف میکنیم:
class ImageToBlackAndWhiteTransformer: public ImageTransformer {
public:
void transformToBlackAndWhite() override {
// Implement the transformation to black and white
}
};
class ImageToColorTransformer: public ImageTransformer {
public:
void transformToColor() override {
// Implement the transformation to color
}
};
حال میتوانیم از هر یک از کلاسهای ImageToBlackAndWhiteTransformer و ImageToColorTransformer برای پردازش تصویر استفاده کنیم.
در نهایت، به عنوان مثالی از استفاده از متدهای رابط ImageTransformer، کد زیر را در نظر بگیرید:
void processImage(ImageTransformer& transformer) {
transformer.transformToBlackAndWhite();
transformer.transformToColor();
}
در این کد، با گرفتن یک شیء از کلاسی که از واسط ImageTransformer ارثبری کرده است، میتوانیم تصویر را به سیاه و سفید و برعکس آن تبدیل کنیم که بر اساس اصل ISP طراحی شدهاست.
برای پیادهسازی اصل ISP در C++20 بهطور کلی، میتوان از این ویژگی بهره برد که به نام concepts شناخته میشود. این ویژگی به برنامه نویسان امکان میدهد که شرایط و محدودیتهایی را برای قالبها، ورودیها و خروجیها تعریف کنند و باعث شود که کد بهتری با پایداری بیشتری نوشته شود.
برای استفاده از این ویژگی در پیادهسازی اصل ISP در C++20، میتوانید از مفهومی استفاده کنید که برای اطمینان از قابلیت اجرا و خطاهای برنامه سازی موجود است. برای مثال، در کد زیر، الگوهای واسط که برای پردازش تصویر استفاده میشوند، با نامهای ImageTransformer و GrayscaleTransformer (با استفاده از template) تعریف شدهاند. هر کدام از این الگوهای واسط، تعدادی عملیات مشخص شده را تعریف می کنند. سپس با استفاده از این الگوها، کلاس ImageProcessor (نیز با استفاده از template) برای پردازش تصویر طراحی شده است.
template<typename T>
concept ImageTransformer = requires(T t, cv::Mat image) {
{ t.transform_to_grayscale(image) } -> cv::Mat;
{ t.transform_to_rgb(image) } -> cv::Mat;
};
template<typename T>
concept GrayscaleTransformer = requires(T t, cv::Mat image) {
{ t.transform_to_grayscale(image) } -> cv::Mat;
};
template <typename T>
class ImageProcessor {
public:
ImageProcessor(T* transformerPtr) : transformerPtr_{ transformerPtr } { }
cv::Mat transform_to_grayscale(cv::Mat image) {
return transformerPtr_->transform_to_grayscale(image);
}
cv::Mat transform_to_rgb(cv::Mat image) {
return transformerPtr_->transform_to_rgb(image);
}
private:
T* transformerPtr_;
};
همچنین، میتوانید کلاسهای مربوط به پردازش تصویر یعنی GrayscaleImageTransformer و RGBImageTransformer را به این الگوها منطبق کنید. در کد زیر، چک کردن اینکه یک کلاس منطبق بر پیشنیازهای ImageTransformer است یا نه، انجام شده است.
class GrayscaleImageTransformer : public GrayscaleTransformer {
public:
cv::Mat transform_to_grayscale(cv::Mat image) override {
cv::Mat grayscaleImage;
cv::cvtColor(image, grayscaleImage, cv::COLOR_BGR2GRAY);
return grayscaleImage;
}
};
class RGBImageTransformer : public ImageTransformer {
public:
cv::Mat transform_to_grayscale(cv::Mat image) override {
cv::Mat grayscaleImage;
cv::cvtColor(image, grayscaleImage, cv::COLOR_BGR2GRAY);
return grayscaleImage;
}
cv::Mat transform_to_rgb(cv::Mat image) override {
cv::Mat rgbImage;
cv::cvtColor(image, rgbImage, cv::COLOR_GRAY2BGR);
return rgbImage;
}
};
int main() {
GrayscaleImageTransformer grayscaleTransformer;
RGBImageTransformer rgbTransformer;
ImageProcessor grayscaleProcessor{ &grayscaleTransformer };
cv::Mat image;
image = cv::imread("image.jpg");
auto grayscaleResult = grayscaleProcessor.transform_to_grayscale(image);
ImageProcessor rgbProcessor{ &rgbTransformer };
auto rgbResult = rgbProcessor.transform_to_rgb(grayscaleResult);
cv::imwrite("grey.jpg", grayscaleResult);
cv::imwrite("rgb.jpg", rgbResult);
return 0;
}
برای پیادهسازی DIP
DIP یا Dependency Inversion Principle (اصل وابستگی معکوس) یکی از اصول SOLID در شیءگرایی است که به مفهوم وابستگی به جایی یا Inversion of Control (IOC) هم معروف است. اصل DIP بیان میکند که برنامه باید به گونهای طراحی شود که وابستگی به جزئیات پیادهسازی برنامه کاهش یابد و به جای آن، برنامه باید بر اساس واسطها (interface) ارتباط برقرار کند، به گونهای که خود وابستگی به جزئیات پیادهسازی به صورت بالادستی بر اساس واسطها مدیریت شود.
با استفاده از اصل DIP، کد قابلیت بازگشت و تغییر بهتر را دارد. این بدان معناست که تغییر در پیادهسازی یک کد، تنها روی کد فعلی تأثیر نمیگذارد و به خاطر استفاده از واسطها، به صورت گستردهتر در برنامه تأثیر میگذارد. با رعایت اصل DIP، کدها با استفاده از واسطها باید طراحی شوند و نباید به کد پیادهسازی جزئیات برچسب تمایل یا وابستگی داشته باشند. به عبارت دیگر، برنامه باید بر اساس قرارداد کار کند نه کد پیادهسازی.
یک مثال ساده از پیادهسازی DIP در C++ به صورت زیر است:
فرض کنید یک برنامه ساده را برای محاسبه جدول ضرب در نظر بگیرید. برای پیادهسازی این برنامه، یک کلاس MatrixCalculator وجود دارد که دارای دو متد است که برای محاسبه جدول ضرب به کار میروند: multiply و print.
ابتدا یک واسط مشترک بین MatrixCalculator و کلاسهای مختلف جدول تعریف میکنیم:
class IMatrix {
public:
virtual void multiply() = 0;
virtual void print() = 0;
};
سپس دو کلاس Matrix2x2 و Matrix3x3 را برای پیادهسازی واسط IMatrix تعریف میکنیم:
class Matrix2x2 : public IMatrix {
public:
void multiply() {
// implementation for 2x2 matrix multiplication
}
void print() {
// implementation for 2x2 matrix printing
}
};
class Matrix3x3 : public IMatrix {
public:
void multiply() {
// implementation for 3x3 matrix multiplication
}
void print() {
// implementation for 3x3 matrix printing
}
};
حالا کلاس MatrixCalculator را به گونهای تغییر میدهیم که به جای استفاده از پیادهسازی خاص هر کلاس، از واسط IMatrix استفاده کند:
class MatrixCalculator {
private:
IMatrix* matrix;
public:
MatrixCalculator(IMatrix* m) : matrix(m) {}
void multiply() {
matrix->multiply();
}
void print() {
matrix->print();
}
};
بنابراین حالا میتوانیم کلاس MatrixCalculator را به صورت زیر استفاده کنیم:
IMatrix* m2x2 = new Matrix2x2();
MatrixCalculator calculator2x2(m2x2);
calculator2x2.multiply();
calculator2x2.print();
IMatrix* m3x3 = new Matrix3x3();
MatrixCalculator calculator3x3(m3x3);
calculator3x3.multiply();
calculator3x3.print();
با این رویکرد، هر زمان که یک کلاس جدید با پیادهسازی واسط IMatrix ایجاد شود، میتوانیم آن را به کلاس MatrixCalculator اضافه کرده و آن را بدون تغییر در کد MatrixCalculator استفاده کنیم. همچنین وابستگی MatrixCalculator به کلاسهای Matrix2x2 و Matrix3x3 برای انجام کارش کاهش یافته است.
در C++20 میتوان از ویژگی concepts برای پیادهسازی DIP استفاده کرد. در اینجا مثال سادهای از پیادهسازی DIP با ویژگی concepts در C++20 آورده شده است:
#include <iostream>
#include <concepts>
template <typename T>
concept Comparable = requires(T a, T b) {
{ a == b } -> std::convertible_to<bool>;
{ a != b } -> std::convertible_to<bool>;
};
template <typename T>
class Calculator {
public:
Calculator(T num1, T num2) : num1_(num1), num2_(num2) {}
T add() requires Comparable<T> {
return num1_ + num2_;
}
private:
T num1_;
T num2_;
};
int main() {
Calculator<int> intCalc(5, 10);
std::cout << intCalc.add() << std::endl;
Calculator<float> floatCalc(5.5, 10.5);
std::cout << floatCalc.add() << std::endl;
// Calculator<std::string> strCalc("hello", "world"); // Compile-time error
return 0;
}
در این مثال، ما از ویژگی concepts برای تعریف واسط Comparable استفاده کردهایم. همچنین، کلاس Calculator از واسط Comparable به عنوان یک شرط برای تعریف تابع add استفاده شده است. با این رویکرد، ما به سادگی میتوانیم به جای وابستگی به یک نوع دقیق، وابستگی به یک واسط را ایجاد کنیم.
0 دیدگاه
نظرهای پیشنهاد شده
هیچ دیدگاهی برای نمایش وجود دارد.