Forskjeller mellom tidlig og sen binding i Java

I artikkelen snakket jeg om hvilken ikke-alle-kjent funksjon du kan støte på når du arbeider med innebygde funksjoner. Artikkelen ga opphav til både flere viktige kommentarer og debatter på flere sider (og til og med holiwars), som begynte med det faktum at det er bedre å ikke bruke innebygde funksjoner i det hele tatt, og ble til standardemnet C vs. C++ vs. Java vs. C# vs. PHP vs. Haskell vs. ...

I dag er det virtuelle funksjoners tur. Og for det første vil jeg umiddelbart ta forbehold om at artikkelen min (i prinsippet, som den forrige) på ingen måte hevder å være fullstendig. Og for det andre, som før, er ikke denne artikkelen for fagfolk. Det vil være nyttig for de som allerede forstår det grunnleggende i C++, men ikke har nok erfaring, eller for de som ikke liker å lese bøker.

Jeg håper alle vet hva virtuelle funksjoner er og hvordan de brukes, siden det ikke lenger er min jobb å forklare dette. Jeg er sikker på at RFry vil komme til dem før eller siden i sin serie med artikler om C++.

Hvis myten i materialet om inline-metoder ikke var helt åpenbar, så er det i denne det motsatte. La oss faktisk gå videre til "myten".

Virtuelle funksjoner og nøkkelord virtuell
Til min overraskelse møtte jeg veldig ofte og fortsetter å møte mennesker (hva kan jeg si, jeg var den samme selv) som tror at det virtuelle nøkkelordet gjør funksjonen virtuell til bare ett nivå i hierarkiet. La meg forklare hva jeg mener med et eksempel:

  1. #inkludere
  2. #inkludere
  3. bruker std::cout;
  4. bruker std::endl;
  5. struktur A
  6. virtuell ~A()()
  7. << "A::foo()" << endl; }
  8. << "A::bar()" << endl; }
  9. void baz() const ( cout<< "A::baz()" << endl; }
  10. struktur B: offentlig A
  11. virtual void foo() const ( cout<< "B::foo()" << endl; }
  12. void bar() const ( cout<< "B::bar()" << endl; }
  13. void baz() const ( cout<< "B::baz()" << endl; }
  14. struktur C: offentlig B
  15. virtual void foo() const ( cout<< "C::foo()" << endl; }
  16. virtual void bar() const ( cout<< "C::bar()" << endl; }
  17. void baz() const ( cout<< "C::baz()" << endl; }
  18. int main()
  19. cout<< "pA is B:" << endl;
  20. A * pA = ny B;
  21. pA->foo();
  22. pA->bar();
  23. pA->baz();
  24. slett pA;
  25. cout<< "\npA is C:" << endl;
  26. pA = ny C;
  27. pA->foo(); pA->bar(); pA->baz();
  28. slett pA;
  29. returner EXIT_SUCCESS;

Så vi har et enkelt klassehierarki. Hver klasse definerer 3 metoder: foo() , bar() og baz() . La oss vurdere feil logikk mennesker som er påvirket myte:
når pekeren pA peker på et objekt av type B har vi utgangen:

pA er B:
B::foo() // fordi i den overordnede klassen A er foo()-metoden merket som virtuell
B::bar() // fordi i den overordnede klassen A er bar()-metoden merket som virtuell
A::baz() // fordi i overordnet klasse A er baz()-metoden ikke merket som virtuell

når pekeren pA peker på et objekt av type C, har vi følgende utgang:
pA er C:
C::foo() // fordi i overordnet klasse B er foo()-metoden merket som virtuell
B::bar() // fordi i overordnet klasse B er ikke bar()-metoden merket som virtuell,
// men den er merket som virtuell i klasse A, pekeren vi bruker til
A::baz() // fordi i klasse A er baz()-metoden ikke merket som virtuell

Alt er klart med den ikke-virtuelle funksjonen baz(). Men det er et problem med logikken i å kalle virtuelle funksjoner. Jeg tror det sier seg selv at den faktiske produksjonen vil være:

pA er B:
B::foo()
B::bar()
A::baz()

PA er C:
C::foo()
C::bar()
A::baz()

Konklusjon: en virtuell funksjon blir virtuell til slutten av hierarkiet, og nøkkelordet virtuell er "nøkkel" bare første gang, og i påfølgende ganger har den en rent informativ funksjon for bekvemmelighet for programmerere.

For å forstå hvorfor dette skjer, må du forstå nøyaktig hvordan den virtuelle funksjonsmekanismen fungerer.

Tidlig og sen binding. Virtuell funksjonstabell
Binding er tilordningen av et funksjonskall til et anrop. I C++ har alle funksjoner som standard tidlig binding, det vil si at kompilatoren og linkeren bestemmer hvilken funksjon som skal kalles før programmet kjøres. Virtuelle funksjoner har sen binding, det vil si at når du kaller en funksjon, velges den nødvendige kroppen på stadiet av programutførelse.

Etter å ha møtt det virtuelle nøkkelordet, bemerker kompilatoren at sen binding bør brukes for denne metoden: først oppretter den en tabell med virtuelle funksjoner for klassen, og legger til et nytt medlem skjult for programmereren til klassen - en peker til denne tabellen . (Faktisk, så vidt jeg vet, foreskriver ikke språkstandarden nøyaktig hvordan den virtuelle funksjonsmekanismen skal implementeres, men den virtuelle tabellbaserte implementeringen har blitt de facto-standarden.) Tenk på denne prøvekoden:

  1. #inkludere
  2. #inkludere
  3. struct Empty();
  4. struct EmptyVirt ( virtuell ~EmptyVirt()() );
  5. struct NotEmpty (int m_i; );
  6. struktur NotEmptyVirt
  7. virtuell ~NotEmptyVirt() ()
  8. int m_i;
  9. struct NotEmptyNonVirt
  10. void foo() const()
  11. int m_i;
  12. int main()
  13. std::cout<< sizeof (Empty) << std::endl;
  14. std::cout<< sizeof (EmptyVirt) << std::endl;
  15. std::cout<< sizeof (NotEmpty) << std::endl;
  16. std::cout<< sizeof (NotEmptyVirt) << std::endl;
  17. std::cout<< sizeof (NotEmptyNonVirt) << std::endl;
  18. returner EXIT_SUCCESS;
