البرمجة بلغة OCaml: صحيحة وفعّالة وجميلة › أساسيات OCaml 3 / 11 السابق التالي

أساسيات OCaml

2. أساسيات OCaml

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

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

هناك خمسة مكونات أساسية لتعلّم أي لغة: الصياغة، والدلالات، والأساليب الاصطلاحية (idioms)، والمكتبات، والأدوات.

الصياغة (Syntax). نعني بـ الصياغة القواعد التي تحدد ما يشكّل برنامجًا سليم الصياغة نصيًا في اللغة، بما في ذلك الكلمات المفتاحية، والقيود على المسافات البيضاء والتنسيق، وعلامات الترقيم، والمؤثرات، وغيرها. ومن أكثر الجوانب المزعجة في تعلّم لغة جديدة أن تبدو صياغتها غريبة مقارنةً باللغات التي تعرفها. لكن كلما تعلمت لغات أكثر، اعتدت أكثر على تقبّل صياغة اللغة كما هي، بدلًا من تمني أن تكون مختلفة. (إذا أردت رؤية بعض اللغات ذات الصياغة غير المعتادة حقًا، فألقِ نظرة على APL، التي تحتاج إلى لوحة مفاتيح موسّعة خاصة بها، وWhitespace، التي تتكون برامجها بالكامل من مسافات وعلامات جدولة وأسطر جديدة.) أنت تحتاج إلى فهم الصياغة لمجرد أن تكون قادرًا على مخاطبة الحاسوب إطلاقًا.

الدلالات (Semantics). نعني بـ الدلالات القواعد التي تحدد سلوك البرامج. بعبارة أخرى، تتعلق الدلالات بمعنى البرنامج — أي الحساب الذي يمثله جزء معيّن من الصياغة. لاحظ أنه رغم أن كلمة «semantics» جمع في الصيغة، فإننا نستخدمها بصيغة المفرد، على غرار «mathematics» و«physics».

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

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

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

الأدوات (Tools). في الحد الأدنى، يوفر أي تنفيذ للغة إما مصرّفًا أو مفسّرًا كأداة للتفاعل مع الحاسوب باستخدام اللغة. لكن هناك أنواعًا أخرى من الأدوات: المنقّحات (debuggers)؛ وبيئات التطوير المتكاملة (IDE)؛ وأدوات التحليل الخاصة بأمور مثل الأداء واستخدام الذاكرة والسلامة. ويمكن أن يجعل تعلّم استخدام الأدوات المرتبطة باللغة منك مبرمجًا أكثر إنتاجية. وأحيانًا يسهل الخلط بين الأداة نفسها واللغة؛ فإذا لم تستخدم يومًا سوى Eclipse وJava معًا، فقد لا يكون واضحًا أن Eclipse بيئة تطوير متكاملة تعمل مع لغات كثيرة، وأن Java يمكن استخدامها دون Eclipse.

وفيما يتعلق بتعلّم OCaml في هذا الكتاب، ينصبّ تركيزنا أساسًا على الدلالات والأساليب الاصطلاحية. وسنضطر إلى تعلّم الصياغة على الطريق بالطبع، لكنها ليست الجزء الممتع من دراستنا. وسنحصل على قدر من التعرض للمكتبة القياسية في OCaml ولمكتبتين أخريين، أبرزهما OUnit (إطار لاختبار الوحدات شبيه بـ JUnit وHUnit وغيرهما). وإلى جانب مصرّف OCaml ونظام البناء، فإن الأداة الرئيسية التي سنستخدمها هي الواجهة التفاعلية (toplevel)، التي توفر إمكانية تجربة الشيفرة تفاعليًا.

2.1. الواجهة التفاعلية لـ OCaml

الواجهة التفاعلية شبيهة بآلة حاسبة أو بواجهة سطر أوامر لـ OCaml. وهي تشبه JShell في Java، أو مفسّر Python التفاعلي. والواجهة التفاعلية مفيدة لتجربة قطع صغيرة من الشيفرة دون عناء تشغيل مصرّف OCaml. لكن لا تعتمد عليها كثيرًا، لأن إنشاء البرامج الكبيرة وتصريفها واختبارها سيتطلب أدوات أقوى. وتسمي بعض اللغات الأخرى الواجهة التفاعلية REPL، وهو اختصار لـ read-eval-print-loop أي «اقرأ-قيّم-اطبع-كرر»: فهي تقرأ مدخلات المبرمج، وتقيّمها، وتطبع النتيجة، ثم تكرر ذلك.

في نافذة طرفية، اكتب utop لتشغيل الواجهة التفاعلية. واضغط Control-D للخروج منها. ويمكنك أيضًا إدخال #quit;; والضغط على Enter. لاحظ أنه يجب عليك كتابة # هنا: فهي إضافة إلى الموجه # الذي تراه بالفعل.

2.1.1. الأنواع والقيم

يمكنك إدخال تعبيرات إلى الواجهة التفاعلية في OCaml. أنهِ التعبير بفاصلة منقوطة مزدوجة ;; واضغط على مفتاح Enter. عندئذ ستقيّم OCaml التعبير، وتخبرك بالقيمة الناتجة ونوع تلك القيمة. على سبيل المثال:

# 42;;
- : int = 42

لنفكّك ذلك الرد من utop، بالقراءة من اليمين إلى اليسار:

  • 42 هي القيمة.
  • int هو نوع القيمة.
  • لم يُعطَ للقيمة اسم، ومن هنا الرمز -.

كان تفاعل utop ذلك «مكتوبًا يدويًا» كجزء من هذا الكتاب. كان علينا كتابة جميع المحارف: # و- وغيرها. لكن البنية التحتية المستخدمة لكتابة هذا الكتاب تمكّننا فعليًا من كتابة شيفرة تُقيّمها OCaml عند ترجمة الكتاب إلى HTML أو PDF. ومن الآن فصاعدًا، هذا عادةً ما سنفعله. وهو يبدو هكذا:

42
- : int = 42

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

ملاحظة

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

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

let x = 42
val x : int = 42

مرة أخرى، لنفكّك ذلك الرد، وهذه المرة بالقراءة من اليسار إلى اليمين:

  • رُبطت قيمة باسم، ومن هنا الكلمة المفتاحية val.
  • x هو الاسم الذي رُبطت به القيمة.
  • int هو نوع القيمة.
  • 42 هي القيمة.

يمكنك قراءة الناتج كله على أنه «x من النوع int ويساوي 42.»

2.1.2. الدوال

يمكن تعريف دالة في الواجهة التفاعلية باستخدام صياغة كهذه:

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

لنفكّك ذلك الرد:

  • increment هو المعرّف الذي رُبطت به القيمة.
  • int -> int هو نوع القيمة. وهذا هو نوع الدوال التي تأخذ int كمدخل وتنتج int كمخرج. فكّر في السهم -> كنوع من الاستعارة البصرية لتحوّل قيمة إلى قيمة أخرى — وهو ما تفعله الدوال.
  • القيمة دالة، وتختار الواجهة التفاعلية عدم طباعتها (لأنها صُرفت الآن ولها تمثيل في الذاكرة ليس سهل العرض الجميل). وبدلًا من ذلك، تطبع الواجهة التفاعلية ``، وهو مجرد عنصر نائب.

ملاحظة

`` نفسه ليس قيمة. إنه يشير فقط إلى قيمة دالة غير قابلة للطباعة.

يمكنك «استدعاء» الدوال بصياغة كهذه:

increment 0
- : int = 1
increment(21)
- : int = 22
increment (increment 5)
- : int = 7

لكن المفردة المعتادة في OCaml هي أننا «نطبّق» الدالة لا أننا «نستدعيها».

لاحظ كيف أن OCaml مرنة بشأن كتابة الأقواس أو عدم كتابتها، وكتابة المسافات البيضاء أو عدم كتابتها. ومن تحديات تعلّم OCaml لأول مرة معرفة متى تكون الأقواس مطلوبة فعلًا. لذا إذا واجهت مشكلات في أخطاء الصياغة، فمن الاستراتيجيات تجربة إضافة بعض الأقواس. لكن الأسلوب المفضّل عادةً هو حذف الأقواس عندما لا تكون ضرورية. لذا فإن increment 21 أفضل من increment(21).

2.1.3. تحميل الشيفرة في الواجهة التفاعلية

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

على سبيل المثال، افترض أنك أنشأت ملفًا باسم mycode.ml. وضع في ذلك الملف الشيفرة التالية:

let inc x = x + 1

شغّل الواجهة التفاعلية. جرّب إدخال التعبير التالي، ولاحظ الخطأ:

inc 3
File "[7]", line 1, characters 0-3:
1 | inc 3
    ^^^
Error: Unbound value inc
Hint: Did you mean incr?

يحدث الخطأ لأن الواجهة التفاعلية لا تعرف بعد أي شيء عن دالة اسمها inc. أصدر الآن التوجيه التالي إلى الواجهة التفاعلية:

# #use "mycode.ml";;

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

جرّب الآن مرة أخرى:

inc 3
- : int = 4

2.1.4. سير العمل في الواجهة التفاعلية

أفضل سير عمل عند استخدام الواجهة التفاعلية مع شيفرة مخزّنة في ملفات هو:

  • حرّر الشيفرة في الملف.
  • حمّل الشيفرة في الواجهة التفاعلية باستخدام #use.
  • اختبر الشيفرة تفاعليًا.
  • اخرج من الواجهة التفاعلية. تحذير: لا تتخطَّ هذه الخطوة.

ملاحظة

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

2.2. تصريف برامج OCaml

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

2.2.1. تخزين الشيفرة في ملفات

افتح طرفية، وأنشئ دليلًا جديدًا، وافتح VS Code في ذلك الدليل. على سبيل المثال، يمكنك استخدام الأوامر التالية:

$ mkdir hello-world
$ cd hello-world

تحذير

لا تستخدم جذر دليلك الرئيسي في Unix مكانًا لتخزين الملف. فنظام البناء الذي سنستخدمه قريبًا جدًا، وهو dune، قد لا يعمل بشكل صحيح في جذر دليلك الرئيسي. بل عليك استخدام دليل فرعي من دليلك الرئيسي.

استخدم VS Code لإنشاء ملف جديد باسم hello.ml. أدخل الشيفرة التالية في الملف:

let _ = print_endline "Hello world!"

ملاحظة

لا توجد فاصلة منقوطة مزدوجة ;; في نهاية سطر الشيفرة ذلك. فالفاصلة المنقوطة المزدوجة مخصصة للجلسات التفاعلية في الواجهة التفاعلية، حتى تعرف الواجهة أنك انتهيت من إدخال قطعة شيفرة. وليس هناك عادةً سبب لكتابتها في ملف ‎.ml.

تعني let _ = أعلاه أننا لا نهمّ بإعطاء اسم (ومن هنا «الفراغ» أو الشرطة السفلية) للشيفرة على الجانب الأيمن من =.

احفظ الملف وعد إلى سطر الأوامر. صرّف الشيفرة:

$ ocamlc -o hello.byte hello.ml

المصرّف اسمه ocamlc. ويقول الخيار -o hello.byte بتسمية الملف التنفيذي الناتج hello.byte. ويحتوي الملف التنفيذي على شيفرة OCaml البايتية المصرّفة. إضافةً إلى ذلك، يُنتَج ملفان آخران هما hello.cmi وhello.cmo. ولا نحتاج إلى الاهتمام بهذين الملفين في الوقت الحالي. شغّل الملف التنفيذي:

$ ./hello.byte

ينبغي أن يطبع Hello world! ثم يتوقف.

غيّر الآن السلسلة النصية المطبوعة إلى شيء من اختيارك. احفظ الملف، وأعد التصريف، وأعد التشغيل. جرّب جعل الشيفرة تطبع عدة أسطر.

دورة التحرير-التصريف-التشغيل هذه بين المحرر وسطر الأوامر قد تبدو غير مألوفة إذا كنت معتادًا على العمل داخل بيئات تطوير متكاملة مثل Eclipse. لا تقلق؛ فستصبح قريبًا أمرًا بديهيًا.

الآن لننظّف كل تلك الملفات المولّدة:

$ rm hello.byte hello.cmi hello.cmo

2.2.2. ماذا عن Main؟

على عكس C أو Java، لا تحتاج برامج OCaml إلى دالة خاصة باسم main تُستدعى لبدء البرنامج. والأسلوب الاصطلاحي المعتاد هو أن يكون التعريف الأخير في الملف هو الدالة الرئيسية التي تنطلق منها أي عملية حسابية مطلوبة.

2.2.3. Dune

في المشروعات الأكبر، لا نريد تشغيل المصرّف أو التنظيف يدويًا. بل نريد استخدام نظام بناء (build system) ليجد المكتبات ويربطها تلقائيًا. ولدى OCaml نظام بناء قديم يسمى ocamlbuild، ونظام بناء أحدث يسمى Dune. ومن الأنظمة المشابهة make، الذي استُخدم طويلًا في عالم Unix مع C ولغات أخرى؛ وGradle وMaven وAnt، التي تُستخدم مع Java.

مشروع Dune هو دليل (ودلائله الفرعية) يحتوي على شيفرة OCaml تريد تصريفها. وجذر المشروع هو أعلى دليل في هرميته. وقد يعتمد المشروع على حزم (packages) خارجية توفر شيفرة إضافية مصرّفة مسبقًا. وعادةً ما تُثبَّت الحزم باستخدام OPAM، مدير حزم OCaml.

