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

Практика: кастомный класс Vector

Пришло время применить всё, что мы знаем о dunder-методах — на реальной задаче. Мы построим полноценный класс Vector с нуля: от конструктора до сортировки и скалярного произведения. Каждый метод разберём пошагово с объяснением — почему именно так.

⏱ ~50 минут 🎓 Практика 🐍 Python 3

📐 Что такое вектор и зачем он нам нужен?

Вектор — это направленный отрезок в пространстве. В двумерном мире вектор задаётся двумя числами: координатой по горизонтали 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().