Open In Colab

Обектно-ориентирано програмиране#

Основни принципи#

  1. Енкапсулация

  2. Абстракция

  3. Наследяване

  4. Полиморфизъм

Обектно-ориентираното програмиране се основава на използването на класове от обекти, които обменят съобщения помежду си. В Python използваме ключовата дума class, за да започнем дефиницията на клас:

class ExampleClass:
    pass

(ключовата дума pass обозначава празен блок (еквивалентно на {} в езиците, използващи скоби за scope))

Инстанции на класа можем да създаваме чрез синтаксис подобен на извикването на функция със същото име. Новосъздадените обекти отиват в динамичната памет и по подразбиране когато искаме да print-нем информация за тях Python ни показва адреса в паметта, на който се намират, както и типа им (класа):

example_object = ExampleClass()
print(example_object)

example_object_2 = ExampleClass()
print(example_object_2)
<__main__.ExampleClass object at 0x113bed6a0>
<__main__.ExampleClass object at 0x112dbbc40>

is сравнява дали два обекта съвпадат, т.е. ще върне False, когато адресите им в паметта са различни. Операторът == пък не е дефиниран за горния клас и затова и неговата стойност засега ще бъде False (по премълчаване се дефинира чрез is):

print(f"{example_object is example_object_2 = }")
print(f"{example_object == example_object_2 = }")
example_object is example_object_2 = False
example_object == example_object_2 = False

Горният клас с име ExampleClass така дефиниран е празен - не притежава нито член-данни, нито методи. Добре е да се знае, обаче, че поради динамичния характер на езика, такива могат да бъдат добавяни (и отнемани) по всяко време (както като част от инстанцията, така и като част от класа). Достъпът до атрибути и методи става чрез точка .:

example_object.example_property = "I am an attribute of this instance only"
print(f"{example_object.example_property = }")

ExampleClass.example_shared_property = "I am a shared/static attribute"
print(f"{ExampleClass.example_shared_property = }")
print(f"{example_object.example_shared_property = }")
print(f"{example_object_2.example_shared_property = }")

del example_object.example_property
print(f"{example_object.example_property = }")  # 💥
example_object.example_property = 'I am an attribute of this instance only'
ExampleClass.example_shared_property = 'I am a shared/static attribute'
example_object.example_shared_property = 'I am a shared/static attribute'
example_object_2.example_shared_property = 'I am a shared/static attribute'
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/var/folders/7x/gdf0mbts74n8p_ckv_b_mfc40000gp/T/ipykernel_62173/980122267.py in <module>
      8 
      9 del example_object.example_property
---> 10 print(f"{example_object.example_property = }")  # 💥

AttributeError: 'ExampleClass' object has no attribute 'example_property'

Обикновено обаче искаме да знаем винаги какви член-данни да очакваме от един клас, както и да можем да ги задаваме при конструирането му. Това е възможно, чрез дефинирането на __init__ метода в класа:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

Това е първият от редица “dunder” (double underscore - двойна подчертавка) методи (още наричани магически методи), които ще разгледаме в лекцията. Този се нарича инициализатор и приема като първи аргумент новосъздадения обект (по конвенция го кръщаваме винаги self, но на теория може да е всякак). Следващите параметри дефинираме и означаваме по наш избор.

Обърнете внимание, че нарочно не го наричаме контруктор, понеже истинският конструктор създава и връща нов обект от даден клас, докато инициализаторът приема този обект и задава начални стойности на член-данните му или изпълнява някакъв друг вид инициализация. Такъв конструктор има и в Python (__new__), но на него няма да се спираме подробно.

p1 = Point(2, 3)
p2 = Point(5j, 3.14)

print(f"{p1.x = }, {p1.y = }")
print(f"{p2.x = }, {p2.y = }")
p1.x = 2, p1.y = 3
p2.x = 5j, p2.y = 3.14

Инициализаторът си е функция като всяка други и може да има стойности на параметрите по подразбиране, както и args и kwargs:

class Coordinate:
    def __init__(self, la=0, lo=0, **kwargs):
        self.latitude = la
        self.longitude = lo
        self.metadata = kwargs

sofia = Coordinate(42.69, 23.420, city="Sofia", country="Bulgaria")
null_island = Coordinate()

print(f"{sofia.latitude = }, {sofia.longitude = }, {sofia.metadata = }")
print(f"{null_island.latitude = }, {null_island.longitude = }, {null_island.metadata = }")
sofia.latitude = 42.69, sofia.longitude = 23.42, sofia.metadata = {'city': 'Sofia', 'country': 'Bulgaria'}
null_island.latitude = 0, null_island.longitude = 0, null_island.metadata = {}

Произволни методи дефинираме по същия начин:

class Path:
    def __init__(self, start, end):
        self.start = start
        self.end = end
    
    def length(self):
        return ((self.start.x - self.end.x) ** 2 + (self.start.y - self.end.y) ** 2) ** 0.5

p1 = Point(0, 3)
p2 = Point(4, 0)

path = Path(p1, p2)
l = path.length()

print(f"{l = }")
l = 5.0

Липсата на self като първи аргумент ще е грешка, понеже Python винаги ще се опита да предаде инстанцията на класа като първи параметър на метода:

class A:
    def a():
        pass

A().a()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/var/folders/7x/gdf0mbts74n8p_ckv_b_mfc40000gp/T/ipykernel_62173/3247279650.py in <module>
      3         pass
      4 
----> 5 A().a()

TypeError: a() takes 0 positional arguments but 1 was given

Енкапсулация#

В Python достъпа до всичко е публичен.

По конвенция е общоприето protected имената да започват с една подчертавка (_name), а private - с две (__name).

class Counter:
    def __init__(self, value=0):
        self._value = value

    def get_value(self):
        return self._value
    
    def increment(self):
        self._value += 1
    
    def decrement(self):
        self._value -= 1

c = Counter(41)
c.increment()

print(f"{c.get_value() = }")
print(f"{c._value = }")
c.get_value() = 42
c._value = 42

При двойните подчертавки обаче съществува една особеност, която ни улеснява в енкапсулирането на private член-данни. Нарича се name mangling и представлява добавянето на _ и името на класа в началото на името на тези член-данни. Тоа се прави с цел замаскиране на атрибута и премахването на достъпа чрез оригиналното му име.

class Counter:
    def __init__(self, value=0):
        self.__value = value  # private access from within the class is OK

    def get_value(self):
        return self.__value

    def increment(self):
        self.__value += 1

    def decrement(self):
        self.__value -= 1


c = Counter(41)
c.increment()

print(f"{c.get_value() = }")
print(f"{c._Counter__value = }")  # name has been mangled
print(f"{c.__value = }")  # 💥 cannot be accessed at its original name
c.get_value() = 42
c._Counter__value = 42
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/var/folders/7x/gdf0mbts74n8p_ckv_b_mfc40000gp/T/ipykernel_62173/3226284858.py in <module>
     18 print(f"{c.get_value() = }")
     19 print(f"{c._Counter__value = }")  # name has been mangled
