التزامن

7. التزامن

حتى الآن لم نأخذ في الاعتبار إلا البرامج التسلسلية (sequential). فتنفيذ البرنامج التسلسلي يسير خطوة بخطوة، دون خيار بشأن الخطوة التالية. والبرامج التسلسلية محدودة بأنها ليست بارعة في التعامل مع مصادر متعددة للمدخلات الآنية، ولا يمكنها أن تُنفَّذ إلا على معالج واحد. أما كثير من التطبيقات الحديثة فهي متزامنة (concurrent) بدلًا من ذلك.

في هذا الفصل سنتعلم الوعود (promises)، وهي طريقة لتنظيم الحسابات المتزامنة ذاعت شهرتها حديثًا في البرمجة الأمرية للويب. وسنرى مكتبة في OCaml تُسمى Lwt تنفّذ الوعود. وتستند تلك المكتبة جزئيًا إلى مفهوم الموناد (monad)، وهو طريقة لتنظيم أي نوع من الحساب ذي التأثيرات (الجانبية)، لذا سنختم الفصل بفحص الموناد.

7.1. التزامن

تمكّن البرامج المتزامنة الحسابات من التداخل زمنيًا، بدلًا من أن تُفرض عليها التسلسلية.

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

والخوادم (servers) مثال آخر على التطبيقات التي تحتاج إلى التزامن. فخادم الويب يحتاج إلى الاستجابة لكثير من الطلبات من العملاء، والعملاء يفضّلون ألا ينتظروا. فإذا صدر تكليف في نظام CMS، مثلًا، فأنت تفضّل أن تتمكن من عرض ذلك التكليف في الوقت نفسه الذي يعرضه فيه كل من في الصف، بدلًا من أن تضطر إلى «أخذ رقم» وانتظار دورك—كما في إدارة المرور، أو في مطعم قديم الطراز، وما إلى ذلك.

ومن المهام الأساسية لنظام التشغيل (OS) توفير التزامن. فهو يمكّن تطبيقات كثيرة من التنفيذ المتزامن: مشغّل موسيقى، ومتصفح ويب، ومحرّر شيفرة، وما إلى ذلك. فكيف يفعل ذلك؟ ثمة مقاربتان أساسيتان متكاملتان:

  • التداخل (Interleaving): التبديل السريع ذهابًا وإيابًا بين الحسابات. فمثلًا، نفّذ مشغّل الموسيقى مدة 100 ميلي ثانية، ثم المتصفح، ثم المحرر، ثم أعد الكرة. وهذا يجعل الأمر يبدو وكأن حسابات متعددة تحدث في الوقت نفسه، لكن في الواقع لا يحدث إلا حساب واحد في أي لحظة.
  • التوازي (Parallelism): استخدام عتاد قادر على أداء حسابين أو أكثر حرفيًا في الوقت نفسه. وكثير من المعالجات اليوم متعددة النوى (multicore)، أي أنها تحتوي وحدات معالجة مركزية (CPUs) متعددة، يمكن لكل منها تنفيذ برنامج في وقت واحد.

ومهما كانت المقاربات المستخدمة، فالبرمجة المتزامنة صعبة. فحتى مع توفر نوى متعددة للاستخدام الآني، تبقى موارد أخرى كثيرة يجب تقاسمها: الذاكرة، والشاشة، وواجهة الشبكة، وما إلى ذلك. وإدارة هذا التقاسم، وخصوصًا دون إدخال أخطاء، أمر بالغ الصعوبة. فمثلًا، إذا أراد برنامجان التواصل باستخدام ذاكرة الحاسوب، فلا بد من اتفاق على متى يُسمح لكل برنامج بالقراءة والكتابة من الذاكرة. وإلا، فقد يحاول البرنامجان مثلًا الكتابة إلى الموقع نفسه في الذاكرة، مما يؤدي إلى بيانات فاسدة. وهذه الأنواع من حالات التسابق (race conditions)، حيث يتسابق برنامج لإتمام عملياته قبل برنامج آخر، من الصعب التفادي بها تفاديًا مشهورًا.

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

البرامج الوظيفية البحتة تجعل اللاحتمية أسهل في الاستدلال، لأن تقييم التعبير يعيد القيمة نفسها دائمًا مهما كان الأمر. فمثلًا، في التعبير (2 * 4) + (3 * 5)، يمكن تنفيذ العمليات بالتزامن (أي بتقييم الجداءين الأيسر والأيمن في الوقت نفسه) دون تغيير الجواب. أما البرمجة الأمرية فإشكالية أكثر. فمثلًا، التعبيران !x وincr x; !x، إذا نُفِّذا بالتزامن، قد يعطيان نتيجة مختلفة حسب أيّهما يُنفَّذ أولًا.

7.1.1. الخيوط

لتسهيل البرمجة المتزامنة، اخترع علماء الحاسوب تجريدات كثيرة. ومن أشهرها الخيوط (threads). فمن حيث التجريد، الخيط حساب تسلسلي واحد. وقد تعمل خيوط كثيرة في وقت واحد، إما بالتداخل وإما بالتوازي حسب العتاد، ويتولى مُجدوِل (scheduler) اختيار الخيوط العاملة في أي لحظة. وقد تكون الجدولة استباقية (preemptive)، بمعنى أنه يُسمح للمجدول بإيقاف خيط وإعادة تشغيله لاحقًا دون أن يكون للخيط خيار في الأمر، أو تعاونية (cooperative)، بمعنى أنه يجب على الخيط أن يختار التنازل عن التحكم للمجدول. ويمكن أن تؤدي الأولى إلى حالات تسابق، والثانية إلى تطبيقات بطيئة الاستجابة.

وعلى المستوى الملموس، الخيط مجموعة قيم تُحمَّل في سجلات المعالج. وتخبر تلك القيم المعالج بمكان العثور على التعليمة التالية التي ينفّذها، وأين يقع مكدسه وكومته في الذاكرة، وما إلى ذلك. ولتنفيذ الاستباقية، يضبط المجدول مؤقتًا في العتاد؛ وعندما ينطلق المؤقت، يُقاطَع الخيط الحالي ليتمكن المجدول من العمل. وعادةً ما تتناول مقررات برمجة الأنظمة وأنظمة التشغيل هذه المفاهيم بالتفصيل.

7.2. الوعود

في نمط البرمجة الوظيفية، من أشهر تجريدات التزامن الوعود (promises). ومن الأسماء الأخرى لهذه الفكرة المستقبليات (futures)، والمؤجَّلات (deferreds)، والمؤخَّرات (delayeds). وتشير كل هذه الأسماء إلى فكرة حساب لم يكتمل بعد: فقد وعد بإنتاج قيمة في النهاية، لكن إكمال الحساب أُجِّل أو أُخِّر. وقد تكون هناك قيم كثيرة من هذا القبيل تُحسب بالتزامن، وعندما تصبح القيمة متاحة أخيرًا، قد تكون هناك حسابات جاهزة للتنفيذ تعتمد عليها.

تبنّت هذه الفكرة على نطاق واسع لغات ومكتبات كثيرة، منها Java وJavaScript و.NET. بل إن JavaScript الحديثة تضيف كلمة مفتاحية async تجعل الدالة تُعيد وعدًا، وكلمة مفتاحية await تنتظر انتهاء حساب وعد. وتوجد مكتبتان واسعتا الانتشار في OCaml تنفذان الوعود: Async و Lwt. وAsync تطوّرها Jane Street. أما Lwt فهي جزء من مشروع Ocsigen، وهو إطار ويب لـ OCaml.

وسنلقي الآن نظرة أعمق على الوعود في Lwt. كان اسم المكتبة اختصارًا لـ «الخيوط خفيفة الوزن». لكن ذلك كان تسمية مضللة، كما تعترف صفحة GitHub (حتى 22/10/2018):

يشير كثير من الدليل الحالي إلى … «الخيوط خفيفة الوزن» أو «الخيوط» فحسب. وسيُصحَّح ذلك في الدليل الجديد. [تنفّذ Lwt] الوعود، ولا علاقة لها بخيوط النظام أو الخيوط الاستباقية.

فلا تفكر إذن في Lwt على أنها ذات علاقة بالخيوط: فهي حقًا مكتبة للوعود.

في Lwt، الوعد (promise) مرجع: قيمة يُسمح لها بالتغيير مرة واحدة على الأكثر. وعند إنشائه يكون كصندوق فارغ لا يحتوي شيئًا. ونقول إن الوعد معلّق (pending). ويمكن في النهاية إيفاؤه (fulfilled)، وهو مثل وضع شيء داخل الصندوق. وبدلًا من إيفائه، يمكن بدلًا من ذلك رفضه (rejected)، وفي هذه الحالة يُملأ الصندوق باستثناء. وفي كلتا الحالتين، سواء أُوفي الوعد أم رُفض، نقول إنه محلول (resolved). وسواء كان الوعد محلولًا أم مرفوضًا، فبمجرد ملء الصندوق لا يمكن أن يتغير محتواه أبدًا.

وسنغفل في الوقت الحاضر عن التزامن في معظم ما يأتي. وسنعود لاحقًا لإدماجه. لكن ثمة جزءًا من تصميم التزامن علينا معالجته الآن. فعندما نبدأ لاحقًا في استخدام دوال للتزامن يوفرها نظام التشغيل، مثل القراءات والكتابات المتزامنة من الملفات، سنحتاج إلى تقسيم للمسؤوليات:

  • الشيفرة العميلة التي تريد الاستفادة من التزامن ستحتاج إلى الوصول إلى الوعود: الاستعلام عما إذا كانت محلولة أم معلّقة، والاستفادة من القيم المحلولة.
  • أما شيفرة المكتبة ونظام التشغيل التي تنفّذ التزامن فستحتاج إلى تغيير الوعد—أي إيفائه أو رفضه فعليًا. والشيفرة العميلة لا تحتاج إلى تلك القدرة.

لذا سنقدم تجريدًا إضافيًا واحدًا يُسمى المُحِلّ (resolver). وستكون هناك علاقة واحد بواحد بين الوعود والمُحِلّات. وسيُستخدم مُحِلّ الوعد داخليًا بواسطة مكتبة التزامن لكن لن يُكشف للعملاء. ولن يحصل العملاء إلا على الوعد.

فمثلًا، لنفترض أن مكتبة التزامن تدعم عملية لقراءة سلسلة نصية من الشبكة بالتزامن. فستنفّذ المكتبة تلك العملية كما يلي:

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

وربما تفكر في المُحِلّ كقيمة «خاصة وقابلة للكتابة» تستخدمها المكتبة أساسًا، وفي الوعد كقيمة «عامة وللقراءة فقط» يستخدمها العميل أساسًا.

7.3. تنفيذ الوعود

إليك واجهة لوعودنا على غرار Lwt. وقد غُيِّرت الأسماء لجعل الواجهة أوضح.

(** A signature for Lwt-style promises, with better names. *)
module type PROMISE = sig
  type 'a state =
    | Pending
    | Fulfilled of 'a
    | Rejected of exn
  type 'a promise
  type 'a resolver
  (** [make ()] is a new promise and resolver. The promise is pending. *)
  val make : unit -> 'a promise * 'a resolver
  (** [return x] is a new promise that is already fulfilled with value
      [x]. *)
  val return : 'a -> 'a promise
  (** [state p] is the state of the promise. *)
  val state : 'a promise -> 'a state
  (** [fulfill r x] fulfills the promise [p] associated with [r] with
      value [x], meaning that [state p] will become [Fulfilled x].
      Requires: [p] is pending. *)
  val fulfill : 'a resolver -> 'a -> unit
  (** [reject r x] rejects the promise [p] associated with [r] with
      exception [x], meaning that [state p] will become [Rejected x].
      Requires: [p] is pending. *)
  val reject : 'a resolver -> exn -> unit
end
module type PROMISE =
  sig
    type 'a state = Pending | Fulfilled of 'a | Rejected of exn
    type 'a promise
    type 'a resolver
    val make : unit -> 'a promise * 'a resolver
    val return : 'a -> 'a promise
    val state : 'a promise -> 'a state
    val fulfill : 'a resolver -> 'a -> unit
    val reject : 'a resolver -> exn -> unit
  end

لتنفيذ تلك الواجهة، يمكننا جعل نوع تمثيل 'a promise مرجعًا إلى حالة:

type 'a state = Pending | Fulfilled of 'a | Rejected of exn
type 'a promise = 'a state ref
type 'a state = Pending | Fulfilled of 'a | Rejected of exn
type 'a promise = 'a state ref

وبهذه الطريقة يصبح من الممكن تغيير محتوى الوعد.

أما نوع تمثيل المُحِلّ فسنفعل شيئًا لطيفًا بعض الشيء. سيكون ببساطة هو نفسه الوعد.

type 'a resolver = 'a promise
type 'a resolver = 'a promise

لذا فالنوعان متطابقان تمامًا من الداخل. لكن من الخارج لن يستطيع أي عميل لوحدة Promise التمييز بينهما. وبعبارة أخرى، نستخدم نظام الأنواع للتحكم في إمكانية تطبيق دوال معينة (مثل state مقابل fulfill) على وعد.

وللمساعدة في تنفيذ بقية الدوال، لنبدأ بكتابة دالة مساعدة write_once : 'a promise -> 'a state -> unit لتحديث المرجع. وستنفّذ هذه الدالة تغيير حالة الوعد من معلّق إلى مُوفى أو مرفوض، وبمجرد أن تتغير الحالة لن تسمح بتغييرها مرة أخرى. أي أنها تفرض ثابت الكتابة مرة واحدة (write once invariant).

(** [write_once p s] changes the state of [p] to be [s].  If [p] and [s]
    are both pending, that has no effect.
    Raises: [Invalid_arg] if the state of [p] is not pending. *)
let write_once p s =
  if !p = Pending
  then p := s
  else invalid_arg "cannot write twice"
val write_once : 'a state ref -> 'a state -> unit = <fun>

