$ sudo teach IT
МОДУЛЬ 4 · УРОК 4

Практика: рисование фигур

В прошлом уроке мы разобрали абстрактные классы в теории. Теперь — время применить всё на практике. Мы построим полноценную систему геометрических фигур: от абстрактного класса-контракта до конкретных Circle, Rectangle, Triangle и Square. По ходу урока вы увидите, как ABC и полиморфизм работают вместе в реальном коде.

⏱ ~50 минут 🎓 Для новичков 🐍 Python 3 🔨 Урок-практика

🎯 Что будем строить и почему ABC здесь уместно

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

Как убедиться, что каждая новая фигура точно умеет считать свою площадь и периметр? Правильно — через абстрактный класс. Он станет обязательным контрактом: хочешь быть фигурой — реализуй оба метода, иначе Python не даст создать объект.

💡 Почему не обычный базовый класс? Без ABC метод можно просто забыть. Python унаследует пустую реализацию и вернёт None — тихая ошибка, которую сложно найти. ABC делает такую ошибку невозможной.

🗺️ Проектируем иерархию классов

Перед тем как писать код, нарисуем план. Хороший разработчик сначала думает, потом пишет. Вот что мы будем строить:

🏛️

Shape (ABC)

Абстрактный базовый класс. Задаёт контракт: методы area() и perimeter(). Создать нельзя — только наследовать.

🔵

Circle(Shape)

Круг. Параметр: радиус. Использует math.pi для точного расчёта площади и длины окружности.

🟩

Rectangle(Shape)

Прямоугольник. Параметры: ширина и высота. Формулы: классические из школьной математики.

🔺

Triangle(Shape)

Треугольник. Параметры: три стороны. Площадь — по формуле Герона.

🟦

Square(Rectangle)

Квадрат — это частный случай прямоугольника. Наследует от Rectangle, не переопределяет методы.

🧠 Обратите внимание: Square наследует не от Shape, а от Rectangle. Это пример цепочки наследования — и отличная демонстрация принципа «квадрат является частным случаем прямоугольника».

🔨 Шаг 1: Абстрактный класс Shape

Начнём с фундамента. Базовый класс Shape определит, какие методы обязаны реализовать все его потомки. Мы объявим два абстрактных метода: area() (площадь) и perimeter() (периметр).

from abc import ABC, abstractmethod


class Shape(ABC):
    """
    Абстрактный базовый класс для всех геометрических фигур.

    Любой класс-потомок обязан реализовать:
    - area()      — площадь фигуры
    - perimeter() — периметр (длина границы) фигуры

    Создать экземпляр Shape напрямую нельзя — это только контракт.
    """

    @abstractmethod
    def area(self) -> float:
        """Возвращает площадь фигуры."""
        pass

    @abstractmethod
    def perimeter(self) -> float:
        """Возвращает периметр фигуры."""
        pass

    # Обычный метод — не абстрактный.
    # Работает для ЛЮБОЙ фигуры без переопределения.
    def describe(self) -> str:
        """Возвращает описание фигуры с площадью и периметром."""
        return (
            f"{self.__class__.__name__}: "
            f"площадь = {self.area():.2f}, "
            f"периметр = {self.perimeter():.2f}"
        )

🔍 Разберём каждую часть:
— class Shape(ABC): — наследуем от ABC, делаем класс абстрактным
— @abstractmethod — декоратор-контракт: «подкласс обязан это реализовать»
— pass в теле — метод пустой, это нормально: тело нужно только для обозначения сигнатуры
— describe() — обычный метод, доступен всем потомкам без переопределения

Обратите внимание на метод describe(). Он вызывает self.area() и self.perimeter() — методы, которые реализованы в конкретных подклассах. Это полиморфизм: один метод в базовом классе использует разные реализации в зависимости от того, какой конкретный объект вызвал его.

🚫 Попытка создать Shape() прямо сейчас:
shape = Shape() # TypeError: Can't instantiate abstract class Shape with abstract methods area, perimeter
Именно так и должно быть — Shape это контракт, а не реальная фигура.

🔵 Шаг 2: Circle — круг с math.pi

Первая конкретная фигура — круг. Для точного расчёта используем константу math.pi из стандартной библиотеки Python. Почему не просто написать 3.14? Потому что math.pi = 3.141592653589793 — намного точнее!

import math  # Импортируем модуль math для доступа к math.pi


