Supraîncărcarea operatorului în programare este una dintre modalitățile de implementare a polimorfismului , care constă în posibilitatea existenței simultane în același domeniu a mai multor opțiuni diferite de utilizare a operatorilor care au același nume, dar diferă prin tipurile de parametri la care sunt. aplicat.
Termenul " supraîncărcare " este o hârtie de calc a cuvântului englezesc supraîncărcare . O astfel de traducere a apărut în cărțile despre limbaje de programare în prima jumătate a anilor 1990. În publicațiile din perioada sovietică, mecanisme similare au fost numite redefinire sau redefinire , operațiuni suprapuse .
Uneori este nevoie de a descrie și de a aplica operațiuni la tipurile de date create de programator care sunt echivalente ca semnificație cu cele deja disponibile în limbaj. Un exemplu clasic este biblioteca pentru lucrul cu numere complexe . Ele, ca și tipurile numerice obișnuite, suportă operații aritmetice și ar fi firesc să creăm pentru acest tip de operație „plus”, „minus”, „înmulțire”, „împărțire”, notându-le cu aceleași semne de operație ca și pentru alte numere. tipuri. Interdicția utilizării elementelor definite în limbaj obligă la crearea multor funcții cu nume precum ComplexPlusComplex, IntegerPlusComplex, ComplexMinusFloat și așa mai departe.
Când operațiunile de aceeași semnificație sunt aplicate operanzilor de diferite tipuri, acestea sunt forțate să fie denumite diferit. Incapacitatea de a folosi funcții cu același nume pentru diferite tipuri de funcții duce la nevoia de a inventa nume diferite pentru același lucru, ceea ce creează confuzie și poate duce chiar la erori. De exemplu, în limbajul clasic C, există două versiuni ale funcției de bibliotecă standard pentru găsirea modulului unui număr: abs() și fabs() - prima este pentru un argument întreg, a doua pentru unul real. Această situație, combinată cu verificarea slabă de tip C, poate duce la o eroare greu de găsit: dacă un programator scrie abs(x) în calcul, unde x este o variabilă reală, atunci unii compilatori vor genera cod fără avertisment care va convertiți x într-un număr întreg prin eliminarea părților fracționale și calculați modulul din întregul rezultat.
În parte, problema este rezolvată prin intermediul programării obiectelor - atunci când noi tipuri de date sunt declarate ca clase, operațiunile asupra acestora pot fi formalizate ca metode de clasă, inclusiv metode de clasă cu același nume (deoarece metodele din clase diferite nu trebuie să aibă nume diferite), dar, în primul rând, un astfel de mod de proiectare a operațiunilor pe valori de diferite tipuri este incomod și, în al doilea rând, nu rezolvă problema creării de noi operatori.
Instrumentele care vă permit să extindeți limbajul, să îl completați cu noi operații și construcții sintactice (și supraîncărcarea operațiilor este unul dintre astfel de instrumente, alături de obiecte, macro-uri, funcționale, închideri) îl transformă într-un metalimbaj - un instrument pentru descrierea limbilor concentrat pe sarcini specifice. Cu ajutorul acestuia, este posibil să construiți o extensie de limbaj pentru fiecare sarcină specifică care este cea mai potrivită pentru aceasta, ceea ce va permite descrierea soluției sale în forma cea mai naturală, mai ușor de înțeles și mai simplă. De exemplu, într-o aplicație de supraîncărcare a operațiilor: crearea unei biblioteci de tipuri matematice complexe (vectori, matrice) și descrierea operațiilor cu acestea într-o formă naturală, „matematică”, creează un „limbaj pentru operații vectoriale”, în care complexitatea calculele este ascunsă și este posibil să se descrie soluția problemelor în termeni de operații vectoriale și matrice, concentrându-se pe esența problemei, nu pe tehnică. Din aceste motive, astfel de mijloace au fost odată incluse în limba Algol-68 .
Supraîncărcarea operatorului implică introducerea a două caracteristici interdependente în limbaj: capacitatea de a declara mai multe proceduri sau funcții cu același nume în același domeniu și capacitatea de a descrie propriile implementări ale operatorilor binari (adică semnele operațiunilor, scris de obicei în notație infixă, între operanzi). Practic, implementarea lor este destul de simplă:
Există patru tipuri de supraîncărcare a operatorului în C++:
Este important să rețineți că supraîncărcarea îmbunătățește limba, nu schimbă limba, așa că nu puteți supraîncărca operatorii pentru tipurile încorporate. Nu puteți modifica precedența și asociativitatea (de la stânga la dreapta sau de la dreapta la stânga) operatorilor. Nu puteți să vă creați proprii operatori și să supraîncărcați unii dintre cei încorporați: :: . .* ?: sizeof typeid. De asemenea, operatorii && || ,își pierd proprietățile unice atunci când sunt supraîncărcați: lenea pentru primele două și prioritate pentru o virgulă (ordinea expresiilor între virgule este strict definită ca fiind asociată stânga, adică de la stânga la dreapta). Operatorul ->trebuie să returneze fie un pointer, fie un obiect (prin copiere sau referință).
Operatorii pot fi supraîncărcați atât ca funcții autonome, cât și ca funcții membre ale unei clase. În al doilea caz, argumentul din stânga al operatorului este întotdeauna *this obiect. Operatorii = -> [] ()pot fi supraîncărcați doar ca metode (funcții membre), nu ca funcții.
Puteți face scrierea codului mult mai ușoară dacă supraîncărcați operatorii într-o anumită ordine. Acest lucru nu numai că va accelera scrierea, dar vă va scuti și de la duplicarea aceluiași cod. Să luăm în considerare o supraîncărcare folosind exemplul unei clase care este un punct geometric într-un spațiu vectorial bidimensional:
classPoint _ { int x , y ; public : Punct ( int x , int xx ) : x ( x ), y ( xx ) {} // Constructorul implicit a dispărut. // Numele argumentelor constructorului pot fi aceleași cu numele câmpurilor de clasă. }Alți operatori nu sunt supuși niciunui ghid general privind supraîncărcarea.
Conversii de tipConversiile de tip vă permit să specificați regulile de conversie a clasei noastre în alte tipuri și clase. De asemenea, puteți specifica specificatorul explicit, care va permite conversia tipului numai dacă programatorul a specificat-o în mod explicit (de exemplu , static_cast<Point3>(Point(2,3)); ). Exemplu:
Point :: operator bool () const { returnează acest lucru -> x != 0 || aceasta -> y != 0 ; } Operatori de alocare și dealocareOperatorii new new[] delete delete[]pot fi supraîncărcați și pot lua orice număr de argumente. În plus, operatorii new и new[]trebuie să ia un argument tip ca prim argument std::size_tși să returneze o valoare de tip void *, iar operatorii trebuie să ia delete delete[]primul void *și să nu returneze nimic ( void). Acești operatori pot fi supraîncărcați atât pentru funcții, cât și pentru clase concrete.
Exemplu:
void * MyClass :: operator new ( std :: size_t s , int a ) { void * p = malloc ( s * a ); dacă ( p == nullptr ) throw "Fără memorie liberă!" ; întoarcere p ; } // ... // Apel: MyClass * p = new ( 12 ) MyClass ;
Literele personalizate există încă de la al unsprezecelea standard C++. Literalele se comportă ca funcții obișnuite. Ele pot fi calificative inline sau constexpr . Este de dorit ca literalul să înceapă cu un caracter de subliniere, deoarece poate exista un conflict cu standardele viitoare. De exemplu, literalul i aparține deja numerelor complexe din std::complex.
Literale pot lua doar unul dintre următoarele tipuri: const char * , unsigned long long int , long double , char , wchar_t , char16_t , char32_t. Este suficient să supraîncărcați literalul numai pentru tipul const char * . Dacă nu se găsește niciun candidat potrivit, atunci va fi apelat un operator de acest tip. Un exemplu de conversie a milelor în kilometri:
constexpr int operator "" _mi ( unsigned long long int i ) { return 1,6 * i ;} constexpr operator dublu "" _mi ( long dublu i ) { return 1,6 * i ;}Literale șiruri iau un al doilea argument std::size_tși unul dintre primul: const char * , const wchar_t *, const char16_t * , const char32_t *. Literale șir de caractere se aplică intrărilor cuprinse între ghilimele duble.
C++ are încorporat un șir de prefix literal R care tratează toate caracterele citate ca caractere obișnuite și nu interpretează anumite secvențe ca caractere speciale. De exemplu, o astfel de comandă std::cout << R"(Hello!\n)"va afișa Hello!\n.
Supraîncărcarea operatorului este strâns legată de supraîncărcarea metodei. Un operator este supraîncărcat cu cuvântul cheie Operator, care definește o „metodă operator”, care, la rândul său, definește acțiunea operatorului în raport cu clasa sa. Există două forme de metode operator (operator): una pentru operatorii unari , cealaltă pentru cei binari . Mai jos este forma generală pentru fiecare variantă a acestor metode.
// formă generală a supraîncărcării operatorului unar. public static return_type operator op ( parameter_type operand ) { // operațiuni } // Forma generală de supraîncărcare a operatorului binar. operator public static return_type op ( parameter_type1 operand1 , parameter_type2 operand2 ) { // operații }Aici, în loc de „op”, este înlocuit un operator supraîncărcat, de exemplu + sau /; iar „return_type” denotă tipul specific de valoare returnat de operația specificată. Această valoare poate fi de orice tip, dar este adesea specificată ca fiind de același tip cu clasa pentru care operatorul este supraîncărcat. Această corelație facilitează utilizarea operatorilor supraîncărcați în expresii. Pentru operatorii unari, operandul indică operandul transmis, iar pentru operatorii binari, același lucru este notat cu „operand1 și operand2”. Rețineți că metodele operatorului trebuie să fie de ambele tipuri, publice și statice. Tipul de operand al operatorilor unari trebuie să fie același cu clasa pentru care operatorul este supraîncărcat. Și în operatorii binari, cel puțin unul dintre operanzi trebuie să fie de același tip cu clasa sa. Prin urmare, C# nu permite supraîncărcarea niciunui operator pe obiecte care nu au fost încă create. De exemplu, alocarea operatorului + nu poate fi suprascrisă pentru elemente de tip int sau string . Nu puteți utiliza modificatorul ref sau out în parametrii operatorului. [unu]
Supraîncărcarea procedurilor și funcțiilor la nivelul unei idei generale, de regulă, nu este dificilă nici de implementat, nici de înțeles. Cu toate acestea, chiar și în el există câteva „capcane” care trebuie luate în considerare. Permiterea supraîncărcării operatorului creează mult mai multe probleme atât pentru implementatorul limbajului, cât și pentru programatorul care lucrează în limba respectivă.
Problemă de identificarePrima problemă este dependența de context . Adică, prima întrebare cu care se confruntă un dezvoltator al unui traducător de limbi care permite supraîncărcarea procedurilor și funcțiilor este: cum să alegeți dintre procedurile cu același nume pe cea care ar trebui aplicată în acest caz particular? Totul este în regulă dacă există o variantă a procedurii, ale căror tipuri de parametri formali se potrivesc exact cu tipurile de parametrii efectivi utilizați în acest apel. Cu toate acestea, în aproape toate limbile, există un anumit grad de libertate în utilizarea tipurilor, presupunând că compilatorul în anumite situații convertește (transmite) în mod automat tipurile de date. De exemplu, în operațiile aritmetice pe argumente reale și întregi, un număr întreg este de obicei convertit automat într-un tip real, iar rezultatul este real. Să presupunem că există două variante ale funcției de adăugare:
int add(int a1, int a2); float add(float a1, float a2);Cum ar trebui să gestioneze compilatorul expresia y = add(x, i)unde x este de tip float și i este de tip int? Evident, nu există o potrivire exactă. Există două opțiuni: fie y=add_int((int)x,i), fie ca (aici , prima și a doua versiune a funcției sunt notate prin y=add_flt(x, (float)i)nume add_intși respectiv).add_flt
Se pune întrebarea: compilatorul ar trebui să permită această utilizare a funcțiilor supraîncărcate și, dacă da, pe ce bază va alege varianta particulară utilizată? În special, în exemplul de mai sus, traducătorul ar trebui să ia în considerare tipul variabilei y atunci când alege? Trebuie menționat că situația dată este cea mai simplă. Dar sunt posibile cazuri mult mai complicate, care sunt agravate de faptul că nu numai tipurile încorporate pot fi convertite conform regulilor limbajului, ci și clasele declarate de programator, dacă au relații de rudenie, pot fi turnate din unul la altul. Există două soluții la această problemă:
Spre deosebire de proceduri și funcții, operațiunile infixe ale limbajelor de programare au două proprietăți suplimentare care le afectează în mod semnificativ funcționalitatea: prioritatea și asociativitatea , a căror prezență se datorează posibilității de înregistrare „în lanț” a operatorilor (cum să înțelegem a+b*c : cum (a+b)*csau cum a+(b*c)? Expresie a-b+c - aceasta (a-b)+csau a-(b+c)?).
Operațiile încorporate în limbaj au întotdeauna precedență tradițională și asociativitate predefinite. Se pune întrebarea: ce priorități și asociativitate vor avea versiunile redefinite ale acestor operații sau, mai mult, noile operații create de programator? Există și alte subtilități care ar putea necesita clarificări. De exemplu, în C există două forme ale operatorilor de creștere și decrementare ++și -- , prefix și postfix, care se comportă diferit. Cum ar trebui să se comporte versiunile supraîncărcate ale unor astfel de operatori?
Diferite limbi abordează aceste probleme în moduri diferite. Deci, în C++, precedența și asociativitatea versiunilor supraîncărcate ale operatorilor sunt păstrate la fel ca cele ale celor predefiniti în limbaj, iar descrierile de supraîncărcare ale formelor de prefix și postfix ale operatorilor de creștere și decrementare folosesc semnături diferite:
formă de prefix | Formular postfix | |
---|---|---|
Funcţie | T&operator ++(T&) | operator T ++(T &, int) |
funcția de membru | T&T::operator ++() | TT::operator ++(int) |
De fapt, operația nu are un parametru întreg - este fictivă și este adăugată doar pentru a face diferența în semnături
Încă o întrebare: este posibil să se permită supraîncărcarea operatorului pentru tipurile de date încorporate și deja declarate? Poate un programator să modifice implementarea operației de adăugare pentru tipul întreg încorporat? Sau pentru biblioteca de tip "matrice"? De regulă, la prima întrebare se răspunde negativ. Modificarea comportamentului operațiunilor standard pentru tipurile încorporate este o acțiune extrem de specifică, a cărei nevoie reală poate apărea doar în cazuri rare, în timp ce consecințele dăunătoare ale utilizării necontrolate a unei astfel de caracteristici sunt greu de prezis chiar și pe deplin. Prin urmare, limbajul fie interzice de obicei redefinirea operațiunilor pentru tipurile încorporate, fie implementează un mecanism de supraîncărcare a operatorului în așa fel încât operațiunile standard pur și simplu nu pot fi depășite cu ajutorul său. În ceea ce privește a doua întrebare (redefinirea operatorilor deja descriși pentru tipurile existente), funcționalitatea necesară este pe deplin oferită de mecanismul de moștenire a clasei și de suprascriere a metodei: dacă doriți să schimbați comportamentul unei clase existente, trebuie să o moșteniți și să o redefiniți. operatorii descriși în acesta. În acest caz, vechea clasă va rămâne neschimbată, cea nouă va primi funcționalitatea necesară și nu vor avea loc coliziuni.
Anunțul de noi operațiuniSituația cu anunțul de noi operațiuni este și mai complicată. Includerea posibilității unei astfel de declarații în limbă nu este dificilă, dar implementarea acesteia este plină de dificultăți semnificative. Declararea unei noi operații înseamnă, de fapt, crearea unui nou cuvânt cheie în limbaj de programare, complicată de faptul că operațiunile din text, de regulă, pot urma fără separatori cu alte jetoane. Când apar, apar dificultăți suplimentare în organizarea analizatorului lexical. De exemplu, dacă limbajul are deja operațiile „+” și unara „-” (schimbarea semnului), atunci expresia a+-bpoate fi interpretată cu acuratețe ca a + (-b), dar dacă o nouă operație este declarată în program +-, apare imediat ambiguitatea, deoarece aceeași expresie poate fi deja analizată și cum a (+-) b. Dezvoltatorul și implementatorul limbajului trebuie să se ocupe de astfel de probleme într-un fel. Opțiunile, din nou, pot fi diferite: necesită ca toate operațiunile noi să fie cu un singur caracter, postulează că, în cazul oricăror discrepanțe, se alege cea mai „lungă” versiune a operației (adică până la următorul set de caractere citit de către translator se potrivește cu orice operațiune, continuă să fie citit), încearcă să detecteze coliziuni în timpul traducerii și să genereze erori în cazuri controversate... Într-un fel sau altul, limbile care permit declararea de noi operațiuni rezolvă aceste probleme.
Nu trebuie uitat că pentru operațiuni noi se pune și problema determinării asociativității și priorității. Nu mai există o soluție gata făcută sub forma unei operațiuni de limbaj standard și, de obicei, trebuie doar să setați acești parametri cu regulile limbii. De exemplu, faceți toate operațiunile noi asociate stânga și acordați-le aceeași, fixă, prioritate sau introduceți în limbaj mijloacele de specificare a ambelor.
Când operatorii, funcțiile și procedurile supraîncărcate sunt utilizate în limbaje puternic tipizate, în care fiecare variabilă are un tip pre-declarat, este la latitudinea compilatorului să decidă ce versiune a operatorului supraîncărcat să folosească în fiecare caz particular, indiferent cât de complexă. . Aceasta înseamnă că pentru limbajele compilate, utilizarea supraîncărcării operatorului nu reduce în niciun fel performanța - în orice caz, există o operație bine definită sau un apel de funcție în codul obiect al programului. Situația este diferită atunci când este posibil să se utilizeze variabile polimorfe în limbaj - variabile care pot conține valori de diferite tipuri în momente diferite.
Deoarece tipul valorii la care va fi aplicată operația supraîncărcată este necunoscut în momentul traducerii codului, compilatorul este lipsit de posibilitatea de a alege în prealabil opțiunea dorită. În această situație, este forțat să încorporați un fragment în codul obiect care, imediat înainte de efectuarea acestei operațiuni, va determina tipurile de valori din argumente și va selecta dinamic o variantă corespunzătoare acestui set de tipuri. Mai mult, o astfel de definiție trebuie făcută de fiecare dată când se efectuează operația, deoarece chiar și același cod, fiind numit a doua oară, poate fi executat diferit...
Astfel, utilizarea supraîncărcării operatorului în combinație cu variabile polimorfe face inevitabil să se determine în mod dinamic ce cod să apeleze.
Utilizarea supraîncărcării nu este considerată un avantaj de către toți experții. Dacă supraîncărcarea de funcții și proceduri, în general, nu găsește obiecții serioase (parțial pentru că nu duce la unele probleme tipice „operatorului”, parțial pentru că este mai puțin tentant să o folosești greșit), atunci supraîncărcarea operatorului, ca în principiu, și în special implementările limbajului, este supus unor critici destul de severe din partea multor teoreticieni și practicieni în programare.
Criticii subliniază că problemele de identificare, precedență și asociativitate evidențiate mai sus fac adesea ca tratarea cu operatori supraîncărcați fie inutil de dificilă, fie nefirească:
Cât de mult poate depăși comoditatea utilizării propriilor operațiuni pe inconvenientul deteriorării gestionabilității programului este o întrebare care nu are un răspuns clar.
Unii critici vorbesc împotriva operațiunilor de supraîncărcare, bazate pe principiile generale ale teoriei dezvoltării software și ale practicii industriale reale.
Această problemă decurge în mod natural din cele două anterioare. Este ușor de nivelat prin acceptarea acordurilor și cultura generală a programării.
Următoarea este o clasificare a unor limbaje de programare în funcție de faptul dacă permit supraîncărcarea operatorului și dacă operatorii sunt limitați la un set predefinit:
Multi Operatori |
Fără supraîncărcare |
Există o supraîncărcare |
---|---|---|
Doar predefinite |
Ada | |
Este posibil să introduceți noi |
Algol 68 |