9. Clase
********

Clasele constituie o modalitate de a aduce laolaltă date și
funcționalități. Atunci când creăm o clasă nouă, creăm un nou *tip* de
obiecte, adică stabilim felul în care pot fi construite *instanțe* ale
tipului respectiv. Fiecărei instanțe a unei clase oarecare îi putem
atașa atribute în scopul păstrării stării instanței în cauză.
Instanțele clasei pot dispune și de metode (definite în cadrul acelei
clase), folosite la modificarea stărilor lor.

În comparație cu alte limbaje de programare, mecanismul claselor din
Python adaugă clase (la familia de tipuri de date deja existente) cu
un minim de sintaxă, respectiv de semantică (în sensul dat în teoria
compilatoarelor). El este o mixtură a mecanismelor privitoare la clase
pe care le găsim în C++ și în Modula-3. Clasele (din) Python posedă
toate trăsăturile tipice pe care le cere *Programarea
orientată-obiect* (adică, POO; se folosesc și constructele *Programare
orientată pe obiecte*, respectiv *Programare orientată înspre
obiecte*): mecanismul de moștenire a claselor îngăduie mai multe clase
de bază (numite și *superclase* ori *clase-părinte*), orice clasă
derivată își poate suprascrie (de la englezescul, în jargon,
*override*) metodele moștenite de la una sau de la mai multe din
clasele de bază, iar orice metodă (a unei anumite clase) poate apela
metoda omonimă a uneia din clasele de bază (ale clasei respective).
Obiectele pot conține cantități și tipuri oarecare de date. Aidoma
modulelor, clasele împărtășesc natura dinamică a Python-ului: ele sunt
create în timpul execuției (sau, în jargon informatic, la *runtime*)
și pot fi modificate după ce au fost create.

Urmând terminologia C++, membrii obișnuiți ai unei clase (incluzând
aici *datele-membru*; sunt utilizate și constructele *datele membre*,
respectiv *variabilele de instanță*) sunt *publici* (cu excepția, vezi
mai jos, Variabile private), pe când toate funcțiile-membru (ori
*funcțiile membre*) sunt *virtuale*. La fel ca în Modula-3, nu există
simplificări de notație care să deosebească membrii unui obiect de
metodele obiectului: funcția metodă se declară cu primul argument
(dat) explicit ca reprezentând obiectul, acesta urmând a fi transmis,
în mod implicit, la apelul funcției. Precum în Smalltalk, clasele
însele sunt obiecte. O atare particularitate aduce cu sine o semantică
a importării și a redenumirii. Spre deosebire de C++ ori de Modula-3,
tipurile predefinite (de date) pot fi întrebuințate drept clase de
bază la extinderile realizate de către utilizator. De asemeni, ca în
C++, majoritatea operatorilor predefiniți care au o sintaxă specială
(cum ar fi operatorii aritmetici, operatorul de indexare șamd.) pot fi
redefiniți pentru instanțele unor clase oarecare.

(Dată fiind lipsa unei terminologii unanim acceptate în discuțiile
relative la clase, vom folosi ocazional formulări specifice C++-ului
și Smalltalk-ului. Am fi întrebuințat termeni din Modula-3, căci
semantica orientată-obiect a acestuia este mai apropiată de Python
decât cea a C++-ului, dacă nu am bănui că prea puțini dintre cititori
au auzit de acest limbaj.)


9.1. O vorbă despre nume și obiecte
===================================

Obiectele au individualitate și este permis ca nume diferite (din
*domenii de valabilitate* distincte; de la englezescul *scope*; se
folosește și constructul *domenii de vizibilitate*) să fie legate de
același obiect. În alte limbaje de programare, o atare permisiune este
cunoscută drept *întrebuințare de pseudonime* (de la englezescul
*aliasing*; sau *atribuire de pseudonime*). Pseudonimele se folosesc
rar de către cei aflați la primul contact cu Python-ul, utilizarea lor
putând fi evitată în mod eficient atunci când avem de a face, în
programul nostru, cu tipurile de date elementare care sunt imutabile
(numere, șiruri de caractere, tupluri). Pe de altă parte, atribuirea
de pseudonime poate avea efecte neașteptate asupra semanticii
programelor Python care întrebuințează obiecte mutabile precum
listele, dicționarele de date șamd. Pseudonimele îi sunt utile unui
program Python deoarece se comportă ca niște *pointeri* în anumite
privințe. De exemplu, transmiterea (ca argument al unei funcții
oarecare) unui obiect este ieftină căci implementarea se îngrijește să
fie transmis doar un pointer; iar dacă funcția va modifica obiectul
transmis ei ca argument, atunci apelantul va observa schimbarea
intervenită --- ceea ce elimină nevoia de a dispune de două mecanisme
de transmitere a argumentelor, ca în Pascal.


9.2. Domenii de valabilitate și spații de nume în Python
========================================================

Înainte de a introduce clasele, trebuie să discutăm puțin despre
regulile Python-ului în ceea ce privește domeniile de valabilitate.
Definițiile claselor le joacă adesea feste celor care utilizează
spațiile de nume (de la englezescul *namespace*), așa că este
important să cunoașteți precis cum funcționează domeniile de
valabilitate și spațiile de nume pentru a putea urmări îndeaproape cum
evoluează lucrurile. Că tot a venit vorba, cunoașterea acestei
problematici îi este utilă oricărui programator matur în Python.

Să începem cu câteva definiții.

Un *spațiu de nume* este o *asociere* (sau o *corespondență*; de la
englezescul *mapping*; se folosește, ca jargon informatic, și termenul
de *mapare*) a unor nume cu niște obiecte. Cele mai multe spații de
nume sunt implementate în Python, la momentul de față, ca dicționare
de date, însă respectiva implementare nu iese în evidență prin nimic
(poate cu excepția performanței) și există posibilitatea ca ea să se
schimbe în viitor. Exemple de spații de nume sunt următoarele: setul
numelor predefinite (conținând funcții precum "abs()", respectiv nume
de excepții predefinite); numele globale dintr-un modul; ori numele
locale dintr-un apel de funcție. Lucrul important de reținut despre
spațiile de nume este că nu există niciun fel de legătură între numele
situate în spații de nume diferite; de exemplu, două module distincte
pot defini fără pericol de confuzie (câte) o funcție intitulată
"maximizează" -- ca să folosească o astfel de funcție, utilizatorii
modulelor vor avea de prefixat numele ei cu numele modulului care o
conține.

