2022-01-02 / Bartłomiej Kurek
OOP: Programowanie zorientowane na obiekty.

W tym artykule wyprowadzamy od podstaw istotę programowania w paradygmacie obiektowym, a zagadnienia omawiamy konstruktywnie. Paradygmat object oriented to szeroki zakres pojęć i wiedzy, tutaj zaczynamy podstawy: dlaczego, skąd, jak i po co. Przykłady kodu obejmują języki Python, C, C++.

img-full

img-full
Repozytorium git.

Model

Mamy punkt. Co o nim możemy powiedzieć? Hmmm... chyba nic. Nie znamy żadnej jego cechy, być może w ogóle nie istnieje. "Punkt". Umieśćmy go "gdzieś", w jakimś układzie współrzędnych, np. na kartce papieru w kratkę. Jeśli punkt jest gdzieś, to teraz ma jakieś cechy.

x = 1
y = 1

W komputerze to liczby, zatem w języku C napisalibyśmy:

int x = 1;
int y = 1;

Oto punkt. Na "kartce papieru" wygląda tak:

img

Ten punkt nie ma nazwy. Dla dalszych rozważań nazwijmy go E (Earth). Chcielibyśmy teraz mieć kolejny punkt S (Sun).

Ex = 1
Ey = 1

Sx = 0
Sy = 0

img

Mamy już 4 zmienne, a tylko 2 punkty. Co jeśli zechcielibyśmy mieć jeszcze kilka innych punktów: Mercury, Venus, Mars, Jupiter, Saturn, Uran, Neptune, Pluto?
Jeśli będziemy po prostu dokładać kolejne zmienne, to szybko się pogubimy. Moglibyśmy wykorzystać jakiś inny typ danych - np. listę w Python, tablicę w C.

E = [1, 1]
S = [0, 0]
...

W języku C:

int E[] = {1, 1};
int S[] = {0, 0};
// ...

Do współrzędnych tych punktów odnosilibyśmy się przez indeksy: E[0], E[1], S[0], S[1]. Ale to daje nam możliwość wyrażenia jedynie współrzędnych tych punktów. Chcielibyśmy jeszcze zapisać ich kolory, czy inne cechy (np. masę /punkt materialny/). Jeśli zapiszemy przykładowo:

E = [1, 1, "blue", (5.972 * 10 ** 24)]
S = [0, 0, "orange", (1.989 * 10 ** 30)]
...

... to będziemy musieli pamiętać, że pierwsze dwie cechy to współrzędne, trzecia z nich to kolor ("napis"), a czwarta to masa. W dodatku dane te są różnych typów.
O ile w Python jest to możliwe do zapisania w ten sposób, to już w języku C elementy tablicy muszą mieć taki sam typ. Jeśli tablica jest typu int, to elementy mogą być tylko typu int.

int E[] = {1, 1, /* ??? */, /* double? */};
int S[] = {0, 0, /* ??? */, /* double? */};

Oczywiście - te współrzędne (coordinates) będą innego typu jeśli zechcemy umieścić punkt np. w x = 0.005. Typ będzie zależał od skali/jednostek w jakich operujemy. Jeśli punktów mielibyśmy wiele, to zmianę typu współrzędnych musielibyśmy wykonać w wielu miejscach. Brzmi to jak mozolne "kopiuj-wklej". Możemy zatem poszukać jakiegoś bardziej wyrafinowanego mechanizmu zapisu danych punktu. W Python moglibyśmy użyć np. słowników:

E = {
    "x": 1.0,
    "y": 1.0,
    "color": "blue",
    "mass": 5.972 * 10 ** 24
}

S = {
    "x": 0.0,
    "y": 0.0,
    "color": "orange",
    "mass": 1.989 * 10 ** 30
}

...

Odnosilibyśmy się do tych cech składowych poprzez nazwy: E["x"], E["mass"], S["x"], S["mass"].
W języku C użylibyśmy w tym celu struktury:

struct Point
{
    double x;
    double y;
    char*  color;
    long double mass; 
};

