المغلقات (Closures) والدوال المكررة (Curried Functions): بناء تجريدات متقدمة في JavaScript

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

المغلقات (Closures): فهم البيئة المعجمية

تُعد المغلقات موضوعًا شائعًا ومحوريًا في JavaScript، وهي النقطة التي سنبدأ منها رحلتنا. وفقًا لشبكة مطوري Mozilla (MDN):

«المغلق (Closure) هو دالة مدمجة (مغلقة) مع مراجع إلى حالتها المحيطة (البيئة المعجمية).»

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

 function makeFunction ( ) {
  const name = 'TK' ;
  function displayName ( ) {
    console .log(name);
  }
  return displayName;
};

ماذا لدينا هنا؟

  • دالتنا الرئيسية تُسمى makeFunction.
  • ثابت باسم name تم تعيينه بالسلسلة النصية 'TK'.
  • تعريف الدالة displayName (التي تقوم فقط بتسجيل قيمة الثابت name).
  • وأخيرًا، الدالة makeFunction تُعيد الدالة displayName.

هذا مجرد تعريف لدالة. عندما نستدعي الدالة makeFunction، ستقوم بإنشاء كل شيء داخلها: ثابت ودالة أخرى في هذه الحالة. كما نعلم، عندما يتم إنشاء الدالة displayName، يتم إنشاء المغلق أيضًا، وهذا يجعل الدالة واعية ببيئتها، وفي هذه الحالة، الثابت name. هذا هو السبب في أننا نستطيع استخدام console.log للثابت name دون أي مشكلة. الدالة تعرف بيئتها المعجمية.

 const myFunction = makeFunction();
myFunction(); // TK

رائع! تعمل كما هو متوقع. القيمة المُعادة من makeFunction هي دالة نقوم بتخزينها في الثابت myFunction. عندما نستدعي myFunction، فإنها تعرض TK. يمكننا أيضًا جعلها تعمل كدالة سهمية (arrow function):

 const makeFunction = () => {
  const name = 'TK' ;
  return () => console .log(name);
};

ولكن ماذا لو أردنا تمرير الاسم وعرضه؟ الأمر بسيط! استخدم معاملًا (parameter):

 const makeFunction = ( name = 'TK' ) => {
  return () => console .log(name);
};

// أو كسطر واحد
const makeFunction = ( name = 'TK' ) => () => console .log(name);

الآن يمكننا اللعب بالاسم:

 const myFunction = makeFunction();
myFunction(); // TK

const myFunction = makeFunction( 'Dan' );
myFunction(); // Dan

الدالة myFunction واعية بالمعامل الذي تم تمريره، سواء كان قيمة افتراضية أو ديناميكية. يضمن المغلق أن الدالة التي تم إنشاؤها لا تدرك فقط الثوابت/المتغيرات، بل أيضًا الدوال الأخرى داخل الدالة. لذا، هذا يعمل أيضًا:

 const makeFunction = ( name = 'TK' ) => {
  const display = () => console .log(name);
  return () => display();
};

const myFunction = makeFunction();
myFunction(); // TK

الدالة المُعادة تعرف الدالة display وتستطيع استدعاءها.

تجريد الدوال والمتغيرات “الخاصة” باستخدام المغلقات

إحدى التقنيات القوية هي استخدام المغلقات لبناء دوال ومتغيرات “خاصة” (private). قبل أشهر، كنت أتعلم هياكل البيانات (مرة أخرى!) وأردت تطبيق كل واحدة منها. لكنني كنت دائمًا أستخدم منهج البرمجة الشيئية (object-oriented approach). بصفتي متحمسًا للبرمجة الوظيفية (functional programming)، أردت بناء جميع هياكل البيانات باتباع مبادئ البرمجة الوظيفية (الدوال النقية pure functions، عدم القابلية للتغيير immutability، الشفافية المرجعية referential transparency، إلخ).

كانت أول بنية بيانات أتعلمها هي المكدس (Stack). إنها بسيطة جدًا. واجهة برمجتها الرئيسية (API) هي:

  • push: إضافة عنصر إلى أول مكان في المكدس.
  • pop: إزالة العنصر الأول من المكدس.
  • peek: الحصول على العنصر الأول من المكدس.
  • isEmpty: التحقق مما إذا كان المكدس فارغًا.
  • size: الحصول على عدد العناصر في المكدس.

يمكننا بوضوح إنشاء دالة بسيطة لكل “طريقة” (method) وتمرير بيانات المكدس إليها. يمكنها بعد ذلك استخدام/تحويل البيانات وإعادتها. ولكن يمكننا أيضًا إنشاء مكدس ببيانات خاصة (private data) وكشف طرق واجهة البرمجة فقط. دعونا نفعل ذلك!

 const buildStack = () => {
  let items = [];
  const push = ( item ) => items = [item, ...items];
  const pop = () => items = items.slice( 1 );
  const peek = () => items[ 0 ];
  const isEmpty = () => !items.length;
  const size = () => items.length;

  return {
    push,
    pop,
    peek,
    isEmpty,
    size,
  };
};

