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