struct Point E = {1.0, 1.0, "blue", (5.972 * pow(10, 24))};
struct Point S = {0.0, 0.0, "orange", (1.989 * pow(10, 30))};

Do składowych (members) tej struktury odnosilibyśmy się już poprzez: E.x, E.mass, czy dla punku S: S.x, S.mass.
Mamy więc już pewne złożone typy i obiekty. Tutaj w języku C typem jest "Point", a konkretnymi obiektami (instancjami) są punkty E oraz S.
W kodzie Python powyżej mamy obiekty E i S, które są typu dict (słownik).
Typ pozwala nam ująć cechy/naturę bytu (ens, entity), o którym rozmawiamy. Konkretne egzemplarze utworzone z definicji takiego typu to właśnie poszczególne "obiekty".

Zachowania

Natura jest dynamiczna. Oprócz samych cech byty posiadają również zachowania. Coś robią, coś się z nimi dzieje pod wypływem różnych czynników. Takie punkty jak nasze E i S moglibyśmy chcieć umieścić w innej lokalizacji na kartce papieru, "gdzieś indziej". Zachowania to funkcje (robią coś), a operują na obiektach (robią coś z czymś). Obiekty to rzeczowniki, zachowania to czasowniki.

W Pythonie mamy obecnie nasz obiekt E, który jest słownikiem:

>>> E
{'x': 1.0, 'y': 1.0, 'color': 'blue', 'mass': 5.972e+24}

Natura chciałaby przesunąć taki punkt. Dodamy zachowanie natury - funkcję move():

def move(p, dx, dy):
    p["x"] += dx
    p["y"] += dy

Teraz możemy zmieniać położenie punktów:

>>> E
{'x': 1.0, 'y': 1.0, 'color': 'blue', 'mass': 5.972e+24}
>>> move(E, 7, 13)
>>> E
{'x': 8.0, 'y': 14.0, 'color': 'blue', 'mass': 5.972e+24}
>>> S
{'x': 0.0, 'y': 0.0, 'color': 'orange', 'mass': 1.9890000000000002e+30}
>>> move(S, 0.000001, 0.000002)
>>> S
{'x': 1e-06, 'y': 2e-06, 'color': 'orange', 'mass': 1.9890000000000002e+30}

To samo możemy zrobić w C:

void move(struct Point* p, double dx, double dy)
{
    p->x += dx;
    p->y += dy;
}

move(&E, 3.14, 6.28);

Tutaj do funkcji przekazujemy już jakiś złożony obiekt umieszczony gdzieś w pamięci komputera, zatem do funkcji możemy przekazać po prostu adres tego miejsca w pamięci. Dlatego pierwszym argumentem tej funkcji jest struct Point* (wskaźnik na adres), a przy wywołaniu przekazujemy &E (ta "kaczka" to właśnie adres /liczba/ tego obszaru pamięci). Bez tego zabiegu podczas wywołania funkcji cały obiekt E musiałby zostać skopiowany, a nie ma takiej potrzeby. W Pythonie dzieje się podobnie - interpreter Python "pod spodem" zna adres słownika i do funkcji przekazuje również jedynie ten adres (referencja). W Pythonie po prostu nie musimy się tym przejmować, ale mechanizm jest identyczny, interpreter Python to program w C.

Metody

W programie możemy mieć jednak obiekty różnych typów, które mają swoje zachowania move(). Moglibyśmy np. przesuwać sam układ współrzędnych, który przechowuje swoje cechy pod innymi nazwami i najpewniej w innych typach danych. Ciężko byłoby tworzyć programy ze wspólną funkcją move(), która miałaby działać z wszelkimi typami. Musielibyśmy w funkcji przyjmować różne argumenty, a następnie w wielu "ifach" wykonywać różne operacje. Nie możemy też zdefiniować funkcji o tej samej nazwie wielokrotnie. Każda nowa definicja zastąpi poprzednią:

>>> move
<function move at 0x7f60a881a160>
>>> def move(coord_system): ...
... 
>>> move
<function move at 0x7f60a881a0d0>

