Skip to main content

Патерн декоратор

Патерн декоратор (Decorator) обгортає об'єкт, щоб додати нову поведінку під час виконання програми, не змінюючи оригінальний об'єкт і не створюючи підкласів.

Теорія

Коротко

  • Аналогія: піца з топінгами. Кожен топінг обгортає попередній шар; піца залишається тією ж. Топінги можна комбінувати в будь-якому порядку.
  • Головна різниця: наслідування додає поведінку до класу на етапі компіляції; декоратор додає поведінку до конкретного екземпляра під час виконання.
  • Правило вибору: використовуй декоратор, коли потрібно динамічно змішувати функції, не можна змінювати оригінальний клас, або хочеш уникнути вибуху підкласів.

Швидкий приклад

typescript
// Оригінальний об'єкт - не змінюється class Coffee { cost() { return 5; } description() { return "Coffee"; } } // Декоратор обгортає і розширює class MilkDecorator { constructor(private coffee: Coffee) {} cost() { return this.coffee.cost() + 2; } description() { return this.coffee.description() + ", Milk"; } } const coffee = new Coffee(); const withMilk = new MilkDecorator(coffee); console.log(withMilk.cost()); // 7 console.log(withMilk.description()); // "Coffee, Milk"

MilkDecorator зберігає посилання на оригінальний Coffee, викликає його методи і додає свою логіку поверх. Оригінал не зачеплений.

Головна різниця з наслідуванням

Наслідування створює фіксовану ієрархію на етапі компіляції. CoffeeWithMilk extends Coffee додає поведінку молока до кожного екземпляра цього підкласу назавжди. Декоратор обгортає конкретні екземпляри під час виконання. Можна застосувати MilkDecorator до одного об'єкта кави і SugarDecorator до іншого, не створюючи нових класів.

Це також вирішує проблему вибуху підкласів. Без декораторів потрібні окремі класи CoffeeWithMilkAndSugar, CoffeeWithMilkAndCaramel, CoffeeWithSugarAndCaramel. З трьома класами-декораторами можна покрити будь-яку комбінацію.

Коли застосовувати

  • Сторонній код: не можеш змінити оригінальний клас - обгорни його.
  • Динамічні комбінації: MilkDecorator(SugarDecorator(coffee)) або просто SugarDecorator(coffee) без зміни класів.
  • Наскрізні задачі: додай логування, кешування або валідацію, не торкаючись основної логіки.
  • Фічер-флаги: обгорни об'єкт умовно залежно від конфігурації або середовища.

Як це працює всередині

Декоратор зберігає посилання на обгорнутий об'єкт і реалізує той самий інтерфейс. Коли метод викликається на декораторі, він викликає метод обгорнутого об'єкта, додає власну логіку до або після і повертає результат. Так утворюється ланцюг. Кожен шар перехоплює виклики і передає їх далі.

В JavaScript і TypeScript це відбувається через об'єктну композицію під час виконання. Жодних змін в ієрархії класів. Просто передаєш об'єкти всередину інших об'єктів.

Типові помилки

1. Не реалізований повний інтерфейс

Декоратор повинен мати всі методи обгорнутого об'єкта. Пропустиш один - і код впаде в рантаймі.

typescript
// Неправильно - відсутні методи зламають виклики class LoggingDecorator { constructor(private obj: UserRepository) {} save() { console.log("saving"); return this.obj.save(); } // Відсутні: delete(), update(), find() } // Правильно - повний інтерфейс збережено class LoggingDecorator implements UserRepository { constructor(private obj: UserRepository) {} save() { console.log("saving"); return this.obj.save(); } delete() { console.log("deleting"); return this.obj.delete(); } update() { console.log("updating"); return this.obj.update(); } find() { console.log("finding"); return this.obj.find(); } }

2. Мутація обгорнутого об'єкта

Додавати властивості напряму до this.obj не можна. Оригінал забруднюється і відкотити декорацію вже не вийде.

typescript
// Неправильно - забруднює оригінал class CacheDecorator { constructor(private obj: DataService) { (this.obj as any).cache = new Map(); // так не роби } } // Правильно - декоратор зберігає власний стан class CacheDecorator implements DataService { private cache = new Map(); constructor(private obj: DataService) {} getData(key: string) { if (this.cache.has(key)) return this.cache.get(key); const data = this.obj.getData(key); this.cache.set(key, data); return data; } }

3. Порядок декораторів ігнорується

Порядок важливий. Якщо UppercaseDecorator обгортає MetadataDecorator, то toUpperCase() отримає об'єкт замість рядка і впаде. В Express withCache(withAuth(handler)) означає, що перевірка авторизації відбувається всередині перевірки кешу. Переверни порядок - і запити без авторизації потраплять у кеш.

typescript
// Працює: спочатку uppercase, потім metadata обгортає результат const v1 = new MetadataDecorator(new UppercaseDecorator(processor)); // { value: "HELLO", timestamp: 1713110126000 } // Падає: UppercaseDecorator отримує об'єкт замість рядка const v2 = new UppercaseDecorator(new MetadataDecorator(processor)); // TypeError: result.toUpperCase is not a function

4. Декоратор замість наслідування

Якщо поведінка потрібна всім екземплярам класу, наслідування простіше. Декоратори - для опціональної поведінки конкретних екземплярів.

typescript
// Правильно: всі собаки гавкають - використовуй наслідування class Animal { move() {} } class Dog extends Animal { bark() {} } // Правильно: тільки деякі собаки навчені - використовуй декоратор const trainedDog = new TrainedDecorator(new Dog());

5. Глибокі ланцюги на часто викликаних шляхах

Кожен шар декоратора додає виклик функції. П'ять декораторів - нормально. П'ятдесят на кожному запиті - варто профілювати. Не вважай декорацію безкоштовною операцією.

