الأصناف
لا يملك أحد الحق في أن يحبّ شيئًا أو يكرهه إن لم يكتسب معرفةً شاملة بطبيعته. الحبّ العظيم ينبع من معرفة عظيمة بالشخص أو الشيء المحبّوب، وإن كنت تعرفه قليلًا فلن تستطيع أن تحبّه إلا قليلًا أو ألا تحبّه إطلاقًا.
ليوناردو دا فينشي
لقد بلغنا الفصل الحادي عشر، والمفسّر (interpreter) الموجود على جهازك يكاد يكون لغة برمجة نصّية (scripting) كاملة. يمكن أن يستفيد من بعض بنى البيانات المدمجة مثل القوائم والخرائط (maps)، ولا بدّ بالطبع أنه يحتاج إلى مكتبة أساسية لعمليات إدخال/إخراج الملفات ومدخلات المستخدم وغيرها. لكن اللغة نفسها كافية. لدينا لغة إجرائية (procedural) صغيرة من الطراز نفسه الذي تنتمي إليه BASIC وTcl وScheme (بدون ماكروهات) والإصدارات المبكّرة من Python وLua.
لو كنا في الثمانينيات لوقفنا عند هنا. لكن اليوم تدعم لغةٌ كثيرة شهيرة «البرمجة الموجّهة للكائنات» (object-oriented programming). وإضافتها إلى Lox ستمنح المستخدمين مجموعة أدوات مألوفة لكتابة برامج أكبر. وحتى إن كنتَ شخصياً لا تحبّ البرمجة الموجّهة للكائنات، فإن هذا الفصل والفصل التالي سيساعدانك على فهم كيف يصمّم الآخرون أنظمة الكائنات ويبونها.
البرمجة الموجّهة للكائنات والأصناف
هناك ثلاثة مسارات عريضة نحو البرمجة الموجّهة للكائنات: الأصناف (class)، والنماذج الأولية، والتعدد البسيط. ظهرت الأصناف أولاً وهي أكثر الأنماط شيوعاً. ومع صعود JavaScript (وبعده وبقدر أقل Lua)، صارت النماذج الأولية معروفة أكثر مما كانت. سنتحدّث عن تلك لاحقًا. أما في Lox فنحن نتبع الأسلوب الكلاسيكي.
بما أنك كتبت معي حوالي ألف سطر من شيفرة Java، فسأفترض أنك لا تحتاج إلى مقدمة مفصّلة عن التوجيه للكائنات. الهدف الرئيسي هو حزم البيانات مع الشيفرة التي تعمل عليها. ويقوم المستخدم بذلك بإعلان صنف (class) يقوم بـ:
-
يكشف عن مُنشئ (constructor) لإنشاء وتهيئة نسخ (instances) جديدة من الصنف
-
يوفّر وسيلة لتخزين الحقول (fields) على النسخ والوصول إليها
-
يعرّف مجموعة من الدوال (methods) تشترك فيها كل نسخ الصنف، وتعمل على حالة كل نسخة.
هذا أقل ما يمكن أن يبلغه الأمر. معظم لغات البرمجة الموجّهة للكائنات، وصولاً إلى Simula، تدعم أيضاً الوراثة (inheritance) لإعادة استخدام السلوك عبر الأصناف. وسنضيف ذلك في الفصل التالي. وحتى مع استبعاد ذلك، ما زال أمامنا الكثير. هذا فصل كبير، ولا يكتمل كل شيء إلا حين تتوفّر لدينا جميع القطع السابقة، فاستجمع طاقتك.
إعلانات الأصناف
كما عهدتَ، سنبدأ بالصياغة (syntax). تُدخل جملة class اسمًا جديدًا، فهي إذن
تعيش في قاعدة declaration من قواعد اللغة (grammar).
declaration → classDecl
| funDecl
| varDecl
| statement ;
classDecl → "class" IDENTIFIER "{" function* "}" ;
تعتمد قاعدة classDecl الجديدة على قاعدة function التي عرّفناها
سابقًا. ولِتُذكّر ذاكرتك:
function → IDENTIFIER "(" parameters? ")" block ;
parameters → IDENTIFIER ( "," IDENTIFIER )* ;
بلغة إنجليزية صريحة، إعلان الصنف هو الكلمة المفتاحية class يليها اسم الصنف،
ثم جسم بين أقواس معقوفة. وداخل ذلك الجسم توجد قائمة من إعلانات الدوال. وبخلاف
إعلانات الدوال، لا تسبق الدوالَ كلمة مفتاحية fun.
كل دالة هي اسم، وقائمة وسائط، وجسم. وإليك مثالاً:
class Breakfast {
cook() {
print "Eggs a-fryin'!";
}
serve(who) {
print "Enjoy your breakfast, " + who + ".";
}
}
مثل معظم اللغات المكتوبة النوع ديناميكياً، لا تُدرَج الحقول صراحةً في إعلان الصنف. النسخ هي أكياس بيانات مرنة، ويمكنك بحرية إضافة حقول إليها كما تشاء باستخدام شيفرة أمرية عادية.
في مولّد شجرة التركيب (AST) لدينا، تأخذ قاعدة classDecl اللغوية عقدة جملة خاصة بها.
^code class-ast (1 before, 1 after)
هي تخزّن اسم الصنف ودوالّه داخل جسمها. وتُمثَّل الدوال بصنف Stmt.Function
القائم الذي نستخدمه لعُقد AST في إعلانات الدوال. بذلك نحصل على كل أجزاء الحالة
التي نحتاجها لدالة: الاسم، وقائمة الوسائط، والجسم.
قد يظهر الصنف في أي مكان يُسمح فيه بإعلان مُسمّى، ويُستدعى بالكلمة المفتاحية
class السابقة له.
^code match-class (1 before, 1 after)
وهذا يستدعي:
^code parse-class-declaration
هناك المزيد من اللبّ في هذه الدالة مقارنةً بمعظم دوال التحليل النحوي الأخرى، لكنها
تتبع القواعد اللغوية إجمالاً. لقد استهلكنا الكلمة المفتاحية class، فنبحث تالياً
عن اسم الصنف المتوقّع، ثم عن قوس الفتح المعقوف. وبعد دخولنا إلى الجسم، نواصل
تحليل إعلانات الدوال حتى نصل إلى قوس الإغلاق. كل إعلان دالة يُحلَّل باستدعاء
function()، وهو ما عرّفناه في الفصل الذي عُرّفت فيه الدوال.
وكما نفعل في أي حلقة مفتوحة النهاية في المحلل النحوي (parser)، فإننا نتحقّق أيضاً من بلوغ نهاية الملف. لن يحدث هذا في الشيفرة الصحيحة، لأن الصنف ينبغي أن ينتهي بقوس إغلاق، لكنه يضمن ألا يعلق المحلل النحوي في حلقة لا نهائية إذا وقع المستخدم في خطأ نحوي ونسي إنهاء جسم الصنف بشكل صحيح.
نلفّ الاسم وقائمة الدوال في عقدة Stmt.Class وينتهي الأمر. كنا سابقاً ننقفز
مباشرة إلى المفسّر، لكننا نحتاج الآن إلى تمرير العقدة عبر المحلّل (resolver)
أولاً.
^code resolver-visit-class
لن نهتم حالياً بحلّ الدوال نفسها، لذا كل ما نحتاج إليه الآن هو إعلان الصنف باسمه. ليس من الشائع إعلان صنف كمتغيّر محلّي، لكن Lox تسمح بذلك، فنحتاج إلى معالجته بشكل صحيح.
الآن نفسّر إعلان الصنف.
^code interpreter-visit-class
يبدو هذا مشابهاً لكيفية تنفيذنا لإعلانات الدوال. نعلن اسم الصنف في البيئة
الحالية. ثم نحوّل عقدة الصنف النحوية (syntax node) إلى LoxClass، وهو التمثيل
الزمني (runtime) للصنف. ثم نعود لنخزّن كائن الصنف في المتغيّر الذي أعلنّاه
سابقاً. تسمح عملية ربط المتغيّر على مرحلتين هذه بالإشارة إلى الصنف داخل دوالّه
الخاصة.
سنحسّن ذلك طوال الفصل، لكن المسوّدة الأولى من LoxClass تبدو هكذا:
^code lox-class
حرفياً غلاف حول اسم. لم نخزّن الدوال بعد. ليس مفيداً جداً، لكنه يملك دالة
toString() حتى نستطيع كتابة سكربت تافه واختبار أن كائنات الأصناف تُحلَّل وتُنفَّذ
فعلاً.
class DevonshireCream {
serveOn() {
return "Scones";
}
}
print DevonshireCream; // Prints "DevonshireCream".
إنشاء النسخ
لدينا أصناف، لكنها لا تفعل شيئاً بعد. لا تمتلك Lox دوالاً «ساكنة» (static) يمكنك استدعاؤها على الصنف نفسه مباشرة، لذا فالنسخ، من دونها، تجعل الأصناف عديمة الفائدة. وبذلك تكون النسخ هي الخطوة التالية.
بينما تكون بعض الصياغة والدلالات موحّدة إلى حدٍّ كبير عبر لغات البرمجة الموجّهة
للكائنات، فإن الطريقة التي تنشئ بها نسخاً جديدة ليست كذلك. فعن Ruby، تتبّعاً
لـ Smalltalk، تُنشَأ النسخ باستدعاء دالة على كائن الصنف نفسه، وهو نهج
رشيق بشكل متكرر (recursively). وبعضها، مثل C++
وJava، لديه الكلمة المفتاحية new المخصّصة لإخراج كائن جديد إلى الوجود. أمّا
Python فتجعلك «تستدعي» الصنف نفسه كأنه دالة. (وJavaScript، الشاذّة المراوغة،
تفعل الأمرين نوعاً ما.)
اخترتُ نهجاً مُختصراً مع Lox. لدينا بالفعل كائنات صنف، ولدينا بالفعل استدعاءات
دوال، لذا سنستخدم تعبيرات الاستدعاء على كائنات الأصناف لإنشاء نسخ جديدة. وكأن
الصنف دالة مصنع تولّد نسخاً من نفسها. هذا يبدو لي أنيقاً، وهو أيضاً يغنّينا عن
الحاجة إلى صياغة مثل new. وبذلك يمكننا تجاوز المقدّمة والانتقال مباشرةً إلى
الزمن التشغيل (runtime).
الآن، إن جرّبتَ هذا:
class Bagel {}
Bagel();
ستحصل على خطأ زمني. يتحقّق visitCallExpr() ممّا إذا كان الكائن المُستدعى يطبّق
LoxCallable ويبلّغ عن خطأ لأن LoxClass لا يطبّقه. ليس بعد، بمعنى الكلمة.
^code lox-class-callable (2 before, 1 after)
يتطلّب تطبيق تلك الواجهة دالتين.
^code lox-class-call-arity
الدالة اللافتة هي call(). عندما «تستدعي» صنفاً، فإنها تنشئ LoxInstance جديداً
للصنف المُستدعى وتعيده. ودالة arity() هي الطريقة التي يتحقّق بها المفسّر من أنك
مرّرت العدد الصحيح من المعاملات (arguments) إلى شيء قابل للاستدعاء. الآن،
سنقول إنك لا تستطيع تمرير أيّ منها. وعندما نصل إلى المُنشئات المعرّفة من
المستخدم، سنعود إلى هذا الموضوع.
يؤدّي بنا هذا إلى LoxInstance، وهو التمثيل الزمني لنسخة من صنف Lox. ومرةً
أخرى، يبدأ تنفيذنا الأول بحجم صغير.
^code lox-instance
مثل LoxClass، هو عظميٌّ إلى حدٍّ كبير، لكننا في بدايتنا فحسب. إن أردتَ
تجربته، فإليك سكربت للتشغيل:
class Bagel {}
var bagel = Bagel();
print bagel; // Prints "Bagel instance".
هذا البرنامج لا يفعل الكثير، لكنه بدأ يفعل شيئاً.
الخصائص على النسخ
لدينا نسخ، لذا يجدر بنا جعلها مفيدة. نحن عند مفترق طرق. يمكننا إضافة السلوك أولاً — الدوال — أو أن نبدأ بالحالة — الخصائص. سناخذ بالخيار الثاني، لأنه كما سنرى، يتشابك الاثنان بطريقة مثيرة للاهتمام، وسيكون فهمهما أسهل إن جعلنا الخصائص تعمل أولاً.
تتبع Lox كلَّ من JavaScript وPython في طريقة تعاملها مع الحالة. كل نسخة هي
مجموعة مفتوحة من قيم مُسمّاة. بإمكان دوال صنف النسخة الوصول إلى الخصائص
وتعديلها، لكن بإمكان شيفرة خارجية ذلك أيضاً. ويجري
الوصول إلى الخصائص بصيغة النقطة ..
someObject.someProperty
تعبير يليه . ومُعرِّف يقرأ الخاصية بذلك الاسم من الكائن الذي يُقيَّم إليه
التعبير. وله النقطة الأولوية نفسها التي للأقواس في تعبير استدعاء دالة، لذلك
ندخلها في القواعد اللغوية باستبدال قاعدة call القائمة بما يلي:
call → primary ( "(" arguments? ")" | "." IDENTIFIER )* ;
بعد التعبير الأولي، نسمح بسلسلة من أي مزيج من استدعاءات محاطة بأقواس للوصولات إلى خصائص بنقطة. «الوصول إلى خاصية» عبارة مُرهِقة، لذا من هذه النقطة فصاعداً سنسميها «تعبيرات get» (get expressions).
تعبيرات get
عقدة شجرة التركيب هي:
^code get-ast (1 before, 1 after)
متبعةً للقواعد اللغوية، تذهب شيفرة التحليل الجديدة إلى دالة call() القائمة
لديّ.
^code parse-property (3 before, 4 after)
حلقة while الخارجية هناك تقابل الـ * في القاعدة اللغوية. نحن نمضي على طول
الرموز نبني سلسلة من الاستدعاءات وعمليات الـ get كلما وجدنا أقواساً ونقاط، هكذا:
نسخ من عقدة Expr.Get الجديدة تمرّ إلى المحلّل.
^code resolver-visit-get
حسناً، ليس في ذلك كثير. بما أن الخصائص يُنظر إليها ديناميكياً، فلا جرى حلّها. أثناء جولة الحلّ، نكرّر فقط إلى التعبير الواقع على يسار النقطة. أما الوصول الفعلي إلى الخاصية فيحدث في المفسّر.
^code interpreter-visit-get
أولاً، نقيّم التعبير الذي تُنال خاصيته. في Lox، نسخ الأصناف فقط هي التي لها خصائص. إذا كان الكائن من نوع آخر مثل عدد، فإن استدعاء getter عليه هو خطأ زمني.
إذا كان الكائن LoxInstance، فإننا نطلب منه البحث عن الخاصية. حان الوقت الآن
لمنح LoxInstance بعض الحالة الفعلية. وستفي خريطة (map) بالغرض.
^code lox-instance-fields (1 before, 2 after)
كل مفتاح في الخريطة هو اسم خاصية، والقيمة المقابلة هي قيمة الخاصية. وللبحث عن خاصية على نسخة:
^code lox-instance-get-property
هناك حالة حدّية مثيرة للاهتمام علينا معالجتها: ماذا يحدث إذا لم تكن النسخة
تملك خاصية بذلك الاسم. يمكننا أن نعيد بصمت قيمة وهمية مثل nil، لكن تجربتي مع
لغات مثل JavaScript هي أن هذا السلوك يخفي الأخطاء أكثر ممّا ينفع. بدلاً من ذلك،
سنجعله خطأً زمنياً.
إذن أول ما نفعله هو أن نتحقّق ممّا إذا كانت النسخة تملك فعلاً حقلاً بذلك الاسم. فقط عندئذٍ نعيده. وإلا فنحن نُطلق خطأً.
لاحظ كيف انتقلتُ من الحديث عن «الخصائص» (properties) إلى «الحقول» (fields). هناك فرق دقيق بين الاثنين. الحقول هي قطع حالة مُسمّاة تُخزَّن مباشرةً داخل نسخة. أما الخصائص فهي، أوه، الأشياء المُسمّاة التي قد يعيدها تعبير get. كل حقل هو خاصية، لكن كما سنرى لاحقاً، ليست كل خاصية حقلاً.
من الناحية النظرية، يمكننا الآن قراءة الخصائص على الكائنات. لكن بما أنه لا توجد طريقة لتخزين أي حالة فعلياً داخل نسخة، فلا توجد حقول يمكن الوصول إليها. وقبل أن نختبر القراءة، علينا دعم الكتابة.
تعبيرات set
تستخدم الـ setters الصيغة نفسها للـ getters، إلا أنها تظهر على الجانب الأيسر من عملية إسناد.
someObject.someProperty = value;
في عالم القواعد اللغوية، نوسّع قاعدة الإسناد لتسمح بمُعرِّفات منقوطة على الجانب الأيسر.
assignment → ( call "." )? IDENTIFIER "=" assignment
| logic_or ;
بخلاف الـ getters، لا تتسلسل الـ setters. لكن الإشارة إلى call تسمح بأي تعبير
ذي أولوية عالية قبل النقطة الأخيرة، بما في ذلك أي عدد من getters، كما في:
لاحظ هنا أن الجزء الأخير فقط، أي .meat، هو الـ setter. أما جزآ .omelette
و.filling فكلاهما تعبيرا get.
تمامًا كما لدينا عقدتان منفصلتان في AST للوصول إلى المتغيّر وإسناد المتغيّر، نحتاج إلى عقدة setter ثانية تُكمِل عقدة الـ getter لدينا.
^code set-ast (1 before, 1 after)
إن لم تكن تتذكّر، فإن الطريقة التي نتعامل بها مع الإسناد في المحلل النحوي طريفة
إلى حدٍّ ما. لا يمكننا تمييز بسهولة أن سلسلة رموز هي الجانب الأيسر من إسناد حتى
نصل إلى =. والآن بما أن قاعدة الإسناد اللغوية لدينا صار فيها call على
الجانب الأيسر، وهو ما يمكن أن يتمدّد إلى تعبيرات كبيرة الحجم، فقد تكون علامة
= تلك الأخيرة على بُعد رموز كثيرة عن النقطة التي نحتاج عندها إلى معرفة أننا
نحلّل إسنادًا.
بدلاً من ذلك، فالطريقة التي نلجأ إليها هي تحليل الجانب الأيسر كتعبير عادي. ثم، عندما نصطدم بعلامة المساواة بعده، نأخذ التعبير الذي حلّلناه بالفعل ونحوّله إلى عقدة شجرة التركيب الصحيحة للإسناد.
نضيف بندًا آخر إلى ذلك التحويل للتعامل مع تحويل تعبير Expr.Get على اليسار إلى
Expr.Set المقابل.
^code assign-set (1 before, 1 after)
هذا كل ما يخصّ تحليل صياغتنا. نحن ندفع تلك العقدة إلى المحلّل.
^code resolver-visit-set
ومرةً أخرى، كما في Expr.Get، تُقيَّم الخاصية نفسها ديناميكياً، فلا شيء لحلّه
هناك. كل ما نحتاج إليه هو التكرار داخل التعبيرين الفرعيين لـ Expr.Set: الكائن
الذي تُضبط خاصيته، والقيمة التي تُضبط إليها.
يؤدّي بنا هذا إلى المفسّر.
^code interpreter-visit-set
نقيّم الكائن الذي تُضبط خاصيته ونتأكّد ممّا إذا كان LoxInstance. إن لم يكن كذلك،
فذلك خطأ زمني. وإلا، فإننا نقيّم القيمة التي تُضبط ونخزّنها على النسخة. وهذا يعتمد
على دالة جديدة في LoxInstance.
^code lox-instance-set-property
لا سحر حقيقي هنا. نحن نضع القيم مباشرةً في خريطة Java حيث تعيش الحقول. وبما أن Lox تسمح بإنشاء حقول جديدة على النسخ بحرية، فلا حاجة للتحقّق ممّا إذا كان المفتاح موجودًا بالفعل.
الدوال على الأصناف
يمكنك إنشاء نسخ من الأصناف ووضع البيانات فيها، لكن الصنف نفسه لا يفعل شيئاً فعلياً. النسخ مجرد خرائط وكل النسخ متشابهة إلى حدٍّ ما. ولكي تبدو وكأنها نسخ من أصناف، نحتاج إلى سلوك — دوال.
المحلل النحوي النافع لدينا يحلّل إعلانات الدوال بالفعل، فلا مشكلة لدينا هناك. ولا
نحتاج أيضاً إلى أي دعم جديد في المحلل لاستدعاءات الدوال. لدينا بالفعل .
(الـ getters) و() (استدعاءات الدوال). «استدعاء الدالة» ليس سوى ربط هذين
بعضهما.
هذا يطرح سؤالاً مثيراً للاهتمام. ماذا يحدث عند فصل هذين التعبيرين؟ بافتراض أن
method في هذا المثال هي دالة على صنف object وليست حقلاً على النسخة، فما
الذي ينبغي أن تفعله الشيفرة التالية؟
var m = object.method;
m(argument);
هذا البرنامج «يبحث عن» الدالة ويخزّن النتيجة — أيًّا ما كانت — في متغيّر ثم يستدعي ذلك الكائن لاحقاً. هل هذا مسموح؟ هل يمكنك معاملة دالة كأنها دالة على النسخة؟
وماذا عن الاتجاه المعاكس؟
class Box {}
fun notMethod(argument) {
print "called function with " + argument;
}
var box = Box();
box.function = notMethod;
box.function("argument");
هذا البرنامج ينشئ نسخة ثم يخزّن دالة في حقل عليها. ثم يستدعي تلك الدالة باستخدام الصيغة نفسها التي تُستخدم في استدعاء الدوال. هل ينجح ذلك؟
الإجابات تختلف بين اللغات. يمكن كتابة رسالة مطوّلة في هذا الموضوع. أما في Lox فسنقول إن الإجابة عن الاثنين هي نعم، فهو ينجح. ولدينا سببان لتبرير ذلك. بالنسبة للمثال الثاني — استدعاء دالة مخزّنة في حقل — نريد دعمه لأن الدوال من الدرجة الأولى مفيدة وتخزينها في الحقول أمر طبيعي تماماً.
أما المثال الأول فأكثر غرابة. أحد الدوافع هو أن المستخدمين يتوقّعون عموماً القدرة على رفع تعبير فرعي إلى متغيّر محلّي دون تغيير معنى البرنامج. يمكنك أن تأخذ هذا:
breakfast(omelette.filledWith(cheese), sausage);
وتحوّله إلى هذا:
var eggs = omelette.filledWith(cheese);
breakfast(eggs, sausage);
وهو يؤدّي الشيء نفسه. وبالمثل، بما أن الـ . و() في استدعاء الدالة هما
تعبيران منفصلان، فيبدو أنه ينبغي أن تتمكّن من رفع جزء البحث إلى متغيّر ثم
استدعائه لاحقاً. علينا أن نفكّر بعناية فيما هو
الشيء الذي تحصل عليه عند البحث عن دالة، وكيف يتصرّف، حتى في الحالات الغريبة
مثل:
class Person {
sayName() {
print this.name;
}
}
var jane = Person();
jane.name = "Jane";
var method = jane.sayName;
method(); // ?
إن أمسكت مقبضاً إلى دالة على نسخة ما واستدعيتها لاحقاً، فهل هي «تتذكّر» النسخة
التي أُخذت منها؟ وهل يبقى this داخل الدالة يشير إلى ذلك الكائن الأصلي؟
إليك مثالًا أكثر تطرّفًا ليُحني عقلك:
class Person {
sayName() {
print this.name;
}
}
var jane = Person();
jane.name = "Jane";
var bill = Person();
bill.name = "Bill";
bill.sayName = jane.sayName;
bill.sayName(); // ?
هل يطبع ذلك السطر الأخير "Bill" لأن تلك هي النسخة التي استدعينا عبرها الدالة، أم "Jane" لأنها النسخة التي أخذنا الدالة منها أول مرة؟
الشيفرة المكافئة في Lua وJavaScript كانت ستطبع "Bill". فهاتان اللغتان لا تملكان
فعلاً مفهوم «الدوال». كل شيء هو شيء شبيه بدوال-في-حقول، فلا يبدو أن jane
«تملك» sayName أكثر مما تملكه bill.
لكن Lox تملك صيغة أصناف حقيقية، لذا نعرف أيّ الأشياء القابلة للاستدعاء هي دوال
وأيّها دوال عادية. وبالتالي، مثل Python وC# وغيرها، ستقوم الدوال بـ«ربط»
(bind) this بالنسخة الأصلية عندما تُؤخذ الدالة أول مرة. وتُسمّي Python
هذه دوالاً مربوطة (bound methods).
في الممارسة، هذا عادةً ما تريده. إن أخذتَ مرجعًا إلى دالة على كائن ما لتستخدمه لاحقاً كـ callback، فأنت تريد أن تتذكّر النسخة التي كان ينتمي إليها، حتى لو اتّضح أن ذلك الـ callback مخزّن في حقل على كائن آخر.
حسناً، هذا كثير من الدلالات لتحميلها في رأسك. انسَ الحالات الحدّية قليلاً الآن. سنعود إليها لاحقاً. الآن، لنجعل استدعاءات الدوال الأساسية تعمل. لقد صرنا نحلّل إعلانات الدوال داخل جسم الصنف، فالخطوة التالية هي حلّها.
^code resolve-methods (1 before, 1 after)
نتكرّر عبر الدوال في جسم الصنف ونستدعي دالة resolveFunction() التي كتبناها
للتعامل مع إعلانات الدوال بالفعل. الفرق الوحيد أننا نمرّر قيمة جديدة من تعداد
FunctionType.
^code function-type-method (1 before, 1 after)
سيكون لذلك أهمية عندما نحلّ تعبيرات this. الآن، لا تقلق بشأنه. المثير للاهتمام
في المفسّر.
^code interpret-methods (1 before, 1 after)
عندما نفسّر جملة إعلان صنف، نحوّل التمثيل النحوي للصنف — عقدة AST الخاصة به — إلى
تمثيله الزمني. والآن، علينا فعل ذلك للدوال المضمَّنة في الصنف أيضاً. كل إعلان دالة
يُزهر إلى كائن LoxFunction.
نأخذ كلّها ونلفّها في خريطة، بمفاتيح أسماء الدوال. وتُخزَّن هذه في LoxClass.
^code lox-class-methods (1 before, 3 after)
حيثما تخزّن النسخة الحالة، يخزّن الصنف السلوك. لدى LoxInstance خريطة الحقول
الخاصة به، ويحصل LoxClass على خريطة من الدوال. وبرغم أن الدوال مملوكة للصنف،
فلا يزال الوصول إليها يتم عبر نسخ ذلك الصنف.
^code lox-instance-get-method (5 before, 2 after)
عند البحث عن خاصية على نسخة، إن لم نجد حقلاً مطابقاً، فإننا نبحث عن دالة بذلك الاسم على صنف النسخة. فإن وُجدت، نعيدها. هنا هو الموضع الذي يصبح فيه التمييز بين «الحقل» و«الخاصية» ذا معنى. عند الوصول إلى خاصية، قد تحصل على حقل — قطعة حالة مخزّنة على النسخة — أو قد تصطدم بدالة معرّفة على صنف النسخة.
يُنظر إلى الدالة باستخدام هذا:
^code lox-class-find-method
يمكنك على الأرجح تخمين أن هذه الدالة ستصبح أكثر إثارة للاهتمام لاحقاً. الآن، يكفي بحث بسيط في خريطة على جدول دوال الصنف لنبدأ. جرّبها:
class Bacon {
eat() {
print "Crunch crunch crunch!";
}
}
Bacon().eat(); // Prints "Crunch crunch crunch!".
this
يسرّنا أن نعرّف السلوك والحالة معًا على الكائنات، لكنهما لم يرتبطا بعد. داخل الدالة، لا سبيل لدينا للوصول إلى حقول الكائن «الحالي» — النسخة التي استُدعيت عليها الدالة — ولا يمكننا استدعاء دوال أخرى على الكائن نفسه.
للوصول إلى تلك النسخة، تحتاج إلى اسم. تستخدم Smalltalk وRuby وSwift كلمة "self". وتستخدم Simula وC++ وJava وغيرها كلمة "this". أما Python فتستخدم "self" بالاتفاق، لكنك تقنياً تستطيع أن تسمّيها ما شئت.
بالنسبة إلى Lox، بما أننا نتبع عموماً أسلوب Java، فسنذهب مع "this". داخل جسم
الدالة، يُقيَّم تعبير this إلى النسخة التي استُدعيت عليها الدالة. أو، على نحو أدق،
بما أن الدوال يتم الوصول إليها ثم استدعاؤها كخطوتين، فسيشير إلى الكائن الذي تم
الوصول إلى الدالة منه.
هذا يجعل مهمتنا أصعب. تأمّل:
class Egotist {
speak() {
print this;
}
}
var method = Egotist().speak;
method();
في السطر قبل الأخير، نأخذ مرجعًا إلى دالة speak() من نسخة من الصنف. يعيد ذلك
دالة، وهذه الدالة تحتاج إلى أن تتذكّر النسخة التي أُخذت منها بحيث تستطيع لاحقاً،
في السطر الأخير، أن تجدها عند استدعاء الدالة.
نحتاج إلى التقاط this عند النقطة التي يتم عندها الوصول إلى الدالة وإرفاقه
بالدالة بطريقة ما بحيث يبقى باقياً طالما احتجنا إليه. هم... طريقة لتخزين بيانات
إضافية تبقى معلّقة بدالة، أليس كذلك؟ أليس هذا يشبه كثيراً الإغلاق (closure)؟
إن عرّفنا this كمتغيّر مخفي نوعًا ما في بيئة تحيط بالدالة المُعادة عند البحث عن
دالة، لاستطاعت استخدامات this في الجسم أن تجدها لاحقاً. وLoxFunction لديه
بالفعل القدرة على الاحتفاظ ببيئة محيطة، فلدينا الآلية التي نحتاجها.
لنمرّ على مثال لنرى كيف يعمل:
class Cake {
taste() {
var adjective = "delicious";
print "The " + this.flavor + " cake is " + adjective + "!";
}
}
var cake = Cake();
cake.flavor = "German chocolate";
cake.taste(); // Prints "The German chocolate cake is delicious!".
عندما نقيّم تعريف الصنف لأول مرة، ننشئ LoxFunction لـ taste(). وإغلاقه هو
البيئة المحيطة بالصنف، وفي هذه الحالة هي البيئة العامة. إذن يبدو LoxFunction
الذي نخزّنه في خريطة دوال الصنف هكذا:
عندما نقيّم تعبير get الخاص بـ cake.taste، ننشئ بيئة جديدة تربط this بالكائن
الذي يتم الوصول إلى الدالة منه (هنا cake). ثم ننتج LoxFunction جديداً له
الشيفرة نفسها التي للأصلية لكن باستخدام تلك البيئة الجديدة كإغلاق له.
هذا هو LoxFunction الذي يُعاد عند تقييم تعبير get لاسم الدالة. وعندما تُستدعى
تلك الدالة لاحقًا بتعبير ()، ننشئ بيئة لجسم الدالة كالمعتاد.
أب بيئة الجسم هو البيئة التي أنشأناها سابقًا لربط this بالكائن الحالي. وبذلك
ينجح أي استخدام لـ this داخل الجسم في الحَلّ إلى تلك النسخة.
إعادة استخدام شيفرة البيئة لدينا لتنفيذ this تعالج أيضاً حالات مهمة تتفاعل فيها
الدوال مع الدوال، مثل:
class Thing {
getCallback() {
fun localFunction() {
print this;
}
return localFunction;
}
}
var callback = Thing().getCallback();
callback();
في لغة مثل JavaScript، من الشائع إعادة callback من داخل دالة. وقد يريد ذلك
الـ callback أن يتمسّك بالكائن الأصلي — قيمة this — الذي كانت مرتبطة به الدالة.
وينبغي أن يتعامل دعمنا الحالي للإغلاق (closures) وسلاسل البيئات مع كل هذا بشكل
صحيح.
لنكتب الشيفرة. الخطوة الأولى هي إضافة صياغة جديدة
لـ this.
^code this-ast (1 before, 1 after)
التحليل بسيط لأنها رمز مفرد يتعرّف عليه محلّلنا المعجمي (scanner) أصلاً ككلمة محجوزة.
^code parse-this (2 before, 2 after)
يمكنك أن تبدأ في رؤية كيف يعمل this كمتغيّر عندما نصل إلى المحلّل.
^code resolver-visit-this
نحلّه تماماً كما نفعل مع أي متغيّر محلّي آخر باستخدام "this" كاسم «للمتغيّر». Of
course، لن ينجح ذلك الآن، لأن "this" ليس معلناً في أي نطاق. لنُصلح ذلك في
visitClassStmt().
^code resolver-begin-this-scope (2 before, 1 after)
قبل أن ننتقل إلى حلّ أجسام الدوال، ندفع نطاقاً جديداً ونعرّف "this" فيه كأنه متغيّر. ثم، عندما ننتهي، نتخلّص من ذلك النطاق المحيط.
^code resolver-end-this-scope (2 before, 1 after)
الآن، كلما صادفنا تعبير this (على الأقل داخل دالة) فسيحَلّ إلى «متغيّر محلّي»
معرَّف في نطاق ضمني يقع خارج كتلة جسم الدالة مباشرةً.
يحصل المحلّل على نطاق (scope) جديد لـ this، لذا يحتاج المفسّر إلى إنشاء
بيئة (environment) مقابلة له. تذكّر، علينا دائماً أن نُبقي سلاسل نطاقات المحلّل
والبيئات المترابطة للمفسّر متطابقة مع بعضها. في الزمن التشغيل، ننشئ البيئة بعد أن
نجد الدالة على النسخة. ونستبدل سطر الشيفرة السابق الذي كان يُعيد ببساطة
LoxFunction الخاص بالدالة بهذا:
^code lox-instance-bind-method (1 before, 3 after)
لاحظ الاستدعاء الجديد لـ bind(). يبدو هكذا:
^code bind-instance
ليس فيه much. نحن ننشئ بيئة جديدة متمحّصة داخل الإغلاق الأصلي للدالة. شيء شبيه بإغلاق داخل إغلاق. وعندما تُستدعى الدالة، ستصبح تلك أباً لبيئة جسم الدالة.
نعلن "this" كمتغيّر في تلك البيئة ونربطه بالنسخة المعطاة، وهي النسخة التي يتم
الوصول إلى الدالة منها. وهكذا، صار الـ LoxFunction المُعاد يحمل حوله عالماً
صغيراً خاصاً به يدوم، حيث "this" مربوطة بالكائن.
المهمة المتبقّية هي تفسير تعبيرات this تلك. تماماً كما في المحلّل، فهو نفسه تفسير
تعبير متغيّر.
^code interpreter-visit-this
انطلق وجرّب باستخدام مثال الكعكة السابق. بأقل من عشرين سطراً من الشيفرة، يتعامل
مفسّرنا مع this داخل الدوال حتى في كل الطرق الغريبة التي يمكن أن يتفاعل بها مع
أصناف متداخلة، ودوال داخل دوال، ومقابض على دوال، إلخ.
الاستخدامات غير الصالحة لـ this
لحظة. ماذا يحدث إن حاولت استخدام this خارج دالة؟ وماذا عن:
print this;
أو:
fun notAMethod() {
print this;
}
لا توجد نسخة ينظر إليها this ليشير إليها إن لم تكن في دالة. يمكننا أن نعطيه قيمة
افتراضية مثل nil أو نجعله خطأً زمنياً، لكن المستخدم وقع خطأً بوضوح. وكلما
عثر عليه وأصلحه في وقت أقرب، كان أسعد.
مرّ جولة الحلّ لدينا مكانٌ جيد لاكتشاف هذا الخطأ ساكناً. فهي تكتشف أصلاً جمل return
خارج الدوال. سنفعل شيئاً مشابهاً لـ this. وعلى نهج تعداد FunctionType القائم،
نعرّف تعداد ClassType جديداً.
^code class-type (1 before, 1 after)
نعم، كان يمكن أن يكون قيمة منطقية. لكن عندما نصل إلى الوراثة، سيحصل على قيمة
ثالثة، ولهذا نستخدم تعداداً الآن. نضيف أيضاً حقلاً مقابلاً هو currentClass. وقيمته
تخبرنا إن كنا داخل إعلان صنف حالياً بينما ننتقل في شجرة التركيب. يبدأ بقيمة NONE
أي أننا لسنا داخل واحد.
عندما نبدأ في حلّ إعلان صنف، نغيّر ذلك.
^code set-current-class (1 before, 1 after)
كما في currentFunction، نخزّن القيمة السابقة للحقل في متغيّر محلّي. يتيح لنا ذلك
أن نستفيد من JVM للحفاظ على مكدّس (stack) قيم currentClass. وبذلك لا نفقد تتبّع
القيمة السابقة إذا تداخل صنف داخل آخر.
بمجرد أن يتم حلّ الدوال، نقوم بـ«إخراج» ذلك المكدّس من المكدّس (pop) أي باستعادة القيمة القديمة.
^code restore-current-class (2 before, 1 after)
عندما نحلّ تعبير this، يعطينا حقل currentClass قطعة البيانات التي نحتاجها
للإبلاغ عن خطأ إذا لم يظهر التعبير منغرساً داخل جسم دالة.
^code this-outside-of-class (1 before, 1 after)
من المفترض أن يساعد ذلك المستخدمين على استخدام this بشكل صحيح، ويوفّر علينا
التعامل مع سوء الاستخدام في الزمن التشغيل داخل المفسّر.
المُنشئات والمهيّئات
يسرّنا أننا نتمكن من تقريباً كل شيء بالأصناف الآن، ومع اقترابنا من نهاية الفصل نجد أنفسنا بشكل غريب مهتمّين بالبداية. تتيح لنا الدوال والحقول حزم الحالة والسلوك معاً بحيث يبقى الكائن دائماً في تكوين صالح. لكن كيف نضمن أن كائناً جديداً تماماً يبدأ في حالة جيدة؟
لذلك نحتاج إلى مُنشئات. أجدها من أصعب أجزاء تصميم اللغة، وإن حدّقت النظر عن قرب في معظم اللغات الأخرى، ستجد تشقّقات حول إنشاء الكائنات حيث لا تتّئس دبابيس التصميم تماماً. ربما في لحظة الولادة شيء فطري الفوضى.
«بناء» كائن هو في الواقع عمليتان:
-
يُخصّص نظام التشغيل الذاكرة المطلوبة لنسخة جديدة طازجة. في معظم اللغات، هذه العملية على مستوى أساسيّ تحت ما تصل إليه شيفرة المستخدم.
-
ثم تُستدعى قطعة شيفرة يوفّرها المستخدم لتُهيّئ الكائن غير المشكَّل بعد.
هذا هو ما نفكّر فيه عادةً عندما نسمع «مُنشئ»، لكن اللغة نفسها تكون قد أنجزت بعض العمل
التحضيري لنا قبل أن نصل إلى تلك النقطة. في الواقع، فإن مفسّر Lox لدينا مغطٍّ بالفعل
عندما ينشئ كائن LoxInstance جديداً.
سنقوم الآن بالجزء المتبقّي — التهيئة المعرّفة من المستخدم. للغات تنوّعات شتى في
التدوين المستخدَم لقطعة الشيفرة التي تُهيّئ كائناً جديداً لصنف. تستخدم C++ وJava
وC# دالة اسمها يطابق اسم الصنف. وتسميها Ruby وPython init(). والأخيرة مختصرة
وجيّدة، لذا سنفعل ذلك.
في تنفيذ LoxClass لواجهة LoxCallable، نضيف بضعة أسطر أخرى.
^code lox-class-call-initializer (2 before, 1 after)
عندما يُستدعى صنف، وبعد إنشاء LoxInstance، نبحث عن دالة "init". إن وُجدت
واحدة، فإننا نربطها فوراً ونستدعيها تماماً مثل استدعاء دالة عادية. وتُمرَّر قائمة
المعاملات إلى الأمام.
تعني قائمة المعاملات تلك أننا نحتاج أيضاً إلى تعديل كيفية إعلان الصنف لعدد وسائطه.
^code lox-initializer-arity (1 before, 1 after)
إذا كان هناك مُهيّئ، فإن عدد وسائط تلك الدالة يحدّد كم عدد المعاملات التي يجب أن تمرّرها عند استدعاء الصنف نفسه. لكننا لا نشترط على الصنف أن يعرّف مُهيّئاً كتلك الملاءمة. إن لم يكن لديك مُهيّئ، فإن عدد الوسائط ما زال صفراً.
هذا كل شيء تقريباً. بما أننا نربط دالة init() قبل استدعائها، فإنها تتمكّن من
الوصول إلى this داخل جسمها. وذاك، مع المعاملات المُمرَّرة إلى الصنف، هو كل ما
تحتاجه لتتمكّن من تهيئة النسخة الجديدة كيفما تشاء.
استدعاء init() مباشرةً
كما جرت العادة، فإن استكشاف هذا الإقليم الدلالي الجديد يُخرج بعض المخلوقات الغريبة. تأمّل:
class Foo {
init() {
print this;
}
}
var foo = Foo();
print foo.init();
هل يمكنك «إعادة التهيئة» لكائن باستدعاء دالة init() الخاصة به مباشرةً؟ إن فعلت،
فماذا تُعيد؟ الإجابة المعتدلة ستكون nil لأن هذا
ما يبدو أن الجسم يُعيده.
غير ذلك — وأنا لا أحبّ التنازل لإرضاء التنفيذ بشكل عام — فإن الأمر سيصبح تنفيذنا
للمُنشئات في clox أسهل بكثير إذا قلنا إن دوال init() تُعيد this دائماً، حتى عند
استدعائها مباشرةً. ولكي نُبقي jlox متوافقة مع ذلك، نضيف شيفرة حالة خاصة صغيرة في
LoxFunction.
^code return-this (2 before, 1 after)
إذا كانت الدالة مُهيّئاً، فإننا نتجاوز قيمة الإرجاع الفعلية ونُعيد this قسراً.
وهذا يعتمد على حقل isInitializer جديد.
^code is-initializer-field (2 before, 2 after)
لا يمكننا ببساطة التحقّق ممّا إذا كان اسم LoxFunction هو "init" لأن المستخدم كان
قد عرّف دالة بذلك الاسم. في تلك الحالة، لا يوجد this لإعادته. ولتجنّب تلك
الحالة الحدّية الغريبة، سنخزّن مباشرةً ما إذا كان LoxFunction يمثّل دالة مُهيّئاً.
وهذا يعني علينا العودة وإصلاح القليلة من المواضع التي ننشئ فيها كائنات
LoxFunction.
^code construct-function (1 before, 1 after)
بالنسبة إلى إعلانات الدوال الفعلية، تكون isInitializer دائماً false. أما بالنسبة
إلى الدوال، فنتحقّق من الاسم.
^code interpreter-method-initializer (1 before, 1 after)
ثم في bind() حيث ننشئ الإغلاق الذي يربط this بدالة، نمرّر أيضاً قيمة الدالة
الأصلية.
^code lox-function-bind-with-initializer (1 before, 1 after)
الإرجاع من init()
ما زلنا لم نخرج من المأزق. كنا نفترض أن المُهيّئ المكتوب من المستخدم لا يُعيد قيمة صراحةً لأن معظم المُنشئات لا تفعل. ماذا ينبغي أن يحدث إن حاول المستخدم ما يلي:
class Foo {
init() {
return "something else";
}
}
من المؤكد أنه لن يفعل ما يريده، لذا يمكننا جعله خطأً ساكناً. العودة في المحلّل،
نضيف حالة أخرى إلى FunctionType.
^code function-type-initializer (1 before, 1 after)
نستخدم اسم الدالة التي تمت زيارتها لتحديد ما إذا كنا نحلّ مُهيّئاً أم لا.
^code resolver-initializer-type (1 before, 1 after)
وح عندما ننتقل لاحقاً إلى جملة return، نتحقّق من ذلك الحقل ونجعل الإرجاع بقيمة من
داخل دالة init() خطأً.
^code return-in-initializer (1 before, 1 after)
ما زلنا لم ننتهِ. نمنع ساكناً الإرجاع بقيمة من مُهيّئ، لكن ما زال بإمكانك استخدام
return مبكّر فارغ.
class Foo {
init() {
return;
}
}
هذا مفيد فعلاً في بعض الأوقات، لذا لا نريد منعه تماماً. بدلاً من ذلك، ينبغي أن يُعيد
this بدلاً من nil. وهذا إصلاح سهل في LoxFunction.
^code early-return-this (1 before, 1 after)
إن كنا في مُهيّئ ونفّذنا جملة return، فبدلاً من إعادة القيمة (والتي ستكون دائماً
nil)، نُعيد this مرةً أخرى.
يا سلام! كانت تلك قائمة كاملة من المهام، لكن مكافأتنا أن مفسّرنا الصغير قد نما ليكتسب
نمط برمجة كاملاً. أصناف، ودوال، وحقول، وthis، ومُنشئات. لغتنا تبدو الآن
بالغة النضج.
التحديات
-
لدينا دوال على النسخ، لكن لا توجد وسيلة لتعريف دوال «ساكنة» يمكن استدعاؤها مباشرةً على كائن الصنف نفسه. أضف دعماً لها. استخدم الكلمة المفتاحية
classقبل الدالة للإشارة إلى دالة ساكنة معلّقة على كائن الصنف.class Math { class square(n) { return n * n; } } print Math.square(3); // Prints "9".يمكنك حلّ هذا كيفما شئت، لكن «[الأصناف الفوقية][]» (metaclasses) التي تستخدمها Smalltalk وRuby منهج أنيق بشكل خاص. تلميح: اجعل
LoxClassيمتدّ منLoxInstanceوانطلق من هناك. -
تسمح Python وJavaScript بالوصول بحرية إلى حقول كائن من خارج دوالّه. أما Ruby وSmalltalk فتغلفان حالة النسخة. فقط دوال الصنف هي التي تستطيع الوصول إلى الحقول الخام، ويترك للصنف قرار أيّ حالة تُعرَض. معظم اللغات المكتوبة النوع ساكناً تقدّم محدّدات مثل
privateوpublicللتحكّم في أي أجزاء من الصنف متاحة خارجياً على أساس كل عضو.class Circle { init(radius) { this.radius = radius; } area { return 3.141592653 * this.radius * this.radius; } } var circle = Circle(4); print circle.area; // Prints roughly "50.2655".ما المقايضات بين هذه المناهج، ولماذا قد تفضّل لغة واحدةٌ على الأخرى؟
ملاحظة تصميم: النماذج الأوّلية والقوّة
في هذا الفصل، قدّمنا كيانَين زمنيَّين جديدَين: LoxClass وLoxInstance. الأوّل
هو مكان سلوك الكائنات، والثاني هو مكان الحالة. ماذا لو استطعتَ تعريف الدوال
مباشرةً على كائن مفرد، داخل LoxInstance؟ في تلك الحالة لن نحتاج إلى LoxClass
إطلاقاً. سيكون LoxInstance حزمة كاملة لتعريف سلوك كائن وحالته.
بقي أن نرغب في وجود وسيلة، بدون أصناف، لإعادة استخدام السلوك عبر نسخ متعددة.
يمكننا أن نسمح لـ LoxInstance بأن يفوّض بشكل مباشر إلى
LoxInstance آخر لإعادة استخدام حقوله ودواله، شيءٌ شبيه بالوراثة تقريباً.
سيصمّم المستخدمون برامجهم ككوكبة من الكائنات، بعضها يفوّض لبعضه الآخر للتعبير عن
التشابه. أما الكائنات المستخدَمة كوسطاء بتفويض فتمثّل كائنات «مرجعية» أو «نموذجية»
يرسّخها الآخرون. والنتيجة نظام زمني أبسط ببنية داخلية واحدة فقط وهي LoxInstance.
من هنا جاء اسم النماذج الأولية لهذا النمط. اخترعه David Ungar وRandall Smith في لغة اسمها Self. فقد توصّلا إليه بالبدء من Smalltalk واتّباع التمرين العقلي أعلاه ليريا إلى أي حدّ يمكنهم تقليصها إليه.
بقيت النماذج الأولية وقتاً طويلاً فضولاً أكاديمياً، فضولاً مثيراً ولّد أبحاثاً مثيرة لكنه لم يحدث أي أثر في عالم البرمجة الأوسع. أي، إلى أن دفع Brendan Eich بالنماذج الأولية إلى داخل JavaScript، الذي احتلّ العالم بسرعة. وقد كُتب الكثير (الكثير جداً) من الكلمات عن النماذج الأولية في JavaScript. وما إذا كان ذلك يدلّ على أنها عبقرية أم مُربكة — أو كلاهما! — يظلّ سؤالاً مفتوحاً.
لن أدخل في ما إذا كنتُ أعتقد أن النماذج الأولية فكرة جيدة للغة أم لا. لقد صنعتُ لغات نموذجية وقائمة على الأصناف، وآرائي في كليهما معقّدة. وما أريد مناقشته هو دور البساطة في اللغة.
النماذج الأولية أبسط من الأصناف — شيفرة أقلّ ليكتبها منفّذ اللغة، ومفاهيم أقلّ ليتعلّمها المستخدم ويفهمها. هل يجعلها ذلك أفضل؟ لدينا كمُعجبين لغات ميلاً إلى تعظيم الحدّ الأدنى. شخصياً، أعتقد أن البساطة جزء فقط من المعادلة. فما نريده فعلاً أن نمنحه المستخدم هو القوّة، وأنا أعرّفها بأنها:
power = breadth × ease ÷ complexity
لا شيء من هذه قياسات عددية دقيقة. فأنا أستخدم الرياضيات تشبيهاً هنا، لا كمّياً حقيقياً.
-
الاتساع (breadth) هو مدى الأشياء المختلفة التي تسمح لك اللغة بالتعبير عنها. لغة C ذات اتساع كبير — استُخدمت لكل شيء من أنظمة التشغيل إلى تطبيقات المستخدم إلى الألعاب. أما اللغات المخصّصة لنطاقات معيّنة مثل AppleScript وMatlab فأتساعها أقلّ.
-
السهولة (ease) هي مقدار الجهد الضئيل الذي يلزم لجعل اللغة تفعل ما تريد. قد يكون «قابلية الاستخدام» مصطلحاً آخر، لكنها تحمل أعباء أكثر مما أريد إدخاله. تميل اللغات «الأعلى مستوى» إلى امتلاك سهولة أكبر من «الأدنى مستوى». لدى معظم اللغات «حبيبات» (grain) تجعل بعض الأشياء أسهل في التعبير عنها من غيرها.
-
التعقيد (complexity) هو حجم اللغة (بما في ذلك نظامها الزمني ومكتباتها الأساسية وأدواتها ومنظومتها البيئية إلخ). يتحدث الناس عن كم عدد صفحات مواصفة اللغة، أو كم عدد كلماتها المفتاحية. إنه مقدار ما يجب على المستخدم أن يحمّله في ذهنه قبل أن يصبح منتِجاً في النظام. وهو نقيض البساطة.
تقليل التعقيد يُزيِد القوّة فعلاً. فكلما كان المقام أصغر كانت القيمة الناتجة أكبر، وبذلك يكون حدسنا بأن البساطة أمر جيد صحيحاً. غير أنه عند تقليل التعقيد، علينا الحذر ألّا نضحي بالاتساع أو السهولة في العملية، وإلا فقد تنخفض القوّة الإجمالية. ستكون Java لغة أبسط تماماً إذا أزالت النصوص، لكنها على الأرجح لا تتعامل جيداً مع مهام معالجة النصوص، ولا يكون إنجاز الأمور فيها بالسهولة نفسها.
إذن فالحرفة هي إيجاد تعقيد عَرَضي يمكن الاستغناء عنه — ميزات وتفاعلات في اللغة لا تستحق أثقلها بزيادة اتساع اللغة أو سهولتها.
إذا أراد المستخدمون التعبير عن برامجهم في حدود أصناف من الكائنات، فإن دمج الأصناف في اللغة يزيد السهولة في ذلك، Hopefully بهامش كبير بما يكفي للتعويض عن التعقيد المُضاف. لكن إن لم تكن هذه هي الطريقة التي يستخدم بها المستخدمون لغتك، فمن باب الأولى أن تترك الأصناف خارجاً.