Skip to content

Latest commit

 

History

History
501 lines (359 loc) · 30.4 KB

File metadata and controls

501 lines (359 loc) · 30.4 KB

Урок 19. Классы в JS: свойства, методы, конструктор

От отдельных объектов — к шаблону для их создания

В прошлом уроке мы работали с объектами, которые создавались вручную, каждый со своим набором фигурных скобок:

const movie1 = { title: 'Матрица', year: 1999, rating: 8.7 };
const movie2 = { title: 'Начало', year: 2010, rating: 8.8 };

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

Вы уже прекрасно знаете классы — из ООП-курса на Python. Хорошая новость: сама концепция классов в JavaScript устроена почти так же — конструктор, свойства, методы, this/self. Этот урок будет во многом опираться на прямые параллели с тем, что вы уже знаете, обращая особое внимание на синтаксические и смысловые различия там, где они есть.


Базовый синтаксис: class, constructor, this

class Movie {
  constructor(title, year, rating) {
    this.title = title;
    this.year = year;
    this.rating = rating;
  }
}

const movie1 = new Movie('Матрица', 1999, 8.7);
const movie2 = new Movie('Начало', 2010, 8.8);

console.log(movie1.title); // Матрица
console.log(movie2.year);  // 2010

Сопоставим с тем же классом на Python, который выглядел бы так:

class Movie:
    def __init__(self, title, year, rating):
        self.title = title
        self.year = year
        self.rating = rating

movie1 = Movie('Матрица', 1999, 8.7)
movie2 = Movie('Начало', 2010, 8.8)

Прямые соответствия:

Python JavaScript Назначение
class Movie: class Movie {} Объявление класса
def __init__(self, ...): constructor(...) {} Специальный метод, вызываемый при создании объекта
self this Ссылка на текущий, конкретный экземпляр класса
Movie('Матрица', ...) new Movie('Матрица', ...) Создание экземпляра — но в JS обязательно ключевое слово new

Самое важное синтаксическое отличие: в JavaScript при создании экземпляра класса обязательно ключевое слово new перед вызовом конструктора. Забыть его — частая ошибка новичков, разберём в подводных камнях.

this внутри конструктора и методов

this внутри класса ссылается на тот конкретный экземпляр, для которого сейчас выполняется код — совершенно аналогично self в Python. Разница лишь в том, что self в Python явно указывается первым параметром метода, а this в JS доступен неявно, без объявления в списке параметров.


Методы класса

class Movie {
  constructor(title, year, rating) {
    this.title = title;
    this.year = year;
    this.rating = rating;
  }

  getSummary() {
    return `${this.title} (${this.year}) — ${this.rating}/10`;
  }

  isTopRated() {
    return this.rating >= 8.5;
  }
}

const movie = new Movie('Матрица', 1999, 8.7);

console.log(movie.getSummary());  // Матрица (1999) — 8.7/10
console.log(movie.isTopRated());  // true

Обратите внимание: у методов класса нет запятых между ними и нет ключевого слова function перед именем метода — это специальный, более компактный синтаксис, доступный именно внутри тела class. Внутри каждого метода this по-прежнему ссылается на конкретный экземпляр, на котором метод был вызван (movie, в данном случае).


Параметры конструктора по умолчанию

Точно так же, как для обычных функций (Урок 17), параметры конструктора могут иметь значения по умолчанию:

class Movie {
  constructor(title, year, rating = 0) {
    this.title = title;
    this.year = year;
    this.rating = rating;
  }
}

const movie = new Movie('Неизвестный фильм', 2024);
console.log(movie.rating); // 0 — использовано значение по умолчанию

Геттеры и сеттеры

Иногда нужно, чтобы обращение к свойству объекта выглядело как обычное чтение поля (movie.ageLabel, без скобок вызова функции), но на самом деле вычислялось на лету. Для этого существуют get и set:

class Movie {
  constructor(title, year, rating) {
    this.title = title;
    this.year = year;
    this.rating = rating;
  }