وباستخدام تلك الدالة المساعدة، يمكننا تنفيذ دالة make:

let make () =
  let p = ref Pending in
  (p, p)
val make : unit -> 'a state ref * 'a state ref = <fun>

أما الدوال المتبقية في الواجهة فتنفيذها تافه. وبجمع كل ذلك في وحدة، نحصل على:

module Promise : PROMISE = struct
  type 'a state =
    | Pending
    | Fulfilled of 'a
    | Rejected of exn
  type 'a promise = 'a state ref
  type 'a resolver = 'a promise
  (** [write_once p s] changes the state of [p] to be [s]. If [p] and
      [s] are both pending, that has no effect. Raises: [Invalid_arg] if
      the state of [p] is not pending. *)
  let write_once p s =
    if !p = Pending then p := s else invalid_arg "cannot write twice"
  let make () =
    let p = ref Pending in
    (p, p)
  let return x = ref (Fulfilled x)
  let state p = !p
  let fulfill r x = write_once r (Fulfilled x)
  let reject r x = write_once r (Rejected x)
end
module Promise : PROMISE

7.3.1. وعود Lwt

الأنواع والأسماء المستخدمة في Lwt أكثر غموضًا قليلًا من تلك التي استخدمناها أعلاه. وتستخدم Lwt مصطلحات مشابهة آتية من الخيوط—لكن بما أن Lwt لا تنفّذ خيوطًا فعليًا، فليس من الضروري أن تكون تلك المصطلحات مفيدة. (ولا نريد التقليل من شأن Lwt! فهي مكتبة ظلت تتطور وتتغير مع الوقت.)

وتحتوي واجهة Lwt على الإعلانات التالية، وقد أضفنا إليها تعليقات لمقارنتها بالواجهة التي نفّذناها أعلاه:

module type Lwt = sig
  (* [Sleep] means pending. [Return] means fulfilled.
     [Fail] means rejected. *)
  type 'a state = Sleep | Return of 'a | Fail of exn
  (* a [t] is a promise *)
  type 'a t
  (* a [u] is a resolver *)
  type 'a u
  val state : 'a t -> 'a state
  (* [wakeup_later] means [fulfill] *)
  val wakeup_later : 'a u -> 'a -> unit
  (* [wakeup_later_exn] means [reject] *)
  val wakeup_later_exn : 'a u -> exn -> unit
  (* [wait] means [make] *)
  val wait : unit -> 'a t * 'a u
  val return : 'a -> 'a t
end
module type Lwt =
  sig
    type 'a state = Sleep | Return of 'a | Fail of exn
    type 'a t
    type 'a u
    val state : 'a t -> 'a state
    val wakeup_later : 'a u -> 'a -> unit
    val wakeup_later_exn : 'a u -> exn -> unit
    val wait : unit -> 'a t * 'a u
    val return : 'a -> 'a t
  end

تنفيذ Lwt لتلك الواجهة أكثر تعقيدًا بكثير من تنفيذنا أعلاه، لأن Lwt تدعم فعليًا عمليات أكثر على الوعود. ومع ذلك، فالأفكار الجوهرية التي طورناها أعلاه توفر حدسًا سليمًا لما تنفّذه Lwt.

وفيما يلي مثال على شيفرة Lwt يمكنك تجربتها في utop:

#require "lwt";;
let p, r = Lwt.wait();;
val p : '_weak1 Lwt.t = <abstr>
val r : '_weak1 Lwt.u = <abstr>

ولتفادي متغيرات الأنواع الضعيفة تلك، يمكننا تقديم تلميح إضافي إلى OCaml بشأن النوع الذي نريد في النهاية وضعه في الوعد. فمثلًا، لو أردنا وعدًا سيحتوي في النهاية على int، لأمكننا كتابة هذه الشيفرة:

let (p : int Lwt.t), r = Lwt.wait ()
val p : int Lwt.t = <abstr>
val r : int Lwt.u = <abstr>

الآن يمكننا حل الوعد:

Lwt.state p
- : int Lwt.state = Lwt.Sleep
Lwt.wakeup_later r 42
- : unit = ()
Lwt.state p;;
- : int Lwt.state = Lwt.Return 42
Lwt.wakeup_later r 42
Exception: Invalid_argument "Lwt.wakeup_later".
Raised at Stdlib.invalid_arg in file "stdlib.ml", line 30, characters 20-45
Called from <unknown> in file "[15]", line 1, characters 0-21
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 93, characters 4-14

رُفع ذلك الاستثناء الأخير لأننا حاولنا حل الوعد مرة ثانية، وهو أمر غير مسموح به.

ولرفض وعد، يمكننا كتابة شيفرة مماثلة:

let (p : int Lwt.t), r = Lwt.wait ();;
Lwt.wakeup_later_exn r (Failure "nope");;
Lwt.state p;;
val p : int Lwt.t = <abstr>
val r : int Lwt.u = <abstr>
- : unit = ()
- : int Lwt.state = Lwt.Fail (Failure "nope")

لاحظ أن شيئًا مما نفّذناه حتى الآن لا يفعل شيئًا بالتزامن. فتجريد الوعد بحد ذاته ليس متزامنًا بطبيعته. إنه مجرد بنية بيانات يمكن الكتابة فيها مرة واحدة على الأكثر، وتوفر وسيلة للتحكم في من يمكنه الكتابة فيه (عبر المُحِلّ).

7.4. الإدخال والإخراج غير المتزامنين

والآن بعد أن فهمنا الوعود كتجريد بيانات، لننتقل إلى كيفية استخدامها للتزامن. والطريقة النمطية لاستخدامها مع Lwt هي الإدخال والإخراج (I/O) المتزامنان.

دوال الإدخال/الإخراج التي تشكل جزءًا من المكتبة القياسية لـ OCaml متزامنة (synchronous) أو حاجِبة (blocking): فعندما تستدعي دالة كهذه، لا تعود حتى يكتمل الإدخال/الإخراج. ويشير «التزامن» هنا إلى التزامن بين شيفرتك ودالة الإدخال/الإخراج: فلا تعود شيفرتك للتنفيذ حتى تنتهي شيفرة الإدخال/الإخراج. ويشير «الحجب» إلى أن شيفرتك يجب أن تنتظر—فهي محجوبة—حتى يكتمل الإدخال/الإخراج.

فمثلًا، تقرأ الدالة Stdlib.input_line : in_channel -> string محارف من قناة إدخال (input channel) حتى تصل إلى محرف سطر جديد، ثم تعيد المحارف التي قرأتها. والنوع in_channel مجرّد؛ وهو يمثل مصدر بيانات يمكن قراءته، مثل ملف أو الشبكة أو لوحة المفاتيح. والقيمة Stdlib.stdin : in_channel تمثل قناة الإدخال القياسي (standard input)، وهي القناة التي توفر عادةً، افتراضيًا، مدخلات لوحة المفاتيح.

إذا نفّذت الشيفرة التالية في utop، فستلاحظ سلوك الحجب:

# ignore(input_line stdin); print_endline "done";;
<type your own input here>
done
- : unit = ()

لا تُطبع السلسلة "done" إلا بعد اكتمال عملية الإدخال، وهو ما يحدث بعد أن تكتب Enter.

الإدخال/الإخراج المتزامن يجعل من المستحيل على البرنامج متابعة حسابات أخرى بينما ينتظر اكتمال عملية الإدخال/الإخراج. وبعض البرامج لا مشكلة لديها في ذلك. فلعبة مغامرات نصية، مثلًا، ليس لديها حسابات خلفية تحتاج إلى أدائها. لكن برامج أخرى، مثل الجداول الحسابية أو الخوادم، سيكون أداؤها أفضل لو تمكنت من متابعة الحسابات في الخلفية بدلًا من الاضطرار إلى الحجب التام انتظارًا للمدخلات.

أما الإدخال/الإخراج غير المتزامن (asynchronous) أو غير الحاجِب (non-blocking) فهو النمط المعاكس. فعمليات الإدخال/الإخراج غير المتزامنة تعود فورًا، بصرف النظر عن اكتمال الإدخال أو الإخراج. وهذا يمكّن البرنامج من إطلاق عملية إدخال/إخراج، ومتابعة حسابات أخرى، ثم العودة لاحقًا للاستفادة من العملية المكتملة.

توفر مكتبة Lwt دوال الإدخال/الإخراج الخاصة بها في وحدة Lwt_io، وهي في حزمة lwt.unix. والدالة Lwt_io.read_line : Lwt_io.input_channel -> string Lwt.t هي المكافئ غير المتزامن لـ Stdlib.input_line. وبالمثل، Lwt_io.input_channel هي مكافئ in_channel في المكتبة القياسية لـ OCaml، وLwt_io.stdin يمثل قناة الإدخال القياسي.

نفّذ هذه الشيفرة في utop لملاحظة السلوك غير الحاجب:

# #require "lwt.unix";;
# open Lwt_io;;
# ignore(read_line stdin); printl "done";;
done
- : unit = ()
# <type your own input here>

تُطبع السلسلة "done" فورًا بواسطة Lwt_io.printl، وهي مكافئ Stdlib.print_endline في Lwt، قبل أن تكتب حتى. ولاحظ أنه من الأفضل استخدام دوال الإدخال/الإخراج لمكتبة واحدة فقط، بدلًا من خلطها معًا.

وعندما تكتب مدخلك فعلًا، فلن تراه معروضًا على الشاشة، لأن ذلك يحدث في الخلفية. فـ utop ما زال يعمل—فهو غير محجوب—لكن مدخلك يُرسَل إلى دالة read_line تلك بدلًا من utop. وعندما تكتب Enter أخيرًا، تكتمل عملية الإدخال، وتعود إلى التفاعل مع utop.

والآن تخيّل أنه بدلًا من قراءة سطر قراءة غير متزامنة، كان البرنامج خادم ويب يقرأ ملفًا ليقدمه لعميل. وبدلًا من طباعة سلسلة، كان الخادم يسلّم محتويات ملف آخر اكتملت قراءته إلى عميل آخر. ولهذا السبب يمكن أن يكون الإدخال/الإخراج غير المتزامن مفيدًا جدًا: فهو يساعد على إخفاء زمن الوصول (latency hiding). ويعني «زمن الوصول» هنا انتظار نقل البيانات من مكان إلى آخر، مثلًا من القرص إلى الذاكرة. وإخفاء زمن الوصول استخدام ممتاز للتزامن.

لاحظ أن كل التزامن هنا يأتي في الحقيقة من نظام التشغيل، وهو ما يوفر البنية التحتية للإدخال/الإخراج غير المتزامن. وLwt ليست إلا كاشفة تلك البنية التحتية لك عبر مكتبة.

7.4.1. الوعود والإدخال/الإخراج غير المتزامن

نوع مخرج Lwt_io.read_line هو string Lwt.t، أي أن الدالة تعيد وعدًا بـ string. ولنتقصَّ كيف تتطور حالة ذلك الوعد.

عندما يُعاد الوعد من read_line، يكون معلّقًا:

# let p = read_line stdin in Lwt.state p;;
- : string Lwt.state = Lwt.Sleep
# <now you have to type input and Enter to regain control of utop>

وعندما يُضغط مفتاح Enter ويكتمل الإدخال، ينبغي أن يصير الوعد المُعاد من read_line مُوفى. فمثلًا، لنفترض أنك أدخلت «Camels are bae»:

# let p = read_line stdin;;
val p : string Lwt.t = <abstr>
<now you type Camels are bae followed by Enter>
# p;;
- : string = "Camels are bae"

لكن إن درست ذلك المخرج بعناية، فستلاحظ أن شيئًا غريبًا جدًا قد حدث للتو! فبعد عبارة let، كان نوع p هو string Lwt.t، كما هو متوقع. لكن عندما قيّمنا p، عاد بنوع string. وكأن الوعد اختفى.

الذي يحدث في الواقع أن utop فيه وظيفة خاصة—وقد تكون مربكة—مرتبطة بـ Lwt. وبوجه التحديد، كلما حاولت تقييم وعد مباشرةً في المستوى الأعلى، سيعطيك utop محتوى الوعد، بدلًا من الوعد نفسه، وإذا لم يكن الوعد محلولًا بعد، فسيحجب utop حتى يصير الوعد محلولًا حتى يمكن إعادة المحتوى.

لذا فالمخرج - : string = "Camels are bae" يعني حقًا أن p يحتوي على string مُوفى قيمته "Camels are bae"، وليس أن p نفسه string. وفعلًا، ستُظهر لنا التوجيه #show_val أن p وعد:

# #show_val p;;
val p : string Lwt.t

لتعطيل تلك الوظيفة في utop، أو لإعادة تمكينها، استدعِ الدالة UTop.set_auto_run_lwt : bool -> unit، التي تغيّر طريقة تقييم utop لوعود Lwt في المستوى الأعلى. ويمكنك رؤية تغيّر السلوك في الشيفرة التالية:

# UTop.set_auto_run_lwt false;;
- : unit = ()
<now you type Camels are bae followed by Enter>
# p;;
- : string Lwt.state = <abstr>
# Lwt.state p;;
- : string Lwt.state = Lwt.Return "Camels are bae"

إذا أعدت تمكين وظيفة «التشغيل التلقائي» هذه، وحاولت مباشرةً تقييم الوعد المُعاد من read_line، فستراه يتصرف تمامًا كالإدخال/الإخراج المتزامن، أي Stdlib.input_line:

# UTop.set_auto_run_lwt true;;
- : unit = ()
# read_line stdin;;
Camels are bae
- : string = "Camels are bae"

ونظرًا لاحتمال الالتباس، سنفترض من الآن فصاعدًا أن التشغيل التلقائي معطّل. ومن الطرق الجيدة لفعل ذلك وضع السطر التالي في ملف .ocamlinit:

UTop.set_auto_run_lwt false;;

7.5. دوال الاستدعاء

