فهم جينات TypeScript
نشرت: 2022-03-10في هذه المقالة ، سنتعلم مفهوم Generics في TypeScript ونفحص كيف يمكن استخدام Generics لكتابة كود معياري ، منفصل ، وقابل لإعادة الاستخدام. على طول الطريق ، سنناقش بإيجاز كيفية ملاءمتها لأنماط اختبار أفضل ، وأساليب معالجة الأخطاء ، وفصل الوصول إلى المجال / البيانات.
مثال من العالم الحقيقي
أريد الدخول إلى عالم Generics ليس من خلال شرح ما هي عليه ، ولكن من خلال تقديم مثال بديهي عن سبب فائدتها. افترض أنك قد تم تكليفك بإنشاء قائمة ديناميكية غنية بالميزات. يمكنك تسميتها مصفوفة أو ArrayList
أو List
أو std::vector
أو أيًا كان ، اعتمادًا على خلفية لغتك. ربما يجب أن تحتوي بنية البيانات هذه على أنظمة مخزن مؤقت مدمجة أو قابلة للتبديل (مثل خيار إدراج المخزن المؤقت الدائري). سيكون غلافًا حول مصفوفة JavaScript العادية حتى نتمكن من العمل مع هيكلنا بدلاً من المصفوفات العادية.
المشكلة الفورية التي ستواجهها هي القيود التي يفرضها نظام الكتابة. لا يمكنك ، في هذه المرحلة ، قبول أي نوع تريده في وظيفة أو طريقة بطريقة نظيفة لطيفة (سنعيد النظر في هذه العبارة لاحقًا).
الحل الوحيد الواضح هو تكرار بنية البيانات الخاصة بنا لجميع الأنواع المختلفة:
const intList = IntegerList.create(); intList.add(4); const stringList = StringList.create(); stringList.add('hello'); const userList = UserList.create(); userList.add(new User('Jamie'));
قد .create()
new SomethingList()
الجملة. داخليًا ، تستدعي طريقة create
المُنشئ.
هذا مريع. لدينا الكثير من المنطق داخل هيكل المجموعة هذا ، ونحن نكرره بشكل صارخ لدعم حالات الاستخدام المختلفة ، مما يكسر تمامًا مبدأ DRY في هذه العملية. عندما نقرر تغيير التنفيذ الخاص بنا ، سيتعين علينا نشر / عكس هذه التغييرات يدويًا عبر جميع الهياكل والأنواع التي ندعمها ، بما في ذلك الأنواع التي يحددها المستخدم ، كما في المثال الأخير أعلاه. لنفترض أن بنية المجموعة نفسها كانت بطول 100 سطر - سيكون بمثابة كابوس للحفاظ على العديد من التطبيقات المختلفة حيث يكون الاختلاف الوحيد بينها هو الأنواع.
الحل الفوري الذي قد يتبادر إلى الذهن ، خاصة إذا كان لديك عقلية OOP ، هو التفكير في "النوع الفائق" الجذري إذا صح التعبير. في C # ، على سبيل المثال ، يتكون نوع من اسم object
، object
هو اسم مستعار لفئة System.Object
. في نظام نوع C # ، ترث جميع الأنواع ، سواء كانت محددة مسبقًا أو معرفة من قبل المستخدم وسواء كانت أنواع مرجعية أو أنواع قيم ، إما بشكل مباشر أو غير مباشر من System.Object
. هذا يعني أنه يمكن تعيين أي قيمة لمتغير من نوع object
(دون الدخول في دلالات المكدس / الكومة والملاكمة / إلغاء العبوة).
في هذه الحالة ، يبدو أن مشكلتنا قد تم حلها. يمكننا فقط استخدام نوع مثل any
نوع وهذا سيسمح لنا بتخزين أي شيء نريده داخل مجموعتنا دون الحاجة إلى تكرار الهيكل ، وهذا صحيح بالفعل:
const intList = AnyList.create(); intList.add(4); const stringList = AnyList.create(); stringList.add('hello'); const userList = AnyList.create(); userList.add(new User('Jamie'));
لنلقِ نظرة على التنفيذ الفعلي لقائمتنا باستخدام any
من:
class AnyList { private values: any[] = []; private constructor (values: any[]) { this.values = values; // Some more construction work. } public add(value: any): void { this.values.push(value); } public where(predicate: (value: any) => boolean): AnyList { return AnyList.from(this.values.filter(predicate)); } public select(selector: (value: any) => any): AnyList { return AnyList.from(this.values.map(selector)); } public toArray(): any[] { return this.values; } public static from(values: any[]): AnyList { // Perhaps we perform some logic here. // ... return new AnyList(values); } public static create(values?: any[]): AnyList { return new AnyList(values ?? []); } // Other collection functions. // ... }
جميع العمليات بسيطة نسبيًا ، لكننا سنبدأ بالمنشئ. إن رؤيتها خاصة ، لأننا سنفترض أن قائمتنا معقدة ونرغب في عدم السماح بالبناء التعسفي. قد نرغب أيضًا في تنفيذ المنطق قبل الإنشاء ، لذلك لهذه الأسباب ، وللحفاظ على المُنشئ خالصًا ، فإننا نفوض هذه المخاوف إلى أساليب المصنع / المساعد الثابت ، والتي تعتبر ممارسة جيدة.
يتم توفير الطرق الثابتة from
create
. يقبل الأسلوب from
مصفوفة من القيم ، وينفذ منطقًا مخصصًا ، ثم يستخدمها لإنشاء القائمة. تأخذ طريقة create
الثابتة مجموعة اختيارية من القيم في حالة رغبتنا في زرع قائمتنا بالبيانات الأولية. يتم استخدام "عامل الاندماج الصفري" ( ??
) لإنشاء القائمة بمصفوفة فارغة في حالة عدم توفيرها. إذا كان الجانب الأيسر من المعامل null
أو undefined
، فسنعود إلى الجانب الأيمن ، لأنه في هذه الحالة ، تكون values
اختيارية ، وبالتالي قد تكون undefined
. يمكنك معرفة المزيد حول الاندماج الصفري في صفحة توثيق TypeScript ذات الصلة.
لقد أضفت أيضًا طريقة select
where
. هذه الطرق تقوم فقط بلف map
JavaScript filter
على التوالي. select
يسمح لنا بإسقاط مجموعة من العناصر في نموذج جديد بناءً على وظيفة المحدد المتوفرة ، where
يسمح لنا بتصفية عناصر معينة بناءً على الوظيفة الأصلية المتوفرة. يقوم toArray
ببساطة بتحويل القائمة إلى مصفوفة عن طريق إرجاع مرجع المصفوفة الذي نحتفظ به داخليًا.
أخيرًا ، افترض أن فئة User
تحتوي على طريقة getName
التي تُرجع اسمًا وتقبل أيضًا اسمًا باعتباره وسيطة المُنشئ الأولى والوحيدة.
ملاحظة: سيتعرف بعض القراء علىWhere
andSelect
from C #'s LINQ ، لكن ضع في اعتبارك أنني أحاول أن أبقي هذا بسيطًا ، وبالتالي لست قلقًا بشأن الكسل أو التنفيذ المؤجل. هذه تحسينات يمكن ويجب إجراؤها في الحياة الواقعية.
علاوة على ذلك ، كملاحظة شيقة ، أود مناقشة معنى "المسند". في الرياضيات المتقطعة والمنطق الإرشادي ، لدينا مفهوم "الاقتراح". الاقتراح هو بعض العبارات التي يمكن اعتبارها صحيحة أو خاطئة ، مثل "أربعة يقبل القسمة على اثنين". "المسند" هو اقتراح يحتوي على متغير واحد أو أكثر ، وبالتالي فإن مصداقية الاقتراح تعتمد على تلك المتغيرات. يمكنك التفكير في الأمر مثل دالة ، مثلP(x) = x is divisible by two
، لأننا نحتاج إلى معرفة قيمةx
لتحديد ما إذا كانت العبارة صحيحة أم خاطئة. يمكنك معرفة المزيد عن المنطق الأصلي هنا.
هناك بعض المشكلات التي ستنشأ عن استخدام any
منها. لا يعرف مترجم TypeScript شيئًا عن العناصر الموجودة داخل القائمة / المصفوفة الداخلية ، وبالتالي لن يقدم أي مساعدة داخل where
أو select
أو عند إضافة العناصر:
// Providing seed data. const userList = AnyList.create([new User('Jamie')]); // This is fine and expected. userList.add(new User('Tom')); userList.add(new User('Alice')); // This is an acceptable input to the TS Compiler, // but it's not what we want. We'll definitely // be surprised later to find strings in a list // of users. userList.add('Hello, World!'); // Also acceptable. We have a large tuple // at this point rather than a homogeneous array. userList.add(0); // This compiles just fine despite the spelling mistake (extra 's'): // The type of `users` is any. const users = userList.where(user => user.getNames() === 'Jamie'); // Property `ssn` doesn't even exist on a `user`, yet it compiles. users.toArray()[0].ssn = '000000000'; // `usersWithId` is, again, just `any`. const usersWithId = userList.select(user => ({ id: newUuid(), name: user.getName() })); // Oops, it's "id" not "ID", but TS doesn't help us. // We compile just fine. console.log(usersWithId.toArray()[0].ID);
نظرًا لأن TypeScript يعرف فقط أن نوع جميع عناصر المصفوفة موجود ، any
يمكن أن يساعدنا في وقت التجميع مع الخصائص غير الموجودة أو وظيفة getNames
غير الموجودة حتى ، وبالتالي سيؤدي هذا الرمز إلى العديد من أخطاء وقت التشغيل غير المتوقعة .
بصراحة ، بدأت الأمور تبدو كئيبة للغاية. حاولنا تنفيذ هيكل البيانات الخاص بنا لكل نوع ملموس نرغب في دعمه ، لكننا سرعان ما أدركنا أن ذلك لم يكن قابلاً للصيانة بأي شكل من الأشكال. بعد ذلك ، اعتقدنا أننا سنصل إلى مكان ما باستخدام any
منها ، وهو مشابه للاعتماد على نوع جذر كبير في سلسلة وراثية تشتق منها جميع الأنواع ، لكننا استنتجنا أننا نفقد أمان النوع بهذه الطريقة. ما الحل إذن؟
اتضح ، في بداية المقال ، كذبت (نوعًا ما):
"لا يمكنك ، في هذه المرحلة ، قبول أي نوع تريده في وظيفة أو طريقة بطريقة لطيفة ونظيفة."
يمكنك فعلاً ، وهنا يأتي دور علم Generics. لاحظ أنني قلت "في هذه المرحلة" ، لأنني كنت أفترض أننا لم نكن نعرف عن Generics في تلك المرحلة من المقالة.
سأبدأ بإظهار التنفيذ الكامل لهيكل قائمتنا مع Generics ، وبعد ذلك سنتراجع خطوة إلى الوراء ، ونناقش ما هي عليه بالفعل ، ونحدد بناء الجملة بشكل أكثر رسمية. لقد قمت بتسميتها TypedList
للتمييز عن AnyList
السابقة الخاصة بنا:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T): void { this.values.push(value); } public where(predicate: (value: T) => boolean): TypedList<T> { return TypedList.from<T>(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U): TypedList<U> { return TypedList.from<U>(this.values.map(selector)); } public toArray(): T[] { return this.values; } public static from<U>(values: U[]): TypedList<U> { // Perhaps we perform some logic here. // ... return new TypedList<U>(values); } public static create<U>(values?: U[]): TypedList<U> { return new TypedList<U>(values ?? []); } // Other collection functions. // .. }
دعنا نحاول ارتكاب نفس الأخطاء السابقة مرة أخرى:
// Here's the magic. `TypedList` will operate on objects // of type `User` due to the `<User>` syntax. const userList = TypedList.create<User>([new User('Jamie')]); // The compiler expects this. userList.add(new User('Tom')); userList.add(new User('Alice')); // Argument of type '0' is not assignable to parameter // of type 'User'. ts(2345) userList.add(0); // Property 'getNames' does not exist on type 'User'. // Did you mean 'getName'? ts(2551) // Note: TypeScript infers the type of `users` to be // `TypedList<User>` const users = userList.where(user => user.getNames() === 'Jamie'); // Property 'ssn' does not exist on type 'User'. ts(2339) users.toArray()[0].ssn = '000000000'; // TypeScript infers `usersWithId` to be of type // `TypedList<`{ id: string, name: string }> const usersWithId = userList.select(user => ({ id: newUuid(), name: user.getName() })); // Property 'ID' does not exist on type '{ id: string; name: string; }'. // Did you mean 'id'? ts(2551) console.log(usersWithId.toArray()[0].ID)
كما ترى ، فإن برنامج التحويل البرمجي TypeScript يساعدنا بنشاط في أمان الكتابة. كل هذه التعليقات هي أخطاء تلقيتها من المترجم عند محاولة تجميع هذا الرمز. سمحت لنا Generics بتحديد النوع الذي نرغب في السماح لقائمتنا بالعمل عليه ، ومن ذلك ، يمكن لـ TypeScript معرفة أنواع كل شيء ، وصولاً إلى خصائص الكائنات الفردية داخل المصفوفة.
يمكن أن تكون الأنواع التي نقدمها بسيطة أو معقدة كما نريدها. هنا ، يمكنك أن ترى أنه يمكننا تمرير كل من الأساسيات والواجهات المعقدة. يمكننا أيضًا تمرير المصفوفات أو الفئات الأخرى أو أي شيء:
const numberList = TypedList.create<number>(); numberList.add(4); const stringList = TypedList.create<string>(); stringList.add('Hello, World'); // Example of a complex type interface IAircraft { apuStatus: ApuStatus; inboardOneRPM: number; altimeter: number; tcasAlert: boolean; pushBackAndStart(): Promise<void>; ilsCaptureGlidescope(): boolean; getFuelStats(): IFuelStats; getTCASHistory(): ITCASHistory; } const aircraftList = TypedList.create<IAircraft>(); aircraftList.add(/* ... */); // Aggregate and generate report: const stats = aircraftList.select(a => ({ ...a.getFuelStats(), ...a.getTCASHistory() }));
الاستخدامات الغريبة لـ T
و U
و <T>
و <U>
في تطبيق TypedList<T>
هي أمثلة على علم Generics في العمل. بعد استيفاء توجيهاتنا لبناء هيكل جمع آمن من النوع ، سنترك هذا المثال وراءنا في الوقت الحالي ، وسنعود إليه بمجرد أن نفهم ماهية Generics في الواقع ، وكيف تعمل ، وبناء الجملة. عندما أتعلم مفهومًا جديدًا ، أود دائمًا أن أبدأ برؤية مثال معقد للمفهوم قيد الاستخدام ، بحيث عندما أبدأ في تعلم الأساسيات ، يمكنني إجراء روابط بين الموضوعات الأساسية والمثال الحالي الذي لدي في رئيس.
ما هي الوراثة؟
من الطرق البسيطة التي يمكن من خلالها فهم علم Generics اعتبارها مماثلة نسبيًا للعناصر النائبة أو المتغيرات ولكن للأنواع. هذا لا يعني أنه يمكنك إجراء نفس العمليات على عنصر نائب من النوع العام كما يمكنك متغيرًا ، ولكن يمكن اعتبار متغير النوع العام كعنصر نائب يمثل نوعًا ملموسًا سيتم استخدامه في المستقبل. أي أن استخدام Generics هو طريقة لكتابة البرامج من حيث الأنواع التي سيتم تحديدها في وقت لاحق. السبب وراء فائدة ذلك هو أنه يسمح لنا ببناء هياكل البيانات التي يمكن إعادة استخدامها عبر الأنواع المختلفة التي تعمل عليها (أو حيادية النوع).
هذا ليس أفضل التفسيرات بشكل خاص ، لذلك لوضعها بعبارات أكثر بساطة ، كما رأينا ، من الشائع في البرمجة أننا قد نحتاج إلى بناء بنية دالة / فئة / بيانات تعمل على نوع معين ، ولكن من الشائع أيضًا أن بنية البيانات هذه تحتاج إلى العمل عبر مجموعة متنوعة من الأنواع المختلفة أيضًا. إذا كنا عالقين في وضع حيث كان علينا أن نعلن بشكل ثابت النوع الملموس الذي سيعمل عليه هيكل البيانات في الوقت الذي نصمم فيه بنية البيانات (في وقت التجميع) ، فسنجد بسرعة أننا بحاجة إلى إعادة بناء تلك الهياكل تقريبًا بنفس الطريقة تقريبًا لكل نوع نرغب في دعمه ، كما رأينا في الأمثلة أعلاه.
تساعدنا العوامل الوراثية على حل هذه المشكلة من خلال السماح لنا بتأجيل متطلبات نوع الخرسانة حتى يتم التعرف عليها بالفعل.
علم الوراثة في TypeScript
لدينا الآن فكرة عضوية إلى حد ما عن سبب فائدة علم Generics وقد رأينا مثالًا معقدًا بعض الشيء في الممارسة. بالنسبة لمعظم الأشخاص ، من المحتمل أن يكون تنفيذ TypedList<T>
بالفعل ، خاصة إذا كنت تأتي من خلفية لغة مكتوبة بشكل ثابت ، لكن يمكنني أن أتذكر أنني واجهت صعوبة في فهم المفهوم عندما كنت أتعلم لأول مرة ، وبالتالي أريد أن بناء على هذا المثال من خلال البدء بوظائف بسيطة. من المعروف أن المفاهيم المتعلقة بالتجريد في البرمجيات يصعب استيعابها ، لذلك إذا لم يتم النقر على فكرة Generics بعد ، فلا بأس بذلك تمامًا ، ونأمل أن تكون الفكرة بديهية إلى حد ما في ختام هذه المقالة.
لبناء القدرة على فهم هذا المثال ، دعنا نعمل من الوظائف البسيطة. سنبدأ بـ "وظيفة الهوية" ، وهو ما ترغب معظم المقالات في استخدامه ، بما في ذلك وثائق TypeScript نفسها.
"وظيفة الهوية" ، في الرياضيات ، هي وظيفة تحدد مدخلاتها مباشرة إلى مخرجاتها ، مثل f(x) = x
. ما تضعه هو ما تحصل عليه. يمكننا تمثيل ذلك ، في JavaScript ، على النحو التالي:
function identity(input) { return input; }
أو بشكل أكثر إيجازًا:
const identity = input => input;
محاولة نقل هذا إلى TypeScript يعيد نفس النوع من مشكلات النظام التي رأيناها من قبل. الحلول تكتب any
منها ، والتي نعلم أنها نادرًا ما تكون فكرة جيدة ، أو تكرار / زيادة التحميل على الوظيفة لكل نوع (فواصل DRY) ، أو باستخدام Generics.
مع الخيار الأخير ، يمكننا تمثيل الوظيفة على النحو التالي:
// ES5 Function function identity<T>(input: T): T { return input; } // Arrow Function const identity = <T>(input: T): T => input; console.log(identity<number>(5)); // 5 console.log(identity<string>('hello')); // hello
<T>
بناء الجملة هنا هذه الوظيفة على أنها عامة. تمامًا مثل الوظيفة التي تسمح لنا بتمرير معلمة إدخال عشوائية إلى قائمة الوسائط الخاصة بها ، باستخدام وظيفة عامة ، يمكننا تمرير معلمة نوع عشوائي أيضًا.
الجزء <T>
من توقيع identity<T>(input: T): T
و <T>(input: T): T
في كلتا الحالتين أن الوظيفة المعنية ستقبل معلمة من النوع العام تسمى T
تمامًا مثل كيف يمكن أن تكون المتغيرات من أي اسم ، كذلك يمكن أن تكون العناصر النائبة العامة لدينا ، ولكن من الاصطلاح استخدام الحرف الكبير "T" ("T" لـ "النوع") والتنقل إلى أسفل الأبجدية حسب الحاجة. تذكر أن T
نوع ، لذلك نذكر أيضًا أننا سنقبل وسيطة دالة واحدة input
الاسم بنوع T
وأن وظيفتنا ستعيد نوعًا من T
هذا كل ما يقوله التوقيع. حاول أن تترك T = string
في رأسك - استبدل كل T
string
في تلك التوقيعات. ترى كيف لا شيء يحدث كل هذا السحر؟ انظر إلى أي مدى تشبه الطريقة غير العامة التي تستخدم بها الوظائف كل يوم؟
ضع في اعتبارك ما تعرفه بالفعل عن TypeScript والتوقيعات الوظيفية. كل ما نقوله هو أن T
هو نوع تعسفي سيوفره المستخدم عند استدعاء الوظيفة ، تمامًا مثل input
هو قيمة عشوائية سيوفرها المستخدم عند استدعاء الوظيفة. في هذه الحالة ، يجب أن يكون input
مهما كان ذلك النوع T
عندما يتم استدعاء الوظيفة في المستقبل .
بعد ذلك ، في "المستقبل" ، في عبارات السجل ، "نمرر" النوع الملموس الذي نرغب في استخدامه ، تمامًا كما نفعل متغيرًا. لاحظ التبديل في الكلام هنا - في الشكل الأولي <T> signature
، عند الإعلان عن وظيفتنا ، فهو عام - أي أنه يعمل على الأنواع العامة ، أو الأنواع التي سيتم تحديدها لاحقًا. هذا لأننا لا نعرف النوع الذي يرغب المتصل في استخدامه عندما نكتب الوظيفة بالفعل. ولكن عندما يستدعي المتصل الوظيفة ، فإنه يعرف بالضبط النوع (الأنواع) الذي يريد العمل معه ، وهي string
number
في هذه الحالة.
يمكنك أن تتخيل فكرة وجود وظيفة سجل معلنة بهذه الطريقة في مكتبة تابعة لجهة خارجية - ليس لدى مؤلف المكتبة أي فكرة عن الأنواع التي سيرغب المطورون الذين يستخدمون lib في استخدامها ، لذلك يجعلون الوظيفة عامة ، مما يؤجل الحاجة بشكل أساسي لأنواع الخرسانة حتى يتم التعرف عليها بالفعل.
أريد أن أؤكد أنه يجب أن تفكر في هذه العملية بطريقة مماثلة لفكرة تمرير متغير إلى دالة لأغراض اكتساب فهم أكثر بديهية. كل ما نقوم به الآن هو تمرير نوع أيضًا.
عند النقطة التي أطلقنا عليها اسم الوظيفة بمعامل number
، يمكن اعتبار التوقيع الأصلي ، لجميع المقاصد والأغراض ، على أنه identity(input: number): number
. وعند النقطة التي استدعينا فيها الوظيفة بمعامل string
، مرة أخرى ، ربما يكون التوقيع الأصلي هو identity(input: string): string
. يمكنك أن تتخيل أنه عند إجراء المكالمة ، يتم استبدال كل T
عام بالنوع الملموس الذي توفره في تلك اللحظة.
استكشاف بناء الجملة العام
توجد صيغ ودلالات مختلفة لتحديد الأدوية الجنيسة في سياق وظائف ES5 ووظائف الأسهم والأسماء المستعارة من النوع والواجهات والفئات. سوف نستكشف هذه الاختلافات في هذا القسم.
استكشاف النحو العام - الوظائف
لقد رأيت بعض الأمثلة على الوظائف العامة حتى الآن ، ولكن من المهم ملاحظة أن الوظيفة العامة يمكن أن تقبل أكثر من معلمة من النوع العام ، تمامًا كما يمكن أن تكون متغيرات. يمكنك اختيار طلب نوع واحد أو اثنين أو ثلاثة أو أيًا كان عدد الأنواع التي تريدها ، وكلها مفصولة بفواصل (مرة أخرى ، تمامًا مثل وسيطات الإدخال).
تقبل هذه الوظيفة ثلاثة أنواع من المدخلات وترجع أحدها عشوائيًا:
function randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V { // This is a tuple if you're not familiar. const options: [T, U, V] = [ one, two, three ]; const rndNum = getRndNumInInclusiveRange(0, 2); return options[rndNum]; } // Calling the function. // `value` has type `string | number | IAircraft` const value = randomValue< string, number, IAircraft >( myString, myNumber, myAircraft );
يمكنك أيضًا أن ترى أن بناء الجملة يختلف قليلاً اعتمادًا على ما إذا كنا نستخدم وظيفة ES5 أو وظيفة السهم ، لكن كلاهما يعلن عن معلمات النوع في التوقيع:
const randomValue = <T, U, V>( one: T, two: U, three: V ): T | U | V => { // This is a tuple if you're not familiar. const options: [T, U, V] = [ one, two, three ]; const rndNum = getRndNumInInclusiveRange(0, 2); return options[rndNum]; }
ضع في اعتبارك أنه لا يوجد "قيود تفرد" مفروضة على الأنواع - يمكنك تمرير أي string
تريدها ، مثل سلسلتين number
، على سبيل المثال. بالإضافة إلى ذلك ، تمامًا مثل وسائط الإدخال "في النطاق" لجسم الوظيفة ، كذلك تكون معلمات النوع العام. يوضح المثال السابق أن لدينا وصولاً كاملاً إلى T
و U
و V
من داخل جسم الوظيفة ، وقد استخدمناها للإعلان عن 3 مجموعات محلية.
يمكنك أن تتخيل أن هذه الأدوية الجنيسة تعمل في "سياق" معين أو ضمن "عمر" معين ، وهذا يعتمد على المكان الذي تم الإعلان عنه فيه. توجد العوامل العامة على الوظائف في النطاق داخل توقيع الوظيفة والجسم (والإغلاق الذي تم إنشاؤه بواسطة وظائف متداخلة) ، في حين أن العناصر العامة المُعلن عنها في فئة أو واجهة أو اسم مستعار من النوع تقع في النطاق لجميع أعضاء الفئة أو الواجهة أو الاسم المستعار للنوع.
لا يقتصر مفهوم الدوال العامة على "الوظائف الحرة" أو "الوظائف العائمة" (الوظائف غير المرتبطة بكائن أو فئة ، مصطلح C ++) ، ولكن يمكن أيضًا استخدامها في الوظائف المرتبطة بهياكل أخرى.
يمكننا وضع هذه randomValue
في فصل دراسي ويمكننا تسميتها بنفس الطريقة:
class Utils { public randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V { // ... } // Or, as an arrow function: public randomValue = <T, U, V>( one: T, two: U, three: V ): T | U | V => { // ... } }
يمكننا أيضًا وضع تعريف داخل الواجهة:
interface IUtils { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
أو ضمن اسم مستعار من النوع:
type Utils = { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
تمامًا كما كان من قبل ، تكون معلمات النوع العام هذه "في النطاق" لهذه الوظيفة المعينة - فهي ليست فئة أو واجهة أو اسم مستعار واسع النطاق. إنهم يعيشون فقط داخل الوظيفة المعينة التي تم تحديدهم عليها. لمشاركة نوع عام عبر جميع أعضاء الهيكل ، يجب كتابة تعليق توضيحي على اسم الهيكل نفسه ، كما سنرى أدناه.
استكشاف بناء الجملة العام - كتابة الأسماء المستعارة
باستخدام "الأسماء المستعارة للنوع" ، يتم استخدام بناء الجملة العام على اسم الاسم المستعار.
على سبيل المثال ، يمكن كتابة بعض وظائف "الإجراء" التي تقبل قيمة ، وربما تغير تلك القيمة ، ولكنها تُرجع الفراغ على النحو التالي:
type Action<T> = (val: T) => void;
ملاحظة : يجب أن يكون هذا مألوفًا لمطوري C # الذين يفهمون مفوض الإجراء <T>.
أو ، وظيفة رد الاتصال التي تقبل كلاً من الخطأ والقيمة يمكن الإعلان عنها على هذا النحو:
type CallbackFunction<T> = (err: Error, data: T) => void; const usersApi = { get(uri: string, cb: CallbackFunction<User>) { /// ... } }
من خلال معرفتنا بالوظائف العامة ، يمكننا أن نذهب إلى أبعد من ذلك ونجعل الوظيفة في كائن واجهة برمجة التطبيقات عامة أيضًا:
type CallbackFunction<T> = (err: Error, data: T) => void; const api = { // `T` is available for use within this function. get<T>(uri: string, cb: CallbackFunction<T>) { /// ... } }
الآن ، نحن نقول أن وظيفة get
تقبل بعض معلمات النوع العام ، ومهما كان ذلك ، فإن وظيفة CallbackFunction
. لقد "مررنا" أساسًا بالرمز T
الذي يدخل في get
مثل T
CallbackFunction
. ربما يكون هذا أكثر منطقية إذا قمنا بتغيير الأسماء:
type CallbackFunction<TData> = (err: Error, data: TData) => void; const api = { get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }
تعد معلمات النوع البادئة مع T
مجرد اصطلاح ، تمامًا مثل بادئة الواجهات بـ I
أو متغيرات العضو بـ _
. ما يمكنك رؤيته هنا هو أن وظيفة CallbackFunction
الاتصال تقبل نوعًا ما ( TData
) الذي يمثل حمولة البيانات المتاحة للوظيفة ، بينما يقبل get
على معلمة نوع تمثل نوع / شكل بيانات استجابة HTTP ( TResponse
). عميل HTTP ( api
) ، على غرار Axios ، يستخدم كل ما هو TResponse
مثل TData
CallbackFunction
الاتصال. يسمح ذلك لمتصل واجهة برمجة التطبيقات بتحديد نوع البيانات الذي سيتلقاه مرة أخرى من واجهة برمجة التطبيقات (لنفترض في مكان آخر في خط الأنابيب أن لدينا برمجيات وسيطة تحلل JSON في DTO).
إذا أردنا المضي قدمًا في هذا الأمر قليلاً ، فيمكننا تعديل معلمات النوع العام في وظيفة CallbackFunction
لقبول نوع خطأ مخصص أيضًا:
type CallbackFunction<TData, TError> = (err: TError, data: TData) => void;
ومثلما يمكنك جعل وسيطات الوظيفة اختيارية ، يمكنك أيضًا باستخدام معلمات الكتابة. في حالة عدم قيام المستخدم بتوفير نوع خطأ ، فسنقوم بتعيينه على مُنشئ الأخطاء افتراضيًا:
type CallbackFunction<TData, TError = Error> = (err: TError, data: TData) => void;
باستخدام هذا ، يمكننا الآن تحديد نوع وظيفة رد الاتصال بعدة طرق:
const apiOne = { // `Error` is used by default for `CallbackFunction`. get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }; apiOne.get<string>('uri', (err: Error, data: string) => { // ... }); const apiTwo = { // Override the default and use `HttpError` instead. get<TResponse>(uri: string, cb: CallbackFunction<TResponse, HttpError>) { // ... } }; apiTwo.get<string>('uri', (err: HttpError, data: string) => { // ... });
فكرة المعلمات الافتراضية هذه مقبولة عبر الوظائف والفئات والواجهات وما إلى ذلك - فهي لا تقتصر على الأسماء المستعارة فقط. في جميع الأمثلة التي رأيناها حتى الآن ، كان بإمكاننا تخصيص أي معلمة نوع نريدها إلى قيمة افتراضية. يمكن أن تأخذ الأسماء المستعارة ، تمامًا مثل الوظائف ، العديد من معلمات النوع العامة كما تريد.
استكشاف بناء الجملة العام - واجهات
كما رأيت ، يمكن توفير معلمة نوع عامة لوظيفة على واجهة:
interface IUselessFunctions { // Not generic printHelloWorld(); // Generic identity<T>(t: T): T; }
في هذه الحالة ، يعيش T
فقط لوظيفة identity
كنوع الإدخال والإرجاع.
يمكننا أيضًا إتاحة معلمة النوع لجميع أعضاء الواجهة ، تمامًا كما هو الحال مع الفئات والأسماء المستعارة للنوع ، من خلال تحديد أن الواجهة نفسها تقبل عامًا. سنتحدث عن نمط المستودع بعد قليل عندما نناقش حالات استخدام أكثر تعقيدًا للأدوية ، لذلك لا بأس إذا لم تسمع بها من قبل. يسمح لنا نمط المستودع بتجريد تخزين البيانات لدينا لجعل منطق الأعمال حياديًا. إذا كنت ترغب في إنشاء واجهة مستودع عامة تعمل على أنواع كيانات غير معروفة ، فيمكننا كتابتها على النحو التالي:
interface IRepository<T> { add(entity: T): Promise<void>; findById(id: string): Promise<T>; updateById(id: string, updated: T): Promise<void>; removeById(id: string): Promise<void>; }
ملاحظة : هناك العديد من الأفكار المختلفة حول المستودعات ، من تعريف Martin Fowler إلى تعريف DDD التجميعي. أنا أحاول فقط إظهار حالة استخدام للأدوية ، لذا فأنا لست مهتمًا جدًا بالتنفيذ الصحيح تمامًا. من المؤكد أن هناك شيئًا يمكن قوله لعدم استخدام المستودعات العامة ، لكننا سنتحدث عن ذلك لاحقًا.
كما ترى هنا ، IRepository
هي واجهة تحتوي على طرق لتخزين واسترجاع البيانات. إنه يعمل على بعض معلمات النوع العام المسماة T
، ويتم استخدام T
كمدخل add
و updateById
، بالإضافة إلى نتيجة حل الوعد لـ findById
.
ضع في اعتبارك أن هناك فرقًا كبيرًا بين قبول معلمة نوع عامة على اسم الواجهة بدلاً من السماح لكل وظيفة نفسها بقبول معلمة نوع عامة. السابق ، كما فعلنا هنا ، يضمن أن كل وظيفة داخل الواجهة تعمل على نفس النوع T
أي بالنسبة لـ IRepository<User>
، تعمل كل طريقة تستخدم T
في الواجهة الآن على كائنات User
. باستخدام الطريقة الأخيرة ، يُسمح لكل وظيفة بالعمل مع أي نوع تريده. سيكون من الغريب جدًا أن تكون قادرًا فقط على إضافة User
إلى المستودع ولكن أن تكون قادرًا على تلقي Policies
أو Orders
مرة أخرى ، على سبيل المثال ، وهو الموقف المحتمل الذي سنجد أنفسنا فيه إذا لم نتمكن من فرض هذا النوع موحدة في جميع الطرق.
لا يمكن أن تحتوي الواجهة المحددة على نوع مشترك فحسب ، بل يمكن أن تحتوي أيضًا على أنواع فريدة لأعضائها. على سبيل المثال ، إذا أردنا تقليد مصفوفة ، يمكننا كتابة واجهة مثل هذه:
interface IArray<T> { forEach(func: (elem: T, index: number) => void): this; map<U>(func: (elem: T, index: number) => U): IArray<U>; }
في هذه الحالة ، يمكن لكل من forEach
map
الوصول إلى T
من اسم الواجهة. كما ذكرنا ، يمكنك أن تتخيل أن T
في النطاق لجميع أعضاء الواجهة. على الرغم من ذلك ، لا شيء يمنع الوظائف الفردية من قبول معلمات النوع الخاصة بها أيضًا. تعمل وظيفة map
مع U
الآن ، map
لديها حق الوصول إلى كل من T
و U
كان علينا تسمية المعلمة بحرف مختلف ، مثل U
، لأن T
مأخوذ بالفعل ولا نريد تضارب التسمية. تمامًا مثل اسمها ، ستعمل map
على "تعيين" عناصر من النوع T
داخل المصفوفة إلى عناصر جديدة من النوع U
يقوم بتعيين T
s إلى U
s. القيمة المرجعة لهذه الوظيفة هي الواجهة نفسها ، والتي تعمل الآن على النوع الجديد U
، حتى نتمكن إلى حد ما من محاكاة بناء جملة JavaScript القابل للتسلسل بطلاقة للمصفوفات.
سنرى مثالاً على قوة Generics and Interfaces قريبًا عندما ننفذ نمط المستودع ونناقش حقن التبعية. مرة أخرى ، يمكننا قبول أكبر عدد من المعلمات العامة وكذلك تحديد معلمة افتراضية واحدة أو أكثر مكدسة في نهاية الواجهة.
استكشاف النحو العام - الفئات
تمامًا كما يمكننا تمرير معلمة نوع عامة إلى اسم مستعار للنوع أو وظيفة أو واجهة ، يمكننا تمرير واحد أو أكثر إلى فئة أيضًا. عند القيام بذلك ، سيكون معلمة النوع هذه متاحة لجميع أعضاء تلك الفئة بالإضافة إلى الفئات الأساسية الممتدة أو الواجهات المطبقة.
دعونا نبني فئة مجموعة أخرى ، ولكن أبسط قليلاً من TypedList
أعلاه ، حتى نتمكن من رؤية التفاعل بين الأنواع العامة والواجهات والأعضاء. سنرى مثالًا على تمرير نوع إلى فئة أساسية ووراثة الواجهة بعد ذلك بقليل.
ستدعم مجموعتنا وظائف CRUD الأساسية بالإضافة إلى map
وطريقة forEach
.
class Collection<T> { private elements: T[] = []; constructor (elements: T[] = []) { this.elements = elements; } add(elem: T): void { this.elements.push(elem); } contains(elem: T): boolean { return this.elements.includes(elem); } remove(elem: T): void { this.elements = this.elements.filter(existing => existing !== elem); } forEach(func: (elem: T, index: number) => void): void { return this.elements.forEach(func); } map<U>(func: (elem: T, index: number) => U): Collection<U> { return new Collection<U>(this.elements.map(func)); } } const stringCollection = new Collection<string>(); stringCollection.add('Hello, World!'); const numberCollection = new Collection<number>(); numberCollection.add(3.14159); const aircraftCollection = new Collection<IAircraft>(); aircraftCollection.add(myAircraft);
دعونا نناقش ما يحدث هنا. تقبل فئة Collection
معلمة من النوع العام تسمى T
يصبح هذا النوع في متناول جميع أعضاء الفصل. نستخدمها لتعريف مصفوفة خاصة من النوع T[]
، والتي كان من الممكن أن نشير إليها أيضًا في الشكل Array<T>
(انظر؟ Generics مرة أخرى لكتابة مصفوفة TS العادية). علاوة على ذلك ، تستخدم معظم وظائف الأعضاء ذلك T
بطريقة ما ، مثل التحكم في الأنواع التي تمت إضافتها وإزالتها أو التحقق مما إذا كانت المجموعة تحتوي على عنصر.
أخيرًا ، كما رأينا من قبل ، تتطلب طريقة map
معلمة النوع العام الخاصة بها. We need to define in the signature of map
that some type T
is mapped to some type U
through a callback function, thus we need a U
. That U
is unique to that function in particular, which means we could have another function in that class that also accepts some type named U
, and that'd be fine, because those types are only “in scope” for their functions and not shared across them, thus there are no naming collisions. What we can't do is have another function that accepts a generic parameter named T
, for that'd conflict with the T
from the class signature.

You can see that when we call the constructor, we pass in the type we want to work with (that is, what type each element of the internal array will be). In the calling code at the bottom of the example, we work with string
s, number
s, and IAircraft
s.
How could we make this work with an interface? What if we have different collection interfaces that we might want to swap out or inject into calling code? To get that level of reduced coupling (low coupling and high cohesion is what we should always aim for), we'll need to depend on an abstraction. Generally, that abstraction will be an interface, but it could also be an abstract class.
Our collection interface will need to be generic, so let's define it:
interface ICollection<T> { add(t: T): void; contains(t: T): boolean; remove(t: T): void; forEach(func: (elem: T, index: number) => void): void; map<U>(func: (elem: T, index: number) => U): ICollection<U>; }
Now, let's suppose we have different kinds of collections. We could have an in-memory collection, one that stores data on disk, one that uses a database, and so on. By having an interface, the dependent code can depend upon the abstraction, permitting us to swap out different implementations without affecting the existing code. Here is the in-memory collection.
class InMemoryCollection<T> implements ICollection<T> { private elements: T[] = []; constructor (elements: T[] = []) { this.elements = elements; } add(elem: T): void { this.elements.push(elem); } contains(elem: T): boolean { return this.elements.includes(elem); } remove(elem: T): void { this.elements = this.elements.filter(existing => existing !== elem); } forEach(func: (elem: T, index: number) => void): void { return this.elements.forEach(func); } map<U>(func: (elem: T, index: number) => U): ICollection<U> { return new InMemoryCollection<U>(this.elements.map(func)); } }
The interface describes the public-facing methods and properties that our class is required to implement, expecting you to pass in a concrete type that those methods will operate upon. However, at the time of defining the class, we still don't know what type the API caller will wish to use. Thus, we make the class generic too — that is, InMemoryCollection
expects to receive some generic type T
, and whatever it is, it's immediately passed to the interface, and the interface methods are implemented using that type.
Calling code can now depend on the interface:
// Using type annotation to be explicit for the purposes of the // tutorial. const userCollection: ICollection<User> = new InMemoryCollection<User>(); function manageUsers(userCollection: ICollection<User>) { userCollection.add(new User()); }
With this, any kind of collection can be passed into the manageUsers
function as long as it satisfies the interface. This is useful for testing scenarios — rather than dealing with over-the-top mocking libraries, in unit and integration test scenarios, I can replace my SqlServerCollection<T>
(for example) with InMemoryCollection<T>
instead and perform state-based assertions instead of interaction-based assertions. This setup makes my tests agnostic to implementation details, which means they are, in turn, less likely to break when refactoring the SUT.
At this point, we should have worked up to the point where we can understand that first TypedList<T>
example. Here it is again:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T): void { this.values.push(value); } public where(predicate: (value: T) => boolean): TypedList<T> { return TypedList.from<T>(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U): TypedList<U> { return TypedList.from<U>(this.values.map(selector)); } public toArray(): T[] { return this.values; } public static from<U>(values: U[]): TypedList<U> { // Perhaps we perform some logic here. // ... return new TypedList<U>(values); } public static create<U>(values?: U[]): TypedList<U> { return new TypedList<U>(values ?? []); } // Other collection functions. // .. }
The class itself accepts a generic type parameter named T
, and all members of the class are provided access to it. The instance method select
and the two static methods from
and create
, which are factories, accept their own generic type parameter named U
.
The create
static method permits the construction of a list with optional seed data. It accepts some type named U
to be the type of every element in the list as well as an optional array of U
elements, typed as U[]
. When it calls the list's constructor with new
, it passes that type U
as the generic parameter to TypedList
. This creates a new list where the type of every element is U
. It is exactly the same as how we could call the constructor of our collection class earlier with new Collection<SomeType>()
. The only difference is that the generic type is now passing through the create
method rather than being provided and used at the top-level.
I want to make sure this is really, really clear. I've stated a few times that we can think about passing around types in a similar way that we do variables. It should already be quite intuitive that we can pass a variable through as many layers of indirection as we please. Forget generics and types for a moment and think about an example of the form:
class MyClass { private constructor (t: number) {} public static create(u: number) { return new MyClass(u); } } const myClass = MyClass.create(2.17);
This is very similar to what is happening with the more-involved example, the difference being that we're working on generic type parameters, not variables. Here, 2.17
becomes the u
in create
, which ultimately becomes the t
in the private constructor.
In the case of generics:
class MyClass<T> { private constructor () {} public static create<U>() { return new MyClass<U>(); } } const myClass = MyClass.create<number>();
The U
passed to create
is ultimately passed in as the T
for MyClass<T>
. When calling create
, we provided number
as U
, thus now U = number
. We put that U
(which, again, is just number
) into the T
for MyClass<T>
, so that MyClass<T>
effectively becomes MyClass<number>
. The benefit of generics is that we're opened up to be able to work with types in this abstract and high-level fashion, similar to how we can normal variables.
The from
method constructs a new list that operates on an array of elements of type U
. It uses that type U
, just like create
, to construct a new instance of the TypedList
class, now passing in that type U
for T
.
The where
instance method performs a filtering operation based upon a predicate function. There's no mapping happening, thus the types of all elements remain the same throughout. The filter
method available on JavaScript's array returns a new array of values, which we pass into the from
method. So, to be clear, after we filter out the values that don't satisfy the predicate function, we get an array back containing the elements that do. All those elements are still of type T
, which is the original type that the caller passed to create
when the list was first created. Those filtered elements get given to the from
method, which in turn creates a new list containing all those values, still using that original type T
. The reason why we return a new instance of the TypedList
class is to be able to chain new method calls onto the return result. This adds an element of “immutability” to our list.
Hopefully, this all provides you with a more intuitive example of generics in practice, and their reason for existence. Next, we'll look at a few of the more advanced topics.
Generic Type Inference
Throughout this article, in all cases where we've used generics, we've explicitly defined the type we're operating on. It's important to note that in most cases, we do not have to explicitly define the type parameter we pass in, for TypeScript can infer the type based on usage.
If I have some function that returns a random number, and I pass the return result of that function to identity
from earlier without specifying the type parameter, it will be inferred automatically as number
:
// `value` is inferred as type `number`. const value = identity(getRandomNumber());
لإثبات استدلال النوع ، قمت بإزالة جميع التعليقات التوضيحية من النوع الخارجي تقنيًا من بنية TypedList
بنا سابقًا ، ويمكنك أن ترى ، من الصور أدناه ، أن TSC لا يزال يستنتج جميع الأنواع بشكل صحيح:
TypedList
بدون إقرارات الأنواع الدخيلة:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T) { this.values.push(value); } public where(predicate: (value: T) => boolean) { return TypedList.from(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U) { return TypedList.from(this.values.map(selector)); } public toArray() { return this.values; } public static from<U>(values: U[]) { // Perhaps we perform some logic here. // ... return new TypedList(values); } public static create<U>(values?: U[]) { return new TypedList(values ?? []); } // Other collection functions. // .. }
استنادًا إلى قيم إرجاع الوظيفة واستنادًا إلى أنواع المدخلات التي تم تمريرها from
والمنشئ ، يفهم TSC جميع معلومات النوع. في الصورة أدناه ، قمت بتجميع العديد من الصور معًا والتي تُظهر امتداد لغة Visual Studio's Code TypeScript (وبالتالي المترجم) يستنتج جميع الأنواع:

القيود العامة
في بعض الأحيان ، نريد وضع قيد حول نوع عام. ربما لا يمكننا دعم كل نوع في الوجود ، لكن يمكننا دعم مجموعة فرعية منها. لنفترض أننا نريد إنشاء دالة تُرجع طول مجموعة ما. كما رأينا أعلاه ، يمكن أن يكون لدينا العديد من الأنواع المختلفة من المصفوفات / المجموعات ، من Array
JavaScript الافتراضية إلى تلك المخصصة لدينا. كيف نجعل وظيفتنا تعرف أن بعض الأنواع العامة لها خاصية length
مرتبطة بها؟ وبالمثل ، كيف نقيد الأنواع الملموسة التي نمررها في الوظيفة بتلك التي تحتوي على البيانات التي نحتاجها؟ مثال مثل هذا ، على سبيل المثال ، لن يعمل:
function getLength<T>(collection: T): number { // Error. TS does not know that a type T contains a `length` property. return collection.length; }
الجواب هو استخدام القيود العامة. يمكننا تحديد واجهة تصف الخصائص التي نحتاجها:
interface IHasLength { length: number; }
الآن ، عند تحديد وظيفتنا العامة ، يمكننا تقييد النوع العام ليكون ذلك الذي يوسع تلك الواجهة:
function getLength<T extends IHasLength>(collection: T): number { // Restricting `collection` to be a type that contains // everything within the `IHasLength` interface. return collection.length; }
أمثلة من العالم الحقيقي
في القسمين التاليين ، سنناقش بعض الأمثلة الواقعية للأدوية التي تخلق كودًا أكثر أناقة ويسهل تفسيره. لقد رأينا الكثير من الأمثلة التافهة ، لكنني أرغب في مناقشة بعض الأساليب لمعالجة الأخطاء وأنماط الوصول إلى البيانات وحالة / دعائم الواجهة الأمامية.
أمثلة من العالم الحقيقي - نُهج للتعامل مع الأخطاء
تحتوي JavaScript على آلية من الدرجة الأولى للتعامل مع الأخطاء ، كما تفعل معظم لغات البرمجة - try
/ catch
. على الرغم من ذلك ، لست من أشد المعجبين بكيفية ظهورها عند استخدامها. هذا لا يعني أنني لا أستخدم الآلية ، أنا أستخدمها ، لكني أميل إلى محاولة إخفاءها قدر الإمكان. من خلال استخلاص try
/ catch
بعيدًا ، يمكنني أيضًا إعادة استخدام منطق معالجة الأخطاء عبر العمليات التي يحتمل فشلها.
لنفترض أننا نبني طبقة الوصول إلى البيانات. هذه طبقة من التطبيق تغلف منطق الثبات للتعامل مع طريقة تخزين البيانات. إذا كنا نجري عمليات قاعدة بيانات ، وإذا تم استخدام قاعدة البيانات هذه عبر شبكة ، فمن المحتمل أن تحدث أخطاء معينة خاصة بقاعدة البيانات واستثناءات عابرة. جزء من سبب وجود طبقة وصول بيانات مخصصة هو تجريد قاعدة البيانات من منطق الأعمال. نتيجة لذلك ، لا يمكن أن يكون لدينا مثل هذه الأخطاء الخاصة بقاعدة البيانات يتم طرحها في المكدس وخارج هذه الطبقة. نحن بحاجة إلى التفاف لهم أولا.
لنلقِ نظرة على تطبيق نموذجي يستخدم try
/ catch
:
async function queryUser(userID: string): Promise<User> { try { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(dbUser); } catch (e) { switch (true) { case e instanceof DbErrorOne: return Promise.reject(new WrapperErrorOne()); case e instanceof DbErrorTwo: return Promise.reject(new WrapperErrorTwo()); case e instanceof NetworkError: return Promise.reject(new TransientException()); default: return Promise.reject(new UnknownError()); } } }
التحويل إلى true
هو مجرد طريقة لتكون قادرًا على استخدام عبارات case
التبديل لمنطق التحقق من الأخطاء الخاص بي بدلاً من الاضطرار إلى إعلان سلسلة من if / else if - وهي خدعة سمعتها لأول مرة منJeffijoe.
إذا كان لدينا العديد من هذه الوظائف ، فعلينا تكرار منطق التفاف الأخطاء هذا ، وهو ممارسة سيئة للغاية. يبدو جيدًا لوظيفة واحدة ، لكنه سيكون كابوسًا مع العديد من الوظائف. للتخلص من هذا المنطق ، يمكننا تغليفه بوظيفة معالجة الأخطاء المخصصة التي ستمر خلال النتيجة ، لكننا نلتقط أي أخطاء ونلتفها إذا تم إلقاؤها:
async function withErrorHandling<T>( dalOperation: () => Promise<T> ): Promise<T> { try { // This unwraps the promise and returns the type `T`. return await dalOperation(); } catch (e) { switch (true) { case e instanceof DbErrorOne: return Promise.reject(new WrapperErrorOne()); case e instanceof DbErrorTwo: return Promise.reject(new WrapperErrorTwo()); case e instanceof NetworkError: return Promise.reject(new TransientException()); default: return Promise.reject(new UnknownError()); } } }
للتأكد من أن هذا أمر منطقي ، لدينا وظيفة بعنوان withErrorHandling
والتي تقبل بعض معلمات النوع العام T
يمثل هذا T
نوع قيمة الحل الناجح للوعد الذي نتوقع إرجاعه من وظيفة رد الاتصال dalOperation
. عادةً ، نظرًا لأننا نعيد فقط النتيجة المرتجعة لوظيفة dalOperation
المتزامنة ، فلن نحتاج إلى await
لأن ذلك من شأنه أن يلف الوظيفة بوعد خارجي ثانٍ ، ويمكننا ترك await
إلى كود الاستدعاء. في هذه الحالة ، نحتاج إلى اكتشاف أي أخطاء ، وبالتالي فإن await
مطلوب.
يمكننا الآن استخدام هذه الوظيفة لإنهاء عمليات DAL الخاصة بنا من وقت سابق:
async function queryUser(userID: string) { return withErrorHandling<User>(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(dbUser); }); }
وها نحن ذا. لدينا وظيفة استعلام مستخدم وظيفية من النوع الآمن والآمن للخطأ.
بالإضافة إلى ذلك ، كما رأيت سابقًا ، إذا كان مترجم TypeScript يحتوي على معلومات كافية لاستنتاج الأنواع ضمنيًا ، فلا يتعين عليك تمريرها بشكل صريح. في هذه الحالة ، يعرف TSC أن نتيجة إرجاع الوظيفة هي النوع العام. وبالتالي ، إذا mapper.toDomain(user)
نوعًا من User
، فلن تحتاج إلى تمرير النوع على الإطلاق:
async function queryUser(userID: string) { return withErrorHandling(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(user); }); }
هناك طريقة أخرى للتعامل مع الأخطاء والتي أميل إلى الإعجاب بها وهي الأنواع الأحادية. إما Monad هو نوع بيانات جبري من النموذج Either<T, U>
، حيث يمكن أن تمثل T
نوع خطأ ، ويمكن أن تمثل U
نوع فشل. استخدام أنواع Monadic يسمع في البرمجة الوظيفية ، وتتمثل الميزة الرئيسية في أن الأخطاء تصبح آمنة من النوع - لا يخبر توقيع الوظيفة العادية مستدعي واجهة برمجة التطبيقات بأي شيء حول الأخطاء التي قد تسببها هذه الوظيفة. لنفترض أننا طرحنا خطأ NotFound
من داخل queryUser
. توقيع queryUser(userID: string): Promise<User>
لا يخبرنا بأي شيء عن ذلك. ولكن ، توقيع مثل queryUser(userID: string): Promise<Either<NotFound, User>>
يفعل ذلك تمامًا. لن أشرح كيف تعمل monads مثل Either Monad في هذه المقالة لأنها يمكن أن تكون معقدة للغاية ، وهناك مجموعة متنوعة من الطرق التي يجب اعتبارها أحادية ، مثل التعيين / الربط. إذا كنت ترغب في معرفة المزيد عنها ، فإنني أوصي بمحادثات NDC لسكوت ولاشين هنا وهنا ، بالإضافة إلى حديث دانييل تشامبر هنا. قد يكون هذا الموقع وكذلك منشورات المدونة هذه مفيدة أيضًا.
أمثلة من العالم الحقيقي - نمط المستودع
دعنا نلقي نظرة على حالة استخدام أخرى حيث قد تكون Generics مفيدة. معظم الأنظمة الخلفية مطلوبة للتفاعل مع قاعدة البيانات بطريقة ما - يمكن أن تكون هذه قاعدة بيانات علائقية مثل PostgreSQL ، أو قاعدة بيانات مستندات مثل MongoDB ، أو ربما قاعدة بيانات رسم بياني ، مثل Neo4j.
نظرًا لأننا كمطورين ، يجب أن نهدف إلى تصميمات منخفضة الاقتران ومتماسكة للغاية ، فسيكون من العدل التفكير في تداعيات ترحيل أنظمة قواعد البيانات. سيكون من العدل أيضًا اعتبار أن احتياجات الوصول إلى البيانات المختلفة قد تفضل طرقًا مختلفة للوصول إلى البيانات (يبدأ هذا في الوصول إلى CQRS قليلاً ، وهو نمط لفصل عمليات القراءة والكتابة. راجع منشور Martin Fowler وقائمة MSDN إذا كنت ترغب في ذلك لمعرفة المزيد كتب "تنفيذ التصميم المدفوع بالمجال" بقلم فوغن فيرنون و "أنماط ومبادئ وممارسات التصميم المستند إلى المجال" بقلم سكوت ميليت هي قراءات جيدة أيضًا). يجب أن نفكر أيضًا في الاختبار الآلي. غالبية البرامج التعليمية التي تشرح بناء أنظمة خلفية مع Node.js رمز الوصول إلى البيانات المتداخل مع منطق الأعمال مع التوجيه. أي أنهم يميلون إلى استخدام MongoDB مع Mongoose ODM ، مع اتباع نهج السجل النشط ، وعدم وجود فصل واضح للمخاوف. هذه التقنيات مرفوضة في التطبيقات الكبيرة ؛ في اللحظة التي تقرر فيها ترحيل نظام قاعدة بيانات إلى آخر ، أو في اللحظة التي تدرك فيها أنك تفضل أسلوبًا مختلفًا للوصول إلى البيانات ، يجب عليك نسخ رمز الوصول إلى البيانات القديم واستبداله برمز جديد ، وآمل ألا تكون قد أدخلت أي أخطاء في التوجيه ومنطق الأعمال على طول الطريق.
بالتأكيد ، قد تجادل بأن اختبارات الوحدة والتكامل ستمنع الانحدار ، ولكن إذا وجدت هذه الاختبارات نفسها مقترنة وتعتمد على تفاصيل التنفيذ التي يجب أن تكون محايدة ، فمن المحتمل أيضًا كسر العملية.
نهج شائع لحل هذه المشكلة هو نمط المستودع. تقول أنه لاستدعاء الكود ، يجب أن نسمح لطبقة الوصول إلى البيانات لدينا بتقليد مجرد مجموعة في الذاكرة من الكائنات أو كيانات المجال. بهذه الطريقة ، يمكننا أن ندع الشركة تقود التصميم بدلاً من قاعدة البيانات (نموذج البيانات). بالنسبة للتطبيقات الكبيرة ، يصبح النمط المعماري المسمى التصميم المستند إلى المجال مفيدًا. المستودعات ، في نمط المستودع ، هي المكونات ، الأكثر شيوعًا الفئات ، التي تغلف وتحمل كل المنطق الداخلي للوصول إلى مصادر البيانات. باستخدام هذا ، يمكننا مركزية رمز الوصول إلى البيانات في طبقة واحدة ، مما يجعلها قابلة للاختبار بسهولة وقابلة لإعادة الاستخدام بسهولة. علاوة على ذلك ، يمكننا وضع طبقة تعيين بينهما ، مما يسمح لنا بتعيين نماذج المجال المحايد لقاعدة البيانات إلى سلسلة من تعيينات جدول واحد إلى واحد. يمكن لكل وظيفة متاحة في المستودع استخدام طريقة مختلفة للوصول إلى البيانات اختياريًا إذا اخترت ذلك.
هناك العديد من الأساليب والدلالات المختلفة للمستودعات ووحدات العمل ومعاملات قاعدة البيانات عبر الجداول وما إلى ذلك. نظرًا لأن هذا مقال عن Generics ، فأنا لا أريد الخوض في الأعشاب كثيرًا ، وبالتالي سأوضح مثالًا بسيطًا هنا ، ولكن من المهم ملاحظة أن التطبيقات المختلفة لها احتياجات مختلفة. سيكون مستودع تجميعات DDD مختلفًا تمامًا عما نفعله هنا ، على سبيل المثال. كيف أقوم بتصوير تطبيقات المستودع هنا ليست كيفية تنفيذها في مشاريع حقيقية ، لأن هناك الكثير من الوظائف المفقودة والممارسات المعمارية الأقل من المرغوب فيها قيد الاستخدام.
لنفترض أن لدينا Users
Tasks
كنماذج للمجال. يمكن أن تكون هذه مجرد POTOs - كائنات TypeScript عادية. لا يوجد مفهوم لقاعدة بيانات مخبأة فيها ، وبالتالي ، لن تقوم باستدعاء User.save()
، على سبيل المثال ، كما تفعل مع Mongoose. باستخدام نمط المستودع ، قد نستمر في استخدام المستخدم أو نحذف مهمة من منطق العمل لدينا على النحو التالي:
// Querying the DB for a User by their ID. const user: User = await userRepository.findById(userID); // Deleting a Task by its ID. await taskRepository.deleteById(taskID); // Deleting a Task by its owner's ID. await taskRepository.deleteByUserId(userID);
من الواضح ، يمكنك أن ترى كيف يتم إخفاء منطق الوصول إلى البيانات الفوضوي العابر خلف واجهة / تجريد المستودع ، مما يجعل منطق الأعمال محايدًا لمخاوف الاستمرارية.
لنبدأ ببناء بعض نماذج المجال البسيطة. هذه هي النماذج التي سيتفاعل معها كود التطبيق. إنهم مصابون بفقر الدم هنا ، لكن لديهم منطقهم الخاص لإرضاء ثوابت الأعمال في العالم الحقيقي ، أي أنهم لن يكونوا مجرد أكياس بيانات.
interface IHasIdentity { id: string; } class User implements IHasIdentity { public constructor ( private readonly _id: string, private readonly _username: string ) {} public get id() { return this._id; } public get username() { return this._username; } } class Task implements IHasIdentity { public constructor ( private readonly _id: string, private readonly _title: string ) {} public get id() { return this._id; } public get title() { return this._title; } }
سترى في لحظة لماذا نستخرج معلومات كتابة الهوية إلى واجهة. هذه الطريقة في تحديد نماذج المجال وتمرير كل شيء من خلال المنشئ ليست الطريقة التي سأفعلها في العالم الحقيقي. بالإضافة إلى ذلك ، كان الاعتماد على فئة نموذج مجال مجردة أكثر تفضيلًا من الواجهة للحصول على تنفيذ id
مجانًا.
بالنسبة إلى المستودع ، نظرًا لأنه في هذه الحالة ، نتوقع مشاركة العديد من آليات الاستمرارية نفسها عبر نماذج مجال مختلفة ، يمكننا تجريد طرق المستودع الخاصة بنا إلى واجهة عامة:
interface IRepository<T> { add(entity: T): Promise<void>; findById(id: string): Promise<T>; updateById(id: string, updated: T): Promise<void>; deleteById(id: string): Promise<void>; existsById(id: string): Promise<boolean>; }
يمكننا المضي قدمًا وإنشاء مستودع عام أيضًا لتقليل الازدواجية. للإيجاز ، لن أفعل ذلك هنا ، ويجب أن أشير إلى أن واجهات المستودعات العامة مثل هذا والمستودعات العامة ، بشكل عام ، تميل إلى الاستياء منها ، فقد يكون لديك كيانات معينة للقراءة فقط ، أو الكتابة -فقط ، أو التي لا يمكن حذفها ، أو ما شابه ذلك. ذلك يعتمد على التطبيق. أيضًا ، ليس لدينا فكرة عن "وحدة العمل" لمشاركة معاملة عبر الجداول ، وهي ميزة يمكنني تنفيذها في العالم الحقيقي ، ولكن ، مرة أخرى ، نظرًا لأن هذا عرض توضيحي صغير ، فأنا لا تريد أن تصبح تقنيًا للغاية.
لنبدأ بتنفيذ UserRepository
بنا. سأحدد واجهة IUserRepository
التي تحتوي على طرق خاصة بالمستخدمين ، وبالتالي تسمح لشفرة الاستدعاء بالاعتماد على هذا التجريد عندما نقوم بحقن التبعية في التطبيقات الملموسة:
interface IUserRepository extends IRepository<User> { existsByUsername(username: string): Promise<boolean>; } class UserRepository implements IUserRepository { // There are 6 methods to implement here all using the // concrete type of `User` - Five from IRepository<User> // and the one above. }
سيكون مستودع المهام متشابهًا ولكنه سيحتوي على طرق مختلفة حسب ما يراه التطبيق مناسبًا.
هنا ، نحدد واجهة تمتد لواجهة عامة ، وبالتالي يتعين علينا تمرير النوع الملموس الذي نعمل عليه. كما ترون من كلا الواجهتين ، لدينا فكرة أننا نرسل نماذج مجال POTO هذه ونخرجها. رمز الاستدعاء ليس لديه فكرة عن آلية الثبات الأساسية ، وهذا هو الهدف.
الاعتبار التالي الذي يجب مراعاته هو أنه اعتمادًا على طريقة الوصول إلى البيانات التي نختارها ، سيتعين علينا التعامل مع الأخطاء الخاصة بقاعدة البيانات. يمكننا وضع Mongoose أو Knex Query Builder خلف هذا المستودع ، على سبيل المثال ، وفي هذه الحالة ، سيتعين علينا التعامل مع تلك الأخطاء المحددة - لا نريدها أن ترقى إلى منطق الأعمال من أجل كسر فصل المخاوف وإدخال درجة أكبر من الاقتران.
دعنا نحدد مستودعًا أساسيًا لطرق الوصول إلى البيانات التي نرغب في استخدامها والتي يمكنها معالجة الأخطاء بالنسبة لنا:
class BaseKnexRepository { // A constructor. /** * Wraps a likely to fail database operation within a function that handles errors by catching * them and wrapping them in a domain-safe error. * * @param dalOp The operation to perform upon the database. */ public async withErrorHandling<T>(dalOp: () => Promise<T>) { try { return await dalOp(); } catch (e) { // Use a proper logger: console.error(e); // Handle errors properly here. } } }
الآن ، يمكننا تمديد هذه الفئة الأساسية في المستودع والوصول إلى تلك الطريقة العامة:
interface IUserRepository extends IRepository<User> { existsByUsername(username: string): Promise<boolean>; } class UserRepository extends BaseKnexRepository implements IUserRepository { private readonly dbContext: Knex | Knex.Transaction; public constructor (private knexInstance: Knex | Knex.Transaction) { super(); this.dbContext = knexInstance; } // Example `findById` implementation: public async findById(id: string): Promise<User> { return this.withErrorHandling<User>(async () => { const dbUser = await this.dbContext<DbUser>() .select() .where({ user_id: id }) .first(); // Maps type DbUser to User return mapper.toDomain(dbUser); }); } // There are 5 methods to implement here all using the // concrete type of `User`. }
لاحظ أن وظيفتنا تسترد DbUser
من قاعدة البيانات وتقوم بتعيينه إلى نموذج مجال User
قبل إعادته. هذا هو نمط مخطط البيانات وهو ضروري للحفاظ على فصل الاهتمامات. DbUser
هو تعيين واحد لواحد لجدول قاعدة البيانات - إنه نموذج البيانات الذي يعمل عليه المستودع - وبالتالي فهو يعتمد بشكل كبير على تقنية تخزين البيانات المستخدمة. لهذا السبب ، لن يترك DbUser
المستودع مطلقًا وسيتم تعيينه إلى نموذج مجال User
قبل إعادته. لم أعرض تطبيق DbUser
، ولكن يمكن أن يكون مجرد فئة أو واجهة بسيطة.
حتى الآن ، باستخدام نمط المستودع ، المدعوم من Generics ، تمكنا من تجريد مخاوف الوصول إلى البيانات في وحدات صغيرة بالإضافة إلى الحفاظ على أمان النوع وإعادة الاستخدام.
أخيرًا ، لأغراض اختبار الوحدة والتكامل ، لنفترض أننا سنحتفظ بتنفيذ مستودع في الذاكرة حتى نتمكن في بيئة الاختبار من حقن هذا المستودع وإجراء تأكيدات تستند إلى الحالة على القرص بدلاً من الاستهزاء بـ إطار يسخر. تفرض هذه الطريقة على كل شيء الاعتماد على الواجهات المواجهة للجمهور بدلاً من السماح باقتران الاختبارات بتفاصيل التنفيذ. نظرًا لأن الاختلافات الوحيدة بين كل مستودع هي الطرق التي يختارونها لإضافتها ضمن واجهة ISomethingRepository
، يمكننا إنشاء مستودع عام في الذاكرة وتوسيع ذلك ضمن عمليات التنفيذ الخاصة بالنوع:
class InMemoryRepository<T extends IHasIdentity> implements IRepository<T> { protected entities: T[] = []; public findById(id: string): Promise<T> { const entityOrNone = this.entities.find(entity => entity.id === id); return entityOrNone ? Promise.resolve(entityOrNone) : Promise.reject(new NotFound()); } // Implement the rest of the IRepository<T> methods here. }
الغرض من هذه الفئة الأساسية هو تنفيذ كل المنطق للتعامل مع التخزين في الذاكرة حتى لا نضطر إلى تكرارها داخل مستودعات الاختبار في الذاكرة. نظرًا لطرق مثل findById
، يجب أن يفهم هذا المستودع أن الكيانات تحتوي على حقل id
، وهذا هو سبب ضرورة القيد العام على واجهة IHasIdentity
. لقد رأينا هذه الواجهة من قبل - إنها ما نفذته نماذج المجال الخاصة بنا.
مع هذا ، عندما يتعلق الأمر ببناء المستخدم في الذاكرة أو مستودع المهام ، يمكننا فقط توسيع هذه الفئة وتنفيذ معظم الطرق تلقائيًا:
class InMemoryUserRepository extends InMemoryRepository<User> { public async existsByUsername(username: string): Promise<boolean> { const userOrNone = this.entities.find(entity => entity.username === username); return Boolean(userOrNone); // or, return !!userOrNone; } // And that's it here. InMemoryRepository implements the rest. }
هنا ، يحتاج InMemoryRepository
بنا إلى معرفة أن الكيانات لديها حقول مثل id
username
، وبالتالي نقوم بتمرير User
كمعامل عام. يقوم User
بالفعل بتنفيذ IHasIdentity
، لذلك يتم استيفاء القيد العام ، ونذكر أيضًا أن لدينا خاصية username
أيضًا.
الآن ، عندما نرغب في استخدام هذه المستودعات من طبقة منطق الأعمال ، فالأمر بسيط للغاية:
class UserService { public constructor ( private readonly userRepository: IUserRepository, private readonly emailService: IEmailService ) {} public async createUser(dto: ICreateUserDTO) { // Validate the DTO: // ... // Create a User Domain Model from the DTO const user = userFactory(dto); // Persist the Entity await this.userRepository.add(user); // Send a welcome email await this.emailService.sendWelcomeEmail(user); } }
(لاحظ أنه في تطبيق حقيقي ، من المحتمل أن ننقل المكالمة إلى emailService
إلى قائمة انتظار الوظائف لعدم إضافة زمن انتقال إلى الطلب وعلى أمل التمكن من إجراء عمليات إعادة المحاولة غير الفعالة عند الإخفاقات (- لا يعتبر إرسال البريد الإلكتروني هذا أمرًا خاصًا غير قادر في المقام الأول). علاوة على ذلك ، فإن تمرير كائن المستخدم بالكامل إلى الخدمة أمر مشكوك فيه أيضًا.المشكلة الأخرى التي يجب ملاحظتها هي أننا يمكن أن نجد أنفسنا في وضع هنا حيث يتعطل الخادم بعد استمرار المستخدم ولكن قبل البريد الإلكتروني مرسلة. هناك أنماط تخفيف لمنع ذلك ، ولكن لأغراض البراغماتية ، من المحتمل أن يعمل التدخل البشري مع التسجيل المناسب بشكل جيد).
وهناك نذهب - باستخدام نمط المستودع بقوة Generics ، قمنا بفصل DAL تمامًا عن BLL الخاص بنا وتمكنا من التفاعل مع مستودعنا بطريقة آمنة من النوع. لقد طورنا أيضًا طريقة لإنشاء مستودعات في الذاكرة بشكل متساوٍ من النوع الآمن سريعًا لأغراض اختبار الوحدة والتكامل ، مما يسمح باختبارات الصندوق الأسود الحقيقية واختبارات عدم القدرة على التنفيذ. لم يكن أي من هذا ممكنًا بدون الأنواع العامة.
كإخلاء للمسؤولية ، أود أن أشير مرة أخرى إلى أن تطبيق المستودع هذا يفتقر إلى الكثير. أردت أن أبقي المثال بسيطًا لأن التركيز هو استخدام الأدوية الجنيسة ، ولهذا السبب لم أتعامل مع النسخ أو القلق بشأن المعاملات. قد تتطلب عمليات تنفيذ المستودعات اللائقة مقالًا من تلقاء نفسه لشرحها بشكل كامل وصحيح ، وتتغير تفاصيل التنفيذ اعتمادًا على ما إذا كنت تقوم بعمل N-Tier Architecture أو DDD. هذا يعني أنه إذا كنت ترغب في استخدام نموذج المستودع ، فلا يجب أن تنظر إلى التنفيذ الخاص بي هنا بأي حال من الأحوال كأفضل ممارسة.
أمثلة من العالم الحقيقي - حالة التفاعل والدعائم
الحالة والمرجع وبقية خطافات المكونات الوظيفية للتفاعل عامة أيضًا. إذا كانت لدي واجهة تحتوي على خصائص Task
، وأريد الاحتفاظ بمجموعة منها في مكون React ، فيمكنني القيام بذلك على النحو التالي:
import React, { useState } from 'react'; export const MyComponent: React.FC = () => { // An empty array of tasks as the initial state: const [tasks, setTasks] = useState<Task[]>([]); // A counter: // Notice, type of `number` is inferred automatically. const [counter, setCounter] = useState(0); return ( <div> <h3>Counter Value: {counter}</h3> <ul> { tasks.map(task => ( <li key={task.id}> <TaskItem {...task} /> </li> )) } </ul> </div> ); };
بالإضافة إلى ذلك ، إذا أردنا تمرير سلسلة من props
إلى وظيفتنا ، فيمكننا استخدام نوع React.FC<T>
العام والوصول إلى الخاصيات:
import React from 'react'; interface IProps { id: string; title: string; description: string; } export const TaskItem: React.FC<IProps> = (props) => { return ( <div> <h3>{props.title}</h3> <p>{props.description}</p> </div> ); };
يتم استنتاج نوع props
تلقائيًا لتكون IProps
بواسطة مترجم TS.
خاتمة
في هذه المقالة ، رأينا العديد من الأمثلة المختلفة للوراثة وحالات استخدامها ، من المجموعات البسيطة ، إلى مناهج معالجة الأخطاء ، إلى عزل طبقة الوصول إلى البيانات ، وما إلى ذلك. في أبسط المصطلحات ، تسمح لنا Generics ببناء هياكل البيانات دون الحاجة إلى معرفة الوقت الملموس الذي ستعمل فيه في وقت الترجمة. نأمل أن يساعد هذا في فتح الموضوع أكثر قليلاً ، وجعل فكرة Generics أكثر سهولة قليلاً ، وتوصيل قوتها الحقيقية.