Как делать11 октября 2026 г.6 мин чтения
JavaШаг 2 из 6

ООП: классы, инкапсуляция, наследование и композиция

Коротко

Объектно-ориентированное программирование (ООП) объединяет данные и поведение в объекты: заказ сам знает свой состав и правила, по которым его можно менять. Класс описывает такие объекты, приватные поля # прячут состояние, наследование позволяет переиспользовать общую логику через extends и super, а композиция собирает поведение из маленьких независимых объектов. На практике наследование подходит для небольших стабильных иерархий, а композиция — почти для всего остального.

ОсновыУровень: СреднийОбъектно-ориентированное программирование

После материала вы сможете

  • описать класс с приватными полями #, геттерами и методами, которые защищают инварианты
  • объяснить разницу между private в TypeScript и # в JavaScript
  • построить полиморфизм через интерфейс и наследование с super
  • объяснить, когда предпочесть композицию наследованию, и собрать поведение из маленьких объектов

Проверено на

Node.js 24.18 LTSTypeScript 7.0Java 21 LTS

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Объект как данные и поведение
  2. 02Классы в TypeScript: constructor, методы, #private
  3. 03Инкапсуляция: инварианты и геттеры
  4. 04Наследование и super
  5. 05Полиморфизм на примере способов оплаты
  6. 06Композиция вместо наследования
  7. 07Как это выглядит в Java

Объект как данные и поведение

В уроке о структурах данных заказ был простым объектом { id, status, total }, а правила работы с ним жили в отдельных функциях. Это нормально, пока правил мало. Но как только появляются условия «нельзя менять оплаченный заказ» или «количество — только целое положительное число», их приходится проверять везде, где заказ меняется, — и рано или поздно кто-то забудет.

ООП предлагает держать данные и правила вместе. Класс — шаблон, по которому создаются объекты; объект, созданный через new, называют экземпляром. Снаружи с экземпляром работают только через методы, а методы следят, чтобы данные не пришли в недопустимое состояние.

  • Инкапсуляция — состояние скрыто, изменить его можно только через методы с проверками
  • Наследование — класс-потомок получает поля и методы родителя и может их дополнить
  • Полиморфизм — разные объекты выполняют один контракт, и код работает с любым из них
  • Композиция — объект содержит другие объекты и делегирует им часть работы

Примеры написаны на TypeScript и запускаются в Node.js 24 напрямую. Начиная с Node.js 24.12 удаление типов (type stripping) стабильно: Node.js заменяет аннотации пробелами и выполняет код, но не проверяет типы. Поэтому проверку делаем отдельно — компилятором tsc с флагом --noEmit. Документация Node.js рекомендует для этого режима настройки erasableSyntaxOnly и verbatimModuleSyntax: они запрещают конструкции TypeScript, которые Node.js не умеет просто удалить, например enum и параметры-свойства в конструкторе.

bash
npm init -y
npm pkg set type=module
npm install --save-dev typescript@7.0.2 @types/node@24
npx tsc --noEmit
node order-demo.ts
Подготовка папки проекта: TypeScript для проверки типов, запуск — самим Node.js.
tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "noImplicitOverride": true,
    "types": ["node"]
  }
}

Классы в TypeScript: constructor, методы, #private

Опишем заказ кафе. У него есть номер, который не меняется, состав и статус. Состав и статус приватные: имя с # — это приватный элемент JavaScript, к которому, как пишет MDN, нельзя законно обратиться вне тела класса, и эту приватность обеспечивает сам язык.

order.ts
export type OrderStatus = "new" | "paid" | "cancelled";
export type LineItem = { name: string; price: number; qty: number };

export class Order {
  readonly id: string;
  #items: LineItem[] = [];
  #status: OrderStatus = "new";

  constructor(id: string) {
    this.id = id;
  }

  addItem(name: string, price: number, qty = 1): void {
    this.#assertEditable();
    if (!Number.isInteger(qty) || qty <= 0) {
      throw new RangeError("Количество — целое число больше нуля");
    }
    if (price < 0) throw new RangeError("Цена не может быть отрицательной");
    this.#items.push({ name, price, qty });
  }

