Сайт вебмастера

Объектно-ориентированное программирование в Python для чайников

22-06-2023Время чтения ~ 11 мин.Python 2961

ООП в Python имеет много особенностей, что делает его непохожим на другие языки. С одной стороны Python очень простой язык и делать на нём программы в ООП-стиле легко, но с другой стороны — в нём нет привычных вещей, которые сейчас являются «стандартным набором» любого ООП-подхода. Если вы новичок в программировании, то эта статья для вас.

Понятие объекта и класса

Объектно-ориентированное программирование — это такой способ программирования, где вместо манипулирования функциями и переменными, используется объект, который содержит в себе функции и переменные. Такой подход позволяет упростить в первую очередь поддержку кода: если в программе сотни функций и переменных, то легко что-то «сломать». Если же сгруппировать их по объектам по функциональности, то это упростит сам код и его структуру.

Для того, чтобы создать объект, следует определить его тип. Тип переменной для объектов называется классом. Рассмотрим такой пример:

# объявим класс
class A():
    # метод класса
    def out(self, t):
        print(t)

# создание экземпляра класса
a = A()

# использование метода класса
a.out('Hello!')  # Hello!

Класс объявляется с помощью ключевого слова class. Всё что в его блоке будет относиться к нему. Внутри класса мы можем определить произвольные функции, которые называются методами. Для методов используется привычное def, но первым аргументом должен указываться self — это ссылка на текущий объект (об этом ниже). Если метод имеет другие аргументы, то они указываются после self как обычно.

Сам по себе класс — это тип данных, который объявлен пользователем. Он точно такой же как и стандартные типы, вроде строк или чисел. Для того, чтобы использовать класс, как и любой другой тип данных, следует создать переменную.

В нашем примере переменная a и есть объект класса A.

В других языках для создания объектов часто используются специальное ключевое слово, например new, но в Python создание объекта синтаксически оформляется точно также, как и обычных функций — со скобками.

После того, как объект создан, можно его использовать. В примере используется вызов метода out(). Обратите внимание, что хотя в классе первым аргументом стоит self, при вызове методов его не нужно указывать, то есть как будто бы его нет.

Это также особенность Python, которую следует запомнить.

Класс формирует свою область видимости, поэтому чтобы вызвать метод, нужно вначале указать его класс. Для больших программ, где много функций, их группировка по классам, упростит жизнь многих программистов.

Кроме методов, класс может хранить и собственные переменные, которые называются полями. рассмотрим ещё один пример:

class Fig():
    def set(self, x, y):
        self.x = x
        self.y = y

    def square(self):
        print(self.x * self.y)

rect = Fig()
rect.set(10, 20)
rect.square()  # 200

Метод set() принимает два аргумента (не считая self), которые присваиваются полям класса. Поля класса задаются через self. Назначение self очень простое — она указывает на текущий объект. То есть когда мы объявляем переменную объекта, то self принимает на него ссылку. Поскольку объектов одного класса может быть множество, то self будет гарантировать данные только текущего объекта.

Строго говоря имя self может быть произвольным. Просто так принято в Python.

Соответственно, когда нам нужно получить значение поля, мы опять же его получаем через self.

Понятие инкапсуляции

Слово инкапсуляция переводится in capsula — «в капсуле», то есть внутри. Сокрытие внутри класса/объекта методов и полей — это и есть инкапсуляция — одного из ключевых понятий объектно-ориентированного программирования.

Всё работает автоматом, поэтому нам не о чем беспокоиться.

Однако следует заметить, что в понятие инкапсуляции также входит ограничение области видимости. Например объект может содержать методы или поля, которые не следует использовать явно. В других языках для этого используются специальные ключевые слова, например private, public, protected, но в Python такой возможности нет. Вместо этого используется соглашение в именовании методов и полей, которое указывает на то, что они скрытые и их не следует использовать.

Например в последнем примере мы используем rect.set(10, 20) для self.x и self.x. Но никто не мешает нам напрямую обратиться к этим полям:

rect = Fig()
rect.x = 10
rect.y = 30
rect.square()  # 300

В таких случаях говорят о том, что поля объекта x и y являются публичными, а значит могут изменяться вне класса. Если бы мы хотели их спрятать, то их следует переименовать в _x и _y. такая нотация будет означать, что поля защищены и не предназначены для изменения извне.

На самом деле к _x и _y можно будет обратиться напрямую, поскольку Python никак не проверяет видимость переменной — это всего лишь соглашение между программистами. Если бы мы хотели ещё больше скрыть поля и методы, то их следует именовать с двойного подчеркивания — в этом случае Python выкинет ошибку.