Apropo, folosim cuvântul *atribut* pentru a ne referi la orice nume
care îi urmează unui (operator) punct --- cum ar fi faptul că, în
expresia "z.real", "real" (partea reală) este un atribut al obiectului
"z". Într-o exprimare riguroasă, referirea la numele dintr-un modul
este o referire la niște atribute: în expresia
"nume_de_modul.nume_de_funcție", "nume_de_modul" desemnează numele
unui modul pe când "nume_de_funcție" pe cel al unui atribut al acestui
modul. În cazul de față se întâmplă să existe o mapare ușor de sesizat
între atributele modulului și numele globale definite în modul: ele
împart același spațiu de nume! [1]

Atributele pot fi sau *doar-de-citit* sau *editabile* (de la
englezescul *writable*). În cel de-al doilea caz, atributelor li se
pot asigna valori. Atributele unui modul sunt editabile: putem scrie
"nume_de_modul.răspunsul = 42". De asemeni, atributele editabile pot
fi șterse, cu ajutorul instrucțiunii "del". De exemplu, "del
nume_de_modul.răspunsul" va elimina atributul "răspunsul" din obiectul
numit "nume_de_modul".

Spațiile de nume sunt create la momente de timp diferite și au durate
de viață variate. Spațiul de nume care conține numele predefinite este
creat la pornirea interpretorului de Python, fără a mai fi șters pe
întreaga durată a funcționării acestuia. Spațiul de nume global al
unui modul este creat odată cu citirea de către interpretor a
definiției modulului în cauză; în mod obișnuit, spațiile de nume ale
modulelor rămân în viață până la oprirea interpretorului.
Instrucțiunile executate de o invocare de la cel mai înalt nivel a
interpretorului, fie că au fost citite dintr-un fișier de script fie
că au fost introduse în mod interactiv, sunt considerate că făcând
parte dintr-un modul numit "__main__", astfel că ele posedă propriul
lor spațiu de nume global. (Numele predefinite, la rândul lor,
funcționează într-un modul; acesta se numește "builtins".)

Spațiul de nume local al unei funcții este creat la apelul funcției și
este șters fie la returnarea din execuția codului funcției fie atunci
când, în codul funcției, este ridicată o excepție care nu va fi
interceptată în cadrul funcției respective. (De fapt, *uitare* ar fi
termenul potrivit pentru a descrie ceea ce se întâmplă cu adevărat
într-o atare situație.) Evident, invocările recursive ale unei funcții
au fiecare propriul său spațiu de nume.

Un *domeniu de valabilitate* este o zonă de text dintr-un program
Python în care un anumit spațiu de nume este accesibil în mod direct.
Prin "accesibil în mod direct" înțelegem faptul că referințele
*necalificate* la nume oarecare reușesc să identifice despre ce nume
este vorba în acel spațiu de nume.

Cu toate că domeniile de valabilitate sunt stabilite *static*,
utilizarea lor se face *dinamic*. La orice moment de timp de pe
parcursul execuției unui program Python, există 3 sau 4 domenii de
valabilitate imbricate la ale căror spații de nume avem acces în mod
direct:

* domeniul de valabilitate poziționat cel mai adânc în interiorul
  codului, acela din care va începe orice căutare de nume, el conține
  numele locale

* domeniile de valabilitate ale funcțiilor care înglobează codul (de
  la englezescul *enclosing*), în care căutările de nume se realizează
  pornind de la domeniul de includere (al codului) situat cel mai
  adânc, acestea deținând atât nume ne-locale cât și nume ne-globale

* penultimul (dinspre interiorul către exteriorul codului) domeniu de
  valabilitate conține numele globale ale modulului curent

* domeniul de valabilitate cel mai de sus (ultimul în care se va căuta
  numele respectiv) este spațiul de nume care cuprinde numele
  predefinite

Atunci când un nume este declarat ca fiind *global*, toate referirile
la el precum și toate atribuirile către el vor fi realizate în
domeniul de valabilitate penultim, adică în domeniul care deține toate
numele globale ale modulului. Pentru a lega din nou variabilele găsite
în exteriorul celui mai adânc situat dintre domeniile de valabilitate
poate fi întrebuințată instrucțiunea "nonlocal" (adică, o declarație
de ne-local); dacă nu au fost declarate drept ne-locale, atunci
asemenea variabile sunt doar-de-citit (orice tentativă de a edita o
atare variabilă va conduce la crearea unei variabile locale *noi*,
conținută în domeniul de valabilitate poziționat cel mai adânc, în
timp ce numele omonim din exterior nu-și va modifica valoarea).

Domeniul de valabilitate local se referă, în mod obișnuit, la numele
locale ale (chiar) funcției de față. În exteriorul codului de funcții,
domeniul de valabilitate local face referire la același spațiu de nume
ca și domeniul de valabilitate global: adică la spațiul de nume al
modulului. Definițiile claselor, la rândul lor, plasează alte spații
de nume în domeniul de valabilitate local.

Este important să realizăm că domeniile de valabilitate se determină
textual: domeniul de valabilitate global al unei funcții definite
într-un modul este chiar spațiul de nume al modulului respectiv,
indiferent de unde ori sub ce pseudonim s-a realizat apelul funcției
în cauză. Pe de altă parte, căutarea efectivă a unui anumit nume se
realizează dinamic, pe parcursul execuției programului --- cu toate
acestea, definiția Python-ului ca limbaj de programare evoluează către
o rezolvare statică a numelor, adică una la momentul "compilării",
motiv pentru care vă recomandăm să nu vă bazați pe rezolvări dinamice
de nume! (În fapt, variabilele locale sunt deja determinate static.)

O caracteristică aparte a Python-ului -- atunci când nu ne găsim sub
efectul vreuneia din instrucțiunile "global" ori "nonlocal" -- este
aceea că atribuirile către nume se fac întotdeauna în domeniul de
valabilitate poziționat cel mai adânc în interiorul codului.
Atribuirile nu copiază date --- ele doar leagă nume de obiecte.
Același lucru este valabil și pentru ștergeri: instrucțiunea "del x"
elimină legătura lui "x" din spațiul de nume la care se referă
domeniul de valabilitate local. În fapt, orice operație care introduce
nume noi întrebuințează domeniul de valabilitate local: în particular,
instrucțiunile "import" precum și definițiile de funcții leagă fie
numele modulului fie numele funcției de domeniul de valabilitate
local.