  pay(): void {
    this.#assertEditable();
    if (this.#items.length === 0) throw new Error("Нельзя оплатить пустой заказ");
    this.#status = "paid";
  }

  // Геттеры: снаружи можно прочитать, но нельзя присвоить
  get status(): OrderStatus {
    return this.#status;
  }

  get total(): number {
    return this.#items.reduce((sum, item) => sum + item.price * item.qty, 0);
  }

  // Отдаём копии, чтобы состав нельзя было изменить в обход addItem
  get items(): LineItem[] {
    return this.#items.map((item) => ({ ...item }));
  }

  #assertEditable(): void {
    if (this.#status !== "new") {
      throw new Error(`Заказ ${this.id} в статусе ${this.#status}, менять его нельзя`);
    }
  }
}

readonly id

Что делает в примере
Публичное поле, TypeScript не даст присвоить его повторно

#items, #status

Что делает в примере
Приватные поля с начальными значениями; снаружи недоступны

constructor

Что делает в примере
Выполняется при new Order(...) и заполняет поля

addItem, pay

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

get status, get total

Что делает в примере
Геттеры: читаются как свойства, но присвоить им нельзя

#assertEditable

Что делает в примере
Приватный метод с общей проверкой для всех изменений

Инкапсуляция: инварианты и геттеры

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

order-demo.ts
import { Order } from "./order.ts";

const order = new Order("A-1001");
order.addItem("Латте", 1800, 2);
order.addItem("Чизкейк", 2400);
console.log(order.total); // 6000
console.log(order.status); // new

// Инвариант: количество — целое положительное число
try {
  order.addItem("Капучино", 1700, 0);
} catch (error) {
  console.log((error as Error).message); // Количество — целое число больше нуля
}

// Копия состава не влияет на сам заказ
order.items[0].qty = 100;
console.log(order.total); // 6000

order.pay();
console.log(order.status); // paid

// После оплаты состав менять нельзя
try {
  order.addItem("Круассан", 1500);
} catch (error) {
  console.log((error as Error).message); // Заказ A-1001 в статусе paid, менять его нельзя
}

// Приватные поля недоступны снаружи даже во время выполнения
console.log(Object.keys(order)); // [ 'id' ]
console.log(JSON.stringify(order)); // {"id":"A-1001"}
Запуск: node order-demo.ts. Комментарии справа — реальный вывод.

Обратите внимание на геттер items: он возвращает копии позиций. Если бы он отдавал сам массив #items, любой код снаружи мог бы сделать order.items.push(...) и обойти все проверки. Попытка присвоить значение свойству только с геттером, например order.status = "paid", отклоняется уже на этапе tsc, а в модуле во время выполнения выбрасывает TypeError.

В TypeScript есть и своё ключевое слово private. Справочник TypeScript подчёркивает разницу: private и protected проверяются только при компиляции, а в JavaScript такое поле остаётся обычным свойством — его видно через квадратные скобки, Object.keys и JSON.stringify. Поля с # остаются приватными и после компиляции.

private-vs-hash.ts
// private из TypeScript — проверка только при компиляции
class SoftPrivate {
  private token = "YOUR_API_KEY";
}

// # из JavaScript — приватность обеспечивает сам язык во время выполнения
class HardPrivate {
  #token = "YOUR_API_KEY";

  hasToken(): boolean {
    return this.#token.length > 0;
  }
}

const soft = new SoftPrivate();
console.log(soft["token"]); // YOUR_API_KEY — TypeScript разрешает доступ через квадратные скобки
console.log(JSON.stringify(soft)); // {"token":"YOUR_API_KEY"}

const hard = new HardPrivate();
console.log(JSON.stringify(hard)); // {}
console.log(Object.keys(hard)); // []
console.log(hard.hasToken()); // true
Запуск: node private-vs-hash.ts.

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

Наследование позволяет описать общее поведение один раз в родительском классе, а потомкам — получить его через extends. Возьмём способы оплаты: у карты и рассрочки комиссия считается в процентах, отличаются только процент и название. Общую логику вынесем в абстрактный класс — его нельзя создать через new, он существует только как родитель.

payments.ts
// Контракт: всё, что умеет назвать себя и посчитать комиссию, — способ оплаты
export interface PaymentMethod {
  readonly title: string;
  fee(amount: number): number;
}