  get ageLabel() {
    const currentYear = new Date().getFullYear();
    const age = currentYear - this.year;
    return age === 0 ? 'Новинка' : `${age} лет назад`;
  }
}

const movie = new Movie('Матрица', 1999, 8.7);
console.log(movie.ageLabel); // например, "26 лет назад" — обратите внимание: без скобок ()!

Это прямой аналог @property в Python-классах, которые вы уже видели в ООП-курсе — та же идея "вычисляемого свойства, которое выглядит как обычное поле", просто другой синтаксис (get в JS вместо декоратора @property в Python).

set работает симметрично — позволяет перехватить присваивание значения свойству и выполнить какую-то логику вместо простого сохранения:

class Movie {
  constructor(title, rating) {
    this.title = title;
    this._rating = rating; // подчёркивание — соглашение, что поле "внутреннее", как и в Python
  }

  get rating() {
    return this._rating;
  }

  set rating(value) {
    if (value < 0 || value > 10) {
      throw new Error('Рейтинг должен быть от 0 до 10');
    }
    this._rating = value;
  }
}

const movie = new Movie('Матрица', 8.7);
movie.rating = 9.5; // вызывает set — валидация происходит автоматически
console.log(movie.rating); // 9.5 — обращение к get

movie.rating = 15; // Error: Рейтинг должен быть от 0 до 10

Статические методы и свойства

class Movie {
  static genreList = ['Фантастика', 'Драма', 'Комедия', 'Боевик'];

  constructor(title, genre) {
    this.title = title;
    this.genre = genre;
  }

  static isValidGenre(genre) {
    return Movie.genreList.includes(genre);
  }
}

console.log(Movie.genreList);              // ['Фантастика', 'Драма', 'Комедия', 'Боевик']
console.log(Movie.isValidGenre('Драма'));  // true
console.log(Movie.isValidGenre('Ужасы'));  // false

const movie = new Movie('Матрица', 'Фантастика');
console.log(movie.genreList); // undefined — статическое свойство недоступно через экземпляр!

static делает свойство или метод принадлежащим самому классу, а не отдельным экземплярам — прямой аналог @staticmethod/@classmethod и атрибутов класса (ClassVar из dataclasses, если вспомнить пример из ООП-курса) в Python. Обращение происходит через имя класса (Movie.genreList), а не через конкретный экземпляр (movie.genreList вернёт undefined, потому что статическое свойство не является частью каждого отдельного объекта).

Уместно использовать static, когда данные или логика относятся ко всем экземплярам класса сразу, а не к какому-то одному конкретному фильму — например, список допустимых жанров, счётчик созданных экземпляров, вспомогательная функция валидации.