Instrucțiunea "global" poate fi utilizată la a arăta că o anume
variabilă trăiește în domeniul de valabilitate global, astfel că
trebuie re-legată (tot) în el; instrucțiunea "nonlocal" indică faptul
că variabila respectivă trăiește într-un domeniu de valabilitate care
înglobează codul de față, deci că va trebui re-legată în acela.


9.2.1. Un exemplu cu domenii de valabilitate și spații de nume
--------------------------------------------------------------

Acesta este un exemplu care ilustrează cum trebuie referențiate
diversele domenii de valabilitate și spațiile de nume, respectiv cum
afectează cuvintele-cheie "global" și "nonlocal" legarea unei
variabile oarecare:

   def testează_domeniul_de_vizibilitate():
       def construiește_ceva_local():
           șuncă = "șuncă produsă local"

       def construiește_ceva_nelocal():
           nonlocal șuncă
           șuncă = "șuncă produsă ne-local"

       def construiește_ceva_global():
           global șuncă
           șuncă = "șuncă produsă global"

       șuncă = "testez șunca"
       construiește_ceva_local()
       print("După asignarea locală:", șuncă)
       construiește_ceva_nelocal()
       print("După asignarea ne-locală:", șuncă)
       construiește_ceva_global()
       print("După asignarea globală:", șuncă)

   testează_domeniul_de_vizibilitate()
   print("În domeniul de valabilitate global:", șuncă)

Rezultatul rulării codului din exemplu este următorul:

   După asignarea locală: testez șunca
   După asignarea ne-locală: șuncă produsă ne-local
   După asignarea globală: șuncă produsă ne-local
   În domeniul de valabilitate global: șuncă produsă global

Remarcați că atribuirea *local* (deci, cea standard) nu a modificat
legarea lui *șuncă* dată de *testează_domeniul_de_vizibilitate*. În
schimb, atribuirea "nonlocal" a schimbat legarea lui *șuncă* dată de
*testează_domeniul_de_vizibilitate*, respectiv atribuirea "global" a
modificat legarea la nivel de modul.

În plus, puteți observa că *șuncă* nu a fost legat în niciun fel până
la momentul asignării "global".


9.3. Prima privire aruncată asupra claselor
===========================================

Clasele aduc un strop de nou în conținutul sintaxei, alte trei tipuri
de obiecte, precum și ceva noutăți la semantică.


9.3.1. Sintaxa definiției unei clase
------------------------------------

Cea mai simplă formă a definiției unei clase arată astfel:

   class NumeleClasei:
       <instrucțiunea-1>
       .
       .
       .
       <instrucțiunea-N>

Definițiile de clase, aidoma definițiilor de funcții (instrucțiuni
"def") trebuie executate mai întâi pentru a avea efect. (Dat fiind că
nu este exclus să plasați o definiție de clasă într-una din
ramificațiile unei instrucțiuni "if", după cum nu este imposibil nici
să o inserați în codul vreunei funcții.)

În practică, instrucțiunile din codul definiției unei clase vor fi mai
ales definiții de funcții, cu toate că sunt permise și altfel de
instrucțiuni, și încă cu mult folos --- vom reveni la aceasta mai
târziu. Definițiile de funcții din interiorul (codului) unei clase
impun (de obicei) o formă neobișnuită a listei de argumente, formă
dictată de convențiile de apel ale metodelor --- și aceste aspecte vor
fi explicate ulterior.

Atunci când interpretorul citește definiția unei clase, un spațiu de
nume (nou) va fi creat și întrebuințat ca domeniu de valabilitate
local --- astfel, toate atribuirile către variabile locale vor fi
realizate în cadrul acestui (nou) spațiu de nume. În particular,
definițiile de funcții leagă fiecare nume nou de funcție de acest
domeniu de valabilitate.

Atunci când interpretorul părăsește în mod normal codul unei definiții
de clasă (deci, după ce a ajuns la finalul acestui cod), va fi creat
un *obiect clasă*. Acesta este, în fapt, o *împachetare* (sau o
*învelitoare*; de la englezescul *wrapper*) a conținutului acelui
spațiu de nume creat de însăși definiția clasei; vom afla mai multe
despre obiectele clasă în secțiunea următoare. Domeniul de
valabilitate local în care ne aflam (adică, domeniul vizibil înainte
ca interpretorul să ajungă la definiția clasei) este reinstaurat iar
obiectul clasă este legat de numele clasei dat în antetul definiției
acesteia ("NumeleClasei" din exemplul nostru).


9.3.2. Obiectele clasă
----------------------

Obiectele clasă permit două feluri de operații: referirea la atribute
și instanțierea.

*Referirea la atribute* (de clasă; sau *referențierea atributelor* ori
*referința la atribute*) întrebuințează sintaxa tipică din Python a
referirii la atribute oarecare: "obiect.nume". Numele valide de
atribute alcătuiesc întreg ansamblul de nume din spațiul de nume al
clasei la momentul creării obiectului clasă. Astfel, dacă definiția
clasei arată ca mai jos:

   class ClasaMea:
       """Un exemplu simplist de clasă"""
       i = 12345

       def f(self):
           return 'salutare, lume'

atunci "ClasaMea.i" și "ClasaMea.f" sunt referiri valide la atribute,
care returnează un număr întreg, respectiv un obiect funcție.
Atributelor de clasă le putem realiza asignări, așa că valoarea lui
"ClasaMea.i" poate fi modificată prin atribuire. Și "__doc__" este un
atribut valid, returnând docstring-ul clasei: ""Un exemplu simplist de
clasă"".

*Instanțierea* unei clase utilizează notația cu operatorul funcție. Ne
putem închipui că obiectul clasă este o funcție fără parametri care
întoarce o (nouă) instanță a clasei. De exemplu (folosind clasa de mai
sus):

   x = ClasaMea()

creează o *instanță* nouă a clasei și îi atribuie obiectul nou creat
variabilei locale "x".

Operația de *instanțiere* (adică de "apelare" a unui obiect clasă; de
la englezescul *instantiation*) creează un obiect *gol* (vid). Pentru
multe clase utilizate în practică se dorește crearea de instanțe
particularizate printr-o *stare inițială* specificată (de către
utilizator). Din acest motiv, unei clase îi poate fi definită o metodă
specială, numită "__init__()", după cum urmează:

   def __init__(self):
       self.datele_clasei = []