class Fig():
    def set(self, x, y):
        self.__x = x
        self.__y = y

    def square(self):
        print(self.__x * self.__y)

rect = Fig()

# при прямом обращении будет ошибка
# rect.__x = 10 # AttributeError: 'Fig' object has no attribute '_Fig__x'
# rect.__y = 30 # AttributeError: 'Fig' object has no attribute '_Fig__y'

# а так можно
rect.set(10, 20)

rect.square()  # 200

Конструктор и «магические методы»

В Python есть методы, которые начинаются и заканчиваются с двойного подчеркивания — это т.н. магические методы, которые имеют специальное назначение. Например есть метод __init__(), который выполняется в момент инициализации (создания) объекта и называется конструктор.

В конструкторе как правило размещают начальные значения полей, а также выполняют какие-то начальные действия при создании объекта.

В нашем примере в классе Fig нужно вначале вызвать set(), чтобы задать начальные значения переменных. Если мы попробуем сразу вызвать rect.square(), то получим ошибку Python, поскольку поля объекта ещё не определены.

Поэтому можно сделать некое дефолтное значение полей в конструкторе.

class Fig1():
    def __init__(self):
        self._x = 0
        self._y = 0

    def set(self, x, y):
        self._x = x
        self._y = y

    def square(self):
        print(self._x * self._y)

rect = Fig1()
rect.square()  # 0

При вызове rect.square() мы получим значение с дефолтными значениям и ошибки не будет.

Однако часто используют немного другой приём — можно сразу указать аргументы при создании объекта. Делается это в конструкторе.

class Fig2():
    def __init__(self, x = 0, y = 0):
        self._x = x
        self._y = y

    def set(self, x, y):
        self._x = x
        self._y = y

    def square(self):
        print(self._x * self._y)

rect = Fig2(20, 40)
rect.square()  # 800

rect.set(10, 20)
rect.square()  # 200

Такой подход позволяет указывать аргументы при создании объекта, а при необходимости изменить их с помощью отдельного метода (называется «сеттер» — от set).

В Python также есть другие магические методы, но они достаточно специфичные, поэтому сейчас не рассматриваем.

Наследование

Наследование ещё одна фундаментальная концепция ООП. Она позволяет создавать классы на основе других. Например есть некий класс, которые нужно расширить и добавить новые методы. Вместо того, чтобы изменять исходный класс (а если это вообще чужой модуль?), можно просто воспользоваться наследованием.
class X():
    def out(self, s):
        print(s)

class Y(X):
    pass  # любые другие методы

y = Y()
y.out('hello!')  # hello!

Класс Y создан на основе класса X — это указывается в скобках после названия класса. То есть класс X будет родительским, а Y — дочерним. При этом в классе Y автоматически будут доступны все методы и поля класса X.

Такой механизм и называется наследованием.

Python поддерживает множественное наследование — это когда можно указать сразу несколько родительских классов. В программировании множественное наследование не особо приветствуется из-за возможных коллизий, но в Python такая возможность есть.

Полиморфизм

Полиморфизм — ещё одна концепция программирования, которая переводится как «много форм» — это возможность функции работать с разными типами данных. Существует несколько видов полиморфизма и в разных языках используются разные реализации.

Например функция print() может работать с разными типами:

print(120)
print([1, 2, 3])
print('hello')
print(1.234)

То есть когда функция может принимать аргументы разных типов — она считается полиморфной. По большому счёту есть два основных вида полиморфизма: Ad-hoc и Параметрический.

  • Ad-hoc полиморфизм — функция ведет себя по разному для аргументов разных типов. Например если функции передать число, то она посчитает его квадрат, а если строку, то сделает её реверс. Разное поведение в зависимости от входящего типа данных.
  • Параметрический полиморфизм — функция ведет себя одинаково для аргументов разных типов. Например print() выводит данные разных типов на экран.
Также полиморфной может быть переменная, но в Python динамическая типизация, поэтому переменные могут быть любого типа.

Полиморфизм может быть частью языка, либо нет. Например в Pascal можно объявить несколько одноименных функций, но с разными аргументами и компилятор сам определит какая функция будет выполняться, в зависимости от входящих аргументов.

Но в Python такой возможности нет, поэтому реализация полиморфной функции ложится на плечи программиста. К счастью такие задачи редки, но ради полноты изложения покажу пример функции, которая может вести себя по разному в зависимости от типа входящего аргумента. Для проверки типа используется функция isinstance().

def add(x):
    if isinstance(x, str):
        print('Работаем со строками')
    elif isinstance(x, int):
        print('Работаем с целыми числами')
    elif isinstance(x, float):
        print('Работаем с дробными числами')

