Разбиваем проект с классами на пакет
В модуле 4 мы написали проект «рисование фигур» — всё в одном файле: Shape, Circle, Rectangle, Triangle, Square. Сегодня мы берём этот код и раскладываем по полочкам — превращаем один большой файл в настоящий Python-пакет shapes/. Шаг за шагом, с объяснением каждого решения.
📄 Ситуация «до»: всё в одном файле
Вот как выглядел наш проект из модуля 4. Один файл shapes.py — пять классов. Работало отлично, но у такого подхода есть проблемы:
😤 Один файл — куча кода
Чтобы найти класс Circle, нужно листать весь файл. С ростом проекта это становится невыносимым.
👥 Командная работа невозможна
Если двое одновременно редактируют shapes.py — конфликты в git. При разных файлах — каждый работает в своём.
🔗 Сложно переиспользовать
Хотите взять только Circle в другой проект? Придётся копировать весь файл или резать вручную.
Так выглядел исходный файл (напоминаем структуру):
# shapes.py — ВСЁ В ОДНОМ ФАЙЛЕ (так было)
from abc import ABC, abstractmethod
import math
class Shape(ABC):
@abstractmethod
def area(self): pass
@abstractmethod
def perimeter(self): pass
def describe(self):
print(f"{self.__class__.__name__}: площадь={self.area():.2f}, периметр={self.perimeter():.2f}")
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
class Triangle(Shape):
def __init__(self, a, b, c):
self.a, self.b, self.c = a, b, c
def area(self):
s = (self.a + self.b + self.c) / 2
return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))
def perimeter(self):
return self.a + self.b + self.c
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)
🌳 Структура «после»: дерево пакета
Вот что мы построим сегодня. Каждый класс живёт в своём файле, а __init__.py делает всё удобным для использования:
my_project/
├── main.py ← точка входа: запускаем этот файл
└── shapes/ ← наш пакет (папка с __init__.py)
├── __init__.py ← «лицо» пакета: удобные импорты
├── base.py ← абстрактный класс Shape
├── circle.py ← класс Circle
├── rectangle.py ← класс Rectangle
├── triangle.py ← класс Triangle
└── square.py ← класс Square (наследует Rectangle)
💡 Принцип одной ответственности: каждый файл отвечает за один класс. Это называется принцип Single Responsibility. Хотите найти код Circle? Открываете circle.py. Просто и предсказуемо.
📝 Шаг 1: создаём shapes/base.py — абстрактный Shape
Почему отдельный файл для базового класса? Класс Shape — это «фундамент». Все остальные классы (Circle, Rectangle и т.д.) будут его импортировать. Если поместить Shape в circle.py, то rectangle.py был бы вынужден импортировать из circle.py — это странно и неочевидно. Базовый класс в отдельном файле делает зависимости чистыми: все зависят от base.py, но base.py ни от кого не зависит.
# shapes/base.py
from abc import ABC, abstractmethod
class Shape(ABC):
"""Абстрактный базовый класс для всех фигур."""
@abstractmethod
def area(self):
"""Вычислить площадь фигуры."""
pass
@abstractmethod
def perimeter(self):
"""Вычислить периметр фигуры."""
pass
def describe(self):
"""Напечатать описание фигуры."""
name = self.__class__.__name__
print(f"{name}: площадь={self.area():.2f}, периметр={self.perimeter():.2f}")
✅ Обратите внимание: base.py импортирует только стандартную библиотеку (abc). Никаких зависимостей от других файлов нашего пакета. Это называется «листовой модуль» — у него нет внутренних зависимостей, его можно переносить в любой проект.
⭕ Шаг 2: создаём shapes/circle.py
Circle зависит только от Shape из base.py и от стандартной библиотеки math. Используем абсолютный импорт — он рекомендован PEP 8 и явно показывает, откуда берётся Shape:
# shapes/circle.py
import math
from shapes.base import Shape # абсолютный импорт: пакет shapes → файл base → класс Shape
class Circle(Shape):
"""Круг. Задаётся радиусом."""
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
💡 Почему from shapes.base import Shape, а не from .base import Shape? Оба варианта работают. Мы выбираем абсолютный путь, потому что он читается как предложение: «из пакета shapes, файла base, возьми класс Shape». Любой разработчик, открыв файл, сразу понимает структуру проекта.
▭ Шаг 3: создаём shapes/rectangle.py
Прямоугольник. Всё так же — наследуем от Shape, math не нужен:
# shapes/rectangle.py
from shapes.base import Shape
class Rectangle(Shape):
"""Прямоугольник. Задаётся шириной и высотой."""
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
🔺 Шаг 4: создаём shapes/triangle.py
Треугольник использует формулу Герона для площади — нужен math.sqrt:
# shapes/triangle.py
import math
from shapes.base import Shape
class Triangle(Shape):
"""Треугольник. Задаётся тремя сторонами a, b, c."""
def __init__(self, a, b, c):
self.a = a
self.b = b
self.c = c
def area(self):
# Формула Герона: s — полупериметр
s = (self.a + self.b + self.c) / 2
return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))
def perimeter(self):
return self.a + self.b + self.c
🟦 Шаг 5: создаём shapes/square.py
Важный момент! Квадрат наследует не от Shape напрямую, а от Rectangle. Это отражает реальность: квадрат — частный случай прямоугольника (ширина равна высоте). Поэтому нам нужно импортировать Rectangle из соседнего файла:
# shapes/square.py
from shapes.rectangle import Rectangle # Square — это специальный Rectangle!
class Square(Rectangle):
"""Квадрат. Задаётся одной стороной.
Наследует Rectangle, потому что квадрат — прямоугольник с равными сторонами."""
def __init__(self, side):
super().__init__(side, side) # передаём одинаковую сторону как ширину и высоту
🔑 Обратите внимание на граф зависимостей:
base.py ← ничего не импортирует из нашего пакета
circle.py ← импортирует base.py
rectangle.py ← импортирует base.py
triangle.py ← импортирует base.py
square.py ← импортирует rectangle.py
Никаких циклов! Каждый файл зависит только от «более низкоуровневых» файлов. Это хорошая архитектура.
🚪 Шаг 6: создаём shapes/__init__.py — витрина пакета
Вот самый важный файл этого урока. Разберёмся, зачем он нужен и что именно писать внутри.
Вариант 1: пустой __init__.py — минимальный. Только говорит Python: «эта папка — пакет». Работает, но неудобен для пользователей:
# shapes/__init__.py — вариант 1: пустой файл
# (просто создаём пустой файл)
При пустом __init__.py пользователю нужно знать внутреннее устройство пакета:
# main.py при ПУСТОМ __init__.py — некрасиво
from shapes.circle import Circle
from shapes.rectangle import Rectangle
from shapes.triangle import Triangle
from shapes.square import Square
# 4 строки импорта вместо одной. Пользователь вынужден знать структуру файлов.
Вариант 2: умный __init__.py — делаем пакет удобным. __init__.py выступает как «витрина магазина»: внутри магазина сотни полок, но снаружи вы видите только витрину с главными товарами.
# shapes/__init__.py — вариант 2: удобные импорты (используем этот!)
"""
Пакет shapes — набор геометрических фигур.
Доступные классы:
Shape — абстрактный базовый класс
Circle — круг
Rectangle — прямоугольник
Triangle — треугольник
Square — квадрат
"""
from shapes.base import Shape
from shapes.circle import Circle
from shapes.rectangle import Rectangle
from shapes.triangle import Triangle
from shapes.square import Square
# __all__ — список того, что экспортируется при "from shapes import *"
# Хорошая практика: явно указывать публичный API пакета
__all__ = ["Shape", "Circle", "Rectangle", "Triangle", "Square"]
✅ Что даёт умный __init__.py? Теперь пользователю пакета достаточно написать from shapes import Circle или даже from shapes import Circle, Rectangle, Square в одну строку. Внутренняя структура файлов скрыта — это инкапсуляция на уровне пакета.
🔑 __all__ — это публичный API пакета. Если кто-то напишет from shapes import *, Python импортирует только то, что перечислено в __all__. Без __all__ импортировалось бы всё, включая вспомогательные функции и служебный код. Указывайте __all__ в библиотеках — это хорошая практика.
🚀 Шаг 7: пишем main.py — запускаем пакет
Теперь посмотрите, насколько чисто выглядит точка входа. Всё, что нам нужно от пакета — в одной строке импорта:
# main.py — точка входа в проект
from shapes import Circle, Rectangle, Triangle, Square
# Всего ОДНА строка! __init__.py сделал всё за нас.
# Создаём фигуры
c = Circle(5)
r = Rectangle(4, 6)
t = Triangle(3, 4, 5)
s = Square(7)
# Используем
shapes = [c, r, t, s]
for shape in shapes:
shape.describe()
print()
print("Самая большая фигура:")
biggest = max(shapes, key=lambda sh: sh.area())
biggest.describe()
Запускаем из корня проекта (важно — именно из корня!):
# Запуск из корня my_project/
python main.py
# Вывод:
# Circle: площадь=78.54, периметр=31.42
# Rectangle: площадь=24.00, периметр=20.00
# Triangle: площадь=6.00, периметр=12.00
# Square: площадь=49.00, периметр=28.00
#
# Самая большая фигура:
# Circle: площадь=78.54, периметр=31.42
⚠️ Запускайте только из корня проекта! Если запустить python shapes/circle.py напрямую — получите ImportError. Файлы пакета не предназначены для прямого запуска. Всегда запускайте python main.py из папки my_project/.
🗺️ Финальная карта проекта
Давайте ещё раз взглянем на весь проект целиком и проследим, как данные текут между файлами:
my_project/
├── main.py ← импортирует из shapes (через __init__.py)
└── shapes/
├── __init__.py ← импортирует из base, circle, rectangle, triangle, square
├── base.py ← импортирует только abc (стандартная библиотека)
├── circle.py ← импортирует из shapes.base, math
├── rectangle.py ← импортирует из shapes.base
├── triangle.py ← импортирует из shapes.base, math
└── square.py ← импортирует из shapes.rectangle
Граф зависимостей — всегда идёт в одну сторону, никаких циклов:
main.py
└── shapes/__init__.py
├── shapes/base.py (только abc — нет зависимостей внутри пакета)
├── shapes/circle.py (base → Circle)
├── shapes/rectangle.py (base → Rectangle)
├── shapes/triangle.py (base → Triangle)
└── shapes/square.py (rectangle → Square)
✏️ Самостоятельная работа: добавляем Pentagon
Теперь ваша очередь! Добавьте в пакет shapes новый класс — правильный пятиугольник Pentagon.
📋 Что нужно сделать:
1. Создать файл shapes/pentagon.py с классом Pentagon
2. Pentagon принимает один аргумент — длину стороны side
3. Реализовать area() и perimeter()
4. Добавить Pentagon в shapes/__init__.py
5. Использовать Pentagon в main.py
Подсказка — формулы для правильного пятиугольника со стороной a:
# Формулы правильного пятиугольника:
# Периметр = 5 * a
# Площадь = (a^2 / 4) * sqrt(5 * (5 + 2*sqrt(5)))
#
# В Python:
# import math
# perimeter = 5 * self.side
# area = (self.side ** 2 / 4) * math.sqrt(5 * (5 + 2 * math.sqrt(5)))
💡 Проверьте себя: Pentagon(4).area() должна вернуть примерно 27.53, а Pentagon(4).perimeter() — ровно 20.0. Если числа совпали — всё сделано правильно!
🏁 Итоги урока
📁 Один класс — один файл
Принцип Single Responsibility: каждый файл отвечает за один класс. Удобно находить, читать и изменять код.
🚪 __init__.py — витрина пакета
Собирает классы из всех файлов и предоставляет удобный единый импорт: from shapes import Circle.
⛓️ Граф без циклов
Зависимости идут в одну сторону: base ← circle, rectangle, triangle; rectangle ← square. Циклов нет.
🚀 Запуск из корня
Всегда запускаем python main.py из корневой папки проекта. Файлы пакета — не скрипты.
Главный вывод: рефакторинг «один файл → пакет» — это не переписывание кода, а его реорганизация. Логика методов осталась той же, изменились только места хранения. Но теперь проект масштабируется: хотите добавить Hexagon? Один новый файл + одна строка в __init__.py — и готово.