Практика: кастомный класс Vector
Пришло время применить всё, что мы знаем о dunder-методах — на реальной задаче. Мы построим полноценный класс Vector с нуля: от конструктора до сортировки и скалярного произведения. Каждый метод разберём пошагово с объяснением — почему именно так.
📐 Что такое вектор и зачем он нам нужен?
Вектор — это направленный отрезок в пространстве. В двумерном мире вектор задаётся двумя числами: координатой по горизонтали x и координатой по вертикали y.
🎮 В играх
Скорость персонажа, направление движения снаряда, сила гравитации — всё это векторы.
🤖 В ML
Векторы — основа машинного обучения. Данные, веса нейросети, эмбеддинги слов — всё векторы.
🗺️ В графике
Координаты точки, смещение объекта, нормаль поверхности — всё это векторные операции.
Для нашего вектора Vector(3, 4) — это вектор, который идёт на 3 единицы вправо и 4 единицы вверх. Его длина по теореме Пифагора: √(3² + 4²) = √25 = 5.
💡 Цель этого урока: создать класс Vector, который ведёт себя «по-математически» — его можно складывать, вычитать, умножать на число, сравнивать, сортировать. Всё это через dunder-методы, которые мы изучали в прошлых уроках.
🗺️ Проектируем класс: какие операции нужны?
Прежде чем писать код, решим — что наш Vector должен уметь. Составим список операций и сопоставим каждую с dunder-методом:
| Что хотим | Пример | Метод |
|---|---|---|
| Создать вектор | v = Vector(3, 4) |
__init__ |
| Красиво вывести | print(v) → Vector(3, 4) |
__repr__ |
| Проверить равенство | v1 == v2 |
__eq__ |
| Сложить два вектора | v1 + v2 |
__add__ |
| Вычесть два вектора | v1 - v2 |
__sub__ |
| Умножить на число (справа) | v * 3 |
__mul__ |
| Умножить на число (слева) | 3 * v |
__rmul__ |
| Длина вектора | abs(v) → 5.0 |
__abs__ |
| Сравнить по длине | v1 < v2, сортировка |
__lt__ |
| Инвертировать вектор | -v → Vector(-3, -4) |
__neg__ |
🧠 Принцип проектирования: сначала продумай, что объект должен уметь с точки зрения пользователя класса — а потом подбирай нужные методы. Так получается чистый и интуитивный API.
🔧 Шаг 1 — __init__ и __repr__: основа класса
Начнём с самого простого — создания и отображения вектора.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v = Vector(3, 4)
print(v) # Vector(3, 4)
print(repr(v)) # Vector(3, 4)
💡 Почему __repr__, а не __str__?
__repr__ — это «официальное» строковое представление объекта. Когда объект один (без явного print), Python показывает именно repr. Для простых классов вроде нашего Vector достаточно одного __repr__ — он будет работать и там, где ожидается __str__. Хороший __repr__ выглядит как вызов конструктора: Vector(3, 4) — прочитав его, сразу понятно, как создать такой же объект.
⚖️ Шаг 2 — __eq__: когда два вектора равны?
Два вектора равны, если равны обе их координаты. Без __eq__ Python сравнивает объекты по их адресу в памяти — два разных объекта с одинаковыми координатами будут «не равны».
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __eq__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
a = Vector(3, 4)
b = Vector(3, 4)
c = Vector(1, 2)
print(a == b) # True — одинаковые координаты
print(a == c) # False — разные координаты
print(a is b) # False — это разные объекты в памяти!
🔍 Почему возвращаем NotImplemented при неправильном типе?
Если other не является вектором, мы не знаем, как его сравнивать. Возвращая NotImplemented, мы говорим Python: «попробуй спросить у другого операнда». Это позволяет, например, сравнивать Vector с подклассами Vector, которые переопределили __eq__. Альтернатива — вернуть False, но это хуже: тогда Vector(1,2) == "что-то" тихо вернёт False вместо честного «не умею сравнивать».
➕ Шаг 3 — __add__ и __sub__: сложение и вычитание векторов
Сложение векторов — покоординатное: (x1+x2, y1+y2). То же самое с вычитанием. Результат — всегда новый вектор.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __eq__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x - other.x, self.y - other.y)
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v2 - v1) # Vector(2, 2)
print(v1) # Vector(1, 2) ← исходный не изменился!
🔑 Почему всегда создаём новый вектор?
В математике v1 + v2 не изменяет ни v1, ни v2 — создаётся новый вектор. Наш класс следует тому же принципу. Это делает код предсказуемым: если передать вектор в функцию, он не изменится случайно внутри. Такой подход называется иммутабельностью — неизменяемостью объекта.
✖️ Шаг 4 — __mul__ и __rmul__: умножение на скаляр
Вектор можно умножить на число (скаляр) — каждая координата умножается на это число. Чтобы работало и v * 3, и 3 * v, нужны оба метода.
def __mul__(self, scalar):
# Вызывается при v * scalar (вектор СЛЕВА)
if not isinstance(scalar, (int, float)):
return NotImplemented
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar):
# Вызывается при scalar * v (вектор СПРАВА)
# Умножение коммутативно: 3 * v == v * 3
# Поэтому просто делегируем __mul__
return self.__mul__(scalar)
Проверка:
v = Vector(2, 3)
print(v * 4) # Vector(8, 12) — через __mul__
print(4 * v) # Vector(8, 12) — через __rmul__
print(v * 0.5) # Vector(1.0, 1.5) — работает с float тоже
🧠 Зачем нужен __rmul__?
Когда Python видит 4 * v, он сначала вызывает (4).__mul__(v). Встроенный тип int не знает, как умножаться на вектор — возвращает NotImplemented. Тогда Python пробует v.__rmul__(4) — и вот здесь мы отвечаем. Без __rmul__ выражение 4 * v упало бы с TypeError.
📏 Шаг 5 — __abs__: длина вектора через теорему Пифагора
Длина (модуль) вектора вычисляется по теореме Пифагора: |v| = √(x² + y²). В Python за оператор abs() отвечает метод __abs__.
import math
def __abs__(self):
# Длина вектора — гипотенуза прямоугольного треугольника
# со сторонами self.x и self.y
return math.sqrt(self.x ** 2 + self.y ** 2)
Проверка — классический «египетский» треугольник 3-4-5:
v = Vector(3, 4)
print(abs(v)) # 5.0 — √(9 + 16) = √25 = 5
💡 Почему abs(), а не метод length()?
В математике модуль вектора обозначается как |v| — это и есть «абсолютное значение» вектора. Встроенная функция Python abs() семантически означает именно это. Использовать abs(v) интуитивнее и «питоничнее», чем v.length(), потому что это стандартный интерфейс Python. Можно определить и length() как дополнительный метод — но основным должен быть __abs__.
🔢 Шаг 6 — __lt__: сравнение векторов по длине и сортировка
Как сравнить два вектора? Сам по себе вопрос неоднозначный — ведь Vector(3, 0) и Vector(0, 3) одинаково длинные, но «смотрят» в разные стороны. Разумное и общепринятое решение — сравнивать по длине.
Метод __lt__ (less than — «меньше чем») нужен для оператора <. Если определить только его, функция sorted() и метод .sort() уже будут работать!
def __lt__(self, other):
# Сравниваем два вектора по длине
if not isinstance(other, Vector):
return NotImplemented
# Используем abs() — который вызовет наш __abs__
return abs(self) < abs(other)
Теперь векторы можно сортировать:
vectors = [Vector(3, 4), Vector(1, 0), Vector(2, 2), Vector(0, 1)]
# abs: 5.0, 1.0, 2.83, 1.0
for v in vectors:
print(v, "→ длина:", round(abs(v), 2))
print(sorted(vectors))
# [Vector(1, 0), Vector(0, 1), Vector(2, 2), Vector(3, 4)]
# отсортированы по возрастанию длины
🤔 Почему достаточно только __lt__ для сортировки?
Функция sorted() использует алгоритмы сортировки, которым нужно уметь сравнивать «меньше» — именно это и делает __lt__. Это минимально необходимый метод. Если хотите поддержать ещё >, <=, >= — используйте декоратор @functools.total_ordering: он автоматически выведет все операторы сравнения из __eq__ и __lt__.
➖ Шаг 7 — __neg__: унарный минус
Унарный минус — это не вычитание. Это операция «инвертировать знак». Для числа -5 это очевидно. Для вектора: -v — это вектор той же длины, но в противоположном направлении. Координаты меняют знак: -Vector(3, 4) = Vector(-3, -4).
За унарный минус отвечает метод __neg__.
def __neg__(self):
# Инвертируем обе координаты
return Vector(-self.x, -self.y)
Проверка:
v = Vector(3, -5)
print(-v) # Vector(-3, 5)
print(-(-v)) # Vector(3, -5) ← двойное отрицание = оригинал
print(v + (-v)) # Vector(0, 0) ← вектор + его обратный = нулевой вектор
💡 Бонус: если у нас есть __neg__ и __add__, то вычитание можно выразить через них: v1 - v2 == v1 + (-v2). Это полезно знать при проектировании классов — иногда удобно реализовать __sub__ через __add__ и __neg__, чтобы не дублировать логику.
🔬 Расширение: метод dot() — скалярное произведение
Скалярное произведение двух векторов — фундаментальная операция в физике, геометрии и машинном обучении. Результат — не вектор, а число. Формула: v1 · v2 = x1*x2 + y1*y2.
Это обычный метод (не dunder), потому что в Python нет стандартного оператора для скалярного произведения.
def dot(self, other):
"""Скалярное произведение: self · other = x1*x2 + y1*y2"""
if not isinstance(other, Vector):
raise TypeError(f"Ожидается Vector, получен {type(other).__name__}")
return self.x * other.x + self.y * other.y
Применение и физический смысл:
v1 = Vector(1, 0) # единичный вектор по оси X
v2 = Vector(0, 1) # единичный вектор по оси Y
v3 = Vector(3, 4)
print(v1.dot(v2)) # 0 — перпендикулярные векторы, dot = 0
print(v1.dot(v3)) # 3 — проекция v3 на ось X
print(v2.dot(v3)) # 4 — проекция v3 на ось Y
print(v3.dot(v3)) # 25 — dot вектора самого с собой = квадрат длины
🎯 Практическое применение dot product
Если v1.dot(v2) == 0 — векторы перпендикулярны. Это используется в геометрии, физике (работа = сила · перемещение) и ML (косинусное сходство текстов = dot(a, b) / (|a| * |b|)). Обратите внимание: здесь мы используем raise TypeError вместо return NotImplemented — потому что dot() это обычный метод, а не перегрузка оператора.
📦 Финальный код: всё вместе
Собираем все методы в один класс:
import math
class Vector:
"""Двумерный вектор с полным набором операций."""
def __init__(self, x, y):
self.x = x
self.y = y
# ── Отображение ─────────────────────────────────────────────
def __repr__(self):
return f"Vector({self.x}, {self.y})"
# ── Сравнение ───────────────────────────────────────────────
def __eq__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __lt__(self, other):
"""Сравнение по длине — позволяет сортировать векторы."""
if not isinstance(other, Vector):
return NotImplemented
return abs(self) < abs(other)
# ── Арифметика ──────────────────────────────────────────────
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
"""Vector * число"""
if not isinstance(scalar, (int, float)):
return NotImplemented
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar):
"""число * Vector — делегируем __mul__"""
return self.__mul__(scalar)
# ── Унарные операции ────────────────────────────────────────
def __abs__(self):
"""Длина вектора: √(x² + y²)"""
return math.sqrt(self.x ** 2 + self.y ** 2)
def __neg__(self):
"""Унарный минус: -v = Vector(-x, -y)"""
return Vector(-self.x, -self.y)
# ── Дополнительные методы ───────────────────────────────────
def dot(self, other):
"""Скалярное произведение: x1*x2 + y1*y2"""
if not isinstance(other, Vector):
raise TypeError(f"Ожидается Vector, получен {type(other).__name__}")
return self.x * other.x + self.y * other.y
🚀 Демонстрация: смотрим как это работает
import math
# Создаём несколько векторов
v1 = Vector(3, 4)
v2 = Vector(1, 2)
zero = Vector(0, 0)
# __repr__
print(v1) # Vector(3, 4)
# __eq__
print(v1 == Vector(3, 4)) # True
print(v1 == v2) # False
# __add__, __sub__
print(v1 + v2) # Vector(4, 6)
print(v1 - v2) # Vector(2, 2)
# __mul__, __rmul__
print(v1 * 2) # Vector(6, 8)
print(3 * v2) # Vector(3, 6) ← объект СПРАВА
# __abs__
print(abs(v1)) # 5.0 (√(9+16) = 5)
print(abs(v2)) # 2.23...
# __neg__
print(-v1) # Vector(-3, -4)
print(v1 + (-v1)) # Vector(0, 0)
# __lt__ + sorted()
group = [Vector(5, 0), Vector(1, 1), Vector(3, 4), Vector(0, 2)]
print(sorted(group))
# [Vector(1, 1), Vector(0, 2), Vector(3, 4), Vector(5, 0)]
# длины: 1.41, 2.0, 5.0, 5.0
# dot()
print(v1.dot(v2)) # 3*1 + 4*2 = 11
print(Vector(1, 0).dot(Vector(0, 1))) # 0 — перпендикулярны
✨ Что мы получили?
Наш класс ведёт себя как «настоящий» математический тип данных Python. Можно складывать, умножать, сортировать, использовать в стандартных функциях. Именно для этого и нужны dunder-методы — создавать объекты, которые органично встраиваются в язык.
💪 Самостоятельная работа
Попробуй реализовать одно или несколько из следующих заданий:
🟦 Уровень 1
Нормализация вектора
Добавь метод normalize(), который возвращает вектор той же длины 1.0 в том же направлении: v / abs(v). Подсказка: нужен __truediv__.
🟪 Уровень 2
3D-вектор
Создай класс Vector3D(x, y, z), унаследовав от Vector или написав заново. Длина: √(x²+y²+z²). Добавь метод cross(other) — векторное произведение.
🟩 Уровень 3
Хешируемый вектор
Добавь __hash__, чтобы векторы можно было использовать в set и как ключи в dict. Подсказка: return hash((self.x, self.y)). Почему это важно, если у нас есть __eq__?
⚠️ Важное замечание про __hash__
Когда ты определяешь __eq__, Python автоматически устанавливает __hash__ = None, делая объект нехешируемым. Это защита от логических ошибок: если два объекта равны (__eq__), у них должны быть одинаковые хеши. Python не может это гарантировать автоматически — поэтому ты должен определить __hash__ вручную, явно взяв на себя ответственность за это.
✅ Итоги урока
__repr__отвечает за строковое представление. Хороший__repr__выглядит как вызов конструктора.__eq__задаёт логику равенства. Без него Python сравнивает по адресу в памяти, а не по значению.__add__и__sub__— покоординатные операции. Всегда возвращают новый объект.__mul__и__rmul__работают в паре: первый дляv * n, второй дляn * v.__rmul__обычно делегирует__mul__.__abs__— длина вектора по теореме Пифагора. Вызывается черезabs(v).__lt__достаточно, чтобыsorted()и.sort()работали с твоими объектами.__neg__— унарный минус, инвертирует координаты.- Обычные методы вроде
dot()дополняют dunder-методы для операций без стандартного оператора. - Если определил
__eq__— не забудь определить__hash__, иначе объект нельзя поместить вset.
👉 В следующем уроке мы разберём контейнерные dunder-методы: __len__, __getitem__, __contains__ — как сделать свой объект похожим на список или словарь. Ты научишь свои объекты работать с оператором in, индексацией obj[i] и функцией len().