نظرًا لأننا أنشأنا مكدس items داخل دالتنا buildStack، فإنه يعتبر “خاصًا”. يمكن الوصول إليه فقط داخل الدالة. في هذه الحالة، فقط الدوال push و pop وما إلى ذلك يمكنها التعامل مع البيانات. هذا بالضبط ما نبحث عنه. وكيف نستخدمه؟ هكذا:

 const stack = buildStack();
stack.isEmpty(); // true
stack.push( 1 ); // [1]
stack.push( 2 ); // [2, 1]
stack.push( 3 ); // [3, 2, 1]
stack.push( 4 ); // [4, 3, 2, 1]
stack.push( 5 ); // [5, 4, 3, 2, 1]
stack.peek(); // 5
stack.size(); // 5
stack.isEmpty(); // false
stack.pop(); // [4, 3, 2, 1]
stack.pop(); // [3, 2, 1]
stack.pop(); // [2, 1]
stack.pop(); // [1]
stack.isEmpty(); // false
stack.peek(); // 1
stack.pop(); // []
stack.isEmpty(); // true
stack.size(); // 0

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

الدوال المكررة (Curried Functions): تحويل الوسائط المتعددة

«التكرير (Currying) هو عملية تحويل دالة ذات وسائط متعددة إلى سلسلة من الدوال، كل منها يقبل وسيطًا واحدًا فقط.» (Frontend Interview)

تخيل أن لديك دالة ذات وسائط متعددة: f(a, b, c). باستخدام التكرير، نحصل على دالة f(a) تُعيد دالة g(b) تُعيد دالة h(c). بشكل أساسي: f(a, b, c)f(a) => g(b) => h(c).

دعونا نبني مثالًا بسيطًا يضيف رقمين. ولكن أولاً، بدون تكرير:

 const add = ( x, y ) => x + y;
add( 1 , 2 ); // 3

رائع! بسيط للغاية! هنا لدينا دالة ذات وسيطين. لتحويلها إلى دالة مكررة (curried function)، نحتاج إلى دالة تستقبل x وتُعيد دالة تستقبل y وتُعيد مجموع القيمتين.

 const add = ( x ) => {
  function addY ( y ) {
    return x + y;
  }
  return addY;
};

يمكننا إعادة هيكلة addY إلى دالة سهمية مجهولة (anonymous arrow function):

 const add = ( x ) => {
  return ( y ) => {
    return x + y;
  }
};

أو تبسيطها عن طريق بناء دوال سهمية في سطر واحد:

 const add = ( x ) => ( y ) => x + y;

هذه الدوال المكررة الثلاث المختلفة لها نفس السلوك: بناء سلسلة من الدوال بوسيط واحد فقط. كيف يمكننا استخدامها؟

add( 10 )( 20 ); // 30

في البداية، قد يبدو الأمر غريبًا بعض الشيء، ولكن هناك منطق وراءه. add(10) تُعيد دالة. ونحن نستدعي هذه الدالة بالقيمة 20. هذا هو نفسه:

 const addTen = add( 10 );
addTen( 20 ); // 30

وهذا مثير للاهتمام. يمكننا إنشاء دوال متخصصة عن طريق استدعاء الدالة الأولى. تخيل أننا نريد دالة increment. يمكننا إنشاؤها من دالتنا add عن طريق تمرير 1 كقيمة.

 const increment = add( 1 );
increment( 9 ); // 10

بناء تجريدات معقدة باستخدام التكرير

عندما كنت أطبق مكتبة Lazy Cypress، وهي مكتبة npm لتسجيل سلوك المستخدم على صفحة نموذج وإنشاء كود اختبار Cypress، أردت بناء دالة لإنشاء السلسلة النصية input[data-testid="123"]. لذلك كان لدي العنصر (input)، والسمة (data-testid)، والقيمة (123). كان دمج هذه السلسلة في JavaScript سيبدو هكذا: ${element}[${attribute}="${value}"].

كان تطبيقي الأول هو استقبال هذه القيم الثلاث كمعاملات وإرجاع السلسلة النصية المدمجة المذكورة أعلاه:

 const buildSelector = ( element, attribute, value ) =>
` ${element} [ ${attribute} =" ${value} "]` ;
buildSelector( 'input' , 'data-testid' , 123 ); // input[data-testid="123"]