* Denne kildekoden ble uthevet med Source Code Highlighter.

Utdataene kan variere avhengig av plattformen, men i mitt tilfelle (Win32, msvc2008) var det som følger:

Hva kan du forstå av dette eksemplet? For det første er størrelsen på en "tom" klasse alltid større enn null fordi kompilatoren med vilje setter inn et dummy-medlem i den. Som Eckel skriver, "forestill deg prosessen med å indeksere i en rekke objekter i null størrelse, og alt vil bli klart" ;) For det andre ser vi at størrelsen på den "ikke tomme" klassen NotEmptyVirt, når du legger til en virtuell funksjon til den, økt med standardstørrelsen til en peker til ugyldig; og i den "tomme" klassen EmptyVirt, dummy-medlemmet som kompilatoren tidligere la til for å gjøre klassen ikke-nullstørrelse erstattet til pekeren. Samtidig vil det å legge til en ikke-virtuell funksjon i en klasse ikke påvirke størrelsen (takk til nullbie for råd). Navnet på tabellpekeren varierer avhengig av kompilatoren. For eksempel kaller Visual Studio 2008-kompilatoren den __vfptr, og selve tabellen 'vftable' (de som ikke tror du kan se i feilsøkeren:) I litteraturen kalles en peker til en tabell med virtuelle funksjoner vanligvis VPTR , og selve tabellen heter VTABLE, så jeg vil holde meg til samme notasjon.

Hva er en virtuell funksjonstabell og hva er den for? Tabellen over virtuelle funksjoner lagrer adressene til alle virtuelle metoder i en klasse (i hovedsak er det en rekke pekere), så vel som alle virtuelle metoder for basisklassene til denne klassen.

