Dziedziczenie klas

Pojęcie dziedziczenia klas jest fundamentalne dla programowania obiektowego. Bez dziedziczenia nie wyszlibyśmy poza etap zaawansowanych struktur. Dziedziczenie umożliwia dodawanie nowej funkcjonalności do klas już napisanych. Nowe klasy mają to, co miała klasa bazowa plus coś jeszcze. Na przykład atom jest nie tylko wektorem w 3D, ma też swoją nazwę. Indeks w zasadzie nie jest konieczny dla atomu, ale programy do modelowania w zasadzie nie mogą się bez tego obyć:

1class Atom(Vec3):
2
3  def __init__(self, id, name, x = 0, y = 0, z = 0) :
4    super.__init__(x,y,z)
5    self.__id = id
6    self.__name = name

Dla porównania analogiczny kod w C++:

1class Atom : public Vec3 {
2public:
3
4  Atom(const int id, const std::string & name, float x, float y, float z ) :
5     Vec3(x,y,z) { id_=id; name_=name; }
6
7  Atom(const int id, const std::string & name) :
8     Vec3(0,0,0) , name_(name), id(id) {}
9}

Klasę Vec3 nazwiemy klasą bazową albo nadklasą; klasę Atom zaś klasą potomną albo podklasą. Klasa Atom dziedziczy po klasie Vec3 wszystkie jej publiczne metody i pola. Dzięki temu Atom ma współrzędne oraz umie policzyć długość swojego wektora (odziedziczył length()).

W linii 4 kodu w Pythonie następuje wywołanie konstruktora klasy bazowej. W tym momencie klasa Vec3, a zatem i klasa Atom, uzyskuje pola __x,``__y`` oraz __z. Instrukcja ta jest poprawna dla Pythona w wersji 3.x. Skrypty napisane dla Pythona 2.x powinny wywoływać konstruktor klasy bazowej jak poniżej:

1class Atom(Vec3):
2
3  def __init__(self, id, name, x = 0, y = 0, z = 0) :
4    super(Vec3, self).__init__()
5    self.__id = id
6    self.__name = name
Zwiększanie funkcjonalności klasy bazowej

W powyższym przypadku klasa bazowa Vec3 dostała nowe ficzery, co zrobiło z niej Atom. Każdy atom jest wektorem, ale nie każdy wektor to atom.

Pełna wersja klasy Atom znajduje się poniżej:

(rozwiń kod klasy Atom)
 1from Vec3 import Vec3
 2
 3
 4class Atom(Vec3):
 5
 6    def __init__(self, atom_no, name, *args):
 7        """Represents a chemical atom"""
 8        super(Atom, self).__init__(*args)
 9        self.__id = atom_no
10        self.__name = name
11
12    def to_pdb(self):
13        return "ATOM   %4d %4s  ARG A   3    %8.3f%8.3f%8.3f  0.50 35.88           N" % \
14            (self.__id, self.__name, self.x, self.y, self.z)
15
16    @property
17    def name(self):
18        return self.__name
19    
20    @property
21    def element(self):
22        return self.__element
23
24
25def atoms_from_pdb(pdb_fname):
26    atoms = []
27    for atom_line in open(pdb_fname):
28        atom_number = int(atom_line[6:11].strip())
29        x_position = float(atom_line[30:38].strip())
30        y_position = float(atom_line[38:47].strip())
31        z_position = float(atom_line[47:54].strip())
32        atom_name = atom_line[12:16].strip()
33        atoms.append(Atom(atom_number, atom_name, x_position, y_position, z_position))
34    return atoms
35
36
37class ChargedAtom(Atom):
38    """represents an atom with a charge (might be fractional)"""
39    def __init__(self, atom_no, name, q):
40        super(ChargedAtom, self).__init__(atom_no, name)
41        self.__q = q
42
43
44if __name__ == "__main__" :
45    v = Vec3(1.3, 2.4, 3.4)
46    a = Atom(1, " CA ")
47    qa = ChargedAtom(1, " NZ ", 1.0)
48
49    # ---------- Atoms behave like vectors: you can add them, or add a vector to an atom
50    print(a.to_pdb())
51    a += v
52    print(a.to_pdb())
53
54    v_cm = Atom(1, "C")
55    atoms = atoms_from_pdb("surpass.pdb")
56    for atom in atoms: v_cm += atom
57    v_cm *= 1.0 / len(atoms)
58    print("center of mass:", v_cm)

Prosta symulacja - oscylator harmoniczny

Dla przykładu napiszmy prosty skrypt, który modeluje oscylator harmoniczny w jednym wymiarze: Na ciało o masie m działa siła \(-k x\), na wskutek której oscyluje ono wokół położenia x0. Proces ten opisany jest równaniem ruchu:

\[m\ddot{x} = - kx\]

Równanie to rozwiązuje numerycznie (całkuje) poniższy skrypt:

 1mass =  1.0
 2x0   = 10.0
 3k    = 5.0
 4dt   = 0.001
 5x    = 3.0
 6v    = 0.0
 7t    = 0
 8for i in range(100) :
 9    for i in range(100):
10        a = -k * (x-x0) / (mass*2.0)
11        v = v + a*dt
12        x = x + v*dt
13        t += dt
14    print(t,x)

Wersja obiektowa będzie dużo dłuższa, ale za to łatwiejsza w rozbudowie. Zastanówmy się najpierw, jakie elementy powinniśmy reprezentować w programie?

system

reprezentuje tu modelowany obiekt - oscylator harmoniczny (klasa Harmonic1DSystem)

solver

rozwiązuje równanie ruchu; tu akurat jest to klasa EulerIntegrator całkująca wg schematu Eulera

observer

odpowiada za wydruk wyników na ekran

 1class Harmonic1DSystem:
 2
 3    def __init__(self):
 4        self.x = 0
 5        self.v = 0
 6        self.k = 5.5
 7        self.x0 = 10
 8        self.mass = 1
 9
10    @property
11    def a(self):
12        return -self.k * (self.x - self.x0) / (self.mass * 2.0)
13
14
15class EulerIntegrator:
16
17    def __init__(self, my_system):
18        self.system = my_system
19        self.observers = []
20        self.dt = 0.001
21
22    def add_observer(self, o):
23        self.observers.append(o)
24
25    def run(self, n_inner_steps, n_outer_steps):
26
27        for j in range(n_outer_steps):
28            for i in range(n_inner_steps):
29                self.system.v += self.system.a * self.dt
30                self.system.x += self.system.v * self.dt
31            for o in self.observers:
32                o.observe()
33
34class ObserveToScreen (Observer):
35
36    def __init__(self, object):
37        self.o = object
38
39    def observe(self):
40        print(self.o.x)
41
42
43if __name__ == "__main__":
44
45    system = Harmonic1DSystem()
46    ene = ObserveToScreen(system)
47    s = EulerIntegrator(system)
48    s.add_observer(ene)
49    s.run(10, 10)
50    print(mmx.min_x, mmx.max_x)
51
52    for obs in s.observers: obs.finalize()
53

Zaletą obiektowości jest to, że można łatwo wymieniać poszczególne komponenty programu na inne, zmieniając w ten sposób jego zachowanie. Na przykład rolą ObserveToScreen jest drukowanie aktualnego położenia oscylatora na ekranie. Łatwo można zmienić wyniki produkowane przez symulację, zmieniając observer na inny, np taki, który obserwuje tylko zakres położenia (jego minimalną i maksymalną wartość) albo obserwer, który nagrywa wyniki do pliku.

 1class Observer:
 2
 3    def observe(self):
 4        pass
 5
 6    def finalize(self):
 7        pass
 8
 9
10class ObserveToScreen (Observer):
11
12    def __init__(self, object):
13        self.o = object
14
15    def observe(self):
16        print(self.o.x)
17
18
19class ObserveToFile (Observer):
20
21    def __init__(self,object, fname):
22        self.o = object
23        self.__file = open(fname,"w")
24
25    def observe(self):
26        print(self.o.x, file=self.__file)
27
28    def finalize(self): self.__file.close()
29
30
31class MinMaxObserver (Observer):
32
33    def __init__(self, object):
34
35        self.o = object
36        self.min_x = self.o.x
37        self.max_x = self.o.x
38
39    def observe(self):
40            if self.o.x > self.max_x : self.max_x = self.o.x
41            if self.o.x < self.min_x : self.min_x = self.o.x
42

Wszystkie obserwery dziedziczą po klasie bazowej Observer, która definiuje ich interfejs, czyli publiczne metody które one udostępniają. Są to metody observe(), która dokonuje obserwacji, oraz finalize(), która kończy obserwacje. Ta ostatnia może zamykać plik, rysować wykres, itp. Wszystkie klasy muszą mieć ten sam interfejs, a więc obie w/w metody. Nie każdy obserwer potrzebuje metody finalize(), dlatego klasa bazowa dostarcza domyślną implementację, która po prostu nic nie robi (instrukcja pass). Tylko dzięki temu poniższa linia programu działa poprawnie:

1    for obs in s.observers: obs.finalize()

Gdyby choć jeden z obiektów na liście nie miał metody finalize(), program zrzuciłby wyjątek.