В этой главе мы будем рассматривать "базу" в объектно-ориентированном программировании. По сути без неё мы не сможем дальше рассматривать остальные парадигмы, такие как наследование, инкапсуляция, полиморфизм, etc.
Также в этой главе я намеренно опускаю конструкторы и деструкторы класса, так как считаю необходимым больше углубиться в механику и использование самих классов, а уже после наслаивать на них всё остальное.
Если вы внимательно читали предыдущую главу, то наверняка обратили внимание на то, что классы и объекты – это две раздельные сущности.
На практике новички часто даже не могут разделить эти понятия и путают их. Поэтому, чтобы добраться до непосредственно парадигм (в которую, тем не менее, класс также входит), сначала определимся с терминами.
Разбирая в прошлой главе пример на апельсинах, я в конце добавила, что глобально цитрусовые – это общий класс, и среднее описание апельсина, без частной конкретики, подходит для любого цитрусового.
Так вот, класс – описывает и моделирует будущее поведение объектов (экземпляров класса). С классами можно взаимодействовать напрямую, в обход непосредственно объекта, но основная цель – это "несущая конструкция" или "шаблон".
Факт состоит в том, что python спроектирован таким образом, что все, что бы вы не создавали в программе – всё, от переменных и даже сами классы будут являться объектами. Гвидо ван Россум спроектировал python так не случайно: именно такой конструкт позволяет python'у быть гибким (принципы такой гибкости мы рассмотрим позднее).
Сам же class является объектом метакласса type.
Об этом вообще редко пишут, но давайте я проговорю это.
В Python работает правило:
"Каждый класс – это объект (типа type), но не каждый объект – это класс"
Из этого вытекает следующее:
Абсолютно всё в python (числа, строки, функции, сами классы) – являются наследниками object,
Класс Car из первого примера – это object
Число 5 – это object
и из isinstance(X, object) не следует, что X – это экземпляр от object()
Существуют подклассы, указывающие наследование (кто чей подкласс) и создание (кто чей тип). Но это две параллельные оси
Класс int является подклассом object (issubclass(int, object) == True).
Но тип класса int — это type, а не object (type(int) is type)
Из type(A) == type(B) не следует, что у них одинаковое поведение
type(int) – это type
type(str) – это тоже type
У них один и тот же тип (type), но ведут они себя абсолютно по-разному и создают принципиально разные объекты.
Если A породило B, то обратно это не сработает
type может создать любой класс
НО! Любой класс НЕ МОЖЕТ стать метаклассом для type. type – это вершина на цепочке создания объектов и замыкает её на себе.
– Но зачем МНЕ эта инфа?
– За шкафом Вот тебе, читатель, целых 4 причины 😁:
Понимать и читать исходный код библиотек и понимать магию Pydantic-моделей
Проектировать сложные системы (например, для автоматической регистрации плагинов, создания строгих API реализации singleton-паттернов)
Проходить в будущем собеседования (а вопросы про петлю type и object и разницу наследований – это классические вопросы для проверки глубины знаний)
Эффективно отлаживать ошибки вида
TypeError: 'type' object is not subscriptable
и понимания путаницы с динамическими атрибутами
Прежде, чем перейти к тому, как работают свойства (и в том числе к различным практическим заданиям) сначала разберёмся с тем, что такое объект.
Если опираться на классы, объекты – это элемент данных, который принадлежит тому или иному классу, имеющий все его свойства (атрибуты), которые допустимы для того или иного класса.
– А экземпляр тогда почему?
– Это связано с тем, что сами классы являются экземпляром type. Экземпляром класса называют те объекты, которые сделаны от определенного класса во избежание путаницы. Так 5 является экземпляром <class 'int'>, а строка 'A little ladybug' – это экземпляр <class 'str'>
Строго говоря, если вы это читаете, то наверняка множество раз создавали в своей жизни объекты в python. Потому что объявление экземпляра класса происходит в Python через знак "=".
Поэтому, чтобы создать экземпляр собственного класса, вам нужно три вещи: переменная, знак присвоения и название вашего класса.
Обратите внимание, что скобки после имени класса обязательны. Фактически мы ими вызываем класс и его методы по умолчанию (которые будут разобраны в следующей главе) для создания нового объекта.
Теперь мы вплотную подошли к тому основному, что характеризует любой класс – это атрибуты.
Если говорить простым языком: атрибуты – это свойства (или поля) класса и его объектов.
В Python они делятся на два типа:
Общие (атрибуты класса, которые создаются один раз для всех)
Индивидуальные (атрибуты экземпляра, которые определяются для каждого объекта индивидуально в момент его создания).
Особый тип атрибутов — это атрибуты-функции. Они называются методами и определяют поведение объектов, служа инструментом для их взаимодействия и обмена данными друг с другом.
Создайте класс Button, который описывает кнопку внутри музыкального плеера. Пусть у кнопки будут свойства, описывающие её: размер, иконку (название), позицию на экране, цвет. У кнопки должен быть метод push, принимающий параметр действия, которое выводится через print в виде текста: "Вы нажали кнопку, произошло <действие>". Можете придумать свой текст.
Создайте три экземпляра этого класса. Каждая из кнопок должна называться по-своему.
Опять же, чисто технически, если вы прошли главы по основам программирования или просто знаете, как писать несложные программы в python, вы уже знаете, как именно используются атрибуты. Ну, или догадались 😄
Если нет, поясняю. Вызов свойств (статичных или функций) объектов происходит через синтаксис точки: объект.свойство или объект.метод()
Выведите информацию о всех трёх кнопках и вызовите их методы, передав разные значения "функций" этих кнопок.
В примерах выше мы обозначаем параметры для функции run. Если мы уберем первый параметр self и попытаемся вызвать метод от объекта, то вы увидите ошибку:
TypeError: Car.run() takes 2 positional arguments but 3 were given
Это происходит потому, что когда вы вызываете метод через объект, Python автоматически передает сам объект в качестве первого аргумента.
Чтобы этой ошибки не было, нужно в качестве первого параметра указать self в качестве первого параметра функции. К тому, как его использовать, мы ещё вернёмся.
Конечно, с учетом того, что мы разобрали, стоит предположить, что мы можем менять атрибуты любого объекта на произвольные. Так, даже ели мы создадим класс цитрусовых, мы сможем определить отдельное свойство для каждого из них: апельсина, мандарина или чего еще.
Изменение атрибутов отдельных экземпляров классов осуществляется через все то же присваивание. При этом изменяется атрибут только для этого объекта, а не для классов
При этом, если вы изменили общее свойство у конкретного объекта, это новое значение будет принадлежать только этому объекту (оно запишется в его личные свойства). Для остальных же объектов по-прежнему будет действовать значение по умолчанию, заданное в классе.
Поэтому, если вы уже после создания объектов измените свойство внутри самого класса, это изменение автоматически применится ко всем объектам, кроме тех, у которых это свойство ранее было перезаписано индивидуально.
Однако, это справедливо только для неизменяемых типов данных. Если в свойстве будет изменяемый тип (список, словарь), то и при его изменении через объект поведение изменится
Измените все три кнопки таким образом, чтобы каждая из них отличалась по-своему.
Создайте класс Employee с атрибутом класса company = "Up2Skills" (общий для всех) и индивидуальными атрибутами name и role. Создайте трёх сотрудников.
Выведите company у каждого — убедитесь, что значение одинаковое, хотя вы его нигде не присваивали конкретному объекту.
Одному сотруднику присвойте employee.company = "Freelance". Снова выведите company у всех троих. У кого изменилось?
Теперь поменяйте атрибут на уровне класса: Employee.company = "Up2Skills Inc.". Ещё раз выведите у всех троих.
Вопрос: почему у сотрудника из шага 2 значение не стало "Up2Skills Inc.", а у остальных — стало?
Создайте класс Playlist с атрибутом класса songs = [] — задайте пустой список прямо в теле класса, не внутри метода. Создайте два плейлиста, rock и jazz. Добавьте одну песню только в первый: rock.songs.append("..."). Затем выведите jazz.songs.
Вопрос: почему песня оказалась и в jazz, хотя вы её туда не клали? Чем это отличается от задания 1.3, где присваивание одному объекту не затрагивало остальных?
Если рассматривать класс как словарь (а в целом, никто не запрещает нам создать словарь с функциями и он будет вести себя как полноценный метод, даже выглядеть будет так же, как и экземпляр класса, а если вспомнить, что dict и классы являются подклассами type...ыххы), то можно в целом решить, что class – это просто красиво оформленный, изолированный словарь. И работает он при этом как отдельное пространство имён (namespace).
По сути, весь код, что вы пишете внутри тела класса (вне методов, например year и model в примерах ранее), выполняется ровно один раз – в момент чтения интерпретатором. Все переменные, созданные на этом этапе, помещаются в пространство имен этого класса.
Именно поэтому мы можем обращаться к ним напрямую через имя класса, даже если не создаем сам объект:
class APIConfig:
# Эти переменные живут в пространстве имён класса APIConfig
base_url = "https://api.example.com"
timeout = 30
retries = 3
# Обращение к переменным класса:
print(APIConfig.base_url) # Выведет: https://api.example.com
# Просмотр самого пространства имён (в виде словаря):
print(APIConfig.__dict__.keys())
# Выведет что-то вроде: dict_keys(['__module__', 'base_url', 'timeout', 'retries', '__dict__', '__weakref__', '__doc__'])
Как вы видите из примера, просмотреть всё пространство имён в виде словаря мы можем как раз через __dict__
Использование класса в качестве пространства имён (часто такие классы называют классами-контейнерами или namespaces) решает сразу несколько задач:
Защита от конфликта имён
Если у вас есть настройки базы данных и настройки кэша, вы можете назвать переменные одинаково (например, port), но благодаря пространствам имён они не пересекутся: Database.port и Redis.port. Это избавляет от необходимости писать длинные переменные вроде database_port и redis_port в глобальной области видимости.
Группировка логически связанных данных
Класс собирает разрозненные константы, переменные и утилитарные функции в единую, понятную структуру. Код становится гораздо легче читать и поддерживать.
Общее состояние для всех экземпляров (Shared State)
Атрибуты класса хранятся в одном экземпляре на весь класс, и это пространство имён доступно всем созданным объектам. Это идеально подходит для счетчиков, общих пулов соединений или кэша.
Пространство для статических методов
Часто классы используются просто чтобы сгруппировать набор функций, которые логически относятся к одной теме, но не требуют создания объекта (с помощью декоратора @staticmethod).
Вывод: класс в виде пространства имён – это просто удобная "папка" для ваших переменных и функций. Помогает для структурирования кода, избегая глобальных переменных и красиво хранить данные, общие для всех объектов этого класса. 😄
На самом деле, namespaces – это концепция, которая является фундамкентальной и встречается практически во всех современных языках. Однако то, как именно происходит реализация namespaces (через классы, модули или специальные ключевые слова), зависит от архитектуры конкретного языка.
Так, например, в C++ и C# используют буквально ключевое слово namespace.
В Java используется система пакетов (package). Файлы физически раскладывются по папкам, структура которых и образует пространство имен.
В Ruby есть ключевое слово module. Модули работают как пространства имён, но от них (в отличие от классов) нельзя создать экземпляр.
При этом всём в строго объектно-ориентированных языках (вроде Java или C#) нельзя написать функцию или объявить глобальную переменную просто так. ВСË должно быть внутри классов.
Поэтому классы сплошь и рядом используются исключительно как пространства имён.
На самом деле главным инструментом по созданию namespaces являются модули. Каждый отдельный файл является автоматически изолированным пространством имён. Вам вовсе не обязательно создавать отдельный класс Math или Config, просто напишите функции в отдельном файле math_utils.py и используйте файл как пространство времён при импорте. 😉
Посмотрите на следующий код. Как вы думаете, какие три числа будут выведены на экран?
class Spaceship:
# Переменная в пространстве имён класса
fuel = 100
# Создаем два корабля
ship1 = Spaceship()
ship2 = Spaceship()
# Изменяем количество топлива
ship1.fuel += 50
Spaceship.fuel -= 20
# Что выведет консоль?
print(ship1.fuel)
print(ship2.fuel)
print(Spaceship.fuel)
Посмотрите на следующий код.
class Player:
hp = 100
p1 = Player()
p2 = Player()
print(p1.hp) # 1
print(p1.__dict__) # 2
p1.hp = 50
print(p1.hp, p2.hp) # 3
print(p1.__dict__) # 4
print(p2.__dict__) # 5
print(Player.hp) # 6
Вопрос: предскажите каждый вывод. Почему в (2) словарь пустой, хотя p1.hp в (1) уже что-то вернул? И почему после присваивания (4) перестаёт быть пустым?
class Database:
host = "db.internal"
port = 5432
class Redis:
host = "cache.internal"
port = 6379
Выведите оба порта, не создавая ни одного объекта — только через имена классов. Имя port есть в обоих классах: почему они не конфликтуют?
Добавьте настройку уже после объявления, в рантайме: Database.timeout = 30. Проверьте, что она появилась в Database.__dict__, но её нет в Redis.__dict__.
Что создаст вызов Database() и почему для такого класса-контейнера экземпляр бессмысленен?