بررسی #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 هم هست که با وجود غیراستاندارد بودن، توسط کامپایلرهای فعلی پشتیبانی می‌شه؛ اما سر استفاده کردن و نکردن ازش، بحث‌وجدل زیادی وجود داره. تصمیم با شما.