Atunci când în codul unei clase este definită (și) metoda
"__init__()", instanțierea clasei respective va invoca în mod automat
această metodă pentru nou creata instanță a clasei. Așadar, în
exemplul nostru, o instanță nouă, *inițializată*, poate fi obținută
prin:

   x = ClasaMea()

Firește, metoda "__init__()" poate primi argumente, această
caracteristică sporindu-i flexibilitatea în utilizare. Într-o atare
situație, argumentele date operatorului de instanțiere a clasei îi vor
fi transmise lui "__init__()". Astfel,

   >>> class NumărComplex:
   ...     def __init__(self, partea_reală, partea_imaginară):
   ...         self.real = partea_reală
   ...         self.imaginar = partea_imaginară
   ...
   >>> x = NumărComplex(3.0, -4.5)
   >>> x.real, x.imaginar
   (3.0, -4.5)


9.3.3. Obiectele instanță
-------------------------

Odată ajunși aici, la ce putem întrebuința aceste obiecte instanță?
Singurele operații înțelese de obiectele instanță sunt referențierile
de atribute. Există două tipuri de nume de atribute valide: atributele
datelor (sau *atributele de date*) și metodele (adică, *atributele de
funcții*).

*Data attributes* correspond to "instance variables" in Smalltalk, and
to "data members" in C++.  Data attributes need not be declared; like
local variables, they spring into existence when they are first
assigned to.  For example, if "x" is the instance of "MyClass" created
above, the following piece of code will print the value "16", without
leaving a trace:

   x.contor = 1
   while x.contor < 10:
       x.contor = x.contor * 2
   print(x.contor)
   del x.contor

Celălalt fel de referențiere de atribute de care dispune instanța este
*metoda*. O metodă este o funcție care "îi aparține" unui obiect.

Numele valide de metode ale unui obiect instanță depind de clasa
acestui obiect instanță. Prin definiție, toate atributele unei clase
care sunt obiecte funcție definesc metodele corespunzătoare (omonime)
ale instanțelor clasei. Așa că, în exemplul nostru, "x.f" este o
referire validă la o metodă, deoarece "ClasaMea.f" este funcție, pe
când "x.i" nu este validă ca referire la vreo metodă pentru că
"ClasaMea.i" nu este funcție. Atenție, "x.f" nu este același lucru cu
"ClasaMea.f" --- primul este un *obiect metodă*, nu un obiect funcție.


9.3.4. Obiectele metodă
-----------------------

Adesea, o metodă va fi apelată de îndată ce a fost legată (de un
anumit obiect):

   x.f()

Dacă "x = MyClass()", ca mai sus, un atare apel va returna șirul de
caractere "'salutare, lume'". Pe de altă parte, nu este nevoie să
apelăm metodele imediat: cum "x.f" este un obiect metodă, el poate fi
stocat undeva și apelat la momentul dorit. De exemplu:

   xf = x.f
   while True:
       print(xf())

va afișa "salutare, lume" fără să se mai oprească din a da binețe.

Ce se întâmplă, cu adevărat, atunci când se apelează o metodă?
Probabil că ați remarcat faptul că "x.f()" a fost apelat, în codul de
mai sus, fără să i se dea niciun argument, chiar dacă definiția lui
"f()" specifica un anume argument. Oare ce s-a întâmplat cu acest
argument? Doar știm că Python-ul va lansa o excepție atunci când vreo
funcție care cerere argument este apelată fără niciun argument ---
chiar dacă argumentul nici măcar nu urmează să fie folosit...

Păi, se prea poate să fi ghicit, deja, răspunsul: un lucru special
privind metodele este acela că obiectul instanță îi este transmis
metodei ca *prim argument* al său. În exemplul nostru, apelul "x.f()"
este absolut echivalent cu "ClasaMea.f(x)". Practic, a apela o metodă
cu o listă de *n* argumente este totuna cu a apela funcția
corespondentă a acestei metode cu o listă de argumente formată prin
inserarea obiectului instanță (de care *aparține* metoda) înaintea
primului din cele *n* argumente.

În general, metodele funcționează după cum urmează. Atunci când un
atribut non-dată este referențiat, va fi căutată instanța clasei
(sale). Dacă numele (atributului) denotă un atribut valid al clasei
iar acest atribut este un obiect funcție, atunci referirile
(referințele) respective, atât la obiectul instanță cât și la obiectul
funcție, vor fi împachetate într-un obiect metodă. Dacă obiectul
metodă este apelat cu o listă de argumente, atunci o nouă listă de
argumente va fi construită din obiectul instanță și din elementele
listei de argumente în cauză, obiectul funcție corespunzător fiind
apelat cu această (nou formată) listă de argumente.


9.3.5. Variabile de clasă și variabile de instanță
--------------------------------------------------

În jargon POO, variabilele de instanță se referă la datele care îi
sunt unice fiecărei instanțe, respectiv variabilele de clasă se referă
la atributele și metodele comune tuturor instanțelor clasei
respective:

   class Câine:

       genul = 'canis'             # variabilă de clasă comună tuturor
                                   # instanțelor

       def __init__(self, numele):
           self.numele = numele    # variabilă de instanță, unică fiecărei
                                   # instanțe

   >>> d = Câine('Fido')
   >>> e = Câine('Amicu\'')
   >>> d.genul                     # comun tuturor câinilor
   'canis'
   >>> e.genul                     # comun tuturor câinilor
   'canis'
   >>> d.numele                    # doar al lui d
   'Fido'
   >>> e.numele                    # doar al lui e
   "Amicu'"

Precum spuneam în O vorbă despre nume și obiecte, organizarea datelor
puse în comun (comune tuturor instanțelor unei clase; sau *partajate*
de către toate instanțele unei clase; de la englezescul *shared*)
poate produce efecte surprinzătoare dacă folosim la o atare acțiune
obiecte *mutabile*, precum listele ori dicționarele de date. De
exemplu, lista *trucuri* din codul de mai jos n-ar fi trebuit
întrebuințată pe post de variabilă de clasă și aceasta pentru că va
exista o singură listă ce va fi folosită *la comun* de către toate
instanțele *Câine*:

   class Câine:

       trucuri = []            # folosită din greșeală ca variabilă de clasă

       def __init__(self, numele):
           self.numele = numele

       def adauga_trucul(self, trucul):
           self.trucuri.append(trucul)

   >>> d = Câine('Fido')
   >>> e = Câine('Amicu\'')
   >>> d.adaugă_trucul('rostogol')
   >>> e.adaugă_trucul('mort')
   >>> d.trucuri               # folosință la comun neașteptată
   ['rostogol', 'mort']

