البرمجة الوصفية (Metaprogramming) في JavaScript: دليل شامل للمطورين

دقائق القراءة: 10
يحتوي JavaScript على العديد من الميزات القوية والمفيدة التي يدركها معظم المطورين. وفي الوقت نفسه، توجد بعض الجواهر الخفية التي يمكنها حل مشكلات معقدة للغاية إذا كنت على دراية بها. تُعد البرمجة الوصفية (Metaprogramming) في JavaScript أحد هذه المفاهيم التي قد لا يكون الكثير منا على دراية بها.

في هذا المقال، سنتعمق في فهم Metaprogramming وكيف يمكن أن تكون مفيدة لنا. مع إصدار ES6 (ECMAScript 2015)، أصبح لدينا دعم لكائنَي Reflect و Proxy اللذين يتيحان لنا ممارسة البرمجة الوصفية بسهولة. سنتعلم في هذا المقال كيفية استخدامهما مع أمثلة عملية.

ما هي البرمجة الوصفية (Metaprogramming)؟

Metaprogramming هي أشبه بالسحر في عالم البرمجة! ماذا عن كتابة برنامج يقرأ، يعدل، يحلل، بل وحتى يولد برنامجًا آخر؟ ألا يبدو ذلك ساحرًا وقويًا؟

صورة توضيحية تشير إلى أن البرمجة الوصفية هي سحر في عالم البرمجيات.

تصف ويكيبيديا Metaprogramming على النحو التالي:

Metaprogramming هي تقنية برمجة تمتلك فيها برامج الكمبيوتر القدرة على التعامل مع برامج أخرى كبيانات خاصة بها. هذا يعني أنه يمكن تصميم برنامج لقراءة أو إنشاء أو تحليل أو تحويل برامج أخرى، وحتى تعديل نفسه أثناء التشغيل.

ببساطة، تتضمن Metaprogramming كتابة كود يمكنه:

  • توليد كود برمجي آخر.
  • التلاعب ببنى اللغة أثناء وقت التشغيل (run time).

تُعرف هذه الظاهرة باسم Reflective Metaprogramming أو Reflection.

ما هو الانعكاس (Reflection) في البرمجة الوصفية؟

Reflection هو فرع من فروع Metaprogramming. وللانعكاس ثلاثة فروع فرعية:

  • الاستبطان (Introspection): قدرة الكود على فحص نفسه. يُستخدم للوصول إلى الخصائص الداخلية بحيث يمكننا الحصول على معلومات منخفضة المستوى (low-level information) عن الكود الخاص بنا.
  • التعديل الذاتي (Self-Modification): كما يوحي الاسم، الكود قادر على تعديل نفسه.
  • التدخل (Intercession): المعنى الحرفي للتدخل هو العمل نيابة عن شخص آخر. في Metaprogramming، يقوم التدخل بنفس الشيء تمامًا باستخدام مفاهيم مثل التغليف (wrapping)، والاعتراض (trapping)، والتداخل (intercepting).

يوفر لنا إصدار ES6 كائن Reflect (المعروف أيضًا باسم Reflect API) لتحقيق Introspection. يساعدنا كائن Proxy في ES6 في تحقيق Intercession. لن نتحدث كثيرًا عن Self-Modification لأننا نرغب في الابتعاد عنها قدر الإمكان.

انتظر لحظة! للتوضيح، لم يتم تقديم Metaprogramming في ES6. بل كانت متاحة في اللغة منذ بدايتها. ES6 جعلها أسهل بكثير في الاستخدام.

عصر ما قبل ES6 للبرمجة الوصفية

هل تتذكر الدالة eval()؟ دعنا نلقي نظرة على كيفية استخدامها:


const blog = {
  name: 'freeCodeCamp'
}

console.log('Before eval:', blog);

const key = 'author';
const value = 'Tapas';

testEval = () => eval(`blog.${key} = '${value}'`);

// Call the function
testEval();

console.log('After eval magic:', blog);

كما قد تلاحظ، ساعدت الدالة eval() في توليد كود إضافي. في هذه الحالة، تم تعديل الكائن blog بخاصية إضافية في وقت التنفيذ.


Before eval: {name: freeCodeCamp}
After eval magic: {name: "freeCodeCamp", author: "Tapas"}

الاستبطان (Introspection)

قبل تضمين كائن Reflect في ES6، كان بإمكاننا القيام بالاستبطان (Introspection). إليك مثال على قراءة بنية البرنامج:


var users = {
  'Tom': 32,
  'Bill': 50,
  'Sam': 65
};

Object.keys(users).forEach(name => {
  const age = users[name];
  console.log(`User ${name} is ${age} years old!`);
});

هنا نقوم بقراءة بنية الكائن users وتسجيل الزوج المفتاح-القيمة في جملة.


