TypeScript Jeneriklerini Anlama
Yayınlanan: 2022-03-10Bu makalede, TypeScript'te Generics kavramını öğreneceğiz ve Generics'in modüler, ayrıştırılmış ve yeniden kullanılabilir kod yazmak için nasıl kullanılabileceğini inceleyeceğiz. Yol boyunca, daha iyi test modellerine, hata işleme yaklaşımlarına ve etki alanı/veri erişimi ayrımına nasıl uyduklarını kısaca tartışacağız.
Gerçek Dünyadan Bir Örnek
Jeneriklerin dünyasına ne olduklarını açıklayarak değil , neden yararlı olduklarına dair sezgisel bir örnek vererek girmek istiyorum. Zengin özelliklere sahip bir dinamik liste oluşturmakla görevlendirildiğinizi varsayalım. Dil geçmişinize bağlı olarak buna bir dizi, bir ArrayList
, bir List
, bir std::vector
veya her neyse diyebilirsiniz. Belki de bu veri yapısı, yerleşik veya değiştirilebilir tampon sistemlerine de sahip olmalıdır (dairesel bir tampon ekleme seçeneği gibi). Düz diziler yerine kendi yapımızla çalışabilmemiz için normal JavaScript dizisinin etrafında bir sarmalayıcı olacak.
Karşılaşacağınız acil sorun, tip sisteminin dayattığı kısıtlamalardır. Bu noktada, istediğiniz herhangi bir türü bir işleve veya yönteme güzel ve temiz bir şekilde kabul edemezsiniz (bu ifadeyi daha sonra tekrar gözden geçireceğiz).
Tek bariz çözüm, tüm farklı türler için veri yapımızı çoğaltmaktır:
const intList = IntegerList.create(); intList.add(4); const stringList = StringList.create(); stringList.add('hello'); const userList = UserList.create(); userList.add(new User('Jamie'));
Buradaki .create()
sözdizimi keyfi görünebilir ve gerçekten de, new SomethingList()
daha basit olurdu, ancak bu statik fabrika yöntemini neden kullandığımızı daha sonra göreceksiniz. Dahili olarak, create
yöntemi yapıcıyı çağırır.
Bu korkunç. Bu toplama yapısı içinde çok fazla mantığımız var ve bunu farklı kullanım durumlarını desteklemek için açıkça çoğaltıyoruz ve bu süreçte DRY İlkesini tamamen yıkıyoruz. Uygulamamızı değiştirmeye karar verdiğimizde, yukarıdaki ikinci örnekte olduğu gibi kullanıcı tanımlı türler de dahil olmak üzere desteklediğimiz tüm yapılar ve türler arasında bu değişiklikleri manuel olarak yaymamız/yansıtmamız gerekecek. Koleksiyon yapısının 100 satır uzunluğunda olduğunu varsayalım - aralarındaki tek farkın tür olduğu birden fazla farklı uygulamayı sürdürmek bir kabus olurdu.
Akla gelebilecek acil bir çözüm, özellikle bir OOP zihniyetiniz varsa, eğer yapacaksanız bir kök “süper tip” düşünmektir. Örneğin C#'da, object
adında bir tür vardır ve object
, System.Object
sınıfı için bir diğer addır. C#'ın tür sisteminde, tüm türler, ister önceden tanımlanmış ister kullanıcı tanımlı olsun ve başvuru türleri veya değer türleri olsun, doğrudan veya dolaylı olarak System.Object
devralır. Bu, herhangi bir değerin bir tür object
değişkenine atanabileceği anlamına gelir (yığın/yığın ve kutulama/kutudan çıkarma semantiğine girmeden).
Bu durumda sorunumuz çözülmüş görünüyor. any
biri gibi bir tür kullanabiliriz ve bu, yapıyı kopyalamak zorunda kalmadan koleksiyonumuzda istediğimiz her şeyi saklamamıza izin verir ve gerçekten de bu çok doğru:
const intList = AnyList.create(); intList.add(4); const stringList = AnyList.create(); stringList.add('hello'); const userList = AnyList.create(); userList.add(new User('Jamie'));
any
kullanarak listemizin gerçek uygulamasına bakalım:
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. // ... }
Tüm yöntemler nispeten basittir, ancak yapıcı ile başlayacağız. Görünürlüğü özeldir, çünkü listemizin karmaşık olduğunu varsayacağız ve keyfi yapılara izin vermemek istiyoruz. Ayrıca inşaattan önce mantık yürütmek isteyebiliriz, bu nedenle bu nedenlerle ve yapıcıyı saf tutmak için bu endişeleri iyi bir uygulama olarak kabul edilen statik fabrika/yardımcı yöntemlerine devrediyoruz.
Statik yöntemler from
create
sağlanır. from
yöntemi bir dizi değeri kabul eder, özel mantık yürütür ve ardından bunları listeyi oluşturmak için kullanır. create
static yöntemi, listemizi ilk verilerle tohumlamak istediğimizde isteğe bağlı bir dizi değer alır. “Boş birleştirme operatörü” ( ??
) sağlanmaması durumunda boş bir dizi ile liste oluşturmak için kullanılır. İşlenenin sol tarafı null
veya undefined
ise, sağ tarafa geri döneceğiz, çünkü bu durumda values
isteğe bağlıdır ve bu nedenle undefined
olabilir. Boş birleştirme hakkında daha fazla bilgiyi ilgili TypeScript dokümantasyon sayfasından edinebilirsiniz.
Ayrıca bir select
ve where
yöntemi ekledim. Bu yöntemler sadece sırasıyla JavaScript'in map
ve filter
sarar. select
, sağlanan seçici işlevine dayalı olarak bir dizi öğeyi yeni bir biçime yansıtmamıza izin verir ve where
sağlanan yüklem işlevine dayalı olarak belirli öğeleri filtrelememize izin verir. toArray
yöntemi, dahili olarak tuttuğumuz dizi referansını döndürerek listeyi bir diziye dönüştürür.
Son olarak, User
sınıfının bir ad döndüren ve aynı zamanda ilk ve tek yapıcı argümanı olarak bir adı kabul eden bir getName
yöntemi içerdiğini varsayalım.
Not: Bazı okuyucular C#'ın LINQ'undanWhere
veSelect
tanıyacaktır, ancak unutmayın, bunu basit tutmaya çalışıyorum, bu nedenle tembellik veya ertelenmiş yürütme konusunda endişelenmiyorum. Bunlar gerçek hayatta yapılabilecek ve yapılması gereken optimizasyonlardır.
Ayrıca ilginç bir not olarak “yüklem”in anlamını tartışmak istiyorum. Kesikli Matematik ve Önermeler Mantığı'nda bir "önerme" kavramına sahibiz. Bir önerme, “dört ikiye bölünebilir” gibi doğru veya yanlış olarak kabul edilebilecek bir ifadedir. Bir "yüklem", bir veya daha fazla değişken içeren bir önermedir, bu nedenle önermenin doğruluğu bu değişkenlerinkine bağlıdır. Bunu bir fonksiyon gibi düşünebilirsiniz, örneğinP(x) = x is divisible by two
, çünkü ifadenin doğru mu yanlış mı olduğunu belirlemek içinx
değerini bilmemiz gerekir. Yüklem mantığı hakkında daha fazla bilgiyi buradan edinebilirsiniz.
any
kullanımından kaynaklanacak birkaç sorun vardır. TypeScript derleyicisi, liste/iç dizi içindeki öğeler hakkında hiçbir şey bilmez, bu nedenle, öğelerin where
veya select
veya eklenmesi konusunda herhangi bir yardım sağlamaz:
// 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 yalnızca tüm dizi öğelerinin türünün any
olduğunu bildiğinden, var olmayan özelliklerle veya var olmayan getNames
işleviyle derleme zamanında bize yardımcı olamaz, bu nedenle bu kod birden çok beklenmeyen çalışma zamanı hatasına neden olur .
Dürüst olmak gerekirse, işler oldukça kasvetli görünmeye başlıyor. Desteklemek istediğimiz her somut tür için veri yapımızı uygulamaya çalıştık, ancak bunun hiçbir şekilde sürdürülebilir olmadığını hemen anladık. Daha sonra, tüm türlerin türetildiği bir kalıtım zincirindeki bir kök üst türe bağlı olmaya benzer şekilde any
kullanarak bir yere vardığımızı düşündük, ancak bu yöntemle tür güvenliğini kaybettiğimiz sonucuna vardık. Çözüm ne peki?
Görünüşe göre, makalenin başında yalan söyledim (bir tür):
"Bu noktada, istediğiniz herhangi bir türü bir işleve veya yönteme güzel ve temiz bir şekilde kabul edemezsiniz."
Aslında yapabilirsiniz ve Jenerikler burada devreye giriyor. Dikkat edin “bu noktada” dedim, çünkü makalenin o noktasında Jenerik hakkında bilgimiz olmadığını varsayıyordum.
Liste yapımızın Generics ile tam uygulamasını göstererek başlayacağım ve sonra bir adım geri gideceğiz, gerçekte ne olduklarını tartışacağız ve sözdizimlerini daha resmi olarak belirleyeceğiz. Önceki AnyList
ayırt etmek için TypedList
adını verdim:
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. // .. }
Daha önce yaptığımız hataları bir kez daha yapmayı deneyelim:
// 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)
Gördüğünüz gibi, TypeScript derleyicisi bize tip güvenliği konusunda aktif olarak yardımcı oluyor. Bu yorumların tümü, bu kodu derlemeye çalışırken derleyiciden aldığım hatalardır. Jenerikler, listemizin üzerinde çalışmasına izin vermek istediğimiz bir tür belirlememize izin verdi ve bundan TypeScript, dizi içindeki tek tek nesnelerin özelliklerine kadar her şeyin türünü söyleyebilir.
Sağladığımız türler, olmasını istediğimiz kadar basit veya karmaşık olabilir. Burada hem ilkelleri hem de karmaşık arayüzleri geçebildiğimizi görebilirsiniz. Diğer dizileri, sınıfları veya herhangi bir şeyi de iletebiliriz:
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() }));
TypedList<T>
uygulamasında T
ve U
ve <T>
ve <U>
'nun özel kullanımları, eylemdeki Generics örnekleridir. Tip güvenli bir koleksiyon yapısı oluşturma yönergemizi yerine getirdikten sonra, bu örneği şimdilik geride bırakacağız ve Jeneriklerin gerçekte ne olduğunu, nasıl çalıştıklarını ve sözdizimlerini anladıktan sonra buna geri döneceğiz. Yeni bir kavram öğrendiğimde, her zaman kullanılan kavramın karmaşık bir örneğini görerek başlamayı severim, böylece temel bilgileri öğrenmeye başladığımda, temel konular ile mevcut örnek arasında bağlantı kurabilirim. kafa.
Jenerik Nedir?
Jenerikleri anlamanın basit bir yolu, onları türler için yer tutuculara veya değişkenlere nispeten benzer olarak düşünmektir. Bu, bir değişken gibi genel bir tür yer tutucu üzerinde aynı işlemleri gerçekleştirebileceğiniz anlamına gelmez, ancak genel bir tür değişkeni, gelecekte kullanılacak somut bir türü temsil eden bir yer tutucu olarak düşünülebilir. Yani, Generics kullanmak, daha sonra belirtilecek türler açısından programlar yazma yöntemidir. Bunun yararlı olmasının nedeni, üzerinde çalıştıkları farklı türlerde (veya türden bağımsız) yeniden kullanılabilen veri yapıları oluşturmamıza izin vermesidir.
Bu özellikle en iyi açıklama değil, bu yüzden daha basit terimlerle ifade etmek gerekirse, gördüğümüz gibi, programlamada belirli bir tür üzerinde çalışacak bir işlev/sınıf/veri yapısı oluşturmamız gerekebilir, ancak böyle bir veri yapısının çeşitli farklı türlerde çalışması gerektiği de aynı derecede yaygındır. Veri yapısını tasarladığımız sırada (derleme zamanında) bir veri yapısının üzerinde çalışacağı somut türü statik olarak bildirmek zorunda olduğumuz bir konumda sıkışıp kalsaydık, bunları yeniden oluşturmamız gerektiğini çok hızlı bir şekilde bulurduk. yukarıdaki örneklerde gördüğümüz gibi, desteklemek istediğimiz her tür için hemen hemen aynı şekilde yapılar.
Jenerikler, somut bir tip için gerekliliği gerçekten bilinene kadar ertelememize izin vererek bu sorunu çözmemize yardımcı olur.
TypeScript'te Jenerikler
Artık Jeneriklerin neden yararlı olduğuna dair organik bir fikrimiz var ve pratikte bunların biraz karmaşık bir örneğini gördük. Çoğu için, TypedList<T>
uygulaması muhtemelen zaten çok mantıklı, özellikle de statik olarak yazılmış bir dil geçmişinden geliyorsanız, ancak ilk öğrendiğimde kavramı anlamakta zorlandığımı hatırlıyorum, bu yüzden yapmak istiyorum. basit işlevlerle başlayarak bu örneği oluşturun. Yazılımda soyutlama ile ilgili kavramların içselleştirilmesi herkesin bildiği gibi zor olabilir, bu nedenle Jenerik kavramı henüz tam olarak yerine oturmadıysa, bu tamamen iyi ve umarım, bu makalenin kapanışıyla fikir en azından bir şekilde sezgisel olacaktır.
Bu örneği anlayabilecek şekilde inşa etmek için basit işlevlerden çalışalım. TypeScript belgelerinin kendisi de dahil olmak üzere çoğu makalenin kullanmayı sevdiği “Kimlik İşlevi” ile başlayacağız.
Matematikte bir "Kimlik Fonksiyonu", girdisini doğrudan çıktısına eşleyen bir fonksiyondur, örneğin f(x) = x
. Ne koyarsan onu çıkarırsın. Bunu JavaScript'te şu şekilde gösterebiliriz:
function identity(input) { return input; }
Veya daha kısaca:
const identity = input => input;
Bunu TypeScript'e taşımaya çalışmak, daha önce gördüğümüz tip sistem sorunlarını geri getiriyor. Çözümler, nadiren iyi bir fikir olduğunu bildiğimiz any
ile yazmak, her tür için işlevi çoğaltmak/aşırı yüklemek (DRY'yi keser) veya Generics kullanmaktır.
İkinci seçenekle, işlevi aşağıdaki gibi temsil edebiliriz:
// 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
Buradaki <T>
sözdizimi, bu işlevi Genel olarak bildirir. Tıpkı bir fonksiyonun kendi argüman listesine rastgele bir girdi parametresi geçirmemize izin vermesi gibi, bir Generic fonksiyon ile biz de rastgele tipte bir parametre iletebiliriz.
<T>(input: T): T ve <T>(input: T): T
identity<T>(input: T): T
imzasının <T>
kısmı, her iki durumda da söz konusu işlevin T
adında bir genel tür parametresini kabul edeceğini bildirir. Değişkenlerin herhangi bir isimde olabileceği gibi, Genel yer tutucularımız da olabilir, ancak büyük harf “T” (“Tür” için “T”) kullanmak ve gerektiğinde alfabeyi aşağı kaydırmak bir kuraldır. Unutmayın, T
bir türdür, bu nedenle ad input
bir işlev argümanını bir tür T
ile kabul edeceğimizi ve işlevimizin bir tür T
döndüreceğini de belirtiyoruz. İmzanın söylediği bu kadar. Kafanızda T = string
olmasına izin vermeyi deneyin - bu imzalardaki tüm T
string
ile değiştirin. Tüm bu büyülü hiçbir şeyin nasıl olmadığını görüyor musun? Her gün işlevleri kullanmanın genel olmayan yöntemine ne kadar benzer olduğunu gördün mü?
TypeScript ve işlev imzaları hakkında zaten bildiklerinizi aklınızda bulundurun. Tek söylediğimiz, T
kullanıcının işlevi çağırırken sağlayacağı keyfi bir tür olduğu, tıpkı input
kullanıcının işlevi çağırırken sağlayacağı keyfi bir değer olması gibi. Bu durumda, işlev gelecekte çağrıldığında T
türü ne olursa olsun input
olmalıdır.
Sonra, "gelecekte", iki günlük ifadesinde, tıpkı bir değişken yaptığımız gibi, kullanmak istediğimiz somut türü "geçiririz". Buradaki sözdizimindeki geçişe dikkat edin - <T> signature
ilk biçiminde, işlevimizi bildirirken geneldir - yani, genel türler veya daha sonra belirtilecek türler üzerinde çalışır. Bunun nedeni, işlevi gerçekten yazarken arayanın ne tür kullanmak isteyeceğini bilmememizdir. Ancak, arayan kişi işlevi çağırdığında, bu durumda string
ve number
olan hangi tür(ler)le çalışmak istediğini tam olarak bilir.
Bir üçüncü taraf kitaplığında bir günlük işlevinin bu şekilde bildirilmesi fikrini hayal edebilirsiniz - kitaplık yazarı, lib'i kullanan geliştiricilerin hangi türleri kullanmak isteyeceği konusunda hiçbir fikre sahip değildir, bu nedenle işlevi genel hale getirirler, esasen ihtiyacı ertelerler. aslında bilinene kadar somut tipler için.
Bu süreci, daha sezgisel bir anlayış kazanmak amacıyla bir değişkeni bir fonksiyona geçirme kavramını yaptığınız gibi düşünmeniz gerektiğini vurgulamak istiyorum. Şu anda yaptığımız tek şey de bir tür geçmek.
number
parametresiyle işlevi çağırdığımız noktada, orijinal imza, tüm niyet ve amaçlar için, identity(input: number): number
olarak düşünülebilir. Ve işlevi string
parametresiyle çağırdığımız noktada, yine orijinal imza aynı şekilde identity(input: string): string
olabilir. Arama yaparken her genel T
o anda sağladığınız somut türle değiştirildiğini hayal edebilirsiniz.
Genel Sözdizimini Keşfetmek
ES5 İşlevleri, Ok İşlevleri, Tür Takma Adları, Arabirimler ve Sınıflar bağlamında jenerikleri belirtmek için farklı sözdizimleri ve anlambilim vardır. Bu bölümde bu farklılıkları keşfedeceğiz.
Genel Sözdizimini Keşfetme — İşlevler
Şimdiye kadar birkaç genel işlev örneği gördünüz, ancak genel bir işlevin, tıpkı değişkenler gibi, birden fazla genel tür parametresini kabul edebileceğini unutmamak önemlidir. Hepsi virgülle ayrılmış (yine, giriş bağımsız değişkenleri gibi) bir, iki veya üç veya istediğiniz sayıda tür istemeyi seçebilirsiniz.
Bu işlev, üç giriş türünü kabul eder ve bunlardan birini rastgele döndürür:
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 );
Ayrıca, ES5 İşlevi veya Ok İşlevi kullanmamıza bağlı olarak sözdiziminin biraz farklı olduğunu görebilirsiniz, ancak her ikisi de imzada tür parametrelerini bildirir:
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]; }
Türler üzerinde zorlanan bir "benzersizlik kısıtlaması" olmadığını unutmayın - örneğin iki string
s ve bir number
gibi istediğiniz herhangi bir kombinasyonu iletebilirsiniz. Ek olarak, girdi bağımsız değişkenlerinin işlevin gövdesi için "kapsam dahilinde" olması gibi, genel tür parametreleri de öyledir. Önceki örnek, işlevin gövdesi içinden T
, U
ve V
öğelerine tam erişimimiz olduğunu gösterir ve bunları yerel bir 3'lü grup bildirmek için kullandık.
Bu jeneriklerin belirli bir "bağlam" üzerinde veya belirli bir "yaşam süresi" içinde çalıştığını ve bunun nerede ilan edildiklerine bağlı olduğunu hayal edebilirsiniz. İşlevlere ilişkin genel bilgiler, işlev imzası ve gövdesi (ve iç içe işlevler tarafından oluşturulan kapanışlar) kapsamındayken, bir sınıf veya arabirim veya tür diğer adı üzerinde bildirilen jenerikler, sınıfın veya arabirimin veya tür diğer adının tüm üyeleri için kapsamdadır.
İşlevlere ilişkin jenerik kavramı, "serbest işlevler" veya "kayan işlevler" (bir nesneye veya sınıfa bağlı olmayan işlevler, bir C++ terimi) ile sınırlı değildir, ancak bunlar başka yapılara eklenmiş işlevlerde de kullanılabilir.
Bu randomValue
bir sınıfa yerleştirebiliriz ve onu aynı şekilde adlandırabiliriz:
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 => { // ... } }
Arayüze bir tanım da koyabiliriz:
interface IUtils { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
Veya bir tür takma adı içinde:
type Utils = { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
Daha önce olduğu gibi, bu genel tür parametreleri söz konusu işlev için "kapsamdadır" - bunlar sınıf veya arabirim veya takma ad genelinde değildir. Yalnızca, tanımlandıkları belirli işlev içinde yaşarlar. Bir yapının tüm üyeleri arasında genel bir tür paylaşmak için, aşağıda göreceğimiz gibi, yapının adının kendisine açıklama eklemelisiniz.
Genel Sözdizimi Keşfetme — Tür Takma Adları
Tür Takma Adları ile, takma adın adında genel sözdizimi kullanılır.
Örneğin, bir değeri kabul eden, muhtemelen o değeri değiştiren, ancak void döndüren bir "eylem" işlevi şu şekilde yazılabilir:
type Action<T> = (val: T) => void;
Not : Bu, Action<T> temsilcisini anlayan C# geliştiricilerine aşina olmalıdır.
Veya hem bir hatayı hem de bir değeri kabul eden bir geri çağırma işlevi şu şekilde bildirilebilir:
type CallbackFunction<T> = (err: Error, data: T) => void; const usersApi = { get(uri: string, cb: CallbackFunction<User>) { /// ... } }
İşlev jenerik bilgimizle daha da ileri gidebilir ve işlevi API nesnesindeki işlevi de genel hale getirebiliriz:
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>) { /// ... } }
Şimdi, get
işlevinin bazı genel tür parametrelerini kabul ettiğini ve bu her neyse, CallbackFunction
onu aldığını söylüyoruz. CallbackFunction
için T
olarak get
içine giren T
esasen "geçtik". İsimleri değiştirirsek belki bu daha mantıklı olur:
type CallbackFunction<TData> = (err: Error, data: TData) => void; const api = { get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }
Tür paramlarını T
ile öneklemek yalnızca bir kuraldır, tıpkı arabirimleri I
veya üye değişkenlerle _
ile öneklemek gibi. Burada görebileceğiniz şey, CallbackFunction
işlev için kullanılabilir veri yükünü temsil eden bazı türleri ( TData
) kabul etmesi, get
ise HTTP Yanıtı veri türünü/şeklini ( TResponse
) temsil eden bir tür parametresini kabul etmesidir. Axios'a benzer HTTP İstemcisi ( api
), CallbackFunction
için TData
olarak TResponse
ne ise onu kullanır. Bu, API arayanın API'den geri alacakları veri türünü seçmesine olanak tanır (boru hattında başka bir yerde JSON'u bir DTO'ya ayrıştıran ara katman yazılımımız olduğunu varsayalım).
Bunu biraz daha ileri götürmek istersek, CallbackFunction
genel tür parametrelerini özel bir hata türünü de kabul edecek şekilde değiştirebiliriz:
type CallbackFunction<TData, TError> = (err: TError, data: TData) => void;
Ve işlev bağımsız değişkenlerini isteğe bağlı hale getirebildiğiniz gibi, tür parametreleriyle de yapabilirsiniz. Kullanıcının bir hata türü sağlamaması durumunda, bunu varsayılan olarak hata yapıcısına ayarlayacağız:
type CallbackFunction<TData, TError = Error> = (err: TError, data: TData) => void;
Bununla, artık bir geri arama işlevi türünü birden çok yolla belirtebiliriz:
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) => { // ... });
Bu varsayılan parametreler fikri, işlevler, sınıflar, arabirimler vb. genelinde kabul edilebilir - yalnızca takma adlarla sınırlı değildir. Şimdiye kadar gördüğümüz tüm örneklerde, istediğimiz herhangi bir tür parametresini varsayılan bir değere atayabilirdik. Tür Takma Adları, tıpkı işlevler gibi, istediğiniz kadar genel tür parametresi alabilir.
Genel Sözdizimini Keşfetme — Arayüzler
Gördüğünüz gibi, bir arabirimdeki bir işleve genel bir tür parametresi sağlanabilir:
interface IUselessFunctions { // Not generic printHelloWorld(); // Generic identity<T>(t: T): T; }
Bu durumda T
, yalnızca giriş ve dönüş türü olarak identity
işlevi için yaşar.
Ayrıca, arabirimin kendisinin bir jenerik kabul ettiğini belirterek, sınıflar ve tür takma adlarında olduğu gibi bir arabirimin tüm üyeleri için bir tür parametresi kullanılabilir hale getirebiliriz. Jenerikler için daha karmaşık kullanım örneklerini tartıştığımızda biraz sonra Depo Modeli hakkında konuşacağız, bu yüzden onu hiç duymadıysanız sorun değil. Depo Kalıbı, iş mantığını kalıcılıktan bağımsız hale getirmek için veri depolamamızı soyutlamamıza izin verir. Bilinmeyen varlık türleri üzerinde çalışan genel bir havuz arayüzü oluşturmak isterseniz, bunu aşağıdaki gibi yazabiliriz:
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>; }
Not : Depolar hakkında Martin Fowler'ın tanımından DDD Toplamı tanımına kadar birçok farklı düşünce vardır. Sadece jenerikler için bir kullanım örneği göstermeye çalışıyorum, bu yüzden uygulama açısından tamamen doğru olmakla ilgilenmiyorum. Jenerik depoları kullanmamak için kesinlikle söylenecek bir şey var, ama bunu daha sonra konuşacağız.
Burada görebileceğiniz gibi IRepository
, verileri depolamak ve almak için yöntemler içeren bir arayüzdür. T
adlı bazı genel tür parametreleri üzerinde çalışır ve T
add
ve updateById
için girdi olarak ve findById
öğesinin vaat edilen çözümleme sonucu olarak kullanılır.
Her işlevin kendisinin genel bir tür parametresini kabul etmesine izin vermekle arabirim adında genel bir tür parametresi kabul etmek arasında çok büyük bir fark olduğunu unutmayın. İlki, burada yaptığımız gibi, arayüz içindeki her fonksiyonun aynı tip T
üzerinde çalışmasını sağlar. Diğer bir deyişle, bir IRepository<User>
için, arabirimde T
kullanan her yöntem artık User
nesneleri üzerinde çalışmaktadır. İkinci yöntemle, her işlevin istediği türde çalışmasına izin verilir. Depoya yalnızca User
ekleyebilmek, ancak Policies
veya Orders
geri alabilmek çok tuhaf olurdu, örneğin, türün olduğunu zorlayamazsak kendimizi bulacağımız potansiyel durum budur. tüm yöntemlerde tek tip.
Belirli bir arabirim, yalnızca paylaşılan bir türü değil, aynı zamanda üyelerine özgü türleri de içerebilir. Örneğin, bir diziyi taklit etmek istersek, şöyle bir arayüz yazabiliriz:
interface IArray<T> { forEach(func: (elem: T, index: number) => void): this; map<U>(func: (elem: T, index: number) => U): IArray<U>; }
Bu durumda, hem forEach
hem de map
, arayüz adından T
erişebilir. Belirtildiği gibi, T
arayüzün tüm üyeleri için kapsam dahilinde olduğunu hayal edebilirsiniz. Buna rağmen, hiçbir şey, içindeki bireysel işlevlerin kendi tür parametrelerini de kabul etmesini engellemez. map
işlevi, U
ile çalışır. Artık map
hem T
hem de U
erişimi var. Parametreyi U
gibi farklı bir harf olarak adlandırmamız gerekiyordu çünkü T
zaten alınmış ve bir adlandırma çakışması istemiyoruz. Adına oldukça benzer şekilde map
, dizi içindeki T
türündeki öğeleri U
türündeki yeni öğelerle "eşler". T
s'yi U
s ile eşler. Bu işlevin dönüş değeri arabirimin kendisidir, şimdi yeni U
türünde çalışır, böylece JavaScript'in diziler için akıcı zincirlenebilir sözdizimini bir şekilde taklit edebiliriz.
Repository Pattern'i uyguladığımızda ve Dependency Injection'ı tartıştığımızda, Jeneriklerin ve Arayüzlerin gücünün bir örneğini kısaca göreceğiz. Bir kez daha, bir arayüzün sonunda yığınlanmış bir veya daha fazla varsayılan parametre seçmenin yanı sıra, birçok genel parametreyi kabul edebiliriz.
Genel Sözdizimini Keşfetme — Sınıflar
Genel bir tür parametresini bir tür takma adına, işleve veya arabirime geçirebileceğimiz gibi, bir sınıfa da bir veya daha fazlasını iletebiliriz. Bunu yaptıktan sonra, bu tür parametresine, o sınıfın tüm üyeleri ve ayrıca genişletilmiş temel sınıflar veya gerçekleştirilmiş arabirimler erişebilir.
Başka bir koleksiyon sınıfı oluşturalım, ancak yukarıdaki TypedList
biraz daha basit, böylece genel türler, arabirimler ve üyeler arasındaki birlikte çalışmayı görebiliriz. Biraz sonra bir türün bir temel sınıfa ve arabirim mirasına iletilmesinin bir örneğini göreceğiz.
Koleksiyonumuz, bir map
ve forEach
yöntemine ek olarak yalnızca temel CRUD işlevlerini destekleyecektir.
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);
Burada neler olduğunu tartışalım. Collection
sınıfı, T
adlı bir genel tür parametresini kabul eder. Bu tür, sınıfın tüm üyeleri tarafından erişilebilir hale gelir. Bunu, Array<T>
biçiminde de belirtebileceğimiz T[]
türünde özel bir dizi tanımlamak için kullanırız (Normal TS dizisi yazma için tekrar Generics'e bakın). Ayrıca, çoğu üye işlevi, eklenen ve kaldırılan türleri kontrol etmek veya koleksiyonun bir öğe içerip içermediğini kontrol etmek gibi bir şekilde bu T
kullanır.
Son olarak, daha önce gördüğümüz gibi, map
yöntemi kendi genel tip parametresini gerektirir. 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());
Tür çıkarımını göstermek için, daha önce TypedList
yapımızdan teknik olarak gereksiz tüm tür açıklamalarını kaldırdım ve aşağıdaki resimlerden TSC'nin hala tüm türleri doğru çıkardığını görebilirsiniz:
TypedList
, gereksiz tür bildirimleri olmadan:
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. // .. }
İşlev dönüş değerlerine ve içinden geçirilen giriş from
ve yapıcıya dayalı olarak, TSC tüm tür bilgilerini anlar. Aşağıdaki resimde, Visual Studio'nun Code TypeScript'in Dil Uzantısını (ve dolayısıyla derleyiciyi) gösteren ve tüm türleri çıkaran birden fazla resmi birbirine diktim:
Genel Kısıtlamalar
Bazen, genel bir türün çevresine bir kısıtlama koymak isteriz. Belki de var olan her türü destekleyemiyoruz, ancak bunların bir alt kümesini destekleyebiliriz. Diyelim ki bazı koleksiyonların uzunluğunu döndüren bir fonksiyon oluşturmak istiyoruz. Yukarıda görüldüğü gibi, varsayılan JavaScript Array
özel dizilerimize kadar birçok farklı türde diziye/koleksiyona sahip olabiliriz. İşlevimize, bazı genel türlerin kendisine bağlı bir length
özelliği olduğunu nasıl bildirebiliriz? Benzer şekilde, fonksiyona aktardığımız somut türleri, ihtiyacımız olan verileri içerenlerle nasıl kısıtlarız? Örneğin, bunun gibi bir örnek işe yaramaz:
function getLength<T>(collection: T): number { // Error. TS does not know that a type T contains a `length` property. return collection.length; }
Cevap, Genel Kısıtlamaları kullanmaktır. İhtiyacımız olan özellikleri tanımlayan bir arayüz tanımlayabiliriz:
interface IHasLength { length: number; }
Şimdi, genel işlevimizi tanımlarken, genel türü, bu arabirimi genişleten bir tür olarak sınırlayabiliriz:
function getLength<T extends IHasLength>(collection: T): number { // Restricting `collection` to be a type that contains // everything within the `IHasLength` interface. return collection.length; }
Gerçek Dünya Örnekleri
Sonraki birkaç bölümde, daha zarif ve akıl yürütmesi kolay kodlar oluşturan bazı gerçek dünyadaki jenerik ilaç örneklerini tartışacağız. Pek çok önemsiz örnek gördük, ancak hata işleme, veri erişim kalıpları ve ön uç React durumu/sahnelerine ilişkin bazı yaklaşımları tartışmak istiyorum.
Gerçek Dünya Örnekleri — Hata İşleme Yaklaşımları
JavaScript, çoğu programlama dilinde olduğu gibi hataları işlemek için birinci sınıf bir mekanizma içerir - try
/ catch
. Buna rağmen, kullanıldığında nasıl göründüğünün çok büyük bir hayranı değilim. Bu, mekanizmayı kullanmadığım anlamına gelmiyor, kullanıyorum ama elimden geldiğince saklamaya çalışıyorum. try
/ catch
soyutlayarak, başarısız olma olasılığı yüksek işlemlerde hata işleme mantığını da yeniden kullanabilirim.
Bir Veri Erişim Katmanı oluşturduğumuzu varsayalım. Bu, veri depolama yöntemiyle ilgilenmek için kalıcılık mantığını saran bir uygulama katmanıdır. Veritabanı işlemleri yapıyorsak ve bu veritabanı bir ağ üzerinde kullanılıyorsa, belirli DB'ye özgü hatalar ve geçici istisnaların oluşması muhtemeldir. Özel bir Veri Erişim Katmanına sahip olmanın bir nedeni, veritabanını iş mantığından soyutlamaktır. Bu nedenle, yığına ve bu katmanın dışına atılan DB'ye özgü hatalara sahip olamayız. Önce onları sarmamız gerekiyor.
try
/ catch
kullanan tipik bir uygulamaya bakalım:
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
değiştirmek, bir if/else if zinciri bildirmek yerine, hata kontrol mantığım için switch case
ifadelerini kullanabilmek için bir yöntemdir - @Jeffijoe'dan ilk kez duyduğum bir numara.
Böyle birden fazla işlevimiz varsa, çok kötü bir uygulama olan bu hata sarma mantığını çoğaltmamız gerekir. Bir işlev için oldukça iyi görünüyor, ancak birçok işlev için bir kabus olacak. Bu mantığı soyutlamak için, sonuçtan geçecek özel bir hata işleme işlevine sarabiliriz, ancak atılmaları durumunda hataları yakalayıp sararız:
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()); } } }
Bunun mantıklı olduğundan emin olmak için, bazı genel tür parametresi T
kabul eden withErrorHandling
başlıklı bir işlevimiz var. Bu T
, dalOperation
geri çağırma işlevinden döndürülmesini beklediğimiz sözün başarılı çözümleme değerinin türünü temsil eder. Genellikle, biz sadece zaman uyumsuz dalOperation
işlevinin dönüş sonucunu döndürdüğümüz için, işlevi ikinci bir yabancı söze await
için onu beklememize gerek kalmaz ve await
kodu çağıran koda bırakabiliriz. Bu durumda, herhangi bir hatayı yakalamamız gerekiyor, bu nedenle await
gerekiyor.
Artık bu işlevi, daha önceki DAL işlemlerimizi sarmak için kullanabiliriz:
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); }); }
Ve işte başlıyoruz. Güvenli ve hatasız bir fonksiyon kullanıcı sorgulama fonksiyonumuz var.
Ek olarak, daha önce gördüğünüz gibi, TypeScript Derleyicisi türleri örtük olarak çıkarmak için yeterli bilgiye sahipse, bunları açıkça iletmeniz gerekmez. Bu durumda, TSC, işlevin dönüş sonucunun genel türün ne olduğunu bilir. Bu nedenle, mapper.toDomain(user)
bir User
türü döndürdüyse, türü hiç iletmeniz gerekmez:
async function queryUser(userID: string) { return withErrorHandling(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(user); }); }
Hata işlemeye yönelik sevdiğim bir başka yaklaşım da Monadik Türlerdir. Ya Monad, Either<T, U>
biçiminde bir cebirsel veri türüdür; burada T
bir hata türünü temsil edebilir ve U
bir başarısızlık türünü temsil edebilir. Monadik Türleri kullanmak, işlevsel programlamaya kulak verir ve büyük bir yararı, hataların tür açısından güvenli hale gelmesidir - normal bir işlev imzası, API arayan kişiye, işlevin hangi hataları atabileceği hakkında hiçbir şey söylemez. queryUser
içinden bir NotFound
hatası attığımızı varsayalım. queryUser(userID: string): Promise<User>
imzası bize bununla ilgili hiçbir şey söylemez. Ancak, queryUser(userID: string): Promise<Either<NotFound, User>>
gibi bir imza kesinlikle yapar. Ya Monad gibi monadların bu makalede nasıl çalıştığını açıklamayacağım çünkü bunlar oldukça karmaşık olabilir ve haritalama/bağlama gibi monad olarak kabul edilmeleri gereken çeşitli yöntemler vardır. Onlar hakkında daha fazla bilgi edinmek isterseniz, Scott Wlaschin'in NDC konuşmalarından ikisini burada ve burada ve Daniel Chamber'ın burada konuşmasını tavsiye ederim. Bu site ve bu blog yazıları da faydalı olabilir.
Gerçek Dünya Örnekleri — Depo Modeli
Jeneriklerin yardımcı olabileceği başka bir kullanım örneğine bakalım. Çoğu arka uç sistemin bir şekilde bir veritabanıyla arayüz oluşturması gerekir - bu PostgreSQL gibi ilişkisel bir veritabanı, MongoDB gibi bir belge veritabanı veya belki de Neo4j gibi bir grafik veritabanı olabilir.
Geliştiriciler olarak, düşük düzeyde eşleştirilmiş ve yüksek düzeyde uyumlu tasarımları hedeflememiz gerektiğinden, geçiş yapan veritabanı sistemlerinin sonuçlarının ne olabileceğini düşünmek adil bir argüman olacaktır. Farklı veri erişim ihtiyaçlarının farklı veri erişim yaklaşımlarını tercih edebileceğini düşünmek de adil olur (bu, okuma ve yazma işlemlerini ayırmak için bir model olan CQRS'ye biraz girmeye başlar. İsterseniz Martin Fowler'ın gönderisine ve MSDN listesine bakın) Vaughn Vernon'un “Implementing Domain Driven Design” ve Scott Millet'in “Patterns, Principles, and Practices of Domain-Driven Design” kitapları da iyi okumalar. Otomatik testi de düşünmeliyiz. Node.js ile arka uç sistemlerin oluşturulmasını açıklayan öğreticilerin çoğu, veri erişim kodunu yönlendirme ile iş mantığı ile karıştırır. Yani, MongoDB'yi Mongoose ODM ile birlikte kullanma, Aktif Kayıt yaklaşımını benimseme ve net bir endişe ayrımına sahip olmama eğilimindedirler. Bu tür teknikler büyük uygulamalarda hoş karşılanmaz; bir veritabanı sistemini diğerine geçirmek istediğinize karar verdiğinizde veya veri erişimi için farklı bir yaklaşımı tercih edeceğinizi anladığınız anda, o eski veri erişim kodunu sökmeniz, yeni kodla değiştirmeniz gerekir. ve yol boyunca yönlendirme ve iş mantığına herhangi bir hata getirmediğinizi umuyoruz.
Elbette, birim ve entegrasyon testlerinin gerilemeleri önleyeceğini iddia edebilirsiniz, ancak bu testler kendilerini birleşmiş ve agnostik olmaları gereken uygulama ayrıntılarına bağımlı bulurlarsa, muhtemelen onlar da süreçten kopacaktır.
Bu sorunu çözmek için yaygın bir yaklaşım, Depo Kalıbıdır. Kod çağırmak için, veri erişim katmanımızın yalnızca bellek içi nesneler veya etki alanı varlıkları koleksiyonunu taklit etmesine izin vermemiz gerektiğini söylüyor. Bu şekilde, veri tabanı (veri modeli) yerine tasarımı işletmenin yönlendirmesine izin verebiliriz. Büyük uygulamalar için Etki Alanına Dayalı Tasarım adlı bir mimari model kullanışlı hale gelir. Depo Modelindeki depolar, veri kaynaklarına erişmek için tüm mantığı kapsayan ve içinde tutan, en yaygın olarak sınıflar olan bileşenlerdir. Bununla, veri erişim kodunu tek bir katmanda merkezileştirerek kolayca test edilebilir ve kolayca yeniden kullanılabilir hale getirebiliriz. Ayrıca, veritabanından bağımsız etki alanı modellerini bir dizi bire bir tablo eşlemelerine eşlememize izin veren bir eşleme katmanı yerleştirebiliriz. Depoda bulunan her bir işlev, tercih ederseniz, isteğe bağlı olarak farklı bir veri erişim yöntemi kullanabilir.
Depolar, İş Birimleri, tablolar arasında veritabanı işlemleri vb. için birçok farklı yaklaşım ve anlambilim vardır. Bu, Jeneriklerle ilgili bir makale olduğu için, yabani otlara çok fazla girmek istemiyorum, bu nedenle burada basit bir örnek göstereceğim, ancak farklı uygulamaların farklı ihtiyaçları olduğunu belirtmek önemlidir. Örneğin, DDD Toplamaları için bir Depo, burada yaptığımızdan oldukça farklı olacaktır. Buradaki Repository uygulamalarını nasıl tasvir ettiğim, onları gerçek projelerde nasıl uyguladığım değil, çünkü birçok eksik işlevsellik ve kullanımda istenenden daha az mimari uygulamalar var.
Etki alanı modelleri olarak Users
ve Tasks
olduğunu varsayalım. Bunlar sadece POTO'lar olabilir - Düz-Eski TypeScript Nesneleri. Bunlara eklenmiş bir veritabanı kavramı yoktur, bu nedenle, örneğin Mongoose'u kullandığınız gibi User.save()
. Depo Kalıbını kullanarak, bir kullanıcıyı devam ettirebilir veya bir görevi iş mantığımızdan aşağıdaki gibi silebiliriz:
// 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);
Açıkça, tüm dağınık ve geçici veri erişim mantığının bu havuz cephesinin/soyutlamasının arkasına nasıl gizlendiğini, iş mantığını kalıcılık endişelerine karşı agnostik hale getirdiğini görebilirsiniz.
Birkaç basit etki alanı modeli oluşturarak başlayalım. Bunlar uygulama kodunun etkileşimde bulunacağı modellerdir. Burada anemikler, ancak gerçek dünyada işle ilgili değişmezleri tatmin etmek için kendi mantıklarına sahip olacaklardı, yani sadece veri çantaları olmayacaklardı.
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; } }
Bir arayüze kimlik yazma bilgilerini neden çıkardığımızı birazdan anlayacaksınız. Bu etki alanı modellerini tanımlama ve her şeyi yapıcıdan geçirme yöntemi, bunu gerçek dünyada nasıl yapacağım değil. Ek olarak, id
uygulamasını ücretsiz olarak elde etmek için soyut bir etki alanı modeli sınıfına güvenmek, arayüzden daha fazla tercih edilirdi.
Depo için, bu durumda, aynı kalıcılık mekanizmalarının birçoğunun farklı etki alanı modelleri arasında paylaşılmasını beklediğimizden, Depo yöntemlerimizi genel bir arayüze soyutlayabiliriz:
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>; }
Yinelemeyi azaltmak için daha ileri gidebilir ve bir Genel Depo oluşturabiliriz. Kısaca, bunu burada yapmayacağım ve şunu belirtmeliyim ki bu ve Genel Depolar gibi Genel Depo arayüzleri genel olarak hoş karşılanmama eğilimindedir, çünkü salt okunur veya yazılabilir belirli varlıklarınız olabilir. -yalnızca veya silinemeyen veya benzeri. Uygulamaya bağlıdır. Ayrıca, tablolar arasında bir işlemi paylaşmak için bir “iş birimi” kavramına sahip değiliz, bu gerçek dünyada uygulayacağım bir özellik, ancak yine, bu küçük bir demo olduğu için yok. çok teknik olmak istiyorum.
UserRepository
uygulamamızı uygulayarak başlayalım. Kullanıcılara özgü yöntemleri tutan bir IUserRepository
arabirimi tanımlayacağım, böylece bağımlılık somut uygulamaları enjekte ettiğimizde çağrı kodunun bu soyutlamaya bağlı olmasına izin vereceğim:
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. }
Görev Deposu benzer olacaktır ancak uygulamanın uygun gördüğü şekilde farklı yöntemler içerecektir.
Burada, genel olanı genişleten bir arabirim tanımlıyoruz, bu nedenle üzerinde çalıştığımız somut türü geçmemiz gerekiyor. Her iki arayüzden de görebileceğiniz gibi, bu POTO domain modellerini içeri gönderip dışarı çıkardığımız fikrine sahibiz. Çağıran kodun, temeldeki kalıcılık mekanizmasının ne olduğu hakkında hiçbir fikri yoktur ve mesele bu.
Bir sonraki düşüncemiz, seçtiğimiz veri erişim yöntemine bağlı olarak, veritabanına özgü hataları ele almamız gerektiğidir. Örneğin, Mongoose veya Knex Query Builder'ı bu Deponun arkasına yerleştirebiliriz ve bu durumda, bu belirli hataları ele almamız gerekecek - bunların iş mantığına dönüşmesini istemiyoruz, çünkü bu endişelerin ayrılmasını bozacaktır. ve daha büyük bir bağlantı derecesi sunar.
Kullanmak istediğimiz veri erişim yöntemleri için bizim için hataları işleyebilecek bir Temel Depo tanımlayalım:
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. } } }
Şimdi, Depodaki bu Temel Sınıfı genişletebilir ve bu Genel yönteme erişebiliriz:
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`. }
İşlevimizin veritabanından bir DbUser
ve onu döndürmeden önce bir User
etki alanı modeline eşlediğine dikkat edin. Bu, Veri Eşleştirici modelidir ve endişelerin ayrılmasını sağlamak çok önemlidir. DbUser
, veritabanı tablosuna bire bir eşlemedir - Deponun üzerinde çalıştığı veri modelidir - ve bu nedenle kullanılan veri depolama teknolojisine büyük ölçüde bağımlıdır. Bu nedenle, DbUser
s, Depodan asla ayrılmaz ve döndürülmeden önce bir User
etki alanı modeline eşlenir. DbUser
uygulamasını göstermedim, ancak bu sadece basit bir sınıf veya arayüz olabilir.
Şimdiye kadar, Generics tarafından desteklenen Depo Kalıbını kullanarak, veri erişimi endişelerini küçük birimlere ayırmayı ve aynı zamanda tür güvenliğini ve yeniden kullanılabilirliği korumayı başardık.
Son olarak, Birim ve Entegrasyon Testi amaçları için, bir test ortamında, bu depoyu enjekte edebilmemiz ve bir disk ile alay etmek yerine diskte durum tabanlı iddialar gerçekleştirebilmemiz için bir bellek içi depo uygulamasını tutacağımızı varsayalım. alaycı çerçeve. Bu yöntem, testlerin uygulama ayrıntılarına bağlanmasına izin vermek yerine, her şeyi halka açık arabirimlere güvenmeye zorlar. Her depo arasındaki tek fark, ISomethingRepository
arabirimi altında eklemeyi seçtikleri yöntemler olduğundan, genel bir bellek içi depo oluşturabilir ve bunu türe özgü uygulamalarda genişletebiliriz:
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. }
Bu temel sınıfın amacı, bellek içi test havuzlarında çoğaltmak zorunda kalmamamız için bellek içi depolamayı işlemek için tüm mantığı gerçekleştirmektir. findById
gibi yöntemler nedeniyle, bu havuzun varlıkların bir id
alanı içerdiğini anlaması gerekir, bu nedenle IHasIdentity
arabirimindeki genel kısıtlama gereklidir. Bu arayüzü daha önce görmüştük - etki alanı modellerimizin uyguladığı şey buydu.
Bununla, bellek içi kullanıcı veya görev deposu oluşturmaya gelince, bu sınıfı genişletebilir ve yöntemlerin çoğunu otomatik olarak uygulayabiliriz:
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. }
Burada, InMemoryRepository
varlıkların id
ve username
gibi alanları olduğunu bilmesi gerekir, bu nedenle genel parametre olarak User
iletiyoruz. User
zaten IHasIdentity
, bu nedenle genel kısıtlama karşılanır ve ayrıca bir username
özelliğine sahip olduğumuzu da belirtiriz.
Şimdi, İş Mantığı Katmanından bu depoları kullanmak istediğimizde, oldukça basit:
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); } }
(Gerçek bir uygulamada, talebe gecikme eklememek ve hatalar üzerinde yetersiz yeniden denemeler yapabilme emailService
çağrısını muhtemelen bir iş kuyruğuna taşıyacağız (— e-posta göndermenin özellikle ilk etapta idempotent) Ayrıca, tüm kullanıcı nesnesini hizmete geçirmek de şüphelidir.Dikkat edilmesi gereken diğer bir konu da, kendimizi burada, kullanıcı kalıcı olduktan sonra ancak e-posta gönderilmeden önce sunucunun çöktüğü bir konumda bulabiliriz. Bunu önlemek için azaltma kalıpları vardır, ancak pragmatizm amacıyla, uygun günlük kaydıyla insan müdahalesi muhtemelen gayet iyi sonuç verecektir).
Ve işte başlıyoruz - Generics'in gücüyle Depo Kalıbını kullanarak, DAL'ımızı BLL'mizden tamamen ayırdık ve depomuzla güvenli bir şekilde arayüz oluşturmayı başardık. Ayrıca, birim ve entegrasyon testi amacıyla, gerçek kara kutu ve uygulamadan bağımsız testlere izin vererek, eşit derecede güvenli bellek içi depoları hızla oluşturmak için bir yol geliştirdik. Bunların hiçbiri Genel türler olmadan mümkün olmazdı.
Bir sorumluluk reddi olarak, bir kez daha bu Depo uygulamasının çok eksik olduğunu belirtmek istiyorum. Odak noktası jenerik ilaçların kullanımı olduğundan, örneği basit tutmak istedim, bu yüzden çoğaltmayı ele almadım veya işlemler hakkında endişelenmedim. Uygun depo uygulamaları, tam ve doğru bir şekilde açıklamak için tek başına bir makaleye ihtiyaç duyar ve uygulama ayrıntıları, N-Tier Architecture veya DDD yapmanıza bağlı olarak değişir. Bu, Depo Modelini kullanmak istiyorsanız, buradaki uygulamama hiçbir şekilde en iyi uygulama olarak bakmamanız gerektiği anlamına gelir.
Gerçek Dünya Örnekleri — React State & Props
React Functional Components için durum, referans ve kancaların geri kalanı da Geneldir. Task
s özelliklerini içeren bir arabirime sahipsem ve bunların bir koleksiyonunu bir React Bileşeninde tutmak istersem, bunu aşağıdaki gibi yapabilirim:
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> ); };
Ek olarak, fonksiyonumuza bir dizi props eklemek istiyorsak, genel React.FC<T>
türünü kullanabilir ve props
erişebiliriz:
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> ); };
IProps
props
belirlenir.
Çözüm
Bu makalede, basit koleksiyonlardan hata işleme yaklaşımlarına, veri erişim katmanı yalıtımına vb. kadar birçok farklı Generics örneği ve kullanım durumları gördük. En basit ifadeyle, Generics, derleme zamanında çalışacakları somut zamanı bilmemize gerek kalmadan veri yapıları oluşturmamıza izin verir. Umarım bu, konuyu biraz daha açmaya, Jenerik kavramını biraz daha sezgisel hale getirmeye ve gerçek güçlerini ortaya çıkarmaya yardımcı olur.