O proiectare corectă a clasei ar trebui să folosescă, în locul unei
variabile de clasă, o variabilă de instanță:

   class Câine:

       def __init__(self, numele):
           self.numele = numele
           self.trucuri = []    # creează câte o listă goală
                                # pentru fiecare câine

       def adaugă_trucul(self, trucul):
           self.trucuri.append(trucul)

   >>> d = Câine('Fido')
   >>> e = Câine('Amicu\'')
   >>> d.adaugă_trucul('rostogol')
   >>> e.adaugă_trucul('mort')
   >>> d.trucuri
   ['rostogol']
   >>> e.trucuri
   ['mort']


9.4. Observații diverse
=======================

Dacă același nume de atribut va apărea și într-o instanță și într-o
clasă, la găsirea atributului i se va da prioritate instanței:

   >>> class Depozitul:
   ...    scopul = 'înmagazinare'
   ...    regiunea = 'vestică'
   ...
   >>> d1 = Depozitul()
   >>> print(d1.scopul, d1.regiunea)
   înmagazinare vestică
   >>> d2 = Depozitul()
   >>> d2.regiunea = 'estică'
   >>> print(d2.scopul, d2.regiunea)
   înmagazinare estică

Atributele de date pot fi referențiate atât de metode cât și de
utilizatorii obișnuiți ("clienții") ai obiectului (având respectivele
atribute). Cu alte cuvinte, clasele sunt inutilizabile la
implementarea unor tipuri de *date abstracte* pure. Practic, nimic din
Python nu ne poate ajuta să asigurăm *ascunderea datelor* --- totul se
bazează pe o simplă convenție. (Pe de altă parte, implementarea de
față a Python-ului, scrisă în C, poate să mascheze în totalitate
detaliile de implementare, respectiv să controleze accesul la un
anumit obiect, atunci când este nevoie de așa ceva; o asemenea
capacitate poate fi întrebuințată de diversele extensii ale Python-
ului scrise în C.)

Clienții trebuie să folosească atributele de date cu grijă --- căci ei
pot strica *invarianții* păstrați de metode dacă își vor pune amprenta
pe atributele de date ale acestora. Nu uitați că clienții îi pot
adăuga atribute după bunul lor plac unui obiect instanță fără ca prin
aceasta să le afecteze validitatea metodelor, atâta timp cât se evită
conflictele de nume --- și aici, întrebuințarea unei convenții de nume
ne poate scăpa de o grămadă de bătăi de cap.

Nu ni se pune la dispoziție nicio scurtătură (și nici alte tehnici!)
atunci când referențiem atribute de date din interiorul unei metode.
Trebuie spus că o astfel de caracteristică a Python-ului crește
lizibilitatea codului oricărei metode: deoarece nu avem nicio șansă să
confundăm variabilele locale cu variabilele de instanță atunci când ne
aruncăm privirea peste instrucțiunile dintr-o metodă.

Adeseori, primul argument al unei metode este numit "self". Aceasta nu
este decât o convenție: numele "self" nu posedă nicio semnificație
aparte pentru Python. Remarcați, însă, că dacă nu vă veți face
obiceiul de a-l folosi, atunci s-ar putea întâmpla ca programul
dumneavoastră să le provoace dificultăți la lectură altor programatori
în Python, după cum s-ar putea și să aveți greutăți cu vreo aplicație
*cititor de clase* care se bazează tocmai pe această convenție de
nume.

Orice obiect funcție care este (și) atribut de clasă definește o
metodă pentru instanțele clasei în cauză. Nu este obligatoriu ca
definiția funcției respective să fie literalmente parte din definiția
clasei: este suficient să îi asignăm unei variabile locale din clasă
un obiect funcție. De exemplu:

   # Funcție definită în afara clasei
   def f1(self, x, y):
       return min(x, x+y)

   class C:
       f = f1

       def g(self):
           return 'salutare, lume'

       h = g

Aici, "f", "g" și "h" îi sunt cu toatele atribute clasei "C" și se
referă la obiecte funcție, din care motiv ele le vor fi metode tuturor
instanțelor lui "C" --- "h" nefiind nimic altceva decât "g". Atenție
la faptul că o astfel de abordare va reuși doar să-i provoace confuzii
vreunui cititor al programului dumneavoastră.

Metodele pot apela alte metode folosind atributele de metodă ale
argumentului "self":

   class Punga:
       def __init__(self):
           self.datele = []

       def adaugă(self, x):
           self.datele.append(x)

       def adaugă_dublu(self, x):
           self.adaugă(x)
           self.adaugă(x)

Metodele se pot referi la numele globale în același fel ca funcțiile
obișnuite. Domeniul de valabilitate global care i se asociază unei
metode este chiar modulul care conține definiția metodei. (O clasă, ca
atare, nu se folosește niciodată pe post de domeniu de valabilitate
global.) Chiar dacă nu vom avea decât arareori motive serioase să
întrebuințăm date globale în vreo metodă, există numeroase utilizări
legitime ale domeniului de valabilitate global: cum ar fi, funcțiile
și modulele importate în domeniul de valabilitate global pot fi
folosite atât de către metode cât și de către funcțiile ori clasele
care au fost definite în el. În mod obișnuit, clasa care conține o
anumită metodă este ea însăși definită în acest domeniu de
valabilitate global, iar noi vom face cunoștință în secțiunea
următoare cu mai multe motive întemeiate ca o metodă oarecare să
dorească să se refere la propria sa clasă.

Orice valoare este, în sine, un obiect și dispune, ca atare, de o
*clasă* (aceasta mai numindu-se și *tipul* valorii). Clasa respectivă
este stocată drept "obiectul_nostru.__class__".


9.5. Moștenirea
===============