لكي يستفيد البرنامج من التزامن الذي يوفره الإدخال/الإخراج غير المتزامن والوعود، لا بد من طريقة ليستفيد البرنامج من الوعود المحلولة. فمثلًا، إذا كان خادم ويب يقرأ ملفات متعددة ويقدمها لعملاء متعددين بصورة غير متزامنة، فيحتاج الخادم إلى طريقة لـ (1) يعلم بها أن قراءة ما قد اكتملت، ثم (2) يقوم بكتابة غير متزامنة جديدة بنتيجة القراءة. وبعبارة أخرى، تحتاج البرامج إلى آلية لإدارة التبعيات بين الوعود.

الآلية التي توفرها Lwt تُسمى دوال الاستدعاء (callbacks). ودالة الاستدعاء دالة، عند «تسجيلها» مع وعد، ستُنفَّذ في وقت ما بعد إيفاء ذلك الوعد. وستتلقى الدالة محتوى الوعد المُوفى كمدخل. فكّر في الأمر كأنك تطلب من صديق أن يجري لك بعض الحسابات: فهو يعدك بفعلها، وبأن يتصل بك هاتفيًا بالجواب في وقت ما بعد أن ينتهي. ويمكنك فعل أشياء أخرى بينما تنتظر اتصاله، وعندما يتصل يمكنك استخدام الجواب الذي يعطيك إياه.

7.5.1. تسجيل دالة استدعاء

إليك دالة تطبع سلسلة نصية باستخدام نسخة Lwt من الدالة printf:

let print_the_string str = Lwt_io.printf "The string is: %S\n" str

وهنا، مكررًا من القسم السابق، شيفرتنا التي تعيد وعدًا بسلسلة تُقرأ من الإدخال القياسي:

let p = read_line stdin

لتسجيل دالة الطباعة كدالة استدعاء للوعد p، نستخدم الدالة Lwt.bind، التي تربط دالة الاستدعاء بالوعد:

Lwt.bind p print_the_string

في وقت ما بعد إيفاء p، أي بعد أن يحتوي سلسلة، ستُنفَّذ دالة الاستدعاء وتلك السلسلة مدخلًا لها. وسيؤدي ذلك إلى طباعة السلسلة.

وإليك نص جلسة utop كامل كمثال على ذلك:

# let print_the_string str = Lwt_io.printf "The string is: %S\n" str;;
val print_the_string : string -> unit Lwt.t = <fun>
# let p = read_line stdin in Lwt.bind p print_the_string;;
- : unit Lwt.t = <abstr>
  <type Camels are bae followed by Enter>
# The string is: "Camels are bae"

7.5.2. الربط

من المهم فهم نوع Lwt.bind:

'a Lwt.t -> ('a -> 'b Lwt.t) -> 'b Lwt.t

تأخذ الدالة bind وعدًا كوسيطها الأول. ولا يهم إن كان ذلك الوعد قد حُلّ بعد أم لا. وتأخذ bind كوسيطها الثاني دالة استدعاء. وتذكّر أن دالة الاستدعاء دالة: وفعلًا، تأخذ دالة الاستدعاء هذه مدخلًا نوعه 'a نفسه، وهو نوع محتوى الوعد. وليس من قبيل المصادفة أن يكون النوعان متماثلين: فالفكرة كلها هي تنفيذ دالة الاستدعاء في النهاية على الوعد المُوفى، لذا يجب أن يكون النوع الذي يحتويه الوعد هو نفسه النوع الذي تتوقعه دالة الاستدعاء كمدخل.

بعد استدعائها على وعد ودالة استدعاء، مثل bind p c، تفعل الدالة bind واحدًا من ثلاثة أشياء حسب حالة p:

  • إذا كان p مُوفى بالفعل، فتُنفَّذ c فورًا على محتوى p. وقد يكون الوعد المُعاد معلّقًا أو لا، حسب ما تفعله c.
  • إذا كان p مرفوضًا بالفعل، فلا تُنفَّذ c. ويكون الوعد المُعاد مرفوضًا أيضًا، بالاستثناء نفسه الذي في p.
  • إذا كان p معلّقًا، فلا تنتظر bind حل p، ولا تنفيذ c. بل تسجّل bind دالة الاستدعاء لتُنفَّذ في النهاية عندما يُوفى الوعد (أو إن أُوفي). لذا تعيد الدالة bind وعدًا جديدًا. وقد يصير ذلك الوعد محلولًا عندما تكتمل دالة الاستدعاء (أو إن اكتملت)، في وقت ما في المستقبل. وسيكون محتواه هو المحتوى الموجود في الوعد الذي تعيده دالة الاستدعاء نفسها.

ملاحظة

في الحالة الأولى أعلاه: يدّعي مصدر Lwt أن هذا السلوك قد يتغير: فتحت الحمل الثقيل، قد تُسجَّل c لتنفيذ لاحق. لكن حتى الإصدار 5.5.0 لم يُفعَّل ذلك السلوك بعد. فلا تقلق بشأنه—هذه الفقرة موجودة فقط لتحصين هذا النقاش تجاه المستقبل.

ولنتأمل تلك الحالة الأخيرة بمزيد من التفصيل. لدينا وعد واحد من النوع 'a Lwt.t ووعدان من النوع 'b Lwt.t:

  • الوعد من النوع 'a Lwt.t، ولنسمّه الوعد X، مدخل إلى bind. وكان معلّقًا عند استدعاء bind، وعند عودة bind.
  • الوعد الأول من النوع 'b Lwt.t، ولنسمّه الوعد Y، تنشئه bind وتعيده فورًا للمستخدم. ويكون معلّقًا في تلك اللحظة.
  • الوعد الثاني من النوع 'b Lwt.t، ولنسمّه الوعد Z، لم يُنشأ بعد. وقد يُنشأ لاحقًا: إذا أُوفي الوعد X، فستُنفَّذ دالة الاستدعاء على محتوى X، وستعيد دالة الاستدعاء الوعد Z. ولا ضمان بشأن حالة Z؛ فقد يكون معلّقًا أيضًا عندما تعيده دالة الاستدعاء.
  • إذا أُوفي Z أخيرًا، يُحدَّث محتوى Y ليكون مماثلًا لمحتوى Z. وإذا رُفض Z، يُرفض Y أيضًا بالاستثناء نفسه. وإذا بقي Z معلّقًا، بقي Y معلّقًا أيضًا. وتذكّر أن Y أُعيد إلى المستخدم بالفعل. و«تمرير» محتوى Z إلى Y يضمن أن يستفيد المستخدم في النهاية من سلسلة العمليات التي بدأت بالوعد X.

وسبب تصميم bind بهذا النوع هو أن يمهّد المبرمجون سلسلة تسلسلية من دوال الاستدعاء. فمثلًا، تقرأ الشيفرة التالية سلسلة واحدة بصورة غير متزامنة؛ ثم عندما تُقرأ تلك السلسلة، تنتقل إلى قراءة سلسلة ثانية بصورة غير متزامنة؛ ثم تطبع سلسلتي النص متصلتين:

Lwt.bind (read_line stdin) (fun s1 ->
  Lwt.bind (read_line stdin) (fun s2 ->
    Lwt_io.printf "%s\n" (s1^s2)));;

إذا نفّذت ذلك في utop، سيحدث شيء مربك قليلًا مرة أخرى: فبعد أن تضغط Enter في نهاية السلسلة الأولى، ستسمح Lwt لـ utop بقراءة محرف واحد. والمشكلة أننا نخلط بين عمليات إدخال Lwt وعمليات إدخال utop. وكان الأفضل إنشاء برنامج وتشغيله من سطر الأوامر.

لفعل ذلك، ضع الشيفرة التالية في ملف باسم read2.ml:

open Lwt_io
let p =
  Lwt.bind (read_line stdin) (fun s1 ->
    Lwt.bind (read_line stdin) (fun s2 ->
      Lwt_io.printf "%s\n" (s1^s2)))
let _ = Lwt_main.run p

أضفنا دالة جديدة واحدة: Lwt_main.run : 'a Lwt.t -> 'a. وهي تنتظر إيفاء وعد مدخلها، ثم تعيد المحتوى. وتُستدعى هذه الدالة مرة واحدة فقط في البرنامج كله، قرب نهاية الملف الرئيسي؛ ومدخلها عادةً وعد يدل حله على انتهاء كل التنفيذ.

أنشئ ملف dune:

(executable
 (name read2)
 (libraries lwt.unix))

ونفّذ البرنامج، وأدخل سلسلتين:

dune exec ./read2.exe
My first string
My second string
My first stringMy second string

جرّب الآن إزالة السطر الأخير من read2.ml. وسترى أن البرنامج يخرج فورًا، دون انتظار أن تكتب.

7.5.3. الربط كعامل

تُعرّف وحدة Lwt.Infix عاملًا بينيًا يُكتب >>= وهو نفسه bind. أي أنك بدلًا من كتابة bind p c تكتب p >>= c. ويجعل هذا العامل كتابة الشيفرة أسهل بكثير دون كل الأقواس والمسافات البادئة الإضافية التي كان عليها مثالنا السابق:

open Lwt_io
open Lwt.Infix
let p =
  read_line stdin >>= fun s1 ->
  read_line stdin >>= fun s2 ->
  Lwt_io.printf "%s\n" (s1^s2)
let _ = Lwt_main.run p

وطريقة تحليل تعريف p بصريًا هي النظر إلى كل سطر على أنه يحسب قيمة موعودة ما. فالسطر الأول، read_line stdin >>= fun s1 -> يعني إنشاء وعد، وإيفاءه، واستخراج محتواه تحت الاسم s1. ويعني السطر الثاني الشيء نفسه، إلا أن محتواه يُسمى s2. وينشئ السطر الثالث وعدًا نهائيًا يُستخرج محتواه في النهاية بواسطة Lwt_main.run، وعندها قد ينتهي البرنامج.

وربما اشتهر العامل >>= من لغة Haskell الوظيفية، التي تستخدمه استخدامًا واسعًا مع المونادات. وسنغطي المونادات في قسم لاحق.

7.5.4. الربط كصياغة let

توجد امتداد صياغة (syntax extension) لـ OCaml يجعل استخدام bind أبسط حتى من العامل البيني >>=. ولتثبيت امتداد الصياغة، نفّذ الأمر التالي:

$ opam install lwt_ppx

وبذلك الامتداد، يمكنك استخدام تعبير let متخصص يُكتب let%lwt x = e1 in e2، وهو مكافئ لـ bind e1 (fun x -> e2) أو e1 >>= fun x -> e2. ويمكننا إعادة كتابة مثالنا الجاري كما يلي:

open Lwt_io
let p =
  let%lwt s1 = read_line stdin in
  let%lwt s2 = read_line stdin in
  Lwt_io.printf "%s\n" (s1^s2)
let _ = Lwt_main.run p

ملاحظة

لتصرّف تلك الشيفرة كجزء من مشروع Dune، استخدم ملف dune كهذا:

(executable
 (public_name ...)
 (name ...)
 (libraries lwt.unix ...)
 (preprocess (pps lwt_ppx)))

تحمّل فقرة libraries الوحدة lwt.unix حتى نتمكن من استخدام Lwt_io، وتتيح فقرة preprocess استخدام let%lwt عبر lwt_ppx.

الآن تبدو الشيفرة شبيهة تمامًا تقريبًا بنسختها المتزامنة المكافئة. لكن لا تنخدع: فكل الإدخال/الإخراج غير المتزامن، والوعود، ودوال الاستدعاء ما زالت هناك. وهكذا، يسجّل تقييم p أولًا دالة استدعاء مع وعد، ثم ينتقل إلى تقييم Lwt_main.run دون انتظار انتهاء قراءة السلسلة الأولى. ولتثبت ذلك لنفسك، نفّذ الشيفرة التالية:

open Lwt_io
let p =
  let%lwt s1 = read_line stdin in
  let%lwt s2 = read_line stdin in
  Lwt_io.printf "%s\n" (s1 ^ s2)
let _ = Lwt_io.printf "Got here first\n"
let _ = Lwt_main.run p

وسترى أن «Got here first» تُطبع قبل أن تسنح لك فرصة إدخال أي مدخل.

صياغة let أخرى. بدلًا من تحميل المكتبة الإضافية lwt_ppx لإتاحة let%lwt، يمكننا استخدام صياغة let* المشابهة التي توفرها Lwt.Syntax:

open Lwt_io
open Lwt.Syntax
let p =
  let* s1 = read_line stdin in
  let* s2 = read_line stdin in
  Lwt_io.printf "%s\n" (s1 ^ s2)

لكننا نفضّل عمومًا استخدام lwt_ppx، لأنه يتيح أيضًا بعض الصياغات المفيدة الأخرى. فمثلًا، يتيح lwt_ppx أيضًا try%lwt، وهو مفيد لمعالجة الاستثناءات التي تتضمن وعودًا.

7.5.5. التركيب المتزامن

توفر الدالة Lwt.bind طريقة لتركيب دوال الاستدعاء تسلسليًا: تُنفَّذ دالة استدعاء أولًا، ثم أخرى، ثم أخرى، وهكذا. وتوجد دوال أخرى في المكتبة لتركيب دوال استدعاء كثيرة كمجموعة. فمثلًا،

  • Lwt.map : 'a Lwt.t -> ('a -> 'b) -> 'b Lwt.t تشبه كثيرًا Lwt.bind، لكن دالة استدعائها تعيد فورًا قيمة من النوع 'b، لا وعدًا بقيمة من النوع 'b. وتعيد Lwt.map p f وعدًا يكون معلّقًا حتى يُحل p. وإذا حُل p بالرفض، فلا تُستدعى دالة الاستدعاء أبدًا ويُرفض الوعد المعلّق بالاستثناء نفسه. وإذا حُل p بالإيفاء (بقيمة v مثلًا)، فيُحل الوعد المعلّق بـ f v. لاحظ أن f قد ترفع استثناءً بنفسها، وفي هذه الحالة يُرفض الوعد المعلّق بذلك الاستثناء.
  • Lwt.join : unit Lwt.t list -> unit Lwt.t تتيح الانتظار على وعود متعددة. وتعيد Lwt.join ps وعدًا يكون معلّقًا حتى تصير جميع الوعود في ps محلولة. وقد تسجّل دالة استدعاء على الوعد المُعاد من join للعناية بحساب ما يحتاج إلى اكتمال جميع وعود مجموعة معينة.
  • Lwt.pick : 'a Lwt.t list -> 'a Lwt.t تتيح أيضًا الانتظار على وعود متعددة، لكن Lwt.pick ps تعيد وعدًا يكون معلّقًا حتى يصير وعد واحد على الأقل في ps محلولًا. وقد تسجّل دالة استدعاء على الوعد المُعاد من pick للعناية بحساب ما يحتاج إلى اكتمال وعد واحد فقط من مجموعة، دون أن يهم أيها.