Teraz move() to inna funkcja, gdzie indziej w pamięci, a więc ma też inny adres.
Dążymy zatem do powiązania zachowania z konkretnym typem. Niech Point ma swoje move(), a inne typy niech mają swoje move(). Jak to zapisać?

Funkcje to po prostu bloki w pamięci, w których umieszczone są instrukcje. Nazwy funkcji/zmiennych to tylko wygodne aliasy dla nas. Pod nazwą funkcji kryje się tak naprawdę adres tego bloku z instrukcjami (w Python: obiektu funkcji). Niemniej, ostatecznie są to więc wskaźniki. Funkcje zresztą nie muszą mieć żadnych nazw.

>>> lambda: ...
<function <lambda> at 0x7f60a881a160>

Ot, anonimowa funkcja. Możemy przypisać taką funkcję do wygodnej nazwy:

>>> move = lambda *args, **kwargs: print("Moving", args, kwargs)
>>> move
<function <lambda> at 0x7f60a881a160>

Spróbujmy zatem do naszego punktu E wpisać tak funkcję:

>>> E["move"] = lambda obj, dx, dy: obj.update(x=obj["x"] + dx, y=obj["y"] + dy)
>>> E
{'x': 8.0, 'y': 14.0, 'color': 'blue', 'mass': 5.972e+24, 'move': <function <lambda> at 0x7f60a881a0d0>}

Mamy zatem w naszym obiekcie funkcję!
Możemy teraz wywołać tę funkcję wpisaną do E["move"] i przekazać jej jako pierwszy argument właśnie to E (ten słownik):

>>> E["move"](E, 19, 27)
>>> E
{'x': 27.0, 'y': 41.0, 'color': 'blue', 'mass': 5.972e+24, 'move': <function <lambda> at 0x7f60a881a0d0>}

Współrzędne x i y punktu E zostały zaktualizowane. Jeśli jednak chcielibyśmy aby punkt S również posiadał taką funkcję, to musielibyśmy osobno wykonać podobny zapis do słownika S.

W języku C możemy postąpić podobnie umieszczając w strukturze Point wskaźnik na funkcję zdefiniowaną poza tą strukturą. Przeanalizujmy cały przykład w C:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <math.h>
#include <stdio.h>


struct Point
{
    double x;
    double y;
    char*  color;
    long double mass;

    void (*move)(struct Point*, double, double);  /* function pointer */

};

void move(struct Point* p, double dx, double dy)
{
    p->x += dx;
    p->y += dy;
}

int main()
{
    struct Point E = {1.0, 1.0, "blue", (5.972 * pow(10, 24))};
    E.move = move;

    struct Point S = {0.0, 0.0, "orange", (1.989 * pow(10, 30))};
    S.move = move;

    printf("E x: %f, y: %f\n", E.x, E.y);
    E.move(&E, 3.14, 6.28);
    printf("E x: %f, y: %f\n", E.x, E.y);

    printf("S x: %f, y: %f\n", S.x, S.y);
    S.move(&S, 0.007, 0.00009);
    printf("S x: %f, y: %f\n", S.x, S.y);

    return 0;
}

Kluczową sprawą jest tutaj fakt, że funkcja move() (linia 16) zdefiniowana jest poza strukturą Point, a struktura umożliwia po prostu przypisanie adresu bloku pamięci (kodu funkcji, wskaźnik w linii 12). Istnieje tylko jedna kopia tej funkcji move(), a obiekty E oraz S używają jej przekazując siebie samych (linie 31, 35) jako jej pierwszy argument. Nic nie stoi na przeszkodzie, by S.move move działało na E. Czyli:

    S.move(&E, 0.333, 0.444);
    printf("E x: %f, y: %f  # S.move(&E...)\n", E.x, E.y);

    /* this is the same function! */
    move(&E, -0.332, -0.443);
    printf("E x: %f, y: %f  #   move(&E...)\n", E.x, E.y);

Po uruchomieniu:

$ gcc solar.c
$ ./a.out
E x: 1.000000, y: 1.000000
E x: 4.140000, y: 7.280000
S x: 0.000000, y: 0.000000
S x: 0.007000, y: 0.000090
E x: 4.473000, y: 7.724000  # S.move(&E...)
E x: 4.141000, y: 7.281000  #   move(&E...)