Vi vil ha like mange tabeller med virtuelle funksjoner som det er klasser som inneholder virtuelle funksjoner - en tabell per klasse. Objektene til hver klasse inneholder bare en pekepinn på bordet, ikke selve bordet! Lærere, så vel som de som gjennomfører intervjuer, liker å stille spørsmål om dette emnet. (Eksempler på vanskelige spørsmål som kan fange nybegynnere: "hvis en klasse inneholder en tabell med virtuelle funksjoner, vil størrelsen på klasseobjektet avhenge av antall virtuelle funksjoner i den, ikke sant?"; "vi har en rekke pekere til basisklassen, som hver peker på et objekt i en av de avledede klassene - hvor mange tabeller med virtuelle funksjoner vil vi ha, osv.).

Så for hver klasse vil vi lage en tabell med virtuelle funksjoner. Hver virtuell funksjon i basisklassen tildeles en fortløpende indeks (i rekkefølgen av funksjonserklæringer), som deretter vil bli brukt til å bestemme adressen til funksjonskroppen i VTABLE-tabellen. Når du arver en basisklasse, "mottar" den avledede klassen tabellen over adresser til virtuelle funksjoner til basisklassen. Hvis en hvilken som helst virtuell metode i en avledet klasse blir overstyrt, vil adressen til kroppen til den tilsvarende metoden ganske enkelt erstattes med en ny i tabellen over virtuelle funksjoner for denne klassen. Når du legger til nye virtuelle VTABLE-metoder til en avledet klasse derivat klasse utvides, og tabellen for basisklassen forblir naturligvis den samme som den var. Derfor, gjennom en peker til basisklassen, er det umulig å virtuelt kalle metoder for en avledet klasse som ikke var i basisklassen - tross alt, baseklassen "vet ingenting" om dem (vi vil se på alt dette senere ved å bruke et eksempel).

Klassekonstruktøren må nå gjøre en ekstra operasjon: initialisere VPTR-pekeren med adressen til den tilsvarende virtuelle funksjonstabellen. Det vil si at når vi oppretter et objekt av en avledet klasse, kalles først basisklassekonstruktøren, initialiserer VPTR med adressen til "dens" virtuelle funksjonstabell, deretter kalles den avledede klassekonstruktøren, som overskriver denne verdien.

Når du kaller en funksjon gjennom adressen til basisklassen (les - gjennom en peker til basisklassen), må kompilatoren først bruke VPTR-pekeren for å få tilgang til tabellen over virtuelle funksjoner til klassen, og fra den hente adressen til kroppen til den oppringte funksjonen, og bare etter det foreta anropet.

Fra alt det ovennevnte kan vi konkludere med at den sene bindingsmekanismen krever ekstra CPU-tid (initialisering av VPTR av konstruktøren, innhenting av funksjonsadressen ved anrop) sammenlignet med tidlig binding.

Jeg tror alt blir klarere med et eksempel. Tenk på følgende hierarki:

I dette tilfellet får vi to tabeller med virtuelle funksjoner:

Utgangspunkt
0
Base::foo()
1 Base::bar()
2 Base::baz()

Og
Nedarvet
0
Base::foo()
1 Arvet::bar()
2 Base::baz()
3 Arvet::qux()

Som du kan se, i tabellen til den avledede klassen, ble adressen til den andre metoden erstattet med den tilsvarende overstyrte. Korrekturkode:

  1. #inkludere
  2. #inkludere
  3. bruker std::cout;
  4. bruker std::endl;
  5. struct Base
  6. Base() (cout<< "Base::Base()" << endl; }
  7. virtuell ~Base() ( cout<< "Base::~Base()" << endl; }
  8. virtual void foo() ( cout<< "Base::foo()" << endl; }
  9. virtual void bar() ( cout<< "Base::bar()" << endl; }
  10. virtual void baz() ( cout<< "Base::baz()" << endl; }
  11. struct Inherited: offentlig base
  12. Inherited() ( cout<< "Inherited::Inherited()" << endl; }
  13. virtuell ~Inherited() ( cout<< "Inherited::~Inherited()" << endl; }
  14. virtual void bar() ( cout<< "Inherited::bar()" << endl; }
  15. virtual void qux() ( cout<< "Inherited::qux()" << endl; }
  16. int main()
  17. Base * pBase = ny arvet;
  18. pBase->foo();
  19. pBase->bar();
  20. pBase->baz();
  21. //pBase->qux(); // Feil
  22. slett pBase;
  23. returner EXIT_SUCCESS;
* Denne kildekoden ble uthevet med Source Code Highlighter.

Hva skjer når programmet starter? Først erklærer vi en peker til et objekt av typen Base, som vi tildeler adressen til det nyopprettede objektet av typen Inherited. Dette kaller opp Base-konstruktøren, initialiserer VPTR med VTABLE-adressen til Base-klassen, og deretter Inherited-konstruktøren, som overskriver VPTR-verdien med VTABLE-adressen til Inherited-klassen. Når pBase->foo() , pBase->bar() og pBase->baz() kalles, henter kompilatoren den faktiske adressen til funksjonskroppen fra den virtuelle funksjonstabellen via VPTR-pekeren. Hvordan skjer dette? Uavhengig av den spesifikke objekttypen, vet kompilatoren at adressen til funksjonen foo() er i første omgang, bar() er i andre, og så videre. (som jeg sa, i rekkefølgen av funksjonserklæringer). For å kalle opp for eksempel baz()-funksjonen, mottar han funksjonsadressen i formen VPTR+2 - forskyvningen fra begynnelsen av tabellen over virtuelle funksjoner, lagrer denne adressen og erstatter den i anropskommandoen. Av samme grunn fører å kalle pBase->qux() til en feil: til tross for at den faktiske typen av objektet er arvet, når vi tilordner adressen til en peker til Base, oppstår en oppoverkast, og det er ingen fjerde metode i VTABLE-tabellen i Base-klassen, så VPTR+3 vil peke på "fremmed" minne (heldigvis kompilerer ikke en slik kode engang).

La oss gå tilbake til myten. Det blir åpenbart at med denne tilnærmingen til implementering av virtuelle funksjoner er det umulig å gjøre funksjonen virtuell for bare ett nivå i hierarkiet.

Det blir også klart hvorfor virtuelle funksjoner bare fungerer når de åpnes av adressen til et objekt (via pekere eller referanser). Som jeg sa, i denne linjen
Base * pBase = ny arvet;
en upcast oppstår: Inherited* kastes til Base*, men uansett lagrer pekeren bare adressen til "begynnelsen" av objektet i minnet. Hvis en upcast utføres direkte på et objekt, blir den faktisk "avskåret" til størrelsen på basisklasseobjektet. Derfor er det logisk at tidlig binding brukes til å kalle funksjoner "gjennom et objekt" - kompilatoren "vet" allerede den faktiske typen av objektet.

Egentlig er det alt. Jeg venter på kommentarene dine. Takk for din oppmerksomhet.

P.S. Denne artikkelen er merket "Hastighetsgaranti"
(Skor, hvis du leser dette, er dette for deg;)

P.P.S. Ja, jeg glemte å si... Javaister vil nå begynne å rope at i Java, som standard, er alle funksjoner virtuelle.
_________
Teksten ble utarbeidet i

"Sen binding" er et lignende informatikkbegrep som "sterk skriving", som betyr forskjellige ting for forskjellige mennesker. Jeg tror jeg kan beskrive hva jeg mener med det.

Først av alt, hva er "kobling"? Vi kan ikke forstå hva sen binding betyr med mindre vi vet hva begrepet "binding" betyr.

Per definisjon er en kompilator en enhet som tar tekst skrevet på ett språk og produserer kode på et annet språk "som betyr det samme." For eksempel utvikler jeg en kompilator som tar tekst i C# som input og produserer CIL (*). Alle viktige oppgaver utført av kompilatoren kan deles inn i tre store grupper:

  • Parser inndatateksten
  • Semantisk syntaksanalyse
  • Generer utdatatekst - i denne artikkelen er vi ikke interessert i dette stadiet

Å analysere inndatateksten vet ingenting om betydning analysert tekst; parser er først og fremst opptatt av leksikalsk programstruktur (dvs. kommentargrenser, identifikatorer, utsagn osv.), og bestemmer deretter fra denne leksikalske strukturen grammatisk programstruktur: grenser for klasser, metoder, operatorer, uttrykk, etc.

Den semantiske analysatoren tar deretter resultatene av parseren og assosierer betydningen av de forskjellige syntaktiske elementene. For eksempel når du skriver:

klasse X()
klasse B()
klasse D: B
{
offentlig statisk tomrom X() ( )
offentlig statisk tomrom Y() ( X(); )
}

så bestemmer parseren at det er tre klasser, at en av dem inneholder to metoder, den andre metoden inneholder en operator, som er et uttrykk for et metodekall. Den semantiske analysatoren bestemmer at X i uttrykket X(); refererer til D.X()-metoden i stedet for for eksempel typen X som er deklarert ovenfor. Dette er et eksempel på "binding" i ordets bredeste betydning: binding er assosiasjonen av et syntaktisk element som inneholder navnet på en metode med den logiske delen av programmet.

Når det gjelder «tidlig» eller «sen» binding, handler det alltid om å definere et navn å kalle en metode. Men fra mitt synspunkt er denne definisjonen for streng. Jeg vil bruke begrepet "binding" for å beskrive prosessen med kompilatorens semantiske analysator som bestemmer at klasse D arver fra klasse B og at navnet "B" er assosiert med navnet på klassen.

Dessuten vil jeg bruke begrepet "lenking" for å beskrive andre typer analyser. Hvis du har uttrykket 1 * 2 + 1.0 i programmet ditt, så kan jeg si at "+"-operatoren er assosiert med en innebygd operator som tar to flyttall, legger dem til og returnerer det tredje tallet. Folk tenker vanligvis ikke på å assosiere navnet "+" med en bestemt metode, men jeg tenker fortsatt på det som en "binding".

For å si det enda mindre strengt, kan jeg bruke begrepet «binding» for å finne assosiasjonen av typer med uttrykk som ikke bruker navnet på den typen direkte. Uformelt sett, i eksemplet ovenfor, er uttrykket 1 * 2 "assosiert" med typen int, selv om det åpenbart ikke navngir typen. Det syntaktiske uttrykket er strengt knyttet til dette semantiske elementet, selv om det ikke bruker det tilsvarende navnet direkte.

Så generelt sett vil jeg si at en "binding" er enhver assosiasjon av et fragment av syntakstreet med et logisk element i programmet. (**)

Så hva er forskjellen mellom "tidlig" og "sen" binding? Folk snakker ofte om disse konseptene som om de er gjensidig utelukkende valg: enten tidlig binding eller sen binding. Som vi snart skal se, er dette ikke tilfelle; noen typer innbinding er helt tidlig, noen er delvis tidlige og delvis forsinket, og noen er faktisk helt forsinket. Men før vi kommer til det, la oss se på i forhold til hva Er binding tidlig eller sent?

Vanligvis, når vi snakker om "tidlig binding" mener vi "kobling gjøres av kompilatoren og resultatet av koblingen er bakt inn i den genererte koden"; hvis koblingen mislykkes, kjører ikke programmet fordi kompilatoren ikke kan fortsette til kodegenereringsfasen. Med "sen binding" mener vi at "noe av bindingen vil bli gjort ved kjøring" og dermed vil bindingsfeil først dukke opp under kjøring. Tidlig og sen binding kalles noen ganger "statisk" og "dynamisk" binding; statisk kobling gjøres basert på "statisk" informasjon kjent for kompilatoren, og dynamisk kobling gjøres basert på "dynamisk" informasjon som kun er kjent under kjøring.

Hvilken av disse typene bondage er bedre? Det er klart at ingen av alternativene er klart bedre enn de andre; hvis ett av alternativene alltid var overlegne det andre, ville vi ikke diskutere noe akkurat nå. Fordelen med tidlig binding er at vi kan være sikre på at det ikke er noen kjøretidsfeil; Ulempen er mangelen på fleksibilitet ved sen binding. Tidlig binding forutsetter at all informasjon som trengs for å ta den riktige avgjørelsen vil være kjent før programmet kjøres; men noen ganger er ikke denne informasjonen tilgjengelig før utførelse.

Jeg har allerede sagt at binding danner et spekter fra tidlig til sent. La oss se på noen C#-eksempler for å vise hvordan vi kan gå fra tidlig binding til sen binding.

Vi startet med et eksempel på å kalle en statisk metode X. Denne analysen er definitivt tidlig. Det er ingen tvil om at når metode Y kalles, vil metode D.X bli kalt. Ingen del av denne analysen blir utsatt til kjøretid, så denne samtalen vil definitivt lykkes.

La oss nå se på følgende eksempel:

klasse B
{
offentlig ugyldig M(dobbel x) ()
offentlig ugyldig M(int x) ()
}
klasse C
{
offentlig statisk tomrom X(B b, int d) ( b.M(d); )
}

Nå har vi mindre informasjon. Vi gjør mye med tidlig binding; vi vet at variabelen b er av type B, og at metoden B.M(int) kalles. Men, i motsetning til forrige eksempel, har vi ingen kompilatorgarantier for at kallet vil lykkes, siden variabelen b kan være null. I hovedsak utsetter vi analysen av om anropsmottakeren vil være gyldig eller ikke til kjøretid. Mange anser ikke denne avgjørelsen som "binding", siden vi ikke gjør det assosierer syntaks med et programelement. La oss ringe inn i metode C litt senere, ved å endre klasse B:

klasse B
{
offentlig virtuell tomrom M(dobbel x) ()
offentlig virtuell tomrom M(int x) ()
}

Nå gjør vi noe av analysen på kompileringstidspunktet; vi vet at den virtuelle metoden B.M(int) vil bli kalt. Vi vet at et metodekall vil lykkes i den forstand at en slik metode eksisterer. Men vi vet ikke hvilken metode som kalles under kjøring! Dette kan være en overstyrt metode i etterkommeren; helt annen kode definert i en annen del av programmet kan kalles. Virtuell metodeutsendelse er en form for sen binding; avgjørelsen om hvilken metode som er assosiert med den syntaktiske konstruksjonen b.M(d) tas delvis av kompilatoren og delvis under kjøretid.

Hva med dette eksemplet?

klasse C
{
offentlig statisk tomrom X(B b, dynamisk d) ( b.M(d); )
}

Nå er bindingen nesten helt utsatt til kjøretid. I dette tilfellet genererer kompilatoren kode som forteller Dynamic Language Runtim at statisk analyse har bestemt at den statiske typen av variabel b er klasse B og at metoden som kalles kalles M, men den reelle overbelastningsoppløsningen for metodedefinisjonen er B.M. (int) eller B.M(double) (eller ingen av dem hvis d, for eksempel er av typen streng) vil bli utført under kjøring basert på denne informasjonen. (***)

klasse C
{
offentlig statisk tomrom X(dynamisk b, dynamisk d) ( b.M(d); )
}

Nå, på kompileringstidspunktet, er alt som er bestemt at en metode kalt M kalles på en eller annen type. Dette er praktisk talt den siste bindingen, men faktisk kan vi gå enda lenger:

klasse C
{
offentlig statisk tomrom X(objekt b, objekt d, streng m, BindingFlags f)
{
b.GetType().GetMethod(m, f).Invoke(b,d);
}
}

All analyse er nå utført under sen binding; vi vet ikke engang hva er navnet vi skal binde oss til metoden som kalles. Alt vi kan vite er at forfatteren av X forventer at objektet b som sendes inn har en metode hvis navn spesifiserer m, tilsvarende flaggene som sendes til f, som tar argumentene som sendes til d. I dette tilfellet kan vi ikke gjøre noe på kompileringstidspunktet. (****)

(*) Selvfølgelig er resultatet kodet i binært format og ikke i lesbart CIL-format.

(**) Du kan spørre: er "kobling" og "semantisk analyse" synonyme; Selvfølgelig er semantisk analyse ikke noe mer enn assosiasjonen av syntaktiske elementer med deres betydninger! Binding er en stor del av kompilatorens semantiske analysefase, men det er mange andre former for analyser som må gjøres etter at metodelegemer er fullstendig "bundet". For eksempel kan analysen av bestemt oppdrag på ingen måte kalles «bindende»; det er ikke en assosiasjon av syntaktiske elementer med spesifikke programelementer. Snarere kobler denne analysen leksikalsk steder med fakta om programelementer som "den lokale variabelen bla er ikke definitivt tilordnet i begynnelsen av denne blokken." Likeledes er optimering av aritmetiske uttrykk en form for semantisk analyse og er ikke eksplisitt relatert til "lenking".

(***) Kompilatoren kan fortsatt gjøre en betydelig mengde statisk analyse. La oss anta at klasse B er en forseglet klasse uten metoder kalt M. Selv med dynamiske argumenter vet vi på kompileringstidspunktet at binding til metode M vil mislykkes, og vi kan fortelle deg det på kompileringstidspunktet. Og kompilatoren gjør faktisk denne typen analyser; og hvordan er et godt tema for en annen samtale.

(****) På noen måter er dette eksemplet et godt moteksempel til min definisjon av binding; vi kobler ikke engang sammen syntaktiske elementer med metode; vi knytter innholdet i strengen til metoden.

VIRTUELLE FUNKSJONER_________________________________________________________________ 1

Tidlig og sen binding. Dynamisk polymorfisme __________________________________ 1

Virtuelle funksjoner___________________________________________________________________ 1 Virtuelle destruktorer ___________________________________________________________ 4 Abstrakte klasser og rene virtuelle funksjoner________________________________________ 5

VIRTUELLE FUNKSJONER

Tidlig og sen binding. Dynamisk polymorfisme

C++ støtter polymorfisme på to måter.

For det første støttes det under kompilering gjennom funksjons- og operatøroverbelastning. Denne typen polymorfisme kalles statisk polymorfisme, siden den er implementert allerede før utførelse

program, av tidlig assosiasjon av funksjonsidentifikatorer med fysiske adresser på kompilerings- og koblingsstadiet.

For det andre støttes det under programkjøring gjennom virtuelle funksjoner. Etter å ha møtt et virtuelt funksjonskall i programkoden, utpeker kompilatoren (eller, mer presist, linkeren) bare dette kallet, og lar assosiasjonen av funksjonsidentifikatoren stå til adressen til utførelsesfasen. Denne prosessen kalles sen binding.

En virtuell funksjon er en funksjon hvis kall (og handlingene den utfører) avhenger av typen objekt den kalles på. Objektet bestemmer hvilken funksjon som skal kalles under programkjøring. Denne typen polymorfisme kalles dynamisk polymorfisme.

Basis dynamisk polymorfisme er evnen som tilbys av C++ for å definere en peker til en basisklasse, som faktisk vil peke ikke bare til et objekt i denne klassen, men også til et hvilket som helst objekt i en avledet klasse. Denne evnen kommer gjennom arv fordi et objekt av en avledet klasse alltid er et objekt i basisklassen. På kompileringstidspunktet er det ennå ikke kjent hvilket klasseobjekt brukeren vil lage, gitt en peker til basisklasseobjektet. En slik peker er kun knyttet til objektet under programkjøring, det vil si dynamisk. En klasse som inneholder minst én virtuell funksjon kalles polymorf.

For hver polymorfe datatype oppretter kompilatoren en tabell med virtuelle funksjoner og legger inn en skjult peker til denne tabellen i hvert objekt i denne klassen. Den inneholder adressene til de virtuelle funksjonene til det tilsvarende objektet. Navnet på pekeren til tabellen over virtuelle funksjoner og navnet på tabellen avhenger av implementeringen i en bestemt kompilator. For eksempel, i Visual C++ 6.0 heter denne pekeren vfptr , og tabellen heter vftable (fra den engelske Virtual Function Table). Kompilatoren legger automatisk inn et stykke kode i begynnelsen av den polymorfe klassekonstruktøren som initialiserer en peker til tabellen over virtuelle funksjoner. Hvis en virtuell funksjon kalles, finner den kompilatorgenererte koden en peker til den virtuelle funksjonstabellen, slår deretter opp den tabellen og henter adressen til den tilsvarende funksjonen fra den. Etter dette foretas en overgang til den angitte adressen og funksjonen kalles opp.

Husk at når du oppretter et objekt av en avledet klasse, kalles først konstruktøren av dens basisklasse. På dette stadiet opprettes en tabell med virtuelle funksjoner, samt en peker til den. Etter å ha kalt den avledede klassens konstruktør, settes den virtuelle funksjonstabellpekeren til å referere til den overstyrte virtuelle funksjonen (hvis noen) som eksisterer for et objekt i den klassen.

I denne forbindelse må du være klar over prisen du må betale for muligheten til å bruke sen binding.

Siden objekter med virtuelle funksjoner også må støtte en tabell over virtuelle funksjoner, fører bruken av dem alltid til en liten økning i minnekostnader og en reduksjon i programytelse. Hvis du jobber med en liten klasse som du ikke har tenkt å bruke som basisklasse for andre klasser, så er det ingen vits i å bruke virtuelle funksjoner.

Virtuelle funksjoner

Funksjoner hvis anropsgrensesnitt (det vil si prototypen) er kjent, men implementeringen ikke kan spesifiseres generelt, men kan bare defineres for spesifikke tilfeller, kalles virtuelle (et begrep som betyr at funksjonen kan overstyres i en avledet klasse) .

Virtuelle funksjoner er funksjoner som sørger for at riktig funksjon på et objekt kalles, uavhengig av hvilket uttrykk som brukes for å foreta anropet.

Anta at en basisklasse inneholder en funksjon som er erklært virtuell, og en avledet klasse definerer den samme funksjonen. I dette tilfellet kalles en funksjon fra en avledet klasse på objekter i den avledede klassen, selv om den kalles ved hjelp av en peker eller referanse til basisklassen. Eksempel:

klasse Coord

Basiskoordinatklasse

// basiskoordinatklasse

beskyttet:

// beskyttet klassemedlemmer

dobbel x, y;

// koordinater

offentlig:

// medlemmer av offentlig klasse

Coord() ( x = 0 ; y = 0 ; )

// basisklassekonstruktør

void Input();

// erklærer en ikke-virtuell funksjon

virtual void Print();

// erklærer en virtuell funksjon

void Coord::Input()

// lar deg legge inn koordinater fra tastaturet

cout<<"\tx=";

// legger inn verdien x fra tastaturet

cout<<"\ty=";

// legger inn y-verdien fra tastaturet

void Coord::Print()

// viser koordinatverdier på skjermen

cout<<"\tx="<

Avledet poengklasse

klasse Punktum: publicCoord

// arving av koordinatklassen

char navn ;

// punktnavn

offentlig:

// medlemmer av offentlig klasse

Punktum (ch ar N): Koord () ( navn = N ; )

// kaller grunnklassekonstruktøren

void Input();

void Print();

void Dot::Input()

// lar deg legge inn punktkoordinater fra tastaturet

røye S ="Skriv inn koordinatene til punktet";

CharToOem(S, S);

cout<

Coord::Input();

void Dot::Print()

// viser koordinatverdiene til punktet på skjermen

char S = "Punktkoordinater";

CharToOem(S, S);

// konverterer strengtegn til kyrillisk

cout<

// viser tittelen og navnet på punktet

Coord::Skriv ut();

// kaller grunnklassefunksjonen

klasse Vec: publicCoord

Avledet vektorklasse

// arving av koordinatklassen

char navn [3];

// vektornavn

offentlig:

// medlemmer av offentlig klasse

Vec (char * pName): Coord () ( strncpy (navn , pName , 3) ​​​​; navn [ 2 ] = "\0" ; )

void Input();

// overstyrer en ikke-virtuell funksjon

void Print();

// overstyrer en virtuell funksjon

void Vec::Input()

// lar deg legge inn vektorprojeksjoner fra tastaturet

Forelesning 9 Virtuelle funksjoner 3

char S ="Skriv inn projeksjoner av vektoren";// erklærer og initialiserer ledetekststrengen

CharToOem(S, S);

// konverterer strengtegn til kyrillisk

cout<

// viser ledeteksten og vektornavnet

Coord::Input();

// kaller grunnklassefunksjonen

void Vec::Print()

// viser verdiene til vektorprojeksjoner på skjermen

char S = "Vektorprojeksjoner";

// erklærer og initialiserer overskriftslinjen

CharToOem(S, S);

// konverterer strengtegn til kyrillisk

cout<

// viser tittelen og navnet på vektoren

Coord::Skriv ut();

// kaller grunnklassefunksjonen

I eksemplet ovenfor er en basisklasse Coord og to avledede klasser Dot og Vec erklært. Print()-funksjonen i avledede klasser er virtuell fordi den er erklært virtuell i basisklassen Coord. Print()-funksjonen i de avledede klassene Dot og Vec overstyrer basisklassefunksjonen. Hvis den avledede klassen ikke gir en overstyrt implementering av Print()-funksjonen, brukes standardimplementeringen fra basisklassen.

Input()-funksjonen er erklært ikke-virtuell i basisklassen Coord og overstyrt i de avledede klassene Dot og Vec.

void main()

Coord* pC = new Coord();

// erklærer en peker til koordinater og tildeler minne

Dot* pD = new Dot("D");

// erklærer en peker til et punkt og tildeler minne

Vec* pV = new Vec("V");

// erklærer en peker til en vektor og tildeler minne

pc->Input () ;

pC->Skriv ut ();

// kaller den virtuelle funksjonen Coord::Print()

// peker til koordinater mottar adressen til et punkttypeobjekt

pc->Input () ;

// kaller ikke-virtuell funksjon Coord::Input()

pC->Skriv ut ();

// kaller virtuell funksjon Dot::Print()

// peker til koordinater mottar adressen til et vektortypeobjekt

pc->Input () ;

// kaller ikke-virtuell funksjon Coord::Input()

pC->Skriv ut ();

// kaller den virtuelle funksjonen Vec::Print()

I eksemplet ovenfor tar koordinatpekeren pC vekselvis verdiene til adressen til koordinatobjekter, et punkt og en vektor. Selv om PC-pekertypen ikke endres, kaller den forskjellige virtuelle funksjoner avhengig av verdien.

Når du bruker en basisklassepeker som faktisk peker til et avledet klasseobjekt, kalles en ikke-virtuell funksjon til basisklassen.

Det skal bemerkes at oppgaveoperasjonen pC = pD, som bruker operander av forskjellige typer (Koord* og Punkt*) uten konvertering, kun er mulig for grunnklassepekeren på venstre side. Den omvendte tilordningsoperasjonen pD = pC er ikke gyldig og forårsaker en syntaksfeil.

Når det kjøres, viser programmet:

Punkt D koordinater:

Projeksjoner av vektor V:

Når du kaller en funksjon ved hjelp av pekere og referanser, gjelder følgende regler:

et kall til en virtuell funksjon løses i henhold til typen av objektet hvis adresse er lagret av pekeren eller referansen;

et kall til en ikke-virtuell funksjon løses i henhold til typen peker eller referanse.

Virtuelle funksjoner kalles bare på objekter som tilhører en bestemt klasse. Derfor

Du kan ikke erklære en global eller statisk funksjon virtuell. Det virtuelle søkeordet kan

---.NET Assemblies --- Sen innbinding

Sen binding er en teknologi som lar deg instansiere en bestemt type og ringe medlemmene ved kjøring uten å hardkode dens eksistens på kompileringstidspunktet. Når du oppretter en applikasjon som krever sen binding til en type fra en ekstern sammenstilling, er det ingen grunn til å legge til en referanse til den sammenstillingen, og derfor er den ikke direkte spesifisert i kallekodens manifest.

Ved første øyekast er det ikke lett å se fordelene med sen innbinding. Faktisk, hvis det er mulig å utføre tidlig binding på et objekt (for eksempel legge til en sammenstillingsreferanse og plassere en type ved å bruke det nye nøkkelordet), bør du definitivt gjøre det. En av de mest overbevisende årsakene er at tidlig binding gjør at feil kan fanges opp på kompileringstidspunktet i stedet for under kjøretiden. Sen binding spiller imidlertid også en viktig rolle i enhver utvidbar applikasjon du lager.

System.Activator klasse

Klassesystem. Aktivatoren (definert i mscorlib.dll-sammenstillingen) spiller en nøkkelrolle i den sene bindingsprosessen i .NET. I det gjeldende eksemplet er det eneste av interesse for øyeblikket Activator.CreateInstance()-metoden, som lar deg instansiere en sent-bundet type. Denne metoden har flere overbelastninger og gir derfor ganske høy fleksibilitet. I sin enkleste versjon tar CreateInstance() et gyldig Type-objekt som beskriver entiteten som skal allokeres til minnet umiddelbart.

For å se hva dette betyr, la oss lage et nytt prosjekt av typen Console Application, importere System.I0 og System.Reflection navnerommene til det ved å bruke nøkkelordet, og deretter endre Programklassen som vist nedenfor:

Bruke System; ved hjelp av System.Reflection; bruker System.IO; navneområde ConsoleApplication1 ( klasse Program ( static void Main() ( Assembly ass = null; try ( ass = Assembly.Load("fontinfo"); ) catch (FileNotFoundException ex) ( Console.WriteLine(ex.Message); ) if (ass != null) CreateBinding (ass); color1);

Før du kan kjøre dette programmet, må du manuelt kopiere fontinfo.dll-sammenstillingen til underkatalogen bin\Debug inne i denne nye applikasjonens katalog ved hjelp av Windows Utforsker. Saken er at Assembly.Load()-metoden kalles her, noe som betyr at CLR-en kun vil sondere klientmappen (hvis du vil, kan du bruke Assembly.LoadFrom()-metoden og spesifisere hele banen til assemblyen, men i dette i dette tilfellet er det ikke behov for dette).

Vi ble kort kjent med hva det er. I hovedsak er dette bare å overstyre superklassemetoder i underklasser. Men kanskje all kraften og skjønnheten i dette er ennå ikke helt klar. Og det er kanskje ikke helt klart hvorfor alt dette er nødvendig. La oss nå prøve å forstå det dypere. Forbered deg på dyp meditasjon. Ommmmm…. Vel, la oss gå! :)

La oss ta et utslitt eksempel med figurer. La oss ikke avvike fra klassikerne i sjangeren :)

Og så vil vår generelle superklasse være Shape-klassen, og den vil ha arvingene konge, prins, konge, prins, sirkel, firkant, trekant. Men vi vil gå litt lenger enn det utslitte eksemplet :) og skape et par flere arvinger. Oval blir etterfølgeren til Circle, og Rect blir etterfølgeren til Square.