User Tom is 32 years old!
User Bill is 50 years old!
User Sam is 65 years old!

التعديل الذاتي (Self Modification)

دعنا نأخذ كائن مدونة يحتوي على دالة لتعديل نفسه:


var blog = {
  name: 'freeCodeCamp',
  modifySelf: function (key, value) { blog[key] = value }
}

يمكن للكائن blog تعديل نفسه بالقيام بما يلي:


blog.modifySelf('author', 'Tapas');

التدخل (Intercession)

Intercession في Metaprogramming تعني العمل أو تغيير الأشياء نيابة عن شخص أو شيء آخر. يمكن لدالة Object.defineProperty() في عصر ما قبل ES6 تغيير دلالات الكائن:


var sun = {};
Object.defineProperty(sun, 'rises', {
  value: true,
  configurable: false,
  writable: false,
  enumerable: false
});

console.log('sun rises', sun.rises);
sun.rises = false;
console.log('sun rises', sun.rises);

الناتج:


sun rises true
sun rises true

كما ترى، تم إنشاء الكائن sun ككائن عادي. ثم تم تغيير دلالاته بحيث لا يكون قابلاً للكتابة (not writable). الآن دعنا ننتقل إلى فهم كائنَي Reflect و Proxy واستخداماتهما.

واجهة برمجة تطبيقات Reflect (Reflect API)

في ES6، Reflect هو كائن Global Object جديد (مثل Math) يوفر عددًا من الدوال المساعدة. قد تقوم بعض هذه الدوال بنفس العمل الذي تقوم به دوال من Object أو Function. جميع هذه الدوال هي دوال Introspection حيث يمكنك الاستعلام عن بعض التفاصيل الداخلية للبرنامج في وقت التشغيل.

إليك قائمة بالدوال المتاحة من كائن Reflect:


// Reflect object methods
Reflect.apply()
Reflect.construct()
Reflect.get()
Reflect.has()
Reflect.ownKeys()
Reflect.set()
Reflect.setPrototypeOf()
Reflect.defineProperty()
Reflect.deleteProperty()
Reflect.getOwnPropertyDescriptor()
Reflect.getPrototypeOf()
Reflect.isExtensible()

ولكن انتظر، هنا سؤال: لماذا نحتاج إلى كائن API جديد بينما يمكن أن تكون هذه الدوال موجودة بالفعل أو يمكن إضافتها إلى Object أو Function؟ هل أنت محتار؟ دعنا نحاول معرفة ذلك.

كل شيء في مساحة اسم واحدة (All in one namespace)

كانت JavaScript تدعم بالفعل انعكاس الكائنات (object reflection). لكن هذه الواجهات البرمجية (APIs) لم تكن منظمة تحت مساحة اسم واحدة. منذ ES6، أصبحت الآن تحت Reflect. جميع دوال كائن Reflect ثابتة بطبيعتها (static in nature). هذا يعني أنك لست بحاجة إلى إنشاء كائن Reflect باستخدام الكلمة المفتاحية new.

سهلة الاستخدام (Simple to use)

تُلقي دوال Introspection الخاصة بـ Object استثناءً (exception) عندما تفشل في إكمال العملية. وهذا عبء إضافي على المطور للتعامل مع هذا الاستثناء في الكود. قد تفضل التعامل معه كقيمة منطقية (boolean) (true | false) بدلاً من استخدام معالجة الاستثناءات (exception handling). يساعدك كائن Reflect في القيام بذلك. إليك مثال مع Object.defineProperty:


try {
  Object.defineProperty(obj, name, desc);
} catch (e) {
  // Handle the exception
}

ومع Reflect API:


if (Reflect.defineProperty(obj, name, desc)) {
  // success
} else {
  // failure (and far better)
}

انطباع الدالة من الدرجة الأولى (First-Class function)

يمكننا العثور على وجود خاصية لكائن ما بالشكل (prop in obj). إذا احتجنا إلى استخدامها عدة مرات في الكود الخاص بنا، فعلينا إنشاء دالة عن طريق تغليف هذا الكود. في ES6، يحل Reflect API هذه المشكلة من خلال تقديم دالة من الدرجة الأولى (first-class function)، وهي Reflect.has(obj, prop). دعنا نلقي نظرة على مثال آخر: حذف خاصية كائن.


const obj = { bar: true, baz: false };

// We define this function
function deleteProperty(object, key) {
  delete object[key];
}

deleteProperty(obj, 'bar');

مع Reflect API:


// With Reflect API
Reflect.deleteProperty(obj, 'bar');

طريقة أكثر موثوقية لاستخدام دالة apply()

تساعد دالة apply() في ES5 في استدعاء دالة بسياق قيمة this. يمكننا أيضًا تمرير الوسيطات كمصفوفة.