class Circle(Shape):
    """
    Круг. Задаётся радиусом.
    Площадь:   π × r²
    Периметр:  2 × π × r  (длина окружности)
    """

    def __init__(self, radius: float):
        # Сохраняем радиус как атрибут объекта
        self.radius = radius

    def area(self) -> float:
        # Формула площади круга: π × r²
        # math.pi — точное значение числа π из стандартной библиотеки
        return math.pi * self.radius ** 2

    def perimeter(self) -> float:
        # Формула длины окружности: 2 × π × r
        return 2 * math.pi * self.radius


# Тестируем
circle = Circle(5)

print(circle.area())       # 78.53981633974483
print(circle.perimeter())  # 31.41592653589793
print(circle.describe())   # Circle: площадь = 78.54, периметр = 31.42

💡 Метод describe() работает автоматически! Мы написали его только в базовом классе Shape, а Circle его унаследовал. Внутри describe() вызов self.area() вернёт правильное значение для круга — потому что self это объект Circle. Это и есть полиморфизм в действии.

🟩 Шаг 3: Rectangle — прямоугольник

Прямоугольник задаётся двумя параметрами: шириной (width) и высотой (height). Формулы простые — из школьной программы.

class Rectangle(Shape):
    """
    Прямоугольник. Задаётся шириной и высотой.
    Площадь:   width × height
    Периметр:  2 × (width + height)
    """

    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        # Площадь прямоугольника = ширина × высота
        return self.width * self.height

    def perimeter(self) -> float:
        # Периметр прямоугольника = 2 × (ширина + высота)
        return 2 * (self.width + self.height)


# Тестируем
rect = Rectangle(4, 6)

print(rect.area())       # 24.0
print(rect.perimeter())  # 20.0
print(rect.describe())   # Rectangle: площадь = 24.00, периметр = 20.00

🧩 Контракт выполнен. Мы реализовали оба обязательных метода — area() и perimeter(). Если бы мы забыли хотя бы один, Python выдал бы TypeError при попытке создать Rectangle(4, 6). ABC защищает нас от таких ошибок.

🔺 Шаг 4: Triangle — треугольник и формула Герона

Треугольник интереснее: у него три стороны, и площадь считается по формуле Герона. Не пугайтесь названия — формула простая и элегантная.

📐 Формула Герона (как она работает):

1. Находим полупериметр: s = (a + b + c) / 2

2. Площадь: S = √(s × (s−a) × (s−b) × (s−c))

Например, для треугольника 3-4-5 (прямоугольный): s = 6, S = √(6 × 3 × 2 × 1) = √36 = 6.0

class Triangle(Shape):
    """
    Треугольник. Задаётся тремя сторонами: a, b, c.
    Площадь:   по формуле Герона
    Периметр:  a + b + c
    """

    def __init__(self, a: float, b: float, c: float):
        self.a = a
        self.b = b
        self.c = c

    def area(self) -> float:
        # Шаг 1: полупериметр
        s = (self.a + self.b + self.c) / 2
        # Шаг 2: формула Герона — корень из произведения
        # math.sqrt() — квадратный корень из стандартной библиотеки
        return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))

    def perimeter(self) -> float:
        # Периметр треугольника = сумма всех сторон
        return self.a + self.b + self.c


# Тестируем с прямоугольным треугольником 3-4-5 (площадь должна быть 6.0)
triangle = Triangle(3, 4, 5)

print(triangle.area())       # 6.0
print(triangle.perimeter())  # 12
print(triangle.describe())   # Triangle: площадь = 6.00, периметр = 12.00

🧠 Почему math.sqrt? В Python можно написать x ** 0.5 вместо math.sqrt(x) — оба варианта дадут квадратный корень. Но math.sqrt() читается явнее: сразу понятно, что это корень. Читаемость кода — важно.

🔄 Шаг 5: Полиморфизм — суммарная площадь всех фигур

Теперь самое интересное. Создадим список, в котором живут фигуры разных типов. Затем одним циклом посчитаем суммарную площадь — не зная и не проверяя, какой именно тип у каждой фигуры.

Это и есть полиморфизм: один вызов shape.area() даёт разный результат для разных объектов — каждый использует свою реализацию метода.

# Создаём список с фигурами РАЗНЫХ типов
shapes = [
    Circle(5),          # Круг с радиусом 5
    Rectangle(4, 6),    # Прямоугольник 4×6
    Triangle(3, 4, 5),  # Прямоугольный треугольник
    Circle(2),          # Ещё один круг
    Rectangle(10, 3),   # Ещё один прямоугольник
]