7.6. تنفيذ دوال الاستدعاء

عندما تُسجَّل دالة استدعاء مع وعد باستخدام bind أو إحدى الصياغات الأخرى، تُضاف إلى قائمة دوال استدعاء مخزنة مع الوعد. وفي النهاية، إذا أُوفي الوعد، تُنفّذ حلقة الحل (resolution loop) في Lwt جميع دوال الاستدعاء المسجلة مع الوعد. ولا ضمان بشأن ترتيب تنفيذ دوال الاستدعاء لوعد ما. وبعبارة أخرى، ترتيب التنفيذ غير حتمي. وإذا كان الترتيب مهمًا، فعلى المبرمج أن يستخدم عوامل التركيب (مثل bind وjoin) لفرض ترتيب ما. وإذا لم يُوفَّ الوعد أبدًا (أو رُفض)، فلن تُنفَّذ أي من دوال استدعائه أبدًا.

ملاحظة

تدعم Lwt أيضًا تسجيل دوال تُنفَّذ بعد رفض وعد. ويُستخدم Lwt.catch وtry%lwt لهذا الغرض. وهما نظيرا Lwt.bind وlet%lwt.

ومرة أخرى، من المهم تتبع مصدر التزامن الحقيقي: نظام التشغيل. فقد تحدث عمليات إدخال/إخراج غير متزامنة كثيرة على مستوى نظام التشغيل. لكن على مستوى OCaml، حلقة الحل تسلسلية، بمعنى أنه لا يمكن أن تُنفَّذ إلا دالة استدعاء واحدة في أي لحظة.

وأخيرًا، لا تحاول حلقة الحل أبدًا مقاطعة دالة استدعاء. فإذا دخلت دالة استدعاء في حلقة لا نهائية، فلن تُنفَّذ أي دالة استدعاء أخرى أبدًا. وهذا يجعل Lwt آلية تزامن تعاونية، لا استباقية.

ولفهم حل دوال الاستدعاء فهمًا أفضل، لننفّذه بأنفسنا. سنستخدم بنية البيانات Promise التي طورناها سابقًا. وللبدء، نضيف عامل ربط إلى توقيع Promise.

module type PROMISE = sig
  ...
  (** [bind p c] registers callback [c] with promise [p].
      When the promise is fulfilled, the callback will be run
      on the promises's contents.  If the promise is never
      fulfilled, the callback will never run. *)
  val bind : 'a promise -> ('a -> 'b promise) -> 'b promise
end

بعد ذلك، لنعد تطوير بنية Promise كلها. نبدأ تمامًا كما سبق:

module Promise : PROMISE = struct
  type 'a state = Pending | Fulfilled of 'a | Rejected of exn
  ...

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

  (** RI: the input may not be [Pending]. *)
  type 'a handler = 'a state -> unit
  (** RI: if [state <> Pending] then [handlers = []]. *)
  type 'a promise = {
    mutable state : 'a state;
    mutable handlers : 'a handler list
  }

الـ مُعالِج (handler) تجريد جديد: دالة تأخذ حالة. والاستخدام الأساسي للمُعالِج هو تنفيذ دوال الاستدعاء. وسيُستخدم لإيفاء الوعود ورفضها عندما تصير حالتها جاهزة للانتقال بعيدًا عن التعليق. ولهذا نطلب، عبر ثابت تمثيل، ألا تكون حالة المدخل إلى المُعالِج معلّقة.

نشترط ألا يكون للوعود المعلّقة إلا مُعالِجات منتظرة في قائمتها. وبمجرد أن تصير الحالة غير معلّقة، أي مُوفاة أو مرفوضة، ستُعالَج جميع المُعالِجات المرتبطة بالوعد وتُزال من القائمة. ولهذا نقول، كثابت تمثيل، إنه إذا لم تكن الحالة معلّقة، فيجب أن تكون قائمة المُعالِجات فارغة.

