یک Container ناهمگن حقیقی در C++

C++ از جمله‌ی زبان‌های Statically-Typed است؛ همین امر مسبب شده است که بر خلاف زبان‌هایی همچون Python امکان ذخیره مقادیری از چندین نوع داده‌ی مختلف در یک Container ممکن نباشد. در این مقاله به بررسی شیوه‌هایی برای دور‌زدن این محدودیت خواهیم پرداخت.

24 بهمن‌ماه 1396
محمد فرهمند

شاید برای شما هم این سوال پیش آمده باشد که

آیا می‌توان متغیرهایی با بیش از یک نوع داده را درون std::vector ذخیره کرد؟

جواب تغییر ناپذیر، رسمی و نهایی این سوال خیر است. C++ زبانی Statically-Typed است و Containerهای آن تنها می‌توانند مقادیری از یک نوع داده را ذخیره کنند. اما راه‌هایی برای دورزدن این محدودیت وجود دارد که در این مقاله به بررسی یکی از آن‌ها خواهیم پرداخت.

یکی از راه‌های رایج برای انجام این کار پنهان‌کردن داده‌ها درون کلاسی خاص است. کتاب‌خانه استاندارد C++ از استاندارد C++14 کلاسی به نام std::variant را معرفی کرده است که در قالب یک Union قادر به ذخیره‌ی چندین نوع داده بوده و Flagـی برای تشخیص Type مورد استفاده دارد. مشکل این ساختار آن است که به هنگام تعریف آن می‌بایست تمامی Typeهایی که ممکن است درون این کلاس ذخیره شوند را مشخص کنید:

ساختار دیگری که واقعا قادر است هر نوع داده‌ای را ذخیره کند std::any است که در استاندارد C++17 معرفی گردید. این کلاس حقیقتا قادر به ذخیره‌ی هر نوع داده‌ای هست اما در عموم موارد استفاده‌کننده مجبور است نوع داده‌ی ذخیره‌شده را خودش نگه‌داری کند. علاوه بر این Polymorphism به‌کارگرفته‌شده و Cast داده از Performance برنامه می‌کاهند.

الگوی Visitor

می‌خواهیم با به‌کار‌گیری Visitor Pattern و استفاده از std::variant یا std::set قابلیتی فراهم کنیم که:

  • واحدی «قابل فراخوانی» (کلاسی با با Overloaded Function Call Operator و یا Polymorphic Lambda) برای تمامی نوع‌های داده‌ای ایجاد کنیم.
  • مجموعه را «بازدید» (Visit) کرده و واحد قابل فراخوانی را برای هر آیتم اجرا کنیم.

مثالی با استفاده از std::variant را بررسی می‌کنیم؛ نخست Variantمان را ایجاد می‌کنیم:

حال Visitor را پیاده‌سازی می‌کنیم:

واضح است که این Visitor داده‌ی مشاهده‌شده را دوبرابر (یا در تعبیری بهتر Double) می‌کنند. همانطور که احتمالا خودتان نیز حدس زدید از آن‌جایی که تعریف تمامی توابع یکسان است بهتر است از Templateها استفاده شود:

حال با استفاده از std::visit داده‌ها را Visit می‌کنیم:

پیاده‌سازی این Visitor به کمک یک Polymorphic Lambda نیز امکان‌پذیر است:

برای Visit داده به شیوه‌ی مشابهی عمل می‌کنیم:

پیاده‌سازی Container

با درنظر‌داشتن مباحث فوق ایجاد یک Container ساده به نظر می‌رسد؛ تنها کافیست Variant را درون Container دلخواهمان قرار دهیم:

کار با Variantها نیز مانند قبل است. به عنوان مثال به تکه‌کد زیر توجه کنید:

نوشتن هر‌باره‌ی این حلقه‌ها کاری خسته‌کننده است؛ پس با Encapsulateکردن Vector درون کلاسی تازه مکانیزم Visit کل Collection را ساده‌تر می‌سازیم:

حال تکه‌ کد بالا به کد کوتاه زیر تبدیل می‌شود:

بدین ترتیب توانستیم Containerـی حقیقتا ناهمگن (Heterogeneous) بسازیم؛ استفاده‌کننده پس از تعیین انواع داده‌ای که ممکن است درون Container ذخیره کند می‌تواند هر چه بخواهد با emplace_back به Vector اضافه کرده و پس از آن آن‌ها را Visit کند.

بهتر از این

چقدر خوب می‌شد اگر می‌توانستیم چنین کاری انجام دهیم:

اگر از یک برنامه‌نویس C++ بپرسید به شما خواهد گفت که «غیرممکن است! مگر آن‌که تعدادی Cast و بررسی که هزینه‌ی گزافی نیز دارند انجام دهید.»

در ادامه می‌خواهیم ببینیم که چقدر از یک Interface را (بدون RTTI) می‌توانیم به کمک ابزارهای تازه‌ی استانداردهای C++14 و C++17 پیاده‌سازی کنیم. به خاطر داشته باشید که ساختار پیاده‌سازی‌شده در ادامه تنها نوعی بازی با ابزارهای استانداردهای تازه‌ی C++ بوده و برای استفاده عملی مناسب نمی‌باشد (زیرا که حاوی گافی امنیتی است).

یک Container ناهمگن در C++

باید اعتراف کنیم که هیچ‌گاه نمی‌توانیم به انعطاف‌پذیری زبانی Duck-Typed همچون Python دست یابیم؛ در C++ نمی‌توانیم نوع داده‌های تازه‌ای به هنگام اجرا ایجاد کنیم و یا به سادگی به روی عناصر Iterate کنیم؛ همچنان مجبوریم از Visitor Pattern استفاده کنیم.

کار با قابلیتی که با C++14 معرفی شد آغاز می‌کنیم: قالب متغیر و یا Variable Template. اگر تاکنون با Templateها در C++ کار کرده باشید درک این مفهوم نیز برایتان آسان خواهد بود. این قابلیت به ما امکان اتخاذ یک برداشت متفاوت از یک داده را برای نوع‌های متفاوت می‌دهد. به طور مثال ثابت عدد پی را می‌توانیم به صورت زیر تعریف کنیم:

می‌توانیم با فراخوانی صریح این متغیر با نوع داده‌ی دلخواهمان همچون pi<double> متغیر مورد نیازمان را ایجاد کنیم. حال که با این ابزار آشنا شدیم وقت سوءاستفاده از آن رسیده است! چه اتفاقی می‌افتد اگر یک Variable Template را درون یک کلاس ببریم؟ قوانین C++ بیان می‌دارند که این Variable Template حالت static خواهد یافت؛ بنابرین هر Instance از قالب عضوی تازه در تمامی Instanceهای کلاس ایجاد خواهد کرد. اما ما می‌خواهیم که Container ناهمگن ما تنها نوع‌‌داده‌های به کارگرفته‌شده در آن Instance خاص را بداند. یک راه‌حل برای این کار استفاده از Mapـی به Vectorهاست:

حال کلاسی داریم که پس از Instanceگیری از آن می‌توانیم اعضایی به آن اضافه کنیم! حتی می‌توانیم Structـی نیز به این کلاس اضافه کنیم:

هنوز موارد دیگری در مورد Containerمان باقی مانده که می‌بایست به آن‌ها رسیدگی کنیم. یکی از این موارد Destruction داده‌هاست. در حال حاضر اگر عنصری از Containerمان به انتهای Scope تعریفش برسد درون Map باقی خواهد ماند. به منظور حل این مشکل می‌بایست به نحوی نوع‌داده‌هایی که دریافت کرده‌ایم را به خاطر سپرده و Vector صحیح را پاک کنیم. برای این کار می‌توانیم از تابعی Lambda و از std::function برای نگه‌داری آن استفاده کنیم. نخست متد push_back را اصلاح می‌کنیم:

حال clear_functions عضوی Local از کلاس خواهد شد که شبیه به کد زیر است:

هرگاه بخواهیم تمام عناصر یک Vector را از بین ببریم می‌توانیم تمامی Clear Functionهای آن را صدا کنیم. تا اینجا Containerمان شبیه به زیر شد:

مسئله‌ی دیگر کپی کلاس است. در حال حاضر نمی‌توانیم کاری این‌چنینی انجام دهیم:

راه حل این مشکل تقریبا واضح است: الگویی که برای Clear به کار گرفتیم را دنبال می‌کنیم: در push_back تابعی دیگر برای کپی یک vector<T> از یک heterogeneous_container به دیگری ایجاد می‌کنیم و در Copy Constructor و Assignment Operator هر یک از توابعمان را صدا می‌کنیم:

با دنبال‌کردن این روند می‌توانیم قابلیت‌های دیگری نیز به کلاسمان اضافه کنیم، همچون یک متد size، متدی همچون number_of<T> و یا متدی با نام gather_all<T> برای جمع‌آوری تمامی متغیرهای دارای نوع T.