---> 20 print(f"{c.__value = }")  # 💥 cannot be accessed at its original name

AttributeError: 'Counter' object has no attribute '__value'

Дълга тема за дискусия е колко често и кога да използваме public, private или protected член-данни и методи в Python. За разлика от повечето езици, които проповядват всичко по подразбиране да е максимално скрито, докато не ни се наложи друго, в Python не е толкова често срещана необходимостта от строгото забраняване на достъп, даже напротив, подходът най-често е по-скоро обратен.

Хубави отговори на този въпрос може да намерите тук.

Наследяване#

Както споменахме, всичко в Python е обект. По-точно object.

object е най-базовия клас в езика, който имплицитно бива наследяван от всички класове.

class A:
    pass

a = A()

print(f"{isinstance(a, A) = }")
print(f"{isinstance(a, object) = }")
isinstance(a, A) = True
isinstance(a, object) = True

(с вградената функция isinstance проверяваме дали даден обект е инстанция на даден клас или на негов базов такъв)

Дори и да изглежда празен класът А, в него в момента се съдържат доста член-методи. С вградената функция dir можем да видим пълния набор от атрибути и методи, които даден обект притежава:

dir(a)
['__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 '__weakref__']

Почти всички от тези магически методи и атрибути са наследени от object:

dir(object())
['__class__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__']

Ако искаме да наследим друг клас, записваме името му в скоби преди дефиницията на наследника:

class BaseClass:
    x = 69

class SubClass(BaseClass):
    pass

sub = SubClass()
print(f"{sub.x = }")
sub.x = 69

Виждаме, че няма нужда да пишем експлицитно, че наследяваме от object. Важно е да се отбележи обаче, че това е задължително за Python 2.

Предефинирането на методи от базовия клас не изисква някаква специална ключова дума. Тяхната дефиниция просто бива замествана от новата.

class Foo:
    def foo(self):
        print("foo")

class Bar(Foo):
    def foo(self):
        print("foobar")

#  (I'm sorry I couldn't come up with anything creative this time)

bar = Bar()
bar.foo()
foobar

Използването на super() ни връща обект-прокси, който пренасочва дефиниции на методи към базов клас:

class Drink:
    def __init__(self, name, alcoholic_percentage):
        self.name = name
        self.alcoholic_percentage = alcoholic_percentage


class SoftDrink(Drink):
    def __init__(self, name):
        super().__init__(name, 0)


drincc = SoftDrink("Coca-Cola")
print(f"{drincc.name = }, {drincc.alcoholic_percentage = }%")
drincc.name = 'Coca-Cola', drincc.alcoholic_percentage = 0%

Python е един от малкото езици, в които множественото наследяване е позволено. За избягване на проблема на диаманта и други подобни, които често това поражда, е хубаво базовите класове да са семпли и с ясно-дефинирана функционалност, по възможност непокриваща се.

Mixin-ите са най-честия пример за използване на множествено наследяване в Python. Те са базови класове, които предоставят “наготово” имплементация на конкретна допълнителна функционалност.

import json

class JSONSerializableMixin:
    def to_json(self):
        return json.dumps(self.__dict__)

class DebugMixin:
    def __repr__(self):
        arguments = ", ".join(f"{k}={v}" for k, v in self.__dict__.items())
        return f"{self.__class__.__name__}({arguments})"


class Person(JSONSerializableMixin, DebugMixin):
    def __init__(self, name, age):
        self.name = name
        self.age = age

class Employee(Person, JSONSerializableMixin, DebugMixin):
    def __init__(self, name, age, salary):
        self.salary = salary
        super().__init__(name, age)


elon = Employee("Elon Musk", 51, 1_000_000_000)
print(f"{elon.to_json() = }")
print(f"{elon = }")
elon.to_json() = '{"salary": 1000000000, "name": "Elon Musk", "age": 51}'
elon = Employee(salary=1000000000, name=Elon Musk, age=51)

В горните примери дефинирахме два mixin-a: един, който дефинира как да бъде сериализиран до JSON всеки обект, а другия - как да бъде принтиран в дебъг конзолата. Класът Employee наслодява както Person, така и двата други класа, като придобива всичката функционалност. В този случай super() знае към кой базов клас да се обърне чрез поредността на изпълнение на методи (Method Resolution Order), който зависи от реда, в който сме изброили базовите класове (може да се види чрез __mro__)

Employee.__mro__
(__main__.Employee,
 __main__.Person,
 __main__.JSONSerializableMixin,
 __main__.DebugMixin,
 object)

Повече за super() може да прочетете тук.

В примерът по-горе използвахме доста непознати dunder-и: __repr__, __dict__, __class__, __name__. За тях, както и други, ще разясним в следващата секция.

Магическите методи ✨#

Документация: https://docs.python.org/3/reference/datamodel.html

__repr__#

От англ. “representation”.

Изиква се при изпълнението на repr(self). Трябва да върне низ с репрезентация на обекта, подходящ за принт в конзолата при дебъгване например. Хубаво е тя максимално много да наподобява начина, по който можем да пресъздадем обекта с изпълним код. Ако това не е възможно е хубаво да върне низ във вида <... някакво полезно инфо...>.

Например за клас Point (точка) с атрибути x и y, които инициализаторът приема, вместо (x, y) е по-удачно да върнем Point(x, y), понеже това може директно да бъде изпълнено като Python код и да получим еквивалентен обект.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

p = Point(2, 3)
print(p)
print(repr(p))
Point(2, 3)
Point(2, 3)

Конвертиране в други типове#

Можем да предефинираме начинът, по който нашият клас бива конвертиран в някои други вградени типове, чрез специалните dunder методи с тяхното име, предназначени за това. Възможните типове са str, bytes, bool, int, float, complex.

class Point:
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

    def __str__(self):
        return f"({self.x}, {self.y})"

    def __bool__(self):
        """Return `True` only for (0, 0)."""
        return not (not self.x and not self.y)

p = Point(2, 3)

print(f"{str(p) = }")
print(f"The `__str__` method is called when using string interpolation: {p}")
print(f"If we want to use the `__repr__` instead we should write it like this: {p!r}")

print(f"{bool(p) = }")

# `__bool__` is useful when building conditions

if p:
    print("p is truthy")

z = Point()

if not z:
    print("z is falsey")
str(p) = '(2, 3)'
The `__str__` method is called when using string interpolation: (2, 3)
If we want to use the `__repr__` instead we should write it like this: Point(2, 3)
bool(p) = True
p is truthy
z is falsey

Note: За доста от класовете, които бихте срещнали, върнатите низове от repr и str съвпадат. Тук обаче умишлено сме избрали пример, в който има повече смисъл да са различни - repr е debuggable репрезентация на обекта точка (валиден изпълним код), а str - стандартен математически запис на точка.

class Fraction:
    def __init__(self, numerator=0, denominator=1):
        self.numerator = numerator
        self.denominator = denominator

    def __float__(self):
        return self.numerator / self.denominator

    def __int__(self):
        return self.numerator // self.denominator  # alternative is `return int(float(self))`
    
    def __complex__(self):
        return complex(float(self))  # alternative is `return float(self) + 0j`
    
    def __str__(self):
        return f"{self.numerator}/{self.denominator}"

    def __repr__(self):
        return f"Fraction({self.numerator}, {self.denominator})"

    def __bool__(self):
        return self.numerator != 0


frac = Fraction(6, 9)
print(f"{float(frac) = }")
print(f"{int(frac) = }")
print(f"{complex(frac) = }")
print(f"{str(frac) = }")
print(f"{repr(frac) = }")
print(f"{bool(frac) = }")
float(frac) = 0.6666666666666666
int(frac) = 0
complex(frac) = (0.6666666666666666+0j)
str(frac) = '6/9'
repr(frac) = 'Fraction(6, 9)'
bool(frac) = True

Note 2: Обърнете внимание, че dunder(x) в случая е същото като x.__dunder__(). Обикновено нямаме причина да използваме втория запис.

Аритметични и логически оператори#

В Python (също както в С++ и повечето други ООП езици) можем да предефинираме поведението на аритметичните и логическите оператори от езика, когато биват извикани върху обект от нашия клас.

Съответните магически методи за това са:

оператор

dunder

==

__eq__

!=/<>

__neq__

<

__lt__

<=

__le__

>

__gt__

>=

__ge__

+ (2)

__add__/__radd__

*

__mul__/__rmul__

- (2)

__sub__/__rsub__

/

__truediv__/__rtruediv__

//

__floordiv__/__rfloordiv__

%

__mod__/__rmod__

divmod

__divmod__/__rdivmod__

**/pow

__pow__/__rpow__

<<

__lshift__/__rlshift__

>>

__rshift__/__rrshift__

&

__and__/__rand__

^

__xor__/__rxor__

|

__or__/__ror__

+=

__iadd__

*=

__imul__

-=

__isub__

/=

__itruediv__

//=

__ifloordiv__

%=

__imod__

**=

__ipow__

<<=

__ilshift__

>>=

__irshift__

&=

__iand__

^=

__ixor__

|=

__ior__

- (1)

__neg__

+ (1)

__pos__

abs

__abs__

~

__invert__

class Fraction:
    def __init__(self, numerator=0, denominator=1):
        self.numerator = numerator
        self.denominator = denominator
    
    def __add__(self, other):
        return Fraction(
            self.numerator * other.denominator + other.numerator * self.denominator,
            self.denominator * other.denominator
        )
    
    def __sub__(self, other):
        return Fraction(
            self.numerator * other.denominator - other.numerator * self.denominator,
            self.denominator * other.denominator
        )
    
    def __mul__(self, other):
        return Fraction(
            self.numerator * other.numerator,
            self.denominator * other.denominator
        )

    def __truediv__(self, other):
        return Fraction(
            self.numerator * other.denominator,
            self.denominator * other.numerator
        )

    def __iadd__(self, other):
        self.numerator = self.numerator * other.denominator + other.numerator * self.denominator
        self.denominator = self.denominator * other.denominator
        return self

    def __isub__(self, other):
        self.numerator = self.numerator * other.denominator - other.numerator * self.denominator
        self.denominator = self.denominator * other.denominator
        return self

    def __imul__(self, other):
        self.numerator = self.numerator * other.numerator
        self.denominator = self.denominator * other.denominator
        return self

    def __itruediv__(self, other):
        self.numerator = self.numerator * other.denominator
        self.denominator = self.denominator * other.numerator
        return self
    
    def __repr__(self):
        return f"Fraction({self.numerator}, {self.denominator})"


frac1 = Fraction(1, 2)
frac2 = Fraction(1, 3)

summ = frac1 + frac2
diff = frac1 - frac2
prod = frac1 * frac2
quot = frac1 / frac2

print(f"{summ = }")
print(f"{diff = }")
print(f"{prod = }")
print(f"{quot = }")

frac1 += frac2
print(f"{frac1 = }")
summ = Fraction(5, 6)
diff = Fraction(1, 6)
prod = Fraction(1, 6)
quot = Fraction(3, 2)
frac1 = Fraction(5, 6)

Вариантите на методите, започващи с r, дефиниращи поведението на операторите, се извикват с разменени аргументи. Те се използват само в случая, когато левият операнд не поддържа съответната операция и операндите са от различни типове.

Нека например имаме израза x - y и нека y имплементира __rsub__. Той ще се извика в случая, когато x не поддържа __sub__ (няма го дефиниран или връща NotImplemented) и x и y са от различни типове.

Хеширане#

Колекции като dict, set и frozenset изискват елементите им да могат да се хешират. Това се осъществява чрез извикването на вградената функция hash(), която връща цяло число - съответният хеш.

hash(1)
1
hash(420)
420
hash("a")
-5016004937874941484
hash((1, "a", False))
-5093137992435649037
hash([1, "a", False])
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/var/folders/7x/gdf0mbts74n8p_ckv_b_mfc40000gp/T/ipykernel_62173/1812877005.py in <module>
----> 1 hash([1, "a", False])

TypeError: unhashable type: 'list'

Rule of thumb във всички езици за програмиране е с дефиниране на __hash__ винаги да има и дефиниция на __eq__ (обратното не е задължително да е вярно), понеже няма смисъл да се хешира обект, който не може да се сравнява с други.

В Python при дефиниране на клас, по премълчаване той получава готова default-на дефиниция на __eq__ и __hash__. Тяхното поведение е такова, че две различни инстанции от този клас не са равни и не произвеждат един и същ хещ (равни са единствено на себе си, т.е. равенство настъпва само при сравняване на имена, сочещи към едно и също място в паметта).

class Point:
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

p1 = Point(4, 2)
p2 = Point(4, 2)

print(f"{hash(p1) = }")
print(f"{hash(p2) = }")

visited = set()
visited.add(p1)
visited.add(p2)

print(f"{visited = }")
hash(p1) = 289920877
hash(p2) = 289920985
visited = {<__main__.Point object at 0x1147d7d90>, <__main__.Point object at 0x1147d76d0>}

В случая от примера може би не искаме две точки с едни и същи координати да се третират като различни. За да го постигнем, трябва да дефинираме __eq__ и __hash__:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __hash__(self):
        return hash((self.x, self.y))  # the hash of the tuple of the coordinates is sufficient


p1 = Point(4, 2)
p2 = Point(4, 2)

print(f"{hash(p1) = }")
print(f"{hash(p2) = }")

visited = set()
visited.add(p1)
visited.add(p2)

print(f"{visited = }")
hash(p1) = -9131917124991031250
hash(p2) = -9131917124991031250
visited = {<__main__.Point object at 0x113d60790>}

По този начин (чрез предефиниране на == и евентуално и на hash()) определяме еднаквост между инстанциите. След това единственият начин, по който можем да различим дали обектите съвпадат (т.е. са едни и същи от гледна точка на паметта) остава чрез използване на is.

p1 is p2
False
p1 == p2
True

Контейнери и итератори#

Съществуват методи, които ни помагат в дефинирането на контейнери и мапинги:

  • __len__(self): трябва да върне дължината на контейнера (цяло число \( \ge 0 \)). Извиква се от вградената функция len().

  • __getitem__(self, key): връща елемента с ключ key (на позиция key). Извиква се при read-only достъп с [].

  • __setitem__(self, key, value): задава стойността на елемента с ключ key (на позиция key) да бъде value. Извиква се при присвояване с [].

  • __delitem__(self, key): изтрива елемента с ключ key (на позиция key). Извиква се от ключовата дума del.

  • __iter__(self): връща итератор, който да се използва за обхождане на контейнера. При мапинги трябва да обхожда ключовете на контейнера. Извиква се от вградената функция iter() когато е необходимо. Итераторът трябва да имплементира __next__, така че при обхождането му да връща следващия елемент при всяко извикване на next().

  • __reversed__(self): връща обратен итератор, който да се използва за обхождане на контейнера. Извиква се от вградената функция reversed().

  • __contains__(self, item): връща bool, казващ дали item се съдържа в контейнера. Извиква се при използване на in за проверка на съдържанието.

class DefaultArray:
    """
    A container that behaves like a list with a maximum size (array)
    but returns a default value for missing indices.
    """

    def __init__(self, limit, default_element_func):
        self._limit = limit
        self._default_element_func = default_element_func
        self._list = [None] * limit

    def __len__(self):
        return self._limit

    def __getitem__(self, index):
        element = self._list[index]
        return self._default_element_func() if element is None else element

    def __setitem__(self, index, element):
        self._list[index] = element

    def __delitem__(self, index):
        self._list[index] = None

    def __contains__(self, item):
        return item in self._list

arr = DefaultArray(10, int)  # `int` can be viewed as a function that returns 0 upon calling

print(f"{arr[1] = }")  # __getitem__

arr[1] = 42  # __setitem__

print(f"{arr[1] = }")
print(f"{42 in arr = }")  # __contains__

del arr[1]  # __delitem__

print(f"{arr[1] = }")
print(f"{42 in arr = }")
arr[1] = 0
arr[1] = 42
42 in arr = True
arr[1] = 0
42 in arr = False

Индексирането в Python е гъвкаво и позволява взимането на цели части (slices) от дадена колекция, както и броене отзад-напред.

Синтаксисът е iterable[start:stop:step], където:

  • iterable е въпросната колекция

  • start е началния индекс (default = 0)

  • stop е крайният индекс (невключително) (default = len)

  • step е стъпката (default = 1), през която се взимат елементи. Ако е отрицателна, то обхождането е наобратно.

Индексът, както и start/stop могат да бъдат отрицателни числа. В такъв случай -1 означава последният елемент, -2 - предпоследният и т.н.

Също е хубаво да се отбележи, че при slicing не се хвърля IndexError, в случай че някой от индексите е извън колекцията.

Най-добре всичко това се демонстрира чрез примери:

items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

print(f"{items[9] = }")
print(f"{items[-1] = }")
print()
print(f"{items[1:5] = }")
print(f"{items[1:] = }")
print(f"{items[:5] = }")
print(f"{items[:-1] = }")
print(f"{items[-6:-1] = }")
print()
print(f"{items[1:5:2] = }")
print(f"{items[1:5:-1] = }")
print(f"{items[5:1:-1] = }")
print()
print(f"{items[5::-1] = }")
print(f"{items[::-1] = }")
print(f"{items[:] = }")
items[9] = 9
items[-1] = 9

items[1:5] = [1, 2, 3, 4]
items[1:] = [1, 2, 3, 4, 5, 6, 7, 8, 9]
items[:5] = [0, 1, 2, 3, 4]
items[:-1] = [0, 1, 2, 3, 4, 5, 6, 7, 8]
items[-6:-1] = [4, 5, 6, 7, 8]

items[1:5:2] = [1, 3]
items[1:5:-1] = []
items[5:1:-1] = [5, 4, 3, 2]

items[5::-1] = [5, 4, 3, 2, 1, 0]
items[::-1] = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
items[:] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

Чрез имплементиране на __getitem__ нашият клас поддържа имплицитно slicing. Това е така, понеже горепоказаният начин за slicing е синтактична захар на създаването на slice обект и предаването му чрез [].

С други думи, следните две операции са екивалентни:

usa_weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]