# Полиморфный обход: один вызов — разные реализации
print("=== Все фигуры ===")
for shape in shapes:
    # shape.area() вызывает нужный метод АВТОМАТИЧЕСКИ —
    # Circle.area() для кругов, Rectangle.area() для прямоугольников и т.д.
    print(shape.describe())

# Считаем суммарную площадь всех фигур в одну строку!
total_area = sum(shape.area() for shape in shapes)
print(f"\n📐 Суммарная площадь всех фигур: {total_area:.2f}")

# Вывод:
# === Все фигуры ===
# Circle: площадь = 78.54, периметр = 31.42
# Rectangle: площадь = 24.00, периметр = 20.00
# Triangle: площадь = 6.00, периметр = 12.00
# Circle: площадь = 12.57, периметр = 12.57
# Rectangle: площадь = 30.00, периметр = 26.00
#
# 📐 Суммарная площадь всех фигур: 151.11

🎯 Ключевая идея полиморфизма: функция sum(shape.area() for shape in shapes) ничего не знает о том, круг это, прямоугольник или треугольник. Она просто вызывает .area() на каждом объекте — и каждый возвращает правильный результат для своего типа. Добавите новую фигуру в список — код не придётся менять!

📝 Синтаксис sum(...): выражение sum(shape.area() for shape in shapes) использует генераторное выражение — это компактный способ создать последовательность значений «на лету» и передать её в sum(). Читается как: «сложи area() каждой фигуры из списка shapes».

🟦 Шаг 6: Square(Rectangle) — наследование от конкретного класса

Квадрат — это прямоугольник, у которого все стороны равны. Зачем писать новые формулы? Лучше унаследовать от Rectangle и передать одинаковое значение для ширины и высоты.

Это отличный пример принципа DRY (Don't Repeat Yourself — не повторяй себя): методы area() и perimeter() уже написаны в Rectangle, переопределять их не нужно.

class Square(Rectangle):
    """
    Квадрат — частный случай прямоугольника.
    Наследует от Rectangle, а не от Shape напрямую.

    Формулы area() и perimeter() из Rectangle работают правильно —
    переопределять их не нужно!
    """

    def __init__(self, side: float):
        # Вызываем __init__ родительского класса (Rectangle),
        # передав side как ширину И как высоту — они одинаковы!
        super().__init__(side, side)
        # Для удобства сохраняем side отдельно
        self.side = side


# Тестируем
square = Square(4)

print(square.area())       # 16.0  — работает! Унаследовано от Rectangle
print(square.perimeter())  # 16.0  — тоже работает без переопределения
print(square.describe())   # Square: площадь = 16.00, периметр = 16.00

# Проверим isinstance — Square является одновременно:
print(isinstance(square, Square))     # True
print(isinstance(square, Rectangle))  # True — квадрат IS-A прямоугольник
print(isinstance(square, Shape))      # True — и IS-A фигура

🔑 Что здесь важно понять:

— super().__init__(side, side) — вызываем конструктор родителя, передавая side дважды: как ширину и как высоту
— area() вернёт side × side (ведь Rectangle вычислит self.width * self.height, а оба равны side)
— Переопределять area() и perimeter() не нужно — они уже работают правильно
— isinstance(square, Rectangle) возвращает True — квадрат является прямоугольником с точки зрения Python

Добавим Square в наш список фигур и убедимся, что полиморфизм работает:

# Square отлично вписывается в полиморфный список
shapes.append(Square(4))

# Суммарная площадь — код не изменился, просто добавился объект
total_area = sum(shape.area() for shape in shapes)
print(f"📐 Суммарная площадь (с квадратом): {total_area:.2f}")
# 📐 Суммарная площадь (с квадратом): 167.11

📄 Финальный код целиком

Собираем всё воедино. Это полный рабочий файл — можно скопировать и запустить:

import math
from abc import ABC, abstractmethod


# ============================================================
# БАЗОВЫЙ АБСТРАКТНЫЙ КЛАСС
# ============================================================

class Shape(ABC):
    """Абстрактный контракт для всех геометрических фигур."""

    @abstractmethod
    def area(self) -> float:
        """Возвращает площадь фигуры."""
        pass

    @abstractmethod
    def perimeter(self) -> float:
        """Возвращает периметр фигуры."""
        pass

    def describe(self) -> str:
        """Текстовое описание фигуры с характеристиками."""
        return (
            f"{self.__class__.__name__}: "
            f"площадь = {self.area():.2f}, "
            f"периметр = {self.perimeter():.2f}"
        )


# ============================================================
# КОНКРЕТНЫЕ КЛАССЫ
# ============================================================

class Circle(Shape):
    """Круг. Параметр: radius (радиус)."""

    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return math.pi * self.radius ** 2

    def perimeter(self) -> float:
        return 2 * math.pi * self.radius


class Rectangle(Shape):
    """Прямоугольник. Параметры: width (ширина), height (высота)."""

    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

    def perimeter(self) -> float:
        return 2 * (self.width + self.height)


class Triangle(Shape):
    """Треугольник. Параметры: a, b, c (три стороны)."""

    def __init__(self, a: float, b: float, c: float):
        self.a = a
        self.b = b
        self.c = c

    def area(self) -> float:
        s = (self.a + self.b + self.c) / 2  # полупериметр
        return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))

    def perimeter(self) -> float:
        return self.a + self.b + self.c


