Очень важная парадигма в программировании – объектно-ориентированное программирование (ООП).
Объекты создаются с помощью классов, основного понятия ООП.
Класс описывает объект, но является независимым от него. Иными словами, класс – это экземпляр, описание или определение объекта.
Вы можете использовать класс в качестве образца для создания различных объектов.

Классы оформляются с помощью ключевого слова class и в виде блока с отступом, содержащего методы класса (которые являются функциями).
Ниже приведен пример простого класса и его объектов.

class Cat: 
  def __init__(self, color, legs): 
    self.color = color 
    self.legs = legs 
 
felix = Cat("ginger", 4) 
rover = Cat("dog-colored", 4) 
stumpy = Cat("brown", 3)


Во фрагменте кода вверху нами определен класс с именем Cat, у которого два атрибута: color и legs.
Затем класс используется для создания 3 отдельных объектов, принадлежащих этому классу.


Метод __init__ – самый важный метод класса.
Он вызывается, когда создается экземпляр (объект) класса; имя класса используется как функция.

Все методы должны иметь self в качестве своего первого параметра; хотя self непосредственно не передается, Python добавляет инструкцию self в список сам; также не нужно включать self, когда вы вызываете методы. В пределах определения метода, инструкция self относится к экземпляру класса, вызывающему метод.

Экземпляры класса берут атрибуты – фрагменты связанных с ними данных.
В нашем примере, экземпляры класса Cat имеют атрибуты color и legs. Их можно получить, указав точку и имя атрибута после экземпляра.
Таким образом внутри метода __init__ с помощью self.attribute можно задать начальное значение атрибутов экземпляра.

class Cat:
    def __init__(self, color, legs):
        self.color = color
        self.legs = legs

felix = Cat("ginger", 4)
print(felix.color)



В приведенном выше примере, метод __init__ принимает два аргумента и присваивает их объекту в качестве его атрибутов. Метод __init__ называют конструктор класса.

Можно использовать и другие методы, расширяющие функциональность классов.
Помните, что первым параметром всех методов должен быть self.
Используйте тот же синтаксис (dot), что и для атрибутов.

class Dog:
  def __init__(self, name, color):
    self.name = name
    self.color = color

  def bark(self):
    print("Woof!")

fido = Dog("Fido", "brown")
print(fido.name)
fido.bark()



Попытка вызова атрибута экземпляра, который не был определен вызывает AttributeError. Такая же ошибка выдается при попытке вызова несуществующего метода.

С помощью наследования мы можем задать единую функциональность разным классам.
Допустим, у нас есть несколько классов: Cat, Dog, Rabbit и другие. Некоторые методы этих классов будут уникальными: только Dog будет иметь метод bark (англ. лай). Но другие методы будут одинаковыми: все классы будут иметь color и name.
Это сходство можно выразить c помощью функции наследования, так чтобы все классы наследовали общую функциональность от суперкласса Animal.
Наследование оформляется путем заключения в круглые скобки имени суперкласса, следующего за именем класса.

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

class Cat(Animal):
    def purr(self):
        print("Purr...")
        
class Dog(Animal):
    def bark(self):
        print("Woof!")

fido = Dog("Fido", "brown")
print(fido.color)
fido.bark()



Класс, наследующий атрибуты или методы другого класса, называется подклассом.
Класс, из которого наследуются атрибуты или методы, называется суперклассом.
Если наследуемый класс имеет такие же атрибуты или методы, что и класс-наследник, то класс-наследник переопределяет их.

class Wolf: 
    def __init__(self, name, color):
        self.name = name
        self.color = color

    def bark(self):
        print("Grr...")

class Dog(Wolf):
    def bark(self):
        print("Woof")

husky = Dog("Max", "grey")
husky.bark()



В примере вверху у нас суперкласс Wolf и подкласс Dog.

Наследование может быть также непрямым. Один класс может наследовать от другого класса, который в свою очередь может наследовать от третьего класса.

class A:
    def method(self):
        print("A method")
    
class B(A):
    def another_method(self):
        print("B method")
    
class C(B):
    def third_method(self):
        print("C method")
    
c = C()
c.method()
c.another_method()
c.third_method()



Функция super – полезная функция наследования, которая указывает на родительский класс. Предназначена для поиска метода по его имени в суперклассе объекта.

class A:
    def spam(self):
        print(1)

class B(A):
    def spam(self):
        print(2)
        super().spam()

B().spam()



super().spam() вызывает метод суперкласса spam.

Существует несколько магических методов, которые задают классам функциональность контейнеров.
__len__ для len()
__getitem__ для индексации
__setitem__ для присваивания значения индексированному элементу
__delitem__ для удаления индексированных элементов
__iter__ для перебора объектов (например, в циклах for)
__contains__ для in


Существует множество других магических методов, которые мы не будем рассматривать здесь. Например: __call__, используемый для вызова объектов как функций; __int__, __str__ и другие подобные им для преобразования объектов в родные для Python типы данных.

import random

class VagueList:
    def __init__(self, cont):
        self.cont = cont

    def __getitem__(self, index):
        return self.cont[index + random.randint(-1, 1)]

    def __len__(self):
        return random.randint(0, len(self.cont)*2)

vague_list = VagueList(["A", "B", "C", "D", "E"])
print(len(vague_list))
print(len(vague_list))
print(vague_list[2])
print(vague_list[2])



Мы переопределили функцию len() для класса VagueList так, чтобы она вернула случайное число.
Функция индексации также возвращает случайный элемент в заданном диапазоне со списка.