// Общая логика процентной комиссии — в базовом классе
abstract class PercentFeePayment implements PaymentMethod {
  abstract readonly title: string;
  readonly #percent: number;

  constructor(percent: number) {
    if (percent < 0) throw new RangeError("Комиссия не может быть отрицательной");
    this.#percent = percent;
  }

  fee(amount: number): number {
    return Math.round((amount * this.#percent) / 100);
  }
}

export class CardPayment extends PercentFeePayment {
  readonly title = "Банковская карта";

  constructor() {
    super(2); // вызвать конструктор родителя до обращения к this
  }
}

export class InstallmentPayment extends PercentFeePayment {
  readonly title: string;

  constructor(months: number) {
    super(months); // условно: 1% за каждый месяц
    this.title = `Рассрочка на ${months} мес.`;
  }

  // Переопределяем метод и дополняем результат родителя
  override fee(amount: number): number {
    return super.fee(amount) + 500; // плюс плата за оформление
  }
}

// Не наследует ничего: достаточно выполнить контракт интерфейса
export class CashOnDelivery implements PaymentMethod {
  readonly title = "Наличные курьеру";

  fee(): number {
    return 300;
  }
}
Комиссии в примере условные и нужны только для иллюстрации.
  1. extends

    CardPayment extends PercentFeePayment — потомок получает метод fee и проверку процента из родителя.

  2. super(...)

    В конструкторе потомка сначала вызывается конструктор родителя. TypeScript и JavaScript требуют сделать это до первого обращения к this.

  3. override

    InstallmentPayment переопределяет fee, а через super.fee(amount) берёт результат родителя и дополняет его. С настройкой noImplicitOverride слово override обязательно — так случайное переопределение не пройдёт незамеченным.

  4. abstract

    Абстрактное поле title обязывает каждого потомка задать название, иначе tsc выдаст ошибку.

Приватные поля родителя потомку недоступны: MDN отмечает, что #-элементы не участвуют в прототипном наследовании и видны только в теле того класса, где объявлены. #percent — дело PercentFeePayment, потомки работают с ним только через fee. Если поле должно быть видно потомкам, в TypeScript для этого есть protected, но это снова проверка только при компиляции.

Полиморфизм на примере способов оплаты

Полиморфизм — возможность работать с разными объектами через общий контракт. Функции оформления заказа неважно, карта это, рассрочка или наличные: ей достаточно, что у объекта есть title и fee. Контракт описан интерфейсом PaymentMethod.

checkout.ts
import { CardPayment, CashOnDelivery, InstallmentPayment } from "./payments.ts";
import type { PaymentMethod } from "./payments.ts";

// Функция знает только контракт PaymentMethod, а не конкретные классы
function checkout(total: number, method: PaymentMethod): string {
  const fee = method.fee(total);
  return `${method.title}: ${total} + ${fee} = ${total + fee} ₸`;
}

const card = new CardPayment();
const installment = new InstallmentPayment(6);
const cash = new CashOnDelivery();

console.log(checkout(20000, card)); // Банковская карта: 20000 + 400 = 20400 ₸
console.log(checkout(20000, installment)); // Рассрочка на 6 мес.: 20000 + 1700 = 21700 ₸
console.log(checkout(20000, cash)); // Наличные курьеру: 20000 + 300 = 20300 ₸

console.log(installment instanceof CardPayment); // false
console.log(Object.getPrototypeOf(InstallmentPayment) === Object.getPrototypeOf(CardPayment)); // true — общий родитель
Запуск: node checkout.ts.

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

Композиция вместо наследования

Наследование описывает отношение «является»: рассрочка является способом оплаты с процентной комиссией. Композиция описывает отношение «содержит»: расчёт цены содержит набор правил. Проблемы наследования начинаются, когда признаки комбинируются. Скидка, экспресс-доставка, промокод — если делать под каждое сочетание свой подкласс заказа, получится DiscountedOrder, ExpressOrder, DiscountedExpressOrder и так далее, а число классов будет расти с каждым новым правилом.

pricing.ts
// Правило цены — маленький объект с одной обязанностью
export interface PriceRule {
  readonly label: string;
  apply(amount: number): number;
}

export class PercentDiscount implements PriceRule {
  readonly label: string;
  readonly #percent: number;

  constructor(percent: number) {
    if (percent < 0 || percent > 100) throw new RangeError("Скидка — от 0 до 100%");
    this.#percent = percent;
    this.label = `Скидка ${percent}%`;
  }

  apply(amount: number): number {
    return Math.round(amount * (1 - this.#percent / 100));
  }
}

export class ExpressDelivery implements PriceRule {
  readonly label = "Экспресс-доставка";

  apply(amount: number): number {
    return amount + 1500;
  }
}

// Ценообразование СОДЕРЖИТ правила, а не наследует их
export class Pricing {
  readonly #rules: PriceRule[];

  constructor(rules: PriceRule[]) {
    this.#rules = [...rules];
  }

  total(subtotal: number): number {
    return this.#rules.reduce((amount, rule) => rule.apply(amount), subtotal);
  }

  describe(): string {
    return this.#rules.map((rule) => rule.label).join(" → ") || "без правил";
  }
}
pricing-demo.ts
import { ExpressDelivery, PercentDiscount, Pricing } from "./pricing.ts";
import type { PriceRule } from "./pricing.ts";

// Любая комбинация собирается без новых классов
const regular = new Pricing([]);
const promo = new Pricing([new PercentDiscount(10)]);
const promoExpress = new Pricing([new PercentDiscount(10), new ExpressDelivery()]);

console.log(regular.total(20000), regular.describe()); // 20000 без правил
console.log(promo.total(20000), promo.describe()); // 18000 Скидка 10%
console.log(promoExpress.total(20000), promoExpress.describe()); // 19500 Скидка 10% → Экспресс-доставка

// Подойдёт и обычный объект: TypeScript сверяет форму, а не родословную
const roundDown: PriceRule = { label: "Округление до 100", apply: (amount) => Math.floor(amount / 100) * 100 };
console.log(new Pricing([new PercentDiscount(7), roundDown]).total(20250)); // 18800
Запуск: node pricing-demo.ts. Новое сочетание правил — новая строка, а не новый класс.

Наследование подходит, когда

  • Отношение «является» верно всегда, а не только в части случаев
  • Иерархия маленькая и стабильная: один-два уровня
  • Потомкам нужна общая реализация и общие инварианты родителя
  • Так требует фреймворк: например, базовый класс исключения

Композиция подходит, когда

  • Признаки комбинируются: скидка, доставка, промокод в любых сочетаниях
  • Поведение нужно менять во время работы программы
  • Части нужно тестировать по отдельности
  • Наследование потребовало бы переопределять методы, чтобы что-то отключить

Принцип «предпочитайте композицию наследованию» — общепринятая инженерная рекомендация, а не правило языка. По нашему опыту, в прикладном коде глубокие иерархии почти всегда со временем превращаются в цепочку переопределений, которую страшно трогать. Одно-два наследования для общей реализации — нормально; дальше лучше собирать поведение из объектов. Подробнее о принципах проектирования — в материалах о SOLID и паттернах.

Как это выглядит в Java

Те же идеи переносятся в Java почти один к одному, и знакомство с ними на TypeScript упрощает переход на бэкенд. Ниже — заказ и два способа оплаты в одном файле. Отличия от TypeScript: модификатор private в Java проверяют и компилятор, и виртуальная машина; запись record создаёт неизменяемый класс данных; у каждого класса, как пишет документация dev.java, ровно один прямой родитель — множественного наследования классов нет, а несколько интерфейсов реализовать можно.

Shop.java
import java.util.ArrayList;
import java.util.List;

record LineItem(String name, int price, int qty) {}

enum OrderStatus { NEW, PAID }

class Order {
    private final String id;
    private final List<LineItem> items = new ArrayList<>();
    private OrderStatus status = OrderStatus.NEW;

    Order(String id) {
        this.id = id;
    }

    void addItem(String name, int price, int qty) {
        assertEditable();
        if (qty <= 0) throw new IllegalArgumentException("Количество — целое число больше нуля");
        if (price < 0) throw new IllegalArgumentException("Цена не может быть отрицательной");
        items.add(new LineItem(name, price, qty));
    }

    void pay() {
        assertEditable();
        if (items.isEmpty()) throw new IllegalStateException("Нельзя оплатить пустой заказ");
        status = OrderStatus.PAID;
    }

    OrderStatus getStatus() { return status; }

    int getTotal() {
        return items.stream().mapToInt(item -> item.price() * item.qty()).sum();
    }

    List<LineItem> getItems() { return List.copyOf(items); } // неизменяемая копия

    private void assertEditable() {
        if (status != OrderStatus.NEW) {
            throw new IllegalStateException("Заказ " + id + " в статусе " + status + ", менять его нельзя");
        }
    }
}

interface PaymentMethod {
    String title();
    int fee(int amount);
}

class CardPayment implements PaymentMethod {
    public String title() { return "Банковская карта"; }
    public int fee(int amount) { return Math.round(amount * 0.02f); }
}

class CashOnDelivery implements PaymentMethod {
    public String title() { return "Наличные курьеру"; }
    public int fee(int amount) { return 300; }
}

public class Shop {
    static String checkout(int total, PaymentMethod method) {
        int fee = method.fee(total);
        return method.title() + ": " + total + " + " + fee + " = " + (total + fee) + " ₸";
    }

    public static void main(String[] args) {
        Order order = new Order("A-1001");
        order.addItem("Латте", 1800, 2);
        order.addItem("Чизкейк", 2400, 1);
        System.out.println(order.getTotal()); // 6000

        order.pay();
        System.out.println(order.getStatus()); // PAID

        try {
            order.addItem("Круассан", 1500, 1);
        } catch (IllegalStateException error) {
            System.out.println(error.getMessage()); // Заказ A-1001 в статусе PAID, менять его нельзя
        }

        System.out.println(checkout(20000, new CardPayment())); // Банковская карта: 20000 + 400 = 20400 ₸
        System.out.println(checkout(20000, new CashOnDelivery())); // Наличные курьеру: 20000 + 300 = 20300 ₸
    }
}
Проверено в Docker-образе eclipse-temurin:21: javac -encoding UTF-8 -d out Shop.java && java -cp out Shop.

Приватное поле

TypeScript
#items (или private — только при компиляции)
Java
private — проверяется и при выполнении

Только чтение

TypeScript
readonly, геттер
Java
final, метод getTotal()

Контракт

TypeScript
interface, совместимость по форме
Java
interface + явное implements

Наследование

TypeScript
extends, один родительский класс
Java
extends, ровно один прямой родитель

Нарушение инварианта

TypeScript
throw new RangeError(...)
Java
throw new IllegalArgumentException(...)
Как проверено5 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    order.ts, order-demo.ts, private-vs-hash.ts, payments.ts, checkout.ts, pricing.ts, pricing-demo.ts, tsconfig.json (RU и EN)

    временный проект scratchpad/kb/verify/oop-osnovy (package.json type=module, typescript 7.0.2, @types/node 24.19.2): `npx tsc --noEmit` в ru/ и en/ — 0 ошибок; запуск `node <file>.ts` на Node 24.18.0 (type stripping) с --import check-preload.mjs — 22 из 22 комментариев с выводом совпали в каждой локали

  • запущено, работает

    решения упражнений (removeItem, BankTransfer, FixedDiscount), RU и EN

    тот же tsc --noEmit + запуск: 10 из 10 комментариев совпали; removeItem в статье — фрагмент метода из проверенного файла ex-remove.ts

  • запущено, работает

    команды подготовки проекта (bash-блок)

    выполнены по порядку в пустой папке с order.ts/order-demo.ts/tsconfig.json: установка прошла, tsc без ошибок, вывод order-demo совпал

  • запущено, работает

    негативные проверки (не в статье)

    tsc ловит: доступ к order.#status снаружи (TS18013), private через точку (TS2341), присваивание геттеру (TS2540); node: SyntaxError для #status снаружи, TypeError при присваивании свойству только с геттером

  • запущено, работает

    Shop.java (RU и EN)

    docker eclipse-temurin:21 (OpenJDK 21.0.12.1): javac -Xlint:all -encoding UTF-8 без предупреждений, java — 5 из 5 строк вывода совпали с комментариями (скрипт check-java.mjs)

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.