Avastage TypeScripti täiustatud kontseptsioon – kõrgema järgu tüübid (HKT). Uurige, mis need on, miks need on olulised ja kuidas neid emuleerida võimsa, abstraktse ja taaskasutatava koodi loomiseks.
Täiustatud Abstraktsioonide Avamine: Sügav Sukeldumine TypeScripti Kõrgema Järgu Tüüpidesse
Staatiliselt tüübitud programmeerimise maailmas otsivad arendajad pidevalt uusi viise, kuidas kirjutada abstraktsemat, taaskasutatavamat ja tüübikindlamat koodi. TypeScripti võimas tüübisüsteem, mis sisaldab selliseid funktsioone nagu geneerikud, tingimuslikud tüübid ja vastendatud tüübid, on toonud JavaScripti ökosüsteemi märkimisväärse ohutuse ja väljendusrikkuse. Siiski on olemas tüübitaseme abstraktsiooni piir, mis jääb TypeScripti jaoks natiivselt kättesaamatuks: kõrgema järgu tüübid (HKT-d).
Kui olete kunagi soovinud kirjutada funktsiooni, mis on geneeriline mitte ainult väärtuse tüübi, vaid ka seda väärtust hoidva konteineri suhtes – näiteks Array
, Promise
või Option
–, siis olete juba tundnud vajadust HKT-de järele. See kontseptsioon, mis on laenatud funktsionaalsest programmeerimisest ja tüüb-iteooriast, on võimas tööriist tõeliselt geneeriliste ja komponeeritavate teekide loomiseks.
Kuigi TypeScript ei toeta HKT-sid vaikimisi, on kogukond välja töötanud geniaalseid viise nende emuleerimiseks. See artikkel viib teid sügavale kõrgema järgu tüüpide maailma. Me uurime:
- Mis on HKT-d kontseptuaalselt, alustades põhiprintsiipidest ja järkudest (kinds).
- Miks standardsed TypeScripti geneerikud jäävad ebapiisavaks.
- Kõige populaarsemad tehnikad HKT-de emuleerimiseks, eriti lähenemisviis, mida kasutavad teegid nagu
fp-ts
. - HKT-de praktilised rakendused võimsate abstraktsioonide, nagu funktorite, aplikatiivide ja monaadide, loomiseks.
- HKT-de praegune seis ja tulevikuväljavaated TypeScriptis.
See on edasijõudnute teema, kuid selle mõistmine muudab põhjalikult teie mõtlemist tüübitaseme abstraktsioonist ja annab teile võimaluse kirjutada robustsemat ja elegantsemat koodi.
Aluste Mõistmine: Geneerikud ja Järgud (Kinds)
Enne kui saame sukelduda kõrgematesse järkudesse, peame esmalt saama kindla arusaama sellest, mis on "järk" (kind). Tüüb-iteoorias on järk "tüübi tüüp". See kirjeldab tüübikonstruktori kuju ehk aarsust. See võib kõlada abstraktselt, seega seostame selle tuttavate TypeScripti kontseptsioonidega.
Järk *
: Päristüübid
Mõelge lihtsatele, konkreetsetele tüüpidele, mida te iga päev kasutate:
string
number
boolean
{ name: string; age: number }
Need on "täielikult moodustatud" tüübid. Saate luua nende tüüpidega muutuja otse. Järgu notatsioonis nimetatakse neid päristüüpideks ja nende järk on *
(hääldatakse "täht" või "tüüp"). Nad ei vaja täielikuks olemiseks muid tüübiparameetreid.
Järk * -> *
: Geneerilised Tüübikonstruktorid
Nüüd vaatleme TypeScripti geneerikuid. Geneeriline tüüp nagu Array
ei ole iseseisvalt päristüüp. Te ei saa deklareerida muutujat let x: Array
. See on mall, kavand või tüübikonstruktor. See vajab päristüübiks saamiseks tüübiparameetrit.
Array
võtab ühe tüübi (nagustring
) ja toodab päristüübi (Array
).Promise
võtab ühe tüübi (nagunumber
) ja toodab päristüübi (Promise
).type Box
võtab ühe tüübi (nagu= { value: T } boolean
) ja toodab päristüübi (Box
).
Nende tüübikonstruktorite järk on * -> *
. See notatsioon tähendab, et nad on funktsioonid tüübitasemel: nad võtavad *
järgu tüübi ja tagastavad uue *
järgu tüübi.
Kõrgemad Järgud: (* -> *) -> *
ja Edasi
Kõrgema järgu tüüp on seega tüübikonstruktor, mis on geneeriline teise tüübikonstruktori suhtes. See opereerib tüüpidega, mille järk on kõrgem kui *
. Näiteks tüübikonstruktoril, mis võtab parameetrina midagi sellist nagu Array
(* -> *
järgu tüüp), oleks järk nagu (* -> *) -> *
.
See on koht, kus TypeScripti natiivsed võimekused põrkavad vastu seina. Vaatame, miks.
Standardsete TypeScripti Geneerikute Piirangud
Kujutage ette, et tahame kirjutada geneerilist map
funktsiooni. Me teame, kuidas seda kirjutada konkreetse tüübi jaoks nagu Array
:
function mapArray<A, B>(arr: A[], f: (a: A) => B): B[] {
return arr.map(f);
}
Me teame ka, kuidas seda kirjutada meie kohandatud Box
tüübi jaoks:
type Box<A> = { value: A };
function mapBox<A, B>(box: Box<A>, f: (a: A) => B): Box<B> {
return { value: f(box.value) };
}
Pange tähele struktuurilist sarnasust. Loogika on identne: võta konteiner A
tüüpi väärtusega, rakenda funktsioon A
-st B
-sse ja tagasta uus sama kujuga konteiner B
tüüpi väärtusega.
Loomulik järgmine samm on abstraheerida konteiner ise. Me tahame ühte map
funktsiooni, mis töötab iga konteineri puhul, mis seda operatsiooni toetab. Meie esimene katse võiks välja näha selline:
// SEE EI OLE KEHTIV TYPESCRIPT
function map<F, A, B>(container: F<A>, f: (a: A) => B): F<B> {
// ... kuidas seda implementeerida?
}
See süntaks ebaõnnestub kohe. TypeScript tõlgendab F
tavalise tüübimuutujana (järgu *
), mitte tüübikonstruktorina (järgu * -> *
). Süntaks F
on ebaseaduslik, sest tüübiparameetrit ei saa rakendada teisele tüübile nagu geneerikut. See on põhiprobleem, mida HKT emuleerimine püüab lahendada. Meil on vaja viisi, kuidas öelda TypeScriptile, et F
on kohatäide millegi jaoks nagu Array
või Box
, mitte string
või number
.
Kõrgema Järgu Tüüpide Emuleerimine TypeScriptis
Kuna TypeScriptil puudub HKT-de jaoks natiivne süntaks, on kogukond välja töötanud mitmeid kodeerimisstrateegiaid. Kõige levinum ja lahingus testitud lähenemine hõlmab liideste, tüübiotsingute ja moodulite laiendamise kombinatsiooni. See on tehnika, mida kuulsalt kasutab teek fp-ts
.
URI ja Tüübiotsingu Meetod
See meetod jaotub kolmeks põhikomponendiks:
Kind
tüüp: Geneeriline kandjaliides, et esindada HKT struktuuri.- URI-d: Unikaalsed stringiliteraalid iga tüübikonstruktori tuvastamiseks.
- URI-Tüübile Vastendamine: Liides, mis ühendab stringi URI-d nende tegelike tüübikonstruktorite definitsioonidega.
Ehitame selle samm-sammult üles.
1. Samm: `Kind` Liides
Esmalt defineerime baasliidese, millele kõik meie emuleeritud HKT-d vastavad. See liides toimib lepinguna.
export interface HKT<URI, A> {
readonly _URI: URI;
readonly _A: A;
}
Vaatame seda lähemalt:
_URI
: See omadus hoiab unikaalset stringiliteraali tüüpi (nt'Array'
,'Option'
). See on meie tüübikonstruktori unikaalne identifikaator (F
meie kujuteldavasF
-s). Kasutame eesliitena allkriipsu, et anda märku, et see on mõeldud ainult tüübitaseme kasutuseks ja ei eksisteeri käivitusajal._A
: See on "fantoomtüüp". See hoiab meie konteineri tüübiparameetrit (A
F
-s). See ei vasta käivitusaja väärtusele, kuid on tüübikontrollijale sisemise tüübi jälgimiseks ülioluline.
Mõnikord näete seda kirjutatuna kui Kind
. Nimetus pole kriitiline, kuid struktuur on.
2. Samm: URI-Tüübile Vastendamine
Järgmisena vajame keskset registrit, et öelda TypeScriptile, millisele konkreetsele tüübile antud URI vastab. Me saavutame selle liidesega, mida saame laiendada moodulite laiendamise abil.
export interface URItoKind<A> {
// Seda täidavad erinevad moodulid
}
See liides on tahtlikult tühjaks jäetud. See toimib konksuna. Iga moodul, mis soovib defineerida kõrgema järgu tüübi, lisab sinna kirje.
3. Samm: `Kind` Abitüübi Määratlemine
Nüüd loome abitüübi, mis suudab lahendada URI ja tüübiparameetri tagasi konkreetseks tüübiks.
export type Kind<URI extends keyof URItoKind<any>, A> = URItoKind<A>[URI];
See Kind
tüüp teeb maagiat. See võtab URI
ja tüübi A
. Seejärel otsib see URI
meie URItoKind
vastendusest, et leida konkreetne tüüp. Näiteks Kind<'Array', string>
peaks lahenduma kui Array
. Vaatame, kuidas me selle saavutame.
4. Samm: Tüübi Registreerimine (nt `Array`)
Et meie süsteem teaks sisseehitatud Array
tüübist, peame selle registreerima. Teeme seda moodulite laiendamise abil.
// Failis nagu `Array.ts`
// Esmalt deklareerime unikaalse URI Array tüübikonstruktori jaoks
export const URI = 'Array';
declare module './hkt' { // Eeldab, et meie HKT definitsioonid on `hkt.ts` failis
interface URItoKind<A> {
readonly [URI]: Array<A>;
}
}
Vaatame, mis just juhtus:
- Me deklareerisime unikaalse stringikonstandi
URI = 'Array'
. Konstandi kasutamine tagab, et meil ei teki trükivigu. - Me kasutasime
declare module
, et uuesti avada./hkt
moodul ja laiendadaURItoKind
liidest. - Me lisasime sellele uue omaduse: `readonly [URI]: Array`. See tähendab sõna-sõnalt: "Kui võti on string 'Array', on tulemuseks olev tüüp
Array
."
Nüüd töötab meie Kind
tüüp Array
jaoks! Tüüp Kind<'Array', number>
lahendatakse TypeScripti poolt kui URItoKind
, mis tänu meie mooduli laiendusele on Array
. Oleme edukalt kodeerinud Array
kui HKT.
Kõike Kokku Pannes: Geneeriline `map` Funktsioon
Meie HKT kodeeringuga saame lõpuks kirjutada abstraktse map
funktsiooni, millest unistasime. Funktsioon ise ei ole geneeriline; selle asemel defineerime geneerilise liidese nimega Functor
, mis kirjeldab iga tüübikonstruktorit, mille üle saab vastendada (map over).
// Failis `Functor.ts`
import { HKT, Kind, URItoKind } from './hkt';
export interface Functor<F extends keyof URItoKind<any>> {
readonly URI: F;
readonly map: <A, B>(fa: Kind<F, A>, f: (a: A) => B) => Kind<F, B>;
}
See Functor
liides on ise geneeriline. See võtab ühe tüübiparameetri, F
, mis on piiratud olema üks meie registreeritud URI-dest. Sellel on kaks liiget:
URI
: Funktori URI (nt'Array'
).map
: Geneeriline meetod. Pange tähele selle signatuuri: see võtabKind
ja funktsiooni ning tagastabKind
. See on meie abstraktnemap
!
Nüüd saame pakkuda selle liidese konkreetse instantsi Array
jaoks.
// Jällegi failis `Array.ts`
import { Functor } from './Functor';
// ... eelnev Array HKT seadistus
export const array: Functor<typeof URI> = {
URI: URI,
map: <A, B>(fa: Array<A>, f: (a: A) => B): Array<B> => fa.map(f)
};
Siin loome objekti array
, mis implementeerib Functor<'Array'>
. map
implementatsioon on lihtsalt ümbris natiivse Array.prototype.map
meetodi ümber.
Lõpuks saame kirjutada funktsiooni, mis kasutab seda abstraktsiooni:
function doSomethingWithFunctor<F extends keyof URItoKind<any>>(
functor: Functor<F>
) {
return <A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B> => {
return functor.map(fa, f);
};
}
// Kasutamine:
const numbers = [1, 2, 3];
const double = (n: number) => n * 2;
// Me anname massiivi instantsi, et saada spetsialiseeritud funktsioon
const mapForArray = doSomethingWithFunctor(array);
const doubledNumbers = mapForArray(numbers, double); // [2, 4, 6]
console.log(doubledNumbers); // Tüüp on korrektselt tuletatud kui number[]
See töötab! Oleme loonud funktsiooni doSomethingWithFunctor
, mis on geneeriline konteineri tüübi F
suhtes. See ei tea, kas see töötab Array
, Promise
või Option
-iga. See teab ainult, et sellel on selle konteineri jaoks Functor
-i instants, mis tagab map
meetodi olemasolu õige signatuuriga.
Praktilised Rakendused: Funktsionaalsete Abstraktsioonide Loomine
Functor
on alles algus. HKT-de peamine motivatsioon on ehitada rikkalik hierarhia tüübiklassidest (liidestest), mis hõlmavad levinud arvutuslikke mustreid. Vaatame veel kahte olulist: aplikatiivsed funktorid ja monaadid.
Aplikatiivsed Funktorid: Funktsioonide Rakendamine Kontekstis
Funktor võimaldab teil rakendada tavalist funktsiooni väärtusele konteksti sees (nt map(valueInContext, normalFunction)
). Aplikatiivne Funktor (või lihtsalt Aplikatiiv) viib selle sammu edasi: see võimaldab teil rakendada funktsiooni, mis on samuti konteksti sees, väärtusele kontekstis.
Aplikatiivi tüübiklass laiendab Funktorit ja lisab kaks uut meetodit:
of
(tuntud ka kui `pure`): Võtab tavalise väärtuse ja tõstab selle konteksti.Array
jaoks oleksof(x)
[x]
.Promise
jaoks oleksof(x)
Promise.resolve(x)
.ap
: Võtab konteineri, mis hoiab funktsiooni `(a: A) => B`, ja konteineri, mis hoiab väärtust `A`, ning tagastab konteineri, mis hoiab väärtust `B`.
import { Functor } from './Functor';
import { Kind, URItoKind } from './hkt';
export interface Applicative<F extends keyof URItoKind<any>> extends Functor<F> {
readonly of: <A>(a: A) => Kind<F, A>;
readonly ap: <A, B>(fab: Kind<F, (a: A) => B>, fa: Kind<F, A>) => Kind<F, B>;
}
Millal on see kasulik? Kujutage ette, et teil on kaks väärtust kontekstis ja te soovite neid kombineerida kaheargumendilise funktsiooniga. Näiteks on teil kaks vormisisendit, mis tagastavad Option
(kus Option
on tüüp, mis võib olla Some
või None
).
// Eeldame, et meil on Option tüüp ja selle Aplikatiivi instants
const name: Option<string> = some('Alice');
const age: Option<number> = some(30);
const createUser = (name: string) => (age: number) => ({ name, age });
// Kuidas me rakendame createUser nimele ja vanusele?
// 1. Tõsta curried funktsioon Option konteksti
const curriedUserInOption = option.of(createUser);
// curriedUserInOption on tüüpi Option<(name: string) => (age: number) => User>
// 2. `map` ei tööta otse. Me vajame `ap`-i!
const userBuilderInOption = option.ap(option.map(curriedUserInOption, f => f), name);
// See on kohmakas. Parem viis:
const userBuilderInOption2 = option.map(name, createUser);
// userBuilderInOption2 on tüüpi Option<(age: number) => User>
// 3. Rakenda funktsioon-kontekstis vanusele-kontekstis
const userInOption = option.ap(userBuilderInOption2, age);
// userInOption on Some({ name: 'Alice', age: 30 })
See muster on uskumatult võimas näiteks vormide valideerimisel, kus mitu sõltumatut valideerimisfunktsiooni tagastavad tulemuse kontekstis (nagu Either
) ja te soovite kõik tulemused kombineerida. Aplikatiivid võimaldavad teil koguda vigu kõigist valideerimistest, samas kui järgmine abstraktsioon, Monaadid, katkestaks esimese vea korral.
Monaadid: Operatsioonide Järjestamine Kontekstis
Monaad on võib-olla kõige kuulsam ja sageli valesti mõistetud funktsionaalne abstraktsioon. Monaadi kasutatakse operatsioonide järjestamiseks, kus iga samm sõltub eelmise tulemusest ja iga samm tagastab väärtuse, mis on pakitud samasse konteksti.
Monaadi tüübiklass laiendab Aplikatiivi ja lisab ühe olulise meetodi: chain
(tuntud ka kui `flatMap` või `bind`).
import { Applicative } from './Applicative';
import { Kind, URItoKind } from './hkt';
export interface Monad<M extends keyof URItoKind<any>> extends Applicative<M> {
readonly chain: <A, B>(fa: Kind<M, A>, f: (a: A) => Kind<M, B>) => Kind<M, B>;
}
Põhiline erinevus map
ja chain
vahel on funktsioon, mida nad aktsepteerivad:
map
võtab funktsiooni(a: A) => B
. See rakendab "tavalist" funktsiooni.chain
võtab funktsiooni(a: A) => Kind
. See rakendab funktsiooni, mis ise tagastab väärtuse monaadilises kontekstis.
chain
on see, mis takistab teil sattumast pesastatud kontekstidesse nagu Promise
või Option
. See "lamestab" automaatselt tulemuse.
Klassikaline Näide: Lubadused (Promises)
Tõenäoliselt olete Monaade kasutanud seda ise teadmata. Promise.prototype.then
toimib monaadilise chain
-ina (kui tagasikutse tagastab teise Promise
-i).
interface User { id: number; name: string; }
interface Post { userId: number; content: string; }
function getUser(id: number): Promise<User> {
return Promise.resolve({ id, name: 'Bob' });
}
function getLatestPost(user: User): Promise<Post> {
return Promise.resolve({ userId: user.id, content: 'Hello HKTs!' });
}
// Ilma `chain` (`then`) saaksite pesastatud Promise'i:
const nestedPromise: Promise<Promise<Post>> = getUser(1).then(user => {
// See `then` toimib siin nagu `map`
return getLatestPost(user); // tagastab Promise'i, luues Promise<Promise<...>>
});
// Monaadilise `chain`-iga (`then`, kui see lamestab), on struktuur puhas:
const postPromise: Promise<Post> = getUser(1).then(user => {
// `then` näeb, et me tagastasime Promise'i ja lamestab selle automaatselt.
return getLatestPost(user);
});
HKT-põhise Monaadi liidese kasutamine võimaldab teil kirjutada funktsioone, mis on geneerilised mis tahes järjestikuse, kontekstiteadliku arvutuse suhtes, olgu need siis asünkroonsed operatsioonid (Promise
), operatsioonid, mis võivad ebaõnnestuda (Either
, Option
), või jagatud olekuga arvutused (State
).
HKT-de Tulevik TypeScriptis
Emuleerimistehnikad, mida oleme arutanud, on võimsad, kuid neil on oma kompromissid. Need toovad kaasa märkimisväärse hulga korduvkoodi (boilerplate) ja järsu õppimiskõvera. TypeScripti kompilaatori veateated võivad olla krüptilised, kui kodeerimisega midagi valesti läheb.
Aga kuidas on lood natiivse toega? Kõrgema Järgu Tüüpide (või mõne mehhanismi samade eesmärkide saavutamiseks) taotlus on üks pikimaid ja enim arutatud teemasid TypeScripti GitHubi repositooriumis. TypeScripti meeskond on nõudlusest teadlik, kuid HKT-de implementeerimine esitab märkimisväärseid väljakutseid:
- Süntaktiline Keerukus: Puhta ja intuitiivse süntaksi leidmine, mis sobib hästi olemasoleva tüübisüsteemiga, on raske. On arutatud ettepanekuid nagu
type F
võiF :: * -> *
, kuid igal neist on oma plussid ja miinused. - Tuletamise Väljakutsed: Tüübi tuletamine, üks TypeScripti suurimaid tugevusi, muutub HKT-dega eksponentsiaalselt keerukamaks. Usaldusväärse ja jõudsa tuletamise tagamine on suur takistus.
- Vastavus JavaScriptiga: TypeScript püüab olla kooskõlas JavaScripti käivitusaja tegelikkusega. HKT-d on puhtalt kompileerimisaegne, tüübitaseme konstrukt, mis võib luua kontseptuaalse lõhe tüübisüsteemi ja aluseks oleva käivitusaja vahel.
Kuigi natiivne tugi ei pruugi olla kohe silmapiiril, tõestavad pidev arutelu ja teekide nagu fp-ts
, Effect
ja ts-toolbelt
edu, et kontseptsioonid on väärtuslikud ja rakendatavad TypeScripti kontekstis. Need teegid pakuvad robustseid, eelnevalt ehitatud HKT kodeeringuid ja rikkalikku funktsionaalsete abstraktsioonide ökosüsteemi, säästes teid korduvkoodi ise kirjutamisest.
Kokkuvõte: Uus Abstraktsioonitase
Kõrgema Järgu Tüübid kujutavad endast märkimisväärset hüpet tüübitaseme abstraktsioonis. Need võimaldavad meil liikuda kaugemale sellest, et olla geneeriline oma andmestruktuuride väärtuste suhtes, ja olla geneeriline struktuuri enda suhtes. Abstrakteerides konteinereid nagu Array
, Promise
, Option
ja Either
, saame kirjutada universaalseid funktsioone ja liideseid – nagu Funktor, Aplikatiiv ja Monaad – mis hõlmavad fundamentaalseid arvutuslikke mustreid.
Kuigi TypeScripti natiivse toe puudumine sunnib meid tuginema keerukatele kodeeringutele, võivad eelised olla tohutud teekide autoritele ja rakenduste arendajatele, kes töötavad suurte ja keerukate süsteemidega. HKT-de mõistmine võimaldab teil:
- Kirjutada Rohkem Taaskasutatavat Koodi: Määratleda loogikat, mis töötab iga andmestruktuuri jaoks, mis vastab konkreetsele liidesele (nt
Functor
). - Parandada Tüübiohutust: Kehtestada lepinguid selle kohta, kuidas andmestruktuurid peaksid tüübitasemel käituma, ennetades terveid vigade klasse.
- Omaks Võtta Funktsionaalseid Mustreid: Kasutada võimsaid, end tõestanud mustreid funktsionaalse programmeerimise maailmast, et hallata kõrvalmõjusid, käsitleda vigu ja kirjutada deklaratiivset, komponeeritavat koodi.
Teekond HKT-de maailma on väljakutsuv, kuid see on tasuv, mis süvendab teie arusaamist TypeScripti tüübisüsteemist ja avab uusi võimalusi puhta, robustse ja elegantse koodi kirjutamiseks. Kui soovite oma TypeScripti oskusi järgmisele tasemele viia, on teekide nagu fp-ts
uurimine ja oma lihtsate HKT-põhiste abstraktsioonide loomine suurepärane koht alustamiseks.