Создание, использование и уничтожение составляют жизненный цикл объекта.

Первый этап жизненного цикла объекта – определение класса, к которому он принадлежит.
Следующий этап – инстанцирование экземпляра, когда вызывается метод __init__. Выделяется память под хранение экземпляра. Непосредственно перед этим вызывается метод класса __new__. Это действие, как правило, отменяется только в редких случаях.
После этого объект готов к использованию.

Одним из ключевых понятий объектно-ориентированного программирования является инкапсуляция – упаковка в целях простоты использования связанных переменных и функций в один объект (экземпляр класса).
Сокрытие данных – близкое к инкапсуляции понятие, суть которого в том, что детали реализации класса должны быть скрыты, и чистый стандартный интерфейс должен быть представлен тем, кто будет использовать класс.
В других языках программирования это достигается с использованием частных методов и атрибутов, которые закрывают доступ извне к определенным методам и атрибутам класса.

Идеология Python несколько иначе. В сообществе Python часто звучит фраза «мы все взрослые и по своему согласию здесь», что означает, что не следует устанавливать свои ограничения на доступ к отдельным частям класса. Так как все равно невозможно обеспечить строгую частность метода или атрибута.

Условно частные методы и атрибуты оформляются с единым подчеркиванием в начале имени.
Это частные методы, которые не должны взаимодействовать со внешней частью программы. Но часто это правило условно; внешняя часть программы может получить к ним доступ.
Реальная особенность этих методов лишь в том, что from module_name import * не будет импортировать переменные, которые начинаются с единого подчеркивания.

class Queue:
    def __init__(self, contents):
        self._hiddenlist = list(contents)

    def push(self, value):
        self._hiddenlist.insert(0, value)

    def pop(self):
        return self._hiddenlist.pop(-1)

    def __repr__(self):
        return "Queue({})".format(self._hiddenlist)

queue = Queue([1, 2, 3])
print(queue)
queue.push(0)
print(queue)
queue.pop()
print(queue)
print(queue._hiddenlist)



Во фрагменте кода вверху атрибут _hiddenlist помечен как частный, но внешний код все же сможет получить к нему доступ.
Магический метод __repr__ возвращает экземпляр в виде строки.

Строго частные методы и атрибуты оформляются с двойным подчеркиванием в начале имени. Таким образом их имена искажаются, и внешняя часть программы не может получить к ним доступ.
Но это делается не для того, чтобы обеспечить их частность, а чтобы избежать ошибок, если где-либо в коде есть подклассы, которые имеют методы или атрибуты с такими же именами.
Методы с искаженными именами все же могут быть вызваны извне, но по другим именам. Метод __privatemethod класса Spam может быть вызван извне по имени _Spam__privatemethod.

class Spam:
    __egg = 7
    def print_egg(self):
        print(self.__egg)

s = Spam()
s.print_egg()
print(s._Spam__egg)
print(s.__egg)



Методы объектов, рассмотренных нами до сих пор, вызываются экземпляром класса, который затем передается в параметр метода self.
Методы класса несколько другие: они вызываются классом, который передается параметру cls метода.
Чаще всего это используется в фабричных методах: создается экземпляр класса, при этом используются иные параметры, чем те, которые обычно передаются в конструктор класса.
Методы класса оформляются с декоратором classmethod.

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

    @classmethod
    def new_square(cls, side_length):
        return cls(side_length, side_length)

square = Rectangle.new_square(5)
print(square.calculate_area())




Статические методы похожи на методы класса с тем отличием, что они не берут никаких дополнительных аргументов; они аналогичны обычным функциям класса.
Они оформляются с декоратором staticmethod.

class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings

    @staticmethod
    def validate_topping(topping):
        if topping == "pineapple":
            raise ValueError("No pineapples!")
        else:
            return True

ingredients = ["cheese", "onions", "spam"]
if all(Pizza.validate_topping(i) for i in ingredients):
    pizza = Pizza(ingredients)



Статические методы ведут себя как обычные функции с тем отличием, что вы можете вызывать их экземпляром класса.
В свойствах мы можем настроить доступ к атрибутам экземпляра.
Чтобы создать свойства, непосредственно перед методом помещается декоратор property: при вызове атрибута экземпляра с таким же именем, что и у метода, вместо него будет вызван метод.
Один из распространенных способов их применения – присвоение атрибуту свойства «только для чтения».

class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings

    @property
    def pineapple_allowed(self):
        return False

pizza = Pizza(["cheese", "tomato"])
print(pizza.pineapple_allowed)
pizza.pineapple_allowed = True



Свойства также могут быть заданы с помощью функций setter/getter.
Функция setter устанавливает значение соответствующего свойства.
Функция getter возвращает значение.
Чтобы определить setter, используется декоратор с таким же именем, что и у свойства, с последующим ключевым словом setter, разделенные точкой.
Точно так же определяются функции getter.

class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings
        self._pineapple_allowed = False

    @property
    def pineapple_allowed(self):
        return self._pineapple_allowed

    @pineapple_allowed.setter
    def pineapple_allowed(self, value):
        if value:
            password = input("Enter the password: ")
            if password == "Sw0rdf1sh!":
                self._pineapple_allowed = value
            else:
                raise ValueError("Alert! Intruder!")

pizza = Pizza(["cheese", "tomato"])
print(pizza.pineapple_allowed)
pizza.pineapple_allowed = True
print(pizza.pineapple_allowed)