در قسمت قبل آموزش برنامه نویسی C به مفاهیم بازگشت در برنامهنویسی C : از فاکتوریل تا مدیریت پشته پرداختیم. در این قسمت به آموزش استفاده از enum، ساختارها و مدیریت حافظه در زبان C می پردازیم.
در این بخش، میخواهیم از مفاهیم سادهای مثل آرایهها و انواع دادههای اولیه فراتر برویم و روشهای جدیدی برای ساخت دادههای پیچیدهتر یاد بگیریم. برای این کار، با چند مفهوم مهم به نامهای enum (نوع شمارشی)، struct (ساختار)، union (اتحادیه) و typedef آشنا میشویم.
گاهی اوقات نیاز داریم بهجای استفاده از اعداد، از نامهای مشخص و قابلفهمتری در برنامه استفاده کنیم. برای مثال، اگر میخواهیم مجموعهی محدودی از رنگها را در یک متغیر ذخیره کنیم، میتوانیم به جای اینکه برای این رنگ ها از اعداد بیمعنی استفاده کنیم، میتوانیم از enum استفاده کنیم که این کار کد را قابلفهمتر و قابلنگهداریتر میکند.
1 2 3 4 5 6 7 8 9 10 | const uint8_t COLOR_RED = 0; const uint8_t COLOR_BLUE = 1; const uint8_t COLOR_GREEN = 2; #define colorType uint8_t; colorType colorIWant = COLOR_RED; |
این کد کار میکند، اما همچنان باید حواسمان به رنگهای مختلف باشدکه مقادیر یکتایی به هرکدام اختصاص داده باشیم. خوشبختانه، با استفاده از enum، زبان C این کار را برای ما انجام میدهد :
1 2 3 4 5 6 7 8 9 10 11 12 | enum colorType { COLOR_RED, COLOR_BLUE, COLOR_GREEN }; enum colorType colorIWant = COLOR_RED; |
با enum، زبان C تخصیص مقدار و نگهداری از یکتایی مقادیر را برعهده میگیرد.شایدفکر کنید روش اول خیلی هم کار مشکلی نیست. اما اینجا ما تنها 3 رنگ داریم ولی اگر به ۷۵۰ رنگ نیاز داشته باشیم، نگهداری همهی این اعداد کار سادهای نیست.
C در مورد انواع داده کمی آسانگیر است. درون خودش به طور خودکار مقادیر عددی ۰، ۱ و ۲ را به COLOR_RED، COLOR_BLUE و COLOR_GREEN اختصاص میدهد. ما معمولاً به این موضوع اهمیت نمیدهیم چون enum برای خوانایی بهتر کد ساخته شده است. اما گاهی اوقات این انتساب داخلی C خودش را نشان میدهد. برای مثال کد زیر:
1 2 3 | enum colorType fgColor = COLOR_GREEN; printf("The foreground color is %d\n", fgColor); |
خروجی زیر را چاپ میکند:
1 | The foreground color is 2 |
همچنین، C انتسابهای enum را از نظر نوع بررسی نمیکندیعنی درست است که ما fgColor را ازنوعenum colorType تعریف کرده ایم اما اختصاص مقداری خارج از مقادیر enum colorType خطایی ایجاد نمیکند . برای مثال، کد زیر هیچ خطا یا هشداری ایجاد نمیکند:
1 | fgColor = 33; |
enum ما سهرنگ تعریف میکند، بنابراین اعداد مجاز برای رنگها ۰، ۱ و ۲ هستند و نه ۳۳. این میتواند مشکلساز باشد.برای جلوگیری از این مشکل، معمولاً از روشهای دیگری مانند اعتبارسنجی مقادیر استفاده میکنیم.یعنی خودتان همیشه باید موظف باشید اعتبار مقادیر اختصاص داده شده را بررسی کنید.
در این بخش، یاد میگیرید که چگونه از برخی از دستورات پیشپردازنده پیشرفته برای کار با enum به شکل سادهتری استفاده کنید. اول این نکته را بگویم که در ۹۹۹ مورد از ۱۰۰۰ مورد استفاده از یک ترفند هوشمندانه، بیشتر از اینکه مفید باشد دردسرساز میشود. راهحلهای ساده و واضح تقریباً همیشه بهتر از راهحلهای پیچیده و هوشمندانه هستند. این موقعیت یکی از معدود استثناها است. (اگر در شروع برنامه نویسی هستید مثال زیر شاید برایتان پیچیده به نظر برسد و ندانستن آن هیچ مشکلی ندارد و میتوانید ا زآن بدون هیچ نگرانی بگذرید. اما دیدن آن خالی از لطف نیست شاید بعد ها که برنامه نویس با تجربه تری شدید دوباره برای خواندن آن به سایت ما سر بزنید.)
بیایید به کدی نگاه کنیم که رنگها و نام رنگها را تعریف میکند:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | هشدار: بدون تغییردادن colorNames، این را تغییر ندهید // enum colorType { COLOR_RED, COLOR_BLUE, COLOR_GREEN }; هشدار: بدون تغییر دادن colorNames، این را تغییر ندهید // static const char* const colorNames[] = { "COLOR_RED", "COLOR_BLUE", "COLOR_GREEN" }; |
این مثال دو عنصر دارد که بههموابسته هستند: colorType و colorNames. برنامهنویسی که این کد را نوشته است با یک کامنت نشان داده این دو عنصر به هم مرتبط هستند و در واقع کنار هم تعریف شدهاند. (گاهی اوقات دو عنصر وابسته به هم میتوانند در فایلهای جداگانهای باشند بدون اینکه کامنتی وابستگی آنها را نشان دهد.)
بهعنوان برنامهنویس، میخواهیم کدمان تا حد ممکن ساده باشد. یعنی میخواهیم کاری کنیم که مقادیر enum و نامهای مرتبط آنها را همزمان تعریف کنیم.
با یک ترفند پیشپردازنده C، میتوانیم این دو مجموعه کد را فقط یکبار تعریف کنیم و به صورت خودکار از آن در هر دو جا استفاده کنیم.
1 2 3 4 5 6 7 | #define COLOR_LIST DEFINE_ITEM(COLOR_RED) DEFINE_ITEM(COLOR_BLUE) DEFINE_ITEM(COLOR_GREEN) DEFINE_ITEM |
را طوری تعریف کنید که مقادیر واقعی برای enum را تولید کند.
1 2 3 4 5 6 7 | //#define DEFINE_ITEM(X) X enum colorType { COLOR_LIST };#undef DEFINE_ITEM |
DEFINE_ITEM را طوری تعریف کنید که نامهای enum را تولید کند.
1 2 3 4 5 6 7 8 9 | //#define DEFINE_ITEM(X) #X static const char* const colorNames[] = { COLOR_LIST }; #undef DEFINE_ITEM |
با یک ترفند پیشپردازنده C توانستیم این دو مجموعه کد را فقط یکبار تعریف کنیم و به صورت خودکار از آن در هر دو جا استفاده کنیم. بیایید به صورت مرحله به مرحله کاری که کردیم را بررسی کنیم:
این لیست فقط یک مجموعه از نامهاست که به همراه یک ماکرو به نام DEFINE_ITEM استفادهمیشود. اما این ترفند جادویی در همین جا اتفاق میافتد! با تغییر تعریف DEFINE_ITEM، میتوانیم یکبار از این لیست برای تعریف enum و بار دیگر برای تعریف آرایه colorNames استفاده کنیم.
1 2 3 4 | #define COLOR_LIST \ DEFINE_ITEM(COLOR_RED), \ DEFINE_ITEM(COLOR_BLUE), \ DEFINE_ITEM(COLOR_GREEN) |
علامت بکاسلش (\) به پیشپردازنده C میگوید که خط ادامه دارد. ما جهت خوانایی بیشتر همه آنها را در یک ستون قرار دادهایم .
حالا هر جایی که از COLOR_LIST استفاده کنیم، پیشپردازنده C آن را به موارد زیر تبدیل میکند:
1 | DEFINE_ITEM(COLOR_RED), DEFINE_ITEM(COLOR_BLUE), DEFINE_ITEM(COLOR_GREEN) |
وقتی enum را تعریف میکنیم، میخواهیم لیست ما بهصورت زیر باشد:
1 | COLOR_RED, COLOR_BLUE, COLOR_GREEN |
بنابرین قبل تعریف enum,
DEFINE_ITEM(X) را به نحوی تعریف میکنیم فقط نام X را بدون هیچ تغییری برمیگرداند. چرا که ما میخواهیم لیست همانگونه که هست درون enum ما قرار بگیرد .
1 | #define DEFINE_ITEM(X) X |
سپس COLOR_LIST را درون enum قرار میدهیم:
1 2 3 | enum colorType { COLOR_RED, COLOR_BLUE, COLOR_GREEN }; |
این یعنی کد زیر:
بنابراین وقتی COLOR_LIST درون enum گسترش پیدا میکند، چیزی شبیه این خواهد بود:
1 | DEFINE_ITEM(COLOR_RED), DEFINE_ITEM(COLOR_BLUE), DEFINE_ITEM(COLOR_GREEN) |
خوب تا اینجا enum خود را با موفقیت ساختیم و میتوانیم DEFINE_ITEM را پاک میکنیم؛ زیرا دیگر برای تعریف enum به آن نیاز نداریم:
1 | #undef DEFINE_ITEM(X) X |
این کار را با تعریف DEFINE_ITEM بهگونهای که فقط نام آیتم را خروجی دهد، انجام میدهیم:
برای اینکار نیاز به یک عملگر پیشپردازنده جدید داریم به نام عملگر رشته ساز (Stringizing operator)(#) نحوه کار این عملگر به این صورت است پارامترهای ماکرو را به رشتههای متنی (string literal) تبدیل میکند، در واقع نام پارامتری که کنارش قرار میگیرد را به صورت رشته به ما میدهد.
همانطور که گفتیم علامت (#) به پیشپردازنده میگوید که توکن بعدی را به یکرشته تبدیل کند، بنابراین زمانی DEFINE_ITEM را به صورت فوق تعریف کرده باشیم زمانی که COLOR_LIST را استفاده کنیم کامپایلر COLOR_LIST را به صورت زیر گسترش میدهد:
1 | "COLOR_RED","COLOR_BLUE","COLOR_GREEN" |
این دقیقا محتویات درون آرایه ماست و چیزی است که برای تعریف آرایه خود به آن نیاز داریم. حالا باید به کمک آرایه خودرا به نحوه زیر تعریف کنیم:
1 2 3 | static const char* const colorNames[] = { COLOR_LIST }; |
کامپایلر خروجی کد بالا را به صورت زیر گسترش میدهد.
1 2 3 4 5 | static const char* const colorNames[] = { "COLOR_RED", "COLOR_BLUE", "COLOR_GREEN" }; |
خوب دیگر نیاز به DEFINE_ITEM نداریم و آن را حذف میکنیم:
1 | #undef DEFINE_ITEM |
کامنتها بخش مهمی از این تعریف هستند. هر بار که از چنین ترفند جذابی استفاده میکنید، آن را بهخوبی مستند کنید تا فرد نگونبختی که این کد را بررسی میکند، کمی از کارپیچیده ای که انجام دادهاید، سر در بیاورد.
ساختار در زبان برنامهنویسی C به ما این امکان را میدهد که چندین عنصر از انواع مختلف را گروه کنیم. این عناصر که فیلد (field) نامیده میشوند، با نام شناسایی میگردند. ساختار با آرایه (array) که یک ساختار دادهای حاوی عناصری از یک نوع خاص میباشد، متفاوت است. در آرایه، عناصر که عضو (element) نامیده میشوند، با ایندکس عددی دسترسی پیدا میکنند.
بهعنوانمثال، به این ساختار که اطلاعات توصیفی یکخانه را گروه میکند، توجه کنید:
1 2 3 4 5 6 7 8 9 | struct house { uint8_t stories; // تعداد طبقات خانه uint8_t bedrooms; // تعداد اتاقخوابها uint32_t squareFeet; // مساحت خانه }; |
برای دسترسی به یک عنصر از یک ساختار، از فرمت variable.field با یک نقطه در وسط استفاده کنید. برای نمونه:
1 2 3 4 5 6 7 8 9 | struct house myHouse; // ... myHouse.stories = 2; myHouse.bedrooms = 4; myHouse.squareFeet = 5000; |
برنامهی زیر نشان میدهد که چگونه همه این موارد را کنار هم قرار دهیم:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | // struct.c /** * استفاده از یک ساختار را نشان میدهد */ #include <stdio.h> #include <stdint.h> struct house { uint8_t stories; // تعداد طبقات خانه uint8_t bedrooms; // تعداد اتاقخوابها uint32_t squareFeet; // مساحت خانه }; int main() { struct house myHouse; myHouse.stories = 2; myHouse.bedrooms = 4; myHouse.squareFeet = 5000; printf("خانه -- طبقات: %d اتاقخوابها %d مساحت %d\n", myHouse.stories, myHouse.bedrooms, myHouse.squareFeet); printf("اندازه ساختار %ld\n", sizeof(myHouse)); return (0); } |
بیایید این برنامه را در محیط توسعهی STM32 (شکل ۸-۱ را ببینید) دیباگ کنیم.
شکل ۸-۱: نمایش متغیر ساختار
با توقف روی خط ۲۰، اکنون میتوانیم ساختار را در لیست متغیرها ببینیم. با کلیک روی نماد + ، ساختار برای نمایش محتوای آن باز میشود.
حالا بیایید ببینیم کامپایلر C چگونه این ساختار را در حافظه قرار میدهد. کامپایلر نیاز به اختصاص ۱ بایت برای طبقات (uint8_t)، ۱ بایت برای اتاقخوابها (uint8_t) و ۴ بایت برای مساحت (uint32_t) دارد. از لحاظ منطقی، چیدمان باید بهصورت جدول ۸-۱ باشد.
آدرس | نوع | فیلد |
۰ | uint8_t | stories |
۱ | uint8_t | bedrooms |
۲ | uint32_t | squareFeet |
۳ | uint32_t | squareFeet |
۴ | uint32_t | squareFeet |
۵ | – | – |
همانطور که از جدول ۸-۱ میبینیم، این ساختار ۶ بایت فضا اشغال میکند. بااینحال، وقتی برنامه را اجرا میکنیم، خروجی زیر را مشاهده میکنیم:
1 | Size of the structure 8 |
مشکل مربوط به طراحی حافظه است. در تراشه ARM (و بسیاری دیگر)، حافظه بهعنوان مجموعهای از اعداد صحیح ۳۲ بیتی سازماندهی شده است که روی یک مرز ۴ بایتی تراز شدهاند، به این صورت:
1 2 3 4 5 6 7 | بیت 0x10000 32 بیت 0x10004 32 بیت 0x10008 32 ... |
فرض کنید یک بایت ۸ بیتی در آدرس 0x10001 میخواهیم. ماشین ۳۲ بیت از 0x10000 میخواند و سپس ۲۴ بیت را دور میریزد که اتلافکننده است؛ زیرا دادههای اضافی خوانده میشود، اگرچه هیچ مشکل برای عملکرد وجود ندارد.
حالا فرض کنید به یک عدد صحیح ۳۲ بیتی نیاز داریم که از 0x10002 شروع میشود. تلاش برای خواندن مستقیم این داده منجر به یک استثنای تراز (alignment exception) میشود که برنامه ما را خراب میکند. کامپیوتر باید کارهای زیر را انجام دهد:
آنها را با هم ترکیب کند.
مدار داخلی ARM این مراحل را تکمیل نمیکند. در عوض، کامپایلر باید دستورالعملهای متعددی برای انجام کار ایجاد کند که عملکرد مناسبی ندارد. (در ادامه فصل به جزئیات بیشتری در این مورد میپردازیم.)
بسیار بهتر است که squareFeet روی یک مرز ۴ بایتی بهجای یک مرز ۲ بایتی تراز شود، بنابراین کامپایلر با افزودن ۲ بایت پدینگ(padding)، چیدمان ساختار را بهینه میکند. این کار باعث میشود ساختار بزرگتر شود؛ اما کار با آن بسیار آسانتر میشود. جدول ۸-۲ چیدمان واقعی تنظیمشده ساختار را نشان میدهد.
جدول ۸-۲: چیدمان ساختار با پدینگ
آدرس | نوع | فیلد |
۰ | uint8_t | stories |
۱ | uint8_t | bedrooms |
۲ | uint8_t | (پدینگ) |
۳ | uint8_t | (پدینگ) |
۴ | uint32_t | squareFeet |
۶ | – | squareFeet |
این پدینگ اضافی گاهی اوقات میتواند مشکلساز شود. برای مثال، اگر خانههای زیادی دارید و حافظهی بسیار محدودی در اختیار دارید، پدینگ موجود در هر ساختار خانه باعث هدررفتن فضای زیادی میشود.
مثال دیگری از این پدینگ در برنامهنویسی امبدد رخ میدهد. من یک دستگاه پخش موسیقی قدیمی به نام Rio داشتم که قبل از ظهور iPod عرضه شده بود. این دستگاه ابزار لینوکسی برای بارگذاری موسیقی نداشت، بنابراین خودم ابزارهایی برای آن نوشتم. هر بلاک داده دارای یک هدر شبیه به این بود:
1 2 3 4 5 | struct dataBlockHeader { uint32_t nextBlock; // شماره بلوک بعدی در این آهنگ uint16_t timeStamp; // زمان (بر حسب ثانیه) این بخش از آهنگ uint32_t previousBlock; // شماره بلوک قبلی در این آهنگ }; |
وقتی برای اولینبار آهنگها را روی Rio بارگذاری کردم، بهدرستی پخش میشدند. اما هنگامی که برای عقب بردن چند ثانیه دکمهی Rewind را فشار میدادم، دستگاه دیوانه میشد و دوباره کل آهنگ را از ابتدا پخش میکرد.
مشکل این بود که GCC پدینگ به ساختار اضافه میکرد:
1 2 3 4 5 6 | struct dataBlockHeader { uint32_t nextBlock; // شماره بلوک بعدی در این آهنگ uint16_t timeStamp; // زمان (بر حسب ثانیه) این بخش از آهنگ uint16_t padding; // بهطور خودکار اضافه شده uint32_t previousBlock; // شماره بلوک قبلی در این آهنگ }; |
در نتیجه، دستگاه Rio فکر میکرد که «بلوک قبلی» درواقع مقداری پدینگ به همراه نصف مقدار واقعی «بلوک قبلی» است. جای تعجب ندارد که دستگاه گیج شده بود!
برای حل این مشکل، باید به کامپایلر اعلام کنیم که پدینگ اضافه نکند. این کار با استفاده از ویژگی پکشده (packed) انجام میشود:
1 2 3 4 5 | struct dataBlockHeader { uint32_t nextBlock; // شماره بلوک بعدی در این آهنگ uint16_t timeStamp; // زمان (بر حسب ثانیه) این بخش از آهنگ uint32_t previousBlock; // شماره بلوک قبلی در این آهنگ } __attribute__((__packed__)); |
در این مثال، __attribute__((__packed__)) یک الحاقیهی GNU به زبان C است و ممکن است روی کامپایلرهای دیگر کار نکند.
در زمان توسعه نرم افزار شرایطی پیش میآید که میخواهیم قسمتی از حافظه که شامل فیلدهای درون ساختار ما است را به طور مستقیم درون ساختار خود کپی کنیم . اما میبینیم این کار به درستی انجام نمیشود. دلیل وجود این پدینگ های اضافی است و میتوان با کمک افزودن این الحاقیه مشکل برطرف میشود.
نویسنده شو !
سیسوگ با افتخار فضایی برای اشتراک گذاری دانش شماست. برای ما مقاله بنویسید.