وستكون هذه الدالة المساعدة التي تُدرج مُعالِجًا في قائمة مُعالِجات وعد مفيدة لاحقًا:

  let enqueue
      (handler : 'a state -> unit)
      (promise : 'a promise) : unit
    =
    promise.handlers <- handler :: promise.handlers

ونواصل تسمية المُحِلّات والوعود بالأسماء نفسها داخليًا:

  type 'a resolver = 'a promise

ولأننا غيّرنا نوع التمثيل من ref إلى سجل، علينا تحديث بعض الدوال تحديثات تافهة:

  (** [write_once p s] changes the state of [p] to be [s].  If [p] and [s]
      are both pending, that has no effect.
      Raises: [Invalid_arg] if the state of [p] is not pending. *)
  let write_once p s =
    if p.state = Pending
    then p.state <- s
    else invalid_arg "cannot write twice"
  let make () =
    let p = {state = Pending; handlers = []} in
    (p, p)
  let return x =
    {state = Fulfilled x; handlers = []}
  let state p = p.state

والآن نصل إلى الأجزاء الأعقد من التنفيذ.

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

  (** Requires: [st] may not be [Pending]. *)
  let resolve (r : 'a resolver) (st : 'a state) =
    assert (st <> Pending);
    let handlers = r.handlers in
    r.handlers <- [];
    write_once r st;
    List.iter (fun f -> f st) handlers
  let reject r e =
    resolve r (Rejected e)
  let fulfill r v =
    resolve r (Fulfilled v)

وأخيرًا، تنفيذ bind هو الجزء الأعقد. تذكّر أن هذه الدالة تحتاج إلى إعادة وعد جديد فورًا. أولًا، تأمل حالة إيفاء وعد المدخل بقيمة ما x بالفعل. علينا أن ننفّذ فورًا دالة الاستدعاء على x: فلا يمكننا إدراج مُعالِج في وعد المدخل، لأن ثابت التمثيل ينص على أن الوعد غير المعلّق (كالوعد المدخل لدينا) يجب أن تكون قائمة مُعالِجاته فارغة. وتنفيذ دالة الاستدعاء سيعطي وعدًا جديدًا نعيده فورًا:

  let bind
      (input_promise : 'a promise)
      (callback : 'a -> 'b promise) : 'b promise
    =
    match input_promise.state with
    | Fulfilled x -> callback x

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

  let fail exc = {state = Rejected exc; handlers = []}
  let bind
      (input_promise : 'a promise)
      (callback : 'a -> 'b promise) : 'b promise
    =
    match input_promise.state with
    | Fulfilled x -> (try callback x with exc -> fail exc)

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

    | Rejected exc -> fail exc

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

إذن، ننشئ وعدًا ومُحِلًّا جديدين نسميهما output_promise وoutput_resolver. ذلك الوعد هو ما تعيده bind. وقبل إعادته، نستخدم دالة مساعدة handler_of_callback (الموصوفة أدناه) لتحويل دالة الاستدعاء إلى مُعالِج، ونُدرج ذلك المُعالِج في الوعد. وهذا يضمن تنفيذ المُعالِج عندما يصير الوعد محلولًا لاحقًا:

    | Pending ->
      let output_promise, output_resolver = make () in
      enqueue (handler_of_callback callback output_resolver) input_promise;
      output_promise

كل ما تبقى هو تنفيذ تلك الدالة المساعدة لإنشاء المُعالِجات من دوال الاستدعاء. تذكّر أن نوع المُعالِج هو نفسه نوع دالة، 'a state -> unit. ولهذا فإن مخرج دالتنا المساعدة هو في الواقع دالة مجهولة. وتأخذ تلك الدالة المجهولة حالة كمدخل لها:

  let handler_of_callback
      (callback : 'a -> 'b promise)
      (resolver : 'b resolver) : 'a handler =
      fun (state : 'a state) ->

نمضي بأخذ الحالات على حالة المدخل تلك. الحالتان الأوليان أدناه بسيطتان. فاستدعاء مُعالِج على حالة معلّقة سيخرق ثابت التمثيل. وإذا كانت الحالة مرفوضة، فينبغي للمُعالِج أن يمرر ذلك الرفض إلى المُحِلّ، وهو ما يجعل الوعد المُعاد من bind مرفوضًا أيضًا.

  let handler_of_callback
      (callback : 'a -> 'b promise)
      (resolver : 'b resolver) : 'a handler =
      fun (state : 'a state) ->
      match state with
      | Pending -> failwith "handler RI violated"
      | Rejected exc -> reject resolver exc

لكن إذا كانت الحالة مُوفاة، فيمكن—أخيرًا!—تنفيذ دالة الاستدعاء المسجلة مع الوعد على محتوى الوعد المُوفى. وإذا نُفِّذت دالة الاستدعاء بنجاح، فهي تنتج وعدًا جديدًا، لكن تذكّر أن دالة الاستدعاء قد ترفع استثناءً بنفسها.

أولًا، تأمل الحالة المتفائلة التي تُنفَّذ فيها دالة الاستدعاء بنجاح وتنتج وعدًا. وقد يكون ذلك الوعد مرفوضًا أو مُوفى بالفعل، وفي هذه الحالة تُمرَّر تلك الحالة مرة أخرى.

      | Fulfilled x ->
        let promise = callback x in
        match promise.state with
        | Fulfilled y -> resolve resolver y
        | Rejected exc -> reject resolver exc

لكن الوعد قد يبقى معلّقًا. وفي هذه الحالة، نحتاج إلى إدراج مُعالِج جديد غرضه إجراء التمرير بمجرد توفر النتيجة:

        | Pending -> enqueue (copying_handler resolver) promise

حيث copying_handler دالة مساعدة جديدة تنشئ مُعالِجًا بسيطًا جدًا لإجراء ذلك التمرير:

  let copying_handler (resolver : 'a resolver) : 'a handler
    = function
      | Pending -> failwith "handler RI violated"
      | Rejected exc -> reject resolver exc
      | Fulfilled x -> resolve resolver x

ثانيًا، تأمل الحالة التي ترفع فيها دالة الاستدعاء نفسها استثناءً ما exc. وفي هذه الحالة، نحتاج إلى رفض الوعد بذلك الاستثناء. ونفعل ذلك بتغليف تنفيذ دالة الاستدعاء في كتلة try:

      | Fulfilled x ->
        try
          let promise = callback x in
          match promise.state with
          | Fulfilled y -> resolve resolver y
          | Rejected exc -> reject resolver exc
          | Pending -> enqueue (copying_handler resolver) promise
        with exc -> reject resolver exc

يتبع تنفيذ Lwt للدالة bind الخوارزمية نفسها أساسًا التي نفّذناها للتو. لاحظ أنه لا يوجد تزامن في bind: فكما قلنا أعلاه، نظام التشغيل هو ما يوفر التزامن.

تحذير

تتخذ Lwt خيارًا مختلفًا عن خيارنا هنا عندما يكون وعد المدخل مُوفى بالفعل وترفع دالة الاستدعاء استثناءً. في هذه الحالة، تجعل Lwt الدالة bind ترفع الاستثناء بنفسها، بدلًا من إعادة وعد مرفوض يحتوي على الاستثناء. ومن المعروف أن ذلك يخالف مواصفة bind لكنه لن يُصلَّح لأسباب تتعلق بالتوافق مع الإصدارات السابقة.

7.7. التنفيذ الكامل للوعود

وللراحة، إليك تنفيذ الوعود كاملًا في كتلة واحدة قابلة للتنفيذ:

(** A signature for Lwt-style promises, with better names. *)
module type PROMISE = sig
  type 'a state =
    | Pending
    | Fulfilled of 'a
    | Rejected of exn
  type 'a promise
  type 'a resolver
  (** [make ()] is a new promise and resolver. The promise is pending. *)
  val make : unit -> 'a promise * 'a resolver
  (** [return x] is a new promise that is already fulfilled with value
      [x]. *)
  val return : 'a -> 'a promise
  (** [state p] is the state of the promise. *)
  val state : 'a promise -> 'a state
  (** [fulfill r x] resolves the promise [p] associated with [r] with
      value [x], meaning that [state p] will become [Fulfilled x].
      Requires: [p] is pending. *)
  val fulfill : 'a resolver -> 'a -> unit
  (** [reject r x] rejects the promise [p] associated with [r] with
      exception [x], meaning that [state p] will become [Rejected x].
      Requires: [p] is pending. *)
  val reject : 'a resolver -> exn -> unit
  (** [bind p c] registers callback [c] with promise [p].
      When the promise is fulfilled, the callback will be run
      on the promises's contents.  If the promise is never
      fulfilled, the callback will never run. *)
  val bind : 'a promise -> ('a -> 'b promise) -> 'b promise
end
module Promise : PROMISE = struct
  type 'a state = Pending | Fulfilled of 'a | Rejected of exn
  (** RI: the input may not be [Pending]. *)
  type 'a handler = 'a state -> unit
  (** RI: if [state <> Pending] then [handlers = []]. *)
  type 'a promise = {
    mutable state : 'a state;
    mutable handlers : 'a handler list
  }
  let enqueue
      (handler : 'a state -> unit)
      (promise : 'a promise) : unit
    =
    promise.handlers <- handler :: promise.handlers
  type 'a resolver = 'a promise
  (** [write_once p s] changes the state of [p] to be [s].  If [p] and [s]
      are both pending, that has no effect.
      Raises: [Invalid_arg] if the state of [p] is not pending. *)
  let write_once p s =
    if p.state = Pending
    then p.state <- s
    else invalid_arg "cannot write twice"
  let make () =
    let p = {state = Pending; handlers = []} in
    p, p
  let return x =
    {state = Fulfilled x; handlers = []}
  let state p = p.state
  (** Requires: [st] may not be [Pending]. *)
  let resolve (r : 'a resolver) (st : 'a state) =
    assert (st <> Pending);
    let handlers = r.handlers in
    r.handlers <- [];
    write_once r st;
    List.iter (fun f -> f st) handlers
  let reject r x =
    resolve r (Rejected x)
  let fulfill r x =
    resolve r (Fulfilled x)
  let copying_handler (resolver : 'a resolver) : 'a handler
    = function
      | Pending -> failwith "handler RI violated"
      | Rejected exc -> reject resolver exc
      | Fulfilled x -> fulfill resolver x
  let handler_of_callback
      (callback : 'a -> 'b promise)
      (resolver : 'b resolver) : 'a handler =
      fun (state : 'a state) ->
      match state with
        | Pending -> failwith "handler RI violated"
        | Rejected exc -> reject resolver exc
        | Fulfilled x ->
          try
            let promise = callback x in
            match promise.state with
            | Fulfilled y -> fulfill resolver y
            | Rejected exc -> reject resolver exc
            | Pending -> enqueue (copying_handler resolver) promise
          with exc -> reject resolver exc
  let bind
      (input_promise : 'a promise)
      (callback : 'a -> 'b promise) : 'b promise
    =
    let fail exc = {state = Rejected exc; handlers = []} in
    match input_promise.state with
    | Fulfilled x -> (try callback x with exc -> fail exc)
    | Rejected exc -> fail exc
    | Pending ->
      let output_promise, output_resolver = make () in
      enqueue (handler_of_callback callback output_resolver) input_promise;
      output_promise
end
module type PROMISE =
  sig
    type 'a state = Pending | Fulfilled of 'a | Rejected of exn
    type 'a promise
    type 'a resolver
    val make : unit -> 'a promise * 'a resolver
    val return : 'a -> 'a promise
    val state : 'a promise -> 'a state
    val fulfill : 'a resolver -> 'a -> unit
    val reject : 'a resolver -> exn -> unit
    val bind : 'a promise -> ('a -> 'b promise) -> 'b promise
  end
module Promise : PROMISE

7.8. المونادات

الموناد (monad) أقرب إلى نمط تصميم منه إلى بنية بيانات. أي أن هناك بنى بيانات كثيرة، إذا نظرت إليها نظرة صحيحة، يتبين أنها مونادات.

يأتي اسم «موناد» من مجال نظرية الفئات (category theory) الرياضي، الذي يدرس تجريدات البنى الرياضية. وإذا درست يومًا مقررًا لدرجة الدكتوراه في نظرية لغات البرمجة، فمن المرجح أن تصادف تلك الفكرة بمزيد من التفصيل. لكننا هنا سنغفل معظم النظرية الرياضية ونركز على الشيفرة.

ذاعت شهرة المونادات في عالم البرمجة عبر استخدامها في Haskell، وهي لغة برمجة وظيفية أكثر نقاءً من OCaml—أي أن Haskell تتجنب التأثيرات الجانبية والميزات الأمرية أكثر من OCaml. لكن لا يمكن لأي لغة عملية أن تستغني عن التأثيرات الجانبية. فالطباعة على الشاشة بعد كل شيء تأثير جانبي. لذا عمدت Haskell إلى التحكم في استخدام التأثيرات الجانبية عبر نمط تصميم الموناد. ومنذ ذلك الحين، أُقرّ بأن المونادات مفيدة في لغات برمجة وظيفية أخرى، بل بدأت تظهر حتى في اللغات الأمرية.

تُستخدم المونادات لنمذجة الحسابات (computations). فكّر في الحساب كأنه دالة تربط مدخلًا بمخرج، لكنها تفعل أيضًا «شيئًا إضافيًا». والشيء الإضافي تأثير يحدث للدالة نتيجة حسابها. فمثلًا، قد يتضمن التأثير الطباعة على الشاشة. وتوفر المونادات تجريدًا للتأثيرات، وتساعد على ضمان حدوث التأثيرات بترتيب منضبط.

7.8.1. توقيع الموناد

لأغراضنا، الموناد بنية تحقق خاصيتين. أولًا، يجب أن تطابق التوقيع التالي:

module type Monad = sig
  type 'a t
  val return : 'a -> 'a t
  val bind : 'a t -> ('a -> 'b t) -> 'b t
end
module type Monad =
  sig
    type 'a t
    val return : 'a -> 'a t
    val bind : 'a t -> ('a -> 'b t) -> 'b t
  end

ثانيًا، يجب أن يخضع الموناد لما يُسمى قوانين الموناد (monad laws). وسنعود إليها لاحقًا كثيرًا، بعد أن ندرس العمليتين return وbind.

فكّر في الموناد كأنه صندوق يحتوي قيمة ما. نوع القيمة 'a، ونوع الصندوق الذي يحتويها 'a t. وقد استخدمنا سابقًا استعارة صندوق مشابهة مع كل من الأنواع الاختيارية والوعود. ولم يكن ذلك مصادفة: فالأنواع الاختيارية والوعود كلاهما مثالان على المونادات، كما سنرى بالتفصيل أدناه.

Return. تضع عملية return مجازيًا قيمة داخل صندوق. ويمكنك رؤية ذلك في نوعها: المدخل من النوع 'a، والمخرج من النوع 'a t.

وبالنسبة إلى الحسابات، يُقصد بـ return أن يكون لها تأثير تافه من نوع ما. فمثلًا، إذا كان الموناد يمثل حسابات تأثيرها الجانبي الطباعة على الشاشة، فالتأثير التافه هو ألا تطبع شيئًا.

Bind. تأخذ عملية bind مجازيًا كمدخل:

  • قيمة موضوعة في صندوق، نوعها 'a t، و
  • دالة تأخذ هي نفسها قيمة غير معبوّأة من النوع 'a كمدخل وتعيد قيمة معبوّأة من النوع 'b t كمخرج.

تُطبّق bind وسيطها الثاني على الأول. ويقتضي ذلك إخراج القيمة 'a من صندوقها، وتطبيق الدالة عليها، وإعادة النتيجة.

وبالنسبة إلى الحسابات، يُقصد بـ bind تتابع التأثيرات واحدًا بعد آخر. وبمواصلة مثال الطباعة الجاري، يعني التتابع الطباعة أولًا لسلسلة، ثم أخرى، وتتولى bind ضمان حدوث الطباعة بالترتيب الصحيح.

والترميز المعتاد لـ bind هو عامل بَيْنِيّ يُكتب >>= ويُلفظ «bind» كذلك. فلنُنقّح إذن توقيعنا للمونادات:

module type Monad = sig
  type 'a t
  val return : 'a -> 'a t
  val ( >>= ) : 'a t -> ('a -> 'b t) -> 'b t
end
module type Monad =
  sig
    type 'a t
    val return : 'a -> 'a t
    val ( >>= ) : 'a t -> ('a -> 'b t) -> 'b t
  end

كل ما سبق مرجّح أن يبدو مجرّدًا جدًا عند القراءة الأولى. وسيساعد رؤية أمثلة ملموسة للمونادات. وبمجرد أن تفهم عدة عمليات >>= وreturn، سيصير نمط التصميم نفسه أوضح.

لذا تتناول الأقسام القليلة التالية عدة أمثلة مختلفة على شيفرة يمكن اكتشاف المونادات فيها. ولأن المونادات نمط تصميم، فليست دائمًا جلية؛ وقد يقتضي الأمر بعض الدراسة لاستخلاص مواضع استخدام عمليات الموناد.

7.8.2. موناد Maybe

كما رأينا سابقًا، تكون الدوال أحيانًا جزئية: فلا يوجد مخرج جيد يمكن أن تنتجه لبعض المدخلات. فمثلًا، الدالة max_list : int list -> int ليس لديها بالضرورة قيمة مخرج جيدة تعيدها للقائمة الفارغة. ومن الاحتمالات رفع استثناء. ومن الاحتمالات الأخرى تغيير نوع الإعادة إلى int option، واستخدام None لتمثيل عجز الدالة عن إنتاج مخرج. وبعبارة أخرى، ربما تنتج الدالة مخرجًا، أو ربما تعجز عن ذلك فتعيد None.

ومثال آخر: تأمل دالة قسمة الأعداد الصحيحة المدمجة في OCaml، ( / ) : int -> int -> int. فإذا كان وسيطها الثاني صفرًا، رفعت استثناءً. لكن من الاحتمالات الأخرى تغيير نوعها إلى ( / ) : int -> int -> int option، وإعادة None كلما كان المقسوم عليه صفرًا.

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

وإليك بعض الشيفرة التي تجسّد هذه الفكرة:

(* works fine *)
let x = 1 + (4 / 2)
val x : int = 3
let div (x:int) (y:int) : int option =
  if y = 0 then None else Some (x / y)
let ( / ) = div
(* won't type check *)
let x = 1 + (4 / 2)
val div : int -> int -> int option = <fun>
val ( / ) : int -> int -> int option = <fun>
File "[4]", line 7, characters 12-19:
7 | let x = 1 + (4 / 2)
                ^^^^^^^
Error: This expression has type int option
       but an expression was expected of type int

المشكلة أننا لا نستطيع جمع int مع int option: فعامل الجمع يتوقع أن يكون مدخله الثاني من النوع int، لكن عامل القسمة الجديد يعيد قيمة من النوع int option.

ومن الاحتمالات إعادة كتابة جميع العوامل الموجودة لتقبل int option كمدخل. فمثلًا،

let plus_opt (x:int option) (y:int option) : int option =
  match x,y with
  | None, _ | _, None -> None
  | Some a, Some b -> Some (Stdlib.( + ) a b)
let ( + ) = plus_opt
let minus_opt (x:int option) (y:int option) : int option =
  match x,y with
  | None, _ | _, None -> None
  | Some a, Some b -> Some (Stdlib.( - ) a b)
let ( - ) = minus_opt
let mult_opt (x:int option) (y:int option) : int option =
  match x,y with
  | None, _ | _, None -> None
  | Some a, Some b -> Some (Stdlib.( * ) a b)
let ( * ) = mult_opt
let div_opt (x:int option) (y:int option) : int option =
  match x,y with
  | None, _ | _, None -> None
  | Some a, Some b ->
    if b=0 then None else Some (Stdlib.( / ) a b)
let ( / ) = div_opt
val plus_opt : int option -> int option -> int option = <fun>
val ( + ) : int option -> int option -> int option = <fun>
val minus_opt : int option -> int option -> int option = <fun>
val ( - ) : int option -> int option -> int option = <fun>
val mult_opt : int option -> int option -> int option = <fun>
val ( * ) : int option -> int option -> int option = <fun>
val div_opt : int option -> int option -> int option = <fun>
val ( / ) : int option -> int option -> int option = <fun>
(* does type check *)
let x = Some 1 + (Some 4 / Some 2)
val x : int option = Some 3

لكن ذلك قدر هائل من تكرار الشيفرة. وينبغي أن نطبّق مبدأ التجريد ونزيل التكرار. ويمكن التعامل مع ثلاثة من العوامل الأربعة بتجريد دالة تجري بعض مطابقة الأنماط فحسب لتمرير None:

let propagate_none (op : int -> int -> int) (x : int option) (y : int option) =
  match x, y with
  | None, _ | _, None -> None
  | Some a, Some b -> Some (op a b)
let ( + ) = propagate_none Stdlib.( + )
let ( - ) = propagate_none Stdlib.( - )
let ( * ) = propagate_none Stdlib.( * )
val propagate_none :
  (int -> int -> int) -> int option -> int option -> int option = <fun>
val ( + ) : int option -> int option -> int option = <fun>
val ( - ) : int option -> int option -> int option = <fun>
val ( * ) : int option -> int option -> int option = <fun>

لكن القسمة للأسف أصعب في إزالة تكرارها. فلا يمكننا مجرد تمرير Stdlib.( / ) إلى propagate_none، لأن أيا من هاتين الدالتين لن يتحقق مما إذا كان المقسوم عليه صفرًا. وسيكون جميلًا لو أمكننا تمرير دالتنا div : int -> int -> int option إلى propagate_none، لكن نوع إعادة div يجعل ذلك مستحيلًا.

لذا لنعد كتابة propagate_none لتقبل عاملًا من النوع نفسه الذي لـ div، وهو ما يسهّل تنفيذ القسمة:

let propagate_none
  (op : int -> int -> int option) (x : int option) (y : int option)
=
  match x, y with
  | None, _ | _, None -> None
  | Some a, Some b -> op a b
let ( / ) = propagate_none div
val propagate_none :
  (int -> int -> int option) -> int option -> int option -> int option =
  <fun>
val ( / ) : int option -> int option -> int option = <fun>

وتنفيذ العمليات الثلاث الأخرى يقتضي عملًا أكثر قليلًا، لأن نوع إعادتها int لا int option. علينا تغليف قيمة إعادتها بـ Some:

let wrap_output (op : int -> int -> int) (x : int) (y : int) : int option =
  Some (op x y)
let ( + ) = propagate_none (wrap_output Stdlib.( + ))
let ( - ) = propagate_none (wrap_output Stdlib.( - ))
let ( * ) = propagate_none (wrap_output Stdlib.( * ))
val wrap_output : (int -> int -> int) -> int -> int -> int option = <fun>
val ( + ) : int option -> int option -> int option = <fun>
val ( - ) : int option -> int option -> int option = <fun>
val ( * ) : int option -> int option -> int option = <fun>

وأخيرًا، يمكننا إعادة تنفيذ div باستخدام wrap_output:

let div (x : int) (y : int) : int option =
  if y = 0 then None else wrap_output Stdlib.( / ) x y
let ( / ) = propagate_none div
val div : int -> int -> int option = <fun>
val ( / ) : int option -> int option -> int option = <fun>

أين الموناد؟ ما فعلناه للتو هو أخذ دوال على الأعداد الصحيحة وتحويلها إلى دوال على قيم ربما تكون أعدادًا صحيحة وربما لا—أي قيم إما Some i حيث i عدد صحيح، وإما None. ويمكننا التفكير في هذه الدوال «المُرقّاة» كحسابات قد يكون لها تأثير إنتاج لا شيء. فهي تنتج صناديق مجازية، وقد تكون تلك الصناديق ممتلئة بشيء، أو لا تحتوي شيئًا.

كانت هناك فكرتان جوهريتان في الشيفرة التي كتبناها للتو، وهما تقابلان عمليتي الموناد return وbind.

الأولى (وهي تافهة كما يبدو) كانت ترقية قيمة من int إلى int option بتغليفها بـ Some. وهذا ما يفعله جسم wrap_output. ويمكننا إظهار تلك الفكرة بوضوح أكبر بتعريف الدالة التالية:

let return (x : int) : int option = Some x
val return : int -> int option = <fun>

لهذه الدالة التأثير التافه المتمثل في وضع قيمة داخل الصندوق المجازي.

والفكرة الثانية كانت استخلاص شيفرة للتعامل مع كل مطابقة الأنماط على None. فقد اضطررنا إلى ترقية دوال كانت مدخلاتها من النوع int لتقبل بدلًا من ذلك مدخلات من النوع int option. وإليك تلك الفكرة معبرًا عنها كدالة قائمة بذاتها:

let bind (x : int option) (op : int -> int option) : int option =
  match x with
  | None -> None
  | Some a -> op a
let ( >>= ) = bind
val bind : int option -> (int -> int option) -> int option = <fun>
val ( >>= ) : int option -> (int -> int option) -> int option = <fun>

يمكن فهم الدالة bind على أنها تقوم بالعمل الجوهري لترقية op من دالة تقبل int كمدخل إلى دالة تقبل int option كمدخل. بل يمكننا حتى كتابة دالة تقوم بذلك الترقية نيابة عنا باستخدام bind:

let upgrade : (int -> int option) -> (int option -> int option) =
  fun (op : int -> int option) (x : int option) -> (x >>= op)
val upgrade : (int -> int option) -> int option -> int option = <fun>

كل تلك التعليقات النوعية مقصودة لمساعدة القارئ على فهم الدالة. ويمكن بالطبع كتابتها بصورة أبسط بكثير كما يلي:

let upgrade op x = x >>= op
val upgrade : (int -> int option) -> int option -> int option = <fun>

وباستخدام الدالتين return و>>= فقط، يمكننا إعادة تنفيذ العمليات الحسابية من أعلاه:

let ( + ) (x : int option) (y : int option) : int option =
  x >>= fun a ->
  y >>= fun b ->
  return (Stdlib.( + ) a b)
let ( - ) (x : int option) (y : int option) : int option =
  x >>= fun a ->
  y >>= fun b ->
  return (Stdlib.( - ) a b)
let ( * ) (x : int option) (y : int option) : int option =
  x >>= fun a ->
  y >>= fun b ->
  return (Stdlib.( * ) a b)
let ( / ) (x : int option) (y : int option) : int option =
  x >>= fun a ->
  y >>= fun b ->
  if b = 0 then None else return (Stdlib.( / ) a b)
val ( + ) : int option -> int option -> int option = <fun>
val ( - ) : int option -> int option -> int option = <fun>
val ( * ) : int option -> int option -> int option = <fun>
val ( / ) : int option -> int option -> int option = <fun>

تذكّر من نقاشنا لعامل bind في Lwt أن الصياغة أعلاه ينبغي أن تُقرأ بعينك كما يلي

  • خذ x واستخرج منها القيمة a،
  • ثم خذ y واستخرج منها b،
  • ثم استخدم a وb لبناء قيمة معادة.

وبالطبع، ما زال هناك قدر لا بأس به من التكرار. ويمكننا إزالة التكرار باستخدام التقنيات نفسها التي استخدمناها سابقًا:

let upgrade_binary op x y =
  x >>= fun a ->
  y >>= fun b ->
  op a b
let return_binary op x y = return (op x y)
let ( + ) = upgrade_binary (return_binary Stdlib.( + ))
let ( - ) = upgrade_binary (return_binary Stdlib.( - ))
let ( * ) = upgrade_binary (return_binary Stdlib.( * ))
let ( / ) = upgrade_binary div
val upgrade_binary :
  (int -> int -> int option) -> int option -> int option -> int option =
  <fun>
val return_binary : ('a -> 'b -> int) -> 'a -> 'b -> int option = <fun>
val ( + ) : int option -> int option -> int option = <fun>
val ( - ) : int option -> int option -> int option = <fun>
val ( * ) : int option -> int option -> int option = <fun>
val ( / ) : int option -> int option -> int option = <fun>

موناد Maybe. الموناد الذي اكتشفناه للتو له عدة أسماء: موناد maybe (بمعنى «ربما توجد قيمة، وربما لا»)، وموناد الخطأ (error monad) (بمعنى «إما توجد قيمة وإما خطأ»، والخطأ ممثَّل بـ None—وإن كان بعض المؤلفين يريدون لموناد الخطأ أن يمثل أنواعًا متعددة من الأخطاء بدلًا من دمجها كلها في None)، وموناد option (وهو واضح).

وإليك تنفيذ توقيع الموناد لموناد maybe:

module Maybe : Monad = struct
  type 'a t = 'a option
  let return x = Some x
  let (>>=) m f =
    match m with
    | None -> None
    | Some x -> f x
end
module Maybe : Monad

هذان هما التنفيذان نفسهان لـ return و>>= اللذان اخترعناهما أعلاه، لكن دون التعليقات النوعية التي تفرض عملهما على الأعداد الصحيحة فقط. وفعلًا، لم نكن نحتاج تلك التعليقات قط؛ فقد ساعدت فقط في جعل الشيفرة أعلاه أوضح قليلًا.

وعمليًا، الدالة return هنا تافهة جدًا وليست ضرورية حقًا. لكن العامل >>= يمكن استخدامه لاستبدال كثير من مطابقة الأنماط المتكررة، كما رأينا في التنفيذ الأخير للعمليات الحسابية أعلاه. فليست هناك إلا مطابقة أنماط واحدة، وهي داخل >>=. قارن ذلك بالتنفيذات الأصلية لـ plus_opt وما شابهها، التي كانت فيها مطابقات أنماط كثيرة.

والنتيجة أننا نحصل على شيفرة (بمجرد أن تفهم كيفية قراءة عامل bind) أسهل قراءة وأسهل صيانة.

والآن بعد أن انتهينا من اللعب بعوامل الأعداد الصحيحة، ينبغي أن نعيد معناها الأصلي لبقية هذا الملف:

let ( + ) = Stdlib.( + )
let ( - ) = Stdlib.( - )
let ( * ) = Stdlib.( * )
let ( / ) = Stdlib.( / )
val ( + ) : int -> int -> int = <fun>
val ( - ) : int -> int -> int = <fun>
val ( * ) : int -> int -> int = <fun>
val ( / ) : int -> int -> int = <fun>

7.8.3. مثال: موناد الكاتب

عند محاولة تشخيص الأعطال في نظام ما، كثيرًا ما يكون سجل (log) للدوال التي استُدعيت، ومدخلاتها ومخرجاتها، مفيدًا.

تخيّل أن لدينا دالتين نريد تصحيحهما، وكلتاهما من النوع int -> int. فمثلًا:

let inc x = x + 1
let dec x = x - 1
val inc : int -> int = <fun>
val dec : int -> int = <fun>

(حسنًا، هاتان دالتان بسيطتان جدًا؛ وربما لا نحتاج إلى أي مساعدة في تصحيحهما. لكن تخيّل أنهما تحسبان شيئًا أعقد بكثير، مثل تشفير الأعداد الصحيحة أو فك تشفيرها.)

ومن طرق الاحتفاظ بسجل لاستدعاءات الدوال تعزيز كل دالة لتعيد زوجًا: قيمة العدد الصحيحة التي كانت الدالة ستعيدها عادةً، بالإضافة إلى سلسلة تحتوي رسالة سجل. فمثلًا:

let inc_log x = (x + 1, Printf.sprintf "Called inc on %i; " x)
let dec_log x = (x - 1, Printf.sprintf "Called dec on %i; " x)
val inc_log : int -> int * string = <fun>
val dec_log : int -> int * string = <fun>

لكن ذلك يغيّر نوع إعادة الدالتين معًا، وهو ما يجعل تركيب الدوال صعبًا. فقد كان بإمكاننا سابقًا كتابة شيفرة مثل

let id x = dec (inc x)
val id : int -> int = <fun>

أو حتى أفضل

let id x = x |> inc |> dec
val id : int -> int = <fun>

أو أفضل من ذلك أيضًا، باستخدام عامل التركيب (composition operator) >>،

let ( >> ) f g x = x |> f |> g
let id = inc >> dec
val ( >> ) : ('a -> 'b) -> ('b -> 'c) -> 'a -> 'c = <fun>
val id : int -> int = <fun>

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

let id = inc_log >> dec_log
File "[24]", line 1, characters 20-27:
1 | let id = inc_log >> dec_log
                        ^^^^^^^
Error: The value dec_log has type int -> int * string
       but an expression was expected of type int * string -> 'a
       Type int is not compatible with type int * string

ذلك لأن inc_log x سيكون زوجًا، لكن dec_log تتوقع ببساطة عددًا صحيحًا كمدخل.

ويمكننا كتابة نسخة مُرقّاة من dec_log قادرة على أخذ زوج كمدخل:

let dec_log_upgraded (x, s) =
  (x - 1, Printf.sprintf "%s; Called dec on %i; " s x)
let id x = x |> inc_log |> dec_log_upgraded
val dec_log_upgraded : int * string -> int * string = <fun>
val id : int -> int * string = <fun>

يعمل ذلك بلا مشكلة، لكننا سنحتاج أيضًا إلى كتابة نسخة مُرقّاة مشابهة من inc_log إذا أردنا يومًا استدعاءهما بترتيب معاكس، مثل let id = dec_log >> inc_log. فعلينا أن نكتب:

let inc_log_upgraded (x, s) =
  (x + 1, Printf.sprintf "%s; Called inc on %i; " s x)
let id = dec_log >> inc_log_upgraded
val inc_log_upgraded : int * string -> int * string = <fun>
val id : int -> int * string = <fun>

وفي هذه المرحلة صرنا نكرر شيفرة أكثر مما ينبغي بكثير. فتنفيذا inc وdec مكرران داخل كل من inc_log وdec_log، وكذلك داخل النسختين المُرقّاتين من الدالتين. وتكرر الترقيتان معًا شيفرة دمج رسائل السجل. وكلما زاد عدد الدوال التي نريد جعلها قابلة للتسجيل، ازداد ذلك التكرار سوءًا!

فلنبدأ إذن من جديد، ونستخلص دالتين مساعدتين. الدالة المساعدة الأولى تستدعي دالة وتنتج رسالة سجل:

let log (name : string) (f : int -> int) : int -> int * string =
  fun x -> (f x, Printf.sprintf "Called %s on %i; " name x)
val log : string -> (int -> int) -> int -> int * string = <fun>

والدالة المساعدة الثانية تنتج دالة تسجيل من النوع 'a * string -> 'b * string من دالة غير قابلة للتسجيل:

let loggable (name : string) (f : int -> int) : int * string -> int * string =
  fun (x, s1) ->
    let (y, s2) = log name f x in
    (y, s1 ^ s2)
val loggable : string -> (int -> int) -> int * string -> int * string = <fun>

وباستخدام هاتين الدالتين المساعدتين، يمكننا تنفيذ النسخ القابلة للتسجيل من دوالنا دون أي تكرار للشيفرة المتعلقة بالأزواج أو مطابقة الأنماط أو دمج السلاسل:

let inc' : int * string -> int * string =
  loggable "inc" inc
let dec' : int * string -> int * string =
  loggable "dec" dec
let id' : int * string -> int * string =
  inc' >> dec'
val inc' : int * string -> int * string = <fun>
val dec' : int * string -> int * string = <fun>
val id' : int * string -> int * string = <fun>

وإليك مثال على الاستخدام:

id' (5, "")
- : int * string = (5, "Called inc on 5; Called dec on 6; ")

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

let e x = (x, "")
val e : 'a -> 'a * string = <fun>

والآن يمكننا كتابة id' (e 5) بدلًا من id' (5, "").

أين الموناد؟ ما فعلناه للتو هو أخذ دوال على الأعداد الصحيحة وتحويلها إلى دوال على أعداد صحيحة مقرونة برسائل سجل. ويمكننا التفكير في هذه الدوال «المُرقّاة» كحسابات تسجّل. وهي تنتج صناديق مجازية، وتحتوي تلك الصناديق مخرجات الدوال ورسائل السجل.

كانت هناك فكرتان جوهريتان في الشيفرة التي كتبناها للتو، وهما تقابلان عمليتي الموناد return وbind.

الأولى كانت ترقية قيمة من int إلى int * string بقرنها بالسلسلة الفارغة. وهذا ما تفعله e. ويمكننا إعادة تسميتها return:

let return (x : int) : int * string = (x, "")
val return : int -> int * string = <fun>

لهذه الدالة التأثير التافه المتمثل في وضع قيمة داخل الصندوق المجازي مع رسالة السجل الفارغة.

والفكرة الثانية كانت استخلاص شيفرة للتعامل مع مطابقة الأنماط على الأزواج ودمج السلاسل. وإليك تلك الفكرة معبرًا عنها كدالة قائمة بذاتها:

let ( >>= ) (m : int * string) (f : int -> int * string) : int * string =
  let (x, s1) = m in
  let (y, s2) = f x in
  (y, s1 ^ s2)
val ( >>= ) : int * string -> (int -> int * string) -> int * string = <fun>

وباستخدام >>=، يمكننا إعادة تنفيذ loggable بحيث لا تُستخدم أي أزواج أو مطابقة أنماط أبدًا في جسمها:

let loggable (name : string) (f : int -> int) : int * string -> int * string =
  fun m ->
    m >>= fun x ->
    log name f x
val loggable : string -> (int -> int) -> int * string -> int * string = <fun>

موناد الكاتب. الموناد الذي اكتشفناه للتو يُسمى عادةً موناد الكاتب (writer monad) (بمعنى «الكتابة إضافةً إلى سجل أو سلسلة»). وإليك تنفيذ توقيع الموناد له:

module Writer : Monad = struct
  type 'a t = 'a * string
  let return x = (x, "")
  let ( >>= ) m f =
    let (x, s1) = m in
    let (y, s2) = f x in
    (y, s1 ^ s2)
end
module Writer : Monad

وكما رأينا مع موناد maybe، هذان هما التنفيذان نفسهان لـ return و>>= اللذان اخترعناهما أعلاه، لكن دون التعليقات النوعية التي تفرض عملهما على الأعداد الصحيحة فقط. وفعلًا، لم نكن نحتاج تلك التعليقات قط؛ فقد ساعدت فقط في جعل الشيفرة أعلاه أوضح قليلًا.

ومن المثير للجدل أي نسخة من loggable أسهل قراءة. ومن المؤكد أنك تحتاج إلى الطمأنينة على النمط المونادي للبرمجة لتقدير النسخة التي تستخدم >>=. لكن لو كنت تطوّر قاعدة شيفرة أكبر بكثير (أي فيها دوال أكثر تتضمن سلاسل مقرونة من مجرد loggable)، فالمرجح أن يكون استخدام العامل >>= خيارًا جيدًا: فهو يعني أن الشيفرة التي تكتبها يمكن أن تركز على 'a في النوع 'a Writer.t بدلًا من التركيز على السلاسل. وبعبارة أخرى، سيتولى موناد الكاتب أمر السلاسل نيابة عنك، ما دمت تستخدم return و>>=.

7.8.4. مثال: موناد Lwt

من الواضح الآن على الأرجح أن مكتبة الوعود Lwt التي ناقشناها هي أيضًا موناد. فنوع الوعود 'a Lwt.t له عملية return وعملية bind بالنوعين الصحيحين ليكون مونادًا:

val return : 'a -> 'a t
val bind : 'a t -> ('a -> 'b t) -> 'b t

والعامل Lwt.Infix.( >>= ) مرادف لـ Lwt.bind، لذا توفر المكتبة فعلًا عامل ربط بينيًا.

والآن نبدأ في رؤية بعض القوة العظيمة لنمط تصميم الموناد. فتنفيذ 'a t وreturn الذي رأيناه سابقًا يتضمن إنشاء مراجع، لكن تلك المراجع مخفية تمامًا خلف الواجهة المونادية. وعلاوة على ذلك، نعلم أن bind يتضمن تسجيل دوال استدعاء، لكن تلك الوظيفة (التي تتضمن، كما قد تتخيل، الاحتفاظ بمجموعات من دوال الاستدعاء) مغلّفة بالكامل.

ومجازيًا، كما ناقشنا سابقًا، الصندوق هنا يبدأ فارغًا لكنه سيُملأ في النهاية بقيمة من النوع 'a. و«الشيء الإضافي» في هذه الحسابات أن القيم تُنتَج بصورة غير متزامنة، لا فورًا.

7.8.5. قوانين الموناد

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

لكن الموناد ليس بنية بيانات واحدة فحسب. إنه نمط تصميم لبنى البيانات. لذا يستحيل كتابة مواصفات لـ return و>>= للمونادات عمومًا: فالمواصفات ستحتاج إلى مناقشة الموناد بعينه، مثل موناد الكاتب أو موناد Lwt.

وفي المقابل، يتبين أننا نستطيع تدوين بعض القوانين التي ينبغي أن تصح على أي موناد. وسبب ذلك يعود إلى أحد الحدوس التي قدمناها عن المونادات، وهو أنها تمثل حسابات لها تأثيرات. تأمل Lwt مثلًا. قد نسجّل دالة استدعاء C على وعد X باستخدام bind. فينتج عن ذلك وعد جديد Y، يمكننا تسجيل دالة استدعاء أخرى D عليه. ونتوقع ترتيبًا تسلسليًا على تلك الدوال: يجب أن تُنفَّذ C قبل D، لأن Y لا يمكن أن يُحل قبل X.

وفكرة الترتيب التسلسلي هذه جزء من ما تنص عليه قوانين الموناد. وسنصوغ تلك القوانين أدناه. لكن أولًا، لنتوقف لنتأمل الترتيب التسلسلي في اللغات الأمرية.

الترتيب التسلسلي. في لغات مثل Java و C، توجد فاصلة منقوطة تفرض ترتيبًا تسلسليًا على العبارات، مثل:

System.out.println(x);
x++;
System.out.println(x);

تُطبع x أولًا، ثم تُزاد، ثم تُطبع مرة أخرى. ويجب أن تحدث التأثيرات التي لهذه العبارات بذلك الترتيب التسلسلي.

ولنتخيل عبارة افتراضية لا تُحدث أي تأثير على الإطلاق. فمثلًا، assert true لا تسبب حدوث أي شيء في Java. (ستتجاهلها بعض المصرّفات تمامًا ولن تنتج لها حتى شيفرة بايت.) وفي معظم لغات التجميع، توجد كذلك تعليمة «لا عملية» يُرمز لها عادةً بـ NOP ولا تُحدث شيئًا أيضًا. (تقنيًا، ستمر بعض دورات الساعة. لكن لن تحدث أي تغييرات في السجلات أو الذاكرة.) وفي نظرية لغات البرمجة، تُسمى عبارات كهذه عادةً skip، بمعنى «تجاوزني لأنني لا أفعل شيئًا مثيرًا».

وإليك قانونين ينبغي أن يصحا على skip والفاصلة المنقوطة:

  • skip; s; ينبغي أن تتصرف مثل s; فحسب.
  • s; skip; ينبغي أن تتصرف مثل s; فحسب.

وبعبارة أخرى، يمكنك إزالة أي ورود لـ skip، لأنها بلا تأثيرات. ورياضيًا، نقول إن skip محايد أيسر (left identity) (القانون الأول) ومحايد أيمن (right identity) (القانون الثاني) للفاصلة المنقوطة.

وتحتوي اللغات الأمرية عادةً أيضًا على طريقة لتجميع العبارات في كتل. وفي Java و C، يُفعل ذلك عادةً بأقواس معقوفة. وإليك قانونًا ينبغي أن يصح على الكتل والفاصلة المنقوطة:

  • {s1; s2;} s3; ينبغي أن تتصرف مثل s1; {s2; s3;}.

وبعبارة أخرى، الترتيب دائمًا s1 ثم s2 ثم s3، بصرف النظر عن تجميع العبارتين الأولى في كتلة أو الأخيرتين في كتلة. بل يمكنك حتى إزالة الأقواس وكتابة s1; s2; s3; فحسب، وهو ما نفعله عادةً على أي حال. ورياضيًا، نقول إن الفاصلة المنقوطة تجميعية (associative).

الترتيب التسلسلي مع قوانين الموناد. القوانين الثلاثة أعلاه تجسّد الحدس نفسه بالضبط الذي تجسّده قوانين الموناد، التي سنصوغها الآن. وقوانين الموناد أكثر تجريدًا قليلًا فحسب، لذا أصعب في الفهم في البداية.

لنفترض أن لدينا أي موناد، ويجب كما اعتدنا أن يكون توقيعه كما يلي:

module type Monad = sig
  type 'a t
  val return : 'a -> 'a t
  val ( >>= ) : 'a t -> ('a -> 'b t) -> 'b t
end
module type Monad =
  sig
    type 'a t
    val return : 'a -> 'a t
    val ( >>= ) : 'a t -> ('a -> 'b t) -> 'b t
  end

وقوانين الموناد الثلاثة كما يلي:

  • القانون 1: return x >>= f تتصرف مثل f x.
  • القانون 2: m >>= return تتصرف مثل m.
  • القانون 3: (m >>= f) >>= g تتصرف مثل m >>= (fun x -> f x >>= g).

وهنا، «تتصرف مثل» تعني أن التعبيرين سيقيّمان كلاهما إلى القيمة نفسها، أو سيدخلان كلاهما في حلقة لا نهائية، أو سيرفعان كلاهما الاستثناء نفسه.

وتقول هذه القوانين رياضيًا الأشياء نفسها التي تقولها قوانين skip والفاصلة المنقوطة والأقواس التي رأيناها أعلاه: return محايد أيسر وأيمن لـ >>=، و>>= تجميعي. ولنفحص كل قانون بمزيد من التفصيل.

يقول القانون 1 إن إحداث التأثير التافه على قيمة، ثم ربط دالة عليها، مماثل لاستدعاء الدالة على القيمة فحسب. تأمل موناد maybe: ستكون return x هي Some x، و>>= f ستستخرج x وتطبّق f عليها. أو تأمل موناد Lwt: ستكون return x وعدًا محلولًا بالفعل بـ x، و>>= f ستسجل f كدالة استدعاء لتنفيذها على x.

ويقول القانون 2 إن الربط على التأثير التافه مماثل لعدم إحداث التأثير فحسب. تأمل موناد maybe: ستعتمد m >>= return على ما إذا كانت m هي Some x أو None. في الحالة الأولى، سيستخرج الربط x، وستعيد return تغليفه بـ Some من جديد. وفي الحالة الثانية، سيعيد الربط None فحسب. وبالمثل، مع Lwt، سيسجل الربط على m الدالة return كدالة استدعاء لتنفيذها على محتوى m بعد حله، وستأخذ return ذلك المحتوى وتعده إلى وعد محلول بالفعل.

ويقول القانون 3 إن bind يتابع التأثيرات بصورة صحيحة، لكن رؤية ذلك في هذا القانون أصعب مما كانت في النسخة أعلاه بالفاصلة المنقوطة والأقواس. وكان القانون 3 سيكون أوضح لو أمكننا إعادة كتابته كما يلي

(m >>= f) >>= g تتصرف مثل m >>= (f >>= g).

لكن المشكلة أن ذلك لا يجتاز فحص الأنواع: فـ f >>= g ليس لها النوع الصحيح لتكون في الطرف الأيمن من >>=. لذا علينا إدراج دالة مجهولة إضافية fun x -> ... لجعل الأنواع صحيحة.

7.8.6. التركيب وقوانين الموناد

ثمة عامل موناد آخر يُسمى compose يمكن استخدامه لتركيب دوال مونادية. فمثلًا، لنفترض أن لديك مونادًا بنوع 'a t، ودالتين:

  • f : 'a -> 'b t
  • g : 'b -> 'c t

سيكون تركيب تلك الدالتين

  • compose f g : 'a -> 'c t

أي أن التركيب سيأخذ قيمة من النوع 'a، ويطبّق f عليها، ويستخرج 'b من النتيجة، ويطبّق g عليها، ويعيد تلك القيمة.

ويمكننا كتابة compose باستخدام >>=؛ فلا نحتاج إلى معرفة أي شيء آخر عن كيفية عمل الموناد من الداخل:

let compose f g x =
  f x >>= fun y ->
  g y
let ( >=> ) = compose
val compose :
  ('a -> int * string) -> (int -> int * string) -> 'a -> int * string = <fun>
val ( >=> ) :
  ('a -> int * string) -> (int -> int * string) -> 'a -> int * string = <fun>

وكما يوحي السطر الأخير، يمكن التعبير عن compose كعامل بيني يُكتب >=>.

وبالعودة إلى مثالنا لموناد maybe مع عامل قسمة آمن، تخيّل أن لدينا دالتي زيادة ونقصان:

let inc (x : int) : int option = Some (x + 1)
let dec (x : int) : int option = Some (x - 1)
let ( >>= ) x op =
  match x with
  | None -> None
  | Some a -> op a
val inc : int -> int option = <fun>
val dec : int -> int option = <fun>
val ( >>= ) : 'a option -> ('a -> 'b option) -> 'b option = <fun>

وسيمكّننا عامل التركيب المونادي من تركيب هاتين الدالتين في دالة هوية دون كتابة أي شيفرة إضافية:

let ( >=> ) f g x =
  f x >>= fun y ->
  g y
let id : int -> int option = inc >=> dec
val ( >=> ) : ('a -> 'b option) -> ('b -> 'c option) -> 'a -> 'c option =
  <fun>
val id : int -> int option = <fun>

وباستخدام عامل التركيب، يصبح لدينا صوغ أنظف بكثير لقوانين الموناد:

  • القانون 1: return >=> f تتصرف مثل f.
  • القانون 2: f >=> return تتصرف مثل f.
  • القانون 3: (f >=> g) >=> h تتصرف مثل f >=> (g >=> h).

وفي ذلك الصوغ، يصبح واضحًا فورًا أن return محايد أيسر وأيمن، وأن التركيب تجميعي.

7.9. الخلاصة

البرمجة المتزامنة صعبة، وهو ما يفسر ربما كثرة المكتبات والميزات اللغوية المختلفة التي تدعمها. والوعود أنيقة ومفيدة في البرمجة الوظيفية والأمرية معًا. لكنها ليست الكلمة الأخيرة: فقد أضاف OCaml 5 حديثًا آليات جديدة للتوازي والتزامن قائمة على النطاقات (domains) والخيوط. وقد يهمك أن تقرأ المزيد عنها في دليل OCaml.

7.9.1. المصطلحات والمفاهيم

  • غير متزامن (asynchronous)
  • الربط (bind)
  • حاجِب (blocking)
  • دالة استدعاء (callback)
  • قناة (channel)
  • حسابات (computations)
  • متزامن (concurrent)
  • التركيب المتزامن (concurrent composition)
  • تعاوني (cooperative)
  • حتمي (deterministic)
  • تأثيرات (effects)
  • التداخل (interleaving)
  • إخفاء زمن الوصول (latency hiding)
  • محايد أيسر (left identity)
  • موناد Lwt (Lwt monad)
  • موناد maybe (maybe monad)
  • مونادات (monads)
  • قوانين الموناد (monad laws)
  • غير حاجب (non-blocking)
  • غير حتمي (nondeterministic)
  • توازٍ (parallelism)
  • معلّق (pending)
  • استباقي (preemptive)
  • وعود (promises)
  • حالات تسابق (race conditions)
  • مرفوض (rejected)
  • حلقة الحل (resolution loop)
  • محلول (resolved)
  • مُحِلّ (resolver)
  • محايد أيمن (right identity)
  • تسلسلي (sequential)
  • التركيب التسلسلي (sequential composition)
  • الإدخال القياسي (standard input)
  • الإخراج القياسي (standard output)
  • متزامن (synchronous)
  • خيوط (threads)
  • موناد الكاتب (writer monad)

7.9.2. قراءات إضافية

7.10. التمارين

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

تمرين: الوعد والحل [★★]

استخدم النسخة المكتملة من وحدة Promise التي طورناها لفعل ما يلي: أنشئ وعدًا ومُحِلًّا لعدد صحيح، واربط دالة بالوعد لطباعة محتواه، ثم حُل الوعد. ولا ينبغي أن تحدث الطباعة إلا بعد حل الوعد.

تمرين: الوعد والحل مع Lwt [★★]

أعد التمرين السابق، لكن باستخدام مكتبة Lwt بدلًا من مكتبة Promise الخاصة بنا. واحرص على استخدام دوال الإدخال/الإخراج في Lwt (مثل Lwt_io.printf).

تمرين: map عبر bind [★]

استخدم النسخة المكتملة من وحدة Promise التي طورناها لتنفيذ العامل map أيضًا. راجع النص للحصول على وصف لسلوك map p f. ويمكنك استدعاء bind في تنفيذك لـ map. تلميح: استخدم return.

تمرين: map من جديد [★★]

استخدم النسخة المكتملة من وحدة Promise التي طورناها لتنفيذ العامل map أيضًا. راجع النص للحصول على وصف لسلوك map p f. ويمكنك استخدام الشيفرة التي طورناها لـ bind كقالب، لكن لا يجوز لك استدعاء bind في تنفيذك لـ map.

تمرين: تحدّي التوقيت 1 [★★]

إليك دالة تنتج تأخيرًا زمنيًا. ويمكننا استخدامها لمحاكاة استدعاء إدخال/إخراج يستغرق وقتًا طويلًا للاكتمال.

(** [delay s] is a promise that resolves after about [s] seconds. *)
let delay (sec : float) : unit Lwt.t =
  Lwt_unix.sleep sec

اكتب دالة delay_then_print : unit -> unit Lwt.t تؤخر ثلاث ثوانٍ ثم تطبع "done".

تمرين: تحدّي التوقيت 2 [★★★]

ماذا يحدث عند تنفيذ timing2 ()؟ وكم يستغرق تنفيذه؟ توقّع الجواب، ثم نفّذ الشيفرة لتعرف.

let delay n = Lwt_unix.sleep n
let timing2 () =
  let%lwt () = delay 1. in
  let%lwt () = Lwt_io.printl "1" in
  let%lwt () = delay 10. in
  let%lwt () = Lwt_io.printl "2" in
  let%lwt () = delay 20. in
  let%lwt () = Lwt_io.printl "3" in
  Lwt_io.printl "all done"
let _ = timing2 ()
let _ = Lwt_main.run (delay 35.)

تمرين: تحدّي التوقيت 3 [★★★]

ماذا يحدث عند تنفيذ timing3 ()؟ وكم يستغرق تنفيذه؟ توقّع الجواب، ثم نفّذ الشيفرة لتعرف.

open Lwt.Infix
let delay n = Lwt_unix.sleep n
let timing3 () =
  let _t1 = let%lwt () = delay 1. in Lwt_io.printl "1" in
  let _t2 = let%lwt () = delay 10. in Lwt_io.printl "2" in
  let _t3 = let%lwt () = delay 20. in Lwt_io.printl "3" in
  Lwt_io.printl "all done"
let _ = timing3 ()
let _ = Lwt_main.run (delay 35.)

تمرين: تحدّي التوقيت 4 [★★★]

ماذا يحدث عند تنفيذ timing4 ()؟ وكم يستغرق تنفيذه؟ توقّع الجواب، ثم نفّذ الشيفرة لتعرف.

open Lwt.Infix
let delay n = Lwt_unix.sleep n
let timing4 () =
  let t1 = let%lwt () = delay 1. in Lwt_io.printl "1" in
  let t2 = let%lwt () = delay 10. in Lwt_io.printl "2" in
  let t3 = let%lwt () = delay 20. in Lwt_io.printl "3" in
  let%lwt () = Lwt.join [t1; t2; t3] in
  Lwt_io.printl "all done"
let _ = timing4 ()
let _ = Lwt_main.run (delay 35.)

تمرين: مراقب ملف [★★★★]

اكتب برنامج Lwt يراقب محتوى ملف باسم «log». وبوجه التحديد، ينبغي لبرنامجك أن يفتح الملف، ويقرأ منه سطرًا باستمرار، وأن يطبع كل سطر إلى الإخراج القياسي بمجرد توفره. وعندما تصل إلى نهاية الملف (EOF)، ينبغي لبرنامجك أن ينتهي انتهاءً نظيفًا دون أي استثناءات.

وإليك شيفرة البداية:

open Lwt_io
open Lwt_unix
(** [log ()] is a promise for an [input_channel] that reads from
    the file named "log". *)
let log () : input_channel Lwt.t =
  let%lwt fd = openfile "log" [O_RDONLY] 0 in
  Lwt.return (of_fd ~mode:input fd)
(** [loop ic] reads one line from [ic], prints it to stdout,
    then calls itself recursively. It is an infinite loop. *)
let rec loop (ic : input_channel) =
  failwith "TODO"
  (* hint: use [Lwt_io.read_line] and [Lwt_io.printlf] *)
(** [monitor ()] monitors the file named "log". *)
let monitor () : unit Lwt.t =
  Lwt.bind (log ()) loop
(** [handler] is a helper function for [main]. If its input is
    [End_of_file], it handles cleanly exiting the program by
    returning the unit promise. Any other input is re-raised
    with [Lwt.fail]. *)
let handler : exn -> unit Lwt.t =
  failwith "TODO"
let main () : unit Lwt.t =
  Lwt.catch monitor handler
let _ = Lwt_main.run (main ())

أكمل loop وhandler. وقد تجد دليل Lwt مفيدًا.

ولتصريف شيفرتك، ضعها في ملف باسم monitor.ml. وأنشئ ملف dune له:

(executable
 (name monitor)
 (libraries lwt.unix))

ونفّذه كالمعتاد:

$ dune exec ./monitor.exe

ولمحاكاة ملف تُضاف إليه أسطر مع الوقت، افتح نافذة طرفية جديدة وأدخل الأوامر التالية:

$ mkfifo log
$ cat >log

الآن سيُضاف كل ما تكتبه في نافذة الطرفية (بعد الضغط على return) إلى الملف المسمى log. وسيمكّنك ذلك من اختبار برنامجك اختبارًا تفاعليًا.

تمرين: add opt [★★]

إليك تعريفات موناد maybe:

module type Monad = sig
  type 'a t
  val return : 'a -> 'a t
  val ( >>= ) : 'a t -> ('a -> 'b t) -> 'b t
end
module Maybe : Monad =
struct
  type 'a t = 'a option
  let return x = Some x
  let ( >>= ) m f =
    match m with
    | Some x -> f x
    | None -> None
end

نفّذ add : int Maybe.t -> int Maybe.t -> int Maybe.t. إذا كان أي من المدخلين None، فينبغي أن يكون المخرج None. وإلا، إذا كان المدخلان Some a وSome b، فينبغي أن يكون المخرج Some (a+b). ويجب أن يكون تعريف add خارج Maybe، كما هو موضح أعلاه، وهو ما يعني أن حلك لا يجوز أن يستخدم المنشئين None أو Some في شيفرته.

تمرين: fmap و join [★★]

إليك توقيعًا موسّعًا للمونادات يضيف عمليتين جديدتين:

module type ExtMonad = sig
  type 'a t
  val return : 'a -> 'a t
  val ( >>= ) : 'a t -> ('a -> 'b t) -> 'b t
  val ( >>| ) : 'a t -> ('a -> 'b) -> 'b t
  val join : 'a t t -> 'a t
end

وكما أن العامل البيني >>= يُعرف باسم bind، فالعامل البيني >>| يُعرف باسم fmap. ولا يختلف العاملان إلا في نوع إعادة وسيطهما الدالي.

وباستخدام استعارة الصندوق، تأخذ >>| قيمة معبوّأة، ودالة لا تعرف إلا العمل على القيم غير المعبوّأة، فتستخرج القيمة من الصندوق، وتنفّذ الدالة عليها، وتعّبئ ذلك المخرج كقيمة إعادتها.

وباستخدام استعارة الصندوق أيضًا، تأخذ join قيمة ملفوفة في صندوقين وتزيل أحد الصندوقين.

ويمكن تنفيذ >>| وjoin مباشرةً بمطابقة الأنماط (كما نفّذنا >>= بالفعل). ويمكن أيضًا تنفيذهما دون مطابقة الأنماط.

ولهذا التمرين، افعل الأمر الأول: نفّذ >>| وjoin كجزء من موناد Maybe، ولا تستخدم >>= أو return في جسم >>| أو join.

تمرين: fmap و join مرة أخرى [★★]

حل التمرين السابق مرة أخرى. لكن هذه المرة، عليك استخدام >>= وreturn لتنفيذ >>| وjoin، ولا يجوز لك استخدام Some أو None في جسم >>| وjoin.

تمرين: bind من fmap و join [★★★]

يوضح التمرين السابق أن >>| وjoin يمكن تنفيذهما بالكامل باستخدام >>= (وreturn)، دون الحاجة إلى معرفة أي شيء عن نوع التمثيل 'a t للموناد.

بل من الممكن فعلًا السير في الاتجاه المعاكس. أي أنه يمكن تنفيذ >>= باستخدام >>| وjoin فحسب، دون الحاجة إلى معرفة أي شيء عن نوع التمثيل 'a t.

أثبت أن ذلك ممكن بإكمال الشيفرة التالية:

module type FmapJoinMonad = sig
  type 'a t
  val ( >>| ) : 'a t -> ('a -> 'b) -> 'b t
  val join : 'a t t -> 'a t
  val return : 'a -> 'a t
end
module type BindMonad = sig
  type 'a t
  val return : 'a -> 'a t
  val ( >>= ) : 'a t -> ('a -> 'b t) -> 'b t
end
module MakeMonad (M : FmapJoinMonad) : BindMonad = struct
  (* TODO *)
end

تلميح: دع الأنواع تكون دليلك.

تمرين: موناد القائمة [★★★]

رأينا ثلاثة أمثلة على المونادات بالفعل؛ ولنفحص مثالًا رابعًا، هو موناد القائمة (list monad). و«الشيء الإضافي» الذي يفعله هو ترقية الدوال لتعمل على القوائم بدلًا من القيم المفردة فحسب. (لاحظ أنه لا يُقصد هنا أي مفهوم للتزامن. فليس الأمر أن موناد القائمة ينفّذ الدوال بالتزامن على كل عنصر من عناصر القائمة. أما موناد Lwt فيوفر ذلك النوع من الوظائف.)

فمثلًا، لنفترض أن لديك هاتين الدالتين:

let inc x = x + 1
let pm x = [x; -x]

حينئذٍ يمكن استخدام موناد القائمة لتطبيق هاتين الدالتين على كل عنصر من عناصر قائمة وإعادة النتيجة كقائمة. فمثلًا،

  • [1; 2; 3] >>| inc هي [2; 3; 4].
  • [1; 2; 3] >>= pm هي [1; -1; 2; -2; 3; -3].
  • [1; 2; 3] >>= pm >>| inc هي [2; 0; 3; -1; 4; -2].

ومن طرق التفكير في ذلك أن عوامل موناد القائمة تأخذ قائمة مدخلات إلى دالة، وتنفّذ الدالة على كل تلك المدخلات، وتعيد لك القائمة المجمّعة للمخرجات.

أكمل التعريف التالي لموناد القائمة:

module type ExtMonad = sig
  type 'a t
  val return : 'a -> 'a t
  val ( >>= ) : 'a t -> ('a -> 'b t) -> 'b t
  val ( >>| ) : 'a t -> ('a -> 'b) -> 'b t
  val join : 'a t t -> 'a t
end
module ListMonad : ExtMonad = struct
  type 'a t = 'a list
  (* TODO *)
end

تلميحات: اترك >>= إلى النهاية. ودع الأنواع تكون دليلك. وهناك دالتان مكتبيتان مفيدتان جدًا للقوائم يمكن أن تساعداك.

تمرين: قوانين الموناد التافه [★★★]

إليك أكثر موناد تافه في العالم. فكل ما يفعله هو لف قيمة داخل منشئ.

module Trivial : Monad = struct
  type 'a t = Wrap of 'a
  let return x = Wrap x
  let ( >>= ) (Wrap x) f = f x
end

أثبت أن قوانين الموناد الثلاثة، بصوغها باستخدام >>= وreturn، تصح على الموناد التافه.