Приватные поля (#)

Современный JavaScript поддерживает по-настоящему приватные поля класса — в отличие от соглашения об именовании через нижнее подчёркивание в Python (_protected), которое остаётся лишь договорённостью между разработчиками, а не техническим ограничением:

class Movie {
  #internalId; // приватное поле — недоступно снаружи класса вообще

  constructor(title, id) {
    this.title = title;
    this.#internalId = id;
  }

  getId() {
    return this.#internalId; // доступ изнутри класса — работает
  }
}

const movie = new Movie('Матрица', 42);
console.log(movie.getId());       // 42 — доступ через публичный метод работает
console.log(movie.#internalId);   // SyntaxError — прямой доступ снаружи класса запрещён на уровне синтаксиса

Важное отличие от Python: нижнее подчёркивание в Python (self._rating) — это лишь соглашение, "джентльменский договор" между разработчиками, что поле не предназначено для внешнего использования, но технически ничто не мешает обратиться к нему напрямую снаружи. Символ # в JavaScript — это по-настоящему приватное поле на уровне синтаксиса языка: обратиться к нему извне класса физически невозможно, попытка вызовет ошибку.


Наследование: extends и super

Вы уже подробно разбирали наследование в Python-курсе — здесь разберём его аналог в JS кратко, поскольку смысловая часть та же самая, меняется в основном синтаксис.

class Content {
  constructor(title, year) {
    this.title = title;
    this.year = year;
  }

  getSummary() {
    return `${this.title} (${this.year})`;
  }
}

class Movie extends Content {
  constructor(title, year, duration) {
    super(title, year); // вызов конструктора родительского класса Content
    this.duration = duration;
  }

  getSummary() {
    return `${super.getSummary()}, длительность: ${this.duration} мин.`;
  }
}

const movie = new Movie('Матрица', 1999, 136);
console.log(movie.getSummary()); // Матрица (1999), длительность: 136 мин.

extends объявляет, что Movie наследует от Content — прямой аналог class Movie(Content): в Python. super(...) внутри конструктора вызывает конструктор родительского класса — аналог super().__init__(...) в Python. super.getSummary() внутри переопределённого метода вызывает родительскую версию метода перед добавлением собственной логики — аналог super().get_summary().


Важная особенность: this и стрелочные функции внутри методов

Мы обещали вернуться к этой теме ещё в Уроке 17 — сейчас самое время. Рассмотрим ситуацию, в которой метод класса передаётся как callback-функция, например, в forEach:

class MovieCatalog {
  constructor(movies) {
    this.movies = movies;
    this.catalogName = 'Мой каталог';
  }

  // Обычный метод — здесь this работает предсказуемо, пока вызывается как movieCatalog.printAll()
  printAll() {
    this.movies.forEach(function (movie) {
      // ⚠️ Внутри обычной function this — уже НЕ экземпляр MovieCatalog!
      console.log(this.catalogName, movie.title); // TypeError или undefined
    });
  }
}

Проблема в том, что у обычной function, переданной как callback внутрь forEach, — собственное, отдельное значение this, никак не связанное с тем this, что было снаружи (this конкретного экземпляра MovieCatalog). Это одна из самых частых причин путаницы у новичков в JS.

Решение — стрелочная функция:

class MovieCatalog {
  constructor(movies) {
    this.movies = movies;
    this.catalogName = 'Мой каталог';
  }

  printAll() {
    this.movies.forEach((movie) => {
      // ✅ Стрелочная функция НЕ создаёт собственный this —
      // она "наследует" this из окружающего кода (из метода printAll)
      console.log(this.catalogName, movie.title);
    });
  }
}

const catalog = new MovieCatalog([{ title: 'Матрица' }, { title: 'Начало' }]);
catalog.printAll();
// Мой каталог Матрица
// Мой каталог Начало

Это и есть та самая "более глубокая техническая причина" использовать стрелочные функции, которую мы анонсировали в Уроке 17: стрелочные функции не создают собственного this — они прозрачно используют this того места в коде, где стрелочная функция была написана. Внутри методов класса, при передаче функции как callback (в forEach, map, filter и подобные), это делает стрелочные функции значительно безопаснее и предсказуемее обычных.

Практическое правило: внутри методов класса, если требуется передать функцию как callback (в forEach/map/filter/обработчик события, который мы разберём в уроке про DOM), всегда используйте стрелочную функцию, а не function, если внутри callback нужно обратиться к this самого экземпляра класса.


Практика: Movie и MovieCatalog — комбинируем классы и методы массивов

Соберём воедино классы этого урока и методы массивов из прошлого урока в единую, связную систему — прямое продолжение практики Урока 18.

class Movie {
  constructor(title, year, rating, genre) {
    this.title = title;
    this.year = year;
    this.rating = rating;
    this.genre = genre;
  }

  get summary() {
    return `${this.title} (${this.year}) — ${this.rating}/10`;
  }

  isTopRated() {
    return this.rating >= 8.5;
  }
}

class MovieCatalog {
  constructor() {
    this.movies = [];
  }

  addMovie(movie) {
    this.movies.push(movie);
  }

  filterByGenre(genre) {
    return this.movies.filter((movie) => movie.genre === genre);
  }

  getTopRated() {
    return this.movies.filter((movie) => movie.isTopRated());
  }

  printAll() {
    this.movies.forEach((movie) => {
      console.log(movie.summary);
    });
  }
}

const catalog = new MovieCatalog();

catalog.addMovie(new Movie('Матрица', 1999, 8.7, 'Фантастика'));
catalog.addMovie(new Movie('Начало', 2010, 8.8, 'Фантастика'));
catalog.addMovie(new Movie('Форсаж 9', 2021, 4.5, 'Боевик'));
catalog.addMovie(new Movie('Крестный отец', 1972, 9.2, 'Драма'));

catalog.printAll();
// Матрица (1999) — 8.7/10
// Начало (2010) — 8.8/10
// Форсаж 9 (2021) — 4.5/10
// Крестный отец (1972) — 9.2/10

console.log(catalog.filterByGenre('Фантастика'));
// [Movie, Movie] — оба фантастических фильма

console.log(catalog.getTopRated());
// [Movie, Movie] — Начало и Крестный отец, у обоих rating >= 8.5

Обратите внимание на связь с предыдущим уроком: методы filterByGenre и getTopRated внутри MovieCatalog устроены буквально на тех же filter, что мы разбирали как отдельные функции над массивом — только теперь они аккуратно спрятаны внутрь методов класса, управляющего своей собственной коллекцией объектов.


Подводные камни

Забытое ключевое слово new

const movie = Movie('Матрица', 1999, 8.7); // ❌ TypeError: Class constructor Movie cannot be invoked without 'new'

В отличие от обычных функций, классы в JavaScript требуют new при создании экземпляра — вызвать конструктор класса напрямую, как обычную функцию, невозможно; язык сам выбросит ошибку. Это отличается от Python, где Movie(...) без каких-либо специальных ключевых слов — стандартный и единственный способ создания экземпляра.

Забытое this. перед свойством внутри конструктора

class Movie {
  constructor(title, year) {
    title = title;  // ❌ Это просто присваивание параметру самому себе, свойство объекта не создаётся!
    this.year = year;
  }
}

const movie = new Movie('Матрица', 1999);
console.log(movie.title); // undefined — свойство title никогда не было создано на объекте
console.log(movie.year);  // 1999 — а это сработало верно, потому что we написали this.year

Без this. слева от имени свойства присваивание происходит просто локальной переменной/параметру внутри функции-конструктора, а не свойству создаваемого объекта. Экземпляр в итоге останется без нужного свойства — распространённая ошибка по невнимательности, особенно когда имя параметра совпадает с именем свойства (что обычно и есть, поэтому ошибка легко проходит мимо взгляда).

Обычная function вместо стрелочной внутри метода, при работе с this

Подробно разобрано в разделе 8 — если callback-функция внутри метода класса должна обращаться к this самого экземпляра, используйте стрелочную функцию, а не function.

Сравнение экземпляров через ===

const movie1 = new Movie('Матрица', 1999, 8.7);
const movie2 = new Movie('Матрица', 1999, 8.7);

console.log(movie1 === movie2); // false — разные объекты в памяти, несмотря на одинаковые данные

Как и с обычными объектами (Урок 18), === для экземпляров классов сравнивает ссылку, а не содержимое полей — даже полностью идентичные по данным экземпляры одного класса не будут равны через ===, если это не буквально одна и та же переменная.

Обращение к статическому свойству через экземпляр вместо класса

class Movie {
  static genreList = ['Фантастика', 'Драма'];
}

const movie = new Movie();
console.log(movie.genreList); // undefined — статическое свойство не "видно" через экземпляр
console.log(Movie.genreList); // ['Фантастика', 'Драма'] — только так

Разобрано в разделе 5 — статические члены принадлежат самому классу, а не отдельным экземплярам, обращение к ним всегда идёт через имя класса.


Итоги урока

Классы в JavaScript решают ту же задачу, что и в Python: описывают шаблон для создания однотипных объектов с общей структурой (свойства) и общим поведением (методы). Синтаксически ключевые соответствия: constructor — аналог __init__, this — аналог self, но с важным отличием — создание экземпляра в JS обязательно требует ключевого слова new.

Геттеры (get) и сеттеры (set) дают вычисляемые свойства, которые читаются без скобок вызова, — прямой аналог @property из Python. Статические члены (static) принадлежат самому классу, а не экземплярам — аналог @staticmethod/атрибутов класса. Приватные поля через # — это по-настоящему защищённые от внешнего доступа поля на уровне синтаксиса, в отличие от простого соглашения об именовании (_protected) в Python. Наследование (extends, super) работает по той же логике, что и в Python, с другим синтаксисом вызова родительского конструктора и методов.

Особое внимание стоит уделить поведению this внутри callback-функций, переданных методам массивов (forEach, map, filter) — обычная function создаёт собственный, несвязанный с классом this, тогда как стрелочная функция "наследует" this из окружающего кода. Это ключевая практическая причина, по которой в методах класса почти всегда стоит использовать именно стрелочные функции для callback-ов.

В следующем уроке — области видимости и замыкания: разберём подробнее, как именно JavaScript решает, к каким переменным у какого куска кода есть доступ, и почему var в циклах и функциях порой ведёт себя неожиданно.


Вопросы для проверки

  1. Какое ключевое синтаксическое отличие есть между созданием экземпляра класса в JavaScript и в Python?
  2. Что произойдёт, если внутри конструктора забыть написать this. перед именем свойства?
  3. Чем геттер (get) отличается от обычного метода класса с точки зрения синтаксиса вызова?
  4. Чем статический метод (static) отличается от обычного метода класса?
  5. В чём принципиальное отличие приватных полей через # в JavaScript от соглашения об именовании через нижнее подчёркивание (_protected) в Python?
  6. Почему внутри метода класса обычная function, переданная в forEach как callback, может привести к неожиданному поведению this? Как это решается?
  7. Почему movie1 === movie2 вернёт false, даже если оба объекта созданы одним и тем же классом с идентичными значениями всех полей?

Практические задания

Задание 1. Класс Review (рецензия на фильм)

Создайте класс Review со свойствами authorName, text, rating (число от 1 до 10), задаваемыми через конструктор.

  1. Добавьте геттер shortText, возвращающий первые 50 символов text с многоточием в конце, если текст длиннее 50 символов, иначе — текст целиком без изменений.
  2. Добавьте метод isPositive(), возвращающий true, если rating >= 7.
  3. Добавьте сеттер для rating, который выбрасывает ошибку (throw new Error(...)), если присваиваемое значение меньше 1 или больше 10 (используйте приватное поле #rating внутри, аналогично примеру в разделе 4 урока).
  4. Создайте 2-3 экземпляра и проверьте работу геттера, метода и сеттера.

Задание 2. Наследование: Content, Movie, Series

Создайте базовый класс Content со свойствами title, year и методом getSummary(), возвращающим "Название (год)".

  1. Создайте класс Movie extends Content с дополнительным свойством duration (в минутах). Переопределите getSummary(), чтобы добавить длительность через вызов super.getSummary().
  2. Создайте класс Series extends Content с дополнительным свойством seasonsCount. Переопределите getSummary(), чтобы добавить количество сезонов через вызов super.getSummary().
  3. Создайте по одному экземпляру каждого класса и выведите результат getSummary() для обоих.

Задание 3. ReviewCatalog — объединяющая практика (классы + методы массивов)

Создайте класс ReviewCatalog, управляющий коллекцией рецензий (используйте класс Review из Задания 1):

  1. Конструктор инициализирует пустой массив this.reviews = [].
  2. Метод addReview(review) добавляет рецензию в массив.
  3. Метод getPositiveReviews() возвращает массив только положительных рецензий (isPositive() === true), используя filter.
  4. Метод getAverageRating() возвращает средний рейтинг всех рецензий (используйте reduce — можно посмотреть его в документации по аналогии с map/filter, либо просто вручную сложить через forEach и разделить на количество).
  5. Метод printAll() выводит shortText и rating каждой рецензии через forEach со стрелочной функцией.

Предыдущий урок | Следующий урок