workdays1 = usa_weekdays[1:-1]
workdays2 = usa_weekdays[slice(1, -1, None)]

print(f"{workdays1 = }")
print(f"{workdays2 = }")
workdays1 = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
workdays2 = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']

Това означава, че ако искаме (или пък нарочно и експлицитно не искаме) да поддържаме slicing по адекватен начин в нашия обект, в__getitem__(self, key), __setitem__(self, key) и __delitem__(self, key) може да е необходимо да проверяваме дали key е от тип slice в случаите, в които това има значение. Това може да стане с isinstance(key, slice) например.

В горния пример с DynamicArray няма да имаме проблем, понеже параметъра го предаваме директно на __getitem__ на листа който си пазим, а листовете както видяхме вече успешно се справят със slice-ове.

arr[:]
[None, None, None, None, None, None, None, None, None, None]

Итерирането в Python става с помощта на итератори. Итераторите са обекти, които имплементират метода __next__, който връща следващия елемент при всяко извикване. Когато няма повече елементи, трябва да се хвърли изключение StopIteration. Итераторите се използват от for-циклите.

iterable = [1, 2, 3]

# `for i in iterable: print(i)` is equal to the following calls:

iterator = iter(iterable)  # the `for` loop uses the iterator of the object

print(f"{iterator = }")  # printing here the iterator just for demo purposes