Așa cum se înțelege de la sine, o caracteristică a indiferent cărui
limbaj de programare n-ar putea fi socotită "de clasă" dacă nu le-ar
face față cum se cuvine *moștenirilor* (dificultăților...). În cazul
Python-ului, sintaxa pentru definirea unei clase *derivate* (dintr-o
clasă *de bază*) arată după cum urmează:

   class NumeleClaseiDerivate(NumeleClaseiDeBază):
       <instrucțiunea-1>
       .
       .
       .
       <instrucțiunea-N>

Numele "NumeleClaseiDeBază" trebuie să fie definit într-un spațiu de
nume ce poate fi accesat din domeniul de valabilitate în care a fost
introdusă definiția clasei derivate despre care discutăm. Sunt
permise, în locul unui nume de clasă cu rolul de clasă de bază, (și)
expresii oarecare. O astfel de flexibilitate în definiții se va dovedi
folositoare atunci când, de exemplu, clasa de bază este situată în alt
modul:

   class NumeleClaseiDerivate(nume_de_modul.NumeleClaseiDeBază):

Execuția (codului) unei definiții de clasă derivată se realizează în
același fel cu execuția (codului) unei clase de bază. Atunci când este
construit obiectul clasă (derivată), interpretorul *își amintește*
(și) de clasa de bază. Această proprietate se întrebuințează la
rezolvarea referințelor de atribute: dacă vreun atribut căutat nu se
găsește în codul clasei (derivate), căutarea sa va trece la codul
clasei de bază. O atare regulă va fi aplicată *recursiv* dacă clasa de
bază se dovedește a fi, la rândul său, derivată din altă clasă.

Nimic deosebit nu se va întâmpla la instanțierea claselor derivate: ca
și până acum, "NumeleClaseiDerivate()" va crea o instanță (nouă) a
clasei respective. Referințele la metode sunt rezolvate astfel: se
caută atributul de clasă corespunzător, coborând, la nevoie, pe
*lanțul* (dependențelor) care duce până la clasele de bază, apoi,
odată găsit numele, referința metodei este considerată validă dacă ea
întoarce un obiect funcție.

Clasele derivate au dreptul să-și suprascrie metodele (moștenite) de
la clasele de bază. Deoarece metodele nu beneficiază de privilegii
anume atunci când apelează metode ale aceluiași obiect, o metodă
aparținând unei clase de bază care apelează altă metodă definită în
aceeași clasă de bază poate avea surpriza că a apelat, de fapt, metoda
unei clase derivate care a suprascris metoda (teoretic) apelată.
(Pentru programatorii în C++: toate metodele din Python sunt efectiv
"virtuale".)

O metodă suprascrisă într-o clasă derivată dorește, cel mai adesea, să
extindă și nu să înlocuiască pur și simplu metoda omonimă din clasa de
bază. Dispunem de un procedeu necomplicat pentru a apela în mod direct
metoda unei clase de bază: apelați, de-a dreptul,
"NumeleClaseiDeBază.numele_metodei(self, restul_argumentelor)". O
atare proprietate le folosește și clienților. (Țineți seama de faptul
că ea funcționează doar atunci când clasa de bază este accesibilă sub
denumirea de "NumeleClaseiDeBază" în domeniul de valabilitate global.)

Python-ul are două funcții predefinite care conlucrează cu moștenirea:

* Utilizați "isinstance()" pentru a verifica tipul unei instanțe:
  "isinstance(obiectul_în_cauză, int)" va întoarce "True" dacă și
  numai dacă "obiectul_în_cauză.__class__" este fie "int" fie altă
  clasă derivată din "int".

* Întrebuințați "issubclass()" pentru a testa moștenirea unei clase:
  "issubclass(bool, int)" va returna "True" pentru că "bool" este o
  *subclasă* (adică, o *moștenitoare*; sau o *urmașă*) a lui "int". În
  schimb, "issubclass(float, int)" va întoarce "False" dat fiind că
  "float" nu îi este subclasă lui "int".


9.5.1. Moștenirea multiplă
--------------------------

Python-ul permite și o anumită formă de moștenire multiplă. Definiția
unei clase care moștenește *mai multe* clase de bază arată în felul
următor:

   class NumeleClaseiDerivate(ClasaDeBază1, ClasaDeBază2, ClasaDeBază3):
       <instrucțiunea-1>
       .
       .
       .
       <instrucțiunea-N>

În cele mai multe dintre situațiile obișnuite (a se citi *simple*), ne
putem imagina *căutarea* unui atribut moștenit de la o clasă părinte
ca efectuându-se în adâncime (de la englezescul, în jargon informatic,
*depth-first*), *de la stânga la dreapta*, fără să se caute de două
ori în aceeași clasă în cazul unor *suprapuneri* în ierarhie (adică,
în ansamblul claselor legate între ele prin mecanismele moștenirii).
Astfel, dacă atributul căutat nu este găsit în "NumeleClaseiDerivate",
el va fi căutat (mai întâi) în clasa de bază "ClasaDeBază1", apoi
(recursiv) în clasele de bază ale lui "ClasaDeBază1", după care, dacă
nu a fost găsit încă, va fi căutat în "ClasaDeBază2" șamd.

În realitate, procedurile sunt oarecum mai complexe decât schița
anterioară; ordinea (căutărilor) în procedura de *rezolvare* a unei
metode se schimbă în mod dinamic pentru a permite apeluri cooperatiste
ale lui "super()". O atare abordare este cunoscută în vocabularul unor
limbaje de programare care implementează moștenirea multiplă drept
*apelul metodei următoare* și dovedește mai multă eficacitate decât
*apelul lui super* utilizat în limbajele de programare care
întrebuințează doar moștenirea simplă.

Ordonarea dinamică este necesară pentru că în toate cazurile de
moștenire multiplă survin relațiile în formă de diamant dintre clase
(în care cel puțin una din clasele părinte va putea fi accesată de
către măcar o clasă urmaș prin *cel puțin două drumuri* care să plece
din clasa părinte, să parcurgă un subset de muchii ale arborelui
moștenirii și să se încheie la clasa urmaș respectivă). De exemplu,
toate clasele moștenesc clasa "object", din care motiv, în cazul unei
moșteniri multiple, vor exista mai multe căi de acces la "object".
Pentru a împiedica accesul la indiferent care clasă de bază pe mai
multe căi (deci *de mai multe ori* într-o singură căutare), algoritmul
dinamic de căutare va liniariza ordinea acesteia în așa fel încât să
fie păstrată ordonarea de la stânga la dreapta specificată pentru
fiecare clasă, ordonare care apelează fiecare părinte o singură dată
și care este *monotonă* (adică, în raport cu care o clasă poate fi
făcută subclasă a cuiva fără să fie afectată ordonarea realizată până
la momentul respectiv a *strămoșilor* ei). Luate împreună, aceste
particularități ale Python-ului ne permit să proiectăm ierarhii de
clase stabile și ușor de extins în care să avem moștenire multiplă.
Pentru mai multe detalii, a se vedea Ordinea rezolvării metodelor în
Python 2.3.


