حفظ سازگاری عقبگرد با تکنیک PIMPL
اگر توسعهدهندهٔ یک کتابخانهٔ نرمافزاری به زبان C++ (یا هر زبان native دیگری) هستید همیشه باید نکاتی رو مدنظر داشته باشید که کاربران کتابخانهٔ شما دچار مشکل نشوند. از جمله این اصول میشه به نسخهگذاری معنایی، طراحی API اصولی و حفظ سازگاری اشاره کرد. سازگاری مبحث بزرگ و پیچیدهای هست که یک نوشتهٔ مفصلتر میخواد. در این پست میخوام در مورد یکی از تکنیکهای پراستفاده در C و C++ بنویسم که به حفظ سازگاری عقبگرد در در سطح باینری کمک میکنه.
سازگاری عقبرو چیست؟
بهطور خلاصه حفظ سازگاری عقبگرد (یا همون عقبرو) توسط سازندهٔ یک رابط تضمین میکنه که نسخهٔ جدید این رابط برای تمام کاربران قبلی، و بدون نیاز به تغییر قابل استفاده است. این اصطلاح در جنبههای مختلف مخابرات، الکترونیک و برنامهنویسی بهطور گستردهای استفاده میشه. برای مثال USB نسخهٔ 3.0 از ابزارهای USB 2.0 و USB 1.1 هم پشتیبانی میکنه.
در مورد کتابخانههای native جنبههای مختلفی از سازگاری عقبرو وجود داره. معمولاً در دو سطح کد و باینری در مورد سازگاری بررسی میشه. سازگاری عقبگرد کد (API) یعنی نسخهٔ جدید کتابخانه برای کاربران قدیمی قابل استفاده است. سازگاری عقبگرد باینری اما جزئیات بیشتری داره…
در سطح سورس؛ سازگاری عقبرو معمولاً با رعایت قواعد نسخهگذاری معنایی تضمین میشه. یعنی نسخهٔ 1.2.3 از کتابخانه باید برای کدهای قدیمی که قبلاً از نسخههای 1.2.2 یا 1.1.0 استفاده میکردند، قابل استفاده باشه. (طبق قواعد نسخهگذاری معنایی مادامی که نسخهٔ MAJOR تغییر داده نشده، سازگاری با نسخههای قبلی وجود داره). اما این قابل استفاده بودن نظری در مورد کامپایل نداره. یعنی ممکنه کاربرِ نسخهٔ قبلی کتابخانه برای استفاده از نسخهٔ جدید، مجبور باشه دوباره برنامهش رو کامپایل و لینک کنه. اما قطعاً نیازی نیست که کدهای برنامهش رو تغییر بده. در این صورت میگیم سازگاری ABI شکسته شده.
حفظ سازگاری در سطح باینری یعنی که نسخهٔ جدید کتابخانه برای برنامههایی که قبلاً بهش لینک کردند، – بدون نیاز به کامپایل مجدد – قابل استفاده است. حفظ سازگاری در سطح باینری اهمیت زیادی داره! چون معمولاً این نیاز وجود داره که پیشنیازهای برنامهٔ مشتری آپدیت بشن. یکی از سناریوهایی که خیلی زیاد پیش میاد آپدیتهای امنیتی هستند. فرض کنید یک آسیبپذیری امنیتی در کتابخانهٔ شما پیدا شده باشه. این آسیبپذیری رو فیکس میکنید و در اسرع وقت برای تمام مشتریها دپلوی میکنید. در این حین هیچ تغییری هم در سطح API انجام ندادید. تمام توابع به همان شکل قبلی، با همان آرگومانها همچنان وجود دارند. حالا اگر موقع انجام این فیکس سازگاری عقبگرد باینری را رعایت نکرده باشید؛ دچار دردسر بسیار بزرگی خواهید شد: شما مجبور هستید تمام کدهای مشتریهای کتابخانه رو مجدد کامپایل کنید. قطعاً به تعدادی از این سورسها اصلا دسترسی ندارید و باید از مالکین سورس بخواهید که صرفاً برای فیکس کردن آسیبپذیری کتابخانهٔ شما، برنامهٔ خودشون رو مجدداً کامپایل و لینک کنند! اما اگر سازگاری باینری در کتابخانهٔ شما حفظ شده باشه، فقط کافیه کتابخانه رو جایگزین کنند. (مثلاً در مورد ویندوز این به معنی جایگزینی یک DLL بدون نیاز به کامپایل برنامهٔ اصلی است)
روشهای حفظِ سازگاریِ عقبگردِ باینری
برای تسهیل در حفظ این نوع سازگاری، تمهیدات مختلفی در نظر گرفته شده. از
همه مهمتر قابلیت تولید کد قابل حمل است. این کار با استفاده از
lookup table ها در زمان اجرا و با صرف هزینهٔ بسیار کم قابل انجام
هست. در مورد لینوکس این یعنی اگر کتابخانهٔ اشتراکی میسازید، حتماً از فلگ
-fPIC
استفاده کنید. (تقریباً تمام پکیجهای مخازن این کار رو میکنند.)
این باعث میشه که کد مستقل از آدرس تولید بشه. به طور خلاصه، یک GOT به
کد تولید شده اضافه میکنه که آدرسها رو براساس نام ذخیره میکنه. در صورت
استفاده از این فلگ، اگر شما ترتیب توابع یک کلاس رو تغییر بدید، یا
توابع جدیدی اضافه کنید؛ با وجود این که آدرسها رو عوض کردید؛ سازگاری
باینری از بین نمیره.
اما استفاده از -fPIC
تمام ماجرا نیست. حالتهایی وجود داره که شما
مجبور به تغییر چینش حافظه هستید. مثلاً اضافه کردن یک عضو جدید به
کلاس. این کار آدرس تمام متغیرها رو میتونه تغییر بده (اگه به اول کلاس
اضافه کرده باشید). در این صورت کد کاربر مجبور به کامپایل مجدد هست. حتی
اگر API هیچ تغییری نکرده باشه!
تکنیک pimpl
روش pimpl (اشارهگر به پیادهسازی - Pointer to Implementation) برای حفظ سازگاری باینری بسیار مؤثر است. با استفاده از این تکنیک میشه سازگاری رو برای سناریوهای گستردهای بهطور کامل حفظ کرد. یعنی اگر متغیر جدیدی به کلاس اضافه کنید، هیچ مشکلی برای کد کاربر پیش نمیاد. به عنوان یک اصل کلی، اگر برنامهنویس احتمال میده کدش در آینده تغییر کنه، باید حتماً از pimpl استفاده کنه.
روش استفاده از این تکنیک به این صورت هست که شما تمام اعضای یک رابط (یعنی یک کلاس در APIی عمومی) را در یک کلاس غیررابط قرار داده و یک اشارهگر (چه ساده چه هوشمند) به کلاس دوم رو در کلاس اصلی قرار میدید.
یک مثال کاربردی
برای درک بهتر موضوع یک مثال خیلی خیلی ساده میزنم که اول مشکل سازگاری
باینری رو نشون میده و بعد راه حل pimpl رو توضیح میده. برای این کار
کلاس ساده و معروف person
را در نظر بگیرید. این کلاس فقط نام و نام
خانوادگی یک فرد رو نگهداری میکنه:
class LIBFOO_API person {
public:
person(const std::string& name, const std::string& last);
~person() = default;
std::string name() const;
std::string last() const;
private:
std::string m_name;
std::string m_last;
};
این کلاس رو مثل هر کتابخانهٔ معمولی در لینوکس کامپایل میکنیم. فقط دقت
داریم که -fPIC
رو فراموش نکنیم:
g++ -DLIBFOO_EXPORT -shared -fPIC -fvisibility=hidden -o libfoo.so ./libfoo.cpp
حالا یک برنامهٔ خیلی ساده مینویسیم که از این کتابخانه استفاده میکنه:
#include <iostream>
#include "libfoo.hpp"
int main(int argc, char* argv[]) {
person people[3] {{"Dexter", "Fortescue"},
{"Armando", "Dippet"},
{"Albus", "Dumbldore"}};
for(int i=0; i<3; ++i)
std::cout << "Hello " << people[i].name() << "!\n";
return 0;
}
برنامهای که نوشتیم (کد مشتری) رو کامپایل کرده و به کتابخانهٔ اصلی لینک میکنیم:
g++ -o program ./main.cpp -L. -lfoo
خروجیای که انتظار داریم بعد از اجرای برنامه ببینیم:
$ ./program
Hello Dexter!
Hello Armando!
Hello Albus!
$ echo $?
0
تا اینجای کار همهچیز به خوبی و خوشی جلو رفته. فرض میکنیم این نسخهٔ
2.1.4
از کتابخانهٔ ما (libfoo) هست. در نسخهٔ بعدی ما باید تغییراتی رو
در کلاس person
ایجاد کنیم. این تغییر شامل اضافه کردن سن فرد به این
کلاس هست. طبق قواعد نسخهگذاری، از آنجایی که تغییرات ما سازگاری APIی
برنامه رو نقض نکرده؛ شمارهٔ نسخهٔ جدید میشه 2.2.0
.
class LIBFOO_API person {
public:
person(const std::string& name, const std::string& last);
person(const std::string& name,
const std::string& last,
const uint16_t age);
~person() = default;
uint16_t age() const;
std::string name() const;
std::string last() const;
private:
uint16_t m_age;
std::string m_name;
std::string m_last;
};
حالا به همان منوال قبلی کتابخانه رو کامپایل میکنیم. برنامهٔ کاربر هیچ اطلاعی از وجود age نداره و ازش استفادهای هم نکرده. بخشی از API که کد کاربر از اون اطلاع داشته هیچ تغییری نکرده. بنابراین انتظار داریم برنامهٔ مقصد بدون هیچ تغییری به درستی کار کنه. اما اگر برنامه رو اجرا کنیم مشاهده میکنیم که کرش میکنه:
free(): invalid pointer
Aborted (core dumped)
$ echo $?
134
برای فهمیدن دلیل این اتفاق باید نگاهی به لیآوت آدرسهای کتابخانه بیندازیم. اول نسخهٔ ابتدایی رو میبینیم:
0 | class person
0 | class std::__cxx11::basic_string<char> m_name
32 | class std::__cxx11::basic_string<char> m_last
| [sizeof=64, dsize=64, align=8,
| nvsize=64, nvalign=8]
بعد از اضافه کردن متغیر age اما ساختار حافظه طبیعتاً متفاوت
هست. آدرسهای متغیرهای قبلی، یعنی m_name
و m_last
عوض شده:
0 | class person
0 | uint16_t m_age
8 | class std::__cxx11::basic_string<char> m_name
40 | class std::__cxx11::basic_string<char> m_last
| [sizeof=72, dsize=72, align=8,
| nvsize=72, nvalign=8]
اما چرا کرش اتفاق میافته؟ اون هم با وجود این که هیچ عضوی از کلاس در کد
کاربر به طور مستقیم استفاده نشده؟ دلیلش اینه که سایز کل کلاس تغییر
کرده. بنابراین کد آزادسازی حافظه برای اون کلاس دیگه به درستی فراخوانی
نمیشه. مثالهای دیگری هم وجود داره. مثلا وقتی به اعضای کلاس از طریق
توابعی در هدر دسترسی پیدا میکنیم. در این حالت مستقیماً خطای
Segmentation Fault دریافت میکنیم و کار به Destructor نمیکشه. حتی
حالتهایی وجود داره که API اصلاً تغییری نمیکنه اما ABI تغییر ناسازگار
داره. مثلاً فرض کنید تابع age()
رو اصلاً نیاز نداشتیم و تغییر صرفاً
شامل اضافه کردن چند عضو جدید به کلاس میبود.
حل مشکل سازگاری با pimpl
برای پیاده کردن تکنیک pimpl تعریف کلاس person
رو به شکل زیر تغییر
میدیم.
class LIBFOO_API person {
public:
person(const std::string& name, const std::string& last);
~person() = default;
std::string name() const;
std::string last() const;
private:
struct details {
details(const std::string& name, const std::string& last);
std::string m_name;
std::string m_last;
};
details* m_impl;
};
توجه داشته باشید که میتونید به جای اشارهگر معمولی از اشارهگر هوشمند
مثل std::unique_ptr
هم استفاده کنید. همچنین میتونید پیادهسازی
details
رو به یک فایل دیگه (خارج از API) منتقل کنید. البته از آنجایی
که سطح دسترسی private
داره همین الانش هم داخل API عمومی نیست؛ با این
حال اگر دوست داشته باشید میتونید خیلی راحت ببریدش داخل کدهایی که دیده
از دید کاربر پنهان هست. مثلاً یک هدر مجزا برای خودش و یا ابتدای فایل
cpp جاهای مناسبی هستند. اگر این کارها رو هم انجام بدیم کلاس ما به این
شکل در میاد:
class LIBFOO_API person {
public:
person(const std::string& name, const std::string& last);
~person() = default;
std::string name() const;
std::string last() const;
private:
struct details;
std::unique_ptr<details> m_impl;
};
طبق روال قبل کتابخانه رو کامپایل و لینک میکنم. برنامهٔ کاربر رو هم با کتابخانهٔ جدید کامپایل کرده و لینک میکنیم. حالا ما یک نسخهای از کتابخانه رو داریم که تکنیک pimpl رو پیادهسازی کرده. اگر به لیآوت حافظهش نگاه کنیم متوجه میشیم که اثری از اعضای پیادهسازی وجود نداره و طبیعتاً فقط یک اشارهگر به پیادهسازی وجود داره:
0 | class person
0 | struct person::details * m_impl
| [sizeof=8, dsize=8, align=8,
| nvsize=8, nvalign=8]
و طبیعتاً باید پیادهسازی توابع کلاس رو هم عوض کنیم طوری که به جای
فراخوانی اعضای کلاس، اعضای اشارهگر رو استفاده کنه. یعنی مثلاً تابع
name()
به این صورت در میاد:
std::string person::name() const {
return m_impl->m_name;
}
برنامهٔ کاربر با این نسخه کامپایل و لینک میشه.
در ادامه میخواهیم نسخهٔ جدید رو دپلوی کنیم. طبق روال قبلی متغیر age رو
– این بار به کلاس details
– اضافه میکنیم. توابع عضو مثل قبل به
کلاس اصلیمون اضافه میشه. در نهایت همچین چیزی خواهیم داشت (تغییرات
علامتگذاری شدهاند):
class LIBFOO_API person {
public:
person(const std::string& name, const std::string& last);
~person() = default;
std::string name() const;
std::string last() const;
// New members
person(const std::string& name,
const std::string& last,
const uint16_t age);
uint16_t age() const;
private:
struct details {
details(const std::string& name,
const std::string& last,
const uint16_t age);
uint16_t m_age;
std::string m_name;
std::string m_last;
};
details* m_impl;
};
همونطور که میبینید، دیگه نیازی نیست API سازندهٔ کلاس details
رو حفظ
کنیم (یعنی تابع سازندهٔ جدید اضافه کنیم) چون اصلا details
بخشی از API
کلاس ما محسوب نمیشه. حالا اگر لیآوت حافظهٔ کلاس جدید رو ببینیم، متوجه
میشیم که هیچ تفاوتی نسبت به حالت قبلی نداره! بنابراین کاربر ما (صاحب
برنامهٔ اصلی) میتونه به راحتی کتابخانه رو جایگزین کنه و بدون نیاز به
کامپایل دوباره، برنامهش بدون هیچ مشکلی اجرا میشه.
مزایا و معایب
در مورد مزیت اصلی استفاده از pimpl توضیح دادم. اما باید به خاطر داشته باشیم که تکنیک pimpl علاوهبر مورد مذکور برای کاربردهای دیگری هم استفاده میشه.
از جمله مزایای اصلی این روش میشه به این موارد اشاره کرد:
-
تفکیک پیادهسازی: از جزئیات. این تکنیک سطح عمیقتری از مخفیسازی جزئیات برای طراحی ما فراهم میکنه. در مواردی میشه حتی پیشنیازهای داخلی رو با این روش مخفی کرد که البته از دید کاربر نهایی کتابخانه بسیار مطلوب هست.
-
بهبود زمان کامپایل: از اونجایی که کلاس اصلی جزئیاتش رو مخفی کرده، نیازی نداره اونها رو
#include
بکنه. بنابراین مشتریهای این کلاس هم نیازی به دانستن جزئیات تایپهای عضو ندارند. در حالت کلی حجم کلاس پیشپردازش شده با استفاده از pimpl و forward declare کردن کلاس جزئیات میتونه کمتر بشه.
معایب خاصی هم برای روش pimpl ذکر شده که مختصر اشاره میکنم:
-
پیچیدگی پیادهسازی: زمانی که از pimpl استفاده میکنید احتمالاً نیاز هست که copy constructor و move constructor و البته سازنده و مخرب غیرپیشفرض را هم پیادهسازی بکنید. اپراتورهای انتساب (انواع =) رو هم نباید فراموش کرد! از منظر پیادهسازی، اضافه کردن pimpl دردسرهایی این شکلی برای برنامهنویس ایجاد میکنه و کد رو پیچیدهتر میکنه.
-
کارایی: با استفاده از این تکنیک، برای دسترسی به اعضای داخلی کلاس یک لایهٔ اضافی در ارجاع به وجود میاد. لازم به ذکر هست که این مرحلهٔ اضافی با توجه به ثابت بودن اشارهگر در طول اجرا، توسط کامپایلرهای امروزی تقریباً به طور کامل بهینهسازی میشه و حتی اگر بهینه نشه، پردازش قابل توجهی از برنامه نمیگیره.
چند نکته!
باید حتماً توجه داشته باشید که تکنیک اضافه کردن pimpl بهصورت جادویی مشکلات کتابخانهٔ شما رو حل نمیکنه. در خیلی از موارد اگر طراحی ابتدایی کلاس مشکل داشته باشه، باید API و یا ABI رو عوض کنید. همیشه باید هزینهٔ اضافه کردن pimpl رو هم درنظر بگیرید. اگر کد شما تعداد کاربران خیلی زیادی نداره، هزینهٔ اضافه کردن pimpl ممکنه بیشتر از سود حفظ ABI باشه. این رو باید همیشه محاسبه کنید.
همیشه باید موقع طراحی این اصل رو به خاطر داشته باشید که طراحی شما ممکنه در آینده عوض بشه. اگر تعداد کاربران زیادی دارید این قضیه میتونه مشکل جدی ایجاد کنه. در این مواقع pimpl میتونه خیلی کمک بزرگی باشه.
برنامهنویسهای حرفهای گاهی بدون این که نیازی وجود داشته باشه یک اشارهگر خالی رو داخل کلاس قرار میدن. (مثلا بهصورت یک اشارهگر به کلاس forward-declared) این کار صرفاً به این دلیل هست که احتمال میدن در آینده طراحی کلاس و ساختار حافظهش عوض بشه.
نکتهٔ بعدی این که دلیلی وجود نداره که تمام اعضای کلاس رو داخل پیادهسازی پنهان کنید. شما میتونید یک تعدادی عضو (که میدونید هیچوقت تغییر نمیکنند) رو بهشکل مستقیم داخل کلاس قرار بدید، و در ادامه یک اشارهگر به پیادهسازی هم قرار بدهید.