print(f"{next(iterator) = }")  # first iteration
print(f"{next(iterator) = }")  # second iteration
print(f"{next(iterator) = }")  # third iteration
print(f"{next(iterator) = }")  # this exception is caught by the `for` loop and is its exit condition
# let's not catch it and let it boom 💥
iterator = <list_iterator object at 0x118986490>
next(iterator) = 1
next(iterator) = 2
next(iterator) = 3
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
/var/folders/7x/gdf0mbts74n8p_ckv_b_mfc40000gp/T/ipykernel_62173/920778728.py in <module>
     10 print(f"{next(iterator) = }")  # second iteration
     11 print(f"{next(iterator) = }")  # third iteration
---> 12 print(f"{next(iterator) = }")  # this exception is caught by the `for` loop and is its exit condition
     13 # let's not catch it and let it boom 💥

StopIteration: 

С други думи, for циклите правят следното:

def for_from_aliexpress(iterable, func):
    iterator = iter(iterable)
    while True:
        try:
            element = next(iterator)
        except StopIteration:
            break

        func(element)

for_from_aliexpress([1, 2, 3], print)
1
2
3

Ако не разбирате част от кода горе, не се притеснявайте. В последваща лекция ще учим за грешки и изключения, както и в друга говорим за итериране и функции от по-висок ред в повече дълбочина.