class Square(Rectangle):
    """Квадрат — частный случай прямоугольника. Параметр: side (сторона)."""

    def __init__(self, side: float):
        super().__init__(side, side)  # передаём side как width и height
        self.side = side


# ============================================================
# ДЕМОНСТРАЦИЯ ПОЛИМОРФИЗМА
# ============================================================

# Список фигур РАЗНЫХ типов — все соответствуют контракту Shape
shapes = [
    Circle(5),
    Rectangle(4, 6),
    Triangle(3, 4, 5),
    Circle(2),
    Rectangle(10, 3),
    Square(4),
]

print("=== Все фигуры ===")
for shape in shapes:
    print(shape.describe())

# Суммарная площадь — полиморфизм в одной строке
total_area = sum(shape.area() for shape in shapes)
print(f"\n📐 Суммарная площадь всех фигур: {total_area:.2f}")

# Найдём фигуру с наибольшей площадью
largest = max(shapes, key=lambda s: s.area())
print(f"🏆 Наибольшая площадь у: {largest.describe()}")

✨ Бонус — поиск максимума: max(shapes, key=lambda s: s.area()) находит фигуру с максимальной площадью. Параметр key говорит функции max(), по какому критерию сравнивать — в нашем случае по площади. lambda s: s.area() — это небольшая анонимная функция: «для фигуры s верни её area()».

🏋️ Задание для самостоятельной работы

Попробуйте выполнить эти задания самостоятельно — это лучший способ закрепить материал:

⭐

Задание 1 (базовое)

Добавьте класс RightTriangle(Shape) — прямоугольный треугольник с катетами a и b. Площадь: a × b / 2. Гипотенуза (для периметра): √(a² + b²).

⭐⭐

Задание 2 (среднее)

Добавьте в базовый класс Shape обычный метод is_larger_than(other), который сравнивает площади двух фигур и возвращает True, если текущая фигура больше. Проверьте на разных парах.

⭐⭐⭐

Задание 3 (продвинутое)

Напишите функцию total_stats(shapes), которая принимает список фигур и возвращает словарь: {'total_area': ..., 'total_perimeter': ..., 'largest': ..., 'smallest': ...}.

💬 Совет: не смотрите на решение сразу! Попробуйте написать код сами, столкнитесь с ошибками, разберитесь — именно так формируется настоящее понимание. Ошибки — это не провал, это часть обучения.

✅ Итоги урока

  • ABC + @abstractmethod = гарантия контракта: забытый метод → ошибка сразу при создании объекта.
  • math.pi из стандартной библиотеки точнее любой константы — используйте его для работы с кругами.
  • Формула Герона: s = (a+b+c)/2, затем √(s(s-a)(s-b)(s-c)) — площадь треугольника по трём сторонам.
  • Полиморфизм позволяет работать с разными фигурами одним кодом — shape.area() сам знает, как считать.
  • Square(Rectangle) — пример цепочки наследования: квадрат повторно использует логику прямоугольника через super().__init__(side, side).
  • Принцип DRY: не дублируй код — наследуй и переиспользуй.
  • Добавить новую фигуру просто: создай класс, унаследуй от Shape, реализуй два метода — система работает без изменений.

👉 В следующем уроке мы изучим магические методы Python — специальные методы с двойным подчёркиванием (__str__, __repr__, __eq__, __len__ и другие). Они позволяют вашим классам вести себя как встроенные типы Python: сравниваться, выводиться в консоль, складываться и многое другое.