وكان رائعًا. حققت ما كنت أبحث عنه. ولكن في الوقت نفسه، أردت بناء دالة أكثر اصطلاحية. شيء يمكنني من خلاله كتابة “احصل على العنصر X بالسمة Y والقيمة Z“.

لذا إذا قسمنا هذه العبارة إلى ثلاث خطوات:

  • “احصل على عنصر X“: get(x)
  • “بالسمة Y“: withAttribute(y)
  • “والقيمة Z“: andValue(z)

يمكننا تحويل buildSelector(x, y, z) إلى get(x)withAttribute(y)andValue(z) باستخدام مفهوم التكرير.

 const get = ( element ) => {
  return {
    withAttribute : ( attribute ) => {
      return {
        andValue : ( value ) => ` ${element} [ ${attribute} =" ${value} "]` ,
      }
    }
  };
};

هنا نستخدم فكرة مختلفة: إرجاع كائن بدالة كزوج مفتاح-قيمة. ثم يمكننا تحقيق هذه الصيغة: get(x).withAttribute(y).andValue(z). ولكل كائن مُعاد، لدينا الدالة والوسيط التالي.

حان وقت إعادة الهيكلة! لنزيل عبارات return:

 const get = ( element ) => ({
  withAttribute : ( attribute ) => ({
    andValue : ( value ) => ` ${element} [ ${attribute} =" ${value} "]` ,
  }),
});

أعتقد أنها تبدو أجمل. وإليك كيفية استخدامها:

 const selector = get( 'input' )
  .withAttribute( 'data-testid' )
  .andValue( 123 );
selector; // input[data-testid="123"]

الدالة andValue تعرف قيمتي element و attribute لأنها واعية بالبيئة المعجمية، تمامًا كما هو الحال مع المغلقات التي تحدثنا عنها سابقًا.

التكرير الجزئي: تجريد مستمعي الأحداث

يمكننا أيضًا تطبيق الدوال باستخدام “التكرير الجزئي” (partial currying) عن طريق فصل الوسيط الأول عن البقية على سبيل المثال. بعد العمل في تطوير الويب لفترة طويلة، أنا على دراية تامة بواجهة برمجة تطبيقات الويب الخاصة بمستمعي الأحداث (event listener Web API). إليك كيفية استخدامها:

 const log = () => console .log( 'clicked' );
button.addEventListener( 'click' , log);

أردت إنشاء تجريد لبناء مستمعي أحداث متخصصين واستخدامهم عن طريق تمرير العنصر ومعالج رد الاتصال (callback handler).

 const buildEventListener = ( event ) => ( element, handler ) => element.addEventListener(event, handler);

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

 const onClick = buildEventListener( 'click' );
onClick(button, log);

const onHover = buildEventListener( 'hover' );
onHover(link, log);

بناء استعلامات SQL باستخدام JavaScript (تجريد متقدم)

باستخدام كل هذه المفاهيم، تمكنت من إنشاء استعلام SQL باستخدام بناء جملة JavaScript. أردت استعلام بيانات JSON بهذا الشكل:

 const json = {
  "users" : [
    { "id" : 1 , "name" : "TK" , "age" : 25 , "email" : "tk@mail.com" },
    { "id" : 2 , "name" : "Kaio" , "age" : 11 , "email" : "kaio@mail.com" },
    { "id" : 3 , "name" : "Daniel" , "age" : 28 , "email" : "dani@mail.com" }
  ]
}

لذلك قمت ببناء محرك بسيط للتعامل مع هذا التطبيق:

 const startEngine = ( json ) => ( attributes ) => ({
  from : from (json, attributes)
});

const buildAttributes = ( node ) => ( acc, attribute ) => ({
  ...acc,
  [attribute]: node[attribute]
});

const executeQuery = ( attributes, attribute, value ) => ( resultList, node ) =>
  node[attribute] === value ? [...resultList, attributes.reduce(buildAttributes(node), {})] : resultList;

const where = ( json, attributes ) => ( attribute, value ) =>
  json .reduce(executeQuery(attributes, attribute, value), []);

const from = ( json, attributes ) => ( node ) => ({
  where : where(json[node], attributes)
});

باستخدام هذا التطبيق، يمكننا بدء المحرك ببيانات JSON:

 const select = startEngine(json);

واستخدامه كاستعلام SQL:

select([ 'id' , 'name' ])
  .from( 'users' )
  .where( 'id' , 1 );
// result; // [{ id: 1, name: 'TK' }]

هذا كل شيء لهذا اليوم. يمكنني الاستمرار في عرض العديد من الأمثلة المختلفة للتجريدات، لكنني سأدعك تتلاعب بهذه المفاهيم بنفسك وتستكشف إمكانياتها.

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

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

اترك تعليقاً

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