Function.prototype.apply.call(func, obj, arr); // or
func.apply(obj, arr);

هذا أقل موثوقية لأن func يمكن أن يكون كائنًا قد حدد دالة apply الخاصة به. في ES6 لدينا طريقة أكثر موثوقية وأناقة لحل هذه المشكلة:


Reflect.apply(func, obj, arr);

في هذه الحالة، سنحصل على TypeError إذا لم تكن func قابلة للاستدعاء (callable).

مساعدة أنواع أخرى من الانعكاس

سنرى ما يعنيه هذا بعد قليل عندما نتعلم عن كائن Proxy. يمكن استخدام دوال Reflect API مع Proxy في العديد من حالات الاستخدام.

كائن Proxy

يساعد كائن Proxy في ES6 في intercession. كما يوحي الاسم، يساعد كائن proxy في العمل نيابة عن شيء ما. يقوم بذلك عن طريق محاكاة كائن آخر (virtualizing another object). توفر محاكاة الكائن سلوكيات مخصصة لهذا الكائن. على سبيل المثال، باستخدام كائن proxy يمكننا محاكاة البحث عن خصائص الكائن (object property lookup)، واستدعاء الدوال (function invocation)، وما إلى ذلك. سنتناول بعض هذه الأمور بتفصيل أكبر أدناه.

إليك بعض المصطلحات المفيدة التي تحتاج إلى تذكرها واستخدامها:

  • الهدف (target): الكائن الذي يوفر له الـ proxy سلوكيات مخصصة.
  • المُعالج (handler): هو كائن يحتوي على مصائد (traps).
  • المصيدة (trap): المصيدة هي دالة توفر الوصول إلى خصائص الكائن الهدف (target object). يتم تحقيق ذلك باستخدام دوال Reflect API. يتم ربط كل دالة مصيدة (trap method) بالدوال من Reflect API. يمكنك تخيل الأمر كالتالي:

مخطط يوضح العلاقة بين Proxy و Handler و Target و Traps و Reflect API.

يجب تعريف مُعالج (handler) بدالة مصيدة (trap function). ثم نحتاج إلى إنشاء كائن Proxy باستخدام المُعالج (handler) والكائن الهدف (target object). سيحتوي كائن Proxy على جميع التغييرات مع السلوكيات المخصصة المطبقة. لا بأس إذا لم تفهم تمامًا من الوصف أعلاه. سنفهم الأمر من خلال الكود والأمثلة في دقيقة.

الصيغة لإنشاء كائن Proxy هي كالتالي:


let proxy = new Proxy(target, handler);

هناك العديد من مصائد الـ proxy (دوال المُعالج) المتاحة للوصول إلى كائن هدف وتخصيصه. إليك قائمة بها:


handler.apply()
handler.construct()
handler.get()
handler.has()
handler.ownKeys()
handler.set()
handler.setPrototypeOf()
handler.getPrototypeOf()
handler.defineProperty()
handler.deleteProperty()
handler.getOwnPropertyDescriptor()
handler.preventExtensions()
handler.isExtensible()

لاحظ أن كل مصيدة لها ربط بدوال كائن Reflect. هذا يعني أنه يمكنك استخدام Reflect و Proxy معًا في العديد من حالات الاستخدام.

كيفية الحصول على قيم خصائص الكائن غير المتاحة

دعنا نلقي نظرة على مثال لكائن employee ونحاول طباعة بعض خصائصه:


const employee = {
  firstName: 'Tapas',
  lastName: 'Adhikary'
};

console.log(employee.firstName);
console.log(employee.lastName);
console.log(employee.org);
console.log(employee.fullName);

الناتج المتوقع هو كالتالي:


Tapas
Adhikary
undefined
undefined

الآن دعنا نستخدم كائن Proxy لإضافة بعض السلوك المخصص إلى الكائن employee.

الخطوة 1: إنشاء مُعالج (Handler) يستخدم مصيدة get

سنستخدم مصيدة تسمى get تتيح لنا الحصول على قيمة خاصية. إليك المُعالج الخاص بنا:


let handler = {
  get: function (target, fieldName) {
    if (fieldName === 'fullName') {
      return `${target.firstName} ${target.lastName}`;
    }
    return fieldName in target ? target[fieldName] : `No such property as, '${fieldName}'!`;
  }
};

يساعد المُعالج أعلاه في إنشاء قيمة لخاصية fullName. كما يضيف رسالة خطأ أفضل عندما تكون خاصية الكائن مفقودة.

الخطوة 2: إنشاء كائن Proxy

بما أن لدينا الكائن الهدف employee والمُعالج (handler)، سنتمكن من إنشاء كائن Proxy كالتالي:


let proxy = new Proxy(employee, handler);

الخطوة 3: الوصول إلى الخصائص في كائن Proxy

