$ sudo teach IT
МОДУЛЬ 9 · УРОК 5 · ПРАКТИКА

Разбиваем проект с классами на пакет

В модуле 4 мы написали проект «рисование фигур» — всё в одном файле: Shape, Circle, Rectangle, Triangle, Square. Сегодня мы берём этот код и раскладываем по полочкам — превращаем один большой файл в настоящий Python-пакет shapes/. Шаг за шагом, с объяснением каждого решения.

⏱ ~55 минут 🎓 Для новичков 🐍 Python 3 🏗️ Рефакторинг

📄 Ситуация «до»: всё в одном файле

Вот как выглядел наш проект из модуля 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 — и готово.