På diagrammet kan alt avbildes noe som dette:

Drow()-metodene i hver klasse vil bli overstyrt, og erase()-metoden vil ganske enkelt arve fra Shape. Nå gjenstår det bare å leke med all denne skjønnheten i koden :)

Koden vår ble veldig vakker :) Bokstav for bokstav :) og utgangen er den samme :)

La oss nå se nærmere på koden. Vi har en endimensjonal formarray av Shape-klasser av størrelse 6. Og vi tilordnet en referanse til Shape-objektet til det første elementet i matrisen (ny Shape() vil bli opprettet). Men så begynner magien, som du allerede har sett og burde forstå. Dette kalles oppoverkonvertering. Jeg har allerede snakket om dette, at en superklasselenke kan peke til objekter av underklasser. Og så videre, vi tildeler underklassereferanser til de neste elementene i formmatrisen. Men så fungerer den vanvittige magien til polymorfisme i utdataene - metoder for underklasser kalles, selv om koblingen har en superklassetype.

Nå er spørsmålet, hvor vet kompilatoren hvilket objekts metode som skal kalles?

Men kompilatoren vet ikke engang... :) Vel, hvem vet da?

Hvem hvem? Drage i frakk!

Selv om det i programmet ovenfor ikke er veldig åpenbart, vet ikke kompilatoren det, siden vi tildeler referanser til spesifikke objekter til elementene i matrisen.