9.6. Variabile private
======================

În Python nu există variabile de instanță "private", adică variabile
care să nu poată fi accesate decât din interiorul instanței
respective. Cu toate acestea, dispunem de o convenție respectată de
aproape întreg codul Python: orice nume prefixat de o *bară jos*
(adică, de o *liniuță de subliniere*; precum în "_șuncă") trebuie
socotit drept parte non-publică a API-ului (indiferent dacă este vorba
de o funcție, de o metodă ori de o dată membru). Numele respectiv
trebuie, așadar, tratat cu grija pe care o avem pentru un detaliu de
implementare, despre care știm că s-ar putea schimba oricând, fără să
fim anunțați.

Deoarece există, în practica POO, un caz de întrebuințare valid pentru
membrii privați ai unei clase (mai precis, procedeul prin care să
evităm *conflictele de nume* cu numele definite de clasele urmaș),
avem la îndemână în Python un suport restrâns pentru un atare
mecanism, denumit *transformare de nume* (de la englezescul *name
mangling*). Astfel, orice identificator de forma "__șuncă" (cel puțin
două bare jos pe post de prefixe și cel mult o bară jos pe post de
sufix) va fi înlocuit în textul programului cu "_numeleclasei__șuncă",
unde "numeleclasei" desemnează numele de clasă curent, cu prefixul ori
prefixele eliminat(e). Această transformare de nume se va realiza
indiferent de poziția sintactică a identificatorului, atâta timp când
aceasta se găsește în interiorul unei definiții de clasă.

Vezi și:

  Specificațiile transformărilor de nume private pentru detalii și
  cazuri speciale.

Transformarea de nume este utilă la a le permite subclaselor să
suprascrie diverse metode fără să strice apelurile de metode intra-
clasă. Ca exemplu:

   class Mapare:
       def __init__(self, iterabilul):
           self.lista_de_itemi = []
           self.__actualizează(iterabilul)

       def actualizează(self, iterabilul):
           for itemul in iterabilul:
               self.lista_de_itemi.append(itemul)

       __actualizează = actualizează   # copie privată a metodei
                                       # actualizează() originale

   class SubclasaLuiMapare(Mapare):

       def actualizează(self, cheile, valorile):
           # îi oferă o nouă signatură lui actualizează()
           # fără să-l strice pe __init__()
           for itemul in zip(cheile, valorile):
               self.lista_de_itemi.append(itemul)

Exemplul de deasupra va funcționa chiar și în cazul când în
"SubclasaLuiMapare" am introduce un identificator "__actualizează" și
aceasta pentru că identificatorul în cauză va fi înlocuit cu
"_Mapare__actualizează" în clasa "Mapare", respectiv cu
"_SubclasaLuiMapare__actualizează" în clasa "SubclasaLuiMapare".

Nu uitați că regulile de transformare ale numelor au fost proiectate
mai ales pentru a ne feri de accidente; este posibil, în pofida lor,
să accesați, respectiv să modificați orice variabilă socotită drept
*privată*. O asemenea libertate ne poate fi de folos în circumstanțe
deosebite, precum cele ale întrebuințării unui depanator de programe
(de la englezescul *debugger*).

Țineți seama (și) de faptul că, indiferent de cod îi transmiteți fie
lui "exec()" fie lui "eval()", acesta nu va considera numele de clasă
al clasei care a făcut invocarea (uneia din cele două funcții) drept
numele clasei curente; o atare situație seamănă cu efectul produs de
execuția instrucțiunii "global", efect care este restrâns doar la
codul compilat într-un singur fragment de cod-de-octeți. Aceeași
restricție le privește și pe "getattr()", "setattr()" și "delattr()",
precum și pe orice referențiere directă a lui "__dict__".


9.7. Șurubărie
==============

Ne servește uneori să avem la îndemână un tip de date asemănător celui
numit "record" în Pascal, respectiv lui "struct" din C, tip care să
pună laolaltă mai mulți itemi de date cu denumiri (individuale).
Procedeul idiomatic pentru așa ceva (în Python) este dat de
"dataclasses":

   from dataclasses import dataclass

   @dataclass
   class Angajatul:
       numele: str
       departamentul: str
       salariul: int

   >>> popescu = Angajatul('popescu', 'sala de calculatoare', 1000)
   >>> popescu.departamentul
   'sala de calculatoare'
   >>> popescu.salariul
   1000

Unui fragment de program Python care se așteaptă să primească un
anumit tip de date abstracte îi putem transmite, de cele mai multe
ori, pe post de înlocuitor o clasă care *emulează* metodele tipului de
date respectiv. De exemplu, admițând că dispunem de o funcție care
formatează diverse date preluate dintr-un obiect fișier, putem defini
mai întâi o clasă căreia să-i aparțină metodele "read()" și
"readline()", metode capabile să extragă datele dintr-o memorie-tampon
(de la englezescul *buffer*) dedicată stocării (temporare a) șirurilor
de caractere, după care îi putem transmite funcției de formatare
această clasă în calitate de argument.

Obiectele metodă de instanță posedă, și ele, atribute:
"metoda.__self__" este obiectul instanță căruia îi aparține metoda
"metoda()", respectiv "metoda.__func__" este obiectul funcție care îi
corespunde metodei în cauză (adică, lui "metoda()").


9.8. Iteratori
==============

Deja ați remarcat, probabil, că majoritatea obiectelor *container* pot
fi inspectate în mod iterativ (de la englezescul, în jargon
informatic, *loop over*; vom întrebuința și constructele *iterate de-a
lungul*, respectiv *iterate în lung*) folosind o instrucțiune de
ciclare "for":

   for elementul in [1, 2, 3]:
       print(elementul)
   for elementul in (1, 2, 3):
       print(elementul)
   for cheia in {'unu':1, 'doi':2}:
       print(cheia)
   for cheia in "123":
       print(cheia)
   for rândul in open("fișierul_meu_text.txt"):
       print(rândul, end='')

