ทำความเข้าใจเกี่ยวกับ TypeScript Generics
เผยแพร่แล้ว: 2022-03-10ในบทความนี้ เราจะมาเรียนรู้แนวคิดของ Generics ใน TypeScript และตรวจสอบว่า Generics สามารถใช้ในการเขียนโค้ดแบบแยกส่วน แยกส่วน และนำมาใช้ใหม่ได้อย่างไร ระหว่างทาง เราจะพูดคุยกันสั้นๆ ว่าพวกเขาเหมาะสมกับรูปแบบการทดสอบที่ดีขึ้น แนวทางในการจัดการข้อผิดพลาด และการแยกโดเมน/การเข้าถึงข้อมูลอย่างไร
ตัวอย่างในโลกแห่งความเป็นจริง
ฉันต้องการเข้าสู่โลกของ Generics ไม่ใช่ ด้วยการอธิบายว่ามันคืออะไร แต่ให้ตัวอย่างโดยสัญชาตญาณว่าเหตุใดจึงมีประโยชน์ สมมติว่าคุณได้รับมอบหมายให้สร้างรายการไดนามิกที่มีคุณลักษณะหลากหลาย คุณสามารถเรียกมันว่าอาร์เรย์, ArrayList
, a List
, a 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 คือการพิจารณาราก “supertype” หากคุณต้องการ ตัวอย่างเช่นใน C# มีประเภทตามชื่อของ object
และ object
เป็นนามแฝงสำหรับคลาส System.Object
ในระบบประเภท C# ทุกประเภท ไม่ว่าจะกำหนดไว้ล่วงหน้าหรือกำหนดโดยผู้ใช้ และไม่ว่าจะเป็นประเภทอ้างอิงหรือประเภทค่า จะรับช่วงโดยตรงหรือโดยอ้อมจาก System.Object
ซึ่งหมายความว่าสามารถกำหนดค่าใดๆ ให้กับตัวแปรประเภท object
ได้ (โดยไม่ต้องเข้าไปใน stack/heap และ boxing/unboxing semantics)
ในกรณีนี้ ปัญหาของเราได้รับการแก้ไขแล้ว เราสามารถใช้ประเภท 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
static จะใช้อาร์เรย์ของค่าทางเลือกในกรณีที่เราต้องการสร้างรายการของเราด้วยข้อมูลเริ่มต้น “โอเปอเรเตอร์การรวมเป็นโมฆะ” ( ??
) ใช้เพื่อสร้างรายการด้วยอาร์เรย์ว่างในกรณีที่ไม่ได้ระบุ หากด้านซ้ายของตัวถูกดำเนินการเป็น null
หรือ undefined
เราจะถอยกลับไปทางด้านขวา สำหรับในกรณีนี้ values
จะเป็นทางเลือก และอาจ undefined
คุณสามารถเรียนรู้เพิ่มเติมเกี่ยวกับการรวมค่า nullish ได้ที่หน้าเอกสารประกอบของ TypeScript ที่เกี่ยวข้อง
ฉันได้เพิ่มวิธีการ select
และ where
วิธีการเหล่านี้เพียงแค่ห่อ map
และ filter
ของ JavaScript ตามลำดับ select
อนุญาตให้เราฉายอาร์เรย์ขององค์ประกอบให้อยู่ในรูปแบบใหม่ตามฟังก์ชันตัวเลือกที่มีให้ และใน where
อนุญาตให้เรากรององค์ประกอบบางอย่างตามฟังก์ชันเพรดิเคตที่ให้มา วิธีการ toArray
เพียงแปลงรายการเป็นอาร์เรย์โดยส่งคืนการอ้างอิงอาร์เรย์ที่เราเก็บไว้ภายใน
สุดท้าย สมมติว่าคลาส User
มีเมธอด getName
ซึ่งส่งคืนชื่อและยอมรับชื่อเป็นอาร์กิวเมนต์ตัวสร้างแรกและตัวเดียว
หมายเหตุ: ผู้อ่านบางคนจะรู้จักWhere
andSelect
จาก LINQ ของ C# แต่โปรดจำไว้ว่า ฉันกำลังพยายามทำให้สิ่งนี้เรียบง่าย ดังนั้นฉันจึงไม่กังวลเกี่ยวกับความเกียจคร้านหรือการดำเนินการที่ล่าช้า สิ่งเหล่านี้เป็นการเพิ่มประสิทธิภาพที่สามารถทำได้และควรทำในชีวิตจริง
นอกจากนี้ เพื่อเป็นบันทึกที่น่าสนใจ ฉันต้องการหารือเกี่ยวกับความหมายของ "ภาคแสดง" ใน Discrete Mathematics และ Propositional Logic เรามีแนวคิดของ "ข้อเสนอ" ข้อเสนอคือข้อความบางคำที่ถือได้ว่าจริงหรือเท็จ เช่น "สี่หารด้วยสองลงตัว" “เพรดิเคต” เป็นประพจน์ที่มีตัวแปรตั้งแต่หนึ่งตัวขึ้นไป ดังนั้น ความจริงของประพจน์จึงขึ้นอยู่กับตัวแปรเหล่านั้น คุณสามารถคิดได้เหมือนกับฟังก์ชัน เช่น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
ซึ่งคล้ายกับการพึ่งพา supertype ของรูทในห่วงโซ่การสืบทอดซึ่งทุกประเภทได้มา แต่เราสรุปว่าเราสูญเสียความปลอดภัยประเภทด้วยวิธีนั้น แล้วทางออกคืออะไร?
ปรากฎว่าตอนต้นของบทความฉันโกหก (ประเภท):
“ ณ จุดนี้ คุณไม่สามารถยอมรับประเภทใดก็ได้ที่คุณต้องการให้เป็นฟังก์ชันหรือวิธีการด้วยวิธีที่สะอาดหมดจด”
คุณทำได้จริงๆ และนั่นคือสิ่งที่ 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 คืออะไร ทำงานอย่างไร และไวยากรณ์ของพวกเขา เมื่อฉันเรียนรู้แนวคิดใหม่ ฉันชอบเริ่มต้นด้วยการดูตัวอย่างที่ซับซ้อนของแนวคิดที่ใช้งาน เพื่อที่ว่าเมื่อฉันเริ่มเรียนรู้พื้นฐาน ฉันจะสามารถเชื่อมโยงระหว่างหัวข้อพื้นฐานกับตัวอย่างที่มีอยู่ ศีรษะ.
Generic คืออะไร?
วิธีง่ายๆ ในการทำความเข้าใจ 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 Functions, Arrow Functions, Type Aliases, Interfaces และ Classes เราจะสำรวจความแตกต่างเหล่านั้นในส่วนนี้
สำรวจไวยากรณ์ทั่วไป — ฟังก์ชัน
คุณได้เห็นตัวอย่างฟังก์ชันทั่วไปสองสามตัวอย่างแล้ว แต่สิ่งสำคัญคือต้องสังเกตว่าฟังก์ชันทั่วไปสามารถรับพารามิเตอร์ประเภททั่วไปได้มากกว่าหนึ่งพารามิเตอร์ เช่นเดียวกับที่สามารถรับตัวแปรได้ คุณสามารถเลือกขอประเภทหนึ่ง สอง สาม หรือกี่แบบก็ได้ตามต้องการ โดยทั้งหมดคั่นด้วยเครื่องหมายจุลภาค (เช่นเดียวกับอาร์กิวเมนต์อินพุต)
ฟังก์ชันนี้ยอมรับประเภทอินพุตสามประเภทและส่งคืนหนึ่งในนั้นแบบสุ่ม:
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]; }
โปรดทราบว่าไม่มี "ข้อจำกัดด้านความเป็นเอกลักษณ์" บังคับในประเภทดังกล่าว คุณสามารถส่งผ่านชุดค่าผสมใดก็ได้ที่ต้องการ เช่น สตริง s สอง string
และ number
เป็นต้น นอกจากนี้ เช่นเดียวกับอาร์กิวเมนต์อินพุต "อยู่ในขอบเขต" สำหรับเนื้อหาของฟังก์ชัน พารามิเตอร์ประเภททั่วไปก็เช่นกัน ตัวอย่างก่อนหน้านี้แสดงให้เห็นว่าเรามีสิทธิ์เข้าถึง T
, U
และ V
เต็มรูปแบบจากภายในเนื้อหาของฟังก์ชัน และเราใช้สิ่งเหล่านี้เพื่อประกาศ 3-tuple ในเครื่อง
คุณสามารถจินตนาการได้ว่ายาชื่อสามัญเหล่านี้ทำงานบน "บริบท" บางอย่างหรือภายใน "ช่วงชีวิต" ที่แน่นอน และนั่นก็ขึ้นอยู่กับว่าได้ประกาศไว้ที่ใด Generics เกี่ยวกับฟังก์ชันจะอยู่ในขอบเขตภายในฟังก์ชัน Signature และ Body (และการปิดที่สร้างโดยฟังก์ชันที่ซ้อนกัน) ในขณะที่ Generics ที่ประกาศในคลาสหรืออินเทอร์เฟซหรือนามแฝงประเภทจะอยู่ในขอบเขตสำหรับสมาชิกทั้งหมดของคลาสหรืออินเทอร์เฟซหรือประเภทนามแฝง
แนวคิดทั่วไปเกี่ยวกับฟังก์ชันไม่จำกัดเฉพาะ "ฟังก์ชันอิสระ" หรือ "ฟังก์ชันลอยตัว" (ฟังก์ชันที่ไม่ได้แนบกับวัตถุหรือคลาส เรียกว่า 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 Aliases ไวยากรณ์ทั่วไปจะใช้กับชื่อของนามแฝง
ตัวอย่างเช่น ฟังก์ชัน "action" บางอย่างที่ยอมรับค่า ซึ่งอาจเปลี่ยนค่านั้น แต่คืนค่า void สามารถเขียนเป็น:
type Action<T> = (val: T) => void;
หมายเหตุ : สิ่งนี้ควรเป็นที่คุ้นเคยสำหรับนักพัฒนา C# ที่เข้าใจผู้รับมอบสิทธิ์ Action<T>
หรือฟังก์ชันเรียกกลับที่ยอมรับทั้งข้อผิดพลาดและค่าสามารถประกาศได้ดังนี้:
type CallbackFunction<T> = (err: Error, data: T) => void; const usersApi = { get(uri: string, cb: CallbackFunction<User>) { /// ... } }
ด้วยความรู้เกี่ยวกับฟังก์ชัน generics เราสามารถดำเนินการต่อไปและทำให้ฟังก์ชันบนอ็อบเจ็กต์ API เป็นแบบทั่วไปได้เช่นกัน:
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>) { // ... } }
Params ประเภทนำหน้าด้วย T
เป็นเพียงข้อตกลง เช่นเดียวกับส่วนต่อประสานนำหน้าด้วย I
หรือตัวแปรสมาชิกที่มี _
สิ่งที่คุณสามารถเห็นที่นี่คือ CallbackFunction
ยอมรับบางประเภท ( TData
) ซึ่งแสดงถึง payload ของข้อมูลที่มีให้กับฟังก์ชัน ในขณะที่ get
ยอมรับพารามิเตอร์ประเภทที่แสดงถึงประเภท/รูปร่างของข้อมูล HTTP Response ( TResponse
) HTTP Client ( api
) คล้ายกับ Axios ใช้ TResponse
ใดก็ตามที่เป็น TData
สำหรับ CallbackFunction
สิ่งนี้ทำให้ผู้เรียก API สามารถเลือกประเภทข้อมูลที่พวกเขาจะได้รับกลับมาจาก API (สมมติว่ามีที่อื่นในไปป์ไลน์ที่เรามีมิดเดิลแวร์ที่แยก 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
ที่เป็นประเภทอินพุตและส่งคืน
นอกจากนี้เรายังสามารถทำให้พารามิเตอร์ type พร้อมใช้งานสำหรับสมาชิกทุกคนในอินเทอร์เฟซ เช่นเดียวกับคลาสและชื่อแทนประเภท โดยระบุว่าอินเทอร์เฟซนั้นยอมรับทั่วไป เราจะพูดถึง Repository Pattern ในอีกสักครู่ เมื่อเราพูดถึงกรณีการใช้งานที่ซับซ้อนมากขึ้นสำหรับ generics ดังนั้น ไม่เป็นไรถ้าคุณไม่เคยได้ยินเรื่องนี้มาก่อน รูปแบบพื้นที่เก็บข้อมูลช่วยให้เราแยกแยะการจัดเก็บข้อมูลของเราออกไปเพื่อทำให้ตรรกะทางธุรกิจมีความคงอยู่อยู่เสมอ-ไม่เชื่อเรื่องพระเจ้า หากคุณต้องการสร้างอินเทอร์เฟซที่เก็บทั่วไปที่ทำงานบนประเภทเอนทิตีที่ไม่รู้จัก เราสามารถพิมพ์ได้ดังนี้:
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>; }
หมายเหตุ : มีความคิดที่แตกต่างกันมากมายเกี่ยวกับ Repositories ตั้งแต่คำจำกัดความของ Martin Fowler ไปจนถึงคำจำกัดความของ DDD Aggregate ฉันแค่พยายามแสดงกรณีการใช้งานสำหรับยาชื่อสามัญ ดังนั้นฉันจึงไม่ได้กังวลมากเกินไปกับการนำไปปฏิบัติอย่างถูกต้องทั้งหมด มีบางอย่างที่ต้องบอกว่าไม่ได้ใช้ที่เก็บข้อมูลทั่วไป แต่เราจะพูดถึงเรื่องนี้ในภายหลัง
ดังที่คุณเห็นที่นี่ IRepository
เป็นอินเทอร์เฟซที่มีวิธีการจัดเก็บและเรียกข้อมูล มันทำงานบนพารามิเตอร์ประเภททั่วไปบางตัวที่ชื่อ T
และ T
ถูกใช้เป็นอินพุตเพื่อ add
และ updateById
เช่นเดียวกับผลลัพธ์การแก้ไขสัญญาของ findById
โปรดทราบว่ามีความแตกต่างอย่างมากระหว่างการยอมรับพารามิเตอร์ประเภททั่วไปในชื่ออินเทอร์เฟซ ซึ่งต่างจากการอนุญาตให้แต่ละฟังก์ชันยอมรับพารามิเตอร์ประเภททั่วไป ก่อนหน้านี้ ดังที่เราทำที่นี่ ทำให้แน่ใจได้ว่าแต่ละฟังก์ชันภายในอินเทอร์เฟซทำงานบนประเภท T
เดียวกัน นั่นคือสำหรับ IRepository<User>
ทุกวิธีที่ใช้ T
ในอินเทอร์เฟซกำลังทำงานบนวัตถุ User
ด้วยวิธีหลัง แต่ละฟังก์ชันจะได้รับอนุญาตให้ทำงานกับประเภทใดก็ได้ที่ต้องการ จะแปลกมากที่จะสามารถเพิ่ม User
ลงใน Repository ได้ แต่สามารถรับ 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
กับ U
ค่าที่ส่งกลับของฟังก์ชันนี้คืออินเทอร์เฟซเอง ซึ่งขณะนี้ทำงานบน U
ชนิดใหม่ เพื่อให้เราสามารถเลียนแบบไวยากรณ์ที่เชื่อมโยงได้อย่างคล่องแคล่วของ JavaScript สำหรับอาร์เรย์
เราจะเห็นตัวอย่างพลังของ Generics และ Interfaces ในไม่ช้าเมื่อเรานำ Repository Pattern ไปใช้และหารือเกี่ยวกับ Dependency Injection เป็นอีกครั้งที่เรายอมรับพารามิเตอร์ทั่วไปได้มากเท่าๆ กับเลือกพารามิเตอร์เริ่มต้นตั้งแต่หนึ่งรายการขึ้นไปที่ซ้อนกันที่ส่วนท้ายของอินเทอร์เฟซ
สำรวจไวยากรณ์ทั่วไป — คลาส
เช่นเดียวกับที่เราสามารถส่งพารามิเตอร์ประเภททั่วไปไปยังประเภทนามแฝง ฟังก์ชัน หรืออินเทอร์เฟซ เราก็สามารถส่งผ่านอย่างน้อยหนึ่งรายการไปยังคลาสได้เช่นกัน เมื่อทำเช่นนั้น พารามิเตอร์ประเภทนั้นจะสามารถเข้าถึงได้โดยสมาชิกทุกคนในคลาสนั้น เช่นเดียวกับคลาสฐานที่ขยายเพิ่มเติมหรืออินเทอร์เฟซที่นำมาใช้
มาสร้างคลาสคอลเล็กชันอื่นกันดีกว่า แต่ง่ายกว่า 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 เข้าใจข้อมูลทุกประเภท ในภาพด้านล่าง ฉันได้ต่อภาพหลายภาพเข้าด้วยกัน ซึ่งแสดงส่วนขยายภาษาของ Code TypeScript ของ Visual Studio (และด้วยเหตุนี้คอมไพเลอร์) โดยอนุมานทุกประเภท:
ข้อจำกัดทั่วไป
บางครั้ง เราต้องการวางข้อจำกัดเกี่ยวกับประเภททั่วไป บางทีเราไม่สามารถรองรับทุกประเภทที่มีอยู่ได้ แต่เราสามารถรองรับส่วนย่อยของพวกมันได้ สมมติว่าเราต้องการสร้างฟังก์ชันที่คืนค่าความยาวของบางคอลเล็กชัน ตามที่เห็นด้านบน เราอาจมีอาร์เรย์/คอลเล็กชันหลายประเภท ตั้งแต่ JavaScript Array
เริ่มต้นไปจนถึงอาร์เรย์ที่กำหนดเอง เราจะให้ฟังก์ชันของเราทราบได้อย่างไรว่าประเภททั่วไปบางประเภทมีคุณสมบัติ 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; }
ตัวอย่างในโลกแห่งความเป็นจริง
ในสองตอนต่อไป เราจะพูดถึงตัวอย่างการใช้งานจริงของ generics ที่สร้างโค้ดที่หรูหราและเข้าใจง่ายขึ้น เราได้เห็นตัวอย่างเล็กๆ น้อยๆ มากมาย แต่ฉันต้องการหารือเกี่ยวกับวิธีการจัดการข้อผิดพลาด รูปแบบการเข้าถึงข้อมูล และสถานะ/อุปกรณ์ประกอบการโต้ตอบส่วนหน้า
ตัวอย่างในโลกแห่งความเป็นจริง — แนวทางในการจัดการข้อผิดพลาด
JavaScript มีกลไกระดับเฟิร์สคลาสสำหรับการจัดการข้อผิดพลาด เช่นเดียวกับภาษาโปรแกรมส่วนใหญ่ — try
/ catch
ถึงอย่างนั้นฉันก็ไม่ใช่แฟนตัวยงของรูปลักษณ์เมื่อใช้งาน ไม่ได้หมายความว่าฉันไม่ได้ใช้กลไกนี้ แต่ฉันมักจะพยายามซ่อนมันให้มากที่สุด โดยการ try
แยกเป็น catch
ฉันยังสามารถใช้ตรรกะการจัดการข้อผิดพลาดซ้ำในการดำเนินการที่มีแนวโน้มว่าจะล้มเหลวได้
สมมติว่าเรากำลังสร้าง Data Access Layer นี่คือเลเยอร์ของแอปพลิเคชันที่ห่อหุ้มตรรกะการคงอยู่สำหรับจัดการกับวิธีการจัดเก็บข้อมูล หากเรากำลังดำเนินการกับฐานข้อมูล และหากฐานข้อมูลนั้นถูกใช้ในเครือข่าย ข้อผิดพลาดเฉพาะของ DB และข้อยกเว้นชั่วคราวก็มักจะเกิดขึ้น เหตุผลส่วนหนึ่งในการมี Data Access Layer โดยเฉพาะคือการแยกฐานข้อมูลออกจากตรรกะทางธุรกิจ ด้วยเหตุนี้ เราจึงไม่สามารถมีข้อผิดพลาดเฉพาะ DB ดังกล่าวที่ถูกส่งขึ้นไปบนสแต็กและออกจากเลเยอร์นี้ เราต้องห่อมันก่อน
ลองดูการใช้งานทั่วไปที่จะใช้ 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
เป็นเพียงวิธีการที่สามารถใช้คำสั่ง switch 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
โดยปกติ เนื่องจากเราเพิ่งส่งคืนผลลัพธ์ของฟังก์ชัน async 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 Compiler มีข้อมูลเพียงพอที่จะอนุมานประเภทโดยปริยาย คุณไม่จำเป็นต้องส่งผ่านโดยตรง ในกรณีนี้ 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); }); }
อีกวิธีในการจัดการข้อผิดพลาดที่ฉันมักจะชอบก็คือ Monadic Types Monad ทั้งสองเป็นชนิดข้อมูลเชิงพีชคณิตของรูปแบบ Either<T, U>
โดยที่ T
สามารถแสดงประเภทข้อผิดพลาด และ U
สามารถแสดงถึงประเภทความล้มเหลวได้ การใช้ Monadic Types ฟังการเขียนโปรแกรมเชิงฟังก์ชัน และประโยชน์ที่สำคัญคือข้อผิดพลาดจะกลายเป็นประเภทที่ปลอดภัย - ลายเซ็นของฟังก์ชันปกติไม่ได้บอกผู้เรียก API เกี่ยวกับข้อผิดพลาดที่ฟังก์ชันอาจเกิดขึ้น สมมติว่าเราโยนข้อผิดพลาด NotFound
จากภายใน queryUser
ลายเซ็นของ queryUser(userID: string): Promise<User>
ไม่ได้บอกอะไรเราเกี่ยวกับเรื่องนั้น แต่ลายเซ็นเช่น queryUser(userID: string): Promise<Either<NotFound, User>>
ทำอย่างแน่นอน ฉันจะไม่อธิบายว่า monads เช่น monad ทำงานอย่างไรในบทความนี้ เพราะมันค่อนข้างซับซ้อน และมีวิธีการที่หลากหลายที่จะต้องพิจารณาว่าเป็น monadic เช่น mapping/binding หากคุณต้องการเรียนรู้เพิ่มเติมเกี่ยวกับพวกเขา ฉันขอแนะนำการพูดคุย NDC ของ Scott Wlaschin สองครั้งที่นี่และที่นี่ รวมทั้งการพูดคุยของ Daniel Chamber ที่นี่ ไซต์นี้เช่นกัน โพสต์บล็อกเหล่านี้อาจมีประโยชน์เช่นกัน
ตัวอย่างในโลกแห่งความเป็นจริง — รูปแบบพื้นที่เก็บข้อมูล
มาดูกรณีการใช้งานอื่นที่อาจเป็นประโยชน์กับ Generics ระบบแบ็คเอนด์ส่วนใหญ่จำเป็นต้องเชื่อมต่อกับฐานข้อมูลในลักษณะใดลักษณะหนึ่ง ซึ่งอาจเป็นฐานข้อมูลเชิงสัมพันธ์ เช่น PostgreSQL ฐานข้อมูลเอกสาร เช่น MongoDB หรือแม้แต่ฐานข้อมูลแบบกราฟ เช่น Neo4j
เนื่องจากในฐานะนักพัฒนา เราควรมุ่งเป้าไปที่การออกแบบที่มีความสัมพันธ์กันในระดับต่ำและมีความเหนียวแน่นสูง จึงควรเป็นข้อโต้แย้งที่ยุติธรรมที่จะพิจารณาว่าการแตกแขนงออกจากระบบฐานข้อมูลเป็นอย่างไร การพิจารณาด้วยว่าความต้องการในการเข้าถึงข้อมูลที่แตกต่างกันอาจชอบวิธีการเข้าถึงข้อมูลที่แตกต่างกัน (สิ่งนี้เริ่มเข้าสู่ CQRS เล็กน้อย ซึ่งเป็นรูปแบบสำหรับแยกการอ่านและเขียน ดูโพสต์ของ Martin Fowler และรายการ MSDN ถ้าคุณต้องการ เพื่อเรียนรู้เพิ่มเติม หนังสือ “Implementing Domain Driven Design” โดย Vaughn Vernon และ “Patterns, Principles, and Practices of Domain-Driven Design” โดย Scott Millet ก็น่าอ่านเช่นกัน) เราควรพิจารณาการทดสอบอัตโนมัติด้วย บทช่วยสอนส่วนใหญ่ที่อธิบายการสร้างระบบแบ็คเอนด์ด้วย Node.js ผสมผสานรหัสการเข้าถึงข้อมูลเข้ากับตรรกะทางธุรกิจกับการกำหนดเส้นทาง นั่นคือพวกเขามักจะใช้ MongoDB กับ Mongoose ODM โดยใช้แนวทาง Active Record และไม่มีการแยกข้อกังวลที่ชัดเจน เทคนิคดังกล่าวไม่ได้รับความสนใจในการใช้งานขนาดใหญ่ ทันทีที่คุณตัดสินใจว่าต้องการย้ายระบบฐานข้อมูลหนึ่งไปยังอีกระบบหนึ่ง หรือเมื่อคุณตระหนักว่าคุณต้องการวิธีการเข้าถึงข้อมูลที่แตกต่างออกไป คุณต้องฉีกรหัสการเข้าถึงข้อมูลเก่าออก แทนที่ด้วยรหัสใหม่ และหวังว่าคุณจะไม่ได้แนะนำจุดบกพร่องใดๆ ให้กับการกำหนดเส้นทางและตรรกะทางธุรกิจไปพร้อมกัน
แน่นอนว่า คุณอาจโต้แย้งว่าการทดสอบหน่วยและการทดสอบการรวมจะป้องกันการถดถอย แต่ถ้าการทดสอบเหล่านั้นพบว่าตนเองมีความเกี่ยวข้องกันและขึ้นอยู่กับรายละเอียดการนำไปใช้ซึ่งควรจะเป็นแบบไม่เชื่อเรื่องพระเจ้า การทดสอบเหล่านั้นก็มีแนวโน้มที่จะหยุดชะงักเช่นกัน
แนวทางทั่วไปในการแก้ปัญหานี้คือรูปแบบพื้นที่เก็บข้อมูล มันบอกว่าสำหรับรหัสการโทร เราควรอนุญาตให้ชั้นการเข้าถึงข้อมูลของเราเลียนแบบการรวบรวมวัตถุหรือเอนทิตีโดเมนในหน่วยความจำเท่านั้น ด้วยวิธีนี้ เราสามารถปล่อยให้ธุรกิจขับเคลื่อนการออกแบบมากกว่าที่จะเป็นฐานข้อมูล (โมเดลข้อมูล) สำหรับการใช้งานขนาดใหญ่ รูปแบบสถาปัตยกรรมที่เรียกว่า Domain-Driven Design จะมีประโยชน์ ที่เก็บในรูปแบบพื้นที่เก็บข้อมูลเป็นส่วนประกอบ คลาสโดยทั่วไป ที่ห่อหุ้มและเก็บตรรกะทั้งหมดไว้ภายในเพื่อเข้าถึงแหล่งข้อมูล ด้วยเหตุนี้ เราจึงสามารถรวมรหัสการเข้าถึงข้อมูลไว้ที่ชั้นเดียว ทำให้สามารถทดสอบได้ง่ายและนำกลับมาใช้ใหม่ได้อย่างง่ายดาย นอกจากนี้ เราสามารถวางเลเยอร์การแมปไว้ระหว่างนั้น ซึ่งช่วยให้เราสามารถแมปโมเดลโดเมนที่ไม่เชื่อเรื่องพระเจ้ากับฐานข้อมูลกับชุดของการแมปตารางแบบหนึ่งต่อหนึ่ง แต่ละฟังก์ชันที่มีอยู่ใน Repository อาจใช้วิธีการเข้าถึงข้อมูลที่แตกต่างกันได้ หากคุณเลือก
มีแนวทางและความหมายที่แตกต่างกันมากมายสำหรับ Repositories, Units of Work, ธุรกรรมฐานข้อมูลข้ามตาราง และอื่นๆ เนื่องจากนี่เป็นบทความเกี่ยวกับ Generics ฉันจึงไม่อยากพูดถึงเรื่องวัชพืชมากเกินไป ดังนั้นฉันจะยกตัวอย่างง่ายๆ ที่นี่ แต่สิ่งสำคัญคือต้องสังเกตว่าแอปพลิเคชันต่างๆ มีความต้องการที่แตกต่างกัน ตัวอย่างเช่น พื้นที่เก็บข้อมูลสำหรับ DDD Aggregates จะค่อนข้างแตกต่างจากที่เราทำที่นี่ วิธีที่ฉันพรรณนาถึงการใช้งาน Repository ที่นี่ไม่ใช่วิธีที่ฉันนำไปใช้ในโครงการจริง เนื่องจากมีฟังก์ชันที่ขาดหายไปมากมายและมีการใช้งานสถาปัตยกรรมที่น้อยกว่าที่ต้องการ
สมมติว่าเรามี Users
และ Tasks
เป็นโมเดลโดเมน สิ่งเหล่านี้อาจเป็นแค่ POTO — ออบเจกต์ TypeScript แบบธรรมดา ไม่มีแนวคิดเกี่ยวกับฐานข้อมูลที่ฝังอยู่ในนั้น ดังนั้น คุณจะไม่เรียก User.save()
เช่นเดียวกันกับที่คุณใช้ Mongoose การใช้ Repository Pattern เราอาจยืนยันผู้ใช้หรือลบงานออกจากตรรกะทางธุรกิจของเราดังนี้:
// 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
ฟรี
สำหรับ Repository เนื่องจากในกรณีนี้ เราคาดหวังว่ากลไกการคงอยู่แบบเดียวกันจำนวนมากจะถูกแชร์ข้ามโมเดลโดเมนต่างๆ เราจึงสามารถสรุปวิธีการ Repository ของเราให้เป็นอินเทอร์เฟซทั่วไปได้:
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>; }
เราสามารถดำเนินการต่อไปและสร้าง Generic Repository เพื่อลดความซ้ำซ้อน เพื่อความกระชับ ฉันจะไม่ทำอย่างนั้นที่นี่ และฉันควรสังเกตว่าอินเทอร์เฟซ Generic Repository เช่นอันนี้และ Generic Repositories โดยทั่วไปมักจะขมวดคิ้วเพราะคุณอาจมีเอนทิตีบางอย่างที่เป็นแบบอ่านอย่างเดียวหรือเขียน -เท่านั้น หรือที่ไม่สามารถลบได้หรือคล้ายกัน ขึ้นอยู่กับแอปพลิเคชัน นอกจากนี้ เราไม่มีแนวคิดเรื่อง "หน่วยของการทำงาน" เพื่อแชร์ธุรกรรมข้ามตาราง ซึ่งเป็นคุณลักษณะที่ฉันจะใช้ในโลกแห่งความเป็นจริง แต่อีกครั้ง เนื่องจากนี่เป็นการสาธิตเล็กๆ ฉันไม่ ต้องการรับเทคนิคมากเกินไป
เริ่มต้นด้วยการติดตั้ง 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. }
Task Repository จะคล้ายกัน แต่จะมีวิธีต่าง ๆ ตามที่แอปพลิเคชันเห็นสมควร
ในที่นี้ เรากำลังกำหนดอินเทอร์เฟซที่ขยายส่วนต่อประสานทั่วไป ดังนั้นเราจึงต้องส่งประเภทที่เป็นรูปธรรมที่เรากำลังดำเนินการอยู่ ดังที่คุณเห็นจากอินเทอร์เฟซทั้งสอง เรามีแนวคิดว่าเราส่งโมเดลโดเมน POTO เหล่านี้เข้ามาและนำออก รหัสการโทรไม่รู้ว่ากลไกการคงอยู่คืออะไร และนั่นคือประเด็น
การพิจารณาต่อไปที่ต้องทำก็คือขึ้นอยู่กับวิธีการเข้าถึงข้อมูลที่เราเลือก เราจะต้องจัดการกับข้อผิดพลาดเฉพาะฐานข้อมูล เราอาจวาง Mongoose หรือ Knex Query Builder ไว้เบื้องหลัง Repository นี้ และในกรณีนั้น เราจะต้องจัดการกับข้อผิดพลาดเฉพาะเหล่านั้น เราไม่ต้องการให้เกิดปัญหาขึ้นกับตรรกะทางธุรกิจเพราะจะทำให้แยกข้อกังวลได้ และแนะนำระดับการมีเพศสัมพันธ์ที่ใหญ่ขึ้น
มากำหนด Base Repository สำหรับวิธีการเข้าถึงข้อมูลที่เราต้องการใช้ซึ่งสามารถจัดการข้อผิดพลาดสำหรับเรา:
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. } } }
ตอนนี้ เราสามารถขยาย Base Class นี้ใน Repository และเข้าถึงวิธี Generic นั้นได้:
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
ก่อนที่จะส่งคืน นี่คือรูปแบบ Data Mapper และจำเป็นต้องแยกข้อกังวลออกจากกัน DbUser
เป็นการแมปแบบตัวต่อตัวกับตารางฐานข้อมูล ซึ่งเป็นโมเดลข้อมูลที่ Repository ทำงาน และดังนั้นจึงขึ้นอยู่กับเทคโนโลยีการจัดเก็บข้อมูลที่ใช้เป็นอย่างมาก ด้วยเหตุผลนี้ DbUser
จะไม่ออกจาก Repository และจะถูกแม็พกับโมเดลโดเมน User
ก่อนที่จะส่งคืน ฉันไม่ได้แสดงการใช้งาน DbUser
แต่อาจเป็นแค่คลาสหรืออินเทอร์เฟซธรรมดา
จนถึงตอนนี้ ด้วยการใช้ Repository Pattern ซึ่งขับเคลื่อนโดย 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
ด้วย
เมื่อเราต้องการใช้ที่เก็บข้อมูลเหล่านี้จาก Business Logic Layer ก็ค่อนข้างง่าย:
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
ไปยังคิวงานเพื่อไม่ให้เพิ่มเวลาแฝงให้กับคำขอและหวังว่าจะสามารถดำเนินการลองใหม่อีกครั้งเมื่อเกิดความล้มเหลว (— ไม่ใช่ว่าการส่งอีเมลนั้นมีความเฉพาะเจาะจง idempotent ตั้งแต่แรก) นอกจากนี้ การส่งวัตถุผู้ใช้ทั้งหมดไปยังบริการก็น่าสงสัยเช่นกัน ปัญหาอื่น ๆ ที่ควรทราบคือเราอาจพบว่าตัวเองอยู่ในตำแหน่งที่เซิร์ฟเวอร์ขัดข้องหลังจากที่ผู้ใช้ยังคงอยู่แต่ก่อนที่อีเมลจะมาถึง ส่ง มีรูปแบบบรรเทาเพื่อป้องกันนี้ แต่สำหรับวัตถุประสงค์ของลัทธิปฏิบัตินิยม การแทรกแซงของมนุษย์กับการตัดไม้ที่เหมาะสมอาจจะใช้ได้ดี)
และต่อไป ด้วยการใช้ Repository Pattern ด้วยพลังของ Generics เราได้แยก DAL ของเราออกจาก BLL ของเราอย่างสมบูรณ์ และจัดการเพื่อเชื่อมต่อกับที่เก็บของเราในลักษณะที่ปลอดภัยสำหรับประเภท นอกจากนี้ เรายังได้พัฒนาวิธีสร้างที่เก็บในหน่วยความจำที่ปลอดภัยสำหรับประเภทให้เท่ากันอย่างรวดเร็วเพื่อวัตถุประสงค์ในการทดสอบหน่วยและการทดสอบการผสานรวม ซึ่งอนุญาตการทดสอบแบบกล่องดำที่แท้จริงและแบบไม่เชื่อเรื่องพระเจ้า สิ่งนี้จะเกิดขึ้นไม่ได้หากไม่มีประเภททั่วไป
เพื่อเป็นการปฏิเสธความรับผิดชอบ ฉันต้องการทราบอีกครั้งว่าการนำ Repository ไปใช้งานยังไม่เพียงพอในหลายๆ ด้าน ฉันต้องการยกตัวอย่างให้เรียบง่าย เนื่องจากเน้นที่การใช้ generics ซึ่งเป็นสาเหตุที่ฉันไม่ได้จัดการกับความซ้ำซ้อนหรือกังวลเกี่ยวกับการทำธุรกรรม การใช้งานที่เก็บที่เหมาะสมจะใช้บทความทั้งหมดด้วยตัวเองเพื่ออธิบายอย่างสมบูรณ์และถูกต้อง และรายละเอียดการนำไปใช้จะเปลี่ยนแปลงไปขึ้นอยู่กับว่าคุณกำลังทำสถาปัตยกรรม N-Tier หรือ DDD นั่นหมายความว่า หากคุณต้องการใช้ Repository Pattern คุณ ไม่ ควรมองว่าการนำไปใช้ของฉันที่นี่เป็นแนวทางปฏิบัติที่ดีที่สุดในทางใดทางหนึ่ง
ตัวอย่างในโลกแห่งความเป็นจริง — React State & Props
สถานะ ผู้อ้างอิง และส่วนอื่นๆ ของ hooks สำหรับ React Functional Components นั้นเป็นแบบทั่วไปเช่นกัน หากฉันมีอินเทอร์เฟซที่มีคุณสมบัติสำหรับ Task
และฉันต้องการเก็บคอลเล็กชันเหล่านี้ไว้ใน React Component ฉันสามารถทำได้ดังนี้:
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> ); };
นอกจากนี้ หากเราต้องการส่งชุดอุปกรณ์ประกอบฉากไปยังฟังก์ชันของเรา เราสามารถใช้ประเภท React.FC<T>
ทั่วไปและเข้าถึง props
ได้:
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 Compiler
บทสรุป
ในบทความนี้ เราได้เห็นตัวอย่าง Generics และกรณีการใช้งานต่างๆ มากมาย ตั้งแต่การรวบรวมอย่างง่าย ไปจนถึงวิธีการจัดการข้อผิดพลาด ไปจนถึงการแยกชั้นการเข้าถึงข้อมูล และอื่นๆ ในแง่ที่ง่ายที่สุด Generics อนุญาตให้เราสร้างโครงสร้างข้อมูลโดยไม่จำเป็นต้องรู้เวลาที่เป็นรูปธรรมซึ่งพวกเขาจะดำเนินการในเวลารวบรวม หวังว่านี่จะช่วยเปิดประเด็นขึ้นอีกหน่อย ทำให้แนวคิดของ Generics เข้าใจง่ายขึ้นอีกเล็กน้อย และนำพลังที่แท้จริงมาสู่พวกเขา