بررسی #include guard در زبان C
گاهیاوقات یه ساختار مشابه رو داخل نرمافزارها، برنامهها، و libraryهای مختلف میبینیم و اصلاً نمیدونیم که چرا اون الگو، هِی تکرار میشه. داخل مهندسی نرمافزار هم مثل بقیهی جاها، ما خیلیاوقات برای حل مشکلات تکراری و رایج، از روشهای تکراری و راهحلهای شناختهشده استفاده میکنیم؛ پس وقتی که یه الگوی تکراری رو میبینیم، باید بدونیم که به احتمال خیلی زیادی، یه مشکل رایج وجود داره و از اون ساختار، برای حل اون مشکل استفاده میشه. یکی از این الگوهای خیلیخیلی متداولی که همیشه داخل فایلهای هِدِر (header) میبینیمش، ساختار #include guard هست. در این مقاله، یه مشکل همیشگی که داخل استفاده از فایلهای هدر وجود داره مطرح میشه و بعدش، ما الگوی #include guard رو برای حل اون مشکل، معرفی و بررسی میکنیم. نکات مختلف و راهحل احتمالی دیگهای هم برای حل این مسأله، بیان خواهد شد.
اگر تا حالا به فایلهای هِدِری (header) که ازشون استفاده میکنید نگاه کرده باشید، بهاحتمال خیلی زیادی با دو تا خط، شبیه کد زیر مواجه شدهاید که معمولاً در همون ابتدای فایل قرار دارن:
#ifndef MYHEADER_H
#define MYHEADER_H
البته وقتی به کد بالا نگاه میکنید، متوجه میشید که اون #ifndef
توی خط اول، قاعدتاً به یه دونه #endif
نیاز داره. خب معمولاً وقتی به انتهای همون فایل هدر مراجعه کنید، میتونید اون #endif
متناظر رو هم ببینید، که اغلب با نوشتن یه کامنت جلوش، یهجوری ارتباطش رو با این خط اول نشون میدن:
#endif /* MYHEADER_H */
پس بیشتر اوقات، وقتی به یه فایل هدر نگاه کنید، میبینید که محتوای اصلی فایل هدر، توسط یه همچین ساختاری که گفتیم، احاطه شده؛ یعنی یهچیزی شبیه الگوی زیر رو میبینید (که در اون، محتوای اصلی فایل هدر رو با کامنت /* file contents */
نشون دادهایم):
#ifndef MYHEADER_H
#define MYHEADER_H
/* file contents */
#endif /* MYHEADER_H */
به این الگو، میگن #include guard. اگر به هر فایل هدری نگاه کنید، با احتمال خیلیخیلی زیادی به یه همچین ساختاری برمیخورید. مثلاً همین الآن یه نگاهی به فایل stm32f0xx.h بندازید. اگر کامنتهای اول فایل رو رد کنیم، به دو خط زیر میرسیم:
#ifndef __STM32F0xx_H
#define __STM32F0xx_H
بعدش محتوای اصلی فایل رو میبینیم و در انتهای فایل، خط زیر مشاهده میشه:
#endif /* __STM32F0xx_H */
پس داخل این هدری که همیشه ازش استفاده میکنیم هم دقیقاً همون الگوی #include guard رو میبینیم که الآن معرفی شد. باید ببینیم که چرا از یه ساختار مشابه، داخل اغلب فایلهای هدر استفاده میشه. در واقع چه مشکلی وجود داشته، که میخوان با این الگوی تکراری، اون مشکل رو حل کنن؟
مشکل رایج در استفاده از فایلهای هدر
فرض کنید که یه فایل هدر point.h داریم که یه struct
نقطهی هندسی داخلش تعریف شده. محتوای این فایل، بهشکل زیر هست:
// point.h
struct Point
{
int x;
int y;
};
یه فایل هدر دیگه هم داریم که line.h نام داره و مثلاً میاد خط رو با استفاده از دو تا نقطه تعریف میکنه. نحوهی تعریف کردن خط و سایر محتوای احتمالی این فایل، برای بحث ما اهمیتی نداره. مهم اینه که چون فایل line.h برای تعریف کردن خط به struct Point
نیاز داره، پس احتیاج داره که فایل point.h رو #include
کنه. پس ساختار کلی فایل line.h بهصورت زیر هست:
// line.h
#include "point.h"
/* line.h file contents */
توجه داشته باشید که فایل line.h فایل point.h رو #include
کرده و سایر محتوای فایل هم برای بحث ما اهمیتی نداره.
حالا ما مثلاً میخوایم داخل برنامهی خودمون از نقطه و خط استفاده کنیم. خب خیلی طبیعی هست که بیایم فایلهای هدر مربوط به نقطه و خط رو داخل برنامهی خودمون #include
کنیم. احتمالاً مثل کد زیر، فایلهای هدر مورد نیاز خودمون رو در ابتدای فایل main.c، #include
میکنیم (سایر محتوای فایل هم برای بحث ما اهمیتی نداره):
// main.c
#include "point.h"
#include "line.h"
/* main.c file contents */
اگر تلاش کنیم تا این برنامه رو کامپایل کنیم، با یه خطایی شبیه خطای زیر مواجه میشیم:
error: redefinition of ‘struct Point’
برای بررسی مشکل، به خطا توجه میکنیم. خطا گویاست: تعریف مجدد struct Point
! اما ما که فقط یک بار struct Point
رو تعریف کردهایم. قضیه چیه؟ کافیه که بیایم خطهای مربوط به #include
رو در فایل main.c، بهصورت دستی جایگذاری کنیم. (البته توجه داشته باشید که روالی که ما برای جایگذاری دستی فایلهای هدر طی میکنیم، لزوماً شبیه روالی که Preprocessor طی میکنه، نیست و ما قصدمون صرفاً بررسی نتیجهی جایگذاریها هست.) اگر عیناً محتوای فایلهای point.h و line.h رو در فایل main.c جایگذاری کنیم، به چیزی شبیه محتوای زیر میرسیم:
// main.c
// point.h
struct Point
{
int x;
int y;
};
// line.h
#include "point.h"
/* line.h file contents */
/* main.c file contents */
همونطوری که میبینید، هنوز یه دونه #include "point.h"
(از محتوای فایل line.h) وجود داره که باید جایگذاری بشه. پس مجدداً باید محتوای فایل point.h رو جایگذاری کنیم. واضحه که مشکل دقیقاً از همین جایگذاری دوبارهی محتوای point.h ناشی میشه. محتوای فایل main.c رو بعد از این جایگذاری مجدد ببینیم:
// main.c
// point.h
struct Point
{
int x;
int y;
};
// line.h
// point.h
struct Point
{
int x;
int y;
};
/* line.h file contents */
/* main.c file contents */
همونطوری که دیگه انتظار داریم، تعریف struct Point
تکرار شده؛ چرا که فایل point.h دو بار جایگذاری شد. اگر کامنتهای بیخودی رو حذف کنیم، همهچی واضحتر میشه:
// main.c
struct Point
{
int x;
int y;
};
struct Point
{
int x;
int y;
};
/* line.h file contents */
/* main.c file contents */
فایل main.c ما، دو بار struct Point
رو تعریف کرده و این یه خطاست. این دقیقاً همون خطایی هست که کامپایلر به ما میده. مشکل از اونجایی ناشی شد که ما هر دو فایل point.h و line.h رو داخل فایل main.c خودمون #include
کردیم و چون فایل line.h، خودش دوباره فایل point.h رو #include
کرده بود، انگار نهایتاً فایل point.h رو دو بار #include
کردهایم. پس مشکل اینه که ما یه فایل هدر رو دوباره #include
کردهایم. باید یهجوری از این #include
شدن مجدد جلوگیری کنیم.
راهحل بهدردنخور
احتمالاً اولش پیش خودمون میگیم که: خب اصلاً چرا داخل فایل main.c، ما فایل point.h رو #include
میکنیم؟ مگه نه که فایل line.h، خودش فایل point.h رو #include
کرده؟ پس قاعدتاً نوشتن #include "line.h"
داخل فایل main.c کافیه تا فایل point.h هم اتوماتیک، #include
بشه. البته که این راهحل، درسته و مشکل فعلی ما رو برطرف میکنه. مثلاً فرض کنید که فایل main.c رو اینطوری بنویسیم:
// main.c
#include "line.h"
/* main.c file contents */
پس با جایگذاری line.h در این فایل، به محتوای زیر میرسیم:
// main.c
// line.h
#include "point.h"
/* line.h file contents */
/* main.c file contents */
و با جایگذاری point.h هم فایل نهایی ما بهدست میاد که اتفاقاً هیچ خطایی هم نداره و همونطوری که میشه دید، struct Point
فقط یک بار تعریف شده:
// main.c
// line.h
// point.h
struct Point
{
int x;
int y;
};
/* line.h file contents */
/* main.c file contents */
اما این راهحل، مشکلات خودشو داره:
- باید حواسمون به این باشه که هر فایل هدر، چه فایلهای هدر دیگهای رو
#include
کرده، تا اینکه یه موقعی خودمون اون فایلها رو دوباره#include
نکنیم. کاملاً واضحه که وقتی تعداد فایلهای هدر ما یهکمی بیشتر بشه، دیگه چک کردن دستی این مسأله اصلاً کار راحتی نیست و اتفاقاً خیلی مستعد خطاست. - بهعلاوه، هر تغییری که یهوقتی داخل یکی از فایلهامون بدیم، ممکنه باعث بشه که
#include
ها نیاز به تغییر و تصحیح داشته باشن. - اصلاً نفس این قضیه که ما همیشه حواسمون به محتوای دقیق و نحوهی پیادهسازی فایلهای هدر جمع باشه، باعث میشه که برنامهنویسی ما دیگه حالت modular نداشته باشه: تغییر
#include
ها در یکی از فایلهای هدر، ممکنه که ما رو مجبور کنه تا کلی فایل دیگه رو هم تغییر بدیم. - اینجا ما از شیوههای درست برنامهنویسی هم پیروی نکردهایم: لیست
#include
های موجود داخل یه فایل، به ما کمک میکنه تا متوجه بشیم که هر فایل، دقیقاً به چه ماجولها و امکاناتی از سیستم نیاز داره. بهتره که این لیست، نیازمندیهای ما رو بهصراحت بیان کنه تا ما با یهنگاه متوجه کارکرد و نیازمندیهای کد موجود داخل فایل بشیم. اگر ما، هم از امکانات موجود در فایل point.h استفاده میکنیم و هم از امکانات موجود در فایل line.h، بهتره که صراحتاً هر دو فایل رو#include
کنیم. الآنی که فقط فایل line.h رو#include
کردهایم، نمیتونیم متوجه بشیم که آیا فایل main.c، مستقیماً خودش به point.h هم نیاز داره یا نه. این کار، کد ما رو مبهم میکنه. نگهداری کد هم سختتر میشه.
خلاصه این که شاید این راهحل برای یه «کد سریع و کثیف» کارمون رو راه بندازه، ولی حقیقت اینه که خودش میتونه مشکلات زیادی هم برامون ایجاد کنه. استفاده از #include guard راهحل خیلیخیلی بهتری هست که اتفاقاً اصلاً هم پیچیده نیست و کارمون رو بهخوبی راه میاندازه.
راهحل خوب
داخل مقدمهی این مقاله با ساختار کلی یه #include guard آشنا شدیم و دیدیم که معمولاً محتوای فایلهای هدر ما، حالتی شبیه کد زیر دارن:
#ifndef MYHEADER_H
#define MYHEADER_H
/* file contents */
#endif /* MYHEADER_H */
توجه اکید میکنیم که اسم ماکروی MYHEADER_H
، دلخواه هست. همینطور، متوجه هستیم که محتوای اصلی فایل هدر، بهجای خط کامنت /* file contents */
و درون #include guard قرار میگیره.
ببینیم سه تا خطی که #include guard رو تشکیل میدن، چهکار میکنن. توجه میکنیم که #endif
موجود در خط آخر، متناظر با دستور #ifndef
موجود داخل خط اول هست و شرط #ifndef
، وضعیت پردازش یا عدم پردازش محتوای بین این دو دستور رو مشخص میکنه. خط #ifndef
ما به Preprocessor میگه که:
- اگر ماکروی
MYHEADER_H
تعریف نشده، برو سراغ خطهای بعدی و هر چیزی که بین خط#ifndef
تا#endif
متناظر باهاش هست رو پردازش کن. - اما اگر ماکروی
MYHEADER_H
تعریف شده، شما لطف کن تا#endif
متناظر با#ifndef
رو کلاً نادیده بگیر! یعنی هرچیزی که بین#ifndef
و#endif
متناظر باهاش هست، انگار که اصلاً وجود نداره.
خط دوم هم که #define MYHEADER_H
باشه، بهوضوح میاد ماکروی MYHEADER_H
رو تعریف میکنه. پس اگر این سه تا خط رو باهم درنظر بگیریم، دارن به Preprocessor میگن که:
- اگر ماکروی
MYHEADER_H
تعریف نشده، اول تعریفش کن، بعدش هم محتوای اصلی فایل هدر رو پردازش کن. - اما اگر ماکروی
MYHEADER_H
تعریف شده، محتوای فایل هدر رو نادیده بگیر.
همینجا، میتونیم ببینیم که چطوری این ساختار به ما کمک میکنه تا مشکل #include
مجدد نداشته باشیم: اگر یک بار محتوای فایل هدر ما پردازش بشه، ماکروی MYHEADER_H
تعریف میشه و در دفعات بعدی، محتوای فایل هدر نادیده گرفته میشه!
بیاین این قضیه رو درعمل ببینیم. برگردیم به مثال نقطه و خط. مشکل ما، #include
کردن دوبارهی فایل point.h بود. میخوایم که فایل point.h فقط یک بار #include
بشه. پس دور محتوای اصلی فایل point.h، یه دونه #include guard قرار میدیم:
// point.h
#ifndef POINT_H
#define POINT_H
struct Point
{
int x;
int y;
};
#endif /* POINT_H */
توجه کنید که این بار از اسم POINT_H
بهعنوان اسم ماکروی #include guard استفاده کردهایم تا اسمش گویاتر باشه و مخصوص به همین فایل باشه. فعلاً نیازی به دستکاری فایل line.h نداریم:
// line.h
#include "point.h"
/* line.h file contents */
و فایل main.c رو هم بههمون شکلی که دوست داریم، با #include
کردن هر دو فایل هدر مینویسیم:
// main.c
#include "point.h"
#include "line.h"
/* main.c file contents */
حالا بیایم دستی، فایلهای point.h و line.h رو جایگذاری کنیم. مجدداً توجه داشته باشید که روال جایگذاری دستی ما، ممکنه که با روال پردازش فایل توسط Preprocessor، متفاوت باشه و ما اینجا فقط میخوایم نتیجهی پردازش رو بررسی کنیم:
// main.c
// point.h
#ifndef POINT_H
#define POINT_H
struct Point
{
int x;
int y;
};
#endif /* POINT_H */
// line.h
#include "point.h"
/* line.h file contents */
/* main.c file contents */
هنوز یه دونه #include "point.h"
باقی مونده که دوباره جایگذاری میکنیم:
// main.c
// point.h
#ifndef POINT_H
#define POINT_H
struct Point
{
int x;
int y;
};
#endif /* POINT_H */
// line.h
// point.h
#ifndef POINT_H
#define POINT_H
struct Point
{
int x;
int y;
};
#endif /* POINT_H */
/* line.h file contents */
/* main.c file contents */
داخل این فایل جدید، تعریف دوم struct Point
، بیاثر هست. اگر این فایل main.c جدید رو بدیم به یه Preprocessor، اتفاقی که میافته اینه:
- میرسه به خط
#ifndef POINT_H
اول. خب اینجا ما اصلاً هنوز هیچ ماکرویی با اسمPOINT_H
تعریف نکردهایم، پس Preprocessor، شرط#ifndef
رو برقرار میدونه و میره سراغ پردازش خطهای بعدی. - خط بعدی ما،
#define POINT_H
هست و ماکرویPOINT_H
در این نقطه از کد ما تعریف میشه. پس توجه داشته باشیم که ماکرویPOINT_H
دیگه تعریف شد! - Preprocessor تعریف
struct Point
رو پردازش میکنه و اون رو داخل فایل نهایی میذاره و میرسه به#endif
اول. - بعدش میرسه به خط
#ifndef POINT_H
دوم! اینجا شرط#ifndef
برقرار نیست؛ چون که ماکرویPOINT_H
تعریف شده. پس Preprocessor ما،#define
بعدی و تعریف دومstruct Point
رو کلاً نادیده میگیره و از فایل نهایی، حذف میکنه.
پس همونطوری که دیدید، نتیجهی نهایی اینه که تعریف دوم struct Point
و درحقیقت، #include
دوم فایل point.h، کلاً نادیده گرفته شد. نتیجهی پردازش فایل main.c توسط Preprocessor، یهچیزی شبیه کد زیر هست:
// main.c
struct Point
{
int x;
int y;
};
/* line.h file contents */
/* main.c file contents */
و این دقیقاً همون چیزیه که ما میخوایم: تعریف struct Point
فقطوفقط یک بار اومده و محتوای فایل line.h هم در خروجی ما قرار داره. حالا برنامهی موجود داخل فایل main.c ما، میتونه که آزادانه از محتوای فایلهای point.h و line.h استفاده کنه و هیچ تعریف مجدد و خطایی هم دیگه وجود نداره. دم #include guard گرم.
نکات
توجه داشته باشیم که فایل line.h در این مثال خاص ما، نیازی به #include guard نداشت؛ اما قرار دادن یه #include guard روی اون، ضرری هم نداره و اتفاقاً میتونه از بروز مشکلات مشابه در آینده، جلوگیری کنه.
نکتهی بعدی اینکه اسم ماکروی #include guard برای هر فایلی باید یکتا و مختص به همون فایل باشه. پس باید اسم ماکرو رو طوری تعریف کنیم که احیاناً با ماکروهایی که libraryهای دیگه تعریف میکنن هم تداخل نداشته باشه. کل این راهحل، به این دلیل کار میکنه که در دفعهی اول پردازش فایل هدر، ماکروی خاص فایل ما تعریف نشده و محتوای فایل، پردازش میشه و با تعریف شدن اون ماکروی خاص، جلوی پردازش فایل در دفعات بعدی گرفته میشه. بهوضوح، اگر اسم ماکروها برای فایلهای مختلف تداخل داشته باشه، این روال کاملاً خراب میشه.
حالا که مشکل و راهحل استانداردش رو خوب بررسی کردیم، این نکته رو هم بدونیم که یه راهحل دیگهای هم وجود داره که از #include guard هم سادهتر هست: نوشتن یه دونه خط ناقابل #pragma once
اول هر فایل هدر و تمام!!! البته دستور #pragma once
، استاندارد نیست، اما کامپایلرهای فعلی اون رو میشناسن. جنگودعوا سر استفاده کردن و نکردن ازش خیلی زیاده. میتونید که با مطالعهی منابع مختلف، نقاط ضعف و قوتش رو بررسی کنید و برای استفاده یا عدم استفاده ازش تصمیمگیری کنید.
جمعبندی
#include
شدن چندبارهی فایلهای هدر، جلوی کامپایل موفق برنامهها رو میگیره. در پروژههای واقعی، عملاً امکان کنترل غیرخودکار و دستی فایلهای هدر برای حل این مشکل، وجود نداره. راهحل #include guard، راهحل استاندارد و خیلی سادهای هست که جلوی #include
شدن چندبارهی یه فایل هدر رو میگیره. البته راهحل سادهتر #pragma once
هم هست که با وجود غیراستاندارد بودن، توسط کامپایلرهای فعلی پشتیبانی میشه؛ اما سر استفاده کردن و نکردن ازش، بحثوجدل زیادی وجود داره. تصمیم با شما.