Де зустрічається в реальних проектах

  • React: Higher-order components (HOC) - withRouter, connect з Redux обгортають компоненти, щоб додати props.
  • Express/Node.js: Middleware. withAuth(withLogging(handler)) - це ланцюг декораторів на обробники запитів.
  • Python: Синтаксис @decorator. @lru_cache, @property, @staticmethod - все це декоратори.
  • Java Streams: stream().filter().map().collect() - ланцюг декораторів на колекціях.
  • TypeScript/JavaScript: Функціональна композиція через Lodash _.compose() або Ramda.

Особисто мені патерн став зрозумілий після роботи з Express. Кожен app.use() - це декоратор, тільки замаскований під middleware.

Питання на співбесіді

Q: Як декоратор відрізняється від патерну Strategy?


A: Strategy замінює алгоритм всередині об'єкта (один алгоритм за раз). Декоратор додає поведінку поверх того, що об'єкт вже робить. Strategy змінює що робить об'єкт; декоратор додає додаткову логіку навколо цього.

Q: Що відбувається, коли застосовуєш кілька декораторів до одного об'єкта?


A: Кожен декоратор обгортає попередній, утворюючи ланцюг. Виклики проходять через кожен шар у зворотному порядку (останній застосований виконується першим). Саме тому порядок важливий, особливо коли декоратори мають побічні ефекти.

Q: Чи можна використовувати декоратори зі збереженням стану?


A: Так. Декоратор обгортає об'єкт, а не його стан. Зміни стану обгорнутого об'єкта видно декоратору. Власний стан декоратора (наприклад, кеш) зберігається окремо.

Q: Як зберегти типобезпеку в TypeScript при ланцюжку декораторів?


A: Оголоси інтерфейс і реалізуй його в кожному декораторі. Або використовуй дженерики: class Logger<T extends UserRepository> - TypeScript знатиме, які методи доступні. Без цього після першого обгортання втрачається автодоповнення.

Q: (Senior) Як зробити декоратор, який підтримує і синхронні, і асинхронні методи?


A: Перевір, чи є повернене значення Promise. Якщо так - перехоплюй через .then(). Якщо ні - модифікуй синхронно.

typescript
class TransformDecorator implements DataProcessor { constructor(private obj: DataProcessor) {} process(...args: any[]) { const result = this.obj.process(...args); if (result instanceof Promise) { return result.then(value => this.transform(value)); } return this.transform(result); } private transform(value: string) { return value.toUpperCase(); } }

Приклади

Базовий: кава з добавками

Класичний приклад. Кожен декоратор реалізує той самий інтерфейс, тому їх можна комбінувати в будь-якому порядку.

typescript
interface Coffee { cost(): number; description(): string; } class SimpleCoffee implements Coffee { cost() { return 5; } description() { return "Coffee"; } } class MilkDecorator implements Coffee { constructor(private coffee: Coffee) {} cost() { return this.coffee.cost() + 2; } description() { return this.coffee.description() + ", Milk"; } } class SugarDecorator implements Coffee { constructor(private coffee: Coffee) {} cost() { return this.coffee.cost() + 1; } description() { return this.coffee.description() + ", Sugar"; } } const plain = new SimpleCoffee(); const latte = new MilkDecorator(plain); const sweetLatte = new SugarDecorator(latte); console.log(sweetLatte.cost()); // 8 console.log(sweetLatte.description()); // "Coffee, Milk, Sugar"

Три класи покривають будь-яку комбінацію. Жодного підкласу CoffeeWithMilkAndSugar не потрібно.

Середній рівень: Express middleware як декоратори

Express middleware - це реальний ланцюг декораторів. Кожна функція обгортає наступний обробник і вирішує, чи передавати виклик далі.

typescript
type Handler = (req: Request, res: Response) => void; function getUser(req: Request, res: Response) { const user = db.findUser(req.params.id); res.json(user); } function withAuth(handler: Handler): Handler { return (req, res) => { if (!req.headers.authorization) { return res.status(401).json({ error: "Unauthorized" }); } return handler(req, res); }; } function withLogging(handler: Handler): Handler { return (req, res) => { console.log(`${req.method} ${req.path}`); return handler(req, res); }; } // Останній застосований виконується першим const secureHandler = withLogging(withAuth(getUser)); app.get("/users/:id", secureHandler); // Потік запиту: логування -> перевірка авторизації -> оригінальний обробник

withLogging обгортає withAuth, який обгортає getUser. Кожен шар відповідає за одну задачу і не торкається інших.

Просунутий рівень: декоратор з підтримкою async

Декоратор, який однаково працює і з синхронними, і з асинхронними методами.

typescript
interface DataProcessor { process(data: string): string | Promise<string>; } class RawProcessor implements DataProcessor { process(data: string) { return data; } } class UppercaseDecorator implements DataProcessor { constructor(private processor: DataProcessor) {} process(data: string) { const result = this.processor.process(data); // Обробляємо обидва варіанти прозоро if (result instanceof Promise) { return result.then(value => value.toUpperCase()); } return result.toUpperCase(); } } // Працює з синхронним процесором const syncDecorated = new UppercaseDecorator(new RawProcessor()); console.log(syncDecorated.process("hello")); // "HELLO" // Працює і з асинхронним class AsyncRawProcessor implements DataProcessor { process(data: string) { return Promise.resolve(data); } } const asyncDecorated = new UppercaseDecorator(new AsyncRawProcessor()); asyncDecorated.process("hello").then(console.log); // "HELLO"

Декоратор не знає і не дбає, чи синхронний чи асинхронний внутрішній процесор. Він перевіряє тип результату в рантаймі і обробляє обидва випадки.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?