W Pythonie można to zaprezentować bardzo podobnie na trywialnym kodzie używając choćby typów wbudowanych. Najpierw wywołujemy s.replace() na obiekcie s typu str, a drugim razem statycznie str.replace() przekazując obiekt s.

>>> s = "hello"
>>> s.replace("h", "H")
'Hello'
>>> str.replace(s, "l", "L")
'heLLo'

Różnicą pomiędzy funkcją a metodą jest jej związanie z konkretnym obiektem. Funkcje to funkcje, a metody to funkcje związane (bound method).

Metody związane z obiektem

Wiemy zatem, że funkcja i metoda to w zasadzie to samo. Generalizując - różnica jest syntaktyczna.
W strukturze w języku C nie da się wyrazić "metody" w inny sposób niż wskazaniem zewnętrznej funkcji.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Point
{
    double x;
    double y;
    char*  color;
    long double mass;

    void can_i_do_this(struct Point* self);  /* No, you cannot do this. */

    void (*move)(struct Point*, double, double);
};

Ten kod się nie zbuduje.

$ gcc solar.c
solar.c:11:14: error: field ‘can_i_do_this’ declared as a function
   11 |         void can_i_do_this(struct Point* self);
      |              ^~~~~~~~~~~~~

W Pythonie w samych słownikach możemy zaprezentować to podobnie. W trakcie deklaracji słownika (kiedy on sam jeszcze nie jest zdefiniowany) nie możemy się jeszcze odnieść do tegoż słownika. Dopiero konstruujemy obiekt, zatem jeszcze nie istnieje, jeszcze nie ma adresu.

>>> D = {"x": 123, "y": D["x"]}
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'D' is not defined

Potrzebna jest zatem lepsza i wygodniejsza składnia. To do czego dążymy to zapis naszego typu jako cechy i zachowania. Docieramy zatem do klas.

Klasy

Język C nie zawiera odpowiednika syntaktycznego klas jakie zwykle widujemy. Istnieje jednak język C++, który powstał najpierw jako rozszerzenie C ("C z klasami"), a następnie znacznie ewoluował zachowując jednak kompatybilność z C. Trywializując - "pod spodem" C++ to nadal to samo "C z klasami", czyli struct i wskaźniki na funkcje. Tutaj zostawiamy więc C, a zaczynamy C++.

Klasa i metoda w C++:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <string>
#include <cmath>


class Point
{
public:
    double x;
    double y;
    double mass;
    std::string color;

    void move(double dx, double dy)
    {
        std::cout << "object address (this): " << this << std::endl;
        x += dx;
        y += dy;
    }
};


int main()
{
    Point E = {1.0, 1.0, (5.972 * pow(10, 24)), "blue"};
    std::cout << "object address (&E):" << &E << "\n" << std::endl;

    std::cout << "E: x=" << E.x << ", y=" << E.y << std::endl;
    E.move(1.4142, 2.7182);
    std::cout << "E: x=" << E.x << ", y=" << E.y << std::endl;

    return 0;
}

Kompiluję, uruchamiam, patrzymy:

$ c++ solar.cxx
$ ./a.out 
object address (&E):0x7fff991cbcb0

E: x=1, y=1
object address (this): 0x7fff991cbcb0
E: x=2.4142, y=3.7182

Tutaj znika nam już dopisywanie wszędzie struct (jak było w C), a metody możemy definiować w kontekście klasy. Kompilator wewnątrz metody (funkcji zdefiniowanej w klasie) automatycznie "pod spodem" przekazuje adres obiektu (wskaźnik this), na rzecz którego ta funkcja zostanie wywołana.
Adres obiektu E (czyli "kaczka e": &E) to to samo, co this użyte wewnątrz metody. To ten sam obiekt, a konkretnie ten sam obszar pamięci. Stąd wypisany wskaźnik this oraz adres &E to to samo: adres pamięci (0x7fff991cbcb0).