بازدید (Visiting)

Container ما هنوز کاراآمد نیست زیرا هیچ راهی برای Iterate به روی عناصر آن نداریم. این کار به سادگی استفاده از std::visit نیز نیست زیرا std::variant نوع‌داده‌ی ذخیره‌شده در آن را نگه‌داری می‌کند اما Container ما قادر به انجام این کار نیست. انتخابی جز آن‌که تعیین نوع‌های قابل‌استفاده و شناسایی آن‌ها را به Visitor بسپاریم که استفاده از Container را دشوار می‌سازد نداریم.

برای آن‌که استفاده‌کننده به راحتی بتواند نوع‌داده‌های مورد پذیرش Visitorـش را تعیین کند از ویژگی تازه در C++11 به نام Type List استفاده می‌کنیم:

پس از آن می‌توانیم کلاس Baseـی برای Visitorها تعیین کنیم که از این Type List استفاده می‌کند:

نحوه استفاده از این کلاس را در قالب یک مثال بررسی می‌کنیم. Containerـی با محتوای زیر مفروض است:

حال می‌خواهیم Visitorـی بسازیم که اعضای این Container را دوبرابر کند. به کمک Templateها می‌توانیم کلاس ساده‌ی زیر را برای آن طراحی کنیم:

مرحله‌ی بعد پیاده‌سازی متد Visit برای کلاس Container است. استراتژی‌مان این است که برای نوع‌داده‌های اعلام‌شده توسط کلاس Visitor متد Function Call Overload مناسب را صدا کنیم. این کار به کمک یک تابع کمکی آسان می‌شود:

استفاده از Templateها باعث می‌شود که از ایجاد هرگونه محدودیت به روی Visitor جلوگیری شود. حتما متوجه شدید که فراخوانی visit_imp نه تنها با Pass شیء Visitor دریافتی همراه است بلکه Instanceـی از نوع‌داده‌ی T::type نیز می‌سازد. علت این امر آن است که اینگونه ارسال نوع‌داده‌هایمان به visit_impl ساده‌تر است. خود visit_impl را به صورت زیر تعریف می‌کنیم:

در اینجا از آرگومان Template‌شده‌ای برای نگه‌داری Typeها استفاده می‌کنیم. با این شیوه‌ی پیاده‌سازی اگر از این متد برای Visitorـی که قبل‌تر پیاده‌کردیم استفاده کنیم نتیجه‌ی جایگزین‌سازی به صورت زیر خواهد بود:

Iterate به روی Container

در گذشته برای انجام این کار مجبور به استفاده از الگوی بازگشتی Head-Tail بودیم؛ می‌بایست پارامتری از جنس <class HEAD, class… TAIL> تعریف می‌کردیم و پس از پردازش Head به روی Tail یک Recursive Call انجام می‌دادیم. این شیوه دردسر زیادی برای کامپایلر ایجاد می‌کند. به لطف Fold Expressionها که در C++17 معرفی شدند دیگر نیازی به این کار نداریم. ممکن است این پیاده‌سازی در ابتدا برایتان کمی عجیب به نظر برسد.

این امر Unary Left Fold نیز نامیده شده و موجب بسط‌ زیر می‌شود که در واقع از عملگر , سوءاستفاده می‌کند:

با به‌کار‌گیری تنها یک تابع کمکی دیگر می‌توانیم روشی برای پیمایش مناسب حلقه‌ها ایجاد کنیم:

با Overload عملگر Function Call در Visitorمان می‌توانیم با آن مانند یک تابع برخورد کنیم و آن را برای هر یک از عناصرمان صدا کنیم.

بالاخره تمام شد!

کلاس ما آماده است. نمونه‌ای از قابلیت‌های کلاسمان در قالب تکه کد زیر آمده است:

که خروجی زیر را نتیجه می‌دهد:

نمونه‌ای اجرایی از این کلاس را با چندین تغییر در این لینک مشاهده کنید.

استانداردهای C++14 و C++17 ویژگی‌های خارق‌العاده‌ای را به این زبان اضافه کردند که باعث شد باور همه نسبت به «آنچه می‌توان به کمک C++ انجام داد» تغییر کند. اگرچه این کلاس مناسب استفاده در پروژه‌های تجاری نیست ممکن است برای برآورده‌کردن یکی از درخواست‌های نه چندان معقولانه مشتریانتان به کارتان آید!

منبع: Andy G’s Blog

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *