1. مقدمه:
در آینده نه چندان دور خواهیم دید که پایه و اصل یک معماری خوب فهم درست و استفاده صحیح از اصول برنامهنویسی شیگرا است. اما OO چیست؟
شاید سادهترین پاسخ به این سوال این باشد "ترکیب عملکرد و دادهها با هم". هر چند این تعریف به دلایل زیادی نمیتواند صحیح باشد و دلالت بر این دارد تکه کدهای زیر با هم متفاوت است.
o.f()
f(o)
این یک تعریف ناقص است، برنامه نویسان بسیار قبلتر از سال 1966 دادهها را به توابع ارسال میکردند، یعنی پیش از اینکه دال و نیگارد function call stack frame را به heap انتقال دهند و شیگرایی را ابدا کنند.
یکی دیگر از جوابهایی که به این پرسش داده میشود این است "روشی برای مدلسازی دنیای واقعی". قطعا یکی از بهترین راههای فرار برای پاسخ به این سوال همین است. اما واقعا مدل دنیای حقیقی چه معنایی دارد؟ اصلا چرا باید تمایل داشته باشیم همچین کاری انجام دهیم؟ شاید منظور اصلی از این جمله این باشد که با توجه به شباهت برنامهنویسی به این روش با دنیای واقعی درک آن سادهتر است. اما با این حال باز هم این تعریف بسیار ساده انگارانه و به دور از واقعیت است و هیچ کمکی به درک بهتر برنامهنویسی شیگرا نمیکند.
برخی دیگر برای تعریف این روش برنامهنویسی دست به دامان سه کلمه جادویی در این حوزه میشوند. کپسولهسازی، چند ریختی و ارث بری. پیامد این تعریف هم این است که برنامهنویسی شیگرا یعنی ترکیبی از این سه کلمه کلیدی یا اینکه زبان برنامهنویسی شیگرا باید این سه ویژگی را داشته باشد. بیایید این سه کلمه جادویی را دقیقتر بشناسیم.
2. کپسولهسازی:
دلیل اینکه کپسولهسازی به عنوان یکی از اصول شیگرایی شناخته میشود این است که زبانهای شیگرا روشی ساده و کارآمد جهت کپسولهسازی دادهها و عملکردها ایجاد میکنند. در نتیجه میتوان مرزی برای دادهها و عمکردهای مرتبط با هم تعیین کرد. بیرون این مرز هیچ اثری از دادهها دیده نمیشود و تنها میتوان از عملکردها عمومی مطلع شد و از آنها استفاده کرد. این عملکرد را احتمالا در تعریف اعضای دادهای خصوصی و توابع عمومی در کلاسها مشاهده کردهاید.
این ویژگی تنها مطلعق به شیگرایی و زبانهای شیگرا نیست و زبانهایی دیگری مانند C نیز از این ویژگی برخوردار هستند. این زبانها نیز توانایی مرزبندی و محدود سازی دسترسی به اعضای خود را دارند. برای مثال به تکه کد زیر به زبان c دقت کنید.
point.h ----------------------------------------------------------------------------------------------------------------------------------------- struct Point; struct Point* makePoint(double x, double y); double distance (struct Point *p1, struct Point *p2);
point.c ----------------------------------------------------------------------------------------------------------------------------------------- #include "point.h" #include <stdlib.h> #include <math.h> struct Point { double x,y; };
struct Point* makepoint(double x, double y) { struct Point* p = malloc(sizeof(struct Point)); p->x = x; p->y = y; return p; }
double distance(struct Point* p1, struct Point* p2) { double dx = p1->x - p2->x; double dy = p1->y - p2->y; return sqrt(dx*dx+dy*dy); }
استفاده کننده از point.h دانش و دسترسی به اعضای داخلی struct Point ندارد. استفاده کنندهها میتوانند تابع makepoint را صدا بزنند یا از تابع distance استفاده کنند. اما دانشی در مورد پیاده سازی داخلی آنها و ساختار دادهای داخلی آن ندارند.
این یک پیاده سازی بیعیب از کپسولهسازی در زیانهای غیر OO است و برنامهنویسان C سالهاست که از این روش استفاده میکنند. آنها تعریف ساختمان داده و متدهای مورد نیاز را در فایلهای header انجام میدهند و سپس آنها را در محلی دیگر پیاده سازی میکنند. استفاده کنندگان نیز هرگز درسترسی به جزئیات پیاده سازیها ندارند و صرفا از بخشهای عمومی مطلع هستند.
با ظهور C++ اما این پیاده سازی بی عیب و نقص کپسوله سازی در C از بین رفت. به دلایل فنی (کامپایلر C++ نیاز داشت تا سایز نمونه ایجاد شده از یک کلاس را بداند.) کامپایلر C++ نیاز داشت تا اعضای دادهای در فایل Header مربوط به کلاس تعریف شوند. بنابراین طراحی و پیاده سازی کلاس Point برای زبان C++ به شکل زیر تغییر یافت:
point.h ----------------------------------------------------------------------------------------------------------------------------------------- class Point { public: Point(double x, double y); double distance(const Point& p) const; private: double x; double y; };
point.cc ----------------------------------------------------------------------------------------------------------------------------------------- #include "point.h" #include <math.h> Point::Point(double x, double y) : x(x), y(y) {} double Point::distance(const Point& p) const { double dx = x-p.x; double dy = y-p.y; return sqrt(dx*dx + dy*dy); }
با این شرایط استفاده کننده از فایل point.h از وجود متغیرهای x و y مطلع میشود. هرچند که کامپایلر جلوی دسترسی به این متغیرها را سد میکند، اما به هرحال درگیر استفاده کننده از حضور این دادهها بیاطلاع نیست. اگر نام این متغیرها را تغییر دهیم point.cc نیاز به کامپایل مجدد دارد. کپسوله سازی از دست رفته است.
با معرفی کلمات کلیدی public, private و protected به زبان کپسوله سازی کمی بهبود پیدا کرد. اما به هرحال حضور این متغیرها در فایل header به خاطر نیاز کامپایلر به دانستن حضور آنها ما را با چالشهایی مواجه میکند.
در جاوا و سیشارپ استفاده از فایل header برای جدا سازی تعریف کلاس از پیاده سازی آن کاملا منسوخ شد و کنار گذاشته شد و در این زبانها تجاوز به کپسوله سازی با شدت بیشتری ادامه پیدا کرد.
به همین خاطر است که پذیرفتن اینکه شیگرایی وابستگی شدیدی به کپسوله سازی دارد کار سختی است. زبانهای بسیاری مانند جاوااسکریپت، پایتون، روبی و لوا وجود دارند که برای پیاده سازی کپسوله سازی یا روالی ندارند یا چندان کار زیادی در این زمینه انجام نمیدهند.
3. ارث بری:
اگر زبانهای شیگرا کپسوله سازی بهتری از پیشینیان خود در اختیار ما قرار نمیدهند، قطعا ارث بری را در اختیار ما میگذارند.
با کمی دقت در مییابیم ارث بری نحوه جدیدی از گروهبندی دادهها و توابع در محدودهای قابل دسترس است. این کاری است که برنامه نویسان C بسیار قبل از تولد زبانهای شی گرا توانایی انجام آن را داشتند. تکه کد زیر را در نظر بگیرید:
namedPoint.h ----------------------------------------------------------------------------------------------------------------------------------------- struct NamedPoint; struct NamedPoint* makeNamedPoint(double x, double y, char* name); void setName(struct NamedPoint* np, char* name); char* getName(struct NamedPoint* np);
namedPoint.c ----------------------------------------------------------------------------------------------------------------------------------------- #include "namedPoint.h" #include <stdlib.h> struct NamedPoint { double x,y; char* name; }; struct NamedPoint* makeNamedPoint(double x, double y, char* name) { struct NamedPoint* p = malloc(sizeof(struct NamedPoint)); p->x = x; p->y = y; p->name = name; return p; } void setName(struct NamedPoint* np, char* name) { np->name = name; } char* getName(struct NamedPoint* np) { return np->name; }
main.c ----------------------------------------------------------------------------------------------------------------------------------------- #include "point.h" #include "namedPoint.h" #include <stdio.h>
int main(int ac, char** av) { struct NamedPoint* origin = makeNamedPoint(0.0, 0.0, "origin"); struct NamedPoint* upperRight = makeNamedPoint(1.0, 1.0, "upperRight"); printf("distance=%f\n", distance((struct Point*) origin,struct Point*) upperRight)); }
به کدهای داخل main.c دقت کنید. عملکرد NamedPoint به گونهای است که گویا از Point مشتق شده است. این به این خاطر است که ترتیب فیلدهای NamedPoint مانند Point است. در این شرایط هر زمانی نیاز باشد NamedPoint میتواند خود را به جای Point جا بزند.
این حقهای است که برنامهنویسان C قبل از ظهور و بروز شیگرایی به کار میبرند و استفاده از همین حقه در زبان C++ هم باعث پیاده سازی آن به صورت Single Inheritance شد.
هر چند با کمی حقه بازی به این دستاورد رسیدهایم و استفاده از این حقه کمی سخت است و امکان پیاده سازی ارثبری چندگانه را نیز برای ما ایجاد نمیکند. وحتی اگر دقت کنیم، در main.c برای پیاده سازی شرایط ارث بری نیاز به cast کردن نوع Point به NamedPoint داشتیم، که این کار در زبانهای شیگرا به صورت اتوماتیک انجام میشود، اما با کمی اغماض میتوانیم بگوییم پیش از زبانهای شیگرا هم ارثبری وجود داشته است.
کمی منصفانه اگر به قضیه نگاه کنیم میتوانیم بگوییم هرچند روشهایی برای پیادهسازی ارث بری قبل از معرفی شیگرایی وجود داشته، اما ظهور و بروز شیگرایی و زبانهای شیگرا موجب تسهیل پیاده سازی این کار شده است.
صرفا جهت یادآوری: تا اینجا فهمیدیم که کپسوله سازی دستاودری برای شیگرایی نیست و ارثبری هم خیلی ابداع و اختراع جدیدی محسوب نمیشود، صرفا کمی ساده سازی شرایط اتفاق افتاده است. اما هنوز یک کلمه جادویی دیگر در کیسه خود داریم که باید به سراغ آن برویم:
4. چند ریختی:
آیا ما قبل از شیگرایی عملکردی شبیه به چندریختی در اختیار داشتیم؟ جواب این سوال به طور قطع بله است. تکه کد C زیر برای پیاده سازی copy را مشاهده کنید.
#include <stdio.h> void copy() { int c; while ((c=getchar()) != EOF) putchar(c); }
تابع getchar مقداری را از STDIN دریافت میکند. اما کدام دستگاه STDIN است؟ تابع putchar نیز مقداری را در STDOUT مینویسد. اما واقعا کدام دستگاه STDOUT است؟ این توابع چندریختی هستند و کارکرد آنها با توجه به STDIN و STDOUT کاملا متفاوت است. اگر برنامه نویس جاوا یا سیشارپ باشید احتمالا میگویید اینکه کاری ندارد، یک Interface برای STDINT و STDOU تعریف شده و در putchar و getcharاز آن استفاده شده است. اما دقت کنید که این برنامه به زبان C نوشته شده و Interface وجود خارجی ندارد. پس چطور این اتفاق میافتد که مثلا getcharمقدار را از دستگاه صحیح و به شکل صحیح دریافت میکند؟
جواب ساده است. سیستمهای UNIX احتیاج دارند که همه دستگاههای ورودی و خروجی پنج تابع استاندارد را فراهم کنند که عبارتند از open, close, read, write و seek. تعریف ساختار این توابع نیز باید برای تمام دستگاهها دقیقا یکسان باشد.
ساختمان داده File شامل پنج اشارهگر تابع میشود که مطابق تکه کد زیر است.
struct FILE { void (*open)(char* name, int mode); void (*close)(); int (*read)(); void (*write)(char); void (*seek)(long index, int mode); };
حال برای پیاده سازی کنسول کافی است که از این ساختمان داده استفاده شود و توابع مربوطه به اشارهگرها متصل شود که پیاده سازی ان به شکل زیر میشود:
#include "file.h" void open(char* name, int mode) {/*...*/} void close() {/*...*/}; int read() {int c;/*...*/ return c;} void write(char c) {/*...*/} void seek(long index, int mode) {/*...*/} struct FILE console = {open, close, read, write, seek};
به این روش در برنامهها از File استفاده میشود و File درخواستها را به ورودی و خروجی منتخب هدایت میکند. به پیاده سازی getchar در تکه کد زیر دقت کنید:
extern struct FILE* STDIN; int getchar() { return STDIN->read(); }
در حقیقت stdin با کمک و واسطه گری File درخواست را به تابع read در ورودی استاندارد منتخب میرساند. این تکنیک ساده اساس پیاده سازی چندریختی در برنامههای شیگرا را بنا نهاد. به عنوان مثال در c++ همه توابع virtual داخل کلاس اشاره گری به جدولی با نام vtable دارند و همه استفاده از توابع virtual از طریق این جدول مدیریت و مسیریابی میشوند. سازندههای کلاسهای مشتقشده از یک کلاس اطلاعات مربوط به پیادهسازیهای خود را در این جدول ثبت میکنند.
کلام آخر اینکه چندریختی استفادهای از اشارهگرها به توابع هستند. برنامه نویسان از سالها قبل از معرفی شیگرایی و از این تکنیک و اشارهگرهای به توابع برای به دست آوردن چندریختی استفاده میکردند. دقیقتر اگر بخواهیم صحبت کنیم، در این زمینه هم شیگرایی حرف جدیدی برای گفتن ندارد.
در حقیقت زبانهای شیگرا امکان جدیدی در اختیار ما قرار نداند، بلکه یک ویژگی و روش قدیمی را به روشی امن و آسان در اختیار ما قرار دادند. همه ما میدانیم استفاده از اشارهگرها به توابع چقدر میتواند خطرناک باشد. مدیریت این اشارهگرها به یاد داشتن همه شرایط کاری بسیار دشوار و خطرناک است. اگر به هر دلیلی خطایی اتفاق بیوفتد رهگیری و رفع خطا بسیار سخت است.
زبانهای شی گرا کاری که انجام دادند حذف فرایندهای دستی مدیریت چند ریختی و توکار کردن فرایند ایجاد و مدیریت این اشارهگرها بود که در نتیجه خطرات استفاده از آنها نیز از بین رفت. سادگی و امنیتی که برای برنامه نویسان تا قبل از شیگرایی آرزویی دست نیافتنی بود.
بر این مبنا ما میتوانیم نتیجه گیری کنیم که OO در جهت معکوسسازی کنترل جریان برنامه قواعدی را ایجاد کرده است.
4.1. قدرت چند ریختی:
واقعا چه چیز فوقالعادهای در مورد چند ریختی وجود دارد؟ برای درک بهتر جذابیتها، بیایید مجددا به سراغ مثال کپی برویم. اگر دستگاه ورودی و خروجی جدیدی ابداع شود چه اتفاقی برای آن تکه کد میافتد؟ فرض کنید که میخواهیم دادههایی را از ورودی که توانایی تحلیل دستخط انسان را دارد دریافت کنیم و برای خروجی که توانایی خواندن مطالب را دارد ارسال کنیم. چه تغییری باید در پیاده سازی تابع copy ایجاد کنیم تا با این ورودی و خروجیهای جدید کار کند؟
ما نیاز نداریم برنامه خود را تغییر دهیم. دقیقا نکته کار همینجاست. حتی نیاز به کامپایل مجدد برنامهخود نیز نداریم. اما چرا؟ چون در برنامه Copy هیچ وابستگی به نحوه پیاده سازی ورودی و خروجی وجود ندارد. تا زمانی که دستگاههای ورود و خروجی ما آن پنج تابع مورد نظر برای File را دارا باشند برنامه copy ما میتواند از آنها استفاده کند.
در حقیقت دستگاههای ورودی و خروجی به عنوان پلاگینهایی به برنامه copy متصل میشوند.
اما چرا سیستمهای UNIX تمامی ورودی و خروجیها را به عنوان پلاگین در نظر میگیرند؟ به خاطر اینکه از اوخر دهه 1950 یادگرفتیم که برنامههای ما نباید به دستگاههای ورودی و خروجی وابسته باشند. به خاطر اینکه برنامههای زیادی وابسته به ورودی و خروجی نوشته شد تا در نهایت فهمیدیم که نیاز داریم یک برنامه باید بتواند با ورودی و خروجیهای متفاوتی کار کند.
در اوایل ورودی ها کارتهای پانچ شده بودند و برای انتشار خروجی نیز کارتهای جدیدی پانچ میشد. ناگهان استفاده از کارتها متوقف شد و استفاده کنندگان شروع به استفاده از نوارها کردند. این کار بسیار سختی بود، چون باید بخش زیادی از برنامهها باز نویسی میشد و از همان زمان یاد گرفتیم که برنامهها باید از دستگاههای ورودی و خروجی جدا و بی اطلاع باشند.
معماری برنامهها به این روش و با استفاده از پلاگینها در اغلب سیستمهای عامل برای دستگاههای ورودی و خروجی مورد استفاده قرار گرفت. با این حال برنامه نویسان، با توجه به نیاز به استفاده از اشارهگر به توابع برای پیاده سازی این معماری تمایل چندانی به استفاده از این روش در برنامههای عادی خود نداشتند.
شیگرایی امکان پیاده سازی معماری مبتنی بر پلاگینها را در هر برنامهای و هر چیزی فراهم کرد.
4.2. معکوسسازی وابستگی:
بیایید با هم تصور کنیم که نرمافزارها پیش از اینکه روش سادهای برای پیادهسازی چندریختی ایجاد شود به چه شکلی بودند. در یک برنامه عادی تابع main یک تابع سطح بالا را صدا میزد،سپس تابع سطح بالا یک تابع سطح میانیرا صدا میزد و در ادامه آن تابع سطح میانی یک تابع سطح پایین را صدا میزد. این جریان را جریان کنترل برنامه مینامیم. با مشاهده تصویر زیر درمیابیم که جریان کنترل برنامه و جریان وابستگی سورس کد برنامه مشابه یکدیگر بوده اند.
در تابع main برای استفاده از یک ویژگی، ماژول مورد نیاز پیادهسازی کننده آن ویژگی باید اسم برده میشود. این کار در C به کمک include انجام میشود. در جاوا import و در سیشارپ using این وظیفه را انجام میدهند. به همین ترتیب هر ماژولی نیاز به استفاده از ماژول دیگر داشته باشد باید آن را نام ببرد.
در این روش پیاده سازی جریان وابستگی توسط جریان کنترل برنامه به برنامه تحمیل میشود. اما با معرفی چند ریختی، راهکار کاملا متفاوتی را میتوان در نظر گرفت.
همانطور که در تصویر بالا مشاهده میکنید، ماژول HL1 تابع F در ماژول ML1 را استفاده میکند و این کار را با واسطه اینترفیس I انجام میدهد. هنگام اجرای برنامه خبری از I نیست و HL1 مستقیما به سراغ ML1 و تابع F میرود. حالا این ML1 است که به I وابسته شده است. جریان کنترل برنامه با جریان وابستگی متفاوت شده است. به این روش اصطلاحا معکوس سازی وابستگی یا dependency inversion گفته میشود که تاثیرات عمیقی بر طراحی و پیادهسازی نرمافزار دارد.
در حقیقت زبانهای شیگرا روشی امن و ساده برای پیادهسازی چند ریختی ایجاد کردند که باعث شد معکوسسازی وابستگی در هرجایی و هرشرایطی به سادگی قابل پیاده باشد.
مجددا به تصور اول مربوط به جریان کنترلبرنامه و جریان وابستگی بازگردید و ببیند در کدام قسمتها با استفاده از این تکنیک جریان کنترل برنامه و وابستگی امکان معکوسسازی دارند.
با استفاده از این روش، معماران نرمافزار کنترل کاملی بر رو جهت وابستگی سورسکد در سیستم دارند. دیگر اجباری برای یکسان در نظر گرفتن جهت کنترل و جهت وابستگی وجود ندارد. اصلا مهم نیست که هنگام اجرا کدام ماژول قرار است از امکانات کدام ماژول استفاده کند. جهت وابستگی سورس کد کاملا به تصمیم معمار بستگی دارد.
این یعنی قدرت. این قدرت اصلی است که OO برای ما به ارمغان آورد. حداقل از منظر یک معمار نرمافزار این بهترین هدیه OO است.
حال با این قدرت چه کارهایی قابل انجام است؟ حال با این قدرتی که در اختیار داریم میتوانیم جریان وابستگی را به گونهای تغییر دهیم که UI و Database به Business وابسته باشند به جای وابستگی Business به UI و Database.
با این روش دیتابیس و UI پلاگینهایی برای Business محسوب میشوند و بدون تغییر در Business توانایی عوض کردن این پلاگینها را داریم.
با این روش UI و Database میتوانند در ماژولهای کاملا جدا توسعه داده شده و کامپایل شوند و در اختیار Business قرار بگیرند. مثلا در جاوا jar فایلها و در سی شارپ dllها این کار را برای ما انجام میدهند. به طور خلاصه اگر سورس کد یکی از پلاگینها تغییر کند، تنها نیاز به کامپایل و انتشار همان پلاگنی است و Business متوجه این تغییرات نمیشود.
5. جمع بندی:
شیگرایی چیست؟ جوابها و نظریههای متفاوتی برای این سوال وجود دارد. اما از نگاه یک معمار نرمافزار پاسخ این است: OO توانایی استفاده از چندریختی برای کنترل کامل بر جریان کنترل و وابستگی در نرم افزار است. OO به معمار امکان میدهند سیستم را بر پایه پلاگینها طراحی و پیاده سازی کند. در این روش ماژول با عملکرد سطح بالا و پیاده سازی Business دیگر وابستگی به ماژولهای سطح پایین ندارد. ماژولهای سطح پایین پلاگینهایی هستند که با توجه به نیاز ماژول سطح بالا عملکردهایی را پیاده سازی میکنند و به صورت کاملا مجزا قابلیت توسعه و انتشار دارند.
پ.ن اول: در طول مدتی که مطالب نوشته شدن، دوستان زیادی لطف کردن و در صورتی که خطایی در متن و نوشته بوده این مطلب رو به من متذکر شدن، در این بین آقایان محمد لطفی و مجتبی حسنلو در ویراستاری و اطلاع دادن خطاها بسیار زحمت کشیدن که همینجا مراتب قدردانی خودم نسبت به لطف همه عزیزان اعلام میکنم.
پ.ن دوم: امیدوارم همگی از این مرحله جنگ با کرونا عبور کنیم و وارد مرحله بعدی بشیم. فقط دیگه واقعا نمیدونم مرحله بعد چیه؟! دیگه ما حمله پروانه، و سیل و زلزله و گرونیهای یک شبه و ترور و کشته شدن عزیزانمون زیر دست و پا و فشار و قطعی اینترنت و اصابت موشک و کرونا رو پشت سر گذاشتیم از این بعد. تنها حالتی که ممکنه نتونیم مقاومت کنیم اینه که یه روز بیدار بشیم ببینیم تانوس رفته بالای برج میلاد منتظره صبح بشه بشکن بزنه و در یک ثانیه پودر بشیم.
سراپا اگر زرد و پژمرده ایم
ولی دل به پاییز نسپرده ایم
چو گلدان خالی، لب پنجره
پُر از خاطرات ترک خورده ایم
اگر داغ دل بود، ما دیده ایم
اگر خون دل بود، ما خورده ایم
اگر دل دلیل است، آورده ایم
اگر داغ شرط است، ما برده ایم
اگر دشنه ی دشمنان، گردنیم!
اگر خنجر دوستان، گرده ایم!
گواهی بخواهید، اینک گواه:
همین زخم هایی که نشمرده ایم!
دلی سربلند و سری سر به زیر
از این دست عمری به سر برده ایم
قیصر امینپور (خدایش بیامرزد)