Wywołanie E.move(1.4142, 2.7182) w linii 29 to to samo co Point::move(&E, x, y), jednak nie możemy użyć tego drugiego zapisu ze względu na liczbę argumentów, które funkcja przyjmuje. Funkcja przyjmuje 2 argumenty, a kompilator C++ sam niejawnie podstawia nam this.
Definiując typ w C++ możemy napisać struct albo class. Jedno i drugie to praktycznie to samo, różnica polega na widoczności pól ("public", "private"). W struct domyślna widoczność to public, a w class domyślnie jest private. Wrócimy do tego później.

W Pythonie klasy i ich metody to dokładnie taki sam powyższy mechanizm.

class Point:
    x: float = 0.0
    y: float = 0.0
    mass: float = 0.0
    color: str = ""

    def move(kuku, dx, dy):
        print(kuku)
        print("object id", id(kuku))
        print("type", type(kuku))
        kuku.x += dx
        kuku.y += dy


E = Point()
E.x = 1.0
E.y = 1.0
E.mass = 5.972 * 10 ** 24
E.color = "blue"

print("id(E):", id(E))
E.move(1.4142, 2.21782)

print(E.__dict__)

Uruchamiamy:

$ python solar.py 
id(E): 139646969761408
<__main__.Point object at 0x7f021806ce80>
object id 139646969761408
type <class '__main__.Point'>
{'x': 2.4142, 'y': 3.21782, 'mass': 5.972e+24, 'color': 'blue'}

Stworzony obiekt E jest typu Point (klasa Point). Obiekt ten ma identyfikator 139646969761408. To dokładnie ten sam obiekt, który Python podstawia automatycznie jako pierwszy argument metody Point.move() (tak jak C++ podstawia this). Nazwałem ten argument kuku, aby pokazać, że "self" to po prostu konwencja. W C++ czy Java to będzie this, w Smalltalk będzie self, a w Pythonie to zwykła zmienna, którą według konwencji nazywamy self. "Self" czyli właśnie ten obiekt. Sam obiekt (self) jest zbudowany na słowniku, dlatego w ostatniej linii kodu wypisać możemy po prostu E.__dict__ - cały słownik tego obiektu. W Pythonie istnieje jeszcze słownik klasy, ale ten temat nie jest tutaj w tej chwili istotny.

Czy Python posiada odpowiedniki "public/private"? Tak i nie. W C++ do publicznych składowych możemy dostawać się z zewnątrz (poza metodami klasy), czyli:

E.x = 17;  // this will work

Jeśli double x byłoby zdefiniowane w sekcji prywatnej, to kompilator nie pozwoliłby nam na to, kod nie skompilowałby się.
No to jak z tym jest w Python?

class X:
    _var_a: str = "A"  # single underscore
    __var_b: str = "B"  # double underscore


obj = X()
print(obj._var_a)  # works
print(obj.__var_b)  # does not work

Uruchamiamy:

$ python private.py 
A
Traceback (most recent call last):
  File "/tmp/private.py", line 8, in <module>
    print(obj.__var_b)  # does not work
AttributeError: 'X' object has no attribute '__var_b'

Jeśli nazwa składowej (member) zdefiniowanej w klasie zaczyna się od podwójnej podłogi, to Python udaje, że to jest prywatne.
Jest to tak proste... Python sprawdza po prostu warunek if func_name.startswith("__").
Możemy się do niej jednak dostać. To co Python robi to zmiana nazwy. Jeśli użylibyśmy kodu:

...
print(obj._X__var_b) 
...

to otrzymamy wartość "B". W nazwie występuje jeszcze nazwa samej klasy.

$ python private.py 
A
B

Python to język dynamiczny, możemy kod programu a nawet same typy zmieniać nawet wtedy, kiedy program już uruchomiliśmy. Wszystko jest obiektem, a obiekt jest słownikiem. Jako przykład przeanalizujmy poniższy fragment z uruchomionego już interpretera Python:

>>> class Foo: ...
... 
>>> Foo.say_hello = lambda self, name: print("hello", name)
>>> f = Foo()
>>> f.say_hello("popo")
hello popo
>>> Foo.say_hello = lambda self, name: print("Hello dear", name.capitalize())
>>> f.say_hello("popo")
Hello dear Popo