add(10)  # Работаем с целыми числами
add('hello')  # Работаем со строками
add(1.3)  # Работаем с дробными числами

Теперь перейдём к ООП.

Вызов метода — это по сути обычный вывод функции, поэтому как она себе будет вести зависит только от неё. Но в Python (да и в других языках) под полиморфизмом часто понимают приём, когда переписываются родительские методы.

class A():
    def out(self):
        print('class A')

class B(A):
    def out(self):
        print('class B')

my = B()
my.out()  # class B

Обратите внимание, что метод out() был переписан в дочернем классе. Таким образом, в зависимости от потребности мы можем использовать заданный метод, но в разных классах.

Такое «переписывание» методов возможно из-за того, что в Python все методы виртуальные.

Более сложный пример, когда используется некий интерфейс в виде отдельной функции:

class A():
    def out(self):
        print('class A')

class B():
    def out(self):
        print('class B')

def my(t):
    t.out()

a = A()
b = B()

my(a)  # class A
my(b)  # class B

Здесь два объекта, которые имею одинаковые методы (или только метод). Функция my() в качестве аргумента принимает какой-то из этих объектов и выполняет его метод. Таким образом в функции my() будет выполнять разные задачи в зависимости от входящего объекта.

Строго говоря, такое поведение не самое идеальное, но это из-за того, что в Python нет возможности создавать полноценные интерфейсы и выполнять какой-то контроль типов данных, как это делается в других языках. Поэтому, когда мы говорим о полиморфизме в Python, то ограничиваемся такими примитивами.

Статичные методы

Иногда стоит задача использовать методы класса без создания объекта. Например класс — это набор однотипных функций и можно упростить их вызов. В таких случаях говорят о статичных методах. В Python для этого используется декоратор @staticmethod.

class MyClass:
    @staticmethod
    def static1(x):
        print('hello', x)

MyClass.static1(42)  # hello 42

Заметьте, что мы сразу вызываем метод без создания объекта. Именно поэтому в методе static1() нет первого аргумента self — просто нет объекта и не на что ссылаться.

Однако, если всё-таки в классе нужно использовать какие-то поля, либо получить доступ к другим методам этого же класса, то используется другой декоратор @classmethod.

class MyRound:
    pi = 3.14

    @classmethod
    def go(cls, x):
        print(cls.pi * x)

MyRound.go(10)  # 31.400000000000002

То есть метод фактически статичный, но при этом есть доступ к самому классу.

В других языках статичные методы объявляются с помощью специального ключевого слова (static), но в Python такой возможности нет, поэтому используются декораторы в качестве «заменителей».

Поля класса и поля объекта

Python поддерживает динамическое создание полей класса. Например:

class D():
    pass

d = D()
d.id = 42
print(d.id) # 42

Обратите внимание, что в классе D нет поля id, но мы можем его создать после создания объекта и после использовать как ни в чём не бывало.

Ещё пример:

class L():
    pass

L.id = 64
print(L.id) # 64

Здесь для класса L даже не создаётся объект, но мы можем создать поле класса.

Эти примеры показывают, что Python разделяет понятия поле класса и поле объекта (экземпляра). Когда мы создаём объект, то его поля доступны через self, а вот поля класса создаются как обычные переменные.

Вот пример для изучения:

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

A.y = 200
print(A.y) # 200

b = A(30)
print(b.x) # 30
print(b.y) # 200

В классе A поле y объявлено как поле класса, поэтому у него нет self. И поэтому мы можем обратиться к этому полю даже без создания объекта.

После создания объекта b мы можем обратиться не только к self.x, который является полем экземпляра, но и к y (поле класса).

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

Паттерны проектирования классов на Python

Хорошей практикой программирования считается использование композиции, а не наследования. Это когда объекты используют другие объекты в разных вариантах. Ради интереса посмотрите несколько шаблонов, которые я делал для PHP. Все эти паттерны являются «стандартными» в программировании и их можно повторить на Python. Другой вопрос — нужно ли?

Дело в том, что такие паттерны нужны больше для теоретического изучения основ ООП и языка, где много завязано на «классический» ООП, за который можно взять язык Java. Но в Python отсутствуют огромные пласты возможностей, поэтому изучение теории объектно-ориентированного программирования на Python, на мой взгляд, лишено смысла.

Подход Python — это краткость и практичность. И если стоит задача написать программу, то его ООП хватит с головой. Да, такой код будет сильно отличаться от «теоретического», но зато будет простым и компактным.

Похожие записи