Men jeg gjorde dette for å gjøre det lettere å forstå og få klarhet i hva som skjer.

For å holde alt rettferdig og for å gjøre det klart at kompilatoren ikke kan vite nøyaktig hvilket objekt elementene i matrisen vil referere til, kan dette programmet modifiseres slik at matrisen fylles tilfeldig.

Til venstre er et eksempel på et modifisert utdrag av det samme programmet, men matrisen er allerede fylt tilfeldig, som man kan se fra programutgangen:

Det samme spørsmålet oppstår - hvem vet hvilket objekts metode som skal kalles i hvert enkelt tilfelle? Og JVM vet dette. Men hvordan skal hun vite det? Det er her den seriøse magien til den virtuelle Java-maskinen og Java-kompilatoren begynner.

Kompilatoren selv vet ikke, men den kan fortelle JVM hvordan den skal behandle metodekallinstruksjoner.

For å fullt ut forstå essensen av det som skjer, er det nødvendig å vurdere konseptet binding ( bindende).

Å knytte et metodekall til metodekroppen kalles binding. Hvis koblingen gjøres før programmet kjører (av kompilatoren og linkeren, hvis det er en), kalles det tidlig kobling ( tidlig binding). På prosessuelle språk var det ikke noe bindende valg. Kompilatorer C støtter kun én type samtale - tidlig binding.