يمكن أن يحتوي كل دليل في مشروعك على ملف باسم dune. ويصف ذلك الملف لـ Dune كيف تريد تصريف الشيفرة في ذلك الدليل (ودلائله الفرعية). وتستخدم ملفات Dune صياغة برمجة وظيفية منحدرة من LISP تسمى تعبيرات s (s-expressions)، حيث تُستخدم الأقواس لإظهار بيانات متداخلة تشكل شجرة، على غرار ما تفعله وسوم HTML. وصياغة ملفات Dune موثّقة في دليل Dune.

2.2.3.1. إنشاء مشروع Dune يدويًا

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

(executable
 (name hello))

يصرّح ذلك بملف تنفيذي (executable) (أي برنامج يمكن تنفيذه) ملفه الرئيسي هو hello.ml.

أنشئ أيضًا ملفًا باسم dune-project وضع فيه ما يلي:

(lang dune 3.24)

يخبر ذلك Dune بأن هذا المشروع يستخدم الإصدار 3.24 من Dune، الذي كان الإصدار الحالي عند إصدار هذه النسخة من الكتاب. وملف المشروع هذا مطلوب في الدليل الجذري لكل شجرة مصادر تريد تصريفها باستخدام Dune. وبشكل عام، سيكون لديك ملف dune في كل دليل فرعي من شجرة المصادر، لكن ملف dune-project واحد فقط في الجذر.

ثم نفّذ هذا الأمر من الطرفية:

$ dune build hello.exe

لاحظ أن امتداد ‎.exe يُستخدم على جميع الأنظمة الأساسية بواسطة Dune، ليس على Windows فقط. فذلك يجعل Dune يبني ملفًا تنفيذيًا أصليًا (native) بدلًا من ملف تنفيذي بالشيفرة البايتية.

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

$ _build/default/hello.exe
Hello world!

لكن Dune يوفر اختصارًا يغنيك عن تذكّر كل ذلك وكتابته. لبناء البرنامج وتنفيذه في خطوة واحدة، يمكننا ببساطة تشغيل:

$ dune exec ./hello.exe
Hello world!

وأخيرًا، لتنظيف كل الشيفرة المصرّفة نشغّل فقط:

$ dune clean

يزيل ذلك دليل _build، ولا يبقى سوى شيفرة المصادر لديك.

ملاحظة

عندما يصرّف Dune برنامجك، فإنه يخزّن مؤقتًا نسخة من ملفات مصادرك في _build/default. فإذا ارتكبت يومًا خطأً أدى إلى فقدان ملف مصدر، فقد تتمكن من استعادته من داخل _build. وبالطبع، يُستحسن أيضًا استخدام نظام تحكم بالمصادر مثل git.

تحذير

لا تحرّر أيًا من الملفات في دليل _build. فإذا ظهرت لك يومًا رسالة خطأ بشأن محاولة حفظ ملف للقراءة فقط، فربما كنت تحاول تحرير ملف في دليل _build.

2.2.3.2. إنشاء مشروع Dune تلقائيًا

في الطرفية، انتقل إلى دليل تريد تخزين عملك فيه، مثل «~/work». واختر اسمًا لمشروعك، مثل «calculator». ثم نفّذ:

$ dune init project calculator
$ cd calculator
$ code .

ينبغي أن يكون VS Code مفتوحًا الآن وأن ترى الملفات التي ولّدها Dune تلقائيًا لمشروعك.

من الطرفية في دليل calculator، نفّذ:

$ dune exec bin/main.exe

سيطبع Hello, World!

ملاحظة

إذا كنت تستخدم ocamlformat لتنسيق شيفرة مصادرك تلقائيًا، فلاحظ أن Dune لا يضيف ملف .ocamlformat إلى مشروعك تلقائيًا. وقد ترغب في إضافة ملف كهذا في دليل المستوى الأعلى، أي الجذر، لمشروعك. وهو الدليل الذي يحتوي على الملف المسمى dune-project.

2.2.3.3. تشغيل Dune باستمرار

عندما تشغّل dune build، يصرّف مشروعك مرة واحدة. وقد ترغب في أن تُصرَّف شيفرتك تلقائيًا في كل مرة تحفظ فيها ملفًا في مشروعك. ولتحقيق ذلك، نفّذ هذا الأمر:

$ dune build --watch

سيردّ Dune بأنه ينتظر تغييرات نظام الملفات. وهذا يعني أن Dune يعمل الآن باستمرار ويعيد بناء مشروعك في كل مرة تحفظ فيها ملفًا في VS Code. ولإيقاف Dune، اضغط Control+C.

2.3. التعبيرات

القطعة الأساسية في صياغة OCaml هي التعبير (expression). وكما أن البرامج في اللغات الأمرية تُبنى أساسًا من الأوامر، فإن البرامج في اللغات الوظيفية تُبنى أساسًا من التعبيرات. ومن أمثلة التعبيرات 2+2 وincrement 21.

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

المهمة الأساسية للحساب في لغة وظيفية هي تقييم تعبير إلى قيمة. والقيمة تعبير لم يبقَ فيه أي حساب يجب إجراؤه. لذا فكل القيم تعبيرات، لكن ليست كل التعبيرات قيمًا. ومن أمثلة القيم 2 وtrue و"yay!".

يحتوي دليل OCaml أيضًا على تعريف لجميع القيم، وإن كانت تلك الصفحة، مرة أخرى، مفيدة للرجوع إليها أكثر من كونها للمدارسة.

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

  1. أن يؤدي تقييم التعبير إلى إطلاق استثناء.
  2. أن لا ينتهي تقييم التعبير أبدًا (أي يدخل في «حلقة لا نهائية»).

2.3.1. الأنواع الأولية والقيم

الأنواع الأولية (primitive types) هي الأنواع المدمجة والأكثر أساسية: الأعداد الصحيحة، وأعداد الفاصلة العائمة، والمحارف، والسلاسل النصية، والقيم المنطقية. وستكون مألوفة لك لتشابهها مع الأنواع الأولية في لغات برمجة أخرى.

النوع int: الأعداد الصحيحة. تُكتب الأعداد الصحيحة في OCaml كالمعتاد: 1 و2 وغيرها. والمؤثرات المعتادة متاحة: + و- و* و/ وmod. والاثنان الأخيران هما القسمة الصحيحة وباقي القسمة:

65 / 60
- : int = 1
65 mod 60
- : int = 5
65 / 0
Exception: Division_by_zero.
Raised by primitive operation at unknown location
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

تتراوح الأعداد الصحيحة في OCaml من −262-2^{62} إلى 262−12^{62}-1 على الأنظمة الأساسية الحديثة. وهي منفّذة باستخدام كلمات (words) آلية بطول 64 بت، وهو حجم سجل على معالج 64 بت. لكن إحدى تلك البتات «تُسرق» بواسطة تنفيذ OCaml، مما يؤدي إلى تمثيل بطول 63 بت. وتُستخدم تلك البتة في وقت التشغيل للتمييز بين الأعداد الصحيحة والمؤشرات. وللتطبيقات التي تحتاج إلى أعداد صحيحة حقيقية بطول 64 بت، توجد وحدة Int64 في المكتبة القياسية. وللتطبيقات التي تحتاج إلى أعداد صحيحة بدقة اعتباطية، توجد مكتبة منفصلة هي Zarith. لكن في معظم الحالات يكفي النوع المدمج int ويقدم أفضل أداء.

النوع float: أعداد الفاصلة العائمة. أعداد الفاصلة العائمة في OCaml هي أعداد الفاصلة العائمة المزدوجة الدقة وفق IEEE 754. ومن الناحية الصياغية، يجب أن تحتوي دائمًا على نقطة — على سبيل المثال 3.14 أو 3.0 أو حتى 3.. والأخير عدد float؛ أما إذا كتبته 3، فهو عندئذ int:

3.
- : float = 3.
3
- : int = 3

لا تدعم OCaml تحميل المؤثرات (operator overloading) عن قصد. فتُكتب العمليات الحسابية على أعداد الفاصلة العائمة بنقطة بعد المؤثر. على سبيل المثال، تُكتب عملية الضرب لأعداد الفاصلة العائمة *. لا *:

3.14 *. 2.
- : float = 6.28
3.14 * 2.
File "[7]", line 1, characters 0-4:
1 | 3.14 * 2.
    ^^^^
Error: This expression has type float but an expression was expected of type
         int

لن تحوّل OCaml تلقائيًا بين int وfloat. وإذا أردت التحويل، فهناك دالتان مدمجتان لهذا الغرض: int_of_float وfloat_of_int.

3.14 *. (float_of_int 2)
- : float = 6.28

وكما في أي لغة، فإن تمثيل الفاصلة العائمة تقريبي. وقد يؤدي ذلك إلى أخطاء تقريب:

0.1 +. 0.2
- : float = 0.300000000000000044

ويمكن ملاحظة السلوك نفسه في Python وJava أيضًا. وإذا لم تصادف هذه الظاهرة من قبل، فهذا دليل مبسّط لتمثيل الفاصلة العائمة قد تستمتع بقراءته.

النوع bool: القيم المنطقية. تُكتب القيم المنطقية true وfalse. ومؤثرا العطف && والفصل || المعتادان بقصرهما الدائري (short-circuit) متاحان.

النوع char: المحارف. تُكتب المحارف بين علامتي اقتباس مفردتين، مثل 'a' و'b' و'c'. وهي ممثَّلة بايتات — أي أعداد صحيحة بطول 8 بت — بترميز ISO 8859-1 «Latin-1». والنصف الأول من المحارف في ذلك النطاق هو محارف ASCII القياسية. ويمكنك التحويل من المحارف وإليها باستخدام char_of_int وint_of_char.

النوع string: السلاسل النصية. السلاسل النصية متتاليات من المحارف. وتُكتب بين علامتي اقتباس مزدوجتين، مثل "abc". ومؤثر دمج السلاسل هو ^:

"abc" ^ "def"
- : string = "abcdef"

غالبًا ما توفر اللغات كائنية التوجه طريقة قابلة للتجاوز لتحويل الكائنات إلى سلاسل نصية، مثل toString() في Java أو __str__() في Python. لكن معظم قيم OCaml ليست كائنات، لذا تلزم وسيلة أخرى للتحويل إلى سلاسل نصية. ولثلاثة من الأنواع الأولية توجد دوال مدمجة: string_of_int وstring_of_float وstring_of_bool. ومن الغريب أنه لا توجد دالة string_of_char، لكن يمكن استخدام دالة المكتبة String.make لتحقيق الغرض نفسه.

string_of_int 42
- : string = "42"
String.make 1 'z'
- : string = "z"

وبالمثل، للأنواع الأولية الثلاثة نفسها، توجد دوال مدمجة للتحويل من سلسلة نصية إن أمكن: int_of_string وfloat_of_string وbool_of_string.

int_of_string "123"
- : int = 123
int_of_string "not an int"
Exception: Failure "int_of_string".
Raised by primitive operation at unknown location
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

لا توجد دالة char_of_string، لكن يمكن الوصول إلى المحارف الفردية في السلسلة النصية عبر فهرس يبدأ من 0. ويُكتب مؤثر الفهرسة بنقطة وقوسين معقوفين:

"abc".[0]
- : char = 'a'
"abc".[1]
- : char = 'b'
"abc".[3]
Exception: Invalid_argument "index out of bounds".
Raised by primitive operation at unknown location
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

2.3.2. المزيد من المؤثرات

غطّينا معظم المؤثرات المدمجة أعلاه، لكن هناك القليل غيرها يمكنك رؤيتها في دليل OCaml.

هناك مؤثرا تساوٍ في OCaml هما = و==، ومؤثرا عدم تساوٍ يقابلهما <> و!=. ويفحص المؤثران = و<> التساوي البنيوي (structural equality)، بينما يفحص == و!= التساوي الفيزيائي (physical equality). وإلى أن ندرس الميزات الأمرية في OCaml، سيكون شرح الفرق بينهما صعبًا. راجع التوثيق الخاص بـ Stdlib.(==) إن كنت فضوليًا الآن.

مهم

ابدأ الآن بتدريب نفسك على استخدام = وعدم استخدام ==. وسيكون ذلك صعبًا إذا كنت قادمًا من لغة مثل Java حيث == هو مؤثر التساوي المعتاد.

2.3.3. التأكيدات

يقيّم التعبير assert e التعبير e. فإذا كانت النتيجة true، لم يحدث شيء آخر، ويقيّم التعبير كله إلى قيمة خاصة تسمى الوحدة (unit). وتُكتب قيمة الوحدة () ونوعها unit. لكن إذا كانت النتيجة false، فيُطلق استثناء.

ومن طرق اختبار دالة f كتابة سلسلة من التأكيدات كهذه:

let () = assert (f input1 = output1)
let () = assert (f input2 = output2)
let () = assert (f input3 = output3)

تؤكد هذه أنه ينبغي أن يكون f input1 مساويًا output1، وهكذا. ويُستخدم الجزء let () = ... منها للتعامل مع قيمة الوحدة التي يعيدها كل تأكيد.

2.3.4. تعبيرات if

يقيّم التعبير if e1 then e2 else e3 إلى e2 إذا قيّم e1 إلى true، وإلى e3 خلاف ذلك. ونسمي e1 الحارس (guard) لتعبير if.

if 3 + 5 > 2 then "yay!" else "boo!"
- : string = "yay!"

وعلى عكس عبارات if-then-else التي ربما استخدمتها في لغات أمرية، فإن تعبيرات if-then-else في OCaml هي كأي تعبير آخر؛ يمكن وضعها في أي موضع يمكن أن يوجد فيه تعبير. وهذا يجعلها شبيهة بالمؤثر الثلاثي ? : الذي ربما استخدمته في لغات أخرى.