Tak, zmieniamy definicję typu w już uruchomionym programie. Tutaj dla odmiany użyliśmy self, zamiast kuku.
Można też zademonstrować statyczne wywołanie, czego nie mogliśmy osiągnać w C++:

>>> Foo.say_hello(f, "popo")
Hello dear Popo

Wywołujemy tutaj bezpośrednio funkcję Foo.say_hello, a jawnie przekazujemy zarówno obiekt, na którym funkcja działa, jak i jej kolejne argumenty. Ten obiekt to właśnie self (vide wcześniejsze kuku). Widać też "różnicę" pomiędzy metodą a funkcją:

>>> Foo.say_hello
<function <lambda> at 0x7f60a891bdc0>
>>> f.say_hello
<bound method <lambda> of <__main__.Foo object at 0x7f60a89129d0>>
>>> f
<__main__.Foo object at 0x7f60a89129d0>

Foo.say_hello to ta funkcja, a kiedy jest związana już z obiektem, to jest metodą (bound method). Adres obiektu się nie zmienia: 0x7f60a89129d0.

Dla innego obiektu zachowanie będzie takie samo:

>>> Foo.say_hello
<function <lambda> at 0x7f60a891bdc0>
>>> g = Foo()
>>> g
<__main__.Foo object at 0x7f60a89125b0>
>>> g.say_hello
<bound method <lambda> of <__main__.Foo object at 0x7f60a89125b0>>

To nadal ta sama funkcja. Jednak... w Pythonie funkcje to też obiekty, konkretnie obiekty typu (klasy) function:

>>> type(Foo.say_hello)
<class 'function'>

Interpreter Python kopiuje czasem (kiedy trzeba) takie obiekty funkcji. Dlatego tutaj widzimy różne identyfikatory:

>>> id(f.say_hello)
140053120709184
>>> id(g.say_hello)
140053120708928

To jednak zostawiamy na inną okazję.

Wnioski

Klasy są mechanizmem syntaktycznym na powiązanie składowych i funkcji z konkretnymi instancjami tej klasy (obiektami).
Składowe definiowane w klasach są zenkapsulowane w obrębie tego typu. "Nie walają" się luźno w globalnym zasięgu (global scope), co było naszym problemem na początku. Wtedy mieliśmy luźne zmienne Ex, Ey, Sx, Sy, itd. Tutaj x, y i reszta zmiennych trafia do definicji typu, a konkretny obiekt "zawiera je już w sobie".

Konstruktory, destruktory

Przyjrzyjmy się klasie Python:

class Example:
    def __new__(cls):  # constructor
        print("NEW")
        return super().__new__(cls)

    def __init__(self):  # initializer
        print("Initializer")

    def __del__(self):  # finalizer
        print("Finalizer")


Example()

Uruchamiamy:

$ python ctor_dtor.py 
NEW
Initializer
Finalizer
  • __new__ to konstruktor. Jego rolą jest stworzenie obiektu. Aby obiekt w ogóle istniał, to system musi przydzielić pamięć na ten obiekt (alokacja) i zwrócić adres tego bloku pamięci. Alokację (malloc()) wywołuje Python "pod spodem", ale my moglibyśmy w tym cls coś dopisać. Dokładnie tak, jak wcześniej zrobiliśmy to z "pustą" klasą Foo dopisując jej say_hello().
  • __init__ to inicjalizator. Skoro self jest słownikiem i możemy na nim działać, to już istnieje, a zatem musiał zostać wcześniej skonstruowany.
  • __del__ to finalizator. Uruchamiany jest kiedy obiekt "znika", czyli nie ma już żadnego uchwytu do tego obiektu (żadna zmienna go nie "trzyma").

Operatory

Paradygmat OOP powstał w celu ułatwienia pisania poprawnych programów. Kiedy mamy już obiekty, to możemy na nich operować. Źródłem rozwoju OOP są symulacje komputerowe. To było zresztą główną przesłanką dla twórcy C++.
Na obiektach możemy operować metodami. Metody mogą realizować różne czynności, ale najważniejsze metody, to te "magiczne", np. w Python __add__(), czy __mul__(). Takie nasze Point moglibyśmy sumować, albo skalować (mnożenie). Sprawdźmy to najpierw w interpreterze Python na typach wbudowanych.