Acest stil de acces la itemi este clar, concis și convenabil.
Utilizarea iteratorilor constituie o practică sistematică, cu caracter
unificator, în Python. În culise, instrucțiunea "for" face apel la
"iter()" având drept argument obiectul container. Funcția va întoarce
un obiect *iterator*, care definește metoda "__next__()", metodă ce
accesează în mod secvențial elementele containerului respectiv. Atunci
când nu mai întâlnește niciun item (al containerului), "__next__()" va
lansa o excepție "StopIteration" pentru a-i spune buclei "for" să se
încheie. Puteți apela metoda "__next__()" cu ajutorul funcției
prestabilite "next()"; exemplul următor vă dezvăluie cum trebuie
procedat:

   >>> șirul = 'abc'
   >>> iteratorul = iter(șirul)
   >>> iteratorul
   <str_iterator object at 0x10c90e650>
   >>> next(iteratorul)
   'a'
   >>> next(iteratorul)
   'b'
   >>> next(iteratorul)
   'c'
   >>> next(iteratorul)
   Traceback (most recent call last):
     File "<stdin>", line 1, in <module>
       next(iteratorul)
   StopIteration

Le putem acum, dat fiind că tocmai am văzut mecanismul prin care se
pune în practică *protocolul* iteratorilor, adăuga un *comportament*
de iterator claselor noastre. Este suficient să definim o metodă
"__iter__()", care să întoarcă un obiect ce posedă o metodă
"__next__()". În caz că clasa noastră îl definește ea însăși pe
"__next__()", metoda "__iter__()" nu mai are de făcut decât să-l
returneze pe "self" (apelantului):

   class ÎntorsulPeDos:
       """Un iterator pentru a itera de-a lungul unei secvențe,
          în sens invers."""
       def __init__(self, datele):
           self.datele = datele
           self.indexul = len(datele)

       def __iter__(self):
           return self

       def __next__(self):
           if self.indexul == 0:
               raise StopIteration
           self.indexul = self.indexul - 1
           return self.datele[self.indexul]

   >>> reversul = ÎntorsulPeDos('arca')
   >>> iter(reversul)
   <__main__.ÎntorsulPeDos object at 0x00A1DB50>
   >>> for caracterul in reversul:
   ...     print(caracterul)
   ...
   a
   c
   r
   a


9.9. Generatori
===============

*Generatorii* sunt o unealtă puternică, ușor de folosit la construcția
de iteratori. Codul Python al generatorilor este asemeni celui al unei
funcții obișnuite cu excepția faptului că întrebuințează instrucțiunea
"yield" (în loc de *return*) ori de câte ori trebuie întoarse date
(apelantului). De fiecare dată când un generator va fi (re)folosit
drept argument al lui "next()", *el își va continua activitatea* (dat
fiind că un generator *își amintește* toate valorile datelor precum și
ultima instrucție executată de la apelul precedent al lui *next*).
Exemplul dat în continuare vă arată că generatorii sunt banal de
creat:

   def întoarce_pe_dos(datele):
       for indexul in range(len(datele)-1, -1, -1):
           yield datele[indexul]

   >>> for caracterul in intoarce_pe_dos('trop'):
   ...     print(caracterul)
   ...
   p
   o
   r
   t

Orice poate fi realizat folosind iteratorii bazați (explicit) pe
clase, vezi cele prezentate în secțiunea anterioară, poate fi obținut
și cu generatori. Ceea ce face codul acestora din urmă atât de compact
este faptul că metodele "__iter__()" și "__next__()" sunt create
automat.

Altă trăsătură cheie a generatorilor este aceea că atât variabilele
locale cât și starea de la momentul execuției sunt salvate automate
între apeluri. Din acest motiv, codul funcției (generator) este mai
ușor de scris și mult mai clar în exprimarea ideilor decât ceea ce
putem realiza cu o abordare (complicată) în care avem de folosit
variabile de instanță precum "self.indexul" și "self.datele".

În afară de creatul automat de metode și de salvatul stării
programului, generatorii au proprietatea că, atunci când își termină
iterarea, vor lansa automat o "StopIteration". Luate împreună, aceste
caracteristici ne permit să construim iteratori cu același efort ca la
scrierea codului unei funcții oarecare.


9.10. Expresii generator
========================

Generatorii simpli pot fi introduși succint în codul Python ca
*expresii* (generator) a căror sintaxă seamănă cu cea de la
comprehensiunea listelor, excepție făcând faptul că acolo erau
folosite paranteze drepte pe când aici vom întrebuința paranteze
rotunde. Astfel de expresii sunt proiectate pentru situații când este
nevoie de un generator (simplu) care să fie utilizat de îndată (ce a
fost citit de interpretor) de către funcția în codul căreia a fost
inserat. Expresiile generator sunt (încă și) mai compacte deși mai
puțin versatile decât definițiile complete de generatori, respectiv
tind să fie mai prietenoase în privința consumului de memorie decât
comprehensiunile de liste corespondente.

Exemple:

   >>> sum(i*i for i in range(10))      # sumă de pătrate perfecte
   285

   >>> vectorul_x = [10, 20, 30]
   >>> vectorul_y = [7, 5, 3]
   >>> sum(x*y for x,y in
   ...     zip(vectorul_x, vectorul_y)) # produs scalar
   260

   >>> cuvinte_unice = set(cuvântul for rândul in pagina
   ...                     for cuvântul in rândul.split())

   >>> șef_de_promoție = max((studentul.media_generală, studentul.numele)
   ...                       for studentul in absolvenți)

   >>> datele = 'trop'
   >>> list(datele[i] for i in range(len(datele)-1, -1, -1))
   ['p', 'o', 'r', 't']

-[ Note de subsol ]-

[1] Cu o excepție. Obiectele modul au un atribut *secret*, doar-de-
    citit, numit "__dict__", atribut care returnează dicționarul de
    date utilizat la implementarea spațiului de nume al modulului;
    numele "__dict__" este atribut, fără a fi (și) un nume global.
    Categoric, întrebuințarea sa violează caracterul de *abstracțiune*
    al implementării spațiilor de nume, motiv pentru care ea trebuie
    rezumată doar la activități precum depanarea post-mortem a
    programelor Python.