Problemet med å bestemme hvilket objekts metode som skal kalles i programmet vårt, er løst takket være sen binding ( sen binding), det vil si binding utført under programkjøring, avhengig av typen objekt. Sen binding kalles også dynamisk ( dynamisk binding) eller kjøretidsbinding ( kjøretidsbinding).

I språk som implementerer sen binding, må det være en mekanisme for å bestemme den faktiske typen av et objekt ved kjøring for å kalle den riktige metoden. Med andre ord, kompilatoren kjenner ikke typen til objektet, men metodekallmekanismen bestemmer den og kaller den tilsvarende metodekroppen. Mekanismen for sen binding avhenger av det spesifikke språket, men det er ikke vanskelig å anta at det for å implementere det må inkluderes noe tilleggsinformasjon i objektene. Nå skal vi prøve å finne ut hva denne informasjonen er.

I det siste innlegget har vi allerede kort berørt denne problemstillingen. La oss nå prøve å forstå dypere.

Alle Java-metoder bruker sen binding med mindre metoden er erklært som privat . Anrop privat metoden kompileres til en bytekode-instruksjon påberope seg spesiell, som kaller implementering av en metode fra en bestemt klasse, definert på kompileringstidspunktet. Å kalle en metode med et annet tilgangsnivå kompileres til påkalle virtuell, som allerede ser på typen av objektet ved referanse på tidspunktet for utførelse. De endelige ikke-private metodene kalles også via påkalle virtuell.