الآن يمكننا الوصول إلى خصائص كائن employee باستخدام كائن proxy، كالتالي:


console.log(proxy.firstName);
console.log(proxy.lastName);
console.log(proxy.org);
console.log(proxy.fullName);

الناتج سيكون:


Tapas
Adhikary
No such property as, 'org'!
Tapas Adhikary

لاحظ كيف قمنا بتغيير الأشياء بشكل سحري لكائن employee!

Proxy للتحقق من صحة القيم (Validation of Values)

دعنا ننشئ كائن proxy للتحقق من صحة قيمة عدد صحيح.

الخطوة 1: إنشاء مُعالج (Handler) يستخدم مصيدة set

يبدو المُعالج كالتالي:


const validator = {
  set: function (obj, prop, value) {
    if (prop === 'age') {
      if (!Number.isInteger(value)) {
        throw new TypeError('Age is always an Integer, Please Correct it!');
      }
      if (value < 0) {
        throw new TypeError('This is insane, a negative age?');
      }
    }
    // يجب دائمًا إعادة تعيين القيمة إذا لم يتم إلقاء خطأ
    Reflect.set(obj, prop, value);
    return true;
  }
};

الخطوة 2: إنشاء كائن Proxy

أنشئ كائن proxy كالتالي:


let proxy = new Proxy(employee, validator);

الخطوة 3: تعيين قيمة غير عدد صحيح لخاصية، على سبيل المثال، age

حاول القيام بذلك:


proxy.age = 'I am testing a blunder'; // string value

الناتج سيكون كالتالي:


TypeError: Age is always an Integer, Please Correct it!
    at Object.set (E:\Projects\KOSS\metaprogramming\js-mtprog\proxy\userSetProxy.js:28:23)
    at Object. (E:\Projects\KOSS\metaprogramming\js-mtprog\proxy\userSetProxy.js:40:7)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Function.Module.runMain (module.js:693:10)
    at startup (bootstrap_node.js:188:16)
    at bootstrap_node.js:609:3

وبالمثل، حاول القيام بذلك:


proxy.age = -1; // will result in error

كيفية استخدام Proxy و Reflect معًا

إليك مثال لمُعالج (handler) نستخدم فيه دوال من Reflect API:


const employee = {
  firstName: 'Tapas',
  lastName: 'Adhikary'
};

let logHandler = {
  get: function (target, fieldName) {
    console.log("Log: ", target[fieldName]);
    // Use the get method of the Reflect object
    return Reflect.get(target, fieldName);
  }
};

let func = () => {
  let p = new Proxy(employee, logHandler);
  p.firstName;
  p.lastName;
};

func();

المزيد من حالات استخدام Proxy

هناك العديد من حالات الاستخدام الأخرى التي يمكن فيها تطبيق هذا المفهوم:

  • لحماية حقل ID لكائن من الحذف (مصيدة: deleteProperty).
  • لتتبع الوصول إلى الخصائص (مصيدة: get، set).
  • لربط البيانات (Data Binding) (مصيدة: set).
  • مع المراجع القابلة للإلغاء (revocable references).
  • للتلاعب بسلوك عامل التشغيل in.
  • ... والعديد غيرها.

مخاطر البرمجة الوصفية (Metaprogramming Pitfalls)

بينما يمنحنا مفهوم Metaprogramming الكثير من القوة، فإن سحرها يمكن أن يسير في الاتجاه الخاطئ أحيانًا.

صورة متحركة لساحر يلقي تعويذة، ترمز إلى مخاطر الاستخدام المفرط للبرمجة الوصفية.

احذر من الجانب الآخر للسحر:

  • الكثير من 'السحر'! تأكد من فهمك للمفهوم جيدًا قبل تطبيقه.
  • ضربات أداء محتملة عندما تحاول جعل المستحيل ممكنًا.
  • يمكن اعتبارها مضادة لتصحيح الأخطاء (counter-debugging).

الخلاصة التقنية

باختصار، يُعد كائنَا Reflect و Proxy إضافتين رائعتين إلى JavaScript لمساعدة المطورين في البرمجة الوصفية (Metaprogramming). يمكن التعامل مع الكثير من المواقف المعقدة بمساعدتهما، مما يتيح لنا كتابة كود أكثر ديناميكية ومرونة. ومع ذلك، يجب أن نكون على دراية بالجوانب السلبية المحتملة، مثل التأثير على الأداء وصعوبة تصحيح الأخطاء، وأن نستخدم هذه الأدوات بحكمة وبعد فهم عميق. يمكن أيضًا استخدام رموز ES6 Symbols مع الفئات والكائنات الموجودة لديك لتغيير سلوكها. آمل أن يكون هذا المقال قد قدم لك رؤى قيمة.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *