الأصناف في TypeScript
مقدمة
تعتمد لغة JavaScript التقليدية على الدوال والوراثة المعتمدة على سلسلة Prototype لبناء مكونات قابلة لإعادة الاستعمال، وقد يجد بعض المبرمجين هذه الطريقة غريبة ومرهقة، خاصّة الذين ألِفوا البرمجة كائنيّة التوجه التي تعتمد على الأصناف التي ترث وظيفة (functionality) الأصناف الأساس (base classes) وتُبنَى فيها الكائنات من هذه الأصناف. بدايةً من الإصدار ECMAScript 2015 المعروف كذلك بالإصدار ECMAScript 6، يُمكن لمبرمجي JavaScript بناء التطبيقات باستخدام البرمجة كائنيّة التوجّه المعتمِدة على الأصناف. وتسمح TypeScript للمطورين باستعمال هذه التقنيات الآن، وتُترجِمها إلى لغة JavaScript تعمل على جميع المنصات المتصفحات المعروفة، دون الحاجة إلى انتظار دعم النسخة التالية من JavaScript.
الأصناف
لنلق نظرة على مثال صنف بسيط:
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
return "Hello, " + this.greeting;
}
}
let greeter = new Greeter("world");
يجب على البنية العامّة (syntax) أن تبدو مألوفة لك إن سبق وأن استعملت لغات مثل C# أو Java. هنا نُصرّح عن صنف جديد باسم Greeter
. لهذا الصنف ثلاثة عناصر: خاصيّة باسم greeting
، ودالة بانيّة (constructor)، وتابع باسم greet
.
ستُلاحظ بأنّنا نستعمل السابقة this.
للوصول إلى عناصر الصنف، وهذا يدلّ على أنّه وصول إلى العنصر (member access).
في السطر الأخير، نقوم ببناء نسخة من الصنف Greeter
باستعمال الكلمة المفتاحية new
. هذا يستدعي الدالة البانيّة التي عرّفنا مسبقًا، ما يُنشئ كائنًا جديدًا على شكل الصنف Greeter
مُنفّذًا الدالة البانيّة لتهيئته.
الوراثة
يُمكننا استعمال أنماط البرمجة كائنيّة التوجّه الشّائعة في لغة TypeScript. وأحد الأنماط الأساسية هو قابلية توسيع الأصناف لإنشاء أصناف جديدة باستخدام الوراثة (inheritance).
لنلقِ نظرةً على مثال بسيط:
class Animal {
move(distanceInMeters: number = 0) {
console.log(`Animal moved ${distanceInMeters}m.`);
}
}
class Dog extends Animal {
bark() {
console.log('Woof! Woof!');
}
}
const dog = new Dog();
dog.bark();
dog.move(10);
dog.bark();
يُوضّح هذا المثال ميزة الوراثة الأكثر مبدئيّةً: وهي أن الأصناف ترث الخاصيات والتوابع من الأصناف الأساس. هنا، الصنف Dog
صنفٌ مُشتقٌّ يَشتقُّ من الصنف الأساس Animal
بالكلمة المفتاحية extends
. عادةً ما يُطلق على الأصناف المُشتقَّة اسم "الأصناف الفرعيّة (subclasses)"، والأصناف الأساس يُطلق عليها اسم "الأصناف العليا (superclasses)".
لأنّ الصنف Dog
يوسّع وظيفة الصنف Animal
، فقد استطعنا إنشاء نسخة من الصنف Dog
لها كلا التابعين bark()
وmove()
.
لننتقل الآن إلى مثال أعقد:
class Animal {
name: string;
constructor(theName: string) { this.name = theName; }
move(distanceInMeters: number = 0) {
console.log(`${this.name} moved ${distanceInMeters}m.`);
}
}
class Snake extends Animal {
constructor(name: string) { super(name); }
move(distanceInMeters = 5) {
console.log("Slithering...");
super.move(distanceInMeters);
}
}
class Horse extends Animal {
constructor(name: string) { super(name); }
move(distanceInMeters = 45) {
console.log("Galloping...");
super.move(distanceInMeters);
}
}
let sam = new Snake("Sammy the Python");
let tom: Animal = new Horse("Tommy the Palomino");
sam.move();
tom.move(34);
يُغطّي هذا المثال بعض الميزات الأخرى التي لم نذكرها مسبقًا. نستخدم مُجدّدًا الكلمة المفتاحية extends
لإنشاء صنفين فرعيّين من الصنف Animal
، وهُما Horse
وSnake
.
من الاختلافات الموجودة بين المثال أعلاه والذي يسبقه هو أن كل صنف مشتقّ يجب أن تحتوي دالته البانية على استدعاء super()
الذي يُنفّذ الدالة البانيّة الخاصّة بالصنف الأساس. إضافةً إلى ذلك، لا بدّ من استدعاء super()
قبل الوصول إلى خاصية باستعمال this
في داخل دالة بانية. هذه قاعدة مهمّة تقضي بها TypeScript.
يوضح هذا المثال كذلك كيفيّة تجاوز (override) توابع الصنف الأساس واستبدالها بتوابع مُخصّصة للصنف الفرعي. إذ يقوم كل من الصنف Snake
والصنف Horse
بإنشاء تابع باسم move
يتجاور ويغطّي على التابع move
الموجود في الصنف Animal
، ما يمنح للتابع وظيفة خاصّة في كل صنف. لاحظ أن tom
مصرحٌ عنه على أنّه من النوع Animal
، ولأن قيمته هي Horse
، فاستدعاء tom.move(34)
سيستدعي التابع المُتجاوِز الموجود في الصنف Horse
:
Slithering...
Sammy the Python moved 5m.
Galloping...
Tommy the Palomino moved 34m.
المحدّدات public
، وprivate
، وprotected
كل شيء عام (public) افتراضيّا
تمكنّا في الأمثلة أعلاه من الوصول بحريّة إلى العناصر التي صرّحنا عنها في البرامج في كل مكان منها. إن كانت لديك خبرة في التعامل مع الأصناف في اللغات الأخرى، فقد تلاحظ بأنّنا لم نحتج إلى استعمال الكلمة المفتاحيّة public
في الأمثلة أعلاه لجعل العناصر عامة، إذ تتطلّب مثلًا لغة C# أن تسبق الكلمة المفتاحيّةُ public
العناصرَ بوضوح لكي تكون مرئيّة. أمّا في TypeScript، فجميع العناصر عامّة بشكل افتراضيّ.
لكنّك لا تزال تستطيع تعليم (mark) عنصر بالكلمة public
صراحةً (explicitly). إذ كان يُمكن كتابة الصنف Animal
السابق كما يلي:
class Animal {
public name: string;
public constructor(theName: string) { this.name = theName; }
public move(distanceInMeters: number) {
console.log(`${this.name} moved ${distanceInMeters}m.`);
}
}
المُحدّد private
عندما يُحدَّد عنصر بالمُحدِّد private
، فهذا يعني بأنّه عنصر خاصّ لا يُمكن الوصول إليه من خارج الصنف الذي يحتوي عليه. مثلًا:
class Animal {
private name: string;
constructor(theName: string) { this.name = theName; }
}
new Animal("Cat").name;
// خطأ، العنصر
// 'name'
// عنصرٌ خاصّ
تعتمد لغة TypeScript على نظام أنواع هيكلي (structural type system). عندما نقارن بين نوعين مختلفين، فسنقول بأنّهما متوافقان إذا كانت أنواع جميع العناصر متوافقة، وذلك بغضّ النظر عن مصدر هاذين النوعين.
لكن عند المقارنة بين نوعين يحتويان على عناصر خاصّة (private
) وعناصر محميّة (protected
)، فإنّنا نتعامل مع هاذين النوعين على أنّهما مختلفان. ولكي يتوافق نوعان لدى أحدهما عنصر خاص، فعلى الآخر أن يحتوي على عنصر خاص تأصّل من نفس التصريح. ونفس المبدأ ينطبق على العناصر المحميّة.
بالمثال يتّضح المقال:
class Animal {
private name: string;
constructor(theName: string) { this.name = theName; }
}
class Rhino extends Animal {
constructor() { super("Rhino"); }
}
class Employee {
private name: string;
constructor(theName: string) { this.name = theName; }
}
let animal = new Animal("Goat");
let rhino = new Rhino();
let employee = new Employee("Bob");
animal = rhino;
animal = employee; // Error
// خطأ النوعان
// 'Animal'و 'Employee'
// ليسا متوافقين
في هذا المثال، لدينا الصنف Animal
والصّنف Rhino
، مع كون الصنف Rhino
صنفًا فرعيًّا من الصنف Animal
. لدينا كذلك صنف جديد باسم Employee
مُشابهٍ للصنف Animal
في شكله. نُنشئُ نسخًا من هذه الأصناف ونحاول إسنادها لبعضها البعض لنرى ما سيحدث. ولأنّ Animal
وRhino
يتشاركان في الجانب الخاص (private side) من شكلهما من نفس التصريح private name: string
الموجود داخل الصنف Animal
، فهُما متوافقان. لكن الصنف Employee
لا يوجد في نفس الحالة. ونحصل على خطئ عندما نحاول تعيين نسخة من الصنف Employee
إلى نسخة من الصنف Animal
، يُخبرنا هذا الخطأ بأنّ النوعين ليسا متوافقين. ورغم أنّ للصنف Employee
عنصرًا خاصًّا باسم name
، إلّا أنّ هذا العنصر ليس هو نفسه ذلك الموجود في الصنف Animal
.
المُحدّد protected
يتصرّف المُحدّد protected
مثل المُحدّد private
باستثناء أنّ العناصر التي يُصرّح عنها على أنّها محميّة بالمُحدّد protected
قابلة للوصول إليها من الأصناف المشتقّة. مثلًا:
class Person {
protected name: string;
constructor(name: string) { this.name = name; }
}
class Employee extends Person {
private department: string;
constructor(name: string, department: string) {
super(name);
this.department = department;
}
public getElevatorPitch() {
return `Hello, my name is ${this.name} and I work in ${this.department}.`;
}
}
let howard = new Employee("Howard", "Sales");
console.log(howard.getElevatorPitch());
console.log(howard.name); // خطأ
لاحظ أنّه رغم أنّنا لا نستطيع الوصول إلى العنصر name
من خارج الصنف Person
، إلا أنّه لا يزال بإمكاننا استعمالها من داخل تابع نسخة (instance method) في الصنف Employee
لأن الصنف Employee
يرث من الصنف Person
.
يُمكن كذلك تعليم الدالة البانية بالمُحدّد protected
. هذا يعني بأنّ الصنف لا يُمكن أن يُهيّأ (instantiated) أي لا يمكن أن تُنشأ نسخة منه خارج الصنف الذي يحتويه، لكنّ توسيعه (أي الوراثة منه) ممكن. مثلًا:
class Person {
protected name: string;
protected constructor(theName: string) { this.name = theName; }
}
// يُمكن للصنف
// Employee
// أن يُوسِّعَ الصنف
// Person
class Employee extends Person {
private department: string;
constructor(name: string, department: string) {
super(name);
this.department = department;
}
public getElevatorPitch() {
return `Hello, my name is ${this.name} and I work in ${this.department}.`;
}
}
let howard = new Employee("Howard", "Sales");
let john = new Person("John"); // خطأ، لا يُمكن إنشاء نسخة من الصنف لأنّ دالّته البانيّة محميّة
محدد قابلية القراءة فقط (Readonly modifier)
يُمكنك تحديد خاصيةٍ على أنها قابلة للقراءة فقط باستعمال الكلمة المفتاحية readonly
. يجب على الخاصيات القابلة للقراءة فقط أن تُهيأ (initialized) عند التصريح عنها أو داخل الدالة البانيّة.
class Octopus {
readonly name: string;
readonly numberOfLegs: number = 8;
constructor (theName: string) {
this.name = theName;
}
}
let dad = new Octopus("Man with the 8 strong legs");
dad.name = "Man with the 3-piece suit"; // خطأ، الخاصية قابلة للقراءة فقط، لا يمكن إسناد قيمة لها
خاصيات المعاملات (Parameter properties)
في المثال أعلاه، توجّب علينا التصريح عن عنصر قابل للقراءة فقط باسم name
ومعامل للدالة البانيّة باسم theName
في الصنف Octopus
، وبعدها فورًا نُعيّن قيمة theName
للخاصية name
. طريقة العمل هذه شائعة جدًا. لذا فخاصيات المعاملات ميّزة تسمح لنا بإنشاء وتهيئة عنصر في مكان واحد. ما يلي مراجعة للصنف Octopus
باستخدام خاصية معامل:
class Octopus {
readonly numberOfLegs: number = 8;
constructor(readonly name: string) {
}
}
لاحظ كيف أنّنا تخلّينا عن استخدام theName
كليًّا واستعملنا العبارة المختصرة readonly name: string
كمعامل للدالة البانية لإنشاء وتهيئة العنصر name
. وقد جمعنا التصريحات مع التعيين في مكان واحد.
يُصرَّح عن خاصيات المعاملات عبر سَبْقِ معاملات الدالة البانية بمُحدّد وصول أو الكلمة المفتاحية readonly
، أو كليهما معًا. استخدام private
مع خاصية معامل يُصرّح عن عنصر خاص ويهيئُه؛ ونفس المبدأ ينطبق مع كل من public
، وprotected
، وreadonly
.
توابع الوصول (Accessors)
تدعم TypeScript توابع الجلب (getters) التي تحصل على قيم الخاصيات، وتوابع الضبط (setters) التي تضبط قيمًا للخاصيات، وهذه ميزة تُستخدم لتعديل طريقة عمل البرامج عند الوصول إلى عناصر كائن معيّن. وهذا يُعطيك طريقة أفضل للتحكم في كيفية الوصول إلى العناصر على كل كائن.
لنُحوِّل صنفًا بسيطًا ليستعمل الكلمتين المفتاحيتين get
وset
. أولًا، لنبدأ بمثال خالٍ من توابع الجلب وتوابع الضبط:
class Employee {
fullName: string;
}
let employee = new Employee();
employee.fullName = "Bob Smith";
if (employee.fullName) {
console.log(employee.fullName);
}
السماح للناس بضبط قيمة للخاصية fullName
التي تُمثّل الاسم الكامل للموظّف يُسهّل المأمورية، إلا أن السماح للناس بتغيير الأسماء بهذه البساطة قد يجلب لنا المشاكل.
في النسخة أدناه، نتحقّق من أن هناك جملة سرية (المتغيّر passcode
) قبل السماح بتعديل بيانات الموظف. نقوم بهذا عبر استبدال الوصول المباشر (direct access) إلى الخاصية fullName
بتابع set
يتحقق من وجود الجملة السرية وصحّتها قبل تغيير الاسم. ونُضيف تابع get
للسماح بالمثال السابق بالعمل كما كان:
let passcode = "secret passcode";
class Employee {
private _fullName: string;
get fullName(): string {
return this._fullName;
}
set fullName(newName: string) {
if (passcode && passcode == "secret passcode") {
this._fullName = newName;
}
else {
console.log("Error: Unauthorized update of employee!");
}
}
}
let employee = new Employee();
employee.fullName = "Bob Smith";
if (employee.fullName) {
console.log(employee.fullName);
}
للتحقق من أن تابع الوصول يتحقق الآن من الجملة السرية، يُمكن تغييرها والنظر فيما إذا كانت هناك رسالة تُنبّهنا إلى أنّنا لا نملك امتيازات تسمح لنا بتحديث بيانات الموظف.
هناك بعض الأمور المتعلقة بتوابع الوصول لأخذها بعين الاعتبار:
- تتطلّب توابع الوصول ضبطَ المُترجم (compiler) لإخراج شيفرة تدعم النسخة ECMAScript 5 من JavaScript أو النسخ الأحدث منها. وتخفيض المستوى إلى ECMAScript 3 لا يُمكن.
- عند استعمال تابع الوصول
get
دون تابع الوصول set
فهذا يعني بأن الخاصية قابلة للقراءة فقط readonly
تلقائيًّا. هذا مُفيد عند توليد ملفّ .d.ts
من شيفرتك، لأن مستخدمي الخاصية سيُلاحظون بأنهم لا يستطيعون تغيير قيمتها.
الخاصيات الساكنة (Static Properties)
إلى الآن، تحدثنا فقط عن عناصر النسخة في الصنف، وهي العناصر التي تظهر عندما يُهيّئ الكائن. لكن يُمكننا إنشاء عناصر ساكنة كذلك، وهي العناصر التي تكون ظاهرة على الصنف ذاته عوضًا عن ظهورها على النسخ. في هذا المثال، نستعمل الكلمة المفتاحية static
على نقطة الأصل origin
، وذلك لأنها قيمة عامة لجميع الشبكات (grids). بحيث تصل كل نسخة إلى هذه القيمة عبر وضع اسم الصنف قبلها كسابقة. وكما نستعمل السابقة this.
عند الوصول إلى بيانات النسخ، فإنّنا نستعمل السابقة Grid.
للوصول إلى البيانات الساكنة.
class Grid {
static origin = {x: 0, y: 0};
calculateDistanceFromOrigin(point: {x: number; y: number;}) {
let xDist = (point.x - Grid.origin.x);
let yDist = (point.y - Grid.origin.y);
return Math.sqrt(xDist * xDist + yDist * yDist) / this.scale;
}
constructor (public scale: number) { }
}
let grid1 = new Grid(1.0); // 1x scale
let grid2 = new Grid(5.0); // 5x scale
console.log(grid1.calculateDistanceFromOrigin({x: 10, y: 10}));
console.log(grid2.calculateDistanceFromOrigin({x: 10, y: 10}));
الأصناف المجرَّدة (Abstract Classes)
الأصناف المجرَّدة هي أصناف أساس (base classes) قد تُشتَقّ منها أصناف أخرى. ويُمكن ألا تُهيّأ مباشرة. وعلى النقيض من الواجهات، يُمكن للأصناف المجرَّدة أن تحتوي على تفاصيل تطبيق (implementation details) لعناصرها. تُستعمل الكلمة المفتاحية abstract
لتعريف الأصناف المُجرّدة وتعريف التوابع المجرّدة داخل صنف مجرّد:
abstract class Animal {
abstract makeSound(): void;
move(): void {
console.log("roaming the earth...");
}
}
التوابع المجردة الموجودة داخل صنف مُجرد لا تحتوي أي تطبيق (implementation) ويجب أن تُطبَّق على الصنف المشتَق. بنية التوابع المجردة مشابهة لتوابع الواجهات، إذ كلاهما يُعرِّف توقيع التابع دون جسمه (أي الشيفرة التي تكون داخل التابع). لكن على التوابع المجرّدة أن تُسبَق بالكلمة المفتاحية abstract
ويُمكن أن تحتوي على محدّدات وصول (access modifiers) اختياريًّا:
abstract class Department {
constructor(public name: string) {
}
printName(): void {
console.log("Department name: " + this.name);
}
abstract printMeeting(): void; // يجب تطبيقها في الأصناف المشتقّة
}
class AccountingDepartment extends Department {
constructor() {
// يجب على الدوال البانيّة داخل الأصناف المشتقّة استدعاء الدالة
// super()
super("Accounting and Auditing");
}
printMeeting(): void {
console.log("The Accounting Department meets each Monday at 10am.");
}
generateReports(): void {
console.log("Generating accounting reports...");
}
}
let department: Department; // إنشاء مرجع يشير إلى نوع مجرَّدٍ أمرٌ مسموح به
department = new Department(); // خطأ، لا يمكن إنشاء نسخة من صنف مجرّد
department = new AccountingDepartment(); // إنشاء نسخة من صنف فرعي غير مجرّد وإسنادها إلى متغيّر مسموح به
department.printName();
department.printMeeting();
department.generateReports(); // خطأ، التابع غير موجود في التصريح عن النوع المجرّد
تقنيات متقدمة
الدوال البانية (Constructor functions)
عند التصريح عن صنف في TypeScript، فإنّك في الواقع تُنشئ عدة تصريحات في نفس الوقت. الأول هو نوع نُسخةِ الصّنف (the instance of the class).
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
return "Hello, " + this.greeting;
}
}
let greeter: Greeter;
greeter = new Greeter("world");
console.log(greeter.greet());
نستعمل في التصريح let greeter: Greeter
في المثال أعلاه الصنف Greeter
ليكون نوع نسخ الصنف Greeter
. هذا الأمر مألوف جدا للمبرمجين المعتادين على لغات أخرى كائنيّة التوجه.
وننشئ كذلك قيمة أخرى نُسميها بالدالة البانيّة. هذه الدالة هي التي تُستدعَى عند إنشاء نُسخ من الصنف بالكلمة المفتاحية new
. لنُلقِ نظرة على شيفرة JavaScript التي يُولدها المثال أعلاه لمعرفة كيف يعمل هذا عمليًّا:
let Greeter = (function () {
function Greeter(message) {
this.greeting = message;
}
Greeter.prototype.greet = function () {
return "Hello, " + this.greeting;
};
return Greeter;
})();
let greeter;
greeter = new Greeter("world");
console.log(greeter.greet());
سيُعيَّن التصريح let Greeter
هنا للدالة البانية. نحصل على نسخة من الصنف عند استدعاء هذه الدالة وتنفيذها بالكلمة المفتاحية new
. تحتوي الدالة البانية كذلك على جميع عناصر الصنف الساكنة. يُمكن النظر إلى كل صنف على أنّ له جانبَ نسخة (instance side) وجانبًا ساكنًا (static side).
لنُعدّل المثال قليلًا لإظهار الفرق:
class Greeter {
static standardGreeting = "Hello, there";
greeting: string;
greet() {
if (this.greeting) {
return "Hello, " + this.greeting;
}
else {
return Greeter.standardGreeting;
}
}
}
let greeter1: Greeter;
greeter1 = new Greeter();
console.log(greeter1.greet());
let greeterMaker: typeof Greeter = Greeter;
greeterMaker.standardGreeting = "Hey there!";
let greeter2: Greeter = new greeterMaker();
console.log(greeter2.greet());
في هذا المثال، يعمل greeter1
كما سبق. إذ نُنشئ نسخة من الصنف Greeter
، ونستعمل هذا الكائن. وقد رأينا هذا من قبل.
ثمّ بعدها نستعمل الصنف مباشرة. وننشئ هنا متغيّرًا جديدًا باسم greeterMaker
. سيحمل هذا المتغيّر الصنف نفسه، أو بالأحرى، دالّتَه البانيّة. نستعمل هنا typeof Greeter
وكأنّنا نقول "أعطني نوع الصنف Greeter
نفسه" عوضًا عن نوع النسخة. أو بدقّة أكثر، وكأنّنا نقول "أعطني نوع الرمز (symbol) المُسمّى بالاسم Greeter
" وهو نوع الدالة البانيّة. يحتوي هذا النوع على جميع العناصر الساكنة للصنف Greeter
إضافة إلى الدالة البانيّة التي تُنشئ نسخًا من الصنف Greeter
. ونُظهر هذا عبر استخدام الكلمة المفتاحية new
على greeterMaker
، مُنشئين نسخًا جديدة من Greeter
مع استعمالها كما سبق.
استعمال صنف كواجهة (interface)
يُنشئ التصريح عن صنف كما قلنا سابقًا شيئين اثنين: نوع يُمثّل نسخ الصنف، ودالة بانيّة. ولأن الأصناف تُنشِئ أنواعًا، فيُمكن استعمالها في نفس الأماكن التي يُمكن استعمال الواجهات فيها.
class Point {
x: number;
y: number;
}
interface Point3d extends Point {
z: number;
}
let point3d: Point3d = {x: 1, y: 2, z: 3};