>>> x = 0
>>> x + 1
1
>>> x
0
>>> x.__add__(1)
1
>>> x
0
>>> int.__add__(x, 1)
1
>>> x
0
>>> x = 10
>>> x * 2
20
>>> int.__mul__(x, 3)
30

A teraz zaimplementujmy swój Integer.

class Integer:
    def __init__(self, value):
        self.value = int(value)

    def __add__(self, other):
        return self.value + (
            other.value if isinstance(other, Integer) else other
        )

    def __mul__(self, other):
        return self.value * (
            other.value if isinstance(other, Integer) else other
        )


x = Integer(2)
print(x + 3)
y = Integer(5)
print(x * y)
print(x - 1)  # won't work, __sub__ operator not implemented

Uruchamiamy i analizujemy:

$ python integer.py 
5
10
Traceback (most recent call last):
  File "/tmp/integer.py", line 20, in <module>
    print(x - 1)  # won't work, __sub__ operator not implemented
TypeError: unsupported operand type(s) for -: 'Integer' and 'int'

Nasza klasa Integer w inicjalizatorze przypisuje sobie przekazaną wartość i upewnia się, że jest to liczba (konwertuje to do int).
Klasa implementuje dodawanie i mnożenie. Przy operacjach arytmetycznych sprawdzamy czy drugim argumentem operacji jest obiekt naszego typu. Jeśli to obiekt naszego typu Integer to wartość mamy w składowej value, a z innymi typami operujemy bezpośrednio. Dla przejrzystości pozostawiłem ten kod bez obsługi błędów (np. mnożenie liczby razy napis...).

Te operatory możemy zauważyć pracując z biblioteką numpy. Przykład:

>>> import numpy as np
>>> A = np.array([1, 2, 3])
>>> A
array([1, 2, 3])
>>> A * 7
array([ 7, 14, 21])

Ot, cała rzecz. To nie "magia" lecz programowanie, a programowanie to w zasadzie matematyka.

W C++ również możemy definiować operatory (przeciążanie operatorów /operator overloading/). Nie będę przytaczał całych przykładów, spójrzmy jedynie na składnię:

MyType operator+ (double other, const MyType& self);
MyType operator+ (const MyType& self, double other);

A jeśli definiujemy je jako metody wewnątrz klasy, to potrzebny nam jest już tylko "other":

struct MyType
{
    MyType operator* (double other);
};

Operatory to jedno z kluczowych zagadnień OOP. Czasami bywają zaimplementowane źle (lub nie wszystkie).

$ nodejs 
Welcome to Node.js v12.22.7.
Type ".help" for more information.
> 2 * "11"
22
> "11" * 2
22
> 2 + "11"
'211'
> "11" + 2
'112'
> "11" - 2
9

Solar

Na tym kończymy dzisiejsze omówienie. Wiele spraw pozostało - m.in. dziedziczenie, polimorfizm, mro, super(), a w C++ choćby vtable... Zostawimy to na inną okazję, żeby nie "przemóżdżyć". Nie jest nam to też potrzebne do przećwiczenia podstaw OOP ("czym są klasy i metody?") ani do napisania naszego "solar". Mieliśmy dopiero E i S. Do dzieła! Jeszcze się nie ruszały!

Do animacji punktów na "kartce papieru" w Python możemy użyć biblioteki matplotlib.
Czas płynie w jedną stronę, wartość w pionie (oś y, sznurek w łuku) to sinus, a w poziomie (oś x, strzała w łuku) to cosinus.
Wikipedia posiada w swoich zasobach ten piękny artykuł: History of trigonometry. Niestety nie ma on polskiego tłumaczenia.

Video

Plot twist: nie mogąc się oprzeć "pokusie" stworzyłem animację 3D.
Kod dostępny jest w repozytorium git.

Video: MP4, 1.6M, 1920x1080. Duration: 00:00:47 Link