Ištirkite šiuolaikinių tipų sistemų vidinius veikimo mechanizmus. Sužinokite, kaip kontrolės srauto analizė (CFA) leidžia galingus tipo susiaurėjimo metodus saugesniam ir patikimesniam kodui.
Kaip kompiliatoriai tampa protingi: gilus žvilgsnis į tipo susiaurėjimą ir kontrolės srauto analizę
Būdami kūrėjai, mes nuolat bendraujame su tyliu mūsų įrankių intelektu. Rašome kodą, o mūsų IDE akimirksniu žino objektui prieinamus metodus. Mes perfaktorizuojame kintamąjį, o tipo tikrintuvas įspėja mus apie galimą vykdymo laiko klaidą dar prieš išsaugant failą. Tai nėra magija; tai yra sudėtingos statinės analizės rezultatas, o viena iš galingiausių ir vartotojui patogiausių funkcijų yra tipo susiaurėjimas.
Ar kada nors dirbote su kintamuoju, kuris galėtų būti string arba number? Tikriausiai parašėte if sakinį, kad patikrintumėte jo tipą prieš atlikdami operaciją. Tame bloke kalba „žinojo“, kad kintamasis yra string, atrakindamas eilutėms būdingus metodus ir neleisdamas jums, pavyzdžiui, bandyti iškviesti .toUpperCase() iš skaičiaus. Tas protingas tipo patikslinimas konkrečiame kodo kelyje yra tipo susiaurėjimas.
Bet kaip kompiliatorius ar tipo tikrintuvas tai pasiekia? Pagrindinis mechanizmas yra galinga technika iš kompiliatorių teorijos, vadinama Kontrolės srauto analize (CFA). Šis straipsnis atskleis šį procesą. Mes išnagrinėsime, kas yra tipo susiaurėjimas, kaip veikia kontrolės srauto analizė ir apžvelgsime konceptualų įgyvendinimą. Šis gilus žvilgsnis skirtas smalsiam kūrėjui, trokštančiam kompiliatoriaus inžinieriui arba kiekvienam, norinčiam suprasti sudėtingą logiką, dėl kurios šiuolaikinės programavimo kalbos yra tokios saugios ir produktyvios.
Kas yra tipo susiaurėjimas? Praktinis įvadas
Iš esmės, tipo susiaurėjimas (taip pat žinomas kaip tipo patikslinimas arba srauto tipų nustatymas) yra procesas, kurio metu statinio tipo tikrintuvas konkrečiam kodo regionui nustato konkretesnį kintamojo tipą nei jo deklaruotas tipas. Jis paima platų tipą, pvz., sąjungą, ir „susiaurina“ jį pagal loginius patikrinimus ir priskyrimus.
Pažvelkime į keletą įprastų pavyzdžių, naudodami TypeScript dėl jo aiškios sintaksės, nors principai taikomi daugeliui šiuolaikinių kalbų, tokių kaip Python (su Mypy), Kotlin ir kt.
Įprastos susiaurėjimo technikos
-
typeofapsaugos: Tai yra klasikinis pavyzdys. Mes tikriname primityvų kintamojo tipą.Pavyzdys:
function processInput(input: string | number) {
if (typeof input === 'string') {
// Inside this block, 'input' is known to be a string.
console.log(input.toUpperCase()); // This is safe!
} else {
// Inside this block, 'input' is known to be a number.
console.log(input.toFixed(2)); // This is also safe!
}
} -
instanceofapsaugos: Naudojamos objektų tipams susiaurinti pagal jų konstruktoriaus funkciją arba klasę.Pavyzdys:
class User { constructor(public name: string) {} }
class Guest { constructor() {} }
function greet(person: User | Guest) {
if (person instanceof User) {
// 'person' is narrowed to type User.
console.log(`Hello, ${person.name}!`);
} else {
// 'person' is narrowed to type Guest.
console.log('Hello, guest!');
}
} -
Teisingumo patikrinimai: Dažnas modelis, skirtas filtruoti
null,undefined,0,falsearba tuščias eilutes.Pavyzdys:
function printName(name: string | null | undefined) {
if (name) {
// 'name' is narrowed from 'string | null | undefined' to just 'string'.
console.log(name.length);
}
} -
Lygybės ir savybių apsaugos: Konkrečių literalų verčių tikrinimas arba savybės egzistavimas taip pat gali susiaurinti tipus, ypač su diskriminuotomis sąjungomis.
Pavyzdys (Diskriminuota sąjunga):
interface Circle { kind: 'circle'; radius: number; }
interface Square { kind: 'square'; sideLength: number; }
type Shape = Circle | Square;
function getArea(shape: Shape) {
if (shape.kind === 'circle') {
// 'shape' is narrowed to Circle.
return Math.PI * shape.radius ** 2;
} else {
// 'shape' is narrowed to Square.
return shape.sideLength ** 2;
}
}
Nauda yra didžiulė. Tai užtikrina saugumą kompiliavimo metu, užkertant kelią didelei daliai vykdymo laiko klaidų. Tai pagerina kūrėjo patirtį su geresniu automatinio užbaigimo įrankiu ir daro kodą labiau savaime dokumentuojamą. Klausimas yra, kaip tipo tikrintuvas sukuria šį kontekstinį supratimą?
Variklis už magijos: kontrolės srauto analizės (CFA) supratimas
Kontrolės srauto analizė yra statinio analizės technika, leidžianti kompiliatoriui arba tipo tikrintuvui suprasti galimus vykdymo kelius, kuriais gali eiti programa. Ji nevykdo kodo; ji analizuoja jo struktūrą. Pagrindinė duomenų struktūra, naudojama šiam tikslui, yra Kontrolės srauto grafas (CFG).
Kas yra kontrolės srauto grafas (CFG)?
CFG yra kryptinis grafas, vaizduojantis visus galimus kelius, kuriais galima pereiti per programą jos vykdymo metu. Jį sudaro:
- Mazgai (arba pagrindiniai blokai): Nuoseklių sakinių seka be šakų į vidų ar išorę, išskyrus pradžioje ir pabaigoje. Vykdymas visada prasideda nuo pirmo bloko sakinio ir tęsiasi iki paskutiniojo be sustojimo ar šakojimosi.
- Kraštinės: Jos vaizduoja kontrolės srautą arba „šuolius“ tarp pagrindinių blokų. Pavyzdžiui,
ifsakinys sukuria mazgą su dviem išeinančiomis kraštinėmis: viena – „tiesos“ keliui, kita – „melagingam“ keliui.
Vizualizuokime CFG paprastam if-else sakiniui:
let x: string | number = ...;
if (typeof x === 'string') { // Block A (Condition)
console.log(x.length); // Block B (True branch)
} else {
console.log(x + 1); // Block C (False branch)
}
console.log('Done'); // Block D (Merge point)
Konceptualus CFG atrodytų maždaug taip:
[ Entry ] --> [ Block A: typeof x === 'string' ] --> (true edge) --> [ Block B ] --> [ Block D ]
\-> (false edge) --> [ Block C ] --/
CFA apima „vaikščiojimą“ šiuo grafu ir informacijos sekimą kiekviename mazge. Tipo susiaurėjimui informacija, kurią sekame, yra galimų tipų rinkinys kiekvienam kintamajam. Analizuodami sąlygas ant kraštinių, galime atnaujinti šią tipo informaciją, kai judame iš bloko į bloką.
Kontrolės srauto analizės įgyvendinimas tipo susiaurėjimui: konceptualus apžvalga
Išskaidykime tipo tikrintuvo, kuris naudoja CFA susiaurėjimui, kūrimo procesą. Nors realaus pasaulio įgyvendinimas kalboje, tokioje kaip Rust ar C++, yra neįtikėtinai sudėtingas, pagrindinės sąvokos yra suprantamos.
1 žingsnis: kontrolės srauto grafo (CFG) kūrimas
Pirmasis žingsnis bet kuriam kompiliatoriui yra šaltinio kodo išanalizavimas į Abstrakčią sintaksės medį (AST). AST atspindi kodo sintaksinę struktūrą. CFG tada sukuriamas iš šio AST.
Algoritmas CFG sukurti paprastai apima:
- Pagrindinių blokų lyderių nustatymas: Sakinys yra lyderis (naujo pagrindinio bloko pradžia), jei jis yra:
- Pirmasis sakinys programoje.
- Šakos taikinys (pvz., kodas
ifarbaelsebloke, ciklo pradžia). - Sakinys, kuris iškart seka šakos arba grąžinimo sakinį.
- Blokų konstravimas: Kiekvienam lyderiui jo pagrindinį bloką sudaro pats lyderis ir visi vėlesni sakiniai iki, bet neįskaitant, kito lyderio.
- Kraštinių pridėjimas: Kraštinės brėžiamos tarp blokų, kad atspindėtų srautą. Sąlyginis sakinys, pvz.,
if (condition), sukuria kraštinę iš sąlygos bloko į „tiesos“ bloką ir kitą į „melagingą“ bloką (arba bloką, kuris iškart seka, jei nėraelse).
2 žingsnis: Būsenos erdvė – tipo informacijos sekimas
Kai analizatorius pereina CFG, jis turi išlaikyti „būseną“ kiekviename taške. Tipo susiaurėjimui ši būsena iš esmės yra žemėlapis arba žodynas, kuris susieja kiekvieną galiojantį kintamąjį su jo dabartiniu, potencialiai susiaurintu, tipu.
// Konceptuali būsena tam tikru kodo tašku
interface TypeState {
[variableName: string]: Type;
}
Analizė prasideda nuo funkcijos arba programos įėjimo taško su pradine būsena, kurioje kiekvienas kintamasis turi savo deklaruotą tipą. Mūsų ankstesniam pavyzdžiui, pradinė būsena būtų: { x: String | Number }. Tada ši būsena perduodama per grafą.
3 žingsnis: Sąlyginių apsaugų analizė (Pagrindinė logika)
Čia vyksta susiaurėjimas. Kai analizatorius susiduria su mazgu, kuris vaizduoja sąlyginę šaką (if, while arba switch sąlyga), jis nagrinėja pačią sąlygą. Remiantis sąlyga, jis sukuria dvi skirtingas išvesties būsenas: vieną keliui, kuriame sąlyga yra teisinga, ir vieną keliui, kuriame ji yra klaidinga.
Išanalizuokime apsaugą typeof x === 'string':
-
„Teisinga“ šaka: Analizatorius atpažįsta šį modelį. Jis žino, kad jei ši išraiška yra teisinga,
xtipas turi būtistring. Taigi, jis sukuria naują būseną „teisingam“ keliui atnaujindamas savo žemėlapį:Įvesties būsena:
{ x: String | Number }Išvesties būsena teisingam keliui:
Ši nauja, tikslesnė būsena perduodama į kitą bloką teisingoje šakoje (Block B). Bloko B viduje bet kokios operacijos su{ x: String }xbus tikrinamos pagal tipąString. -
„Klaidinga“ šaka: Tai taip pat svarbu. Jei
typeof x === 'string'yra klaidinga, ką tai mums sako apiex? Analizatorius gali atimti „teisingą“ tipą iš originalaus tipo.Įvesties būsena:
{ x: String | Number }Tipas, kurį reikia pašalinti:
StringIšvesties būsena klaidingam keliui:
Ši patikslinta būsena perduodama žemyn į „klaidingą“ kelią į Block C. Bloko C viduje{ x: Number }(kadangi(String | Number) - String = Number)xteisingai traktuojamas kaipNumber.
Analizatorius turi turėti įdiegtą logiką, kad suprastų įvairius modelius:
x instanceof C: Teisingame kelyjextipas tampaC. Klaidingame kelyje jis išlieka savo originaliu tipu.x != null: Teisingame kelyjeNullirUndefinedpašalinami išxtipo.shape.kind === 'circle': Jeishapeyra diskriminuota sąjunga, jos tipas susiaurinamas iki nario, kuriamekindyra literalus tipas'circle'.
4 žingsnis: Kontrolės srauto kelių sujungimas
Kas atsitinka, kai šakos vėl susijungia, pvz., po mūsų if-else sakinio Block D? Analizatorius turi dvi skirtingas būsenas, pasiekiančias šį sujungimo tašką:
- Iš Block B (teisingas kelias):
{ x: String } - Iš Block C (klaidingas kelias):
{ x: Number }
Kodas Block D turi būti galiojantis, nepriklausomai nuo to, kuris kelias buvo pasirinktas. Siekiant tai užtikrinti, analizatorius turi sujungti šias būsenas. Kiekvienam kintamajam jis apskaičiuoja naują tipą, kuris apima visas galimybes. Tai paprastai daroma paimant tipų iš visų įeinančių kelių sąjungą.
Sujungta būsena Block D: { x: Union(String, Number) }, kuri supaprastinama iki { x: String | Number }.
x tipas grįžta prie savo originalaus, platesnio tipo, nes šiuo programos tašku jis galėjo atsirasti iš bet kurios šakos. Štai kodėl negalite naudoti x.toUpperCase() po if-else bloko – tipo saugos garantija dingo.
5 žingsnis: Ciklų ir priskyrimų tvarkymas
-
Priskyrimai: Priskyrimas kintamajam yra kritinis įvykis CFA. Jei analizatorius mato
x = 10;, jis turi atmesti bet kokią ankstesnę susiaurėjimo informaciją, kurią turėjo apiex.xtipas dabar yra galutinai priskirtos vertės tipas (šiuo atvejuNumber). Šis anuliavimas yra labai svarbus teisingumui. Dažnas kūrėjo sumaišties šaltinis yra tada, kai susiaurintas kintamasis iš naujo priskiriamas uždarymo viduje, o tai anuliuoja susiaurėjimą už jo ribų. -
Ciklai: Ciklai sukuria ciklus CFG. Ciklo analizė yra sudėtingesnė. Analizatorius turi apdoroti ciklo kūną, tada pažiūrėti, kaip būsena ciklo pabaigoje veikia būseną pradžioje. Gali tekti iš naujo analizuoti ciklo kūną kelis kartus, kiekvieną kartą patikslinant tipus, kol tipo informacija stabilizuojasi – procesas, žinomas kaip fiksuoto taško pasiekimas. Pavyzdžiui,
for...ofcikle kintamojo tipas gali būti susiaurintas ciklo viduje, bet šis susiaurėjimas atstatomas su kiekviena iteracija.
Už pagrindų ribų: Pažangios CFA sąvokos ir iššūkiai
Paprastas modelis, pateiktas aukščiau, apima pagrindus, bet realaus pasaulio scenarijai įveda didelį sudėtingumą.
Tipo predikatai ir vartotojo apibrėžtos tipo apsaugos
Šiuolaikinės kalbos, tokios kaip TypeScript, leidžia kūrėjams pateikti užuominų CFA sistemai. Vartotojo apibrėžta tipo apsauga yra funkcija, kurios grąžinimo tipas yra specialus tipo predikatas.
function isUser(obj: any): obj is User {
return obj && typeof obj.name === 'string';
}
Grąžinimo tipas obj is User sako tipo tikrintuvui: „Jei ši funkcija grąžina true, galite daryti prielaidą, kad argumentas obj turi tipą User.“
Kai CFA susiduria su if (isUser(someVar)) { ... }, jam nereikia suprasti funkcijos vidinės logikos. Jis pasitiki parašu. „Teisingame“ kelyje jis susiaurina someVar iki User. Tai yra išplečiamas būdas išmokyti analizatorių naujų susiaurėjimo modelių, būdingų jūsų programos sričiai.
Destruktūrizacijos ir aliasų analizė
Kas atsitinka, kai kuriate kintamųjų kopijas ar nuorodas? CFA turi būti pakankamai protingas, kad sektų šiuos santykius, o tai žinoma kaip aliasų analizė.
const { kind, radius } = shape; // shape is Circle | Square
if (kind === 'circle') {
// Here, 'kind' is narrowed to 'circle'.
// But does the analyzer know 'shape' is now a Circle?
console.log(radius); // In TS, this fails! 'radius' may not exist on 'shape'.
}
Aukščiau pateiktame pavyzdyje vietinės konstantos kind susiaurėjimas automatiškai nesusiaurina originalaus shape objekto. Taip yra todėl, kad shape gali būti iš naujo priskirtas kitur. Tačiau, jei tikrinate savybę tiesiogiai, ji veikia:
if (shape.kind === 'circle') {
// This works! The CFA knows 'shape' itself is being checked.
console.log(shape.radius);
}
Sudėtingam CFA reikia sekti ne tik kintamuosius, bet ir kintamųjų savybes bei suprasti, kada aliasas yra „saugus“ (pvz., jei originalus objektas yra const ir negali būti iš naujo priskirtas).
Uždarymų ir aukštesnės eilės funkcijų poveikis
Kontrolės srautas tampa netiesinis ir daug sunkiau analizuojamas, kai funkcijos perduodamos kaip argumentai arba kai uždarymai užfiksuoja kintamuosius iš savo tėvų srities. Apsvarstykite tai:
function process(value: string | null) {
if (value === null) {
return;
}
// At this point, CFA knows 'value' is a string.
setTimeout(() => {
// What is the type of 'value' here, inside the callback?
console.log(value.toUpperCase()); // Is this safe?
}, 1000);
}
Ar tai saugu? Tai priklauso. Jei kita programos dalis galėtų potencialiai modifikuoti value tarp setTimeout iškvietimo ir jo vykdymo, susiaurėjimas yra negaliojantis. Dauguma tipo tikrintuvų, įskaitant TypeScript, čia yra konservatyvūs. Jie daro prielaidą, kad užfiksuotas kintamasis kintamame uždaryme gali pasikeisti, todėl susiaurėjimas, atliktas išorinėje srityje, dažnai prarandamas atgalinio iškvietimo viduje, nebent kintamasis yra const.
Išsamumo tikrinimas su never
Viena iš galingiausių CFA taikymo sričių yra išsamumo patikrinimų įgalinimas. Tipas never vaizduoja vertę, kuri niekada neturėtų atsirasti. switch sakinyje virš diskriminuotos sąjungos, kai tvarkote kiekvieną atvejį, CFA susiaurina kintamojo tipą atimdamas tvarkomą atvejį.
function getArea(shape: Shape) { // Shape is Circle | Square
switch (shape.kind) {
case 'circle':
// Here, shape is Circle
return Math.PI * shape.radius ** 2;
case 'square':
// Here, shape is Square
return shape.sideLength ** 2;
default:
// What is the type of 'shape' here?
// It is (Circle | Square) - Circle - Square = never
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
Jei vėliau pridėsite Triangle prie Shape sąjungos, bet pamiršite pridėti case jam, default šaka bus pasiekiama. shape tipas toje šakoje bus Triangle. Bandant priskirti Triangle kintamajam, kurio tipas yra never, įvyks kompiliavimo laiko klaida, akimirksniu įspėjanti, kad jūsų switch sakinys nebėra išsamus. Tai CFA suteikia tvirtą saugos tinklą nuo neišsamios logikos.
Praktinės pasekmės kūrėjams
Kontrolės srauto analizės principų supratimas gali padaryti jus efektyvesniu programuotoju. Galite rašyti kodą, kuris yra ne tik teisingas, bet ir „gerai veikia“ su tipo tikrintuvu, o tai lemia aiškesnį kodą ir mažiau su tipais susijusių kovų.
- Teikite pirmenybę
constnuspėjamam susiaurėjimui: Kai kintamasis negali būti iš naujo priskirtas, analizatorius gali pateikti stipresnes garantijas dėl jo tipo. Naudojantconstvietojletpadeda išsaugoti susiaurėjimą sudėtingesnėse srityse, įskaitant uždarymus. - Priimkite diskriminuotas sąjungas: Duomenų struktūrų projektavimas su literalia savybe (pvz.,
kindarbatype) yra aiškiausias ir galingiausias būdas signalizuoti ketinimą CFA sistemai.switchsakiniai virš šių sąjungų yra aiškūs, efektyvūs ir leidžia patikrinti išsamumą. - Laikykite patikrinimus tiesioginius: Kaip matėme su aliasais, savybės tikrinimas tiesiogiai objekte (
obj.prop) yra patikimesnis susiaurėjimui nei savybės kopijavimas į vietinį kintamąjį ir jo tikrinimas. - Derinkite turėdami omenyje CFA: Kai susiduriate su tipo klaida, kur, jūsų manymu, tipas turėjo būti susiaurintas, pagalvokite apie kontrolės srautą. Ar kintamasis buvo iš naujo priskirtas kažkur? Ar jis naudojamas uždarymo viduje, kurio analizatorius negali visiškai suprasti? Šis mentalinis modelis yra galingas derinimo įrankis.
Išvada: Tylus tipo saugos sargas
Tipo susiaurėjimas jaučiasi intuityvus, beveik kaip magija, bet tai yra dešimtmečius trukusių kompiliatorių teorijos tyrimų produktas, atgaivintas per kontrolės srauto analizę. Sukūrę programos vykdymo kelių grafą ir kruopščiai sekdami tipo informaciją palei kiekvieną kraštinę ir kiekviename sujungimo taške, tipo tikrintuvai suteikia nepaprastą intelektą ir saugumą.
CFA yra tylus sargas, leidžiantis mums dirbti su lanksčiais tipais, tokiais kaip sąjungos ir sąsajos, ir vis tiek sugauti klaidas prieš joms pasiekiant gamybą. Tai paverčia statinį tipų nustatymą iš griežto apribojimų rinkinio į dinamišką, į kontekstą atsižvelgiantį asistentą. Kitą kartą, kai jūsų redaktorius pateiks tobulą automatinio užbaigimo įrankį if bloke arba pažymės neapdorotą atvejį switch sakinyje, žinosite, kad tai nėra magija – tai elegantiška ir galinga kontrolės srauto analizės logika.