Følgende er kompilert inn i invokespesial bytecode-instruksjonen:

  • Initialiseringsanrop ( ) når du oppretter et objekt
  • Anrop privat metode
  • Kalle en metode ved hjelp av et nøkkelord super

Det er selvfølgelig flere andre bytekode-instruksjoner for anropsmetoder: påkallingsdynamisk , påkalle grensesnitt Og påkalle statisk. Men selv om navnene deres indikerer bruken, vil vi ikke diskutere dem foreløpig. Hvis noen virkelig vil, kan de lese den på et borgerlig språk som er fiendtlig mot enhver sann-troende programmerer :) Det er nyttig lesning, men for å forstå hva vi snakker om nå, er det nok med det jeg allerede har skrevet her . Du kan også lese på ditt morsmål.

Så vi må gå videre til praksis. La oss endre programmet fra dette innlegget som følger:

jeg fremhevet privat Og endelig modifikatorer slik at du tar hensyn til dem og deretter hvilken bytekode kompilatoren vil lage for dem. Utgangen av programmet vårt er nå som følger:

La meg rette oppmerksomheten mot det faktum at rotlenken er av typen Root, men peker på et objekt av typen Branch. Og som jeg har skrevet mer enn en gang, kalles vanlige metoder i henhold til versjonen av objektet som lenken peker til. Det er gjennom denne egenskapen at polymorfisme blir realisert.

Men i vårt tilfelle, til tross for dette, viste den første kommandoen Root, ikke Branch, på konsollen.

La oss nå se under panseret på dette programmet ved å bruke kommandoen: javap -c -p -v Root.class

Denne kommandoen vil generere ganske lang utgang, men vi trenger bare denne delen:

Som du kan se fra utdataene, har root.prt()-kommandoen blitt konvertert til et kall som påberope seg spesiell, og branch.prt()-kommandoen i påkalle virtuell.

Så vi har avslørt magien i hele denne handlingen. Jeg håper du likte presentasjonen :) og nå forstår du litt mer hvordan polymorfe metoder fungerer i Java.

Start