За бъде итеруем (iterable) един клас, трябва да имплементира __iter__, връщайки итератор. Възможно е да върне и собствената си инстанция, в случай, че има имплементиран __next__.

class NotAnIterable:
    pass

not_an_iterable = NotAnIterable()

for i in not_an_iterable:
    print(i)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/var/folders/7x/gdf0mbts74n8p_ckv_b_mfc40000gp/T/ipykernel_62173/2000024672.py in <module>
      4 not_an_iterable = NotAnIterable()
      5 
----> 6 for i in not_an_iterable:
      7     print(i)

TypeError: 'NotAnIterable' object is not iterable
class DefaultArray:
    """
    A container that behaves like a list with a maximum size (array)
    but returns a default value for missing indices.
    """

    def __init__(self, limit, default_element_func):
        self._limit = limit
        self._default_element_func = default_element_func
        self._list = [None] * limit

    def __len__(self):
        return self._limit

    def __getitem__(self, index):
        element = self._list[index]
        return self._default_element_func() if element is None else element

    def __setitem__(self, index, element):
        self._list[index] = element

    def __delitem__(self, index):
        self._list[index] = None

    def __contains__(self, item):
        return item in self._list

    def __iter__(self):
        return iter(self._list)

arr = DefaultArray(10, int)

for i in range(10):
    arr[i] = i*i*i  # squares are overrated, let's print the cubes

for element in arr:  # __iter__ is called here
    print(element)
0
1
8
27
64
125
216
343
512
729
class FibonacciGenerator:
    def __init__(self, max_index):
        self.max_index = max_index
        self._a = 1
        self._b = 1
        self._current_index = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self._current_index >= self.max_index:
            raise StopIteration

        current = self._a

        self._a, self._b = self._b, self._a + self._b
        self._current_index += 1

        return current


lateralus = FibonacciGenerator(16)

for i in lateralus:
    print(i)
1
1
2
3
5
8
13
21
34
55
89
144
233
377
610
987
class YetAnotherCustomIterable:
    class CustomIterator:
        def __init__(self, iterable):
            self._iterable = iterable
            self._index = 0

        def __next__(self):
            if self._index >= len(self._iterable):
                raise StopIteration

            element = self._iterable[self._index]
            self._index += 1
            return element
    
    def __init__(self, iterable):
        self._iterable = iterable
    
    def __iter__(self):
        return YetAnotherCustomIterable.CustomIterator(self._iterable)

it = YetAnotherCustomIterable("strings are also iterables")

print("👏".join(i for i in it))  # __iter__ is also called by comprehensions
# more info on list/generator/dict comprehensions in another lecture
s👏t👏r👏i👏n👏g👏s👏 👏a👏r👏e👏 👏a👏l👏s👏o👏 👏i👏t👏e👏r👏a👏b👏l👏e👏s

Функции#

Искате обектът ви да бъде извикан като функция?

Няма проблеми.

class LinearBinomial:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def __call__(self, x):
        return self.a * x + self.b

    def __str__(self):
        A = f"{self.a}x" if self.a != 1 else "x"
        B = f"{self.b}" if self.b != 0 else ""
        return f"{A} + {B}"


f = LinearBinomial(1, 2)

print(f"f(x) = {f}")

print(f"f(0) = {f(0)}")
print(f"f(1) = {f(1)}")
f(x) = x + 2
f(0) = 2
f(1) = 3

Pure witchcraft#

class ExampleClass:
    """This is an example class.
    
    Note: The first multiline comment in a definition of a class, module or function
    is called a 'docstring'.
    """

    def __init__(self, var):
        """Take a variable and store it in the instance.

        Note: It is good practice to document as many public methods and classes as possible.
        """
        self.var = var

    def __repr__(self):
        """Return repr(self).
        
        Note: there are established strict conventions as to what good code documentation sould look like.
        For example, such comments should always be imperative (e.g. 'return', not 'returns').
        For more info check out `pydocstyle` (https://www.pydocstyle.org/en/stable/). It includes a linter
        that can be configured inside your IDE to constantly check and give out warnings
        regarding your code documentation and style.
        Good thing to check out as well is PEP-8 but keep in mind that we will cover PEP-8 in details
        in a following lecture.
        """
        return f"ExampleClass({self.var})"

    __str__ = __repr__  # after all, methods are objects too. They can be passed around and assigned.


instance = ExampleClass(42)

# now let the black magic begin
print(f"{instance.__doc__ = }")           # the docstring of the object
print(f"{instance.__repr__.__doc__ = }")  # the docstring of a method of the object
print(f"{ExampleClass.__name__ = }")  # the name of the class as a string
print(f"{instance.__class__ = }")  # the class of the instance as a class object
print(f"{instance.__module__ = }")  # the name of the module in which the object exists. In this case, it's `__main__` because we are in the REPL.
print(f"{instance.__dict__ = }")  # a `dict` containing all the attributes of the instance. It is returned by the `vars` builtin function:
print(f"{vars(instance) = }")
print(f"{instance.__repr__.__code__ = }")  # the code object of the method. Don't expect to see the exact code though, this is some representation of the compiled code.
print(f"{instance.__repr__.__code__.co_code = }")  # the actual compiled code. This is a `bytes` object.
print(f"{instance.__str__.__code__.co_code = }")  # it's the same picture
instance.__doc__ = "This is an example class.\n    \n    Note: The first multiline comment in a definition of a class, module or function\n    is called a 'docstring'.\n    "
instance.__repr__.__doc__ = "Return repr(self).\n        \n        Note: there are established strict conventions as to what good code documentation sould look like.\n        For example, such comments should always be imperative (e.g. 'return', not 'returns').\n        For more info check out `pydocstyle` (https://www.pydocstyle.org/en/stable/). It includes a linter\n        that can be configured inside your IDE to constantly check and give out warnings\n        regarding your code documentation and style.\n        Good thing to check out as well is PEP-8 but keep in mind that we will cover PEP-8 in details\n        in a following lecture.\n        "
ExampleClass.__name__ = 'ExampleClass'
instance.__class__ = <class '__main__.ExampleClass'>
instance.__module__ = '__main__'
instance.__dict__ = {'var': 42}
vars(instance) = {'var': 42}
instance.__repr__.__code__ = <code object __repr__ at 0x113d91df0, file "/var/folders/7x/gdf0mbts74n8p_ckv_b_mfc40000gp/T/ipykernel_62173/2566046567.py", line 15>
instance.__repr__.__code__.co_code = b'd\x01|\x00j\x00\x9b\x00d\x02\x9d\x03S\x00'
instance.__str__.__code__.co_code = b'd\x01|\x00j\x00\x9b\x00d\x02\x9d\x03S\x00'