4 + (if 'a' = 'b' then 1 else 2)
- : int = 6

ويمكن تداخل تعبيرات if بطريقة أنيقة:

if e1 then e2
else if e3 then e4
else if e5 then e6
...
else en

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

if 2 > 3 then 5
File "[20]", line 1, characters 14-15:
1 | if 2 > 3 then 5
                  ^
Error: This expression has type int but an expression was expected of type
         unit
       because it is in the result of a conditional with no else branch

الصياغة. صياغة تعبير if:

if e1 then e2 else e3

يُستخدم الحرف e هنا لتمثيل أي تعبير OCaml آخر؛ وهو مثال على متغير صياغي (syntactic variable) أي متغير فوقي (metavariable)، وهو ليس متغيرًا فعليًا في لغة OCaml نفسها، بل اسم لبنية صياغية معيّنة. وتُستخدم الأرقام بعد الحرف e لتمييز المواضع الثلاثة المختلفة منه.

الدلالات الديناميكية. الدلالات الديناميكية لتعبير if:

  • إذا قيّم e1 إلى true، وإذا قيّم e2 إلى قيمة v، فإن if e1 then e2 else e3 يقيّم إلى v
  • إذا قيّم e1 إلى false، وإذا قيّم e3 إلى قيمة v، فإن if e1 then e2 else e3 يقيّم إلى v.

ونسمي هذه قواعد التقييم (evaluation rules): فهي تحدد كيفية تقييم التعبيرات. لاحظ أنها تحتاج إلى قاعدتين لوصف تقييم تعبير if، واحدة عندما يكون الحارس صحيحًا، وأخرى عندما يكون الحارس خاطئًا. ويُستخدم الحرف v هنا لتمثيل أي قيمة OCaml؛ وهو مثال آخر على المتغير الفوقي. وسنطوّر لاحقًا طريقة أكثر رياضية للتعبير عن الدلالات الديناميكية، لكننا سنكتفي الآن بهذا الأسلوب الأقل رسمية في الشرح.

الدلالات الساكنة. الدلالات الساكنة لتعبير if:

  • إذا كان e1 من النوع bool وكان e2 من النوع t وكان e3 من النوع t، فإن if e1 then e2 else e3 يكون من النوع t

ونسمي هذه قاعدة كتابة الأنواع (typing rule): فهي تصف كيفية فحص أنواع تعبير ما. لاحظ أنها تحتاج إلى قاعدة واحدة فقط لوصف فحص أنواع تعبير if. وفي وقت التصريف، عند إجراء فحص الأنواع، لا فرق بين أن يكون الحارس صحيحًا أو خاطئًا؛ بل لا سبيل للمصرّف إلى معرفة القيمة التي سيكون عليها الحارس في وقت التشغيل. ويُستخدم الحرف t هنا لتمثيل أي نوع OCaml؛ ويحتوي دليل OCaml أيضًا على تعريف لجميع الأنواع (وهو، على نحو مثير للفضول، لا يسمي الأنواع الأساسية للغة مثل int وbool).

سنكتب «له النوع» كثيرًا، فلنستحدث ترميزًا أكثر إيجازًا له. فكلما أردنا كتابة «e له النوع t»، فلنكتب بدلًا من ذلك e : t. وتُلفظ النقطتان «له النوع». وهذا الاستخدام للنقطتين متوافق مع كيفية استجابة الواجهة التفاعلية بعد تقييمها تعبيرًا تدخله:

let x = 42
val x : int = 42

في المثال أعلاه، المتغير x له النوع int، وهو ما تشير إليه النقطتان.

2.3.5. تعبيرات let

في استخدامنا لكلمة let حتى الآن، كنا نجرّد تعريفات في الواجهة التفاعلية وفي ملفات ‎.ml. على سبيل المثال،

let x = 42;;
val x : int = 42

يعرّف x على أنها 42، وبعد ذلك يمكننا استخدام x في تعريفات مستقبلية في الواجهة التفاعلية. وسنسمي هذا الاستخدام لـ let تعريف let (let definition).

وهناك استخدام آخر لـ let يكون بوصفه تعبيرًا:

let x = 42 in x + 1
- : int = 43

هنا نعمل على ربط قيمة بالاسم x ثم استخدام ذلك الربط داخل تعبير آخر هو x+1. وسنسمي هذا الاستخدام لـ let تعبير let (let expression). ولأنه تعبير، فإنه يقيّم إلى قيمة. وهذا يختلف عن التعريفات، التي لا تقيّم هي نفسها إلى أي قيمة. ويمكنك رؤية ذلك إن جرّبت وضع تعريف let في موضع يُتوقع فيه تعبير:

(let x = 42) + 1
File "[24]", line 1, characters 11-12:
1 | (let x = 42) + 1
               ^
Error: Syntax error

فمن الناحية الصياغية، لا يُسمح بتعريف let على الجانب الأيسر من المؤثر +، لأن قيمة مطلوبة هناك، والتعريفات لا تقيّم إلى قيم. وفي المقابل، سيعمل تعبير let بشكل جيد:

(let x = 42 in x) + 1
- : int = 43

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

# let a = "big";;
# let b = "red";;
# let c = a ^ b;;
# ...

يفهمها OCaml بالطريقة نفسها التي يفهم بها

let a = "big" in
let b = "red" in
let c = a ^ b in
...

وتلك السلسلة الأخيرة من ارتباطات let هي الطريقة الاصطلاحية لربط عدة متغيرات داخل كتلة شيفرة معيّنة.

الصياغة.

let x = e1 in e2

كما هي العادة، x معرّف. ويجب أن تبدأ هذه المعرّفات بحرف صغير لا كبير، وتُكتب اصطلاحيًا بنمط snake_case لا camelCase. ونسمي e1 تعبير الربط (binding expression)، لأنه ما يُربط بـ x؛ ونسمي e2 تعبير الجسم (body expression)، لأنه جسم الشيفرة الذي سيكون الربط داخل نطاقه.

الدلالات الديناميكية.

لتقييم let x = e1 in e2:

  • قيّم e1 إلى قيمة v1.
  • عوّض v1 محل x في e2، فينتج تعبير جديد e2'.
  • قيّم e2' إلى قيمة v2.
  • نتيجة تقييم تعبير let هي v2.

وإليك مثالًا:

    let x = 1 + 4 in x * 3
-->   (evaluate e1 to a value v1)
    let x = 5 in x * 3
-->   (substitute v1 for x in e2, yielding e2')
    5 * 3
-->   (evaluate e2' to v2)
    15
      (result of evaluation is v2)

الدلالات الساكنة.

  • إذا كان e1 : t1، وإذا صحّ، بافتراض أن x : t1، أن e2 : t2، فإن (let x = e1 in e2) : t2.

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

let x : t = e1 in e2

2.3.6. النطاق

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

let x = 42 in
  (* y is not meaningful here *)
  x + (let y = "3110" in
         (* y is meaningful here *)
         int_of_string y)

نطاق (scope) المتغير هو الموضع الذي يكون فيه اسمه ذا معنى. فالمتغير y لا يكون في النطاق إلا داخل تعبير let الذي يربطه أعلاه.

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

let x = 5 in
  ((let x = 6 in x) + x)

لكن هذا مربك للغاية، ولهذا السبب يُنصح بشدة بتجنّبه. ومع ذلك، لننظر فيما يعنيه ذلك الكود.

إلى أي قيمة يقيّم ذلك الكود؟ تعود الإجابة إلى كيفية استبدال x بقيمة في كل مرة يظهر فيها. وإليك بعض الاحتمالات لذلك الإحلال (substitution):

(* possibility 1 *)
let x = 5 in
  ((let x = 6 in 6) + 5)
(* possibility 2 *)
let x = 5 in
  ((let x = 6 in 5) + 5)
(* possibility 3 *)
let x = 5 in
  ((let x = 6 in 6) + 6)

الأول هو ما تفعله تقريبًا أي لغة معقولة. وعلى الأرجح هو ما كنت ستخمّنه. لكن، لماذا؟

الإجابة هي ما سنسميه مبدأ عدم أهمية الاسم (Principle of Name Irrelevance): إذ لا ينبغي أن يكون لاسم المتغير أهمية جوهرية. وأنت معتاد على ذلك من الرياضيات. على سبيل المثال، الدالتان التاليتان متماثلتان:

f(x)=x2f(y)=y2\begin{align*} f(x) &= x^2 \\ f(y) &= y^2 \end{align*}

لا يهم جوهريًا أن نسمي وسيط الدالة xx أو yy؛ ففي الحالتين تبقى دالة التربيع. لذا ينبغي أن تكون هاتان الدالتان في البرامج متطابقتين:

let f x = x * x
let f y = y * y

وُيعرف هذا المبدأ على نحو أكثر شيوعًا باسم التكافؤ الألفي (alpha equivalence): فالدالتان متكافئتان حتى إعادة تسمية المتغيرات، وهو ما يسمى أيضًا التحويل الألفي (alpha conversion) لأسباب تاريخية لا تهمنا هنا.

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

let x = 6 in x
let y = 6 in y

لذا ينبغي أن يكون التعبيران التاليان، اللذان يحتويان على التعبيرين أعلاه، متطابقين أيضًا:

let x = 5 in (let x = 6 in x) + x
let x = 5 in (let y = 6 in y) + x

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

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

التظليل ليس إسنادًا يغيّر قيمة متغير. على سبيل المثال، كلا التعبيرين التاليين يقيّم إلى 11:

let x = 5 in ((let x = 6 in x) + x)
let x = 5 in (x + (let x = 6 in x))

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

# let x = 42;;
val x : int = 42
# let x = 22;;
val x : int = 22

تذكّر أن كل تعريف let في الواجهة التفاعلية هو فعليًا تعبير let متداخل. لذا فما سبق هو فعليًا ما يلي:

let x = 42 in
  let x = 22 in
    ... (* whatever else is typed in the toplevel *)

والطريقة الصحيحة للتفكير في هذا هي أن let الثانية تربط متغيرًا جديدًا تمامًا يصادف أنه يحمل الاسم نفسه الذي تحمله let الأولى.

وإليك نص جلسة utop آخر يجدر بنا دراسته جيدًا:

# let x = 42;;
val x : int = 42
# let f y = x + y;;
val f : int -> int = <fun>
# f 0;;
: int = 42
# let x = 22;;
val x : int = 22
# f 0;;
- : int = 42  (* x did not mutate! *)

وخلاصة القول، كل تعريف let يربط متغيرًا جديدًا تمامًا. وإذا صادف أن المتغير الجديد يحمل الاسم نفسه الذي يحمله متغير قديم، فإن المتغير الجديد يظلّل القديم مؤقتًا. لكن المتغير القديم لا يزال موجودًا، وقيمته غير قابلة للتغيير: فهي لا تتغير أبدًا، مطلقًا. لذا فرغم أن تعبيرات let قد تبدو سطحيًا كعبارات إسناد من لغات أمرية، فإنها مختلفة عنها تمامًا.

2.3.7. التعليقات النوعية

تستنتج OCaml تلقائيًا نوع كل تعبير، دون حاجة المبرمج إلى كتابته يدويًا. ومع ذلك، قد يكون من المفيد أحيانًا تحديد النوع المطلوب لتعبير ما يدويًا. ويقوم التعليق النوعي (type annotation) بذلك:

(5 : int)
- : int = 5

وسيؤدي تعليق غير صحيح إلى خطأ في وقت التصريف:

(5 : float)
File "[27]", line 1, characters 1-2:
1 | (5 : float)
     ^
Error: This expression has type int but an expression was expected of type
         float
  Hint: Did you mean `5.'?

ويوضح ذلك المثال سبب رغبتك في استخدام التعليقات النوعية اليدوية أثناء التنقيح. فلعلّك كنت قد نسيت أن 5 لا يمكن معاملتها بوصفها float، وحاولت كتابة:

5 +. 1.1

وقد تجرّب أن تحدد يدويًا أنه كان يُفترض أن يكون 5 من النوع float:

(5 : float) +. 1.1
File "[28]", line 1, characters 1-2:
1 | (5 : float) +. 1.1
     ^
Error: This expression has type int but an expression was expected of type
         float
  Hint: Did you mean `5.'?

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

مهم

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

الصياغة. صياغة التعليق النوعي:

(e : t)

لاحظ أن الأقواس مطلوبة.

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

الدلالات الساكنة. إذا كان e من النوع t فإن (e : t) يكون من النوع t.

2.4. الدوال

بما أن OCaml لغة وظيفية، فهناك الكثير لتغطيته عن الدوال. فلنبدأ.

مهم

الطرق (methods) والدوال ليستا الفكرة نفسها. فالطريقة مكوّن من كائن، ولها ضمنيًا مستقبِل (receiver) يُوصل إليه عادةً بكلمة مفتاحية مثل this أو self. أما دوال OCaml فليست طرقًا: فهي ليست مكونات لكائنات، وليس لها مستقبِل.

قد يقول بعضهم إن كل الطرق دوال، لكن ليست كل الدوال طرقًا. وقد يجادل بعضهم حتى في ذلك، فيميّز بين الدوال والإجراءات (procedures). والأخيرة ستكون دوالًا لا تعيد أي قيمة ذات معنى، مثل نوع الإرجاع void في Java أو قيمة الإرجاع None في Python.

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

2.4.1. تعريفات الدوال

الكود التالي

let x = 42

يحتوي على تعبير في داخله (42) لكنه ليس تعبيرًا في نفسه. بل هو تعريف (definition). تربط التعريفات القيم بالأسماء، وفي هذه الحالة تُربط القيمة 42 بالاسم x. ويصف دليل OCaml التعريفات (انظر المجموعة الرئيسية الثالثة المعنونة «definition» في تلك الصفحة)، لكن صفحة الدليل تلك مخصصة أيضًا أساسًا للرجوع لا للمدارسة. والتعريفات ليست تعبيرات، والتعبيرات ليست تعريفات — فهي أصناف صياغية متمايزة.

في الوقت الحالي، لنركّز على نوع معيّن من التعريفات هو تعريف الدالة (function definition). وتُعرّف الدوال غير التعاودية هكذا:

let f x = ...

وتُعرّف الدوال التعاودية هكذا:

let rec f x = ...

والفرق هو مجرد الكلمة المفتاحية rec. وربما يكون مفاجئًا بعض الشيء أن تضطر صراحةً إلى إضافة كلمة مفتاحية لجعل الدالة تعاودية، لأن معظم اللغات تفترض ذلك افتراضيًا. لكن OCaml لا يفترض ذلك. (ولا تفترضه عائلة لغات Scheme أيضًا.)

ومن أشهر الدوال التعاودية دالة المضروب (factorial). ويمكن كتابتها في OCaml على النحو التالي:

(** [fact n] is [n!].
    Requires: [n >= 0]. *)
let rec fact n = if n = 0 then 1 else n * fact (n - 1)
val fact : int -> int = <fun>

وقد قدّمنا تعليق مواصفة فوق الدالة لتوثيق الشرط المسبق (Requires) والشرط اللاحق (is) للدالة.

لاحظ أنه، كما في لغات كثيرة، ليست الأعداد الصحيحة في OCaml هي الأعداد الصحيحة «الرياضية»، بل هي محدودة بعدد ثابت من البتات. ويحدد الدليل أن الأعداد الصحيحة (المُوقَّعة) لا تقل عن 31 بت، لكنها قد تكون أوسع. ومع تطور المعماريات، تطور ذلك الحجم أيضًا. وفي التنفيذات الحالية، تكون الأعداد الصحيحة في OCaml بطول 63 بت. لذا إذا اختبرت على مدخلات كبيرة بما يكفي، فقد تبدأ في رؤية نتائج غريبة. فالمشكلة مشكلة حساب الآلة، لا مشكلة OCaml. (وللقراء المهتمين: لماذا 31 أو 63 بدلًا من 32 أو 64؟ لأن جامع القمامة في OCaml يحتاج إلى التمييز بين الأعداد الصحيحة والمؤشرات. لذا يسرق التمثيل في وقت التشغيل بتة واحدة للإشارة إلى ما إذا كانت الكلمة عددًا صحيحًا أم مؤشرًا.)

وإليك دالة تعاودية أخرى:

(** [pow x y] is [x] to the power of [y].
     Requires: [y >= 0]. *)
let rec pow x y = if y = 0 then 1 else x * pow x (y - 1)
val pow : int -> int -> int = <fun>

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

  • بما أن تعبير if يمكن أن يعيد 1 في فرع then، فنحن نعلم من قاعدة كتابة الأنواع الخاصة بـ if أن تعبير if كله من النوع int.
  • وبما أن تعبير if من النوع int، فيجب أن يكون نوع إرجاع الدالة int.
  • وبما أن y تُقارن بـ 0 بمؤثر التساوي، فيجب أن تكون y من النوع int.
  • وبما أن x تُضرب في تعبير آخر بمؤثر *، فيجب أن تكون x من النوع int.

ولو أردنا كتابة الأنواع لسبب ما، لاستطعنا فعل ذلك:

let rec pow (x : int) (y : int) : int = ...

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

الصياغة. صياغة تعريفات الدوال:

let rec f x1 x2 ... xn = e

الحرف f متغير فوقي يشير إلى معرّف يُستخدم اسمًا لدالة. ويجب أن تبدأ هذه المعرّفات بحرف صغير. ويمكن العثور على بقية قواعد المعرّفات بحروف صغيرة في الدليل. والأسماء x1 حتى xn متغيرات فوقية تشير إلى معرّفات الوسائط. وهي تتبع القواعد نفسها التي تتبعها معرّفات الدوال. والكلمة المفتاحية rec مطلوبة إذا أردنا أن تكون f دالة تعاودية؛ وإلا فيمكن حذفها.

لاحظ أن صياغة تعريفات الدوال مبسّطة فعليًا مقارنةً بما تسمح به OCaml حقًا. وسنتعلم المزيد عن بعض الصياغات الموسّعة لتعريف الدوال في الأسبوعين القادمين. لكن هذه النسخة المبسّطة ستساعدنا الآن على التركيز.

ويمكن تعريف الدوال التعاودية المتبادلة بالكلمة المفتاحية and:

let rec f x1 ... xn = e1
and g y1 ... yn = e2

على سبيل المثال:

(** [even n] is whether [n] is even.
    Requires: [n >= 0]. *)
let rec even n =
  n = 0 || odd (n - 1)
(** [odd n] is whether [n] is odd.
    Requires: [n >= 0]. *)
and odd n =
  n <> 0 && even (n - 1);;
val even : int -> bool = <fun>
val odd : int -> bool = <fun>

وصياغة أنواع الدوال هي:

t -> u
t1 -> t2 -> u
t1 -> ... -> tn -> u

الحرفان t وu متغيران فوقيان يشيران إلى أنواع. والنوع t -> u هو نوع دالة تأخذ مدخلًا من النوع t وتعيد مخرجًا من النوع u. ويمكن أن نفكر في t1 -> t2 -> u على أنه نوع دالة تأخذ مدخلين، الأول من النوع t1 والثاني من النوع t2، وتعيد مخرجًا من النوع u. وكذلك الأمر في دالة تأخذ n من الوسائط.

الدلالات الديناميكية. لا توجد دلالات ديناميكية لتعريفات الدوال. فلا يوجد شيء يُقيَّم. كل ما تفعله OCaml هو تسجيل أن الاسم f مرتبط بدالة لها الوسائط المعطاة x1..xn والجسم المعطى e. وسيكون هناك تقييم يجب إجراؤه لاحقًا فقط، عند تطبيق الدالة.

الدلالات الساكنة. الدلالات الساكنة لتعريفات الدوال:

  • بالنسبة للدوال غير التعاودية: إذا أمكننا، بافتراض أن x1 : t1 وx2 : t2 و… وxn : tn، أن نستنتج أن e : u، فإن f : t1 -> t2 -> ... -> tn -> u.
  • بالنسبة للدوال التعاودية: إذا أمكننا، بافتراض أن x1 : t1 وx2 : t2 و… وxn : tn وf : t1 -> t2 -> ... -> tn -> u، أن نستنتج أن e : u، فإن f : t1 -> t2 -> ... -> tn -> u.

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

2.4.2. الدوال المجهولة

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

42
- : int = 42

أو يمكننا ربطه باسم:

let x = 42
val x : int = 42

وبالمثل، لا يلزم أن تكون لدوال OCaml أسماء؛ فقد تكون مجهولة (anonymous). على سبيل المثال، إليك دالة مجهولة تزيد مدخلها بمقدار واحد: fun x -> x + 1. هنا fun كلمة مفتاحية تدل على دالة مجهولة، وx هو الوسيط، و-> يفصل الوسيط عن الجسم.

وأصبح لدينا الآن طريقتان لكتابة دالة الزيادة:

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

وهما مختلفتان صياغيًا لكنهما متكافئتان دلاليًا. أي أنهما، رغم استخدامهما كلمات مفتاحية مختلفة ووضع بعض المعرّفات في مواضع مختلفة، تعنيان الشيء نفسه.

وتسمى الدوال المجهولة أيضًا تعبيرات لامبدا (lambda expressions)، وهو مصطلح يأتي من حساب لامبدا (lambda calculus)، وهو نموذج رياضي للحساب بالمعنى نفسه الذي تُعدّ فيه آلات تورينغ نموذجًا للحساب. وفي حساب لامبدا، تُكتب fun x -> e هكذا λx.e\lambda x . e. ويرمز λ\lambda إلى دالة مجهولة.

قد يبدو الآن غامضًا بعض الشيء سبب رغبتنا في دوال بلا أسماء. لا تقلق؛ سنرى استخدامات جيدة لها لاحقًا في المقرر، خصوصًا عندما ندرس ما يسمى «البرمجة من الرتبة العليا». وعلى وجه الخصوص، سننشئ غالبًا دوال مجهولة ونمررها كمدخلات إلى دوال أخرى.

الصياغة.

fun x1 ... xn -> e

الدلالات الساكنة.

  • إذا أمكننا، بافتراض أن x1 : t1 وx2 : t2 و… وxn : tn، أن نستنتج أن e : u، فإن fun x1 ... xn -> e : t1 -> t2 -> ... -> tn -> u.

الدلالات الديناميكية. الدالة المجهولة قيمة بالفعل. فلا يوجد حساب يجب إجراؤه.

2.4.3. تطبيق الدوال

نغطي هنا صياغة مبسّطة بعض الشيء لتطبيق الدوال مقارنةً بما تسمح به OCaml فعليًا.

الصياغة.

e0 e1 e2 ... en

التعبير الأول e0 هو الدالة، ويُطبَّق على الوسائط e1 حتى en. لاحظ أن الأقواس غير مطلوبة حول الوسائط للإشارة إلى تطبيق الدالة، على عكس ما هو الحال في لغات عائلة C، ومنها Java.

الدلالات الساكنة.

  • إذا كان e0 : t1 -> ... -> tn -> u وكان e1 : t1 و… وen : tn، فإن e0 e1 ... en : u.

الدلالات الديناميكية.

لتقييم e0 e1 ... en:

  1. قيّم e0 إلى دالة. وقيّم أيضًا تعبيرات الوسائط e1 حتى en إلى قيم v1 حتى vn. وبالنسبة إلى e0، قد تكون النتيجة دالة مجهولة fun x1 ... xn -> e أو اسمًا f. وفي الحالة الأخيرة، نحتاج إلى إيجاد تعريف f، ويمكننا افتراض أنه على الصيغة let rec f x1 ... xn = e. وفي الحالتين، نعرف الآن أسماء الوسائط x1 حتى xn والجسم e.
  2. عوّض كل قيمة vi محل اسم الوسيط المقابل xi في الجسم e للدالة. وينتج عن ذلك الإحلال تعبير جديد e'.
  3. قيّم e' إلى قيمة v، وهي نتيجة تقييم e0 e1 ... en.

وإذا قارنت قواعد التقييم هذه بقواعد تعبيرات let، فستلاحظ أن كليهما يتضمن إحلالًا. وهذا ليس مصادفة. ففي الواقع، في أي موضع يظهر فيه let x = e1 in e2 في برنامج، يمكننا استبداله بـ (fun x -> e2) e1. وهما مختلفان صياغيًا لكنهما متكافئان دلاليًا. وفي جوهره، تعبيرات let مجرد سكر صياغي (syntactic sugar) لتطبيق دالة مجهولة.

2.4.4. خط الأنابيب

هناك مؤثر بيني (infix) مدمج في OCaml لتطبيق الدوال يسمى مؤثر خط الأنابيب (pipeline)، ويُكتب |>. تخيّله كأنه يمثل مثلثًا يشير إلى اليمين. والاستعارة هنا أن القيم تُرسَل عبر خط الأنابيب من اليسار إلى اليمين. على سبيل المثال، افترض أن لدينا دالة الزيادة inc من قبل، وكذلك دالة square التي تربّع مدخلها:

let square x = x * x
val square : int -> int = <fun>

وإليك طريقتين متكافئتين لتربيع 6:

square (inc 5);;
5 |> inc |> square;;
- : int = 36
- : int = 36

تستخدم الطريقة الأخيرة مؤثر خط الأنابيب لإرسال 5 عبر الدالة inc، ثم إرسال نتيجة ذلك عبر الدالة square. وهذه طريقة أنيقة واصطلاحية للتعبير عن الحساب في OCaml. أما الطريقة الأولى فليست بنفس الأناقة على الأرجح: فهي تتضمن كتابة أقواس إضافية وتتطلب من عيني القارئ التنقل هنا وهناك، بدلًا من التحرك خطيًا من اليسار إلى اليمين. وتتوسع الطريقة الأخيرة بشكل جميل عندما يزداد عدد الدوال المطبَّقة، بينما تتطلب الطريقة الأولى المزيد والمزيد من الأقواس:

5 |> inc |> square |> inc |> inc |> square;;
square (inc (inc (square (inc 5))));;
- : int = 1444
- : int = 1444

قد يبدو الأمر غريبًا في البداية، لكن جرّب استخدام مؤثر خط الأنابيب في شيفرتك الخاصة في المرة التالية التي تجد نفسك فيها تكتب سلسلة طويلة من تطبيقات الدوال.

وبما أن e1 |> e2 مجرد طريقة أخرى لكتابة e2 e1، فلا حاجة إلى ذكر دلالات |>: فهي تمامًا كتطبيق الدالة. وهذان البرنامجان مثال آخر على تعبيرات مختلفة صياغيًا لكن متكافئة دلاليًا.

2.4.5. الدوال متعددة الأشكال

دالة الهوية (identity) هي الدالة التي تعيد مدخلها فحسب:

let id x = x
val id : 'a -> 'a = <fun>

أو ما يكافئها كدالة مجهولة:

let id = fun x -> x
val id : 'a -> 'a = <fun>

الحرف 'a هو متغير نوعي (type variable): فهو يشير إلى نوع غير معروف، تمامًا كما يشير المتغير العادي إلى قيمة غير معروفة. وتبدأ المتغيرات النوعية دائمًا بعلامة اقتباس مفردة. ومن المتغيرات النوعية الشائعة الاستخدام 'a و'b و'c، ويلفظها مبرمجو OCaml عادةً بالأحرف اليونانية: ألفا وبيتا وغاما.

يمكننا تطبيق دالة الهوية على أي نوع من القيم نريده:

id 42;;
id true;;
id "bigred";;
- : int = 42
- : bool = true
- : string = "bigred"

ولأنك تستطيع تطبيق id على أنواع كثيرة من القيم، فهي دالة متعددة الأشكال (polymorphic): أي يمكن تطبيقها على أشكال (morph) كثيرة (poly).

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

let id_int (x : int) : int = x
val id_int : int -> int = <fun>

هذه هي الدالة نفسها التي id، إلا من حيث التعليقين النوعيين اليدويين. وبسببهما، لا يمكننا تطبيق id_int على bool كما فعلنا مع id:

id_int true
File "[14]", line 1, characters 7-11:
1 | id_int true
           ^^^^
Error: The constructor true has type bool
       but an expression was expected of type int

وثمة طريقة أخرى لكتابة id_int من حيث id:

let id_int' : int -> int = id
val id_int' : int -> int = <fun>

فعليًا، أخذنا قيمة من النوع 'a -> 'a، وربطناها باسم حُدد نوعه يدويًا بأنه int -> int. وقد تسأل: لماذا يعمل ذلك؟ فالنوعان ليسا متماثلين بعد كل شيء.

إحدى طرق التفكير في هذا هي من حيث السلوك. يحدد نوع id_int جانبًا من سلوكها: فبالنظر إلى int كمدخل، تعد بإنتاج int كمخرج. وتبين أن id تقدم الوعد نفسه أيضًا: فبالنظر إلى int كمدخل، ستعيد هي أيضًا int كمخرج. لكن id تقدم كذلك وعودًا كثيرة إضافية، مثل: بالنظر إلى bool كمدخل، ستعيد bool كمخرج. لذا، بربط id بنوع أكثر تقييدًا هو int -> int، نكون قد تخلّينا عن كل تلك الوعود الإضافية باعتبارها غير ذات صلة. صحيح أن تلك معلومات فُقدت، لكن لن تُخرَق أي وعود على الأقل. وسيكون استخدام دالة من النوع 'a -> 'a آمنًا دائمًا عندما نحتاج إلى دالة من النوع int -> int.

أما العكس فغير صحيح. فإذا احتجنا إلى دالة من النوع 'a -> 'a لكن حاولنا استخدام دالة من النوع int -> int، فسنقع في مشكلة بمجرد أن يمرر أحدهم مدخلًا من نوع آخر مثل bool. ولمنع تلك المشكلة، تفعل OCaml شيئًا قد يكون مفاجئًا مع الشيفرة التالية:

let id' : 'a -> 'a = fun x -> x + 1
val id' : int -> int = <fun>

الدالة id' هي في الواقع دالة الزيادة، لا دالة الهوية. لذا فتمرير bool أو string أو بنية بيانات معقدة إليها غير آمن؛ فالمعطيات الوحيدة التي يمكن لمؤثر + التعامل معها بأمان هي الأعداد الصحيحة. ولذلك تُجسّد OCaml المتغير النوعي 'a إلى int، فتمنعنا بذلك من تطبيق id' على غير الأعداد الصحيحة:

id' true
File "[17]", line 1, characters 4-8:
1 | id' true
        ^^^^
Error: The constructor true has type bool
       but an expression was expected of type int

وهذا يقودنا إلى طريقة أخرى أكثر آليّة للتفكير في كل هذا من حيث التطبيق. ونعني بذلك الفكرة نفسها لكيفية تطبيق دالة على الوسائط: فعند تقييم التطبيق id 5، يُجسّد الوسيط x بالقيمة 5. وبالمثل، يُجسّد 'a في نوع id بالنوع int عند ذلك التطبيق. لذا إذا كتبنا

let id_int' : int -> int = id
val id_int' : int -> int = <fun>

فنحن في الواقع نجسّد 'a في نوع id بالنوع int. وكما أنه لا سبيل إلى «إلغاء تطبيق» دالة — فمثلًا، بالنظر إلى 5 لا يمكننا الحساب عكسيًا للوصول إلى id 5 — لا يمكننا إلغاء تجسيد النوع ذاك وإعادة int إلى 'a.

ولجعل ذلك دقيقًا، افترض أن لدينا تعريف let [أو تعبير let]:

let x = e [in e']

وأن OCaml يستنتج أن x له نوع t يتضمن بعض المتغيرات النوعية 'a و'b وغيرها. عندئذ يُسمح لنا بتجسيد تلك المتغيرات النوعية. ويمكننا فعل ذلك بتطبيق الدالة على وسائط تكشف ما ينبغي أن تكون عليه تجسيدات الأنواع (كما في id 5) أو بتعليق نوعي (كما في id_int')، بين طرق أخرى. لكن يجب أن نكون متسقين في التجسيد. على سبيل المثال، لا يمكننا أخذ دالة من النوع 'a -> 'b -> 'a وتجسيدها بالنوع int -> 'b -> string، لأن تجسيد 'a ليس النوع نفسه في كلا الموضعين اللذين ظهر فيهما:

let first x y = x;;
let first_int : int -> 'b -> int = first;;
let bad_first : int -> 'b -> string = first;;
val first : 'a -> 'b -> 'a = <fun>
val first_int : int -> 'b -> int = <fun>
File "[19]", line 3, characters 38-43:
3 | let bad_first : int -> 'b -> string = first;;
                                          ^^^^^
Error: The value first has type int -> 'b -> int
       but an expression was expected of type int -> 'b -> string
       Type int is not compatible with type string

2.4.6. الوسائط المسمّاة والاختيارية

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

String.sub;;
- : string -> int -> int -> string = <fun>

لكن ليس واضحًا من النوع كيفية استخدامها — إذ تُضطر إلى الرجوع إلى التوثيق.

تدعم OCaml الوسائط المسمّاة (labeled arguments) للدوال. ويمكنك تعريف هذا النوع من الدوال بالصياغة التالية:

let f ~name1:arg1 ~name2:arg2 = arg1 + arg2;;
val f : name1:int -> name2:int -> int = <fun>

ويمكن استدعاء هذه الدالة بتمرير الوسيطين المسمّيين بأي ترتيب:

f ~name2:3 ~name1:4

غالبًا ما تكون تسميات الوسائط نفسها أسماء المتغيرات الخاصة بها. وتوفر OCaml اختصارًا لهذه الحالة. والتاليان متكافئان:

let f ~name1:name1 ~name2:name2 = name1 + name2
let f ~name1 ~name2 = name1 + name2

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

وصياغة كتابة وسيط مسمّى وتعليق نوعي صريح له معًا هي:

let f ~name1:(arg1 : int) ~name2:(arg2 : int) = arg1 + arg2

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

let f ?name:(arg1=8) arg2 = arg1 + arg2
val f : ?name:int -> int -> int = <fun>

ويمكنك بعدها تطبيق الدالة مع الوسيط أو دونه:

f ~name:2 7
- : int = 9
f 7
- : int = 15

2.4.7. التطبيق الجزئي

يمكننا تعريف دالة جمع على النحو التالي:

let add x y = x + y
val add : int -> int -> int = <fun>

وإليك دالة مشابهة إلى حد كبير:

let addx x = fun y -> x + y
val addx : int -> int -> int = <fun>

تأخذ الدالة addx عددًا صحيحًا x كمدخل وتعيد دالة من النوع int -> int ستضيف x إلى أي شيء يُمرَّر إليها.

نوع addx هو int -> int -> int. ونوع add هو أيضًا int -> int -> int. لذا فهما، من منظور نوعيهما، الدالة نفسها. لكن صيغة addx تشير إلى شيء مثير للاهتمام: يمكننا تطبيقها على وسيط واحد فقط.

let add5 = addx 5
val add5 : int -> int = <fun>
add5 2
- : int = 7

وتبين أنه يمكن فعل الشيء نفسه مع add:

let add5 = add 5
val add5 : int -> int = <fun>
add5 2;;
- : int = 7

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

let add x y = x + y
let add x = fun y -> x + y
let add = fun x -> (fun y -> x + y)

لذا فإن add في الحقيقة دالة تأخذ وسيطًا x وتعيد دالة (fun y -> x + y). وهذا يقودنا إلى حقيقة عميقة…

2.4.8. ترابطية الدوال

هل أنت مستعد للحقيقة؟ خذ نفسًا عميقًا. إليك الأمر…

كل دالة OCaml تأخذ وسيطًا واحدًا بالضبط.

لماذا؟ انظر إلى add: فرغم أننا نستطيع كتابتها let add x y = x + y، نعلم أن ذلك مكافئ دلاليًا لـ let add = fun x -> (fun y -> x + y). وبشكل عام،

let f x1 x2 ... xn = e

مكافئة دلاليًا لـ

let f =
  fun x1 ->
    (fun x2 ->
       (...
          (fun xn -> e)...))

لذا فحتى لو كنت تعتبر f دالة تأخذ n من الوسائط، فهي في الواقع دالة تأخذ وسيطًا واحدًا وتعيد دالة.

ونوع دالة كهذه

t1 -> t2 -> t3 -> t4

يعني في الحقيقة الشيء نفسه الذي يعنيه

t1 -> (t2 -> (t3 -> t4))

أي أن أنواع الدوال يمينية الترابط (right associative): فهناك أقواس ضمنية حول أنواع الدوال، من اليمين إلى اليسار. والحدس هنا أن الدالة تأخذ وسيطًا واحدًا وتعيد دالة جديدة تتوقع بقية الوسائط.

أما تطبيق الدوال، في المقابل، فهو يساري الترابط (left associative): فهناك أقواس ضمنية حول تطبيقات الدوال، من اليسار إلى اليمين. لذا فإن

e1 e2 e3 e4

يعني في الحقيقة الشيء نفسه الذي يعنيه

((e1 e2) e3) e4

والحدس هنا أن التعبير الأقصى يسارًا يلتقط التعبير التالي على يمينه بوصفه وسيطه الوحيد.

2.4.9. المؤثرات كدوال

نوع مؤثر الجمع + هو int -> int -> int. وهو يُكتب عادةً بينيًا (infix)، مثل 3 + 4. وبوضع أقواس حوله، يمكننا جعله مؤثرًا بادئيًا (prefix):

( + )
- : int -> int -> int = <fun>
( + ) 3 4;;
- : int = 7
let add3 = ( + ) 3
val add3 : int -> int = <fun>
add3 2
- : int = 5

وتنجح التقنية نفسها مع أي مؤثر مدمج.

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

ويمكننا حتى تعريف مؤثرات بينية جديدة خاصة بنا، على سبيل المثال:

let ( ^^ ) x y = max x y

والآن يقيّم 2 ^^ 3 إلى 3.

وقواعد علامات الترقيم التي يمكن استخدامها لإنشاء مؤثرات بينية ليست بديهية بالضرورة. ولا الأسبقية النسبية التي ستُحلَّل بها تلك المؤثرات. لذا كن حذرًا في هذا الاستخدام.

2.4.10. التعاود الذيلي

انظر إلى الدالة التالية التي تبدو غير مثيرة للاهتمام، وهي تعُدّ من 1 إلى n:

(** [count n] is [n], computed by adding 1 to itself [n] times.  That is,
    this function counts up from 1 to [n]. *)
let rec count n =
  if n = 0 then 0 else 1 + count (n - 1)
val count : int -> int = <fun>

لا مشكلة في العَدّ إلى 10:

count 10
- : int = 10

ولا مشكلة أيضًا في العَدّ إلى 100,000:

count 100_000
- : int = 100000

لكن جرّب العَدّ إلى 1,000,000 وستحصل على الخطأ التالي:

Stack overflow during evaluation (looping recursion?).

ما الذي يجري هنا؟

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

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

let rec count_forever n = 1 + count_forever n
val count_forever : 'a -> int = <fun>

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

التعاود الذيلي. هناك حل لهذه المشكلة وُصف في ورقة نُشرت عام 1977 عن LISP بقلم غاي ستيل. ويقتضي هذا الحل، أي تحسين النداء الذيلي (tail-call optimization)، قدرًا من التعاون بين المبرمج والمصرّف. فيقوم المبرمج بإعادة كتابة الدالة قليلًا، فيلاحظ المصرّف ذلك ويطبّق تحسينًا. ولنرَ كيف يعمل ذلك.

افترض أن دالة تعاودية f تستدعي نفسها ثم تعيد نتيجة ذلك النداء التعاودي. الدالة count لدينا لا تفعل ذلك:

let rec count n =
  if n = 0 then 0 else 1 + count (n - 1)
val count : int -> int = <fun>

بل إنه بعد النداء التعاودي count (n - 1)، يبقى حساب يجب إجراؤه: فلا يزال على الحاسوب إضافة 1 إلى نتيجة ذلك النداء.

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

let rec count_aux n acc =
  if n = 0 then acc else count_aux (n - 1) (acc + 1)
let count_tr n = count_aux n 0
val count_aux : int -> int -> int = <fun>
val count_tr : int -> int = <fun>

الدالة count_aux تكاد تكون مطابقة لـ count الأصلية، لكنها تضيف وسيطًا إضافيًا اسمه acc، وهو اسم اصطلاحي يرمز إلى «المجمِّع» (accumulator). والفكرة أن القيمة التي نريد إرجاعها من الدالة تتراكم فيه ببطء مع كل نداء تعاودي. و«الحساب المتبقي» — أي إضافة 1 — يحدث الآن قبل النداء التعاودي لا بعده. وعندما تصل الحالة الأساسية للتعاود أخيرًا، تعيد الدالة الآن acc، حيث تكون الإجابة قد تراكمت.

لكن الحالة الأساسية الأصلية عند 0 لا تزال بحاجة إلى الوجود في الشيفرة في مكان ما. وهي موجودة بالفعل، بوصفها القيمة الأولية لـ acc التي تُمرَّر إلى count_aux. والآن تعمل count_tr (وسنوضح سبب التسمية «tr» بعد قليل) بديلًا عن count الأصلية لدينا.

في هذه المرحلة أكملنا مسؤولية المبرمج، لكن من غير المرجح أن يكون واضحًا لماذا بذلنا هذا الجهد. فـ count_aux بعد كل شيء ستستدعي نفسها تعاوديًا عددًا كبيرًا جدًا من المرات كما فعلت count، وستطفح المكدّس في النهاية.

وهنا تبدأ مسؤولية المصرّف. فالمصرّف الجيد (ومصرّف OCaml جيد في هذا الشأن) يمكنه ملاحظة وجود نداء تعاودي في الموضع الذيلي (tail position)، وهو تعبير تقني معناه «لا يوجد حساب آخر يجب إجراؤه بعد عودته». فالنداء التعاودي لـ count_aux في موضع ذيلي؛ أما النداء التعاودي لـ count فليس كذلك. وإليك الدالتين مرة أخرى لتتمكن من المقارنة:

let rec count n =
  if n = 0 then 0 else 1 + count (n - 1)
let rec count_aux n acc =
  if n = 0 then acc else count_aux (n - 1) (acc + 1)

وإليك لماذا يهمّ الموضع الذيلي: النداء التعاودي في الموضع الذيلي لا يحتاج إلى إطار مكدّس جديد. بل يمكنه ببساطة إعادة استخدام إطار المكدّس القائم. ذلك لأنه لم يبقَ في إطار المكدّس القائم أي شيء مفيد! فلا يوجد حساب متبقٍ يجب إجراؤه، لذا لم تعد أي من المتغيرات المحلية أو التعليمة التالية المراد تنفيذها وغيرها ذات أهمية. ولا تحتاج أي من تلك الذاكرة إلى القراءة مرة أخرى أبدًا، لأن ذلك النداء انتهى فعليًا بالفعل. لذا، بدلًا من إهدار المساحة بتخصيص إطار مكدّس آخر، «يعيد» المصرّف تدوير المساحة التي كان يستخدمها الإطار السابق.

وهذا هو تحسين النداء الذيلي. ويمكن تطبيقه حتى في حالات تتجاوز الدوال التعاودية إذا كان إطار مكدّس الدالة المستدعية متوافقًا على النحو المناسب مع الدالة المستدعاة. وهو أمر بالغ الأهمية. فتحسين النداء الذيلي يقلّص متطلبات مساحة المكدّس من خطية إلى ثابتة. فبينما احتاجت count إلى O(n)O(n) من إطارات المكدّس، تحتاج count_aux إلى O(1)O(1) فقط، لأن الإطار نفسه يُعاد استخدامه مرارًا وتكرارًا في كل نداء تعاودي. وهذا يعني أن count_tr يمكنها بالفعل العَدّ إلى 1,000,000:

count_tr 1_000_000
- : int = 1000000

وأخيرًا، لماذا سمّينا هذه الدالة count_tr؟ يرمز «tr» إلى تعاودي ذيلي (tail recursive). والدالة التعاودية الذيلية دالة تعاودية جميع نداءاتها التعاودية في موضع ذيلي. وبعبارة أخرى، هي دالة لن تستنفد المكدّس (ما لم توجد علل أخرى).

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

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

وصفة التعاود الذيلي. باختصار، هكذا جعلنا دالة ما تعاودية ذيلية:

  1. غيّر الدالة إلى دالة مساعدة. أضف وسيطًا إضافيًا: المجمِّع، ويُسمى غالبًا acc.
  2. اكتب نسخة «رئيسية» جديدة من الدالة تستدعي الدالة المساعدة. وهي تمرّر قيمة إرجاع الحالة الأساسية الأصلية بوصفها القيمة الأولية للمجمِّع.
  3. غيّر الدالة المساعدة لتعيد المجمِّع في الحالة الأساسية.
  4. غيّر الحالة التعاودية في الدالة المساعدة. فهي تحتاج الآن إلى إجراء العمل الإضافي على وسيط المجمِّع، قبل النداء التعاودي. وهذه هي الخطوة الوحيدة التي تتطلب قدرًا كبيرًا من البراعة.

مثال: المضروب. لنحوّل دالة المضروب هذه لتصبح تعاودية ذيلية:

(** [fact n] is [n] factorial. *)
let rec fact n =
  if n = 0 then 1 else n * fact (n - 1)
val fact : int -> int = <fun>

أولًا، نغيّر اسمها ونضيف وسيط مجمِّع:

let rec fact_aux n acc = ...

ثانيًا، نكتب دالة «رئيسية» جديدة تستدعي الدالة المساعدة بالحالة الأساسية الأصلية بوصفها المجمِّع:

let fact_tr n = fact_aux n 1

ثالثًا، نغيّر الدالة المساعدة لتعيد المجمِّع في الحالة الأساسية:

if n = 0 then acc ...

وأخيرًا، نغيّر الحالة التعاودية:

else fact_aux (n - 1) (n * acc)

وبجمع كل ذلك معًا، نحصل على:

let rec fact_aux n acc =
  if n = 0 then acc else fact_aux (n - 1) (n * acc)
let fact_tr n = fact_aux n 1
val fact_aux : int -> int -> int = <fun>
val fact_tr : int -> int = <fun>

كان تمرينًا جميلًا، لكنه ربما لا يستحق العناء. فحتى قبل أن نستنفد مساحة المكدّس، يعاني الحساب من طفح الأعداد الصحيحة:

fact 50
- : int = -3258495067890909184

ولحل تلك المشكلة، نلجأ إلى مكتبة الأعداد الصحيحة الكبيرة في OCaml، وهي Zarith. ونستخدم هنا بضع ميزات من OCaml تتجاوز كل ما رأيناه حتى الآن، لكنها ليست مفاجئة إلى حد كبير كما نأمل. (وإذا أردت متابعة هذه الشيفرة، فثبّت Zarith أولًا في OPAM بالأمر opam install zarith.)

#require "zarith.top";;
let rec zfact_aux n acc =
  if Z.equal n Z.zero then acc else zfact_aux (Z.pred n) (Z.mul acc n);;
let zfact_tr n = zfact_aux n Z.one;;
zfact_tr (Z.of_int 50)
val zfact_aux : Z.t -> Z.t -> Z.t = <fun>
val zfact_tr : Z.t -> Z.t = <fun>
- : Z.t = 30414093201713378043612608166064768844377641568960512000000000000

وإذا أردت، يمكنك استخدام تلك الشيفرة لحساب zfact_tr 1_000_000 دون طفح في المكدّس أو في الأعداد الصحيحة، وإن كان ذلك سيستغرق عدة دقائق.

سيشرح فصل الوحدات ميزات OCaml التي استخدمناها أعلاه بالتفصيل، لكن الآن بإيجاز:

  • #require يحمّل المكتبة، التي توفر وحدة باسم Z. وتذكّر أن Z\mathbb{Z} هو الرمز المستخدم في الرياضيات للدلالة على الأعداد الصحيحة.
  • Z.n تعني الاسم n المعرّف داخل Z.
  • النوع Z.t هو اسم المكتبة لنوع الأعداد الصحيحة الكبيرة.
  • نستخدم قيم المكتبة Z.equal لمقارنة التساوي، وZ.zero للصفر، وZ.pred للسابق (أي طرح 1)، وZ.mul للضرب، وZ.one للواحد، وZ.of_int لتحويل عدد صحيح أولي إلى عدد صحيح كبير.

2.5. التوثيق

توفر OCaml أداة تسمى OCamldoc تعمل كثيرًا مثل أداة Javadoc في Java: فهي تستخرج تعليقات منسّقة تنسيقًا خاصًا من شيفرة المصادر وتعرضها بصيغة HTML، مما يسهّل على المبرمجين قراءة التوثيق.

2.5.1. كيفية التوثيق

إليك مثالًا على تعليق OCamldoc:

(** [sum lst] is the sum of the elements of [lst]. *)
let rec sum lst = ...
  • النجمة المزدوجة هي ما يجعل التعليق يُعرف بوصفه تعليق OCamldoc.
  • الأقواس المعقوفة حول أجزاء من التعليق تعني أن تلك الأجزاء ينبغي عرضها في HTML بخط آلة كاتبة بدلًا من الخط العادي.

وكما في Javadoc أيضًا، يدعم OCamldoc وسوم التوثيق (documentation tags)، مثل &#64;author و&#64;deprecated و&#64;param و&#64;return وغيرها.

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

2.5.2. ما الذي ينبغي توثيقه

أسلوب التوثيق الذي نفضّله في هذا الكتاب يشبه أسلوب المكتبة القياسية في OCaml: موجز وتصريحي. وعلى سبيل المثال، لنعد إلى توثيق sum:

(** [sum lst] is the sum of the elements of [lst]. *)
let rec sum lst = ...

يبدأ ذلك التعليق بـ sum lst، وهو مثال على تطبيق الدالة على وسيط. ويستمر التعليق بكلمة «is» (أي «هو»)، فيصف بذلك تصريحيًا نتيجة التطبيق. (وكان يمكن استخدام كلمة «returns» بدلًا منها، لكن «is» تؤكد الطبيعة الرياضية للدالة.) ويستخدم ذلك الوصف اسم الوسيط lst لشرح النتيجة.

لاحظ أنه لا حاجة إلى إضافة وسوم لوصف الوسائط أو القيم المرجعة وصفًا مكرّرًا، كما يُفعل كثيرًا مع Javadoc. فكل ما يحتاج إلى قول قد قيل بالفعل. ونحن نستنكر بشدة توثيقًا مثل التالي:

(** Sum a list.
    @param lst The list to be summed.
    @return The sum of the list. *)
let rec sum lst = ...

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

وهناك طريقة واحدة قد نحسّن بها التوثيق الذي لدينا حتى الآن، وهي ذكر ما يحدث مع القوائم الفارغة صراحةً:

(** [sum lst] is the sum of the elements of [lst].
    The sum of an empty list is 0. *)
let rec sum lst = ...

2.5.3. الشروط المسبقة واللاحقة

وإليك بضعة أمثلة أخرى على تعليقات مكتوبة بالأسلوب الذي نفضّله.

(** [lowercase_ascii c] is the lowercase ASCII equivalent of
    character [c]. *)
(** [index s c] is the index of the first occurrence of
    character [c] in string [s].  Raises: [Not_found]
    if [c] does not occur in [s]. *)
(** [random_int bound] is a random integer between 0 (inclusive)
    and [bound] (exclusive).  Requires: [bound] is greater than 0
    and less than 2^30. *)

يحدد توثيق index أن الدالة تطلق استثناءً، وكذلك ما هو ذلك الاستثناء والشرط الذي يُطلق تحته. (وسنغطي الاستثناءات بمزيد من التفصيل في الفصل التالي.) ويحدد توثيق random_int أنه يجب أن يحقق وسيط الدالة شرطًا ما.

في مقررات سابقة، تعرضت لفكرتي الشروط المسبقة (preconditions) والشروط اللاحقة (postconditions). فالشرط المسبق شيء يجب أن يكون صحيحًا قبل قسم معيّن من الشيفرة؛ والشرط اللاحق يكون صحيحًا بعده.

وعبارة «Requires» أعلاه في توثيق random_int نوع من الشروط المسبقة. فهي تقول إن عميل الدالة random_int مسؤول عن ضمان شيء ما بشأن قيمة bound. وبالمثل، الجملة الأولى من التوثيق نفسه نوع من الشروط اللاحقة. فهي تضمن شيئًا بشأن القيمة التي تعيدها الدالة.

وعبارة «Raises» في توثيق index نوع آخر من الشروط اللاحقة. فهي تضمن أن الدالة تطلق استثناءً. ولاحظ أن العبارة ليست شرطًا مسبقًا، رغم أنها تنص على شرط يتعلق بمدخل.

لاحظ أنه ليس في أي من هذه الأمثلة عبارة «Requires» تقول شيئًا عن نوع أحد المدخلات. وإذا كنت قادمًا من لغة ديناميكية الأنواع مثل Python، فقد يكون ذلك مفاجئًا. فمبرمجو Python يوثّقون كثيرًا الشروط المسبقة المتعلقة بأنواع مدخلات الدوال. أما مبرمجو OCaml فلا يفعلون ذلك. والسبب أن المصرّف نفسه يقوم بفحص الأنواع لضمان عدم تمريرك أبدًا قيمة من النوع الخطأ إلى دالة. خذ lowercase_ascii مرة أخرى: فرغم أن التعليق المكتوب بالإنجليزية يحدد للقارئ نوع c على نحو مفيد، فإن التعليق لا ينص على عبارة «Requires» كهذه:

(** [lowercase_ascii c] is the lowercase ASCII equivalent of [c].
    Requires: [c] is a character. *)

فقراءة تعليق كهذا تبدو غير اصطلاحية إلى حد كبير لمبرمج OCaml، إذ سيقرأ ذلك التعليق ويحار، وقد يفكر: «حسنًا، بالطبع c محرف؛ فالمصرّف سيضمن ذلك. فما الذي قصد إليه كاتب هذا فعلًا؟ هل هناك شيء يغيب عنه أو عني؟»

2.6. الطباعة

لدى OCaml دوال طباعة مدمجة لبعض الأنواع الأولية المدمجة: print_char وprint_string وprint_int وprint_float. وهناك أيضًا الدالة print_endline، وهي شبيهة بـ print_string، لكنها تطبع سطرًا جديدًا أيضًا.

print_endline "Camels are bae"
Camels are bae
- : unit = ()

2.6.1. الوحدة

لننظر إلى نوعي بعض تلك الدوال:

print_endline
- : string -> unit = <fun>
print_string
- : string -> unit = <fun>

كلتاهما تأخذ سلسلة نصية كمدخل وتعيد قيمة من النوع unit، وهو نوع لم نره من قبل. ولا توجد من هذا النوع إلا قيمة واحدة، تُكتب () وتُلفظ أيضًا «unit». لذا فـ unit شبيه بـ bool، إلا أن قيم النوع unit أقل بواحدة من قيم bool.

تُستخدم الوحدة عندما تحتاج إلى أخذ وسيط أو إعادة قيمة، لكن لا توجد قيمة مثيرة للاهتمام تُمرَّر أو تُعاد. وهي مكافئة لـ void في Java، وتشبه None في Python. وتُستخدم الوحدة غالبًا عندما تكتب شيفرة لها آثار جانبية أو تستخدمها. والطباعة مثال على الأثر الجانبي: فهي تغيّر العالم ولا يمكن التراجع عنها.

2.6.2. الفاصلة المنقوطة

إذا أردت طباعة شيء بعد آخر، يمكنك تسلسل بعض دوال الطباعة باستخدام تعبيرات let متداخلة:

let _ = print_endline "Camels" in
let _ = print_endline "are" in
print_endline "bae"
Camels
are
bae
- : unit = ()

صياغة let _ = e أعلاه طريقة لتقييم e دون ربط قيمته بأي اسم. بل إننا نعلم القيمة التي ستعيدها كل من دوال print_endline تلك: ستكون دائمًا ()، قيمة الوحدة. لذا لا يوجد سبب وجيه لربطها باسم متغير. ويمكننا أيضًا كتابة let () = e للإشارة إلى أننا نعلم أنها مجرد قيمة وحدة لا تهمّنا:

let () = print_endline "Camels" in
let () = print_endline "are" in
print_endline "bae"
Camels
are
bae
- : unit = ()

لكن في كلتا الحالتين، من المزعج أن نضطر إلى كتابة القوالب المكررة كلها من let..in! لذا توجد صياغة خاصة يمكن استخدامها لتسلسل عدة دوال تعيد الوحدة. فالتعبير e1; e2 يقيّم أولًا e1، الذي ينبغي أن يقيّم إلى ()، ثم يهمل تلك القيمة، ويقيّم e2. لذا يمكننا إعادة كتابة الشيفرة أعلاه هكذا:

print_endline "Camels";
print_endline "are";
print_endline "bae"
Camels
are
bae
- : unit = ()

وهذه شيفرة OCaml أكثر اصطلاحية، وتبدو أيضًا أكثر طبيعية للمبرمجين الأمرين.

تحذير

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

2.6.3. ignore

إذا لم يكن e1 من النوع unit، فإن e1; e2 سيعطي تحذيرًا، لأنك تُهمل قيمة قد تكون مفيدة. وإذا كان ذلك مقصدك حقًا، فيمكنك استدعاء الدالة المدمجة ignore : 'a -> unit لتحويل أي قيمة إلى ():

(ignore 3); 5
- : int = 5

في الواقع، ignore سهلة التنفيذ بنفسك:

let ignore x = ()
val ignore : 'a -> unit = <fun>

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

let ignore _ = ()
val ignore : 'a -> unit = <fun>

2.6.4. Printf

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

(** [print_stat name num] prints [name: num]. *)
let print_stat name num =
  print_string name;
  print_string ": ";
  print_float num;
  print_newline ()
val print_stat : string -> float -> unit = <fun>
print_stat "mean" 84.39
mean: 84.39
- : unit = ()

كيف يمكننا تقصير print_stat؟ في Java قد تستخدم المؤثر + المحمَّل لتحويل كل الكائنات إلى سلاسل نصية:

void print_stat(String name, double num) {
   System.out.println(name + ": " + num);
}

لكن قيم OCaml ليست كائنات، وليست لها طريقة toString() ترثها من صنف جذر ما مثل Object. ولا تسمح OCaml أيضًا بتحميل المؤثرات.

لكن قبل زمن بعيد، اخترعت FORTRAN حلًا مختلفًا تدعمه لغات أخرى مثل C وJava وحتى Python. والفكرة هي استخدام محدِّد تنسيق (format specifier) لتحديد كيفية تنسيق المخرجات — كما يوحي الاسم. وأشهر اسم تُعرف به هذه الفكرة هو على الأرجح «printf»، وهو اسم دالة المكتبة في C التي نفّذتها. ولا تزال لغات ومكتبات كثيرة تستخدم ذلك الاسم، ومنها وحدة Printf في OCaml.

وإليك كيف سنستخدم printf لإعادة تنفيذ print_stat:

let print_stat name num =
  Printf.printf "%s: %F\n%!" name num
val print_stat : string -> float -> unit = <fun>
print_stat "mean" 84.39
mean: 84.39
- : unit = ()

الوسيط الأول للدالة Printf.printf هو محدِّد التنسيق. وهو يبدو كسلسلة نصية، لكن الأمر أكثر من ذلك. فهو في الحقيقة مفهوم لدى مصرّف OCaml بعمق كبير. وداخل محدِّد التنسيق توجد:

  • محارف عادية، و
  • محدِّدات تحويل، تبدأ بـ %.

وهناك نحو عشرين محدِّد تحويل متاحًا، يمكنك القراءة عنها في توثيق Printf. ولنفكّك محدِّد التنسيق أعلاه على سبيل المثال.

  • يبدأ بـ "%s"، وهو محدِّد التحويل الخاص بالسلاسل النصية. وهذا يعني أن الوسيط التالي لـ printf يجب أن يكون string، وأن محتوى تلك السلسلة سيُطبع.
  • ويستمر بـ ": "، وهي مجرد محارف عادية. وتُدرج هذه في المخرجات.
  • ثم يحتوي على محدِّد تحويل آخر هو %F. وهذا يعني أن الوسيط التالي لـ printf يجب أن يكون من النوع float، وأنه سيُطبع بالتنسيق نفسه الذي تستخدمه OCaml لطباعة أعداد الفاصلة العائمة.
  • والسطر الجديد "\n" بعد ذلك متتالية أخرى من المحارف العادية.
  • وأخيرًا، يعني محدِّد التحويل "%!" تفريغ مخزن الإخراج (buffer). وكما تعلمت ربما في مقررات برمجة سابقة، غالبًا ما تكون المخرجات مخزّنة مؤقتًا، أي أنها لا تحدث كلها دفعة واحدة أو فورًا. ويضمن تفريغ المخزن أن أي شيء لا يزال راكدًا في المخزن يُطبع فورًا. وهذا المحدِّد خاص من حيث إنه لا يحتاج فعليًا إلى وسيط آخر لـ printf.

وإذا كان نوع أحد الوسائط غير صحيح بالنسبة إلى محدِّد التحويل، فستكتشف OCaml ذلك. لنضف تعليقًا نوعيًا لنجعل num من النوع int، ولنرَ ما يحدث مع محدِّد تحويل الفاصلة العائمة %F:

let print_stat name (num : int) =
  Printf.printf "%s: %F\n%!" name num
File "[14]", line 2, characters 34-37:
2 |   Printf.printf "%s: %F\n%!" name num
                                      ^^^
Error: This expression has type int but an expression was expected of type
         float

ولإصلاح ذلك، يمكننا التغيير إلى محدِّد التحويل الخاص بـ int، وهو %i:

let print_stat name num =
  Printf.printf "%s: %i\n%!" name num
val print_stat : string -> int -> unit = <fun>

وثمة صيغة أخرى مفيدة جدًا من printf هي sprintf، التي تجمع المخرجات في سلسلة نصية بدلًا من طباعتها:

let string_of_stat name num =
  Printf.sprintf "%s: %F" name num
val string_of_stat : string -> float -> string = <fun>
string_of_stat "mean" 84.39
- : string = "mean: 84.39"

2.7. التنقيح

التنقيح ملاذ أخير بعد فشل كل شيء آخر. فلنتراجع خطوة ونفكر في كل ما يأتي قبل التنقيح.

2.7.1. دفاعات ضد العلل

وفقًا لـ روب ميلر، هناك أربعة دفاعات ضد العلل (bugs):

  1. الدفاع الأول ضد العلل هو جعلها مستحيلة. يمكن استئصال أصناف كاملة من العلل باختيار البرمجة بلغات تضمن أمان الذاكرة (أي أنه لا يمكن الوصول إلى أي جزء من الذاكرة إلا عبر مؤشر (pointer) أو مرجع صالح لتلك المنطقة من الذاكرة) و*أمان الأنواع* (أي أنه لا يمكن استخدام أي قيمة على نحو يتعارض مع نوعها). فنظام الأنواع في OCaml، على سبيل المثال، يمنع البرامج من طفح المخازن ومن العمليات بلا معنى (مثل جمع قيمة منطقية مع عدد عشري)، بينما نظام الأنواع في C لا يفعل ذلك.
  2. الدفاع الثاني ضد العلل هو استخدام أدوات تكشفها. فهناك أدوات آلية لتحليل شيفرة المصادر، مثل FindBugs، التي يمكنها كشف كثير من الأنواع الشائعة للعلل في برامج Java، وSLAM، التي تُستخدم لكشف العلل في مشغلات الأجهزة (device drivers). والفرع الفرعي من علوم الحاسوب المعروف باسم الأساليب الشكلية (formal methods) يدرس كيفية استخدام الرياضيات لتحديد البرامج والتحقق منها، أي كيفية إثبات أن البرامج بلا علل. وسندرس التحقق لاحقًا في هذا المقرر. كما أن الأساليب الاجتماعية مثل مراجعات الشيفرة والبرمجة الثنائية أدوات مفيدة أيضًا لكشف العلل. وقد أشارت دراسات في IBM بين السبعينيات والتسعينيات إلى أن مراجعات الشيفرة يمكن أن تكون فعالة بشكل ملحوظ. في إحدى الدراسات (جونز، 1991)، كشف تفتيش الشيفرة 65% من أخطاء الشيفرة المعروفة و25% من أخطاء التوثيق المعروفة، بينما كشف الاختبار 20% فقط من أخطاء الشيفرة ولا شيء من أخطاء التوثيق.
  3. الدفاع الثالث ضد العلل هو جعلها مرئية فورًا. فكلما ظهرت العلة أبكر، كان تشخيصها وإصلاحها أسهل. وإذا مضى الحساب بدلًا من ذلك متجاوزًا نقطة العلة، فقد يحجب ذلك الحساب الإضافي موضع الفشل الحقيقي. وتجعل التأكيدات في شيفرة المصادر البرامج «تفشل بسرعة» و«تفشل بصوت عالٍ»، بحيث تظهر العلل فورًا، ويعرف المبرمج بالضبط موضع البحث في شيفرة المصادر.
  4. الدفاع الرابع ضد العلل هو الاختبار المستفيض. كيف تعرف إن كانت قطعة شيفرة مصابة بعلة معيّنة؟ اكتب اختبارات تكشف تلك العلة، ثم تأكد من أن شيفرتك لا تفشل في تلك الاختبارات. واختبارات الوحدة لقطعة شيفرة صغيرة نسبيًا، مثل دالة أو وحدة منفردة، مهمة بشكل خاص أن تُكتب في الوقت نفسه الذي تطوّر فيه تلك الشيفرة. وينبغي أن يكون تشغيل تلك الاختبارات آليًا، حتى إذا كسرت الشيفرة يومًا ما، تعرف ذلك في أسرع وقت ممكن. (وهذا في الحقيقة هو الدفاع الثالث مرة أخرى.)

وبعد أن تفشل كل تلك الدفاعات، يُضطر المبرمج إلى اللجوء إلى التنقيح.

2.7.2. كيفية التنقيح

إذن اكتشفت علة. ماذا بعد؟

  1. قطّر العلة إلى حالة اختبار صغيرة. التنقيح عمل شاق، لكن كلما صغرت حالة الاختبار، زاد احتمال تركيز انتباهك على قطعة الشيفرة التي تتربص فيها العلة. لذا فالوقت المستغرق في هذا التقطير يمكن أن يكون وقتًا موفورًا، لأنك لن تضطر إلى إعادة قراءة الكثير من الشيفرة. لا تواصل التنقيح حتى تحصل على حالة اختبار صغيرة!
  2. استخدم المنهج العلمي. صِغ فرضية عن سبب حدوث العلة. بل يمكنك كتابة تلك الفرضية في دفتر، كما لو كنت في مختبر كيمياء، لتوضيحها في ذهنك وتتبّع الفرضيات التي نظرت فيها بالفعل. ثم صمّم تجربة لتأكيد تلك الفرضية أو نفيها. ونفّذ تجربتك وسجّل النتيجة. وبناءً على ما تعلمته، أعد صياغة فرضيتك. وواصل حتى تحدد سبب العلة بعقلانية وبمنهج علمي.
  3. أصلح العلة. قد يكون الإصلاح مجرد تصحيح بسيط لخطأ طباعي. أو قد يكشف عن عيب في التصميم يدفعك إلى إجراء تغييرات كبيرة. وفكّر فيما إذا كنت قد تحتاج إلى تطبيق الإصلاح في مواضع أخرى من قاعدة شيفرتك — فهل كان خطأ نسخ ولصق مثلًا؟ وإن كان كذلك، فهل تحتاج إلى إعادة هيكلة شيفرتك؟
  4. أضف حالة الاختبار الصغيرة إلى مجموعة اختباراتك بشكل دائم. فأنت لا تريد أن تزحف العلة مجددًا إلى قاعدة شيفرتك. لذا تتبّع حالة الاختبار الصغيرة تلك بإبقائها جزءًا من اختبارات وحدتك. وبذلك، كلما أجريت تغييرات مستقبلية، ستكون تحرس تلقائيًا ضد العلة نفسها. وتشغيل الاختبارات المستخلصة من علل سابقة مرارًا وتكرارًا جزء من اختبار الانحدار (regression testing).

2.7.3. التنقيح في OCaml

وإليك بضع نصائح حول كيفية التنقيح — إن أُجبرت عليه — في OCaml.

عبارات الطباعة. أدخل عبارة طباعة لتتبيّن قيمة متغير. افترض أنك تريد معرفة قيمة x في الدالة التالية:

let inc x = x + 1

فقط أضف السطر التالي لطباعة تلك القيمة:

let inc x =
  let () = print_int x in
  x + 1

تتبّع الدوال. افترض أنك تريد رؤية أثر (trace) النداءات والعودات التعاودية لدالة. استخدم التوجيه #trace:

# let rec fib x = if x <= 1 then 1 else fib (x - 1) + fib (x - 2);;
# #trace fib;;

وإذا قيّمت fib 2، فسترى الآن المخرجات التالية:

fib <-- 2
fib <-- 0
fib --> 1
fib <-- 1
fib --> 1
fib --> 2

ولإيقاف التتبّع، استخدم التوجيه #untrace.

المنقّح. لدى OCaml أداة تنقيح هي ocamldebug. ويمكنك العثور على برنامج تعليمي على موقع OCaml. وما لم تكن تستخدم Emacs كمحرر، فمن المرجح أن تجد هذه الأداة أصعب في الاستخدام من مجرد إدراج عبارات الطباعة.

2.7.4. البرمجة الدفاعية

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

انظر إلى هذه المواصفة لـ random_int:

(** [random_int bound] is a random integer between 0 (inclusive)
    and [bound] (exclusive).  Requires: [bound] is greater than 0
    and less than 2^30. *)

إذا مرّر عميل random_int قيمة لـ bound تخالف عبارة «Requires»، مثل -1، فإن تنفيذ random_int حرّ في فعل أي شيء على الإطلاق. فكل الاحتمالات مفتوحة عندما يخالف العميل الشرط المسبق.

لكن أكثر ما يفيد random_int فعله هو كشف حقيقة أن الشرط المسبق قد خُورق فورًا. ففي نهاية الأمر، من المرجح أن العميل لم يقصد خرق ذلك الشرط.

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

(* possibility 1 *)
let random_int bound =
  assert (bound > 0 && bound < 1 lsl 30);
  (* proceed with the implementation of the function *)
(* possibility 2 *)
let random_int bound =
  if not (bound > 0 && bound < 1 lsl 30)
  then invalid_arg "bound";
  (* proceed with the implementation of the function *)
(* possibility 3 *)
let random_int bound =
  if not (bound > 0 && bound < 1 lsl 30)
  then failwith "bound";
  (* proceed with the implementation of the function *)

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

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

ولا يختلف الاحتمال الثالث عن الثاني إلا في اسم الاستثناء الذي يُطلق (Failure). وقد يكون مفيدًا في حالات يشمل فيها الشرط المسبق أكثر من مجرد وسيط واحد غير صالح.

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

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

وأخيرًا، قد يختار المنفِّذ حتى إزالة الشرط المسبق وإعادة صياغته كشرط لاحق:

(** [random_int bound] is a random integer between 0 (inclusive)
    and [bound] (exclusive).  Raises: [Invalid_argument "bound"]
    unless [bound] is greater than 0 and less than 2^30. *)

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

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

2.8. الملخّص

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

ودلالات تطبيق الدوال هي قلب OCaml والبرمجة الوظيفية، وسنعود إليها عدة مرات خلال المقرر لتعميق فهمنا.

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

  • الدوال المجهولة (anonymous functions)
  • التأكيدات (assertions)
  • الربط (binding)
  • تعبير الربط (binding expression)
  • تعبير الجسم (body expression)
  • التنقيح (debugging)
  • البرمجة الدفاعية (defensive programming)
  • التعريفات (definitions)
  • التوثيق (documentation)
  • الدلالات الديناميكية (dynamic semantics)
  • التقييم (evaluation)
  • التعبيرات (expressions)
  • تطبيق الدوال (function application)
  • تعريفات الدوال (function definitions)
  • المعرّفات (identifiers)
  • الأساليب الاصطلاحية (idioms)
  • تعبيرات if (if expressions)
  • تعبيرات لامبدا (lambda expressions)
  • تعريف let (let definition)
  • تعبير let (let expression)
  • المكتبات (libraries)
  • المتغيرات الفوقية (metavariables)
  • التعاود المتبادل (mutual recursion)
  • مؤثر خط الأنابيب (pipeline operator)
  • الشرط اللاحق (postcondition)
  • الشرط المسبق (precondition)
  • الطباعة (printing)
  • التعاود (recursion)
  • الدلالات (semantics)
  • الدلالات الساكنة (static semantics)
  • الإحلال (substitution)
  • الصياغة (syntax)
  • الأدوات (tools)
  • فحص الأنواع (type checking)
  • استدلال الأنواع (type inference)
  • القيم (values)

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

  • Introduction to Objective Caml، الفصل 3
  • OCaml from the Very Beginning، الفصل 2
  • Real World OCaml، الفصل 2
  • Tail Recursion, The Musical. تحسين النداء الذيلي مشروحًا في سياق JavaScript مع رسوم متحركة لطيفة بأسلوب 8 بت، وأغانٍ من ديزني!

2.9. التمارين

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

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

ما نوع وقيمة كل من تعبيرات OCaml التالية؟

  • 7 * (1 + 2 + 3)
  • "CS " ^ string_of_int 3110

تلميح: اكتب كل تعبير في الواجهة التفاعلية وستخبرك بالإجابة. ملاحظة: ^ ليس رفعًا إلى قوة.

تمرين: المؤثرات [★★]

افحص جدول جميع المؤثرات في دليل OCaml (سيتعين عليك التمرير للأسفل للعثور عليه في تلك الصفحة).

  • اكتب تعبيرًا يضرب 42 في 10.
  • اكتب تعبيرًا يقسم 3.14 على 2.0. تلميح: تُكتب مؤثرات الأعداد الصحيحة وأعداد الفاصلة العائمة بشكل مختلف في OCaml.
  • اكتب تعبيرًا يحسب 4.2 مرفوعًا إلى القوة السابعة. ملاحظة: لا يوجد مؤثر مدمج لرفع الأعداد الصحيحة إلى قوة في OCaml (ولا يوجد في C أيضًا، بالمناسبة)، ويعود ذلك جزئيًا إلى أن معظم المعالجات لا توفر هذه العملية.

تمرين: التساوي [★]

  • اكتب تعبيرًا يقارن 42 بـ 42 باستخدام التساوي البنيوي.
  • اكتب تعبيرًا يقارن "hi" بـ "hi" باستخدام التساوي البنيوي. ما النتيجة؟
  • اكتب تعبيرًا يقارن "hi" بـ "hi" باستخدام التساوي الفيزيائي. ما النتيجة؟

تمرين: assert [★]

  • أدخل assert true;; في utop ولاحظ ما يحدث.
  • أدخل assert false;; في utop ولاحظ ما يحدث.
  • اكتب تعبيرًا يؤكد أن 2110 ليس مساويًا (بنيويًا) لـ 3110.

تمرين: if [★]

اكتب تعبير if يقيّم إلى 42 إذا كان 2 أكبر من 1، ويقيّم خلاف ذلك إلى 7.

تمرين: دالة الضعف [★]

باستخدام دالة الزيادة أعلاه دليلًا، عرّف دالة double تضرب مدخلها في 2. على سبيل المثال، ستكون double 7 هي 14. اختبر دالتك بتطبيقها على بضعة مدخلات. وحوّل حالات الاختبار تلك إلى تأكيدات.

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

  • عرّف دالة تحسب مكعب عدد من أعداد الفاصلة العائمة. اختبر دالتك بتطبيقها على بضعة مدخلات.
  • عرّف دالة تحسب الإشارة (1 أو 0 أو -1) لعدد صحيح. استخدم تعبير if متداخلًا. اختبر دالتك بتطبيقها على بضعة مدخلات.
  • عرّف دالة تحسب مساحة دائرة بمعلومية نصف قطرها. اختبر دالتك باستخدام assert.

وفي الحالة الأخيرة، تذكّر أن حساب الفاصلة العائمة ليس دقيقًا. فبدلًا من التأكيد على قيمة دقيقة، ينبغي أن تؤكد أن النتيجة «قريبة بدرجة كافية»، مثل أن تكون ضمن 1e-5. وإذا كان ذلك غير مألوف لديك، فمن الجدير بالاطلاع على حساب الفاصلة العائمة.

ويمكن تعريف دالة تأخذ مدخلات متعددة بمجرد توفير أسماء إضافية لتلك المدخلات ضمن تعريف let. على سبيل المثال، تحسب الدالة التالية متوسط ثلاثة وسائط:

let avg3 x y z = (x +. y +. z) /. 3.

تمرين: الجذر التربيعي لمتوسط المربعات [★★]

عرّف دالة تحسب الجذر التربيعي لمتوسط مربعي عددين — أي (x2+y2)/2\sqrt{(x^2 + y^2) / 2}. اختبر دالتك باستخدام assert.

تمرين: دالة التاريخ [★★★]

عرّف دالة تأخذ عددًا صحيحًا d وسلسلة نصية m كمدخلين وتعيد true فقط عندما يشكّل d وm تاريخًا صالحًا. وهنا، التاريخ الصالح هو الذي يكون شهره أحد الاختصارات التالية: Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sept, Oct, Nov, Dec. ويجب أن يكون اليوم رقمًا بين 1 والحد الأدنى لعدد أيام ذلك الشهر، شاملًا الطرفين. على سبيل المثال، إذا كان الشهر Jan، فيكون اليوم بين 1 و31 شاملًا الطرفين، أما إذا كان الشهر Feb، فيكون اليوم بين 1 و28 شاملًا الطرفين.

ما مدى إيجاز دالتك (أي قلة أسطر الشيفرة وقصرها)؟ يمكنك بالتأكيد فعل ذلك في أقل من 12 سطرًا.

تمرين: fib [★★]

عرّف دالة تعاودية fib : int -> int، بحيث تكون fib n هي العدد n في متتالية فيبوناتشي، وهي 1، 1، 2، 3، 5، 8، 13، … أي:

  • fib 1 = 1،
  • fib 2 = 1، و
  • fib n = fib (n-1) + fib (n-2) لأي n > 2.

اختبر دالتك في الواجهة التفاعلية.

تمرين: fib السريعة [★★★]

ما مدى سرعة تنفيذك لـ fib في حساب عدد فيبوناتشي الخمسين؟ إذا كان الحساب يكاد يكون فوريًا، فتهانينا! لكن الحل التعاودي الذي يبتكره معظم الناس في البداية سيبدو وكأنه يتعلّق إلى ما لا نهاية. والمشكلة أن الحل البديهي يحسب المسائل الفرعية مرارًا وتكرارًا. على سبيل المثال، يتطلب حساب fib 5 حساب كل من fib 3 وfib 4، وإذا حُسبا كلًا على حدة، فقد أُعيد إجراء قدر كبير من العمل (وقدْر أُسّي في الواقع).

أنشئ دالة fib_fast تتطلب قدرًا خطيًا فقط من العمل. تلميح: اكتب دالة مساعدة تعاودية h : int -> int -> int -> int، حيث تُعرّف h n pp p كما يلي:

  • h 1 pp p = p، و
  • h n pp p = h (n-1) p (pp+p) لأي n > 1.

وفكرة h أنها تفترض أن عددي فيبوناتشي السابقين كانا pp وp، ثم تحسب للأمام n عددًا إضافيًا. ومن هنا فإن fib n = h n 0 1 لأي n > 0.

ما أول قيمة لـ n تكون عندها fib_fast n سالبة، مما يدل على حدوث طفح في الأعداد الصحيحة؟

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

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

let f x = if x then x else x
let g x y = if y then x else x
let h x y z = if x then y else z
let i x y z = if x then y else y

تمرين: القسمة [★★]

اكتب دالة divide : numerator:float -> denominator:float -> float. وطبّق دالتك.

تمرين: الترابطية [★★]

افترض أننا عرّفنا let add x y = x + y. أي مما يلي ينتج عددًا صحيحًا، وأيها ينتج دالة، وأيها ينتج خطأً؟ قرّر إجابتك، ثم تحقق منها في الواجهة التفاعلية.

  • add 5 1
  • add 5
  • (add 5) 1
  • add (5 1)

تمرين: المتوسط [★★]

عرّف مؤثرًا بينيًا +/. لحساب متوسط عددين من أعداد الفاصلة العائمة. على سبيل المثال،

  • 1.0 +/. 2.0 = 1.5
  • 0. +/. 0. = 0.

تمرين: hello world [★]

اكتب ما يلي في utop:

  • print_endline "Hello world!";;
  • print_string "Hello world!";;

لاحظ الفرق في المخرجات بينهما.