Някои полезни помощни класове и декоратори#

Но първо, какво е “декоратор”?#

Накратко (понеже в друга лекция ще обясним по-подробно), това е функция, обграждаща и “добавяща” функционалност на друга такава. Т.е. приема за параметър някоя функция и връща друга, която е модифицирана версия на първата. В Python може да се използва и чрез @, което е удобен syntax sugar за декориране.

def logged(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with {args} and {kwargs}")
        return func(*args, **kwargs)
    return wrapper

@logged
def fibonacci(n=10_000):
    a = 0
    b = 1
    for i in range(n):
        a, b = b, a + b
    return a

lateralus = fibonacci(16)  # here we observe the added side effect of logging the call

print(f"{lateralus = }")  # as above, so below

# and beyond I imagine
fibonacci()
Calling fibonacci with (16,) and {}
lateralus = 987
Calling fibonacci with () and {}
33644764876431783266621612005107543310302148460680063906564769974680081442166662368155595513633734025582065332680836159373734790483865268263040892463056431887354544369559827491606602099884183933864652731300088830269235673613135117579297437854413752130520504347701602264758318906527890855154366159582987279682987510631200575428783453215515103870818298969791613127856265033195487140214287532698187962046936097879900350962302291026368131493195275630227837628441540360584402572114334961180023091208287046088923962328835461505776583271252546093591128203925285393434620904245248929403901706233888991085841065183173360437470737908552631764325733993712871937587746897479926305837065742830161637408969178426378624212835258112820516370298089332099905707920064367426202389783111470054074998459250360633560933883831923386783056136435351892133279732908133732642652633989763922723407882928177953580570993691049175470808931841056146322338217465637321248226383092103297701648054726243842374862411453093812206564914032751086643394517512161526545361333111314042436854805106765843493523836959653428071768775328348234345557366719731392746273629108210679280784718035329131176778924659089938635459327894523777674406192240337638674004021330343297496902028328145933418826817683893072003634795623117103101291953169794607632737589253530772552375943788434504067715555779056450443016640119462580972216729758615026968443146952034614932291105970676243268515992834709891284706740862008587135016260312071903172086094081298321581077282076353186624611278245537208532365305775956430072517744315051539600905168603220349163222640885248852433158051534849622434848299380905070483482449327453732624567755879089187190803662058009594743150052402532709746995318770724376825907419939632265984147498193609285223945039707165443156421328157688908058783183404917434556270520223564846495196112460268313970975069382648706613264507665074611512677522748621598642530711298441182622661057163515069260029861704945425047491378115154139941550671256271197133252763631939606902895650288268608362241082050562430701794976171121233066073310059947366875

Съществуват различни полезни декоратори, които ни улесняват с имплеменетирането на често-срещани ООП фунцкионалности.

@property#

В някои езици наричано “computed property”, това е поле/атрибут, чиято стойност се изчислява при използването му и обикновено не се съхранява никъде. На практика това е метод, който се използва със синтаксиса атрибут. Достатъчно е да декорираме с @property нормален метод, за да го превърнем в атрибут.

class Player:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name

doncho = Player("Doncho")
print(doncho.name)
Doncho

@setter#

Освен read-only computed property-та, можем да имаме и writable такива. Това се постига чрез декоратора @{propertyname}.setter, който се използва върху метод, който да се изпълнява при присвояване на стойност на атрибута ({propertyname} се замества с името на property-то).

class Player:
    def __init__(self, xp):
        self._xp = xp

    @property
    def level(self):
        return self._xp // 1000 + 1
    
    @level.setter
    def level(self, value):  # has to have the same name!
        self._xp = (value - 1) * 1000
    
newb = Player(0)
print(f"{newb.level = }")

print("now the player buys something and advances some levels automatically...")
newb.level = 10  # ...cheater

print(f"{newb.level = }")
print(f"{newb._xp = }")
newb.level = 1
now the player buys something and advances some levels automatically...
newb.level = 10
newb._xp = 9000

@staticmethod#

Ако дефинираме функция в блока за дефиниция на клас, тя става метод на класа и при извикването ѝ винаги имплицитно се предава като първи параметър съответната инстанция на класа. Но как да създадем метод, който не зависи от инстанцията?

class A:
    def instance_method(self):
        print(self)

    def static_method():
        print("I am free")

a = A()
a.instance_method()
A.static_method()
a.static_method()
<__main__.A object at 0x113d60be0>
I am free
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/var/folders/7x/gdf0mbts74n8p_ckv_b_mfc40000gp/T/ipykernel_62173/666281317.py in <module>
      9 a.instance_method()
     10 A.static_method()
---> 11 a.static_method()

TypeError: static_method() takes 0 positional arguments but 1 was given

Ако в горния случай изпълним A.static_method() няма да бъде направен опит да се предаде инстанция като първи параметър, понеже такава нямаме. Но не винаги можем да сме сигурни, че метода ще бъде извикан върху класа, а не върху конкретен обект. Начинът по който можем да подсигурим безпроблемното изпълнение и в двата случая е да декорираме метода със @staticmethod:

class A:

    @staticmethod
    def static_method():
        print("I am free")

a = A()
A.static_method()
a.static_method()
I am free
I am free

@classmethod#

Понякога може да искаме като първи аргумент на метода да ни бъде подадена не инстанцията, а самият клас. Такъв метод трябва да декорираме с @classmethod. Отново, също както със self, името на аргумента може да е всякакво, но по конвенция е прието да се кръщава cls (понеже class е запазена дума).

class User:
    def __init__(self, username, email, money):
        self.username = username
        self.email = email
        self.money = money

    @classmethod
    def from_json_dict(cls, json):
        username = json["username"]
        email = json["email"]
        money = json["money"]
        return cls(username, email, money)

    def __repr__(self):
        return f"User({self.username}, {self.email}, {self.money})"


json_data = {
    "username": "yalishanda",
    "email": "yalishanda@example.com",
    "money": 420
}

user = User.from_json_dict(json_data)
print(user)
User(yalishanda, yalishanda@example.com, 420)

ABC (Abstract Base Class) и @abstractmethod#

Модулът abc ни предоставя начини, с които да дефинираме абстрактни базови класове. Те не могат да бъдат инстанцирани, когато съдържат един или повече от един абстрактни метода, чиято цел е да опишат общ интерфейс и да бъдат имплементирани от наследниците на класа.

from abc import ABC, abstractmethod

class Animal(ABC):
    def __init__(self, name):
        self._name = name

    @abstractmethod
    def make_sound(self):
        pass

    def pet(self):
        print(f"Petting {self._name}...")
        self.make_sound()

class Dog(Animal):
    def make_sound(self):
        print("woof")

class Cat(Animal):
    def make_sound(self):
        print("purr")

class Snek(Animal):
    def make_sound(self):
        print("sss")

doge = Dog("Doge")
doge.pet()

catto = Cat("Мишо")
catto.pet()

snek = Snek("Съска")
snek.pet()

base = Animal("опа")
Petting Doge...
woof
Petting Мишо...
purr
Petting Съска...
sss
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/var/folders/7x/gdf0mbts74n8p_ckv_b_mfc40000gp/T/ipykernel_62173/3318180173.py in <module>
     34 snek.pet()
     35 
---> 36 base = Animal("опа")

TypeError: Can't instantiate abstract class Animal with abstract method make_sound

Примери (задачи за решаване в час)#

Пример 1#

Напишете клас Rectangle, описващ правоъгълник, със следните характеристики:

  • дължина

  • широчина

  • цвят (низ)

  • методи, връщащи стойностите на член-данните на класа

  • метод за изчисляване на лице

Напишете клас Circle, описващ кръг, със следните характеристики:

  • радиус

  • цвят (низ)

  • методи, връщащи стойностите на член-данните на класа

  • метод за изчисляване на лице

Напишете клас Shapes, със следните характеристики:

  • съдържа в себе си горе-дефинираните геометрични фигури

  • метод за добавяне на нов кръг към колекцията

  • метод за добавяне на нов правоъгълник към колекцията

  • метод, който връща сумата от всички лица на правоъгълниците

  • метод, който връща сумата от всички лица на кръговете

  • метод, който връща геометрична фигура по подаден индекс

Пример 2#

Съществуват различни видове броячи - някои могат само да увеличават бройката, която пазят, други могат и да я намалят, а трети имат ограничение до колко могат да отброяват.

Клас Counter#

Най-простият брояч - само нагоре, без ограничение.

  • Инициализатор с параметри initial=0 (начална стойност) и step=1 (стъпка)

  • increment(): увеличава текущата стойност със стъпката на брояча

  • get_total(): връща int - текущата отброена стойност

  • get_step(): връща int - стъпката на брояча (не трябва да може да бъде променяна)

Клас TwowayCounter#

Брояч, който може и да намалява отброяваната стойност.

Освен всичко изброено в Counter, съдържа и:

  • decrement(): намалява текущата стойност със стъпката на брояча

Клас LimitedCounter#

Брояч, който отброява само до дадена максимална стойност.

  • Инициализатор с 3 параметъра max (максимална стойност), initial=0 (начална ст-т) и step=1 (стъпка)

  • increment(): увеличава текущата стойност със стъпката на брояча само ако няма да надмине максималната

  • get_max(): връща int - максималната стойност на брояча

  • get_total(): същия като този на Counter

  • get_step(): същия като този на Counter

Клас LimitedTwowayCounter#

Той е и LimitedCounter, и TwowayCounter едновременно: може да отброява нагоре до определена максимална стойност и надолу до определена минимална стойност.

  • Инциализатор с 4 параметъра min (минимална ст-т), max (максимална ст-т), initial=0 (начална ст-т) и step=1 (стъпка)

  • increment(): същия като на LimitedCounter

  • decrement(): намаля текущата стойност със стъпката на брояча само ако няма да стане по-ниска от минималната

  • get_min(): връща минималната стойност на брояча

  • get_max(): същия като този на LimitedCounter

  • get_total(): същия като този на Counter

  • get_step(): същия като този на Counter

Клас Semaphore#

Най-простия бинарен семафор - това е LimitedTwowayCounter, който има минимална стойност 0, максимална стойност 1 и стъпка 1. Използва се от процесите в операционнитe системи за синхронизационни цели. (За повече информация: https://skelet.ludost.net)

  • Инициализатор с 1 параметър is_available=False. При True началната стойност на брояча е 1, а при False е 0.

  • is_available(): връща bool, показващ дали стойността на брояча е над 0

  • wait() - прави същото като decrement() на LimitedTwowayCounter

  • signal() - прави същото като increment() на LimitedTwowayCounter

Pythonic bonus: Use @property instead of all the getters.

Двата примера достигат до вас благодарение на курса по ООП 2020/2021 за СИ.#

Задачи за упражнение (за вкъщи)#

Задача 0#

Дефинирайте клас PolarCoordinate, който да моделира точка чрез двумерни полярни координати. Класът трябва да има:

  • инициализатор, който приема два аргумента - радиус \( r \) и ъгъл \( \phi \) (ще интерпретираме мерната му единица да е в радиани)

  • атрибути/методи r и angle за достъп до съответните полета, но не и за тяхната промяна

  • метод to_cartesian(), който връща tuple от две числа (x, y) - координатите на точката, конвертирани в декартова система по формулата:

    • \( x = r\cos(\phi) \)

    • \( y = r \sin(\phi) \)

  • клас-метод from_cartesian(x, y), който приема два аргумента - координатите на точката в декартова система - и създава и връща инстанция на PolarCoordinate, изчислена по формулата:

    • \( r = \sqrt{x^2 + y^2} \)

    • \( \phi = \arctan(\frac{y}{x}) \)

  • дефинирана подходяща репрезентация на обекта

  • дефиниция на конвертиране в str във формат (r: {self.r}, angle: {self.angle})

  • дефиниране на хеширане

  • дефиниране на сравнение с == и !=

Важно: стойностите на полетата на класа не трябва да могат да бъдат променяни по никакъв начин, освен чрез инициализатора, с цел гарантиране на immutability.

from math import sqrt, sin, cos, atan  # you will need these
from math import pi, isclose  # the tests below will need these


class PolarCoordinate:
    pass  # your code here


p1 = PolarCoordinate(1, pi/6)

print(p1.r == 1)
print(p1.angle == pi/6)

p2 = PolarCoordinate.from_cartesian(3, 4)
print(isclose(p2.r, 5))
print(isclose(p2.angle, atan(4/3)))

x, y = p2.to_cartesian()
print(isclose(x, 3))
print(isclose(y, 4))

p3 = PolarCoordinate(1, 0)
print(str(p3) == "(r: 1, angle: 0)")
print(repr(p3) == "PolarCoordinate(1, 0)")

pp1, pp2, pp3 = PolarCoordinate(1, pi/6), PolarCoordinate.from_cartesian(3, 4), PolarCoordinate(1, 0)
print(p1 == pp1)
print(p2 == pp2)
print(p3 == pp3)

d = {p1: "A", p2: "B", p3: "C"}
print(d[pp1] == "A")
print(d[pp2] == "B")
print(d[pp3] == "C")

s = {p1, p2, p3, pp1, pp2, pp3, p1, p2, p3}
print(len(s) == 3)

Задача 1#

Дефинирайте клас Player, който да моделира играч от RPG игра. Трябва да има следните атрибути и техните условия към тях:

  • name - име на играча (read-only)

  • hp - жизнени точки на играча (да не могат да падат под 0, по подразбиране са 10)

  • xp - опит на играча (да може само да бъде увеличаван, по подразбиране е 0)

  • level - ниво на играча (read-only), зависи от xp по формулата:

    • \( level = 1 \), ако xp е по-малко от 300;

    • \( level = 2 + \log_2 int(\frac{xp}{300}) \), иначе.

(т.е. 0-299 XP са ниво 1, 300-599 XP са ниво 2, 600-1199 XP са ниво 3, 1200-2399 са ниво 4 и т.н.)

Имате свобода при имплементацията на класа, стига горните атрибути да съществуват и условията им да са винаги изпълнени.

Note: за \( log_2 \) може да използвате math.log2. Функцията int в горната формула е аналогична на конструктора на int в Python, както и на floor функцията, т.е. закръглянето към цяло число е винаги надолу.

Задача 2#

Дефинирайте клас Vector3D, който да репрезентира вектор в тримерното пространство. Класът трябва да има поне следните методи:

  • __init__(self, x, y, z): инициализира вектора със стойности x, y и z

  • __repr__(self): връща репрезентация на обекта

  • __str__(self): връща стрингово представяне на вектора във формат "(x, y, z)" (например, "(1, 2, 3)")

  • __add__(self, other): връща сумата на два вектора (или на вектор (вляво) с число (вдясно)) за целите на задачата, нека дефинираме:

    • сума на вектор \( v = (v_x, v_y, v_z) \) с вектор \( u = (u_x, u_y, u_z) \) като \( u + v = (v_x + u_x, v_y + u_y, v_z + u_z) \)

    • сума на вектор \( v = (v_x, v_y, v_z) \) с число \( \lambda \) като \( u + \lambda = (v_x + \lambda, v_y + \lambda, v_z + \lambda) \)

  • __radd__(self, other): връща сумата на число (в ляво) с вектор (в дясно)

  • __iadd__(self, other): прибавя other към self и връща self

  • __mul__(self, other): връща “произведение”, дефинирано по следния начин:

    • произведението \( v * u \) на два вектора \( v = (v_x, v_y, v_z) \) и \( u = (u_x, u_y, u_z) \) като \( v * u = (v_x u_x, v_y u_y, v_z u_z) \)

    • произведението \( v * \lambda \) на вектора \( v = (v_x, v_y, v_z) \) и числото \( \lambda \) като \( v * \lambda = (v_x \lambda, v_y \lambda, v_z \lambda) \)

  • __rmul__(self, other): връща произведението (както е дефинирано горе) на число с вектор

  • __imul__(self, other): умножава other към self и връща self

  • __eq__(self, other): връща True, ако два вектора са поелементно равни, и False в противен случай

  • __ne__(self, other): връща обратното на __eq__

  • __abs__(self): връща дължината на вектора (по формулата \( \sqrt{x^2 + y^2 + z^2} \) )

  • __getattr__(self, name): пренасочва X, Y и Z към x, y и z (например vector.X да е еквивалентно на vector.x)

  • __setattr__(self, name, value), аналогичен на горния

  • __iter__(self): връща итератор на вектора, който да позволява итериране в реда x, y, z.

Добавете още каквито методи смятате за нужни.

Note 1: нека в контекста на задачата за скалари смятаме int, float и complex.

Note 2: корен квадратен може да изчислите с math.sqrt или като повдигнете нещо на степен 0.5.

Note 3: понеже още не сме учили грешки и изключения, не се очаква да хвърляте такива в случай, че някой аргумент е невалиден. Очаква се обаче поне където се налага да проверите за това. Ако не ви се разучава как да raise-нете TypeError, AttributeError или ValueError, може вместо това да return-нете None за сега.

Задача 3#

В тази задача ще направим мини-framework за чертане на UI в конзолата.

С цел той да бъде декларативен и лесно композируем, всички елементи трябва да имат метод render(), който да връща str със съдържанието на елемента.

Различните елементи могат да биват:

  • Spacer(length=1): празно място с дадена дължина (length на брой интервали)

  • Line(length, symbol="-"): ред от символи с дадена дължина (примерно Line(10).render() трябва да върне "----------")

  • Text(text): текст със съдържание text

  • FancyText(text, symbol="="): текст със съдържание text, между всeки символ на който има сложен symbol, както и в началото и в края му (примерно FancyText("Hello").render() трябва да върне "=H=e=l=l=o=")

  • HorizontalStack(*elements): елементи, подредени един до друг в ред (слепени на реда / разделени чрез “”) (примерно HorizontalStack(Text("Hello"), Line(3), Text("World")).render() трябва да върне "Hello---World")

  • VerticalStack(*elements): елементи, подредени един под друг (разделени чрез симвла за празен ред) (примерно VerticalStack(Text("Hello"), Line(3), Text("World")).render() трябва да върне "Hello\n---\nWorld")

  • Box(width, *elements): елемент, който е като VerticalStack, с разликата че:

    • преди и след elements добавя HorizontalStack(Text("+"), Line(width - 2, symbol="="), Text("+"))

    • всеки ред е с дължина width и започва и завършва с "|", т.е. при render()-ването на всеки елемент от elements добавя Text("|") в началото и в края на реда. В случай, че дължината на реда е повече от width отрязва излишъка от подадения елемент, a в случай, че дължината е по-малка - добавя Spacer след елемента с дължина width - len(element.render()) - 2.

  • ProgressBar(length, progress): прогрес бар с дължина length символа и коефициент на запълване progress (\( \in [0, 1] \)) (примерно ProgressBar(10, 0.5).render() трябва да върне "[====----]")

Добавяйте каквито прецените други класове и методи към тях.

Пример#

ui = Box(19,
    FancyText("WELCOME!"),
    Spacer(),
    Text("Loading packages:THIS SHOULD NOT BE SHOWN IN THE BOX"),
    HorizontalStack(
        Line(3),
        Spacer(),
        Text("cowsay")
    ),
    HorizontalStack(
        Line(3),
        Spacer(),
        Text("lolcat")
    ),
    HorizontalStack(
        Line(3, symbol=">"),
        Spacer(),
        Text("whoami"),
        Text("...")
    ),
    Spacer(),
    HorizontalStack(
        Spacer(),
        ProgressBar(15, 0.4),
        Spacer()
    )
)

print(ui.render())

Изход:

+=================+
|=W=E=L=C=O=M=E=!=|
|                 |
|Loading packages:|
|--- cowsay       |
|--- lolcat       |
|>>> whoami...    |
|                 |
| [======-------] |
+=================+